🐛 fix(im): 修复主壳初始化期间消息漏拉 / 缓冲回放失效

三处时序竞态修复:
- loading=true 提前到 connect 前,避免 WS 早于 pullOnce 推进 maxId 漏拉断线积压
- loading=false 提到 flushBuffer 前,让回放走正常 insertMessage 而非被 push 回 buffer
- 加 bootstrapped 守卫,避免 isConnected watcher 在 friend/group 加载完前抢跑

附带:主壳文件名 Index.vue → index.vue 对齐其他模块小写惯例;清理 5 个 TODO @AI。
im
YunaiV 2026-04-26 23:32:55 +08:00
parent 8a7991261f
commit 3a77001b42
3 changed files with 42 additions and 26 deletions

View File

@ -756,7 +756,7 @@ const remainingRouter: AppRouteRecordRaw[] = [
children: [
{
path: 'home',
component: () => import('@/views/im/home/Index.vue'),
component: () => import('@/views/im/home/index.vue'),
name: 'ImHome',
redirect: '/im/home/conversation',
meta: { hidden: true, title: '聊天' },

View File

@ -175,6 +175,12 @@ export const useMessagePuller = () => {
/** 同一时刻只允许一次 pullIndex.vue 的手动调用与重连 watch 触发可能并发,共用同一个 promise 即可去重 */
let pullPromise: Promise<void> | null = null
/**
* pull true isConnected watch pull
* socket onopen friendStore/groupStore watcher senderNickName
*/
let bootstrapped = false
/** 执行一次全量增量拉取(重入安全:进行中再次调用复用同一个 promise */
const pullOnce = (): Promise<void> => {
if (!currentUserId) {
@ -192,23 +198,26 @@ export const useMessagePuller = () => {
pullByType(ImConversationType.PRIVATE, conversationStore.privateMessageMaxId),
pullByType(ImConversationType.GROUP, conversationStore.groupMessageMaxId)
])
// 回放 WebSocket 在 loading 期间收到的缓冲消息
const buffered = wsStore.flushBuffer()
for (const item of buffered) {
if (item.conversationType === ImConversationType.PRIVATE) {
wsStore.handlePrivateMessage(item.payload)
} else {
wsStore.handleGroupMessage(item.payload)
}
}
} catch (e) {
console.error('[IM] 拉取离线消息失败:', e)
} finally {
// 关闭 buffer 模式必须早于 flushBuffer否则 handler 看到 loading=true 会把消息又 push 回 buffer
conversationStore.loading = false
conversationStore.sortConversations()
}
// 回放 WebSocket 在 loading 期间收到的缓冲消息(此刻走正常 insertMessage 路径)
const buffered = wsStore.flushBuffer()
for (const item of buffered) {
if (item.conversationType === ImConversationType.PRIVATE) {
wsStore.handlePrivateMessage(item.payload)
} else {
wsStore.handleGroupMessage(item.payload)
}
}
// pull + replay 都完成后再排序,避免回放消息打乱顺序
conversationStore.sortConversations()
// 重连 / 冷启动后补齐当前激活私聊会话的「对方已读位置」
// 离线期间错过的 RECEIPT 推送会被这里补回;其他私聊会话等用户点开时由 Index.vue 的 watch 触发
const active = conversationStore.activeConversation
@ -229,6 +238,7 @@ export const useMessagePuller = () => {
} finally {
// 整个 IIFE 全部完成(含已读位置补齐)后才允许下一次 pullOnce 重入
pullPromise = null
bootstrapped = true
}
})()
return pullPromise
@ -236,12 +246,12 @@ export const useMessagePuller = () => {
/**
* WS minId
* Index.vue pullOnce isConnected falsetrue
* Index.vue pullOnce bootstrap
*/
watch(
() => wsStore.isConnected,
(isConnected) => {
if (isConnected) {
if (isConnected && bootstrapped) {
void pullOnce()
}
}

View File

@ -1,10 +1,9 @@
<!-- TODO @AI文件名是不是应该小写 -->
<template>
<!--
IM 外层容器聊天模块的全屏沉浸式壳
- 左侧 ToolBar头像 + Tab消息/好友/群聊+ 底部设置
- 右侧 <router-view>按路由渲染 MessagePage / FriendPage / GroupPage
- 挂载全局弹层UserInfoCard / ContextMenu / ImageViewer
- 挂载全局弹层UserInfoCard / ContextMenu
-->
<div class="flex w-full h-full overflow-hidden">
<ToolBar />
@ -20,7 +19,6 @@
</router-view>
<!-- 全局弹层 useImUiStore 统一调度 -->
<!-- TODO @AI是不是没必要大图预览改为 <el-image :preview-src-list> 在调用方就地承接不再全局挂 -->
<UserInfoCard />
<ContextMenu />
</div>
@ -49,27 +47,35 @@ const groupStore = useGroupStore()
const { pullOnce } = useMessagePuller()
const { readActive, syncPrivateReadStatus } = useMessageSender()
// TODO @AI
/** 初始化:本地缓存恢复 → 远端通信/同步 → 默认视图 */
onMounted(async () => {
// 1. IndexedDB await
// ========== 1. ==========
// 1.1 loading=true saveConversations + WS
// connect pullOnce maxId pull 线
conversationStore.loading = true
// 1.2 IndexedDB await
await conversationStore.loadConversations()
// 2. WebSocket Tab
// ========== 2. + ==========
// 2.1 WebSocket Tab
wsStore.connect()
// 3. / awaitpullOnce friendStore / groupStore senderNickName name/avatar
// 2.2 / awaitpullOnce friendStore / groupStore senderNickName name/avatar
await Promise.all([
friendStore.loadFriends().catch((e) => console.warn('[IM] 预拉好友失败', e)),
groupStore.loadGroups().catch((e) => console.warn('[IM] 预拉群列表失败', e))
])
// 4. 线 + 使 minId
// 2.3 线 + 使 minId pullOnce finally loading
await pullOnce()
// 5. Tab
// ========== 3. ==========
// 3.1 Tab
const sorted = conversationStore.getSortedConversations
if (sorted.length > 0 && !conversationStore.activeConversation) {
conversationStore.setActiveConversation(sorted[0])
}
})
// TODO @AI
/** 离开 IM 主壳:主动断 WSdisconnect 内部已清掉 onclose 防自动重连) */
onUnmounted(() => {
wsStore.disconnect()
})
@ -86,9 +92,9 @@ watch(
if (!targetId) {
return
}
// TODO @AI
// + / UI
await readActive()
// TODO @AI
// ""线 / RECEIPT
if (conversationStore.activeConversation?.type === ImConversationType.PRIVATE) {
void syncPrivateReadStatus(targetId)
}