fix: 修复 IM 申请与 RTC 边界问题

- 复用好友申请、群申请和群邀请唯一键冲突后的旧记录,并补充测试
- 收敛 RTC 旁观者加入、忙线校验、追加邀请超员和群通话通知逻辑
- 为 RTC 参与者补充房间用户唯一约束与 MySQL 迁移
- 统一群本体管理请求的 id 字段,并同步前端调用
- 修复前端来电活跃态守卫和 LiveKit 重连前断开旧房间
- 清理群成员通知基类命名和相关注释
im
YunaiV 2026-05-25 20:54:11 +08:00
parent a4dfb717aa
commit e1b8370267
9 changed files with 56 additions and 21 deletions

View File

@ -20,7 +20,7 @@ export interface ImGroupRespVO {
// 群消息置顶 / 取消置顶 Request VO // 群消息置顶 / 取消置顶 Request VO
export interface ImGroupMessagePinReqVO { export interface ImGroupMessagePinReqVO {
groupId: number // 群编号 id: number // 群编号
messageId: number // 消息编号 messageId: number // 消息编号
} }
@ -42,16 +42,35 @@ export interface ImGroupUpdateReqVO {
// 添加 / 撤销群管理员 Request VO // 添加 / 撤销群管理员 Request VO
export interface ImGroupAdminReqVO { export interface ImGroupAdminReqVO {
groupId: number // 群编号 id: number // 群编号
userIds: number[] // 目标用户编号列表 userIds: number[] // 目标用户编号列表
} }
// 群主转让 Request VO // 群主转让 Request VO
export interface ImGroupTransferOwnerReqVO { export interface ImGroupTransferOwnerReqVO {
groupId: number // 群编号 id: number // 群编号
newOwnerUserId: number // 新群主用户编号 newOwnerUserId: number // 新群主用户编号
} }
// 全群禁言 / 取消 Request VO
export interface ImGroupMuteAllReqVO {
id: number // 群编号
mutedAll: boolean // 是否全群禁言
}
// 成员禁言 Request VO
export interface ImGroupMuteMemberReqVO {
id: number // 群编号
userId: number // 被禁言的用户编号
mutedSeconds: number // 禁言时长0 表示永久禁言
}
// 取消成员禁言 Request VO
export interface ImGroupCancelMuteMemberReqVO {
id: number // 群编号
userId: number // 被取消禁言的用户编号
}
// 获得当前登录用户的群列表 // 获得当前登录用户的群列表
export const getMyGroupList = () => { export const getMyGroupList = () => {
return request.get<ImGroupRespVO[]>({ url: '/im/group/list' }) return request.get<ImGroupRespVO[]>({ url: '/im/group/list' })
@ -103,16 +122,16 @@ export const unpinGroupMessage = (data: ImGroupMessagePinReqVO) => {
} }
// 全群禁言 / 取消(仅群主 / 管理员可调) // 全群禁言 / 取消(仅群主 / 管理员可调)
export const muteAll = (data: { groupId: number; mutedAll: boolean }) => { export const muteAll = (data: ImGroupMuteAllReqVO) => {
return request.put<boolean>({ url: '/im/group/mute-all', data }) return request.put<boolean>({ url: '/im/group/mute-all', data })
} }
// 禁言成员 // 禁言成员
export const muteMember = (data: { groupId: number; userId: number; mutedSeconds: number }) => { export const muteMember = (data: ImGroupMuteMemberReqVO) => {
return request.put<boolean>({ url: '/im/group/mute-member', data }) return request.put<boolean>({ url: '/im/group/mute-member', data })
} }
// 取消成员禁言 // 取消成员禁言
export const cancelMuteMember = (data: { groupId: number; userId: number }) => { export const cancelMuteMember = (data: ImGroupCancelMuteMemberReqVO) => {
return request.put<boolean>({ url: '/im/group/cancel-mute-member', data }) return request.put<boolean>({ url: '/im/group/cancel-mute-member', data })
} }

View File

@ -98,10 +98,10 @@ async function handleOk() {
submitting.value = true submitting.value = true
try { try {
if (addedIds.length > 0) { if (addedIds.length > 0) {
await addGroupAdmin({ groupId: groupId.value, userIds: addedIds }) await addGroupAdmin({ id: groupId.value, userIds: addedIds })
} }
if (removedIds.length > 0) { if (removedIds.length > 0) {
await removeGroupAdmin({ groupId: groupId.value, userIds: removedIds }) await removeGroupAdmin({ id: groupId.value, userIds: removedIds })
} }
message.success(`已更新群管理员(新增 ${addedIds.length} 位,撤销 ${removedIds.length} 位)`) message.success(`已更新群管理员(新增 ${addedIds.length} 位,撤销 ${removedIds.length} 位)`)
emit('reload') emit('reload')

View File

@ -81,7 +81,7 @@ async function handleConfirm() {
loading.value = true loading.value = true
try { try {
await muteMember({ await muteMember({
groupId: groupId.value, id: groupId.value,
userId: userId.value, userId: userId.value,
mutedSeconds: selected.value mutedSeconds: selected.value
}) })

View File

@ -101,7 +101,7 @@ async function handleOk() {
submitting.value = true submitting.value = true
try { try {
await transferGroupOwner({ await transferGroupOwner({
groupId: groupId.value, id: groupId.value,
newOwnerUserId: newOwner.value.userId newOwnerUserId: newOwner.value.userId
}) })
message.success('群主转让成功') message.success('群主转让成功')
@ -121,4 +121,3 @@ async function handleOk() {
@include picker.styles; @include picker.styles;
} }
</style> </style>

View File

@ -50,6 +50,10 @@ export function useLiveKitRoom() {
/** 连接 LiveKit Serveraudio / video 控制初始默认开关 */ /** 连接 LiveKit Serveraudio / video 控制初始默认开关 */
async function connect(url: string, token: string, opts: { audio?: boolean; video?: boolean }) { async function connect(url: string, token: string, opts: { audio?: boolean; video?: boolean }) {
// 新连接前先断开旧 Room保留本次注册的事件回调
if (_room.value) {
await disconnectRoom(false)
}
const r = new Room({ const r = new Room({
// 按格子尺寸自动选 simulcast 层 // 按格子尺寸自动选 simulcast 层
adaptiveStream: true, adaptiveStream: true,
@ -267,18 +271,23 @@ export function useLiveKitRoom() {
return stream return stream
} }
/** 主动断开;通话结束统一调 */ /** 断开当前 RoomclearHandlers 为 true 时同步清理外部注册的事件回调 */
async function disconnect() { async function disconnectRoom(clearHandlers: boolean) {
disconnectedHandlers.clear() // 清理通话结束后不再复用的订阅回调
participantConnectedHandlers.clear() if (clearHandlers) {
participantDisconnectedHandlers.clear() disconnectedHandlers.clear()
participantConnectedHandlers.clear()
participantDisconnectedHandlers.clear()
}
// 清理音视频轨道缓存
streamCache.clear() streamCache.clear()
if (_room.value) { if (_room.value) {
// 断开前先卸事件,避免 disconnect 期间 ParticipantDisconnected / TrackUnsubscribed 仍触发 syncRemotes // 卸载 Room 事件并断开连接
_room.value.removeAllListeners() _room.value.removeAllListeners()
await _room.value.disconnect() await _room.value.disconnect()
_room.value = null _room.value = null
} }
// 重置连接和设备状态
localParticipant.value = null localParticipant.value = null
remoteParticipants.value = [] remoteParticipants.value = []
isConnected.value = false isConnected.value = false
@ -289,6 +298,11 @@ export function useLiveKitRoom() {
screenShareEnabled.value = false screenShareEnabled.value = false
} }
/** 主动断开;通话结束统一调 */
async function disconnect() {
await disconnectRoom(true)
}
return { return {
room, room,
localParticipant, localParticipant,

View File

@ -649,7 +649,7 @@ async function onMuteAllChange(value: boolean | string | number) {
return return
} }
const newValue = !!value const newValue = !!value
await muteAll({ groupId: props.group.id, mutedAll: newValue }) await muteAll({ id: props.group.id, mutedAll: newValue })
message.success(newValue ? '已开启全群禁言' : '已关闭全群禁言') message.success(newValue ? '已开启全群禁言' : '已关闭全群禁言')
emit('reload') emit('reload')
} }

View File

@ -147,7 +147,7 @@ async function handleRemove(msg: Message) {
} }
removingId.value = msg.id removingId.value = msg.id
try { try {
await apiUnpinGroupMessage({ groupId: group.value.id, messageId: msg.id }) await apiUnpinGroupMessage({ id: group.value.id, messageId: msg.id })
message.success('已取消置顶') message.success('已取消置顶')
} finally { } finally {
removingId.value = null removingId.value = null

View File

@ -875,7 +875,7 @@ async function handlePin() {
cancelButtonText: '取消', cancelButtonText: '取消',
type: 'warning' type: 'warning'
}) })
await apiPinGroupMessage({ groupId: group.id, messageId: props.message.id }) await apiPinGroupMessage({ id: group.id, messageId: props.message.id })
successMessage('已置顶') successMessage('已置顶')
} catch {} } catch {}
} }
@ -1007,7 +1007,7 @@ async function handleUnmute() {
} }
try { try {
await confirmDialog('确定解除该成员的禁言吗?', '解除禁言') await confirmDialog('确定解除该成员的禁言吗?', '解除禁言')
await cancelMuteMember({ groupId: group.id, userId: props.message.senderId }) await cancelMuteMember({ id: group.id, userId: props.message.senderId })
successMessage('已解除禁言') successMessage('已解除禁言')
emit('reload') emit('reload')
} catch {} } catch {}

View File

@ -163,6 +163,9 @@ export const useRtcStore = defineStore('imRtc', () => {
/** 被叫收到来电;切到 INCOMING接收 RTC_CALL(INVITE) payload */ /** 被叫收到来电;切到 INCOMING接收 RTC_CALL(INVITE) payload */
function showIncoming(payload: ImRtcCallNotification) { function showIncoming(payload: ImRtcCallNotification) {
if (isActive.value) {
return
}
incomingPayload.value = payload incomingPayload.value = payload
stage.value = ImRtcCallStage.INCOMING stage.value = ImRtcCallStage.INCOMING
// 按 inviter 兜底首次填充胶囊条 // 按 inviter 兜底首次填充胶囊条