Notifications 包含两组目前尚未闭环的能力:一组是 SysNotification 个人收件箱与外部渠道扇出,另一组是 UniversalTemplate 多表模板、版本、变量、渲染和渠道内容适配。两组代码共处同一模块,却没有在 CreateNotification 中自动串联:创建通知直接接收 Title/Body,模板渲染必须单独调用。
1. 模块实际拥有的边界
当前已实现:
- 当前用户收件箱、未读数、Read/Unread/Archived 状态;
- 个人级全局/通知代码偏好;
- CAP
notification.created事件和严重级别默认渠道; - SMS、企业邮件、企微、钉钉、飞书投递端口;
- 租户模板、版本历史、变量声明、Scriban 编译缓存;
- Email、IM、SMS、Inbox、HTML 内容适配;
- SQL Server 通知热表/归档表合并读取。
当前未实现:模板驱动的通知创建、跨模块稳定通知意图契约、批量收件人、发送计划、投递尝试持久化、重试/补偿、去重、退订强制通知规则、Webhook/Push、附件、退信反馈、送达/打开回执和通知 Feature catalog。
2. 当前端到端结构
NotificationService.CreateAsync 先解析偏好;InboxEnabled=true 才保存聚合,随后总会发布事件。消费者只读取 notificationId,不使用事件中的 tenantId、userId、code 或 skipExternalDelivery。因而事件载荷与消费逻辑并不对称。
3. 两组 HTTP 表面
个人通知
| 方法与路由 | 用例 | 权限动作 | 关键语义 |
|---|---|---|---|
POST /api/notifications | CreateNotification | Create | 给当前 UserId 创建,Title/Body 由客户端直传 |
GET /api/notifications | GetNotificationInbox | View | 状态过滤、1..100 页、默认合并归档 |
GET /api/notifications/unread-count | GetUnreadCount | View | 当前租户当前用户未读数 |
POST .../{id}/read | MarkNotificationRead | Update | 幂等;Archived 保持 Archived |
POST .../{id}/unread | MarkNotificationUnread | Update | Archived 返回 Conflict |
POST .../{id}/archive | ArchiveNotification | Update | 只是状态归档,不搬表 |
POST .../read-all | MarkAllNotificationsRead | Update | 加载全部未读并逐项 SaveRange |
GET/PUT .../preferences | Get/UpdatePreference | View/Update | 精确 Code 或全局空 Code |
权限目录只声明 notifications.notification.view 和 .update,但 CreateNotification 请求使用 AuthorizationAction.Create。目录没有 .create 定义,是必须通过授权契约测试确认并修复的治理漂移。
模板
模板提供 Create、Update、Delete、Get、Search、Versions、Activate、Render、Preview、Validate 共 11 条生成端点。Create/Update/Delete 使用对应通用动作,Activate 却仍使用 Update,Preview/Validate/Render 使用 View;目录中单独声明的 .activate 与 .preview 当前未被这些请求引用。
4. 核心存储模型
Notification 是统一 TenantAggregateRoot;模板则是已登记的多表非对称例外,由显式 projector 在根、版本、变量三表间转换。模板子表的读写都带 TenantId+TemplateId,但以 TemplateId 查询根时依赖全局租户过滤,没有在方法签名中显式传租户。
SQL Server 默认保留策略把超过 365 天的通知从热表事务迁入 SysNotification_Archive,归档保留配置为 1095 天、Action=ColdStorage。公开收件箱默认 IncludeArchived=true,会分别读取热/冷窗口后合并排序;这不是 ArchiveNotification 命令的即时行为。
5. 创建通知的真实方式
POST /api/notifications HTTP/1.1Authorization: Bearer eyJhbGciOiJSUzI1NiJ9Content-Type: application/json
{ "code": "tickets.assignment.changed", "title": "工单已转派给你", "body": "工单 T-20260715-0042 已由一线支持转派。", "type": "Todo", "category": "Tickets", "severity": "Warning", "linkUrl": "/tickets/T-20260715-0042", "metadataJson": "{\"email\":\"agent@example.com\",\"phone\":\"13800000000\"}"}HTTP handler 从 CurrentUser 取得 TenantId 和 UserId;客户端不能指定别的收件人。无 UserId 时写字符串 "0"。聚合只验证 TenantId/UserId/Code/Title 非空,不提前校验数据库长度、MetadataJson JSON 格式或 LinkUrl scheme。
内部业务模块可以直接调用 NotificationService 指定目标 userId,但必须保证 ambient EffectiveTenant 与传入 tenantId 一致,并自行决定模板、正文、幂等键和敏感数据最小化。
6. 主要实现缺口
- CreateNotification 与模板系统完全分离,不会按 Code 查模板或语言。
- Notification 权限目录缺少 Create;Template Activate/Preview 权限存在但请求不用。
- 所有 HTTP handler 使用 User.TenantId/UserId=
0回退,不使用统一 EffectiveTenant/User requirement。 - 通知创建没有业务幂等键;重复事件或请求会创建多行。
- InboxEnabled=false 时不保存,但消费者必须回读,外部投递断路。
- CAP 消费者忽略 tenantId 和 skipExternalDelivery,也未显式建立 tenant context。
- 消费与渠道异常被吞掉,失败无可查询状态、重试或对账。
- EnabledChannels 被保存却不参与 ResolveChannels;用户也可同时关闭全部渠道,没有 mandatory 规则。
- 模板渲染使用空 TenantId,CallerModule 不校验,非 Scriban 引擎名不改变编译器。
- 双语 Identity 种子复用同一 Tenant+TemplateKey,唯一索引使第二语言被跳过;Identity 当前仍用内联正文。
- 模板 Update 的 Description 入参未进入 manager;导入忽略导出 JSON 中的历史版本和变量。
- HTML/Email/Inbox 的正文安全边界不足以作为生产级 allowlist sanitizer。
7. 阅读路径
关联章节:Authorization、Multitenancy、Auditing 和 Operations。
8. 源码核对
# 全部通知/模板路由与权限动作。rg -n 'GenerateEndpoint|AuthorizationAction' \ src/Platform/Notifications -g '*.cs'
# 创建、发布、消费和异常处理真实链路。rg -n 'notification.created|skipExternalDelivery|FindByIdAsync|catch \(Exception' \ src/Platform/Notifications -g '*.cs'
# 模板租户、引擎、调用模块和版本号缺口。rg -n 'GetByKeyAsync\(|CallerModule|TemplateEngine|OrderByDescending' \ src/Platform/Notifications -g '*.cs'