《互联网产品的客户端架构·内容、社交与实时互动》10.3 推送降级:没有推送权限时怎么补

推送不是「有没有」的二值问题:权限被拒、系统通知关闭、令牌失效,三种情况的恢复路径完全不同。本节给出推送降级矩阵、打开应用时的补偿同步策略与「旧推送角标不能覆盖新已读」的规则,并用 TypeScript 实现计划器与角标合并,复盘一次关掉通知的用户永远看不到红点的事故。

推送是一条会断的链路

前两节把未读数合并稳定了,也把已读位置对齐了,但它们都默认「新消息到达时端上知道」。现实里,端上知道的方式只有两条:应用开着时靠长连接或轮询,应用没开时靠系统推送。系统推送不是一条永远可用的通道,它会因为权限、系统开关、令牌轮换随时断掉,而且断掉时通常没有任何报错。

这一节只讨论推送不可用时怎么补:三种失效如何区分、各自的恢复动作是什么、打开应用时如何做补偿同步、推送携带的角标与本地已读谁优先。它不重复未读数的来源合并(10.1),也不重复多端已读位置的计算(10.2);我们假定数字本身已经能算对,问题只在于「什么时候算」。

四种状态,不是「有推送 / 没推送」

把推送当成布尔值,会导致所有失败都走同一条兜底路径:关掉通知的用户和令牌过期的用户看到同样的提示,而后者其实只需要客户端重新注册一次。实际至少有三层状态:

层取值客户端能做什么
用户权限granted / denied / prompt请求一次;被拒后只能引导去系统设置
系统开关开 / 关只能引导去设置,应用内无法绕过
推送令牌valid / expired / missing重新注册,属于客户端可自动修复
应用前后台前台 / 后台前台不展示系统通知,改用应用内提醒

这四层要按顺序判断。权限是 prompt 时,先请求一次;已经是 denied 时,再请求也不会弹系统弹窗,只会静默失败,必须跳设置。系统开关关闭时,权限可能仍是 granted,但通知不会展示。令牌失效时,权限和开关都可能正常,推送服务却投递不到这台设备。同样是「收不到推送」,恢复动作完全不同。

权限:能问、不能再问、只能去设置

权限请求只值得在用户能理解上下文的时刻发一次,例如用户第一次关注某人、第一次打开通知设置页。冷启动就弹窗,拒绝率最高,而且被拒之后没有第二次机会。

被拒之后的正确动作不是反复请求,而是:

  1. 在通知设置页明确说明「系统通知已关闭,可能错过评论和关注」;
  2. 提供一个按钮跳转到系统设置;
  3. 用户从设置返回时重新读取权限,而不是假设他已经打开;
  4. 在权限恢复前,所有提醒走应用内通道,不假装推送还在工作。

「重新读取」这一步经常被漏掉。用户在设置里打开了通知,回到应用,客户端仍缓存着 denied,于是继续不注册令牌、不更新角标,直到下次冷启动才恢复。正确做法是监听应用回到前台的事件,每次前台恢复都重新检查权限与令牌。

令牌失效是服务端可修复的

推送令牌会过期、会被系统轮换、会在重装后变化。这类失败不应该打扰用户,客户端在登录后、启动时、以及收到「令牌无效」响应时重新注册即可。

需要区分两种失败:注册请求失败(网络问题,稍后重试)和注册成功但投递失败(服务端或系统侧问题)。前者客户端能自愈,后者只能等。把两者都提示成「请打开通知权限」,会让用户去设置里反复确认一个本来就开着的开关。

令牌重新注册完成之前,应用仍处于降级态:打开应用要补偿同步,角标要来自服务端快照,而不是等推送。

打开应用时的补偿同步

推送是加速器,不是事实来源。事实来源永远是服务端:未读总数、已读位置、通知列表都在服务端。只要推送链路不完整,打开应用就必须做一次完整同步;即使推送正常,距上次同步超过阈值也应该同步,因为推送可能丢、可能延迟、可能被系统折叠。

补偿同步至少包含四件事:

  1. 合并服务端已读位置(10.2 的最大位置规则);
  2. 拉取未读总数与分类计数(10.1 的快照与水位);
  3. 拉取通知列表第一页,保证点进去能看到内容;
  4. 用同一次快照原子地更新角标,避免先显示旧值再跳。

顺序很重要。如果先渲染角标、再合并已读位置,用户会看到角标先变大再变小;如果先拉列表、后拉总数,列表里的条数和角标可能短暂不一致。把「读位置、总数、列表、角标」放在同一个同步事务里,渲染层只接收最终结果。

同步阈值是产品与成本的权衡。太短会在每次切前台时发请求,耗电且浪费流量;太长会让推送丢失后的空窗期变长。五分钟是一个常见起点,但要根据消息类型调整:私信可以更短,系统公告可以更长。

角标与推送是两条通道

推送载荷里经常带一个角标数字。这个数字是发送时刻的快照,不是实时值。用户读完消息、本地角标清零之后,一条两分钟前发出的推送才到达,如果直接用它覆盖,角标会重新亮起,用户以为又有新消息。

正确的合并规则是给本地角标和推送角标都带时间:

  • 本地每次已读、每次同步成功,都更新 updatedAt;
  • 推送携带 sentAt;
  • sentAt 不晚于本地 updatedAt 的推送角标直接丢弃;
  • 时间相同时本地优先,因为用户的已读操作发生在端上,不能被一条同时刻的推送覆盖。

更严谨的做法是让推送携带服务端的已读位置或水位,端上按位置比较,而不是按时间比较。时间只能解决「明显更旧」的情况,解决不了时钟偏差与同毫秒竞态;但在推送载荷无法扩展时,sentAt 是最低成本的保护。

推送点击还要处理冷启动:用户点通知进入应用时,应用可能是被系统重新拉起的,内存里没有未读状态。这时不能假设推送里的角标就是最新值,仍然要走一次同步,再决定跳到哪个页面。

降级矩阵

把上面的判断固化成矩阵,客户端就不需要在每个调用点重新想一遍:

场景系统推送应用内提醒恢复动作打开应用
权限 prompt不可用可用请求一次权限强制同步
权限 denied不可用可用跳系统设置强制同步
系统开关关闭不可用可用跳系统设置强制同步
令牌失效不可用可用重新注册令牌强制同步
应用在前台不展示可用前台轮询或长连接按阈值同步
链路正常且在后台可用不需要无按阈值同步

矩阵的价值不在于穷举,而在于让每个分支都有明确的「下一步」。没有恢复动作的分支,最终都会变成用户永远收不到提醒。

一个可运行的降级计划器

下面的 push-fallback.ts 属于 L1:它不调用任何系统 API,只根据传入的状态返回通道、动作与原因;同步判断与角标合并也是纯函数,测试不需要真实定时器或推送服务。

// file: src/notify/push-fallback.ts
// tier: L1
// verify: node --test test/notify/push-fallback.test.ts
export type PermissionState = "granted" | "denied" | "prompt";
export type TokenState = "valid" | "expired" | "missing";

export interface PushEnv {
  permission: PermissionState;
  systemEnabled: boolean;
  token: TokenState;
  appForeground: boolean;
}

export type PushChannel = "system" | "in-app";

export type PushAction =
  | "request-permission"
  | "open-settings"
  | "register-token"
  | "sync-on-open"
  | "foreground-poll";

export interface PushPlan {
  channel: PushChannel;
  systemReachable: boolean;
  actions: PushAction[];
  reason: string;
}

// 降级不是「没有推送就什么都不做」,而是按失败原因给出不同的恢复动作。
export function planPush(env: PushEnv): PushPlan {
  if (env.permission === "prompt") {
    return {
      channel: "in-app",
      systemReachable: false,
      actions: ["request-permission", "sync-on-open"],
      reason: "尚未授权,先向用户请求一次",
    };
  }
  if (env.permission === "denied") {
    return {
      channel: "in-app",
      systemReachable: false,
      actions: ["open-settings", "sync-on-open"],
      reason: "权限被拒,只有用户去系统设置才能恢复",
    };
  }
  if (!env.systemEnabled) {
    return {
      channel: "in-app",
      systemReachable: false,
      actions: ["open-settings", "sync-on-open"],
      reason: "系统通知开关关闭,应用内无法绕过",
    };
  }
  if (env.token !== "valid") {
    return {
      channel: "in-app",
      systemReachable: false,
      actions: ["register-token", "sync-on-open"],
      reason: env.token === "expired" ? "推送令牌过期,需要重新注册" : "推送令牌缺失,需要重新注册",
    };
  }
  if (env.appForeground) {
    return {
      channel: "in-app",
      systemReachable: false,
      actions: ["foreground-poll"],
      reason: "应用在前台,系统通知不展示,改用应用内提醒",
    };
  }
  return { channel: "system", systemReachable: true, actions: [], reason: "推送链路可用" };
}

export const SYNC_MAX_AGE_MS = 5 * 60_000;

export interface SyncDecision {
  shouldSync: boolean;
  full: boolean;
  reason: string;
}

// 推送只是加速器;只要链路不完整,打开应用就必须做一次完整补偿同步。
export function decideOpenSync(
  env: PushEnv,
  now: number,
  lastSyncAt: number,
  maxAgeMs = SYNC_MAX_AGE_MS,
): SyncDecision {
  if (!Number.isInteger(maxAgeMs) || maxAgeMs < 0) throw new Error("maxAgeMs 必须是非负整数");
  const degraded =
    env.permission !== "granted" || !env.systemEnabled || env.token !== "valid";
  if (degraded) {
    return { shouldSync: true, full: true, reason: "推送链路不可用,打开应用必须补偿同步" };
  }
  const age = Math.max(0, now - lastSyncAt);
  if (age >= maxAgeMs) {
    return { shouldSync: true, full: true, reason: "距上次同步超过阈值" };
  }
  return { shouldSync: false, full: false, reason: "推送可用且同步足够新" };
}

export interface BadgeState {
  total: number;
  updatedAt: number;
}

export interface PushBadgePayload {
  badge: number;
  sentAt: number;
}

// 推送携带的角标是发送时刻的快照。晚到的旧快照不能把用户刚读掉的数字重新点亮。
export function applyPushBadge(local: BadgeState, payload: PushBadgePayload): BadgeState {
  if (!Number.isInteger(local.total) || local.total < 0) throw new Error("local.total 必须是非负整数");
  if (!Number.isInteger(local.updatedAt) || local.updatedAt < 0) {
    throw new Error("local.updatedAt 必须是非负整数");
  }
  if (!Number.isInteger(payload.badge) || payload.badge < 0) {
    throw new Error("payload.badge 必须是非负整数");
  }
  if (!Number.isInteger(payload.sentAt) || payload.sentAt < 0) {
    throw new Error("payload.sentAt 必须是非负整数");
  }
  // 同一毫秒时本地优先:用户刚做的已读操作不应被一条同时刻的推送覆盖。
  if (payload.sentAt <= local.updatedAt) return { ...local };
  return { total: payload.badge, updatedAt: payload.sentAt };
}

测试与真实输出

本机运行结果如下:

$ node --test test/notify/push-fallback.test.ts
✔ 权限、系统开关、令牌都正常时走系统推送 (0.805667ms)
✔ 权限被拒时只能应用内提醒,并引导去系统设置 (0.116708ms)
✔ 尚未授权时先请求一次,而不是直接放弃 (0.06025ms)
✔ 系统通知开关关闭与权限被拒需要同样的设置引导 (0.060916ms)
✔ 令牌失效时先重新注册,注册完成前仍按降级处理 (0.542958ms)
✔ 应用在前台时不弹系统通知,改用应用内轮询 (0.131041ms)
✔ 推送链路不可用时打开应用强制补偿同步 (0.145833ms)
✔ 迟到的推送角标不覆盖更新的本地已读 (0.114ms)
ℹ tests 8
ℹ suites 0
ℹ pass 8
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 117.794583

八条用例覆盖了正常链路、权限待请求、权限被拒、系统开关、令牌失效、前台抑制、补偿同步判断与旧角标丢弃。它们证明的是决策逻辑,不是系统 API 的真实行为;权限弹窗、设置跳转与令牌注册仍然必须在真机上验证。

失败复盘:关掉通知的用户永远看不到红点

「回声」早期只在推送处理器里更新角标:收到推送,解析 badge,写入本地,渲染。这个设计默认「有未读就一定有推送」,而通知权限被拒的用户恰好推翻了这个前提。

反馈很一致:用户说「我明明关掉了通知,但打开应用也看不到红点」。排查发现,应用内列表其实有 20 条未读,但角标只在推送到达时更新,推送被系统拦住,角标就永远是 0。更糟的是,打开应用时客户端还会发一次「标记已读」的回执,把服务端未读也清掉了,用户彻底失去线索。

修复分三步。第一,角标不再依赖推送:启动、回前台、权限变化时都从服务端拉一次快照,用快照渲染角标。第二,权限被拒时不再发送自动已读回执,只有用户真正打开通知页才标记已读。第三,降级状态下每次打开应用都强制完整同步,不再等推送。上线后,「看不到红点」的反馈消失,代价是这部分用户每次打开应用多一个请求。

第二个事故是旧角标复活。用户读完 5 条,角标本应清零;两分钟前发出的一条推送这时才到达,携带 badge: 5,客户端直接覆盖,红点重新亮起。修复是给本地角标记录 updatedAt,推送携带 sentAt,旧于本地的直接丢弃。这个修复很小,但它把「推送是快照」这件事写进了代码,而不是留在文档里。

补偿同步的代价与边界

补偿同步不是免费的。冷启动时多一次请求会拉长首屏时间,频繁切前台会耗电,弱网下还可能失败。工程上要做三件事:

  • 给同步设最小间隔,避免前后台切换抖动触发连续请求;
  • 同步失败时保留上一次的角标与列表,并标记「数据可能不是最新」,不要清空;
  • 把「完整同步」和「只刷角标」分开,前者用于降级恢复,后者用于阈值内的轻量刷新。

多账号场景还要注意令牌与角标都按账号隔离。同一个设备切换账号后,如果沿用旧令牌,推送会投递到错误的人;如果沿用旧角标,新账号会看到别人的未读数。账号切换时必须清空本地通知状态并重新注册令牌。

最后是隐私边界。锁屏通知的预览内容、角标数字本身都可能泄露信息;这些属于通知内容的展示策略,不属于投递链路,但客户端在降级与补偿时同样要遵守:同步下来的内容不能因为「反正是自己拉的」就绕过锁屏策略。

小结

推送是一条会断的链路,降级要按原因分流:权限待请求就请求一次,权限被拒与系统开关关闭只能引导设置,令牌失效由客户端重新注册,前台则改用应用内提醒。推送不可用时,打开应用必须补偿同步;角标来自服务端快照,旧推送不能覆盖新已读。把恢复动作写进矩阵,才能避免「收不到推送」变成「永远没有提醒」。

三节合起来,通知与未读数这条链路才算闭合:10.1 决定数字怎么合并,10.2 决定已读以谁为准,10.3 决定没有推送时怎么补。下一章离开社交互动,进入内容审核与安全边界,看端上预检能拦什么、不能拦什么。

阅读导航:上一节:10.2 多端已读:一致性从哪里来 · 下一节:11.1 端上预检:能拦什么、不能拦什么 。

常见错误与边界

  • 把推送当成布尔值,权限被拒与令牌失效走同一条兜底路径。
  • 冷启动就请求通知权限,被拒后反复请求,系统不再弹窗。
  • 用户从设置返回后不重新读取权限,仍按缓存的 denied 运行。
  • 令牌失效时提示用户去开权限,而客户端其实可以自动重新注册。
  • 角标只在推送处理器里更新,关掉通知的用户永远看不到红点。
  • 直接用推送携带的角标覆盖本地,迟到的旧推送让红点复活。
  • 降级状态下打开应用不做补偿同步,等推送等到天荒地老。
  • 多账号切换时沿用旧令牌或旧角标,推送与数字串到另一个账号。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「books」更多文章

  1. 《互联网产品的 AI 架构·平台化与治理》附录 C 平台化评审清单
  2. 《互联网产品的 AI 架构·平台化与治理》附录 B 能力目录与配额字段速查
  3. 《互联网产品的 AI 架构·平台化与治理》附录 A 治理角色与职责速查