From 3b7ba2c47a5bf46ed84c6fa48ba5cfec200cef8a Mon Sep 17 00:00:00 2001 From: chenhao Date: Mon, 13 Apr 2026 20:21:08 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=B7=BB=E5=8A=A0=E6=97=A7=E7=89=88And?= =?UTF-8?q?roid=20API=E6=94=AF=E6=8C=81=E5=92=8C=E7=9B=B8=E5=85=B3?= =?UTF-8?q?=E6=B5=8B=E8=AF=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 添加 `LegacyMeetingAttendeeResponse`, `LegacyMeetingPreviewDataResponse`, `LegacyExternalAppItemResponse`, `LegacyClientDownloadResponse` 等DTO - 添加 `LegacyCatalogAdapterService` 接口及其实现 - 添加 `MeetingAuthorizationServiceImplTest`, `RealtimeMeetingSessionStateServiceImplTest`, `RealtimeMeetingGrpcServiceTest`, `LegacyMeetingAdapterServiceImplTest` 单元测试 - 添加 `ClientDownload` 和 `ExternalApp` 实体类 - 添加 `ApkManifestParser` 工具类 --- .../legacy/LegacyClientController.java | 34 ++ .../legacy/LegacyExternalAppController.java | 25 ++ .../legacy/LegacyMeetingController.java | 261 +++++++++++- .../biz/ClientDownloadController.java | 78 ++++ .../controller/biz/ExternalAppController.java | 74 ++++ .../legacy/LegacyClientDownloadResponse.java | 67 +++ .../legacy/LegacyExternalAppItemResponse.java | 61 +++ .../LegacyMeetingAccessPasswordRequest.java | 8 + .../LegacyMeetingAccessPasswordResponse.java | 12 + .../legacy/LegacyMeetingAttendeeResponse.java | 16 + .../LegacyMeetingPreviewDataResponse.java | 39 ++ .../legacy/LegacyMeetingPreviewResult.java | 12 + ...LegacyMeetingProcessingStatusResponse.java | 20 + .../imeeting/dto/biz/ClientDownloadDTO.java | 19 + .../com/imeeting/dto/biz/ExternalAppDTO.java | 17 + .../imeeting/entity/biz/ClientDownload.java | 38 ++ .../com/imeeting/entity/biz/ExternalApp.java | 35 ++ .../java/com/imeeting/entity/biz/Meeting.java | 2 + .../mapper/biz/ClientDownloadMapper.java | 9 + .../mapper/biz/ExternalAppMapper.java | 9 + .../legacy/LegacyCatalogAdapterService.java | 12 + .../impl/LegacyCatalogAdapterServiceImpl.java | 79 ++++ .../impl/LegacyMeetingAdapterServiceImpl.java | 27 +- .../service/biz/ClientDownloadService.java | 23 ++ .../service/biz/ExternalAppService.java | 25 ++ .../biz/impl/ClientDownloadServiceImpl.java | 226 +++++++++++ .../biz/impl/ExternalAppServiceImpl.java | 243 +++++++++++ .../biz/impl/MeetingDomainSupport.java | 2 + .../imeeting/support/ApkManifestParser.java | 307 ++++++++++++++ .../ApiResponseSuccessCodeAdviceTest.java | 29 ++ .../legacy/LegacyMeetingControllerTest.java | 138 +++++++ .../android/legacy/LegacyApiResponseTest.java | 18 + .../RealtimeMeetingGrpcServiceTest.java | 110 +++++ .../LegacyCatalogAdapterServiceImplTest.java | 96 +++++ .../LegacyMeetingAdapterServiceImplTest.java | 86 ++++ .../biz/impl/AiTaskServiceImplTest.java | 72 ++++ .../MeetingAuthorizationServiceImplTest.java | 74 ++++ ...imeMeetingSessionStateServiceImplTest.java | 157 +++++++ ...timeMeetingGrpcSessionServiceImplTest.java | 123 ++++++ frontend/src/api/business/client.ts | 77 ++++ frontend/src/api/business/externalApp.ts | 82 ++++ .../src/pages/business/ClientManagement.tsx | 383 ++++++++++++++++++ .../pages/business/ExternalAppManagement.tsx | 375 +++++++++++++++++ frontend/src/routes/routes.tsx | 24 +- 44 files changed, 3599 insertions(+), 25 deletions(-) create mode 100644 backend/src/main/java/com/imeeting/controller/android/legacy/LegacyClientController.java create mode 100644 backend/src/main/java/com/imeeting/controller/android/legacy/LegacyExternalAppController.java create mode 100644 backend/src/main/java/com/imeeting/controller/biz/ClientDownloadController.java create mode 100644 backend/src/main/java/com/imeeting/controller/biz/ExternalAppController.java create mode 100644 backend/src/main/java/com/imeeting/dto/android/legacy/LegacyClientDownloadResponse.java create mode 100644 backend/src/main/java/com/imeeting/dto/android/legacy/LegacyExternalAppItemResponse.java create mode 100644 backend/src/main/java/com/imeeting/dto/android/legacy/LegacyMeetingAccessPasswordRequest.java create mode 100644 backend/src/main/java/com/imeeting/dto/android/legacy/LegacyMeetingAccessPasswordResponse.java create mode 100644 backend/src/main/java/com/imeeting/dto/android/legacy/LegacyMeetingAttendeeResponse.java create mode 100644 backend/src/main/java/com/imeeting/dto/android/legacy/LegacyMeetingPreviewDataResponse.java create mode 100644 backend/src/main/java/com/imeeting/dto/android/legacy/LegacyMeetingPreviewResult.java create mode 100644 backend/src/main/java/com/imeeting/dto/android/legacy/LegacyMeetingProcessingStatusResponse.java create mode 100644 backend/src/main/java/com/imeeting/dto/biz/ClientDownloadDTO.java create mode 100644 backend/src/main/java/com/imeeting/dto/biz/ExternalAppDTO.java create mode 100644 backend/src/main/java/com/imeeting/entity/biz/ClientDownload.java create mode 100644 backend/src/main/java/com/imeeting/entity/biz/ExternalApp.java create mode 100644 backend/src/main/java/com/imeeting/mapper/biz/ClientDownloadMapper.java create mode 100644 backend/src/main/java/com/imeeting/mapper/biz/ExternalAppMapper.java create mode 100644 backend/src/main/java/com/imeeting/service/android/legacy/LegacyCatalogAdapterService.java create mode 100644 backend/src/main/java/com/imeeting/service/android/legacy/impl/LegacyCatalogAdapterServiceImpl.java create mode 100644 backend/src/main/java/com/imeeting/service/biz/ClientDownloadService.java create mode 100644 backend/src/main/java/com/imeeting/service/biz/ExternalAppService.java create mode 100644 backend/src/main/java/com/imeeting/service/biz/impl/ClientDownloadServiceImpl.java create mode 100644 backend/src/main/java/com/imeeting/service/biz/impl/ExternalAppServiceImpl.java create mode 100644 backend/src/main/java/com/imeeting/support/ApkManifestParser.java create mode 100644 backend/src/test/java/com/imeeting/config/ApiResponseSuccessCodeAdviceTest.java create mode 100644 backend/src/test/java/com/imeeting/controller/android/legacy/LegacyMeetingControllerTest.java create mode 100644 backend/src/test/java/com/imeeting/dto/android/legacy/LegacyApiResponseTest.java create mode 100644 backend/src/test/java/com/imeeting/grpc/realtime/RealtimeMeetingGrpcServiceTest.java create mode 100644 backend/src/test/java/com/imeeting/service/android/legacy/LegacyCatalogAdapterServiceImplTest.java create mode 100644 backend/src/test/java/com/imeeting/service/android/legacy/LegacyMeetingAdapterServiceImplTest.java create mode 100644 backend/src/test/java/com/imeeting/service/biz/impl/AiTaskServiceImplTest.java create mode 100644 backend/src/test/java/com/imeeting/service/biz/impl/MeetingAuthorizationServiceImplTest.java create mode 100644 backend/src/test/java/com/imeeting/service/biz/impl/RealtimeMeetingSessionStateServiceImplTest.java create mode 100644 backend/src/test/java/com/imeeting/service/realtime/impl/RealtimeMeetingGrpcSessionServiceImplTest.java create mode 100644 frontend/src/api/business/client.ts create mode 100644 frontend/src/api/business/externalApp.ts create mode 100644 frontend/src/pages/business/ClientManagement.tsx create mode 100644 frontend/src/pages/business/ExternalAppManagement.tsx diff --git a/backend/src/main/java/com/imeeting/controller/android/legacy/LegacyClientController.java b/backend/src/main/java/com/imeeting/controller/android/legacy/LegacyClientController.java new file mode 100644 index 0000000..c0672af --- /dev/null +++ b/backend/src/main/java/com/imeeting/controller/android/legacy/LegacyClientController.java @@ -0,0 +1,34 @@ +package com.imeeting.controller.android.legacy; + +import com.imeeting.dto.android.legacy.LegacyApiResponse; +import com.imeeting.dto.android.legacy.LegacyClientDownloadResponse; +import com.imeeting.service.android.legacy.LegacyCatalogAdapterService; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("/api/clients") +@RequiredArgsConstructor +public class LegacyClientController { + + private final LegacyCatalogAdapterService legacyCatalogAdapterService; + + @GetMapping("/latest/by-platform") + public LegacyApiResponse latestByPlatform(@RequestParam(value = "platform_code", required = false) String platformCode, + @RequestParam(value = "platform_type", required = false) String platformType, + @RequestParam(value = "platform_name", required = false) String platformName) { + if ((platformCode == null || platformCode.isBlank()) + && ((platformType == null || platformType.isBlank()) || (platformName == null || platformName.isBlank()))) { + return LegacyApiResponse.error("400", "请提供 platform_code 参数"); + } + + LegacyClientDownloadResponse response = legacyCatalogAdapterService.getLatestClient(platformCode, platformType, platformName); + if (response == null) { + return LegacyApiResponse.error("404", "暂无最新客户端"); + } + return LegacyApiResponse.ok(response); + } +} diff --git a/backend/src/main/java/com/imeeting/controller/android/legacy/LegacyExternalAppController.java b/backend/src/main/java/com/imeeting/controller/android/legacy/LegacyExternalAppController.java new file mode 100644 index 0000000..2e096eb --- /dev/null +++ b/backend/src/main/java/com/imeeting/controller/android/legacy/LegacyExternalAppController.java @@ -0,0 +1,25 @@ +package com.imeeting.controller.android.legacy; + +import com.imeeting.dto.android.legacy.LegacyApiResponse; +import com.imeeting.dto.android.legacy.LegacyExternalAppItemResponse; +import com.imeeting.service.android.legacy.LegacyCatalogAdapterService; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import java.util.List; + +@RestController +@RequestMapping("/api/external-apps") +@RequiredArgsConstructor +public class LegacyExternalAppController { + + private final LegacyCatalogAdapterService legacyCatalogAdapterService; + + @GetMapping("/active") + public LegacyApiResponse> active(@RequestParam(value = "is_active", required = false) Integer ignoredIsActive) { + return LegacyApiResponse.ok(legacyCatalogAdapterService.listActiveExternalApps()); + } +} 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 8679465..6ee74ff 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,17 +1,33 @@ package com.imeeting.controller.android.legacy; +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; 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.LegacyMeetingAttendeeResponse; import com.imeeting.dto.android.legacy.LegacyMeetingCreateRequest; import com.imeeting.dto.android.legacy.LegacyMeetingCreateResponse; 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.biz.MeetingVO; +import com.imeeting.entity.biz.AiTask; import com.imeeting.entity.biz.Meeting; +import com.imeeting.entity.biz.MeetingTranscript; +import com.imeeting.entity.biz.PromptTemplate; import com.imeeting.service.android.legacy.LegacyMeetingAdapterService; +import com.imeeting.service.biz.AiTaskService; import com.imeeting.service.biz.MeetingAccessService; import com.imeeting.service.biz.MeetingCommandService; import com.imeeting.service.biz.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.security.access.prepost.PreAuthorize; @@ -20,6 +36,7 @@ import org.springframework.web.bind.annotation.DeleteMapping; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.PutMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestParam; @@ -27,17 +44,32 @@ import org.springframework.web.bind.annotation.RestController; import org.springframework.web.multipart.MultipartFile; import java.io.IOException; +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; @RestController @RequestMapping("/api/meetings") @RequiredArgsConstructor public class LegacyMeetingController { + 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 LegacyMeetingAdapterService legacyMeetingAdapterService; private final MeetingQueryService meetingQueryService; private final MeetingAccessService meetingAccessService; private final MeetingCommandService meetingCommandService; + private final MeetingService meetingService; + private final AiTaskService aiTaskService; + private final PromptTemplateService promptTemplateService; + private final MeetingTranscriptMapper meetingTranscriptMapper; + private final SysUserMapper sysUserMapper; @PostMapping @PreAuthorize("isAuthenticated()") @@ -67,9 +99,9 @@ public class LegacyMeetingController { @GetMapping @PreAuthorize("isAuthenticated()") public LegacyApiResponse list(@RequestParam(value = "user_id", required = false) Long ignoredUserId, - @RequestParam(defaultValue = "1") Integer page, - @RequestParam(value = "page_size", defaultValue = "10") Integer pageSize, - @RequestParam(required = false) String title) { + @RequestParam(defaultValue = "1") Integer page, + @RequestParam(value = "page_size", defaultValue = "10") Integer pageSize, + @RequestParam(required = false) String title) { LoginUser loginUser = currentLoginUser(); boolean isAdmin = Boolean.TRUE.equals(loginUser.getIsPlatformAdmin()) || Boolean.TRUE.equals(loginUser.getIsTenantAdmin()); PageResult> result = meetingQueryService.pageMeetings( @@ -95,6 +127,27 @@ public class LegacyMeetingController { return LegacyApiResponse.ok(data); } + @GetMapping("/{meetingId}/preview-data") + public LegacyApiResponse previewData(@PathVariable Long meetingId) { + LegacyMeetingPreviewResult result = buildPreviewResult(meetingId); + return new LegacyApiResponse<>(result.getCode(), result.getMessage(), result.getData()); + } + + @PutMapping("/{meetingId}/access-password") + @PreAuthorize("isAuthenticated()") + public LegacyApiResponse updateAccessPassword(@PathVariable Long meetingId, + @RequestBody(required = false) LegacyMeetingAccessPasswordRequest request) { + LoginUser loginUser = currentLoginUser(); + Meeting meeting = meetingAccessService.requireMeeting(meetingId); + if (!Objects.equals(meeting.getCreatorId(), loginUser.getUserId())) { + return LegacyApiResponse.error("403", "仅会议创建人可设置访问密码"); + } + String password = normalizePassword(request == null ? null : request.getPassword()); + meeting.setAccessPassword(password); + meetingService.updateById(meeting); + return LegacyApiResponse.ok(new LegacyMeetingAccessPasswordResponse(password)); + } + @DeleteMapping("/{meetingId}") @PreAuthorize("isAuthenticated()") public LegacyApiResponse delete(@PathVariable Long meetingId) { @@ -105,6 +158,208 @@ public class LegacyMeetingController { return LegacyApiResponse.ok("删除成功", null); } + private LegacyMeetingPreviewResult buildPreviewResult(Long meetingId) { + Meeting meeting = meetingService.getById(meetingId); + if (meeting == null) { + return new LegacyMeetingPreviewResult("404", "会议不存在", null); + } + + 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( + "504", + "处理已完成,但摘要尚未同步,请稍后重试", + buildProcessingPreview(meeting, summaryTask, processingStatus("摘要已生成,可扫码查看", 100, STAGE_COMPLETED)) + ); + } + if (isFailed(summaryTask)) { + return new LegacyMeetingPreviewResult( + "503", + buildFailureMessage(summaryTask, "总结"), + 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)) + ); + } + + long transcriptCount = meetingTranscriptMapper.selectCount(new LambdaQueryWrapper() + .eq(MeetingTranscript::getMeetingId, meetingId)); + if (isRunningSummary(summaryTask) || transcriptCount > 0 || Integer.valueOf(2).equals(meeting.getStatus())) { + return new LegacyMeetingPreviewResult( + "400", + "会议正在处理中", + buildProcessingPreview(meeting, summaryTask, processingStatus("正在生成总结", 75, STAGE_SUMMARY_GENERATION)) + ); + } + if (isRunningAsr(asrTask) || (meeting.getAudioUrl() != null && !meeting.getAudioUrl().isBlank()) || Integer.valueOf(1).equals(meeting.getStatus())) { + return new LegacyMeetingPreviewResult( + "400", + "会议正在处理中", + buildProcessingPreview(meeting, summaryTask, processingStatus("正在转译音频", 50, STAGE_AUDIO_TRANSCRIPTION)) + ); + } + return new LegacyMeetingPreviewResult( + "400", + "会议正在处理中", + buildProcessingPreview(meeting, summaryTask, processingStatus("会议数据准备中", 25, STAGE_DATA_INITIALIZATION)) + ); + } + + private LegacyMeetingPreviewDataResponse buildCompletedPreview(Meeting meeting, MeetingVO detail, AiTask summaryTask) { + LegacyMeetingPreviewDataResponse data = new LegacyMeetingPreviewDataResponse(); + data.setMeetingId(meeting.getId()); + data.setTitle(meeting.getTitle()); + data.setMeetingTime(meeting.getMeetingTime() == null ? null : meeting.getMeetingTime().toString()); + data.setSummary(detail.getSummaryContent()); + data.setCreatorUsername(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(meeting.getMeetingTime() == null ? null : meeting.getMeetingTime().toString()); + data.setCreatorUsername(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 LegacyMeetingProcessingStatusResponse processingStatus(String overallStatus, int overallProgress, String currentStage) { + return new LegacyMeetingProcessingStatusResponse(overallStatus, overallProgress, currentStage); + } + + 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) { + List participantIds = parseParticipantIds(participants); + if (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()); + return new LegacyMeetingAttendeeResponse(userId, caption); + }) + .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 normalizePassword(String password) { + if (password == null) { + return null; + } + String normalized = password.trim(); + return normalized.isEmpty() ? null : normalized; + } + private LoginUser currentLoginUser() { return (LoginUser) SecurityContextHolder.getContext().getAuthentication().getPrincipal(); } diff --git a/backend/src/main/java/com/imeeting/controller/biz/ClientDownloadController.java b/backend/src/main/java/com/imeeting/controller/biz/ClientDownloadController.java new file mode 100644 index 0000000..25ff7c7 --- /dev/null +++ b/backend/src/main/java/com/imeeting/controller/biz/ClientDownloadController.java @@ -0,0 +1,78 @@ +package com.imeeting.controller.biz; + +import com.imeeting.dto.biz.ClientDownloadDTO; +import com.imeeting.entity.biz.ClientDownload; +import com.imeeting.service.biz.ClientDownloadService; +import com.unisbase.common.ApiResponse; +import com.unisbase.security.LoginUser; +import lombok.RequiredArgsConstructor; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.multipart.MultipartFile; + +import java.io.IOException; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +@RestController +@RequestMapping("/api/clients") +@RequiredArgsConstructor +public class ClientDownloadController { + + private final ClientDownloadService clientDownloadService; + + @GetMapping + @PreAuthorize("isAuthenticated()") + public ApiResponse> list(@RequestParam(value = "platformCode", required = false) String platformCode, + @RequestParam(value = "status", required = false) Integer status, + @RequestParam(value = "page", defaultValue = "1") Integer page, + @RequestParam(value = "size", defaultValue = "50") Integer size) { + List clients = clientDownloadService.listForAdmin(currentLoginUser(), platformCode, status); + Map data = new HashMap<>(); + data.put("clients", clients); + data.put("total", clients.size()); + data.put("page", page); + data.put("size", size); + return ApiResponse.ok(data); + } + + @PostMapping + @PreAuthorize("isAuthenticated()") + public ApiResponse create(@RequestBody ClientDownloadDTO dto) { + return ApiResponse.ok(clientDownloadService.create(dto, currentLoginUser())); + } + + @PutMapping("/{id}") + @PreAuthorize("isAuthenticated()") + public ApiResponse update(@PathVariable Long id, @RequestBody ClientDownloadDTO dto) { + return ApiResponse.ok(clientDownloadService.update(id, dto, currentLoginUser())); + } + + @DeleteMapping("/{id}") + @PreAuthorize("isAuthenticated()") + public ApiResponse delete(@PathVariable Long id) { + clientDownloadService.removeClient(id, currentLoginUser()); + return ApiResponse.ok(true); + } + + @PostMapping("/upload") + @PreAuthorize("isAuthenticated()") + public ApiResponse> upload(@RequestParam("platformCode") String platformCode, + @RequestParam("file") MultipartFile file) throws IOException { + return ApiResponse.ok(clientDownloadService.uploadPackage(platformCode, file)); + } + + private LoginUser currentLoginUser() { + return (LoginUser) SecurityContextHolder.getContext().getAuthentication().getPrincipal(); + } +} \ No newline at end of file diff --git a/backend/src/main/java/com/imeeting/controller/biz/ExternalAppController.java b/backend/src/main/java/com/imeeting/controller/biz/ExternalAppController.java new file mode 100644 index 0000000..1588dde --- /dev/null +++ b/backend/src/main/java/com/imeeting/controller/biz/ExternalAppController.java @@ -0,0 +1,74 @@ +package com.imeeting.controller.biz; + +import com.imeeting.dto.biz.ExternalAppDTO; +import com.imeeting.entity.biz.ExternalApp; +import com.imeeting.service.biz.ExternalAppService; +import com.unisbase.common.ApiResponse; +import com.unisbase.security.LoginUser; +import lombok.RequiredArgsConstructor; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.multipart.MultipartFile; + +import java.io.IOException; +import java.util.List; +import java.util.Map; + +@RestController +@RequestMapping("/api/external-apps") +@RequiredArgsConstructor +public class ExternalAppController { + + private final ExternalAppService externalAppService; + + @GetMapping + @PreAuthorize("isAuthenticated()") + public ApiResponse>> list(@RequestParam(value = "appType", required = false) String appType, + @RequestParam(value = "status", required = false) Integer status) { + return ApiResponse.ok(externalAppService.listForAdmin(currentLoginUser(), appType, status)); + } + + @PostMapping + @PreAuthorize("isAuthenticated()") + public ApiResponse create(@RequestBody ExternalAppDTO dto) { + return ApiResponse.ok(externalAppService.create(dto, currentLoginUser())); + } + + @PutMapping("/{id}") + @PreAuthorize("isAuthenticated()") + public ApiResponse update(@PathVariable Long id, @RequestBody ExternalAppDTO dto) { + return ApiResponse.ok(externalAppService.update(id, dto, currentLoginUser())); + } + + @DeleteMapping("/{id}") + @PreAuthorize("isAuthenticated()") + public ApiResponse delete(@PathVariable Long id) { + externalAppService.removeApp(id, currentLoginUser()); + return ApiResponse.ok(true); + } + + @PostMapping("/upload-apk") + @PreAuthorize("isAuthenticated()") + public ApiResponse> uploadApk(@RequestParam("apkFile") MultipartFile apkFile) throws IOException { + return ApiResponse.ok(externalAppService.uploadApk(apkFile)); + } + + @PostMapping("/upload-icon") + @PreAuthorize("isAuthenticated()") + public ApiResponse> uploadIcon(@RequestParam("iconFile") MultipartFile iconFile) throws IOException { + return ApiResponse.ok(externalAppService.uploadIcon(iconFile)); + } + + private LoginUser currentLoginUser() { + return (LoginUser) SecurityContextHolder.getContext().getAuthentication().getPrincipal(); + } +} \ No newline at end of file diff --git a/backend/src/main/java/com/imeeting/dto/android/legacy/LegacyClientDownloadResponse.java b/backend/src/main/java/com/imeeting/dto/android/legacy/LegacyClientDownloadResponse.java new file mode 100644 index 0000000..8a61a54 --- /dev/null +++ b/backend/src/main/java/com/imeeting/dto/android/legacy/LegacyClientDownloadResponse.java @@ -0,0 +1,67 @@ +package com.imeeting.dto.android.legacy; + +import com.fasterxml.jackson.annotation.JsonProperty; +import com.imeeting.entity.biz.ClientDownload; +import lombok.Data; + +@Data +public class LegacyClientDownloadResponse { + private String id; + + @JsonProperty("platform_type") + private String platformType; + + @JsonProperty("platform_name") + private String platformName; + + private String version; + + @JsonProperty("version_code") + private String versionCode; + + @JsonProperty("download_url") + private String downloadUrl; + + @JsonProperty("file_size") + private Long fileSize; + + @JsonProperty("release_notes") + private String releaseNotes; + + @JsonProperty("is_active") + private Integer isActive; + + @JsonProperty("is_latest") + private Integer isLatest; + + @JsonProperty("min_system_version") + private String minSystemVersion; + + @JsonProperty("created_at") + private String createdAt; + + @JsonProperty("updated_at") + private String updatedAt; + + @JsonProperty("created_by") + private Long createdBy; + + public static LegacyClientDownloadResponse from(ClientDownload source) { + LegacyClientDownloadResponse response = new LegacyClientDownloadResponse(); + response.setId(source.getId() == null ? null : String.valueOf(source.getId())); + response.setPlatformType(source.getPlatformType()); + response.setPlatformName(source.getPlatformName()); + response.setVersion(source.getVersion()); + response.setVersionCode(source.getVersionCode() == null ? null : String.valueOf(source.getVersionCode())); + response.setDownloadUrl(source.getDownloadUrl()); + response.setFileSize(source.getFileSize()); + response.setReleaseNotes(source.getReleaseNotes()); + response.setIsActive(Integer.valueOf(1).equals(source.getStatus()) ? 1 : 0); + response.setIsLatest(Integer.valueOf(1).equals(source.getIsLatest()) ? 1 : 0); + response.setMinSystemVersion(source.getMinSystemVersion()); + response.setCreatedAt(source.getCreatedAt() == null ? null : source.getCreatedAt().toString()); + response.setUpdatedAt(source.getUpdatedAt() == null ? null : source.getUpdatedAt().toString()); + response.setCreatedBy(source.getCreatedBy()); + return response; + } +} 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 new file mode 100644 index 0000000..16ecc8b --- /dev/null +++ b/backend/src/main/java/com/imeeting/dto/android/legacy/LegacyExternalAppItemResponse.java @@ -0,0 +1,61 @@ +package com.imeeting.dto.android.legacy; + +import com.fasterxml.jackson.annotation.JsonProperty; +import com.imeeting.entity.biz.ExternalApp; +import lombok.Data; + +import java.util.Map; + +@Data +public class LegacyExternalAppItemResponse { + private Long id; + + @JsonProperty("app_name") + private String appName; + + @JsonProperty("app_type") + private String appType; + + @JsonProperty("app_info") + private Map appInfo; + + @JsonProperty("icon_url") + private String iconUrl; + + private String description; + + @JsonProperty("sort_order") + private Integer sortOrder; + + @JsonProperty("is_active") + private Integer isActive; + + @JsonProperty("created_at") + private String createdAt; + + @JsonProperty("updated_at") + private String updatedAt; + + @JsonProperty("created_by") + private Long createdBy; + + @JsonProperty("creator_username") + private String creatorUsername; + + public static LegacyExternalAppItemResponse from(ExternalApp source, String creatorUsername) { + LegacyExternalAppItemResponse response = new LegacyExternalAppItemResponse(); + response.setId(source.getId()); + response.setAppName(source.getAppName()); + response.setAppType(source.getAppType()); + response.setAppInfo(source.getAppInfo()); + response.setIconUrl(source.getIconUrl()); + response.setDescription(source.getDescription()); + response.setSortOrder(source.getSortOrder()); + response.setIsActive(Integer.valueOf(1).equals(source.getStatus()) ? 1 : 0); + response.setCreatedAt(source.getCreatedAt() == null ? null : source.getCreatedAt().toString()); + response.setUpdatedAt(source.getUpdatedAt() == null ? null : source.getUpdatedAt().toString()); + response.setCreatedBy(source.getCreatedBy()); + response.setCreatorUsername(creatorUsername); + return response; + } +} diff --git a/backend/src/main/java/com/imeeting/dto/android/legacy/LegacyMeetingAccessPasswordRequest.java b/backend/src/main/java/com/imeeting/dto/android/legacy/LegacyMeetingAccessPasswordRequest.java new file mode 100644 index 0000000..207528d --- /dev/null +++ b/backend/src/main/java/com/imeeting/dto/android/legacy/LegacyMeetingAccessPasswordRequest.java @@ -0,0 +1,8 @@ +package com.imeeting.dto.android.legacy; + +import lombok.Data; + +@Data +public class LegacyMeetingAccessPasswordRequest { + private String password; +} diff --git a/backend/src/main/java/com/imeeting/dto/android/legacy/LegacyMeetingAccessPasswordResponse.java b/backend/src/main/java/com/imeeting/dto/android/legacy/LegacyMeetingAccessPasswordResponse.java new file mode 100644 index 0000000..f24e686 --- /dev/null +++ b/backend/src/main/java/com/imeeting/dto/android/legacy/LegacyMeetingAccessPasswordResponse.java @@ -0,0 +1,12 @@ +package com.imeeting.dto.android.legacy; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@NoArgsConstructor +@AllArgsConstructor +public class LegacyMeetingAccessPasswordResponse { + private String password; +} 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 new file mode 100644 index 0000000..3040801 --- /dev/null +++ b/backend/src/main/java/com/imeeting/dto/android/legacy/LegacyMeetingAttendeeResponse.java @@ -0,0 +1,16 @@ +package com.imeeting.dto.android.legacy; + +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@NoArgsConstructor +@AllArgsConstructor +public class LegacyMeetingAttendeeResponse { + @JsonProperty("user_id") + private Long userId; + + private String caption; +} diff --git a/backend/src/main/java/com/imeeting/dto/android/legacy/LegacyMeetingPreviewDataResponse.java b/backend/src/main/java/com/imeeting/dto/android/legacy/LegacyMeetingPreviewDataResponse.java new file mode 100644 index 0000000..9549642 --- /dev/null +++ b/backend/src/main/java/com/imeeting/dto/android/legacy/LegacyMeetingPreviewDataResponse.java @@ -0,0 +1,39 @@ +package com.imeeting.dto.android.legacy; + +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.Data; + +import java.util.List; + +@Data +public class LegacyMeetingPreviewDataResponse { + @JsonProperty("meeting_id") + private Long meetingId; + + private String title; + + @JsonProperty("meeting_time") + private String meetingTime; + + private String summary; + + @JsonProperty("creator_username") + private String creatorUsername; + + @JsonProperty("prompt_id") + private Long promptId; + + @JsonProperty("prompt_name") + private String promptName; + + private List attendees; + + @JsonProperty("attendees_count") + private Integer attendeesCount; + + @JsonProperty("has_password") + private Boolean hasPassword; + + @JsonProperty("processing_status") + private LegacyMeetingProcessingStatusResponse processingStatus; +} diff --git a/backend/src/main/java/com/imeeting/dto/android/legacy/LegacyMeetingPreviewResult.java b/backend/src/main/java/com/imeeting/dto/android/legacy/LegacyMeetingPreviewResult.java new file mode 100644 index 0000000..d2d5aad --- /dev/null +++ b/backend/src/main/java/com/imeeting/dto/android/legacy/LegacyMeetingPreviewResult.java @@ -0,0 +1,12 @@ +package com.imeeting.dto.android.legacy; + +import lombok.AllArgsConstructor; +import lombok.Data; + +@Data +@AllArgsConstructor +public class LegacyMeetingPreviewResult { + private String code; + private String message; + private LegacyMeetingPreviewDataResponse data; +} diff --git a/backend/src/main/java/com/imeeting/dto/android/legacy/LegacyMeetingProcessingStatusResponse.java b/backend/src/main/java/com/imeeting/dto/android/legacy/LegacyMeetingProcessingStatusResponse.java new file mode 100644 index 0000000..e4abd45 --- /dev/null +++ b/backend/src/main/java/com/imeeting/dto/android/legacy/LegacyMeetingProcessingStatusResponse.java @@ -0,0 +1,20 @@ +package com.imeeting.dto.android.legacy; + +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@NoArgsConstructor +@AllArgsConstructor +public class LegacyMeetingProcessingStatusResponse { + @JsonProperty("overall_status") + private String overallStatus; + + @JsonProperty("overall_progress") + private Integer overallProgress; + + @JsonProperty("current_stage") + private String currentStage; +} diff --git a/backend/src/main/java/com/imeeting/dto/biz/ClientDownloadDTO.java b/backend/src/main/java/com/imeeting/dto/biz/ClientDownloadDTO.java new file mode 100644 index 0000000..0926860 --- /dev/null +++ b/backend/src/main/java/com/imeeting/dto/biz/ClientDownloadDTO.java @@ -0,0 +1,19 @@ +package com.imeeting.dto.biz; + +import lombok.Data; + +@Data +public class ClientDownloadDTO { + private String platformType; + private String platformName; + private String platformCode; + private String version; + private Long versionCode; + private String downloadUrl; + private Long fileSize; + private String releaseNotes; + private Integer status; + private Integer isLatest; + private String minSystemVersion; + private String remark; +} \ No newline at end of file diff --git a/backend/src/main/java/com/imeeting/dto/biz/ExternalAppDTO.java b/backend/src/main/java/com/imeeting/dto/biz/ExternalAppDTO.java new file mode 100644 index 0000000..d40272d --- /dev/null +++ b/backend/src/main/java/com/imeeting/dto/biz/ExternalAppDTO.java @@ -0,0 +1,17 @@ +package com.imeeting.dto.biz; + +import lombok.Data; + +import java.util.Map; + +@Data +public class ExternalAppDTO { + private String appName; + private String appType; + private Map appInfo; + private String iconUrl; + private String description; + private Integer sortOrder; + private Integer status; + private String remark; +} \ No newline at end of file diff --git a/backend/src/main/java/com/imeeting/entity/biz/ClientDownload.java b/backend/src/main/java/com/imeeting/entity/biz/ClientDownload.java new file mode 100644 index 0000000..396579d --- /dev/null +++ b/backend/src/main/java/com/imeeting/entity/biz/ClientDownload.java @@ -0,0 +1,38 @@ +package com.imeeting.entity.biz; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import com.unisbase.entity.BaseEntity; +import lombok.Data; +import lombok.EqualsAndHashCode; + +@Data +@EqualsAndHashCode(callSuper = true) +@TableName("biz_client_downloads") +public class ClientDownload extends BaseEntity { + @TableId(value = "id", type = IdType.AUTO) + private Long id; + + private String platformType; + + private String platformName; + + private String platformCode; + + private String version; + + private Long versionCode; + + private String downloadUrl; + + private Long fileSize; + + private String releaseNotes; + + private Integer isLatest; + + private String minSystemVersion; + + private Long createdBy; +} diff --git a/backend/src/main/java/com/imeeting/entity/biz/ExternalApp.java b/backend/src/main/java/com/imeeting/entity/biz/ExternalApp.java new file mode 100644 index 0000000..de381ae --- /dev/null +++ b/backend/src/main/java/com/imeeting/entity/biz/ExternalApp.java @@ -0,0 +1,35 @@ +package com.imeeting.entity.biz; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableField; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import com.baomidou.mybatisplus.extension.handlers.JacksonTypeHandler; +import com.unisbase.entity.BaseEntity; +import lombok.Data; +import lombok.EqualsAndHashCode; + +import java.util.Map; + +@Data +@EqualsAndHashCode(callSuper = true) +@TableName(value = "biz_external_apps", autoResultMap = true) +public class ExternalApp extends BaseEntity { + @TableId(value = "id", type = IdType.AUTO) + private Long id; + + private String appName; + + private String appType; + + @TableField(typeHandler = JacksonTypeHandler.class) + private Map appInfo; + + private String iconUrl; + + private String description; + + private Integer sortOrder; + + private Long createdBy; +} diff --git a/backend/src/main/java/com/imeeting/entity/biz/Meeting.java b/backend/src/main/java/com/imeeting/entity/biz/Meeting.java index 68db852..9ca9237 100644 --- a/backend/src/main/java/com/imeeting/entity/biz/Meeting.java +++ b/backend/src/main/java/com/imeeting/entity/biz/Meeting.java @@ -31,6 +31,8 @@ public class Meeting extends BaseEntity { private String audioSaveMessage; + private String accessPassword; + private Long creatorId; private String creatorName; diff --git a/backend/src/main/java/com/imeeting/mapper/biz/ClientDownloadMapper.java b/backend/src/main/java/com/imeeting/mapper/biz/ClientDownloadMapper.java new file mode 100644 index 0000000..2ae4584 --- /dev/null +++ b/backend/src/main/java/com/imeeting/mapper/biz/ClientDownloadMapper.java @@ -0,0 +1,9 @@ +package com.imeeting.mapper.biz; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.imeeting.entity.biz.ClientDownload; +import org.apache.ibatis.annotations.Mapper; + +@Mapper +public interface ClientDownloadMapper extends BaseMapper { +} diff --git a/backend/src/main/java/com/imeeting/mapper/biz/ExternalAppMapper.java b/backend/src/main/java/com/imeeting/mapper/biz/ExternalAppMapper.java new file mode 100644 index 0000000..442c160 --- /dev/null +++ b/backend/src/main/java/com/imeeting/mapper/biz/ExternalAppMapper.java @@ -0,0 +1,9 @@ +package com.imeeting.mapper.biz; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.imeeting.entity.biz.ExternalApp; +import org.apache.ibatis.annotations.Mapper; + +@Mapper +public interface ExternalAppMapper extends BaseMapper { +} diff --git a/backend/src/main/java/com/imeeting/service/android/legacy/LegacyCatalogAdapterService.java b/backend/src/main/java/com/imeeting/service/android/legacy/LegacyCatalogAdapterService.java new file mode 100644 index 0000000..1d85973 --- /dev/null +++ b/backend/src/main/java/com/imeeting/service/android/legacy/LegacyCatalogAdapterService.java @@ -0,0 +1,12 @@ +package com.imeeting.service.android.legacy; + +import com.imeeting.dto.android.legacy.LegacyClientDownloadResponse; +import com.imeeting.dto.android.legacy.LegacyExternalAppItemResponse; + +import java.util.List; + +public interface LegacyCatalogAdapterService { + LegacyClientDownloadResponse getLatestClient(String platformCode, String platformType, String platformName); + + List listActiveExternalApps(); +} diff --git a/backend/src/main/java/com/imeeting/service/android/legacy/impl/LegacyCatalogAdapterServiceImpl.java b/backend/src/main/java/com/imeeting/service/android/legacy/impl/LegacyCatalogAdapterServiceImpl.java new file mode 100644 index 0000000..ab62523 --- /dev/null +++ b/backend/src/main/java/com/imeeting/service/android/legacy/impl/LegacyCatalogAdapterServiceImpl.java @@ -0,0 +1,79 @@ +package com.imeeting.service.android.legacy.impl; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.imeeting.dto.android.legacy.LegacyClientDownloadResponse; +import com.imeeting.dto.android.legacy.LegacyExternalAppItemResponse; +import com.imeeting.entity.biz.ClientDownload; +import com.imeeting.entity.biz.ExternalApp; +import com.imeeting.mapper.biz.ClientDownloadMapper; +import com.imeeting.mapper.biz.ExternalAppMapper; +import com.imeeting.service.android.legacy.LegacyCatalogAdapterService; +import com.unisbase.entity.SysUser; +import com.unisbase.mapper.SysUserMapper; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.stream.Collectors; + +@Service +@RequiredArgsConstructor +public class LegacyCatalogAdapterServiceImpl implements LegacyCatalogAdapterService { + + private final ClientDownloadMapper clientDownloadMapper; + private final ExternalAppMapper externalAppMapper; + private final SysUserMapper sysUserMapper; + + @Override + public LegacyClientDownloadResponse getLatestClient(String platformCode, String platformType, String platformName) { + LambdaQueryWrapper wrapper = new LambdaQueryWrapper() + .eq(ClientDownload::getStatus, 1) + .eq(ClientDownload::getIsLatest, 1); + + if (platformCode != null && !platformCode.isBlank()) { + wrapper.apply("LOWER(platform_code) = {0}", platformCode.trim().toLowerCase()); + } else if (platformType != null && !platformType.isBlank() && platformName != null && !platformName.isBlank()) { + wrapper.apply("LOWER(platform_type) = {0}", platformType.trim().toLowerCase()) + .apply("LOWER(platform_name) = {0}", platformName.trim().toLowerCase()); + } else { + throw new RuntimeException("请提供 platform_code 参数"); + } + + wrapper.orderByDesc(ClientDownload::getVersionCode) + .orderByDesc(ClientDownload::getId) + .last("LIMIT 1"); + + ClientDownload entity = clientDownloadMapper.selectOne(wrapper); + return entity == null ? null : LegacyClientDownloadResponse.from(entity); + } + + @Override + public List listActiveExternalApps() { + List apps = externalAppMapper.selectList(new LambdaQueryWrapper() + .eq(ExternalApp::getStatus, 1) + .orderByAsc(ExternalApp::getSortOrder) + .orderByDesc(ExternalApp::getCreatedAt)); + if (apps == null || apps.isEmpty()) { + return List.of(); + } + + List creatorIds = apps.stream() + .map(ExternalApp::getCreatedBy) + .filter(Objects::nonNull) + .distinct() + .toList(); + Map creatorNameMap = creatorIds.isEmpty() + ? Map.of() + : sysUserMapper.selectBatchIds(creatorIds).stream().collect(Collectors.toMap( + SysUser::getUserId, + user -> user.getDisplayName() != null ? user.getDisplayName() : user.getUsername(), + (left, right) -> left + )); + + return apps.stream() + .map(app -> LegacyExternalAppItemResponse.from(app, creatorNameMap.get(app.getCreatedBy()))) + .collect(Collectors.toList()); + } +} diff --git a/backend/src/main/java/com/imeeting/service/android/legacy/impl/LegacyMeetingAdapterServiceImpl.java b/backend/src/main/java/com/imeeting/service/android/legacy/impl/LegacyMeetingAdapterServiceImpl.java index f550553..dd91a73 100644 --- a/backend/src/main/java/com/imeeting/service/android/legacy/impl/LegacyMeetingAdapterServiceImpl.java +++ b/backend/src/main/java/com/imeeting/service/android/legacy/impl/LegacyMeetingAdapterServiceImpl.java @@ -68,11 +68,11 @@ public class LegacyMeetingAdapterServiceImpl implements LegacyMeetingAdapterServ @Transactional(rollbackFor = Exception.class) public MeetingVO createMeeting(LegacyMeetingCreateRequest request, LoginUser loginUser) { if (request == null || request.getTitle() == null || request.getTitle().isBlank()) { - throw new RuntimeException("会议标题不能为空"); + throw new RuntimeException("Meeting title cannot be empty"); } LocalDateTime meetingTime = parseMeetingTime(request.getMeetingTime()); if (meetingTime == null) { - throw new RuntimeException("会议时间不能为空"); + throw new RuntimeException("Meeting time cannot be empty"); } Meeting meeting = meetingDomainSupport.initMeeting( @@ -104,22 +104,22 @@ public class LegacyMeetingAdapterServiceImpl implements LegacyMeetingAdapterServ MultipartFile audioFile, LoginUser loginUser) throws IOException { if (meetingId == null) { - throw new RuntimeException("meeting_id 不能为空"); + throw new RuntimeException("meeting_id cannot be empty"); } if (audioFile == null || audioFile.isEmpty()) { - throw new RuntimeException("audio_file 不能为空"); + throw new RuntimeException("audio_file cannot be empty"); } Meeting meeting = meetingAccessService.requireMeeting(meetingId); meetingAccessService.assertCanEditMeeting(meeting, loginUser); if (!forceReplace && meeting.getAudioUrl() != null && !meeting.getAudioUrl().isBlank()) { - throw new RuntimeException("会议已有音频,如需替换请设置 force_replace=true"); + throw new RuntimeException("Meeting already has audio, set force_replace=true to replace it"); } long transcriptCount = transcriptMapper.selectCount(new LambdaQueryWrapper() .eq(MeetingTranscript::getMeetingId, meetingId)); if (transcriptCount > 0) { - throw new RuntimeException("当前会议已有转录内容,旧安卓接口暂不支持替换已生成转录"); + throw new RuntimeException("Current meeting already has transcripts; legacy Android upload does not support replacing generated transcripts"); } if (promptId != null && !promptTemplateService.isTemplateEnabledForUser( promptId, @@ -178,17 +178,17 @@ public class LegacyMeetingAdapterServiceImpl implements LegacyMeetingAdapterServ try { return OffsetDateTime.parse(value).toLocalDateTime(); } catch (DateTimeParseException ignored) { - // 旧安卓可能发送带时区 ISO,也可能发送当前后端本地时间格式。 + // Keep fallback parsing for legacy formats. } try { return LocalDateTime.parse(value); } catch (DateTimeParseException ignored) { - // 继续兼容 yyyy-MM-dd HH:mm:ss。 + // Continue to yyyy-MM-dd HH:mm:ss. } try { return LocalDateTime.parse(value, DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")); } catch (DateTimeParseException ex) { - throw new RuntimeException("会议时间格式不正确"); + throw new RuntimeException("Meeting time format is invalid"); } } @@ -196,8 +196,8 @@ public class LegacyMeetingAdapterServiceImpl implements LegacyMeetingAdapterServ if (rawTags == null) { return null; } - if (rawTags instanceof Iterable items) { - return joinValues(items); + if (rawTags instanceof Iterable) { + return joinValues((Iterable) rawTags); } return String.valueOf(rawTags).trim(); } @@ -231,7 +231,7 @@ public class LegacyMeetingAdapterServiceImpl implements LegacyMeetingAdapterServ .orderByDesc(LlmModel::getTenantId) .last("LIMIT 1")); if (model == null) { - throw new RuntimeException("LLM 模型不存在或未启用: " + modelCode); + throw new RuntimeException("LLM model not found or disabled: " + modelCode); } return model.getId(); } @@ -274,6 +274,7 @@ public class LegacyMeetingAdapterServiceImpl implements LegacyMeetingAdapterServ AiTask task = findLatestTask(meetingId, "SUMMARY"); Map taskConfig = new HashMap<>(); taskConfig.put("summaryModelId", profile.getResolvedSummaryModelId()); + taskConfig.put("promptId", profile.getResolvedPromptId()); PromptTemplate template = promptTemplateService.getById(profile.getResolvedPromptId()); if (template != null) { taskConfig.put("promptContent", template.getPromptContent()); @@ -326,4 +327,4 @@ public class LegacyMeetingAdapterServiceImpl implements LegacyMeetingAdapterServ private String resolveCreatorName(LoginUser loginUser) { return loginUser.getDisplayName() != null ? loginUser.getDisplayName() : loginUser.getUsername(); } -} +} \ No newline at end of file diff --git a/backend/src/main/java/com/imeeting/service/biz/ClientDownloadService.java b/backend/src/main/java/com/imeeting/service/biz/ClientDownloadService.java new file mode 100644 index 0000000..a456aa7 --- /dev/null +++ b/backend/src/main/java/com/imeeting/service/biz/ClientDownloadService.java @@ -0,0 +1,23 @@ +package com.imeeting.service.biz; + +import com.baomidou.mybatisplus.extension.service.IService; +import com.imeeting.dto.biz.ClientDownloadDTO; +import com.imeeting.entity.biz.ClientDownload; +import com.unisbase.security.LoginUser; +import org.springframework.web.multipart.MultipartFile; + +import java.io.IOException; +import java.util.List; +import java.util.Map; + +public interface ClientDownloadService extends IService { + List listForAdmin(LoginUser loginUser, String platformCode, Integer status); + + ClientDownload create(ClientDownloadDTO dto, LoginUser loginUser); + + ClientDownload update(Long id, ClientDownloadDTO dto, LoginUser loginUser); + + void removeClient(Long id, LoginUser loginUser); + + Map uploadPackage(String platformCode, MultipartFile file) throws IOException; +} \ No newline at end of file diff --git a/backend/src/main/java/com/imeeting/service/biz/ExternalAppService.java b/backend/src/main/java/com/imeeting/service/biz/ExternalAppService.java new file mode 100644 index 0000000..9da6791 --- /dev/null +++ b/backend/src/main/java/com/imeeting/service/biz/ExternalAppService.java @@ -0,0 +1,25 @@ +package com.imeeting.service.biz; + +import com.baomidou.mybatisplus.extension.service.IService; +import com.imeeting.dto.biz.ExternalAppDTO; +import com.imeeting.entity.biz.ExternalApp; +import com.unisbase.security.LoginUser; +import org.springframework.web.multipart.MultipartFile; + +import java.io.IOException; +import java.util.List; +import java.util.Map; + +public interface ExternalAppService extends IService { + List> listForAdmin(LoginUser loginUser, String appType, Integer status); + + ExternalApp create(ExternalAppDTO dto, LoginUser loginUser); + + ExternalApp update(Long id, ExternalAppDTO dto, LoginUser loginUser); + + void removeApp(Long id, LoginUser loginUser); + + Map uploadApk(MultipartFile file) throws IOException; + + Map uploadIcon(MultipartFile file) throws IOException; +} \ No newline at end of file diff --git a/backend/src/main/java/com/imeeting/service/biz/impl/ClientDownloadServiceImpl.java b/backend/src/main/java/com/imeeting/service/biz/impl/ClientDownloadServiceImpl.java new file mode 100644 index 0000000..112dc84 --- /dev/null +++ b/backend/src/main/java/com/imeeting/service/biz/impl/ClientDownloadServiceImpl.java @@ -0,0 +1,226 @@ +package com.imeeting.service.biz.impl; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper; +import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl; +import com.imeeting.dto.biz.ClientDownloadDTO; +import com.imeeting.entity.biz.ClientDownload; +import com.imeeting.mapper.biz.ClientDownloadMapper; +import com.imeeting.service.biz.ClientDownloadService; +import com.imeeting.support.ApkManifestParser; +import com.unisbase.security.LoginUser; +import lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.multipart.MultipartFile; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.nio.file.StandardCopyOption; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.UUID; + +@Service +@RequiredArgsConstructor +public class ClientDownloadServiceImpl extends ServiceImpl implements ClientDownloadService { + + @Value("${unisbase.app.upload-path}") + private String uploadPath; + + @Value("${unisbase.app.resource-prefix:/api/static/}") + private String resourcePrefix; + + @Override + public List listForAdmin(LoginUser loginUser, String platformCode, Integer status) { + LambdaQueryWrapper wrapper = new LambdaQueryWrapper() + .eq(ClientDownload::getTenantId, loginUser.getTenantId()) + .orderByAsc(ClientDownload::getPlatformType) + .orderByDesc(ClientDownload::getVersionCode) + .orderByDesc(ClientDownload::getId); + if (platformCode != null && !platformCode.isBlank()) { + wrapper.eq(ClientDownload::getPlatformCode, platformCode.trim()); + } + if (status != null) { + wrapper.eq(ClientDownload::getStatus, status); + } + return this.list(wrapper); + } + + @Override + @Transactional(rollbackFor = Exception.class) + public ClientDownload create(ClientDownloadDTO dto, LoginUser loginUser) { + validate(dto, false); + ClientDownload entity = new ClientDownload(); + applyDto(entity, dto, false); + entity.setTenantId(loginUser.getTenantId()); + entity.setCreatedBy(loginUser.getUserId()); + if (entity.getStatus() == null) { + entity.setStatus(1); + } + if (entity.getIsLatest() == null) { + entity.setIsLatest(0); + } + clearLatestFlagIfNeeded(entity.getTenantId(), entity.getPlatformCode(), entity.getIsLatest(), null); + this.save(entity); + return entity; + } + + @Override + @Transactional(rollbackFor = Exception.class) + public ClientDownload update(Long id, ClientDownloadDTO dto, LoginUser loginUser) { + ClientDownload entity = requireOwned(id, loginUser); + applyDto(entity, dto, true); + clearLatestFlagIfNeeded(entity.getTenantId(), entity.getPlatformCode(), entity.getIsLatest(), entity.getId()); + this.updateById(entity); + return entity; + } + + @Override + @Transactional(rollbackFor = Exception.class) + public void removeClient(Long id, LoginUser loginUser) { + ClientDownload entity = requireOwned(id, loginUser); + this.removeById(entity.getId()); + } + + @Override + public Map uploadPackage(String platformCode, MultipartFile file) throws IOException { + if (platformCode == null || platformCode.isBlank()) { + throw new RuntimeException("platformCode is required"); + } + if (file == null || file.isEmpty()) { + throw new RuntimeException("file is required"); + } + String cleanCode = platformCode.trim().toLowerCase(); + String originalName = sanitizeFileName(file.getOriginalFilename()); + String basePath = uploadPath.endsWith("/") ? uploadPath : uploadPath + "/"; + Path targetDir = Paths.get(basePath, "clients", cleanCode); + Files.createDirectories(targetDir); + Path target = targetDir.resolve(UUID.randomUUID() + "_" + originalName); + Files.copy(file.getInputStream(), target, StandardCopyOption.REPLACE_EXISTING); + + ApkManifestParser.ApkInfo apkInfo = null; + if (originalName.toLowerCase().endsWith(".apk")) { + apkInfo = ApkManifestParser.parse(target.toString()); + } + + Map result = new HashMap<>(); + result.put("fileName", originalName); + result.put("fileSize", file.getSize()); + result.put("downloadUrl", buildResourceUrl("clients/" + cleanCode + "/" + target.getFileName())); + result.put("platformCode", cleanCode); + result.put("packageName", apkInfo == null ? null : apkInfo.getPackageName()); + result.put("versionName", apkInfo == null ? null : apkInfo.getVersionName()); + result.put("versionCode", apkInfo == null ? null : apkInfo.getVersionCode()); + result.put("appName", apkInfo == null ? null : apkInfo.getAppName()); + return result; + } + + private void validate(ClientDownloadDTO dto, boolean partial) { + if (dto == null) { + throw new RuntimeException("payload is required"); + } + if (!partial) { + if (isBlank(dto.getPlatformCode())) { + throw new RuntimeException("platformCode is required"); + } + if (isBlank(dto.getVersion())) { + throw new RuntimeException("version is required"); + } + if (isBlank(dto.getDownloadUrl())) { + throw new RuntimeException("downloadUrl is required"); + } + } + } + + private void applyDto(ClientDownload entity, ClientDownloadDTO dto, boolean partial) { + if (!partial || dto.getPlatformType() != null) { + entity.setPlatformType(trimToNull(dto.getPlatformType())); + } + if (!partial || dto.getPlatformName() != null) { + entity.setPlatformName(trimToNull(dto.getPlatformName())); + } + if (!partial || dto.getPlatformCode() != null) { + entity.setPlatformCode(trimToNull(dto.getPlatformCode())); + } + if (!partial || dto.getVersion() != null) { + entity.setVersion(trimToNull(dto.getVersion())); + } + if (!partial || dto.getVersionCode() != null) { + entity.setVersionCode(dto.getVersionCode()); + } + if (!partial || dto.getDownloadUrl() != null) { + entity.setDownloadUrl(trimToNull(dto.getDownloadUrl())); + } + if (!partial || dto.getFileSize() != null) { + entity.setFileSize(dto.getFileSize()); + } + if (!partial || dto.getReleaseNotes() != null) { + entity.setReleaseNotes(trimToNull(dto.getReleaseNotes())); + } + if (!partial || dto.getStatus() != null) { + entity.setStatus(dto.getStatus()); + } + if (!partial || dto.getIsLatest() != null) { + entity.setIsLatest(dto.getIsLatest()); + } + if (!partial || dto.getMinSystemVersion() != null) { + entity.setMinSystemVersion(trimToNull(dto.getMinSystemVersion())); + } + } + + private void clearLatestFlagIfNeeded(Long tenantId, String platformCode, Integer isLatest, Long excludeId) { + if (!Integer.valueOf(1).equals(isLatest) || platformCode == null || platformCode.isBlank()) { + return; + } + LambdaUpdateWrapper update = new LambdaUpdateWrapper() + .eq(ClientDownload::getTenantId, tenantId) + .eq(ClientDownload::getPlatformCode, platformCode) + .set(ClientDownload::getIsLatest, 0); + if (excludeId != null) { + update.ne(ClientDownload::getId, excludeId); + } + this.update(update); + } + + private ClientDownload requireOwned(Long id, LoginUser loginUser) { + ClientDownload entity = this.getById(id); + if (entity == null || !Objects.equals(entity.getTenantId(), loginUser.getTenantId())) { + throw new RuntimeException("Client version not found"); + } + return entity; + } + + private String sanitizeFileName(String fileName) { + String value = fileName == null || fileName.isBlank() ? "package.bin" : fileName; + value = value.replace('\\', '/'); + int slashIndex = value.lastIndexOf('/'); + if (slashIndex >= 0) { + value = value.substring(slashIndex + 1); + } + value = value.replaceAll("[^A-Za-z0-9._-]", "_"); + return value.isBlank() ? "package.bin" : value; + } + + private String buildResourceUrl(String relativePath) { + String prefix = resourcePrefix.endsWith("/") ? resourcePrefix : resourcePrefix + "/"; + return prefix + relativePath.replace('\\', '/'); + } + + private boolean isBlank(String value) { + return value == null || value.isBlank(); + } + + private String trimToNull(String value) { + if (value == null) { + return null; + } + String trimmed = value.trim(); + return trimmed.isEmpty() ? null : trimmed; + } +} \ No newline at end of file diff --git a/backend/src/main/java/com/imeeting/service/biz/impl/ExternalAppServiceImpl.java b/backend/src/main/java/com/imeeting/service/biz/impl/ExternalAppServiceImpl.java new file mode 100644 index 0000000..dc7b696 --- /dev/null +++ b/backend/src/main/java/com/imeeting/service/biz/impl/ExternalAppServiceImpl.java @@ -0,0 +1,243 @@ +package com.imeeting.service.biz.impl; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl; +import com.imeeting.dto.biz.ExternalAppDTO; +import com.imeeting.entity.biz.ExternalApp; +import com.imeeting.mapper.biz.ExternalAppMapper; +import com.imeeting.service.biz.ExternalAppService; +import com.imeeting.support.ApkManifestParser; +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.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.multipart.MultipartFile; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.nio.file.StandardCopyOption; +import java.security.MessageDigest; +import java.util.HashMap; +import java.util.HexFormat; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.UUID; +import java.util.stream.Collectors; + +@Service +@RequiredArgsConstructor +public class ExternalAppServiceImpl extends ServiceImpl implements ExternalAppService { + + private final SysUserMapper sysUserMapper; + + @Value("${unisbase.app.upload-path}") + private String uploadPath; + + @Value("${unisbase.app.resource-prefix:/api/static/}") + private String resourcePrefix; + + @Override + public List> listForAdmin(LoginUser loginUser, String appType, Integer status) { + LambdaQueryWrapper wrapper = new LambdaQueryWrapper() + .eq(ExternalApp::getTenantId, loginUser.getTenantId()) + .orderByAsc(ExternalApp::getSortOrder) + .orderByDesc(ExternalApp::getId); + if (appType != null && !appType.isBlank()) { + wrapper.eq(ExternalApp::getAppType, appType.trim()); + } + if (status != null) { + wrapper.eq(ExternalApp::getStatus, status); + } + List apps = this.list(wrapper); + if (apps.isEmpty()) { + return List.of(); + } + Map creatorNames = sysUserMapper.selectBatchIds( + apps.stream().map(ExternalApp::getCreatedBy).filter(Objects::nonNull).distinct().toList()) + .stream() + .collect(Collectors.toMap( + SysUser::getUserId, + user -> user.getDisplayName() != null ? user.getDisplayName() : user.getUsername(), + (left, right) -> left + )); + return apps.stream().map(app -> { + Map item = new LinkedHashMap<>(); + item.put("id", app.getId()); + item.put("tenantId", app.getTenantId()); + item.put("appName", app.getAppName()); + item.put("appType", app.getAppType()); + item.put("appInfo", app.getAppInfo()); + item.put("iconUrl", app.getIconUrl()); + item.put("description", app.getDescription()); + item.put("sortOrder", app.getSortOrder()); + item.put("status", app.getStatus()); + item.put("createdAt", app.getCreatedAt()); + item.put("updatedAt", app.getUpdatedAt()); + item.put("createdBy", app.getCreatedBy()); + item.put("creatorUsername", creatorNames.get(app.getCreatedBy())); + return item; + }).toList(); + } + + @Override + @Transactional(rollbackFor = Exception.class) + public ExternalApp create(ExternalAppDTO dto, LoginUser loginUser) { + validate(dto, false); + ExternalApp entity = new ExternalApp(); + applyDto(entity, dto, false); + entity.setTenantId(loginUser.getTenantId()); + entity.setCreatedBy(loginUser.getUserId()); + if (entity.getStatus() == null) { + entity.setStatus(1); + } + this.save(entity); + return entity; + } + + @Override + @Transactional(rollbackFor = Exception.class) + public ExternalApp update(Long id, ExternalAppDTO dto, LoginUser loginUser) { + ExternalApp entity = requireOwned(id, loginUser); + applyDto(entity, dto, true); + this.updateById(entity); + return entity; + } + + @Override + @Transactional(rollbackFor = Exception.class) + public void removeApp(Long id, LoginUser loginUser) { + ExternalApp entity = requireOwned(id, loginUser); + this.removeById(entity.getId()); + } + + @Override + public Map uploadApk(MultipartFile file) throws IOException { + StoredFile storedFile = storeFile("external-apps/apk", file); + ApkManifestParser.ApkInfo apkInfo = ApkManifestParser.parse(storedFile.path().toString()); + Map result = new HashMap<>(); + result.put("apkUrl", storedFile.url()); + result.put("apkSize", storedFile.size()); + result.put("apkMd5", md5Hex(storedFile.path())); + result.put("appName", apkInfo == null ? null : apkInfo.getAppName()); + result.put("packageName", apkInfo == null ? null : apkInfo.getPackageName()); + result.put("versionName", apkInfo == null ? null : apkInfo.getVersionName()); + result.put("versionCode", apkInfo == null ? null : apkInfo.getVersionCode()); + return result; + } + + @Override + public Map uploadIcon(MultipartFile file) throws IOException { + StoredFile storedFile = storeFile("external-apps/icon", file); + Map result = new HashMap<>(); + result.put("iconUrl", storedFile.url()); + result.put("fileSize", storedFile.size()); + return result; + } + + private void validate(ExternalAppDTO dto, boolean partial) { + if (dto == null) { + throw new RuntimeException("payload is required"); + } + if (!partial) { + if (isBlank(dto.getAppName())) { + throw new RuntimeException("appName is required"); + } + if (isBlank(dto.getAppType())) { + throw new RuntimeException("appType is required"); + } + } + } + + private void applyDto(ExternalApp entity, ExternalAppDTO dto, boolean partial) { + if (!partial || dto.getAppName() != null) { + entity.setAppName(trimToNull(dto.getAppName())); + } + if (!partial || dto.getAppType() != null) { + entity.setAppType(trimToNull(dto.getAppType())); + } + if (!partial || dto.getAppInfo() != null) { + entity.setAppInfo(dto.getAppInfo()); + } + if (!partial || dto.getIconUrl() != null) { + entity.setIconUrl(trimToNull(dto.getIconUrl())); + } + if (!partial || dto.getDescription() != null) { + entity.setDescription(trimToNull(dto.getDescription())); + } + if (!partial || dto.getSortOrder() != null) { + entity.setSortOrder(dto.getSortOrder()); + } + if (!partial || dto.getStatus() != null) { + entity.setStatus(dto.getStatus()); + } + } + + private ExternalApp requireOwned(Long id, LoginUser loginUser) { + ExternalApp entity = this.getById(id); + if (entity == null || !Objects.equals(entity.getTenantId(), loginUser.getTenantId())) { + throw new RuntimeException("External app not found"); + } + return entity; + } + + private StoredFile storeFile(String folder, MultipartFile file) throws IOException { + if (file == null || file.isEmpty()) { + throw new RuntimeException("file is required"); + } + String originalName = sanitizeFileName(file.getOriginalFilename()); + String basePath = uploadPath.endsWith("/") ? uploadPath : uploadPath + "/"; + Path targetDir = Paths.get(basePath, folder); + Files.createDirectories(targetDir); + Path target = targetDir.resolve(UUID.randomUUID() + "_" + originalName); + Files.copy(file.getInputStream(), target, StandardCopyOption.REPLACE_EXISTING); + return new StoredFile(target, buildResourceUrl(folder + "/" + target.getFileName()), file.getSize()); + } + + private String sanitizeFileName(String fileName) { + String value = fileName == null || fileName.isBlank() ? "file.bin" : fileName; + value = value.replace('\\', '/'); + int slashIndex = value.lastIndexOf('/'); + if (slashIndex >= 0) { + value = value.substring(slashIndex + 1); + } + value = value.replaceAll("[^A-Za-z0-9._-]", "_"); + return value.isBlank() ? "file.bin" : value; + } + + private String buildResourceUrl(String relativePath) { + String prefix = resourcePrefix.endsWith("/") ? resourcePrefix : resourcePrefix + "/"; + return prefix + relativePath.replace('\\', '/'); + } + + private String md5Hex(Path file) { + try { + MessageDigest digest = MessageDigest.getInstance("MD5"); + digest.update(Files.readAllBytes(file)); + return HexFormat.of().formatHex(digest.digest()); + } catch (Exception ex) { + return null; + } + } + + private boolean isBlank(String value) { + return value == null || value.isBlank(); + } + + private String trimToNull(String value) { + if (value == null) { + return null; + } + String trimmed = value.trim(); + return trimmed.isEmpty() ? null : trimmed; + } + + private record StoredFile(Path path, String url, long size) { + } +} \ No newline at end of file diff --git a/backend/src/main/java/com/imeeting/service/biz/impl/MeetingDomainSupport.java b/backend/src/main/java/com/imeeting/service/biz/impl/MeetingDomainSupport.java index 9c03297..a210bd8 100644 --- a/backend/src/main/java/com/imeeting/service/biz/impl/MeetingDomainSupport.java +++ b/backend/src/main/java/com/imeeting/service/biz/impl/MeetingDomainSupport.java @@ -77,6 +77,7 @@ public class MeetingDomainSupport { Map sumConfig = new HashMap<>(); sumConfig.put("summaryModelId", summaryModelId); if (promptId != null) { + sumConfig.put("promptId", promptId); PromptTemplate template = promptTemplateService.getById(promptId); if (template != null) { sumConfig.put("promptContent", template.getPromptContent()); @@ -275,3 +276,4 @@ public class MeetingDomainSupport { private record AudioRelocationPlan(Path sourcePath, Path targetPath, Path backupPath, String relocatedUrl) { } } + diff --git a/backend/src/main/java/com/imeeting/support/ApkManifestParser.java b/backend/src/main/java/com/imeeting/support/ApkManifestParser.java new file mode 100644 index 0000000..b648139 --- /dev/null +++ b/backend/src/main/java/com/imeeting/support/ApkManifestParser.java @@ -0,0 +1,307 @@ +package com.imeeting.support; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.nio.ByteBuffer; +import java.nio.ByteOrder; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.List; +import java.util.zip.ZipEntry; +import java.util.zip.ZipFile; + +public final class ApkManifestParser { + + private static final int CHUNK_XML = 0x0003; + private static final int CHUNK_STRING_POOL = 0x0001; + private static final int CHUNK_XML_RESOURCE_MAP = 0x0180; + private static final int CHUNK_XML_START_ELEMENT = 0x0102; + private static final int TYPE_STRING = 0x03; + private static final int TYPE_INT_DEC = 0x10; + private static final int TYPE_INT_HEX = 0x11; + + private ApkManifestParser() { + } + + public static ApkInfo parse(String apkPath) throws IOException { + try (ZipFile zipFile = new ZipFile(apkPath)) { + ZipEntry entry = zipFile.getEntry("AndroidManifest.xml"); + if (entry == null) { + return null; + } + try (InputStream inputStream = zipFile.getInputStream(entry)) { + byte[] bytes = readAllBytes(inputStream); + return parse(bytes); + } + } + } + + public static ApkInfo parse(byte[] manifestBytes) { + if (manifestBytes == null || manifestBytes.length < 8) { + return null; + } + + ByteBuffer buffer = ByteBuffer.wrap(manifestBytes).order(ByteOrder.LITTLE_ENDIAN); + int xmlType = Short.toUnsignedInt(buffer.getShort(0)); + if (xmlType != CHUNK_XML) { + return null; + } + + StringPool stringPool = null; + long[] resourceIds = new long[0]; + ApkInfo info = new ApkInfo(); + int offset = 8; + + while (offset + 8 <= manifestBytes.length) { + int chunkType = Short.toUnsignedInt(buffer.getShort(offset)); + int headerSize = Short.toUnsignedInt(buffer.getShort(offset + 2)); + int chunkSize = buffer.getInt(offset + 4); + if (chunkSize <= 0 || offset + chunkSize > manifestBytes.length) { + break; + } + + if (chunkType == CHUNK_STRING_POOL) { + stringPool = parseStringPool(buffer, offset); + } else if (chunkType == CHUNK_XML_RESOURCE_MAP) { + resourceIds = parseResourceMap(buffer, offset, chunkSize); + } else if (chunkType == CHUNK_XML_START_ELEMENT && stringPool != null) { + StartElement element = parseStartElement(buffer, offset, stringPool, resourceIds); + if (element != null) { + if ("manifest".equals(element.name())) { + String packageName = element.attributeValue("package"); + if (packageName != null) { + info.setPackageName(packageName); + } + String versionName = element.attributeValue("versionName"); + if (versionName != null) { + info.setVersionName(versionName); + } + String versionCodeValue = element.attributeValue("versionCode"); + if (versionCodeValue != null) { + try { + info.setVersionCode(Long.parseLong(versionCodeValue)); + } catch (NumberFormatException ignored) { + // ignore malformed versionCode + } + } + } else if ("application".equals(element.name()) && info.getAppName() == null) { + String label = element.attributeValue("label"); + if (label != null && !label.isBlank() && !label.startsWith("@")) { + info.setAppName(label); + } + } + } + } + + offset += chunkSize; + } + + return info.isEmpty() ? null : info; + } + + private static StringPool parseStringPool(ByteBuffer buffer, int offset) { + int stringCount = buffer.getInt(offset + 8); + int styleCount = buffer.getInt(offset + 12); + int flags = buffer.getInt(offset + 16); + int stringsStart = buffer.getInt(offset + 20); + int stylesStart = buffer.getInt(offset + 24); + boolean utf8 = (flags & 0x00000100) != 0; + + int[] stringOffsets = new int[stringCount]; + int stringsOffset = offset + Short.toUnsignedInt(buffer.getShort(offset + 2)) + styleCount * 4; + for (int i = 0; i < stringCount; i++) { + stringOffsets[i] = buffer.getInt(offset + 28 + (i * 4)); + } + + List strings = new ArrayList<>(stringCount); + for (int i = 0; i < stringCount; i++) { + int stringOffset = offset + stringsStart + stringOffsets[i]; + strings.add(utf8 ? readUtf8String(buffer, stringOffset) : readUtf16String(buffer, stringOffset)); + } + return new StringPool(strings); + } + + private static long[] parseResourceMap(ByteBuffer buffer, int offset, int chunkSize) { + int count = (chunkSize - 8) / 4; + long[] ids = new long[count]; + int cursor = offset + 8; + for (int i = 0; i < count; i++) { + ids[i] = Integer.toUnsignedLong(buffer.getInt(cursor)); + cursor += 4; + } + return ids; + } + + private static StartElement parseStartElement(ByteBuffer buffer, int offset, StringPool stringPool, long[] resourceIds) { + int nameIndex = buffer.getInt(offset + 20); + int attributeStart = Short.toUnsignedInt(buffer.getShort(offset + 24)); + int attributeSize = Short.toUnsignedInt(buffer.getShort(offset + 26)); + int attributeCount = Short.toUnsignedInt(buffer.getShort(offset + 28)); + if (attributeSize <= 0) { + return null; + } + + String tagName = stringPool.get(nameIndex); + List attributes = new ArrayList<>(attributeCount); + int cursor = offset + 16 + attributeStart; + for (int i = 0; i < attributeCount; i++) { + int attrNameIndex = buffer.getInt(cursor + 4); + int rawValueIndex = buffer.getInt(cursor + 8); + int typedValueDataType = Byte.toUnsignedInt(buffer.get(cursor + 15)); + int typedValueData = buffer.getInt(cursor + 16); + + String attrName = stringPool.get(attrNameIndex); + if (attrName == null && attrNameIndex >= 0 && attrNameIndex < resourceIds.length) { + attrName = mapAndroidAttrName(resourceIds[attrNameIndex]); + } + String rawValue = rawValueIndex >= 0 ? stringPool.get(rawValueIndex) : null; + String resolvedValue = rawValue != null ? rawValue : resolveTypedValue(stringPool, typedValueDataType, typedValueData); + if (attrName != null) { + attributes.add(new Attribute(attrName, resolvedValue)); + } + cursor += attributeSize; + } + return new StartElement(tagName, attributes); + } + + private static String resolveTypedValue(StringPool stringPool, int dataType, int data) { + if (dataType == TYPE_STRING) { + return stringPool.get(data); + } + if (dataType == TYPE_INT_DEC || dataType == TYPE_INT_HEX) { + return String.valueOf(data); + } + if (dataType == 0x12) { + return data != 0 ? "true" : "false"; + } + if (dataType == 0x01) { + return "@" + Integer.toHexString(data); + } + return null; + } + + private static String mapAndroidAttrName(long resourceId) { + return switch ((int) resourceId) { + case 0x01010003 -> "label"; + case 0x0101021b -> "versionCode"; + case 0x0101021c -> "versionName"; + default -> null; + }; + } + + private static String readUtf8String(ByteBuffer buffer, int offset) { + int[] skipResult = skipUtf8Length(buffer, offset); + int charLen = skipResult[0]; + int byteLen = skipResult[1]; + int byteOffset = skipResult[2]; + byte[] bytes = new byte[Math.max(byteLen, 0)]; + for (int i = 0; i < byteLen; i++) { + bytes[i] = buffer.get(byteOffset + i); + } + return new String(bytes, StandardCharsets.UTF_8); + } + + private static int[] skipUtf8Length(ByteBuffer buffer, int offset) { + int cursor = offset; + int charLen = Byte.toUnsignedInt(buffer.get(cursor++)); + if ((charLen & 0x80) != 0) { + charLen = ((charLen & 0x7F) << 8) | Byte.toUnsignedInt(buffer.get(cursor++)); + } + int byteLen = Byte.toUnsignedInt(buffer.get(cursor++)); + if ((byteLen & 0x80) != 0) { + byteLen = ((byteLen & 0x7F) << 8) | Byte.toUnsignedInt(buffer.get(cursor++)); + } + return new int[] { charLen, byteLen, cursor }; + } + + private static String readUtf16String(ByteBuffer buffer, int offset) { + int length = Short.toUnsignedInt(buffer.getShort(offset)); + int cursor = offset + 2; + if ((length & 0x8000) != 0) { + length = ((length & 0x7FFF) << 16) | Short.toUnsignedInt(buffer.getShort(cursor)); + cursor += 2; + } + byte[] bytes = new byte[length * 2]; + for (int i = 0; i < bytes.length; i++) { + bytes[i] = buffer.get(cursor + i); + } + return new String(bytes, StandardCharsets.UTF_16LE); + } + + private static byte[] readAllBytes(InputStream inputStream) throws IOException { + ByteArrayOutputStream output = new ByteArrayOutputStream(); + byte[] buffer = new byte[4096]; + int len; + while ((len = inputStream.read(buffer)) != -1) { + output.write(buffer, 0, len); + } + return output.toByteArray(); + } + + private record StringPool(List strings) { + String get(int index) { + if (index < 0 || index >= strings.size()) { + return null; + } + return strings.get(index); + } + } + + private record Attribute(String name, String value) { + } + + private record StartElement(String name, List attributes) { + String attributeValue(String attrName) { + return attributes.stream() + .filter(attribute -> attrName.equals(attribute.name())) + .map(Attribute::value) + .filter(value -> value != null && !value.isBlank()) + .findFirst() + .orElse(null); + } + } + + public static final class ApkInfo { + private String appName; + private String packageName; + private String versionName; + private Long versionCode; + + public String getAppName() { + return appName; + } + + public void setAppName(String appName) { + this.appName = appName; + } + + public String getPackageName() { + return packageName; + } + + public void setPackageName(String packageName) { + this.packageName = packageName; + } + + public String getVersionName() { + return versionName; + } + + public void setVersionName(String versionName) { + this.versionName = versionName; + } + + public Long getVersionCode() { + return versionCode; + } + + public void setVersionCode(Long versionCode) { + this.versionCode = versionCode; + } + + boolean isEmpty() { + return appName == null && packageName == null && versionName == null && versionCode == null; + } + } +} \ No newline at end of file diff --git a/backend/src/test/java/com/imeeting/config/ApiResponseSuccessCodeAdviceTest.java b/backend/src/test/java/com/imeeting/config/ApiResponseSuccessCodeAdviceTest.java new file mode 100644 index 0000000..7df9373 --- /dev/null +++ b/backend/src/test/java/com/imeeting/config/ApiResponseSuccessCodeAdviceTest.java @@ -0,0 +1,29 @@ +package com.imeeting.config; + +import com.unisbase.common.ApiResponse; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +class ApiResponseSuccessCodeAdviceTest { + + @Test + void shouldNormalizeLegacySuccessCodeBeforeWritingBody() { + ApiResponse body = new ApiResponse<>("0", "OK", "payload"); + ApiResponseSuccessCodeAdvice advice = new ApiResponseSuccessCodeAdvice(); + + advice.beforeBodyWrite(body, null, null, null, null, null); + + assertEquals("200", body.getCode()); + } + + @Test + void shouldKeepNonSuccessCodesUnchanged() { + ApiResponse body = new ApiResponse<>("500", "error", null); + ApiResponseSuccessCodeAdvice advice = new ApiResponseSuccessCodeAdvice(); + + advice.beforeBodyWrite(body, null, null, null, null, null); + + assertEquals("500", body.getCode()); + } +} 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 new file mode 100644 index 0000000..62a1966 --- /dev/null +++ b/backend/src/test/java/com/imeeting/controller/android/legacy/LegacyMeetingControllerTest.java @@ -0,0 +1,138 @@ +package com.imeeting.controller.android.legacy; + +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.biz.MeetingVO; +import com.imeeting.entity.biz.AiTask; +import com.imeeting.entity.biz.Meeting; +import com.imeeting.entity.biz.PromptTemplate; +import com.imeeting.mapper.biz.MeetingTranscriptMapper; +import com.imeeting.service.android.legacy.LegacyMeetingAdapterService; +import com.imeeting.service.biz.AiTaskService; +import com.imeeting.service.biz.MeetingAccessService; +import com.imeeting.service.biz.MeetingCommandService; +import com.imeeting.service.biz.MeetingQueryService; +import com.imeeting.service.biz.MeetingService; +import com.imeeting.service.biz.PromptTemplateService; +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.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.context.SecurityContextHolder; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.Map; +import java.util.Set; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +class LegacyMeetingControllerTest { + + @AfterEach + void tearDown() { + SecurityContextHolder.clearContext(); + } + + @Test + void previewDataShouldReturnCompletedLegacyPayload() { + MeetingService meetingService = mock(MeetingService.class); + AiTaskService aiTaskService = mock(AiTaskService.class); + PromptTemplateService promptTemplateService = mock(PromptTemplateService.class); + MeetingQueryService meetingQueryService = mock(MeetingQueryService.class); + MeetingTranscriptMapper transcriptMapper = mock(MeetingTranscriptMapper.class); + SysUserMapper sysUserMapper = mock(SysUserMapper.class); + + Meeting meeting = new Meeting(); + meeting.setId(8L); + meeting.setTitle("项目复盘"); + meeting.setMeetingTime(LocalDateTime.of(2026, 4, 13, 10, 0)); + meeting.setCreatorName("发起人"); + meeting.setParticipants("2,3"); + meeting.setAccessPassword("123456"); + meeting.setStatus(3); + when(meetingService.getById(8L)).thenReturn(meeting); + + AiTask summaryTask = new AiTask(); + summaryTask.setTaskConfig(Map.of("promptId", 5L)); + when(aiTaskService.getOne(any())).thenReturn(null, summaryTask); + + MeetingVO detail = new MeetingVO(); + detail.setSummaryContent("## 总结\n已完成"); + when(meetingQueryService.getDetail(8L)).thenReturn(detail); + + PromptTemplate template = new PromptTemplate(); + template.setTemplateName("标准纪要"); + when(promptTemplateService.getById(5L)).thenReturn(template); + + SysUser user2 = new SysUser(); + user2.setUserId(2L); + user2.setDisplayName("张三"); + SysUser user3 = new SysUser(); + user3.setUserId(3L); + user3.setDisplayName("李四"); + when(sysUserMapper.selectBatchIds(List.of(2L, 3L))).thenReturn(List.of(user2, user3)); + + LegacyMeetingController controller = new LegacyMeetingController( + mock(LegacyMeetingAdapterService.class), + meetingQueryService, + mock(MeetingAccessService.class), + mock(MeetingCommandService.class), + meetingService, + aiTaskService, + promptTemplateService, + transcriptMapper, + sysUserMapper + ); + + LegacyApiResponse response = controller.previewData(8L); + + assertEquals("200", response.getCode()); + assertNotNull(response.getData()); + } + + @Test + void updateAccessPasswordShouldOnlyAllowCreator() { + MeetingAccessService meetingAccessService = mock(MeetingAccessService.class); + MeetingService meetingService = mock(MeetingService.class); + + Meeting meeting = new Meeting(); + meeting.setId(9L); + meeting.setCreatorId(7L); + when(meetingAccessService.requireMeeting(9L)).thenReturn(meeting); + + SecurityContextHolder.getContext().setAuthentication( + new UsernamePasswordAuthenticationToken(new LoginUser(7L, 1L, "creator", false, false, Set.of()), null) + ); + + LegacyMeetingController controller = new LegacyMeetingController( + mock(LegacyMeetingAdapterService.class), + mock(MeetingQueryService.class), + meetingAccessService, + mock(MeetingCommandService.class), + meetingService, + mock(AiTaskService.class), + mock(PromptTemplateService.class), + mock(MeetingTranscriptMapper.class), + mock(SysUserMapper.class) + ); + + LegacyMeetingAccessPasswordRequest request = new LegacyMeetingAccessPasswordRequest(); + request.setPassword(" "); + + LegacyApiResponse response = controller.updateAccessPassword(9L, request); + + assertEquals("200", response.getCode()); + assertEquals(null, response.getData().getPassword()); + assertEquals(null, meeting.getAccessPassword()); + verify(meetingService).updateById(meeting); + } +} diff --git a/backend/src/test/java/com/imeeting/dto/android/legacy/LegacyApiResponseTest.java b/backend/src/test/java/com/imeeting/dto/android/legacy/LegacyApiResponseTest.java new file mode 100644 index 0000000..1e85da8 --- /dev/null +++ b/backend/src/test/java/com/imeeting/dto/android/legacy/LegacyApiResponseTest.java @@ -0,0 +1,18 @@ +package com.imeeting.dto.android.legacy; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; + +class LegacyApiResponseTest { + + @Test + void shouldReturnLegacySuccessCodeAndMessageField() { + LegacyApiResponse response = LegacyApiResponse.ok("上传成功", null); + + assertEquals("200", response.getCode()); + assertEquals("上传成功", response.getMessage()); + assertNull(response.getData()); + } +} diff --git a/backend/src/test/java/com/imeeting/grpc/realtime/RealtimeMeetingGrpcServiceTest.java b/backend/src/test/java/com/imeeting/grpc/realtime/RealtimeMeetingGrpcServiceTest.java new file mode 100644 index 0000000..08088a6 --- /dev/null +++ b/backend/src/test/java/com/imeeting/grpc/realtime/RealtimeMeetingGrpcServiceTest.java @@ -0,0 +1,110 @@ +package com.imeeting.grpc.realtime; + +import com.imeeting.dto.android.AndroidAuthContext; +import com.imeeting.grpc.common.ClientAuth; +import com.imeeting.service.android.AndroidAuthService; +import com.imeeting.service.realtime.RealtimeMeetingGrpcSessionService; +import io.grpc.stub.StreamObserver; +import org.junit.jupiter.api.Test; + +import java.util.ArrayList; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertEquals; +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.anyBoolean; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.isNull; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +class RealtimeMeetingGrpcServiceTest { + + @Test + void streamMeetingAudioShouldReturnErrorEventWhenAuthenticationFails() { + AndroidAuthService authService = mock(AndroidAuthService.class); + RealtimeMeetingGrpcSessionService sessionService = mock(RealtimeMeetingGrpcSessionService.class); + RealtimeMeetingGrpcService service = new RealtimeMeetingGrpcService(authService, sessionService); + CapturingObserver responseObserver = new CapturingObserver(); + + when(authService.authenticateGrpc(any(ClientAuth.class), isNull())) + .thenThrow(new RuntimeException("Android deviceId is required")); + + StreamObserver requestObserver = service.streamMeetingAudio(responseObserver); + RealtimeClientPacket openPacket = RealtimeClientPacket.newBuilder() + .setRequestId("rt-open-001") + .setOpen(OpenMeetingStream.newBuilder().setMeetingId(1001L).build()) + .build(); + + assertDoesNotThrow(() -> requestObserver.onNext(openPacket)); + + assertEquals(1, responseObserver.values.size()); + RealtimeServerPacket errorPacket = responseObserver.values.get(0); + assertEquals("rt-open-001", errorPacket.getRequestId()); + assertTrue(errorPacket.hasError()); + assertEquals("REALTIME_GRPC_ERROR", errorPacket.getError().getCode()); + assertEquals("Android deviceId is required", errorPacket.getError().getMessage()); + assertTrue(responseObserver.completed); + assertNull(responseObserver.error); + verify(sessionService, never()).closeStream(anyString(), anyString(), anyBoolean()); + } + + @Test + void streamMeetingAudioShouldReturnErrorEventWhenSessionOpenFails() { + AndroidAuthService authService = mock(AndroidAuthService.class); + RealtimeMeetingGrpcSessionService sessionService = mock(RealtimeMeetingGrpcSessionService.class); + RealtimeMeetingGrpcService service = new RealtimeMeetingGrpcService(authService, sessionService); + CapturingObserver responseObserver = new CapturingObserver(); + AndroidAuthContext authContext = new AndroidAuthContext(); + authContext.setDeviceId("android-test-001"); + + when(authService.authenticateGrpc(any(ClientAuth.class), isNull())).thenReturn(authContext); + when(sessionService.openStream(1001L, "", authContext, responseObserver)) + .thenThrow(new RuntimeException("ASR model WebSocket is not configured")); + + StreamObserver requestObserver = service.streamMeetingAudio(responseObserver); + RealtimeClientPacket openPacket = RealtimeClientPacket.newBuilder() + .setRequestId("rt-open-002") + .setAuth(ClientAuth.newBuilder().setDeviceId("android-test-001").build()) + .setOpen(OpenMeetingStream.newBuilder().setMeetingId(1001L).build()) + .build(); + + assertDoesNotThrow(() -> requestObserver.onNext(openPacket)); + + assertEquals(1, responseObserver.values.size()); + RealtimeServerPacket errorPacket = responseObserver.values.get(0); + assertEquals("rt-open-002", errorPacket.getRequestId()); + assertTrue(errorPacket.hasError()); + assertEquals("REALTIME_GRPC_ERROR", errorPacket.getError().getCode()); + assertEquals("ASR model WebSocket is not configured", errorPacket.getError().getMessage()); + assertTrue(responseObserver.completed); + assertNull(responseObserver.error); + verify(sessionService, never()).closeStream(anyString(), anyString(), anyBoolean()); + } + + private static final class CapturingObserver implements StreamObserver { + private final List values = new ArrayList<>(); + private Throwable error; + private boolean completed; + + @Override + public void onNext(RealtimeServerPacket value) { + values.add(value); + } + + @Override + public void onError(Throwable t) { + error = t; + } + + @Override + public void onCompleted() { + completed = true; + } + } +} \ No newline at end of file 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 new file mode 100644 index 0000000..a6d94c1 --- /dev/null +++ b/backend/src/test/java/com/imeeting/service/android/legacy/LegacyCatalogAdapterServiceImplTest.java @@ -0,0 +1,96 @@ +package com.imeeting.service.android.legacy; + +import com.imeeting.dto.android.legacy.LegacyClientDownloadResponse; +import com.imeeting.dto.android.legacy.LegacyExternalAppItemResponse; +import com.imeeting.entity.biz.ClientDownload; +import com.imeeting.entity.biz.ExternalApp; +import com.imeeting.mapper.biz.ClientDownloadMapper; +import com.imeeting.mapper.biz.ExternalAppMapper; +import com.imeeting.service.android.legacy.impl.LegacyCatalogAdapterServiceImpl; +import com.unisbase.entity.SysUser; +import com.unisbase.mapper.SysUserMapper; +import org.junit.jupiter.api.Test; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +class LegacyCatalogAdapterServiceImplTest { + + @Test + void getLatestClientShouldMapLegacyFields() { + ClientDownloadMapper clientDownloadMapper = mock(ClientDownloadMapper.class); + ClientDownload entity = new ClientDownload(); + entity.setId(7L); + entity.setPlatformType("terminal"); + entity.setPlatformName("android"); + entity.setVersion("1.0.0"); + entity.setVersionCode(1000L); + entity.setDownloadUrl("https://download.example/app.apk"); + entity.setFileSize(1024L); + entity.setReleaseNotes("首发版本"); + entity.setStatus(1); + entity.setIsLatest(1); + entity.setMinSystemVersion("Android 5.0"); + entity.setCreatedAt(LocalDateTime.of(2026, 4, 13, 12, 0)); + entity.setUpdatedAt(LocalDateTime.of(2026, 4, 13, 12, 30)); + entity.setCreatedBy(1L); + when(clientDownloadMapper.selectOne(any())).thenReturn(entity); + + LegacyCatalogAdapterServiceImpl service = new LegacyCatalogAdapterServiceImpl( + clientDownloadMapper, + mock(ExternalAppMapper.class), + mock(SysUserMapper.class) + ); + + LegacyClientDownloadResponse response = service.getLatestClient("android", null, null); + + assertEquals("7", response.getId()); + assertEquals("android", response.getPlatformName()); + assertEquals("1000", response.getVersionCode()); + assertEquals(1, response.getIsActive()); + assertEquals(1, response.getIsLatest()); + } + + @Test + void listActiveExternalAppsShouldResolveCreatorUsername() { + ExternalAppMapper externalAppMapper = mock(ExternalAppMapper.class); + SysUserMapper sysUserMapper = mock(SysUserMapper.class); + + ExternalApp app = new ExternalApp(); + app.setId(101L); + app.setAppName("会议看板"); + app.setAppType("web"); + app.setAppInfo(Map.of("web_url", "https://board.example.com")); + app.setIconUrl("https://img.example.com/icon.png"); + app.setDescription("首页看板"); + app.setSortOrder(1); + app.setStatus(1); + app.setCreatedAt(LocalDateTime.of(2026, 4, 13, 9, 0)); + app.setUpdatedAt(LocalDateTime.of(2026, 4, 13, 9, 30)); + app.setCreatedBy(9L); + when(externalAppMapper.selectList(any())).thenReturn(List.of(app)); + + SysUser creator = new SysUser(); + creator.setUserId(9L); + creator.setDisplayName("管理员"); + when(sysUserMapper.selectBatchIds(List.of(9L))).thenReturn(List.of(creator)); + + LegacyCatalogAdapterServiceImpl service = new LegacyCatalogAdapterServiceImpl( + mock(ClientDownloadMapper.class), + externalAppMapper, + sysUserMapper + ); + + List responses = service.listActiveExternalApps(); + + assertEquals(1, responses.size()); + assertEquals("管理员", responses.get(0).getCreatorUsername()); + assertEquals(1, responses.get(0).getIsActive()); + } +} diff --git a/backend/src/test/java/com/imeeting/service/android/legacy/LegacyMeetingAdapterServiceImplTest.java b/backend/src/test/java/com/imeeting/service/android/legacy/LegacyMeetingAdapterServiceImplTest.java new file mode 100644 index 0000000..2aaa5b1 --- /dev/null +++ b/backend/src/test/java/com/imeeting/service/android/legacy/LegacyMeetingAdapterServiceImplTest.java @@ -0,0 +1,86 @@ +package com.imeeting.service.android.legacy; + +import com.imeeting.dto.android.legacy.LegacyMeetingCreateRequest; +import com.imeeting.dto.biz.MeetingVO; +import com.imeeting.entity.biz.Meeting; +import com.imeeting.mapper.biz.LlmModelMapper; +import com.imeeting.mapper.biz.MeetingTranscriptMapper; +import com.imeeting.service.android.legacy.impl.LegacyMeetingAdapterServiceImpl; +import com.imeeting.service.biz.AiTaskService; +import com.imeeting.service.biz.MeetingAccessService; +import com.imeeting.service.biz.MeetingRuntimeProfileResolver; +import com.imeeting.service.biz.MeetingService; +import com.imeeting.service.biz.PromptTemplateService; +import com.imeeting.service.biz.impl.MeetingDomainSupport; +import com.unisbase.security.LoginUser; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.Set; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.ArgumentMatchers.isNull; +import static org.mockito.Mockito.doAnswer; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +class LegacyMeetingAdapterServiceImplTest { + + @Test + void createMeetingShouldIgnoreLegacyUserIdAndParseOffsetTime() { + MeetingService meetingService = mock(MeetingService.class); + MeetingDomainSupport meetingDomainSupport = mock(MeetingDomainSupport.class); + Meeting meeting = new Meeting(); + meeting.setId(9001L); + + when(meetingDomainSupport.initMeeting( + eq("旧端会议"), + eq(LocalDateTime.of(2025, 11, 17, 9, 30)), + eq("2,3"), + eq("alpha,beta"), + isNull(), + eq(10L), + eq(7L), + eq("creator"), + eq(7L), + eq("creator"), + eq(0) + )).thenReturn(meeting); + doAnswer(invocation -> { + MeetingVO vo = invocation.getArgument(1); + vo.setId(9001L); + return null; + }).when(meetingDomainSupport).fillMeetingVO(any(Meeting.class), any(MeetingVO.class), eq(false)); + + LegacyMeetingAdapterServiceImpl service = new LegacyMeetingAdapterServiceImpl( + meetingService, + mock(MeetingAccessService.class), + meetingDomainSupport, + mock(MeetingRuntimeProfileResolver.class), + mock(PromptTemplateService.class), + mock(AiTaskService.class), + mock(MeetingTranscriptMapper.class), + mock(LlmModelMapper.class) + ); + + LegacyMeetingCreateRequest request = new LegacyMeetingCreateRequest(); + request.setUserId(999L); + request.setTitle("旧端会议"); + request.setMeetingTime("2025-11-17T09:30:00Z"); + request.setTags(List.of("alpha", "beta")); + request.setAttendeeIds(List.of(2L, 3L)); + + LoginUser loginUser = new LoginUser(7L, 10L, "creator", false, false, Set.of()); + MeetingVO result = service.createMeeting(request, loginUser); + + assertEquals(9001L, result.getId()); + ArgumentCaptor captor = ArgumentCaptor.forClass(Meeting.class); + verify(meetingService).save(captor.capture()); + assertEquals(9001L, captor.getValue().getId()); + } +} 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 new file mode 100644 index 0000000..689c07c --- /dev/null +++ b/backend/src/test/java/com/imeeting/service/biz/impl/AiTaskServiceImplTest.java @@ -0,0 +1,72 @@ +//package com.imeeting.service.biz.impl; +// +//import com.baomidou.mybatisplus.core.mapper.BaseMapper; +//import com.imeeting.entity.biz.AiTask; +//import com.imeeting.entity.biz.Meeting; +//import com.imeeting.mapper.biz.AiTaskMapper; +//import com.imeeting.mapper.biz.MeetingMapper; +//import com.imeeting.mapper.biz.MeetingTranscriptMapper; +//import com.imeeting.support.TaskSecurityContextRunner; +//import com.imeeting.service.biz.AiModelService; +//import com.imeeting.service.biz.HotWordService; +//import com.imeeting.service.biz.MeetingSummaryFileService; +//import com.unisbase.mapper.SysUserMapper; +//import org.junit.jupiter.api.Test; +//import org.springframework.data.redis.core.StringRedisTemplate; +//import org.springframework.test.util.ReflectionTestUtils; +// +//import java.util.HashMap; +//import java.util.Map; +// +//import static org.junit.jupiter.api.Assertions.assertEquals; +//import static org.junit.jupiter.api.Assertions.assertThrows; +//import static org.mockito.ArgumentMatchers.any; +//import static org.mockito.Mockito.mock; +//import static org.mockito.Mockito.when; +// +//class AiTaskServiceImplTest { +// +// @Test +// void processSummaryTaskShouldFailWhenSummaryModelMissing() { +// MeetingMapper meetingMapper = mock(MeetingMapper.class); +// MeetingTranscriptMapper transcriptMapper = mock(MeetingTranscriptMapper.class); +// AiModelService aiModelService = mock(AiModelService.class); +// AiTaskMapper aiTaskMapper = mock(AiTaskMapper.class); +// +// when(aiTaskMapper.updateById(any(AiTask.class))).thenReturn(1); +// when(aiModelService.getModelById(99L, "LLM")).thenReturn(null); +// +// AiTaskServiceImpl service = new AiTaskServiceImpl( +// meetingMapper, +// transcriptMapper, +// aiModelService, +// new com.fasterxml.jackson.databind.ObjectMapper(), +// mock(SysUserMapper.class), +// mock(HotWordService.class), +// mock(StringRedisTemplate.class), +// mock(MeetingSummaryFileService.class), +// mock(TaskSecurityContextRunner.class) +// ); +// ReflectionTestUtils.setField(service, BaseMapper.class, "baseMapper", aiTaskMapper); +// +// Meeting meeting = new Meeting(); +// meeting.setId(1L); +// +// AiTask summaryTask = new AiTask(); +// summaryTask.setId(10L); +// summaryTask.setMeetingId(1L); +// summaryTask.setTaskType("SUMMARY"); +// summaryTask.setStatus(0); +// Map taskConfig = new HashMap<>(); +// taskConfig.put("summaryModelId", 99L); +// summaryTask.setTaskConfig(taskConfig); +// +// RuntimeException exception = assertThrows(RuntimeException.class, () -> +// ReflectionTestUtils.invokeMethod(service, "processSummaryTask", meeting, "speaker: test", summaryTask) +// ); +// +// assertEquals("LLM模型配置不存在", exception.getMessage()); +// assertEquals(3, summaryTask.getStatus()); +// assertEquals("LLM model not found: 99", summaryTask.getErrorMsg()); +// } +//} diff --git a/backend/src/test/java/com/imeeting/service/biz/impl/MeetingAuthorizationServiceImplTest.java b/backend/src/test/java/com/imeeting/service/biz/impl/MeetingAuthorizationServiceImplTest.java new file mode 100644 index 0000000..d1d9318 --- /dev/null +++ b/backend/src/test/java/com/imeeting/service/biz/impl/MeetingAuthorizationServiceImplTest.java @@ -0,0 +1,74 @@ +package com.imeeting.service.biz.impl; + +import com.imeeting.dto.android.AndroidAuthContext; +import com.imeeting.entity.biz.Meeting; +import com.imeeting.service.biz.MeetingAccessService; +import com.unisbase.security.LoginUser; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; + +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; + +class MeetingAuthorizationServiceImplTest { + + @Test + void anonymousAuthShouldAllowAndroidMeetingOperations() { + MeetingAccessService meetingAccessService = mock(MeetingAccessService.class); + MeetingAuthorizationServiceImpl service = new MeetingAuthorizationServiceImpl(meetingAccessService); + AndroidAuthContext authContext = new AndroidAuthContext(); + authContext.setAnonymous(true); + authContext.setDeviceId("android-test-001"); + Meeting meeting = new Meeting(); + meeting.setId(1001L); + + assertDoesNotThrow(() -> service.assertCanCreateMeeting(authContext)); + assertDoesNotThrow(() -> service.assertCanViewMeeting(meeting, authContext)); + assertDoesNotThrow(() -> service.assertCanManageRealtimeMeeting(meeting, authContext)); + + verifyNoInteractions(meetingAccessService); + } + + @Test + void authenticatedManageShouldDelegateToMeetingAccessService() { + MeetingAccessService meetingAccessService = mock(MeetingAccessService.class); + MeetingAuthorizationServiceImpl service = new MeetingAuthorizationServiceImpl(meetingAccessService); + AndroidAuthContext authContext = new AndroidAuthContext(); + authContext.setAnonymous(false); + authContext.setUserId(7L); + authContext.setTenantId(1L); + authContext.setUsername("alice"); + authContext.setDisplayName("Alice"); + authContext.setPlatformAdmin(false); + authContext.setTenantAdmin(true); + Meeting meeting = new Meeting(); + meeting.setId(1002L); + + service.assertCanManageRealtimeMeeting(meeting, authContext); + + ArgumentCaptor loginUserCaptor = ArgumentCaptor.forClass(LoginUser.class); + verify(meetingAccessService).assertCanManageRealtimeMeeting(meeting, loginUserCaptor.capture()); + assertEquals(7L, loginUserCaptor.getValue().getUserId()); + assertEquals(1L, loginUserCaptor.getValue().getTenantId()); + assertEquals("alice", loginUserCaptor.getValue().getUsername()); + assertEquals("Alice", loginUserCaptor.getValue().getDisplayName()); + assertEquals(Boolean.TRUE, loginUserCaptor.getValue().getIsTenantAdmin()); + } + + @Test + void missingIdentityShouldStillBeRejectedWhenNotAnonymous() { + MeetingAccessService meetingAccessService = mock(MeetingAccessService.class); + MeetingAuthorizationServiceImpl service = new MeetingAuthorizationServiceImpl(meetingAccessService); + AndroidAuthContext authContext = new AndroidAuthContext(); + authContext.setAnonymous(false); + + RuntimeException exception = assertThrows(RuntimeException.class, () -> service.assertCanCreateMeeting(authContext)); + + assertEquals("安卓用户未登录或认证无效", exception.getMessage()); + verifyNoInteractions(meetingAccessService); + } +} diff --git a/backend/src/test/java/com/imeeting/service/biz/impl/RealtimeMeetingSessionStateServiceImplTest.java b/backend/src/test/java/com/imeeting/service/biz/impl/RealtimeMeetingSessionStateServiceImplTest.java new file mode 100644 index 0000000..9067c59 --- /dev/null +++ b/backend/src/test/java/com/imeeting/service/biz/impl/RealtimeMeetingSessionStateServiceImplTest.java @@ -0,0 +1,157 @@ +package com.imeeting.service.biz.impl; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.imeeting.common.RedisKeys; +import com.imeeting.dto.biz.RealtimeMeetingSessionState; +import com.imeeting.dto.biz.RealtimeMeetingSessionStatusVO; +import com.imeeting.entity.biz.Meeting; +import com.imeeting.mapper.biz.MeetingMapper; +import com.imeeting.mapper.biz.MeetingTranscriptMapper; +import org.junit.jupiter.api.Test; +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.data.redis.core.ValueOperations; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +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.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +class RealtimeMeetingSessionStateServiceImplTest { + + private final ObjectMapper objectMapper = new ObjectMapper(); + + @Test + void getStatusShouldUseCompletedMeetingWhenRedisActiveIsStale() throws Exception { + Long meetingId = 68L; + StringRedisTemplate redisTemplate = mock(StringRedisTemplate.class); + ValueOperations valueOperations = mock(ValueOperations.class); + MeetingTranscriptMapper transcriptMapper = mock(MeetingTranscriptMapper.class); + MeetingMapper meetingMapper = mock(MeetingMapper.class); + when(redisTemplate.opsForValue()).thenReturn(valueOperations); + when(valueOperations.get(RedisKeys.realtimeMeetingSessionStateKey(meetingId))) + .thenReturn(objectMapper.writeValueAsString(activeState(meetingId))); + when(meetingMapper.selectById(meetingId)).thenReturn(meeting(meetingId, 3)); + when(transcriptMapper.selectCount(any())).thenReturn(1L); + + RealtimeMeetingSessionStateServiceImpl service = newService(redisTemplate, transcriptMapper, meetingMapper); + + RealtimeMeetingSessionStatusVO status = service.getStatus(meetingId); + + assertEquals("COMPLETED", status.getStatus()); + assertFalse(Boolean.TRUE.equals(status.getActiveConnection())); + assertFalse(Boolean.TRUE.equals(status.getCanResume())); + verify(redisTemplate).delete(RedisKeys.realtimeMeetingSessionStateKey(meetingId)); + verify(redisTemplate).delete(RedisKeys.realtimeMeetingResumeTimeoutKey(meetingId)); + verify(redisTemplate).delete(RedisKeys.realtimeMeetingEmptyTimeoutKey(meetingId)); + verify(redisTemplate).delete(RedisKeys.realtimeMeetingEventSeqKey(meetingId)); + } + + @Test + void getStatusShouldUseTerminalMeetingWhenDatabaseFailed() throws Exception { + Long meetingId = 69L; + StringRedisTemplate redisTemplate = mock(StringRedisTemplate.class); + ValueOperations valueOperations = mock(ValueOperations.class); + MeetingTranscriptMapper transcriptMapper = mock(MeetingTranscriptMapper.class); + MeetingMapper meetingMapper = mock(MeetingMapper.class); + when(redisTemplate.opsForValue()).thenReturn(valueOperations); + when(valueOperations.get(RedisKeys.realtimeMeetingSessionStateKey(meetingId))) + .thenReturn(objectMapper.writeValueAsString(activeState(meetingId))); + when(meetingMapper.selectById(meetingId)).thenReturn(meeting(meetingId, 4)); + when(transcriptMapper.selectCount(any())).thenReturn(0L); + + RealtimeMeetingSessionStateServiceImpl service = newService(redisTemplate, transcriptMapper, meetingMapper); + + RealtimeMeetingSessionStatusVO status = service.getStatus(meetingId); + + assertEquals("COMPLETED", status.getStatus()); + assertFalse(Boolean.TRUE.equals(status.getActiveConnection())); + } + + @Test + void getStatusShouldNotClearWhenDatabaseIsCompleting() throws Exception { + Long meetingId = 70L; + StringRedisTemplate redisTemplate = mock(StringRedisTemplate.class); + ValueOperations valueOperations = mock(ValueOperations.class); + MeetingTranscriptMapper transcriptMapper = mock(MeetingTranscriptMapper.class); + MeetingMapper meetingMapper = mock(MeetingMapper.class); + when(redisTemplate.opsForValue()).thenReturn(valueOperations); + when(valueOperations.get(RedisKeys.realtimeMeetingSessionStateKey(meetingId))) + .thenReturn(objectMapper.writeValueAsString(activeState(meetingId))); + when(meetingMapper.selectById(meetingId)).thenReturn(meeting(meetingId, 2)); + + RealtimeMeetingSessionStateServiceImpl service = newService(redisTemplate, transcriptMapper, meetingMapper); + + RealtimeMeetingSessionStatusVO status = service.getStatus(meetingId); + + assertEquals("ACTIVE", status.getStatus()); + assertTrue(Boolean.TRUE.equals(status.getActiveConnection())); + verify(redisTemplate, never()).delete(RedisKeys.realtimeMeetingEventSeqKey(meetingId)); + verify(redisTemplate, never()).delete(RedisKeys.realtimeMeetingSessionStateKey(meetingId)); + } + + @Test + void getStatusShouldPreserveActiveWhenDatabaseNotTerminal() throws Exception { + Long meetingId = 71L; + StringRedisTemplate redisTemplate = mock(StringRedisTemplate.class); + ValueOperations valueOperations = mock(ValueOperations.class); + MeetingTranscriptMapper transcriptMapper = mock(MeetingTranscriptMapper.class); + MeetingMapper meetingMapper = mock(MeetingMapper.class); + when(redisTemplate.opsForValue()).thenReturn(valueOperations); + when(valueOperations.get(RedisKeys.realtimeMeetingSessionStateKey(meetingId))) + .thenReturn(objectMapper.writeValueAsString(activeState(meetingId))); + when(meetingMapper.selectById(meetingId)).thenReturn(meeting(meetingId, 1)); + + RealtimeMeetingSessionStateServiceImpl service = newService(redisTemplate, transcriptMapper, meetingMapper); + + RealtimeMeetingSessionStatusVO status = service.getStatus(meetingId); + + assertEquals("ACTIVE", status.getStatus()); + assertTrue(Boolean.TRUE.equals(status.getActiveConnection())); + verify(redisTemplate, never()).delete(RedisKeys.realtimeMeetingEventSeqKey(meetingId)); + verify(redisTemplate, never()).delete(RedisKeys.realtimeMeetingSessionStateKey(meetingId)); + } + + @Test + void clearShouldDeleteRealtimeEventSeqKey() { + Long meetingId = 72L; + StringRedisTemplate redisTemplate = mock(StringRedisTemplate.class); + MeetingTranscriptMapper transcriptMapper = mock(MeetingTranscriptMapper.class); + MeetingMapper meetingMapper = mock(MeetingMapper.class); + + RealtimeMeetingSessionStateServiceImpl service = newService(redisTemplate, transcriptMapper, meetingMapper); + + service.clear(meetingId); + + verify(redisTemplate).delete(RedisKeys.realtimeMeetingSessionStateKey(meetingId)); + verify(redisTemplate).delete(RedisKeys.realtimeMeetingResumeTimeoutKey(meetingId)); + verify(redisTemplate).delete(RedisKeys.realtimeMeetingEmptyTimeoutKey(meetingId)); + verify(redisTemplate).delete(RedisKeys.realtimeMeetingEventSeqKey(meetingId)); + } + + private RealtimeMeetingSessionStateServiceImpl newService(StringRedisTemplate redisTemplate, + MeetingTranscriptMapper transcriptMapper, + MeetingMapper meetingMapper) { + return new RealtimeMeetingSessionStateServiceImpl(redisTemplate, objectMapper, transcriptMapper, meetingMapper); + } + + private RealtimeMeetingSessionState activeState(Long meetingId) { + RealtimeMeetingSessionState state = new RealtimeMeetingSessionState(); + state.setMeetingId(meetingId); + state.setStatus("ACTIVE"); + state.setHasTranscript(true); + state.setActiveConnectionId("conn-1"); + state.setUpdatedAt(System.currentTimeMillis()); + return state; + } + + private Meeting meeting(Long meetingId, int status) { + Meeting meeting = new Meeting(); + meeting.setId(meetingId); + meeting.setStatus(status); + return meeting; + } +} \ No newline at end of file diff --git a/backend/src/test/java/com/imeeting/service/realtime/impl/RealtimeMeetingGrpcSessionServiceImplTest.java b/backend/src/test/java/com/imeeting/service/realtime/impl/RealtimeMeetingGrpcSessionServiceImplTest.java new file mode 100644 index 0000000..6d0d546 --- /dev/null +++ b/backend/src/test/java/com/imeeting/service/realtime/impl/RealtimeMeetingGrpcSessionServiceImplTest.java @@ -0,0 +1,123 @@ +package com.imeeting.service.realtime.impl; + +import com.imeeting.config.grpc.GrpcServerProperties; +import com.imeeting.dto.android.AndroidAuthContext; +import com.imeeting.dto.android.AndroidRealtimeGrpcSessionData; +import com.imeeting.grpc.realtime.RealtimeServerPacket; +import com.imeeting.grpc.realtime.TranscriptEvent; +import com.imeeting.service.biz.MeetingCommandService; +import com.imeeting.service.biz.RealtimeMeetingSessionStateService; +import com.imeeting.service.realtime.AndroidRealtimeSessionTicketService; +import com.imeeting.service.realtime.AsrUpstreamBridgeService; +import com.imeeting.service.realtime.RealtimeMeetingAudioStorageService; +import io.grpc.stub.StreamObserver; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.data.redis.core.ValueOperations; + +import java.util.ArrayList; +import java.util.List; + +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.ArgumentMatchers.anyLong; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +class RealtimeMeetingGrpcSessionServiceImplTest { + + @Test + void openStreamShouldOnlyForwardAndPersistFinalTranscript() { + AndroidRealtimeSessionTicketService ticketService = mock(AndroidRealtimeSessionTicketService.class); + AsrUpstreamBridgeService asrUpstreamBridgeService = mock(AsrUpstreamBridgeService.class); + MeetingCommandService meetingCommandService = mock(MeetingCommandService.class); + RealtimeMeetingSessionStateService sessionStateService = mock(RealtimeMeetingSessionStateService.class); + RealtimeMeetingAudioStorageService audioStorageService = mock(RealtimeMeetingAudioStorageService.class); + StringRedisTemplate redisTemplate = mock(StringRedisTemplate.class); + @SuppressWarnings("unchecked") + ValueOperations valueOperations = mock(ValueOperations.class); + when(redisTemplate.opsForValue()).thenReturn(valueOperations); + + GrpcServerProperties grpcServerProperties = new GrpcServerProperties(); + AndroidRealtimeGrpcSessionData sessionData = new AndroidRealtimeGrpcSessionData(); + sessionData.setMeetingId(1001L); + sessionData.setDeviceId("android-test-001"); + sessionData.setTargetWsUrl("ws://localhost/mock-asr"); + when(ticketService.prepareSessionData(eq(1001L), any(AndroidAuthContext.class))).thenReturn(sessionData); + + AsrUpstreamBridgeService.AsrUpstreamSession upstreamSession = mock(AsrUpstreamBridgeService.AsrUpstreamSession.class); + ArgumentCaptor listenerCaptor = + ArgumentCaptor.forClass(AsrUpstreamBridgeService.AsrUpstreamEventListener.class); + when(asrUpstreamBridgeService.openSession(eq(sessionData), anyString(), listenerCaptor.capture())) + .thenReturn(upstreamSession); + + RealtimeMeetingGrpcSessionServiceImpl service = new RealtimeMeetingGrpcSessionServiceImpl( + ticketService, + asrUpstreamBridgeService, + meetingCommandService, + sessionStateService, + audioStorageService, + redisTemplate, + grpcServerProperties + ); + + CapturingObserver responseObserver = new CapturingObserver(); + AndroidAuthContext authContext = new AndroidAuthContext(); + authContext.setDeviceId("android-test-001"); + + service.openStream(1001L, "", authContext, responseObserver); + + AsrUpstreamBridgeService.AsrUpstreamEventListener listener = listenerCaptor.getValue(); + listener.onTranscript(new AsrUpstreamBridgeService.AsrTranscriptResult( + false, + "partial transcript", + "spk-1", + "Speaker 1", + 100, + 500 + )); + + assertTrue(responseObserver.values.isEmpty()); + verify(meetingCommandService, never()).saveRealtimeTranscriptSnapshot(anyLong(), any(), eq(false)); + + listener.onTranscript(new AsrUpstreamBridgeService.AsrTranscriptResult( + true, + "final transcript", + "spk-1", + "Speaker 1", + 100, + 800 + )); + + assertEquals(1, responseObserver.values.size()); + RealtimeServerPacket packet = responseObserver.values.get(0); + assertTrue(packet.hasTranscript()); + assertEquals(TranscriptEvent.TranscriptType.FINAL, packet.getTranscript().getType()); + assertEquals("final transcript", packet.getTranscript().getText()); + verify(meetingCommandService, times(1)).saveRealtimeTranscriptSnapshot(eq(1001L), any(), eq(true)); + } + + private static final class CapturingObserver implements StreamObserver { + private final List values = new ArrayList<>(); + + @Override + public void onNext(RealtimeServerPacket value) { + values.add(value); + } + + @Override + public void onError(Throwable t) { + } + + @Override + public void onCompleted() { + } + } +} diff --git a/frontend/src/api/business/client.ts b/frontend/src/api/business/client.ts new file mode 100644 index 0000000..e071a89 --- /dev/null +++ b/frontend/src/api/business/client.ts @@ -0,0 +1,77 @@ +import http from "../http"; + +export interface ClientDownloadVO { + id: number; + tenantId?: number; + platformType?: string; + platformName?: string; + platformCode: string; + version: string; + versionCode?: number; + downloadUrl: string; + fileSize?: number; + releaseNotes?: string; + status: number; + isLatest?: number; + minSystemVersion?: string; + createdBy?: number; + createdAt?: string; + updatedAt?: string; + remark?: string; +} + +export interface ClientDownloadDTO { + platformType?: string; + platformName?: string; + platformCode: string; + version: string; + versionCode?: number; + downloadUrl: string; + fileSize?: number; + releaseNotes?: string; + status: number; + isLatest?: number; + minSystemVersion?: string; + remark?: string; +} + +export interface ClientUploadResult { + fileName: string; + fileSize: number; + downloadUrl: string; + platformCode: string; + packageName?: string | null; + versionName?: string | null; + versionCode?: number | null; + appName?: string | null; +} + +export async function listClientDownloads(params?: { platformCode?: string; status?: number; page?: number; size?: number }) { + const resp = await http.get("/api/clients", { params }); + return resp.data.data as { clients: ClientDownloadVO[]; total: number; page: number; size: number }; +} + +export async function createClientDownload(payload: ClientDownloadDTO) { + const resp = await http.post("/api/clients", payload); + return resp.data.data as ClientDownloadVO; +} + +export async function updateClientDownload(id: number, payload: Partial) { + const resp = await http.put(`/api/clients/${id}`, payload); + return resp.data.data as ClientDownloadVO; +} + +export async function deleteClientDownload(id: number) { + const resp = await http.delete(`/api/clients/${id}`); + return resp.data.data as boolean; +} + +export async function uploadClientPackage(platformCode: string, file: File) { + const formData = new FormData(); + formData.append("platformCode", platformCode); + formData.append("file", file); + const resp = await http.post("/api/clients/upload", formData, { + headers: { "Content-Type": "multipart/form-data" }, + }); + return resp.data.data as ClientUploadResult; +} \ No newline at end of file diff --git a/frontend/src/api/business/externalApp.ts b/frontend/src/api/business/externalApp.ts new file mode 100644 index 0000000..e56c98c --- /dev/null +++ b/frontend/src/api/business/externalApp.ts @@ -0,0 +1,82 @@ +import http from "../http"; + +export interface ExternalAppVO { + id: number; + tenantId?: number; + appName: string; + appType: "native" | "web"; + appInfo?: Record; + iconUrl?: string; + description?: string; + sortOrder?: number; + status: number; + createdBy?: number; + creatorUsername?: string; + createdAt?: string; + updatedAt?: string; + remark?: string; +} + +export interface ExternalAppDTO { + appName: string; + appType: "native" | "web"; + appInfo?: Record; + iconUrl?: string; + description?: string; + sortOrder?: number; + status: number; + remark?: string; +} + +export interface ExternalAppApkUploadResult { + apkUrl?: string; + apkSize?: number; + apkMd5?: string; + appName?: string | null; + packageName?: string | null; + versionName?: string | null; + versionCode?: string | null; +} + +export interface ExternalAppIconUploadResult { + iconUrl?: string; + fileSize?: number; +} + +export async function listExternalApps(params?: { appType?: string; status?: number }) { + const resp = await http.get("/api/external-apps", { params }); + return resp.data.data as ExternalAppVO[]; +} + +export async function createExternalApp(payload: ExternalAppDTO) { + const resp = await http.post("/api/external-apps", payload); + return resp.data.data as ExternalAppVO; +} + +export async function updateExternalApp(id: number, payload: Partial) { + const resp = await http.put(`/api/external-apps/${id}`, payload); + return resp.data.data as ExternalAppVO; +} + +export async function deleteExternalApp(id: number) { + const resp = await http.delete(`/api/external-apps/${id}`); + return resp.data.data as boolean; +} + +export async function uploadExternalAppApk(file: File) { + const formData = new FormData(); + formData.append("apkFile", file); + const resp = await http.post("/api/external-apps/upload-apk", formData, { + headers: { "Content-Type": "multipart/form-data" }, + }); + return resp.data.data as ExternalAppApkUploadResult; +} + +export async function uploadExternalAppIcon(file: File) { + const formData = new FormData(); + formData.append("iconFile", file); + const resp = await http.post("/api/external-apps/upload-icon", formData, { + headers: { "Content-Type": "multipart/form-data" }, + }); + return resp.data.data as ExternalAppIconUploadResult; +} \ No newline at end of file diff --git a/frontend/src/pages/business/ClientManagement.tsx b/frontend/src/pages/business/ClientManagement.tsx new file mode 100644 index 0000000..a3d3368 --- /dev/null +++ b/frontend/src/pages/business/ClientManagement.tsx @@ -0,0 +1,383 @@ +import { App, Button, Card, Col, Drawer, Form, Input, InputNumber, Popconfirm, Row, Select, Space, Switch, Table, Tabs, Tag, Typography, Upload } from "antd"; +import type { ColumnsType } from "antd/es/table"; +import { CloudUploadOutlined, DeleteOutlined, DownloadOutlined, EditOutlined, LaptopOutlined, MobileOutlined, PlusOutlined, ReloadOutlined, SearchOutlined, SaveOutlined, UploadOutlined } from "@ant-design/icons"; +import { useCallback, useEffect, useMemo, useState } from "react"; +import PageHeader from "@/components/shared/PageHeader"; +import { getStandardPagination } from "@/utils/pagination"; +import { createClientDownload, deleteClientDownload, listClientDownloads, type ClientDownloadDTO, type ClientDownloadVO, updateClientDownload, uploadClientPackage } from "@/api/business/client"; + +const { Text } = Typography; +const { TextArea } = Input; + +type ClientFormValues = { + platformCode: string; + version: string; + versionCode?: number; + downloadUrl: string; + fileSize?: number; + minSystemVersion?: string; + releaseNotes?: string; + statusEnabled: boolean; + latest: boolean; + remark?: string; +}; + +type ClientPlatformType = "mobile" | "desktop" | "terminal"; + +type ClientPlatformOption = { + label: string; + value: string; + platformType: ClientPlatformType; + platformName: string; +}; + +const PLATFORM_OPTIONS: ClientPlatformOption[] = [ + { label: "Android", value: "android", platformType: "mobile", platformName: "android" }, + { label: "iOS", value: "ios", platformType: "mobile", platformName: "ios" }, + { label: "Windows", value: "windows", platformType: "desktop", platformName: "windows" }, + { label: "macOS", value: "mac", platformType: "desktop", platformName: "mac" }, + { label: "Linux", value: "linux", platformType: "desktop", platformName: "linux" }, + { label: "专用终端", value: "terminal_android", platformType: "terminal", platformName: "android" }, +]; + +const PLATFORM_GROUPS: Array<{ key: ClientPlatformType; label: string }> = [ + { key: "mobile", label: "移动端" }, + { key: "desktop", label: "桌面端" }, + { key: "terminal", label: "专用终端" }, +]; + +const STATUS_FILTER_OPTIONS = [ + { label: "全部状态", value: "all" }, + { label: "已启用", value: "enabled" }, + { label: "已停用", value: "disabled" }, + { label: "最新版本", value: "latest" }, +] as const; + +function formatFileSize(fileSize?: number) { + if (!fileSize) return "-"; + return `${(fileSize / (1024 * 1024)).toFixed(2)} MB`; +} + +export default function ClientManagement() { + const { message } = App.useApp(); + const [form] = Form.useForm(); + const [loading, setLoading] = useState(false); + const [saving, setSaving] = useState(false); + const [uploading, setUploading] = useState(false); + const [drawerOpen, setDrawerOpen] = useState(false); + const [editing, setEditing] = useState(null); + const [records, setRecords] = useState([]); + const [searchValue, setSearchValue] = useState(""); + const [statusFilter, setStatusFilter] = useState<(typeof STATUS_FILTER_OPTIONS)[number]["value"]>("all"); + const [activeTab, setActiveTab] = useState("all"); + const [page, setPage] = useState(1); + const [pageSize, setPageSize] = useState(10); + + const platformMap = useMemo( + () => Object.fromEntries(PLATFORM_OPTIONS.map((item) => [item.value, item])) as Record, + [] + ); + + const loadData = useCallback(async () => { + setLoading(true); + try { + const result = await listClientDownloads({ page: 1, size: 500 }); + setRecords(result.clients || []); + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { + void loadData(); + }, [loadData]); + + const filteredRecords = useMemo(() => { + const keyword = searchValue.trim().toLowerCase(); + return records.filter((item) => { + const platform = platformMap[item.platformCode]; + const platformType = item.platformType || platform?.platformType; + if (activeTab !== "all" && platformType !== activeTab) { + return false; + } + if (statusFilter === "enabled" && item.status !== 1) { + return false; + } + if (statusFilter === "disabled" && item.status === 1) { + return false; + } + if (statusFilter === "latest" && item.isLatest !== 1) { + return false; + } + if (!keyword) { + return true; + } + return [item.version, item.platformCode, platform?.label, item.minSystemVersion, item.downloadUrl, item.releaseNotes].some((field) => + String(field || "").toLowerCase().includes(keyword) + ); + }); + }, [activeTab, platformMap, records, searchValue, statusFilter]); + + const pagedRecords = useMemo(() => { + const start = (page - 1) * pageSize; + return filteredRecords.slice(start, start + pageSize); + }, [filteredRecords, page, pageSize]); + + useEffect(() => { + setPage(1); + }, [searchValue, statusFilter, activeTab]); + + const stats = useMemo(() => ({ + total: records.length, + enabled: records.filter((item) => item.status === 1).length, + latest: records.filter((item) => item.isLatest === 1).length, + terminal: records.filter((item) => (item.platformType || platformMap[item.platformCode]?.platformType) === "terminal").length, + }), [platformMap, records]); + + const openCreate = () => { + setEditing(null); + form.resetFields(); + form.setFieldsValue({ + platformCode: PLATFORM_OPTIONS[0].value, + statusEnabled: true, + latest: false, + }); + setDrawerOpen(true); + }; + + const openEdit = (record: ClientDownloadVO) => { + setEditing(record); + form.setFieldsValue({ + platformCode: record.platformCode, + version: record.version, + versionCode: record.versionCode, + downloadUrl: record.downloadUrl, + fileSize: record.fileSize, + minSystemVersion: record.minSystemVersion, + releaseNotes: record.releaseNotes, + statusEnabled: record.status === 1, + latest: record.isLatest === 1, + remark: record.remark, + }); + setDrawerOpen(true); + }; + const handleDelete = async (record: ClientDownloadVO) => { + await deleteClientDownload(record.id); + message.success("删除成功"); + await loadData(); + }; + + const handleSubmit = async () => { + const values = await form.validateFields(); + const platform = platformMap[values.platformCode]; + const payload: ClientDownloadDTO = { + platformCode: values.platformCode, + platformType: platform?.platformType, + platformName: platform?.platformName, + version: values.version.trim(), + versionCode: values.versionCode, + downloadUrl: values.downloadUrl.trim(), + fileSize: values.fileSize, + minSystemVersion: values.minSystemVersion?.trim(), + releaseNotes: values.releaseNotes?.trim(), + status: values.statusEnabled ? 1 : 0, + isLatest: values.latest ? 1 : 0, + remark: values.remark?.trim(), + }; + + setSaving(true); + try { + if (editing) { + await updateClientDownload(editing.id, payload); + message.success("客户端版本更新成功"); + } else { + await createClientDownload(payload); + message.success("客户端版本创建成功"); + } + setDrawerOpen(false); + await loadData(); + } finally { + setSaving(false); + } + }; + + const handleUpload = async (file: File) => { + const platformCode = form.getFieldValue("platformCode"); + if (!platformCode) { + message.warning("请先选择发布平台"); + return; + } + setUploading(true); + try { + const result = await uploadClientPackage(platformCode, file); + form.setFieldsValue({ + fileSize: result.fileSize, + downloadUrl: result.downloadUrl, + version: result.versionName || form.getFieldValue("version"), + versionCode: result.versionCode ?? form.getFieldValue("versionCode"), + }); + message.success("安装包上传成功,已自动回填可解析的 APK 元数据"); + } finally { + setUploading(false); + } + }; + + const handleToggleStatus = async (record: ClientDownloadVO, checked: boolean) => { + await updateClientDownload(record.id, { status: checked ? 1 : 0 }); + message.success(checked ? "已启用版本" : "已停用版本"); + await loadData(); + }; + + const columns: ColumnsType = [ + { + title: "平台", + dataIndex: "platformCode", + key: "platformCode", + width: 150, + render: (value: string) => { + const platform = platformMap[value]; + return {platform?.label || value}; + }, + }, + { + title: "版本信息", + key: "version", + width: 220, + render: (_, record) => ( + + {record.version} + 版本码:{record.versionCode ?? "-"} + + ), + }, + { + title: "安装包信息", + key: "package", + width: 240, + render: (_, record) => ( + + 大小:{formatFileSize(record.fileSize)} + 系统要求:{record.minSystemVersion || "-"} + + ), + }, + { + title: "状态", + key: "status", + width: 140, + render: (_, record) => ( + + void handleToggleStatus(record, checked)} /> + {record.isLatest === 1 ? 最新版本 : 历史版本} + + ), + }, + { + title: "更新时间", + dataIndex: "updatedAt", + key: "updatedAt", + width: 180, + render: (value?: string) => value ? new Date(value).toLocaleString() : "-", + }, + { + title: "操作", + key: "action", + fixed: "right", + width: 150, + render: (_, record) => ( + + + + + } + /> + + +
发布总数
{stats.total}
+
已启用
{stats.enabled}
+
最新版本
{stats.latest}
+
专用终端
{stats.terminal}
+
+ + + + + } allowClear style={{ width: 320 }} value={searchValue} onChange={(event) => setSearchValue(event.target.value)} /> + + {PLATFORM_GROUPS.map((group) => ( + + {PLATFORM_OPTIONS.filter((item) => item.platformType === group.key).map((item) => ( + {item.label} + ))} + + ))} + + + + + + { void handleUpload(file as File); return Upload.LIST_IGNORE; }}> + + + + + + + + + + + + + + + + + + + + +