diff --git a/docs/IM-前后端对接文档.md b/docs/IM-前后端对接文档.md index 7bad68c..4d91c5e 100644 --- a/docs/IM-前后端对接文档.md +++ b/docs/IM-前后端对接文档.md @@ -906,7 +906,7 @@ X-MeetingIM-APIKey: <预置的普通 API Key> | 接口 | 输入示例 | 成功 `data` 示例 | | --- | --- | --- | -| `GET /v1/im/bootstrap` | Query only | `{"features":{"create_group":true,"user_search":true,"scan_qr":true,"system_notifications":true,"message_recall":true,"message_edit":true},"permissions":{"create_group":true,"user_search":true,"scan_qr":true},"entries":{"new_friends":{"enabled":true,"route":"/contacts/new-friends","unread_count":0},"groups":{"enabled":true,"route":"/contacts/groups","unread_count":0},"file_assistant":{"enabled":true,"route":"/chat/file-assistant","topic":"slf","unread_count":0},"system_messages":{"enabled":true,"route":"/chat/system","topic":"sys","unread_count":0}},"entry_badges":{"new_friends_pending_count":0,"groups_unread_count":0,"file_assistant_unread_count":0,"system_unread_count":0,"workbench_total_unread":0},"system_topic":{"topic":"sys","type":"system"},"message_policy":{"recall_window_seconds":120,"edit_window_seconds":300,"supported_actions":["copy","quote","delete","recall","edit"]},"security":{"realname":{"status":"unverified","required":false,"allow_skip":true,"remind_after":"24h"}}}` | +| `GET /v1/im/bootstrap` | Query only | `{"features":{"create_group":true,"user_search":true,"scan_qr":true,"system_notifications":true,"message_recall":true,"message_edit":true,"audio_call":true,"video_call":true},"permissions":{"create_group":true,"user_search":true,"scan_qr":true,"audio_call":true,"video_call":true},"entries":{"new_friends":{"enabled":true,"route":"/contacts/new-friends","unread_count":0},"groups":{"enabled":true,"route":"/contacts/groups","unread_count":0},"file_assistant":{"enabled":true,"route":"/chat/file-assistant","topic":"slf","unread_count":0},"system_messages":{"enabled":true,"route":"/chat/system","topic":"sys","unread_count":0}},"entry_badges":{"new_friends_pending_count":0,"groups_unread_count":0,"file_assistant_unread_count":0,"system_unread_count":0,"workbench_total_unread":0},"system_topic":{"topic":"sys","type":"system"},"message_policy":{"recall_window_seconds":120,"edit_window_seconds":300,"supported_actions":["copy","quote","delete","recall","edit"]},"security":{"realname":{"status":"unverified","required":false,"allow_skip":true,"remind_after":"24h"}}}` | | `GET /v1/im/search/conversations?q=&cursor=` | Query only | `{"items":[{"topic":"grp...","public":{"name":"项目群"},"private":{"pinned":false,"mute_notifications":false},"seq":12,"read":10,"recv":12,"unread":2,"updated_at":"2026-08-20T09:00:00Z"}],"next_cursor":""}` | | `GET /v1/im/system-notifications?cursor=&limit=20` | Query only | `{"items":[{"id":"evt_1","event_type":"friend.request.created","title":"新的好友申请","summary":"张三请求添加你为好友","action":{"type":"friend_request","request_id":"frq_1"},"read_at":null,"created_at":"2026-08-20T09:00:00Z"}],"next_cursor":""}` | | `POST /v1/im/system-notifications/{id}/read` | `{"read_at":"2026-08-20T09:00:00Z"}` | `{"status":"ok","id":"evt_1","read_at":"2026-08-20T09:00:00Z"}` | @@ -1971,6 +1971,7 @@ X-MeetingIM-APIKey: <预置的普通 API Key> **状态:发起与超时已实现基础;好友/拉黑/租户开关校验需改造。** +- 音视频入口先看 `GET /v1/im/bootstrap` 的 `features.audio_call/video_call` 和 `permissions.audio_call/video_call`;这两个值来源于 Admin 后台现有配置读取链路 `im_client_feature` 分组中的 `audio_call_enabled`、`video_call_enabled`,与声网参数使用同一个配置读取方法,但不放在声网分组。具体到某个会话或用户时,再看 `capabilities.can_call`。任一为 `false` 时前端不展示对应语音/视频按钮。 - 发起前查询 `capabilities.can_call`,再发布持久邀请消息: ```json diff --git a/docs/third/IM-第三方配置与文件安全SQL.sql b/docs/third/IM-第三方配置与文件安全SQL.sql index 9c71f12..1e52f40 100644 --- a/docs/third/IM-第三方配置与文件安全SQL.sql +++ b/docs/third/IM-第三方配置与文件安全SQL.sql @@ -220,6 +220,16 @@ INSERT INTO hg_sys_config SELECT 'im_shengwang', '声网启用状态', 'bool', 'agora_enabled', '0', '0', 100, '是否启用声网 RTC 音视频通话', 1, 1, NOW(), NOW() WHERE NOT EXISTS (SELECT 1 FROM hg_sys_config WHERE `group` = 'im_shengwang' AND `key` = 'agora_enabled'); +INSERT INTO hg_sys_config + (`group`, `name`, `type`, `key`, `value`, `default_value`, `sort`, `tip`, `is_default`, `status`, `created_at`, `updated_at`) +SELECT 'im_client_feature', '语音通话入口开关', 'bool', 'audio_call_enabled', '1', '1', 10, '控制 /v1/im/bootstrap features.audio_call 与 permissions.audio_call', 1, 1, NOW(), NOW() +WHERE NOT EXISTS (SELECT 1 FROM hg_sys_config WHERE `group` = 'im_client_feature' AND `key` = 'audio_call_enabled'); + +INSERT INTO hg_sys_config + (`group`, `name`, `type`, `key`, `value`, `default_value`, `sort`, `tip`, `is_default`, `status`, `created_at`, `updated_at`) +SELECT 'im_client_feature', '视频通话入口开关', 'bool', 'video_call_enabled', '1', '1', 20, '控制 /v1/im/bootstrap features.video_call 与 permissions.video_call', 1, 1, NOW(), NOW() +WHERE NOT EXISTS (SELECT 1 FROM hg_sys_config WHERE `group` = 'im_client_feature' AND `key` = 'video_call_enabled'); + INSERT INTO hg_sys_config (`group`, `name`, `type`, `key`, `value`, `default_value`, `sort`, `tip`, `is_default`, `status`, `created_at`, `updated_at`) SELECT 'im_shengwang', 'RTC 服务商', 'string', 'agora_provider', 'agora', 'agora', 101, '取值来自字典 im_rtc_provider,当前支持 agora', 1, 1, NOW(), NOW() diff --git a/server/calls.go b/server/calls.go index 21981c5..113d806 100644 --- a/server/calls.go +++ b/server/calls.go @@ -358,7 +358,7 @@ func (t *Topic) handleCallInvite(msg *ClientComMessage, asUid types.Uid) { } t.auditCallCreate(t.currentCall, asUid) // Wait for constCallEstablishmentTimeout for the other side to accept the call. - t.callEstablishmentTimer.Reset(time.Duration(globals.callEstablishmentTimeout) * time.Second) + t.callEstablishmentTimer.Reset(time.Duration(imCallTimeoutSeconds()) * time.Second) } // Handles events on existing video call (acceptance, termination, metadata exchange). diff --git a/server/im_agora_http.go b/server/im_agora_http.go index a72be43..ff5dcb2 100644 --- a/server/im_agora_http.go +++ b/server/im_agora_http.go @@ -20,6 +20,8 @@ const ( imReasonCallNotFound = "CALL_NOT_FOUND" imReasonCallNotAllowed = "CALL_NOT_ALLOWED" imReasonCallStateInvalid = "CALL_STATE_INVALID" + + imClientFeatureConfigGroup = "im_client_feature" ) type imCallRTCJoinRequest struct { @@ -189,6 +191,46 @@ func loadIMAgoraConfig() (imAgoraConfig, error) { return config, nil } +func imCallFeatureFlags() (bool, bool) { + runtimeEnabled := imCallRuntimeEnabled() + if !runtimeEnabled || store.ThirdPartyConfig == nil { + return false, false + } + values, err := store.ThirdPartyConfig.Get(imClientFeatureConfigGroup, "audio_call_enabled", "video_call_enabled") + if err != nil { + return false, false + } + return imTruthy(values["audio_call_enabled"]), imTruthy(values["video_call_enabled"]) +} + +func imCallRuntimeEnabled() bool { + webrtcEnabled := len(globals.iceServers) > 0 + agoraEnabled := false + if config, err := loadIMAgoraConfig(); err == nil { + agoraEnabled = config.Enabled + } + return webrtcEnabled || agoraEnabled +} + +func imCallFeatureEnabled() bool { + audioCall, videoCall := imCallFeatureFlags() + return audioCall || videoCall +} + +func imCallTimeoutSeconds() int { + if globals.callEstablishmentTimeout > 0 { + return globals.callEstablishmentTimeout + } + if imCallFeatureEnabled() { + return defaultCallEstablishmentTimeout + } + return 0 +} + +func imCanCall(allowed bool) bool { + return allowed && imCallFeatureEnabled() +} + func findCallRecordForRTCJoin(tenantID types.TenantID, uid types.Uid, body imCallRTCJoinRequest) (*types.CallRecord, error) { if store.Calls == nil { return nil, types.ErrUnsupported diff --git a/server/im_http.go b/server/im_http.go index b655284..a8eb410 100644 --- a/server/im_http.go +++ b/server/im_http.go @@ -1712,6 +1712,7 @@ func serveIMBootstrap(wrt http.ResponseWriter, req *http.Request) { writeIMRelationError(wrt, req, err) return } + audioCallEnabled, videoCallEnabled := imCallFeatureFlags() writeIMSuccess(wrt, req, map[string]any{ "features": map[string]any{ "create_group": true, @@ -1721,11 +1722,15 @@ func serveIMBootstrap(wrt http.ResponseWriter, req *http.Request) { "attendance": true, "message_recall": true, "message_edit": true, + "audio_call": audioCallEnabled, + "video_call": videoCallEnabled, }, "permissions": map[string]any{ "create_group": true, "user_search": true, "scan_qr": true, + "audio_call": audioCallEnabled, + "video_call": videoCallEnabled, }, "entry_badges": entryBadges, "entries": entries, @@ -6913,7 +6918,7 @@ func groupMemberProfilePayload(topic string, sub *types.Subscription, user *type data["relationship"] = ctx.Relationship data["capabilities"] = map[string]any{ "can_send": ctx.CanSend, - "can_call": ctx.CanSend, + "can_call": imCanCall(ctx.CanSend), "can_add_friend": ctx.Relationship == types.RelationshipNone, } if ctx.RequestID != "" { @@ -8447,7 +8452,7 @@ func relationshipContextPayload(ctx *types.RelationshipContext) map[string]any { "can_send_text": ctx.CanSend, "can_send_media": ctx.CanSend, "can_send_card": ctx.CanSend, - "can_call": ctx.CanSend, + "can_call": imCanCall(ctx.CanSend), "can_add_friend": ctx.Relationship == types.RelationshipNone, "can_handle_request": ctx.Relationship == types.RelationshipPendingInbound, }, @@ -8506,7 +8511,7 @@ func friendListItemPayload(item types.FriendListItem) map[string]any { "online_visibility": "unknown", "created_at": item.Friendship.CreatedAt.UTC().Format(time.RFC3339Nano), "updated_at": item.Friendship.UpdatedAt.UTC().Format(time.RFC3339Nano), - "capabilities": map[string]any{"can_send": item.Friendship.Status == types.RelationshipFriend, "can_call": item.Friendship.Status == types.RelationshipFriend}, + "capabilities": map[string]any{"can_send": item.Friendship.Status == types.RelationshipFriend, "can_call": imCanCall(item.Friendship.Status == types.RelationshipFriend)}, "display_name_source": "profile", } if item.Friendship.Remark != "" { @@ -8572,7 +8577,7 @@ func userProfilePayload(user *types.User, ctx *types.RelationshipContext) map[st data["topic"] = ctx.Topic data["capabilities"] = map[string]any{ "can_send": ctx.CanSend, - "can_call": ctx.CanSend, + "can_call": imCanCall(ctx.CanSend), "can_add_friend": ctx.Relationship == types.RelationshipNone, } if ctx.RequestID != "" { diff --git a/server/im_http_test.go b/server/im_http_test.go index ad5098f..4d55c29 100644 --- a/server/im_http_test.go +++ b/server/im_http_test.go @@ -2315,6 +2315,7 @@ func TestServeIMBootstrapIncludesHomeCapabilities(t *testing.T) { oldSecurity := store.Security oldWorkbench := store.Workbench oldRelations := store.Relations + oldThirdParty := store.ThirdPartyConfig security := &testSecurityStore{ realName: &types.RealNameVerification{Status: "verified"}, unreadNotifications: 4, @@ -2328,10 +2329,19 @@ func TestServeIMBootstrapIncludesHomeCapabilities(t *testing.T) { store.Security = security store.Relations = relations store.Workbench = workbench + store.ThirdPartyConfig = testThirdPartyConfigStore{ + "agora_enabled": "1", + "agora_auth_mode": "app_id", + "agora_app_id": "app123", + "agora_token_ttl_seconds": "3600", + "audio_call_enabled": "1", + "video_call_enabled": "1", + } defer func() { store.Security = oldSecurity store.Relations = oldRelations store.Workbench = oldWorkbench + store.ThirdPartyConfig = oldThirdParty }() req := httptest.NewRequest(http.MethodGet, "/v1/im/bootstrap", nil) @@ -2347,6 +2357,10 @@ func TestServeIMBootstrapIncludesHomeCapabilities(t *testing.T) { if data["features"] == nil || data["permissions"] == nil || data["system_topic"] == nil || data["message_policy"] == nil { t.Fatalf("bootstrap missing home capabilities: %#v", data) } + features := data["features"].(map[string]any) + if features["audio_call"] != true || features["video_call"] != true { + t.Fatalf("bootstrap call features disabled unexpectedly: %#v", features) + } if security.realNameTenant != 7 || security.realNameUser != 42 { t.Fatalf("bootstrap realname lookup scope: tenant=%d uid=%s", security.realNameTenant, security.realNameUser) } diff --git a/server/session.go b/server/session.go index 51c12f8..7f4f57c 100644 --- a/server/session.go +++ b/server/session.go @@ -898,8 +898,8 @@ func (s *Session) hello(msg *ClientComMessage) { if len(globals.iceServers) > 0 { params["iceServers"] = globals.iceServers } - if globals.callEstablishmentTimeout > 0 { - params["callTimeout"] = globals.callEstablishmentTimeout + if timeout := imCallTimeoutSeconds(); timeout > 0 { + params["callTimeout"] = timeout } if s.proto == GRPC { diff --git a/server/topic.go b/server/topic.go index dbcd0a1..83f788f 100644 --- a/server/topic.go +++ b/server/topic.go @@ -1218,7 +1218,7 @@ func (t *Topic) handlePubBroadcast(msg *ClientComMessage) { isCall := msg.Pub.Head != nil && msg.Pub.Head["webrtc"] != nil if isCall { - if len(globals.iceServers) == 0 { + if !imCallFeatureEnabled() { msg.sess.queueOut(ErrNotImplementedReply(msg, types.TimeNow())) return } diff --git a/server/topic_test.go b/server/topic_test.go index 524108d..4cd5e51 100644 --- a/server/topic_test.go +++ b/server/topic_test.go @@ -375,9 +375,18 @@ func TestHandleBroadcastCall(t *testing.T) { numUsers := 2 helper := TopicTestHelper{} helper.setUp(t, numUsers, types.TopicCatP2P, "p2p-test" /*attach=*/, true) + oldThirdParty := store.ThirdPartyConfig globals.iceServers = []iceServer{{Username: "dummy"}} + store.ThirdPartyConfig = testThirdPartyConfigStore{ + "audio_call_enabled": "1", + "video_call_enabled": "1", + } helper.topic.lastID = 5 - defer helper.tearDown() + defer func() { + globals.iceServers = nil + store.ThirdPartyConfig = oldThirdParty + helper.tearDown() + }() helper.mm.EXPECT().Save(types.TenantID(1), gomock.Any(), gomock.Any(), gomock.Any()). Return(&types.MessageSaveResult{SeqId: 1, MarkedReadBySender: true}, nil) @@ -401,8 +410,6 @@ func TestHandleBroadcastCall(t *testing.T) { t.Fatal(errorMsgs[0].Ctrl.Text) } - globals.iceServers = nil - // Message uid1 -> uid2. for i, m := range helper.results { if i == 0 { @@ -490,9 +497,15 @@ func TestHandleBroadcastCallRequiresStructuredInvite(t *testing.T) { numUsers := 2 helper := TopicTestHelper{} helper.setUp(t, numUsers, types.TopicCatP2P, "p2p-test" /*attach=*/, true) + oldThirdParty := store.ThirdPartyConfig globals.iceServers = []iceServer{{Username: "dummy"}} + store.ThirdPartyConfig = testThirdPartyConfigStore{ + "audio_call_enabled": "1", + "video_call_enabled": "1", + } defer func() { globals.iceServers = nil + store.ThirdPartyConfig = oldThirdParty helper.tearDown() }()