模板渲染由 TemplateRendererService 编排 Repository、变量命名转换、Compiler、CompilationCache、SecurityChecker、VariableAnalyzer 和 ChannelAdapterFactory。它看似完整,但当前生产路径在租户查找、引擎选择、调用模块授权、验证结果与降级语义上都有关键断点。
1. 当前渲染流程
第一处断点是固定空 TenantId:普通模板创建要求非空 TenantId,Repository 按 TenantId+Key 查询,因此 RenderAsync 通常找不到 tenant 或 PLATFORM 模板。Command 不传 CurrentTenant,当前没有可工作的租户选择/fallback。
2. CallerModule 没有授权作用
RenderTemplate.Command 接收 ModuleCaller,Contracts 也有 ModuleAuthorization 白名单,但 TemplateRendererService 不使用 callerModule,ModuleAuthorization 也没有任何调用点。调用者只需通过通用 template View 权限,就能声明任意已登记 ModuleCaller。
OwnerModule 目前只是模板元数据/搜索字段,不限制谁能渲染或修改模板。
public async Task<Result<TemplateRenderResult>> RenderAsync( string tenantId, string key, ModuleCaller caller, IReadOnlyDictionary<string, object> variables, CancellationToken ct){ // 模块身份必须由服务端调用契约/凭证派生,不能信任 HTTP body 枚举。 var authorization = modulePolicy.Authorize(caller, key); if (authorization.IsFailure) return Result.Failure<TemplateRenderResult>(authorization.Error);
// 先查 tenant override,再按明确规则查 PLATFORM;每一步都带 locale/channel。 var template = await templates.ResolveAsync( tenantId, key, culture.Current, channel.Current, ct); return await RenderValidatedAsync(template, variables, ct);}3. 引擎枚举与真实编译器
TemplateEngine 声明 Scriban、Liquid、Handlebars,但 DI 只注册一个 ScribanTemplateCompiler。Compiler 的 Compile/Validate 参数接收 engine,却完全不分支,三个值都调用 Template.Parse。
因此 Liquid/Handlebars 不是已实现能力:语法可能按 Scriban 失败,或更危险地被当作普通文本成功。对外 API 应暂时只接受 Scriban,直到每个引擎有独立 adapter 和契约测试。
Scriban context 设置 LoopLimit=1000 和 member renamer。源码注释宣称限制递归,但没有显式 RecursiveLimit、总执行 deadline 或输出长度限制。HTTP 有 userPolicy rate limit,却不能代替单次渲染资源预算。
4. 编译和渲染失败的降级
RenderTemplateTextAsync 在 Compile Failure 时返回原始 templateText;Render Failure 同样返回原文。最外层只有抛异常才根据 EnableDegradation 返回 [渲染失败] key。
这会让语法错误/变量错误看似成功,并把 {{ secret }} 等模板源码直接交给下游。生产安全通知不应静默降级为原文;应返回类型化失败,或使用预先批准的静态 fallback,并记录 degraded=true。
编译缓存 key 是模板文本 SHA256,不含 tenant/key/version/engine。相同文本复用是合理优化,但若未来不同引擎解析同一文本,必须把 Engine 和编译器版本纳入 key。Set/Get 失败当前被忽略,渲染仍可继续。
5. Preview/Validate 的结果缺陷
ValidateTemplateAsync 总是返回 Result.Success(TemplateValidationResult),是否有效放在 IsValid。Preview 随后只判断 validationResult.IsSuccess;因为它通常为 true,Preview 会构造新的 IsValid=true 空结果,丢掉语法错误、安全违规和缺失变量。
var validation = await renderer.ValidateTemplateAsync( titleTemplate, bodyTemplate, engine, variables, ct);if (validation.IsFailure) return Result.Failure<TemplatePreviewResult>(validation.Error);
// 传递真正的 IsValid/Errors/SecurityViolations/MissingVariables,// 不要把“Result 调用成功”误当成“模板内容有效”。var report = validation.GetValueOrThrow();return Result.Success(new TemplatePreviewResult( renderedTitle, renderedBody, adaptedContent, report));CreateTemplate/UpdateTemplate 不调用 ValidateTemplateAsync 或 SecurityChecker,所以无效/危险内容可以直接成为 active version。Validate/Preview 只是可选工具,不是写入门禁。
6. 变量命名与 schema
NamingConvention 提供 PascalCase、SnakeCase、Auto。Renderer 先把变量字典转换,再由 Scriban context 设置 MemberRenamer。模板聚合的 Variable 表没有进入这个过程:required/default/data type 不会驱动 runtime binding。
变量来自任意 IReadOnlyDictionary<string,object>。应限制允许的标量/DTO 类型、深度、集合大小和字符串长度,避免把 EF aggregate、lazy proxy、Stream 或含秘密的对象暴露给模板。
建议每个模板版本保存变量 schema snapshot,并在发布时编译,在渲染前执行:未知变量策略、required、类型转换、default、PII classification、最大长度。
7. 渠道适配器矩阵
| TemplateChannel | 当前行为 | 边界 |
|---|---|---|
| HTML wrapper,Title encode | Body 原样插入 HTML | |
| DingTalk/WeCom/Feishu | 去 HTML、Markdown、截断 | regex 去标签不是完整 parser |
| SMS | 去 HTML、空白归一、≤500 | 字符而非供应商计费段/字节 |
| Inbox | 标题 encode、Body 原样 HTML | 未 sanitizer;plainText 计算后未返回 |
| Html | regex 移除少量 tag/事件/js href | 非 allowlist sanitizer,可绕过 |
| PDF/Word/Excel | 工厂已注册 | Adapt 返回 NotImplemented Failure |
Renderer 对 adapter Get/Adapt failure 不返回错误,只让 AdaptedContent=null 并整体 Success。请求 PDF/Word/Excel 看起来成功但没有文档产物。
8. HTML/XSS 边界
Email 与 Inbox 直接把渲染 Body 插入 HTML;Scriban 不会自动对所有变量做 HTML encode。若变量包含 <img onerror=...>,输出可进入邮件/网页。HtmlChannelAdapter 的 regex 只覆盖成对 script/iframe/object、embed、带引号 on* 和 href=javascript,无法等同成熟 HTML sanitizer。
安全策略应按上下文编码:HTML text、attribute、URL、plain text 不可共用。推荐:
- 模板作者只使用批准组件/受限 Markdown;
- 变量默认作为文本 encode,显式 SafeHtml 类型必须由可信 sanitizer 产生;
- URL 变量使用 scheme+host allowlist;
- 渲染后再做 DOM-based allowlist sanitizer;
- CSP/邮件客户端规则作为第二道防线,不替代输出编码;
- 验证日志不记录模板全体或变量值。
TemplateSecurityChecker 的正则针对 System.IO/Net/Reflection/Database/Process 字符串,不能替代模板沙箱或 XSS 清洗,而且当前写路径根本不调用它。
9. 模板与 Notification 的组合方式
当前框架没有一条 API 完成“按租户/语言选择模板→验证变量→渲染→创建通知→投递”。调用方若自行组合,应明确当前不可直接用于 GA:Render 的空租户 bug和 Create 的非幂等仍在。
目标 facade 可接受 NotificationIntent(Code, Recipient, Locale, Variables, BusinessId),内部:
- 用 Tenant+Code+Locale+Channel 解析已发布模板;
- 校验变量 schema 和敏感数据;
- 对每个渠道用对应上下文安全渲染;
- 以 Tenant+Code+Recipient+BusinessId 建唯一逻辑通知;
- 事务保存事实/outbox;
- 生成可恢复 DeliveryAttempt。
不要先渲染一个 HTML Body 再把它同时用于 SMS、IM 和 Inbox;渠道需要独立模板或独立上下文编码。
10. 必测场景
- tenant template、PLATFORM fallback、override、locale/channel 选择;
- CallerModule 伪造、OwnerModule 不符和权限分离;
- Scriban/Liquid/Handlebars 的明确支持/拒绝;
- 循环 1000 边界、深对象、大输出、取消/timeout;
- compile/render failure 不泄漏源码,fallback 显式标记;
- Preview 保留 Validate 的全部错误;Create/Update 拒绝 invalid;
- required/default/type schema 与未知变量;
- HTML text/attribute/URL 注入和 sanitizer 绕过语料;
- PDF/Word/Excel 返回稳定 NotImplemented 而非伪成功;
- cache engine/version 隔离和并发编译。