From f791f37328a91cd7488977d5dc43b3b58fd98ad0 Mon Sep 17 00:00:00 2001 From: YunaiV Date: Tue, 14 Jul 2026 21:01:35 +0800 Subject: [PATCH] =?UTF-8?q?fix(im):=20=E4=BF=AE=E5=A4=8D=E8=B4=A6=E5=8F=B7?= =?UTF-8?q?=E5=88=87=E6=8D=A2=E6=97=B6=E7=9A=84=E5=BC=82=E6=AD=A5=E7=8A=B6?= =?UTF-8?q?=E6=80=81=E7=AB=9E=E6=80=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 增加 IndexedDB 用户与 session 守卫,串行衔接停止和初始化 - 隔离首页初始化及频道缓存异步任务,阻止旧账号响应回写 - 使用事务单调更新消息拉取游标,避免并发覆盖 --- .../src/views/im/home/store/channelStore.ts | 53 +++++++- apps/web-antd/src/views/im/utils/db.ts | 121 ++++++++++++------ .../src/views/im/home/index.vue | 50 +++++++- .../src/views/im/home/store/channelStore.ts | 53 +++++++- apps/web-antdv-next/src/views/im/utils/db.ts | 121 ++++++++++++------ apps/web-ele/src/views/im/home/index.vue | 50 +++++++- .../src/views/im/home/store/channelStore.ts | 53 +++++++- apps/web-ele/src/views/im/utils/db.ts | 121 ++++++++++++------ 8 files changed, 472 insertions(+), 150 deletions(-) diff --git a/apps/web-antd/src/views/im/home/store/channelStore.ts b/apps/web-antd/src/views/im/home/store/channelStore.ts index 70a3a6a18..628f57f38 100644 --- a/apps/web-antd/src/views/im/home/store/channelStore.ts +++ b/apps/web-antd/src/views/im/home/store/channelStore.ts @@ -3,11 +3,14 @@ import type { ImManagerChannelApi } from '#/api/im/manager/channel'; import { acceptHMRUpdate, defineStore } from 'pinia'; import { getSimpleChannelList } from '#/api/im/manager/channel'; +import { getCurrentUserId } from '#/views/im/utils/auth'; import { ImConversationType } from '../../utils/constants'; import { getDb } from '../../utils/db'; import { useConversationStore } from './conversationStore'; +let storeEpoch = 0; // clear 时递增;旧账号请求返回后不得写入新账号状态 + /** * IM 频道 Store * @@ -31,33 +34,54 @@ export const useChannelStore = defineStore('imChannelStore', { /** 从 IndexedDB 恢复频道列表 */ async loadChannelList(): Promise { + const requestEpoch = storeEpoch; + const requestUserId = getCurrentUserId(); try { const cached = await getDb().getAll('channels'); + if ( + requestEpoch !== storeEpoch || + getCurrentUserId() !== requestUserId + ) { + return false; + } if (!cached || cached.length === 0) { return false; } this.channels = cached; return true; } catch (error) { - console.warn('[IM channelStore] 本地频道缓存读取失败', error); + if ( + requestEpoch === storeEpoch && + getCurrentUserId() === requestUserId + ) { + console.warn('[IM channelStore] 本地频道缓存读取失败', error); + } return false; } }, /** 保存频道列表 */ saveChannelList(): void { + const requestEpoch = storeEpoch; + const requestUserId = getCurrentUserId(); + const channels = [...this.channels]; void getDb() .transaction(['channels'], 'readwrite', async (tx) => { const db = getDb(); await db.clearStore('channels', tx); - for (const channel of this.channels) { + for (const channel of channels) { await db.put('channels', channel, tx); } }) - .catch((error) => - console.warn('[IM channelStore] 本地频道缓存写入失败', error), - ); + .catch((error) => { + if ( + requestEpoch === storeEpoch && + getCurrentUserId() === requestUserId + ) { + console.warn('[IM channelStore] 本地频道缓存写入失败', error); + } + }); }, // ==================== 远端拉取 ==================== @@ -67,13 +91,27 @@ export const useChannelStore = defineStore('imChannelStore', { if (this.loaded && !force) { return; } + const requestEpoch = storeEpoch; + const requestUserId = getCurrentUserId(); try { - this.channels = (await getSimpleChannelList()) || []; + const channels = (await getSimpleChannelList()) || []; + if ( + requestEpoch !== storeEpoch || + getCurrentUserId() !== requestUserId + ) { + return; + } + this.channels = channels; this.loaded = true; this.syncChannelConversationMetadata(); this.saveChannelList(); } catch (error) { - console.warn('[IM channelStore] fetchChannelList 失败', error); + if ( + requestEpoch === storeEpoch && + getCurrentUserId() === requestUserId + ) { + console.warn('[IM channelStore] fetchChannelList 失败', error); + } } }, @@ -102,6 +140,7 @@ export const useChannelStore = defineStore('imChannelStore', { /** 清空频道内存 */ clear() { + storeEpoch++; this.channels = []; this.loaded = false; }, diff --git a/apps/web-antd/src/views/im/utils/db.ts b/apps/web-antd/src/views/im/utils/db.ts index 432423544..e90ccb0f3 100644 --- a/apps/web-antd/src/views/im/utils/db.ts +++ b/apps/web-antd/src/views/im/utils/db.ts @@ -67,6 +67,7 @@ export const StorageKeys = { let currentDb: IDBDatabase | null = null; let currentUserId: null | number = null; let currentSession = 0; +let stopPromise: Promise | undefined; /** 校验当前 IM IndexedDB session 仍有效 */ export function isCurrentDbSession(session: number): boolean { @@ -195,6 +196,9 @@ function openDb(name: string): Promise { /** 初始化当前用户 IM DB */ export async function initDb(): Promise { + while (stopPromise) { + await stopPromise; + } const userId = getCurrentUserId(); if (!Number.isFinite(userId) || userId <= 0) { throw new Error('当前用户不存在,无法初始化 IM DB'); @@ -202,10 +206,21 @@ export async function initDb(): Promise { if (currentDb && currentUserId === userId) { return; } - currentDb?.close(); - currentSession++; + const session = ++currentSession; + const previousDb = currentDb; + currentDb = null; currentUserId = userId; - currentDb = await openDb(getDbName(userId)); + previousDb?.close(); + const nextDb = await openDb(getDbName(userId)); + if ( + !isCurrentDbSession(session) || + currentUserId !== userId || + getCurrentUserId() !== userId + ) { + nextDb.close(); + throw new Error('IM DB 初始化已失效'); + } + currentDb = nextDb; } /** 关闭当前 IM DB 连接 */ @@ -223,9 +238,13 @@ function getRawDb(): IDBDatabase { return currentDb; } -/** 校验单次写入 session */ -function guardSession(session: number) { - if (!isCurrentDbSession(session)) { +/** 校验单次事务仍属于当前用户与 DB session */ +function guardSession(session: number, userId: number) { + if ( + !isCurrentDbSession(session) || + currentUserId !== userId || + getCurrentUserId() !== userId + ) { throw new Error('IM DB session 已失效'); } } @@ -477,7 +496,8 @@ class DbClient { ): Promise { // 开启事务前校验 session const session = getDbSession(); - guardSession(session); + const userId = getCurrentUserId(); + guardSession(session, userId); const tx = getRawDb().transaction(storeNames, mode); const done = transactionDone(tx); let result: T; @@ -493,7 +513,7 @@ class DbClient { } // commit 后再次校验 session await done; - guardSession(session); + guardSession(session, userId); return result; } } @@ -567,42 +587,61 @@ export async function setMessageMaxId( } } const db = getDb(); - const current = (await db.getSetting(key, tx)) || 0; - if (maxId > current) { - await db.setSetting(key, maxId, tx); + const updateMaxId = async (transaction: DbTransaction) => { + const current = (await db.getSetting(key, transaction)) || 0; + if (maxId > current) { + await db.setSetting(key, maxId, transaction); + } + }; + if (tx) { + await updateMaxId(tx); + return; } + await db.transaction(['settings'], 'readwrite', updateMaxId); } /** 停止当前 IM DB session */ -export async function stopRequests(): Promise { - currentSession++; - const [ - { useMessageStoreWithOut }, - { useConversationStoreWithOut }, - { useFriendStoreWithOut }, - { useGroupStoreWithOut }, - { useChannelStoreWithOut }, - { useGroupRequestStoreWithOut }, - { useFaceStoreWithOut }, - { useRtcStore }, - ] = await Promise.all([ - import('../home/store/messageStore'), - import('../home/store/conversationStore'), - import('../home/store/friendStore'), - import('../home/store/groupStore'), - import('../home/store/channelStore'), - import('../home/store/groupRequestStore'), - import('../home/store/faceStore'), - import('../home/store/rtcStore'), - ]); - useMessageStoreWithOut().clear(); - useConversationStoreWithOut().clear(); - useFriendStoreWithOut().clear(); - useGroupStoreWithOut().clear(); - useChannelStoreWithOut().clear(); - useGroupRequestStoreWithOut().clear(); - useFaceStoreWithOut().clear(); - useRtcStore().reset(); - useRtcStore().clearGroupCallCache(); +export function stopRequests(): Promise { + const session = ++currentSession; closeDbConnection(); + const task = (async () => { + const [ + { useMessageStoreWithOut }, + { useConversationStoreWithOut }, + { useFriendStoreWithOut }, + { useGroupStoreWithOut }, + { useChannelStoreWithOut }, + { useGroupRequestStoreWithOut }, + { useFaceStoreWithOut }, + { useRtcStore }, + ] = await Promise.all([ + import('../home/store/messageStore'), + import('../home/store/conversationStore'), + import('../home/store/friendStore'), + import('../home/store/groupStore'), + import('../home/store/channelStore'), + import('../home/store/groupRequestStore'), + import('../home/store/faceStore'), + import('../home/store/rtcStore'), + ]); + if (!isCurrentDbSession(session)) { + return; + } + useMessageStoreWithOut().clear(); + useConversationStoreWithOut().clear(); + useFriendStoreWithOut().clear(); + useGroupStoreWithOut().clear(); + useChannelStoreWithOut().clear(); + useGroupRequestStoreWithOut().clear(); + useFaceStoreWithOut().clear(); + useRtcStore().reset(); + useRtcStore().clearGroupCallCache(); + })(); + const settled = task.finally(() => { + if (stopPromise === settled) { + stopPromise = undefined; + } + }); + stopPromise = settled; + return settled; } diff --git a/apps/web-antdv-next/src/views/im/home/index.vue b/apps/web-antdv-next/src/views/im/home/index.vue index 8161283b5..fad930f56 100644 --- a/apps/web-antdv-next/src/views/im/home/index.vue +++ b/apps/web-antdv-next/src/views/im/home/index.vue @@ -6,8 +6,16 @@ import { useRoute } from 'vue-router'; import { preferences } from '@vben/preferences'; +import { getCurrentUserId } from '#/views/im/utils/auth'; + import { ImConversationType } from '../utils/constants'; -import { initDb, stopRequests, StorageKeys } from '../utils/db'; +import { + getDbSession, + initDb, + isCurrentDbSession, + stopRequests, + StorageKeys, +} from '../utils/db'; import { ContextMenu, ToolBar } from './components'; import { GroupInfoCard } from './components/group'; import { RtcCallContainer } from './components/rtc'; @@ -39,9 +47,21 @@ const { pullOnce, cancelPull } = useMessagePuller(); const { readActive, syncPrivateReadStatus } = useMessageSender(); const voicePlayer = useVoicePlayer(); const childRouteReady = ref(false); // 子路由是否可挂载 +let disposed = false; // 当前 IM 主壳是否已经卸载 + +/** 判断当前首页初始化任务仍属于本组件与登录账号 */ +function isInitializationActive(userId: number, session?: number) { + return ( + !disposed && + getCurrentUserId() === userId && + (session === undefined || isCurrentDbSession(session)) + ); +} /** 初始化:先吃本地缓存让首屏立即渲染,再远端刷新最新数据,最后建实时通信拉离线消息 */ onMounted(async () => { + const userId = getCurrentUserId(); + let session: number | undefined; // 0.1 系统表情包后台预拉:独立链路与首屏 IDB / 远端拉取并发,消除表情面板首次展开白屏;失败仅记日志,不阻塞主流程 void faceStore .ensureFacePackList() @@ -51,6 +71,10 @@ onMounted(async () => { try { // 1.2 打开当前用户 IM DB await initDb(); + session = getDbSession(); + if (!isInitializationActive(userId, session)) { + return; + } // 1.3 多个 store 并发从 IDB 读取本地缓存 const cacheResults = await Promise.all([ conversationStore.loadConversationList(), @@ -60,6 +84,9 @@ onMounted(async () => { channelStore.loadChannelList(), groupRequestStore.loadGroupRequestList(), ]); + if (!isInitializationActive(userId, session)) { + return; + } const hasFriendRows = cacheResults[2]; const hasGroupRows = cacheResults[3]; const hasChannelRows = cacheResults[4]; @@ -102,6 +129,9 @@ onMounted(async () => { // 2.4 执行加载 if (requiredFetches.length > 0) { await Promise.all(requiredFetches); + if (!isInitializationActive(userId, session)) { + return; + } } // 2.5 好友申请增量补偿:首登也要跑,离线期间好友申请变更不会影响好友主表 @@ -111,12 +141,22 @@ onMounted(async () => { // 3. 会话读位置先补偿,消息入库时可直接过滤已读历史消息 await conversationStore - .pullConversationReads() - .catch((error) => console.warn('[IM] 拉取会话读位置失败', error)); + .pullConversationReads(() => isInitializationActive(userId, session)) + .catch((error) => { + if (isInitializationActive(userId, session)) { + console.warn('[IM] 拉取会话读位置失败', error); + } + }); + if (!isInitializationActive(userId, session)) { + return; + } // 4. 实时通信:建 WebSocket 长连接 + 拉离线消息(pullOnce finally 把 loading 归位) webSocketStore.connect(); await pullOnce(); + if (!isInitializationActive(userId, session)) { + return; + } // 5. 默认选中第一个会话;若置顶分组处于折叠态,需跳过被折叠隐藏的置顶项,避免自动展开折叠 const sorted = conversationStore.getSortedConversationList; @@ -125,6 +165,9 @@ onMounted(async () => { conversationStore.setActiveConversation(firstVisible); } } catch (error) { + if (!isInitializationActive(userId, session)) { + return; + } // 1. 首拉失败:手动复位 loading(pullOnce 没跑到,它的 finally 兜不到这里),否则后续会话列表写入全被早 return 阻断 // 2. WebSocket 不在这里 disconnect——路由离开会走 onUnmounted 自然清理,用户也可以刷新重试 conversationStore.loading = false; @@ -164,6 +207,7 @@ window.addEventListener('beforeunload', onBeforeUnload); /** 离开 IM 主壳:取消 pull、断开 WebSocket、保存草稿、停止语音、解绑 unload,并结束当前 IM session */ onUnmounted(() => { + disposed = true; cancelPull(); webSocketStore.disconnect(); conversationStore.flushConversationDraftSave(); diff --git a/apps/web-antdv-next/src/views/im/home/store/channelStore.ts b/apps/web-antdv-next/src/views/im/home/store/channelStore.ts index 70a3a6a18..628f57f38 100644 --- a/apps/web-antdv-next/src/views/im/home/store/channelStore.ts +++ b/apps/web-antdv-next/src/views/im/home/store/channelStore.ts @@ -3,11 +3,14 @@ import type { ImManagerChannelApi } from '#/api/im/manager/channel'; import { acceptHMRUpdate, defineStore } from 'pinia'; import { getSimpleChannelList } from '#/api/im/manager/channel'; +import { getCurrentUserId } from '#/views/im/utils/auth'; import { ImConversationType } from '../../utils/constants'; import { getDb } from '../../utils/db'; import { useConversationStore } from './conversationStore'; +let storeEpoch = 0; // clear 时递增;旧账号请求返回后不得写入新账号状态 + /** * IM 频道 Store * @@ -31,33 +34,54 @@ export const useChannelStore = defineStore('imChannelStore', { /** 从 IndexedDB 恢复频道列表 */ async loadChannelList(): Promise { + const requestEpoch = storeEpoch; + const requestUserId = getCurrentUserId(); try { const cached = await getDb().getAll('channels'); + if ( + requestEpoch !== storeEpoch || + getCurrentUserId() !== requestUserId + ) { + return false; + } if (!cached || cached.length === 0) { return false; } this.channels = cached; return true; } catch (error) { - console.warn('[IM channelStore] 本地频道缓存读取失败', error); + if ( + requestEpoch === storeEpoch && + getCurrentUserId() === requestUserId + ) { + console.warn('[IM channelStore] 本地频道缓存读取失败', error); + } return false; } }, /** 保存频道列表 */ saveChannelList(): void { + const requestEpoch = storeEpoch; + const requestUserId = getCurrentUserId(); + const channels = [...this.channels]; void getDb() .transaction(['channels'], 'readwrite', async (tx) => { const db = getDb(); await db.clearStore('channels', tx); - for (const channel of this.channels) { + for (const channel of channels) { await db.put('channels', channel, tx); } }) - .catch((error) => - console.warn('[IM channelStore] 本地频道缓存写入失败', error), - ); + .catch((error) => { + if ( + requestEpoch === storeEpoch && + getCurrentUserId() === requestUserId + ) { + console.warn('[IM channelStore] 本地频道缓存写入失败', error); + } + }); }, // ==================== 远端拉取 ==================== @@ -67,13 +91,27 @@ export const useChannelStore = defineStore('imChannelStore', { if (this.loaded && !force) { return; } + const requestEpoch = storeEpoch; + const requestUserId = getCurrentUserId(); try { - this.channels = (await getSimpleChannelList()) || []; + const channels = (await getSimpleChannelList()) || []; + if ( + requestEpoch !== storeEpoch || + getCurrentUserId() !== requestUserId + ) { + return; + } + this.channels = channels; this.loaded = true; this.syncChannelConversationMetadata(); this.saveChannelList(); } catch (error) { - console.warn('[IM channelStore] fetchChannelList 失败', error); + if ( + requestEpoch === storeEpoch && + getCurrentUserId() === requestUserId + ) { + console.warn('[IM channelStore] fetchChannelList 失败', error); + } } }, @@ -102,6 +140,7 @@ export const useChannelStore = defineStore('imChannelStore', { /** 清空频道内存 */ clear() { + storeEpoch++; this.channels = []; this.loaded = false; }, diff --git a/apps/web-antdv-next/src/views/im/utils/db.ts b/apps/web-antdv-next/src/views/im/utils/db.ts index 432423544..e90ccb0f3 100644 --- a/apps/web-antdv-next/src/views/im/utils/db.ts +++ b/apps/web-antdv-next/src/views/im/utils/db.ts @@ -67,6 +67,7 @@ export const StorageKeys = { let currentDb: IDBDatabase | null = null; let currentUserId: null | number = null; let currentSession = 0; +let stopPromise: Promise | undefined; /** 校验当前 IM IndexedDB session 仍有效 */ export function isCurrentDbSession(session: number): boolean { @@ -195,6 +196,9 @@ function openDb(name: string): Promise { /** 初始化当前用户 IM DB */ export async function initDb(): Promise { + while (stopPromise) { + await stopPromise; + } const userId = getCurrentUserId(); if (!Number.isFinite(userId) || userId <= 0) { throw new Error('当前用户不存在,无法初始化 IM DB'); @@ -202,10 +206,21 @@ export async function initDb(): Promise { if (currentDb && currentUserId === userId) { return; } - currentDb?.close(); - currentSession++; + const session = ++currentSession; + const previousDb = currentDb; + currentDb = null; currentUserId = userId; - currentDb = await openDb(getDbName(userId)); + previousDb?.close(); + const nextDb = await openDb(getDbName(userId)); + if ( + !isCurrentDbSession(session) || + currentUserId !== userId || + getCurrentUserId() !== userId + ) { + nextDb.close(); + throw new Error('IM DB 初始化已失效'); + } + currentDb = nextDb; } /** 关闭当前 IM DB 连接 */ @@ -223,9 +238,13 @@ function getRawDb(): IDBDatabase { return currentDb; } -/** 校验单次写入 session */ -function guardSession(session: number) { - if (!isCurrentDbSession(session)) { +/** 校验单次事务仍属于当前用户与 DB session */ +function guardSession(session: number, userId: number) { + if ( + !isCurrentDbSession(session) || + currentUserId !== userId || + getCurrentUserId() !== userId + ) { throw new Error('IM DB session 已失效'); } } @@ -477,7 +496,8 @@ class DbClient { ): Promise { // 开启事务前校验 session const session = getDbSession(); - guardSession(session); + const userId = getCurrentUserId(); + guardSession(session, userId); const tx = getRawDb().transaction(storeNames, mode); const done = transactionDone(tx); let result: T; @@ -493,7 +513,7 @@ class DbClient { } // commit 后再次校验 session await done; - guardSession(session); + guardSession(session, userId); return result; } } @@ -567,42 +587,61 @@ export async function setMessageMaxId( } } const db = getDb(); - const current = (await db.getSetting(key, tx)) || 0; - if (maxId > current) { - await db.setSetting(key, maxId, tx); + const updateMaxId = async (transaction: DbTransaction) => { + const current = (await db.getSetting(key, transaction)) || 0; + if (maxId > current) { + await db.setSetting(key, maxId, transaction); + } + }; + if (tx) { + await updateMaxId(tx); + return; } + await db.transaction(['settings'], 'readwrite', updateMaxId); } /** 停止当前 IM DB session */ -export async function stopRequests(): Promise { - currentSession++; - const [ - { useMessageStoreWithOut }, - { useConversationStoreWithOut }, - { useFriendStoreWithOut }, - { useGroupStoreWithOut }, - { useChannelStoreWithOut }, - { useGroupRequestStoreWithOut }, - { useFaceStoreWithOut }, - { useRtcStore }, - ] = await Promise.all([ - import('../home/store/messageStore'), - import('../home/store/conversationStore'), - import('../home/store/friendStore'), - import('../home/store/groupStore'), - import('../home/store/channelStore'), - import('../home/store/groupRequestStore'), - import('../home/store/faceStore'), - import('../home/store/rtcStore'), - ]); - useMessageStoreWithOut().clear(); - useConversationStoreWithOut().clear(); - useFriendStoreWithOut().clear(); - useGroupStoreWithOut().clear(); - useChannelStoreWithOut().clear(); - useGroupRequestStoreWithOut().clear(); - useFaceStoreWithOut().clear(); - useRtcStore().reset(); - useRtcStore().clearGroupCallCache(); +export function stopRequests(): Promise { + const session = ++currentSession; closeDbConnection(); + const task = (async () => { + const [ + { useMessageStoreWithOut }, + { useConversationStoreWithOut }, + { useFriendStoreWithOut }, + { useGroupStoreWithOut }, + { useChannelStoreWithOut }, + { useGroupRequestStoreWithOut }, + { useFaceStoreWithOut }, + { useRtcStore }, + ] = await Promise.all([ + import('../home/store/messageStore'), + import('../home/store/conversationStore'), + import('../home/store/friendStore'), + import('../home/store/groupStore'), + import('../home/store/channelStore'), + import('../home/store/groupRequestStore'), + import('../home/store/faceStore'), + import('../home/store/rtcStore'), + ]); + if (!isCurrentDbSession(session)) { + return; + } + useMessageStoreWithOut().clear(); + useConversationStoreWithOut().clear(); + useFriendStoreWithOut().clear(); + useGroupStoreWithOut().clear(); + useChannelStoreWithOut().clear(); + useGroupRequestStoreWithOut().clear(); + useFaceStoreWithOut().clear(); + useRtcStore().reset(); + useRtcStore().clearGroupCallCache(); + })(); + const settled = task.finally(() => { + if (stopPromise === settled) { + stopPromise = undefined; + } + }); + stopPromise = settled; + return settled; } diff --git a/apps/web-ele/src/views/im/home/index.vue b/apps/web-ele/src/views/im/home/index.vue index 9f68fcc77..e5afc5748 100644 --- a/apps/web-ele/src/views/im/home/index.vue +++ b/apps/web-ele/src/views/im/home/index.vue @@ -6,8 +6,16 @@ import { useRoute } from 'vue-router'; import { preferences } from '@vben/preferences'; +import { getCurrentUserId } from '#/views/im/utils/auth'; + import { ImConversationType } from '../utils/constants'; -import { initDb, stopRequests, StorageKeys } from '../utils/db'; +import { + getDbSession, + initDb, + isCurrentDbSession, + stopRequests, + StorageKeys, +} from '../utils/db'; import { ContextMenu, ToolBar } from './components'; import { GroupInfoCard } from './components/group'; import { RtcCallContainer } from './components/rtc'; @@ -39,9 +47,21 @@ const { pullOnce, cancelPull } = useMessagePuller(); const { readActive, syncPrivateReadStatus } = useMessageSender(); const voicePlayer = useVoicePlayer(); const childRouteReady = ref(false); // 子路由是否可挂载 +let disposed = false; // 当前 IM 主壳是否已经卸载 + +/** 判断当前首页初始化任务仍属于本组件与登录账号 */ +function isInitializationActive(userId: number, session?: number) { + return ( + !disposed && + getCurrentUserId() === userId && + (session === undefined || isCurrentDbSession(session)) + ); +} /** 初始化:先吃本地缓存让首屏立即渲染,再远端刷新最新数据,最后建实时通信拉离线消息 */ onMounted(async () => { + const userId = getCurrentUserId(); + let session: number | undefined; // 0.1 系统表情包后台预拉:独立链路与首屏 IDB / 远端拉取并发,消除表情面板首次展开白屏;失败仅记日志,不阻塞主流程 void faceStore .ensureFacePackList() @@ -51,6 +71,10 @@ onMounted(async () => { try { // 1.2 打开当前用户 IM DB await initDb(); + session = getDbSession(); + if (!isInitializationActive(userId, session)) { + return; + } // 1.3 多个 store 并发从 IDB 读取本地缓存 const cacheResults = await Promise.all([ conversationStore.loadConversationList(), @@ -60,6 +84,9 @@ onMounted(async () => { channelStore.loadChannelList(), groupRequestStore.loadGroupRequestList(), ]); + if (!isInitializationActive(userId, session)) { + return; + } const hasFriendRows = cacheResults[2]; const hasGroupRows = cacheResults[3]; const hasChannelRows = cacheResults[4]; @@ -102,6 +129,9 @@ onMounted(async () => { // 2.4 执行加载 if (requiredFetches.length > 0) { await Promise.all(requiredFetches); + if (!isInitializationActive(userId, session)) { + return; + } } // 2.5 好友申请增量补偿:首登也要跑,离线期间好友申请变更不会影响好友主表 @@ -111,12 +141,22 @@ onMounted(async () => { // 3. 会话读位置先补偿,消息入库时可直接过滤已读历史消息 await conversationStore - .pullConversationReads() - .catch((error) => console.warn('[IM] 拉取会话读位置失败', error)); + .pullConversationReads(() => isInitializationActive(userId, session)) + .catch((error) => { + if (isInitializationActive(userId, session)) { + console.warn('[IM] 拉取会话读位置失败', error); + } + }); + if (!isInitializationActive(userId, session)) { + return; + } // 4. 实时通信:建 WebSocket 长连接 + 拉离线消息(pullOnce finally 把 loading 归位) webSocketStore.connect(); await pullOnce(); + if (!isInitializationActive(userId, session)) { + return; + } // 5. 默认选中第一个会话;若置顶分组处于折叠态,需跳过被折叠隐藏的置顶项,避免自动展开折叠 const sorted = conversationStore.getSortedConversationList; @@ -125,6 +165,9 @@ onMounted(async () => { conversationStore.setActiveConversation(firstVisible); } } catch (error) { + if (!isInitializationActive(userId, session)) { + return; + } // 1. 首拉失败:手动复位 loading(pullOnce 没跑到,它的 finally 兜不到这里),否则后续会话列表写入全被早 return 阻断 // 2. WebSocket 不在这里 disconnect——路由离开会走 onUnmounted 自然清理,用户也可以刷新重试 conversationStore.loading = false; @@ -164,6 +207,7 @@ window.addEventListener('beforeunload', onBeforeUnload); /** 离开 IM 主壳:取消 pull、断开 WebSocket、保存草稿、停止语音、解绑 unload,并结束当前 IM session */ onUnmounted(() => { + disposed = true; cancelPull(); webSocketStore.disconnect(); conversationStore.flushConversationDraftSave(); diff --git a/apps/web-ele/src/views/im/home/store/channelStore.ts b/apps/web-ele/src/views/im/home/store/channelStore.ts index 70a3a6a18..628f57f38 100644 --- a/apps/web-ele/src/views/im/home/store/channelStore.ts +++ b/apps/web-ele/src/views/im/home/store/channelStore.ts @@ -3,11 +3,14 @@ import type { ImManagerChannelApi } from '#/api/im/manager/channel'; import { acceptHMRUpdate, defineStore } from 'pinia'; import { getSimpleChannelList } from '#/api/im/manager/channel'; +import { getCurrentUserId } from '#/views/im/utils/auth'; import { ImConversationType } from '../../utils/constants'; import { getDb } from '../../utils/db'; import { useConversationStore } from './conversationStore'; +let storeEpoch = 0; // clear 时递增;旧账号请求返回后不得写入新账号状态 + /** * IM 频道 Store * @@ -31,33 +34,54 @@ export const useChannelStore = defineStore('imChannelStore', { /** 从 IndexedDB 恢复频道列表 */ async loadChannelList(): Promise { + const requestEpoch = storeEpoch; + const requestUserId = getCurrentUserId(); try { const cached = await getDb().getAll('channels'); + if ( + requestEpoch !== storeEpoch || + getCurrentUserId() !== requestUserId + ) { + return false; + } if (!cached || cached.length === 0) { return false; } this.channels = cached; return true; } catch (error) { - console.warn('[IM channelStore] 本地频道缓存读取失败', error); + if ( + requestEpoch === storeEpoch && + getCurrentUserId() === requestUserId + ) { + console.warn('[IM channelStore] 本地频道缓存读取失败', error); + } return false; } }, /** 保存频道列表 */ saveChannelList(): void { + const requestEpoch = storeEpoch; + const requestUserId = getCurrentUserId(); + const channels = [...this.channels]; void getDb() .transaction(['channels'], 'readwrite', async (tx) => { const db = getDb(); await db.clearStore('channels', tx); - for (const channel of this.channels) { + for (const channel of channels) { await db.put('channels', channel, tx); } }) - .catch((error) => - console.warn('[IM channelStore] 本地频道缓存写入失败', error), - ); + .catch((error) => { + if ( + requestEpoch === storeEpoch && + getCurrentUserId() === requestUserId + ) { + console.warn('[IM channelStore] 本地频道缓存写入失败', error); + } + }); }, // ==================== 远端拉取 ==================== @@ -67,13 +91,27 @@ export const useChannelStore = defineStore('imChannelStore', { if (this.loaded && !force) { return; } + const requestEpoch = storeEpoch; + const requestUserId = getCurrentUserId(); try { - this.channels = (await getSimpleChannelList()) || []; + const channels = (await getSimpleChannelList()) || []; + if ( + requestEpoch !== storeEpoch || + getCurrentUserId() !== requestUserId + ) { + return; + } + this.channels = channels; this.loaded = true; this.syncChannelConversationMetadata(); this.saveChannelList(); } catch (error) { - console.warn('[IM channelStore] fetchChannelList 失败', error); + if ( + requestEpoch === storeEpoch && + getCurrentUserId() === requestUserId + ) { + console.warn('[IM channelStore] fetchChannelList 失败', error); + } } }, @@ -102,6 +140,7 @@ export const useChannelStore = defineStore('imChannelStore', { /** 清空频道内存 */ clear() { + storeEpoch++; this.channels = []; this.loaded = false; }, diff --git a/apps/web-ele/src/views/im/utils/db.ts b/apps/web-ele/src/views/im/utils/db.ts index 432423544..e90ccb0f3 100644 --- a/apps/web-ele/src/views/im/utils/db.ts +++ b/apps/web-ele/src/views/im/utils/db.ts @@ -67,6 +67,7 @@ export const StorageKeys = { let currentDb: IDBDatabase | null = null; let currentUserId: null | number = null; let currentSession = 0; +let stopPromise: Promise | undefined; /** 校验当前 IM IndexedDB session 仍有效 */ export function isCurrentDbSession(session: number): boolean { @@ -195,6 +196,9 @@ function openDb(name: string): Promise { /** 初始化当前用户 IM DB */ export async function initDb(): Promise { + while (stopPromise) { + await stopPromise; + } const userId = getCurrentUserId(); if (!Number.isFinite(userId) || userId <= 0) { throw new Error('当前用户不存在,无法初始化 IM DB'); @@ -202,10 +206,21 @@ export async function initDb(): Promise { if (currentDb && currentUserId === userId) { return; } - currentDb?.close(); - currentSession++; + const session = ++currentSession; + const previousDb = currentDb; + currentDb = null; currentUserId = userId; - currentDb = await openDb(getDbName(userId)); + previousDb?.close(); + const nextDb = await openDb(getDbName(userId)); + if ( + !isCurrentDbSession(session) || + currentUserId !== userId || + getCurrentUserId() !== userId + ) { + nextDb.close(); + throw new Error('IM DB 初始化已失效'); + } + currentDb = nextDb; } /** 关闭当前 IM DB 连接 */ @@ -223,9 +238,13 @@ function getRawDb(): IDBDatabase { return currentDb; } -/** 校验单次写入 session */ -function guardSession(session: number) { - if (!isCurrentDbSession(session)) { +/** 校验单次事务仍属于当前用户与 DB session */ +function guardSession(session: number, userId: number) { + if ( + !isCurrentDbSession(session) || + currentUserId !== userId || + getCurrentUserId() !== userId + ) { throw new Error('IM DB session 已失效'); } } @@ -477,7 +496,8 @@ class DbClient { ): Promise { // 开启事务前校验 session const session = getDbSession(); - guardSession(session); + const userId = getCurrentUserId(); + guardSession(session, userId); const tx = getRawDb().transaction(storeNames, mode); const done = transactionDone(tx); let result: T; @@ -493,7 +513,7 @@ class DbClient { } // commit 后再次校验 session await done; - guardSession(session); + guardSession(session, userId); return result; } } @@ -567,42 +587,61 @@ export async function setMessageMaxId( } } const db = getDb(); - const current = (await db.getSetting(key, tx)) || 0; - if (maxId > current) { - await db.setSetting(key, maxId, tx); + const updateMaxId = async (transaction: DbTransaction) => { + const current = (await db.getSetting(key, transaction)) || 0; + if (maxId > current) { + await db.setSetting(key, maxId, transaction); + } + }; + if (tx) { + await updateMaxId(tx); + return; } + await db.transaction(['settings'], 'readwrite', updateMaxId); } /** 停止当前 IM DB session */ -export async function stopRequests(): Promise { - currentSession++; - const [ - { useMessageStoreWithOut }, - { useConversationStoreWithOut }, - { useFriendStoreWithOut }, - { useGroupStoreWithOut }, - { useChannelStoreWithOut }, - { useGroupRequestStoreWithOut }, - { useFaceStoreWithOut }, - { useRtcStore }, - ] = await Promise.all([ - import('../home/store/messageStore'), - import('../home/store/conversationStore'), - import('../home/store/friendStore'), - import('../home/store/groupStore'), - import('../home/store/channelStore'), - import('../home/store/groupRequestStore'), - import('../home/store/faceStore'), - import('../home/store/rtcStore'), - ]); - useMessageStoreWithOut().clear(); - useConversationStoreWithOut().clear(); - useFriendStoreWithOut().clear(); - useGroupStoreWithOut().clear(); - useChannelStoreWithOut().clear(); - useGroupRequestStoreWithOut().clear(); - useFaceStoreWithOut().clear(); - useRtcStore().reset(); - useRtcStore().clearGroupCallCache(); +export function stopRequests(): Promise { + const session = ++currentSession; closeDbConnection(); + const task = (async () => { + const [ + { useMessageStoreWithOut }, + { useConversationStoreWithOut }, + { useFriendStoreWithOut }, + { useGroupStoreWithOut }, + { useChannelStoreWithOut }, + { useGroupRequestStoreWithOut }, + { useFaceStoreWithOut }, + { useRtcStore }, + ] = await Promise.all([ + import('../home/store/messageStore'), + import('../home/store/conversationStore'), + import('../home/store/friendStore'), + import('../home/store/groupStore'), + import('../home/store/channelStore'), + import('../home/store/groupRequestStore'), + import('../home/store/faceStore'), + import('../home/store/rtcStore'), + ]); + if (!isCurrentDbSession(session)) { + return; + } + useMessageStoreWithOut().clear(); + useConversationStoreWithOut().clear(); + useFriendStoreWithOut().clear(); + useGroupStoreWithOut().clear(); + useChannelStoreWithOut().clear(); + useGroupRequestStoreWithOut().clear(); + useFaceStoreWithOut().clear(); + useRtcStore().reset(); + useRtcStore().clearGroupCallCache(); + })(); + const settled = task.finally(() => { + if (stopPromise === settled) { + stopPromise = undefined; + } + }); + stopPromise = settled; + return settled; }