feat: 添加重新识别会议转录功能

- 在 `MeetingCommandServiceImpl` 中添加 `retryTranscription` 方法,用于重新提交转录任务
- 更新 `MeetingCommandService` 接口,添加 `retryTranscription` 方法
- 在 `MeetingController` 中添加 `retryTranscription` API 端点
- 更新前端API和组件,支持重新识别会议转录
- 添加相关单元测试以验证新功能的正确性
dev_na
chenhao 2026-04-10 15:18:05 +08:00
parent b9593324a5
commit 53ff2292a8
6 changed files with 199 additions and 0 deletions

View File

@ -366,6 +366,16 @@ public class MeetingController {
return ApiResponse.ok(true);
}
@PostMapping("/{id}/transcripts/regenerate")
@PreAuthorize("isAuthenticated()")
public ApiResponse<Boolean> 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<Boolean> updateBasic(@PathVariable Long id, @RequestBody UpdateMeetingBasicCommand command) {

View File

@ -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);
}

View File

@ -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<MeetingTranscript>()
.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<AiTask>()
.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<AiTask>()
.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<String, Object> progress = new HashMap<>();

View File

@ -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);

View File

@ -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`,

View File

@ -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 = () => {
</Col>
<Col>
<Space>
{canRetryTranscription && (
<Button icon={<SyncOutlined />} type="primary" onClick={handleRetryTranscription} loading={actionLoading}>
</Button>
)}
{canRetrySummary && (
<Button icon={<SyncOutlined />} type="primary" ghost onClick={() => setSummaryVisible(true)} disabled={actionLoading}>