通知系统的成功不是 Create API 返回 200,而是目标用户在允许的渠道收到正确、唯一、安全、可追踪的内容;失败时可重试、可解释、可对账。当前源码已有聚合/持久化基础,但投递与模板闭环不足以支撑这一 SLO。
1. 当前测试资产
仓库已有:
- NotificationAggregate 状态/恢复测试;
- NotificationPreference 与 ReadMarker 单元测试;
- 通知/模板 read-query handler 测试;
- TemplateSecurityChecker、VariableAnalyzer、NamingConverter、StorageProjector 测试;
- NotificationArchivePagination 测试;
- Notification/Template repository 与 read model 双 ORM parity;
- Notifications Infrastructure 架构测试和生产 adapter readiness evidence;
- Workflow notification adapter/dispatcher 测试;
- 模板迁移 tenant backfill parity。
没有直接覆盖 NotificationService.CreateAsync、NotificationCreatedConsumer、MultiChannelDeliveryAdapter、NotificationOrchestrator、TemplateAppService、TemplateRendererService、ScribanTemplateCompiler、19 条 HTTP 契约和供应商故障恢复的测试。
2. 测试分层
单元测试固定纯规则,契约测试固定边界和失败语义,E2E 使用可控 provider fake 验证一次完整交付。真实供应商用 sandbox smoke,不应替代确定性契约。
3. 创建与投递红测试
[Fact]public async Task Create_Should_Persist_Delivery_Fact_When_Inbox_Is_Disabled(){ // 用户不展示站内信,但保留邮件;逻辑通知仍须存在供 consumer 回读。 preferences.Set("tenant-a", "user-1", "security.login", new( InboxEnabled: false, ExternalDeliveryEnabled: true));
var created = await service.CreateAsync( "tenant-a", "user-1", "security.login", "新设备登录", "检测到新的登录设备", severity: NotificationSeverity.Warning, metadataJson: "{\"email\":\"user@example.com\"}");
created.IsSuccess.ShouldBeTrue(); // 当前实现未保存,下面断言会失败;它是修复可靠事实模型的 red test。 (await repository.FindByIdAsync(created.Value!.NotificationId, default)) .IsSuccess.ShouldBeTrue();}还要证明 Save 与 outbox 原子、同 BusinessId 不重复、Consumer 在目标租户回读、每渠道唯一 attempt、端口异常进入 retry/dead 而非被吞掉。
4. 模板红测试
[Fact]public async Task Render_Should_Resolve_Tenant_Template_And_Reject_Unsafe_Content(){ // 租户自有模板用于暴露当前 RenderAsync 固定空 TenantId 的缺陷。 await templates.SeedAsync( tenantId: "tenant-a", key: "Tickets.Assigned.Email.zh-CN", body: "<script>alert(1)</script>{{ user_name }}");
var validation = await renderer.ValidateTemplateAsync( "title", "<script>alert(1)</script>{{ user_name }}", TemplateEngine.Scriban, new Dictionary<string, object> { ["user_name"] = "张三" });
validation.Value!.IsValid.ShouldBeFalse(); // 目标入口必须带 tenant;当前 RenderAsync 固定空 TenantId,查不到该模板。 var rendered = await facade.RenderAsync( "tenant-a", "Tickets.Assigned.Email.zh-CN", ModuleCaller.Notification, new Dictionary<string, object> { ["user_name"] = "张三" }); rendered.IsFailure.ShouldBeTrue();}版本测试必须包含 1.0.9/1.0.10/1.0.11、并发更新、Description、语言唯一性、导入导出 round-trip、Create/Update 安全门禁和 Preview 验证报告。
5. HTTP 与授权契约
为每条生成路由验证:401、错误权限 403、正确权限到 handler、RouteParam、请求/响应 JSON、Problem Details、timeout、rate limit。重点漂移:
POST /api/notifications需要 Create action,但 catalog 没有 notification.create;- Activate 使用 template.update 而不是 template.activate;
- Render/Preview/Validate 共用 template.view,template.preview 未用;
- 当前用户专属 API不能指定 recipient;
- Read/Unread/Archive 对他人 Id 返回同一 NotFound。
权限测试不能只检查 attribute 存在,必须用真实 catalog seed/authorization middleware 发请求。
6. 可观测性
业务指标
- notification_intent_total / deduplicated_total;
- inbox_projection_total / hidden_by_preference;
delivery_attempt_total{channel,result,provider};- delivery_latency、queue_age、retry_count、dead_count;
template_resolve{scope,locale,channel,result};- render_latency、compile_cache_hit、validation_reject、degraded_total;
- unread_count 与 inbox query latency;
- hot/archive rows、archive lag、reconciliation_delta。
日志最小字段
EventId、NotificationId、tenant_hash、code、channel、attempt、provider、error_code、traceId。禁止 Body、模板变量、MetadataJson、Email、Phone、UserId 原值。对错误消息也要清洗,供应商异常常带地址或内容。
7. SLO 与告警
示例 SLO 需按产品等级确认:
| SLI | 示例目标 |
|---|---|
| intent→inbox P99 | < 2s |
| intent→provider accepted P95 | < 60s |
| 24h 最终投递成功率 | ≥99.9%,排除永久地址错误 |
| duplicate delivered | < 1 ppm |
| template resolve/render success | ≥99.99% |
| queue oldest age | < 5 min |
告警必须基于 attempt 状态/队列,而不是仅搜 Error 日志。当前没有 attempt ledger,无法可靠计算这些指标,是 GA 阻断项。
8. 事故手册
| 信号 | 首轮判断 | 处置 |
|---|---|---|
| CAP 有事件但找不到通知 | Inbox=false 或 tenant context 缺失 | 暂停消费,核对事件 tenant/DB,修复后 replay |
| 某渠道全量跳过 | port 未注册/联系信息缺失 | 检查配置与 secret,不记录联系值 |
| 供应商 429/5xx | 瞬时限流/故障 | 降并发,按 Retry-After,保留 attempt |
| 重复短信/邮件 | 无幂等或未知结果重试 | 停 replay,按 provider message id 对账 |
| 模板大量 NotFound | 空 TenantId/fallback 错误 | 回滚调用路径,不复制模板规避 |
| 模板预览绿、线上 XSS | Preview 丢验证/输出未清洗 | 禁用受影响模板,切安全 fallback,排查历史 |
| unread badge 不一致 | 归档含 unread/计数只看热表 | 核对热冷状态,修复统计口径 |
没有持久化 attempt 前无法安全“重放失败通知”;不要从日志批量重新调用 provider,以免重复发送。
9. 容量与恢复
容量矩阵:每秒 intent、单租户热点、单用户 10 万未读、MarkAllRead、深页热冷合并、模板 10/100/10000、变量 1/100/1000、正文大小、SMS 分段、provider timeout、CAP backlog。
备份恢复必须同时覆盖 SysNotification、Archive、Preference、三张模板表、CAP outbox/inbox,未来还包括 DeliveryAttempt。恢复后用 Id/业务键/版本/哈希对账,验证不会把已 delivered attempt 再次发送。
SQL Server 通知归档是 provider-specific 物理能力;EF Core runtime 与非 SQL Server 的行为不能从 read-model parity 推导,需要明确支持矩阵。
10. GA 阻断项
- Create 权限与 catalog 一致;Activate/Preview 使用专用权限或删除虚假权限。
- 所有 HTTP/后台路径统一 EffectiveTenant,RequireUserId 语义明确。
- NotificationIntent 有业务幂等键,Save+outbox 原子。
- Inbox=false 不会破坏外部投递;mandatory 策略存在。
- Consumer 建立 tenant context,不吞异常,支持 retry/dead/replay。
- 每渠道 DeliveryAttempt、唯一键、状态、ProviderMessageId 与对账落地。
- EnabledChannels 真实生效,联系信息不长期明文留在 Inbox metadata。
- 模板按 tenant/PLATFORM/locale/channel 正确解析。
- 只接受真实支持的 TemplateEngine,CallerModule 服务端授权。
- Create/Update 必经 compile、schema、安全审查与发布审批。
- Preview 保留验证结果,渲染失败不回显模板源码。
- HTML 使用上下文编码和成熟 allowlist sanitizer。
- 版本号数值化、唯一约束、并发更新与 Description 修复。
- 双语 seed、导入导出 round-trip、100+ 模板分页修复。
- HTTP、双 ORM、CAP、provider、容量、归档、备份恢复测试通过。
11. 验证命令
# 当前模块相关测试资产。dotnet test tests/BitzOrcas.Unit.Tests/BitzOrcas.Unit.Tests.csproj \ --filter 'FullyQualifiedName~Notification|FullyQualifiedName~Template'dotnet test tests/BitzOrcas.Application.Tests/BitzOrcas.Application.Tests.csproj \ --filter 'FullyQualifiedName~Notifications'dotnet test tests/BitzOrcas.Architecture.Tests/BitzOrcas.Architecture.Tests.csproj \ --filter 'FullyQualifiedName~NotificationsInfrastructureArchitectureTests'
# 关键缺口全局扫描。rg -n 'skipExternalDelivery|EnabledChannels|GetByKeyAsync\(\s*string.Empty' \ src/Platform/Notifications -g '*.cs'rg -n 'DeliveryAttempt|ProviderMessageId|DeduplicationKey|ModuleAuthorization' \ src/Platform/Notifications -g '*.cs'