外部投递由 NotificationService → notification.created → NotificationCreatedConsumer → MultiChannelDeliveryAdapter 串联。当前链路实现了“尽力而为”的渠道调用,不具备可证明的至少一次业务投递或逐渠道恢复能力。
1. 偏好解析优先级
RepositoryNotificationPreferenceStore 按以下顺序解析:
- TenantId+UserId+Code 精确记录;
- TenantId+UserId+空 Code 全局记录;
- 默认 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.1Authorization: Bearer eyJhbGciOiJSUzI1NiJ9Content-Type: application/json
{ "code": "marketing.campaign.started", "inboxEnabled": true, "externalDeliveryEnabled": false, "enabledChannels": "Email"}上例的 EnabledChannels 会保存但不生效;ExternalDeliveryEnabled=false 才会影响编排。代码没有”安全/交易通知必须保留”的分类策略,用户可把 Inbox 和 External 同时关闭。
2. 严重级别默认渠道
| Severity | 默认集合 |
|---|---|
| Info | Inbox |
| Success | Inbox + Email |
| Warning | Inbox + SMS |
| Error | Inbox + 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 的当前顺序:
若 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。
| 渠道 | 需要 | 连接器选择 |
|---|---|---|
| SMS | Phone;可选模板码/参数/签名 | Aliyun/Tencent/Huawei/Twilio/Vonage/Yunpian |
| Alibaba Enterprise Mail / Microsoft 365 | ||
| WeCom/DingTalk/Feishu | metadata 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 | 配置段 |
|---|---|
EnterpriseMail(按 provider 细化为 EnterpriseMail:Alibaba 或 EnterpriseMail:Microsoft365) | |
| Sms | Sms(按 provider 细化为 Sms:Aliyun/Sms:Tencent/Sms:Huawei/Sms:Twilio/Sms:Vonage/Sms:Yunpian) |
| WeCom | TeamWork:WeCom |
| DingTalk | TeamWork:DingTalk |
| Feishu | TeamWork:Feishu |
这份报告与 Operations 的外部连接器就绪度是同一类只读投影:它回答”配置段是否存在、适配器是否注册”,不发起真实投递探针,ChangesRequireRestart 固定为 true。适配器未注册时以 fail-closed 的 Unavailable 呈现,而不是假装可用。默认实现 UnavailableNotificationDeliveryReadinessReader 在未注册真实 reader 时同样返回 Unavailable,避免静默乐观。
8. 可靠投递目标模型
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 和恢复演练。