feat: 添加旧版Android API支持和相关测试

- 添加 `LegacyMeetingAttendeeResponse`, `LegacyMeetingPreviewDataResponse`, `LegacyExternalAppItemResponse`, `LegacyClientDownloadResponse` 等DTO
- 添加 `LegacyCatalogAdapterService` 接口及其实现
- 添加 `MeetingAuthorizationServiceImplTest`, `RealtimeMeetingSessionStateServiceImplTest`, `RealtimeMeetingGrpcServiceTest`, `LegacyMeetingAdapterServiceImplTest` 单元测试
- 添加 `ClientDownload` 和 `ExternalApp` 实体类
- 添加 `ApkManifestParser` 工具类
dev_na
chenhao 2026-04-13 20:21:08 +08:00
parent dffd33206a
commit 3b7ba2c47a
44 changed files with 3599 additions and 25 deletions

View File

@ -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<LegacyClientDownloadResponse> 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);
}
}

View File

@ -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<List<LegacyExternalAppItemResponse>> active(@RequestParam(value = "is_active", required = false) Integer ignoredIsActive) {
return LegacyApiResponse.ok(legacyCatalogAdapterService.listActiveExternalApps());
}
}

View File

@ -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<LegacyMeetingListResponse> 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<List<MeetingVO>> 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<LegacyMeetingAccessPasswordResponse> 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<Void> 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<MeetingTranscript>()
.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<LegacyMeetingAttendeeResponse> 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<AiTask>()
.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<LegacyMeetingAttendeeResponse> buildAttendees(String participants) {
List<Long> participantIds = parseParticipantIds(participants);
if (participantIds.isEmpty()) {
return List.of();
}
Map<Long, SysUser> 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<Long> 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();
}

View File

@ -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<Map<String, Object>> 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<ClientDownload> clients = clientDownloadService.listForAdmin(currentLoginUser(), platformCode, status);
Map<String, Object> 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<ClientDownload> create(@RequestBody ClientDownloadDTO dto) {
return ApiResponse.ok(clientDownloadService.create(dto, currentLoginUser()));
}
@PutMapping("/{id}")
@PreAuthorize("isAuthenticated()")
public ApiResponse<ClientDownload> update(@PathVariable Long id, @RequestBody ClientDownloadDTO dto) {
return ApiResponse.ok(clientDownloadService.update(id, dto, currentLoginUser()));
}
@DeleteMapping("/{id}")
@PreAuthorize("isAuthenticated()")
public ApiResponse<Boolean> delete(@PathVariable Long id) {
clientDownloadService.removeClient(id, currentLoginUser());
return ApiResponse.ok(true);
}
@PostMapping("/upload")
@PreAuthorize("isAuthenticated()")
public ApiResponse<Map<String, Object>> 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();
}
}

View File

@ -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<Map<String, Object>>> 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<ExternalApp> create(@RequestBody ExternalAppDTO dto) {
return ApiResponse.ok(externalAppService.create(dto, currentLoginUser()));
}
@PutMapping("/{id}")
@PreAuthorize("isAuthenticated()")
public ApiResponse<ExternalApp> update(@PathVariable Long id, @RequestBody ExternalAppDTO dto) {
return ApiResponse.ok(externalAppService.update(id, dto, currentLoginUser()));
}
@DeleteMapping("/{id}")
@PreAuthorize("isAuthenticated()")
public ApiResponse<Boolean> delete(@PathVariable Long id) {
externalAppService.removeApp(id, currentLoginUser());
return ApiResponse.ok(true);
}
@PostMapping("/upload-apk")
@PreAuthorize("isAuthenticated()")
public ApiResponse<Map<String, Object>> uploadApk(@RequestParam("apkFile") MultipartFile apkFile) throws IOException {
return ApiResponse.ok(externalAppService.uploadApk(apkFile));
}
@PostMapping("/upload-icon")
@PreAuthorize("isAuthenticated()")
public ApiResponse<Map<String, Object>> uploadIcon(@RequestParam("iconFile") MultipartFile iconFile) throws IOException {
return ApiResponse.ok(externalAppService.uploadIcon(iconFile));
}
private LoginUser currentLoginUser() {
return (LoginUser) SecurityContextHolder.getContext().getAuthentication().getPrincipal();
}
}

View File

@ -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;
}
}

View File

@ -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<String, Object> 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;
}
}

View File

@ -0,0 +1,8 @@
package com.imeeting.dto.android.legacy;
import lombok.Data;
@Data
public class LegacyMeetingAccessPasswordRequest {
private String password;
}

View File

@ -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;
}

View File

@ -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;
}

View File

@ -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<LegacyMeetingAttendeeResponse> attendees;
@JsonProperty("attendees_count")
private Integer attendeesCount;
@JsonProperty("has_password")
private Boolean hasPassword;
@JsonProperty("processing_status")
private LegacyMeetingProcessingStatusResponse processingStatus;
}

View File

@ -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;
}

View File

@ -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;
}

View File

@ -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;
}

View File

@ -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<String, Object> appInfo;
private String iconUrl;
private String description;
private Integer sortOrder;
private Integer status;
private String remark;
}

View File

@ -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;
}

View File

@ -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<String, Object> appInfo;
private String iconUrl;
private String description;
private Integer sortOrder;
private Long createdBy;
}

View File

@ -31,6 +31,8 @@ public class Meeting extends BaseEntity {
private String audioSaveMessage;
private String accessPassword;
private Long creatorId;
private String creatorName;

View File

@ -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<ClientDownload> {
}

View File

@ -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<ExternalApp> {
}

View File

@ -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<LegacyExternalAppItemResponse> listActiveExternalApps();
}

View File

@ -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<ClientDownload> wrapper = new LambdaQueryWrapper<ClientDownload>()
.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<LegacyExternalAppItemResponse> listActiveExternalApps() {
List<ExternalApp> apps = externalAppMapper.selectList(new LambdaQueryWrapper<ExternalApp>()
.eq(ExternalApp::getStatus, 1)
.orderByAsc(ExternalApp::getSortOrder)
.orderByDesc(ExternalApp::getCreatedAt));
if (apps == null || apps.isEmpty()) {
return List.of();
}
List<Long> creatorIds = apps.stream()
.map(ExternalApp::getCreatedBy)
.filter(Objects::nonNull)
.distinct()
.toList();
Map<Long, String> 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());
}
}

View File

@ -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<MeetingTranscript>()
.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<String, Object> 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();
}
}
}

View File

@ -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<ClientDownload> {
List<ClientDownload> 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<String, Object> uploadPackage(String platformCode, MultipartFile file) throws IOException;
}

View File

@ -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<ExternalApp> {
List<Map<String, Object>> 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<String, Object> uploadApk(MultipartFile file) throws IOException;
Map<String, Object> uploadIcon(MultipartFile file) throws IOException;
}

View File

@ -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<ClientDownloadMapper, ClientDownload> implements ClientDownloadService {
@Value("${unisbase.app.upload-path}")
private String uploadPath;
@Value("${unisbase.app.resource-prefix:/api/static/}")
private String resourcePrefix;
@Override
public List<ClientDownload> listForAdmin(LoginUser loginUser, String platformCode, Integer status) {
LambdaQueryWrapper<ClientDownload> wrapper = new LambdaQueryWrapper<ClientDownload>()
.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<String, Object> 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<String, Object> 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<ClientDownload> update = new LambdaUpdateWrapper<ClientDownload>()
.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;
}
}

View File

@ -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<ExternalAppMapper, ExternalApp> 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<Map<String, Object>> listForAdmin(LoginUser loginUser, String appType, Integer status) {
LambdaQueryWrapper<ExternalApp> wrapper = new LambdaQueryWrapper<ExternalApp>()
.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<ExternalApp> apps = this.list(wrapper);
if (apps.isEmpty()) {
return List.of();
}
Map<Long, String> 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<String, Object> 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<String, Object> uploadApk(MultipartFile file) throws IOException {
StoredFile storedFile = storeFile("external-apps/apk", file);
ApkManifestParser.ApkInfo apkInfo = ApkManifestParser.parse(storedFile.path().toString());
Map<String, Object> 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<String, Object> uploadIcon(MultipartFile file) throws IOException {
StoredFile storedFile = storeFile("external-apps/icon", file);
Map<String, Object> 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) {
}
}

View File

@ -77,6 +77,7 @@ public class MeetingDomainSupport {
Map<String, Object> 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) {
}
}

View File

@ -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<String> 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<Attribute> 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<String> 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<Attribute> 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;
}
}
}

View File

@ -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<String> 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<String> body = new ApiResponse<>("500", "error", null);
ApiResponseSuccessCodeAdvice advice = new ApiResponseSuccessCodeAdvice();
advice.beforeBodyWrite(body, null, null, null, null, null);
assertEquals("500", body.getCode());
}
}

View File

@ -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<LegacyMeetingAccessPasswordResponse> response = controller.updateAccessPassword(9L, request);
assertEquals("200", response.getCode());
assertEquals(null, response.getData().getPassword());
assertEquals(null, meeting.getAccessPassword());
verify(meetingService).updateById(meeting);
}
}

View File

@ -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<String> response = LegacyApiResponse.ok("上传成功", null);
assertEquals("200", response.getCode());
assertEquals("上传成功", response.getMessage());
assertNull(response.getData());
}
}

View File

@ -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<RealtimeClientPacket> 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<RealtimeClientPacket> 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<RealtimeServerPacket> {
private final List<RealtimeServerPacket> 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;
}
}
}

View File

@ -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<LegacyExternalAppItemResponse> responses = service.listActiveExternalApps();
assertEquals(1, responses.size());
assertEquals("管理员", responses.get(0).getCreatorUsername());
assertEquals(1, responses.get(0).getIsActive());
}
}

View File

@ -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<Meeting> captor = ArgumentCaptor.forClass(Meeting.class);
verify(meetingService).save(captor.capture());
assertEquals(9001L, captor.getValue().getId());
}
}

View File

@ -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<String, Object> 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());
// }
//}

View File

@ -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<LoginUser> 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);
}
}

View File

@ -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<String, String> 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<String, String> 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<String, String> 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<String, String> 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;
}
}

View File

@ -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<String, String> 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<AsrUpstreamBridgeService.AsrUpstreamEventListener> 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<RealtimeServerPacket> {
private final List<RealtimeServerPacket> values = new ArrayList<>();
@Override
public void onNext(RealtimeServerPacket value) {
values.add(value);
}
@Override
public void onError(Throwable t) {
}
@Override
public void onCompleted() {
}
}
}

View File

@ -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<ClientDownloadDTO>) {
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;
}

View File

@ -0,0 +1,82 @@
import http from "../http";
export interface ExternalAppVO {
id: number;
tenantId?: number;
appName: string;
appType: "native" | "web";
appInfo?: Record<string, unknown>;
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<string, unknown>;
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<ExternalAppDTO>) {
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;
}

View File

@ -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<ClientFormValues>();
const [loading, setLoading] = useState(false);
const [saving, setSaving] = useState(false);
const [uploading, setUploading] = useState(false);
const [drawerOpen, setDrawerOpen] = useState(false);
const [editing, setEditing] = useState<ClientDownloadVO | null>(null);
const [records, setRecords] = useState<ClientDownloadVO[]>([]);
const [searchValue, setSearchValue] = useState("");
const [statusFilter, setStatusFilter] = useState<(typeof STATUS_FILTER_OPTIONS)[number]["value"]>("all");
const [activeTab, setActiveTab] = useState<ClientPlatformType | "all">("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<string, ClientPlatformOption>,
[]
);
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<ClientDownloadVO> = [
{
title: "平台",
dataIndex: "platformCode",
key: "platformCode",
width: 150,
render: (value: string) => {
const platform = platformMap[value];
return <Tag color="blue">{platform?.label || value}</Tag>;
},
},
{
title: "版本信息",
key: "version",
width: 220,
render: (_, record) => (
<Space direction="vertical" size={0}>
<Text strong>{record.version}</Text>
<Text type="secondary">{record.versionCode ?? "-"}</Text>
</Space>
),
},
{
title: "安装包信息",
key: "package",
width: 240,
render: (_, record) => (
<Space direction="vertical" size={0}>
<Text type="secondary">{formatFileSize(record.fileSize)}</Text>
<Text type="secondary">{record.minSystemVersion || "-"}</Text>
</Space>
),
},
{
title: "状态",
key: "status",
width: 140,
render: (_, record) => (
<Space direction="vertical" size={4}>
<Switch size="small" checked={record.status === 1} onChange={(checked) => void handleToggleStatus(record, checked)} />
{record.isLatest === 1 ? <Tag color="green"></Tag> : <Tag></Tag>}
</Space>
),
},
{
title: "更新时间",
dataIndex: "updatedAt",
key: "updatedAt",
width: 180,
render: (value?: string) => value ? new Date(value).toLocaleString() : "-",
},
{
title: "操作",
key: "action",
fixed: "right",
width: 150,
render: (_, record) => (
<Space size={4}>
<Button type="text" icon={<EditOutlined />} onClick={() => openEdit(record)} />
<Button type="text" icon={<DownloadOutlined />} href={record.downloadUrl} target="_blank" />
<Popconfirm title="确认删除该版本吗?" onConfirm={() => void handleDelete(record)}>
<Button type="text" danger icon={<DeleteOutlined />} />
</Popconfirm>
</Space>
),
},
];
return (
<div style={{ height: "100%", display: "flex", flexDirection: "column", overflow: "hidden" }}>
<PageHeader
title="客户端管理"
subtitle="统一维护移动端、桌面端与专用终端的版本发布、安装包上传和启停状态。"
extra={
<Space>
<Button icon={<ReloadOutlined />} onClick={() => void loadData()} loading={loading}></Button>
<Button type="primary" icon={<PlusOutlined />} onClick={openCreate}></Button>
</Space>
}
/>
<Row gutter={16} className="mb-4">
<Col span={6}><Card size="small"><Space><CloudUploadOutlined style={{ color: "#1677ff" }} /><div><div></div><Text strong>{stats.total}</Text></div></Space></Card></Col>
<Col span={6}><Card size="small"><Space><LaptopOutlined style={{ color: "#52c41a" }} /><div><div></div><Text strong>{stats.enabled}</Text></div></Space></Card></Col>
<Col span={6}><Card size="small"><Space><MobileOutlined style={{ color: "#722ed1" }} /><div><div></div><Text strong>{stats.latest}</Text></div></Space></Card></Col>
<Col span={6}><Card size="small"><Space><LaptopOutlined style={{ color: "#fa8c16" }} /><div><div></div><Text strong>{stats.terminal}</Text></div></Space></Card></Col>
</Row>
<Card className="app-page__filter-card mb-4" styles={{ body: { padding: 16 } }}>
<Space wrap style={{ width: "100%", justifyContent: "space-between" }}>
<Space wrap>
<Input placeholder="搜索平台、版本、系统要求或下载地址" prefix={<SearchOutlined />} allowClear style={{ width: 320 }} value={searchValue} onChange={(event) => setSearchValue(event.target.value)} />
<Select style={{ width: 150 }} value={statusFilter} options={STATUS_FILTER_OPTIONS as unknown as { label: string; value: string }[]} onChange={(value) => setStatusFilter(value as typeof statusFilter)} />
</Space>
<Tabs activeKey={activeTab} onChange={(value) => setActiveTab(value as ClientPlatformType | "all")} items={[{ key: "all", label: "全部" }, ...PLATFORM_GROUPS.map((item) => ({ key: item.key, label: item.label }))]} />
</Space>
</Card>
<Card className="app-page__content-card flex-1 flex flex-col overflow-hidden" styles={{ body: { padding: 0, flex: 1, display: "flex", flexDirection: "column", overflow: "hidden" } }}>
<Table rowKey="id" columns={columns} dataSource={pagedRecords} loading={loading} scroll={{ x: 960, y: "calc(100vh - 360px)" }} pagination={getStandardPagination(filteredRecords.length, page, pageSize, (nextPage, nextSize) => { setPage(nextPage); setPageSize(nextSize); })} />
</Card>
<Drawer title={editing ? "编辑客户端版本" : "新增客户端版本"} open={drawerOpen} onClose={() => setDrawerOpen(false)} width={680} destroyOnHidden forceRender footer={<div className="app-page__drawer-footer"><Button onClick={() => setDrawerOpen(false)}></Button><Button type="primary" icon={<SaveOutlined />} loading={saving} onClick={() => void handleSubmit()}></Button></div>}>
<Form form={form} layout="vertical">
<Row gutter={16}>
<Col span={12}>
<Form.Item name="platformCode" label="发布平台" rules={[{ required: true, message: "请选择发布平台" }]}>
<Select placeholder="选择平台" disabled={!!editing}>
{PLATFORM_GROUPS.map((group) => (
<Select.OptGroup key={group.key} label={group.label}>
{PLATFORM_OPTIONS.filter((item) => item.platformType === group.key).map((item) => (
<Select.Option key={item.value} value={item.value}>{item.label}</Select.Option>
))}
</Select.OptGroup>
))}
</Select>
</Form.Item>
</Col>
<Col span={12}>
<Form.Item label="安装包上传">
<Upload showUploadList={false} beforeUpload={(file) => { void handleUpload(file as File); return Upload.LIST_IGNORE; }}>
<Button icon={<UploadOutlined />} loading={uploading} style={{ width: "100%" }}></Button>
</Upload>
</Form.Item>
</Col>
</Row>
<Row gutter={16}>
<Col span={12}><Form.Item name="version" label="版本号" rules={[{ required: true, message: "请输入版本号" }]}><Input placeholder="如 1.0.0" /></Form.Item></Col>
<Col span={12}><Form.Item name="versionCode" label="版本码"><InputNumber min={0} style={{ width: "100%" }} placeholder="如 1000" /></Form.Item></Col>
</Row>
<Form.Item name="downloadUrl" label="下载链接" rules={[{ required: true, message: "请输入下载链接" }]}>
<Input placeholder="https://..." />
</Form.Item>
<Row gutter={16}>
<Col span={12}><Form.Item name="fileSize" label="文件大小Bytes"><InputNumber min={0} style={{ width: "100%" }} /></Form.Item></Col>
<Col span={12}><Form.Item name="minSystemVersion" label="最低系统要求"><Input placeholder="如 Android 8.0 / Windows 10" /></Form.Item></Col>
</Row>
<Form.Item name="releaseNotes" label="更新说明"><TextArea rows={4} placeholder="请输入版本更新内容" /></Form.Item>
<Form.Item name="remark" label="备注"><TextArea rows={2} placeholder="选填" /></Form.Item>
<Space size={32}>
<Form.Item name="statusEnabled" label="启用状态" valuePropName="checked"><Switch /></Form.Item>
<Form.Item name="latest" label="设为最新版本" valuePropName="checked"><Switch /></Form.Item>
</Space>
</Form>
</Drawer>
</div>
);
}

View File

@ -0,0 +1,375 @@
import { App, Avatar, Button, Card, Col, Drawer, Form, Input, InputNumber, Popconfirm, Row, Select, Space, Switch, Table, Tag, Typography, Upload } from "antd";
import type { ColumnsType } from "antd/es/table";
import { AppstoreOutlined, DeleteOutlined, EditOutlined, GlobalOutlined, LinkOutlined, PictureOutlined, PlusOutlined, ReloadOutlined, RobotOutlined, SaveOutlined, SearchOutlined, UploadOutlined } from "@ant-design/icons";
import { useCallback, useEffect, useMemo, useState } from "react";
import PageHeader from "@/components/shared/PageHeader";
import { getStandardPagination } from "@/utils/pagination";
import { createExternalApp, deleteExternalApp, listExternalApps, type ExternalAppDTO, type ExternalAppVO, updateExternalApp, uploadExternalAppApk, uploadExternalAppIcon } from "@/api/business/externalApp";
const { Text } = Typography;
const { TextArea } = Input;
type ExternalAppFormValues = {
appName: string;
appType: "native" | "web";
appInfo?: {
versionName?: string;
webUrl?: string;
packageName?: string;
apkUrl?: string;
};
iconUrl?: string;
description?: string;
sortOrder?: number;
statusEnabled: boolean;
remark?: string;
};
const STATUS_OPTIONS = [
{ label: "全部状态", value: "all" },
{ label: "已启用", value: "enabled" },
{ label: "已停用", value: "disabled" },
] as const;
function normalizeAppInfo(value: ExternalAppVO["appInfo"]): ExternalAppFormValues["appInfo"] {
if (!value || typeof value !== "object") {
return {};
}
const info = value as Record<string, unknown>;
return {
versionName: typeof info.versionName === "string" ? info.versionName : undefined,
webUrl: typeof info.webUrl === "string" ? info.webUrl : undefined,
packageName: typeof info.packageName === "string" ? info.packageName : undefined,
apkUrl: typeof info.apkUrl === "string" ? info.apkUrl : undefined,
};
}
function compactObject<T extends Record<string, unknown>>(value: T): T {
return Object.fromEntries(Object.entries(value).filter(([, field]) => field !== undefined && field !== null && field !== "")) as T;
}
export default function ExternalAppManagement() {
const { message } = App.useApp();
const [form] = Form.useForm<ExternalAppFormValues>();
const [loading, setLoading] = useState(false);
const [saving, setSaving] = useState(false);
const [drawerOpen, setDrawerOpen] = useState(false);
const [editing, setEditing] = useState<ExternalAppVO | null>(null);
const [records, setRecords] = useState<ExternalAppVO[]>([]);
const [searchValue, setSearchValue] = useState("");
const [statusFilter, setStatusFilter] = useState<(typeof STATUS_OPTIONS)[number]["value"]>("all");
const [appTypeFilter, setAppTypeFilter] = useState<"all" | "native" | "web">("all");
const [page, setPage] = useState(1);
const [pageSize, setPageSize] = useState(10);
const [uploadingApk, setUploadingApk] = useState(false);
const [uploadingIcon, setUploadingIcon] = useState(false);
const loadData = useCallback(async () => {
setLoading(true);
try {
const result = await listExternalApps();
setRecords(result || []);
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
void loadData();
}, [loadData]);
const filteredRecords = useMemo(() => {
const keyword = searchValue.trim().toLowerCase();
return records.filter((item) => {
if (appTypeFilter !== "all" && item.appType !== appTypeFilter) {
return false;
}
if (statusFilter === "enabled" && item.status !== 1) {
return false;
}
if (statusFilter === "disabled" && item.status === 1) {
return false;
}
if (!keyword) {
return true;
}
const info = normalizeAppInfo(item.appInfo);
return [item.appName, item.description, info.versionName, info.webUrl, info.packageName, info.apkUrl, item.creatorUsername].some((field) =>
String(field || "").toLowerCase().includes(keyword)
);
});
}, [appTypeFilter, 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, appTypeFilter]);
const stats = useMemo(() => ({
total: records.length,
native: records.filter((item) => item.appType === "native").length,
web: records.filter((item) => item.appType === "web").length,
enabled: records.filter((item) => item.status === 1).length,
}), [records]);
const openCreate = () => {
setEditing(null);
form.resetFields();
form.setFieldsValue({
appType: "native",
statusEnabled: true,
sortOrder: 0,
appInfo: {},
});
setDrawerOpen(true);
};
const openEdit = (record: ExternalAppVO) => {
setEditing(record);
form.setFieldsValue({
appName: record.appName,
appType: record.appType,
appInfo: normalizeAppInfo(record.appInfo),
iconUrl: record.iconUrl,
description: record.description,
sortOrder: record.sortOrder,
statusEnabled: record.status === 1,
remark: record.remark,
});
setDrawerOpen(true);
};
const handleDelete = async (record: ExternalAppVO) => {
message.success("删除成功");
message.success("Deleted successfully");
await loadData();
};
const handleSubmit = async () => {
const values = await form.validateFields();
const payload: ExternalAppDTO = {
appName: values.appName.trim(),
appType: values.appType,
appInfo: compactObject(values.appInfo || {}),
iconUrl: values.iconUrl?.trim(),
description: values.description?.trim(),
sortOrder: values.sortOrder,
status: values.statusEnabled ? 1 : 0,
remark: values.remark?.trim(),
};
setSaving(true);
try {
if (editing) {
await updateExternalApp(editing.id, payload);
message.success("外部应用更新成功");
} else {
await createExternalApp(payload);
message.success("外部应用创建成功");
}
setDrawerOpen(false);
await loadData();
} finally {
setSaving(false);
}
};
const handleUploadApk = async (file: File) => {
setUploadingApk(true);
try {
const result = await uploadExternalAppApk(file);
const currentInfo = form.getFieldValue("appInfo") || {};
form.setFieldsValue({
appInfo: {
...currentInfo,
apkUrl: result.apkUrl || currentInfo.apkUrl,
packageName: result.packageName || currentInfo.packageName,
versionName: result.versionName || currentInfo.versionName,
},
});
if (result.appName && !form.getFieldValue("appName")) {
form.setFieldValue("appName", result.appName);
}
message.success("APK 上传成功,已自动回填可解析的 APK 元数据");
} finally {
setUploadingApk(false);
}
};
const handleUploadIcon = async (file: File) => {
setUploadingIcon(true);
try {
const result = await uploadExternalAppIcon(file);
if (result.iconUrl) {
form.setFieldValue("iconUrl", result.iconUrl);
}
message.success("图标上传成功");
} finally {
setUploadingIcon(false);
}
};
const handleToggleStatus = async (record: ExternalAppVO, checked: boolean) => {
await updateExternalApp(record.id, { status: checked ? 1 : 0 });
message.success(checked ? "已启用应用" : "已停用应用");
await loadData();
};
const columns: ColumnsType<ExternalAppVO> = [
{
title: "应用",
key: "app",
width: 260,
render: (_, record) => (
<Space>
<Avatar shape="square" size={44} src={record.iconUrl} icon={<AppstoreOutlined />} />
<Space direction="vertical" size={0}>
<Text strong>{record.appName}</Text>
<Text type="secondary">{record.description || "暂无描述"}</Text>
</Space>
</Space>
),
},
{
title: "类型",
dataIndex: "appType",
key: "appType",
width: 120,
render: (value: ExternalAppVO["appType"]) => value === "native" ? <Tag color="green" icon={<RobotOutlined />}></Tag> : <Tag color="blue" icon={<GlobalOutlined />}>Web </Tag>,
},
{
title: "入口信息",
key: "entry",
width: 260,
render: (_, record) => {
const info = normalizeAppInfo(record.appInfo);
return (
<Space direction="vertical" size={0}>
<Text type="secondary">{info.versionName || "-"}</Text>
<Text type="secondary">{record.appType === "native" ? `包名:${info.packageName || "-"}` : `地址:${info.webUrl || "-"}`}</Text>
</Space>
);
},
},
{
title: "状态",
key: "status",
width: 120,
render: (_, record) => <Switch size="small" checked={record.status === 1} onChange={(checked) => void handleToggleStatus(record, checked)} />,
},
{
title: "排序权重",
dataIndex: "sortOrder",
key: "sortOrder",
width: 90,
align: "center",
},
{
title: "操作",
key: "action",
width: 150,
fixed: "right",
render: (_, record) => {
const info = normalizeAppInfo(record.appInfo);
const link = record.appType === "native" ? info.apkUrl : info.webUrl;
return (
<Space size={4}>
<Button type="text" icon={<LinkOutlined />} href={link} target="_blank" disabled={!link} />
<Button type="text" icon={<EditOutlined />} onClick={() => openEdit(record)} />
<Popconfirm title="确认删除该应用吗?" onConfirm={() => void handleDelete(record)}>
<Button type="text" danger icon={<DeleteOutlined />} />
</Popconfirm>
</Space>
);
},
},
];
return (
<div style={{ height: "100%", display: "flex", flexDirection: "column", overflow: "hidden" }}>
<PageHeader
title="外部应用管理"
subtitle="统一维护首页九宫格与抽屉入口中的原生应用、Web 服务和应用图标资源。"
extra={
<Space>
<Button icon={<ReloadOutlined />} onClick={() => void loadData()} loading={loading}></Button>
<Button type="primary" icon={<PlusOutlined />} onClick={openCreate}></Button>
</Space>
}
/>
<Row gutter={16} className="mb-4">
<Col span={6}><Card size="small"><Space><AppstoreOutlined style={{ color: "#1677ff" }} /><div><div></div><Text strong>{stats.total}</Text></div></Space></Card></Col>
<Col span={6}><Card size="small"><Space><RobotOutlined style={{ color: "#52c41a" }} /><div><div></div><Text strong>{stats.native}</Text></div></Space></Card></Col>
<Col span={6}><Card size="small"><Space><GlobalOutlined style={{ color: "#722ed1" }} /><div><div>Web </div><Text strong>{stats.web}</Text></div></Space></Card></Col>
<Col span={6}><Card size="small"><Space><PictureOutlined style={{ color: "#fa8c16" }} /><div><div></div><Text strong>{stats.enabled}</Text></div></Space></Card></Col>
</Row>
<Card className="app-page__filter-card mb-4" styles={{ body: { padding: 16 } }}>
<Space wrap style={{ width: "100%", justifyContent: "space-between" }}>
<Space wrap>
<Input placeholder="搜索名称、描述、包名或入口地址" prefix={<SearchOutlined />} allowClear style={{ width: 320 }} value={searchValue} onChange={(event) => setSearchValue(event.target.value)} />
<Select value={appTypeFilter} style={{ width: 140 }} onChange={(value) => setAppTypeFilter(value)} options={[{ label: "全部类型", value: "all" }, { label: "原生应用", value: "native" }, { label: "Web 应用", value: "web" }]} />
<Select value={statusFilter} style={{ width: 140 }} onChange={(value) => setStatusFilter(value as typeof statusFilter)} options={STATUS_OPTIONS as unknown as { label: string; value: string }[]} />
</Space>
</Space>
</Card>
<Card className="app-page__content-card flex-1 flex flex-col overflow-hidden" styles={{ body: { padding: 0, flex: 1, display: "flex", flexDirection: "column", overflow: "hidden" } }}>
<Table rowKey="id" columns={columns} dataSource={pagedRecords} loading={loading} scroll={{ x: 980, y: "calc(100vh - 360px)" }} pagination={getStandardPagination(filteredRecords.length, page, pageSize, (nextPage, nextSize) => { setPage(nextPage); setPageSize(nextSize); })} />
</Card>
<Drawer title={editing ? "编辑外部应用" : "新增外部应用"} open={drawerOpen} onClose={() => setDrawerOpen(false)} width={700} destroyOnHidden forceRender footer={<div className="app-page__drawer-footer"><Button onClick={() => setDrawerOpen(false)}></Button><Button type="primary" icon={<SaveOutlined />} loading={saving} onClick={() => void handleSubmit()}></Button></div>}>
<Form form={form} layout="vertical">
<Row gutter={16}>
<Col span={16}><Form.Item name="appName" label="应用名称" rules={[{ required: true, message: "请输入应用名称" }]}><Input placeholder="请输入应用名称" /></Form.Item></Col>
<Col span={8}><Form.Item name="appType" label="应用类型" rules={[{ required: true, message: "请选择应用类型" }]}><Select options={[{ label: "原生应用", value: "native" }, { label: "Web 应用", value: "web" }]} /></Form.Item></Col>
</Row>
<Space align="start" className="mb-4">
<Upload showUploadList={false} beforeUpload={(file) => { void handleUploadIcon(file as File); return Upload.LIST_IGNORE; }}>
<Avatar shape="square" size={72} src={form.getFieldValue("iconUrl")} icon={<PictureOutlined />} style={{ cursor: "pointer", border: "1px dashed var(--app-border-color)", background: "var(--app-bg-card)" }} />
</Upload>
<div style={{ flex: 1 }}>
<Form.Item name="iconUrl" label="图标地址"><Input placeholder="上传图标后自动回填,或手动填写" /></Form.Item>
</div>
</Space>
<Form.Item noStyle shouldUpdate={(prev, curr) => prev.appType !== curr.appType}>
{({ getFieldValue }) => getFieldValue("appType") === "native" ? (
<>
<Form.Item label="上传 APK">
<Upload showUploadList={false} beforeUpload={(file) => { void handleUploadApk(file as File); return Upload.LIST_IGNORE; }}>
<Button icon={<UploadOutlined />} loading={uploadingApk}> APK </Button>
</Upload>
</Form.Item>
<Row gutter={16}>
<Col span={12}><Form.Item name={["appInfo", "versionName"]} label="版本号"><Input placeholder="如 1.0.0" /></Form.Item></Col>
<Col span={12}><Form.Item name={["appInfo", "packageName"]} label="包名"><Input placeholder="com.example.app" /></Form.Item></Col>
</Row>
<Form.Item name={["appInfo", "apkUrl"]} label="APK 地址" rules={[{ required: true, message: "请上传或填写 APK 地址" }]}><Input prefix={<LinkOutlined />} placeholder="https://..." /></Form.Item>
</>
) : (
<>
<Row gutter={16}>
<Col span={12}><Form.Item name={["appInfo", "versionName"]} label="版本号"><Input placeholder="可选" /></Form.Item></Col>
<Col span={12}><Form.Item name={["appInfo", "webUrl"]} label="Web 地址" rules={[{ required: true, message: "请输入 Web 地址" }]}><Input prefix={<GlobalOutlined />} placeholder="https://..." /></Form.Item></Col>
</Row>
</>
)}
</Form.Item>
<Form.Item name="description" label="应用描述"><TextArea rows={3} placeholder="请输入应用用途说明" /></Form.Item>
<Row gutter={16}>
<Col span={12}><Form.Item name="sortOrder" label="排序权重"><InputNumber min={0} style={{ width: "100%" }} /></Form.Item></Col>
<Col span={12}><Form.Item name="remark" label="备注"><Input placeholder="选填" /></Form.Item></Col>
</Row>
<Form.Item name="statusEnabled" label="启用状态" valuePropName="checked"><Switch /></Form.Item>
</Form>
</Drawer>
</div>
);
}

View File

@ -17,27 +17,32 @@ const Logs = lazy(() => import("@/pages/system/logs"));
const Devices = lazy(() => import("@/pages/devices"));
const UserRoleBinding = lazy(() => import("@/pages/bindings/user-role"));
const RolePermissionBinding = lazy(() => import("@/pages/bindings/role-permission"));
const ClientManagement = lazy(() => import("@/pages/business/ClientManagement"));
const ExternalAppManagement = lazy(() => import("@/pages/business/ExternalAppManagement"));
import SpeakerReg from "../pages/business/SpeakerReg";
const RealtimeAsrSession = lazy(async () => { const mod = await import("../pages/business/RealtimeAsrSession"); return { default: mod.default ?? mod.RealtimeAsrSession }; });
const RealtimeAsrSession = lazy(async () => {
const mod = await import("../pages/business/RealtimeAsrSession");
return { default: mod.default ?? mod.RealtimeAsrSession };
});
import HotWords from "../pages/business/HotWords";
import PromptTemplates from "../pages/business/PromptTemplates";
import AiModels from "../pages/business/AiModels";
import Meetings from "../pages/business/Meetings";
import MeetingDetail from "../pages/business/MeetingDetail";
function RouteFallback() {
return (
<div className="app-page__empty-state" style={{ minHeight: 320 }}>
<Spin />
</div>
<div className="app-page__empty-state" style={{ minHeight: 320 }}>
<Spin />
</div>
);
}
function LazyPage({ children }: { children: JSX.Element }) {
return <Suspense fallback={<RouteFallback />}>{children}</Suspense>;
}
export const menuRoutes: MenuRoute[] = [
{ path: "/", label: "首页", element: <HomePage />, perm: "menu:dashboard" },
{ path: "/profile", label: "个人中心", element: <Profile /> },
@ -57,12 +62,13 @@ export const menuRoutes: MenuRoute[] = [
{ path: "/hotwords", label: "热词管理", element: <HotWords />, perm: "menu:hotword" },
{ path: "/prompts", label: "总结模板", element: <PromptTemplates />, perm: "menu:prompt" },
{ path: "/aimodels", label: "模型配置", element: <AiModels />, perm: "menu:aimodel" },
{ path: "/meetings", label: "会议中心", element: <Meetings />, perm: "menu:meeting" }
{ path: "/clients", label: "客户端管理", element: <ClientManagement />, perm: "menu:clients" },
{ path: "/external-apps", label: "外部应用管理", element: <ExternalAppManagement />, perm: "menu:external-apps" },
{ path: "/meetings", label: "会议中心", element: <Meetings />, perm: "menu:meeting" },
];
export const extraRoutes = [
{ path: "/dashboard-monitor", element: <Dashboard />, perm: "menu:dashboard" },
{ path: "/meetings/:id", element: <MeetingDetail />, perm: "menu:meeting" },
{ path: "/meeting-live-session/:id", element: <RealtimeAsrSession />, perm: "menu:meeting" }
];
{ path: "/meeting-live-session/:id", element: <RealtimeAsrSession />, perm: "menu:meeting" },
];