feat: 添加音频文件大小验证和系统参数配置
- 在 `MeetingAudioUploadSupport` 中添加 `validateFileSize` 方法,验证上传的音频文件大小 - 引入 `SysParamService` 以获取系统参数配置的最大上传大小 - 在前端 `MeetingCreateDrawer.tsx` 中添加文件大小验证逻辑,并显示系统配置的最大大小dev_na
parent
2f80c6c55e
commit
e6580beaa8
|
|
@ -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";
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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" />
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue