feat: 添加旧版Android API支持和相关测试
- 添加 `LegacyMeetingAttendeeResponse`, `LegacyMeetingPreviewDataResponse`, `LegacyExternalAppItemResponse`, `LegacyClientDownloadResponse` 等DTO - 添加 `LegacyCatalogAdapterService` 接口及其实现 - 添加 `MeetingAuthorizationServiceImplTest`, `RealtimeMeetingSessionStateServiceImplTest`, `RealtimeMeetingGrpcServiceTest`, `LegacyMeetingAdapterServiceImplTest` 单元测试 - 添加 `ClientDownload` 和 `ExternalApp` 实体类 - 添加 `ApkManifestParser` 工具类dev_na
parent
dffd33206a
commit
3b7ba2c47a
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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());
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
package com.imeeting.dto.android.legacy;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
@Data
|
||||
public class LegacyMeetingAccessPasswordRequest {
|
||||
private String password;
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -31,6 +31,8 @@ public class Meeting extends BaseEntity {
|
|||
|
||||
private String audioSaveMessage;
|
||||
|
||||
private String accessPassword;
|
||||
|
||||
private Long creatorId;
|
||||
|
||||
private String creatorName;
|
||||
|
|
|
|||
|
|
@ -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> {
|
||||
}
|
||||
|
|
@ -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> {
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
|
|
@ -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());
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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) {
|
||||
}
|
||||
}
|
||||
|
|
@ -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) {
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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());
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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());
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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());
|
||||
}
|
||||
}
|
||||
|
|
@ -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());
|
||||
}
|
||||
}
|
||||
|
|
@ -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());
|
||||
// }
|
||||
//}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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() {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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" },
|
||||
];
|
||||
Loading…
Reference in New Issue