预览和应用共享“动态表达式渲染”,但后果完全不同:Preview 只返回树,Apply 会直接创建 Documents 分类。当前两条路径都把表达式错误静默降级为固定 Name,应用还缺少目标实例授权、原子性和幂等。
1. NamingContext
请求把 IReadOnlyDictionary<string,string> 转成不区分大小写、值为 object 的字典,并使用 NamingConvention.PascalCase 渲染 Scriban。文档必须按实际模板编译器行为说明变量形式,不能凭空承诺完整 Scriban 语法、函数或沙箱。
POST /api/v1/document-structures/templates/template-1/previewContent-Type: application/json
{ "namingContext": { "clientName": "北辰科技", "year": "2026" }}NamingContext 没有键数量、键名、值长度或敏感数据限制。表达式与上下文都可能进入编译器;上线前需要输入上限、允许变量和执行资源限制。
2. Preview 构树
Preview 先按租户读取模板和节点,从顶层节点递归扫描所有节点,按 SortOrder 排序并构造 Children。创建/更新已阻止环,但数据库历史或人工数据损坏仍可能导致递归风险;Preview 没有运行时深度/visited 防御。
返回 OriginalName 与 RenderedName,但不返回节点 ID、ParentId、表达式错误或警告。客户端无法定位哪个表达式失败,只会看到退回固定名称。
3. 同步阻塞异步编译器
两处 RenderName 都调用 CompileAsync(..., CancellationToken.None).GetAwaiter().GetResult(),随后同样同步等待 RenderAsync。它违反仓库禁止同步阻塞 async 的规则,并忽略请求取消。
// 目标契约:当前源码仍同步阻塞并静默回退。var compiled = await templateCompiler.CompileAsync( node.DynamicNameExpression, TemplateEngine.Scriban, cancellationToken);
if (compiled.IsFailure) return RenderedNode.Failed(node.Id, compiled.Error);
// 把失败作为预览诊断或应用阻断项,而不是悄悄使用固定 Name。var rendered = await templateCompiler.RenderAsync( compiled.Value!, context, NamingConvention.PascalCase, cancellationToken);4. 静默回退
编译 Result 失败、渲染 Result 失败或任意 Exception 都返回 node.Name。这使系统“看起来成功”,却可能把变量表达式拼错、编译器不可用、超时或恶意表达式全部隐藏。
Preview 应返回逐节点 diagnostics;Apply 默认失败关闭。只有产品明确允许的 OptionalDynamicName 才能配置化回退,并记录 warning/metric。
诊断应包含稳定错误码、节点 ID 与安全位置,不应回传编译器堆栈或完整上下文。预览可以收集全部节点错误帮助编辑者一次修正,应用则应在生成写计划阶段汇总并阻断。
5. Apply 前置检查
Handler 只检查模板存在和 IsActive,不比较 request.TargetType 与 template.TargetType,不校验 TargetId 是知识库,不读取知识库对象,也不执行当前用户对目标的实例级授权。IAuthorizedRequest 的通用 Update 不能证明目标 ID 可管理。
任何 TargetType 最终都走 DocumentCategory.Create(tenantId, request.TargetId, ...)。返回结果却回显 request.TargetType,可能让客户端误以为支持多 owner target。
6. 父优先创建
顶层节点先按 SortOrder 创建;子节点进入 Queue,只有父节点已经有 CategoryId 才创建,否则重新入队。最大迭代次数是 nodes.Count + 10。
// TargetType 当前只会被回显,并不会分派到另一个 owner 实现。var result = await mediator.Send(new ApplyTemplate.Command( TemplateId: "template-1", TargetType: "KnowledgeBase", TargetId: "kb-100", NamingContext: new Dictionary<string, string> { ["ClientName"] = "北辰科技" }), cancellationToken);
// 当前成功只返回已创建 CategoryId;没有节点级状态或模板版本。return result.Value!.CreatedCategoryIds;正常合法图能完成;但若持久节点损坏或并发替换导致父引用无法解析,循环到上限后 Queue 仍非空,Handler 没有检查并返回成功。这是明确的部分成功缺口。
7. 逐节点保存窗口
每个节点调用 DocumentCategory.Create 和 repo.SaveAsync。第 N 个失败时前 N-1 个已经保存。若外层 Command Transaction Pipeline 和 Repository adapter 保持同一工作单元,可能整体回滚;但当前没有专门故障测试证明。文档不能无条件宣称原子。
更稳健的边界是 Documents 提供 CreateCategoryTree owner contract:一次校验目标、名称冲突、父关系和节点数,在一个事务中保存并返回完整映射。
8. 重复应用
Command 没有 ApplicationId/IdempotencyKey,数据库分类也没有模板应用唯一来源。客户端超时重试、双击或并发执行会创建重复目录。名称是否冲突取决于 DocumentCategory 领域规则,不能作为通用幂等方案。
幂等记录至少包含 TenantId、TemplateId、TemplateVersion、TargetType、TargetId、ApplicationId、状态与节点映射。Running 请求返回当前状态,Completed 返回原结果,Failed 允许明确恢复/补偿。
幂等键必须由数据库唯一约束兜底,不能只依赖分布式锁。调用方应在一次业务意图内复用 ApplicationId;若相同 ID 携带不同模板、目标或上下文哈希,服务端返回 IdempotencyConflict,而不是重放旧结果。
9. 名称冲突
模板内不同节点可渲染成同一名称;同级现有分类也可能同名。Apply 没有预演全部名称后统一校验。第一个冲突可能发生在树中间,扩大部分写窗口。
Preview 应包含目标上下文时的冲突诊断;Apply 在写前固定完整计划,包括 rendered name、parent、现有冲突策略和预计创建数量。
10. 安全边界
Scriban 表达式来自租户管理员输入,但仍属于不可信配置。需要限制可访问对象、函数、递归、循环、输出长度、编译/执行时间和内存,不允许文件、网络、反射或宿主对象泄漏。NamingContext 中的 Secret/PII 不应进入错误、日志或不必要的表达式。
11. 可观测性
建议记录 template/app ID、version、target、node count、render duration、created count、failed node、compensation status;不得记录完整 NamingContext 或渲染后的敏感名称。指标包括 preview error、fallback count、apply partial、idempotency replay、category conflict 和 transaction rollback。
追踪跨度应覆盖授权、规划、渲染、owner 批量创建和事务提交。若采用异步补偿,原 CorrelationId 必须贯穿补偿 Job 与最终审计报告。
发布看板还应区分 Preview 与 Apply 的表达式失败率。前者上升通常说明模板编辑质量问题,后者出现任何静默回退或部分写都应视为发布阻断,而不是普通业务告警。
12. 故障测试
| 注入点 | 应验证 |
|---|---|
| 第 1/N 个 Compile 失败 | Preview diagnostics;Apply 无写入 |
| Render 超时/取消 | 请求取消传播,无后台继续创建 |
| 第 N 个 Save 失败 | 全回滚或补偿完成 |
| 父节点缺失 | 明确错误,不返回部分成功 |
| 同 ApplicationId 并发 | 一棵树、同一结果 |
| 目标失权 | 创建前拒绝 |
| 模板应用中被删除 | 固定版本或冲突 |
故障注入必须查询实际分类树和幂等记录,不能只断言 Command 返回失败。对可补偿方案,还要模拟补偿进程重启与重复执行,证明最终收敛且不会误删原有分类。
13. 检查命令
# 当前同步阻塞、无取消和逐节点写路径。rg -n "GetAwaiter\(\)\.GetResult|CancellationToken.None|SaveAsync\(category|remaining.Count" \ src/Platform/DocumentStructure -g '*.cs'
# 目标授权、幂等和补偿当前预期无命中。rg -n "EnsureCanManageKnowledgeBase|ApplicationId|Compensat|CreateCategoryTree" \ src/Platform/DocumentStructure -g '*.cs'