feat: 添加实时会议创建和验证测试
- 添加 `MeetingCreateCommandValidationTest` 以验证创建会议命令的必填字段 - 添加 `MeetingRuntimeProfileResolverImplTest` 以测试运行时配置解析 - 添加 `MeetingCommandServiceImplTest` 以测试会议命令服务的逻辑 - 添加 `AndroidAuthServiceImplTest` 以测试 Android 认证服务 - 更新 `MeetingCommandService` 接口,添加 `saveRealtimeTranscriptSnapshot` 和更新 `completeRealtimeMeeting` 方法 - 在 `AndroidMeetingRealtimeController` 中添加创建实时会议的 API 端点 - 定义 `AndroidCreateRealtimeMeetingCommand` 和 `AndroidCreateRealtimeMeetingVO` 数据传输对象dev_na
parent
24c3835b79
commit
135203b9f6
|
|
@ -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<AndroidCreateRealtimeMeetingVO> 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<RealtimeMeetingSessionStatusVO> 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<List<MeetingTranscriptVO>> 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<RealtimeMeetingSessionStatusVO> 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<Boolean> 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<AndroidRealtimeGrpcSessionVO> 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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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<String> permissions;
|
||||
private String appId;
|
||||
private String appVersion;
|
||||
private String platform;
|
||||
|
|
|
|||
|
|
@ -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<String> hotWords;
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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<String> hotWords;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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<Long> participantIds;
|
||||
private String tags;
|
||||
private String audioUrl;
|
||||
private String audioSaveStatus;
|
||||
private String audioSaveMessage;
|
||||
private Integer duration;
|
||||
private String summaryContent;
|
||||
private Map<String, Object> analysis;
|
||||
|
|
|
|||
|
|
@ -4,5 +4,6 @@ import lombok.Data;
|
|||
|
||||
@Data
|
||||
public class RealtimeMeetingCompleteDTO {
|
||||
private Boolean overwriteAudio;
|
||||
private String audioUrl;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<String> resolvedHotWords;
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -18,7 +18,9 @@ public interface MeetingCommandService {
|
|||
|
||||
void appendRealtimeTranscripts(Long meetingId, List<RealtimeTranscriptItemDTO> 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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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<String> hotWords);
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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<MeetingTranscript>()
|
||||
.eq(MeetingTranscript::getMeetingId, meetingId)
|
||||
.orderByDesc(MeetingTranscript::getSortOrder)
|
||||
.last("LIMIT 1"));
|
||||
|
||||
if (isSameRealtimeSegment(latest, speakerId, item.getStartTime(), content)) {
|
||||
transcriptMapper.update(null, new LambdaUpdateWrapper<MeetingTranscript>()
|
||||
.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<MeetingTranscript>()
|
||||
.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<AiTask>()
|
||||
.eq(AiTask::getMeetingId, meetingId)
|
||||
.eq(AiTask::getTaskType, "ASR")
|
||||
.orderByDesc(AiTask::getId)
|
||||
.last("LIMIT 1"));
|
||||
|
||||
Map<String, Object> 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<AiTask>()
|
||||
.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<String, Object> 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<String> extractOfflineHotwords(List<Map<String, Object>> 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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
|
|
|
|||
|
|
@ -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 = "<html><head><style>" +
|
||||
|
|
@ -146,6 +150,8 @@ public class MeetingExportServiceImpl implements MeetingExportService {
|
|||
"<div style='font-size:14px; color:#666;'>" +
|
||||
"<span>Meeting Time: " + time + "</span>" +
|
||||
"<span style='margin: 0 20px;'>|</span>" +
|
||||
"<span>Host: " + host + "</span>" +
|
||||
"<span style='margin: 0 20px;'>|</span>" +
|
||||
"<span>Participants: " + participants + "</span>" +
|
||||
"</div></div>" +
|
||||
"<div class='markdown-body'>" + htmlBody + "</div>" +
|
||||
|
|
|
|||
|
|
@ -0,0 +1,176 @@
|
|||
package com.imeeting.service.biz.impl;
|
||||
|
||||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||
import com.imeeting.dto.biz.AiModelVO;
|
||||
import com.imeeting.dto.biz.RealtimeMeetingRuntimeProfile;
|
||||
import com.imeeting.entity.biz.AsrModel;
|
||||
import com.imeeting.entity.biz.LlmModel;
|
||||
import com.imeeting.entity.biz.PromptTemplate;
|
||||
import com.imeeting.mapper.biz.AsrModelMapper;
|
||||
import com.imeeting.mapper.biz.LlmModelMapper;
|
||||
import com.imeeting.service.biz.AiModelService;
|
||||
import com.imeeting.service.biz.MeetingRuntimeProfileResolver;
|
||||
import com.imeeting.service.biz.PromptTemplateService;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class MeetingRuntimeProfileResolverImpl implements MeetingRuntimeProfileResolver {
|
||||
|
||||
private final AiModelService aiModelService;
|
||||
private final PromptTemplateService promptTemplateService;
|
||||
private final AsrModelMapper asrModelMapper;
|
||||
private final LlmModelMapper llmModelMapper;
|
||||
|
||||
@Override
|
||||
public 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<String> hotWords) {
|
||||
long resolvedTenantId = tenantId == null ? 0L : tenantId;
|
||||
AiModelVO asrModel = resolveModel("ASR", asrModelId, resolvedTenantId);
|
||||
AiModelVO summaryModel = resolveModel("LLM", summaryModelId, resolvedTenantId);
|
||||
PromptTemplate promptTemplate = resolvePrompt(promptId, resolvedTenantId);
|
||||
|
||||
RealtimeMeetingRuntimeProfile profile = new RealtimeMeetingRuntimeProfile();
|
||||
profile.setResolvedAsrModelId(asrModel.getId());
|
||||
profile.setResolvedAsrModelName(asrModel.getModelName());
|
||||
profile.setResolvedSummaryModelId(summaryModel.getId());
|
||||
profile.setResolvedSummaryModelName(summaryModel.getModelName());
|
||||
profile.setResolvedPromptId(promptTemplate.getId());
|
||||
profile.setResolvedPromptName(promptTemplate.getTemplateName());
|
||||
profile.setResolvedMode(nonBlank(mode, "2pass"));
|
||||
profile.setResolvedLanguage(nonBlank(language, "auto"));
|
||||
profile.setResolvedUseSpkId(useSpkId != null ? useSpkId : 1);
|
||||
profile.setResolvedEnablePunctuation(enablePunctuation != null ? enablePunctuation : Boolean.TRUE);
|
||||
profile.setResolvedEnableItn(enableItn != null ? enableItn : Boolean.TRUE);
|
||||
profile.setResolvedEnableTextRefine(Boolean.TRUE.equals(enableTextRefine));
|
||||
profile.setResolvedSaveAudio(Boolean.TRUE.equals(saveAudio));
|
||||
profile.setResolvedHotWords(hotWords == null ? List.of() : hotWords.stream()
|
||||
.filter(Objects::nonNull)
|
||||
.map(String::trim)
|
||||
.filter(item -> !item.isEmpty())
|
||||
.distinct()
|
||||
.toList());
|
||||
return profile;
|
||||
}
|
||||
|
||||
private AiModelVO resolveModel(String type, Long requestedId, Long tenantId) {
|
||||
AiModelVO model;
|
||||
if (requestedId != null) {
|
||||
model = aiModelService.getModelById(requestedId, type);
|
||||
if (model == null) {
|
||||
throw new RuntimeException(type + " 模型不存在");
|
||||
}
|
||||
assertModelAvailable(model, tenantId, type);
|
||||
return model;
|
||||
}
|
||||
|
||||
model = aiModelService.getDefaultModel(type, tenantId);
|
||||
if (model != null) {
|
||||
assertModelAvailable(model, tenantId, type);
|
||||
return model;
|
||||
}
|
||||
|
||||
Long firstEnabledId = "ASR".equals(type) ? findFirstEnabledAsrModelId(tenantId) : findFirstEnabledLlmModelId(tenantId);
|
||||
if (firstEnabledId == null) {
|
||||
throw new RuntimeException(type + " 默认模型未配置");
|
||||
}
|
||||
model = aiModelService.getModelById(firstEnabledId, type);
|
||||
if (model == null) {
|
||||
throw new RuntimeException(type + " 默认模型未配置");
|
||||
}
|
||||
assertModelAvailable(model, tenantId, type);
|
||||
return model;
|
||||
}
|
||||
|
||||
private void assertModelAvailable(AiModelVO model, Long tenantId, String type) {
|
||||
if (model.getTenantId() != null && !Objects.equals(model.getTenantId(), tenantId) && !Objects.equals(model.getTenantId(), 0L)) {
|
||||
throw new RuntimeException(type + " 模型不属于当前租户");
|
||||
}
|
||||
if (!Integer.valueOf(1).equals(model.getStatus())) {
|
||||
throw new RuntimeException(type + " 模型未启用");
|
||||
}
|
||||
}
|
||||
|
||||
private Long findFirstEnabledAsrModelId(Long tenantId) {
|
||||
AsrModel entity = asrModelMapper.selectOne(new LambdaQueryWrapper<AsrModel>()
|
||||
.eq(AsrModel::getStatus, 1)
|
||||
.and(wrapper -> wrapper.eq(AsrModel::getTenantId, tenantId).or().eq(AsrModel::getTenantId, 0L))
|
||||
.orderByDesc(AsrModel::getTenantId)
|
||||
.orderByDesc(AsrModel::getIsDefault)
|
||||
.orderByDesc(AsrModel::getCreatedAt)
|
||||
.last("LIMIT 1"));
|
||||
return entity == null ? null : entity.getId();
|
||||
}
|
||||
|
||||
private Long findFirstEnabledLlmModelId(Long tenantId) {
|
||||
LlmModel entity = llmModelMapper.selectOne(new LambdaQueryWrapper<LlmModel>()
|
||||
.eq(LlmModel::getStatus, 1)
|
||||
.and(wrapper -> wrapper.eq(LlmModel::getTenantId, tenantId).or().eq(LlmModel::getTenantId, 0L))
|
||||
.orderByDesc(LlmModel::getTenantId)
|
||||
.orderByDesc(LlmModel::getIsDefault)
|
||||
.orderByDesc(LlmModel::getCreatedAt)
|
||||
.last("LIMIT 1"));
|
||||
return entity == null ? null : entity.getId();
|
||||
}
|
||||
|
||||
private PromptTemplate resolvePrompt(Long requestedId, Long tenantId) {
|
||||
if (requestedId != null) {
|
||||
PromptTemplate template = promptTemplateService.getById(requestedId);
|
||||
if (template == null) {
|
||||
throw new RuntimeException("提示词模板不存在");
|
||||
}
|
||||
assertPromptAvailable(template, tenantId);
|
||||
return template;
|
||||
}
|
||||
|
||||
PromptTemplate template = promptTemplateService.getOne(new LambdaQueryWrapper<PromptTemplate>()
|
||||
.eq(PromptTemplate::getStatus, 1)
|
||||
.eq(PromptTemplate::getIsSystem, 1)
|
||||
.and(wrapper -> wrapper.eq(PromptTemplate::getTenantId, tenantId).or().eq(PromptTemplate::getTenantId, 0L))
|
||||
.orderByDesc(PromptTemplate::getTenantId)
|
||||
.orderByDesc(PromptTemplate::getCreatedAt)
|
||||
.last("LIMIT 1"));
|
||||
if (template != null) {
|
||||
return template;
|
||||
}
|
||||
|
||||
template = promptTemplateService.getOne(new LambdaQueryWrapper<PromptTemplate>()
|
||||
.eq(PromptTemplate::getStatus, 1)
|
||||
.and(wrapper -> wrapper.eq(PromptTemplate::getTenantId, tenantId).or().eq(PromptTemplate::getTenantId, 0L))
|
||||
.orderByDesc(PromptTemplate::getTenantId)
|
||||
.orderByDesc(PromptTemplate::getIsSystem)
|
||||
.orderByDesc(PromptTemplate::getCreatedAt)
|
||||
.last("LIMIT 1"));
|
||||
if (template == null) {
|
||||
throw new RuntimeException("提示词模板未配置");
|
||||
}
|
||||
return template;
|
||||
}
|
||||
|
||||
private void assertPromptAvailable(PromptTemplate template, Long tenantId) {
|
||||
if (template.getTenantId() != null && !Objects.equals(template.getTenantId(), tenantId) && !Objects.equals(template.getTenantId(), 0L)) {
|
||||
throw new RuntimeException("提示词模板不属于当前租户");
|
||||
}
|
||||
if (!Integer.valueOf(1).equals(template.getStatus())) {
|
||||
throw new RuntimeException("提示词模板未启用");
|
||||
}
|
||||
}
|
||||
|
||||
private String nonBlank(String value, String defaultValue) {
|
||||
return value != null && !value.isBlank() ? value.trim() : defaultValue;
|
||||
}
|
||||
}
|
||||
|
|
@ -8,5 +8,7 @@ import com.imeeting.dto.android.AndroidRealtimeGrpcSessionVO;
|
|||
public interface AndroidRealtimeSessionTicketService {
|
||||
AndroidRealtimeGrpcSessionVO createSession(Long meetingId, AndroidOpenRealtimeGrpcSessionCommand command, AndroidAuthContext authContext);
|
||||
|
||||
AndroidRealtimeGrpcSessionData prepareSessionData(Long meetingId, AndroidAuthContext authContext);
|
||||
|
||||
AndroidRealtimeGrpcSessionData getSessionData(String streamToken);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,28 @@
|
|||
package com.imeeting.service.realtime;
|
||||
|
||||
public interface RealtimeMeetingAudioStorageService {
|
||||
|
||||
String STATUS_NONE = "NONE";
|
||||
String STATUS_SUCCESS = "SUCCESS";
|
||||
String STATUS_FAILED = "FAILED";
|
||||
|
||||
String DEFAULT_FAILURE_MESSAGE = "\u5b9e\u65f6\u4f1a\u8bae\u5df2\u5b8c\u6210\uff0c\u4f46\u97f3\u9891\u4fdd\u5b58\u5931\u8d25\uff0c\u5f53\u524d\u65e0\u6cd5\u64ad\u653e\u4f1a\u8bae\u5f55\u97f3\u3002\u8f6c\u5199\u548c\u603b\u7ed3\u4e0d\u53d7\u5f71\u54cd\u3002";
|
||||
|
||||
void openSession(Long meetingId, String connectionId);
|
||||
|
||||
void append(String connectionId, byte[] pcm16);
|
||||
|
||||
void closeSession(String connectionId);
|
||||
|
||||
FinalizeResult finalizeMeetingAudio(Long meetingId);
|
||||
|
||||
record FinalizeResult(String status, String audioUrl, String message) {
|
||||
public boolean success() {
|
||||
return STATUS_SUCCESS.equals(status);
|
||||
}
|
||||
|
||||
public boolean failed() {
|
||||
return STATUS_FAILED.equals(status);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -5,7 +5,7 @@ import io.grpc.stub.StreamObserver;
|
|||
import com.imeeting.grpc.realtime.RealtimeServerPacket;
|
||||
|
||||
public interface RealtimeMeetingGrpcSessionService {
|
||||
String openStream(String streamToken, AndroidAuthContext authContext, StreamObserver<RealtimeServerPacket> responseObserver);
|
||||
String openStream(Long meetingId, String streamToken, AndroidAuthContext authContext, StreamObserver<RealtimeServerPacket> responseObserver);
|
||||
|
||||
void onAudio(String connectionId, byte[] payload, long seq, boolean lastChunk);
|
||||
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ import com.imeeting.dto.biz.RealtimeMeetingSessionStatusVO;
|
|||
import com.imeeting.entity.biz.Meeting;
|
||||
import com.imeeting.service.biz.AiModelService;
|
||||
import com.imeeting.service.biz.MeetingAccessService;
|
||||
import com.imeeting.service.biz.MeetingAuthorizationService;
|
||||
import com.imeeting.service.biz.RealtimeMeetingSessionStateService;
|
||||
import com.imeeting.service.realtime.AndroidRealtimeSessionTicketService;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
|
|
@ -32,60 +33,21 @@ public class AndroidRealtimeSessionTicketServiceImpl implements AndroidRealtimeS
|
|||
private final ObjectMapper objectMapper;
|
||||
private final StringRedisTemplate redisTemplate;
|
||||
private final MeetingAccessService meetingAccessService;
|
||||
private final MeetingAuthorizationService meetingAuthorizationService;
|
||||
private final AiModelService aiModelService;
|
||||
private final RealtimeMeetingSessionStateService realtimeMeetingSessionStateService;
|
||||
private final GrpcServerProperties grpcServerProperties;
|
||||
|
||||
@Override
|
||||
public AndroidRealtimeGrpcSessionVO createSession(Long meetingId, AndroidOpenRealtimeGrpcSessionCommand command, AndroidAuthContext authContext) {
|
||||
if (meetingId == null) {
|
||||
throw new RuntimeException("Meeting ID is required");
|
||||
}
|
||||
Meeting meeting = meetingAccessService.requireMeeting(meetingId);
|
||||
realtimeMeetingSessionStateService.initSessionIfAbsent(meetingId, meeting.getTenantId(), meeting.getCreatorId());
|
||||
RealtimeMeetingSessionStatusVO currentStatus = realtimeMeetingSessionStateService.getStatus(meetingId);
|
||||
RealtimeMeetingResumeConfig currentResumeConfig = currentStatus == null ? null : currentStatus.getResumeConfig();
|
||||
|
||||
Long asrModelId = firstNonNull(command == null ? null : command.getAsrModelId(), currentResumeConfig == null ? null : currentResumeConfig.getAsrModelId());
|
||||
if (asrModelId == null) {
|
||||
throw new RuntimeException("ASR model ID is required");
|
||||
}
|
||||
|
||||
realtimeMeetingSessionStateService.assertCanOpenSession(meetingId);
|
||||
AiModelVO asrModel = aiModelService.getModelById(asrModelId, "ASR");
|
||||
if (asrModel == null) {
|
||||
throw new RuntimeException("ASR model not found");
|
||||
}
|
||||
String targetWsUrl = resolveWsUrl(asrModel);
|
||||
if (targetWsUrl == null || targetWsUrl.isBlank()) {
|
||||
throw new RuntimeException("ASR model WebSocket is not configured");
|
||||
}
|
||||
|
||||
RealtimeMeetingResumeConfig resumeConfig = buildResumeConfig(command, currentResumeConfig, asrModelId);
|
||||
realtimeMeetingSessionStateService.rememberResumeConfig(meetingId, resumeConfig);
|
||||
currentStatus = realtimeMeetingSessionStateService.getStatus(meetingId);
|
||||
|
||||
Map<String, Object> startMessage = buildStartMessage(asrModel, meetingId, resumeConfig);
|
||||
AndroidRealtimeGrpcSessionData sessionData = new AndroidRealtimeGrpcSessionData();
|
||||
sessionData.setMeetingId(meetingId);
|
||||
sessionData.setTenantId(meeting.getTenantId());
|
||||
sessionData.setUserId(meeting.getCreatorId());
|
||||
sessionData.setDeviceId(authContext.getDeviceId());
|
||||
sessionData.setAsrModelId(asrModelId);
|
||||
sessionData.setTargetWsUrl(targetWsUrl);
|
||||
sessionData.setResumeConfig(resumeConfig);
|
||||
try {
|
||||
sessionData.setStartMessageJson(objectMapper.writeValueAsString(startMessage));
|
||||
} catch (Exception ex) {
|
||||
throw new RuntimeException("Failed to serialize realtime start message", ex);
|
||||
}
|
||||
PreparedRealtimeSession prepared = prepareSession(meetingId, command, authContext);
|
||||
|
||||
String streamToken = UUID.randomUUID().toString().replace("-", "");
|
||||
Duration ttl = Duration.ofSeconds(grpcServerProperties.getRealtime().getSessionTtlSeconds());
|
||||
try {
|
||||
redisTemplate.opsForValue().set(
|
||||
RedisKeys.realtimeMeetingGrpcSessionKey(streamToken),
|
||||
objectMapper.writeValueAsString(sessionData),
|
||||
objectMapper.writeValueAsString(prepared.sessionData()),
|
||||
ttl
|
||||
);
|
||||
} catch (Exception ex) {
|
||||
|
|
@ -99,11 +61,16 @@ public class AndroidRealtimeSessionTicketServiceImpl implements AndroidRealtimeS
|
|||
vo.setSampleRate(grpcServerProperties.getRealtime().getSampleRate());
|
||||
vo.setChannels(grpcServerProperties.getRealtime().getChannels());
|
||||
vo.setEncoding(grpcServerProperties.getRealtime().getEncoding());
|
||||
vo.setResumeConfig(resumeConfig);
|
||||
vo.setStatus(currentStatus);
|
||||
vo.setResumeConfig(prepared.resumeConfig());
|
||||
vo.setStatus(prepared.status());
|
||||
return vo;
|
||||
}
|
||||
|
||||
@Override
|
||||
public AndroidRealtimeGrpcSessionData prepareSessionData(Long meetingId, AndroidAuthContext authContext) {
|
||||
return prepareSession(meetingId, null, authContext).sessionData();
|
||||
}
|
||||
|
||||
@Override
|
||||
public AndroidRealtimeGrpcSessionData getSessionData(String streamToken) {
|
||||
if (streamToken == null || streamToken.isBlank()) {
|
||||
|
|
@ -120,6 +87,61 @@ public class AndroidRealtimeSessionTicketServiceImpl implements AndroidRealtimeS
|
|||
}
|
||||
}
|
||||
|
||||
private PreparedRealtimeSession prepareSession(Long meetingId, AndroidOpenRealtimeGrpcSessionCommand command, AndroidAuthContext authContext) {
|
||||
if (meetingId == null) {
|
||||
throw new RuntimeException("Meeting ID is required");
|
||||
}
|
||||
Meeting meeting = meetingAccessService.requireMeeting(meetingId);
|
||||
meetingAuthorizationService.assertCanManageRealtimeMeeting(meeting, authContext);
|
||||
realtimeMeetingSessionStateService.initSessionIfAbsent(meetingId, meeting.getTenantId(), meeting.getCreatorId());
|
||||
RealtimeMeetingSessionStatusVO currentStatus = realtimeMeetingSessionStateService.getStatus(meetingId);
|
||||
RealtimeMeetingResumeConfig currentResumeConfig = currentStatus == null ? null : currentStatus.getResumeConfig();
|
||||
|
||||
Long asrModelId = firstNonNull(
|
||||
command == null ? null : command.getAsrModelId(),
|
||||
currentResumeConfig == null ? null : currentResumeConfig.getAsrModelId(),
|
||||
resolveDefaultAsrModelId(meeting.getTenantId())
|
||||
);
|
||||
if (asrModelId == null) {
|
||||
throw new RuntimeException("ASR model ID is required");
|
||||
}
|
||||
|
||||
realtimeMeetingSessionStateService.assertCanOpenSession(meetingId);
|
||||
AiModelVO asrModel = aiModelService.getModelById(asrModelId, "ASR");
|
||||
if (asrModel == null) {
|
||||
throw new RuntimeException("ASR model not found");
|
||||
}
|
||||
String targetWsUrl = resolveWsUrl(asrModel);
|
||||
if (targetWsUrl == null || targetWsUrl.isBlank()) {
|
||||
throw new RuntimeException("ASR model WebSocket is not configured");
|
||||
}
|
||||
|
||||
RealtimeMeetingResumeConfig resumeConfig = buildResumeConfig(command, currentResumeConfig, asrModelId);
|
||||
realtimeMeetingSessionStateService.rememberResumeConfig(meetingId, resumeConfig);
|
||||
RealtimeMeetingSessionStatusVO latestStatus = realtimeMeetingSessionStateService.getStatus(meetingId);
|
||||
|
||||
Map<String, Object> startMessage = buildStartMessage(asrModel, meetingId, resumeConfig);
|
||||
AndroidRealtimeGrpcSessionData sessionData = new AndroidRealtimeGrpcSessionData();
|
||||
sessionData.setMeetingId(meetingId);
|
||||
sessionData.setTenantId(authContext != null && authContext.getTenantId() != null ? authContext.getTenantId() : meeting.getTenantId());
|
||||
sessionData.setUserId(authContext != null && authContext.getUserId() != null ? authContext.getUserId() : meeting.getCreatorId());
|
||||
sessionData.setDeviceId(authContext == null ? null : authContext.getDeviceId());
|
||||
sessionData.setAsrModelId(asrModelId);
|
||||
sessionData.setTargetWsUrl(targetWsUrl);
|
||||
sessionData.setResumeConfig(resumeConfig);
|
||||
try {
|
||||
sessionData.setStartMessageJson(objectMapper.writeValueAsString(startMessage));
|
||||
} catch (Exception ex) {
|
||||
throw new RuntimeException("Failed to serialize realtime start message", ex);
|
||||
}
|
||||
return new PreparedRealtimeSession(sessionData, resumeConfig, latestStatus);
|
||||
}
|
||||
|
||||
private Long resolveDefaultAsrModelId(Long tenantId) {
|
||||
AiModelVO defaultModel = aiModelService.getDefaultModel("ASR", tenantId == null ? 0L : tenantId);
|
||||
return defaultModel == null ? null : defaultModel.getId();
|
||||
}
|
||||
|
||||
private RealtimeMeetingResumeConfig buildResumeConfig(AndroidOpenRealtimeGrpcSessionCommand command,
|
||||
RealtimeMeetingResumeConfig currentResumeConfig,
|
||||
Long asrModelId) {
|
||||
|
|
@ -132,7 +154,11 @@ public class AndroidRealtimeSessionTicketServiceImpl implements AndroidRealtimeS
|
|||
config.setEnableItn(firstNonNull(command == null ? null : command.getEnableItn(), currentResumeConfig == null ? null : currentResumeConfig.getEnableItn(), Boolean.TRUE));
|
||||
config.setEnableTextRefine(firstNonNull(command == null ? null : command.getEnableTextRefine(), currentResumeConfig == null ? null : currentResumeConfig.getEnableTextRefine(), Boolean.FALSE));
|
||||
config.setSaveAudio(firstNonNull(command == null ? null : command.getSaveAudio(), currentResumeConfig == null ? null : currentResumeConfig.getSaveAudio(), Boolean.FALSE));
|
||||
config.setHotwords(command != null && command.getHotwords() != null ? command.getHotwords() : currentResumeConfig == null ? List.of() : currentResumeConfig.getHotwords());
|
||||
config.setHotwords(command != null && command.getHotwords() != null
|
||||
? command.getHotwords()
|
||||
: currentResumeConfig == null || currentResumeConfig.getHotwords() == null
|
||||
? List.of()
|
||||
: currentResumeConfig.getHotwords());
|
||||
return config;
|
||||
}
|
||||
|
||||
|
|
@ -216,4 +242,9 @@ public class AndroidRealtimeSessionTicketServiceImpl implements AndroidRealtimeS
|
|||
}
|
||||
return defaultValue;
|
||||
}
|
||||
|
||||
private record PreparedRealtimeSession(AndroidRealtimeGrpcSessionData sessionData,
|
||||
RealtimeMeetingResumeConfig resumeConfig,
|
||||
RealtimeMeetingSessionStatusVO status) {
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,254 @@
|
|||
package com.imeeting.service.realtime.impl;
|
||||
|
||||
import com.imeeting.service.realtime.RealtimeMeetingAudioStorageService;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.io.BufferedOutputStream;
|
||||
import java.io.IOException;
|
||||
import java.io.OutputStream;
|
||||
import java.nio.file.AtomicMoveNotSupportedException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.Paths;
|
||||
import java.nio.file.StandardCopyOption;
|
||||
import java.nio.file.StandardOpenOption;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.concurrent.ConcurrentMap;
|
||||
|
||||
@Slf4j
|
||||
@Service
|
||||
public class RealtimeMeetingAudioStorageServiceImpl implements RealtimeMeetingAudioStorageService {
|
||||
|
||||
private static final int SAMPLE_RATE = 16000;
|
||||
private static final int CHANNELS = 1;
|
||||
private static final int BITS_PER_SAMPLE = 16;
|
||||
private static final String INIT_FAILURE_MESSAGE = "实时会议音频保存初始化失败";
|
||||
private static final String WRITE_FAILURE_MESSAGE = "实时会议音频写入失败";
|
||||
private static final String FLUSH_FAILURE_MESSAGE = "实时会议音频刷新失败";
|
||||
private static final String MAYBE_INCOMPLETE_SUFFIX = ",录音可能不完整。";
|
||||
|
||||
private final ConcurrentMap<String, SessionState> sessions = new ConcurrentHashMap<>();
|
||||
private final ConcurrentMap<Long, Object> meetingLocks = new ConcurrentHashMap<>();
|
||||
private final ConcurrentMap<Long, String> meetingErrors = new ConcurrentHashMap<>();
|
||||
|
||||
@Value("${unisbase.app.upload-path}")
|
||||
private String uploadPath;
|
||||
|
||||
@Value("${unisbase.app.resource-prefix:/api/static/}")
|
||||
private String resourcePrefix;
|
||||
|
||||
@Override
|
||||
public void openSession(Long meetingId, String connectionId) {
|
||||
if (meetingId == null || connectionId == null || connectionId.isBlank()) {
|
||||
return;
|
||||
}
|
||||
closeSession(connectionId);
|
||||
try {
|
||||
Path tmpPath = tmpPcmPath(meetingId);
|
||||
Files.createDirectories(tmpPath.getParent());
|
||||
OutputStream output = new BufferedOutputStream(Files.newOutputStream(
|
||||
tmpPath,
|
||||
StandardOpenOption.CREATE,
|
||||
StandardOpenOption.APPEND
|
||||
));
|
||||
sessions.put(connectionId, new SessionState(meetingId, output));
|
||||
} catch (Exception ex) {
|
||||
recordFailure(meetingId, INIT_FAILURE_MESSAGE);
|
||||
log.warn("Failed to open realtime audio storage session, meetingId={}, connectionId={}", meetingId, connectionId, ex);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void append(String connectionId, byte[] pcm16) {
|
||||
if (connectionId == null || pcm16 == null || pcm16.length == 0) {
|
||||
return;
|
||||
}
|
||||
SessionState session = sessions.get(connectionId);
|
||||
if (session == null || session.output == null) {
|
||||
return;
|
||||
}
|
||||
synchronized (lockFor(session.meetingId)) {
|
||||
try {
|
||||
session.output.write(pcm16);
|
||||
} catch (Exception ex) {
|
||||
recordFailure(session.meetingId, WRITE_FAILURE_MESSAGE);
|
||||
closeQuietly(session.output);
|
||||
sessions.remove(connectionId);
|
||||
log.warn("Failed to append realtime audio, meetingId={}, connectionId={}", session.meetingId, connectionId, ex);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void closeSession(String connectionId) {
|
||||
if (connectionId == null || connectionId.isBlank()) {
|
||||
return;
|
||||
}
|
||||
SessionState session = sessions.remove(connectionId);
|
||||
if (session == null || session.output == null) {
|
||||
return;
|
||||
}
|
||||
synchronized (lockFor(session.meetingId)) {
|
||||
try {
|
||||
session.output.flush();
|
||||
} catch (Exception ex) {
|
||||
recordFailure(session.meetingId, FLUSH_FAILURE_MESSAGE);
|
||||
log.warn("Failed to flush realtime audio, meetingId={}, connectionId={}", session.meetingId, connectionId, ex);
|
||||
} finally {
|
||||
closeQuietly(session.output);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public FinalizeResult finalizeMeetingAudio(Long meetingId) {
|
||||
if (meetingId == null) {
|
||||
return new FinalizeResult(STATUS_NONE, null, null);
|
||||
}
|
||||
synchronized (lockFor(meetingId)) {
|
||||
closeOpenSessionsForMeeting(meetingId);
|
||||
Path tmpPath = tmpPcmPath(meetingId);
|
||||
Path wavPath = wavPath(meetingId);
|
||||
String priorError = meetingErrors.remove(meetingId);
|
||||
try {
|
||||
if (!Files.exists(tmpPath) || Files.size(tmpPath) <= 0) {
|
||||
if (Files.exists(wavPath) && Files.size(wavPath) > 44) {
|
||||
return new FinalizeResult(STATUS_SUCCESS, publicUrl(meetingId), null);
|
||||
}
|
||||
return new FinalizeResult(STATUS_FAILED, null, messageOrDefault(priorError));
|
||||
}
|
||||
|
||||
Files.createDirectories(wavPath.getParent());
|
||||
Path tmpWavPath = wavPath.resolveSibling("source_audio.wav.tmp");
|
||||
writeWav(tmpPath, tmpWavPath);
|
||||
moveReplacing(tmpWavPath, wavPath);
|
||||
Files.deleteIfExists(tmpPath);
|
||||
|
||||
if (priorError != null && !priorError.isBlank()) {
|
||||
return new FinalizeResult(STATUS_FAILED, publicUrl(meetingId), priorError + MAYBE_INCOMPLETE_SUFFIX);
|
||||
}
|
||||
return new FinalizeResult(STATUS_SUCCESS, publicUrl(meetingId), null);
|
||||
} catch (Exception ex) {
|
||||
log.warn("Failed to finalize realtime audio, meetingId={}", meetingId, ex);
|
||||
return new FinalizeResult(STATUS_FAILED, null, DEFAULT_FAILURE_MESSAGE);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void closeOpenSessionsForMeeting(Long meetingId) {
|
||||
sessions.entrySet().removeIf(entry -> {
|
||||
SessionState session = entry.getValue();
|
||||
if (session == null || !meetingId.equals(session.meetingId)) {
|
||||
return false;
|
||||
}
|
||||
closeQuietly(session.output);
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
private void writeWav(Path pcmPath, Path wavPath) throws IOException {
|
||||
long dataSize = Files.size(pcmPath);
|
||||
try (OutputStream output = new BufferedOutputStream(Files.newOutputStream(wavPath));
|
||||
var input = Files.newInputStream(pcmPath)) {
|
||||
writeWavHeader(output, dataSize);
|
||||
input.transferTo(output);
|
||||
}
|
||||
}
|
||||
|
||||
private void writeWavHeader(OutputStream output, long dataSize) throws IOException {
|
||||
long byteRate = (long) SAMPLE_RATE * CHANNELS * BITS_PER_SAMPLE / 8;
|
||||
int blockAlign = CHANNELS * BITS_PER_SAMPLE / 8;
|
||||
|
||||
output.write(new byte[]{'R', 'I', 'F', 'F'});
|
||||
writeIntLe(output, 36 + dataSize);
|
||||
output.write(new byte[]{'W', 'A', 'V', 'E'});
|
||||
output.write(new byte[]{'f', 'm', 't', ' '});
|
||||
writeIntLe(output, 16);
|
||||
writeShortLe(output, 1);
|
||||
writeShortLe(output, CHANNELS);
|
||||
writeIntLe(output, SAMPLE_RATE);
|
||||
writeIntLe(output, byteRate);
|
||||
writeShortLe(output, blockAlign);
|
||||
writeShortLe(output, BITS_PER_SAMPLE);
|
||||
output.write(new byte[]{'d', 'a', 't', 'a'});
|
||||
writeIntLe(output, dataSize);
|
||||
}
|
||||
|
||||
private void writeIntLe(OutputStream output, long value) throws IOException {
|
||||
output.write((int) (value & 0xff));
|
||||
output.write((int) ((value >> 8) & 0xff));
|
||||
output.write((int) ((value >> 16) & 0xff));
|
||||
output.write((int) ((value >> 24) & 0xff));
|
||||
}
|
||||
|
||||
private void writeShortLe(OutputStream output, int value) throws IOException {
|
||||
output.write(value & 0xff);
|
||||
output.write((value >> 8) & 0xff);
|
||||
}
|
||||
|
||||
private void moveReplacing(Path source, Path target) throws IOException {
|
||||
try {
|
||||
Files.move(source, target, StandardCopyOption.REPLACE_EXISTING, StandardCopyOption.ATOMIC_MOVE);
|
||||
} catch (AtomicMoveNotSupportedException ex) {
|
||||
Files.move(source, target, StandardCopyOption.REPLACE_EXISTING);
|
||||
}
|
||||
}
|
||||
|
||||
private Path tmpPcmPath(Long meetingId) {
|
||||
return meetingDir(meetingId).resolve("source_audio.pcm.tmp");
|
||||
}
|
||||
|
||||
private Path wavPath(Long meetingId) {
|
||||
return meetingDir(meetingId).resolve("source_audio.wav");
|
||||
}
|
||||
|
||||
private Path meetingDir(Long meetingId) {
|
||||
return Paths.get(normalizedUploadPath(), "meetings", String.valueOf(meetingId));
|
||||
}
|
||||
|
||||
private String publicUrl(Long meetingId) {
|
||||
String prefix = resourcePrefix.endsWith("/") ? resourcePrefix : resourcePrefix + "/";
|
||||
return prefix + "meetings/" + meetingId + "/source_audio.wav";
|
||||
}
|
||||
|
||||
private String normalizedUploadPath() {
|
||||
return uploadPath.endsWith("/") ? uploadPath.substring(0, uploadPath.length() - 1) : uploadPath;
|
||||
}
|
||||
|
||||
private Object lockFor(Long meetingId) {
|
||||
return meetingLocks.computeIfAbsent(meetingId, ignored -> new Object());
|
||||
}
|
||||
|
||||
private void recordFailure(Long meetingId, String message) {
|
||||
if (meetingId != null) {
|
||||
meetingErrors.put(meetingId, messageOrDefault(message));
|
||||
}
|
||||
}
|
||||
|
||||
private String messageOrDefault(String message) {
|
||||
return message == null || message.isBlank() ? DEFAULT_FAILURE_MESSAGE : message;
|
||||
}
|
||||
|
||||
private void closeQuietly(OutputStream output) {
|
||||
if (output == null) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
output.close();
|
||||
} catch (Exception ignored) {
|
||||
// ignore close failure
|
||||
}
|
||||
}
|
||||
|
||||
private static final class SessionState {
|
||||
private final Long meetingId;
|
||||
private final OutputStream output;
|
||||
|
||||
private SessionState(Long meetingId, OutputStream output) {
|
||||
this.meetingId = meetingId;
|
||||
this.output = output;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -16,6 +16,7 @@ import com.imeeting.service.biz.MeetingCommandService;
|
|||
import com.imeeting.service.biz.RealtimeMeetingSessionStateService;
|
||||
import com.imeeting.service.realtime.AndroidRealtimeSessionTicketService;
|
||||
import com.imeeting.service.realtime.AsrUpstreamBridgeService;
|
||||
import com.imeeting.service.realtime.RealtimeMeetingAudioStorageService;
|
||||
import com.imeeting.service.realtime.RealtimeMeetingGrpcSessionService;
|
||||
import io.grpc.stub.StreamObserver;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
|
|
@ -38,21 +39,30 @@ public class RealtimeMeetingGrpcSessionServiceImpl implements RealtimeMeetingGrp
|
|||
private final AsrUpstreamBridgeService asrUpstreamBridgeService;
|
||||
private final MeetingCommandService meetingCommandService;
|
||||
private final RealtimeMeetingSessionStateService realtimeMeetingSessionStateService;
|
||||
private final RealtimeMeetingAudioStorageService realtimeMeetingAudioStorageService;
|
||||
private final StringRedisTemplate redisTemplate;
|
||||
private final GrpcServerProperties grpcServerProperties;
|
||||
|
||||
private final ConcurrentMap<String, SessionRuntime> sessions = new ConcurrentHashMap<>();
|
||||
|
||||
@Override
|
||||
public String openStream(String streamToken, AndroidAuthContext authContext, StreamObserver<RealtimeServerPacket> responseObserver) {
|
||||
AndroidRealtimeGrpcSessionData sessionData = ticketService.getSessionData(streamToken);
|
||||
if (sessionData == null) {
|
||||
throw new RuntimeException("Invalid realtime gRPC session token");
|
||||
}
|
||||
if (sessionData.getDeviceId() != null && !sessionData.getDeviceId().isBlank()
|
||||
&& authContext.getDeviceId() != null
|
||||
&& !sessionData.getDeviceId().equals(authContext.getDeviceId())) {
|
||||
throw new RuntimeException("Realtime gRPC session token does not match deviceId");
|
||||
public String openStream(Long meetingId, String streamToken, AndroidAuthContext authContext, StreamObserver<RealtimeServerPacket> responseObserver) {
|
||||
AndroidRealtimeGrpcSessionData sessionData;
|
||||
if (meetingId != null && meetingId > 0) {
|
||||
sessionData = ticketService.prepareSessionData(meetingId, authContext);
|
||||
streamToken = "";
|
||||
} else if (streamToken != null && !streamToken.isBlank()) {
|
||||
sessionData = ticketService.getSessionData(streamToken);
|
||||
if (sessionData == null) {
|
||||
throw new RuntimeException("Invalid realtime gRPC session token");
|
||||
}
|
||||
if (sessionData.getDeviceId() != null && !sessionData.getDeviceId().isBlank()
|
||||
&& authContext.getDeviceId() != null
|
||||
&& !sessionData.getDeviceId().equals(authContext.getDeviceId())) {
|
||||
throw new RuntimeException("Realtime gRPC session token does not match deviceId");
|
||||
}
|
||||
} else {
|
||||
throw new RuntimeException("Meeting ID is required");
|
||||
}
|
||||
|
||||
String connectionId = "grpc_" + java.util.UUID.randomUUID().toString().replace("-", "");
|
||||
|
|
@ -63,6 +73,7 @@ public class RealtimeMeetingGrpcSessionServiceImpl implements RealtimeMeetingGrp
|
|||
}
|
||||
|
||||
writeConnectionState(runtime);
|
||||
realtimeMeetingAudioStorageService.openSession(sessionData.getMeetingId(), connectionId);
|
||||
runtime.upstreamSession = asrUpstreamBridgeService.openSession(sessionData, connectionId, new UpstreamCallback(runtime));
|
||||
return connectionId;
|
||||
}
|
||||
|
|
@ -74,6 +85,7 @@ public class RealtimeMeetingGrpcSessionServiceImpl implements RealtimeMeetingGrp
|
|||
return;
|
||||
}
|
||||
touchConnectionState(runtime);
|
||||
realtimeMeetingAudioStorageService.append(connectionId, payload);
|
||||
runtime.upstreamSession.sendAudio(payload);
|
||||
if (lastChunk) {
|
||||
runtime.upstreamSession.sendStopSpeaking();
|
||||
|
|
@ -109,6 +121,7 @@ public class RealtimeMeetingGrpcSessionServiceImpl implements RealtimeMeetingGrp
|
|||
}
|
||||
|
||||
redisTemplate.delete(RedisKeys.realtimeMeetingGrpcConnectionKey(connectionId));
|
||||
realtimeMeetingAudioStorageService.closeSession(connectionId);
|
||||
realtimeMeetingSessionStateService.pauseByDisconnect(runtime.sessionData.getMeetingId(), connectionId);
|
||||
|
||||
if (notifyClient) {
|
||||
|
|
@ -193,15 +206,13 @@ public class RealtimeMeetingGrpcSessionServiceImpl implements RealtimeMeetingGrp
|
|||
if (runtime.closed.get() || result == null || result.getText() == null || result.getText().isBlank()) {
|
||||
return;
|
||||
}
|
||||
if (result.isFinalResult()) {
|
||||
RealtimeTranscriptItemDTO item = new RealtimeTranscriptItemDTO();
|
||||
item.setSpeakerId(result.getSpeakerId());
|
||||
item.setSpeakerName(result.getSpeakerName());
|
||||
item.setContent(result.getText());
|
||||
item.setStartTime(result.getStartTime());
|
||||
item.setEndTime(result.getEndTime());
|
||||
meetingCommandService.appendRealtimeTranscripts(runtime.sessionData.getMeetingId(), List.of(item));
|
||||
}
|
||||
RealtimeTranscriptItemDTO item = new RealtimeTranscriptItemDTO();
|
||||
item.setSpeakerId(result.getSpeakerId());
|
||||
item.setSpeakerName(result.getSpeakerName());
|
||||
item.setContent(result.getText());
|
||||
item.setStartTime(result.getStartTime());
|
||||
item.setEndTime(result.getEndTime());
|
||||
meetingCommandService.saveRealtimeTranscriptSnapshot(runtime.sessionData.getMeetingId(), item, result.isFinalResult());
|
||||
runtime.send(RealtimeServerPacket.newBuilder()
|
||||
.setTranscript(TranscriptEvent.newBuilder()
|
||||
.setMeetingId(runtime.sessionData.getMeetingId())
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ package com.imeeting.websocket;
|
|||
import com.imeeting.dto.biz.RealtimeSocketSessionData;
|
||||
import com.imeeting.service.biz.RealtimeMeetingSessionStateService;
|
||||
import com.imeeting.service.biz.RealtimeMeetingSocketSessionService;
|
||||
import com.imeeting.service.realtime.RealtimeMeetingAudioStorageService;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
|
@ -50,6 +51,7 @@ public class RealtimeMeetingProxyWebSocketHandler extends AbstractWebSocketHandl
|
|||
|
||||
private final RealtimeMeetingSocketSessionService realtimeMeetingSocketSessionService;
|
||||
private final RealtimeMeetingSessionStateService realtimeMeetingSessionStateService;
|
||||
private final RealtimeMeetingAudioStorageService realtimeMeetingAudioStorageService;
|
||||
|
||||
@Override
|
||||
public void afterConnectionEstablished(WebSocketSession session) throws Exception {
|
||||
|
|
@ -71,6 +73,7 @@ public class RealtimeMeetingProxyWebSocketHandler extends AbstractWebSocketHandl
|
|||
session.getAttributes().put(ATTR_UPSTREAM_SEND_CHAIN, COMPLETED);
|
||||
session.getAttributes().put(ATTR_START_MESSAGE_SENT, Boolean.FALSE);
|
||||
session.getAttributes().put(ATTR_PENDING_AUDIO_FRAMES, new ArrayList<byte[]>());
|
||||
realtimeMeetingAudioStorageService.openSession(sessionData.getMeetingId(), session.getId());
|
||||
log.info("Realtime websocket accepted: meetingId={}, sessionId={}, upstream={}",
|
||||
sessionData.getMeetingId(), session.getId(), sessionData.getTargetWsUrl());
|
||||
|
||||
|
|
@ -92,11 +95,13 @@ public class RealtimeMeetingProxyWebSocketHandler extends AbstractWebSocketHandl
|
|||
log.error("Realtime websocket upstream connect interrupted: meetingId={}, sessionId={}",
|
||||
sessionData.getMeetingId(), session.getId(), ex);
|
||||
sendFrontendError(frontendSession, "REALTIME_UPSTREAM_CONNECT_INTERRUPTED", "连接第三方识别服务时被中断");
|
||||
realtimeMeetingAudioStorageService.closeSession(session.getId());
|
||||
frontendSession.close(CloseStatus.SERVER_ERROR.withReason("Interrupted while connecting upstream"));
|
||||
return;
|
||||
} catch (ExecutionException | CompletionException ex) {
|
||||
log.warn("Failed to connect upstream websocket, meetingId={}, target={}", sessionData.getMeetingId(), sessionData.getTargetWsUrl(), ex);
|
||||
sendFrontendError(frontendSession, "REALTIME_UPSTREAM_CONNECT_FAILED", "连接第三方识别服务失败,请检查模型 WebSocket 配置或服务状态");
|
||||
realtimeMeetingAudioStorageService.closeSession(session.getId());
|
||||
frontendSession.close(CloseStatus.SERVER_ERROR.withReason("Failed to connect ASR websocket"));
|
||||
return;
|
||||
}
|
||||
|
|
@ -137,6 +142,7 @@ public class RealtimeMeetingProxyWebSocketHandler extends AbstractWebSocketHandl
|
|||
session.getAttributes().get(ATTR_MEETING_ID), session.getId(), count, bytes);
|
||||
}
|
||||
byte[] payload = toByteArray(message.getPayload());
|
||||
realtimeMeetingAudioStorageService.append(session.getId(), payload);
|
||||
if (!Boolean.TRUE.equals(session.getAttributes().get(ATTR_START_MESSAGE_SENT))) {
|
||||
queuePendingAudioFrame(session, payload);
|
||||
if (shouldLogBinaryFrame(count)) {
|
||||
|
|
@ -175,6 +181,7 @@ public class RealtimeMeetingProxyWebSocketHandler extends AbstractWebSocketHandl
|
|||
if (meetingIdValue instanceof Long meetingId) {
|
||||
realtimeMeetingSessionStateService.pauseByDisconnect(meetingId, session.getId());
|
||||
}
|
||||
realtimeMeetingAudioStorageService.closeSession(session.getId());
|
||||
closeUpstreamSocket(session, status);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -42,7 +42,6 @@ unisbase:
|
|||
permit-all-urls:
|
||||
- /actuator/health
|
||||
- /api/static/**
|
||||
- /api/android/**
|
||||
- /ws/**
|
||||
internal-auth:
|
||||
enabled: true
|
||||
|
|
|
|||
|
|
@ -0,0 +1,63 @@
|
|||
package com.imeeting.dto.biz;
|
||||
|
||||
import jakarta.validation.Validation;
|
||||
import jakarta.validation.Validator;
|
||||
import org.junit.jupiter.api.BeforeAll;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.Set;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
class MeetingCreateCommandValidationTest {
|
||||
|
||||
private static Validator validator;
|
||||
|
||||
@BeforeAll
|
||||
static void initValidator() {
|
||||
validator = Validation.buildDefaultValidatorFactory().getValidator();
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldRequireOfflineMeetingCoreFields() {
|
||||
CreateMeetingCommand command = new CreateMeetingCommand();
|
||||
|
||||
Set<String> invalidFields = validator.validate(command).stream()
|
||||
.map(violation -> violation.getPropertyPath().toString())
|
||||
.collect(java.util.stream.Collectors.toSet());
|
||||
|
||||
assertEquals(Set.of("title", "meetingTime", "audioUrl", "asrModelId", "summaryModelId", "promptId"), invalidFields);
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldRequireRealtimeMeetingCoreFields() {
|
||||
CreateRealtimeMeetingCommand command = new CreateRealtimeMeetingCommand();
|
||||
|
||||
Set<String> invalidFields = validator.validate(command).stream()
|
||||
.map(violation -> violation.getPropertyPath().toString())
|
||||
.collect(java.util.stream.Collectors.toSet());
|
||||
|
||||
assertEquals(Set.of("title", "meetingTime", "asrModelId", "summaryModelId", "promptId"), invalidFields);
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldAcceptCompleteRealtimeMeetingCommand() {
|
||||
CreateRealtimeMeetingCommand command = new CreateRealtimeMeetingCommand();
|
||||
command.setTitle("实时评审");
|
||||
command.setMeetingTime(LocalDateTime.of(2026, 4, 3, 10, 0));
|
||||
command.setAsrModelId(1L);
|
||||
command.setSummaryModelId(2L);
|
||||
command.setPromptId(3L);
|
||||
command.setMode("2pass");
|
||||
command.setLanguage("auto");
|
||||
command.setUseSpkId(1);
|
||||
command.setEnablePunctuation(true);
|
||||
command.setEnableItn(true);
|
||||
command.setEnableTextRefine(true);
|
||||
command.setSaveAudio(false);
|
||||
|
||||
assertTrue(validator.validate(command).isEmpty());
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,428 @@
|
|||
package com.imeeting.manual;
|
||||
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.google.protobuf.ByteString;
|
||||
import com.imeeting.grpc.common.ClientAuth;
|
||||
import com.imeeting.grpc.realtime.AudioChunk;
|
||||
import com.imeeting.grpc.realtime.OpenMeetingStream;
|
||||
import com.imeeting.grpc.realtime.RealtimeClientPacket;
|
||||
import com.imeeting.grpc.realtime.RealtimeControl;
|
||||
import com.imeeting.grpc.realtime.RealtimeMeetingServiceGrpc;
|
||||
import com.imeeting.grpc.realtime.RealtimeServerPacket;
|
||||
import io.grpc.ManagedChannel;
|
||||
import io.grpc.ManagedChannelBuilder;
|
||||
import io.grpc.stub.StreamObserver;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.io.InputStream;
|
||||
import java.net.URI;
|
||||
import java.net.http.HttpClient;
|
||||
import java.net.http.HttpRequest;
|
||||
import java.net.http.HttpResponse;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.time.Duration;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.CountDownLatch;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
public class AndroidRealtimeGrpcManualTest {
|
||||
|
||||
private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper();
|
||||
|
||||
@Test
|
||||
void shouldRunAndroidRealtimeFlow() throws Exception {
|
||||
TestConfig config = TestConfig.load();
|
||||
System.out.println("[manual-test] config=" + config);
|
||||
|
||||
CreatedMeetingInfo meetingInfo = createRealtimeMeeting(config);
|
||||
System.out.println("[manual-test] created meetingId=" + meetingInfo.meetingId());
|
||||
|
||||
ManualRealtimeClient client = new ManualRealtimeClient(config, meetingInfo);
|
||||
try {
|
||||
client.open();
|
||||
assertTrue(client.awaitReady(config.readyTimeoutSeconds(), TimeUnit.SECONDS),
|
||||
"Did not receive StreamReady within timeout");
|
||||
|
||||
client.streamPcmFile(config.pcmFile(), config.chunkMs());
|
||||
client.sendStopSpeaking();
|
||||
|
||||
if (config.afterStopWaitSeconds() > 0) {
|
||||
TimeUnit.SECONDS.sleep(config.afterStopWaitSeconds());
|
||||
}
|
||||
|
||||
client.sendCloseStream();
|
||||
client.completeClientStream();
|
||||
client.awaitClosed(Math.max(5, config.afterStopWaitSeconds()), TimeUnit.SECONDS);
|
||||
|
||||
assertTrue(client.isReadyReceived(), "StreamReady was not received");
|
||||
assertTrue(client.getErrorMessage() == null, "Realtime stream returned error: " + client.getErrorMessage());
|
||||
if (config.requireTranscript()) {
|
||||
assertTrue(client.getTranscriptCount() > 0,
|
||||
"No transcript events received. Check ASR config, PCM format, and upstream websocket connectivity.");
|
||||
}
|
||||
} finally {
|
||||
client.shutdown();
|
||||
}
|
||||
|
||||
if (config.beforeCompleteWaitSeconds() > 0) {
|
||||
TimeUnit.SECONDS.sleep(config.beforeCompleteWaitSeconds());
|
||||
}
|
||||
|
||||
completeRealtimeMeeting(config, meetingInfo.meetingId());
|
||||
|
||||
if (config.afterCompleteWaitSeconds() > 0) {
|
||||
TimeUnit.SECONDS.sleep(config.afterCompleteWaitSeconds());
|
||||
}
|
||||
|
||||
JsonNode transcripts = queryTranscripts(config, meetingInfo.meetingId());
|
||||
if (config.requireTranscript()) {
|
||||
assertTrue(transcripts.isArray() && transcripts.size() > 0,
|
||||
"No persisted transcripts found after complete. meetingId=" + meetingInfo.meetingId());
|
||||
}
|
||||
}
|
||||
|
||||
private CreatedMeetingInfo createRealtimeMeeting(TestConfig config) throws Exception {
|
||||
HttpClient httpClient = HttpClient.newBuilder()
|
||||
.connectTimeout(Duration.ofSeconds(10))
|
||||
.build();
|
||||
|
||||
Map<String, Object> body = new LinkedHashMap<>();
|
||||
body.put("title", config.meetingTitle());
|
||||
if (config.tags() != null && !config.tags().isBlank()) {
|
||||
body.put("tags", config.tags());
|
||||
}
|
||||
|
||||
HttpRequest request = HttpRequest.newBuilder()
|
||||
.uri(URI.create(config.baseUrl() + "/api/android/meeting/realtime/create"))
|
||||
.timeout(Duration.ofSeconds(20))
|
||||
.header("Content-Type", "application/json")
|
||||
.header("X-Android-Device-Id", config.deviceId())
|
||||
.header("X-Android-Platform", "android")
|
||||
.POST(HttpRequest.BodyPublishers.ofString(OBJECT_MAPPER.writeValueAsString(body), StandardCharsets.UTF_8))
|
||||
.build();
|
||||
|
||||
HttpResponse<String> response = httpClient.send(request, HttpResponse.BodyHandlers.ofString());
|
||||
assertTrue(response.statusCode() >= 200 && response.statusCode() < 300,
|
||||
"Create realtime meeting failed, httpStatus=" + response.statusCode() + ", body=" + response.body());
|
||||
|
||||
JsonNode data = readData(response.body());
|
||||
long meetingId = data.path("meetingId").asLong(0L);
|
||||
assertTrue(meetingId > 0, "Invalid meetingId in response: " + response.body());
|
||||
|
||||
int sampleRate = data.path("sampleRate").asInt(16000);
|
||||
int channels = data.path("channels").asInt(1);
|
||||
String encoding = text(data, "encoding");
|
||||
assertNotNull(encoding, "encoding is null in response: " + response.body());
|
||||
|
||||
return new CreatedMeetingInfo(meetingId, sampleRate, channels, encoding);
|
||||
}
|
||||
|
||||
private void completeRealtimeMeeting(TestConfig config, long meetingId) throws Exception {
|
||||
HttpClient httpClient = HttpClient.newBuilder()
|
||||
.connectTimeout(Duration.ofSeconds(10))
|
||||
.build();
|
||||
|
||||
Map<String, Object> body = new LinkedHashMap<>();
|
||||
body.put("overwriteAudio", config.overwriteAudio());
|
||||
if (config.completeAudioUrl() != null && !config.completeAudioUrl().isBlank()) {
|
||||
body.put("audioUrl", config.completeAudioUrl());
|
||||
}
|
||||
|
||||
HttpRequest request = HttpRequest.newBuilder()
|
||||
.uri(URI.create(config.baseUrl() + "/api/android/meeting/" + meetingId + "/realtime/complete"))
|
||||
.timeout(Duration.ofSeconds(20))
|
||||
.header("Content-Type", "application/json")
|
||||
.header("X-Android-Device-Id", config.deviceId())
|
||||
.header("X-Android-Platform", "android")
|
||||
.POST(HttpRequest.BodyPublishers.ofString(OBJECT_MAPPER.writeValueAsString(body), StandardCharsets.UTF_8))
|
||||
.build();
|
||||
|
||||
HttpResponse<String> response = httpClient.send(request, HttpResponse.BodyHandlers.ofString());
|
||||
assertTrue(response.statusCode() >= 200 && response.statusCode() < 300,
|
||||
"Complete realtime meeting failed, httpStatus=" + response.statusCode() + ", body=" + response.body());
|
||||
|
||||
JsonNode data = readData(response.body());
|
||||
assertTrue(data.asBoolean(false), "Complete realtime meeting did not return true: " + response.body());
|
||||
}
|
||||
|
||||
private JsonNode queryTranscripts(TestConfig config, long meetingId) throws Exception {
|
||||
HttpClient httpClient = HttpClient.newBuilder()
|
||||
.connectTimeout(Duration.ofSeconds(10))
|
||||
.build();
|
||||
|
||||
HttpRequest request = HttpRequest.newBuilder()
|
||||
.uri(URI.create(config.baseUrl() + "/api/android/meeting/" + meetingId + "/transcripts"))
|
||||
.timeout(Duration.ofSeconds(20))
|
||||
.header("X-Android-Device-Id", config.deviceId())
|
||||
.header("X-Android-Platform", "android")
|
||||
.GET()
|
||||
.build();
|
||||
|
||||
HttpResponse<String> response = httpClient.send(request, HttpResponse.BodyHandlers.ofString());
|
||||
assertTrue(response.statusCode() >= 200 && response.statusCode() < 300,
|
||||
"Query transcripts failed, httpStatus=" + response.statusCode() + ", body=" + response.body());
|
||||
return readData(response.body());
|
||||
}
|
||||
|
||||
private JsonNode readData(String responseBody) throws Exception {
|
||||
JsonNode root = OBJECT_MAPPER.readTree(responseBody);
|
||||
JsonNode data = root.path("data");
|
||||
assertTrue(!data.isMissingNode() && !data.isNull(), "Invalid REST response, missing data: " + responseBody);
|
||||
return data;
|
||||
}
|
||||
|
||||
private String text(JsonNode node, String fieldName) {
|
||||
JsonNode value = node.path(fieldName);
|
||||
return value.isMissingNode() || value.isNull() ? null : value.asText();
|
||||
}
|
||||
|
||||
private record CreatedMeetingInfo(long meetingId, int sampleRate, int channels, String encoding) {
|
||||
}
|
||||
|
||||
private record TestConfig(String baseUrl,
|
||||
String grpcHost,
|
||||
int grpcPort,
|
||||
String deviceId,
|
||||
String meetingTitle,
|
||||
String tags,
|
||||
Path pcmFile,
|
||||
int chunkMs,
|
||||
long readyTimeoutSeconds,
|
||||
long afterStopWaitSeconds,
|
||||
long beforeCompleteWaitSeconds,
|
||||
long afterCompleteWaitSeconds,
|
||||
boolean overwriteAudio,
|
||||
String completeAudioUrl,
|
||||
boolean requireTranscript) {
|
||||
|
||||
private static final String DEFAULT_BASE_URL = "http://127.0.0.1:8081";
|
||||
private static final String DEFAULT_GRPC_HOST = "127.0.0.1";
|
||||
private static final int DEFAULT_GRPC_PORT = 19090;
|
||||
private static final String DEFAULT_DEVICE_ID = "android-local-test-001";
|
||||
private static final String DEFAULT_MEETING_TITLE = "android-realtime-manual-test";
|
||||
private static final String DEFAULT_TAGS = "android,grpc,manual";
|
||||
private static final String DEFAULT_PCM_FILE = "C:\\Users\\85206\\Downloads\\no_recoder_audio.pcm";
|
||||
private static final int DEFAULT_CHUNK_MS = 40;
|
||||
private static final long DEFAULT_READY_TIMEOUT_SECONDS = 15L;
|
||||
private static final long DEFAULT_AFTER_STOP_WAIT_SECONDS = 8L;
|
||||
private static final long DEFAULT_BEFORE_COMPLETE_WAIT_SECONDS = 3L;
|
||||
private static final long DEFAULT_AFTER_COMPLETE_WAIT_SECONDS = 1L;
|
||||
private static final boolean DEFAULT_OVERWRITE_AUDIO = false;
|
||||
private static final String DEFAULT_COMPLETE_AUDIO_URL = "";
|
||||
private static final boolean DEFAULT_REQUIRE_TRANSCRIPT = true;
|
||||
|
||||
private static TestConfig load() {
|
||||
String baseUrl = stringProperty("manualRealtime.baseUrl", DEFAULT_BASE_URL);
|
||||
String grpcHost = stringProperty("manualRealtime.grpcHost", DEFAULT_GRPC_HOST);
|
||||
int grpcPort = intProperty("manualRealtime.grpcPort", DEFAULT_GRPC_PORT);
|
||||
String deviceId = stringProperty("manualRealtime.deviceId", DEFAULT_DEVICE_ID);
|
||||
String meetingTitle = stringProperty("manualRealtime.meetingTitle", DEFAULT_MEETING_TITLE);
|
||||
String tags = stringProperty("manualRealtime.tags", DEFAULT_TAGS);
|
||||
Path pcmFile = Path.of(stringProperty("manualRealtime.pcmFile", DEFAULT_PCM_FILE));
|
||||
int chunkMs = intProperty("manualRealtime.chunkMs", DEFAULT_CHUNK_MS);
|
||||
long readyTimeoutSeconds = longProperty("manualRealtime.readyTimeoutSeconds", DEFAULT_READY_TIMEOUT_SECONDS);
|
||||
long afterStopWaitSeconds = longProperty("manualRealtime.afterStopWaitSeconds", DEFAULT_AFTER_STOP_WAIT_SECONDS);
|
||||
long beforeCompleteWaitSeconds = longProperty("manualRealtime.beforeCompleteWaitSeconds", DEFAULT_BEFORE_COMPLETE_WAIT_SECONDS);
|
||||
long afterCompleteWaitSeconds = longProperty("manualRealtime.afterCompleteWaitSeconds", DEFAULT_AFTER_COMPLETE_WAIT_SECONDS);
|
||||
boolean overwriteAudio = booleanProperty("manualRealtime.overwriteAudio", DEFAULT_OVERWRITE_AUDIO);
|
||||
String completeAudioUrl = stringProperty("manualRealtime.completeAudioUrl", DEFAULT_COMPLETE_AUDIO_URL);
|
||||
boolean requireTranscript = booleanProperty("manualRealtime.requireTranscript", DEFAULT_REQUIRE_TRANSCRIPT);
|
||||
|
||||
assertTrue(Files.exists(pcmFile),
|
||||
"PCM file does not exist: " + pcmFile + ". Please set -DmanualRealtime.pcmFile=<your pcm file path>.");
|
||||
assertTrue(Files.isRegularFile(pcmFile), "PCM file is not a regular file: " + pcmFile);
|
||||
|
||||
return new TestConfig(baseUrl, grpcHost, grpcPort, deviceId, meetingTitle, tags, pcmFile,
|
||||
chunkMs, readyTimeoutSeconds, afterStopWaitSeconds,
|
||||
beforeCompleteWaitSeconds, afterCompleteWaitSeconds, overwriteAudio, completeAudioUrl, requireTranscript);
|
||||
}
|
||||
|
||||
private static String stringProperty(String key, String defaultValue) {
|
||||
String value = System.getProperty(key);
|
||||
return value == null || value.isBlank() ? defaultValue : value.trim();
|
||||
}
|
||||
|
||||
private static int intProperty(String key, int defaultValue) {
|
||||
return Integer.parseInt(stringProperty(key, Integer.toString(defaultValue)));
|
||||
}
|
||||
|
||||
private static long longProperty(String key, long defaultValue) {
|
||||
return Long.parseLong(stringProperty(key, Long.toString(defaultValue)));
|
||||
}
|
||||
|
||||
private static boolean booleanProperty(String key, boolean defaultValue) {
|
||||
return Boolean.parseBoolean(stringProperty(key, Boolean.toString(defaultValue)));
|
||||
}
|
||||
}
|
||||
|
||||
private static final class ManualRealtimeClient {
|
||||
private final TestConfig config;
|
||||
private final CreatedMeetingInfo meetingInfo;
|
||||
private final CountDownLatch readyLatch = new CountDownLatch(1);
|
||||
private final CountDownLatch closedLatch = new CountDownLatch(1);
|
||||
private final AtomicInteger transcriptCount = new AtomicInteger();
|
||||
private final AtomicReference<String> errorMessage = new AtomicReference<>();
|
||||
private final ManagedChannel channel;
|
||||
private final StreamObserver<RealtimeClientPacket> requestObserver;
|
||||
private volatile boolean readyReceived;
|
||||
|
||||
private ManualRealtimeClient(TestConfig config, CreatedMeetingInfo meetingInfo) {
|
||||
this.config = config;
|
||||
this.meetingInfo = meetingInfo;
|
||||
this.channel = ManagedChannelBuilder.forAddress(config.grpcHost(), config.grpcPort())
|
||||
.usePlaintext()
|
||||
.build();
|
||||
this.requestObserver = RealtimeMeetingServiceGrpc.newStub(channel)
|
||||
.streamMeetingAudio(new StreamObserver<>() {
|
||||
@Override
|
||||
public void onNext(RealtimeServerPacket packet) {
|
||||
switch (packet.getBodyCase()) {
|
||||
case READY -> {
|
||||
readyReceived = true;
|
||||
readyLatch.countDown();
|
||||
System.out.println("[manual-test] READY connectionId=" + packet.getReady().getConnectionId());
|
||||
}
|
||||
case STATUS -> System.out.println("[manual-test] STATUS status=" + packet.getStatus().getStatus()
|
||||
+ ", active=" + packet.getStatus().getActiveConnection());
|
||||
case TRANSCRIPT -> {
|
||||
transcriptCount.incrementAndGet();
|
||||
System.out.println("[manual-test] TRANSCRIPT type=" + packet.getTranscript().getType()
|
||||
+ ", text=" + packet.getTranscript().getText());
|
||||
}
|
||||
case ERROR -> {
|
||||
String message = packet.getError().getCode() + ": " + packet.getError().getMessage();
|
||||
errorMessage.compareAndSet(null, message);
|
||||
System.out.println("[manual-test] ERROR " + message);
|
||||
}
|
||||
case CLOSED -> {
|
||||
System.out.println("[manual-test] CLOSED reason=" + packet.getClosed().getReason());
|
||||
closedLatch.countDown();
|
||||
}
|
||||
case BODY_NOT_SET -> System.out.println("[manual-test] EMPTY packet received");
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onError(Throwable throwable) {
|
||||
errorMessage.compareAndSet(null, throwable.getMessage());
|
||||
readyLatch.countDown();
|
||||
closedLatch.countDown();
|
||||
System.out.println("[manual-test] gRPC onError: " + throwable.getMessage());
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onCompleted() {
|
||||
closedLatch.countDown();
|
||||
System.out.println("[manual-test] gRPC onCompleted");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private void open() {
|
||||
ClientAuth auth = ClientAuth.newBuilder()
|
||||
.setAuthType(ClientAuth.AuthType.NONE)
|
||||
.setDeviceId(config.deviceId())
|
||||
.setPlatform("android")
|
||||
.setAppVersion("manual-test")
|
||||
.build();
|
||||
|
||||
OpenMeetingStream open = OpenMeetingStream.newBuilder()
|
||||
.setStreamToken("")
|
||||
.setMeetingId(meetingInfo.meetingId())
|
||||
.setSampleRate(meetingInfo.sampleRate())
|
||||
.setChannels(meetingInfo.channels())
|
||||
.setEncoding(meetingInfo.encoding())
|
||||
.build();
|
||||
|
||||
requestObserver.onNext(RealtimeClientPacket.newBuilder()
|
||||
.setRequestId("manual-open")
|
||||
.setAuth(auth)
|
||||
.setOpen(open)
|
||||
.build());
|
||||
}
|
||||
|
||||
private boolean awaitReady(long timeout, TimeUnit timeUnit) throws InterruptedException {
|
||||
return readyLatch.await(timeout, timeUnit);
|
||||
}
|
||||
|
||||
private void streamPcmFile(Path pcmFile, int chunkMs) throws Exception {
|
||||
int bytesPerSample = 2;
|
||||
int chunkSize = meetingInfo.sampleRate() * meetingInfo.channels() * bytesPerSample * chunkMs / 1000;
|
||||
byte[] buffer = new byte[chunkSize];
|
||||
long seq = 1L;
|
||||
|
||||
try (InputStream inputStream = Files.newInputStream(pcmFile)) {
|
||||
while (true) {
|
||||
int read = inputStream.read(buffer);
|
||||
if (read <= 0) {
|
||||
break;
|
||||
}
|
||||
AudioChunk audioChunk = AudioChunk.newBuilder()
|
||||
.setPcm16(ByteString.copyFrom(buffer, 0, read))
|
||||
.setSeq(seq)
|
||||
.setClientTime(System.currentTimeMillis())
|
||||
.setLastChunk(false)
|
||||
.build();
|
||||
requestObserver.onNext(RealtimeClientPacket.newBuilder()
|
||||
.setRequestId("manual-audio-" + seq)
|
||||
.setAudio(audioChunk)
|
||||
.build());
|
||||
seq++;
|
||||
Thread.sleep(chunkMs);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void sendStopSpeaking() {
|
||||
requestObserver.onNext(RealtimeClientPacket.newBuilder()
|
||||
.setRequestId("manual-stop-speaking")
|
||||
.setControl(RealtimeControl.newBuilder()
|
||||
.setType(RealtimeControl.ControlType.STOP_SPEAKING)
|
||||
.build())
|
||||
.build());
|
||||
}
|
||||
|
||||
private void sendCloseStream() {
|
||||
requestObserver.onNext(RealtimeClientPacket.newBuilder()
|
||||
.setRequestId("manual-close-stream")
|
||||
.setControl(RealtimeControl.newBuilder()
|
||||
.setType(RealtimeControl.ControlType.CLOSE_STREAM)
|
||||
.build())
|
||||
.build());
|
||||
}
|
||||
|
||||
private void completeClientStream() {
|
||||
requestObserver.onCompleted();
|
||||
}
|
||||
|
||||
private boolean awaitClosed(long timeout, TimeUnit timeUnit) throws InterruptedException {
|
||||
return closedLatch.await(timeout, timeUnit);
|
||||
}
|
||||
|
||||
private boolean isReadyReceived() {
|
||||
return readyReceived;
|
||||
}
|
||||
|
||||
private int getTranscriptCount() {
|
||||
return transcriptCount.get();
|
||||
}
|
||||
|
||||
private String getErrorMessage() {
|
||||
return errorMessage.get();
|
||||
}
|
||||
|
||||
private void shutdown() throws InterruptedException {
|
||||
channel.shutdownNow();
|
||||
channel.awaitTermination(5, TimeUnit.SECONDS);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,63 @@
|
|||
package com.imeeting.service.android.impl;
|
||||
|
||||
import com.imeeting.config.grpc.AndroidGrpcAuthProperties;
|
||||
import com.imeeting.dto.android.AndroidAuthContext;
|
||||
import com.unisbase.dto.InternalAuthCheckResponse;
|
||||
import com.unisbase.service.TokenValidationService;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.security.core.context.SecurityContextHolder;
|
||||
|
||||
import java.util.Set;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
class AndroidAuthServiceImplTest {
|
||||
|
||||
@AfterEach
|
||||
void clearSecurityContext() {
|
||||
SecurityContextHolder.clearContext();
|
||||
}
|
||||
|
||||
@Test
|
||||
void authenticateHttpShouldResolveBearerTokenAndIdentity() {
|
||||
AndroidGrpcAuthProperties properties = new AndroidGrpcAuthProperties();
|
||||
TokenValidationService tokenValidationService = mock(TokenValidationService.class);
|
||||
AndroidAuthServiceImpl service = new AndroidAuthServiceImpl(properties, tokenValidationService);
|
||||
HttpServletRequest request = mock(HttpServletRequest.class);
|
||||
|
||||
when(request.getHeader("Authorization")).thenReturn("Bearer access-token");
|
||||
when(request.getHeader("X-Android-Device-Id")).thenReturn("device-01");
|
||||
when(request.getHeader("X-Android-App-Id")).thenReturn("imeeting");
|
||||
when(request.getHeader("X-Android-App-Version")).thenReturn("1.0.0");
|
||||
when(request.getHeader("X-Android-Platform")).thenReturn("android");
|
||||
|
||||
InternalAuthCheckResponse authResult = new InternalAuthCheckResponse();
|
||||
authResult.setValid(true);
|
||||
authResult.setUserId(11L);
|
||||
authResult.setTenantId(22L);
|
||||
authResult.setUsername("alice");
|
||||
authResult.setPlatformAdmin(false);
|
||||
authResult.setTenantAdmin(true);
|
||||
authResult.setPermissions(Set.of("meeting:create"));
|
||||
when(tokenValidationService.validateAccessToken("access-token")).thenReturn(authResult);
|
||||
|
||||
AndroidAuthContext context = service.authenticateHttp(request);
|
||||
|
||||
assertFalse(context.isAnonymous());
|
||||
assertEquals("USER_JWT", context.getAuthMode());
|
||||
assertEquals("device-01", context.getDeviceId());
|
||||
assertEquals(11L, context.getUserId());
|
||||
assertEquals(22L, context.getTenantId());
|
||||
assertEquals("alice", context.getUsername());
|
||||
assertEquals("alice", context.getDisplayName());
|
||||
assertTrue(context.getTenantAdmin());
|
||||
assertEquals(Set.of("meeting:create"), context.getPermissions());
|
||||
assertEquals("access-token", context.getAccessToken());
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,314 @@
|
|||
package com.imeeting.service.biz.impl;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.imeeting.dto.biz.CreateMeetingCommand;
|
||||
import com.imeeting.dto.biz.CreateRealtimeMeetingCommand;
|
||||
import com.imeeting.dto.biz.MeetingVO;
|
||||
import com.imeeting.entity.biz.Meeting;
|
||||
import com.imeeting.service.biz.AiTaskService;
|
||||
import com.imeeting.service.biz.HotWordService;
|
||||
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 org.junit.jupiter.api.Test;
|
||||
import org.mockito.ArgumentCaptor;
|
||||
import org.springframework.data.redis.core.StringRedisTemplate;
|
||||
import org.springframework.transaction.support.TransactionSynchronizationManager;
|
||||
import org.springframework.transaction.support.TransactionSynchronizationUtils;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertNull;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.ArgumentMatchers.isNull;
|
||||
import static org.mockito.Mockito.doAnswer;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
class MeetingCommandServiceImplTest {
|
||||
|
||||
@Test
|
||||
void createMeetingShouldDefaultHostToCreatorWhenHostOmitted() {
|
||||
MeetingService meetingService = mock(MeetingService.class);
|
||||
MeetingDomainSupport meetingDomainSupport = mock(MeetingDomainSupport.class);
|
||||
Meeting meeting = new Meeting();
|
||||
meeting.setId(101L);
|
||||
meeting.setTenantId(1L);
|
||||
meeting.setHostUserId(7L);
|
||||
meeting.setHostName("creator");
|
||||
|
||||
when(meetingDomainSupport.initMeeting(
|
||||
eq("Design Review"),
|
||||
any(LocalDateTime.class),
|
||||
eq("1,2"),
|
||||
eq("web"),
|
||||
eq("/audio/demo.wav"),
|
||||
eq(1L),
|
||||
eq(7L),
|
||||
eq("creator"),
|
||||
eq(7L),
|
||||
eq("creator"),
|
||||
eq(0)
|
||||
)).thenReturn(meeting);
|
||||
when(meetingDomainSupport.relocateAudioUrl(eq(101L), eq("/audio/demo.wav"))).thenReturn("/audio/demo.wav");
|
||||
fillHostFieldsFromMeeting(meetingDomainSupport);
|
||||
|
||||
MeetingCommandServiceImpl service = newService(meetingService, meetingDomainSupport);
|
||||
|
||||
CreateMeetingCommand command = new CreateMeetingCommand();
|
||||
command.setTitle("Design Review");
|
||||
command.setMeetingTime(LocalDateTime.of(2026, 4, 3, 19, 0));
|
||||
command.setParticipants("1,2");
|
||||
command.setTags("web");
|
||||
command.setAudioUrl("/audio/demo.wav");
|
||||
command.setAsrModelId(11L);
|
||||
command.setSummaryModelId(22L);
|
||||
command.setPromptId(33L);
|
||||
command.setHotWords(java.util.List.of("design"));
|
||||
|
||||
MeetingVO result = service.createMeeting(command, 1L, 7L, "creator");
|
||||
|
||||
ArgumentCaptor<Meeting> meetingCaptor = ArgumentCaptor.forClass(Meeting.class);
|
||||
verify(meetingService).save(meetingCaptor.capture());
|
||||
assertEquals(7L, meetingCaptor.getValue().getHostUserId());
|
||||
assertEquals("creator", meetingCaptor.getValue().getHostName());
|
||||
assertEquals(7L, result.getHostUserId());
|
||||
assertEquals("creator", result.getHostName());
|
||||
}
|
||||
|
||||
@Test
|
||||
void createRealtimeMeetingShouldDefaultHostToCreatorWhenHostOmitted() {
|
||||
MeetingService meetingService = mock(MeetingService.class);
|
||||
MeetingDomainSupport meetingDomainSupport = mock(MeetingDomainSupport.class);
|
||||
Meeting meeting = new Meeting();
|
||||
meeting.setId(101L);
|
||||
meeting.setHostUserId(7L);
|
||||
meeting.setHostName("creator");
|
||||
|
||||
when(meetingDomainSupport.initMeeting(
|
||||
eq("Design Review"),
|
||||
any(LocalDateTime.class),
|
||||
eq("1,2"),
|
||||
eq("web"),
|
||||
isNull(),
|
||||
eq(1L),
|
||||
eq(7L),
|
||||
eq("creator"),
|
||||
eq(7L),
|
||||
eq("creator"),
|
||||
eq(0)
|
||||
)).thenReturn(meeting);
|
||||
fillHostFieldsFromMeeting(meetingDomainSupport);
|
||||
|
||||
MeetingCommandServiceImpl service = newService(meetingService, meetingDomainSupport);
|
||||
|
||||
CreateRealtimeMeetingCommand command = new CreateRealtimeMeetingCommand();
|
||||
command.setTitle("Design Review");
|
||||
command.setMeetingTime(LocalDateTime.of(2026, 4, 3, 19, 0));
|
||||
command.setParticipants("1,2");
|
||||
command.setTags("web");
|
||||
command.setAsrModelId(11L);
|
||||
command.setSummaryModelId(22L);
|
||||
command.setPromptId(33L);
|
||||
|
||||
MeetingVO result = service.createRealtimeMeeting(command, 1L, 7L, "creator");
|
||||
|
||||
ArgumentCaptor<Meeting> meetingCaptor = ArgumentCaptor.forClass(Meeting.class);
|
||||
verify(meetingService).save(meetingCaptor.capture());
|
||||
assertEquals(7L, meetingCaptor.getValue().getHostUserId());
|
||||
assertEquals("creator", meetingCaptor.getValue().getHostName());
|
||||
assertEquals(7L, result.getHostUserId());
|
||||
assertEquals("creator", result.getHostName());
|
||||
}
|
||||
|
||||
@Test
|
||||
void createRealtimeMeetingShouldNotFallbackCreatorNameForDelegateHost() {
|
||||
MeetingService meetingService = mock(MeetingService.class);
|
||||
MeetingDomainSupport meetingDomainSupport = mock(MeetingDomainSupport.class);
|
||||
Meeting meeting = new Meeting();
|
||||
meeting.setId(101L);
|
||||
meeting.setHostUserId(99L);
|
||||
meeting.setHostName(null);
|
||||
|
||||
when(meetingDomainSupport.initMeeting(
|
||||
eq("Design Review"),
|
||||
any(LocalDateTime.class),
|
||||
eq("1,2"),
|
||||
eq("android"),
|
||||
isNull(),
|
||||
eq(1L),
|
||||
eq(7L),
|
||||
eq("creator"),
|
||||
eq(99L),
|
||||
isNull(),
|
||||
eq(0)
|
||||
)).thenReturn(meeting);
|
||||
|
||||
fillHostFieldsFromMeeting(meetingDomainSupport);
|
||||
|
||||
MeetingCommandServiceImpl service = newService(meetingService, meetingDomainSupport);
|
||||
|
||||
CreateRealtimeMeetingCommand command = new CreateRealtimeMeetingCommand();
|
||||
command.setTitle("Design Review");
|
||||
command.setMeetingTime(LocalDateTime.of(2026, 4, 3, 19, 0));
|
||||
command.setParticipants("1,2");
|
||||
command.setTags("android");
|
||||
command.setHostUserId(99L);
|
||||
command.setAsrModelId(11L);
|
||||
command.setSummaryModelId(22L);
|
||||
command.setPromptId(33L);
|
||||
|
||||
MeetingVO result = service.createRealtimeMeeting(command, 1L, 7L, "creator");
|
||||
|
||||
ArgumentCaptor<Meeting> meetingCaptor = ArgumentCaptor.forClass(Meeting.class);
|
||||
verify(meetingService).save(meetingCaptor.capture());
|
||||
assertEquals(99L, meetingCaptor.getValue().getHostUserId());
|
||||
assertNull(meetingCaptor.getValue().getHostName());
|
||||
assertEquals(99L, result.getHostUserId());
|
||||
assertNull(result.getHostName());
|
||||
}
|
||||
|
||||
|
||||
@Test
|
||||
void completeRealtimeMeetingShouldBindFinalizedRealtimeAudio() {
|
||||
MeetingService meetingService = mock(MeetingService.class);
|
||||
MeetingDomainSupport meetingDomainSupport = mock(MeetingDomainSupport.class);
|
||||
com.imeeting.mapper.biz.MeetingTranscriptMapper transcriptMapper = mock(com.imeeting.mapper.biz.MeetingTranscriptMapper.class);
|
||||
RealtimeMeetingAudioStorageService audioStorageService = mock(RealtimeMeetingAudioStorageService.class);
|
||||
AiTaskService aiTaskService = mock(AiTaskService.class);
|
||||
RealtimeMeetingSessionStateService sessionStateService = mock(RealtimeMeetingSessionStateService.class);
|
||||
Meeting meeting = new Meeting();
|
||||
meeting.setId(202L);
|
||||
meeting.setStatus(0);
|
||||
|
||||
when(meetingService.getById(202L)).thenReturn(meeting);
|
||||
when(transcriptMapper.selectCount(any())).thenReturn(1L);
|
||||
when(audioStorageService.finalizeMeetingAudio(202L))
|
||||
.thenReturn(new RealtimeMeetingAudioStorageService.FinalizeResult(RealtimeMeetingAudioStorageService.STATUS_SUCCESS, "/api/static/meetings/202/source_audio.wav", null));
|
||||
|
||||
MeetingCommandServiceImpl service = new MeetingCommandServiceImpl(
|
||||
meetingService,
|
||||
aiTaskService,
|
||||
mock(HotWordService.class),
|
||||
transcriptMapper,
|
||||
mock(MeetingSummaryFileService.class),
|
||||
meetingDomainSupport,
|
||||
sessionStateService,
|
||||
audioStorageService,
|
||||
mock(StringRedisTemplate.class),
|
||||
new ObjectMapper()
|
||||
);
|
||||
|
||||
service.completeRealtimeMeeting(202L, null, false);
|
||||
|
||||
ArgumentCaptor<Meeting> meetingCaptor = ArgumentCaptor.forClass(Meeting.class);
|
||||
verify(meetingService).updateById(meetingCaptor.capture());
|
||||
assertEquals("/api/static/meetings/202/source_audio.wav", meetingCaptor.getValue().getAudioUrl());
|
||||
assertEquals(RealtimeMeetingAudioStorageService.STATUS_SUCCESS, meetingCaptor.getValue().getAudioSaveStatus());
|
||||
verify(aiTaskService).dispatchSummaryTask(202L);
|
||||
}
|
||||
|
||||
@Test
|
||||
void completeRealtimeMeetingShouldKeepExplicitAudioUrlAndSkipFinalize() {
|
||||
MeetingService meetingService = mock(MeetingService.class);
|
||||
MeetingDomainSupport meetingDomainSupport = mock(MeetingDomainSupport.class);
|
||||
com.imeeting.mapper.biz.MeetingTranscriptMapper transcriptMapper = mock(com.imeeting.mapper.biz.MeetingTranscriptMapper.class);
|
||||
RealtimeMeetingAudioStorageService audioStorageService = mock(RealtimeMeetingAudioStorageService.class);
|
||||
AiTaskService aiTaskService = mock(AiTaskService.class);
|
||||
Meeting meeting = new Meeting();
|
||||
meeting.setId(203L);
|
||||
|
||||
when(meetingService.getById(203L)).thenReturn(meeting);
|
||||
when(meetingDomainSupport.relocateAudioUrl(203L, "/api/static/audio/manual.wav"))
|
||||
.thenReturn("/api/static/meetings/203/source_audio.wav");
|
||||
when(transcriptMapper.selectCount(any())).thenReturn(1L);
|
||||
|
||||
MeetingCommandServiceImpl service = new MeetingCommandServiceImpl(
|
||||
meetingService,
|
||||
aiTaskService,
|
||||
mock(HotWordService.class),
|
||||
transcriptMapper,
|
||||
mock(MeetingSummaryFileService.class),
|
||||
meetingDomainSupport,
|
||||
mock(RealtimeMeetingSessionStateService.class),
|
||||
audioStorageService,
|
||||
mock(StringRedisTemplate.class),
|
||||
new ObjectMapper()
|
||||
);
|
||||
|
||||
service.completeRealtimeMeeting(203L, "/api/static/audio/manual.wav", false);
|
||||
|
||||
verify(audioStorageService, never()).finalizeMeetingAudio(203L);
|
||||
}
|
||||
|
||||
@Test
|
||||
void reSummaryShouldDispatchAfterTransactionCommit() {
|
||||
MeetingService meetingService = mock(MeetingService.class);
|
||||
MeetingDomainSupport meetingDomainSupport = mock(MeetingDomainSupport.class);
|
||||
AiTaskService aiTaskService = mock(AiTaskService.class);
|
||||
Meeting meeting = new Meeting();
|
||||
meeting.setId(301L);
|
||||
|
||||
when(meetingService.getById(301L)).thenReturn(meeting);
|
||||
|
||||
MeetingCommandServiceImpl service = new MeetingCommandServiceImpl(
|
||||
meetingService,
|
||||
aiTaskService,
|
||||
mock(HotWordService.class),
|
||||
mock(com.imeeting.mapper.biz.MeetingTranscriptMapper.class),
|
||||
mock(MeetingSummaryFileService.class),
|
||||
meetingDomainSupport,
|
||||
mock(RealtimeMeetingSessionStateService.class),
|
||||
mock(RealtimeMeetingAudioStorageService.class),
|
||||
mock(StringRedisTemplate.class),
|
||||
new ObjectMapper()
|
||||
);
|
||||
|
||||
TransactionSynchronizationManager.initSynchronization();
|
||||
try {
|
||||
service.reSummary(301L, 22L, 33L);
|
||||
|
||||
verify(meetingDomainSupport).createSummaryTask(301L, 22L, 33L);
|
||||
assertEquals(2, meeting.getStatus());
|
||||
verify(meetingService).updateById(meeting);
|
||||
verify(aiTaskService, never()).dispatchSummaryTask(301L);
|
||||
|
||||
TransactionSynchronizationUtils.triggerAfterCommit();
|
||||
|
||||
verify(aiTaskService).dispatchSummaryTask(301L);
|
||||
} finally {
|
||||
TransactionSynchronizationManager.clearSynchronization();
|
||||
}
|
||||
}
|
||||
|
||||
private MeetingCommandServiceImpl newService(MeetingService meetingService, MeetingDomainSupport meetingDomainSupport) {
|
||||
return new MeetingCommandServiceImpl(
|
||||
meetingService,
|
||||
mock(AiTaskService.class),
|
||||
mock(HotWordService.class),
|
||||
mock(com.imeeting.mapper.biz.MeetingTranscriptMapper.class),
|
||||
mock(MeetingSummaryFileService.class),
|
||||
meetingDomainSupport,
|
||||
mock(RealtimeMeetingSessionStateService.class),
|
||||
mock(RealtimeMeetingAudioStorageService.class),
|
||||
mock(StringRedisTemplate.class),
|
||||
new ObjectMapper()
|
||||
);
|
||||
}
|
||||
|
||||
private void fillHostFieldsFromMeeting(MeetingDomainSupport meetingDomainSupport) {
|
||||
doAnswer(invocation -> {
|
||||
MeetingVO vo = invocation.getArgument(1);
|
||||
Meeting source = invocation.getArgument(0);
|
||||
vo.setId(source.getId());
|
||||
vo.setHostUserId(source.getHostUserId());
|
||||
vo.setHostName(source.getHostName());
|
||||
return null;
|
||||
}).when(meetingDomainSupport).fillMeetingVO(any(Meeting.class), any(MeetingVO.class), eq(false));
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,114 @@
|
|||
package com.imeeting.service.biz.impl;
|
||||
|
||||
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.unisbase.mapper.SysUserMapper;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
import org.springframework.context.ApplicationEventPublisher;
|
||||
import org.springframework.test.util.ReflectionTestUtils;
|
||||
import org.springframework.transaction.support.TransactionSynchronization;
|
||||
import org.springframework.transaction.support.TransactionSynchronizationManager;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
import static org.mockito.Mockito.mock;
|
||||
|
||||
class MeetingDomainSupportTest {
|
||||
|
||||
@TempDir
|
||||
Path tempDir;
|
||||
|
||||
@AfterEach
|
||||
void clearSynchronization() {
|
||||
if (TransactionSynchronizationManager.isSynchronizationActive()) {
|
||||
TransactionSynchronizationManager.clearSynchronization();
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldKeepRelocatedAudioAfterCommit() throws Exception {
|
||||
MeetingDomainSupport support = newSupport();
|
||||
Path source = writeFile(tempDir.resolve("uploads/audio/offline.wav"), "offline-audio");
|
||||
|
||||
TransactionSynchronizationManager.initSynchronization();
|
||||
String relocatedUrl = support.relocateAudioUrl(101L, "/api/static/audio/offline.wav");
|
||||
triggerAfterCompletion(TransactionSynchronization.STATUS_COMMITTED);
|
||||
|
||||
Path target = tempDir.resolve("uploads/meetings/101/source_audio.wav");
|
||||
assertEquals("/api/static/meetings/101/source_audio.wav", relocatedUrl);
|
||||
assertFalse(Files.exists(source));
|
||||
assertTrue(Files.exists(target));
|
||||
assertEquals("offline-audio", Files.readString(target, StandardCharsets.UTF_8));
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldRestoreSourceAndTargetWhenTransactionRollsBack() throws Exception {
|
||||
MeetingDomainSupport support = newSupport();
|
||||
Path source = writeFile(tempDir.resolve("uploads/audio/offline.wav"), "new-audio");
|
||||
Path target = writeFile(tempDir.resolve("uploads/meetings/102/source_audio.wav"), "old-audio");
|
||||
|
||||
TransactionSynchronizationManager.initSynchronization();
|
||||
String relocatedUrl = support.relocateAudioUrl(102L, "/api/static/audio/offline.wav");
|
||||
|
||||
assertEquals("/api/static/meetings/102/source_audio.wav", relocatedUrl);
|
||||
assertFalse(Files.exists(source));
|
||||
assertTrue(Files.exists(target));
|
||||
assertEquals("new-audio", Files.readString(target, StandardCharsets.UTF_8));
|
||||
|
||||
triggerAfterCompletion(TransactionSynchronization.STATUS_ROLLED_BACK);
|
||||
|
||||
assertTrue(Files.exists(source));
|
||||
assertTrue(Files.exists(target));
|
||||
assertEquals("new-audio", Files.readString(source, StandardCharsets.UTF_8));
|
||||
assertEquals("old-audio", Files.readString(target, StandardCharsets.UTF_8));
|
||||
assertFalse(hasBackupFile(tempDir.resolve("uploads/meetings/102")));
|
||||
}
|
||||
|
||||
private MeetingDomainSupport newSupport() {
|
||||
MeetingDomainSupport support = new MeetingDomainSupport(
|
||||
mock(PromptTemplateService.class),
|
||||
mock(AiTaskService.class),
|
||||
mock(MeetingTranscriptMapper.class),
|
||||
mock(SysUserMapper.class),
|
||||
mock(ApplicationEventPublisher.class),
|
||||
mock(MeetingSummaryFileService.class)
|
||||
);
|
||||
ReflectionTestUtils.setField(support, "uploadPath", tempDir.resolve("uploads").toString());
|
||||
return support;
|
||||
}
|
||||
|
||||
private void triggerAfterCompletion(int status) {
|
||||
for (TransactionSynchronization synchronization : TransactionSynchronizationManager.getSynchronizations()) {
|
||||
synchronization.afterCompletion(status);
|
||||
}
|
||||
TransactionSynchronizationManager.clearSynchronization();
|
||||
}
|
||||
|
||||
private Path writeFile(Path path, String content) throws IOException {
|
||||
Files.createDirectories(path.getParent());
|
||||
Files.writeString(path, content, StandardCharsets.UTF_8);
|
||||
return path;
|
||||
}
|
||||
|
||||
private boolean hasBackupFile(Path directory) throws IOException {
|
||||
if (!Files.exists(directory)) {
|
||||
return false;
|
||||
}
|
||||
try (var stream = Files.list(directory)) {
|
||||
return stream
|
||||
.map(Path::getFileName)
|
||||
.map(Path::toString)
|
||||
.anyMatch(name -> name.contains(".rollback-") && name.endsWith(".bak"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,114 @@
|
|||
package com.imeeting.service.biz.impl;
|
||||
|
||||
import com.imeeting.dto.biz.AiModelVO;
|
||||
import com.imeeting.dto.biz.RealtimeMeetingRuntimeProfile;
|
||||
import com.imeeting.entity.biz.PromptTemplate;
|
||||
import com.imeeting.mapper.biz.AsrModelMapper;
|
||||
import com.imeeting.mapper.biz.LlmModelMapper;
|
||||
import com.imeeting.service.biz.AiModelService;
|
||||
import com.imeeting.service.biz.PromptTemplateService;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertIterableEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
class MeetingRuntimeProfileResolverImplTest {
|
||||
|
||||
@Test
|
||||
void resolveShouldUseRequestedResourcesAndNormalizeHotWords() {
|
||||
AiModelService aiModelService = mock(AiModelService.class);
|
||||
PromptTemplateService promptTemplateService = mock(PromptTemplateService.class);
|
||||
MeetingRuntimeProfileResolverImpl resolver = new MeetingRuntimeProfileResolverImpl(
|
||||
aiModelService,
|
||||
promptTemplateService,
|
||||
mock(AsrModelMapper.class),
|
||||
mock(LlmModelMapper.class)
|
||||
);
|
||||
|
||||
when(aiModelService.getModelById(11L, "ASR")).thenReturn(enabledModel(11L, 1L, "ASR-Model"));
|
||||
when(aiModelService.getModelById(22L, "LLM")).thenReturn(enabledModel(22L, 1L, "LLM-Model"));
|
||||
when(promptTemplateService.getById(33L)).thenReturn(enabledPrompt(33L, 1L, "Summary Prompt"));
|
||||
|
||||
RealtimeMeetingRuntimeProfile profile = resolver.resolve(
|
||||
1L,
|
||||
11L,
|
||||
22L,
|
||||
33L,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
Boolean.TRUE,
|
||||
Boolean.TRUE,
|
||||
List.of(" alpha ", "", "alpha", "beta", null)
|
||||
);
|
||||
|
||||
assertEquals(11L, profile.getResolvedAsrModelId());
|
||||
assertEquals("ASR-Model", profile.getResolvedAsrModelName());
|
||||
assertEquals(22L, profile.getResolvedSummaryModelId());
|
||||
assertEquals("LLM-Model", profile.getResolvedSummaryModelName());
|
||||
assertEquals(33L, profile.getResolvedPromptId());
|
||||
assertEquals("Summary Prompt", profile.getResolvedPromptName());
|
||||
assertEquals("2pass", profile.getResolvedMode());
|
||||
assertEquals("auto", profile.getResolvedLanguage());
|
||||
assertEquals(1, profile.getResolvedUseSpkId());
|
||||
assertEquals(Boolean.TRUE, profile.getResolvedEnablePunctuation());
|
||||
assertEquals(Boolean.TRUE, profile.getResolvedEnableItn());
|
||||
assertEquals(Boolean.TRUE, profile.getResolvedEnableTextRefine());
|
||||
assertEquals(Boolean.TRUE, profile.getResolvedSaveAudio());
|
||||
assertIterableEquals(List.of("alpha", "beta"), profile.getResolvedHotWords());
|
||||
}
|
||||
|
||||
@Test
|
||||
void resolveShouldRejectCrossTenantModel() {
|
||||
AiModelService aiModelService = mock(AiModelService.class);
|
||||
PromptTemplateService promptTemplateService = mock(PromptTemplateService.class);
|
||||
MeetingRuntimeProfileResolverImpl resolver = new MeetingRuntimeProfileResolverImpl(
|
||||
aiModelService,
|
||||
promptTemplateService,
|
||||
mock(AsrModelMapper.class),
|
||||
mock(LlmModelMapper.class)
|
||||
);
|
||||
|
||||
when(aiModelService.getModelById(11L, "ASR")).thenReturn(enabledModel(11L, 2L, "ASR-Model"));
|
||||
|
||||
assertThrows(RuntimeException.class, () -> resolver.resolve(
|
||||
1L,
|
||||
11L,
|
||||
22L,
|
||||
33L,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
List.of()
|
||||
));
|
||||
}
|
||||
|
||||
private AiModelVO enabledModel(Long id, Long tenantId, String name) {
|
||||
AiModelVO model = new AiModelVO();
|
||||
model.setId(id);
|
||||
model.setTenantId(tenantId);
|
||||
model.setModelName(name);
|
||||
model.setStatus(1);
|
||||
return model;
|
||||
}
|
||||
|
||||
private PromptTemplate enabledPrompt(Long id, Long tenantId, String name) {
|
||||
PromptTemplate template = new PromptTemplate();
|
||||
template.setId(id);
|
||||
template.setTenantId(tenantId);
|
||||
template.setTemplateName(name);
|
||||
template.setStatus(1);
|
||||
return template;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,63 @@
|
|||
package com.imeeting.service.realtime.impl;
|
||||
|
||||
import com.imeeting.service.realtime.RealtimeMeetingAudioStorageService;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
import org.springframework.test.util.ReflectionTestUtils;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertArrayEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
class RealtimeMeetingAudioStorageServiceImplTest {
|
||||
|
||||
@TempDir
|
||||
Path tempDir;
|
||||
|
||||
@Test
|
||||
void shouldAppendMultipleSessionsAndFinalizeWav() throws Exception {
|
||||
RealtimeMeetingAudioStorageServiceImpl service = newService();
|
||||
|
||||
service.openSession(101L, "ws-1");
|
||||
service.append("ws-1", new byte[]{1, 2, 3, 4});
|
||||
service.closeSession("ws-1");
|
||||
|
||||
service.openSession(101L, "ws-2");
|
||||
service.append("ws-2", new byte[]{5, 6});
|
||||
service.closeSession("ws-2");
|
||||
|
||||
RealtimeMeetingAudioStorageService.FinalizeResult result = service.finalizeMeetingAudio(101L);
|
||||
|
||||
assertEquals(RealtimeMeetingAudioStorageService.STATUS_SUCCESS, result.status());
|
||||
assertEquals("/api/static/meetings/101/source_audio.wav", result.audioUrl());
|
||||
|
||||
Path wavPath = tempDir.resolve("uploads/meetings/101/source_audio.wav");
|
||||
byte[] wav = Files.readAllBytes(wavPath);
|
||||
assertEquals(50, wav.length);
|
||||
assertEquals("RIFF", new String(wav, 0, 4, StandardCharsets.US_ASCII));
|
||||
assertEquals("WAVE", new String(wav, 8, 4, StandardCharsets.US_ASCII));
|
||||
assertEquals("data", new String(wav, 36, 4, StandardCharsets.US_ASCII));
|
||||
assertArrayEquals(new byte[]{1, 2, 3, 4, 5, 6}, java.util.Arrays.copyOfRange(wav, 44, 50));
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldReportFailureWhenNoPcmWasCaptured() {
|
||||
RealtimeMeetingAudioStorageServiceImpl service = newService();
|
||||
|
||||
RealtimeMeetingAudioStorageService.FinalizeResult result = service.finalizeMeetingAudio(202L);
|
||||
|
||||
assertEquals(RealtimeMeetingAudioStorageService.STATUS_FAILED, result.status());
|
||||
assertTrue(result.message().contains("音频保存失败"));
|
||||
}
|
||||
|
||||
private RealtimeMeetingAudioStorageServiceImpl newService() {
|
||||
RealtimeMeetingAudioStorageServiceImpl service = new RealtimeMeetingAudioStorageServiceImpl();
|
||||
ReflectionTestUtils.setField(service, "uploadPath", tempDir.resolve("uploads").toString());
|
||||
ReflectionTestUtils.setField(service, "resourcePrefix", "/api/static/");
|
||||
return service;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
import http from "../http";
|
||||
import http from "../http";
|
||||
import axios from "axios";
|
||||
|
||||
export interface MeetingVO {
|
||||
|
|
@ -6,12 +6,16 @@ export interface MeetingVO {
|
|||
tenantId: number;
|
||||
creatorId: number;
|
||||
creatorName?: string;
|
||||
hostUserId?: number;
|
||||
hostName?: string;
|
||||
title: string;
|
||||
meetingTime: string;
|
||||
participants: string;
|
||||
participantIds?: number[];
|
||||
tags: string;
|
||||
audioUrl: string;
|
||||
audioSaveStatus?: "NONE" | "SUCCESS" | "FAILED";
|
||||
audioSaveMessage?: string;
|
||||
summaryContent: string;
|
||||
analysis?: {
|
||||
overview?: string;
|
||||
|
|
@ -33,6 +37,8 @@ export interface CreateMeetingCommand {
|
|||
meetingTime: string;
|
||||
participants: string;
|
||||
tags: string;
|
||||
hostUserId?: number;
|
||||
hostName?: string;
|
||||
audioUrl?: string;
|
||||
asrModelId: number;
|
||||
summaryModelId?: number;
|
||||
|
|
@ -49,6 +55,8 @@ export interface CreateRealtimeMeetingCommand {
|
|||
meetingTime: string;
|
||||
participants: string;
|
||||
tags: string;
|
||||
hostUserId?: number;
|
||||
hostName?: string;
|
||||
asrModelId: number;
|
||||
summaryModelId?: number;
|
||||
promptId: number;
|
||||
|
|
@ -179,7 +187,7 @@ export const openRealtimeMeetingSocketSession = (
|
|||
);
|
||||
};
|
||||
|
||||
export const completeRealtimeMeeting = (meetingId: number, data?: { audioUrl?: string }) => {
|
||||
export const completeRealtimeMeeting = (meetingId: number, data?: { audioUrl?: string; overwriteAudio?: boolean }) => {
|
||||
return http.post<any, { code: string; data: boolean; msg: string }>(
|
||||
`/api/biz/meeting/${meetingId}/realtime/complete`,
|
||||
data || {}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useNavigate, useParams } from 'react-router-dom';
|
||||
import {
|
||||
Alert,
|
||||
Avatar,
|
||||
Breadcrumb,
|
||||
Button,
|
||||
|
|
@ -600,6 +601,12 @@ const MeetingDetail: React.FC = () => {
|
|||
setSelectedKeywords((current) => current.filter((item) => analysis.keywords.includes(item)));
|
||||
}, [analysis.keywords]);
|
||||
|
||||
useEffect(() => {
|
||||
if (meeting?.audioSaveStatus === 'FAILED') {
|
||||
message.warning(meeting.audioSaveMessage || '实时会议已完成,但音频保存失败,当前无法播放会议录音。转写和总结不受影响。');
|
||||
}
|
||||
}, [meeting?.id, meeting?.audioSaveStatus, meeting?.audioSaveMessage]);
|
||||
|
||||
useEffect(() => {
|
||||
const audio = audioRef.current;
|
||||
if (!audio) return undefined;
|
||||
|
|
@ -1201,6 +1208,14 @@ const MeetingDetail: React.FC = () => {
|
|||
|
||||
<Card className="left-flow-card" bordered={false} title={<span><AudioOutlined /> 原文</span>}>
|
||||
{meeting.audioUrl && <audio ref={audioRef} src={meeting.audioUrl} style={{ display: 'none' }} preload="metadata" />}
|
||||
{meeting.audioSaveStatus === 'FAILED' && (
|
||||
<Alert
|
||||
type="warning"
|
||||
showIcon
|
||||
style={{ marginBottom: 16 }}
|
||||
message={meeting.audioSaveMessage || '实时会议已完成,但音频保存失败,当前无法播放会议录音。转写和总结不受影响。'}
|
||||
/>
|
||||
)}
|
||||
<List
|
||||
dataSource={transcripts}
|
||||
renderItem={(item) => (
|
||||
|
|
@ -1991,4 +2006,3 @@ const MeetingDetail: React.FC = () => {
|
|||
};
|
||||
|
||||
export default MeetingDetail;
|
||||
|
||||
|
|
|
|||
|
|
@ -186,11 +186,16 @@ const MeetingCreateForm: React.FC<{
|
|||
<Col span={12}><Form.Item name="meetingTime" label="会议时间" rules={[{ required: true }]} style={{ marginBottom: 12 }}><DatePicker showTime style={{ width: '100%' }} size="large" /></Form.Item></Col>
|
||||
<Col span={12}><Form.Item name="tags" label="会议标签" style={{ marginBottom: 12 }}><Select mode="tags" placeholder="输入标签" size="large" /></Form.Item></Col>
|
||||
</Row>
|
||||
<Form.Item name="participants" label="参会人员" style={{ marginBottom: 0 }}>
|
||||
<Form.Item name="participants" label="参会人员" style={{ marginBottom: 12 }}>
|
||||
<Select mode="multiple" placeholder="选择人员" showSearch optionFilterProp="children" size="large">
|
||||
{userList.map(u => (<Option key={u.userId} value={u.userId}><Space><Avatar size="small" icon={<UserOutlined />} />{u.displayName || u.username}</Space></Option>))}
|
||||
</Select>
|
||||
</Form.Item>
|
||||
<Form.Item name="hostUserId" label="会议主持人" style={{ marginBottom: 0 }}>
|
||||
<Select allowClear placeholder="不选择则默认为创建人" showSearch optionFilterProp="children" size="large">
|
||||
{userList.map(u => (<Option key={u.userId} value={u.userId}><Space><Avatar size="small" icon={<UserOutlined />} />{u.displayName || u.username}</Space></Option>))}
|
||||
</Select>
|
||||
</Form.Item>
|
||||
</Card>
|
||||
|
||||
{/* 录音上传卡片 - 占满剩余高度 */}
|
||||
|
|
@ -487,14 +492,16 @@ const Meetings: React.FC = () => {
|
|||
return;
|
||||
}
|
||||
const values = await form.validateFields();
|
||||
const { hostUserId, ...meetingValues } = values;
|
||||
setSubmitLoading(true);
|
||||
try {
|
||||
await createMeeting({
|
||||
...values,
|
||||
meetingTime: values.meetingTime.format('YYYY-MM-DD HH:mm:ss'),
|
||||
...meetingValues,
|
||||
...(hostUserId != null ? { hostUserId } : {}),
|
||||
meetingTime: meetingValues.meetingTime.format('YYYY-MM-DD HH:mm:ss'),
|
||||
audioUrl,
|
||||
participants: values.participants?.join(','),
|
||||
tags: values.tags?.join(',')
|
||||
participants: meetingValues.participants?.join(','),
|
||||
tags: meetingValues.tags?.join(',')
|
||||
});
|
||||
message.success('会议发起成功');
|
||||
setCreateDrawerVisible(false);
|
||||
|
|
|
|||
|
|
@ -171,19 +171,21 @@ export default function RealtimeAsr() {
|
|||
weight: Number(item.weight || 2) / 10,
|
||||
}));
|
||||
|
||||
const { hostUserId, ...meetingValues } = values;
|
||||
const payload: CreateRealtimeMeetingCommand = {
|
||||
...values,
|
||||
meetingTime: values.meetingTime.format("YYYY-MM-DD HH:mm:ss"),
|
||||
participants: values.participants?.join(",") || "",
|
||||
tags: values.tags?.join(",") || "",
|
||||
mode: values.mode || "2pass",
|
||||
language: values.language || "auto",
|
||||
useSpkId: values.useSpkId ? 1 : 0,
|
||||
enablePunctuation: values.enablePunctuation !== false,
|
||||
enableItn: values.enableItn !== false,
|
||||
enableTextRefine: !!values.enableTextRefine,
|
||||
saveAudio: !!values.saveAudio,
|
||||
hotWords: values.hotWords,
|
||||
...meetingValues,
|
||||
...(hostUserId != null ? { hostUserId } : {}),
|
||||
meetingTime: meetingValues.meetingTime.format("YYYY-MM-DD HH:mm:ss"),
|
||||
participants: meetingValues.participants?.join(",") || "",
|
||||
tags: meetingValues.tags?.join(",") || "",
|
||||
mode: meetingValues.mode || "2pass",
|
||||
language: meetingValues.language || "auto",
|
||||
useSpkId: meetingValues.useSpkId ? 1 : 0,
|
||||
enablePunctuation: meetingValues.enablePunctuation !== false,
|
||||
enableItn: meetingValues.enableItn !== false,
|
||||
enableTextRefine: !!meetingValues.enableTextRefine,
|
||||
saveAudio: !!meetingValues.saveAudio,
|
||||
hotWords: meetingValues.hotWords,
|
||||
};
|
||||
|
||||
const res = await createRealtimeMeeting(payload);
|
||||
|
|
@ -321,6 +323,23 @@ export default function RealtimeAsr() {
|
|||
</Select>
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col xs={24} md={12}>
|
||||
<Form.Item name="hostUserId" label="会议主持人">
|
||||
<Select allowClear showSearch optionFilterProp="children" placeholder="不选择则默认为创建人">
|
||||
{userList.map((user) => (
|
||||
<Option key={user.userId} value={user.userId}>
|
||||
<Space>
|
||||
<Avatar size="small" icon={<UserOutlined />} />
|
||||
{user.displayName || user.username}
|
||||
</Space>
|
||||
</Option>
|
||||
))}
|
||||
</Select>
|
||||
</Form.Item>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<Row gutter={16}>
|
||||
<Col xs={24} md={12}>
|
||||
<Form.Item name="tags" label="会议标签">
|
||||
<Select mode="tags" placeholder="输入标签后回车" />
|
||||
|
|
|
|||
|
|
@ -596,10 +596,21 @@ export function RealtimeAsrSession() {
|
|||
|
||||
try {
|
||||
await completeRealtimeMeeting(meetingId, {});
|
||||
let savedMeeting: MeetingVO | null = null;
|
||||
try {
|
||||
const detailRes = await getMeetingDetail(meetingId);
|
||||
savedMeeting = detailRes.data.data;
|
||||
} catch {
|
||||
// 会议完成已成功提交,详情刷新失败不应反向标记为结束失败。
|
||||
}
|
||||
sessionStorage.removeItem(getSessionKey(meetingId));
|
||||
setSessionStatus((prev) => prev ? { ...prev, status: "COMPLETING", canResume: false, activeConnection: false } : prev);
|
||||
setStatusText("已提交总结任务");
|
||||
message.success("实时会议已结束,正在生成总结");
|
||||
if (savedMeeting?.audioSaveStatus === "FAILED") {
|
||||
message.warning(savedMeeting.audioSaveMessage || "实时会议已完成,但音频保存失败,当前无法播放会议录音。转写和总结不受影响。");
|
||||
} else {
|
||||
message.success("实时会议已结束,正在生成总结");
|
||||
}
|
||||
if (navigateAfterStop) {
|
||||
navigate(`/meetings/${meetingId}`);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -69,6 +69,7 @@ const SpeakerReg: React.FC = () => {
|
|||
const [editingSpeaker, setEditingSpeaker] = useState<SpeakerVO | null>(null);
|
||||
const [seconds, setSeconds] = useState(0);
|
||||
const timerRef = useRef<any>(null);
|
||||
const autoStopTimerRef = useRef<any>(null);
|
||||
const mediaRecorderRef = useRef<MediaRecorder | null>(null);
|
||||
const audioChunksRef = useRef<Blob[]>([]);
|
||||
const { profile } = useAuth();
|
||||
|
|
@ -165,16 +166,15 @@ const SpeakerReg: React.FC = () => {
|
|||
};
|
||||
|
||||
const startTimer = () => {
|
||||
stopTimer();
|
||||
setSeconds(0);
|
||||
timerRef.current = setInterval(() => {
|
||||
setSeconds(prev => {
|
||||
if (prev + 1 >= DEFAULT_DURATION) {
|
||||
stopRecording();
|
||||
return DEFAULT_DURATION;
|
||||
}
|
||||
return prev + 1;
|
||||
});
|
||||
setSeconds(prev => Math.min(prev + 1, DEFAULT_DURATION));
|
||||
}, 1000);
|
||||
autoStopTimerRef.current = setTimeout(() => {
|
||||
setSeconds(DEFAULT_DURATION);
|
||||
stopRecording();
|
||||
}, DEFAULT_DURATION * 1000);
|
||||
};
|
||||
|
||||
const stopTimer = () => {
|
||||
|
|
@ -182,6 +182,10 @@ const SpeakerReg: React.FC = () => {
|
|||
clearInterval(timerRef.current);
|
||||
timerRef.current = null;
|
||||
}
|
||||
if (autoStopTimerRef.current) {
|
||||
clearTimeout(autoStopTimerRef.current);
|
||||
autoStopTimerRef.current = null;
|
||||
}
|
||||
};
|
||||
|
||||
const startRecording = async () => {
|
||||
|
|
|
|||
Loading…
Reference in New Issue