🐛 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
parent
8a7991261f
commit
3a77001b42
|
|
@ -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: '聊天' },
|
||||||
|
|
|
||||||
|
|
@ -175,6 +175,12 @@ export const useMessagePuller = () => {
|
||||||
/** 同一时刻只允许一次 pull:Index.vue 的手动调用与重连 watch 触发可能并发,共用同一个 promise 即可去重 */
|
/** 同一时刻只允许一次 pull:Index.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 的 false→true 转换,覆盖后续每次重连
|
* 首次连接由 Index.vue 显式调 pullOnce 完成 bootstrap,这里仅覆盖之后的重连
|
||||||
*/
|
*/
|
||||||
watch(
|
watch(
|
||||||
() => wsStore.isConnected,
|
() => wsStore.isConnected,
|
||||||
(isConnected) => {
|
(isConnected) => {
|
||||||
if (isConnected) {
|
if (isConnected && bootstrapped) {
|
||||||
void pullOnce()
|
void pullOnce()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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. 预拉好友 / 群列表:必须 await,pullOnce 内部要靠 friendStore / groupStore 补 senderNickName 和会话 name/avatar
|
// 2.2 预拉好友 / 群列表:必须 await,pullOnce 内部要靠 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 主壳:主动断 WS(disconnect 内部已清掉 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)
|
||||||
}
|
}
|
||||||
Loading…
Reference in New Issue