UniversalTemplate 面向 Notification、Reporting、Document、Integration、Audit 等用途。它把根属性、不可变版本历史和变量声明聚合在一个领域对象中,持久化时拆成三张租户表。这是明确的多表非对称例外,不是旧式每聚合一套 mapper 的通用模式。
1. 模板身份与唯一性
TemplateKey 约定 {Module}.{Scenario}.{EventType},数据库唯一键是 (TenantId, TemplateKey)。Language、Channel、OwnerModule 都不在唯一键内。
POST /api/templates HTTP/1.1Authorization: Bearer eyJhbGciOiJSUzI1NiJ9Content-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 自动创建并激活 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. 三表持久化
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 | 实际权限语义 |
|---|---|---|
| create | Create | .create |
| update/activate | Update | .update;.activate 未用 |
| delete | Delete | .delete |
| get/search/versions/render/preview/validate | View | .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、审计和权限分离。