封面:发送成功不等于收到

(示例场景)运营问:“昨天那条活动推送,发出去 1 万条,怎么才 200 个人点开?”

后端翻了一下日志:“接口都返回成功了啊。”

两个人说的“成功”其实不是一回事。后端的成功,是厂商服务器收下了这条请求;运营以为的成功,是用户手机上弹出了通知。这两者之间,隔着设备离线、通知权限被关、厂商限流、系统省电策略、Token 失效等一长串变数。

这篇文章基于开源项目 AppKernia 的推送设计(架构决策记录 ADR-0021),讲三件事:推送状态怎么拆分,发送失败了怎么分类,推送权限应该在什么时候申请。

一、为什么不用聚合推送,而要直连九个通道

AppKernia 要覆盖的分发渠道有:App Store、Google Play、国内各家安卓应用商店,以及 HarmonyOS NEXT。最终的选择是服务端直接对接下面这些通道:

  • APNs(HTTP/2)、FCM(HTTP v1)
  • 华为(Android)、荣耀、小米、OPPO、vivo、魅族
  • HarmonyOS Push Kit

不引入第三方聚合推送。代价是要逐个适配厂商接口;换来的是凭据、限流、错误码全部掌握在自己手里,出了问题不用隔着一层黑盒排查。

还有一个工程上的决定:Android 拆成两个互斥的构建变体,android_google 和 android_china。把所有厂商 SDK 打进同一个安装包,体积和隐私合规都很难处理;拆开以后,海外包只带 FCM,国内包只带国内厂商通道。不支持的设备怎么办?只保留站内消息,不硬推。

二、“推送成功”其实是四个状态

一条推送要经过的四个状态

示意图:状态拆分说明,不代表各阶段耗时。

状态含义谁能确认
业务事件已产生比如工单派给了某位工程师你自己的服务端
厂商已受理厂商服务器接收了请求厂商接口返回
设备已展示通知出现在用户手机上大部分通道无法可靠回传
用户已打开用户点了通知,App 被拉起App 上报

AppKernia 的规定很直接:sent 只表示厂商已受理。 首期只统计受理、失败、Token 失效和应用打开这几项,不声称“设备已展示”。

这样做的好处是,运营看到的报表不会骗人。“受理 1 万、打开 200”是一个可以讨论的数字;“送达 1 万、打开 200”则会把排查方向带偏。

三、发送结果的七种分类,决定了能不能重试

七种发送结果与处理方式

示意图:AppKernia 推送发送结果的分类与处理。

各家厂商的错误码五花八门。AppKernia 把它们统一归成七类,每一类有固定的处理方式:

结果含义处理
accepted厂商受理记录受理
invalid_tokenToken 已失效立即作废这个设备绑定,不再发送
throttled被厂商限流按 Retry-After 退避后重试
transient明确的临时错误(如 5xx)指数退避 + 抖动后重试
permanent永久错误不重试,记录原因
auth_config_error鉴权或配置错误渠道进入故障状态,等待人工处理
unknown_after_write请求已经写出,结果未知不自动重放

最值得说的是最后一类。

请求已经通过网络发了出去,但连接在收到响应之前断开了。这时厂商可能已经收下并推给了用户,也可能根本没收到。如果自动重试,用户可能收到两条一模一样的通知;如果是“验证码”或“订单状态”这类消息,重复推送会直接造成困扰。

所以规则是:写出之前的连接错误可以重试,写出之后的未知结果不重试。 宁可漏发一条,也不要重复发送。站内消息本来就不依赖推送成功,用户打开 App 照样能看到。

四、权限什么时候申请:同意之前,什么都不做

很多 App 一启动就弹“允许通知吗?”,这时用户还不知道你是做什么的,大概率会点“不允许”。而且在用户同意隐私政策之前就初始化推送 SDK、上传设备 Token,在合规上也站不住。

AppKernia 的推送模块 ak-push 是移动端业务代码唯一可以调用的推送入口,规则如下:

法定同意和用户的推送总开关都生效之前,不申请通知权限、不初始化 SDK、不获取也不上传 Token。

是否真的发出一条推送,由四道“门”同时决定:

推送启用的四道门

示意图:四个条件必须同时满足。

  1. 全局 Kill Switch:出现问题时一键停发;
  2. App 和环境配置:厂商凭据按租户、App、环境、厂商分别隔离,凭据加密保存,支持版本轮换;
  3. 用户订阅:用户自己关闭了推送,就不发;
  4. 系统权限:用户在系统设置里撤销了通知权限,就停用对应的设备绑定。

用户退出登录、设备换了账号的时候,也会停用设备绑定。否则会出现 A 退出、B 登录同一台手机,B 却收到 A 的工单通知这种事故。

另外,运营类推送(news_operations)和安全/服务类推送(service_security)是两个独立的协议分类:运营推送默认关闭,需要单独的权限 notify.operations.publish 才能发送。HarmonyOS 和 vivo 的厂商场景值也是按这两类分别配置的。

五、通知点击后能跳到哪里,必须有白名单

推送消息的点击载荷里,只允许放这几样东西:

  • schema 版本号
  • delivery / message ID
  • 受控的 route_key
  • 不透明的资源 ID

不允许放任意 URL、组件名、脚本,也不允许静默后台执行。

原因很简单:推送内容经过了厂商服务器,App 不应该因为“消息是从推送来的”就信任它。用户点开通知以后,App 拿着资源 ID 去服务端重新鉴权读取。如果这条工单已经转派给别人了,就应该显示“无权访问”,而不是直接展示推送里带过来的内容。

六、上线前的验收矩阵

代码编译通过、Mock 测试通过、接口契约正确,都不能代替厂商验收。每个厂商通道启用之前,都要逐项确认:

  • 厂商账号权益已开通(例如 HarmonyOS 需要先开通 Push Kit,再重新申请调试和发布 Profile)
  • 凭据、包名 / Bundle ID、签名指纹全部匹配
  • 隐私合规与联网审查
  • 真机矩阵:前台、后台、进程被杀、离线后恢复
  • 异常场景:拒绝授权、Token 失效、未登录时点击通知、点开已无权限的资源

没有通过验收的通道保持禁用状态。Token 在服务端加密保存,用 HMAC 哈希去重;日志、审计、指标和管理接口都不返回 Token 或凭据明文。

写在最后

推送最难的部分不是“发出去”,而是把每一种“没发出去”说清楚。把状态拆开、把错误分类、把重试规则写死,运营看到的数字才可信,用户也不会被重复消息打扰。

ak-push 及其依赖的 ak-permissions 已经作为免费源码包,放在 AppKernia 官网的插件目录里;完整的服务端实现在开源仓库中,MIT 许可:

  • 源码:github.com/Payhon/AppKernia
  • 文档:docs.appkernia.com(推送渠道配置)

需要提醒的是:源码不包含你的厂商账号和签名材料,各通道仍然要在你的项目里用你自己的凭据完成真机验收。