From cf85fd4c8610945466d57ef5d011b4930584f57b Mon Sep 17 00:00:00 2001 From: YunaiV Date: Sun, 14 Jun 2026 09:34:16 +0800 Subject: [PATCH] =?UTF-8?q?feat(im):=20=E7=BB=9F=E4=B8=80=E6=B6=88?= =?UTF-8?q?=E6=81=AF=E8=AF=BB=E4=BD=8D=E7=BD=AE=E5=92=8C=E5=9B=9E=E6=89=A7?= =?UTF-8?q?=E7=8A=B6=E6=80=81=E6=A8=A1=E5=9E=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 im_conversation_read 会话读位置表,并补充消息存储推拉相关索引 - 群消息固化 receiver_user_ids 快照,按可见成员快照拉取和统计回执 - 统一消息 status 为 NORMAL/RECALL,新增私聊 receipt_status 并复用统一回执状态 - 前端改用 receiptStatus 展示私聊已读、群回执和频道已读态 - 补齐私聊、群聊、频道 WebSocket 已读同步和离线补偿逻辑 - 更新 IM 消息状态、回执状态字典和管理后台展示 - 调整相关单测和测试建表脚本 --- src/api/im/manager/message/group/index.ts | 2 +- src/api/im/manager/message/private/index.ts | 1 + src/api/im/message/channel/index.ts | 3 +- src/api/im/message/private/index.ts | 4 +- src/utils/dict.ts | 5 +-- .../im/home/composables/useMessagePuller.ts | 4 +- .../im/home/composables/useMessageSender.ts | 10 ++--- .../components/message/MessageItem.vue | 10 ++--- .../components/message/MessageReadStatus.vue | 10 ++--- src/views/im/home/store/groupStore.ts | 6 +-- src/views/im/home/store/messageStore.ts | 32 ++----------- src/views/im/home/store/websocketStore.ts | 45 ++++++++++--------- src/views/im/home/types/index.ts | 3 +- .../message/group/GroupMessageDetail.vue | 5 ++- src/views/im/manager/message/group/index.vue | 4 +- .../message/private/PrivateMessageDetail.vue | 5 ++- .../im/manager/message/private/index.vue | 13 +++++- src/views/im/utils/constants.ts | 11 +++-- 18 files changed, 87 insertions(+), 86 deletions(-) diff --git a/src/api/im/manager/message/group/index.ts b/src/api/im/manager/message/group/index.ts index d56ed13c5..bad5d7b44 100644 --- a/src/api/im/manager/message/group/index.ts +++ b/src/api/im/manager/message/group/index.ts @@ -13,7 +13,7 @@ export interface ImManagerGroupMessageVO { atUserIds?: number[] // 与 atUserIds 同长度;后端对找不到 / 已删除的成员返回 null,UI 用 `?.[idx] || userId` 回退到 userId 渲染 atUserNicknames?: (string | null)[] - receiptStatus?: number + receiptStatus: number sendTime: Date createTime: Date } diff --git a/src/api/im/manager/message/private/index.ts b/src/api/im/manager/message/private/index.ts index 3ccf70b79..4fb8d736f 100644 --- a/src/api/im/manager/message/private/index.ts +++ b/src/api/im/manager/message/private/index.ts @@ -10,6 +10,7 @@ export interface ImManagerPrivateMessageVO { type: number content: string status: number + receiptStatus: number sendTime: Date createTime: Date } diff --git a/src/api/im/message/channel/index.ts b/src/api/im/message/channel/index.ts index 0760a0670..4f769fc5e 100644 --- a/src/api/im/message/channel/index.ts +++ b/src/api/im/message/channel/index.ts @@ -7,8 +7,7 @@ export interface ImChannelMessageRespVO { materialId: number type: number content: string - /** 当前用户已读态;pull 时按 Redis 游标计算填充,多端同步使用 */ - status?: number + receiptStatus?: number sendTime: string } diff --git a/src/api/im/message/private/index.ts b/src/api/im/message/private/index.ts index 43ee78ad6..376347e0b 100644 --- a/src/api/im/message/private/index.ts +++ b/src/api/im/message/private/index.ts @@ -8,7 +8,8 @@ export interface ImPrivateMessageRespVO { receiverId: number // 接收人编号 type: number // 消息类型 content: string // 消息内容(JSON 格式) - status: number // 消息状态 + status: number // 消息状态(正常 / 已撤回) + receiptStatus?: number // 回执状态(不需要 / 待完成 / 已完成),对齐 ImMessageReceiptStatus sendTime: string // 发送时间 } @@ -18,6 +19,7 @@ export interface ImPrivateMessageSendReqVO { receiverId: number // 接收人编号 type: number // 消息类型 content: string // 消息内容(JSON 格式) + receipt?: boolean // 是否需要回执;不传后端默认 true(普通私聊用户消息) } // 私聊历史消息列表 Request VO diff --git a/src/utils/dict.ts b/src/utils/dict.ts index 855189a71..cd82beaa1 100644 --- a/src/utils/dict.ts +++ b/src/utils/dict.ts @@ -329,9 +329,8 @@ export enum DICT_TYPE { // ========== IM - 即时通讯模块 ========== IM_MESSAGE_TYPE = 'im_message_type', // IM 消息类型 - IM_PRIVATE_MESSAGE_STATUS = 'im_private_message_status', // IM 私聊消息状态:0=未读 / 2=已撤回 / 3=已读 - IM_GROUP_MESSAGE_STATUS = 'im_group_message_status', // IM 群聊消息状态:0=正常 / 2=已撤回 - IM_GROUP_MESSAGE_RECEIPT_STATUS = 'im_group_message_receipt_status', // IM 群消息回执状态 + IM_MESSAGE_STATUS = 'im_message_status', // IM 消息状态:0=正常 / 2=已撤回(私聊 / 群聊共用) + IM_MESSAGE_RECEIPT_STATUS = 'im_message_receipt_status', // IM 消息回执状态:0=不需要 / 1=待完成 / 2=已完成 IM_FRIEND_STATUS = 'im_friend_status', // IM 好友状态 IM_FRIEND_ADD_SOURCE = 'im_friend_add_source', // IM 好友添加来源 IM_FRIEND_REQUEST_HANDLE_RESULT = 'im_friend_request_handle_result', // IM 好友申请处理结果 diff --git a/src/views/im/home/composables/useMessagePuller.ts b/src/views/im/home/composables/useMessagePuller.ts index 7850ea622..5adf59eeb 100644 --- a/src/views/im/home/composables/useMessagePuller.ts +++ b/src/views/im/home/composables/useMessagePuller.ts @@ -76,6 +76,7 @@ export const useMessagePuller = () => { type: message.type, content: message.content, status: message.status, + receiptStatus: message.receiptStatus, sendTime: new Date(message.sendTime).getTime(), senderId: message.senderId, targetId: getPrivatePeerId(message), @@ -109,7 +110,8 @@ export const useMessagePuller = () => { clientMessageId: message.clientMessageId || generateClientMessageId(), type: message.type, content: message.content, - status: message.status ?? ImMessageStatus.UNREAD, + status: ImMessageStatus.NORMAL, // 频道无撤回,恒为正常 + receiptStatus: message.receiptStatus, // 频道已读态:DONE 已读 / PENDING 未读 sendTime: new Date(message.sendTime).getTime(), senderId: 0, // 系统下发,无发送人 targetId: message.channelId, // 会话归属到频道编号 diff --git a/src/views/im/home/composables/useMessageSender.ts b/src/views/im/home/composables/useMessageSender.ts index ff030b0a4..5c1acb398 100644 --- a/src/views/im/home/composables/useMessageSender.ts +++ b/src/views/im/home/composables/useMessageSender.ts @@ -53,7 +53,7 @@ interface SendExtOptions { * * 设计要点: * 1. 私聊 / 群聊接口签名对称,按 conversation.type 分支调度,差异在分支内部消化 - * 2. 发送走「乐观更新」:先 insertMessage 写入 SENDING 占位,请求成功 ackMessage 更新为 UNREAD,失败更新为 FAILED + * 2. 发送走「乐观更新」:先 insertMessage 写入 SENDING 占位,请求成功 ackMessage 更新为 NORMAL,失败更新为 FAILED * 3. 撤回不做乐观更新:服务端通过 WebSocket RECALL 事件回传,由 websocketStore 统一更新状态,避免网络失败后不可回退 * 4. 已读上报:本端立刻清未读数;服务端回包成功后再做持久化 */ @@ -134,7 +134,7 @@ export const useMessageSender = () => { messageStore.insertMessage(conversationInfo, message) } - // 3. 发送请求:按会话类型分发到不同接口;成功后 ackMessage 更新为 UNREAD,失败更新为 FAILED + // 3. 发送请求:按会话类型分发到不同接口;成功后 ackMessage 更新为 NORMAL,失败更新为 FAILED try { if (conversation.type === ImConversationType.PRIVATE) { const data = await apiSendPrivateMessage({ @@ -147,6 +147,7 @@ export const useMessageSender = () => { id: data.id, sendTime: new Date(data.sendTime).getTime(), status: data.status, + receiptStatus: data.receiptStatus, content: data.content }) } else if (conversation.type === ImConversationType.GROUP) { @@ -222,9 +223,8 @@ export const useMessageSender = () => { if (!conversation) { return } - // 本地标记已读:未读数清零 + 消息状态更新为 READ(UI 立刻响应) + // 本地标记已读:未读数清零(UI 立刻响应) conversationStore.markConversationRead(conversation.type, conversation.targetId) - messageStore.markConversationMessageListRead(conversation) const maxMessageId = messageStore .getMessages(getClientConversationId(conversation.type, conversation.targetId)) .reduce( @@ -286,7 +286,7 @@ export const useMessageSender = () => { if (!maxReadId) { return } - // applyMessageReadReceipt 内部把 ≤ maxReadId 的本端消息更新为 READ + // applyMessageReadReceipt 内部把 ≤ maxReadId 的本端消息回执更新为 DONE messageStore.applyMessageReadReceipt({ conversationType: ImConversationType.PRIVATE, targetId: peerId, diff --git a/src/views/im/home/pages/conversation/components/message/MessageItem.vue b/src/views/im/home/pages/conversation/components/message/MessageItem.vue index 2e7e429a0..6fdea3e32 100644 --- a/src/views/im/home/pages/conversation/components/message/MessageItem.vue +++ b/src/views/im/home/pages/conversation/components/message/MessageItem.vue @@ -149,7 +149,7 @@ v-else-if="privateReadLabel" class="text-12px whitespace-nowrap" :class=" - message.status === ImMessageStatus.READ + message.receiptStatus === ImMessageReceiptStatus.DONE ? 'text-[#409eff]' : 'text-[var(--el-text-color-secondary)]' " @@ -202,7 +202,7 @@ import { ImForwardMode, ImMessageType, ImMessageStatus, - ImGroupReceiptStatus, + ImMessageReceiptStatus, ImConversationType, ImFriendAddSource, ImGroupMemberRole, @@ -527,10 +527,10 @@ const privateReadLabel = computed(() => { if (conversationStore.activeConversation?.type !== ImConversationType.PRIVATE) { return '' } - if (props.message.status === ImMessageStatus.READ) { + if (props.message.receiptStatus === ImMessageReceiptStatus.DONE) { return '已读' } - if (props.message.status === ImMessageStatus.UNREAD) { + if (props.message.receiptStatus === ImMessageReceiptStatus.PENDING) { return '未读' } return '' @@ -551,7 +551,7 @@ const showGroupReadStatus = computed(() => { if (status === undefined || status === null) { return false } - return status !== ImGroupReceiptStatus.NO_RECEIPT + return status !== ImMessageReceiptStatus.NO_RECEIPT }) /** 当前群成员(供 MessageReadStatus 计算未读名单;未加载完时兜底空数组不渲染) */ diff --git a/src/views/im/home/pages/conversation/components/message/MessageReadStatus.vue b/src/views/im/home/pages/conversation/components/message/MessageReadStatus.vue index 1127627d1..0b44ace09 100644 --- a/src/views/im/home/pages/conversation/components/message/MessageReadStatus.vue +++ b/src/views/im/home/pages/conversation/components/message/MessageReadStatus.vue @@ -57,7 +57,7 @@ import { computed, ref } from 'vue' import { getGroupReadUsers as apiGetGroupReadUsers } from '@/api/im/message/group' import { CommonStatusEnum } from '@/utils/constants' -import { ImConversationType, ImGroupReceiptStatus } from '../../../../../utils/constants' +import { ImConversationType, ImMessageReceiptStatus } from '../../../../../utils/constants' import type { Message } from '../../../../types' import { useMessageStore } from '../../../../store/messageStore' import GroupMember, { type GroupMemberLite } from '../../../../components/group/GroupMember.vue' @@ -88,7 +88,7 @@ const readUserIds = ref([]) * - 其他(readCount = 0 或 undefined,且未到 DONE):显示"未读" */ const label = computed(() => { - if (props.message.receiptStatus === ImGroupReceiptStatus.DONE) { + if (props.message.receiptStatus === ImMessageReceiptStatus.DONE) { return '全部已读' } const readCount = props.message.readCount || 0 @@ -147,15 +147,15 @@ async function loadReadUsers() { }) readUserIds.value = userIds || [] const readCount = readUserIds.value.length - // 全可见成员都已读 → flip 到 DONE,让外面 label 直接命中"全部已读"分支; - // 否则只更新 readCount,receiptStatus 维持不变(PENDING / READING) + // 全可见成员都已读 → 更新为 DONE,让外面 label 直接命中「全部已读」分支; + // 否则只更新 readCount,receiptStatus 维持不变(PENDING) const allRead = readCount > 0 && readCount >= visibleMembers.value.length messageStore.applyMessageReadReceipt({ conversationType: ImConversationType.GROUP, targetId: props.groupId, groupMessageId: props.message.id, readCount, - receiptStatus: allRead ? ImGroupReceiptStatus.DONE : undefined + receiptStatus: allRead ? ImMessageReceiptStatus.DONE : undefined }) } catch (error) { console.error('[IM] 拉取群已读列表失败:', error) diff --git a/src/views/im/home/store/groupStore.ts b/src/views/im/home/store/groupStore.ts index 6795ec033..6881cff88 100644 --- a/src/views/im/home/store/groupStore.ts +++ b/src/views/im/home/store/groupStore.ts @@ -391,9 +391,9 @@ export const useGroupStore = defineStore('imGroupStore', { this.saveGroup(merged) }, - /** 本地移除(由 WebSocket GROUP_DEL 事件触发) */ + /** 本地移除群缓存和群会话;群解散(GROUP_DEL)、退群、被踢都复用 */ removeGroup(id: number) { - // 群解散是硬删(区别于好友删除的软删保留记录);级联清群聊会话避免列表里留死群 + // 本地硬删(区别于好友删除的软删保留记录);级联清群聊会话避免列表里留死群 this.groups = this.groups.filter((g) => g.id !== id) const conversationStore = useConversationStore() conversationStore.removeGroupConversation(id) @@ -735,7 +735,7 @@ export const useGroupStore = defineStore('imGroupStore', { senderId: message.senderId, type: message.type, content: message.content, - status: ImMessageStatus.UNREAD, + status: ImMessageStatus.NORMAL, sendTime: new Date(message.sendTime).getTime(), targetId: message.groupId || groupId, selfSend: message.senderId === getCurrentUserId(), diff --git a/src/views/im/home/store/messageStore.ts b/src/views/im/home/store/messageStore.ts index 057c9ef39..90597c33e 100644 --- a/src/views/im/home/store/messageStore.ts +++ b/src/views/im/home/store/messageStore.ts @@ -4,6 +4,7 @@ import { store } from '@/store' import { IM_AT_ALL_USER_ID, ImConversationType, + ImMessageReceiptStatus, ImMessageStatus, ImMessageType, isGroupNotification, @@ -194,8 +195,7 @@ function syncConversationAtFlags(conversation: Conversation, message: Message): message.selfSend || conversation.type !== ImConversationType.GROUP || !message.atUserIds || - message.atUserIds.length === 0 || - message.status === ImMessageStatus.READ + message.atUserIds.length === 0 ) { return } @@ -518,7 +518,6 @@ export const useMessageStore = defineStore('imMessageStore', { !message.selfSend && !isActive && isNormalMessage(message.type) && - message.status !== ImMessageStatus.READ && message.status !== ImMessageStatus.RECALL ) { conversation.unreadCount++ @@ -616,7 +615,6 @@ export const useMessageStore = defineStore('imMessageStore', { !message.selfSend && !isActive && isNormalMessage(message.type) && - message.status !== ImMessageStatus.READ && message.status !== ImMessageStatus.RECALL ) { conversation.unreadCount++ @@ -779,9 +777,9 @@ export const useMessageStore = defineStore('imMessageStore', { message.selfSend && message.id && message.id <= options.privateReadMaxId! && - message.status !== ImMessageStatus.RECALL + message.receiptStatus === ImMessageReceiptStatus.PENDING ) { - message.status = ImMessageStatus.READ + message.receiptStatus = ImMessageReceiptStatus.DONE changed.push(message) } }) @@ -871,28 +869,6 @@ export const useMessageStore = defineStore('imMessageStore', { conversationStore.saveConversation(conversation) }, - /** 当前会话标记已读 */ - markConversationMessageListRead(conversation: Conversation) { - const messages = this.getMessageList(conversation.type, conversation.targetId) - const changed: Message[] = [] - messages.forEach((message) => { - if (!message.selfSend && message.status === ImMessageStatus.UNREAD) { - message.status = ImMessageStatus.READ - changed.push(message) - } - }) - if (changed.length === 0) { - return - } - void getDb() - .transaction(['messages'], 'readwrite', async (tx) => { - for (const message of changed) { - await this.saveMessageRecord(message, conversation.type, tx) - } - }) - .catch((e) => console.warn('[IM messageStore] 已读状态写入失败', e)) - }, - /** 删除会话全部消息 */ deleteConversationMessageList(conversationType: number, targetId: number) { // 1. 清理内存消息和媒体资源 diff --git a/src/views/im/home/store/websocketStore.ts b/src/views/im/home/store/websocketStore.ts index 272dcfb21..513bf1daa 100644 --- a/src/views/im/home/store/websocketStore.ts +++ b/src/views/im/home/store/websocketStore.ts @@ -5,6 +5,7 @@ import { getCurrentUserId, getRefreshToken } from '@/utils/auth' import { ImWebSocketMessageType, ImMessageStatus, + ImMessageReceiptStatus, ImMessageType, ImConversationType, ImRtcCallMediaType, @@ -41,6 +42,7 @@ import { } from './rtcStore' import { readPrivateMessages as apiReadPrivateMessages } from '@/api/im/message/private' import { readGroupMessages as apiReadGroupMessages } from '@/api/im/message/group' +import { readChannelMessages as apiReadChannelMessages } from '@/api/im/message/channel' import type { ImChannelMessageRespVO } from '@/api/im/message/channel' import { buildChannelConversationStub } from '../../utils/channel' import type { @@ -106,6 +108,7 @@ const convertPrivateMessage = ( type: websocketMessage.type, content: websocketMessage.content, status: websocketMessage.status, + receiptStatus: websocketMessage.receiptStatus, sendTime: new Date(websocketMessage.sendTime).getTime(), senderId: websocketMessage.senderId, targetId: getPrivateMessagePeerId(websocketMessage, currentUserId), @@ -334,19 +337,6 @@ export const useImWebSocketStore = defineStore('imWebSocketStore', { typeof websocketMessage.sendTime === 'number' ? websocketMessage.sendTime : new Date(websocketMessage.sendTime).getTime() - messageStore.insertMessage(buildChannelConversationStub(websocketMessage.channelId), { - id: websocketMessage.id, - clientMessageId: '', - type: websocketMessage.type, - content: websocketMessage.content, - status: ImMessageStatus.UNREAD, - sendTime: sendTimeMs, - senderId: 0, - targetId: websocketMessage.channelId, - selfSend: false, - materialId: websocketMessage.materialId - }) - // 非当前会话 + 未免打扰:响一下提示音 const conversation = conversationStore.getConversation( ImConversationType.CHANNEL, websocketMessage.channelId @@ -354,7 +344,28 @@ export const useImWebSocketStore = defineStore('imWebSocketStore', { const isActive = conversationStore.activeConversation?.type === ImConversationType.CHANNEL && conversationStore.activeConversation?.targetId === websocketMessage.channelId - if (!isActive && !conversation?.silent && isNormalMessage(websocketMessage.type)) { + // 频道单向订阅,receiptStatus 表达「我是否已读这条」:会话打开即已读 DONE,否则 PENDING(与 pull 口径一致) + messageStore.insertMessage(buildChannelConversationStub(websocketMessage.channelId), { + id: websocketMessage.id, + clientMessageId: '', + type: websocketMessage.type, + content: websocketMessage.content, + status: ImMessageStatus.NORMAL, + receiptStatus: isActive ? ImMessageReceiptStatus.DONE : ImMessageReceiptStatus.PENDING, + sendTime: sendTimeMs, + senderId: 0, + targetId: websocketMessage.channelId, + selfSend: false, + materialId: websocketMessage.materialId + }) + if (isActive) { + // 窗口打开 = 已读:本端清未读 + 上报服务端读位置,避免读位置滞后 + conversationStore.markConversationRead(ImConversationType.CHANNEL, websocketMessage.channelId) + apiReadChannelMessages(websocketMessage.channelId, websocketMessage.id).catch((e) => { + console.warn('[IM WS] 频道自动已读上报失败', e) + }) + } else if (!conversation?.silent && isNormalMessage(websocketMessage.type)) { + // 非当前会话且未免打扰:响一下提示音 playAudioTip() } }, @@ -543,9 +554,6 @@ export const useImWebSocketStore = defineStore('imWebSocketStore', { // 聊天窗口打开 = 实际看到了:本端清未读;私聊已读开启时再上报后端,让对方 UI 立刻切到"已读" // 已读位置直接用刚到的消息 id(这条就是当前会话最大 id) conversationStore.markConversationRead(ImConversationType.PRIVATE, peerId) - if (conversation) { - useMessageStore().markConversationMessageListRead(conversation) - } if (MESSAGE_PRIVATE_READ_ENABLED) { apiReadPrivateMessages(peerId, websocketMessage.id).catch((e) => { console.warn('[IM WS] 自动已读上报失败', e) @@ -673,9 +681,6 @@ export const useImWebSocketStore = defineStore('imWebSocketStore', { ImConversationType.GROUP, websocketMessage.groupId ) - if (conversation) { - useMessageStore().markConversationMessageListRead(conversation) - } if (MESSAGE_GROUP_READ_ENABLED) { apiReadGroupMessages(websocketMessage.groupId, websocketMessage.id).catch((e) => { console.warn('[IM WS] 自动已读上报失败', e) diff --git a/src/views/im/home/types/index.ts b/src/views/im/home/types/index.ts index ebb75007a..8c990da9d 100644 --- a/src/views/im/home/types/index.ts +++ b/src/views/im/home/types/index.ts @@ -15,6 +15,7 @@ export interface ImPrivateMessageDTO { type: number // 消息类型 content: string // 消息内容 status: number // 消息状态 + receiptStatus?: number // 回执状态(不需要 / 待完成 / 已完成) sendTime: string // 发送时间 } @@ -93,7 +94,7 @@ export interface Message { senderId: number // 发送人编号 atUserIds?: number[] // 群 @ 目标用户列表 receiverUserIds?: number[] // 群定向接收用户列表 - receiptStatus?: number // 群回执状态,对齐 ImGroupReceiptStatus(仅群消息) + receiptStatus?: number // 回执状态,对齐 ImMessageReceiptStatus(私聊 / 群 / 频道通用) readCount?: number // 群回执已读人数(仅群消息) materialId?: number // 关联频道素材编号(仅频道消息 type=MATERIAL) diff --git a/src/views/im/manager/message/group/GroupMessageDetail.vue b/src/views/im/manager/message/group/GroupMessageDetail.vue index 0a7078b92..792f689fb 100644 --- a/src/views/im/manager/message/group/GroupMessageDetail.vue +++ b/src/views/im/manager/message/group/GroupMessageDetail.vue @@ -13,7 +13,10 @@ - + + + +