feat: 添加总结和章节任务重试功能并优化会议处理逻辑

- 在 `MeetingCommandServiceImpl` 中添加 `retrySummary` 和 `retryChapter` 方法,支持总结和章节任务的重试
- 更新 `MeetingCommandService` 接口以包含新的重试方法
- 优化 `executeSummaryFlow` 和 `doDispatchChapterTask` 方法,简化任务执行逻辑
- 更新 `finalizeSummary` 方法,移除不必要的章节版本检查
- 调整 `updateMeetingProgress` 的进度值和消息,更准确地反映任务状态
- 在前端 `MeetingDetail.tsx` 中添加重试按钮和相关逻辑,支持用户手动重试失败的任务
dev_na
chenhao 2026-05-27 17:44:15 +08:00
parent 892275bc65
commit 384494d9ff
15 changed files with 414 additions and 189 deletions

View File

@ -9,4 +9,7 @@ public final class SysParamKeys {
public static final String MEETING_CREATE_OFFLINE_ENABLED = "meeting.create.offline_enabled";
public static final String MEETING_CREATE_REALTIME_ENABLED = "meeting.create.realtime_enabled";
public static final String MEETING_ASR_MAX_CONCURRENT = "meeting.asr.max_concurrent";
public static final String MEETING_MAX_PAUSE_DURATION = "meeting.max_pause_duration";
public static final String MEETING_MAX_MEETING_DURATION = "meeting.max_meeting_duration";
public static final String MEETING_PACKET_LOSS_RATE = "meeting.packet_loss_rate";
}

View File

@ -12,6 +12,7 @@ import io.swagger.v3.oas.annotations.responses.ApiResponses;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.servlet.http.HttpServletRequest;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
@ -21,6 +22,7 @@ import org.springframework.web.bind.annotation.RestController;
@RestController
@RequestMapping("/api/android/clients")
@RequiredArgsConstructor
@Slf4j
public class AndroidClientController {
private final AndroidAuthService androidAuthService;

View File

@ -5,7 +5,9 @@ import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.imeeting.common.RedisKeys;
import com.imeeting.common.SysParamKeys;
import com.imeeting.dto.android.AndroidAuthContext;
import com.imeeting.dto.android.AndroidMeetingConfigVo;
import com.imeeting.dto.android.legacy.LegacyMeetingAccessPasswordRequest;
import com.imeeting.dto.android.legacy.LegacyMeetingAttendeeResponse;
import com.imeeting.dto.android.legacy.LegacyMeetingCreateRequest;
@ -13,19 +15,15 @@ import com.imeeting.dto.android.legacy.LegacyMeetingPreviewDataResponse;
import com.imeeting.dto.android.legacy.LegacyMeetingPreviewResult;
import com.imeeting.dto.android.legacy.LegacyMeetingProcessingStatusResponse;
import com.imeeting.dto.android.legacy.LegacyUploadAudioResponse;
import com.imeeting.dto.biz.AiModelVO;
import com.imeeting.dto.biz.MeetingVO;
import com.imeeting.dto.biz.PromptTemplateVO;
import com.imeeting.entity.biz.AiTask;
import com.imeeting.entity.biz.Meeting;
import com.imeeting.entity.biz.PromptTemplate;
import com.imeeting.service.android.AndroidAuthService;
import com.imeeting.service.android.legacy.LegacyMeetingAdapterService;
import com.imeeting.service.biz.AiTaskService;
import com.imeeting.service.biz.MeetingAccessService;
import com.imeeting.service.biz.MeetingCommandService;
import com.imeeting.service.biz.MeetingProgressService;
import com.imeeting.service.biz.MeetingQueryService;
import com.imeeting.service.biz.MeetingService;
import com.imeeting.service.biz.PromptTemplateService;
import com.imeeting.service.biz.*;
import com.imeeting.service.biz.impl.RedisOnlyMeetingProgressServiceAdapter;
import com.unisbase.common.ApiResponse;
import com.unisbase.common.annotation.Log;
@ -33,6 +31,9 @@ import com.unisbase.dto.PageResult;
import com.unisbase.entity.SysUser;
import com.unisbase.mapper.SysUserMapper;
import com.unisbase.security.LoginUser;
import com.unisbase.service.SysDictItemService;
import com.unisbase.service.SysParamService;
import com.unisbase.service.impl.SysDictItemServiceImpl;
import io.swagger.v3.oas.annotations.media.ArraySchema;
import io.swagger.v3.oas.annotations.media.Content;
import io.swagger.v3.oas.annotations.media.Schema;
@ -54,6 +55,7 @@ import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.multipart.MultipartFile;
import java.io.IOException;
import java.math.BigDecimal;
import java.time.LocalDateTime;
import java.util.Arrays;
import java.util.LinkedHashMap;
@ -81,6 +83,9 @@ public class AndroidMeetingController {
private final AiTaskService aiTaskService;
private final PromptTemplateService promptTemplateService;
private final SysUserMapper sysUserMapper;
private final AiModelService aiModelService;
private final SysDictItemService dictItemService;
private final SysParamService paramService;
private final MeetingProgressService meetingProgressService;
private final ObjectMapper objectMapper;
@ -94,6 +99,9 @@ public class AndroidMeetingController {
AiTaskService aiTaskService,
PromptTemplateService promptTemplateService,
SysUserMapper sysUserMapper,
AiModelService aiModelService,
SysDictItemService dictItemService,
SysParamService paramService,
MeetingProgressService meetingProgressService,
ObjectMapper objectMapper) {
this.androidAuthService = androidAuthService;
@ -107,6 +115,9 @@ public class AndroidMeetingController {
this.sysUserMapper = sysUserMapper;
this.meetingProgressService = meetingProgressService;
this.objectMapper = objectMapper;
this.aiModelService = aiModelService;
this.paramService = paramService;
this.dictItemService = dictItemService;
}
public AndroidMeetingController(AndroidAuthService androidAuthService,
@ -118,6 +129,9 @@ public class AndroidMeetingController {
AiTaskService aiTaskService,
PromptTemplateService promptTemplateService,
SysUserMapper sysUserMapper,
AiModelService aiModelService,
SysDictItemService dictItemService,
SysParamService paramService,
StringRedisTemplate redisTemplate,
ObjectMapper objectMapper) {
this(
@ -130,6 +144,9 @@ public class AndroidMeetingController {
aiTaskService,
promptTemplateService,
sysUserMapper,
aiModelService,
dictItemService,
paramService,
new RedisOnlyMeetingProgressServiceAdapter(redisTemplate, objectMapper),
objectMapper
);
@ -266,6 +283,43 @@ public class AndroidMeetingController {
meetingCommandService.deleteMeeting(meetingId);
return ApiResponse.ok(true);
}
@GetMapping("/config")
@Log(value = "获取会议配置", type = "Android会议管理")
@Operation(summary = "获取会议配置")
public ApiResponse<AndroidMeetingConfigVo> config(HttpServletRequest request) {
AndroidAuthContext authContext = androidAuthService.authenticateHttp(request);
LoginUser loginUser = AndroidLoginUserSupport.requireLoginUser(authContext);
AndroidMeetingConfigVo resultVo = new AndroidMeetingConfigVo();
PageResult<List<PromptTemplateVO>> promptTemplateList = promptTemplateService.pageTemplates(
1,
1000,
null,
null,
loginUser.getTenantId(),
loginUser.getUserId(),
loginUser.getIsPlatformAdmin(),
loginUser.getIsTenantAdmin()
);
List<PromptTemplateVO> enabledTemplates = promptTemplateList.getRecords() == null
? List.of()
: promptTemplateList.getRecords().stream()
.filter(item -> Integer.valueOf(1).equals(item.getStatus()))
.toList();
resultVo.setTemplateList(enabledTemplates);
PageResult<List<AiModelVO>> modelList = aiModelService.pageModels(1, 1000, null, "LLM", loginUser.getTenantId());
List<AiModelVO> enabledModels = modelList.getRecords() == null
? List.of()
: modelList.getRecords().stream()
.filter(item -> Integer.valueOf(1).equals(item.getStatus()))
.toList();
resultVo.setModelsList(enabledModels);
resultVo.setSummaryDegreeOfDetail(dictItemService.getItemsByTypeCode("summary_degree_detail"));
resultVo.setMaxMeetingDuration(Integer.valueOf(paramService.getParamValue(SysParamKeys.MEETING_MAX_MEETING_DURATION,"30")));
resultVo.setMaxPauseDuration(Integer.valueOf(paramService.getParamValue(SysParamKeys.MEETING_MAX_PAUSE_DURATION,String.valueOf(60*4))));
resultVo.setPacketLossRate(new BigDecimal(paramService.getParamValue(SysParamKeys.MEETING_MAX_PAUSE_DURATION,"99")));
return ApiResponse.ok(resultVo);
}
private LegacyMeetingPreviewResult buildPreviewResult(Long meetingId) {
Meeting meeting = meetingService.getById(meetingId);
@ -291,7 +345,7 @@ public class AndroidMeetingController {
buildProcessingPreview(meeting, summaryTask, processingStatus("摘要已生成,可查看详情", 100, STAGE_COMPLETED))
);
}
if (isFailed(asrTask) || Integer.valueOf(4).equals(meeting.getStatus())) {
if (isFailed(asrTask)) {
return new LegacyMeetingPreviewResult(
"503",
buildFailureMessage(asrTask, "转写"),

View File

@ -283,7 +283,7 @@ public class LegacyMeetingController {
// buildProcessingPreview(meeting, summaryTask, processingStatus("摘要已生成,可扫码查看", 100, STAGE_COMPLETED))
// );
// }
if (isFailed(asrTask) || Integer.valueOf(4).equals(meeting.getStatus())) {
if (isFailed(asrTask)) {
return new LegacyMeetingPreviewResult(
"503",
buildFailureMessage(asrTask, "转译"),
@ -440,7 +440,7 @@ public class LegacyMeetingController {
if (Integer.valueOf(3).equals(meeting.getStatus()) || summaryCompleted) {
return new LegacyMeetingProcessingStatusResponse("completed", 100, STAGE_COMPLETED);
}
if (isFailed(asrTask) || Integer.valueOf(4).equals(meeting.getStatus())) {
if (isFailed(asrTask)) {
return new LegacyMeetingProcessingStatusResponse("failed", 50, STAGE_AUDIO_TRANSCRIPTION);
}
if (isFailed(summaryTask)) {

View File

@ -531,6 +531,28 @@ public class MeetingController {
return ApiResponse.ok(true);
}
@Operation(summary = "重试 AI 目录")
@PostMapping("/{id}/chapters/retry")
@PreAuthorize("isAuthenticated()")
public ApiResponse<Boolean> retryChapter(@PathVariable Long id) {
LoginUser loginUser = currentLoginUser();
Meeting meeting = meetingAccessService.requireMeeting(id);
meetingAccessService.assertCanEditMeeting(meeting, loginUser);
meetingCommandService.retryChapter(id);
return ApiResponse.ok(true);
}
@Operation(summary = "重试会议总结")
@PostMapping("/{id}/summary/retry")
@PreAuthorize("isAuthenticated()")
public ApiResponse<Boolean> retrySummary(@PathVariable Long id) {
LoginUser loginUser = currentLoginUser();
Meeting meeting = meetingAccessService.requireMeeting(id);
meetingAccessService.assertCanEditMeeting(meeting, loginUser);
meetingCommandService.retrySummary(id);
return ApiResponse.ok(true);
}
@Operation(summary = "更新会议基础信息")
@PutMapping("/{id}/basic")
@PreAuthorize("isAuthenticated()")

View File

@ -0,0 +1,34 @@
package com.imeeting.dto.android;
import com.imeeting.dto.biz.AiModelVO;
import com.imeeting.dto.biz.PromptTemplateVO;
import com.unisbase.dto.SysDictItemDTO;
import com.unisbase.entity.SysDictItem;
import lombok.Data;
import java.math.BigDecimal;
import java.util.List;
/**
* @author : ch
* @version : 1.0
* @ClassName : AndroidMeetingConfigVo
* @Description :
* @DATE : Created in 17:02 2026/5/27
* <pre> Copyright: Copyright(c) 2026 </pre>
* <pre> Company : </pre>
* Modification History:
* Date Author Version Discription
* --------------------------------------------------------------------------
* 2026/05/27 ch 1.0 Why & What is modified: <> *
*/
@Data
public class AndroidMeetingConfigVo {
private List<AiModelVO> modelsList;
private List<PromptTemplateVO> templateList;
private List<SysDictItemDTO> summaryDegreeOfDetail;
private Integer maxPauseDuration;
private Integer maxMeetingDuration;
private BigDecimal packetLossRate;
}

View File

@ -21,8 +21,7 @@ public class MeetingSummaryFinalizeDTO {
@Schema(description = "转录指纹")
private String sourceFingerprint;
@NotNull(message = "chapterVersionId must not be null")
@Schema(description = "章节版本ID")
@Schema(description = "章节版本ID可选仅用于审计记录")
private Long chapterVersionId;
@NotBlank(message = "summaryContent must not be blank")

View File

@ -11,6 +11,7 @@ import com.unisbase.security.LoginUser;
import com.unisbase.service.TokenValidationService;
import jakarta.servlet.http.HttpServletRequest;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.stereotype.Service;
@ -18,6 +19,7 @@ import org.springframework.util.StringUtils;
@Service
@RequiredArgsConstructor
@Slf4j
public class AndroidAuthServiceImpl implements AndroidAuthService {
private static final String HEADER_DEVICE_ID = "X-Android-Device-Id";
@ -50,8 +52,8 @@ public class AndroidAuthServiceImpl implements AndroidAuthService {
String platform = request.getHeader(HEADER_PLATFORM);
requireAndroidHttpHeaders(deviceId, appVersion, platform);
log.info("[安卓接口访问]X-Android-Device-Id={},X-Android-App-Version={},X-Android-Platform={}",deviceId,appVersion,platform);
assertDeviceEnabled(deviceId);
if (loginUser != null) {
return buildContext("USER_JWT", false,
deviceId,

View File

@ -5,5 +5,7 @@ import com.imeeting.entity.biz.AiTask;
public interface AiTaskService extends IService<AiTask> {
void dispatchTasks(Long meetingId, Long tenantId, Long userId);
void dispatchChapterTask(Long meetingId, Long tenantId, Long userId);
void dispatchSummaryTask(Long meetingId, Long tenantId, Long userId);
void reconcileMeetingStatus(Long meetingId);
}

View File

@ -41,6 +41,10 @@ public interface MeetingCommandService {
void retryTranscription(Long meetingId);
void retrySummary(Long meetingId);
void retryChapter(Long meetingId);
MeetingTranscriptChapterImportResultVO importTranscriptChapters(MeetingTranscriptChapterImportDTO command);
void finalizeSummary(MeetingSummaryFinalizeDTO command);

View File

@ -10,6 +10,7 @@ import com.imeeting.common.MeetingProgressStage;
import com.imeeting.common.RedisKeys;
import com.imeeting.dto.biz.AiModelVO;
import com.imeeting.dto.biz.MeetingSummarySource;
import com.imeeting.dto.biz.MeetingTranscriptSourceVO;
import com.imeeting.entity.biz.AiTask;
import com.imeeting.entity.biz.HotWord;
import com.imeeting.entity.biz.Meeting;
@ -247,30 +248,14 @@ public class AiTaskServiceImpl extends ServiceImpl<AiTaskMapper, AiTask> impleme
if (!asrText.isBlank()) {
meetingProgressService.markStage(meetingId, asrTask, 1, MeetingProgressStage.ASR_COMPLETED, 80, "转写完成,准备生成总结", 0);
scheduleQueuedAsrTasks();
self.dispatchChapterTask(meetingId, meeting.getTenantId(), meeting.getCreatorId());
self.dispatchSummaryTask(meetingId, meeting.getTenantId(), meeting.getCreatorId());
return;
}
if (chapterTask != null && canExecuteTask(chapterTask)) {
executeChapterFlow(meeting, chapterTask);
}
if (chapterTask != null && Integer.valueOf(3).equals(chapterTask.getStatus()) && hasDetailedChapterFailureMessage(chapterTask)) {
String chapterFailureMessage = buildChapterFailurePropagationMessage(chapterTask);
failPendingSummaryTask(sumTask, chapterFailureMessage);
updateMeetingStatus(meetingId, 4);
updateProgress(meetingId, -1, chapterFailureMessage, 0);
return;
}
if (chapterTask != null && Integer.valueOf(3).equals(chapterTask.getStatus())) {
failPendingSummaryTask(sumTask, "章节生成失败,无法继续总结");
updateMeetingStatus(meetingId, 4);
updateProgress(meetingId, -1, "章节生成失败,无法继续总结", 0);
return;
}
if (sumTask != null && canExecuteTask(sumTask)) {
executeSummaryFlow(meeting, sumTask, chapterTask);
} else if (meeting.getStatus() != 3) {
updateMeetingStatus(meetingId, 3);
executeSummaryFlow(meeting, sumTask);
}
reconcileMeetingStatus(meetingId);
} catch (Exception e) {
log.error("Meeting {} AI Task Flow failed", meetingId, e);
failPendingSummaryTask(findLatestSummaryTask(meetingId), "转录失败,已跳过总结任务: " + e.getMessage());
@ -282,6 +267,31 @@ public class AiTaskServiceImpl extends ServiceImpl<AiTaskMapper, AiTask> impleme
}
}
@Override
@Async("summaryDispatchExecutor")
public void dispatchChapterTask(Long meetingId, Long tenantId, Long userId) {
Runnable task = () -> taskSecurityContextRunner.runAsTenantUser(tenantId, userId, () -> doDispatchChapterTask(meetingId));
if (summaryTaskExecutor == null) {
task.run();
return;
}
summaryTaskExecutor.execute(task);
}
private void doDispatchChapterTask(Long meetingId) {
Meeting meeting = meetingMapper.selectById(meetingId);
if (meeting == null) {
return;
}
AiTask chapterTask = findLatestTask(meetingId, "CHAPTER");
if (chapterTask == null || !canExecuteTask(chapterTask)) {
reconcileMeetingStatus(meetingId);
return;
}
executeChapterFlow(meeting, chapterTask);
reconcileMeetingStatus(meetingId);
}
@Override
@Async("summaryDispatchExecutor")
public void dispatchSummaryTask(Long meetingId, Long tenantId, Long userId) {
@ -304,32 +314,15 @@ public class AiTaskServiceImpl extends ServiceImpl<AiTaskMapper, AiTask> impleme
triggerExternalSummaryWebhook(meeting, sumTask, chapterTask, "AUTO_SUMMARY_DISPATCH", false);
return;
}
AiTask chapterTask = findLatestTask(meetingId, "CHAPTER");
AiTask sumTask = findLatestTask(meetingId, "SUMMARY");
try {
if (chapterTask != null && canExecuteTask(chapterTask)) {
executeChapterFlow(meeting, chapterTask);
}
if (chapterTask != null && Integer.valueOf(3).equals(chapterTask.getStatus()) && hasDetailedChapterFailureMessage(chapterTask)) {
String chapterFailureMessage = buildChapterFailurePropagationMessage(chapterTask);
failPendingSummaryTask(sumTask, chapterFailureMessage);
updateMeetingStatus(meetingId, 4);
updateProgress(meetingId, -1, chapterFailureMessage, 0);
return;
}
if (chapterTask != null && Integer.valueOf(3).equals(chapterTask.getStatus())) {
failPendingSummaryTask(sumTask, "章节生成失败,无法继续总结");
updateMeetingStatus(meetingId, 4);
updateProgress(meetingId, -1, "章节生成失败,无法继续总结", 0);
return;
}
if (sumTask != null && canExecuteTask(sumTask)) {
executeSummaryFlow(meeting, sumTask, chapterTask);
executeSummaryFlow(meeting, sumTask);
}
reconcileMeetingStatus(meetingId);
} catch (Exception e) {
log.error("Re-summary failed for meeting {}", meetingId, e);
updateMeetingStatus(meetingId, 4);
updateProgress(meetingId, -1, "Summary flow failed: " + e.getMessage(), 0);
reconcileMeetingStatus(meetingId);
}
}
@ -994,10 +987,14 @@ public class AiTaskServiceImpl extends ServiceImpl<AiTaskMapper, AiTask> impleme
this.updateById(taskRecord);
meeting.setLatestSummaryTaskId(taskRecord.getId());
meeting.setStatus(3);
meetingMapper.updateById(meeting);
AiTask latestChapterTask = findLatestTask(meeting.getId(), "CHAPTER");
if (latestChapterTask != null && Integer.valueOf(2).equals(latestChapterTask.getStatus())) {
updateProgress(meeting.getId(), 100, "全流程分析完成", 0);
} else {
updateProgress(meeting.getId(), 95, "总结生成完成,等待 AI 目录完成...", 0);
}
} else {
updateAiTaskFail(taskRecord, "LLM 总结失败: " + response.body());
throw new RuntimeException("AI总结生成异常");
@ -1048,54 +1045,9 @@ public class AiTaskServiceImpl extends ServiceImpl<AiTaskMapper, AiTask> impleme
}
}
private MeetingSummarySource restorePreparedSummarySource(AiTask chapterTask) {
if (chapterTask == null || chapterTask.getResponseData() == null) {
return null;
}
String rawTranscriptText = stringValue(chapterTask.getResponseData().get("rawTranscriptText"));
String chapterOutlineText = stringValue(chapterTask.getResponseData().get("chapterOutlineText"));
String text = stringValue(chapterTask.getResponseData().get("summarySourceText"));
if ((rawTranscriptText == null || rawTranscriptText.isBlank())
&& (text == null || text.isBlank())) {
return null;
}
Object summarySourceSnapshot = chapterTask.getResponseData().get("summarySource");
Map<?, ?> snapshot = summarySourceSnapshot instanceof Map<?, ?> map ? map : Map.of();
if (text == null || text.isBlank()) {
text = rawTranscriptText != null && !rawTranscriptText.isBlank()
? rawTranscriptText
: chapterOutlineText;
}
return MeetingSummarySource.builder()
.text(text)
.sourceType(stringValue(snapshot.get("sourceType")))
.fallbackUsed(Boolean.TRUE.equals(snapshot.get("fallbackUsed")))
.sourceFingerprint(firstNonBlank(
stringValue(snapshot.get("sourceFingerprint")),
stringValue(chapterTask.getResponseData().get("sourceFingerprint"))
))
.chapterVersionId(longValue(firstNonNull(
snapshot.get("chapterVersionId"),
chapterTask.getResponseData().get("chapterVersionId")
)))
.chapterCount(intValue(firstNonNull(
snapshot.get("chapterCount"),
chapterTask.getResponseData().get("chapterCount")
)))
.algorithmVersion(stringValue(snapshot.get("algorithmVersion")))
.generationMode(stringValue(snapshot.get("generationMode")))
.rawTranscriptText(rawTranscriptText)
.chapterOutlineText(chapterOutlineText)
.chapterFilePath(firstNonBlank(
stringValue(snapshot.get("chapterFilePath")),
stringValue(chapterTask.getResponseData().get("chapterFilePath"))
))
.build();
}
private void executeSummaryFlow(Meeting meeting, AiTask sumTask, AiTask chapterTask) throws Exception {
private void executeSummaryFlow(Meeting meeting, AiTask sumTask) throws Exception {
if (isExternalSummaryModeEnabled()) {
AiTask chapterTask = findLatestTask(meeting.getId(), "CHAPTER");
triggerExternalSummaryWebhook(meeting, sumTask, chapterTask, "AUTO_AFTER_TRANSCRIPT_READY", false);
return;
}
@ -1106,22 +1058,33 @@ public class AiTaskServiceImpl extends ServiceImpl<AiTaskMapper, AiTask> impleme
return;
}
try {
MeetingSummarySource summarySource = restorePreparedSummarySource(chapterTask);
if (summarySource == null || summarySource.getText() == null || summarySource.getText().isBlank()) {
summarySource = meetingTranscriptChapterService.resolveSummarySource(meeting, chapterTask != null ? chapterTask : sumTask);
}
MeetingSummarySource summarySource = buildRawTranscriptSummarySource(meeting);
if (summarySource.getText() == null || summarySource.getText().isBlank()) {
failPendingSummaryTask(sumTask, "没有转录内容");
updateMeetingStatus(meeting.getId(), 4);
updateProgress(meeting.getId(), -1, "没有转录内容", 0);
reconcileMeetingStatus(meeting.getId());
return;
}
processSummaryTask(meeting, summarySource, sumTask);
reconcileMeetingStatus(meeting.getId());
} finally {
redisValueSupport.delete(summaryLockKey);
}
}
private MeetingSummarySource buildRawTranscriptSummarySource(Meeting meeting) {
MeetingTranscriptSourceVO transcriptSource = meetingTranscriptChapterService.buildTranscriptSource(meeting.getId());
String transcriptText = transcriptSource == null ? null : stringValue(transcriptSource.getTranscriptText());
return MeetingSummarySource.builder()
.text(transcriptText)
.sourceType("RAW_TRANSCRIPT")
.fallbackUsed(false)
.sourceFingerprint(transcriptSource == null ? null : transcriptSource.getSourceFingerprint())
.generationMode("NONE")
.rawTranscriptText(transcriptText)
.chapterOutlineText("")
.build();
}
private AiTask findLatestTask(Long meetingId, String taskType) {
return this.getOne(new LambdaQueryWrapper<AiTask>()
.eq(AiTask::getMeetingId, meetingId)
@ -1176,24 +1139,37 @@ public class AiTaskServiceImpl extends ServiceImpl<AiTaskMapper, AiTask> impleme
&& !Integer.valueOf(3).equals(task.getStatus());
}
private boolean hasDetailedChapterFailureMessage(AiTask chapterTask) {
String failureMessage = buildChapterFailurePropagationMessage(chapterTask);
return failureMessage != null && !failureMessage.isBlank() && !failureMessage.equals("章节生成失败,无法继续总结");
public void reconcileMeetingStatus(Long meetingId) {
if (meetingId == null) {
return;
}
AiTask asrTask = findLatestTask(meetingId, "ASR");
AiTask chapterTask = findLatestTask(meetingId, "CHAPTER");
AiTask summaryTask = findLatestTask(meetingId, "SUMMARY");
if (isTaskFailed(asrTask) || isTaskFailed(chapterTask) || isTaskFailed(summaryTask)) {
updateMeetingStatus(meetingId, 4);
return;
}
if (isTaskCompleted(chapterTask) && isTaskCompleted(summaryTask)) {
updateMeetingStatus(meetingId, 3);
return;
}
if (isTaskCompleted(asrTask) || isTaskRunningOrQueued(chapterTask) || isTaskRunningOrQueued(summaryTask)) {
updateMeetingStatus(meetingId, 2);
}
}
private String buildChapterFailurePropagationMessage(AiTask chapterTask) {
if (chapterTask == null) {
return "章节生成失败,无法继续总结";
private boolean isTaskCompleted(AiTask task) {
return task != null && Integer.valueOf(2).equals(task.getStatus());
}
String detail = firstNonBlank(
chapterTask.getErrorMsg(),
stringValue(chapterTask.getResponseData() == null ? null : chapterTask.getResponseData().get("failureReason")),
stringValue(chapterTask.getResponseData() == null ? null : chapterTask.getResponseData().get("exceptionMessage"))
);
if (detail == null) {
return "章节生成失败,无法继续总结";
private boolean isTaskFailed(AiTask task) {
return task != null && Integer.valueOf(3).equals(task.getStatus());
}
return "章节生成失败,无法继续总结: " + detail;
private boolean isTaskRunningOrQueued(AiTask task) {
return task != null && (Integer.valueOf(0).equals(task.getStatus()) || Integer.valueOf(1).equals(task.getStatus()));
}
private String stringValue(Object value) {
@ -1212,18 +1188,6 @@ public class AiTaskServiceImpl extends ServiceImpl<AiTaskMapper, AiTask> impleme
return null;
}
private Object firstNonNull(Object... values) {
if (values == null) {
return null;
}
for (Object value : values) {
if (value != null) {
return value;
}
}
return null;
}
private Long longValue(Object value) {
if (value == null) {
return null;
@ -1235,17 +1199,6 @@ public class AiTaskServiceImpl extends ServiceImpl<AiTaskMapper, AiTask> impleme
}
}
private Integer intValue(Object value) {
if (value == null) {
return null;
}
try {
return Integer.parseInt(String.valueOf(value).trim());
} catch (Exception ex) {
return null;
}
}
private AiModelVO resolveAsrModelForRevision(AiTask asrTask) {
if (asrTask == null || asrTask.getTaskConfig() == null) {
return null;

View File

@ -12,6 +12,7 @@ import com.imeeting.dto.biz.MeetingSummaryFinalizeDTO;
import com.imeeting.dto.biz.MeetingSummaryOrchestrationTriggerResultVO;
import com.imeeting.dto.biz.MeetingTranscriptChapterImportDTO;
import com.imeeting.dto.biz.MeetingTranscriptChapterImportResultVO;
import com.imeeting.dto.biz.MeetingTranscriptSourceVO;
import com.imeeting.dto.biz.MeetingVO;
import com.imeeting.dto.biz.RealtimeMeetingRuntimeProfile;
import com.imeeting.dto.biz.RealtimeMeetingResumeConfig;
@ -428,8 +429,9 @@ public class MeetingCommandServiceImpl implements MeetingCommandService {
realtimeMeetingSessionStateService.clear(meetingId);
meeting.setStatus(2);
meetingService.updateById(meeting);
updateMeetingProgress(meetingId, 90, "正在生成会议总结...", 0);
updateMeetingProgress(meetingId, 85, "正在生成 AI 目录与总结...", 0);
meetingDomainSupport.prewarmPlaybackAudioAfterCommit(meeting.getAudioUrl());
aiTaskService.dispatchChapterTask(meetingId, meeting.getTenantId(), meeting.getCreatorId());
aiTaskService.dispatchSummaryTask(meetingId, meeting.getTenantId(), meeting.getCreatorId());
}
@ -501,6 +503,15 @@ public class MeetingCommandServiceImpl implements MeetingCommandService {
resetAiTask(summaryTask, summaryTask.getTaskConfig());
aiTaskService.updateById(summaryTask);
AiTask chapterTask = aiTaskService.getOne(new LambdaQueryWrapper<AiTask>()
.eq(AiTask::getMeetingId, meetingId)
.eq(AiTask::getTaskType, "CHAPTER")
.orderByDesc(AiTask::getId)
.last("LIMIT 1"));
if (chapterTask != null) {
resetAiTask(chapterTask, chapterTask.getTaskConfig());
aiTaskService.updateById(chapterTask);
}
}
private void resetAiTask(AiTask task, Map<String, Object> taskConfig) {
@ -746,14 +757,9 @@ public class MeetingCommandServiceImpl implements MeetingCommandService {
throw new RuntimeException("总结任务不存在或不属于当前会议");
}
MeetingTranscriptChapterVersion currentVersion = meetingTranscriptChapterService.getCurrentVersion(meeting.getId());
if (currentVersion == null) {
throw new RuntimeException("当前会议不存在有效章节版本");
}
if (!Objects.equals(currentVersion.getId(), command.getChapterVersionId())) {
throw new RuntimeException("章节版本不是当前生效版本,拒绝回填总结");
}
if (!Objects.equals(currentVersion.getSourceFingerprint(), command.getSourceFingerprint())) {
MeetingTranscriptSourceVO transcriptSource = meetingTranscriptChapterService.buildTranscriptSource(meeting.getId());
String currentFingerprint = transcriptSource == null ? null : transcriptSource.getSourceFingerprint();
if (currentFingerprint == null || !Objects.equals(currentFingerprint, command.getSourceFingerprint())) {
throw new RuntimeException("转录指纹已变化,拒绝回填过期总结结果");
}
@ -764,12 +770,11 @@ public class MeetingCommandServiceImpl implements MeetingCommandService {
? new HashMap<>()
: new HashMap<>(summaryTask.getResponseData());
Map<String, Object> summarySource = new HashMap<>();
summarySource.put("sourceType", "CHAPTER_VERSION");
summarySource.put("chapterVersionId", currentVersion.getId());
summarySource.put("chapterCount", currentVersion.getChapterCount());
summarySource.put("sourceFingerprint", currentVersion.getSourceFingerprint());
summarySource.put("algorithmVersion", currentVersion.getAlgorithmVersion());
summarySource.put("generationMode", currentVersion.getGenerationMode());
summarySource.put("sourceType", "RAW_TRANSCRIPT");
summarySource.put("sourceFingerprint", currentFingerprint);
if (command.getChapterVersionId() != null) {
summarySource.put("chapterVersionId", command.getChapterVersionId());
}
responseData.put("summarySource", summarySource);
Map<String, Object> summaryBundle = new HashMap<>();
@ -786,9 +791,19 @@ public class MeetingCommandServiceImpl implements MeetingCommandService {
aiTaskService.updateById(summaryTask);
meeting.setLatestSummaryTaskId(summaryTask.getId());
meeting.setStatus(3);
meetingService.updateById(meeting);
aiTaskService.reconcileMeetingStatus(meeting.getId());
AiTask latestChapterTask = aiTaskService.getOne(new LambdaQueryWrapper<AiTask>()
.eq(AiTask::getMeetingId, meeting.getId())
.eq(AiTask::getTaskType, "CHAPTER")
.orderByDesc(AiTask::getId)
.last("LIMIT 1"));
if (latestChapterTask != null && Integer.valueOf(2).equals(latestChapterTask.getStatus())) {
updateMeetingProgress(meeting.getId(), 100, "外部总结回填完成", 0);
} else {
updateMeetingProgress(meeting.getId(), 95, "外部总结回填完成,等待 AI 目录完成...", 0);
}
}
@Override
@ -870,16 +885,15 @@ public class MeetingCommandServiceImpl implements MeetingCommandService {
if ("CHAPTER".equals(stage)) {
markTaskFailed(chapterTask, "外部章节编排失败: " + errorMessage, command.getRawError());
markTaskFailed(summaryTask, "外部章节编排失败,无法继续总结: " + errorMessage, command.getRawError());
} else {
markTaskFailed(summaryTask, "外部总结编排失败: " + errorMessage, command.getRawError());
}
if (summaryTask != null) {
meeting.setLatestSummaryTaskId(summaryTask.getId());
}
meeting.setStatus(4);
meetingService.updateById(meeting);
}
aiTaskService.reconcileMeetingStatus(meeting.getId());
updateMeetingProgress(meeting.getId(), -1, errorMessage, 0);
}
@ -892,34 +906,20 @@ public class MeetingCommandServiceImpl implements MeetingCommandService {
}
String effectiveSummaryDetailLevel = resolveSummaryDetailLevel(summaryDetailLevel != null ? summaryDetailLevel : meeting.getSummaryDetailLevel());
Long effectiveChapterModelId = chapterModelId != null ? chapterModelId : summaryModelId;
meetingDomainSupport.createChapterTask(
meetingId,
summaryModelId,
effectiveChapterModelId,
promptId,
userPrompt,
effectiveSummaryDetailLevel
);
if (Objects.equals(effectiveChapterModelId, summaryModelId)) {
meetingDomainSupport.createSummaryTask(meetingId, summaryModelId, promptId, userPrompt, effectiveSummaryDetailLevel);
} else {
meetingDomainSupport.createSummaryTask(
meetingId,
summaryModelId,
effectiveChapterModelId,
promptId,
userPrompt,
effectiveSummaryDetailLevel
);
}
meeting.setSummaryDetailLevel(effectiveSummaryDetailLevel);
meeting.setStatus(2);
meetingService.updateById(meeting);
if ("EXTERNAL_N8N".equalsIgnoreCase(summaryOrchestrationMode)) {
updateMeetingProgress(meetingId, 95, "等待外部章节与总结编排...", 0);
updateMeetingProgress(meetingId, 95, "等待外部总结编排...", 0);
} else {
updateMeetingProgress(meetingId, 85, "重新总结已提交,正在生成章节...", 0);
updateMeetingProgress(meetingId, 90, "重新总结已提交,正在生成总结...", 0);
}
dispatchSummaryTaskAfterCommit(meetingId, meeting.getTenantId(), meeting.getCreatorId());
}
@ -996,6 +996,74 @@ public class MeetingCommandServiceImpl implements MeetingCommandService {
dispatchTasksAfterCommit(meetingId, meeting.getTenantId(), meeting.getCreatorId());
}
@Override
@Transactional(rollbackFor = Exception.class)
public void retrySummary(Long meetingId) {
Meeting meeting = meetingService.getById(meetingId);
if (meeting == null) {
throw new RuntimeException("会议不存在");
}
if (meeting.getStatus() == 2) {
throw new RuntimeException("当前会议仍在处理中,请稍后再试");
}
long transcriptCount = transcriptMapper.selectCount(new LambdaQueryWrapper<MeetingTranscript>()
.eq(MeetingTranscript::getMeetingId, meetingId));
if (transcriptCount <= 0) {
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("未找到可用的总结任务配置");
}
if (!Integer.valueOf(3).equals(summaryTask.getStatus())) {
throw new RuntimeException("当前总结环节未失败,无需重试");
}
resetAiTask(summaryTask, new HashMap<>(summaryTask.getTaskConfig()));
aiTaskService.updateById(summaryTask);
meeting.setStatus(2);
meetingService.updateById(meeting);
updateMeetingProgress(meetingId, 90, "已重新提交总结任务,正在生成总结...", 0);
dispatchSummaryTaskAfterCommit(meetingId, meeting.getTenantId(), meeting.getCreatorId());
}
@Override
@Transactional(rollbackFor = Exception.class)
public void retryChapter(Long meetingId) {
Meeting meeting = meetingService.getById(meetingId);
if (meeting == null) {
throw new RuntimeException("会议不存在");
}
if (meeting.getStatus() == 2) {
throw new RuntimeException("当前会议仍在处理中,请稍后再试");
}
long transcriptCount = transcriptMapper.selectCount(new LambdaQueryWrapper<MeetingTranscript>()
.eq(MeetingTranscript::getMeetingId, meetingId));
if (transcriptCount <= 0) {
throw new RuntimeException("当前会议没有可用转录,无法重试 AI 目录");
}
AiTask chapterTask = aiTaskService.getOne(new LambdaQueryWrapper<AiTask>()
.eq(AiTask::getMeetingId, meetingId)
.eq(AiTask::getTaskType, "CHAPTER")
.orderByDesc(AiTask::getId)
.last("LIMIT 1"));
if (chapterTask == null || chapterTask.getTaskConfig() == null) {
throw new RuntimeException("未找到可用的 AI 目录任务配置");
}
if (!Integer.valueOf(3).equals(chapterTask.getStatus())) {
throw new RuntimeException("当前 AI 目录环节未失败,无需重试");
}
resetAiTask(chapterTask, new HashMap<>(chapterTask.getTaskConfig()));
aiTaskService.updateById(chapterTask);
meeting.setStatus(2);
meetingService.updateById(meeting);
updateMeetingProgress(meetingId, 85, "已重新提交 AI 目录任务,正在生成目录...", 0);
dispatchChapterTaskAfterCommit(meetingId, meeting.getTenantId(), meeting.getCreatorId());
}
private void clearLegacyDispatchState(Long meetingId) {
if (compatibilityRedisTemplate == null || meetingId == null) {
return;
@ -1092,6 +1160,19 @@ public class MeetingCommandServiceImpl implements MeetingCommandService {
aiTaskService.updateById(task);
}
private void dispatchChapterTaskAfterCommit(Long meetingId, Long tenantId, Long userId) {
if (!TransactionSynchronizationManager.isSynchronizationActive()) {
aiTaskService.dispatchChapterTask(meetingId, tenantId, userId);
return;
}
TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() {
@Override
public void afterCommit() {
aiTaskService.dispatchChapterTask(meetingId, tenantId, userId);
}
});
}
private void dispatchSummaryTaskAfterCommit(Long meetingId, Long tenantId, Long userId) {
if (!TransactionSynchronizationManager.isSynchronizationActive()) {
aiTaskService.dispatchSummaryTask(meetingId, tenantId, userId);

View File

@ -225,7 +225,7 @@ public class MeetingMcpToolService {
if (summaryCompleted) {
return new LegacyMeetingPreviewResult("200", "success", buildCompletedPreview(meeting, detail, summaryTask));
}
if (isFailed(asrTask) || Integer.valueOf(4).equals(meeting.getStatus())) {
if (isFailed(asrTask)) {
return new LegacyMeetingPreviewResult(
"503",
buildFailureMessage(asrTask, "转译"),
@ -369,7 +369,7 @@ public class MeetingMcpToolService {
if (Integer.valueOf(3).equals(meeting.getStatus()) || summaryCompleted) {
return new LegacyMeetingProcessingStatusResponse("completed", 100, STAGE_COMPLETED);
}
if (isFailed(asrTask) || Integer.valueOf(4).equals(meeting.getStatus())) {
if (isFailed(asrTask)) {
return new LegacyMeetingProcessingStatusResponse("failed", 50, STAGE_AUDIO_TRANSCRIPTION);
}
if (isFailed(summaryTask)) {

View File

@ -380,6 +380,20 @@ export const retryMeetingTranscription = (meetingId: number) => {
);
};
export const retryMeetingSummary = (meetingId: number) => {
return http.post<{ code: string; data: boolean; msg: string }>(
`/api/biz/meeting/${meetingId}/summary/retry`,
{}
);
};
export const retryMeetingChapter = (meetingId: number) => {
return http.post<{ code: string; data: boolean; msg: string }>(
`/api/biz/meeting/${meetingId}/chapters/retry`,
{}
);
};
export const updateMeetingBasic = (data: UpdateMeetingBasicCommand) => {
return http.put<{ code: string; data: boolean; msg: string }>(
`/api/biz/meeting/${data.meetingId}/basic`,

View File

@ -40,6 +40,8 @@ import {
reSummary,
resolveAudioMimeType,
resolveMeetingPlaybackAudioUrl,
retryMeetingChapter,
retryMeetingSummary,
retryMeetingTranscription,
updateMeetingBasic,
updateMeetingTranscript,
@ -1015,8 +1017,15 @@ const MeetingDetail: React.FC = () => {
return false;
}, [meeting]);
const canRetrySummary = isOwner && transcripts.length > 0 && meeting?.status !== 1 && meeting?.status !== 2;
const canRetrySummary = isOwner
&& transcripts.length > 0
&& meeting?.status !== 1
&& meeting?.status !== 2
&& meeting?.latestSummaryAttemptStatus !== 3
&& meeting?.latestChapterAttemptStatus !== 3;
const canRetryTranscription = isOwner && meeting?.status === 4 && transcripts.length === 0 && !!meeting?.audioUrl;
const canRetryFailedSummaryTask = isOwner && meeting?.latestSummaryAttemptStatus === 3 && meeting?.status !== 2;
const canRetryFailedChapterTask = isOwner && meeting?.latestChapterAttemptStatus === 3 && meeting?.status !== 2;
const hasSummaryContent = Boolean(meeting?.summaryContent?.trim());
const hasCatalogContent = catalogChapterLinks.length > 0;
const generationFailureNotice = useMemo<MeetingStateNotice | null>(() => {
@ -1362,13 +1371,11 @@ const MeetingDetail: React.FC = () => {
setMeeting((current) => current ? {
...current,
status: 2,
latestChapterAttemptStatus: 0,
latestChapterAttemptErrorMsg: undefined,
latestSummaryAttemptStatus: 0,
latestSummaryAttemptErrorMsg: undefined,
} : current);
setGenerationProgress({
percent: 85,
percent: 90,
message: '已重新发起总结任务',
updateAt: Date.now(),
eta: 0,
@ -1455,6 +1462,44 @@ const MeetingDetail: React.FC = () => {
}
};
const handleRetrySummaryTask = async () => {
setActionLoading(true);
try {
await retryMeetingSummary(Number(id));
message.success('已重新提交总结任务');
setMeeting((current) => current ? {
...current,
status: 2,
latestSummaryAttemptStatus: 0,
latestSummaryAttemptErrorMsg: undefined,
} : current);
await fetchData(Number(id));
} catch (error) {
console.error(error);
} finally {
setActionLoading(false);
}
};
const handleRetryChapterTask = async () => {
setActionLoading(true);
try {
await retryMeetingChapter(Number(id));
message.success('已重新提交 AI 目录任务');
setMeeting((current) => current ? {
...current,
status: 2,
latestChapterAttemptStatus: 0,
latestChapterAttemptErrorMsg: undefined,
} : current);
await fetchData(Number(id));
} catch (error) {
console.error(error);
} finally {
setActionLoading(false);
}
};
const handleKeywordToggle = (keyword: string, checked: boolean) => {
setSelectedKeywords((current) => {
if (checked) {
@ -1976,6 +2021,16 @@ const MeetingDetail: React.FC = () => {
</Button>
)}
{canRetryFailedChapterTask && (
<Button icon={<SyncOutlined />} onClick={handleRetryChapterTask} loading={actionLoading}>
AI
</Button>
)}
{canRetryFailedSummaryTask && (
<Button icon={<SyncOutlined />} onClick={handleRetrySummaryTask} loading={actionLoading}>
</Button>
)}
{canRetryTranscription && (
<Button icon={<SyncOutlined />} onClick={handleRetryTranscription} loading={actionLoading}>