Skip to content
bitzorcas
中EN

Guide

Notifications 偏好、CAP 与多渠道投递

讲清通知偏好优先级、严重级别默认渠道、CAP 事件消费、联系信息、供应商连接器、失败语义和可靠投递改造。

Last updated

外部投递由 NotificationService → notification.created → NotificationCreatedConsumer → MultiChannelDeliveryAdapter 串联。当前链路实现了“尽力而为”的渠道调用,不具备可证明的至少一次业务投递或逐渠道恢复能力。

1. 偏好解析优先级

RepositoryNotificationPreferenceStore 按以下顺序解析:

  1. TenantId+UserId+Code 精确记录;
  2. TenantId+UserId+空 Code 全局记录;
  3. 默认 InboxEnabled=true、ExternalDeliveryEnabled=true。

偏好表还保存 EnabledChannels 逗号字符串,但解析结果类型不包含它,编排器完全不读取。当前只能整体关闭站内或全部外部渠道,不能真正按 Email/SMS/WeCom 精选。

偏好对象 NotificationPreference 现在还承载一组呈现偏好字段:InAppPopupEnabled(默认 true)、SystemNotificationEnabled(默认 false)、SoundEnabled(默认 false)、MutedUntil(可空)、CenterView(默认 Recent,可选 Recent/Grouped)、PreferredType(可空,可选 System/Business/Todo/Approval/Alert)。PUT /api/notifications/preferences 接受这些字段,并通过 IsSupportedCenterView / IsSupportedPreferredType 校验取值,非法时返回 Notification.Preference.PresentationInvalid。

此外有一条独立的静音端点 PUT /api/notifications/preferences/mute,请求体只有一个 DurationMinutes 字段(取值必须是预设档位 0/10/15/30/60/120/240/480/720/1440 分钟之一,0 表示取消静音),服务端据此计算 MutedUntil。非法时长返回 Notification.Preference.MuteDurationInvalid。静音只压制弹窗、系统通知和声音,不影响站内信事实入库和外部投递。

关闭某一通知代码的外部投递
PUT /api/notifications/preferences HTTP/1.1
Authorization: Bearer eyJhbGciOiJSUzI1NiJ9
Content-Type: application/json
{
"code": "marketing.campaign.started",
"inboxEnabled": true,
"externalDeliveryEnabled": false,
"enabledChannels": "Email"
}

上例的 EnabledChannels 会保存但不生效;ExternalDeliveryEnabled=false 才会影响编排。代码没有”安全/交易通知必须保留”的分类策略,用户可把 Inbox 和 External 同时关闭。

2. 严重级别默认渠道

Severity默认集合
InfoInbox
SuccessInbox + Email
WarningInbox + SMS
ErrorInbox + SMS + WeCom + DingTalk + Feishu

ExternalDeliveryEnabled=false 时编排器只返回 Inbox;InboxEnabled=false 时从默认集合移除 Inbox。偏好只能做开关,不会增加默认集合之外的渠道。

当前渠道解析的行为测试
[Theory]
[InlineData(false, true, 0)] // Info 移除 Inbox 后没有任何渠道。
[InlineData(true, false, 1)] // 外部关闭后只保留 Inbox。
public async Task Info_Preference_Should_Resolve_Expected_Channel_Count(
bool inboxEnabled,
bool externalEnabled,
int expected)
{
// 精确代码偏好优先于用户的全局偏好;这里直接准备解析后的规则。
preferenceStore.Returns(new NotificationPreference(
"tenant-a", "user-1", "info.code", inboxEnabled, externalEnabled));
// Info 的默认集合只有 Inbox,不会因为 external=true 自动增加 Email。
var channels = await orchestrator.ResolveChannelsAsync(
new NotificationContext(
"tenant-a", "user-1", "info.code", NotificationSeverity.Info,
"General", null),
CancellationToken.None);
channels.Count.ShouldBe(expected);
}

3. 创建与 CAP 事件

CreateAsync 的当前顺序:

CAP publisherNotificationRepositoryPreferenceStoreNotificationServiceCAP publisherNotificationRepositoryPreferenceStoreNotificationService不保存通知alt[Inbox enabled][Inbox disabled]external disabled 时再加 skipExternalDelivery=trueResolve tenant + user + codeSave Notificationnotification.created + notificationId + tenantId + userId + code

若 Command pipeline 与 CAP outbox/数据库事务正确集成,保存与发布可以共享事务;但 NotificationService 是公开应用服务,也可能从非 Command 路径调用,调用方必须验证实际事务边界。

没有业务 DeduplicationKey 或事件 Id。调用方重放 CreateAsync 会产生新 Id 和新事件,CAP 自身的消息去重不能合并两次业务创建。

4. Inbox 关闭的断路缺陷

InboxEnabled=false 时 Service 不保存 Notification,却仍发布 notificationId。Consumer 收到事件后第一步 repository.FindByIdAsync(notificationId);找不到就 Warning 并 return。因此预期“无站内信但保留外部渠道”在当前实现中无法工作。

修复可以选择:

  • 始终保存逻辑 Notification,把 InboxVisible 独立成投影/偏好;或
  • 事件携带已验证的不可变投递快照,不依赖回读 Inbox;或
  • 单独建立 NotificationIntent/DeliveryJob 事实,再按偏好生成 Inbox projection。

第一种最接近当前模型,也便于审计、重试和去重。

5. Consumer 的租户与失败语义

Consumer 只读取 notificationId。事件里的 tenantId 和 skipExternalDelivery 均未使用,也没有显式 Push EffectiveTenant。仓储 FindById 又不接受 tenantId;后台消费能否找到租户记录取决于外部 ambient-tenant 机制,当前消费者本身没有建立该边界。

目标消费边界
public async Task HandleAsync(NotificationCreatedV1 message, CancellationToken ct)
{
// 用受信事件租户建立一次性上下文;禁止依赖 CAP 线程残留的 ambient tenant。
await using var tenantScope = currentTenant.Push(message.TenantId);
// Inbox 是否可见不影响逻辑通知事实,因此通知必须可恢复。
var notification = await repository.GetRequiredAsync(
message.TenantId, message.NotificationId, ct);
// 每个渠道用 NotificationId+Channel 建唯一 DeliveryAttempt。
foreach (var channel in await policy.ResolveAsync(notification, ct))
await attempts.EnqueueOnceAsync(notification.Id, channel, ct);
}

当前 Consumer 捕获所有非 OperationCanceledException,记录 Error 后不重新抛出。CAP 会把消息视为已消费,不会进入正常重试/死信恢复。MultiChannelDeliveryAdapter 又逐渠道捕获异常;端口返回 Failure 也只 Warning。任何失败都会丢失为日志事实。

6. 联系信息与渠道端口

MultiChannel 从 Notification.MetadataJson 反序列化:UserId、Phone、Email、SmsTemplateCode、SmsTemplateParams、SmsSignName。JSON 失败返回空联系信息,然后对应渠道 Warning+skip。

渠道需要连接器选择
SMSPhone;可选模板码/参数/签名Aliyun/Tencent/Huawei/Twilio/Vonage/Yunpian
EmailEmailAlibaba Enterprise Mail / Microsoft 365
WeCom/DingTalk/Feishumetadata UserId同时注册多个 TeamWork provider,由 providerKey 路由

SMS 和 Email 的 DI 集合只取 FirstOrDefault,因此配置层只选择一个 Provider。IM adapter 内部收集多个 TeamWork provider并按 key 选择。

把电话号码/邮箱放进通知 MetadataJson 会让它们进入热表、归档表、备份和响应。更稳妥的是保存 recipient reference,在投递时通过受控 Directory 端口解析短期联系信息,或者把加密投递快照放到独立保留期更短的 DeliveryJob。

7. 外部投递渠道就绪度

GET /api/notifications/delivery-readiness(资源 notifications/delivery、动作 View)返回五个投递渠道的就绪度,供运维在发投递前确认装配边界。报告字段:Channel、Provider、Status(Configured/NotConfigured/Unavailable)、AdapterRegistered、ConfigurationSection、ChangesRequireRestart、DependentCapabilities。

Channel配置段
EmailEnterpriseMail(按 provider 细化为 EnterpriseMail:Alibaba 或 EnterpriseMail:Microsoft365)
SmsSms(按 provider 细化为 Sms:Aliyun/Sms:Tencent/Sms:Huawei/Sms:Twilio/Sms:Vonage/Sms:Yunpian)
WeComTeamWork:WeCom
DingTalkTeamWork:DingTalk
FeishuTeamWork:Feishu

这份报告与 Operations 的外部连接器就绪度是同一类只读投影:它回答”配置段是否存在、适配器是否注册”,不发起真实投递探针,ChangesRequireRestart 固定为 true。适配器未注册时以 fail-closed 的 Unavailable 呈现,而不是假装可用。默认实现 UnavailableNotificationDeliveryReadinessReader 在未注册真实 reader 时同样返回 Unavailable,避免静默乐观。

8. 可靠投递目标模型

retry/dead

NotificationIntent
业务幂等键

InboxProjection

DeliveryAttempt
NotificationId+Channel unique

有界重试 Worker

Provider

ProviderMessageId / receipt

DeliveryAttempt 至少需要 Channel、Status、AttemptCount、NextAttemptAt、LastErrorCode、ProviderMessageId、PayloadHash、CreatedAt/DeliveredAt。状态应可查询、可人工重放,并以唯一键防止重复 enqueue。

Provider timeout 的结果通常未知,不能无条件重试;优先使用供应商幂等键/查询接口,再决定重放。永久错误(无效地址、退订)与瞬时错误(429、5xx、网络)要分类。

9. 事件契约建议

当前 Dictionary<string, string> 缺少编译期结构、版本和 schema 演进。目标事件可定义:

版本化通知创建事件
public sealed record NotificationCreatedV1(
string EventId,
string NotificationId,
string TenantId,
string UserId,
string Code,
DateTimeOffset OccurredAt,
string TraceId);
// Body、手机号、邮箱不进入事件;消费者在租户范围内按 Id 读取最小数据。
// EventId 与 NotificationId+Channel 分别守护消费幂等和渠道任务幂等。

如果保留回读模式,Consumer 必须建立租户上下文并区分“暂时不可见、永久不存在、已关闭渠道”。

10. 可观测性与告警

应记录但不泄漏内容:notificationId、tenant_hash、code(低基数 registry)、channel、attempt、result、provider、provider_code、latency、eventId/traceId。不要记录 Body、MetadataJson、Email、Phone 或 UserId 原值。

告警:事件找不到通知、无 ambient tenant、端口未注册、联系信息缺失、投递 Failure/exception、重试耗尽、队列年龄、同 NotificationId 重复 delivered、偏好解析失败。

11. 必测场景

  • 精确 Code、全局、默认偏好优先级;EnabledChannels 真正生效;
  • mandatory 安全通知不能全部关闭;
  • Inbox=false + External=true 仍能外部投递;
  • Consumer 在正确 tenant 下回读,错误 tenant 失败关闭;
  • skipExternalDelivery 或等价策略一致;
  • provider success/failure/throw/timeout/429、取消传播;
  • CAP 重放、并发消费者、每渠道唯一尝试;
  • MetadataJson 损坏/缺字段、PII 日志扫描;
  • 多 IM provider 路由与 SMS/Email 单 provider 配置;
  • 失败对账、人工 replay 和恢复演练。

返回 Notifications 总览

100%

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