diff --git a/backend/src/main/java/com/imeeting/controller/biz/MeetingController.java b/backend/src/main/java/com/imeeting/controller/biz/MeetingController.java index 89054da..6ce8af2 100644 --- a/backend/src/main/java/com/imeeting/controller/biz/MeetingController.java +++ b/backend/src/main/java/com/imeeting/controller/biz/MeetingController.java @@ -366,6 +366,16 @@ public class MeetingController { return ApiResponse.ok(true); } + @PostMapping("/{id}/transcripts/regenerate") + @PreAuthorize("isAuthenticated()") + public ApiResponse retryTranscription(@PathVariable Long id) { + LoginUser loginUser = currentLoginUser(); + Meeting meeting = meetingAccessService.requireMeeting(id); + meetingAccessService.assertCanEditMeeting(meeting, loginUser); + meetingCommandService.retryTranscription(id); + return ApiResponse.ok(true); + } + @PutMapping("/{id}/basic") @PreAuthorize("isAuthenticated()") public ApiResponse updateBasic(@PathVariable Long id, @RequestBody UpdateMeetingBasicCommand command) { diff --git a/backend/src/main/java/com/imeeting/service/biz/MeetingCommandService.java b/backend/src/main/java/com/imeeting/service/biz/MeetingCommandService.java index a41a88f..81969d8 100644 --- a/backend/src/main/java/com/imeeting/service/biz/MeetingCommandService.java +++ b/backend/src/main/java/com/imeeting/service/biz/MeetingCommandService.java @@ -33,4 +33,6 @@ public interface MeetingCommandService { void updateSummaryContent(Long meetingId, String summaryContent); void reSummary(Long meetingId, Long summaryModelId, Long promptId); + + void retryTranscription(Long meetingId); } diff --git a/backend/src/main/java/com/imeeting/service/biz/impl/MeetingCommandServiceImpl.java b/backend/src/main/java/com/imeeting/service/biz/impl/MeetingCommandServiceImpl.java index 45fabae..3641c2c 100644 --- a/backend/src/main/java/com/imeeting/service/biz/impl/MeetingCommandServiceImpl.java +++ b/backend/src/main/java/com/imeeting/service/biz/impl/MeetingCommandServiceImpl.java @@ -458,6 +458,52 @@ public class MeetingCommandServiceImpl implements MeetingCommandService { dispatchSummaryTaskAfterCommit(meetingId, meeting.getTenantId(), meeting.getCreatorId()); } + @Override + @Transactional(rollbackFor = Exception.class) + public void retryTranscription(Long meetingId) { + Meeting meeting = meetingService.getById(meetingId); + if (meeting == null) { + throw new RuntimeException("Meeting not found"); + } + + long transcriptCount = transcriptMapper.selectCount(new LambdaQueryWrapper() + .eq(MeetingTranscript::getMeetingId, meetingId)); + if (transcriptCount > 0) { + throw new RuntimeException("当前会议已有转录内容,无需重新识别"); + } + if (meeting.getAudioUrl() == null || meeting.getAudioUrl().isBlank()) { + throw new RuntimeException("当前会议缺少音频文件,无法重新识别"); + } + + AiTask asrTask = aiTaskService.getOne(new LambdaQueryWrapper() + .eq(AiTask::getMeetingId, meetingId) + .eq(AiTask::getTaskType, "ASR") + .orderByDesc(AiTask::getId) + .last("LIMIT 1")); + if (asrTask == null || asrTask.getTaskConfig() == null || asrTask.getTaskConfig().get("asrModelId") == null) { + throw new RuntimeException("未找到可用的识别任务配置"); + } + + AiTask summaryTask = aiTaskService.getOne(new LambdaQueryWrapper() + .eq(AiTask::getMeetingId, meetingId) + .eq(AiTask::getTaskType, "SUMMARY") + .orderByDesc(AiTask::getId) + .last("LIMIT 1")); + if (summaryTask == null || summaryTask.getTaskConfig() == null) { + throw new RuntimeException("未找到可用的总结任务配置"); + } + + resetAiTask(asrTask, new HashMap<>(asrTask.getTaskConfig())); + aiTaskService.updateById(asrTask); + resetAiTask(summaryTask, new HashMap<>(summaryTask.getTaskConfig())); + aiTaskService.updateById(summaryTask); + + meeting.setStatus(1); + meetingService.updateById(meeting); + updateMeetingProgress(meetingId, 0, "已重新提交识别任务,等待 ASR 处理...", 0); + dispatchTasksAfterCommit(meetingId, meeting.getTenantId(), meeting.getCreatorId()); + } + private void dispatchSummaryTaskAfterCommit(Long meetingId, Long tenantId, Long userId) { if (!TransactionSynchronizationManager.isSynchronizationActive()) { aiTaskService.dispatchSummaryTask(meetingId, tenantId, userId); @@ -471,6 +517,19 @@ public class MeetingCommandServiceImpl implements MeetingCommandService { }); } + private void dispatchTasksAfterCommit(Long meetingId, Long tenantId, Long userId) { + if (!TransactionSynchronizationManager.isSynchronizationActive()) { + aiTaskService.dispatchTasks(meetingId, tenantId, userId); + return; + } + TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() { + @Override + public void afterCommit() { + aiTaskService.dispatchTasks(meetingId, tenantId, userId); + } + }); + } + private void updateMeetingProgress(Long meetingId, int percent, String message, int eta) { try { Map progress = new HashMap<>(); diff --git a/backend/src/test/java/com/imeeting/service/biz/impl/MeetingCommandServiceImplTest.java b/backend/src/test/java/com/imeeting/service/biz/impl/MeetingCommandServiceImplTest.java index b87b386..15a9eed 100644 --- a/backend/src/test/java/com/imeeting/service/biz/impl/MeetingCommandServiceImplTest.java +++ b/backend/src/test/java/com/imeeting/service/biz/impl/MeetingCommandServiceImplTest.java @@ -23,9 +23,11 @@ import org.springframework.transaction.support.TransactionSynchronizationManager import org.springframework.transaction.support.TransactionSynchronizationUtils; import java.time.LocalDateTime; +import java.util.Map; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; import static org.mockito.ArgumentMatchers.argThat; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyLong; @@ -332,6 +334,105 @@ class MeetingCommandServiceImplTest { } } + @Test + void retryTranscriptionShouldResetTasksAndDispatchAfterTransactionCommit() { + MeetingService meetingService = mock(MeetingService.class); + MeetingDomainSupport meetingDomainSupport = mock(MeetingDomainSupport.class); + AiTaskService aiTaskService = mock(AiTaskService.class); + com.imeeting.mapper.biz.MeetingTranscriptMapper transcriptMapper = mock(com.imeeting.mapper.biz.MeetingTranscriptMapper.class); + Meeting meeting = new Meeting(); + meeting.setId(401L); + meeting.setAudioUrl("/audio/demo.wav"); + + AiTask asrTask = new AiTask(); + asrTask.setTaskType("ASR"); + asrTask.setStatus(3); + asrTask.setTaskConfig(Map.of("asrModelId", 11L)); + asrTask.setErrorMsg("failed"); + asrTask.setStartedAt(LocalDateTime.now()); + asrTask.setCompletedAt(LocalDateTime.now()); + + AiTask summaryTask = new AiTask(); + summaryTask.setTaskType("SUMMARY"); + summaryTask.setStatus(3); + summaryTask.setTaskConfig(Map.of("summaryModelId", 22L, "promptId", 33L)); + summaryTask.setErrorMsg("failed"); + summaryTask.setStartedAt(LocalDateTime.now()); + summaryTask.setCompletedAt(LocalDateTime.now()); + + when(meetingService.getById(401L)).thenReturn(meeting); + when(transcriptMapper.selectCount(any())).thenReturn(0L); + when(aiTaskService.getOne(any())).thenReturn(asrTask, summaryTask); + + MeetingCommandServiceImpl service = new MeetingCommandServiceImpl( + meetingService, + aiTaskService, + mock(HotWordService.class), + transcriptMapper, + mock(MeetingSummaryFileService.class), + meetingDomainSupport, + mockRuntimeProfileResolver(), + mock(RealtimeMeetingSessionStateService.class), + mock(RealtimeMeetingAudioStorageService.class), + mock(StringRedisTemplate.class), + new ObjectMapper() + ); + + TransactionSynchronizationManager.initSynchronization(); + try { + service.retryTranscription(401L); + + assertEquals(1, meeting.getStatus()); + assertEquals(0, asrTask.getStatus()); + assertEquals(0, summaryTask.getStatus()); + assertNull(asrTask.getErrorMsg()); + assertNull(summaryTask.getErrorMsg()); + verify(aiTaskService).updateById(asrTask); + verify(aiTaskService).updateById(summaryTask); + verify(meetingService).updateById(meeting); + verify(aiTaskService, never()).dispatchTasks(401L, null, null); + + TransactionSynchronizationUtils.triggerAfterCommit(); + + verify(aiTaskService).dispatchTasks(401L, null, null); + } finally { + TransactionSynchronizationManager.clearSynchronization(); + } + } + + @Test + void retryTranscriptionShouldRejectMeetingsWithExistingTranscripts() { + MeetingService meetingService = mock(MeetingService.class); + AiTaskService aiTaskService = mock(AiTaskService.class); + com.imeeting.mapper.biz.MeetingTranscriptMapper transcriptMapper = mock(com.imeeting.mapper.biz.MeetingTranscriptMapper.class); + Meeting meeting = new Meeting(); + meeting.setId(402L); + meeting.setAudioUrl("/audio/demo.wav"); + + when(meetingService.getById(402L)).thenReturn(meeting); + when(transcriptMapper.selectCount(any())).thenReturn(1L); + + MeetingCommandServiceImpl service = new MeetingCommandServiceImpl( + meetingService, + aiTaskService, + mock(HotWordService.class), + transcriptMapper, + mock(MeetingSummaryFileService.class), + mock(MeetingDomainSupport.class), + mockRuntimeProfileResolver(), + mock(RealtimeMeetingSessionStateService.class), + mock(RealtimeMeetingAudioStorageService.class), + mock(StringRedisTemplate.class), + new ObjectMapper() + ); + + RuntimeException error = assertThrows(RuntimeException.class, () -> service.retryTranscription(402L)); + + assertEquals("当前会议已有转录内容,无需重新识别", error.getMessage()); + verify(aiTaskService, never()).getOne(any()); + verify(aiTaskService, never()).dispatchTasks(anyLong(), any(), any()); + } + @Test void createMeetingShouldPersistResolvedRuntimeProfile() { MeetingService meetingService = mock(MeetingService.class); diff --git a/frontend/src/api/business/meeting.ts b/frontend/src/api/business/meeting.ts index 36d8cce..d24c584 100644 --- a/frontend/src/api/business/meeting.ts +++ b/frontend/src/api/business/meeting.ts @@ -262,6 +262,13 @@ export const reSummary = (params: MeetingResummaryDTO) => { ); }; +export const retryMeetingTranscription = (meetingId: number) => { + return http.post<{ code: string; data: boolean; msg: string }>( + `/api/biz/meeting/${meetingId}/transcripts/regenerate`, + {} + ); +}; + export const updateMeetingBasic = (data: UpdateMeetingBasicCommand) => { return http.put<{ code: string; data: boolean; msg: string }>( `/api/biz/meeting/${data.meetingId}/basic`, diff --git a/frontend/src/pages/business/MeetingDetail.tsx b/frontend/src/pages/business/MeetingDetail.tsx index 1b69365..ec9d3e1 100644 --- a/frontend/src/pages/business/MeetingDetail.tsx +++ b/frontend/src/pages/business/MeetingDetail.tsx @@ -26,6 +26,7 @@ import { MeetingTranscriptVO, MeetingVO, reSummary, + retryMeetingTranscription, updateMeetingBasic, updateMeetingTranscript, updateMeetingSummary, @@ -566,6 +567,7 @@ const MeetingDetail: React.FC = () => { }, [meeting]); const canRetrySummary = isOwner && transcripts.length > 0 && meeting?.status !== 1 && meeting?.status !== 2; + const canRetryTranscription = isOwner && meeting?.status === 4 && transcripts.length === 0 && !!meeting?.audioUrl; useEffect(() => { if (!id) return; @@ -715,6 +717,19 @@ const MeetingDetail: React.FC = () => { } }; + const handleRetryTranscription = async () => { + setActionLoading(true); + try { + await retryMeetingTranscription(Number(id)); + message.success('已重新提交识别任务'); + await fetchData(Number(id)); + } catch (error) { + console.error(error); + } finally { + setActionLoading(false); + } + }; + const handleKeywordToggle = (keyword: string, checked: boolean) => { setSelectedKeywords((current) => { if (checked) { @@ -975,6 +990,11 @@ const MeetingDetail: React.FC = () => { + {canRetryTranscription && ( + + )} {canRetrySummary && (