diff --git a/backend/src/main/java/com/imeeting/controller/android/AndroidAuthController.java b/backend/src/main/java/com/imeeting/controller/android/AndroidAuthController.java new file mode 100644 index 0000000..7f5f4e7 --- /dev/null +++ b/backend/src/main/java/com/imeeting/controller/android/AndroidAuthController.java @@ -0,0 +1,74 @@ +package com.imeeting.controller.android; + +import com.unisbase.common.ApiResponse; +import com.unisbase.dto.LoginRequest; +import com.unisbase.dto.RefreshRequest; +import com.unisbase.dto.TokenResponse; +import com.unisbase.service.AuthService; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.util.StringUtils; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestHeader; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@Tag(name = "Android认证接口") +@RestController +@RequestMapping("/api/android/auth") +@RequiredArgsConstructor +public class AndroidAuthController { + + private final AuthService authService; + + @Operation(summary = "Android登录") + @ApiResponses({ + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "200", + description = "返回登录成功后的访问令牌、刷新令牌和当前用户信息", + content = @Content(schema = @Schema(implementation = TokenResponse.class)) + ) + }) + @PostMapping("/login") + public ApiResponse login(@Valid @RequestBody LoginRequest request) { + return ApiResponse.ok(authService.login(request, true)); + } + + @Operation(summary = "Android刷新令牌") + @ApiResponses({ + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "200", + description = "返回刷新后的访问令牌、刷新令牌和当前用户信息", + content = @Content(schema = @Schema(implementation = TokenResponse.class)) + ) + }) + @PostMapping("/refresh") + public ApiResponse refresh(@RequestBody(required = false) RefreshRequest request, + @RequestHeader(value = "Authorization", required = false) String authorization, + @RequestHeader(value = "X-Android-Access-Token", required = false) String androidAccessToken) { + return ApiResponse.ok(authService.refresh(resolveRefreshToken(request, authorization, androidAccessToken))); + } + + private String resolveRefreshToken(RefreshRequest request, String authorization, String androidAccessToken) { + if (request != null && StringUtils.hasText(request.getRefreshToken())) { + return request.getRefreshToken().trim(); + } + if (StringUtils.hasText(androidAccessToken)) { + return androidAccessToken.trim(); + } + if (StringUtils.hasText(authorization)) { + String value = authorization.trim(); + if (value.startsWith("Bearer ")) { + return value.substring(7).trim(); + } + return value; + } + throw new IllegalArgumentException("refreshToken不能为空"); + } +} diff --git a/backend/src/main/java/com/imeeting/controller/android/AndroidClientController.java b/backend/src/main/java/com/imeeting/controller/android/AndroidClientController.java new file mode 100644 index 0000000..9102207 --- /dev/null +++ b/backend/src/main/java/com/imeeting/controller/android/AndroidClientController.java @@ -0,0 +1,67 @@ +package com.imeeting.controller.android; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.imeeting.entity.biz.ClientDownload; +import com.imeeting.service.android.AndroidAuthService; +import com.imeeting.service.biz.ClientDownloadService; +import com.unisbase.common.ApiResponse; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.servlet.http.HttpServletRequest; +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; + +@Tag(name = "Android客户端接口") +@RestController +@RequestMapping("/api/android/clients") +@RequiredArgsConstructor +public class AndroidClientController { + + private final AndroidAuthService androidAuthService; + private final ClientDownloadService clientDownloadService; + + @Operation(summary = "查询平台最新客户端") + @ApiResponses({ + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "200", + description = "返回指定平台当前生效的最新客户端安装包信息", + content = @Content(schema = @Schema(implementation = ClientDownload.class)) + ) + }) + @GetMapping("/latest/by-platform") + public ApiResponse latestByPlatform(HttpServletRequest request, + @RequestParam(value = "platform_code", required = false) String platformCode, + @RequestParam(value = "platform_type", required = false) String platformType, + @RequestParam(value = "platform_name", required = false) String platformName) { + androidAuthService.authenticateHttp(request); + if ((platformCode == null || platformCode.isBlank()) + && ((platformType == null || platformType.isBlank()) || (platformName == null || platformName.isBlank()))) { + return ApiResponse.error("请提供 platform_code 参数"); + } + + LambdaQueryWrapper wrapper = new LambdaQueryWrapper() + .eq(ClientDownload::getStatus, 1) + .eq(ClientDownload::getIsLatest, 1); + if (platformCode != null && !platformCode.isBlank()) { + wrapper.apply("LOWER(platform_code) = {0}", platformCode.trim().toLowerCase()); + } else { + wrapper.apply("LOWER(platform_type) = {0}", platformType.trim().toLowerCase()) + .apply("LOWER(platform_name) = {0}", platformName.trim().toLowerCase()); + } + wrapper.orderByDesc(ClientDownload::getVersionCode) + .orderByDesc(ClientDownload::getId) + .last("LIMIT 1"); + + ClientDownload clientDownload = clientDownloadService.getOne(wrapper); + if (clientDownload == null) { + return ApiResponse.error("暂无最新客户端"); + } + return ApiResponse.ok(clientDownload); + } +} diff --git a/backend/src/main/java/com/imeeting/controller/android/AndroidExternalAppController.java b/backend/src/main/java/com/imeeting/controller/android/AndroidExternalAppController.java new file mode 100644 index 0000000..ab0a35c --- /dev/null +++ b/backend/src/main/java/com/imeeting/controller/android/AndroidExternalAppController.java @@ -0,0 +1,50 @@ +package com.imeeting.controller.android; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.imeeting.entity.biz.ExternalApp; +import com.imeeting.service.android.AndroidAuthService; +import com.imeeting.service.biz.ExternalAppService; +import com.unisbase.common.ApiResponse; +import io.swagger.v3.oas.annotations.media.ArraySchema; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.servlet.http.HttpServletRequest; +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; + +@Tag(name = "Android外部应用接口") +@RestController +@RequestMapping("/api/android/external-apps") +@RequiredArgsConstructor +public class AndroidExternalAppController { + + private final AndroidAuthService androidAuthService; + private final ExternalAppService externalAppService; + + @Operation(summary = "查询启用的外部应用") + @ApiResponses({ + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "200", + description = "返回当前启用的外部应用列表", + content = @Content(array = @ArraySchema(schema = @Schema(implementation = ExternalApp.class))) + ) + }) + @GetMapping("/active") + public ApiResponse> active(HttpServletRequest request, + @RequestParam(value = "is_active", required = false) Integer ignoredIsActive) { + androidAuthService.authenticateHttp(request); + List apps = externalAppService.list(new LambdaQueryWrapper() + .eq(ExternalApp::getStatus, 1) + .orderByAsc(ExternalApp::getSortOrder) + .orderByDesc(ExternalApp::getCreatedAt)); + return ApiResponse.ok(apps); + } +} diff --git a/backend/src/main/java/com/imeeting/controller/android/AndroidLlmModelController.java b/backend/src/main/java/com/imeeting/controller/android/AndroidLlmModelController.java new file mode 100644 index 0000000..f8ba007 --- /dev/null +++ b/backend/src/main/java/com/imeeting/controller/android/AndroidLlmModelController.java @@ -0,0 +1,53 @@ +package com.imeeting.controller.android; + +import com.imeeting.dto.android.AndroidAuthContext; +import com.imeeting.dto.biz.AiModelVO; +import com.imeeting.service.android.AndroidAuthService; +import com.imeeting.service.biz.AiModelService; +import com.unisbase.common.ApiResponse; +import com.unisbase.dto.PageResult; +import com.unisbase.security.LoginUser; +import io.swagger.v3.oas.annotations.media.ArraySchema; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.servlet.http.HttpServletRequest; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import java.util.List; + +@Tag(name = "Android模型接口") +@RestController +@RequestMapping("/api/android/llm-models") +@RequiredArgsConstructor +public class AndroidLlmModelController { + + private final AndroidAuthService androidAuthService; + private final AiModelService aiModelService; + + @Operation(summary = "查询启用的大模型列表") + @ApiResponses({ + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "200", + description = "返回当前租户下启用的大语言模型列表", + content = @Content(array = @ArraySchema(schema = @Schema(implementation = AiModelVO.class))) + ) + }) + @GetMapping("/active") + public ApiResponse> activeModels(HttpServletRequest request) { + AndroidAuthContext authContext = androidAuthService.authenticateHttp(request); + LoginUser loginUser = AndroidLoginUserSupport.requireLoginUser(authContext); + PageResult> result = aiModelService.pageModels(1, 1000, null, "LLM", loginUser.getTenantId()); + List enabledModels = result.getRecords() == null + ? List.of() + : result.getRecords().stream() + .filter(item -> Integer.valueOf(1).equals(item.getStatus())) + .toList(); + return ApiResponse.ok(enabledModels); + } +} diff --git a/backend/src/main/java/com/imeeting/controller/android/AndroidLoginUserSupport.java b/backend/src/main/java/com/imeeting/controller/android/AndroidLoginUserSupport.java new file mode 100644 index 0000000..878ea3e --- /dev/null +++ b/backend/src/main/java/com/imeeting/controller/android/AndroidLoginUserSupport.java @@ -0,0 +1,54 @@ +package com.imeeting.controller.android; + +import com.imeeting.dto.android.AndroidAuthContext; +import com.unisbase.security.LoginUser; + +final class AndroidLoginUserSupport { + + private AndroidLoginUserSupport() { + } + + static LoginUser requireLoginUser(AndroidAuthContext authContext) { + LoginUser loginUser = toLoginUser(authContext); + if (loginUser == null) { + throw new RuntimeException("Android用户未登录或认证无效"); + } + return loginUser; + } + + static LoginUser toLoginUser(AndroidAuthContext authContext) { + if (authContext == null || authContext.isAnonymous() + || authContext.getUserId() == null || authContext.getTenantId() == null) { + return null; + } + LoginUser loginUser = new LoginUser( + authContext.getUserId(), + authContext.getTenantId(), + authContext.getUsername(), + authContext.getPlatformAdmin(), + authContext.getTenantAdmin(), + authContext.getPermissions() + ); + loginUser.setDisplayName(authContext.getDisplayName()); + return loginUser; + } + + static boolean isAdmin(AndroidAuthContext authContext) { + return authContext != null + && (Boolean.TRUE.equals(authContext.getPlatformAdmin()) + || Boolean.TRUE.equals(authContext.getTenantAdmin())); + } + + static String resolveDisplayName(AndroidAuthContext authContext) { + if (authContext == null) { + return null; + } + if (authContext.getDisplayName() != null && !authContext.getDisplayName().isBlank()) { + return authContext.getDisplayName().trim(); + } + if (authContext.getUsername() != null && !authContext.getUsername().isBlank()) { + return authContext.getUsername().trim(); + } + return authContext.getDeviceId(); + } +} diff --git a/backend/src/main/java/com/imeeting/controller/android/AndroidMeetingController.java b/backend/src/main/java/com/imeeting/controller/android/AndroidMeetingController.java new file mode 100644 index 0000000..ebe5e5e --- /dev/null +++ b/backend/src/main/java/com/imeeting/controller/android/AndroidMeetingController.java @@ -0,0 +1,483 @@ +package com.imeeting.controller.android; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.imeeting.common.RedisKeys; +import com.imeeting.dto.android.AndroidAuthContext; +import com.imeeting.dto.android.legacy.LegacyMeetingAccessPasswordRequest; +import com.imeeting.dto.android.legacy.LegacyMeetingAttendeeResponse; +import com.imeeting.dto.android.legacy.LegacyMeetingCreateRequest; +import com.imeeting.dto.android.legacy.LegacyMeetingPreviewDataResponse; +import com.imeeting.dto.android.legacy.LegacyMeetingPreviewResult; +import com.imeeting.dto.android.legacy.LegacyMeetingProcessingStatusResponse; +import com.imeeting.dto.android.legacy.LegacyUploadAudioResponse; +import com.imeeting.dto.biz.MeetingVO; +import com.imeeting.entity.biz.AiTask; +import com.imeeting.entity.biz.Meeting; +import com.imeeting.entity.biz.PromptTemplate; +import com.imeeting.service.android.AndroidAuthService; +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.common.ApiResponse; +import com.unisbase.dto.PageResult; +import com.unisbase.entity.SysUser; +import com.unisbase.mapper.SysUserMapper; +import com.unisbase.security.LoginUser; +import io.swagger.v3.oas.annotations.media.ArraySchema; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.servlet.http.HttpServletRequest; +import lombok.RequiredArgsConstructor; +import org.springframework.data.redis.core.StringRedisTemplate; +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.time.LocalDateTime; +import java.util.Arrays; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.stream.Collectors; + +@Tag(name = "Android会议接口") +@RestController +@RequestMapping("/api/android/meetings") +@RequiredArgsConstructor +public class AndroidMeetingController { + + 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 AndroidAuthService androidAuthService; + 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 SysUserMapper sysUserMapper; + private final StringRedisTemplate redisTemplate; + private final ObjectMapper objectMapper; + + @Operation(summary = "创建Android离线会议") + @ApiResponses({ + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "200", + description = "返回新创建的会议详情", + content = @Content(schema = @Schema(implementation = MeetingVO.class)) + ) + }) + @PostMapping + public ApiResponse create(HttpServletRequest request, @RequestBody LegacyMeetingCreateRequest command) { + AndroidAuthContext authContext = androidAuthService.authenticateHttp(request); + LoginUser loginUser = AndroidLoginUserSupport.requireLoginUser(authContext); + return ApiResponse.ok(legacyMeetingAdapterService.createMeeting(command, loginUser)); + } + + @Operation(summary = "上传Android会议音频") + @ApiResponses({ + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "200", + description = "返回上传后的会议 ID 和音频地址", + content = @Content(schema = @Schema(implementation = LegacyUploadAudioResponse.class)) + ) + }) + @PostMapping("/upload-audio") + public ApiResponse uploadAudio(HttpServletRequest request, + @RequestParam("meeting_id") Long meetingId, + @RequestParam(value = "prompt_id", required = false) Long promptId, + @RequestParam(value = "model_code", required = false) String modelCode, + @RequestParam(value = "force_replace", defaultValue = "false") boolean forceReplace, + @RequestParam("audio_file") MultipartFile audioFile) throws IOException { + AndroidAuthContext authContext = androidAuthService.authenticateHttp(request); + LoginUser loginUser = AndroidLoginUserSupport.requireLoginUser(authContext); + return ApiResponse.ok(legacyMeetingAdapterService.uploadAndTriggerOfflineProcess( + meetingId, + promptId, + modelCode, + forceReplace, + audioFile, + loginUser + )); + } + + @Operation(summary = "分页查询Android会议") + @ApiResponses({ + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "200", + description = "返回当前用户可见的会议分页结果", + content = @Content(schema = @Schema(implementation = PageResult.class)) + ) + }) + @GetMapping + public ApiResponse>> list(HttpServletRequest request, + @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) { + AndroidAuthContext authContext = androidAuthService.authenticateHttp(request); + LoginUser loginUser = AndroidLoginUserSupport.requireLoginUser(authContext); + return ApiResponse.ok(meetingQueryService.pageMeetings( + page, + pageSize, + title, + loginUser.getTenantId(), + loginUser.getUserId(), + AndroidLoginUserSupport.resolveDisplayName(authContext), + "all", + AndroidLoginUserSupport.isAdmin(authContext) + )); + } + + @Operation(summary = "查询Android会议预览数据") + @ApiResponses({ + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "200", + description = "返回会议预览结果,包含已完成摘要或处理中状态信息", + content = @Content(schema = @Schema(implementation = LegacyMeetingPreviewDataResponse.class)) + ) + }) + @GetMapping("/{meetingId}/preview-data") + public ApiResponse previewData(HttpServletRequest request, @PathVariable Long meetingId) { + androidAuthService.authenticateHttp(request); + LegacyMeetingPreviewResult result = buildPreviewResult(meetingId); + return new ApiResponse<>(result.getCode(), result.getMessage(), result.getData()); + } + + @Operation(summary = "更新Android会议访问密码") + @ApiResponses({ + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "200", + description = "返回更新后的会议访问密码,传空时表示清空访问密码", + content = @Content(schema = @Schema(implementation = String.class)) + ) + }) + @PutMapping("/{meetingId}/access-password") + public ApiResponse updateAccessPassword(HttpServletRequest request, + @PathVariable Long meetingId, + @RequestBody(required = false) LegacyMeetingAccessPasswordRequest command) { + AndroidAuthContext authContext = androidAuthService.authenticateHttp(request); + LoginUser loginUser = AndroidLoginUserSupport.requireLoginUser(authContext); + Meeting meeting = meetingAccessService.requireMeeting(meetingId); + if (!Objects.equals(meeting.getCreatorId(), loginUser.getUserId())) { + return ApiResponse.error("仅会议创建人可设置访问密码"); + } + String password = normalizePassword(command == null ? null : command.getPassword()); + meeting.setAccessPassword(password); + meetingService.updateById(meeting); + return ApiResponse.ok(password); + } + + @Operation(summary = "删除Android会议") + @ApiResponses({ + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "200", + description = "返回是否删除成功", + content = @Content(schema = @Schema(implementation = Boolean.class)) + ) + }) + @DeleteMapping("/{meetingId}") + public ApiResponse delete(HttpServletRequest request, @PathVariable Long meetingId) { + AndroidAuthContext authContext = androidAuthService.authenticateHttp(request); + LoginUser loginUser = AndroidLoginUserSupport.requireLoginUser(authContext); + Meeting meeting = meetingAccessService.requireMeeting(meetingId); + meetingAccessService.assertCanEditMeeting(meeting, loginUser); + meetingCommandService.deleteMeeting(meetingId); + return ApiResponse.ok(true); + } + + 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(asrTask) || Integer.valueOf(4).equals(meeting.getStatus())) { + return new LegacyMeetingPreviewResult( + "503", + buildFailureMessage(asrTask, "转写"), + buildProcessingPreview(meeting, summaryTask, processingStatus("转写或总结失败", 50, STAGE_AUDIO_TRANSCRIPTION)) + ); + } + if (isFailed(summaryTask)) { + return new LegacyMeetingPreviewResult( + "503", + buildFailureMessage(summaryTask, "总结"), + buildProcessingPreview(meeting, summaryTask, processingStatus("转写或总结失败", 75, STAGE_SUMMARY_GENERATION)) + ); + } + + Integer realtimeProgress = resolveRealtimeProgress(meetingId); + if (realtimeProgress != null) { + if (realtimeProgress < 90) { + return new LegacyMeetingPreviewResult( + "400", + "会议正在处理中", + buildProcessingPreview(meeting, summaryTask, processingStatus("正在转写音频", 50, STAGE_AUDIO_TRANSCRIPTION)) + ); + } + if (realtimeProgress == 90) { + return new LegacyMeetingPreviewResult( + "400", + "会议正在处理中", + buildProcessingPreview(meeting, summaryTask, processingStatus("正在生成总结", 75, STAGE_SUMMARY_GENERATION)) + ); + } + } + + boolean isSummaryStage = isSummaryStage(meeting.getStatus(), summaryTask); + boolean isAsrStage = isAsrStage(meeting.getStatus(), asrTask, hasAudio(meeting), isSummaryStage); + + if (!isAsrStage && !isSummaryStage) { + return new LegacyMeetingPreviewResult( + "400", + "会议正在处理中", + buildProcessingPreview(meeting, summaryTask, processingStatus("会议数据准备中", 25, STAGE_DATA_INITIALIZATION)) + ); + } + if (!isSummaryStage) { + return new LegacyMeetingPreviewResult( + "400", + "会议正在处理中", + buildProcessingPreview(meeting, summaryTask, processingStatus("正在转写音频", 50, STAGE_AUDIO_TRANSCRIPTION)) + ); + } + return new LegacyMeetingPreviewResult( + "400", + "会议正在处理中", + buildProcessingPreview(meeting, summaryTask, processingStatus("正在生成总结", 75, STAGE_SUMMARY_GENERATION)) + ); + } + + private LegacyMeetingPreviewDataResponse buildCompletedPreview(Meeting meeting, MeetingVO detail, AiTask summaryTask) { + LegacyMeetingPreviewDataResponse data = new LegacyMeetingPreviewDataResponse(); + data.setMeetingId(meeting.getId()); + data.setTitle(meeting.getTitle()); + data.setMeetingTime(formatDateTime(meeting.getMeetingTime())); + data.setSummary(detail.getSummaryContent()); + data.setCreatorUsername(resolveCreatorDisplayName(meeting.getCreatorId(), meeting.getCreatorName())); + Long promptId = resolvePromptId(summaryTask); + data.setPromptId(promptId); + data.setPromptName(resolvePromptName(promptId)); + List attendees = buildAttendees(meeting.getParticipants()); + data.setAttendees(attendees); + data.setAttendeesCount(attendees.size()); + data.setHasPassword(meeting.getAccessPassword() != null && !meeting.getAccessPassword().isBlank()); + data.setProcessingStatus(processingStatus("摘要已生成,可查看详情", 100, STAGE_COMPLETED)); + return data; + } + + private LegacyMeetingPreviewDataResponse buildProcessingPreview(Meeting meeting, + AiTask summaryTask, + LegacyMeetingProcessingStatusResponse status) { + LegacyMeetingPreviewDataResponse data = new LegacyMeetingPreviewDataResponse(); + data.setMeetingId(meeting.getId()); + data.setTitle(meeting.getTitle()); + data.setMeetingTime(formatDateTime(meeting.getMeetingTime())); + data.setCreatorUsername(resolveCreatorDisplayName(meeting.getCreatorId(), meeting.getCreatorName())); + Long promptId = resolvePromptId(summaryTask); + data.setPromptId(promptId); + data.setPromptName(resolvePromptName(promptId)); + data.setHasPassword(meeting.getAccessPassword() != null && !meeting.getAccessPassword().isBlank()); + data.setProcessingStatus(status); + return data; + } + + private LegacyMeetingProcessingStatusResponse processingStatus(String overallStatus, int overallProgress, String currentStage) { + return new LegacyMeetingProcessingStatusResponse(overallStatus, overallProgress, currentStage); + } + + private Integer resolveRealtimeProgress(Long meetingId) { + String rawProgress = redisTemplate.opsForValue().get(RedisKeys.meetingProgressKey(meetingId)); + if (rawProgress == null || rawProgress.isBlank()) { + return null; + } + try { + JsonNode progress = objectMapper.readTree(rawProgress); + return progress.hasNonNull("percent") ? progress.path("percent").asInt() : null; + } catch (Exception ignored) { + return null; + } + } + + private String buildFailureMessage(AiTask failedTask, String stageName) { + String error = failedTask == null || failedTask.getErrorMsg() == null || failedTask.getErrorMsg().isBlank() + ? "处理失败" + : failedTask.getErrorMsg(); + return "会议" + stageName + "失败: " + error; + } + + private boolean isRunningAsr(AiTask task) { + return task != null && (Integer.valueOf(0).equals(task.getStatus()) || Integer.valueOf(1).equals(task.getStatus())); + } + + private boolean isRunningSummary(AiTask task) { + return task != null && (Integer.valueOf(0).equals(task.getStatus()) || Integer.valueOf(1).equals(task.getStatus())); + } + + private boolean isFailed(AiTask task) { + return task != null && Integer.valueOf(3).equals(task.getStatus()); + } + + private AiTask findLatestTask(Long meetingId, String taskType) { + return aiTaskService.getOne(new LambdaQueryWrapper() + .eq(AiTask::getMeetingId, meetingId) + .eq(AiTask::getTaskType, taskType) + .orderByDesc(AiTask::getId) + .last("LIMIT 1")); + } + + private Long resolvePromptId(AiTask summaryTask) { + if (summaryTask == null || summaryTask.getTaskConfig() == null) { + return null; + } + Object rawPromptId = summaryTask.getTaskConfig().get("promptId"); + if (rawPromptId == null) { + return null; + } + if (rawPromptId instanceof Number number) { + return number.longValue(); + } + String value = String.valueOf(rawPromptId).trim(); + if (value.isEmpty()) { + return null; + } + try { + return Long.parseLong(value); + } catch (NumberFormatException ignored) { + return null; + } + } + + private String resolvePromptName(Long promptId) { + if (promptId == null) { + return null; + } + PromptTemplate template = promptTemplateService.getById(promptId); + return template == null ? null : template.getTemplateName(); + } + + private List buildAttendees(String participants) { + return buildAttendees(parseParticipantIds(participants)); + } + + private List buildAttendees(List participantIds) { + if (participantIds == null || participantIds.isEmpty()) { + return List.of(); + } + Map userMap = sysUserMapper.selectBatchIds(participantIds).stream() + .collect(Collectors.toMap(SysUser::getUserId, user -> user, (left, right) -> left, LinkedHashMap::new)); + + return participantIds.stream() + .map(userId -> { + SysUser user = userMap.get(userId); + String caption = user == null + ? String.valueOf(userId) + : (user.getDisplayName() != null ? user.getDisplayName() : user.getUsername()); + String username = user == null ? null : user.getUsername(); + return new LegacyMeetingAttendeeResponse(userId, username, caption); + }) + .toList(); + } + + private List 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 String resolveCreatorDisplayName(Long creatorId, String fallbackName) { + if (creatorId == null) { + return fallbackName; + } + SysUser creator = sysUserMapper.selectById(creatorId); + if (creator == null) { + return fallbackName; + } + if (creator.getDisplayName() != null && !creator.getDisplayName().isBlank()) { + return creator.getDisplayName(); + } + if (creator.getUsername() != null && !creator.getUsername().isBlank()) { + return creator.getUsername(); + } + return fallbackName; + } + + private boolean hasAudio(Meeting meeting) { + return meeting.getAudioUrl() != null && !meeting.getAudioUrl().isBlank(); + } + + private boolean isSummaryStage(Integer meetingStatus, AiTask summaryTask) { + return Integer.valueOf(2).equals(meetingStatus) || isRunningSummary(summaryTask); + } + + private boolean isAsrStage(Integer meetingStatus, AiTask asrTask, boolean hasAudio, boolean isSummaryStage) { + return Integer.valueOf(1).equals(meetingStatus) + || isRunningAsr(asrTask) + || (hasAudio && !isSummaryStage); + } + + private String formatDateTime(LocalDateTime value) { + return value == null ? null : value.toString(); + } +} diff --git a/backend/src/main/java/com/imeeting/controller/android/AndroidMeetingRealtimeController.java b/backend/src/main/java/com/imeeting/controller/android/AndroidMeetingRealtimeController.java index 3ccf1b2..73f3170 100644 --- a/backend/src/main/java/com/imeeting/controller/android/AndroidMeetingRealtimeController.java +++ b/backend/src/main/java/com/imeeting/controller/android/AndroidMeetingRealtimeController.java @@ -22,7 +22,11 @@ import com.imeeting.service.biz.MeetingRuntimeProfileResolver; import com.imeeting.service.biz.RealtimeMeetingSessionStateService; import com.imeeting.service.realtime.AndroidRealtimeSessionTicketService; import com.unisbase.common.ApiResponse; +import io.swagger.v3.oas.annotations.media.ArraySchema; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.Schema; import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.responses.ApiResponses; import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.servlet.http.HttpServletRequest; import lombok.RequiredArgsConstructor; @@ -56,6 +60,13 @@ public class AndroidMeetingRealtimeController { private final GrpcServerProperties grpcServerProperties; @Operation(summary = "创建Android实时会议") + @ApiResponses({ + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "200", + description = "返回实时会议创建结果与本次生效的运行时参数", + content = @Content(schema = @Schema(implementation = AndroidCreateRealtimeMeetingVO.class)) + ) + }) @PostMapping("/realtime/create") public ApiResponse createRealtimeMeeting(HttpServletRequest request, @RequestBody(required = false) AndroidCreateRealtimeMeetingCommand command) { @@ -104,6 +115,13 @@ public class AndroidMeetingRealtimeController { } @Operation(summary = "查询Android实时会议状态") + @ApiResponses({ + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "200", + description = "返回实时会议当前状态、恢复信息和连接状态", + content = @Content(schema = @Schema(implementation = RealtimeMeetingSessionStatusVO.class)) + ) + }) @GetMapping("/{id}/realtime/session-status") public ApiResponse getRealtimeSessionStatus(@PathVariable Long id, HttpServletRequest request) { AndroidAuthContext authContext = androidAuthService.authenticateHttp(request); @@ -113,6 +131,13 @@ public class AndroidMeetingRealtimeController { } @Operation(summary = "查询Android会议转写") + @ApiResponses({ + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "200", + description = "返回会议转写记录列表", + content = @Content(array = @ArraySchema(schema = @Schema(implementation = MeetingTranscriptVO.class))) + ) + }) @GetMapping("/{id}/transcripts") public ApiResponse> getTranscripts(@PathVariable Long id, HttpServletRequest request) { AndroidAuthContext authContext = androidAuthService.authenticateHttp(request); @@ -122,6 +147,13 @@ public class AndroidMeetingRealtimeController { } @Operation(summary = "暂停Android实时会议") + @ApiResponses({ + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "200", + description = "返回暂停后的实时会议状态", + content = @Content(schema = @Schema(implementation = RealtimeMeetingSessionStatusVO.class)) + ) + }) @PostMapping("/{id}/realtime/pause") public ApiResponse pauseRealtimeMeeting(@PathVariable Long id, HttpServletRequest request) { AndroidAuthContext authContext = androidAuthService.authenticateHttp(request); @@ -131,6 +163,13 @@ public class AndroidMeetingRealtimeController { } @Operation(summary = "完成Android实时会议") + @ApiResponses({ + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "200", + description = "返回实时会议完成是否成功", + content = @Content(schema = @Schema(implementation = Boolean.class)) + ) + }) @PostMapping("/{id}/realtime/complete") public ApiResponse completeRealtimeMeeting(@PathVariable Long id, HttpServletRequest request, @@ -147,6 +186,13 @@ public class AndroidMeetingRealtimeController { } @Operation(summary = "打开Android实时会议gRPC会话") + @ApiResponses({ + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "200", + description = "返回实时会议 gRPC 会话信息,包括连接参数和状态", + content = @Content(schema = @Schema(implementation = AndroidRealtimeGrpcSessionVO.class)) + ) + }) @PostMapping("/{id}/realtime/grpc-session") public ApiResponse openRealtimeGrpcSession(@PathVariable Long id, HttpServletRequest request, diff --git a/backend/src/main/java/com/imeeting/controller/android/AndroidPromptController.java b/backend/src/main/java/com/imeeting/controller/android/AndroidPromptController.java new file mode 100644 index 0000000..79e1586 --- /dev/null +++ b/backend/src/main/java/com/imeeting/controller/android/AndroidPromptController.java @@ -0,0 +1,69 @@ +package com.imeeting.controller.android; + +import com.imeeting.dto.android.AndroidAuthContext; +import com.imeeting.dto.biz.PromptTemplateVO; +import com.imeeting.service.android.AndroidAuthService; +import com.imeeting.service.biz.PromptTemplateService; +import com.unisbase.common.ApiResponse; +import com.unisbase.dto.PageResult; +import com.unisbase.security.LoginUser; +import io.swagger.v3.oas.annotations.media.ArraySchema; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.servlet.http.HttpServletRequest; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import java.util.List; + +@Tag(name = "Android提示词接口") +@RestController +@RequestMapping("/api/android/prompts") +@RequiredArgsConstructor +public class AndroidPromptController { + + private static final String LEGACY_MEETING_SCENE = "MEETING_TASK"; + + private final AndroidAuthService androidAuthService; + private final PromptTemplateService promptTemplateService; + + @Operation(summary = "查询场景提示词") + @ApiResponses({ + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "200", + description = "返回指定场景下当前用户可用且启用的提示词模板列表", + content = @Content(array = @ArraySchema(schema = @Schema(implementation = PromptTemplateVO.class))) + ) + }) + @GetMapping("/active/{scene}") + public ApiResponse> activePrompts(HttpServletRequest request, @PathVariable String scene) { + if (!LEGACY_MEETING_SCENE.equals(scene)) { + return ApiResponse.error("scene only supports MEETING_TASK"); + } + + AndroidAuthContext authContext = androidAuthService.authenticateHttp(request); + LoginUser loginUser = AndroidLoginUserSupport.requireLoginUser(authContext); + PageResult> result = promptTemplateService.pageTemplates( + 1, + 1000, + null, + null, + loginUser.getTenantId(), + loginUser.getUserId(), + loginUser.getIsPlatformAdmin(), + loginUser.getIsTenantAdmin() + ); + List enabledTemplates = result.getRecords() == null + ? List.of() + : result.getRecords().stream() + .filter(item -> Integer.valueOf(1).equals(item.getStatus())) + .toList(); + return ApiResponse.ok(enabledTemplates); + } +} diff --git a/backend/src/main/java/com/imeeting/controller/android/AndroidScreenSaverController.java b/backend/src/main/java/com/imeeting/controller/android/AndroidScreenSaverController.java index bf99c49..11808db 100644 --- a/backend/src/main/java/com/imeeting/controller/android/AndroidScreenSaverController.java +++ b/backend/src/main/java/com/imeeting/controller/android/AndroidScreenSaverController.java @@ -6,8 +6,12 @@ import com.imeeting.dto.android.AndroidScreenSaverItemVO; import com.imeeting.dto.biz.ScreenSaverSelectionResult; import com.imeeting.service.android.AndroidAuthService; import com.imeeting.service.biz.ScreenSaverService; +import com.imeeting.support.TaskSecurityContextRunner; import com.unisbase.common.ApiResponse; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.Schema; import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.responses.ApiResponses; import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.servlet.http.HttpServletRequest; import lombok.RequiredArgsConstructor; @@ -23,12 +27,20 @@ public class AndroidScreenSaverController { private final AndroidAuthService androidAuthService; private final ScreenSaverService screenSaverService; + private final TaskSecurityContextRunner taskSecurityContextRunner; @Operation(summary = "获取当前生效屏保") + @ApiResponses({ + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "200", + description = "返回当前生效的屏保配置和轮播项列表", + content = @Content(schema = @Schema(implementation = AndroidScreenSaverCatalogVO.class)) + ) + }) @GetMapping("/active") public ApiResponse active(HttpServletRequest request) { AndroidAuthContext authContext = androidAuthService.authenticateHttp(request); - ScreenSaverSelectionResult selection = screenSaverService.getActiveSelection(authContext == null ? null : authContext.getUserId()); + ScreenSaverSelectionResult selection = querySelection(authContext); AndroidScreenSaverCatalogVO vo = new AndroidScreenSaverCatalogVO(); vo.setRefreshIntervalSec(300); vo.setPlayMode("SEQUENTIAL"); @@ -46,4 +58,16 @@ public class AndroidScreenSaverController { }).toList()); return ApiResponse.ok(vo); } + + private ScreenSaverSelectionResult querySelection(AndroidAuthContext authContext) { + if (authContext == null || authContext.isAnonymous() + || authContext.getUserId() == null || authContext.getTenantId() == null) { + return screenSaverService.getActiveSelection(null); + } + return taskSecurityContextRunner.callAsTenantUser( + authContext.getTenantId(), + authContext.getUserId(), + () -> screenSaverService.getActiveSelection(authContext.getUserId()) + ); + } } diff --git a/backend/src/main/java/com/imeeting/controller/android/legacy/LegacyScreenSaverController.java b/backend/src/main/java/com/imeeting/controller/android/legacy/LegacyScreenSaverController.java index dad6cb1..f6b47cc 100644 --- a/backend/src/main/java/com/imeeting/controller/android/legacy/LegacyScreenSaverController.java +++ b/backend/src/main/java/com/imeeting/controller/android/legacy/LegacyScreenSaverController.java @@ -3,9 +3,16 @@ package com.imeeting.controller.android.legacy; import com.imeeting.dto.android.legacy.LegacyApiResponse; import com.imeeting.dto.android.legacy.LegacyScreenSaverItemResponse; import com.imeeting.service.android.legacy.LegacyScreenSaverAdapterService; +import com.imeeting.support.TaskSecurityContextRunner; +import com.unisbase.dto.InternalAuthCheckResponse; +import com.unisbase.security.LoginUser; +import com.unisbase.service.TokenValidationService; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.servlet.http.HttpServletRequest; import lombok.RequiredArgsConstructor; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.util.StringUtils; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; @@ -18,11 +25,77 @@ import java.util.List; @RequiredArgsConstructor public class LegacyScreenSaverController { - private final LegacyScreenSaverAdapterService legacyScreenSaverAdapterService; + private static final String HEADER_AUTHORIZATION = "Authorization"; + private static final String BEARER_PREFIX = "Bearer "; - @Operation(summary = "查询启用的屏保列表") - @GetMapping("/active") - public LegacyApiResponse> active() { - return LegacyApiResponse.ok(legacyScreenSaverAdapterService.listActiveScreenSavers()); + private final LegacyScreenSaverAdapterService legacyScreenSaverAdapterService; + private final TokenValidationService tokenValidationService; + private final TaskSecurityContextRunner taskSecurityContextRunner; + + @Operation(summary = "查询启用的屏保列表") + @GetMapping("/active") + public LegacyApiResponse> active(HttpServletRequest request) { + LoginUser loginUser = resolveLoginUser(request); + return LegacyApiResponse.ok(queryActive(loginUser)); + } + + private List queryActive(LoginUser loginUser) { + if (loginUser == null || loginUser.getUserId() == null || loginUser.getTenantId() == null) { + return legacyScreenSaverAdapterService.listActiveScreenSavers(null); } + return taskSecurityContextRunner.callAsTenantUser( + loginUser.getTenantId(), + loginUser.getUserId(), + () -> legacyScreenSaverAdapterService.listActiveScreenSavers(loginUser.getUserId()) + ); + } + + private LoginUser resolveLoginUser(HttpServletRequest request) { + LoginUser loginUser = currentLoginUserFromContext(); + if (loginUser != null) { + return loginUser; + } + + String token = resolveBearerToken(request); + if (!StringUtils.hasText(token)) { + return null; + } + + InternalAuthCheckResponse authResult = tokenValidationService.validateAccessToken(token); + if (authResult == null || !authResult.isValid() + || authResult.getUserId() == null || authResult.getTenantId() == null) { + return null; + } + + LoginUser resolved = new LoginUser( + authResult.getUserId(), + authResult.getTenantId(), + authResult.getUsername(), + authResult.getPlatformAdmin(), + authResult.getTenantAdmin(), + authResult.getPermissions() + ); + resolved.setDisplayName(authResult.getDisplayName()); + return resolved; + } + + private LoginUser currentLoginUserFromContext() { + if (SecurityContextHolder.getContext().getAuthentication() == null + || !(SecurityContextHolder.getContext().getAuthentication().getPrincipal() instanceof LoginUser loginUser)) { + return null; + } + if (loginUser.getUserId() == null || loginUser.getTenantId() == null) { + return null; + } + return loginUser; + } + + private String resolveBearerToken(HttpServletRequest request) { + String authorization = request.getHeader(HEADER_AUTHORIZATION); + if (!StringUtils.hasText(authorization) || !authorization.startsWith(BEARER_PREFIX)) { + return null; + } + String token = authorization.substring(BEARER_PREFIX.length()).trim(); + return token.isEmpty() ? null : token; + } } diff --git a/backend/src/main/java/com/imeeting/controller/biz/ScreenSaverController.java b/backend/src/main/java/com/imeeting/controller/biz/ScreenSaverController.java index c04e8ad..a37e6a0 100644 --- a/backend/src/main/java/com/imeeting/controller/biz/ScreenSaverController.java +++ b/backend/src/main/java/com/imeeting/controller/biz/ScreenSaverController.java @@ -58,6 +58,17 @@ public class ScreenSaverController { return ApiResponse.ok(screenSaverService.update(id, dto, currentLoginUser())); } + @Operation(summary = "更新屏保状态") + @PutMapping("/{id}/status") + @PreAuthorize("isAuthenticated()") + public ApiResponse updateStatus(@PathVariable Long id, @RequestParam Integer status) { + boolean success = screenSaverService.updateStatus(id, status, currentLoginUser()); + if (!success) { + return ApiResponse.error("Screen saver not found or no permission"); + } + return ApiResponse.ok(true); + } + @Operation(summary = "删除屏保") @DeleteMapping("/{id}") @PreAuthorize("isAuthenticated()") diff --git a/backend/src/main/java/com/imeeting/dto/android/AndroidCreateRealtimeMeetingVO.java b/backend/src/main/java/com/imeeting/dto/android/AndroidCreateRealtimeMeetingVO.java index 7cd6bde..f10592c 100644 --- a/backend/src/main/java/com/imeeting/dto/android/AndroidCreateRealtimeMeetingVO.java +++ b/backend/src/main/java/com/imeeting/dto/android/AndroidCreateRealtimeMeetingVO.java @@ -2,23 +2,40 @@ package com.imeeting.dto.android; import com.imeeting.dto.biz.RealtimeMeetingResumeConfig; import com.imeeting.dto.biz.RealtimeMeetingSessionStatusVO; +import io.swagger.v3.oas.annotations.media.Schema; import lombok.Data; +@Schema(description = "Android 实时会议创建结果") @Data public class AndroidCreateRealtimeMeetingVO { + @Schema(description = "会议 ID") private Long meetingId; + @Schema(description = "会议标题") private String title; + @Schema(description = "主持人用户 ID") private Long hostUserId; + @Schema(description = "主持人名称") private String hostName; + @Schema(description = "实时音频采样率") private Integer sampleRate; + @Schema(description = "音频通道数") private Integer channels; + @Schema(description = "音频编码格式") private String encoding; + @Schema(description = "最终生效的 ASR 模型 ID") private Long resolvedAsrModelId; + @Schema(description = "最终生效的 ASR 模型名称") private String resolvedAsrModelName; + @Schema(description = "最终生效的总结模型 ID") private Long resolvedSummaryModelId; + @Schema(description = "最终生效的总结模型名称") private String resolvedSummaryModelName; + @Schema(description = "最终生效的提示词模板 ID") private Long resolvedPromptId; + @Schema(description = "最终生效的提示词模板名称") private String resolvedPromptName; + @Schema(description = "恢复会议时使用的运行时参数") private RealtimeMeetingResumeConfig resumeConfig; + @Schema(description = "当前实时会议状态") private RealtimeMeetingSessionStatusVO status; } diff --git a/backend/src/main/java/com/imeeting/dto/android/AndroidRealtimeGrpcSessionVO.java b/backend/src/main/java/com/imeeting/dto/android/AndroidRealtimeGrpcSessionVO.java index e1dce32..5bf028f 100644 --- a/backend/src/main/java/com/imeeting/dto/android/AndroidRealtimeGrpcSessionVO.java +++ b/backend/src/main/java/com/imeeting/dto/android/AndroidRealtimeGrpcSessionVO.java @@ -2,16 +2,26 @@ package com.imeeting.dto.android; import com.imeeting.dto.biz.RealtimeMeetingResumeConfig; import com.imeeting.dto.biz.RealtimeMeetingSessionStatusVO; +import io.swagger.v3.oas.annotations.media.Schema; import lombok.Data; +@Schema(description = "Android 实时会议 gRPC 会话信息") @Data public class AndroidRealtimeGrpcSessionVO { + @Schema(description = "会议 ID") private Long meetingId; + @Schema(description = "实时流会话令牌") private String streamToken; + @Schema(description = "令牌剩余有效秒数") private Long expiresInSeconds; + @Schema(description = "实时音频采样率") private Integer sampleRate; + @Schema(description = "音频通道数") private Integer channels; + @Schema(description = "音频编码格式") private String encoding; + @Schema(description = "恢复会议时使用的运行时参数") private RealtimeMeetingResumeConfig resumeConfig; + @Schema(description = "当前实时会议状态") private RealtimeMeetingSessionStatusVO status; } diff --git a/backend/src/main/java/com/imeeting/dto/android/AndroidScreenSaverCatalogVO.java b/backend/src/main/java/com/imeeting/dto/android/AndroidScreenSaverCatalogVO.java index e978e21..26b2d88 100644 --- a/backend/src/main/java/com/imeeting/dto/android/AndroidScreenSaverCatalogVO.java +++ b/backend/src/main/java/com/imeeting/dto/android/AndroidScreenSaverCatalogVO.java @@ -1,13 +1,19 @@ package com.imeeting.dto.android; +import io.swagger.v3.oas.annotations.media.Schema; import lombok.Data; import java.util.List; +@Schema(description = "Android 屏保配置") @Data public class AndroidScreenSaverCatalogVO { + @Schema(description = "客户端建议刷新间隔,单位秒") private Integer refreshIntervalSec; + @Schema(description = "播放模式") private String playMode; + @Schema(description = "当前屏保来源范围") private String sourceScope; + @Schema(description = "屏保图片项列表") private List items; } diff --git a/backend/src/main/java/com/imeeting/dto/android/AndroidScreenSaverItemVO.java b/backend/src/main/java/com/imeeting/dto/android/AndroidScreenSaverItemVO.java index 81cc769..22f37e2 100644 --- a/backend/src/main/java/com/imeeting/dto/android/AndroidScreenSaverItemVO.java +++ b/backend/src/main/java/com/imeeting/dto/android/AndroidScreenSaverItemVO.java @@ -1,14 +1,23 @@ package com.imeeting.dto.android; +import io.swagger.v3.oas.annotations.media.Schema; import lombok.Data; +@Schema(description = "Android 屏保图片项") @Data public class AndroidScreenSaverItemVO { + @Schema(description = "屏保项 ID") private Long id; + @Schema(description = "屏保名称") private String name; + @Schema(description = "屏保图片地址") private String imageUrl; + @Schema(description = "屏保描述") private String description; + @Schema(description = "单张展示时长,单位秒") private Integer displayDurationSec; + @Schema(description = "排序值") private Integer sortOrder; + @Schema(description = "最近更新时间") private String updatedAt; } diff --git a/backend/src/main/java/com/imeeting/dto/android/legacy/LegacyMeetingAttendeeResponse.java b/backend/src/main/java/com/imeeting/dto/android/legacy/LegacyMeetingAttendeeResponse.java index 2838b61..5b0fba6 100644 --- a/backend/src/main/java/com/imeeting/dto/android/legacy/LegacyMeetingAttendeeResponse.java +++ b/backend/src/main/java/com/imeeting/dto/android/legacy/LegacyMeetingAttendeeResponse.java @@ -1,18 +1,23 @@ package com.imeeting.dto.android.legacy; import com.fasterxml.jackson.annotation.JsonProperty; +import io.swagger.v3.oas.annotations.media.Schema; import lombok.AllArgsConstructor; import lombok.Data; import lombok.NoArgsConstructor; +@Schema(description = "参会人信息") @Data @NoArgsConstructor @AllArgsConstructor public class LegacyMeetingAttendeeResponse { @JsonProperty("user_id") + @Schema(description = "用户 ID") private Long userId; + @Schema(description = "用户名") private String username; + @Schema(description = "展示名称") private String caption; } diff --git a/backend/src/main/java/com/imeeting/dto/android/legacy/LegacyMeetingPreviewDataResponse.java b/backend/src/main/java/com/imeeting/dto/android/legacy/LegacyMeetingPreviewDataResponse.java index 9549642..b464b27 100644 --- a/backend/src/main/java/com/imeeting/dto/android/legacy/LegacyMeetingPreviewDataResponse.java +++ b/backend/src/main/java/com/imeeting/dto/android/legacy/LegacyMeetingPreviewDataResponse.java @@ -1,39 +1,52 @@ package com.imeeting.dto.android.legacy; import com.fasterxml.jackson.annotation.JsonProperty; +import io.swagger.v3.oas.annotations.media.Schema; import lombok.Data; import java.util.List; +@Schema(description = "Android 会议预览数据") @Data public class LegacyMeetingPreviewDataResponse { @JsonProperty("meeting_id") + @Schema(description = "会议 ID") private Long meetingId; + @Schema(description = "会议标题") private String title; @JsonProperty("meeting_time") + @Schema(description = "会议时间") private String meetingTime; + @Schema(description = "会议摘要") private String summary; @JsonProperty("creator_username") + @Schema(description = "创建人名称") private String creatorUsername; @JsonProperty("prompt_id") + @Schema(description = "提示词模板 ID") private Long promptId; @JsonProperty("prompt_name") + @Schema(description = "提示词模板名称") private String promptName; + @Schema(description = "参会人列表") private List attendees; @JsonProperty("attendees_count") + @Schema(description = "参会人数") private Integer attendeesCount; @JsonProperty("has_password") + @Schema(description = "是否设置访问密码") private Boolean hasPassword; @JsonProperty("processing_status") + @Schema(description = "处理状态") private LegacyMeetingProcessingStatusResponse processingStatus; } diff --git a/backend/src/main/java/com/imeeting/dto/android/legacy/LegacyMeetingProcessingStatusResponse.java b/backend/src/main/java/com/imeeting/dto/android/legacy/LegacyMeetingProcessingStatusResponse.java index e4abd45..18456fd 100644 --- a/backend/src/main/java/com/imeeting/dto/android/legacy/LegacyMeetingProcessingStatusResponse.java +++ b/backend/src/main/java/com/imeeting/dto/android/legacy/LegacyMeetingProcessingStatusResponse.java @@ -1,20 +1,25 @@ package com.imeeting.dto.android.legacy; import com.fasterxml.jackson.annotation.JsonProperty; +import io.swagger.v3.oas.annotations.media.Schema; import lombok.AllArgsConstructor; import lombok.Data; import lombok.NoArgsConstructor; +@Schema(description = "会议处理状态") @Data @NoArgsConstructor @AllArgsConstructor public class LegacyMeetingProcessingStatusResponse { @JsonProperty("overall_status") + @Schema(description = "整体状态说明") private String overallStatus; @JsonProperty("overall_progress") + @Schema(description = "整体进度百分比") private Integer overallProgress; @JsonProperty("current_stage") + @Schema(description = "当前阶段") private String currentStage; } diff --git a/backend/src/main/java/com/imeeting/dto/android/legacy/LegacyUploadAudioResponse.java b/backend/src/main/java/com/imeeting/dto/android/legacy/LegacyUploadAudioResponse.java index fe496b9..30f506c 100644 --- a/backend/src/main/java/com/imeeting/dto/android/legacy/LegacyUploadAudioResponse.java +++ b/backend/src/main/java/com/imeeting/dto/android/legacy/LegacyUploadAudioResponse.java @@ -1,17 +1,21 @@ package com.imeeting.dto.android.legacy; import com.fasterxml.jackson.annotation.JsonProperty; +import io.swagger.v3.oas.annotations.media.Schema; import lombok.AllArgsConstructor; import lombok.Data; import lombok.NoArgsConstructor; +@Schema(description = "Android 上传会议音频结果") @Data @NoArgsConstructor @AllArgsConstructor public class LegacyUploadAudioResponse { @JsonProperty("meeting_id") + @Schema(description = "会议 ID") private Long meetingId; @JsonProperty("audio_url") + @Schema(description = "上传后的音频访问地址") private String audioUrl; } diff --git a/backend/src/main/java/com/imeeting/dto/biz/AiModelVO.java b/backend/src/main/java/com/imeeting/dto/biz/AiModelVO.java index 9fac1b9..aa7a553 100644 --- a/backend/src/main/java/com/imeeting/dto/biz/AiModelVO.java +++ b/backend/src/main/java/com/imeeting/dto/biz/AiModelVO.java @@ -1,27 +1,46 @@ package com.imeeting.dto.biz; +import io.swagger.v3.oas.annotations.media.Schema; import lombok.Data; import java.math.BigDecimal; import java.time.LocalDateTime; import java.util.Map; +@Schema(description = "AI 模型信息") @Data public class AiModelVO { + @Schema(description = "模型 ID") private Long id; + @Schema(description = "租户 ID") private Long tenantId; + @Schema(description = "模型类型") private String modelType; + @Schema(description = "模型名称") private String modelName; + @Schema(description = "提供方") private String provider; + @Schema(description = "服务基础地址") private String baseUrl; + @Schema(description = "接口路径") private String apiPath; + @Schema(description = "接口密钥,返回时通常为脱敏值") private String apiKey; // Will be masked in actual implementation + @Schema(description = "模型编码") private String modelCode; + @Schema(description = "WebSocket 地址") private String wsUrl; + @Schema(description = "温度参数") private BigDecimal temperature; + @Schema(description = "TopP 参数") private BigDecimal topP; + @Schema(description = "多媒体配置") private Map mediaConfig; + @Schema(description = "是否为默认模型") private Integer isDefault; + @Schema(description = "启用状态") private Integer status; + @Schema(description = "备注") private String remark; + @Schema(description = "创建时间") private LocalDateTime createdAt; } diff --git a/backend/src/main/java/com/imeeting/dto/biz/MeetingTranscriptVO.java b/backend/src/main/java/com/imeeting/dto/biz/MeetingTranscriptVO.java index d650a72..5cbf484 100644 --- a/backend/src/main/java/com/imeeting/dto/biz/MeetingTranscriptVO.java +++ b/backend/src/main/java/com/imeeting/dto/biz/MeetingTranscriptVO.java @@ -1,15 +1,24 @@ package com.imeeting.dto.biz; +import io.swagger.v3.oas.annotations.media.Schema; import lombok.Data; import java.time.LocalDateTime; +@Schema(description = "会议转写记录") @Data public class MeetingTranscriptVO { + @Schema(description = "转写记录 ID") private Long id; + @Schema(description = "说话人标识") private String speakerId; + @Schema(description = "说话人名称") private String speakerName; + @Schema(description = "说话人标签") private String speakerLabel; + @Schema(description = "转写文本内容") private String content; + @Schema(description = "开始时间,单位毫秒") private Integer startTime; + @Schema(description = "结束时间,单位毫秒") private Integer endTime; } diff --git a/backend/src/main/java/com/imeeting/dto/biz/MeetingVO.java b/backend/src/main/java/com/imeeting/dto/biz/MeetingVO.java index f8a870a..fdb44c2 100644 --- a/backend/src/main/java/com/imeeting/dto/biz/MeetingVO.java +++ b/backend/src/main/java/com/imeeting/dto/biz/MeetingVO.java @@ -1,37 +1,60 @@ package com.imeeting.dto.biz; import com.fasterxml.jackson.annotation.JsonFormat; +import io.swagger.v3.oas.annotations.media.Schema; import lombok.Data; import java.time.LocalDateTime; import java.util.List; import java.util.Map; +@Schema(description = "会议详情返回对象") @Data public class MeetingVO { + @Schema(description = "会议 ID") private Long id; + @Schema(description = "租户 ID") private Long tenantId; + @Schema(description = "创建人用户 ID") private Long creatorId; + @Schema(description = "创建人名称") private String creatorName; + @Schema(description = "主持人用户 ID") private Long hostUserId; + @Schema(description = "主持人名称") private String hostName; + @Schema(description = "会议标题") private String title; @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") + @Schema(description = "会议时间") private LocalDateTime meetingTime; + @Schema(description = "参会人 ID 串,逗号分隔") private String participants; + @Schema(description = "参会人 ID 列表") private List participantIds; + @Schema(description = "标签串") private String tags; + @Schema(description = "音频地址") private String audioUrl; + @Schema(description = "音频保存状态") private String audioSaveStatus; + @Schema(description = "音频保存说明") private String audioSaveMessage; + @Schema(description = "访问密码") private String accessPassword; + @Schema(description = "音频时长,单位秒") private Integer duration; + @Schema(description = "会议摘要内容") private String summaryContent; + @Schema(description = "最后一次用户补充提示词") private String lastUserPrompt; + @Schema(description = "分析结果") private Map analysis; + @Schema(description = "会议状态") private Integer status; @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") + @Schema(description = "创建时间") private LocalDateTime createdAt; } diff --git a/backend/src/main/java/com/imeeting/dto/biz/PromptTemplateVO.java b/backend/src/main/java/com/imeeting/dto/biz/PromptTemplateVO.java index f58d4d0..2a47d32 100644 --- a/backend/src/main/java/com/imeeting/dto/biz/PromptTemplateVO.java +++ b/backend/src/main/java/com/imeeting/dto/biz/PromptTemplateVO.java @@ -1,22 +1,38 @@ package com.imeeting.dto.biz; +import io.swagger.v3.oas.annotations.media.Schema; import lombok.Data; import java.time.LocalDateTime; +@Schema(description = "提示词模板信息") @Data public class PromptTemplateVO { + @Schema(description = "模板 ID") private Long id; + @Schema(description = "租户 ID") private Long tenantId; + @Schema(description = "创建人用户 ID") private Long creatorId; + @Schema(description = "模板名称") private String templateName; + @Schema(description = "模板描述") private String description; + @Schema(description = "模板分类") private String category; + @Schema(description = "是否为系统模板") private Integer isSystem; + @Schema(description = "标签列表") private java.util.List tags; + @Schema(description = "使用次数") private Integer usageCount; + @Schema(description = "提示词正文") private String promptContent; + @Schema(description = "启用状态") private Integer status; + @Schema(description = "备注") private String remark; + @Schema(description = "创建时间") private LocalDateTime createdAt; + @Schema(description = "更新时间") private LocalDateTime updatedAt; } diff --git a/backend/src/main/java/com/imeeting/dto/biz/RealtimeMeetingResumeConfig.java b/backend/src/main/java/com/imeeting/dto/biz/RealtimeMeetingResumeConfig.java index 4a55d86..7906eba 100644 --- a/backend/src/main/java/com/imeeting/dto/biz/RealtimeMeetingResumeConfig.java +++ b/backend/src/main/java/com/imeeting/dto/biz/RealtimeMeetingResumeConfig.java @@ -1,19 +1,30 @@ package com.imeeting.dto.biz; +import io.swagger.v3.oas.annotations.media.Schema; import lombok.Data; import java.util.List; import java.util.Map; +@Schema(description = "实时会议恢复配置") @Data public class RealtimeMeetingResumeConfig { + @Schema(description = "ASR 模型 ID") private Long asrModelId; + @Schema(description = "识别模式") private String mode; + @Schema(description = "识别语言") private String language; + @Schema(description = "是否开启说话人区分") private Integer useSpkId; + @Schema(description = "是否开启标点恢复") private Boolean enablePunctuation; + @Schema(description = "是否开启 ITN 归一化") private Boolean enableItn; + @Schema(description = "是否开启文本润色") private Boolean enableTextRefine; + @Schema(description = "是否保存音频") private Boolean saveAudio; + @Schema(description = "热词列表") private List> hotwords; } diff --git a/backend/src/main/java/com/imeeting/dto/biz/RealtimeMeetingSessionStatusVO.java b/backend/src/main/java/com/imeeting/dto/biz/RealtimeMeetingSessionStatusVO.java index b802e7d..110b024 100644 --- a/backend/src/main/java/com/imeeting/dto/biz/RealtimeMeetingSessionStatusVO.java +++ b/backend/src/main/java/com/imeeting/dto/biz/RealtimeMeetingSessionStatusVO.java @@ -1,15 +1,25 @@ package com.imeeting.dto.biz; +import io.swagger.v3.oas.annotations.media.Schema; import lombok.Data; +@Schema(description = "实时会议会话状态") @Data public class RealtimeMeetingSessionStatusVO { + @Schema(description = "会议 ID") private Long meetingId; + @Schema(description = "实时会议状态") private String status; + @Schema(description = "是否已存在转写内容") private Boolean hasTranscript; + @Schema(description = "是否允许恢复") private Boolean canResume; + @Schema(description = "距离恢复过期剩余秒数") private Long remainingSeconds; + @Schema(description = "恢复过期时间戳") private Long resumeExpireAt; + @Schema(description = "是否存在活动连接") private Boolean activeConnection; + @Schema(description = "恢复会议所需参数") private RealtimeMeetingResumeConfig resumeConfig; } diff --git a/backend/src/main/java/com/imeeting/entity/biz/ScreenSaverUserConfig.java b/backend/src/main/java/com/imeeting/entity/biz/ScreenSaverUserConfig.java new file mode 100644 index 0000000..b3d900d --- /dev/null +++ b/backend/src/main/java/com/imeeting/entity/biz/ScreenSaverUserConfig.java @@ -0,0 +1,26 @@ +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 io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; +import lombok.EqualsAndHashCode; + +@Data +@EqualsAndHashCode(callSuper = true) +@Schema(description = "屏保用户配置实体") +@TableName("biz_screen_saver_user_config") +public class ScreenSaverUserConfig extends BaseEntity { + + @TableId(value = "id", type = IdType.AUTO) + @Schema(description = "配置 ID") + private Long id; + + @Schema(description = "用户 ID") + private Long userId; + + @Schema(description = "屏保素材 ID") + private Long screenSaverId; +} diff --git a/backend/src/main/java/com/imeeting/mapper/biz/ScreenSaverUserConfigMapper.java b/backend/src/main/java/com/imeeting/mapper/biz/ScreenSaverUserConfigMapper.java new file mode 100644 index 0000000..94e2695 --- /dev/null +++ b/backend/src/main/java/com/imeeting/mapper/biz/ScreenSaverUserConfigMapper.java @@ -0,0 +1,9 @@ +package com.imeeting.mapper.biz; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.imeeting.entity.biz.ScreenSaverUserConfig; +import org.apache.ibatis.annotations.Mapper; + +@Mapper +public interface ScreenSaverUserConfigMapper extends BaseMapper { +} diff --git a/backend/src/main/java/com/imeeting/service/android/impl/AndroidAuthServiceImpl.java b/backend/src/main/java/com/imeeting/service/android/impl/AndroidAuthServiceImpl.java index c6a290f..97e0a4f 100644 --- a/backend/src/main/java/com/imeeting/service/android/impl/AndroidAuthServiceImpl.java +++ b/backend/src/main/java/com/imeeting/service/android/impl/AndroidAuthServiceImpl.java @@ -19,8 +19,6 @@ import org.springframework.util.StringUtils; public class AndroidAuthServiceImpl implements AndroidAuthService { private static final String HEADER_DEVICE_ID = "X-Android-Device-Id"; - private static final String HEADER_TENANT_CODE = "X-Android-Tenant-Code"; - private static final String HEADER_ACCESS_TOKEN = "X-Android-Access-Token"; private static final String HEADER_APP_ID = "X-Android-App-Id"; private static final String HEADER_APP_VERSION = "X-Android-App-Version"; private static final String HEADER_PLATFORM = "X-Android-Platform"; @@ -35,11 +33,11 @@ public class AndroidAuthServiceImpl implements AndroidAuthService { ClientAuth.AuthType authType = auth == null ? ClientAuth.AuthType.AUTH_TYPE_UNSPECIFIED : auth.getAuthType(); if (authType == ClientAuth.AuthType.USER_JWT) { InternalAuthCheckResponse authResult = validateToken(auth == null ? null : auth.getAccessToken()); - return buildContext(authType.name(), false, auth == null ? null : auth.getDeviceId(), auth == null ? null : auth.getTenantCode(), auth == null ? null : auth.getAppId(), + return buildContext(authType.name(), false, auth == null ? null : auth.getDeviceId(),auth == null ? null : auth.getAppId(), auth == null ? null : auth.getAppVersion(), auth == null ? null : auth.getPlatform(), auth == null ? null : auth.getAccessToken(), fallbackDeviceId, authResult, null); } if (authType == ClientAuth.AuthType.DEVICE_TOKEN) { - return buildContext(authType.name(), false, auth == null ? null : auth.getDeviceId(), auth == null ? null : auth.getTenantCode(), auth == null ? null : auth.getAppId(), + return buildContext(authType.name(), false, auth == null ? null : auth.getDeviceId(), auth == null ? null : auth.getAppId(), auth == null ? null : auth.getAppVersion(), auth == null ? null : auth.getPlatform(), auth == null ? null : auth.getAccessToken(), fallbackDeviceId, null, null); } if (properties.isEnabled() && !properties.isAllowAnonymous()) { @@ -47,7 +45,6 @@ public class AndroidAuthServiceImpl implements AndroidAuthService { } return buildContext("NONE", true, auth == null ? null : auth.getDeviceId(), - auth == null ? null : auth.getTenantCode(), auth == null ? null : auth.getAppId(), auth == null ? null : auth.getAppVersion(), auth == null ? null : auth.getPlatform(), @@ -61,13 +58,19 @@ public class AndroidAuthServiceImpl implements AndroidAuthService { public AndroidAuthContext authenticateHttp(HttpServletRequest request) { LoginUser loginUser = currentLoginUser(); String resolvedToken = resolveHttpToken(request); + String deviceId = firstHeader(request, HEADER_DEVICE_ID); + String appId = request.getHeader(HEADER_APP_ID); + String appVersion = firstHeader(request, HEADER_APP_VERSION); + String platform = request.getHeader(HEADER_PLATFORM); + + requireAndroidHttpHeaders(deviceId, appVersion, platform); + if (loginUser != null) { return buildContext("USER_JWT", false, - request.getHeader(HEADER_DEVICE_ID), - request.getHeader(HEADER_TENANT_CODE), - request.getHeader(HEADER_APP_ID), - request.getHeader(HEADER_APP_VERSION), - request.getHeader(HEADER_PLATFORM), + deviceId, + appId, + appVersion, + platform, resolvedToken, null, null, @@ -77,34 +80,30 @@ public class AndroidAuthServiceImpl implements AndroidAuthService { if (StringUtils.hasText(resolvedToken)) { InternalAuthCheckResponse authResult = validateToken(resolvedToken); return buildContext("USER_JWT", false, - request.getHeader(HEADER_DEVICE_ID), - request.getHeader(HEADER_TENANT_CODE), - request.getHeader(HEADER_APP_ID), - request.getHeader(HEADER_APP_VERSION), - request.getHeader(HEADER_PLATFORM), + deviceId, + appId, + appVersion, + platform, resolvedToken, null, authResult, null); } - - if (properties.isEnabled() && !properties.isAllowAnonymous()) { - throw new RuntimeException("Android HTTP auth is required"); + if (properties.isAllowAnonymous()) { + return buildContext("NONE", true, + deviceId, + appId, + appVersion, + platform, + null, + null, + null, + null); } - - return buildContext("NONE", true, - request.getHeader(HEADER_DEVICE_ID), - request.getHeader(HEADER_TENANT_CODE), - request.getHeader(HEADER_APP_ID), - request.getHeader(HEADER_APP_VERSION), - request.getHeader(HEADER_PLATFORM), - request.getHeader(HEADER_ACCESS_TOKEN), - null, - null, - null); + throw new RuntimeException("Android HTTP access token is required"); } - private AndroidAuthContext buildContext(String authMode, boolean anonymous, String deviceId, String tenantCode, + private AndroidAuthContext buildContext(String authMode, boolean anonymous, String deviceId, String appId, String appVersion, String platform, String accessToken, String fallbackDeviceId, InternalAuthCheckResponse authResult, LoginUser loginUser) { String resolvedDeviceId = StringUtils.hasText(deviceId) ? deviceId : fallbackDeviceId; @@ -115,7 +114,6 @@ public class AndroidAuthServiceImpl implements AndroidAuthService { context.setAuthMode(authMode); context.setAnonymous(anonymous); context.setDeviceId(resolvedDeviceId.trim()); - context.setTenantCode(StringUtils.hasText(tenantCode) ? tenantCode.trim() : null); context.setAppId(StringUtils.hasText(appId) ? appId.trim() : null); context.setAppVersion(StringUtils.hasText(appVersion) ? appVersion.trim() : null); context.setPlatform(StringUtils.hasText(platform) ? platform.trim() : "android"); @@ -164,10 +162,35 @@ public class AndroidAuthServiceImpl implements AndroidAuthService { private String resolveHttpToken(HttpServletRequest request) { String authorization = request.getHeader(HEADER_AUTHORIZATION); - if (StringUtils.hasText(authorization) && authorization.startsWith(BEARER_PREFIX)) { - return authorization.substring(BEARER_PREFIX.length()).trim(); + if (!StringUtils.hasText(authorization)) { + return null; + } + if (!authorization.startsWith(BEARER_PREFIX)) { + throw new RuntimeException("Android HTTP access token is invalid"); + } + return authorization.substring(BEARER_PREFIX.length()).trim(); + } + + private String firstHeader(HttpServletRequest request, String... names) { + for (String name : names) { + String value = request.getHeader(name); + if (StringUtils.hasText(value)) { + return value.trim(); + } + } + return null; + } + + private void requireAndroidHttpHeaders(String deviceId, String appVersion, String platform) { + if (!StringUtils.hasText(deviceId)) { + throw new RuntimeException("Android device_id is required"); + } + if (!StringUtils.hasText(appVersion)) { + throw new RuntimeException("Android-App-Version is required"); + } + if (!StringUtils.hasText(platform)) { + throw new RuntimeException("X-Android-Platform is required"); } - return normalizeToken(request.getHeader(HEADER_ACCESS_TOKEN)); } private String normalizeToken(String token) { diff --git a/backend/src/main/java/com/imeeting/service/android/legacy/LegacyScreenSaverAdapterService.java b/backend/src/main/java/com/imeeting/service/android/legacy/LegacyScreenSaverAdapterService.java index 7c9f97b..32939cc 100644 --- a/backend/src/main/java/com/imeeting/service/android/legacy/LegacyScreenSaverAdapterService.java +++ b/backend/src/main/java/com/imeeting/service/android/legacy/LegacyScreenSaverAdapterService.java @@ -5,5 +5,5 @@ import com.imeeting.dto.android.legacy.LegacyScreenSaverItemResponse; import java.util.List; public interface LegacyScreenSaverAdapterService { - List listActiveScreenSavers(); + List listActiveScreenSavers(Long userId); } diff --git a/backend/src/main/java/com/imeeting/service/android/legacy/impl/LegacyScreenSaverAdapterServiceImpl.java b/backend/src/main/java/com/imeeting/service/android/legacy/impl/LegacyScreenSaverAdapterServiceImpl.java index b1f930e..b8ebc67 100644 --- a/backend/src/main/java/com/imeeting/service/android/legacy/impl/LegacyScreenSaverAdapterServiceImpl.java +++ b/backend/src/main/java/com/imeeting/service/android/legacy/impl/LegacyScreenSaverAdapterServiceImpl.java @@ -16,8 +16,8 @@ public class LegacyScreenSaverAdapterServiceImpl implements LegacyScreenSaverAda private final ScreenSaverService screenSaverService; @Override - public List listActiveScreenSavers() { - ScreenSaverSelectionResult selection = screenSaverService.getActiveSelection(null); + public List listActiveScreenSavers(Long userId) { + ScreenSaverSelectionResult selection = screenSaverService.getActiveSelection(userId); if (selection == null || selection.getItems() == null || selection.getItems().isEmpty()) { return List.of(); } diff --git a/backend/src/main/java/com/imeeting/service/biz/ScreenSaverService.java b/backend/src/main/java/com/imeeting/service/biz/ScreenSaverService.java index 1fb875b..d33fa94 100644 --- a/backend/src/main/java/com/imeeting/service/biz/ScreenSaverService.java +++ b/backend/src/main/java/com/imeeting/service/biz/ScreenSaverService.java @@ -19,6 +19,8 @@ public interface ScreenSaverService extends IService { ScreenSaver update(Long id, ScreenSaverDTO dto, LoginUser loginUser); + boolean updateStatus(Long id, Integer status, LoginUser loginUser); + void removeScreenSaver(Long id, LoginUser loginUser); ScreenSaverImageUploadVO uploadImage(MultipartFile file) throws IOException; diff --git a/backend/src/main/java/com/imeeting/service/biz/impl/ScreenSaverServiceImpl.java b/backend/src/main/java/com/imeeting/service/biz/impl/ScreenSaverServiceImpl.java index 4fd0325..efeb92a 100644 --- a/backend/src/main/java/com/imeeting/service/biz/impl/ScreenSaverServiceImpl.java +++ b/backend/src/main/java/com/imeeting/service/biz/impl/ScreenSaverServiceImpl.java @@ -7,7 +7,9 @@ import com.imeeting.dto.biz.ScreenSaverDTO; import com.imeeting.dto.biz.ScreenSaverImageUploadVO; import com.imeeting.dto.biz.ScreenSaverSelectionResult; import com.imeeting.entity.biz.ScreenSaver; +import com.imeeting.entity.biz.ScreenSaverUserConfig; import com.imeeting.mapper.biz.ScreenSaverMapper; +import com.imeeting.mapper.biz.ScreenSaverUserConfigMapper; import com.imeeting.service.biz.ScreenSaverService; import com.unisbase.entity.SysUser; import com.unisbase.mapper.SysUserMapper; @@ -27,6 +29,8 @@ import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.Paths; import java.nio.file.StandardCopyOption; +import java.util.ArrayList; +import java.util.Comparator; import java.util.HashMap; import java.util.List; import java.util.Map; @@ -39,13 +43,17 @@ import java.util.stream.Collectors; @RequiredArgsConstructor public class ScreenSaverServiceImpl extends ServiceImpl implements ScreenSaverService { - private static final long GLOBAL_TENANT_ID = 0L; private static final String SCOPE_PLATFORM = "PLATFORM"; private static final String SCOPE_USER = "USER"; + private static final String SCOPE_MIXED = "MIXED"; private static final int REQUIRED_WIDTH = 1280; private static final int REQUIRED_HEIGHT = 800; private static final Set ALLOWED_FORMATS = Set.of("jpg", "jpeg", "png"); + private static final Comparator SCREEN_SAVER_ORDER = Comparator + .comparing((ScreenSaver item) -> item.getSortOrder() == null ? 0 : item.getSortOrder()) + .thenComparing(ScreenSaver::getId, Comparator.nullsLast(Comparator.reverseOrder())); + private final ScreenSaverUserConfigMapper userConfigMapper; private final SysUserMapper sysUserMapper; @Value("${unisbase.app.upload-path}") @@ -56,7 +64,7 @@ public class ScreenSaverServiceImpl extends ServiceImpl listForAdmin(LoginUser loginUser, String keyword, Integer status, String scopeType, Long ownerUserId) { - LambdaQueryWrapper wrapper = new LambdaQueryWrapper() + LambdaQueryWrapper wrapper = buildVisibilityWrapper(loginUser) .orderByAsc(ScreenSaver::getSortOrder) .orderByDesc(ScreenSaver::getId); if (StringUtils.hasText(keyword)) { @@ -65,25 +73,27 @@ public class ScreenSaverServiceImpl extends ServiceImpl records = this.list(wrapper); + Map userStatusMap = queryUserStatusMap(loginUser == null ? null : loginUser.getUserId(), extractPlatformIds(records)); + return toAdminVOs(records, userStatusMap).stream() + .filter(item -> status == null || Objects.equals(item.getStatus(), status)) + .toList(); } @Override @Transactional(rollbackFor = Exception.class) public ScreenSaver create(ScreenSaverDTO dto, LoginUser loginUser) { - validate(dto, false, null); + ScreenSaverDTO normalizedDto = normalizeCreateDto(dto, loginUser); + validate(normalizedDto, false, null); ScreenSaver entity = new ScreenSaver(); - applyDto(entity, dto, false); - entity.setTenantId(GLOBAL_TENANT_ID); + applyDto(entity, normalizedDto, false); + entity.setTenantId(loginUser.getTenantId()); entity.setCreatedBy(loginUser.getUserId()); if (entity.getStatus() == null) { entity.setStatus(1); @@ -96,10 +106,14 @@ public class ScreenSaverServiceImpl extends ServiceImpl selected = selectActiveEntities(userId); - String sourceScope = selected.isEmpty() ? SCOPE_PLATFORM : normalizeScopeType(selected.get(0).getScopeType()); - return new ScreenSaverSelectionResult(sourceScope, toAdminVOs(selected)); + List platformItems = listActiveByScope(SCOPE_PLATFORM, null); + if (userId == null) { + return new ScreenSaverSelectionResult(SCOPE_PLATFORM, toAdminVOs(platformItems)); + } + + Map userStatusMap = queryUserStatusMap(userId, extractPlatformIds(platformItems)); + List effectivePlatformItems = platformItems.stream() + .filter(item -> effectiveStatus(item, userStatusMap.get(item.getId())) == 1) + .toList(); + List userItems = listActiveByScope(SCOPE_USER, userId); + + List selected = new ArrayList<>(effectivePlatformItems.size() + userItems.size()); + selected.addAll(effectivePlatformItems); + selected.addAll(userItems); + selected.sort(SCREEN_SAVER_ORDER); + + return new ScreenSaverSelectionResult(resolveSourceScope(effectivePlatformItems, userItems), toAdminVOs(selected, userStatusMap)); } - private List selectActiveEntities(Long userId) { - if (userId != null) { - List userScoped = listActiveByScope(SCOPE_USER, userId); - if (!userScoped.isEmpty()) { - return userScoped; - } + private ScreenSaverDTO normalizeCreateDto(ScreenSaverDTO dto, LoginUser loginUser) { + if (dto == null) { + return null; } - return listActiveByScope(SCOPE_PLATFORM, null); + String scopeType = normalizeScopeType(dto.getScopeType()); + if (!SCOPE_PLATFORM.equals(scopeType) && !SCOPE_USER.equals(scopeType)) { + throw new RuntimeException("scopeType only supports PLATFORM or USER"); + } + if (SCOPE_USER.equals(scopeType)) { + dto.setOwnerUserId(loginUser.getUserId()); + } else if (!isAdmin(loginUser)) { + throw new RuntimeException("no permission to create platform screen saver"); + } + return dto; + } + + private ScreenSaverDTO normalizeUpdateDto(ScreenSaverDTO dto, ScreenSaver existing, LoginUser loginUser) { + if (dto == null) { + return null; + } + String scopeType = resolveScopeTypeForValidation(dto, existing); + if (SCOPE_USER.equals(scopeType)) { + dto.setOwnerUserId(loginUser.getUserId()); + } + return dto; + } + + private LambdaQueryWrapper buildVisibilityWrapper(LoginUser loginUser) { + LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>(); + if (loginUser == null || loginUser.getUserId() == null) { + return wrapper.eq(ScreenSaver::getScopeType, SCOPE_PLATFORM); + } + if (isAdmin(loginUser)) { + return wrapper; + } + return wrapper.and(w -> w.eq(ScreenSaver::getScopeType, SCOPE_PLATFORM) + .or(sw -> sw.eq(ScreenSaver::getScopeType, SCOPE_USER) + .eq(ScreenSaver::getOwnerUserId, loginUser.getUserId()))); + } + + private boolean upsertUserStatusConfig(Long screenSaverId, Integer status, LoginUser loginUser) { + ScreenSaverUserConfig existing = userConfigMapper.selectOne(new LambdaQueryWrapper() + .eq(ScreenSaverUserConfig::getTenantId, loginUser.getTenantId()) + .eq(ScreenSaverUserConfig::getUserId, loginUser.getUserId()) + .eq(ScreenSaverUserConfig::getScreenSaverId, screenSaverId) + .last("LIMIT 1")); + if (existing != null) { + existing.setStatus(status); + return userConfigMapper.updateById(existing) > 0; + } + + ScreenSaverUserConfig entity = new ScreenSaverUserConfig(); + entity.setTenantId(loginUser.getTenantId()); + entity.setUserId(loginUser.getUserId()); + entity.setScreenSaverId(screenSaverId); + entity.setStatus(status); + return userConfigMapper.insert(entity) > 0; + } + + private Map queryUserStatusMap(Long userId, List screenSaverIds) { + if (userId == null || screenSaverIds == null || screenSaverIds.isEmpty()) { + return Map.of(); + } + List configs = userConfigMapper.selectList(new LambdaQueryWrapper() + .eq(ScreenSaverUserConfig::getUserId, userId) + .in(ScreenSaverUserConfig::getScreenSaverId, screenSaverIds)); + + Map statusMap = new HashMap<>(); + for (ScreenSaverUserConfig config : configs) { + statusMap.put(config.getScreenSaverId(), config.getStatus()); + } + return statusMap; + } + + private List extractPlatformIds(List entities) { + if (entities == null || entities.isEmpty()) { + return List.of(); + } + return entities.stream() + .filter(this::isPlatformScope) + .map(ScreenSaver::getId) + .filter(Objects::nonNull) + .toList(); } private List listActiveByScope(String scopeType, Long ownerUserId) { @@ -176,12 +299,21 @@ public class ScreenSaverServiceImpl extends ServiceImpl toAdminVOs(List entities) { + return toAdminVOs(entities, Map.of()); + } + + private List toAdminVOs(List entities, Map userStatusMap) { if (entities == null || entities.isEmpty()) { return List.of(); } Map creatorNames = resolveCreatorNames(entities); return entities.stream() - .map(item -> ScreenSaverAdminVO.from(item, creatorNames.get(item.getCreatedBy()))) + .map(item -> { + String creatorName = item.getCreatedBy() == null ? null : creatorNames.get(item.getCreatedBy()); + ScreenSaverAdminVO vo = ScreenSaverAdminVO.from(item, creatorName); + vo.setStatus(effectiveStatus(item, userStatusMap.get(item.getId()))); + return vo; + }) .toList(); } @@ -203,6 +335,25 @@ public class ScreenSaverServiceImpl extends ServiceImpl platformItems, List userItems) { + boolean hasPlatform = platformItems != null && !platformItems.isEmpty(); + boolean hasUser = userItems != null && !userItems.isEmpty(); + if (hasPlatform && hasUser) { + return SCOPE_MIXED; + } + if (hasUser) { + return SCOPE_USER; + } + return SCOPE_PLATFORM; + } + private void validate(ScreenSaverDTO dto, boolean partial, ScreenSaver existing) { if (dto == null) { throw new RuntimeException("payload is required"); @@ -240,9 +391,9 @@ public class ScreenSaverServiceImpl extends ServiceImpl ((Supplier) invocation.getArgument(2)).get()); + when(screenSaverService.getActiveSelection(88L)) + .thenReturn(new ScreenSaverSelectionResult("USER", List.of())); + + AndroidScreenSaverController controller = + new AndroidScreenSaverController(androidAuthService, screenSaverService, taskSecurityContextRunner); + + ApiResponse response = controller.active(request); + + verify(taskSecurityContextRunner).callAsTenantUser(eq(9L), eq(88L), any()); + verify(screenSaverService).getActiveSelection(88L); + assertEquals("USER", response.getData().getSourceScope()); + } + + @Test + void activeShouldSkipSecurityContextRunnerWhenAnonymous() { + AndroidAuthService androidAuthService = mock(AndroidAuthService.class); + ScreenSaverService screenSaverService = mock(ScreenSaverService.class); + TaskSecurityContextRunner taskSecurityContextRunner = mock(TaskSecurityContextRunner.class); + HttpServletRequest request = mock(HttpServletRequest.class); + + AndroidAuthContext authContext = new AndroidAuthContext(); + authContext.setAnonymous(true); + + when(androidAuthService.authenticateHttp(request)).thenReturn(authContext); + when(screenSaverService.getActiveSelection(null)) + .thenReturn(new ScreenSaverSelectionResult("PLATFORM", List.of())); + + AndroidScreenSaverController controller = + new AndroidScreenSaverController(androidAuthService, screenSaverService, taskSecurityContextRunner); + + ApiResponse response = controller.active(request); + + verify(taskSecurityContextRunner, never()).callAsTenantUser(any(), any(), any()); + verify(screenSaverService).getActiveSelection(null); + assertEquals("PLATFORM", response.getData().getSourceScope()); + } +} diff --git a/backend/src/test/java/com/imeeting/controller/android/legacy/LegacyScreenSaverControllerTest.java b/backend/src/test/java/com/imeeting/controller/android/legacy/LegacyScreenSaverControllerTest.java new file mode 100644 index 0000000..0a494d1 --- /dev/null +++ b/backend/src/test/java/com/imeeting/controller/android/legacy/LegacyScreenSaverControllerTest.java @@ -0,0 +1,84 @@ +package com.imeeting.controller.android.legacy; + +import com.imeeting.dto.android.legacy.LegacyApiResponse; +import com.imeeting.dto.android.legacy.LegacyScreenSaverItemResponse; +import com.imeeting.service.android.legacy.LegacyScreenSaverAdapterService; +import com.imeeting.support.TaskSecurityContextRunner; +import com.unisbase.dto.InternalAuthCheckResponse; +import com.unisbase.service.TokenValidationService; +import jakarta.servlet.http.HttpServletRequest; +import org.junit.jupiter.api.Test; + +import java.util.List; +import java.util.function.Supplier; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +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 LegacyScreenSaverControllerTest { + + @Test + void activeShouldRunAdapterInsideTenantSecurityContextWhenTokenExists() { + LegacyScreenSaverAdapterService adapterService = mock(LegacyScreenSaverAdapterService.class); + TokenValidationService tokenValidationService = mock(TokenValidationService.class); + TaskSecurityContextRunner taskSecurityContextRunner = mock(TaskSecurityContextRunner.class); + HttpServletRequest request = mock(HttpServletRequest.class); + + when(request.getHeader("Authorization")).thenReturn("Bearer access-token"); + InternalAuthCheckResponse authResult = new InternalAuthCheckResponse(); + authResult.setValid(true); + authResult.setUserId(55L); + authResult.setTenantId(7L); + authResult.setUsername("alice"); + when(tokenValidationService.validateAccessToken("access-token")).thenReturn(authResult); + + when(taskSecurityContextRunner.callAsTenantUser(eq(7L), eq(55L), any())) + .thenAnswer(invocation -> ((Supplier) invocation.getArgument(2)).get()); + + LegacyScreenSaverItemResponse item = new LegacyScreenSaverItemResponse(); + item.setId(1L); + when(adapterService.listActiveScreenSavers(55L)).thenReturn(List.of(item)); + + LegacyScreenSaverController controller = new LegacyScreenSaverController( + adapterService, + tokenValidationService, + taskSecurityContextRunner + ); + + LegacyApiResponse> response = controller.active(request); + + verify(taskSecurityContextRunner).callAsTenantUser(eq(7L), eq(55L), any()); + verify(adapterService).listActiveScreenSavers(55L); + assertEquals(1, response.getData().size()); + } + + @Test + void activeShouldReturnAnonymousSelectionWhenTokenMissing() { + LegacyScreenSaverAdapterService adapterService = mock(LegacyScreenSaverAdapterService.class); + TokenValidationService tokenValidationService = mock(TokenValidationService.class); + TaskSecurityContextRunner taskSecurityContextRunner = mock(TaskSecurityContextRunner.class); + HttpServletRequest request = mock(HttpServletRequest.class); + + when(request.getHeader("Authorization")).thenReturn(null); + LegacyScreenSaverItemResponse item = new LegacyScreenSaverItemResponse(); + item.setId(2L); + when(adapterService.listActiveScreenSavers(null)).thenReturn(List.of(item)); + + LegacyScreenSaverController controller = new LegacyScreenSaverController( + adapterService, + tokenValidationService, + taskSecurityContextRunner + ); + + LegacyApiResponse> response = controller.active(request); + + verify(taskSecurityContextRunner, never()).callAsTenantUser(any(), any(), any()); + verify(adapterService).listActiveScreenSavers(null); + assertEquals(1, response.getData().size()); + } +} diff --git a/backend/src/test/java/com/imeeting/service/android/impl/AndroidAuthServiceImplTest.java b/backend/src/test/java/com/imeeting/service/android/impl/AndroidAuthServiceImplTest.java index a559ec6..00885e5 100644 --- a/backend/src/test/java/com/imeeting/service/android/impl/AndroidAuthServiceImplTest.java +++ b/backend/src/test/java/com/imeeting/service/android/impl/AndroidAuthServiceImplTest.java @@ -13,6 +13,7 @@ import java.util.Set; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertTrue; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.when; @@ -60,4 +61,28 @@ class AndroidAuthServiceImplTest { assertEquals(Set.of("meeting:create"), context.getPermissions()); assertEquals("access-token", context.getAccessToken()); } + + @Test + void authenticateHttpShouldAllowAnonymousWhenConfigured() { + AndroidGrpcAuthProperties properties = new AndroidGrpcAuthProperties(); + properties.setAllowAnonymous(true); + TokenValidationService tokenValidationService = mock(TokenValidationService.class); + AndroidAuthServiceImpl service = new AndroidAuthServiceImpl(properties, tokenValidationService); + HttpServletRequest request = mock(HttpServletRequest.class); + + when(request.getHeader("Authorization")).thenReturn(null); + when(request.getHeader("X-Android-Device-Id")).thenReturn("device-anon"); + when(request.getHeader("X-Android-App-Id")).thenReturn("imeeting"); + when(request.getHeader("X-Android-App-Version")).thenReturn("1.0.0"); + when(request.getHeader("X-Android-Platform")).thenReturn("android"); + + AndroidAuthContext context = service.authenticateHttp(request); + + assertTrue(context.isAnonymous()); + assertEquals("NONE", context.getAuthMode()); + assertEquals("device-anon", context.getDeviceId()); + assertNull(context.getUserId()); + assertNull(context.getTenantId()); + assertNull(context.getAccessToken()); + } } diff --git a/backend/src/test/java/com/imeeting/service/android/legacy/LegacyScreenSaverAdapterServiceImplTest.java b/backend/src/test/java/com/imeeting/service/android/legacy/LegacyScreenSaverAdapterServiceImplTest.java index 2cb00b0..d8facc7 100644 --- a/backend/src/test/java/com/imeeting/service/android/legacy/LegacyScreenSaverAdapterServiceImplTest.java +++ b/backend/src/test/java/com/imeeting/service/android/legacy/LegacyScreenSaverAdapterServiceImplTest.java @@ -32,12 +32,12 @@ class LegacyScreenSaverAdapterServiceImplTest { item.setCreatedBy(7L); item.setCreatorUsername("admin"); - when(screenSaverService.getActiveSelection(null)) + when(screenSaverService.getActiveSelection(55L)) .thenReturn(new ScreenSaverSelectionResult("PLATFORM", List.of(item))); LegacyScreenSaverAdapterServiceImpl service = new LegacyScreenSaverAdapterServiceImpl(screenSaverService); - List result = service.listActiveScreenSavers(); + List result = service.listActiveScreenSavers(55L); assertEquals(1, result.size()); assertEquals(9L, result.get(0).getId()); diff --git a/backend/src/test/java/com/imeeting/service/biz/impl/ScreenSaverServiceImplTest.java b/backend/src/test/java/com/imeeting/service/biz/impl/ScreenSaverServiceImplTest.java new file mode 100644 index 0000000..fb1a081 --- /dev/null +++ b/backend/src/test/java/com/imeeting/service/biz/impl/ScreenSaverServiceImplTest.java @@ -0,0 +1,120 @@ +package com.imeeting.service.biz.impl; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.imeeting.dto.biz.ScreenSaverAdminVO; +import com.imeeting.dto.biz.ScreenSaverSelectionResult; +import com.imeeting.entity.biz.ScreenSaver; +import com.imeeting.entity.biz.ScreenSaverUserConfig; +import com.imeeting.mapper.biz.ScreenSaverUserConfigMapper; +import com.unisbase.mapper.SysUserMapper; +import com.unisbase.security.LoginUser; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; + +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.Mockito.doReturn; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.spy; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +class ScreenSaverServiceImplTest { + + @Test + void getActiveSelectionShouldMergePlatformAndUserItems() { + ScreenSaverUserConfigMapper userConfigMapper = mock(ScreenSaverUserConfigMapper.class); + SysUserMapper sysUserMapper = mock(SysUserMapper.class); + when(userConfigMapper.selectList(any())).thenReturn(List.of(userConfig(101L, 77L, 0))); + when(sysUserMapper.selectBatchIds(any())).thenReturn(List.of()); + + ScreenSaverServiceImpl service = spy(new ScreenSaverServiceImpl(userConfigMapper, sysUserMapper)); + doReturn(List.of( + screenSaver(101L, "PLATFORM", null, 1, 2), + screenSaver(102L, "PLATFORM", null, 1, 5) + )).doReturn(List.of( + screenSaver(201L, "USER", 77L, 1, 1) + )).when(service).list(any(LambdaQueryWrapper.class)); + + ScreenSaverSelectionResult result = service.getActiveSelection(77L); + + assertEquals("MIXED", result.getSourceScope()); + assertEquals(List.of(201L, 102L), result.getItems().stream().map(ScreenSaverAdminVO::getId).toList()); + assertEquals(List.of(1, 1), result.getItems().stream().map(ScreenSaverAdminVO::getStatus).toList()); + } + + @Test + void listForAdminShouldApplyCurrentUserStatusFilter() { + ScreenSaverUserConfigMapper userConfigMapper = mock(ScreenSaverUserConfigMapper.class); + SysUserMapper sysUserMapper = mock(SysUserMapper.class); + when(userConfigMapper.selectList(any())).thenReturn(List.of(userConfig(101L, 88L, 0))); + when(sysUserMapper.selectBatchIds(any())).thenReturn(List.of()); + + ScreenSaverServiceImpl service = spy(new ScreenSaverServiceImpl(userConfigMapper, sysUserMapper)); + doReturn(List.of( + screenSaver(101L, "PLATFORM", null, 1, 1), + screenSaver(201L, "USER", 88L, 1, 2) + )).when(service).list(any(LambdaQueryWrapper.class)); + + List result = service.listForAdmin(loginUser(88L, 9L, false), null, 0, null, null); + + assertEquals(1, result.size()); + assertEquals(101L, result.get(0).getId()); + assertEquals(0, result.get(0).getStatus()); + } + + @Test + void updateStatusShouldStoreUserOverrideForPlatformItem() { + ScreenSaverUserConfigMapper userConfigMapper = mock(ScreenSaverUserConfigMapper.class); + SysUserMapper sysUserMapper = mock(SysUserMapper.class); + when(userConfigMapper.selectOne(any())).thenReturn(null); + when(userConfigMapper.insert(any(ScreenSaverUserConfig.class))).thenReturn(1); + + ScreenSaverServiceImpl service = spy(new ScreenSaverServiceImpl(userConfigMapper, sysUserMapper)); + doReturn(screenSaver(101L, "PLATFORM", null, 1, 1)).when(service).getOne(any(LambdaQueryWrapper.class)); + + boolean success = service.updateStatus(101L, 0, loginUser(88L, 9L, false)); + + assertTrue(success); + ArgumentCaptor captor = ArgumentCaptor.forClass(ScreenSaverUserConfig.class); + verify(userConfigMapper).insert(captor.capture()); + verify(service, never()).updateById(any(ScreenSaver.class)); + assertEquals(9L, captor.getValue().getTenantId()); + assertEquals(88L, captor.getValue().getUserId()); + assertEquals(101L, captor.getValue().getScreenSaverId()); + assertEquals(0, captor.getValue().getStatus()); + } + + private ScreenSaver screenSaver(Long id, String scopeType, Long ownerUserId, Integer status, Integer sortOrder) { + ScreenSaver entity = new ScreenSaver(); + entity.setId(id); + entity.setScopeType(scopeType); + entity.setOwnerUserId(ownerUserId); + entity.setName("item-" + id); + entity.setImageUrl("/api/static/" + id + ".jpg"); + entity.setStatus(status); + entity.setSortOrder(sortOrder); + return entity; + } + + private ScreenSaverUserConfig userConfig(Long screenSaverId, Long userId, Integer status) { + ScreenSaverUserConfig config = new ScreenSaverUserConfig(); + config.setScreenSaverId(screenSaverId); + config.setUserId(userId); + config.setStatus(status); + return config; + } + + private LoginUser loginUser(Long userId, Long tenantId, boolean admin) { + LoginUser loginUser = new LoginUser(); + loginUser.setUserId(userId); + loginUser.setTenantId(tenantId); + loginUser.setIsTenantAdmin(admin); + loginUser.setIsPlatformAdmin(false); + return loginUser; + } +} diff --git a/frontend/src/api/business/screenSaver.ts b/frontend/src/api/business/screenSaver.ts index 72859c4..b0cbdf8 100644 --- a/frontend/src/api/business/screenSaver.ts +++ b/frontend/src/api/business/screenSaver.ts @@ -66,6 +66,11 @@ export async function updateScreenSaver(id: number, payload: Partial(null); + const { targetWidth, targetHeight } = state; + useEffect(() => { if (!state.open) { return; @@ -229,20 +234,20 @@ function ScreenSaverCropDialog({ state, onCancel, onConfirm }: CropDialogProps) const exportCroppedFile = async () => { const image = await createImage(state.src); const canvas = document.createElement("canvas"); - canvas.width = CROP_WIDTH; - canvas.height = CROP_HEIGHT; + canvas.width = targetWidth; + canvas.height = targetHeight; const context = canvas.getContext("2d"); if (!context) { throw new Error("浏览器不支持图片裁剪"); } - const previewScale = CROP_WIDTH / VIEWPORT_WIDTH; + const previewScale = targetWidth / VIEWPORT_WIDTH; const exportedWidth = image.width * zoom * previewScale; const exportedHeight = image.height * zoom * previewScale; - const drawX = CROP_WIDTH / 2 - exportedWidth / 2 + offset.x * previewScale; - const drawY = CROP_HEIGHT / 2 - exportedHeight / 2 + offset.y * previewScale; + const drawX = targetWidth / 2 - exportedWidth / 2 + offset.x * previewScale; + const drawY = targetHeight / 2 - exportedHeight / 2 + offset.y * previewScale; context.drawImage(image, drawX, drawY, exportedWidth, exportedHeight); const extension = state.mimeType === "image/png" ? "png" : "jpg"; - const fileName = state.fileName.replace(/\.[^.]+$/, "") + `_1280x800.${extension}`; + const fileName = state.fileName.replace(/\.[^.]+$/, "") + `_${targetWidth}x${targetHeight}.${extension}`; const blob = await new Promise((resolve) => { if (state.mimeType === "image/png") { canvas.toBlob(resolve, "image/png"); @@ -262,7 +267,7 @@ function ScreenSaverCropDialog({ state, onCancel, onConfirm }: CropDialogProps) const file = await exportCroppedFile(); await onConfirm(file); } catch (error) { - message.error(error instanceof Error ? error.message : "裁剪上传失败"); + message.error(error instanceof Error ? error.message : "瑁佸壀涓婁紶澶辫触"); } finally { setLoading(false); } @@ -284,7 +289,7 @@ function ScreenSaverCropDialog({ state, onCancel, onConfirm }: CropDialogProps)

裁剪成屏保成品图

-

请在固定 8:5 取景框内调整画面位置。导出尺寸固定为 1280 × 800,安卓端将直接使用该成品图展示。

+

请在固定 8:5 取景框内调整画面位置。导出尺寸固定为 {targetWidth} × {targetHeight},安卓端将直接使用该成品图展示。

原图:{naturalSize.width || "-"} × {naturalSize.height || "-"} - 输出:{CROP_WIDTH} × {CROP_HEIGHT} + 输出:{targetWidth} × {targetHeight}

缩放与构图

-

拖动画面调整主体位置。保留足够安全边距,避免标题、人物或徽标在不同设备上被视觉切边。

+

拖动画面调整主体位置,保留足够安全边距,避免标题、人物或徽标在不同设备上被裁切。

缩放

交付标准

-

仅支持 JPG / JPEG / PNG。导出的屏保图片会以 1280 × 800 固定尺寸上传,后端仅做格式与尺寸校验,不再重新裁剪。

+

仅支持 JPG / JPEG / PNG。导出的屏保图片会以 {targetWidth} × {targetHeight} 固定尺寸上传,后端仅做格式与尺寸校验,不再重新裁剪。

@@ -377,7 +382,15 @@ export default function ScreenSaverManagement() { src: "", fileName: "", mimeType: "image/jpeg", + targetWidth: CROP_WIDTH, + targetHeight: CROP_HEIGHT, }); + const userProfile = useMemo(() => { + const profileStr = sessionStorage.getItem("userProfile"); + return profileStr ? JSON.parse(profileStr) : {}; + }, []); + const currentUserId = Number(userProfile.userId || 0); + const isAdmin = userProfile.isAdmin === true || userProfile.isPlatformAdmin === true || userProfile.isTenantAdmin === true; const userMap = useMemo(() => { return new Map(users.map((user) => [user.userId, user])); @@ -435,7 +448,8 @@ export default function ScreenSaverManagement() { setEditing(null); form.resetFields(); form.setFieldsValue({ - scopeType: "PLATFORM", + scopeType: "USER", + ownerUserId: currentUserId || undefined, displayDurationSec: 15, sortOrder: 0, statusEnabled: true, @@ -447,6 +461,10 @@ export default function ScreenSaverManagement() { }; const openEdit = (record: ScreenSaverVO) => { + if (!isAdmin && (record.scopeType !== "USER" || record.ownerUserId !== currentUserId)) { + message.warning("普通用户只能编辑自己的用户级屏保"); + return; + } setEditing(record); form.setFieldsValue({ scopeType: record.scopeType, @@ -473,9 +491,11 @@ export default function ScreenSaverManagement() { const handleSubmit = async () => { const values = await form.validateFields(); + const resolvedScopeType = isAdmin ? values.scopeType : "USER"; + const resolvedOwnerUserId = resolvedScopeType === "USER" ? currentUserId || null : null; const payload: ScreenSaverDTO = { - scopeType: values.scopeType, - ownerUserId: values.scopeType === "USER" ? values.ownerUserId ?? null : null, + scopeType: resolvedScopeType, + ownerUserId: resolvedOwnerUserId, name: values.name.trim(), imageUrl: values.imageUrl.trim(), description: values.description?.trim(), @@ -507,11 +527,16 @@ export default function ScreenSaverManagement() { const openCropper = async (file: File) => { const mimeType = validateImageFile(file); const src = await readFileAsDataUrl(file); + const targetWidth = form.getFieldValue("imageWidth") || CROP_WIDTH; + const targetHeight = form.getFieldValue("imageHeight") || CROP_HEIGHT; + setCropState({ open: true, src, fileName: file.name, mimeType, + targetWidth, + targetHeight, }); }; @@ -525,7 +550,7 @@ export default function ScreenSaverManagement() { imageHeight: result.imageHeight, imageFormat: result.imageFormat, }); - setCropState({ open: false, src: "", fileName: "", mimeType: "image/jpeg" }); + setCropState((prev) => ({ ...prev, open: false, src: "" })); message.success("屏保图片已上传"); } finally { setUploading(false); @@ -543,7 +568,7 @@ export default function ScreenSaverManagement() { }; const handleToggleStatus = async (record: ScreenSaverVO, checked: boolean) => { - await updateScreenSaver(record.id, { status: checked ? 1 : 0 }); + await updateScreenSaverStatus(record.id, checked ? 1 : 0); message.success(checked ? "屏保已启用" : "屏保已停用"); await loadData(); }; @@ -621,20 +646,27 @@ export default function ScreenSaverManagement() { key: "action", width: 140, fixed: "right", - render: (_, record) => ( - -
- 输出规格 1280 × 800 + 输出规格 {currentWidth} × {currentHeight} 当前作用域 {currentScopeType === "USER" ? "用户级" : "平台级"} - {currentScopeType === "USER" && currentOwnerUserId ? ( + {currentScopeType === "USER" ? ( - 归属 {normalizeOwnerLabel(userMap.get(currentOwnerUserId))} + 归属 {normalizeOwnerLabel(userMap.get(currentUserId))} ) : null} @@ -818,13 +854,13 @@ export default function ScreenSaverManagement() { - - + + - - + + @@ -834,7 +870,7 @@ export default function ScreenSaverManagement() { -