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 CAPTCHA_ENABLED = "security.captcha.enabled";
public static final String MEETING_SUMMARY_SYSTEM_PROMPT = "meeting.summary.system_prompt"; 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; 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.beans.factory.annotation.Value;
import org.springframework.stereotype.Component; import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils; import org.springframework.util.StringUtils;
@ -17,9 +20,11 @@ import java.util.Set;
import java.util.UUID; import java.util.UUID;
@Component @Component
@RequiredArgsConstructor
public class MeetingAudioUploadSupport { public class MeetingAudioUploadSupport {
public static final String STAGING_AUDIO_TOKEN_PREFIX = "staging:audio/"; 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 int HEADER_SIZE = 32;
private static final Set<String> SUPPORTED_EXTENSIONS = Set.of("mp3", "wav", "m4a"); private static final Set<String> SUPPORTED_EXTENSIONS = Set.of("mp3", "wav", "m4a");
@ -30,11 +35,14 @@ public class MeetingAudioUploadSupport {
@Value("${unisbase.app.upload-path}") @Value("${unisbase.app.upload-path}")
private String uploadPath; private String uploadPath;
private final SysParamService sysParamService;
public String storeUploadedAudio(MultipartFile file) throws IOException { public String storeUploadedAudio(MultipartFile file) throws IOException {
if (file == null || file.isEmpty()) { if (file == null || file.isEmpty()) {
throw new RuntimeException("音频文件不能为空"); throw new RuntimeException("音频文件不能为空");
} }
validateFileSize(file);
String extension = resolveExtension(file.getOriginalFilename()); String extension = resolveExtension(file.getOriginalFilename());
// validateContentType(file.getContentType(), extension); // validateContentType(file.getContentType(), extension);
validateFileHeader(file, extension); validateFileHeader(file, extension);
@ -106,6 +114,30 @@ public class MeetingAudioUploadSupport {
return extension; 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) { private void validateContentType(String contentType, String extension) {
if (!StringUtils.hasText(contentType)) { if (!StringUtils.hasText(contentType)) {
return; return;

View File

@ -32,8 +32,7 @@ public class MeetingQueryServiceImpl implements MeetingQueryService {
@Override @Override
public PageResult<List<MeetingVO>> pageMeetings(Integer current, Integer size, String title, Long tenantId, public PageResult<List<MeetingVO>> pageMeetings(Integer current, Integer size, String title, Long tenantId,
Long userId, String userName, String viewType, boolean isAdmin) { Long userId, String userName, String viewType, boolean isAdmin) {
LambdaQueryWrapper<Meeting> wrapper = new LambdaQueryWrapper<Meeting>() LambdaQueryWrapper<Meeting> wrapper = new LambdaQueryWrapper<Meeting>();
.eq(Meeting::getTenantId, tenantId);
if (!isAdmin || !"all".equals(viewType)) { if (!isAdmin || !"all".equals(viewType)) {
String userIdStr = String.valueOf(userId); 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 { getAiModelPage, getAiModelDefault, AiModelVO } from '../../api/business/aimodel';
import { getPromptPage, PromptTemplateVO } from '../../api/business/prompt'; import { getPromptPage, PromptTemplateVO } from '../../api/business/prompt';
import { getHotWordPage, HotWordVO } from '../../api/business/hotword'; 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 { createMeeting, createRealtimeMeeting, uploadAudio, CreateRealtimeMeetingCommand } from '../../api/business/meeting';
import { SysUser } from '../../types'; import { SysUser } from '../../types';
@ -17,6 +17,9 @@ const { Text, Title } = Typography;
export type MeetingCreateType = 'upload' | 'realtime'; 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 { interface MeetingCreateDrawerProps {
open: boolean; open: boolean;
initialType?: MeetingCreateType; initialType?: MeetingCreateType;
@ -73,6 +76,7 @@ export const MeetingCreateDrawer: React.FC<MeetingCreateDrawerProps> = ({ open,
const [audioUrl, setAudioUrl] = useState(''); const [audioUrl, setAudioUrl] = useState('');
const [uploadProgress, setUploadProgress] = useState(0); const [uploadProgress, setUploadProgress] = useState(0);
const [fileList, setFileList] = useState<any[]>([]); const [fileList, setFileList] = useState<any[]>([]);
const [offlineAudioMaxSizeMb, setOfflineAudioMaxSizeMb] = useState(DEFAULT_OFFLINE_AUDIO_MAX_SIZE_MB);
const watchedAsrModelId = Form.useWatch("asrModelId", form); const watchedAsrModelId = Form.useWatch("asrModelId", form);
const watchedPromptId = Form.useWatch("promptId", 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 selectedAsrModel = useMemo(() => asrModels.find((item) => item.id === watchedAsrModelId) || null, [asrModels, watchedAsrModelId]);
const selectedSummaryModel = useMemo(() => llmModels.find((item) => item.id === watchedSummaryModelId) || null, [llmModels, watchedSummaryModelId]); const selectedSummaryModel = useMemo(() => llmModels.find((item) => item.id === watchedSummaryModelId) || null, [llmModels, watchedSummaryModelId]);
const offlineAudioMaxSizeBytes = useMemo(() => offlineAudioMaxSizeMb * 1024 * 1024, [offlineAudioMaxSizeMb]);
useEffect(() => { useEffect(() => {
if (open) { if (open) {
@ -101,7 +106,7 @@ export const MeetingCreateDrawer: React.FC<MeetingCreateDrawerProps> = ({ open,
getHotWordPage({ current: 1, size: 1000 }), getHotWordPage({ current: 1, size: 1000 }),
listUsers(), listUsers(),
getAiModelDefault("ASR"), getAiModelDefault("ASR"),
getAiModelDefault("LLM") getAiModelDefault("LLM"),
]); ]);
const activeAsrModels = asrRes.data.data.records.filter((m: AiModelVO) => m.status === 1); 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); setPrompts(activePrompts);
setHotwordList(activeHotwords); setHotwordList(activeHotwords);
setUserList(users || []); setUserList(users || []);
setOfflineAudioMaxSizeMb(await loadOfflineAudioMaxSizeMb());
form.setFieldsValue({ form.setFieldsValue({
title: type === 'upload' ? `文件会议 ${dayjs().format("MM-DD HH:mm")}` : `实时会议 ${dayjs().format("MM-DD HH:mm")}`, title: type === 'upload' ? `文件会议 ${dayjs().format("MM-DD HH:mm")}` : `实时会议 ${dayjs().format("MM-DD HH:mm")}`,
@ -168,8 +174,34 @@ export const MeetingCreateDrawer: React.FC<MeetingCreateDrawerProps> = ({ open,
message.success('录音上传成功'); message.success('录音上传成功');
} catch (err) { } catch (err) {
onError(err); onError(err);
if (!(err instanceof Error) || !err.message) {
message.error('文件上传失败'); 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;
}
}; };
const handleOk = async () => { const handleOk = async () => {
@ -472,6 +504,7 @@ export const MeetingCreateDrawer: React.FC<MeetingCreateDrawerProps> = ({ open,
</div> </div>
<Dragger <Dragger
accept=".mp3,.wav,.m4a" accept=".mp3,.wav,.m4a"
beforeUpload={beforeAudioUpload}
fileList={fileList} fileList={fileList}
customRequest={customUpload} customRequest={customUpload}
onChange={info => setFileList(info.fileList.slice(-1))} 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-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-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: 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 && ( {uploadProgress > 0 && uploadProgress < 100 && (
<div style={{ width: '60%', margin: '32px auto 0' }}> <div style={{ width: '60%', margin: '32px auto 0' }}>
<Progress percent={uploadProgress} size="small" /> <Progress percent={uploadProgress} size="small" />