fix(im):批量修复群管理、RTC 和消息链路问题
- 修复群管理行锁、管理员角色更新、群主转让、置顶消息并发问题 - 修复好友申请 maxId 游标、重复申请排序、通知类型校验和消息内容结构校验 - 修复消息统计口径、RTC token 鉴权、离会通知、前端拉取取消和媒体重试 - 优化表情批量删除、WebSocket 推送注释、群 READ 字段和相关单测 - 更新 bug_todo、bug_done 和 bug_rejected,剩余 9 个待修im
parent
f3807e30d5
commit
a4dfb717aa
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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
|
||||
})
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 })
|
||||
}
|
||||
|
||||
// 查询群聊历史消息
|
||||
|
|
|
|||
|
|
@ -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
|
||||
})
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 供重试
|
||||
|
|
|
|||
|
|
@ -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 = () => {
|
|||
|
||||
/** 同一时刻只允许一次 pull:Index.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 仍属于当前 session:epoch 未漂 + 用户未切;任何动新 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
|
||||
}
|
||||
}
|
||||
}
|
||||
})()
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 // 进群是否需群主 / 管理员审批
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue