Skip to content
bitzorcas
中EN

Guide

Notifications 模板、版本、变量与多表持久化

解释 UniversalTemplate 聚合、租户业务键、版本激活、变量声明、物理三表例外、双语种子、导入导出和当前生命周期缺口。

Last updated

UniversalTemplate 面向 Notification、Reporting、Document、Integration、Audit 等用途。它把根属性、不可变版本历史和变量声明聚合在一个领域对象中,持久化时拆成三张租户表。这是明确的多表非对称例外,不是旧式每聚合一套 mapper 的通用模式。

1. 模板身份与唯一性

TemplateKey 约定 {Module}.{Scenario}.{EventType},数据库唯一键是 (TenantId, TemplateKey)。Language、Channel、OwnerModule 都不在唯一键内。

创建一个中文工单分派邮件模板
POST /api/templates HTTP/1.1
Authorization: Bearer eyJhbGciOiJSUzI1NiJ9
Content-Type: application/json
{
"templateKey": "Tickets.Assignment.Changed.Email.zh-CN",
"templateName": "工单转派邮件",
"templateType": "Notification",
"templateCategory": "Transactional",
"channelType": "Email",
"templateEngine": "Scriban",
"language": "zh-CN",
"titleTemplate": "工单 {{ ticket_number }} 已转派",
"bodyTemplate": "<p>{{ assignee_name }},请在 {{ due_at }} 前响应。</p>",
"namingConvention": "SnakeCase",
"ownerModule": "Notification",
"description": "Tickets 分派事件的中文邮件模板"
}

在当前 schema 下,多语言/多渠道必须进入 TemplateKey,否则同租户的第二条会冲突。Identity seed 定义中 zh-CN/en-US 复用了相同 key;种子循环先存在即跳过,所以实际只会保存每个场景的第一语言,而不是注释宣称的 8 条。

建议把 Locale+Channel 作为显式 key 维度或唯一索引列,并提供解析规则:exact locale → language fallback → tenant default → platform default。

2. 生命周期状态机

Create + version 1.0.0Update creates andactivates patchActivate prior versionActivate newer versionSoftDeleteSoftDeleterepeated Delete succeeds

ActiveV100

ActiveNext

ActiveOld

Deleted

Create 自动创建并激活 1.0.0。Update 修改 TemplateName、追加新 patch 版本、停用旧 active、激活新版本并写 UpdateTime。Activate 可将任意历史版本设为 active,相当于指针回退,而不是生成一个新版本。

ActivateVersion 不更新 UpdateTime;根 IsActive 与“有一个 active version”是两套状态。SoftDelete 把根 IsDeleted=true、IsActive=false,但版本行仍保留。

3. 版本号算法缺陷

GenerateNextVersionNumber 把版本字符串按字典序降序,再把最后一段 +1。到 1.0.10 后,字符串 1.0.9 会排在 1.0.10 前面,后续更新可能再次生成 1.0.10,撞重复语义/记录。

版本号必须按数值语义排序
static SemanticVersion Next(IEnumerable<string> versions)
{
// 先严格解析 Major.Minor.Patch;非法历史值应隔离,不能静默回退。
var parsed = versions.Select(SemanticVersion.Parse).ToList();
var latest = parsed.Count == 0
? new SemanticVersion(1, 0, -1)
: parsed.Max();
// 当前业务只自动递增 Patch;Major/Minor 需要显式发布动作。
return new SemanticVersion(latest.Major, latest.Minor, latest.Patch + 1);
}

数据库版本索引当前不是 unique;应至少增加 (TenantId,TemplateId,VersionNumber) 唯一约束,并用并发测试保护两个 Update 同时计算下一版本的竞态。

4. 三表持久化

UniversalTemplate 聚合

TemplateStorageRecordProjector

SysUniversalTemplate
root + current version

SysUniversalTemplateVersion
追加/更新,不删除历史

SysTemplateVariable
同步当前集合

TemplateRepository 先分配根/子项物理 Id,保存根,再 SyncVersions 和 SyncVariables。它依赖外部 Command transaction 覆盖三张表;直接从应用服务之外调用 SaveAsync 时,调用方要提供同一事务。

版本同步不会删除聚合外的历史版本,以保护审计事实;变量同步会删除当前聚合已移除的变量。所有子行读取和修改都使用根 TenantId+TemplateId。

GetById 只传 TemplateId 读取根,租户隔离依赖 IEntitySet 的全局租户过滤。对于后台、平台共享模板和模拟租户,应把 tenantId 明确加入端口,避免隐含上下文。

5. 变量声明的真实作用

TemplateVariable 包含 VariableName、VariablePath、DataType、IsRequired、DefaultValue、Description、ExampleValue。聚合支持 AddVariable 和名称去重,但当前没有变量 CRUD HTTP;CreateTemplate 也不接收变量列表。

Identity seed 会调用 AddVariable。普通管理员只能通过内部服务/导入代码添加,而当前 ImportAsync 又忽略 JSON 中的 Variables,仅把 active title/body 创建成新 1.0.0。变量声明因此不是所有模板都具备的强制 schema。

渲染器也没有按聚合 Variables 验证类型、默认值和 required;它只分析模板文本和本次字典。不要把变量表描述成运行时强类型契约。

6. 更新与权限漂移

UpdateTemplate.Command 声明 Description,但 handler/ITemplateManager.UpdateTemplateAsync 不接收 Description,提交后不会更新。前端不应展示“描述已保存”的成功提示,直到契约修复并加回归测试。

TemplatePermissions 定义 View/Create/Update/Delete/Activate/Preview 六个权限。生成请求却映射为:

路径当前 AuthorizationAction实际权限语义
createCreate.create
update/activateUpdate.update;.activate 未用
deleteDelete.delete
get/search/versions/render/preview/validateView.view;.preview 未用

如果希望内容编辑者能预览但不能读取全部模板,或发布经理能激活但不能修改正文,当前权限无法表达。

7. 导入导出并非 round-trip

TemplateImportExportService 没有 HTTP 端点。ExportAsync 输出 active 内容、变量和所有版本;ImportAsync 反序列化后仅调用 CreateTemplateAsync,忽略 dto.Variables 和 dto.Versions。

ExportAllAsync 请求 PageSize=int.MaxValue,但 repository clamp 到 100,因此只导出第一页最多 100 个模板。它随后逐模板 GetById,存在 N+1 查询。

导出后导入的完整性断言
var exported = await service.ExportAsync(source.TemplateId, ct);
var imported = await service.ImportAsync(
exported.Value!, "tenant-target", "migration-tool", ct);
// 当前实现只保证根摘要和 active 内容,不保证版本/变量 round-trip。
var target = await repository.GetByIdAsync(imported.Value!.TemplateId, ct);
target.Value!.Versions.Count.ShouldBe(source.Versions.Count);
target.Value.Variables.Count.ShouldBe(source.Variables.Count);
// 这两个断言当前会暴露实现缺口,应作为修复前 red tests。

生产导入还需要 schemaVersion、dry-run、签名/来源、冲突策略、所有版本内容安全验证、事务批次和对账报告。

8. 平台模板与租户模板

Identity seed 把共享模板写到 TenantId=PLATFORM。普通 RenderAsync 却按空 tenantId 查 key,没有“先租户覆盖、再 PLATFORM fallback”策略。平台模板与租户自定义模板目前没有可用的继承/覆盖模型。

需要明确:

  • 谁可编辑 PLATFORM 模板;
  • tenant override 的 key/locale/channel 规则;
  • 平台更新是否影响已覆盖租户;
  • 缓存键是否包含 tenant/template/version;
  • 删除 override 后是否安全回退;
  • 发布和回滚是否审计并产生失效事件。

9. 当前没有模板变更事件

TemplateAppService 明确不发布事件。编译缓存以模板文本 SHA256 为 key,因此新文本自然产生新 cache entry,不需按 TemplateId 失效;旧 entry 会按缓存 TTL 存在。跨服务模板复制、审计、审批和发布通知都没有事件。

商业化模板治理通常需要 Draft→Review→Published,而当前 IsActive/active version 在创建和每次 Update 时自动上线,没有审批隔离。Update API 本质上是“编辑并立即发布”。

10. 必测场景

  • Tenant+Key 唯一、locale/channel 变体和大小写规则;
  • 1.0.9→1.0.10→1.0.11 与并发 Update;
  • Update 自动激活、激活旧版本、删除后更新/激活;
  • Description 更新真实持久化;
  • 根/版本/变量三表单事务和失败回滚;
  • 子表跨租户注入、TemplateId 碰撞与 global filter;
  • 变量重复、类型/default/required 运行时约束;
  • 101+ 模板 ExportAll 与版本/变量 round-trip;
  • zh-CN/en-US seed 都存在且可按 locale 选择;
  • PLATFORM fallback、tenant override、审计和权限分离。

返回 Notifications 总览

100%

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