个人收件箱以 Notification 统一聚合作为写侧事实,读侧通过 QueryShape 投影热表,并可合并只读归档表。HTTP API 只能操作当前 UserId 的通知;内部 NotificationService 才允许显式指定收件人。
1. 聚合字段与输入边界
| 字段 | 持久化限制 | 当前校验 |
|---|---|---|
| Id/NotificationId | 聚合主键 | 仓储为占位 "0" 分配 Id |
| UserId | 64 | 只校验非空;HTTP 无用户时写 "0" |
| Code | 100 | 只校验非空 |
| Title | 255 | 只校验非空,不预检长度 |
| Body | max text | null 归一为空字符串 |
| Category | 50 | 空白归一 General |
| LinkUrl | 512 | 不验证 scheme/目标域 |
| LinkText | 100 | 不预检长度 |
| MetadataJson | max JSON 列 | 不验证 JSON;投递阶段解析失败变空联系信息 |
Type、Severity、Status 用 SmartEnum Name 持久化,未知数据库值在恢复时失败关闭。新通知默认 Type=Business、Severity=Info、Status=Unread。
var result = await notificationService.CreateAsync( tenantId: tenant.Id, userId: assignee.UserId, code: "tickets.assignment.changed", title: "工单已转派给你", body: $"工单 {ticket.Number} 已转派,请在 SLA 前响应。", type: NotificationType.Todo, category: "Tickets", severity: NotificationSeverity.Warning, linkUrl: $"/tickets/{ticket.Id}", // 只放外部投递所需联系字段;不要把整个 User/Ticket 序列化进来。 metadataJson: JsonSerializer.Serialize(new { email = assignee.Email }), cancellationToken);
// 当前服务没有 DeduplicationKey;调用方重放会创建第二条通知。if (result.IsFailure) return Result.Failure(result.Error);2. 状态转换
MarkRead、Archive 是幂等操作。Archived 上 MarkRead 返回成功并保持 Archived;如果 ReadAt 仍为空,聚合会写入本次 readAt。MarkUnread 会清空 ReadAt,但 Archived 返回 Notification.AlreadyArchived Conflict。
ArchiveNotification 只改变热表记录的 Status,不会立即把行移入 SysNotification_Archive。物理归档由通用 DataRetention 执行器按年龄搬迁,两种“归档”语义必须区分。
3. 当前用户所有权
Read/Unread/Archive 先由仓储按 Id 找聚合,再由 NotificationService 比较 TenantId 和 UserId;不匹配统一返回 NotFound,避免泄露存在性。
[Fact]public async Task MarkRead_Should_Not_Reveal_Another_Users_Notification(){ // Arrange:同租户 user-a 的通知,当前请求主体为 user-b。 var notification = Notification.Create( "tenant-a", "user-a", "invoice.ready", "发票已生成", "您的服务发票已开具完成,请前往账单中心查看。").Value!; repository.Seed(notification);
// Act:服务会先恢复聚合,再执行 TenantId + UserId 所有权检查。 var result = await service.MarkReadAsync( "tenant-a", "user-b", notification.Id, CancellationToken.None);
// Assert:对外只返回 NotFound,且原状态不变。 result.Error.Code.ShouldBe(NotificationErrorCodes.NotFound); notification.Status.ShouldBe(NotificationStatus.Unread);}仓储 FindByIdAsync 本身不接收 tenantId/userId,依赖通用租户仓储的 ambient filter。HTTP handler 又传 CurrentUser.User.TenantId 做所有权比较。模拟租户时 UserTenant 与 EffectiveTenant 不同,可能出现仓储在目标租户找到记录、所有权却用 home tenant 拒绝,或直接查不到。应统一 EffectiveTenant 快照。
4. 收件箱分页
GET 参数:Status、PageIndex、PageSize、IncludeArchived。Handler 对非法页码归一到 1,对 PageSize <= 0 或 PageSize > 100 归一为 20;store 再把 PageIndex clamp 到 1..1000、PageSize 到 1..100。
热表查询按 CreateTime desc、NotificationId desc 稳定排序并下推分页。谓词始终包含 TenantId+UserId,可选 Status。
GET /api/notifications?status=Unread&pageIndex=1&pageSize=20&includeArchived=true HTTP/1.1Authorization: Bearer eyJhbGciOiJSUzI1NiJ9Accept: application/jsonIncludeArchived=true 时,读模型分别从热表和归档表读取 pageIndex * pageSize 条,再内存合并、排序、Skip/Take。第 1000 页、100 条会从每侧取 100000 行,虽不是全表加载,仍可能造成高页码放大。生产 API 更适合 keyset/cursor 分页。
5. 未读数与全部已读
GetUnreadCount 使用 UnreadNotificationFilter(tenantId,userId) 在仓储计数,只看热表。归档表中的 Unread 不进入该计数;如果保留策略能搬迁未读记录,收件箱 IncludeArchived 与 badge 数可能不一致。
MarkAllReadAsync:
- 加载当前租户/用户所有 Unread 聚合;
- 逐个调用 MarkRead;
- SaveRange 全量保存;
- 返回影响数量。
没有分页或批次限制。高积压用户会放大内存、事务和更新量;应改成 tenant+user+status 的集合更新,或有界批处理并明确定义 ReadAt。
6. 热表与归档表
SQL Server 的 NotificationInbox 策略:OnlineDays=365、ArchiveDays=1095、Action=ColdStorage、ArchiveTable=SysNotification_Archive。执行器用 DELETE ... OUTPUT ... INTO 在每租户事务中以 1000 行批次迁移。
注意:通用 DataRetention Job 当前只自动处理 Action=Delete 的策略;ColdStorage 策略需要由归档操作路径实际触发并验证调度。文档不能仅凭 registry 就宣称通知会自动按时搬迁。
归档表是只读非对称记录,没有 Notification Command 写端口。MarkRead/Unread/Archive 的仓储只找热表,因此已物理归档通知虽然能被 GET 看见,却不能再通过当前命令改变状态。
7. 数据与显示安全
收件箱响应会返回完整 Body、MetadataJson 和 LinkUrl。UI 必须:
- 把 Body 当纯文本或经 allowlist 清洗的受控 Markdown;
- LinkUrl 只允许站内相对路径或批准域名,拒绝 javascript/data scheme;
- 不在 DOM、埋点或错误日志展开 MetadataJson;
- 对归档记录采用与热表相同的授权和输出编码;
- 对通知中的 PII 设置明确保留与 GDPR 删除策略。
当前 Notification 没有软删除 HTTP、用户清空收件箱或 GDPR contributor。物理归档保留正文和元数据,不能把“从热表移走”理解为数据最小化。
8. 错误和客户端动作
| 条件 | 结果 | 客户端动作 |
|---|---|---|
| 创建元数据缺失 | Notification.MetadataRequired | 修正输入 |
| Id 不存在/非本人/租户不符 | Notification.NotFound | 隐藏操作,不探测 |
| Archived 标记 Unread | Notification.AlreadyArchived | 保持归档状态 |
| Read/Archive 重放 | Success | 视为幂等成功 |
| 高页码 | 当前 clamp/窗口放大 | 客户端避免深翻页 |
| 物理归档项更新 | NotFound | 当前仅支持读取 |
9. 必测场景
- 聚合字段长度、无效 MetadataJson、危险 LinkUrl;
- Unread→Read→Unread、Unread/Read→Archived 及所有重放;
- Archived+ReadAt null 后 MarkRead 的精确结果;
- 同租户异用户、跨租户和 UserTenant≠EffectiveTenant;
- 页码边界、相同 CreateTime 稳定排序、热冷重复 Id;
- 归档表含 Unread 时 badge 与列表一致性策略;
- 10 万未读的 MarkAllRead 容量/事务;
- SqlSugar/EF Core 热表查询等价,以及 SQL Server 物理归档恢复。