From 8c1f17f5a69db1f85ae37977a8ba7fc3dfa93e97 Mon Sep 17 00:00:00 2001 From: YunaiV Date: Sun, 26 Apr 2026 09:46:09 +0800 Subject: [PATCH] =?UTF-8?q?=F0=9F=90=9B=20fix(im):=20=E7=A7=81=E8=81=8A?= =?UTF-8?q?=E5=B7=B2=E8=AF=BB=E6=B6=88=E8=B4=B9=E7=AB=AF=E5=8D=A1=20maxRea?= =?UTF-8?q?dId=20+=20=E4=B8=8A=E6=8A=A5=20messageId=20=E4=B8=8E=E5=90=8E?= =?UTF-8?q?=E7=AB=AF=E5=AF=B9=E9=BD=90=20handlePrivateReceipt=20=E6=94=B6?= =?UTF-8?q?=E5=88=B0=E5=AF=B9=E6=96=B9=20RECEIPT=20=E6=97=B6=E4=B8=A2?= =?UTF-8?q?=E5=BC=83=E4=BA=86=E5=90=8E=E7=AB=AF=E7=BC=96=E7=A0=81=E5=9C=A8?= =?UTF-8?q?=20DTO=20id=20=E5=AD=97=E6=AE=B5=20=E7=9A=84=20maxReadId?= =?UTF-8?q?=EF=BC=8CapplyReadReceipt=20=E6=8A=8A=E4=BC=9A=E8=AF=9D?= =?UTF-8?q?=E9=87=8C=E6=89=80=E6=9C=89=20selfSend=20=E6=9C=AA=E6=92=A4?= =?UTF-8?q?=E5=9B=9E=E6=B6=88=E6=81=AF=E4=B8=80=E5=88=80=E5=88=87=20?= =?UTF-8?q?=E6=A0=87=20READ=EF=BC=9B=E5=9B=9E=E6=89=A7=E5=9C=A8=E8=B7=AF?= =?UTF-8?q?=E4=B8=8A=E6=97=B6=E5=88=9A=E5=8F=91=E7=9A=84=E6=B6=88=E6=81=AF?= =?UTF-8?q?=E4=BC=9A=E8=A2=AB=E8=AF=AF=E6=A0=87=E5=B7=B2=E8=AF=BB=E3=80=82?= =?UTF-8?q?=20-=20applyReadReceipt=20=E7=9A=84=20markPrivateRead=20?= =?UTF-8?q?=E6=94=B9=E4=B8=BA=20privateReadMaxId=EF=BC=8C=E6=8C=89=20=20?= =?UTF-8?q?=20id=20<=3D=20maxReadId=20=E5=8D=A1=E8=BE=B9=E7=95=8C=EF=BC=8C?= =?UTF-8?q?=E8=B6=85=E8=BF=87=20maxReadId=20=E7=9A=84=E8=87=AA=E5=8F=91?= =?UTF-8?q?=E6=B6=88=E6=81=AF=E4=BF=9D=E7=95=99=E5=8E=9F=E7=8A=B6=E6=80=81?= =?UTF-8?q?=EF=BC=9B=20-=20handlePrivateReceipt=20=E9=80=8F=E4=BC=A0=20web?= =?UTF-8?q?socketMessage.id=20=E4=BD=9C=E4=B8=BA=20privateReadMaxId?= =?UTF-8?q?=EF=BC=9B=20-=20apiReadPrivateMessages=20=E5=A2=9E=E5=8A=A0=20m?= =?UTF-8?q?essageId=20=E5=BD=A2=E5=8F=82=EF=BC=8C=E4=B8=8E=E5=90=8E?= =?UTF-8?q?=E7=AB=AF=E6=96=B0=E6=8E=A5=E5=8F=A3=E5=AF=B9=E9=BD=90=EF=BC=9B?= =?UTF-8?q?=20-=20websocketStore=20=E7=A7=81=E8=81=8A=E8=87=AA=E5=8A=A8?= =?UTF-8?q?=E5=B7=B2=E8=AF=BB=E7=94=A8=E5=88=9A=E5=88=B0=E7=9A=84=E6=B6=88?= =?UTF-8?q?=E6=81=AF=20id=EF=BC=9BuseMessageSender.readActive=20=20=20?= =?UTF-8?q?=E6=8A=8A=E7=A7=81=E8=81=8A=20/=20=E7=BE=A4=E8=81=8A=E7=9A=84?= =?UTF-8?q?=20maxMessageId=20=E8=AE=A1=E7=AE=97=E5=90=88=E5=B9=B6=E5=88=B0?= =?UTF-8?q?=E8=B0=83=E7=94=A8=E5=89=8D=E3=80=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/api/im/message/private/index.ts | 8 ++++++-- src/views/im/home/store/conversationStore.ts | 15 +++++++++++---- src/views/im/home/store/websocketStore.ts | 14 +++++++++++--- 3 files changed, 28 insertions(+), 9 deletions(-) diff --git a/src/api/im/message/private/index.ts b/src/api/im/message/private/index.ts index bfbdbbcff..698e55d43 100644 --- a/src/api/im/message/private/index.ts +++ b/src/api/im/message/private/index.ts @@ -38,13 +38,17 @@ export const pullPrivateMessages = (params: { minId: number | string; size: numb } // 查询私聊历史消息 +// TODO @AI:历史消息,是不是通过这个接口? export const getPrivateMessageList = (params: ImPrivateMessageListReqVO) => { return request.get({ url: '/im/message/private/list', params }) } // 标记私聊消息已读 -export const readPrivateMessages = (receiverId: number | string) => { - return request.put({ url: '/im/message/private/read', params: { receiverId } }) +export const readPrivateMessages = (receiverId: number | string, messageId: number | string) => { + return request.put({ + url: '/im/message/private/read', + params: { receiverId, messageId } + }) } // 撤回私聊消息 diff --git a/src/views/im/home/store/conversationStore.ts b/src/views/im/home/store/conversationStore.ts index 2d172e292..f810386c0 100644 --- a/src/views/im/home/store/conversationStore.ts +++ b/src/views/im/home/store/conversationStore.ts @@ -512,8 +512,9 @@ export const useConversationStore = defineStore('imConversationStore', { applyReadReceipt(options: { conversationType: number targetId: number - // 私聊:把和该好友的「自己发送的」消息标为已读 - markPrivateRead?: boolean + // 私聊:把和该好友的「自己发送的、id <= privateReadMaxId 的」消息标为已读 + // 必须卡 maxId 边界:回执在路上时新发的消息不能被误标为已读 + privateReadMaxId?: number // 群聊:针对单条消息的回执刷新 groupMessageId?: number readCount?: number @@ -523,9 +524,15 @@ export const useConversationStore = defineStore('imConversationStore', { if (!conversation) { return } - if (options.conversationType === ImConversationType.PRIVATE && options.markPrivateRead) { + if (options.conversationType === ImConversationType.PRIVATE && options.privateReadMaxId) { + const maxReadId = options.privateReadMaxId conversation.messages.forEach((message) => { - if (message.selfSend && message.status !== ImMessageStatus.RECALL) { + if ( + message.selfSend && + message.id && + message.id <= maxReadId && + message.status !== ImMessageStatus.RECALL + ) { message.status = ImMessageStatus.READ } }) diff --git a/src/views/im/home/store/websocketStore.ts b/src/views/im/home/store/websocketStore.ts index 1e6c4bc06..e30ad386e 100644 --- a/src/views/im/home/store/websocketStore.ts +++ b/src/views/im/home/store/websocketStore.ts @@ -318,8 +318,9 @@ export const useImWebSocketStore = defineStore('imWebSocketStore', { conversationStore.activeConversation?.targetId === peerId if (isActive) { // 聊天窗口打开 = 实际看到了:本端清未读 + 上报后端,让对方 UI 立刻切到"已读" + // 已读位置直接用刚到的消息 id(这条就是当前会话最大 id) conversationStore.markActiveAsRead() - apiReadPrivateMessages(peerId).catch((e) => { + apiReadPrivateMessages(peerId, websocketMessage.id).catch((e) => { console.warn('[IM WS] 自动已读上报失败', e) }) } else if (!conversation?.muted) { @@ -342,13 +343,20 @@ export const useImWebSocketStore = defineStore('imWebSocketStore', { conversationStore.saveConversations() }, - /** 私聊 RECEIPT 事件:对方读了我的消息,把和对方会话里自己发的消息标为已读 */ + /** + * 私聊 RECEIPT 事件:对方读了我的消息,把和对方会话里自己发的消息标为已读 + * 后端将 maxReadId 编码在 DTO 的 id 字段(见 ImPrivateMessageDTO.ofReceipt), + * 这里据此卡边界,避免把"回执在路上时刚发的消息"误标为已读。 + */ handlePrivateReceipt(websocketMessage: ImPrivateMessageDTO) { + if (!websocketMessage.id) { + return + } const conversationStore = useConversationStore() conversationStore.applyReadReceipt({ conversationType: ImConversationType.PRIVATE, targetId: websocketMessage.senderId, - markPrivateRead: true + privateReadMaxId: websocketMessage.id }) },