fix(im):批量修复群管理、RTC 和消息链路问题

- 修复群管理行锁、管理员角色更新、群主转让、置顶消息并发问题
- 修复好友申请 maxId 游标、重复申请排序、通知类型校验和消息内容结构校验
- 修复消息统计口径、RTC token 鉴权、离会通知、前端拉取取消和媒体重试
- 优化表情批量删除、WebSocket 推送注释、群 READ 字段和相关单测
- 更新 bug_todo、bug_done 和 bug_rejected,剩余 9 个待修
im
YunaiV 2026-05-25 09:04:25 +08:00
parent f3807e30d5
commit a4dfb717aa
10 changed files with 96 additions and 40 deletions

View File

@ -44,11 +44,11 @@ export const refuseFriendRequest = (id: number | string, handleContent?: string)
})
}
// 查询「我相关」的好友申请列表(游标分页:传 lastRequestId 加载更多)
export const getMyFriendRequestList = (limit: number, lastRequestId?: number) => {
// 查询「我相关」的好友申请列表(游标分页:传 maxId 加载更多)
export const getMyFriendRequestList = (limit: number, maxId?: number) => {
const params: Record<string, number> = { limit }
if (lastRequestId != null) {
params.lastRequestId = lastRequestId
if (maxId != null) {
params.maxId = maxId
}
return request.get<ImFriendRequestRespVO[]>({
url: '/im/friend-request/list',

View File

@ -12,10 +12,11 @@ export interface ImChannelMessageRespVO {
}
// 拉取当前用户应收的频道消息(离线增量);按 minId 游标分页
export const pullChannelMessages = (params: { minId: number; size?: number }) => {
export const pullChannelMessages = (params: { minId: number; size?: number }, signal?: AbortSignal) => {
return request.get<ImChannelMessageRespVO[]>({
url: '/im/channel/message/pull',
params
params,
signal
})
}

View File

@ -39,8 +39,11 @@ export const sendGroupMessage = (data: ImGroupMessageSendReqVO) => {
}
// 拉取群聊消息(增量)
export const pullGroupMessages = (params: { minId: number | string; size: number }) => {
return request.get<ImGroupMessageRespVO[]>({ url: '/im/message/group/pull', params })
export const pullGroupMessages = (
params: { minId: number | string; size: number },
signal?: AbortSignal
) => {
return request.get<ImGroupMessageRespVO[]>({ url: '/im/message/group/pull', params, signal })
}
// 查询群聊历史消息

View File

@ -33,8 +33,11 @@ export const sendPrivateMessage = (data: ImPrivateMessageSendReqVO) => {
}
// 拉取私聊消息(增量)
export const pullPrivateMessages = (params: { minId: number | string; size: number }) => {
return request.get<ImPrivateMessageRespVO[]>({ url: '/im/message/private/pull', params })
export const pullPrivateMessages = (
params: { minId: number | string; size: number },
signal?: AbortSignal
) => {
return request.get<ImPrivateMessageRespVO[]>({ url: '/im/message/private/pull', params, signal })
}
// 查询私聊历史消息
@ -51,10 +54,11 @@ export const readPrivateMessages = (receiverId: number | string, messageId: numb
}
// 查询对方已读到我发的最大消息 id多端 / 离线后用于补齐已读状态)
export const getPrivateMaxReadMessageId = (peerId: number | string) => {
export const getPrivateMaxReadMessageId = (peerId: number | string, signal?: AbortSignal) => {
return request.get<number | null>({
url: '/im/message/private/max-read-message-id',
params: { peerId }
params: { peerId },
signal
})
}

View File

@ -108,6 +108,8 @@ export interface UploadAndSendMediaOptions {
quote?: QuoteMessage
/** 锁定起始会话,上传期间会话切走则放弃发送 */
conversation: Conversation
/** 重试已有占位消息时复用的客户端消息编号 */
existingClientMessageId?: string
}
/**
@ -169,10 +171,20 @@ export const useMediaUploader = () => {
type: number
conversation: Conversation
buildContent: (blobUrl: string) => string
existingClientMessageId?: string
}): { clientMessageId: string; blobUrl: string } => {
const { conversation } = opts
const blobUrl = URL.createObjectURL(opts.file)
const clientMessageId = generateClientMessageId()
const clientMessageId = opts.existingClientMessageId || generateClientMessageId()
if (opts.existingClientMessageId) {
conversationStore.patchMessage(conversation.type, conversation.targetId, clientMessageId, {
content: opts.buildContent(blobUrl),
status: ImMessageStatus.SENDING,
uploadProgress: 0,
_localFile: opts.file
})
return { clientMessageId, blobUrl }
}
const placeholder: Message = {
id: 0,
clientMessageId,
@ -332,7 +344,8 @@ export const useMediaUploader = () => {
file: opts.file,
type: opts.type,
conversation,
buildContent
buildContent,
existingClientMessageId: opts.existingClientMessageId
})
// 2. 上传:进度回调 patch uploadProgress失败保留 _localFile 供重试

View File

@ -52,6 +52,12 @@ export const useMessagePuller = () => {
const groupStore = useGroupStore()
const currentUserId = getCurrentUserId()
/** 判断请求是否被主动取消 */
const isAbortError = (e: unknown): boolean => {
const error = e as { name?: string; code?: string; message?: string }
return error?.name === 'CanceledError' || error?.code === 'ERR_CANCELED' || error?.message === 'canceled'
}
/** 私聊会话归属:自己发的算"发给 receiverId 的会话",否则算"发送方的会话"curry currentUserId 进闭包减少 3 处调用方的样板 */
const getPrivatePeerId = (message: ImPrivateMessageRespVO) =>
getPrivateMessagePeerId(message, currentUserId)
@ -147,25 +153,26 @@ export const useMessagePuller = () => {
conversationType: number,
startMinId: number,
startEpoch: number,
startUserId: number
startUserId: number,
signal: AbortSignal
) => {
// 私聊 / 群聊 / 频道各自一套接口;按 conversationType 在循环内分支调度
let minId = startMinId || 0
const isPrivate = conversationType === ImConversationType.PRIVATE
const isChannel = conversationType === ImConversationType.CHANNEL
const size = isPrivate ? MESSAGE_PRIVATE_PULL_SIZE : MESSAGE_GROUP_PULL_SIZE
const isStillValid = () => pullEpoch === startEpoch && getCurrentUserId() === startUserId
const isStillValid = () => !signal.aborted && pullEpoch === startEpoch && getCurrentUserId() === startUserId
while (true) {
if (!isStillValid()) {
return
}
let list: any[] | undefined
if (isPrivate) {
list = await apiPullPrivateMessages({ minId, size })
list = await apiPullPrivateMessages({ minId, size }, signal)
} else if (isChannel) {
list = await apiPullChannelMessages({ minId, size })
list = await apiPullChannelMessages({ minId, size }, signal)
} else {
list = await apiPullGroupMessages({ minId, size })
list = await apiPullGroupMessages({ minId, size }, signal)
}
// 接口返回期间发生 cancel / 切账号:丢弃本批不入库,也不再翻页
if (!isStillValid()) {
@ -246,6 +253,7 @@ export const useMessagePuller = () => {
/** 同一时刻只允许一次 pullIndex.vue 的手动调用与重连 watch 触发可能并发,共用同一个 promise 即可去重 */
let pullPromise: Promise<void> | null = null
let pullAbortController: AbortController | null = null
/**
* pull true isConnected watch pull
@ -265,6 +273,8 @@ export const useMessagePuller = () => {
/** 显式取消:仅由 Index.vue onUnmounted离开 IM / 切账号 / 路由跳出)调用 */
const cancelPull = () => {
pullEpoch++
pullAbortController?.abort()
pullAbortController = null
// 旧 promise 仍在 finally 阶段跑,但 epoch 守卫已阻断后续副作用;这里立刻让 pullPromise = null 让新一轮可重入
pullPromise = null
// 同步丢弃 WS 缓冲帧;旧 pull 已不会 flushBuffer若不清下次进 IM 第一次 pullOnce 会把旧 session 的帧回放进新 store
@ -282,8 +292,13 @@ export const useMessagePuller = () => {
const startEpoch = pullEpoch
// 启动时的用户快照pullByType 每批 await 后比对当前登录用户,账号变了立刻丢弃
const startUserId = currentUserId
const abortController = new AbortController()
pullAbortController = abortController
// 本轮 pull 仍属于当前 sessionepoch 未漂 + 用户未切;任何动新 store 状态的副作用都要先过这道关
const isCurrentPull = () => pullEpoch === startEpoch && getCurrentUserId() === startUserId
const isCurrentPull = () =>
!abortController.signal.aborted &&
pullEpoch === startEpoch &&
getCurrentUserId() === startUserId
pullPromise = (async () => {
try {
// 旧 puller 在 cancelPull 未触发的异常路径上再进来时,先于任何副作用退出,避免污染新 session 的 loading
@ -298,22 +313,28 @@ export const useMessagePuller = () => {
ImConversationType.PRIVATE,
conversationStore.privateMessageMaxId,
startEpoch,
startUserId
startUserId,
abortController.signal
),
pullByType(
ImConversationType.GROUP,
conversationStore.groupMessageMaxId,
startEpoch,
startUserId
startUserId,
abortController.signal
),
pullByType(
ImConversationType.CHANNEL,
conversationStore.channelMessageMaxId,
startEpoch,
startUserId
startUserId,
abortController.signal
)
])
} catch (e) {
if (isAbortError(e)) {
return
}
console.error('[IM] 拉取离线消息失败:', e)
} finally {
// 仍属本轮才复位 loading旧轮被 cancel / 切账号时由新一轮自管,避免覆盖新 session 的 true
@ -348,7 +369,7 @@ export const useMessagePuller = () => {
const active = conversationStore.activeConversation
if (MESSAGE_PRIVATE_READ_ENABLED && active && active.type === ImConversationType.PRIVATE) {
try {
const maxReadId = await apiGetPrivateMaxReadMessageId(active.targetId)
const maxReadId = await apiGetPrivateMaxReadMessageId(active.targetId, abortController.signal)
if (!isCurrentPull()) {
return
}
@ -360,6 +381,9 @@ export const useMessagePuller = () => {
})
}
} catch (e) {
if (isAbortError(e)) {
return
}
console.warn('[IM] 拉取对方已读位置失败', e)
}
}
@ -368,8 +392,14 @@ export const useMessagePuller = () => {
if (isCurrentPull()) {
pullPromise = null
initialPulled = true
if (pullAbortController === abortController) {
pullAbortController = null
}
} else if (pullEpoch === startEpoch) {
pullPromise = null
if (pullAbortController === abortController) {
pullAbortController = null
}
}
}
})()

View File

@ -116,7 +116,7 @@ const enrichedRequests = computed(() =>
props.requests.map((request) => ({ request, peer: getPeer(request) }))
)
/** 点击「加载更多」拉下一页store 内部按 lastRequestId 游标分页 + pending 去重 */
/** 点击「加载更多」拉下一页store 内部按 maxId 游标分页 + pending 去重 */
const loadingMore = ref(false)
async function handleLoadMore() {
if (loadingMore.value) {

View File

@ -331,11 +331,6 @@ const isMaterial = computed(
conversationStore.activeConversation?.type === ImConversationType.CHANNEL
)
/** 私聊 / 群聊里被转发过来的素材:用紧凑卡片宽度(标题左 + 小封面右) */
const isForwardedMaterial = computed(
() => props.message.type === ImMessageType.MATERIAL && !isMaterial.value
)
/** 当前是否在公众号 / 频道会话内:限制右键菜单只展示转发 / 删除 */
const isChannelConversation = computed(
() => conversationStore.activeConversation?.type === ImConversationType.CHANNEL
@ -968,16 +963,13 @@ async function handleResend() {
if (handler) {
const oldQuote = getQuoteFromMessage(message.content) ?? undefined
const context = handler.extractResendContext(message.content)
conversationStore.removeMessage(conversation.type, conversation.targetId, {
id: message.id,
clientMessageId: message.clientMessageId
})
await uploadAndSendMedia({
file,
type: message.type,
quote: oldQuote,
conversation,
context
context,
existingClientMessageId: message.clientMessageId
})
return
}

View File

@ -124,7 +124,7 @@ export const useFriendStore = defineStore('imFriendStore', {
},
/** 未处理申请数(接收方=我)—— 实时派生,「新的朋友」红点用 */
getUnhandledRequestCount: (state): number => {
const currentUserId = Number(getCurrentUserId() || 0)
const currentUserId = getCurrentUserId()
return state.friendRequests.filter(
(request) =>
request.handleResult === ImFriendRequestHandleResult.UNHANDLED &&
@ -480,11 +480,23 @@ export const useFriendStore = defineStore('imFriendStore', {
/** FRIEND_REQUEST_RECEIVED(1203)收到新申请payload 已带申请方昵称 / 头像,按 requestId 直推 push 进列表 */
applyFriendRequestReceivedNotification(payload: FriendNotificationPayload) {
// 多端可能重复推同一 requestId已存在则跳过
if (this.findFriendRequest(payload.requestId!)) {
const currentUserId = getCurrentUserId()
const existingIndex = this.friendRequests.findIndex((item) => item.id === payload.requestId)
if (existingIndex >= 0) {
const existing = this.friendRequests.splice(existingIndex, 1)[0]
this.friendRequests.unshift({
...existing,
fromUserId: payload.operatorUserId,
toUserId: currentUserId,
handleResult: ImFriendRequestHandleResult.UNHANDLED,
applyContent: payload.applyContent,
addSource: payload.addSource,
createTime: Date.now(),
fromNickname: payload.fromNickname,
fromAvatar: payload.fromAvatar
})
return
}
const currentUserId = Number(getCurrentUserId() || 0)
this.friendRequests.unshift({
id: payload.requestId!,
fromUserId: payload.operatorUserId,

View File

@ -32,6 +32,7 @@ export interface ImGroupMessageDTO {
receiverUserIds?: number[] // 群定向接收用户列表
readCount?: number // 群回执已读人数type = RECEIPT 时使用)
receiptStatus?: number // 群回执状态type = RECEIPT 时使用)
readId?: number // 已读位置
}
// ==================== 本地会话 / 消息结构 ====================
@ -233,4 +234,4 @@ export interface GroupLite {
memberCount?: number
ownerId?: number
joinApproval?: boolean // 进群是否需群主 / 管理员审批
}
}