From 2c1ff59286d1b74cdfe8a74271473c6f3fe26bab Mon Sep 17 00:00:00 2001 From: YunaiV Date: Sun, 26 Apr 2026 15:56:24 +0800 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20feat(im):=20=E5=88=9D=E5=A7=8B?= =?UTF-8?q?=E5=8C=96=20useMessageSender.ts?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../im/home/composables/useMessagePuller.ts | 4 +- .../im/home/composables/useMessageSender.ts | 225 ++++++++++++++++++ 2 files changed, 227 insertions(+), 2 deletions(-) create mode 100644 src/views/im/home/composables/useMessageSender.ts diff --git a/src/views/im/home/composables/useMessagePuller.ts b/src/views/im/home/composables/useMessagePuller.ts index 114e7d08b..c193457ed 100644 --- a/src/views/im/home/composables/useMessagePuller.ts +++ b/src/views/im/home/composables/useMessagePuller.ts @@ -125,8 +125,8 @@ export const useMessagePuller = () => { break } - // 逐条 dispatch:原消息走 insertMessage;RECALL 信号走 recallMessage 把同批内已 insert 的原消息翻成撤回提示。 - // 后端按 id 升序返回,且信号 id 一定 > 原消息 id(先翻 status 再插信号),所以原消息一定先到、recallMessage 找得到 + // 逐条 dispatch:原消息走 insertMessage;RECALL 信号走 recallMessage 把同批内已 insert 的原消息更新为撤回提示。 + // 后端按 id 升序返回,且信号 id 一定 > 原消息 id(先更新 status 再插信号),所以原消息一定先到、recallMessage 找得到 for (const raw of list) { if (isPrivate) { const message = raw as ImPrivateMessageRespVO diff --git a/src/views/im/home/composables/useMessageSender.ts b/src/views/im/home/composables/useMessageSender.ts new file mode 100644 index 000000000..b745101eb --- /dev/null +++ b/src/views/im/home/composables/useMessageSender.ts @@ -0,0 +1,225 @@ +import { useConversationStore } from '../store/conversationStore' +import { + sendPrivateMessage as apiSendPrivateMessage, + readPrivateMessages as apiReadPrivateMessages, + getPrivateMaxReadMessageId as apiGetPrivateMaxReadMessageId, + recallPrivateMessage as apiRecallPrivateMessage +} from '@/api/im/message/private' +import { + sendGroupMessage as apiSendGroupMessage, + readGroupMessages as apiReadGroupMessages, + recallGroupMessage as apiRecallGroupMessage +} from '@/api/im/message/group' +import { generateClientMessageId, serializeMessage, type TextMessage } from '../../utils/message' +import { ImMessageType, ImMessageStatus, ImConversationType } from '../../utils/constants' +import type { Message } from '../types' +import { useUserStore } from '@/store/modules/user' + +/** 非文本消息的扩展选项(通用) */ +interface SendExtOptions { + atUserIds?: number[] // 群聊 @ 的用户编号列表 + needReceipt?: boolean // 是否需要群回执(默认 false) + targetId?: number // 覆盖默认的 targetId +} + +/** + * 消息发送 / 撤回 / 已读 组合式逻辑 + * + * 设计要点: + * 1. 私聊 / 群聊接口签名对称,按 conversation.type 分支调度,差异在分支内部消化 + * 2. 发送走「乐观更新」:先 insertMessage 写入 SENDING 占位,请求成功 ackMessage 更新为 UNREAD,失败更新为 FAILED + * 3. 撤回不做乐观更新:服务端通过 WebSocket RECALL 事件回传,由 websocketStore 统一更新状态,避免网络失败后不可回退 + * 4. 已读上报:本端立刻清未读数;服务端回包成功后再做持久化 + */ +export const useMessageSender = () => { + const conversationStore = useConversationStore() + const userStore = useUserStore() + + /** 构造本地乐观消息对象(id=0 表示尚未拿到服务端消息 id) */ + const buildLocalMessage = (opts: { + clientMessageId: string + content: string + targetId: number + type: number + atUserIds?: number[] + }): Message => ({ + id: 0, + clientMessageId: opts.clientMessageId, + type: opts.type, + content: opts.content, + status: ImMessageStatus.SENDING, + sendTime: Date.now(), + senderId: Number(userStore.getUser?.id) || 0, + senderNickName: userStore.getUser?.nickname || '', + targetId: opts.targetId, + selfSend: true, + atUserIds: opts.atUserIds + }) + + /** + * 发送任意类型的消息(底层实现) + * 1. 文本、图片、文件、语音等都走这里 + * 2. type / content 由调用方构造 + */ + const sendRaw = async (type: number, content: string, options?: SendExtOptions) => { + // 1. 参数校验:必须有激活会话和目标 id + const conversation = conversationStore.activeConversation + if (!conversation) { + return + } + const realTarget = options?.targetId || conversation.targetId + if (!realTarget) { + return + } + + // 2. 构造本地消息并乐观插入会话;状态先置 SENDING,请求结果回来由 ackMessage 更新 + const clientMessageId = generateClientMessageId() + const message = buildLocalMessage({ + clientMessageId, + content, + targetId: realTarget, + type, + atUserIds: options?.atUserIds + }) + const conversationInfo = { + type: conversation.type, + targetId: realTarget, + name: conversation.name || String(realTarget), + avatar: conversation.avatar || '' + } + conversationStore.insertMessage(conversationInfo, message) + + // 3. 发送请求:按会话类型分发到不同接口;成功后 ackMessage 更新为 UNREAD,失败更新为 FAILED + try { + if (conversation.type === ImConversationType.PRIVATE) { + const data = await apiSendPrivateMessage({ + clientMessageId, + receiverId: realTarget, + type, + content + }) + conversationStore.ackMessage(conversation.type, realTarget, clientMessageId, { + id: data.id, + sendTime: new Date(data.sendTime).getTime(), + status: data.status + }) + } else if (conversation.type === ImConversationType.GROUP) { + const data = await apiSendGroupMessage({ + clientMessageId, + groupId: realTarget, + type, + content, + atUserIds: options?.atUserIds, + receipt: options?.needReceipt + }) + conversationStore.ackMessage(conversation.type, realTarget, clientMessageId, { + id: data.id, + sendTime: new Date(data.sendTime).getTime(), + status: data.status, + receiptStatus: data.receiptStatus, + readCount: data.readCount + }) + } + } catch (e) { + console.error('[IM] 消息发送失败', { type, realTarget, clientMessageId }, e) + conversationStore.ackMessage(conversation.type, realTarget, clientMessageId, { + status: ImMessageStatus.FAILED + }) + } + } + + /** 发送文本消息(最常用的快捷入口):MessageInput.vue 文本回车走这里 */ + const send = async (text: string, options?: SendExtOptions) => { + if (!text.trim()) { + return + } + await sendRaw(ImMessageType.TEXT, serializeMessage({ content: text }), options) + } + + /** + * 撤回某条消息 + * 1. 服务端会通过 WebSocket RECALL 事件回传,本端 UI 由 websocketStore 统一更新 + * 2. 此处不做乐观撤回,避免网络失败后状态不可回退 + */ + const recall = async (message: Message) => { + // 参数校验:本地占位消息(id=0)不能撤回 + if (!message.id) { + return + } + const conversation = conversationStore.activeConversation + if (!conversation) { + return + } + // 私聊 / 群聊接口签名一致,按会话类型分发 + const isPrivate = conversation.type === ImConversationType.PRIVATE + try { + await (isPrivate ? apiRecallPrivateMessage(message.id) : apiRecallGroupMessage(message.id)) + } catch (e) { + console.error('[IM] 撤回失败', { messageId: message.id, type: conversation.type }, e) + } + } + + /** + * 触发当前会话的已读上报(切会话 / 进入页面时调用) + * 1. 本端立刻清未读数;服务端回包成功后再做持久化 + * 2. 已读位置取会话内最大真实消息 id(id=0 的本地发送中消息跳过) + */ + const readActive = async () => { + const conversation = conversationStore.activeConversation + if (!conversation) { + return + } + // 本地标记已读:未读数清零 + 消息状态更新为 READ(UI 立刻响应) + conversationStore.markActiveAsRead() + const maxMessageId = conversationStore.getActiveMessages.reduce( + (max, m) => (m.id > max ? m.id : max), + 0 + ) + if (!maxMessageId) { + return + } + // 接口调用:私聊 / 群聊接口签名一致,按会话类型分发;失败仅记录日志,不回退本地已读状态 + const isPrivate = conversation.type === ImConversationType.PRIVATE + try { + await (isPrivate + ? apiReadPrivateMessages(conversation.targetId, maxMessageId) + : apiReadGroupMessages(conversation.targetId, maxMessageId)) + } catch (e) { + console.error( + '[IM] 标记已读失败', + { type: conversation.type, targetId: conversation.targetId, maxMessageId }, + e + ) + } + } + + /** + * 拉取「对方已读到我哪条消息」并补齐本地状态 + * + * 1. 弥补离线 / 多端期间错过的 RECEIPT 推送:进入私聊会话或断线重连后调一次, + * 把对方 maxReadId 同步到本地消息 status,避免对方明明读了、本端却仍显示未读 + * 2. 仅私聊使用:群聊已读位置在每条消息的 readCount / receiptStatus 字段,离线拉取自带回 + */ + const syncPrivateReadStatus = async (peerId: number) => { + if (!peerId) { + return + } + try { + // 拉取对方已读到的最大消息 id + const maxReadId = await apiGetPrivateMaxReadMessageId(peerId) + if (!maxReadId) { + return + } + // applyReadReceipt 内部把 ≤ maxReadId 的本端消息更新为 READ + conversationStore.applyReadReceipt({ + conversationType: ImConversationType.PRIVATE, + targetId: peerId, + privateReadMaxId: maxReadId + }) + } catch (e) { + console.warn('[IM] 拉取对方已读位置失败', { peerId }, e) + } + } + + return { send, sendRaw, recall, readActive, syncPrivateReadStatus } +}