跳转至

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 设备上报)