fix(im): 修复账号切换时的异步状态竞态
- 增加 IndexedDB 用户与 session 守卫,串行衔接停止和初始化 - 隔离首页初始化及频道缓存异步任务,阻止旧账号响应回写 - 使用事务单调更新消息拉取游标,避免并发覆盖
This commit is contained in:
@@ -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;
|
||||
},
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
},
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
},
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user