From 6d46998abe65aa2c72729b4c0aadf7494b149b12 Mon Sep 17 00:00:00 2001 From: chenhao Date: Wed, 15 Apr 2026 15:21:00 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=A2=9E=E5=BC=BA=E6=97=A7=E7=89=88?= =?UTF-8?q?=E4=BC=9A=E8=AE=AE=E6=8E=A7=E5=88=B6=E5=99=A8=E5=8A=9F=E8=83=BD?= =?UTF-8?q?=E5=92=8C=E7=9B=B8=E5=85=B3=E6=B5=8B=E8=AF=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 添加 `StringRedisTemplate` 和 `ObjectMapper` 依赖 - 更新 `buildListItem` 方法以构建会议列表项 - 增加实时进度解析和处理逻辑 - 优化会议预览数据响应构建 - 添加并更新相关单元测试以验证新功能的正确性 --- .../legacy/LegacyMeetingController.java | 268 +++++++++- .../legacy/LegacyExternalAppItemResponse.java | 42 +- .../legacy/LegacyMeetingAttendeeResponse.java | 2 + .../legacy/LegacyMeetingItemResponse.java | 45 +- .../legacy/LegacyMeetingControllerTest.java | 493 +++++++++++++++++- .../LegacyCatalogAdapterServiceImplTest.java | 43 ++ frontend/src/routes/index.tsx | 9 + 7 files changed, 862 insertions(+), 40 deletions(-) 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 6ee74ff..3bd3016 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 @@ -1,6 +1,9 @@ package com.imeeting.controller.android.legacy; import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.imeeting.common.RedisKeys; import com.imeeting.dto.android.legacy.LegacyApiResponse; import com.imeeting.dto.android.legacy.LegacyMeetingAccessPasswordRequest; import com.imeeting.dto.android.legacy.LegacyMeetingAccessPasswordResponse; @@ -12,10 +15,11 @@ 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.MeetingVO; import com.imeeting.entity.biz.AiTask; import com.imeeting.entity.biz.Meeting; -import com.imeeting.entity.biz.MeetingTranscript; +import com.imeeting.mapper.biz.MeetingTranscriptMapper; import com.imeeting.entity.biz.PromptTemplate; import com.imeeting.service.android.legacy.LegacyMeetingAdapterService; import com.imeeting.service.biz.AiTaskService; @@ -24,12 +28,12 @@ import com.imeeting.service.biz.MeetingCommandService; import com.imeeting.service.biz.MeetingQueryService; import com.imeeting.service.biz.MeetingService; import com.imeeting.service.biz.PromptTemplateService; -import com.imeeting.mapper.biz.MeetingTranscriptMapper; 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.Autowired; +import org.springframework.data.redis.core.StringRedisTemplate; import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.web.bind.annotation.DeleteMapping; @@ -44,6 +48,7 @@ import org.springframework.web.bind.annotation.RestController; import org.springframework.web.multipart.MultipartFile; import java.io.IOException; +import java.time.LocalDateTime; import java.util.Arrays; import java.util.LinkedHashMap; import java.util.List; @@ -53,7 +58,7 @@ import java.util.stream.Collectors; @RestController @RequestMapping("/api/meetings") -@RequiredArgsConstructor + public class LegacyMeetingController { private static final String STAGE_DATA_INITIALIZATION = "data_initialization"; @@ -70,6 +75,55 @@ public class LegacyMeetingController { private final PromptTemplateService promptTemplateService; private final MeetingTranscriptMapper meetingTranscriptMapper; private final SysUserMapper sysUserMapper; + private final StringRedisTemplate redisTemplate; + private final ObjectMapper objectMapper; + + public LegacyMeetingController(LegacyMeetingAdapterService legacyMeetingAdapterService, + MeetingQueryService meetingQueryService, + MeetingAccessService meetingAccessService, + MeetingCommandService meetingCommandService, + MeetingService meetingService, + AiTaskService aiTaskService, + PromptTemplateService promptTemplateService, + MeetingTranscriptMapper meetingTranscriptMapper, + SysUserMapper sysUserMapper) { + this(legacyMeetingAdapterService, + meetingQueryService, + meetingAccessService, + meetingCommandService, + meetingService, + aiTaskService, + promptTemplateService, + meetingTranscriptMapper, + sysUserMapper, + null, + new ObjectMapper()); + } + + @Autowired + public LegacyMeetingController(LegacyMeetingAdapterService legacyMeetingAdapterService, + MeetingQueryService meetingQueryService, + MeetingAccessService meetingAccessService, + MeetingCommandService meetingCommandService, + MeetingService meetingService, + AiTaskService aiTaskService, + PromptTemplateService promptTemplateService, + MeetingTranscriptMapper meetingTranscriptMapper, + SysUserMapper sysUserMapper, + StringRedisTemplate redisTemplate, + ObjectMapper objectMapper) { + this.legacyMeetingAdapterService = legacyMeetingAdapterService; + this.meetingQueryService = meetingQueryService; + this.meetingAccessService = meetingAccessService; + this.meetingCommandService = meetingCommandService; + this.meetingService = meetingService; + this.aiTaskService = aiTaskService; + this.promptTemplateService = promptTemplateService; + this.meetingTranscriptMapper = meetingTranscriptMapper; + this.sysUserMapper = sysUserMapper; + this.redisTemplate = redisTemplate; + this.objectMapper = objectMapper; + } @PostMapping @PreAuthorize("isAuthenticated()") @@ -123,7 +177,7 @@ public class LegacyMeetingController { data.setHasMore(page != null && page < data.getTotalPages()); data.setMeetings(result.getRecords() == null ? List.of() - : result.getRecords().stream().map(LegacyMeetingItemResponse::from).toList()); + : result.getRecords().stream().map(this::buildListItem).toList()); return LegacyApiResponse.ok(data); } @@ -182,6 +236,13 @@ public class LegacyMeetingController { buildProcessingPreview(meeting, summaryTask, processingStatus("摘要已生成,可扫码查看", 100, STAGE_COMPLETED)) ); } + 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", @@ -189,24 +250,36 @@ public class LegacyMeetingController { buildProcessingPreview(meeting, summaryTask, processingStatus("转译或总结失败", 75, STAGE_SUMMARY_GENERATION)) ); } - if (isFailed(asrTask) || Integer.valueOf(4).equals(meeting.getStatus())) { - return new LegacyMeetingPreviewResult( - "503", - buildFailureMessage(asrTask, "转译"), - buildProcessingPreview(meeting, summaryTask, processingStatus("转译或总结失败", 45, STAGE_AUDIO_TRANSCRIPTION)) - ); + + Integer realtimeProgress = resolveRealtimeProgress(meetingId); + if (realtimeProgress != null) { + if (realtimeProgress < 90) { + return new LegacyMeetingPreviewResult( + "400", + "浼氳姝e湪澶勭悊涓?", + buildProcessingPreview(meeting, summaryTask, processingStatus("姝e湪杞瘧闊抽", 50, STAGE_AUDIO_TRANSCRIPTION)) + ); + } + if (realtimeProgress == 90) { + return new LegacyMeetingPreviewResult( + "400", + "浼氳姝e湪澶勭悊涓?", + buildProcessingPreview(meeting, summaryTask, processingStatus("姝e湪鐢熸垚鎬荤粨", 75, STAGE_SUMMARY_GENERATION)) + ); + } } - long transcriptCount = meetingTranscriptMapper.selectCount(new LambdaQueryWrapper() - .eq(MeetingTranscript::getMeetingId, meetingId)); - if (isRunningSummary(summaryTask) || transcriptCount > 0 || Integer.valueOf(2).equals(meeting.getStatus())) { + 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("正在生成总结", 75, STAGE_SUMMARY_GENERATION)) + buildProcessingPreview(meeting, summaryTask, processingStatus("会议数据准备中", 25, STAGE_DATA_INITIALIZATION)) ); } - if (isRunningAsr(asrTask) || (meeting.getAudioUrl() != null && !meeting.getAudioUrl().isBlank()) || Integer.valueOf(1).equals(meeting.getStatus())) { + if (!isSummaryStage) { return new LegacyMeetingPreviewResult( "400", "会议正在处理中", @@ -216,7 +289,7 @@ public class LegacyMeetingController { return new LegacyMeetingPreviewResult( "400", "会议正在处理中", - buildProcessingPreview(meeting, summaryTask, processingStatus("会议数据准备中", 25, STAGE_DATA_INITIALIZATION)) + buildProcessingPreview(meeting, summaryTask, processingStatus("正在生成总结", 75, STAGE_SUMMARY_GENERATION)) ); } @@ -224,9 +297,9 @@ public class LegacyMeetingController { LegacyMeetingPreviewDataResponse data = new LegacyMeetingPreviewDataResponse(); data.setMeetingId(meeting.getId()); data.setTitle(meeting.getTitle()); - data.setMeetingTime(meeting.getMeetingTime() == null ? null : meeting.getMeetingTime().toString()); + data.setMeetingTime(formatDateTime(meeting.getMeetingTime())); data.setSummary(detail.getSummaryContent()); - data.setCreatorUsername(meeting.getCreatorName()); + data.setCreatorUsername(resolveCreatorDisplayName(meeting.getCreatorId(), meeting.getCreatorName())); Long promptId = resolvePromptId(summaryTask); data.setPromptId(promptId); data.setPromptName(resolvePromptName(promptId)); @@ -238,14 +311,39 @@ public class LegacyMeetingController { 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(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 LegacyMeetingPreviewDataResponse buildProcessingPreview(Meeting meeting, AiTask summaryTask, LegacyMeetingProcessingStatusResponse status) { LegacyMeetingPreviewDataResponse data = new LegacyMeetingPreviewDataResponse(); data.setMeetingId(meeting.getId()); data.setTitle(meeting.getTitle()); - data.setMeetingTime(meeting.getMeetingTime() == null ? null : meeting.getMeetingTime().toString()); - data.setCreatorUsername(meeting.getCreatorName()); + data.setMeetingTime(formatDateTime(meeting.getMeetingTime())); + data.setCreatorUsername(resolveCreatorDisplayName(meeting.getCreatorId(), meeting.getCreatorName())); Long promptId = resolvePromptId(summaryTask); data.setPromptId(promptId); data.setPromptName(resolvePromptName(promptId)); @@ -258,6 +356,50 @@ public class LegacyMeetingController { return new LegacyMeetingProcessingStatusResponse(overallStatus, overallProgress, currentStage); } + private Integer resolveRealtimeProgress(Long meetingId) { + if (redisTemplate == null) { + return null; + } + 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 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() ? "处理失败" @@ -316,8 +458,11 @@ public class LegacyMeetingController { } private List buildAttendees(String participants) { - List participantIds = parseParticipantIds(participants); - if (participantIds.isEmpty()) { + return buildAttendees(parseParticipantIds(participants)); + } + + private List buildAttendees(List participantIds) { + if (participantIds == null || participantIds.isEmpty()) { return List.of(); } Map userMap = sysUserMapper.selectBatchIds(participantIds).stream() @@ -329,11 +474,23 @@ public class LegacyMeetingController { String caption = user == null ? String.valueOf(userId) : (user.getDisplayName() != null ? user.getDisplayName() : user.getUsername()); - return new LegacyMeetingAttendeeResponse(userId, caption); + 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(); @@ -360,6 +517,69 @@ public class LegacyMeetingController { return normalized.isEmpty() ? null : normalized; } + 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 : normalizePassword(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 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 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() { return (LoginUser) SecurityContextHolder.getContext().getAuthentication().getPrincipal(); } diff --git a/backend/src/main/java/com/imeeting/dto/android/legacy/LegacyExternalAppItemResponse.java b/backend/src/main/java/com/imeeting/dto/android/legacy/LegacyExternalAppItemResponse.java index 16ecc8b..d5c7baa 100644 --- a/backend/src/main/java/com/imeeting/dto/android/legacy/LegacyExternalAppItemResponse.java +++ b/backend/src/main/java/com/imeeting/dto/android/legacy/LegacyExternalAppItemResponse.java @@ -4,7 +4,11 @@ import com.fasterxml.jackson.annotation.JsonProperty; import com.imeeting.entity.biz.ExternalApp; import lombok.Data; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; import java.util.Map; +import java.util.Locale; @Data public class LegacyExternalAppItemResponse { @@ -47,7 +51,7 @@ public class LegacyExternalAppItemResponse { response.setId(source.getId()); response.setAppName(source.getAppName()); response.setAppType(source.getAppType()); - response.setAppInfo(source.getAppInfo()); + response.setAppInfo(normalizeAppInfo(source.getAppInfo())); response.setIconUrl(source.getIconUrl()); response.setDescription(source.getDescription()); response.setSortOrder(source.getSortOrder()); @@ -58,4 +62,40 @@ public class LegacyExternalAppItemResponse { response.setCreatorUsername(creatorUsername); return response; } + + private static Map normalizeAppInfo(Map appInfo) { + if (appInfo == null || appInfo.isEmpty()) { + return appInfo; + } + Map normalized = new LinkedHashMap<>(); + appInfo.forEach((key, value) -> normalized.put(toSnakeCase(key), normalizeValue(value))); + return normalized; + } + + private static Object normalizeValue(Object value) { + if (value instanceof Map nestedMap) { + Map normalized = new LinkedHashMap<>(); + nestedMap.forEach((key, nestedValue) -> normalized.put(toSnakeCase(String.valueOf(key)), normalizeValue(nestedValue))); + return normalized; + } + if (value instanceof List list) { + List normalized = new ArrayList<>(list.size()); + list.forEach(item -> normalized.add(normalizeValue(item))); + return normalized; + } + return value; + } + + private static String toSnakeCase(String value) { + if (value == null || value.isBlank()) { + return value; + } + return value + .replace('-', '_') + .replace(' ', '_') + .replaceAll("([A-Z]+)([A-Z][a-z])", "$1_$2") + .replaceAll("([a-z0-9])([A-Z])", "$1_$2") + .replaceAll("_+", "_") + .toLowerCase(Locale.ROOT); + } } diff --git a/backend/src/main/java/com/imeeting/dto/android/legacy/LegacyMeetingAttendeeResponse.java b/backend/src/main/java/com/imeeting/dto/android/legacy/LegacyMeetingAttendeeResponse.java index 3040801..2838b61 100644 --- a/backend/src/main/java/com/imeeting/dto/android/legacy/LegacyMeetingAttendeeResponse.java +++ b/backend/src/main/java/com/imeeting/dto/android/legacy/LegacyMeetingAttendeeResponse.java @@ -12,5 +12,7 @@ public class LegacyMeetingAttendeeResponse { @JsonProperty("user_id") private Long userId; + private String username; + private String caption; } diff --git a/backend/src/main/java/com/imeeting/dto/android/legacy/LegacyMeetingItemResponse.java b/backend/src/main/java/com/imeeting/dto/android/legacy/LegacyMeetingItemResponse.java index 0e53c7d..13ad9d3 100644 --- a/backend/src/main/java/com/imeeting/dto/android/legacy/LegacyMeetingItemResponse.java +++ b/backend/src/main/java/com/imeeting/dto/android/legacy/LegacyMeetingItemResponse.java @@ -1,9 +1,10 @@ package com.imeeting.dto.android.legacy; import com.fasterxml.jackson.annotation.JsonProperty; -import com.imeeting.dto.biz.MeetingVO; import lombok.Data; +import java.util.List; + @Data public class LegacyMeetingItemResponse { @JsonProperty("meeting_id") @@ -14,11 +15,39 @@ public class LegacyMeetingItemResponse { @JsonProperty("meeting_time") private String meetingTime; - public static LegacyMeetingItemResponse from(MeetingVO source) { - LegacyMeetingItemResponse response = new LegacyMeetingItemResponse(); - response.setMeetingId(source.getId()); - response.setTitle(source.getTitle()); - response.setMeetingTime(source.getMeetingTime() == null ? null : source.getMeetingTime().toString()); - return response; - } + private String summary; + + @JsonProperty("created_at") + private String createdAt; + + @JsonProperty("creator_id") + private Long creatorId; + + @JsonProperty("creator_username") + private String creatorUsername; + + private List attendees; + + @JsonProperty("attendee_ids") + private List attendeeIds; + + private List tags; + + @JsonProperty("audio_file_path") + private String audioFilePath; + + @JsonProperty("audio_duration") + private Integer audioDuration; + + @JsonProperty("overall_status") + private String overallStatus; + + @JsonProperty("overall_progress") + private Integer overallProgress; + + @JsonProperty("current_stage") + private String currentStage; + + @JsonProperty("access_password") + private String accessPassword; } diff --git a/backend/src/test/java/com/imeeting/controller/android/legacy/LegacyMeetingControllerTest.java b/backend/src/test/java/com/imeeting/controller/android/legacy/LegacyMeetingControllerTest.java index 62a1966..3db7262 100644 --- a/backend/src/test/java/com/imeeting/controller/android/legacy/LegacyMeetingControllerTest.java +++ b/backend/src/test/java/com/imeeting/controller/android/legacy/LegacyMeetingControllerTest.java @@ -1,8 +1,12 @@ package com.imeeting.controller.android.legacy; +import com.fasterxml.jackson.databind.ObjectMapper; import com.imeeting.dto.android.legacy.LegacyApiResponse; import com.imeeting.dto.android.legacy.LegacyMeetingAccessPasswordRequest; import com.imeeting.dto.android.legacy.LegacyMeetingAccessPasswordResponse; +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.biz.MeetingVO; import com.imeeting.entity.biz.AiTask; import com.imeeting.entity.biz.Meeting; @@ -15,11 +19,14 @@ import com.imeeting.service.biz.MeetingCommandService; import com.imeeting.service.biz.MeetingQueryService; import com.imeeting.service.biz.MeetingService; 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 org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.Test; +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.data.redis.core.ValueOperations; import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; import org.springframework.security.core.context.SecurityContextHolder; @@ -53,9 +60,10 @@ class LegacyMeetingControllerTest { Meeting meeting = new Meeting(); meeting.setId(8L); - meeting.setTitle("项目复盘"); + meeting.setTitle("retro"); meeting.setMeetingTime(LocalDateTime.of(2026, 4, 13, 10, 0)); - meeting.setCreatorName("发起人"); + meeting.setCreatorId(7L); + meeting.setCreatorName("owner"); meeting.setParticipants("2,3"); meeting.setAccessPassword("123456"); meeting.setStatus(3); @@ -63,23 +71,30 @@ class LegacyMeetingControllerTest { AiTask summaryTask = new AiTask(); summaryTask.setTaskConfig(Map.of("promptId", 5L)); - when(aiTaskService.getOne(any())).thenReturn(null, summaryTask); + when(aiTaskService.getOne(any())).thenReturn((AiTask) null, summaryTask); MeetingVO detail = new MeetingVO(); - detail.setSummaryContent("## 总结\n已完成"); + detail.setSummaryContent("done"); when(meetingQueryService.getDetail(8L)).thenReturn(detail); PromptTemplate template = new PromptTemplate(); - template.setTemplateName("标准纪要"); + template.setTemplateName("standard"); when(promptTemplateService.getById(5L)).thenReturn(template); SysUser user2 = new SysUser(); user2.setUserId(2L); - user2.setDisplayName("张三"); + user2.setUsername("alice"); + user2.setDisplayName("Alice"); SysUser user3 = new SysUser(); user3.setUserId(3L); - user3.setDisplayName("李四"); + user3.setUsername("bob"); + user3.setDisplayName("Bob"); when(sysUserMapper.selectBatchIds(List.of(2L, 3L))).thenReturn(List.of(user2, user3)); + SysUser creator = new SysUser(); + creator.setUserId(7L); + creator.setUsername("owner-login"); + creator.setDisplayName("Owner Display"); + when(sysUserMapper.selectById(7L)).thenReturn(creator); LegacyMeetingController controller = new LegacyMeetingController( mock(LegacyMeetingAdapterService.class), @@ -99,6 +114,388 @@ class LegacyMeetingControllerTest { assertNotNull(response.getData()); } + @Test + void listShouldReturnLegacyMeetingPayloadAlignedWithPythonResponse() { + MeetingQueryService meetingQueryService = mock(MeetingQueryService.class); + MeetingService meetingService = mock(MeetingService.class); + AiTaskService aiTaskService = mock(AiTaskService.class); + MeetingTranscriptMapper transcriptMapper = mock(MeetingTranscriptMapper.class); + SysUserMapper sysUserMapper = mock(SysUserMapper.class); + + MeetingVO meeting = new MeetingVO(); + meeting.setId(18L); + meeting.setTitle("weekly"); + meeting.setMeetingTime(LocalDateTime.of(2026, 4, 14, 9, 30)); + meeting.setCreatedAt(LocalDateTime.of(2026, 4, 14, 10, 0)); + meeting.setCreatorId(7L); + meeting.setCreatorName("owner"); + meeting.setParticipantIds(List.of(2L, 3L)); + meeting.setTags("dev,weekly"); + meeting.setAudioUrl("/api/static/meetings/18/source_audio.wav"); + meeting.setDuration(366); + meeting.setStatus(3); + + PageResult> pageResult = new PageResult<>(); + pageResult.setTotal(1L); + pageResult.setRecords(List.of(meeting)); + when(meetingQueryService.pageMeetings(any(), any(), any(), any(), any(), any(), any(), org.mockito.ArgumentMatchers.anyBoolean())).thenReturn(pageResult); + + MeetingVO detail = new MeetingVO(); + detail.setSummaryContent("summary"); + when(meetingQueryService.getDetail(18L)).thenReturn(detail); + + Meeting entity = new Meeting(); + entity.setId(18L); + entity.setAccessPassword("123456"); + when(meetingService.getById(18L)).thenReturn(entity); + + SysUser user2 = new SysUser(); + user2.setUserId(2L); + user2.setUsername("alice"); + user2.setDisplayName("Alice"); + SysUser user3 = new SysUser(); + user3.setUserId(3L); + user3.setUsername("bob"); + user3.setDisplayName("Bob"); + when(sysUserMapper.selectBatchIds(List.of(2L, 3L))).thenReturn(List.of(user2, user3)); + SysUser creator = new SysUser(); + creator.setUserId(7L); + creator.setUsername("owner-login"); + creator.setDisplayName("Owner Display"); + when(sysUserMapper.selectById(7L)).thenReturn(creator); + when(aiTaskService.getOne(any())).thenReturn((AiTask) null, (AiTask) null); + + SecurityContextHolder.getContext().setAuthentication( + new UsernamePasswordAuthenticationToken(new LoginUser(7L, 1L, "creator", false, false, Set.of()), null) + ); + + LegacyMeetingController controller = new LegacyMeetingController( + mock(LegacyMeetingAdapterService.class), + meetingQueryService, + mock(MeetingAccessService.class), + mock(MeetingCommandService.class), + meetingService, + aiTaskService, + mock(PromptTemplateService.class), + transcriptMapper, + sysUserMapper + ); + + LegacyApiResponse response = controller.list(null, 1, 10, null); + + assertEquals("200", response.getCode()); + assertNotNull(response.getData()); + assertEquals(1L, response.getData().getTotal()); + assertEquals(1, response.getData().getMeetings().size()); + + LegacyMeetingItemResponse item = response.getData().getMeetings().get(0); + assertEquals(18L, item.getMeetingId()); + assertEquals("weekly", item.getTitle()); + assertEquals("summary", item.getSummary()); + assertEquals(7L, item.getCreatorId()); + assertEquals("Owner Display", item.getCreatorUsername()); + assertEquals("/api/static/meetings/18/source_audio.wav", item.getAudioFilePath()); + assertEquals(366, item.getAudioDuration()); + assertEquals("123456", item.getAccessPassword()); + assertEquals("completed", item.getOverallStatus()); + assertEquals(100, item.getOverallProgress()); + assertEquals("completed", item.getCurrentStage()); + assertEquals(List.of(2L, 3L), item.getAttendeeIds()); + assertEquals(2, item.getAttendees().size()); + assertEquals("alice", item.getAttendees().get(0).getUsername()); + assertEquals("Alice", item.getAttendees().get(0).getCaption()); + assertEquals(2, item.getTags().size()); + assertEquals("dev", item.getTags().get(0).getName()); + } + + @Test + void listShouldPreferTranscriptionStageBeforeSummaryStage() { + MeetingQueryService meetingQueryService = mock(MeetingQueryService.class); + MeetingService meetingService = mock(MeetingService.class); + AiTaskService aiTaskService = mock(AiTaskService.class); + MeetingTranscriptMapper transcriptMapper = mock(MeetingTranscriptMapper.class); + + MeetingVO meeting = new MeetingVO(); + meeting.setId(19L); + meeting.setTitle("status ordering"); + meeting.setAudioUrl("/tmp/audio.wav"); + meeting.setStatus(0); + + PageResult> pageResult = new PageResult<>(); + pageResult.setTotal(1L); + pageResult.setRecords(List.of(meeting)); + when(meetingQueryService.pageMeetings(any(), any(), any(), any(), any(), any(), any(), org.mockito.ArgumentMatchers.anyBoolean())).thenReturn(pageResult); + when(meetingQueryService.getDetail(19L)).thenReturn(new MeetingVO()); + when(meetingService.getById(19L)).thenReturn(new Meeting()); + when(aiTaskService.getOne(any())).thenReturn((AiTask) null, (AiTask) null); + when(transcriptMapper.selectCount(any())).thenReturn(2L); + + SecurityContextHolder.getContext().setAuthentication( + new UsernamePasswordAuthenticationToken(new LoginUser(7L, 1L, "creator", false, false, Set.of()), null) + ); + + LegacyMeetingController controller = new LegacyMeetingController( + mock(LegacyMeetingAdapterService.class), + meetingQueryService, + mock(MeetingAccessService.class), + mock(MeetingCommandService.class), + meetingService, + aiTaskService, + mock(PromptTemplateService.class), + transcriptMapper, + mock(SysUserMapper.class) + ); + + LegacyApiResponse response = controller.list(null, 1, 10, null); + + assertEquals("200", response.getCode()); + assertNotNull(response.getData()); + assertEquals(1, response.getData().getMeetings().size()); + LegacyMeetingItemResponse item = response.getData().getMeetings().get(0); + assertEquals("transcribing", item.getOverallStatus()); + assertEquals(50, item.getOverallProgress()); + assertEquals("transcription", item.getCurrentStage()); + } + + @Test + void previewDataShouldReportAsrFailureAtFiftyPercent() { + MeetingService meetingService = mock(MeetingService.class); + AiTaskService aiTaskService = mock(AiTaskService.class); + MeetingQueryService meetingQueryService = mock(MeetingQueryService.class); + MeetingTranscriptMapper transcriptMapper = mock(MeetingTranscriptMapper.class); + + Meeting meeting = new Meeting(); + meeting.setId(20L); + meeting.setTitle("asr failed"); + meeting.setStatus(4); + when(meetingService.getById(20L)).thenReturn(meeting); + + AiTask asrTask = new AiTask(); + asrTask.setStatus(3); + asrTask.setErrorMsg("asr failed"); + when(aiTaskService.getOne(any())).thenReturn(asrTask, (AiTask) null); + + LegacyMeetingController controller = new LegacyMeetingController( + mock(LegacyMeetingAdapterService.class), + meetingQueryService, + mock(MeetingAccessService.class), + mock(MeetingCommandService.class), + meetingService, + aiTaskService, + mock(PromptTemplateService.class), + transcriptMapper, + mock(SysUserMapper.class) + ); + + LegacyApiResponse response = controller.previewData(20L); + + assertEquals("503", response.getCode()); + LegacyMeetingPreviewDataResponse data = (LegacyMeetingPreviewDataResponse) response.getData(); + assertNotNull(data); + assertEquals(50, data.getProcessingStatus().getOverallProgress()); + } + + @Test + void previewDataShouldPrioritizeAsrFailureBeforeSummaryFailure() { + MeetingService meetingService = mock(MeetingService.class); + AiTaskService aiTaskService = mock(AiTaskService.class); + + Meeting meeting = new Meeting(); + meeting.setId(23L); + meeting.setTitle("both failed"); + when(meetingService.getById(23L)).thenReturn(meeting); + + AiTask asrTask = new AiTask(); + asrTask.setStatus(3); + asrTask.setErrorMsg("asr failed"); + AiTask summaryTask = new AiTask(); + summaryTask.setStatus(3); + summaryTask.setErrorMsg("summary failed"); + when(aiTaskService.getOne(any())).thenReturn(asrTask, summaryTask); + + LegacyMeetingController controller = new LegacyMeetingController( + mock(LegacyMeetingAdapterService.class), + mock(MeetingQueryService.class), + mock(MeetingAccessService.class), + mock(MeetingCommandService.class), + meetingService, + aiTaskService, + mock(PromptTemplateService.class), + mock(MeetingTranscriptMapper.class), + mock(SysUserMapper.class) + ); + + LegacyApiResponse response = controller.previewData(23L); + + assertEquals("503", response.getCode()); + LegacyMeetingPreviewDataResponse data = (LegacyMeetingPreviewDataResponse) response.getData(); + assertNotNull(data); + assertEquals(50, data.getProcessingStatus().getOverallProgress()); + assertEquals("audio_transcription", data.getProcessingStatus().getCurrentStage()); + } + @Test + void previewDataShouldPreferTranscriptionStageBeforeSummaryStage() { + MeetingService meetingService = mock(MeetingService.class); + AiTaskService aiTaskService = mock(AiTaskService.class); + MeetingTranscriptMapper transcriptMapper = mock(MeetingTranscriptMapper.class); + + Meeting meeting = new Meeting(); + meeting.setId(22L); + meeting.setTitle("transcribing first"); + meeting.setStatus(0); + meeting.setAudioUrl("/tmp/audio.wav"); + when(meetingService.getById(22L)).thenReturn(meeting); + + when(aiTaskService.getOne(any())).thenReturn((AiTask) null, (AiTask) null); + when(transcriptMapper.selectCount(any())).thenReturn(3L); + + LegacyMeetingController controller = new LegacyMeetingController( + mock(LegacyMeetingAdapterService.class), + mock(MeetingQueryService.class), + mock(MeetingAccessService.class), + mock(MeetingCommandService.class), + meetingService, + aiTaskService, + mock(PromptTemplateService.class), + transcriptMapper, + mock(SysUserMapper.class) + ); + + LegacyApiResponse response = controller.previewData(22L); + + assertEquals("400", response.getCode()); + LegacyMeetingPreviewDataResponse data = (LegacyMeetingPreviewDataResponse) response.getData(); + assertNotNull(data); + assertEquals(50, data.getProcessingStatus().getOverallProgress()); + assertEquals("audio_transcription", data.getProcessingStatus().getCurrentStage()); + } + + @Test + void previewDataShouldReportSummaryStageAtSeventyFivePercent() { + MeetingService meetingService = mock(MeetingService.class); + AiTaskService aiTaskService = mock(AiTaskService.class); + MeetingTranscriptMapper transcriptMapper = mock(MeetingTranscriptMapper.class); + + Meeting meeting = new Meeting(); + meeting.setId(21L); + meeting.setTitle("summary running"); + meeting.setStatus(2); + when(meetingService.getById(21L)).thenReturn(meeting); + + AiTask summaryTask = new AiTask(); + summaryTask.setStatus(1); + when(aiTaskService.getOne(any())).thenReturn((AiTask) null, summaryTask); + when(transcriptMapper.selectCount(any())).thenReturn(0L); + + LegacyMeetingController controller = new LegacyMeetingController( + mock(LegacyMeetingAdapterService.class), + mock(MeetingQueryService.class), + mock(MeetingAccessService.class), + mock(MeetingCommandService.class), + meetingService, + aiTaskService, + mock(PromptTemplateService.class), + transcriptMapper, + mock(SysUserMapper.class) + ); + + LegacyApiResponse response = controller.previewData(21L); + + assertEquals("400", response.getCode()); + LegacyMeetingPreviewDataResponse data = (LegacyMeetingPreviewDataResponse) response.getData(); + assertNotNull(data); + assertEquals(75, data.getProcessingStatus().getOverallProgress()); + } + + @Test + void previewDataShouldReportSummaryStageAtSeventyFivePercentWhenAudioExists() { + MeetingService meetingService = mock(MeetingService.class); + AiTaskService aiTaskService = mock(AiTaskService.class); + MeetingTranscriptMapper transcriptMapper = mock(MeetingTranscriptMapper.class); + + Meeting meeting = new Meeting(); + meeting.setId(24L); + meeting.setTitle("summary running with audio"); + meeting.setStatus(2); + meeting.setAudioUrl("/tmp/audio.wav"); + when(meetingService.getById(24L)).thenReturn(meeting); + + AiTask summaryTask = new AiTask(); + summaryTask.setStatus(1); + when(aiTaskService.getOne(any())).thenReturn((AiTask) null, summaryTask); + when(transcriptMapper.selectCount(any())).thenReturn(3L); + + LegacyMeetingController controller = new LegacyMeetingController( + mock(LegacyMeetingAdapterService.class), + mock(MeetingQueryService.class), + mock(MeetingAccessService.class), + mock(MeetingCommandService.class), + meetingService, + aiTaskService, + mock(PromptTemplateService.class), + transcriptMapper, + mock(SysUserMapper.class) + ); + + LegacyApiResponse response = controller.previewData(24L); + + assertEquals("400", response.getCode()); + LegacyMeetingPreviewDataResponse data = (LegacyMeetingPreviewDataResponse) response.getData(); + assertNotNull(data); + assertEquals(75, data.getProcessingStatus().getOverallProgress()); + assertEquals("summary_generation", data.getProcessingStatus().getCurrentStage()); + } + + @Test + void listShouldReportSummaryStageAtSeventyFivePercentWhenAudioExists() { + MeetingQueryService meetingQueryService = mock(MeetingQueryService.class); + MeetingService meetingService = mock(MeetingService.class); + AiTaskService aiTaskService = mock(AiTaskService.class); + MeetingTranscriptMapper transcriptMapper = mock(MeetingTranscriptMapper.class); + + MeetingVO meeting = new MeetingVO(); + meeting.setId(25L); + meeting.setTitle("summary stage with audio"); + meeting.setAudioUrl("/tmp/audio.wav"); + meeting.setStatus(2); + + PageResult> pageResult = new PageResult<>(); + pageResult.setTotal(1L); + pageResult.setRecords(List.of(meeting)); + when(meetingQueryService.pageMeetings(any(), any(), any(), any(), any(), any(), any(), org.mockito.ArgumentMatchers.anyBoolean())).thenReturn(pageResult); + + AiTask summaryTask = new AiTask(); + summaryTask.setStatus(1); + when(aiTaskService.getOne(any())).thenReturn((AiTask) null, summaryTask); + when(transcriptMapper.selectCount(any())).thenReturn(3L); + + SecurityContextHolder.getContext().setAuthentication( + new UsernamePasswordAuthenticationToken(new LoginUser(7L, 1L, "creator", false, false, Set.of()), null) + ); + + LegacyMeetingController controller = new LegacyMeetingController( + mock(LegacyMeetingAdapterService.class), + meetingQueryService, + mock(MeetingAccessService.class), + mock(MeetingCommandService.class), + meetingService, + aiTaskService, + mock(PromptTemplateService.class), + transcriptMapper, + mock(SysUserMapper.class) + ); + + LegacyApiResponse response = controller.list(null, 1, 10, null); + + assertEquals("200", response.getCode()); + assertNotNull(response.getData()); + assertEquals(1, response.getData().getMeetings().size()); + LegacyMeetingItemResponse item = response.getData().getMeetings().get(0); + assertEquals("summarizing", item.getOverallStatus()); + assertEquals(75, item.getOverallProgress()); + assertEquals("llm", item.getCurrentStage()); + } + @Test void updateAccessPasswordShouldOnlyAllowCreator() { MeetingAccessService meetingAccessService = mock(MeetingAccessService.class); @@ -135,4 +532,86 @@ class LegacyMeetingControllerTest { assertEquals(null, meeting.getAccessPassword()); verify(meetingService).updateById(meeting); } + + @Test + void previewDataShouldTranslateRealtimeProgressBelowNinetyToTranscription() { + MeetingService meetingService = mock(MeetingService.class); + AiTaskService aiTaskService = mock(AiTaskService.class); + MeetingTranscriptMapper transcriptMapper = mock(MeetingTranscriptMapper.class); + StringRedisTemplate redisTemplate = mock(StringRedisTemplate.class); + @SuppressWarnings("unchecked") + ValueOperations valueOperations = mock(ValueOperations.class); + + Meeting meeting = new Meeting(); + meeting.setId(26L); + meeting.setTitle("progress translating"); + meeting.setStatus(0); + when(meetingService.getById(26L)).thenReturn(meeting); + when(aiTaskService.getOne(any())).thenReturn((AiTask) null, (AiTask) null); + when(redisTemplate.opsForValue()).thenReturn(valueOperations); + when(valueOperations.get("biz:meeting:progress:26")).thenReturn("{\"percent\":45}"); + + LegacyMeetingController controller = new LegacyMeetingController( + mock(LegacyMeetingAdapterService.class), + mock(MeetingQueryService.class), + mock(MeetingAccessService.class), + mock(MeetingCommandService.class), + meetingService, + aiTaskService, + mock(PromptTemplateService.class), + transcriptMapper, + mock(SysUserMapper.class), + redisTemplate, + new ObjectMapper() + ); + + LegacyApiResponse response = controller.previewData(26L); + + assertEquals("400", response.getCode()); + LegacyMeetingPreviewDataResponse data = (LegacyMeetingPreviewDataResponse) response.getData(); + assertNotNull(data); + assertEquals(50, data.getProcessingStatus().getOverallProgress()); + assertEquals("audio_transcription", data.getProcessingStatus().getCurrentStage()); + } + + @Test + void previewDataShouldTranslateRealtimeProgressAtNinetyToSummaryStage() { + MeetingService meetingService = mock(MeetingService.class); + AiTaskService aiTaskService = mock(AiTaskService.class); + MeetingTranscriptMapper transcriptMapper = mock(MeetingTranscriptMapper.class); + StringRedisTemplate redisTemplate = mock(StringRedisTemplate.class); + @SuppressWarnings("unchecked") + ValueOperations valueOperations = mock(ValueOperations.class); + + Meeting meeting = new Meeting(); + meeting.setId(27L); + meeting.setTitle("summary by progress"); + meeting.setStatus(0); + when(meetingService.getById(27L)).thenReturn(meeting); + when(aiTaskService.getOne(any())).thenReturn((AiTask) null, (AiTask) null); + when(redisTemplate.opsForValue()).thenReturn(valueOperations); + when(valueOperations.get("biz:meeting:progress:27")).thenReturn("{\"percent\":90}"); + + LegacyMeetingController controller = new LegacyMeetingController( + mock(LegacyMeetingAdapterService.class), + mock(MeetingQueryService.class), + mock(MeetingAccessService.class), + mock(MeetingCommandService.class), + meetingService, + aiTaskService, + mock(PromptTemplateService.class), + transcriptMapper, + mock(SysUserMapper.class), + redisTemplate, + new ObjectMapper() + ); + + LegacyApiResponse response = controller.previewData(27L); + + assertEquals("400", response.getCode()); + LegacyMeetingPreviewDataResponse data = (LegacyMeetingPreviewDataResponse) response.getData(); + assertNotNull(data); + assertEquals(75, data.getProcessingStatus().getOverallProgress()); + assertEquals("summary_generation", data.getProcessingStatus().getCurrentStage()); + } } diff --git a/backend/src/test/java/com/imeeting/service/android/legacy/LegacyCatalogAdapterServiceImplTest.java b/backend/src/test/java/com/imeeting/service/android/legacy/LegacyCatalogAdapterServiceImplTest.java index a6d94c1..eba1999 100644 --- a/backend/src/test/java/com/imeeting/service/android/legacy/LegacyCatalogAdapterServiceImplTest.java +++ b/backend/src/test/java/com/imeeting/service/android/legacy/LegacyCatalogAdapterServiceImplTest.java @@ -1,5 +1,7 @@ package com.imeeting.service.android.legacy; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; import com.imeeting.dto.android.legacy.LegacyClientDownloadResponse; import com.imeeting.dto.android.legacy.LegacyExternalAppItemResponse; import com.imeeting.entity.biz.ClientDownload; @@ -16,6 +18,7 @@ import java.util.List; import java.util.Map; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.when; @@ -93,4 +96,44 @@ class LegacyCatalogAdapterServiceImplTest { assertEquals("管理员", responses.get(0).getCreatorUsername()); assertEquals(1, responses.get(0).getIsActive()); } + + @Test + void listActiveExternalAppsShouldSerializeAppInfoKeysAsSnakeCase() throws Exception { + ExternalAppMapper externalAppMapper = mock(ExternalAppMapper.class); + SysUserMapper sysUserMapper = mock(SysUserMapper.class); + + ExternalApp app = new ExternalApp(); + app.setId(102L); + app.setAppName("鎵撳崱宸ュ叿"); + app.setAppType("native"); + app.setAppInfo(Map.of( + "versionName", "2.1.0", + "webUrl", "https://board.example.com", + "nestedConfig", Map.of("packageName", "com.example.clockin"), + "launchTargets", List.of(Map.of("apkUrl", "https://dl.example.com/app.apk")) + )); + app.setStatus(1); + app.setCreatedBy(1L); + when(externalAppMapper.selectList(any())).thenReturn(List.of(app)); + + SysUser creator = new SysUser(); + creator.setUserId(1L); + creator.setDisplayName("admin"); + when(sysUserMapper.selectBatchIds(List.of(1L))).thenReturn(List.of(creator)); + + LegacyCatalogAdapterServiceImpl service = new LegacyCatalogAdapterServiceImpl( + mock(ClientDownloadMapper.class), + externalAppMapper, + sysUserMapper + ); + + List responses = service.listActiveExternalApps(); + JsonNode json = new ObjectMapper().readTree(new ObjectMapper().writeValueAsBytes(responses.get(0))); + + assertTrue(json.has("app_info")); + assertEquals("2.1.0", json.path("app_info").path("version_name").asText()); + assertEquals("https://board.example.com", json.path("app_info").path("web_url").asText()); + assertEquals("com.example.clockin", json.path("app_info").path("nested_config").path("package_name").asText()); + assertEquals("https://dl.example.com/app.apk", json.path("app_info").path("launch_targets").get(0).path("apk_url").asText()); + } } diff --git a/frontend/src/routes/index.tsx b/frontend/src/routes/index.tsx index 3d491f7..a76330d 100644 --- a/frontend/src/routes/index.tsx +++ b/frontend/src/routes/index.tsx @@ -6,6 +6,7 @@ import { menuRoutes,extraRoutes } from "./routes"; const Login = lazy(() => import("@/pages/auth/login")); const ResetPassword = lazy(() => import("@/pages/auth/reset-password")); +const MeetingPreview = lazy(() => import("@/pages/business/MeetingPreview")); function RouteFallback() { return
Loading...
; @@ -31,6 +32,14 @@ export default function AppRoutes() { } /> } /> + + + + } + />