feat(im): 修一批上传安全与群聊交互问题

- 限制消息媒体上传大小,并让视频独立上传路径复用同一校验
- 禁止发送可执行 / 脚本类文件扩展名
- 切账号时废弃好友 store 未返回请求
- 多选转发过滤撤回 / 系统类消息
- 邀请群成员时前端拦截人数上限
- 允许群管理员 @ 所有人
im
YunaiV 2026-05-21 17:31:46 +08:00
parent fead282395
commit 5a983bb1eb
7 changed files with 147 additions and 24 deletions

View File

@ -39,6 +39,7 @@ import { useFriendStore } from '../../store/friendStore'
import { useGroupStore } from '../../store/groupStore' import { useGroupStore } from '../../store/groupStore'
import { useUserStore } from '@/store/modules/user' import { useUserStore } from '@/store/modules/user'
import { ImGroupMemberRole } from '@/views/im/utils/constants' import { ImGroupMemberRole } from '@/views/im/utils/constants'
import { GROUP_MAX_MEMBER } from '@/views/im/utils/config'
import FriendPickerPanel from '../picker/FriendPickerPanel.vue' import FriendPickerPanel from '../picker/FriendPickerPanel.vue'
import type { GroupMemberLite } from './GroupMember.vue' import type { GroupMemberLite } from './GroupMember.vue'
@ -114,8 +115,18 @@ const willGoApproval = computed(() => {
return myRole !== ImGroupMemberRole.ADMIN return myRole !== ImGroupMemberRole.ADMIN
}) })
/** 添加按钮可点:至少有 1 个新邀请的好友 */ /** 当前群已启用成员数DISABLE 即退群 / 被踢不计入),用于上限判定 */
const canSubmit = computed(() => selectedIds.value.length > 0) const activeMemberCount = computed(
() => members.value.filter((member) => member.status !== CommonStatusEnum.DISABLE).length
)
/** 邀请后群总人数若超 GROUP_MAX_MEMBER前端先拦activeMemberCount + selectedIds.length 即邀请后的成员数 */
const willExceedLimit = computed(
() => activeMemberCount.value + selectedIds.value.length > GROUP_MAX_MEMBER
)
/** 添加按钮可点:至少有 1 个新邀请的好友 + 不超群人数上限 */
const canSubmit = computed(() => selectedIds.value.length > 0 && !willExceedLimit.value)
/** 邀请入群:调 /im/group/invite成功后 emit reload 让父侧刷新群成员 */ /** 邀请入群:调 /im/group/invite成功后 emit reload 让父侧刷新群成员 */
async function handleOk() { async function handleOk() {
@ -126,6 +137,11 @@ async function handleOk() {
if (memberUserIds.length === 0) { if (memberUserIds.length === 0) {
return return
} }
// canSubmit await
if (activeMemberCount.value + memberUserIds.length > GROUP_MAX_MEMBER) {
message.warning(`群成员上限为 ${GROUP_MAX_MEMBER}`)
return
}
submitting.value = true submitting.value = true
try { try {
await inviteGroupMember({ groupId: groupId.value, memberUserIds }) await inviteGroupMember({ groupId: groupId.value, memberUserIds })

View File

@ -1,10 +1,17 @@
import { updateFile } from '@/api/infra/file' import { updateFile } from '@/api/infra/file'
import { useUserStore } from '@/store/modules/user' import { useUserStore } from '@/store/modules/user'
import { useMessage } from '@/hooks/web/useMessage'
import { useConversationStore } from '../store/conversationStore' import { useConversationStore } from '../store/conversationStore'
import { useMessageSender } from './useMessageSender' import { useMessageSender } from './useMessageSender'
import { useMuteOverlay } from './useMuteOverlay' import { useMuteOverlay } from './useMuteOverlay'
import { ImMessageStatus, ImMessageType } from '../../utils/constants' import { ImMessageStatus, ImMessageType } from '../../utils/constants'
import {
MESSAGE_FILE_MAX_MB,
MESSAGE_IMAGE_MAX_MB,
MESSAGE_VIDEO_MAX_MB,
MESSAGE_VOICE_MAX_MB
} from '../../utils/config'
import { getConversationKey } from '../../utils/conversation' import { getConversationKey } from '../../utils/conversation'
import { import {
BLOB_URL_PREFIX, BLOB_URL_PREFIX,
@ -113,10 +120,41 @@ export interface UploadAndSendMediaOptions {
* *
* FAILEDMessageItem _localFile * FAILEDMessageItem _localFile
*/ */
/** 按消息类型映射体积上限MB未识别类型返回 0 表示不限 */
function resolveMediaMaxMb(type: number): number {
switch (type) {
case ImMessageType.IMAGE:
return MESSAGE_IMAGE_MAX_MB
case ImMessageType.VIDEO:
return MESSAGE_VIDEO_MAX_MB
case ImMessageType.VOICE:
return MESSAGE_VOICE_MAX_MB
case ImMessageType.FILE:
return MESSAGE_FILE_MAX_MB
default:
return 0
}
}
/** 校验媒体文件大小是否超过消息类型上限;超限触发 warn 并返回 false调用方不应进入占位 / 上传链路 */
export function ensureMediaSizeWithinLimit(
file: File,
type: number,
warn: (text: string) => void
): boolean {
const maxMb = resolveMediaMaxMb(type)
if (maxMb && file.size > maxMb * 1024 * 1024) {
warn(`文件大小超过上限 ${maxMb}MB请压缩后再发`)
return false
}
return true
}
export const useMediaUploader = () => { export const useMediaUploader = () => {
const conversationStore = useConversationStore() const conversationStore = useConversationStore()
const userStore = useUserStore() const userStore = useUserStore()
const muteOverlay = useMuteOverlay() const muteOverlay = useMuteOverlay()
const message = useMessage()
const { sendRaw } = useMessageSender() const { sendRaw } = useMessageSender()
/** /**
@ -279,6 +317,10 @@ export const useMediaUploader = () => {
console.warn('[IM] uploadAndSendMedia 收到未注册的媒体类型', { type: opts.type }) console.warn('[IM] uploadAndSendMedia 收到未注册的媒体类型', { type: opts.type })
return '' return ''
} }
// 体积上限拦截:大文件浏览器内截帧 / 解码可致 OOM超限直接 warning不进入占位 / 上传链路
if (!ensureMediaSizeWithinLimit(opts.file, opts.type, message.warning)) {
return ''
}
const startKey = getConversationKey(conversation) const startKey = getConversationKey(conversation)
const context = opts.context ?? {} const context = opts.context ?? {}
const buildContent = (url: string): string => const buildContent = (url: string): string =>

View File

@ -79,7 +79,7 @@ const props = withDefaults(
position: { x: number; top?: number; bottom?: number } position: { x: number; top?: number; bottom?: number }
members: GroupMemberLite[] // members: GroupMemberLite[] //
searchText?: string // @ searchText?: string // @
ownerId?: number // id"" canAtAll?: boolean // 当前用户是否能 @ 全员(群主 / 管理员父组件按角色算好传入
}>(), }>(),
{ {
searchText: '', searchText: '',
@ -99,19 +99,14 @@ const activeIdx = ref(0)
/** 当前登录用户 id成员列表过滤掉自己 */ /** 当前登录用户 id成员列表过滤掉自己 */
const selfUserId = computed(() => Number(userStore.getUser?.id) || 0) const selfUserId = computed(() => Number(userStore.getUser?.id) || 0)
/** 是否群主(只有群主能 @ 所有人,对齐微信) */
const isOwner = computed(() => {
return props.ownerId != null && props.ownerId === selfUserId.value
})
/** /**
* 虚拟"所有人"群主 + 关键字命中"所有人"前缀时存在 * 虚拟"所有人"群主 / 管理员canAtAll=true+ 关键字命中"所有人"前缀时存在
* *
* MessageInput token data-id 收集 atUserIds不依赖文案字符串 * MessageInput token data-id 收集 atUserIds不依赖文案字符串
* 这里的 userId / 文案都从 im/utils/constants 避免散落 * 这里的 userId / 文案都从 im/utils/constants 避免散落
*/ */
const allItem = computed<GroupMemberLite | null>(() => { const allItem = computed<GroupMemberLite | null>(() => {
if (!isOwner.value) { if (!props.canAtAll) {
return null return null
} }
if (!IM_AT_ALL_NICKNAME.startsWith(props.searchText)) { if (!IM_AT_ALL_NICKNAME.startsWith(props.searchText)) {

View File

@ -148,7 +148,7 @@
:position="mentionPosition" :position="mentionPosition"
:members="groupMembers" :members="groupMembers"
:search-text="mentionSearchText" :search-text="mentionSearchText"
:owner-id="groupOwnerId" :can-at-all="canAtAll"
@select="onMentionSelect" @select="onMentionSelect"
/> />
@ -169,12 +169,14 @@ import { useGroupStore } from '@/views/im/home/store/groupStore'
import { useFriendStore } from '@/views/im/home/store/friendStore' import { useFriendStore } from '@/views/im/home/store/friendStore'
import { useDraftStore } from '@/views/im/home/store/draftStore' import { useDraftStore } from '@/views/im/home/store/draftStore'
import { getMemberDisplayName } from '@/views/im/utils/user' import { getMemberDisplayName } from '@/views/im/utils/user'
import { useMessage } from '@/hooks/web/useMessage'
import { useUserStore } from '@/store/modules/user'
import { useMessageSender } from '@/views/im/home/composables/useMessageSender' import { useMessageSender } from '@/views/im/home/composables/useMessageSender'
import { useMediaUploader } from '@/views/im/home/composables/useMediaUploader' import { ensureMediaSizeWithinLimit, useMediaUploader } from '@/views/im/home/composables/useMediaUploader'
import { useMuteOverlay } from '@/views/im/home/composables/useMuteOverlay' import { useMuteOverlay } from '@/views/im/home/composables/useMuteOverlay'
import { getConversationKey } from '@/views/im/utils/conversation' import { getConversationKey } from '@/views/im/utils/conversation'
import { ImConversationType, ImMessageType } from '@/views/im/utils/constants' import { ImConversationType, ImGroupMemberRole, ImMessageType } from '@/views/im/utils/constants'
import { MESSAGE_GROUP_READ_ENABLED } from '@/views/im/utils/config' import { DANGEROUS_FILE_EXTENSIONS, MESSAGE_GROUP_READ_ENABLED } from '@/views/im/utils/config'
import { import {
serializeMessage, serializeMessage,
type FaceMessage, type FaceMessage,
@ -195,6 +197,8 @@ const conversationStore = useConversationStore()
const groupStore = useGroupStore() const groupStore = useGroupStore()
const friendStore = useFriendStore() const friendStore = useFriendStore()
const draftStore = useDraftStore() const draftStore = useDraftStore()
const userStore = useUserStore()
const message = useMessage()
const { send, sendRaw } = useMessageSender() const { send, sendRaw } = useMessageSender()
const { const {
uploadAndSendMedia, uploadAndSendMedia,
@ -634,12 +638,24 @@ const groupMembers = computed<GroupMemberLite[]>(() => {
}) })
}) })
const groupOwnerId = computed<number | undefined>(() => { /** 当前用户是否能 @ 全员;群主 + 管理员都允许(对齐微信 PCadmin 也能 @ 所有人) */
const canAtAll = computed<boolean>(() => {
const conversation = conversationStore.activeConversation const conversation = conversationStore.activeConversation
if (!conversation || conversation.type !== ImConversationType.GROUP) { if (!conversation || conversation.type !== ImConversationType.GROUP) {
return undefined return false
} }
return groupStore.getGroup(conversation.targetId)?.ownerUserId const group = groupStore.getGroup(conversation.targetId)
if (!group) {
return false
}
const myId = Number(userStore.getUser?.id) || 0
if (!myId) {
return false
}
if (group.ownerUserId === myId) {
return true
}
return group.members?.find((member) => member.userId === myId)?.role === ImGroupMemberRole.ADMIN
}) })
const mentionVisible = ref(false) const mentionVisible = ref(false)
@ -849,6 +865,12 @@ async function uploadAndSendImage(file: File) {
/** 上传并发送 FILE 消息payload 由 mediaTypeHandlers[FILE] 自动拼 url + name + size */ /** 上传并发送 FILE 消息payload 由 mediaTypeHandlers[FILE] 自动拼 url + name + size */
async function uploadAndSendFile(file: File) { async function uploadAndSendFile(file: File) {
// "." / ""
const extension = file.name.split('.').pop()?.toLowerCase() ?? ''
if (extension && DANGEROUS_FILE_EXTENSIONS.includes(extension)) {
message.warning(`不允许发送 .${extension} 类型文件`)
return
}
const context = prepareMediaUpload() const context = prepareMediaUpload()
if (!context) { if (!context) {
return return
@ -1007,6 +1029,9 @@ async function probeVideoFile(file: File): Promise<VideoProbe> {
* 4. 视频链路耗时长上传期间用户切会话则放弃发送避免落到错误会话里切走再切回来不算变化key 仍相等 * 4. 视频链路耗时长上传期间用户切会话则放弃发送避免落到错误会话里切走再切回来不算变化key 仍相等
*/ */
async function uploadAndSendVideo(file: File) { async function uploadAndSendVideo(file: File) {
if (!ensureMediaSizeWithinLimit(file, ImMessageType.VIDEO, message.warning)) {
return
}
const context = prepareMediaUpload() const context = prepareMediaUpload()
if (!context) { if (!context) {
return return

View File

@ -52,7 +52,7 @@ import { useMessage } from '@/hooks/web/useMessage'
import { useConversationStore } from '@/views/im/home/store/conversationStore' import { useConversationStore } from '@/views/im/home/store/conversationStore'
import { useMessageMultiSelect } from '@/views/im/home/composables/useMessageMultiSelect' import { useMessageMultiSelect } from '@/views/im/home/composables/useMessageMultiSelect'
import { ImForwardMode } from '@/views/im/utils/constants' import { ImForwardMode, isNormalMessage } from '@/views/im/utils/constants'
import type { Message } from '@/views/im/home/types' import type { Message } from '@/views/im/home/types'
import { IM_FORWARD_DIALOG_KEY } from '../message/forward/keys' import { IM_FORWARD_DIALOG_KEY } from '../message/forward/keys'
@ -66,14 +66,16 @@ const multiSelect = useMessageMultiSelect()
/** 选中条数 */ /** 选中条数 */
const selectedCount = computed(() => multiSelect.state.selectedClientMessageIds.length) const selectedCount = computed(() => multiSelect.state.selectedClientMessageIds.length)
/** 当前会话内已选消息conversation.messages 已按 sendTime 升序filter 保序无需再 sort */ /** 当前会话内已选消息conversation.messages 已按 sendTime 升序filter 保序无需再 sortisNormalMessage 过滤掉 RECALL / 系统事件,与 MessageItem.canForward 对齐 */
function getSelectedMessages(): Message[] { function getSelectedMessages(): Message[] {
const conversation = conversationStore.activeConversation const conversation = conversationStore.activeConversation
if (!conversation) { if (!conversation) {
return [] return []
} }
const ids = multiSelect.selectedIdSet.value const ids = multiSelect.selectedIdSet.value
return conversation.messages.filter((m) => ids.has(m.clientMessageId)) return conversation.messages.filter(
(message) => ids.has(message.clientMessageId) && isNormalMessage(message.type)
)
} }
/** 逐条转发:开 ForwardDialog 单条模式 */ /** 逐条转发:开 ForwardDialog 单条模式 */

View File

@ -34,6 +34,9 @@ let pendingFetchRequests: Promise<void> | null = null
/** 当前正在进行的「加载更多申请」请求 */ /** 当前正在进行的「加载更多申请」请求 */
let pendingLoadMoreRequests: Promise<void> | null = null let pendingLoadMoreRequests: Promise<void> | null = null
/** clear() 时递增;旧账号那次还没返回的请求 resolve 后比对一致才写 store防跨账号数据泄漏 */
let storeEpoch = 0
/** 好友通知 payload对齐后端 BaseFriendNotification + 子类裁减后的字段) */ /** 好友通知 payload对齐后端 BaseFriendNotification + 子类裁减后的字段) */
export interface FriendNotificationPayload { export interface FriendNotificationPayload {
operatorUserId: number operatorUserId: number
@ -160,8 +163,13 @@ export const useFriendStore = defineStore('imFriendStore', {
if (pendingFetchFriends) { if (pendingFetchFriends) {
return pendingFetchFriends return pendingFetchFriends
} }
// 快照 epochclear() 之后到 .then 之间触发的 epoch++ 表示账号已切,旧结果不能写入新 store
const requestEpoch = storeEpoch
pendingFetchFriends = apiGetMyFriendList() pendingFetchFriends = apiGetMyFriendList()
.then((list) => { .then((list) => {
if (requestEpoch !== storeEpoch) {
return
}
this.friends = (list || []).map(convertFriend) this.friends = (list || []).map(convertFriend)
this.loaded = true this.loaded = true
const conversationStore = useConversationStore() const conversationStore = useConversationStore()
@ -178,7 +186,9 @@ export const useFriendStore = defineStore('imFriendStore', {
this.saveFriends() this.saveFriends()
}) })
.finally(() => { .finally(() => {
pendingFetchFriends = null if (requestEpoch === storeEpoch) {
pendingFetchFriends = null
}
}) })
return pendingFetchFriends return pendingFetchFriends
}, },
@ -238,15 +248,21 @@ export const useFriendStore = defineStore('imFriendStore', {
if (pendingFetchRequests) { if (pendingFetchRequests) {
return pendingFetchRequests return pendingFetchRequests
} }
const requestEpoch = storeEpoch
pendingFetchRequests = apiGetMyFriendRequestList(FRIEND_REQUEST_PAGE_SIZE) pendingFetchRequests = apiGetMyFriendRequestList(FRIEND_REQUEST_PAGE_SIZE)
.then((list) => { .then((list) => {
if (requestEpoch !== storeEpoch) {
return
}
const items = (list || []).map(convertFriendRequest) const items = (list || []).map(convertFriendRequest)
this.friendRequests = items this.friendRequests = items
// 不足一页即没有更多;满页可能还有,等 loadMore 拉到 0 条再确定 // 不足一页即没有更多;满页可能还有,等 loadMore 拉到 0 条再确定
this.hasMoreFriendRequests = items.length >= FRIEND_REQUEST_PAGE_SIZE this.hasMoreFriendRequests = items.length >= FRIEND_REQUEST_PAGE_SIZE
}) })
.finally(() => { .finally(() => {
pendingFetchRequests = null if (requestEpoch === storeEpoch) {
pendingFetchRequests = null
}
}) })
return pendingFetchRequests return pendingFetchRequests
}, },
@ -260,14 +276,20 @@ export const useFriendStore = defineStore('imFriendStore', {
if (!oldest) { if (!oldest) {
return this.fetchFriendRequests() return this.fetchFriendRequests()
} }
const requestEpoch = storeEpoch
pendingLoadMoreRequests = apiGetMyFriendRequestList(FRIEND_REQUEST_PAGE_SIZE, oldest.id) pendingLoadMoreRequests = apiGetMyFriendRequestList(FRIEND_REQUEST_PAGE_SIZE, oldest.id)
.then((list) => { .then((list) => {
if (requestEpoch !== storeEpoch) {
return
}
const items = (list || []).map(convertFriendRequest) const items = (list || []).map(convertFriendRequest)
this.friendRequests.push(...items) this.friendRequests.push(...items)
this.hasMoreFriendRequests = items.length >= FRIEND_REQUEST_PAGE_SIZE this.hasMoreFriendRequests = items.length >= FRIEND_REQUEST_PAGE_SIZE
}) })
.finally(() => { .finally(() => {
pendingLoadMoreRequests = null if (requestEpoch === storeEpoch) {
pendingLoadMoreRequests = null
}
}) })
return pendingLoadMoreRequests return pendingLoadMoreRequests
}, },
@ -508,12 +530,16 @@ export const useFriendStore = defineStore('imFriendStore', {
this.saveFriends() this.saveFriends()
}, },
/** 切账号时仅清 in-memoryIDB 按 userId 分桶天然隔离,回切秒开 */ /** 清空好友内存状态并废弃未返回请求pending Promise 置空 + storeEpoch++ */
clear() { clear() {
this.friends = [] this.friends = []
this.friendRequests = [] this.friendRequests = []
this.loaded = false this.loaded = false
this.hasMoreFriendRequests = true this.hasMoreFriendRequests = true
pendingFetchFriends = null
pendingFetchRequests = null
pendingLoadMoreRequests = null
storeEpoch++
} }
} }
}) })

View File

@ -58,6 +58,23 @@ export const FRIEND_REQUEST_PAGE_SIZE = 100
/** 「我相关」加群申请列表的单次拉取条数 */ /** 「我相关」加群申请列表的单次拉取条数 */
export const GROUP_REQUEST_PAGE_SIZE = 100 export const GROUP_REQUEST_PAGE_SIZE = 100
// ==================== 上传安全策略 ====================
// 数值与后端 Spring multipart 配置对齐(`spring.servlet.multipart.max-file-size = 16MB`
// 后端调大后这里同步调;不要单边放宽,否则用户上传完到后端 413
/** 图片 / 视频 / 普通文件单文件上限MB对齐后端 max-file-size */
export const MESSAGE_IMAGE_MAX_MB = 16
export const MESSAGE_VIDEO_MAX_MB = 16
export const MESSAGE_FILE_MAX_MB = 16
/** 语音单文件上限MB60s @ 64kbps 约 0.5MB5MB 已远超录制可能产出的大小 */
export const MESSAGE_VOICE_MAX_MB = 5
/** 可执行 / 脚本类扩展名黑名单接收端点击下载后本地双击就跑html 本地打开还能执行脚本 */
export const DANGEROUS_FILE_EXTENSIONS = [
'exe', 'bat', 'cmd', 'com', 'msi', 'scr', 'pif', 'vbs', 'vbe', 'wsf', 'ws',
'js', 'jse', 'jar', 'sh', 'app', 'ps1', 'reg', 'html', 'htm'
]
// ==================== 前端独有UI 阈值 ==================== // ==================== 前端独有UI 阈值 ====================
/** 消息之间渲染「时间分隔条」的阈值10 分钟 */ /** 消息之间渲染「时间分隔条」的阈值10 分钟 */