🐛 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: [ children: [
{ {
path: 'home', path: 'home',
component: () => import('@/views/im/home/Index.vue'), component: () => import('@/views/im/home/index.vue'),
name: 'ImHome', name: 'ImHome',
redirect: '/im/home/conversation', redirect: '/im/home/conversation',
meta: { hidden: true, title: '聊天' }, meta: { hidden: true, title: '聊天' },

View File

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

View File

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