Skip to content
bitzorcas
中EN

Guide

Notifications 模板渲染、渠道适配与内容安全

深入解释模板查找、Scriban 编译、变量命名、缓存、预览验证、十种渠道适配器、安全边界和当前渲染缺口。

Last updated

模板渲染由 TemplateRendererService 编排 Repository、变量命名转换、Compiler、CompilationCache、SecurityChecker、VariableAnalyzer 和 ChannelAdapterFactory。它看似完整,但当前生产路径在租户查找、引擎选择、调用模块授权、验证结果与降级语义上都有关键断点。

1. 当前渲染流程

ChannelAdapterScribanTemplateCompilerCompilationCacheTemplateRepositoryTemplateRendererServiceRenderTemplate handlerChannelAdapterScribanTemplateCompilerCompilationCacheTemplateRepositoryTemplateRendererServiceRenderTemplate handleralt[cache miss]key + variables + callerModule + optionsGetByKeyAsync("", key)template or not foundSHA256(template text)CompileAsync(text, engine)Scriban TemplateRenderAsync(compiled, variables)optional AdaptAsync(title, body)TemplateRenderResult

第一处断点是固定空 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当前行为边界
EmailHTML wrapper,Title encodeBody 原样插入 HTML
DingTalk/WeCom/Feishu去 HTML、Markdown、截断regex 去标签不是完整 parser
SMS去 HTML、空白归一、≤500字符而非供应商计费段/字节
Inbox标题 encode、Body 原样 HTML未 sanitizer;plainText 计算后未返回
Htmlregex 移除少量 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),内部:

  1. 用 Tenant+Code+Locale+Channel 解析已发布模板;
  2. 校验变量 schema 和敏感数据;
  3. 对每个渠道用对应上下文安全渲染;
  4. 以 Tenant+Code+Recipient+BusinessId 建唯一逻辑通知;
  5. 事务保存事实/outbox;
  6. 生成可恢复 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 隔离和并发编译。

返回 Notifications 总览

100%

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