From 8d0ef246f3ff7b14573e414bcd6c073da430c116 Mon Sep 17 00:00:00 2001 From: chenhao Date: Wed, 22 Apr 2026 17:54:58 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=B7=BB=E5=8A=A0=E5=AE=9E=E6=97=B6?= =?UTF-8?q?=E4=BC=9A=E8=AE=AE=E7=8A=B6=E6=80=81=E5=A4=84=E7=90=86=E5=92=8C?= =?UTF-8?q?=E8=BD=AC=E5=BD=95=E5=86=85=E5=AE=B9=E6=A3=80=E6=9F=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 在 `AiTaskServiceImpl` 中添加 `buildTranscriptText` 和 `failPendingSummaryTask` 方法,用于构建转录文本和处理失败的摘要任务 - 更新 `doDispatchSummaryTask` 和 `dispatchTasks` 方法,以在转录内容为空时处理失败情况 - 在前端 `Meetings.tsx` 中添加实时会议状态处理逻辑,支持实时会议的暂停、进行中和待开始状态 - 更新测试类 `AiTaskServiceImplTest` 以包含新的测试用例,验证转录内容为空时的任务处理逻辑 --- .../service/biz/impl/AiTaskServiceImpl.java | 72 +++++++-- .../biz/impl/AiTaskServiceImplTest.java | 126 +++++++++++++++- frontend/src/pages/business/Meetings.tsx | 141 ++++++++++++++---- 3 files changed, 293 insertions(+), 46 deletions(-) diff --git a/backend/src/main/java/com/imeeting/service/biz/impl/AiTaskServiceImpl.java b/backend/src/main/java/com/imeeting/service/biz/impl/AiTaskServiceImpl.java index 6a4cda5..3189c28 100644 --- a/backend/src/main/java/com/imeeting/service/biz/impl/AiTaskServiceImpl.java +++ b/backend/src/main/java/com/imeeting/service/biz/impl/AiTaskServiceImpl.java @@ -103,9 +103,7 @@ public class AiTaskServiceImpl extends ServiceImpl impleme List transcripts = transcriptMapper.selectList(new LambdaQueryWrapper() .eq(MeetingTranscript::getMeetingId, meetingId) .orderByAsc(MeetingTranscript::getStartTime)); - asrText = transcripts.stream() - .map(t -> (t.getSpeakerName() != null ? t.getSpeakerName() : t.getSpeakerId()) + ": " + t.getContent()) - .collect(Collectors.joining("\n")); + asrText = buildTranscriptText(transcripts); } // Real-time meetings are created without audio files and without ASR tasks. @@ -123,6 +121,12 @@ public class AiTaskServiceImpl extends ServiceImpl impleme .eq(AiTask::getTaskType, "SUMMARY") .orderByDesc(AiTask::getId) .last("limit 1")); + if (asrText == null || asrText.isBlank()) { + failPendingSummaryTask(sumTask, "No transcript content available for summary"); + updateMeetingStatus(meetingId, 4); + updateProgress(meetingId, -1, "未识别到可用于总结的转录内容", 0); + return; + } if (sumTask != null && sumTask.getStatus() == 0) { processSummaryTask(meeting, asrText, sumTask); } else if (meeting.getStatus() != 3) { @@ -132,6 +136,7 @@ public class AiTaskServiceImpl extends ServiceImpl impleme redisTemplate.delete(RedisKeys.meetingProgressKey(meetingId)); } catch (Exception e) { log.error("Meeting {} AI Task Flow failed", meetingId, e); + failPendingSummaryTask(findLatestSummaryTask(meetingId), "Summary skipped because transcription failed: " + e.getMessage()); updateMeetingStatus(meetingId, 4); updateProgress(meetingId, -1, "分析失败: " + e.getMessage(), 0); } finally { @@ -148,24 +153,26 @@ public class AiTaskServiceImpl extends ServiceImpl impleme private void doDispatchSummaryTask(Long meetingId) { Meeting meeting = meetingMapper.selectById(meetingId); if (meeting == null) return; + AiTask sumTask = this.getOne(new LambdaQueryWrapper() + .eq(AiTask::getMeetingId, meetingId) + .eq(AiTask::getTaskType, "SUMMARY") + .orderByDesc(AiTask::getId) + .last("limit 1")); try { List transcripts = transcriptMapper.selectList(new LambdaQueryWrapper() .eq(MeetingTranscript::getMeetingId, meetingId) .orderByAsc(MeetingTranscript::getStartTime)); if (transcripts.isEmpty()) { + failPendingSummaryTask(sumTask, "No transcript content available for summary"); throw new RuntimeException("没有找到可用的转录文本,无法生成总结"); } - String asrText = transcripts.stream() - .map(t -> (t.getSpeakerName() != null ? t.getSpeakerName() : t.getSpeakerId()) + ": " + t.getContent()) - .collect(Collectors.joining("\n")); - - AiTask sumTask = this.getOne(new LambdaQueryWrapper() - .eq(AiTask::getMeetingId, meetingId) - .eq(AiTask::getTaskType, "SUMMARY") - .orderByDesc(AiTask::getId) - .last("limit 1")); + String asrText = buildTranscriptText(transcripts); + if (asrText == null || asrText.isBlank()) { + failPendingSummaryTask(sumTask, "No transcript content available for summary"); + throw new RuntimeException("No transcript content available for summary"); + } if (sumTask != null && sumTask.getStatus() == 0) { processSummaryTask(meeting, asrText, sumTask); } @@ -418,6 +425,47 @@ public class AiTaskServiceImpl extends ServiceImpl impleme return (int) Math.round(node.asDouble() * 1000D); } + private String buildTranscriptText(List transcripts) { + if (transcripts == null || transcripts.isEmpty()) { + return ""; + } + return transcripts.stream() + .filter(Objects::nonNull) + .map(this::formatTranscriptLine) + .filter(line -> line != null && !line.isBlank()) + .collect(Collectors.joining("\n")); + } + + private String formatTranscriptLine(MeetingTranscript transcript) { + String content = transcript.getContent(); + if (content == null || content.isBlank()) { + return null; + } + String speaker = transcript.getSpeakerName(); + if (speaker == null || speaker.isBlank()) { + speaker = transcript.getSpeakerId(); + } + if (speaker == null || speaker.isBlank()) { + return content.trim(); + } + return speaker.trim() + ": " + content.trim(); + } + + private void failPendingSummaryTask(AiTask task, String error) { + if (task == null || Integer.valueOf(2).equals(task.getStatus()) || Integer.valueOf(3).equals(task.getStatus())) { + return; + } + updateAiTaskFail(task, error); + } + + private AiTask findLatestSummaryTask(Long meetingId) { + return this.getOne(new LambdaQueryWrapper() + .eq(AiTask::getMeetingId, meetingId) + .eq(AiTask::getTaskType, "SUMMARY") + .orderByDesc(AiTask::getId) + .last("limit 1")); + } + private void processSummaryTask(Meeting meeting, String asrText, AiTask taskRecord) throws Exception { updateMeetingStatus(meeting.getId(), 2); updateProgress(meeting.getId(), 90, "正在生成智能总结纪要...", 0); diff --git a/backend/src/test/java/com/imeeting/service/biz/impl/AiTaskServiceImplTest.java b/backend/src/test/java/com/imeeting/service/biz/impl/AiTaskServiceImplTest.java index 21ad449..47527cc 100644 --- a/backend/src/test/java/com/imeeting/service/biz/impl/AiTaskServiceImplTest.java +++ b/backend/src/test/java/com/imeeting/service/biz/impl/AiTaskServiceImplTest.java @@ -1,6 +1,9 @@ package com.imeeting.service.biz.impl; import com.fasterxml.jackson.databind.ObjectMapper; +import com.imeeting.entity.biz.AiTask; +import com.imeeting.entity.biz.Meeting; +import com.imeeting.entity.biz.MeetingTranscript; import com.imeeting.mapper.biz.MeetingMapper; import com.imeeting.mapper.biz.MeetingTranscriptMapper; import com.imeeting.service.biz.AiModelService; @@ -10,12 +13,23 @@ import com.imeeting.support.TaskSecurityContextRunner; import com.unisbase.mapper.SysUserMapper; import org.junit.jupiter.api.Test; import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.data.redis.core.ValueOperations; import org.springframework.test.util.ReflectionTestUtils; import java.net.URI; +import java.util.List; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.doReturn; import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.spy; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; class AiTaskServiceImplTest { @@ -60,18 +74,118 @@ class AiTaskServiceImplTest { assertEquals("http://10.100.52.43:1234/v1/chat/completions", uri.toString()); } + @Test + void dispatchTasksShouldFailSummaryTaskWhenTranscriptContentIsBlank() { + MeetingMapper meetingMapper = mock(MeetingMapper.class); + MeetingTranscriptMapper transcriptMapper = mock(MeetingTranscriptMapper.class); + AiModelService aiModelService = mock(AiModelService.class); + StringRedisTemplate redisTemplate = mock(StringRedisTemplate.class); + @SuppressWarnings("unchecked") + ValueOperations valueOperations = mock(ValueOperations.class); + when(redisTemplate.opsForValue()).thenReturn(valueOperations); + when(valueOperations.setIfAbsent(anyString(), anyString(), anyLong(), any())).thenReturn(true); + + AiTaskServiceImpl service = spy(createService( + meetingMapper, + transcriptMapper, + aiModelService, + redisTemplate, + new TaskSecurityContextRunner() + )); + doReturn(true).when(service).updateById(any()); + + Meeting meeting = new Meeting(); + meeting.setId(66L); + meeting.setAudioUrl("/audio/demo.wav"); + when(meetingMapper.selectById(66L)).thenReturn(meeting); + + MeetingTranscript transcript = new MeetingTranscript(); + transcript.setSpeakerName("Alice"); + transcript.setContent(" "); + when(transcriptMapper.selectList(any())).thenReturn(List.of(transcript)); + + AiTask summaryTask = new AiTask(); + summaryTask.setId(99L); + summaryTask.setMeetingId(66L); + summaryTask.setTaskType("SUMMARY"); + summaryTask.setStatus(0); + doReturn(null, summaryTask).when(service).getOne(any()); + + service.dispatchTasks(66L, 1L, 2L); + + assertEquals(3, summaryTask.getStatus()); + assertTrue(summaryTask.getErrorMsg().contains("No transcript content available for summary")); + verify(aiModelService, never()).getModelById(anyLong(), anyString()); + } + + @Test + void dispatchSummaryTaskShouldFailWhenTranscriptContentIsBlank() { + MeetingMapper meetingMapper = mock(MeetingMapper.class); + MeetingTranscriptMapper transcriptMapper = mock(MeetingTranscriptMapper.class); + AiModelService aiModelService = mock(AiModelService.class); + StringRedisTemplate redisTemplate = mock(StringRedisTemplate.class); + @SuppressWarnings("unchecked") + ValueOperations valueOperations = mock(ValueOperations.class); + when(redisTemplate.opsForValue()).thenReturn(valueOperations); + + AiTaskServiceImpl service = spy(createService( + meetingMapper, + transcriptMapper, + aiModelService, + redisTemplate, + new TaskSecurityContextRunner() + )); + doReturn(true).when(service).updateById(any()); + + Meeting meeting = new Meeting(); + meeting.setId(77L); + when(meetingMapper.selectById(77L)).thenReturn(meeting); + + MeetingTranscript transcript = new MeetingTranscript(); + transcript.setSpeakerId("spk-1"); + transcript.setContent(" "); + when(transcriptMapper.selectList(any())).thenReturn(List.of(transcript)); + + AiTask summaryTask = new AiTask(); + summaryTask.setId(100L); + summaryTask.setMeetingId(77L); + summaryTask.setTaskType("SUMMARY"); + summaryTask.setStatus(0); + doReturn(summaryTask).when(service).getOne(any()); + + service.dispatchSummaryTask(77L, 1L, 2L); + + assertEquals(3, summaryTask.getStatus()); + assertTrue(summaryTask.getErrorMsg().contains("No transcript content available for summary")); + verify(aiModelService, never()).getModelById(anyLong(), anyString()); + } + private AiTaskServiceImpl createService() { - return new AiTaskServiceImpl( + return createService( mock(MeetingMapper.class), mock(MeetingTranscriptMapper.class), mock(AiModelService.class), - new ObjectMapper(), - mock(SysUserMapper.class), - mock(HotWordService.class), mock(StringRedisTemplate.class), - mock(MeetingSummaryFileService.class), - mock(MeetingSummaryPromptAssembler.class), mock(TaskSecurityContextRunner.class) ); } + + private AiTaskServiceImpl createService(MeetingMapper meetingMapper, + MeetingTranscriptMapper transcriptMapper, + AiModelService aiModelService, + StringRedisTemplate redisTemplate, + TaskSecurityContextRunner taskSecurityContextRunner) { + return new AiTaskServiceImpl( + meetingMapper, + transcriptMapper, + aiModelService, + new ObjectMapper(), + mock(SysUserMapper.class), + mock(HotWordService.class), + redisTemplate, + mock(MeetingSummaryFileService.class), + mock(MeetingSummaryPromptAssembler.class), + taskSecurityContextRunner + ); + } } diff --git a/frontend/src/pages/business/Meetings.tsx b/frontend/src/pages/business/Meetings.tsx index 6437858..0548049 100644 --- a/frontend/src/pages/business/Meetings.tsx +++ b/frontend/src/pages/business/Meetings.tsx @@ -57,6 +57,38 @@ import {SysUser} from '../../types'; const { Text, Title } = Typography; const { Option } = Select; const PAUSED_DISPLAY_STATUS = 5; +const REALTIME_ACTIVE_DISPLAY_STATUS = 6; +const REALTIME_IDLE_DISPLAY_STATUS = 7; + +const isRealtimeMeetingCandidate = (item: MeetingVO) => item.status === 0 && !item.audioUrl; + +const isPausedRealtimeSessionStatus = (status?: RealtimeMeetingSessionStatus["status"]) => + status === 'PAUSED_EMPTY' || status === 'PAUSED_RESUMABLE'; + +const canOpenRealtimeSession = (status?: RealtimeMeetingSessionStatus["status"]) => + status === 'PAUSED_EMPTY' + || status === 'PAUSED_RESUMABLE' + || status === 'ACTIVE' + || status === 'IDLE'; + +const shouldPollMeetingCard = (item: MeetingVO) => + item.status === 1 + || item.status === 2 + || item.realtimeSessionStatus === 'ACTIVE' + || isPausedRealtimeSessionStatus(item.realtimeSessionStatus); + +const canManageRealtimeMeeting = (meeting: MeetingVO) => { + try { + const profileStr = sessionStorage.getItem('userProfile'); + if (!profileStr) { + return false; + } + const profile = JSON.parse(profileStr); + return profile.isPlatformAdmin === true || profile.isTenantAdmin === true || profile.userId === meeting.creatorId; + } catch { + return false; + } +}; const applyRealtimeSessionStatus = (item: MeetingVO, sessionStatus?: RealtimeMeetingSessionStatus): MeetingVO => { if (!sessionStatus) { @@ -72,14 +104,14 @@ const applyRealtimeSessionStatus = (item: MeetingVO, sessionStatus?: RealtimeMee if (sessionStatus.status === 'ACTIVE') { return { ...item, - displayStatus: 1, + displayStatus: REALTIME_ACTIVE_DISPLAY_STATUS, realtimeSessionStatus: sessionStatus.status }; } - if (sessionStatus.status === 'IDLE' && !item.audioUrl) { + if (sessionStatus.status === 'IDLE' && isRealtimeMeetingCandidate(item)) { return { ...item, - displayStatus: 0, + displayStatus: REALTIME_IDLE_DISPLAY_STATUS, realtimeSessionStatus: sessionStatus.status }; } @@ -93,8 +125,7 @@ const applyRealtimeSessionStatus = (item: MeetingVO, sessionStatus?: RealtimeMee const useMeetingProgress = (meeting: MeetingVO, onComplete?: () => void) => { const [progress, setProgress] = useState(null); useEffect(() => { - const effectiveStatus = meeting.displayStatus ?? meeting.status; - if (effectiveStatus !== 1 && effectiveStatus !== 2) return; + if (meeting.status !== 1 && meeting.status !== 2) return; const fetchProgress = async () => { try { const res = await getMeetingProgress(meeting.id); @@ -109,7 +140,7 @@ const useMeetingProgress = (meeting: MeetingVO, onComplete?: () => void) => { fetchProgress(); const timer = setInterval(fetchProgress, 3000); return () => clearInterval(timer); - }, [meeting.id, meeting.status, meeting.displayStatus]); + }, [meeting.id, meeting.status]); return progress; }; @@ -122,11 +153,13 @@ const IntegratedStatusTag: React.FC<{ meeting: MeetingVO, progress: MeetingProgr 2: { text: '总结中', color: '#faad14', bgColor: '#fff7e6' }, 3: { text: '已完成', color: '#52c41a', bgColor: '#f6ffed' }, 4: { text: '失败', color: '#ff4d4f', bgColor: '#fff1f0' }, - 5: { text: '会议暂停', color: '#d48806', bgColor: '#fff7e6' } + 5: { text: '会议暂停', color: '#d48806', bgColor: '#fff7e6' }, + 6: { text: '实时进行中', color: '#1677ff', bgColor: '#e6f4ff' }, + 7: { text: '待开始', color: '#595959', bgColor: '#f5f5f5' } }; const config = statusConfig[effectiveStatus] || statusConfig[0]; - const percent = progress?.percent || 0; - const isProcessing = effectiveStatus === 1 || effectiveStatus === 2; + const percent = (meeting.status === 1 || meeting.status === 2) ? (progress?.percent || 0) : 0; + const isProcessing = meeting.status === 1 || meeting.status === 2; return (
{isProcessing && percent > 0 && ( @@ -151,8 +184,10 @@ const TableStatusCell: React.FC<{ meeting: MeetingVO, fetchData: () => void }> = const MeetingCardItem: React.FC<{ item: MeetingVO, config: any, fetchData: () => void, t: any, onEditParticipants: (meeting: MeetingVO) => void, onOpenMeeting: (meeting: MeetingVO) => void }> = ({ item, config, fetchData, t, onEditParticipants, onOpenMeeting }) => { const progress = useMeetingProgress(item, () => fetchData()); const effectiveStatus = item.displayStatus ?? item.status; - const isProcessing = effectiveStatus === 1 || effectiveStatus === 2; + const isProcessing = item.status === 1 || item.status === 2; const isPaused = effectiveStatus === PAUSED_DISPLAY_STATUS; + const isRealtimeActive = effectiveStatus === REALTIME_ACTIVE_DISPLAY_STATUS; + const isRealtimeIdle = effectiveStatus === REALTIME_IDLE_DISPLAY_STATUS; return ( @@ -164,7 +199,7 @@ const MeetingCardItem: React.FC<{ item: MeetingVO, config: any, fetchData: () =>
onEditParticipants(item)} />
{ e?.stopPropagation(); deleteMeeting(item.id).then(fetchData); }} + onConfirm={(e) => { e?.stopPropagation(); deleteMeeting(item.id).then(() => fetchData()); }} okText={t('common.confirm')} cancelText={t('common.cancel')} onCancel={(e) => e?.stopPropagation()} @@ -231,6 +266,44 @@ const MeetingCardItem: React.FC<{ item: MeetingVO, config: any, fetchData: () => 会议已暂停,可继续识别
+ ) : isRealtimeActive ? ( +
+ + + 实时会议进行中,可直接进入控制页 + +
+ ) : isRealtimeIdle ? ( +
+ + + 实时会议尚未开始,可进入继续操作 + +
) : (
{item.participants || '无参与人员'}
@@ -271,10 +344,7 @@ const Meetings: React.FC = () => { const [editingMeeting, setEditingMeeting] = useState(null); const [participantsEditLoading, setParticipantsEditLoading] = useState(false); const [participantsEditForm] = Form.useForm(); - const hasRunningTasks = data.some(item => { - const effectiveStatus = item.displayStatus ?? item.status; - return effectiveStatus === 0 || effectiveStatus === 1 || effectiveStatus === 2; - }); + const hasRunningTasks = data.some((item) => shouldPollMeetingCard(item)); const handleDisplayModeChange = (mode: 'card' | 'list') => { setDisplayMode(mode); @@ -309,10 +379,13 @@ const Meetings: React.FC = () => { if (res.data && res.data.data) { const records = res.data.data.records || []; let statusMap: Record = {}; - try { - const sessionRes = await getRealtimeMeetingSessionStatuses(records.map((item) => item.id)); - statusMap = sessionRes.data?.data || {}; - } catch {} + const realtimeCandidates = records.filter(isRealtimeMeetingCandidate); + if (realtimeCandidates.length > 0) { + try { + const sessionRes = await getRealtimeMeetingSessionStatuses(realtimeCandidates.map((item) => item.id)); + statusMap = sessionRes.data?.data || {}; + } catch {} + } const withDisplayStatus = records.map((item) => applyRealtimeSessionStatus(item, statusMap[item.id])); setData(withDisplayStatus); setTotal(res.data.data.total); @@ -321,15 +394,25 @@ const Meetings: React.FC = () => { }; const handleOpenMeeting = async (meeting: MeetingVO) => { + if (!isRealtimeMeetingCandidate(meeting)) { + navigate(`/meetings/${meeting.id}`); + return; + } + + if (canOpenRealtimeSession(meeting.realtimeSessionStatus)) { + navigate(`/meeting-live-session/${meeting.id}`); + return; + } + + if (!canManageRealtimeMeeting(meeting)) { + navigate(`/meetings/${meeting.id}`); + return; + } + try { const res = await getRealtimeMeetingSessionStatus(meeting.id); - const sessionStatus = res.data?.data; - if (sessionStatus && !meeting.audioUrl && ( - sessionStatus.status === 'PAUSED_EMPTY' - || sessionStatus.status === 'PAUSED_RESUMABLE' - || sessionStatus.status === 'ACTIVE' - || sessionStatus.status === 'IDLE' - )) { + const sessionStatus = res.data?.data?.status; + if (canOpenRealtimeSession(sessionStatus)) { navigate(`/meeting-live-session/${meeting.id}`); return; } @@ -371,7 +454,9 @@ const Meetings: React.FC = () => { 2: { text: '总结中', color: '#faad14', bgColor: '#fff7e6' }, 3: { text: '已完成', color: '#52c41a', bgColor: '#f6ffed' }, 4: { text: '失败', color: '#ff4d4f', bgColor: '#fff1f0' }, - 5: { text: '会议暂停', color: '#d48806', bgColor: '#fff7e6' } + 5: { text: '会议暂停', color: '#d48806', bgColor: '#fff7e6' }, + 6: { text: '实时进行中', color: '#1677ff', bgColor: '#e6f4ff' }, + 7: { text: '待开始', color: '#595959', bgColor: '#f5f5f5' } }; const tableColumns = [ @@ -415,7 +500,7 @@ const Meetings: React.FC = () => { { e?.stopPropagation(); deleteMeeting(record.id).then(fetchData); }} + onConfirm={(e) => { e?.stopPropagation(); deleteMeeting(record.id).then(() => fetchData()); }} okText={t('common.confirm')} cancelText={t('common.cancel')} onCancel={(e) => e?.stopPropagation()}