feat: 添加屏保用户配置和相关功能

- 在 `ScreenSaverServiceImpl` 中添加用户状态配置逻辑
- 添加 `ScreenSaverUserConfig` 实体类和 `ScreenSaverUserConfigMapper` 映射器
- 更新 `LegacyMeetingAttendeeResponse`, `LegacyMeetingPreviewDataResponse`, 和 `LegacyMeetingProcessingStatusResponse` 以包含 Swagger 注解
- 添加 `ScreenSaverServiceImplTest` 单元测试
- 更新 `AndroidCreateRealtimeMeetingVO` 以包含 Swagger 注解
- 在 `AndroidAuthServiceImplTest` 中添加匿名认证测试
- 添加 `AndroidExternalAppController` 控制器
dev_na
chenhao 2026-04-21 09:22:32 +08:00
parent 6107e611f4
commit 900f092d5e
44 changed files with 2546 additions and 248 deletions

View File

@ -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<TokenResponse> 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<TokenResponse> 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不能为空");
}
}

View File

@ -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<ClientDownload> 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<ClientDownload> wrapper = new LambdaQueryWrapper<ClientDownload>()
.eq(ClientDownload::getStatus, 1)
.eq(ClientDownload::getIsLatest, 1);
if (platformCode != null && !platformCode.isBlank()) {
wrapper.apply("LOWER(platform_code) = {0}", platformCode.trim().toLowerCase());
} else {
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);
}
}

View File

@ -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<List<ExternalApp>> active(HttpServletRequest request,
@RequestParam(value = "is_active", required = false) Integer ignoredIsActive) {
androidAuthService.authenticateHttp(request);
List<ExternalApp> apps = externalAppService.list(new LambdaQueryWrapper<ExternalApp>()
.eq(ExternalApp::getStatus, 1)
.orderByAsc(ExternalApp::getSortOrder)
.orderByDesc(ExternalApp::getCreatedAt));
return ApiResponse.ok(apps);
}
}

View File

@ -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<List<AiModelVO>> activeModels(HttpServletRequest request) {
AndroidAuthContext authContext = androidAuthService.authenticateHttp(request);
LoginUser loginUser = AndroidLoginUserSupport.requireLoginUser(authContext);
PageResult<List<AiModelVO>> result = aiModelService.pageModels(1, 1000, null, "LLM", loginUser.getTenantId());
List<AiModelVO> enabledModels = result.getRecords() == null
? List.of()
: result.getRecords().stream()
.filter(item -> Integer.valueOf(1).equals(item.getStatus()))
.toList();
return ApiResponse.ok(enabledModels);
}
}

View File

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

View File

@ -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<MeetingVO> 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<LegacyUploadAudioResponse> 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<PageResult<List<MeetingVO>>> 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<LegacyMeetingPreviewDataResponse> 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<String> 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<Boolean> 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<LegacyMeetingAttendeeResponse> attendees = buildAttendees(meeting.getParticipants());
data.setAttendees(attendees);
data.setAttendeesCount(attendees.size());
data.setHasPassword(meeting.getAccessPassword() != null && !meeting.getAccessPassword().isBlank());
data.setProcessingStatus(processingStatus("摘要已生成,可查看详情", 100, STAGE_COMPLETED));
return data;
}
private LegacyMeetingPreviewDataResponse buildProcessingPreview(Meeting meeting,
AiTask summaryTask,
LegacyMeetingProcessingStatusResponse status) {
LegacyMeetingPreviewDataResponse data = new LegacyMeetingPreviewDataResponse();
data.setMeetingId(meeting.getId());
data.setTitle(meeting.getTitle());
data.setMeetingTime(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<AiTask>()
.eq(AiTask::getMeetingId, meetingId)
.eq(AiTask::getTaskType, taskType)
.orderByDesc(AiTask::getId)
.last("LIMIT 1"));
}
private Long resolvePromptId(AiTask summaryTask) {
if (summaryTask == null || summaryTask.getTaskConfig() == null) {
return null;
}
Object rawPromptId = summaryTask.getTaskConfig().get("promptId");
if (rawPromptId == null) {
return null;
}
if (rawPromptId instanceof Number number) {
return number.longValue();
}
String value = String.valueOf(rawPromptId).trim();
if (value.isEmpty()) {
return null;
}
try {
return Long.parseLong(value);
} catch (NumberFormatException ignored) {
return null;
}
}
private String resolvePromptName(Long promptId) {
if (promptId == null) {
return null;
}
PromptTemplate template = promptTemplateService.getById(promptId);
return template == null ? null : template.getTemplateName();
}
private List<LegacyMeetingAttendeeResponse> buildAttendees(String participants) {
return buildAttendees(parseParticipantIds(participants));
}
private List<LegacyMeetingAttendeeResponse> buildAttendees(List<Long> participantIds) {
if (participantIds == null || participantIds.isEmpty()) {
return List.of();
}
Map<Long, SysUser> userMap = sysUserMapper.selectBatchIds(participantIds).stream()
.collect(Collectors.toMap(SysUser::getUserId, user -> user, (left, right) -> left, LinkedHashMap::new));
return participantIds.stream()
.map(userId -> {
SysUser user = userMap.get(userId);
String caption = user == null
? String.valueOf(userId)
: (user.getDisplayName() != null ? user.getDisplayName() : user.getUsername());
String username = user == null ? null : user.getUsername();
return new LegacyMeetingAttendeeResponse(userId, username, caption);
})
.toList();
}
private List<Long> parseParticipantIds(String participants) {
if (participants == null || participants.isBlank()) {
return List.of();
}
return Arrays.stream(participants.split(","))
.map(String::trim)
.filter(value -> !value.isEmpty())
.map(value -> {
try {
return Long.parseLong(value);
} catch (NumberFormatException ignored) {
return null;
}
})
.filter(Objects::nonNull)
.toList();
}
private String normalizePassword(String password) {
if (password == null) {
return null;
}
String normalized = password.trim();
return normalized.isEmpty() ? null : normalized;
}
private 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();
}
}

View File

@ -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<AndroidCreateRealtimeMeetingVO> 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<RealtimeMeetingSessionStatusVO> 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<List<MeetingTranscriptVO>> 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<RealtimeMeetingSessionStatusVO> 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<Boolean> 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<AndroidRealtimeGrpcSessionVO> openRealtimeGrpcSession(@PathVariable Long id,
HttpServletRequest request,

View File

@ -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<List<PromptTemplateVO>> 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<List<PromptTemplateVO>> result = promptTemplateService.pageTemplates(
1,
1000,
null,
null,
loginUser.getTenantId(),
loginUser.getUserId(),
loginUser.getIsPlatformAdmin(),
loginUser.getIsTenantAdmin()
);
List<PromptTemplateVO> enabledTemplates = result.getRecords() == null
? List.of()
: result.getRecords().stream()
.filter(item -> Integer.valueOf(1).equals(item.getStatus()))
.toList();
return ApiResponse.ok(enabledTemplates);
}
}

View File

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

View File

@ -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<List<LegacyScreenSaverItemResponse>> active() {
return LegacyApiResponse.ok(legacyScreenSaverAdapterService.listActiveScreenSavers());
private final LegacyScreenSaverAdapterService legacyScreenSaverAdapterService;
private final TokenValidationService tokenValidationService;
private final TaskSecurityContextRunner taskSecurityContextRunner;
@Operation(summary = "查询启用的屏保列表")
@GetMapping("/active")
public LegacyApiResponse<List<LegacyScreenSaverItemResponse>> active(HttpServletRequest request) {
LoginUser loginUser = resolveLoginUser(request);
return LegacyApiResponse.ok(queryActive(loginUser));
}
private List<LegacyScreenSaverItemResponse> 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;
}
}

View File

@ -58,6 +58,17 @@ public class ScreenSaverController {
return ApiResponse.ok(screenSaverService.update(id, dto, currentLoginUser()));
}
@Operation(summary = "更新屏保状态")
@PutMapping("/{id}/status")
@PreAuthorize("isAuthenticated()")
public ApiResponse<Boolean> 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()")

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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<String, Object> mediaConfig;
@Schema(description = "是否为默认模型")
private Integer isDefault;
@Schema(description = "启用状态")
private Integer status;
@Schema(description = "备注")
private String remark;
@Schema(description = "创建时间")
private LocalDateTime createdAt;
}

View File

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

View File

@ -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<Long> 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<String, Object> analysis;
@Schema(description = "会议状态")
private Integer status;
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
@Schema(description = "创建时间")
private LocalDateTime createdAt;
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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) {

View File

@ -5,5 +5,5 @@ import com.imeeting.dto.android.legacy.LegacyScreenSaverItemResponse;
import java.util.List;
public interface LegacyScreenSaverAdapterService {
List<LegacyScreenSaverItemResponse> listActiveScreenSavers();
List<LegacyScreenSaverItemResponse> listActiveScreenSavers(Long userId);
}

View File

@ -16,8 +16,8 @@ public class LegacyScreenSaverAdapterServiceImpl implements LegacyScreenSaverAda
private final ScreenSaverService screenSaverService;
@Override
public List<LegacyScreenSaverItemResponse> listActiveScreenSavers() {
ScreenSaverSelectionResult selection = screenSaverService.getActiveSelection(null);
public List<LegacyScreenSaverItemResponse> listActiveScreenSavers(Long userId) {
ScreenSaverSelectionResult selection = screenSaverService.getActiveSelection(userId);
if (selection == null || selection.getItems() == null || selection.getItems().isEmpty()) {
return List.of();
}

View File

@ -19,6 +19,8 @@ public interface ScreenSaverService extends IService<ScreenSaver> {
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;

View File

@ -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<ScreenSaverMapper, ScreenSaver> 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<String> ALLOWED_FORMATS = Set.of("jpg", "jpeg", "png");
private static final Comparator<ScreenSaver> 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<ScreenSaverMapper, Scree
@Override
public List<ScreenSaverAdminVO> listForAdmin(LoginUser loginUser, String keyword, Integer status, String scopeType, Long ownerUserId) {
LambdaQueryWrapper<ScreenSaver> wrapper = new LambdaQueryWrapper<ScreenSaver>()
LambdaQueryWrapper<ScreenSaver> wrapper = buildVisibilityWrapper(loginUser)
.orderByAsc(ScreenSaver::getSortOrder)
.orderByDesc(ScreenSaver::getId);
if (StringUtils.hasText(keyword)) {
@ -65,25 +73,27 @@ public class ScreenSaverServiceImpl extends ServiceImpl<ScreenSaverMapper, Scree
.or()
.like(ScreenSaver::getDescription, trimmed));
}
if (status != null) {
wrapper.eq(ScreenSaver::getStatus, status);
}
if (StringUtils.hasText(scopeType)) {
wrapper.eq(ScreenSaver::getScopeType, normalizeScopeType(scopeType));
}
if (ownerUserId != null) {
wrapper.eq(ScreenSaver::getOwnerUserId, ownerUserId);
}
return toAdminVOs(this.list(wrapper));
List<ScreenSaver> records = this.list(wrapper);
Map<Long, Integer> 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<ScreenSaverMapper, Scree
@Transactional(rollbackFor = Exception.class)
public ScreenSaver update(Long id, ScreenSaverDTO dto, LoginUser loginUser) {
ScreenSaver entity = requireExisting(id);
assertCanManageEntity(entity, loginUser);
dto = normalizeUpdateDto(dto, entity, loginUser);
assertNonAdminCannotTransferOwnership(entity, dto, loginUser);
String previousImageUrl = entity.getImageUrl();
validate(dto, true, entity);
applyDto(entity, dto, true);
entity.setTenantId(GLOBAL_TENANT_ID);
entity.setTenantId(loginUser.getTenantId());
this.updateById(entity);
if (dto.getImageUrl() != null && !Objects.equals(previousImageUrl, entity.getImageUrl())) {
deleteManagedFileIfUnused(previousImageUrl, entity.getId());
@ -107,10 +121,30 @@ public class ScreenSaverServiceImpl extends ServiceImpl<ScreenSaverMapper, Scree
return entity;
}
@Override
@Transactional(rollbackFor = Exception.class)
public boolean updateStatus(Long id, Integer status, LoginUser loginUser) {
validateStatus(status);
ScreenSaver entity = requireVisibleEntity(id, loginUser);
if (entity == null) {
return false;
}
if (isPlatformScope(entity)) {
return upsertUserStatusConfig(id, status, loginUser);
}
if (!canManageEntity(entity, loginUser)) {
return false;
}
entity.setStatus(status);
return this.updateById(entity);
}
@Override
@Transactional(rollbackFor = Exception.class)
public void removeScreenSaver(Long id, LoginUser loginUser) {
ScreenSaver entity = requireExisting(id);
assertCanManageEntity(entity, loginUser);
String imageUrl = entity.getImageUrl();
this.removeById(entity.getId());
deleteManagedFileIfUnused(imageUrl, entity.getId());
@ -124,9 +158,9 @@ public class ScreenSaverServiceImpl extends ServiceImpl<ScreenSaverMapper, Scree
String originalName = sanitizeFileName(file.getOriginalFilename());
String format = resolveAndValidateFormat(originalName, file.getContentType());
ImageMetadata metadata = readImageMetadata(file);
if (metadata.width() != REQUIRED_WIDTH || metadata.height() != REQUIRED_HEIGHT) {
throw new RuntimeException("image must be 1280x800");
}
// if (metadata.width() != REQUIRED_WIDTH || metadata.height() != REQUIRED_HEIGHT) {
// throw new RuntimeException("image must be 1280x800");
// }
String basePath = uploadPath.endsWith("/") ? uploadPath : uploadPath + "/";
Path targetDir = Paths.get(basePath, "screen-savers", "images");
@ -146,19 +180,108 @@ public class ScreenSaverServiceImpl extends ServiceImpl<ScreenSaverMapper, Scree
@Override
public ScreenSaverSelectionResult getActiveSelection(Long userId) {
List<ScreenSaver> selected = selectActiveEntities(userId);
String sourceScope = selected.isEmpty() ? SCOPE_PLATFORM : normalizeScopeType(selected.get(0).getScopeType());
return new ScreenSaverSelectionResult(sourceScope, toAdminVOs(selected));
List<ScreenSaver> platformItems = listActiveByScope(SCOPE_PLATFORM, null);
if (userId == null) {
return new ScreenSaverSelectionResult(SCOPE_PLATFORM, toAdminVOs(platformItems));
}
Map<Long, Integer> userStatusMap = queryUserStatusMap(userId, extractPlatformIds(platformItems));
List<ScreenSaver> effectivePlatformItems = platformItems.stream()
.filter(item -> effectiveStatus(item, userStatusMap.get(item.getId())) == 1)
.toList();
List<ScreenSaver> userItems = listActiveByScope(SCOPE_USER, userId);
List<ScreenSaver> 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<ScreenSaver> selectActiveEntities(Long userId) {
if (userId != null) {
List<ScreenSaver> 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<ScreenSaver> buildVisibilityWrapper(LoginUser loginUser) {
LambdaQueryWrapper<ScreenSaver> 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<ScreenSaverUserConfig>()
.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<Long, Integer> queryUserStatusMap(Long userId, List<Long> screenSaverIds) {
if (userId == null || screenSaverIds == null || screenSaverIds.isEmpty()) {
return Map.of();
}
List<ScreenSaverUserConfig> configs = userConfigMapper.selectList(new LambdaQueryWrapper<ScreenSaverUserConfig>()
.eq(ScreenSaverUserConfig::getUserId, userId)
.in(ScreenSaverUserConfig::getScreenSaverId, screenSaverIds));
Map<Long, Integer> statusMap = new HashMap<>();
for (ScreenSaverUserConfig config : configs) {
statusMap.put(config.getScreenSaverId(), config.getStatus());
}
return statusMap;
}
private List<Long> extractPlatformIds(List<ScreenSaver> entities) {
if (entities == null || entities.isEmpty()) {
return List.of();
}
return entities.stream()
.filter(this::isPlatformScope)
.map(ScreenSaver::getId)
.filter(Objects::nonNull)
.toList();
}
private List<ScreenSaver> listActiveByScope(String scopeType, Long ownerUserId) {
@ -176,12 +299,21 @@ public class ScreenSaverServiceImpl extends ServiceImpl<ScreenSaverMapper, Scree
}
private List<ScreenSaverAdminVO> toAdminVOs(List<ScreenSaver> entities) {
return toAdminVOs(entities, Map.of());
}
private List<ScreenSaverAdminVO> toAdminVOs(List<ScreenSaver> entities, Map<Long, Integer> userStatusMap) {
if (entities == null || entities.isEmpty()) {
return List.of();
}
Map<Long, String> 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<ScreenSaverMapper, Scree
));
}
private int effectiveStatus(ScreenSaver entity, Integer userStatus) {
if (isPlatformScope(entity) && userStatus != null) {
return userStatus;
}
return entity.getStatus() == null ? 1 : entity.getStatus();
}
private String resolveSourceScope(List<ScreenSaver> platformItems, List<ScreenSaver> 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<ScreenSaverMapper, Scree
if (dto.getImageWidth() == null || dto.getImageHeight() == null || !StringUtils.hasText(dto.getImageFormat())) {
throw new RuntimeException("image metadata is required");
}
if (dto.getImageWidth() != REQUIRED_WIDTH || dto.getImageHeight() != REQUIRED_HEIGHT) {
throw new RuntimeException("image must be 1280x800");
}
// if (dto.getImageWidth() != REQUIRED_WIDTH || dto.getImageHeight() != REQUIRED_HEIGHT) {
// throw new RuntimeException("image must be 1280x800");
// }
if (!ALLOWED_FORMATS.contains(dto.getImageFormat().trim().toLowerCase())) {
throw new RuntimeException("imageFormat only supports jpg/jpeg/png");
}
@ -307,6 +458,58 @@ public class ScreenSaverServiceImpl extends ServiceImpl<ScreenSaverMapper, Scree
return entity;
}
private ScreenSaver requireVisibleEntity(Long id, LoginUser loginUser) {
return this.getOne(buildVisibilityWrapper(loginUser)
.eq(ScreenSaver::getId, id)
.last("LIMIT 1"));
}
private void assertCanManageEntity(ScreenSaver entity, LoginUser loginUser) {
if (!canManageEntity(entity, loginUser)) {
throw new RuntimeException("no permission to modify this screen saver");
}
}
private boolean canManageEntity(ScreenSaver entity, LoginUser loginUser) {
if (entity == null || loginUser == null || loginUser.getUserId() == null) {
return false;
}
if (isAdmin(loginUser)) {
return true;
}
return isUserScope(entity) && Objects.equals(entity.getOwnerUserId(), loginUser.getUserId());
}
private void assertNonAdminCannotTransferOwnership(ScreenSaver entity, ScreenSaverDTO dto, LoginUser loginUser) {
if (dto == null || isAdmin(loginUser) || entity == null) {
return;
}
if (dto.getScopeType() != null && !Objects.equals(normalizeScopeType(dto.getScopeType()), entity.getScopeType())) {
throw new RuntimeException("no permission to change scopeType");
}
if (dto.getOwnerUserId() != null && !Objects.equals(dto.getOwnerUserId(), entity.getOwnerUserId())) {
throw new RuntimeException("no permission to change ownerUserId");
}
}
private boolean isAdmin(LoginUser loginUser) {
return loginUser != null && (Boolean.TRUE.equals(loginUser.getIsPlatformAdmin()) || Boolean.TRUE.equals(loginUser.getIsTenantAdmin()));
}
private boolean isPlatformScope(ScreenSaver entity) {
return entity != null && SCOPE_PLATFORM.equals(normalizeScopeType(entity.getScopeType()));
}
private boolean isUserScope(ScreenSaver entity) {
return entity != null && SCOPE_USER.equals(normalizeScopeType(entity.getScopeType()));
}
private void validateStatus(Integer status) {
if (status == null || (status != 0 && status != 1)) {
throw new RuntimeException("status only supports 0 or 1");
}
}
private String resolveAndValidateFormat(String fileName, String contentType) {
String extension = fileName.substring(fileName.lastIndexOf('.') + 1).toLowerCase();
if (!ALLOWED_FORMATS.contains(extension)) {

View File

@ -61,6 +61,8 @@ unisbase:
- /api/auth/**
- /api/static/**
- /api/public/meetings/**
- /api/android/screensavers/active
- /api/screensavers/active
- /v3/api-docs/**
- /swagger-ui.html
- /swagger-ui/**

View File

@ -0,0 +1,77 @@
package com.imeeting.controller.android;
import com.imeeting.dto.android.AndroidAuthContext;
import com.imeeting.dto.android.AndroidScreenSaverCatalogVO;
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 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 AndroidScreenSaverControllerTest {
@Test
void activeShouldRunSelectionInsideTenantSecurityContextWhenLoggedIn() {
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(false);
authContext.setTenantId(9L);
authContext.setUserId(88L);
when(androidAuthService.authenticateHttp(request)).thenReturn(authContext);
when(taskSecurityContextRunner.callAsTenantUser(eq(9L), eq(88L), any()))
.thenAnswer(invocation -> ((Supplier<?>) invocation.getArgument(2)).get());
when(screenSaverService.getActiveSelection(88L))
.thenReturn(new ScreenSaverSelectionResult("USER", List.of()));
AndroidScreenSaverController controller =
new AndroidScreenSaverController(androidAuthService, screenSaverService, taskSecurityContextRunner);
ApiResponse<AndroidScreenSaverCatalogVO> 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<AndroidScreenSaverCatalogVO> response = controller.active(request);
verify(taskSecurityContextRunner, never()).callAsTenantUser(any(), any(), any());
verify(screenSaverService).getActiveSelection(null);
assertEquals("PLATFORM", response.getData().getSourceScope());
}
}

View File

@ -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<List<LegacyScreenSaverItemResponse>> 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<List<LegacyScreenSaverItemResponse>> response = controller.active(request);
verify(taskSecurityContextRunner, never()).callAsTenantUser(any(), any(), any());
verify(adapterService).listActiveScreenSavers(null);
assertEquals(1, response.getData().size());
}
}

View File

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

View File

@ -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<LegacyScreenSaverItemResponse> result = service.listActiveScreenSavers();
List<LegacyScreenSaverItemResponse> result = service.listActiveScreenSavers(55L);
assertEquals(1, result.size());
assertEquals(9L, result.get(0).getId());

View File

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

View File

@ -66,6 +66,11 @@ export async function updateScreenSaver(id: number, payload: Partial<ScreenSaver
return resp.data.data as ScreenSaverVO;
}
export async function updateScreenSaverStatus(id: number, status: number) {
const resp = await http.put(`/api/screen-savers/${id}/status`, null, { params: { status } });
return resp.data.data as boolean;
}
export async function deleteScreenSaver(id: number) {
const resp = await http.delete(`/api/screen-savers/${id}`);
return resp.data.data as boolean;

View File

@ -1,4 +1,4 @@
import "./ScreenSaverManagement.css";
import "./ScreenSaverManagement.css";
import {
App,
@ -48,10 +48,11 @@ import {
type ScreenSaverUploadResult,
type ScreenSaverVO,
updateScreenSaver,
updateScreenSaverStatus,
uploadScreenSaverImage,
} from "@/api/business/screenSaver";
import { listUsers } from "@/api";
import type { SysUser } from "@/types";
import type { SysUser, UserProfile } from "@/types";
import dayjs from "dayjs";
const { Text, Title } = Typography;
@ -87,6 +88,8 @@ type CropModalState = {
src: string;
fileName: string;
mimeType: "image/jpeg" | "image/png";
targetWidth: number;
targetHeight: number;
};
type DragState = {
@ -156,6 +159,8 @@ function ScreenSaverCropDialog({ state, onCancel, onConfirm }: CropDialogProps)
const [offset, setOffset] = useState({ x: 0, y: 0 });
const dragRef = useRef<DragState | null>(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<Blob | null>((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)
<div className="screen-saver-crop-modal__stage">
<div className="screen-saver-crop-modal__stage-head">
<h3></h3>
<p> 8:5 1280 × 800使</p>
<p> 8:5 {targetWidth} × {targetHeight}使</p>
</div>
<div
className={`screen-saver-crop-modal__viewport${dragging ? " is-dragging" : ""}`}
@ -322,13 +327,13 @@ function ScreenSaverCropDialog({ state, onCancel, onConfirm }: CropDialogProps)
</div>
<div className="screen-saver-crop-modal__meta">
<span>{naturalSize.width || "-"} × {naturalSize.height || "-"}</span>
<span>{CROP_WIDTH} × {CROP_HEIGHT}</span>
<span>{targetWidth} × {targetHeight}</span>
</div>
</div>
<div className="screen-saver-crop-modal__sidebar">
<div className="screen-saver-crop-modal__sidebar-card">
<h4></h4>
<p></p>
<p></p>
<div style={{ marginTop: 18 }}>
<Text type="secondary"></Text>
<Slider
@ -343,7 +348,7 @@ function ScreenSaverCropDialog({ state, onCancel, onConfirm }: CropDialogProps)
</div>
<div className="screen-saver-crop-modal__sidebar-card">
<h4></h4>
<p> JPG / JPEG / PNG 1280 × 800 </p>
<p> JPG / JPEG / PNG {targetWidth} × {targetHeight} </p>
</div>
<div className="screen-saver-crop-modal__footer">
<Button onClick={onCancel} disabled={loading}></Button>
@ -377,7 +382,15 @@ export default function ScreenSaverManagement() {
src: "",
fileName: "",
mimeType: "image/jpeg",
targetWidth: CROP_WIDTH,
targetHeight: CROP_HEIGHT,
});
const userProfile = useMemo<UserProfile>(() => {
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<number, SysUser>(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) => (
<Space size={4}>
<Button type="text" icon={<EditOutlined />} onClick={() => openEdit(record)} />
<Popconfirm title="确认删除该屏保吗?" onConfirm={() => void handleDelete(record)}>
<Button type="text" danger icon={<DeleteOutlined />} />
</Popconfirm>
</Space>
),
render: (_, record) => {
const canManageRecord = isAdmin || (record.scopeType === "USER" && record.ownerUserId === currentUserId);
if (!canManageRecord) {
return null;
}
return (
<Space size={4}>
<Button type="text" icon={<EditOutlined />} onClick={() => openEdit(record)} />
<Popconfirm title="确认删除该屏保吗?" onConfirm={() => void handleDelete(record)}>
<Button type="text" danger icon={<DeleteOutlined />} />
</Popconfirm>
</Space>
);
},
},
];
const currentImageUrl = Form.useWatch("imageUrl", form);
const currentScopeType = Form.useWatch("scopeType", form) || "PLATFORM";
const currentOwnerUserId = Form.useWatch("ownerUserId", form);
const currentWidth = Form.useWatch("imageWidth", form) || CROP_WIDTH;
const currentHeight = Form.useWatch("imageHeight", form) || CROP_HEIGHT;
return (
<div className="app-page screen-saver-page">
@ -738,48 +770,52 @@ export default function ScreenSaverManagement() {
</Col>
</Row>
<Row gutter={16}>
<Col xs={24} md={12}>
<Form.Item name="scopeType" label="作用域" rules={[{ required: true, message: "请选择作用域" }]}>
<Select
options={[
{ label: "平台级(全平台共用)", value: "PLATFORM" },
{ label: "用户级(指定用户优先)", value: "USER" },
]}
/>
</Form.Item>
</Col>
<Col xs={24} md={12}>
<Form.Item
name="ownerUserId"
label="归属用户"
rules={currentScopeType === "USER" ? [{ required: true, message: "请选择归属用户" }] : []}
>
<Select
disabled={currentScopeType !== "USER"}
showSearch
allowClear
placeholder={currentScopeType === "USER" ? "请选择用户" : "平台级无需选择"}
optionFilterProp="label"
options={users.map((user) => ({
value: user.userId,
label: `${normalizeOwnerLabel(user)} / ${user.username}`,
}))}
/>
</Form.Item>
</Col>
</Row>
{isAdmin ? (
<Row gutter={16}>
<Col xs={24} md={12}>
<Form.Item name="scopeType" label="作用域" rules={[{ required: true, message: "请选择作用域" }]}>
<Select
options={[
{ label: "平台级(全平台共用)", value: "PLATFORM" },
{ label: "用户级(当前用户自己使用)", value: "USER" },
]}
/>
</Form.Item>
</Col>
<Col xs={24} md={12}>
<Form.Item label="归属用户">
<Input
value={currentScopeType === "USER" ? normalizeOwnerLabel(userMap.get(currentUserId)) : "平台级无需选择"}
readOnly
/>
</Form.Item>
</Col>
</Row>
) : (
<Row gutter={16}>
<Col xs={24} md={12}>
<Form.Item label="作用域">
<Input value="个人级" readOnly />
</Form.Item>
</Col>
<Col xs={24} md={12}>
<Form.Item label="归属用户">
<Input value={normalizeOwnerLabel(userMap.get(currentUserId))} readOnly />
</Form.Item>
</Col>
</Row>
)}
<Card
className="screen-saver-preview-card"
<Card
className="screen-saver-preview-card"
style={{ marginBottom: 18 }}
styles={{ body: { padding: 18 } }}
>
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
<div style={{ display: "flex", flexDirection: "column", gap: 16 }}>
<Space wrap style={{ width: "100%", justifyContent: "space-between" }}>
<div>
<Title level={5} style={{ margin: 0 }}></Title>
<Text type="secondary"> 8:5 1280 × 800</Text>
<Text type="secondary"> 8:5 </Text>
</div>
<Upload {...uploadProps}>
<Button type="primary" icon={<UploadOutlined />} loading={uploading}>
@ -800,11 +836,11 @@ export default function ScreenSaverManagement() {
)}
</div>
<Space wrap>
<span className="screen-saver-preview-pill"> 1280 × 800</span>
<span className="screen-saver-preview-pill"> {currentWidth} × {currentHeight}</span>
<span className="screen-saver-preview-pill"> {currentScopeType === "USER" ? "用户级" : "平台级"}</span>
{currentScopeType === "USER" && currentOwnerUserId ? (
{currentScopeType === "USER" ? (
<span className="screen-saver-preview-pill">
{normalizeOwnerLabel(userMap.get(currentOwnerUserId))}
{normalizeOwnerLabel(userMap.get(currentUserId))}
</span>
) : null}
</Space>
@ -818,13 +854,13 @@ export default function ScreenSaverManagement() {
</Form.Item>
</Col>
<Col xs={12} md={8}>
<Form.Item name="imageWidth" label="宽度">
<InputNumber disabled style={{ width: "100%" }} />
<Form.Item name="imageWidth" label="宽度" rules={[{ required: true, message: "请输入宽度" }]}>
<InputNumber min={100} max={4096} style={{ width: "100%" }} />
</Form.Item>
</Col>
<Col xs={12} md={8}>
<Form.Item name="imageHeight" label="高度">
<InputNumber disabled style={{ width: "100%" }} />
<Form.Item name="imageHeight" label="高度" rules={[{ required: true, message: "请输入高度" }]}>
<InputNumber min={100} max={4096} style={{ width: "100%" }} />
</Form.Item>
</Col>
</Row>
@ -834,7 +870,7 @@ export default function ScreenSaverManagement() {
</Form.Item>
<Form.Item name="description" label="描述">
<TextArea rows={3} placeholder="描述这张屏保用于什么场景、展示何种品牌信息或氛围。" />
<TextArea rows={3} placeholder="描述这张屏保用于什么场景、展示什么信息。" />
</Form.Item>
<Row gutter={16}>
@ -845,20 +881,22 @@ export default function ScreenSaverManagement() {
</Col>
<Col xs={24} md={12}>
<Form.Item name="remark" label="备注">
<Input placeholder="例如:大厅屏、品牌发布期、用户专属欢迎页" />
<Input placeholder="例如:大厅屏、品牌发布期、个人欢迎页" />
</Form.Item>
</Col>
</Row>
<Form.Item name="statusEnabled" label="启用状态" valuePropName="checked">
<Switch />
</Form.Item>
{(isAdmin || currentScopeType === "USER") ? (
<Form.Item name="statusEnabled" label="启用状态" valuePropName="checked">
<Switch />
</Form.Item>
) : null}
</Form>
</Drawer>
<ScreenSaverCropDialog
state={cropState}
onCancel={() => setCropState({ open: false, src: "", fileName: "", mimeType: "image/jpeg" })}
onCancel={() => setCropState((prev) => ({ ...prev, open: false, src: "" }))}
onConfirm={handleUploadCroppedImage}
/>
</div>

View File

@ -1,113 +1,224 @@
import { Row, Col, Card, Typography, Table, Tag, Skeleton, Button } from "antd";
import React, { useState, useEffect } from 'react';
import { Row, Col, Card, Statistic, List, Tag, Typography, Button, Space, Empty, Steps, Progress, Divider } from 'antd';
import {
VideoCameraOutlined,
DesktopOutlined,
UserOutlined,
ClockCircleOutlined,
HistoryOutlined,
CheckCircleOutlined,
SyncOutlined,
ArrowRightOutlined
} from "@ant-design/icons";
import { useTranslation } from "react-i18next";
import StatCard from "@/components/shared/StatCard/StatCard";
import PageHeader from "@/components/shared/PageHeader";
LoadingOutlined,
AudioOutlined,
RobotOutlined,
CalendarOutlined,
TeamOutlined,
RiseOutlined,
ClockCircleOutlined,
PlayCircleOutlined,
FileTextOutlined,
} from '@ant-design/icons';
import { useNavigate } from 'react-router-dom';
import dayjs from 'dayjs';
import { getDashboardStats, getRecentTasks, DashboardStats } from '@/api/business/dashboard';
import { MeetingVO, getMeetingProgress, MeetingProgress } from '@/api/business/meeting';
const { Text } = Typography;
const { Title, Text } = Typography;
export default function Dashboard() {
const { t } = useTranslation();
const MeetingProgressDisplay: React.FC<{ meeting: MeetingVO }> = ({ meeting }) => {
const [progress, setProgress] = useState<MeetingProgress | null>(null);
const recentMeetings = [
{ key: "1", name: "Product Sync", time: "2024-02-10 14:00", duration: "45min", status: "processing" },
{ key: "2", name: "Tech Review", time: "2024-02-10 10:00", duration: "60min", status: "success" },
{ key: "3", name: "Daily Standup", time: "2024-02-10 09:00", duration: "15min", status: "success" },
{ key: "4", name: "Client Call", time: "2024-02-10 16:30", duration: "30min", status: "default" }
];
useEffect(() => {
if (meeting.status !== 1 && meeting.status !== 2) return;
const columns = [
{
title: t("dashboard.meetingName"),
dataIndex: "name",
key: "name",
render: (text: string) => <Text strong>{text}</Text>
},
{
title: t("dashboard.startTime"),
dataIndex: "time",
key: "time",
className: "tabular-nums",
render: (text: string) => <Text type="secondary">{text}</Text>
},
{
title: t("dashboard.duration"),
dataIndex: "duration",
key: "duration",
width: 100,
className: "tabular-nums"
},
{
title: t("common.status"),
dataIndex: "status",
key: "status",
width: 120,
render: (status: string) => {
if (status === "processing") return <Tag icon={<SyncOutlined spin aria-hidden="true" />} color="processing">{t("dashboardExt.processing")}</Tag>;
if (status === "success") return <Tag icon={<CheckCircleOutlined aria-hidden="true" />} color="success">{t("dashboardExt.completed")}</Tag>;
return <Tag color="default">{t("dashboardExt.pending")}</Tag>;
const fetchProgress = async () => {
try {
const res = await getMeetingProgress(meeting.id);
if (res.data?.data) {
setProgress(res.data.data);
}
} catch (err) {
// ignore
}
},
{
title: t("common.action"),
key: "action",
width: 80,
render: () => <Button type="link" size="small" icon={<ArrowRightOutlined aria-hidden="true" />} aria-label={t("dashboard.viewAll")} />
};
fetchProgress();
const timer = setInterval(fetchProgress, 3000);
return () => clearInterval(timer);
}, [meeting.id, meeting.status]);
if (meeting.status !== 1 && meeting.status !== 2) return null;
const percent = progress?.percent || 0;
const isError = percent < 0;
return (
<div style={{ marginTop: 12, padding: '12px 16px', background: 'var(--app-bg-surface-soft)', borderRadius: 8, border: '1px solid var(--app-border-color)' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 6 }}>
<Text type="secondary" style={{ fontSize: 12 }}>
<LoadingOutlined style={{ marginRight: 6, color: '#1890ff' }} spin={!isError} />
{progress?.message || '准备分析中...'}
</Text>
{!isError && <Text strong style={{ color: '#1890ff' }}>{percent}%</Text>}
</div>
<Progress
percent={isError ? 100 : percent}
size="small"
status={isError ? 'exception' : (percent === 100 ? 'success' : 'active')}
showInfo={false}
strokeColor={isError ? '#ff4d4f' : { '0%': '#108ee9', '100%': '#87d068' }}
/>
</div>
);
};
export const Dashboard: React.FC = () => {
const navigate = useNavigate();
const [stats, setStats] = useState<DashboardStats | null>(null);
const [recentTasks, setRecentTasks] = useState<MeetingVO[]>([]);
const [loading, setLoading] = useState(true);
const processingCount = Number(stats?.processingTasks || 0);
const dashboardLoading = loading && processingCount > 0;
useEffect(() => {
fetchDashboardData();
const timer = setInterval(fetchDashboardData, 5000);
return () => clearInterval(timer);
}, []);
const fetchDashboardData = async () => {
try {
const [statsRes, tasksRes] = await Promise.all([getDashboardStats(), getRecentTasks()]);
setStats(statsRes.data.data);
setRecentTasks(tasksRes.data.data || []);
} catch (err) {
console.error('Dashboard data load failed', err);
} finally {
setLoading(false);
}
};
const renderTaskProgress = (item: MeetingVO) => {
const currentStep = item.status === 4 ? 0 : (item.status === 3 ? 2 : item.status);
return (
<div style={{ width: '100%', maxWidth: 450 }}>
<Steps
size="small"
current={currentStep}
status={item.status === 4 ? 'error' : (item.status === 3 ? 'finish' : 'process')}
items={[
{
title: '语音转录',
icon: item.status === 1 ? <LoadingOutlined spin /> : <AudioOutlined />,
description: item.status > 1 ? '识别完成' : (item.status === 1 ? 'AI转录中' : '排队中')
},
{
title: '智能总结',
icon: item.status === 2 ? <LoadingOutlined spin /> : <RobotOutlined />,
description: item.status === 3 ? '总结完成' : (item.status === 2 ? '正在生成' : '待执行')
},
{
title: '分析完成',
icon: item.status === 3 ? <CheckCircleOutlined style={{ color: '#52c41a' }} /> : <FileTextOutlined />,
}
]}
/>
</div>
);
};
const statCards = [
{ label: '累计会议记录', value: stats?.totalMeetings, icon: <HistoryOutlined />, color: '#1890ff' },
{
label: '当前分析中任务',
value: stats?.processingTasks,
icon: processingCount > 0 ? <LoadingOutlined spin /> : <ClockCircleOutlined />,
color: '#faad14'
},
{ label: '今日新增分析', value: stats?.todayNew, icon: <RiseOutlined />, color: '#52c41a' },
{ label: 'AI 处理成功率', value: `${stats?.successRate || 100}%`, icon: <CheckCircleOutlined />, color: '#13c2c2' },
];
return (
<div className="app-page dashboard-page">
<PageHeader
title={t("dashboard.title")}
subtitle={t("dashboard.subtitle")}
/>
<div style={{ padding: '24px', background: 'var(--app-bg-page)', minHeight: '100%', overflowY: 'auto' }}>
<div style={{ maxWidth: 1400, margin: '0 auto' }}>
<Row gutter={24} style={{ marginBottom: 24 }}>
{statCards.map((s, idx) => (
<Col span={6} key={idx}>
<Card variant="borderless" style={{ borderRadius: 16, boxShadow: 'var(--app-shadow)', background: 'var(--app-bg-card)', border: '1px solid var(--app-border-color)', backdropFilter: 'blur(16px)' }}>
<Statistic
title={<Text type="secondary" style={{ fontSize: 13 }}>{s.label}</Text>}
value={s.value || 0}
valueStyle={{ color: s.color, fontWeight: 700 }}
prefix={React.cloneElement(s.icon as React.ReactElement, { style: { marginRight: 8 } })}
/>
</Card>
</Col>
))}
</Row>
<div className="app-page__page-actions">
<Button icon={<SyncOutlined aria-hidden="true" />} size="small">{t("common.refresh")}</Button>
<Card
title={
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<Space><ClockCircleOutlined /> </Space>
<Button type="link" onClick={() => navigate('/meetings')}></Button>
</div>
}
variant="borderless"
style={{ borderRadius: 16, boxShadow: 'var(--app-shadow)', background: 'var(--app-bg-card)', border: '1px solid var(--app-border-color)', backdropFilter: 'blur(16px)' }}
>
<List
loading={dashboardLoading}
dataSource={recentTasks}
renderItem={(item) => (
<List.Item style={{ padding: '24px 0', borderBottom: '1px solid #f0f2f5' }}>
<div style={{ width: '100%' }}>
<Row gutter={32} align="middle">
<Col span={8}>
<Space direction="vertical" size={4}>
<Title level={5} style={{ margin: 0, cursor: 'pointer' }} onClick={() => navigate(`/meetings/${item.id}`)}>
{item.title}
</Title>
<Space size={12} split={<Divider type="vertical" style={{ margin: 0 }} />}>
<Text type="secondary"><CalendarOutlined /> {dayjs(item.meetingTime).format('MM-DD HH:mm')}</Text>
<Text type="secondary"><TeamOutlined /> {item.participants || item.creatorName || '未指定'}</Text>
</Space>
<div style={{ marginTop: 8 }}>
{item.tags?.split(',').filter(Boolean).map((t) => (
<Tag key={t} style={{ border: '1px solid var(--app-border-color)', background: 'color-mix(in srgb, var(--app-primary-color) 12%, var(--app-bg-surface-strong))', color: 'var(--app-text-main)', borderRadius: 4, fontSize: 11 }}>{t}</Tag>
))}
</div>
</Space>
</Col>
<Col span={12}>
{renderTaskProgress(item)}
</Col>
<Col span={4} style={{ textAlign: 'right' }}>
<Button
type={item.status === 3 ? 'primary' : 'default'}
ghost={item.status === 3}
icon={item.status === 3 ? <FileTextOutlined /> : <PlayCircleOutlined />}
onClick={() => navigate(`/meetings/${item.id}`)}
>
{item.status === 3 ? '查看纪要' : '监控详情'}
</Button>
</Col>
</Row>
<MeetingProgressDisplay meeting={item} />
</div>
</List.Item>
)}
locale={{ emptyText: <Empty description="暂无近期分析任务" /> }}
/>
</Card>
</div>
<Row gutter={[24, 24]}>
<Col xs={24} sm={12} lg={6}>
<StatCard title={t("dashboard.todayMeetings")} value={12} icon={<VideoCameraOutlined aria-hidden="true" />} color="blue" trend={{ value: 8, direction: "up" }} />
</Col>
<Col xs={24} sm={12} lg={6}>
<StatCard title={t("dashboard.activeDevices")} value={45} icon={<DesktopOutlined aria-hidden="true" />} color="green" trend={{ value: 2, direction: "up" }} />
</Col>
<Col xs={24} sm={12} lg={6}>
<StatCard title={t("dashboard.transcriptionDuration")} value={1280} suffix="min" icon={<ClockCircleOutlined aria-hidden="true" />} color="orange" trend={{ value: 5, direction: "down" }} />
</Col>
<Col xs={24} sm={12} lg={6}>
<StatCard title={t("dashboard.totalUsers")} value={320} icon={<UserOutlined aria-hidden="true" />} color="purple" trend={{ value: 12, direction: "up" }} />
</Col>
</Row>
<Row gutter={[24, 24]} className="mt-6">
<Col xs={24} xl={16}>
<Card title={t("dashboard.recentMeetings")} bordered={false} className="app-page__content-card" extra={<Button type="link" size="small">{t("dashboard.viewAll")}</Button>} styles={{ body: { padding: 0 } }}>
<Table dataSource={recentMeetings} columns={columns} pagination={false} size="middle" className="roles-table" />
</Card>
</Col>
<Col xs={24} xl={8}>
<Card title={t("dashboard.deviceLoad")} bordered={false} className="app-page__content-card">
<div className="flex flex-col items-center justify-center py-12">
<Skeleton active paragraph={{ rows: 4 }} />
<div className="mt-4 text-gray-400 flex items-center gap-2">
<SyncOutlined spin aria-hidden="true" />
<span>{t("dashboardExt.chartLoading")}</span>
</div>
</div>
</Card>
</Col>
</Row>
<style>{`
.ant-steps-item-title { font-size: 13px !important; font-weight: 600 !important; }
.ant-steps-item-description { font-size: 11px !important; }
`}</style>
</div>
);
}
};
export default Dashboard;

View File

@ -0,0 +1,118 @@
.avatar-crop-modal .ant-modal-content {
overflow: hidden;
border-radius: 26px;
}
.avatar-crop-modal .ant-modal-body {
padding: 0;
}
.avatar-crop-modal__layout {
display: grid;
grid-template-columns: minmax(0, 1.2fr) 320px;
min-height: 540px;
}
.avatar-crop-modal__stage {
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
gap: 30px;
padding: 32px;
background:
radial-gradient(circle at top left, rgba(22, 119, 255, 0.18), transparent 28%),
linear-gradient(160deg, #081326, #12284b 55%, #17315b 100%);
}
.avatar-crop-modal__stage-head {
width: 100%;
color: rgba(233, 242, 252, 0.92);
text-align: center;
}
.avatar-crop-modal__stage-head h3 {
margin: 0 0 8px;
color: #f8fbff;
font-size: 20px;
font-weight: 800;
letter-spacing: -0.04em;
}
.avatar-crop-modal__stage-head p {
margin: 0;
color: rgba(223, 233, 247, 0.72);
font-size: 13px;
line-height: 1.6;
}
.avatar-crop-modal__viewport {
position: relative;
width: 300px;
height: 300px;
border-radius: 50%;
background: rgba(6, 13, 28, 0.78);
box-shadow: 0 24px 50px rgba(5, 12, 25, 0.4), 0 0 0 1px rgba(255, 255, 255, 0.12);
overflow: hidden;
cursor: grab;
touch-action: none;
}
.avatar-crop-modal__viewport.is-dragging {
cursor: grabbing;
}
/* Added inner shadow to make border clearer on dark image */
.avatar-crop-modal__viewport::before {
content: "";
position: absolute;
inset: 0;
border: 1px solid rgba(255, 255, 255, 0.16);
border-radius: 50%;
pointer-events: none;
z-index: 10;
}
.avatar-crop-modal__image {
position: absolute;
top: 50%;
left: 50%;
transform-origin: center;
max-width: none;
pointer-events: none;
}
.avatar-crop-modal__sidebar {
display: flex;
flex-direction: column;
background: #fff;
}
.avatar-crop-modal__sidebar-card {
padding: 32px 28px;
border-bottom: 1px solid rgba(15, 23, 42, 0.06);
}
.avatar-crop-modal__sidebar-card h4 {
margin: 0 0 8px;
color: #10233f;
font-size: 16px;
font-weight: 700;
}
.avatar-crop-modal__sidebar-card p {
margin: 0;
color: #5b6b84;
font-size: 13px;
line-height: 1.6;
}
.avatar-crop-modal__footer {
margin-top: auto;
padding: 24px 28px;
display: flex;
justify-content: flex-end;
gap: 12px;
background: #f8fafc;
border-top: 1px solid rgba(15, 23, 42, 0.04);
}

View File

@ -0,0 +1,261 @@
import { Button, Modal, Slider, Typography, App } from "antd";
import { ScissorOutlined } from "@ant-design/icons";
import { useEffect, useMemo, useRef, useState } from "react";
import "./AvatarCropDialog.css";
const { Text } = Typography;
const VIEWPORT_SIZE = 300;
const ALLOWED_TYPES = new Map([
["image/jpeg", "jpg"],
["image/jpg", "jpg"],
["image/png", "png"],
]);
export type CropModalState = {
open: boolean;
src: string;
fileName: string;
mimeType: "image/jpeg" | "image/png";
targetSize: number;
};
type DragState = {
startX: number;
startY: number;
originX: number;
originY: number;
};
function clamp(value: number, min: number, max: number) {
return Math.min(Math.max(value, min), max);
}
function createImage(src: string) {
return new Promise<HTMLImageElement>((resolve, reject) => {
const image = new Image();
image.onload = () => resolve(image);
image.onerror = () => reject(new Error("图片加载失败"));
image.src = src;
});
}
type CropDialogProps = {
state: CropModalState;
onCancel: () => void;
onConfirm: (file: File) => Promise<void>;
};
export default function AvatarCropDialog({ state, onCancel, onConfirm }: CropDialogProps) {
const { message } = App.useApp();
const [loading, setLoading] = useState(false);
const [dragging, setDragging] = useState(false);
const [naturalSize, setNaturalSize] = useState({ width: 0, height: 0 });
const [minZoom, setMinZoom] = useState(1);
const [zoom, setZoom] = useState(1);
const [offset, setOffset] = useState({ x: 0, y: 0 });
const dragRef = useRef<DragState | null>(null);
const { targetSize } = state;
useEffect(() => {
if (!state.open) {
return;
}
let active = true;
void createImage(state.src)
.then((image) => {
if (!active) {
return;
}
const nextMinZoom = Math.max(VIEWPORT_SIZE / image.width, VIEWPORT_SIZE / image.height);
setNaturalSize({ width: image.width, height: image.height });
setMinZoom(nextMinZoom);
setZoom(nextMinZoom);
setOffset({ x: 0, y: 0 });
})
.catch((error: Error) => {
message.error(error.message || "图片加载失败");
});
return () => {
active = false;
};
}, [message, state.open, state.src]);
const displaySize = useMemo(() => ({
width: naturalSize.width * zoom,
height: naturalSize.height * zoom,
}), [naturalSize, zoom]);
const clampOffset = (x: number, y: number, currentZoom = zoom) => {
const width = naturalSize.width * currentZoom;
const height = naturalSize.height * currentZoom;
const maxX = Math.max(0, (width - VIEWPORT_SIZE) / 2);
const maxY = Math.max(0, (height - VIEWPORT_SIZE) / 2);
return {
x: clamp(x, -maxX, maxX),
y: clamp(y, -maxY, maxY),
};
};
const handleZoomChange = (nextZoom: number) => {
const nextOffset = clampOffset(offset.x, offset.y, nextZoom);
setZoom(nextZoom);
setOffset(nextOffset);
};
const beginDrag = (clientX: number, clientY: number) => {
dragRef.current = {
startX: clientX,
startY: clientY,
originX: offset.x,
originY: offset.y,
};
setDragging(true);
};
const updateDrag = (clientX: number, clientY: number) => {
if (!dragRef.current) {
return;
}
const deltaX = clientX - dragRef.current.startX;
const deltaY = clientY - dragRef.current.startY;
setOffset(clampOffset(dragRef.current.originX + deltaX, dragRef.current.originY + deltaY));
};
const endDrag = () => {
dragRef.current = null;
setDragging(false);
};
const exportCroppedFile = async () => {
const image = await createImage(state.src);
const canvas = document.createElement("canvas");
canvas.width = targetSize;
canvas.height = targetSize;
const context = canvas.getContext("2d");
if (!context) {
throw new Error("浏览器不支持图片裁剪");
}
const previewScale = targetSize / VIEWPORT_SIZE;
const exportedWidth = image.width * zoom * previewScale;
const exportedHeight = image.height * zoom * previewScale;
const drawX = targetSize / 2 - exportedWidth / 2 + offset.x * previewScale;
const drawY = targetSize / 2 - exportedHeight / 2 + offset.y * previewScale;
context.drawImage(image, drawX, drawY, exportedWidth, exportedHeight);
// 如果想要导出也是圆角的透明底图片PNG可以在这里进行裁剪
// if (state.mimeType === "image/png") {
// context.globalCompositeOperation = "destination-in";
// context.beginPath();
// context.arc(targetSize / 2, targetSize / 2, targetSize / 2, 0, Math.PI * 2);
// context.fill();
// }
const extension = state.mimeType === "image/png" ? "png" : "jpg";
const fileName = state.fileName.replace(/\.[^.]+$/, "") + `_avatar.${extension}`;
const blob = await new Promise<Blob | null>((resolve) => {
if (state.mimeType === "image/png") {
canvas.toBlob(resolve, "image/png");
} else {
canvas.toBlob(resolve, "image/jpeg", 0.92);
}
});
if (!blob) {
throw new Error("导出裁剪图片失败");
}
return new File([blob], fileName, { type: state.mimeType });
};
const handleConfirm = async () => {
setLoading(true);
try {
const file = await exportCroppedFile();
await onConfirm(file);
} catch (error) {
message.error(error instanceof Error ? error.message : "裁剪上传失败");
} finally {
setLoading(false);
}
};
return (
<Modal
open={state.open}
onCancel={loading ? undefined : onCancel}
footer={null}
width={800}
centered
destroyOnHidden
className="avatar-crop-modal"
maskClosable={!loading}
closable={!loading}
>
<div className="avatar-crop-modal__layout">
<div className="avatar-crop-modal__stage">
<div className="avatar-crop-modal__stage-head">
<h3></h3>
<p> {targetSize} × {targetSize} </p>
</div>
<div
className={`avatar-crop-modal__viewport${dragging ? " is-dragging" : ""}`}
onMouseDown={(event) => beginDrag(event.clientX, event.clientY)}
onMouseMove={(event) => updateDrag(event.clientX, event.clientY)}
onMouseUp={endDrag}
onMouseLeave={endDrag}
onTouchStart={(event) => {
const touch = event.touches[0];
if (touch) {
beginDrag(touch.clientX, touch.clientY);
}
}}
onTouchMove={(event) => {
const touch = event.touches[0];
if (touch) {
updateDrag(touch.clientX, touch.clientY);
}
}}
onTouchEnd={endDrag}
>
{state.src ? (
<img
src={state.src}
alt="待裁剪头像"
className="avatar-crop-modal__image"
draggable={false}
style={{
width: displaySize.width,
height: displaySize.height,
transform: `translate(calc(-50% + ${offset.x}px), calc(-50% + ${offset.y}px))`,
}}
/>
) : null}
<div className="avatar-crop-modal__mask"></div>
</div>
</div>
<div className="avatar-crop-modal__sidebar">
<div className="avatar-crop-modal__sidebar-card">
<h4></h4>
<div style={{ marginTop: 18 }}>
<Slider
min={minZoom}
max={Math.max(minZoom * 3, minZoom + 0.2)}
step={0.01}
value={zoom}
tooltip={{ formatter: (value) => `${Math.round((value || 1) / minZoom * 100)}%` }}
onChange={handleZoomChange}
/>
</div>
</div>
<div className="avatar-crop-modal__footer">
<Button onClick={onCancel} disabled={loading}></Button>
<Button type="primary" icon={<ScissorOutlined />} loading={loading} onClick={() => void handleConfirm()}>
</Button>
</div>
</div>
</div>
</Modal>
);
}

View File

@ -5,6 +5,7 @@ import { useTranslation } from "react-i18next";
import { generateMyBotCredential, getCurrentUser, getMyBotCredential, updateMyPassword, updateMyProfile, uploadPlatformAsset } from "@/api";
import PageHeader from "@/components/shared/PageHeader";
import type { BotCredential, UserProfile } from "@/types";
import AvatarCropDialog, { type CropModalState } from "./AvatarCropDialog";
const { Paragraph, Title, Text } = Typography;
@ -19,6 +20,13 @@ export default function Profile() {
const [credential, setCredential] = useState<BotCredential | null>(null);
const [profileForm] = Form.useForm();
const [pwdForm] = Form.useForm();
const [cropState, setCropState] = useState<CropModalState>({
open: false,
src: "",
fileName: "",
mimeType: "image/jpeg",
targetSize: 300,
});
const loadUser = async () => {
setLoading(true);
@ -60,19 +68,34 @@ export default function Profile() {
}
};
const handleAvatarUpload = async (file: File) => {
const handleAvatarUpload = (file: File) => {
const reader = new FileReader();
reader.onload = () => {
setCropState({
open: true,
src: String(reader.result || ""),
fileName: file.name,
mimeType: file.type === "image/png" ? "image/png" : "image/jpeg",
targetSize: 300,
});
};
reader.onerror = () => message.error(t("common.error"));
reader.readAsDataURL(file);
return Upload.LIST_IGNORE;
};
const handleUploadCroppedImage = async (file: File) => {
try {
setAvatarUploading(true);
const url = await uploadPlatformAsset(file);
profileForm.setFieldValue("avatarUrl", url);
setCropState((prev) => ({ ...prev, open: false, src: "" }));
message.success(t("common.success"));
} catch (error) {
message.error(error instanceof Error ? error.message : t("common.error"));
return Upload.LIST_IGNORE;
} finally {
setAvatarUploading(false);
}
return false;
};
const handleUpdatePassword = async () => {
@ -109,7 +132,11 @@ export default function Profile() {
<Row gutter={24}>
<Col xs={24} lg={8}>
<Card className="app-page__content-card text-center" loading={loading}>
<Avatar size={80} src={avatarUrl} icon={avatarUrl ? undefined : <UserOutlined />} style={{ backgroundColor: "#1677ff", marginBottom: 16 }} />
<Upload accept="image/*" showUploadList={false} beforeUpload={handleAvatarUpload} disabled={avatarUploading}>
<div style={{ cursor: "pointer", display: "inline-block" }}>
<Avatar size={80} src={avatarUrl} icon={avatarUrl ? undefined : <UserOutlined />} style={{ backgroundColor: "#1677ff", marginBottom: 16 }} />
</div>
</Upload>
<Title level={5} style={{ margin: 0 }}>{user?.displayName}</Title>
<Text type="secondary">@{user?.username}</Text>
<div className="mt-4">
@ -269,6 +296,12 @@ export default function Profile() {
</Card>
</Col>
</Row>
<AvatarCropDialog
state={cropState}
onCancel={() => setCropState((prev) => ({ ...prev, open: false, src: "" }))}
onConfirm={handleUploadCroppedImage}
/>
</div>
);
}