
(示例场景)运营问:“昨天那条活动推送,发出去 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_token | Token 已失效 | 立即作废这个设备绑定,不再发送 |
throttled | 被厂商限流 | 按 Retry-After 退避后重试 |
transient | 明确的临时错误(如 5xx) | 指数退避 + 抖动后重试 |
permanent | 永久错误 | 不重试,记录原因 |
auth_config_error | 鉴权或配置错误 | 渠道进入故障状态,等待人工处理 |
unknown_after_write | 请求已经写出,结果未知 | 不自动重放 |
最值得说的是最后一类。
请求已经通过网络发了出去,但连接在收到响应之前断开了。这时厂商可能已经收下并推给了用户,也可能根本没收到。如果自动重试,用户可能收到两条一模一样的通知;如果是“验证码”或“订单状态”这类消息,重复推送会直接造成困扰。
所以规则是:写出之前的连接错误可以重试,写出之后的未知结果不重试。 宁可漏发一条,也不要重复发送。站内消息本来就不依赖推送成功,用户打开 App 照样能看到。
四、权限什么时候申请:同意之前,什么都不做
很多 App 一启动就弹“允许通知吗?”,这时用户还不知道你是做什么的,大概率会点“不允许”。而且在用户同意隐私政策之前就初始化推送 SDK、上传设备 Token,在合规上也站不住。
AppKernia 的推送模块 ak-push 是移动端业务代码唯一可以调用的推送入口,规则如下:
法定同意和用户的推送总开关都生效之前,不申请通知权限、不初始化 SDK、不获取也不上传 Token。
是否真的发出一条推送,由四道“门”同时决定:

示意图:四个条件必须同时满足。
- 全局 Kill Switch:出现问题时一键停发;
- App 和环境配置:厂商凭据按租户、App、环境、厂商分别隔离,凭据加密保存,支持版本轮换;
- 用户订阅:用户自己关闭了推送,就不发;
- 系统权限:用户在系统设置里撤销了通知权限,就停用对应的设备绑定。
用户退出登录、设备换了账号的时候,也会停用设备绑定。否则会出现 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(推送渠道配置)
需要提醒的是:源码不包含你的厂商账号和签名材料,各通道仍然要在你的项目里用你自己的凭据完成真机验收。