From 66514fc59777a8664699c0578dd681150bc7db73 Mon Sep 17 00:00:00 2001 From: YunaiV Date: Sat, 25 Apr 2026 22:52:00 +0800 Subject: [PATCH] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20refactor(im):=20conversati?= =?UTF-8?q?onStore=20=E5=AD=98=E5=82=A8=E6=94=B9=E4=B8=BA=20IndexedDB=20?= =?UTF-8?q?=E6=8C=89=E4=BC=9A=E8=AF=9D=E5=88=86=E6=A1=B6=20+=20=E5=91=BD?= =?UTF-8?q?=E5=90=8D=E7=BB=9F=E4=B8=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 持久化迁到 localforage(IndexedDB),meta 索引与单会话 messages 分 key 存,消除写放大 - saveConversations 支持 不传 / 单个 / 数组 三种粒度;签名改为 sync void(fire-and-forget) - 修复 sortConversations 仅刷 meta 不刷 messages 导致离线消息重启丢失的 bug - 方法重命名:saveToStorage→saveConversations、updateMessageState→ackMessage、applyRecall→recallMessage、refreshConversations→sortConversations、removeLocalMessage→removeMessage、_removeMessagesStorage→removeConversationMessages - 删除 dead field Conversation.lastReadCount;TIP_TIME clientMessageId 改用 uuid --- src/views/im/home/store/conversationStore.ts | 201 ++++++++++++------- src/views/im/home/store/websocketStore.ts | 14 +- src/views/im/home/types/index.ts | 16 +- src/views/im/utils/storage.ts | 51 ++++- 4 files changed, 196 insertions(+), 86 deletions(-) diff --git a/src/views/im/home/store/conversationStore.ts b/src/views/im/home/store/conversationStore.ts index 35e467156..ebbfdd1b8 100644 --- a/src/views/im/home/store/conversationStore.ts +++ b/src/views/im/home/store/conversationStore.ts @@ -8,14 +8,16 @@ import { ImMessageStatus, TIME_TIP_GAP_MS } from '../../utils/constants' -import { StorageKeys } from '../../utils/storage' -import { parseMessage, buildRecallTip, type TextMessage } from '../../utils/message' -import type { Conversation, Message, ConversationsData } from '../types' +import { imStorage, StorageKeys } from '../../utils/storage' +import { + buildRecallTip, + generateClientMessageId, + parseMessage, + type TextMessage +} from '../../utils/message' +import type { Conversation, ConversationStoreMeta, Message } from '../types' const AT_ALL_FLAG = -1 // @全体成员 的特殊 userId 标识:atUserIds 中包含 -1 表示 @all -// 单会话持久化消息数上限:localStorage 整体配额一般 5~10MB,全量序列化容易撑爆。 -// 内存里保留完整历史,落盘只截最近 N 条;用户重启后历史不够再向后端拉。 -const MAX_PERSISTED_MESSAGES_PER_CONVERSATION = 100 /** 获取当前登录用户编号 */ function getCurrentUserId(): number { @@ -24,18 +26,13 @@ function getCurrentUserId(): number { return Number(user?.id) || 0 } -/** 当前登录用户的会话列表 localStorage key */ -function currentConversationsKey(): string { - return StorageKeys.conversations(getCurrentUserId()) -} - export const useConversationStore = defineStore('imConversationStore', { state: () => ({ conversations: [] as Conversation[], // 全量会话列表(私聊 + 群聊) activeConversation: null as Conversation | null, // 当前激活的会话 privateMessageMaxId: 0, // 私聊最大消息 id,作为 pull 的游标 groupMessageMaxId: 0, // 群聊最大消息 id,作为 pull 的游标 - loading: false // 是否正在批量加载(例如离线消息拉取期间),避免频繁写 localStorage + loading: false // 是否正在批量加载(例如离线消息拉取期间),避免频繁写存储 }), getters: { @@ -77,62 +74,116 @@ export const useConversationStore = defineStore('imConversationStore', { actions: { // ==================== 本地存储 ==================== - /** 从 localStorage 恢复会话数据 */ - loadConversations() { - const item = localStorage.getItem(currentConversationsKey()) - if (!item) { + /** + * 从 IndexedDB 恢复会话数据 + * + * 1. 读 meta(游标 + 会话索引),无 meta 直接返回 + * 2. 并发读取每个会话的消息 key,组装回 Conversation + * 3. 修正重启前遗留的"发送中"状态为失败 + */ + async loadConversations() { + const userId = getCurrentUserId() + if (!userId) { return } - try { - // 反序列化缓存数据,恢复消息游标(privateMessageMaxId / groupMessageMaxId) - const storageData = JSON.parse(item) as ConversationsData - this.privateMessageMaxId = Number(storageData.privateMessageMaxId) || 0 - this.groupMessageMaxId = Number(storageData.groupMessageMaxId) || 0 - - // 回放会话列表,同时修正重启前遗留的"发送中"状态 - if (storageData.conversations && storageData.conversations.length > 0) { - for (const conversation of storageData.conversations) { - if (conversation.messages) { - conversation.messages.forEach((message) => { - // 发送中状态的消息标记为失败:重启后不可能仍处在发送中 - if (message.status === ImMessageStatus.SENDING) { - message.status = ImMessageStatus.FAILED - } - }) - } - } - this.conversations = storageData.conversations + const meta = await imStorage.getItem( + StorageKeys.conversationMeta(userId) + ) + if (!meta) { + return } + this.privateMessageMaxId = Number(meta.privateMessageMaxId) || 0 + this.groupMessageMaxId = Number(meta.groupMessageMaxId) || 0 + if (!meta.conversations || meta.conversations.length === 0) { + return + } + + // 并发拉取每个会话的消息,组装回完整 Conversation; + // 单会话失败时退化为空消息列表 + 打印日志,避免拖垮整体加载 + const tasks = meta.conversations.map(async (conversation): Promise => { + try { + const messages = + (await imStorage.getItem( + StorageKeys.conversationMessage(userId, conversation.type, conversation.targetId) + )) || [] + // 发送中状态的消息标记为失败:重启后不可能仍处在发送中 + messages.forEach((message) => { + if (message.status === ImMessageStatus.SENDING) { + message.status = ImMessageStatus.FAILED + } + }) + return { ...conversation, messages } + } catch (e) { + console.warn( + '[IM] 单会话消息加载失败', + { type: conversation.type, targetId: conversation.targetId }, + e + ) + return { ...conversation, messages: [] } + } + }) + this.conversations = await Promise.all(tasks) } catch (e) { console.error('[IM] 本地消息缓存读取失败', e) } }, - /** 持久化到 localStorage */ - saveToStorage() { - // loading 期间跳过,避免大量写入阻塞主线程 + /** + * 持久化到 IndexedDB(fire-and-forget;调用方无需 await) + * + * - 不传 target:仅写 meta(适用于 top / muted / unread 等元数据变更) + * - 传单个 conversation:写 meta + 该会话的消息(单条消息变更走这里) + * - 传数组:写 meta + 数组里所有未删除会话的消息(loading 完成后兜底 flush 用) + * + * 按会话分桶后,单条消息变更只重写当前会话的消息 key,避免老方案的全量序列化。 + * 写入失败已在内部 catch 兜底(仅打印日志),不影响 UI 流程,所以接口签名设为 void。 + */ + saveConversations(target?: Conversation | Conversation[] | null): void { + // loading 期间跳过,避免离线消息批量到达时的密集写入 if (this.loading) { return } - - // TODO @AI:可能要调整存储方案; - // 落盘前对每个会话的 messages 做尾部截断,避免长会话把 localStorage 撑爆 - const storageData: ConversationsData = { + const userId = getCurrentUserId() + if (!userId) { + return + } + // 1. meta:游标 + 会话索引(剔除 messages,过滤软删除) + const meta: ConversationStoreMeta = { privateMessageMaxId: this.privateMessageMaxId, groupMessageMaxId: this.groupMessageMaxId, conversations: this.conversations .filter((c) => !c.deleted) - .map((c) => ({ - ...c, - messages: c.messages.slice(-MAX_PERSISTED_MESSAGES_PER_CONVERSATION) - })) + .map(({ messages, ...rest }) => rest) } - try { - localStorage.setItem(currentConversationsKey(), JSON.stringify(storageData)) - } catch (e) { - console.error('[IM] 本地消息缓存存储失败', e) + const tasks: Promise[] = [ + imStorage.setItem(StorageKeys.conversationMeta(userId), meta) + ] + // 2. 归一化 target 为待 flush 的会话列表,过滤掉已软删除的 + const conversationsToFlush: Conversation[] = ( + Array.isArray(target) ? target : target ? [target] : [] + ).filter((c) => !c.deleted) + for (const conversation of conversationsToFlush) { + tasks.push( + imStorage.setItem( + StorageKeys.conversationMessage(userId, conversation.type, conversation.targetId), + conversation.messages + ) + ) } + // 3. fire-and-forget:失败仅打日志,不影响 UI + void Promise.all(tasks).catch((e) => console.error('[IM] 本地消息缓存存储失败', e)) + }, + + /** 物理删除某个会话的消息 key(软删除会话时释放空间;fire-and-forget) */ + removeConversationMessages(type: number, targetId: number): void { + const userId = getCurrentUserId() + if (!userId) { + return + } + void imStorage + .removeItem(StorageKeys.conversationMessage(userId, type, targetId)) + .catch((e) => console.error('[IM] 本地消息缓存删除失败', e)) }, // ==================== 会话查找 / 打开 ==================== @@ -182,7 +233,8 @@ export const useConversationStore = defineStore('imConversationStore', { conversation.unreadCount = 0 conversation.atMe = false conversation.atAll = false - this.saveToStorage() + // 仅元数据变更(unreadCount / atMe / atAll),不动 messages + this.saveConversations() } }, @@ -216,7 +268,7 @@ export const useConversationStore = defineStore('imConversationStore', { return } conversation.top = top - this.saveToStorage() + this.saveConversations() }, /** 设置会话免打扰(本地状态;后端同步由 friendStore / groupStore + /muted API 负责) */ @@ -226,10 +278,10 @@ export const useConversationStore = defineStore('imConversationStore', { return } conversation.muted = muted - this.saveToStorage() + this.saveConversations() }, - /** 删除会话(软删:标记 deleted=true,持久化时过滤)*/ + /** 删除会话(软删:标记 deleted=true,持久化时过滤;同步物理删除消息 key 释放空间)*/ removeConversation(type: number, targetId: number) { const conversation = this.getConversation(type, targetId) if (!conversation) { @@ -239,7 +291,9 @@ export const useConversationStore = defineStore('imConversationStore', { this.activeConversation = null } conversation.deleted = true - this.saveToStorage() + // 软删后会话的消息文件不再有用,物理删除该 key + this.removeConversationMessages(type, targetId) + this.saveConversations() }, removePrivateConversation(friendId: number) { @@ -289,7 +343,7 @@ export const useConversationStore = defineStore('imConversationStore', { conversation.messages[existingIndex] = { ...conversation.messages[existingIndex], ...messageInfo } conversation.lastSendTime = messageInfo.sendTime || conversation.lastSendTime this.updateMaxId(conversationInfo.type, messageInfo.id) - this.saveToStorage() + this.saveConversations(conversation) return } @@ -336,7 +390,7 @@ export const useConversationStore = defineStore('imConversationStore', { if (!conversation.lastTimeTip || conversation.lastTimeTip < sendTime - TIME_TIP_GAP_MS) { conversation.messages.push({ id: 0, - clientMessageId: `tip-${sendTime}`, + clientMessageId: generateClientMessageId(), type: ImMessageType.TIP_TIME, content: '', status: ImMessageStatus.UNREAD, @@ -369,8 +423,8 @@ export const useConversationStore = defineStore('imConversationStore', { // 4.1 更新游标 this.updateMaxId(conversationInfo.type, messageInfo.id) - // 4.2 持久化到 localStorage - this.saveToStorage() + // 4.2 持久化:消息 + meta + this.saveConversations(conversation) }, /** 根据消息类型计算会话列表最后一条摘要 */ @@ -400,7 +454,7 @@ export const useConversationStore = defineStore('imConversationStore', { * 乐观更新回填:本地先以 SENDING 状态插入临时消息(id=0 + clientMessageId), * 待服务端返回后再用此方法回填真实 id、sendTime、status 等字段。 */ - updateMessageState( + ackMessage( conversationType: number, targetId: number, clientMessageId: string, @@ -418,14 +472,14 @@ export const useConversationStore = defineStore('imConversationStore', { if (updates.id) { this.updateMaxId(conversationType, updates.id) } - this.saveToStorage() + this.saveConversations(conversation) }, /** * 撤回消息:将原消息 type 改为 RECALL,并刷新会话摘要 * 对应后端 RECALL 事件:按原 messageId 更新 */ - applyRecall( + recallMessage( conversationType: number, targetId: number, messageId: number, @@ -449,7 +503,7 @@ export const useConversationStore = defineStore('imConversationStore', { if (conversation.messages[conversation.messages.length - 1]?.id === messageId) { conversation.lastContent = buildRecallTip(senderNickName, selfSend) } - this.saveToStorage() + this.saveConversations(conversation) }, /** 处理对方已读 / 群回执:更新发送方自己消息的 status / readCount / receiptStatus */ @@ -484,14 +538,14 @@ export const useConversationStore = defineStore('imConversationStore', { } } } - this.saveToStorage() + this.saveConversations(conversation) }, /** * 从本地消息列表移除一条消息(右键"删除";不同步后端) * 按 id 优先匹配;若 id 为 0(本地发送中),则按 clientMessageId 匹配 */ - removeLocalMessage( + removeMessage( conversationType: number, targetId: number, key: { id?: number; clientMessageId?: string } @@ -517,7 +571,7 @@ export const useConversationStore = defineStore('imConversationStore', { conversation.lastSendTime = last?.sendTime || conversation.lastSendTime conversation.senderNickName = last?.senderNickName || '' } - this.saveToStorage() + this.saveConversations(conversation) }, /** @@ -536,7 +590,7 @@ export const useConversationStore = defineStore('imConversationStore', { message.status = ImMessageStatus.READ } }) - this.saveToStorage() + this.saveConversations(this.activeConversation) }, /** 更新 privateMessageMaxId / groupMessageMaxId 游标 */ @@ -555,10 +609,15 @@ export const useConversationStore = defineStore('imConversationStore', { } }, - /** 离线消息加载完后重排:按 lastSendTime 倒序并持久化 */ - refreshConversations() { + /** + * 离线消息加载完后重排:按 lastSendTime 倒序,并把 loading 期间累积的内存变更全量 flush + * + * loading 期间 saveConversations 都会被早 return 跳过,这里把所有会话作为数组传入兜底, + * 否则离线拉取的消息只在内存里、未落盘,重启会丢。 + */ + sortConversations() { this.conversations.sort((a, b) => b.lastSendTime - a.lastSendTime) - this.saveToStorage() + this.saveConversations(this.conversations) }, /** @@ -591,7 +650,7 @@ export const useConversationStore = defineStore('imConversationStore', { changed = true } if (changed) { - this.saveToStorage() + this.saveConversations() } } } diff --git a/src/views/im/home/store/websocketStore.ts b/src/views/im/home/store/websocketStore.ts index 8a9c69dea..bd60a0e17 100644 --- a/src/views/im/home/store/websocketStore.ts +++ b/src/views/im/home/store/websocketStore.ts @@ -220,7 +220,7 @@ export const useImWebSocketStore = defineStore('imWebSocketStore', { * 流程: * 1. 离线加载期缓冲(避开与 pull 回填的竞态) * 2. 计算 selfSend / peerId 维度,拉好友信息回填展示字段 - * 3. 撤回 TIP 短路:转走 applyRecall,不进消息列表 + * 3. 撤回 TIP 短路:转走 recallMessage,不进消息列表 * 4. 构造前端 Message,插入到对应私聊会话 * 5. 当前会话激活时自动上报已读;否则非免打扰响提示音 */ @@ -245,11 +245,11 @@ export const useImWebSocketStore = defineStore('imWebSocketStore', { } // 3. 后端撤回:下发一条 RECALL 消息,content 为 `{"messageId": xxx}`(对齐 ImMessageTypeEnum.RECALL → RecallMessage) - // 这里拦截下来改走 applyRecall(把原消息翻转为 RECALL 态),不让它作为新消息进列表 + // 这里拦截下来改走 recallMessage(把原消息翻转为 RECALL 态),不让它作为新消息进列表 if (websocketMessage.type === ImMessageType.RECALL) { const recallMessageId = parseRecallMessageId(websocketMessage.content) if (recallMessageId) { - conversationStore.applyRecall( + conversationStore.recallMessage( ImConversationType.PRIVATE, peerId, recallMessageId, @@ -312,7 +312,7 @@ export const useImWebSocketStore = defineStore('imWebSocketStore', { if (conversation) { conversation.unreadCount = 0 } - conversationStore.saveToStorage() + conversationStore.saveConversations() }, /** 私聊 RECEIPT 事件:对方读了我的消息,把和对方会话里自己发的消息标为已读 */ @@ -357,11 +357,11 @@ export const useImWebSocketStore = defineStore('imWebSocketStore', { const senderNickName = senderMember?.displayUserName || senderMember?.nickname || '' // 3. 后端撤回:下发一条 RECALL 消息,content 为 `{"messageId": xxx}` - // 这里拦截下来改走 applyRecall(把原消息翻转为 RECALL 态) + // 这里拦截下来改走 recallMessage(把原消息翻转为 RECALL 态) if (websocketMessage.type === ImMessageType.RECALL) { const recallMessageId = parseRecallMessageId(websocketMessage.content) if (recallMessageId) { - conversationStore.applyRecall( + conversationStore.recallMessage( ImConversationType.GROUP, websocketMessage.groupId, recallMessageId, @@ -430,7 +430,7 @@ export const useImWebSocketStore = defineStore('imWebSocketStore', { if (conversation) { conversation.unreadCount = 0 } - conversationStore.saveToStorage() + conversationStore.saveConversations() }, /** 群聊 RECEIPT:更新某条群消息的 readCount / receiptStatus */ diff --git a/src/views/im/home/types/index.ts b/src/views/im/home/types/index.ts index 66c65d909..ae59b9a10 100644 --- a/src/views/im/home/types/index.ts +++ b/src/views/im/home/types/index.ts @@ -57,7 +57,6 @@ export interface Conversation { muted?: boolean // 是否免打扰(不展示未读徽标 + 不响提示音) atMe?: boolean // 群聊:是否有人 @我 atAll?: boolean // 群聊:是否有人 @全体成员 - lastReadCount?: number // 群回执:当前会话最近一条需回执消息的已读人数 lastTimeTip?: number // 最后一条"时间分隔线"的时间戳,判断是否需要插入下一条 TIP_TIME } @@ -82,11 +81,20 @@ export interface Message { selfSend: boolean // 是否自己发送(前端按 senderId 计算) } -// localStorage 存储结构:按用户 ID 分桶,保存所有会话元数据 -export interface ConversationsData { +/** + * 会话索引项:基于 Conversation 派生,但剥离 messages 字段(消息按会话独立存到 messages key) + * + * Omit 是 TS 内置工具类型:从类型 T 中剔除 K 指定的字段,得到剩余字段组成的新类型。 + * 这里 `Omit` 等价于"Conversation 去掉 messages 字段后的版本", + * 与"Conversation 派生但少一个 messages 字段"的语义一致,不需要再手写一份重复结构。 + */ +export type ConversationMeta = Omit + +// 持久化的会话索引:游标 + 会话元数据列表,按用户 ID 分桶 +export interface ConversationStoreMeta { privateMessageMaxId: number // 私聊消息最大编号 groupMessageMaxId: number // 群聊消息最大编号 - conversations: Conversation[] // 会话列表 + conversations: ConversationMeta[] // 会话索引(不含 messages) } // ==================== 群 / 群成员 ==================== diff --git a/src/views/im/utils/storage.ts b/src/views/im/utils/storage.ts index 69ee3efa1..fb8eb6daf 100644 --- a/src/views/im/utils/storage.ts +++ b/src/views/im/utils/storage.ts @@ -1,6 +1,49 @@ -// localStorage key 统一在此生成。im: 前缀避免与其他模块冲突。 -// 当前数据量(会话 / 消息)直接用 localStorage 满足,不需要 IndexedDB。 +import localforage from 'localforage' + +/** + * IM 模块的 IndexedDB 实例(localforage 优先 IndexedDB,自动降级到 WebSQL / localStorage) + * + * 为什么不用 localStorage 直接存: + * 1. 配额:localStorage 整体上限 5~10MB,多会话长历史很容易撑爆 + * 2. 写放大:localStorage 必须按 key 整体写入,单次写就是 MB 级序列化阻塞主线程 + * + * 配套策略:会话与消息按 key 分桶(见 StorageKeys),让单次变更只重写最小粒度的 key。 + * IndexedDB 默认配额一般是浏览器可用空间的 ~50%,远大于 localStorage,配合分桶才发挥效果。 + */ +export const imStorage = localforage.createInstance({ + name: 'im', + storeName: 'conversation', + description: 'IM 会话索引与消息缓存' +}) + +/** + * 存储 key 统一在此生成 + * + * - 会话相关(meta / message)走 imStorage(IndexedDB),key 形如 `conversation:xxx:{userId}:...` + * - 轻量 UI 状态(侧边栏宽度)仍走 localStorage:体积小、跨 Tab 同步天然,没必要走 IndexedDB + * + * 所有会话相关 key 都注入 userId:多账号切换时按用户隔离,避免数据互串。 + */ export const StorageKeys = { - conversations: (userId: number | string) => `im:conversations:${userId}`, - asideWidth: (page: 'friend' | 'group') => `im:aside:${page}` + /** + * 会话索引:游标 + 会话元数据(不含 messages),对应 ConversationStoreMeta + * + * 任何会话级元数据变更(top / muted / unread / 列表增删 / 排序)都会重写这一个 key; + * 由于 messages 已剥离到独立 key,单次写体积稳定(仅元数据,量级 KB 级)。 + */ + conversationMeta: (userId: number | string) => `conversation:meta:${userId}`, + /** + * 单会话消息:按 (type, targetId) 分桶,存 Message[] + * + * - type:私聊 / 群聊(对齐 ImConversationType) + * - targetId:私聊的对方 userId / 群聊的 groupId + * + * 每条消息变更只重写当前会话这一个 key,避免老方案"全量写所有会话所有消息"的写放大。 + * 软删除会话时由 conversationStore.removeConversationMessages 物理删除该 key,避免 orphan 残留。 + */ + conversationMessage: (userId: number | string, type: number, targetId: number) => + `conversation:message:${userId}:${type}:${targetId}`, + + /** 侧边栏宽度(localStorage);page 区分消息 / 好友 / 群三个 Tab,独立记忆。 */ + asideWidth: (page: 'message' | 'friend' | 'group') => `im:aside:${page}` } as const