✨ feat(im): 初始化 useMessageSender.ts

pull/881/head
YunaiV 2026-04-26 15:56:24 +08:00
parent e573462cb7
commit 2c1ff59286
2 changed files with 227 additions and 2 deletions

View File

@ -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

View File

@ -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<TextMessage>({ 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<number>(
(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 }
}