diff --git a/docs/IM-前后端对接文档.md b/docs/IM-前后端对接文档.md index dde8047..da9c411 100644 --- a/docs/IM-前后端对接文档.md +++ b/docs/IM-前后端对接文档.md @@ -446,6 +446,138 @@ X-MeetingIM-APIKey: <预置的普通 API Key> | 消息过大 | `{"ctrl":{"id":"pub-1","code":413,"text":"message too large","params":{"reason":"MESSAGE_TOO_LARGE","max_size":4194304}}}` | 禁止重试,提示压缩或拆分 | | 断线 | WebSocket close | 指数退避重连,重新 `hi -> token login -> sub me -> sub topics`,按最后完整 `seq + 1` 增量拉取 | +##### 0.5.4.9 极光离线 Push 与重连补偿 + +离线 Push 只承担提醒和唤醒 App,不作为消息正文或业务状态真源。客户端收到极光 Push 后,必须重新登录 IM,通过 `topic + seq`、`call_id` 或系统通知 ID 拉取服务端状态。普通聊天、群 @、系统通知、好友/群审批、音视频来电都复用同一套 extras 分发协议。 + +客户端侧要求: + +- App 集成 JPush SDK,初始化使用极光 `AppKey`,不接触 `MasterSecret`。 +- 登录 IM 后将极光 alias 绑定为当前 `user_id`,例如 `usr...`;同一用户多设备允许绑定同一个 alias。 +- SDK 返回 `registration_id` 后调用 `PUT /v1/im/devices/{device_id}/push-token` 上报,`provider` 固定传 `jpush`。 +- 点击 Push 后按 `extras.type` 跳转,再按服务端数据补偿,不能只凭 Push payload 渲染最终状态。 + +服务端侧要求: + +- `jpush_master_secret` 只保存在后台配置 `im_jpush` 分组,IM Server 通过内部配置接口读取并调用极光 REST API。 +- 用户在线时优先 WebSocket 实时投递;用户离线、后台或不可投递时生成 Push 任务。 +- 万人大群实时 fanout 不靠 Push;离线只对需要提醒的成员生成极光 Push,客户端重连后按 Topic seq 拉取补偿。 +- Push payload 默认不带敏感正文,通知栏文案按用户隐私设置降级。 + +极光 Push extras 统一格式: + +```json +{ + "type": "im_message", + "tenant_code": "default", + "topic": "p2p...", + "seq": "128", + "sender_id": "usr_sender", + "message_type": "text" +} +``` + +| `extras.type` | 必填字段 | 客户端动作 | +| --- | --- | --- | +| `im_message` | `tenant_code,topic,seq` | 打开会话,按本地最后 seq 补齐消息并定位。 | +| `group_mention` | `tenant_code,topic,seq,sender_id` | 打开群会话并定位被 @ 消息。 | +| `call_invite` | `tenant_code,topic,seq,call_id,call_type,caller_id` | 打开来电页前先登录 IM 并确认通话仍为有效来电。 | +| `call_missed` | `tenant_code,topic,call_id` | 打开通话记录或对应会话。 | +| `system_notification` | `tenant_code,notification_id` | 打开系统消息列表并刷新详情。 | +| `friend_request` | `tenant_code,request_id` | 打开新朋友列表并刷新未处理数。 | +| `group_request` | `tenant_code,topic,request_id` | 打开群审批入口并刷新列表。 | + +普通离线消息极光 payload 示例: + +```json +{ + "platform": "all", + "audience": { + "alias": ["usr_target"] + }, + "notification": { + "alert": "你收到一条新消息", + "android": { + "title": "MeetingIM", + "alert": "你收到一条新消息", + "extras": { + "type": "im_message", + "tenant_code": "default", + "topic": "p2p...", + "seq": "128" + } + }, + "ios": { + "alert": { + "title": "MeetingIM", + "body": "你收到一条新消息" + }, + "sound": "default", + "badge": "+1", + "extras": { + "type": "im_message", + "tenant_code": "default", + "topic": "p2p...", + "seq": "128" + } + } + }, + "options": { + "time_to_live": 86400, + "apns_production": false, + "classification": 1 + } +} +``` + +音视频来电极光 payload 示例: + +```json +{ + "platform": "all", + "audience": { + "alias": ["usr_callee"] + }, + "notification": { + "alert": "收到一通语音通话", + "android": { + "title": "语音通话", + "alert": "张三邀请你语音通话", + "extras": { + "type": "call_invite", + "tenant_code": "default", + "topic": "p2p...", + "seq": "30", + "call_id": "call_01K...", + "call_type": "audio", + "caller_id": "usr_caller" + } + }, + "ios": { + "alert": { + "title": "语音通话", + "body": "张三邀请你语音通话" + }, + "sound": "default", + "badge": "+1", + "extras": { + "type": "call_invite", + "tenant_code": "default", + "topic": "p2p...", + "seq": "30", + "call_id": "call_01K...", + "call_type": "audio", + "caller_id": "usr_caller" + } + } + }, + "options": { + "time_to_live": 60, + "apns_production": false, + "classification": 1 + } +} +``` #### 0.5.5 Long Polling 与文件基础示例 diff --git a/docs/third/IM-极光推送前后端对接文档.md b/docs/third/IM-极光推送前后端对接文档.md new file mode 100644 index 0000000..693df9d --- /dev/null +++ b/docs/third/IM-极光推送前后端对接文档.md @@ -0,0 +1,317 @@ +# IM 极光推送前后端对接文档 + +更新时间:2026-09-04 + +## 1. 对接边界 + +极光 JPush 接入分三段: + +| 边界 | 责任 | +| --- | --- | +| 客户端和极光 SDK | 初始化 SDK、申请通知权限、获取 `registration_id`、绑定 alias、接收和点击 Push。 | +| 客户端和 IM Server | 登录后上报设备 Push Token,收到 Push 后按 `topic/seq/call_id` 回 IM 拉取补偿。 | +| IM Server 和极光 REST API | 保存 `master_secret`,生成 Push 任务,调用 `POST /v3/push`,记录 `msg_id` 和失败原因。 | + +客户端不需要知道 `master_secret`,也不直接调用极光 REST API。 + +## 2. 客户端接入 + +### 2.1 SDK 初始化 + +客户端按平台集成 JPush SDK: + +- Android:初始化 JPush SDK,创建通知 Channel,按厂商通道要求配置包名、签名、Channel ID。 +- iOS:配置 APNs 证书或 Token Authentication,申请通知权限。 +- HarmonyOS:按鸿蒙 SDK 文档初始化并配置通知分类。 + +客户端启动后取得 `registration_id`,登录 IM 后把 alias 绑定为当前 `user_id`。同一用户多设备允许绑定同一个 alias。 + +### 2.2 上报 Push Token + +```http +PUT /v1/im/devices/{device_id}/push-token +Authorization: Bearer +X-MeetingIM-APIKey: +X-IM-Schema: 1 +X-Tenant-Code: +X-Request-ID: +Idempotency-Key: +Content-Type: application/json +``` + +请求: + +```json +{ + "provider": "jpush", + "token": "registration_id", + "platform": "ios", + "app_version": "1.0.0", + "push_env": "test", + "sdk_version": "5.0.0", + "notification_permission": "granted" +} +``` + +响应 `data`: + +```json +{ + "device_id": "ios_device_1", + "provider": "jpush", + "platform": "ios", + "app_version": "1.0.0", + "updated_at": "2026-09-04T10:00:00Z" +} +``` + +字段说明: + +| 字段 | 必填 | 说明 | +| --- | --- | --- | +| `provider` | 是 | 固定传 `jpush`。后续保留 `fcm`、`apns` 等兼容值。 | +| `token` | 是 | 极光 SDK 返回的 `registration_id`。 | +| `platform` | 是 | `android`、`ios`、`hmos`。 | +| `app_version` | 否 | 客户端版本,用于排查。 | +| `push_env` | 否 | `test` 或 `production`,用于 iOS APNs 环境核对。 | +| `sdk_version` | 否 | JPush SDK 版本。 | +| `notification_permission` | 否 | `granted`、`denied`、`unknown`。 | + +### 2.3 点击 Push 后处理 + +客户端统一读取 `extras`: + +```json +{ + "type": "im_message", + "tenant_code": "default", + "topic": "p2p...", + "seq": "128", + "sender_id": "usr_sender", + "message_type": "text" +} +``` + +处理规则: + +1. 如果未登录,先进入登录或静默登录。 +2. 如果租户不一致,先切换到 `tenant_code`。 +3. 按 `type` 跳转目标页面。 +4. 按 `topic` 和本地游标拉取消息补偿。 +5. 按 `seq` 定位消息;若消息已撤回,展示撤回占位。 +6. 对通话类 Push,必须向 IM Server 查询当前通话状态,不能只凭 Push 打开通话中页面。 + +## 3. 后端接入 + +### 3.1 配置读取 + +IM Server 从后台配置读取 `im_jpush` 分组: + +```json +{ + "jpush_enabled": "true", + "jpush_app_key": "app_key", + "jpush_master_secret": "******", + "jpush_api_domain": "https://api.jpush.cn", + "jpush_apns_production": "false", + "jpush_default_ttl_seconds": "86400", + "jpush_call_ttl_seconds": "60", + "jpush_use_alias": "true", + "jpush_test_model": "false", + "jpush_classification": "1" +} +``` + +### 3.2 设备记录 + +设备表建议至少支持: + +| 字段 | 说明 | +| --- | --- | +| `user_id` | IM 用户 ID。 | +| `device_id` | 客户端设备 ID。 | +| `provider` | `jpush`。 | +| `push_token` | 极光 `registration_id`。 | +| `platform` | `android/ios/hmos`。 | +| `app_version` | App 版本。 | +| `push_env` | `test/production`。 | +| `enabled` | 是否有效。 | +| `last_seen_at` | 最近上报时间。 | +| `disabled_reason` | 禁用原因。 | + +同一 `user_id + device_id + provider` 覆盖更新;同一 token 换绑账号时,以最后一次登录上报为准并禁用旧绑定。 + +### 3.3 Push 任务 + +服务端生成任务时推荐数据结构: + +```json +{ + "task_id": "push_01K...", + "tenant_code": "default", + "target_user_id": "usr_target", + "push_type": "im_message", + "topic": "p2p...", + "seq": 128, + "call_id": "", + "dedupe_key": "default:usr_target:p2p...:128:im_message", + "status": "pending", + "retry_count": 0, + "next_retry_at": "2026-09-04T10:00:00Z" +} +``` + +任务落库或投 Kafka 后由 worker 发送。极光成功返回后记录 `msg_id`;失败按错误类型重试、跳过或禁用设备。 + +## 4. IM Server 调极光 + +### 4.1 请求 + +```http +POST https://api.jpush.cn/v3/push +Authorization: Basic base64(app_key + ":" + master_secret) +Content-Type: application/json +``` + +普通消息: + +```json +{ + "platform": "all", + "audience": { + "alias": ["usr_target"] + }, + "notification": { + "alert": "你收到一条新消息", + "android": { + "title": "MeetingIM", + "alert": "你收到一条新消息", + "extras": { + "type": "im_message", + "tenant_code": "default", + "topic": "p2p...", + "seq": "128" + } + }, + "ios": { + "alert": { + "title": "MeetingIM", + "body": "你收到一条新消息" + }, + "sound": "default", + "badge": "+1", + "extras": { + "type": "im_message", + "tenant_code": "default", + "topic": "p2p...", + "seq": "128" + } + } + }, + "options": { + "time_to_live": 86400, + "apns_production": false, + "classification": 1 + } +} +``` + +来电提醒: + +```json +{ + "platform": "all", + "audience": { + "alias": ["usr_callee"] + }, + "notification": { + "alert": "收到一通视频通话", + "android": { + "title": "视频通话", + "alert": "张三邀请你视频通话", + "extras": { + "type": "call_invite", + "tenant_code": "default", + "topic": "p2p...", + "seq": "30", + "call_id": "call_01K...", + "call_type": "video", + "caller_id": "usr_caller" + } + }, + "ios": { + "alert": { + "title": "视频通话", + "body": "张三邀请你视频通话" + }, + "sound": "default", + "badge": "+1", + "extras": { + "type": "call_invite", + "tenant_code": "default", + "topic": "p2p...", + "seq": "30", + "call_id": "call_01K...", + "call_type": "video", + "caller_id": "usr_caller" + } + } + }, + "options": { + "time_to_live": 60, + "apns_production": false, + "classification": 1 + } +} +``` + +### 4.2 响应处理 + +成功响应示例: + +```json +{ + "sendno": "0", + "msg_id": "18100287008546343" +} +``` + +处理规则: + +- HTTP 200 且返回 `msg_id`:任务置为 `sent`。 +- 网络超时、5xx、限流:任务置为 `retry_wait`,按退避重试。 +- 鉴权失败或配置错误:任务置为 `failed`,告警并停止继续重试同配置。 +- registration_id 无效:禁用对应设备 token。 +- 用户关闭通知权限:记录设备能力,后续可跳过普通提醒。 + +## 5. Push extras 规范 + +所有 extras 值建议使用字符串,避免不同平台 SDK 对数字类型解析不一致。 + +| 字段 | 必填 | 说明 | +| --- | --- | --- | +| `type` | 是 | `im_message`、`group_mention`、`call_invite`、`call_missed`、`system_notification`、`friend_request`、`group_request`。 | +| `tenant_code` | 是 | 租户编码。 | +| `topic` | 条件 | 会话或群 Topic。消息、群、通话类必填。 | +| `seq` | 条件 | Topic 消息序号。消息、群、通话类建议必填。 | +| `call_id` | 条件 | 通话类必填。 | +| `call_type` | 条件 | `audio` 或 `video`。 | +| `sender_id` | 否 | 消息发送人。 | +| `caller_id` | 条件 | 来电类必填。 | +| `notification_id` | 条件 | 系统通知类必填。 | +| `route` | 否 | 客户端白名单路由,不允许外部 URL。 | + +## 6. 前后端联调清单 + +| 步骤 | 操作 | 验证 | +| --- | --- | --- | +| 1 | 客户端初始化 JPush SDK | 能拿到 `registration_id`。 | +| 2 | 登录 IM 并绑定 alias=`user_id` | 极光控制台能查到别名或设备。 | +| 3 | 调用设备上报接口 | 服务端设备记录 provider=`jpush`。 | +| 4 | 目标用户退出或断网 | IM 判断为离线或不可 WS 投递。 | +| 5 | 发送 P2P 消息 | 离线用户收到 Push。 | +| 6 | 点击 Push | App 打开对应会话并补齐 `seq`。 | +| 7 | 发起语音/视频通话 | 离线被叫收到短 TTL 来电 Push。 | +| 8 | 通话取消后点击旧 Push | 客户端查询后显示已取消或未接,不进入通话中。 | +| 9 | 关闭会话免打扰 | 普通消息不发 Push,@提醒按策略执行。 | +| 10 | 极光返回无效 token | 后端禁用设备 token,重新上报后恢复。 | diff --git a/docs/third/IM-极光推送完整调用流程.md b/docs/third/IM-极光推送完整调用流程.md new file mode 100644 index 0000000..7501147 --- /dev/null +++ b/docs/third/IM-极光推送完整调用流程.md @@ -0,0 +1,368 @@ +# IM 极光推送完整调用流程 + +更新时间:2026-09-04 + +## 1. 流程目标 + +本文用于说明 IM 系统接入极光 JPush 后,离线消息、离线来电、系统通知和业务红点提醒从客户端注册设备、后端保存设备、服务端生成 Push 任务、调用极光 REST API 到客户端点击补偿拉取的完整链路。 + +官方参考: + +- 极光快速开始:https://docs.jiguang.cn/jpush/quickstart/ +- 极光 REST API 概述:https://docs.jiguang.cn/jpush/server/push/server_overview +- 极光创建推送 API:https://docs.jiguang.cn/jpush/server/push/rest_api_v3_push + +## 2. 整体链路 + +```text +后台配置极光参数 +-> 客户端集成 JPush SDK +-> 客户端获得 registration_id +-> 客户端登录 IM 后上报设备 Push Token +-> IM Server 保存 user_id/device_id/provider/registration_id/platform +-> 用户离线或目标设备不在线 +-> IM Server 生成离线 Push 任务 +-> Push worker 调用极光 POST /v3/push +-> 极光下发通知到 Android/iOS/HarmonyOS +-> 用户点击通知打开 App +-> 客户端按 extras 中的 topic/seq/call_id 跳转并补偿拉取 +``` + +## 3. 后台配置流程 + +配置建议存放在 HotGo 后台 `hg_sys_config`,分组建议使用 `im_jpush`。IM Server 通过 Admin 内部配置接口读取并缓存,配置变更后通过 Kafka 配置事件或进程内缓存过期生效。 + +| 配置项 | 示例 | 说明 | +| --- | --- | --- | +| `jpush_enabled` | `true` | 是否启用极光推送。 | +| `jpush_app_key` | `xxxxxxxx` | 极光应用 AppKey。客户端 SDK 初始化也会用到,允许给客户端配置使用。 | +| `jpush_master_secret` | `******` | 极光 REST API 密钥,只能后端保存和使用,不允许返回客户端。 | +| `jpush_api_domain` | `https://api.jpush.cn` | 极光 REST API 域名。 | +| `jpush_push_path` | `/v3/push` | 创建推送 API 路径。 | +| `jpush_apns_production` | `false/true` | iOS APNs 环境,测试包用 false,生产包用 true。 | +| `jpush_default_ttl_seconds` | `86400` | 离线 Push 默认保留时长。 | +| `jpush_call_ttl_seconds` | `60` | 来电 Push 保留时长,建议短一些。 | +| `jpush_use_alias` | `true` | 是否优先使用 alias=user_id 投递。 | +| `jpush_test_model` | `false` | 极光测试模式,仅联调测试设备时开启。 | +| `jpush_classification` | `1` | 重要业务通知建议按系统消息分类处理,需结合厂商通道规则确认。 | +| `jpush_log_payload` | `false` | 生产关闭 payload 明文日志,避免敏感信息进入日志。 | + +## 4. 客户端初始化流程 + +### 4.1 获取极光配置 + +客户端安装包通常内置对应环境的 `AppKey`,也可以从 App 配置接口获取非敏感配置。`MasterSecret` 不允许下发。 + +客户端需要完成: + +1. 集成 JPush SDK。 +2. 初始化 SDK 使用 `jpush_app_key`。 +3. 申请系统通知权限。 +4. 取得 `registration_id`。 +5. 登录 IM 后绑定 alias,建议 alias 使用当前 IM `user_id`,例如 `usr...`。 +6. 调用 IM 后端设备接口上报 Push Token。 + +### 4.2 设备上报 + +```http +PUT /v1/im/devices/{device_id}/push-token +Authorization: Bearer +X-MeetingIM-APIKey: +X-IM-Schema: 1 +X-Tenant-Code: +X-Request-ID: +Idempotency-Key: +Content-Type: application/json +``` + +请求 Body: + +```json +{ + "provider": "jpush", + "token": "registration_id_from_jpush", + "platform": "android", + "app_version": "1.0.0", + "push_env": "test", + "sdk_version": "5.0.0", + "notification_permission": "granted" +} +``` + +成功响应: + +```json +{ + "code": 0, + "data": { + "device_id": "device_1", + "provider": "jpush", + "platform": "android", + "app_version": "1.0.0", + "updated_at": "2026-09-04T10:00:00Z" + }, + "request_id": "req_01K...", + "server_time": "2026-09-04T10:00:00Z" +} +``` + +### 4.3 设备解绑 + +退出登录、删除设备、切换账号或 SDK 返回 registration_id 失效时,客户端应调用设备解绑接口或覆盖上报为空状态。后端要把该设备 token 标记为 disabled,避免继续推送到错误账号。 + +## 5. 服务端 Push 任务生成 + +### 5.1 触发场景 + +| 场景 | 触发条件 | Push 目标 | +| --- | --- | --- | +| P2P 离线消息 | 接收方当前无在线可投递设备,或需要离线提醒 | 接收方 user_id。 | +| 群离线消息 | 成员离线且未免打扰,按 Topic seq 补偿 | 被提醒成员 user_id,不对万人群逐条广播无差别 Push。 | +| @提醒 | 被 @ 用户离线或后台 | 被 @ 用户 user_id,高优先级。 | +| 好友申请/审批 | 目标用户离线 | 目标用户 user_id。 | +| 群申请/邀请/审批 | 审批人或目标成员离线 | 对应用户 user_id。 | +| 系统通知 | 已写入系统通知表且用户离线 | 通知目标 user_id。 | +| 语音/视频来电 | 被叫方当前无在线可响铃设备或 App 后台 | 被叫 user_id,短 TTL。 | +| 未接来电 | 通话超时、取消或未接 | 被叫 user_id。 | + +### 5.2 在线与离线判断 + +在线实时消息仍走 WebSocket。Push 不是消息正文真源,只负责提醒。客户端重连后必须按 Topic `seq` 或业务 ID 从 IM Server 拉取补偿,不能只依赖 Push payload 展示最终状态。 + +推荐判断: + +- 当前用户存在可投递在线会话:优先 WS,下发 Push 可按用户设置降级或不发。 +- 用户全离线:生成 Push 任务。 +- 用户在线但 App 后台状态不可判定:可生成低干扰 Push,客户端本地去重。 +- 群万人场景:实时 fanout 走 Redis/Kafka/Topic 路由,离线只对需要提醒的成员生成 Push。 + +### 5.3 任务状态机 + +```text +pending -> sending -> sent +pending -> sending -> retry_wait -> sending +pending -> sending -> failed +pending -> skipped +``` + +| 状态 | 说明 | +| --- | --- | +| `pending` | 已生成,等待 worker 发送。 | +| `sending` | worker 已领取,正在请求极光。 | +| `sent` | 极光 API 返回成功并记录 `msg_id`。 | +| `retry_wait` | 临时错误,等待退避重试。 | +| `failed` | 永久失败或超过最大重试次数。 | +| `skipped` | 用户免打扰、无有效设备、消息已过期或策略不允许发送。 | + +任务需保存 `dedupe_key`,例如 `tenant_code:user_id:topic:seq:push_type`,避免重复生成离线提醒。 + +## 6. 服务端调用极光 REST API + +极光创建推送 API: + +```http +POST https://api.jpush.cn/v3/push +Authorization: Basic base64(app_key + ":" + master_secret) +Content-Type: application/json +``` + +官方说明中,创建推送 API 使用 HTTPS、HTTP Basic Authentication 和 JSON 请求体;推送目标支持 `alias`、`registration_id`、`tag` 等方式,`alias` 单次最多 1000 个,`registration_id` 单次最多 1000 个;返回成功时包含 `sendno` 和 `msg_id`。 + +### 6.1 普通离线消息 Push + +```json +{ + "platform": ["android", "ios", "hmos"], + "audience": { + "alias": ["usr_target"] + }, + "notification": { + "alert": "你收到一条新消息", + "android": { + "title": "MeetingIM", + "alert": "你收到一条新消息", + "extras": { + "type": "im_message", + "tenant_code": "default", + "topic": "p2p...", + "seq": "128", + "sender_id": "usr_sender", + "message_type": "text" + } + }, + "ios": { + "alert": { + "title": "MeetingIM", + "body": "你收到一条新消息" + }, + "sound": "default", + "badge": "+1", + "extras": { + "type": "im_message", + "tenant_code": "default", + "topic": "p2p...", + "seq": "128", + "sender_id": "usr_sender", + "message_type": "text" + } + }, + "hmos": { + "title": "MeetingIM", + "alert": "你收到一条新消息", + "category": "IM", + "extras": { + "type": "im_message", + "tenant_code": "default", + "topic": "p2p...", + "seq": "128", + "sender_id": "usr_sender", + "message_type": "text" + } + } + }, + "options": { + "time_to_live": 86400, + "apns_production": false, + "classification": 1 + } +} +``` + +### 6.2 群消息 Push + +群消息 Push 只提醒,不携带完整消息正文。万人群不建议按群全员同步投递,只对离线且需要提醒的人生成任务,例如被 @、特别关注、管理员公告、未免打扰小群等。 + +```json +{ + "platform": "all", + "audience": { + "alias": ["usr_mentioned"] + }, + "notification": { + "alert": "群里有人提到了你", + "android": { + "title": "项目群", + "alert": "群里有人提到了你", + "extras": { + "type": "group_mention", + "tenant_code": "default", + "topic": "grp...", + "seq": "56001", + "sender_id": "usr_sender", + "mention": "1" + } + }, + "ios": { + "alert": { + "title": "项目群", + "body": "群里有人提到了你" + }, + "badge": "+1", + "sound": "default", + "extras": { + "type": "group_mention", + "tenant_code": "default", + "topic": "grp...", + "seq": "56001", + "sender_id": "usr_sender", + "mention": "1" + } + } + }, + "options": { + "time_to_live": 86400, + "apns_production": false, + "classification": 1 + } +} +``` + +### 6.3 来电 Push + +语音/视频来电需要短 TTL。普通离线 Push 只做提醒;如后续要实现系统级强提醒、VoIP 样式或厂商 VoIP,需要额外开通并按平台单独配置。 + +```json +{ + "platform": "all", + "audience": { + "alias": ["usr_callee"] + }, + "notification": { + "alert": "收到一通语音通话", + "android": { + "title": "语音通话", + "alert": "张三邀请你语音通话", + "extras": { + "type": "call_invite", + "tenant_code": "default", + "topic": "p2p...", + "seq": "30", + "call_id": "call_01K...", + "call_type": "audio", + "caller_id": "usr_caller" + } + }, + "ios": { + "alert": { + "title": "语音通话", + "body": "张三邀请你语音通话" + }, + "sound": "default", + "badge": "+1", + "extras": { + "type": "call_invite", + "tenant_code": "default", + "topic": "p2p...", + "seq": "30", + "call_id": "call_01K...", + "call_type": "audio", + "caller_id": "usr_caller" + } + } + }, + "options": { + "time_to_live": 60, + "apns_production": false, + "classification": 1 + } +} +``` + +## 7. 客户端点击与补偿拉取 + +客户端收到或点击 Push 后按 `extras.type` 分发: + +| `extras.type` | 客户端动作 | +| --- | --- | +| `im_message` | 打开 `topic` 对应会话,按本地最后 `seq + 1` 拉取消息。 | +| `group_mention` | 打开群会话并定位到 `seq`,若本地无该消息先拉取。 | +| `call_invite` | 如果通话仍有效,打开来电页;同时通过 IM 登录和会话补偿确认通话状态。 | +| `call_missed` | 打开通话记录或对应会话。 | +| `system_notification` | 打开系统消息列表或 `action` 指定页面,再调用 HTTP 拉取详情。 | +| `friend_request` | 打开新朋友列表并刷新未处理数。 | +| `group_request` | 打开群审批入口并刷新列表。 | + +Push 中的 `topic`、`seq`、`call_id` 只用于定位和补偿,不作为业务真源。客户端必须重新登录 IM 并拉取服务端数据确认状态,例如通话是否已取消、消息是否已撤回、系统通知是否已读。 + +## 8. 隐私与安全 + +1. `jpush_master_secret` 只在后端配置和请求极光时使用,不返回客户端。 +2. Push 通知栏默认不展示敏感正文,可按用户隐私设置决定是否展示摘要。 +3. `extras` 不放身份证号、手机号、文件直链、完整消息正文、审核原因明细等敏感数据。 +4. 日志只记录 `task_id/user_id/provider/status/msg_id/error_code`,不记录完整 Push payload。 +5. 推送失败后按错误类型禁用无效 registration_id,避免持续打失败请求。 +6. 多租户场景下 `tenant_code` 必须进入任务、payload、dedupe_key 和日志,禁止跨租户复用设备 token。 + +## 9. 联调验收 + +| 验收项 | 期望结果 | +| --- | --- | +| 客户端初始化 | SDK 返回 registration_id,alias 绑定为当前 `usr...`。 | +| 设备上报 | `PUT /v1/im/devices/{device_id}/push-token` 返回成功,服务端记录 provider=`jpush`。 | +| 普通离线消息 | 用户离线后收到 Push,点击后进入会话并补齐 Topic seq。 | +| 群 @ 提醒 | 被 @ 用户离线时只收到一次提醒,重连后定位到对应消息。 | +| 语音来电 | 被叫离线时收到短 TTL 来电 Push,通话取消后点击不会进入过期通话。 | +| 免打扰 | 免打扰会话不产生普通 Push,@ 或强提醒按策略例外。 | +| 幂等重试 | 同一 `dedupe_key` 不重复发,极光临时失败可重试。 | +| 无效设备 | 极光返回无效 token 后设备记录被禁用或待清理。 | +| iOS 环境 | 测试包 `apns_production=false`,生产包为 true。 | diff --git a/server/db/mysql/tenant_adapter.go b/server/db/mysql/tenant_adapter.go index d1bb98d..682ae6e 100644 --- a/server/db/mysql/tenant_adapter.go +++ b/server/db/mysql/tenant_adapter.go @@ -5853,15 +5853,36 @@ func (a *adapter) TenantDevicePushTokenUpsert(id t.TenantID, token *t.DevicePush } func (a *adapter) TenantDevicePushTokenGetAll(id t.TenantID, uids ...t.Uid) (map[t.Uid][]t.DeviceDef, int, error) { + return a.tenantDevicePushTokenGetAllByProviders(id, []string{"fcm", "firebase"}, uids...) +} + +func (a *adapter) TenantDevicePushTokenGetAllByProvider(id t.TenantID, provider string, uids ...t.Uid) (map[t.Uid][]t.DeviceDef, int, error) { + provider = strings.ToLower(strings.TrimSpace(provider)) + if provider == "" { + return map[t.Uid][]t.DeviceDef{}, 0, nil + } + return a.tenantDevicePushTokenGetAllByProviders(id, []string{provider}, uids...) +} + +func (a *adapter) tenantDevicePushTokenGetAllByProviders(id t.TenantID, providers []string, uids ...t.Uid) (map[t.Uid][]t.DeviceDef, int, error) { if !a.tenantScopedDB { tenantAdp, err := a.routedTenantAdapter(id) if err != nil { return nil, 0, err } if tenantAdp != a { - return tenantAdp.TenantDevicePushTokenGetAll(id, uids...) + return tenantAdp.tenantDevicePushTokenGetAllByProviders(id, providers, uids...) } } + normalizedProviders := make([]any, 0, len(providers)) + for _, provider := range providers { + if provider = strings.ToLower(strings.TrimSpace(provider)); provider != "" { + normalizedProviders = append(normalizedProviders, provider) + } + } + if len(normalizedProviders) == 0 { + return map[t.Uid][]t.DeviceDef{}, 0, nil + } decoded := make([]any, 0, len(uids)) for _, uid := range uids { if !uid.IsZero() { @@ -5872,7 +5893,7 @@ func (a *adapter) TenantDevicePushTokenGetAll(id t.TenantID, uids ...t.Uid) (map return map[t.Uid][]t.DeviceDef{}, 0, nil } query, args, err := sqlx.In(`SELECT user_id,token,platform,updated_at FROM im_device_push_tokens - WHERE provider IN ('fcm','firebase') AND user_id IN (?)`, decoded) + WHERE provider IN (?) AND user_id IN (?)`, normalizedProviders, decoded) if err != nil { return nil, 0, err } diff --git a/server/main.go b/server/main.go index 96cf30d..a28fb18 100644 --- a/server/main.go +++ b/server/main.go @@ -46,6 +46,7 @@ import ( // Push notifications "github.com/tinode/chat/server/push" _ "github.com/tinode/chat/server/push/fcm" + _ "github.com/tinode/chat/server/push/jpush" _ "github.com/tinode/chat/server/push/stdout" _ "github.com/tinode/chat/server/push/tnpg" diff --git a/server/meetingim.conf b/server/meetingim.conf index 1701406..60711bc 100644 --- a/server/meetingim.conf +++ b/server/meetingim.conf @@ -207,6 +207,19 @@ "config": { "enabled": false } + }, + { + "name": "jpush", + "config": { + "enabled": false, + "api_domain": "https://api.jpush.cn", + "push_path": "/v3/push", + "default_ttl_seconds": 86400, + "call_ttl_seconds": 60, + "apns_production": false, + "use_alias": true, + "classification": 1 + } } ], diff --git a/server/push/jpush/push_jpush.go b/server/push/jpush/push_jpush.go new file mode 100644 index 0000000..3dd83d6 --- /dev/null +++ b/server/push/jpush/push_jpush.go @@ -0,0 +1,617 @@ +// Package jpush implements push notification plugin for JPush. +package jpush + +import ( + "bytes" + "encoding/base64" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "os" + "strconv" + "strings" + "time" + + "github.com/tinode/chat/server/logs" + "github.com/tinode/chat/server/push" + "github.com/tinode/chat/server/store" + t "github.com/tinode/chat/server/store/types" +) + +const ( + defaultBufferSize = 1024 + defaultAPIDomain = "https://api.jpush.cn" + defaultPushPath = "/v3/push" + defaultTTL = 86400 + defaultCallTTL = 60 +) + +var handler Handler + +type Handler struct { + input chan *push.Receipt + channel chan *push.ChannelReq + stop chan bool + client *http.Client + config configType +} + +type configType struct { + Enabled bool `json:"enabled"` + DryRun bool `json:"dry_run"` + AppKey string `json:"app_key"` + MasterSecret string `json:"master_secret"` + APIDomain string `json:"api_domain"` + PushPath string `json:"push_path"` + Buffer int `json:"buffer"` + DefaultTTLSeconds int `json:"default_ttl_seconds"` + CallTTLSeconds int `json:"call_ttl_seconds"` + APNsProduction bool `json:"apns_production"` + UseAlias *bool `json:"use_alias"` + Classification int `json:"classification"` + Title string `json:"title"` +} + +type jpushPayload struct { + Platform any `json:"platform"` + Audience map[string][]string `json:"audience,omitempty"` + Notification *jpushNotification `json:"notification,omitempty"` + Message *jpushMessage `json:"message,omitempty"` + Options jpushOptions `json:"options"` + CID string `json:"cid,omitempty"` +} + +type jpushNotification struct { + Alert string `json:"alert,omitempty"` + Android *androidPayload `json:"android,omitempty"` + IOS *iosPayload `json:"ios,omitempty"` + HMOS *hmosPayload `json:"hmos,omitempty"` +} + +type androidPayload struct { + Alert string `json:"alert"` + Title string `json:"title,omitempty"` + Extras map[string]string `json:"extras,omitempty"` + Priority int `json:"priority,omitempty"` +} + +type iosPayload struct { + Alert iosAlert `json:"alert"` + Sound string `json:"sound,omitempty"` + Badge string `json:"badge,omitempty"` + Extras map[string]string `json:"extras,omitempty"` +} + +type iosAlert struct { + Title string `json:"title,omitempty"` + Body string `json:"body,omitempty"` +} + +type hmosPayload struct { + Alert string `json:"alert"` + Title string `json:"title,omitempty"` + Category string `json:"category,omitempty"` + Extras map[string]string `json:"extras,omitempty"` +} + +type jpushMessage struct { + MsgContent string `json:"msg_content"` + Title string `json:"title,omitempty"` + Extras map[string]string `json:"extras,omitempty"` +} + +type jpushOptions struct { + TimeToLive int `json:"time_to_live"` + APNsProduction bool `json:"apns_production"` + Classification int `json:"classification,omitempty"` +} + +type jpushResponse struct { + SendNo any `json:"sendno"` + MsgID any `json:"msg_id"` + Error *struct { + Code int `json:"code"` + Message string `json:"message"` + } `json:"error,omitempty"` +} + +func (Handler) Init(jsonconf json.RawMessage) (bool, error) { + if handler.input != nil { + return false, errors.New("already initialized") + } + var config configType + if err := json.Unmarshal(jsonconf, &config); err != nil { + return false, errors.New("failed to parse config: " + err.Error()) + } + loadThirdPartyConfig(&config) + if v := strings.TrimSpace(os.Getenv("IM_JPUSH_APP_KEY")); v != "" { + config.AppKey = v + config.Enabled = true + } + if v := strings.TrimSpace(os.Getenv("IM_JPUSH_MASTER_SECRET")); v != "" { + config.MasterSecret = v + config.Enabled = true + } + if v := strings.TrimSpace(os.Getenv("IM_JPUSH_API_DOMAIN")); v != "" { + config.APIDomain = v + } + if !config.Enabled { + return false, nil + } + normalized, err := normalizeConfig(config) + if err != nil { + return false, err + } + handler.input = make(chan *push.Receipt, normalized.Buffer) + handler.channel = make(chan *push.ChannelReq, normalized.Buffer) + handler.stop = make(chan bool, 1) + handler.client = &http.Client{Timeout: 10 * time.Second} + handler.config = normalized + + go func() { + for { + select { + case rcpt := <-handler.input: + go sendReceipt(rcpt, &handler.config, handler.client) + case req := <-handler.channel: + logs.Info.Println("jpush: channel subscription ignored", req.Uid.UserId(), req.Channel) + case <-handler.stop: + return + } + } + }() + return true, nil +} + +func normalizeConfig(config configType) (configType, error) { + config.AppKey = strings.TrimSpace(config.AppKey) + config.MasterSecret = strings.TrimSpace(config.MasterSecret) + config.APIDomain = strings.TrimRight(strings.TrimSpace(config.APIDomain), "/") + config.PushPath = strings.TrimSpace(config.PushPath) + config.Title = strings.TrimSpace(config.Title) + if config.APIDomain == "" { + config.APIDomain = defaultAPIDomain + } + if config.PushPath == "" { + config.PushPath = defaultPushPath + } + if !strings.HasPrefix(config.PushPath, "/") { + config.PushPath = "/" + config.PushPath + } + if config.Buffer <= 0 { + config.Buffer = defaultBufferSize + } + if config.DefaultTTLSeconds <= 0 { + config.DefaultTTLSeconds = defaultTTL + } + if config.CallTTLSeconds <= 0 { + config.CallTTLSeconds = defaultCallTTL + } + if config.Title == "" { + config.Title = "MeetingIM" + } + if config.AppKey == "" || config.MasterSecret == "" { + return config, errors.New("jpush: missing app_key or master_secret") + } + return config, nil +} + +func sendReceipt(rcpt *push.Receipt, config *configType, client *http.Client) { + payloads, uids := preparePayloads(rcpt, config) + for i := range payloads { + if config.DryRun { + logs.Info.Println("jpush dry run:", uids[i].UserId(), payloads[i].CID) + continue + } + if err := sendPayload(payloads[i], config, client); err != nil { + logs.Warn.Println("jpush push failed:", uids[i].UserId(), err) + return + } + } +} + +func sendPayload(payload *jpushPayload, config *configType, client *http.Client) error { + body, err := json.Marshal(payload) + if err != nil { + return err + } + req, err := http.NewRequest(http.MethodPost, config.APIDomain+config.PushPath, bytes.NewReader(body)) + if err != nil { + return err + } + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", "Basic "+base64.StdEncoding.EncodeToString([]byte(config.AppKey+":"+config.MasterSecret))) + resp, err := client.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + respBody, _ := io.ReadAll(io.LimitReader(resp.Body, 4096)) + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return fmt.Errorf("http %d: %s", resp.StatusCode, strings.TrimSpace(string(respBody))) + } + var parsed jpushResponse + if len(respBody) > 0 { + if err = json.Unmarshal(respBody, &parsed); err != nil { + return fmt.Errorf("decode response: %w", err) + } + } + if parsed.Error != nil { + return fmt.Errorf("jpush error %d: %s", parsed.Error.Code, parsed.Error.Message) + } + if parsed.MsgID != nil && logs.Info != nil { + logs.Info.Println("jpush sent:", parsed.MsgID) + } + return nil +} + +func loadThirdPartyConfig(config *configType) { + if config == nil || store.ThirdPartyConfig == nil { + return + } + values, err := store.ThirdPartyConfig.Get("im_jpush", + "jpush_enabled", + "jpush_app_key", + "jpush_master_secret", + "jpush_api_domain", + "jpush_push_path", + "jpush_default_ttl_seconds", + "jpush_call_ttl_seconds", + "jpush_apns_production", + "jpush_use_alias", + "jpush_classification", + "jpush_title", + ) + if err != nil || len(values) == 0 { + return + } + if v, ok := parseConfigBool(values["jpush_enabled"]); ok { + config.Enabled = v + } + if v := strings.TrimSpace(values["jpush_app_key"]); v != "" { + config.AppKey = v + } + if v := strings.TrimSpace(values["jpush_master_secret"]); v != "" { + config.MasterSecret = v + } + if v := strings.TrimSpace(values["jpush_api_domain"]); v != "" { + config.APIDomain = v + } + if v := strings.TrimSpace(values["jpush_push_path"]); v != "" { + config.PushPath = v + } + if v, ok := parseConfigInt(values["jpush_default_ttl_seconds"]); ok { + config.DefaultTTLSeconds = v + } + if v, ok := parseConfigInt(values["jpush_call_ttl_seconds"]); ok { + config.CallTTLSeconds = v + } + if v, ok := parseConfigBool(values["jpush_apns_production"]); ok { + config.APNsProduction = v + } + if v, ok := parseConfigBool(values["jpush_use_alias"]); ok { + config.UseAlias = &v + } + if v, ok := parseConfigInt(values["jpush_classification"]); ok { + config.Classification = v + } + if v := strings.TrimSpace(values["jpush_title"]); v != "" { + config.Title = v + } +} + +func parseConfigBool(value string) (bool, bool) { + switch strings.ToLower(strings.TrimSpace(value)) { + case "1", "true", "yes", "on", "enabled": + return true, true + case "0", "false", "no", "off", "disabled": + return false, true + default: + return false, false + } +} + +func parseConfigInt(value string) (int, bool) { + parsed, err := strconv.Atoi(strings.TrimSpace(value)) + return parsed, err == nil +} + +func preparePayloads(rcpt *push.Receipt, config *configType) ([]*jpushPayload, []t.Uid) { + if rcpt == nil || config == nil { + return nil, nil + } + data, err := pushData(rcpt.Payload) + if err != nil { + logs.Warn.Println("jpush: could not build payload:", err) + return nil, nil + } + useAlias := true + if config.UseAlias != nil { + useAlias = *config.UseAlias + } + uids := sortedRecipients(rcpt.To) + if len(uids) == 0 { + return nil, nil + } + var devices map[t.Uid][]t.DeviceDef + if !useAlias { + devices, _, err = providerDevicesForUsers(rcpt.TenantID, "jpush", uids...) + if err != nil { + logs.Warn.Println("jpush: db error", err) + return nil, nil + } + } + var payloads []*jpushPayload + var targets []t.Uid + for _, uid := range uids { + recipient := rcpt.To[uid] + if recipient.Delivered > 0 { + continue + } + userData := cloneData(data) + topic := rcpt.Payload.Topic + if t.GetTopicCat(topic) == t.TopicCatP2P { + if alias, err := t.P2PNameForUser(uid, topic); err == nil { + topic = alias + userData["topic"] = alias + } + } + var audience map[string][]string + if useAlias { + audience = map[string][]string{"alias": {uid.UserId()}} + } else { + var registrationIDs []string + for _, device := range devices[uid] { + if device.DeviceId != "" && !containsString(recipient.Devices, device.DeviceId) { + registrationIDs = append(registrationIDs, device.DeviceId) + } + } + if len(registrationIDs) == 0 { + continue + } + audience = map[string][]string{"registration_id": registrationIDs} + } + payloads = append(payloads, buildPayload(audience, topic, userData, recipient.Unread, config)) + targets = append(targets, uid) + } + return payloads, targets +} + +func pushData(pl push.Payload) (map[string]string, error) { + data := map[string]string{ + "what": pl.What, + "type": "im_message", + "topic": pl.Topic, + "ts": pl.Timestamp.Format(time.RFC3339Nano), + } + if pl.From != "" { + data["sender_id"] = pl.From + data["xfrom"] = pl.From + } + if pl.Silent { + data["silent"] = "true" + } + switch pl.What { + case push.ActMsg: + data["seq"] = strconv.FormatInt(pl.SeqId, 10) + if pl.ContentType != "" { + data["mime"] = pl.ContentType + data["message_type"] = messageTypeFromMime(pl.ContentType) + } + if text := payloadContentString(pl.Content, "text"); text != "" { + data["content"] = truncateRunes(text, push.MaxPayloadLength) + } + if pl.Webrtc != "" { + data["webrtc"] = pl.Webrtc + if callID := payloadContentString(pl.Content, "callId"); callID != "" { + data["call_id"] = callID + } + if callType := payloadContentString(pl.Content, "callType"); callType == "audio" || callType == "video" { + data["call_type"] = callType + } + data["type"] = "call_invite" + if pl.Webrtc != "started" { + data["type"] = "call_missed" + } + if pl.AudioOnly || data["call_type"] == "audio" { + data["call_type"] = "audio" + data["aonly"] = "true" + } else if data["call_type"] == "" { + data["call_type"] = "video" + } + if pl.From != "" { + data["caller_id"] = pl.From + } + } + if pl.Replace != "" { + data["replace"] = pl.Replace + data["silent"] = "true" + } + case push.ActSub: + data["type"] = "system_notification" + data["modeWant"] = pl.ModeWant.String() + data["modeGiven"] = pl.ModeGiven.String() + case push.ActRead: + data["type"] = "read" + data["seq"] = strconv.FormatInt(pl.SeqId, 10) + data["silent"] = "true" + default: + return nil, errors.New("unknown push type") + } + return data, nil +} + +func buildPayload(audience map[string][]string, topic string, data map[string]string, unread int, config *configType) *jpushPayload { + alert, title := alertText(data, config) + ttl := config.DefaultTTLSeconds + if data["type"] == "call_invite" { + ttl = config.CallTTLSeconds + } + payload := &jpushPayload{ + Platform: "all", + Audience: audience, + Options: jpushOptions{ + TimeToLive: ttl, + APNsProduction: config.APNsProduction, + Classification: config.Classification, + }, + CID: cidFor(topic, data), + } + if data["silent"] == "true" || data["type"] == "read" { + payload.Message = &jpushMessage{MsgContent: alert, Title: title, Extras: data} + return payload + } + badge := "+1" + if unread > 0 { + badge = strconv.Itoa(unread) + } + payload.Notification = &jpushNotification{ + Alert: alert, + Android: &androidPayload{ + Title: title, + Alert: alert, + Extras: data, + Priority: 1, + }, + IOS: &iosPayload{ + Alert: iosAlert{Title: title, Body: alert}, + Sound: "default", + Badge: badge, + Extras: data, + }, + HMOS: &hmosPayload{ + Title: title, + Alert: alert, + Category: "IM", + Extras: data, + }, + } + return payload +} + +func alertText(data map[string]string, config *configType) (string, string) { + title := config.Title + switch data["type"] { + case "call_invite": + if data["call_type"] == "audio" { + return "收到一通语音通话", "语音通话" + } + return "收到一通视频通话", "视频通话" + case "call_missed": + return "你有一通未接来电", "未接来电" + case "system_notification": + return "你收到一条系统通知", title + default: + return "你收到一条新消息", title + } +} + +func cidFor(topic string, data map[string]string) string { + parts := []string{data["type"], topic, data["seq"], data["replace"]} + return strings.Trim(strings.Join(parts, "_"), "_") +} + +func messageTypeFromMime(mime string) string { + switch { + case strings.Contains(mime, "image"): + return "image" + case strings.Contains(mime, "audio"): + return "voice" + case strings.Contains(mime, "video"): + return "video" + default: + return "text" + } +} + +func payloadContentString(content any, key string) string { + if content == nil { + return "" + } + if values, ok := content.(map[string]any); ok { + if value, ok := values[key].(string); ok { + return strings.TrimSpace(value) + } + } + if values, ok := content.(map[string]string); ok { + return strings.TrimSpace(values[key]) + } + return "" +} + +func truncateRunes(value string, limit int) string { + if limit <= 0 || len(value) <= limit { + return value + } + runes := []rune(value) + if len(runes) <= limit { + return value + } + return string(runes[:limit]) + "…" +} + +func sortedRecipients(recipients map[t.Uid]push.Recipient) []t.Uid { + uids := make([]t.Uid, 0, len(recipients)) + for uid := range recipients { + if !uid.IsZero() { + uids = append(uids, uid) + } + } + for i := 1; i < len(uids); i++ { + for j := i; j > 0 && uids[j].String() < uids[j-1].String(); j-- { + uids[j], uids[j-1] = uids[j-1], uids[j] + } + } + return uids +} + +func cloneData(src map[string]string) map[string]string { + dst := make(map[string]string, len(src)) + for k, v := range src { + dst[k] = v + } + return dst +} + +func containsString(values []string, target string) bool { + for _, value := range values { + if value == target { + return true + } + } + return false +} + +func providerDevicesForUsers(tenantID t.TenantID, provider string, uids ...t.Uid) (map[t.Uid][]t.DeviceDef, int, error) { + if tokens, ok := store.DevicePushTokens.(store.DevicePushTokenProviderQueryInterface); ok { + return tokens.GetAllByProvider(tenantID, provider, uids...) + } + return map[t.Uid][]t.DeviceDef{}, 0, nil +} + +func (Handler) IsReady() bool { + return handler.input != nil +} + +func (Handler) Push() chan<- *push.Receipt { + return handler.input +} + +func (Handler) Channel() chan<- *push.ChannelReq { + return handler.channel +} + +func (Handler) Stop() { + if handler.stop != nil { + handler.stop <- true + } + handler.input = nil + handler.channel = nil + handler.stop = nil +} + +func init() { + push.Register("jpush", &handler) +} diff --git a/server/push/jpush/push_jpush_test.go b/server/push/jpush/push_jpush_test.go new file mode 100644 index 0000000..799e544 --- /dev/null +++ b/server/push/jpush/push_jpush_test.go @@ -0,0 +1,209 @@ +package jpush + +import ( + "encoding/base64" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/tinode/chat/server/push" + "github.com/tinode/chat/server/store" + "github.com/tinode/chat/server/store/types" +) + +type testThirdPartyConfigStore map[string]string + +func (s testThirdPartyConfigStore) Get(_ string, keys ...string) (map[string]string, error) { + result := make(map[string]string, len(keys)) + for _, key := range keys { + if value, ok := s[key]; ok { + result[key] = value + } + } + return result, nil +} + +type testDevicePushTokenStore struct { + devices map[types.Uid][]types.DeviceDef +} + +func (s *testDevicePushTokenStore) Upsert(types.TenantID, *types.DevicePushToken) (*types.DevicePushToken, error) { + return nil, nil +} + +func (s *testDevicePushTokenStore) GetAllByProvider(types.TenantID, string, ...types.Uid) (map[types.Uid][]types.DeviceDef, int, error) { + count := 0 + for _, devices := range s.devices { + count += len(devices) + } + return s.devices, count, nil +} + +func TestNormalizeConfigLoadsThirdPartyConfig(t *testing.T) { + oldConfig := store.ThirdPartyConfig + store.ThirdPartyConfig = testThirdPartyConfigStore{ + "jpush_enabled": "1", + "jpush_app_key": "app-key", + "jpush_master_secret": "master", + "jpush_api_domain": "https://push.example.com/", + "jpush_push_path": "v3/push", + "jpush_default_ttl_seconds": "123", + "jpush_call_ttl_seconds": "45", + "jpush_use_alias": "false", + "jpush_classification": "1", + } + defer func() { store.ThirdPartyConfig = oldConfig }() + + var config configType + loadThirdPartyConfig(&config) + config, err := normalizeConfig(config) + if err != nil { + t.Fatalf("normalize config: %v", err) + } + if !config.Enabled || config.AppKey != "app-key" || config.MasterSecret != "master" { + t.Fatalf("unexpected credentials: %#v", config) + } + if config.APIDomain != "https://push.example.com" || config.PushPath != "/v3/push" { + t.Fatalf("unexpected endpoint: %#v", config) + } + if config.DefaultTTLSeconds != 123 || config.CallTTLSeconds != 45 || config.UseAlias == nil || *config.UseAlias { + t.Fatalf("unexpected ttl/use_alias: %#v", config) + } +} + +func TestPreparePayloadsUsesAliasAndNormalizesP2PTopic(t *testing.T) { + caller := types.Uid(42) + callee := types.Uid(43) + topic := caller.P2PName(callee) + config := &configType{Title: "MeetingIM", DefaultTTLSeconds: 86400, CallTTLSeconds: 60} + payloads, uids := preparePayloads(&push.Receipt{ + TenantID: 7, + To: map[types.Uid]push.Recipient{ + callee: {Unread: 3}, + }, + Payload: push.Payload{ + What: push.ActMsg, + Topic: topic, + From: caller.UserId(), + SeqId: 12, + ContentType: "text/plain", + Content: map[string]any{"text": "hello"}, + Timestamp: time.Date(2026, 9, 4, 10, 0, 0, 0, time.UTC), + }, + }, config) + if len(payloads) != 1 || len(uids) != 1 || uids[0] != callee { + t.Fatalf("unexpected payload count: %d %#v", len(payloads), uids) + } + payload := payloads[0] + if got := payload.Audience["alias"][0]; got != callee.UserId() { + t.Fatalf("unexpected alias: %s", got) + } + userTopic, _ := types.P2PNameForUser(callee, topic) + if got := payload.Notification.Android.Extras["topic"]; got != userTopic { + t.Fatalf("topic was not normalized for callee: got %s want %s", got, userTopic) + } + if payload.Notification.IOS.Badge != "3" { + t.Fatalf("unexpected badge: %s", payload.Notification.IOS.Badge) + } +} + +func TestPreparePayloadsCanUseRegistrationID(t *testing.T) { + oldTokens := store.DevicePushTokens + uid := types.Uid(42) + store.DevicePushTokens = &testDevicePushTokenStore{devices: map[types.Uid][]types.DeviceDef{ + uid: { + {DeviceId: "online-token", Platform: "ios"}, + {DeviceId: "offline-token", Platform: "android"}, + }, + }} + defer func() { store.DevicePushTokens = oldTokens }() + useAlias := false + config := &configType{Title: "MeetingIM", DefaultTTLSeconds: 86400, CallTTLSeconds: 60, UseAlias: &useAlias} + payloads, _ := preparePayloads(&push.Receipt{ + TenantID: 7, + To: map[types.Uid]push.Recipient{ + uid: {Devices: []string{"online-token"}}, + }, + Payload: push.Payload{What: push.ActMsg, Topic: "grpTopic", SeqId: 9, Timestamp: time.Now()}, + }, config) + if len(payloads) != 1 { + t.Fatalf("unexpected payload count: %d", len(payloads)) + } + got := payloads[0].Audience["registration_id"] + if len(got) != 1 || got[0] != "offline-token" { + t.Fatalf("unexpected registration ids: %#v", got) + } +} + +func TestBuildPayloadUsesShortTTLForCalls(t *testing.T) { + config := &configType{Title: "MeetingIM", DefaultTTLSeconds: 86400, CallTTLSeconds: 60} + payload := buildPayload(map[string][]string{"alias": {"usr1"}}, "p2pTopic", map[string]string{ + "type": "call_invite", + "topic": "p2pTopic", + "seq": "30", + "call_type": "audio", + }, 0, config) + if payload.Options.TimeToLive != 60 { + t.Fatalf("unexpected ttl: %d", payload.Options.TimeToLive) + } + if payload.Notification.Android.Title != "语音通话" { + t.Fatalf("unexpected title: %s", payload.Notification.Android.Title) + } +} + +func TestPushDataExtractsCallFields(t *testing.T) { + data, err := pushData(push.Payload{ + What: push.ActMsg, + Topic: "p2pTopic", + From: "usrCaller", + SeqId: 30, + Webrtc: "started", + Content: map[string]any{ + "callId": "call_1", + "callType": "audio", + }, + Timestamp: time.Date(2026, 9, 4, 10, 0, 0, 0, time.UTC), + }) + if err != nil { + t.Fatalf("push data: %v", err) + } + if data["type"] != "call_invite" || data["call_id"] != "call_1" || data["call_type"] != "audio" || data["caller_id"] != "usrCaller" { + t.Fatalf("unexpected call data: %#v", data) + } +} + +func TestSendPayloadUsesBasicAuth(t *testing.T) { + var gotAuth string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotAuth = r.Header.Get("Authorization") + if r.URL.Path != "/v3/push" { + t.Fatalf("unexpected path: %s", r.URL.Path) + } + var payload jpushPayload + if err := json.NewDecoder(r.Body).Decode(&payload); err != nil { + t.Fatalf("decode payload: %v", err) + } + if payload.Audience["alias"][0] != "usr1" { + t.Fatalf("unexpected audience: %#v", payload.Audience) + } + w.WriteHeader(http.StatusOK) + w.Write([]byte(`{"sendno":"0","msg_id":"1"}`)) + })) + defer srv.Close() + config := &configType{AppKey: "key", MasterSecret: "secret", APIDomain: srv.URL, PushPath: "/v3/push"} + err := sendPayload(&jpushPayload{ + Platform: "all", + Audience: map[string][]string{"alias": {"usr1"}}, + Message: &jpushMessage{MsgContent: "hello"}, + Options: jpushOptions{TimeToLive: 60}, + }, config, srv.Client()) + if err != nil { + t.Fatalf("send payload: %v", err) + } + wantAuth := "Basic " + base64.StdEncoding.EncodeToString([]byte("key:secret")) + if gotAuth != wantAuth { + t.Fatalf("unexpected auth: got %s want %s", gotAuth, wantAuth) + } +} diff --git a/server/store/store.go b/server/store/store.go index 1706a38..90d7cb4 100644 --- a/server/store/store.go +++ b/server/store/store.go @@ -2606,6 +2606,10 @@ type DevicePushTokenQueryInterface interface { GetAll(tenantID types.TenantID, uid ...types.Uid) (map[types.Uid][]types.DeviceDef, int, error) } +type DevicePushTokenProviderQueryInterface interface { + GetAllByProvider(tenantID types.TenantID, provider string, uid ...types.Uid) (map[types.Uid][]types.DeviceDef, int, error) +} + type devicePushTokenMapper struct{} var DevicePushTokens DevicePushTokenPersistenceInterface @@ -2636,6 +2640,24 @@ func (devicePushTokenMapper) GetAll(tenantID types.TenantID, uids ...types.Uid) return reader.TenantDevicePushTokenGetAll(tenantID, uids...) } +func (devicePushTokenMapper) GetAllByProvider(tenantID types.TenantID, provider string, uids ...types.Uid) (map[types.Uid][]types.DeviceDef, int, error) { + provider = strings.ToLower(strings.TrimSpace(provider)) + if provider == "" { + return map[types.Uid][]types.DeviceDef{}, 0, nil + } + tenantAdp, err := tenantBusinessAdapter(tenantID) + if err != nil { + return nil, 0, err + } + reader, ok := tenantAdp.(interface { + TenantDevicePushTokenGetAllByProvider(types.TenantID, string, ...types.Uid) (map[types.Uid][]types.DeviceDef, int, error) + }) + if !ok { + return map[types.Uid][]types.DeviceDef{}, 0, nil + } + return reader.TenantDevicePushTokenGetAllByProvider(tenantID, provider, uids...) +} + type SafetyPersistenceInterface interface { CreateRiskChallenge(tenantID types.TenantID, challenge *types.RiskChallenge) (*types.RiskChallenge, error) ConfirmRiskChallenge(tenantID types.TenantID, uid types.Uid, challengeID, decision, actionTokenID string, now time.Time) (*types.RiskChallenge, error) diff --git a/server/tinode.conf b/server/tinode.conf index 1701406..60711bc 100644 --- a/server/tinode.conf +++ b/server/tinode.conf @@ -207,6 +207,19 @@ "config": { "enabled": false } + }, + { + "name": "jpush", + "config": { + "enabled": false, + "api_domain": "https://api.jpush.cn", + "push_path": "/v3/push", + "default_ttl_seconds": 86400, + "call_ttl_seconds": 60, + "apns_production": false, + "use_alias": true, + "classification": 1 + } } ],