fix(im): 批量修复 P0 安全边界和通话流程问题

- 拒绝匿名 WebSocket 握手,收紧 RTC 接听和入会忙线校验
- 支持封禁群解散,管理端解散改为独立权限码
- 增加个人表情数量配置、唯一约束和并发重复兜底
- 修复 RTC 异常断开上报、视频远端音频和好友选择大列表渲染
- 让个人表情添加失败透出后端业务错误
- 流转 P0 bug 文档,并按产品取舍记录 apiSecret 默认值不强制拦截
im
YunaiV 2026-05-24 20:21:00 +08:00
parent 00f273ca77
commit 2ede2b371f
8 changed files with 109 additions and 83 deletions

View File

@ -51,6 +51,11 @@ export const unbanManagerGroup = (id: number) => {
return request.put({ url: '/im/manager/group/unban?id=' + id })
}
// 解散群
export const dissolveManagerGroup = (id: number) => {
return request.delete({ url: '/im/manager/group/dissolve?id=' + id })
}
// 获得群成员列表(含已退群成员,由前端按需过滤)
export const getManagerGroupMemberList = (groupId: number) => {
return request.get({ url: '/im/manager/group/member/list?groupId=' + groupId })

View File

@ -1,7 +1,7 @@
<template>
<!--
好友选择面板用于新建群聊 / 邀请好友 / 推荐时创建聊天等好友选择场景
- 搜索 + 字母分桶好友列表圆形勾选
- 搜索 + 好友列表圆形勾选
- 已选数标题 + 已选好友列表按点击顺序
- Panel 不带 el-dialog dialog 由业务壳持有
- 三态语义hide > locked > disabled详见 contract
@ -20,59 +20,49 @@
</el-input>
</div>
<el-scrollbar class="flex-1">
<template v-for="bucket in buckets" :key="bucket.letter">
<!-- 字母分桶 header浅底 + 小字号 -->
<div
class="pt-1 pb-0.5 px-3.5 text-12px text-[var(--el-text-color-secondary)] bg-[var(--el-fill-color-lighter)]"
>
{{ bucket.letter }}
</div>
<div
v-for="friend in bucket.list"
:key="friend.id"
class="flex gap-2.5 items-center px-3 py-2 cursor-pointer hover:bg-[var(--el-fill-color)]"
:class="{
'opacity-60 cursor-not-allowed hover:bg-transparent': isDisabled(friend)
}"
@click="handleToggle(friend)"
>
<!-- 圆形勾选指示器未选灰色空心圆选中实心微信绿 + 白对勾锁定 / 禁用走灰底 -->
<span
class="flex flex-shrink-0 justify-center items-center w-5 h-5 rounded-full transition-colors"
:class="getCheckClass(friend)"
<div class="flex-1 min-h-0">
<PagedScroller v-if="filtered.length > 0" :items="filtered" :page-size="30" item-key="id">
<template #default="{ item }">
<div
:key="(item as FriendLite).id"
class="flex gap-2.5 items-center px-3 py-2 cursor-pointer hover:bg-[var(--el-fill-color)]"
:class="{
'opacity-60 cursor-not-allowed hover:bg-transparent': isDisabled(item as FriendLite)
}"
@click="handleToggle(item as FriendLite)"
>
<Icon
v-if="isSelected(friend) || isLocked(friend)"
icon="ant-design:check-outlined"
:size="12"
color="#fff"
<!-- 圆形勾选指示器未选灰色空心圆选中实心微信绿 + 白对勾锁定 / 禁用走灰底 -->
<span
class="flex flex-shrink-0 justify-center items-center w-5 h-5 rounded-full transition-colors"
:class="getCheckClass(item as FriendLite)"
>
<Icon
v-if="isSelected(item as FriendLite) || isLocked(item as FriendLite)"
icon="ant-design:check-outlined"
:size="12"
color="#fff"
/>
</span>
<UserAvatar
:id="(item as FriendLite).id"
:url="(item as FriendLite).avatar"
:name="(item as FriendLite).nickname"
:size="36"
:clickable="false"
/>
</span>
<UserAvatar
:id="friend.id"
:url="friend.avatar"
:name="friend.nickname"
:size="36"
:clickable="false"
/>
<!-- 行内名字备注优先列表里不重复展示昵称 -->
<span
class="flex-1 min-w-0 overflow-hidden text-sm truncate text-[var(--el-text-color-primary)]"
>
{{ friend.displayName || friend.nickname }}
</span>
</div>
</template>
<!-- 空态 -->
<div
v-if="filtered.length === 0"
class="py-10 text-13px text-center text-[var(--el-text-color-disabled)]"
>
<!-- 行内名字备注优先列表里不重复展示昵称 -->
<span
class="flex-1 min-w-0 overflow-hidden text-sm truncate text-[var(--el-text-color-primary)]"
>
{{ (item as FriendLite).displayName || (item as FriendLite).nickname }}
</span>
</div>
</template>
</PagedScroller>
<div v-else class="py-10 text-13px text-center text-[var(--el-text-color-disabled)]">
{{ keyword ? '没有匹配的好友' : '暂无好友' }}
</div>
</el-scrollbar>
</div>
</div>
<!-- 右栏 -->
@ -137,6 +127,7 @@ import Icon from '@/components/Icon/src/Icon.vue'
import { useMessage } from '@/hooks/web/useMessage'
import UserAvatar from '../user/UserAvatar.vue'
import PagedScroller from '../PagedScroller.vue'
import { useFriendBuckets } from '../../composables/useFriendBuckets'
import { useSelectedItems } from '../../composables/useSelectedItems'
import type { FriendLite } from '../../types'
@ -194,8 +185,8 @@ const candidates = computed(() =>
props.friends.filter((friend) => !hideSet.value.has(friend.id))
)
/** 委托 useFriendBuckets搜索 + 字母分桶共用一套规则 */
const { filtered, buckets } = useFriendBuckets(candidates, keyword)
/** 委托 useFriendBuckets搜索规则复用,左侧列表按滚动分页渲染 */
const { filtered } = useFriendBuckets(candidates, keyword)
/** 已选数 + 已选好友列表:三态优先级 + 顺序拼接由 useSelectedItems 统一承担 */
const { selectedCount, selectedItems: selectedFriends } = useSelectedItems<FriendLite>(
@ -251,4 +242,3 @@ function handleToggle(friend: FriendLite) {
emit('update:selectedIds', next)
}
</script>

View File

@ -369,12 +369,18 @@ function handlePeerDisconnected() {
if (!rtcStore.isActive) {
return
}
const room = rtcStore.call?.room
// RTC_CALL_END WebSocket / endSession RTC_CALL_END
// "" / "" reset toast
setTimeout(() => {
if (!rtcStore.isActive) {
return
}
//
if (room) {
leaveCall(room).catch(() => undefined)
}
//
message.warning('通话已断开')
rtcStore.reset()
}, 100)

View File

@ -75,8 +75,13 @@
<div class="text-[17px] font-medium">{{ peerNickname }}</div>
<div class="text-13px text-white/60">{{ formattedDuration }}</div>
</div>
<audio v-if="remoteAudioStream" ref="remoteAudioRef" autoplay :muted="!speakerEnabled"></audio>
</template>
<audio
v-if="!isGroup && remoteAudioStream"
ref="remoteAudioRef"
autoplay
:muted="!speakerEnabled"
></audio>
</div>
<!-- 底部操作区麦克风 / 扬声器 / 摄像头 / (群聊共享屏幕 / 添加成员) / 挂断 -->

View File

@ -280,6 +280,7 @@ async function onUploadPicked(e: Event) {
return
}
uploading.value = true
let payload: { url: string; width: number; height: number }
try {
// probe + OSS probe
const form = new FormData()
@ -293,13 +294,15 @@ async function onUploadPicked(e: Event) {
ElMessage.error('上传失败')
return
}
const ok = await faceStore.addFaceUserItem({ url, width: size.width, height: size.height })
if (!ok) {
ElMessage.error('添加失败,可能已添加过')
}
payload = { url, width: size.width, height: size.height }
} catch (err) {
console.warn('[IM] 上传个人表情失败', err)
ElMessage.error('上传失败')
uploading.value = false
return
}
try {
await faceStore.addFaceUserItem(payload)
} finally {
uploading.value = false
}

View File

@ -786,7 +786,7 @@ const canAddToFace = computed(() => {
return extractAddableFace(props.message) !== null
})
/** 添加到个人表情:从 message 抽 url + 尺寸 + name 写入个人表情库;幂等失败时返回 false 走 toast 兜底 */
/** 添加到个人表情:从 message 抽 url + 尺寸 + name 写入个人表情库 */
async function handleAddToFace() {
const payload = extractAddableFace(props.message)
if (!payload) {

View File

@ -88,34 +88,28 @@ export const useFaceStore = defineStore('imFace', () => {
* URL FACE_USER_ITEM_DUPLICATED
*
* 1. + 2.
* true / false removeFaceUserItem boolean
*/
async function addFaceUserItem(reqVO: ImFaceUserItemSaveReqVO): Promise<boolean> {
const requestEpoch = storeEpoch
try {
const id = await apiCreateFaceUserItem(reqVO)
if (!id) {
return false
}
// reset 已切账号:旧请求拿到的 id 不能再 unshift 进新账号内存
if (requestEpoch !== storeEpoch) {
return false
}
// id 不在缓存里才插入;服务端唯一约束兜底了 race本地理论上不会拿到重复 id
if (!faceUserItems.value.some((item) => item.id === id)) {
faceUserItems.value.unshift({
id,
url: reqVO.url,
name: reqVO.name,
width: reqVO.width,
height: reqVO.height
})
}
return true
} catch (e) {
console.warn('[IM] 添加个人表情失败', { reqVO }, e)
const id = await apiCreateFaceUserItem(reqVO)
if (!id) {
return false
}
// reset 已切账号:旧请求拿到的 id 不能再 unshift 进新账号内存
if (requestEpoch !== storeEpoch) {
return false
}
// id 不在缓存里才插入;服务端唯一约束兜底了 race本地理论上不会拿到重复 id
if (!faceUserItems.value.some((item) => item.id === id)) {
faceUserItems.value.unshift({
id,
url: reqVO.url,
name: reqVO.name,
width: reqVO.width,
height: reqVO.height
})
}
return true
}
/** 删除个人表情;本地立即移除 */

View File

@ -124,7 +124,7 @@
width="180"
:formatter="dateFormatter"
/>
<el-table-column label="操作" align="center" width="280" fixed="right">
<el-table-column label="操作" align="center" width="340" fixed="right">
<template #default="{ row }">
<el-button
link
@ -159,6 +159,15 @@
>
解封
</el-button>
<el-button
v-if="row.status === CommonStatusEnum.ENABLE"
link
type="danger"
@click="handleDissolve(row)"
v-hasPermi="['im:manager:group:dissolve']"
>
解散
</el-button>
</template>
</el-table-column>
</el-table>
@ -179,6 +188,7 @@
<script lang="ts" setup>
import { DICT_TYPE, getIntDictOptions, getBoolDictOptions } from '@/utils/dict'
import { dateFormatter } from '@/utils/formatTime'
import { CommonStatusEnum } from '@/utils/constants'
import * as ManagerGroupApi from '@/api/im/manager/group'
import UserSelectV2 from '@/views/system/user/components/UserSelectV2.vue'
import GroupDetail from './GroupDetail.vue'
@ -252,6 +262,19 @@ const handleUnban = async (row: ManagerGroupApi.ImManagerGroupVO) => {
} catch {}
}
/** 解散按钮操作 */
const handleDissolve = async (row: ManagerGroupApi.ImManagerGroupVO) => {
try {
//
await message.confirm(`确认解散群「${row.name}」吗?`)
//
await ManagerGroupApi.dissolveManagerGroup(row.id)
message.success('解散成功')
//
await getList()
} catch {}
}
/** 跳转到群聊消息页面,查看该群的对话 */
const goConversation = (row: ManagerGroupApi.ImManagerGroupVO) => {
push({