From 153f541078078c4d1f441e321e9d2cb728410f8e Mon Sep 17 00:00:00 2001 From: richjohnrambo Date: Fri, 18 Sep 2026 13:38:57 +0800 Subject: [PATCH] fix: add call push category --- docs/IM-前后端对接文档.md | 16 +++-- server/push/jpush/push_jpush.go | 90 +++++++++++++++++++++++++--- server/push/jpush/push_jpush_test.go | 25 +++++--- 3 files changed, 111 insertions(+), 20 deletions(-) diff --git a/docs/IM-前后端对接文档.md b/docs/IM-前后端对接文档.md index 873c932..8036d52 100644 --- a/docs/IM-前后端对接文档.md +++ b/docs/IM-前后端对接文档.md @@ -530,7 +530,7 @@ X-MeetingIM-APIKey: <预置的普通 API Key> | --- | --- | --- | | `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_invite` | `tenant_code,topic,seq,call_id,call_type,caller_id,caller_display,expires_at` | 打开来电页前先登录 IM 并确认通话仍为有效来电。 | | `call_missed` | `tenant_code,topic,call_id` | 打开通话记录或对应会话。 | | `system_notification` | `tenant_code,notification_id` | 打开系统消息列表并刷新详情。 | | `friend_request` | `tenant_code,request_id` | 打开新朋友列表并刷新未处理数。 | @@ -584,6 +584,7 @@ X-MeetingIM-APIKey: <预置的普通 API Key> }, "sound": "default", "badge": "+1", + "category": "im_call", "extras": { "type": "call_invite", "tenant_code": "default", @@ -591,18 +592,22 @@ X-MeetingIM-APIKey: <预置的普通 API Key> "seq": "30", "call_id": "call_01K...", "call_type": "audio", - "caller_id": "usr_caller" + "caller_id": "usr_caller", + "caller_display": "张三", + "expires_at": "2026-09-18T10:00:30Z" } } }, "options": { - "time_to_live": 60, + "time_to_live": 30, "apns_production": false, "classification": 1 } } ``` +来电推送必须走普通 APNs/极光 alert 通知,不使用 PushKit VoIP topic;iOS `notification.ios.category` 固定为 `im_call`,用于客户端通知按钮。`time_to_live` 和 `expires_at` 以 30 秒左右短有效期为准,过期后服务端不再投递。 + #### 0.5.5 Long Polling 与文件基础示例 ##### 0.5.5.1 WebSocket 与文件基础接口 @@ -2405,11 +2410,12 @@ GET /v1/im/topics/{topic}/message-mutations?since_version={localMutationVersion} 对方状态 -**状态:在线邀请已实现;Push 与产品鉴权需改造。** +**状态:在线邀请、短有效期普通 Push 与产品鉴权已实现。** - 来电是 Topic 下行的初始 `{data}`:`head.webrtc="started"`、`head.x-im-type="call"`,`content` 至少含 `callId,callType,audio,video`。客户端保存该消息 `seq` 作为后续所有 call note 的关联 ID。 - 来电端开始响铃后发送 `{note:{topic,what:"call",event:"ringing",seq,payload:{"callId":"..."}}}`;接听发送 `event:"accept"`;拒绝发送 `event:"hang-up"`。 -- 后台 Push 只携带 `callId,topic,seq,caller_display,callType,expires_at`,不含 SDP、ICE 或 Token;用户操作后必须回 WebSocket 鉴权。 +- 后台 Push 只携带 `callId,topic,seq,caller_id,caller_display,callType,expires_at`,不含 SDP、ICE 或 Token;iOS 来电 Push 使用普通 alert,`category=im_call`,短 TTL 约 30 秒,不使用 PushKit VoIP。 +- 被叫在未发送 `ringing/accept` 前也可以直接发送 `hang-up`,服务端按 `topic+seq+callId` 校验后立即结束主叫侧振铃,并把原通话消息替换为 `declined`;主叫主动取消则替换为 `missed`。 ### 8.3 语音通话-对方待接受状态.png diff --git a/server/push/jpush/push_jpush.go b/server/push/jpush/push_jpush.go index 007955a..d776227 100644 --- a/server/push/jpush/push_jpush.go +++ b/server/push/jpush/push_jpush.go @@ -26,6 +26,7 @@ const ( defaultPushPath = "/v3/push" defaultTTL = 86400 defaultCallTTL = 30 + callCategory = "im_call" ) var handler Handler @@ -78,10 +79,11 @@ type androidPayload struct { } type iosPayload struct { - Alert iosAlert `json:"alert"` - Sound string `json:"sound,omitempty"` - Badge string `json:"badge,omitempty"` - Extras map[string]string `json:"extras,omitempty"` + Alert iosAlert `json:"alert"` + Sound string `json:"sound,omitempty"` + Badge string `json:"badge,omitempty"` + Category string `json:"category,omitempty"` + Extras map[string]string `json:"extras,omitempty"` } type iosAlert struct { @@ -327,6 +329,7 @@ func preparePayloads(rcpt *push.Receipt, config *configType) ([]*jpushPayload, [ logs.Warn.Println("jpush: could not build payload:", err) return nil, nil } + hydrateCallInviteData(rcpt.TenantID, data) if pushExpired(data) { return nil, nil } @@ -424,6 +427,9 @@ func pushData(pl push.Payload) (map[string]string, error) { if pl.From != "" { data["caller_id"] = pl.From } + if display := payloadContentFirstString(pl.Content, "caller_display", "callerDisplay", "caller_name", "callerName", "display_name", "from_display"); display != "" { + data["caller_display"] = display + } } if pl.Replace != "" { data["replace"] = pl.Replace @@ -495,10 +501,11 @@ func buildPayload(audience map[string][]string, topic string, data map[string]st Priority: 1, }, IOS: &iosPayload{ - Alert: iosAlert{Title: title, Body: alert}, - Sound: "default", - Badge: badge, - Extras: data, + Alert: iosAlert{Title: title, Body: alert}, + Sound: "default", + Badge: badge, + Category: iosCategory(data), + Extras: data, }, HMOS: &hmosPayload{ Title: title, @@ -514,9 +521,16 @@ func alertText(data map[string]string, config *configType) (string, string) { title := config.Title switch data["type"] { case "call_invite": + caller := strings.TrimSpace(data["caller_display"]) if data["call_type"] == "audio" { + if caller != "" { + return caller + "邀请你语音通话", "语音通话" + } return "收到一通语音通话", "语音通话" } + if caller != "" { + return caller + "邀请你视频通话", "视频通话" + } return "收到一通视频通话", "视频通话" case "call_missed": return "你有一通未接来电", "未接来电" @@ -527,6 +541,57 @@ func alertText(data map[string]string, config *configType) (string, string) { } } +func iosCategory(data map[string]string) string { + if data["type"] == "call_invite" { + return callCategory + } + return "" +} + +func hydrateCallInviteData(tenantID t.TenantID, data map[string]string) { + if data["type"] != "call_invite" || strings.TrimSpace(data["caller_display"]) != "" { + return + } + callerID := strings.TrimSpace(data["caller_id"]) + if callerID == "" || store.Users == nil { + return + } + uid := t.ParseUserId(callerID) + if uid.IsZero() { + return + } + user, err := store.Users.Get(tenantID, uid) + if err != nil || user == nil { + return + } + if display := displayNameFromPublic(user.Public); display != "" { + data["caller_display"] = display + } +} + +func displayNameFromPublic(public any) string { + for _, key := range []string{"display_name", "fn", "nickname", "name"} { + if value := publicMapString(public, key); value != "" { + return value + } + } + return "" +} + +func publicMapString(public any, key string) string { + switch values := public.(type) { + case map[string]any: + value, ok := values[key] + if !ok || value == nil { + return "" + } + return strings.TrimSpace(fmt.Sprint(value)) + case map[string]string: + return strings.TrimSpace(values[key]) + } + return "" +} + func cidFor(topic string, data map[string]string) string { parts := []string{data["type"], topic, data["seq"], data["replace"]} return strings.Trim(strings.Join(parts, "_"), "_") @@ -560,6 +625,15 @@ func payloadContentString(content any, key string) string { return "" } +func payloadContentFirstString(content any, keys ...string) string { + for _, key := range keys { + if value := payloadContentString(content, key); value != "" { + return value + } + } + return "" +} + func truncateRunes(value string, limit int) string { if limit <= 0 || len(value) <= limit { return value diff --git a/server/push/jpush/push_jpush_test.go b/server/push/jpush/push_jpush_test.go index 4d4a77e..c8ea81c 100644 --- a/server/push/jpush/push_jpush_test.go +++ b/server/push/jpush/push_jpush_test.go @@ -174,11 +174,12 @@ func TestBuildPayloadUsesShortTTLForCalls(t *testing.T) { expiresAt := time.Now().Add(30 * time.Second).UTC().Format(time.RFC3339Nano) 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", - "expires_at": expiresAt, + "type": "call_invite", + "topic": "p2pTopic", + "seq": "30", + "call_type": "audio", + "caller_display": "张三", + "expires_at": expiresAt, }, 0, config) if payload.Options.TimeToLive > 30 { t.Fatalf("unexpected ttl: %d", payload.Options.TimeToLive) @@ -186,6 +187,12 @@ func TestBuildPayloadUsesShortTTLForCalls(t *testing.T) { if payload.Notification.Android.Title != "语音通话" { t.Fatalf("unexpected title: %s", payload.Notification.Android.Title) } + if payload.Notification.IOS.Category != callCategory { + t.Fatalf("unexpected iOS category: %s", payload.Notification.IOS.Category) + } + if payload.Notification.IOS.Alert.Body != "张三邀请你语音通话" { + t.Fatalf("unexpected body: %s", payload.Notification.IOS.Alert.Body) + } } func TestPushDataExtractsCallFields(t *testing.T) { @@ -198,8 +205,9 @@ func TestPushDataExtractsCallFields(t *testing.T) { Webrtc: "started", ExpiresAt: &expiresAt, Content: map[string]any{ - "callId": "call_1", - "callType": "audio", + "callId": "call_1", + "callType": "audio", + "caller_display": "张三", }, Timestamp: time.Date(2026, 9, 4, 10, 0, 0, 0, time.UTC), }) @@ -212,6 +220,9 @@ func TestPushDataExtractsCallFields(t *testing.T) { if data["expires_at"] != expiresAt.Format(time.RFC3339Nano) { t.Fatalf("unexpected expires_at: %#v", data) } + if data["caller_display"] != "张三" { + t.Fatalf("unexpected caller_display: %#v", data) + } } func TestPreparePayloadsDropsExpiredCallInvite(t *testing.T) {