Compare commits

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

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

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

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

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

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

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

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-26 17:46:52 +08:00
黄兆尉 912c44c9bb Merge remote-tracking branch 'origin/main' 2026-08-26 13:56:20 +08:00
wei.wu 4962d768d4 Merge branch 'main' of http://it.fairylandtech.com:8666/Third_Dev_Dept/Migu2.0 2026-08-25 11:21:21 +08:00
wei.wu 95205cbcde 新增WCS任务模板引擎原型
新增“WCS模板原型”入口及自检脚本,支持无代码拖拽流程设计、选位试算、实例运行、预占管理等。实现了DSL类型定义、控件库、画布节点、节点属性面板、试算台等组件。补充了流程编排、参数组装、表达式求值、选位与打分、预占管理、模板校验等核心逻辑。提供假数据、演示脚本及README说明,便于后续对接真实系统。
2026-08-25 11:21:11 +08:00
wei.wu 4ee6379c26 在 .gitignore 中添加 /.cursor/plans 路径
新增 /.cursor/plans 到 .gitignore,避免该目录被 Git 追踪。
2026-08-25 11:17:36 +08:00
ykkokluo c5c1578696 修改交管和信号交互插件加载时机 2026-08-25 08:59:00 +08:00
ykkokluo 134c7480b3 新增磁导航内部交管和信号交互界面 2026-08-24 16:16:58 +08:00
黄兆尉andCursor 5d277cf17d merge: 合并 origin/main(MiGu.DB 持久层拆分与车辆/OTA 更新)
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-20 16:34:44 +08:00
wei.wu 125372b157 移除历史状态归一化及相关遗留逻辑
- 移除 LegacyStatusNormalizationMigrator,仅保留 AreaLayoutModeDefaultMigrator
- 删除 WmsDispatchStatus 枚举、常量和全局 using
- 统一所有 DbContext 注入为 MiGuDbContext,移除 PlatformDbContext
- 服务实体操作统一用 IEditableRepository<T>,移除本地实现
- 移除 WmsService 的 MigrateLegacyAsync 方法
- 精简接口模型,移除部分字段和请求体
- 前端保存容器时 status 字段取自已有数据,移除写死默认值
- 更新文档,去除旧说明,统一数据库初始化流程
2026-08-03 09:41:03 +08:00
wei.wu 1f72488a8d 新增车辆表及OTA权限与逻辑优化
新增车辆任务与报警表,完善实体与DbContext配置。细化OTA权限校验,增强回传会话IP安全。优化OTA上传与设置面板,调度器支持重启恢复。报警采集逻辑支持历史分段。
2026-07-27 15:12:14 +08:00
wei.wu bc3e9c5172 merge 2026-07-27 14:29:46 +08:00
wei.wu 51c3fc1994 持久层重构为独立 MiGu.DB 工程并优化集成
- 新增 MiGu.DB 项目,迁移所有领域实体与枚举,统一模型约定
- 实现 Entity/Repository/UoW/Provider/Exception 等接口与实现
- 支持数据修补机制,完善 Sqlite 初始迁移与数据库管理
- Server 侧移除 EF Core 相关,依赖 MiGu.DB,PlatformPersistence 适配
- 业务服务注入 UoW/Repository,状态字段统一用 enum 及辅助类
- 统一异常处理,Controller 映射 HTTP 状态码
- 配置项与文档补充数据库启动、SchemaMode、迁移说明
- 新增 GlobalUsings.Db.cs、WmsStatusAliases.cs 简化类型引用
- 新增 HttpActorContextMiddleware 支持操作者上下文一致性
- 新增 MiGuDbContextModelSnapshot 追踪数据库结构
- 优化代码结构,解耦领域与持久层,提升扩展性与安全性
2026-07-27 14:26:04 +08:00
zhaowei.huang 7af29fe07f fix: improve map editor and monitor operations 2026-07-26 14:14:19 +08:00
239 changed files with 21348 additions and 5019 deletions
+6 -2
View File
@@ -13,7 +13,7 @@ MiGu.Server/data/*.db
MiGu.Server/data/*.db-shm
MiGu.Server/data/*.db-wal
MiGu.Server/data/.internal-token
# OTA 运行时产物:拉取/上传包、任务、历史、settings(子目录*.json 上面那行不递归)
# OTA 运行时产物:拉取/上传包、任务、历史、settings(子目录 *.json 上面那行不递归)
MiGu.Server/data/ota/
# 前端构建产物
@@ -22,6 +22,8 @@ frontends/apps/simple-platform-vue/auto-imports.d.ts
# 临时构建输出
.tmp-build*/
_build_out.txt
.tools/
# SimpleLite 运行目录
/SimpleLite/
@@ -32,10 +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
View File
@@ -1,10 +1,10 @@
# SimpleLite 三端系统架构设计
# Simple3 三端系统架构设计
> **Migu2.0 仓库说明**:平台后端已拆至本仓库,工程名为 **`MiGu.Server`**(原 `Platform.Server`)。SimpleLite 与前端源码仍在兄弟仓库 `Simple`。下文中的 `MiGu.Server` 即指本仓库 `MiGu.Server/` 目录。
> **Migu2.0 仓库说明**:平台后端已拆至本仓库,工程名为 **`MiGu.Server`**(原 `Platform.Server`)。Simple3 与前端源码仍在兄弟仓库 `Simple`。下文中的 `MiGu.Server` 即指本仓库 `MiGu.Server/` 目录。
> 文档版本:v1.7.0
> 更新日期:2026-05-20
> 范围:SimpleLite(主程序 / 设计端 / 调度引擎宿主 / 进程编排器)+ Platform(业务平台 · 同时承载管理员前端 platform-vue 与运营前端 rcsmonitor-vue · 共用一套 WebAPI · **主入口为 MiGu.Server,登录后拉起 SimpleLite**
> 范围:Simple3(主程序 / 设计端 / 调度引擎宿主 / 进程编排器)+ Platform(业务平台 · 同时承载管理员前端 platform-vue 与运营前端 rcsmonitor-vue · 共用一套 WebAPI · **主入口为 MiGu.Server,登录后拉起 Simple3**
> v1.7.0 变更(多主题色板 · 工业紫默认):
> - **6 套可切换主题**`themes.ts` 定义色板(工业紫、深邃蓝、翡翠绿、熔铁赤、石墨钢、琥珀金);默认 **工业紫色** `#6a1b9a`(比 v1.6.2 的 `#641393` 更深更饱和,解决登录后内容区配色过淡)。
@@ -37,13 +37,13 @@
> - 新增 §7.4–§7.7 四类 DB 部署形态详述、选型矩阵、连接串与配置示例、迁移注意事项。
> v1.4 变更(保留):
> - **明确进程编排归属**Platform 后端进程 `MiGu.Server.exe` 不再被视为"独立的业务平台服务",而是 SimpleLite 内置 `SystemMission``StartPlatformMission`)拉起、被 SimpleLite Bootstrapper 守护的**受控子进程**RCSMonitor 不存在独立后端进程,复用 MiGu.Server。
> - **明确进程编排归属**Platform 后端进程 `MiGu.Server.exe` 不再被视为"独立的业务平台服务",而是 Simple3 内置 `SystemMission``StartPlatformMission`)拉起、被 Simple3 Bootstrapper 守护的**受控子进程**RCSMonitor 不存在独立后端进程,复用 MiGu.Server。
> - **整体定位章节重写**:明确"两类后端可执行文件 + 一个进程编排者"的关系,避免读者误以为 Platform 是用 Windows Service / systemd 独立部署的服务。
> v1.3 变更(保留):
> 1. **取消 RCSMonitor.Server**`rcsmonitor-vue` 不再有独立后端进程,直接连 MiGu.Server;由 JWT 中的 `scope=RCSMonitor` + 角色权限码 + `WidgetGrant` 三层限制可见控件与可调用 API。
> 2. **3D 渲染基座定为 webVRender**:复用 SimpleLite WebTerminal 已经提供的 webVRender,以 Web Component / iframe 嵌入到 platform-vue 与 rcsmonitor-vue,两端共享同一份 Workspace 协议(PutModel / PutPointCloud / SetCamera 等 DTO)。
> 3. **反向代理选定 YARP**MiGu.Server 内置 [YARP](https://microsoft.github.io/reverse-proxy/) 做 `/api/sl/*` → SimpleLite WebAPI 的反代;带鉴权与审计中间件。
> 2. **3D 渲染基座定为 webVRender**:复用 Simple3 WebTerminal 已经提供的 webVRender,以 Web Component / iframe 嵌入到 platform-vue 与 rcsmonitor-vue,两端共享同一份 Workspace 协议(PutModel / PutPointCloud / SetCamera 等 DTO)。
> 3. **反向代理选定 YARP**MiGu.Server 内置 [YARP](https://microsoft.github.io/reverse-proxy/) 做 `/api/sl/*` → Simple3 WebAPI 的反代;带鉴权与审计中间件。
> 4. **运行模式持久化到 `simple.json`**:登录窗"记住模式"勾选后写入 `simple.json.runMode`;下次启动直接进入。
> 5. **Web-Enabled 下严禁同时本地操作**CycleGUI 主窗只剩"服务状态窗",所有业务面板硬性隐藏;不提供"临时启用本地 UI"开关。
> 6. **共享前端组件库**:使用 pnpm workspace + `frontends/packages/sl-controls`,封装 Workspace3D / 地图 / 任务 / CAD 等可复用 Vue 组件。
@@ -79,43 +79,43 @@
一套为 AGV/AMR 现场打造的「**设计 → 调度 → 运营监控**」三端协同系统。
**三个"端"对应三套用户体验,但只有两类后端可执行文件,且都由 SimpleLite 统一编排:**
**三个"端"对应三套用户体验,但只有两类后端可执行文件,且都由 Simple3 统一编排:**
| 端 | 前端载体 | 后端可执行文件 | **启动者** | 角色 | 写权限 |
|----|----------|----------------|------------|------|--------|
| **SimpleLite 桌面端** | CycleGUI 客户端 | `SimpleLite.exe` 自身 | 用户双击 / 服务管理器 | 主程序 + 调度引擎宿主 + 全功能 WebAPI 提供者 + **进程编排器(Bootstrapper + SystemMission** | **全功能** |
| **Platform 管理端** | `platform-vue` (Vue 3 SPA) | `MiGu.Server.exe` | **SimpleLite 内置 `SystemMission.StartPlatformMission` 拉起**Web-Enabled 时自动 spawn;崩溃由 SimpleLite Watchdog 重启) | **SimpleLite 全能力套壳**:管理员侧 Vue 前端复刻所有设计/调度控件 + 业务平台特有能力(配置中心 / 外部对接 / 库位 / 账号) | **全功能**(等价 SimpleLite |
| **RCSMonitor 运营端** | `rcsmonitor-vue` (Vue 3 SPA) | **共用 `MiGu.Server.exe`**(无独立后端进程) | 随 MiGu.Server 一起被 SimpleLite 拉起 | 运营监控前端:按 `scope=RCSMonitor` + 角色权限 + WidgetGrant 三层裁剪 UI;可对任务/车辆执行白名单运维动作 | **受限**(仅运维白名单) |
| **Simple3 桌面端** | CycleGUI 客户端 | `Simple3.exe` 自身 | 用户双击 / 服务管理器 | 主程序 + 调度引擎宿主 + 全功能 WebAPI 提供者 + **进程编排器(Bootstrapper + SystemMission** | **全功能** |
| **Platform 管理端** | `platform-vue` (Vue 3 SPA) | `MiGu.Server.exe` | **Simple3 内置 `SystemMission.StartPlatformMission` 拉起**Web-Enabled 时自动 spawn;崩溃由 Simple3 Watchdog 重启) | **Simple3 全能力套壳**:管理员侧 Vue 前端复刻所有设计/调度控件 + 业务平台特有能力(配置中心 / 外部对接 / 库位 / 账号) | **全功能**(等价 Simple3 |
| **RCSMonitor 运营端** | `rcsmonitor-vue` (Vue 3 SPA) | **共用 `MiGu.Server.exe`**(无独立后端进程) | 随 MiGu.Server 一起被 Simple3 拉起 | 运营监控前端:按 `scope=RCSMonitor` + 角色权限 + WidgetGrant 三层裁剪 UI;可对任务/车辆执行白名单运维动作 | **受限**(仅运维白名单) |
**关键澄清(v1.4 重点):**
- **后端可执行文件只有 2 个**`SimpleLite.exe``MiGu.Server.exe`
- **进程启动只有 1 个真正的入口**:用户/服务管理器只启动 `SimpleLite.exe`MiGu.Server 由 SimpleLite 自己拉起,**不需要也不允许独立安装为 Windows Service**。
- **进程编排归属 SimpleLite**`SystemMission` 是 SimpleLite 自带的 Mission 体系扩展(与业务 Mission 区分开),专门负责拉起/守护/优雅停止 MiGu.Server。Bootstrapper 监听心跳、按指数退避重启、统一记录 OpsAuditLog。
- **后端可执行文件只有 2 个**`Simple3.exe``MiGu.Server.exe`
- **进程启动只有 1 个真正的入口**:用户/服务管理器只启动 `Simple3.exe`MiGu.Server 由 Simple3 自己拉起,**不需要也不允许独立安装为 Windows Service**。
- **进程编排归属 Simple3**`SystemMission` 是 Simple3 自带的 Mission 体系扩展(与业务 Mission 区分开),专门负责拉起/守护/优雅停止 MiGu.Server。Bootstrapper 监听心跳、按指数退避重启、统一记录 OpsAuditLog。
- **Vue 两份产物,同一进程托管**MiGu.Server 在 `/admin/*` 路径下托管 `platform-vue`,在 `/monitor/*` 路径下托管 `rcsmonitor-vue`
- **鉴权与限权完全在 MiGu.Server 完成**RCSMonitor 不持有任何独立后端代码(没有 `RCSMonitor.Server.exe`、没有独立库)。
- **Desktop-Only 模式下,SystemMission 不触发**MiGu.Server 根本不会启动,Vue 端无法访问。
> 一句话:**用户点 SimpleLite.exe,由 SimpleLite 通过 Mission 把 Platform 后端拉起来;Platform 后端再把两套 Vue 端服好。**
> 一句话:**用户点 Simple3.exe,由 Simple3 通过 Mission 把 Platform 后端拉起来;Platform 后端再把两套 Vue 端服好。**
### 1.2 核心拆分原则
1. **Crash-Isolation(搞不崩主服务)**
Platform / RCSMonitor 对**地图/任务/车辆等写操作**都通过 SimpleLite`/api/*` 网关;主服务侧做白名单 + 幂等校验 + 频次限流 + 审计。Platform-Vue 走完整 API 集,RCSMonitor-Vue 仅限运维白名单 `/api/ops/*`
Platform / RCSMonitor 对**地图/任务/车辆等写操作**都通过 Simple3`/api/*` 网关;主服务侧做白名单 + 幂等校验 + 频次限流 + 审计。Platform-Vue 走完整 API 集,RCSMonitor-Vue 仅限运维白名单 `/api/ops/*`
2. **共享数据模型,独立运行时**
地图、车辆、任务三端共用 `SimpleCore` 的 DTO;调度引擎只在 SimpleLite 里跑,Platform 只持有"投影"。
地图、车辆、任务三端共用 `SimpleCore` 的 DTO;调度引擎只在 Simple3 里跑,Platform 只持有"投影"。
3. **库分离 + Provider 抽象**(详见 §7
- `simple_main` 由 SimpleLite 持有;`platform` 由 Platform 持有(含 RCSMonitor 审计/标注);不再有独立的 `rcsmonitor_local`
- `simple_main` 由 Simple3 持有;`platform` 由 Platform 持有(含 RCSMonitor 审计/标注);不再有独立的 `rcsmonitor_local`
- 持久层基于 **EF Core****通过 Provider 切换底层数据库**:默认 **SQLite**(边缘单机,配 ROSE HA);可选 **MySQL / PostgreSQL / SQL Server**,匹配现场已有 IT 基础设施与企业级 HA 能力(详见 §7.4–§7.7)。
- 高可用方案随 Provider 不同:SQLite 用 ROSE HAMySQL 用 InnoDB ClusterPostgreSQL 用 PatroniSQL Server 用 Always On AG。
4. **统一协议层**
所有进程间调用统一 **WebAPIREST/JSON+ WebSocket 事件流**Vue 端直接对接 Platform 后端的 REST + WSPlatform 后端用 **YARP** 反代 SimpleLite 的设计/调度 API。
所有进程间调用统一 **WebAPIREST/JSON+ WebSocket 事件流**Vue 端直接对接 Platform 后端的 REST + WSPlatform 后端用 **YARP** 反代 Simple3 的设计/调度 API。
5. **统一身份**
`SimpleShared.Auth` 提供 JWT + RBACPlatform 是主权限源(在线时),SimpleLite 在 Desktop-Only 下用本地用户表降级。
`SimpleShared.Auth` 提供 JWT + RBACPlatform 是主权限源(在线时),Simple3 在 Desktop-Only 下用本地用户表降级。
6. **运行模式互斥(强约束)**
- **Desktop-Only**CycleGUI 客户端独占;WebAPI / Platform **不启动**
@@ -139,7 +139,7 @@ flowchart TB
end
subgraph Host["主控宿主机 / 边缘服务器"]
subgraph SL["SimpleLite 主进程 (CycleGUI + ASP.NET Core 8)"]
subgraph SL["Simple3 主进程 (CycleGUI + ASP.NET Core 8)"]
direction TB
SL_Login["启动登录窗<br/>(模式选择 Desktop / Web)"]
SL_UI["CycleGUI 客户端<br/>(Desktop-Only: 全功能<br/>Web-Enabled: 仅状态窗)"]
@@ -155,7 +155,7 @@ flowchart TB
direction TB
PL_Static["静态资源<br/>/admin/* → platform-vue dist<br/>/monitor/* → rcsmonitor-vue dist"]
PL_API["Platform WebAPI<br/>(配置中心 / 外部对接 / 库位 / 账号)"]
PL_YARP["YARP 反代<br/>/api/sl/* → SimpleLite WebAPI<br/>+ Auth/审计中间件"]
PL_YARP["YARP 反代<br/>/api/sl/* → Simple3 WebAPI<br/>+ Auth/审计中间件"]
PL_Auth["SimpleAuth<br/>(JWT 签发 + Scope 校验)"]
PL_Adp["外部系统适配器<br/>(MES / WMS / RCS)"]
PL_DB[("SQLite: platform.db<br/>WAL")]
@@ -203,11 +203,11 @@ flowchart TB
**要点:**
- **只有两个后端可执行文件**`SimpleLite.exe``MiGu.Server.exe`RCSMonitor 不再有独立后端。
- **`MiGu.Server.exe` 由 SimpleLite 的 SystemMission 拉起**:用户只启动 SimpleLiteWeb-Enabled 时 `StartPlatformMission` 自动 spawn MiGu.Server,子进程心跳异常由 Bootstrapper 重启。
- **只有两个后端可执行文件**`Simple3.exe``MiGu.Server.exe`RCSMonitor 不再有独立后端。
- **`MiGu.Server.exe` 由 Simple3 的 SystemMission 拉起**:用户只启动 Simple3Web-Enabled 时 `StartPlatformMission` 自动 spawn MiGu.Server,子进程心跳异常由 Bootstrapper 重启。
- **`PL_Static` 同一进程双 SPA 托管**:浏览器访问路径决定加载哪份 dist,登录时也按路径决定 `scope` 参数。
- **`PL_YARP``/api/sl/*` 反代到 SimpleLite**:YARP 在请求转发前后挂中间件做 JWT 校验、权限码二次确认、写操作审计、`Idempotency-Key` 透传、Scope 检查(RCSMonitor 的请求仅允许命中 `/api/ops/*` 这一前缀)。
- **3D 由 SimpleLite 的 webVRender 提供**Vue 端通过 iframe 或 Web Component 嵌入 webVRender 的页面,业务交互(选择、点击坐标等)通过 `postMessage` 与 Vue 桥接。
- **`PL_YARP``/api/sl/*` 反代到 Simple3**:YARP 在请求转发前后挂中间件做 JWT 校验、权限码二次确认、写操作审计、`Idempotency-Key` 透传、Scope 检查(RCSMonitor 的请求仅允许命中 `/api/ops/*` 这一前缀)。
- **3D 由 Simple3 的 webVRender 提供**Vue 端通过 iframe 或 Web Component 嵌入 webVRender 的页面,业务交互(选择、点击坐标等)通过 `postMessage` 与 Vue 桥接。
- **Desktop-Only 模式**`SL_API``PL` 都不启动;Vue 无法访问;CycleGUI 是唯一入口。
- **Web-Enabled 模式**CycleGUI 退化为状态窗;所有业务操作通过 Vue 完成;硬性不允许同时本地操作。
@@ -219,8 +219,8 @@ flowchart TB
```mermaid
flowchart TB
Start([SimpleLite.exe 启动]) --> CheckPrev{simple.json 中<br/>runMode 已记忆?}
CheckPrev -- 否 --> Login[弹出登录窗<br/>SimpleLite Boot Login]
Start([Simple3.exe 启动]) --> CheckPrev{simple.json 中<br/>runMode 已记忆?}
CheckPrev -- 否 --> Login[弹出登录窗<br/>Simple3 Boot Login]
CheckPrev -- 是 --> AutoLogin[自动按记忆模式登录<br/>仍需鉴权身份]
Login --> Form
AutoLogin --> ModeSwitch
@@ -269,7 +269,7 @@ flowchart TB
| 元素 | 控件 | 说明 |
|------|------|------|
| 标题 | Label | "SimpleLite · 启动登录" |
| 标题 | Label | "Simple3 · 启动登录" |
| 用户名 | TextInput | 默认上次登录用户 |
| 密码 | TextInput (password) | — |
| 运行模式 | RadioButtons | `Desktop-Only` / `Web-Enabled` |
@@ -281,7 +281,7 @@ flowchart TB
| 登录按钮 | Button | 通过后销毁登录窗,进入选定模式 |
| 退出按钮 | Button | 不进入主程序,退出进程 |
> 实现位置:新增 `SimpleLite/UI/BootLoginPanel.cs`,由 `Program.Main` 在 `Enssentials.Load` 之后、`Startup.EntryPoint` 之前调用 `GUI.PromptAndWaitPanel(BootLoginPanel.Build())`(阻塞式模态)。
> 实现位置:新增 `Simple3/UI/BootLoginPanel.cs`,由 `Program.Main` 在 `Enssentials.Load` 之后、`Startup.EntryPoint` 之前调用 `GUI.PromptAndWaitPanel(BootLoginPanel.Build())`(阻塞式模态)。
#### simple.json 增量字段
@@ -310,18 +310,18 @@ flowchart TB
| 场景 | 行为 |
|------|------|
| Desktop-Only 运行中 | Kestrel 不监听任何端口;MiGu.Server 不 spawn;尝试连接 7001/8080 直接 ECONNREFUSED |
| Desktop-Only → Web-Enabled | **必须退出 SimpleLite 重新登录**;不支持热切(避免半启动状态) |
| Desktop-Only → Web-Enabled | **必须退出 Simple3 重新登录**;不支持热切(避免半启动状态) |
| Web-Enabled 运行中 | CycleGUI 主窗只显示状态窗(运行时长 / 在线用户 / 端口 / 主备状态)+「停止」按钮 |
| Web-Enabled 下用户尝试本地控件 | **所有业务面板硬性不渲染**;Workspace 视口也不显示;用户无法通过任何方式重新启用 |
| Web-Enabled 异常退出 | Watchdog 重新拉起,恢复到 Web-Enabled;不强迫用户重选 |
| 想取消"记住模式" | 启动时按住 `Shift` 或运行 `SimpleLite.exe --re-login` 强制弹出登录窗 |
| 想取消"记住模式" | 启动时按住 `Shift` 或运行 `Simple3.exe --re-login` 强制弹出登录窗 |
| 远程开关模式 | Platform 管理员可以发 `POST /api/sl/runmode/reset``rememberRunMode` 置为 `false`**仅在下次重启时生效** |
### 3.4 服务状态窗(Web-Enabled 唯一可见的 CycleGUI 面板)
```text
┌──────────────────────────────────────────┐
│ SimpleLite Service Status │
│ Simple3 Service Status │
├──────────────────────────────────────────┤
│ 模式: Web-Enabled │
│ 启动时间: 2026-05-18 09:00:00 │
@@ -348,7 +348,7 @@ flowchart TB
```mermaid
flowchart LR
A["SimpleLite.exe<br/>启动 + 登录"] --> Mode{运行模式}
A["Simple3.exe<br/>启动 + 登录"] --> Mode{运行模式}
Mode -- Desktop-Only --> D[CycleGUI 全功能 UI]
D --> SchedD[调度引擎]
@@ -360,7 +360,7 @@ flowchart LR
WHost --> WV[WebTerminal :8223<br/>webVRender]
WHost --> StatusPanel[CycleGUI 状态窗]
subgraph SysMis["SimpleLite SystemMission 体系"]
subgraph SysMis["Simple3 SystemMission 体系"]
E["StartPlatformMission<br/>(SystemMission 子类)"]
SM_Reg["SystemMissionRegistry"]
SM_Hb["HeartbeatChannel<br/>(named pipe)"]
@@ -380,24 +380,24 @@ flowchart LR
### 4.2 SystemMission 体系(v1.4 新增章节)
SimpleLite 现有 `Mission` 是业务任务(运送/搬运),新增 **`SystemMission`** 作为同一基类的"系统级 Mission",用于拉起并守护进程级服务。它**不参与调度引擎的 DispatchLoop**,由 Bootstrapper 直接驱动。
Simple3 现有 `Mission` 是业务任务(运送/搬运),新增 **`SystemMission`** 作为同一基类的"系统级 Mission",用于拉起并守护进程级服务。它**不参与调度引擎的 DispatchLoop**,由 Bootstrapper 直接驱动。
**关键类:**
| 类型 | 类型 / 命名空间 | 职责 |
|------|------------------|------|
| `SystemMission`(抽象) | `SimpleLite.RCS.SystemMissions` | 继承自现有 `Mission`,新增 `Spawn() / Stop() / Heartbeat()` 等抽象方法 |
| `StartPlatformMission` | `SimpleLite.RCS.SystemMissions` | 拉起 `MiGu.Server.exe`;通过命名管道 `\\.\pipe\simple-bootstrap` 接收心跳 |
| `SystemMissionRegistry` | `SimpleLite.Bootstrap` | 单例,注册所有 SystemMissionWeb-Enabled 启动时遍历 `Spawn()`;进程退出时遍历 `Stop()` |
| `ProcessHost` | `SimpleLite.Bootstrap` | 封装 `System.Diagnostics.Process` + 重定向标准输出到日志 + 退出码捕获 |
| `HeartbeatChannel` | `SimpleLite.Bootstrap` | 命名管道服务端;JSON 协议 `{ "ts", "rss", "queueDepth", "status" }`3 个周期未达 → 标记不健康 |
| `ProcessWatchdog` | `SimpleLite.Bootstrap` | 不健康时 `Stop() + Spawn()`;指数退避 1s → 2s → 4s → 8s → 30s(上限) |
| `RunMode`(枚举) | `SimpleLite.Bootstrap` | `DesktopOnly` / `WebEnabled` |
| `SystemMission`(抽象) | `Simple3.RCS.SystemMissions` | 继承自现有 `Mission`,新增 `Spawn() / Stop() / Heartbeat()` 等抽象方法 |
| `StartPlatformMission` | `Simple3.RCS.SystemMissions` | 拉起 `MiGu.Server.exe`;通过命名管道 `\\.\pipe\simple-bootstrap` 接收心跳 |
| `SystemMissionRegistry` | `Simple3.Bootstrap` | 单例,注册所有 SystemMissionWeb-Enabled 启动时遍历 `Spawn()`;进程退出时遍历 `Stop()` |
| `ProcessHost` | `Simple3.Bootstrap` | 封装 `System.Diagnostics.Process` + 重定向标准输出到日志 + 退出码捕获 |
| `HeartbeatChannel` | `Simple3.Bootstrap` | 命名管道服务端;JSON 协议 `{ "ts", "rss", "queueDepth", "status" }`3 个周期未达 → 标记不健康 |
| `ProcessWatchdog` | `Simple3.Bootstrap` | 不健康时 `Stop() + Spawn()`;指数退避 1s → 2s → 4s → 8s → 30s(上限) |
| `RunMode`(枚举) | `Simple3.Bootstrap` | `DesktopOnly` / `WebEnabled` |
**生命周期:**
```text
SimpleLite 启动 (Web-Enabled)
Simple3 启动 (Web-Enabled)
├─ Kestrel Up :7001/7002
├─ WebTerminal Up :8223
├─ SchedulerRuntime.Start()
@@ -414,7 +414,7 @@ SimpleLite 启动 (Web-Enabled)
Watchdog 触发 → ProcessHost.Kill() → 退避等待 → Spawn() 重试
连续 5 次失败 → 状态窗高亮 "Platform Down",停止重试,需人工介入
SimpleLite 优雅停机:
Simple3 优雅停机:
SystemMissionRegistry.StopAll()
└─ StartPlatformMission.Stop()
├─ 发送 SIGTERM 等价(命名管道 "shutdown" 消息)
@@ -424,26 +424,26 @@ SimpleLite 优雅停机:
**实现要点:**
- **位置**:新建 `SimpleLite/Bootstrap/``SimpleLite/RCS/SystemMissions/`
- **位置**:新建 `Simple3/Bootstrap/``Simple3/RCS/SystemMissions/`
- **不写入主库 Missions 表**SystemMission 是进程级配置,不参与项目序列化;存储在 `simple_main.db.SystemMissionConfig`(或直接读 `simple.json`)。
- **崩溃隔离**Platform 异常不影响 SimpleLite 调度循环;MiGu.Server 也实现 `IHostedService.StopAsync` 做 WAL Checkpoint + WS 排空 → 给 ROSE 释放磁盘。
- **多实例预防**:通过命名互斥体 + `simple.json.allowMultiple=false`(已有)确保 SimpleLite 唯一;MiGu.Server 启动时校验"父进程是否为 SimpleLite",否则拒绝启动(防止有人手动双击 `MiGu.Server.exe`)。
- **崩溃隔离**Platform 异常不影响 Simple3 调度循环;MiGu.Server 也实现 `IHostedService.StopAsync` 做 WAL Checkpoint + WS 排空 → 给 ROSE 释放磁盘。
- **多实例预防**:通过命名互斥体 + `simple.json.allowMultiple=false`(已有)确保 Simple3 唯一;MiGu.Server 启动时校验"父进程是否为 Simple3",否则拒绝启动(防止有人手动双击 `MiGu.Server.exe`)。
- **静态资源**MiGu.Server 用 `MapWhen + UseStaticFiles + UseSpa` 各挂一份 `index.html`
---
## 5. 三端职责矩阵
| 维度 | SimpleLite 桌面端 | **Platform 管理端(platform-vue** | **RCSMonitor 运营端(rcsmonitor-vue** |
| 维度 | Simple3 桌面端 | **Platform 管理端(platform-vue** | **RCSMonitor 运营端(rcsmonitor-vue** |
|------|---------------------|------------------------------------------|------------------------------------------|
| 地图(站点/轨道/区域)编辑 | **读写**Desktop-Only | **全功能读写**Vue 地图控件内嵌SimpleLite地图) | 只读投影,不可编辑 |
| 地图(站点/轨道/区域)编辑 | **读写**Desktop-Only | **全功能读写**Vue 地图控件内嵌Simple3地图) | 只读投影,不可编辑 |
| 任务编排(Mission/Recipe | **读写** | **全功能读写**(含 Demand / Recipe | 基础动作:暂停/取消/重派/优先级 |
| 车辆配置(参数 / 维护策略) | **读写** | **全功能读写** | 基础动作:暂停/继续/结束任务/上线/离线/手动充电等 |
| CAD 工具 | **可用** | **可用**Vue 调用 `/api/sl/cad/*` | 不可用 |
| 自定义字段 / 图层 | **可用** | **可用** | 只读 |
| 调度引擎(DispatchLoop) | 进程内拥有 | 远程订阅状态 + 全 API 操作 | 远程订阅状态 + 受限 API |
| 车端通信 | 拥有 | 不直连,只看投影 | 不直连,只看投影 |
| 3D 视口 | 本地 OpenGLCycleGUI 原生) | **嵌入 SimpleLite webVRender**(全功能交互) | **嵌入 SimpleLite webVRender**(只读 + 圈选) |
| 3D 视口 | 本地 OpenGLCycleGUI 原生) | **嵌入 Simple3 webVRender**(全功能交互) | **嵌入 Simple3 webVRender**(只读 + 圈选) |
| 系统级配置 | 局部(Desktop-Only | **全局编辑 + 下发** | 不可编辑 |
| 外部对接(MES/WMS/RCS | 不涉及 | **唯一入口** | 不涉及 |
| 路径规划/交通/充电策略 | 算法 + 调试 | **可视化编辑 + 下发** | 只读 |
@@ -549,7 +549,7 @@ Platform 将典型业务场景抽象为标准模板,支持开箱即用和后
```mermaid
flowchart TB
subgraph 顶层["顶层(按端隔离)"]
L1A["SimpleLite (CycleGUI + WebAPI Host)"]
L1A["Simple3 (CycleGUI + WebAPI Host)"]
L1B["MiGu.Server (ASP.NET Core + YARP)"]
L1B2["platform-vue (SPA, 管理员)"]
L1B3["rcsmonitor-vue (SPA, 运营)"]
@@ -608,17 +608,17 @@ flowchart TB
| `SimpleShared.Persistence` | net8.0 | EF Core 8 多 Provider 抽象(**SQLite / MySQL / PostgreSQL / SQL Server**+ 迁移 + `DbProviderOptions` + 连接串构建器 + `WalSqliteOptions`(仅 SQLite |
| `SimpleShared.OpsCommand` | net8.0 | 运维白名单命令、审计、幂等键、频次限流 |
| `SimpleShared.WebApiHost` | net8.0 | Kestrel 公共启动器:JWT 中间件 / Swagger / CORS / 健康检查 / 请求审计 |
| `MiGu.Server` | net8.0 ASP.NET Core | 业务 BFF + **YARP 反代 SimpleLite** + 静态托管两份 Vue dist;**启动时校验父进程为 SimpleLite,否则拒绝运行**(防误用) |
| `platform-vue` | Vue 3 + Vite + Pinia | 全功能套壳:复刻 SimpleLite 所有控件 |
| `MiGu.Server` | net8.0 ASP.NET Core | 业务 BFF + **YARP 反代 Simple3** + 静态托管两份 Vue dist;**启动时校验父进程为 Simple3,否则拒绝运行**(防误用) |
| `platform-vue` | Vue 3 + Vite + Pinia | 全功能套壳:复刻 Simple3 所有控件 |
| `rcsmonitor-vue` | Vue 3 + Vite + Pinia | 运营监控:按权限渲染,运维动作面板 |
| `packages/sl-controls`pnpm workspace | Vue 3 组件库 | Workspace3D(嵌 webVRender/ 地图列表 / 任务表 / CAD 工具栏等共享组件 |
### 6.1 Platform Vue 控件复刻清单(与 SimpleLite CycleGUI 对照)
### 6.1 Platform Vue 控件复刻清单(与 Simple3 CycleGUI 对照)
| SimpleLite CycleGUI 面板 | Vue 组件(位于 `sl-controls` | 备注 |
| Simple3 CycleGUI 面板 | Vue 组件(位于 `sl-controls` | 备注 |
|--------------------------|--------------------------------|------|
| 主菜单栏 / 工具栏 | `<AppShell>` 顶栏 + 侧栏 | platform-vue 全功能;rcsmonitor-vue 仅显示运营菜单 |
| Workspace 3D 视口 | `<Workspace3D>` | **iframe / Web Component 嵌入 SimpleLite webVRender**;交互事件用 `postMessage` 桥接 |
| Workspace 3D 视口 | `<Workspace3D>` | **iframe / Web Component 嵌入 Simple3 webVRender**;交互事件用 `postMessage` 桥接 |
| 地图面板(Sites / Tracks | `<MapEditor>` + `<TrackTable>` | 表格 + 拓扑视图 |
| Car 面板 | `<CarPanel>` | 表格 + 表单 |
| Mission 面板 | `<MissionEditor>` | 任务流图 + 表单 |
@@ -674,7 +674,7 @@ function onReady() {
</script>
```
`SimpleLite/Rendering/SimpleSceneRenderer.cs` 端按 `scope``ro` 控制:
`Simple3/Rendering/SimpleSceneRenderer.cs` 端按 `scope``ro` 控制:
- `scope=RCSMonitor``ro=1` → 禁用编辑工具(不响应 `SelectObject`/`GetPosition` 中的拖动)
- 通过 `postMessage` 向 iframe 父页面回传选择/点击事件
@@ -690,7 +690,7 @@ builder.Services.AddReverseProxy()
.LoadFromConfig(builder.Configuration.GetSection("ReverseProxy"))
.AddTransforms(ctx =>
{
// 透传当前 JWT 的 userId / scope 给 SimpleLite
// 透传当前 JWT 的 userId / scope 给 Simple3
ctx.AddRequestTransform(async tc =>
{
var ident = tc.HttpContext.User;
@@ -767,7 +767,7 @@ app.Run();
```mermaid
flowchart TB
subgraph App["应用代码 (SimpleLite / MiGu.Server)"]
subgraph App["应用代码 (Simple3 / MiGu.Server)"]
DOM["领域服务 / Repository"]
EFC["EF Core 8 DbContext"]
OPT["DbProviderOptions<br/>{ provider, conn, ha }"]
@@ -813,7 +813,7 @@ flowchart TB
| 逻辑库 | 拥有进程 | 主要表/数据 |
|--------|----------|-------------|
| **simple_main** | SimpleLite | Maps, Sites, Tracks, SpecialProps, Missions, Cars, Plugins, DesignAuditLog, OpsAuditLog, LocalUsers, RunModeHistory |
| **simple_main** | Simple3 | Maps, Sites, Tracks, SpecialProps, Missions, Cars, Plugins, DesignAuditLog, OpsAuditLog, LocalUsers, RunModeHistory |
| **platform** | Platform | Users, Roles, RolePermissions, WidgetGrants, SystemConfig, ExternalAdapters, RoutingPolicies, VehicleMaintenancePolicies, ChargePolicies, TaskAllocationPolicies, TrafficRules, Locations, Inventory, OpsLogs(含 RCSMonitor 来源), Annotations, Snapshots, Versions, CustomWidgets |
> 原 v1.2 中的 `rcsmonitor_local` 取消;其表(OpsLogs / Annotations / Snapshots / WidgetLayoutCache)合并到 `platform`,加 `source='RCSMonitor'` 字段区分。
@@ -824,12 +824,12 @@ flowchart TB
```mermaid
flowchart TB
subgraph A["主节点 NODE-A (Active)"]
SL_A["SimpleLite.exe"]
SL_A["Simple3.exe"]
PL_A["MiGu.Server.exe"]
D_A[("受 ROSE 保护盘 R:\<br/>simple_main.db<br/>platform.db<br/>(WAL)")]
end
subgraph B["备节点 NODE-B (Standby)"]
SL_B["SimpleLite.exe (suspended)"]
SL_B["Simple3.exe (suspended)"]
PL_B["MiGu.Server.exe (suspended)"]
D_B[("ROSE 镜像盘 R:\")]
end
@@ -860,7 +860,7 @@ flowchart TB
|------|------|------|
| `RES_DISK_R` | Mirror Disk | — |
| `RES_VIP_LAN` | Virtual IP | RES_DISK_R |
| `RES_SVC_SL` | Service: SimpleLite (Web-Enabled) | RES_DISK_R, RES_VIP_LAN |
| `RES_SVC_SL` | Service: Simple3 (Web-Enabled) | RES_DISK_R, RES_VIP_LAN |
| `RES_SVC_PL` | Service: MiGu.Server | RES_DISK_R, RES_VIP_LAN, RES_SVC_SL |
| `RES_SCRIPT_CHK` | 数据库健康检查脚本 | RES_DISK_R |
@@ -871,7 +871,7 @@ flowchart TB
```mermaid
flowchart TB
subgraph App["应用层 (VIP + 重试)"]
SL["SimpleLite.exe"]
SL["Simple3.exe"]
PL["MiGu.Server.exe"]
end
subgraph Router["MySQL Router (或 ProxySQL)"]
@@ -911,7 +911,7 @@ flowchart TB
```mermaid
flowchart TB
subgraph App["应用层"]
SL["SimpleLite.exe"]
SL["Simple3.exe"]
PL["MiGu.Server.exe"]
end
subgraph LB["HAProxy / PgBouncer"]
@@ -953,7 +953,7 @@ flowchart TB
```mermaid
flowchart TB
subgraph App["应用层"]
SL["SimpleLite.exe"]
SL["Simple3.exe"]
PL["MiGu.Server.exe"]
end
subgraph Listener["AG Listener (DNS + VIP)"]
@@ -983,7 +983,7 @@ flowchart TB
- **EF Core Provider**`Microsoft.EntityFrameworkCore.SqlServer`;支持 SQL Server 2019+(建议 Enterprise 版以启用同步提交副本数 ≥3 + Read-Only Routing)。Standard 版可降级为 2 副本基本可用性组。
- **HA 形态****WSFCWindows Server Failover Cluster+ Always On Availability Groups**;通过 **AG Listener** 暴露统一虚拟名/VIP,客户端连 Listener 名即可。
- **同步策略**:核心库(`platform` / `simple_main`)配 1 个同步提交副本(RPO=0),1–N 个异步副本(可读分析)。
- **认证**:建议混合身份认证;MiGu.Server 用 SQL LoginSimpleLite 桌面端可走 Windows 集成认证。
- **认证**:建议混合身份认证;MiGu.Server 用 SQL LoginSimple3 桌面端可走 Windows 集成认证。
- **TempDB**:每节点分摊;MultipleActiveResultSetsMARS)建议关闭,EF Core 已能避免。
- **备份**`BACKUP DATABASE ... WITH COMPRESSION` + 事务日志备份每 15min;保留策略与公司 IT 一致。
- **RTO / RPO**RTO ≈ 1030sAG 自动 failover);RPO ≈ 0(同步提交副本)。
@@ -1091,7 +1091,7 @@ flowchart TB
"database": "simple_main",
"user": "simple_app",
"password": "${SECRET_MSSQL_PWD}",
"extra": "Encrypt=True;TrustServerCertificate=False;MultiSubnetFailover=True;Application Name=SimpleLite"
"extra": "Encrypt=True;TrustServerCertificate=False;MultiSubnetFailover=True;Application Name=Simple3"
},
"platform": {
"server": "tcp:rcs-ag.lan,1433",
@@ -1158,7 +1158,7 @@ classDiagram
**落地四件套:**
1. **认证**`SimpleShared.Auth` 提供 `JwtIssuer`Web-Enabled 时 MiGu.Server 签发;Desktop-Only 时 SimpleLite 本地签发短期 Token(24h)。
1. **认证**`SimpleShared.Auth` 提供 `JwtIssuer`Web-Enabled 时 MiGu.Server 签发;Desktop-Only 时 Simple3 本地签发短期 Token(24h)。
2. **授权**:所有 WebAPI + WS 调用走 `IPermissionEvaluator`;权限码 `<area>.<action>`
3. **范围(Scope**`Lite` / `Platform` / `RCSMonitor` / `*`;同一用户在不同端可用集合不同。Vue 端登录时由前端代码根据访问路径自动带入 `scope``/admin/*``Platform``/monitor/*``RCSMonitor`)。
4. **控件级权限**:除 `Permission` 外,角色还可关联 `WidgetGrant`,每个 `WidgetId` 三档可见性:
@@ -1173,7 +1173,7 @@ sequenceDiagram
participant U as 运营用户
participant RV as rcsmonitor-vue
participant P as MiGu.Server
participant L as SimpleLite (YARP 后侧)
participant L as Simple3 (YARP 后侧)
participant DBp as platform.db
U->>RV: 访问 https://host:8080/monitor
@@ -1255,7 +1255,7 @@ flowchart LR
UI11 --> Sch
Sch --> Val --> Ver --> Pub
Pub -- "REST PUT /api/sl/config/{section} (via YARP)" --> SL["SimpleLite (主服务)"]
Pub -- "REST PUT /api/sl/config/{section} (via YARP)" --> SL["Simple3 (主服务)"]
Pub -- "WS broadcast: config.updated" --> RMVue["rcsmonitor-vue"]
Pub -- "WS broadcast: config.updated" --> AdminVue["其他在线 platform-vue"]
```
@@ -1316,7 +1316,7 @@ public record EffectivePermissions(
List<WidgetGrantDto> VisibleWidgets);
```
**发布机制:** Schema → 校验 → 版本 → 灰度 → 全量;MiGu.Server 通过 YARP `PUT /api/sl/config/{section}` 推到 SimpleLite,主服务原子切换 + ack;失败自动回滚,WS `config.rollback` 广播。
**发布机制:** Schema → 校验 → 版本 → 灰度 → 全量;MiGu.Server 通过 YARP `PUT /api/sl/config/{section}` 推到 Simple3,主服务原子切换 + ack;失败自动回滚,WS `config.rollback` 广播。
---
@@ -1327,14 +1327,14 @@ public record EffectivePermissions(
```mermaid
sequenceDiagram
participant U as 用户
participant L as SimpleLite (CycleGUI)
participant L as Simple3 (CycleGUI)
participant BL as BootLoginPanel
participant K as Kestrel (WebAPI)
participant P as MiGu.Server (YARP)
participant V as platform-vue
participant RV as rcsmonitor-vue
U->>L: 启动 SimpleLite.exe
U->>L: 启动 Simple3.exe
L->>L: 读取 simple.json
alt rememberRunMode = true
L->>L: 直接采用 runMode (仍鉴权)
@@ -1373,10 +1373,10 @@ sequenceDiagram
```mermaid
sequenceDiagram
participant U as 用户
participant L as SimpleLite
participant L as Simple3
participant BL as BootLoginPanel
U->>L: 启动 SimpleLite.exe
U->>L: 启动 Simple3.exe
L->>L: 读 simple.json (无记忆 或 选项=DesktopOnly)
L->>BL: 必要时弹出登录窗
U->>BL: 输入凭据 + 选 Desktop-Only
@@ -1387,13 +1387,13 @@ sequenceDiagram
L-->>U: 进入设计端主界面
```
### 10.3 platform-vue 编辑地图站点 → SimpleLite 落库
### 10.3 platform-vue 编辑地图站点 → Simple3 落库
```mermaid
sequenceDiagram
participant V as platform-vue
participant P as MiGu.Server (YARP)
participant L as SimpleLite (WebAPI)
participant L as Simple3 (WebAPI)
participant DBl as simple_main.db
participant RV as rcsmonitor-vue (订阅)
@@ -1406,7 +1406,7 @@ sequenceDiagram
P-->>V: 201 + siteId
L->>L: WS broadcast { type: "map.site.created", payload }
L-->>P: WS event
P-->>V: WS event (前端 Workspace3D iframe 收到 SimpleLite 推送的 PutModelObject 同步)
P-->>V: WS event (前端 Workspace3D iframe 收到 Simple3 推送的 PutModelObject 同步)
P-->>RV: WS event (RCSMonitor 接收并按权限展示)
```
@@ -1416,7 +1416,7 @@ sequenceDiagram
sequenceDiagram
participant V as platform-vue
participant P as Platform
participant L as SimpleLite
participant L as Simple3
participant E as DispatchLoop
participant RV as rcsmonitor-vue
@@ -1440,7 +1440,7 @@ sequenceDiagram
participant U as 运营人员
participant RV as rcsmonitor-vue
participant P as MiGu.Server (YARP)
participant L as SimpleLite (Ops 网关)
participant L as Simple3 (Ops 网关)
participant E as DispatchLoop
participant DBp as platform.db
participant DBl as simple_main.db
@@ -1476,10 +1476,10 @@ sequenceDiagram
VIP->>NA: 路由到 Active
Note over NA: 节点故障
ROSE->>NA: 心跳超时 → 触发切换
ROSE->>NA: 强制停止 SimpleLite / Platform
ROSE->>NA: 强制停止 Simple3 / Platform
ROSE->>NA: 卸载 R: 卷
ROSE->>NB: 挂载 R: 卷
ROSE->>NB: 启动 SimpleLite (WAL 重放 + 主库自检)
ROSE->>NB: 启动 Simple3 (WAL 重放 + 主库自检)
ROSE->>NB: 启动 MiGu.Server
ROSE->>VIP: 浮动 IP 漂移到 NODE-B
CLI--xVIP: 短暂中断
@@ -1495,12 +1495,12 @@ sequenceDiagram
| 通道 | 协议 | 编码 | 端点 | 用途 |
|------|------|------|------|------|
| MiGu.Server (YARP) ↔ SimpleLite | REST / WebSocket | JSON | `:7001` / `:7002` | 全功能 API 反代 + 事件订阅 |
| MiGu.Server (YARP) ↔ Simple3 | REST / WebSocket | JSON | `:7001` / `:7002` | 全功能 API 反代 + 事件订阅 |
| platform-vue ↔ MiGu.Server | REST / WebSocket | JSON | `:8080` / `:8081` (路径前缀 `/admin` | UI 操作、事件推送 |
| rcsmonitor-vue ↔ MiGu.Server | REST / WebSocket | JSON | `:8080` / `:8081` (路径前缀 `/monitor``scope=RCSMonitor`) | UI 操作、事件推送(受限) |
| Vue Workspace3D ↔ SimpleLite WebTerminal | WSSwebVRender 协议) | webVRender 自有 | `:8223` | 3D 视口同步(嵌入 iframe |
| Vue Workspace3D ↔ Simple3 WebTerminal | WSSwebVRender 协议) | webVRender 自有 | `:8223` | 3D 视口同步(嵌入 iframe |
| 子进程心跳 | Named Pipe | JSON | `\\.\pipe\simple-bootstrap` | 守护 / 重启 |
| Vehicle ↔ SimpleLite | TCP / WS / OPC-UA | 业务私有 | `:8222` | 车端协议 |
| Vehicle ↔ Simple3 | TCP / WS / OPC-UA | 业务私有 | `:8222` | 车端协议 |
| ROSE 心跳 | 专用网卡 + 串口 | ROSE 私有 | — | 节点健康监测 |
| ROSE 镜像复制 | 专用网卡 | ROSE 私有 | — | 块级实时数据同步 |
@@ -1510,7 +1510,7 @@ sequenceDiagram
- 鉴权:`Authorization: Bearer <JWT>`;服务间互调附 `X-Svc-Token` + `X-On-Behalf-Of: <userId>` + `X-Scope: <scope>`
- 幂等:写操作支持 `Idempotency-Key` 请求头
- 版本化:配置类资源使用 `ETag` / `If-Match` 做乐观锁
- SwaggerPlatform / SimpleLite 都暴露 `/swagger`(生产可关)
- SwaggerPlatform / Simple3 都暴露 `/swagger`(生产可关)
- **YARP Scope 白名单**`scope=RCSMonitor` 时仅允许 `/api/sl/ops/*``/api/sl/projection/*` 命中;其他路径直接 403
---
@@ -1521,12 +1521,12 @@ sequenceDiagram
flowchart TB
subgraph Cluster["双机 ROSE HA 集群 (Web-Enabled)"]
subgraph NA["NODE-A (Active)"]
SL_A["SimpleLite.exe<br/>Kestrel :7001/7002<br/>WebTerminal :8223"]
SL_A["Simple3.exe<br/>Kestrel :7001/7002<br/>WebTerminal :8223"]
PL_A["MiGu.Server.exe<br/>:8080/8081<br/>(YARP + 双 Vue dist)"]
R_A["ROSE 受保护盘 R:\<br/>两库 (WAL)"]
end
subgraph NB["NODE-B (Standby)"]
SL_B["SimpleLite.exe (suspended)"]
SL_B["Simple3.exe (suspended)"]
PL_B["MiGu.Server.exe (suspended)"]
R_B["ROSE 镜像盘 R:\<br/>(实时同步)"]
end
@@ -1556,12 +1556,12 @@ flowchart TB
| 端口 | 服务 | 启用条件 |
|------|------|----------|
| 7001 | SimpleLite WebAPI | Web-Enabled |
| 7002 | SimpleLite WebSocket | Web-Enabled |
| 7001 | Simple3 WebAPI | Web-Enabled |
| 7002 | Simple3 WebSocket | Web-Enabled |
| 8080 | Platform WebAPI + platform-vue 静态(/admin+ rcsmonitor-vue 静态(/monitor | Web-Enabled |
| 8081 | Platform WebSocket | Web-Enabled |
| 8222 | SimpleLite 车端 / API(现有) | 所有模式 |
| 8223 | SimpleLite WebTerminalwebVRenderVue 嵌入用) | Web-Enabled |
| 8222 | Simple3 车端 / API(现有) | 所有模式 |
| 8223 | Simple3 WebTerminalwebVRenderVue 嵌入用) | Web-Enabled |
> Desktop-Only 模式下:7001 / 7002 / 8080 / 8081 / 8223 全部**不监听**。
@@ -1588,7 +1588,7 @@ flowchart LR
SHH["SimpleShared.WebApiHost"]
end
subgraph apps["两端后端"]
SL["SimpleLite (CycleGUI + WebAPI)"]
SL["Simple3 (CycleGUI + WebAPI)"]
PSrv["MiGu.Server (WebAPI + YARP)"]
end
subgraph frontends["前端 (pnpm workspace)"]
@@ -1709,7 +1709,7 @@ pnpm --filter rcsmonitor-vue build # 产出 apps/rcsmonitor-vue/dist
|--------|----------|----------|
| **M0 - 内核下沉** | 1 周 | 新建 SimpleShared.* 6 个工程;Configuration → IConfigSnapshotProviderEF Core + SQLite + 迁移 |
| **M1 - 启动登录 + 运行模式** | 1 周 | `BootLoginPanel``RunMode` 枚举;CycleGUI 状态窗;`simple.json.runMode` 持久化;Web-Enabled 业务面板硬隐藏 |
| **M2 - WebAPI 协议层** | 1.5 周 | SimpleLite 集成 Kestrel;定义 `/api/projection/*``/api/ops/*``/api/config/*``/api/map/*` 等;WS HubJWT |
| **M2 - WebAPI 协议层** | 1.5 周 | Simple3 集成 Kestrel;定义 `/api/projection/*``/api/ops/*``/api/config/*``/api/map/*` 等;WS HubJWT |
| **M3 - 共享前端组件库** | 1.5 周 | `frontends/packages/sl-controls`Workspace3D(嵌入 webVRender+ 地图列表 + 任务表 + CAD 工具栏 + OpsActionPanel 等 |
| **M4 - Platform 后端 + YARP** | 2 周 | MiGu.ServerWebAPI + Swagger + YARP `/api/sl/*` + Scope 中间件 + 静态托管双 Vue dist+ 配置中心 11 个 schema + 用户/角色/控件权限 |
| **M5 - platform-vue (管理员)** | 2.5 周 | 全功能套壳:登录、地图设计、车辆/任务、CAD、字段/图层、配置中心、库位、自定义控件、回放、设备管理、车队生命周期、场景模板 |
@@ -1726,14 +1726,14 @@ pnpm --filter rcsmonitor-vue build # 产出 apps/rcsmonitor-vue/dist
|---|------|------|
| 1 | 运行模式持久化 | **写入 `simple.json.runMode` / `rememberRunMode`**(不使用 %APPDATA% |
| 2 | Web-Enabled 下是否允许同时本地操作 | **不允许**CycleGUI 仅状态窗;不提供"临时启用本地 UI"开关 |
| 3 | 3D 引擎 | **复用 SimpleLite WebTerminal 的 webVRender**Vue 通过 iframe / Web Component 嵌入 |
| 3 | 3D 引擎 | **复用 Simple3 WebTerminal 的 webVRender**Vue 通过 iframe / Web Component 嵌入 |
| 4 | 反向代理 | **YARP**MiGu.Server 用 YARP 反代 `/api/sl/*` |
| 5 | RCSMonitor 是否需要独立后端 | **否**,完全依赖 MiGu.Server;通过 `scope=RCSMonitor` + 角色 + WidgetGrant 限权 |
| 6 | 共享前端组件库 | **pnpm workspace + `frontends/packages/sl-controls`** |
| 7 | 运营白名单 | 采纳 §5.1 列表(10 条,后续可扩) |
| 8 | ROSE 复制粒度 | 块级镜像盘(RPO≈0)首选;视现场版本兜底文件级 |
| 9 | 车端连接策略 | 走 VIP,统一切换;车端实现 WS 自动重连 |
| 10 | **Platform / RCSMonitor 启动归属(v1.4** | **由 SimpleLite 内置 `SystemMission.StartPlatformMission` 拉起并守护**;不允许独立安装 MiGu.Server.exe 为 Windows ServiceMiGu.Server 启动时校验父进程必须是 SimpleLite |
| 10 | **Platform / RCSMonitor 启动归属(v1.4** | **由 Simple3 内置 `SystemMission.StartPlatformMission` 拉起并守护**;不允许独立安装 MiGu.Server.exe 为 Windows ServiceMiGu.Server 启动时校验父进程必须是 Simple3 |
| 11 | **持久层多 Providerv1.5** | EF Core 抽象,**默认 SQLite + ROSE HA**;可选 **MySQL InnoDB Cluster / PostgreSQL Patroni / SQL Server Always On AG**;每 Provider 一套 EF MigrationsCI 全矩阵跑通;运行期由 `simple.json.database.provider` 决定 |
| 12 | **跨 Provider 切换策略** | 不支持热切;切换走停服窗口 + `tools/db/migrate.ps1`;旧文件作为回退包保留 |
@@ -1743,22 +1743,22 @@ pnpm --filter rcsmonitor-vue build # 产出 apps/rcsmonitor-vue/dist
| 现有文件 | 改动方向 |
|----------|----------|
| `SimpleLite/Program.cs` | 在 `Enssentials.Load` 之后插入 `BootLoginPanel.RunModal()`;根据返回的 `RunMode` 决定后续路径 |
| `SimpleLite/Configuration.cs` | 拆为 `LocalBootConfig`(启动期,含 `runMode``rememberRunMode`、监听地址、各端口)+ `IConfigSnapshotProvider`(运行期);数据库路径走 `%SIMPLE_DATA_ROOT%` |
| `SimpleLite/Startup.cs` | 按 `RunMode` 分支:Desktop-Only 走原有 CycleGUIWeb-Enabled 启动 Kestrel + WebAPI + WebTerminal + Spawn Platform + 切换 CycleGUI 到状态窗 |
| `SimpleLite/UI/SimpleUI.cs` | 增加 `RunMode.WebEnabled` 时**所有业务面板硬性不创建**;新增"服务状态窗"面板(唯一允许) |
| `SimpleLite/UI/BootLoginPanel.cs`(新增) | 启动登录窗实现 + 模式持久化 |
| `SimpleLite/UI/ServiceStatusPanel.cs`(新增) | Web-Enabled 状态窗实现 |
| `SimpleLite/RCS/Mission.cs` | 抽出 `SystemMission` 抽象基类(与业务 Mission 同一基类但走独立 Registry**不进入 DispatchLoop** |
| `SimpleLite/RCS/SystemMissions/StartPlatformMission.cs`(新增) | 拉起 `MiGu.Server.exe`、绑定命名管道、上报心跳与状态到 ServiceStatusPanel |
| `SimpleLite/Bootstrap/ProcessHost.cs`(新增) | 封装 `System.Diagnostics.Process` + 日志重定向 + 优雅停止 |
| `SimpleLite/Bootstrap/HeartbeatChannel.cs`(新增) | 命名管道服务端 + JSON 心跳协议 |
| `SimpleLite/Bootstrap/SystemMissionRegistry.cs`(新增) | 注册/批量 Spawn/Stop SystemMission |
| `SimpleLite/Bootstrap/ProcessWatchdog.cs`(新增) | 指数退避重启 + 多次失败后熔断并通知状态窗 |
| `SimpleLite/Bootstrap/RunMode.cs`(新增) | `DesktopOnly` / `WebEnabled` 枚举 + 启动期校验 |
| `SimpleLite/Rendering/SimpleSceneRenderer.cs` | 按 webVRender 的 `?scope=` / `?ro=` 参数禁用编辑工具 |
| `Simple3/Program.cs` | 在 `Enssentials.Load` 之后插入 `BootLoginPanel.RunModal()`;根据返回的 `RunMode` 决定后续路径 |
| `Simple3/Configuration.cs` | 拆为 `LocalBootConfig`(启动期,含 `runMode``rememberRunMode`、监听地址、各端口)+ `IConfigSnapshotProvider`(运行期);数据库路径走 `%SIMPLE_DATA_ROOT%` |
| `Simple3/Startup.cs` | 按 `RunMode` 分支:Desktop-Only 走原有 CycleGUIWeb-Enabled 启动 Kestrel + WebAPI + WebTerminal + Spawn Platform + 切换 CycleGUI 到状态窗 |
| `Simple3/UI/SimpleUI.cs` | 增加 `RunMode.WebEnabled` 时**所有业务面板硬性不创建**;新增"服务状态窗"面板(唯一允许) |
| `Simple3/UI/BootLoginPanel.cs`(新增) | 启动登录窗实现 + 模式持久化 |
| `Simple3/UI/ServiceStatusPanel.cs`(新增) | Web-Enabled 状态窗实现 |
| `Simple3/RCS/Mission.cs` | 抽出 `SystemMission` 抽象基类(与业务 Mission 同一基类但走独立 Registry**不进入 DispatchLoop** |
| `Simple3/RCS/SystemMissions/StartPlatformMission.cs`(新增) | 拉起 `MiGu.Server.exe`、绑定命名管道、上报心跳与状态到 ServiceStatusPanel |
| `Simple3/Bootstrap/ProcessHost.cs`(新增) | 封装 `System.Diagnostics.Process` + 日志重定向 + 优雅停止 |
| `Simple3/Bootstrap/HeartbeatChannel.cs`(新增) | 命名管道服务端 + JSON 心跳协议 |
| `Simple3/Bootstrap/SystemMissionRegistry.cs`(新增) | 注册/批量 Spawn/Stop SystemMission |
| `Simple3/Bootstrap/ProcessWatchdog.cs`(新增) | 指数退避重启 + 多次失败后熔断并通知状态窗 |
| `Simple3/Bootstrap/RunMode.cs`(新增) | `DesktopOnly` / `WebEnabled` 枚举 + 启动期校验 |
| `Simple3/Rendering/SimpleSceneRenderer.cs` | 按 webVRender 的 `?scope=` / `?ro=` 参数禁用编辑工具 |
| `SimpleScheduler/WebApi.cs` | 调用方从 Nancy 切到 ASP.NET Core Controller;保持 `SchedulerRuntime` 外观稳定 |
| `SimpleLite/SimpleProject.cs` | 持久化双写:JSON(兼容)+ SQLite(新主存,受 ROSE 保护盘) |
| `Simple3/SimpleProject.cs` | 持久化双写:JSON(兼容)+ SQLite(新主存,受 ROSE 保护盘) |
| `Plugins/Sample` | 示例插件读取 `IConfigSnapshotProvider`;不再读 `Configuration.conf` |
| 新增 `tools/ha/*` | ROSE 资源启停脚本、健康检查脚本、切换演练脚本 |
| 新增 `tools/db/*` | 跨 Provider 迁移脚本(`migrate.ps1` / `dump.ps1` / `restore.ps1`);按目标 DB 校验 schema 与索引 |
@@ -1909,12 +1909,12 @@ pnpm --filter rcsmonitor-vue build # 产出 apps/rcsmonitor-vue/dist
|---|-----|------|------|
| B1 | Kestrel + Swagger + CORS | ✅ | `Program.cs` |
| B2 | Auth / Health / Config / Ops / Projection 控制器 | ✅ 骨架 | Mock/内存存储 |
| B3 | YARP `/api/sl/*` → :8222 | ✅ 配置 | 依赖 SimpleLite 实际监听 |
| B3 | YARP `/api/sl/*` → :8222 | ✅ 配置 | 依赖 Simple3 实际监听 |
| B4 | 静态托管 + SPA fallback | ✅ | 单一 `wwwroot/index.html` |
| B5 | JWT 真签发 + 库持久化 | ☐ | 仍为 Mock |
| B6 | 父进程 SimpleLite 校验 | ☐ | §4、§15 #10 |
| B6 | 父进程 Simple3 校验 | ☐ | §4、§15 #10 |
### 18.3 SimpleLite / 共享内核(ARCHITECTURE 主体)
### 18.3 Simple3 / 共享内核(ARCHITECTURE 主体)
| # | 项 | 状态 | 备注 |
|---|-----|------|------|
@@ -1942,5 +1942,5 @@ pnpm --filter rcsmonitor-vue build # 产出 apps/rcsmonitor-vue/dist
## 关联文档
- [SIMPLELITE_DEVELOPMENT_PLAN.md](./SIMPLELITE_DEVELOPMENT_PLAN.md) — SimpleLite 功能对齐执行计划
- [SIMPLELITE_DEVELOPMENT_PLAN.md](./SIMPLELITE_DEVELOPMENT_PLAN.md) — Simple3 功能对齐执行计划
- [SIMPLELITE_COMPOSER_PARITY_PLAN.md](./SIMPLELITE_COMPOSER_PARITY_PLAN.md) — 与 SimpleComposer 差距分析
+19 -19
View File
@@ -1,19 +1,19 @@
# SimpleLite 面向咪咕平台数据 API 文档
# Simple3 面向咪咕平台数据 API 文档
> **版本**:与 SimpleLite 源码同步
> **版本**:与 Simple3 源码同步
> **更新日期**2026-06-18
> **服务栈**EmbedIO WebApi(非 ASP.NET Core
> **OpenAPI**`Docs/openapi/simplelite-projection.json`(已在 MiGu.Server Swagger UI 中展示)
> **OpenAPI**`Docs/openapi/simple3-projection.json`(已在 MiGu.Server Swagger UI 中展示)
---
## 1. 架构与访问路径
SimpleLite 在进程内启动 HTTP 投影服务,默认监听 `http://127.0.0.1:8222`。咪咕平台后端 **MiGu.Server** 通过 YARP 反向代理将请求转发到 SimpleLite
Simple3 在进程内启动 HTTP 投影服务,默认监听 `http://127.0.0.1:8222`。咪咕平台后端 **MiGu.Server** 通过 YARP 反向代理将请求转发到 Simple3
| 层级 | 前缀 | 说明 |
|------|------|------|
| 直连 SimpleLite | `http://127.0.0.1:8222/projection/...` | 开发调试、本机回环 |
| 直连 Simple3 | `http://127.0.0.1:8222/projection/...` | 开发调试、本机回环 |
| 经 MiGu.Server | `http://{host}:8080/api/sl/projection/...` | 平台前端/运维正式入口 |
```
@@ -23,7 +23,7 @@ SimpleLite 在进程内启动 HTTP 投影服务,默认监听 `http://127.0.0.1
MiGu.Server :8080 (/api/sl/*)
│ 追加 X-Platform-Internal-Token
SimpleLite :8222 (/projection/*)
Simple3 :8222 (/projection/*)
```
### 1.1 鉴权
@@ -31,12 +31,12 @@ SimpleLite :8222 (/projection/*)
| 场景 | 要求 |
|------|------|
| 经 MiGu.Server 访问 | 需登录 JWT`Authorization: Bearer` 或 Cookie `simple.auth.token`);YARP 自动注入 `X-Platform-Internal-Token` |
| 直连 SimpleLite | 本机回环(127.0.0.1 / ::1)默认放行;远程需携带与 MiGu.Server 共享的 `X-Platform-Internal-Token` |
| 直连 Simple3 | 本机回环(127.0.0.1 / ::1)默认放行;远程需携带与 MiGu.Server 共享的 `X-Platform-Internal-Token` |
Token 解析优先级(SimpleLite 侧):
Token 解析优先级(Simple3 侧):
1. `simple.json``platform.internalToken`
2. 环境变量 `SIMPLELITE__PLATFORM__INTERNALTOKEN`
2. 环境变量 `SIMPLE3__PLATFORM__INTERNALTOKEN`
3. `Platform.Server/data/.internal-token``MiGu.Server/data/.internal-token`
### 1.2 通用响应格式
@@ -60,8 +60,8 @@ Token 解析优先级(SimpleLite 侧):
开发环境下启动 MiGu.Server 后访问:
- **统一文档**`http://localhost:8080/swagger`(含 MiGu.Server 自有 API + SimpleLite 全部 WebApi
- SimpleLite 相关接口标签前缀为 `SimpleLite/`
- **统一文档**`http://localhost:8080/swagger`(含 MiGu.Server 自有 API + Simple3 全部 WebApi
- Simple3 相关接口标签前缀为 `Simple3/`
---
@@ -112,7 +112,7 @@ Token 解析优先级(SimpleLite 侧):
{
"id": "C01",
"name": "AGV-1",
"typeName": "SimpleLite.RCS.CarTypes.Car",
"typeName": "Simple3.RCS.CarTypes.Car",
"rawId": 1,
"x": 1200.5,
"y": 800.0,
@@ -285,7 +285,7 @@ Token 解析优先级(SimpleLite 侧):
同一车型若多个 Coder 引用不同 Fields 类型,接口会合并后取**最派生、字段最全**的类型(如 Kiva 取 `KivaTrackFields` 而非 `BasicTrackFields`)。返回的 `fields` 数组**包含继承链上全部 public 字段**(例如 `KivaTrackFields` 会同时包含 `BasicTrackFields``IOArea``Speed` 等基类字段)。
> **实现说明**:Fields 类在插件程序集中多为 `internal`;插件已通过 `InternalsVisibleTo("SimpleLite")` 向 SimpleLite 开放。**插件在 collectible ALC 中加载时**SimpleCore 特性类型与宿主不一致,须通过 `CustomAttributeData` 读取 Coder 上的 `siteFields`/`trackFields` 等 metadata(不能依赖 `GetCustomAttributes(typeof(TemplateTrackCoderSettings))`)。`StandardScene.dll` 内 Fields 由 `CoderFieldsMetadata.Describe` 在 Core 程序集内反射导出。
> **实现说明**:Fields 类在插件程序集中多为 `internal`;插件已通过 `InternalsVisibleTo("Simple3")` 向 Simple3 开放。**插件在 collectible ALC 中加载时**SimpleCore 特性类型与宿主不一致,须通过 `CustomAttributeData` 读取 Coder 上的 `siteFields`/`trackFields` 等 metadata(不能依赖 `GetCustomAttributes(typeof(TemplateTrackCoderSettings))`)。`StandardScene.dll` 内 Fields 由 `CoderFieldsMetadata.Describe` 在 Core 程序集内反射导出。
---
@@ -467,7 +467,7 @@ Token 解析优先级(SimpleLite 侧):
咪咕 Vue 前端封装位于 `Migu2.0/frontends/apps/simple-platform-vue/src/api/`
| 文件 | 对应 SimpleLite 前缀 |
| 文件 | 对应 Simple3 前缀 |
|------|----------------------|
| `reflection.ts` | `/api/sl/projection/reflection` |
| `mapEdit.ts` | `/api/sl/projection/map-edit` |
@@ -480,16 +480,16 @@ Token 解析优先级(SimpleLite 侧):
| 服务 | 默认端口 | 路径 |
|------|----------|------|
| MiGu.Server | 8080 | `/api/sl/*` → SimpleLite |
| SimpleLite 投影 API | 8222 | `/projection/*` |
| SimpleLite webVRender | 8223 | `/vr/*`(3D 嵌入 UI,非数据 API |
| MiGu.Server | 8080 | `/api/sl/*` → Simple3 |
| Simple3 投影 API | 8222 | `/projection/*` |
| Simple3 webVRender | 8223 | `/vr/*`(3D 嵌入 UI,非数据 API |
**健康检查**`GET http://localhost:8080/api/health/simplelite`
**健康检查**`GET http://localhost:8080/api/health/simple3`
**本地联调**
```powershell
# 1. 启动 SimpleLite(或登录 MiGu.Server 后自动拉起)
# 1. 启动 Simple3(或登录 MiGu.Server 后自动拉起)
# 2. 启动 MiGu.Server
# 3. 浏览器打开 http://localhost:8080/swagger
# 4. 授权 Bearer token 后测试 /api/sl/projection/cars
+66 -66
View File
@@ -6,10 +6,10 @@
`MIGU2.0` 是“迷毂 · 智能调度平台”的平台仓库,主要包含两部分:
- 平台后端:`MiGu.Server`ASP.NET Core 8 WebAPI,监听默认 `8080`,负责登录、权限、配置中心、静态前端托管、YARP 反向代理和 SimpleLite 子进程拉起。
- 平台后端:`MiGu.Server`ASP.NET Core 8 WebAPI,监听默认 `8080`,负责登录、权限、配置中心、静态前端托管、YARP 反向代理和 Simple3 子进程拉起。
- 平台前端:`frontends/apps/simple-platform-vue`Vue 3 + Vite + Pinia + Element Plus 单页应用,单工程承载管理员端 `/admin/*` 与运营端 `/monitor/*`
调度内核 `SimpleLite` 不在本仓库内,按 README 描述位于相邻仓库 `../Simple/SimpleLite`。当前仓库通过进程拉起、HTTP 代理、webVRender iframe 和本地文件读取等方式与 `SimpleLite` 协作。
调度内核 `Simple3` 不在本仓库内,按 README 描述位于相邻仓库 `../Simple/Simple3`。当前仓库通过进程拉起、HTTP 代理、webVRender iframe 和本地文件读取等方式与 `Simple3` 协作。
## 2. 技术栈总览
@@ -21,7 +21,7 @@
- 授权:ASP.NET Core Authorization Policy + 自研 RBAC。
- 代理:YARP Reverse Proxy。
- 持久化:当前为 JSON 文件持久化,主要落在 `MiGu.Server/data/`;长期蓝图中计划接入 EF Core 多数据库 Provider。
- 进程编排:`SimpleLiteLauncher` 拉起或复用 `SimpleLite.exe`
- 进程编排:`Simple3Launcher` 拉起或复用 `Simple3.exe`
### 前端
@@ -34,9 +34,9 @@
### 外部/相邻系统
- `SimpleLite.exe`:调度内核、地图/任务/车辆领域能力、Projection API、webVRender。
- `Simple3.exe`:调度内核、地图/任务/车辆领域能力、Projection API、webVRender。
- webVRender:默认 `8223`,由前端 `Workspace3D.vue` 以 iframe 方式嵌入。
- SimpleLite Projection/Web API:默认 `8222`,通过 `/api/sl/*` 由 YARP 转发。
- Simple3 Projection/Web API:默认 `8222`,通过 `/api/sl/*` 由 YARP 转发。
## 3. 顶层目录职责
@@ -64,33 +64,33 @@ flowchart LR
Server --> Auth["Auth/RBAC/JWT"]
Server --> Config["ConfigStore<br/>data/config-*.json"]
Server --> Wizard["部署向导<br/>deployment profile"]
Server --> Launcher["SimpleLiteLauncher"]
Launcher -->|登录后按 launchMode 拉起/复用| SL["SimpleLite.exe<br/>相邻仓库"]
Server -->|YARP /api/sl/*| SLApi["SimpleLite :8222"]
Server --> Launcher["Simple3Launcher"]
Launcher -->|登录后按 launchMode 拉起/复用| SL["Simple3.exe<br/>相邻仓库"]
Server -->|YARP /api/sl/*| SLApi["Simple3 :8222"]
Server -->|YARP /vr/*| VR["webVRender :8223"]
Vue -->|iframe 或 /vr 代理| VR
```
关键说明:
- `MiGu.Server` 负责托管 SPA、处理登录与配置、代理 `/api/sl/*` 到 SimpleLite
- 用户登录时可选择 `DesktopAndWeb``WebOnly`,后端转换为 SimpleLite 命令行 `--display-mode=web+local``--display-mode=web`
- `SimpleLiteLauncher` 默认 `FollowParent=false`,即 `MiGu.Server` 退出不会杀掉已启动的 SimpleLite
- 如果 `8222` 已经有 SimpleLite 在运行,后端会做 TCP + HTTP 探测并复用既有实例,避免重复拉起。
- `MiGu.Server` 负责托管 SPA、处理登录与配置、代理 `/api/sl/*` 到 Simple3
- 用户登录时可选择 `DesktopAndWeb``WebOnly`,后端转换为 Simple3 命令行 `--display-mode=web+local``--display-mode=web`
- `Simple3Launcher` 默认 `FollowParent=false`,即 `MiGu.Server` 退出不会杀掉已启动的 Simple3
- 如果 `8222` 已经有 Simple3 在运行,后端会做 TCP + HTTP 探测并复用既有实例,避免重复拉起。
- 前端的 3D/地图画布主要通过 `Workspace3D.vue` 加载 `http://localhost:8223` 或经 `/vr` 同源代理。
## 5. 文档蓝图与当前代码的差异
仓库中存在两类叙述:
- `README.md``MiGu.Server/README.md` 已经描述“用户先启动 `MiGu.Server`,登录后拉起 `SimpleLite`”这一当前实现。
- `ARCHITECTURE.md` 仍保留较多早期蓝图,例如“SimpleLite 通过 SystemMission 拉起 MiGu.Server”“双 SPA 独立托管”“SimpleShared.* 多工程”“EF Core 多 Provider”等。
- `README.md``MiGu.Server/README.md` 已经描述“用户先启动 `MiGu.Server`,登录后拉起 `Simple3`”这一当前实现。
- `ARCHITECTURE.md` 仍保留较多早期蓝图,例如“Simple3 通过 SystemMission 拉起 MiGu.Server”“双 SPA 独立托管”“SimpleShared.* 多工程”“EF Core 多 Provider”等。
当前代码落地状态:
- 已落地:`MiGu.Server` 主入口、登录拉起 SimpleLite、YARP 代理、JWT/Cookie 鉴权、RBAC、配置中心 JSON 持久化、部署向导、日志管理、地图 JSON 预览、单 Vue SPA 管理/运营双域。
- 部分落地:配置中心模型、运维白名单、SimpleLite 反射下发、webVRender 嵌入、地图编辑/监控前端能力。
- 尚未在本仓库落地:`SimpleShared.*` 工程、EF Core 多数据库持久层、SystemMission 由 SimpleLite 反向守护平台、独立 `platform-vue`/`rcsmonitor-vue` 双工程、`packages/sl-controls` 共享组件库。
- 已落地:`MiGu.Server` 主入口、登录拉起 Simple3、YARP 代理、JWT/Cookie 鉴权、RBAC、配置中心 JSON 持久化、部署向导、日志管理、地图 JSON 预览、单 Vue SPA 管理/运营双域。
- 部分落地:配置中心模型、运维白名单、Simple3 反射下发、webVRender 嵌入、地图编辑/监控前端能力。
- 尚未在本仓库落地:`SimpleShared.*` 工程、EF Core 多数据库持久层、SystemMission 由 Simple3 反向守护平台、独立 `platform-vue`/`rcsmonitor-vue` 双工程、`packages/sl-controls` 共享组件库。
## 6. 后端模块
@@ -101,14 +101,14 @@ flowchart LR
- 修正 `ContentRootPath`:直接运行 `bin/Debug/net8.0/MiGu.Server.exe` 时向上寻找 `MiGu.Server.csproj`,确保配置、data、wwwroot 路径一致。
- 默认监听:未配置 `urls` 时使用 `http://0.0.0.0:8080`
- WebRoot 自动探测:优先使用 `frontends/apps/simple-platform-vue/dist/index.html`,其次 `MiGu.Server/wwwroot/index.html`
- 注册 Controller、Swagger、CORS、JWT Bearer、Authorization Policy、YARP、ConfigStore、OpsAuditStore、HttpClient、SimpleLiteLauncher。
- 注册 Controller、Swagger、CORS、JWT Bearer、Authorization Policy、YARP、ConfigStore、OpsAuditStore、HttpClient、Simple3Launcher。
- 设置 Forwarded Headers,兼容反向代理后的 HTTPS Cookie。
- 托管静态资源和 SPA fallback。
核心关系:
- 调用 `JwtIssuer` 颁发/验签 JWT。
- 调用 `InternalTokenStore` 管理转发给 SimpleLite`X-Platform-Internal-Token`
- 调用 `InternalTokenStore` 管理转发给 Simple3`X-Platform-Internal-Token`
- YARP `sl-route` 会自动注入内部 token。
- `app.MapControllers()` 处理平台自身 API`app.MapReverseProxy()` 处理 `/api/sl/*``/vr/*`
@@ -116,7 +116,7 @@ flowchart LR
职责:
- `POST /api/auth/login`:校验用户名密码与 scope,按登录请求 `launchMode` 拉起 SimpleLite,签发 JWT 和 Cookie。
- `POST /api/auth/login`:校验用户名密码与 scope,按登录请求 `launchMode` 拉起 Simple3,签发 JWT 和 Cookie。
- `POST /api/auth/logout`:清理 Cookie。
- `GET /api/auth/me`:让前端对本地 token 进行服务端实校。
- `POST /api/auth/switch-scope`:同一用户在 `Platform``RCSMonitor` 之间切换 scope,并由服务端重新计算权限。
@@ -125,7 +125,7 @@ flowchart LR
- `RbacStore`:用户密码校验、scope 判断、有效权限计算。
- `JwtIssuer`:签发 JWT。
- `SimpleLiteLauncher`:登录后拉起或复用 SimpleLite
- `Simple3Launcher`:登录后拉起或复用 Simple3
- `ConfigStore`:读取部署画像,决定是否需要进入配置向导。
### 6.3 RBAC 权限模块:`Auth/`
@@ -136,7 +136,7 @@ flowchart LR
- `RbacModels.cs`:用户、角色、DTO。
- `PageCatalog.cs`:页面权限目录。
- `JwtIssuer.cs`JWT 颁发和验签参数。
- `InternalTokenStore.cs`:平台与 SimpleLite 内部通信 token。
- `InternalTokenStore.cs`:平台与 Simple3 内部通信 token。
职责:
@@ -182,32 +182,32 @@ flowchart LR
- 前端 `useConfigStore` 统一读写这些 section。
- 许多配置页只是不同 section 的编辑视图。
- `deployment` section 还会参与菜单裁剪与 SimpleLite 插件选择。
- `deployment` section 还会参与菜单裁剪与 Simple3 插件选择。
注意:
- 控制器类加了 `[Authorize]`,但当前 `PUT` 也是普通 `[Authorize]`,注释中曾提到 PlatformScope,实际代码没有强制仅平台 scope 可写。
- 当前 JSON 文件属于占位持久层;长期架构文档计划迁移到 EF Core。
### 6.5 SimpleLite 拉起与诊断:`Launcher/`
### 6.5 Simple3 拉起与诊断:`Launcher/`
主要文件:
- `SimpleLiteLauncher.cs`:拉起、复用、重启、诊断、插件选择联动。
- `SimpleLiteOptions.cs`:绑定 `appsettings.json:SimpleLite`
- `SimpleLiteBuildSync.cs`:辅助同步/探测 SimpleLite 构建与 API。
- `Simple3Launcher.cs`:拉起、复用、重启、诊断、插件选择联动。
- `Simple3Options.cs`:绑定 `appsettings.json:Simple3`
- `Simple3BuildSync.cs`:辅助同步/探测 Simple3 构建与 API。
职责:
- 将业务启动模式翻译为 SimpleLite 内部 display mode
- 将业务启动模式翻译为 Simple3 内部 display mode
- `WebOnly` -> `--display-mode=web`
- `DesktopAndWeb` -> `--display-mode=web+local`
- 拉起前检查 `8222` 是否已有 SimpleLite,并用 `/projection/cars` 确认不是其他进程占用端口。
- 查找 `SimpleLite.exe`:优先配置路径,再尝试相邻仓库、发布包同目录等候选路径。
- 默认 Windows 下通过 `cmd /c start` 脱离父进程,避免关闭平台后端时带走 SimpleLite
- 拉起前检查 `8222` 是否已有 Simple3,并用 `/projection/cars` 确认不是其他进程占用端口。
- 查找 `Simple3.exe`:优先配置路径,再尝试相邻仓库、发布包同目录等候选路径。
- 默认 Windows 下通过 `cmd /c start` 脱离父进程,避免关闭平台后端时带走 Simple3
- `FollowParent=true` 时使用 Windows JobObject 绑定父子进程。
- 写入 `plugins/active-scenes.json`,把部署向导选出的导航场景传给 SimpleLite
- `GET /api/health/simplelite` 返回配置、路径解析、端口、版本 API 可用性等诊断信息。
- 写入 `plugins/active-scenes.json`,把部署向导选出的导航场景传给 Simple3
- `GET /api/health/simple3` 返回配置、路径解析、端口、版本 API 可用性等诊断信息。
### 6.6 运维白名单与审计:`OpsController` + `OpsAuditStore`
@@ -217,7 +217,7 @@ flowchart LR
- 二次校验 JWT 的 `ops` claim。
- 支持 `IdempotencyKey`,避免重复点击/重试重复下发。
- `monitor.note.write` 仅写审计。
- 其他运维动作需要在 `appsettings.json:Ops:Dispatch` 显式配置 `opCode -> "kind:Method"` 后,才会转发到 SimpleLite 反射 API。
- 其他运维动作需要在 `appsettings.json:Ops:Dispatch` 显式配置 `opCode -> "kind:Method"` 后,才会转发到 Simple3 反射 API。
- 审计记录由 `OpsAuditStore` 落盘,重启后不丢。
默认白名单:
@@ -229,9 +229,9 @@ flowchart LR
重要设计:
- 未配置映射时不会“假成功”,而是返回 `ok=false` 并说明“已记录审计但未下发”。
- 真实下发路径为 SimpleLite 反射执行接口:`/projection/reflection/execute/{kind}/{id}/{method}`
- 真实下发路径为 Simple3 反射执行接口:`/projection/reflection/execute/{kind}/{id}/{method}`
### 6.7 投影与 SimpleLite 代理
### 6.7 投影与 Simple3 代理
模块组成:
@@ -249,8 +249,8 @@ flowchart LR
职责:
- `GET /api/health`:平台后端健康检查,返回启动时间、运行时长、端口规划。
- `GET /api/health/simplelite`SimpleLite 路径和端口诊断。
- `POST /api/health/simplelite/restart-for-update`:授权用户可触发关闭 SimpleLite、同步最新 DLL 并重新拉起。
- `GET /api/health/simple3`Simple3 路径和端口诊断。
- `POST /api/health/simple3/restart-for-update`:授权用户可触发关闭 Simple3、同步最新 DLL 并重新拉起。
### 6.9 部署配置向导:`WizardController` + `DeploymentCatalog`
@@ -258,7 +258,7 @@ flowchart LR
- 首次部署时收集导航方式、功能模块、业务场景。
- 保存后写入 `deployment` 配置 section。
- 将导航方式映射为 SimpleLite 场景插件 ID,并调用 `SimpleLiteLauncher.WriteActiveScenes()` 写入 `plugins/active-scenes.json`
- 将导航方式映射为 Simple3 场景插件 ID,并调用 `Simple3Launcher.WriteActiveScenes()` 写入 `plugins/active-scenes.json`
- 按部署画像裁剪页面,例如当前 `wms` 模块会点亮 `admin-config-location`
对外 API
@@ -273,7 +273,7 @@ flowchart LR
职责:
- 读取 SimpleLite 工作目录下的 `log/` 文件。
- 读取 Simple3 工作目录下的 `log/` 文件。
- 支持概览、文件列表、目录浏览、日志条目分页、合订本、日志分析、原文查看、下载。
- 日志行按 `Diagnosis.Log` 的格式解析,支持标签聚合和数值字段识别。
@@ -290,14 +290,14 @@ flowchart LR
职责:
- 先调用 SimpleLite `/projection/map-edit/maps` 获取地图目录。
- 先调用 Simple3 `/projection/map-edit/maps` 获取地图目录。
- 再在本机读取对应地图 JSON 原文,供平台地图管理右侧预览。
- 对地图名做非法字符检查,并校验最终路径必须落在地图目录下。
关系:
- 依赖 SimpleLite 与 MiGu.Server 同机部署。
- 依赖 `SimpleLiteOptions.ProjectionPort``InternalTokenStore`
- 依赖 Simple3 与 MiGu.Server 同机部署。
- 依赖 `Simple3Options.ProjectionPort``InternalTokenStore`
## 7. 前端模块
@@ -328,7 +328,7 @@ flowchart LR
职责:
- 登录页采集用户名、密码、scope 和 SimpleLite 启动模式。
- 登录页采集用户名、密码、scope 和 Simple3 启动模式。
- 登录成功后保存 token、用户、scope、runMode、effectivePermissions 到 Pinia 与 localStorage。
- `auth.validate()` 通过 `/api/auth/me` 处理服务端重启、JWT secret 重生导致的旧 token 失效问题。
- `switchScope()` 不在客户端伪造权限,而是请求后端重新签发 token 和权限。
@@ -346,7 +346,7 @@ flowchart LR
- `api/config.ts`:配置中心读写。
- `api/projection.ts`:站点、路径、车辆、任务投影。
- `api/ops.ts`:运维白名单执行和审计。
- `api/reflection.ts`SimpleLite 反射对象、字段、方法执行。
- `api/reflection.ts`Simple3 反射对象、字段、方法执行。
- `api/mapEdit.ts`:地图编辑相关 API。
- `api/wizard.ts`:部署向导。
- `api/logs.ts`:日志管理。
@@ -354,7 +354,7 @@ flowchart LR
关系:
- 所有平台 API 都以 `/api` 为 baseURL。
- `/api/sl/*` 由 MiGu.Server 反代给 SimpleLite
- `/api/sl/*` 由 MiGu.Server 反代给 Simple3
- 开发环境可通过 `VITE_USE_MOCK=true` 使用前端 mock,但反射、工作台等部分 API 仍要求真实后端。
### 7.4 Shell、主题和权限控制组件
@@ -418,7 +418,7 @@ flowchart LR
- `useHistory`:撤销/重做命令栈。
- `useSelection`:对象选择状态。
- `useAlignment``useBatchGenerate``useClipboard`:编辑辅助能力。
- `mapEditApi``reflectionApi`:与 SimpleLite 的地图编辑和反射 API 通信。
- `mapEditApi``reflectionApi`:与 Simple3 的地图编辑和反射 API 通信。
#### 地图监控
@@ -431,7 +431,7 @@ flowchart LR
- `VehicleMonitorPanel`:车辆监控台。
- `MissionListPanel`:任务列表。
- `MonitorSelectionPanel`:选中对象详情和动作。
- `useProjectionStream`:订阅 SimpleLite/平台 SSE 事件。
- `useProjectionStream`:订阅 Simple3/平台 SSE 事件。
### 7.7 运营端页面:`/monitor/*`
@@ -469,13 +469,13 @@ flowchart LR
- 选择导航方式:磁导航、二维码导航、激光导航。
- 选择功能模块:当前包含 WMS、PTL。
- 选择业务场景模板。
- 预览将激活的 SimpleLite 场景插件,如 `scene.magnetic``scene.qrcode``scene.laser`
- 预览将激活的 Simple3 场景插件,如 `scene.magnetic``scene.qrcode``scene.laser`
保存后:
- 调用 `PUT /api/wizard/profile`
- 后端写入 `deployment` section。
- 后端写入 SimpleLite `plugins/active-scenes.json`
- 后端写入 Simple3 `plugins/active-scenes.json`
- 前端调用 `auth.markWizardDone()`,跳转到对应首页。
## 8. 数据与持久化
@@ -491,9 +491,9 @@ flowchart LR
这些文件采用 JSON 持久化,部分写入走 `AtomicFile`,损坏时会备份后回退默认值。
### SimpleLite 侧文件
### Simple3 侧文件
MiGu.Server 会直接或间接使用 SimpleLite 工作目录:
MiGu.Server 会直接或间接使用 Simple3 工作目录:
- `plugins/active-scenes.json`:部署向导写入,控制内核场景插件选择。
- `log/**/*.log`:日志管理模块读取并分析。
@@ -511,7 +511,7 @@ MiGu.Server 会直接或间接使用 SimpleLite 工作目录:
- `/api/maps/{name}/content`:地图 JSON 预览。
- `/api/health/*`:健康检查。
### 平台后端到 SimpleLite
### 平台后端到 Simple3
- `/api/sl/*` -> YARP -> `http://127.0.0.1:8222/*`
- `/vr/*` -> YARP -> `http://127.0.0.1:8223/*`
@@ -533,7 +533,7 @@ flowchart TB
RBAC["RbacStore / JwtIssuer"]
Config["ConfigStore"]
Wizard["WizardController"]
Launcher["SimpleLiteLauncher"]
Launcher["Simple3Launcher"]
Ops["OpsController"]
Logs["LogsController"]
Maps["MapsContentController"]
@@ -551,8 +551,8 @@ flowchart TB
WizardVue["WizardView.vue"]
end
subgraph SimpleLite["SimpleLite 相邻仓库"]
SLExe["SimpleLite.exe"]
subgraph Simple3["Simple3 相邻仓库"]
SLExe["Simple3.exe"]
Projection["Projection API :8222"]
VRender["webVRender :8223"]
LogsFile["log/**/*.log"]
@@ -591,14 +591,14 @@ flowchart TB
## 11. 典型业务链路
### 11.1 登录并启动 SimpleLite
### 11.1 登录并启动 Simple3
1. 用户打开 `/login`
2. 前端提交用户名、密码、scope、launchMode 到 `/api/auth/login`
3. `AuthController``RbacStore.VerifyCredentials()` 校验密码。
4. `AuthController` 检查用户是否可使用请求的 scope。
5. `AuthController``SimpleLiteLauncher.MaybeStart()`
6. `SimpleLiteLauncher` 复用或拉起 `SimpleLite.exe`
5. `AuthController``Simple3Launcher.MaybeStart()`
6. `Simple3Launcher` 复用或拉起 `Simple3.exe`
7. 后端计算有效权限,签发 JWT,写 httpOnly Cookie。
8. 前端保存登录态,并按 `needsWizard` 跳转 `/wizard` 或业务首页。
@@ -608,7 +608,7 @@ flowchart TB
2. 守卫确保 scope 为 `Platform`,并检查 `admin-map-editor` 页面权限。
3. `MapEditorView` 加载 `Workspace3D`,以 `canvasOnly=true` 打开 `8223` webVRender。
4. Vue 侧顶栏、工具栏、属性面板接管编辑 UI。
5. 创建、删除、批量生成、字段修改等操作通过 `mapEditApi``reflectionApi` 调用 SimpleLite
5. 创建、删除、批量生成、字段修改等操作通过 `mapEditApi``reflectionApi` 调用 Simple3
6. `useHistory` 记录可回滚命令,部分操作会用快照恢复。
### 11.3 运营端执行运维动作
@@ -618,7 +618,7 @@ flowchart TB
3. 用户确认后调用 `/api/sl/ops/execute`
4. `OpsController` 校验白名单、JWT 操作码、幂等键。
5. 若是备注,则只写审计;若是内核动作,读取 `Ops:Dispatch` 映射。
6. 已配置映射时调用 SimpleLite 反射 execute;未配置映射时返回未下发。
6. 已配置映射时调用 Simple3 反射 execute;未配置映射时返回未下发。
7. 审计落盘,前端显示执行结果。
### 11.4 首次部署配置向导
@@ -628,7 +628,7 @@ flowchart TB
3. 前端加载 `/api/wizard/options``/api/wizard/profile`
4. 用户选择导航方式、模块、场景。
5. 保存后端写 `deployment` section。
6. 后端将导航方式转换为场景插件 ID,并写 SimpleLite `plugins/active-scenes.json`
6. 后端将导航方式转换为场景插件 ID,并写 Simple3 `plugins/active-scenes.json`
7. 后续登录时,`AuthController.BuildSession()` 会按部署画像裁剪可见页面。
## 12. 构建与运行
@@ -645,7 +645,7 @@ dotnet run
- 平台:`http://localhost:8080/login`
- 健康检查:`http://localhost:8080/api/health`
- SimpleLite 诊断:`http://localhost:8080/api/health/simplelite`
- Simple3 诊断:`http://localhost:8080/api/health/simple3`
- Swagger:开发环境 `/swagger`
### 前端开发
@@ -679,7 +679,7 @@ pnpm dev
- 架构文档与当前实现存在历史差异,后续应更新 `ARCHITECTURE.md` 中关于启动归属、双 SPA 拆分、共享组件库和持久层状态的描述。
- `ConfigController.Put` 当前只要求登录,未按注释限制 `PlatformScope`,如果运营 scope 可调用配置写入,需要确认是否符合预期。
- 前端登录页仍有 Mock 文案,真实 API 模式下会误导用户。
- `ServiceStatusView.vue` 目前展示大量 Mock 状态,尚未接入 `/api/health``/api/health/simplelite` 的真实数据。
- `ServiceStatusView.vue` 目前展示大量 Mock 状态,尚未接入 `/api/health``/api/health/simple3` 的真实数据。
- `ProjectionController` 是本地 Mock,占位意义大于生产意义,真实链路依赖 `/api/sl/*`
- 运维白名单默认没有 `Ops:Dispatch` 映射,未配置前只会审计,不会真实下发内核动作。
- 多数据库、EF Core、SystemMission、独立前端包和共享组件库仍是蓝图,不应被外部交付文档表述为已完成。
@@ -687,8 +687,8 @@ pnpm dev
## 14. 快速索引
- 后端启动:`MiGu.Server/Program.cs`
- 登录和启动 SimpleLite`MiGu.Server/Controllers/AuthController.cs`
- SimpleLite 拉起器:`MiGu.Server/Launcher/SimpleLiteLauncher.cs`
- 登录和启动 Simple3`MiGu.Server/Controllers/AuthController.cs`
- Simple3 拉起器:`MiGu.Server/Launcher/Simple3Launcher.cs`
- 配置中心:`MiGu.Server/Configs/ConfigStore.cs`
- 权限中心:`MiGu.Server/Auth/RbacStore.cs`
- 权限 API`MiGu.Server/Controllers/RbacController.cs`
@@ -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;
}
View File
@@ -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; } = "";
@@ -1,9 +1,8 @@
namespace MiGu.Server.Fleet;
namespace MiGu.DB.Domains.Fleet;
/// <summary>
/// CDM 搬运任务的平台侧快照(表 cdm_tasks)。
/// 以任务 Id 为主键;SimpleLite/StandardScene 把终态任务从自身 JSON 里删除,这里则永久保留=完整历史
/// 且 SimpleLite 关闭后平台仍可从本表读取最近快照。
/// 以任务 Id 为主键;Simple3/StandardScene 把终态任务从自身 JSON 里删除,这里则永久保留=完整历史
/// </summary>
public sealed class CdmTaskRecord
{
@@ -0,0 +1,61 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace MiGu.DB.Domains.Fleet;
public sealed class CdmTaskRecordConfiguration : IEntityTypeConfiguration<CdmTaskRecord>
{
public void Configure(EntityTypeBuilder<CdmTaskRecord> e)
{
e.ToTable("cdm_tasks");
e.HasKey(x => x.Id);
e.Property(x => x.Id).HasColumnName("id").HasMaxLength(64);
e.Property(x => x.TaskId).HasColumnName("task_id").HasMaxLength(128).IsRequired(false);
e.Property(x => x.MissionId).HasColumnName("mission_id");
e.Property(x => x.MissionName).HasColumnName("mission_name").HasMaxLength(128);
e.Property(x => x.MissionTypeName).HasColumnName("mission_type").HasMaxLength(128);
e.Property(x => x.SrcSiteId).HasColumnName("src_site_id");
e.Property(x => x.SrcLabel).HasColumnName("src_label").HasMaxLength(256);
e.Property(x => x.DstSiteId).HasColumnName("dst_site_id");
e.Property(x => x.DstLabel).HasColumnName("dst_label").HasMaxLength(256);
e.Property(x => x.Status).HasColumnName("status").HasMaxLength(32);
e.Property(x => x.StatusCode).HasColumnName("status_code").HasMaxLength(32);
e.Property(x => x.CarId).HasColumnName("car_id").IsRequired(false);
e.Property(x => x.CarName).HasColumnName("car_name").HasMaxLength(128).IsRequired(false);
e.Property(x => x.Priority).HasColumnName("priority");
e.Property(x => x.CreateTime).HasColumnName("create_time").HasMaxLength(40).IsRequired(false);
e.Property(x => x.StartTime).HasColumnName("start_time").HasMaxLength(40).IsRequired(false);
e.Property(x => x.FinishTime).HasColumnName("finish_time").HasMaxLength(40).IsRequired(false);
e.Property(x => x.StuckReason).HasColumnName("stuck_reason").HasMaxLength(512).IsRequired(false);
e.Property(x => x.Overdue).HasColumnName("overdue");
e.Property(x => x.FirstSeenAt).HasColumnName("first_seen_at");
e.Property(x => x.LastSeenAt).HasColumnName("last_seen_at");
e.HasIndex(x => x.StatusCode);
e.HasIndex(x => x.CreateTime);
}
}
public sealed class VehicleAlarmRecordConfiguration : IEntityTypeConfiguration<VehicleAlarmRecord>
{
public void Configure(EntityTypeBuilder<VehicleAlarmRecord> e)
{
e.ToTable("vehicle_alarms");
e.HasKey(x => x.Id);
e.Property(x => x.Id).HasColumnName("id").HasMaxLength(36);
e.Property(x => x.CarId).HasColumnName("car_id");
e.Property(x => x.CarName).HasColumnName("car_name").HasMaxLength(128);
e.Property(x => x.Info).HasColumnName("info").HasColumnType("text");
e.Property(x => x.Level).HasColumnName("level");
e.Property(x => x.Status).HasColumnName("status").HasMaxLength(16);
e.Property(x => x.FirstAt).HasColumnName("first_at");
e.Property(x => x.LastAt).HasColumnName("last_at");
e.Property(x => x.ResolvedAt).HasColumnName("resolved_at").IsRequired(false);
e.Property(x => x.DurationSecs).HasColumnName("duration_secs").IsRequired(false);
e.Property(x => x.Acknowledged).HasColumnName("acknowledged");
e.Property(x => x.AcknowledgedAt).HasColumnName("acknowledged_at").IsRequired(false);
e.Property(x => x.AcknowledgedBy).HasColumnName("acknowledged_by").HasMaxLength(128).IsRequired(false);
e.HasIndex(x => new { x.CarId, x.Status });
e.HasIndex(x => x.Status);
e.HasIndex(x => x.FirstAt);
}
}
@@ -1,10 +1,8 @@
namespace MiGu.Server.Fleet;
namespace MiGu.DB.Domains.Fleet;
/// <summary>
/// 车辆报警的平台侧记录(表 vehicle_alarms)。
/// SimpleLite 只在 SSE/状态里给出「当前是否报警 + 文案」,无历史;平台按车对帐:
/// 出现报警→开一条 active 记录,报警文案变化→更新,报警消失→置为 cleared 并记录恢复时间/持续时长。
/// 永不删除=完整历史,重启/刷新不丢,SimpleLite 离线也可查。
/// 出现报警→开一条 active 记录;文案/级别变化→先 clear 再建新 active;消失→cleared。
/// </summary>
public sealed class VehicleAlarmRecord
{
@@ -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 UPDATESqlite)。
/// <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 空值补 Flatraw 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>
/// 运输域状态/触发类型枚举。存储规则同 WmsHasConversion&lt;string&gt;,成员名 = 列值。
/// </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);
}
}
}
+170
View File
@@ -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);
}
}
+174
View File
@@ -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 = "默认仓库";
}
+65
View File
@@ -0,0 +1,65 @@
namespace MiGu.DB.Domains.Wms;
/// <summary>
/// WMS 状态/类型枚举。列以字符串存储(见约定 HasConversion&lt;string&gt;),成员名即库内合法值。
/// </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
}
+107
View File
@@ -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>();
}
+25
View File
@@ -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);
}
}
+167
View File
@@ -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);
}
}
+46
View File
@@ -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));
}
+116
View File
@@ -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;
}
});
}
}
+221
View File
@@ -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/MySqlnamespace 同步修改
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
+30
View File
@@ -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>
View File
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
+56
View File
@@ -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)
## 生成 MigrationSqlite
完整步骤、命令说明与排错见 **[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.
+6
View File
@@ -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
+12 -12
View File
@@ -3,24 +3,24 @@
namespace MiGu.Server.Auth;
/// <summary>
/// MiGu.Server ↔ SimpleLite 8222 之间的 **内部共享 token**。
/// MiGu.Server ↔ Simple3 8222 之间的 **内部共享 token**。
///
/// 设计动机:SimpleLite 8222 的 EmbedIO WebApiReflectionApi / PersistenceApi / MapEditApi
/// 历史上完全无鉴权,远程访问 = 接管调度内核。为不在 SimpleLite 侧实现完整 JWT 验签
/// (减少 SimpleLite 复杂度),采用一个轻量约定:
/// 设计动机:Simple3 8222 的 EmbedIO WebApiReflectionApi / PersistenceApi / MapEditApi
/// 历史上完全无鉴权,远程访问 = 接管调度内核。为不在 Simple3 侧实现完整 JWT 验签
/// (减少 Simple3 复杂度),采用一个轻量约定:
///
/// - <b>本机回环</b>127.0.0.1 / ::1SimpleLite 直接放行(开发机直连不受影响);
/// - <b>本机回环</b>127.0.0.1 / ::1Simple3 直接放行(开发机直连不受影响);
/// - <b>其它来源</b>必须携带 <c>X-Platform-Internal-Token</c> header
/// - MiGu.Server YARP 反代 <c>/api/sl/*</c> 到 SimpleLite 8222 时,YARP transform
/// - MiGu.Server YARP 反代 <c>/api/sl/*</c> 到 Simple3 8222 时,YARP transform
/// 会自动追加该 header
/// - SimpleLite 端通过 <c>simple.json:platform.internalToken</c> 或环境变量
/// <c>SIMPLELITE__PLATFORM__INTERNALTOKEN</c> 配置同一个 token;两端不一致即拒绝。
/// - Simple3 端通过 <c>simple.json:platform.internalToken</c> 或环境变量
/// <c>SIMPLE3__PLATFORM__INTERNALTOKEN</c> 配置同一个 token;两端不一致即拒绝。
///
/// Token 来源优先级:
/// 1) appsettings.json:Internal:Token 显式配置(生产推荐:strong, length ≥ 32
/// 2) 环境变量 PLATFORM__INTERNAL__TOKEN
/// 3) 兜底:进程随机 64 字节 base64,并在日志告警 + 写入 <c>data/.internal-token</c>
/// 文件供本机 SimpleLite 读取(同机部署常见场景)
/// 文件供本机 Simple3 读取(同机部署常见场景)
/// </summary>
public sealed class InternalTokenStore
{
@@ -39,7 +39,7 @@ public sealed class InternalTokenStore
return;
}
// 兜底:进程随机 + 落地到 data/.internal-token 便于同机 SimpleLite 读取
// 兜底:进程随机 + 落地到 data/.internal-token 便于同机 Simple3 读取
var dataDir = Path.Combine(env.ContentRootPath, "data");
Directory.CreateDirectory(dataDir);
var file = Path.Combine(dataDir, ".internal-token");
@@ -71,12 +71,12 @@ public sealed class InternalTokenStore
PersistedFilePath = file;
logger.LogWarning(
"Internal token 未配置 —— 已生成进程随机值并写入 {File}(重启后保留)。" +
"若 MiGu.Server 与 SimpleLite 不在同一台机器,请把同一个值写入 SimpleLite 端 simple.json:platform.internalToken。",
"若 MiGu.Server 与 Simple3 不在同一台机器,请把同一个值写入 Simple3 端 simple.json:platform.internalToken。",
file);
}
catch (Exception ex)
{
logger.LogError(ex, "internal token 落盘失败 —— 远程 SimpleLite 调用将无法通过鉴权(每次重启 token 都不同)。");
logger.LogError(ex, "internal token 落盘失败 —— 远程 Simple3 调用将无法通过鉴权(每次重启 token 都不同)。");
}
}
}
+134 -18
View File
@@ -29,9 +29,14 @@ public static class PageCatalog
{
// ── 管理端 / Platform:概览 ──
new("admin-dashboard", "总览", "概览", ScopePlatform),
new("admin-setup", "初始配置", "概览", ScopePlatform),
new("admin-map-monitor", "地图监控", "概览", ScopePlatform),
new("admin-playback", "调度回放", "概览", ScopePlatform),
new("admin-tasks", "任务管理", "概览", ScopePlatform),
new("admin-alarms", "报警管理", "概览", ScopePlatform),
new("admin-ops-log", "运维记录", "概览", ScopePlatform),
new("admin-notes", "运维备注", "概览", ScopePlatform),
new("admin-logs", "日志管理", "概览", ScopePlatform),
// ── 管理端 / Platform:设计与编排 ──
new("admin-maps", "地图管理", "设计与编排", ScopePlatform),
@@ -42,23 +47,25 @@ public static class PageCatalog
new("admin-processes", "进程管理", "设计与编排", ScopePlatform),
new("admin-scripts", "脚本管理", "设计与编排", ScopePlatform),
new("admin-task-templates", "任务编排", "设计与编排", ScopePlatform),
new("admin-wcs-template-proto", "WCS模板原型", "设计与编排", ScopePlatform),
new("admin-simple-fields", "字段管理", "设计与编排", ScopePlatform),
// ── 管理端 / Platform:数据中心(scene.signal,选 SPS / Pack 时显示) ──
new("admin-data-center", "数据中心", "数据中心", ScopePlatform),
// ── 管理端 / Platform:平台配置中心(聚合页,每个 Key 对齐前端聚合路由 route.name) ──
new("admin-config-strategy", "调度策略", "平台配置中心", ScopePlatform),
new("admin-vehicle-hub", "车辆运维", "平台配置中心", ScopePlatform),
new("admin-config-facility", "设备接入", "平台配置中心", ScopePlatform),
new("admin-config-warehouse", "库位管理", "平台配置中心", ScopePlatform),
new("admin-config-business", "业务与集成", "平台配置中心", ScopePlatform),
new("admin-config-ops-center", "运维与回放", "平台配置中心", ScopePlatform),
new("admin-config-system-center", "系统与权限", "平台配置中心", ScopePlatform),
new("admin-config-ops-center", "监控配置", "平台配置中心", ScopePlatform),
new("admin-config-system", "系统配置", "平台配置中心", ScopePlatform),
new("admin-config-auth", "权限与角色", "平台配置中心", ScopePlatform),
// ── 运营端 / RCSMonitor ──
new("monitor-dashboard", "运营总览", "运营监控", ScopeMonitor),
new("monitor-vehicle-hub", "车辆运维", "运营监控", ScopeMonitor),
new("monitor-map", "地图监控", "运营监控", ScopeMonitor),
new("monitor-ops", "运维操作", "运营监控", ScopeMonitor),
new("monitor-notes", "运备注", "运营监控", ScopeMonitor),
new("monitor-ops", "运维记录", "运营监控", ScopeMonitor),
new("monitor-notes", "运备注", "运营监控", ScopeMonitor),
};
private static readonly HashSet<string> _keys =
@@ -71,23 +78,79 @@ public static class PageCatalog
["admin-config-vehicle"] = "admin-vehicle-hub",
["admin-config-fleet"] = "admin-vehicle-hub",
// 会话 16:平台配置中心入口按业务收敛为 6 个聚合页,旧 Key 迁移到对应聚合页 Key。
["admin-config-routing"] = "admin-config-strategy",
["admin-config-task"] = "admin-config-strategy",
["admin-config-traffic"] = "admin-config-strategy",
["admin-config-charge"] = "admin-config-strategy",
["admin-config-device"] = "admin-config-facility",
["admin-config-location"] = "admin-config-warehouse",
["admin-config-integrations"] = "admin-config-business",
["admin-config-scenario"] = "admin-config-business",
["admin-config-widget"] = "admin-config-business",
["admin-playback"] = "admin-config-ops-center",
["admin-config-ops"] = "admin-config-ops-center",
["admin-config-logs"] = "admin-config-ops-center",
["admin-config-logs"] = "admin-logs",
["admin-config-map-monitor"] = "admin-config-ops-center",
["admin-config-system"] = "admin-config-system-center",
["admin-config-auth"] = "admin-config-system-center",
["admin-config-system-center"] = "admin-config-system",
// 运营总览页已下线:旧角色勾选迁到地图监控,避免权限清单里出现幽灵页面。
["monitor-dashboard"] = "monitor-map",
["admin-data-center-stations"] = "admin-data-center",
["admin-data-center-docks"] = "admin-data-center",
["admin-data-center-handshake"] = "admin-data-center",
["admin-data-center-release"] = "admin-data-center",
["admin-data-center-mag-control"] = "admin-data-center",
};
/// <summary>
/// 管理端页面 → 运营端对应页面。给运营角色勾页时,只能从「当前管理员已有的管理端页」映射出来。
/// 无对应运营页的管理端入口(如任务/报警)不出现在映射里。
/// </summary>
public static readonly IReadOnlyDictionary<string, string> PlatformToMonitor =
new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
{
["admin-map-monitor"] = "monitor-map",
["admin-vehicle-hub"] = "monitor-vehicle-hub",
["admin-ops-log"] = "monitor-ops",
["admin-notes"] = "monitor-notes",
};
/// <summary>仅运营端有的页面:不依赖管理端对应页,始终可授给运营角色。</summary>
public static readonly IReadOnlyList<string> MonitorOnlyPages =
new[] { "monitor-ops", "monitor-notes" };
/// <summary>
/// 把角色里勾选的页面展开到指定登录域:勾了运营监控的「运维记录」时,
/// 管理账号/通用角色进管理壳也能拿到对应的 admin-ops-log。
/// </summary>
public static HashSet<string> ExpandKeysForScope(IEnumerable<string> keys, string scope)
{
var scopeKeys = KeysForScope(scope).ToHashSet(StringComparer.OrdinalIgnoreCase);
var set = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
foreach (var raw in keys ?? Array.Empty<string>())
{
if (string.Equals(raw, Wildcard, StringComparison.OrdinalIgnoreCase))
{
foreach (var k in scopeKeys) set.Add(k);
return set;
}
var key = NormalizeKey(raw);
if (scopeKeys.Contains(key)) set.Add(key);
var counterpart = CounterpartForScope(key, scope);
if (counterpart != null && scopeKeys.Contains(counterpart)) set.Add(counterpart);
}
return set;
}
/// <summary>管理页 ↔ 运营页互认:monitor-ops → admin-ops-log(当前域是 Platform 时)。</summary>
public static string? CounterpartForScope(string key, string targetScope)
{
if (string.Equals(targetScope, ScopePlatform, StringComparison.OrdinalIgnoreCase))
{
foreach (var kv in PlatformToMonitor)
{
if (string.Equals(kv.Value, key, StringComparison.OrdinalIgnoreCase))
return kv.Key;
}
return null;
}
if (string.Equals(targetScope, ScopeMonitor, StringComparison.OrdinalIgnoreCase)
&& PlatformToMonitor.TryGetValue(key, out var monitorKey))
return monitorKey;
return null;
}
/// <summary>判断页面 Key 是否合法(用于角色保存时过滤掉脏数据 / 已下线页面)。</summary>
public static bool IsValidKey(string key) => _keys.Contains(key);
@@ -100,4 +163,57 @@ public static class PageCatalog
All.Where(p => string.Equals(p.Scope, scope, StringComparison.OrdinalIgnoreCase))
.Select(p => p.Key)
.ToList();
/// <summary>当前管理员在管理端拥有的页面 → 可授给运营端角色的页面集合。</summary>
public static HashSet<string> GrantableMonitorPages(IEnumerable<string>? actorPlatformPages)
{
var set = new HashSet<string>(MonitorOnlyPages, StringComparer.OrdinalIgnoreCase);
var list = (actorPlatformPages ?? Array.Empty<string>()).ToList();
if (list.Contains(Wildcard, StringComparer.OrdinalIgnoreCase)
|| HasAllKeys(list, KeysForScope(ScopePlatform)))
{
foreach (var k in KeysForScope(ScopeMonitor)) set.Add(k);
return set;
}
foreach (var p in list)
{
var key = NormalizeKey(p);
if (PlatformToMonitor.TryGetValue(key, out var monitorKey) && IsValidKey(monitorKey))
set.Add(monitorKey);
}
return set;
}
/// <summary>当前管理员可授给管理端角色的页面(不能超权:只能授自己已有的)。</summary>
public static HashSet<string> GrantablePlatformPages(IEnumerable<string>? actorPlatformPages)
{
var list = (actorPlatformPages ?? Array.Empty<string>()).ToList();
var all = KeysForScope(ScopePlatform);
if (list.Contains(Wildcard, StringComparer.OrdinalIgnoreCase) || HasAllKeys(list, all))
return all.ToHashSet(StringComparer.OrdinalIgnoreCase);
return list
.Select(NormalizeKey)
.Where(k => IsValidKey(k) && all.Contains(k, StringComparer.OrdinalIgnoreCase))
.ToHashSet(StringComparer.OrdinalIgnoreCase);
}
/// <summary>按角色归属域,算出当前管理员可勾选的页面 Key。</summary>
public static HashSet<string> GrantablePagesForRoleScope(
string roleScope, IEnumerable<string>? actorPlatformPages)
{
if (string.Equals(roleScope, ScopeMonitor, StringComparison.OrdinalIgnoreCase))
return GrantableMonitorPages(actorPlatformPages);
if (string.Equals(roleScope, ScopePlatform, StringComparison.OrdinalIgnoreCase))
return GrantablePlatformPages(actorPlatformPages);
// 通用域:管理端可授 + 运营端可授
var set = GrantablePlatformPages(actorPlatformPages);
set.UnionWith(GrantableMonitorPages(actorPlatformPages));
return set;
}
private static bool HasAllKeys(IEnumerable<string> have, IEnumerable<string> all)
{
var set = have.ToHashSet(StringComparer.OrdinalIgnoreCase);
return all.All(k => set.Contains(k));
}
}
+71 -9
View File
@@ -16,7 +16,7 @@ namespace MiGu.Server.Auth;
///
/// 首次启动(rbac.json 不存在)时 seed 两个内置账号:
/// admin(超级管理员,scope=*,全部页面 / 操作)
/// ops (运营人员,scope=RCSMonitor,运营页 + 运维操作码)
/// ops (运营人员,scope=RCSMonitor,运营监控页 + 运维操作码)
/// 初始密码取 appsettings <c>Auth:Users:{name}:Password</c>,缺省 admin/ops(开发弱口令,生产须改)。
/// </summary>
public sealed class RbacStore
@@ -58,9 +58,14 @@ public sealed class RbacStore
var snap = JsonSerializer.Deserialize<RbacSnapshot>(json, _jsonOpts);
if (snap is { Users.Count: > 0 })
{
_snapshot = Normalize(snap);
_snapshot = Normalize(snap, out var migrated);
_logger.LogInformation("RBAC 从 {File} 载入:{Users} 用户 / {Roles} 角色。",
_file, _snapshot.Users.Count, _snapshot.Roles.Count);
if (migrated)
{
Persist();
_logger.LogInformation("RBAC 已将下线页面 Key 迁移并写回 {File}。", _file);
}
return;
}
_logger.LogWarning("RBAC 文件 {File} 内容为空或无用户,回退到默认 seed。", _file);
@@ -82,21 +87,30 @@ public sealed class RbacStore
}
/// <summary>清洗加载结果:补默认、去重、过滤非法页面 Key,保证内置角色存在。</summary>
private static RbacSnapshot Normalize(RbacSnapshot snap)
/// <param name="migrated">任一角色页面 Key 被别名替换 / 丢弃 / 补齐时为 true,调用方应落盘。</param>
private static RbacSnapshot Normalize(RbacSnapshot snap, out bool migrated)
{
migrated = false;
snap.Roles ??= new();
snap.Users ??= new();
foreach (var r in snap.Roles)
{
r.Pages = (r.Pages ?? new())
var before = r.Pages ?? new();
var hadSystemHub = before.Any(p =>
string.Equals(p, "admin-config-system-center", StringComparison.OrdinalIgnoreCase));
var after = before
.Select(p => p == PageCatalog.Wildcard ? p : PageCatalog.NormalizeKey(p))
.Where(p => p == PageCatalog.Wildcard || PageCatalog.IsValidKey(p))
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToList();
if (!SamePages(before, after)) migrated = true;
r.Pages = after;
r.Ops = (r.Ops ?? new()).Distinct().ToList();
r.WidgetGrants ??= new();
if (string.IsNullOrWhiteSpace(r.Scope)) r.Scope = PageCatalog.ScopePlatform;
BackfillKnownPageMigrations(r);
var countBeforeBackfill = r.Pages.Count;
BackfillKnownPageMigrations(r, hadSystemHub);
if (r.Pages.Count != countBeforeBackfill) migrated = true;
}
foreach (var u in snap.Users)
{
@@ -105,7 +119,12 @@ public sealed class RbacStore
return snap;
}
private static void BackfillKnownPageMigrations(RbacRole r)
private static bool SamePages(IReadOnlyList<string> a, IReadOnlyList<string> b) =>
a.Count == b.Count
&& !a.Except(b, StringComparer.OrdinalIgnoreCase).Any()
&& !b.Except(a, StringComparer.OrdinalIgnoreCase).Any();
private static void BackfillKnownPageMigrations(RbacRole r, bool hadSystemHub)
{
if (!string.Equals(r.Scope, PageCatalog.ScopePlatform, StringComparison.OrdinalIgnoreCase)
&& r.Scope != PageCatalog.Wildcard) return;
@@ -126,6 +145,31 @@ public sealed class RbacStore
&& !r.Pages.Contains("admin-simple-fields", StringComparer.OrdinalIgnoreCase)
&& r.Pages.Contains("admin-task-templates", StringComparer.OrdinalIgnoreCase))
r.Pages.Add("admin-simple-fields");
if (!r.Pages.Contains(PageCatalog.Wildcard)
&& !r.Pages.Contains("admin-setup", StringComparer.OrdinalIgnoreCase)
&& r.Pages.Contains("admin-cars", StringComparer.OrdinalIgnoreCase)
&& r.Pages.Contains("admin-maps", StringComparer.OrdinalIgnoreCase))
r.Pages.Add("admin-setup");
// 日志管理、调度回放从监控配置聚合页拆出:原先有聚合页权限的角色补上新入口。
if (!r.Pages.Contains(PageCatalog.Wildcard)
&& r.Pages.Contains("admin-config-ops-center", StringComparer.OrdinalIgnoreCase))
{
if (!r.Pages.Contains("admin-logs", StringComparer.OrdinalIgnoreCase))
r.Pages.Add("admin-logs");
if (!r.Pages.Contains("admin-playback", StringComparer.OrdinalIgnoreCase))
r.Pages.Add("admin-playback");
}
// 「系统与权限」拆成系统配置 / 权限与角色。
if (!r.Pages.Contains(PageCatalog.Wildcard) && hadSystemHub)
{
if (!r.Pages.Contains("admin-config-system", StringComparer.OrdinalIgnoreCase))
r.Pages.Add("admin-config-system");
if (!r.Pages.Contains("admin-config-auth", StringComparer.OrdinalIgnoreCase))
r.Pages.Add("admin-config-auth");
}
}
private RbacSnapshot SeedDefault(IConfiguration config)
@@ -153,7 +197,7 @@ public sealed class RbacStore
{
Id = RoleOpsId, Name = "运营人员", Description = "运营监控端默认角色:可执行运维操作、查看监控",
Scope = PageCatalog.ScopeMonitor,
Pages = new() { "monitor-dashboard", "monitor-vehicle-hub", "monitor-map", "monitor-ops", "monitor-notes" },
Pages = new() { "monitor-vehicle-hub", "monitor-map", "monitor-ops", "monitor-notes" },
Ops = new()
{
"ops.car.pause", "ops.car.resume", "ops.car.gohome", "ops.car.resetSession",
@@ -244,6 +288,20 @@ public sealed class RbacStore
public bool CanUseScope(RbacUser user, string scope) =>
UsableScopes(user).Contains(scope, StringComparer.OrdinalIgnoreCase);
/// <summary>
/// 登录按账号角色自动选域:能进管理端则进管理端,否则进运营权限页。
/// 不再接受客户端挑选「运营端 / 管理端」。
/// </summary>
public string? ResolveLoginScope(RbacUser user)
{
var scopes = UsableScopes(user);
if (scopes.Contains(PageCatalog.ScopePlatform, StringComparer.OrdinalIgnoreCase))
return PageCatalog.ScopePlatform;
if (scopes.Contains(PageCatalog.ScopeMonitor, StringComparer.OrdinalIgnoreCase))
return PageCatalog.ScopeMonitor;
return null;
}
/// <summary>角色名(展示用,写入 AuthUserDto.Roles / JWT role claim)。</summary>
public List<string> RoleNamesOf(RbacUser user)
{
@@ -270,7 +328,7 @@ public sealed class RbacStore
foreach (var r in roles)
{
if (r.Pages.Contains(PageCatalog.Wildcard)) pages.UnionWith(scopeKeys);
else foreach (var p in r.Pages) if (scopeKeys.Contains(p)) pages.Add(p);
else pages.UnionWith(PageCatalog.ExpandKeysForScope(r.Pages, scope));
foreach (var o in r.Ops)
{
@@ -480,7 +538,11 @@ public sealed class RbacStore
{
if (pages is null) return new();
if (pages.Contains(PageCatalog.Wildcard)) return new() { PageCatalog.Wildcard };
return pages.Where(PageCatalog.IsValidKey).Distinct().ToList();
return pages
.Select(PageCatalog.NormalizeKey)
.Where(p => p == PageCatalog.Wildcard || PageCatalog.IsValidKey(p))
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToList();
}
private static string NormalizeScope(string? scope) => scope switch
+53 -1
View File
@@ -7,7 +7,7 @@ namespace MiGu.Server.Configs;
/// <summary>
/// 配置中心存储(内存 + JSON 文件持久化占位)。
/// 前 14 个 section 对应 ARCHITECTURE.md §9 的 13+1 维度;外加 deployment —— 登录后「配置向导」的部署画像。
/// 真实落地时由 SimpleShared.Persistence 接入 EF Core,并配合 YARP 下发至 SimpleLite
/// 真实落地时由 SimpleShared.Persistence 接入 EF Core,并配合 YARP 下发至 Simple3
/// </summary>
public sealed class ConfigStore
{
@@ -99,6 +99,58 @@ public sealed class ConfigStore
return Put("deployment", el);
}
public LogCleanupOptions GetLogCleanup()
{
var env = Get("system");
return env.Payload switch
{
SystemConfig sc => (sc.LogCleanup ?? new LogCleanupOptions()).Clamp(),
JsonElement el => ParseLogCleanup(el),
_ => LogCleanupOptions.CreateDefaults()
};
}
public Envelope PutLogCleanup(LogCleanupOptions options)
{
var opt = (options ?? new LogCleanupOptions()).Clamp();
Dictionary<string, JsonElement> dict;
try
{
var current = Get("system").Payload;
var el = current is JsonElement je
? je
: JsonSerializer.SerializeToElement(current, _jsonOpts);
dict = el.ValueKind == JsonValueKind.Object
? JsonSerializer.Deserialize<Dictionary<string, JsonElement>>(el.GetRawText()) ?? new()
: new Dictionary<string, JsonElement>();
}
catch (Exception ex)
{
_logger.LogWarning(ex, "读取 system 配置失败,按空对象合并 logCleanup");
dict = new Dictionary<string, JsonElement>();
}
dict["logCleanup"] = JsonSerializer.SerializeToElement(opt, _jsonOpts);
var merged = JsonSerializer.SerializeToElement(dict, _jsonOpts);
return Put("system", merged);
}
private LogCleanupOptions ParseLogCleanup(JsonElement el)
{
try
{
if (el.ValueKind == JsonValueKind.Object && el.TryGetProperty("logCleanup", out var nested))
return (nested.Deserialize<LogCleanupOptions>(_jsonOpts) ?? new LogCleanupOptions()).Clamp();
var sc = el.Deserialize<SystemConfig>(_jsonOpts);
return (sc?.LogCleanup ?? new LogCleanupOptions()).Clamp();
}
catch (Exception ex)
{
_logger.LogWarning(ex, "反序列化 logCleanup 失败,回退默认值");
return LogCleanupOptions.CreateDefaults();
}
}
private DeploymentProfile SafeDeserializeDeployment(JsonElement el)
{
try { return el.Deserialize<DeploymentProfile>(_jsonOpts) ?? DeploymentProfile.Default(); }
+21 -2
View File
@@ -37,21 +37,40 @@ public static class DeploymentCatalog
["wms"] = new[] { "admin-config-facility", "admin-config-warehouse" },
};
/// <summary>所有「可被选型控制」的页面 Key(Module → 页 映射值的并集)。</summary>
/// <summary>
/// 场景插件 → 平台页面 Key。选 SPS / Pack 时 ToLauncherSceneIds 会并入
/// <see cref="DeploymentProfile.SignalSceneId"/>,从而点亮「数据中心」。
/// </summary>
public static readonly IReadOnlyDictionary<string, string[]> SceneToPages =
new Dictionary<string, string[]>(StringComparer.OrdinalIgnoreCase)
{
[DeploymentProfile.SignalSceneId] = new[] { "admin-data-center" },
};
/// <summary>所有「可被选型控制」的页面 Key(Module / Scene 映射值的并集)。</summary>
public static IReadOnlyCollection<string> TailorablePages()
{
var set = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
foreach (var v in ModuleToPages.Values) foreach (var p in v) set.Add(p);
foreach (var v in SceneToPages.Values) foreach (var p in v) set.Add(p);
return set;
}
/// <summary>当前部署画像下被「点亮」的可裁剪页(已启用 Module 映射到的页)。</summary>
/// <summary>当前部署画像下被「点亮」的可裁剪页(已启用 Module / 激活场景映射到的页)。</summary>
public static IReadOnlyCollection<string> EnabledPages(DeploymentProfile dp)
{
var set = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
if (dp == null) return set;
foreach (var m in dp.Modules ?? new List<string>())
if (ModuleToPages.TryGetValue(m, out var ps)) foreach (var p in ps) set.Add(p);
var activeScenes = dp.ToLauncherSceneIds();
foreach (var (sceneId, pages) in SceneToPages)
{
if (!activeScenes.Contains(sceneId, StringComparer.OrdinalIgnoreCase)) continue;
foreach (var p in pages) set.Add(p);
}
return set;
}
+35 -3
View File
@@ -7,7 +7,7 @@ namespace MiGu.Server.Configs;
/// <para>它是「按选型裁剪」的单一事实来源:</para>
/// <list type="number">
/// <item><see cref="Configured"/>=false 时,登录返回 <c>NeedsWizard=true</c>,前端跳转配置向导;</item>
/// <item><see cref="NavigationKinds"/> 经 <see cref="ToActiveSceneIds"/> 映射为 SimpleLite 场景 id
/// <item><see cref="NavigationKinds"/> 经 <see cref="ToActiveSceneIds"/> 映射为 Simple3 场景 id
/// 由 Launcher 写入 <c>plugins/active-scenes.json</c> / 透传 <c>--scenes</c>,驱动内核「选择性加载」导航场景插件;</item>
/// <item><see cref="Modules"/> 驱动平台菜单与能力裁剪(PageCatalog 可见页)。</item>
/// </list>
@@ -35,7 +35,7 @@ public record DeploymentProfile(
UpdatedBy: "");
/// <summary>
/// 导航方式 → SimpleLite 场景 id 映射。与 <c>*.scene.json.id</c>、<c>SimpleCore.Navigation.NavKind</c>
/// 导航方式 → Simple3 场景 id 映射。与 <c>*.scene.json.id</c>、<c>SimpleCore.Navigation.NavKind</c>
/// 以及内核 <c>active-scenes.json.activeScenes</c> 一致;这是平台与内核之间「导航选型」的契约约定。
/// <para>两平台制(StandardScene 拆分落地):磁导航 → <c>scene.mag</c>
/// 二维码与激光共用融合平台 <c>scene.qrlidar</c>(激光坐标导航 + 二维码按轨道逐段触发,可融合可单用)。</para>
@@ -49,7 +49,7 @@ public record DeploymentProfile(
};
/// <summary>
/// 把已选 <see cref="NavigationKinds"/> 映射为 SimpleLite 激活场景 id 列表(去重、忽略未知项、保持选择顺序)。
/// 把已选 <see cref="NavigationKinds"/> 映射为 Simple3 激活场景 id 列表(去重、忽略未知项、保持选择顺序)。
/// 供 Launcher 写 <c>active-scenes.json</c> / 拼 <c>--scenes</c> 参数使用。
/// </summary>
public IReadOnlyList<string> ToActiveSceneIds()
@@ -64,4 +64,36 @@ public record DeploymentProfile(
}
return result;
}
/// <summary>信号交互场景 idPLC 握手)。仅 SPS / Pack 业务场景时加载。</summary>
public const string SignalSceneId = "scene.signal";
/// <summary>设备驱动场景 id(门/充电桩等,各类项目通用)。</summary>
public const string DeviceSceneId = "scene.device";
public const string ScenarioSps = "tpl-sps";
public const string ScenarioPack = "tpl-pack";
/// <summary>是否选了磁导航。</summary>
public bool UsesMagnetic() =>
NavigationKinds?.Any(k => string.Equals(k, "magnetic", StringComparison.OrdinalIgnoreCase)) == true;
/// <summary>是否选了需要 scene.signal 的业务场景(SPS 物料配送 / 电池 Pack 产线)。</summary>
public bool UsesSignalPlugin() =>
Scenarios?.Any(s =>
string.Equals(s, ScenarioSps, StringComparison.OrdinalIgnoreCase)
|| string.Equals(s, ScenarioPack, StringComparison.OrdinalIgnoreCase)) == true;
/// <summary>
/// 写入 active-scenes.json 的完整场景集合:导航插件 + SPS/Pack 时并入 scene.signal + scene.device。
/// </summary>
public IReadOnlyList<string> ToLauncherSceneIds()
{
var result = ToActiveSceneIds().ToList();
if (UsesSignalPlugin() && !result.Contains(SignalSceneId, StringComparer.OrdinalIgnoreCase))
result.Add(SignalSceneId);
if (!result.Contains(DeviceSceneId, StringComparer.OrdinalIgnoreCase))
result.Add(DeviceSceneId);
return result;
}
}
+39
View File
@@ -0,0 +1,39 @@
namespace MiGu.Server.Configs;
/// <summary>
/// 日志清理与磁盘告警(system.logCleanup)。字段对齐 Simple3 <c>LogCleanupOptions</c>。
/// </summary>
public sealed class LogCleanupOptions
{
/// <summary>是否启用后台自动清理。关闭后仅「立即清理」可手动触发。默认 true。</summary>
public bool Enabled { get; set; } = true;
/// <summary>保留天数:删除 log/ 下 LastWriteTime 早于「现在 N 天」的 *.log。默认 30,最小 1。</summary>
public int RetentionDays { get; set; } = 30;
/// <summary>后台清理间隔(小时)。默认 24;&lt;= 0 归一为 1。</summary>
public int CheckIntervalHours { get; set; } = 24;
/// <summary>启动时先执行一次清理。默认 true。</summary>
public bool RunOnStartup { get; set; } = true;
/// <summary>是否启用磁盘剩余空间不足告警。默认 true。</summary>
public bool DiskAlertEnabled { get; set; } = true;
/// <summary>日志所在盘剩余低于该值(GB)时告警。默认 5。</summary>
public double DiskFreeAlertGB { get; set; } = 5;
/// <summary>磁盘检测间隔(分钟)。默认 30;&lt;= 0 归一为 30。</summary>
public int DiskCheckIntervalMinutes { get; set; } = 30;
public static LogCleanupOptions CreateDefaults() => new();
public LogCleanupOptions Clamp()
{
RetentionDays = Math.Clamp(RetentionDays, 1, 3650);
CheckIntervalHours = Math.Max(1, CheckIntervalHours);
if (DiskFreeAlertGB <= 0) DiskFreeAlertGB = 5;
DiskCheckIntervalMinutes = DiskCheckIntervalMinutes <= 0 ? 30 : DiskCheckIntervalMinutes;
return this;
}
}
+6 -3
View File
@@ -103,12 +103,15 @@ public sealed class OpsAuditStore
}
}
/// <summary>最近的审计(倒序,最新在前)。</summary>
public IReadOnlyList<AuditEntry> Recent()
/// <summary>最近的审计(倒序,最新在前)。传入 user 时只返回该操作人(忽略大小写)。</summary>
public IReadOnlyList<AuditEntry> Recent(string? user = null)
{
lock (_gate)
{
var copy = new List<AuditEntry>(_entries);
IEnumerable<AuditEntry> src = _entries;
if (!string.IsNullOrWhiteSpace(user))
src = _entries.Where(e => string.Equals(e.User, user, StringComparison.OrdinalIgnoreCase));
var copy = src.ToList();
copy.Reverse();
return copy;
}
+4 -8
View File
@@ -1,12 +1,8 @@
namespace MiGu.Server.Configs;
public record LogPolicy(string Level, int RollDays, int MaxSizeMB);
public record SecurityPolicy(int JwtExpireMin, bool EnableSwagger, List<string> CorsWhitelist);
public record SystemConfig(int DispatchLoopHz, LogPolicy Log, SecurityPolicy Security)
public record SystemConfig
{
public static SystemConfig Default() => new(
DispatchLoopHz: 50,
Log: new LogPolicy("info", 7, 256),
Security: new SecurityPolicy(1440, false, new List<string> { "http://localhost:5173" }));
public LogCleanupOptions LogCleanup { get; init; } = new();
public static SystemConfig Default() => new();
}
+46 -34
View File
@@ -12,19 +12,17 @@ namespace MiGu.Server.Controllers;
public class AuthController : ControllerBase
{
/// <summary>
/// 登录请求体。
/// 会话 N+1(启动反转):新增 <see cref="LaunchMode"/>。前端登录页让用户选 "WebOnly" / "DesktopAndWeb"
/// MiGu.Server 据此拉起 SimpleLite 子进程并透传 <c>--display-mode=web|web+local</c>。
/// 历史调用方不传该字段时默认 "DesktopAndWeb"(与之前 web+local 默认行为一致,向后兼容)。
/// 登录请求体。可选 <see cref="LaunchMode"/>Simple3 仅 Web 宿主,缺省/未知一律按 WebOnly。
/// MiGu.Server 据此拉起子进程并透传 <c>--display-mode=web</c>。
/// </summary>
public record LoginRequest(string Username, string Password, string Scope, string? LaunchMode = null);
public record LoginRequest(string Username, string Password, string? Scope = null, string? LaunchMode = null);
/// <summary>
/// 登录响应。
/// 会话 N+1 增量字段:
/// <list type="bullet">
/// <item><c>RunMode</c>:根据 SimpleLite 真实拉起结果回填(WebEnabled / WebOnly / Detached)。Detached 表示后端未能拉起 SimpleLite,前端可降级展示。</item>
/// <item><c>LaunchStatus</c><see cref="SimpleLiteLauncher.LaunchResult.Status"/> 枚举字符串,前端用于精细化提示。</item>
/// <item><c>RunMode</c>:根据 Simple3 真实拉起结果回填(WebEnabled / WebOnly / Detached)。Detached 表示后端未能拉起 Simple3,前端可降级展示。</item>
/// <item><c>LaunchStatus</c><see cref="Simple3Launcher.LaunchResult.Status"/> 枚举字符串,前端用于精细化提示。</item>
/// <item><c>LaunchWarning</c>:可空告警文本;非空时前端应该弹消息条告知用户。</item>
/// </list>
/// </summary>
@@ -51,17 +49,18 @@ public class AuthController : ControllerBase
string Scope,
string RunMode,
EffectivePermissions EffectivePermissions,
bool NeedsWizard = false);
bool NeedsWizard = false,
string? Token = null);
private const string CookieName = "simple.auth.token";
private readonly RbacStore _rbac;
private readonly JwtIssuer _jwt;
private readonly SimpleLiteLauncher _launcher;
private readonly Simple3Launcher _launcher;
private readonly ConfigStore _config;
private readonly ILogger<AuthController> _log;
public AuthController(RbacStore rbac, JwtIssuer jwt, SimpleLiteLauncher launcher, ConfigStore config, ILogger<AuthController> log)
public AuthController(RbacStore rbac, JwtIssuer jwt, Simple3Launcher launcher, ConfigStore config, ILogger<AuthController> log)
{
_rbac = rbac;
_jwt = jwt;
@@ -79,41 +78,40 @@ public class AuthController : ControllerBase
{
if (string.IsNullOrWhiteSpace(req.Username))
return BadRequest(new { message = "用户名不能为空" });
if (req.Scope is not ("Platform" or "RCSMonitor"))
return BadRequest(new { message = "无效 scope" });
// 真密码校验:RbacStore.VerifyCredentials 对不存在 / 已禁用 / 密码错统一返回 null,防用户名枚举。
var user = _rbac.VerifyCredentials(req.Username, req.Password);
if (user == null)
return Unauthorized(new { message = "用户名或密码错误,或账号已被停用" });
// scope 必须落在该账号角色覆盖的 scope」集合内(admin 角色 scope=* 覆盖全部)
if (!_rbac.CanUseScope(user, req.Scope))
return StatusCode(403, new { message = $"账号 {user.Username} 没有访问 {req.Scope} 的权限" });
// 入口由账号角色决定,不再接受登录页挑选「管理端 / 运营端」
var scope = _rbac.ResolveLoginScope(user);
if (scope == null)
return StatusCode(403, new { message = $"账号 {user.Username} 没有任何可登录区域,请联系管理员分配角色" });
// 会话 N+1:按 LaunchMode 拉起 SimpleLite 子进程(线程池执行,避免占用请求线程)。
// 会话 N+1:按 LaunchMode 拉起 Simple3 子进程(线程池执行,避免占用请求线程)。
var launchMode = NormalizeLaunchMode(req.LaunchMode);
SimpleLiteLauncher.LaunchResult? launchResult = null;
Simple3Launcher.LaunchResult? launchResult = null;
try
{
// M1waitForReady=false —— 拉起 SimpleLite 后立即返回,不在登录请求里同步等端口
// M1waitForReady=false —— 拉起 Simple3 后立即返回,不在登录请求里同步等端口
// 就绪(冷启动可能十几秒)。前端拿 LaunchStatus=Starting 即可,必要时轮询健康检查。
launchResult = await Task.Run(() => _launcher.MaybeStart(launchMode, waitForReady: false));
_log.LogInformation("SimpleLite launch result for user={User} launchMode={Mode}: Started={Started} Status={Status} Detail={Detail}",
_log.LogInformation("Simple3 launch result for user={User} launchMode={Mode}: Started={Started} Status={Status} Detail={Detail}",
user.Username, launchMode, launchResult.Value.Started, launchResult.Value.Status, launchResult.Value.Detail);
}
catch (Exception ex)
{
_log.LogError(ex, "SimpleLite launch threw for user={User} launchMode={Mode}", user.Username, launchMode);
_log.LogError(ex, "Simple3 launch threw for user={User} launchMode={Mode}", user.Username, launchMode);
}
var runMode = ResolveRunMode(launchResult, launchMode);
var (perm, roleNames, token) = BuildSession(user, req.Scope);
var (perm, roleNames, token) = BuildSession(user, scope);
SetAuthCookie(token);
var dto = new AuthUserDto(user.Id, user.Username, user.DisplayName, roleNames);
return Ok(new LoginResponse(token, dto, req.Scope, runMode, perm,
return Ok(new LoginResponse(token, dto, scope, runMode, perm,
LaunchStatus: launchResult?.Status,
LaunchWarning: launchResult?.Warning,
NeedsWizard: NeedsWizard()));
@@ -144,13 +142,20 @@ public class AuthController : ControllerBase
if (user == null || !user.Enabled)
return Unauthorized(new { message = "账号已失效或被停用" });
// 账号当前是否还允许这个 scope(管理员可能在此期间调整了角色)。
if (!_rbac.CanUseScope(user, scope))
return StatusCode(403, new { message = $"账号 {user.Username} 没有访问 {scope} 的权限" });
var preferred = _rbac.ResolveLoginScope(user);
if (preferred == null)
return StatusCode(403, new { message = $"账号 {user.Username} 没有任何可登录区域,请联系管理员分配角色" });
var (perm, roleNames, token) = BuildSession(user, preferred);
string? rotated = null;
if (!string.Equals(preferred, scope, StringComparison.OrdinalIgnoreCase))
{
SetAuthCookie(token);
rotated = token;
}
var (perm, roleNames, _) = BuildSession(user, scope);
var dto = new AuthUserDto(user.Id, user.Username, user.DisplayName, roleNames);
return Ok(new MeResponse(dto, scope, InferRunMode(), perm, NeedsWizard()));
return Ok(new MeResponse(dto, preferred, InferRunMode(), perm, NeedsWizard(), rotated));
}
/// <summary>用同一身份切换 scope 并重发 token + perms。</summary>
@@ -172,6 +177,11 @@ public class AuthController : ControllerBase
if (!_rbac.CanUseScope(user, req.Scope))
return StatusCode(403, new { message = $"账号 {user.Username} 没有访问 {req.Scope} 的权限" });
// 有管理权限的账号固定走管理入口,不再切到运营壳;请用运营权限账号登录查看运营页。
var preferred = _rbac.ResolveLoginScope(user);
if (preferred == PageCatalog.ScopePlatform && req.Scope == PageCatalog.ScopeMonitor)
return StatusCode(403, new { message = "请使用运营权限账号查看运营页面" });
var (perm, roleNames, token) = BuildSession(user, req.Scope);
SetAuthCookie(token);
@@ -200,22 +210,23 @@ public class AuthController : ControllerBase
return (perm, roleNames, token);
}
/// <summary>me / switchScope 不重启 SimpleLite,依据 Launcher 记录的 LastLaunchMode 反推 RunMode。</summary>
/// <summary>me / switchScope 不重启 Simple3,依据 Launcher 记录的 LastLaunchMode 反推 RunMode。</summary>
private string InferRunMode()
{
var last = _launcher.LastLaunchMode;
if (string.IsNullOrEmpty(last)) return "Detached";
if (last == SimpleLiteLauncher.ExternalReuseLaunchMode) return "WebEnabled";
if (last == Simple3Launcher.ExternalReuseLaunchMode) return "WebOnly";
return last.Contains("local", StringComparison.OrdinalIgnoreCase) ? "WebEnabled" : "WebOnly";
}
/// <summary>根据 Launcher 真实结果决定 RunMode(避免 SimpleLite 没起却假装 WebEnabled)。</summary>
private static string ResolveRunMode(SimpleLiteLauncher.LaunchResult? result, string launchMode)
/// <summary>根据 Launcher 真实结果决定 RunMode(避免内核没起却假装已连接)。</summary>
private static string ResolveRunMode(Simple3Launcher.LaunchResult? result, string launchMode)
{
if (result is not { Started: true })
return "Detached";
// Simple3 无本地端:复用既有实例也按 WebOnly 展示。
if (result.Value.Status == "ReusingExisting")
return "WebEnabled";
return "WebOnly";
if (!string.IsNullOrEmpty(result.Value.DisplayMode))
{
if (result.Value.DisplayMode.Equals("web", StringComparison.OrdinalIgnoreCase))
@@ -230,8 +241,9 @@ public class AuthController : ControllerBase
{
return raw?.Trim().ToLowerInvariant() switch
{
"webonly" or "web-only" or "web" => "WebOnly",
_ => "DesktopAndWeb",
"desktopandweb" or "web+local" or "weblocal" or "local" => "DesktopAndWeb",
// Simple3 默认仅 Web;未传 / 未知也走 WebOnly
_ => "WebOnly",
};
}
+6 -1
View File
@@ -2,6 +2,7 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using MiGu.Server.Configs;
using MiGu.Server.Logs;
namespace MiGu.Server.Controllers;
@@ -13,10 +14,12 @@ namespace MiGu.Server.Controllers;
public class ConfigController : ControllerBase
{
private readonly ConfigStore _store;
private readonly LogCleanupService _cleanup;
public ConfigController(ConfigStore store)
public ConfigController(ConfigStore store, LogCleanupService cleanup)
{
_store = store;
_cleanup = cleanup;
}
[HttpGet]
@@ -66,6 +69,8 @@ public class ConfigController : ControllerBase
}
var env = _store.Put(section, payload);
if (string.Equals(section, "system", StringComparison.OrdinalIgnoreCase))
_cleanup.ApplySchedule();
return Ok(new
{
section = env.Section,
+4 -4
View File
@@ -15,9 +15,9 @@ public sealed class FleetController : ControllerBase
private readonly FleetHealthService _health;
private readonly CdmTaskSyncer _cdmSyncer;
private readonly AlarmCollector _alarmCollector;
private readonly PlatformDbContext _db;
private readonly MiGuDbContext _db;
public FleetController(FleetHealthService health, CdmTaskSyncer cdmSyncer, AlarmCollector alarmCollector, PlatformDbContext db)
public FleetController(FleetHealthService health, CdmTaskSyncer cdmSyncer, AlarmCollector alarmCollector, MiGuDbContext db)
{
_health = health;
_cdmSyncer = cdmSyncer;
@@ -31,7 +31,7 @@ public sealed class FleetController : ControllerBase
/// <summary>
/// CDM 搬运任务列表(读平台快照库 cdm_tasks)。在线时先即时同步一次拿最新,
/// SimpleLite 关闭时回退最近快照,并通过 online/lastSyncAt 告知前端数据是否滞后。
/// Simple3 关闭时回退最近快照,并通过 online/lastSyncAt 告知前端数据是否滞后。
/// </summary>
[HttpGet("tasks")]
public async Task<ActionResult<object>> Tasks([FromQuery] int limit = 1000, CancellationToken ct = default)
@@ -76,7 +76,7 @@ public sealed class FleetController : ControllerBase
}
/// <summary>
/// 车辆报警列表(读平台记录 vehicle_alarms)。含活跃 + 历史;SimpleLite 离线时回退最近记录,
/// 车辆报警列表(读平台记录 vehicle_alarms)。含活跃 + 历史;Simple3 离线时回退最近记录,
/// 通过 online/lastSyncAt 告知数据是否滞后。activeOnly=true 仅返回未恢复的报警。
/// </summary>
[HttpGet("alarms")]
+16 -16
View File
@@ -9,9 +9,9 @@ namespace MiGu.Server.Controllers;
public class HealthController : ControllerBase
{
private static readonly DateTimeOffset StartTime = DateTimeOffset.UtcNow;
private readonly SimpleLiteLauncher _launcher;
private readonly Simple3Launcher _launcher;
public HealthController(SimpleLiteLauncher launcher) => _launcher = launcher;
public HealthController(Simple3Launcher launcher) => _launcher = launcher;
/// <summary>匿名存活探针:仅返回进程级状态,不暴露端口拓扑等部署细节。</summary>
[HttpGet]
@@ -26,28 +26,28 @@ public class HealthController : ControllerBase
}
/// <summary>
/// SimpleLite 拉起配置诊断:查看当前 ExecutablePath、解析结果、端口是否已有服务。
/// 配置位置:<c>MiGu.Server/appsettings.json</c> → <c>SimpleLite</c> 节点。
/// Simple3 拉起配置诊断:查看当前 ExecutablePath、解析结果、端口是否已有服务。
/// 配置位置:<c>MiGu.Server/appsettings.json</c> → <c>Simple3</c> 节点。
/// 含服务器本地路径等敏感信息,要求登录。
/// </summary>
[HttpGet("simplelite")]
[HttpGet("simple3")]
[Authorize]
public IActionResult GetSimpleLiteDiagnostics() => Ok(_launcher.GetDiagnostics());
public IActionResult GetSimple3Diagnostics() => Ok(_launcher.GetDiagnostics());
/// <summary>关闭本机全部 SimpleLite 进程。仅 Platform 管理端可调。</summary>
[HttpPost("simplelite/stop")]
/// <summary>关闭本机全部 Simple3 进程。仅 Platform 管理端可调。</summary>
[HttpPost("simple3/stop")]
[Authorize(Policy = "PlatformScope")]
public IActionResult StopSimpleLite()
public IActionResult StopSimple3()
{
var killed = _launcher.StopAll();
var diag = _launcher.GetDiagnostics();
return Ok(new { killed, diagnostics = diag });
}
/// <summary>关闭并重新拉起 SimpleLite(不同步 DLL)。仅 Platform 管理端可调。</summary>
[HttpPost("simplelite/restart")]
/// <summary>关闭并重新拉起 Simple3(不同步 DLL)。仅 Platform 管理端可调。</summary>
[HttpPost("simple3/restart")]
[Authorize(Policy = "PlatformScope")]
public IActionResult RestartSimpleLite([FromQuery] string launchMode = "webonly")
public IActionResult RestartSimple3([FromQuery] string launchMode = "webonly")
{
var result = _launcher.Restart(launchMode);
var diag = _launcher.GetDiagnostics();
@@ -55,12 +55,12 @@ public class HealthController : ControllerBase
}
/// <summary>
/// 关闭 SimpleLite、同步最新 DLL、重新拉起。用于「前往站点」API 缺失时一键更新。
/// 会终止本机全部 SimpleLite 进程并重启,仅 Platform 管理端可调。
/// 关闭 Simple3、同步最新 DLL、重新拉起。用于「前往站点」API 缺失时一键更新。
/// 会终止本机全部 Simple3 进程并重启,仅 Platform 管理端可调。
/// </summary>
[HttpPost("simplelite/restart-for-update")]
[HttpPost("simple3/restart-for-update")]
[Authorize(Policy = "PlatformScope")]
public IActionResult RestartSimpleLiteForUpdate([FromQuery] string launchMode = "webonly")
public IActionResult RestartSimple3ForUpdate([FromQuery] string launchMode = "webonly")
{
var result = _launcher.RestartForUpdate(launchMode);
var diag = _launcher.GetDiagnostics();
+60 -12
View File
@@ -2,18 +2,20 @@ using System.Globalization;
using System.Text.RegularExpressions;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using MiGu.Server.Configs;
using MiGu.Server.Launcher;
using MiGu.Server.Logs;
namespace MiGu.Server.Controllers;
/// <summary>
/// 平台「日志管理」后端:把 SimpleLite 内核 <c>SimpleCore.Library.Diagnosis</c> 的落盘日志
/// 平台「日志管理」后端:把 Simple3 内核 <c>SimpleCore.Library.Diagnosis</c> 的落盘日志
/// <c>Diagnosis.Post</c> / <c>Diagnosis.Log</c> 写入的 <c>log/**/*.log</c>,俗称 DLog)暴露给
/// platform-vue 的配置中心「日志管理」页查看。对齐 SimpleLite 桌面端 <c>SimpleLite/UI/LogViewer.cs</c>
/// platform-vue 的配置中心「日志管理」页查看。对齐 Simple3 桌面端 <c>Simple3/UI/LogViewer.cs</c>
/// 的两区设计(诊断条目表 + 落盘文件表),并在 Web 端额外提供「按标签合订」视图。
///
/// 数据来源:日志是 SimpleLite 进程在其工作目录写出的历史文件,<b>不依赖 SimpleLite 是否在运行</b>
/// MiGu.Server 通过 <see cref="SimpleLiteLauncher.ResolveWorkingDirectory"/> 定位工作目录后直接读
/// 数据来源:日志是 Simple3 进程在其工作目录写出的历史文件,<b>不依赖 Simple3 是否在运行</b>
/// MiGu.Server 通过 <see cref="Simple3Launcher.ResolveWorkingDirectory"/> 定位工作目录后直接读
/// <c>{工作目录}/log/</c>。可用 appsettings <c>Logs:Root</c> 显式覆盖日志根目录。
///
/// 落盘行格式(见 Diagnosis.Log):<c>[{prefix}yyyy/MM/dd-HH:mm:ss.fff] >{tag}: {content}</c>
@@ -59,15 +61,21 @@ public sealed class LogsController : ControllerBase
private static readonly Regex NumericFieldRegex =
new(@"(?<k>[A-Za-z_\u4e00-\u9fff][\w\u4e00-\u9fff\.]*)\s*[=:]\s*(?<v>-?\d+(?:\.\d+)?)", RegexOptions.Compiled);
private readonly SimpleLiteLauncher _launcher;
private readonly Simple3Launcher _launcher;
private readonly IConfiguration _config;
private readonly ILogger<LogsController> _log;
private readonly LogCleanupService _cleanup;
public LogsController(SimpleLiteLauncher launcher, IConfiguration config, ILogger<LogsController> log)
public LogsController(
Simple3Launcher launcher,
IConfiguration config,
ILogger<LogsController> log,
LogCleanupService cleanup)
{
_launcher = launcher;
_config = config;
_log = log;
_cleanup = cleanup;
}
// ─────────────────────────────────────────────────────────── 概览 ──
@@ -78,14 +86,15 @@ public sealed class LogsController : ControllerBase
{
var (root, workdir, error) = ResolveLogRoot();
if (root == null)
return Ok(new { exists = false, root = (string?)null, workingDirectory = workdir, message = error });
return Ok(new { exists = false, root = (string?)null, workingDirectory = workdir, message = error, disk = DiskDto() });
if (!Directory.Exists(root))
return Ok(new
{
exists = false, root, workingDirectory = workdir,
totalFiles = 0, totalBytes = 0L, days = Array.Empty<object>(),
message = "日志目录尚未生成(SimpleLite 产生落盘日志后会自动创建 log 目录)。"
message = "日志目录尚未生成(Simple3 产生落盘日志后会自动创建 log 目录)。",
disk = DiskDto()
});
var files = EnumerateLogFiles(root);
@@ -101,7 +110,32 @@ public sealed class LogsController : ControllerBase
totalFiles = files.Count,
totalBytes = files.Sum(f => f.Bytes),
latestFileTime = files.Count > 0 ? files.Max(f => f.Mtime) : (DateTime?)null,
days
days,
disk = DiskDto()
});
}
/// <summary>日志清理配置(对齐 Simple3 logCleanup)。</summary>
[HttpGet("cleanup-config")]
public IActionResult GetCleanupConfig() => Ok(_cleanup.GetOptions());
/// <summary>保存日志清理配置并立即重排后台定时器。</summary>
[HttpPut("cleanup-config")]
public IActionResult PutCleanupConfig([FromBody] LogCleanupOptions body)
=> Ok(_cleanup.SaveOptions(body ?? new LogCleanupOptions()));
/// <summary>按当前保留天数立即清理一次过期 *.log。</summary>
[HttpPost("cleanup-now")]
public IActionResult CleanupNow()
{
var r = _cleanup.CleanNow();
if (r.Error != null)
return Problem(r.Error, statusCode: 500);
return Ok(new
{
deletedFiles = r.DeletedFiles,
freedBytes = r.FreedBytes,
freedMB = Math.Round(r.FreedMB, 1)
});
}
@@ -151,7 +185,7 @@ public sealed class LogsController : ControllerBase
root = normRoot, exists = false, path = "", parent = (string?)null,
dirCount = 0, fileCount = 0,
dirs = Array.Empty<object>(), files = Array.Empty<object>(),
message = "日志目录尚未生成(SimpleLite 产生落盘日志后会自动创建 log 目录)。"
message = "日志目录尚未生成(Simple3 产生落盘日志后会自动创建 log 目录)。"
});
var target = SafeResolveDir(normRoot, path);
@@ -592,9 +626,23 @@ public sealed class LogsController : ControllerBase
return PhysicalFile(full, "application/octet-stream", downloadName);
}
private object DiskDto()
{
var d = _cleanup.GetDiskStatus();
return new
{
known = d.Known,
drive = d.Drive,
freeGB = Math.Round(d.FreeGB, 1),
alertEnabled = d.AlertEnabled,
alertGB = d.AlertGB,
belowThreshold = d.BelowThreshold
};
}
// ─────────────────────────────────────────────────────── helpers ──
/// <summary>定位日志根:优先 appsettings <c>Logs:Root</c>,否则取 SimpleLite 工作目录下的 <c>log</c>。</summary>
/// <summary>定位日志根:优先 appsettings <c>Logs:Root</c>,否则取 Simple3 工作目录下的 <c>log</c>。</summary>
private (string? root, string? workdir, string? error) ResolveLogRoot()
{
var overrideRoot = _config["Logs:Root"];
@@ -607,7 +655,7 @@ public sealed class LogsController : ControllerBase
var wd = _launcher.ResolveWorkingDirectory();
if (string.IsNullOrWhiteSpace(wd))
return (null, null,
"未能定位 SimpleLite 工作目录,无法读取日志。请在 appsettings.json 配置 SimpleLite:WorkingDirectory" +
"未能定位 Simple3 工作目录,无法读取日志。请在 appsettings.json 配置 Simple3:WorkingDirectory" +
"或显式设置 Logs:Root 指向日志根目录。");
return (Path.GetFullPath(Path.Combine(wd, "log")), wd, null);
@@ -8,8 +8,8 @@ using MiGu.Server.Launcher;
namespace MiGu.Server.Controllers;
/// <summary>
/// 读取 SimpleLite 地图固定目录下的 JSON 原文,供平台「地图管理」右侧预览。
/// 先向 SimpleLite 拉取 maps 列表拿到 directory,再读本机同路径文件(与 SimpleLite 同机部署)。
/// 读取 Simple3 地图固定目录下的 JSON 原文,供平台「地图管理」右侧预览。
/// 先向 Simple3 拉取 maps 列表拿到 directory,再读本机同路径文件(与 Simple3 同机部署)。
/// </summary>
[ApiController]
[Authorize]
@@ -18,13 +18,13 @@ public class MapsContentController : ControllerBase
{
private readonly IHttpClientFactory _httpFactory;
private readonly InternalTokenStore _internalToken;
private readonly SimpleLiteOptions _sl;
private readonly Simple3Options _sl;
private readonly ILogger<MapsContentController> _log;
public MapsContentController(
IHttpClientFactory httpFactory,
InternalTokenStore internalToken,
IOptions<SimpleLiteOptions> sl,
IOptions<Simple3Options> sl,
ILogger<MapsContentController> log)
{
_httpFactory = httpFactory;
@@ -47,7 +47,7 @@ public class MapsContentController : ControllerBase
{
var directory = await FetchMapsDirectoryAsync(ct);
if (string.IsNullOrWhiteSpace(directory))
return StatusCode(503, new { message = "无法从 SimpleLite 获取地图目录" });
return StatusCode(503, new { message = "无法从 Simple3 获取地图目录" });
var fileName = name.EndsWith(".json", StringComparison.OrdinalIgnoreCase) ? name : $"{name}.json";
var fullPath = Path.GetFullPath(Path.Combine(directory, fileName));
@@ -86,7 +86,7 @@ public class MapsContentController : ControllerBase
using var resp = await client.SendAsync(msg, ct);
var body = await resp.Content.ReadAsStringAsync(ct);
if (!resp.IsSuccessStatusCode)
throw new InvalidOperationException($"SimpleLite maps 列表返回 {(int)resp.StatusCode}");
throw new InvalidOperationException($"Simple3 maps 列表返回 {(int)resp.StatusCode}");
using var doc = JsonDocument.Parse(body);
var root = doc.RootElement;
+300 -42
View File
@@ -17,34 +17,58 @@ namespace MiGu.Server.Controllers;
/// M4 修复(运维操作真实下发 + 审计落库):
/// - 旧实现只把动作塞进静态内存队列、永远回 success=true,是「假操作」(前端显示
/// “暂停成功”但内核毫无反应,且重启审计全丢);
/// - 现在:命中 <c>Ops:Dispatch</c> 映射的 op 会**真实转发**到 SimpleLite 反射 execute
/// - 现在:命中 <c>Ops:Dispatch</c> 映射的 op 会**真实转发**到 Simple3 反射 execute
/// 按内核返回如实记成功 / 失败;未映射的 op 不再假成功,明确回「未下发」;
/// - 审计统一经 <see cref="OpsAuditStore"/> 落盘(重启不丢)。
///
/// 关于映射:运营语义(暂停 / 恢复 / 回库 / 重置会话 / 手动充电)与 SimpleLite 内核反射
/// 方法(OnlineCar/OfflineCar/Repair/Blown/Reset… 见 Car.cs <c>[MethodMember]</c>)并非
/// 一一对应。为避免「猜错方法名 → 误操作车辆」,默认不预置车辆映射,由部署方在
/// appsettings.json <c>Ops:Dispatch</c> 显式配置 <c>"opCode": "kind:Method"</c> 后即真实下发
/// 关于映射:
/// - 地图监控选中车辆:<c>ops.car.execute</c> 按管理端「运营维护」<c>carActionByType</c>
/// 勾选的 Simple3 方法下发(与管理端同一份 monitor-config)。
/// - 运维操作页的 pause/resume/gohome 等仍走 <c>Ops:Dispatch</c> 显式映射
/// </summary>
[ApiController]
[Authorize]
[Route("api/sl/ops")]
public class OpsController : ControllerBase
{
public record ExecuteRequest(string OpCode, string TargetId, string? Reason, string? IdempotencyKey);
public const string CarExecuteOp = "ops.car.execute";
public record ExecuteRequest(
string OpCode,
string TargetId,
string? Reason,
string? IdempotencyKey,
string? Method,
Dictionary<string, string>? Params,
int? SiteId);
public record ExecuteResponse(bool Ok, string AuditId, string? Message);
private static readonly HashSet<string> Whitelist = new(StringComparer.Ordinal)
{
"ops.car.pause", "ops.car.resume", "ops.car.gohome", "ops.car.resetSession",
"ops.car.manualCharge", "ops.task.pause", "ops.task.cancel", "ops.task.reassign",
"ops.car.manualCharge", CarExecuteOp,
"ops.task.pause", "ops.task.cancel", "ops.task.reassign",
"ops.task.boostPriority", "monitor.note.write"
};
private static readonly HashSet<string> GotoMethods = new(StringComparer.OrdinalIgnoreCase)
{
"Goto", "GotoSite", "WebGotoSite"
};
/// <summary>
/// 地图监控车辆列表快捷动作(上线/下线/结束任务等),与 carActionByType 勾选并集。
/// </summary>
private static readonly HashSet<string> MapMonitorQuickMethods = new(StringComparer.Ordinal)
{
"OnlineCar", "OfflineCar", "DisableCar", "EnableCar",
"ForceStopUI", "ForceStop", "UIIntercept"
};
private readonly OpsAuditStore _audits;
private readonly IHttpClientFactory _httpFactory;
private readonly InternalTokenStore _internalToken;
private readonly SimpleLiteOptions _sl;
private readonly Simple3Options _sl;
private readonly ILogger<OpsController> _log;
private readonly IReadOnlyDictionary<string, (string Kind, string Method)> _dispatch;
@@ -52,7 +76,7 @@ public class OpsController : ControllerBase
OpsAuditStore audits,
IHttpClientFactory httpFactory,
InternalTokenStore internalToken,
IOptions<SimpleLiteOptions> sl,
IOptions<Simple3Options> sl,
IConfiguration config,
ILogger<OpsController> log)
{
@@ -91,10 +115,10 @@ public class OpsController : ControllerBase
// AR-4JWT ops claim 二次校验 —— (op 在白名单) AND (op 在用户 ops claim)admin 的 "*" 特判通过。
var opsClaim = User.FindFirst("ops")?.Value ?? "";
var userOps = opsClaim.Split(' ', StringSplitOptions.RemoveEmptyEntries);
if (!userOps.Contains("*") && !userOps.Contains(req.OpCode))
if (!CanRunOp(userOps, req.OpCode, User.FindFirst("scope")?.Value))
return StatusCode(403, new { message = $"当前账号无权执行 {req.OpCode}" });
var user = User.Identity?.Name ?? User.FindFirstValue(ClaimTypes.NameIdentifier) ?? "anonymous";
var user = CurrentUsername();
var scope = User.FindFirst("scope")?.Value ?? "unknown";
// 幂等:同一 IdempotencyKey 若已有成功审计,直接复用上次结果,避免前端重试 / 双击造成重复下发与重复审计。
@@ -109,10 +133,13 @@ public class OpsController : ControllerBase
if (req.OpCode == "monitor.note.write")
return Ok(Done(user, scope, req, "ok", req.Reason));
if (req.OpCode == CarExecuteOp)
return Ok(await ExecuteConfiguredCarMethodAsync(user, scope, req));
// 未配置映射:不再「假成功」,如实告知未下发(在 appsettings Ops:Dispatch 绑定后即真实生效)。
if (!_dispatch.TryGetValue(req.OpCode, out var map))
return Ok(Done(user, scope, req, "unmapped",
$"运维动作 {req.OpCode} 尚未绑定 SimpleLite 内核方法,已记录审计但未下发。" +
$"运维动作 {req.OpCode} 尚未绑定 Simple3 内核方法,已记录审计但未下发。" +
$"请在 appsettings.json 的 Ops:Dispatch 配置 \"{req.OpCode}\": \"kind:Method\"。",
ok: false));
@@ -120,41 +147,62 @@ public class OpsController : ControllerBase
if (numericId is null)
return Ok(Done(user, scope, req, "failed", $"目标 ID『{req.TargetId}』无法解析为数字", ok: false));
// M4:真实转发到 SimpleLite 反射 execute(与前端 reflectionApi.execute 同路径,本机直连 8222)。
string result;
string? message;
try
{
var url = $"http://127.0.0.1:{_sl.ProjectionPort}/projection/reflection/execute/" +
$"{map.Kind}/{numericId}/{Uri.EscapeDataString(map.Method)}";
using var client = _httpFactory.CreateClient();
client.Timeout = TimeSpan.FromSeconds(8);
using var msg = new HttpRequestMessage(HttpMethod.Post, url);
// 即使 SimpleLite 默认放行 loopback,也带上 internal token,兼容其严格模式(AllowLoopback=false)。
if (!string.IsNullOrEmpty(_internalToken.Token))
msg.Headers.TryAddWithoutValidation("X-Platform-Internal-Token", _internalToken.Token);
// 运维面板已对 needConfirm 动作做过二次确认;内核 RequiresPlatformConfirm 方法需此头。
msg.Headers.TryAddWithoutValidation("X-Platform-Confirmed", "1");
if (!string.IsNullOrWhiteSpace(user))
msg.Headers.TryAddWithoutValidation("X-Platform-User", user);
using var resp = await client.SendAsync(msg);
var body = await resp.Content.ReadAsStringAsync();
var success = resp.IsSuccessStatusCode && ParseSuccess(body);
result = success ? "ok" : "failed";
message = success ? null : $"SimpleLite 返回 {(int)resp.StatusCode}{ExtractMessage(body)}";
}
catch (Exception ex)
{
result = "failed";
message = $"下发 SimpleLite 失败:{ex.GetType().Name}: {ex.Message}";
_log.LogWarning(ex, "ops execute 转发失败 op={Op} target={Target}", req.OpCode, req.TargetId);
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));
}
return Ok(Done(user, scope, req, result, message, ok: result == "ok"));
/// <summary>
/// 地图监控:按管理端 monitor-config.carActionByType 勾选的方法下发。
/// 账号需具备任意 <c>ops.car.*</c>(或 *);方法名必须落在该车型已配置白名单内。
/// </summary>
private async Task<ExecuteResponse> ExecuteConfiguredCarMethodAsync(string user, string scope, ExecuteRequest req)
{
var method = (req.Method ?? "").Trim();
if (!IsSafeMethodName(method))
return Done(user, scope, req, "failed", "方法名无效", ok: false);
var numericId = ExtractNumericId(req.TargetId);
if (numericId is null)
return Done(user, scope, req, "failed", $"目标 ID『{req.TargetId}』无法解析为数字", ok: false);
var allowed = await LoadAllowedCarMethodsAsync(numericId.Value);
if (allowed is null)
return Done(user, scope, req, "failed", "无法读取管理端车辆动作配置(monitor-config", ok: false);
allowed.UnionWith(MapMonitorQuickMethods);
if (allowed.Count == 0)
return Done(user, scope, req, "unmapped", "当前车型未配置动作。请在管理端「配置中心 → 运营维护」按车型勾选。", ok: false);
if (!allowed.Contains(method))
return Done(user, scope, req, "failed", $"方法 {method} 不在该车型已配置的动作列表中", ok: false);
var siteId = req.SiteId;
if (siteId is null && req.Params is not null && req.Params.TryGetValue("siteId", out var rawSite)
&& int.TryParse(rawSite, out var parsedSite))
siteId = parsedSite;
if (siteId is int sid && GotoMethods.Contains(method))
{
var gotoResult = await ForwardGotoSiteAsync(numericId.Value, sid, user);
if (gotoResult.Ok || !string.Equals(method, "WebGotoSite", StringComparison.OrdinalIgnoreCase))
return Done(user, scope, req, gotoResult.Result,
CarAuditMessage(method, gotoResult.Ok ? $"前往站点 {sid}" : gotoResult.Message, gotoResult.Ok),
ok: gotoResult.Ok);
}
var forwarded = await ForwardExecuteAsync("car", numericId.Value, method, req.Params, user);
return Done(user, scope, req, forwarded.Result,
CarAuditMessage(method, forwarded.Message, forwarded.Ok),
ok: forwarded.Ok);
}
private static string CarAuditMessage(string method, string? detail, bool ok)
{
if (!ok) return string.IsNullOrWhiteSpace(detail) ? $"执行 {method} 失败" : detail;
return string.IsNullOrWhiteSpace(detail) ? $"执行 {method}" : $"{method}{detail}";
}
/// <summary>只返回当前登录人的操作记录,不提供全员查询参数。</summary>
[HttpGet("audits")]
public IActionResult Audits200() => Ok(_audits.Recent());
public IActionResult Audits200() => Ok(_audits.Recent(CurrentUsername()));
/// <summary>写一条审计并组装响应(成功时审计落 Reason,失败 / 未下发落具体 message)。</summary>
private ExecuteResponse Done(string user, string scope, ExecuteRequest req, string result, string? message, bool ok = true)
@@ -163,6 +211,14 @@ public class OpsController : ControllerBase
return new ExecuteResponse(ok, entry.Id, message);
}
/// <summary>与 YARP 注入 X-Platform-User 同一套取值:登录名,不是用户 id。</summary>
private string CurrentUsername() =>
User.FindFirst("unique_name")?.Value
?? User.Identity?.Name
?? User.FindFirstValue(ClaimTypes.Name)
?? User.FindFirstValue(ClaimTypes.NameIdentifier)
?? "anonymous";
/// <summary>
/// 前端可能传 "C01" / "M03" / "5",抽取「首段」连续数字作为内核对象 id。
/// 取首段而非拼接所有数字,避免 "AGV-12-3" 被误合并成 123。
@@ -195,4 +251,206 @@ public class OpsController : ControllerBase
catch { /* ignore,下面回退裁剪原文 */ }
return body.Length <= 200 ? body : body[..200] + "…";
}
private static bool CanRunOp(string[] userOps, string opCode, string? scope)
{
if (userOps.Contains("*") || userOps.Contains(opCode)) return true;
if (opCode == CarExecuteOp && userOps.Any(o => o.StartsWith("ops.car.", StringComparison.Ordinal)))
return true;
// 管理面地图监控原先可直调反射;走网关只为记审计,不额外收权。
return opCode == CarExecuteOp
&& string.Equals(scope, PageCatalog.ScopePlatform, StringComparison.OrdinalIgnoreCase);
}
private static bool IsSafeMethodName(string method) =>
method.Length is > 0 and <= 64 && method.All(c => char.IsAsciiLetterOrDigit(c) || c == '_');
private readonly record struct ForwardOutcome(bool Ok, string Result, string? Message);
private async Task<HashSet<string>?> LoadAllowedCarMethodsAsync(int carId)
{
try
{
var bundle = await GetLiteJsonAsync($"/projection/reflection/bundle/car/{carId}");
var config = await GetLiteJsonAsync("/projection/reflection/monitor-config");
if (bundle is null || config is null) return null;
var typeName = ReadString(bundle, "typeName") ?? "";
var fullTypeName = ReadString(bundle, "fullTypeName") ?? typeName;
var map = ReadCarActionByType(config.Value);
return LookupCarMethods(map, fullTypeName, typeName);
}
catch (Exception ex)
{
_log.LogWarning(ex, "读取 monitor-config 失败 car={CarId}", carId);
return null;
}
}
private static Dictionary<string, string[]> ReadCarActionByType(JsonElement envelope)
{
var map = new Dictionary<string, string[]>(StringComparer.Ordinal);
if (!TryData(envelope, out var data)) return map;
if (!data.TryGetProperty("config", out var cfg) || cfg.ValueKind != JsonValueKind.Object)
return map;
if (!cfg.TryGetProperty("carActionByType", out var cat) || cat.ValueKind != JsonValueKind.Object)
return map;
foreach (var prop in cat.EnumerateObject())
{
if (prop.Value.ValueKind != JsonValueKind.Array) continue;
map[prop.Name] = prop.Value.EnumerateArray()
.Where(x => x.ValueKind == JsonValueKind.String)
.Select(x => x.GetString() ?? "")
.Where(x => x.Length > 0)
.ToArray();
}
return map;
}
private static HashSet<string> LookupCarMethods(Dictionary<string, string[]> map, string fullType, string shortType)
{
var shortName = shortType;
var dot = shortType.LastIndexOf('.');
if (dot >= 0 && dot < shortType.Length - 1) shortName = shortType[(dot + 1)..];
if (map.TryGetValue(fullType, out var exact) && exact.Length > 0)
return new HashSet<string>(exact, StringComparer.Ordinal);
if (map.TryGetValue(shortType, out var byType) && byType.Length > 0)
return new HashSet<string>(byType, StringComparer.Ordinal);
if (map.TryGetValue(shortName, out var byShort) && byShort.Length > 0)
return new HashSet<string>(byShort, StringComparer.Ordinal);
foreach (var kv in map)
{
if (kv.Value.Length == 0) continue;
var k = kv.Key;
if (k == fullType || k == shortType || k == shortName) return new HashSet<string>(kv.Value, StringComparer.Ordinal);
if (!string.IsNullOrEmpty(fullType) && (fullType.EndsWith('.' + k, StringComparison.Ordinal) || k.EndsWith('.' + shortName, StringComparison.Ordinal)))
return new HashSet<string>(kv.Value, StringComparer.Ordinal);
if (!string.IsNullOrEmpty(shortName) && k.EndsWith('.' + shortName, StringComparison.Ordinal))
return new HashSet<string>(kv.Value, StringComparer.Ordinal);
}
return new HashSet<string>(StringComparer.Ordinal);
}
private static bool TryData(JsonElement envelope, out JsonElement data)
{
if (envelope.TryGetProperty("data", out data) && data.ValueKind == JsonValueKind.Object)
return true;
data = envelope;
return envelope.ValueKind == JsonValueKind.Object;
}
private static string? ReadString(JsonElement? envelope, string name)
{
if (envelope is null) return null;
var root = envelope.Value;
if (TryData(root, out var data) && data.TryGetProperty(name, out var v) && v.ValueKind == JsonValueKind.String)
return v.GetString();
if (root.TryGetProperty(name, out var direct) && direct.ValueKind == JsonValueKind.String)
return direct.GetString();
return null;
}
private async Task<JsonElement?> GetLiteJsonAsync(string path)
{
var call = await CallLiteAsync(HttpMethod.Get, path, actor: null);
if (!call.Ok) return null;
using var doc = JsonDocument.Parse(call.Body);
return doc.RootElement.Clone();
}
private async Task<ForwardOutcome> ForwardGotoSiteAsync(int carId, int siteId, string actor)
{
var path = $"/projection/reflection/car/{carId}/goto-site?siteId={siteId}";
return await SendLiteExecuteAsync(path, actor);
}
private async Task<ForwardOutcome> ForwardExecuteAsync(
string kind, int id, string method, Dictionary<string, string>? query, string actor)
{
var path = $"/projection/reflection/execute/{kind}/{id}/{Uri.EscapeDataString(method)}";
if (query is { Count: > 0 })
{
var qs = string.Join("&", query
.Where(kv => !string.IsNullOrEmpty(kv.Key))
.Select(kv => $"{Uri.EscapeDataString(kv.Key)}={Uri.EscapeDataString(kv.Value ?? "")}"));
if (qs.Length > 0) path += "?" + qs;
}
return await SendLiteExecuteAsync(path, actor);
}
private async Task<ForwardOutcome> SendLiteExecuteAsync(string path, string actor)
{
try
{
var call = await CallLiteAsync(HttpMethod.Post, path, actor);
if (TryReadConfirmTicket(call.Body, out var token))
{
call = await CallLiteAsync(HttpMethod.Post, path, actor, new Dictionary<string, string>
{
[ConfirmTokenHeader] = token
});
}
var success = call.Ok && ParseSuccess(call.Body);
return success
? new ForwardOutcome(true, "ok", null)
: new ForwardOutcome(false, "failed", $"Simple3 返回 {call.Status}{ExtractMessage(call.Body)}");
}
catch (Exception ex)
{
_log.LogWarning(ex, "ops 转发 Simple3 失败 path={Path}", path);
return new ForwardOutcome(false, "failed", $"下发 Simple3 失败:{ex.GetType().Name}: {ex.Message}");
}
}
private const string ConfirmTokenHeader = "X-Platform-Confirm-Token";
private static bool TryReadConfirmTicket(string body, out string token)
{
token = "";
try
{
using var doc = JsonDocument.Parse(body);
var root = doc.RootElement;
var code = root.TryGetProperty("code", out var c) && c.TryGetInt32(out var n) ? n : 0;
if (code != 428) return false;
if (!root.TryGetProperty("data", out var data) || data.ValueKind != JsonValueKind.Object)
return false;
token = data.TryGetProperty("confirmToken", out var t) && t.ValueKind == JsonValueKind.String
? t.GetString() ?? ""
: "";
return !string.IsNullOrWhiteSpace(token);
}
catch
{
return false;
}
}
private async Task<(bool Ok, int Status, string Body)> CallLiteAsync(
HttpMethod method, string path, string? actor, IReadOnlyDictionary<string, string>? extraHeaders = null)
{
var url = $"http://127.0.0.1:{_sl.ProjectionPort}{path}";
using var client = _httpFactory.CreateClient();
client.Timeout = TimeSpan.FromSeconds(8);
using var msg = new HttpRequestMessage(method, url);
if (!string.IsNullOrEmpty(_internalToken.Token))
msg.Headers.TryAddWithoutValidation("X-Platform-Internal-Token", _internalToken.Token);
if (method == HttpMethod.Post)
msg.Headers.TryAddWithoutValidation("X-Platform-Confirmed", "1");
if (!string.IsNullOrWhiteSpace(actor))
msg.Headers.TryAddWithoutValidation("X-Platform-User", actor);
if (extraHeaders != null)
{
foreach (var kv in extraHeaders)
{
if (string.IsNullOrWhiteSpace(kv.Key) || kv.Value is null) continue;
msg.Headers.TryAddWithoutValidation(kv.Key, kv.Value);
}
}
using var resp = await client.SendAsync(msg);
var body = await resp.Content.ReadAsStringAsync();
return (resp.IsSuccessStatusCode, (int)resp.StatusCode, body);
}
}
+27 -4
View File
@@ -48,7 +48,10 @@ public class OtaController : ControllerBase
if (string.Equals(Scope, "Platform", StringComparison.OrdinalIgnoreCase)) return true;
var ops = User.FindFirst("ops")?.Value ?? "";
var set = ops.Split(' ', StringSplitOptions.RemoveEmptyEntries);
return set.Contains("*") || set.Any(o => o.StartsWith("ops.ota", StringComparison.OrdinalIgnoreCase));
return set.Contains("*")
|| set.Contains("ops.ota")
|| set.Contains("ops.ota.write")
|| set.Any(o => o.StartsWith("ops.ota.", StringComparison.OrdinalIgnoreCase));
}
private bool DenyWrite(out ActionResult denied)
@@ -147,12 +150,32 @@ public class OtaController : ControllerBase
var receiveBase = $"{baseUrl.TrimEnd('/')}/api/ota/receive";
var time = DateTime.Now.ToString("yyyyMMddHHmmss");
await _wd.TriggerPullAsync(car.Ip, receiveBase, time, ct);
// 等待文件落盘
// 等待文件落盘:绝对超时 + 收到文件后的空闲窗口,避免首个组件到达就清会话导致半包
await Task.Delay(1500, ct);
for (var i = 0; i < 40; i++)
var deadline = DateTime.UtcNow.AddSeconds(90);
var idleAfterReceive = TimeSpan.FromSeconds(8);
var lastCount = 0;
DateTime? lastProgressAt = null;
while (DateTime.UtcNow < deadline)
{
ct.ThrowIfCancellationRequested();
var info = _store.ScanPackage(pkgId);
if (info.Components.Count > 0) break;
if (info.Components.Count > lastCount)
{
lastCount = info.Components.Count;
lastProgressAt = DateTime.UtcNow;
}
else if (_store.TryGetLastReceiveAt(car.Ip, out var recvAt) &&
(lastProgressAt == null || recvAt > lastProgressAt.Value))
{
lastProgressAt = recvAt;
}
if (lastCount > 0 &&
lastProgressAt != null &&
DateTime.UtcNow - lastProgressAt.Value >= idleAfterReceive)
break;
await Task.Delay(500, ct);
}
_store.ClearActivePull(car.Ip);
@@ -8,11 +8,14 @@ namespace MiGu.Server.Controllers;
/// WatchDog 回传包接收端。
/// WatchDog 写死 POST 到 http://{config.serverIP}:8000/upload-mdcs/{routeKey}
/// 必须与参考 Electron Express :8000 路径一致;/api/ota/receive/* 仅作兼容别名。
/// 会话校验使用 TCP 对端 IP(见 Program 中 TcpRemoteIp),忽略可伪造的 X-Forwarded-For。
/// </summary>
[ApiController]
[AllowAnonymous]
public class OtaReceiveController : ControllerBase
{
public const string TcpRemoteIpItemKey = "TcpRemoteIp";
private readonly OtaStore _store;
private readonly ILogger<OtaReceiveController> _log;
@@ -40,15 +43,15 @@ public class OtaReceiveController : ControllerBase
[RequestSizeLimit(512_000_000)]
public async Task<IActionResult> UploadHistory(string routeKey, CancellationToken ct)
{
var ip = HttpContext.Connection.RemoteIpAddress?.ToString() ?? "unknown";
var ip = ResolveTcpRemoteIp();
if (!_store.TryGetActivePullId(ip, out _))
{
_log.LogWarning("OTA history rejected without active pull session from {Ip}", ip);
_log.LogWarning("OTA history rejected without active pull session from {Ip}", ip ?? "unknown");
return BadRequest("no active pull session");
}
var day = DateTime.Now.ToString("yyyy-MM-dd");
var dir = Path.Combine(_store.HistoryDir, day, SafeFileName(ip, "unknown"));
var dir = Path.Combine(_store.HistoryDir, day, SafeFileName(ip ?? "unknown", "unknown"));
Directory.CreateDirectory(dir);
var file = await ReadFirstFileAsync(ct);
if (file == null || file.Length == 0) return BadRequest("empty");
@@ -58,6 +61,7 @@ public class OtaReceiveController : ControllerBase
var path = Path.Combine(dir, safeName);
await using var fs = System.IO.File.Create(path);
await file.CopyToAsync(fs, ct);
_store.NotePullReceive(ip);
_log.LogInformation("OTA history receive {Route} -> {Path} ({Len})", routeKey, path, file.Length);
return Ok(new { ok = true });
}
@@ -66,7 +70,7 @@ public class OtaReceiveController : ControllerBase
{
try
{
var clientIp = HttpContext.Connection.RemoteIpAddress?.ToString();
var clientIp = ResolveTcpRemoteIp();
if (!_store.TryGetActivePullId(clientIp, out _))
{
_log.LogWarning("OTA mdcs rejected without active pull session from {Ip}", clientIp ?? "unknown");
@@ -80,8 +84,9 @@ public class OtaReceiveController : ControllerBase
Directory.CreateDirectory(Path.GetDirectoryName(dest)!);
await using (var fs = System.IO.File.Create(dest))
await file.CopyToAsync(fs, ct);
_store.NotePullReceive(clientIp);
_log.LogInformation("OTA mdcs receive {Route} -> {Dest} ({Len})", routeKey, dest, file.Length);
return Ok(new { ok = true, path = dest });
return Ok(new { ok = true });
}
catch (Exception ex)
{
@@ -90,6 +95,14 @@ public class OtaReceiveController : ControllerBase
}
}
/// <summary>优先取 ForwardedHeaders 之前写入的 TCP 对端 IP,避免 X-Forwarded-For 投毒。</summary>
private string? ResolveTcpRemoteIp()
{
if (HttpContext.Items.TryGetValue(TcpRemoteIpItemKey, out var boxed) && boxed is string s && !string.IsNullOrWhiteSpace(s))
return s;
return HttpContext.Connection.RemoteIpAddress?.ToString();
}
private async Task<IFormFile?> ReadFirstFileAsync(CancellationToken ct)
{
if (!Request.HasFormContentType) return null;
@@ -4,8 +4,8 @@ using Microsoft.AspNetCore.Mvc;
namespace MiGu.Server.Controllers;
/// <summary>
/// 投影 API 占位:真实落地时由 YARP 反代到 SimpleLite WebAPI 的 /api/projection/* 路径。
/// 本地 Mock 数据仅用于无 SimpleLite 运行时的开发联调。
/// 投影 API 占位:真实落地时由 YARP 反代到 Simple3 WebAPI 的 /api/projection/* 路径。
/// 本地 Mock 数据仅用于无 Simple3 运行时的开发联调。
///
/// AR-4: 全 class 加 [Authorize] —— 任何登录用户都能读 mock 投影数据;未登录直接 401。
/// </summary>
+67 -6
View File
@@ -29,10 +29,13 @@ public class RbacController : ControllerBase
new("ops.car.gohome", "车辆 · 回库"),
new("ops.car.resetSession", "车辆 · 重置会话"),
new("ops.car.manualCharge", "车辆 · 手动充电"),
new("ops.car.execute", "车辆 · 地图监控动作(按管理端配置)"),
new("ops.task.pause", "任务 · 暂停"),
new("ops.task.cancel", "任务 · 取消"),
new("ops.task.reassign", "任务 · 改派"),
new("ops.task.boostPriority", "任务 · 提升优先级"),
new("ops.ota", "OTA · 运维读写"),
new("ops.ota.write", "OTA · 写操作"),
new("monitor.note.write", "监控 · 写运营备注"),
new("auth.manage", "系统 · 权限与角色管理"),
};
@@ -59,18 +62,35 @@ public class RbacController : ControllerBase
/// <summary>权限「字典」:页面清单 + 可选操作码 + 可选控件 + scope 选项。前端角色编辑器据此渲染勾选项。</summary>
[HttpGet("catalog")]
public IActionResult Catalog() => Ok(new
public IActionResult Catalog()
{
var actorPages = ActorPlatformPages();
var grantablePlatform = PageCatalog.GrantablePlatformPages(actorPages).OrderBy(x => x).ToList();
var grantableMonitor = PageCatalog.GrantableMonitorPages(actorPages).OrderBy(x => x).ToList();
return Ok(new
{
pages = PageCatalog.All,
ops = KnownOps,
widgets = KnownWidgets,
scopes = new[]
{
new { value = PageCatalog.ScopePlatform, label = "管理 (Platform)" },
new { value = PageCatalog.ScopeMonitor, label = "运营 (RCSMonitor)" },
new { value = PageCatalog.ScopePlatform, label = "管理权限 (Platform)" },
new { value = PageCatalog.ScopeMonitor, label = "运营权限 (RCSMonitor)" },
new { value = PageCatalog.Wildcard, label = "通用 (全部域)" },
}
},
// 当前登录管理员可勾选的页面(运营端由管理端已有页映射而来)。
grantablePages = new Dictionary<string, List<string>>(StringComparer.OrdinalIgnoreCase)
{
[PageCatalog.ScopePlatform] = grantablePlatform,
[PageCatalog.ScopeMonitor] = grantableMonitor,
[PageCatalog.Wildcard] = grantablePlatform.Concat(grantableMonitor).Distinct(StringComparer.OrdinalIgnoreCase).OrderBy(x => x).ToList(),
},
platformToMonitor = PageCatalog.PlatformToMonitor
.Select(kv => new { platform = kv.Key, monitor = kv.Value })
.ToList(),
monitorOnlyPages = PageCatalog.MonitorOnlyPages,
});
}
// ───────────────────────── 角色 ─────────────────────────
@@ -78,10 +98,12 @@ public class RbacController : ControllerBase
public IActionResult ListRoles() => Ok(_store.ListRoles());
[HttpPost("roles")]
public IActionResult CreateRole([FromBody] SaveRoleRequest req) => Guard(() => Ok(_store.CreateRole(req)));
public IActionResult CreateRole([FromBody] SaveRoleRequest req) => Guard(() =>
Ok(_store.CreateRole(ClampPages(req))));
[HttpPut("roles/{id}")]
public IActionResult UpdateRole(string id, [FromBody] SaveRoleRequest req) => Guard(() => Ok(_store.UpdateRole(id, req)));
public IActionResult UpdateRole(string id, [FromBody] SaveRoleRequest req) => Guard(() =>
Ok(_store.UpdateRole(id, ClampPages(req))));
[HttpDelete("roles/{id}")]
public IActionResult DeleteRole(string id) => Guard(() =>
@@ -134,4 +156,43 @@ public class RbacController : ControllerBase
private string? CurrentUserId() =>
User.FindFirstValue("sub") ?? User.FindFirstValue(ClaimTypes.NameIdentifier);
/// <summary>当前登录管理员在管理端的有效页面集合。</summary>
private List<string> ActorPlatformPages()
{
var id = CurrentUserId();
if (string.IsNullOrEmpty(id)) return new();
var user = _store.FindUserById(id);
if (user is null) return new();
return _store.ComputeEffective(user, PageCatalog.ScopePlatform).Pages;
}
/// <summary>
/// 保存角色时按「当前管理员可授页面」裁剪:运营端页只能选自管理端已有页的映射 + 运营专属页。
/// 通配 <c>*</c> 仅当可授集合已覆盖该域全部页面时才保留。
/// </summary>
private SaveRoleRequest ClampPages(SaveRoleRequest req)
{
var scope = string.IsNullOrWhiteSpace(req.Scope) ? PageCatalog.ScopePlatform : req.Scope.Trim();
var grantable = PageCatalog.GrantablePagesForRoleScope(scope, ActorPlatformPages());
var pages = req.Pages ?? new List<string>();
if (pages.Contains(PageCatalog.Wildcard, StringComparer.OrdinalIgnoreCase))
{
var scopeKeys = scope == PageCatalog.Wildcard
? PageCatalog.All.Select(p => p.Key).ToList()
: PageCatalog.KeysForScope(scope).ToList();
if (scopeKeys.All(k => grantable.Contains(k)))
return req with { Pages = new List<string> { PageCatalog.Wildcard } };
return req with
{
Pages = scopeKeys.Where(k => grantable.Contains(k)).Distinct(StringComparer.OrdinalIgnoreCase).ToList()
};
}
var clamped = pages
.Select(PageCatalog.NormalizeKey)
.Where(p => p == PageCatalog.Wildcard || (PageCatalog.IsValidKey(p) && grantable.Contains(p)))
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToList();
return req with { Pages = clamped };
}
}
@@ -0,0 +1,112 @@
using System.Text.Json;
using System.Text.Json.Nodes;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using MiGu.Server.Configs;
using MiGu.Server.Signal;
namespace MiGu.Server.Controllers;
/// <summary>
/// 迷毂「数据中心」:把 scene.signal 的 Model JSON 以表格读写(PLC 握手 / 磁条交管)。
/// 文件落在 Simple3 工作目录 <c>Config/Signal/*.json</c>,不依赖 Simple3 进程是否在跑。
/// </summary>
[ApiController]
[Authorize(Policy = "PlatformScope")]
[Route("api/signal-data")]
public sealed class SignalDataController : ControllerBase
{
private readonly SignalDataStore _store;
private readonly ConfigStore _config;
public SignalDataController(SignalDataStore store, ConfigStore config)
{
_store = store;
_config = config;
}
[HttpGet]
public IActionResult List([FromQuery] bool summary = false)
{
var enabled = SignalEnabled();
var tables = summary
? _store.GetTables().Select(ProjectSummary).ToList()
: _store.GetTables().Select(ProjectTable).ToList();
return Ok(new
{
signalEnabled = enabled,
workingDirectory = _store.ResolveWorkingDirectory(),
tables
});
}
[HttpGet("{id}")]
public IActionResult Get(string id)
{
var table = _store.Find(id);
if (table == null)
return NotFound(new { message = $"未知数据表:{id}" });
return Ok(ProjectTable(table));
}
[HttpPut("{id}")]
public IActionResult Save(string id, [FromBody] JsonElement body)
{
var table = _store.Find(id);
if (table == null)
return NotFound(new { message = $"未知数据表:{id}" });
if (!body.TryGetProperty("rows", out var rowsEl) || rowsEl.ValueKind != JsonValueKind.Array)
return BadRequest(new { message = "请求体需要 rows 数组" });
JsonArray rows;
try
{
rows = JsonNode.Parse(rowsEl.GetRawText()) as JsonArray ?? new JsonArray();
}
catch (Exception ex)
{
return BadRequest(new { message = $"rows 不是合法 JSON 数组:{ex.Message}" });
}
try
{
_store.SaveRows(table, rows);
}
catch (Exception ex)
{
return StatusCode(500, new { message = $"保存失败:{ex.Message}" });
}
return Ok(ProjectTable(table));
}
private object ProjectSummary(SignalTableDef table) => new
{
id = table.Id,
title = table.Title,
category = table.Category,
fileName = table.FileName
};
private object ProjectTable(SignalTableDef table)
{
var (path, error) = _store.ResolveFile(table, createDir: false);
var exists = path != null && System.IO.File.Exists(path);
return new
{
id = table.Id,
title = table.Title,
category = table.Category,
fileName = table.FileName,
exists,
error,
columns = table.Columns,
rows = _store.LoadRows(table)
};
}
private bool SignalEnabled() =>
_config.GetDeployment().ToLauncherSceneIds()
.Contains(DeploymentProfile.SignalSceneId, StringComparer.OrdinalIgnoreCase);
}
@@ -10,7 +10,7 @@ namespace MiGu.Server.Controllers;
/// 数据持久化于 <c>platform.db</c> 的 <c>simple_fields</c> 表。
///
/// 对应前端「字段管理」页(<c>/admin/simple-fields</c>,页面 key <c>admin-simple-fields</c>)。
/// 前端主流程为「刷新」读库、「默认」从 SimpleLite 拉取模板、「保存」走 <see cref="SaveBatch"/> 全量替换。
/// 前端主流程为「刷新」读库、「默认」从 Simple3 拉取模板、「保存」走 <see cref="SaveBatch"/> 全量替换。
/// </summary>
[ApiController]
[Authorize]
+8 -9
View File
@@ -17,7 +17,7 @@ namespace MiGu.Server.Controllers;
/// </list>
/// 说明:保存时即把选型固化为单一事实来源 <c>deployment</c> section,并同步联动 Launcher ——
/// <see cref="SaveProfile"/> 调 <c>WriteActiveScenes</c> 写 <c>plugins/active-scenes.json</c> / 透传 <c>--scenes</c>
/// 驱动 SimpleLite 下次启动选择性加载选定的导航场景插件。
/// 驱动 Simple3 下次启动选择性加载选定的导航场景插件。
/// </summary>
[ApiController]
[Authorize]
@@ -25,10 +25,10 @@ namespace MiGu.Server.Controllers;
public class WizardController : ControllerBase
{
private readonly ConfigStore _store;
private readonly SimpleLiteLauncher _launcher;
private readonly Simple3Launcher _launcher;
private readonly ILogger<WizardController> _log;
public WizardController(ConfigStore store, SimpleLiteLauncher launcher, ILogger<WizardController> log)
public WizardController(ConfigStore store, Simple3Launcher launcher, ILogger<WizardController> log)
{
_store = store;
_launcher = launcher;
@@ -81,12 +81,11 @@ public class WizardController : ControllerBase
_store.PutDeployment(profile);
// 平台 → 内核联动:把导航选型写入 SimpleLite 的 plugins/active-scenes.json(下次启动选择性加载;
// 平台 → 内核联动:把导航选型写入 Simple3 的 plugins/active-scenes.json(下次启动选择性加载;
// 已运行实例可由前端再调 POST /api/sl/projection/scenes/apply 触发增量 reload)。
// scene.device(门/充电桩/按钮盒驱动)为各类项目通用能力,向导暂无独立选项,固定并入激活集合;
// scene.vda5050 等协议插件保持按需(不在集合则不加载)。基座 StandardScene.dll 由内核按
// 清单 requiresCore 自动 alwaysLoad,无需在此声明。
var sceneIds = profile.ToActiveSceneIds().Concat(new[] { "scene.device" }).Distinct().ToList();
// 选 SPS / Pack 时 ToLauncherSceneIds 会并入 scene.signalPLC 握手)。
var sceneIds = profile.ToLauncherSceneIds();
var write = _launcher.WriteActiveScenes(sceneIds, alwaysLoad: null, source: "deployment-profile");
_log.LogInformation("部署向导已保存 by={User} nav=[{Nav}] scenes=[{Scenes}] activeScenesWritten={Ok}",
@@ -108,7 +107,7 @@ public class WizardController : ControllerBase
/// 统一对外投影:camelCase 字段 + 推导出的 activeSceneIds(导航选型 → 内核场景 id)+
/// hiddenPages(被裁剪的菜单页)+ 可选的 activeScenesWrite(保存时写 active-scenes.json 的结果)。
/// </summary>
private static object Project(DeploymentProfile dp, SimpleLiteLauncher.ActiveScenesWriteResult? write = null) => new
private static object Project(DeploymentProfile dp, Simple3Launcher.ActiveScenesWriteResult? write = null) => new
{
configured = dp.Configured,
platformType = dp.PlatformType,
@@ -116,7 +115,7 @@ public class WizardController : ControllerBase
navigationKinds = dp.NavigationKinds,
scenarios = dp.Scenarios,
updatedBy = dp.UpdatedBy,
activeSceneIds = dp.ToActiveSceneIds(),
activeSceneIds = dp.ToLauncherSceneIds(),
hiddenPages = DeploymentCatalog.HiddenPages(dp),
activeScenesWrite = write == null ? null : new
{
+19 -6
View File
@@ -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;
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;
+19 -12
View File
@@ -8,14 +8,14 @@ using MiGu.Server.Persistence;
namespace MiGu.Server.Fleet;
/// <summary>
/// 单例:轮询 SimpleLite 车辆 + 每车状态,读取「车体_AlarmInfo/车体_AlarmLevel」并对帐进 platform.db(vehicle_alarms)。
/// 出现→开 active;文案变→更新;消失→置 cleared 并记录恢复时间/时长。永不删=完整历史;SimpleLite 离线仍可查最近记录。
/// 单例:轮询 Simple3 车辆 + 每车状态,读取「车体_AlarmInfo/车体_AlarmLevel」并对帐进 platform.db(vehicle_alarms)。
/// 出现→开 active;文案变→更新;消失→置 cleared 并记录恢复时间/时长。永不删=完整历史;Simple3 离线仍可查最近记录。
/// </summary>
public sealed class AlarmCollector
{
private readonly IServiceScopeFactory _scopeFactory;
private readonly IHttpClientFactory _httpFactory;
private readonly SimpleLiteOptions _sl;
private readonly Simple3Options _sl;
private readonly InternalTokenStore _token;
private readonly ILogger<AlarmCollector> _log;
private readonly SemaphoreSlim _gate = new(1, 1);
@@ -28,7 +28,7 @@ public sealed class AlarmCollector
public AlarmCollector(
IServiceScopeFactory scopeFactory,
IHttpClientFactory httpFactory,
IOptions<SimpleLiteOptions> sl,
IOptions<Simple3Options> sl,
InternalTokenStore token,
ILogger<AlarmCollector> log)
{
@@ -76,7 +76,7 @@ public sealed class AlarmCollector
try
{
using var scope = _scopeFactory.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<PlatformDbContext>();
var db = scope.ServiceProvider.GetRequiredService<MiGuDbContext>();
await ReconcileAsync(db, current, ct);
}
catch (Exception ex)
@@ -161,25 +161,33 @@ public sealed class AlarmCollector
}
}
private static async Task ReconcileAsync(PlatformDbContext db, Dictionary<int, CurrentAlarm> current, CancellationToken ct)
private static async Task ReconcileAsync(MiGuDbContext db, Dictionary<int, CurrentAlarm> current, CancellationToken ct)
{
var now = DateTimeOffset.UtcNow;
var active = await db.VehicleAlarms.Where(a => a.Status == "active").ToListAsync(ct);
var activeByCar = new Dictionary<int, VehicleAlarmRecord>();
foreach (var a in active) activeByCar[a.CarId] = a; // 每车取一条 active
// 出现 / 更新
// 出现 / 更新:文案或级别变化时先 clear 旧记录再开新 active,保留分段历史
foreach (var cur in current.Values)
{
if (activeByCar.TryGetValue(cur.CarId, out var rec))
{
rec.Info = cur.Info;
rec.Level = cur.Level;
var same =
string.Equals(rec.Info, cur.Info, StringComparison.Ordinal) &&
rec.Level == cur.Level;
if (same)
{
rec.CarName = cur.CarName;
rec.LastAt = now;
continue;
}
else
{
rec.Status = "cleared";
rec.ResolvedAt = now;
rec.DurationSecs = (long)Math.Max(0, (now - rec.FirstAt).TotalSeconds);
}
db.VehicleAlarms.Add(new VehicleAlarmRecord
{
CarId = cur.CarId,
@@ -191,7 +199,6 @@ public sealed class AlarmCollector
LastAt = now
});
}
}
// 消失 → 恢复
foreach (var rec in active)
+6 -6
View File
@@ -32,14 +32,14 @@ public sealed class CdmTaskDto
}
/// <summary>
/// 单例:从 SimpleLite 投影拉取 CDM 任务并 upsert 到 platform.db(cdm_tasks),永不删除=保留历史。
/// 同时维护「SimpleLite 是否在线 / 最近同步时间」,供任务页离线降级展示。
/// 单例:从 Simple3 投影拉取 CDM 任务并 upsert 到 platform.db(cdm_tasks),永不删除=保留历史。
/// 同时维护「Simple3 是否在线 / 最近同步时间」,供任务页离线降级展示。
/// </summary>
public sealed class CdmTaskSyncer
{
private readonly IServiceScopeFactory _scopeFactory;
private readonly IHttpClientFactory _httpFactory;
private readonly SimpleLiteOptions _sl;
private readonly Simple3Options _sl;
private readonly InternalTokenStore _token;
private readonly ILogger<CdmTaskSyncer> _log;
private readonly SemaphoreSlim _gate = new(1, 1);
@@ -53,7 +53,7 @@ public sealed class CdmTaskSyncer
public CdmTaskSyncer(
IServiceScopeFactory scopeFactory,
IHttpClientFactory httpFactory,
IOptions<SimpleLiteOptions> sl,
IOptions<Simple3Options> sl,
InternalTokenStore token,
ILogger<CdmTaskSyncer> log)
{
@@ -80,7 +80,7 @@ public sealed class CdmTaskSyncer
try
{
using var scope = _scopeFactory.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<PlatformDbContext>();
var db = scope.ServiceProvider.GetRequiredService<MiGuDbContext>();
await UpsertAsync(db, dtos, ct);
}
catch (Exception ex)
@@ -126,7 +126,7 @@ public sealed class CdmTaskSyncer
}
}
private static async Task UpsertAsync(PlatformDbContext db, IReadOnlyList<CdmTaskDto> dtos, CancellationToken ct)
private static async Task UpsertAsync(MiGuDbContext db, IReadOnlyList<CdmTaskDto> dtos, CancellationToken ct)
{
var valid = dtos.Where(d => !string.IsNullOrWhiteSpace(d.id)).ToList();
if (valid.Count == 0) return;
+1 -1
View File
@@ -1,6 +1,6 @@
namespace MiGu.Server.Fleet;
/// <summary>与 SimpleLite GET /projection/fleet/health 行对齐,供车队运维前端消费。</summary>
/// <summary>与 Simple3 GET /projection/fleet/health 行对齐,供车队运维前端消费。</summary>
public sealed class FleetHealthRowDto
{
public int CarId { get; set; }
+9 -9
View File
@@ -7,13 +7,13 @@ using MiGu.Server.Ota;
namespace MiGu.Server.Fleet;
/// <summary>
/// 车队健康:保留 SimpleLite 的 CPU/故障率等,延迟改为对 WatchDog(:9776) 做 TCP RTT。
/// SimpleLite /fleet/health 探测的是车载 HTTP :8081,多数现场未开该端口会假超时 2000ms。
/// 车队健康:保留 Simple3 的 CPU/故障率等,延迟改为对 WatchDog(:9776) 做 TCP RTT。
/// Simple3 /fleet/health 探测的是车载 HTTP :8081,多数现场未开该端口会假超时 2000ms。
/// </summary>
public sealed class FleetHealthService
{
private readonly IHttpClientFactory _httpFactory;
private readonly SimpleLiteOptions _sl;
private readonly Simple3Options _sl;
private readonly InternalTokenStore _token;
private readonly WatchDogClient _wd;
private readonly ILogger<FleetHealthService> _log;
@@ -25,7 +25,7 @@ public sealed class FleetHealthService
public FleetHealthService(
IHttpClientFactory httpFactory,
IOptions<SimpleLiteOptions> sl,
IOptions<Simple3Options> sl,
InternalTokenStore token,
WatchDogClient wd,
ILogger<FleetHealthService> log)
@@ -39,7 +39,7 @@ public sealed class FleetHealthService
public async Task<List<FleetHealthRowDto>> GetAsync(CancellationToken ct)
{
var rows = await FetchSimpleLiteHealthAsync(ct);
var rows = await FetchSimple3HealthAsync(ct);
if (rows.Count == 0)
rows = await BuildRowsFromCarsAsync(ct);
@@ -50,7 +50,7 @@ public sealed class FleetHealthService
{
if (string.IsNullOrWhiteSpace(row.Ip))
{
// 无 IP 时保留 SimpleLite 原探测结果
// 无 IP 时保留 Simple3 原探测结果
row.LatencySource ??= row.LatencyMs != null ? "onboard" : "none";
return;
}
@@ -75,7 +75,7 @@ public sealed class FleetHealthService
}
else
{
// WatchDog 不通:若 SimpleLite 车载探测成功则保留,否则标不可达
// WatchDog 不通:若 Simple3 车载探测成功则保留,否则标不可达
if (row.Reachable == true && row.LatencyMs is > 0 and < 2000)
{
row.LatencySource = "onboard";
@@ -93,7 +93,7 @@ public sealed class FleetHealthService
return rows;
}
private async Task<List<FleetHealthRowDto>> FetchSimpleLiteHealthAsync(CancellationToken ct)
private async Task<List<FleetHealthRowDto>> FetchSimple3HealthAsync(CancellationToken ct)
{
var port = _sl.ProjectionPort > 0 ? _sl.ProjectionPort : 8222;
try
@@ -114,7 +114,7 @@ public sealed class FleetHealthService
}
catch (Exception ex)
{
_log.LogDebug(ex, "fleet/health from SimpleLite failed");
_log.LogDebug(ex, "fleet/health from Simple3 failed");
return new();
}
}
+21
View File
@@ -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;
@@ -4,45 +4,46 @@ using Microsoft.Extensions.Logging;
namespace MiGu.Server.Launcher;
/// <summary>
/// 将 <c>obj/Debug</c> 下最新编译的 SimpleLite 同步到 <c>bin/Debug</c>(仅当 SimpleLite 未运行时)。
/// 将 <c>obj/Debug</c> 下最新编译的 Simple3 同步到 <c>bin/Debug</c>(仅当 Simple3 未运行时)。
/// </summary>
public static class SimpleLiteBuildSync
public static class Simple3BuildSync
{
public static bool TrySyncFromObjToBin(string contentRoot, ILogger? log = null)
{
if (!TryResolvePaths(contentRoot, out var objDll, out var objExe, out var binDir))
return false;
var binDll = Path.Combine(binDir, "SimpleLite.dll");
var binExe = Path.Combine(binDir, "SimpleLite.exe");
var binDll = Path.Combine(binDir, "Simple3.dll");
var binExe = Path.Combine(binDir, "Simple3.exe");
if (!File.Exists(objDll))
{
log?.LogDebug("[SimpleLiteBuildSync] obj DLL 不存在: {Path}", objDll);
log?.LogDebug("[Simple3BuildSync] obj DLL 不存在: {Path}", objDll);
return false;
}
if (Process.GetProcessesByName("SimpleLite").Any(p => !p.HasExited))
if (Process.GetProcessesByName("Simple3").Any(p => !p.HasExited)
|| Process.GetProcessesByName("SimpleLite").Any(p => !p.HasExited))
{
log?.LogWarning("[SimpleLiteBuildSync] SimpleLite 仍在运行,跳过 DLL 同步。请先关闭 SimpleLite 窗口。");
log?.LogWarning("[Simple3BuildSync] Simple3 仍在运行,跳过 DLL 同步。请先关闭内核窗口。");
return false;
}
var objTime = File.GetLastWriteTimeUtc(objDll);
if (File.Exists(binDll) && File.GetLastWriteTimeUtc(binDll) >= objTime)
{
log?.LogDebug("[SimpleLiteBuildSync] bin 已是最新,无需同步");
log?.LogDebug("[Simple3BuildSync] bin 已是最新,无需同步");
return false;
}
Directory.CreateDirectory(binDir);
File.Copy(objDll, binDll, true);
log?.LogInformation("[SimpleLiteBuildSync] 已同步 {Src} → {Dst}", objDll, binDll);
log?.LogInformation("[Simple3BuildSync] 已同步 {Src} → {Dst}", objDll, binDll);
if (File.Exists(objExe))
{
File.Copy(objExe, binExe, true);
log?.LogInformation("[SimpleLiteBuildSync] 已同步 {Src} → {Dst}", objExe, binExe);
log?.LogInformation("[Simple3BuildSync] 已同步 {Src} → {Dst}", objExe, binExe);
}
SyncRuntimeDeps(objDll, binDir, log);
@@ -51,7 +52,7 @@ public static class SimpleLiteBuildSync
}
/// <summary>
/// 探测 SimpleLite 是否带「前往站点」路由(区分新旧 DLL)。
/// 探测 Simple3 是否带「前往站点」路由(区分新旧 DLL)。
/// 必须用不存在的对象 id(-1):路由存在时后端进 handler 找不到对象,返回 JSON
/// <c>success:false</c>(无任何副作用);路由不存在时 EmbedIO 返回 HTML 404。
/// 早期版本误用 car/0 + siteId=0 —— 一旦场景里真有 id=0 的车和站点,每次健康
@@ -96,7 +97,7 @@ public static class SimpleLiteBuildSync
var dst = Path.Combine(binDir, name);
File.Copy(src, dst, true);
log?.LogInformation("[SimpleLiteBuildSync] 已同步依赖 {Name}", name);
log?.LogInformation("[Simple3BuildSync] 已同步依赖 {Name}", name);
}
}
@@ -106,11 +107,11 @@ public static class SimpleLiteBuildSync
binDir = "";
var repo = FindRepoRoot(contentRoot);
if (repo == null) return false;
var sl = Path.Combine(repo, "Simple", "SimpleLite");
objDll = Path.Combine(sl, "obj", "Debug", "SimpleLite.dll");
objExe = Path.Combine(sl, "obj", "Debug", "SimpleLite.exe");
var sl = Path.Combine(repo, "Simple", "Simple3");
objDll = Path.Combine(sl, "obj", "Debug", "Simple3.dll");
objExe = Path.Combine(sl, "obj", "Debug", "Simple3.exe");
binDir = Path.Combine(sl, "bin", "Debug");
return true;
return Directory.Exists(sl);
}
private static string? FindRepoRoot(string contentRoot)
@@ -118,7 +119,8 @@ public static class SimpleLiteBuildSync
var dir = new DirectoryInfo(contentRoot);
while (dir != null)
{
if (Directory.Exists(Path.Combine(dir.FullName, "Simple", "SimpleLite")))
if (Directory.Exists(Path.Combine(dir.FullName, "Simple", "Simple3"))
|| Directory.Exists(Path.Combine(dir.FullName, "Simple", "SimpleLite")))
return dir.FullName;
dir = dir.Parent;
}
@@ -7,21 +7,21 @@ using Microsoft.Extensions.Options;
namespace MiGu.Server.Launcher;
/// <summary>
/// 平台登录成功后按 LaunchMode 把 SimpleLite.exe 作为子进程拉起。
/// 平台登录成功后按 LaunchMode 把 Simple3.exe 作为子进程拉起。
///
/// 设计要点(与原 <c>SimpleLite/Platform/PlatformLauncher.cs</c> 镜像):
/// 设计要点(与原 <c>Simple3/Platform/PlatformLauncher.cs</c> 镜像):
/// - 幂等:第一次登录拉起,后续登录复用同一个子进程(不会重复启)。
/// - 子进程退出后下一次登录可以重新拉起(不阻塞用户重试)。
/// - 默认独立(<see cref="SimpleLiteOptions.FollowParent"/> = false):SimpleLite 与 MiGu.Server 互不影响,
/// - 默认独立(<see cref="Simple3Options.FollowParent"/> = false):Simple3 与 MiGu.Server 互不影响,
/// 关闭任一方不会 kill 另一方;Windows 上用 <c>cmd /c start</c> 脱离父进程组/控制台。
/// - 可选跟随(FollowParent = true):Windows JobObject 绑定,MiGu.Server 退出时一并结束 SimpleLite
/// - 命令行透传:把 LaunchMode 翻成 SimpleLite 的 <c>--display-mode=web</c> / <c>--display-mode=web+local</c>。
/// - 启动后阻塞等待 SimpleLite Projection :8222 端口可达(最长 ReadinessTimeoutMs),让前端 /api/sl/* 不再立刻 502。
/// - 可选跟随(FollowParent = true):Windows JobObject 绑定,MiGu.Server 退出时一并结束 Simple3
/// - 命令行透传:把 LaunchMode 翻成 Simple3 的 <c>--display-mode=web</c> / <c>--display-mode=web+local</c>。
/// - 启动后阻塞等待 Simple3 Projection :8222 端口可达(最长 ReadinessTimeoutMs),让前端 /api/sl/* 不再立刻 502。
/// </summary>
public sealed class SimpleLiteLauncher : IDisposable
public sealed class Simple3Launcher : IDisposable
{
private readonly SimpleLiteOptions _opts;
private readonly ILogger<SimpleLiteLauncher> _log;
private readonly Simple3Options _opts;
private readonly ILogger<Simple3Launcher> _log;
private readonly IHostEnvironment _env;
private readonly object _sync = new();
@@ -30,13 +30,13 @@ public sealed class SimpleLiteLauncher : IDisposable
private string? _lastLaunchMode;
private bool _disposed;
public SimpleLiteLauncher(IOptions<SimpleLiteOptions> opts, ILogger<SimpleLiteLauncher> log, IHostEnvironment env)
public Simple3Launcher(IOptions<Simple3Options> opts, ILogger<Simple3Launcher> log, IHostEnvironment env)
{
_opts = opts.Value;
_log = log;
_env = env;
// 会话 N+2:默认不再在 ProcessExit 时清理子进程 —— SimpleLite 是「独立程序」,MiGu.Server 关掉
// 不应该带走 SimpleLite。仅当用户显式 opt-in FollowParent=true 时才挂软关闭兜底。
// 会话 N+2:默认不再在 ProcessExit 时清理子进程 —— Simple3 是「独立程序」,MiGu.Server 关掉
// 不应该带走 Simple3。仅当用户显式 opt-in FollowParent=true 时才挂软关闭兜底。
if (_opts.FollowParent)
{
AppDomain.CurrentDomain.ProcessExit += (_, _) => Dispose();
@@ -53,11 +53,11 @@ public sealed class SimpleLiteLauncher : IDisposable
public string? LastLaunchMode { get { lock (_sync) return _lastLaunchMode; } }
/// <summary>外部已存在的 SimpleLiteMiGu.Server 重启复用上一轮实例)占位 LaunchMode 值,不参与命令行 displayMode 翻译。</summary>
/// <summary>外部已存在的 Simple3MiGu.Server 重启复用上一轮实例)占位 LaunchMode 值,不参与命令行 displayMode 翻译。</summary>
internal const string ExternalReuseLaunchMode = "external";
/// <summary>
/// 按 launchMode 拉起 SimpleLite(已运行则跳过)。
/// 按 launchMode 拉起 Simple3(已运行则跳过)。
/// </summary>
/// <param name="launchMode">"WebOnly" 或 "DesktopAndWeb"(大小写不敏感)。</param>
/// <returns>本次调用产生的状态摘要,可写入登录响应或日志。</returns>
@@ -67,20 +67,20 @@ public sealed class SimpleLiteLauncher : IDisposable
if (!_opts.Enabled)
{
_log.LogInformation("[SimpleLite] auto-start disabled (appsettings: SimpleLite.Enabled=false); launchMode={Mode} ignored", launchMode);
return new LaunchResult(false, "Disabled", "appsettings:SimpleLite:Enabled=false", DisplayMode: null,
Warning: "SimpleLite 自动拉起已被 appsettings:SimpleLite:Enabled=false 关闭;登录已成功但 SimpleLite 未启动,/api/sl/* 反代请求会返回 502。");
_log.LogInformation("[Simple3] auto-start disabled (appsettings: Simple3.Enabled=false); launchMode={Mode} ignored", launchMode);
return new LaunchResult(false, "Disabled", "appsettings:Simple3:Enabled=false", DisplayMode: null,
Warning: "Simple3 自动拉起已被 appsettings:Simple3:Enabled=false 关闭;登录已成功但 Simple3 未启动,/api/sl/* 反代请求会返回 502。");
}
lock (_sync)
{
if (_proc is { HasExited: false })
{
_log.LogInformation("[SimpleLite] already running pid={Pid}, displayMode={DisplayMode}; skip duplicate launch", _proc.Id, _lastLaunchMode);
_log.LogInformation("[Simple3] already running pid={Pid}, displayMode={DisplayMode}; skip duplicate launch", _proc.Id, _lastLaunchMode);
var warning = !string.IsNullOrEmpty(_lastLaunchMode) &&
!string.Equals(_lastLaunchMode, displayMode, StringComparison.OrdinalIgnoreCase)
? $"SimpleLite 已经在运行(pid={_proc.Id}, displayMode={_lastLaunchMode})。" +
$"本次选择的启动模式『{launchMode}』未被应用;如需切换,请手动关闭旧 SimpleLite 后重新登录。"
? $"Simple3 已经在运行(pid={_proc.Id}, displayMode={_lastLaunchMode})。" +
$"本次选择的启动模式『{launchMode}』未被应用;如需切换,请手动关闭旧 Simple3 后重新登录。"
: null;
return new LaunchResult(true, "AlreadyRunning",
$"pid={_proc.Id}, displayMode={_lastLaunchMode}",
@@ -88,44 +88,44 @@ public sealed class SimpleLiteLauncher : IDisposable
Warning: warning);
}
// 会话 N+2MiGu.Server 重启后 _proc 引用丢失,但上一轮拉起的 SimpleLite 可能仍在跑(因为
// 会话 N+2MiGu.Server 重启后 _proc 引用丢失,但上一轮拉起的 Simple3 可能仍在跑(因为
// 默认 FollowParent=false 不带走它)。这里在拉起前先探测 Projection 端口:能连通就视为复用,
// 避免「allowMultiple=false 时新 SimpleLite 检测到多开自杀」+「端口冲突」两类常见崩溃。
// 避免「allowMultiple=false 时新 Simple3 检测到多开自杀」+「端口冲突」两类常见崩溃。
//
// 注意:探测仅判断「有 SimpleLite 在 8222 占着」,无法知道它当时选的 LaunchMode。如果用户本次
// 想换模式,这条路径下不会生效;日志里会明确告知,由用户决定是否手动关掉旧 SimpleLite 再登录。
// 注意:探测仅判断「有 Simple3 在 8222 占着」,无法知道它当时选的 LaunchMode。如果用户本次
// 想换模式,这条路径下不会生效;日志里会明确告知,由用户决定是否手动关掉旧 Simple3 再登录。
//
// 增强(A3):先做 TCP 探活(快),再做 HTTP JSON 探针验证「真的是 SimpleLite」,避免被某个偶然占
// 增强(A3):先做 TCP 探活(快),再做 HTTP JSON 探针验证「真的是 Simple3」,避免被某个偶然占
// 用 8222 的无关进程误判成复用。Probe 失败时不再当作复用成功,否则前端会拿到假的 WebEnabled。
if (TryConnect("127.0.0.1", _opts.ProjectionPort, TimeSpan.FromMilliseconds(500)))
{
var probeOk = ProbeSimpleLiteHttp("127.0.0.1", _opts.ProjectionPort, TimeSpan.FromMilliseconds(800));
var probeOk = ProbeSimple3Http("127.0.0.1", _opts.ProjectionPort, TimeSpan.FromMilliseconds(800));
if (!probeOk)
{
_log.LogWarning(
"[SimpleLite] projection port :{Port} is occupied, but SimpleLite HTTP probe failed. Skip launch to avoid port conflict.",
"[Simple3] projection port :{Port} is occupied, but Simple3 HTTP probe failed. Skip launch to avoid port conflict.",
_opts.ProjectionPort);
return new LaunchResult(false, "PortOccupied",
$"projection :{_opts.ProjectionPort} tcp reachable but /projection/cars probe failed",
DisplayMode: null,
Warning: $"端口 :{_opts.ProjectionPort} 已被占用,但未识别为 SimpleLite Projection 服务。" +
"请关闭占用该端口的进程,或调整 SimpleLite:ProjectionPort / SimpleLite 配置后重试。");
Warning: $"端口 :{_opts.ProjectionPort} 已被占用,但未识别为 Simple3 Projection 服务。" +
"请关闭占用该端口的进程,或调整 Simple3:ProjectionPort / Simple3 配置后重试。");
}
_log.LogInformation(
"[SimpleLite] projection :{Port} already reachable (httpProbe=ok); assume an existing SimpleLite is running. " +
"Skip launch. If user picked a different LaunchMode this session, please close the existing SimpleLite window and login again.",
"[Simple3] projection :{Port} already reachable (httpProbe=ok); assume an existing Simple3 is running. " +
"Skip launch. If user picked a different LaunchMode this session, please close the existing Simple3 window and login again.",
_opts.ProjectionPort);
// A2 修复:保留一个占位 LaunchMode 让 LastLaunchMode 不再为 null —— 这样 SwitchScope
// 推断 runMode 时不会落到 "web+local" 兜底,前端 RunMode 角标也不会与实际不符。
_lastLaunchMode = ExternalReuseLaunchMode;
var reuseWarning = $"检测到 SimpleLite 已经在 :{_opts.ProjectionPort} 上运行(可能是 MiGu.Server 重启前残留)。" +
$"本次选择的启动模式『{launchMode}』未被应用到既有实例。如需切换,请手动关闭旧 SimpleLite 窗口后重新登录。";
if (SimpleLiteBuildSync.ProbeGotoSiteRoute(_opts.ProjectionPort) == false)
var reuseWarning = $"检测到 Simple3 已经在 :{_opts.ProjectionPort} 上运行(可能是 MiGu.Server 重启前残留)。" +
$"本次选择的启动模式『{launchMode}』未被应用到既有实例。如需切换,请手动关闭旧 Simple3 窗口后重新登录。";
if (Simple3BuildSync.ProbeGotoSiteRoute(_opts.ProjectionPort) == false)
{
reuseWarning += " 当前 SimpleLite 版本过旧,缺少「前往站点」API;请关闭 SimpleLite 窗口后调用 POST /api/health/simplelite/restart-for-update,或运行 scripts/redeploy-simplelite.ps1。";
reuseWarning += " 当前 Simple3 版本过旧,缺少「前往站点」API;请关闭 Simple3 窗口后调用 POST /api/health/simple3/restart-for-update,或运行 scripts/redeploy-simple3.ps1。";
}
return new LaunchResult(true, "ReusingExisting",
@@ -134,18 +134,18 @@ public sealed class SimpleLiteLauncher : IDisposable
Warning: reuseWarning);
}
SimpleLiteBuildSync.TrySyncFromObjToBin(_env.ContentRootPath, _log);
Simple3BuildSync.TrySyncFromObjToBin(_env.ContentRootPath, _log);
var resolved = ResolveExecutable(_opts.ExecutablePath);
if (resolved == null)
{
_log.LogWarning(
"[SimpleLite] auto-start skipped: SimpleLite.exe not found. Configure appsettings:SimpleLite:ExecutablePath or place SimpleLite next to MiGu.Server. ContentRoot={Root}",
"[Simple3] auto-start skipped: Simple3.exe not found. Configure appsettings:Simple3:ExecutablePath or place Simple3 next to MiGu.Server. ContentRoot={Root}",
_env.ContentRootPath);
return new LaunchResult(false, "ExecutableNotFound",
"Set appsettings:SimpleLite:ExecutablePath or place SimpleLite next to MiGu.Server.",
"Set appsettings:Simple3:ExecutablePath or place Simple3 next to MiGu.Server.",
DisplayMode: null,
Warning: "找不到 SimpleLite.exe。请在 appsettings:SimpleLite:ExecutablePath 显式配置,或者把 SimpleLite.exe 放到 MiGu.Server 同目录。");
Warning: "找不到 Simple3.exe。请在 appsettings:Simple3:ExecutablePath 显式配置,或者把 Simple3.exe 放到 MiGu.Server 同目录。");
}
var workdir = string.IsNullOrWhiteSpace(_opts.WorkingDirectory)
@@ -156,12 +156,12 @@ public sealed class SimpleLiteLauncher : IDisposable
try
{
var proc = StartSimpleLiteProcess(resolved, arguments, workdir);
var proc = StartSimple3Process(resolved, arguments, workdir);
if (proc == null)
{
_log.LogError("[SimpleLite] Process.Start returned null; exe={Exe} args={Args}", resolved, arguments);
_log.LogError("[Simple3] Process.Start returned null; exe={Exe} args={Args}", resolved, arguments);
return new LaunchResult(false, "ProcessStartFailed", $"exe={resolved}", DisplayMode: null,
Warning: $"启动 SimpleLite 进程失败;exe={resolved}");
Warning: $"启动 Simple3 进程失败;exe={resolved}");
}
WireProcessExitHandler(proc);
@@ -171,20 +171,20 @@ public sealed class SimpleLiteLauncher : IDisposable
if (_opts.FollowParent) AttachToJobObject(proc);
_log.LogInformation("[SimpleLite] launched pid={Pid} displayMode={Mode} exe={Exe} args=\"{Args}\" workdir={Workdir}; standalone={Standalone}",
_log.LogInformation("[Simple3] launched pid={Pid} displayMode={Mode} exe={Exe} args=\"{Args}\" workdir={Workdir}; standalone={Standalone}",
proc.Id, displayMode, resolved, arguments, workdir, !_opts.FollowParent);
}
catch (Exception ex)
{
_log.LogError(ex, "[SimpleLite] auto-start failed");
_log.LogError(ex, "[Simple3] auto-start failed");
return new LaunchResult(false, "Exception", ex.Message, DisplayMode: null,
Warning: $"启动 SimpleLite 时抛异常:{ex.GetType().Name}: {ex.Message}");
Warning: $"启动 Simple3 时抛异常:{ex.GetType().Name}: {ex.Message}");
}
}
// M1:登录路径传 waitForReady=false —— 进程拉起后立即返回,不再同步阻塞最长
// ReadinessTimeoutMs 等端口就绪(避免冷启动登录干等十几秒)。前端可轮询
// GET /api/health/simplelite 获知就绪状态。
// GET /api/health/simple3 获知就绪状态。
if (!waitForReady)
{
return new LaunchResult(true, "Starting",
@@ -200,12 +200,12 @@ public sealed class SimpleLiteLauncher : IDisposable
DisplayMode: displayMode,
Warning: ready
? null
: $"SimpleLite 进程已起,但 Projection :{_opts.ProjectionPort} 在 {_opts.ReadinessTimeoutMs}ms 内未响应。前端 /api/sl/* 可能短暂 502;可稍后刷新。");
: $"Simple3 进程已起,但 Projection :{_opts.ProjectionPort} 在 {_opts.ReadinessTimeoutMs}ms 内未响应。前端 /api/sl/* 可能短暂 502;可稍后刷新。");
}
/// <summary>
/// 手动关闭 SimpleLite 子进程。
/// 会话 N+2 起默认不调(FollowParent=false)—— SimpleLite 是独立程序,MiGu.Server 关闭不带走它。
/// 手动关闭 Simple3 子进程。
/// 会话 N+2 起默认不调(FollowParent=false)—— Simple3 是独立程序,MiGu.Server 关闭不带走它。
/// 仅当用户显式 opt-in FollowParent=true 时由 ProcessExit / ApplicationStopping 钩子调用。
/// </summary>
public void Dispose()
@@ -227,7 +227,7 @@ public sealed class SimpleLiteLauncher : IDisposable
// 独立模式:不杀子进程,仅释放 MiGu.Server 侧 Process 句柄。
if (proc is { HasExited: false })
{
_log.LogInformation("[SimpleLite] standalone mode: MiGu.Server stopping — SimpleLite pid={Pid} keeps running (FollowParent=false)", proc.Id);
_log.LogInformation("[Simple3] standalone mode: MiGu.Server stopping — Simple3 pid={Pid} keeps running (FollowParent=false)", proc.Id);
}
try { proc?.Dispose(); } catch { /* ignore */ }
return;
@@ -236,7 +236,7 @@ public sealed class SimpleLiteLauncher : IDisposable
if (proc is { HasExited: false })
{
try { proc.Kill(entireProcessTree: true); }
catch (Exception ex) { _log.LogWarning("[SimpleLite] kill failed: {Msg}", ex.Message); }
catch (Exception ex) { _log.LogWarning("[Simple3] kill failed: {Msg}", ex.Message); }
try { proc.WaitForExit(2000); } catch { /* ignore */ }
}
@@ -248,21 +248,21 @@ public sealed class SimpleLiteLauncher : IDisposable
private static string NormalizeDisplayMode(string launchMode)
{
// LaunchMode 是「业务语言」(WebOnly / DesktopAndWeb)displayMode 是 SimpleLite「内部语言」(web / web+local)。
// LaunchMode 是「业务语言」(WebOnly / DesktopAndWeb)displayMode 是 Simple3「内部语言」(web / web+local)。
// 这里做一次显式翻译,前端 / 后端日志均用业务语言,命令行用内部语言。
return launchMode?.Trim().ToLowerInvariant() switch
{
"webonly" or "web" or "web-only" => "web",
// 任何未知值都按"完整本地+web"兜底,保证最小惊讶(既能桌面用,也能浏览器用)。
_ => "web+local",
"desktopandweb" or "web+local" or "weblocal" or "local" => "web+local",
// Simple3 无本地桌面端:默认 / 未知一律 web
_ => "web",
};
}
private string BuildArguments(string displayMode, string extra)
{
var args = $"--display-mode={displayMode}";
// 迷榖嵌入:平台 iframe 场景默认带 --migu,让 SimpleLite WebTerminal 默认纯画布(隐藏 ImGui panel),
// 业务 UI 由 Vue 平台前端接管;declare 时序失败时也安全回退到画布。可用 appsettings:SimpleLite:EmbeddedCanvas=false 关闭。
// 迷榖嵌入:平台 iframe 场景默认带 --migu,让 Simple3 WebTerminal 默认纯画布(隐藏 ImGui panel),
// 业务 UI 由 Vue 平台前端接管;declare 时序失败时也安全回退到画布。可用 appsettings:Simple3:EmbeddedCanvas=false 关闭。
if (_opts.EmbeddedCanvas) args += " --migu";
// 选择性加载:把平台写入的 plugins/active-scenes.json 同步透传为 --scenes(命令行优先级最高,与文件一致,双保险)。
var sceneArg = ReadActiveScenesArg();
@@ -271,7 +271,7 @@ public sealed class SimpleLiteLauncher : IDisposable
return args;
}
/// <summary>解析 SimpleLite 工作目录(与拉起时一致):优先显式 <see cref="SimpleLiteOptions.WorkingDirectory"/>
/// <summary>解析 Simple3 工作目录(与拉起时一致):优先显式 <see cref="Simple3Options.WorkingDirectory"/>
/// 否则取解析到的 exe 所在目录。两者都拿不到返回 null。</summary>
public string? ResolveWorkingDirectory()
{
@@ -281,7 +281,7 @@ public sealed class SimpleLiteLauncher : IDisposable
return resolved == null ? null : (Path.GetDirectoryName(resolved) ?? Environment.CurrentDirectory);
}
/// <summary>SimpleLite 的 plugins 目录(工作目录/plugins);定位不到工作目录时返回 null。</summary>
/// <summary>Simple3 的 plugins 目录(工作目录/plugins);定位不到工作目录时返回 null。</summary>
public string? ResolvePluginsDir()
{
var wd = ResolveWorkingDirectory();
@@ -289,16 +289,16 @@ public sealed class SimpleLiteLauncher : IDisposable
}
/// <summary>
/// 把「配置向导选定的导航场景」写入 SimpleLite 的 <c>plugins/active-scenes.json</c> ——
/// 这是「平台 → 内核」选择性加载的主通道。下次 SimpleLite 启动即据此只加载选定导航场景插件;
/// 已在运行的实例需重启或调 <c>POST /projection/scenes/apply</c> 才生效。字段名与 SimpleLite
/// 把「配置向导选定的导航场景」写入 Simple3 的 <c>plugins/active-scenes.json</c> ——
/// 这是「平台 → 内核」选择性加载的主通道。下次 Simple3 启动即据此只加载选定导航场景插件;
/// 已在运行的实例需重启或调 <c>POST /projection/scenes/apply</c> 才生效。字段名与 Simple3
/// <c>ActiveScenesConfig</c> 对齐(activeScenes / alwaysLoad / source / updatedAt)。
/// </summary>
public ActiveScenesWriteResult WriteActiveScenes(IEnumerable<string> activeScenes, IEnumerable<string>? alwaysLoad, string source)
{
var pluginsDir = ResolvePluginsDir();
if (pluginsDir == null)
return new ActiveScenesWriteResult(false, null, "未找到 SimpleLite 工作目录/可执行文件,无法定位 plugins 目录");
return new ActiveScenesWriteResult(false, null, "未找到 Simple3 工作目录/可执行文件,无法定位 plugins 目录");
try
{
Directory.CreateDirectory(pluginsDir);
@@ -313,13 +313,13 @@ public sealed class SimpleLiteLauncher : IDisposable
updatedAt = DateTime.UtcNow
};
File.WriteAllText(path, JsonSerializer.Serialize(payload, new JsonSerializerOptions { WriteIndented = true }));
_log.LogInformation("[SimpleLite] active-scenes.json 写入 {Path}: active=[{Scenes}]",
_log.LogInformation("[Simple3] active-scenes.json 写入 {Path}: active=[{Scenes}]",
path, string.Join(",", payload.activeScenes));
return new ActiveScenesWriteResult(true, path, null);
}
catch (Exception ex)
{
_log.LogWarning(ex, "[SimpleLite] 写 active-scenes.json 失败");
_log.LogWarning(ex, "[Simple3] 写 active-scenes.json 失败");
return new ActiveScenesWriteResult(false, null, ex.Message);
}
}
@@ -349,11 +349,12 @@ public sealed class SimpleLiteLauncher : IDisposable
/// <summary>写 active-scenes.json 的结果(供向导保存接口回显)。</summary>
public readonly record struct ActiveScenesWriteResult(bool Ok, string? Path, string? Error);
/// <summary>终止本机全部 SimpleLite 进程并清理 MiGu.Server 侧托管引用。</summary>
/// <summary>终止本机全部 Simple3(及旧名 SimpleLite进程并清理 MiGu.Server 侧托管引用。</summary>
public int StopAll()
{
var killed = 0;
foreach (var proc in Process.GetProcessesByName("SimpleLite"))
foreach (var name in new[] { "Simple3", "SimpleLite" })
foreach (var proc in Process.GetProcessesByName(name))
{
try
{
@@ -361,12 +362,12 @@ public sealed class SimpleLiteLauncher : IDisposable
{
proc.Kill(entireProcessTree: true);
killed++;
_log.LogInformation("[SimpleLite] stop: killed pid={Pid}", proc.Id);
_log.LogInformation("[Simple3] stop: killed {Name} pid={Pid}", name, proc.Id);
}
}
catch (Exception ex)
{
_log.LogWarning("[SimpleLite] stop: kill pid={Pid} failed: {Msg}", proc.Id, ex.Message);
_log.LogWarning("[Simple3] stop: kill {Name} pid={Pid} failed: {Msg}", name, proc.Id, ex.Message);
}
finally
{
@@ -385,7 +386,7 @@ public sealed class SimpleLiteLauncher : IDisposable
return killed;
}
/// <summary>关闭全部 SimpleLite 后按 launchMode 重新拉起(不同步 DLL)。</summary>
/// <summary>关闭全部 Simple3 后按 launchMode 重新拉起(不同步 DLL)。</summary>
public LaunchResult Restart(string launchMode = "webonly")
{
StopAll();
@@ -393,19 +394,19 @@ public sealed class SimpleLiteLauncher : IDisposable
}
/// <summary>
/// 关闭所有 SimpleLite 进程、同步 obj→bin 最新 DLL,再按 launchMode 重新拉起。
/// 关闭所有 Simple3 进程、同步 obj→bin 最新 DLL,再按 launchMode 重新拉起。
/// </summary>
public LaunchResult RestartForUpdate(string launchMode = "webonly")
{
StopAll();
var synced = SimpleLiteBuildSync.TrySyncFromObjToBin(_env.ContentRootPath, _log);
var synced = Simple3BuildSync.TrySyncFromObjToBin(_env.ContentRootPath, _log);
var result = MaybeStart(launchMode);
if (!synced && result.Warning == null)
{
return result with
{
Warning = "未能从 obj/Debug 同步 DLL(可能未编译或 SimpleLite 仍占用文件)。请先运行 scripts/redeploy-simplelite.ps1。"
Warning = "未能从 obj/Debug 同步 DLL(可能未编译或 Simple3 仍占用文件)。请先运行 scripts/redeploy-simple3.ps1。"
};
}
@@ -416,12 +417,12 @@ public sealed class SimpleLiteLauncher : IDisposable
private DateTimeOffset _gotoSiteProbeAt = DateTimeOffset.MinValue;
private static readonly TimeSpan GotoSiteProbeTtl = TimeSpan.FromSeconds(60);
/// <summary>启动前诊断:当前配置、解析到的 exe、端口占用等(供 /api/health/simplelite 与启动日志)。</summary>
public SimpleLiteDiagnostics GetDiagnostics()
/// <summary>启动前诊断:当前配置、解析到的 exe、端口占用等(供 /api/health/simple3 与启动日志)。</summary>
public Simple3Diagnostics GetDiagnostics()
{
var resolved = ResolveExecutable(_opts.ExecutablePath);
var projectionUp = TryConnect("127.0.0.1", _opts.ProjectionPort, TimeSpan.FromMilliseconds(400));
// 探测结果缓存 60s:诊断端点可能被前端轮询,避免每次都对 SimpleLite 发探测请求。
// 探测结果缓存 60s:诊断端点可能被前端轮询,避免每次都对 Simple3 发探测请求。
bool? gotoSite = null;
if (projectionUp)
{
@@ -431,7 +432,7 @@ public sealed class SimpleLiteLauncher : IDisposable
}
else
{
gotoSite = SimpleLiteBuildSync.ProbeGotoSiteRoute(_opts.ProjectionPort);
gotoSite = Simple3BuildSync.ProbeGotoSiteRoute(_opts.ProjectionPort);
if (gotoSite is not null)
{
_gotoSiteProbeCache = gotoSite;
@@ -440,9 +441,9 @@ public sealed class SimpleLiteLauncher : IDisposable
}
}
var deployHint = gotoSite == false
? "关闭 SimpleLite 后:运行 Migu2.0/scripts/redeploy-simplelite.ps1,或 POST /api/health/simplelite/restart-for-update"
? "关闭 Simple3 后:运行 Migu2.0/scripts/redeploy-simple3.ps1,或 POST /api/health/simple3/restart-for-update"
: null;
return new SimpleLiteDiagnostics(
return new Simple3Diagnostics(
Enabled: _opts.Enabled,
FollowParent: _opts.FollowParent,
ConfiguredExecutablePath: _opts.ExecutablePath ?? "",
@@ -456,26 +457,26 @@ public sealed class SimpleLiteLauncher : IDisposable
ProjectionPortReachable: projectionUp,
GotoSiteApiAvailable: gotoSite,
DeployHint: deployHint,
ConfigHint: "编辑 MiGu.Server/appsettings.json 的 SimpleLite 节点(所有环境生效);开发机可叠加 appsettings.Development.json。" +
" FollowParent=false 时关闭 MiGu.Server 不会结束 SimpleLite。");
ConfigHint: "编辑 MiGu.Server/appsettings.json 的 Simple3 节点(所有环境生效);开发机可叠加 appsettings.Development.json。" +
" FollowParent=false 时关闭 MiGu.Server 不会结束 Simple3。");
}
/// <summary>
/// 拉起 SimpleLite。FollowParent=false 时在 Windows 上用 <c>cmd /c start</c> 脱离父进程组,避免平台退出连带结束 SimpleLite
/// 拉起 Simple3。FollowParent=false 时在 Windows 上用 <c>cmd /c start</c> 脱离父进程组,避免平台退出连带结束 Simple3
/// </summary>
private Process? StartSimpleLiteProcess(string resolved, string arguments, string workdir)
private Process? StartSimple3Process(string resolved, string arguments, string workdir)
{
if (_opts.FollowParent)
return StartSimpleLiteDirect(resolved, arguments, workdir, useShellExecute: true);
return StartSimple3Direct(resolved, arguments, workdir, useShellExecute: true);
if (OperatingSystem.IsWindows())
return StartSimpleLiteDetachedWindows(resolved, arguments, workdir)
?? StartSimpleLiteDirect(resolved, arguments, workdir, useShellExecute: true);
return StartSimple3DetachedWindows(resolved, arguments, workdir)
?? StartSimple3Direct(resolved, arguments, workdir, useShellExecute: true);
return StartSimpleLiteDirect(resolved, arguments, workdir, useShellExecute: true);
return StartSimple3Direct(resolved, arguments, workdir, useShellExecute: true);
}
private static Process? StartSimpleLiteDirect(string resolved, string arguments, string workdir, bool useShellExecute)
private static Process? StartSimple3Direct(string resolved, string arguments, string workdir, bool useShellExecute)
{
var psi = new ProcessStartInfo
{
@@ -493,14 +494,15 @@ public sealed class SimpleLiteLauncher : IDisposable
/// <summary>
/// 通过 cmd start 在新进程组/新控制台中启动,与 MiGu.Server 控制台 Ctrl+C、进程树结束解耦。
/// </summary>
private Process? StartSimpleLiteDetachedWindows(string resolved, string arguments, string workdir)
private Process? StartSimple3DetachedWindows(string resolved, string arguments, string workdir)
{
var exeName = Path.GetFileNameWithoutExtension(resolved);
var beforeIds = new HashSet<int>(
Process.GetProcessesByName(exeName).Select(p => { try { return p.Id; } catch { return -1; } })
.Where(id => id > 0));
var cmdArgs = $"/c start \"SimpleLite\" /D \"{workdir}\" \"{resolved}\" {arguments}";
// cmd start 的第一个引号参数是「窗口标题」,不是 exe;写死 Simple3 会让 Simple3 控制台标题错名。
var cmdArgs = $"/c start \"{exeName}\" /D \"{workdir}\" \"{resolved}\" {arguments}";
var shimPsi = new ProcessStartInfo
{
FileName = "cmd.exe",
@@ -521,7 +523,7 @@ public sealed class SimpleLiteLauncher : IDisposable
{
if (p.HasExited) continue;
if (beforeIds.Contains(p.Id)) continue;
_log.LogInformation("[SimpleLite] detached start via cmd.exe; new pid={Pid}", p.Id);
_log.LogInformation("[Simple3] detached start via cmd.exe; new pid={Pid}", p.Id);
return AttachToExistingProcess(p.Id);
}
catch { /* ignore */ }
@@ -529,7 +531,7 @@ public sealed class SimpleLiteLauncher : IDisposable
Thread.Sleep(200);
}
_log.LogWarning("[SimpleLite] detached start: no new {Name} process observed after cmd.exe start", exeName);
_log.LogWarning("[Simple3] detached start: no new {Name} process observed after cmd.exe start", exeName);
return null;
}
@@ -543,7 +545,7 @@ public sealed class SimpleLiteLauncher : IDisposable
}
catch (Exception ex)
{
_log.LogWarning("[SimpleLite] attach to pid={Pid} failed: {Msg}", pid, ex.Message);
_log.LogWarning("[Simple3] attach to pid={Pid} failed: {Msg}", pid, ex.Message);
return null;
}
}
@@ -554,7 +556,7 @@ public sealed class SimpleLiteLauncher : IDisposable
{
int exit;
try { exit = proc.ExitCode; } catch { exit = -1; }
_log.LogInformation("[SimpleLite] process exited code={Code}; next login will relaunch if needed", exit);
_log.LogInformation("[Simple3] process exited code={Code}; next login will relaunch if needed", exit);
lock (_sync)
{
if (ReferenceEquals(_proc, proc))
@@ -577,16 +579,26 @@ public sealed class SimpleLiteLauncher : IDisposable
var cwd = _env.ContentRootPath;
var staticCandidates = new[]
{
Path.Combine(cwd, "Simple3.exe"),
Path.Combine(cwd, "SimpleLite.exe"),
Path.Combine(cwd, "Simple3", "Simple3.exe"),
Path.Combine(cwd, "SimpleLite", "SimpleLite.exe"),
Path.Combine(cwd, "..", "Simple3.exe"),
Path.Combine(cwd, "..", "SimpleLite.exe"),
Path.Combine(cwd, "..", "SimpleLite", "bin", "Debug", "SimpleLite.exe"),
Path.Combine(cwd, "..", "SimpleLite", "bin", "Release", "SimpleLite.exe"),
// Migu2.0 与 Simple 并列:Migu2.0/MiGu.Server → ../../Simple/SimpleLite/bin/Debug
// 发布布局(本机常见):D:\MDCS\Release\Simple\Simple3
@"D:\MDCS\Release\Simple\Simple3\Simple3.exe",
// 源码并列:Migu2.0/MiGu.Server → ../../Simple/Simple3OutputPath 常直接写到 Release
Path.Combine(cwd, "..", "..", "Simple", "Simple3", "Simple3.exe"),
Path.Combine(cwd, "..", "..", "Simple", "Simple3", "bin", "Debug", "Simple3.exe"),
Path.Combine(cwd, "..", "..", "Simple", "Simple3", "bin", "Release", "Simple3.exe"),
Path.Combine(cwd, "..", "Simple", "Simple3", "Simple3.exe"),
// 兼容旧 SimpleLite 目录/产物名
Path.Combine(cwd, "..", "..", "Simple", "SimpleLite", "bin", "Debug", "SimpleLite.exe"),
Path.Combine(cwd, "..", "..", "Simple", "SimpleLite", "bin", "Release", "SimpleLite.exe"),
Path.Combine(cwd, "..", "Simple", "SimpleLite", "bin", "Debug", "SimpleLite.exe"),
Path.Combine(cwd, "..", "Simple", "SimpleLite", "bin", "Release", "SimpleLite.exe"),
Path.Combine(cwd, "..", "SimpleLite", "bin", "Debug", "SimpleLite.exe"),
Path.Combine(cwd, "..", "SimpleLite", "bin", "Release", "SimpleLite.exe"),
};
foreach (var c in staticCandidates)
{
@@ -599,6 +611,13 @@ public sealed class SimpleLiteLauncher : IDisposable
{
foreach (var sub in new[]
{
"Simple3/Simple3.exe",
"Simple3/bin/Debug/Simple3.exe",
"Simple3/bin/Release/Simple3.exe",
"Simple/Simple3/Simple3.exe",
"Simple/Simple3/bin/Debug/Simple3.exe",
"Simple/Simple3/bin/Release/Simple3.exe",
"SimpleLite/SimpleLite.exe",
"SimpleLite/bin/Debug/SimpleLite.exe",
"SimpleLite/bin/Release/SimpleLite.exe",
"Simple/SimpleLite/bin/Debug/SimpleLite.exe",
@@ -668,14 +687,14 @@ public sealed class SimpleLiteLauncher : IDisposable
if (TryConnect("127.0.0.1", _opts.ProjectionPort, TimeSpan.FromMilliseconds(interval)))
{
_log.LogInformation("[SimpleLite] projection :{Port} ready", _opts.ProjectionPort);
_log.LogInformation("[Simple3] projection :{Port} ready", _opts.ProjectionPort);
return true;
}
Thread.Sleep(interval);
}
_log.LogWarning("[SimpleLite] projection :{Port} not ready within {Timeout}ms", _opts.ProjectionPort, _opts.ReadinessTimeoutMs);
_log.LogWarning("[Simple3] projection :{Port} not ready within {Timeout}ms", _opts.ProjectionPort, _opts.ReadinessTimeoutMs);
return false;
}
@@ -694,10 +713,10 @@ public sealed class SimpleLiteLauncher : IDisposable
}
/// <summary>
/// 在 TCP 通的基础上读取 SimpleLite Projection 的强类型 JSON 端点,判别「真的是 SimpleLite」。
/// 在 TCP 通的基础上读取 Simple3 Projection 的强类型 JSON 端点,判别「真的是 Simple3」。
/// 只接受 2xx 且响应体像 JSON 数组/对象;任意普通 HTTP 服务占用 8222 不再被误认为可复用。
/// </summary>
private static bool ProbeSimpleLiteHttp(string host, int port, TimeSpan timeout)
private static bool ProbeSimple3Http(string host, int port, TimeSpan timeout)
{
try
{
@@ -718,20 +737,20 @@ public sealed class SimpleLiteLauncher : IDisposable
}
/// <summary>
/// 拉起 SimpleLite 的结果摘要。
/// 拉起 Simple3 的结果摘要。
/// <list type="bullet">
/// <item><c>Started</c>:本次调用后是否处于"已运行"状态(包含 AlreadyRunning / ReusingExisting / Ready / StartedButNotReady)。</item>
/// <item><c>Status</c>:状态枚举字符串(见上)。</item>
/// <item><c>Detail</c>:技术细节,写入服务端日志。</item>
/// <item><c>DisplayMode</c>:本次实际生效的 displayModeweb / web+local / external / null)。
/// 与 LaunchMode 业务字段区分:external 表示复用了 MiGu.Server 重启前残留的 SimpleLite,对应模式未知。</item>
/// 与 LaunchMode 业务字段区分:external 表示复用了 MiGu.Server 重启前残留的 Simple3,对应模式未知。</item>
/// <item><c>Warning</c>:透传给前端登录响应的告警文本,null 表示无需告警。</item>
/// </list>
/// </summary>
public readonly record struct LaunchResult(bool Started, string Status, string Detail,
string? DisplayMode = null, string? Warning = null);
public sealed record SimpleLiteDiagnostics(
public sealed record Simple3Diagnostics(
bool Enabled,
bool FollowParent,
string ConfiguredExecutablePath,
@@ -807,7 +826,7 @@ public sealed class SimpleLiteLauncher : IDisposable
{
if (!OperatingSystem.IsWindows())
{
_log.LogInformation("[SimpleLite] JobObject skipped on non-Windows; falling back to ProcessExit-only cleanup");
_log.LogInformation("[Simple3] JobObject skipped on non-Windows; falling back to ProcessExit-only cleanup");
return;
}
@@ -818,7 +837,7 @@ public sealed class SimpleLiteLauncher : IDisposable
_job = CreateJobObject(IntPtr.Zero, null);
if (_job == IntPtr.Zero)
{
_log.LogWarning("[SimpleLite] CreateJobObject failed; child may outlive MiGu.Server");
_log.LogWarning("[Simple3] CreateJobObject failed; child may outlive MiGu.Server");
return;
}
@@ -831,17 +850,17 @@ public sealed class SimpleLiteLauncher : IDisposable
{
Marshal.StructureToPtr(info, ptr, false);
if (!SetInformationJobObject(_job, JobObjectExtendedLimitInformation, ptr, (uint)len))
_log.LogWarning("[SimpleLite] SetInformationJobObject failed; child may outlive MiGu.Server");
_log.LogWarning("[Simple3] SetInformationJobObject failed; child may outlive MiGu.Server");
}
finally { Marshal.FreeHGlobal(ptr); }
}
if (!AssignProcessToJobObject(_job, child.Handle))
_log.LogWarning("[SimpleLite] AssignProcessToJobObject failed; child may outlive MiGu.Server");
_log.LogWarning("[Simple3] AssignProcessToJobObject failed; child may outlive MiGu.Server");
}
catch (Exception ex)
{
_log.LogWarning("[SimpleLite] JobObject setup error: {Type}: {Msg}", ex.GetType().Name, ex.Message);
_log.LogWarning("[Simple3] JobObject setup error: {Type}: {Msg}", ex.GetType().Name, ex.Message);
}
}
}
@@ -1,31 +1,30 @@
namespace MiGu.Server.Launcher;
/// <summary>
/// MiGu.Server 启动后由「平台登录」按 LaunchMode 拉起的 SimpleLite 子进程配置。
/// MiGu.Server 启动后由「平台登录」按 LaunchMode 拉起的 Simple3 子进程配置。
///
/// 会话 N+1(启动反转):原架构是 SimpleLite 启动 → 拉 MiGu.Server;现在反过来:
/// MiGu.Server 作为主入口启动 → 登录页选 LaunchMode → 后端拉起 SimpleLite.exe
/// 通过 <c>--display-mode</c> 参数把 web / web+local 透传给 SimpleLite 的 Configuration。
/// 会话 N+1(启动反转):原架构是 Simple3 启动 → 拉 MiGu.Server;现在反过来:
/// MiGu.Server 作为主入口启动 → 登录页选 LaunchMode → 后端拉起 Simple3.exe
/// 通过 <c>--display-mode</c> 参数把 web / web+local 透传给 Simple3 的 Configuration。
///
/// 绑定 <c>appsettings.json:SimpleLite</c>。
/// 绑定 <c>appsettings.json:Simple3</c>。
/// </summary>
public sealed class SimpleLiteOptions
public sealed class Simple3Options
{
/// <summary>主开关。false = 永不拉起;登录无论选什么 LaunchMode 都不会启动子进程,用于「只跑 Platform 调试」场景。</summary>
public bool Enabled { get; set; } = true;
/// <summary>
/// SimpleLite 可执行文件绝对/相对路径(相对 MiGu.Server 工作目录)。留空则按以下顺序自动探测:
/// 1) <c>./SimpleLite.exe</c>(合并发布布局SimpleLite 与 MiGu.Server 同目录
/// 2) <c>./SimpleLite/SimpleLite.exe</c>
/// 3) <c>../SimpleLite.exe</c>MiGu.Server 部署到子目录的常见布局)
/// 4) <c>../SimpleLite/bin/Debug/SimpleLite.exe</c>(开发态:Visual Studio 默认输出
/// 5) <c>../SimpleLite/bin/Release/SimpleLite.exe</c>
/// 6) 沿父目录上行寻找 <c>SimpleLite/bin/{Debug|Release}/SimpleLite.exe</c>
/// Simple3 可执行文件绝对/相对路径(相对 MiGu.Server 工作目录)。留空则按以下顺序自动探测:
/// 1) <c>./Simple3.exe</c>(合并发布布局;仍兼容探测旧名 <c>./SimpleLite.exe</c>
/// 2) <c>./Simple3/Simple3.exe</c>
/// 3) 开发态:兄弟仓库 <c>../Simple/Simple3/</c> 产出目录或 <c>bin/Debug|Release</c>
/// 4) 发布目录常见布局:<c>D:\MDCS\Release\Simple\Simple3\Simple3.exe</c>(仅当该绝对路径存在
/// 5) 沿父目录上行寻找 Simple3 产出
/// </summary>
public string ExecutablePath { get; set; } = "";
/// <summary>留空时取 <see cref="ExecutablePath"/> 所在目录。SimpleLite 在 CWD 读写 simple.json / imgui.iniCWD 选错会出意外。</summary>
/// <summary>留空时取 <see cref="ExecutablePath"/> 所在目录。内核在 CWD 读写 simple.json / imgui.iniCWD 选错会出意外。</summary>
public string WorkingDirectory { get; set; } = "";
/// <summary>附加命令行参数(拼在 <c>--display-mode=xxx</c> 之后)。常用于本地调试时强制 autoload 某场景。</summary>
@@ -33,7 +32,7 @@ public sealed class SimpleLiteOptions
/// <summary>
/// 子进程启动后阻塞等待 Projection (:8222) 端口就绪的最长毫秒数。
/// 0 = 不等待(登录立即返回,前端可能还连不上 SimpleLite WebApi);
/// 0 = 不等待(登录立即返回,前端可能还连不上 Simple3 WebApi);
/// 负值 = 无限等待(直到子进程退出或就绪)。
/// </summary>
public int ReadinessTimeoutMs { get; set; } = 8000;
@@ -41,17 +40,17 @@ public sealed class SimpleLiteOptions
/// <summary>每隔多少毫秒 poll 一次 Projection 端口可达性。</summary>
public int ReadinessPollIntervalMs { get; set; } = 250;
/// <summary>SimpleLite Projection WebApi 监听端口。默认与 SimpleLite Configuration 的 `port` 一致。用于就绪检测。</summary>
/// <summary>Simple3 Projection WebApi 监听端口。默认与 Simple3 Configuration 的 `port` 一致。用于就绪检测。</summary>
public int ProjectionPort { get; set; } = 8222;
/// <summary>
/// 是否把 SimpleLite 绑定到 MiGu.Server 生命周期,默认 <b>false</b>(会话 N+2 用户反馈)。
/// 是否把 Simple3 绑定到 MiGu.Server 生命周期,默认 <b>false</b>(会话 N+2 用户反馈)。
///
/// 设计原则:SimpleLite 与 MiGu.Server 是「两个独立程序」,Platform 只是登录后顺手拉起 SimpleLite
/// MiGu.Server 关闭不应该带走 SimpleLite,反之亦然。所以 FollowParent 默认 false
/// 设计原则:Simple3 与 MiGu.Server 是「两个独立程序」,Platform 只是登录后顺手拉起 Simple3
/// MiGu.Server 关闭不应该带走 Simple3,反之亦然。所以 FollowParent 默认 false
/// - 子进程走 <c>UseShellExecute=true</c> 创建独立进程组 + 独立控制台窗口;
/// - 不挂 JobObject,不在 ApplicationStopping / ProcessExit 时 kill 子进程;
/// - SimpleLite 退出由用户自己负责(关窗口 / 任务管理器 / 调度内核异常退出)。
/// - Simple3 退出由用户自己负责(关窗口 / 任务管理器 / 调度内核异常退出)。
///
/// true 仍可用:会启动 Windows JobObject 父子绑定(仅 Windows 有效)+ 注册 ApplicationStopping 软关闭。
/// 一般只在临时联调期 / CI 流水线想自动清理时打开。
@@ -59,11 +58,11 @@ public sealed class SimpleLiteOptions
public bool FollowParent { get; set; } = false;
/// <summary>
/// 平台拉起 SimpleLite 时是否以「迷榖嵌入画布」模式运行(透传命令行 <c>--migu</c>),默认 <b>true</b>。
/// 平台拉起 Simple3 时是否以「迷榖嵌入画布」模式运行(透传命令行 <c>--migu</c>),默认 <b>true</b>。
///
/// true:平台 iframe 嵌入场景,SimpleLite WebTerminal 默认只显示 3D 画布、隐藏所有 ImGui panel
/// true:平台 iframe 嵌入场景,Simple3 WebTerminal 默认只显示 3D 画布、隐藏所有 ImGui panel
/// 业务 UI 全部由 Vue 平台前端接管;declare 时序失败时也安全回退到纯画布。
/// false:平台拉起的 SimpleLite web 端默认显示完整 panel(便于把平台拉起的实例直连 :8223 调试)。
/// false:平台拉起的 Simple3 web 端默认显示完整 panel(便于把平台拉起的实例直连 :8223 调试)。
///
/// 与 LaunchModeweb / web+local,是否保留本地调试窗口)正交,可任意组合。
/// </summary>
+270
View File
@@ -0,0 +1,270 @@
using MiGu.Server.Configs;
using MiGu.Server.Launcher;
namespace MiGu.Server.Logs;
/// <summary>
/// 对齐 Simple3 LogCleaner:定时删除 log/ 下过期 *.log,并检测日志盘剩余空间(边沿告警,写日志不弹窗)。
/// </summary>
public sealed class LogCleanupService : IHostedService, IDisposable
{
private readonly ConfigStore _store;
private readonly Simple3Launcher _launcher;
private readonly IConfiguration _config;
private readonly ILogger<LogCleanupService> _log;
private readonly object _cleanLock = new();
private readonly object _scheduleLock = new();
private Timer? _cleanTimer;
private Timer? _diskTimer;
private bool _diskBelow;
public LogCleanupService(
ConfigStore store,
Simple3Launcher launcher,
IConfiguration config,
ILogger<LogCleanupService> log)
{
_store = store;
_launcher = launcher;
_config = config;
_log = log;
}
public Task StartAsync(CancellationToken cancellationToken)
{
try
{
var opt = _store.GetLogCleanup();
if (opt.Enabled && opt.RunOnStartup)
RunCleanSafely(opt, "启动清理");
ApplySchedule();
CheckDisk(opt);
}
catch (Exception ex)
{
_log.LogWarning(ex, "LogCleanupService 启动失败");
}
return Task.CompletedTask;
}
public Task StopAsync(CancellationToken cancellationToken)
{
lock (_scheduleLock)
{
_cleanTimer?.Dispose();
_cleanTimer = null;
_diskTimer?.Dispose();
_diskTimer = null;
}
return Task.CompletedTask;
}
public void Dispose()
{
_cleanTimer?.Dispose();
_diskTimer?.Dispose();
}
public LogCleanupOptions GetOptions() => _store.GetLogCleanup();
public LogCleanupOptions SaveOptions(LogCleanupOptions options)
{
var saved = _store.PutLogCleanup(options);
_ = saved;
var opt = _store.GetLogCleanup();
ApplySchedule();
CheckDisk(opt, forceReeval: true);
return opt;
}
public void ApplySchedule()
{
var opt = _store.GetLogCleanup();
lock (_scheduleLock)
{
_cleanTimer?.Dispose();
_cleanTimer = null;
if (opt.Enabled)
{
var hours = opt.CheckIntervalHours <= 0 ? 1 : opt.CheckIntervalHours;
var period = TimeSpan.FromHours(hours);
_cleanTimer = new Timer(_ => RunCleanSafely(_store.GetLogCleanup(), "定时清理"),
null, period, period);
}
_diskTimer?.Dispose();
var mins = opt.DiskCheckIntervalMinutes <= 0 ? 30 : opt.DiskCheckIntervalMinutes;
var diskPeriod = TimeSpan.FromMinutes(mins);
_diskTimer = new Timer(_ => CheckDisk(_store.GetLogCleanup()),
null, diskPeriod, diskPeriod);
}
}
public CleanResult CleanNow()
{
var opt = _store.GetLogCleanup();
var r = CleanOnce(opt);
if (r.Error != null)
_log.LogWarning("立即清理失败:{Error}", r.Error);
else if (r.DeletedFiles > 0)
_log.LogInformation("立即清理删除 {Count} 个日志文件,释放 {Mb:F1} MB", r.DeletedFiles, r.FreedMB);
else
_log.LogInformation("立即清理完成:没有过期日志");
return r;
}
public DiskStatus GetDiskStatus()
{
var opt = _store.GetLogCleanup();
var known = TryGetFreeGB(out var freeGB, out var drive);
var below = known && opt.DiskAlertEnabled && freeGB < opt.DiskFreeAlertGB;
return new DiskStatus(known, drive, freeGB, opt.DiskAlertEnabled, opt.DiskFreeAlertGB, below);
}
private void RunCleanSafely(LogCleanupOptions opt, string reason)
{
if (!opt.Enabled && reason != "立即清理") return;
var r = CleanOnce(opt);
if (r.Error != null)
_log.LogWarning("[{Reason}] 失败:{Error}", reason, r.Error);
else if (r.DeletedFiles > 0)
_log.LogInformation("[{Reason}] 删除 {Count} 个日志文件,释放 {Mb:F1} MB", reason, r.DeletedFiles, r.FreedMB);
}
private CleanResult CleanOnce(LogCleanupOptions opt)
{
opt.Clamp();
lock (_cleanLock)
{
try
{
var (root, _, error) = ResolveLogRoot();
if (root == null)
return new CleanResult(0, 0, error ?? "无法定位日志目录");
if (!Directory.Exists(root))
return new CleanResult(0, 0, null);
var cutoff = DateTime.Now.AddDays(-opt.RetentionDays);
var deleted = 0;
long freed = 0;
foreach (var f in Directory.EnumerateFiles(root, "*.log", SearchOption.AllDirectories))
{
try
{
var fi = new FileInfo(f);
if (fi.LastWriteTime >= cutoff) continue;
var len = fi.Length;
fi.Delete();
deleted++;
freed += len;
}
catch
{
/* 占用/权限:跳过 */
}
}
TryRemoveEmptyDirs(root);
return new CleanResult(deleted, freed, null);
}
catch (Exception ex)
{
return new CleanResult(0, 0, ex.Message);
}
}
}
private static void TryRemoveEmptyDirs(string root)
{
try
{
foreach (var dir in Directory.EnumerateDirectories(root, "*", SearchOption.AllDirectories)
.OrderByDescending(d => d.Length))
{
try
{
if (!Directory.EnumerateFileSystemEntries(dir).Any())
Directory.Delete(dir, false);
}
catch { /* 忽略单目录失败 */ }
}
}
catch { /* 忽略枚举失败 */ }
}
private void CheckDisk(LogCleanupOptions opt, bool forceReeval = false)
{
if (forceReeval) _diskBelow = false;
if (!opt.DiskAlertEnabled)
{
_diskBelow = false;
return;
}
if (!TryGetFreeGB(out var freeGB, out var drive))
return;
if (freeGB < opt.DiskFreeAlertGB)
{
if (_diskBelow) return;
_diskBelow = true;
_log.LogWarning(
"磁盘剩余空间不足:{Drive} 仅剩 {Free:F1} GB(低于阈值 {Threshold:F0} GB)。请及时清理磁盘,或在系统配置中调低日志保留天数 / 立即清理。",
drive, freeGB, opt.DiskFreeAlertGB);
}
else
{
_diskBelow = false;
}
}
private bool TryGetFreeGB(out double freeGB, out string driveName)
{
freeGB = 0;
driveName = "";
var (root, _, _) = ResolveLogRoot();
if (root == null) return false;
try
{
var driveRoot = Path.GetPathRoot(root);
if (string.IsNullOrEmpty(driveRoot)) return false;
var di = new DriveInfo(driveRoot);
driveName = di.Name;
freeGB = di.AvailableFreeSpace / 1024.0 / 1024.0 / 1024.0;
return true;
}
catch
{
return false;
}
}
private (string? root, string? workdir, string? error) ResolveLogRoot()
{
var overrideRoot = _config["Logs:Root"];
if (!string.IsNullOrWhiteSpace(overrideRoot))
{
var r = Path.GetFullPath(overrideRoot);
return (r, Path.GetDirectoryName(r), null);
}
var wd = _launcher.ResolveWorkingDirectory();
if (string.IsNullOrWhiteSpace(wd))
return (null, null, "未能定位 Simple3 工作目录");
return (Path.GetFullPath(Path.Combine(wd, "log")), wd, null);
}
public readonly record struct CleanResult(int DeletedFiles, long FreedBytes, string? Error)
{
public double FreedMB => FreedBytes / 1024.0 / 1024.0;
}
public readonly record struct DiskStatus(
bool Known,
string Drive,
double FreeGB,
bool AlertEnabled,
double AlertGB,
bool BelowThreshold);
}
+10 -7
View File
@@ -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>
@@ -5,10 +5,10 @@ using Swashbuckle.AspNetCore.SwaggerGen;
namespace MiGu.Server.OpenApi;
/// <summary>
/// 将 SimpleLite EmbedIO WebApi 的 OpenAPI 描述合并进 MiGu.Server Swagger 文档。
/// 源文件:Simple/SimpleLite/Docs/openapi/simplelite-projection.json
/// 将 Simple3 EmbedIO WebApi 的 OpenAPI 描述合并进 MiGu.Server Swagger 文档。
/// 源文件:Simple/Simple3/Docs/openapi/simple3-projection.json
/// </summary>
public sealed class SimpleLiteOpenApiDocumentFilter : IDocumentFilter
public sealed class Simple3OpenApiDocumentFilter : IDocumentFilter
{
private static readonly Dictionary<string, OperationType> VerbMap = new(StringComparer.OrdinalIgnoreCase)
{
@@ -175,9 +175,12 @@ public sealed class SimpleLiteOpenApiDocumentFilter : IDocumentFilter
foreach (var root in roots)
{
yield return Path.GetFullPath(Path.Combine(root, "OpenApi", "simple3-projection.json"));
yield return Path.GetFullPath(Path.Combine(root, "..", "..", "..", "Simple", "Simple3", "Docs", "openapi", "simple3-projection.json"));
yield return Path.GetFullPath(Path.Combine(root, "..", "..", "Simple", "Simple3", "Docs", "openapi", "simple3-projection.json"));
// 兼容旧文件名 / 旧目录
yield return Path.GetFullPath(Path.Combine(root, "OpenApi", "simplelite-projection.json"));
yield return Path.GetFullPath(Path.Combine(root, "..", "..", "..", "Simple", "SimpleLite", "Docs", "openapi", "simplelite-projection.json"));
yield return Path.GetFullPath(Path.Combine(root, "..", "..", "Simple", "SimpleLite", "Docs", "openapi", "simplelite-projection.json"));
yield return Path.GetFullPath(Path.Combine(root, "..", "..", "..", "Simple", "Simple3", "Docs", "openapi", "simplelite-projection.json"));
}
}
}
+29
View File
@@ -16,6 +16,35 @@ public sealed class OtaJobRunner
_wd = wd;
_vehicles = vehicles;
_log = log;
RecoverInterruptedJobs();
}
/// <summary>进程重启后把落盘中非终态 job 标为 failed,避免 UI 永久显示 running。</summary>
private void RecoverInterruptedJobs()
{
try
{
foreach (var job in _store.ListJobs(500))
{
if (job.Status is not ("running" or "pending")) continue;
job.Status = "failed";
job.Message = string.IsNullOrWhiteSpace(job.Message)
? "进程重启,任务中断"
: job.Message;
job.FinishedAt = DateTimeOffset.UtcNow;
foreach (var step in job.Steps.Where(s => s.Status is "pending" or "running"))
{
step.Status = "failed";
step.Error ??= "进程重启,任务中断";
}
_store.SaveJob(job);
_log.LogWarning("OTA job {Id} marked failed after process restart", job.Id);
}
}
catch (Exception ex)
{
_log.LogWarning(ex, "OTA interrupted job recovery failed");
}
}
public OtaJob EnqueueSync(CreateSyncJobRequest req, string? user)
+3 -1
View File
@@ -6,9 +6,11 @@ public sealed class OtaSettings
public int MaxCar { get; set; } = 2;
public bool LatencyEnabled { get; set; }
public int RttThresholdMs { get; set; } = 200;
/// <summary>skip | confirm</summary>
/// <summary>skip | allow(历史值 confirm 视为 allow</summary>
public string OverThreshold { get; set; } = "skip";
/// <summary>保留字段:备份尚未实现,仅反序列化兼容。</summary>
public int BackupPeriodMinutes { get; set; } = 60;
/// <summary>保留字段:备份尚未实现,仅反序列化兼容。</summary>
public bool BackupExe { get; set; }
public string? NewVersionName { get; set; }
}
+24 -7
View File
@@ -24,6 +24,8 @@ public sealed class OtaStore
private string? _lastPullId;
private readonly System.Collections.Concurrent.ConcurrentDictionary<string, string> _pullByIp =
new(StringComparer.OrdinalIgnoreCase);
private readonly System.Collections.Concurrent.ConcurrentDictionary<string, DateTime> _pullLastReceiveUtc =
new(StringComparer.OrdinalIgnoreCase);
private long _jobSeq;
public OtaStore(IWebHostEnvironment env, IOptions<OtaOptions> options, ILogger<OtaStore> log)
@@ -144,29 +146,44 @@ public sealed class OtaStore
lock (_gate)
{
var ip = NormalizeIp(sourceIp);
// 必须匹配会话 IP;禁止无 IP 时回退到最近一次拉包(可被伪造/误写)。
if (ip != null && _pullByIp.TryGetValue(ip, out var byIp))
{
id = byIp;
return true;
}
if (ip == null && _lastPullId != null)
{
id = _lastPullId;
return true;
}
id = null;
return false;
}
}
public void NotePullReceive(string? sourceIp)
{
var ip = NormalizeIp(sourceIp);
if (ip == null) return;
_pullLastReceiveUtc[ip] = DateTime.UtcNow;
}
public bool TryGetLastReceiveAt(string? sourceIp, out DateTime utc)
{
var ip = NormalizeIp(sourceIp);
if (ip != null && _pullLastReceiveUtc.TryGetValue(ip, out utc))
return true;
utc = default;
return false;
}
public void ClearActivePull(string? sourceIp = null)
{
lock (_gate)
{
var ip = NormalizeIp(sourceIp);
if (ip != null) _pullByIp.TryRemove(ip, out _);
if (ip != null)
{
_pullByIp.TryRemove(ip, out _);
_pullLastReceiveUtc.TryRemove(ip, out _);
}
if (_pullByIp.IsEmpty) _lastPullId = null;
}
}
+2 -2
View File
@@ -7,13 +7,13 @@ namespace MiGu.Server.Ota;
public sealed class OtaVehicleSource
{
private readonly IHttpClientFactory _httpFactory;
private readonly SimpleLiteOptions _sl;
private readonly Simple3Options _sl;
private readonly InternalTokenStoreAccessor _token;
private readonly ILogger<OtaVehicleSource> _log;
public OtaVehicleSource(
IHttpClientFactory httpFactory,
IOptions<SimpleLiteOptions> sl,
IOptions<Simple3Options> sl,
InternalTokenStoreAccessor token,
ILogger<OtaVehicleSource> log)
{
-17
View File
@@ -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,316 +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>();
public DbSet<MiGu.Server.Fleet.CdmTaskRecord> CdmTasks => Set<MiGu.Server.Fleet.CdmTaskRecord>();
public DbSet<MiGu.Server.Fleet.VehicleAlarmRecord> VehicleAlarms => Set<MiGu.Server.Fleet.VehicleAlarmRecord>();
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();
// 库位占用 1:1:同一 Storage LocationId 同时只能有一条未删除记录(Car 等其它类型不限)
modelBuilder.Entity<ContainerLocation>()
.HasIndex(x => x.LocationId)
.IsUnique()
.HasFilter("LocationType = 'Storage' AND IsDeleted = 0")
.HasDatabaseName("IX_wms_container_locations_StorageLocationId");
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);
ConfigureCdmTask(modelBuilder);
ConfigureVehicleAlarm(modelBuilder);
}
private static void ConfigureVehicleAlarm(ModelBuilder modelBuilder)
{
var e = modelBuilder.Entity<MiGu.Server.Fleet.VehicleAlarmRecord>();
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);
}
private static void ConfigureCdmTask(ModelBuilder modelBuilder)
{
var e = modelBuilder.Entity<MiGu.Server.Fleet.CdmTaskRecord>();
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);
}
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);
}
}
+13 -555
View File
@@ -1,566 +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 EnsureCdmTasksTableAsync(db);
await EnsureVehicleAlarmsTableAsync(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>为已存在的数据库补建 vehicle_alarms 表(车辆报警平台侧记录,幂等)。</summary>
private static async Task EnsureVehicleAlarmsTableAsync(PlatformDbContext db)
{
if (db.Database.IsSqlite())
{
await db.Database.ExecuteSqlRawAsync("""
CREATE TABLE IF NOT EXISTS vehicle_alarms (
id TEXT NOT NULL CONSTRAINT PK_vehicle_alarms PRIMARY KEY,
car_id INTEGER NOT NULL DEFAULT 0,
car_name TEXT NOT NULL DEFAULT '',
info TEXT NOT NULL DEFAULT '',
level INTEGER NOT NULL DEFAULT 0,
status TEXT NOT NULL DEFAULT 'active',
first_at TEXT NOT NULL,
last_at TEXT NOT NULL,
resolved_at TEXT,
duration_secs INTEGER,
acknowledged INTEGER NOT NULL DEFAULT 0,
acknowledged_at TEXT,
acknowledged_by TEXT
);
""");
await db.Database.ExecuteSqlRawAsync("CREATE INDEX IF NOT EXISTS IX_vehicle_alarms_car_status ON vehicle_alarms (car_id, status);");
await db.Database.ExecuteSqlRawAsync("CREATE INDEX IF NOT EXISTS IX_vehicle_alarms_status ON vehicle_alarms (status);");
await db.Database.ExecuteSqlRawAsync("CREATE INDEX IF NOT EXISTS IX_vehicle_alarms_first_at ON vehicle_alarms (first_at);");
return;
}
if (!await TableExistsAsync(db, "vehicle_alarms"))
{
var creator = db.GetService<Microsoft.EntityFrameworkCore.Storage.IRelationalDatabaseCreator>();
await creator.CreateTablesAsync();
}
}
/// <summary>为已存在的数据库补建 cdm_tasks 表(CDM 搬运任务平台侧快照,幂等)。</summary>
private static async Task EnsureCdmTasksTableAsync(PlatformDbContext db)
{
if (db.Database.IsSqlite())
{
await db.Database.ExecuteSqlRawAsync("""
CREATE TABLE IF NOT EXISTS cdm_tasks (
id TEXT NOT NULL CONSTRAINT PK_cdm_tasks PRIMARY KEY,
task_id TEXT,
mission_id INTEGER NOT NULL DEFAULT 0,
mission_name TEXT NOT NULL DEFAULT '',
mission_type TEXT NOT NULL DEFAULT '',
src_site_id INTEGER NOT NULL DEFAULT 0,
src_label TEXT NOT NULL DEFAULT '',
dst_site_id INTEGER NOT NULL DEFAULT 0,
dst_label TEXT NOT NULL DEFAULT '',
status TEXT NOT NULL DEFAULT '',
status_code TEXT NOT NULL DEFAULT '',
car_id INTEGER,
car_name TEXT,
priority INTEGER NOT NULL DEFAULT 0,
create_time TEXT,
start_time TEXT,
finish_time TEXT,
stuck_reason TEXT,
overdue INTEGER NOT NULL DEFAULT 0,
first_seen_at TEXT NOT NULL,
last_seen_at TEXT NOT NULL
);
""");
await db.Database.ExecuteSqlRawAsync("CREATE INDEX IF NOT EXISTS IX_cdm_tasks_status_code ON cdm_tasks (status_code);");
await db.Database.ExecuteSqlRawAsync("CREATE INDEX IF NOT EXISTS IX_cdm_tasks_create_time ON cdm_tasks (create_time);");
return;
}
if (!await TableExistsAsync(db, "cdm_tasks"))
{
var creator = db.GetService<Microsoft.EntityFrameworkCore.Storage.IRelationalDatabaseCreator>();
await creator.CreateTablesAsync();
}
}
/// <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
);
""");
// 库位占用唯一约束(幂等);若库内已有重复占用会创建失败,不阻断启动
try
{
await db.Database.ExecuteSqlRawAsync("""
CREATE UNIQUE INDEX IF NOT EXISTS IX_wms_container_locations_StorageLocationId
ON wms_container_locations (LocationId)
WHERE LocationType = 'Storage' AND IsDeleted = 0;
""");
}
catch (Exception ex)
{
Console.Error.WriteLine(
$"[WMS] 无法创建库位占用唯一索引 IX_wms_container_locations_StorageLocationId(可能已有重复占用): {ex.Message}");
}
}
private static async Task EnsureSqliteColumnAsync(PlatformDbContext db, string table, string column, string definition)
{
// 仅允许内部迁移调用方传入的标识符;拒绝注入用分隔符/空白。
static bool IsSafeIdent(string s) =>
!string.IsNullOrEmpty(s) && s.All(ch => char.IsAsciiLetterOrDigit(ch) || ch == '_');
if (!IsSafeIdent(table) || !IsSafeIdent(column)
|| definition.Contains(';') || definition.Contains("--")
|| !System.Text.RegularExpressions.Regex.IsMatch(definition, @"^[A-Za-z0-9_()'.,\s]+$"))
throw new ArgumentException("unsafe sqlite migration identifier");
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();
}
}
+34 -23
View File
@@ -8,6 +8,8 @@ using MiGu.Server.Configs;
using MiGu.Server.Launcher;
using MiGu.Server.OpenApi;
using MiGu.Server.Ota;
using MiGu.Server.Signal;
using MiGu.DB.Kernel.Hosting;
using MiGu.Server.Persistence;
using Yarp.ReverseProxy.Transforms;
@@ -122,11 +124,11 @@ builder.Services.AddSwaggerGen(c =>
{
c.SwaggerDoc("v1", new()
{
Title = "MiGu.Server + SimpleLite",
Title = "MiGu.Server + Simple3",
Version = "v1",
Description = "咪咕平台后端 API,以及经 YARP 反代的 SimpleLite 数据 WebApi(标签 SimpleLite/*)。详见 Simple/SimpleLite/Docs/MIGU-API.md。"
Description = "咪咕平台后端 API,以及经 YARP 反代的 Simple3 数据 WebApi(标签 Simple3/*)。详见 Simple/Simple3/Docs/MIGU-API.md。"
});
c.DocumentFilter<SimpleLiteOpenApiDocumentFilter>();
c.DocumentFilter<Simple3OpenApiDocumentFilter>();
// Swagger 里挂 Bearer 输入框,便于手工测带鉴权的端点。
c.AddSecurityDefinition("Bearer", new OpenApiSecurityScheme
{
@@ -160,7 +162,7 @@ builder.Services.AddCors(opts => opts.AddDefaultPolicy(p =>
// - 旧 SPA 还在用 localStorage.token + Bearer,逐步迁移到 Cookie;过渡期两路都接受;
// - JwtIssuer 集中颁发 + 验签;secret 来自 appsettings:Jwt:Secret 或环境变量
// PLATFORM__JWT__SECRET,占位值会被运行时随机化并强制告警。
// - InternalTokenStore 管理 SimpleLite 8222 ↔ MiGu.Server 之间的 X-Platform-Internal-Token
// - InternalTokenStore 管理 Simple3 8222 ↔ MiGu.Server 之间的 X-Platform-Internal-Token
// 共享密钥(YARP transform 自动追加)。
builder.Services.AddSingleton<RbacStore>();
builder.Services.AddSingleton<JwtIssuer>(sp =>
@@ -215,7 +217,7 @@ builder.Services.AddAuthorization(opts =>
}));
});
// YARP + transform:把 Platform 内部 token 透传给 SimpleLite 8222AR-1/AR-2 配套)。
// YARP + transform:把 Platform 内部 token 透传给 Simple3 8222AR-1/AR-2 配套)。
builder.Services.AddReverseProxy()
.LoadFromConfig(builder.Configuration.GetSection("ReverseProxy"))
.AddTransforms(tctx =>
@@ -253,16 +255,19 @@ builder.Services.AddReverseProxy()
// 单例配置仓库(内存 + JSON 文件持久化占位)
builder.Services.AddSingleton<ConfigStore>();
builder.Services.AddSingleton<MiGu.Server.Logs.LogCleanupService>();
builder.Services.AddHostedService(sp => sp.GetRequiredService<MiGu.Server.Logs.LogCleanupService>());
// 运维审计持久化存储(替代旧的纯内存队列,MiGu.Server 重启后审计不丢)。
builder.Services.AddSingleton<OpsAuditStore>();
// OpsController 真实转发 SimpleLite reflection execute 所需的 HttpClient 工厂。
// OpsController 真实转发 Simple3 reflection execute 所需的 HttpClient 工厂。
builder.Services.AddHttpClient();
builder.Services.AddPlatformPersistence(builder.Configuration);
// 会话 N+1(启动反转):把 SimpleLite 子进程拉起器接入 DIAuthController 登录成功后按 LaunchMode 调 MaybeStart。
// 配置段绑定 appsettings.json:SimpleLite,可被环境变量 SIMPLELITE__XXX 覆盖。
builder.Services.Configure<SimpleLiteOptions>(builder.Configuration.GetSection("SimpleLite"));
builder.Services.AddSingleton<SimpleLiteLauncher>();
// 会话 N+1(启动反转):把 Simple3 子进程拉起器接入 DIAuthController 登录成功后按 LaunchMode 调 MaybeStart。
// 配置段绑定 appsettings.json:Simple3,可被环境变量 SIMPLE3__XXX 覆盖。
builder.Services.Configure<Simple3Options>(builder.Configuration.GetSection("Simple3"));
builder.Services.AddSingleton<Simple3Launcher>();
builder.Services.AddSingleton<SignalDataStore>();
// OTAWatchDog 编排):包库 / 任务 / 出站客户端
builder.Services.Configure<OtaOptions>(builder.Configuration.GetSection("Ota"));
@@ -279,42 +284,47 @@ builder.Services.AddHostedService<MiGu.Server.Fleet.AlarmCollectorService>();
builder.Services.AddHttpClient(nameof(WatchDogClient));
var app = builder.Build();
await app.Services.EnsurePlatformDatabaseAsync();
await app.Services.MigrateMiGuDbAsync();
// 启动期主动构造 JwtIssuer / InternalTokenStore:让 secret 校验日志在请求来之前打印。
_ = app.Services.GetRequiredService<JwtIssuer>();
_ = app.Services.GetRequiredService<InternalTokenStore>();
// 主动构造 RbacStore:首启时尽早 seed 默认用户 / 角色并打印 data/rbac.json 载入日志。
_ = app.Services.GetRequiredService<RbacStore>();
// 主动实例化 SimpleLiteLauncherFollowParent=true 时注册 ProcessExit 软关闭钩子)。
var simpleLiteLauncher = app.Services.GetRequiredService<SimpleLiteLauncher>();
// 主动实例化 Simple3LauncherFollowParent=true 时注册 ProcessExit 软关闭钩子)。
var simple3Launcher = app.Services.GetRequiredService<Simple3Launcher>();
{
var sl = simpleLiteLauncher.GetDiagnostics();
var sl = simple3Launcher.GetDiagnostics();
app.Logger.LogInformation(
"[MiGu.Server] SimpleLite: Enabled={Enabled}, FollowParent={FollowParent}, ConfiguredPath={Cfg}, Resolved={Resolved}, Exists={Exists}, Port:{Port} reachable={PortUp}. 配置见 appsettings.json → SimpleLite",
"[MiGu.Server] Simple3: Enabled={Enabled}, FollowParent={FollowParent}, ConfiguredPath={Cfg}, Resolved={Resolved}, Exists={Exists}, Port:{Port} reachable={PortUp}. 配置见 appsettings.json → Simple3",
sl.Enabled, sl.FollowParent, sl.ConfiguredExecutablePath, sl.ResolvedExecutablePath ?? "(未找到)", sl.ExecutableExists,
sl.ProjectionPort, sl.ProjectionPortReachable);
}
// FollowParent=true 时 MiGu.Server 退出会 kill SimpleLite;默认 false 时不注册停机清理(两进程独立)。
if (builder.Configuration.GetValue("SimpleLite:FollowParent", false))
// FollowParent=true 时 MiGu.Server 退出会 kill Simple3;默认 false 时不注册停机清理(两进程独立)。
if (builder.Configuration.GetValue("Simple3:FollowParent", false))
{
app.Lifetime.ApplicationStopping.Register(() =>
{
try { app.Services.GetRequiredService<SimpleLiteLauncher>().Dispose(); }
try { app.Services.GetRequiredService<Simple3Launcher>().Dispose(); }
catch { /* shutdown best-effort */ }
});
}
else
{
app.Logger.LogInformation("[MiGu.Server] SimpleLite: FollowParent=false — MiGu.Server 退出不会结束 SimpleLite");
app.Logger.LogInformation("[MiGu.Server] Simple3: FollowParent=false — MiGu.Server 退出不会结束 Simple3");
}
if (app.Environment.IsDevelopment())
app.UseSwagger();
app.UseSwaggerUI();
// OTA 回传会话按 TCP 对端 IP 校验;必须在 ForwardedHeaders 改写 RemoteIpAddress 之前捕获。
app.Use(async (ctx, next) =>
{
app.UseSwagger();
app.UseSwaggerUI();
}
ctx.Items[MiGu.Server.Controllers.OtaReceiveController.TcpRemoteIpItemKey] =
ctx.Connection.RemoteIpAddress?.ToString();
await next();
});
// M6:在反向代理 / 负载均衡后运行时,根据 X-Forwarded-Proto 还原真实 scheme,让
// Request.IsHttps 正确 → 登录 Cookie 的 Secure 标志在生产 HTTPS 下能正确置位。
@@ -357,6 +367,7 @@ app.UseStaticFiles();
// 鉴权 / 授权管道必须放在 MapControllers 之前;CORS 之后。
app.UseAuthentication();
app.UseMiddleware<HttpActorContextMiddleware>();
app.UseAuthorization();
app.MapControllers();
+119 -35
View File
@@ -5,8 +5,8 @@
>
> **会话 N+1(启动反转):现在 `MiGu.Server.exe` 是主入口**。
> 用户先启动 MiGu.Server,浏览器登录页选择「启动模式」(本地+Web / 仅Web)
> 后端 `AuthController.Login` 调用 `SimpleLiteLauncher.MaybeStart(launchMode)` 按所选模式拉起 `SimpleLite.exe`
> 通过 `--display-mode=web|web+local` 透传给 SimpleLite 的 `Configuration.displayMode`。
> 后端 `AuthController.Login` 调用 `Simple3Launcher.MaybeStart(launchMode)` 按所选模式拉起 `Simple3.exe`
> 通过 `--display-mode=web|web+local` 透传给 Simple3 的 `Configuration.displayMode`。
>
> 工程名 `MiGu.Server` / 程序集 `MiGu.Server.dll` 保持稳定,仅用户可见前端文案改为「迷毂」。
@@ -19,83 +19,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(库已存在则不改结构)
→ IDataMigratorApplyDataMigratorsOnStartup=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 内部 APIRV-04**:默认 `simple.json:platform.allowLoopbackBypass=true`,本机回环请求免 token;**多租户 / 共享主机的生产环境建议设为 `false`**,强制所有请求携带 `X-Platform-Internal-Token`(配合 `platform.internalToken` 或 `MiGu.Server/data/.internal-token`)。详见复审 `Doc/CODE_REVIEW_WEEK_2026-05-29.md` RV-04。
> **Simple3 8222 内部 APIRV-04**:默认 `simple.json:platform.allowLoopbackBypass=true`,本机回环请求免 token;**多租户 / 共享主机的生产环境建议设为 `false`**,强制所有请求携带 `X-Platform-Internal-Token`(配合 `platform.internalToken` 或 `MiGu.Server/data/.internal-token`)。详见复审 `Doc/CODE_REVIEW_WEEK_2026-05-29.md` RV-04。
## 能力清单(本轮)
@@ -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/.../distassets/ 与 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
+139
View File
@@ -0,0 +1,139 @@
using System.Text.Encodings.Web;
using System.Text.Json;
using System.Text.Json.Nodes;
using MiGu.Server.Launcher;
namespace MiGu.Server.Signal;
public sealed record SignalColumn(string Key, string Label, string Type, IReadOnlyList<string>? Options = null, string? Group = null);
public sealed record SignalTableDef(
string Id,
string Title,
string Category,
string FileName,
IReadOnlyList<SignalColumn> Columns);
/// <summary>
/// 读写 Simple3 工作目录 <c>Config/Signal/*.json</c>,供迷毂「数据中心」表格编辑。
/// 表结构只从 Simple3 <c>plugins/StandardScene.Signal.dll</c> 反射;无插件时才读 signal-tables.json。
/// </summary>
public sealed class SignalDataStore
{
private static readonly JsonSerializerOptions FileJson = new()
{
WriteIndented = true,
Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping
};
private static readonly object FileLock = new();
private readonly Simple3Launcher _launcher;
private IReadOnlyList<SignalTableDef>? _cachedTables;
private long _cachedSignature;
public SignalDataStore(Simple3Launcher launcher) => _launcher = launcher;
public IReadOnlyList<SignalTableDef> GetTables()
{
var sig = SignalTableManifestLoader.ComputeManifestSignature(_launcher);
if (_cachedTables == null || sig != _cachedSignature)
{
_cachedTables = SignalTableManifestLoader.Load(_launcher);
_cachedSignature = sig;
}
return _cachedTables;
}
public SignalTableDef? Find(string id) =>
GetTables().FirstOrDefault(t => string.Equals(t.Id, id, StringComparison.OrdinalIgnoreCase));
public string? ResolveWorkingDirectory() => _launcher.ResolveWorkingDirectory();
public (string? Path, string? Error) ResolveFile(SignalTableDef table, bool createDir)
{
var wd = _launcher.ResolveWorkingDirectory();
if (string.IsNullOrWhiteSpace(wd))
return (null, "未找到 Simple3 工作目录,无法定位 Config/Signal");
if (!TryResolveUnderSignalDir(wd, table.FileName, out var dest, out var pathError))
return (null, pathError);
if (File.Exists(dest))
return (dest, null);
if (createDir)
{
try
{
var dir = Path.GetDirectoryName(dest);
if (!string.IsNullOrEmpty(dir))
Directory.CreateDirectory(dir);
}
catch (Exception ex)
{
return (null, ex.Message);
}
}
return (dest, null);
}
public JsonArray LoadRows(SignalTableDef table)
{
var (path, _) = ResolveFile(table, createDir: false);
if (path == null || !File.Exists(path))
return new JsonArray();
lock (FileLock)
{
var json = File.ReadAllText(path);
if (string.IsNullOrWhiteSpace(json))
return new JsonArray();
var node = JsonNode.Parse(json);
if (node is JsonArray arr)
return arr;
return new JsonArray();
}
}
public void SaveRows(SignalTableDef table, JsonArray rows)
{
var (path, error) = ResolveFile(table, createDir: true);
if (path == null)
throw new InvalidOperationException(error ?? "无法解析信号配置文件路径");
var dir = Path.GetDirectoryName(path);
if (!string.IsNullOrEmpty(dir))
Directory.CreateDirectory(dir);
lock (FileLock)
File.WriteAllText(path, rows.ToJsonString(FileJson));
}
private static bool TryResolveUnderSignalDir(string workingDirectory, string fileName, out string dest, out string error)
{
dest = "";
error = "";
if (string.IsNullOrWhiteSpace(fileName) ||
Path.IsPathRooted(fileName) ||
fileName.IndexOfAny(Path.GetInvalidFileNameChars()) >= 0)
{
error = "信号表文件名非法";
return false;
}
var root = Path.GetFullPath(Path.Combine(workingDirectory, "Config", "Signal"));
dest = Path.GetFullPath(Path.Combine(root, fileName));
var prefix = root.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar) + Path.DirectorySeparatorChar;
if (!dest.StartsWith(prefix, StringComparison.OrdinalIgnoreCase))
{
error = "信号表路径超出 Config/Signal";
dest = "";
return false;
}
return true;
}
}
@@ -0,0 +1,289 @@
using System.ComponentModel;
using System.Reflection;
using System.Text.Json.Serialization;
namespace MiGu.Server.Signal;
/// <summary>
/// 从 Simple3 <c>plugins/StandardScene.Signal.dll</c> 反射数据中心表和列。
/// 平台不编译引用该插件工程,避免仓外路径和 Windows TFM 耦合。
/// </summary>
public static class SignalModelSchemaResolver
{
private const string ModelNamespace = "StandardScene.Signal.Model";
private const string AssemblyFileName = "StandardScene.Signal.dll";
public static string? FindAssemblyPath(string? pluginsDir)
{
if (string.IsNullOrWhiteSpace(pluginsDir))
return null;
var path = Path.Combine(pluginsDir, AssemblyFileName);
return File.Exists(path) ? path : null;
}
public static IReadOnlyList<SignalTableDef> ResolveTables(string? pluginsDir, string? workingDirectory = null)
{
return WithSignalAssembly(pluginsDir, workingDirectory, assembly =>
{
if (assembly == null)
return Array.Empty<SignalTableDef>();
try
{
return BuildTablesFromAssembly(assembly);
}
catch
{
return Array.Empty<SignalTableDef>();
}
});
}
public static IReadOnlyList<SignalColumn> ResolveColumns(string? modelName, string? pluginsDir, string? workingDirectory = null)
{
if (string.IsNullOrWhiteSpace(modelName))
return Array.Empty<SignalColumn>();
return WithSignalAssembly(pluginsDir, workingDirectory, assembly =>
{
if (assembly == null)
return Array.Empty<SignalColumn>();
try
{
var type = assembly.GetType($"{ModelNamespace}.{modelName.Trim()}", throwOnError: false, ignoreCase: true);
if (type == null)
return Array.Empty<SignalColumn>();
return DiscoverColumns(type);
}
catch
{
return Array.Empty<SignalColumn>();
}
});
}
private static T WithSignalAssembly<T>(string? pluginsDir, string? workingDirectory, Func<Assembly?, T> use)
{
var assemblyPath = FindAssemblyPath(pluginsDir);
if (assemblyPath == null)
return use(null);
var expected = Path.GetFullPath(assemblyPath);
var cached = FindLoadedFrom(expected);
if (cached != null)
return use(cached);
var probeDirs = BuildProbeDirs(pluginsDir, workingDirectory);
ResolveEventHandler handler = (_, args) => ResolveAssembly(args.Name, probeDirs);
AppDomain.CurrentDomain.AssemblyResolve += handler;
try
{
var loaded = Assembly.LoadFrom(expected);
return use(HasModelTypes(loaded) ? loaded : null);
}
catch
{
return use(null);
}
finally
{
AppDomain.CurrentDomain.AssemblyResolve -= handler;
}
}
private static Assembly? FindLoadedFrom(string expectedFullPath)
{
try
{
foreach (var assembly in AppDomain.CurrentDomain.GetAssemblies())
{
if (!string.Equals(assembly.GetName().Name, "StandardScene.Signal", StringComparison.OrdinalIgnoreCase))
continue;
if (string.IsNullOrWhiteSpace(assembly.Location))
continue;
if (!string.Equals(Path.GetFullPath(assembly.Location), expectedFullPath, StringComparison.OrdinalIgnoreCase))
continue;
return HasModelTypes(assembly) ? assembly : null;
}
}
catch
{
/* 继续 LoadFrom */
}
return null;
}
private static bool HasModelTypes(Assembly assembly)
{
return SafeGetTypes(assembly).Any(t =>
t != null && string.Equals(t.Namespace, ModelNamespace, StringComparison.Ordinal));
}
private static IReadOnlyList<string> BuildProbeDirs(string? pluginsDir, string? workingDirectory)
{
var dirs = new List<string>();
void Add(string? dir)
{
if (string.IsNullOrWhiteSpace(dir)) return;
var full = Path.GetFullPath(dir);
if (Directory.Exists(full) && !dirs.Contains(full, StringComparer.OrdinalIgnoreCase))
dirs.Add(full);
}
Add(pluginsDir);
Add(workingDirectory);
if (!string.IsNullOrWhiteSpace(pluginsDir))
Add(Path.GetDirectoryName(pluginsDir));
return dirs;
}
private static Assembly? ResolveAssembly(string? assemblyName, IReadOnlyList<string> probeDirs)
{
if (string.IsNullOrWhiteSpace(assemblyName))
return null;
string simpleName;
try
{
simpleName = new AssemblyName(assemblyName).Name ?? assemblyName;
}
catch
{
simpleName = assemblyName.Split(',')[0];
}
foreach (var dir in probeDirs)
{
var path = Path.Combine(dir, simpleName + ".dll");
if (!File.Exists(path))
continue;
try
{
return Assembly.LoadFrom(path);
}
catch
{
/* try next dir */
}
}
return null;
}
private static IReadOnlyList<SignalTableDef> BuildTablesFromAssembly(Assembly assembly)
{
var found = new List<(int Order, SignalTableDef Table)>();
foreach (var type in SafeGetTypes(assembly))
{
if (type == null || !string.Equals(type.Namespace, ModelNamespace, StringComparison.Ordinal))
continue;
var attr = type.GetCustomAttributes(inherit: false)
.FirstOrDefault(a => a.GetType().Name == "SignalTableAttribute");
if (attr == null)
continue;
var attrType = attr.GetType();
var id = (attrType.GetProperty("Id")?.GetValue(attr) as string ?? "").Trim();
var fileName = (attrType.GetProperty("FileName")?.GetValue(attr) as string ?? "").Trim();
var title = (attrType.GetProperty("Title")?.GetValue(attr) as string ?? "").Trim();
var order = attrType.GetProperty("Order")?.GetValue(attr) as int? ?? 0;
if (string.IsNullOrWhiteSpace(id) || string.IsNullOrWhiteSpace(fileName))
continue;
var category = type.GetCustomAttribute<CategoryAttribute>()?.Category ?? "";
if (string.IsNullOrWhiteSpace(title))
title = type.GetCustomAttribute<DisplayNameAttribute>()?.DisplayName ?? id;
found.Add((order, new SignalTableDef(id, title, category, fileName, DiscoverColumns(type))));
}
return found.OrderBy(x => x.Order).ThenBy(x => x.Table.Id).Select(x => x.Table).ToList();
}
private static IEnumerable<Type> SafeGetTypes(Assembly assembly)
{
try
{
return assembly.GetTypes();
}
catch (ReflectionTypeLoadException ex)
{
return ex.Types.Where(t => t != null)!;
}
}
private static IReadOnlyList<SignalColumn> DiscoverColumns(Type type)
{
var list = new List<SignalColumn>();
foreach (var prop in type.GetProperties(BindingFlags.Public | BindingFlags.Instance)
.Where(p => p.CanRead && p.CanWrite && p.GetIndexParameters().Length == 0)
.Where(p => p.GetCustomAttribute<BrowsableAttribute>()?.Browsable != false)
.Where(p => !IsJsonIgnored(p))
.OrderBy(p => p.MetadataToken))
{
var label = prop.GetCustomAttribute<DisplayNameAttribute>()?.DisplayName;
if (string.IsNullOrWhiteSpace(label))
label = prop.Name;
var columnType = MapType(prop.PropertyType);
string[]? options = null;
var group = prop.GetCustomAttribute<CategoryAttribute>(inherit: false)?.Category;
if (string.IsNullOrWhiteSpace(group))
group = null;
var select = ReadSelectOptions(prop);
if (select is { Length: > 0 })
{
columnType = "enum";
options = select;
}
else if (columnType == "enum" && (Nullable.GetUnderlyingType(prop.PropertyType) ?? prop.PropertyType).IsEnum)
{
options = Enum.GetNames(Nullable.GetUnderlyingType(prop.PropertyType) ?? prop.PropertyType);
}
list.Add(new SignalColumn(prop.Name, label, columnType, options, group));
}
return list;
}
private static bool IsJsonIgnored(PropertyInfo prop)
{
if (prop.GetCustomAttribute<JsonIgnoreAttribute>() != null)
return true;
foreach (var attr in prop.GetCustomAttributes(inherit: true))
{
if (attr.GetType().FullName == "Newtonsoft.Json.JsonIgnoreAttribute")
return true;
}
return false;
}
private static string[]? ReadSelectOptions(PropertyInfo prop)
{
var attr = prop.GetCustomAttributes(inherit: false)
.FirstOrDefault(a => a.GetType().Name == "SignalSelectAttribute");
if (attr == null)
return null;
var options = attr.GetType().GetProperty("Options")?.GetValue(attr) as string[];
return options is { Length: > 0 } ? options : null;
}
private static string MapType(Type type)
{
var underlying = Nullable.GetUnderlyingType(type) ?? type;
if (underlying == typeof(bool))
return "bool";
if (underlying == typeof(int) || underlying == typeof(long) || underlying == typeof(short) ||
underlying == typeof(byte) || underlying == typeof(uint) || underlying == typeof(ulong))
return "int";
if (underlying.IsEnum)
return "enum";
return "string";
}
}
+40
View File
@@ -0,0 +1,40 @@
namespace MiGu.Server.Signal;
using System.Text.Json.Serialization;
public sealed class SignalTableManifest
{
public int Version { get; set; } = 1;
public List<SignalTableManifestEntry> Tables { get; set; } = new();
}
public sealed class SignalTableManifestEntry
{
public string Id { get; set; } = "";
public string Title { get; set; } = "";
public string Category { get; set; } = "";
public string FileName { get; set; } = "";
public string Model { get; set; } = "";
public List<SignalTableColumnEntry>? Columns { get; set; }
}
public sealed class SignalTableColumnEntry
{
public string Key { get; set; } = "";
public string Label { get; set; } = "";
public string Type { get; set; } = "string";
[JsonPropertyName("options")]
public string[]? Options { get; set; }
[JsonPropertyName("group")]
public string? Group { get; set; }
}
@@ -0,0 +1,174 @@
using System.Text.Json;
using MiGu.Server.Launcher;
namespace MiGu.Server.Signal;
/// <summary>
/// 优先从 plugins/StandardScene.Signal.dll 反射表和列(Model 上的 SignalTable / DisplayName)。
/// 没有插件 DLL 时才读 signal-tables.json 或内置清单。
/// </summary>
public static class SignalTableManifestLoader
{
private const string ManifestFileName = "signal-tables.json";
private static readonly JsonSerializerOptions JsonOptions = new()
{
PropertyNameCaseInsensitive = true
};
public static IReadOnlyList<SignalTableDef> Load(Simple3Launcher launcher)
{
var workingDirectory = launcher.ResolveWorkingDirectory();
var pluginsDir = launcher.ResolvePluginsDir();
var fromPlugin = SignalModelSchemaResolver.ResolveTables(pluginsDir, workingDirectory);
if (fromPlugin.Count > 0)
return fromPlugin;
var manifest = TryLoadManifest(launcher);
return manifest.Tables
.Where(t => !string.IsNullOrWhiteSpace(t.Id) && !string.IsNullOrWhiteSpace(t.FileName))
.Select(e => ToDef(e, pluginsDir, workingDirectory))
.ToList();
}
public static long ComputeManifestSignature(Simple3Launcher launcher)
{
long sig = 0;
foreach (var path in ResolveManifestPaths(launcher))
{
if (!File.Exists(path)) continue;
try
{
var info = new FileInfo(path);
sig ^= info.LastWriteTimeUtc.Ticks;
sig ^= info.Length;
}
catch { /* ignore */ }
}
var pluginsDir = launcher.ResolvePluginsDir();
var dll = pluginsDir == null ? null : Path.Combine(pluginsDir, "StandardScene.Signal.dll");
if (dll != null && File.Exists(dll))
{
try
{
var info = new FileInfo(dll);
sig ^= info.LastWriteTimeUtc.Ticks;
}
catch { /* ignore */ }
}
return sig;
}
private static SignalTableManifest TryLoadManifest(Simple3Launcher launcher)
{
foreach (var path in ResolveManifestPaths(launcher))
{
if (!File.Exists(path)) continue;
try
{
var json = File.ReadAllText(path);
var manifest = JsonSerializer.Deserialize<SignalTableManifest>(json, JsonOptions);
if (manifest?.Tables is { Count: > 0 })
return manifest;
}
catch { /* try next */ }
}
return JsonSerializer.Deserialize<SignalTableManifest>(EmbeddedFallbackJson, JsonOptions)
?? new SignalTableManifest();
}
private static IEnumerable<string> ResolveManifestPaths(Simple3Launcher launcher)
{
var wd = launcher.ResolveWorkingDirectory();
if (!string.IsNullOrWhiteSpace(wd))
yield return Path.Combine(wd, "Config", "Signal", ManifestFileName);
var plugins = launcher.ResolvePluginsDir();
if (string.IsNullOrWhiteSpace(plugins)) yield break;
yield return Path.Combine(plugins, ManifestFileName);
yield return Path.Combine(plugins, "Config", "Signal", ManifestFileName);
}
private static SignalTableDef ToDef(SignalTableManifestEntry entry, string? pluginsDir, string? workingDirectory)
{
var columns = BuildColumns(entry, pluginsDir, workingDirectory);
return new SignalTableDef(
entry.Id.Trim(),
string.IsNullOrWhiteSpace(entry.Title) ? entry.Id.Trim() : entry.Title.Trim(),
entry.Category ?? "",
entry.FileName.Trim(),
columns);
}
private static IReadOnlyList<SignalColumn> BuildColumns(
SignalTableManifestEntry entry,
string? pluginsDir,
string? workingDirectory)
{
var reflected = SignalModelSchemaResolver.ResolveColumns(entry.Model, pluginsDir, workingDirectory);
if (reflected.Count > 0)
return reflected;
if (entry.Columns is { Count: > 0 })
{
return entry.Columns
.Where(c => !string.IsNullOrWhiteSpace(c.Key))
.Select(c => new SignalColumn(
c.Key.Trim(),
string.IsNullOrWhiteSpace(c.Label) ? c.Key.Trim() : c.Label.Trim(),
string.IsNullOrWhiteSpace(c.Type) ? "string" : c.Type.Trim(),
c.Options,
string.IsNullOrWhiteSpace(c.Group) ? null : c.Group.Trim()))
.ToList();
}
return Array.Empty<SignalColumn>();
}
private const string EmbeddedFallbackJson = """
{
"version": 1,
"tables": [
{
"id": "stations",
"title": "PLC机构",
"category": "PLC数据管理",
"fileName": "stations.json",
"model": "PlcStationModel"
},
{
"id": "docks",
"title": "机构工位",
"category": "PLC数据管理",
"fileName": "station-docks.json",
"model": "PlcStationDockModel"
},
{
"id": "handshake",
"title": "握手点",
"category": "握手点数据管理",
"fileName": "handshake-points.json",
"model": "HandshakePointModel"
},
{
"id": "release",
"title": "放行点",
"category": "放行点数据管理",
"fileName": "release-points.json",
"model": "ReleasePointModel"
},
{
"id": "mag-control",
"title": "磁条管控区",
"category": "磁条交管",
"fileName": "mag-control-areas.json",
"model": "MagControlAreaModel"
}
]
}
""";
}
@@ -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);
}
}
@@ -1,72 +1,5 @@
using System.ComponentModel.DataAnnotations;
namespace MiGu.Server.SimpleFields;
public sealed class SimpleField
{
public Guid Id { get; set; } = Guid.NewGuid();
/// <summary>车型唯一标识:assemblyName.shortName,如 StandardScene.QrLidar.Forklift。</summary>
[MaxLength(64)]
public string CarType { get; set; } = "";
/// <summary>
/// 字段类型唯一标识:assemblyName.shortName,如 StandardScene.QrLidar.Forklift。
/// </summary>
[MaxLength(64)]
public string FieldType { get; set; } = "";
/// <summary>
/// 字段唯一标识:key,如 Forklift.PositionX。
/// </summary>
[MaxLength(128)]
public string Key { get; set; } = "";
/// <summary>
/// 字段值,如 10.0。
/// </summary>
public string Value { get; set; } = "";
/// <summary>
/// 数据类型,如 System.String。
/// </summary>
[MaxLength(128)]
public string DataType { get; set; } = "";
/// <summary>
/// 中文名称,如 位置 X。
/// </summary>
[MaxLength(256)]
public string? Chinese { get; set; }
/// <summary>
/// 英文名称,如 Position X。
/// </summary>
[MaxLength(256)]
public string? English { get; set; }
/// <summary>
/// 其他语言名称,如 位置 X。
/// </summary>
[MaxLength(512)]
public string Other { get; set; } = "";
/// <summary>
/// 是否内置默认字段,如 true。
/// </summary>
public bool IsDefault { get; set; }
/// <summary>
/// 创建时间,如 2021-01-01 12:00:00。
/// </summary>
public DateTimeOffset CreateTime { get; set; }
/// <summary>
/// 更新时间,如 2021-01-01 12:00:00。
/// </summary>
public DateTimeOffset UpdateTime { get; set; }
}
public sealed record SimpleFieldRequest(
Guid? Id,
string CarType,
@@ -1,13 +1,14 @@
using Microsoft.EntityFrameworkCore;
using MiGu.Server.Persistence;
using MiGu.DB.Domains.SimpleFields;
namespace MiGu.Server.SimpleFields;
public sealed class SimpleFieldService
{
private readonly PlatformDbContext _db;
private readonly MiGuDbContext _db;
public SimpleFieldService(PlatformDbContext db) => _db = db;
public SimpleFieldService(MiGuDbContext db) => _db = db;
public async Task<List<SimpleField>> ListAsync(string? fieldType = null, string? carType = null, string? q = null)
{
+44 -343
View File
@@ -1,358 +1,59 @@
using System.ComponentModel.DataAnnotations;
using MiGu.Server.Persistence;
namespace MiGu.Server.Wms;
public abstract class WarehouseHistoryBase
{
public Guid Id { get; set; } = Guid.NewGuid();
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; } = "{}";
}
// API / 读模型 DTO(留在 Server,不进 MiGu.DB)。请求字段中的状态仍为 string,由 Service 解析为枚举后写入实体。
public sealed class Warehouse : EntityBase
{
[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 record InventoryMaterialRow(
Guid MaterialId, string MaterialCode, string MaterialName, string MaterialBarcode, string MaterialTypeCode,
Guid ContainerId, string ContainerCode, string ContainerName,
Guid? StorageId, string StorageCode, string StorageName,
Guid? AreaId, string AreaCode, string AreaName,
string LocationType, DateTimeOffset BoundAt);
public sealed class WarehouseArea : EntityBase
{
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";
[MaxLength(32)] public string LayoutMode { get; set; } = AreaLayoutModes.Flat;
[MaxLength(32)] public string State { get; set; } = "Default";
public bool Enabled { get; set; } = true;
public int SortOrder { get; set; }
}
public sealed record ContainerLocationSnapshot(
Guid Id, Guid ContainerId, string LocationType, string LocationId, string LocationCode, string LocationName,
string Status, DateTimeOffset EnteredAt, long Version);
public sealed class Storage : EntityBase
{
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; } = "Storage";
[MaxLength(32)] public string LocationKind { get; set; } = LocationKinds.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; } = "";
/// <summary>已废弃:一货位一容器,保留列兼容旧库。</summary>
public int Capacity { get; set; }
[MaxLength(32)] public string Status { get; set; } = StorageStatuses.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 record ContainerMaterialSnapshot(
Guid Id, Guid ContainerId, Guid MaterialId, decimal Quantity, string BatchNo, string SerialNo, string Status,
DateTimeOffset BoundAt, DateTimeOffset LoadedAt, DateTimeOffset? UnloadedAt, long Version);
public sealed class Container : EntityBase
{
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";
[MaxLength(64)] public string Status { get; set; } = ContainerStatuses.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 abstract record CommonRequest(Guid? Id, long? Version, bool IsLock, string Remark, string Extend);
public sealed class MaterialType : EntityBase
{
[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 record MasterDataRequest(
Guid? Id, long? Version, string Code, string Name, string Type, bool Enabled, int SortOrder,
bool IsLock, string Remark, string Extend) : CommonRequest(Id, Version, IsLock, Remark, Extend);
public sealed class Material : EntityBase
{
[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; } = "";
[MaxLength(32)] public string LifecycleStatus { get; set; } = MaterialLifecycles.Active;
public DateTimeOffset? UnboundAt { get; set; }
public bool Enabled { get; set; } = true;
}
public sealed record AreaRequest(
Guid? Id, long? Version, Guid? WarehouseId, string Code, string Name, string Type, string LayoutMode, string State,
bool Enabled, int SortOrder, bool IsLock, string Remark, string Extend) : CommonRequest(Id, Version, IsLock, Remark, Extend);
public sealed class ContainerLocation : EntityBase
{
public Guid ContainerId { get; set; }
[MaxLength(32)] public string LocationType { get; set; } = ContainerLocationTypes.Storage;
[MaxLength(64)] public string LocationId { get; set; } = "";
[MaxLength(64)] public string LocationCode { get; set; } = "";
[MaxLength(128)] public string LocationName { get; set; } = "";
[MaxLength(32)] public string Status { get; set; } = ContainerLocationStatuses.Active;
public DateTimeOffset EnteredAt { get; set; }
}
public sealed record StorageRequest(
Guid? Id, long? Version, Guid AreaId, string Code, string Name, string StorageType, string LocationKind,
int ColumnNo, int LevelNo, int DepthNo, string SiteId, string SiteCode, string Barcode, int Capacity,
string Status, string Usage, int Priority, string ZoneCode, bool AllowInbound, bool AllowOutbound, bool Enabled,
bool IsLock, string Remark, string Extend) : CommonRequest(Id, Version, IsLock, Remark, Extend);
/// <summary>容器-物料绑定(无数量语义;Quantity 列仅兼容旧库,固定为 1)。</summary>
public sealed class ContainerMaterial : EntityBase
{
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; } = "";
[MaxLength(32)] public string Status { get; set; } = ContainerMaterialStatuses.Bound;
public DateTimeOffset BoundAt { get; set; }
public DateTimeOffset LoadedAt { get; set; }
public DateTimeOffset? UnloadedAt { get; set; }
}
public sealed record GenerateBinsRequest(int ColumnFrom, int ColumnTo, int LevelFrom, int LevelTo, int DepthFrom, int DepthTo, string? CodePattern);
public sealed class StockEvent
{
public Guid Id { get; set; } = Guid.NewGuid();
[MaxLength(32)] public string 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 record ContainerRequest(
Guid? Id, long? Version, Guid? AreaId, string Code, string Name, string Type, string Status, string Barcode,
double Length, double Width, double Height, bool Enabled, bool IsLock, string Remark, string Extend)
: CommonRequest(Id, Version, IsLock, Remark, Extend);
public sealed class ContainerLocationHistory : WarehouseHistoryBase
{
[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 record MaterialTypeRequest(
Guid? Id, long? Version, string Code, string Name, string Spec, string Unit, string Category, string BarcodePrefix,
bool Enabled, bool IsLock, string Remark, string Extend) : CommonRequest(Id, Version, IsLock, Remark, Extend);
public sealed class ContainerMaterialHistory : WarehouseHistoryBase
{
public Guid? MaterialId { get; set; }
public decimal QuantityDelta { get; set; }
}
public sealed record MaterialRequest(
Guid? Id, long? Version, string Code, string Name, string TypeCode, string Barcode, string Spec, string Unit,
string Category, string LifecycleStatus, bool Enabled, bool IsLock, string Remark, string Extend)
: CommonRequest(Id, Version, IsLock, Remark, Extend);
public static class AreaLayoutModes
{
public const string Flat = "Flat";
public const string Grid = "Grid";
public static readonly HashSet<string> All = new(StringComparer.OrdinalIgnoreCase) { Flat, Grid };
}
public sealed record ContainerLocationRequest(
Guid? Id, long? Version, Guid ContainerId, string LocationType, string LocationId, string Status,
DateTimeOffset? EnteredAt, string Source, string Reason, bool IsLock, string Remark, string Extend)
: CommonRequest(Id, Version, IsLock, Remark, Extend);
public static class LocationKinds
{
public const string Grid = "Grid";
public const string Station = "Station";
public static readonly HashSet<string> All = new(StringComparer.OrdinalIgnoreCase) { Grid, Station };
}
public static class ContainerLocationTypes
{
public const string Storage = "Storage";
public const string Car = "Car";
public static readonly HashSet<string> All = new(StringComparer.OrdinalIgnoreCase) { Storage, Car };
}
public static class ContainerLocationStatuses
{
public const string Active = "Active";
public const string Locked = "Locked";
public const string Exception = "Exception";
public static readonly HashSet<string> All = new(StringComparer.OrdinalIgnoreCase) { Active, Locked, Exception };
}
public static class ContainerMaterialStatuses
{
public const string Bound = "Bound";
// 兼容旧数据
public const string Loaded = "Loaded";
public const string Unloaded = "Unloaded";
public const string Adjusted = "Adjusted";
public const string Frozen = "Frozen";
public static readonly HashSet<string> ActiveBind = new(StringComparer.OrdinalIgnoreCase) { Bound, Loaded, Adjusted, Frozen };
}
public static class ContainerStatuses
{
public const string EmptyMaterial = "EmptyMaterial";
public const string FullMaterial = "FullMaterial";
public static readonly HashSet<string> All = new(StringComparer.OrdinalIgnoreCase) { EmptyMaterial, FullMaterial };
}
public static class StorageStatuses
{
public const string Empty = "Empty";
public const string EmptyContainer = "EmptyContainer";
public const string FullContainer = "FullContainer";
public const string Disabled = "Disabled";
// 兼容旧值(读时映射,写时用新值)
public const string Available = "Available";
public const string Idle = "Idle";
public const string Occupied = "Occupied";
public static readonly HashSet<string> All = new(StringComparer.OrdinalIgnoreCase)
{ Empty, EmptyContainer, FullContainer, Disabled };
public static string Normalize(string? status) => status switch
{
Available or Idle => Empty,
Occupied => FullContainer,
_ when string.IsNullOrWhiteSpace(status) => Empty,
_ => status
};
}
public static class StorageTypes
{
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 MaterialLifecycles
{
public const string Active = "Active";
public const string Archived = "Archived";
public static readonly HashSet<string> All = new(StringComparer.OrdinalIgnoreCase) { Active, Archived };
}
public static class StockEventTypes
{
public const string Bind = "Bind";
public const string Unbind = "Unbind";
public const string ContainerMove = "ContainerMove";
}
public static class WmsTransportTriggerTypes
{
public const string MaterialCall = "MaterialCall";
public const string FinishedGoodsOffline = "FinishedGoodsOffline";
public const string AutoTransfer = "AutoTransfer";
public static readonly HashSet<string> All = new(StringComparer.OrdinalIgnoreCase)
{ MaterialCall, FinishedGoodsOffline, AutoTransfer };
}
public static class WmsTransportTaskStatuses
{
public const string Pending = "Pending";
public const string Reserved = "Reserved";
public const string Dispatched = "Dispatched";
public const string InTransit = "InTransit";
public const string Completed = "Completed";
public const string Failed = "Failed";
public const string Cancelled = "Cancelled";
public static readonly HashSet<string> Active = new(StringComparer.OrdinalIgnoreCase)
{ Pending, Reserved, Dispatched, InTransit };
public static readonly HashSet<string> All = new(StringComparer.OrdinalIgnoreCase)
{ Pending, Reserved, Dispatched, InTransit, Completed, Failed, Cancelled };
}
public static class WmsReservationStatuses
{
public const string Active = "Active";
public const string Released = "Released";
public const string Expired = "Expired";
}
public sealed class WmsTransportRule : EntityBase
{
[MaxLength(64)] public string Code { get; set; } = "";
[MaxLength(128)] public string Name { get; set; } = "";
[MaxLength(64)] public string TriggerType { get; set; } = WmsTransportTriggerTypes.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 : EntityBase
{
[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; }
/// <summary>已废弃:单物料实体模型不再使用数量。</summary>
public decimal? Quantity { get; set; }
[MaxLength(32)] public string Status { get; set; } = WmsTransportTaskStatuses.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 : EntityBase
{
public Guid TaskId { get; set; }
public Guid ContainerId { get; set; }
public Guid SourceStorageId { get; set; }
public Guid TargetStorageId { get; set; }
[MaxLength(32)] public string Status { get; set; } = WmsReservationStatuses.Active;
public DateTimeOffset? ExpiresAt { get; set; }
}
public sealed class WmsTransportTaskHistory
{
public Guid Id { get; set; } = Guid.NewGuid();
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 WmsDefaults
{
public const string DefaultWarehouseCode = "DEFAULT";
public const string DefaultWarehouseName = "默认仓库";
}
public sealed record BindMaterialRequest(
Guid? Id, long? Version, Guid ContainerId, Guid MaterialId, string Source, string Reason,
bool IsLock, string Remark, string Extend) : CommonRequest(Id, Version, IsLock, Remark, Extend);
+4 -4
View File
@@ -5,9 +5,9 @@ namespace MiGu.Server.Wms;
public sealed class WmsReferenceValidator
{
private readonly PlatformDbContext _db;
private readonly MiGuDbContext _db;
public WmsReferenceValidator(PlatformDbContext db)
public WmsReferenceValidator(MiGuDbContext db)
{
_db = db;
}
@@ -50,12 +50,12 @@ public sealed class WmsReferenceValidator
public async Task<(string Code, string Name)> ResolveLocationSnapshotAsync(string locationType, string locationId)
{
if (!ContainerLocationTypes.All.Contains(locationType))
if (!ContainerLocationTypes.IsDefined(locationType))
throw new InvalidOperationException("位置类型无效");
if (string.IsNullOrWhiteSpace(locationId))
throw new InvalidOperationException("位置 ID 不能为空");
if (string.Equals(locationType, ContainerLocationTypes.Storage, StringComparison.OrdinalIgnoreCase))
if (ContainerLocationTypes.EqualsString(ContainerLocationTypes.Storage, locationType))
{
if (!Guid.TryParse(locationId, out var id)) throw new InvalidOperationException("库位 ID 格式无效");
var s = await _db.Storages.FirstOrDefaultAsync(x => x.Id == id);
+141 -258
View File
@@ -1,19 +1,30 @@
using System.Security.Claims;
using System.Text.Json;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using MiGu.DB.Abstractions.Entities;
using MiGu.DB.Abstractions.Persistence;
using MiGu.Server.Persistence;
namespace MiGu.Server.Wms;
public sealed class WmsService
{
private readonly PlatformDbContext _db;
private readonly MiGuDbContext _db;
private readonly IUnitOfWork _uow;
private readonly IServiceProvider _services;
private readonly WmsReferenceValidator _refs;
private readonly JsonSerializerOptions _json = new(JsonSerializerDefaults.Web);
public WmsService(PlatformDbContext db, WmsReferenceValidator refs)
public WmsService(
MiGuDbContext db,
IUnitOfWork uow,
IServiceProvider services,
WmsReferenceValidator refs)
{
_db = db;
_uow = uow;
_services = services;
_refs = refs;
}
@@ -31,7 +42,10 @@ public sealed class WmsService
{
var query = _db.Storages.AsNoTracking().AsQueryable();
if (areaId.HasValue) query = query.Where(x => x.AreaId == areaId.Value);
if (!string.IsNullOrWhiteSpace(locationKind)) query = query.Where(x => x.LocationKind == locationKind);
if (!string.IsNullOrWhiteSpace(locationKind) &&
Enum.TryParse<LocationKind>(locationKind.Trim(), true, out var kind) &&
LocationKinds.All.Contains(kind))
query = query.Where(x => x.LocationKind == kind);
return FilterByKeyword(query.OrderBy(x => x.Code), q).ToListAsync();
}
@@ -44,7 +58,7 @@ public sealed class WmsService
public Task<List<Material>> Materials(string? q = null, string? lifecycle = null, bool? onlyUnbound = null, bool? onlyBound = null)
{
var query = _db.Materials.AsNoTracking().AsQueryable();
var life = string.IsNullOrWhiteSpace(lifecycle) ? MaterialLifecycles.Active : lifecycle.Trim();
var life = MaterialLifecycles.ParseOr(lifecycle);
query = query.Where(x => x.LifecycleStatus == life);
if (onlyUnbound == true || onlyBound == true)
{
@@ -58,7 +72,11 @@ public sealed class WmsService
public Task<List<ContainerLocation>> ContainerLocations(string? locationType = null, string? q = null)
{
var query = _db.ContainerLocations.AsNoTracking().OrderBy(x => x.ContainerId).AsQueryable();
if (!string.IsNullOrWhiteSpace(locationType)) query = query.Where(x => x.LocationType == locationType);
if (!string.IsNullOrWhiteSpace(locationType) && ContainerLocationTypes.IsDefined(locationType))
{
var lt = ContainerLocationTypes.ParseOr(locationType);
query = query.Where(x => x.LocationType == lt);
}
return FilterByKeyword(query, q).ToListAsync();
}
@@ -69,75 +87,85 @@ public sealed class WmsService
return FilterByKeyword(query.OrderBy(x => x.ContainerId), q).ToListAsync();
}
/// <summary>
/// 库存物料列表:筛选/排序下推到 SQL。
/// 库位:优先 ContainerLocation.StorageId;未回填时用 LocationId 与库位 Id 的存储字符串(Guid "D")匹配。
/// </summary>
public async Task<List<InventoryMaterialRow>> InventoryMaterials(Guid? areaId = null, Guid? storageId = null, string? q = null)
{
var binds = await _db.ContainerMaterials.AsNoTracking().ToListAsync();
var materials = await _db.Materials.AsNoTracking().ToDictionaryAsync(x => x.Id);
var locations = await _db.ContainerLocations.AsNoTracking().ToDictionaryAsync(x => x.ContainerId);
var storages = await _db.Storages.AsNoTracking().ToDictionaryAsync(x => x.Id);
var areas = await _db.WarehouseAreas.AsNoTracking().ToDictionaryAsync(x => x.Id);
var containers = await _db.Containers.AsNoTracking().ToDictionaryAsync(x => x.Id);
var query =
from b in _db.ContainerMaterials.AsNoTracking()
join mat in _db.Materials.AsNoTracking() on b.MaterialId equals mat.Id
from ctn in _db.Containers.AsNoTracking().Where(c => c.Id == b.ContainerId).DefaultIfEmpty()
from loc in _db.ContainerLocations.AsNoTracking().Where(l => l.ContainerId == b.ContainerId).DefaultIfEmpty()
from st in _db.Storages.AsNoTracking().Where(s =>
loc != null &&
loc.LocationType == ContainerLocationType.Storage &&
(loc.StorageId == s.Id ||
(loc.StorageId == null && loc.LocationId == EF.Property<string>(s, nameof(Storage.Id))))).DefaultIfEmpty()
from area in _db.WarehouseAreas.AsNoTracking().Where(a => st != null && a.Id == st.AreaId).DefaultIfEmpty()
select new { b, mat, ctn, loc, st, area };
var rows = new List<InventoryMaterialRow>();
foreach (var b in binds)
{
if (!materials.TryGetValue(b.MaterialId, out var mat)) continue;
locations.TryGetValue(b.ContainerId, out var loc);
Storage? storage = null;
WarehouseArea? area = null;
if (loc != null && loc.LocationType == ContainerLocationTypes.Storage && Guid.TryParse(loc.LocationId, out var sid))
storages.TryGetValue(sid, out storage);
if (storage != null) areas.TryGetValue(storage.AreaId, out area);
containers.TryGetValue(b.ContainerId, out var ctn);
if (storageId.HasValue && storage?.Id != storageId.Value) continue;
if (areaId.HasValue && area?.Id != areaId.Value) continue;
if (storageId.HasValue)
query = query.Where(x => x.st != null && x.st.Id == storageId.Value);
if (areaId.HasValue)
query = query.Where(x => x.area != null && x.area.Id == areaId.Value);
if (!string.IsNullOrWhiteSpace(q))
{
var s = q.Trim();
if (!(mat.Code.Contains(s) || mat.Name.Contains(s) || (ctn?.Code.Contains(s) ?? false) || (storage?.Code.Contains(s) ?? false)))
continue;
query = query.Where(x =>
x.mat.Code.Contains(s) || x.mat.Name.Contains(s) ||
(x.ctn != null && (x.ctn.Code.Contains(s) || x.ctn.Name.Contains(s))) ||
(x.st != null && x.st.Code.Contains(s)));
}
rows.Add(new InventoryMaterialRow(
mat.Id, mat.Code, mat.Name, mat.Barcode, mat.TypeCode,
b.ContainerId, ctn?.Code ?? "", ctn?.Name ?? "",
storage?.Id, storage?.Code ?? "", storage?.Name ?? "",
area?.Id, area?.Code ?? "", area?.Name ?? "",
loc?.LocationType ?? "", b.BoundAt));
}
return rows.OrderBy(x => x.MaterialCode).ToList();
return await query
.OrderBy(x => x.mat.Code)
.Select(x => new InventoryMaterialRow(
x.mat.Id, x.mat.Code, x.mat.Name, x.mat.Barcode, x.mat.TypeCode,
x.b.ContainerId, x.ctn != null ? x.ctn.Code : "", x.ctn != null ? x.ctn.Name : "",
x.st != null ? x.st.Id : null, x.st != null ? x.st.Code : "", x.st != null ? x.st.Name : "",
x.area != null ? x.area.Id : null, x.area != null ? x.area.Code : "", x.area != null ? x.area.Name : "",
x.loc != null ? x.loc.LocationType.ToString() : "", x.b.BoundAt))
.ToListAsync();
}
/// <summary>
/// 库存事件:条件与 OrderBy/Take(500) 均下推;非法 eventType 直接返回空,避免全表拉取后再过滤。
/// </summary>
public async Task<List<StockEvent>> StockEvents(string? eventType = null, Guid? materialId = null, Guid? containerId = null)
{
var query = _db.StockEvents.AsNoTracking().AsQueryable();
if (!string.IsNullOrWhiteSpace(eventType)) query = query.Where(x => x.EventType == eventType);
if (!string.IsNullOrWhiteSpace(eventType))
{
if (Enum.TryParse<StockEventType>(eventType.Trim(), true, out var et))
query = query.Where(x => x.EventType == et);
else
return [];
}
if (materialId.HasValue) query = query.Where(x => x.MaterialId == materialId.Value);
if (containerId.HasValue) query = query.Where(x => x.ContainerId == containerId.Value);
var rows = await query.ToListAsync();
return rows.OrderByDescending(x => x.OperatedAt).Take(500).ToList();
return await query.OrderByDescending(x => x.OperatedAt).Take(500).ToListAsync();
}
public async Task<Warehouse> SaveWarehouse(MasterDataRequest req, string actor)
{
Warehouse entity;
if (req.Id.HasValue) entity = await FindEditable(_db.Warehouses, req.Id.Value, req.Version);
if (req.Id.HasValue) entity = await FindEditable<Warehouse>(req.Id.Value, req.Version);
else
{
entity = new Warehouse();
StampCreate(entity, actor);
_db.Warehouses.Add(entity);
}
await EnsureUnique(_db.Warehouses, x => x.Code == req.Code && x.Id != entity.Id, "仓库编码已存在");
await Repo<Warehouse>().EnsureUniqueAsync(x => x.Code == req.Code && x.Id != entity.Id, "仓库编码已存在");
entity.Code = req.Code.Trim();
entity.Name = req.Name.Trim();
entity.Type = req.Type.TrimOr("Default");
entity.Enabled = req.Enabled;
entity.SortOrder = req.SortOrder;
ApplyCommon(entity, req, actor);
await _db.SaveChangesAsync();
await _uow.SaveChangesAsync();
return entity;
}
@@ -150,43 +178,43 @@ public sealed class WmsService
await _refs.EnsureWarehouseAsync(warehouseId);
WarehouseArea entity;
if (req.Id.HasValue) entity = await FindEditable(_db.WarehouseAreas, req.Id.Value, req.Version);
if (req.Id.HasValue) entity = await FindEditable<WarehouseArea>(req.Id.Value, req.Version);
else
{
entity = new WarehouseArea();
StampCreate(entity, actor);
_db.WarehouseAreas.Add(entity);
}
await EnsureUnique(_db.WarehouseAreas, x => x.Code == req.Code && x.Id != entity.Id, "库区编码已存在");
await Repo<WarehouseArea>().EnsureUniqueAsync(x => x.Code == req.Code && x.Id != entity.Id, "库区编码已存在");
entity.WarehouseId = warehouseId;
entity.Code = req.Code.Trim();
entity.Name = req.Name.Trim();
entity.Type = req.Type.TrimOr("Storage");
entity.LayoutMode = AreaLayoutModes.All.Contains(req.LayoutMode) ? req.LayoutMode : AreaLayoutModes.Flat;
entity.LayoutMode = AreaLayoutModes.ParseOr(req.LayoutMode);
entity.State = req.State.TrimOr("Default");
entity.Enabled = req.Enabled;
entity.SortOrder = req.SortOrder;
ApplyCommon(entity, req, actor);
await _db.SaveChangesAsync();
await _uow.SaveChangesAsync();
return entity;
}
public async Task<Storage> SaveStorage(StorageRequest req, string actor)
{
await _refs.EnsureAreaAsync(req.AreaId);
var kind = LocationKinds.All.Contains(req.LocationKind) ? req.LocationKind : LocationKinds.Station;
var kind = LocationKinds.ParseOr(req.LocationKind);
if (kind == LocationKinds.Grid)
await EnsureGridCoordUnique(req.AreaId, req.ColumnNo, req.LevelNo, req.DepthNo, req.Id);
Storage entity;
if (req.Id.HasValue) entity = await FindEditable(_db.Storages, req.Id.Value, req.Version);
if (req.Id.HasValue) entity = await FindEditable<Storage>(req.Id.Value, req.Version);
else
{
entity = new Storage();
StampCreate(entity, actor);
_db.Storages.Add(entity);
}
await EnsureUnique(_db.Storages, x => x.Code == req.Code && x.Id != entity.Id, "库位编码已存在");
await Repo<Storage>().EnsureUniqueAsync(x => x.Code == req.Code && x.Id != entity.Id, "库位编码已存在");
entity.AreaId = req.AreaId;
entity.Code = req.Code.Trim();
entity.Name = req.Name.Trim();
@@ -199,7 +227,7 @@ public sealed class WmsService
entity.SiteCode = req.SiteCode.TrimOr(entity.SiteId);
entity.Barcode = req.Barcode.TrimOr("");
entity.Capacity = 1;
var status = StorageStatuses.Normalize(req.Status.TrimOr(StorageStatuses.Empty));
var status = StorageStatuses.ParseOr(req.Status);
if (status == StorageStatuses.Disabled || req.Enabled == false)
entity.Status = req.Enabled ? status : StorageStatuses.Disabled;
else if (!string.IsNullOrWhiteSpace(req.Status) && StorageStatuses.All.Contains(status))
@@ -211,7 +239,7 @@ public sealed class WmsService
entity.AllowOutbound = req.AllowOutbound;
entity.Enabled = req.Enabled;
ApplyCommon(entity, req, actor);
await _db.SaveChangesAsync();
await _uow.SaveChangesAsync();
if (entity.Status != StorageStatuses.Disabled)
await SyncOccupancyStatus(storageId: entity.Id);
return entity;
@@ -262,28 +290,28 @@ public sealed class WmsService
_db.Storages.Add(entity);
created++;
}
await _db.SaveChangesAsync();
await _uow.SaveChangesAsync();
return created;
}
public async Task<Storage> SetStorageLock(Guid id, bool isLock, long? version, string actor)
{
var entity = await FindEditable(_db.Storages, id, version);
var entity = await FindEditable<Storage>(id, version);
entity.IsLock = isLock;
entity.UpdatedBy = actor;
await _db.SaveChangesAsync();
await _uow.SaveChangesAsync();
return entity;
}
public async Task<Storage> SetStorageEnabled(Guid id, bool enabled, long? version, string actor)
{
var entity = await FindEditable(_db.Storages, id, version);
var entity = await FindEditable<Storage>(id, version);
entity.Enabled = enabled;
entity.Status = enabled
? (entity.Status == StorageStatuses.Disabled ? StorageStatuses.Empty : entity.Status)
: StorageStatuses.Disabled;
entity.UpdatedBy = actor;
await _db.SaveChangesAsync();
await _uow.SaveChangesAsync();
if (enabled) await SyncOccupancyStatus(storageId: id);
return entity;
}
@@ -291,27 +319,26 @@ public sealed class WmsService
public async Task<Container> SaveContainer(ContainerRequest req, string actor)
{
Container entity;
if (req.Id.HasValue) entity = await FindEditable(_db.Containers, req.Id.Value, req.Version);
if (req.Id.HasValue) entity = await FindEditable<Container>(req.Id.Value, req.Version);
else
{
entity = new Container();
StampCreate(entity, actor);
_db.Containers.Add(entity);
}
await EnsureUnique(_db.Containers, x => x.Code == req.Code && x.Id != entity.Id, "容器编码已存在");
await Repo<Container>().EnsureUniqueAsync(x => x.Code == req.Code && x.Id != entity.Id, "容器编码已存在");
entity.AreaId = req.AreaId;
entity.Code = req.Code.Trim();
entity.Name = req.Name.Trim();
entity.ContainerType = req.Type.TrimOr("Box");
var status = req.Status.TrimOr(ContainerStatuses.EmptyMaterial);
entity.Status = ContainerStatuses.All.Contains(status) ? status : ContainerStatuses.EmptyMaterial;
entity.Status = ContainerStatuses.ParseOr(req.Status);
entity.Barcode = req.Barcode.TrimOr("");
entity.Length = req.Length;
entity.Width = req.Width;
entity.Height = req.Height;
entity.Enabled = req.Enabled;
ApplyCommon(entity, req, actor);
await _db.SaveChangesAsync();
await _uow.SaveChangesAsync();
await SyncOccupancyStatus(containerId: entity.Id);
return entity;
}
@@ -319,14 +346,14 @@ public sealed class WmsService
public async Task<MaterialType> SaveMaterialType(MaterialTypeRequest req, string actor)
{
MaterialType entity;
if (req.Id.HasValue) entity = await FindEditable(_db.MaterialTypes, req.Id.Value, req.Version);
if (req.Id.HasValue) entity = await FindEditable<MaterialType>(req.Id.Value, req.Version);
else
{
entity = new MaterialType();
StampCreate(entity, actor);
_db.MaterialTypes.Add(entity);
}
await EnsureUnique(_db.MaterialTypes, x => x.Code == req.Code && x.Id != entity.Id, "物料类型编码已存在");
await Repo<MaterialType>().EnsureUniqueAsync(x => x.Code == req.Code && x.Id != entity.Id, "物料类型编码已存在");
entity.Code = req.Code.Trim();
entity.Name = req.Name.Trim();
entity.Spec = req.Spec.TrimOr("");
@@ -335,7 +362,7 @@ public sealed class WmsService
entity.BarcodePrefix = req.BarcodePrefix.TrimOr("");
entity.Enabled = req.Enabled;
ApplyCommon(entity, req, actor);
await _db.SaveChangesAsync();
await _uow.SaveChangesAsync();
return entity;
}
@@ -344,16 +371,16 @@ public sealed class WmsService
if (!string.IsNullOrWhiteSpace(req.TypeCode))
await _refs.EnsureMaterialTypeCodeAsync(req.TypeCode);
Material entity;
if (req.Id.HasValue) entity = await FindEditable(_db.Materials, req.Id.Value, req.Version);
if (req.Id.HasValue) entity = await FindEditable<Material>(req.Id.Value, req.Version);
else
{
entity = new Material();
StampCreate(entity, actor);
_db.Materials.Add(entity);
}
await EnsureUnique(_db.Materials, x => x.Code == req.Code && x.Id != entity.Id, "物料编码已存在");
await Repo<Material>().EnsureUniqueAsync(x => x.Code == req.Code && x.Id != entity.Id, "物料编码已存在");
if (!string.IsNullOrWhiteSpace(req.Barcode))
await EnsureUnique(_db.Materials, x => x.Barcode == req.Barcode && x.Id != entity.Id, "物料条码已存在");
await Repo<Material>().EnsureUniqueAsync(x => x.Barcode == req.Barcode && x.Id != entity.Id, "物料条码已存在");
entity.Code = req.Code.Trim();
entity.Name = req.Name.Trim();
entity.TypeCode = req.TypeCode.TrimOr("");
@@ -361,23 +388,22 @@ public sealed class WmsService
entity.Spec = req.Spec.TrimOr("");
entity.Unit = req.Unit.TrimOr("pcs");
entity.Category = req.Category.TrimOr("");
var life = req.LifecycleStatus.TrimOr(MaterialLifecycles.Active);
entity.LifecycleStatus = MaterialLifecycles.All.Contains(life) ? life : MaterialLifecycles.Active;
entity.LifecycleStatus = MaterialLifecycles.ParseOr(req.LifecycleStatus);
entity.Enabled = req.Enabled;
ApplyCommon(entity, req, actor);
await _db.SaveChangesAsync();
await _uow.SaveChangesAsync();
return entity;
}
public async Task<Material> ArchiveMaterial(Guid id, long? version, string actor)
{
var entity = await FindEditable(_db.Materials, id, version);
var entity = await FindEditable<Material>(id, version);
if (await _db.ContainerMaterials.AnyAsync(x => x.MaterialId == id))
throw new InvalidOperationException("物料仍在绑定中,不能归档");
entity.LifecycleStatus = MaterialLifecycles.Archived;
entity.UnboundAt ??= DateTimeOffset.UtcNow;
entity.UpdatedBy = actor;
await _db.SaveChangesAsync();
await _uow.SaveChangesAsync();
return entity;
}
@@ -396,13 +422,12 @@ public sealed class WmsService
entity.DeletedBy = actor;
entity.UpdatedBy = actor;
}
await _db.SaveChangesAsync();
await _uow.SaveChangesAsync();
}
public async Task DeleteEntity<T>(Guid id, long? version, string actor) where T : EntityBase
public async Task DeleteEntity<T>(Guid id, long? version, string actor)
where T : class, IEntity<Guid>, ISoftDeletable, IVersioned, ILockable, IAuditable
{
var set = _db.Set<T>();
var entity = await FindEditable(set, id, version);
if (typeof(T) == typeof(WarehouseArea))
{
if (await _db.Storages.AnyAsync(x => x.AreaId == id))
@@ -419,23 +444,24 @@ public sealed class WmsService
if (await _db.ContainerMaterials.AnyAsync(x => x.MaterialId == id))
throw new InvalidOperationException("物料仍在绑定中,不能删除");
}
entity.IsDeleted = true;
entity.DeletedAt = DateTimeOffset.UtcNow;
entity.DeletedBy = actor;
entity.UpdatedBy = actor;
await _db.SaveChangesAsync();
await Repo<T>().SoftDeleteAsync(id, version);
await _uow.SaveChangesAsync();
}
public async Task<ContainerLocation> BindOrTransferLocation(ContainerLocationRequest req, string actor)
{
await _refs.EnsureContainerAsync(req.ContainerId);
var (code, name) = await _refs.ResolveLocationSnapshotAsync(req.LocationType, req.LocationId);
if (!ContainerLocationStatuses.All.Contains(req.Status))
if (!ContainerLocationStatuses.IsDefined(req.Status))
throw new InvalidOperationException("容器位置状态无效");
var locationType = ContainerLocationTypes.ParseOr(req.LocationType);
var locationStatus = ContainerLocationStatuses.ParseOr(req.Status);
Guid? fromStorageId = null;
Guid? toStorageId = null;
if (string.Equals(req.LocationType, ContainerLocationTypes.Storage, StringComparison.OrdinalIgnoreCase) &&
if (locationType == ContainerLocationTypes.Storage &&
Guid.TryParse(req.LocationId, out var targetStorageId))
{
toStorageId = targetStorageId;
@@ -455,7 +481,9 @@ public sealed class WmsService
var current = await _db.ContainerLocations.FirstOrDefaultAsync(x => x.ContainerId == req.ContainerId);
var before = current == null ? null : Snapshot(current);
if (before != null && before.LocationType == ContainerLocationTypes.Storage && Guid.TryParse(before.LocationId, out var fs))
if (before != null &&
ContainerLocationTypes.EqualsString(ContainerLocationTypes.Storage, before.LocationType) &&
Guid.TryParse(before.LocationId, out var fs))
fromStorageId = fs;
var now = DateTimeOffset.UtcNow;
@@ -467,15 +495,15 @@ public sealed class WmsService
}
else
{
EnsureVersion(current, req.Version);
EnsureUnlocked(current);
await Repo<ContainerLocation>().GetEditableAsync(current.Id, req.Version);
}
current.LocationType = req.LocationType;
current.LocationType = locationType;
current.LocationId = req.LocationId.Trim();
current.StorageId = toStorageId;
current.LocationCode = code;
current.LocationName = name;
current.Status = req.Status;
current.Status = locationStatus;
current.EnteredAt = req.EnteredAt ?? now;
ApplyCommon(current, req, actor);
@@ -486,7 +514,7 @@ public sealed class WmsService
EventType = before == null ? "Bind" : "Transfer",
FromLocationType = before?.LocationType ?? "",
FromLocationId = before?.LocationId ?? "",
ToLocationType = current.LocationType,
ToLocationType = current.LocationType.ToString(),
ToLocationId = current.LocationId,
BeforeJson = before == null ? "{}" : JsonSerializer.Serialize(before, _json),
AfterJson = JsonSerializer.Serialize(Snapshot(current), _json),
@@ -499,14 +527,7 @@ public sealed class WmsService
});
await AddContainerMoveEventAsync(req.ContainerId, fromStorageId, toStorageId, actor, req.Reason.TrimOr(""), now);
try
{
await _db.SaveChangesAsync();
}
catch (DbUpdateException ex) when (IsUniqueConstraintViolation(ex))
{
throw new InvalidOperationException("目标库位已被其他容器占用,库位与容器为 1 对 1");
}
await _uow.SaveChangesAsync();
await SyncOccupancyStatus(containerId: req.ContainerId, storageId: fromStorageId);
await SyncOccupancyStatus(storageId: toStorageId);
return current;
@@ -516,7 +537,7 @@ public sealed class WmsService
{
var current = await _db.ContainerLocations.FirstOrDefaultAsync(x => x.ContainerId == containerId)
?? throw new InvalidOperationException("容器当前位置不存在");
EnsureUnlocked(current);
await Repo<ContainerLocation>().GetEditableAsync(current.Id, null);
Guid? fromStorageId = null;
if (current.LocationType == ContainerLocationTypes.Storage && Guid.TryParse(current.LocationId, out var fs))
fromStorageId = fs;
@@ -527,7 +548,7 @@ public sealed class WmsService
RelationId = current.Id,
ContainerId = current.ContainerId,
EventType = "Unbind",
FromLocationType = current.LocationType,
FromLocationType = current.LocationType.ToString(),
FromLocationId = current.LocationId,
BeforeJson = JsonSerializer.Serialize(before, _json),
AfterJson = "{}",
@@ -538,7 +559,7 @@ public sealed class WmsService
});
await AddContainerMoveEventAsync(containerId, fromStorageId, null, actor, reason, now);
_db.ContainerLocations.Remove(current);
await _db.SaveChangesAsync();
await _uow.SaveChangesAsync();
await SyncOccupancyStatus(containerId: containerId, storageId: fromStorageId);
}
@@ -585,20 +606,14 @@ public sealed class WmsService
material.UnboundAt = null;
await AddBindUnbindEventAsync(StockEventTypes.Bind, material, req.ContainerId, actor, req.Reason.TrimOr(""), now);
await _db.SaveChangesAsync();
await _uow.SaveChangesAsync();
await SyncOccupancyStatus(containerId: req.ContainerId);
return current;
}
/// <summary>兼容旧接口:忽略数量,按实体绑定。</summary>
public Task<ContainerMaterial> SaveContainerMaterial(ContainerMaterialRequest req, string actor) =>
BindMaterial(new BindMaterialRequest(req.Id, req.Version, req.ContainerId, req.MaterialId, req.Source, req.Reason, req.IsLock, req.Remark, req.Extend), actor);
public async Task UnbindMaterial(Guid bindingId, string actor, string reason = "", bool archive = false)
{
var current = await _db.ContainerMaterials.FirstOrDefaultAsync(x => x.Id == bindingId)
?? throw new InvalidOperationException("绑定不存在");
EnsureUnlocked(current);
var current = await Repo<ContainerMaterial>().GetEditableAsync(bindingId, null);
var material = await _db.Materials.FirstOrDefaultAsync(x => x.Id == current.MaterialId);
var before = Snapshot(current);
var now = DateTimeOffset.UtcNow;
@@ -627,7 +642,7 @@ public sealed class WmsService
}
_db.ContainerMaterials.Remove(current);
await _db.SaveChangesAsync();
await _uow.SaveChangesAsync();
await SyncOccupancyStatus(containerId: containerId);
}
@@ -673,7 +688,7 @@ public sealed class WmsService
}
}
await _db.SaveChangesAsync();
await _uow.SaveChangesAsync();
}
public async Task<Warehouse> EnsureDefaultWarehouseAsync(string actor = "system")
@@ -690,68 +705,10 @@ public sealed class WmsService
};
StampCreate(wh, actor);
_db.Warehouses.Add(wh);
await _db.SaveChangesAsync();
await _uow.SaveChangesAsync();
return wh;
}
public async Task MigrateLegacyAsync(string actor = "system")
{
var wh = await EnsureDefaultWarehouseAsync(actor);
var areas = await _db.WarehouseAreas.Where(x => x.WarehouseId == Guid.Empty).ToListAsync();
foreach (var a in areas)
{
a.WarehouseId = wh.Id;
if (string.IsNullOrWhiteSpace(a.LayoutMode)) a.LayoutMode = AreaLayoutModes.Flat;
}
var storages = await _db.Storages.ToListAsync();
foreach (var s in storages)
{
if (string.IsNullOrWhiteSpace(s.LocationKind))
s.LocationKind = LocationKinds.Station;
if (s.LevelNo <= 0) s.LevelNo = 1;
if (s.DepthNo <= 0) s.DepthNo = 1;
if (string.IsNullOrWhiteSpace(s.SiteCode)) s.SiteCode = s.SiteId;
s.Status = StorageStatuses.Normalize(s.Status) switch
{
StorageStatuses.Available or StorageStatuses.Idle => StorageStatuses.Empty,
StorageStatuses.Occupied => StorageStatuses.FullContainer,
var x => x
};
if (!s.Enabled) s.Status = StorageStatuses.Disabled;
}
var containers = await _db.Containers.ToListAsync();
foreach (var c in containers)
{
c.Status = c.Status switch
{
"Idle" or "Empty" => ContainerStatuses.EmptyMaterial,
"Loaded" => ContainerStatuses.FullMaterial,
_ when ContainerStatuses.All.Contains(c.Status) => c.Status,
_ => ContainerStatuses.EmptyMaterial
};
}
var materials = await _db.Materials.Where(x => string.IsNullOrWhiteSpace(x.LifecycleStatus)).ToListAsync();
foreach (var m in materials)
m.LifecycleStatus = MaterialLifecycles.Active;
var binds = await _db.ContainerMaterials.ToListAsync();
foreach (var b in binds)
{
b.Quantity = 1;
if (b.BoundAt == default) b.BoundAt = b.LoadedAt == default ? DateTimeOffset.UtcNow : b.LoadedAt;
if (!ContainerMaterialStatuses.ActiveBind.Contains(b.Status))
b.Status = ContainerMaterialStatuses.Bound;
}
await _db.SaveChangesAsync();
foreach (var s in storages.Where(x => x.Status != StorageStatuses.Disabled))
await SyncOccupancyStatus(storageId: s.Id);
}
public async Task<List<ContainerLocationHistory>> LocationHistory(Guid? containerId = null)
{
var rows = await (containerId.HasValue
@@ -780,13 +737,14 @@ public sealed class WmsService
if (exists) throw new InvalidOperationException("同库区网格坐标已存在");
}
private async Task AddBindUnbindEventAsync(string eventType, Material material, Guid containerId, string actor, string reason, DateTimeOffset now)
private async Task AddBindUnbindEventAsync(StockEventType eventType, Material material, Guid containerId, string actor, string reason, DateTimeOffset now)
{
var ctn = await _db.Containers.AsNoTracking().FirstOrDefaultAsync(x => x.Id == containerId);
var loc = await _db.ContainerLocations.AsNoTracking().FirstOrDefaultAsync(x => x.ContainerId == containerId);
Storage? storage = null;
WarehouseArea? area = null;
if (loc is { LocationType: ContainerLocationTypes.Storage } && Guid.TryParse(loc.LocationId, out var sid))
if (loc is { LocationType: ContainerLocationType.Storage } &&
(loc.StorageId is { } sid || Guid.TryParse(loc.LocationId, out sid)))
{
storage = await _db.Storages.AsNoTracking().FirstOrDefaultAsync(x => x.Id == sid);
if (storage != null)
@@ -850,19 +808,13 @@ public sealed class WmsService
});
}
private async Task<T> FindEditable<T>(DbSet<T> set, Guid id, long? version) where T : EntityBase
{
var entity = await set.FirstOrDefaultAsync(x => x.Id == id) ?? throw new InvalidOperationException("数据不存在");
EnsureVersion(entity, version);
EnsureUnlocked(entity);
return entity;
}
private IEditableRepository<T> Repo<T>()
where T : class, IEntity<Guid>, ISoftDeletable, IVersioned, ILockable
=> _services.GetRequiredService<IEditableRepository<T>>();
private static void EnsureVersion(EntityBase entity, long? version)
{
if (version.HasValue && entity.Version != version.Value)
throw new InvalidOperationException("数据已被其他用户修改,请刷新后重试");
}
private Task<T> FindEditable<T>(Guid id, long? version)
where T : class, IEntity<Guid>, ISoftDeletable, IVersioned, ILockable
=> Repo<T>().GetEditableAsync(id, version);
private static bool IsUniqueConstraintViolation(DbUpdateException ex)
{
@@ -878,18 +830,13 @@ public sealed class WmsService
return false;
}
private static void EnsureUnlocked(EntityBase entity)
{
if (entity.IsLock) throw new InvalidOperationException("数据已锁定,不能修改");
}
private static void StampCreate(EntityBase entity, string actor)
private static void StampCreate(AggregateRoot entity, string actor)
{
entity.CreatedBy = actor;
entity.UpdatedBy = actor;
}
private void ApplyCommon(EntityBase entity, CommonRequest req, string actor)
private void ApplyCommon(AggregateRoot entity, CommonRequest req, string actor)
{
entity.IsLock = req.IsLock;
entity.Remark = req.Remark.TrimOr("");
@@ -897,11 +844,6 @@ public sealed class WmsService
entity.UpdatedBy = actor;
}
private static async Task EnsureUnique<T>(IQueryable<T> query, System.Linq.Expressions.Expression<Func<T, bool>> predicate, string message)
{
if (await query.AnyAsync(predicate)) throw new InvalidOperationException(message);
}
private string NormalizeExtend(string? extend)
{
if (string.IsNullOrWhiteSpace(extend)) return "{}";
@@ -930,73 +872,14 @@ public sealed class WmsService
}
private static ContainerLocationSnapshot Snapshot(ContainerLocation x) => new(
x.Id, x.ContainerId, x.LocationType, x.LocationId, x.LocationCode, x.LocationName, x.Status, x.EnteredAt, x.Version);
x.Id, x.ContainerId, x.LocationType.ToString(), x.LocationId, x.LocationCode, x.LocationName,
x.Status.ToString(), x.EnteredAt, x.Version);
private static ContainerMaterialSnapshot Snapshot(ContainerMaterial x) => new(
x.Id, x.ContainerId, x.MaterialId, x.Quantity, x.BatchNo, x.SerialNo, x.Status, x.BoundAt, x.LoadedAt, x.UnloadedAt, x.Version);
x.Id, x.ContainerId, x.MaterialId, x.Quantity, x.BatchNo, x.SerialNo, x.Status.ToString(),
x.BoundAt, x.LoadedAt, x.UnloadedAt, x.Version);
}
public sealed record InventoryMaterialRow(
Guid MaterialId, string MaterialCode, string MaterialName, string MaterialBarcode, string TypeCode,
Guid ContainerId, string ContainerCode, string ContainerName,
Guid? StorageId, string StorageCode, string StorageName,
Guid? AreaId, string AreaCode, string AreaName,
string LocationType, DateTimeOffset BoundAt);
public sealed record ContainerLocationSnapshot(
Guid Id, Guid ContainerId, string LocationType, string LocationId, string LocationCode, string LocationName,
string Status, DateTimeOffset EnteredAt, long Version);
public sealed record ContainerMaterialSnapshot(
Guid Id, Guid ContainerId, Guid MaterialId, decimal Quantity, string BatchNo, string SerialNo, string Status,
DateTimeOffset BoundAt, DateTimeOffset LoadedAt, DateTimeOffset? UnloadedAt, long Version);
public abstract record CommonRequest(Guid? Id, long? Version, bool IsLock, string Remark, string Extend);
public sealed record MasterDataRequest(
Guid? Id, long? Version, string Code, string Name, string Type, string Status, bool Enabled, int SortOrder,
bool IsLock, string Remark, string Extend) : CommonRequest(Id, Version, IsLock, Remark, Extend);
public sealed record AreaRequest(
Guid? Id, long? Version, Guid? WarehouseId, string Code, string Name, string Type, string LayoutMode, string State,
bool Enabled, int SortOrder, bool IsLock, string Remark, string Extend) : CommonRequest(Id, Version, IsLock, Remark, Extend);
public sealed record StorageRequest(
Guid? Id, long? Version, Guid AreaId, string Code, string Name, string StorageType, string LocationKind,
int ColumnNo, int LevelNo, int DepthNo, string SiteId, string SiteCode, string Barcode, int Capacity,
string Status, string Usage, int Priority, string ZoneCode, bool AllowInbound, bool AllowOutbound, bool Enabled,
bool IsLock, string Remark, string Extend) : CommonRequest(Id, Version, IsLock, Remark, Extend);
public sealed record GenerateBinsRequest(int ColumnFrom, int ColumnTo, int LevelFrom, int LevelTo, int DepthFrom, int DepthTo, string? CodePattern);
public sealed record ContainerRequest(
Guid? Id, long? Version, Guid? AreaId, string Code, string Name, string Type, string Status, string Barcode,
double Length, double Width, double Height, bool Enabled, bool IsLock, string Remark, string Extend)
: CommonRequest(Id, Version, IsLock, Remark, Extend);
public sealed record MaterialTypeRequest(
Guid? Id, long? Version, string Code, string Name, string Spec, string Unit, string Category, string BarcodePrefix,
bool Enabled, bool IsLock, string Remark, string Extend) : CommonRequest(Id, Version, IsLock, Remark, Extend);
public sealed record MaterialRequest(
Guid? Id, long? Version, string Code, string Name, string TypeCode, string Barcode, string Spec, string Unit,
string Category, string LifecycleStatus, bool Enabled, bool IsLock, string Remark, string Extend)
: CommonRequest(Id, Version, IsLock, Remark, Extend);
public sealed record ContainerLocationRequest(
Guid? Id, long? Version, Guid ContainerId, string LocationType, string LocationId, string Status,
DateTimeOffset? EnteredAt, string Source, string Reason, bool IsLock, string Remark, string Extend)
: CommonRequest(Id, Version, IsLock, Remark, Extend);
public sealed record BindMaterialRequest(
Guid? Id, long? Version, Guid ContainerId, Guid MaterialId, string Source, string Reason,
bool IsLock, string Remark, string Extend) : CommonRequest(Id, Version, IsLock, Remark, Extend);
public sealed record ContainerMaterialRequest(
Guid? Id, long? Version, Guid ContainerId, Guid MaterialId, decimal Quantity, string BatchNo, string SerialNo,
string Status, DateTimeOffset? LoadedAt, DateTimeOffset? UnloadedAt, string Source, string Reason,
bool IsLock, string Remark, string Extend) : CommonRequest(Id, Version, IsLock, Remark, Extend);
public static class WmsStringExtensions
{
public static string TrimOr(this string? value, string fallback) =>

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