♻️ refactor(im): conversationStore 存储改为 IndexedDB 按会话分桶 + 命名统一

- 持久化迁到 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
pull/881/head
YunaiV 2026-04-25 22:52:00 +08:00
parent 2785e2bea6
commit 66514fc597
4 changed files with 196 additions and 86 deletions

View File

@ -8,14 +8,16 @@ import {
ImMessageStatus, ImMessageStatus,
TIME_TIP_GAP_MS TIME_TIP_GAP_MS
} from '../../utils/constants' } from '../../utils/constants'
import { StorageKeys } from '../../utils/storage' import { imStorage, StorageKeys } from '../../utils/storage'
import { parseMessage, buildRecallTip, type TextMessage } from '../../utils/message' import {
import type { Conversation, Message, ConversationsData } from '../types' buildRecallTip,
generateClientMessageId,
parseMessage,
type TextMessage
} from '../../utils/message'
import type { Conversation, ConversationStoreMeta, Message } from '../types'
const AT_ALL_FLAG = -1 // @全体成员 的特殊 userId 标识:atUserIds 中包含 -1 表示 @all const AT_ALL_FLAG = -1 // @全体成员 的特殊 userId 标识:atUserIds 中包含 -1 表示 @all
// 单会话持久化消息数上限:localStorage 整体配额一般 5~10MB,全量序列化容易撑爆。
// 内存里保留完整历史,落盘只截最近 N 条;用户重启后历史不够再向后端拉。
const MAX_PERSISTED_MESSAGES_PER_CONVERSATION = 100
/** 获取当前登录用户编号 */ /** 获取当前登录用户编号 */
function getCurrentUserId(): number { function getCurrentUserId(): number {
@ -24,18 +26,13 @@ function getCurrentUserId(): number {
return Number(user?.id) || 0 return Number(user?.id) || 0
} }
/** 当前登录用户的会话列表 localStorage key */
function currentConversationsKey(): string {
return StorageKeys.conversations(getCurrentUserId())
}
export const useConversationStore = defineStore('imConversationStore', { export const useConversationStore = defineStore('imConversationStore', {
state: () => ({ state: () => ({
conversations: [] as Conversation[], // 全量会话列表(私聊 + 群聊) conversations: [] as Conversation[], // 全量会话列表(私聊 + 群聊)
activeConversation: null as Conversation | null, // 当前激活的会话 activeConversation: null as Conversation | null, // 当前激活的会话
privateMessageMaxId: 0, // 私聊最大消息 id,作为 pull 的游标 privateMessageMaxId: 0, // 私聊最大消息 id,作为 pull 的游标
groupMessageMaxId: 0, // 群聊最大消息 id,作为 pull 的游标 groupMessageMaxId: 0, // 群聊最大消息 id,作为 pull 的游标
loading: false // 是否正在批量加载(例如离线消息拉取期间),避免频繁写 localStorage loading: false // 是否正在批量加载(例如离线消息拉取期间),避免频繁写存储
}), }),
getters: { getters: {
@ -77,62 +74,116 @@ export const useConversationStore = defineStore('imConversationStore', {
actions: { actions: {
// ==================== 本地存储 ==================== // ==================== 本地存储 ====================
/** 从 localStorage 恢复会话数据 */ /**
loadConversations() { * 从 IndexedDB 恢复会话数据
const item = localStorage.getItem(currentConversationsKey()) *
if (!item) { * 1. 读 meta(游标 + 会话索引),无 meta 直接返回
* 2. 并发读取每个会话的消息 key,组装回 Conversation
* 3. 修正重启前遗留的"发送中"状态为失败
*/
async loadConversations() {
const userId = getCurrentUserId()
if (!userId) {
return return
} }
try { try {
// 反序列化缓存数据,恢复消息游标(privateMessageMaxId / groupMessageMaxId) const meta = await imStorage.getItem<ConversationStoreMeta>(
const storageData = JSON.parse(item) as ConversationsData StorageKeys.conversationMeta(userId)
this.privateMessageMaxId = Number(storageData.privateMessageMaxId) || 0 )
this.groupMessageMaxId = Number(storageData.groupMessageMaxId) || 0 if (!meta) {
return
// 回放会话列表,同时修正重启前遗留的"发送中"状态
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
} }
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<Conversation> => {
try {
const messages =
(await imStorage.getItem<Message[]>(
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) { } catch (e) {
console.error('[IM] 本地消息缓存读取失败', e) console.error('[IM] 本地消息缓存读取失败', e)
} }
}, },
/** 持久化到 localStorage */ /**
saveToStorage() { * 持久化到 IndexedDB(fire-and-forget;调用方无需 await)
// loading 期间跳过,避免大量写入阻塞主线程 *
* - 不传 target:仅写 meta(适用于 top / muted / unread 等元数据变更)
* - 传单个 conversation:写 meta + 该会话的消息(单条消息变更走这里)
* - 传数组:写 meta + 数组里所有未删除会话的消息(loading 完成后兜底 flush 用)
*
* 按会话分桶后,单条消息变更只重写当前会话的消息 key,避免老方案的全量序列化。
* 写入失败已在内部 catch 兜底(仅打印日志),不影响 UI 流程,所以接口签名设为 void。
*/
saveConversations(target?: Conversation | Conversation[] | null): void {
// loading 期间跳过,避免离线消息批量到达时的密集写入
if (this.loading) { if (this.loading) {
return return
} }
const userId = getCurrentUserId()
// TODO @AI:可能要调整存储方案; if (!userId) {
// 落盘前对每个会话的 messages 做尾部截断,避免长会话把 localStorage 撑爆 return
const storageData: ConversationsData = { }
// 1. meta:游标 + 会话索引(剔除 messages,过滤软删除)
const meta: ConversationStoreMeta = {
privateMessageMaxId: this.privateMessageMaxId, privateMessageMaxId: this.privateMessageMaxId,
groupMessageMaxId: this.groupMessageMaxId, groupMessageMaxId: this.groupMessageMaxId,
conversations: this.conversations conversations: this.conversations
.filter((c) => !c.deleted) .filter((c) => !c.deleted)
.map((c) => ({ .map(({ messages, ...rest }) => rest)
...c,
messages: c.messages.slice(-MAX_PERSISTED_MESSAGES_PER_CONVERSATION)
}))
} }
try { const tasks: Promise<unknown>[] = [
localStorage.setItem(currentConversationsKey(), JSON.stringify(storageData)) imStorage.setItem(StorageKeys.conversationMeta(userId), meta)
} catch (e) { ]
console.error('[IM] 本地消息缓存存储失败', e) // 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.unreadCount = 0
conversation.atMe = false conversation.atMe = false
conversation.atAll = false conversation.atAll = false
this.saveToStorage() // 仅元数据变更(unreadCount / atMe / atAll),不动 messages
this.saveConversations()
} }
}, },
@ -216,7 +268,7 @@ export const useConversationStore = defineStore('imConversationStore', {
return return
} }
conversation.top = top conversation.top = top
this.saveToStorage() this.saveConversations()
}, },
/** 设置会话免打扰(本地状态;后端同步由 friendStore / groupStore + /muted API 负责) */ /** 设置会话免打扰(本地状态;后端同步由 friendStore / groupStore + /muted API 负责) */
@ -226,10 +278,10 @@ export const useConversationStore = defineStore('imConversationStore', {
return return
} }
conversation.muted = muted conversation.muted = muted
this.saveToStorage() this.saveConversations()
}, },
/** 删除会话(软删:标记 deleted=true,持久化时过滤)*/ /** 删除会话(软删:标记 deleted=true,持久化时过滤;同步物理删除消息 key 释放空间)*/
removeConversation(type: number, targetId: number) { removeConversation(type: number, targetId: number) {
const conversation = this.getConversation(type, targetId) const conversation = this.getConversation(type, targetId)
if (!conversation) { if (!conversation) {
@ -239,7 +291,9 @@ export const useConversationStore = defineStore('imConversationStore', {
this.activeConversation = null this.activeConversation = null
} }
conversation.deleted = true conversation.deleted = true
this.saveToStorage() // 软删后会话的消息文件不再有用,物理删除该 key
this.removeConversationMessages(type, targetId)
this.saveConversations()
}, },
removePrivateConversation(friendId: number) { removePrivateConversation(friendId: number) {
@ -289,7 +343,7 @@ export const useConversationStore = defineStore('imConversationStore', {
conversation.messages[existingIndex] = { ...conversation.messages[existingIndex], ...messageInfo } conversation.messages[existingIndex] = { ...conversation.messages[existingIndex], ...messageInfo }
conversation.lastSendTime = messageInfo.sendTime || conversation.lastSendTime conversation.lastSendTime = messageInfo.sendTime || conversation.lastSendTime
this.updateMaxId(conversationInfo.type, messageInfo.id) this.updateMaxId(conversationInfo.type, messageInfo.id)
this.saveToStorage() this.saveConversations(conversation)
return return
} }
@ -336,7 +390,7 @@ export const useConversationStore = defineStore('imConversationStore', {
if (!conversation.lastTimeTip || conversation.lastTimeTip < sendTime - TIME_TIP_GAP_MS) { if (!conversation.lastTimeTip || conversation.lastTimeTip < sendTime - TIME_TIP_GAP_MS) {
conversation.messages.push({ conversation.messages.push({
id: 0, id: 0,
clientMessageId: `tip-${sendTime}`, clientMessageId: generateClientMessageId(),
type: ImMessageType.TIP_TIME, type: ImMessageType.TIP_TIME,
content: '', content: '',
status: ImMessageStatus.UNREAD, status: ImMessageStatus.UNREAD,
@ -369,8 +423,8 @@ export const useConversationStore = defineStore('imConversationStore', {
// 4.1 更新游标 // 4.1 更新游标
this.updateMaxId(conversationInfo.type, messageInfo.id) this.updateMaxId(conversationInfo.type, messageInfo.id)
// 4.2 持久化到 localStorage // 4.2 持久化:消息 + meta
this.saveToStorage() this.saveConversations(conversation)
}, },
/** 根据消息类型计算会话列表最后一条摘要 */ /** 根据消息类型计算会话列表最后一条摘要 */
@ -400,7 +454,7 @@ export const useConversationStore = defineStore('imConversationStore', {
* 乐观更新回填:本地先以 SENDING 状态插入临时消息(id=0 + clientMessageId), * 乐观更新回填:本地先以 SENDING 状态插入临时消息(id=0 + clientMessageId),
* 待服务端返回后再用此方法回填真实 id、sendTime、status 等字段。 * 待服务端返回后再用此方法回填真实 id、sendTime、status 等字段。
*/ */
updateMessageState( ackMessage(
conversationType: number, conversationType: number,
targetId: number, targetId: number,
clientMessageId: string, clientMessageId: string,
@ -418,14 +472,14 @@ export const useConversationStore = defineStore('imConversationStore', {
if (updates.id) { if (updates.id) {
this.updateMaxId(conversationType, updates.id) this.updateMaxId(conversationType, updates.id)
} }
this.saveToStorage() this.saveConversations(conversation)
}, },
/** /**
* 撤回消息:将原消息 type 改为 RECALL,并刷新会话摘要 * 撤回消息:将原消息 type 改为 RECALL,并刷新会话摘要
* 对应后端 RECALL 事件:按原 messageId 更新 * 对应后端 RECALL 事件:按原 messageId 更新
*/ */
applyRecall( recallMessage(
conversationType: number, conversationType: number,
targetId: number, targetId: number,
messageId: number, messageId: number,
@ -449,7 +503,7 @@ export const useConversationStore = defineStore('imConversationStore', {
if (conversation.messages[conversation.messages.length - 1]?.id === messageId) { if (conversation.messages[conversation.messages.length - 1]?.id === messageId) {
conversation.lastContent = buildRecallTip(senderNickName, selfSend) conversation.lastContent = buildRecallTip(senderNickName, selfSend)
} }
this.saveToStorage() this.saveConversations(conversation)
}, },
/** 处理对方已读 / 群回执:更新发送方自己消息的 status / readCount / receiptStatus */ /** 处理对方已读 / 群回执:更新发送方自己消息的 status / readCount / receiptStatus */
@ -484,14 +538,14 @@ export const useConversationStore = defineStore('imConversationStore', {
} }
} }
} }
this.saveToStorage() this.saveConversations(conversation)
}, },
/** /**
* 从本地消息列表移除一条消息(右键"删除";不同步后端) * 从本地消息列表移除一条消息(右键"删除";不同步后端)
* 按 id 优先匹配;若 id 为 0(本地发送中),则按 clientMessageId 匹配 * 按 id 优先匹配;若 id 为 0(本地发送中),则按 clientMessageId 匹配
*/ */
removeLocalMessage( removeMessage(
conversationType: number, conversationType: number,
targetId: number, targetId: number,
key: { id?: number; clientMessageId?: string } key: { id?: number; clientMessageId?: string }
@ -517,7 +571,7 @@ export const useConversationStore = defineStore('imConversationStore', {
conversation.lastSendTime = last?.sendTime || conversation.lastSendTime conversation.lastSendTime = last?.sendTime || conversation.lastSendTime
conversation.senderNickName = last?.senderNickName || '' conversation.senderNickName = last?.senderNickName || ''
} }
this.saveToStorage() this.saveConversations(conversation)
}, },
/** /**
@ -536,7 +590,7 @@ export const useConversationStore = defineStore('imConversationStore', {
message.status = ImMessageStatus.READ message.status = ImMessageStatus.READ
} }
}) })
this.saveToStorage() this.saveConversations(this.activeConversation)
}, },
/** 更新 privateMessageMaxId / groupMessageMaxId 游标 */ /** 更新 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.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 changed = true
} }
if (changed) { if (changed) {
this.saveToStorage() this.saveConversations()
} }
} }
} }

View File

@ -220,7 +220,7 @@ export const useImWebSocketStore = defineStore('imWebSocketStore', {
* 流程: * 流程:
* 1. 离线加载期缓冲(避开与 pull 回填的竞态) * 1. 离线加载期缓冲(避开与 pull 回填的竞态)
* 2. 计算 selfSend / peerId 维度,拉好友信息回填展示字段 * 2. 计算 selfSend / peerId 维度,拉好友信息回填展示字段
* 3. 撤回 TIP 短路:转走 applyRecall,不进消息列表 * 3. 撤回 TIP 短路:转走 recallMessage,不进消息列表
* 4. 构造前端 Message,插入到对应私聊会话 * 4. 构造前端 Message,插入到对应私聊会话
* 5. 当前会话激活时自动上报已读;否则非免打扰响提示音 * 5. 当前会话激活时自动上报已读;否则非免打扰响提示音
*/ */
@ -245,11 +245,11 @@ export const useImWebSocketStore = defineStore('imWebSocketStore', {
} }
// 3. 后端撤回:下发一条 RECALL 消息,content 为 `{"messageId": xxx}`(对齐 ImMessageTypeEnum.RECALL → RecallMessage) // 3. 后端撤回:下发一条 RECALL 消息,content 为 `{"messageId": xxx}`(对齐 ImMessageTypeEnum.RECALL → RecallMessage)
// 这里拦截下来改走 applyRecall(把原消息翻转为 RECALL 态),不让它作为新消息进列表 // 这里拦截下来改走 recallMessage(把原消息翻转为 RECALL 态),不让它作为新消息进列表
if (websocketMessage.type === ImMessageType.RECALL) { if (websocketMessage.type === ImMessageType.RECALL) {
const recallMessageId = parseRecallMessageId(websocketMessage.content) const recallMessageId = parseRecallMessageId(websocketMessage.content)
if (recallMessageId) { if (recallMessageId) {
conversationStore.applyRecall( conversationStore.recallMessage(
ImConversationType.PRIVATE, ImConversationType.PRIVATE,
peerId, peerId,
recallMessageId, recallMessageId,
@ -312,7 +312,7 @@ export const useImWebSocketStore = defineStore('imWebSocketStore', {
if (conversation) { if (conversation) {
conversation.unreadCount = 0 conversation.unreadCount = 0
} }
conversationStore.saveToStorage() conversationStore.saveConversations()
}, },
/** 私聊 RECEIPT 事件:对方读了我的消息,把和对方会话里自己发的消息标为已读 */ /** 私聊 RECEIPT 事件:对方读了我的消息,把和对方会话里自己发的消息标为已读 */
@ -357,11 +357,11 @@ export const useImWebSocketStore = defineStore('imWebSocketStore', {
const senderNickName = senderMember?.displayUserName || senderMember?.nickname || '' const senderNickName = senderMember?.displayUserName || senderMember?.nickname || ''
// 3. 后端撤回:下发一条 RECALL 消息,content 为 `{"messageId": xxx}` // 3. 后端撤回:下发一条 RECALL 消息,content 为 `{"messageId": xxx}`
// 这里拦截下来改走 applyRecall(把原消息翻转为 RECALL 态) // 这里拦截下来改走 recallMessage(把原消息翻转为 RECALL 态)
if (websocketMessage.type === ImMessageType.RECALL) { if (websocketMessage.type === ImMessageType.RECALL) {
const recallMessageId = parseRecallMessageId(websocketMessage.content) const recallMessageId = parseRecallMessageId(websocketMessage.content)
if (recallMessageId) { if (recallMessageId) {
conversationStore.applyRecall( conversationStore.recallMessage(
ImConversationType.GROUP, ImConversationType.GROUP,
websocketMessage.groupId, websocketMessage.groupId,
recallMessageId, recallMessageId,
@ -430,7 +430,7 @@ export const useImWebSocketStore = defineStore('imWebSocketStore', {
if (conversation) { if (conversation) {
conversation.unreadCount = 0 conversation.unreadCount = 0
} }
conversationStore.saveToStorage() conversationStore.saveConversations()
}, },
/** 群聊 RECEIPT:更新某条群消息的 readCount / receiptStatus */ /** 群聊 RECEIPT:更新某条群消息的 readCount / receiptStatus */

View File

@ -57,7 +57,6 @@ export interface Conversation {
muted?: boolean // 是否免打扰(不展示未读徽标 + 不响提示音) muted?: boolean // 是否免打扰(不展示未读徽标 + 不响提示音)
atMe?: boolean // 群聊:是否有人 @我 atMe?: boolean // 群聊:是否有人 @我
atAll?: boolean // 群聊:是否有人 @全体成员 atAll?: boolean // 群聊:是否有人 @全体成员
lastReadCount?: number // 群回执:当前会话最近一条需回执消息的已读人数
lastTimeTip?: number // 最后一条"时间分隔线"的时间戳,判断是否需要插入下一条 TIP_TIME lastTimeTip?: number // 最后一条"时间分隔线"的时间戳,判断是否需要插入下一条 TIP_TIME
} }
@ -82,11 +81,20 @@ export interface Message {
selfSend: boolean // 是否自己发送(前端按 senderId 计算) selfSend: boolean // 是否自己发送(前端按 senderId 计算)
} }
// localStorage 存储结构:按用户 ID 分桶,保存所有会话元数据 /**
export interface ConversationsData { * 会话索引项:基于 Conversation 派生,但剥离 messages 字段(消息按会话独立存到 messages key)
*
* Omit<T, K> 是 TS 内置工具类型:从类型 T 中剔除 K 指定的字段,得到剩余字段组成的新类型。
* 这里 `Omit<Conversation, 'messages'>` 等价于"Conversation 去掉 messages 字段后的版本",
* 与"Conversation 派生但少一个 messages 字段"的语义一致,不需要再手写一份重复结构。
*/
export type ConversationMeta = Omit<Conversation, 'messages'>
// 持久化的会话索引:游标 + 会话元数据列表,按用户 ID 分桶
export interface ConversationStoreMeta {
privateMessageMaxId: number // 私聊消息最大编号 privateMessageMaxId: number // 私聊消息最大编号
groupMessageMaxId: number // 群聊消息最大编号 groupMessageMaxId: number // 群聊消息最大编号
conversations: Conversation[] // 会话列表 conversations: ConversationMeta[] // 会话索引(不含 messages)
} }
// ==================== 群 / 群成员 ==================== // ==================== 群 / 群成员 ====================

View File

@ -1,6 +1,49 @@
// localStorage key 统一在此生成。im: 前缀避免与其他模块冲突。 import localforage from 'localforage'
// 当前数据量(会话 / 消息)直接用 localStorage 满足,不需要 IndexedDB。
/**
* 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 = { 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 } as const