From b6e13c59c7dfabf80ceefa3afcbb2cff2256fb20 Mon Sep 17 00:00:00 2001 From: YunaiV Date: Fri, 19 Jun 2026 11:05:06 -0700 Subject: [PATCH] =?UTF-8?q?feat(im)=EF=BC=9A=E4=BC=98=E5=8C=96=E5=B7=B2?= =?UTF-8?q?=E8=AF=BB=E4=B8=8A=E6=8A=A5=E8=A1=A5=E5=81=BF=E4=B8=8E=E7=BE=A4?= =?UTF-8?q?=E9=80=9A=E8=AF=9D=E6=8E=A2=E6=B5=8B=E7=BC=93=E5=AD=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 会话新增 readMessageId,记录已上报到服务端的最大已读消息编号 - readActive 与 WebSocket 自动已读改为基于服务端已上报读位置判断是否跳过接口 - read 接口成功后同步 readMessageId,失败时保留本端已读体验并允许后续重新进入补上报 - 拉取服务端 read 进度时同步更新会话 readMessageId,同时保持本地读位置单调合并 - 群信息新增 activeCallLoaded / activeCallExpired,首登与重连时失效群通话探测缓存 - 群通话胶囊在本地无通话且探测过期时懒加载 getActiveCall,避免离线错过通话后无法发现 - 群通话写入或移除时标记探测已加载,并避免通话探测状态写入 IndexedDB - 为 IndexedDB DO 类型补充存储结构注释 --- .../components/rtc/RtcGroupCallBanner.vue | 36 ++++++++-- .../im/home/composables/useMessagePuller.ts | 1 + .../im/home/composables/useMessageSender.ts | 9 ++- src/views/im/home/index.vue | 1 + src/views/im/home/store/conversationStore.ts | 67 ++++++++++--------- src/views/im/home/store/groupStore.ts | 29 +++++++- src/views/im/home/store/rtcStore.ts | 2 + src/views/im/home/store/websocketStore.ts | 39 ++++++++--- src/views/im/home/types/index.ts | 18 ++++- 9 files changed, 151 insertions(+), 51 deletions(-) diff --git a/src/views/im/home/components/rtc/RtcGroupCallBanner.vue b/src/views/im/home/components/rtc/RtcGroupCallBanner.vue index 33ca4cc6f..ff1d92225 100644 --- a/src/views/im/home/components/rtc/RtcGroupCallBanner.vue +++ b/src/views/im/home/components/rtc/RtcGroupCallBanner.vue @@ -67,6 +67,7 @@ import Icon from '@/components/Icon/src/Icon.vue' import UserAvatar from '../user/UserAvatar.vue' import { useMessage } from '@/hooks/web/useMessage' import { useRtcStore } from '../../store/rtcStore' +import { useGroupStore } from '../../store/groupStore' import { useGroupCallMembers } from '../../composables/useGroupCallMembers' import { joinCall, getActiveCall } from '@/api/im/rtc' import { DICT_TYPE, getDictLabel } from '@/utils/dict' @@ -79,6 +80,7 @@ const props = defineProps<{ defineOptions({ name: 'ImRtcGroupCallBanner' }) const rtcStore = useRtcStore() +const groupStore = useGroupStore() const message = useMessage() const popoverVisible = ref(false) @@ -99,19 +101,43 @@ const pillText = computed(() => { * 用 [groupId, room] 双源监听 + 已填充守卫,避免切群 / 首次填充触发的双次重复拉取 */ watch( - () => [props.groupId, activeCall.value?.room] as const, + () => + [ + props.groupId, + activeCall.value?.room, + groupStore.isGroupActiveCallExpired(props.groupId) + ] as const, async ([groupId, room], oldValues) => { - if (!groupId || !activeCall.value) { + if (!groupId) { 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 (e) { + console.warn('[GroupCallBanner] getActiveCall 失败', { groupId }, e) + } + return + } + + // 决策是否需要拉取:补齐本地已有通话;没有本地通话时按群缓存过期状态懒探测一次 const groupChanged = !oldValues || oldValues[0] !== groupId const roomChanged = oldValues && oldValues[1] !== room const participantsLoaded = (activeCall.value?.joinedUserIds?.length ?? 0) > 1 + const activeCallExpired = groupStore.isGroupActiveCallExpired(groupId) if ( - rtcStore.isGroupCallParticipantsLoaded(groupId, room) || - (!groupChanged && !roomChanged && participantsLoaded) + !activeCallExpired && + (rtcStore.isGroupCallParticipantsLoaded(groupId, room) || + (!groupChanged && !roomChanged && participantsLoaded)) ) { return } diff --git a/src/views/im/home/composables/useMessagePuller.ts b/src/views/im/home/composables/useMessagePuller.ts index 3ffd38a6c..e26bcb612 100644 --- a/src/views/im/home/composables/useMessagePuller.ts +++ b/src/views/im/home/composables/useMessagePuller.ts @@ -295,6 +295,7 @@ export const useMessagePuller = () => { // 1. 清理连接级缓存 messageStore.clearPrivateReadMaxIdCache() rtcStore.clearGroupCallCache() + groupStore.markAllGroupActiveCallsExpired() groupStore.markAllGroupInfoExpired() groupStore.markAllGroupMembersExpired() // 2. 并发补偿远端状态 diff --git a/src/views/im/home/composables/useMessageSender.ts b/src/views/im/home/composables/useMessageSender.ts index 12109493d..b11419df0 100644 --- a/src/views/im/home/composables/useMessageSender.ts +++ b/src/views/im/home/composables/useMessageSender.ts @@ -237,12 +237,12 @@ export const useMessageSender = () => { 0 ) const maxMessageId = Math.max(loadedMaxMessageId, conversation.lastMessageId || 0) - const readCovered = conversationStore.isReadPositionCovered( + const readReported = conversationStore.isReportedReadPositionCovered( conversation.type, conversation.targetId, maxMessageId ) - if (readCovered) { + if (readReported) { conversationStore.markConversationRead(conversation.type, conversation.targetId) return } @@ -272,6 +272,11 @@ export const useMessageSender = () => { } else { await apiReadChannelMessages(conversation.targetId, maxMessageId) } + conversationStore.markConversationReadReported( + conversation.type, + conversation.targetId, + maxMessageId + ) } catch (e) { console.error( '[IM] 标记已读失败', diff --git a/src/views/im/home/index.vue b/src/views/im/home/index.vue index 3326cb117..0930f479e 100644 --- a/src/views/im/home/index.vue +++ b/src/views/im/home/index.vue @@ -91,6 +91,7 @@ onMounted(async () => { groupRequestStore.loadGroupRequestList() ]) childRouteReady.value = true + groupStore.markAllGroupActiveCallsExpired() groupStore.markAllGroupMembersExpired() // 1.4 我管理的群下未处理加群申请红点:首登用 unhandled-list(服务端直接过滤未处理,语义精准、启动轻); // pullGroupRequests 只在重连 / 后续补偿时跑(见 useMessagePuller.pullStateEvents),不进首登主链路 diff --git a/src/views/im/home/store/conversationStore.ts b/src/views/im/home/store/conversationStore.ts index e500508e9..4d3967eae 100644 --- a/src/views/im/home/store/conversationStore.ts +++ b/src/views/im/home/store/conversationStore.ts @@ -28,8 +28,6 @@ import type { const PERSIST_DRAFT_DEBOUNCE_MS = 500 const pendingDraftConversations = new Set() -type LegacyConversationDO = ConversationDO & { readMessageId?: number } - /** 创建会话读位置记录 */ function createConversationRead( type: number, @@ -63,6 +61,7 @@ function toConversationDO(conversation: Conversation): ConversationDO { lastReceiptStatus: conversation.lastReceiptStatus, lastSelfSend: conversation.lastSelfSend, lastSenderDisplayName: conversation.lastSenderDisplayName, + readMessageId: conversation.readMessageId, deleted: conversation.deleted, top: conversation.top, silent: conversation.silent, @@ -74,10 +73,9 @@ function toConversationDO(conversation: Conversation): ConversationDO { } /** IndexedDB 记录转会话 */ -function fromConversationDO(conversation: LegacyConversationDO): Conversation { +function fromConversationDO(conversation: ConversationDO): Conversation { const { clientConversationId: _clientConversationId, - readMessageId: _readMessageId, ...rest } = conversation return rest @@ -194,32 +192,10 @@ export const useConversationStore = defineStore('imConversationStore', { 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) + const nextConversations = conversations.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) } @@ -325,6 +301,15 @@ export const useConversationStore = defineStore('imConversationStore', { 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 { if (!conversation.lastMessageId || conversation.lastMessageId > messageId) { @@ -378,6 +363,14 @@ export const useConversationStore = defineStore('imConversationStore', { } const current = this.conversationReads[clientConversationId] 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) { const next = { conversationType: record.conversationType, @@ -389,7 +382,6 @@ export const useConversationStore = defineStore('imConversationStore', { 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) { @@ -594,11 +586,7 @@ export const useConversationStore = defineStore('imConversationStore', { if (!conversation) { return } - // 1. 清理会话级未读状态 - conversation.unreadCount = 0 - conversation.atMe = false - conversation.atAll = false - // 2. 懒加载消息并保存会话摘要 + // 懒加载消息并保存会话摘要 void useMessageStore().ensureConversationMessageListLoaded(conversation) this.saveConversation(conversation) }, @@ -719,6 +707,19 @@ export const useConversationStore = defineStore('imConversationStore', { 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) + }, + // ==================== 最近转发 ==================== /** 推送最近转发会话 */ diff --git a/src/views/im/home/store/groupStore.ts b/src/views/im/home/store/groupStore.ts index 498349112..732c9cd30 100644 --- a/src/views/im/home/store/groupStore.ts +++ b/src/views/im/home/store/groupStore.ts @@ -51,6 +51,8 @@ const pendingSingleMemberKey = (userId: number, groupId: number, memberUserId: n /** 构建群 IndexedDB 记录 */ function buildGroupDO(group: Group): GroupDO { const { + activeCallExpired: _activeCallExpired, + activeCallLoaded: _activeCallLoaded, infoLoaded: _infoLoaded, members: _members, membersLoaded: _membersLoaded, @@ -233,11 +235,13 @@ export const useGroupStore = defineStore('imGroupStore', { this.groups = fresh.map((group) => { const existing = groupMap.get(group.id) if (!existing) { - return { ...group, infoLoaded: true } + return { ...group, activeCallExpired: true, infoLoaded: true } } return { ...group, infoLoaded: true, + activeCallExpired: existing.activeCallExpired, + activeCallLoaded: existing.activeCallLoaded, members: existing.members, memberCount: existing.memberCount ?? group.memberCount, membersLoaded: existing.membersLoaded, @@ -291,6 +295,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) { const group = this.getGroup(groupId) diff --git a/src/views/im/home/store/rtcStore.ts b/src/views/im/home/store/rtcStore.ts index 0cc5abf0e..fc8e8cfc7 100644 --- a/src/views/im/home/store/rtcStore.ts +++ b/src/views/im/home/store/rtcStore.ts @@ -257,6 +257,7 @@ export const useRtcStore = defineStore('imRtc', () => { if (!payload?.groupId) { return } + useGroupStore().markGroupActiveCallLoaded(payload.groupId) // 浅比较:room / mediaType / joinedUserIds / inviteeIds 都没变就跳过,避免下游 watcher 无意义重算 const existing = groupActiveCalls.value.get(payload.groupId) const nextParticipantsLoaded = participantsLoaded ?? !!existing?.participantsLoaded @@ -313,6 +314,7 @@ export const useRtcStore = defineStore('imRtc', () => { return } clearGroupCallCache(groupId) + useGroupStore().markGroupActiveCallLoaded(groupId) } /** 获取群当前活跃通话;用于胶囊条按 groupId 查询 */ diff --git a/src/views/im/home/store/websocketStore.ts b/src/views/im/home/store/websocketStore.ts index 1c7713032..ac2455763 100644 --- a/src/views/im/home/store/websocketStore.ts +++ b/src/views/im/home/store/websocketStore.ts @@ -425,7 +425,7 @@ export const useImWebSocketStore = defineStore('imWebSocketStore', { ) if (isActive) { // 窗口打开 = 已读:本端清未读 + 上报服务端读位置,避免读位置滞后 - const readCovered = conversationStore.isReadPositionCovered( + const readReported = conversationStore.isReportedReadPositionCovered( ImConversationType.CHANNEL, websocketMessage.channelId, websocketMessage.id @@ -433,10 +433,17 @@ export const useImWebSocketStore = defineStore('imWebSocketStore', { conversationStore.markConversationRead( ImConversationType.CHANNEL, websocketMessage.channelId, - readCovered ? undefined : websocketMessage.id + websocketMessage.id ) - if (!readCovered) { + if (!readReported) { apiReadChannelMessages(websocketMessage.channelId, websocketMessage.id) + .then(() => + conversationStore.markConversationReadReported( + ImConversationType.CHANNEL, + websocketMessage.channelId, + websocketMessage.id + ) + ) .catch((e) => { console.warn( '[IM WS] 频道自动已读上报失败', @@ -630,7 +637,7 @@ export const useImWebSocketStore = defineStore('imWebSocketStore', { if (isActive) { // 聊天窗口打开 = 实际看到了:本端清未读;私聊已读开启时再上报后端,让对方 UI 立刻切到"已读" // 已读位置直接用刚到的消息 id(这条就是当前会话最大 id) - const readCovered = conversationStore.isReadPositionCovered( + const readReported = conversationStore.isReportedReadPositionCovered( ImConversationType.PRIVATE, peerId, websocketMessage.id @@ -638,10 +645,17 @@ export const useImWebSocketStore = defineStore('imWebSocketStore', { conversationStore.markConversationRead( ImConversationType.PRIVATE, peerId, - readCovered ? undefined : websocketMessage.id + websocketMessage.id ) - if (MESSAGE_PRIVATE_READ_ENABLED && !readCovered) { + if (MESSAGE_PRIVATE_READ_ENABLED && !readReported) { apiReadPrivateMessages(peerId, websocketMessage.id) + .then(() => + conversationStore.markConversationReadReported( + ImConversationType.PRIVATE, + peerId, + websocketMessage.id + ) + ) .catch((e) => { console.warn( '[IM WS] 私聊自动已读上报失败', @@ -785,7 +799,7 @@ export const useImWebSocketStore = defineStore('imWebSocketStore', { conversationStore.activeConversation?.targetId === websocketMessage.groupId if (isActive) { // 群已读上报需要带 messageId(群消息以"读到第几条"的游标为准,区别于私聊只标 receiverId);群已读关闭时仅本地清零 - const readCovered = conversationStore.isReadPositionCovered( + const readReported = conversationStore.isReportedReadPositionCovered( ImConversationType.GROUP, websocketMessage.groupId, websocketMessage.id @@ -793,10 +807,17 @@ export const useImWebSocketStore = defineStore('imWebSocketStore', { conversationStore.markConversationRead( ImConversationType.GROUP, 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) + .then(() => + conversationStore.markConversationReadReported( + ImConversationType.GROUP, + websocketMessage.groupId, + websocketMessage.id + ) + ) .catch((e) => { console.warn( '[IM WS] 群聊自动已读上报失败', diff --git a/src/views/im/home/types/index.ts b/src/views/im/home/types/index.ts index 753380c50..3807b3a2a 100644 --- a/src/views/im/home/types/index.ts +++ b/src/views/im/home/types/index.ts @@ -110,6 +110,7 @@ export interface Conversation { silent?: boolean // 是否免打扰(不展示未读徽标 + 不响提示音) atMe?: boolean // 群聊:是否有人 @我 atAll?: boolean // 群聊:是否有人 @全体成员 + readMessageId?: number // 已上报到服务端的最大已读消息编号 draft?: { html: string // 输入框 HTML plain: string // 输入框纯文本 @@ -147,6 +148,7 @@ export interface Message { // ==================== IndexedDB 本地存储结构 ==================== +/** 会话 IndexedDB 存储结构 */ export interface ConversationDO extends Conversation { clientConversationId: string // `${type}:${targetId}` } @@ -158,16 +160,19 @@ export interface ConversationRead { updateTime?: number // 更新时间 } +/** 会话读位置 IndexedDB 存储结构 */ export interface ConversationReadDO extends ConversationRead { clientConversationId: string // `${conversationType}:${targetId}` } +/** 消息 IndexedDB 存储结构 */ export interface MessageDO extends Omit { messageKey: string // `${conversationType}:${id}` 或 `client:${clientMessageId}` conversationType: number // 会话类型,对齐 ImConversationType clientConversationId: string // ConversationDO.clientConversationId } +/** 设置 IndexedDB 存储结构 */ export interface SettingDO { key: string value: T @@ -195,12 +200,18 @@ export interface Group { groupRemark?: string // 群备注。从当前用户的 GroupMember 回填(当前用户对该群的自定义名) members?: GroupMember[] // 群成员缓存(按需懒加载) infoLoaded?: boolean // 群详情是否已加载,本轮会话内存标记,不持久化 + activeCallLoaded?: boolean // 群活跃通话是否已探测,本轮会话内存标记,不持久化 + activeCallExpired?: boolean // 群活跃通话探测是否已过期 membersLoaded?: boolean // members 是否"完整加载"——只有整群 loadGroupMemberList / fetchGroupMemberList 命中时为 true;fetchGroupMember 单成员补齐不置位,避免 fetchGroupMemberList(force=false) 命中缓存时误判整群已加载 membersExpired?: boolean // 群成员缓存是否已过期;重连 / 重新进入 IM 后只标记不删除,下次进入群会话再刷新 memberCount?: number // 成员总数 } -export type GroupDO = Omit +/** 群 IndexedDB 存储结构 */ +export type GroupDO = Omit< + Group, + 'activeCallExpired' | 'activeCallLoaded' | 'infoLoaded' | 'members' | 'membersLoaded' | 'membersExpired' +> // 群成员实体(前端内部结构) export interface GroupMember { @@ -219,6 +230,7 @@ export interface GroupMember { isOwner?: boolean // 是否群主(前端从 Group.ownerUserId 计算) } +/** 群成员 IndexedDB 存储结构 */ export type GroupMemberDO = GroupMember // ==================== 好友 ==================== @@ -242,6 +254,7 @@ export interface Friend { deleteTime?: number // 删除好友时间(毫秒时间戳;后端为 LocalDateTime 字符串,在 convertFriend 转换) } +/** 好友 IndexedDB 存储结构 */ export type FriendDO = Friend /** @@ -266,10 +279,13 @@ export interface FriendRequest { toAvatar?: string // 接收方头像 } +/** 好友申请 IndexedDB 存储结构 */ export type FriendRequestDO = FriendRequest +/** 加群申请 IndexedDB 存储结构 */ export type GroupRequestDO = import('@/api/im/group/request').ImGroupRequestRespVO +/** 频道 IndexedDB 存储结构 */ export type ChannelDO = import('@/api/im/manager/channel').ImManagerChannelVO // ==================== 用户名片 ====================