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;
|
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.AndroidOpenRealtimeGrpcSessionCommand;
|
||||||
import com.imeeting.dto.android.AndroidRealtimeGrpcSessionVO;
|
import com.imeeting.dto.android.AndroidRealtimeGrpcSessionVO;
|
||||||
|
import com.imeeting.dto.biz.CreateRealtimeMeetingCommand;
|
||||||
import com.imeeting.dto.biz.MeetingTranscriptVO;
|
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.RealtimeMeetingCompleteDTO;
|
||||||
import com.imeeting.dto.biz.RealtimeMeetingSessionStatusVO;
|
import com.imeeting.dto.biz.RealtimeMeetingSessionStatusVO;
|
||||||
|
import com.imeeting.entity.biz.Meeting;
|
||||||
import com.imeeting.service.android.AndroidAuthService;
|
import com.imeeting.service.android.AndroidAuthService;
|
||||||
import com.imeeting.service.biz.MeetingAccessService;
|
import com.imeeting.service.biz.MeetingAccessService;
|
||||||
|
import com.imeeting.service.biz.MeetingAuthorizationService;
|
||||||
import com.imeeting.service.biz.MeetingCommandService;
|
import com.imeeting.service.biz.MeetingCommandService;
|
||||||
import com.imeeting.service.biz.MeetingQueryService;
|
import com.imeeting.service.biz.MeetingQueryService;
|
||||||
|
import com.imeeting.service.biz.MeetingRuntimeProfileResolver;
|
||||||
import com.imeeting.service.biz.RealtimeMeetingSessionStateService;
|
import com.imeeting.service.biz.RealtimeMeetingSessionStateService;
|
||||||
import com.imeeting.service.realtime.AndroidRealtimeSessionTicketService;
|
import com.imeeting.service.realtime.AndroidRealtimeSessionTicketService;
|
||||||
import com.unisbase.common.ApiResponse;
|
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.RequestMapping;
|
||||||
import org.springframework.web.bind.annotation.RestController;
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import java.time.format.DateTimeFormatter;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
|
||||||
@RestController
|
@RestController
|
||||||
|
|
@ -28,31 +40,86 @@ import java.util.List;
|
||||||
@RequiredArgsConstructor
|
@RequiredArgsConstructor
|
||||||
public class AndroidMeetingRealtimeController {
|
public class AndroidMeetingRealtimeController {
|
||||||
|
|
||||||
|
private static final DateTimeFormatter TITLE_TIME_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
|
||||||
|
|
||||||
private final AndroidAuthService androidAuthService;
|
private final AndroidAuthService androidAuthService;
|
||||||
private final MeetingAccessService meetingAccessService;
|
private final MeetingAccessService meetingAccessService;
|
||||||
|
private final MeetingAuthorizationService meetingAuthorizationService;
|
||||||
private final MeetingQueryService meetingQueryService;
|
private final MeetingQueryService meetingQueryService;
|
||||||
private final MeetingCommandService meetingCommandService;
|
private final MeetingCommandService meetingCommandService;
|
||||||
private final RealtimeMeetingSessionStateService realtimeMeetingSessionStateService;
|
private final RealtimeMeetingSessionStateService realtimeMeetingSessionStateService;
|
||||||
private final AndroidRealtimeSessionTicketService androidRealtimeSessionTicketService;
|
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")
|
@GetMapping("/{id}/realtime/session-status")
|
||||||
public ApiResponse<RealtimeMeetingSessionStatusVO> getRealtimeSessionStatus(@PathVariable Long id, HttpServletRequest request) {
|
public ApiResponse<RealtimeMeetingSessionStatusVO> getRealtimeSessionStatus(@PathVariable Long id, HttpServletRequest request) {
|
||||||
androidAuthService.authenticateHttp(request);
|
AndroidAuthContext authContext = androidAuthService.authenticateHttp(request);
|
||||||
meetingAccessService.requireMeeting(id);
|
Meeting meeting = meetingAccessService.requireMeeting(id);
|
||||||
|
meetingAuthorizationService.assertCanManageRealtimeMeeting(meeting, authContext);
|
||||||
return ApiResponse.ok(realtimeMeetingSessionStateService.getStatus(id));
|
return ApiResponse.ok(realtimeMeetingSessionStateService.getStatus(id));
|
||||||
}
|
}
|
||||||
|
|
||||||
@GetMapping("/{id}/transcripts")
|
@GetMapping("/{id}/transcripts")
|
||||||
public ApiResponse<List<MeetingTranscriptVO>> getTranscripts(@PathVariable Long id, HttpServletRequest request) {
|
public ApiResponse<List<MeetingTranscriptVO>> getTranscripts(@PathVariable Long id, HttpServletRequest request) {
|
||||||
androidAuthService.authenticateHttp(request);
|
AndroidAuthContext authContext = androidAuthService.authenticateHttp(request);
|
||||||
meetingAccessService.requireMeeting(id);
|
Meeting meeting = meetingAccessService.requireMeeting(id);
|
||||||
|
meetingAuthorizationService.assertCanViewMeeting(meeting, authContext);
|
||||||
return ApiResponse.ok(meetingQueryService.getTranscripts(id));
|
return ApiResponse.ok(meetingQueryService.getTranscripts(id));
|
||||||
}
|
}
|
||||||
|
|
||||||
@PostMapping("/{id}/realtime/pause")
|
@PostMapping("/{id}/realtime/pause")
|
||||||
public ApiResponse<RealtimeMeetingSessionStatusVO> pauseRealtimeMeeting(@PathVariable Long id, HttpServletRequest request) {
|
public ApiResponse<RealtimeMeetingSessionStatusVO> pauseRealtimeMeeting(@PathVariable Long id, HttpServletRequest request) {
|
||||||
androidAuthService.authenticateHttp(request);
|
AndroidAuthContext authContext = androidAuthService.authenticateHttp(request);
|
||||||
meetingAccessService.requireMeeting(id);
|
Meeting meeting = meetingAccessService.requireMeeting(id);
|
||||||
|
meetingAuthorizationService.assertCanManageRealtimeMeeting(meeting, authContext);
|
||||||
return ApiResponse.ok(realtimeMeetingSessionStateService.pause(id));
|
return ApiResponse.ok(realtimeMeetingSessionStateService.pause(id));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -60,9 +127,14 @@ public class AndroidMeetingRealtimeController {
|
||||||
public ApiResponse<Boolean> completeRealtimeMeeting(@PathVariable Long id,
|
public ApiResponse<Boolean> completeRealtimeMeeting(@PathVariable Long id,
|
||||||
HttpServletRequest request,
|
HttpServletRequest request,
|
||||||
@RequestBody(required = false) RealtimeMeetingCompleteDTO dto) {
|
@RequestBody(required = false) RealtimeMeetingCompleteDTO dto) {
|
||||||
androidAuthService.authenticateHttp(request);
|
AndroidAuthContext authContext = androidAuthService.authenticateHttp(request);
|
||||||
meetingAccessService.requireMeeting(id);
|
Meeting meeting = meetingAccessService.requireMeeting(id);
|
||||||
meetingCommandService.completeRealtimeMeeting(id, dto != null ? dto.getAudioUrl() : null);
|
meetingAuthorizationService.assertCanManageRealtimeMeeting(meeting, authContext);
|
||||||
|
meetingCommandService.completeRealtimeMeeting(
|
||||||
|
id,
|
||||||
|
dto != null ? dto.getAudioUrl() : null,
|
||||||
|
dto != null && Boolean.TRUE.equals(dto.getOverwriteAudio())
|
||||||
|
);
|
||||||
return ApiResponse.ok(true);
|
return ApiResponse.ok(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -70,6 +142,86 @@ public class AndroidMeetingRealtimeController {
|
||||||
public ApiResponse<AndroidRealtimeGrpcSessionVO> openRealtimeGrpcSession(@PathVariable Long id,
|
public ApiResponse<AndroidRealtimeGrpcSessionVO> openRealtimeGrpcSession(@PathVariable Long id,
|
||||||
HttpServletRequest request,
|
HttpServletRequest request,
|
||||||
@RequestBody(required = false) AndroidOpenRealtimeGrpcSessionCommand command) {
|
@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();
|
LoginUser loginUser = currentLoginUser();
|
||||||
Meeting meeting = meetingAccessService.requireMeeting(id);
|
Meeting meeting = meetingAccessService.requireMeeting(id);
|
||||||
meetingAccessService.assertCanManageRealtimeMeeting(meeting, loginUser);
|
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);
|
return ApiResponse.ok(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -2,11 +2,20 @@ package com.imeeting.dto.android;
|
||||||
|
|
||||||
import lombok.Data;
|
import lombok.Data;
|
||||||
|
|
||||||
|
import java.util.Set;
|
||||||
|
|
||||||
@Data
|
@Data
|
||||||
public class AndroidAuthContext {
|
public class AndroidAuthContext {
|
||||||
private String authMode;
|
private String authMode;
|
||||||
private String deviceId;
|
private String deviceId;
|
||||||
|
private Long tenantId;
|
||||||
private String tenantCode;
|
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 appId;
|
||||||
private String appVersion;
|
private String appVersion;
|
||||||
private String platform;
|
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
|
@Data
|
||||||
public class CreateMeetingCommand {
|
public class CreateMeetingCommand {
|
||||||
@NotBlank(message = "会议标题不能为空")
|
@NotBlank(message = "title must not be blank")
|
||||||
private String title;
|
private String title;
|
||||||
|
|
||||||
@NotNull(message = "会议时间不能为空")
|
@NotNull(message = "meetingTime must not be null")
|
||||||
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
|
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
|
||||||
private LocalDateTime meetingTime;
|
private LocalDateTime meetingTime;
|
||||||
|
|
||||||
private String participants;
|
private String participants;
|
||||||
private String tags;
|
private String tags;
|
||||||
@NotBlank(message = "音频地址不能为空")
|
private Long hostUserId;
|
||||||
|
private String hostName;
|
||||||
|
|
||||||
|
@NotBlank(message = "audioUrl must not be blank")
|
||||||
private String audioUrl;
|
private String audioUrl;
|
||||||
@NotNull(message = "识别模型不能为空")
|
|
||||||
|
@NotNull(message = "asrModelId must not be null")
|
||||||
private Long asrModelId;
|
private Long asrModelId;
|
||||||
@NotNull(message = "总结模型不能为空")
|
|
||||||
|
@NotNull(message = "summaryModelId must not be null")
|
||||||
private Long summaryModelId;
|
private Long summaryModelId;
|
||||||
@NotNull(message = "提示词模板不能为空")
|
|
||||||
|
@NotNull(message = "promptId must not be null")
|
||||||
private Long promptId;
|
private Long promptId;
|
||||||
|
|
||||||
private Integer useSpkId;
|
private Integer useSpkId;
|
||||||
private Boolean enableTextRefine;
|
private Boolean enableTextRefine;
|
||||||
private List<String> hotWords;
|
private List<String> hotWords;
|
||||||
|
|
|
||||||
|
|
@ -10,21 +10,27 @@ import java.util.List;
|
||||||
|
|
||||||
@Data
|
@Data
|
||||||
public class CreateRealtimeMeetingCommand {
|
public class CreateRealtimeMeetingCommand {
|
||||||
@NotBlank(message = "会议标题不能为空")
|
@NotBlank(message = "title must not be blank")
|
||||||
private String title;
|
private String title;
|
||||||
|
|
||||||
@NotNull(message = "会议时间不能为空")
|
@NotNull(message = "meetingTime must not be null")
|
||||||
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
|
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
|
||||||
private LocalDateTime meetingTime;
|
private LocalDateTime meetingTime;
|
||||||
|
|
||||||
private String participants;
|
private String participants;
|
||||||
private String tags;
|
private String tags;
|
||||||
@NotNull(message = "识别模型不能为空")
|
private Long hostUserId;
|
||||||
|
private String hostName;
|
||||||
|
|
||||||
|
@NotNull(message = "asrModelId must not be null")
|
||||||
private Long asrModelId;
|
private Long asrModelId;
|
||||||
@NotNull(message = "总结模型不能为空")
|
|
||||||
|
@NotNull(message = "summaryModelId must not be null")
|
||||||
private Long summaryModelId;
|
private Long summaryModelId;
|
||||||
@NotNull(message = "提示词模板不能为空")
|
|
||||||
|
@NotNull(message = "promptId must not be null")
|
||||||
private Long promptId;
|
private Long promptId;
|
||||||
|
|
||||||
private String mode;
|
private String mode;
|
||||||
private String language;
|
private String language;
|
||||||
private Integer useSpkId;
|
private Integer useSpkId;
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,8 @@ public class MeetingVO {
|
||||||
private Long tenantId;
|
private Long tenantId;
|
||||||
private Long creatorId;
|
private Long creatorId;
|
||||||
private String creatorName;
|
private String creatorName;
|
||||||
|
private Long hostUserId;
|
||||||
|
private String hostName;
|
||||||
private String title;
|
private String title;
|
||||||
|
|
||||||
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
|
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
|
||||||
|
|
@ -21,6 +23,8 @@ public class MeetingVO {
|
||||||
private List<Long> participantIds;
|
private List<Long> participantIds;
|
||||||
private String tags;
|
private String tags;
|
||||||
private String audioUrl;
|
private String audioUrl;
|
||||||
|
private String audioSaveStatus;
|
||||||
|
private String audioSaveMessage;
|
||||||
private Integer duration;
|
private Integer duration;
|
||||||
private String summaryContent;
|
private String summaryContent;
|
||||||
private Map<String, Object> analysis;
|
private Map<String, Object> analysis;
|
||||||
|
|
|
||||||
|
|
@ -4,5 +4,6 @@ import lombok.Data;
|
||||||
|
|
||||||
@Data
|
@Data
|
||||||
public class RealtimeMeetingCompleteDTO {
|
public class RealtimeMeetingCompleteDTO {
|
||||||
|
private Boolean overwriteAudio;
|
||||||
private String audioUrl;
|
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 audioUrl;
|
||||||
|
|
||||||
|
private String audioSaveStatus;
|
||||||
|
|
||||||
|
private String audioSaveMessage;
|
||||||
|
|
||||||
private Long creatorId;
|
private Long creatorId;
|
||||||
|
|
||||||
private String creatorName;
|
private String creatorName;
|
||||||
|
|
||||||
|
private Long hostUserId;
|
||||||
|
|
||||||
|
private String hostName;
|
||||||
|
|
||||||
private Long latestSummaryTaskId;
|
private Long latestSummaryTaskId;
|
||||||
|
|
||||||
@TableField(exist = false)
|
@TableField(exist = false)
|
||||||
|
|
|
||||||
|
|
@ -50,7 +50,12 @@ public class RealtimeMeetingGrpcService extends RealtimeMeetingServiceGrpc.Realt
|
||||||
|
|
||||||
private void handleOpen(RealtimeClientPacket packet) {
|
private void handleOpen(RealtimeClientPacket packet) {
|
||||||
authContext = androidAuthService.authenticateGrpc(packet.getAuth(), null);
|
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) {
|
private void handleAudio(AudioChunk audioChunk) {
|
||||||
|
|
|
||||||
|
|
@ -44,7 +44,7 @@ public class RealtimeMeetingSessionExpirationListener extends KeyExpirationEvent
|
||||||
if (expiredKey.startsWith(RedisKeys.realtimeMeetingResumeTimeoutPrefix())) {
|
if (expiredKey.startsWith(RedisKeys.realtimeMeetingResumeTimeoutPrefix())) {
|
||||||
Long meetingId = parseMeetingId(expiredKey, RedisKeys.realtimeMeetingResumeTimeoutPrefix());
|
Long meetingId = parseMeetingId(expiredKey, RedisKeys.realtimeMeetingResumeTimeoutPrefix());
|
||||||
if (meetingId != null && realtimeMeetingSessionStateService.markCompletingIfResumeExpired(meetingId)) {
|
if (meetingId != null && realtimeMeetingSessionStateService.markCompletingIfResumeExpired(meetingId)) {
|
||||||
meetingCommandService.completeRealtimeMeeting(meetingId, null);
|
meetingCommandService.completeRealtimeMeeting(meetingId, null, false);
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -4,8 +4,13 @@ import com.imeeting.config.grpc.AndroidGrpcAuthProperties;
|
||||||
import com.imeeting.dto.android.AndroidAuthContext;
|
import com.imeeting.dto.android.AndroidAuthContext;
|
||||||
import com.imeeting.grpc.common.ClientAuth;
|
import com.imeeting.grpc.common.ClientAuth;
|
||||||
import com.imeeting.service.android.AndroidAuthService;
|
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 jakarta.servlet.http.HttpServletRequest;
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.springframework.security.core.Authentication;
|
||||||
|
import org.springframework.security.core.context.SecurityContextHolder;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
import org.springframework.util.StringUtils;
|
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_ID = "X-Android-App-Id";
|
||||||
private static final String HEADER_APP_VERSION = "X-Android-App-Version";
|
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_PLATFORM = "X-Android-Platform";
|
||||||
|
private static final String HEADER_AUTHORIZATION = "Authorization";
|
||||||
|
private static final String BEARER_PREFIX = "Bearer ";
|
||||||
|
|
||||||
private final AndroidGrpcAuthProperties properties;
|
private final AndroidGrpcAuthProperties properties;
|
||||||
|
private final TokenValidationService tokenValidationService;
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public AndroidAuthContext authenticateGrpc(ClientAuth auth, String fallbackDeviceId) {
|
public AndroidAuthContext authenticateGrpc(ClientAuth auth, String fallbackDeviceId) {
|
||||||
ClientAuth.AuthType authType = auth == null ? ClientAuth.AuthType.AUTH_TYPE_UNSPECIFIED : auth.getAuthType();
|
ClientAuth.AuthType authType = auth == null ? ClientAuth.AuthType.AUTH_TYPE_UNSPECIFIED : auth.getAuthType();
|
||||||
if (authType == ClientAuth.AuthType.DEVICE_TOKEN || authType == ClientAuth.AuthType.USER_JWT) {
|
if (authType == ClientAuth.AuthType.USER_JWT) {
|
||||||
return buildContext(authType.name(), false, auth.getDeviceId(), auth.getTenantCode(), auth.getAppId(),
|
InternalAuthCheckResponse authResult = validateToken(auth == null ? null : auth.getAccessToken());
|
||||||
auth.getAppVersion(), auth.getPlatform(), auth.getAccessToken(), fallbackDeviceId);
|
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()) {
|
if (properties.isEnabled() && !properties.isAllowAnonymous()) {
|
||||||
throw new RuntimeException("Android gRPC auth is required");
|
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.getAppVersion(),
|
||||||
auth == null ? null : auth.getPlatform(),
|
auth == null ? null : auth.getPlatform(),
|
||||||
auth == null ? null : auth.getAccessToken(),
|
auth == null ? null : auth.getAccessToken(),
|
||||||
fallbackDeviceId);
|
fallbackDeviceId,
|
||||||
|
null,
|
||||||
|
null);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public AndroidAuthContext authenticateHttp(HttpServletRequest request) {
|
public AndroidAuthContext authenticateHttp(HttpServletRequest request) {
|
||||||
if (properties.isEnabled() && !properties.isAllowAnonymous()) {
|
LoginUser loginUser = currentLoginUser();
|
||||||
String token = request.getHeader(HEADER_ACCESS_TOKEN);
|
String resolvedToken = resolveHttpToken(request);
|
||||||
if (!StringUtils.hasText(token)) {
|
if (loginUser != null) {
|
||||||
throw new RuntimeException("Android HTTP auth is required");
|
return buildContext("USER_JWT", false,
|
||||||
}
|
|
||||||
return buildContext("DEVICE_TOKEN", false,
|
|
||||||
request.getHeader(HEADER_DEVICE_ID),
|
request.getHeader(HEADER_DEVICE_ID),
|
||||||
request.getHeader(HEADER_TENANT_CODE),
|
request.getHeader(HEADER_TENANT_CODE),
|
||||||
request.getHeader(HEADER_APP_ID),
|
request.getHeader(HEADER_APP_ID),
|
||||||
request.getHeader(HEADER_APP_VERSION),
|
request.getHeader(HEADER_APP_VERSION),
|
||||||
request.getHeader(HEADER_PLATFORM),
|
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);
|
null);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (properties.isEnabled() && !properties.isAllowAnonymous()) {
|
||||||
|
throw new RuntimeException("Android HTTP auth is required");
|
||||||
|
}
|
||||||
|
|
||||||
return buildContext("NONE", true,
|
return buildContext("NONE", true,
|
||||||
request.getHeader(HEADER_DEVICE_ID),
|
request.getHeader(HEADER_DEVICE_ID),
|
||||||
request.getHeader(HEADER_TENANT_CODE),
|
request.getHeader(HEADER_TENANT_CODE),
|
||||||
|
|
@ -65,12 +99,14 @@ public class AndroidAuthServiceImpl implements AndroidAuthService {
|
||||||
request.getHeader(HEADER_APP_VERSION),
|
request.getHeader(HEADER_APP_VERSION),
|
||||||
request.getHeader(HEADER_PLATFORM),
|
request.getHeader(HEADER_PLATFORM),
|
||||||
request.getHeader(HEADER_ACCESS_TOKEN),
|
request.getHeader(HEADER_ACCESS_TOKEN),
|
||||||
|
null,
|
||||||
|
null,
|
||||||
null);
|
null);
|
||||||
}
|
}
|
||||||
|
|
||||||
private AndroidAuthContext buildContext(String authMode, boolean anonymous, String deviceId, String tenantCode,
|
private AndroidAuthContext buildContext(String authMode, boolean anonymous, String deviceId, String tenantCode,
|
||||||
String appId, String appVersion, String platform, String accessToken,
|
String appId, String appVersion, String platform, String accessToken,
|
||||||
String fallbackDeviceId) {
|
String fallbackDeviceId, InternalAuthCheckResponse authResult, LoginUser loginUser) {
|
||||||
String resolvedDeviceId = StringUtils.hasText(deviceId) ? deviceId : fallbackDeviceId;
|
String resolvedDeviceId = StringUtils.hasText(deviceId) ? deviceId : fallbackDeviceId;
|
||||||
if (!StringUtils.hasText(resolvedDeviceId)) {
|
if (!StringUtils.hasText(resolvedDeviceId)) {
|
||||||
throw new RuntimeException("Android deviceId is required");
|
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.setAppVersion(StringUtils.hasText(appVersion) ? appVersion.trim() : null);
|
||||||
context.setPlatform(StringUtils.hasText(platform) ? platform.trim() : "android");
|
context.setPlatform(StringUtils.hasText(platform) ? platform.trim() : "android");
|
||||||
context.setAccessToken(StringUtils.hasText(accessToken) ? accessToken.trim() : null);
|
context.setAccessToken(StringUtils.hasText(accessToken) ? accessToken.trim() : null);
|
||||||
|
applyIdentity(context, authResult, loginUser);
|
||||||
return context;
|
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 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);
|
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.CreateRealtimeMeetingCommand;
|
||||||
import com.imeeting.dto.biz.MeetingVO;
|
import com.imeeting.dto.biz.MeetingVO;
|
||||||
import com.imeeting.dto.biz.RealtimeMeetingResumeConfig;
|
import com.imeeting.dto.biz.RealtimeMeetingResumeConfig;
|
||||||
|
import com.imeeting.dto.biz.RealtimeMeetingSessionStatusVO;
|
||||||
import com.imeeting.dto.biz.RealtimeTranscriptItemDTO;
|
import com.imeeting.dto.biz.RealtimeTranscriptItemDTO;
|
||||||
import com.imeeting.dto.biz.UpdateMeetingBasicCommand;
|
import com.imeeting.dto.biz.UpdateMeetingBasicCommand;
|
||||||
import com.imeeting.dto.biz.UpdateMeetingTranscriptCommand;
|
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.MeetingService;
|
||||||
import com.imeeting.service.biz.MeetingSummaryFileService;
|
import com.imeeting.service.biz.MeetingSummaryFileService;
|
||||||
import com.imeeting.service.biz.RealtimeMeetingSessionStateService;
|
import com.imeeting.service.biz.RealtimeMeetingSessionStateService;
|
||||||
|
import com.imeeting.service.realtime.RealtimeMeetingAudioStorageService;
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
import org.springframework.data.redis.core.StringRedisTemplate;
|
import org.springframework.data.redis.core.StringRedisTemplate;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
import org.springframework.transaction.annotation.Transactional;
|
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.BigDecimal;
|
||||||
import java.math.RoundingMode;
|
import java.math.RoundingMode;
|
||||||
|
|
@ -46,14 +50,17 @@ public class MeetingCommandServiceImpl implements MeetingCommandService {
|
||||||
private final MeetingSummaryFileService meetingSummaryFileService;
|
private final MeetingSummaryFileService meetingSummaryFileService;
|
||||||
private final MeetingDomainSupport meetingDomainSupport;
|
private final MeetingDomainSupport meetingDomainSupport;
|
||||||
private final RealtimeMeetingSessionStateService realtimeMeetingSessionStateService;
|
private final RealtimeMeetingSessionStateService realtimeMeetingSessionStateService;
|
||||||
|
private final RealtimeMeetingAudioStorageService realtimeMeetingAudioStorageService;
|
||||||
private final StringRedisTemplate redisTemplate;
|
private final StringRedisTemplate redisTemplate;
|
||||||
private final ObjectMapper objectMapper;
|
private final ObjectMapper objectMapper;
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
@Transactional(rollbackFor = Exception.class)
|
@Transactional(rollbackFor = Exception.class)
|
||||||
public MeetingVO createMeeting(CreateMeetingCommand command, Long tenantId, Long creatorId, String creatorName) {
|
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(),
|
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);
|
meetingService.save(meeting);
|
||||||
|
|
||||||
AiTask asrTask = new AiTask();
|
AiTask asrTask = new AiTask();
|
||||||
|
|
@ -92,8 +99,10 @@ public class MeetingCommandServiceImpl implements MeetingCommandService {
|
||||||
@Override
|
@Override
|
||||||
@Transactional(rollbackFor = Exception.class)
|
@Transactional(rollbackFor = Exception.class)
|
||||||
public MeetingVO createRealtimeMeeting(CreateRealtimeMeetingCommand command, Long tenantId, Long creatorId, String creatorName) {
|
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(),
|
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);
|
meetingService.save(meeting);
|
||||||
meetingDomainSupport.createSummaryTask(meeting.getId(), command.getSummaryModelId(), command.getPromptId());
|
meetingDomainSupport.createSummaryTask(meeting.getId(), command.getSummaryModelId(), command.getPromptId());
|
||||||
realtimeMeetingSessionStateService.initSessionIfAbsent(meeting.getId(), tenantId, creatorId);
|
realtimeMeetingSessionStateService.initSessionIfAbsent(meeting.getId(), tenantId, creatorId);
|
||||||
|
|
@ -164,14 +173,79 @@ public class MeetingCommandServiceImpl implements MeetingCommandService {
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
@Transactional(rollbackFor = Exception.class)
|
@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);
|
Meeting meeting = meetingService.getById(meetingId);
|
||||||
if (meeting == null) {
|
if (meeting == null) {
|
||||||
throw new RuntimeException("Meeting not found");
|
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()) {
|
if (audioUrl != null && !audioUrl.isBlank()) {
|
||||||
meeting.setAudioUrl(meetingDomainSupport.relocateAudioUrl(meetingId, audioUrl));
|
meeting.setAudioUrl(meetingDomainSupport.relocateAudioUrl(meetingId, audioUrl));
|
||||||
|
markAudioSaveSuccess(meeting);
|
||||||
meetingService.updateById(meeting);
|
meetingService.updateById(meeting);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -182,6 +256,12 @@ public class MeetingCommandServiceImpl implements MeetingCommandService {
|
||||||
throw new RuntimeException("当前还没有转录内容,无法结束会议。请先开始识别,或直接离开页面稍后继续。");
|
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);
|
realtimeMeetingSessionStateService.clear(meetingId);
|
||||||
meeting.setStatus(2);
|
meeting.setStatus(2);
|
||||||
meetingService.updateById(meeting);
|
meetingService.updateById(meeting);
|
||||||
|
|
@ -189,6 +269,121 @@ public class MeetingCommandServiceImpl implements MeetingCommandService {
|
||||||
aiTaskService.dispatchSummaryTask(meetingId);
|
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
|
@Override
|
||||||
@Transactional(rollbackFor = Exception.class)
|
@Transactional(rollbackFor = Exception.class)
|
||||||
public void updateSpeakerInfo(Long meetingId, String speakerId, String newName, String label) {
|
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);
|
meetingDomainSupport.createSummaryTask(meetingId, summaryModelId, promptId);
|
||||||
meeting.setStatus(2);
|
meeting.setStatus(2);
|
||||||
meetingService.updateById(meeting);
|
meetingService.updateById(meeting);
|
||||||
|
dispatchSummaryTaskAfterCommit(meetingId);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void dispatchSummaryTaskAfterCommit(Long meetingId) {
|
||||||
|
if (!TransactionSynchronizationManager.isSynchronizationActive()) {
|
||||||
aiTaskService.dispatchSummaryTask(meetingId);
|
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) {
|
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());
|
item.put("weight", BigDecimal.valueOf(rawWeight).divide(BigDecimal.TEN, 2, RoundingMode.HALF_UP).doubleValue());
|
||||||
return item;
|
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.AiTaskService;
|
||||||
import com.imeeting.service.biz.MeetingSummaryFileService;
|
import com.imeeting.service.biz.MeetingSummaryFileService;
|
||||||
import com.imeeting.service.biz.PromptTemplateService;
|
import com.imeeting.service.biz.PromptTemplateService;
|
||||||
|
import com.imeeting.service.realtime.RealtimeMeetingAudioStorageService;
|
||||||
import com.unisbase.entity.SysUser;
|
import com.unisbase.entity.SysUser;
|
||||||
import com.unisbase.mapper.SysUserMapper;
|
import com.unisbase.mapper.SysUserMapper;
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
|
|
@ -49,7 +50,8 @@ public class MeetingDomainSupport {
|
||||||
private String uploadPath;
|
private String uploadPath;
|
||||||
|
|
||||||
public Meeting initMeeting(String title, LocalDateTime meetingTime, String participants, String tags,
|
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 meeting = new Meeting();
|
||||||
meeting.setTitle(title);
|
meeting.setTitle(title);
|
||||||
meeting.setMeetingTime(meetingTime);
|
meeting.setMeetingTime(meetingTime);
|
||||||
|
|
@ -57,8 +59,11 @@ public class MeetingDomainSupport {
|
||||||
meeting.setTags(tags);
|
meeting.setTags(tags);
|
||||||
meeting.setCreatorId(creatorId);
|
meeting.setCreatorId(creatorId);
|
||||||
meeting.setCreatorName(creatorName);
|
meeting.setCreatorName(creatorName);
|
||||||
|
meeting.setHostUserId(hostUserId);
|
||||||
|
meeting.setHostName(hostName);
|
||||||
meeting.setTenantId(tenantId != null ? tenantId : 0L);
|
meeting.setTenantId(tenantId != null ? tenantId : 0L);
|
||||||
meeting.setAudioUrl(audioUrl);
|
meeting.setAudioUrl(audioUrl);
|
||||||
|
meeting.setAudioSaveStatus(RealtimeMeetingAudioStorageService.STATUS_NONE);
|
||||||
meeting.setStatus(status);
|
meeting.setStatus(status);
|
||||||
return meeting;
|
return meeting;
|
||||||
}
|
}
|
||||||
|
|
@ -227,10 +232,14 @@ public class MeetingDomainSupport {
|
||||||
vo.setTenantId(meeting.getTenantId());
|
vo.setTenantId(meeting.getTenantId());
|
||||||
vo.setCreatorId(meeting.getCreatorId());
|
vo.setCreatorId(meeting.getCreatorId());
|
||||||
vo.setCreatorName(meeting.getCreatorName());
|
vo.setCreatorName(meeting.getCreatorName());
|
||||||
|
vo.setHostUserId(meeting.getHostUserId());
|
||||||
|
vo.setHostName(meeting.getHostName());
|
||||||
vo.setTitle(meeting.getTitle());
|
vo.setTitle(meeting.getTitle());
|
||||||
vo.setMeetingTime(meeting.getMeetingTime());
|
vo.setMeetingTime(meeting.getMeetingTime());
|
||||||
vo.setTags(meeting.getTags());
|
vo.setTags(meeting.getTags());
|
||||||
vo.setAudioUrl(meeting.getAudioUrl());
|
vo.setAudioUrl(meeting.getAudioUrl());
|
||||||
|
vo.setAudioSaveStatus(meeting.getAudioSaveStatus());
|
||||||
|
vo.setAudioSaveMessage(meeting.getAudioSaveMessage());
|
||||||
vo.setDuration(resolveMeetingDuration(meeting.getId()));
|
vo.setDuration(resolveMeetingDuration(meeting.getId()));
|
||||||
vo.setStatus(meeting.getStatus());
|
vo.setStatus(meeting.getStatus());
|
||||||
vo.setCreatedAt(meeting.getCreatedAt());
|
vo.setCreatedAt(meeting.getCreatedAt());
|
||||||
|
|
|
||||||
|
|
@ -95,6 +95,9 @@ public class MeetingExportServiceImpl implements MeetingExportService {
|
||||||
XWPFParagraph timeP = document.createParagraph();
|
XWPFParagraph timeP = document.createParagraph();
|
||||||
timeP.createRun().setText("Meeting Time: " + String.valueOf(meeting.getMeetingTime()));
|
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();
|
XWPFParagraph participantsP = document.createParagraph();
|
||||||
participantsP.createRun().setText("Participants: " + (meeting.getParticipants() == null ? "" : meeting.getParticipants()));
|
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 title = meeting.getTitle() == null ? "Meeting" : meeting.getTitle();
|
||||||
String time = meeting.getMeetingTime() == null ? "" : meeting.getMeetingTime().toString();
|
String time = meeting.getMeetingTime() == null ? "" : meeting.getMeetingTime().toString();
|
||||||
|
String host = meeting.getHostName() == null ? "" : meeting.getHostName();
|
||||||
String participants = meeting.getParticipants() == null ? "" : meeting.getParticipants();
|
String participants = meeting.getParticipants() == null ? "" : meeting.getParticipants();
|
||||||
|
|
||||||
String html = "<html><head><style>" +
|
String html = "<html><head><style>" +
|
||||||
|
|
@ -146,6 +150,8 @@ public class MeetingExportServiceImpl implements MeetingExportService {
|
||||||
"<div style='font-size:14px; color:#666;'>" +
|
"<div style='font-size:14px; color:#666;'>" +
|
||||||
"<span>Meeting Time: " + time + "</span>" +
|
"<span>Meeting Time: " + time + "</span>" +
|
||||||
"<span style='margin: 0 20px;'>|</span>" +
|
"<span style='margin: 0 20px;'>|</span>" +
|
||||||
|
"<span>Host: " + host + "</span>" +
|
||||||
|
"<span style='margin: 0 20px;'>|</span>" +
|
||||||
"<span>Participants: " + participants + "</span>" +
|
"<span>Participants: " + participants + "</span>" +
|
||||||
"</div></div>" +
|
"</div></div>" +
|
||||||
"<div class='markdown-body'>" + htmlBody + "</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 {
|
public interface AndroidRealtimeSessionTicketService {
|
||||||
AndroidRealtimeGrpcSessionVO createSession(Long meetingId, AndroidOpenRealtimeGrpcSessionCommand command, AndroidAuthContext authContext);
|
AndroidRealtimeGrpcSessionVO createSession(Long meetingId, AndroidOpenRealtimeGrpcSessionCommand command, AndroidAuthContext authContext);
|
||||||
|
|
||||||
|
AndroidRealtimeGrpcSessionData prepareSessionData(Long meetingId, AndroidAuthContext authContext);
|
||||||
|
|
||||||
AndroidRealtimeGrpcSessionData getSessionData(String streamToken);
|
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;
|
import com.imeeting.grpc.realtime.RealtimeServerPacket;
|
||||||
|
|
||||||
public interface RealtimeMeetingGrpcSessionService {
|
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);
|
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.entity.biz.Meeting;
|
||||||
import com.imeeting.service.biz.AiModelService;
|
import com.imeeting.service.biz.AiModelService;
|
||||||
import com.imeeting.service.biz.MeetingAccessService;
|
import com.imeeting.service.biz.MeetingAccessService;
|
||||||
|
import com.imeeting.service.biz.MeetingAuthorizationService;
|
||||||
import com.imeeting.service.biz.RealtimeMeetingSessionStateService;
|
import com.imeeting.service.biz.RealtimeMeetingSessionStateService;
|
||||||
import com.imeeting.service.realtime.AndroidRealtimeSessionTicketService;
|
import com.imeeting.service.realtime.AndroidRealtimeSessionTicketService;
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
|
|
@ -32,60 +33,21 @@ public class AndroidRealtimeSessionTicketServiceImpl implements AndroidRealtimeS
|
||||||
private final ObjectMapper objectMapper;
|
private final ObjectMapper objectMapper;
|
||||||
private final StringRedisTemplate redisTemplate;
|
private final StringRedisTemplate redisTemplate;
|
||||||
private final MeetingAccessService meetingAccessService;
|
private final MeetingAccessService meetingAccessService;
|
||||||
|
private final MeetingAuthorizationService meetingAuthorizationService;
|
||||||
private final AiModelService aiModelService;
|
private final AiModelService aiModelService;
|
||||||
private final RealtimeMeetingSessionStateService realtimeMeetingSessionStateService;
|
private final RealtimeMeetingSessionStateService realtimeMeetingSessionStateService;
|
||||||
private final GrpcServerProperties grpcServerProperties;
|
private final GrpcServerProperties grpcServerProperties;
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public AndroidRealtimeGrpcSessionVO createSession(Long meetingId, AndroidOpenRealtimeGrpcSessionCommand command, AndroidAuthContext authContext) {
|
public AndroidRealtimeGrpcSessionVO createSession(Long meetingId, AndroidOpenRealtimeGrpcSessionCommand command, AndroidAuthContext authContext) {
|
||||||
if (meetingId == null) {
|
PreparedRealtimeSession prepared = prepareSession(meetingId, command, authContext);
|
||||||
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);
|
|
||||||
}
|
|
||||||
|
|
||||||
String streamToken = UUID.randomUUID().toString().replace("-", "");
|
String streamToken = UUID.randomUUID().toString().replace("-", "");
|
||||||
Duration ttl = Duration.ofSeconds(grpcServerProperties.getRealtime().getSessionTtlSeconds());
|
Duration ttl = Duration.ofSeconds(grpcServerProperties.getRealtime().getSessionTtlSeconds());
|
||||||
try {
|
try {
|
||||||
redisTemplate.opsForValue().set(
|
redisTemplate.opsForValue().set(
|
||||||
RedisKeys.realtimeMeetingGrpcSessionKey(streamToken),
|
RedisKeys.realtimeMeetingGrpcSessionKey(streamToken),
|
||||||
objectMapper.writeValueAsString(sessionData),
|
objectMapper.writeValueAsString(prepared.sessionData()),
|
||||||
ttl
|
ttl
|
||||||
);
|
);
|
||||||
} catch (Exception ex) {
|
} catch (Exception ex) {
|
||||||
|
|
@ -99,11 +61,16 @@ public class AndroidRealtimeSessionTicketServiceImpl implements AndroidRealtimeS
|
||||||
vo.setSampleRate(grpcServerProperties.getRealtime().getSampleRate());
|
vo.setSampleRate(grpcServerProperties.getRealtime().getSampleRate());
|
||||||
vo.setChannels(grpcServerProperties.getRealtime().getChannels());
|
vo.setChannels(grpcServerProperties.getRealtime().getChannels());
|
||||||
vo.setEncoding(grpcServerProperties.getRealtime().getEncoding());
|
vo.setEncoding(grpcServerProperties.getRealtime().getEncoding());
|
||||||
vo.setResumeConfig(resumeConfig);
|
vo.setResumeConfig(prepared.resumeConfig());
|
||||||
vo.setStatus(currentStatus);
|
vo.setStatus(prepared.status());
|
||||||
return vo;
|
return vo;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public AndroidRealtimeGrpcSessionData prepareSessionData(Long meetingId, AndroidAuthContext authContext) {
|
||||||
|
return prepareSession(meetingId, null, authContext).sessionData();
|
||||||
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public AndroidRealtimeGrpcSessionData getSessionData(String streamToken) {
|
public AndroidRealtimeGrpcSessionData getSessionData(String streamToken) {
|
||||||
if (streamToken == null || streamToken.isBlank()) {
|
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,
|
private RealtimeMeetingResumeConfig buildResumeConfig(AndroidOpenRealtimeGrpcSessionCommand command,
|
||||||
RealtimeMeetingResumeConfig currentResumeConfig,
|
RealtimeMeetingResumeConfig currentResumeConfig,
|
||||||
Long asrModelId) {
|
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.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.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.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;
|
return config;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -216,4 +242,9 @@ public class AndroidRealtimeSessionTicketServiceImpl implements AndroidRealtimeS
|
||||||
}
|
}
|
||||||
return defaultValue;
|
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.biz.RealtimeMeetingSessionStateService;
|
||||||
import com.imeeting.service.realtime.AndroidRealtimeSessionTicketService;
|
import com.imeeting.service.realtime.AndroidRealtimeSessionTicketService;
|
||||||
import com.imeeting.service.realtime.AsrUpstreamBridgeService;
|
import com.imeeting.service.realtime.AsrUpstreamBridgeService;
|
||||||
|
import com.imeeting.service.realtime.RealtimeMeetingAudioStorageService;
|
||||||
import com.imeeting.service.realtime.RealtimeMeetingGrpcSessionService;
|
import com.imeeting.service.realtime.RealtimeMeetingGrpcSessionService;
|
||||||
import io.grpc.stub.StreamObserver;
|
import io.grpc.stub.StreamObserver;
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
|
|
@ -38,14 +39,20 @@ public class RealtimeMeetingGrpcSessionServiceImpl implements RealtimeMeetingGrp
|
||||||
private final AsrUpstreamBridgeService asrUpstreamBridgeService;
|
private final AsrUpstreamBridgeService asrUpstreamBridgeService;
|
||||||
private final MeetingCommandService meetingCommandService;
|
private final MeetingCommandService meetingCommandService;
|
||||||
private final RealtimeMeetingSessionStateService realtimeMeetingSessionStateService;
|
private final RealtimeMeetingSessionStateService realtimeMeetingSessionStateService;
|
||||||
|
private final RealtimeMeetingAudioStorageService realtimeMeetingAudioStorageService;
|
||||||
private final StringRedisTemplate redisTemplate;
|
private final StringRedisTemplate redisTemplate;
|
||||||
private final GrpcServerProperties grpcServerProperties;
|
private final GrpcServerProperties grpcServerProperties;
|
||||||
|
|
||||||
private final ConcurrentMap<String, SessionRuntime> sessions = new ConcurrentHashMap<>();
|
private final ConcurrentMap<String, SessionRuntime> sessions = new ConcurrentHashMap<>();
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public String openStream(String streamToken, AndroidAuthContext authContext, StreamObserver<RealtimeServerPacket> responseObserver) {
|
public String openStream(Long meetingId, String streamToken, AndroidAuthContext authContext, StreamObserver<RealtimeServerPacket> responseObserver) {
|
||||||
AndroidRealtimeGrpcSessionData sessionData = ticketService.getSessionData(streamToken);
|
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) {
|
if (sessionData == null) {
|
||||||
throw new RuntimeException("Invalid realtime gRPC session token");
|
throw new RuntimeException("Invalid realtime gRPC session token");
|
||||||
}
|
}
|
||||||
|
|
@ -54,6 +61,9 @@ public class RealtimeMeetingGrpcSessionServiceImpl implements RealtimeMeetingGrp
|
||||||
&& !sessionData.getDeviceId().equals(authContext.getDeviceId())) {
|
&& !sessionData.getDeviceId().equals(authContext.getDeviceId())) {
|
||||||
throw new RuntimeException("Realtime gRPC session token does not match deviceId");
|
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("-", "");
|
String connectionId = "grpc_" + java.util.UUID.randomUUID().toString().replace("-", "");
|
||||||
SessionRuntime runtime = new SessionRuntime(connectionId, streamToken, sessionData, responseObserver);
|
SessionRuntime runtime = new SessionRuntime(connectionId, streamToken, sessionData, responseObserver);
|
||||||
|
|
@ -63,6 +73,7 @@ public class RealtimeMeetingGrpcSessionServiceImpl implements RealtimeMeetingGrp
|
||||||
}
|
}
|
||||||
|
|
||||||
writeConnectionState(runtime);
|
writeConnectionState(runtime);
|
||||||
|
realtimeMeetingAudioStorageService.openSession(sessionData.getMeetingId(), connectionId);
|
||||||
runtime.upstreamSession = asrUpstreamBridgeService.openSession(sessionData, connectionId, new UpstreamCallback(runtime));
|
runtime.upstreamSession = asrUpstreamBridgeService.openSession(sessionData, connectionId, new UpstreamCallback(runtime));
|
||||||
return connectionId;
|
return connectionId;
|
||||||
}
|
}
|
||||||
|
|
@ -74,6 +85,7 @@ public class RealtimeMeetingGrpcSessionServiceImpl implements RealtimeMeetingGrp
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
touchConnectionState(runtime);
|
touchConnectionState(runtime);
|
||||||
|
realtimeMeetingAudioStorageService.append(connectionId, payload);
|
||||||
runtime.upstreamSession.sendAudio(payload);
|
runtime.upstreamSession.sendAudio(payload);
|
||||||
if (lastChunk) {
|
if (lastChunk) {
|
||||||
runtime.upstreamSession.sendStopSpeaking();
|
runtime.upstreamSession.sendStopSpeaking();
|
||||||
|
|
@ -109,6 +121,7 @@ public class RealtimeMeetingGrpcSessionServiceImpl implements RealtimeMeetingGrp
|
||||||
}
|
}
|
||||||
|
|
||||||
redisTemplate.delete(RedisKeys.realtimeMeetingGrpcConnectionKey(connectionId));
|
redisTemplate.delete(RedisKeys.realtimeMeetingGrpcConnectionKey(connectionId));
|
||||||
|
realtimeMeetingAudioStorageService.closeSession(connectionId);
|
||||||
realtimeMeetingSessionStateService.pauseByDisconnect(runtime.sessionData.getMeetingId(), connectionId);
|
realtimeMeetingSessionStateService.pauseByDisconnect(runtime.sessionData.getMeetingId(), connectionId);
|
||||||
|
|
||||||
if (notifyClient) {
|
if (notifyClient) {
|
||||||
|
|
@ -193,15 +206,13 @@ public class RealtimeMeetingGrpcSessionServiceImpl implements RealtimeMeetingGrp
|
||||||
if (runtime.closed.get() || result == null || result.getText() == null || result.getText().isBlank()) {
|
if (runtime.closed.get() || result == null || result.getText() == null || result.getText().isBlank()) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (result.isFinalResult()) {
|
|
||||||
RealtimeTranscriptItemDTO item = new RealtimeTranscriptItemDTO();
|
RealtimeTranscriptItemDTO item = new RealtimeTranscriptItemDTO();
|
||||||
item.setSpeakerId(result.getSpeakerId());
|
item.setSpeakerId(result.getSpeakerId());
|
||||||
item.setSpeakerName(result.getSpeakerName());
|
item.setSpeakerName(result.getSpeakerName());
|
||||||
item.setContent(result.getText());
|
item.setContent(result.getText());
|
||||||
item.setStartTime(result.getStartTime());
|
item.setStartTime(result.getStartTime());
|
||||||
item.setEndTime(result.getEndTime());
|
item.setEndTime(result.getEndTime());
|
||||||
meetingCommandService.appendRealtimeTranscripts(runtime.sessionData.getMeetingId(), List.of(item));
|
meetingCommandService.saveRealtimeTranscriptSnapshot(runtime.sessionData.getMeetingId(), item, result.isFinalResult());
|
||||||
}
|
|
||||||
runtime.send(RealtimeServerPacket.newBuilder()
|
runtime.send(RealtimeServerPacket.newBuilder()
|
||||||
.setTranscript(TranscriptEvent.newBuilder()
|
.setTranscript(TranscriptEvent.newBuilder()
|
||||||
.setMeetingId(runtime.sessionData.getMeetingId())
|
.setMeetingId(runtime.sessionData.getMeetingId())
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ package com.imeeting.websocket;
|
||||||
import com.imeeting.dto.biz.RealtimeSocketSessionData;
|
import com.imeeting.dto.biz.RealtimeSocketSessionData;
|
||||||
import com.imeeting.service.biz.RealtimeMeetingSessionStateService;
|
import com.imeeting.service.biz.RealtimeMeetingSessionStateService;
|
||||||
import com.imeeting.service.biz.RealtimeMeetingSocketSessionService;
|
import com.imeeting.service.biz.RealtimeMeetingSocketSessionService;
|
||||||
|
import com.imeeting.service.realtime.RealtimeMeetingAudioStorageService;
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
import org.springframework.stereotype.Component;
|
import org.springframework.stereotype.Component;
|
||||||
|
|
@ -50,6 +51,7 @@ public class RealtimeMeetingProxyWebSocketHandler extends AbstractWebSocketHandl
|
||||||
|
|
||||||
private final RealtimeMeetingSocketSessionService realtimeMeetingSocketSessionService;
|
private final RealtimeMeetingSocketSessionService realtimeMeetingSocketSessionService;
|
||||||
private final RealtimeMeetingSessionStateService realtimeMeetingSessionStateService;
|
private final RealtimeMeetingSessionStateService realtimeMeetingSessionStateService;
|
||||||
|
private final RealtimeMeetingAudioStorageService realtimeMeetingAudioStorageService;
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void afterConnectionEstablished(WebSocketSession session) throws Exception {
|
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_UPSTREAM_SEND_CHAIN, COMPLETED);
|
||||||
session.getAttributes().put(ATTR_START_MESSAGE_SENT, Boolean.FALSE);
|
session.getAttributes().put(ATTR_START_MESSAGE_SENT, Boolean.FALSE);
|
||||||
session.getAttributes().put(ATTR_PENDING_AUDIO_FRAMES, new ArrayList<byte[]>());
|
session.getAttributes().put(ATTR_PENDING_AUDIO_FRAMES, new ArrayList<byte[]>());
|
||||||
|
realtimeMeetingAudioStorageService.openSession(sessionData.getMeetingId(), session.getId());
|
||||||
log.info("Realtime websocket accepted: meetingId={}, sessionId={}, upstream={}",
|
log.info("Realtime websocket accepted: meetingId={}, sessionId={}, upstream={}",
|
||||||
sessionData.getMeetingId(), session.getId(), sessionData.getTargetWsUrl());
|
sessionData.getMeetingId(), session.getId(), sessionData.getTargetWsUrl());
|
||||||
|
|
||||||
|
|
@ -92,11 +95,13 @@ public class RealtimeMeetingProxyWebSocketHandler extends AbstractWebSocketHandl
|
||||||
log.error("Realtime websocket upstream connect interrupted: meetingId={}, sessionId={}",
|
log.error("Realtime websocket upstream connect interrupted: meetingId={}, sessionId={}",
|
||||||
sessionData.getMeetingId(), session.getId(), ex);
|
sessionData.getMeetingId(), session.getId(), ex);
|
||||||
sendFrontendError(frontendSession, "REALTIME_UPSTREAM_CONNECT_INTERRUPTED", "连接第三方识别服务时被中断");
|
sendFrontendError(frontendSession, "REALTIME_UPSTREAM_CONNECT_INTERRUPTED", "连接第三方识别服务时被中断");
|
||||||
|
realtimeMeetingAudioStorageService.closeSession(session.getId());
|
||||||
frontendSession.close(CloseStatus.SERVER_ERROR.withReason("Interrupted while connecting upstream"));
|
frontendSession.close(CloseStatus.SERVER_ERROR.withReason("Interrupted while connecting upstream"));
|
||||||
return;
|
return;
|
||||||
} catch (ExecutionException | CompletionException ex) {
|
} catch (ExecutionException | CompletionException ex) {
|
||||||
log.warn("Failed to connect upstream websocket, meetingId={}, target={}", sessionData.getMeetingId(), sessionData.getTargetWsUrl(), ex);
|
log.warn("Failed to connect upstream websocket, meetingId={}, target={}", sessionData.getMeetingId(), sessionData.getTargetWsUrl(), ex);
|
||||||
sendFrontendError(frontendSession, "REALTIME_UPSTREAM_CONNECT_FAILED", "连接第三方识别服务失败,请检查模型 WebSocket 配置或服务状态");
|
sendFrontendError(frontendSession, "REALTIME_UPSTREAM_CONNECT_FAILED", "连接第三方识别服务失败,请检查模型 WebSocket 配置或服务状态");
|
||||||
|
realtimeMeetingAudioStorageService.closeSession(session.getId());
|
||||||
frontendSession.close(CloseStatus.SERVER_ERROR.withReason("Failed to connect ASR websocket"));
|
frontendSession.close(CloseStatus.SERVER_ERROR.withReason("Failed to connect ASR websocket"));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
@ -137,6 +142,7 @@ public class RealtimeMeetingProxyWebSocketHandler extends AbstractWebSocketHandl
|
||||||
session.getAttributes().get(ATTR_MEETING_ID), session.getId(), count, bytes);
|
session.getAttributes().get(ATTR_MEETING_ID), session.getId(), count, bytes);
|
||||||
}
|
}
|
||||||
byte[] payload = toByteArray(message.getPayload());
|
byte[] payload = toByteArray(message.getPayload());
|
||||||
|
realtimeMeetingAudioStorageService.append(session.getId(), payload);
|
||||||
if (!Boolean.TRUE.equals(session.getAttributes().get(ATTR_START_MESSAGE_SENT))) {
|
if (!Boolean.TRUE.equals(session.getAttributes().get(ATTR_START_MESSAGE_SENT))) {
|
||||||
queuePendingAudioFrame(session, payload);
|
queuePendingAudioFrame(session, payload);
|
||||||
if (shouldLogBinaryFrame(count)) {
|
if (shouldLogBinaryFrame(count)) {
|
||||||
|
|
@ -175,6 +181,7 @@ public class RealtimeMeetingProxyWebSocketHandler extends AbstractWebSocketHandl
|
||||||
if (meetingIdValue instanceof Long meetingId) {
|
if (meetingIdValue instanceof Long meetingId) {
|
||||||
realtimeMeetingSessionStateService.pauseByDisconnect(meetingId, session.getId());
|
realtimeMeetingSessionStateService.pauseByDisconnect(meetingId, session.getId());
|
||||||
}
|
}
|
||||||
|
realtimeMeetingAudioStorageService.closeSession(session.getId());
|
||||||
closeUpstreamSocket(session, status);
|
closeUpstreamSocket(session, status);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -42,7 +42,6 @@ unisbase:
|
||||||
permit-all-urls:
|
permit-all-urls:
|
||||||
- /actuator/health
|
- /actuator/health
|
||||||
- /api/static/**
|
- /api/static/**
|
||||||
- /api/android/**
|
|
||||||
- /ws/**
|
- /ws/**
|
||||||
internal-auth:
|
internal-auth:
|
||||||
enabled: true
|
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";
|
import axios from "axios";
|
||||||
|
|
||||||
export interface MeetingVO {
|
export interface MeetingVO {
|
||||||
|
|
@ -6,12 +6,16 @@ export interface MeetingVO {
|
||||||
tenantId: number;
|
tenantId: number;
|
||||||
creatorId: number;
|
creatorId: number;
|
||||||
creatorName?: string;
|
creatorName?: string;
|
||||||
|
hostUserId?: number;
|
||||||
|
hostName?: string;
|
||||||
title: string;
|
title: string;
|
||||||
meetingTime: string;
|
meetingTime: string;
|
||||||
participants: string;
|
participants: string;
|
||||||
participantIds?: number[];
|
participantIds?: number[];
|
||||||
tags: string;
|
tags: string;
|
||||||
audioUrl: string;
|
audioUrl: string;
|
||||||
|
audioSaveStatus?: "NONE" | "SUCCESS" | "FAILED";
|
||||||
|
audioSaveMessage?: string;
|
||||||
summaryContent: string;
|
summaryContent: string;
|
||||||
analysis?: {
|
analysis?: {
|
||||||
overview?: string;
|
overview?: string;
|
||||||
|
|
@ -33,6 +37,8 @@ export interface CreateMeetingCommand {
|
||||||
meetingTime: string;
|
meetingTime: string;
|
||||||
participants: string;
|
participants: string;
|
||||||
tags: string;
|
tags: string;
|
||||||
|
hostUserId?: number;
|
||||||
|
hostName?: string;
|
||||||
audioUrl?: string;
|
audioUrl?: string;
|
||||||
asrModelId: number;
|
asrModelId: number;
|
||||||
summaryModelId?: number;
|
summaryModelId?: number;
|
||||||
|
|
@ -49,6 +55,8 @@ export interface CreateRealtimeMeetingCommand {
|
||||||
meetingTime: string;
|
meetingTime: string;
|
||||||
participants: string;
|
participants: string;
|
||||||
tags: string;
|
tags: string;
|
||||||
|
hostUserId?: number;
|
||||||
|
hostName?: string;
|
||||||
asrModelId: number;
|
asrModelId: number;
|
||||||
summaryModelId?: number;
|
summaryModelId?: number;
|
||||||
promptId: 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 }>(
|
return http.post<any, { code: string; data: boolean; msg: string }>(
|
||||||
`/api/biz/meeting/${meetingId}/realtime/complete`,
|
`/api/biz/meeting/${meetingId}/realtime/complete`,
|
||||||
data || {}
|
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 { useNavigate, useParams } from 'react-router-dom';
|
||||||
import {
|
import {
|
||||||
|
Alert,
|
||||||
Avatar,
|
Avatar,
|
||||||
Breadcrumb,
|
Breadcrumb,
|
||||||
Button,
|
Button,
|
||||||
|
|
@ -600,6 +601,12 @@ const MeetingDetail: React.FC = () => {
|
||||||
setSelectedKeywords((current) => current.filter((item) => analysis.keywords.includes(item)));
|
setSelectedKeywords((current) => current.filter((item) => analysis.keywords.includes(item)));
|
||||||
}, [analysis.keywords]);
|
}, [analysis.keywords]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (meeting?.audioSaveStatus === 'FAILED') {
|
||||||
|
message.warning(meeting.audioSaveMessage || '实时会议已完成,但音频保存失败,当前无法播放会议录音。转写和总结不受影响。');
|
||||||
|
}
|
||||||
|
}, [meeting?.id, meeting?.audioSaveStatus, meeting?.audioSaveMessage]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const audio = audioRef.current;
|
const audio = audioRef.current;
|
||||||
if (!audio) return undefined;
|
if (!audio) return undefined;
|
||||||
|
|
@ -1201,6 +1208,14 @@ const MeetingDetail: React.FC = () => {
|
||||||
|
|
||||||
<Card className="left-flow-card" bordered={false} title={<span><AudioOutlined /> 原文</span>}>
|
<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.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
|
<List
|
||||||
dataSource={transcripts}
|
dataSource={transcripts}
|
||||||
renderItem={(item) => (
|
renderItem={(item) => (
|
||||||
|
|
@ -1991,4 +2006,3 @@ const MeetingDetail: React.FC = () => {
|
||||||
};
|
};
|
||||||
|
|
||||||
export default MeetingDetail;
|
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="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>
|
<Col span={12}><Form.Item name="tags" label="会议标签" style={{ marginBottom: 12 }}><Select mode="tags" placeholder="输入标签" size="large" /></Form.Item></Col>
|
||||||
</Row>
|
</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">
|
<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>))}
|
{userList.map(u => (<Option key={u.userId} value={u.userId}><Space><Avatar size="small" icon={<UserOutlined />} />{u.displayName || u.username}</Space></Option>))}
|
||||||
</Select>
|
</Select>
|
||||||
</Form.Item>
|
</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>
|
</Card>
|
||||||
|
|
||||||
{/* 录音上传卡片 - 占满剩余高度 */}
|
{/* 录音上传卡片 - 占满剩余高度 */}
|
||||||
|
|
@ -487,14 +492,16 @@ const Meetings: React.FC = () => {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const values = await form.validateFields();
|
const values = await form.validateFields();
|
||||||
|
const { hostUserId, ...meetingValues } = values;
|
||||||
setSubmitLoading(true);
|
setSubmitLoading(true);
|
||||||
try {
|
try {
|
||||||
await createMeeting({
|
await createMeeting({
|
||||||
...values,
|
...meetingValues,
|
||||||
meetingTime: values.meetingTime.format('YYYY-MM-DD HH:mm:ss'),
|
...(hostUserId != null ? { hostUserId } : {}),
|
||||||
|
meetingTime: meetingValues.meetingTime.format('YYYY-MM-DD HH:mm:ss'),
|
||||||
audioUrl,
|
audioUrl,
|
||||||
participants: values.participants?.join(','),
|
participants: meetingValues.participants?.join(','),
|
||||||
tags: values.tags?.join(',')
|
tags: meetingValues.tags?.join(',')
|
||||||
});
|
});
|
||||||
message.success('会议发起成功');
|
message.success('会议发起成功');
|
||||||
setCreateDrawerVisible(false);
|
setCreateDrawerVisible(false);
|
||||||
|
|
|
||||||
|
|
@ -171,19 +171,21 @@ export default function RealtimeAsr() {
|
||||||
weight: Number(item.weight || 2) / 10,
|
weight: Number(item.weight || 2) / 10,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
const { hostUserId, ...meetingValues } = values;
|
||||||
const payload: CreateRealtimeMeetingCommand = {
|
const payload: CreateRealtimeMeetingCommand = {
|
||||||
...values,
|
...meetingValues,
|
||||||
meetingTime: values.meetingTime.format("YYYY-MM-DD HH:mm:ss"),
|
...(hostUserId != null ? { hostUserId } : {}),
|
||||||
participants: values.participants?.join(",") || "",
|
meetingTime: meetingValues.meetingTime.format("YYYY-MM-DD HH:mm:ss"),
|
||||||
tags: values.tags?.join(",") || "",
|
participants: meetingValues.participants?.join(",") || "",
|
||||||
mode: values.mode || "2pass",
|
tags: meetingValues.tags?.join(",") || "",
|
||||||
language: values.language || "auto",
|
mode: meetingValues.mode || "2pass",
|
||||||
useSpkId: values.useSpkId ? 1 : 0,
|
language: meetingValues.language || "auto",
|
||||||
enablePunctuation: values.enablePunctuation !== false,
|
useSpkId: meetingValues.useSpkId ? 1 : 0,
|
||||||
enableItn: values.enableItn !== false,
|
enablePunctuation: meetingValues.enablePunctuation !== false,
|
||||||
enableTextRefine: !!values.enableTextRefine,
|
enableItn: meetingValues.enableItn !== false,
|
||||||
saveAudio: !!values.saveAudio,
|
enableTextRefine: !!meetingValues.enableTextRefine,
|
||||||
hotWords: values.hotWords,
|
saveAudio: !!meetingValues.saveAudio,
|
||||||
|
hotWords: meetingValues.hotWords,
|
||||||
};
|
};
|
||||||
|
|
||||||
const res = await createRealtimeMeeting(payload);
|
const res = await createRealtimeMeeting(payload);
|
||||||
|
|
@ -321,6 +323,23 @@ export default function RealtimeAsr() {
|
||||||
</Select>
|
</Select>
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
</Col>
|
</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}>
|
<Col xs={24} md={12}>
|
||||||
<Form.Item name="tags" label="会议标签">
|
<Form.Item name="tags" label="会议标签">
|
||||||
<Select mode="tags" placeholder="输入标签后回车" />
|
<Select mode="tags" placeholder="输入标签后回车" />
|
||||||
|
|
|
||||||
|
|
@ -596,10 +596,21 @@ export function RealtimeAsrSession() {
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await completeRealtimeMeeting(meetingId, {});
|
await completeRealtimeMeeting(meetingId, {});
|
||||||
|
let savedMeeting: MeetingVO | null = null;
|
||||||
|
try {
|
||||||
|
const detailRes = await getMeetingDetail(meetingId);
|
||||||
|
savedMeeting = detailRes.data.data;
|
||||||
|
} catch {
|
||||||
|
// 会议完成已成功提交,详情刷新失败不应反向标记为结束失败。
|
||||||
|
}
|
||||||
sessionStorage.removeItem(getSessionKey(meetingId));
|
sessionStorage.removeItem(getSessionKey(meetingId));
|
||||||
setSessionStatus((prev) => prev ? { ...prev, status: "COMPLETING", canResume: false, activeConnection: false } : prev);
|
setSessionStatus((prev) => prev ? { ...prev, status: "COMPLETING", canResume: false, activeConnection: false } : prev);
|
||||||
setStatusText("已提交总结任务");
|
setStatusText("已提交总结任务");
|
||||||
|
if (savedMeeting?.audioSaveStatus === "FAILED") {
|
||||||
|
message.warning(savedMeeting.audioSaveMessage || "实时会议已完成,但音频保存失败,当前无法播放会议录音。转写和总结不受影响。");
|
||||||
|
} else {
|
||||||
message.success("实时会议已结束,正在生成总结");
|
message.success("实时会议已结束,正在生成总结");
|
||||||
|
}
|
||||||
if (navigateAfterStop) {
|
if (navigateAfterStop) {
|
||||||
navigate(`/meetings/${meetingId}`);
|
navigate(`/meetings/${meetingId}`);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -69,6 +69,7 @@ const SpeakerReg: React.FC = () => {
|
||||||
const [editingSpeaker, setEditingSpeaker] = useState<SpeakerVO | null>(null);
|
const [editingSpeaker, setEditingSpeaker] = useState<SpeakerVO | null>(null);
|
||||||
const [seconds, setSeconds] = useState(0);
|
const [seconds, setSeconds] = useState(0);
|
||||||
const timerRef = useRef<any>(null);
|
const timerRef = useRef<any>(null);
|
||||||
|
const autoStopTimerRef = useRef<any>(null);
|
||||||
const mediaRecorderRef = useRef<MediaRecorder | null>(null);
|
const mediaRecorderRef = useRef<MediaRecorder | null>(null);
|
||||||
const audioChunksRef = useRef<Blob[]>([]);
|
const audioChunksRef = useRef<Blob[]>([]);
|
||||||
const { profile } = useAuth();
|
const { profile } = useAuth();
|
||||||
|
|
@ -165,16 +166,15 @@ const SpeakerReg: React.FC = () => {
|
||||||
};
|
};
|
||||||
|
|
||||||
const startTimer = () => {
|
const startTimer = () => {
|
||||||
|
stopTimer();
|
||||||
setSeconds(0);
|
setSeconds(0);
|
||||||
timerRef.current = setInterval(() => {
|
timerRef.current = setInterval(() => {
|
||||||
setSeconds(prev => {
|
setSeconds(prev => Math.min(prev + 1, DEFAULT_DURATION));
|
||||||
if (prev + 1 >= DEFAULT_DURATION) {
|
|
||||||
stopRecording();
|
|
||||||
return DEFAULT_DURATION;
|
|
||||||
}
|
|
||||||
return prev + 1;
|
|
||||||
});
|
|
||||||
}, 1000);
|
}, 1000);
|
||||||
|
autoStopTimerRef.current = setTimeout(() => {
|
||||||
|
setSeconds(DEFAULT_DURATION);
|
||||||
|
stopRecording();
|
||||||
|
}, DEFAULT_DURATION * 1000);
|
||||||
};
|
};
|
||||||
|
|
||||||
const stopTimer = () => {
|
const stopTimer = () => {
|
||||||
|
|
@ -182,6 +182,10 @@ const SpeakerReg: React.FC = () => {
|
||||||
clearInterval(timerRef.current);
|
clearInterval(timerRef.current);
|
||||||
timerRef.current = null;
|
timerRef.current = null;
|
||||||
}
|
}
|
||||||
|
if (autoStopTimerRef.current) {
|
||||||
|
clearTimeout(autoStopTimerRef.current);
|
||||||
|
autoStopTimerRef.current = null;
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const startRecording = async () => {
|
const startRecording = async () => {
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue