From 8b06efe5ee225845de88e43d1763473cfa1144cd Mon Sep 17 00:00:00 2001 From: YunaiV Date: Sun, 24 May 2026 23:41:46 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E5=8A=A0=E5=BC=BA=20IM=20=E4=B8=8A?= =?UTF-8?q?=E4=BC=A0=20URL=20=E4=B8=8E=20RTC=20=E6=9D=A5=E7=94=B5=E8=BD=BD?= =?UTF-8?q?=E8=8D=B7=E6=A0=A1=E9=AA=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../im/home/composables/useMediaUploader.ts | 7 ++++ .../components/input/MessageInput.vue | 36 ++++++++++++++----- src/views/im/home/store/websocketStore.ts | 34 ++++++++++++++++++ 3 files changed, 69 insertions(+), 8 deletions(-) diff --git a/src/views/im/home/composables/useMediaUploader.ts b/src/views/im/home/composables/useMediaUploader.ts index 8eb3b3cad..b8feb8ab8 100644 --- a/src/views/im/home/composables/useMediaUploader.ts +++ b/src/views/im/home/composables/useMediaUploader.ts @@ -1,6 +1,7 @@ import { updateFile } from '@/api/infra/file' import { useUserStore } from '@/store/modules/user' import { useMessage } from '@/hooks/web/useMessage' +import { isOpenableUrl } from '@/utils/url' import { useConversationStore } from '../store/conversationStore' import { useMessageSender } from './useMessageSender' @@ -351,6 +352,12 @@ export const useMediaUploader = () => { markMediaFailed(conversation.type, conversation.targetId, clientMessageId) return clientMessageId } + if (!isOpenableUrl(url)) { + console.warn(`[IM] ${handler.kind}上传返回了不支持打开的 URL`, { url }) + message.warning('上传返回的文件地址不支持打开') + markMediaFailed(conversation.type, conversation.targetId, clientMessageId) + return clientMessageId + } // 3. 上传期间会话切换 / 用户登出 / 被禁言:任一情况都放弃发送,占位置 FAILED if (!verifyMediaUploadStillAllowed(conversation, startKey, opts.type, clientMessageId)) { diff --git a/src/views/im/home/pages/conversation/components/input/MessageInput.vue b/src/views/im/home/pages/conversation/components/input/MessageInput.vue index 656886706..d1a14124b 100644 --- a/src/views/im/home/pages/conversation/components/input/MessageInput.vue +++ b/src/views/im/home/pages/conversation/components/input/MessageInput.vue @@ -172,8 +172,12 @@ import { getMemberDisplayName } from '@/views/im/utils/user' import { useMessage } from '@/hooks/web/useMessage' import { useUserStore } from '@/store/modules/user' import { useMessageSender } from '@/views/im/home/composables/useMessageSender' -import { ensureMediaSizeWithinLimit, useMediaUploader } from '@/views/im/home/composables/useMediaUploader' +import { + ensureMediaSizeWithinLimit, + useMediaUploader +} from '@/views/im/home/composables/useMediaUploader' import { useMuteOverlay } from '@/views/im/home/composables/useMuteOverlay' +import { isOpenableUrl } from '@/utils/url' import { getConversationKey } from '@/views/im/utils/conversation' import { ImConversationType, ImGroupMemberRole, ImMessageType } from '@/views/im/utils/constants' import { DANGEROUS_FILE_EXTENSIONS, MESSAGE_GROUP_READ_ENABLED } from '@/views/im/utils/config' @@ -209,6 +213,7 @@ const { verifyMediaUploadStillAllowed, requireMediaHandler } = useMediaUploader() +const muteOverlay = useMuteOverlay() // 禁言 / 封禁覆盖层 const editorRef = useTemplateRef('editorRef') const imageInputRef = useTemplateRef('imageInputRef') @@ -244,6 +249,14 @@ function syncEditorState() { syncDraftToStore(editor) } +/** 禁言状态变化时同步发送按钮 */ +watch(muteOverlay, () => { + const editor = editorRef.value + if (editor) { + applyEditorUiState(editor) + } +}) + /** 把 editor 当前内容写到 draftStore;plain 由 collectFromEditor 拿,与发送时同源避免列表与实发不一致 */ function syncDraftToStore(editor: HTMLDivElement) { const conversation = conversationStore.activeConversation @@ -614,11 +627,6 @@ const isGroup = computed( () => conversationStore.activeConversation?.type === ImConversationType.GROUP ) -// ==================== 禁言 / 封禁状态 ==================== - -/** 禁言 / 封禁覆盖层;handleResend 重试 / uploadAndSendMedia 上传完后也共用同一份,避免绕过 overlay */ -const muteOverlay = useMuteOverlay() - /** 从 groupStore 读当前激活群的成员(切会话时由 MessagePanel 预拉) */ const groupMembers = computed(() => { const conversation = conversationStore.activeConversation @@ -1106,8 +1114,20 @@ async function uploadAndSendVideo(file: File) { markMediaFailed(conversation.type, conversation.targetId, clientMessageId) return } + if (!isOpenableUrl(url)) { + console.warn('[IM] 视频上传返回了不支持打开的 URL', { url }) + message.warning('上传返回的视频地址不支持打开') + markMediaFailed(conversation.type, conversation.targetId, clientMessageId) + return + } + const safeCoverUrl = coverUrl && isOpenableUrl(coverUrl) ? coverUrl : undefined + if (coverUrl && !safeCoverUrl) { + console.warn('[IM] 视频封面上传返回了不支持打开的 URL', { coverUrl }) + } // 3.3 上传后会话校验 + muteOverlay 复查(与 useMediaUploader.uploadAndSendMedia 同一道) - if (!verifyMediaUploadStillAllowed(conversation, startKey, ImMessageType.VIDEO, clientMessageId)) { + if ( + !verifyMediaUploadStillAllowed(conversation, startKey, ImMessageType.VIDEO, clientMessageId) + ) { return } @@ -1116,7 +1136,7 @@ async function uploadAndSendVideo(file: File) { withQuotePayload( videoHandler.build(file, url, { videoProbe: { duration: probe.duration, width: probe.width, height: probe.height }, - videoCoverUrl: coverUrl + videoCoverUrl: safeCoverUrl }), replyQuote ) diff --git a/src/views/im/home/store/websocketStore.ts b/src/views/im/home/store/websocketStore.ts index a0df7f7ed..5b19898d9 100644 --- a/src/views/im/home/store/websocketStore.ts +++ b/src/views/im/home/store/websocketStore.ts @@ -8,6 +8,7 @@ import { ImMessageStatus, ImMessageType, ImConversationType, + ImRtcCallMediaType, ImRtcParticipantStatus, isFriendChatTip, isFriendNotification, @@ -63,6 +64,35 @@ const isFriendDeleteWithClear = (frame: ImPrivateMessageDTO): boolean => { } } +const RTC_LIVEKIT_PROTOCOLS = new Set(['ws:', 'wss:', 'http:', 'https:']) +const RTC_MEDIA_TYPES = new Set(Object.values(ImRtcCallMediaType)) + +/** 校验 LiveKit 连接地址 */ +function isValidLiveKitUrl(url?: string): boolean { + if (!url) { + return false + } + try { + return RTC_LIVEKIT_PROTOCOLS.has(new URL(url).protocol) + } catch { + return false + } +} + +/** 校验来电信令载荷 */ +function isValidRtcInvitePayload(payload: ImRtcCallNotification): boolean { + if (!payload.room || !payload.token || !isValidLiveKitUrl(payload.livekitUrl)) { + return false + } + if (!RTC_MEDIA_TYPES.has(payload.mediaType) || !payload.inviterUserId) { + return false + } + if (payload.conversationType === ImConversationType.PRIVATE) { + return true + } + return payload.conversationType === ImConversationType.GROUP && !!payload.groupId +} + /** * WebSocket 私聊 DTO -> 前端 Message;targetId 是会话主键(对端 userId) * 不写发送人名字段:渲染层走 utils/user 实时算(备注 / 群昵称变更后历史消息自动刷新) @@ -889,6 +919,10 @@ export const useImWebSocketStore = defineStore('imWebSocketStore', { } switch (payload.status) { case ImRtcParticipantStatus.INVITING: + if (!isValidRtcInvitePayload(payload)) { + console.warn('[IM WS] RTC_CALL invite payload 不合法', payload) + return + } // 当前已在通话中:忽略新来电;后端层面也会拒绝,这里是兜底 if (!rtcStore.isActive) { rtcStore.showIncoming(payload)