fix(im): 修复账号切换时的异步状态竞态

- 增加 IndexedDB 用户与 session 守卫,串行衔接停止和初始化
- 隔离首页初始化及频道缓存异步任务,阻止旧账号响应回写
- 使用事务单调更新消息拉取游标,避免并发覆盖
This commit is contained in:
YunaiV
2026-07-14 21:01:35 +08:00
parent 2084ad8ba4
commit f791f37328
8 changed files with 472 additions and 150 deletions

View File

@@ -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<boolean> {
const requestEpoch = storeEpoch;
const requestUserId = getCurrentUserId();
try {
const cached =
await getDb().getAll<ImManagerChannelApi.Channel>('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;
},

View File

@@ -67,6 +67,7 @@ export const StorageKeys = {
let currentDb: IDBDatabase | null = null;
let currentUserId: null | number = null;
let currentSession = 0;
let stopPromise: Promise<void> | undefined;
/** 校验当前 IM IndexedDB session 仍有效 */
export function isCurrentDbSession(session: number): boolean {
@@ -195,6 +196,9 @@ function openDb(name: string): Promise<IDBDatabase> {
/** 初始化当前用户 IM DB */
export async function initDb(): Promise<void> {
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<void> {
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<T> {
// 开启事务前校验 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<number>(key, tx)) || 0;
if (maxId > current) {
await db.setSetting(key, maxId, tx);
const updateMaxId = async (transaction: DbTransaction) => {
const current = (await db.getSetting<number>(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<void> {
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<void> {
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;
}

View File

@@ -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. 首拉失败:手动复位 loadingpullOnce 没跑到,它的 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();

View File

@@ -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<boolean> {
const requestEpoch = storeEpoch;
const requestUserId = getCurrentUserId();
try {
const cached =
await getDb().getAll<ImManagerChannelApi.Channel>('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;
},

View File

@@ -67,6 +67,7 @@ export const StorageKeys = {
let currentDb: IDBDatabase | null = null;
let currentUserId: null | number = null;
let currentSession = 0;
let stopPromise: Promise<void> | undefined;
/** 校验当前 IM IndexedDB session 仍有效 */
export function isCurrentDbSession(session: number): boolean {
@@ -195,6 +196,9 @@ function openDb(name: string): Promise<IDBDatabase> {
/** 初始化当前用户 IM DB */
export async function initDb(): Promise<void> {
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<void> {
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<T> {
// 开启事务前校验 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<number>(key, tx)) || 0;
if (maxId > current) {
await db.setSetting(key, maxId, tx);
const updateMaxId = async (transaction: DbTransaction) => {
const current = (await db.getSetting<number>(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<void> {
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<void> {
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;
}

View File

@@ -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. 首拉失败:手动复位 loadingpullOnce 没跑到,它的 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();

View File

@@ -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<boolean> {
const requestEpoch = storeEpoch;
const requestUserId = getCurrentUserId();
try {
const cached =
await getDb().getAll<ImManagerChannelApi.Channel>('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;
},

View File

@@ -67,6 +67,7 @@ export const StorageKeys = {
let currentDb: IDBDatabase | null = null;
let currentUserId: null | number = null;
let currentSession = 0;
let stopPromise: Promise<void> | undefined;
/** 校验当前 IM IndexedDB session 仍有效 */
export function isCurrentDbSession(session: number): boolean {
@@ -195,6 +196,9 @@ function openDb(name: string): Promise<IDBDatabase> {
/** 初始化当前用户 IM DB */
export async function initDb(): Promise<void> {
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<void> {
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<T> {
// 开启事务前校验 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<number>(key, tx)) || 0;
if (maxId > current) {
await db.setSetting(key, maxId, tx);
const updateMaxId = async (transaction: DbTransaction) => {
const current = (await db.getSetting<number>(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<void> {
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<void> {
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;
}