推送通道与投递
本页是推送能力的当前事实源:设备 token 台账已经实现,厂商通道发送仍未实现。投递管线会真实记录 no_token / not_configured,sent 恒为 0,绝不伪造成功。旧的 推送总览 保留作背景阅读。
已实现:设备 token 注册/查询/注销(客户端 REST + 管理端台账)、6 个厂商通道的配置存储与校验、投递尝试记录(no_token/not_configured)与手动重试、推送统计中的 token/delivery 维度。
未实现:APNs/FCM/华为/小米/OPPO/vivo 的真实发送(不持有凭据、不发起网络请求)、通知点击跳转、角标、通知分组、多语言推送。
离线消息 webhook 与 push.send 是另一条链路(fire-and-forget,3 秒超时、无重试/签名),不要与厂商通道混淆。
端到端链路
| 环节 | 当前实现 | 状态 |
|---|---|---|
| 1. 客户端取得厂商 token | 由宿主 App 集成厂商 SDK 后自行取得(服务端不提供获取能力) | ✅ 由业务侧完成 |
| 2. 注册设备 token | 客户端 REST POST /api/apps/{appkey}/push/tokens(Bearer 用户 token) | ✅ 已实现(台账 + 幂等 + 换绑) |
| 3. 配置厂商通道 | Admin API POST /api/admin/apps/{appkey}/push/channels(6 个通道) | ✅ 仅存储/校验/脱敏,deliveryImplemented=false |
| 4. 选择 token 并发送 | 投递尝试只写入 no_token 或 not_configured,不发起网络请求 | ⛔ 厂商发送未实现 |
| 5. 系统通知展示 | 无(服务端不生成通知载荷) | ⛔ 未实现 |
| 6. 点击跳转 | 无(投递记录不含点击事件) | ⛔ 规划中 |
| 7. 角标 | 无(只有会话 unreadCount) | ⛔ 规划中 |
设备 token 注册(客户端 REST)
token 注册属于客户端接口,使用用户登录后的 Bearer token,不是 App-Key 签名,也不是 Admin API。服务端按 (userId, platform, token) 幂等:重复注册返回既有 tokenId 并重新置 active=true。
| 操作 | 方法与路径 | 说明 |
|---|---|---|
| 注册 / 刷新 | POST /api/apps/{appkey}/push/tokens | body {token, platform, deviceId?, appVersion?} |
| 查询本人 | GET /api/apps/{appkey}/push/tokens | 含 active=false 历史;按 createdAt 倒序 |
| 注销本人 | DELETE /api/apps/{appkey}/push/tokens/{tokenId} | 软删除 active=false;非本人返回 1001(不泄露他人 token) |
| 管理端查询 | GET /api/admin/apps/{appkey}/push/tokens | 过滤 userId/platform/active,分页 |
| 管理端注销 | DELETE /api/admin/apps/{appkey}/push/tokens/{tokenId} | 写审计;不存在 404/1001 |
字段与规则
| 字段 / 规则 | 值 | 失败表现 |
|---|---|---|
platform | ios / android / web / huawei / xiaomi / oppo / vivo / fcm(8 个) | 未知平台 1001 |
token | 8–512 字符(trim 后) | 长度非法 1001 |
| 每用户活跃上限 | 20 个 | 超限 1001(需先注销) |
| 设备换绑 | 同一 (platform, token) 被其他用户注册时,旧记录 active=false | 响应 reboundFrom 列出旧 userId |
| 注销/封禁用户 | 不允许注册 token | 1016 |
| 返回字段 | tokenId/userId/platform/active/createdAt/updatedAt/deviceId/appVersion/duplicate/reboundFrom | — |
| deviceId/appVersion | 可选;deviceId ≤128 字符、appVersion ≤32 字符;不参与幂等键 | 超长 1001 |
curl -sS -X POST "http://127.0.0.1:8080/api/apps/demo/push/tokens" \
-H "authorization: Bearer $USER_TOKEN" \
-H 'content-type: application/json' \
-d '{"token":"vendor-device-token-abc","platform":"android","deviceId":"pixel-7","appVersion":"2.0.0"}'Web / Electron SDK 已实现 registerPushToken / unregisterPushToken / getPushTokens(当前版本,走同一组 REST)。
Android / Flutter / Native / iOS 当前没有这三个方法(IDL 已声明、实现缺失);这些端请直接调用上面的 REST,不要把 IDL 声明当作已交付能力。
6 个厂商通道配置
通道配置在 控制台 · 推送通道 或 Admin API。配置只做字段校验、敏感字段脱敏与启用位存储,不会让消息真的走厂商通道。
| 通道 | 必填字段 | 敏感字段(回读脱敏) |
|---|---|---|
| apns | keyId / teamId / bundleId / privateKey;production 可选 | privateKey |
| fcm | projectId / serviceAccountJson | serviceAccountJson |
| huawei | appId / appSecret | appSecret |
| xiaomi | appSecret / packageName | appSecret |
| oppo | appKey / masterSecret | masterSecret |
| vivo | appId / appKey / appSecret | appSecret |
- 敏感字段读取只返回
****末四位,并给出secretsSet布尔;保存时传以****开头的值表示保持原值。 - 通道测试
POST /api/admin/apps/{appkey}/push/channels/{channel}/test返回implemented=false、delivered=false与原因,不伪造成功。 - 上报字段
supported为 6 个通道;投递适配器视图按 8 个平台展开(ios/android/web/huawei/xiaomi/oppo/vivo/fcm)。
curl -sS -X POST "http://127.0.0.1:8080/api/admin/apps/demo/push/channels" \
-H 'X-Admin-Secret: dev-admin' \
-H 'content-type: application/json' \
-d '{"channel":"apns","enabled":true,"fields":{"keyId":"ABC123","teamId":"TEAM123","bundleId":"com.example.app","privateKey":"-----BEGIN PRIVATE KEY-----..."}}'
curl -sS -X POST "http://127.0.0.1:8080/api/admin/apps/demo/push/channels/apns/test" \
-H 'X-Admin-Secret: dev-admin'投递尝试记录(Admin API)
投递记录用于回答“这条推送为什么没到”。创建时按用户逐条生成记录:有活跃 token → not_configured(适配器未实现);没有活跃 token → no_token。两者都不发起网络请求。
| 操作 | 方法与路径 | 关键规则 |
|---|---|---|
| 创建 | POST /api/admin/apps/{appkey}/push/deliveries | ≤100 用户;title 1–128 字符、body 1–2048、extras ≤4096 字节 |
| 列表 | GET /api/admin/apps/{appkey}/push/deliveries | 按 userId/status 过滤;返回 counts 与 retryableStatuses |
| 单条 | GET .../push/deliveries/{deliveryId} | 含 attemptLog(每次尝试的状态/厂商/错误/时间) |
| 重试 | POST .../push/deliveries/{deliveryId}/retry | 仅 failed / not_configured;生成新记录、attempts+1、retryOf 指向旧记录 |
状态机
| 状态 | 含义 | 本次实现会写入吗 |
|---|---|---|
no_token | 该用户没有 active 设备 token | ✅ 会 |
not_configured | 有 token,但厂商适配器未配置/未实现;未发起网络请求 | ✅ 会(终态) |
queued | 已入队待发送 | ⛔ 预留 |
sent | 厂商已接受 | ⛔ 预留(恒为 0) |
failed | 厂商明确失败 | ⛔ 预留(真实适配器接入后才可能出现) |
重试只接受 failed | not_configured;no_token 需先补设备 token。重试会重新选择当前活跃 token,因此可能从 not_configured 变为 no_token。
真实运行结果(本地隔离实例)
admin.push.deliveries.create (no token): {"requested":1,"sent":0,"status":"no_token","counts":{"no_token":1,"not_configured":0,"sent":0,"failed":0,"queued":0}}
client push token register: {"code":0,"tokenId":"pt_e7a59fe6892c49cbb75e100f18f01ad7"}
admin.push.deliveries.create (token present): {"requested":1,"sent":0,"status":"not_configured","counts":{"no_token":0,"not_configured":1,"sent":0,"failed":0,"queued":0}}
admin.push.deliveries.retry: {"status":"not_configured","attempts":2}
admin.push.stats: {"sent":0,"delivered":0,"deliveryCounts":{"total":3,"byStatus":{"no_token":1,"not_configured":2,"sent":0,"failed":0,"queued":0}}}import { AdminClient } from '@ni-paas-im/server-sdk';
const admin = new AdminClient({ baseUrl: 'http://127.0.0.1:8080', adminSecret: process.env.IM_ADMIN_SECRET });
const created = await admin.createPushDeliveries({
appkey: 'demo',
userIds: ['alice'],
title: '订单提醒',
body: '你的订单已发货',
});
console.log(created.sent, created.deliveries[0].status); // 0 no_token | not_configured
if (created.deliveries[0].status === 'not_configured') {
const retried = await admin.retryPushDelivery({ appkey: 'demo', deliveryId: created.deliveries[0].deliveryId });
console.log(retried.attempts); // 2
}保留期与统计口径
| 项 | 值 | 说明 |
|---|---|---|
| 投递记录保留 | 每应用最近 5000 条 | 维护周期淘汰最旧;retentionTruncated 标记是否已截断 |
| 已注销 token 保留 | 每用户最近 100 条 | 维护周期清理 |
| 统计 sent/delivered/failed | 来自既有 push_logs(push.send/webhook) | 不是厂商回执 |
| deliveryCounts | 来自服务端投递尝试记录 | sent 只可能由未来真实适配器写入,当前恒 0 |
| channelStatsAvailable | false | 没有通道级投递数据 |
| 适配器视图 | 8 个平台,sendImplemented=false | 含 channelConfigStored / channelEnabled |
离线 webhook 与 push.send(另一条链路)
- 接收者无在线 WS 会话时,服务端把消息 JSON POST 到应用配置的
webhookUrl;3 秒超时、无签名、无重试、无死信。 push.send按 scope 投递“不落地通知”到同一个 webhook;未配置 webhook 时 delivered=0,但推送日志仍记录。- 这条链路与厂商通道无关;应用可在 webhook 接收端自行调用 APNs/FCM/厂商接口(本项目不代做)。
- 用户推送免打扰时段(
user.push.period.*)只保存配置,当前未在 webhook 派发时生效。
未实现 / 规划中
| 能力 | 状态 | 说明 |
|---|---|---|
| APNs/FCM/华为/小米/OPPO/vivo 真实发送 | ⛔ 未实现 | 适配器恒为 not_configured;不持有凭据、不发起网络请求 |
| 通知点击跳转 | ⛔ 未实现 | 投递记录没有 click 事件;webhook 载荷可由业务自行跳转 |
| 角标 / 通知分组 | ⛔ 未实现 | 只有会话 unreadCount |
| 多语言推送模板 | ⛔ 未实现 | title/body 由调用方原样传入 |
| 自动重试队列 | ⛔ 未实现 | 只有手动 retry;failed/not_configured 才能重试 |
| 客户端 SDK token 方法 | 🟡 部分平台 | Web/Electron 已实现;Android/Flutter/Native/iOS 缺失,可用 REST |
相关页面
- 推送总览(链路背景;能力结论以本页为准)
- 推送接入(webhook 接入步骤)
- 控制台 · 推送通道 / 推送统计
- push.send / push.log.list
- 消息计数、存储与推送策略