fix(im): 强化好友关系、消息历史和前端交互

- 校验群资料字段长度,并在同意好友申请时复验双方用户
- 仅向双向有效好友推送资料更新通知
- WebSocket 推送收件人去重,并忽略空用户编号
- 群聊和私聊历史保留撤回消息记录
- 校验群通话排除发起人后仍需存在被邀请人
- 统一 IM 前端接口参数传递方式
- 抽取全局 URL 安全打开工具,并复用到消息预览
- 防止好友申请同意和拒绝按钮重复操作
- 补充好友、消息、RTC、WebSocket 相关测试
im
YunaiV 2026-05-24 21:24:15 +08:00
parent 2ede2b371f
commit 309a4bf4d0
18 changed files with 55 additions and 30 deletions

View File

@ -14,5 +14,5 @@ export interface ImChannelMaterialRespVO {
// 获取频道素材详情;用于客户端点击图文卡片渲染详情页
export const getChannelMaterial = (id: number) => {
return request.get<ImChannelMaterialRespVO>({ url: '/im/channel/material/get?id=' + id })
return request.get<ImChannelMaterialRespVO>({ url: '/im/channel/material/get', params: { id } })
}

View File

@ -29,5 +29,5 @@ export const createFaceUserItem = (data: ImFaceUserItemSaveReqVO) => {
// 删除个人表情
export const deleteFaceUserItem = (id: number) => {
return request.delete({ url: '/im/face-user-item/delete?id=' + id })
return request.delete({ url: '/im/face-user-item/delete', params: { id } })
}

View File

@ -17,7 +17,7 @@ export const getManagerChannelPage = (params: PageParam) => {
// 获得频道详情
export const getManagerChannel = (id: number) => {
return request.get({ url: '/im/manager/channel/get?id=' + id })
return request.get({ url: '/im/manager/channel/get', params: { id } })
}
// 新增频道
@ -32,7 +32,7 @@ export const updateManagerChannel = (data: ImManagerChannelVO) => {
// 删除频道
export const deleteManagerChannel = (id: number) => {
return request.delete({ url: '/im/manager/channel/delete?id=' + id })
return request.delete({ url: '/im/manager/channel/delete', params: { id } })
}
// 获得启用的频道精简列表(表单选择用)

View File

@ -21,13 +21,14 @@ export const getManagerChannelMaterialPage = (params: PageParam) => {
// 获得指定频道下的素材精简列表
export const getSimpleManagerChannelMaterialList = (channelId: number) => {
return request.get({
url: '/im/manager/channel-material/simple-list?channelId=' + channelId
url: '/im/manager/channel-material/simple-list',
params: { channelId }
})
}
// 获得素材详情
export const getManagerChannelMaterial = (id: number) => {
return request.get({ url: '/im/manager/channel-material/get?id=' + id })
return request.get({ url: '/im/manager/channel-material/get', params: { id } })
}
// 新增素材
@ -42,5 +43,5 @@ export const updateManagerChannelMaterial = (data: ImManagerChannelMaterialVO) =
// 删除素材
export const deleteManagerChannelMaterial = (id: number) => {
return request.delete({ url: '/im/manager/channel-material/delete?id=' + id })
return request.delete({ url: '/im/manager/channel-material/delete', params: { id } })
}

View File

@ -25,7 +25,7 @@ export const sendManagerChannelMessage = (data: ImManagerChannelMessageSendReqVO
// 删除频道消息
export const deleteManagerChannelMessage = (id: number) => {
return request.delete({ url: '/im/manager/channel-message/delete?id=' + id })
return request.delete({ url: '/im/manager/channel-message/delete', params: { id } })
}
// 获得频道消息分页

View File

@ -19,7 +19,7 @@ export const getManagerFacePackItemPage = (params: PageParam) => {
// 获得表情详情
export const getManagerFacePackItem = (id: number) => {
return request.get({ url: '/im/manager/face-pack-item/get?id=' + id })
return request.get({ url: '/im/manager/face-pack-item/get', params: { id } })
}
// 新增表情
@ -34,7 +34,7 @@ export const updateManagerFacePackItem = (data: ImManagerFacePackItemVO) => {
// 删除表情
export const deleteManagerFacePackItem = (id: number) => {
return request.delete({ url: '/im/manager/face-pack-item/delete?id=' + id })
return request.delete({ url: '/im/manager/face-pack-item/delete', params: { id } })
}
// 批量删除表情

View File

@ -16,7 +16,7 @@ export const getManagerFacePackPage = (params: PageParam) => {
// 获得表情包详情
export const getManagerFacePack = (id: number) => {
return request.get({ url: '/im/manager/face-pack/get?id=' + id })
return request.get({ url: '/im/manager/face-pack/get', params: { id } })
}
// 新增表情包
@ -31,7 +31,7 @@ export const updateManagerFacePack = (data: ImManagerFacePackVO) => {
// 删除表情包
export const deleteManagerFacePack = (id: number) => {
return request.delete({ url: '/im/manager/face-pack/delete?id=' + id })
return request.delete({ url: '/im/manager/face-pack/delete', params: { id } })
}
// 批量删除表情包

View File

@ -18,5 +18,5 @@ export const getManagerFaceUserItemPage = (params: PageParam) => {
// 删除用户表情
export const deleteManagerFaceUserItem = (id: number) => {
return request.delete({ url: '/im/manager/face-user-item/delete?id=' + id })
return request.delete({ url: '/im/manager/face-user-item/delete', params: { id } })
}

View File

@ -38,7 +38,7 @@ export const getManagerGroupPage = (params: PageParam) => {
// 获得群详情
export const getManagerGroup = (id: number) => {
return request.get({ url: '/im/manager/group/get?id=' + id })
return request.get({ url: '/im/manager/group/get', params: { id } })
}
// 封禁群
@ -48,15 +48,15 @@ export const banManagerGroup = (data: { id: number; reason: string }) => {
// 解封群
export const unbanManagerGroup = (id: number) => {
return request.put({ url: '/im/manager/group/unban?id=' + id })
return request.put({ url: '/im/manager/group/unban', params: { id } })
}
// 解散群
export const dissolveManagerGroup = (id: number) => {
return request.delete({ url: '/im/manager/group/dissolve?id=' + id })
return request.delete({ url: '/im/manager/group/dissolve', params: { id } })
}
// 获得群成员列表(含已退群成员,由前端按需过滤)
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', params: { groupId } })
}

View File

@ -25,5 +25,5 @@ export const getManagerGroupMessagePage = (params: PageParam) => {
// 获得群聊消息详情
export const getManagerGroupMessage = (id: number) => {
return request.get({ url: '/im/manager/message/group/get?id=' + id })
return request.get({ url: '/im/manager/message/group/get', params: { id } })
}

View File

@ -21,5 +21,5 @@ export const getManagerPrivateMessagePage = (params: PageParam) => {
// 获得私聊消息详情
export const getManagerPrivateMessage = (id: number) => {
return request.get({ url: '/im/manager/message/private/get?id=' + id })
return request.get({ url: '/im/manager/message/private/get', params: { id } })
}

View File

@ -36,5 +36,5 @@ export const getManagerRtcCallPage = (params: PageParam) => {
// 获得通话参与者列表
export const getManagerRtcCallParticipantList = (id: number) => {
return request.get({ url: '/im/manager/rtc/participant-list?id=' + id })
return request.get({ url: '/im/manager/rtc/participant-list', params: { id } })
}

View File

@ -16,7 +16,7 @@ export const getManagerSensitiveWordPage = (params: PageParam) => {
// 获得敏感词详情
export const getManagerSensitiveWord = (id: number) => {
return request.get({ url: '/im/manager/sensitive-word/get?id=' + id })
return request.get({ url: '/im/manager/sensitive-word/get', params: { id } })
}
// 新增敏感词
@ -31,7 +31,7 @@ export const updateManagerSensitiveWord = (data: ImManagerSensitiveWordVO) => {
// 删除敏感词
export const deleteManagerSensitiveWord = (id: number) => {
return request.delete({ url: '/im/manager/sensitive-word/delete?id=' + id })
return request.delete({ url: '/im/manager/sensitive-word/delete', params: { id } })
}
// 批量删除敏感词

22
src/utils/url.ts Normal file
View File

@ -0,0 +1,22 @@
const OPENABLE_PROTOCOLS = new Set(['http:', 'https:', 'blob:'])
/** 判断 URL 是否允许在新窗口打开 */
export function isOpenableUrl(url?: string | null): boolean {
if (!url) {
return false
}
try {
const parsed = new URL(url, window.location.origin)
return OPENABLE_PROTOCOLS.has(parsed.protocol)
} catch {
return false
}
}
/** 安全打开 URL */
export function openSafeUrl(url?: string | null): void {
if (!url || !isOpenableUrl(url)) {
return
}
window.open(url, '_blank', 'noopener,noreferrer')
}

View File

@ -69,8 +69,10 @@
</el-button>
<!-- 别人加我 + 等待中同意 / 拒绝 -->
<template v-if="!iSentIt && request.handleResult === ImFriendRequestHandleResult.UNHANDLED">
<el-button @click="handleRefuse" :loading="refusing">拒绝</el-button>
<el-button type="primary" @click="handleAgree" :loading="agreeing"> 同意 </el-button>
<el-button @click="handleRefuse" :loading="refusing" :disabled="processing">拒绝</el-button>
<el-button type="primary" @click="handleAgree" :loading="agreeing" :disabled="processing">
同意
</el-button>
</template>
<!-- 已拒绝占位禁用按钮 -->
<el-button v-if="refused" disabled>已拒绝</el-button>

View File

@ -80,6 +80,7 @@ import { parseMessage, type MaterialMessage } from '@/views/im/utils/message'
import { useConversationStore } from '@/views/im/home/store/conversationStore'
import { useChannelStore } from '@/views/im/home/store/channelStore'
import { ImConversationType } from '@/views/im/utils/constants'
import { openSafeUrl } from '@/utils/url'
const props = defineProps<{
content: string
@ -110,8 +111,7 @@ const detailHtml = ref('')
/** 点击行为url 非空跳外链;为空则按 payload.materialId 拉富文本正文,全屏 dialog 渲染 */
const onClick = async () => {
if (payload.value.url) {
// noopener,noreferrer window.opener / referrer
window.open(payload.value.url, '_blank', 'noopener,noreferrer')
openSafeUrl(payload.value.url)
return
}
if (!payload.value.materialId) {

View File

@ -184,6 +184,7 @@ import {
type VideoMessage
} from '@/views/im/utils/message'
import { summarizeMessageContent } from '@/views/im/utils/conversation'
import { openSafeUrl } from '@/utils/url'
import CardBubble from '@/views/im/home/components/card/CardBubble.vue'
import TipSegments from './TipSegments.vue'
import { useVoicePlayer } from '@/views/im/home/composables/useVoicePlayer'
@ -322,8 +323,7 @@ function handleFileClick() {
if (isUploading.value || !filePayload.value?.url) {
return
}
// noopener,noreferrer window.opener Tabnabbing
window.open(filePayload.value.url, '_blank', 'noopener,noreferrer')
openSafeUrl(filePayload.value.url)
}
/** 语音点击:托管给 useVoicePlayer 全局互斥播放,新点的语音会停掉旧的 */

View File

@ -164,6 +164,7 @@ import {
type MergeMessage
} from '@/views/im/utils/message'
import { buildFacePreviewText, summarizeMessageContent } from '@/views/im/utils/conversation'
import { openSafeUrl } from '@/utils/url'
defineOptions({ name: 'ImMessageContentPreview' })
@ -227,8 +228,7 @@ const mergePreviewLines = computed(() => {
function openVideo() {
const url = videoPayload.value?.url
if (url) {
// noopener,noreferrer window.opener Tabnabbing
window.open(url, '_blank', 'noopener,noreferrer')
openSafeUrl(url)
}
}