feat: 添加屏保用户配置和相关功能
- 在 `ScreenSaverServiceImpl` 中添加用户状态配置逻辑 - 添加 `ScreenSaverUserConfig` 实体类和 `ScreenSaverUserConfigMapper` 映射器 - 更新 `LegacyMeetingAttendeeResponse`, `LegacyMeetingPreviewDataResponse`, 和 `LegacyMeetingProcessingStatusResponse` 以包含 Swagger 注解 - 添加 `ScreenSaverServiceImplTest` 单元测试 - 更新 `AndroidCreateRealtimeMeetingVO` 以包含 Swagger 注解 - 在 `AndroidAuthServiceImplTest` 中添加匿名认证测试 - 添加 `AndroidExternalAppController` 控制器dev_na
parent
6107e611f4
commit
900f092d5e
|
|
@ -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不能为空");
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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())
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()")
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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> {
|
||||
}
|
||||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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)) {
|
||||
|
|
|
|||
|
|
@ -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/**
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
}
|
||||
}
|
||||
|
|
@ -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());
|
||||
}
|
||||
}
|
||||
|
|
@ -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());
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue