Skip to content
bitzorcas
中EN

Reference

DocumentStructure 模板节点图、版本与持久化

深入解释模板图校验、临时节点 ID 重映射、租户约束、当前节点覆盖写、历史 JSON 快照、版本号算法和并发边界。

Last updated

文件夹模板由根记录、当前节点集合和历史版本快照组成。它是显式多表聚合,而不是一个完整领域聚合镜像;Store 负责可信租户传播、主键重映射和覆盖写。

1. 三表模型

TemplateIdTemplateId

DocsFolderTemplate
Name · TargetType · CurrentVersion · IsActive

DocsTemplateNode
current nodes · ParentNodeId · SortOrder

DocsTemplateVersion
old version · NodesSnapshotJson · description

根和节点支持软删除元数据;删除模板时 Store 物理删除当前节点并把根标记 IsDeleted。历史版本不会随根删除而物理删除。没有公开 API 读取历史,因此当前快照主要是持久化证据,不是可用的版本产品。

2. 图不变量

ValidateNodeGraph 对 Create/Update 共用,逐节点检查:

  • Id 不得空白;
  • 同一请求 Id 唯一,比较为 Ordinal;
  • 非空 ParentNodeId 必须指向本次请求节点;
  • 从每个节点向上遍历不得形成环;
  • 包含自身在内的深度不得超过 MaxNodeDepth=10。

它没有校验 Name、DynamicNameExpression、Icon、Color、Description 的长度或格式,也没有限制总节点数、同级重名、SortOrder 重复和 TargetType allowlist。数据库列长度可能把过长输入变成持久化异常。

构造合法的三层节点图
var nodes = new TemplateNodeDefinition[]
{
// 请求 ID 只在本次图中稳定,不能被外部长期保存。
new("root", null, "根目录", null, null, null, null, 10),
new("phase", "root", "阶段", null, null, null, null, 20),
new("deliverable", "phase", "交付物", null, null, null, null, 30)
};
var result = await mediator.Send(new CreateFolderTemplate.Command(
"项目目录", null, "KnowledgeBase", nodes), cancellationToken);
// 成功响应里的节点 ID 已全部换成持久化 ID。
return result.Value!.Nodes;

3. 临时 ID 重映射

Store 为每个 NewTemplateNodeRecord.Id 预生成最终 ID,构造映射字典,再把 ParentNodeId 转成父节点最终 ID,最后 AddRange。这样父子顺序不必依赖数据库生成 ID,两个 ORM 可以共享同一行为。

若父 ID 未出现在映射中,ResolveParentId 应抛出存储错误;正常 Handler 会在写入前阻止。Store 自身再次检查 ID 非空/唯一,形成持久化边界防御。

4. 可信租户

读方法同时要求传入 TenantId 与 ICurrentTenant.Tenant.EffectiveTenantId 相等。写方法忽略不可信外部租户并用 EffectiveTenantId;更新还调用 EnsureCurrentTenant 和 EnsureSnapshotOwnership,拒绝把其他租户的根或节点写进当前历史。

Store 端租户边界的审查形态
// 查询同时受调用租户和当前有效租户限制。
var template = await store.GetTemplateAsync(
currentUser.User.TenantId,
templateId,
cancellationToken);
// 不要从客户端接收 TenantId 并直接用于写入。
if (template is null)
return DocsErrors.Template.NotFound(templateId);

5. 创建语义

创建根版本固定 1.0.0、IsActive=true。TargetType 为空时回退 KnowledgeBase,其他任意字符串被原样保存。名称应用层先查重,数据库再以 (TenantId, Name) 唯一兜底。

并发同名创建可能都通过查重,最终一个事务因唯一约束失败。若统一异常映射没有识别具体索引,客户端可能得到 500 而不是 Docs.Template.NameAlreadyExists;需要真实双 ORM 并发测试。

6. 更新与版本号

更新先验证新图、读取根、查重、读取旧节点,再把 CurrentVersion 最后一段加一。1.0.0→1.0.1,1.2→1.3;无法解析末段时使用 1。它不是完整 SemVer,也没有 major/minor 业务语义。

Store 按以下顺序执行:追加旧版本快照 → 更新根字段和当前版本 → 删除当前节点 → 插入新节点。能否原子依赖 Command 事务管道与适配器;Store 方法本身不创建事务。

7. 快照内容

JSON 快照保存旧节点 Id、ParentNodeId、Name、DynamicNameExpression、Icon、Color、Description、SortOrder,使用源生成 DocumentStructureJsonSerializerContext。它不保存模板 Name、Description、TargetType、IsActive,也没有 SnapshotSchemaVersion、创建人或策略版本。

因此即使未来读取,也无法完整恢复历史模板元数据;节点 ID 还是旧持久 ID,回滚必须重新映射而不能直接覆盖到另一个模板。

8. 覆盖写与并发

两个编辑者同时从 1.0.0 更新时,都可能计算 1.0.1、各保存旧快照,后提交者覆盖前者节点。根没有条件版本更新,历史也没有 (TenantId, TemplateId, VersionNumber) 唯一约束。GA 需要 ETag/Version 条件和冲突结果。

覆盖写成本随节点数增长,且删除与插入扩大锁和日志。应先测百/千节点,再决定继续覆盖写、用 diff 增量,还是采用不可变 PublishedVersion + Draft。

9. 删除语义

删除 Handler 先按租户查模板,再删当前节点并软删根。历史版本保留,但没有恢复模板 API。收藏模板引用不会清理;已经应用出的 DocumentCategory 不受影响,也没有 TemplateId/Version 反向追踪。

删除与并发更新、应用的时序未固定。应用在读取 IsActive 后,另一个请求可删除模板,前者仍可能继续创建分类。

10. 目标版本模型

建议明确两种之一:

  1. 可变模板:每次更新保留完整快照,提供历史/diff/rollback,应用记录精确版本;
  2. Draft/Published 不可变版本:编辑 Draft,发布生成新不可变版本,应用只引用 Published。

两种都需要 SnapshotSchemaVersion、并发 token、完整元数据、作者/时间、变更说明、回滚审计和旧 schema 迁移。

版本查询还要区分“查看历史”与“恢复历史”的权限:读取快照可能暴露旧名称和描述,回滚则是写操作,不能共用普通 View。若旧版本来自更宽松的表达式策略,回滚前必须重新验证节点图、字段限制与当前 Scriban 策略。

应用模板时也要固定所用版本。否则审计只能知道 TemplateId,无法回答“当时究竟应用了哪棵树”。推荐在应用记录中保存版本号与快照哈希,而不是复制完整敏感上下文。

11. 测试矩阵

场景当前证据GA 补充
重复临时 IDHandler 测试HTTP 错误合同
缺父节点Handler 测试多层/跨模板引用
环与深度实现存在边界 10/11 与大图性能
ID 重映射Handler/Store 测试双 ORM parity
跨租户快照Store 测试HTTP/集成攻击
并发同名未覆盖唯一冲突映射
并发更新未覆盖ETag 冲突与无丢失更新
中途插入失败未覆盖事务回滚

上述测试不能只校验返回值。还要重新查询根、节点和历史三表,证明成功时三者一致、失败时三者都保持旧状态,并在 SqlSugar 与 EF Core 上使用相同断言。

12. 检查命令

Terminal window
# 节点图、版本和覆盖写的当前实现表面。
rg -n "ValidateNodeGraph|MaxNodeDepth|NodesSnapshotJson|DeleteWhereAsync|AddNodesAsync" \
src/Platform/DocumentStructure -g '*.cs'
# 版本读取、回滚和并发 token 当前预期无命中。
rg -n "ListTemplateVersions|RollbackTemplate|SnapshotSchemaVersion|ConcurrencyToken" \
src/Platform/DocumentStructure -g '*.cs'

DocumentStructure 总览 · 模板应用与预览 · 测试与 GA

100%

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