五个写用例都遵循“读取/校验 → Store 写入 → 本地 tag 失效 → 发送同步事件”的基本结构,但业务校验与原子性仍很有限。
1. 写链路总览
所有写端点默认认证,且命令实现 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.InvalidInput | Create 的 Name/Code 空白 |
Menu.NotFound | detail/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. 一致性窗口
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. 核查命令
# 五条写链路及共同失效 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'