From a046ecf05b661f0503c0564281eed77d19790264 Mon Sep 17 00:00:00 2001 From: chenhao Date: Fri, 22 May 2026 17:28:59 +0800 Subject: [PATCH] =?UTF-8?q?refactor:=20=E5=88=A0=E9=99=A4=20MeetingTranscr?= =?UTF-8?q?iptRevisionServiceImpl=20=E7=B1=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 移除 `MeetingTranscriptRevisionServiceImpl` 类及其相关方法和逻辑 - 该类涉及会议转录修订的生成、解析和更新等功能 --- .../com/imeeting/common/SysParamKeys.java | 2 - .../android/AndroidMeetingController.java | 1 + .../legacy/LegacyMeetingController.java | 1 + .../controller/biz/MeetingController.java | 43 +- .../dto/biz/MeetingProgressSnapshot.java | 1 + .../service/biz/MeetingProgressService.java | 3 + .../service/biz/MeetingQueryService.java | 2 +- .../biz/MeetingTranscriptRevisionService.java | 7 - .../service/biz/impl/AiTaskServiceImpl.java | 215 ++++- .../biz/impl/MeetingCommandServiceImpl.java | 11 +- .../biz/impl/MeetingProgressServiceImpl.java | 70 +- .../biz/impl/MeetingQueryServiceImpl.java | 6 +- .../MeetingTranscriptRevisionServiceImpl.java | 833 ------------------ ...edisOnlyMeetingProgressServiceAdapter.java | 23 +- .../service/mcp/MeetingMcpToolService.java | 1 + .../biz/impl/AiTaskServiceImplTest.java | 367 +++----- frontend/src/api/business/meeting.ts | 12 + frontend/src/pages/business/MeetingDetail.tsx | 121 ++- frontend/src/pages/business/Meetings.tsx | 311 +++++-- frontend/src/pages/business/SpeakerReg.tsx | 1 + 20 files changed, 788 insertions(+), 1243 deletions(-) delete mode 100644 backend/src/main/java/com/imeeting/service/biz/impl/MeetingTranscriptRevisionServiceImpl.java diff --git a/backend/src/main/java/com/imeeting/common/SysParamKeys.java b/backend/src/main/java/com/imeeting/common/SysParamKeys.java index 33330b2..756bb8b 100644 --- a/backend/src/main/java/com/imeeting/common/SysParamKeys.java +++ b/backend/src/main/java/com/imeeting/common/SysParamKeys.java @@ -5,8 +5,6 @@ public final class SysParamKeys { 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_TRANSCRIPT_CLEANUP_FILLER_WORDS = "meeting.transcript.cleanup.filler_words"; - public static final String MEETING_TRANSCRIPT_CLEANUP_REPLACEMENTS = "meeting.transcript.cleanup.replacements"; public static final String MEETING_OFFLINE_AUDIO_MAX_SIZE_MB = "meeting.offline_audio.max_size_mb"; public static final String MEETING_CREATE_OFFLINE_ENABLED = "meeting.create.offline_enabled"; public static final String MEETING_CREATE_REALTIME_ENABLED = "meeting.create.realtime_enabled"; diff --git a/backend/src/main/java/com/imeeting/controller/android/AndroidMeetingController.java b/backend/src/main/java/com/imeeting/controller/android/AndroidMeetingController.java index 0060768..5ffd1c0 100644 --- a/backend/src/main/java/com/imeeting/controller/android/AndroidMeetingController.java +++ b/backend/src/main/java/com/imeeting/controller/android/AndroidMeetingController.java @@ -202,6 +202,7 @@ public class AndroidMeetingController { loginUser.getUserId(), AndroidLoginUserSupport.resolveDisplayName(authContext), "all", + null, AndroidLoginUserSupport.isAdmin(authContext) )); } diff --git a/backend/src/main/java/com/imeeting/controller/android/legacy/LegacyMeetingController.java b/backend/src/main/java/com/imeeting/controller/android/legacy/LegacyMeetingController.java index aff4848..b29d568 100644 --- a/backend/src/main/java/com/imeeting/controller/android/legacy/LegacyMeetingController.java +++ b/backend/src/main/java/com/imeeting/controller/android/legacy/LegacyMeetingController.java @@ -203,6 +203,7 @@ public class LegacyMeetingController { loginUser.getUserId(), resolveCreatorName(loginUser), "all", + null, isAdmin ); 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 9062656..a589dd1 100644 --- a/backend/src/main/java/com/imeeting/controller/biz/MeetingController.java +++ b/backend/src/main/java/com/imeeting/controller/biz/MeetingController.java @@ -166,7 +166,44 @@ public class MeetingController { return ApiResponse.ok(progress); } - @Operation(summary = "上传会议音频") + + @Operation(summary = "批量查询会议处理进度") + @PostMapping("/progress/batch") + @PreAuthorize("isAuthenticated()") + public ApiResponse>> getProgressBatch(@RequestBody List ids) { + LoginUser loginUser = currentLoginUser(); + Map> result = new LinkedHashMap<>(); + if (ids == null || ids.isEmpty()) { + return ApiResponse.ok(result); + } + for (Long id : ids) { + if (id == null) { + continue; + } + try { + Meeting meeting = meetingAccessService.requireMeeting(id); + meetingAccessService.assertCanViewMeeting(meeting, loginUser); + Map progress = meetingProgressService.getProgressMap(id); + if (compatibilityAiTaskService != null && "Waiting...".equals(progress.get("message"))) { + AiTask asrTask = compatibilityAiTaskService.getOne(new LambdaQueryWrapper() + .eq(AiTask::getMeetingId, id) + .eq(AiTask::getTaskType, "ASR") + .orderByDesc(AiTask::getId) + .last("LIMIT 1")); + if (asrTask != null && Integer.valueOf(0).equals(asrTask.getStatus())) { + progress = Map.of("percent", 0, "message", "排队中,等待 ASR 执行名额..."); + } else if (asrTask != null && Integer.valueOf(1).equals(asrTask.getStatus())) { + progress = Map.of("percent", 5, "message", "识别中,等待进度刷新..."); + } + } + result.put(id, progress); + } catch (RuntimeException ignored) { + // Ignore inaccessible meetings in batch mode. + } + } + return ApiResponse.ok(result); + } + @Operation(summary = "上传会议音频") @PostMapping("/upload") @PreAuthorize("isAuthenticated()") public ApiResponse upload(@RequestParam("file") MultipartFile file) throws IOException { @@ -223,7 +260,8 @@ public class MeetingController { @RequestParam(defaultValue = "1") Integer current, @RequestParam(defaultValue = "10") Integer size, @RequestParam(required = false) String title, - @RequestParam(defaultValue = "all") String viewType) { + @RequestParam(defaultValue = "all") String viewType, + @RequestParam(required = false) Integer status) { LoginUser loginUser = currentLoginUser(); boolean isAdmin = Boolean.TRUE.equals(loginUser.getIsPlatformAdmin()) || Boolean.TRUE.equals(loginUser.getIsTenantAdmin()); @@ -236,6 +274,7 @@ public class MeetingController { loginUser.getUserId(), resolveCreatorName(loginUser), viewType, + status, isAdmin )); } diff --git a/backend/src/main/java/com/imeeting/dto/biz/MeetingProgressSnapshot.java b/backend/src/main/java/com/imeeting/dto/biz/MeetingProgressSnapshot.java index 48661ec..a501fe6 100644 --- a/backend/src/main/java/com/imeeting/dto/biz/MeetingProgressSnapshot.java +++ b/backend/src/main/java/com/imeeting/dto/biz/MeetingProgressSnapshot.java @@ -22,6 +22,7 @@ public class MeetingProgressSnapshot { private Integer percent; private String message; private Integer eta; + private Integer queueAheadCount; private String externalTaskId; private LocalDateTime queuedAt; private LocalDateTime startedAt; diff --git a/backend/src/main/java/com/imeeting/service/biz/MeetingProgressService.java b/backend/src/main/java/com/imeeting/service/biz/MeetingProgressService.java index 8271798..6b9522a 100644 --- a/backend/src/main/java/com/imeeting/service/biz/MeetingProgressService.java +++ b/backend/src/main/java/com/imeeting/service/biz/MeetingProgressService.java @@ -4,6 +4,7 @@ import com.imeeting.common.MeetingProgressStage; import com.imeeting.dto.biz.MeetingProgressSnapshot; import com.imeeting.entity.biz.AiTask; +import java.util.List; import java.util.Map; public interface MeetingProgressService { @@ -11,6 +12,8 @@ public interface MeetingProgressService { Map getProgressMap(Long meetingId); + Map> getProgressMaps(List meetingIds); + Integer resolvePercent(Long meetingId); void markQueued(Long meetingId, AiTask task, Integer meetingStatus, String message); diff --git a/backend/src/main/java/com/imeeting/service/biz/MeetingQueryService.java b/backend/src/main/java/com/imeeting/service/biz/MeetingQueryService.java index 2d1bb72..8d494c7 100644 --- a/backend/src/main/java/com/imeeting/service/biz/MeetingQueryService.java +++ b/backend/src/main/java/com/imeeting/service/biz/MeetingQueryService.java @@ -12,7 +12,7 @@ import java.util.Map; public interface MeetingQueryService { PageResult> pageMeetings(Integer current, Integer size, String title, Long tenantId, - Long userId, String userName, String viewType, boolean isAdmin); + Long userId, String userName, String viewType, Integer status, boolean isAdmin); MeetingVO getDetail(Long id); diff --git a/backend/src/main/java/com/imeeting/service/biz/MeetingTranscriptRevisionService.java b/backend/src/main/java/com/imeeting/service/biz/MeetingTranscriptRevisionService.java index f9a89d5..66a8a43 100644 --- a/backend/src/main/java/com/imeeting/service/biz/MeetingTranscriptRevisionService.java +++ b/backend/src/main/java/com/imeeting/service/biz/MeetingTranscriptRevisionService.java @@ -9,13 +9,6 @@ import com.imeeting.entity.biz.Meeting; import java.util.List; public interface MeetingTranscriptRevisionService { - String generateOfflineCurrentRevision(Meeting meeting, AiTask task, AiModelVO asrModel); - - MeetingSummarySource resolveSummarySource(Meeting meeting, AiTask summaryTask, AiModelVO asrModel); - - List listEffectiveTranscripts(Long meetingId); - - boolean updateCurrentRevisionContent(Long meetingId, Long operatorId, String content); void invalidateCurrentRevision(Long meetingId); } 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 f52e368..3aa5a16 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 @@ -25,7 +25,7 @@ import com.imeeting.service.biz.MeetingProgressService; import com.imeeting.service.biz.MeetingSummaryFileService; import com.imeeting.service.biz.MeetingTranscriptChapterService; import com.imeeting.service.biz.MeetingTranscriptFileService; -import com.imeeting.service.biz.MeetingTranscriptRevisionService; + import com.imeeting.support.RedisValueSupport; import com.unisbase.entity.SysUser; @@ -71,7 +71,7 @@ public class AiTaskServiceImpl extends ServiceImpl impleme private final MeetingProgressService meetingProgressService; private final MeetingSummaryFileService meetingSummaryFileService; private final MeetingTranscriptFileService meetingTranscriptFileService; - private final MeetingTranscriptRevisionService meetingTranscriptRevisionService; + private final MeetingTranscriptChapterService meetingTranscriptChapterService; private final MeetingSummaryPromptAssembler meetingSummaryPromptAssembler; private final TaskSecurityContextRunner taskSecurityContextRunner; @@ -115,7 +115,6 @@ public class AiTaskServiceImpl extends ServiceImpl impleme MeetingProgressService meetingProgressService, MeetingSummaryFileService meetingSummaryFileService, MeetingTranscriptFileService meetingTranscriptFileService, - MeetingTranscriptRevisionService meetingTranscriptRevisionService, MeetingTranscriptChapterService meetingTranscriptChapterService, MeetingSummaryPromptAssembler meetingSummaryPromptAssembler, TaskSecurityContextRunner taskSecurityContextRunner, @@ -131,7 +130,6 @@ public class AiTaskServiceImpl extends ServiceImpl impleme this.meetingProgressService = meetingProgressService; this.meetingSummaryFileService = meetingSummaryFileService; this.meetingTranscriptFileService = meetingTranscriptFileService; - this.meetingTranscriptRevisionService = meetingTranscriptRevisionService; this.meetingTranscriptChapterService = meetingTranscriptChapterService; this.meetingSummaryPromptAssembler = meetingSummaryPromptAssembler; this.taskSecurityContextRunner = taskSecurityContextRunner; @@ -148,7 +146,7 @@ public class AiTaskServiceImpl extends ServiceImpl impleme StringRedisTemplate redisTemplate, MeetingSummaryFileService meetingSummaryFileService, MeetingTranscriptFileService meetingTranscriptFileService, - MeetingTranscriptRevisionService meetingTranscriptRevisionService, + MeetingTranscriptChapterService meetingTranscriptChapterService, MeetingSummaryPromptAssembler meetingSummaryPromptAssembler, TaskSecurityContextRunner taskSecurityContextRunner, @@ -164,7 +162,6 @@ public class AiTaskServiceImpl extends ServiceImpl impleme new RedisOnlyMeetingProgressServiceAdapter(redisTemplate, objectMapper), meetingSummaryFileService, meetingTranscriptFileService, - meetingTranscriptRevisionService, meetingTranscriptChapterService, meetingSummaryPromptAssembler, taskSecurityContextRunner, @@ -213,7 +210,7 @@ public class AiTaskServiceImpl extends ServiceImpl impleme this.updateById(asrTask); } if (!claimQueuedAsrTask(asrTask)) { - meetingProgressService.markQueued(meetingId, asrTask, 1, "已进入 ASR 队列,等待执行"); + meetingProgressService.markQueued(meetingId, asrTask, 1, "ASR queued and waiting for execution"); return; } } @@ -249,6 +246,7 @@ public class AiTaskServiceImpl extends ServiceImpl impleme } if (!asrText.isBlank()) { meetingProgressService.markStage(meetingId, asrTask, 1, MeetingProgressStage.ASR_COMPLETED, 80, "转写完成,准备生成总结", 0); + scheduleQueuedAsrTasks(); self.dispatchSummaryTask(meetingId, meeting.getTenantId(), meeting.getCreatorId()); return; } @@ -268,7 +266,7 @@ public class AiTaskServiceImpl extends ServiceImpl impleme } } catch (Exception e) { log.error("Meeting {} AI Task Flow failed", meetingId, e); - failPendingSummaryTask(findLatestSummaryTask(meetingId), "转录失败,已跳过总结任务:" + e.getMessage()); + failPendingSummaryTask(findLatestSummaryTask(meetingId), "转录失败,已跳过总结任务: " + e.getMessage()); updateMeetingStatus(meetingId, 4); updateProgress(meetingId, -1, "分析失败: " + e.getMessage(), 0); } finally { @@ -384,6 +382,7 @@ public class AiTaskServiceImpl extends ServiceImpl impleme task.setStatus(1); task.setStartedAt(now); meetingProgressService.markStage(task.getMeetingId(), task, 1, MeetingProgressStage.ASR_SUBMITTED, 5, "ASR 任务已开始执行", 0); + refreshQueuedAsrProgress(); } return claimed; } finally { @@ -409,6 +408,7 @@ public class AiTaskServiceImpl extends ServiceImpl impleme if (available <= 0) { return; } + refreshQueuedAsrProgress(); List queuedTasks = list(new LambdaQueryWrapper() .eq(AiTask::getTaskType, "ASR") .eq(AiTask::getStatus, 0) @@ -427,6 +427,20 @@ public class AiTaskServiceImpl extends ServiceImpl impleme } } + private void refreshQueuedAsrProgress() { + List queuedTasks = list(new LambdaQueryWrapper() + .eq(AiTask::getTaskType, "ASR") + .eq(AiTask::getStatus, 0) + .orderByAsc(AiTask::getQueuedAt) + .orderByAsc(AiTask::getId)); + for (AiTask queuedTask : queuedTasks) { + if (queuedTask.getMeetingId() == null) { + continue; + } + meetingProgressService.markQueued(queuedTask.getMeetingId(), queuedTask, 1, null); + } + } + private int resolveAsrMaxConcurrent() { if (sysParamService == null) { return 2; @@ -459,6 +473,7 @@ public class AiTaskServiceImpl extends ServiceImpl impleme task.setResponseData(responseData); updateById(task); meetingProgressService.markQueued(task.getMeetingId(), task, 1, reason == null || reason.isBlank() ? "已重新进入 ASR 队列" : reason); + refreshQueuedAsrProgress(); } private Long extractAsrModelId(AiTask task) { @@ -565,20 +580,17 @@ public class AiTaskServiceImpl extends ServiceImpl impleme } }) .collect(Collectors.joining("/")); - req.put("file_url", serverBaseUrl + (encodedAudioUrl.startsWith("/") ? "" : "/") + encodedAudioUrl); + req.put("audio_address", serverBaseUrl + (encodedAudioUrl.startsWith("/") ? "" : "/") + encodedAudioUrl); Map config = new HashMap<>(); - if (asrModel.getModelCode() != null && !asrModel.getModelCode().isBlank()) { - config.put("model", asrModel.getModelCode()); - } Object useSpkObj = taskRecord.getTaskConfig().get("useSpkId"); boolean useSpk = useSpkObj != null && useSpkObj.toString().equals("1"); config.put("enable_speaker", useSpk); + config.put("match_speaker_registry", useSpk); Object enableTextRefineObj = taskRecord.getTaskConfig().get("enableTextRefine"); boolean enableTextRefine = enableTextRefineObj != null && Boolean.parseBoolean(enableTextRefineObj.toString()); - config.put("enable_text_refine", enableTextRefine); - config.put("enable_two_pass", true); + config.put("enable_text_cleanup", enableTextRefine); List> hotwords = new ArrayList<>(); Object hotWordsObj = taskRecord.getTaskConfig().get("hotWords"); @@ -647,8 +659,12 @@ public class AiTaskServiceImpl extends ServiceImpl impleme String respBody = postJson(submitUrl, req, asrModel.getApiKey()); JsonNode submitNode = objectMapper.readTree(respBody); if (submitNode.path("code").asInt() != 0) { - updateAiTaskFail(taskRecord, "ASR识别失败: " + respBody); - throw new RuntimeException("ASR识别失败: " + submitNode.path("msg").asText()); + updateAiTaskFail(taskRecord, "ASR识别失败 " + respBody); + throw new RuntimeException("ASR识别失败: " + firstNonBlank( + submitNode.path("message").asText(""), + submitNode.path("msg").asText(""), + "unknown error" + )); } String taskId = submitNode.path("data").path("task_id").asText(); taskRecord.setResponseData(Map.of("task_id", taskId)); @@ -657,11 +673,12 @@ public class AiTaskServiceImpl extends ServiceImpl impleme } @Transactional(rollbackFor = Exception.class) protected String saveTranscripts(Meeting meeting, JsonNode resultNode) { - // 关键:入库前清理旧记录,防止恢复任务导致数据重复 + // 闂備胶顭堢换鎴炵箾婵犲洤鏋佹い鎾卞灪閺咁剚鎱ㄥ鍡楀鐎殿喗濞婇獮鏍偓娑櫳戠亸顓烆熆瑜忔慨鎾Υ閹烘宸濇い鏍ㄧ☉閳ь剛鍋ら弻锟犲礃閸曨偅锛嶉柛鐐插閹叉悂鎮ч崼鐔衡敍缂備浇椴哥换鍫濐潖婵傜鐭楀鑸得竟姗€姊虹拠鈥冲箲闁搞劌缍婅棟闁告瑥顦遍々鐑芥偣閸ャ劌绲绘い顐犲€濋幃妤佹媴閸愵煈妫堥梺鎼炰紘閸パ勭€梺缁橆殔閻楀棛绮婇敃鍌涒拺闁圭粯甯炲瓭濡? transcriptMapper.delete(new LambdaQueryWrapper().eq(MeetingTranscript::getMeetingId, meeting.getId())); - StringBuilder sb = new StringBuilder(); JsonNode segments = resultNode.path("segments"); + StringBuilder sb = new StringBuilder(); + Map resolvedUserNameCache = buildResolvedUserNameCache(segments); int savedCount = 0; if (segments.isArray()) { int order = 0; @@ -670,7 +687,7 @@ public class AiTaskServiceImpl extends ServiceImpl impleme mt.setMeetingId(meeting.getId()); String spkId = extractSpeakerId(seg); - String spkName = resolveTranscriptSpeakerName(seg, spkId); + String spkName = resolveTranscriptSpeakerName(seg, spkId, resolvedUserNameCache); mt.setSpeakerId(spkId); mt.setSpeakerName(spkName); @@ -714,7 +731,7 @@ public class AiTaskServiceImpl extends ServiceImpl impleme return speakerId.trim(); } - private String resolveTranscriptSpeakerName(JsonNode seg, String speakerId) { + private String resolveTranscriptSpeakerName(JsonNode seg, String speakerId, Map resolvedUserNameCache) { String speakerName = seg.path("speaker_name").asText(""); if (speakerName == null || speakerName.isBlank()) { JsonNode speakerNode = seg.path("speaker"); @@ -728,13 +745,13 @@ public class AiTaskServiceImpl extends ServiceImpl impleme if (userId == null || userId.isBlank()) { userId = seg.path("speaker").path("user_id").asText(""); } - String resolvedUserName = resolveUserName(userId); + String resolvedUserName = resolveUserName(userId, resolvedUserNameCache); if (resolvedUserName != null) { return resolvedUserName; } if (speakerId != null && speakerId.matches("\\d+")) { - String resolvedSpeakerName = resolveUserName(speakerId); + String resolvedSpeakerName = resolveUserName(speakerId, resolvedUserNameCache); if (resolvedSpeakerName != null) { return resolvedSpeakerName; } @@ -746,10 +763,13 @@ public class AiTaskServiceImpl extends ServiceImpl impleme return speakerName.trim(); } - private String resolveUserName(String userId) { + private String resolveUserName(String userId, Map resolvedUserNameCache) { if (userId == null || userId.isBlank() || !userId.matches("\\d+")) { return null; } + if (resolvedUserNameCache != null) { + return resolvedUserNameCache.get(userId); + } SysUser user = sysUserMapper.selectById(Long.parseLong(userId)); if (user == null) { return null; @@ -757,6 +777,45 @@ public class AiTaskServiceImpl extends ServiceImpl impleme return user.getDisplayName() != null ? user.getDisplayName() : user.getUsername(); } + private Map buildResolvedUserNameCache(JsonNode segments) { + if (segments == null || !segments.isArray()) { + return Map.of(); + } + Set userIds = new LinkedHashSet<>(); + for (JsonNode seg : segments) { + collectNumericUserId(userIds, seg.path("user_id").asText("")); + JsonNode speakerNode = seg.path("speaker"); + collectNumericUserId(userIds, speakerNode.path("user_id").asText("")); + collectNumericUserId(userIds, speakerNode.path("id").asText("")); + collectNumericUserId(userIds, seg.path("speaker_id").asText("")); + } + if (userIds.isEmpty()) { + return Map.of(); + } + return sysUserMapper.selectBatchIds(userIds).stream() + .filter(Objects::nonNull) + .collect(Collectors.toMap( + item -> String.valueOf(item.getUserId()), + item -> item.getDisplayName() != null ? item.getDisplayName() : item.getUsername(), + (left, right) -> left, + LinkedHashMap::new + )); + } + + private void collectNumericUserId(Set userIds, String candidate) { + if (candidate == null) { + return; + } + String normalized = candidate.trim(); + if (!normalized.matches("\\d+")) { + return; + } + try { + userIds.add(Long.parseLong(normalized)); + } catch (NumberFormatException ignored) { + } + } + private void fillTranscriptTime(MeetingTranscript transcript, JsonNode seg) { JsonNode timestamp = seg.path("timestamp"); if (timestamp.isArray() && timestamp.size() >= 2) { @@ -955,6 +1014,8 @@ public class AiTaskServiceImpl extends ServiceImpl impleme ? new HashMap<>() : new HashMap<>(chapterTask.getResponseData()); responseData.put("summarySource", summarySource.toSnapshot()); + responseData.put("summarySourceText", summarySource.getText()); + responseData.put("rawTranscriptText", summarySource.getRawTranscriptText()); responseData.put("chapterOutlineText", summarySource.getChapterOutlineText()); responseData.put("sourceFingerprint", summarySource.getSourceFingerprint()); responseData.put("chapterVersionId", summarySource.getChapterVersionId()); @@ -973,6 +1034,52 @@ public class AiTaskServiceImpl extends ServiceImpl 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 { if (isExternalSummaryModeEnabled()) { triggerExternalSummaryWebhook(meeting, sumTask, chapterTask, "AUTO_AFTER_TRANSCRIPT_READY", false); @@ -985,7 +1092,10 @@ public class AiTaskServiceImpl extends ServiceImpl impleme return; } try { - MeetingSummarySource summarySource = meetingTranscriptChapterService.resolveSummarySource(meeting, chapterTask != null ? chapterTask : sumTask); + MeetingSummarySource summarySource = restorePreparedSummarySource(chapterTask); + if (summarySource == null || summarySource.getText() == null || summarySource.getText().isBlank()) { + summarySource = meetingTranscriptChapterService.resolveSummarySource(meeting, chapterTask != null ? chapterTask : sumTask); + } if (summarySource.getText() == null || summarySource.getText().isBlank()) { failPendingSummaryTask(sumTask, "没有转录内容"); updateMeetingStatus(meeting.getId(), 4); @@ -1031,7 +1141,7 @@ public class AiTaskServiceImpl extends ServiceImpl impleme return; } if (summaryTask == null) { - updateProgress(meeting.getId(), -1, "缺少总结任务,无法触发外部 n8n 编排", 0); + updateProgress(meeting.getId(), -1, "Summary task is missing, external n8n orchestration cannot be triggered", 0); return; } updateMeetingStatus(meeting.getId(), 2); @@ -1041,7 +1151,7 @@ public class AiTaskServiceImpl extends ServiceImpl impleme updateProgress(meeting.getId(), 95, result.getMessage(), 0); } catch (Exception ex) { this.updateById(summaryTask); - updateProgress(meeting.getId(), -1, "触发外部 n8n 编排失败: " + ex.getMessage(), 0); + updateProgress(meeting.getId(), -1, "闂佽崵鍠愰悷杈╃不閹达絻浜归柛灞剧☉缁剁偤鏌″搴″箹闁?n8n 缂傚倸鍊搁崐褰掓偋濡ゅ啯鏆滈柟鐐綑缁剁偤寮堕崼顐函鐞? " + ex.getMessage(), 0); log.error("Failed to trigger external n8n webhook for meeting {}", meeting.getId(), ex); } } @@ -1052,6 +1162,56 @@ public class AiTaskServiceImpl extends ServiceImpl impleme && !Integer.valueOf(3).equals(task.getStatus()); } + private String stringValue(Object value) { + return value == null ? null : String.valueOf(value); + } + + private String firstNonBlank(String... values) { + if (values == null) { + return null; + } + for (String value : values) { + if (value != null && !value.isBlank()) { + return value.trim(); + } + } + 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; + } + try { + return Long.parseLong(String.valueOf(value).trim()); + } catch (Exception ex) { + return null; + } + } + + 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; @@ -1138,7 +1298,7 @@ public class AiTaskServiceImpl extends ServiceImpl impleme private String normalizeUrlComponent(String value, String fieldName) { if (value == null || value.isBlank()) { - throw new IllegalArgumentException(fieldName + "不能为空"); + throw new IllegalArgumentException(fieldName + " must not be blank"); } return value.trim(); } @@ -1214,3 +1374,4 @@ public class AiTaskServiceImpl extends ServiceImpl impleme } } + 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 b2b79dd..7e480a8 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 @@ -65,7 +65,7 @@ public class MeetingCommandServiceImpl implements MeetingCommandService { private final com.imeeting.mapper.biz.MeetingTranscriptMapper transcriptMapper; private final MeetingSummaryFileService meetingSummaryFileService; private final MeetingTranscriptFileService meetingTranscriptFileService; - private final MeetingTranscriptRevisionService meetingTranscriptRevisionService; + private final MeetingTranscriptChapterService meetingTranscriptChapterService; private final MeetingDomainSupport meetingDomainSupport; private final MeetingRuntimeProfileResolver meetingRuntimeProfileResolver; @@ -86,7 +86,7 @@ public class MeetingCommandServiceImpl implements MeetingCommandService { com.imeeting.mapper.biz.MeetingTranscriptMapper transcriptMapper, MeetingSummaryFileService meetingSummaryFileService, MeetingTranscriptFileService meetingTranscriptFileService, - MeetingTranscriptRevisionService meetingTranscriptRevisionService, + MeetingTranscriptChapterService meetingTranscriptChapterService, MeetingDomainSupport meetingDomainSupport, MeetingRuntimeProfileResolver meetingRuntimeProfileResolver, @@ -101,7 +101,7 @@ public class MeetingCommandServiceImpl implements MeetingCommandService { this.transcriptMapper = transcriptMapper; this.meetingSummaryFileService = meetingSummaryFileService; this.meetingTranscriptFileService = meetingTranscriptFileService; - this.meetingTranscriptRevisionService = meetingTranscriptRevisionService; + this.meetingTranscriptChapterService = meetingTranscriptChapterService; this.meetingDomainSupport = meetingDomainSupport; this.meetingRuntimeProfileResolver = meetingRuntimeProfileResolver; @@ -118,7 +118,7 @@ public class MeetingCommandServiceImpl implements MeetingCommandService { com.imeeting.mapper.biz.MeetingTranscriptMapper transcriptMapper, MeetingSummaryFileService meetingSummaryFileService, MeetingTranscriptFileService meetingTranscriptFileService, - MeetingTranscriptRevisionService meetingTranscriptRevisionService, + MeetingTranscriptChapterService meetingTranscriptChapterService, MeetingDomainSupport meetingDomainSupport, MeetingRuntimeProfileResolver meetingRuntimeProfileResolver, @@ -134,7 +134,7 @@ public class MeetingCommandServiceImpl implements MeetingCommandService { transcriptMapper, meetingSummaryFileService, meetingTranscriptFileService, - meetingTranscriptRevisionService, + meetingTranscriptChapterService, meetingDomainSupport, meetingRuntimeProfileResolver, @@ -585,7 +585,6 @@ public class MeetingCommandServiceImpl implements MeetingCommandService { if (updated <= 0) { throw new RuntimeException("转录记录不存在"); } - meetingTranscriptRevisionService.invalidateCurrentRevision(command.getMeetingId()); meetingTranscriptChapterService.invalidateCurrentVersion(command.getMeetingId()); } diff --git a/backend/src/main/java/com/imeeting/service/biz/impl/MeetingProgressServiceImpl.java b/backend/src/main/java/com/imeeting/service/biz/impl/MeetingProgressServiceImpl.java index 98479a1..2df3e52 100644 --- a/backend/src/main/java/com/imeeting/service/biz/impl/MeetingProgressServiceImpl.java +++ b/backend/src/main/java/com/imeeting/service/biz/impl/MeetingProgressServiceImpl.java @@ -18,6 +18,8 @@ import org.springframework.transaction.support.TransactionSynchronizationManager import java.time.LocalDateTime; import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.List; import java.util.Map; import java.util.concurrent.TimeUnit; @@ -55,6 +57,21 @@ public class MeetingProgressServiceImpl implements MeetingProgressService { return objectMapper.convertValue(snapshot, Map.class); } + @Override + public Map> getProgressMaps(List meetingIds) { + Map> result = new LinkedHashMap<>(); + if (meetingIds == null || meetingIds.isEmpty()) { + return result; + } + for (Long meetingId : meetingIds) { + if (meetingId == null) { + continue; + } + result.put(meetingId, getProgressMap(meetingId)); + } + return result; + } + @Override public Integer resolvePercent(Long meetingId) { MeetingProgressSnapshot snapshot = redisValueSupport.getJson(RedisKeys.meetingProgressKey(meetingId), MeetingProgressSnapshot.class); @@ -112,6 +129,7 @@ public class MeetingProgressServiceImpl implements MeetingProgressService { int percent, String message, int eta) { + Integer queueAheadCount = resolveQueueAheadCount(task, stage); String externalTaskId = null; if (task != null && task.getResponseData() != null && task.getResponseData().get("task_id") != null) { externalTaskId = String.valueOf(task.getResponseData().get("task_id")); @@ -125,8 +143,9 @@ public class MeetingProgressServiceImpl implements MeetingProgressService { .stage(stage.getCode()) .stageOrder(stage.getOrder()) .percent(percent) - .message(message) + .message(resolveMessage(stage, message, queueAheadCount)) .eta(eta) + .queueAheadCount(queueAheadCount) .externalTaskId(externalTaskId) .queuedAt(task == null ? null : task.getQueuedAt()) .startedAt(task == null ? null : task.getStartedAt()) @@ -185,6 +204,36 @@ public class MeetingProgressServiceImpl implements MeetingProgressService { .last("LIMIT 1")); } + private Integer resolveQueueAheadCount(AiTask task, MeetingProgressStage stage) { + if (task == null + || task.getId() == null + || task.getQueuedAt() == null + || stage != MeetingProgressStage.QUEUED + || !"ASR".equals(task.getTaskType()) + || !Integer.valueOf(0).equals(task.getStatus())) { + return null; + } + return Math.toIntExact(aiTaskMapper.selectCount(new LambdaQueryWrapper() + .eq(AiTask::getTaskType, "ASR") + .eq(AiTask::getStatus, 0) + .and(wrapper -> wrapper + .lt(AiTask::getQueuedAt, task.getQueuedAt()) + .or(orWrapper -> orWrapper + .eq(AiTask::getQueuedAt, task.getQueuedAt()) + .lt(AiTask::getId, task.getId()))))); + } + + private String resolveMessage(MeetingProgressStage stage, String message, Integer queueAheadCount) { + if (stage != MeetingProgressStage.QUEUED) { + return message; + } + String baseMessage = (message == null || message.isBlank()) ? "已进入 ASR 队列,等待执行" : message.trim(); + if (queueAheadCount == null || baseMessage.contains("前面还有")) { + return baseMessage; + } + return baseMessage + ",前面还有 " + queueAheadCount + " 个任务"; + } + private boolean shouldReplace(MeetingProgressSnapshot existing, MeetingProgressSnapshot candidate) { if (candidate == null) { return false; @@ -198,6 +247,9 @@ public class MeetingProgressServiceImpl implements MeetingProgressService { } if (isTerminal(existing) && !isTerminal(candidate)) { + if (isNewAttempt(existing, candidate)) { + return true; + } return false; } if (isTerminal(candidate)) { @@ -239,6 +291,22 @@ public class MeetingProgressServiceImpl implements MeetingProgressService { && (existing.getQueuedAt() == null || candidate.getQueuedAt().isAfter(existing.getQueuedAt())); } + private boolean isNewAttempt(MeetingProgressSnapshot existing, MeetingProgressSnapshot candidate) { + Long existingUpdateAt = existing.getUpdateAt() == null ? 0L : existing.getUpdateAt(); + Long candidateUpdateAt = candidate.getUpdateAt() == null ? 0L : candidate.getUpdateAt(); + if (candidateUpdateAt < existingUpdateAt) { + return false; + } + if (candidate.getTaskId() != null && !candidate.getTaskId().equals(existing.getTaskId())) { + return true; + } + Integer existingMeetingStatus = existing.getMeetingStatus(); + Integer candidateMeetingStatus = candidate.getMeetingStatus(); + return candidateMeetingStatus != null + && existingMeetingStatus != null + && !candidateMeetingStatus.equals(existingMeetingStatus); + } + private void afterCommitOrNow(Runnable runnable) { if (!TransactionSynchronizationManager.isSynchronizationActive()) { runnable.run(); diff --git a/backend/src/main/java/com/imeeting/service/biz/impl/MeetingQueryServiceImpl.java b/backend/src/main/java/com/imeeting/service/biz/impl/MeetingQueryServiceImpl.java index 2efe69c..d19d9bd 100644 --- a/backend/src/main/java/com/imeeting/service/biz/impl/MeetingQueryServiceImpl.java +++ b/backend/src/main/java/com/imeeting/service/biz/impl/MeetingQueryServiceImpl.java @@ -40,7 +40,7 @@ public class MeetingQueryServiceImpl implements MeetingQueryService { @Override public PageResult> pageMeetings(Integer current, Integer size, String title, Long tenantId, - Long userId, String userName, String viewType, boolean isAdmin) { + Long userId, String userName, String viewType, Integer status, boolean isAdmin) { LambdaQueryWrapper wrapper = new LambdaQueryWrapper(); if (!isAdmin || !"all".equals(viewType)) { @@ -61,6 +61,10 @@ public class MeetingQueryServiceImpl implements MeetingQueryService { wrapper.like(Meeting::getTitle, title); } + if (status != null) { + wrapper.eq(Meeting::getStatus, status); + } + wrapper.orderByDesc(Meeting::getCreatedAt); Page page = meetingService.page(new Page<>(current, size), wrapper); diff --git a/backend/src/main/java/com/imeeting/service/biz/impl/MeetingTranscriptRevisionServiceImpl.java b/backend/src/main/java/com/imeeting/service/biz/impl/MeetingTranscriptRevisionServiceImpl.java deleted file mode 100644 index a63cd18..0000000 --- a/backend/src/main/java/com/imeeting/service/biz/impl/MeetingTranscriptRevisionServiceImpl.java +++ /dev/null @@ -1,833 +0,0 @@ -package com.imeeting.service.biz.impl; - -import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; -import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper; -import com.fasterxml.jackson.core.JsonProcessingException; -import com.fasterxml.jackson.core.type.TypeReference; -import com.fasterxml.jackson.databind.ObjectMapper; -import com.imeeting.common.SysParamKeys; -import com.imeeting.dto.biz.AiModelVO; -import com.imeeting.dto.biz.MeetingSummarySource; -import com.imeeting.dto.biz.MeetingTranscriptVO; -import com.imeeting.entity.biz.AiTask; -import com.imeeting.entity.biz.Meeting; -import com.imeeting.entity.biz.MeetingTranscript; -import com.imeeting.entity.biz.MeetingTranscriptRevision; -import com.imeeting.entity.biz.MeetingTranscriptRevisionItem; -import com.imeeting.mapper.biz.MeetingTranscriptMapper; -import com.imeeting.mapper.biz.MeetingTranscriptRevisionItemMapper; -import com.imeeting.mapper.biz.MeetingTranscriptRevisionMapper; -import com.imeeting.service.biz.MeetingTranscriptRevisionService; -import com.unisbase.service.SysParamService; -import lombok.RequiredArgsConstructor; -import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; - -import java.nio.charset.StandardCharsets; -import java.security.MessageDigest; -import java.math.BigDecimal; -import java.time.LocalDateTime; -import java.util.ArrayList; -import java.util.Comparator; -import java.util.LinkedHashMap; -import java.util.List; -import java.util.Map; -import java.util.Objects; -import java.util.TreeMap; -import java.util.UUID; -import java.util.regex.Matcher; -import java.util.regex.Pattern; -import java.util.stream.Collectors; - -@Service -@RequiredArgsConstructor -public class MeetingTranscriptRevisionServiceImpl implements MeetingTranscriptRevisionService { - - private static final String RULE_PROFILE_VERSION = "v1"; - private static final String TRIGGER_TASK_TYPE = "SUMMARY"; - private static final String SEMANTIC_CORRECTOR = "NONE_V1"; - private static final String SOURCE_TYPE_REVISION = "REVISION"; - private static final String SOURCE_TYPE_RAW_FALLBACK = "RAW_FALLBACK"; - private static final int MERGE_GAP_THRESHOLD_MS = 3000; - - private final MeetingTranscriptMapper transcriptMapper; - private final MeetingTranscriptRevisionMapper revisionMapper; - private final MeetingTranscriptRevisionItemMapper itemMapper; - private final ObjectMapper objectMapper; - private final SysParamService sysParamService; - - @Override - @Transactional(rollbackFor = Exception.class) - public String generateOfflineCurrentRevision(Meeting meeting, AiTask task, AiModelVO asrModel) { - MeetingTranscriptRevision revision = createCurrentRevision(meeting, task, asrModel); - return revision.getCleanedFullText(); - } - - @Override - @Transactional(rollbackFor = Exception.class) - public MeetingSummarySource resolveSummarySource(Meeting meeting, AiTask summaryTask, AiModelVO asrModel) { - List transcripts = loadRawTranscripts(meeting.getId()); - String rawText = buildRawTranscriptText(transcripts); - String fingerprint = buildSourceFingerprint(transcripts); - if (transcripts.isEmpty() || rawText.isBlank()) { - return buildFallbackSource(rawText, fingerprint); - } - - Map ruleProfile = buildRuleProfile(fingerprint, asrModel); - String ruleProfileJson = toJson(ruleProfile); - MeetingTranscriptRevision current = findCurrentRevision(meeting.getId()); - if (isReusableCurrentRevision(current, ruleProfileJson)) { - return MeetingSummarySource.builder() - .text(current.getCleanedFullText()) - .sourceType(SOURCE_TYPE_REVISION) - .revisionId(current.getId()) - .fallbackUsed(false) - .sourceFingerprint(fingerprint) - .triggerTaskType(TRIGGER_TASK_TYPE) - .semanticCorrector(SEMANTIC_CORRECTOR) - .ruleProfileVersion(RULE_PROFILE_VERSION) - .build(); - } - - MeetingTranscriptRevision revision = createCurrentRevision(meeting, summaryTask, asrModel); - return MeetingSummarySource.builder() - .text(revision.getCleanedFullText()) - .sourceType(SOURCE_TYPE_REVISION) - .revisionId(revision.getId()) - .fallbackUsed(false) - .sourceFingerprint(fingerprint) - .triggerTaskType(TRIGGER_TASK_TYPE) - .semanticCorrector(SEMANTIC_CORRECTOR) - .ruleProfileVersion(RULE_PROFILE_VERSION) - .build(); - } - - @Override - public List listEffectiveTranscripts(Long meetingId) { - List transcripts = loadRawTranscripts(meetingId); - MeetingTranscriptRevision current = findCurrentRevision(meetingId); - Map itemByTranscriptId = new LinkedHashMap<>(); - if (current != null) { - itemByTranscriptId = itemMapper.selectList(new LambdaQueryWrapper() - .eq(MeetingTranscriptRevisionItem::getRevisionId, current.getId()) - .orderByAsc(MeetingTranscriptRevisionItem::getSourceSortOrder) - .orderByAsc(MeetingTranscriptRevisionItem::getId)) - .stream() - .collect(Collectors.toMap( - MeetingTranscriptRevisionItem::getSourceTranscriptId, - item -> item, - (left, right) -> right.getCleanedContent() != null && !right.getCleanedContent().isBlank() ? right : left, - LinkedHashMap::new - )); - } - List result = new ArrayList<>(); - for (MeetingTranscript transcript : transcripts) { - MeetingTranscriptRevisionItem item = itemByTranscriptId.get(transcript.getId()); - if (item != null && isSuppressedAction(item.getActionType())) { - continue; - } - MeetingTranscriptVO vo = new MeetingTranscriptVO(); - vo.setId(transcript.getId()); - vo.setSpeakerId(transcript.getSpeakerId()); - vo.setSpeakerName(transcript.getSpeakerName()); - vo.setSpeakerLabel(transcript.getSpeakerLabel()); - vo.setStartTime(transcript.getStartTime()); - vo.setEndTime(transcript.getEndTime()); - vo.setContent(resolveEffectiveContent(transcript, item)); - result.add(vo); - } - return result; - } - - @Override - @Transactional(rollbackFor = Exception.class) - public boolean updateCurrentRevisionContent(Long meetingId, Long operatorId, String content) { - MeetingTranscriptRevision current = findCurrentRevision(meetingId); - if (current == null) { - return false; - } - current.setCleanedFullText(content); - revisionMapper.updateById(current); - return true; - } - - @Override - @Transactional(rollbackFor = Exception.class) - public void invalidateCurrentRevision(Long meetingId) { - revisionMapper.update(null, new LambdaUpdateWrapper() - .eq(MeetingTranscriptRevision::getMeetingId, meetingId) - .eq(MeetingTranscriptRevision::getIsCurrent, 1) - .set(MeetingTranscriptRevision::getIsCurrent, 0)); - } - - @Transactional(rollbackFor = Exception.class) - protected MeetingTranscriptRevision createCurrentRevision(Meeting meeting, AiTask task, AiModelVO asrModel) { - List transcripts = loadRawTranscripts(meeting.getId()); - String fingerprint = buildSourceFingerprint(transcripts); - Map ruleProfile = buildRuleProfile(fingerprint, asrModel); - CleaningResult cleaningResult = cleanTranscripts(transcripts, ruleProfile); - MeetingTranscriptRevision current = findCurrentRevision(meeting.getId()); - int nextRevisionNo = resolveNextRevisionNo(meeting.getId(), current); - - MeetingTranscriptRevision draft = new MeetingTranscriptRevision(); - draft.setMeetingId(meeting.getId()); - draft.setSourceTaskId(task != null ? task.getId() : null); - draft.setRevisionNo(nextRevisionNo); - draft.setStatus(1); - draft.setCleanedFullText(cleaningResult.fullText()); - draft.setRuleProfile(toJson(ruleProfile)); - draft.setSegmentCount(transcripts.size()); - draft.setDroppedSegmentCount(cleaningResult.droppedCount()); - draft.setMergedGroupCount(cleaningResult.mergedGroupCount()); - draft.setIsCurrent(0); - revisionMapper.insert(draft); - - if (current != null) { - revisionMapper.update(null, new LambdaUpdateWrapper() - .eq(MeetingTranscriptRevision::getMeetingId, meeting.getId()) - .eq(MeetingTranscriptRevision::getIsCurrent, 1) - .set(MeetingTranscriptRevision::getIsCurrent, 0)); - } - - MeetingTranscriptRevision finalRevision = new MeetingTranscriptRevision(); - finalRevision.setMeetingId(meeting.getId()); - finalRevision.setSourceTaskId(task != null ? task.getId() : null); - finalRevision.setRevisionNo(nextRevisionNo + 1); - finalRevision.setStatus(2); - finalRevision.setCleanedFullText(cleaningResult.fullText()); - finalRevision.setRuleProfile(toJson(ruleProfile)); - finalRevision.setSegmentCount(transcripts.size()); - finalRevision.setDroppedSegmentCount(cleaningResult.droppedCount()); - finalRevision.setMergedGroupCount(cleaningResult.mergedGroupCount()); - finalRevision.setIsCurrent(1); - revisionMapper.insert(finalRevision); - - for (MeetingTranscriptRevisionItem item : cleaningResult.items()) { - item.setRevisionId(finalRevision.getId()); - itemMapper.insert(item); - } - return finalRevision; - } - - private List loadRawTranscripts(Long meetingId) { - return transcriptMapper.selectList(new LambdaQueryWrapper() - .eq(MeetingTranscript::getMeetingId, meetingId) - .orderByAsc(MeetingTranscript::getSortOrder) - .orderByAsc(MeetingTranscript::getStartTime) - .orderByAsc(MeetingTranscript::getId)); - } - - private MeetingTranscriptRevision findCurrentRevision(Long meetingId) { - return revisionMapper.selectOne(new LambdaQueryWrapper() - .eq(MeetingTranscriptRevision::getMeetingId, meetingId) - .eq(MeetingTranscriptRevision::getIsCurrent, 1) - .orderByDesc(MeetingTranscriptRevision::getRevisionNo) - .last("limit 1")); - } - - private int resolveNextRevisionNo(Long meetingId, MeetingTranscriptRevision current) { - if (current != null && current.getRevisionNo() != null) { - return current.getRevisionNo() + 1; - } - MeetingTranscriptRevision latest = revisionMapper.selectOne(new LambdaQueryWrapper() - .eq(MeetingTranscriptRevision::getMeetingId, meetingId) - .orderByDesc(MeetingTranscriptRevision::getRevisionNo) - .last("limit 1")); - return latest == null || latest.getRevisionNo() == null ? 1 : latest.getRevisionNo() + 1; - } - - private boolean isReusableCurrentRevision(MeetingTranscriptRevision current, String expectedRuleProfile) { - return current != null - && Integer.valueOf(1).equals(current.getIsCurrent()) - && Integer.valueOf(2).equals(current.getStatus()) - && current.getCleanedFullText() != null - && !current.getCleanedFullText().isBlank() - && Objects.equals(expectedRuleProfile, current.getRuleProfile()); - } - - private Map buildRuleProfile(String fingerprint, AiModelVO asrModel) { - Map profile = new LinkedHashMap<>(); - profile.put("ruleProfileVersion", RULE_PROFILE_VERSION); - profile.put("sourceFingerprint", fingerprint); - profile.put("triggerTaskType", TRIGGER_TASK_TYPE); - profile.put("semanticCorrector", SEMANTIC_CORRECTOR); - profile.put("transcriptRuleFillerWords", resolveFillerWords(asrModel)); - profile.put("transcriptRuleReplacements", resolveReplacementRules(asrModel)); - return profile; - } - - private List resolveFillerWords(AiModelVO asrModel) { - List configured = parseCleanupWords(sysParamService.getCachedParamValue( - SysParamKeys.MEETING_TRANSCRIPT_CLEANUP_FILLER_WORDS, - "" - )); - if (!configured.isEmpty()) { - return configured; - } - if (asrModel == null || asrModel.getMediaConfig() == null) { - return List.of(); - } - Object raw = asrModel.getMediaConfig().get("transcriptRuleFillerWords"); - if (!(raw instanceof List list)) { - return List.of(); - } - return list.stream() - .filter(Objects::nonNull) - .map(String::valueOf) - .map(String::trim) - .filter(value -> !value.isBlank()) - .distinct() - .sorted() - .toList(); - } - - private Map resolveReplacementRules(AiModelVO asrModel) { - Map configured = parseCleanupReplacements(sysParamService.getCachedParamValue( - SysParamKeys.MEETING_TRANSCRIPT_CLEANUP_REPLACEMENTS, - "" - )); - if (!configured.isEmpty()) { - return configured; - } - if (asrModel == null || asrModel.getMediaConfig() == null) { - return Map.of(); - } - Object raw = asrModel.getMediaConfig().get("transcriptRuleReplacements"); - if (!(raw instanceof Map map)) { - return Map.of(); - } - Map result = new TreeMap<>(); - for (Map.Entry entry : map.entrySet()) { - if (entry.getKey() == null || entry.getValue() == null) { - continue; - } - String key = String.valueOf(entry.getKey()).trim(); - String value = String.valueOf(entry.getValue()).trim(); - if (!key.isBlank() && !value.isBlank()) { - result.put(key, value); - } - } - return result; - } - - private List parseCleanupWords(String raw) { - if (raw == null || raw.isBlank()) { - return List.of(); - } - String normalized = raw.trim(); - try { - if (normalized.startsWith("[")) { - List parsed = objectMapper.readValue(normalized, new TypeReference>() {}); - return parsed.stream() - .filter(Objects::nonNull) - .map(String::valueOf) - .map(String::trim) - .filter(value -> !value.isBlank()) - .distinct() - .sorted() - .toList(); - } - } catch (Exception ignored) { - // Fall back to plain-text parsing. - } - return java.util.Arrays.stream(normalized.split("[,,;;\\r\\n]+")) - .map(String::trim) - .filter(value -> !value.isBlank()) - .distinct() - .sorted() - .toList(); - } - - private Map parseCleanupReplacements(String raw) { - if (raw == null || raw.isBlank()) { - return Map.of(); - } - String normalized = raw.trim(); - try { - if (normalized.startsWith("{")) { - Map parsed = objectMapper.readValue(normalized, new TypeReference>() {}); - Map result = new TreeMap<>(); - for (Map.Entry entry : parsed.entrySet()) { - if (entry.getKey() == null || entry.getValue() == null) { - continue; - } - String key = entry.getKey().trim(); - String value = String.valueOf(entry.getValue()).trim(); - if (!key.isBlank() && !value.isBlank()) { - result.put(key, value); - } - } - return result; - } - } catch (Exception ignored) { - // Fall back to line-based parsing. - } - - Map result = new TreeMap<>(); - for (String line : normalized.split("[\\r\\n]+")) { - String candidate = line == null ? "" : line.trim(); - if (candidate.isBlank()) { - continue; - } - String[] separatorCandidates = {"=>", "=", ":"}; - for (String separator : separatorCandidates) { - int index = candidate.indexOf(separator); - if (index <= 0 || index >= candidate.length() - separator.length()) { - continue; - } - String key = candidate.substring(0, index).trim(); - String value = candidate.substring(index + separator.length()).trim(); - if (!key.isBlank() && !value.isBlank()) { - result.put(key, value); - } - break; - } - } - return result; - } - - private CleaningResult cleanTranscripts(List transcripts, Map ruleProfile) { - @SuppressWarnings("unchecked") - List fillerWords = (List) ruleProfile.getOrDefault("transcriptRuleFillerWords", List.of()); - @SuppressWarnings("unchecked") - Map replacements = (Map) ruleProfile.getOrDefault("transcriptRuleReplacements", Map.of()); - - List orderedFillerWords = fillerWords.stream() - .filter(Objects::nonNull) - .map(String::trim) - .filter(value -> !value.isBlank()) - .distinct() - .sorted(Comparator.comparingInt(String::length).reversed().thenComparing(String::compareTo)) - .toList(); - List> orderedReplacementRules = replacements.entrySet().stream() - .filter(entry -> entry.getKey() != null && entry.getValue() != null) - .sorted(Comparator.>comparingInt(entry -> entry.getKey().length()).reversed() - .thenComparing(Map.Entry::getKey)) - .toList(); - - List items = new ArrayList<>(); - List groups = new ArrayList<>(); - MeetingTranscript previousTranscript = null; - SegmentGroupState currentGroup = null; - int droppedCount = 0; - - for (int index = 0; index < transcripts.size(); index++) { - MeetingTranscript transcript = transcripts.get(index); - String normalizedSource = normalizeRawContent(transcript.getContent()); - MeetingTranscriptRevisionItem item = createRevisionItem(transcript, transcripts, index); - - if (previousTranscript != null - && currentGroup != null - && isAdjacentDuplicate(previousTranscript, transcript)) { - item.setActionType("DROP_DUPLICATE"); - item.setCleanedContent(""); - item.setCleanedSpeakerName(transcript.getSpeakerName()); - item.setMergeGroupId(currentGroup.getGroupId()); - item.setConfidence(confidenceForAction("DROP_DUPLICATE")); - item.setRuleHits(toJson(buildRuleHits(List.of(), List.of()))); - items.add(item); - droppedCount++; - previousTranscript = transcript; - continue; - } - - if (currentGroup != null && shouldMergeIntoCurrentGroup(currentGroup, transcript)) { - currentGroup.appendSourceContent(normalizedSource, transcript); - currentGroup.incrementSourceSegmentCount(); - currentGroup.getRepresentativeItem().setMergeGroupId(currentGroup.getGroupId()); - item.setActionType("MERGE_INTO_PREV"); - item.setCleanedContent(""); - item.setCleanedSpeakerName(transcript.getSpeakerName()); - item.setMergeGroupId(currentGroup.getGroupId()); - item.setConfidence(confidenceForAction("MERGE_INTO_PREV")); - item.setRuleHits(toJson(buildRuleHits(List.of(), List.of()))); - items.add(item); - previousTranscript = transcript; - continue; - } - - item.setMergeGroupId(""); - items.add(item); - currentGroup = SegmentGroupState.start("merge-" + transcript.getId() + "-" + UUID.randomUUID(), transcript, item, normalizedSource); - groups.add(currentGroup); - previousTranscript = transcript; - } - - List finalLines = new ArrayList<>(); - int mergedGroupCount = 0; - for (SegmentGroupState group : groups) { - if (group.getSourceSegmentCount() > 1) { - mergedGroupCount++; - group.getRepresentativeItem().setMergeGroupId(group.getGroupId()); - } - - TextCleanupResult cleanupResult = cleanTranscriptContent(group.getMergedSourceContent(), orderedFillerWords, orderedReplacementRules); - String cleaned = cleanupResult.cleanedContent(); - MeetingTranscriptRevisionItem representativeItem = group.getRepresentativeItem(); - representativeItem.setCleanedContent(cleaned); - representativeItem.setCleanedSpeakerName(group.getRepresentativeTranscript().getSpeakerName()); - representativeItem.setActionType(resolveActionType(group.getMergedSourceContent(), cleaned, - cleanupResult.matchedFillerWords(), cleanupResult.matchedReplacementRules())); - representativeItem.setRuleHits(toJson(buildRuleHits( - cleanupResult.matchedFillerWords(), - cleanupResult.matchedReplacementRules() - ))); - representativeItem.setConfidence(confidenceForAction(representativeItem.getActionType())); - - if (cleaned.isBlank()) { - droppedCount += group.getSourceSegmentCount(); - continue; - } - finalLines.add(formatTranscriptLine(group.getRepresentativeTranscript(), cleaned)); - } - - String fullText = finalLines.stream() - .filter(line -> line != null && !line.isBlank()) - .collect(Collectors.joining("\n")); - return new CleaningResult(fullText, items, droppedCount, mergedGroupCount); - } - - private String buildSourceFingerprint(List transcripts) { - String raw = transcripts.stream() - .sorted(Comparator.comparing(MeetingTranscript::getSortOrder, Comparator.nullsLast(Integer::compareTo)) - .thenComparing(MeetingTranscript::getStartTime, Comparator.nullsLast(Integer::compareTo)) - .thenComparing(MeetingTranscript::getId, Comparator.nullsLast(Long::compareTo))) - .map(transcript -> String.join("|", - String.valueOf(transcript.getId()), - nullSafe(transcript.getSpeakerId()), - nullSafe(transcript.getSpeakerName()), - nullSafe(transcript.getContent()), - String.valueOf(transcript.getStartTime()), - String.valueOf(transcript.getEndTime()), - String.valueOf(transcript.getSortOrder()))) - .collect(Collectors.joining("\n")); - try { - MessageDigest digest = MessageDigest.getInstance("SHA-256"); - byte[] hashed = digest.digest(raw.getBytes(StandardCharsets.UTF_8)); - StringBuilder builder = new StringBuilder(); - for (byte b : hashed) { - builder.append(String.format("%02x", b)); - } - return builder.toString(); - } catch (Exception ex) { - throw new RuntimeException("计算转录内容失败", ex); - } - } - - private String buildRawTranscriptText(List transcripts) { - return transcripts.stream() - .filter(Objects::nonNull) - .map(transcript -> formatTranscriptLine(transcript, normalizeRawContent(transcript.getContent()))) - .filter(line -> line != null && !line.isBlank()) - .collect(Collectors.joining("\n")); - } - - private MeetingSummarySource buildFallbackSource(String rawText, String fingerprint) { - return MeetingSummarySource.builder() - .text(rawText) - .sourceType(SOURCE_TYPE_RAW_FALLBACK) - .revisionId(null) - .fallbackUsed(true) - .sourceFingerprint(fingerprint) - .triggerTaskType(TRIGGER_TASK_TYPE) - .semanticCorrector(SEMANTIC_CORRECTOR) - .ruleProfileVersion(RULE_PROFILE_VERSION) - .build(); - } - - private String formatTranscriptLine(MeetingTranscript transcript, String content) { - 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 String resolveEffectiveContent(MeetingTranscript transcript, MeetingTranscriptRevisionItem item) { - if (item == null) { - return transcript.getContent(); - } - if (item.getCleanedContent() != null) { - return item.getCleanedContent(); - } - return transcript.getContent(); - } - - private boolean isSuppressedAction(String actionType) { - return "MERGE_INTO_PREV".equals(actionType) - || "DROP_FILLER".equals(actionType) - || "DROP_DUPLICATE".equals(actionType); - } - - private String resolveActionType(String normalizedSource, - String cleaned, - List matchedFillerWords, - List matchedReplacementRules) { - if (cleaned == null || cleaned.isBlank()) { - return !matchedFillerWords.isEmpty() ? "DROP_FILLER" : "EDIT"; - } - if (Objects.equals(cleaned, normalizedSource)) { - return "KEEP"; - } - return "RULE_REPLACED"; - } - - private Map buildRuleHits(List matchedFillerWords, List matchedReplacementRules) { - Map ruleHits = new LinkedHashMap<>(); - ruleHits.put("fillerWords", matchedFillerWords == null ? List.of() : List.copyOf(matchedFillerWords)); - ruleHits.put("replacements", matchedReplacementRules == null ? List.of() : List.copyOf(matchedReplacementRules)); - return ruleHits; - } - - private MeetingTranscriptRevisionItem createRevisionItem(MeetingTranscript transcript, - List transcripts, - int index) { - MeetingTranscriptRevisionItem item = new MeetingTranscriptRevisionItem(); - item.setSourceTranscriptId(transcript.getId()); - item.setSourceSortOrder(transcript.getSortOrder()); - item.setSourceSpeakerId(transcript.getSpeakerId()); - item.setSourceSpeakerName(transcript.getSpeakerName()); - item.setSourceContent(transcript.getContent()); - item.setCleanedSpeakerName(transcript.getSpeakerName()); - item.setContextSnapshot(buildContextSnapshot(transcripts, index)); - return item; - } - - private String buildContextSnapshot(List transcripts, int index) { - Map snapshot = new LinkedHashMap<>(); - snapshot.put("prevContent", index > 0 ? normalizeRawContent(transcripts.get(index - 1).getContent()) : ""); - snapshot.put("currentContent", normalizeRawContent(transcripts.get(index).getContent())); - snapshot.put("nextContent", index + 1 < transcripts.size() - ? normalizeRawContent(transcripts.get(index + 1).getContent()) - : ""); - return toJson(snapshot); - } - - private boolean isAdjacentDuplicate(MeetingTranscript previousTranscript, MeetingTranscript currentTranscript) { - return sameSpeaker(previousTranscript, currentTranscript) - && !normalizeComparisonText(previousTranscript.getContent()).isBlank() - && Objects.equals( - normalizeComparisonText(previousTranscript.getContent()), - normalizeComparisonText(currentTranscript.getContent()) - ); - } - - private boolean shouldMergeIntoCurrentGroup(SegmentGroupState currentGroup, MeetingTranscript currentTranscript) { - if (currentGroup == null || currentTranscript == null) { - return false; - } - if (!sameSpeaker(currentGroup.getLastTranscript(), currentTranscript)) { - return false; - } - Integer previousEndTime = currentGroup.getLastTranscript().getEndTime(); - Integer currentStartTime = currentTranscript.getStartTime(); - if (previousEndTime == null || currentStartTime == null) { - return false; - } - int gap = currentStartTime - previousEndTime; - return gap >= 0 && gap <= MERGE_GAP_THRESHOLD_MS; - } - - private boolean sameSpeaker(MeetingTranscript left, MeetingTranscript right) { - if (left == null || right == null) { - return false; - } - return Objects.equals(nullSafe(left.getSpeakerId()), nullSafe(right.getSpeakerId())) - && Objects.equals(nullSafe(left.getSpeakerName()), nullSafe(right.getSpeakerName())); - } - - private String normalizeComparisonText(String content) { - return normalizeRawContent(content) - .replaceAll("\\s+", "") - .replaceAll("[,。?!;:、,.!?;:]", ""); - } - - private TextCleanupResult cleanTranscriptContent(String content, - List fillerWords, - List> replacementRules) { - String cleaned = normalizeRawContent(content); - List matchedFillerWords = new ArrayList<>(); - List matchedReplacementRules = new ArrayList<>(); - for (String fillerWord : fillerWords) { - String updated = removeFillerWord(cleaned, fillerWord); - if (!Objects.equals(updated, cleaned)) { - matchedFillerWords.add(fillerWord); - cleaned = updated; - } - } - for (Map.Entry entry : replacementRules) { - if (cleaned.contains(entry.getKey())) { - matchedReplacementRules.add(entry.getKey() + "->" + entry.getValue()); - cleaned = cleaned.replace(entry.getKey(), entry.getValue()); - } - } - cleaned = normalizeCleanedContent(cleaned); - return new TextCleanupResult(cleaned, matchedFillerWords, matchedReplacementRules); - } - - private String removeFillerWord(String content, String fillerWord) { - if (content == null || content.isBlank() || fillerWord == null || fillerWord.isBlank()) { - return content == null ? "" : content; - } - Pattern pattern = Pattern.compile("(^|[\\s,。?!;:、,.!?;:])(" + Pattern.quote(fillerWord) + ")(?=($|[\\s,。?!;:、,.!?;:]))"); - Matcher matcher = pattern.matcher(content); - StringBuffer buffer = new StringBuffer(); - boolean changed = false; - while (matcher.find()) { - String prefix = matcher.group(1); - matcher.appendReplacement(buffer, Matcher.quoteReplacement(prefix == null ? "" : prefix)); - changed = true; - } - matcher.appendTail(buffer); - return changed ? buffer.toString() : content; - } - - private String normalizeRawContent(String content) { - if (content == null || content.isBlank()) { - return ""; - } - return content.trim(); - } - - private String normalizeCleanedContent(String content) { - if (content == null || content.isBlank()) { - return ""; - } - String normalized = content.replace("\r\n", " ") - .replace("\n", " ") - .replaceAll("\\s+", " ") - .trim(); - normalized = normalized.replaceAll("\\s*([,。?!;:、,.!?;:])\\s*", "$1"); - normalized = normalized.replaceAll("([,。?!;:、,.!?;:])[,。?!;:、,.!?;:]+", "$1"); - normalized = normalized.replaceAll("^[,。?!;:、,.!?;:]+", ""); - normalized = normalized.replaceAll("\\(\\s+\\)", "()"); - return normalized.trim(); - } - - private BigDecimal confidenceForAction(String actionType) { - return switch (actionType) { - case "DROP_DUPLICATE" -> BigDecimal.valueOf(0.98D); - case "DROP_FILLER" -> BigDecimal.valueOf(0.95D); - case "MERGE_INTO_PREV" -> BigDecimal.valueOf(0.90D); - case "RULE_REPLACED" -> BigDecimal.valueOf(0.88D); - default -> BigDecimal.ONE; - }; - } - - private String nullSafe(String value) { - return value == null ? "" : value; - } - - private String toJson(Object value) { - try { - return objectMapper.writeValueAsString(value); - } catch (JsonProcessingException ex) { - throw new RuntimeException("序列化修正版元数据失败", ex); - } - } - - @SuppressWarnings("unused") - private Map readRuleProfile(String raw) { - if (raw == null || raw.isBlank()) { - return Map.of(); - } - try { - return objectMapper.readValue(raw, new TypeReference<>() {}); - } catch (JsonProcessingException ex) { - return Map.of(); - } - } - - private record CleaningResult(String fullText, - List items, - int droppedCount, - int mergedGroupCount) { - } - - private record TextCleanupResult(String cleanedContent, - List matchedFillerWords, - List matchedReplacementRules) { - } - - private static final class SegmentGroupState { - private final String groupId; - private final MeetingTranscript representativeTranscript; - private final MeetingTranscriptRevisionItem representativeItem; - private MeetingTranscript lastTranscript; - private String mergedSourceContent; - private int sourceSegmentCount; - - private SegmentGroupState(String groupId, - MeetingTranscript representativeTranscript, - MeetingTranscriptRevisionItem representativeItem, - String mergedSourceContent) { - this.groupId = groupId; - this.representativeTranscript = representativeTranscript; - this.representativeItem = representativeItem; - this.lastTranscript = representativeTranscript; - this.mergedSourceContent = mergedSourceContent; - this.sourceSegmentCount = 1; - } - - private static SegmentGroupState start(String groupId, - MeetingTranscript representativeTranscript, - MeetingTranscriptRevisionItem representativeItem, - String mergedSourceContent) { - return new SegmentGroupState(groupId, representativeTranscript, representativeItem, mergedSourceContent); - } - - private void appendSourceContent(String nextContent, MeetingTranscript transcript) { - this.mergedSourceContent = joinTranscriptContent(this.mergedSourceContent, nextContent); - this.lastTranscript = transcript; - } - - private void incrementSourceSegmentCount() { - this.sourceSegmentCount++; - } - - private static String joinTranscriptContent(String left, String right) { - if (left == null || left.isBlank()) { - return right == null ? "" : right; - } - if (right == null || right.isBlank()) { - return left; - } - boolean leftAsciiTail = Character.isLetterOrDigit(left.charAt(left.length() - 1)); - boolean rightAsciiHead = Character.isLetterOrDigit(right.charAt(0)); - if (leftAsciiTail && rightAsciiHead) { - return left + " " + right; - } - return left + right; - } - - private String getGroupId() { - return groupId; - } - - private MeetingTranscript getRepresentativeTranscript() { - return representativeTranscript; - } - - private MeetingTranscriptRevisionItem getRepresentativeItem() { - return representativeItem; - } - - private MeetingTranscript getLastTranscript() { - return lastTranscript; - } - - private String getMergedSourceContent() { - return mergedSourceContent; - } - - private int getSourceSegmentCount() { - return sourceSegmentCount; - } - } -} diff --git a/backend/src/main/java/com/imeeting/service/biz/impl/RedisOnlyMeetingProgressServiceAdapter.java b/backend/src/main/java/com/imeeting/service/biz/impl/RedisOnlyMeetingProgressServiceAdapter.java index 2851546..9f4db9d 100644 --- a/backend/src/main/java/com/imeeting/service/biz/impl/RedisOnlyMeetingProgressServiceAdapter.java +++ b/backend/src/main/java/com/imeeting/service/biz/impl/RedisOnlyMeetingProgressServiceAdapter.java @@ -8,6 +8,8 @@ import com.imeeting.entity.biz.AiTask; import com.imeeting.service.biz.MeetingProgressService; import org.springframework.data.redis.core.StringRedisTemplate; +import java.util.LinkedHashMap; +import java.util.List; import java.util.Map; import java.util.concurrent.TimeUnit; @@ -38,6 +40,21 @@ public class RedisOnlyMeetingProgressServiceAdapter implements MeetingProgressSe return objectMapper.convertValue(snapshot, Map.class); } + @Override + public Map> getProgressMaps(List meetingIds) { + Map> result = new LinkedHashMap<>(); + if (meetingIds == null || meetingIds.isEmpty()) { + return result; + } + for (Long meetingId : meetingIds) { + if (meetingId == null) { + continue; + } + result.put(meetingId, getProgressMap(meetingId)); + } + return result; + } + @Override public Integer resolvePercent(Long meetingId) { MeetingProgressSnapshot snapshot = readSnapshot(meetingId); @@ -108,6 +125,10 @@ public class RedisOnlyMeetingProgressServiceAdapter implements MeetingProgressSe int percent, String message, int eta) { + String resolvedMessage = message; + if (stage == MeetingProgressStage.QUEUED && (resolvedMessage == null || resolvedMessage.isBlank())) { + resolvedMessage = "已进入 ASR 队列,等待执行"; + } return MeetingProgressSnapshot.builder() .meetingId(meetingId) .taskId(task == null ? null : task.getId()) @@ -117,7 +138,7 @@ public class RedisOnlyMeetingProgressServiceAdapter implements MeetingProgressSe .stage(stage.getCode()) .stageOrder(stage.getOrder()) .percent(percent) - .message(message) + .message(resolvedMessage) .eta(eta) .queuedAt(task == null ? null : task.getQueuedAt()) .startedAt(task == null ? null : task.getStartedAt()) diff --git a/backend/src/main/java/com/imeeting/service/mcp/MeetingMcpToolService.java b/backend/src/main/java/com/imeeting/service/mcp/MeetingMcpToolService.java index 4fd974a..eb6cb21 100644 --- a/backend/src/main/java/com/imeeting/service/mcp/MeetingMcpToolService.java +++ b/backend/src/main/java/com/imeeting/service/mcp/MeetingMcpToolService.java @@ -129,6 +129,7 @@ public class MeetingMcpToolService { loginUser.getUserId(), resolveCreatorName(loginUser), "all", + null, isAdmin ); 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 fe99398..603442a 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,303 +1,152 @@ package com.imeeting.service.biz.impl; import com.fasterxml.jackson.databind.ObjectMapper; -import com.imeeting.dto.biz.MeetingSummarySource; +import com.imeeting.dto.biz.AiModelVO; import com.imeeting.entity.biz.AiTask; +import com.imeeting.entity.biz.HotWord; 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; import com.imeeting.service.biz.HotWordService; +import com.imeeting.service.biz.MeetingProgressService; import com.imeeting.service.biz.MeetingSummaryFileService; import com.imeeting.service.biz.MeetingTranscriptChapterService; import com.imeeting.service.biz.MeetingTranscriptFileService; -import com.imeeting.service.biz.MeetingTranscriptRevisionService; +import com.imeeting.support.RedisValueSupport; import com.imeeting.support.TaskSecurityContextRunner; import com.unisbase.mapper.SysUserMapper; +import com.unisbase.service.SysParamService; 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.HashMap; import java.util.List; +import java.util.Map; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNull; 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.ArgumentMatchers.eq; -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.times; -import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; class AiTaskServiceImplTest { @Test - void appendPathShouldTrimWhitespaceAndNormalizeSlashBoundaries() { - AiTaskServiceImpl service = createService(); + void buildAsrRequestShouldFollowCurrentOfflineAsrContract() { + HotWordService hotWordService = mock(HotWordService.class); + HotWord hotWord = new HotWord(); + hotWord.setWord("汇智"); + hotWord.setWeight(25); + when(hotWordService.list(any())).thenReturn(List.of(hotWord)); - String url = ReflectionTestUtils.invokeMethod( - service, - "appendPath", - " http://10.100.52.43:1234/ ", - " /v1/chat/completions " - ); - - assertEquals("http://10.100.52.43:1234/v1/chat/completions", url); - } - - @Test - void appendPathShouldReturnAbsolutePathWhenApiPathIsFullUrl() { - AiTaskServiceImpl service = createService(); - - String url = ReflectionTestUtils.invokeMethod( - service, - "appendPath", - " http://10.100.52.43:1234/ ", - " https://example.com/custom-endpoint " - ); - - assertEquals("https://example.com/custom-endpoint", url); - } - - @Test - void buildUriShouldTrimWhitespaceBeforeUriCreate() { - AiTaskServiceImpl service = createService(); - - URI uri = ReflectionTestUtils.invokeMethod( - service, - "buildUri", - " http://10.100.52.43:1234/v1/chat/completions " - ); - - 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(), - mock(MeetingTranscriptFileService.class), - mock(MeetingTranscriptRevisionService.class), - mock(MeetingTranscriptChapterService.class) - )); - 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, null, summaryTask).when(service).getOne(any()); - - service.dispatchTasks(66L, 1L, 2L); - - assertEquals(3, summaryTask.getStatus()); - assertTrue(summaryTask.getErrorMsg() != null && !summaryTask.getErrorMsg().isBlank()); - 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); - MeetingTranscriptChapterService chapterService = mock(MeetingTranscriptChapterService.class); - @SuppressWarnings("unchecked") - ValueOperations valueOperations = mock(ValueOperations.class); - when(redisTemplate.opsForValue()).thenReturn(valueOperations); - when(valueOperations.setIfAbsent(anyString(), anyString(), anyLong(), any())).thenReturn(true); - when(chapterService.resolveSummarySource(any(), any())).thenReturn(MeetingSummarySource.builder() - .text(" ") - .sourceType("RAW_FALLBACK") - .fallbackUsed(true) - .algorithmVersion("cohesion-v1") - .build()); - - AiTaskServiceImpl service = spy(createService( - meetingMapper, - transcriptMapper, - aiModelService, - redisTemplate, - new TaskSecurityContextRunner(), - mock(MeetingTranscriptFileService.class), - mock(MeetingTranscriptRevisionService.class), - chapterService - )); - doReturn(true).when(service).updateById(any()); - - Meeting meeting = new Meeting(); - meeting.setId(77L); - when(meetingMapper.selectById(77L)).thenReturn(meeting); - - AiTask chapterTask = new AiTask(); - chapterTask.setId(88L); - chapterTask.setMeetingId(77L); - chapterTask.setTaskType("CHAPTER"); - chapterTask.setStatus(0); - AiTask summaryTask = new AiTask(); - summaryTask.setId(100L); - summaryTask.setMeetingId(77L); - summaryTask.setTaskType("SUMMARY"); - summaryTask.setStatus(0); - doReturn(chapterTask, summaryTask).when(service).getOne(any()); - - service.dispatchSummaryTask(77L, 1L, 2L); - - assertEquals(3, summaryTask.getStatus()); - assertTrue(summaryTask.getErrorMsg() != null && !summaryTask.getErrorMsg().isBlank()); - verify(aiModelService, never()).getModelById(anyLong(), anyString()); - } - - @Test - void dispatchSummaryTaskShouldWaitForExternalOrchestrationWhenExternalModeEnabled() { - MeetingMapper meetingMapper = mock(MeetingMapper.class); - MeetingTranscriptMapper transcriptMapper = mock(MeetingTranscriptMapper.class); - AiModelService aiModelService = mock(AiModelService.class); - StringRedisTemplate redisTemplate = mock(StringRedisTemplate.class); - MeetingTranscriptChapterService chapterService = mock(MeetingTranscriptChapterService.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(), - mock(MeetingTranscriptFileService.class), - mock(MeetingTranscriptRevisionService.class), - chapterService - )); - ReflectionTestUtils.setField(service, "summaryOrchestrationMode", "EXTERNAL_N8N"); - - Meeting meeting = new Meeting(); - meeting.setId(78L); - when(meetingMapper.selectById(78L)).thenReturn(meeting); - - AiTask summaryTask = new AiTask(); - summaryTask.setId(101L); - summaryTask.setMeetingId(78L); - summaryTask.setTaskType("SUMMARY"); - summaryTask.setStatus(0); - doReturn(summaryTask, null).when(service).getOne(any()); - - service.dispatchSummaryTask(78L, 1L, 2L); - - verify(chapterService, never()).resolveSummarySource(any(), any()); - verify(aiModelService, never()).getModelById(anyLong(), anyString()); - } - - @Test - void saveTranscriptsShouldInitializeTranscriptFileAfterFirstPersist() throws Exception { - MeetingMapper meetingMapper = mock(MeetingMapper.class); - MeetingTranscriptMapper transcriptMapper = mock(MeetingTranscriptMapper.class); - MeetingTranscriptFileService transcriptFileService = mock(MeetingTranscriptFileService.class); - AiTaskServiceImpl service = createService( - meetingMapper, - transcriptMapper, - mock(AiModelService.class), - mock(StringRedisTemplate.class), - mock(TaskSecurityContextRunner.class), - transcriptFileService, - mock(MeetingTranscriptRevisionService.class), - mock(MeetingTranscriptChapterService.class) - ); - - Meeting meeting = new Meeting(); - meeting.setId(88L); - - ReflectionTestUtils.invokeMethod( - service, - "saveTranscripts", - meeting, - new ObjectMapper().readTree(""" - { - "segments": [ - { - "speaker_id": "123", - "speaker_name": "Alice", - "text": "hello world", - "timestamp": [0, 1200] - } - ] - } - """) - ); - - verify(transcriptMapper, times(1)).insert(any(MeetingTranscript.class)); - verify(transcriptFileService, times(1)).initializeTranscriptFileIfAbsent(eq(88L)); - } - - private AiTaskServiceImpl createService() { - return createService( + AiTaskServiceImpl service = new AiTaskServiceImpl( mock(MeetingMapper.class), mock(MeetingTranscriptMapper.class), mock(AiModelService.class), - mock(StringRedisTemplate.class), - mock(TaskSecurityContextRunner.class), + new ObjectMapper(), + mock(SysUserMapper.class), + hotWordService, + mock(RedisValueSupport.class), + mock(MeetingProgressService.class), + mock(MeetingSummaryFileService.class), mock(MeetingTranscriptFileService.class), - mock(MeetingTranscriptRevisionService.class), - mock(MeetingTranscriptChapterService.class) + mock(MeetingTranscriptChapterService.class), + mock(MeetingSummaryPromptAssembler.class), + mock(TaskSecurityContextRunner.class), + mock(MeetingExternalSummaryWebhookTrigger.class), + mock(SysParamService.class) ); + ReflectionTestUtils.setField(service, "serverBaseUrl", "http://localhost:8080"); + + Meeting meeting = new Meeting(); + meeting.setAudioUrl("/api/static/meetings/12/source audio.mp4"); + + AiTask task = new AiTask(); + Map taskConfig = new HashMap<>(); + taskConfig.put("useSpkId", 1); + taskConfig.put("enableTextRefine", true); + taskConfig.put("hotWords", List.of("汇智")); + task.setTaskConfig(taskConfig); + + AiModelVO asrModel = new AiModelVO(); + asrModel.setModelCode("legacy-model-code"); + + @SuppressWarnings("unchecked") + Map request = (Map) ReflectionTestUtils.invokeMethod( + service, + "buildAsrRequest", + meeting, + task, + asrModel + ); + + assertEquals("http://localhost:8080/api/static/meetings/12/source%20audio.mp4", request.get("audio_address")); + assertFalse(request.containsKey("file_url")); + + @SuppressWarnings("unchecked") + Map config = (Map) request.get("config"); + assertEquals(Boolean.TRUE, config.get("enable_speaker")); + assertEquals(Boolean.TRUE, config.get("match_speaker_registry")); + assertEquals(Boolean.TRUE, config.get("enable_text_cleanup")); + assertFalse(config.containsKey("enable_text_refine")); + assertFalse(config.containsKey("enable_two_pass")); + assertFalse(config.containsKey("model")); + + @SuppressWarnings("unchecked") + List> hotwords = (List>) config.get("hotwords"); + assertEquals(1, hotwords.size()); + assertEquals("汇智", hotwords.get(0).get("hotword")); + assertEquals(2.5, hotwords.get(0).get("weight")); } - private AiTaskServiceImpl createService(MeetingMapper meetingMapper, - MeetingTranscriptMapper transcriptMapper, - AiModelService aiModelService, - StringRedisTemplate redisTemplate, - TaskSecurityContextRunner taskSecurityContextRunner, - MeetingTranscriptFileService meetingTranscriptFileService, - MeetingTranscriptRevisionService revisionService, - MeetingTranscriptChapterService chapterService) { - return new AiTaskServiceImpl( - meetingMapper, - transcriptMapper, - aiModelService, + @Test + void buildAsrRequestShouldDisableRegistryMatchWhenSpeakerSplitDisabled() { + AiTaskServiceImpl service = new AiTaskServiceImpl( + mock(MeetingMapper.class), + mock(MeetingTranscriptMapper.class), + mock(AiModelService.class), new ObjectMapper(), mock(SysUserMapper.class), mock(HotWordService.class), - redisTemplate, + mock(RedisValueSupport.class), + mock(MeetingProgressService.class), mock(MeetingSummaryFileService.class), - meetingTranscriptFileService, - revisionService, - chapterService, + mock(MeetingTranscriptFileService.class), + mock(MeetingTranscriptChapterService.class), mock(MeetingSummaryPromptAssembler.class), - taskSecurityContextRunner, - mock(MeetingExternalSummaryWebhookTrigger.class) + mock(TaskSecurityContextRunner.class), + mock(MeetingExternalSummaryWebhookTrigger.class), + mock(SysParamService.class) ); + ReflectionTestUtils.setField(service, "serverBaseUrl", "http://localhost:8080"); + + Meeting meeting = new Meeting(); + meeting.setAudioUrl("/api/static/audio/demo.wav"); + + AiTask task = new AiTask(); + Map taskConfig = new HashMap<>(); + taskConfig.put("useSpkId", 0); + taskConfig.put("enableTextRefine", false); + task.setTaskConfig(taskConfig); + + @SuppressWarnings("unchecked") + Map request = (Map) ReflectionTestUtils.invokeMethod( + service, + "buildAsrRequest", + meeting, + task, + new AiModelVO() + ); + + @SuppressWarnings("unchecked") + Map config = (Map) request.get("config"); + assertEquals(Boolean.FALSE, config.get("enable_speaker")); + assertEquals(Boolean.FALSE, config.get("match_speaker_registry")); + assertEquals(Boolean.FALSE, config.get("enable_text_cleanup")); + assertTrue(((List) config.get("hotwords")).isEmpty()); + assertNull(request.get("file_url")); } } diff --git a/frontend/src/api/business/meeting.ts b/frontend/src/api/business/meeting.ts index 4f96a8e..f66c97b 100644 --- a/frontend/src/api/business/meeting.ts +++ b/frontend/src/api/business/meeting.ts @@ -142,6 +142,7 @@ export const getMeetingPage = (params: { size: number; title?: string; viewType?: "all" | "created" | "involved"; + status?: number; }) => { return http.get<{ code: string; data: { records: MeetingVO[]; total: number }; msg: string }>( "/api/biz/meeting/page", @@ -426,6 +427,7 @@ export interface MeetingProgress { message: string; updateAt: number; eta?: number; + queueAheadCount?: number; } export const getMeetingProgress = (id: number, options?: { suppressErrorToast?: boolean }) => { @@ -437,6 +439,16 @@ export const getMeetingProgress = (id: number, options?: { suppressErrorToast?: ); }; +export const getMeetingProgressBatch = (ids: number[], options?: { suppressErrorToast?: boolean }) => { + return http.post<{ code: string; data: Record; msg: string }>( + "/api/biz/meeting/progress/batch", + ids, + { + suppressErrorToast: options?.suppressErrorToast, + } + ); +}; + export const downloadMeetingSummary = (id: number, format: "pdf" | "word") => { const token = localStorage.getItem("accessToken"); return axios.get(`/api/biz/meeting/${id}/summary/export`, { diff --git a/frontend/src/pages/business/MeetingDetail.tsx b/frontend/src/pages/business/MeetingDetail.tsx index e1c3436..2eec373 100644 --- a/frontend/src/pages/business/MeetingDetail.tsx +++ b/frontend/src/pages/business/MeetingDetail.tsx @@ -359,67 +359,101 @@ function parseChapterTimeToMs(value?: string) { return totalSeconds * 1000; } +type MeetingProgressPhase = 'queued' | 'asr' | 'chapter' | 'summary' | 'terminal'; + +const meetingProgressTerminalRefreshCache = new Map(); +const meetingProgressPhaseRefreshCache = new Map(); + +const resolveProgressPhase = (progress: MeetingProgress | null | undefined): MeetingProgressPhase => { + const percent = progress?.percent ?? 0; + if (percent < 0 || percent >= 100) { + return 'terminal'; + } + if (percent >= 90) { + return 'summary'; + } + if (percent >= 85) { + return 'chapter'; + } + if (percent >= 5) { + return 'asr'; + } + return 'queued'; +}; + const MeetingProgressDisplay: React.FC<{ meetingId: number; onComplete: () => void; - onProgressUpdate?: (meeting: MeetingVO) => void; + onRefreshNeeded?: (phase: MeetingProgressPhase) => void; onProgressChange?: (progress: MeetingProgress | null) => void; compact?: boolean; inline?: boolean; -}> = ({ meetingId, onComplete, onProgressUpdate, onProgressChange, compact, inline }) => { +}> = ({ meetingId, onComplete, onRefreshNeeded, onProgressChange, compact, inline }) => { const [progress, setProgress] = useState(null); const onCompleteRef = useRef(onComplete); - const onProgressUpdateRef = useRef(onProgressUpdate); + const onRefreshNeededRef = useRef(onRefreshNeeded); const onProgressChangeRef = useRef(onProgressChange); useEffect(() => { onCompleteRef.current = onComplete; - onProgressUpdateRef.current = onProgressUpdate; + onRefreshNeededRef.current = onRefreshNeeded; onProgressChangeRef.current = onProgressChange; - }, [onComplete, onProgressUpdate, onProgressChange]); + }, [onComplete, onRefreshNeeded, onProgressChange]); useEffect(() => { let completed = false; + let timer: ReturnType | null = null; + let requesting = false; const fetchProgress = async () => { - if (completed) { + if (completed || requesting) { return; } + requesting = true; + let shouldContinue = true; try { - const [progressRes, detailRes] = await Promise.all([ - getMeetingProgress(meetingId, { suppressErrorToast: true }), - getMeetingDetail(meetingId, { suppressErrorToast: true }), - ]); - - if (detailRes.data?.data) { - onProgressUpdateRef.current?.(detailRes.data.data); - if (detailRes.data.data.status !== 1 && detailRes.data.data.status !== 2) { - completed = true; - onCompleteRef.current?.(); - return; - } - } - + const progressRes = await getMeetingProgress(meetingId, { suppressErrorToast: true }); if (progressRes.data?.data) { const nextProgress = progressRes.data.data; setProgress(nextProgress); onProgressChangeRef.current?.(nextProgress); + const phase = resolveProgressPhase(nextProgress); + const previousPhase = meetingProgressPhaseRefreshCache.get(meetingId); + meetingProgressPhaseRefreshCache.set(meetingId, phase); + if ((phase === 'chapter' || phase === 'summary') && previousPhase !== phase) { + onRefreshNeededRef.current?.(phase); + } if (nextProgress.percent === 100 || nextProgress.percent < 0) { completed = true; - onCompleteRef.current?.(); + shouldContinue = false; + const terminalKey = `${nextProgress.updateAt}:${nextProgress.percent}`; + if (meetingProgressTerminalRefreshCache.get(meetingId) !== terminalKey) { + meetingProgressTerminalRefreshCache.set(meetingId, terminalKey); + onCompleteRef.current?.(); + } + } else { + meetingProgressTerminalRefreshCache.delete(meetingId); } } } catch { // ignore + } finally { + requesting = false; + if (!completed && shouldContinue) { + timer = setTimeout(() => { + void fetchProgress(); + }, 3000); + } } }; - fetchProgress(); - const timer = setInterval(fetchProgress, 3000); + void fetchProgress(); return () => { completed = true; - clearInterval(timer); + if (timer) { + clearTimeout(timer); + } }; }, [meetingId]); @@ -1325,9 +1359,22 @@ const MeetingDetail: React.FC = () => { userPrompt: values.userPrompt, summaryDetailLevel: values.summaryDetailLevel as SummaryDetailLevel, }); - message.success('已重新发起总结任务'); - setSummaryVisible(false); - fetchData(Number(id)); + setMeeting((current) => current ? { + ...current, + status: 2, + latestChapterAttemptStatus: 0, + latestChapterAttemptErrorMsg: undefined, + latestSummaryAttemptStatus: 0, + latestSummaryAttemptErrorMsg: undefined, + } : current); + setGenerationProgress({ + percent: 85, + message: '已重新发起总结任务', + updateAt: Date.now(), + eta: 0, + }); + message.success('操作成功'); + await fetchData(Number(id)); } catch (error) { console.error(error); } finally { @@ -2000,15 +2047,11 @@ const MeetingDetail: React.FC = () => { />
- {meeting.status === 1 ? ( + {meeting.status === 0 || meeting.status === 1 ? ( fetchData(meeting.id)} - onProgressUpdate={(updated) => { - if (updated.status !== meeting.status) { - void fetchData(updated.id); - } - }} + onRefreshNeeded={() => { void fetchData(meeting.id); }} onProgressChange={setGenerationProgress} /> ) : ( @@ -2051,11 +2094,7 @@ const MeetingDetail: React.FC = () => { fetchData(meeting.id)} - onProgressUpdate={(updated) => { - if (updated.status === 2 || updated.status !== meeting.status) { - void fetchData(updated.id); - } - }} + onRefreshNeeded={() => { void fetchData(meeting.id); }} onProgressChange={setGenerationProgress} compact /> @@ -2075,11 +2114,7 @@ const MeetingDetail: React.FC = () => { fetchData(meeting.id)} - onProgressUpdate={(updated) => { - if (updated.status !== meeting.status) { - void fetchData(updated.id); - } - }} + onRefreshNeeded={() => { void fetchData(meeting.id); }} onProgressChange={setGenerationProgress} inline /> diff --git a/frontend/src/pages/business/Meetings.tsx b/frontend/src/pages/business/Meetings.tsx index 481c28a..d7bc877 100644 --- a/frontend/src/pages/business/Meetings.tsx +++ b/frontend/src/pages/business/Meetings.tsx @@ -6,6 +6,7 @@ import { CloudUploadOutlined, DeleteOutlined, EditOutlined, + FilterOutlined, InfoCircleOutlined, PauseCircleOutlined, PlusOutlined, @@ -38,7 +39,7 @@ import { Typography, } from "antd"; import dayjs from "dayjs"; -import React, { useEffect, useState } from "react"; +import React, { useEffect, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; import { useNavigate, useSearchParams } from "react-router-dom"; @@ -47,7 +48,7 @@ import { deleteMeeting, getMeetingCreateConfig, getMeetingPage, - getMeetingProgress, + getMeetingProgressBatch, getRealtimeMeetingSessionStatus, getRealtimeMeetingSessionStatuses, type MeetingCreateConfig, @@ -64,11 +65,21 @@ import PageContainer from "../../components/shared/PageContainer"; const { Title, Text } = Typography; const { Option } = Select; +const { Search } = Input; const CURRENT_PLATFORM = "WEB" as const; const PAUSED_DISPLAY_STATUS = 5; const REALTIME_ACTIVE_DISPLAY_STATUS = 6; const REALTIME_IDLE_DISPLAY_STATUS = 7; +const ALL_STATUS_FILTER = "all"; +const MEETING_STATUS_FILTER_OPTIONS = [ + { label: "全部状态", value: ALL_STATUS_FILTER, color: "#8c8c8c", bgColor: "#f5f5f5" }, + { label: "排队中", value: "0", color: "#8c8c8c", bgColor: "#f5f5f5" }, + { label: "识别中", value: "1", color: "#1890ff", bgColor: "#e6f7ff" }, + { label: "总结中", value: "2", color: "#faad14", bgColor: "#fff7e6" }, + { label: "已完成", value: "3", color: "#52c41a", bgColor: "#f6ffed" }, + { label: "失败", value: "4", color: "#ff4d4f", bgColor: "#fff1f0" }, +] as const; const DEFAULT_CREATE_CONFIG: MeetingCreateConfig = { offlineEnabled: true, realtimeEnabled: true, @@ -95,9 +106,17 @@ const canOpenRealtimeSession = (status?: RealtimeMeetingSessionStatus["status"]) || status === "ACTIVE" || status === "IDLE"; +const hasLatestGenerationFailure = (item: MeetingVO) => + item.latestChapterAttemptStatus === 3 || item.latestSummaryAttemptStatus === 3; + +const shouldTrackGenerationProgress = (item: MeetingVO) => + !hasLatestGenerationFailure(item) && (item.status === 0 || item.status === 1 || item.status === 2); + +const isTerminalMeetingProgress = (progress?: MeetingProgress | null) => + !!progress && (progress.percent === 100 || progress.percent < 0); + const shouldPollMeetingCard = (item: MeetingVO) => - item.status === 1 - || item.status === 2 + shouldTrackGenerationProgress(item) || item.realtimeSessionStatus === "ACTIVE" || isPausedRealtimeSessionStatus(item.realtimeSessionStatus); @@ -130,35 +149,9 @@ const applyRealtimeSessionStatus = (item: MeetingVO, sessionStatus?: RealtimeMee return { ...item, realtimeSessionStatus: sessionStatus.status }; }; -const useMeetingProgress = (meeting: MeetingVO, onComplete?: () => void) => { - const [progress, setProgress] = useState(null); - - useEffect(() => { - if (meeting.status !== 1 && meeting.status !== 2) { - return; - } - const fetchProgress = async () => { - try { - const res = await getMeetingProgress(meeting.id, { suppressErrorToast: true }); - const nextProgress = res.data?.data; - if (nextProgress) { - setProgress(nextProgress); - if ((nextProgress.percent === 100 || nextProgress.percent < 0) && onComplete) { - onComplete(); - } - } - } catch {} - }; - void fetchProgress(); - const timer = setInterval(fetchProgress, 3000); - return () => clearInterval(timer); - }, [meeting.id, meeting.status, onComplete]); - - return progress; -}; - const IntegratedStatusTag: React.FC<{ meeting: MeetingVO; progress: MeetingProgress | null }> = ({ meeting, progress }) => { - const effectiveStatus = meeting.displayStatus ?? meeting.status; + const failedByLatestAttempt = hasLatestGenerationFailure(meeting); + const effectiveStatus = failedByLatestAttempt ? 4 : (meeting.displayStatus ?? meeting.status); const statusConfig: Record = { 0: { text: "排队中", color: "#8c8c8c", bgColor: "rgba(140, 140, 140, 0.1)", icon: }, 1: { text: "识别中", color: "#1890ff", bgColor: "rgba(24, 144, 255, 0.1)", icon: }, @@ -170,8 +163,8 @@ const IntegratedStatusTag: React.FC<{ meeting: MeetingVO; progress: MeetingProgr 7: { text: "待开始", color: "#595959", bgColor: "rgba(89, 89, 89, 0.1)", icon: }, }; const config = statusConfig[effectiveStatus] || statusConfig[0]; - const percent = meeting.status === 1 || meeting.status === 2 ? progress?.percent || 0 : 0; - const isProcessing = meeting.status === 1 || meeting.status === 2; + const isProcessing = shouldTrackGenerationProgress(meeting); + const percent = isProcessing ? progress?.percent || 0 : 0; return (
void }> = ({ meeting, fetchData }) => { - const progress = useMeetingProgress(meeting, fetchData); +const TableStatusCell: React.FC<{ meeting: MeetingVO; progress: MeetingProgress | null }> = ({ meeting, progress }) => { return ; }; const MeetingCardItem: React.FC<{ item: MeetingVO; config: { text: string; color: string; bgColor: string }; + progress: MeetingProgress | null; fetchData: () => void; onOpenMeeting: (meeting: MeetingVO) => void; -}> = ({ item, config, fetchData, onOpenMeeting }) => { - const progress = useMeetingProgress(item, fetchData); - const effectiveStatus = item.displayStatus ?? item.status; - const isProcessing = item.status === 1 || item.status === 2; +}> = ({ item, config, progress, fetchData, onOpenMeeting }) => { + const effectiveStatus = hasLatestGenerationFailure(item) ? 4 : (item.displayStatus ?? item.status); + const isProcessing = shouldTrackGenerationProgress(item); const isPaused = effectiveStatus === PAUSED_DISPLAY_STATUS; const isRealtimeActive = effectiveStatus === REALTIME_ACTIVE_DISPLAY_STATUS; const isRealtimeIdle = effectiveStatus === REALTIME_IDLE_DISPLAY_STATUS; @@ -403,12 +395,15 @@ const Meetings: React.FC = () => { const [searchParams, setSearchParams] = useSearchParams(); const [loading, setLoading] = useState(false); const [data, setData] = useState([]); + const [progressMap, setProgressMap] = useState>({}); const [total, setTotal] = useState(0); const [current, setCurrent] = useState(1); const [displayMode, setDisplayMode] = useState<"card" | "list">("card"); const [size, setSize] = useState(8); const [searchTitle, setSearchTitle] = useState(""); + const [searchKeyword, setSearchKeyword] = useState(""); const [viewType, setViewType] = useState<"all" | "created" | "involved">("all"); + const [statusFilter, setStatusFilter] = useState(ALL_STATUS_FILTER); const [createDrawerVisible, setCreateDrawerVisible] = useState(false); const [createDrawerType, setCreateDrawerType] = useState("upload"); const [configLoaded, setConfigLoaded] = useState(false); @@ -418,8 +413,9 @@ const Meetings: React.FC = () => { offlineAudioMaxSizeMb: 1024, }); const [userList, setUserList] = useState([]); + const progressTerminalRefreshRef = useRef>(new Map()); - const hasRunningTasks = data.some(shouldPollMeetingCard); + const activeFilterCount = (statusFilter !== ALL_STATUS_FILTER ? 1 : 0) + (searchTitle ? 1 : 0); const handleDisplayModeChange = (mode: "card" | "list") => { setDisplayMode(mode); @@ -427,6 +423,44 @@ const Meetings: React.FC = () => { setCurrent(1); }; + const handleSearch = (value?: string) => { + const nextValue = (value ?? searchKeyword).trim(); + setSearchKeyword(value ?? searchKeyword); + setSearchTitle(nextValue); + setCurrent(1); + }; + + const handleResetFilters = () => { + setSearchKeyword(""); + setSearchTitle(""); + setStatusFilter(ALL_STATUS_FILTER); + setCurrent(1); + }; + + const loadBatchProgress = async (meetings: MeetingVO[]) => { + const trackedIds = meetings.filter(shouldTrackGenerationProgress).map((item) => item.id); + if (trackedIds.length === 0) { + progressTerminalRefreshRef.current.clear(); + setProgressMap({}); + return {} as Record; + } + try { + const progressRes = await getMeetingProgressBatch(trackedIds, { suppressErrorToast: true }); + const nextProgressMap = progressRes.data?.data || {}; + const activeIds = new Set(trackedIds); + progressTerminalRefreshRef.current.forEach((_, id) => { + if (!activeIds.has(id)) { + progressTerminalRefreshRef.current.delete(id); + } + }); + setProgressMap(nextProgressMap); + return nextProgressMap; + } catch { + setProgressMap({}); + return {} as Record; + } + }; + useEffect(() => { const action = searchParams.get("action"); const type = searchParams.get("type") as MeetingCreateType; @@ -439,15 +473,105 @@ const Meetings: React.FC = () => { useEffect(() => { void fetchData(); - }, [current, size, searchTitle, viewType]); + }, [current, size, searchTitle, viewType, statusFilter]); useEffect(() => { - if (!hasRunningTasks) { + const trackedMeetings = data.filter(shouldTrackGenerationProgress); + if (trackedMeetings.length === 0) { + progressTerminalRefreshRef.current.clear(); + setProgressMap({}); return; } - const timer = setInterval(() => void fetchData(true), 5000); - return () => clearInterval(timer); - }, [hasRunningTasks, current, size, searchTitle, viewType]); + + let cancelled = false; + let timer: ReturnType | null = null; + let requesting = false; + + const poll = async () => { + if (cancelled || requesting) { + return; + } + requesting = true; + try { + const nextProgressMap = await loadBatchProgress(trackedMeetings); + if (cancelled) { + return; + } + let shouldRefresh = false; + for (const meeting of trackedMeetings) { + const progress = nextProgressMap[meeting.id]; + if (!isTerminalMeetingProgress(progress)) { + progressTerminalRefreshRef.current.delete(meeting.id); + continue; + } + const terminalKey = `${progress.updateAt}:${progress.percent}`; + if (progressTerminalRefreshRef.current.get(meeting.id) !== terminalKey) { + progressTerminalRefreshRef.current.set(meeting.id, terminalKey); + shouldRefresh = true; + } + } + if (shouldRefresh) { + await fetchData(true); + } + } finally { + requesting = false; + if (!cancelled) { + timer = setTimeout(() => { + void poll(); + }, 3000); + } + } + }; + + timer = setTimeout(() => { + void poll(); + }, 3000); + return () => { + cancelled = true; + if (timer) { + clearTimeout(timer); + } + }; + }, [data, current, size, searchTitle, viewType, statusFilter]); + + useEffect(() => { + const hasRealtimeSessionsToPoll = data.some( + (item) => item.realtimeSessionStatus === "ACTIVE" || isPausedRealtimeSessionStatus(item.realtimeSessionStatus), + ); + if (!hasRealtimeSessionsToPoll) { + return; + } + let cancelled = false; + let timer: ReturnType | null = null; + let requesting = false; + + const poll = async () => { + if (cancelled || requesting) { + return; + } + requesting = true; + try { + await fetchData(true); + } finally { + requesting = false; + if (!cancelled) { + timer = setTimeout(() => { + void poll(); + }, 5000); + } + } + }; + + timer = setTimeout(() => { + void poll(); + }, 5000); + return () => { + cancelled = true; + if (timer) { + clearTimeout(timer); + } + }; + }, [data, current, size, searchTitle, viewType, statusFilter]); useEffect(() => { listUsers().then((users) => setUserList(users || [])).catch(() => setUserList([])); @@ -467,7 +591,13 @@ const Meetings: React.FC = () => { setLoading(true); } try { - const res = await getMeetingPage({ current, size, title: searchTitle, viewType }); + const res = await getMeetingPage({ + current, + size, + title: searchTitle, + viewType, + status: statusFilter === ALL_STATUS_FILTER ? undefined : Number(statusFilter), + }); const records = res.data?.data?.records || []; let statusMap: Record = {}; const realtimeCandidates = records.filter(isRealtimeMeetingCandidate); @@ -477,8 +607,10 @@ const Meetings: React.FC = () => { statusMap = sessionRes.data?.data || {}; } catch {} } - setData(records.map((item) => applyRealtimeSessionStatus(item, statusMap[item.id]))); + const nextData = records.map((item) => applyRealtimeSessionStatus(item, statusMap[item.id])); + setData(nextData); setTotal(res.data?.data?.total || 0); + await loadBatchProgress(nextData); } finally { if (!silent) { setLoading(false); @@ -525,7 +657,7 @@ const Meetings: React.FC = () => { title: "状态", key: "status", width: 150, - render: (_: unknown, record: MeetingVO) => void fetchData()} />, + render: (_: unknown, record: MeetingVO) => , }, { title: "会议时间", @@ -611,20 +743,70 @@ const Meetings: React.FC = () => { } toolbar={ - <> + { setViewType(e.target.value); setCurrent(1); }} buttonStyle="solid"> 全部 我发起 我参与 - } - allowClear - onPressEnter={(e) => { setSearchTitle((e.target as HTMLInputElement).value); setCurrent(1); }} - style={{ width: 200 }} - /> - + + 0 ? "processing" : "default"} + style={{ margin: 0, borderRadius: 999, paddingInline: 10, lineHeight: "24px" }} + > + + + {activeFilterCount > 0 ? `已筛选 ${activeFilterCount} 项` : "未筛选"} + + +