diff --git a/backend/src/main/java/com/imeeting/common/MeetingConstants.java b/backend/src/main/java/com/imeeting/common/MeetingConstants.java index ff8fe4c..1e05429 100644 --- a/backend/src/main/java/com/imeeting/common/MeetingConstants.java +++ b/backend/src/main/java/com/imeeting/common/MeetingConstants.java @@ -1,12 +1,21 @@ package com.imeeting.common; public final class MeetingConstants { - public static final String TYPE_OFFLINE = "OFFLINE"; public static final String TYPE_REALTIME = "REALTIME"; public static final String SOURCE_WEB = "WEB"; public static final String SOURCE_ANDROID = "ANDROID"; + + public static final String DEVICE_MODE_PUBLIC = "PUBLIC"; + public static final String DEVICE_MODE_PRIVATE = "PRIVATE"; + + public static final String DEVICE_DELIVERY_NONE = "NONE"; + public static final String DEVICE_DELIVERY_PENDING = "PENDING"; + public static final String DEVICE_DELIVERY_ACKED = "ACKED"; + public static final String DEVICE_DELIVERY_EXPIRED = "EXPIRED"; + public static final String DEVICE_DELIVERY_CANCELLED = "CANCELLED"; + public static final String SUMMARY_DETAIL_DETAILED = "DETAILED"; public static final String SUMMARY_DETAIL_STANDARD = "STANDARD"; public static final String SUMMARY_DETAIL_BRIEF = "BRIEF"; diff --git a/backend/src/main/java/com/imeeting/common/RedisKeys.java b/backend/src/main/java/com/imeeting/common/RedisKeys.java index d2f60a6..dc7d5d4 100644 --- a/backend/src/main/java/com/imeeting/common/RedisKeys.java +++ b/backend/src/main/java/com/imeeting/common/RedisKeys.java @@ -111,6 +111,18 @@ public final class RedisKeys { return "biz:meeting:realtime:event-seq:" + meetingId; } + public static String publicMeetingSessionKey(String sessionId) { + return "biz:meeting:public-session:" + sessionId; + } + + public static String androidChunkUploadSessionKey(String uploadSessionId) { + return "biz:meeting:android:chunk-upload:" + uploadSessionId; + } + + public static String androidPendingMeetingDraftKey(Long meetingId) { + return "biz:meeting:android:draft:" + meetingId; + } + public static final String CACHE_EMPTY_MARKER = "EMPTY_MARKER"; public static final String SYS_PARAM_FIELD_VALUE = "value"; public static final String SYS_PARAM_FIELD_TYPE = "type"; diff --git a/backend/src/main/java/com/imeeting/common/SysParamKeys.java b/backend/src/main/java/com/imeeting/common/SysParamKeys.java index 3cb0411..fd5836a 100644 --- a/backend/src/main/java/com/imeeting/common/SysParamKeys.java +++ b/backend/src/main/java/com/imeeting/common/SysParamKeys.java @@ -12,4 +12,6 @@ public final class SysParamKeys { public static final String MEETING_MAX_PAUSE_DURATION = "meeting.max_pause_duration"; public static final String MEETING_MAX_MEETING_DURATION = "meeting.max_meeting_duration"; public static final String MEETING_PACKET_LOSS_RATE = "meeting.packet_loss_rate"; + public static final String MEETING_ANDROID_AUDIO_CHUNK_UPLOAD_ENABLED = "meeting.android.audio.chunk_upload_enabled"; + public static final String MEETING_ANDROID_AUDIO_CHUNK_DURATION_SECONDS = "meeting.android.audio.chunk_duration_seconds"; } diff --git a/backend/src/main/java/com/imeeting/controller/android/AndroidAuthController.java b/backend/src/main/java/com/imeeting/controller/android/AndroidAuthController.java index 98b9789..c1adfd4 100644 --- a/backend/src/main/java/com/imeeting/controller/android/AndroidAuthController.java +++ b/backend/src/main/java/com/imeeting/controller/android/AndroidAuthController.java @@ -1,16 +1,19 @@ package com.imeeting.controller.android; +import com.imeeting.service.android.AndroidDeviceBindingService; +import com.imeeting.support.AndroidRequestLogHelper; +import com.unisbase.auth.JwtTokenProvider; 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 com.imeeting.support.AndroidRequestLogHelper; +import io.swagger.v3.oas.annotations.Operation; 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 jakarta.validation.Valid; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -27,8 +30,9 @@ import org.springframework.web.bind.annotation.RestController; @RequiredArgsConstructor @Slf4j public class AndroidAuthController { - private final AuthService authService; + private final AndroidDeviceBindingService androidDeviceBindingService; + private final JwtTokenProvider jwtTokenProvider; @Operation(summary = "Android登录") @ApiResponses({ @@ -39,9 +43,26 @@ public class AndroidAuthController { ) }) @PostMapping("/login") - public ApiResponse login(@Valid @RequestBody LoginRequest request) { - AndroidRequestLogHelper.logRequest(log, "Android认证", "登录接口", "request", request); - return ApiResponse.ok(authService.login(request, true)); + public ApiResponse login(@Valid @RequestBody LoginRequest request, + @RequestHeader(value = "X-Android-Device-Id", required = false) String deviceId, + @RequestHeader(value = "X-Android-App-Version", required = false) String appVersion, + @RequestHeader(value = "X-Android-Platform", required = false) String platform) { + AndroidRequestLogHelper.logRequest(log, "Android认证", "登录接口", + "request", request, + "deviceId", deviceId, + "appVersion", appVersion, + "platform", platform); + TokenResponse response = authService.login(request, true); + if (response != null && response.getUser() != null && response.getCurrentTenantId() != null && StringUtils.hasText(deviceId)) { + androidDeviceBindingService.bindPrivateDevice( + deviceId.trim(), + response.getCurrentTenantId(), + response.getUser().getUserId(), + appVersion, + platform + ); + } + return ApiResponse.ok(response); } @Operation(summary = "Android刷新令牌") @@ -63,6 +84,26 @@ public class AndroidAuthController { return ApiResponse.ok(authService.refresh(resolveRefreshToken(request, authorization, androidAccessToken))); } + @Operation(summary = "Android退出登录") + @PostMapping("/logout") + public ApiResponse logout(HttpServletRequest request, + @RequestHeader(value = "Authorization", required = false) String authorization, + @RequestHeader(value = "X-Android-Device-Id", required = false) String deviceId) { + AndroidRequestLogHelper.logRequest(log, "Android认证", "退出登录接口", + "authorization", authorization, + "deviceId", deviceId); + String token = extractToken(authorization); + var claims = jwtTokenProvider.parseToken(token); + Long userId = claims.get("userId", Long.class); + Long tenantId = claims.get("tenantId", Long.class); + String sessionId = claims.get("sessionId", String.class); + authService.logout(userId, tenantId, sessionId); + if (StringUtils.hasText(deviceId)) { + androidDeviceBindingService.unbindPrivateDevice(deviceId.trim()); + } + return ApiResponse.ok(null); + } + private String resolveRefreshToken(RefreshRequest request, String authorization, String androidAccessToken) { if (request != null && StringUtils.hasText(request.getRefreshToken())) { return request.getRefreshToken().trim(); @@ -79,4 +120,12 @@ public class AndroidAuthController { } throw new IllegalArgumentException("refreshToken不能为空"); } + + private String extractToken(String authorization) { + if (!StringUtils.hasText(authorization)) { + throw new IllegalArgumentException("Authorization不能为空"); + } + String value = authorization.trim(); + return value.startsWith("Bearer ") ? value.substring(7).trim() : value; + } } diff --git a/backend/src/main/java/com/imeeting/controller/android/AndroidMeetingChunkUploadController.java b/backend/src/main/java/com/imeeting/controller/android/AndroidMeetingChunkUploadController.java new file mode 100644 index 0000000..d5af5a5 --- /dev/null +++ b/backend/src/main/java/com/imeeting/controller/android/AndroidMeetingChunkUploadController.java @@ -0,0 +1,93 @@ +package com.imeeting.controller.android; + +import com.imeeting.dto.android.AndroidAuthContext; +import com.imeeting.dto.android.legacy.LegacyUploadAudioResponse; +import com.imeeting.service.android.AndroidAuthService; +import com.imeeting.service.android.AndroidChunkUploadService; +import com.imeeting.support.AndroidRequestLogHelper; +import com.unisbase.annotation.Anonymous; +import com.unisbase.common.ApiResponse; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.servlet.http.HttpServletRequest; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.web.bind.annotation.PostMapping; +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; + +@Tag(name = "Android会议分片上传接口") +@RestController +@RequestMapping("/api/android/meetings/upload-audio") +@RequiredArgsConstructor +@Slf4j +public class AndroidMeetingChunkUploadController { + private final AndroidAuthService androidAuthService; + private final AndroidChunkUploadService androidChunkUploadService; + + @Operation(summary = "上传会议音频分片") + @io.swagger.v3.oas.annotations.responses.ApiResponses({ + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "200", + description = "分片上传成功返回 true", + content = @Content(schema = @Schema(implementation = Boolean.class)) + ) + }) + @PostMapping("/chunk") + @Anonymous + public ApiResponse uploadChunk(HttpServletRequest request, + @RequestParam("meeting_id") Long meetingId, + @RequestParam("upload_session_id") String uploadSessionId, + @RequestParam("chunk_index") Integer chunkIndex, + @RequestParam("total_chunks") Integer totalChunks, + @RequestParam("chunk_file") MultipartFile chunkFile) throws IOException { + AndroidRequestLogHelper.logRequest(log, "Android会议", "上传会议音频分片", + "meetingId", meetingId, + "uploadSessionId", uploadSessionId, + "chunkIndex", chunkIndex, + "totalChunks", totalChunks, + "chunkFile", chunkFile); + AndroidAuthContext authContext = androidAuthService.authenticateHttp(request); + androidChunkUploadService.saveChunk(meetingId, uploadSessionId, chunkIndex, totalChunks, chunkFile, authContext); + return ApiResponse.ok(true); + } + + @Operation(summary = "完成分片上传并触发会议音频处理") + @io.swagger.v3.oas.annotations.responses.ApiResponses({ + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "200", + description = "返回上传后的会议 ID 和音频地址", + content = @Content(schema = @Schema(implementation = LegacyUploadAudioResponse.class)) + ) + }) + @PostMapping("/complete") + @Anonymous + public ApiResponse completeUpload(HttpServletRequest request, + @RequestParam("meeting_id") Long meetingId, + @RequestParam("upload_session_id") String uploadSessionId, + @RequestParam(value = "force_replace", defaultValue = "false") boolean forceReplace, + @RequestParam(value = "prompt_id", required = false) Long promptId, + @RequestParam(value = "model_code", required = false) String modelCode) throws IOException { + AndroidRequestLogHelper.logRequest(log, "Android会议", "完成分片上传", + "meetingId", meetingId, + "uploadSessionId", uploadSessionId, + "forceReplace", forceReplace, + "promptId", promptId, + "modelCode", modelCode); + AndroidAuthContext authContext = androidAuthService.authenticateHttp(request); + return ApiResponse.ok(androidChunkUploadService.completeUpload( + meetingId, + uploadSessionId, + forceReplace, + promptId, + modelCode, + authContext + )); + } +} diff --git a/backend/src/main/java/com/imeeting/controller/android/AndroidMeetingController.java b/backend/src/main/java/com/imeeting/controller/android/AndroidMeetingController.java index ed341bd..6b4e922 100644 --- a/backend/src/main/java/com/imeeting/controller/android/AndroidMeetingController.java +++ b/backend/src/main/java/com/imeeting/controller/android/AndroidMeetingController.java @@ -2,9 +2,6 @@ package com.imeeting.controller.android; import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper; -import com.fasterxml.jackson.databind.JsonNode; -import com.fasterxml.jackson.databind.ObjectMapper; -import com.imeeting.common.RedisKeys; import com.imeeting.common.SysParamKeys; import com.imeeting.dto.android.AndroidAuthContext; import com.imeeting.dto.android.AndroidMeetingConfigVo; @@ -25,7 +22,6 @@ import com.imeeting.service.android.AndroidAuthService; import com.imeeting.service.android.legacy.LegacyMeetingAdapterService; import com.imeeting.support.AndroidRequestLogHelper; import com.imeeting.service.biz.*; -import com.imeeting.service.biz.impl.RedisOnlyMeetingProgressServiceAdapter; import com.unisbase.common.ApiResponse; import com.unisbase.common.annotation.Log; import com.unisbase.dto.PageResult; @@ -44,7 +40,6 @@ import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.servlet.http.HttpServletRequest; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Autowired; -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; @@ -91,7 +86,6 @@ public class AndroidMeetingController { private final SysDictItemService dictItemService; private final SysParamService paramService; private final MeetingProgressService meetingProgressService; - private final ObjectMapper objectMapper; @Autowired public AndroidMeetingController(AndroidAuthService androidAuthService, @@ -106,8 +100,7 @@ public class AndroidMeetingController { AiModelService aiModelService, SysDictItemService dictItemService, SysParamService paramService, - MeetingProgressService meetingProgressService, - ObjectMapper objectMapper) { + MeetingProgressService meetingProgressService) { this.androidAuthService = androidAuthService; this.legacyMeetingAdapterService = legacyMeetingAdapterService; this.meetingQueryService = meetingQueryService; @@ -118,44 +111,11 @@ public class AndroidMeetingController { this.promptTemplateService = promptTemplateService; this.sysUserMapper = sysUserMapper; this.meetingProgressService = meetingProgressService; - this.objectMapper = objectMapper; this.aiModelService = aiModelService; this.paramService = paramService; this.dictItemService = dictItemService; } - public AndroidMeetingController(AndroidAuthService androidAuthService, - LegacyMeetingAdapterService legacyMeetingAdapterService, - MeetingQueryService meetingQueryService, - MeetingAccessService meetingAccessService, - MeetingCommandService meetingCommandService, - MeetingService meetingService, - AiTaskService aiTaskService, - PromptTemplateService promptTemplateService, - SysUserMapper sysUserMapper, - AiModelService aiModelService, - SysDictItemService dictItemService, - SysParamService paramService, - StringRedisTemplate redisTemplate, - ObjectMapper objectMapper) { - this( - androidAuthService, - legacyMeetingAdapterService, - meetingQueryService, - meetingAccessService, - meetingCommandService, - meetingService, - aiTaskService, - promptTemplateService, - sysUserMapper, - aiModelService, - dictItemService, - paramService, - new RedisOnlyMeetingProgressServiceAdapter(redisTemplate, objectMapper), - objectMapper - ); - } - @Operation(summary = "创建Android离线会议") @ApiResponses({ @io.swagger.v3.oas.annotations.responses.ApiResponse( @@ -170,7 +130,11 @@ public class AndroidMeetingController { AndroidRequestLogHelper.logRequest(log, "Android会议", "创建离线会议接口", "request", command); AndroidAuthContext authContext = androidAuthService.authenticateHttp(request); LoginUser loginUser = AndroidLoginUserSupport.requireLoginUser(authContext); - return ApiResponse.ok(legacyMeetingAdapterService.createMeeting(command, loginUser)); + Meeting existingMeeting = findLatestUnfinishedMeetingByDevice(authContext.getDeviceId()); + if (existingMeeting != null) { + return new ApiResponse<>("409", "设备端已有会议", meetingQueryService.getDetailIgnoreTenant(existingMeeting.getId())); + } + return ApiResponse.ok(legacyMeetingAdapterService.createMeeting(command, authContext, loginUser)); } @Operation(summary = "上传Android会议音频") @@ -195,6 +159,14 @@ public class AndroidMeetingController { "forceReplace", forceReplace, "audioFile", audioFile); AndroidAuthContext authContext = androidAuthService.authenticateHttp(request); + if (authContext.isAnonymous()) { + return ApiResponse.ok(legacyMeetingAdapterService.uploadAndTriggerOfflineProcessForPublicDevice( + meetingId, + forceReplace, + audioFile, + authContext + )); + } LoginUser loginUser = AndroidLoginUserSupport.requireLoginUser(authContext); return ApiResponse.ok(legacyMeetingAdapterService.uploadAndTriggerOfflineProcess( meetingId, @@ -202,6 +174,7 @@ public class AndroidMeetingController { modelCode, forceReplace, audioFile, + authContext, loginUser )); } @@ -341,6 +314,8 @@ public class AndroidMeetingController { BigDecimal bigDecimal = new BigDecimal(paramService.getParamValue(SysParamKeys.MEETING_MAX_PAUSE_DURATION, "99")); bigDecimal = bigDecimal.setScale(2, RoundingMode.HALF_UP); resultVo.setPacketLossRate(bigDecimal ); + resultVo.setChunkUploadEnabled(Boolean.parseBoolean(paramService.getParamValue(SysParamKeys.MEETING_ANDROID_AUDIO_CHUNK_UPLOAD_ENABLED, "false"))); + resultVo.setChunkDurationSeconds(Integer.valueOf(paramService.getParamValue(SysParamKeys.MEETING_ANDROID_AUDIO_CHUNK_DURATION_SECONDS, "60"))); return ApiResponse.ok(resultVo); } @@ -629,4 +604,15 @@ public class AndroidMeetingController { private String formatDateTime(LocalDateTime value) { return value == null ? null : value.toString(); } + + private Meeting findLatestUnfinishedMeetingByDevice(String deviceId) { + if (deviceId == null || deviceId.isBlank()) { + return null; + } + return meetingService.getOne(new LambdaQueryWrapper() + .eq(Meeting::getSourceDeviceCode, deviceId) + .in(Meeting::getStatus, 0, 1, 2) + .orderByDesc(Meeting::getId) + .last("LIMIT 1")); + } } diff --git a/backend/src/main/java/com/imeeting/controller/android/AndroidPublicMeetingController.java b/backend/src/main/java/com/imeeting/controller/android/AndroidPublicMeetingController.java new file mode 100644 index 0000000..6e89332 --- /dev/null +++ b/backend/src/main/java/com/imeeting/controller/android/AndroidPublicMeetingController.java @@ -0,0 +1,120 @@ +package com.imeeting.controller.android; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.imeeting.dto.android.AndroidAuthContext; +import com.imeeting.dto.android.AndroidPublicMeetingSessionRequest; +import com.imeeting.dto.android.AndroidPublicMeetingSessionVO; +import com.imeeting.dto.biz.MeetingVO; +import com.imeeting.entity.biz.Meeting; +import com.imeeting.mapper.DeviceInfoMapper; +import com.imeeting.service.android.AndroidAuthService; +import com.imeeting.service.android.AndroidPublicMeetingSessionService; +import com.imeeting.service.biz.MeetingCommandService; +import com.imeeting.service.biz.MeetingQueryService; +import com.imeeting.service.biz.MeetingService; +import com.imeeting.support.AndroidRequestLogHelper; +import com.unisbase.annotation.Anonymous; +import com.unisbase.common.ApiResponse; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.servlet.http.HttpServletRequest; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@Tag(name = "Android公有设备会议接口") +@RestController +@RequestMapping("/api/android/public-meetings") +@RequiredArgsConstructor +@Slf4j +public class AndroidPublicMeetingController { + private final AndroidAuthService androidAuthService; + private final AndroidPublicMeetingSessionService androidPublicMeetingSessionService; + private final MeetingQueryService meetingQueryService; + private final MeetingCommandService meetingCommandService; + private final MeetingService meetingService; + private final DeviceInfoMapper deviceInfoMapper; + + @Operation(summary = "创建公有设备扫码发会会话") + @io.swagger.v3.oas.annotations.responses.ApiResponses({ + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "200", + description = "返回扫码会话信息,供设备展示二维码", + content = @Content(schema = @Schema(implementation = AndroidPublicMeetingSessionVO.class)) + ) + }) + @PostMapping("/session") + @Anonymous + public ApiResponse createSession(HttpServletRequest request, + @RequestBody(required = false) AndroidPublicMeetingSessionRequest command) { + AndroidRequestLogHelper.logRequest(log, "Android公有会议", "创建扫码发会会话", + "request", command); + AndroidAuthContext authContext = androidAuthService.authenticateHttp(request); + assertPublicDevice(authContext.getDeviceId()); + Meeting existingMeeting = findLatestUnfinishedMeetingByDevice(authContext.getDeviceId()); + if (existingMeeting != null) { + return new ApiResponse<>("409", "设备端已有会议", meetingQueryService.getDetailIgnoreTenant(existingMeeting.getId())); + } + AndroidPublicMeetingSessionVO vo = androidPublicMeetingSessionService.create( + authContext.getDeviceId(), + command == null ? null : command.getTitle() + ); + return ApiResponse.ok(vo); + } + + @Operation(summary = "公有设备删除未开始会议") + @io.swagger.v3.oas.annotations.responses.ApiResponses({ + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "200", + description = "删除成功返回 true", + content = @Content(schema = @Schema(implementation = Boolean.class)) + ) + }) + @DeleteMapping("/{meetingId}") + @Anonymous + public ApiResponse deletePendingMeeting(HttpServletRequest request, @PathVariable Long meetingId) { + AndroidRequestLogHelper.logRequest(log, "Android公有会议", "删除未开始会议", "meetingId", meetingId); + AndroidAuthContext authContext = androidAuthService.authenticateHttp(request); + assertPublicDevice(authContext.getDeviceId()); + Meeting meeting = meetingService.getById(meetingId); + if (meeting == null) { + throw new RuntimeException("会议不存在"); + } + if (meeting.getSourceDeviceCode() == null || !meeting.getSourceDeviceCode().equals(authContext.getDeviceId())) { + throw new RuntimeException("当前会议不属于该设备"); + } + if (meeting.getStatus() != null && meeting.getStatus() > 2) { + throw new RuntimeException("当前会议状态不允许删除"); + } + meetingCommandService.deleteMeeting(meetingId); + return ApiResponse.ok(true); + } + + private Meeting findLatestUnfinishedMeetingByDevice(String deviceId) { + if (deviceId == null || deviceId.isBlank()) { + return null; + } + return meetingService.getOne(new LambdaQueryWrapper() + .eq(Meeting::getSourceDeviceCode, deviceId) + .in(Meeting::getStatus, 0, 1, 2) + .orderByDesc(Meeting::getId) + .last("LIMIT 1")); + } + + private void assertPublicDevice(String deviceId) { + if (deviceId == null || deviceId.isBlank()) { + throw new RuntimeException("设备ID不能为空"); + } + var device = deviceInfoMapper.selectByDeviceCodeIgnoreTenant(deviceId); + if (device != null && device.getUserId() != null) { + throw new RuntimeException("当前设备为私有设备,请走私有设备发会流程"); + } + } +} diff --git a/backend/src/main/java/com/imeeting/controller/android/legacy/LegacyMeetingController.java b/backend/src/main/java/com/imeeting/controller/android/legacy/LegacyMeetingController.java index dae8560..aebd366 100644 --- a/backend/src/main/java/com/imeeting/controller/android/legacy/LegacyMeetingController.java +++ b/backend/src/main/java/com/imeeting/controller/android/legacy/LegacyMeetingController.java @@ -2,10 +2,9 @@ package com.imeeting.controller.android.legacy; import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper; -import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; -import com.imeeting.common.RedisKeys; import com.imeeting.dto.android.legacy.LegacyApiResponse; +import com.imeeting.dto.android.AndroidAuthContext; import com.imeeting.dto.android.legacy.LegacyMeetingAccessPasswordRequest; import com.imeeting.dto.android.legacy.LegacyMeetingAccessPasswordResponse; import com.imeeting.dto.android.legacy.LegacyMeetingAttendeeResponse; @@ -31,7 +30,6 @@ import com.imeeting.service.biz.MeetingProgressService; import com.imeeting.service.biz.MeetingQueryService; import com.imeeting.service.biz.MeetingService; import com.imeeting.service.biz.PromptTemplateService; -import com.imeeting.service.biz.impl.RedisOnlyMeetingProgressServiceAdapter; import com.unisbase.common.annotation.Log; import com.unisbase.dto.PageResult; import com.unisbase.entity.SysUser; @@ -41,7 +39,6 @@ import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.data.redis.core.StringRedisTemplate; import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.web.bind.annotation.DeleteMapping; @@ -109,32 +106,6 @@ public class LegacyMeetingController { new ObjectMapper()); } - public LegacyMeetingController(LegacyMeetingAdapterService legacyMeetingAdapterService, - MeetingQueryService meetingQueryService, - MeetingAccessService meetingAccessService, - MeetingCommandService meetingCommandService, - MeetingService meetingService, - AiTaskService aiTaskService, - PromptTemplateService promptTemplateService, - MeetingTranscriptMapper meetingTranscriptMapper, - SysUserMapper sysUserMapper, - StringRedisTemplate redisTemplate, - ObjectMapper objectMapper) { - this( - legacyMeetingAdapterService, - meetingQueryService, - meetingAccessService, - meetingCommandService, - meetingService, - aiTaskService, - promptTemplateService, - meetingTranscriptMapper, - sysUserMapper, - new RedisOnlyMeetingProgressServiceAdapter(redisTemplate, objectMapper), - objectMapper - ); - } - @Autowired public LegacyMeetingController(LegacyMeetingAdapterService legacyMeetingAdapterService, MeetingQueryService meetingQueryService, @@ -166,7 +137,7 @@ public class LegacyMeetingController { @Log(value = "新增兼容会议", type = "兼容会议管理") public LegacyApiResponse create(@RequestBody LegacyMeetingCreateRequest request) { AndroidRequestLogHelper.logRequest(log, "兼容会议", "创建会议接口", "request", request); - MeetingVO meeting = legacyMeetingAdapterService.createMeeting(request, currentLoginUser()); + MeetingVO meeting = legacyMeetingAdapterService.createMeeting(request, buildLegacyAuthContext(), currentLoginUser()); return LegacyApiResponse.ok(new LegacyMeetingCreateResponse(meeting.getId())); } @@ -190,6 +161,7 @@ public class LegacyMeetingController { modelCode, forceReplace, audioFile, + buildLegacyAuthContext(), currentLoginUser() ); return LegacyApiResponse.ok("上传成功", null); @@ -665,4 +637,7 @@ public class LegacyMeetingController { private String resolveCreatorName(LoginUser loginUser) { return loginUser.getDisplayName() != null ? loginUser.getDisplayName() : loginUser.getUsername(); } + private AndroidAuthContext buildLegacyAuthContext() { + return new AndroidAuthContext(); + } } diff --git a/backend/src/main/java/com/imeeting/controller/biz/MeetingController.java b/backend/src/main/java/com/imeeting/controller/biz/MeetingController.java index 8cf30e5..8115e34 100644 --- a/backend/src/main/java/com/imeeting/controller/biz/MeetingController.java +++ b/backend/src/main/java/com/imeeting/controller/biz/MeetingController.java @@ -2,7 +2,6 @@ package com.imeeting.controller.biz; import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; import com.imeeting.common.MeetingConstants; -import com.imeeting.common.RedisKeys; import com.imeeting.common.SysParamKeys; import com.imeeting.dto.biz.CreateMeetingCommand; import com.imeeting.dto.biz.CreateRealtimeMeetingCommand; @@ -36,7 +35,6 @@ import com.imeeting.service.biz.PromptTemplateService; import com.imeeting.service.biz.RealtimeMeetingSessionStateService; import com.imeeting.service.biz.RealtimeMeetingSocketSessionService; import com.imeeting.service.biz.impl.MeetingAudioUploadSupport; -import com.imeeting.service.biz.impl.RedisOnlyMeetingProgressServiceAdapter; import com.unisbase.common.ApiResponse; import com.unisbase.common.annotation.Log; import com.unisbase.dto.PageResult; @@ -46,7 +44,6 @@ import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.validation.Valid; import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.data.redis.core.StringRedisTemplate; import org.springframework.http.HttpHeaders; import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; @@ -88,7 +85,6 @@ public class MeetingController { private final MeetingProgressService meetingProgressService; private final SysParamService sysParamService; private final AiTaskService aiTaskService; - private AiTaskService compatibilityAiTaskService; @Autowired public MeetingController(MeetingQueryService meetingQueryService, @@ -117,35 +113,6 @@ public class MeetingController { this.aiTaskService = aiTaskService; } - public MeetingController(MeetingQueryService meetingQueryService, - MeetingCommandService meetingCommandService, - MeetingAccessService meetingAccessService, - MeetingExportService meetingExportService, - MeetingTranscriptFileService meetingTranscriptFileService, - PromptTemplateService promptTemplateService, - RealtimeMeetingSocketSessionService realtimeMeetingSocketSessionService, - RealtimeMeetingSessionStateService realtimeMeetingSessionStateService, - AiTaskService unusedAiTaskService, - MeetingAudioUploadSupport meetingAudioUploadSupport, - StringRedisTemplate redisTemplate, - SysParamService sysParamService) { - this( - meetingQueryService, - meetingCommandService, - meetingAccessService, - meetingExportService, - meetingTranscriptFileService, - promptTemplateService, - realtimeMeetingSocketSessionService, - realtimeMeetingSessionStateService, - meetingAudioUploadSupport, - new RedisOnlyMeetingProgressServiceAdapter(redisTemplate, new com.fasterxml.jackson.databind.ObjectMapper()), - sysParamService, - unusedAiTaskService - ); - this.compatibilityAiTaskService = unusedAiTaskService; - } - @Operation(summary = "查询会议处理进度") @GetMapping("/{id}/progress") @PreAuthorize("isAuthenticated()") @@ -154,8 +121,8 @@ public class MeetingController { Meeting meeting = meetingAccessService.requireMeeting(id); meetingAccessService.assertCanViewMeeting(meeting, loginUser); Map progress = meetingProgressService.getProgressMap(id); - if (compatibilityAiTaskService != null && "Waiting...".equals(progress.get("message"))) { - AiTask asrTask = compatibilityAiTaskService.getOne(new LambdaQueryWrapper() + if ("Waiting...".equals(progress.get("message"))) { + AiTask asrTask = aiTaskService.getOne(new LambdaQueryWrapper() .eq(AiTask::getMeetingId, id) .eq(AiTask::getTaskType, "ASR") .orderByDesc(AiTask::getId) @@ -188,8 +155,8 @@ public class MeetingController { Meeting meeting = meetingAccessService.requireMeeting(id); meetingAccessService.assertCanViewMeeting(meeting, loginUser); Map progress = meetingProgressService.getProgressMap(id); - if (compatibilityAiTaskService != null && "Waiting...".equals(progress.get("message"))) { - AiTask asrTask = compatibilityAiTaskService.getOne(new LambdaQueryWrapper() + if ("Waiting...".equals(progress.get("message"))) { + AiTask asrTask = aiTaskService.getOne(new LambdaQueryWrapper() .eq(AiTask::getMeetingId, id) .eq(AiTask::getTaskType, "ASR") .orderByDesc(AiTask::getId) diff --git a/backend/src/main/java/com/imeeting/controller/biz/PublicDeviceMeetingController.java b/backend/src/main/java/com/imeeting/controller/biz/PublicDeviceMeetingController.java new file mode 100644 index 0000000..a28a2f4 --- /dev/null +++ b/backend/src/main/java/com/imeeting/controller/biz/PublicDeviceMeetingController.java @@ -0,0 +1,98 @@ +package com.imeeting.controller.biz; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.imeeting.dto.android.AndroidPublicMeetingSessionState; +import com.imeeting.dto.biz.MeetingVO; +import com.imeeting.dto.biz.PublicDeviceMeetingCreateCommand; +import com.imeeting.entity.biz.Meeting; +import com.imeeting.service.android.AndroidMeetingPushService; +import com.imeeting.service.android.AndroidPublicMeetingSessionService; +import com.imeeting.service.biz.MeetingCommandService; +import com.imeeting.service.biz.MeetingQueryService; +import com.imeeting.service.biz.MeetingService; +import com.unisbase.common.ApiResponse; +import com.unisbase.security.LoginUser; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@Tag(name = "公有设备扫码建会接口") +@RestController +@RequestMapping("/api/biz/public-device-meetings") +public class PublicDeviceMeetingController { + private final AndroidPublicMeetingSessionService androidPublicMeetingSessionService; + private final MeetingCommandService meetingCommandService; + private final MeetingQueryService meetingQueryService; + private final AndroidMeetingPushService androidMeetingPushService; + private final MeetingService meetingService; + + public PublicDeviceMeetingController(AndroidPublicMeetingSessionService androidPublicMeetingSessionService, + MeetingCommandService meetingCommandService, + MeetingQueryService meetingQueryService, + AndroidMeetingPushService androidMeetingPushService, + MeetingService meetingService) { + this.androidPublicMeetingSessionService = androidPublicMeetingSessionService; + this.meetingCommandService = meetingCommandService; + this.meetingQueryService = meetingQueryService; + this.androidMeetingPushService = androidMeetingPushService; + this.meetingService = meetingService; + } + + @Operation(summary = "H5扫码为公有设备创建会议") + @io.swagger.v3.oas.annotations.responses.ApiResponses({ + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "200", + description = "返回已创建的会议详情;若设备已有未结束会议,则返回已有会议", + content = @Content(schema = @Schema(implementation = MeetingVO.class)) + ) + }) + @PostMapping("/sessions/{sessionId}/create") + public ApiResponse createBySession(@PathVariable String sessionId, + @Valid @RequestBody PublicDeviceMeetingCreateCommand command) { + LoginUser loginUser = currentLoginUser(); + AndroidPublicMeetingSessionState session = androidPublicMeetingSessionService.require(sessionId); + Meeting existingMeeting = findLatestUnfinishedMeetingByDevice(session.getDeviceId()); + if (existingMeeting != null) { + return new ApiResponse<>("409", "设备端已有会议", meetingQueryService.getDetailIgnoreTenant(existingMeeting.getId())); + } + String creatorName = loginUser.getDisplayName() != null ? loginUser.getDisplayName() : loginUser.getUsername(); + MeetingVO vo = meetingCommandService.createPublicDeviceMeeting( + command, + loginUser.getTenantId(), + loginUser.getUserId(), + creatorName, + session.getDeviceId() + ); + androidMeetingPushService.pushPendingMeetingToDevice(vo.getId(), session.getDeviceId()); + androidPublicMeetingSessionService.clear(sessionId); + return ApiResponse.ok(vo); + } + + private Meeting findLatestUnfinishedMeetingByDevice(String deviceId) { + if (deviceId == null || deviceId.isBlank()) { + return null; + } + return meetingService.getOne(new LambdaQueryWrapper() + .eq(Meeting::getSourceDeviceCode, deviceId) + .in(Meeting::getStatus, 0, 1, 2) + .orderByDesc(Meeting::getId) + .last("LIMIT 1")); + } + + private LoginUser currentLoginUser() { + Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); + if (authentication == null || !(authentication.getPrincipal() instanceof LoginUser loginUser)) { + throw new RuntimeException("未获取到登录用户"); + } + return loginUser; + } +} diff --git a/backend/src/main/java/com/imeeting/dto/android/AndroidChunkUploadSessionState.java b/backend/src/main/java/com/imeeting/dto/android/AndroidChunkUploadSessionState.java new file mode 100644 index 0000000..5960d5a --- /dev/null +++ b/backend/src/main/java/com/imeeting/dto/android/AndroidChunkUploadSessionState.java @@ -0,0 +1,17 @@ +package com.imeeting.dto.android; + +import lombok.Data; + +import java.util.Set; +import java.util.TreeSet; + +@Data +public class AndroidChunkUploadSessionState { + private String uploadSessionId; + private Long meetingId; + private String deviceId; + private Integer totalChunks; + private String fileName; + private String contentType; + private Set receivedChunks = new TreeSet<>(); +} diff --git a/backend/src/main/java/com/imeeting/dto/android/AndroidMeetingConfigVo.java b/backend/src/main/java/com/imeeting/dto/android/AndroidMeetingConfigVo.java index 28846ed..c02723e 100644 --- a/backend/src/main/java/com/imeeting/dto/android/AndroidMeetingConfigVo.java +++ b/backend/src/main/java/com/imeeting/dto/android/AndroidMeetingConfigVo.java @@ -25,10 +25,20 @@ import java.util.List; */ @Data public class AndroidMeetingConfigVo { + @io.swagger.v3.oas.annotations.media.Schema(description = "可用模型列表") private List modelsList; + @io.swagger.v3.oas.annotations.media.Schema(description = "可用模板列表") private List templateList; + @io.swagger.v3.oas.annotations.media.Schema(description = "总结详细程度字典项") private List summaryDegreeOfDetail; + @io.swagger.v3.oas.annotations.media.Schema(description = "允许暂停最大时长,单位秒") private Integer maxPauseDuration; + @io.swagger.v3.oas.annotations.media.Schema(description = "最大会议时长,单位分钟") private Integer maxMeetingDuration; + @io.swagger.v3.oas.annotations.media.Schema(description = "允许的最大丢包率") private BigDecimal packetLossRate; + @io.swagger.v3.oas.annotations.media.Schema(description = "是否启用音频分片上传") + private Boolean chunkUploadEnabled; + @io.swagger.v3.oas.annotations.media.Schema(description = "分片时长,单位秒") + private Integer chunkDurationSeconds; } diff --git a/backend/src/main/java/com/imeeting/dto/android/AndroidPendingMeetingDraft.java b/backend/src/main/java/com/imeeting/dto/android/AndroidPendingMeetingDraft.java new file mode 100644 index 0000000..efdb8ef --- /dev/null +++ b/backend/src/main/java/com/imeeting/dto/android/AndroidPendingMeetingDraft.java @@ -0,0 +1,13 @@ +package com.imeeting.dto.android; + +import com.imeeting.dto.biz.PublicDeviceMeetingCreateCommand; +import lombok.Data; + +@Data +public class AndroidPendingMeetingDraft { + private Long meetingId; + private String deviceId; + private Long tenantId; + private Long creatorId; + private PublicDeviceMeetingCreateCommand command; +} diff --git a/backend/src/main/java/com/imeeting/dto/android/AndroidPublicMeetingSessionRequest.java b/backend/src/main/java/com/imeeting/dto/android/AndroidPublicMeetingSessionRequest.java new file mode 100644 index 0000000..8fa6f70 --- /dev/null +++ b/backend/src/main/java/com/imeeting/dto/android/AndroidPublicMeetingSessionRequest.java @@ -0,0 +1,11 @@ +package com.imeeting.dto.android; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +@Data +@Schema(description = "公有设备发会准备请求") +public class AndroidPublicMeetingSessionRequest { + @Schema(description = "设备端展示用途的会话标题,可为空") + private String title; +} diff --git a/backend/src/main/java/com/imeeting/dto/android/AndroidPublicMeetingSessionState.java b/backend/src/main/java/com/imeeting/dto/android/AndroidPublicMeetingSessionState.java new file mode 100644 index 0000000..f331093 --- /dev/null +++ b/backend/src/main/java/com/imeeting/dto/android/AndroidPublicMeetingSessionState.java @@ -0,0 +1,14 @@ +package com.imeeting.dto.android; + +import lombok.Data; + +import java.time.LocalDateTime; + +@Data +public class AndroidPublicMeetingSessionState { + private String sessionId; + private String sessionToken; + private String deviceId; + private String title; + private LocalDateTime expireAt; +} diff --git a/backend/src/main/java/com/imeeting/dto/android/AndroidPublicMeetingSessionVO.java b/backend/src/main/java/com/imeeting/dto/android/AndroidPublicMeetingSessionVO.java new file mode 100644 index 0000000..6644219 --- /dev/null +++ b/backend/src/main/java/com/imeeting/dto/android/AndroidPublicMeetingSessionVO.java @@ -0,0 +1,22 @@ +package com.imeeting.dto.android; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import java.time.LocalDateTime; + +@Data +@Schema(description = "公有设备发会会话") +public class AndroidPublicMeetingSessionVO { + @Schema(description = "发会会话ID") + private String sessionId; + + @Schema(description = "用于H5扫码建会的token") + private String sessionToken; + + @Schema(description = "设备ID") + private String deviceId; + + @Schema(description = "会话过期时间") + private LocalDateTime expireAt; +} diff --git a/backend/src/main/java/com/imeeting/dto/biz/MeetingVO.java b/backend/src/main/java/com/imeeting/dto/biz/MeetingVO.java index fd44af3..5a1cf7e 100644 --- a/backend/src/main/java/com/imeeting/dto/biz/MeetingVO.java +++ b/backend/src/main/java/com/imeeting/dto/biz/MeetingVO.java @@ -43,6 +43,10 @@ public class MeetingVO { private String meetingType; @Schema(description = "会议来源") private String meetingSource; + @Schema(description = "来源设备编码") + private String sourceDeviceCode; + @Schema(description = "来源设备模式") + private String sourceDeviceMode; @Schema(description = "总结详细程度") private String summaryDetailLevel; @Schema(description = "音频保存状态") diff --git a/backend/src/main/java/com/imeeting/dto/biz/PublicDeviceMeetingCreateCommand.java b/backend/src/main/java/com/imeeting/dto/biz/PublicDeviceMeetingCreateCommand.java new file mode 100644 index 0000000..d577780 --- /dev/null +++ b/backend/src/main/java/com/imeeting/dto/biz/PublicDeviceMeetingCreateCommand.java @@ -0,0 +1,75 @@ +package com.imeeting.dto.biz; + +import com.fasterxml.jackson.annotation.JsonFormat; +import com.imeeting.common.MeetingConstants; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; +import lombok.Data; + +import java.time.LocalDateTime; +import java.util.List; + +@Data +@Schema(description = "公有设备扫码创建会议请求") +public class PublicDeviceMeetingCreateCommand { + @NotBlank(message = "标题不能为空") + @Schema(description = "会议标题") + private String title; + + @NotNull(message = "meetingTime不能为空") + @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") + @Schema(description = "会议时间") + private LocalDateTime meetingTime; + + @Schema(description = "参会人ID串,逗号分隔") + private String participants; + @Schema(description = "会议标签") + private String tags; + @Schema(description = "主持人用户ID") + private Long hostUserId; + @Schema(description = "主持人名称") + private String hostName; + + @NotNull(message = "asrModelId不能为空") + @Schema(description = "ASR模型ID") + private Long asrModelId; + + @NotNull(message = "summaryModelId不能为空") + @Schema(description = "总结模型ID") + private Long summaryModelId; + + @Schema(description = "章节模型ID,可为空,默认复用总结模型") + private Long chapterModelId; + + @NotNull(message = "promptId不能为空") + @Schema(description = "模板ID") + private Long promptId; + + @Schema(description = "热词组ID") + private Long hotWordGroupId; + + @Size(max = 2000, message = "userPrompt length must be <= 2000") + @Schema(description = "用户补充提示词") + private String userPrompt; + + @Schema( + description = "总结详细程度:DETAILED=详细,STANDARD=标准,BRIEF=简洁", + allowableValues = { + MeetingConstants.SUMMARY_DETAIL_DETAILED, + MeetingConstants.SUMMARY_DETAIL_STANDARD, + MeetingConstants.SUMMARY_DETAIL_BRIEF + } + ) + private String summaryDetailLevel; + + @Schema(description = "是否启用说话人分离") + private Integer useSpkId; + @Schema(description = "是否启用文本规整") + private Boolean enableTextRefine; + @Schema(description = "热词列表") + private List hotWords; + @Schema(description = "会议访问密码") + private String accessPassword; +} diff --git a/backend/src/main/java/com/imeeting/entity/biz/AndroidPushMessage.java b/backend/src/main/java/com/imeeting/entity/biz/AndroidPushMessage.java new file mode 100644 index 0000000..b75c478 --- /dev/null +++ b/backend/src/main/java/com/imeeting/entity/biz/AndroidPushMessage.java @@ -0,0 +1,42 @@ +package com.imeeting.entity.biz; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import com.unisbase.entity.BaseEntity; +import lombok.Data; +import lombok.EqualsAndHashCode; + +import java.time.LocalDateTime; + +@Data +@EqualsAndHashCode(callSuper = true) +@TableName("biz_android_push_message") +public class AndroidPushMessage extends BaseEntity { + @TableId(value = "id", type = IdType.AUTO) + private Long id; + + private Long meetingId; + + private String deviceCode; + + private String messageId; + + private String messageType; + + private String payload; + + private Integer needAck; + + private Integer acked; + + private String pushStatus; + + private Integer pushCount; + + private LocalDateTime lastPushAt; + + private LocalDateTime ackAt; + + private LocalDateTime expireAt; +} diff --git a/backend/src/main/java/com/imeeting/entity/biz/Meeting.java b/backend/src/main/java/com/imeeting/entity/biz/Meeting.java index 4415341..c48b79c 100644 --- a/backend/src/main/java/com/imeeting/entity/biz/Meeting.java +++ b/backend/src/main/java/com/imeeting/entity/biz/Meeting.java @@ -40,6 +40,13 @@ public class Meeting extends BaseEntity { @Schema(description = "会议来源") private String meetingSource; + + @Schema(description = "来源设备编码") + private String sourceDeviceCode; + + @Schema(description = "来源设备模式") + private String sourceDeviceMode; + @Schema(description = "总结详细程度") private String summaryDetailLevel; diff --git a/backend/src/main/java/com/imeeting/enums/MeetingPushTypeEnum.java b/backend/src/main/java/com/imeeting/enums/MeetingPushTypeEnum.java index 6438775..afef3fa 100644 --- a/backend/src/main/java/com/imeeting/enums/MeetingPushTypeEnum.java +++ b/backend/src/main/java/com/imeeting/enums/MeetingPushTypeEnum.java @@ -4,16 +4,14 @@ import lombok.Getter; @Getter public enum MeetingPushTypeEnum { - MEETING_COMPLETED("MEETING_COMPLETED","会议完成通知"),; + MEETING_PENDING("MEETING_PENDING", "待开始会议通知"), + MEETING_COMPLETED("MEETING_COMPLETED", "会议完成通知"); + private final String code; private final String desc; - MeetingPushTypeEnum(String code,String desc){ - this.code=code; - this.desc=desc; - }; - - - - + MeetingPushTypeEnum(String code, String desc) { + this.code = code; + this.desc = desc; + } } diff --git a/backend/src/main/java/com/imeeting/grpc/push/AndroidPushGrpcService.java b/backend/src/main/java/com/imeeting/grpc/push/AndroidPushGrpcService.java index 4812eb0..4893475 100644 --- a/backend/src/main/java/com/imeeting/grpc/push/AndroidPushGrpcService.java +++ b/backend/src/main/java/com/imeeting/grpc/push/AndroidPushGrpcService.java @@ -3,6 +3,7 @@ package com.imeeting.grpc.push; import com.imeeting.dto.android.AndroidAuthContext; import com.imeeting.dto.android.AndroidDeviceSessionState; import com.imeeting.service.android.AndroidAuthService; +import com.imeeting.service.android.AndroidPushMessageService; import com.imeeting.service.android.AndroidDeviceSessionService; import com.imeeting.service.android.AndroidGatewayPushService; import com.imeeting.service.biz.DeviceOnlineManagementService; @@ -26,6 +27,7 @@ public class AndroidPushGrpcService extends PushServiceGrpc.PushServiceImplBase private final AndroidAuthService androidAuthService; private final AndroidDeviceSessionService androidDeviceSessionService; private final AndroidGatewayPushService androidGatewayPushService; + private final AndroidPushMessageService androidPushMessageService; private final DeviceOnlineManagementService deviceOnlineManagementService; @Override @@ -203,6 +205,7 @@ public class AndroidPushGrpcService extends PushServiceGrpc.PushServiceImplBase deviceId, appVersion, platform)); + androidPushMessageService.ack(request.getMessageId(), deviceId); } private boolean validateConnected() { diff --git a/backend/src/main/java/com/imeeting/listener/MeetingTaskRecoveryListener.java b/backend/src/main/java/com/imeeting/listener/MeetingTaskRecoveryListener.java index 31c247f..d078214 100644 --- a/backend/src/main/java/com/imeeting/listener/MeetingTaskRecoveryListener.java +++ b/backend/src/main/java/com/imeeting/listener/MeetingTaskRecoveryListener.java @@ -1,17 +1,16 @@ package com.imeeting.listener; import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; -import com.imeeting.common.RedisKeys; import com.imeeting.entity.biz.Meeting; import com.imeeting.mapper.biz.MeetingMapper; import com.imeeting.service.biz.AiTaskService; -import com.imeeting.support.RedisValueSupport; import com.imeeting.support.TaskSecurityContextRunner; +import com.imeeting.support.redis.MeetingAsrPermitCache; +import com.imeeting.support.redis.MeetingLockCache; +import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.boot.ApplicationArguments; import org.springframework.boot.ApplicationRunner; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.data.redis.core.StringRedisTemplate; import org.springframework.stereotype.Component; import java.util.List; @@ -19,32 +18,14 @@ import java.util.concurrent.TimeUnit; @Component @Slf4j +@RequiredArgsConstructor public class MeetingTaskRecoveryListener implements ApplicationRunner { private final MeetingMapper meetingMapper; private final AiTaskService aiTaskService; - private final RedisValueSupport redisValueSupport; + private final MeetingLockCache meetingLockCache; + private final MeetingAsrPermitCache meetingAsrPermitCache; private final TaskSecurityContextRunner taskSecurityContextRunner; - private StringRedisTemplate compatibilityRedisTemplate; - - @Autowired - public MeetingTaskRecoveryListener(MeetingMapper meetingMapper, - AiTaskService aiTaskService, - RedisValueSupport redisValueSupport, - TaskSecurityContextRunner taskSecurityContextRunner) { - this.meetingMapper = meetingMapper; - this.aiTaskService = aiTaskService; - this.redisValueSupport = redisValueSupport; - this.taskSecurityContextRunner = taskSecurityContextRunner; - } - - public MeetingTaskRecoveryListener(MeetingMapper meetingMapper, - AiTaskService aiTaskService, - StringRedisTemplate redisTemplate, - TaskSecurityContextRunner taskSecurityContextRunner) { - this(meetingMapper, aiTaskService, new RedisValueSupport(redisTemplate, new com.fasterxml.jackson.databind.ObjectMapper()), taskSecurityContextRunner); - this.compatibilityRedisTemplate = redisTemplate; - } @Override public void run(ApplicationArguments args) { @@ -62,9 +43,8 @@ public class MeetingTaskRecoveryListener implements ApplicationRunner { for (Meeting meeting : pendingMeetings) { try { - redisValueSupport.delete(RedisKeys.meetingPollingLockKey(meeting.getId())); - redisValueSupport.delete(RedisKeys.meetingSummaryLockKey(meeting.getId())); - clearLegacyRedisState(meeting.getId()); + meetingLockCache.clearDispatchLocks(meeting.getId()); + meetingAsrPermitCache.clearRecoveryState(meeting.getId()); if (Integer.valueOf(1).equals(meeting.getStatus())) { log.info("Recovering ASR task for meeting {}", meeting.getId()); @@ -82,15 +62,4 @@ public class MeetingTaskRecoveryListener implements ApplicationRunner { log.info("Meeting task recovery processed {} meetings.", pendingMeetings.size()); } - - private void clearLegacyRedisState(Long meetingId) { - if (compatibilityRedisTemplate == null || meetingId == null) { - return; - } - compatibilityRedisTemplate.delete(List.of( - RedisKeys.meetingAsrPermitSyncLockKey(), - RedisKeys.meetingAsrRefillLockKey() - )); - compatibilityRedisTemplate.opsForSet().remove(RedisKeys.meetingAsrPermitSetKey(), String.valueOf(meetingId)); - } } diff --git a/backend/src/main/java/com/imeeting/mapper/biz/AndroidPushMessageMapper.java b/backend/src/main/java/com/imeeting/mapper/biz/AndroidPushMessageMapper.java new file mode 100644 index 0000000..06d21f7 --- /dev/null +++ b/backend/src/main/java/com/imeeting/mapper/biz/AndroidPushMessageMapper.java @@ -0,0 +1,9 @@ +package com.imeeting.mapper.biz; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.imeeting.entity.biz.AndroidPushMessage; +import org.apache.ibatis.annotations.Mapper; + +@Mapper +public interface AndroidPushMessageMapper extends BaseMapper { +} diff --git a/backend/src/main/java/com/imeeting/service/android/AndroidChunkUploadService.java b/backend/src/main/java/com/imeeting/service/android/AndroidChunkUploadService.java new file mode 100644 index 0000000..a2089ef --- /dev/null +++ b/backend/src/main/java/com/imeeting/service/android/AndroidChunkUploadService.java @@ -0,0 +1,23 @@ +package com.imeeting.service.android; + +import com.imeeting.dto.android.AndroidAuthContext; +import com.imeeting.dto.android.legacy.LegacyUploadAudioResponse; +import org.springframework.web.multipart.MultipartFile; + +import java.io.IOException; + +public interface AndroidChunkUploadService { + void saveChunk(Long meetingId, + String uploadSessionId, + Integer chunkIndex, + Integer totalChunks, + MultipartFile chunkFile, + AndroidAuthContext authContext) throws IOException; + + LegacyUploadAudioResponse completeUpload(Long meetingId, + String uploadSessionId, + boolean forceReplace, + Long promptId, + String modelCode, + AndroidAuthContext authContext) throws IOException; +} diff --git a/backend/src/main/java/com/imeeting/service/android/AndroidDeviceBindingService.java b/backend/src/main/java/com/imeeting/service/android/AndroidDeviceBindingService.java new file mode 100644 index 0000000..d44fed8 --- /dev/null +++ b/backend/src/main/java/com/imeeting/service/android/AndroidDeviceBindingService.java @@ -0,0 +1,9 @@ +package com.imeeting.service.android; + +public interface AndroidDeviceBindingService { + void bindPrivateDevice(String deviceCode, Long tenantId, Long userId, String appVersion, String platform); + + void validatePrivateDeviceAccess(String deviceCode, Long tenantId, Long userId); + + void unbindPrivateDevice(String deviceCode); +} diff --git a/backend/src/main/java/com/imeeting/service/android/AndroidMeetingPushService.java b/backend/src/main/java/com/imeeting/service/android/AndroidMeetingPushService.java index 35d1321..df3dd9c 100644 --- a/backend/src/main/java/com/imeeting/service/android/AndroidMeetingPushService.java +++ b/backend/src/main/java/com/imeeting/service/android/AndroidMeetingPushService.java @@ -1,10 +1,7 @@ package com.imeeting.service.android; public interface AndroidMeetingPushService { + void pushPendingMeetingToDevice(Long meetingId, String deviceId); void pushMeetingCompleted(Long meetingId); - - - - } diff --git a/backend/src/main/java/com/imeeting/service/android/AndroidPendingMeetingDraftService.java b/backend/src/main/java/com/imeeting/service/android/AndroidPendingMeetingDraftService.java new file mode 100644 index 0000000..b6f519c --- /dev/null +++ b/backend/src/main/java/com/imeeting/service/android/AndroidPendingMeetingDraftService.java @@ -0,0 +1,11 @@ +package com.imeeting.service.android; + +import com.imeeting.dto.android.AndroidPendingMeetingDraft; + +public interface AndroidPendingMeetingDraftService { + void save(AndroidPendingMeetingDraft draft); + + AndroidPendingMeetingDraft get(Long meetingId); + + void clear(Long meetingId); +} diff --git a/backend/src/main/java/com/imeeting/service/android/AndroidPublicMeetingSessionService.java b/backend/src/main/java/com/imeeting/service/android/AndroidPublicMeetingSessionService.java new file mode 100644 index 0000000..2d0c1d9 --- /dev/null +++ b/backend/src/main/java/com/imeeting/service/android/AndroidPublicMeetingSessionService.java @@ -0,0 +1,12 @@ +package com.imeeting.service.android; + +import com.imeeting.dto.android.AndroidPublicMeetingSessionState; +import com.imeeting.dto.android.AndroidPublicMeetingSessionVO; + +public interface AndroidPublicMeetingSessionService { + AndroidPublicMeetingSessionVO create(String deviceId, String title); + + AndroidPublicMeetingSessionState require(String sessionId); + + void clear(String sessionId); +} diff --git a/backend/src/main/java/com/imeeting/service/android/AndroidPushMessageService.java b/backend/src/main/java/com/imeeting/service/android/AndroidPushMessageService.java new file mode 100644 index 0000000..aa51eed --- /dev/null +++ b/backend/src/main/java/com/imeeting/service/android/AndroidPushMessageService.java @@ -0,0 +1,20 @@ +package com.imeeting.service.android; + +import com.imeeting.entity.biz.AndroidPushMessage; +import com.imeeting.grpc.push.PushMessage; + +import java.util.List; + +public interface AndroidPushMessageService { + AndroidPushMessage saveMeetingPushMessage(Long tenantId, Long meetingId, String deviceCode, PushMessage pushMessage, long expireAfterMinutes); + + boolean ack(String messageId, String deviceCode); + + List listPendingMeetingPushMessages(); + + void markPushed(Long id); + + void markExpired(Long id); + + void markCancelledByMeeting(Long meetingId); +} diff --git a/backend/src/main/java/com/imeeting/service/android/impl/AndroidAuthServiceImpl.java b/backend/src/main/java/com/imeeting/service/android/impl/AndroidAuthServiceImpl.java index 3d9cb2c..063dd0f 100644 --- a/backend/src/main/java/com/imeeting/service/android/impl/AndroidAuthServiceImpl.java +++ b/backend/src/main/java/com/imeeting/service/android/impl/AndroidAuthServiceImpl.java @@ -5,6 +5,7 @@ import com.imeeting.dto.android.AndroidAuthContext; import com.imeeting.entity.biz.DeviceInfoEntity; import com.imeeting.mapper.DeviceInfoMapper; import com.imeeting.service.android.AndroidAuthService; +import com.imeeting.service.android.AndroidDeviceBindingService; import com.unisbase.common.exception.BusinessException; import com.unisbase.dto.InternalAuthCheckResponse; import com.unisbase.security.LoginUser; @@ -32,6 +33,7 @@ public class AndroidAuthServiceImpl implements AndroidAuthService { private final AndroidGrpcAuthProperties properties; private final TokenValidationService tokenValidationService; private final DeviceInfoMapper deviceInfoMapper; + private final AndroidDeviceBindingService androidDeviceBindingService; @Override public AndroidAuthContext authenticateGrpc(String deviceId, String appVersion, String platform) { @@ -39,7 +41,13 @@ public class AndroidAuthServiceImpl implements AndroidAuthService { throw new RuntimeException("Android gRPC push does not allow anonymous access"); } assertDeviceEnabled(deviceId); - return buildContext("NONE", true, deviceId, null, appVersion, platform, null, null, null, null); + AndroidAuthContext context = buildContext("NONE", true, deviceId, null, appVersion, platform, null, null, null, null); + DeviceInfoEntity device = deviceInfoMapper.selectByDeviceCodeIgnoreTenant(deviceId.trim()); + if (device != null) { + context.setUserId(device.getUserId()); + context.setTenantId(device.getTenantId()); + } + return context; } @Override @@ -55,6 +63,7 @@ public class AndroidAuthServiceImpl implements AndroidAuthService { log.info("[安卓接口访问]X-Android-Device-Id={},X-Android-App-Version={},X-Android-Platform={}",deviceId,appVersion,platform); assertDeviceEnabled(deviceId); if (loginUser != null) { + androidDeviceBindingService.validatePrivateDeviceAccess(deviceId, loginUser.getTenantId(), loginUser.getUserId()); return buildContext("USER_JWT", false, deviceId, appId, @@ -68,6 +77,7 @@ public class AndroidAuthServiceImpl implements AndroidAuthService { if (StringUtils.hasText(resolvedToken)) { InternalAuthCheckResponse authResult = validateToken(resolvedToken); + androidDeviceBindingService.validatePrivateDeviceAccess(deviceId, authResult.getTenantId(), authResult.getUserId()); return buildContext("USER_JWT", false, deviceId, appId, diff --git a/backend/src/main/java/com/imeeting/service/android/impl/AndroidChunkUploadServiceImpl.java b/backend/src/main/java/com/imeeting/service/android/impl/AndroidChunkUploadServiceImpl.java new file mode 100644 index 0000000..7a94941 --- /dev/null +++ b/backend/src/main/java/com/imeeting/service/android/impl/AndroidChunkUploadServiceImpl.java @@ -0,0 +1,243 @@ +package com.imeeting.service.android.impl; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.imeeting.dto.android.AndroidAuthContext; +import com.imeeting.dto.android.AndroidChunkUploadSessionState; +import com.imeeting.dto.android.legacy.LegacyUploadAudioResponse; +import com.imeeting.service.android.AndroidChunkUploadService; +import com.imeeting.service.android.legacy.LegacyMeetingAdapterService; +import com.imeeting.support.redis.AndroidChunkUploadSessionCache; +import lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; +import org.springframework.web.multipart.MultipartFile; + +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.nio.file.StandardOpenOption; +import java.util.Objects; + +import com.unisbase.security.LoginUser; + +@Service +@RequiredArgsConstructor +public class AndroidChunkUploadServiceImpl implements AndroidChunkUploadService { + private final AndroidChunkUploadSessionCache sessionCache; + private final LegacyMeetingAdapterService legacyMeetingAdapterService; + + @Value("${unisbase.app.upload-path}") + private String uploadPath; + + @Override + public void saveChunk(Long meetingId, + String uploadSessionId, + Integer chunkIndex, + Integer totalChunks, + MultipartFile chunkFile, + AndroidAuthContext authContext) throws IOException { + if (meetingId == null || uploadSessionId == null || uploadSessionId.isBlank()) { + throw new RuntimeException("uploadSessionId不能为空"); + } + if (chunkIndex == null || totalChunks == null || chunkIndex < 0 || totalChunks <= 0) { + throw new RuntimeException("分片参数无效"); + } + if (chunkFile == null || chunkFile.isEmpty()) { + throw new RuntimeException("chunk_file不能为空"); + } + AndroidChunkUploadSessionState state = getOrCreateState(meetingId, uploadSessionId, totalChunks, chunkFile, authContext); + if (!Objects.equals(state.getMeetingId(), meetingId) || !Objects.equals(state.getDeviceId(), authContext.getDeviceId())) { + throw new RuntimeException("分片上传会话与当前设备或会议不匹配"); + } + Path sessionDir = sessionDir(uploadSessionId); + Files.createDirectories(sessionDir); + Files.write(sessionDir.resolve(chunkIndex + ".part"), chunkFile.getBytes(), StandardOpenOption.CREATE, StandardOpenOption.TRUNCATE_EXISTING); + state.getReceivedChunks().add(chunkIndex); + saveState(uploadSessionId, state); + } + + @Override + public LegacyUploadAudioResponse completeUpload(Long meetingId, + String uploadSessionId, + boolean forceReplace, + Long promptId, + String modelCode, + AndroidAuthContext authContext) throws IOException { + AndroidChunkUploadSessionState state = requireState(uploadSessionId); + if (!Objects.equals(state.getMeetingId(), meetingId) || !Objects.equals(state.getDeviceId(), authContext.getDeviceId())) { + throw new RuntimeException("分片上传会话与当前设备或会议不匹配"); + } + for (int i = 0; i < state.getTotalChunks(); i++) { + if (!state.getReceivedChunks().contains(i)) { + throw new RuntimeException("分片未上传完整"); + } + } + Path mergedFile = mergeChunks(state); + try { + MultipartFile mergedMultipart = new LocalMultipartFile( + state.getFileName() == null ? "meeting-audio.bin" : state.getFileName(), + state.getContentType(), + Files.readAllBytes(mergedFile) + ); + if (authContext.isAnonymous()) { + return legacyMeetingAdapterService.uploadAndTriggerOfflineProcessForPublicDevice( + meetingId, + forceReplace, + mergedMultipart, + authContext + ); + } + LoginUser loginUser = toLoginUser(authContext); + return legacyMeetingAdapterService.uploadAndTriggerOfflineProcess( + meetingId, + promptId, + modelCode, + forceReplace, + mergedMultipart, + authContext, + loginUser + ); + } finally { + cleanup(uploadSessionId); + } + } + + private AndroidChunkUploadSessionState getOrCreateState(Long meetingId, + String uploadSessionId, + Integer totalChunks, + MultipartFile chunkFile, + AndroidAuthContext authContext) throws IOException { + AndroidChunkUploadSessionState existing = getState(uploadSessionId); + if (existing != null) { + return existing; + } + AndroidChunkUploadSessionState state = new AndroidChunkUploadSessionState(); + state.setUploadSessionId(uploadSessionId); + state.setMeetingId(meetingId); + state.setDeviceId(authContext.getDeviceId()); + state.setTotalChunks(totalChunks); + state.setFileName(chunkFile.getOriginalFilename()); + state.setContentType(chunkFile.getContentType()); + saveState(uploadSessionId, state); + return state; + } + + private Path mergeChunks(AndroidChunkUploadSessionState state) throws IOException { + Path sessionDir = sessionDir(state.getUploadSessionId()); + Path merged = sessionDir.resolve("merged.bin"); + Files.deleteIfExists(merged); + Files.createFile(merged); + for (int i = 0; i < state.getTotalChunks(); i++) { + Files.write(merged, Files.readAllBytes(sessionDir.resolve(i + ".part")), StandardOpenOption.APPEND); + } + return merged; + } + + private AndroidChunkUploadSessionState requireState(String uploadSessionId) { + AndroidChunkUploadSessionState state = getState(uploadSessionId); + if (state == null) { + throw new RuntimeException("分片上传会话不存在或已过期"); + } + return state; + } + + private AndroidChunkUploadSessionState getState(String uploadSessionId) { + return sessionCache.get(uploadSessionId); + } + + private void saveState(String uploadSessionId, AndroidChunkUploadSessionState state) { + sessionCache.save(uploadSessionId, state); + } + + private void cleanup(String uploadSessionId) throws IOException { + sessionCache.clear(uploadSessionId); + Path sessionDir = sessionDir(uploadSessionId); + if (Files.exists(sessionDir)) { + try (var paths = Files.walk(sessionDir)) { + paths.sorted((left, right) -> right.compareTo(left)).forEach(path -> { + try { + Files.deleteIfExists(path); + } catch (IOException ex) { + throw new RuntimeException(ex); + } + }); + } + } + } + + private Path sessionDir(String uploadSessionId) { + String normalizedBasePath = uploadPath.endsWith("/") || uploadPath.endsWith("\\") ? uploadPath : uploadPath + "/"; + return Paths.get(normalizedBasePath, "android-chunks", uploadSessionId); + } + + private LoginUser toLoginUser(AndroidAuthContext authContext) { + if (authContext == null || authContext.isAnonymous() || authContext.getUserId() == null || authContext.getTenantId() == null) { + throw new RuntimeException("安卓用户未登录或认证无效"); + } + LoginUser loginUser = new LoginUser( + authContext.getUserId(), + authContext.getTenantId(), + authContext.getUsername(), + authContext.getPlatformAdmin(), + authContext.getTenantAdmin(), + authContext.getPermissions() + ); + loginUser.setDisplayName(authContext.getDisplayName()); + return loginUser; + } + + private static final class LocalMultipartFile implements MultipartFile { + private final String originalFilename; + private final String contentType; + private final byte[] bytes; + + private LocalMultipartFile(String originalFilename, String contentType, byte[] bytes) { + this.originalFilename = originalFilename; + this.contentType = contentType; + this.bytes = bytes == null ? new byte[0] : bytes; + } + + @Override + public String getName() { + return originalFilename; + } + + @Override + public String getOriginalFilename() { + return originalFilename; + } + + @Override + public String getContentType() { + return contentType; + } + + @Override + public boolean isEmpty() { + return bytes.length == 0; + } + + @Override + public long getSize() { + return bytes.length; + } + + @Override + public byte[] getBytes() { + return bytes; + } + + @Override + public InputStream getInputStream() { + return new ByteArrayInputStream(bytes); + } + + @Override + public void transferTo(java.io.File dest) throws IOException, IllegalStateException { + Files.write(dest.toPath(), bytes); + } + } +} diff --git a/backend/src/main/java/com/imeeting/service/android/impl/AndroidDeviceBindingServiceImpl.java b/backend/src/main/java/com/imeeting/service/android/impl/AndroidDeviceBindingServiceImpl.java new file mode 100644 index 0000000..a5b9e97 --- /dev/null +++ b/backend/src/main/java/com/imeeting/service/android/impl/AndroidDeviceBindingServiceImpl.java @@ -0,0 +1,79 @@ +package com.imeeting.service.android.impl; + +import com.imeeting.mapper.DeviceInfoMapper; +import com.imeeting.entity.biz.DeviceInfoEntity; +import com.imeeting.service.android.AndroidDeviceBindingService; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.util.StringUtils; + +import java.time.LocalDateTime; +import java.util.Objects; + +@Service +@RequiredArgsConstructor +public class AndroidDeviceBindingServiceImpl implements AndroidDeviceBindingService { + private final DeviceInfoMapper deviceInfoMapper; + + @Override + public void bindPrivateDevice(String deviceCode, Long tenantId, Long userId, String appVersion, String platform) { + if (!StringUtils.hasText(deviceCode) || userId == null || tenantId == null) { + throw new RuntimeException("设备登录缺少绑定上下文"); + } + DeviceInfoEntity existing = deviceInfoMapper.selectByDeviceCodeIgnoreTenant(deviceCode.trim()); + LocalDateTime now = LocalDateTime.now(); + if (existing == null) { + DeviceInfoEntity created = new DeviceInfoEntity(); + created.setTenantId(tenantId); + created.setUserId(userId); + created.setDeviceCode(deviceCode.trim()); + created.setTerminalType(normalize(platform)); + created.setTerminalVersion(normalize(appVersion)); + created.setLastOnlineAt(now); + created.setStatus(1); + deviceInfoMapper.insert(created); + return; + } + existing.setTenantId(tenantId); + existing.setUserId(userId); + existing.setTerminalType(normalize(platform)); + existing.setTerminalVersion(normalize(appVersion)); + existing.setLastOnlineAt(now); + deviceInfoMapper.updateConnectionInfoByIdIgnoreTenant(existing); + } + + @Override + public void validatePrivateDeviceAccess(String deviceCode, Long tenantId, Long userId) { + if (!StringUtils.hasText(deviceCode) || userId == null || tenantId == null) { + throw new RuntimeException("设备登录态无效"); + } + DeviceInfoEntity existing = deviceInfoMapper.selectByDeviceCodeIgnoreTenant(deviceCode.trim()); + if (existing == null || existing.getUserId() == null || existing.getTenantId() == null) { + throw new RuntimeException("设备未登录,请先完成设备登录"); + } + if (!Objects.equals(existing.getUserId(), userId) || !Objects.equals(existing.getTenantId(), tenantId)) { + throw new RuntimeException("当前设备已被其他用户占用,请使用当前登录用户或重新登录占用设备"); + } + } + + @Override + public void unbindPrivateDevice(String deviceCode) { + if (!StringUtils.hasText(deviceCode)) { + return; + } + DeviceInfoEntity existing = deviceInfoMapper.selectByDeviceCodeIgnoreTenant(deviceCode.trim()); + if (existing == null) { + return; + } + existing.setUserId(null); + existing.setTenantId(null); + deviceInfoMapper.updateConnectionInfoByIdIgnoreTenant(existing); + } + + private String normalize(String value) { + if (!StringUtils.hasText(value)) { + return null; + } + return value.trim().toLowerCase(); + } +} diff --git a/backend/src/main/java/com/imeeting/service/android/impl/AndroidDeviceSessionServiceImpl.java b/backend/src/main/java/com/imeeting/service/android/impl/AndroidDeviceSessionServiceImpl.java index 55aec55..0e6e194 100644 --- a/backend/src/main/java/com/imeeting/service/android/impl/AndroidDeviceSessionServiceImpl.java +++ b/backend/src/main/java/com/imeeting/service/android/impl/AndroidDeviceSessionServiceImpl.java @@ -1,14 +1,12 @@ package com.imeeting.service.android.impl; -import com.fasterxml.jackson.databind.ObjectMapper; -import com.imeeting.common.RedisKeys; import com.imeeting.config.grpc.GrpcServerProperties; import com.imeeting.dto.android.AndroidAuthContext; import com.imeeting.dto.android.AndroidDeviceSessionState; import com.imeeting.service.android.AndroidDeviceSessionService; +import com.imeeting.support.redis.AndroidDeviceSessionCache; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; -import org.springframework.data.redis.core.StringRedisTemplate; import org.springframework.stereotype.Service; import java.time.Duration; @@ -24,8 +22,7 @@ public class AndroidDeviceSessionServiceImpl implements AndroidDeviceSessionServ private static final DateTimeFormatter LOG_TIME_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"); - private final StringRedisTemplate redisTemplate; - private final ObjectMapper objectMapper; + private final AndroidDeviceSessionCache sessionCache; private final GrpcServerProperties grpcServerProperties; @Override @@ -61,63 +58,37 @@ public class AndroidDeviceSessionServiceImpl implements AndroidDeviceSessionServ @Override public AndroidDeviceSessionState getByConnectionId(String connectionId) { - String raw = redisTemplate.opsForValue().get(RedisKeys.androidDeviceConnectionKey(connectionId)); - if (raw == null || raw.isBlank()) { - return null; - } - try { - return objectMapper.readValue(raw, AndroidDeviceSessionState.class); - } catch (Exception ex) { - log.warn("Failed to read android device session, connectionId={}", connectionId, ex); - return null; - } + return sessionCache.getByConnectionId(connectionId); } @Override public AndroidDeviceSessionState getByDeviceId(String deviceId) { - String raw = redisTemplate.opsForValue().get(RedisKeys.androidDeviceOnlineKey(deviceId)); - if (raw == null || raw.isBlank()) { - return null; - } - try { - return objectMapper.readValue(raw, AndroidDeviceSessionState.class); - } catch (Exception ex) { - log.warn("Failed to read android device online state, deviceId={}", deviceId, ex); - return null; - } + return sessionCache.getByDeviceId(deviceId); } @Override public String getActiveConnectionId(String deviceId) { - String value = redisTemplate.opsForValue().get(RedisKeys.androidDeviceActiveConnectionKey(deviceId)); - return value == null || value.isBlank() ? null : value; + return sessionCache.getActiveConnectionId(deviceId); } @Override public void updateTopics(String deviceId, List topics) { - try { - redisTemplate.opsForValue().set( - RedisKeys.androidDeviceTopicsKey(deviceId), - objectMapper.writeValueAsString(topics == null ? List.of() : topics) - ); - } catch (Exception ex) { - throw new RuntimeException("Failed to update android device topics", ex); - } + sessionCache.saveTopics(deviceId, topics); } @Override public void closeSession(String connectionId) { AndroidDeviceSessionState state = getByConnectionId(connectionId); if (state == null) { - redisTemplate.delete(RedisKeys.androidDeviceConnectionKey(connectionId)); + sessionCache.deleteConnection(connectionId); return; } String activeConn = getActiveConnectionId(state.getDeviceId()); if (connectionId.equals(activeConn)) { - redisTemplate.delete(RedisKeys.androidDeviceActiveConnectionKey(state.getDeviceId())); - redisTemplate.delete(RedisKeys.androidDeviceOnlineKey(state.getDeviceId())); + sessionCache.deleteActiveConnection(state.getDeviceId()); + sessionCache.deleteOnlineState(state.getDeviceId()); } - redisTemplate.delete(RedisKeys.androidDeviceConnectionKey(connectionId)); + sessionCache.deleteConnection(connectionId); log.info(buildLog("gRPC会话关闭", "关闭Android设备会话,连接ID=" + connectionId, state.getDeviceId(), @@ -127,14 +98,7 @@ public class AndroidDeviceSessionServiceImpl implements AndroidDeviceSessionServ private void writeState(AndroidDeviceSessionState state) { Duration ttl = Duration.ofSeconds(grpcServerProperties.getGateway().getHeartbeatTimeoutSeconds()); - try { - String json = objectMapper.writeValueAsString(state); - redisTemplate.opsForValue().set(RedisKeys.androidDeviceOnlineKey(state.getDeviceId()), json, ttl); - redisTemplate.opsForValue().set(RedisKeys.androidDeviceActiveConnectionKey(state.getDeviceId()), state.getConnectionId(), ttl); - redisTemplate.opsForValue().set(RedisKeys.androidDeviceConnectionKey(state.getConnectionId()), json, ttl); - } catch (Exception ex) { - throw new RuntimeException("Failed to store android device session", ex); - } + sessionCache.saveState(state, ttl); } private String nonBlank(String value, String defaultValue) { diff --git a/backend/src/main/java/com/imeeting/service/android/impl/AndroidMeetingPushServiceImpl.java b/backend/src/main/java/com/imeeting/service/android/impl/AndroidMeetingPushServiceImpl.java index 557e503..cdfc44f 100644 --- a/backend/src/main/java/com/imeeting/service/android/impl/AndroidMeetingPushServiceImpl.java +++ b/backend/src/main/java/com/imeeting/service/android/impl/AndroidMeetingPushServiceImpl.java @@ -1,19 +1,17 @@ package com.imeeting.service.android.impl; -import cn.hutool.json.JSON; import cn.hutool.json.JSONUtil; import com.imeeting.dto.biz.MeetingVO; -import com.imeeting.entity.biz.Meeting; import com.imeeting.enums.MeetingPushTypeEnum; import com.imeeting.grpc.push.PushMessage; - import com.imeeting.service.android.AndroidGatewayPushService; import com.imeeting.service.android.AndroidMeetingPushService; +import com.imeeting.service.android.AndroidPushMessageService; import com.imeeting.service.biz.MeetingQueryService; -import com.imeeting.service.biz.MeetingService; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; import org.springframework.context.annotation.Lazy; import org.springframework.stereotype.Service; @@ -25,54 +23,102 @@ import java.util.UUID; @Slf4j @Service - public class AndroidMeetingPushServiceImpl implements AndroidMeetingPushService { - - private static final DateTimeFormatter TITLE_TIME_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm"); - + private static final DateTimeFormatter TITLE_TIME_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm"); + @Autowired + @Lazy + private MeetingQueryService meetingQueryService; @Autowired - @Lazy - private MeetingQueryService meetingService; + private AndroidGatewayPushService androidGatewayPushService; @Autowired - private AndroidGatewayPushService androidGatewayPushService; + private AndroidPushMessageService androidPushMessageService; - @Override - public void pushMeetingCompleted(Long meetingId) { - if (meetingId == null) { - return; + @Value("${imeeting.android.push.pending-expire-minutes:30}") + private long pendingExpireMinutes; + + @Override + public void pushPendingMeetingToDevice(Long meetingId, String deviceId) { + if (meetingId == null || deviceId == null || deviceId.isBlank()) { + return; + } + MeetingVO meeting = meetingQueryService.getDetailIgnoreTenant(meetingId); + if (meeting == null) { + return; + } + PushMessage message = PushMessage.newBuilder() + .setMessageId("meeting_pending:" + meetingId + ":" + UUID.randomUUID()) + .setTimestamp(System.currentTimeMillis()) + .setType(MeetingPushTypeEnum.MEETING_PENDING.getCode()) + .setTitle(resolvePendingTitle(meeting)) + .setContent(buildPendingContent(meeting)) + .setNeedAck(true) + .build(); + var pushEntity = androidPushMessageService.saveMeetingPushMessage(meeting.getTenantId(), meetingId, deviceId, message, pendingExpireMinutes); + int pushed = androidGatewayPushService.pushToDevice(deviceId, message); + if (pushEntity.getId() != null) { + androidPushMessageService.markPushed(pushEntity.getId()); + } + log.info("Android pending meeting push finished, meetingId={}, deviceId={}, pushedConnections={}", meetingId, deviceId, pushed); } - MeetingVO meeting = meetingService.getDetailIgnoreTenant(meetingId); - if (meeting == null || meeting.getTenantId() == null || meeting.getCreatorId() == null) { - return; + + @Override + public void pushMeetingCompleted(Long meetingId) { + if (meetingId == null) { + return; + } + MeetingVO meeting = meetingQueryService.getDetailIgnoreTenant(meetingId); + if (meeting == null || meeting.getTenantId() == null || meeting.getCreatorId() == null) { + return; + } + PushMessage message = PushMessage.newBuilder() + .setMessageId("meeting_completed:" + meetingId + ":" + UUID.randomUUID()) + .setTimestamp(System.currentTimeMillis()) + .setType(MeetingPushTypeEnum.MEETING_COMPLETED.getCode()) + .setTitle(resolveCompletedTitle(meeting)) + .setContent(buildCompletedContent(meeting)) + .setNeedAck(false) + .build(); + int pushed = androidGatewayPushService.pushToUser(meeting.getTenantId(), meeting.getCreatorId(), message); + log.info("Android meeting completion push finished, meetingId={}, tenantId={}, creatorId={}, pushedConnections={}", + meetingId, meeting.getTenantId(), meeting.getCreatorId(), pushed); } - PushMessage message = PushMessage.newBuilder() - .setMessageId("meeting_completed:" + meetingId + ":" + UUID.randomUUID()) - .setTimestamp(System.currentTimeMillis()) - .setType(MeetingPushTypeEnum.MEETING_COMPLETED.getCode()) - .setTitle(resolveTitle(meeting)) - .setContent(resolveContent(meeting)) - .setNeedAck(false) - .build(); - int pushed = androidGatewayPushService.pushToUser(meeting.getTenantId(), meeting.getCreatorId(), message); - log.info("Android meeting completion push finished, meetingId={}, tenantId={}, creatorId={}, pushedConnections={}", - meetingId, meeting.getTenantId(), meeting.getCreatorId(), pushed); - } - - private String resolveTitle(MeetingVO meeting) { - String title = meeting.getTitle(); - if (title != null && !title.isBlank()) { - return "会议已完成: " + title.trim(); + private String resolvePendingTitle(MeetingVO meeting) { + String title = meeting.getTitle(); + if (title != null && !title.isBlank()) { + return "待开始会议: " + title.trim(); + } + LocalDateTime meetingTime = meeting.getMeetingTime(); + return meetingTime == null + ? "待开始会议" + : "待开始会议: " + TITLE_TIME_FORMATTER.format(meetingTime); } - LocalDateTime meetingTime = meeting.getMeetingTime(); - return meetingTime == null - ? "会议已完成" - : "会议已完成: " + TITLE_TIME_FORMATTER.format(meetingTime); - } - private String resolveContent(MeetingVO meeting) { - Map result=new HashMap<>(); - result.put("meetingId",meeting.getId()); - return JSONUtil.toJsonStr(result); - } + private String resolveCompletedTitle(MeetingVO meeting) { + String title = meeting.getTitle(); + if (title != null && !title.isBlank()) { + return "会议已完成: " + title.trim(); + } + LocalDateTime meetingTime = meeting.getMeetingTime(); + return meetingTime == null + ? "会议已完成" + : "会议已完成: " + TITLE_TIME_FORMATTER.format(meetingTime); + } + + private String buildPendingContent(MeetingVO meeting) { + Map result = new HashMap<>(); + result.put("meetingId", meeting.getId()); + result.put("title", meeting.getTitle()); + result.put("meetingTime", meeting.getMeetingTime()); + result.put("sourceDeviceCode", meeting.getSourceDeviceCode()); + result.put("sourceDeviceMode", meeting.getSourceDeviceMode()); + result.put("status", meeting.getStatus()); + return JSONUtil.toJsonStr(result); + } + + private String buildCompletedContent(MeetingVO meeting) { + Map result = new HashMap<>(); + result.put("meetingId", meeting.getId()); + return JSONUtil.toJsonStr(result); + } } diff --git a/backend/src/main/java/com/imeeting/service/android/impl/AndroidPendingMeetingDraftServiceImpl.java b/backend/src/main/java/com/imeeting/service/android/impl/AndroidPendingMeetingDraftServiceImpl.java new file mode 100644 index 0000000..c1d6de0 --- /dev/null +++ b/backend/src/main/java/com/imeeting/service/android/impl/AndroidPendingMeetingDraftServiceImpl.java @@ -0,0 +1,29 @@ +package com.imeeting.service.android.impl; + +import com.imeeting.dto.android.AndroidPendingMeetingDraft; +import com.imeeting.service.android.AndroidPendingMeetingDraftService; +import com.imeeting.support.redis.AndroidPendingMeetingDraftCache; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +public class AndroidPendingMeetingDraftServiceImpl implements AndroidPendingMeetingDraftService { + + private final AndroidPendingMeetingDraftCache draftCache; + + @Override + public void save(AndroidPendingMeetingDraft draft) { + draftCache.save(draft); + } + + @Override + public AndroidPendingMeetingDraft get(Long meetingId) { + return draftCache.get(meetingId); + } + + @Override + public void clear(Long meetingId) { + draftCache.clear(meetingId); + } +} diff --git a/backend/src/main/java/com/imeeting/service/android/impl/AndroidPublicMeetingSessionServiceImpl.java b/backend/src/main/java/com/imeeting/service/android/impl/AndroidPublicMeetingSessionServiceImpl.java new file mode 100644 index 0000000..3a69c86 --- /dev/null +++ b/backend/src/main/java/com/imeeting/service/android/impl/AndroidPublicMeetingSessionServiceImpl.java @@ -0,0 +1,55 @@ +package com.imeeting.service.android.impl; + +import com.imeeting.dto.android.AndroidPublicMeetingSessionState; +import com.imeeting.dto.android.AndroidPublicMeetingSessionVO; +import com.imeeting.service.android.AndroidPublicMeetingSessionService; +import com.imeeting.support.redis.AndroidPublicMeetingSessionCache; +import lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; + +import java.time.Duration; +import java.time.LocalDateTime; +import java.util.UUID; + +@Service +@RequiredArgsConstructor +public class AndroidPublicMeetingSessionServiceImpl implements AndroidPublicMeetingSessionService { + private final AndroidPublicMeetingSessionCache sessionCache; + + @Value("${imeeting.public-device.session-ttl-minutes:30}") + private long sessionTtlMinutes; + + @Override + public AndroidPublicMeetingSessionVO create(String deviceId, String title) { + String sessionId = UUID.randomUUID().toString().replace("-", ""); + LocalDateTime expireAt = LocalDateTime.now().plusMinutes(Math.max(sessionTtlMinutes, 1)); + AndroidPublicMeetingSessionState state = new AndroidPublicMeetingSessionState(); + state.setSessionId(sessionId); + state.setSessionToken(sessionId); + state.setDeviceId(deviceId); + state.setTitle(title); + state.setExpireAt(expireAt); + sessionCache.save(sessionId, state, Duration.ofMinutes(Math.max(sessionTtlMinutes, 1))); + AndroidPublicMeetingSessionVO vo = new AndroidPublicMeetingSessionVO(); + vo.setSessionId(sessionId); + vo.setSessionToken(sessionId); + vo.setDeviceId(deviceId); + vo.setExpireAt(expireAt); + return vo; + } + + @Override + public AndroidPublicMeetingSessionState require(String sessionId) { + AndroidPublicMeetingSessionState state = sessionCache.get(sessionId); + if (state == null) { + throw new RuntimeException("公有设备发会会话不存在或已过期"); + } + return state; + } + + @Override + public void clear(String sessionId) { + sessionCache.clear(sessionId); + } +} diff --git a/backend/src/main/java/com/imeeting/service/android/impl/AndroidPushMessageServiceImpl.java b/backend/src/main/java/com/imeeting/service/android/impl/AndroidPushMessageServiceImpl.java new file mode 100644 index 0000000..1788a3b --- /dev/null +++ b/backend/src/main/java/com/imeeting/service/android/impl/AndroidPushMessageServiceImpl.java @@ -0,0 +1,95 @@ +package com.imeeting.service.android.impl; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper; +import com.imeeting.common.MeetingConstants; +import com.imeeting.entity.biz.AndroidPushMessage; +import com.imeeting.grpc.push.PushMessage; +import com.imeeting.mapper.biz.AndroidPushMessageMapper; +import com.imeeting.service.android.AndroidPushMessageService; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +import java.time.LocalDateTime; +import java.util.List; + +@Service +@RequiredArgsConstructor +public class AndroidPushMessageServiceImpl implements AndroidPushMessageService { + private final AndroidPushMessageMapper androidPushMessageMapper; + + @Override + public AndroidPushMessage saveMeetingPushMessage(Long tenantId, + Long meetingId, + String deviceCode, + PushMessage pushMessage, + long expireAfterMinutes) { + AndroidPushMessage entity = new AndroidPushMessage(); + entity.setTenantId(tenantId); + entity.setMeetingId(meetingId); + entity.setDeviceCode(deviceCode); + entity.setMessageId(pushMessage.getMessageId()); + entity.setMessageType(pushMessage.getType()); + entity.setPayload(pushMessage.getContent()); + entity.setNeedAck(pushMessage.getNeedAck() ? 1 : 0); + entity.setAcked(0); + entity.setPushStatus(MeetingConstants.DEVICE_DELIVERY_PENDING); + entity.setPushCount(0); + entity.setExpireAt(LocalDateTime.now().plusMinutes(Math.max(expireAfterMinutes, 1))); + androidPushMessageMapper.insert(entity); + return entity; + } + + @Override + public boolean ack(String messageId, String deviceCode) { + if (messageId == null || messageId.isBlank() || deviceCode == null || deviceCode.isBlank()) { + return false; + } + int updated = androidPushMessageMapper.update(null, new LambdaUpdateWrapper() + .eq(AndroidPushMessage::getMessageId, messageId) + .eq(AndroidPushMessage::getDeviceCode, deviceCode) + .eq(AndroidPushMessage::getIsDeleted, 0) + .set(AndroidPushMessage::getAcked, 1) + .set(AndroidPushMessage::getPushStatus, MeetingConstants.DEVICE_DELIVERY_ACKED) + .set(AndroidPushMessage::getAckAt, LocalDateTime.now())); + return updated > 0; + } + + @Override + public List listPendingMeetingPushMessages() { + return androidPushMessageMapper.selectList(new LambdaQueryWrapper() + .eq(AndroidPushMessage::getNeedAck, 1) + .eq(AndroidPushMessage::getAcked, 0) + .eq(AndroidPushMessage::getPushStatus, MeetingConstants.DEVICE_DELIVERY_PENDING) + .eq(AndroidPushMessage::getIsDeleted, 0)); + } + + @Override + public void markPushed(Long id) { + androidPushMessageMapper.update(null, new LambdaUpdateWrapper() + .eq(AndroidPushMessage::getId, id) + .eq(AndroidPushMessage::getIsDeleted, 0) + .setSql("push_count = COALESCE(push_count, 0) + 1") + .set(AndroidPushMessage::getLastPushAt, LocalDateTime.now())); + } + + @Override + public void markExpired(Long id) { + androidPushMessageMapper.update(null, new LambdaUpdateWrapper() + .eq(AndroidPushMessage::getId, id) + .eq(AndroidPushMessage::getIsDeleted, 0) + .set(AndroidPushMessage::getPushStatus, MeetingConstants.DEVICE_DELIVERY_EXPIRED)); + } + + @Override + public void markCancelledByMeeting(Long meetingId) { + if (meetingId == null) { + return; + } + androidPushMessageMapper.update(null, new LambdaUpdateWrapper() + .eq(AndroidPushMessage::getMeetingId, meetingId) + .eq(AndroidPushMessage::getAcked, 0) + .eq(AndroidPushMessage::getIsDeleted, 0) + .set(AndroidPushMessage::getPushStatus, MeetingConstants.DEVICE_DELIVERY_CANCELLED)); + } +} diff --git a/backend/src/main/java/com/imeeting/service/android/legacy/LegacyMeetingAdapterService.java b/backend/src/main/java/com/imeeting/service/android/legacy/LegacyMeetingAdapterService.java index b386034..68f5276 100644 --- a/backend/src/main/java/com/imeeting/service/android/legacy/LegacyMeetingAdapterService.java +++ b/backend/src/main/java/com/imeeting/service/android/legacy/LegacyMeetingAdapterService.java @@ -2,6 +2,7 @@ package com.imeeting.service.android.legacy; import com.imeeting.dto.android.legacy.LegacyMeetingCreateRequest; import com.imeeting.dto.android.legacy.LegacyUploadAudioResponse; +import com.imeeting.dto.android.AndroidAuthContext; import com.imeeting.dto.biz.MeetingVO; import com.unisbase.security.LoginUser; import org.springframework.web.multipart.MultipartFile; @@ -9,12 +10,18 @@ import org.springframework.web.multipart.MultipartFile; import java.io.IOException; public interface LegacyMeetingAdapterService { - MeetingVO createMeeting(LegacyMeetingCreateRequest request, LoginUser loginUser); + MeetingVO createMeeting(LegacyMeetingCreateRequest request, AndroidAuthContext authContext, LoginUser loginUser); LegacyUploadAudioResponse uploadAndTriggerOfflineProcess(Long meetingId, Long promptId, String modelCode, boolean forceReplace, MultipartFile audioFile, + AndroidAuthContext authContext, LoginUser loginUser) throws IOException; + + LegacyUploadAudioResponse uploadAndTriggerOfflineProcessForPublicDevice(Long meetingId, + boolean forceReplace, + MultipartFile audioFile, + AndroidAuthContext authContext) throws IOException; } diff --git a/backend/src/main/java/com/imeeting/service/android/legacy/impl/LegacyMeetingAdapterServiceImpl.java b/backend/src/main/java/com/imeeting/service/android/legacy/impl/LegacyMeetingAdapterServiceImpl.java index adce025..816ce51 100644 --- a/backend/src/main/java/com/imeeting/service/android/legacy/impl/LegacyMeetingAdapterServiceImpl.java +++ b/backend/src/main/java/com/imeeting/service/android/legacy/impl/LegacyMeetingAdapterServiceImpl.java @@ -2,9 +2,12 @@ package com.imeeting.service.android.legacy.impl; import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; import com.imeeting.common.MeetingConstants; +import com.imeeting.dto.android.AndroidAuthContext; +import com.imeeting.dto.android.AndroidPendingMeetingDraft; import com.imeeting.dto.android.legacy.LegacyMeetingCreateRequest; import com.imeeting.dto.android.legacy.LegacyUploadAudioResponse; import com.imeeting.dto.biz.MeetingVO; +import com.imeeting.dto.biz.PublicDeviceMeetingCreateCommand; import com.imeeting.dto.biz.RealtimeMeetingRuntimeProfile; import com.imeeting.entity.biz.AiTask; import com.imeeting.entity.biz.LlmModel; @@ -14,6 +17,7 @@ import com.imeeting.entity.biz.PromptTemplate; import com.imeeting.mapper.biz.LlmModelMapper; import com.imeeting.mapper.biz.MeetingTranscriptMapper; import com.imeeting.service.android.legacy.LegacyMeetingAdapterService; +import com.imeeting.service.android.AndroidPendingMeetingDraftService; import com.imeeting.service.biz.AiTaskService; import com.imeeting.service.biz.MeetingAccessService; import com.imeeting.service.biz.MeetingRuntimeProfileResolver; @@ -56,10 +60,11 @@ public class LegacyMeetingAdapterServiceImpl implements LegacyMeetingAdapterServ private final MeetingTranscriptMapper transcriptMapper; private final LlmModelMapper llmModelMapper; private final MeetingAudioUploadSupport meetingAudioUploadSupport; + private final AndroidPendingMeetingDraftService androidPendingMeetingDraftService; @Override @Transactional(rollbackFor = Exception.class) - public MeetingVO createMeeting(LegacyMeetingCreateRequest request, LoginUser loginUser) { + public MeetingVO createMeeting(LegacyMeetingCreateRequest request, AndroidAuthContext authContext, LoginUser loginUser) { if (request == null || request.getTitle() == null || request.getTitle().isBlank()) { throw new RuntimeException("会议标题不能为空"); } @@ -82,7 +87,9 @@ public class LegacyMeetingAdapterServiceImpl implements LegacyMeetingAdapterServ loginUser.getUserId(), resolveCreatorName(loginUser), MeetingConstants.SUMMARY_DETAIL_STANDARD, - 0 + 0, + authContext == null ? null : authContext.getDeviceId(), + MeetingConstants.DEVICE_MODE_PRIVATE ); meetingService.save(meeting); @@ -98,6 +105,7 @@ public class LegacyMeetingAdapterServiceImpl implements LegacyMeetingAdapterServ String modelCode, boolean forceReplace, MultipartFile audioFile, + AndroidAuthContext authContext, LoginUser loginUser) throws IOException { if (meetingId == null) { throw new RuntimeException("meeting_id 不能为空"); @@ -108,6 +116,7 @@ public class LegacyMeetingAdapterServiceImpl implements LegacyMeetingAdapterServ Meeting meeting = meetingAccessService.requireMeeting(meetingId); meetingAccessService.assertCanEditMeeting(meeting, loginUser); + assertDeviceOwnsMeeting(meeting, authContext); if (!forceReplace && meeting.getAudioUrl() != null && !meeting.getAudioUrl().isBlank()) { throw new RuntimeException("当前会议已存在音频,如需替换请设置 force_replace=true"); @@ -158,6 +167,69 @@ public class LegacyMeetingAdapterServiceImpl implements LegacyMeetingAdapterServ return new LegacyUploadAudioResponse(meetingId, relocatedUrl); } + @Override + @Transactional(rollbackFor = Exception.class) + public LegacyUploadAudioResponse uploadAndTriggerOfflineProcessForPublicDevice(Long meetingId, + boolean forceReplace, + MultipartFile audioFile, + AndroidAuthContext authContext) throws IOException { + if (meetingId == null) { + throw new RuntimeException("meeting_id 不能为空"); + } + if (audioFile == null || audioFile.isEmpty()) { + throw new RuntimeException("audio_file 不能为空"); + } + Meeting meeting = meetingAccessService.requireMeeting(meetingId); + assertDeviceOwnsMeeting(meeting, authContext); + if (!MeetingConstants.DEVICE_MODE_PUBLIC.equals(meeting.getSourceDeviceMode())) { + throw new RuntimeException("当前会议不是公有设备会议"); + } + if (!forceReplace && meeting.getAudioUrl() != null && !meeting.getAudioUrl().isBlank()) { + throw new RuntimeException("当前会议已存在音频,如需替换请设置 force_replace=true"); + } + AndroidPendingMeetingDraft draft = androidPendingMeetingDraftService.get(meetingId); + if (draft == null || draft.getCommand() == null) { + throw new RuntimeException("未找到公有设备会议配置草稿"); + } + long transcriptCount = transcriptMapper.selectCount(new LambdaQueryWrapper() + .eq(MeetingTranscript::getMeetingId, meetingId)); + if (transcriptCount > 0) { + throw new RuntimeException("当前会议已存在转录内容,不支持替换已生成的转录"); + } + PublicDeviceMeetingCreateCommand command = draft.getCommand(); + RealtimeMeetingRuntimeProfile profile = runtimeProfileResolver.resolve( + meeting.getTenantId(), + command.getAsrModelId(), + command.getSummaryModelId(), + command.getPromptId(), + null, + null, + command.getUseSpkId(), + null, + null, + command.getEnableTextRefine(), + null, + command.getHotWordGroupId(), + command.getHotWords() + ); + String stagingUrl = storeStagingAudio(audioFile); + String relocatedUrl = meetingDomainSupport.relocateAudioUrl(meetingId, stagingUrl); + meeting.setAudioUrl(relocatedUrl); + meeting.setAudioSaveStatus(RealtimeMeetingAudioStorageService.STATUS_SUCCESS); + meeting.setAudioSaveMessage(null); + meeting.setStatus(1); + meetingService.updateById(meeting); + + resetOrCreateAsrTask(meetingId, profile); + Long summaryModelId = profile.getResolvedSummaryModelId(); + Long chapterModelId = command.getChapterModelId() != null ? command.getChapterModelId() : summaryModelId; + resetOrCreateChapterTask(meetingId, summaryModelId, chapterModelId, profile.getResolvedPromptId(), command.getUserPrompt(), command.getSummaryDetailLevel()); + resetOrCreateSummaryTask(meetingId, summaryModelId, chapterModelId, profile.getResolvedPromptId(), command.getUserPrompt(), command.getSummaryDetailLevel()); + dispatchTasksAfterCommit(meetingId, meeting.getTenantId(), meeting.getCreatorId()); + androidPendingMeetingDraftService.clear(meetingId); + return new LegacyUploadAudioResponse(meetingId, relocatedUrl); + } + private String joinIds(List ids) { if (ids == null || ids.isEmpty()) { return ""; @@ -249,22 +321,65 @@ public class LegacyMeetingAdapterServiceImpl implements LegacyMeetingAdapterServ } private void resetOrCreateSummaryTask(Long meetingId, RealtimeMeetingRuntimeProfile profile) { - AiTask task = findLatestTask(meetingId, "SUMMARY"); - Map taskConfig = meetingSummaryPromptAssembler.buildTaskConfig( + resetOrCreateSummaryTask(meetingId, profile, null, null); + } + + private void resetOrCreateSummaryTask(Long meetingId, RealtimeMeetingRuntimeProfile profile, String userPrompt, String summaryDetailLevel) { + resetOrCreateSummaryTask( + meetingId, + profile.getResolvedSummaryModelId(), profile.getResolvedSummaryModelId(), profile.getResolvedPromptId(), - null + userPrompt, + summaryDetailLevel + ); + } + + private void resetOrCreateSummaryTask(Long meetingId, + Long summaryModelId, + Long chapterModelId, + Long promptId, + String userPrompt, + String summaryDetailLevel) { + AiTask task = findLatestTask(meetingId, "SUMMARY"); + Map taskConfig = meetingSummaryPromptAssembler.buildTaskConfig( + summaryModelId, + chapterModelId, + promptId, + userPrompt, + summaryDetailLevel ); resetOrCreateTask(task, meetingId, "SUMMARY", taskConfig); } private void resetOrCreateChapterTask(Long meetingId, RealtimeMeetingRuntimeProfile profile) { - AiTask task = findLatestTask(meetingId, "CHAPTER"); - Map taskConfig = meetingSummaryPromptAssembler.buildTaskConfig( + resetOrCreateChapterTask(meetingId, profile, null, null); + } + + private void resetOrCreateChapterTask(Long meetingId, RealtimeMeetingRuntimeProfile profile, String userPrompt, String summaryDetailLevel) { + resetOrCreateChapterTask( + meetingId, profile.getResolvedSummaryModelId(), profile.getResolvedSummaryModelId(), profile.getResolvedPromptId(), - null + userPrompt, + summaryDetailLevel + ); + } + + private void resetOrCreateChapterTask(Long meetingId, + Long summaryModelId, + Long chapterModelId, + Long promptId, + String userPrompt, + String summaryDetailLevel) { + AiTask task = findLatestTask(meetingId, "CHAPTER"); + Map taskConfig = meetingSummaryPromptAssembler.buildTaskConfig( + summaryModelId, + chapterModelId, + promptId, + userPrompt, + summaryDetailLevel ); resetOrCreateTask(task, meetingId, "CHAPTER", taskConfig); } @@ -315,4 +430,14 @@ public class LegacyMeetingAdapterServiceImpl implements LegacyMeetingAdapterServ private String resolveCreatorName(LoginUser loginUser) { return loginUser.getDisplayName() != null ? loginUser.getDisplayName() : loginUser.getUsername(); } + + private void assertDeviceOwnsMeeting(Meeting meeting, AndroidAuthContext authContext) { + if (meeting == null || authContext == null || authContext.getDeviceId() == null) { + return; + } + if (meeting.getSourceDeviceCode() != null && !meeting.getSourceDeviceCode().isBlank() + && !meeting.getSourceDeviceCode().equals(authContext.getDeviceId())) { + throw new RuntimeException("当前会议不属于该设备"); + } + } } diff --git a/backend/src/main/java/com/imeeting/service/biz/MeetingCommandService.java b/backend/src/main/java/com/imeeting/service/biz/MeetingCommandService.java index e6cd734..80313e8 100644 --- a/backend/src/main/java/com/imeeting/service/biz/MeetingCommandService.java +++ b/backend/src/main/java/com/imeeting/service/biz/MeetingCommandService.java @@ -8,6 +8,7 @@ import com.imeeting.dto.biz.MeetingSummaryFinalizeDTO; import com.imeeting.dto.biz.MeetingTranscriptChapterImportDTO; import com.imeeting.dto.biz.MeetingTranscriptChapterImportResultVO; import com.imeeting.dto.biz.MeetingVO; +import com.imeeting.dto.biz.PublicDeviceMeetingCreateCommand; import com.imeeting.dto.biz.RealtimeTranscriptItemDTO; import com.imeeting.dto.biz.UpdateMeetingBasicCommand; import com.imeeting.dto.biz.UpdateMeetingTranscriptCommand; @@ -19,6 +20,12 @@ public interface MeetingCommandService { MeetingVO createRealtimeMeeting(CreateRealtimeMeetingCommand command, Long tenantId, Long creatorId, String creatorName, String meetingSource); + MeetingVO createPublicDeviceMeeting(PublicDeviceMeetingCreateCommand command, + Long tenantId, + Long creatorId, + String creatorName, + String deviceCode); + void deleteMeeting(Long id); void appendRealtimeTranscripts(Long meetingId, List items); diff --git a/backend/src/main/java/com/imeeting/service/biz/impl/AiTaskServiceImpl.java b/backend/src/main/java/com/imeeting/service/biz/impl/AiTaskServiceImpl.java index d69453e..5801a0d 100644 --- a/backend/src/main/java/com/imeeting/service/biz/impl/AiTaskServiceImpl.java +++ b/backend/src/main/java/com/imeeting/service/biz/impl/AiTaskServiceImpl.java @@ -7,7 +7,6 @@ import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; import com.imeeting.common.MeetingProgressStage; -import com.imeeting.common.RedisKeys; import com.imeeting.dto.biz.AiModelVO; import com.imeeting.dto.biz.MeetingSummarySource; import com.imeeting.dto.biz.MeetingTranscriptSourceVO; @@ -28,8 +27,8 @@ import com.imeeting.service.biz.MeetingSummaryFileService; import com.imeeting.service.biz.MeetingTranscriptChapterService; import com.imeeting.service.biz.MeetingTranscriptFileService; -import com.imeeting.support.RedisValueSupport; - +import com.imeeting.support.TaskSecurityContextRunner; +import com.imeeting.support.redis.MeetingLockCache; import com.unisbase.entity.SysUser; import com.unisbase.mapper.SysUserMapper; import com.unisbase.service.SysParamService; @@ -38,7 +37,6 @@ import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Qualifier; import org.springframework.beans.factory.annotation.Value; import org.springframework.context.annotation.Lazy; -import org.springframework.data.redis.core.StringRedisTemplate; import org.springframework.scheduling.annotation.Async; import org.springframework.scheduling.annotation.Scheduled; import org.springframework.stereotype.Service; @@ -59,7 +57,6 @@ import java.time.Duration; import java.time.LocalDateTime; import java.util.*; import java.util.concurrent.Executor; -import java.util.concurrent.TimeUnit; import java.util.stream.Collectors; @Service @@ -72,7 +69,7 @@ public class AiTaskServiceImpl extends ServiceImpl impleme private final ObjectMapper objectMapper; private final SysUserMapper sysUserMapper; private final HotWordService hotWordService; - private final RedisValueSupport redisValueSupport; + private final MeetingLockCache meetingLockCache; private final MeetingProgressService meetingProgressService; private final MeetingSummaryFileService meetingSummaryFileService; private final MeetingTranscriptFileService meetingTranscriptFileService; @@ -117,7 +114,7 @@ public class AiTaskServiceImpl extends ServiceImpl impleme ObjectMapper objectMapper, SysUserMapper sysUserMapper, HotWordService hotWordService, - RedisValueSupport redisValueSupport, + MeetingLockCache meetingLockCache, MeetingProgressService meetingProgressService, MeetingSummaryFileService meetingSummaryFileService, MeetingTranscriptFileService meetingTranscriptFileService, @@ -133,7 +130,7 @@ public class AiTaskServiceImpl extends ServiceImpl impleme this.objectMapper = objectMapper; this.sysUserMapper = sysUserMapper; this.hotWordService = hotWordService; - this.redisValueSupport = redisValueSupport; + this.meetingLockCache = meetingLockCache; this.meetingProgressService = meetingProgressService; this.meetingSummaryFileService = meetingSummaryFileService; this.meetingTranscriptFileService = meetingTranscriptFileService; @@ -145,41 +142,6 @@ public class AiTaskServiceImpl extends ServiceImpl impleme this.androidMeetingPushService = androidMeetingPushService; } - public AiTaskServiceImpl(MeetingMapper meetingMapper, - MeetingTranscriptMapper transcriptMapper, - AiModelService aiModelService, - ObjectMapper objectMapper, - SysUserMapper sysUserMapper, - HotWordService hotWordService, - StringRedisTemplate redisTemplate, - MeetingSummaryFileService meetingSummaryFileService, - MeetingTranscriptFileService meetingTranscriptFileService, - - MeetingTranscriptChapterService meetingTranscriptChapterService, - MeetingSummaryPromptAssembler meetingSummaryPromptAssembler, - TaskSecurityContextRunner taskSecurityContextRunner, - MeetingExternalSummaryWebhookTrigger meetingExternalSummaryWebhookTrigger, - AndroidMeetingPushService androidMeetingPushService) { - this( - meetingMapper, - transcriptMapper, - aiModelService, - objectMapper, - sysUserMapper, - hotWordService, - new RedisValueSupport(redisTemplate, objectMapper), - new RedisOnlyMeetingProgressServiceAdapter(redisTemplate, objectMapper), - meetingSummaryFileService, - meetingTranscriptFileService, - meetingTranscriptChapterService, - meetingSummaryPromptAssembler, - taskSecurityContextRunner, - meetingExternalSummaryWebhookTrigger, - null, - androidMeetingPushService - ); - } - @Override @Async("asrDispatchExecutor") public void dispatchTasks(Long meetingId, Long tenantId, Long userId) { @@ -216,9 +178,8 @@ public class AiTaskServiceImpl extends ServiceImpl impleme private void doDispatchTasks(Long meetingId) { - String lockKey = RedisKeys.meetingPollingLockKey(meetingId); - Boolean acquired = redisValueSupport.setIfAbsent(lockKey, "locked", 30, TimeUnit.MINUTES); - if (Boolean.FALSE.equals(acquired)) { + boolean acquired = meetingLockCache.tryAcquirePollingLock(meetingId, Duration.ofMinutes(30)); + if (!acquired) { log.warn("Meeting {} is already being processed", meetingId); return; } @@ -293,7 +254,7 @@ public class AiTaskServiceImpl extends ServiceImpl impleme updateMeetingStatus(meetingId, 4); updateProgress(meetingId, -1, "分析失败: " + e.getMessage(), 0); } finally { - redisValueSupport.delete(lockKey); + meetingLockCache.releasePollingLock(meetingId); scheduleQueuedAsrTasks(); } } @@ -390,9 +351,8 @@ public class AiTaskServiceImpl extends ServiceImpl impleme if (queuedCount <= 0) { return; } - String scheduleLockKey = RedisKeys.meetingAsrScheduleLockKey(); - Boolean acquired = redisValueSupport.setIfAbsent(scheduleLockKey, "locked", 30, TimeUnit.SECONDS); - if (Boolean.FALSE.equals(acquired)) { + boolean acquired = meetingLockCache.tryAcquireAsrScheduleLock(Duration.ofSeconds(30)); + if (!acquired) { return; } try { @@ -425,7 +385,7 @@ public class AiTaskServiceImpl extends ServiceImpl impleme self.dispatchTasks(queuedMeeting.getId(), queuedMeeting.getTenantId(), queuedMeeting.getCreatorId()); } } finally { - redisValueSupport.delete(scheduleLockKey); + meetingLockCache.releaseAsrScheduleLock(); } } @@ -1070,9 +1030,8 @@ public class AiTaskServiceImpl extends ServiceImpl impleme triggerExternalSummaryWebhook(meeting, sumTask, chapterTask, "AUTO_AFTER_TRANSCRIPT_READY", false); return; } - String summaryLockKey = RedisKeys.meetingSummaryLockKey(meeting.getId()); - Boolean acquired = redisValueSupport.setIfAbsent(summaryLockKey, "locked", 30, TimeUnit.MINUTES); - if (Boolean.FALSE.equals(acquired)) { + boolean acquired = meetingLockCache.tryAcquireSummaryLock(meeting.getId(), Duration.ofMinutes(30)); + if (!acquired) { log.warn("Meeting {} summary is already being processed", meeting.getId()); return; } @@ -1086,7 +1045,7 @@ public class AiTaskServiceImpl extends ServiceImpl impleme processSummaryTask(meeting, summarySource, sumTask); reconcileMeetingStatus(meeting.getId()); } finally { - redisValueSupport.delete(summaryLockKey); + meetingLockCache.releaseSummaryLock(meeting.getId()); } } diff --git a/backend/src/main/java/com/imeeting/service/biz/impl/MeetingCommandServiceImpl.java b/backend/src/main/java/com/imeeting/service/biz/impl/MeetingCommandServiceImpl.java index 7d85320..0c6f71d 100644 --- a/backend/src/main/java/com/imeeting/service/biz/impl/MeetingCommandServiceImpl.java +++ b/backend/src/main/java/com/imeeting/service/biz/impl/MeetingCommandServiceImpl.java @@ -5,6 +5,7 @@ import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper; import com.fasterxml.jackson.databind.ObjectMapper; import com.imeeting.common.MeetingConstants; import com.imeeting.common.RedisKeys; +import com.imeeting.dto.android.AndroidPendingMeetingDraft; import com.imeeting.dto.biz.CreateMeetingCommand; import com.imeeting.dto.biz.CreateRealtimeMeetingCommand; import com.imeeting.dto.biz.MeetingExternalWorkflowFailureDTO; @@ -14,6 +15,7 @@ import com.imeeting.dto.biz.MeetingTranscriptChapterImportDTO; import com.imeeting.dto.biz.MeetingTranscriptChapterImportResultVO; import com.imeeting.dto.biz.MeetingTranscriptSourceVO; import com.imeeting.dto.biz.MeetingVO; +import com.imeeting.dto.biz.PublicDeviceMeetingCreateCommand; import com.imeeting.dto.biz.RealtimeMeetingRuntimeProfile; import com.imeeting.dto.biz.RealtimeMeetingResumeConfig; import com.imeeting.dto.biz.RealtimeMeetingSessionStatusVO; @@ -26,6 +28,8 @@ import com.imeeting.entity.biz.Meeting; import com.imeeting.entity.biz.MeetingTranscript; import com.imeeting.entity.biz.MeetingTranscriptChapterVersion; import com.imeeting.service.android.AndroidMeetingPushService; +import com.imeeting.service.android.AndroidPendingMeetingDraftService; +import com.imeeting.service.android.AndroidPushMessageService; import com.imeeting.service.biz.AiTaskService; import com.imeeting.service.biz.HotWordService; import com.imeeting.service.biz.MeetingCommandService; @@ -37,12 +41,12 @@ import com.imeeting.service.biz.MeetingTranscriptChapterService; import com.imeeting.service.biz.MeetingTranscriptFileService; import com.imeeting.service.biz.MeetingTranscriptRevisionService; import com.imeeting.service.biz.RealtimeMeetingSessionStateService; -import com.imeeting.service.biz.impl.RedisOnlyMeetingProgressServiceAdapter; import com.imeeting.service.realtime.RealtimeMeetingAudioStorageService; +import com.imeeting.support.redis.MeetingAsrPermitCache; +import com.imeeting.support.redis.MeetingLockCache; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Value; -import org.springframework.data.redis.core.StringRedisTemplate; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.support.TransactionSynchronization; @@ -77,7 +81,10 @@ public class MeetingCommandServiceImpl implements MeetingCommandService { private final ObjectMapper objectMapper; private final MeetingExternalSummaryWebhookTrigger meetingExternalSummaryWebhookTrigger; private final AndroidMeetingPushService androidMeetingPushService; - private StringRedisTemplate compatibilityRedisTemplate; + private final AndroidPushMessageService androidPushMessageService; + private final AndroidPendingMeetingDraftService androidPendingMeetingDraftService; + private final MeetingLockCache meetingLockCache; + private final MeetingAsrPermitCache meetingAsrPermitCache; @Value("${imeeting.summary-orchestration.mode:INTERNAL_BUILTIN}") private String summaryOrchestrationMode; @@ -98,7 +105,11 @@ public class MeetingCommandServiceImpl implements MeetingCommandService { MeetingProgressService meetingProgressService, ObjectMapper objectMapper, MeetingExternalSummaryWebhookTrigger meetingExternalSummaryWebhookTrigger, - AndroidMeetingPushService androidMeetingPushService) { + AndroidMeetingPushService androidMeetingPushService, + AndroidPushMessageService androidPushMessageService, + AndroidPendingMeetingDraftService androidPendingMeetingDraftService, + MeetingLockCache meetingLockCache, + MeetingAsrPermitCache meetingAsrPermitCache) { this.meetingService = meetingService; this.aiTaskService = aiTaskService; this.hotWordService = hotWordService; @@ -115,43 +126,10 @@ public class MeetingCommandServiceImpl implements MeetingCommandService { this.objectMapper = objectMapper; this.meetingExternalSummaryWebhookTrigger = meetingExternalSummaryWebhookTrigger; this.androidMeetingPushService = androidMeetingPushService; - } - - public MeetingCommandServiceImpl(MeetingService meetingService, - AiTaskService aiTaskService, - HotWordService hotWordService, - com.imeeting.mapper.biz.MeetingTranscriptMapper transcriptMapper, - MeetingSummaryFileService meetingSummaryFileService, - MeetingTranscriptFileService meetingTranscriptFileService, - - MeetingTranscriptChapterService meetingTranscriptChapterService, - MeetingDomainSupport meetingDomainSupport, - MeetingRuntimeProfileResolver meetingRuntimeProfileResolver, - RealtimeMeetingSessionStateService realtimeMeetingSessionStateService, - RealtimeMeetingAudioStorageService realtimeMeetingAudioStorageService, - StringRedisTemplate redisTemplate, - ObjectMapper objectMapper, - MeetingExternalSummaryWebhookTrigger meetingExternalSummaryWebhookTrigger, - AndroidMeetingPushService androidMeetingPushService) { - this( - meetingService, - aiTaskService, - hotWordService, - transcriptMapper, - meetingSummaryFileService, - meetingTranscriptFileService, - - meetingTranscriptChapterService, - meetingDomainSupport, - meetingRuntimeProfileResolver, - realtimeMeetingSessionStateService, - realtimeMeetingAudioStorageService, - new RedisOnlyMeetingProgressServiceAdapter(redisTemplate, objectMapper), - objectMapper, - meetingExternalSummaryWebhookTrigger, - androidMeetingPushService - ); - this.compatibilityRedisTemplate = redisTemplate; + this.androidPushMessageService = androidPushMessageService; + this.androidPendingMeetingDraftService = androidPendingMeetingDraftService; + this.meetingLockCache = meetingLockCache; + this.meetingAsrPermitCache = meetingAsrPermitCache; } @Override @@ -273,6 +251,65 @@ public class MeetingCommandServiceImpl implements MeetingCommandService { return vo; } + @Override + @Transactional(rollbackFor = Exception.class) + public MeetingVO createPublicDeviceMeeting(PublicDeviceMeetingCreateCommand command, + Long tenantId, + Long creatorId, + String creatorName, + String deviceCode) { + RealtimeMeetingRuntimeProfile runtimeProfile = meetingRuntimeProfileResolver.resolve( + tenantId, + command.getAsrModelId(), + command.getSummaryModelId(), + command.getPromptId(), + null, + null, + command.getUseSpkId(), + null, + null, + command.getEnableTextRefine(), + null, + command.getHotWordGroupId(), + command.getHotWords() + ); + Long hostUserId = resolveHostUserId(command.getHostUserId(), creatorId); + String hostName = resolveHostName(command.getHostName(), creatorName, creatorId, hostUserId); + String summaryDetailLevel = resolveSummaryDetailLevel(command.getSummaryDetailLevel()); + Meeting meeting = meetingDomainSupport.initMeeting( + command.getTitle(), + command.getMeetingTime(), + command.getParticipants(), + command.getTags(), + null, + MeetingConstants.TYPE_OFFLINE, + MeetingConstants.SOURCE_ANDROID, + tenantId, + creatorId, + creatorName, + hostUserId, + hostName, + summaryDetailLevel, + 0, + deviceCode, + MeetingConstants.DEVICE_MODE_PUBLIC + ); + meeting.setAccessPassword(command.getAccessPassword()); + meetingService.save(meeting); + + AndroidPendingMeetingDraft draft = new AndroidPendingMeetingDraft(); + draft.setMeetingId(meeting.getId()); + draft.setDeviceId(deviceCode); + draft.setTenantId(tenantId); + draft.setCreatorId(creatorId); + draft.setCommand(command); + androidPendingMeetingDraftService.save(draft); + + MeetingVO vo = new MeetingVO(); + meetingDomainSupport.fillMeetingVO(meeting, vo, false, false); + return vo; + } + @Override @Transactional(rollbackFor = Exception.class) public void deleteMeeting(Long id) { @@ -283,6 +320,8 @@ public class MeetingCommandServiceImpl implements MeetingCommandService { meetingService.removeById(id); realtimeMeetingSessionStateService.clear(id); meetingProgressService.clear(id); + androidPushMessageService.markCancelledByMeeting(id); + androidPendingMeetingDraftService.clear(id); deleteMeetingArtifactsAfterCommit(id); } @@ -1073,12 +1112,11 @@ public class MeetingCommandServiceImpl implements MeetingCommandService { } private void clearLegacyDispatchState(Long meetingId) { - if (compatibilityRedisTemplate == null || meetingId == null) { + if (meetingId == null) { return; } - compatibilityRedisTemplate.delete(RedisKeys.meetingPollingLockKey(meetingId)); - compatibilityRedisTemplate.delete(RedisKeys.meetingSummaryLockKey(meetingId)); - compatibilityRedisTemplate.opsForSet().remove(RedisKeys.meetingAsrPermitSetKey(), String.valueOf(meetingId)); + meetingLockCache.clearDispatchLocks(meetingId); + meetingAsrPermitCache.removePermit(meetingId); } private void ensureExternalSummaryModeEnabled() { diff --git a/backend/src/main/java/com/imeeting/service/biz/impl/MeetingDomainSupport.java b/backend/src/main/java/com/imeeting/service/biz/impl/MeetingDomainSupport.java index 9807839..b95f138 100644 --- a/backend/src/main/java/com/imeeting/service/biz/impl/MeetingDomainSupport.java +++ b/backend/src/main/java/com/imeeting/service/biz/impl/MeetingDomainSupport.java @@ -54,6 +54,16 @@ public class MeetingDomainSupport { String audioUrl, String meetingType, String meetingSource, Long tenantId, Long creatorId, String creatorName, Long hostUserId, String hostName, String summaryDetailLevel, int status) { + return initMeeting(title, meetingTime, participants, tags, audioUrl, meetingType, meetingSource, + tenantId, creatorId, creatorName, hostUserId, hostName, summaryDetailLevel, status, + null, null); + } + + public Meeting initMeeting(String title, LocalDateTime meetingTime, String participants, String tags, + String audioUrl, String meetingType, String meetingSource, + Long tenantId, Long creatorId, String creatorName, + Long hostUserId, String hostName, String summaryDetailLevel, int status, + String sourceDeviceCode, String sourceDeviceMode) { Meeting meeting = new Meeting(); meeting.setTitle(title); meeting.setMeetingTime(meetingTime); @@ -67,6 +77,8 @@ public class MeetingDomainSupport { meeting.setHostName(hostName); meeting.setTenantId(tenantId != null ? tenantId : 0L); meeting.setAudioUrl(audioUrl); + meeting.setSourceDeviceCode(sourceDeviceCode); + meeting.setSourceDeviceMode(sourceDeviceMode); meeting.setSummaryDetailLevel(normalizeSummaryDetailLevel(summaryDetailLevel)); meeting.setAudioSaveStatus(RealtimeMeetingAudioStorageService.STATUS_NONE); meeting.setStatus(status); @@ -365,6 +377,8 @@ public class MeetingDomainSupport { } vo.setMeetingType(meeting.getMeetingType()); vo.setMeetingSource(meeting.getMeetingSource()); + vo.setSourceDeviceCode(meeting.getSourceDeviceCode()); + vo.setSourceDeviceMode(meeting.getSourceDeviceMode()); vo.setSummaryDetailLevel(normalizeSummaryDetailLevel(meeting.getSummaryDetailLevel())); vo.setAudioSaveStatus(meeting.getAudioSaveStatus()); vo.setAudioSaveMessage(meeting.getAudioSaveMessage()); diff --git a/backend/src/main/java/com/imeeting/service/biz/impl/MeetingProgressServiceImpl.java b/backend/src/main/java/com/imeeting/service/biz/impl/MeetingProgressServiceImpl.java index 2df3e52..90e6921 100644 --- a/backend/src/main/java/com/imeeting/service/biz/impl/MeetingProgressServiceImpl.java +++ b/backend/src/main/java/com/imeeting/service/biz/impl/MeetingProgressServiceImpl.java @@ -3,14 +3,13 @@ package com.imeeting.service.biz.impl; import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; import com.fasterxml.jackson.databind.ObjectMapper; import com.imeeting.common.MeetingProgressStage; -import com.imeeting.common.RedisKeys; import com.imeeting.dto.biz.MeetingProgressSnapshot; import com.imeeting.entity.biz.AiTask; import com.imeeting.entity.biz.Meeting; import com.imeeting.mapper.biz.AiTaskMapper; import com.imeeting.mapper.biz.MeetingMapper; import com.imeeting.service.biz.MeetingProgressService; -import com.imeeting.support.RedisValueSupport; +import com.imeeting.support.redis.MeetingProgressCache; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; import org.springframework.transaction.support.TransactionSynchronization; @@ -21,17 +20,14 @@ import java.util.HashMap; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; -import java.util.concurrent.TimeUnit; @Service @RequiredArgsConstructor public class MeetingProgressServiceImpl implements MeetingProgressService { - private static final long PROGRESS_TTL_HOURS = 1L; - private final MeetingMapper meetingMapper; private final AiTaskMapper aiTaskMapper; - private final RedisValueSupport redisValueSupport; + private final MeetingProgressCache meetingProgressCache; private final ObjectMapper objectMapper; @Override @@ -39,12 +35,12 @@ public class MeetingProgressServiceImpl implements MeetingProgressService { if (meetingId == null) { return; } - redisValueSupport.delete(RedisKeys.meetingProgressKey(meetingId)); + meetingProgressCache.clear(meetingId); } @Override public Map getProgressMap(Long meetingId) { - MeetingProgressSnapshot snapshot = redisValueSupport.getJson(RedisKeys.meetingProgressKey(meetingId), MeetingProgressSnapshot.class); + MeetingProgressSnapshot snapshot = meetingProgressCache.getSnapshot(meetingId); if (snapshot == null) { snapshot = buildFallbackSnapshot(meetingId); if (snapshot != null) { @@ -74,7 +70,7 @@ public class MeetingProgressServiceImpl implements MeetingProgressService { @Override public Integer resolvePercent(Long meetingId) { - MeetingProgressSnapshot snapshot = redisValueSupport.getJson(RedisKeys.meetingProgressKey(meetingId), MeetingProgressSnapshot.class); + MeetingProgressSnapshot snapshot = meetingProgressCache.getSnapshot(meetingId); if (snapshot != null && snapshot.getPercent() != null) { return snapshot.getPercent(); } @@ -115,11 +111,11 @@ public class MeetingProgressServiceImpl implements MeetingProgressService { if (snapshot == null || snapshot.getMeetingId() == null) { return; } - MeetingProgressSnapshot existing = redisValueSupport.getJson(RedisKeys.meetingProgressKey(snapshot.getMeetingId()), MeetingProgressSnapshot.class); + MeetingProgressSnapshot existing = meetingProgressCache.getSnapshot(snapshot.getMeetingId()); if (!shouldReplace(existing, snapshot)) { return; } - redisValueSupport.setJson(RedisKeys.meetingProgressKey(snapshot.getMeetingId()), snapshot, PROGRESS_TTL_HOURS, TimeUnit.HOURS); + meetingProgressCache.saveSnapshot(snapshot); } private MeetingProgressSnapshot buildSnapshot(Long meetingId, diff --git a/backend/src/main/java/com/imeeting/service/biz/impl/RealtimeMeetingSessionStateServiceImpl.java b/backend/src/main/java/com/imeeting/service/biz/impl/RealtimeMeetingSessionStateServiceImpl.java index 11f80f3..1a00c30 100644 --- a/backend/src/main/java/com/imeeting/service/biz/impl/RealtimeMeetingSessionStateServiceImpl.java +++ b/backend/src/main/java/com/imeeting/service/biz/impl/RealtimeMeetingSessionStateServiceImpl.java @@ -1,8 +1,6 @@ package com.imeeting.service.biz.impl; import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; -import com.fasterxml.jackson.databind.ObjectMapper; -import com.imeeting.common.RedisKeys; import com.imeeting.dto.biz.RealtimeMeetingResumeConfig; import com.imeeting.dto.biz.RealtimeMeetingSessionState; import com.imeeting.dto.biz.RealtimeMeetingSessionStatusVO; @@ -11,10 +9,11 @@ import com.imeeting.entity.biz.MeetingTranscript; import com.imeeting.mapper.biz.MeetingMapper; import com.imeeting.mapper.biz.MeetingTranscriptMapper; import com.imeeting.service.biz.RealtimeMeetingSessionStateService; +import com.imeeting.support.redis.MeetingLockCache; +import com.imeeting.support.redis.RealtimeMeetingSessionCache; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Value; -import org.springframework.data.redis.core.StringRedisTemplate; import org.springframework.stereotype.Service; import java.time.Duration; @@ -27,8 +26,8 @@ import java.util.Map; @RequiredArgsConstructor public class RealtimeMeetingSessionStateServiceImpl implements RealtimeMeetingSessionStateService { - private final StringRedisTemplate redisTemplate; - private final ObjectMapper objectMapper; + private final RealtimeMeetingSessionCache sessionCache; + private final MeetingLockCache meetingLockCache; private final MeetingTranscriptMapper transcriptMapper; private final MeetingMapper meetingMapper; @@ -113,8 +112,8 @@ public class RealtimeMeetingSessionStateServiceImpl implements RealtimeMeetingSe state.setUpdatedAt(now); writeState(state); - redisTemplate.delete(RedisKeys.realtimeMeetingResumeTimeoutKey(meetingId)); - redisTemplate.delete(RedisKeys.realtimeMeetingEmptyTimeoutKey(meetingId)); + sessionCache.clearResumeTimeout(meetingId); + sessionCache.clearEmptyTimeout(meetingId); return true; } @@ -194,8 +193,8 @@ public class RealtimeMeetingSessionStateServiceImpl implements RealtimeMeetingSe if ("PAUSED_EMPTY".equals(state.getStatus()) || "PAUSED_RESUMABLE".equals(state.getStatus())) { state.setStatus("PAUSED_RESUMABLE"); state.setResumeExpireAt(now + Duration.ofMinutes(getResumeWindowMinutes()).toMillis()); - ensureTimeoutKey(meetingId, RedisKeys.realtimeMeetingResumeTimeoutKey(meetingId), Duration.ofMinutes(getResumeWindowMinutes())); - redisTemplate.delete(RedisKeys.realtimeMeetingEmptyTimeoutKey(meetingId)); + sessionCache.saveResumeTimeout(meetingId, Duration.ofMinutes(getResumeWindowMinutes())); + sessionCache.clearEmptyTimeout(meetingId); } writeState(state); @@ -203,9 +202,8 @@ public class RealtimeMeetingSessionStateServiceImpl implements RealtimeMeetingSe @Override public boolean markCompletingIfResumeExpired(Long meetingId) { - String lockKey = RedisKeys.realtimeMeetingTimeoutLockKey(meetingId); - Boolean locked = redisTemplate.opsForValue().setIfAbsent(lockKey, "1", Duration.ofMinutes(1)); - if (Boolean.FALSE.equals(locked)) { + boolean locked = meetingLockCache.tryAcquireRealtimeTimeoutLock(meetingId, Duration.ofMinutes(1)); + if (!locked) { return false; } @@ -227,7 +225,7 @@ public class RealtimeMeetingSessionStateServiceImpl implements RealtimeMeetingSe state.setLastTranscriptAt(now); state.setResumeExpireAt(now + Duration.ofMinutes(getResumeWindowMinutes()).toMillis()); writeState(state); - ensureTimeoutKey(meetingId, RedisKeys.realtimeMeetingResumeTimeoutKey(meetingId), Duration.ofMinutes(getResumeWindowMinutes())); + sessionCache.saveResumeTimeout(meetingId, Duration.ofMinutes(getResumeWindowMinutes())); return false; } @@ -236,7 +234,7 @@ public class RealtimeMeetingSessionStateServiceImpl implements RealtimeMeetingSe writeState(state); return true; } finally { - redisTemplate.delete(lockKey); + meetingLockCache.releaseRealtimeTimeoutLock(meetingId); } } @@ -253,10 +251,7 @@ public class RealtimeMeetingSessionStateServiceImpl implements RealtimeMeetingSe @Override public void clear(Long meetingId) { - redisTemplate.delete(RedisKeys.realtimeMeetingSessionStateKey(meetingId)); - redisTemplate.delete(RedisKeys.realtimeMeetingResumeTimeoutKey(meetingId)); - redisTemplate.delete(RedisKeys.realtimeMeetingEmptyTimeoutKey(meetingId)); - redisTemplate.delete(RedisKeys.realtimeMeetingEventSeqKey(meetingId)); + sessionCache.clearAll(meetingId); } private RealtimeMeetingSessionStatusVO pauseState(Long meetingId, RealtimeMeetingSessionState state) { @@ -272,16 +267,16 @@ public class RealtimeMeetingSessionStateServiceImpl implements RealtimeMeetingSe if (transcriptCount > 0) { state.setStatus("PAUSED_RESUMABLE"); state.setResumeExpireAt(now + Duration.ofMinutes(getResumeWindowMinutes()).toMillis()); - ensureTimeoutKey(meetingId, RedisKeys.realtimeMeetingResumeTimeoutKey(meetingId), Duration.ofMinutes(getResumeWindowMinutes())); - redisTemplate.delete(RedisKeys.realtimeMeetingEmptyTimeoutKey(meetingId)); + sessionCache.saveResumeTimeout(meetingId, Duration.ofMinutes(getResumeWindowMinutes())); + sessionCache.clearEmptyTimeout(meetingId); if (state.getLastTranscriptAt() == null) { state.setLastTranscriptAt(now); } } else { state.setStatus("PAUSED_EMPTY"); state.setResumeExpireAt(null); - redisTemplate.delete(RedisKeys.realtimeMeetingResumeTimeoutKey(meetingId)); - ensureTimeoutKey(meetingId, RedisKeys.realtimeMeetingEmptyTimeoutKey(meetingId), Duration.ofMinutes(getEmptySessionRetentionMinutes())); + sessionCache.clearResumeTimeout(meetingId); + sessionCache.saveEmptyTimeout(meetingId, Duration.ofMinutes(getEmptySessionRetentionMinutes())); } writeState(state); @@ -372,31 +367,11 @@ public class RealtimeMeetingSessionStateServiceImpl implements RealtimeMeetingSe } private RealtimeMeetingSessionState readState(Long meetingId) { - String raw = redisTemplate.opsForValue().get(RedisKeys.realtimeMeetingSessionStateKey(meetingId)); - if (raw == null || raw.isBlank()) { - return null; - } - try { - return objectMapper.readValue(raw, RealtimeMeetingSessionState.class); - } catch (Exception ex) { - log.warn("Failed to read realtime meeting session state, meetingId={}", meetingId, ex); - return null; - } + return sessionCache.getState(meetingId); } private void writeState(RealtimeMeetingSessionState state) { - try { - redisTemplate.opsForValue().set( - RedisKeys.realtimeMeetingSessionStateKey(state.getMeetingId()), - objectMapper.writeValueAsString(state) - ); - } catch (Exception ex) { - throw new RuntimeException("写入实时会议会话状态失败", ex); - } - } - - private void ensureTimeoutKey(Long meetingId, String key, Duration ttl) { - redisTemplate.opsForValue().set(key, String.valueOf(meetingId), ttl); + sessionCache.saveState(state); } private long getResumeWindowMinutes() { diff --git a/backend/src/main/java/com/imeeting/service/biz/impl/RealtimeMeetingSocketSessionServiceImpl.java b/backend/src/main/java/com/imeeting/service/biz/impl/RealtimeMeetingSocketSessionServiceImpl.java index d18fed4..6477014 100644 --- a/backend/src/main/java/com/imeeting/service/biz/impl/RealtimeMeetingSocketSessionServiceImpl.java +++ b/backend/src/main/java/com/imeeting/service/biz/impl/RealtimeMeetingSocketSessionServiceImpl.java @@ -1,8 +1,6 @@ package com.imeeting.service.biz.impl; -import com.fasterxml.jackson.databind.ObjectMapper; import com.imeeting.common.MeetingConstants; -import com.imeeting.common.RedisKeys; import com.imeeting.dto.biz.AiModelVO; import com.imeeting.dto.biz.RealtimeMeetingResumeConfig; import com.imeeting.dto.biz.RealtimeSocketSessionData; @@ -12,12 +10,11 @@ import com.imeeting.service.biz.AiModelService; import com.imeeting.service.biz.MeetingAccessService; import com.imeeting.service.biz.RealtimeMeetingSessionStateService; import com.imeeting.service.biz.RealtimeMeetingSocketSessionService; +import com.imeeting.support.redis.RealtimeMeetingSocketSessionCache; import com.unisbase.security.LoginUser; import lombok.RequiredArgsConstructor; -import org.springframework.data.redis.core.StringRedisTemplate; import org.springframework.stereotype.Service; -import java.time.Duration; import java.util.HashMap; import java.util.List; import java.util.Map; @@ -27,11 +24,9 @@ import java.util.UUID; @RequiredArgsConstructor public class RealtimeMeetingSocketSessionServiceImpl implements RealtimeMeetingSocketSessionService { - private static final Duration SESSION_TTL = Duration.ofMinutes(10); private static final String WS_PATH = "/ws/meeting/realtime"; - private final ObjectMapper objectMapper; - private final StringRedisTemplate redisTemplate; + private final RealtimeMeetingSocketSessionCache socketSessionCache; private final MeetingAccessService meetingAccessService; private final AiModelService aiModelService; private final RealtimeMeetingSessionStateService realtimeMeetingSessionStateService; @@ -90,20 +85,12 @@ public class RealtimeMeetingSocketSessionServiceImpl implements RealtimeMeetingS sessionData.setTargetWsUrl(targetWsUrl); String sessionToken = UUID.randomUUID().toString().replace("-", ""); - try { - redisTemplate.opsForValue().set( - RedisKeys.realtimeMeetingSocketSessionKey(sessionToken), - objectMapper.writeValueAsString(sessionData), - SESSION_TTL - ); - } catch (Exception ex) { - throw new RuntimeException("创建实时 Socket 会话失败", ex); - } + socketSessionCache.save(sessionToken, sessionData); RealtimeSocketSessionVO vo = new RealtimeSocketSessionVO(); vo.setSessionToken(sessionToken); vo.setPath(WS_PATH); - vo.setExpiresInSeconds(SESSION_TTL.toSeconds()); + vo.setExpiresInSeconds(socketSessionCache.getSessionTtlSeconds()); vo.setStartMessage(buildStartMessage( asrModel, meetingId, @@ -121,18 +108,7 @@ public class RealtimeMeetingSocketSessionServiceImpl implements RealtimeMeetingS @Override public RealtimeSocketSessionData getSessionData(String sessionToken) { - if (sessionToken == null || sessionToken.isBlank()) { - return null; - } - String raw = redisTemplate.opsForValue().get(RedisKeys.realtimeMeetingSocketSessionKey(sessionToken)); - if (raw == null || raw.isBlank()) { - return null; - } - try { - return objectMapper.readValue(raw, RealtimeSocketSessionData.class); - } catch (Exception ex) { - throw new RuntimeException("读取实时 Socket 会话失败", ex); - } + return socketSessionCache.get(sessionToken); } private String resolveWsUrl(AiModelVO model) { diff --git a/backend/src/main/java/com/imeeting/service/biz/impl/RedisOnlyMeetingProgressServiceAdapter.java b/backend/src/main/java/com/imeeting/service/biz/impl/RedisOnlyMeetingProgressServiceAdapter.java deleted file mode 100644 index 9f4db9d..0000000 --- a/backend/src/main/java/com/imeeting/service/biz/impl/RedisOnlyMeetingProgressServiceAdapter.java +++ /dev/null @@ -1,149 +0,0 @@ -package com.imeeting.service.biz.impl; - -import com.fasterxml.jackson.databind.ObjectMapper; -import com.imeeting.common.MeetingProgressStage; -import com.imeeting.common.RedisKeys; -import com.imeeting.dto.biz.MeetingProgressSnapshot; -import com.imeeting.entity.biz.AiTask; -import com.imeeting.service.biz.MeetingProgressService; -import org.springframework.data.redis.core.StringRedisTemplate; - -import java.util.LinkedHashMap; -import java.util.List; -import java.util.Map; -import java.util.concurrent.TimeUnit; - -public class RedisOnlyMeetingProgressServiceAdapter implements MeetingProgressService { - - private final StringRedisTemplate redisTemplate; - private final ObjectMapper objectMapper; - - public RedisOnlyMeetingProgressServiceAdapter(StringRedisTemplate redisTemplate, ObjectMapper objectMapper) { - this.redisTemplate = redisTemplate; - this.objectMapper = objectMapper == null ? new ObjectMapper() : objectMapper; - } - - @Override - public void clear(Long meetingId) { - if (redisTemplate == null || meetingId == null) { - return; - } - redisTemplate.delete(RedisKeys.meetingProgressKey(meetingId)); - } - - @Override - public Map getProgressMap(Long meetingId) { - MeetingProgressSnapshot snapshot = readSnapshot(meetingId); - if (snapshot == null) { - return Map.of("percent", 0, "message", "Waiting..."); - } - return objectMapper.convertValue(snapshot, Map.class); - } - - @Override - public Map> getProgressMaps(List meetingIds) { - Map> result = new LinkedHashMap<>(); - if (meetingIds == null || meetingIds.isEmpty()) { - return result; - } - for (Long meetingId : meetingIds) { - if (meetingId == null) { - continue; - } - result.put(meetingId, getProgressMap(meetingId)); - } - return result; - } - - @Override - public Integer resolvePercent(Long meetingId) { - MeetingProgressSnapshot snapshot = readSnapshot(meetingId); - return snapshot == null ? null : snapshot.getPercent(); - } - - @Override - public void markQueued(Long meetingId, AiTask task, Integer meetingStatus, String message) { - writeSnapshot(buildSnapshot(meetingId, task, meetingStatus, MeetingProgressStage.QUEUED, 0, message, 0)); - } - - @Override - public void markQueuedAfterCommitOrNow(Long meetingId, AiTask task, Integer meetingStatus, String message) { - markQueued(meetingId, task, meetingStatus, message); - } - - @Override - public void markStage(Long meetingId, AiTask task, Integer meetingStatus, MeetingProgressStage stage, int percent, String message, int eta) { - writeSnapshot(buildSnapshot(meetingId, task, meetingStatus, stage, percent, message, eta)); - } - - @Override - public void markStageAfterCommitOrNow(Long meetingId, AiTask task, Integer meetingStatus, MeetingProgressStage stage, int percent, String message, int eta) { - markStage(meetingId, task, meetingStatus, stage, percent, message, eta); - } - - @Override - public void syncFromDatabase(Long meetingId) { - // No-op for constructor compatibility in tests. - } - - @Override - public void writeSnapshot(MeetingProgressSnapshot snapshot) { - if (redisTemplate == null || snapshot == null || snapshot.getMeetingId() == null) { - return; - } - try { - redisTemplate.opsForValue().set( - RedisKeys.meetingProgressKey(snapshot.getMeetingId()), - objectMapper.writeValueAsString(snapshot), - 1, - TimeUnit.HOURS - ); - } catch (Exception ignored) { - // Compatibility adapter keeps test setup lightweight. - } - } - - private MeetingProgressSnapshot readSnapshot(Long meetingId) { - if (redisTemplate == null || meetingId == null) { - return null; - } - try { - String raw = redisTemplate.opsForValue().get(RedisKeys.meetingProgressKey(meetingId)); - if (raw == null || raw.isBlank()) { - return null; - } - return objectMapper.readValue(raw, MeetingProgressSnapshot.class); - } catch (Exception ignored) { - return null; - } - } - - private MeetingProgressSnapshot buildSnapshot(Long meetingId, - AiTask task, - Integer meetingStatus, - MeetingProgressStage stage, - int percent, - String message, - int eta) { - String resolvedMessage = message; - if (stage == MeetingProgressStage.QUEUED && (resolvedMessage == null || resolvedMessage.isBlank())) { - resolvedMessage = "已进入 ASR 队列,等待执行"; - } - return MeetingProgressSnapshot.builder() - .meetingId(meetingId) - .taskId(task == null ? null : task.getId()) - .taskType(task == null ? null : task.getTaskType()) - .taskStatus(task == null ? null : task.getStatus()) - .meetingStatus(meetingStatus) - .stage(stage.getCode()) - .stageOrder(stage.getOrder()) - .percent(percent) - .message(resolvedMessage) - .eta(eta) - .queuedAt(task == null ? null : task.getQueuedAt()) - .startedAt(task == null ? null : task.getStartedAt()) - .completedAt(task == null ? null : task.getCompletedAt()) - .updateAt(System.currentTimeMillis()) - .build(); - } -} diff --git a/backend/src/main/java/com/imeeting/service/mcp/MeetingMcpToolService.java b/backend/src/main/java/com/imeeting/service/mcp/MeetingMcpToolService.java index d675482..5f9e46f 100644 --- a/backend/src/main/java/com/imeeting/service/mcp/MeetingMcpToolService.java +++ b/backend/src/main/java/com/imeeting/service/mcp/MeetingMcpToolService.java @@ -25,14 +25,12 @@ import com.imeeting.service.biz.MeetingService; import com.imeeting.service.biz.MeetingTranscriptChapterService; import com.imeeting.service.biz.MeetingTranscriptFileService; import com.imeeting.service.biz.PromptTemplateService; -import com.imeeting.service.biz.impl.RedisOnlyMeetingProgressServiceAdapter; import com.unisbase.dto.PageResult; import com.unisbase.entity.SysUser; import com.unisbase.mapper.SysUserMapper; import com.unisbase.security.LoginUser; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Value; -import org.springframework.data.redis.core.StringRedisTemplate; import org.springframework.security.core.Authentication; import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.stereotype.Service; @@ -87,30 +85,6 @@ public class MeetingMcpToolService { this.objectMapper = objectMapper; } - public MeetingMcpToolService(MeetingQueryService meetingQueryService, - MeetingAccessService meetingAccessService, - MeetingService meetingService, - AiTaskService aiTaskService, - PromptTemplateService promptTemplateService, - MeetingTranscriptFileService meetingTranscriptFileService, - MeetingTranscriptChapterService meetingTranscriptChapterService, - SysUserMapper sysUserMapper, - StringRedisTemplate redisTemplate, - ObjectMapper objectMapper) { - this( - meetingQueryService, - meetingAccessService, - meetingService, - aiTaskService, - promptTemplateService, - meetingTranscriptFileService, - meetingTranscriptChapterService, - sysUserMapper, - new RedisOnlyMeetingProgressServiceAdapter(redisTemplate, objectMapper), - objectMapper - ); - } - @Value("${unisbase.app.server-base-url:}") private String serverBaseUrl; diff --git a/backend/src/main/java/com/imeeting/support/RedisSupport.java b/backend/src/main/java/com/imeeting/support/RedisSupport.java new file mode 100644 index 0000000..5c0ab0d --- /dev/null +++ b/backend/src/main/java/com/imeeting/support/RedisSupport.java @@ -0,0 +1,119 @@ +package com.imeeting.support; + +import com.fasterxml.jackson.databind.ObjectMapper; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.stereotype.Component; + +import java.time.Duration; +import java.util.Collection; + +@Component +@Slf4j +@RequiredArgsConstructor +public class RedisSupport { + + private final StringRedisTemplate redisTemplate; + private final ObjectMapper objectMapper; + + public String getStringQuietly(String key) { + try { + return redisTemplate.opsForValue().get(key); + } catch (Exception ex) { + log.warn("读取 Redis 字符串失败, key={}", key, ex); + return null; + } + } + + public T getJsonQuietly(String key, Class type) { + String raw = getStringQuietly(key); + if (raw == null || raw.isBlank()) { + return null; + } + try { + return objectMapper.readValue(raw, type); + } catch (Exception ex) { + log.warn("读取 Redis JSON 失败, key={}, type={}", key, type == null ? null : type.getSimpleName(), ex); + return null; + } + } + + public void setString(String key, String value) { + try { + redisTemplate.opsForValue().set(key, value); + } catch (Exception ex) { + throw new RuntimeException("写入 Redis 字符串失败, key=" + key, ex); + } + } + + public void setString(String key, String value, Duration ttl) { + try { + redisTemplate.opsForValue().set(key, value, ttl); + } catch (Exception ex) { + throw new RuntimeException("写入 Redis 字符串失败, key=" + key, ex); + } + } + + public void setJson(String key, Object value) { + setString(key, writeJson(value)); + } + + public void setJson(String key, Object value, Duration ttl) { + setString(key, writeJson(value), ttl); + } + + public boolean setIfAbsentQuietly(String key, String value, Duration ttl) { + try { + Boolean success = redisTemplate.opsForValue().setIfAbsent(key, value, ttl); + return Boolean.TRUE.equals(success); + } catch (Exception ex) { + log.warn("写入 Redis 锁失败, key={}", key, ex); + return false; + } + } + + public boolean setIfAbsentOrThrow(String key, String value, Duration ttl) { + try { + Boolean success = redisTemplate.opsForValue().setIfAbsent(key, value, ttl); + return Boolean.TRUE.equals(success); + } catch (Exception ex) { + throw new RuntimeException("写入 Redis 锁失败, key=" + key, ex); + } + } + + public void deleteQuietly(String key) { + try { + redisTemplate.delete(key); + } catch (Exception ex) { + log.warn("删除 Redis Key 失败, key={}", key, ex); + } + } + + public void deleteQuietly(Collection keys) { + try { + redisTemplate.delete(keys); + } catch (Exception ex) { + log.warn("批量删除 Redis Key 失败, keys={}", keys, ex); + } + } + + public void removeFromSetQuietly(String key, String... members) { + if (members == null || members.length == 0) { + return; + } + try { + redisTemplate.opsForSet().remove(key, (Object[]) members); + } catch (Exception ex) { + log.warn("从 Redis Set 删除成员失败, key={}", key, ex); + } + } + + private String writeJson(Object value) { + try { + return objectMapper.writeValueAsString(value); + } catch (Exception ex) { + throw new RuntimeException("序列化 Redis JSON 失败", ex); + } + } +} diff --git a/backend/src/main/java/com/imeeting/support/RedisValueSupport.java b/backend/src/main/java/com/imeeting/support/RedisValueSupport.java deleted file mode 100644 index adf408e..0000000 --- a/backend/src/main/java/com/imeeting/support/RedisValueSupport.java +++ /dev/null @@ -1,74 +0,0 @@ -package com.imeeting.support; - -import com.fasterxml.jackson.databind.ObjectMapper; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.springframework.data.redis.core.StringRedisTemplate; -import org.springframework.stereotype.Component; - -import java.util.Collection; -import java.util.concurrent.TimeUnit; - -@Component -@Slf4j -@RequiredArgsConstructor -public class RedisValueSupport { - - private final StringRedisTemplate redisTemplate; - private final ObjectMapper objectMapper; - - public T getJson(String key, Class type) { - String raw = getString(key); - if (raw == null || raw.isBlank()) { - return null; - } - try { - return objectMapper.readValue(raw, type); - } catch (Exception ex) { - log.warn("Failed to parse redis json, key={}", key, ex); - return null; - } - } - - public String getString(String key) { - try { - return redisTemplate.opsForValue().get(key); - } catch (Exception ex) { - log.warn("Failed to get redis value, key={}", key, ex); - return null; - } - } - - public void setJson(String key, Object value, long ttl, TimeUnit unit) { - try { - redisTemplate.opsForValue().set(key, objectMapper.writeValueAsString(value), ttl, unit); - } catch (Exception ex) { - log.warn("Failed to write redis json, key={}", key, ex); - } - } - - public Boolean setIfAbsent(String key, String value, long ttl, TimeUnit unit) { - try { - return redisTemplate.opsForValue().setIfAbsent(key, value, ttl, unit); - } catch (Exception ex) { - log.warn("Failed to acquire redis lock, key={}", key, ex); - return false; - } - } - - public void delete(String key) { - try { - redisTemplate.delete(key); - } catch (Exception ex) { - log.warn("Failed to delete redis key, key={}", key, ex); - } - } - - public void delete(Collection keys) { - try { - redisTemplate.delete(keys); - } catch (Exception ex) { - log.warn("Failed to delete redis keys, keys={}", keys, ex); - } - } -} diff --git a/backend/src/main/java/com/imeeting/support/redis/AndroidChunkUploadSessionCache.java b/backend/src/main/java/com/imeeting/support/redis/AndroidChunkUploadSessionCache.java new file mode 100644 index 0000000..d934ac0 --- /dev/null +++ b/backend/src/main/java/com/imeeting/support/redis/AndroidChunkUploadSessionCache.java @@ -0,0 +1,36 @@ +package com.imeeting.support.redis; + +import com.imeeting.common.RedisKeys; +import com.imeeting.dto.android.AndroidChunkUploadSessionState; +import com.imeeting.support.RedisSupport; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +import java.time.Duration; + +@Component +@RequiredArgsConstructor +public class AndroidChunkUploadSessionCache { + + private static final Duration SESSION_TTL = Duration.ofHours(6); + + private final RedisSupport redisSupport; + + public AndroidChunkUploadSessionState get(String uploadSessionId) { + if (uploadSessionId == null || uploadSessionId.isBlank()) { + return null; + } + return redisSupport.getJsonQuietly(RedisKeys.androidChunkUploadSessionKey(uploadSessionId), AndroidChunkUploadSessionState.class); + } + + public void save(String uploadSessionId, AndroidChunkUploadSessionState state) { + redisSupport.setJson(RedisKeys.androidChunkUploadSessionKey(uploadSessionId), state, SESSION_TTL); + } + + public void clear(String uploadSessionId) { + if (uploadSessionId == null || uploadSessionId.isBlank()) { + return; + } + redisSupport.deleteQuietly(RedisKeys.androidChunkUploadSessionKey(uploadSessionId)); + } +} diff --git a/backend/src/main/java/com/imeeting/support/redis/AndroidDeviceSessionCache.java b/backend/src/main/java/com/imeeting/support/redis/AndroidDeviceSessionCache.java new file mode 100644 index 0000000..aa89978 --- /dev/null +++ b/backend/src/main/java/com/imeeting/support/redis/AndroidDeviceSessionCache.java @@ -0,0 +1,61 @@ +package com.imeeting.support.redis; + +import com.imeeting.common.RedisKeys; +import com.imeeting.dto.android.AndroidDeviceSessionState; +import com.imeeting.support.RedisSupport; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +import java.time.Duration; +import java.util.List; + +@Component +@RequiredArgsConstructor +public class AndroidDeviceSessionCache { + + private final RedisSupport redisSupport; + + public AndroidDeviceSessionState getByConnectionId(String connectionId) { + if (connectionId == null || connectionId.isBlank()) { + return null; + } + return redisSupport.getJsonQuietly(RedisKeys.androidDeviceConnectionKey(connectionId), AndroidDeviceSessionState.class); + } + + public AndroidDeviceSessionState getByDeviceId(String deviceId) { + if (deviceId == null || deviceId.isBlank()) { + return null; + } + return redisSupport.getJsonQuietly(RedisKeys.androidDeviceOnlineKey(deviceId), AndroidDeviceSessionState.class); + } + + public String getActiveConnectionId(String deviceId) { + if (deviceId == null || deviceId.isBlank()) { + return null; + } + String value = redisSupport.getStringQuietly(RedisKeys.androidDeviceActiveConnectionKey(deviceId)); + return value == null || value.isBlank() ? null : value; + } + + public void saveTopics(String deviceId, List topics) { + redisSupport.setJson(RedisKeys.androidDeviceTopicsKey(deviceId), topics == null ? List.of() : topics); + } + + public void saveState(AndroidDeviceSessionState state, Duration ttl) { + redisSupport.setJson(RedisKeys.androidDeviceOnlineKey(state.getDeviceId()), state, ttl); + redisSupport.setString(RedisKeys.androidDeviceActiveConnectionKey(state.getDeviceId()), state.getConnectionId(), ttl); + redisSupport.setJson(RedisKeys.androidDeviceConnectionKey(state.getConnectionId()), state, ttl); + } + + public void deleteConnection(String connectionId) { + redisSupport.deleteQuietly(RedisKeys.androidDeviceConnectionKey(connectionId)); + } + + public void deleteActiveConnection(String deviceId) { + redisSupport.deleteQuietly(RedisKeys.androidDeviceActiveConnectionKey(deviceId)); + } + + public void deleteOnlineState(String deviceId) { + redisSupport.deleteQuietly(RedisKeys.androidDeviceOnlineKey(deviceId)); + } +} diff --git a/backend/src/main/java/com/imeeting/support/redis/AndroidPendingMeetingDraftCache.java b/backend/src/main/java/com/imeeting/support/redis/AndroidPendingMeetingDraftCache.java new file mode 100644 index 0000000..6b8057b --- /dev/null +++ b/backend/src/main/java/com/imeeting/support/redis/AndroidPendingMeetingDraftCache.java @@ -0,0 +1,39 @@ +package com.imeeting.support.redis; + +import com.imeeting.common.RedisKeys; +import com.imeeting.dto.android.AndroidPendingMeetingDraft; +import com.imeeting.support.RedisSupport; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +import java.time.Duration; + +@Component +@RequiredArgsConstructor +public class AndroidPendingMeetingDraftCache { + + private static final Duration DRAFT_TTL = Duration.ofHours(24); + + private final RedisSupport redisSupport; + + public void save(AndroidPendingMeetingDraft draft) { + if (draft == null || draft.getMeetingId() == null) { + return; + } + redisSupport.setJson(RedisKeys.androidPendingMeetingDraftKey(draft.getMeetingId()), draft, DRAFT_TTL); + } + + public AndroidPendingMeetingDraft get(Long meetingId) { + if (meetingId == null) { + return null; + } + return redisSupport.getJsonQuietly(RedisKeys.androidPendingMeetingDraftKey(meetingId), AndroidPendingMeetingDraft.class); + } + + public void clear(Long meetingId) { + if (meetingId == null) { + return; + } + redisSupport.deleteQuietly(RedisKeys.androidPendingMeetingDraftKey(meetingId)); + } +} diff --git a/backend/src/main/java/com/imeeting/support/redis/AndroidPublicMeetingSessionCache.java b/backend/src/main/java/com/imeeting/support/redis/AndroidPublicMeetingSessionCache.java new file mode 100644 index 0000000..bd68605 --- /dev/null +++ b/backend/src/main/java/com/imeeting/support/redis/AndroidPublicMeetingSessionCache.java @@ -0,0 +1,34 @@ +package com.imeeting.support.redis; + +import com.imeeting.common.RedisKeys; +import com.imeeting.dto.android.AndroidPublicMeetingSessionState; +import com.imeeting.support.RedisSupport; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +import java.time.Duration; + +@Component +@RequiredArgsConstructor +public class AndroidPublicMeetingSessionCache { + + private final RedisSupport redisSupport; + + public void save(String sessionId, AndroidPublicMeetingSessionState state, Duration ttl) { + redisSupport.setJson(RedisKeys.publicMeetingSessionKey(sessionId), state, ttl); + } + + public AndroidPublicMeetingSessionState get(String sessionId) { + if (sessionId == null || sessionId.isBlank()) { + return null; + } + return redisSupport.getJsonQuietly(RedisKeys.publicMeetingSessionKey(sessionId), AndroidPublicMeetingSessionState.class); + } + + public void clear(String sessionId) { + if (sessionId == null || sessionId.isBlank()) { + return; + } + redisSupport.deleteQuietly(RedisKeys.publicMeetingSessionKey(sessionId)); + } +} diff --git a/backend/src/main/java/com/imeeting/support/redis/MeetingAsrPermitCache.java b/backend/src/main/java/com/imeeting/support/redis/MeetingAsrPermitCache.java new file mode 100644 index 0000000..5f3303e --- /dev/null +++ b/backend/src/main/java/com/imeeting/support/redis/MeetingAsrPermitCache.java @@ -0,0 +1,32 @@ +package com.imeeting.support.redis; + +import com.imeeting.common.RedisKeys; +import com.imeeting.support.RedisSupport; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +import java.util.List; + +@Component +@RequiredArgsConstructor +public class MeetingAsrPermitCache { + + private final RedisSupport redisSupport; + + public void clearRecoveryState(Long meetingId) { + redisSupport.deleteQuietly(List.of( + RedisKeys.meetingAsrPermitSyncLockKey(), + RedisKeys.meetingAsrRefillLockKey() + )); + if (meetingId != null) { + redisSupport.removeFromSetQuietly(RedisKeys.meetingAsrPermitSetKey(), String.valueOf(meetingId)); + } + } + + public void removePermit(Long meetingId) { + if (meetingId == null) { + return; + } + redisSupport.removeFromSetQuietly(RedisKeys.meetingAsrPermitSetKey(), String.valueOf(meetingId)); + } +} diff --git a/backend/src/main/java/com/imeeting/support/redis/MeetingLockCache.java b/backend/src/main/java/com/imeeting/support/redis/MeetingLockCache.java new file mode 100644 index 0000000..1bd7ca3 --- /dev/null +++ b/backend/src/main/java/com/imeeting/support/redis/MeetingLockCache.java @@ -0,0 +1,55 @@ +package com.imeeting.support.redis; + +import com.imeeting.common.RedisKeys; +import com.imeeting.support.RedisSupport; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +import java.time.Duration; + +@Component +@RequiredArgsConstructor +public class MeetingLockCache { + + private final RedisSupport redisSupport; + + public boolean tryAcquirePollingLock(Long meetingId, Duration ttl) { + return redisSupport.setIfAbsentOrThrow(RedisKeys.meetingPollingLockKey(meetingId), "locked", ttl); + } + + public boolean tryAcquireSummaryLock(Long meetingId, Duration ttl) { + return redisSupport.setIfAbsentOrThrow(RedisKeys.meetingSummaryLockKey(meetingId), "locked", ttl); + } + + public boolean tryAcquireAsrScheduleLock(Duration ttl) { + return redisSupport.setIfAbsentOrThrow(RedisKeys.meetingAsrScheduleLockKey(), "locked", ttl); + } + + public boolean tryAcquireRealtimeTimeoutLock(Long meetingId, Duration ttl) { + return redisSupport.setIfAbsentOrThrow(RedisKeys.realtimeMeetingTimeoutLockKey(meetingId), "1", ttl); + } + + public void releasePollingLock(Long meetingId) { + redisSupport.deleteQuietly(RedisKeys.meetingPollingLockKey(meetingId)); + } + + public void releaseSummaryLock(Long meetingId) { + redisSupport.deleteQuietly(RedisKeys.meetingSummaryLockKey(meetingId)); + } + + public void releaseAsrScheduleLock() { + redisSupport.deleteQuietly(RedisKeys.meetingAsrScheduleLockKey()); + } + + public void releaseRealtimeTimeoutLock(Long meetingId) { + redisSupport.deleteQuietly(RedisKeys.realtimeMeetingTimeoutLockKey(meetingId)); + } + + public void clearDispatchLocks(Long meetingId) { + if (meetingId == null) { + return; + } + redisSupport.deleteQuietly(RedisKeys.meetingPollingLockKey(meetingId)); + redisSupport.deleteQuietly(RedisKeys.meetingSummaryLockKey(meetingId)); + } +} diff --git a/backend/src/main/java/com/imeeting/support/redis/MeetingProgressCache.java b/backend/src/main/java/com/imeeting/support/redis/MeetingProgressCache.java new file mode 100644 index 0000000..da8a9a2 --- /dev/null +++ b/backend/src/main/java/com/imeeting/support/redis/MeetingProgressCache.java @@ -0,0 +1,45 @@ +package com.imeeting.support.redis; + +import com.imeeting.common.RedisKeys; +import com.imeeting.dto.biz.MeetingProgressSnapshot; +import com.imeeting.support.RedisSupport; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; + +import java.time.Duration; + +@Component +@Slf4j +@RequiredArgsConstructor +public class MeetingProgressCache { + + private static final Duration PROGRESS_TTL = Duration.ofHours(1); + + private final RedisSupport redisSupport; + + public MeetingProgressSnapshot getSnapshot(Long meetingId) { + if (meetingId == null) { + return null; + } + return redisSupport.getJsonQuietly(RedisKeys.meetingProgressKey(meetingId), MeetingProgressSnapshot.class); + } + + public void saveSnapshot(MeetingProgressSnapshot snapshot) { + if (snapshot == null || snapshot.getMeetingId() == null) { + return; + } + try { + redisSupport.setJson(RedisKeys.meetingProgressKey(snapshot.getMeetingId()), snapshot, PROGRESS_TTL); + } catch (Exception ex) { + log.warn("写入会议进度缓存失败, meetingId={}", snapshot.getMeetingId(), ex); + } + } + + public void clear(Long meetingId) { + if (meetingId == null) { + return; + } + redisSupport.deleteQuietly(RedisKeys.meetingProgressKey(meetingId)); + } +} diff --git a/backend/src/main/java/com/imeeting/support/redis/RealtimeMeetingSessionCache.java b/backend/src/main/java/com/imeeting/support/redis/RealtimeMeetingSessionCache.java new file mode 100644 index 0000000..0634e04 --- /dev/null +++ b/backend/src/main/java/com/imeeting/support/redis/RealtimeMeetingSessionCache.java @@ -0,0 +1,56 @@ +package com.imeeting.support.redis; + +import com.imeeting.common.RedisKeys; +import com.imeeting.dto.biz.RealtimeMeetingSessionState; +import com.imeeting.support.RedisSupport; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +import java.time.Duration; + +@Component +@RequiredArgsConstructor +public class RealtimeMeetingSessionCache { + + private final RedisSupport redisSupport; + + public RealtimeMeetingSessionState getState(Long meetingId) { + if (meetingId == null) { + return null; + } + return redisSupport.getJsonQuietly(RedisKeys.realtimeMeetingSessionStateKey(meetingId), RealtimeMeetingSessionState.class); + } + + public void saveState(RealtimeMeetingSessionState state) { + if (state == null || state.getMeetingId() == null) { + return; + } + redisSupport.setJson(RedisKeys.realtimeMeetingSessionStateKey(state.getMeetingId()), state); + } + + public void saveResumeTimeout(Long meetingId, Duration ttl) { + redisSupport.setString(RedisKeys.realtimeMeetingResumeTimeoutKey(meetingId), String.valueOf(meetingId), ttl); + } + + public void saveEmptyTimeout(Long meetingId, Duration ttl) { + redisSupport.setString(RedisKeys.realtimeMeetingEmptyTimeoutKey(meetingId), String.valueOf(meetingId), ttl); + } + + public void clearResumeTimeout(Long meetingId) { + redisSupport.deleteQuietly(RedisKeys.realtimeMeetingResumeTimeoutKey(meetingId)); + } + + public void clearEmptyTimeout(Long meetingId) { + redisSupport.deleteQuietly(RedisKeys.realtimeMeetingEmptyTimeoutKey(meetingId)); + } + + public void clearAll(Long meetingId) { + if (meetingId == null) { + return; + } + redisSupport.deleteQuietly(RedisKeys.realtimeMeetingSessionStateKey(meetingId)); + redisSupport.deleteQuietly(RedisKeys.realtimeMeetingResumeTimeoutKey(meetingId)); + redisSupport.deleteQuietly(RedisKeys.realtimeMeetingEmptyTimeoutKey(meetingId)); + redisSupport.deleteQuietly(RedisKeys.realtimeMeetingEventSeqKey(meetingId)); + } +} diff --git a/backend/src/main/java/com/imeeting/support/redis/RealtimeMeetingSocketSessionCache.java b/backend/src/main/java/com/imeeting/support/redis/RealtimeMeetingSocketSessionCache.java new file mode 100644 index 0000000..68f43a0 --- /dev/null +++ b/backend/src/main/java/com/imeeting/support/redis/RealtimeMeetingSocketSessionCache.java @@ -0,0 +1,33 @@ +package com.imeeting.support.redis; + +import com.imeeting.common.RedisKeys; +import com.imeeting.dto.biz.RealtimeSocketSessionData; +import com.imeeting.support.RedisSupport; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +import java.time.Duration; + +@Component +@RequiredArgsConstructor +public class RealtimeMeetingSocketSessionCache { + + private static final Duration SESSION_TTL = Duration.ofMinutes(10); + + private final RedisSupport redisSupport; + + public void save(String sessionToken, RealtimeSocketSessionData sessionData) { + redisSupport.setJson(RedisKeys.realtimeMeetingSocketSessionKey(sessionToken), sessionData, SESSION_TTL); + } + + public RealtimeSocketSessionData get(String sessionToken) { + if (sessionToken == null || sessionToken.isBlank()) { + return null; + } + return redisSupport.getJsonQuietly(RedisKeys.realtimeMeetingSocketSessionKey(sessionToken), RealtimeSocketSessionData.class); + } + + public long getSessionTtlSeconds() { + return SESSION_TTL.toSeconds(); + } +} diff --git a/backend/src/main/java/com/imeeting/task/AndroidPushMessageRetryTask.java b/backend/src/main/java/com/imeeting/task/AndroidPushMessageRetryTask.java new file mode 100644 index 0000000..9137912 --- /dev/null +++ b/backend/src/main/java/com/imeeting/task/AndroidPushMessageRetryTask.java @@ -0,0 +1,45 @@ +package com.imeeting.task; + +import com.imeeting.entity.biz.AndroidPushMessage; +import com.imeeting.grpc.push.PushMessage; +import com.imeeting.service.android.AndroidGatewayPushService; +import com.imeeting.service.android.AndroidPushMessageService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; + +import java.time.LocalDateTime; +import java.util.List; + +@Slf4j +@Component +@RequiredArgsConstructor +public class AndroidPushMessageRetryTask { + private final AndroidPushMessageService androidPushMessageService; + private final AndroidGatewayPushService androidGatewayPushService; + + @Scheduled(fixedDelayString = "${imeeting.android.push.retry-interval-ms:15000}") + public void retryPendingMessages() { + List pendingMessages = androidPushMessageService.listPendingMeetingPushMessages(); + for (AndroidPushMessage message : pendingMessages) { + if (message.getExpireAt() != null && message.getExpireAt().isBefore(LocalDateTime.now())) { + androidPushMessageService.markExpired(message.getId()); + continue; + } + PushMessage pushMessage = PushMessage.newBuilder() + .setMessageId(message.getMessageId()) + .setTimestamp(System.currentTimeMillis()) + .setType(message.getMessageType()) + .setTitle("待开始会议") + .setContent(message.getPayload() == null ? "" : message.getPayload()) + .setNeedAck(true) + .build(); + int pushed = androidGatewayPushService.pushToDevice(message.getDeviceCode(), pushMessage); + if (pushed > 0) { + androidPushMessageService.markPushed(message.getId()); + log.info("Retried android push message, messageId={}, deviceCode={}, pushCountIncreased=true", message.getMessageId(), message.getDeviceCode()); + } + } + } +} diff --git a/frontend/src/api/business/meeting.ts b/frontend/src/api/business/meeting.ts index 44c4e7e..7d2609b 100644 --- a/frontend/src/api/business/meeting.ts +++ b/frontend/src/api/business/meeting.ts @@ -10,6 +10,8 @@ export interface MeetingCreateConfig { offlineEnabled: boolean; realtimeEnabled: boolean; offlineAudioMaxSizeMb: number; + chunkUploadEnabled?: boolean; + chunkDurationSeconds?: number; } export interface MeetingVO { @@ -28,6 +30,8 @@ export interface MeetingVO { playbackAudioUrl?: string; meetingType?: "OFFLINE" | "REALTIME"; meetingSource?: "WEB" | "ANDROID"; + sourceDeviceCode?: string; + sourceDeviceMode?: "PUBLIC" | "PRIVATE"; summaryDetailLevel?: SummaryDetailLevel; audioSaveStatus?: "NONE" | "SUCCESS" | "FAILED"; audioSaveMessage?: string; @@ -95,6 +99,26 @@ export interface CreateMeetingCommand { hotWords?: string[]; } +export interface PublicDeviceMeetingCreateCommand { + title: string; + meetingTime: string; + participants: string; + tags: string; + hostUserId?: number; + hostName?: string; + asrModelId: number; + summaryModelId: number; + chapterModelId?: number; + promptId: number; + hotWordGroupId?: number; + userPrompt?: string; + summaryDetailLevel?: SummaryDetailLevel; + useSpkId?: number; + enableTextRefine?: boolean; + hotWords?: string[]; + accessPassword?: string; +} + export type MeetingDTO = CreateMeetingCommand; export interface CreateRealtimeMeetingCommand { @@ -160,6 +184,13 @@ export const createMeeting = (data: CreateMeetingCommand) => { ); }; +export const createPublicDeviceMeetingBySession = (sessionId: string, data: PublicDeviceMeetingCreateCommand) => { + return http.post<{ code: string; data: MeetingVO; msg: string }>( + `/api/biz/public-device-meetings/sessions/${sessionId}/create`, + data + ); +}; + export interface RealtimeTranscriptItemDTO { speakerId?: string; speakerName?: string; diff --git a/frontend/src/pages/auth/login/index.tsx b/frontend/src/pages/auth/login/index.tsx index 7290f19..fe61326 100644 --- a/frontend/src/pages/auth/login/index.tsx +++ b/frontend/src/pages/auth/login/index.tsx @@ -101,7 +101,9 @@ export default function Login() { } message.success(t("common.success")); - window.location.href = "/"; + const searchParams = new URLSearchParams(window.location.search); + const redirect = searchParams.get("redirect"); + window.location.href = redirect || "/"; } catch { if (captchaEnabled) { await loadCaptcha(); diff --git a/frontend/src/pages/business/Meetings.tsx b/frontend/src/pages/business/Meetings.tsx index 311c327..9bd5160 100644 --- a/frontend/src/pages/business/Meetings.tsx +++ b/frontend/src/pages/business/Meetings.tsx @@ -239,8 +239,9 @@ const MeetingCardItem: React.FC<{ progress: MeetingProgress | null; onOpenMeeting: (meeting: MeetingVO) => void; onRetrySchedule: (meeting: MeetingVO) => void; + onDelete: (id: number) => void; retrying: boolean; -}> = ({ item, config, progress, onOpenMeeting, onRetrySchedule, retrying }) => { +}> = ({ item, config, progress, onOpenMeeting, onRetrySchedule, onDelete, retrying }) => { const effectiveStatus = getEffectiveStatus(item, progress); const isProcessing = shouldTrackGenerationProgress(item); const isPaused = effectiveStatus === PAUSED_DISPLAY_STATUS; @@ -310,7 +311,7 @@ const MeetingCardItem: React.FC<{ deleteMeeting(item.id).then(fetchData)} + onConfirm={() => onDelete(item.id)} okText="删除" cancelText="取消" okButtonProps={{ danger: true }} @@ -905,6 +906,7 @@ const Meetings: React.FC = () => { progress={progress} onOpenMeeting={handleOpenMeeting} onRetrySchedule={(meeting) => { void handleRetrySchedule(meeting); }} + onDelete={(id) => { deleteMeeting(id).then(() => { message.success('删除成功'); fetchData(); }) }} retrying={!!retryingMeetingIds[item.id]} /> ); diff --git a/frontend/src/routes/index.tsx b/frontend/src/routes/index.tsx index 8e7874c..ffbb53c 100644 --- a/frontend/src/routes/index.tsx +++ b/frontend/src/routes/index.tsx @@ -1,5 +1,5 @@ import { Suspense, lazy } from "react"; -import { Navigate, Route, Routes } from "react-router-dom"; +import { Navigate, Route, Routes, useLocation } from "react-router-dom"; import AppLayout from "@/layouts/AppLayout"; import { useAuth } from "@/hooks/useAuth"; import { menuRoutes,extraRoutes } from "./routes"; @@ -7,6 +7,7 @@ import { menuRoutes,extraRoutes } from "./routes"; const Login = lazy(() => import("@/pages/auth/login")); const ResetPassword = lazy(() => import("@/pages/auth/reset-password")); const MeetingPreview = lazy(() => import("@/pages/business/MeetingPreview")); +const PublicDeviceMeetingCreate = lazy(() => import("@/pages/business/PublicDeviceMeetingCreate")); function RouteFallback() { let platformName = "iMeeting"; @@ -50,9 +51,11 @@ function RouteFallback() { function RequireAuth({ children }: { children: JSX.Element }) { const { isAuthed, profile } = useAuth(); + const location = useLocation(); if (!isAuthed) { - return ; + const redirect = encodeURIComponent(`${location.pathname}${location.search}`); + return ; } if (profile?.pwdResetRequired === 1) { @@ -72,6 +75,14 @@ export default function AppRoutes() { path="/meetings/:id/preview" element={} /> + + + + } + />