diff --git a/src/views/im/home/components/group/GroupMemberAddDialog.vue b/src/views/im/home/components/group/GroupMemberAddDialog.vue index d5deba086..89e619732 100644 --- a/src/views/im/home/components/group/GroupMemberAddDialog.vue +++ b/src/views/im/home/components/group/GroupMemberAddDialog.vue @@ -39,6 +39,7 @@ import { useFriendStore } from '../../store/friendStore' import { useGroupStore } from '../../store/groupStore' import { useUserStore } from '@/store/modules/user' import { ImGroupMemberRole } from '@/views/im/utils/constants' +import { GROUP_MAX_MEMBER } from '@/views/im/utils/config' import FriendPickerPanel from '../picker/FriendPickerPanel.vue' import type { GroupMemberLite } from './GroupMember.vue' @@ -114,8 +115,18 @@ const willGoApproval = computed(() => { return myRole !== ImGroupMemberRole.ADMIN }) -/** 添加按钮可点:至少有 1 个新邀请的好友 */ -const canSubmit = computed(() => selectedIds.value.length > 0) +/** 当前群已启用成员数(DISABLE 即退群 / 被踢不计入),用于上限判定 */ +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 让父侧刷新群成员 */ async function handleOk() { @@ -126,6 +137,11 @@ async function handleOk() { if (memberUserIds.length === 0) { return } + // 群人数上限冗余防御:与 canSubmit 重复判一次,防止状态在 await 间隙变化或调用方绕过按钮直接调 + if (activeMemberCount.value + memberUserIds.length > GROUP_MAX_MEMBER) { + message.warning(`群成员上限为 ${GROUP_MAX_MEMBER} 人`) + return + } submitting.value = true try { await inviteGroupMember({ groupId: groupId.value, memberUserIds }) diff --git a/src/views/im/home/composables/useMediaUploader.ts b/src/views/im/home/composables/useMediaUploader.ts index c9bd879ca..8eb3b3cad 100644 --- a/src/views/im/home/composables/useMediaUploader.ts +++ b/src/views/im/home/composables/useMediaUploader.ts @@ -1,10 +1,17 @@ import { updateFile } from '@/api/infra/file' import { useUserStore } from '@/store/modules/user' +import { useMessage } from '@/hooks/web/useMessage' import { useConversationStore } from '../store/conversationStore' import { useMessageSender } from './useMessageSender' import { useMuteOverlay } from './useMuteOverlay' 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 { BLOB_URL_PREFIX, @@ -113,10 +120,41 @@ export interface UploadAndSendMediaOptions { * * 任意失败把消息状态置 FAILED;MessageItem 上点重试再走一次本函数(_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 = () => { const conversationStore = useConversationStore() const userStore = useUserStore() const muteOverlay = useMuteOverlay() + const message = useMessage() const { sendRaw } = useMessageSender() /** @@ -279,6 +317,10 @@ export const useMediaUploader = () => { console.warn('[IM] uploadAndSendMedia 收到未注册的媒体类型', { type: opts.type }) return '' } + // 体积上限拦截:大文件浏览器内截帧 / 解码可致 OOM;超限直接 warning,不进入占位 / 上传链路 + if (!ensureMediaSizeWithinLimit(opts.file, opts.type, message.warning)) { + return '' + } const startKey = getConversationKey(conversation) const context = opts.context ?? {} const buildContent = (url: string): string => diff --git a/src/views/im/home/pages/conversation/components/input/MentionPicker.vue b/src/views/im/home/pages/conversation/components/input/MentionPicker.vue index 72e21a4d3..af7256b5a 100644 --- a/src/views/im/home/pages/conversation/components/input/MentionPicker.vue +++ b/src/views/im/home/pages/conversation/components/input/MentionPicker.vue @@ -79,7 +79,7 @@ const props = withDefaults( position: { x: number; top?: number; bottom?: number } members: GroupMemberLite[] // 当前群的成员列表 searchText?: string // @ 后输入的过滤文本 - ownerId?: number // 群主 id,判断是否能展示"所有人" + canAtAll?: boolean // 当前用户是否能 @ 全员(群主 / 管理员),父组件按角色算好传入 }>(), { searchText: '', @@ -99,19 +99,14 @@ const activeIdx = ref(0) /** 当前登录用户 id(成员列表过滤掉自己) */ 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,不依赖文案字符串; * 这里的 userId / 文案都从 im/utils/constants 取,避免散落 */ const allItem = computed(() => { - if (!isOwner.value) { + if (!props.canAtAll) { return null } if (!IM_AT_ALL_NICKNAME.startsWith(props.searchText)) { diff --git a/src/views/im/home/pages/conversation/components/input/MessageInput.vue b/src/views/im/home/pages/conversation/components/input/MessageInput.vue index 821b46e5f..656886706 100644 --- a/src/views/im/home/pages/conversation/components/input/MessageInput.vue +++ b/src/views/im/home/pages/conversation/components/input/MessageInput.vue @@ -148,7 +148,7 @@ :position="mentionPosition" :members="groupMembers" :search-text="mentionSearchText" - :owner-id="groupOwnerId" + :can-at-all="canAtAll" @select="onMentionSelect" /> @@ -169,12 +169,14 @@ import { useGroupStore } from '@/views/im/home/store/groupStore' import { useFriendStore } from '@/views/im/home/store/friendStore' import { useDraftStore } from '@/views/im/home/store/draftStore' 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 { 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 { getConversationKey } from '@/views/im/utils/conversation' -import { ImConversationType, ImMessageType } from '@/views/im/utils/constants' -import { MESSAGE_GROUP_READ_ENABLED } from '@/views/im/utils/config' +import { ImConversationType, ImGroupMemberRole, ImMessageType } from '@/views/im/utils/constants' +import { DANGEROUS_FILE_EXTENSIONS, MESSAGE_GROUP_READ_ENABLED } from '@/views/im/utils/config' import { serializeMessage, type FaceMessage, @@ -195,6 +197,8 @@ const conversationStore = useConversationStore() const groupStore = useGroupStore() const friendStore = useFriendStore() const draftStore = useDraftStore() +const userStore = useUserStore() +const message = useMessage() const { send, sendRaw } = useMessageSender() const { uploadAndSendMedia, @@ -634,12 +638,24 @@ const groupMembers = computed(() => { }) }) -const groupOwnerId = computed(() => { +/** 当前用户是否能 @ 全员;群主 + 管理员都允许(对齐微信 PC:admin 也能 @ 所有人) */ +const canAtAll = computed(() => { const conversation = conversationStore.activeConversation 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) @@ -849,6 +865,12 @@ async function uploadAndSendImage(file: File) { /** 上传并发送 FILE 消息;payload 由 mediaTypeHandlers[FILE] 自动拼 url + name + size */ 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() if (!context) { return @@ -1007,6 +1029,9 @@ async function probeVideoFile(file: File): Promise { * 4. 视频链路耗时长,上传期间用户切会话则放弃发送(避免落到错误会话里);切走再切回来不算变化(key 仍相等) */ async function uploadAndSendVideo(file: File) { + if (!ensureMediaSizeWithinLimit(file, ImMessageType.VIDEO, message.warning)) { + return + } const context = prepareMediaUpload() if (!context) { return diff --git a/src/views/im/home/pages/conversation/components/input/MessageMultiSelectBar.vue b/src/views/im/home/pages/conversation/components/input/MessageMultiSelectBar.vue index e90538b56..a957b681a 100644 --- a/src/views/im/home/pages/conversation/components/input/MessageMultiSelectBar.vue +++ b/src/views/im/home/pages/conversation/components/input/MessageMultiSelectBar.vue @@ -52,7 +52,7 @@ import { useMessage } from '@/hooks/web/useMessage' import { useConversationStore } from '@/views/im/home/store/conversationStore' 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 { IM_FORWARD_DIALOG_KEY } from '../message/forward/keys' @@ -66,14 +66,16 @@ const multiSelect = useMessageMultiSelect() /** 选中条数 */ const selectedCount = computed(() => multiSelect.state.selectedClientMessageIds.length) -/** 当前会话内已选消息;conversation.messages 已按 sendTime 升序,filter 保序无需再 sort */ +/** 当前会话内已选消息;conversation.messages 已按 sendTime 升序,filter 保序无需再 sort;isNormalMessage 过滤掉 RECALL / 系统事件,与 MessageItem.canForward 对齐 */ function getSelectedMessages(): Message[] { const conversation = conversationStore.activeConversation if (!conversation) { return [] } 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 单条模式 */ diff --git a/src/views/im/home/store/friendStore.ts b/src/views/im/home/store/friendStore.ts index c1d6d5b4c..85afc5bbd 100644 --- a/src/views/im/home/store/friendStore.ts +++ b/src/views/im/home/store/friendStore.ts @@ -34,6 +34,9 @@ let pendingFetchRequests: Promise | null = null /** 当前正在进行的「加载更多申请」请求 */ let pendingLoadMoreRequests: Promise | null = null +/** clear() 时递增;旧账号那次还没返回的请求 resolve 后比对一致才写 store,防跨账号数据泄漏 */ +let storeEpoch = 0 + /** 好友通知 payload(对齐后端 BaseFriendNotification + 子类裁减后的字段) */ export interface FriendNotificationPayload { operatorUserId: number @@ -160,8 +163,13 @@ export const useFriendStore = defineStore('imFriendStore', { if (pendingFetchFriends) { return pendingFetchFriends } + // 快照 epoch;clear() 之后到 .then 之间触发的 epoch++ 表示账号已切,旧结果不能写入新 store + const requestEpoch = storeEpoch pendingFetchFriends = apiGetMyFriendList() .then((list) => { + if (requestEpoch !== storeEpoch) { + return + } this.friends = (list || []).map(convertFriend) this.loaded = true const conversationStore = useConversationStore() @@ -178,7 +186,9 @@ export const useFriendStore = defineStore('imFriendStore', { this.saveFriends() }) .finally(() => { - pendingFetchFriends = null + if (requestEpoch === storeEpoch) { + pendingFetchFriends = null + } }) return pendingFetchFriends }, @@ -238,15 +248,21 @@ export const useFriendStore = defineStore('imFriendStore', { if (pendingFetchRequests) { return pendingFetchRequests } + const requestEpoch = storeEpoch pendingFetchRequests = apiGetMyFriendRequestList(FRIEND_REQUEST_PAGE_SIZE) .then((list) => { + if (requestEpoch !== storeEpoch) { + return + } const items = (list || []).map(convertFriendRequest) this.friendRequests = items // 不足一页即没有更多;满页可能还有,等 loadMore 拉到 0 条再确定 this.hasMoreFriendRequests = items.length >= FRIEND_REQUEST_PAGE_SIZE }) .finally(() => { - pendingFetchRequests = null + if (requestEpoch === storeEpoch) { + pendingFetchRequests = null + } }) return pendingFetchRequests }, @@ -260,14 +276,20 @@ export const useFriendStore = defineStore('imFriendStore', { if (!oldest) { return this.fetchFriendRequests() } + const requestEpoch = storeEpoch pendingLoadMoreRequests = apiGetMyFriendRequestList(FRIEND_REQUEST_PAGE_SIZE, oldest.id) .then((list) => { + if (requestEpoch !== storeEpoch) { + return + } const items = (list || []).map(convertFriendRequest) this.friendRequests.push(...items) this.hasMoreFriendRequests = items.length >= FRIEND_REQUEST_PAGE_SIZE }) .finally(() => { - pendingLoadMoreRequests = null + if (requestEpoch === storeEpoch) { + pendingLoadMoreRequests = null + } }) return pendingLoadMoreRequests }, @@ -508,12 +530,16 @@ export const useFriendStore = defineStore('imFriendStore', { this.saveFriends() }, - /** 切账号时仅清 in-memory,IDB 按 userId 分桶天然隔离,回切秒开 */ + /** 清空好友内存状态,并废弃未返回请求(pending Promise 置空 + storeEpoch++) */ clear() { this.friends = [] this.friendRequests = [] this.loaded = false this.hasMoreFriendRequests = true + pendingFetchFriends = null + pendingFetchRequests = null + pendingLoadMoreRequests = null + storeEpoch++ } } }) diff --git a/src/views/im/utils/config.ts b/src/views/im/utils/config.ts index c74359d8b..6c689961e 100644 --- a/src/views/im/utils/config.ts +++ b/src/views/im/utils/config.ts @@ -58,6 +58,23 @@ export const FRIEND_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 +/** 语音单文件上限(MB):60s @ 64kbps 约 0.5MB,5MB 已远超录制可能产出的大小 */ +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 阈值 ==================== /** 消息之间渲染「时间分隔条」的阈值:10 分钟 */