fix(im): 修复重新登录会话未读闪烁

- 新增会话读位置本地存储,独立维护 conversationReads
- 启动时先恢复本地读位置,并在会话列表渲染前修正未读状态
- 消息入库时基于读位置过滤已读历史消息,避免重新累计未读
- READ 同步与主动已读统一走 conversationStore,保证读位置单调推进
- 兼容旧会话 readMessageId 数据迁移
pull/884/MERGE
YunaiV 2026-06-17 13:30:16 +08:00
parent 8ba76813ae
commit 07c8f143ea
8 changed files with 406 additions and 188 deletions

View File

@ -19,7 +19,6 @@ import {
pullChannelMessages as apiPullChannelMessages,
type ImChannelMessageRespVO
} from '@/api/im/message/channel'
import { pullMyConversationReadList as apiPullMyConversationReadList } from '@/api/im/conversation/read'
import {
ImConversationType,
ImMessageStatus,
@ -34,8 +33,7 @@ import {
} from '../../utils/config'
import { buildChannelConversationStub } from '../../utils/channel'
import { generateClientMessageId, getPrivateMessagePeerId } from '../../utils/message'
import { runIncrementalPull, runMinIdPull } from '../../utils/pull'
import { StorageKeys } from '../../utils/db'
import { runMinIdPull } from '../../utils/pull'
import { getCurrentUserId } from '@/utils/auth'
import type { Message } from '../types'
@ -285,25 +283,6 @@ export const useMessagePuller = () => {
wsStore.discardBuffer()
}
/** 增量拉取我的会话读位置并合并到本地展示态 */
const pullConversationReads = async (isActive: () => boolean): Promise<void> => {
await runIncrementalPull(
StorageKeys.settings.conversationReadPullCursor,
apiPullMyConversationReadList,
async (records) => {
if (!isActive()) {
return false
}
await messageStore.applyConversationReadList(records, isActive)
if (!isActive()) {
return false
}
return true
},
isActive
)
}
/**
* /
*
@ -315,6 +294,7 @@ export const useMessagePuller = () => {
const results = await Promise.allSettled([
friendStore.pullFriends(),
friendStore.pullFriendRequests(),
conversationStore.pullConversationReads(),
groupStore.fetchGroupList(true),
groupRequestStore.pullGroupRequests(),
groupRequestStore.fetchUnhandledGroupRequestList()
@ -415,12 +395,6 @@ export const useMessagePuller = () => {
// pull + replay 都完成后再排序,避免回放消息打乱顺序
conversationStore.sortConversationList()
// 消息和缓冲帧落库后再补读位置,避免读位置游标先推进导致新消息展示态漏更新
await pullConversationReads(isCurrentPull)
if (!isCurrentPull()) {
return
}
// 重连 / 冷启动后补齐当前激活私聊会话的「对方已读位置」
// 离线期间错过的 RECEIPT 推送会被这里补回;其他私聊会话等用户点开时由 Index.vue 的 watch 触发
// 私聊已读关闭时跳过,避免打到已禁用接口触发错误日志

View File

@ -229,8 +229,6 @@ export const useMessageSender = () => {
if (!conversation) {
return
}
// 本地标记已读未读数清零UI 立刻响应)
conversationStore.markConversationRead(conversation.type, conversation.targetId)
const loadedMaxMessageId = messageStore
.getMessages(getClientConversationId(conversation.type, conversation.targetId))
.reduce<number>(
@ -239,6 +237,8 @@ export const useMessageSender = () => {
0
)
const maxMessageId = Math.max(loadedMaxMessageId, conversation.lastMessageId || 0)
// 本地标记已读未读数清零UI 立刻响应)
conversationStore.markConversationRead(conversation.type, conversation.targetId, maxMessageId)
if (!maxMessageId) {
return
}

View File

@ -125,11 +125,16 @@ onMounted(async () => {
.pullFriendRequests()
.catch((e) => console.warn('[IM] 后台增量拉好友申请失败', e))
// 3. WebSocket + 线pullOnce finally loading
// 3.
await conversationStore
.pullConversationReads()
.catch((e) => console.warn('[IM] 拉取会话读位置失败', e))
// 4. WebSocket + 线pullOnce finally loading
webSocketStore.connect()
await pullOnce()
// 4.
// 5.
const sorted = conversationStore.getSortedConversationList
const firstVisible = pickFirstVisibleConversation(sorted)
if (firstVisible && !conversationStore.activeConversation) {

View File

@ -3,15 +3,33 @@ import { debounce } from 'lodash-es'
import { store } from '@/store'
import { CONVERSATION_RECENT_FORWARD_MAX } from '../../utils/config'
import { ImConversationType } from '../../utils/constants'
import {
ImConversationType,
ImMessageReceiptStatus,
ImMessageStatus,
isNormalMessage
} from '../../utils/constants'
import { getClientConversationId, getDb, StorageKeys, type DbTransaction } from '../../utils/db'
import { runIncrementalPull } from '../../utils/pull'
import { getCurrentUserId } from '@/utils/auth'
import { useMessageStore } from './messageStore'
import type { Conversation, ConversationDO } from '../types'
import {
pullMyConversationReadList as apiPullMyConversationReadList,
type ImConversationReadRespVO
} from '@/api/im/conversation/read'
import type {
Conversation,
ConversationDO,
ConversationRead,
ConversationReadDO,
MessageDO
} from '../types'
const PERSIST_DRAFT_DEBOUNCE_MS = 500
const pendingDraftConversations = new Set<Conversation>()
type LegacyConversationDO = ConversationDO & { readMessageId?: number }
/** 会话转 IndexedDB 记录 */
function toConversationDO(conversation: Conversation): ConversationDO {
const draft = conversation.draft
@ -42,14 +60,59 @@ function toConversationDO(conversation: Conversation): ConversationDO {
}
/** IndexedDB 记录转会话 */
function fromConversationDO(conversation: ConversationDO): Conversation {
const { clientConversationId: _clientConversationId, ...rest } = conversation
function fromConversationDO(conversation: LegacyConversationDO): Conversation {
const {
clientConversationId: _clientConversationId,
readMessageId: _readMessageId,
...rest
} = conversation
return rest
}
/** 会话读位置转 IndexedDB 记录 */
function toConversationReadDO(record: ConversationRead): ConversationReadDO {
return {
conversationType: record.conversationType,
targetId: record.targetId,
messageId: record.messageId,
updateTime: record.updateTime,
clientConversationId: getClientConversationId(record.conversationType, record.targetId)
}
}
/** IndexedDB 记录转会话读位置 */
function fromConversationReadDO(record: ConversationReadDO): ConversationRead {
const { clientConversationId: _clientConversationId, ...rest } = record
return rest
}
/** 是否为有效会话读位置 */
function isValidConversationReadRecord(record: ImConversationReadRespVO): boolean {
return !!record.conversationType && !!record.targetId && !!record.messageId
}
/** 获取对方普通消息最大编号 */
function getMaxIncomingNormalMessageId(
messages: Array<Pick<MessageDO, 'id' | 'selfSend' | 'type' | 'status'>>
): number {
return messages.reduce((maxMessageId, message) => {
if (
message.id &&
!message.selfSend &&
isNormalMessage(message.type) &&
message.status !== ImMessageStatus.RECALL &&
message.id > maxMessageId
) {
return message.id
}
return maxMessageId
}, 0)
}
export const useConversationStore = defineStore('imConversationStore', {
state: () => ({
conversations: [] as Conversation[], // 全量会话列表(私聊 + 群聊 + 频道)
conversationReads: {} as Record<string, ConversationRead>, // 会话读位置
activeConversation: null as Conversation | null, // 当前激活的会话
loading: false, // 是否正在批量加载
recentForwardConversationKeys: [] as string[] // 最近转发会话 key 列表
@ -83,7 +146,13 @@ export const useConversationStore = defineStore('imConversationStore', {
(type: number, targetId: number): Conversation | undefined =>
state.conversations.find(
(conversation) => conversation.type === type && conversation.targetId === targetId
)
),
/** 查找会话读位置 */
getConversationRead:
(state) =>
(type: number, targetId: number): ConversationRead | undefined =>
state.conversationReads[getClientConversationId(type, targetId)]
},
actions: {
@ -101,11 +170,42 @@ export const useConversationStore = defineStore('imConversationStore', {
this.clear()
// 2. 从 IndexedDB 读取会话和轻量设置
const db = getDb()
const [conversations, recent] = await Promise.all([
const [conversations, conversationReads, recent] = await Promise.all([
db.getAll<ConversationDO>('conversations'),
db.getAll<ConversationReadDO>('conversationReads'),
db.getSetting<string[]>(StorageKeys.settings.recentForwardConversationKeys)
])
this.conversations = conversations.map(fromConversationDO)
const nextConversationReads: Record<string, ConversationRead> = {}
for (const record of conversationReads) {
const item = fromConversationReadDO(record)
nextConversationReads[getClientConversationId(item.conversationType, item.targetId)] = item
}
const migratedReads: ConversationRead[] = []
for (const conversation of conversations as LegacyConversationDO[]) {
if (!conversation.readMessageId) {
continue
}
const key = getClientConversationId(conversation.type, conversation.targetId)
if (nextConversationReads[key]) {
continue
}
const record = {
conversationType: conversation.type,
targetId: conversation.targetId,
messageId: conversation.readMessageId
}
nextConversationReads[key] = record
migratedReads.push(record)
}
const nextConversations = (conversations as LegacyConversationDO[]).map(fromConversationDO)
this.conversationReads = nextConversationReads
await this.applyLocalConversationReads(nextConversations)
this.conversations = nextConversations
if (migratedReads.length > 0) {
void this.saveConversationReadRecord(migratedReads).catch((e) =>
console.warn('[IM conversationStore] 会话读位置迁移失败', e)
)
}
if (Array.isArray(recent)) {
this.recentForwardConversationKeys = recent.slice(0, CONVERSATION_RECENT_FORWARD_MAX)
}
@ -126,10 +226,236 @@ export const useConversationStore = defineStore('imConversationStore', {
saveDraftConversationListDebounced.cancel()
pendingDraftConversations.clear()
this.conversations = []
this.conversationReads = {}
this.activeConversation = null
this.recentForwardConversationKeys = []
},
/** 持久化会话读位置 */
async saveConversationReadRecord(
target: ConversationRead | ConversationRead[] | null | undefined,
tx?: DbTransaction
): Promise<void> {
const records = (Array.isArray(target) ? target : target ? [target] : []).map(
toConversationReadDO
)
if (records.length === 0) {
return
}
const db = getDb()
if (tx) {
for (const record of records) {
await db.put('conversationReads', record, tx)
}
return
}
await db.transaction(['conversationReads'], 'readwrite', async (tx) => {
for (const record of records) {
await db.put('conversationReads', record, tx)
}
})
},
/** 应用本地会话读位置 */
async applyLocalConversationReads(conversations?: Conversation[]) {
const targetConversations = conversations || this.conversations
const changedConversations: Conversation[] = []
for (const conversation of targetConversations) {
const record = this.getConversationRead(conversation.type, conversation.targetId)
if (!record) {
continue
}
if (this.applyReadToConversation(conversation, record.messageId)) {
changedConversations.push(conversation)
continue
}
if (conversation.unreadCount === 0 && !conversation.atMe && !conversation.atAll) {
continue
}
const messages = await getDb().getAllByIndex<MessageDO>(
'messages',
'clientConversationId',
getClientConversationId(conversation.type, conversation.targetId)
)
const maxIncomingMessageId = getMaxIncomingNormalMessageId(messages)
if (maxIncomingMessageId > 0 && maxIncomingMessageId <= record.messageId) {
conversation.unreadCount = 0
conversation.atMe = false
conversation.atAll = false
changedConversations.push(conversation)
}
}
if (changedConversations.length > 0) {
await this.saveConversationRecord(changedConversations)
}
},
/** 判断消息是否已被会话读位置覆盖 */
isMessageCoveredByReadPosition(
conversation: Pick<Conversation, 'type' | 'targetId'>,
message?: { id?: number } | null
): boolean {
if (!message?.id) {
return false
}
const record = this.getConversationRead(conversation.type, conversation.targetId)
return !!record && message.id <= record.messageId
},
/** 应用读位置到会话 */
applyReadToConversation(conversation: Conversation, messageId: number): boolean {
if (!conversation.lastMessageId || conversation.lastMessageId > messageId) {
return false
}
if (conversation.unreadCount === 0 && !conversation.atMe && !conversation.atAll) {
return false
}
conversation.unreadCount = 0
conversation.atMe = false
conversation.atAll = false
return true
},
/** 应用会话读位置 */
async applyConversationReadList(
records: ImConversationReadRespVO[],
isActive?: () => boolean
): Promise<void> {
if (records.length === 0) {
return
}
const changedReads = new Map<string, ConversationRead>()
const changedConversations = new Map<string, Conversation>()
const changedMessages = new Map<string, MessageDO>()
const db = getDb()
const messageStore = useMessageStore()
// 1. 按读位置更新会话未读和频道已读态
for (const record of records) {
if (isActive && !isActive()) {
return
}
if (!isValidConversationReadRecord(record)) {
continue
}
const clientConversationId = getClientConversationId(
record.conversationType,
record.targetId
)
let storedMessages: MessageDO[] | undefined
const getStoredMessages = async () => {
if (!storedMessages) {
storedMessages = await db.getAllByIndex<MessageDO>(
'messages',
'clientConversationId',
clientConversationId
)
}
return storedMessages
}
const current = this.conversationReads[clientConversationId]
const messageId = Math.max(record.messageId, current?.messageId || 0)
if (!current || messageId > current.messageId) {
const next = {
conversationType: record.conversationType,
targetId: record.targetId,
messageId,
updateTime: record.updateTime
}
this.conversationReads[clientConversationId] = next
changedReads.set(clientConversationId, next)
}
const conversation = this.getConversation(record.conversationType, record.targetId)
if (conversation && this.applyReadToConversation(conversation, messageId)) {
changedConversations.set(clientConversationId, conversation)
} else if (conversation) {
const maxIncomingMessageId = getMaxIncomingNormalMessageId(await getStoredMessages())
if (maxIncomingMessageId > 0 && maxIncomingMessageId <= messageId) {
conversation.unreadCount = 0
conversation.atMe = false
conversation.atAll = false
changedConversations.set(clientConversationId, conversation)
}
}
if (record.conversationType !== ImConversationType.CHANNEL) {
continue
}
const memoryMessages = messageStore.getMessages(clientConversationId)
for (const message of memoryMessages) {
if (
message.id &&
message.id <= messageId &&
message.receiptStatus !== ImMessageReceiptStatus.DONE
) {
message.receiptStatus = ImMessageReceiptStatus.DONE
}
}
for (const message of await getStoredMessages()) {
if (
message.id &&
message.id <= messageId &&
message.receiptStatus !== ImMessageReceiptStatus.DONE
) {
message.receiptStatus = ImMessageReceiptStatus.DONE
changedMessages.set(message.messageKey, message)
}
}
}
// 2. 持久化本轮变更
if (
changedReads.size === 0 &&
changedConversations.size === 0 &&
changedMessages.size === 0
) {
return
}
if (isActive && !isActive()) {
return
}
const stores: Array<'conversationReads' | 'conversations' | 'messages'> = []
if (changedReads.size > 0) {
stores.push('conversationReads')
}
if (changedConversations.size > 0) {
stores.push('conversations')
}
if (changedMessages.size > 0) {
stores.push('messages')
}
await db.transaction(stores, 'readwrite', async (tx) => {
if (changedReads.size > 0) {
await this.saveConversationReadRecord([...changedReads.values()], tx)
}
if (changedConversations.size > 0) {
await this.saveConversationRecord([...changedConversations.values()], tx)
}
for (const message of changedMessages.values()) {
await db.put('messages', message, tx)
}
})
},
/** 增量拉取会话读位置 */
async pullConversationReads(isActive?: () => boolean): Promise<void> {
await runIncrementalPull(
StorageKeys.settings.conversationReadPullCursor,
apiPullMyConversationReadList,
async (records) => {
if (isActive && !isActive()) {
return false
}
await this.applyConversationReadList(records, isActive)
if (isActive && !isActive()) {
return false
}
return true
},
isActive
)
},
/** 执行会话记录持久化 */
async saveConversationRecord(
target: Conversation | Conversation[] | null | undefined,
@ -326,17 +652,41 @@ export const useConversationStore = defineStore('imConversationStore', {
},
/** 标记会话已读 */
markConversationRead(type: number, targetId: number) {
markConversationRead(type: number, targetId: number, messageId?: number) {
const conversation = this.getConversation(type, targetId)
if (!conversation) {
return
}
if (conversation.unreadCount === 0 && !conversation.atMe && !conversation.atAll) {
const key = getClientConversationId(type, targetId)
const current = this.conversationReads[key]
const readMessageIdAdvanced = !!messageId && messageId > (current?.messageId || 0)
if (
conversation.unreadCount === 0 &&
!conversation.atMe &&
!conversation.atAll &&
!readMessageIdAdvanced
) {
return
}
conversation.unreadCount = 0
conversation.atMe = false
conversation.atAll = false
if (readMessageIdAdvanced) {
const record = {
conversationType: type,
targetId,
messageId,
updateTime: Date.now()
}
this.conversationReads[key] = record
void getDb()
.transaction(['conversations', 'conversationReads'], 'readwrite', async (tx) => {
await this.saveConversationRecord(conversation, tx)
await this.saveConversationReadRecord(record, tx)
})
.catch((e) => console.warn('[IM conversationStore] 会话已读写入失败', e))
return
}
this.saveConversation(conversation)
},

View File

@ -30,7 +30,6 @@ import { getCurrentUserId } from '@/utils/auth'
import { isGroupQuit, tryGetSenderDisplayName } from '../../utils/user'
import { useGroupStore } from './groupStore'
import { useConversationStore } from './conversationStore'
import type { ImConversationReadRespVO } from '@/api/im/conversation/read'
import type { Conversation, Message, MessageDO } from '../types'
const MESSAGE_CACHE_RECENT_CONVERSATION_LIMIT = 5
@ -238,24 +237,6 @@ function isSameMessage(left: Message, right: Message): boolean {
return !!left.clientMessageId && left.clientMessageId === right.clientMessageId
}
/** 获取对方普通消息最大编号 */
function getMaxIncomingNormalMessageId(
messages: Array<Pick<Message, 'id' | 'selfSend' | 'type' | 'status'>>
): number {
return messages.reduce((maxMessageId, message) => {
if (
message.id &&
!message.selfSend &&
isNormalMessage(message.type) &&
message.status !== ImMessageStatus.RECALL &&
message.id > maxMessageId
) {
return message.id
}
return maxMessageId
}, 0)
}
export const useMessageStore = defineStore('imMessageStore', {
state: () => ({
messagesByConversation: {} as Record<string, Message[]>,
@ -527,6 +508,7 @@ export const useMessageStore = defineStore('imMessageStore', {
if (
!message.selfSend &&
!isActive &&
!conversationStore.isMessageCoveredByReadPosition(conversation, message) &&
isNormalMessage(message.type) &&
message.status !== ImMessageStatus.RECALL
) {
@ -632,6 +614,7 @@ export const useMessageStore = defineStore('imMessageStore', {
if (
!message.selfSend &&
!isActive &&
!conversationStore.isMessageCoveredByReadPosition(conversation, message) &&
isNormalMessage(message.type) &&
message.status !== ImMessageStatus.RECALL
) {
@ -844,127 +827,6 @@ export const useMessageStore = defineStore('imMessageStore', {
.catch((e) => console.warn('[IM messageStore] 回执写入失败', e))
},
/** 应用会话读位置补偿 */
async applyConversationReadList(
records: ImConversationReadRespVO[],
isActive?: () => boolean
): Promise<void> {
if (records.length === 0) {
return
}
const conversationStore = useConversationStore()
const changedConversations = new Map<string, Conversation>()
const changedMessages = new Map<string, MessageDO>()
const db = getDb()
// 1. 按读位置更新会话未读和频道已读态
for (const record of records) {
if (isActive && !isActive()) {
return
}
if (!record.conversationType || !record.targetId || !record.messageId) {
continue
}
const clientConversationId = getClientConversationId(
record.conversationType,
record.targetId
)
let storedMessages: MessageDO[] | undefined
const getStoredMessages = async () => {
if (!storedMessages) {
storedMessages = await db.getAllByIndex<MessageDO>(
'messages',
'clientConversationId',
clientConversationId
)
}
return storedMessages
}
const conversation = conversationStore.getConversation(
record.conversationType,
record.targetId
)
if (
conversation &&
(conversation.unreadCount > 0 || conversation.atMe || conversation.atAll)
) {
const memoryMessages = this.messagesByConversation[clientConversationId]
let readCovered =
!!conversation.lastMessageId && conversation.lastMessageId <= record.messageId
const latestMessageLoaded =
!!conversation.lastMessageId &&
memoryMessages?.some((message) => message.id === conversation.lastMessageId)
if (!readCovered && latestMessageLoaded && memoryMessages) {
const maxIncomingMessageId = getMaxIncomingNormalMessageId(memoryMessages)
readCovered = maxIncomingMessageId > 0 && maxIncomingMessageId <= record.messageId
}
if (!readCovered && !latestMessageLoaded) {
const storedMessages = await getStoredMessages()
const latestMessageStored =
!!conversation.lastMessageId &&
storedMessages.some((message) => message.id === conversation.lastMessageId)
if (latestMessageStored) {
const storedMaxIncomingMessageId = getMaxIncomingNormalMessageId(storedMessages)
readCovered =
storedMaxIncomingMessageId > 0 && storedMaxIncomingMessageId <= record.messageId
}
}
if (readCovered) {
conversation.unreadCount = 0
conversation.atMe = false
conversation.atAll = false
changedConversations.set(clientConversationId, conversation)
}
}
if (record.conversationType !== ImConversationType.CHANNEL) {
continue
}
const memoryMessages = this.messagesByConversation[clientConversationId] || []
for (const message of memoryMessages) {
if (
message.id &&
message.id <= record.messageId &&
message.receiptStatus !== ImMessageReceiptStatus.DONE
) {
message.receiptStatus = ImMessageReceiptStatus.DONE
}
}
for (const message of await getStoredMessages()) {
if (
message.id &&
message.id <= record.messageId &&
message.receiptStatus !== ImMessageReceiptStatus.DONE
) {
message.receiptStatus = ImMessageReceiptStatus.DONE
changedMessages.set(message.messageKey, message)
}
}
}
// 2. 持久化本轮变更
if (changedConversations.size === 0 && changedMessages.size === 0) {
return
}
if (isActive && !isActive()) {
return
}
const stores: Array<'conversations' | 'messages'> = []
if (changedConversations.size > 0) {
stores.push('conversations')
}
if (changedMessages.size > 0) {
stores.push('messages')
}
await db.transaction(stores, 'readwrite', async (tx) => {
if (changedConversations.size > 0) {
await conversationStore.saveConversationRecord([...changedConversations.values()], tx)
}
for (const message of changedMessages.values()) {
await db.put('messages', message, tx)
}
})
},
/** 前置历史消息 */
prependMessageList(conversationType: number, targetId: number, earlierMessages: Message[]) {
if (earlierMessages.length === 0) {

View File

@ -366,7 +366,7 @@ export const useImWebSocketStore = defineStore('imWebSocketStore', {
/** 频道 READ自己其它终端在某频道里标为已读本端同步清零该频道未读 */
handleChannelRead(websocketMessage: ImChannelMessageRespVO) {
void useMessageStore()
void useConversationStore()
.applyConversationReadList([
{
id: websocketMessage.id,
@ -425,7 +425,8 @@ export const useImWebSocketStore = defineStore('imWebSocketStore', {
// 窗口打开 = 已读:本端清未读 + 上报服务端读位置,避免读位置滞后
conversationStore.markConversationRead(
ImConversationType.CHANNEL,
websocketMessage.channelId
websocketMessage.channelId,
websocketMessage.id
)
apiReadChannelMessages(websocketMessage.channelId, websocketMessage.id).catch((e) => {
console.warn('[IM WS] 频道自动已读上报失败', e)
@ -606,7 +607,11 @@ export const useImWebSocketStore = defineStore('imWebSocketStore', {
if (isActive) {
// 聊天窗口打开 = 实际看到了:本端清未读;私聊已读开启时再上报后端,让对方 UI 立刻切到"已读"
// 已读位置直接用刚到的消息 id这条就是当前会话最大 id
conversationStore.markConversationRead(ImConversationType.PRIVATE, peerId)
conversationStore.markConversationRead(
ImConversationType.PRIVATE,
peerId,
websocketMessage.id
)
if (MESSAGE_PRIVATE_READ_ENABLED) {
apiReadPrivateMessages(peerId, websocketMessage.id).catch((e) => {
console.warn('[IM WS] 自动已读上报失败', e)
@ -628,7 +633,7 @@ export const useImWebSocketStore = defineStore('imWebSocketStore', {
if (!websocketMessage.id || !websocketMessage.receiverId) {
return
}
void useMessageStore()
void useConversationStore()
.applyConversationReadList([
{
id: websocketMessage.id,
@ -741,7 +746,11 @@ export const useImWebSocketStore = defineStore('imWebSocketStore', {
conversationStore.activeConversation?.targetId === websocketMessage.groupId
if (isActive) {
// 群已读上报需要带 messageId群消息以"读到第几条"的游标为准,区别于私聊只标 receiverId群已读关闭时仅本地清零
conversationStore.markConversationRead(ImConversationType.GROUP, websocketMessage.groupId)
conversationStore.markConversationRead(
ImConversationType.GROUP,
websocketMessage.groupId,
websocketMessage.id
)
if (MESSAGE_GROUP_READ_ENABLED) {
apiReadGroupMessages(websocketMessage.groupId, websocketMessage.id).catch((e) => {
console.warn('[IM WS] 自动已读上报失败', e)
@ -766,7 +775,7 @@ export const useImWebSocketStore = defineStore('imWebSocketStore', {
if (!readMessageId || !websocketMessage.groupId) {
return
}
void useMessageStore()
void useConversationStore()
.applyConversationReadList([
{
id: readMessageId,

View File

@ -151,6 +151,17 @@ export interface ConversationDO extends Conversation {
clientConversationId: string // `${type}:${targetId}`
}
export interface ConversationRead {
conversationType: number // 会话类型,对齐 ImConversationType
targetId: number // 会话目标编号
messageId: number // 当前用户已读到的最大消息编号
updateTime?: number // 更新时间
}
export interface ConversationReadDO extends ConversationRead {
clientConversationId: string // `${conversationType}:${targetId}`
}
export interface MessageDO extends Omit<Message, 'uploadProgress' | '_localFile' | '_ackMerging'> {
messageKey: string // `${conversationType}:${id}` 或 `client:${clientMessageId}`
conversationType: number // 会话类型,对齐 ImConversationType

View File

@ -4,10 +4,11 @@ import { getCurrentUserId } from '@/utils/auth'
import { ImConversationType } from './constants'
import type { MessageDO, SettingDO } from '../home/types'
export const DB_SCHEMA_VERSION = 1
export const DB_SCHEMA_VERSION = 2
export type DbStoreName =
| 'conversations'
| 'conversationReads'
| 'messages'
| 'friends'
| 'friendRequests'
@ -103,6 +104,12 @@ function upgradeSchema(db: IDBDatabase) {
const store = db.createObjectStore('conversations', { keyPath: 'clientConversationId' })
createIndex(store, 'lastSendTime', 'lastSendTime')
}
if (!db.objectStoreNames.contains('conversationReads')) {
const store = db.createObjectStore('conversationReads', { keyPath: 'clientConversationId' })
createIndex(store, 'conversationType+targetId', ['conversationType', 'targetId'], {
unique: true
})
}
if (!db.objectStoreNames.contains('messages')) {
const store = db.createObjectStore('messages', { keyPath: 'messageKey' })
createIndex(store, 'clientConversationId', 'clientConversationId')