From a4dfb717aa386b4beea7ba5c8d9396681c5ec170 Mon Sep 17 00:00:00 2001 From: YunaiV Date: Mon, 25 May 2026 09:04:25 +0800 Subject: [PATCH] =?UTF-8?q?fix(im)=EF=BC=9A=E6=89=B9=E9=87=8F=E4=BF=AE?= =?UTF-8?q?=E5=A4=8D=E7=BE=A4=E7=AE=A1=E7=90=86=E3=80=81RTC=20=E5=92=8C?= =?UTF-8?q?=E6=B6=88=E6=81=AF=E9=93=BE=E8=B7=AF=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 修复群管理行锁、管理员角色更新、群主转让、置顶消息并发问题 - 修复好友申请 maxId 游标、重复申请排序、通知类型校验和消息内容结构校验 - 修复消息统计口径、RTC token 鉴权、离会通知、前端拉取取消和媒体重试 - 优化表情批量删除、WebSocket 推送注释、群 READ 字段和相关单测 - 更新 bug_todo、bug_done 和 bug_rejected,剩余 9 个待修 --- src/api/im/friend/request/index.ts | 8 +-- src/api/im/message/channel/index.ts | 5 +- src/api/im/message/group/index.ts | 7 ++- src/api/im/message/private/index.ts | 12 +++-- .../im/home/composables/useMediaUploader.ts | 17 ++++++- .../im/home/composables/useMessagePuller.ts | 50 +++++++++++++++---- .../home/pages/contact/FriendRequestList.vue | 2 +- .../components/message/MessageItem.vue | 12 +---- src/views/im/home/store/friendStore.ts | 20 ++++++-- src/views/im/home/types/index.ts | 3 +- 10 files changed, 96 insertions(+), 40 deletions(-) diff --git a/src/api/im/friend/request/index.ts b/src/api/im/friend/request/index.ts index 007715800..63072b57e 100644 --- a/src/api/im/friend/request/index.ts +++ b/src/api/im/friend/request/index.ts @@ -44,11 +44,11 @@ export const refuseFriendRequest = (id: number | string, handleContent?: string) }) } -// 查询「我相关」的好友申请列表(游标分页:传 lastRequestId 加载更多) -export const getMyFriendRequestList = (limit: number, lastRequestId?: number) => { +// 查询「我相关」的好友申请列表(游标分页:传 maxId 加载更多) +export const getMyFriendRequestList = (limit: number, maxId?: number) => { const params: Record = { limit } - if (lastRequestId != null) { - params.lastRequestId = lastRequestId + if (maxId != null) { + params.maxId = maxId } return request.get({ url: '/im/friend-request/list', diff --git a/src/api/im/message/channel/index.ts b/src/api/im/message/channel/index.ts index 41ab605ff..3207bfb82 100644 --- a/src/api/im/message/channel/index.ts +++ b/src/api/im/message/channel/index.ts @@ -12,10 +12,11 @@ export interface ImChannelMessageRespVO { } // 拉取当前用户应收的频道消息(离线增量);按 minId 游标分页 -export const pullChannelMessages = (params: { minId: number; size?: number }) => { +export const pullChannelMessages = (params: { minId: number; size?: number }, signal?: AbortSignal) => { return request.get({ url: '/im/channel/message/pull', - params + params, + signal }) } diff --git a/src/api/im/message/group/index.ts b/src/api/im/message/group/index.ts index 70e9ebeb1..e8988e588 100644 --- a/src/api/im/message/group/index.ts +++ b/src/api/im/message/group/index.ts @@ -39,8 +39,11 @@ export const sendGroupMessage = (data: ImGroupMessageSendReqVO) => { } // 拉取群聊消息(增量) -export const pullGroupMessages = (params: { minId: number | string; size: number }) => { - return request.get({ url: '/im/message/group/pull', params }) +export const pullGroupMessages = ( + params: { minId: number | string; size: number }, + signal?: AbortSignal +) => { + return request.get({ url: '/im/message/group/pull', params, signal }) } // 查询群聊历史消息 diff --git a/src/api/im/message/private/index.ts b/src/api/im/message/private/index.ts index c1c53090c..43ee78ad6 100644 --- a/src/api/im/message/private/index.ts +++ b/src/api/im/message/private/index.ts @@ -33,8 +33,11 @@ export const sendPrivateMessage = (data: ImPrivateMessageSendReqVO) => { } // 拉取私聊消息(增量) -export const pullPrivateMessages = (params: { minId: number | string; size: number }) => { - return request.get({ url: '/im/message/private/pull', params }) +export const pullPrivateMessages = ( + params: { minId: number | string; size: number }, + signal?: AbortSignal +) => { + return request.get({ url: '/im/message/private/pull', params, signal }) } // 查询私聊历史消息 @@ -51,10 +54,11 @@ export const readPrivateMessages = (receiverId: number | string, messageId: numb } // 查询对方已读到我发的最大消息 id(多端 / 离线后用于补齐已读状态) -export const getPrivateMaxReadMessageId = (peerId: number | string) => { +export const getPrivateMaxReadMessageId = (peerId: number | string, signal?: AbortSignal) => { return request.get({ url: '/im/message/private/max-read-message-id', - params: { peerId } + params: { peerId }, + signal }) } diff --git a/src/views/im/home/composables/useMediaUploader.ts b/src/views/im/home/composables/useMediaUploader.ts index b8feb8ab8..adb3571a7 100644 --- a/src/views/im/home/composables/useMediaUploader.ts +++ b/src/views/im/home/composables/useMediaUploader.ts @@ -108,6 +108,8 @@ export interface UploadAndSendMediaOptions { quote?: QuoteMessage /** 锁定起始会话,上传期间会话切走则放弃发送 */ conversation: Conversation + /** 重试已有占位消息时复用的客户端消息编号 */ + existingClientMessageId?: string } /** @@ -169,10 +171,20 @@ export const useMediaUploader = () => { type: number conversation: Conversation buildContent: (blobUrl: string) => string + existingClientMessageId?: string }): { clientMessageId: string; blobUrl: string } => { const { conversation } = opts const blobUrl = URL.createObjectURL(opts.file) - const clientMessageId = generateClientMessageId() + const clientMessageId = opts.existingClientMessageId || generateClientMessageId() + if (opts.existingClientMessageId) { + conversationStore.patchMessage(conversation.type, conversation.targetId, clientMessageId, { + content: opts.buildContent(blobUrl), + status: ImMessageStatus.SENDING, + uploadProgress: 0, + _localFile: opts.file + }) + return { clientMessageId, blobUrl } + } const placeholder: Message = { id: 0, clientMessageId, @@ -332,7 +344,8 @@ export const useMediaUploader = () => { file: opts.file, type: opts.type, conversation, - buildContent + buildContent, + existingClientMessageId: opts.existingClientMessageId }) // 2. 上传:进度回调 patch uploadProgress;失败保留 _localFile 供重试 diff --git a/src/views/im/home/composables/useMessagePuller.ts b/src/views/im/home/composables/useMessagePuller.ts index 7ce8fac25..f5c87e0ad 100644 --- a/src/views/im/home/composables/useMessagePuller.ts +++ b/src/views/im/home/composables/useMessagePuller.ts @@ -52,6 +52,12 @@ export const useMessagePuller = () => { const groupStore = useGroupStore() const currentUserId = getCurrentUserId() + /** 判断请求是否被主动取消 */ + const isAbortError = (e: unknown): boolean => { + const error = e as { name?: string; code?: string; message?: string } + return error?.name === 'CanceledError' || error?.code === 'ERR_CANCELED' || error?.message === 'canceled' + } + /** 私聊会话归属:自己发的算"发给 receiverId 的会话",否则算"发送方的会话";curry currentUserId 进闭包减少 3 处调用方的样板 */ const getPrivatePeerId = (message: ImPrivateMessageRespVO) => getPrivateMessagePeerId(message, currentUserId) @@ -147,25 +153,26 @@ export const useMessagePuller = () => { conversationType: number, startMinId: number, startEpoch: number, - startUserId: number + startUserId: number, + signal: AbortSignal ) => { // 私聊 / 群聊 / 频道各自一套接口;按 conversationType 在循环内分支调度 let minId = startMinId || 0 const isPrivate = conversationType === ImConversationType.PRIVATE const isChannel = conversationType === ImConversationType.CHANNEL const size = isPrivate ? MESSAGE_PRIVATE_PULL_SIZE : MESSAGE_GROUP_PULL_SIZE - const isStillValid = () => pullEpoch === startEpoch && getCurrentUserId() === startUserId + const isStillValid = () => !signal.aborted && pullEpoch === startEpoch && getCurrentUserId() === startUserId while (true) { if (!isStillValid()) { return } let list: any[] | undefined if (isPrivate) { - list = await apiPullPrivateMessages({ minId, size }) + list = await apiPullPrivateMessages({ minId, size }, signal) } else if (isChannel) { - list = await apiPullChannelMessages({ minId, size }) + list = await apiPullChannelMessages({ minId, size }, signal) } else { - list = await apiPullGroupMessages({ minId, size }) + list = await apiPullGroupMessages({ minId, size }, signal) } // 接口返回期间发生 cancel / 切账号:丢弃本批不入库,也不再翻页 if (!isStillValid()) { @@ -246,6 +253,7 @@ export const useMessagePuller = () => { /** 同一时刻只允许一次 pull:Index.vue 的手动调用与重连 watch 触发可能并发,共用同一个 promise 即可去重 */ let pullPromise: Promise | null = null + let pullAbortController: AbortController | null = null /** * 首次 pull 是否已完成。仅在置 true 后,isConnected watch 才会触发 pull。 @@ -265,6 +273,8 @@ export const useMessagePuller = () => { /** 显式取消:仅由 Index.vue onUnmounted(离开 IM / 切账号 / 路由跳出)调用 */ const cancelPull = () => { pullEpoch++ + pullAbortController?.abort() + pullAbortController = null // 旧 promise 仍在 finally 阶段跑,但 epoch 守卫已阻断后续副作用;这里立刻让 pullPromise = null 让新一轮可重入 pullPromise = null // 同步丢弃 WS 缓冲帧;旧 pull 已不会 flushBuffer,若不清下次进 IM 第一次 pullOnce 会把旧 session 的帧回放进新 store @@ -282,8 +292,13 @@ export const useMessagePuller = () => { const startEpoch = pullEpoch // 启动时的用户快照;pullByType 每批 await 后比对当前登录用户,账号变了立刻丢弃 const startUserId = currentUserId + const abortController = new AbortController() + pullAbortController = abortController // 本轮 pull 仍属于当前 session:epoch 未漂 + 用户未切;任何动新 store 状态的副作用都要先过这道关 - const isCurrentPull = () => pullEpoch === startEpoch && getCurrentUserId() === startUserId + const isCurrentPull = () => + !abortController.signal.aborted && + pullEpoch === startEpoch && + getCurrentUserId() === startUserId pullPromise = (async () => { try { // 旧 puller 在 cancelPull 未触发的异常路径上再进来时,先于任何副作用退出,避免污染新 session 的 loading @@ -298,22 +313,28 @@ export const useMessagePuller = () => { ImConversationType.PRIVATE, conversationStore.privateMessageMaxId, startEpoch, - startUserId + startUserId, + abortController.signal ), pullByType( ImConversationType.GROUP, conversationStore.groupMessageMaxId, startEpoch, - startUserId + startUserId, + abortController.signal ), pullByType( ImConversationType.CHANNEL, conversationStore.channelMessageMaxId, startEpoch, - startUserId + startUserId, + abortController.signal ) ]) } catch (e) { + if (isAbortError(e)) { + return + } console.error('[IM] 拉取离线消息失败:', e) } finally { // 仍属本轮才复位 loading;旧轮被 cancel / 切账号时由新一轮自管,避免覆盖新 session 的 true @@ -348,7 +369,7 @@ export const useMessagePuller = () => { const active = conversationStore.activeConversation if (MESSAGE_PRIVATE_READ_ENABLED && active && active.type === ImConversationType.PRIVATE) { try { - const maxReadId = await apiGetPrivateMaxReadMessageId(active.targetId) + const maxReadId = await apiGetPrivateMaxReadMessageId(active.targetId, abortController.signal) if (!isCurrentPull()) { return } @@ -360,6 +381,9 @@ export const useMessagePuller = () => { }) } } catch (e) { + if (isAbortError(e)) { + return + } console.warn('[IM] 拉取对方已读位置失败', e) } } @@ -368,8 +392,14 @@ export const useMessagePuller = () => { if (isCurrentPull()) { pullPromise = null initialPulled = true + if (pullAbortController === abortController) { + pullAbortController = null + } } else if (pullEpoch === startEpoch) { pullPromise = null + if (pullAbortController === abortController) { + pullAbortController = null + } } } })() diff --git a/src/views/im/home/pages/contact/FriendRequestList.vue b/src/views/im/home/pages/contact/FriendRequestList.vue index c111e981c..0ddb3c7bf 100644 --- a/src/views/im/home/pages/contact/FriendRequestList.vue +++ b/src/views/im/home/pages/contact/FriendRequestList.vue @@ -116,7 +116,7 @@ const enrichedRequests = computed(() => props.requests.map((request) => ({ request, peer: getPeer(request) })) ) -/** 点击「加载更多」拉下一页;store 内部按 lastRequestId 游标分页 + pending 去重 */ +/** 点击「加载更多」拉下一页;store 内部按 maxId 游标分页 + pending 去重 */ const loadingMore = ref(false) async function handleLoadMore() { if (loadingMore.value) { diff --git a/src/views/im/home/pages/conversation/components/message/MessageItem.vue b/src/views/im/home/pages/conversation/components/message/MessageItem.vue index 25246f74b..260300b48 100644 --- a/src/views/im/home/pages/conversation/components/message/MessageItem.vue +++ b/src/views/im/home/pages/conversation/components/message/MessageItem.vue @@ -331,11 +331,6 @@ const isMaterial = computed( conversationStore.activeConversation?.type === ImConversationType.CHANNEL ) -/** 私聊 / 群聊里被转发过来的素材:用紧凑卡片宽度(标题左 + 小封面右) */ -const isForwardedMaterial = computed( - () => props.message.type === ImMessageType.MATERIAL && !isMaterial.value -) - /** 当前是否在公众号 / 频道会话内:限制右键菜单只展示转发 / 删除 */ const isChannelConversation = computed( () => conversationStore.activeConversation?.type === ImConversationType.CHANNEL @@ -968,16 +963,13 @@ async function handleResend() { if (handler) { const oldQuote = getQuoteFromMessage(message.content) ?? undefined const context = handler.extractResendContext(message.content) - conversationStore.removeMessage(conversation.type, conversation.targetId, { - id: message.id, - clientMessageId: message.clientMessageId - }) await uploadAndSendMedia({ file, type: message.type, quote: oldQuote, conversation, - context + context, + existingClientMessageId: message.clientMessageId }) return } diff --git a/src/views/im/home/store/friendStore.ts b/src/views/im/home/store/friendStore.ts index be2a2f6ad..3a1dbd93e 100644 --- a/src/views/im/home/store/friendStore.ts +++ b/src/views/im/home/store/friendStore.ts @@ -124,7 +124,7 @@ export const useFriendStore = defineStore('imFriendStore', { }, /** 未处理申请数(接收方=我)—— 实时派生,「新的朋友」红点用 */ getUnhandledRequestCount: (state): number => { - const currentUserId = Number(getCurrentUserId() || 0) + const currentUserId = getCurrentUserId() return state.friendRequests.filter( (request) => request.handleResult === ImFriendRequestHandleResult.UNHANDLED && @@ -480,11 +480,23 @@ export const useFriendStore = defineStore('imFriendStore', { /** FRIEND_REQUEST_RECEIVED(1203):收到新申请;payload 已带申请方昵称 / 头像,按 requestId 直推 push 进列表 */ applyFriendRequestReceivedNotification(payload: FriendNotificationPayload) { - // 多端可能重复推同一 requestId,已存在则跳过 - if (this.findFriendRequest(payload.requestId!)) { + const currentUserId = getCurrentUserId() + const existingIndex = this.friendRequests.findIndex((item) => item.id === payload.requestId) + if (existingIndex >= 0) { + const existing = this.friendRequests.splice(existingIndex, 1)[0] + this.friendRequests.unshift({ + ...existing, + fromUserId: payload.operatorUserId, + toUserId: currentUserId, + handleResult: ImFriendRequestHandleResult.UNHANDLED, + applyContent: payload.applyContent, + addSource: payload.addSource, + createTime: Date.now(), + fromNickname: payload.fromNickname, + fromAvatar: payload.fromAvatar + }) return } - const currentUserId = Number(getCurrentUserId() || 0) this.friendRequests.unshift({ id: payload.requestId!, fromUserId: payload.operatorUserId, diff --git a/src/views/im/home/types/index.ts b/src/views/im/home/types/index.ts index ae0fe20b9..01e0eba8f 100644 --- a/src/views/im/home/types/index.ts +++ b/src/views/im/home/types/index.ts @@ -32,6 +32,7 @@ export interface ImGroupMessageDTO { receiverUserIds?: number[] // 群定向接收用户列表 readCount?: number // 群回执已读人数(type = RECEIPT 时使用) receiptStatus?: number // 群回执状态(type = RECEIPT 时使用) + readId?: number // 已读位置 } // ==================== 本地会话 / 消息结构 ==================== @@ -233,4 +234,4 @@ export interface GroupLite { memberCount?: number ownerId?: number joinApproval?: boolean // 进群是否需群主 / 管理员审批 -} \ No newline at end of file +}