Skip to content
bitzorcas
中EN

Tutorial

Menu 管理用例、排序与缓存一致性

深入创建、更新、删除、排序、启停五条写链路,类型化错误、缓存失效顺序、多实例同步和一致性缺口。

Last updated

五个写用例都遵循“读取/校验 → Store 写入 → 本地 tag 失效 → 发送同步事件”的基本结构,但业务校验与原子性仍很有限。

1. 写链路总览

Sync notifierLocal cacheMenuStoreCommand handlerAuthorization pipelineGenerated endpointSync notifierLocal cacheMenuStoreCommand handlerAuthorization pipelineGenerated endpointauthenticated commandmenus.menu create/update/deleteauthorizedinsert/update/soft-deleteRemoveByTag("menus")Cache / menus / tenant 0 / Updated

所有写端点默认认证,且命令实现 IAuthorizedRequest。

2. CreateMenu

创建只验证 Name 和 Code 非空白;空白 ParentId 归一化为 null。它不验证父级、URL、Scope、排序范围、唯一 Code 或图标格式。

创建全局菜单
// ① Code 是跨 Menu 与 Authorization 的稳定业务键。
var command = new CreateMenu.Command(
ParentId: null,
Name: "订单中心",
Code: "orders",
LinkUrl: "/orders",
Icon: "shopping-cart",
OrderSort: 20,
IsMenu: true,
Enabled: true,
Scope: 0);
// ② 调用者需要 menus.menu.create;数据库唯一索引是最终冲突防线。
var result = await mediator.Send(command, cancellationToken);

唯一索引异常如何映射取决于统一异常管线;Handler 自己不会返回类型化 duplicate 错误。

3. UpdateMenu

更新先按 Id 读取,未找到返回 Menu.NotFound。随后覆盖九个管理字段。它没有重复执行创建时的 Name/Code 校验,也没有保护 Code 身份。

因此更新可写空 Name/Code,或把 ParentId 改成自身/后代。商业管理 API 应使用统一 validator,并把重父级视为独立、事务化操作。

4. SortMenu

Sort 仅修改目标行的 OrderSort:

当前排序语义
public static class MenuErrors
{
public static readonly Error NotFound =
Error.NotFound("Menu.NotFound", "菜单不存在");
}
// ① 读取不存在时返回 Menu.NotFound。
var entity = await menuStore.GetByIdAsync(request.Id, cancellationToken);
if (entity is null)
return Result.Failure(MenuErrors.NotFound);
// ② 不移动兄弟节点,也不阻止相同排序值。
entity.OrderSort = request.OrderSort;
await menuStore.UpdateAsync(entity, cancellationToken);
// ③ 写成功后才失效所有菜单投影缓存。
await CreateMenu.InvalidateCacheAsync(cacheStore, syncNotifier, cancellationToken);

相同 OrderSort 的稳定次序没有第二排序键保证;不同数据库返回基础顺序也可能不同。

5. ToggleMenu

Toggle 只覆盖一个节点的 Enabled。读取侧会过滤禁用节点,因此禁用父节点会隐藏整条可达路径,但不会修改子节点数据。

如果业务期望“禁用目录即禁用所有子模块”,当前实现不满足;如果期望临时隐藏父级但保留子状态,则恢复时行为是可预测的。

6. DeleteMenu

删除先确认根节点存在,再深度优先递归查直接子节点并逐行软删,最后删除根节点。

目标节点

查询直接子节点

递归删除每个子树

软删当前节点

全部完成后失效缓存

没有 Unit of Work/事务包围整棵树;中途失败会留下部分软删状态,也不会执行缓存失效。环会让递归无法正常终止。

7. 类型化失败

明确的应用错误只有:

错误触发位置
Menu.InvalidInputCreate 的 Name/Code 空白
Menu.NotFounddetail/update/delete/sort/toggle 找不到 Id

父级不存在、重复 Code、环、并发覆盖、授权关系占用等没有专用错误码。

8. 缓存失效顺序

共享 helper 先执行 RemoveByTagAsync("menus"),再通知:

当前跨实例失效事件
// ① 先清当前实例的 tree/flat/navigation 缓存。
await cacheStore.RemoveByTagAsync(MenuCacheTags.Menus, cancellationToken);
// ② 再广播 Cache 类型、menus 资源、全局 tenant "0"。
await syncNotifier.NotifyAsync(
LocalResourceTypes.Cache,
"menus",
"0",
LocalResourceAction.Updated,
DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(),
cancellationToken);

数据库写入已经完成时,缓存或通知失败会让请求失败,但不会自动回滚数据库。

9. 同步消费者

MenuCacheSyncConsumer.ResourceType 是通用 Cache。其 Handler 不检查 event.ResourceId == "menus",收到任何路由给它的 Cache 事件都会清 Menu tag。

消费者对非取消异常记录 error 后抑制,因此消息可能被视为消费成功而缓存仍旧。当前没有失败重试或 dead-letter 证据。

10. 一致性窗口

failure gapfailure gapdelivery gap

DB commit

local invalidation

broadcast

remote invalidation

15 分钟 TTL 是最终兜底,不是强一致保证。商业 GA 应考虑 outbox、版本化目录快照、重试/死信和可观测的缓存陈旧指标。

11. 并发语义

Store 的 update/delete 谓词只有 Id,不检查版本或原值。两个管理员同时更新时后写覆盖先写;Sort/Toggle 也没有 compare-and-swap。

EntityBase 可能带审计字段,但当前 UpdateWhere builder 没有显式并发版本条件。文档不能声称已有乐观并发保护。

12. 生产管理建议

在当前能力上构建后台时:限制全局管理员权限;提交前显示影响租户;Code 默认只读;重父级先做本地环检测;删除前导出子树和授权引用;操作后同时核对数据库与 navigation;失败时不要盲目重放非幂等创建。

长期应把创建、重命名、移动、排序、启停、删除分别建模,形成明确的校验、审计与回滚语义。

13. 测试清单

  • Create 空值与唯一冲突;
  • Update 空 Code、自父级、后代父级;
  • Sort 相同值、并发写、确定性次排序;
  • Toggle 父子投影;
  • Delete 部分失败、深树、环、授权引用;
  • 本地失效失败和广播失败;
  • 远端重复/乱序/丢失事件;
  • 15 分钟 TTL 兜底。

当前自动化没有覆盖这些命令与缓存同步语义。

14. 核查命令

Terminal window
# 五条写链路及共同失效 helper。
rg -n "InvalidateCacheAsync|InsertAsync|UpdateAsync|DeleteDescendantsAsync" src/Platform/Menu -g '*.cs'
# 检查事务、outbox、并发版本;当前预期无 Menu 命中。
rg -n "UnitOfWork|Transaction|Outbox|Concurrency|RowVersion" src/Platform/Menu -g '*.cs'

模块总览 · 可见性与导航

100%

滚轮或按钮缩放 · 放大后拖动画面 · 双击切换 100% / 200%