Menu 的持久化核心只有一张全局表 SysModule。理解它的全局性、兼容字段和种子写回边界,是安全扩展菜单的前提。
1. 物理模型
ParentId 只由应用约定表达自关联;源码没有数据库外键或级联约束。
2. 字段语义
| 字段 | 当前语义 | 约束 |
|---|---|---|
| Id | 持久化标识 | EntityBase 提供 |
| ParentId | 父行 Id;null/0 代表根 | 长度 36,普通索引 |
| Code | Authorization 关系共享的模块码 | 长度 50,唯一索引,可空 |
| Name | 管理与导航显示名 | 长度 50,可空 |
| LinkUrl | 前端或 API 相对入口 | 长度 100,可空 |
| Area/Controller/Action | 旧 MVC 发现兼容字段 | 各 2000,可空 |
| Icon | 图标代码 | 长度 100,可空 |
| OrderSort | 同级显示顺序 | 必填整数 |
| Description | 管理说明 | 长度 100,可空 |
| IsMenu | 是否进入 navigation 投影 | 必填布尔 |
| Enabled | 是否进入所有树查询 | 必填布尔 |
| Scope | Web=0、Host=1 的遗留平台范围 | 必填整数 |
Scope 当前只存储和透传,MenuTreeBuilder 没有按登录平台过滤它。
3. 编译期 ORM 元数据
// ① 这是全局目录:没有 IsTenant 元数据,也不继承 BizEntityBase。[BitzTable("SysModule", Description = "API/控制器/动作注册表")][BitzIndex("UX_SysModule_Code", "Code", IsUnique = true)][BitzIndex("IX_SysModule_ParentId", "ParentId")]public sealed class MenuModuleCatalogRecord : EntityBase{ // ② 父子关系只存 Id;数据库没有外键约束。 [BitzColumn(Length = 36)] public string? ParentId { get; set; }
// ③ Code 与 Authorization 关系共享,应视为稳定业务键。 [BitzColumn(Length = 50)] public string? Code { get; set; }}Generator 在编译期输出模型清单,SqlSugar 与 EF Core adapter 消费同一元数据;Menu Infrastructure 不引用具体 ORM 包。
4. Store 端口
IMenuStore 暴露三组能力:读取启用目录/详情/直接子节点、读取可见模块码,以及新增/更新/软删。它不暴露 IQueryable,也不让 Menu 直接消费 Authorization 的关系行。
5. 查询行为
GetAllEnabledAsync 过滤 !IsDeleted && Enabled,再在内存按 OrderSort 排序。GetChildrenAsync 不要求 Enabled,删除用例因此仍能递归软删已禁用子节点。
// ① 不从业务代码注入具体 DbContext 或 SqlSugarClient。var enabled = await menuStore.GetAllEnabledAsync(cancellationToken);
// ② 目录是全局的;租户可见性在另一阶段用模块码过滤。var visibleCodes = await menuStore.GetVisibleModuleCodesAsync( currentUser.User.Roles, cancellationToken);
// ③ Code 是跨 owner 连接键,Id 只用于目录父子关系。var visible = enabled.Where(row => visibleCodes.Contains(row.Code!));6. 更新与删除语义
Update 使用 UpdateWhereAsync 一次写入 ParentId、Name、Code、LinkUrl、Icon、OrderSort、IsMenu、Enabled 与 Scope。它不更新旧 MVC 字段和 Description。
Delete 不是物理删除,只把 IsDeleted=true。两个方法都没有检查影响行数,因此“读后被并发删除”的更新可能仍返回成功。
7. Seed step
MenuModuleSeedStep:
- Order:220;
- SeedId:
sys_module; - CSV:
220-sys_module.csv; - WhereColumns:Code;
- Match:
row.Code == source.Code; - 基类:ORM 中立
EntitySetCsvSeedStepBase<T>。
// ① Code 同时是数据库唯一键和 seed 自然键。protected override string[] WhereColumns => [nameof(MenuModuleCatalogRecord.Code)];
// ② 适配器必须能翻译该表达式。protected override Expression<Func<MenuModuleCatalogRecord, bool>> Match( MenuModuleCatalogRecord source) => row => row.Code == source.Code;
// ③ Copy 不修改 Code,避免导入时更换跨 owner 身份。protected override void Copy(MenuModuleCatalogRecord source, MenuModuleCatalogRecord target) => target.Name = source.Name;最后一段是教学化裁剪,完整 Copy 还更新父级、路由、图标、排序、说明、状态、Scope 与软删标记。
8. 当前九行资产
| 顺序 | Code | LinkUrl |
|---|---|---|
| 0 | operations | /api/operations |
| 1 | platformbilling | /api/platform-billing |
| 2 | files | /api/files |
| 3 | notifications | /api/notifications |
| 4 | webhooks | /api/webhooks |
| 5 | catalog | /api/catalog |
| 6 | tickets | /api/tickets |
| 7 | chat | /api/chat |
| 8 | sandbox | /api/notes |
它们都是根节点、IsMenu=true、Enabled=true、Scope=0。资产不代表这些链接一定有同名页面,也不证明对应 API 已具备相同权限。
9. 修改 Code 的风险
Code 被 Authorization 的角色—模块—权限关系当作 ModuleId 使用。直接改 Code 会让既有授权关系找不到目录项;seed 还会把新 Code 当成新行,而不是重命名旧行。
商业迁移必须同时处理目录唯一键、授权关系、缓存失效、回滚与兼容别名。单独改 CSV 不构成安全迁移。
10. ParentId 完整性
当前没有外键、父存在校验、环校验或最大深度。可以写入孤儿、自引用与 A→B→A。孤儿不会从根树输出;环可能导致递归溢出。
计划中的修复应在应用校验和数据库约束能力之间分层:父存在/自引用/祖先环在事务内校验,读侧仍保留深度和 visited 防御。
11. 测试证据与缺口
架构测试固定 Menu 程序集 ORM 中立、查询 Handler 只能走 Store、fail-closed 默认端口、owner-local 模型与生成清单。集成 parity 覆盖双 ORM 的新增、读取、可见码与软删。
当前没有专门固定 Code 重命名、父环、孤儿、唯一冲突、影响行数或 seed 与授权关系协同迁移的测试。
12. 核查命令
# 模型、索引与 Store 更新字段。rg -n "BitzTable|BitzIndex|UpdateWhereAsync" src/Platform/Menu -g '*.cs'
# 资产应为表头加九行。wc -l src/Platform/Menu/*Infrastructure/Seeders/Assets/220-sys_module.csv
# 模块不应引用具体 ORM adapter。rg -n "SqlSugar|EntityFrameworkCore" src/Platform/Menu -g '*.cs' -g '*.csproj'