Skip to content
bitzorcas
中EN

Guide

Notifications 收件箱、状态机与归档读取

深入讲解 Notification 统一聚合、个人所有权、Unread/Read/Archived 转换、分页、全部已读、热冷表合并和租户边界。

Last updated

个人收件箱以 Notification 统一聚合作为写侧事实,读侧通过 QueryShape 投影热表,并可合并只读归档表。HTTP API 只能操作当前 UserId 的通知;内部 NotificationService 才允许显式指定收件人。

1. 聚合字段与输入边界

字段持久化限制当前校验
Id/NotificationId聚合主键仓储为占位 "0" 分配 Id
UserId64只校验非空;HTTP 无用户时写 "0"
Code100只校验非空
Title255只校验非空,不预检长度
Bodymax textnull 归一为空字符串
Category50空白归一 General
LinkUrl512不验证 scheme/目标域
LinkText100不预检长度
MetadataJsonmax 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. 状态转换

CreateMarkReadMarkReadMarkUnreadMarkUnreadArchiveArchiveArchive / MarkReadMarkUnread

Unread

Read

Archived

Conflict

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.1
Authorization: Bearer eyJhbGciOiJSUzI1NiJ9
Accept: application/json

IncludeArchived=true 时,读模型分别从热表和归档表读取 pageIndex * pageSize 条,再内存合并、排序、Skip/Take。第 1000 页、100 条会从每侧取 100000 行,虽不是全表加载,仍可能造成高页码放大。生产 API 更适合 keyset/cursor 分页。

5. 未读数与全部已读

GetUnreadCount 使用 UnreadNotificationFilter(tenantId,userId) 在仓储计数,只看热表。归档表中的 Unread 不进入该计数;如果保留策略能搬迁未读记录,收件箱 IncludeArchived 与 badge 数可能不一致。

MarkAllReadAsync:

  1. 加载当前租户/用户所有 Unread 聚合;
  2. 逐个调用 MarkRead;
  3. SaveRange 全量保存;
  4. 返回影响数量。

没有分页或批次限制。高积压用户会放大内存、事务和更新量;应改成 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 标记 UnreadNotification.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 物理归档恢复。

返回 Notifications 总览

100%

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