From 18e5c97bf387e7a66aae5c1eb5690c2406c2f3a3 Mon Sep 17 00:00:00 2001 From: YunaiV Date: Tue, 12 May 2026 20:29:08 +0800 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20feat(im):=20=E5=B0=86=E5=90=8E?= =?UTF-8?q?=E7=AB=AF=E7=9A=84=20roomName=20=E5=92=8C=20callId=20=E8=9E=8D?= =?UTF-8?q?=E5=90=88=EF=BC=8C=E7=AE=80=E5=8C=96=E5=AD=97=E6=AE=B5=E5=92=8C?= =?UTF-8?q?=E9=80=BB=E8=BE=91=EF=BC=88=E4=B8=80=E8=87=B4=E6=80=A7=E6=9B=B4?= =?UTF-8?q?=E5=A5=BD=E3=80=81=E6=A6=82=E5=BF=B5=E6=9B=B4=E7=AE=80=E6=B4=81?= =?UTF-8?q?=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/api/im/home/rtc/index.ts | 176 +++++++++--------- .../im/home/composables/useMessagePuller.ts | 8 +- src/views/im/home/store/groupStore.ts | 6 +- src/views/im/utils/constants.ts | 59 +++++- 4 files changed, 146 insertions(+), 103 deletions(-) diff --git a/src/api/im/home/rtc/index.ts b/src/api/im/home/rtc/index.ts index d9a93473c..31abb85c6 100644 --- a/src/api/im/home/rtc/index.ts +++ b/src/api/im/home/rtc/index.ts @@ -1,33 +1,8 @@ import request from '@/config/axios' - -/** 会话场景;对齐后端 ImConversationTypeEnum */ -export const ImCallScene = { - PRIVATE: 1, - GROUP: 2 -} as const - -/** 媒体类型;对齐后端 ImCallMediaTypeEnum */ -export const ImCallMediaType = { - VOICE: 1, - VIDEO: 2 -} as const - -/** 通话状态;对齐后端 ImCallStatusEnum */ -export const ImCallStatus = { - INVITING: 10, - ONGOING: 20, - ENDED: 30 -} as const - -/** 通话结束原因;对齐后端 ImCallEndReasonEnum */ -export const ImCallEndReason = { - HANGUP: 1, - REJECT: 2, - CANCEL: 3, - TIMEOUT: 4, - BUSY: 5, - ERROR: 9 -} as const +import type { + ImCallEndReasonValue, + ImCallParticipantStatusValue +} from '@/views/im/utils/constants' /** 发起通话请求 VO */ export interface ImRtcCallInviteReqVO { @@ -41,14 +16,14 @@ export interface ImRtcCallInviteReqVO { /** 通话中添加成员请求 VO */ export interface ImRtcCallInviteMoreReqVO { - roomName: string + room: string inviteeIds: number[] } -/** 通话会话 VO;invite / accept / refreshToken / getActiveSessions 共用 */ +/** 通话会话 VO;invite / join / accept / refreshToken 共用 */ export interface ImRtcCallRespVO { - callId: string - roomName: string + /** 业务通话编号(同时作为 LiveKit 房间名) */ + room: string livekitUrl: string token?: string scene: number @@ -58,54 +33,81 @@ export interface ImRtcCallRespVO { groupId?: number inviteeIds?: number[] joinedUserIds?: number[] - newCreated?: boolean } -/** RTC_INVITE 信令载荷;payload 走 ImPrivateMessageDTO.content(JSON 字符串) */ -export interface ImRtcInviteNotification { - callId: string - roomName: string - livekitUrl: string - token: string - scene: number +/** RTC_CALL 通话信令载荷(通话信令统一入口);status 区分子类型(复用参与者状态枚举);走 ImPrivateMessageDTO.content 仅推参与方 */ +export interface ImRtcCallNotification { + /** 信令对应的参与者状态变迁;取值参见 ImCallParticipantStatus */ + status: ImCallParticipantStatusValue + /** 业务通话编号(同时作为 LiveKit 房间名) */ + room: string + conversationType: number mediaType: number - inviterId: number + groupId?: number + /** INVITE 专属 */ + livekitUrl?: string + /** INVITE 专属 */ + token?: string + /** INVITE 专属 */ + inviterUserId?: number + /** INVITE 专属 */ inviterNickname?: string + /** INVITE 专属 */ inviterAvatar?: string + /** ACCEPT / REJECT / CANCEL / HUNGUP 专属 */ + operatorUserId?: number + /** 操作者昵称;按需展示,普通文案不依赖 */ + operatorNickname?: string + /** 操作者头像;按需展示,普通文案不依赖 */ + operatorAvatar?: string +} + +/** RTC_PARTICIPANT_CONNECTED 通话参与者加入载荷;LiveKit webhook participant_joined 转推;callStore +userId 进 joinedUserIds;群聊场景非邀请成员靠 mediaType/inviterUserId 字段首次填充胶囊条 */ +export interface ImRtcParticipantConnectedNotification { + room: string + userId: number + conversationType: number + groupId?: number + mediaType?: number + inviterUserId?: number +} + +/** RTC_PARTICIPANT_DISCONNECTED 通话参与者离开载荷;LiveKit webhook participant_left 转推;callStore -userId 出 joinedUserIds */ +export interface ImRtcParticipantDisconnectedNotification { + room: string + userId: number + conversationType: number groupId?: number } -/** RTC_ACCEPT 信令载荷 */ -export interface ImRtcAcceptNotification { - callId: string - roomName: string - acceptorId: number -} - -/** RTC_END 信令载荷 */ -export interface ImRtcEndNotification { - callId: string - roomName: string - operatorId?: number - reason: number - durationSeconds?: number -} - -/** RTC_GROUP_STARTED / RTC_GROUP_UPDATED / RTC_GROUP_ENDED 共用载荷 */ -export interface ImRtcGroupNotification { - callId: string - roomName: string - groupId: number +/** RTC_CALL_START 通话开始载荷;仅群聊;入消息流;前端渲染聊天 tip「{inviterNickname} 发起了{voice/video}通话」;与 END 两段式配对 */ +export interface ImRtcCallStartNotification { + room: string + conversationType: number mediaType: number - inviterId: number - joinedUserIds?: number[] - inviteeIds?: number[] + inviterUserId: number + inviterNickname?: string + inviterAvatar?: string +} + +/** RTC_CALL_END 通话结束载荷;入消息流;私聊渲染准气泡,群聊渲染 tip「{voice/video}通话已结束 [时长 X]」 */ +export interface ImRtcCallEndNotification { + room: string + conversationType: number + mediaType: number + endReason: ImCallEndReasonValue + durationSeconds?: number + /** 操作者用户编号;HANGUP/CANCEL/REJECT 触发人;webhook 兜底为 null */ + operatorUserId?: number + /** 操作者昵称;按需展示,普通文案不依赖 */ + operatorNickname?: string + /** 操作者头像;按需展示,普通文案不依赖 */ + operatorAvatar?: string } /** 群活跃通话查询响应;不含 token */ export interface ImRtcGroupCallRespVO { - callId: string - roomName: string + room: string groupId: number mediaType: number inviterId: number @@ -113,50 +115,50 @@ export interface ImRtcGroupCallRespVO { inviteeIds?: number[] } -/** 发起通话;同好友对 / 群已有进行中通话则返回该会话并标记 newCreated=false */ +/** 发起新通话;同好友对 / 同群已有进行中通话直接抛错(群场景应改走 joinCall) */ export const inviteCall = (data: ImRtcCallInviteReqVO) => { return request.post({ url: '/im/rtc/invite', data }) } +/** 加入已有群通话;用于胶囊条「加入」按钮 */ +export const joinCall = (room: string) => { + return request.post({ url: '/im/rtc/join', params: { room } }) +} + /** 通话中添加成员;仅群通话可用 */ export const inviteMoreCall = (data: ImRtcCallInviteMoreReqVO) => { return request.post({ url: '/im/rtc/invite-more', data }) } /** 接听通话 */ -export const acceptCall = (roomName: string) => { - return request.post({ url: '/im/rtc/accept', params: { roomName } }) +export const acceptCall = (room: string) => { + return request.post({ url: '/im/rtc/accept', params: { room } }) } /** 拒绝通话 */ -export const rejectCall = (roomName: string) => { - return request.post({ url: '/im/rtc/reject', params: { roomName } }) +export const rejectCall = (room: string) => { + return request.post({ url: '/im/rtc/reject', params: { room } }) } /** 取消邀请;主叫接通前调用 */ -export const cancelCall = (roomName: string) => { - return request.post({ url: '/im/rtc/cancel', params: { roomName } }) +export const cancelCall = (room: string) => { + return request.post({ url: '/im/rtc/cancel', params: { room } }) } /** 离开通话;接通后调用 */ -export const leaveCall = (roomName: string) => { - return request.post({ url: '/im/rtc/leave', params: { roomName } }) +export const leaveCall = (room: string) => { + return request.post({ url: '/im/rtc/leave', params: { room } }) } /** 重新签发 Token;客户端重连或 Token 过期续期 */ -export const refreshCallToken = (roomName: string) => { - return request.get({ url: '/im/rtc/refresh-token', params: { roomName } }) +export const refreshCallToken = (room: string) => { + return request.get({ url: '/im/rtc/refresh-token', params: { room } }) } -/** 查询当前用户活跃通话;冷启动 / 推送点开恢复 */ -export const getActiveCallSessions = () => { - return request.get({ url: '/im/rtc/active-sessions' }) -} - -/** 查询群当前进行中的通话;用于群聊顶部胶囊条;返回 null 表示无活跃通话 */ -export const getGroupActiveCall = (groupId: number) => { +/** 查询当前进行中的通话;目前仅群聊场景(胶囊条),后端 API 已留扩展点;返回 null 表示无活跃通话 */ +export const getActiveCall = (groupId: number) => { return request.get({ - url: '/im/rtc/group-active-call', + url: '/im/rtc/get-active-call', params: { groupId } }) } diff --git a/src/views/im/home/composables/useMessagePuller.ts b/src/views/im/home/composables/useMessagePuller.ts index 170527fb0..68b820bac 100644 --- a/src/views/im/home/composables/useMessagePuller.ts +++ b/src/views/im/home/composables/useMessagePuller.ts @@ -183,7 +183,7 @@ export const useMessagePuller = () => { * 首次 pull 是否已完成。仅在置 true 后,isConnected watch 才会触发 pull。 * 防止 socket onopen 比 friendStore/groupStore 预拉先到达时,watcher 抢跑造成消息插入早于会话元数据可见 */ - let bootstrapped = false + let initialPulled = false /** 执行一次全量增量拉取(重入安全:进行中再次调用复用同一个 promise) */ const pullOnce = (): Promise => { @@ -246,7 +246,7 @@ export const useMessagePuller = () => { } finally { // 整个 IIFE 全部完成(含已读位置补齐)后才允许下一次 pullOnce 重入 pullPromise = null - bootstrapped = true + initialPulled = true } })() return pullPromise @@ -254,12 +254,12 @@ export const useMessagePuller = () => { /** * 断网期间 WS 收不到推送,期间产生的消息只能靠拉取接口按 minId 游标补齐; - * 首次连接由 Index.vue 显式调 pullOnce 完成 bootstrap,这里仅覆盖之后的重连 + * 首次连接由 Index.vue 显式调 pullOnce 完成首拉,这里仅覆盖之后的重连 */ watch( () => wsStore.isConnected, (isConnected) => { - if (isConnected && bootstrapped) { + if (isConnected && initialPulled) { void pullOnce() } } diff --git a/src/views/im/home/store/groupStore.ts b/src/views/im/home/store/groupStore.ts index cfe4019c7..af6e8cffa 100644 --- a/src/views/im/home/store/groupStore.ts +++ b/src/views/im/home/store/groupStore.ts @@ -585,7 +585,7 @@ export const useGroupStore = defineStore('imGroupStore', { } }, - /** 创建群广播:创建者多端同步 + 初始成员 bootstrap;payload.memberUserIds 含自己 → 拉群详情 / 成员;本端发起者已经 upsert 过本群,跳过避免双拉 */ + /** 创建群广播:创建者多端同步 + 初始成员首次拉取;payload.memberUserIds 含自己 → 拉群详情 / 成员;本端发起者已经 upsert 过本群,跳过避免双拉 */ async applyGroupCreateNotification(groupId: number, payload: GroupNotificationPayload) { if (!isSelfInPayloadMembers(payload)) { return @@ -623,7 +623,7 @@ export const useGroupStore = defineStore('imGroupStore', { } }, - /** 成员加入:被邀请者本端 group 未就位先 fetchGroupInfo bootstrap;所有人都刷成员列表(新成员 nickname / avatar 不在 payload) */ + /** 成员加入:被邀请者本端 group 未就位先 fetchGroupInfo 初次拉取;所有人都刷成员列表(新成员 nickname / avatar 不在 payload) */ async applyGroupMemberInviteNotification(groupId: number, payload: GroupNotificationPayload) { // 自己刚被拉进来:必须 await fetchGroupInfo 让群入 state.groups,否则 fetchGroupMembers 的 guard 会兜空 if (isSelfInPayloadMembers(payload) && !this.getGroup(groupId)) { @@ -632,7 +632,7 @@ export const useGroupStore = defineStore('imGroupStore', { this.fetchGroupMembers(groupId, true).catch(() => undefined) }, - /** 自由进群:进群者本端 group 未就位先 fetchGroupInfo bootstrap;所有人都刷成员列表 */ + /** 自由进群:进群者本端 group 未就位先 fetchGroupInfo 初次拉取;所有人都刷成员列表 */ async applyGroupMemberEnterNotification(groupId: number, payload: GroupNotificationPayload) { const selfUserId = getCurrentUserId() // 自己自由进群:必须 await fetchGroupInfo 让群入 state.groups,否则 fetchGroupMembers 的 guard 会兜空 diff --git a/src/views/im/utils/constants.ts b/src/views/im/utils/constants.ts index cd8357d5c..09e94a8e0 100644 --- a/src/views/im/utils/constants.ts +++ b/src/views/im/utils/constants.ts @@ -13,15 +13,13 @@ export const ImMessageType = { RECALL: 2101, // 撤回(对应 OpenIM RevokeNotification=2101) RECEIPT: 2200, // 回执(对应 OpenIM HasReadReceipt=2200) READ: 2201, // 已读(多端同步,OpenIM 无对应;自有扩展) - // TODO @AI:是不是要把单聊、群聊的信令融合? - // ========== 实时通话信令(2300-2302) ========== - RTC_INVITE: 2300, // 通话邀请(推给被叫弹来电) - RTC_ACCEPT: 2301, // 通话接通(推给主叫切到通话中 UI) - RTC_END: 2302, // 通话结束(拒绝/取消/挂断/超时统一) - // ========== 群通话广播(2310-2312):让所有群成员能看胶囊条 / 主动加入 ========== - RTC_GROUP_STARTED: 2310, // 群通话开始(全群广播) - RTC_GROUP_ENDED: 2311, // 群通话结束(全群广播;胶囊条移除) - RTC_GROUP_UPDATED: 2312, // 群通话成员变更(全群广播;胶囊条人数刷新) + // ========== 实时通话信令(1601-1605 段位与 OpenIM 对齐;1610+ 自有扩展) ========== + RTC_CALL: 1601, // 通话信令统一入口(对应 OpenIM SignalingNotification=1601) + RTC_PARTICIPANT_CONNECTED: 1602, // 通话参与者加入(对应 OpenIM RoomParticipantsConnectedNotification=1602) + RTC_PARTICIPANT_DISCONNECTED: 1603, // 通话参与者离开(对应 OpenIM RoomParticipantsDisconnectedNotification=1603) + // 1604-1609 OpenIM 已用 / 留作扩展,本系统暂不使用 + RTC_CALL_START: 1610, // 通话开始(自有扩展,OpenIM 无;仅群聊;与 END 两段式配对) + RTC_CALL_END: 1611, // 通话结束(自有扩展,OpenIM 无;私聊 / 群聊) // ========== 好友通知(1201-1210 直接复用 OpenIM 段位编号) ========== FRIEND_REQUEST_APPROVED: 1201, // 好友申请被同意 FRIEND_REQUEST_REJECTED: 1202, // 好友申请被拒绝 @@ -89,6 +87,11 @@ export function isFriendChatTip(type: number): boolean { return type === ImMessageType.FRIEND_ADD || type === ImMessageType.FRIEND_DELETE } +/** 判断是否「会话内的通话事件气泡」:RTC_CALL_START / RTC_CALL_END 渲染成灰色提示 */ +export function isRtcCallTip(type: number): boolean { + return type === ImMessageType.RTC_CALL_START || type === ImMessageType.RTC_CALL_END +} + /** * IM 普通消息类型集合(normal vs event 二分;与后端 ImMessageTypeEnum.normal 字段语义一致) * @@ -161,6 +164,44 @@ export function isGroupConversation(type: number | undefined): boolean { return type === ImConversationType.GROUP } +/** IM 通话媒体类型(对齐后端 ImRtcCallMediaTypeEnum) */ +export const ImCallMediaType = { + VOICE: 1, + VIDEO: 2 +} as const + +/** IM 通话状态(对齐后端 ImRtcCallStatusEnum) */ +export const ImCallStatus = { + CREATED: 10, // 创建:私聊等被叫接听;群聊发起人已进房等其他人加入 + RUNNING: 20, // 进行中:第一个非发起人接通后进入 + ENDED: 30 // 已结束 +} as const + +/** IM 通话结束原因(对齐后端 ImRtcCallEndReasonEnum) */ +export const ImCallEndReason = { + HANGUP: 1, // 接通后任一方主动挂断 + REJECT: 2, // 被叫接通前点拒接 + CANCEL: 3, // 主叫接通前主动取消 + BUSY: 5, // 私聊呼叫时对方正忙 + ERROR: 9 // 网络中断 / 设备失败 +} as const + +/** ImCallEndReason 取值类型 */ +export type ImCallEndReasonValue = (typeof ImCallEndReason)[keyof typeof ImCallEndReason] + +/** IM 通话参与者状态(对齐后端 ImRtcParticipantStatusEnum);同时作为 RTC_CALL 信令 status 字段取值 */ +export const ImCallParticipantStatus = { + INVITING: 10, // 来电邀请 + JOINED: 20, // 接听 / 已加入 + REJECTED: 30, // 拒接 + NO_ANSWER: 40, // 主叫取消,被邀请方未应答 + LEFT: 50 // 挂断离开 +} as const + +/** ImCallParticipantStatus 取值类型 */ +export type ImCallParticipantStatusValue = + (typeof ImCallParticipantStatus)[keyof typeof ImCallParticipantStatus] + /** IM WebSocket 外层帧类型(对齐后端 ImPrivateMessageDTO.TYPE / ImGroupMessageDTO.TYPE) */ export const ImWebSocketMessageType = { PRIVATE_MESSAGE: 'im-private-message', // 私聊通道