✨ feat(im): 修一批上传安全与群聊交互问题
- 限制消息媒体上传大小,并让视频独立上传路径复用同一校验 - 禁止发送可执行 / 脚本类文件扩展名 - 切账号时废弃好友 store 未返回请求 - 多选转发过滤撤回 / 系统类消息 - 邀请群成员时前端拦截人数上限 - 允许群管理员 @ 所有人im
parent
fead282395
commit
5a983bb1eb
|
|
@ -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 })
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
*
|
*
|
||||||
* 任意失败把消息状态置 FAILED;MessageItem 上点重试再走一次本函数(_localFile 还在内存就行)
|
* 任意失败把消息状态置 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 = () => {
|
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 =>
|
||||||
|
|
|
||||||
|
|
@ -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)) {
|
||||||
|
|
|
||||||
|
|
@ -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>(() => {
|
/** 当前用户是否能 @ 全员;群主 + 管理员都允许(对齐微信 PC:admin 也能 @ 所有人) */
|
||||||
|
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
|
||||||
|
|
|
||||||
|
|
@ -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 保序无需再 sort;isNormalMessage 过滤掉 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 单条模式 */
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
// 快照 epoch;clear() 之后到 .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-memory,IDB 按 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++
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
/** 语音单文件上限(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 阈值 ====================
|
// ==================== 前端独有:UI 阈值 ====================
|
||||||
|
|
||||||
/** 消息之间渲染「时间分隔条」的阈值:10 分钟 */
|
/** 消息之间渲染「时间分隔条」的阈值:10 分钟 */
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue