diff --git a/backend/src/main/java/com/imeeting/controller/biz/MeetingPublicPreviewController.java b/backend/src/main/java/com/imeeting/controller/biz/MeetingPublicPreviewController.java index 439cae9..bdc104f 100644 --- a/backend/src/main/java/com/imeeting/controller/biz/MeetingPublicPreviewController.java +++ b/backend/src/main/java/com/imeeting/controller/biz/MeetingPublicPreviewController.java @@ -53,7 +53,7 @@ public class MeetingPublicPreviewController { data.getMeeting().setAccessPassword(null); } data.setTranscripts(meetingQueryService.getTranscripts(id)); - data.setChapters(meetingQueryService.getChapters(id)); + data.setChapters(meetingQueryService.getChaptersIgnoreTenant(id)); return ApiResponse.ok(data); } catch (RuntimeException ex) { return ApiResponse.error(ex.getMessage()); 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 2488f01..2d1bb72 100644 --- a/backend/src/main/java/com/imeeting/service/biz/MeetingQueryService.java +++ b/backend/src/main/java/com/imeeting/service/biz/MeetingQueryService.java @@ -22,6 +22,8 @@ public interface MeetingQueryService { List> getChapters(Long meetingId); + List> getChaptersIgnoreTenant(Long meetingId); + MeetingTranscriptSourceVO getTranscriptSource(Long meetingId); MeetingSummaryPromptContextVO buildSummaryPromptContext(Long meetingId, MeetingSummaryPromptContextRequestDTO request); 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 3187e65..9dfb3bc 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 @@ -112,6 +112,15 @@ public class MeetingQueryServiceImpl implements MeetingQueryService { return meetingTranscriptChapterService.listDisplayChapterAnalysis(meeting); } + @Override + public List> getChaptersIgnoreTenant(Long meetingId) { + Meeting meeting = meetingMapper.selectByIdIgnoreTenant(meetingId); + if (meeting == null) { + return List.of(); + } + return meetingTranscriptChapterService.listDisplayChapterAnalysis(meeting); + } + @Override public MeetingTranscriptSourceVO getTranscriptSource(Long meetingId) { return meetingTranscriptChapterService.buildTranscriptSource(meetingId); diff --git a/backend/src/main/java/com/imeeting/service/mcp/MeetingMcpToolService.java b/backend/src/main/java/com/imeeting/service/mcp/MeetingMcpToolService.java new file mode 100644 index 0000000..90f4b8b --- /dev/null +++ b/backend/src/main/java/com/imeeting/service/mcp/MeetingMcpToolService.java @@ -0,0 +1,588 @@ +package com.imeeting.service.mcp; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.imeeting.common.RedisKeys; +import com.imeeting.dto.android.legacy.LegacyMeetingAttendeeResponse; +import com.imeeting.dto.android.legacy.LegacyMeetingItemResponse; +import com.imeeting.dto.android.legacy.LegacyMeetingListResponse; +import com.imeeting.dto.android.legacy.LegacyMeetingPreviewDataResponse; +import com.imeeting.dto.android.legacy.LegacyMeetingPreviewResult; +import com.imeeting.dto.android.legacy.LegacyMeetingProcessingStatusResponse; +import com.imeeting.dto.android.legacy.LegacyMeetingTagResponse; +import com.imeeting.dto.biz.MeetingTranscriptSourceVO; +import com.imeeting.dto.biz.MeetingVO; +import com.imeeting.entity.biz.AiTask; +import com.imeeting.entity.biz.Meeting; +import com.imeeting.entity.biz.PromptTemplate; +import com.imeeting.service.biz.AiTaskService; +import com.imeeting.service.biz.MeetingAccessService; +import com.imeeting.service.biz.MeetingQueryService; +import com.imeeting.service.biz.MeetingService; +import com.imeeting.service.biz.MeetingTranscriptChapterService; +import com.imeeting.service.biz.MeetingTranscriptFileService; +import com.imeeting.service.biz.PromptTemplateService; +import com.unisbase.dto.PageResult; +import com.unisbase.entity.SysUser; +import com.unisbase.mapper.SysUserMapper; +import com.unisbase.security.LoginUser; +import lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.stereotype.Service; + +import java.time.LocalDateTime; +import java.util.Arrays; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.stream.Collectors; + +@Service +@RequiredArgsConstructor +public class MeetingMcpToolService { + + private static final String STAGE_DATA_INITIALIZATION = "data_initialization"; + private static final String STAGE_AUDIO_TRANSCRIPTION = "audio_transcription"; + private static final String STAGE_SUMMARY_GENERATION = "summary_generation"; + private static final String STAGE_COMPLETED = "completed"; + + private final MeetingQueryService meetingQueryService; + private final MeetingAccessService meetingAccessService; + private final MeetingService meetingService; + private final AiTaskService aiTaskService; + private final PromptTemplateService promptTemplateService; + private final MeetingTranscriptFileService meetingTranscriptFileService; + private final MeetingTranscriptChapterService meetingTranscriptChapterService; + private final SysUserMapper sysUserMapper; + private final StringRedisTemplate redisTemplate; + private final ObjectMapper objectMapper; + + @Value("${unisbase.app.server-base-url:}") + private String serverBaseUrl; + + public LegacyMeetingListResponse listCurrentUserMeetings(Integer page, Integer pageSize, String title) { + LoginUser loginUser = currentLoginUser(); + int normalizedPage = normalizePositive(page, 1); + int normalizedPageSize = normalizePositive(pageSize, 10); + boolean isAdmin = Boolean.TRUE.equals(loginUser.getIsPlatformAdmin()) + || Boolean.TRUE.equals(loginUser.getIsTenantAdmin()); + + PageResult> result = meetingQueryService.pageMeetings( + normalizedPage, + normalizedPageSize, + normalizeOptionalText(title), + loginUser.getTenantId(), + loginUser.getUserId(), + resolveCreatorName(loginUser), + "all", + isAdmin + ); + + LegacyMeetingListResponse data = new LegacyMeetingListResponse(); + data.setPage(normalizedPage); + data.setPageSize(normalizedPageSize); + data.setTotal(result == null ? 0L : result.getTotal()); + data.setTotalPages(normalizedPageSize <= 0 ? 0 : (data.getTotal() + normalizedPageSize - 1) / normalizedPageSize); + data.setHasMore(normalizedPage < data.getTotalPages()); + data.setMeetings(result == null || result.getRecords() == null + ? List.of() + : result.getRecords().stream().map(this::buildListItem).toList()); + return data; + } + + public LegacyMeetingPreviewResult getMeetingPreview(Long meetingId) { + if (meetingId == null) { + throw new IllegalArgumentException("meetingId is required"); + } + LoginUser loginUser = currentLoginUser(); + Meeting meeting = meetingAccessService.requireMeeting(meetingId); + meetingAccessService.assertCanViewMeeting(meeting, loginUser); + return buildPreviewResult(meeting); + } + + public Map getMeetingRichDetail(Long meetingId) { + if (meetingId == null) { + throw new IllegalArgumentException("meetingId is required"); + } + LoginUser loginUser = currentLoginUser(); + Meeting meeting = meetingAccessService.requireMeeting(meetingId); + meetingAccessService.assertCanViewMeeting(meeting, loginUser); + + MeetingVO detail = meetingQueryService.getDetail(meetingId); + normalizeMeetingAudioUrls(detail); + LegacyMeetingPreviewResult preview = buildPreviewResult(meeting); + Map previewData = preview.getData() == null + ? new LinkedHashMap<>() + : objectMapper.convertValue(preview.getData(), new TypeReference>() {}); + List> chapters = meetingQueryService.getChapters(meetingId); + MeetingTranscriptSourceVO transcriptSource = meetingQueryService.getTranscriptSource(meetingId); + Map analysis = detail == null || detail.getAnalysis() == null ? Map.of() : detail.getAnalysis(); + + previewData.put("previewCode", preview.getCode()); + previewData.put("previewMessage", preview.getMessage()); + previewData.put("meetingDetail", detail); + previewData.put("keywords", normalizeStringList(analysis.get("keywords"))); + previewData.put("analysis", analysis); + previewData.put("chapters", chapters); + previewData.put("transcriptSource", transcriptSource); + previewData.put("audioUrl", detail == null ? toAbsoluteUrl(meeting.getAudioUrl()) : detail.getAudioUrl()); + previewData.put("playbackAudioUrl", detail == null ? null : detail.getPlaybackAudioUrl()); + previewData.put("hasRawTranscript", transcriptSource != null + && transcriptSource.getTranscriptText() != null + && !transcriptSource.getTranscriptText().isBlank()); + return previewData; + } + + public Map getMeetingMarkdownBundle(Long meetingId) { + if (meetingId == null) { + throw new IllegalArgumentException("meetingId is required"); + } + LoginUser loginUser = currentLoginUser(); + Meeting meeting = meetingAccessService.requireMeeting(meetingId); + meetingAccessService.assertCanViewMeeting(meeting, loginUser); + + MeetingVO detail = meetingQueryService.getDetail(meetingId); + Map result = new LinkedHashMap<>(); + result.put("meetingId", meetingId); + result.put("summaryMarkdown", detail == null ? null : detail.getSummaryContent()); + result.put("transcriptMarkdown", meetingTranscriptFileService.loadTranscriptMarkdown(meeting, detail)); + result.put("chapterMarkdown", meetingTranscriptChapterService.loadCurrentChapterMarkdown(meeting)); + return result; + } + + private LegacyMeetingPreviewResult buildPreviewResult(Meeting meeting) { + if (meeting == null) { + return new LegacyMeetingPreviewResult("404", "会议不存在", null); + } + + Long meetingId = meeting.getId(); + AiTask asrTask = findLatestTask(meetingId, "ASR"); + AiTask summaryTask = findLatestTask(meetingId, "SUMMARY"); + boolean summaryCompleted = summaryTask != null && Integer.valueOf(2).equals(summaryTask.getStatus()); + MeetingVO detail = (Integer.valueOf(3).equals(meeting.getStatus()) || summaryCompleted) + ? meetingQueryService.getDetail(meetingId) + : null; + boolean hasSummary = detail != null && detail.getSummaryContent() != null && !detail.getSummaryContent().isBlank(); + + if (hasSummary) { + return new LegacyMeetingPreviewResult("200", "success", buildCompletedPreview(meeting, detail, summaryTask)); + } + if (summaryCompleted) { + return new LegacyMeetingPreviewResult("200", "success", buildCompletedPreview(meeting, detail, summaryTask)); + } + if (isFailed(asrTask) || Integer.valueOf(4).equals(meeting.getStatus())) { + return new LegacyMeetingPreviewResult( + "503", + buildFailureMessage(asrTask, "转译"), + buildProcessingPreview(meeting, summaryTask, processingStatus("转译或总结失败", 50, STAGE_AUDIO_TRANSCRIPTION)) + ); + } + if (isFailed(summaryTask)) { + return new LegacyMeetingPreviewResult( + "503", + buildFailureMessage(summaryTask, "总结"), + buildProcessingPreview(meeting, summaryTask, processingStatus("转译或总结失败", 75, STAGE_SUMMARY_GENERATION)) + ); + } + + Integer realtimeProgress = resolveRealtimeProgress(meetingId); + if (realtimeProgress != null) { + if (realtimeProgress >= 100) { + MeetingVO completedDetail = detail != null ? detail : meetingQueryService.getDetail(meetingId); + boolean completedHasSummary = completedDetail != null + && completedDetail.getSummaryContent() != null + && !completedDetail.getSummaryContent().isBlank(); + if (completedHasSummary) { + return new LegacyMeetingPreviewResult("200", "success", buildCompletedPreview(meeting, completedDetail, summaryTask)); + } + return new LegacyMeetingPreviewResult( + "504", + "处理已完成,但摘要尚未同步,请稍后重试", + buildProcessingPreview(meeting, summaryTask, processingStatus("摘要已生成,可扫码查看", 100, STAGE_COMPLETED)) + ); + } + if (realtimeProgress < 90) { + return new LegacyMeetingPreviewResult( + "400", + "会议正在处理中", + buildProcessingPreview(meeting, summaryTask, processingStatus("正在转译音频", 50, STAGE_AUDIO_TRANSCRIPTION)) + ); + } + return new LegacyMeetingPreviewResult( + "400", + "会议正在处理中", + buildProcessingPreview(meeting, summaryTask, processingStatus("正在生成总结", 75, STAGE_SUMMARY_GENERATION)) + ); + } + + boolean isSummaryStage = isSummaryStage(meeting.getStatus(), summaryTask); + boolean isAsrStage = isAsrStage(meeting.getStatus(), asrTask, hasAudio(meeting), isSummaryStage); + + if (!isAsrStage && !isSummaryStage) { + return new LegacyMeetingPreviewResult( + "400", + "会议正在处理中", + buildProcessingPreview(meeting, summaryTask, processingStatus("会议数据准备中", 25, STAGE_DATA_INITIALIZATION)) + ); + } + if (!isSummaryStage) { + return new LegacyMeetingPreviewResult( + "400", + "会议正在处理中", + buildProcessingPreview(meeting, summaryTask, processingStatus("正在转译音频", 50, STAGE_AUDIO_TRANSCRIPTION)) + ); + } + return new LegacyMeetingPreviewResult( + "400", + "会议正在处理中", + buildProcessingPreview(meeting, summaryTask, processingStatus("正在生成总结", 75, STAGE_SUMMARY_GENERATION)) + ); + } + + private LegacyMeetingPreviewDataResponse buildCompletedPreview(Meeting meeting, MeetingVO detail, AiTask summaryTask) { + LegacyMeetingPreviewDataResponse data = new LegacyMeetingPreviewDataResponse(); + data.setMeetingId(meeting.getId()); + data.setTitle(meeting.getTitle()); + data.setMeetingTime(formatDateTime(meeting.getMeetingTime())); + data.setSummary(detail == null ? null : detail.getSummaryContent()); + data.setCreatorUsername(resolveCreatorDisplayName(meeting.getCreatorId(), meeting.getCreatorName())); + Long promptId = resolvePromptId(summaryTask); + data.setPromptId(promptId); + data.setPromptName(resolvePromptName(promptId)); + List attendees = buildAttendees(meeting.getParticipants()); + data.setAttendees(attendees); + data.setAttendeesCount(attendees.size()); + data.setHasPassword(meeting.getAccessPassword() != null && !meeting.getAccessPassword().isBlank()); + data.setProcessingStatus(processingStatus("摘要已生成,可扫码查看", 100, STAGE_COMPLETED)); + return data; + } + + private LegacyMeetingPreviewDataResponse buildProcessingPreview(Meeting meeting, + AiTask summaryTask, + LegacyMeetingProcessingStatusResponse status) { + LegacyMeetingPreviewDataResponse data = new LegacyMeetingPreviewDataResponse(); + data.setMeetingId(meeting.getId()); + data.setTitle(meeting.getTitle()); + data.setMeetingTime(formatDateTime(meeting.getMeetingTime())); + data.setCreatorUsername(resolveCreatorDisplayName(meeting.getCreatorId(), meeting.getCreatorName())); + Long promptId = resolvePromptId(summaryTask); + data.setPromptId(promptId); + data.setPromptName(resolvePromptName(promptId)); + data.setHasPassword(meeting.getAccessPassword() != null && !meeting.getAccessPassword().isBlank()); + data.setProcessingStatus(status); + return data; + } + + private LegacyMeetingItemResponse buildListItem(MeetingVO meeting) { + LegacyMeetingItemResponse item = new LegacyMeetingItemResponse(); + item.setMeetingId(meeting.getId()); + item.setTitle(meeting.getTitle()); + item.setMeetingTime(formatDateTime(meeting.getMeetingTime())); + item.setCreatedAt(formatDateTime(meeting.getCreatedAt())); + item.setCreatorId(meeting.getCreatorId()); + item.setCreatorUsername(resolveCreatorDisplayName(meeting.getCreatorId(), meeting.getCreatorName())); + item.setAudioFilePath(toAbsoluteUrl(meeting.getAudioUrl())); + item.setAudioDuration(meeting.getDuration()); + item.setAccessPassword(resolveAccessPassword(meeting.getId())); + + List attendeeIds = meeting.getParticipantIds() == null ? List.of() : meeting.getParticipantIds(); + item.setAttendeeIds(attendeeIds); + item.setAttendees(buildAttendees(attendeeIds)); + item.setTags(buildTags(meeting.getTags())); + item.setSummary(resolveListSummary(meeting.getId())); + + LegacyMeetingProcessingStatusResponse status = buildListStatus(meeting); + item.setOverallStatus(status.getOverallStatus()); + item.setOverallProgress(status.getOverallProgress()); + item.setCurrentStage(translateListStage(status.getCurrentStage())); + return item; + } + + private LegacyMeetingProcessingStatusResponse buildListStatus(MeetingVO meeting) { + Long meetingId = meeting.getId(); + AiTask asrTask = findLatestTask(meetingId, "ASR"); + AiTask summaryTask = findLatestTask(meetingId, "SUMMARY"); + boolean summaryCompleted = summaryTask != null && Integer.valueOf(2).equals(summaryTask.getStatus()); + + if (Integer.valueOf(3).equals(meeting.getStatus()) || summaryCompleted) { + return new LegacyMeetingProcessingStatusResponse("completed", 100, STAGE_COMPLETED); + } + if (isFailed(asrTask) || Integer.valueOf(4).equals(meeting.getStatus())) { + return new LegacyMeetingProcessingStatusResponse("failed", 50, STAGE_AUDIO_TRANSCRIPTION); + } + if (isFailed(summaryTask)) { + return new LegacyMeetingProcessingStatusResponse("failed", 75, STAGE_SUMMARY_GENERATION); + } + + boolean isSummaryStage = isSummaryStage(meeting.getStatus(), summaryTask); + boolean isAsrStage = isAsrStage(meeting.getStatus(), asrTask, hasAudio(meeting), isSummaryStage); + + if (!isAsrStage && !isSummaryStage) { + return new LegacyMeetingProcessingStatusResponse("pending", 0, STAGE_DATA_INITIALIZATION); + } + if (isSummaryStage) { + return new LegacyMeetingProcessingStatusResponse("summarizing", 75, STAGE_SUMMARY_GENERATION); + } + return new LegacyMeetingProcessingStatusResponse("transcribing", 50, STAGE_AUDIO_TRANSCRIPTION); + } + + private String buildFailureMessage(AiTask failedTask, String stageName) { + String error = failedTask == null || failedTask.getErrorMsg() == null || failedTask.getErrorMsg().isBlank() + ? "处理失败" + : failedTask.getErrorMsg(); + return "会议" + stageName + "失败: " + error; + } + + private boolean isRunningAsr(AiTask task) { + return task != null && (Integer.valueOf(0).equals(task.getStatus()) || Integer.valueOf(1).equals(task.getStatus())); + } + + private boolean isRunningSummary(AiTask task) { + return task != null && (Integer.valueOf(0).equals(task.getStatus()) || Integer.valueOf(1).equals(task.getStatus())); + } + + private boolean isFailed(AiTask task) { + return task != null && Integer.valueOf(3).equals(task.getStatus()); + } + + private AiTask findLatestTask(Long meetingId, String taskType) { + return aiTaskService.getOne(new LambdaQueryWrapper() + .eq(AiTask::getMeetingId, meetingId) + .eq(AiTask::getTaskType, taskType) + .orderByDesc(AiTask::getId) + .last("LIMIT 1")); + } + + private Long resolvePromptId(AiTask summaryTask) { + if (summaryTask == null || summaryTask.getTaskConfig() == null) { + return null; + } + Object rawPromptId = summaryTask.getTaskConfig().get("promptId"); + if (rawPromptId == null) { + return null; + } + if (rawPromptId instanceof Number number) { + return number.longValue(); + } + String value = String.valueOf(rawPromptId).trim(); + if (value.isEmpty()) { + return null; + } + try { + return Long.parseLong(value); + } catch (NumberFormatException ignored) { + return null; + } + } + + private String resolvePromptName(Long promptId) { + if (promptId == null) { + return null; + } + PromptTemplate template = promptTemplateService.getById(promptId); + return template == null ? null : template.getTemplateName(); + } + + private List buildAttendees(String participants) { + return buildAttendees(parseParticipantIds(participants)); + } + + private List buildAttendees(List participantIds) { + if (participantIds == null || participantIds.isEmpty()) { + return List.of(); + } + Map userMap = sysUserMapper.selectBatchIds(participantIds).stream() + .collect(Collectors.toMap(SysUser::getUserId, user -> user, (left, right) -> left, LinkedHashMap::new)); + + return participantIds.stream() + .map(userId -> { + SysUser user = userMap.get(userId); + String caption = user == null + ? String.valueOf(userId) + : (user.getDisplayName() != null ? user.getDisplayName() : user.getUsername()); + String username = user == null ? null : user.getUsername(); + return new LegacyMeetingAttendeeResponse(userId, username, caption); + }) + .toList(); + } + + private List buildTags(String rawTags) { + if (rawTags == null || rawTags.isBlank()) { + return List.of(); + } + return Arrays.stream(rawTags.split(",")) + .map(String::trim) + .filter(value -> !value.isEmpty()) + .map(value -> new LegacyMeetingTagResponse(null, value)) + .toList(); + } + + private List parseParticipantIds(String participants) { + if (participants == null || participants.isBlank()) { + return List.of(); + } + return Arrays.stream(participants.split(",")) + .map(String::trim) + .filter(value -> !value.isEmpty()) + .map(value -> { + try { + return Long.parseLong(value); + } catch (NumberFormatException ignored) { + return null; + } + }) + .filter(Objects::nonNull) + .toList(); + } + + private String resolveListSummary(Long meetingId) { + MeetingVO detail = meetingQueryService.getDetail(meetingId); + if (detail == null || detail.getSummaryContent() == null || detail.getSummaryContent().isBlank()) { + return null; + } + String summary = detail.getSummaryContent().trim(); + return summary.length() <= 240 ? summary : summary.substring(0, 240); + } + + private String resolveAccessPassword(Long meetingId) { + Meeting meeting = meetingService.getById(meetingId); + return meeting == null ? null : normalizeOptionalText(meeting.getAccessPassword()); + } + + private String resolveCreatorDisplayName(Long creatorId, String fallbackName) { + if (creatorId == null) { + return fallbackName; + } + SysUser creator = sysUserMapper.selectById(creatorId); + if (creator == null) { + return fallbackName; + } + if (creator.getDisplayName() != null && !creator.getDisplayName().isBlank()) { + return creator.getDisplayName(); + } + if (creator.getUsername() != null && !creator.getUsername().isBlank()) { + return creator.getUsername(); + } + return fallbackName; + } + + private void normalizeMeetingAudioUrls(MeetingVO meeting) { + if (meeting == null) { + return; + } + meeting.setAudioUrl(toAbsoluteUrl(meeting.getAudioUrl())); + meeting.setPlaybackAudioUrl(toAbsoluteUrl(meeting.getPlaybackAudioUrl())); + } + + private String toAbsoluteUrl(String url) { + if (url == null || url.isBlank()) { + return url; + } + String trimmedUrl = url.trim(); + if (trimmedUrl.matches("^[a-zA-Z][a-zA-Z\\d+\\-.]*://.*$") || trimmedUrl.startsWith("//")) { + return trimmedUrl; + } + if (serverBaseUrl == null || serverBaseUrl.isBlank()) { + return trimmedUrl; + } + String base = serverBaseUrl.trim(); + if (base.endsWith("/") && trimmedUrl.startsWith("/")) { + return base.substring(0, base.length() - 1) + trimmedUrl; + } + if (!base.endsWith("/") && !trimmedUrl.startsWith("/")) { + return base + "/" + trimmedUrl; + } + return base + trimmedUrl; + } + + private boolean hasAudio(Meeting meeting) { + return meeting.getAudioUrl() != null && !meeting.getAudioUrl().isBlank(); + } + + private boolean hasAudio(MeetingVO meeting) { + return meeting.getAudioUrl() != null && !meeting.getAudioUrl().isBlank(); + } + + private boolean isSummaryStage(Integer meetingStatus, AiTask summaryTask) { + return Integer.valueOf(2).equals(meetingStatus) || isRunningSummary(summaryTask); + } + + private boolean isAsrStage(Integer meetingStatus, AiTask asrTask, boolean hasAudio, boolean isSummaryStage) { + return Integer.valueOf(1).equals(meetingStatus) + || isRunningAsr(asrTask) + || (hasAudio && !isSummaryStage); + } + + private Integer resolveRealtimeProgress(Long meetingId) { + String rawProgress = redisTemplate.opsForValue().get(RedisKeys.meetingProgressKey(meetingId)); + if (rawProgress == null || rawProgress.isBlank()) { + return null; + } + try { + JsonNode progress = objectMapper.readTree(rawProgress); + return progress.hasNonNull("percent") ? progress.path("percent").asInt() : null; + } catch (Exception ignored) { + return null; + } + } + + private LegacyMeetingProcessingStatusResponse processingStatus(String overallStatus, int overallProgress, String currentStage) { + return new LegacyMeetingProcessingStatusResponse(overallStatus, overallProgress, currentStage); + } + + private String formatDateTime(LocalDateTime value) { + return value == null ? null : value.toString(); + } + + private String translateListStage(String stage) { + if (STAGE_SUMMARY_GENERATION.equals(stage)) { + return "llm"; + } + if (STAGE_COMPLETED.equals(stage)) { + return "completed"; + } + return "transcription"; + } + + private LoginUser currentLoginUser() { + Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); + if (authentication == null || !(authentication.getPrincipal() instanceof LoginUser loginUser)) { + throw new IllegalStateException("MCP login user is required"); + } + return loginUser; + } + + private String resolveCreatorName(LoginUser loginUser) { + return loginUser.getDisplayName() != null ? loginUser.getDisplayName() : loginUser.getUsername(); + } + + private List normalizeStringList(Object value) { + if (!(value instanceof List list)) { + return List.of(); + } + return list.stream() + .filter(Objects::nonNull) + .map(String::valueOf) + .map(String::trim) + .filter(item -> !item.isEmpty()) + .toList(); + } + + private int normalizePositive(Integer value, int defaultValue) { + return value == null || value <= 0 ? defaultValue : value; + } + + private String normalizeOptionalText(String value) { + if (value == null) { + return null; + } + String normalized = value.trim(); + return normalized.isEmpty() ? null : normalized; + } +}