Menu 的读取不是“查表后原样返回”,而是可见码解析、祖先补齐、投影与缓存四个阶段。每一阶段都有不同的安全和数据完整性语义。
1. 可见性决策
admin 按角色名、不区分大小写短路。它不是 permission code,也不核对 Authorization 中的模块关系。
2. 角色名到模块码
非管理员把当前用户角色名交给 IMenuStore.GetVisibleModuleCodesAsync,store 再调用 IAuthorizationAssignmentReader.GetGrantedModuleCodesAsync。关系行由 Authorization owner 管理,Menu 不直接读取其 persistence record。
// ① 角色名来自已认证的 ICurrentUser,不接受查询参数中的任意角色。var roles = currentUser.User.Roles;if (roles.Contains("admin", StringComparer.OrdinalIgnoreCase)) return null; // null 是“全部”,不是故障。
// ② 无角色明确返回空集合。if (roles.Count == 0) return Array.Empty<string>();
// ③ 其他情况只调用 Authorization owner 的窄读端口。return await menuStore.GetVisibleModuleCodesAsync(roles, cancellationToken);3. 祖先补齐
如果用户只获准 orders.detail,父导航仍需出现才能承载子节点。Builder 对每个可见 Code 找到行,再沿 ParentId 向上,把所有祖先 Id 加入结果。
追溯用 resultIds.Add 遇到重复 Id 即停止,因此祖先追溯本身对重复路径有一定终止性;后面的递归建树却没有 visited 集合。
4. Code 查找边界
Builder 对非空 Code 调用 ToDictionary。数据库唯一索引通常阻止重复 Code,但历史脏数据、大小写排序规则差异或 adapter 语义仍可能导致异常。
可见码在目录中不存在时会被静默忽略;不会返回配置错误或诊断详情。这对删除旧模块有容错价值,也可能掩盖授权关系漂移。
5. 三种投影
| 方法 | 输出 | 过滤 |
|---|---|---|
| GetFlatAsync | MenuListItem[] | Enabled + 可见/祖先;保留非菜单行 |
| GetTreeAsync | MenuNode[] | 同上;递归 Children |
| GetNavigationAsync | NavigationItem[] | 同上,再只保留 IsMenu=true |
Flat 不含 Description、旧 MVC 字段、IsMenu 和 Scope。Tree 包含 Code、IsMenu、Enabled;Navigation 不含 Code、OrderSort、Scope 或 Enabled。
6. 树构建规则
根键使用 null/0 的归一化:加载时 ParentId ?? "0",入口调用 BuildChildren(null)。子节点按 OrderSort 升序。
// ① 服务端已经完成可见性过滤和祖先补齐。var tree = await menuTreeBuilder.GetTreeAsync(currentUser, cancellationToken);
// ② 客户端不要根据 Name 反推权限;Code 才是稳定模块键。foreach (var node in tree) RenderNode(node.Id, node.Name, node.LinkUrl, node.Children);
// ③ 点击目标链接后,目标端点再次独立授权。// 菜单响应不能充当 capability token。7. 非菜单父节点陷阱
Navigation 在建 lookup 前先过滤 IsMenu=true。如果一个可见子节点是菜单,但父节点 IsMenu=false,子节点仍保留原 ParentId,却没有根路径,因此不会出现在最终导航。
Tree 与 Flat 仍能看到该行。这会造成三个读取 API 结果不一致,配置工具应在写入时阻止或明确支持这种结构。
8. 禁用父节点
store 只加载 Enabled 行。禁用父节点但不禁用子节点时,子节点可能成为孤儿;它不会从根树出现。Toggle 只修改目标行,并不级联子孙的 Enabled。
恢复父节点后,仍启用的子节点会重新出现。这是“投影隐藏”,不是状态级联。
9. 环与孤儿
BuildChildren 递归没有 visited 或最大深度。根可达的环会无限递归并可能触发 StackOverflow;完全脱离根的环则不会输出。当前写入又允许创建这些数据。
商业 GA 前应同时补:写侧事务内环检测、读侧 visited/depth 防御、启动完整性扫描、运维修复命令和异常指标。
10. 缓存键
三类缓存区域分别是 menu-tree、menu-flat、menu-nav,scope 为 User。segment:
- admin 固定为
admin; - 其他用户对排序后的可见 Code 使用
System.HashCode,形成visxxxxxxxx; - policy 是
CachePolicy.Medium(),当前 TTL 15 分钟; - 所有项带
menusarea tag。
可见集合变化会形成新键,写操作广播会清理旧 tag。System.HashCode 不承诺跨进程持久稳定,但当前 key 只服务本地缓存生命周期。
11. 认证与资源授权
Navigation 需要认证,因为生成属性默认要求授权;它不实现 IAuthorizedRequest,因此不要求 Menu 管理查看权限。tree/flat/detail 同时需要认证与 view 资源权限。
12. 建议的边界测试
至少固定:admin 大小写短路、无角色空结果、授权叶节点补父级、未知 Code、非菜单父节点、禁用父节点、孤儿、自引用、两节点环、相同排序、缓存集合变化和目标端点独立拒绝。
当前自动化只直接覆盖 detail handler 的命中/未找到;上述树策略尚无专门单元测试。
13. 核查命令
# 阅读完整可见性与树算法。sed -n '1,280p' src/Platform/Menu/*Application/MenuTreeBuilder.cssed -n '1,180p' src/Platform/Menu/*Application/MenuVisibilityResolver.cs
# 确认 navigation 端点仍由生成器 RequireAuthorization。rg -n "RequireAuthorization.*true|Append\(\"\.RequireAuthorization" src/Framework/BitzOrcas.Endpoint* -g '*.cs'