♻️ 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
im
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,
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<ConversationStoreMeta>(
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<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) {
console.error('[IM] 本地消息缓存读取失败', e)
}
},
/** 持久化到 localStorage */
saveToStorage() {
// loading 期间跳过,避免大量写入阻塞主线程
/**
* IndexedDBfire-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<unknown>[] = [
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
* idsendTimestatus
*/
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()
}
}
}

View File

@ -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 */

View File

@ -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<T, K> TS T K
* `Omit<Conversation, 'messages'>` "Conversation 去掉 messages 字段后的版本"
* "Conversation 派生但少一个 messages 字段"
*/
export type ConversationMeta = Omit<Conversation, 'messages'>
// 持久化的会话索引:游标 + 会话元数据列表,按用户 ID 分桶
export interface ConversationStoreMeta {
privateMessageMaxId: number // 私聊消息最大编号
groupMessageMaxId: number // 群聊消息最大编号
conversations: Conversation[] // 会话列表
conversations: ConversationMeta[] // 会话索引(不含 messages
}
// ==================== 群 / 群成员 ====================

View File

@ -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 imStorageIndexedDBkey `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}`,
/** 侧边栏宽度localStoragepage 区分消息 / 好友 / 群三个 Tab独立记忆。 */
asideWidth: (page: 'message' | 'friend' | 'group') => `im:aside:${page}`
} as const