diff --git a/backend/src/main/java/com/imeeting/controller/android/AndroidMeetingRealtimeController.java b/backend/src/main/java/com/imeeting/controller/android/AndroidMeetingRealtimeController.java index b0f55ed..3d8d4c5 100644 --- a/backend/src/main/java/com/imeeting/controller/android/AndroidMeetingRealtimeController.java +++ b/backend/src/main/java/com/imeeting/controller/android/AndroidMeetingRealtimeController.java @@ -1,14 +1,24 @@ package com.imeeting.controller.android; +import com.imeeting.config.grpc.GrpcServerProperties; +import com.imeeting.dto.android.AndroidAuthContext; +import com.imeeting.dto.android.AndroidCreateRealtimeMeetingCommand; +import com.imeeting.dto.android.AndroidCreateRealtimeMeetingVO; import com.imeeting.dto.android.AndroidOpenRealtimeGrpcSessionCommand; import com.imeeting.dto.android.AndroidRealtimeGrpcSessionVO; +import com.imeeting.dto.biz.CreateRealtimeMeetingCommand; import com.imeeting.dto.biz.MeetingTranscriptVO; +import com.imeeting.dto.biz.RealtimeMeetingRuntimeProfile; +import com.imeeting.dto.biz.MeetingVO; import com.imeeting.dto.biz.RealtimeMeetingCompleteDTO; import com.imeeting.dto.biz.RealtimeMeetingSessionStatusVO; +import com.imeeting.entity.biz.Meeting; import com.imeeting.service.android.AndroidAuthService; import com.imeeting.service.biz.MeetingAccessService; +import com.imeeting.service.biz.MeetingAuthorizationService; import com.imeeting.service.biz.MeetingCommandService; import com.imeeting.service.biz.MeetingQueryService; +import com.imeeting.service.biz.MeetingRuntimeProfileResolver; import com.imeeting.service.biz.RealtimeMeetingSessionStateService; import com.imeeting.service.realtime.AndroidRealtimeSessionTicketService; import com.unisbase.common.ApiResponse; @@ -21,6 +31,8 @@ import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; import java.util.List; @RestController @@ -28,31 +40,86 @@ import java.util.List; @RequiredArgsConstructor public class AndroidMeetingRealtimeController { + private static final DateTimeFormatter TITLE_TIME_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"); + private final AndroidAuthService androidAuthService; private final MeetingAccessService meetingAccessService; + private final MeetingAuthorizationService meetingAuthorizationService; private final MeetingQueryService meetingQueryService; private final MeetingCommandService meetingCommandService; private final RealtimeMeetingSessionStateService realtimeMeetingSessionStateService; private final AndroidRealtimeSessionTicketService androidRealtimeSessionTicketService; + private final MeetingRuntimeProfileResolver meetingRuntimeProfileResolver; + private final GrpcServerProperties grpcServerProperties; + + @PostMapping("/realtime/create") + public ApiResponse createRealtimeMeeting(HttpServletRequest request, + @RequestBody(required = false) AndroidCreateRealtimeMeetingCommand command) { + AndroidAuthContext authContext = androidAuthService.authenticateHttp(request); + meetingAuthorizationService.assertCanCreateMeeting(authContext); + RealtimeMeetingRuntimeProfile runtimeProfile = meetingRuntimeProfileResolver.resolve( + authContext.getTenantId(), + command == null ? null : command.getAsrModelId(), + command == null ? null : command.getSummaryModelId(), + command == null ? null : command.getPromptId(), + command == null ? null : command.getMode(), + command == null ? null : command.getLanguage(), + command == null ? null : command.getUseSpkId(), + command == null ? null : command.getEnablePunctuation(), + command == null ? null : command.getEnableItn(), + command == null ? null : command.getEnableTextRefine(), + command == null ? null : command.getSaveAudio(), + command == null ? null : command.getHotWords() + ); + CreateRealtimeMeetingCommand createCommand = buildCreateCommand(command, authContext, runtimeProfile); + MeetingVO meeting = meetingCommandService.createRealtimeMeeting( + createCommand, + authContext.getTenantId(), + authContext.getUserId(), + resolveCreatorName(authContext) + ); + + RealtimeMeetingSessionStatusVO status = realtimeMeetingSessionStateService.getStatus(meeting.getId()); + AndroidCreateRealtimeMeetingVO vo = new AndroidCreateRealtimeMeetingVO(); + vo.setMeetingId(meeting.getId()); + vo.setTitle(meeting.getTitle()); + vo.setHostUserId(meeting.getHostUserId()); + vo.setHostName(meeting.getHostName()); + vo.setSampleRate(grpcServerProperties.getRealtime().getSampleRate()); + vo.setChannels(grpcServerProperties.getRealtime().getChannels()); + vo.setEncoding(grpcServerProperties.getRealtime().getEncoding()); + vo.setResolvedAsrModelId(runtimeProfile.getResolvedAsrModelId()); + vo.setResolvedAsrModelName(runtimeProfile.getResolvedAsrModelName()); + vo.setResolvedSummaryModelId(runtimeProfile.getResolvedSummaryModelId()); + vo.setResolvedSummaryModelName(runtimeProfile.getResolvedSummaryModelName()); + vo.setResolvedPromptId(runtimeProfile.getResolvedPromptId()); + vo.setResolvedPromptName(runtimeProfile.getResolvedPromptName()); + vo.setResumeConfig(status == null ? null : status.getResumeConfig()); + vo.setStatus(status); + return ApiResponse.ok(vo); + } @GetMapping("/{id}/realtime/session-status") public ApiResponse getRealtimeSessionStatus(@PathVariable Long id, HttpServletRequest request) { - androidAuthService.authenticateHttp(request); - meetingAccessService.requireMeeting(id); + AndroidAuthContext authContext = androidAuthService.authenticateHttp(request); + Meeting meeting = meetingAccessService.requireMeeting(id); + meetingAuthorizationService.assertCanManageRealtimeMeeting(meeting, authContext); return ApiResponse.ok(realtimeMeetingSessionStateService.getStatus(id)); } @GetMapping("/{id}/transcripts") public ApiResponse> getTranscripts(@PathVariable Long id, HttpServletRequest request) { - androidAuthService.authenticateHttp(request); - meetingAccessService.requireMeeting(id); + AndroidAuthContext authContext = androidAuthService.authenticateHttp(request); + Meeting meeting = meetingAccessService.requireMeeting(id); + meetingAuthorizationService.assertCanViewMeeting(meeting, authContext); return ApiResponse.ok(meetingQueryService.getTranscripts(id)); } @PostMapping("/{id}/realtime/pause") public ApiResponse pauseRealtimeMeeting(@PathVariable Long id, HttpServletRequest request) { - androidAuthService.authenticateHttp(request); - meetingAccessService.requireMeeting(id); + AndroidAuthContext authContext = androidAuthService.authenticateHttp(request); + Meeting meeting = meetingAccessService.requireMeeting(id); + meetingAuthorizationService.assertCanManageRealtimeMeeting(meeting, authContext); return ApiResponse.ok(realtimeMeetingSessionStateService.pause(id)); } @@ -60,9 +127,14 @@ public class AndroidMeetingRealtimeController { public ApiResponse completeRealtimeMeeting(@PathVariable Long id, HttpServletRequest request, @RequestBody(required = false) RealtimeMeetingCompleteDTO dto) { - androidAuthService.authenticateHttp(request); - meetingAccessService.requireMeeting(id); - meetingCommandService.completeRealtimeMeeting(id, dto != null ? dto.getAudioUrl() : null); + AndroidAuthContext authContext = androidAuthService.authenticateHttp(request); + Meeting meeting = meetingAccessService.requireMeeting(id); + meetingAuthorizationService.assertCanManageRealtimeMeeting(meeting, authContext); + meetingCommandService.completeRealtimeMeeting( + id, + dto != null ? dto.getAudioUrl() : null, + dto != null && Boolean.TRUE.equals(dto.getOverwriteAudio()) + ); return ApiResponse.ok(true); } @@ -70,6 +142,86 @@ public class AndroidMeetingRealtimeController { public ApiResponse openRealtimeGrpcSession(@PathVariable Long id, HttpServletRequest request, @RequestBody(required = false) AndroidOpenRealtimeGrpcSessionCommand command) { - return ApiResponse.ok(androidRealtimeSessionTicketService.createSession(id, command, androidAuthService.authenticateHttp(request))); + AndroidAuthContext authContext = androidAuthService.authenticateHttp(request); + Meeting meeting = meetingAccessService.requireMeeting(id); + meetingAuthorizationService.assertCanManageRealtimeMeeting(meeting, authContext); + return ApiResponse.ok(androidRealtimeSessionTicketService.createSession(id, command, authContext)); + } + + private CreateRealtimeMeetingCommand buildCreateCommand(AndroidCreateRealtimeMeetingCommand command, + AndroidAuthContext authContext, + RealtimeMeetingRuntimeProfile runtimeProfile) { + CreateRealtimeMeetingCommand createCommand = new CreateRealtimeMeetingCommand(); + LocalDateTime meetingTime = command != null && command.getMeetingTime() != null ? command.getMeetingTime() : LocalDateTime.now(); + createCommand.setTitle(resolveMeetingTitle(command, meetingTime)); + createCommand.setMeetingTime(meetingTime); + createCommand.setParticipants(command == null ? "" : normalize(command.getParticipants(), "")); + createCommand.setTags(command == null ? "" : normalize(command.getTags())); + createCommand.setHostUserId(resolveHostUserId(command, authContext)); + createCommand.setHostName(resolveHostName(command, authContext, createCommand.getHostUserId())); + createCommand.setAsrModelId(runtimeProfile.getResolvedAsrModelId()); + createCommand.setSummaryModelId(runtimeProfile.getResolvedSummaryModelId()); + createCommand.setPromptId(runtimeProfile.getResolvedPromptId()); + createCommand.setMode(runtimeProfile.getResolvedMode()); + createCommand.setLanguage(runtimeProfile.getResolvedLanguage()); + createCommand.setUseSpkId(runtimeProfile.getResolvedUseSpkId()); + createCommand.setEnablePunctuation(runtimeProfile.getResolvedEnablePunctuation()); + createCommand.setEnableItn(runtimeProfile.getResolvedEnableItn()); + createCommand.setEnableTextRefine(runtimeProfile.getResolvedEnableTextRefine()); + createCommand.setSaveAudio(runtimeProfile.getResolvedSaveAudio()); + createCommand.setHotWords(runtimeProfile.getResolvedHotWords()); + return createCommand; + } + + private String resolveMeetingTitle(AndroidCreateRealtimeMeetingCommand command, LocalDateTime meetingTime) { + String title = command == null ? null : normalize(command.getTitle()); + if (title != null && !title.isBlank()) { + return title; + } + return "Android-Realtime-Meeting-" + TITLE_TIME_FORMATTER.format(meetingTime); + } + + private Long resolveHostUserId(AndroidCreateRealtimeMeetingCommand command, AndroidAuthContext authContext) { + if (command != null && command.getHostUserId() != null) { + return command.getHostUserId(); + } + return authContext.getUserId(); + } + + private String resolveHostName(AndroidCreateRealtimeMeetingCommand command, AndroidAuthContext authContext, Long hostUserId) { + if (command != null && command.getHostName() != null && !command.getHostName().isBlank()) { + return command.getHostName().trim(); + } + if (hostUserId != null && hostUserId.equals(authContext.getUserId())) { + return resolveCreatorName(authContext); + } + return null; + } + + private String resolveCreatorName(AndroidAuthContext authContext) { + if (authContext == null) { + return "android"; + } + if (authContext.getDisplayName() != null && !authContext.getDisplayName().isBlank()) { + return authContext.getDisplayName().trim(); + } + if (authContext.getUsername() != null && !authContext.getUsername().isBlank()) { + return authContext.getUsername().trim(); + } + return authContext.getDeviceId() == null || authContext.getDeviceId().isBlank() + ? "android" + : "android:" + authContext.getDeviceId().trim(); + } + + private String normalize(String value) { + return normalize(value, null); + } + + private String normalize(String value, String defaultValue) { + if (value == null) { + return defaultValue; + } + String normalized = value.trim(); + return normalized.isEmpty() ? defaultValue : normalized; } } 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 1bb4e89..89054da 100644 --- a/backend/src/main/java/com/imeeting/controller/biz/MeetingController.java +++ b/backend/src/main/java/com/imeeting/controller/biz/MeetingController.java @@ -310,7 +310,11 @@ public class MeetingController { LoginUser loginUser = currentLoginUser(); Meeting meeting = meetingAccessService.requireMeeting(id); meetingAccessService.assertCanManageRealtimeMeeting(meeting, loginUser); - meetingCommandService.completeRealtimeMeeting(id, dto != null ? dto.getAudioUrl() : null); + meetingCommandService.completeRealtimeMeeting( + id, + dto != null ? dto.getAudioUrl() : null, + dto != null && Boolean.TRUE.equals(dto.getOverwriteAudio()) + ); return ApiResponse.ok(true); } diff --git a/backend/src/main/java/com/imeeting/dto/android/AndroidAuthContext.java b/backend/src/main/java/com/imeeting/dto/android/AndroidAuthContext.java index 537de0a..cce8f5f 100644 --- a/backend/src/main/java/com/imeeting/dto/android/AndroidAuthContext.java +++ b/backend/src/main/java/com/imeeting/dto/android/AndroidAuthContext.java @@ -2,11 +2,20 @@ package com.imeeting.dto.android; import lombok.Data; +import java.util.Set; + @Data public class AndroidAuthContext { private String authMode; private String deviceId; + private Long tenantId; private String tenantCode; + private Long userId; + private String username; + private String displayName; + private Boolean platformAdmin; + private Boolean tenantAdmin; + private Set permissions; private String appId; private String appVersion; private String platform; diff --git a/backend/src/main/java/com/imeeting/dto/android/AndroidCreateRealtimeMeetingCommand.java b/backend/src/main/java/com/imeeting/dto/android/AndroidCreateRealtimeMeetingCommand.java new file mode 100644 index 0000000..f3fb902 --- /dev/null +++ b/backend/src/main/java/com/imeeting/dto/android/AndroidCreateRealtimeMeetingCommand.java @@ -0,0 +1,31 @@ +package com.imeeting.dto.android; + +import com.fasterxml.jackson.annotation.JsonFormat; +import lombok.Data; + +import java.time.LocalDateTime; +import java.util.List; + +@Data +public class AndroidCreateRealtimeMeetingCommand { + private String title; + + @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") + private LocalDateTime meetingTime; + + private String participants; + private String tags; + private Long hostUserId; + private String hostName; + private Long asrModelId; + private Long summaryModelId; + private Long promptId; + private String mode; + private String language; + private Integer useSpkId; + private Boolean enablePunctuation; + private Boolean enableItn; + private Boolean enableTextRefine; + private Boolean saveAudio; + private List hotWords; +} diff --git a/backend/src/main/java/com/imeeting/dto/android/AndroidCreateRealtimeMeetingVO.java b/backend/src/main/java/com/imeeting/dto/android/AndroidCreateRealtimeMeetingVO.java new file mode 100644 index 0000000..7cd6bde --- /dev/null +++ b/backend/src/main/java/com/imeeting/dto/android/AndroidCreateRealtimeMeetingVO.java @@ -0,0 +1,24 @@ +package com.imeeting.dto.android; + +import com.imeeting.dto.biz.RealtimeMeetingResumeConfig; +import com.imeeting.dto.biz.RealtimeMeetingSessionStatusVO; +import lombok.Data; + +@Data +public class AndroidCreateRealtimeMeetingVO { + private Long meetingId; + private String title; + private Long hostUserId; + private String hostName; + private Integer sampleRate; + private Integer channels; + private String encoding; + private Long resolvedAsrModelId; + private String resolvedAsrModelName; + private Long resolvedSummaryModelId; + private String resolvedSummaryModelName; + private Long resolvedPromptId; + private String resolvedPromptName; + private RealtimeMeetingResumeConfig resumeConfig; + private RealtimeMeetingSessionStatusVO status; +} diff --git a/backend/src/main/java/com/imeeting/dto/biz/CreateMeetingCommand.java b/backend/src/main/java/com/imeeting/dto/biz/CreateMeetingCommand.java index 26ec77e..ec0f66d 100644 --- a/backend/src/main/java/com/imeeting/dto/biz/CreateMeetingCommand.java +++ b/backend/src/main/java/com/imeeting/dto/biz/CreateMeetingCommand.java @@ -10,23 +10,30 @@ import java.util.List; @Data public class CreateMeetingCommand { - @NotBlank(message = "会议标题不能为空") + @NotBlank(message = "title must not be blank") private String title; - @NotNull(message = "会议时间不能为空") + @NotNull(message = "meetingTime must not be null") @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") private LocalDateTime meetingTime; private String participants; private String tags; - @NotBlank(message = "音频地址不能为空") + private Long hostUserId; + private String hostName; + + @NotBlank(message = "audioUrl must not be blank") private String audioUrl; - @NotNull(message = "识别模型不能为空") + + @NotNull(message = "asrModelId must not be null") private Long asrModelId; - @NotNull(message = "总结模型不能为空") + + @NotNull(message = "summaryModelId must not be null") private Long summaryModelId; - @NotNull(message = "提示词模板不能为空") + + @NotNull(message = "promptId must not be null") private Long promptId; + private Integer useSpkId; private Boolean enableTextRefine; private List hotWords; diff --git a/backend/src/main/java/com/imeeting/dto/biz/CreateRealtimeMeetingCommand.java b/backend/src/main/java/com/imeeting/dto/biz/CreateRealtimeMeetingCommand.java index cac6005..d6b764a 100644 --- a/backend/src/main/java/com/imeeting/dto/biz/CreateRealtimeMeetingCommand.java +++ b/backend/src/main/java/com/imeeting/dto/biz/CreateRealtimeMeetingCommand.java @@ -10,21 +10,27 @@ import java.util.List; @Data public class CreateRealtimeMeetingCommand { - @NotBlank(message = "会议标题不能为空") + @NotBlank(message = "title must not be blank") private String title; - @NotNull(message = "会议时间不能为空") + @NotNull(message = "meetingTime must not be null") @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") private LocalDateTime meetingTime; private String participants; private String tags; - @NotNull(message = "识别模型不能为空") + private Long hostUserId; + private String hostName; + + @NotNull(message = "asrModelId must not be null") private Long asrModelId; - @NotNull(message = "总结模型不能为空") + + @NotNull(message = "summaryModelId must not be null") private Long summaryModelId; - @NotNull(message = "提示词模板不能为空") + + @NotNull(message = "promptId must not be null") private Long promptId; + private String mode; private String language; private Integer useSpkId; 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 0af0993..97cbb03 100644 --- a/backend/src/main/java/com/imeeting/dto/biz/MeetingVO.java +++ b/backend/src/main/java/com/imeeting/dto/biz/MeetingVO.java @@ -12,6 +12,8 @@ public class MeetingVO { private Long tenantId; private Long creatorId; private String creatorName; + private Long hostUserId; + private String hostName; private String title; @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") @@ -21,6 +23,8 @@ public class MeetingVO { private List participantIds; private String tags; private String audioUrl; + private String audioSaveStatus; + private String audioSaveMessage; private Integer duration; private String summaryContent; private Map analysis; diff --git a/backend/src/main/java/com/imeeting/dto/biz/RealtimeMeetingCompleteDTO.java b/backend/src/main/java/com/imeeting/dto/biz/RealtimeMeetingCompleteDTO.java index 8629b1f..55c9450 100644 --- a/backend/src/main/java/com/imeeting/dto/biz/RealtimeMeetingCompleteDTO.java +++ b/backend/src/main/java/com/imeeting/dto/biz/RealtimeMeetingCompleteDTO.java @@ -4,5 +4,6 @@ import lombok.Data; @Data public class RealtimeMeetingCompleteDTO { + private Boolean overwriteAudio; private String audioUrl; } diff --git a/backend/src/main/java/com/imeeting/dto/biz/RealtimeMeetingRuntimeProfile.java b/backend/src/main/java/com/imeeting/dto/biz/RealtimeMeetingRuntimeProfile.java new file mode 100644 index 0000000..2e516e3 --- /dev/null +++ b/backend/src/main/java/com/imeeting/dto/biz/RealtimeMeetingRuntimeProfile.java @@ -0,0 +1,23 @@ +package com.imeeting.dto.biz; + +import lombok.Data; + +import java.util.List; + +@Data +public class RealtimeMeetingRuntimeProfile { + private Long resolvedAsrModelId; + private String resolvedAsrModelName; + private Long resolvedSummaryModelId; + private String resolvedSummaryModelName; + private Long resolvedPromptId; + private String resolvedPromptName; + private String resolvedMode; + private String resolvedLanguage; + private Integer resolvedUseSpkId; + private Boolean resolvedEnablePunctuation; + private Boolean resolvedEnableItn; + private Boolean resolvedEnableTextRefine; + private Boolean resolvedSaveAudio; + private List resolvedHotWords; +} 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 bd14bb1..68db852 100644 --- a/backend/src/main/java/com/imeeting/entity/biz/Meeting.java +++ b/backend/src/main/java/com/imeeting/entity/biz/Meeting.java @@ -27,10 +27,18 @@ public class Meeting extends BaseEntity { private String audioUrl; + private String audioSaveStatus; + + private String audioSaveMessage; + private Long creatorId; private String creatorName; + private Long hostUserId; + + private String hostName; + private Long latestSummaryTaskId; @TableField(exist = false) diff --git a/backend/src/main/java/com/imeeting/grpc/realtime/RealtimeMeetingGrpcService.java b/backend/src/main/java/com/imeeting/grpc/realtime/RealtimeMeetingGrpcService.java index 6e3f9c5..3a0d385 100644 --- a/backend/src/main/java/com/imeeting/grpc/realtime/RealtimeMeetingGrpcService.java +++ b/backend/src/main/java/com/imeeting/grpc/realtime/RealtimeMeetingGrpcService.java @@ -50,7 +50,12 @@ public class RealtimeMeetingGrpcService extends RealtimeMeetingServiceGrpc.Realt private void handleOpen(RealtimeClientPacket packet) { authContext = androidAuthService.authenticateGrpc(packet.getAuth(), null); - connectionId = realtimeMeetingGrpcSessionService.openStream(packet.getOpen().getStreamToken(), authContext, responseObserver); + connectionId = realtimeMeetingGrpcSessionService.openStream( + packet.getOpen().getMeetingId(), + packet.getOpen().getStreamToken(), + authContext, + responseObserver + ); } private void handleAudio(AudioChunk audioChunk) { diff --git a/backend/src/main/java/com/imeeting/listener/RealtimeMeetingSessionExpirationListener.java b/backend/src/main/java/com/imeeting/listener/RealtimeMeetingSessionExpirationListener.java index 2e2f9ff..3aa62a2 100644 --- a/backend/src/main/java/com/imeeting/listener/RealtimeMeetingSessionExpirationListener.java +++ b/backend/src/main/java/com/imeeting/listener/RealtimeMeetingSessionExpirationListener.java @@ -44,7 +44,7 @@ public class RealtimeMeetingSessionExpirationListener extends KeyExpirationEvent if (expiredKey.startsWith(RedisKeys.realtimeMeetingResumeTimeoutPrefix())) { Long meetingId = parseMeetingId(expiredKey, RedisKeys.realtimeMeetingResumeTimeoutPrefix()); if (meetingId != null && realtimeMeetingSessionStateService.markCompletingIfResumeExpired(meetingId)) { - meetingCommandService.completeRealtimeMeeting(meetingId, null); + meetingCommandService.completeRealtimeMeeting(meetingId, null, false); } return; } 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 e0e4f3a..c6a290f 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 @@ -4,8 +4,13 @@ import com.imeeting.config.grpc.AndroidGrpcAuthProperties; import com.imeeting.dto.android.AndroidAuthContext; import com.imeeting.grpc.common.ClientAuth; import com.imeeting.service.android.AndroidAuthService; +import com.unisbase.dto.InternalAuthCheckResponse; +import com.unisbase.security.LoginUser; +import com.unisbase.service.TokenValidationService; import jakarta.servlet.http.HttpServletRequest; import lombok.RequiredArgsConstructor; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.stereotype.Service; import org.springframework.util.StringUtils; @@ -19,15 +24,23 @@ public class AndroidAuthServiceImpl implements AndroidAuthService { private static final String HEADER_APP_ID = "X-Android-App-Id"; private static final String HEADER_APP_VERSION = "X-Android-App-Version"; private static final String HEADER_PLATFORM = "X-Android-Platform"; + private static final String HEADER_AUTHORIZATION = "Authorization"; + private static final String BEARER_PREFIX = "Bearer "; private final AndroidGrpcAuthProperties properties; + private final TokenValidationService tokenValidationService; @Override public AndroidAuthContext authenticateGrpc(ClientAuth auth, String fallbackDeviceId) { ClientAuth.AuthType authType = auth == null ? ClientAuth.AuthType.AUTH_TYPE_UNSPECIFIED : auth.getAuthType(); - if (authType == ClientAuth.AuthType.DEVICE_TOKEN || authType == ClientAuth.AuthType.USER_JWT) { - return buildContext(authType.name(), false, auth.getDeviceId(), auth.getTenantCode(), auth.getAppId(), - auth.getAppVersion(), auth.getPlatform(), auth.getAccessToken(), fallbackDeviceId); + if (authType == ClientAuth.AuthType.USER_JWT) { + InternalAuthCheckResponse authResult = validateToken(auth == null ? null : auth.getAccessToken()); + return buildContext(authType.name(), false, auth == null ? null : auth.getDeviceId(), auth == null ? null : auth.getTenantCode(), auth == null ? null : auth.getAppId(), + auth == null ? null : auth.getAppVersion(), auth == null ? null : auth.getPlatform(), auth == null ? null : auth.getAccessToken(), fallbackDeviceId, authResult, null); + } + if (authType == ClientAuth.AuthType.DEVICE_TOKEN) { + return buildContext(authType.name(), false, auth == null ? null : auth.getDeviceId(), auth == null ? null : auth.getTenantCode(), auth == null ? null : auth.getAppId(), + auth == null ? null : auth.getAppVersion(), auth == null ? null : auth.getPlatform(), auth == null ? null : auth.getAccessToken(), fallbackDeviceId, null, null); } if (properties.isEnabled() && !properties.isAllowAnonymous()) { throw new RuntimeException("Android gRPC auth is required"); @@ -39,25 +52,46 @@ public class AndroidAuthServiceImpl implements AndroidAuthService { auth == null ? null : auth.getAppVersion(), auth == null ? null : auth.getPlatform(), auth == null ? null : auth.getAccessToken(), - fallbackDeviceId); + fallbackDeviceId, + null, + null); } @Override public AndroidAuthContext authenticateHttp(HttpServletRequest request) { - if (properties.isEnabled() && !properties.isAllowAnonymous()) { - String token = request.getHeader(HEADER_ACCESS_TOKEN); - if (!StringUtils.hasText(token)) { - throw new RuntimeException("Android HTTP auth is required"); - } - return buildContext("DEVICE_TOKEN", false, + LoginUser loginUser = currentLoginUser(); + String resolvedToken = resolveHttpToken(request); + if (loginUser != null) { + return buildContext("USER_JWT", false, request.getHeader(HEADER_DEVICE_ID), request.getHeader(HEADER_TENANT_CODE), request.getHeader(HEADER_APP_ID), request.getHeader(HEADER_APP_VERSION), request.getHeader(HEADER_PLATFORM), - token, + resolvedToken, + null, + null, + loginUser); + } + + if (StringUtils.hasText(resolvedToken)) { + InternalAuthCheckResponse authResult = validateToken(resolvedToken); + return buildContext("USER_JWT", false, + request.getHeader(HEADER_DEVICE_ID), + request.getHeader(HEADER_TENANT_CODE), + request.getHeader(HEADER_APP_ID), + request.getHeader(HEADER_APP_VERSION), + request.getHeader(HEADER_PLATFORM), + resolvedToken, + null, + authResult, null); } + + if (properties.isEnabled() && !properties.isAllowAnonymous()) { + throw new RuntimeException("Android HTTP auth is required"); + } + return buildContext("NONE", true, request.getHeader(HEADER_DEVICE_ID), request.getHeader(HEADER_TENANT_CODE), @@ -65,12 +99,14 @@ public class AndroidAuthServiceImpl implements AndroidAuthService { request.getHeader(HEADER_APP_VERSION), request.getHeader(HEADER_PLATFORM), request.getHeader(HEADER_ACCESS_TOKEN), + null, + null, null); } private AndroidAuthContext buildContext(String authMode, boolean anonymous, String deviceId, String tenantCode, String appId, String appVersion, String platform, String accessToken, - String fallbackDeviceId) { + String fallbackDeviceId, InternalAuthCheckResponse authResult, LoginUser loginUser) { String resolvedDeviceId = StringUtils.hasText(deviceId) ? deviceId : fallbackDeviceId; if (!StringUtils.hasText(resolvedDeviceId)) { throw new RuntimeException("Android deviceId is required"); @@ -84,6 +120,75 @@ public class AndroidAuthServiceImpl implements AndroidAuthService { context.setAppVersion(StringUtils.hasText(appVersion) ? appVersion.trim() : null); context.setPlatform(StringUtils.hasText(platform) ? platform.trim() : "android"); context.setAccessToken(StringUtils.hasText(accessToken) ? accessToken.trim() : null); + applyIdentity(context, authResult, loginUser); return context; } + + private void applyIdentity(AndroidAuthContext context, InternalAuthCheckResponse authResult, LoginUser loginUser) { + if (loginUser != null) { + context.setUserId(loginUser.getUserId()); + context.setTenantId(loginUser.getTenantId()); + context.setUsername(loginUser.getUsername()); + context.setDisplayName(loginUser.getDisplayName()); + context.setPlatformAdmin(Boolean.TRUE.equals(loginUser.getIsPlatformAdmin())); + context.setTenantAdmin(Boolean.TRUE.equals(loginUser.getIsTenantAdmin())); + context.setPermissions(loginUser.getPermissions()); + return; + } + if (authResult == null) { + return; + } + context.setUserId(authResult.getUserId()); + context.setTenantId(authResult.getTenantId()); + context.setUsername(authResult.getUsername()); + context.setDisplayName(authResult.getUsername()); + context.setPlatformAdmin(Boolean.TRUE.equals(authResult.getPlatformAdmin())); + context.setTenantAdmin(Boolean.TRUE.equals(authResult.getTenantAdmin())); + context.setPermissions(authResult.getPermissions()); + } + + private InternalAuthCheckResponse validateToken(String token) { + String resolvedToken = normalizeToken(token); + if (!StringUtils.hasText(resolvedToken)) { + throw new RuntimeException("Android access token is required"); + } + InternalAuthCheckResponse authResult = tokenValidationService.validateAccessToken(resolvedToken); + if (authResult == null || !authResult.isValid()) { + throw new RuntimeException(authResult == null || !StringUtils.hasText(authResult.getMessage()) ? "Android access token is invalid" : authResult.getMessage()); + } + if (authResult.getUserId() == null || authResult.getTenantId() == null) { + throw new RuntimeException("Android access token missing user or tenant context"); + } + return authResult; + } + + private String resolveHttpToken(HttpServletRequest request) { + String authorization = request.getHeader(HEADER_AUTHORIZATION); + if (StringUtils.hasText(authorization) && authorization.startsWith(BEARER_PREFIX)) { + return authorization.substring(BEARER_PREFIX.length()).trim(); + } + return normalizeToken(request.getHeader(HEADER_ACCESS_TOKEN)); + } + + private String normalizeToken(String token) { + if (!StringUtils.hasText(token)) { + return null; + } + String resolved = token.trim(); + if (resolved.startsWith(BEARER_PREFIX)) { + resolved = resolved.substring(BEARER_PREFIX.length()).trim(); + } + return resolved; + } + + private LoginUser currentLoginUser() { + Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); + if (authentication == null || !(authentication.getPrincipal() instanceof LoginUser loginUser)) { + return null; + } + if (loginUser.getUserId() == null || loginUser.getTenantId() == null) { + return null; + } + return loginUser; + } } diff --git a/backend/src/main/java/com/imeeting/service/biz/MeetingAuthorizationService.java b/backend/src/main/java/com/imeeting/service/biz/MeetingAuthorizationService.java new file mode 100644 index 0000000..c0bba2d --- /dev/null +++ b/backend/src/main/java/com/imeeting/service/biz/MeetingAuthorizationService.java @@ -0,0 +1,12 @@ +package com.imeeting.service.biz; + +import com.imeeting.dto.android.AndroidAuthContext; +import com.imeeting.entity.biz.Meeting; + +public interface MeetingAuthorizationService { + void assertCanCreateMeeting(AndroidAuthContext authContext); + + void assertCanViewMeeting(Meeting meeting, AndroidAuthContext authContext); + + void assertCanManageRealtimeMeeting(Meeting meeting, AndroidAuthContext authContext); +} 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 74c768f..a41a88f 100644 --- a/backend/src/main/java/com/imeeting/service/biz/MeetingCommandService.java +++ b/backend/src/main/java/com/imeeting/service/biz/MeetingCommandService.java @@ -18,7 +18,9 @@ public interface MeetingCommandService { void appendRealtimeTranscripts(Long meetingId, List items); - void completeRealtimeMeeting(Long meetingId, String audioUrl); + void saveRealtimeTranscriptSnapshot(Long meetingId, RealtimeTranscriptItemDTO item, boolean finalResult); + + void completeRealtimeMeeting(Long meetingId, String audioUrl, boolean overwriteAudio); void updateSpeakerInfo(Long meetingId, String speakerId, String newName, String label); diff --git a/backend/src/main/java/com/imeeting/service/biz/MeetingRuntimeProfileResolver.java b/backend/src/main/java/com/imeeting/service/biz/MeetingRuntimeProfileResolver.java new file mode 100644 index 0000000..668cb53 --- /dev/null +++ b/backend/src/main/java/com/imeeting/service/biz/MeetingRuntimeProfileResolver.java @@ -0,0 +1,20 @@ +package com.imeeting.service.biz; + +import com.imeeting.dto.biz.RealtimeMeetingRuntimeProfile; + +import java.util.List; + +public interface MeetingRuntimeProfileResolver { + RealtimeMeetingRuntimeProfile resolve(Long tenantId, + Long asrModelId, + Long summaryModelId, + Long promptId, + String mode, + String language, + Integer useSpkId, + Boolean enablePunctuation, + Boolean enableItn, + Boolean enableTextRefine, + Boolean saveAudio, + List hotWords); +} diff --git a/backend/src/main/java/com/imeeting/service/biz/impl/MeetingAuthorizationServiceImpl.java b/backend/src/main/java/com/imeeting/service/biz/impl/MeetingAuthorizationServiceImpl.java new file mode 100644 index 0000000..3807caa --- /dev/null +++ b/backend/src/main/java/com/imeeting/service/biz/impl/MeetingAuthorizationServiceImpl.java @@ -0,0 +1,47 @@ +package com.imeeting.service.biz.impl; + +import com.imeeting.dto.android.AndroidAuthContext; +import com.imeeting.entity.biz.Meeting; +import com.imeeting.service.biz.MeetingAccessService; +import com.imeeting.service.biz.MeetingAuthorizationService; +import com.unisbase.security.LoginUser; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +public class MeetingAuthorizationServiceImpl implements MeetingAuthorizationService { + + private final MeetingAccessService meetingAccessService; + + @Override + public void assertCanCreateMeeting(AndroidAuthContext authContext) { + requireUser(authContext); + } + + @Override + public void assertCanViewMeeting(Meeting meeting, AndroidAuthContext authContext) { + meetingAccessService.assertCanViewMeeting(meeting, requireUser(authContext)); + } + + @Override + public void assertCanManageRealtimeMeeting(Meeting meeting, AndroidAuthContext authContext) { + meetingAccessService.assertCanManageRealtimeMeeting(meeting, requireUser(authContext)); + } + + private LoginUser requireUser(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; + } +} 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 7cb8b3e..7dec192 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 @@ -8,6 +8,7 @@ import com.imeeting.dto.biz.CreateMeetingCommand; import com.imeeting.dto.biz.CreateRealtimeMeetingCommand; import com.imeeting.dto.biz.MeetingVO; import com.imeeting.dto.biz.RealtimeMeetingResumeConfig; +import com.imeeting.dto.biz.RealtimeMeetingSessionStatusVO; import com.imeeting.dto.biz.RealtimeTranscriptItemDTO; import com.imeeting.dto.biz.UpdateMeetingBasicCommand; import com.imeeting.dto.biz.UpdateMeetingTranscriptCommand; @@ -21,10 +22,13 @@ import com.imeeting.service.biz.MeetingCommandService; import com.imeeting.service.biz.MeetingService; import com.imeeting.service.biz.MeetingSummaryFileService; import com.imeeting.service.biz.RealtimeMeetingSessionStateService; +import com.imeeting.service.realtime.RealtimeMeetingAudioStorageService; import lombok.RequiredArgsConstructor; import org.springframework.data.redis.core.StringRedisTemplate; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import org.springframework.transaction.support.TransactionSynchronization; +import org.springframework.transaction.support.TransactionSynchronizationManager; import java.math.BigDecimal; import java.math.RoundingMode; @@ -46,14 +50,17 @@ public class MeetingCommandServiceImpl implements MeetingCommandService { private final MeetingSummaryFileService meetingSummaryFileService; private final MeetingDomainSupport meetingDomainSupport; private final RealtimeMeetingSessionStateService realtimeMeetingSessionStateService; + private final RealtimeMeetingAudioStorageService realtimeMeetingAudioStorageService; private final StringRedisTemplate redisTemplate; private final ObjectMapper objectMapper; @Override @Transactional(rollbackFor = Exception.class) public MeetingVO createMeeting(CreateMeetingCommand command, Long tenantId, Long creatorId, String creatorName) { + Long hostUserId = resolveHostUserId(command.getHostUserId(), creatorId); + String hostName = resolveHostName(command.getHostName(), creatorName, creatorId, hostUserId); Meeting meeting = meetingDomainSupport.initMeeting(command.getTitle(), command.getMeetingTime(), command.getParticipants(), command.getTags(), - command.getAudioUrl(), tenantId, creatorId, creatorName, 0); + command.getAudioUrl(), tenantId, creatorId, creatorName, hostUserId, hostName, 0); meetingService.save(meeting); AiTask asrTask = new AiTask(); @@ -92,8 +99,10 @@ public class MeetingCommandServiceImpl implements MeetingCommandService { @Override @Transactional(rollbackFor = Exception.class) public MeetingVO createRealtimeMeeting(CreateRealtimeMeetingCommand command, Long tenantId, Long creatorId, String creatorName) { + Long hostUserId = resolveHostUserId(command.getHostUserId(), creatorId); + String hostName = resolveHostName(command.getHostName(), creatorName, creatorId, hostUserId); Meeting meeting = meetingDomainSupport.initMeeting(command.getTitle(), command.getMeetingTime(), command.getParticipants(), command.getTags(), - null, tenantId, creatorId, creatorName, 0); + null, tenantId, creatorId, creatorName, hostUserId, hostName, 0); meetingService.save(meeting); meetingDomainSupport.createSummaryTask(meeting.getId(), command.getSummaryModelId(), command.getPromptId()); realtimeMeetingSessionStateService.initSessionIfAbsent(meeting.getId(), tenantId, creatorId); @@ -164,14 +173,79 @@ public class MeetingCommandServiceImpl implements MeetingCommandService { @Override @Transactional(rollbackFor = Exception.class) - public void completeRealtimeMeeting(Long meetingId, String audioUrl) { + public void saveRealtimeTranscriptSnapshot(Long meetingId, RealtimeTranscriptItemDTO item, boolean finalResult) { + if (item == null || item.getContent() == null || item.getContent().isBlank()) { + return; + } + + String speakerId = meetingDomainSupport.resolveSpeakerId(item.getSpeakerId()); + String speakerName = meetingDomainSupport.resolveSpeakerName(item.getSpeakerId(), item.getSpeakerName()); + String content = item.getContent().trim(); + + MeetingTranscript latest = transcriptMapper.selectOne(new LambdaQueryWrapper() + .eq(MeetingTranscript::getMeetingId, meetingId) + .orderByDesc(MeetingTranscript::getSortOrder) + .last("LIMIT 1")); + + if (isSameRealtimeSegment(latest, speakerId, item.getStartTime(), content)) { + transcriptMapper.update(null, new LambdaUpdateWrapper() + .eq(MeetingTranscript::getId, latest.getId()) + .set(MeetingTranscript::getSpeakerId, speakerId) + .set(MeetingTranscript::getSpeakerName, speakerName) + .set(MeetingTranscript::getContent, content) + .set(item.getStartTime() != null, MeetingTranscript::getStartTime, item.getStartTime()) + .set(item.getEndTime() != null, MeetingTranscript::getEndTime, item.getEndTime())); + realtimeMeetingSessionStateService.refreshAfterTranscript(meetingId); + return; + } + + Integer maxSortOrder = transcriptMapper.selectList(new LambdaQueryWrapper() + .eq(MeetingTranscript::getMeetingId, meetingId) + .orderByDesc(MeetingTranscript::getSortOrder) + .last("LIMIT 1")) + .stream() + .findFirst() + .map(MeetingTranscript::getSortOrder) + .orElse(0); + + MeetingTranscript transcript = new MeetingTranscript(); + transcript.setMeetingId(meetingId); + transcript.setSpeakerId(speakerId); + transcript.setSpeakerName(speakerName); + transcript.setContent(content); + transcript.setStartTime(item.getStartTime()); + transcript.setEndTime(item.getEndTime()); + transcript.setSortOrder(maxSortOrder == null ? 0 : maxSortOrder + 1); + transcriptMapper.insert(transcript); + realtimeMeetingSessionStateService.refreshAfterTranscript(meetingId); + } + + @Override + @Transactional(rollbackFor = Exception.class) + public void completeRealtimeMeeting(Long meetingId, String audioUrl, boolean overwriteAudio) { Meeting meeting = meetingService.getById(meetingId); if (meeting == null) { throw new RuntimeException("Meeting not found"); } + RealtimeMeetingSessionStatusVO currentStatus = realtimeMeetingSessionStateService.getStatus(meetingId); + if (overwriteAudio) { + if (audioUrl == null || audioUrl.isBlank()) { + throw new RuntimeException("Audio URL is required when overwriteAudio is true"); + } + meeting.setAudioUrl(meetingDomainSupport.relocateAudioUrl(meetingId, audioUrl)); + markAudioSaveSuccess(meeting); + meetingService.updateById(meeting); + prepareOfflineReprocessTasks(meetingId, currentStatus); + realtimeMeetingSessionStateService.clear(meetingId); + updateMeetingProgress(meetingId, 0, "正在转入离线音频识别流程...", 0); + aiTaskService.dispatchTasks(meetingId); + return; + } + if (audioUrl != null && !audioUrl.isBlank()) { meeting.setAudioUrl(meetingDomainSupport.relocateAudioUrl(meetingId, audioUrl)); + markAudioSaveSuccess(meeting); meetingService.updateById(meeting); } @@ -182,6 +256,12 @@ public class MeetingCommandServiceImpl implements MeetingCommandService { throw new RuntimeException("当前还没有转录内容,无法结束会议。请先开始识别,或直接离开页面稍后继续。"); } + if ((audioUrl == null || audioUrl.isBlank()) && (meeting.getAudioUrl() == null || meeting.getAudioUrl().isBlank())) { + applyRealtimeAudioFinalizeResult(meeting, realtimeMeetingAudioStorageService.finalizeMeetingAudio(meetingId)); + } else if (meeting.getAudioUrl() != null && !meeting.getAudioUrl().isBlank()) { + markAudioSaveSuccess(meeting); + } + realtimeMeetingSessionStateService.clear(meetingId); meeting.setStatus(2); meetingService.updateById(meeting); @@ -189,6 +269,121 @@ public class MeetingCommandServiceImpl implements MeetingCommandService { aiTaskService.dispatchSummaryTask(meetingId); } + private void applyRealtimeAudioFinalizeResult(Meeting meeting, RealtimeMeetingAudioStorageService.FinalizeResult result) { + if (result == null) { + markAudioSaveFailure(meeting, RealtimeMeetingAudioStorageService.DEFAULT_FAILURE_MESSAGE); + return; + } + if (result.audioUrl() != null && !result.audioUrl().isBlank()) { + meeting.setAudioUrl(result.audioUrl()); + } + if (result.failed()) { + markAudioSaveFailure(meeting, result.message()); + } else if (result.success()) { + markAudioSaveSuccess(meeting); + } + } + + private void markAudioSaveSuccess(Meeting meeting) { + meeting.setAudioSaveStatus(RealtimeMeetingAudioStorageService.STATUS_SUCCESS); + meeting.setAudioSaveMessage(null); + } + + private void markAudioSaveFailure(Meeting meeting, String message) { + meeting.setAudioSaveStatus(RealtimeMeetingAudioStorageService.STATUS_FAILED); + meeting.setAudioSaveMessage(message == null || message.isBlank() + ? RealtimeMeetingAudioStorageService.DEFAULT_FAILURE_MESSAGE + : message); + } + + private void prepareOfflineReprocessTasks(Long meetingId, RealtimeMeetingSessionStatusVO currentStatus) { + RealtimeMeetingResumeConfig resumeConfig = currentStatus == null ? null : currentStatus.getResumeConfig(); + if (resumeConfig == null || resumeConfig.getAsrModelId() == null) { + throw new RuntimeException("Realtime resume config missing, cannot overwrite audio"); + } + + AiTask asrTask = aiTaskService.getOne(new LambdaQueryWrapper() + .eq(AiTask::getMeetingId, meetingId) + .eq(AiTask::getTaskType, "ASR") + .orderByDesc(AiTask::getId) + .last("LIMIT 1")); + + Map asrConfig = new HashMap<>(); + asrConfig.put("asrModelId", resumeConfig.getAsrModelId()); + asrConfig.put("useSpkId", resumeConfig.getUseSpkId() != null ? resumeConfig.getUseSpkId() : 1); + asrConfig.put("enableTextRefine", Boolean.TRUE.equals(resumeConfig.getEnableTextRefine())); + asrConfig.put("hotWords", extractOfflineHotwords(resumeConfig.getHotwords())); + + if (asrTask == null) { + asrTask = new AiTask(); + asrTask.setMeetingId(meetingId); + asrTask.setTaskType("ASR"); + asrTask.setStatus(0); + asrTask.setTaskConfig(asrConfig); + aiTaskService.save(asrTask); + } else { + resetAiTask(asrTask, asrConfig); + aiTaskService.updateById(asrTask); + } + + AiTask summaryTask = aiTaskService.getOne(new LambdaQueryWrapper() + .eq(AiTask::getMeetingId, meetingId) + .eq(AiTask::getTaskType, "SUMMARY") + .orderByDesc(AiTask::getId) + .last("LIMIT 1")); + if (summaryTask == null) { + throw new RuntimeException("Summary task missing, cannot continue offline process"); + } + + resetAiTask(summaryTask, summaryTask.getTaskConfig()); + aiTaskService.updateById(summaryTask); + } + + private void resetAiTask(AiTask task, Map taskConfig) { + task.setStatus(0); + task.setTaskConfig(taskConfig); + task.setRequestData(null); + task.setResponseData(null); + task.setResultFilePath(null); + task.setErrorMsg(null); + task.setStartedAt(null); + task.setCompletedAt(null); + } + + private List extractOfflineHotwords(List> hotwords) { + if (hotwords == null || hotwords.isEmpty()) { + return List.of(); + } + return hotwords.stream() + .map(item -> item == null ? null : item.get("hotword")) + .filter(Objects::nonNull) + .map(String::valueOf) + .map(String::trim) + .filter(word -> !word.isEmpty()) + .distinct() + .toList(); + } + + private boolean isSameRealtimeSegment(MeetingTranscript latest, String speakerId, Integer startTime, String content) { + if (latest == null) { + return false; + } + if (!Objects.equals(latest.getSpeakerId(), speakerId)) { + return false; + } + if (startTime != null && latest.getStartTime() != null) { + if (Math.abs(latest.getStartTime() - startTime) <= 1500) { + return true; + } + } + + String latestContent = latest.getContent(); + if (latestContent == null || latestContent.isBlank()) { + return false; + } + return content.startsWith(latestContent) || latestContent.startsWith(content); + } + @Override @Transactional(rollbackFor = Exception.class) public void updateSpeakerInfo(Long meetingId, String speakerId, String newName, String label) { @@ -255,7 +450,20 @@ public class MeetingCommandServiceImpl implements MeetingCommandService { meetingDomainSupport.createSummaryTask(meetingId, summaryModelId, promptId); meeting.setStatus(2); meetingService.updateById(meeting); - aiTaskService.dispatchSummaryTask(meetingId); + dispatchSummaryTaskAfterCommit(meetingId); + } + + private void dispatchSummaryTaskAfterCommit(Long meetingId) { + if (!TransactionSynchronizationManager.isSynchronizationActive()) { + aiTaskService.dispatchSummaryTask(meetingId); + return; + } + TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() { + @Override + public void afterCommit() { + aiTaskService.dispatchSummaryTask(meetingId); + } + }); } private void updateMeetingProgress(Long meetingId, int percent, String message, int eta) { @@ -319,4 +527,18 @@ public class MeetingCommandServiceImpl implements MeetingCommandService { item.put("weight", BigDecimal.valueOf(rawWeight).divide(BigDecimal.TEN, 2, RoundingMode.HALF_UP).doubleValue()); return item; } + + private Long resolveHostUserId(Long requestedHostUserId, Long creatorId) { + return requestedHostUserId != null ? requestedHostUserId : creatorId; + } + + private String resolveHostName(String requestedHostName, String creatorName, Long creatorId, Long hostUserId) { + if (requestedHostName != null && !requestedHostName.isBlank()) { + return requestedHostName.trim(); + } + if (hostUserId != null && Objects.equals(hostUserId, creatorId)) { + return creatorName; + } + return null; + } } 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 8a84f49..0532805 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 @@ -10,6 +10,7 @@ import com.imeeting.mapper.biz.MeetingTranscriptMapper; import com.imeeting.service.biz.AiTaskService; import com.imeeting.service.biz.MeetingSummaryFileService; import com.imeeting.service.biz.PromptTemplateService; +import com.imeeting.service.realtime.RealtimeMeetingAudioStorageService; import com.unisbase.entity.SysUser; import com.unisbase.mapper.SysUserMapper; import lombok.RequiredArgsConstructor; @@ -49,7 +50,8 @@ public class MeetingDomainSupport { private String uploadPath; public Meeting initMeeting(String title, LocalDateTime meetingTime, String participants, String tags, - String audioUrl, Long tenantId, Long creatorId, String creatorName, int status) { + String audioUrl, Long tenantId, Long creatorId, String creatorName, + Long hostUserId, String hostName, int status) { Meeting meeting = new Meeting(); meeting.setTitle(title); meeting.setMeetingTime(meetingTime); @@ -57,8 +59,11 @@ public class MeetingDomainSupport { meeting.setTags(tags); meeting.setCreatorId(creatorId); meeting.setCreatorName(creatorName); + meeting.setHostUserId(hostUserId); + meeting.setHostName(hostName); meeting.setTenantId(tenantId != null ? tenantId : 0L); meeting.setAudioUrl(audioUrl); + meeting.setAudioSaveStatus(RealtimeMeetingAudioStorageService.STATUS_NONE); meeting.setStatus(status); return meeting; } @@ -227,10 +232,14 @@ public class MeetingDomainSupport { vo.setTenantId(meeting.getTenantId()); vo.setCreatorId(meeting.getCreatorId()); vo.setCreatorName(meeting.getCreatorName()); + vo.setHostUserId(meeting.getHostUserId()); + vo.setHostName(meeting.getHostName()); vo.setTitle(meeting.getTitle()); vo.setMeetingTime(meeting.getMeetingTime()); vo.setTags(meeting.getTags()); vo.setAudioUrl(meeting.getAudioUrl()); + vo.setAudioSaveStatus(meeting.getAudioSaveStatus()); + vo.setAudioSaveMessage(meeting.getAudioSaveMessage()); vo.setDuration(resolveMeetingDuration(meeting.getId())); vo.setStatus(meeting.getStatus()); vo.setCreatedAt(meeting.getCreatedAt()); diff --git a/backend/src/main/java/com/imeeting/service/biz/impl/MeetingExportServiceImpl.java b/backend/src/main/java/com/imeeting/service/biz/impl/MeetingExportServiceImpl.java index 77e319b..60ba0dd 100644 --- a/backend/src/main/java/com/imeeting/service/biz/impl/MeetingExportServiceImpl.java +++ b/backend/src/main/java/com/imeeting/service/biz/impl/MeetingExportServiceImpl.java @@ -95,6 +95,9 @@ public class MeetingExportServiceImpl implements MeetingExportService { XWPFParagraph timeP = document.createParagraph(); timeP.createRun().setText("Meeting Time: " + String.valueOf(meeting.getMeetingTime())); + XWPFParagraph hostP = document.createParagraph(); + hostP.createRun().setText("Host: " + (meeting.getHostName() == null ? "" : meeting.getHostName())); + XWPFParagraph participantsP = document.createParagraph(); participantsP.createRun().setText("Participants: " + (meeting.getParticipants() == null ? "" : meeting.getParticipants())); @@ -130,6 +133,7 @@ public class MeetingExportServiceImpl implements MeetingExportService { String title = meeting.getTitle() == null ? "Meeting" : meeting.getTitle(); String time = meeting.getMeetingTime() == null ? "" : meeting.getMeetingTime().toString(); + String host = meeting.getHostName() == null ? "" : meeting.getHostName(); String participants = meeting.getParticipants() == null ? "" : meeting.getParticipants(); String html = "