🐛 fix(im): 群聊离线拉取看不到撤回提示,pull 路径接入 recallMessage

pullByType 之前对 RECALL 信号一律 skip、只靠原消息 status=RECALL 走 OR 兜底渲染。
当 pull 的 minId 卡在原消息处、回拉只返回信号时,本地缓存里的老消息没人翻成
RECALL,会一直停在原态——配合后端群聊 mapper 过滤掉 status=RECALL 的原消息,群聊
离线撤回完全不可见。

改成 pull / WS 走同一套 dispatch:
- pullByType 信号转 conversationStore.recallMessage(),跟 WS 路径一致
- recallMessage 把 parseRecallMessageId 收敛进内部,第 3 个参数从
  messageId: number 改成 recallSignalContent: string,4 个调用点都缩成一行
- MessageItem.isRecall 只判 type=RECALL,去掉 status=RECALL OR 分支
  (conversationStore 里跳未读 / 跳已读那两处对 status 的判断是业务逻辑保留)
im
YunaiV 2026-04-26 00:28:43 +08:00
parent 66514fc597
commit a35698fc07
6 changed files with 295 additions and 70 deletions

View File

@ -0,0 +1,209 @@
import { watch } from 'vue'
import { useConversationStore } from '../store/conversationStore'
import { useImWebSocketStore } from '../store/websocketStore'
import {
pullPrivateMessages as apiPullPrivateMessages,
type ImPrivateMessageRespVO
} from '@/api/im/message/private'
import {
pullGroupMessages as apiPullGroupMessages,
type ImGroupMessageRespVO
} from '@/api/im/message/group'
import {
ImConversationType,
ImMessageType,
PRIVATE_MESSAGE_PULL_SIZE,
GROUP_MESSAGE_PULL_SIZE
} from '../../utils/constants'
import { useUserStore } from '@/store/modules/user'
import type { Message } from '../types'
/**
* 线
*
*
* 1. + 使 `minId` privateMessageMaxId / groupMessageMaxId
* 2. size minId
* 3. conversationStore.loading=true
* - conversationStore localStorage
* - websocketStore WS
* 4. WebSocket
*/
export const useMessagePuller = () => {
const conversationStore = useConversationStore()
const wsStore = useImWebSocketStore()
const userStore = useUserStore()
const currentUserId = Number(userStore.getUser?.id) || 0
/** 服务端私聊消息 -> 本地 Message */
const convertPrivateMessage = (message: ImPrivateMessageRespVO): Message => {
return {
id: message.id,
clientMessageId: message.clientMessageId || '',
type: message.type,
content: message.content,
status: message.status,
sendTime: new Date(message.sendTime).getTime(),
senderId: message.senderId,
senderNickName: '',
targetId: message.receiverId,
selfSend: message.senderId === currentUserId
}
}
/** 服务端群聊消息 -> 本地 Message */
const convertGroupMessage = (message: ImGroupMessageRespVO): Message => {
return {
id: message.id,
clientMessageId: message.clientMessageId || '',
type: message.type,
content: message.content,
status: message.status,
sendTime: new Date(message.sendTime).getTime(),
senderId: message.senderId,
senderNickName: '',
targetId: message.groupId,
selfSend: message.senderId === currentUserId,
atUserIds: message.atUserIds || [],
receiverUserIds: message.receiverUserIds || [],
receiptStatus: message.receiptStatus,
readCount: message.readCount
}
}
/** 私聊:会话归属到对端 userId */
const convertPrivateConversation = (message: ImPrivateMessageRespVO) => {
const targetId = message.senderId === currentUserId ? message.receiverId : message.senderId
return {
type: ImConversationType.PRIVATE,
targetId,
name: String(targetId),
avatar: ''
}
}
/** 群聊:会话归属到 groupId */
const convertGroupConversation = (message: ImGroupMessageRespVO) => {
return {
type: ImConversationType.GROUP,
targetId: message.groupId,
name: String(message.groupId),
avatar: ''
}
}
/** 循环拉取指定会话类型的消息:以列表最后一条 id 作为下次 minId直到接口返回空列表 */
const pullByType = async (conversationType: number, startMinId: number) => {
// 私聊 / 群聊各自一套接口和分页大小,按 isPrivate 在循环内分支调度
let minId = startMinId || 0
const isPrivate = conversationType === ImConversationType.PRIVATE
const size = isPrivate ? PRIVATE_MESSAGE_PULL_SIZE : GROUP_MESSAGE_PULL_SIZE
while (true) {
const list = isPrivate
? await apiPullPrivateMessages({ minId, size })
: await apiPullGroupMessages({ minId, size })
if (!list || list.length === 0) {
break
}
// 逐条 dispatch原消息走 insertMessageRECALL 信号走 recallMessage 把同批内已 insert 的原消息翻成撤回提示。
// 后端按 id 升序返回,且信号 id 一定 > 原消息 id先翻 status 再插信号所以原消息一定先到、recallMessage 找得到
for (const raw of list) {
if (isPrivate) {
const message = raw as ImPrivateMessageRespVO
if (message.type === ImMessageType.RECALL) {
conversationStore.recallMessage(
ImConversationType.PRIVATE,
message.senderId === currentUserId ? message.receiverId : message.senderId,
message.content,
'',
message.senderId === currentUserId
)
continue
}
conversationStore.insertMessage(
convertPrivateConversation(message),
convertPrivateMessage(message)
)
} else {
const message = raw as ImGroupMessageRespVO
if (message.type === ImMessageType.RECALL) {
conversationStore.recallMessage(
ImConversationType.GROUP,
message.groupId,
message.content,
'',
message.senderId === currentUserId
)
continue
}
conversationStore.insertMessage(
convertGroupConversation(message),
convertGroupMessage(message)
)
}
}
// 游标推进到本批最后一条 id下一轮从此处续翻
const lastId = list[list.length - 1].id
if (lastId != null) {
minId = lastId
}
}
}
/** 同一时刻只允许一次 pullIndex.vue 的手动调用与重连 watch 触发可能并发,共用同一个 promise 即可去重 */
let pullPromise: Promise<void> | null = null
/** 执行一次全量增量拉取(重入安全:进行中再次调用复用同一个 promise */
const pullOnce = (): Promise<void> => {
if (!currentUserId) {
return Promise.resolve()
}
if (pullPromise) {
return pullPromise
}
pullPromise = (async () => {
conversationStore.loading = true
try {
// 并发拉取私聊 + 群聊,降低初始加载耗时
await Promise.all([
pullByType(ImConversationType.PRIVATE, conversationStore.privateMessageMaxId),
pullByType(ImConversationType.GROUP, conversationStore.groupMessageMaxId)
])
// 回放 WebSocket 在 loading 期间收到的缓冲消息
const buffered = wsStore.flushBuffer()
for (const item of buffered) {
if (item.conversationType === ImConversationType.PRIVATE) {
wsStore.handlePrivateMessage(item.payload)
} else {
wsStore.handleGroupMessage(item.payload)
}
}
} catch (e) {
console.error('[IM] 拉取离线消息失败:', e)
} finally {
conversationStore.loading = false
conversationStore.sortConversations()
pullPromise = null
}
})()
return pullPromise
}
/**
* WS minId
* Index.vue pullOnce isConnected falsetrue
*/
watch(
() => wsStore.isConnected,
(isConnected) => {
if (isConnected) {
void pullOnce()
}
}
)
return { pullOnce }
}

View File

@ -13,6 +13,7 @@ import {
buildRecallTip,
generateClientMessageId,
parseMessage,
parseRecallMessageId,
type TextMessage
} from '../../utils/message'
import type { Conversation, ConversationStoreMeta, Message } from '../types'
@ -475,17 +476,18 @@ export const useConversationStore = defineStore('imConversationStore', {
this.saveConversations(conversation)
},
/**
* type RECALL
* RECALL messageId
*/
/** 撤回消息:解析撤回信号 content`{"messageId": xxx}`),找到原消息翻成 RECALL 态 + 刷新会话摘要 */
recallMessage(
conversationType: number,
targetId: number,
messageId: number,
recallSignalContent: string,
senderNickName: string,
selfSend: boolean
) {
const messageId = parseRecallMessageId(recallSignalContent)
if (messageId <= 0) {
return
}
const conversation = this.getConversation(conversationType, targetId)
if (!conversation) {
return

View File

@ -52,7 +52,7 @@ export const useFriendStore = defineStore('imFriendStore', {
return
}
const list = await apiGetMyFriendList()
this.friends = (list || []).map(toFriend)
this.friends = (list || []).map(convertFriend)
this.loaded = true
// 同步 conversationStore 私聊会话的展示名 / 头像 / 免打扰
const conversationStore = useConversationStore()
@ -72,7 +72,7 @@ export const useFriendStore = defineStore('imFriendStore', {
if (!data) {
return
}
this.upsertFriend(toFriend(data))
this.upsertFriend(convertFriend(data))
} catch (e) {
console.warn('[IM friendStore] loadFriendInfo 失败', e)
}
@ -154,7 +154,7 @@ export const useFriendStore = defineStore('imFriendStore', {
}
})
function toFriend(vo: ImFriendRespVO): Friend {
function convertFriend(vo: ImFriendRespVO): Friend {
return {
id: vo.id,
friendUserId: vo.friendUserId,

View File

@ -44,7 +44,7 @@ export const useGroupStore = defineStore('imGroupStore', {
}
// 拉取当前登录用户加入的所有群(不带成员;成员按需再走 loadGroupMembers
const list = await apiGetMyGroupList()
this.groups = (list || []).map(toGroup)
this.groups = (list || []).map(convertGroup)
this.loaded = true
const conversationStore = useConversationStore()
for (const g of this.groups) {
@ -63,7 +63,7 @@ export const useGroupStore = defineStore('imGroupStore', {
if (!data) {
return
}
this.upsertGroup(toGroup(data))
this.upsertGroup(convertGroup(data))
} catch (e) {
console.warn('[IM groupStore] loadGroupInfo 失败', e)
}
@ -79,7 +79,7 @@ export const useGroupStore = defineStore('imGroupStore', {
// 拉取该群所有成员(聚合自 AdminUser含 nickname / avatar / displayUserName
const list = await apiGetGroupMemberList(groupId)
const members = (list || []).map((member) => toGroupMember(member, groupId))
const members = (list || []).map((member) => convertGroupMember(member, groupId))
// 成员列表可能在群列表之前触发,此时需要占位一个 group
if (!group) {
this.upsertGroup({
@ -138,7 +138,7 @@ export const useGroupStore = defineStore('imGroupStore', {
}
})
function toGroup(vo: ImGroupRespVO): Group {
function convertGroup(vo: ImGroupRespVO): Group {
return {
id: vo.id,
name: vo.name,
@ -148,7 +148,7 @@ function toGroup(vo: ImGroupRespVO): Group {
}
}
function toGroupMember(member: ImGroupMemberRespVO, groupId: number): GroupMember {
function convertGroupMember(member: ImGroupMemberRespVO, groupId: number): GroupMember {
return {
id: member.id,
userId: member.userId,

View File

@ -4,7 +4,7 @@ import { getRefreshToken } from '@/utils/auth'
import { useUserStore } from '@/store/modules/user'
import { ImWebSocketMessageType, ImMessageType, ImConversationType } from '../../utils/constants'
import { parseRecallMessageId, playAudioTip } from '../../utils/message'
import { playAudioTip } from '../../utils/message'
import { useConversationStore } from './conversationStore'
import { useFriendStore } from './friendStore'
import { useGroupStore } from './groupStore'
@ -17,6 +17,44 @@ import type {
Message
} from '../types'
/** WebSocket 私聊 DTO -> 前端 MessagesendTime 转毫秒senderNickName 由调用方按好友信息补 */
const convertPrivateMessage = (
websocketMessage: ImPrivateMessageDTO,
currentUserId: number,
senderNickName: string
): Message => ({
id: websocketMessage.id,
clientMessageId: websocketMessage.clientMessageId,
type: websocketMessage.type,
content: websocketMessage.content,
status: websocketMessage.status,
sendTime: new Date(websocketMessage.sendTime).getTime(),
senderId: websocketMessage.senderId,
senderNickName,
targetId: websocketMessage.receiverId,
selfSend: websocketMessage.senderId === currentUserId
})
/** WebSocket 群聊 DTO -> 前端 Message群消息额外带 atUserIds / receiverUserIds给 @ 标记和回执用 */
const convertGroupMessage = (
websocketMessage: ImGroupMessageDTO,
currentUserId: number,
senderNickName: string
): Message => ({
id: websocketMessage.id,
clientMessageId: websocketMessage.clientMessageId,
type: websocketMessage.type,
content: websocketMessage.content,
status: websocketMessage.status,
sendTime: new Date(websocketMessage.sendTime).getTime(),
senderId: websocketMessage.senderId,
senderNickName,
targetId: websocketMessage.groupId,
selfSend: websocketMessage.senderId === currentUserId,
atUserIds: websocketMessage.atUserIds || [],
receiverUserIds: websocketMessage.receiverUserIds || []
})
/**
* IM WebSocket Store
*
@ -38,8 +76,8 @@ export const useImWebSocketStore = defineStore('imWebSocketStore', {
reconnectTimer: null as ReturnType<typeof setTimeout> | null,
heartbeatTimer: null as ReturnType<typeof setInterval> | null,
messageBuffer: [] as Array<
| { kind: 'private'; payload: ImPrivateMessageDTO }
| { kind: 'group'; payload: ImGroupMessageDTO }
| { conversationType: typeof ImConversationType.PRIVATE; payload: ImPrivateMessageDTO }
| { conversationType: typeof ImConversationType.GROUP; payload: ImGroupMessageDTO }
> // 初始化加载期内先把普通消息丢进缓冲区pull 完成后再一次性回放
}),
@ -228,7 +266,10 @@ export const useImWebSocketStore = defineStore('imWebSocketStore', {
const conversationStore = useConversationStore()
// 1. 离线加载期间先缓冲,等 pull 完成后再统一回放,避免重复或顺序错乱
if (conversationStore.loading) {
this.messageBuffer.push({ kind: 'private', payload: websocketMessage })
this.messageBuffer.push({
conversationType: ImConversationType.PRIVATE,
payload: websocketMessage
})
return
}
@ -247,32 +288,18 @@ export const useImWebSocketStore = defineStore('imWebSocketStore', {
// 3. 后端撤回:下发一条 RECALL 消息content 为 `{"messageId": xxx}`(对齐 ImMessageTypeEnum.RECALL → RecallMessage
// 这里拦截下来改走 recallMessage把原消息翻转为 RECALL 态),不让它作为新消息进列表
if (websocketMessage.type === ImMessageType.RECALL) {
const recallMessageId = parseRecallMessageId(websocketMessage.content)
if (recallMessageId) {
conversationStore.recallMessage(
ImConversationType.PRIVATE,
peerId,
recallMessageId,
friend?.nickname || '',
selfSend
)
return
}
conversationStore.recallMessage(
ImConversationType.PRIVATE,
peerId,
websocketMessage.content,
friend?.nickname || '',
selfSend
)
return
}
// 4. 后端 DTO → 前端 MessagesendTime 转毫秒selfSend / senderNickName 是前端补的
const message: Message = {
id: websocketMessage.id,
clientMessageId: websocketMessage.clientMessageId,
type: websocketMessage.type,
content: websocketMessage.content,
status: websocketMessage.status,
sendTime: new Date(websocketMessage.sendTime).getTime(),
senderId: websocketMessage.senderId,
senderNickName: friend?.nickname || '',
targetId: websocketMessage.receiverId,
selfSend
}
// 4. 后端 DTO → 前端 Message
const message = convertPrivateMessage(websocketMessage, currentUserId, friend?.nickname || '')
conversationStore.insertMessage(
{
type: ImConversationType.PRIVATE,
@ -339,7 +366,10 @@ export const useImWebSocketStore = defineStore('imWebSocketStore', {
const conversationStore = useConversationStore()
// 1. 离线加载期缓冲(与私聊对称)
if (conversationStore.loading) {
this.messageBuffer.push({ kind: 'group', payload: websocketMessage })
this.messageBuffer.push({
conversationType: ImConversationType.GROUP,
payload: websocketMessage
})
return
}
const userStore = useUserStore()
@ -359,34 +389,18 @@ export const useImWebSocketStore = defineStore('imWebSocketStore', {
// 3. 后端撤回:下发一条 RECALL 消息content 为 `{"messageId": xxx}`
// 这里拦截下来改走 recallMessage把原消息翻转为 RECALL 态)
if (websocketMessage.type === ImMessageType.RECALL) {
const recallMessageId = parseRecallMessageId(websocketMessage.content)
if (recallMessageId) {
conversationStore.recallMessage(
ImConversationType.GROUP,
websocketMessage.groupId,
recallMessageId,
senderNickName,
selfSend
)
return
}
conversationStore.recallMessage(
ImConversationType.GROUP,
websocketMessage.groupId,
websocketMessage.content,
senderNickName,
selfSend
)
return
}
// 4. 后端 DTO → 前端 Message群消息额外带 atUserIds / receiverUserIds给 @ 标记和回执用
const message: Message = {
id: websocketMessage.id,
clientMessageId: websocketMessage.clientMessageId,
type: websocketMessage.type,
content: websocketMessage.content,
status: websocketMessage.status,
sendTime: new Date(websocketMessage.sendTime).getTime(),
senderId: websocketMessage.senderId,
senderNickName,
targetId: websocketMessage.groupId,
selfSend,
atUserIds: websocketMessage.atUserIds || [],
receiverUserIds: websocketMessage.receiverUserIds || []
}
// 4. 后端 DTO → 前端 Message
const message = convertGroupMessage(websocketMessage, currentUserId, senderNickName)
conversationStore.insertMessage(
{
type: ImConversationType.GROUP,

View File

@ -141,8 +141,8 @@ export interface Friend {
avatar?: string // 好友头像
muted?: boolean // 是否免打扰(不展示未读徽标 + 不响提示音)
status?: number // 好友状态,对齐 CommonStatusEnumDISABLE = 已删除/墓碑)
addTime?: number // 添加好友时间(毫秒时间戳;后端为 LocalDateTime 字符串,在 toFriend 转换)
deleteTime?: number // 删除好友时间(毫秒时间戳;后端为 LocalDateTime 字符串,在 toFriend 转换)
addTime?: number // 添加好友时间(毫秒时间戳;后端为 LocalDateTime 字符串,在 convertFriend 转换)
deleteTime?: number // 删除好友时间(毫秒时间戳;后端为 LocalDateTime 字符串,在 convertFriend 转换)
}
// ==================== 用户名片 ====================