09 离线推送与角标¶
状态:目标设计;NotificationService 与
UserBadgeState完整链路尚未实现 更新日期:2026-09-01 上游契约:docs/PLAN.md§5.4、§7.7、§7.10、§12.5、§16、§22 实施决策:ADR-0007(一期简化形态)
0. 文档边界¶
本文定义:APNs/FCM/VoIP 接入、device_token 生命周期、推送去重与频控、
静音生效层、角标下发实现。
本文不得重新定义:PushTask 字段集与 PD-1 不变量(§16.1)、
UserBadgeState 聚合口径(§7.7)、NOTIFIED 的到达层级语义(§11.2)。
1. 判定:粒度是设备¶
用户级在线 Bitmap 只是粗过滤器,判定必须下沉到 device_id(§16.1.1)。
粗过滤(O(1) 位运算) U_on / U_off
判定(设备级) 对 U_on 展开 D(user),按 PresenceEntry 分 I(在线)/ F(离线)
U_off 的全部设备直接进 F
D(user) = DeviceToken 摘要缓存(§16.6 的 compacted topic)∪ PresenceEntry
PD-1 不变量:任意可唤醒设备 d,若邮箱条目已物化且 d 在
push_grace_window 内既无 PUSHED 也无 APPLIED 证据,必须为 (u,d)
产生 PushTask,与该用户其他设备是否在线无关。
禁止:以"该用户至少一个设备在线"为由取消整个用户的推送
—— 手机在线不代表平板不需要推送
1.1 PUSHED 不是取消依据(§16.1.2)¶
PUSHED 只是"写入发送缓冲"(§11.2),不是设备收到。
向僵尸连接 write() 会成功并立即产生 PUSHED,而连接死亡要等
tcp_user_timeout(15s) 才暴露 —— 3 秒的 push_grace_window 早已过期。
规则:
收到 PUSHED -> 任务**不删除**,改挂长窗口
push_confirm_extended_window = tcp_user_timeout(15s)
+ push_grace_window(3s) = 18 s
收到 APPLIED -> 删除任务(唯一的充分证据)
连接关闭且 APPLIED < 待确认 seq -> **立即**产生 PushTask,不等窗口
长窗口到期仍无 APPLIED -> 产生 PushTask
2. 去重、合并与撤销¶
去重键 (tenant, user, device, message_id) —— 与 PushTask 主键同源
合并 同一 (user, device, conversation) 在 push_merge_window(5s) 内
合并为一条"N 条新消息",不逐条推
撤销 跨设备已读后,对该会话尚未送达的 PushTask 发 APNs 的
apns-collapse-id 覆盖 / FCM 的 collapse_key 覆盖,
并下发新 badge(content-available 静默推送)
幂等 NotificationService 重试、MailboxNode 漂移接管后重放,
都不得产生第二条通知(去重键在 `push_dedup_ttl`(10 min) 内有效,附录 B.5.2)
3. 静音生效层¶
静音在**推送层**生效,不在未读层(§12.5 已规定静音不清未读)
muted 会话:
counts_unread 仍然累加(§7.7 的 total_unread 按租户策略决定是否计入)
mention_type != NONE 时**仍然推送**(@我不受静音抑制)
其余消息不产生 PushTask
夜间免打扰:按用户时区,窗口内只推 mention_type != NONE 的任务
4. 频控¶
push_rate_per_user_per_min 10 每用户每分钟推送上限(附录 B.5.2)
push_rate_per_conversation_per_min 3 每会话每分钟上限(附录 B.5.2)
rtc_voip_push_daily_quota 200 次/设备/天(§13.5.2、附录 B.6.1)
超限行为:丢弃低优先级任务并计数,**不排队**
—— 排队会让通知延迟到失去意义,且放大内存
优先级:mention > 单聊 > 小群 > 大群;RTC VoIP 独立通道不参与该排序
5. device_token 生命周期¶
注册 客户端在 AUTH 后经独立 HTTP 接口上报(不占长连接帧)
写入 NotificationService 私有表,并发布到 device_token_digest
compacted topic(§16.6),供 MailboxNode 做设备全集展开
刷新 token 变更时重新上报,旧 token 立即失效
失效 APNs 返回 410 Unregistered / FCM 返回 UNREGISTERED
-> 立即从私有表与摘要 topic 删除
-> 该设备不再产生 PushTask(但仍可在下次连接时按 §9 同步)
吊销 设备被远程登出(§15.3、§20.3)时同步删除 token 与 DevicePreKeyBundle
摘要 topic 只发布摘要,不发布 token 本体——token 是凭证, MailboxNode 只需要知道"该设备可被唤醒",不需要 token 值。这把凭证的持有面 收敛在 NotificationService 内(§17.3 的内部鉴权白名单)。
摘要滞后只造成多推或少唤醒一次,由 §2 的去重与撤销兜底(去重键 TTL 为 push_dedup_ttl,附录 B.5.2);
滞后指标 device_token_digest_lag 入 §24.1.6。
6. 角标¶
目标态数值来源唯一:UserBadgeState(§7.7),与目标态投影按同一一致性边界提交。
当前 qsession 只维护会话集合并在读路径计算未读,`UserBadgeState`/NotificationService
尚未闭环;不得把本节描述成已实现能力。
在线 BADGE_UPDATE 帧,与 SESSION_DELTA 共用 session_delta_merge_window
客户端按 badge_projection_mailbox_seq 单调丢弃旧帧
离线 APNs 的 aps.badge / FCM 的 notification_count,携带**绝对值**
登录 AUTH_OK 携带 total_unread / total_mention / muted_unread 快照
NotificationService **不自行聚合未读**(§16.9、§17.1),只读 UserBadgeState
多租户/多账号客户端:服务端只给 (tenant, account) 维度数字,
客户端求和后写系统角标(§7.7)。
7. iOS 后台降级路径¶
iOS 后台长连接会被系统回收 —— 这是常态不是异常
普通消息 APNs 常规推送,用户点击后 App 前台拉取
静默唤醒 content-available=1,App 后台短暂唤起 -> 建连 -> §9.3 同步 -> 生成本地通知
受 iOS 静默推送频率限制,只用于角标更新与高优先级会话
RTC 来电 VoIP push(PushKit)唤起 CallKit 全屏来电界面
iOS 要求收到 VoIP push 后**必须**报告来电,否则应用会被限制
因此 VoIP 通道严格受 rtc_voip_push_daily_quota 约束,不得挪作他用
8. E2EE 下的推送(§22.3)¶
服务端无法解密正文 -> 推送 payload 只含:
会话名(若会话名本身未加密)
通用占位文案
badge 绝对值
真实内容由客户端唤起后拉取并**本地生成通知**
因此 E2EE 会话的通知延迟 = 推送触达 + 客户端拉取,高于明文会话。
这一点写入 §22.3 的降级矩阵,属已知代价。
9. 与到达层级的关系¶
NOTIFIED = 已提交给 APNs/FCM,仅表示**外部通道受理**(§11.2)
APNs/FCM 均不保证送达
因此 NOTIFIED:
不得用于任何完整性判定
不得推进任何游标
不计入 §2.4 的"消息最终到达率"(该指标的分子是 APPLIED)
10. 验收¶
N-1【发布阻断】PD-1 多设备
用户有手机(在线)+ 平板(离线):一条消息必须为平板产生 PushTask
"因手机在线而取消整个用户推送"的发生次数 == 0
N-2【发布阻断】僵尸连接不漏推
kill -9 客户端进程(无 FIN),服务端 write() 成功产生 PUSHED:
任务不被取消;tcp_user_timeout(15s) 后连接暴露死亡
push_confirm_extended_window(18s) 内产生 PushTask
漏推次数 == 0
N-3 静音与 @
静音会话的普通消息:PushTask 产生数 == 0,但 unread_count 正常累加
静音会话的 @我消息:PushTask 正常产生
N-4 已读撤销
设备 A 已读后:对该会话未送达的 PushTask 被 collapse 覆盖
离线设备收到的最终 badge == UserBadgeState.total_unread
N-5 token 失效
APNs 返回 410:token 从私有表与摘要 topic 删除
该设备后续 PushTask 产生数 == 0;下次连接仍能按 §9 同步到全部消息
N-6 角标一致性
任意时刻 aps.badge == BADGE_UPDATE.total_unread == UserBadgeState.total_unread
三者差异 == 0
N-7 频控不排队
注入超 push_rate_per_user_per_min 的任务:超额任务被丢弃并计数
队列深度不增长(断言无排队内存放大)
11. 待办¶
[ ] APNs JWT 与 FCM OAuth 凭证轮换流程(进 docs/07)
[ ] push_retry_max_attempts 的退避序列与 APNs/FCM 各自错误码的映射表
[ ] nse_pull_max_items 与 iOS NSE 的 24 MB 内存上限的联合验证
[ ] 夜间免打扰的时区来源(用户配置 vs 设备上报)