fix(im): 收敛离线拉取的实时副作用

- 离线 pull 只还原历史好友、群聊事件气泡,不再重放实时通知副作用
- 好友详情请求增加 in-flight 去重,有效好友已存在时跳过重复拉取
- 修复软删好友重新添加时被本地缓存误跳过的问题
- 群创建通知只拉群详情,群成员改为进入会话后懒加载
- 避免群基础信息缺失或退群时兜底拉取整群成员
pull/884/MERGE
YunaiV 2026-06-17 09:20:29 +08:00
parent ddafacf64d
commit 8ba76813ae
5 changed files with 54 additions and 44 deletions

View File

@ -217,10 +217,8 @@ export const useMessagePuller = () => {
})
continue
}
// 特殊:离线 pull 期间入库的 FRIEND_* 帧(目前仅 FRIEND_ADD persistent=true也要走好友数据分发
// 否则断线期间的好友列表更新会丢失;与 WebSocket 路径 dispatchPrivateFrame 保持对称
// 特殊:历史好友事件只还原聊天气泡;好友主数据由好友增量补偿同步
if (isFriendNotification(message.type)) {
wsStore.handleFriendNotification(message)
// 仅 FRIEND_ADD / FRIEND_DELETE 才作为会话气泡入消息列表
if (!isFriendChatTip(message.type)) {
continue
@ -244,7 +242,6 @@ export const useMessagePuller = () => {
})
continue
}
// 其它消息正常入会话消息列表
pulledMessages.push({
kind: 'insert',
conversationInfo: convertGroupConversation(message),

View File

@ -79,7 +79,7 @@ onMounted(async () => {
// 1.2 IM DB
await initDb()
// 1.3 store IDB
const [, , hasCachedFriends, hasCachedGroups, hasCachedChannels] = await Promise.all([
const [, , hasFriendRows, hasGroupRows, hasChannelRows] = await Promise.all([
conversationStore.loadConversationList(),
messageStore.loadMessageCursorList(),
friendStore.loadFriendData(),
@ -99,18 +99,18 @@ onMounted(async () => {
// 2.2 / await + onMounted
// pullOnce senderId IDB Promise.all RTT
const requiredFetches: Promise<unknown>[] = []
if (hasCachedFriends) {
if (hasFriendRows) {
void friendStore.pullFriends().catch((e) => console.warn('[IM] 后台增量拉好友失败', e))
} else {
requiredFetches.push(friendStore.pullFriends())
}
if (hasCachedGroups) {
if (hasGroupRows) {
void groupStore.fetchGroupList(true).catch((e) => console.warn('[IM] 后台刷新群列表失败', e))
} else {
requiredFetches.push(groupStore.fetchGroupList(true))
}
// 2.3 pull list
if (hasCachedChannels) {
if (hasChannelRows) {
void channelStore.fetchChannelList().catch((e) => console.warn('[IM] 后台刷频道列表失败', e))
} else {
requiredFetches.push(channelStore.fetchChannelList())

View File

@ -39,10 +39,17 @@ let pendingFetchFriends: PendingRequest | null = null
let pendingFetchRequests: PendingRequest | null = null
/** 当前正在进行的「加载更多申请」请求 */
let pendingLoadMoreRequests: PendingRequest | null = null
/** 当前正在进行的好友详情请求 */
const pendingFetchFriendInfos = new Map<string, Promise<void>>()
/** clear() 时递增;旧账号那次还没返回的请求 resolve 后比对一致才写 store防跨账号数据泄漏 */
let storeEpoch = 0
/** 构建好友详情请求去重 key */
function getPendingFriendInfoKey(userId: number, friendUserId: number): string {
return `${userId}:${friendUserId}`
}
/** 好友通知 payload对齐后端 BaseFriendNotification + 子类裁减后的字段) */
export interface FriendNotificationPayload {
operatorUserId: number
@ -303,19 +310,35 @@ export const useFriendStore = defineStore('imFriendStore', {
async fetchFriendInfo(friendUserId: number) {
const requestEpoch = storeEpoch
const requestUserId = getCurrentUserId()
try {
const data = await apiGetFriend(friendUserId)
if (!data) {
return
}
// clear() 已切账号:旧请求的好友详情不能再 upsert 进新账号的 friends
if (requestEpoch !== storeEpoch || getCurrentUserId() !== requestUserId) {
return
}
this.upsertFriend(convertFriend(data))
} catch (e) {
console.warn('[IM friendStore] fetchFriendInfo 失败', e)
if (!requestUserId) {
return
}
const key = getPendingFriendInfoKey(requestUserId, friendUserId)
const inflight = pendingFetchFriendInfos.get(key)
if (inflight) {
return inflight
}
const promise = (async () => {
try {
const data = await apiGetFriend(friendUserId)
if (!data) {
return
}
// clear() 已切账号:旧请求的好友详情不能再 upsert 进新账号的 friends
if (requestEpoch !== storeEpoch || getCurrentUserId() !== requestUserId) {
return
}
this.upsertFriend(convertFriend(data))
} catch (e) {
console.warn('[IM friendStore] fetchFriendInfo 失败', e)
}
})().finally(() => {
if (pendingFetchFriendInfos.get(key) === promise) {
pendingFetchFriendInfos.delete(key)
}
})
pendingFetchFriendInfos.set(key, promise)
return promise
},
// ==================== 申请-审批 ====================
@ -707,6 +730,9 @@ export const useFriendStore = defineStore('imFriendStore', {
* payload.friendUserIdpayload toUserId
*/
applyFriendAddNotification(_payload: FriendNotificationPayload, peerUserId: number) {
if (this.isActiveFriend(peerUserId)) {
return
}
void this.fetchFriendInfo(peerUserId)
},
@ -773,6 +799,7 @@ export const useFriendStore = defineStore('imFriendStore', {
pendingFetchFriends = null
pendingFetchRequests = null
pendingLoadMoreRequests = null
pendingFetchFriendInfos.clear()
storeEpoch++
}
}

View File

@ -226,7 +226,7 @@ export const useGroupStore = defineStore('imGroupStore', {
if (requestEpoch !== storeEpoch || getCurrentUserId() !== requestUserId) {
return
}
const fresh = (list || []).map(convertGroup)
const fresh = (list || []).map((group) => convertGroup(group))
// 合并而非全量替换silent / groupRemark / 成员缓存这些字段不在 ImGroupRespVO 里,得从旧 group 保留
const groupMap = new Map(this.groups.map((group) => [group.id, group]))
this.groups = fresh.map((group) => {
@ -584,14 +584,14 @@ export const useGroupStore = defineStore('imGroupStore', {
/**
* GROUP_* 广 type action
*
* WebSocket + useMessagePuller 线 pull messageStore.insertMessage
* WebSocket messageStore.insertMessage
* store fetchGroupList
*/
applyGroupNotification(groupId: number, type: number, content?: string) {
if (!groupId) {
return
}
let payload: GroupNotificationPayload = {}
let payload: GroupNotificationPayload
try {
payload = content ? JSON.parse(content) : {}
} catch (error) {
@ -683,7 +683,7 @@ export const useGroupStore = defineStore('imGroupStore', {
}
},
/** 创建群广播:创建者多端同步 + 初始成员首次拉取payload.memberUserIds 含自己 → 拉群详情 / 成员;本端发起者已经 upsert 过本群,跳过避免双拉 */
/** 创建群广播:群未就位时拉群详情 */
async applyGroupCreateNotification(groupId: number, payload: GroupNotificationPayload) {
if (!isSelfInPayloadMembers(payload)) {
return
@ -693,9 +693,7 @@ export const useGroupStore = defineStore('imGroupStore', {
if (selfIsOperator && this.getGroup(groupId)) {
return
}
// 先 await fetchGroupInfo 把群 upsert 进 state.groups否则 fetchGroupMemberList 的「不是我加入的群」guard 会兜空
await this.fetchGroupInfo(groupId)
this.fetchGroupMemberList(groupId, true).catch(() => undefined)
},
/** 群名变更:按 newName 局部更新本地群名 */
@ -890,7 +888,7 @@ function convertGroup(group: ImGroupRespVO): Group {
avatar: group.avatar,
notice: group.notice,
ownerUserId: group.ownerUserId,
pinnedMessages: group.pinnedMessages?.map(convertGroupMessageVO),
pinnedMessages: group.pinnedMessages?.map((message) => convertGroupMessageVO(message)),
mutedAll: group.mutedAll,
banned: group.banned,
joinApproval: group.joinApproval,

View File

@ -137,7 +137,7 @@ function deriveLastSenderDisplayName(
if (conversation.type === ImConversationType.GROUP) {
const groupStore = useGroupStore()
const group = groupStore.getGroup(conversation.targetId)
if (isGroupQuit(group)) {
if (!group || isGroupQuit(group)) {
return conversation.lastSenderId === senderId ? conversation.lastSenderDisplayName : undefined
}
const fetchPromise =
@ -501,24 +501,12 @@ export const useMessageStore = defineStore('imMessageStore', {
const { conversationInfo } = pulledMessage
const hasServerClientMessageId = !!pulledMessage.message.clientMessageId
const message = ensureClientMessageId(pulledMessage.message)
// 1.2 群通知先同步群资料
if (
conversationInfo.type === ImConversationType.GROUP &&
isGroupNotification(message.type)
) {
useGroupStore().applyGroupNotification(
conversationInfo.targetId,
message.type,
message.content
)
}
// 1.3 确保会话和消息缓存存在
// 1.2 确保会话和消息缓存存在
const conversation = conversationStore.ensureConversation(conversationInfo)
const messages = this.getMessageList(conversationInfo.type, conversationInfo.targetId)
const existingIndex = messages.findIndex((existing) => isSameMessage(existing, message))
if (existingIndex >= 0) {
// 1.4 已存在消息合并服务端状态
// 1.3 已存在消息合并服务端状态
applyServerMessageUpdate(messages[existingIndex], message)
if (existingIndex === messages.length - 1) {
recomputeConversationLast(conversation, messages)
@ -530,7 +518,7 @@ export const useMessageStore = defineStore('imMessageStore', {
continue
}
// 1.5 新消息更新会话摘要和未读状态
// 1.4 新消息更新会话摘要和未读状态
applyConversationSummary(conversation, message)
syncConversationAtFlags(conversation, message)
const isActive =
@ -545,7 +533,7 @@ export const useMessageStore = defineStore('imMessageStore', {
conversation.unreadCount++
}
// 1.6 新消息按服务端 id 插入内存列表
// 1.5 新消息按服务端 id 插入内存列表
let insertIndex = messages.length
if (message.id) {
for (let index = 0; index < messages.length; index++) {