feat(im):同步已读上报补偿与群通话探测优化

- web-antd、web-antdv-next、web-ele 同步会话 readMessageId 逻辑
- readActive 与 WebSocket 自动已读改为基于服务端已上报读位置判断是否跳过接口
- read 接口成功后同步 readMessageId,失败时保留本端已读体验并允许后续重新进入补上报
- 拉取服务端 read 进度时同步更新会话 readMessageId,同时保持本地读位置单调合并
- 三端同步 activeCallLoaded / activeCallExpired 群通话探测状态
- 首登与重连时失效群通话探测缓存,本地无通话且探测过期时懒加载 getActiveCall
- 群通话写入或移除时标记探测已加载,并避免通话探测状态写入 IndexedDB
- 为 IndexedDB DO 类型补充存储结构注释,并修复 conversationStore lint 写法
pull/367/head
YunaiV 2026-06-19 11:05:19 -07:00
parent c0ead15bc3
commit 09b97f1e04
27 changed files with 464 additions and 148 deletions

View File

@ -11,6 +11,7 @@ import { getActiveCall, joinCall } from '#/api/im/rtc'
import { getCurrentUserId } from '#/views/im/utils/auth' import { getCurrentUserId } from '#/views/im/utils/auth'
import { useGroupCallMembers } from '../../composables/useGroupCallMembers' import { useGroupCallMembers } from '../../composables/useGroupCallMembers'
import { useGroupStore } from '../../store/groupStore'
import { useRtcStore } from '../../store/rtcStore' import { useRtcStore } from '../../store/rtcStore'
import { UserAvatar } from '../user' import { UserAvatar } from '../user'
@ -21,6 +22,7 @@ const props = defineProps<{
}>() }>()
const rtcStore = useRtcStore() const rtcStore = useRtcStore()
const groupStore = useGroupStore()
const popoverVisible = ref(false) const popoverVisible = ref(false)
@ -40,24 +42,48 @@ const pillText = computed(() => {
* [groupId, room] 双源监听 + 已填充守卫避免切群 / 首次填充触发的双次重复拉取 * [groupId, room] 双源监听 + 已填充守卫避免切群 / 首次填充触发的双次重复拉取
*/ */
watch( watch(
() => [props.groupId, activeCall.value?.room] as const, () =>
[
props.groupId,
activeCall.value?.room,
groupStore.isGroupActiveCallExpired(props.groupId)
] as const,
async ([groupId, room], oldValues) => { async ([groupId, room], oldValues) => {
if (!groupId || !activeCall.value) { if (!groupId) {
return return
} }
// if (!activeCall.value) {
if (!groupStore.isGroupActiveCallExpired(groupId)) {
return
}
try {
const data = await getActiveCall(groupId)
if (data) {
rtcStore.setGroupCall(data, true)
} else {
rtcStore.removeGroupCall(groupId)
}
} catch (error) {
console.warn('[GroupCallBanner] getActiveCall 失败', { groupId }, error)
}
return
}
//
const groupChanged = !oldValues || oldValues[0] !== groupId const groupChanged = !oldValues || oldValues[0] !== groupId
const roomChanged = oldValues && oldValues[1] !== room const roomChanged = oldValues && oldValues[1] !== room
const participantsLoaded = (activeCall.value?.joinedUserIds?.length ?? 0) > 1 const participantsLoaded = (activeCall.value?.joinedUserIds?.length ?? 0) > 1
const activeCallExpired = groupStore.isGroupActiveCallExpired(groupId)
if ( if (
rtcStore.isGroupCallParticipantsLoaded(groupId, room) || !activeCallExpired &&
(!groupChanged && !roomChanged && participantsLoaded) (rtcStore.isGroupCallParticipantsLoaded(groupId, room) ||
(!groupChanged && !roomChanged && participantsLoaded))
) { ) {
return return
} }
// store // store
try { try {
const data = await getActiveCall(groupId) const data = await getActiveCall(groupId)
if (data) { if (data) {

View File

@ -290,6 +290,7 @@ export const useMessagePuller = () => {
// 1. 清理连接级缓存 // 1. 清理连接级缓存
messageStore.clearPrivateReadMaxIdCache() messageStore.clearPrivateReadMaxIdCache()
rtcStore.clearGroupCallCache() rtcStore.clearGroupCallCache()
groupStore.markAllGroupActiveCallsExpired()
groupStore.markAllGroupInfoExpired() groupStore.markAllGroupInfoExpired()
groupStore.markAllGroupMembersExpired() groupStore.markAllGroupMembersExpired()
// 2. 并发补偿远端状态 // 2. 并发补偿远端状态

View File

@ -240,12 +240,12 @@ export const useMessageSender = () => {
} }
} }
const maxMessageId = Math.max(loadedMaxMessageId, conversation.lastMessageId || 0) const maxMessageId = Math.max(loadedMaxMessageId, conversation.lastMessageId || 0)
const readCovered = conversationStore.isReadPositionCovered( const readReported = conversationStore.isReportedReadPositionCovered(
conversation.type, conversation.type,
conversation.targetId, conversation.targetId,
maxMessageId maxMessageId
) )
if (readCovered) { if (readReported) {
conversationStore.markConversationRead(conversation.type, conversation.targetId) conversationStore.markConversationRead(conversation.type, conversation.targetId)
return return
} }
@ -275,6 +275,11 @@ export const useMessageSender = () => {
} else { } else {
await apiReadChannelMessages(conversation.targetId, maxMessageId) await apiReadChannelMessages(conversation.targetId, maxMessageId)
} }
conversationStore.markConversationReadReported(
conversation.type,
conversation.targetId,
maxMessageId
)
} catch (error) { } catch (error) {
console.error( console.error(
'[IM] 标记已读失败', '[IM] 标记已读失败',

View File

@ -61,6 +61,7 @@ onMounted(async () => {
const hasFriendRows = cacheResults[2] const hasFriendRows = cacheResults[2]
const hasGroupRows = cacheResults[3] const hasGroupRows = cacheResults[3]
const hasChannelRows = cacheResults[4] const hasChannelRows = cacheResults[4]
groupStore.markAllGroupActiveCallsExpired()
groupStore.markAllGroupMembersExpired() groupStore.markAllGroupMembersExpired()
childRouteReady.value = true childRouteReady.value = true
// 1.4 unhandled-list // 1.4 unhandled-list

View File

@ -27,7 +27,19 @@ import { useMessageStore } from './messageStore'
const PERSIST_DRAFT_DEBOUNCE_MS = 500 const PERSIST_DRAFT_DEBOUNCE_MS = 500
const pendingDraftConversations = new Set<Conversation>() const pendingDraftConversations = new Set<Conversation>()
type LegacyConversationDO = ConversationDO & { readMessageId?: number } /** 创建会话读位置记录 */
function createConversationRead(
type: number,
targetId: number,
messageId: number
): ConversationRead {
return {
conversationType: type,
targetId,
messageId,
updateTime: Date.now()
}
}
/** 创建草稿保存防抖函数 */ /** 创建草稿保存防抖函数 */
function createDraftDebounce(fn: () => void, wait: number) { function createDraftDebounce(fn: () => void, wait: number) {
@ -75,6 +87,7 @@ function toConversationDO(conversation: Conversation): ConversationDO {
lastReceiptStatus: conversation.lastReceiptStatus, lastReceiptStatus: conversation.lastReceiptStatus,
lastSelfSend: conversation.lastSelfSend, lastSelfSend: conversation.lastSelfSend,
lastSenderDisplayName: conversation.lastSenderDisplayName, lastSenderDisplayName: conversation.lastSenderDisplayName,
readMessageId: conversation.readMessageId,
deleted: conversation.deleted, deleted: conversation.deleted,
top: conversation.top, top: conversation.top,
silent: conversation.silent, silent: conversation.silent,
@ -86,10 +99,9 @@ function toConversationDO(conversation: Conversation): ConversationDO {
} }
/** IndexedDB 记录转会话 */ /** IndexedDB 记录转会话 */
function fromConversationDO(conversation: LegacyConversationDO): Conversation { function fromConversationDO(conversation: ConversationDO): Conversation {
const { const {
clientConversationId: _clientConversationId, clientConversationId: _clientConversationId,
readMessageId: _readMessageId,
...rest ...rest
} = conversation } = conversation
return rest return rest
@ -207,34 +219,10 @@ export const useConversationStore = defineStore('imConversationStore', {
const item = fromConversationReadDO(record) const item = fromConversationReadDO(record)
nextConversationReads[getClientConversationId(item.conversationType, item.targetId)] = item nextConversationReads[getClientConversationId(item.conversationType, item.targetId)] = item
} }
const migratedReads: ConversationRead[] = [] const nextConversations = conversations.map((conversation) => fromConversationDO(conversation))
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((conversation) =>
fromConversationDO(conversation)
)
this.conversationReads = nextConversationReads this.conversationReads = nextConversationReads
await this.applyLocalConversationReads(nextConversations) await this.applyLocalConversationReads(nextConversations)
this.conversations = nextConversations this.conversations = nextConversations
if (migratedReads.length > 0) {
void this.saveConversationReadRecord(migratedReads).catch((error) =>
console.warn('[IM conversationStore] 会话读位置迁移失败', error)
)
}
if (Array.isArray(recent)) { if (Array.isArray(recent)) {
this.recentForwardConversationKeys = recent.slice(0, CONVERSATION_RECENT_FORWARD_MAX) this.recentForwardConversationKeys = recent.slice(0, CONVERSATION_RECENT_FORWARD_MAX)
} }
@ -344,6 +332,15 @@ export const useConversationStore = defineStore('imConversationStore', {
return !!record && record.messageId >= messageId return !!record && record.messageId >= messageId
}, },
/** 判断服务端已读位置是否覆盖消息编号 */
isReportedReadPositionCovered(type: number, targetId: number, messageId?: number): boolean {
if (!messageId) {
return false
}
const conversation = this.getConversation(type, targetId)
return (conversation?.readMessageId || 0) >= messageId
},
/** 应用读位置到会话 */ /** 应用读位置到会话 */
applyReadToConversation(conversation: Conversation, messageId: number): boolean { applyReadToConversation(conversation: Conversation, messageId: number): boolean {
if (!conversation.lastMessageId || conversation.lastMessageId > messageId) { if (!conversation.lastMessageId || conversation.lastMessageId > messageId) {
@ -397,6 +394,11 @@ export const useConversationStore = defineStore('imConversationStore', {
} }
const current = this.conversationReads[clientConversationId] const current = this.conversationReads[clientConversationId]
const messageId = Math.max(record.messageId, current?.messageId || 0) const messageId = Math.max(record.messageId, current?.messageId || 0)
const conversation = this.getConversation(record.conversationType, record.targetId)
if (conversation && record.messageId > (conversation.readMessageId || 0)) {
conversation.readMessageId = record.messageId
changedConversations.set(clientConversationId, conversation)
}
if (!current || messageId > current.messageId) { if (!current || messageId > current.messageId) {
const next = { const next = {
conversationType: record.conversationType, conversationType: record.conversationType,
@ -408,7 +410,6 @@ export const useConversationStore = defineStore('imConversationStore', {
changedReads.set(clientConversationId, next) changedReads.set(clientConversationId, next)
} }
const conversation = this.getConversation(record.conversationType, record.targetId)
if (conversation && this.applyReadToConversation(conversation, messageId)) { if (conversation && this.applyReadToConversation(conversation, messageId)) {
changedConversations.set(clientConversationId, conversation) changedConversations.set(clientConversationId, conversation)
} else if (conversation) { } else if (conversation) {
@ -690,7 +691,7 @@ export const useConversationStore = defineStore('imConversationStore', {
}, },
/** 标记会话已读 */ /** 标记会话已读 */
markConversationRead(type: number, targetId: number, messageId?: number) { markConversationRead(type: number, targetId: number, messageId?: number): void {
const conversation = this.getConversation(type, targetId) const conversation = this.getConversation(type, targetId)
if (!conversation) { if (!conversation) {
return return
@ -717,12 +718,36 @@ export const useConversationStore = defineStore('imConversationStore', {
await this.saveConversationRecord(conversation, tx) await this.saveConversationRecord(conversation, tx)
await this.saveConversationReadRecord(record, tx) await this.saveConversationReadRecord(record, tx)
}) })
.catch((error) => console.warn('[IM conversationStore] 会话已读写入失败', error)) .catch((error) =>
console.warn(
'[IM conversationStore] 会话已读写入失败',
{
conversationType: type,
targetId,
messageId,
conversationKey: key
},
error
)
)
return return
} }
this.saveConversation(conversation) this.saveConversation(conversation)
}, },
/** 标记会话已上报服务端读位置 */
markConversationReadReported(type: number, targetId: number, messageId?: number): void {
if (!messageId) {
return
}
const conversation = this.getConversation(type, targetId)
if (!conversation || messageId <= (conversation.readMessageId || 0)) {
return
}
conversation.readMessageId = messageId
this.saveConversation(conversation)
},
// ==================== 最近转发 ==================== // ==================== 最近转发 ====================
/** 推送最近转发会话 */ /** 推送最近转发会话 */

View File

@ -47,6 +47,8 @@ const pendingSingleMemberKey = (userId: number, groupId: number, memberUserId: n
/** 构建群 IndexedDB 记录 */ /** 构建群 IndexedDB 记录 */
function buildGroupDO(group: Group): GroupDO { function buildGroupDO(group: Group): GroupDO {
const { const {
activeCallExpired: _activeCallExpired,
activeCallLoaded: _activeCallLoaded,
infoLoaded: _infoLoaded, infoLoaded: _infoLoaded,
members: _members, members: _members,
membersLoaded: _membersLoaded, membersLoaded: _membersLoaded,
@ -229,11 +231,13 @@ export const useGroupStore = defineStore('imGroupStore', {
this.groups = fresh.map((group) => { this.groups = fresh.map((group) => {
const existing = groupMap.get(group.id) const existing = groupMap.get(group.id)
if (!existing) { if (!existing) {
return { ...group, infoLoaded: true } return { ...group, activeCallExpired: true, infoLoaded: true }
} }
return { return {
...group, ...group,
infoLoaded: true, infoLoaded: true,
activeCallExpired: existing.activeCallExpired,
activeCallLoaded: existing.activeCallLoaded,
members: existing.members, members: existing.members,
memberCount: existing.memberCount ?? group.memberCount, memberCount: existing.memberCount ?? group.memberCount,
membersLoaded: existing.membersLoaded, membersLoaded: existing.membersLoaded,
@ -287,6 +291,29 @@ export const useGroupStore = defineStore('imGroupStore', {
} }
}, },
/** 失效全部群通话探测缓存 */
markAllGroupActiveCallsExpired() {
for (const group of this.groups) {
group.activeCallExpired = true
}
},
/** 标记群通话探测已加载 */
markGroupActiveCallLoaded(groupId: number) {
const group = this.getGroup(groupId)
if (!group) {
return
}
group.activeCallLoaded = true
group.activeCallExpired = false
},
/** 判断群通话是否需要重新探测 */
isGroupActiveCallExpired(groupId: number): boolean {
const group = this.getGroup(groupId)
return !group?.activeCallLoaded || !!group.activeCallExpired
},
/** 失效指定群成员缓存 */ /** 失效指定群成员缓存 */
markGroupMembersExpired(groupId: number) { markGroupMembersExpired(groupId: number) {
const group = this.getGroup(groupId) const group = this.getGroup(groupId)

View File

@ -260,6 +260,7 @@ export const useRtcStore = defineStore('imRtc', () => {
if (!payload?.groupId) { if (!payload?.groupId) {
return return
} }
useGroupStore().markGroupActiveCallLoaded(payload.groupId)
// 浅比较room / mediaType / joinedUserIds / inviteeIds 都没变就跳过,避免下游 watcher 无意义重算 // 浅比较room / mediaType / joinedUserIds / inviteeIds 都没变就跳过,避免下游 watcher 无意义重算
const existing = groupActiveCalls.value.get(payload.groupId) const existing = groupActiveCalls.value.get(payload.groupId)
const nextParticipantsLoaded = participantsLoaded ?? !!existing?.participantsLoaded const nextParticipantsLoaded = participantsLoaded ?? !!existing?.participantsLoaded
@ -320,7 +321,11 @@ export const useRtcStore = defineStore('imRtc', () => {
/** 群通话结束:从 groupActiveCalls 移除;胶囊条消失 */ /** 群通话结束:从 groupActiveCalls 移除;胶囊条消失 */
function removeGroupCall(groupId: number) { function removeGroupCall(groupId: number) {
if (!groupId) {
return
}
clearGroupCallCache(groupId) clearGroupCallCache(groupId)
useGroupStore().markGroupActiveCallLoaded(groupId)
} }
/** 获取群当前活跃通话;用于胶囊条按 groupId 查询 */ /** 获取群当前活跃通话;用于胶囊条按 groupId 查询 */

View File

@ -463,7 +463,7 @@ export const useImWebSocketStore = defineStore('imWebSocketStore', {
) )
if (isActive) { if (isActive) {
// 窗口打开 = 已读:本端清未读 + 上报服务端读位置,避免读位置滞后 // 窗口打开 = 已读:本端清未读 + 上报服务端读位置,避免读位置滞后
const readCovered = conversationStore.isReadPositionCovered( const readReported = conversationStore.isReportedReadPositionCovered(
ImConversationType.CHANNEL, ImConversationType.CHANNEL,
websocketMessage.channelId, websocketMessage.channelId,
websocketMessage.id websocketMessage.id
@ -471,10 +471,17 @@ export const useImWebSocketStore = defineStore('imWebSocketStore', {
conversationStore.markConversationRead( conversationStore.markConversationRead(
ImConversationType.CHANNEL, ImConversationType.CHANNEL,
websocketMessage.channelId, websocketMessage.channelId,
readCovered ? undefined : websocketMessage.id websocketMessage.id
) )
if (!readCovered) { if (!readReported) {
apiReadChannelMessages(websocketMessage.channelId, websocketMessage.id) apiReadChannelMessages(websocketMessage.channelId, websocketMessage.id)
.then(() =>
conversationStore.markConversationReadReported(
ImConversationType.CHANNEL,
websocketMessage.channelId,
websocketMessage.id
)
)
.catch((error) => { .catch((error) => {
console.warn( console.warn(
'[IM WS] 频道自动已读上报失败', '[IM WS] 频道自动已读上报失败',
@ -678,7 +685,7 @@ export const useImWebSocketStore = defineStore('imWebSocketStore', {
if (isActive) { if (isActive) {
// 聊天窗口打开 = 实际看到了:本端清未读;私聊已读开启时再上报后端,让对方 UI 立刻切到"已读" // 聊天窗口打开 = 实际看到了:本端清未读;私聊已读开启时再上报后端,让对方 UI 立刻切到"已读"
// 已读位置直接用刚到的消息 id这条就是当前会话最大 id // 已读位置直接用刚到的消息 id这条就是当前会话最大 id
const readCovered = conversationStore.isReadPositionCovered( const readReported = conversationStore.isReportedReadPositionCovered(
ImConversationType.PRIVATE, ImConversationType.PRIVATE,
peerId, peerId,
websocketMessage.id websocketMessage.id
@ -686,10 +693,17 @@ export const useImWebSocketStore = defineStore('imWebSocketStore', {
conversationStore.markConversationRead( conversationStore.markConversationRead(
ImConversationType.PRIVATE, ImConversationType.PRIVATE,
peerId, peerId,
readCovered ? undefined : websocketMessage.id websocketMessage.id
) )
if (MESSAGE_PRIVATE_READ_ENABLED && !readCovered) { if (MESSAGE_PRIVATE_READ_ENABLED && !readReported) {
apiReadPrivateMessages(peerId, websocketMessage.id) apiReadPrivateMessages(peerId, websocketMessage.id)
.then(() =>
conversationStore.markConversationReadReported(
ImConversationType.PRIVATE,
peerId,
websocketMessage.id
)
)
.catch((error) => { .catch((error) => {
console.warn( console.warn(
'[IM WS] 私聊自动已读上报失败', '[IM WS] 私聊自动已读上报失败',
@ -831,7 +845,7 @@ export const useImWebSocketStore = defineStore('imWebSocketStore', {
conversationStore.activeConversation?.targetId === websocketMessage.groupId conversationStore.activeConversation?.targetId === websocketMessage.groupId
if (isActive) { if (isActive) {
// 群已读上报需要带 messageId群消息以"读到第几条"的游标为准,区别于私聊只标 receiverId群已读关闭时仅本地清零 // 群已读上报需要带 messageId群消息以"读到第几条"的游标为准,区别于私聊只标 receiverId群已读关闭时仅本地清零
const readCovered = conversationStore.isReadPositionCovered( const readReported = conversationStore.isReportedReadPositionCovered(
ImConversationType.GROUP, ImConversationType.GROUP,
websocketMessage.groupId, websocketMessage.groupId,
websocketMessage.id websocketMessage.id
@ -839,10 +853,17 @@ export const useImWebSocketStore = defineStore('imWebSocketStore', {
conversationStore.markConversationRead( conversationStore.markConversationRead(
ImConversationType.GROUP, ImConversationType.GROUP,
websocketMessage.groupId, websocketMessage.groupId,
readCovered ? undefined : websocketMessage.id websocketMessage.id
) )
if (MESSAGE_GROUP_READ_ENABLED && !readCovered) { if (MESSAGE_GROUP_READ_ENABLED && !readReported) {
apiReadGroupMessages(websocketMessage.groupId, websocketMessage.id) apiReadGroupMessages(websocketMessage.groupId, websocketMessage.id)
.then(() =>
conversationStore.markConversationReadReported(
ImConversationType.GROUP,
websocketMessage.groupId,
websocketMessage.id
)
)
.catch((error) => { .catch((error) => {
console.warn( console.warn(
'[IM WS] 群聊自动已读上报失败', '[IM WS] 群聊自动已读上报失败',

View File

@ -110,6 +110,7 @@ export interface Conversation {
silent?: boolean // 是否免打扰(不展示未读徽标 + 不响提示音) silent?: boolean // 是否免打扰(不展示未读徽标 + 不响提示音)
atMe?: boolean // 群聊:是否有人 @我 atMe?: boolean // 群聊:是否有人 @我
atAll?: boolean // 群聊:是否有人 @全体成员 atAll?: boolean // 群聊:是否有人 @全体成员
readMessageId?: number // 已上报到服务端的最大已读消息编号
draft?: { draft?: {
html: string // 输入框 HTML html: string // 输入框 HTML
plain: string // 输入框纯文本 plain: string // 输入框纯文本
@ -147,6 +148,7 @@ export interface Message {
// ==================== IndexedDB 本地存储结构 ==================== // ==================== IndexedDB 本地存储结构 ====================
/** 会话 IndexedDB 存储结构 */
export interface ConversationDO extends Conversation { export interface ConversationDO extends Conversation {
clientConversationId: string // `${type}:${targetId}` clientConversationId: string // `${type}:${targetId}`
} }
@ -158,16 +160,19 @@ export interface ConversationRead {
updateTime?: number // 更新时间 updateTime?: number // 更新时间
} }
/** 会话读位置 IndexedDB 存储结构 */
export interface ConversationReadDO extends ConversationRead { export interface ConversationReadDO extends ConversationRead {
clientConversationId: string // `${conversationType}:${targetId}` clientConversationId: string // `${conversationType}:${targetId}`
} }
/** 消息 IndexedDB 存储结构 */
export interface MessageDO extends Omit<Message, '_ackMerging' | '_localFile' | 'uploadProgress'> { export interface MessageDO extends Omit<Message, '_ackMerging' | '_localFile' | 'uploadProgress'> {
messageKey: string // `${conversationType}:${id}` 或 `client:${clientMessageId}` messageKey: string // `${conversationType}:${id}` 或 `client:${clientMessageId}`
conversationType: number // 会话类型,对齐 ImConversationType conversationType: number // 会话类型,对齐 ImConversationType
clientConversationId: string // ConversationDO.clientConversationId clientConversationId: string // ConversationDO.clientConversationId
} }
/** 设置 IndexedDB 存储结构 */
export interface SettingDO<T = unknown> { export interface SettingDO<T = unknown> {
key: string key: string
value: T value: T
@ -195,12 +200,18 @@ export interface Group {
groupRemark?: string // 群备注。从当前用户的 GroupMember 回填(当前用户对该群的自定义名) groupRemark?: string // 群备注。从当前用户的 GroupMember 回填(当前用户对该群的自定义名)
members?: GroupMember[] // 群成员缓存(按需懒加载) members?: GroupMember[] // 群成员缓存(按需懒加载)
infoLoaded?: boolean // 群详情是否已加载,本轮会话内存标记,不持久化 infoLoaded?: boolean // 群详情是否已加载,本轮会话内存标记,不持久化
activeCallLoaded?: boolean // 群活跃通话是否已探测,本轮会话内存标记,不持久化
activeCallExpired?: boolean // 群活跃通话探测是否已过期
membersLoaded?: boolean // members 是否"完整加载"——只有整群 loadGroupMemberList / fetchGroupMemberList 命中时为 truefetchGroupMember 单成员补齐不置位,避免 fetchGroupMemberList(force=false) 命中缓存时误判整群已加载 membersLoaded?: boolean // members 是否"完整加载"——只有整群 loadGroupMemberList / fetchGroupMemberList 命中时为 truefetchGroupMember 单成员补齐不置位,避免 fetchGroupMemberList(force=false) 命中缓存时误判整群已加载
membersExpired?: boolean // 群成员缓存是否已过期;重连 / 重新进入 IM 后只标记不删除,下次进入群会话再刷新 membersExpired?: boolean // 群成员缓存是否已过期;重连 / 重新进入 IM 后只标记不删除,下次进入群会话再刷新
memberCount?: number // 成员总数 memberCount?: number // 成员总数
} }
export type GroupDO = Omit<Group, 'infoLoaded' | 'members' | 'membersExpired' | 'membersLoaded'> /** 群 IndexedDB 存储结构 */
export type GroupDO = Omit<
Group,
'activeCallExpired' | 'activeCallLoaded' | 'infoLoaded' | 'members' | 'membersExpired' | 'membersLoaded'
>
// 群成员实体(前端内部结构) // 群成员实体(前端内部结构)
export interface GroupMember { export interface GroupMember {

View File

@ -11,6 +11,7 @@ import { getActiveCall, joinCall } from '#/api/im/rtc'
import { getCurrentUserId } from '#/views/im/utils/auth' import { getCurrentUserId } from '#/views/im/utils/auth'
import { useGroupCallMembers } from '../../composables/useGroupCallMembers' import { useGroupCallMembers } from '../../composables/useGroupCallMembers'
import { useGroupStore } from '../../store/groupStore'
import { useRtcStore } from '../../store/rtcStore' import { useRtcStore } from '../../store/rtcStore'
import { UserAvatar } from '../user' import { UserAvatar } from '../user'
@ -21,6 +22,7 @@ const props = defineProps<{
}>() }>()
const rtcStore = useRtcStore() const rtcStore = useRtcStore()
const groupStore = useGroupStore()
const popoverVisible = ref(false) const popoverVisible = ref(false)
@ -40,24 +42,48 @@ const pillText = computed(() => {
* [groupId, room] 双源监听 + 已填充守卫避免切群 / 首次填充触发的双次重复拉取 * [groupId, room] 双源监听 + 已填充守卫避免切群 / 首次填充触发的双次重复拉取
*/ */
watch( watch(
() => [props.groupId, activeCall.value?.room] as const, () =>
[
props.groupId,
activeCall.value?.room,
groupStore.isGroupActiveCallExpired(props.groupId)
] as const,
async ([groupId, room], oldValues) => { async ([groupId, room], oldValues) => {
if (!groupId || !activeCall.value) { if (!groupId) {
return return
} }
// if (!activeCall.value) {
if (!groupStore.isGroupActiveCallExpired(groupId)) {
return
}
try {
const data = await getActiveCall(groupId)
if (data) {
rtcStore.setGroupCall(data, true)
} else {
rtcStore.removeGroupCall(groupId)
}
} catch (error) {
console.warn('[GroupCallBanner] getActiveCall 失败', { groupId }, error)
}
return
}
//
const groupChanged = !oldValues || oldValues[0] !== groupId const groupChanged = !oldValues || oldValues[0] !== groupId
const roomChanged = oldValues && oldValues[1] !== room const roomChanged = oldValues && oldValues[1] !== room
const participantsLoaded = (activeCall.value?.joinedUserIds?.length ?? 0) > 1 const participantsLoaded = (activeCall.value?.joinedUserIds?.length ?? 0) > 1
const activeCallExpired = groupStore.isGroupActiveCallExpired(groupId)
if ( if (
rtcStore.isGroupCallParticipantsLoaded(groupId, room) || !activeCallExpired &&
(!groupChanged && !roomChanged && participantsLoaded) (rtcStore.isGroupCallParticipantsLoaded(groupId, room) ||
(!groupChanged && !roomChanged && participantsLoaded))
) { ) {
return return
} }
// store // store
try { try {
const data = await getActiveCall(groupId) const data = await getActiveCall(groupId)
if (data) { if (data) {

View File

@ -290,6 +290,7 @@ export const useMessagePuller = () => {
// 1. 清理连接级缓存 // 1. 清理连接级缓存
messageStore.clearPrivateReadMaxIdCache() messageStore.clearPrivateReadMaxIdCache()
rtcStore.clearGroupCallCache() rtcStore.clearGroupCallCache()
groupStore.markAllGroupActiveCallsExpired()
groupStore.markAllGroupInfoExpired() groupStore.markAllGroupInfoExpired()
groupStore.markAllGroupMembersExpired() groupStore.markAllGroupMembersExpired()
// 2. 并发补偿远端状态 // 2. 并发补偿远端状态

View File

@ -240,12 +240,12 @@ export const useMessageSender = () => {
} }
} }
const maxMessageId = Math.max(loadedMaxMessageId, conversation.lastMessageId || 0) const maxMessageId = Math.max(loadedMaxMessageId, conversation.lastMessageId || 0)
const readCovered = conversationStore.isReadPositionCovered( const readReported = conversationStore.isReportedReadPositionCovered(
conversation.type, conversation.type,
conversation.targetId, conversation.targetId,
maxMessageId maxMessageId
) )
if (readCovered) { if (readReported) {
conversationStore.markConversationRead(conversation.type, conversation.targetId) conversationStore.markConversationRead(conversation.type, conversation.targetId)
return return
} }
@ -275,6 +275,11 @@ export const useMessageSender = () => {
} else { } else {
await apiReadChannelMessages(conversation.targetId, maxMessageId) await apiReadChannelMessages(conversation.targetId, maxMessageId)
} }
conversationStore.markConversationReadReported(
conversation.type,
conversation.targetId,
maxMessageId
)
} catch (error) { } catch (error) {
console.error( console.error(
'[IM] 标记已读失败', '[IM] 标记已读失败',

View File

@ -61,6 +61,7 @@ onMounted(async () => {
const hasFriendRows = cacheResults[2] const hasFriendRows = cacheResults[2]
const hasGroupRows = cacheResults[3] const hasGroupRows = cacheResults[3]
const hasChannelRows = cacheResults[4] const hasChannelRows = cacheResults[4]
groupStore.markAllGroupActiveCallsExpired()
groupStore.markAllGroupMembersExpired() groupStore.markAllGroupMembersExpired()
childRouteReady.value = true childRouteReady.value = true
// 1.4 unhandled-list // 1.4 unhandled-list

View File

@ -27,8 +27,6 @@ import { useMessageStore } from './messageStore'
const PERSIST_DRAFT_DEBOUNCE_MS = 500 const PERSIST_DRAFT_DEBOUNCE_MS = 500
const pendingDraftConversations = new Set<Conversation>() const pendingDraftConversations = new Set<Conversation>()
type LegacyConversationDO = ConversationDO & { readMessageId?: number }
/** 创建会话读位置记录 */ /** 创建会话读位置记录 */
function createConversationRead( function createConversationRead(
type: number, type: number,
@ -89,6 +87,7 @@ function toConversationDO(conversation: Conversation): ConversationDO {
lastReceiptStatus: conversation.lastReceiptStatus, lastReceiptStatus: conversation.lastReceiptStatus,
lastSelfSend: conversation.lastSelfSend, lastSelfSend: conversation.lastSelfSend,
lastSenderDisplayName: conversation.lastSenderDisplayName, lastSenderDisplayName: conversation.lastSenderDisplayName,
readMessageId: conversation.readMessageId,
deleted: conversation.deleted, deleted: conversation.deleted,
top: conversation.top, top: conversation.top,
silent: conversation.silent, silent: conversation.silent,
@ -100,10 +99,9 @@ function toConversationDO(conversation: Conversation): ConversationDO {
} }
/** IndexedDB 记录转会话 */ /** IndexedDB 记录转会话 */
function fromConversationDO(conversation: LegacyConversationDO): Conversation { function fromConversationDO(conversation: ConversationDO): Conversation {
const { const {
clientConversationId: _clientConversationId, clientConversationId: _clientConversationId,
readMessageId: _readMessageId,
...rest ...rest
} = conversation } = conversation
return rest return rest
@ -221,34 +219,10 @@ export const useConversationStore = defineStore('imConversationStore', {
const item = fromConversationReadDO(record) const item = fromConversationReadDO(record)
nextConversationReads[getClientConversationId(item.conversationType, item.targetId)] = item nextConversationReads[getClientConversationId(item.conversationType, item.targetId)] = item
} }
const migratedReads: ConversationRead[] = [] const nextConversations = conversations.map((conversation) => fromConversationDO(conversation))
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((conversation) =>
fromConversationDO(conversation)
)
this.conversationReads = nextConversationReads this.conversationReads = nextConversationReads
await this.applyLocalConversationReads(nextConversations) await this.applyLocalConversationReads(nextConversations)
this.conversations = nextConversations this.conversations = nextConversations
if (migratedReads.length > 0) {
void this.saveConversationReadRecord(migratedReads).catch((error) =>
console.warn('[IM conversationStore] 会话读位置迁移失败', error)
)
}
if (Array.isArray(recent)) { if (Array.isArray(recent)) {
this.recentForwardConversationKeys = recent.slice(0, CONVERSATION_RECENT_FORWARD_MAX) this.recentForwardConversationKeys = recent.slice(0, CONVERSATION_RECENT_FORWARD_MAX)
} }
@ -358,6 +332,15 @@ export const useConversationStore = defineStore('imConversationStore', {
return !!record && record.messageId >= messageId return !!record && record.messageId >= messageId
}, },
/** 判断服务端已读位置是否覆盖消息编号 */
isReportedReadPositionCovered(type: number, targetId: number, messageId?: number): boolean {
if (!messageId) {
return false
}
const conversation = this.getConversation(type, targetId)
return (conversation?.readMessageId || 0) >= messageId
},
/** 应用读位置到会话 */ /** 应用读位置到会话 */
applyReadToConversation(conversation: Conversation, messageId: number): boolean { applyReadToConversation(conversation: Conversation, messageId: number): boolean {
if (!conversation.lastMessageId || conversation.lastMessageId > messageId) { if (!conversation.lastMessageId || conversation.lastMessageId > messageId) {
@ -411,6 +394,11 @@ export const useConversationStore = defineStore('imConversationStore', {
} }
const current = this.conversationReads[clientConversationId] const current = this.conversationReads[clientConversationId]
const messageId = Math.max(record.messageId, current?.messageId || 0) const messageId = Math.max(record.messageId, current?.messageId || 0)
const conversation = this.getConversation(record.conversationType, record.targetId)
if (conversation && record.messageId > (conversation.readMessageId || 0)) {
conversation.readMessageId = record.messageId
changedConversations.set(clientConversationId, conversation)
}
if (!current || messageId > current.messageId) { if (!current || messageId > current.messageId) {
const next = { const next = {
conversationType: record.conversationType, conversationType: record.conversationType,
@ -422,7 +410,6 @@ export const useConversationStore = defineStore('imConversationStore', {
changedReads.set(clientConversationId, next) changedReads.set(clientConversationId, next)
} }
const conversation = this.getConversation(record.conversationType, record.targetId)
if (conversation && this.applyReadToConversation(conversation, messageId)) { if (conversation && this.applyReadToConversation(conversation, messageId)) {
changedConversations.set(clientConversationId, conversation) changedConversations.set(clientConversationId, conversation)
} else if (conversation) { } else if (conversation) {
@ -748,6 +735,19 @@ export const useConversationStore = defineStore('imConversationStore', {
this.saveConversation(conversation) this.saveConversation(conversation)
}, },
/** 标记会话已上报服务端读位置 */
markConversationReadReported(type: number, targetId: number, messageId?: number): void {
if (!messageId) {
return
}
const conversation = this.getConversation(type, targetId)
if (!conversation || messageId <= (conversation.readMessageId || 0)) {
return
}
conversation.readMessageId = messageId
this.saveConversation(conversation)
},
// ==================== 最近转发 ==================== // ==================== 最近转发 ====================
/** 推送最近转发会话 */ /** 推送最近转发会话 */

View File

@ -47,6 +47,8 @@ const pendingSingleMemberKey = (userId: number, groupId: number, memberUserId: n
/** 构建群 IndexedDB 记录 */ /** 构建群 IndexedDB 记录 */
function buildGroupDO(group: Group): GroupDO { function buildGroupDO(group: Group): GroupDO {
const { const {
activeCallExpired: _activeCallExpired,
activeCallLoaded: _activeCallLoaded,
infoLoaded: _infoLoaded, infoLoaded: _infoLoaded,
members: _members, members: _members,
membersLoaded: _membersLoaded, membersLoaded: _membersLoaded,
@ -229,11 +231,13 @@ export const useGroupStore = defineStore('imGroupStore', {
this.groups = fresh.map((group) => { this.groups = fresh.map((group) => {
const existing = groupMap.get(group.id) const existing = groupMap.get(group.id)
if (!existing) { if (!existing) {
return { ...group, infoLoaded: true } return { ...group, activeCallExpired: true, infoLoaded: true }
} }
return { return {
...group, ...group,
infoLoaded: true, infoLoaded: true,
activeCallExpired: existing.activeCallExpired,
activeCallLoaded: existing.activeCallLoaded,
members: existing.members, members: existing.members,
memberCount: existing.memberCount ?? group.memberCount, memberCount: existing.memberCount ?? group.memberCount,
membersLoaded: existing.membersLoaded, membersLoaded: existing.membersLoaded,
@ -287,6 +291,29 @@ export const useGroupStore = defineStore('imGroupStore', {
} }
}, },
/** 失效全部群通话探测缓存 */
markAllGroupActiveCallsExpired() {
for (const group of this.groups) {
group.activeCallExpired = true
}
},
/** 标记群通话探测已加载 */
markGroupActiveCallLoaded(groupId: number) {
const group = this.getGroup(groupId)
if (!group) {
return
}
group.activeCallLoaded = true
group.activeCallExpired = false
},
/** 判断群通话是否需要重新探测 */
isGroupActiveCallExpired(groupId: number): boolean {
const group = this.getGroup(groupId)
return !group?.activeCallLoaded || !!group.activeCallExpired
},
/** 失效指定群成员缓存 */ /** 失效指定群成员缓存 */
markGroupMembersExpired(groupId: number) { markGroupMembersExpired(groupId: number) {
const group = this.getGroup(groupId) const group = this.getGroup(groupId)

View File

@ -260,6 +260,7 @@ export const useRtcStore = defineStore('imRtc', () => {
if (!payload?.groupId) { if (!payload?.groupId) {
return return
} }
useGroupStore().markGroupActiveCallLoaded(payload.groupId)
// 浅比较room / mediaType / joinedUserIds / inviteeIds 都没变就跳过,避免下游 watcher 无意义重算 // 浅比较room / mediaType / joinedUserIds / inviteeIds 都没变就跳过,避免下游 watcher 无意义重算
const existing = groupActiveCalls.value.get(payload.groupId) const existing = groupActiveCalls.value.get(payload.groupId)
const nextParticipantsLoaded = participantsLoaded ?? !!existing?.participantsLoaded const nextParticipantsLoaded = participantsLoaded ?? !!existing?.participantsLoaded
@ -320,7 +321,11 @@ export const useRtcStore = defineStore('imRtc', () => {
/** 群通话结束:从 groupActiveCalls 移除;胶囊条消失 */ /** 群通话结束:从 groupActiveCalls 移除;胶囊条消失 */
function removeGroupCall(groupId: number) { function removeGroupCall(groupId: number) {
if (!groupId) {
return
}
clearGroupCallCache(groupId) clearGroupCallCache(groupId)
useGroupStore().markGroupActiveCallLoaded(groupId)
} }
/** 获取群当前活跃通话;用于胶囊条按 groupId 查询 */ /** 获取群当前活跃通话;用于胶囊条按 groupId 查询 */

View File

@ -463,7 +463,7 @@ export const useImWebSocketStore = defineStore('imWebSocketStore', {
) )
if (isActive) { if (isActive) {
// 窗口打开 = 已读:本端清未读 + 上报服务端读位置,避免读位置滞后 // 窗口打开 = 已读:本端清未读 + 上报服务端读位置,避免读位置滞后
const readCovered = conversationStore.isReadPositionCovered( const readReported = conversationStore.isReportedReadPositionCovered(
ImConversationType.CHANNEL, ImConversationType.CHANNEL,
websocketMessage.channelId, websocketMessage.channelId,
websocketMessage.id websocketMessage.id
@ -471,10 +471,17 @@ export const useImWebSocketStore = defineStore('imWebSocketStore', {
conversationStore.markConversationRead( conversationStore.markConversationRead(
ImConversationType.CHANNEL, ImConversationType.CHANNEL,
websocketMessage.channelId, websocketMessage.channelId,
readCovered ? undefined : websocketMessage.id websocketMessage.id
) )
if (!readCovered) { if (!readReported) {
apiReadChannelMessages(websocketMessage.channelId, websocketMessage.id) apiReadChannelMessages(websocketMessage.channelId, websocketMessage.id)
.then(() =>
conversationStore.markConversationReadReported(
ImConversationType.CHANNEL,
websocketMessage.channelId,
websocketMessage.id
)
)
.catch((error) => { .catch((error) => {
console.warn( console.warn(
'[IM WS] 频道自动已读上报失败', '[IM WS] 频道自动已读上报失败',
@ -678,7 +685,7 @@ export const useImWebSocketStore = defineStore('imWebSocketStore', {
if (isActive) { if (isActive) {
// 聊天窗口打开 = 实际看到了:本端清未读;私聊已读开启时再上报后端,让对方 UI 立刻切到"已读" // 聊天窗口打开 = 实际看到了:本端清未读;私聊已读开启时再上报后端,让对方 UI 立刻切到"已读"
// 已读位置直接用刚到的消息 id这条就是当前会话最大 id // 已读位置直接用刚到的消息 id这条就是当前会话最大 id
const readCovered = conversationStore.isReadPositionCovered( const readReported = conversationStore.isReportedReadPositionCovered(
ImConversationType.PRIVATE, ImConversationType.PRIVATE,
peerId, peerId,
websocketMessage.id websocketMessage.id
@ -686,10 +693,17 @@ export const useImWebSocketStore = defineStore('imWebSocketStore', {
conversationStore.markConversationRead( conversationStore.markConversationRead(
ImConversationType.PRIVATE, ImConversationType.PRIVATE,
peerId, peerId,
readCovered ? undefined : websocketMessage.id websocketMessage.id
) )
if (MESSAGE_PRIVATE_READ_ENABLED && !readCovered) { if (MESSAGE_PRIVATE_READ_ENABLED && !readReported) {
apiReadPrivateMessages(peerId, websocketMessage.id) apiReadPrivateMessages(peerId, websocketMessage.id)
.then(() =>
conversationStore.markConversationReadReported(
ImConversationType.PRIVATE,
peerId,
websocketMessage.id
)
)
.catch((error) => { .catch((error) => {
console.warn( console.warn(
'[IM WS] 私聊自动已读上报失败', '[IM WS] 私聊自动已读上报失败',
@ -831,7 +845,7 @@ export const useImWebSocketStore = defineStore('imWebSocketStore', {
conversationStore.activeConversation?.targetId === websocketMessage.groupId conversationStore.activeConversation?.targetId === websocketMessage.groupId
if (isActive) { if (isActive) {
// 群已读上报需要带 messageId群消息以"读到第几条"的游标为准,区别于私聊只标 receiverId群已读关闭时仅本地清零 // 群已读上报需要带 messageId群消息以"读到第几条"的游标为准,区别于私聊只标 receiverId群已读关闭时仅本地清零
const readCovered = conversationStore.isReadPositionCovered( const readReported = conversationStore.isReportedReadPositionCovered(
ImConversationType.GROUP, ImConversationType.GROUP,
websocketMessage.groupId, websocketMessage.groupId,
websocketMessage.id websocketMessage.id
@ -839,10 +853,17 @@ export const useImWebSocketStore = defineStore('imWebSocketStore', {
conversationStore.markConversationRead( conversationStore.markConversationRead(
ImConversationType.GROUP, ImConversationType.GROUP,
websocketMessage.groupId, websocketMessage.groupId,
readCovered ? undefined : websocketMessage.id websocketMessage.id
) )
if (MESSAGE_GROUP_READ_ENABLED && !readCovered) { if (MESSAGE_GROUP_READ_ENABLED && !readReported) {
apiReadGroupMessages(websocketMessage.groupId, websocketMessage.id) apiReadGroupMessages(websocketMessage.groupId, websocketMessage.id)
.then(() =>
conversationStore.markConversationReadReported(
ImConversationType.GROUP,
websocketMessage.groupId,
websocketMessage.id
)
)
.catch((error) => { .catch((error) => {
console.warn( console.warn(
'[IM WS] 群聊自动已读上报失败', '[IM WS] 群聊自动已读上报失败',

View File

@ -110,6 +110,7 @@ export interface Conversation {
silent?: boolean // 是否免打扰(不展示未读徽标 + 不响提示音) silent?: boolean // 是否免打扰(不展示未读徽标 + 不响提示音)
atMe?: boolean // 群聊:是否有人 @我 atMe?: boolean // 群聊:是否有人 @我
atAll?: boolean // 群聊:是否有人 @全体成员 atAll?: boolean // 群聊:是否有人 @全体成员
readMessageId?: number // 已上报到服务端的最大已读消息编号
draft?: { draft?: {
html: string // 输入框 HTML html: string // 输入框 HTML
plain: string // 输入框纯文本 plain: string // 输入框纯文本
@ -147,6 +148,7 @@ export interface Message {
// ==================== IndexedDB 本地存储结构 ==================== // ==================== IndexedDB 本地存储结构 ====================
/** 会话 IndexedDB 存储结构 */
export interface ConversationDO extends Conversation { export interface ConversationDO extends Conversation {
clientConversationId: string // `${type}:${targetId}` clientConversationId: string // `${type}:${targetId}`
} }
@ -158,16 +160,19 @@ export interface ConversationRead {
updateTime?: number // 更新时间 updateTime?: number // 更新时间
} }
/** 会话读位置 IndexedDB 存储结构 */
export interface ConversationReadDO extends ConversationRead { export interface ConversationReadDO extends ConversationRead {
clientConversationId: string // `${conversationType}:${targetId}` clientConversationId: string // `${conversationType}:${targetId}`
} }
/** 消息 IndexedDB 存储结构 */
export interface MessageDO extends Omit<Message, '_ackMerging' | '_localFile' | 'uploadProgress'> { export interface MessageDO extends Omit<Message, '_ackMerging' | '_localFile' | 'uploadProgress'> {
messageKey: string // `${conversationType}:${id}` 或 `client:${clientMessageId}` messageKey: string // `${conversationType}:${id}` 或 `client:${clientMessageId}`
conversationType: number // 会话类型,对齐 ImConversationType conversationType: number // 会话类型,对齐 ImConversationType
clientConversationId: string // ConversationDO.clientConversationId clientConversationId: string // ConversationDO.clientConversationId
} }
/** 设置 IndexedDB 存储结构 */
export interface SettingDO<T = unknown> { export interface SettingDO<T = unknown> {
key: string key: string
value: T value: T
@ -195,12 +200,18 @@ export interface Group {
groupRemark?: string // 群备注。从当前用户的 GroupMember 回填(当前用户对该群的自定义名) groupRemark?: string // 群备注。从当前用户的 GroupMember 回填(当前用户对该群的自定义名)
members?: GroupMember[] // 群成员缓存(按需懒加载) members?: GroupMember[] // 群成员缓存(按需懒加载)
infoLoaded?: boolean // 群详情是否已加载,本轮会话内存标记,不持久化 infoLoaded?: boolean // 群详情是否已加载,本轮会话内存标记,不持久化
activeCallLoaded?: boolean // 群活跃通话是否已探测,本轮会话内存标记,不持久化
activeCallExpired?: boolean // 群活跃通话探测是否已过期
membersLoaded?: boolean // members 是否"完整加载"——只有整群 loadGroupMemberList / fetchGroupMemberList 命中时为 truefetchGroupMember 单成员补齐不置位,避免 fetchGroupMemberList(force=false) 命中缓存时误判整群已加载 membersLoaded?: boolean // members 是否"完整加载"——只有整群 loadGroupMemberList / fetchGroupMemberList 命中时为 truefetchGroupMember 单成员补齐不置位,避免 fetchGroupMemberList(force=false) 命中缓存时误判整群已加载
membersExpired?: boolean // 群成员缓存是否已过期;重连 / 重新进入 IM 后只标记不删除,下次进入群会话再刷新 membersExpired?: boolean // 群成员缓存是否已过期;重连 / 重新进入 IM 后只标记不删除,下次进入群会话再刷新
memberCount?: number // 成员总数 memberCount?: number // 成员总数
} }
export type GroupDO = Omit<Group, 'infoLoaded' | 'members' | 'membersExpired' | 'membersLoaded'> /** 群 IndexedDB 存储结构 */
export type GroupDO = Omit<
Group,
'activeCallExpired' | 'activeCallLoaded' | 'infoLoaded' | 'members' | 'membersExpired' | 'membersLoaded'
>
// 群成员实体(前端内部结构) // 群成员实体(前端内部结构)
export interface GroupMember { export interface GroupMember {

View File

@ -11,6 +11,7 @@ import { getActiveCall, joinCall } from '#/api/im/rtc'
import { getCurrentUserId } from '#/views/im/utils/auth' import { getCurrentUserId } from '#/views/im/utils/auth'
import { useGroupCallMembers } from '../../composables/useGroupCallMembers' import { useGroupCallMembers } from '../../composables/useGroupCallMembers'
import { useGroupStore } from '../../store/groupStore'
import { useRtcStore } from '../../store/rtcStore' import { useRtcStore } from '../../store/rtcStore'
import { UserAvatar } from '../user' import { UserAvatar } from '../user'
@ -21,6 +22,7 @@ const props = defineProps<{
}>() }>()
const rtcStore = useRtcStore() const rtcStore = useRtcStore()
const groupStore = useGroupStore()
const popoverVisible = ref(false) const popoverVisible = ref(false)
@ -40,24 +42,48 @@ const pillText = computed(() => {
* [groupId, room] 双源监听 + 已填充守卫避免切群 / 首次填充触发的双次重复拉取 * [groupId, room] 双源监听 + 已填充守卫避免切群 / 首次填充触发的双次重复拉取
*/ */
watch( watch(
() => [props.groupId, activeCall.value?.room] as const, () =>
[
props.groupId,
activeCall.value?.room,
groupStore.isGroupActiveCallExpired(props.groupId)
] as const,
async ([groupId, room], oldValues) => { async ([groupId, room], oldValues) => {
if (!groupId || !activeCall.value) { if (!groupId) {
return return
} }
// if (!activeCall.value) {
if (!groupStore.isGroupActiveCallExpired(groupId)) {
return
}
try {
const data = await getActiveCall(groupId)
if (data) {
rtcStore.setGroupCall(data, true)
} else {
rtcStore.removeGroupCall(groupId)
}
} catch (error) {
console.warn('[GroupCallBanner] getActiveCall 失败', { groupId }, error)
}
return
}
//
const groupChanged = !oldValues || oldValues[0] !== groupId const groupChanged = !oldValues || oldValues[0] !== groupId
const roomChanged = oldValues && oldValues[1] !== room const roomChanged = oldValues && oldValues[1] !== room
const participantsLoaded = (activeCall.value?.joinedUserIds?.length ?? 0) > 1 const participantsLoaded = (activeCall.value?.joinedUserIds?.length ?? 0) > 1
const activeCallExpired = groupStore.isGroupActiveCallExpired(groupId)
if ( if (
rtcStore.isGroupCallParticipantsLoaded(groupId, room) || !activeCallExpired &&
(!groupChanged && !roomChanged && participantsLoaded) (rtcStore.isGroupCallParticipantsLoaded(groupId, room) ||
(!groupChanged && !roomChanged && participantsLoaded))
) { ) {
return return
} }
// store // store
try { try {
const data = await getActiveCall(groupId) const data = await getActiveCall(groupId)
if (data) { if (data) {

View File

@ -290,6 +290,7 @@ export const useMessagePuller = () => {
// 1. 清理连接级缓存 // 1. 清理连接级缓存
messageStore.clearPrivateReadMaxIdCache() messageStore.clearPrivateReadMaxIdCache()
rtcStore.clearGroupCallCache() rtcStore.clearGroupCallCache()
groupStore.markAllGroupActiveCallsExpired()
groupStore.markAllGroupInfoExpired() groupStore.markAllGroupInfoExpired()
groupStore.markAllGroupMembersExpired() groupStore.markAllGroupMembersExpired()
// 2. 并发补偿远端状态 // 2. 并发补偿远端状态

View File

@ -240,12 +240,12 @@ export const useMessageSender = () => {
} }
} }
const maxMessageId = Math.max(loadedMaxMessageId, conversation.lastMessageId || 0) const maxMessageId = Math.max(loadedMaxMessageId, conversation.lastMessageId || 0)
const readCovered = conversationStore.isReadPositionCovered( const readReported = conversationStore.isReportedReadPositionCovered(
conversation.type, conversation.type,
conversation.targetId, conversation.targetId,
maxMessageId maxMessageId
) )
if (readCovered) { if (readReported) {
conversationStore.markConversationRead(conversation.type, conversation.targetId) conversationStore.markConversationRead(conversation.type, conversation.targetId)
return return
} }
@ -275,6 +275,11 @@ export const useMessageSender = () => {
} else { } else {
await apiReadChannelMessages(conversation.targetId, maxMessageId) await apiReadChannelMessages(conversation.targetId, maxMessageId)
} }
conversationStore.markConversationReadReported(
conversation.type,
conversation.targetId,
maxMessageId
)
} catch (error) { } catch (error) {
console.error( console.error(
'[IM] 标记已读失败', '[IM] 标记已读失败',

View File

@ -61,6 +61,7 @@ onMounted(async () => {
const hasFriendRows = cacheResults[2] const hasFriendRows = cacheResults[2]
const hasGroupRows = cacheResults[3] const hasGroupRows = cacheResults[3]
const hasChannelRows = cacheResults[4] const hasChannelRows = cacheResults[4]
groupStore.markAllGroupActiveCallsExpired()
groupStore.markAllGroupMembersExpired() groupStore.markAllGroupMembersExpired()
childRouteReady.value = true childRouteReady.value = true
// 1.4 unhandled-list // 1.4 unhandled-list

View File

@ -27,8 +27,6 @@ import { useMessageStore } from './messageStore'
const PERSIST_DRAFT_DEBOUNCE_MS = 500 const PERSIST_DRAFT_DEBOUNCE_MS = 500
const pendingDraftConversations = new Set<Conversation>() const pendingDraftConversations = new Set<Conversation>()
type LegacyConversationDO = ConversationDO & { readMessageId?: number }
/** 创建会话读位置记录 */ /** 创建会话读位置记录 */
function createConversationRead( function createConversationRead(
type: number, type: number,
@ -89,6 +87,7 @@ function toConversationDO(conversation: Conversation): ConversationDO {
lastReceiptStatus: conversation.lastReceiptStatus, lastReceiptStatus: conversation.lastReceiptStatus,
lastSelfSend: conversation.lastSelfSend, lastSelfSend: conversation.lastSelfSend,
lastSenderDisplayName: conversation.lastSenderDisplayName, lastSenderDisplayName: conversation.lastSenderDisplayName,
readMessageId: conversation.readMessageId,
deleted: conversation.deleted, deleted: conversation.deleted,
top: conversation.top, top: conversation.top,
silent: conversation.silent, silent: conversation.silent,
@ -100,10 +99,9 @@ function toConversationDO(conversation: Conversation): ConversationDO {
} }
/** IndexedDB 记录转会话 */ /** IndexedDB 记录转会话 */
function fromConversationDO(conversation: LegacyConversationDO): Conversation { function fromConversationDO(conversation: ConversationDO): Conversation {
const { const {
clientConversationId: _clientConversationId, clientConversationId: _clientConversationId,
readMessageId: _readMessageId,
...rest ...rest
} = conversation } = conversation
return rest return rest
@ -221,34 +219,10 @@ export const useConversationStore = defineStore('imConversationStore', {
const item = fromConversationReadDO(record) const item = fromConversationReadDO(record)
nextConversationReads[getClientConversationId(item.conversationType, item.targetId)] = item nextConversationReads[getClientConversationId(item.conversationType, item.targetId)] = item
} }
const migratedReads: ConversationRead[] = [] const nextConversations = conversations.map((conversation) => fromConversationDO(conversation))
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((conversation) =>
fromConversationDO(conversation)
)
this.conversationReads = nextConversationReads this.conversationReads = nextConversationReads
await this.applyLocalConversationReads(nextConversations) await this.applyLocalConversationReads(nextConversations)
this.conversations = nextConversations this.conversations = nextConversations
if (migratedReads.length > 0) {
void this.saveConversationReadRecord(migratedReads).catch((error) =>
console.warn('[IM conversationStore] 会话读位置迁移失败', error)
)
}
if (Array.isArray(recent)) { if (Array.isArray(recent)) {
this.recentForwardConversationKeys = recent.slice(0, CONVERSATION_RECENT_FORWARD_MAX) this.recentForwardConversationKeys = recent.slice(0, CONVERSATION_RECENT_FORWARD_MAX)
} }
@ -358,6 +332,15 @@ export const useConversationStore = defineStore('imConversationStore', {
return !!record && record.messageId >= messageId return !!record && record.messageId >= messageId
}, },
/** 判断服务端已读位置是否覆盖消息编号 */
isReportedReadPositionCovered(type: number, targetId: number, messageId?: number): boolean {
if (!messageId) {
return false
}
const conversation = this.getConversation(type, targetId)
return (conversation?.readMessageId || 0) >= messageId
},
/** 应用读位置到会话 */ /** 应用读位置到会话 */
applyReadToConversation(conversation: Conversation, messageId: number): boolean { applyReadToConversation(conversation: Conversation, messageId: number): boolean {
if (!conversation.lastMessageId || conversation.lastMessageId > messageId) { if (!conversation.lastMessageId || conversation.lastMessageId > messageId) {
@ -411,6 +394,11 @@ export const useConversationStore = defineStore('imConversationStore', {
} }
const current = this.conversationReads[clientConversationId] const current = this.conversationReads[clientConversationId]
const messageId = Math.max(record.messageId, current?.messageId || 0) const messageId = Math.max(record.messageId, current?.messageId || 0)
const conversation = this.getConversation(record.conversationType, record.targetId)
if (conversation && record.messageId > (conversation.readMessageId || 0)) {
conversation.readMessageId = record.messageId
changedConversations.set(clientConversationId, conversation)
}
if (!current || messageId > current.messageId) { if (!current || messageId > current.messageId) {
const next = { const next = {
conversationType: record.conversationType, conversationType: record.conversationType,
@ -422,7 +410,6 @@ export const useConversationStore = defineStore('imConversationStore', {
changedReads.set(clientConversationId, next) changedReads.set(clientConversationId, next)
} }
const conversation = this.getConversation(record.conversationType, record.targetId)
if (conversation && this.applyReadToConversation(conversation, messageId)) { if (conversation && this.applyReadToConversation(conversation, messageId)) {
changedConversations.set(clientConversationId, conversation) changedConversations.set(clientConversationId, conversation)
} else if (conversation) { } else if (conversation) {
@ -748,6 +735,19 @@ export const useConversationStore = defineStore('imConversationStore', {
this.saveConversation(conversation) this.saveConversation(conversation)
}, },
/** 标记会话已上报服务端读位置 */
markConversationReadReported(type: number, targetId: number, messageId?: number): void {
if (!messageId) {
return
}
const conversation = this.getConversation(type, targetId)
if (!conversation || messageId <= (conversation.readMessageId || 0)) {
return
}
conversation.readMessageId = messageId
this.saveConversation(conversation)
},
// ==================== 最近转发 ==================== // ==================== 最近转发 ====================
/** 推送最近转发会话 */ /** 推送最近转发会话 */

View File

@ -47,6 +47,8 @@ const pendingSingleMemberKey = (userId: number, groupId: number, memberUserId: n
/** 构建群 IndexedDB 记录 */ /** 构建群 IndexedDB 记录 */
function buildGroupDO(group: Group): GroupDO { function buildGroupDO(group: Group): GroupDO {
const { const {
activeCallExpired: _activeCallExpired,
activeCallLoaded: _activeCallLoaded,
infoLoaded: _infoLoaded, infoLoaded: _infoLoaded,
members: _members, members: _members,
membersLoaded: _membersLoaded, membersLoaded: _membersLoaded,
@ -229,11 +231,13 @@ export const useGroupStore = defineStore('imGroupStore', {
this.groups = fresh.map((group) => { this.groups = fresh.map((group) => {
const existing = groupMap.get(group.id) const existing = groupMap.get(group.id)
if (!existing) { if (!existing) {
return { ...group, infoLoaded: true } return { ...group, activeCallExpired: true, infoLoaded: true }
} }
return { return {
...group, ...group,
infoLoaded: true, infoLoaded: true,
activeCallExpired: existing.activeCallExpired,
activeCallLoaded: existing.activeCallLoaded,
members: existing.members, members: existing.members,
memberCount: existing.memberCount ?? group.memberCount, memberCount: existing.memberCount ?? group.memberCount,
membersLoaded: existing.membersLoaded, membersLoaded: existing.membersLoaded,
@ -287,6 +291,29 @@ export const useGroupStore = defineStore('imGroupStore', {
} }
}, },
/** 失效全部群通话探测缓存 */
markAllGroupActiveCallsExpired() {
for (const group of this.groups) {
group.activeCallExpired = true
}
},
/** 标记群通话探测已加载 */
markGroupActiveCallLoaded(groupId: number) {
const group = this.getGroup(groupId)
if (!group) {
return
}
group.activeCallLoaded = true
group.activeCallExpired = false
},
/** 判断群通话是否需要重新探测 */
isGroupActiveCallExpired(groupId: number): boolean {
const group = this.getGroup(groupId)
return !group?.activeCallLoaded || !!group.activeCallExpired
},
/** 失效指定群成员缓存 */ /** 失效指定群成员缓存 */
markGroupMembersExpired(groupId: number) { markGroupMembersExpired(groupId: number) {
const group = this.getGroup(groupId) const group = this.getGroup(groupId)

View File

@ -260,6 +260,7 @@ export const useRtcStore = defineStore('imRtc', () => {
if (!payload?.groupId) { if (!payload?.groupId) {
return return
} }
useGroupStore().markGroupActiveCallLoaded(payload.groupId)
// 浅比较room / mediaType / joinedUserIds / inviteeIds 都没变就跳过,避免下游 watcher 无意义重算 // 浅比较room / mediaType / joinedUserIds / inviteeIds 都没变就跳过,避免下游 watcher 无意义重算
const existing = groupActiveCalls.value.get(payload.groupId) const existing = groupActiveCalls.value.get(payload.groupId)
const nextParticipantsLoaded = participantsLoaded ?? !!existing?.participantsLoaded const nextParticipantsLoaded = participantsLoaded ?? !!existing?.participantsLoaded
@ -320,7 +321,11 @@ export const useRtcStore = defineStore('imRtc', () => {
/** 群通话结束:从 groupActiveCalls 移除;胶囊条消失 */ /** 群通话结束:从 groupActiveCalls 移除;胶囊条消失 */
function removeGroupCall(groupId: number) { function removeGroupCall(groupId: number) {
if (!groupId) {
return
}
clearGroupCallCache(groupId) clearGroupCallCache(groupId)
useGroupStore().markGroupActiveCallLoaded(groupId)
} }
/** 获取群当前活跃通话;用于胶囊条按 groupId 查询 */ /** 获取群当前活跃通话;用于胶囊条按 groupId 查询 */

View File

@ -463,7 +463,7 @@ export const useImWebSocketStore = defineStore('imWebSocketStore', {
) )
if (isActive) { if (isActive) {
// 窗口打开 = 已读:本端清未读 + 上报服务端读位置,避免读位置滞后 // 窗口打开 = 已读:本端清未读 + 上报服务端读位置,避免读位置滞后
const readCovered = conversationStore.isReadPositionCovered( const readReported = conversationStore.isReportedReadPositionCovered(
ImConversationType.CHANNEL, ImConversationType.CHANNEL,
websocketMessage.channelId, websocketMessage.channelId,
websocketMessage.id websocketMessage.id
@ -471,10 +471,17 @@ export const useImWebSocketStore = defineStore('imWebSocketStore', {
conversationStore.markConversationRead( conversationStore.markConversationRead(
ImConversationType.CHANNEL, ImConversationType.CHANNEL,
websocketMessage.channelId, websocketMessage.channelId,
readCovered ? undefined : websocketMessage.id websocketMessage.id
) )
if (!readCovered) { if (!readReported) {
apiReadChannelMessages(websocketMessage.channelId, websocketMessage.id) apiReadChannelMessages(websocketMessage.channelId, websocketMessage.id)
.then(() =>
conversationStore.markConversationReadReported(
ImConversationType.CHANNEL,
websocketMessage.channelId,
websocketMessage.id
)
)
.catch((error) => { .catch((error) => {
console.warn( console.warn(
'[IM WS] 频道自动已读上报失败', '[IM WS] 频道自动已读上报失败',
@ -678,7 +685,7 @@ export const useImWebSocketStore = defineStore('imWebSocketStore', {
if (isActive) { if (isActive) {
// 聊天窗口打开 = 实际看到了:本端清未读;私聊已读开启时再上报后端,让对方 UI 立刻切到"已读" // 聊天窗口打开 = 实际看到了:本端清未读;私聊已读开启时再上报后端,让对方 UI 立刻切到"已读"
// 已读位置直接用刚到的消息 id这条就是当前会话最大 id // 已读位置直接用刚到的消息 id这条就是当前会话最大 id
const readCovered = conversationStore.isReadPositionCovered( const readReported = conversationStore.isReportedReadPositionCovered(
ImConversationType.PRIVATE, ImConversationType.PRIVATE,
peerId, peerId,
websocketMessage.id websocketMessage.id
@ -686,10 +693,17 @@ export const useImWebSocketStore = defineStore('imWebSocketStore', {
conversationStore.markConversationRead( conversationStore.markConversationRead(
ImConversationType.PRIVATE, ImConversationType.PRIVATE,
peerId, peerId,
readCovered ? undefined : websocketMessage.id websocketMessage.id
) )
if (MESSAGE_PRIVATE_READ_ENABLED && !readCovered) { if (MESSAGE_PRIVATE_READ_ENABLED && !readReported) {
apiReadPrivateMessages(peerId, websocketMessage.id) apiReadPrivateMessages(peerId, websocketMessage.id)
.then(() =>
conversationStore.markConversationReadReported(
ImConversationType.PRIVATE,
peerId,
websocketMessage.id
)
)
.catch((error) => { .catch((error) => {
console.warn( console.warn(
'[IM WS] 私聊自动已读上报失败', '[IM WS] 私聊自动已读上报失败',
@ -831,7 +845,7 @@ export const useImWebSocketStore = defineStore('imWebSocketStore', {
conversationStore.activeConversation?.targetId === websocketMessage.groupId conversationStore.activeConversation?.targetId === websocketMessage.groupId
if (isActive) { if (isActive) {
// 群已读上报需要带 messageId群消息以"读到第几条"的游标为准,区别于私聊只标 receiverId群已读关闭时仅本地清零 // 群已读上报需要带 messageId群消息以"读到第几条"的游标为准,区别于私聊只标 receiverId群已读关闭时仅本地清零
const readCovered = conversationStore.isReadPositionCovered( const readReported = conversationStore.isReportedReadPositionCovered(
ImConversationType.GROUP, ImConversationType.GROUP,
websocketMessage.groupId, websocketMessage.groupId,
websocketMessage.id websocketMessage.id
@ -839,10 +853,17 @@ export const useImWebSocketStore = defineStore('imWebSocketStore', {
conversationStore.markConversationRead( conversationStore.markConversationRead(
ImConversationType.GROUP, ImConversationType.GROUP,
websocketMessage.groupId, websocketMessage.groupId,
readCovered ? undefined : websocketMessage.id websocketMessage.id
) )
if (MESSAGE_GROUP_READ_ENABLED && !readCovered) { if (MESSAGE_GROUP_READ_ENABLED && !readReported) {
apiReadGroupMessages(websocketMessage.groupId, websocketMessage.id) apiReadGroupMessages(websocketMessage.groupId, websocketMessage.id)
.then(() =>
conversationStore.markConversationReadReported(
ImConversationType.GROUP,
websocketMessage.groupId,
websocketMessage.id
)
)
.catch((error) => { .catch((error) => {
console.warn( console.warn(
'[IM WS] 群聊自动已读上报失败', '[IM WS] 群聊自动已读上报失败',

View File

@ -110,6 +110,7 @@ export interface Conversation {
silent?: boolean // 是否免打扰(不展示未读徽标 + 不响提示音) silent?: boolean // 是否免打扰(不展示未读徽标 + 不响提示音)
atMe?: boolean // 群聊:是否有人 @我 atMe?: boolean // 群聊:是否有人 @我
atAll?: boolean // 群聊:是否有人 @全体成员 atAll?: boolean // 群聊:是否有人 @全体成员
readMessageId?: number // 已上报到服务端的最大已读消息编号
draft?: { draft?: {
html: string // 输入框 HTML html: string // 输入框 HTML
plain: string // 输入框纯文本 plain: string // 输入框纯文本
@ -147,6 +148,7 @@ export interface Message {
// ==================== IndexedDB 本地存储结构 ==================== // ==================== IndexedDB 本地存储结构 ====================
/** 会话 IndexedDB 存储结构 */
export interface ConversationDO extends Conversation { export interface ConversationDO extends Conversation {
clientConversationId: string // `${type}:${targetId}` clientConversationId: string // `${type}:${targetId}`
} }
@ -158,16 +160,19 @@ export interface ConversationRead {
updateTime?: number // 更新时间 updateTime?: number // 更新时间
} }
/** 会话读位置 IndexedDB 存储结构 */
export interface ConversationReadDO extends ConversationRead { export interface ConversationReadDO extends ConversationRead {
clientConversationId: string // `${conversationType}:${targetId}` clientConversationId: string // `${conversationType}:${targetId}`
} }
/** 消息 IndexedDB 存储结构 */
export interface MessageDO extends Omit<Message, '_ackMerging' | '_localFile' | 'uploadProgress'> { export interface MessageDO extends Omit<Message, '_ackMerging' | '_localFile' | 'uploadProgress'> {
messageKey: string // `${conversationType}:${id}` 或 `client:${clientMessageId}` messageKey: string // `${conversationType}:${id}` 或 `client:${clientMessageId}`
conversationType: number // 会话类型,对齐 ImConversationType conversationType: number // 会话类型,对齐 ImConversationType
clientConversationId: string // ConversationDO.clientConversationId clientConversationId: string // ConversationDO.clientConversationId
} }
/** 设置 IndexedDB 存储结构 */
export interface SettingDO<T = unknown> { export interface SettingDO<T = unknown> {
key: string key: string
value: T value: T
@ -195,12 +200,18 @@ export interface Group {
groupRemark?: string // 群备注。从当前用户的 GroupMember 回填(当前用户对该群的自定义名) groupRemark?: string // 群备注。从当前用户的 GroupMember 回填(当前用户对该群的自定义名)
members?: GroupMember[] // 群成员缓存(按需懒加载) members?: GroupMember[] // 群成员缓存(按需懒加载)
infoLoaded?: boolean // 群详情是否已加载,本轮会话内存标记,不持久化 infoLoaded?: boolean // 群详情是否已加载,本轮会话内存标记,不持久化
activeCallLoaded?: boolean // 群活跃通话是否已探测,本轮会话内存标记,不持久化
activeCallExpired?: boolean // 群活跃通话探测是否已过期
membersLoaded?: boolean // members 是否"完整加载"——只有整群 loadGroupMemberList / fetchGroupMemberList 命中时为 truefetchGroupMember 单成员补齐不置位,避免 fetchGroupMemberList(force=false) 命中缓存时误判整群已加载 membersLoaded?: boolean // members 是否"完整加载"——只有整群 loadGroupMemberList / fetchGroupMemberList 命中时为 truefetchGroupMember 单成员补齐不置位,避免 fetchGroupMemberList(force=false) 命中缓存时误判整群已加载
membersExpired?: boolean // 群成员缓存是否已过期;重连 / 重新进入 IM 后只标记不删除,下次进入群会话再刷新 membersExpired?: boolean // 群成员缓存是否已过期;重连 / 重新进入 IM 后只标记不删除,下次进入群会话再刷新
memberCount?: number // 成员总数 memberCount?: number // 成员总数
} }
export type GroupDO = Omit<Group, 'infoLoaded' | 'members' | 'membersExpired' | 'membersLoaded'> /** 群 IndexedDB 存储结构 */
export type GroupDO = Omit<
Group,
'activeCallExpired' | 'activeCallLoaded' | 'infoLoaded' | 'members' | 'membersExpired' | 'membersLoaded'
>
// 群成员实体(前端内部结构) // 群成员实体(前端内部结构)
export interface GroupMember { export interface GroupMember {