Menu persistence centers on one global table, SysModule. Its global scope, compatibility fields, and seed write-back rules must be understood before extending it safely.
1. Physical model
ParentId expresses the self-reference only by application convention. There is no database foreign key or cascade declaration in this module.
2. Field semantics
| Field | Current meaning | Constraint |
|---|---|---|
| Id | persistence identifier | inherited from EntityBase |
| ParentId | parent row Id; null/0 is root | length 36, normal index |
| Code | module code shared with Authorization | length 50, unique, nullable |
| Name | management/navigation label | length 50, nullable |
| LinkUrl | client or API relative entry | length 100, nullable |
| Area/Controller/Action | legacy MVC discovery fields | 2000 each, nullable |
| Icon | icon code | length 100, nullable |
| OrderSort | sibling display order | required integer |
| Description | management description | length 100, nullable |
| IsMenu | included in navigation projection | required Boolean |
| Enabled | included in any tree query | required Boolean |
| Scope | legacy Web=0, Host=1 platform scope | required integer |
Scope is currently stored and returned but is not used by MenuTreeBuilder to filter a login platform.
3. Compile-time ORM metadata
// ① This is a global catalog: no IsTenant metadata and no BizEntityBase.[BitzTable("SysModule", Description = "API/控制器/动作注册表")][BitzIndex("UX_SysModule_Code", "Code", IsUnique = true)][BitzIndex("IX_SysModule_ParentId", "ParentId")]public sealed class MenuModuleCatalogRecord : EntityBase{ // ② Parent relationships are Id values without a database FK. [BitzColumn(Length = 36)] public string? ParentId { get; set; }
// ③ Authorization shares Code, so treat it as a stable business key. [BitzColumn(Length = 50)] public string? Code { get; set; }}The generator emits the model manifest at compile time. SqlSugar and EF Core adapters consume the same metadata; Menu Infrastructure references neither concrete ORM package.
4. Store port
IMenuStore groups enabled/detail/child reads, visible-module-code reads, and insert/update/soft-delete writes. It does not expose IQueryable or let Menu consume Authorization persistence rows.
5. Query behavior
GetAllEnabledAsync applies !IsDeleted && Enabled and then orders in memory by OrderSort. GetChildrenAsync does not require Enabled, so deletion can still find disabled descendants.
// ① Do not inject a concrete DbContext or SqlSugarClient into business code.var enabled = await menuStore.GetAllEnabledAsync(cancellationToken);
// ② The catalog is global; tenant visibility is filtered in another stage.var visibleCodes = await menuStore.GetVisibleModuleCodesAsync( currentUser.User.Roles, cancellationToken);
// ③ Code joins owners; Id serves only catalog parent relationships.var visible = enabled.Where(row => visibleCodes.Contains(row.Code!));6. Update and delete semantics
Update uses one UpdateWhereAsync to write ParentId, Name, Code, LinkUrl, Icon, OrderSort, IsMenu, Enabled, and Scope. It does not update legacy MVC fields or Description.
Delete is a soft delete that sets IsDeleted=true. Neither method checks affected rows, so an update after a concurrent delete can still appear successful.
7. Seed step
MenuModuleSeedStep declares:
- Order 220;
- SeedId
sys_module; - CSV
220-sys_module.csv; - WhereColumns Code;
- match predicate
row.Code == source.Code; - ORM-neutral
EntitySetCsvSeedStepBase<T>.
// ① Code is both the unique database key and seed natural key.protected override string[] WhereColumns => [nameof(MenuModuleCatalogRecord.Code)];
// ② Every supported adapter must translate this predicate.protected override Expression<Func<MenuModuleCatalogRecord, bool>> Match( MenuModuleCatalogRecord source) => row => row.Code == source.Code;
// ③ Copy does not change Code, preserving the cross-owner identity.protected override void Copy(MenuModuleCatalogRecord source, MenuModuleCatalogRecord target) => target.Name = source.Name;The final snippet is a focused teaching extract; the real Copy also updates parent, routes, icon, order, description, flags, Scope, and soft deletion.
8. Nine shipped rows
| Order | 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 |
Every row is a root, menu-enabled, enabled, and Scope=0. The asset does not prove that every link has a same-named page or a matching target permission.
9. Changing Code
Authorization role-module-permission relations use Code as ModuleId. Renaming it can strand existing grants; the seeder also treats a new Code as a new row, not a rename.
A production migration must coordinate catalog uniqueness, authorization relations, cache invalidation, rollback, and compatibility aliases. Editing only the CSV is not a safe migration.
10. ParentId integrity
There is no foreign key, parent-existence check, cycle check, or depth limit. An orphan, self-parent, or A→B→A cycle can be stored. Orphans disappear from root projections; a reachable cycle can overflow recursion.
Remediation belongs in layers: transactional parent/self/ancestor validation on writes, plus defensive visited/depth checks on reads.
11. Test evidence and gaps
Architecture tests fix ORM neutrality, Store-only query handlers, fail-closed defaults, owner-local metadata, and generated manifests. Integration parity covers insert, read, visible codes, and soft deletion on both ORMs.
No focused test currently fixes Code rename, parent cycles, orphans, unique conflicts, affected-row handling, or coordinated seed/Authorization migrations.
12. Inspection commands
# Model, indexes, and Store update fields.rg -n "BitzTable|BitzIndex|UpdateWhereAsync" src/Platform/Menu -g '*.cs'
# Expect one header plus nine rows.wc -l src/Platform/Menu/*Infrastructure/Seeders/Assets/220-sys_module.csv
# Concrete ORM dependencies should be absent.rg -n "SqlSugar|EntityFrameworkCore" src/Platform/Menu -g '*.cs' -g '*.csproj'