🐛 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。
pull/881/head
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 = () => {
/** 同一时刻只允许一次 pull:Index.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 的 false→true 转换,覆盖后续每次重连
* 首次连接由 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. 预拉好友 / 群列表:必须 await,pullOnce 内部要靠 friendStore / groupStore 补 senderNickName 和会话 name/avatar
// 2.2 预拉好友 / 群列表:必须 await,pullOnce 内部要靠 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 主壳:主动断 WS(disconnect 内部已清掉 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)
}