feat(im): 增加好友申请的逻辑(v1)

im
YunaiV 2026-05-04 09:18:35 +08:00
parent bf79e07d5c
commit f86cd30af4
6 changed files with 429 additions and 136 deletions

View File

@ -7,6 +7,9 @@ export interface ImFriendRespVO {
muted?: boolean // 是否免打扰 muted?: boolean // 是否免打扰
displayName?: string // 好友展示备注(仅自己可见) displayName?: string // 好友展示备注(仅自己可见)
displayNamePinyin?: string // 备注的拼音(小写无空格,前端按首字母分桶 / 拼音搜索) displayNamePinyin?: string // 备注的拼音(小写无空格,前端按首字母分桶 / 拼音搜索)
addSource?: number // 添加来源;参见 ImFriendAddSourceEnum
pinned?: boolean // 是否置顶联系人
blocked?: boolean // 是否拉黑
status?: number // 好友状态0=正常1=已删除) status?: number // 好友状态0=正常1=已删除)
addTime?: string // 添加好友时间 addTime?: string // 添加好友时间
deleteTime?: string // 删除好友时间 deleteTime?: string // 删除好友时间
@ -21,6 +24,7 @@ export interface ImFriendUpdateReqVO {
friendUserId: number // 好友的用户编号 friendUserId: number // 好友的用户编号
muted?: boolean // 是否免打扰 muted?: boolean // 是否免打扰
displayName?: string // 好友展示备注 displayName?: string // 好友展示备注
pinned?: boolean // 是否置顶联系人
} }
// 获得当前登录用户的好友列表 // 获得当前登录用户的好友列表
@ -33,17 +37,13 @@ export const getFriend = (friendUserId: number | string) => {
return request.get<ImFriendRespVO>({ url: '/im/friend/get', params: { friendUserId } }) return request.get<ImFriendRespVO>({ url: '/im/friend/get', params: { friendUserId } })
} }
// 添加好友(双向建立关系) // 删除好友(单向软删除)
export const addFriend = (friendUserId: number | string) => {
return request.post<boolean>({ url: '/im/friend/add', params: { friendUserId } })
}
// 删除好友(双向软删除)
export const deleteFriend = (friendUserId: number | string) => { export const deleteFriend = (friendUserId: number | string) => {
return request.delete<boolean>({ url: '/im/friend/delete', params: { friendUserId } }) return request.delete<boolean>({ url: '/im/friend/delete', params: { friendUserId } })
} }
// 更新好友信息 // 更新好友信息(备注 / 免打扰 / 联系人置顶)
export const updateFriend = (data: ImFriendUpdateReqVO) => { export const updateFriend = (data: ImFriendUpdateReqVO) => {
return request.put<boolean>({ url: '/im/friend/update', data }) return request.put<boolean>({ url: '/im/friend/update', data })
} }

View File

@ -0,0 +1,51 @@
import request from '@/config/axios'
// TODO DONE @AI路径迁移到 api/im/friend/request/index.ts与 api/im/group/member 这种嵌套结构对齐
// IM 好友申请 Response VO
export interface ImFriendRequestRespVO {
id: number // 申请编号
fromUserId: number // 发起方用户编号
toUserId: number // 接收方用户编号
handleResult: number // 处理结果0=未处理1=同意2=拒绝
applyContent?: string // 申请理由
handleContent?: string // 处理理由(接收方拒绝时可选填)
addSource?: number // 添加来源;参见 ImFriendAddSourceEnum
handleTime?: string // 处理时间
createTime: string // 申请创建时间
// 聚合字段(自 AdminUser
fromNickname?: string // 发起方昵称
fromAvatar?: string // 发起方头像
toNickname?: string // 接收方昵称
toAvatar?: string // 接收方头像
}
// IM 好友申请发起 Request VO
export interface ImFriendRequestApplyReqVO {
toUserId: number // 接收方用户编号
applyContent?: string // 申请理由
displayName?: string // 对接收方的备注(仅自己可见)
addSource?: number // 添加来源
}
// 发起好友申请
export const applyFriendRequest = (data: ImFriendRequestApplyReqVO) => {
return request.post<number | null>({ url: '/im/friend-request/apply', data })
}
// 同意好友申请
export const agreeFriendRequest = (id: number | string) => {
return request.put<boolean>({ url: '/im/friend-request/agree', params: { id } })
}
// 拒绝好友申请
export const refuseFriendRequest = (id: number | string, handleContent?: string) => {
return request.put<boolean>({
url: '/im/friend-request/refuse',
params: { id, handleContent }
})
}
// 查询「我相关」的好友申请列表(含我发起的、别人加我的)
export const getMyFriendRequestList = () => {
return request.get<ImFriendRequestRespVO[]>({ url: '/im/friend-request/list' })
}

View File

@ -5,30 +5,61 @@ import { CommonStatusEnum } from '@/utils/constants'
import { import {
getMyFriendList as apiGetMyFriendList, getMyFriendList as apiGetMyFriendList,
getFriend as apiGetFriend, getFriend as apiGetFriend,
addFriend as apiAddFriend,
deleteFriend as apiDeleteFriend, deleteFriend as apiDeleteFriend,
updateFriend as apiUpdateFriend, updateFriend as apiUpdateFriend,
type ImFriendRespVO type ImFriendRespVO
} from '@/api/im/friend' } from '@/api/im/friend'
import {
applyFriendRequest as apiApplyFriendRequest,
agreeFriendRequest as apiAgreeFriendRequest,
refuseFriendRequest as apiRefuseFriendRequest,
getMyFriendRequestList as apiGetMyFriendRequestList,
type ImFriendRequestApplyReqVO,
type ImFriendRequestRespVO
} from '@/api/im/friend/request'
import { useConversationStore } from './conversationStore' import { useConversationStore } from './conversationStore'
import { ImConversationType } from '../../utils/constants' import { ImConversationType } from '../../utils/constants'
import { getCurrentUserId, imStorage, setQuietly, StorageKeys } from '../../utils/storage' import { getCurrentUserId, imStorage, setQuietly, StorageKeys } from '../../utils/storage'
import { getFriendDisplayName } from '../../utils/user' import { getFriendDisplayName } from '../../utils/user'
import type { Friend } from '../types' import type { Friend, FriendRequest } from '../types'
/** 好友申请处理结果(对齐后端 ImFriendRequestHandleResultEnum */
const FriendRequestHandleResult = {
UNHANDLED: 0,
AGREED: 1,
REFUSED: 2
} as const
/** 好友通知 payload对齐后端 BaseFriendNotification + 子类裁减后的字段) */
export interface FriendNotificationPayload {
operatorUserId: number
friendUserId: number
// FRIEND_APPLICATION 系列:申请记录的核心字段(避免 payload 携带完整 DO
requestId?: number
applyContent?: string
handleContent?: string
addSource?: number
// FRIEND_UPDATE单边属性变更
displayName?: string
muted?: boolean
pinned?: boolean
}
/** /**
* IM Store * IM Store
* *
* *
* - / * - / +
* - / API + * - -apply / agree / refuse+ / / /
* - ConversationItem / FriendPage / MessageInput * - WebSocket 1201-1210 friendStore dispatcher
*/ */
export const useFriendStore = defineStore('imFriendStore', { export const useFriendStore = defineStore('imFriendStore', {
state: () => ({ state: () => ({
friends: [] as Friend[], friends: [] as Friend[],
// 仅 fetchFriends 成功后置位loadFriendsIDB不置位否则后台 SWR 刷新会被缓存命中跳过 // 仅 fetchFriends 成功后置位loadFriendsIDB不置位否则后台 SWR 刷新会被缓存命中跳过
loaded: false loaded: false,
/** 我相关的好友申请列表(含我发起的 + 别人加我的;后端按 id 倒序,前端不再分页) */
friendRequests: [] as FriendRequest[]
}), }),
getters: { getters: {
@ -48,17 +79,26 @@ export const useFriendStore = defineStore('imFriendStore', {
const entry = this.getFriend(friendUserId) const entry = this.getFriend(friendUserId)
return !!entry && entry.status !== CommonStatusEnum.DISABLE return !!entry && entry.status !== CommonStatusEnum.DISABLE
} }
},
/** 我的黑名单blocked=true 且 ENABLE */
getBlockedFriends: (state): Friend[] => {
return state.friends.filter(
(f) => f.status !== CommonStatusEnum.DISABLE && f.blocked === true
)
},
/** 未处理申请数(接收方=我)—— 实时派生,「新的朋友」红点用 */
getUnhandledRequestCount: (state): number => {
const me = Number(getCurrentUserId() || 0)
return state.friendRequests.filter(
(r) => r.handleResult === FriendRequestHandleResult.UNHANDLED && r.toUserId === me
).length
} }
}, },
actions: { actions: {
// ==================== 本地缓存 ==================== // ==================== 本地缓存 ====================
/** /** 从 IDB 恢复好友列表 */
* IDB
*
* @return
*/
async loadFriends(): Promise<boolean> { async loadFriends(): Promise<boolean> {
const userId = getCurrentUserId() const userId = getCurrentUserId()
if (!userId) { if (!userId) {
@ -121,30 +161,96 @@ export const useFriendStore = defineStore('imFriendStore', {
} }
}, },
/** 添加好友:后端双向建立关系后,本地占位插入(服务端返回后可 fetchFriends 刷新) */ // ==================== 申请-审批 ====================
async addFriend(friendUserId: number, preview?: Partial<Friend>) {
await apiAddFriend(friendUserId) /** 发起好友申请:成功后等待对方同意(不直接落地为好友) */
if (preview) { async applyFriend(reqVO: ImFriendRequestApplyReqVO): Promise<number | null> {
this.upsertFriend({ return await apiApplyFriendRequest(reqVO)
friendUserId, },
nickname: preview.nickname || String(friendUserId),
avatar: preview.avatar, /** 同意一条好友申请;后端会双向落库 + 推 FRIEND_ADD本端等通知到达再 upsertFriend */
status: CommonStatusEnum.ENABLE async agreeFriendRequest(requestId: number) {
}) await apiAgreeFriendRequest(requestId)
const request = this.findFriendRequest(requestId)
if (request) {
request.handleResult = FriendRequestHandleResult.AGREED
request.handleTime = Date.now()
} else { } else {
await this.loadFriendInfo(friendUserId) // 列表过期场景兜底重拉
await this.fetchFriendRequests()
} }
}, },
/** 删除好友(软删,保留记录但置 DISABLE同时级联清理本地私聊会话 */ /** 拒绝一条好友申请 */
async refuseFriendRequest(requestId: number, handleContent?: string) {
await apiRefuseFriendRequest(requestId, handleContent)
const request = this.findFriendRequest(requestId)
if (request) {
request.handleResult = FriendRequestHandleResult.REFUSED
request.handleContent = handleContent
request.handleTime = Date.now()
} else {
await this.fetchFriendRequests()
}
},
/** 拉取「我相关」的好友申请列表(页面打开时 / 收到 FRIEND_APPLICATION 时刷新) */
async fetchFriendRequests() {
const list = await apiGetMyFriendRequestList()
this.friendRequests = (list || []).map(convertFriendRequest)
},
/** 按 id 查申请记录 */
findFriendRequest(requestId: number): FriendRequest | undefined {
return this.friendRequests.find((r) => r.id === requestId)
},
// ==================== 好友关系操作 ====================
/** 删除好友(单向软删,本端置 DISABLE级联清理本地私聊会话 */
async deleteFriend(friendUserId: number) { async deleteFriend(friendUserId: number) {
await apiDeleteFriend(friendUserId) await apiDeleteFriend(friendUserId)
this.removeFriend(friendUserId) this.removeFriend(friendUserId)
}, },
/** 切换免打扰 */
async setMuted(friendUserId: number, muted: boolean) {
await apiUpdateFriend({ friendUserId, muted })
const friend = this.getFriend(friendUserId)
if (friend) {
friend.muted = muted
this.saveFriends()
}
},
/** 切换联系人置顶 */
async setPinned(friendUserId: number, pinned: boolean) {
await apiUpdateFriend({ friendUserId, pinned })
const friend = this.getFriend(friendUserId)
if (friend) {
friend.pinned = pinned
this.saveFriends()
}
},
/** 修改好友展示备注(仅自己可见) */
async setDisplayName(friendUserId: number, displayName: string) {
const value = displayName.trim()
// 后端 displayName 语义null/undefined = 不改,"" = 清空,所以这里直接传 value可能是空串
await apiUpdateFriend({ friendUserId, displayName: value })
const friend = this.getFriend(friendUserId)
if (friend) {
friend.displayName = value
const conversationStore = useConversationStore()
conversationStore.updateConversation(ImConversationType.PRIVATE, friendUserId, {
name: getFriendDisplayName(friend)
})
this.saveFriends()
}
},
/** 本地合并 / 新增某个好友WebSocket 事件 & 手动刷新都用) */ /** 本地合并 / 新增某个好友WebSocket 事件 & 手动刷新都用) */
upsertFriend(friend: Friend) { upsertFriend(friend: Friend) {
// 按 friendUserId 查已有记录下标:>=0 命中则覆盖合并,<0 则追加
const index = this.friends.findIndex((f) => f.friendUserId === friend.friendUserId) const index = this.friends.findIndex((f) => f.friendUserId === friend.friendUserId)
if (index >= 0) { if (index >= 0) {
this.friends[index] = { this.friends[index] = {
@ -158,7 +264,6 @@ export const useFriendStore = defineStore('imFriendStore', {
status: friend.status ?? CommonStatusEnum.ENABLE status: friend.status ?? CommonStatusEnum.ENABLE
}) })
} }
// 同步对应私聊会话的展示
const conversationStore = useConversationStore() const conversationStore = useConversationStore()
const merged = this.getFriend(friend.friendUserId) const merged = this.getFriend(friend.friendUserId)
conversationStore.updateConversation(ImConversationType.PRIVATE, friend.friendUserId, { conversationStore.updateConversation(ImConversationType.PRIVATE, friend.friendUserId, {
@ -169,13 +274,13 @@ export const useFriendStore = defineStore('imFriendStore', {
this.saveFriends() this.saveFriends()
}, },
/** 本地标记删除WebSocket FRIEND_DEL 事件触发;同时级联清私聊会话) */ /** 本地标记删除WebSocket FRIEND_DELETE 事件触发;同时级联清私聊会话) */
removeFriend(friendUserId: number) { removeFriend(friendUserId: number) {
// 软删:保留记录但置为 DISABLE避免后续误判"陌生人"
const friend = this.getFriend(friendUserId) const friend = this.getFriend(friendUserId)
if (friend) { if (friend) {
friend.status = CommonStatusEnum.DISABLE friend.status = CommonStatusEnum.DISABLE
friend.deleteTime = Date.now() friend.deleteTime = Date.now()
friend.blocked = false
} }
// 级联清理:把对应的私聊会话也软删,避免会话列表里留着已删好友 // 级联清理:把对应的私聊会话也软删,避免会话列表里留着已删好友
const conversationStore = useConversationStore() const conversationStore = useConversationStore()
@ -183,39 +288,105 @@ export const useFriendStore = defineStore('imFriendStore', {
this.saveFriends() this.saveFriends()
}, },
/** 切换免打扰 */ // ==================== WebSocket 事件 dispatcher1201-1210 段) ====================
async setMuted(friendUserId: number, muted: boolean) {
await apiUpdateFriend({ friendUserId, muted }) /** FRIEND_APPLICATION(1203)收到新申请payload 已裁减为核心字段,本地拉一次列表补齐 fromUser 等聚合字段 */
const friend = this.getFriend(friendUserId) applyFriendRequestNotification(_payload: FriendNotificationPayload) {
this.fetchFriendRequests().catch(() => undefined)
},
/** FRIEND_REQUEST_APPROVED(1201):我的申请被同意;按 requestId 更新状态FRIEND_ADD 会另外推) */
applyFriendRequestApprovedNotification(payload: FriendNotificationPayload) {
const request = payload.requestId ? this.findFriendRequest(payload.requestId) : undefined
if (request) {
request.handleResult = FriendRequestHandleResult.AGREED
request.handleTime = Date.now()
} else {
this.fetchFriendRequests().catch(() => undefined)
}
},
/** FRIEND_REQUEST_REJECTED(1202):我的申请被拒绝;按 requestId 更新状态 */
applyFriendRequestRejectedNotification(payload: FriendNotificationPayload) {
const request = payload.requestId ? this.findFriendRequest(payload.requestId) : undefined
if (request) {
request.handleResult = FriendRequestHandleResult.REFUSED
request.handleContent = payload.handleContent
request.handleTime = Date.now()
} else {
this.fetchFriendRequests().catch(() => undefined)
}
},
/** FRIEND_ADD(1204):新增好友;本端拉取好友详情并入库 */
applyFriendAddNotification(payload: FriendNotificationPayload) {
if (payload.friendUserId) {
this.loadFriendInfo(payload.friendUserId).catch(() => undefined)
}
},
/** FRIEND_DELETE(1205):好友被删除;本端清理 + 级联会话 */
applyFriendDeleteNotification(payload: FriendNotificationPayload) {
if (payload.friendUserId) {
this.removeFriend(payload.friendUserId)
}
},
/** FRIEND_BLOCK(1207):拉黑;多端同步 */
applyFriendBlockNotification(payload: FriendNotificationPayload) {
const friend = this.getFriend(payload.friendUserId)
if (friend) { if (friend) {
friend.muted = muted friend.blocked = true
this.saveFriends()
}
},
/** FRIEND_UNBLOCK(1208):移出黑名单;多端同步 */
applyFriendUnblockNotification(payload: FriendNotificationPayload) {
const friend = this.getFriend(payload.friendUserId)
if (friend) {
friend.blocked = false
this.saveFriends() this.saveFriends()
} }
}, },
/** /**
* * FRIEND_INFO_UPDATED(1209) /
* * TODO @AI 1209 system / dispatcher
* /im/friend/update friend + name UI /
*/ */
async setDisplayName(friendUserId: number, displayName: string) { applyFriendInfoUpdatedNotification(payload: FriendNotificationPayload) {
const value = displayName.trim() if (payload.friendUserId) {
// 后端的 displayName 语义null/undefined = 不改,"" = 清空,所以这里直接传 value可能是空串 this.loadFriendInfo(payload.friendUserId).catch(() => undefined)
await apiUpdateFriend({ friendUserId, displayName: value })
const friend = this.getFriend(friendUserId)
if (friend) {
friend.displayName = value
const conversationStore = useConversationStore()
conversationStore.updateConversation(ImConversationType.PRIVATE, friendUserId, {
name: getFriendDisplayName(friend)
})
this.saveFriends()
} }
}, },
/** FRIEND_UPDATE(1210):批量更新(备注 / 免打扰 / 联系人置顶);多端同步 */
applyFriendUpdateNotification(payload: FriendNotificationPayload) {
const friend = this.getFriend(payload.friendUserId)
if (!friend) {
return
}
if (payload.displayName != null) {
friend.displayName = payload.displayName
}
if (payload.muted != null) {
friend.muted = payload.muted
}
if (payload.pinned != null) {
friend.pinned = payload.pinned
}
const conversationStore = useConversationStore()
conversationStore.updateConversation(ImConversationType.PRIVATE, payload.friendUserId, {
name: getFriendDisplayName(friend),
muted: friend.muted
})
this.saveFriends()
},
/** 切账号时仅清 in-memoryIDB 按 userId 分桶天然隔离,回切秒开 */ /** 切账号时仅清 in-memoryIDB 按 userId 分桶天然隔离,回切秒开 */
clear() { clear() {
this.friends = [] this.friends = []
this.friendRequests = []
this.loaded = false this.loaded = false
} }
} }
@ -231,16 +402,36 @@ function convertFriend(vo: ImFriendRespVO): Friend {
muted: !!vo.muted, muted: !!vo.muted,
displayName: vo.displayName || '', displayName: vo.displayName || '',
displayNamePinyin: vo.displayNamePinyin, displayNamePinyin: vo.displayNamePinyin,
addSource: vo.addSource,
pinned: !!vo.pinned,
blocked: !!vo.blocked,
status: vo.status, status: vo.status,
addTime: vo.addTime ? new Date(vo.addTime).getTime() : undefined, addTime: vo.addTime ? new Date(vo.addTime).getTime() : undefined,
deleteTime: vo.deleteTime ? new Date(vo.deleteTime).getTime() : undefined deleteTime: vo.deleteTime ? new Date(vo.deleteTime).getTime() : undefined
} }
} }
function convertFriendRequest(vo: ImFriendRequestRespVO): FriendRequest {
return {
id: vo.id,
fromUserId: vo.fromUserId,
toUserId: vo.toUserId,
handleResult: vo.handleResult,
applyContent: vo.applyContent,
handleContent: vo.handleContent,
addSource: vo.addSource,
handleTime: vo.handleTime ? new Date(vo.handleTime).getTime() : undefined,
createTime: vo.createTime ? new Date(vo.createTime).getTime() : 0,
fromNickname: vo.fromNickname,
fromAvatar: vo.fromAvatar,
toNickname: vo.toNickname,
toAvatar: vo.toAvatar
}
}
export const useFriendStoreWithOut = () => useFriendStore(store) export const useFriendStoreWithOut = () => useFriendStore(store)
// dev: 让 Pinia 的 actions / state 改动支持 HMR避免每次改 store 都得硬刷 // dev: 让 Pinia 的 actions / state 改动支持 HMR避免每次改 store 都得硬刷
// 否则 Vite 把新模块推下来后,老 store 实例的 action 闭包仍指向旧函数体
if (import.meta.hot) { if (import.meta.hot) {
import.meta.hot.accept(acceptHMRUpdate(useFriendStore, import.meta.hot)) import.meta.hot.accept(acceptHMRUpdate(useFriendStore, import.meta.hot))
} }

View File

@ -3,10 +3,15 @@ import { store } from '@/store'
import { getRefreshToken } from '@/utils/auth' import { getRefreshToken } from '@/utils/auth'
import { useUserStore } from '@/store/modules/user' import { useUserStore } from '@/store/modules/user'
import { ImWebSocketMessageType, ImMessageType, ImConversationType } from '../../utils/constants' import {
ImWebSocketMessageType,
ImMessageType,
ImConversationType,
isFriendNotification
} from '../../utils/constants'
import { playAudioTip } from '../../utils/message' import { playAudioTip } from '../../utils/message'
import { useConversationStore } from './conversationStore' import { useConversationStore } from './conversationStore'
import { useFriendStore } from './friendStore' import { useFriendStore, type FriendNotificationPayload } from './friendStore'
import { getFriendDisplayName } from '../../utils/user' import { getFriendDisplayName } from '../../utils/user'
import { useGroupStore } from './groupStore' import { useGroupStore } from './groupStore'
import { readPrivateMessages as apiReadPrivateMessages } from '@/api/im/message/private' import { readPrivateMessages as apiReadPrivateMessages } from '@/api/im/message/private'
@ -202,30 +207,30 @@ export const useImWebSocketStore = defineStore('imWebSocketStore', {
// ==================== 普通消息 ==================== // ==================== 普通消息 ====================
/** /**
* payload.typeImMessageType / / / * payload.typeImMessageType / / /
* *
* ImPrivateMessageDTO ofRead / ofReceipt / ofFriendAdd / ofFriendDelete / ofFriendUpdate / ofSend * ImPrivateMessageDTO ofRead / ofReceipt / ofFriendNotification / ofSend
*/ */
dispatchPrivateFrame(websocketMessage: ImPrivateMessageDTO) { dispatchPrivateFrame(websocketMessage: ImPrivateMessageDTO) {
switch (websocketMessage.type) { try {
case ImMessageType.READ: switch (websocketMessage.type) {
this.handlePrivateRead(websocketMessage) case ImMessageType.READ:
break this.handlePrivateRead(websocketMessage)
case ImMessageType.RECEIPT: break
this.handlePrivateReceipt(websocketMessage) case ImMessageType.RECEIPT:
break this.handlePrivateReceipt(websocketMessage)
case ImMessageType.FRIEND_ADD: break
this.handleFriendAdd(websocketMessage) default:
break if (isFriendNotification(websocketMessage.type)) {
case ImMessageType.FRIEND_DELETE: this.handleFriendNotification(websocketMessage)
this.handleFriendDelete(websocketMessage) } else {
break // TEXT / IMAGE / FILE / VOICE / VIDEO / TIP_TEXT 等普通消息
case ImMessageType.FRIEND_UPDATE: this.handlePrivateMessage(websocketMessage)
this.handleFriendUpdate(websocketMessage) }
break }
default: } catch (e) {
// TEXT / IMAGE / FILE / VOICE / VIDEO / TIP_TEXT 等普通消息 // 单条帧的处理异常不应阻断后续帧;打印完整 websocketMessage 便于排查
this.handlePrivateMessage(websocketMessage) console.warn('[IM WS] dispatchPrivateFrame 处理失败', websocketMessage, e)
} }
}, },
@ -235,19 +240,24 @@ export const useImWebSocketStore = defineStore('imWebSocketStore', {
* 1530 GROUP_MEMBER_SETTING_UPDATE + 1501-1520 OpenIM 广 handleGroupMessage + applyGroupNotification * 1530 GROUP_MEMBER_SETTING_UPDATE + 1501-1520 OpenIM 广 handleGroupMessage + applyGroupNotification
*/ */
dispatchGroupFrame(websocketMessage: ImGroupMessageDTO) { dispatchGroupFrame(websocketMessage: ImGroupMessageDTO) {
switch (websocketMessage.type) { try {
case ImMessageType.READ: switch (websocketMessage.type) {
this.handleGroupRead(websocketMessage) case ImMessageType.READ:
break this.handleGroupRead(websocketMessage)
case ImMessageType.RECEIPT: break
this.handleGroupReceipt(websocketMessage) case ImMessageType.RECEIPT:
break this.handleGroupReceipt(websocketMessage)
case ImMessageType.GROUP_MEMBER_SETTING_UPDATE: break
this.handleGroupMemberSettingUpdate(websocketMessage) case ImMessageType.GROUP_MEMBER_SETTING_UPDATE:
break this.handleGroupMemberSettingUpdate(websocketMessage)
default: break
// TEXT / IMAGE / FILE / VOICE / VIDEO / TIP_TEXT + GROUP_* 群广播事件 default:
this.handleGroupMessage(websocketMessage) // TEXT / IMAGE / FILE / VOICE / VIDEO / TIP_TEXT + GROUP_* 群广播事件
this.handleGroupMessage(websocketMessage)
}
} catch (e) {
// 单条帧的处理异常不应阻断后续帧;打印完整 websocketMessage 便于排查
console.warn('[IM WS] dispatchGroupFrame 处理失败', websocketMessage, e)
} }
}, },
@ -461,43 +471,49 @@ export const useImWebSocketStore = defineStore('imWebSocketStore', {
}) })
}, },
// ==================== 好友关系事件(承载于私聊通道,按 inner type 分流 ==================== // ==================== 好友通知1201-1210 段位,承载于私聊通道 ====================
/** FRIEND_ADD后端推送给好友双方本端拉取好友详情并入库级联刷新私聊会话展示 */ /**
handleFriendAdd(websocketMessage: ImPrivateMessageDTO) { * content payload type friendStore dispatcher
*
* ImPrivateMessageDTO.ofFriendNotification payload
* BaseFriendNotification FriendRequestNotification / FriendAddNotification
*/
handleFriendNotification(websocketMessage: ImPrivateMessageDTO) {
// content 解析失败由外层 dispatchPrivateFrame 的 try-catch 兜底(含 websocketMessage 打印),不重复 catch
const payload = JSON.parse(websocketMessage.content || '{}') as FriendNotificationPayload
const friendStore = useFriendStore() const friendStore = useFriendStore()
// 后端 DTO 里只带 senderId/receiverId收到这条时对端 = 非自己的那一方 switch (websocketMessage.type) {
const userStore = useUserStore() case ImMessageType.FRIEND_APPLICATION:
const selfId = Number(userStore.getUser?.id) || 0 friendStore.applyFriendRequestNotification(payload)
const friendUserId = break
websocketMessage.senderId === selfId case ImMessageType.FRIEND_REQUEST_APPROVED:
? websocketMessage.receiverId friendStore.applyFriendRequestApprovedNotification(payload)
: websocketMessage.senderId break
friendStore.loadFriendInfo(friendUserId).catch(() => undefined) case ImMessageType.FRIEND_REQUEST_REJECTED:
}, friendStore.applyFriendRequestRejectedNotification(payload)
break
/** FRIEND_DELETE本端标记好友已删 + 级联清理私聊会话 */ case ImMessageType.FRIEND_ADD:
handleFriendDelete(websocketMessage: ImPrivateMessageDTO) { friendStore.applyFriendAddNotification(payload)
const friendStore = useFriendStore() break
const userStore = useUserStore() case ImMessageType.FRIEND_DELETE:
const selfId = Number(userStore.getUser?.id) || 0 friendStore.applyFriendDeleteNotification(payload)
const friendUserId = break
websocketMessage.senderId === selfId case ImMessageType.FRIEND_BLOCK:
? websocketMessage.receiverId friendStore.applyFriendBlockNotification(payload)
: websocketMessage.senderId break
friendStore.removeFriend(friendUserId) case ImMessageType.FRIEND_UNBLOCK:
}, friendStore.applyFriendUnblockNotification(payload)
break
/** FRIEND_UPDATE多端同步好友属性变更当前主要是免打扰重新拉取好友详情即可 */ case ImMessageType.FRIEND_INFO_UPDATED:
handleFriendUpdate(websocketMessage: ImPrivateMessageDTO) { friendStore.applyFriendInfoUpdatedNotification(payload)
const friendStore = useFriendStore() break
const userStore = useUserStore() case ImMessageType.FRIEND_UPDATE:
const selfId = Number(userStore.getUser?.id) || 0 friendStore.applyFriendUpdateNotification(payload)
const friendUserId = break
websocketMessage.senderId === selfId default:
? websocketMessage.receiverId console.debug('[IM WS] 未识别好友通知', websocketMessage)
: websocketMessage.senderId }
friendStore.loadFriendInfo(friendUserId).catch(() => undefined)
}, },
// ==================== 群关系事件(承载于群聊通道,按 inner type 分流) ==================== // ==================== 群关系事件(承载于群聊通道,按 inner type 分流) ====================
@ -508,13 +524,10 @@ export const useImWebSocketStore = defineStore('imWebSocketStore', {
* payload null fetchGroupMembers * payload null fetchGroupMembers
*/ */
handleGroupMemberSettingUpdate(websocketMessage: ImGroupMessageDTO) { handleGroupMemberSettingUpdate(websocketMessage: ImGroupMessageDTO) {
let payload: { muted?: boolean; groupRemark?: string } = {} // content 解析失败由外层 dispatchGroupFrame 的 try-catch 兜底(含 websocketMessage 打印),不重复 catch
try { const payload: { muted?: boolean; groupRemark?: string } = JSON.parse(
payload = JSON.parse(websocketMessage.content || '{}') websocketMessage.content || '{}'
} catch (error) { )
console.warn('[IM WS] handleGroupMemberSettingUpdate 解析 content 失败', error)
return
}
const groupStore = useGroupStore() const groupStore = useGroupStore()
const group = groupStore.getGroup(websocketMessage.groupId) const group = groupStore.getGroup(websocketMessage.groupId)
if (!group) { if (!group) {

View File

@ -153,10 +153,35 @@ export interface Friend {
displayName?: string // 好友展示备注:仅自己可见的别名(单字段不歧义,不带 Friend 前缀) displayName?: string // 好友展示备注:仅自己可见的别名(单字段不歧义,不带 Friend 前缀)
displayNamePinyin?: string // 备注的拼音(后端用 Pinyin4j 算好回填,小写无空格) displayNamePinyin?: string // 备注的拼音(后端用 Pinyin4j 算好回填,小写无空格)
status?: number // 好友状态,对齐 CommonStatusEnumDISABLE = 已删除,软删保留记录) status?: number // 好友状态,对齐 CommonStatusEnumDISABLE = 已删除,软删保留记录)
addSource?: number // 添加来源;参见 ImFriendAddSourceEnum
pinned?: boolean // 是否置顶联系人
blocked?: boolean // 是否拉黑(仅自己可见,单边屏蔽对方私聊消息)
addTime?: number // 添加好友时间(毫秒时间戳;后端为 LocalDateTime 字符串,在 convertFriend 转换) addTime?: number // 添加好友时间(毫秒时间戳;后端为 LocalDateTime 字符串,在 convertFriend 转换)
deleteTime?: number // 删除好友时间(毫秒时间戳;后端为 LocalDateTime 字符串,在 convertFriend 转换) deleteTime?: number // 删除好友时间(毫秒时间戳;后端为 LocalDateTime 字符串,在 convertFriend 转换)
} }
/**
* ImFriendRequestRespVO
*/
export interface FriendRequest {
// ========== 后端字段(对齐 ImFriendRequestRespVO ==========
id: number // 申请编号
fromUserId: number // 发起方用户编号
toUserId: number // 接收方用户编号
handleResult: number // 处理结果0=未处理1=同意2=拒绝
applyContent?: string // 申请理由(发起方填写)
handleContent?: string // 处理理由(接收方拒绝时可选填)
addSource?: number // 添加来源;参见 ImFriendAddSourceEnum
handleTime?: number // 处理时间(毫秒时间戳)
createTime: number // 申请创建时间(毫秒时间戳)
// ========== 聚合字段(自 AdminUser仅展示用 ==========
fromNickname?: string // 发起方昵称
fromAvatar?: string // 发起方头像
toNickname?: string // 接收方昵称
toAvatar?: string // 接收方头像
}
// ==================== 用户名片 ==================== // ==================== 用户名片 ====================
// 用户精简信息(对齐后端 UserSimpleRespVO名片 / 头像 hover 等场景共用) // 用户精简信息(对齐后端 UserSimpleRespVO名片 / 头像 hover 等场景共用)

View File

@ -10,9 +10,17 @@ export const ImMessageType = {
RECEIPT: 12, // 回执 RECEIPT: 12, // 回执
TIP_TIME: 20, // 时间分隔线(前端本地生成,不发送到后端) TIP_TIME: 20, // 时间分隔线(前端本地生成,不发送到后端)
TIP_TEXT: 21, // 提示文本(撤回提示等) TIP_TEXT: 21, // 提示文本(撤回提示等)
FRIEND_ADD: 100, // 好友添加 // 好友通知1201-1210 复用 OpenIM 段位编号)
FRIEND_DELETE: 101, // 好友删除 FRIEND_REQUEST_APPROVED: 1201, // 好友申请被同意
FRIEND_UPDATE: 102, // 好友更新(客户端收到后自行拉取) FRIEND_REQUEST_REJECTED: 1202, // 好友申请被拒绝
FRIEND_APPLICATION: 1203, // 收到新的好友申请
FRIEND_ADD: 1204, // 新增好友(双方建立关系)
FRIEND_DELETE: 1205, // 好友被删除
// 1206 对应 OpenIM FriendRemarkSetNotification本系统并入 FRIEND_UPDATE(1210) 统一推送
FRIEND_BLOCK: 1207, // 加入黑名单
FRIEND_UNBLOCK: 1208, // 移出黑名单
FRIEND_INFO_UPDATED: 1209, // 好友资料变更(昵称 / 头像)
FRIEND_UPDATE: 1210, // 好友信息批量更新muted / pinned
// 群事件1501-1520 复用 OpenIM 段位编号1530+ 自有扩展段) // 群事件1501-1520 复用 OpenIM 段位编号1530+ 自有扩展段)
GROUP_CREATE: 1501, // 群创建 GROUP_CREATE: 1501, // 群创建
GROUP_INFO_UPDATE: 1502, // 群信息变更NAME / NOTICE 之外字段兜底) GROUP_INFO_UPDATE: 1502, // 群信息变更NAME / NOTICE 之外字段兜底)
@ -48,6 +56,11 @@ export function isGroupNotification(type: number): boolean {
) )
} }
/** 判断是否「好友通知事件」1201-1210 段位 */
export function isFriendNotification(type: number): boolean {
return type >= ImMessageType.FRIEND_REQUEST_APPROVED && type <= ImMessageType.FRIEND_UPDATE
}
/** IM 普通消息类型集合(聊天气泡中显示,并作为会话最后一条摘要) */ /** IM 普通消息类型集合(聊天气泡中显示,并作为会话最后一条摘要) */
const ImMessageTypeNormals: number[] = [ const ImMessageTypeNormals: number[] = [
ImMessageType.TEXT, ImMessageType.TEXT,