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 }) 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) => { export const getManagerGroupMemberList = (groupId: number) => {
return request.get({ url: '/im/manager/group/member/list?groupId=' + groupId }) return request.get({ url: '/im/manager/group/member/list?groupId=' + groupId })

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -88,34 +88,28 @@ export const useFaceStore = defineStore('imFace', () => {
* URL FACE_USER_ITEM_DUPLICATED * URL FACE_USER_ITEM_DUPLICATED
* *
* 1. + 2. * 1. + 2.
* true / false removeFaceUserItem boolean
*/ */
async function addFaceUserItem(reqVO: ImFaceUserItemSaveReqVO): Promise<boolean> { async function addFaceUserItem(reqVO: ImFaceUserItemSaveReqVO): Promise<boolean> {
const requestEpoch = storeEpoch const requestEpoch = storeEpoch
try { const id = await apiCreateFaceUserItem(reqVO)
const id = await apiCreateFaceUserItem(reqVO) if (!id) {
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)
return false 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" width="180"
:formatter="dateFormatter" :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 }"> <template #default="{ row }">
<el-button <el-button
link link
@ -159,6 +159,15 @@
> >
解封 解封
</el-button> </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> </template>
</el-table-column> </el-table-column>
</el-table> </el-table>
@ -179,6 +188,7 @@
<script lang="ts" setup> <script lang="ts" setup>
import { DICT_TYPE, getIntDictOptions, getBoolDictOptions } from '@/utils/dict' import { DICT_TYPE, getIntDictOptions, getBoolDictOptions } from '@/utils/dict'
import { dateFormatter } from '@/utils/formatTime' import { dateFormatter } from '@/utils/formatTime'
import { CommonStatusEnum } from '@/utils/constants'
import * as ManagerGroupApi from '@/api/im/manager/group' import * as ManagerGroupApi from '@/api/im/manager/group'
import UserSelectV2 from '@/views/system/user/components/UserSelectV2.vue' import UserSelectV2 from '@/views/system/user/components/UserSelectV2.vue'
import GroupDetail from './GroupDetail.vue' import GroupDetail from './GroupDetail.vue'
@ -252,6 +262,19 @@ const handleUnban = async (row: ManagerGroupApi.ImManagerGroupVO) => {
} catch {} } 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) => { const goConversation = (row: ManagerGroupApi.ImManagerGroupVO) => {
push({ push({