feat: 添加音频文件大小验证和系统参数配置

- 在 `MeetingAudioUploadSupport` 中添加 `validateFileSize` 方法,验证上传的音频文件大小
- 引入 `SysParamService` 以获取系统参数配置的最大上传大小
- 在前端 `MeetingCreateDrawer.tsx` 中添加文件大小验证逻辑,并显示系统配置的最大大小
dev_na
chenhao 2026-04-24 15:47:52 +08:00
parent 2f80c6c55e
commit e6580beaa8
4 changed files with 71 additions and 5 deletions

View File

@ -5,4 +5,5 @@ public final class SysParamKeys {
public static final String CAPTCHA_ENABLED = "security.captcha.enabled";
public static final String MEETING_SUMMARY_SYSTEM_PROMPT = "meeting.summary.system_prompt";
public static final String MEETING_OFFLINE_AUDIO_MAX_SIZE_MB = "meeting.offline_audio.max_size_mb";
}

View File

@ -1,5 +1,8 @@
package com.imeeting.service.biz.impl;
import com.imeeting.common.SysParamKeys;
import com.unisbase.service.SysParamService;
import lombok.RequiredArgsConstructor;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils;
@ -17,9 +20,11 @@ import java.util.Set;
import java.util.UUID;
@Component
@RequiredArgsConstructor
public class MeetingAudioUploadSupport {
public static final String STAGING_AUDIO_TOKEN_PREFIX = "staging:audio/";
private static final long DEFAULT_MAX_UPLOAD_SIZE_MB = 1024L;
private static final int HEADER_SIZE = 32;
private static final Set<String> SUPPORTED_EXTENSIONS = Set.of("mp3", "wav", "m4a");
@ -30,11 +35,14 @@ public class MeetingAudioUploadSupport {
@Value("${unisbase.app.upload-path}")
private String uploadPath;
private final SysParamService sysParamService;
public String storeUploadedAudio(MultipartFile file) throws IOException {
if (file == null || file.isEmpty()) {
throw new RuntimeException("音频文件不能为空");
}
validateFileSize(file);
String extension = resolveExtension(file.getOriginalFilename());
// validateContentType(file.getContentType(), extension);
validateFileHeader(file, extension);
@ -106,6 +114,30 @@ public class MeetingAudioUploadSupport {
return extension;
}
private void validateFileSize(MultipartFile file) {
long maxUploadSizeMb = resolveMaxUploadSizeMb();
long maxUploadSizeBytes = maxUploadSizeMb * 1024 * 1024;
if (file.getSize() > maxUploadSizeBytes) {
throw new RuntimeException("音频文件大小不能超过 " + maxUploadSizeMb + "MB");
}
}
private long resolveMaxUploadSizeMb() {
String configured = sysParamService.getCachedParamValue(
SysParamKeys.MEETING_OFFLINE_AUDIO_MAX_SIZE_MB,
String.valueOf(DEFAULT_MAX_UPLOAD_SIZE_MB)
);
if (!StringUtils.hasText(configured)) {
return DEFAULT_MAX_UPLOAD_SIZE_MB;
}
try {
long parsed = Long.parseLong(configured.trim());
return parsed > 0 ? parsed : DEFAULT_MAX_UPLOAD_SIZE_MB;
} catch (NumberFormatException ex) {
return DEFAULT_MAX_UPLOAD_SIZE_MB;
}
}
private void validateContentType(String contentType, String extension) {
if (!StringUtils.hasText(contentType)) {
return;

View File

@ -32,8 +32,7 @@ public class MeetingQueryServiceImpl implements MeetingQueryService {
@Override
public PageResult<List<MeetingVO>> pageMeetings(Integer current, Integer size, String title, Long tenantId,
Long userId, String userName, String viewType, boolean isAdmin) {
LambdaQueryWrapper<Meeting> wrapper = new LambdaQueryWrapper<Meeting>()
.eq(Meeting::getTenantId, tenantId);
LambdaQueryWrapper<Meeting> wrapper = new LambdaQueryWrapper<Meeting>();
if (!isAdmin || !"all".equals(viewType)) {
String userIdStr = String.valueOf(userId);

View File

@ -7,7 +7,7 @@ import { useNavigate } from 'react-router-dom';
import { getAiModelPage, getAiModelDefault, AiModelVO } from '../../api/business/aimodel';
import { getPromptPage, PromptTemplateVO } from '../../api/business/prompt';
import { getHotWordPage, HotWordVO } from '../../api/business/hotword';
import { listUsers } from '../../api';
import { listUsers, pageParams } from '../../api';
import { createMeeting, createRealtimeMeeting, uploadAudio, CreateRealtimeMeetingCommand } from '../../api/business/meeting';
import { SysUser } from '../../types';
@ -17,6 +17,9 @@ const { Text, Title } = Typography;
export type MeetingCreateType = 'upload' | 'realtime';
const DEFAULT_OFFLINE_AUDIO_MAX_SIZE_MB = 1024;
const OFFLINE_AUDIO_MAX_SIZE_PARAM_KEY = 'meeting.offline_audio.max_size_mb';
interface MeetingCreateDrawerProps {
open: boolean;
initialType?: MeetingCreateType;
@ -73,6 +76,7 @@ export const MeetingCreateDrawer: React.FC<MeetingCreateDrawerProps> = ({ open,
const [audioUrl, setAudioUrl] = useState('');
const [uploadProgress, setUploadProgress] = useState(0);
const [fileList, setFileList] = useState<any[]>([]);
const [offlineAudioMaxSizeMb, setOfflineAudioMaxSizeMb] = useState(DEFAULT_OFFLINE_AUDIO_MAX_SIZE_MB);
const watchedAsrModelId = Form.useWatch("asrModelId", form);
const watchedPromptId = Form.useWatch("promptId", form);
@ -80,6 +84,7 @@ export const MeetingCreateDrawer: React.FC<MeetingCreateDrawerProps> = ({ open,
const selectedAsrModel = useMemo(() => asrModels.find((item) => item.id === watchedAsrModelId) || null, [asrModels, watchedAsrModelId]);
const selectedSummaryModel = useMemo(() => llmModels.find((item) => item.id === watchedSummaryModelId) || null, [llmModels, watchedSummaryModelId]);
const offlineAudioMaxSizeBytes = useMemo(() => offlineAudioMaxSizeMb * 1024 * 1024, [offlineAudioMaxSizeMb]);
useEffect(() => {
if (open) {
@ -101,7 +106,7 @@ export const MeetingCreateDrawer: React.FC<MeetingCreateDrawerProps> = ({ open,
getHotWordPage({ current: 1, size: 1000 }),
listUsers(),
getAiModelDefault("ASR"),
getAiModelDefault("LLM")
getAiModelDefault("LLM"),
]);
const activeAsrModels = asrRes.data.data.records.filter((m: AiModelVO) => m.status === 1);
@ -114,6 +119,7 @@ export const MeetingCreateDrawer: React.FC<MeetingCreateDrawerProps> = ({ open,
setPrompts(activePrompts);
setHotwordList(activeHotwords);
setUserList(users || []);
setOfflineAudioMaxSizeMb(await loadOfflineAudioMaxSizeMb());
form.setFieldsValue({
title: type === 'upload' ? `文件会议 ${dayjs().format("MM-DD HH:mm")}` : `实时会议 ${dayjs().format("MM-DD HH:mm")}`,
@ -168,7 +174,33 @@ export const MeetingCreateDrawer: React.FC<MeetingCreateDrawerProps> = ({ open,
message.success('录音上传成功');
} catch (err) {
onError(err);
message.error('文件上传失败');
if (!(err instanceof Error) || !err.message) {
message.error('文件上传失败');
}
}
};
const beforeAudioUpload = (file: File) => {
if (file.size > offlineAudioMaxSizeBytes) {
message.error(`录音文件大小不能超过 ${offlineAudioMaxSizeMb}MB`);
setUploadProgress(0);
return Upload.LIST_IGNORE;
}
return true;
};
const loadOfflineAudioMaxSizeMb = async () => {
try {
const result = await pageParams({
paramKey: OFFLINE_AUDIO_MAX_SIZE_PARAM_KEY,
pageNum: 1,
pageSize: 10,
});
const matched = (result.records || []).find((item) => item.paramKey === OFFLINE_AUDIO_MAX_SIZE_PARAM_KEY);
const parsed = Number(matched?.paramValue);
return Number.isFinite(parsed) && parsed > 0 ? parsed : DEFAULT_OFFLINE_AUDIO_MAX_SIZE_MB;
} catch {
return DEFAULT_OFFLINE_AUDIO_MAX_SIZE_MB;
}
};
@ -472,6 +504,7 @@ export const MeetingCreateDrawer: React.FC<MeetingCreateDrawerProps> = ({ open,
</div>
<Dragger
accept=".mp3,.wav,.m4a"
beforeUpload={beforeAudioUpload}
fileList={fileList}
customRequest={customUpload}
onChange={info => setFileList(info.fileList.slice(-1))}
@ -482,6 +515,7 @@ export const MeetingCreateDrawer: React.FC<MeetingCreateDrawerProps> = ({ open,
<p className="ant-upload-drag-icon" style={{ marginBottom: 16 }}><CloudUploadOutlined style={{ fontSize: 56, color: '#1890ff' }} /></p>
<p className="ant-upload-text" style={{ fontSize: 18, fontWeight: 500, color: 'var(--app-text-main)' }}></p>
<p className="ant-upload-hint" style={{ fontSize: 14, marginTop: 12, color: 'var(--app-text-secondary)' }}> .mp3, .wav, .m4a </p>
<p className="ant-upload-hint" style={{ fontSize: 13, marginTop: 8, color: 'var(--app-text-secondary)' }}> {offlineAudioMaxSizeMb}MB</p>
{uploadProgress > 0 && uploadProgress < 100 && (
<div style={{ width: '60%', margin: '32px auto 0' }}>
<Progress percent={uploadProgress} size="small" />