feat: 添加实时会议创建和验证测试

- 添加 `MeetingCreateCommandValidationTest` 以验证创建会议命令的必填字段
- 添加 `MeetingRuntimeProfileResolverImplTest` 以测试运行时配置解析
- 添加 `MeetingCommandServiceImplTest` 以测试会议命令服务的逻辑
- 添加 `AndroidAuthServiceImplTest` 以测试 Android 认证服务
- 更新 `MeetingCommandService` 接口,添加 `saveRealtimeTranscriptSnapshot` 和更新 `completeRealtimeMeeting` 方法
- 在 `AndroidMeetingRealtimeController` 中添加创建实时会议的 API 端点
- 定义 `AndroidCreateRealtimeMeetingCommand` 和 `AndroidCreateRealtimeMeetingVO` 数据传输对象
dev_na
chenhao 2026-04-08 09:15:26 +08:00
parent 24c3835b79
commit 135203b9f6
43 changed files with 2563 additions and 136 deletions

View File

@ -1,14 +1,24 @@
package com.imeeting.controller.android;
import com.imeeting.config.grpc.GrpcServerProperties;
import com.imeeting.dto.android.AndroidAuthContext;
import com.imeeting.dto.android.AndroidCreateRealtimeMeetingCommand;
import com.imeeting.dto.android.AndroidCreateRealtimeMeetingVO;
import com.imeeting.dto.android.AndroidOpenRealtimeGrpcSessionCommand;
import com.imeeting.dto.android.AndroidRealtimeGrpcSessionVO;
import com.imeeting.dto.biz.CreateRealtimeMeetingCommand;
import com.imeeting.dto.biz.MeetingTranscriptVO;
import com.imeeting.dto.biz.RealtimeMeetingRuntimeProfile;
import com.imeeting.dto.biz.MeetingVO;
import com.imeeting.dto.biz.RealtimeMeetingCompleteDTO;
import com.imeeting.dto.biz.RealtimeMeetingSessionStatusVO;
import com.imeeting.entity.biz.Meeting;
import com.imeeting.service.android.AndroidAuthService;
import com.imeeting.service.biz.MeetingAccessService;
import com.imeeting.service.biz.MeetingAuthorizationService;
import com.imeeting.service.biz.MeetingCommandService;
import com.imeeting.service.biz.MeetingQueryService;
import com.imeeting.service.biz.MeetingRuntimeProfileResolver;
import com.imeeting.service.biz.RealtimeMeetingSessionStateService;
import com.imeeting.service.realtime.AndroidRealtimeSessionTicketService;
import com.unisbase.common.ApiResponse;
@ -21,6 +31,8 @@ import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.List;
@RestController
@ -28,31 +40,86 @@ import java.util.List;
@RequiredArgsConstructor
public class AndroidMeetingRealtimeController {
private static final DateTimeFormatter TITLE_TIME_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
private final AndroidAuthService androidAuthService;
private final MeetingAccessService meetingAccessService;
private final MeetingAuthorizationService meetingAuthorizationService;
private final MeetingQueryService meetingQueryService;
private final MeetingCommandService meetingCommandService;
private final RealtimeMeetingSessionStateService realtimeMeetingSessionStateService;
private final AndroidRealtimeSessionTicketService androidRealtimeSessionTicketService;
private final MeetingRuntimeProfileResolver meetingRuntimeProfileResolver;
private final GrpcServerProperties grpcServerProperties;
@PostMapping("/realtime/create")
public ApiResponse<AndroidCreateRealtimeMeetingVO> createRealtimeMeeting(HttpServletRequest request,
@RequestBody(required = false) AndroidCreateRealtimeMeetingCommand command) {
AndroidAuthContext authContext = androidAuthService.authenticateHttp(request);
meetingAuthorizationService.assertCanCreateMeeting(authContext);
RealtimeMeetingRuntimeProfile runtimeProfile = meetingRuntimeProfileResolver.resolve(
authContext.getTenantId(),
command == null ? null : command.getAsrModelId(),
command == null ? null : command.getSummaryModelId(),
command == null ? null : command.getPromptId(),
command == null ? null : command.getMode(),
command == null ? null : command.getLanguage(),
command == null ? null : command.getUseSpkId(),
command == null ? null : command.getEnablePunctuation(),
command == null ? null : command.getEnableItn(),
command == null ? null : command.getEnableTextRefine(),
command == null ? null : command.getSaveAudio(),
command == null ? null : command.getHotWords()
);
CreateRealtimeMeetingCommand createCommand = buildCreateCommand(command, authContext, runtimeProfile);
MeetingVO meeting = meetingCommandService.createRealtimeMeeting(
createCommand,
authContext.getTenantId(),
authContext.getUserId(),
resolveCreatorName(authContext)
);
RealtimeMeetingSessionStatusVO status = realtimeMeetingSessionStateService.getStatus(meeting.getId());
AndroidCreateRealtimeMeetingVO vo = new AndroidCreateRealtimeMeetingVO();
vo.setMeetingId(meeting.getId());
vo.setTitle(meeting.getTitle());
vo.setHostUserId(meeting.getHostUserId());
vo.setHostName(meeting.getHostName());
vo.setSampleRate(grpcServerProperties.getRealtime().getSampleRate());
vo.setChannels(grpcServerProperties.getRealtime().getChannels());
vo.setEncoding(grpcServerProperties.getRealtime().getEncoding());
vo.setResolvedAsrModelId(runtimeProfile.getResolvedAsrModelId());
vo.setResolvedAsrModelName(runtimeProfile.getResolvedAsrModelName());
vo.setResolvedSummaryModelId(runtimeProfile.getResolvedSummaryModelId());
vo.setResolvedSummaryModelName(runtimeProfile.getResolvedSummaryModelName());
vo.setResolvedPromptId(runtimeProfile.getResolvedPromptId());
vo.setResolvedPromptName(runtimeProfile.getResolvedPromptName());
vo.setResumeConfig(status == null ? null : status.getResumeConfig());
vo.setStatus(status);
return ApiResponse.ok(vo);
}
@GetMapping("/{id}/realtime/session-status")
public ApiResponse<RealtimeMeetingSessionStatusVO> getRealtimeSessionStatus(@PathVariable Long id, HttpServletRequest request) {
androidAuthService.authenticateHttp(request);
meetingAccessService.requireMeeting(id);
AndroidAuthContext authContext = androidAuthService.authenticateHttp(request);
Meeting meeting = meetingAccessService.requireMeeting(id);
meetingAuthorizationService.assertCanManageRealtimeMeeting(meeting, authContext);
return ApiResponse.ok(realtimeMeetingSessionStateService.getStatus(id));
}
@GetMapping("/{id}/transcripts")
public ApiResponse<List<MeetingTranscriptVO>> getTranscripts(@PathVariable Long id, HttpServletRequest request) {
androidAuthService.authenticateHttp(request);
meetingAccessService.requireMeeting(id);
AndroidAuthContext authContext = androidAuthService.authenticateHttp(request);
Meeting meeting = meetingAccessService.requireMeeting(id);
meetingAuthorizationService.assertCanViewMeeting(meeting, authContext);
return ApiResponse.ok(meetingQueryService.getTranscripts(id));
}
@PostMapping("/{id}/realtime/pause")
public ApiResponse<RealtimeMeetingSessionStatusVO> pauseRealtimeMeeting(@PathVariable Long id, HttpServletRequest request) {
androidAuthService.authenticateHttp(request);
meetingAccessService.requireMeeting(id);
AndroidAuthContext authContext = androidAuthService.authenticateHttp(request);
Meeting meeting = meetingAccessService.requireMeeting(id);
meetingAuthorizationService.assertCanManageRealtimeMeeting(meeting, authContext);
return ApiResponse.ok(realtimeMeetingSessionStateService.pause(id));
}
@ -60,9 +127,14 @@ public class AndroidMeetingRealtimeController {
public ApiResponse<Boolean> completeRealtimeMeeting(@PathVariable Long id,
HttpServletRequest request,
@RequestBody(required = false) RealtimeMeetingCompleteDTO dto) {
androidAuthService.authenticateHttp(request);
meetingAccessService.requireMeeting(id);
meetingCommandService.completeRealtimeMeeting(id, dto != null ? dto.getAudioUrl() : null);
AndroidAuthContext authContext = androidAuthService.authenticateHttp(request);
Meeting meeting = meetingAccessService.requireMeeting(id);
meetingAuthorizationService.assertCanManageRealtimeMeeting(meeting, authContext);
meetingCommandService.completeRealtimeMeeting(
id,
dto != null ? dto.getAudioUrl() : null,
dto != null && Boolean.TRUE.equals(dto.getOverwriteAudio())
);
return ApiResponse.ok(true);
}
@ -70,6 +142,86 @@ public class AndroidMeetingRealtimeController {
public ApiResponse<AndroidRealtimeGrpcSessionVO> openRealtimeGrpcSession(@PathVariable Long id,
HttpServletRequest request,
@RequestBody(required = false) AndroidOpenRealtimeGrpcSessionCommand command) {
return ApiResponse.ok(androidRealtimeSessionTicketService.createSession(id, command, androidAuthService.authenticateHttp(request)));
AndroidAuthContext authContext = androidAuthService.authenticateHttp(request);
Meeting meeting = meetingAccessService.requireMeeting(id);
meetingAuthorizationService.assertCanManageRealtimeMeeting(meeting, authContext);
return ApiResponse.ok(androidRealtimeSessionTicketService.createSession(id, command, authContext));
}
private CreateRealtimeMeetingCommand buildCreateCommand(AndroidCreateRealtimeMeetingCommand command,
AndroidAuthContext authContext,
RealtimeMeetingRuntimeProfile runtimeProfile) {
CreateRealtimeMeetingCommand createCommand = new CreateRealtimeMeetingCommand();
LocalDateTime meetingTime = command != null && command.getMeetingTime() != null ? command.getMeetingTime() : LocalDateTime.now();
createCommand.setTitle(resolveMeetingTitle(command, meetingTime));
createCommand.setMeetingTime(meetingTime);
createCommand.setParticipants(command == null ? "" : normalize(command.getParticipants(), ""));
createCommand.setTags(command == null ? "" : normalize(command.getTags()));
createCommand.setHostUserId(resolveHostUserId(command, authContext));
createCommand.setHostName(resolveHostName(command, authContext, createCommand.getHostUserId()));
createCommand.setAsrModelId(runtimeProfile.getResolvedAsrModelId());
createCommand.setSummaryModelId(runtimeProfile.getResolvedSummaryModelId());
createCommand.setPromptId(runtimeProfile.getResolvedPromptId());
createCommand.setMode(runtimeProfile.getResolvedMode());
createCommand.setLanguage(runtimeProfile.getResolvedLanguage());
createCommand.setUseSpkId(runtimeProfile.getResolvedUseSpkId());
createCommand.setEnablePunctuation(runtimeProfile.getResolvedEnablePunctuation());
createCommand.setEnableItn(runtimeProfile.getResolvedEnableItn());
createCommand.setEnableTextRefine(runtimeProfile.getResolvedEnableTextRefine());
createCommand.setSaveAudio(runtimeProfile.getResolvedSaveAudio());
createCommand.setHotWords(runtimeProfile.getResolvedHotWords());
return createCommand;
}
private String resolveMeetingTitle(AndroidCreateRealtimeMeetingCommand command, LocalDateTime meetingTime) {
String title = command == null ? null : normalize(command.getTitle());
if (title != null && !title.isBlank()) {
return title;
}
return "Android-Realtime-Meeting-" + TITLE_TIME_FORMATTER.format(meetingTime);
}
private Long resolveHostUserId(AndroidCreateRealtimeMeetingCommand command, AndroidAuthContext authContext) {
if (command != null && command.getHostUserId() != null) {
return command.getHostUserId();
}
return authContext.getUserId();
}
private String resolveHostName(AndroidCreateRealtimeMeetingCommand command, AndroidAuthContext authContext, Long hostUserId) {
if (command != null && command.getHostName() != null && !command.getHostName().isBlank()) {
return command.getHostName().trim();
}
if (hostUserId != null && hostUserId.equals(authContext.getUserId())) {
return resolveCreatorName(authContext);
}
return null;
}
private String resolveCreatorName(AndroidAuthContext authContext) {
if (authContext == null) {
return "android";
}
if (authContext.getDisplayName() != null && !authContext.getDisplayName().isBlank()) {
return authContext.getDisplayName().trim();
}
if (authContext.getUsername() != null && !authContext.getUsername().isBlank()) {
return authContext.getUsername().trim();
}
return authContext.getDeviceId() == null || authContext.getDeviceId().isBlank()
? "android"
: "android:" + authContext.getDeviceId().trim();
}
private String normalize(String value) {
return normalize(value, null);
}
private String normalize(String value, String defaultValue) {
if (value == null) {
return defaultValue;
}
String normalized = value.trim();
return normalized.isEmpty() ? defaultValue : normalized;
}
}

View File

@ -310,7 +310,11 @@ public class MeetingController {
LoginUser loginUser = currentLoginUser();
Meeting meeting = meetingAccessService.requireMeeting(id);
meetingAccessService.assertCanManageRealtimeMeeting(meeting, loginUser);
meetingCommandService.completeRealtimeMeeting(id, dto != null ? dto.getAudioUrl() : null);
meetingCommandService.completeRealtimeMeeting(
id,
dto != null ? dto.getAudioUrl() : null,
dto != null && Boolean.TRUE.equals(dto.getOverwriteAudio())
);
return ApiResponse.ok(true);
}

View File

@ -2,11 +2,20 @@ package com.imeeting.dto.android;
import lombok.Data;
import java.util.Set;
@Data
public class AndroidAuthContext {
private String authMode;
private String deviceId;
private Long tenantId;
private String tenantCode;
private Long userId;
private String username;
private String displayName;
private Boolean platformAdmin;
private Boolean tenantAdmin;
private Set<String> permissions;
private String appId;
private String appVersion;
private String platform;

View File

@ -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;
}

View File

@ -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;
}

View File

@ -10,23 +10,30 @@ import java.util.List;
@Data
public class CreateMeetingCommand {
@NotBlank(message = "会议标题不能为空")
@NotBlank(message = "title must not be blank")
private String title;
@NotNull(message = "会议时间不能为空")
@NotNull(message = "meetingTime must not be null")
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
private LocalDateTime meetingTime;
private String participants;
private String tags;
@NotBlank(message = "音频地址不能为空")
private Long hostUserId;
private String hostName;
@NotBlank(message = "audioUrl must not be blank")
private String audioUrl;
@NotNull(message = "识别模型不能为空")
@NotNull(message = "asrModelId must not be null")
private Long asrModelId;
@NotNull(message = "总结模型不能为空")
@NotNull(message = "summaryModelId must not be null")
private Long summaryModelId;
@NotNull(message = "提示词模板不能为空")
@NotNull(message = "promptId must not be null")
private Long promptId;
private Integer useSpkId;
private Boolean enableTextRefine;
private List<String> hotWords;

View File

@ -10,21 +10,27 @@ import java.util.List;
@Data
public class CreateRealtimeMeetingCommand {
@NotBlank(message = "会议标题不能为空")
@NotBlank(message = "title must not be blank")
private String title;
@NotNull(message = "会议时间不能为空")
@NotNull(message = "meetingTime must not be null")
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
private LocalDateTime meetingTime;
private String participants;
private String tags;
@NotNull(message = "识别模型不能为空")
private Long hostUserId;
private String hostName;
@NotNull(message = "asrModelId must not be null")
private Long asrModelId;
@NotNull(message = "总结模型不能为空")
@NotNull(message = "summaryModelId must not be null")
private Long summaryModelId;
@NotNull(message = "提示词模板不能为空")
@NotNull(message = "promptId must not be null")
private Long promptId;
private String mode;
private String language;
private Integer useSpkId;

View File

@ -12,6 +12,8 @@ public class MeetingVO {
private Long tenantId;
private Long creatorId;
private String creatorName;
private Long hostUserId;
private String hostName;
private String title;
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
@ -21,6 +23,8 @@ public class MeetingVO {
private List<Long> participantIds;
private String tags;
private String audioUrl;
private String audioSaveStatus;
private String audioSaveMessage;
private Integer duration;
private String summaryContent;
private Map<String, Object> analysis;

View File

@ -4,5 +4,6 @@ import lombok.Data;
@Data
public class RealtimeMeetingCompleteDTO {
private Boolean overwriteAudio;
private String audioUrl;
}

View File

@ -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;
}

View File

@ -27,10 +27,18 @@ public class Meeting extends BaseEntity {
private String audioUrl;
private String audioSaveStatus;
private String audioSaveMessage;
private Long creatorId;
private String creatorName;
private Long hostUserId;
private String hostName;
private Long latestSummaryTaskId;
@TableField(exist = false)

View File

@ -50,7 +50,12 @@ public class RealtimeMeetingGrpcService extends RealtimeMeetingServiceGrpc.Realt
private void handleOpen(RealtimeClientPacket packet) {
authContext = androidAuthService.authenticateGrpc(packet.getAuth(), null);
connectionId = realtimeMeetingGrpcSessionService.openStream(packet.getOpen().getStreamToken(), authContext, responseObserver);
connectionId = realtimeMeetingGrpcSessionService.openStream(
packet.getOpen().getMeetingId(),
packet.getOpen().getStreamToken(),
authContext,
responseObserver
);
}
private void handleAudio(AudioChunk audioChunk) {

View File

@ -44,7 +44,7 @@ public class RealtimeMeetingSessionExpirationListener extends KeyExpirationEvent
if (expiredKey.startsWith(RedisKeys.realtimeMeetingResumeTimeoutPrefix())) {
Long meetingId = parseMeetingId(expiredKey, RedisKeys.realtimeMeetingResumeTimeoutPrefix());
if (meetingId != null && realtimeMeetingSessionStateService.markCompletingIfResumeExpired(meetingId)) {
meetingCommandService.completeRealtimeMeeting(meetingId, null);
meetingCommandService.completeRealtimeMeeting(meetingId, null, false);
}
return;
}

View File

@ -4,8 +4,13 @@ import com.imeeting.config.grpc.AndroidGrpcAuthProperties;
import com.imeeting.dto.android.AndroidAuthContext;
import com.imeeting.grpc.common.ClientAuth;
import com.imeeting.service.android.AndroidAuthService;
import com.unisbase.dto.InternalAuthCheckResponse;
import com.unisbase.security.LoginUser;
import com.unisbase.service.TokenValidationService;
import jakarta.servlet.http.HttpServletRequest;
import lombok.RequiredArgsConstructor;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.stereotype.Service;
import org.springframework.util.StringUtils;
@ -19,15 +24,23 @@ public class AndroidAuthServiceImpl implements AndroidAuthService {
private static final String HEADER_APP_ID = "X-Android-App-Id";
private static final String HEADER_APP_VERSION = "X-Android-App-Version";
private static final String HEADER_PLATFORM = "X-Android-Platform";
private static final String HEADER_AUTHORIZATION = "Authorization";
private static final String BEARER_PREFIX = "Bearer ";
private final AndroidGrpcAuthProperties properties;
private final TokenValidationService tokenValidationService;
@Override
public AndroidAuthContext authenticateGrpc(ClientAuth auth, String fallbackDeviceId) {
ClientAuth.AuthType authType = auth == null ? ClientAuth.AuthType.AUTH_TYPE_UNSPECIFIED : auth.getAuthType();
if (authType == ClientAuth.AuthType.DEVICE_TOKEN || authType == ClientAuth.AuthType.USER_JWT) {
return buildContext(authType.name(), false, auth.getDeviceId(), auth.getTenantCode(), auth.getAppId(),
auth.getAppVersion(), auth.getPlatform(), auth.getAccessToken(), fallbackDeviceId);
if (authType == ClientAuth.AuthType.USER_JWT) {
InternalAuthCheckResponse authResult = validateToken(auth == null ? null : auth.getAccessToken());
return buildContext(authType.name(), false, auth == null ? null : auth.getDeviceId(), auth == null ? null : auth.getTenantCode(), auth == null ? null : auth.getAppId(),
auth == null ? null : auth.getAppVersion(), auth == null ? null : auth.getPlatform(), auth == null ? null : auth.getAccessToken(), fallbackDeviceId, authResult, null);
}
if (authType == ClientAuth.AuthType.DEVICE_TOKEN) {
return buildContext(authType.name(), false, auth == null ? null : auth.getDeviceId(), auth == null ? null : auth.getTenantCode(), auth == null ? null : auth.getAppId(),
auth == null ? null : auth.getAppVersion(), auth == null ? null : auth.getPlatform(), auth == null ? null : auth.getAccessToken(), fallbackDeviceId, null, null);
}
if (properties.isEnabled() && !properties.isAllowAnonymous()) {
throw new RuntimeException("Android gRPC auth is required");
@ -39,25 +52,46 @@ public class AndroidAuthServiceImpl implements AndroidAuthService {
auth == null ? null : auth.getAppVersion(),
auth == null ? null : auth.getPlatform(),
auth == null ? null : auth.getAccessToken(),
fallbackDeviceId);
fallbackDeviceId,
null,
null);
}
@Override
public AndroidAuthContext authenticateHttp(HttpServletRequest request) {
if (properties.isEnabled() && !properties.isAllowAnonymous()) {
String token = request.getHeader(HEADER_ACCESS_TOKEN);
if (!StringUtils.hasText(token)) {
throw new RuntimeException("Android HTTP auth is required");
}
return buildContext("DEVICE_TOKEN", false,
LoginUser loginUser = currentLoginUser();
String resolvedToken = resolveHttpToken(request);
if (loginUser != null) {
return buildContext("USER_JWT", false,
request.getHeader(HEADER_DEVICE_ID),
request.getHeader(HEADER_TENANT_CODE),
request.getHeader(HEADER_APP_ID),
request.getHeader(HEADER_APP_VERSION),
request.getHeader(HEADER_PLATFORM),
token,
resolvedToken,
null,
null,
loginUser);
}
if (StringUtils.hasText(resolvedToken)) {
InternalAuthCheckResponse authResult = validateToken(resolvedToken);
return buildContext("USER_JWT", false,
request.getHeader(HEADER_DEVICE_ID),
request.getHeader(HEADER_TENANT_CODE),
request.getHeader(HEADER_APP_ID),
request.getHeader(HEADER_APP_VERSION),
request.getHeader(HEADER_PLATFORM),
resolvedToken,
null,
authResult,
null);
}
if (properties.isEnabled() && !properties.isAllowAnonymous()) {
throw new RuntimeException("Android HTTP auth is required");
}
return buildContext("NONE", true,
request.getHeader(HEADER_DEVICE_ID),
request.getHeader(HEADER_TENANT_CODE),
@ -65,12 +99,14 @@ public class AndroidAuthServiceImpl implements AndroidAuthService {
request.getHeader(HEADER_APP_VERSION),
request.getHeader(HEADER_PLATFORM),
request.getHeader(HEADER_ACCESS_TOKEN),
null,
null,
null);
}
private AndroidAuthContext buildContext(String authMode, boolean anonymous, String deviceId, String tenantCode,
String appId, String appVersion, String platform, String accessToken,
String fallbackDeviceId) {
String fallbackDeviceId, InternalAuthCheckResponse authResult, LoginUser loginUser) {
String resolvedDeviceId = StringUtils.hasText(deviceId) ? deviceId : fallbackDeviceId;
if (!StringUtils.hasText(resolvedDeviceId)) {
throw new RuntimeException("Android deviceId is required");
@ -84,6 +120,75 @@ public class AndroidAuthServiceImpl implements AndroidAuthService {
context.setAppVersion(StringUtils.hasText(appVersion) ? appVersion.trim() : null);
context.setPlatform(StringUtils.hasText(platform) ? platform.trim() : "android");
context.setAccessToken(StringUtils.hasText(accessToken) ? accessToken.trim() : null);
applyIdentity(context, authResult, loginUser);
return context;
}
private void applyIdentity(AndroidAuthContext context, InternalAuthCheckResponse authResult, LoginUser loginUser) {
if (loginUser != null) {
context.setUserId(loginUser.getUserId());
context.setTenantId(loginUser.getTenantId());
context.setUsername(loginUser.getUsername());
context.setDisplayName(loginUser.getDisplayName());
context.setPlatformAdmin(Boolean.TRUE.equals(loginUser.getIsPlatformAdmin()));
context.setTenantAdmin(Boolean.TRUE.equals(loginUser.getIsTenantAdmin()));
context.setPermissions(loginUser.getPermissions());
return;
}
if (authResult == null) {
return;
}
context.setUserId(authResult.getUserId());
context.setTenantId(authResult.getTenantId());
context.setUsername(authResult.getUsername());
context.setDisplayName(authResult.getUsername());
context.setPlatformAdmin(Boolean.TRUE.equals(authResult.getPlatformAdmin()));
context.setTenantAdmin(Boolean.TRUE.equals(authResult.getTenantAdmin()));
context.setPermissions(authResult.getPermissions());
}
private InternalAuthCheckResponse validateToken(String token) {
String resolvedToken = normalizeToken(token);
if (!StringUtils.hasText(resolvedToken)) {
throw new RuntimeException("Android access token is required");
}
InternalAuthCheckResponse authResult = tokenValidationService.validateAccessToken(resolvedToken);
if (authResult == null || !authResult.isValid()) {
throw new RuntimeException(authResult == null || !StringUtils.hasText(authResult.getMessage()) ? "Android access token is invalid" : authResult.getMessage());
}
if (authResult.getUserId() == null || authResult.getTenantId() == null) {
throw new RuntimeException("Android access token missing user or tenant context");
}
return authResult;
}
private String resolveHttpToken(HttpServletRequest request) {
String authorization = request.getHeader(HEADER_AUTHORIZATION);
if (StringUtils.hasText(authorization) && authorization.startsWith(BEARER_PREFIX)) {
return authorization.substring(BEARER_PREFIX.length()).trim();
}
return normalizeToken(request.getHeader(HEADER_ACCESS_TOKEN));
}
private String normalizeToken(String token) {
if (!StringUtils.hasText(token)) {
return null;
}
String resolved = token.trim();
if (resolved.startsWith(BEARER_PREFIX)) {
resolved = resolved.substring(BEARER_PREFIX.length()).trim();
}
return resolved;
}
private LoginUser currentLoginUser() {
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
if (authentication == null || !(authentication.getPrincipal() instanceof LoginUser loginUser)) {
return null;
}
if (loginUser.getUserId() == null || loginUser.getTenantId() == null) {
return null;
}
return loginUser;
}
}

View File

@ -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);
}

View File

@ -18,7 +18,9 @@ public interface MeetingCommandService {
void appendRealtimeTranscripts(Long meetingId, List<RealtimeTranscriptItemDTO> items);
void completeRealtimeMeeting(Long meetingId, String audioUrl);
void saveRealtimeTranscriptSnapshot(Long meetingId, RealtimeTranscriptItemDTO item, boolean finalResult);
void completeRealtimeMeeting(Long meetingId, String audioUrl, boolean overwriteAudio);
void updateSpeakerInfo(Long meetingId, String speakerId, String newName, String label);

View File

@ -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);
}

View File

@ -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;
}
}

View File

@ -8,6 +8,7 @@ import com.imeeting.dto.biz.CreateMeetingCommand;
import com.imeeting.dto.biz.CreateRealtimeMeetingCommand;
import com.imeeting.dto.biz.MeetingVO;
import com.imeeting.dto.biz.RealtimeMeetingResumeConfig;
import com.imeeting.dto.biz.RealtimeMeetingSessionStatusVO;
import com.imeeting.dto.biz.RealtimeTranscriptItemDTO;
import com.imeeting.dto.biz.UpdateMeetingBasicCommand;
import com.imeeting.dto.biz.UpdateMeetingTranscriptCommand;
@ -21,10 +22,13 @@ import com.imeeting.service.biz.MeetingCommandService;
import com.imeeting.service.biz.MeetingService;
import com.imeeting.service.biz.MeetingSummaryFileService;
import com.imeeting.service.biz.RealtimeMeetingSessionStateService;
import com.imeeting.service.realtime.RealtimeMeetingAudioStorageService;
import lombok.RequiredArgsConstructor;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.transaction.support.TransactionSynchronization;
import org.springframework.transaction.support.TransactionSynchronizationManager;
import java.math.BigDecimal;
import java.math.RoundingMode;
@ -46,14 +50,17 @@ public class MeetingCommandServiceImpl implements MeetingCommandService {
private final MeetingSummaryFileService meetingSummaryFileService;
private final MeetingDomainSupport meetingDomainSupport;
private final RealtimeMeetingSessionStateService realtimeMeetingSessionStateService;
private final RealtimeMeetingAudioStorageService realtimeMeetingAudioStorageService;
private final StringRedisTemplate redisTemplate;
private final ObjectMapper objectMapper;
@Override
@Transactional(rollbackFor = Exception.class)
public MeetingVO createMeeting(CreateMeetingCommand command, Long tenantId, Long creatorId, String creatorName) {
Long hostUserId = resolveHostUserId(command.getHostUserId(), creatorId);
String hostName = resolveHostName(command.getHostName(), creatorName, creatorId, hostUserId);
Meeting meeting = meetingDomainSupport.initMeeting(command.getTitle(), command.getMeetingTime(), command.getParticipants(), command.getTags(),
command.getAudioUrl(), tenantId, creatorId, creatorName, 0);
command.getAudioUrl(), tenantId, creatorId, creatorName, hostUserId, hostName, 0);
meetingService.save(meeting);
AiTask asrTask = new AiTask();
@ -92,8 +99,10 @@ public class MeetingCommandServiceImpl implements MeetingCommandService {
@Override
@Transactional(rollbackFor = Exception.class)
public MeetingVO createRealtimeMeeting(CreateRealtimeMeetingCommand command, Long tenantId, Long creatorId, String creatorName) {
Long hostUserId = resolveHostUserId(command.getHostUserId(), creatorId);
String hostName = resolveHostName(command.getHostName(), creatorName, creatorId, hostUserId);
Meeting meeting = meetingDomainSupport.initMeeting(command.getTitle(), command.getMeetingTime(), command.getParticipants(), command.getTags(),
null, tenantId, creatorId, creatorName, 0);
null, tenantId, creatorId, creatorName, hostUserId, hostName, 0);
meetingService.save(meeting);
meetingDomainSupport.createSummaryTask(meeting.getId(), command.getSummaryModelId(), command.getPromptId());
realtimeMeetingSessionStateService.initSessionIfAbsent(meeting.getId(), tenantId, creatorId);
@ -164,14 +173,79 @@ public class MeetingCommandServiceImpl implements MeetingCommandService {
@Override
@Transactional(rollbackFor = Exception.class)
public void completeRealtimeMeeting(Long meetingId, String audioUrl) {
public void saveRealtimeTranscriptSnapshot(Long meetingId, RealtimeTranscriptItemDTO item, boolean finalResult) {
if (item == null || item.getContent() == null || item.getContent().isBlank()) {
return;
}
String speakerId = meetingDomainSupport.resolveSpeakerId(item.getSpeakerId());
String speakerName = meetingDomainSupport.resolveSpeakerName(item.getSpeakerId(), item.getSpeakerName());
String content = item.getContent().trim();
MeetingTranscript latest = transcriptMapper.selectOne(new LambdaQueryWrapper<MeetingTranscript>()
.eq(MeetingTranscript::getMeetingId, meetingId)
.orderByDesc(MeetingTranscript::getSortOrder)
.last("LIMIT 1"));
if (isSameRealtimeSegment(latest, speakerId, item.getStartTime(), content)) {
transcriptMapper.update(null, new LambdaUpdateWrapper<MeetingTranscript>()
.eq(MeetingTranscript::getId, latest.getId())
.set(MeetingTranscript::getSpeakerId, speakerId)
.set(MeetingTranscript::getSpeakerName, speakerName)
.set(MeetingTranscript::getContent, content)
.set(item.getStartTime() != null, MeetingTranscript::getStartTime, item.getStartTime())
.set(item.getEndTime() != null, MeetingTranscript::getEndTime, item.getEndTime()));
realtimeMeetingSessionStateService.refreshAfterTranscript(meetingId);
return;
}
Integer maxSortOrder = transcriptMapper.selectList(new LambdaQueryWrapper<MeetingTranscript>()
.eq(MeetingTranscript::getMeetingId, meetingId)
.orderByDesc(MeetingTranscript::getSortOrder)
.last("LIMIT 1"))
.stream()
.findFirst()
.map(MeetingTranscript::getSortOrder)
.orElse(0);
MeetingTranscript transcript = new MeetingTranscript();
transcript.setMeetingId(meetingId);
transcript.setSpeakerId(speakerId);
transcript.setSpeakerName(speakerName);
transcript.setContent(content);
transcript.setStartTime(item.getStartTime());
transcript.setEndTime(item.getEndTime());
transcript.setSortOrder(maxSortOrder == null ? 0 : maxSortOrder + 1);
transcriptMapper.insert(transcript);
realtimeMeetingSessionStateService.refreshAfterTranscript(meetingId);
}
@Override
@Transactional(rollbackFor = Exception.class)
public void completeRealtimeMeeting(Long meetingId, String audioUrl, boolean overwriteAudio) {
Meeting meeting = meetingService.getById(meetingId);
if (meeting == null) {
throw new RuntimeException("Meeting not found");
}
RealtimeMeetingSessionStatusVO currentStatus = realtimeMeetingSessionStateService.getStatus(meetingId);
if (overwriteAudio) {
if (audioUrl == null || audioUrl.isBlank()) {
throw new RuntimeException("Audio URL is required when overwriteAudio is true");
}
meeting.setAudioUrl(meetingDomainSupport.relocateAudioUrl(meetingId, audioUrl));
markAudioSaveSuccess(meeting);
meetingService.updateById(meeting);
prepareOfflineReprocessTasks(meetingId, currentStatus);
realtimeMeetingSessionStateService.clear(meetingId);
updateMeetingProgress(meetingId, 0, "正在转入离线音频识别流程...", 0);
aiTaskService.dispatchTasks(meetingId);
return;
}
if (audioUrl != null && !audioUrl.isBlank()) {
meeting.setAudioUrl(meetingDomainSupport.relocateAudioUrl(meetingId, audioUrl));
markAudioSaveSuccess(meeting);
meetingService.updateById(meeting);
}
@ -182,6 +256,12 @@ public class MeetingCommandServiceImpl implements MeetingCommandService {
throw new RuntimeException("当前还没有转录内容,无法结束会议。请先开始识别,或直接离开页面稍后继续。");
}
if ((audioUrl == null || audioUrl.isBlank()) && (meeting.getAudioUrl() == null || meeting.getAudioUrl().isBlank())) {
applyRealtimeAudioFinalizeResult(meeting, realtimeMeetingAudioStorageService.finalizeMeetingAudio(meetingId));
} else if (meeting.getAudioUrl() != null && !meeting.getAudioUrl().isBlank()) {
markAudioSaveSuccess(meeting);
}
realtimeMeetingSessionStateService.clear(meetingId);
meeting.setStatus(2);
meetingService.updateById(meeting);
@ -189,6 +269,121 @@ public class MeetingCommandServiceImpl implements MeetingCommandService {
aiTaskService.dispatchSummaryTask(meetingId);
}
private void applyRealtimeAudioFinalizeResult(Meeting meeting, RealtimeMeetingAudioStorageService.FinalizeResult result) {
if (result == null) {
markAudioSaveFailure(meeting, RealtimeMeetingAudioStorageService.DEFAULT_FAILURE_MESSAGE);
return;
}
if (result.audioUrl() != null && !result.audioUrl().isBlank()) {
meeting.setAudioUrl(result.audioUrl());
}
if (result.failed()) {
markAudioSaveFailure(meeting, result.message());
} else if (result.success()) {
markAudioSaveSuccess(meeting);
}
}
private void markAudioSaveSuccess(Meeting meeting) {
meeting.setAudioSaveStatus(RealtimeMeetingAudioStorageService.STATUS_SUCCESS);
meeting.setAudioSaveMessage(null);
}
private void markAudioSaveFailure(Meeting meeting, String message) {
meeting.setAudioSaveStatus(RealtimeMeetingAudioStorageService.STATUS_FAILED);
meeting.setAudioSaveMessage(message == null || message.isBlank()
? RealtimeMeetingAudioStorageService.DEFAULT_FAILURE_MESSAGE
: message);
}
private void prepareOfflineReprocessTasks(Long meetingId, RealtimeMeetingSessionStatusVO currentStatus) {
RealtimeMeetingResumeConfig resumeConfig = currentStatus == null ? null : currentStatus.getResumeConfig();
if (resumeConfig == null || resumeConfig.getAsrModelId() == null) {
throw new RuntimeException("Realtime resume config missing, cannot overwrite audio");
}
AiTask asrTask = aiTaskService.getOne(new LambdaQueryWrapper<AiTask>()
.eq(AiTask::getMeetingId, meetingId)
.eq(AiTask::getTaskType, "ASR")
.orderByDesc(AiTask::getId)
.last("LIMIT 1"));
Map<String, Object> asrConfig = new HashMap<>();
asrConfig.put("asrModelId", resumeConfig.getAsrModelId());
asrConfig.put("useSpkId", resumeConfig.getUseSpkId() != null ? resumeConfig.getUseSpkId() : 1);
asrConfig.put("enableTextRefine", Boolean.TRUE.equals(resumeConfig.getEnableTextRefine()));
asrConfig.put("hotWords", extractOfflineHotwords(resumeConfig.getHotwords()));
if (asrTask == null) {
asrTask = new AiTask();
asrTask.setMeetingId(meetingId);
asrTask.setTaskType("ASR");
asrTask.setStatus(0);
asrTask.setTaskConfig(asrConfig);
aiTaskService.save(asrTask);
} else {
resetAiTask(asrTask, asrConfig);
aiTaskService.updateById(asrTask);
}
AiTask summaryTask = aiTaskService.getOne(new LambdaQueryWrapper<AiTask>()
.eq(AiTask::getMeetingId, meetingId)
.eq(AiTask::getTaskType, "SUMMARY")
.orderByDesc(AiTask::getId)
.last("LIMIT 1"));
if (summaryTask == null) {
throw new RuntimeException("Summary task missing, cannot continue offline process");
}
resetAiTask(summaryTask, summaryTask.getTaskConfig());
aiTaskService.updateById(summaryTask);
}
private void resetAiTask(AiTask task, Map<String, Object> taskConfig) {
task.setStatus(0);
task.setTaskConfig(taskConfig);
task.setRequestData(null);
task.setResponseData(null);
task.setResultFilePath(null);
task.setErrorMsg(null);
task.setStartedAt(null);
task.setCompletedAt(null);
}
private List<String> extractOfflineHotwords(List<Map<String, Object>> hotwords) {
if (hotwords == null || hotwords.isEmpty()) {
return List.of();
}
return hotwords.stream()
.map(item -> item == null ? null : item.get("hotword"))
.filter(Objects::nonNull)
.map(String::valueOf)
.map(String::trim)
.filter(word -> !word.isEmpty())
.distinct()
.toList();
}
private boolean isSameRealtimeSegment(MeetingTranscript latest, String speakerId, Integer startTime, String content) {
if (latest == null) {
return false;
}
if (!Objects.equals(latest.getSpeakerId(), speakerId)) {
return false;
}
if (startTime != null && latest.getStartTime() != null) {
if (Math.abs(latest.getStartTime() - startTime) <= 1500) {
return true;
}
}
String latestContent = latest.getContent();
if (latestContent == null || latestContent.isBlank()) {
return false;
}
return content.startsWith(latestContent) || latestContent.startsWith(content);
}
@Override
@Transactional(rollbackFor = Exception.class)
public void updateSpeakerInfo(Long meetingId, String speakerId, String newName, String label) {
@ -255,7 +450,20 @@ public class MeetingCommandServiceImpl implements MeetingCommandService {
meetingDomainSupport.createSummaryTask(meetingId, summaryModelId, promptId);
meeting.setStatus(2);
meetingService.updateById(meeting);
aiTaskService.dispatchSummaryTask(meetingId);
dispatchSummaryTaskAfterCommit(meetingId);
}
private void dispatchSummaryTaskAfterCommit(Long meetingId) {
if (!TransactionSynchronizationManager.isSynchronizationActive()) {
aiTaskService.dispatchSummaryTask(meetingId);
return;
}
TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() {
@Override
public void afterCommit() {
aiTaskService.dispatchSummaryTask(meetingId);
}
});
}
private void updateMeetingProgress(Long meetingId, int percent, String message, int eta) {
@ -319,4 +527,18 @@ public class MeetingCommandServiceImpl implements MeetingCommandService {
item.put("weight", BigDecimal.valueOf(rawWeight).divide(BigDecimal.TEN, 2, RoundingMode.HALF_UP).doubleValue());
return item;
}
private Long resolveHostUserId(Long requestedHostUserId, Long creatorId) {
return requestedHostUserId != null ? requestedHostUserId : creatorId;
}
private String resolveHostName(String requestedHostName, String creatorName, Long creatorId, Long hostUserId) {
if (requestedHostName != null && !requestedHostName.isBlank()) {
return requestedHostName.trim();
}
if (hostUserId != null && Objects.equals(hostUserId, creatorId)) {
return creatorName;
}
return null;
}
}

View File

@ -10,6 +10,7 @@ import com.imeeting.mapper.biz.MeetingTranscriptMapper;
import com.imeeting.service.biz.AiTaskService;
import com.imeeting.service.biz.MeetingSummaryFileService;
import com.imeeting.service.biz.PromptTemplateService;
import com.imeeting.service.realtime.RealtimeMeetingAudioStorageService;
import com.unisbase.entity.SysUser;
import com.unisbase.mapper.SysUserMapper;
import lombok.RequiredArgsConstructor;
@ -49,7 +50,8 @@ public class MeetingDomainSupport {
private String uploadPath;
public Meeting initMeeting(String title, LocalDateTime meetingTime, String participants, String tags,
String audioUrl, Long tenantId, Long creatorId, String creatorName, int status) {
String audioUrl, Long tenantId, Long creatorId, String creatorName,
Long hostUserId, String hostName, int status) {
Meeting meeting = new Meeting();
meeting.setTitle(title);
meeting.setMeetingTime(meetingTime);
@ -57,8 +59,11 @@ public class MeetingDomainSupport {
meeting.setTags(tags);
meeting.setCreatorId(creatorId);
meeting.setCreatorName(creatorName);
meeting.setHostUserId(hostUserId);
meeting.setHostName(hostName);
meeting.setTenantId(tenantId != null ? tenantId : 0L);
meeting.setAudioUrl(audioUrl);
meeting.setAudioSaveStatus(RealtimeMeetingAudioStorageService.STATUS_NONE);
meeting.setStatus(status);
return meeting;
}
@ -227,10 +232,14 @@ public class MeetingDomainSupport {
vo.setTenantId(meeting.getTenantId());
vo.setCreatorId(meeting.getCreatorId());
vo.setCreatorName(meeting.getCreatorName());
vo.setHostUserId(meeting.getHostUserId());
vo.setHostName(meeting.getHostName());
vo.setTitle(meeting.getTitle());
vo.setMeetingTime(meeting.getMeetingTime());
vo.setTags(meeting.getTags());
vo.setAudioUrl(meeting.getAudioUrl());
vo.setAudioSaveStatus(meeting.getAudioSaveStatus());
vo.setAudioSaveMessage(meeting.getAudioSaveMessage());
vo.setDuration(resolveMeetingDuration(meeting.getId()));
vo.setStatus(meeting.getStatus());
vo.setCreatedAt(meeting.getCreatedAt());

View File

@ -95,6 +95,9 @@ public class MeetingExportServiceImpl implements MeetingExportService {
XWPFParagraph timeP = document.createParagraph();
timeP.createRun().setText("Meeting Time: " + String.valueOf(meeting.getMeetingTime()));
XWPFParagraph hostP = document.createParagraph();
hostP.createRun().setText("Host: " + (meeting.getHostName() == null ? "" : meeting.getHostName()));
XWPFParagraph participantsP = document.createParagraph();
participantsP.createRun().setText("Participants: " + (meeting.getParticipants() == null ? "" : meeting.getParticipants()));
@ -130,6 +133,7 @@ public class MeetingExportServiceImpl implements MeetingExportService {
String title = meeting.getTitle() == null ? "Meeting" : meeting.getTitle();
String time = meeting.getMeetingTime() == null ? "" : meeting.getMeetingTime().toString();
String host = meeting.getHostName() == null ? "" : meeting.getHostName();
String participants = meeting.getParticipants() == null ? "" : meeting.getParticipants();
String html = "<html><head><style>" +
@ -146,6 +150,8 @@ public class MeetingExportServiceImpl implements MeetingExportService {
"<div style='font-size:14px; color:#666;'>" +
"<span>Meeting Time: " + time + "</span>" +
"<span style='margin: 0 20px;'>|</span>" +
"<span>Host: " + host + "</span>" +
"<span style='margin: 0 20px;'>|</span>" +
"<span>Participants: " + participants + "</span>" +
"</div></div>" +
"<div class='markdown-body'>" + htmlBody + "</div>" +

View File

@ -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;
}
}

View File

@ -8,5 +8,7 @@ import com.imeeting.dto.android.AndroidRealtimeGrpcSessionVO;
public interface AndroidRealtimeSessionTicketService {
AndroidRealtimeGrpcSessionVO createSession(Long meetingId, AndroidOpenRealtimeGrpcSessionCommand command, AndroidAuthContext authContext);
AndroidRealtimeGrpcSessionData prepareSessionData(Long meetingId, AndroidAuthContext authContext);
AndroidRealtimeGrpcSessionData getSessionData(String streamToken);
}

View File

@ -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);
}
}
}

View File

@ -5,7 +5,7 @@ import io.grpc.stub.StreamObserver;
import com.imeeting.grpc.realtime.RealtimeServerPacket;
public interface RealtimeMeetingGrpcSessionService {
String openStream(String streamToken, AndroidAuthContext authContext, StreamObserver<RealtimeServerPacket> responseObserver);
String openStream(Long meetingId, String streamToken, AndroidAuthContext authContext, StreamObserver<RealtimeServerPacket> responseObserver);
void onAudio(String connectionId, byte[] payload, long seq, boolean lastChunk);

View File

@ -13,6 +13,7 @@ import com.imeeting.dto.biz.RealtimeMeetingSessionStatusVO;
import com.imeeting.entity.biz.Meeting;
import com.imeeting.service.biz.AiModelService;
import com.imeeting.service.biz.MeetingAccessService;
import com.imeeting.service.biz.MeetingAuthorizationService;
import com.imeeting.service.biz.RealtimeMeetingSessionStateService;
import com.imeeting.service.realtime.AndroidRealtimeSessionTicketService;
import lombok.RequiredArgsConstructor;
@ -32,60 +33,21 @@ public class AndroidRealtimeSessionTicketServiceImpl implements AndroidRealtimeS
private final ObjectMapper objectMapper;
private final StringRedisTemplate redisTemplate;
private final MeetingAccessService meetingAccessService;
private final MeetingAuthorizationService meetingAuthorizationService;
private final AiModelService aiModelService;
private final RealtimeMeetingSessionStateService realtimeMeetingSessionStateService;
private final GrpcServerProperties grpcServerProperties;
@Override
public AndroidRealtimeGrpcSessionVO createSession(Long meetingId, AndroidOpenRealtimeGrpcSessionCommand command, AndroidAuthContext authContext) {
if (meetingId == null) {
throw new RuntimeException("Meeting ID is required");
}
Meeting meeting = meetingAccessService.requireMeeting(meetingId);
realtimeMeetingSessionStateService.initSessionIfAbsent(meetingId, meeting.getTenantId(), meeting.getCreatorId());
RealtimeMeetingSessionStatusVO currentStatus = realtimeMeetingSessionStateService.getStatus(meetingId);
RealtimeMeetingResumeConfig currentResumeConfig = currentStatus == null ? null : currentStatus.getResumeConfig();
Long asrModelId = firstNonNull(command == null ? null : command.getAsrModelId(), currentResumeConfig == null ? null : currentResumeConfig.getAsrModelId());
if (asrModelId == null) {
throw new RuntimeException("ASR model ID is required");
}
realtimeMeetingSessionStateService.assertCanOpenSession(meetingId);
AiModelVO asrModel = aiModelService.getModelById(asrModelId, "ASR");
if (asrModel == null) {
throw new RuntimeException("ASR model not found");
}
String targetWsUrl = resolveWsUrl(asrModel);
if (targetWsUrl == null || targetWsUrl.isBlank()) {
throw new RuntimeException("ASR model WebSocket is not configured");
}
RealtimeMeetingResumeConfig resumeConfig = buildResumeConfig(command, currentResumeConfig, asrModelId);
realtimeMeetingSessionStateService.rememberResumeConfig(meetingId, resumeConfig);
currentStatus = realtimeMeetingSessionStateService.getStatus(meetingId);
Map<String, Object> startMessage = buildStartMessage(asrModel, meetingId, resumeConfig);
AndroidRealtimeGrpcSessionData sessionData = new AndroidRealtimeGrpcSessionData();
sessionData.setMeetingId(meetingId);
sessionData.setTenantId(meeting.getTenantId());
sessionData.setUserId(meeting.getCreatorId());
sessionData.setDeviceId(authContext.getDeviceId());
sessionData.setAsrModelId(asrModelId);
sessionData.setTargetWsUrl(targetWsUrl);
sessionData.setResumeConfig(resumeConfig);
try {
sessionData.setStartMessageJson(objectMapper.writeValueAsString(startMessage));
} catch (Exception ex) {
throw new RuntimeException("Failed to serialize realtime start message", ex);
}
PreparedRealtimeSession prepared = prepareSession(meetingId, command, authContext);
String streamToken = UUID.randomUUID().toString().replace("-", "");
Duration ttl = Duration.ofSeconds(grpcServerProperties.getRealtime().getSessionTtlSeconds());
try {
redisTemplate.opsForValue().set(
RedisKeys.realtimeMeetingGrpcSessionKey(streamToken),
objectMapper.writeValueAsString(sessionData),
objectMapper.writeValueAsString(prepared.sessionData()),
ttl
);
} catch (Exception ex) {
@ -99,11 +61,16 @@ public class AndroidRealtimeSessionTicketServiceImpl implements AndroidRealtimeS
vo.setSampleRate(grpcServerProperties.getRealtime().getSampleRate());
vo.setChannels(grpcServerProperties.getRealtime().getChannels());
vo.setEncoding(grpcServerProperties.getRealtime().getEncoding());
vo.setResumeConfig(resumeConfig);
vo.setStatus(currentStatus);
vo.setResumeConfig(prepared.resumeConfig());
vo.setStatus(prepared.status());
return vo;
}
@Override
public AndroidRealtimeGrpcSessionData prepareSessionData(Long meetingId, AndroidAuthContext authContext) {
return prepareSession(meetingId, null, authContext).sessionData();
}
@Override
public AndroidRealtimeGrpcSessionData getSessionData(String streamToken) {
if (streamToken == null || streamToken.isBlank()) {
@ -120,6 +87,61 @@ public class AndroidRealtimeSessionTicketServiceImpl implements AndroidRealtimeS
}
}
private PreparedRealtimeSession prepareSession(Long meetingId, AndroidOpenRealtimeGrpcSessionCommand command, AndroidAuthContext authContext) {
if (meetingId == null) {
throw new RuntimeException("Meeting ID is required");
}
Meeting meeting = meetingAccessService.requireMeeting(meetingId);
meetingAuthorizationService.assertCanManageRealtimeMeeting(meeting, authContext);
realtimeMeetingSessionStateService.initSessionIfAbsent(meetingId, meeting.getTenantId(), meeting.getCreatorId());
RealtimeMeetingSessionStatusVO currentStatus = realtimeMeetingSessionStateService.getStatus(meetingId);
RealtimeMeetingResumeConfig currentResumeConfig = currentStatus == null ? null : currentStatus.getResumeConfig();
Long asrModelId = firstNonNull(
command == null ? null : command.getAsrModelId(),
currentResumeConfig == null ? null : currentResumeConfig.getAsrModelId(),
resolveDefaultAsrModelId(meeting.getTenantId())
);
if (asrModelId == null) {
throw new RuntimeException("ASR model ID is required");
}
realtimeMeetingSessionStateService.assertCanOpenSession(meetingId);
AiModelVO asrModel = aiModelService.getModelById(asrModelId, "ASR");
if (asrModel == null) {
throw new RuntimeException("ASR model not found");
}
String targetWsUrl = resolveWsUrl(asrModel);
if (targetWsUrl == null || targetWsUrl.isBlank()) {
throw new RuntimeException("ASR model WebSocket is not configured");
}
RealtimeMeetingResumeConfig resumeConfig = buildResumeConfig(command, currentResumeConfig, asrModelId);
realtimeMeetingSessionStateService.rememberResumeConfig(meetingId, resumeConfig);
RealtimeMeetingSessionStatusVO latestStatus = realtimeMeetingSessionStateService.getStatus(meetingId);
Map<String, Object> startMessage = buildStartMessage(asrModel, meetingId, resumeConfig);
AndroidRealtimeGrpcSessionData sessionData = new AndroidRealtimeGrpcSessionData();
sessionData.setMeetingId(meetingId);
sessionData.setTenantId(authContext != null && authContext.getTenantId() != null ? authContext.getTenantId() : meeting.getTenantId());
sessionData.setUserId(authContext != null && authContext.getUserId() != null ? authContext.getUserId() : meeting.getCreatorId());
sessionData.setDeviceId(authContext == null ? null : authContext.getDeviceId());
sessionData.setAsrModelId(asrModelId);
sessionData.setTargetWsUrl(targetWsUrl);
sessionData.setResumeConfig(resumeConfig);
try {
sessionData.setStartMessageJson(objectMapper.writeValueAsString(startMessage));
} catch (Exception ex) {
throw new RuntimeException("Failed to serialize realtime start message", ex);
}
return new PreparedRealtimeSession(sessionData, resumeConfig, latestStatus);
}
private Long resolveDefaultAsrModelId(Long tenantId) {
AiModelVO defaultModel = aiModelService.getDefaultModel("ASR", tenantId == null ? 0L : tenantId);
return defaultModel == null ? null : defaultModel.getId();
}
private RealtimeMeetingResumeConfig buildResumeConfig(AndroidOpenRealtimeGrpcSessionCommand command,
RealtimeMeetingResumeConfig currentResumeConfig,
Long asrModelId) {
@ -132,7 +154,11 @@ public class AndroidRealtimeSessionTicketServiceImpl implements AndroidRealtimeS
config.setEnableItn(firstNonNull(command == null ? null : command.getEnableItn(), currentResumeConfig == null ? null : currentResumeConfig.getEnableItn(), Boolean.TRUE));
config.setEnableTextRefine(firstNonNull(command == null ? null : command.getEnableTextRefine(), currentResumeConfig == null ? null : currentResumeConfig.getEnableTextRefine(), Boolean.FALSE));
config.setSaveAudio(firstNonNull(command == null ? null : command.getSaveAudio(), currentResumeConfig == null ? null : currentResumeConfig.getSaveAudio(), Boolean.FALSE));
config.setHotwords(command != null && command.getHotwords() != null ? command.getHotwords() : currentResumeConfig == null ? List.of() : currentResumeConfig.getHotwords());
config.setHotwords(command != null && command.getHotwords() != null
? command.getHotwords()
: currentResumeConfig == null || currentResumeConfig.getHotwords() == null
? List.of()
: currentResumeConfig.getHotwords());
return config;
}
@ -216,4 +242,9 @@ public class AndroidRealtimeSessionTicketServiceImpl implements AndroidRealtimeS
}
return defaultValue;
}
private record PreparedRealtimeSession(AndroidRealtimeGrpcSessionData sessionData,
RealtimeMeetingResumeConfig resumeConfig,
RealtimeMeetingSessionStatusVO status) {
}
}

View File

@ -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;
}
}
}

View File

@ -16,6 +16,7 @@ import com.imeeting.service.biz.MeetingCommandService;
import com.imeeting.service.biz.RealtimeMeetingSessionStateService;
import com.imeeting.service.realtime.AndroidRealtimeSessionTicketService;
import com.imeeting.service.realtime.AsrUpstreamBridgeService;
import com.imeeting.service.realtime.RealtimeMeetingAudioStorageService;
import com.imeeting.service.realtime.RealtimeMeetingGrpcSessionService;
import io.grpc.stub.StreamObserver;
import lombok.RequiredArgsConstructor;
@ -38,21 +39,30 @@ public class RealtimeMeetingGrpcSessionServiceImpl implements RealtimeMeetingGrp
private final AsrUpstreamBridgeService asrUpstreamBridgeService;
private final MeetingCommandService meetingCommandService;
private final RealtimeMeetingSessionStateService realtimeMeetingSessionStateService;
private final RealtimeMeetingAudioStorageService realtimeMeetingAudioStorageService;
private final StringRedisTemplate redisTemplate;
private final GrpcServerProperties grpcServerProperties;
private final ConcurrentMap<String, SessionRuntime> sessions = new ConcurrentHashMap<>();
@Override
public String openStream(String streamToken, AndroidAuthContext authContext, StreamObserver<RealtimeServerPacket> responseObserver) {
AndroidRealtimeGrpcSessionData sessionData = ticketService.getSessionData(streamToken);
if (sessionData == null) {
throw new RuntimeException("Invalid realtime gRPC session token");
}
if (sessionData.getDeviceId() != null && !sessionData.getDeviceId().isBlank()
&& authContext.getDeviceId() != null
&& !sessionData.getDeviceId().equals(authContext.getDeviceId())) {
throw new RuntimeException("Realtime gRPC session token does not match deviceId");
public String openStream(Long meetingId, String streamToken, AndroidAuthContext authContext, StreamObserver<RealtimeServerPacket> responseObserver) {
AndroidRealtimeGrpcSessionData sessionData;
if (meetingId != null && meetingId > 0) {
sessionData = ticketService.prepareSessionData(meetingId, authContext);
streamToken = "";
} else if (streamToken != null && !streamToken.isBlank()) {
sessionData = ticketService.getSessionData(streamToken);
if (sessionData == null) {
throw new RuntimeException("Invalid realtime gRPC session token");
}
if (sessionData.getDeviceId() != null && !sessionData.getDeviceId().isBlank()
&& authContext.getDeviceId() != null
&& !sessionData.getDeviceId().equals(authContext.getDeviceId())) {
throw new RuntimeException("Realtime gRPC session token does not match deviceId");
}
} else {
throw new RuntimeException("Meeting ID is required");
}
String connectionId = "grpc_" + java.util.UUID.randomUUID().toString().replace("-", "");
@ -63,6 +73,7 @@ public class RealtimeMeetingGrpcSessionServiceImpl implements RealtimeMeetingGrp
}
writeConnectionState(runtime);
realtimeMeetingAudioStorageService.openSession(sessionData.getMeetingId(), connectionId);
runtime.upstreamSession = asrUpstreamBridgeService.openSession(sessionData, connectionId, new UpstreamCallback(runtime));
return connectionId;
}
@ -74,6 +85,7 @@ public class RealtimeMeetingGrpcSessionServiceImpl implements RealtimeMeetingGrp
return;
}
touchConnectionState(runtime);
realtimeMeetingAudioStorageService.append(connectionId, payload);
runtime.upstreamSession.sendAudio(payload);
if (lastChunk) {
runtime.upstreamSession.sendStopSpeaking();
@ -109,6 +121,7 @@ public class RealtimeMeetingGrpcSessionServiceImpl implements RealtimeMeetingGrp
}
redisTemplate.delete(RedisKeys.realtimeMeetingGrpcConnectionKey(connectionId));
realtimeMeetingAudioStorageService.closeSession(connectionId);
realtimeMeetingSessionStateService.pauseByDisconnect(runtime.sessionData.getMeetingId(), connectionId);
if (notifyClient) {
@ -193,15 +206,13 @@ public class RealtimeMeetingGrpcSessionServiceImpl implements RealtimeMeetingGrp
if (runtime.closed.get() || result == null || result.getText() == null || result.getText().isBlank()) {
return;
}
if (result.isFinalResult()) {
RealtimeTranscriptItemDTO item = new RealtimeTranscriptItemDTO();
item.setSpeakerId(result.getSpeakerId());
item.setSpeakerName(result.getSpeakerName());
item.setContent(result.getText());
item.setStartTime(result.getStartTime());
item.setEndTime(result.getEndTime());
meetingCommandService.appendRealtimeTranscripts(runtime.sessionData.getMeetingId(), List.of(item));
}
RealtimeTranscriptItemDTO item = new RealtimeTranscriptItemDTO();
item.setSpeakerId(result.getSpeakerId());
item.setSpeakerName(result.getSpeakerName());
item.setContent(result.getText());
item.setStartTime(result.getStartTime());
item.setEndTime(result.getEndTime());
meetingCommandService.saveRealtimeTranscriptSnapshot(runtime.sessionData.getMeetingId(), item, result.isFinalResult());
runtime.send(RealtimeServerPacket.newBuilder()
.setTranscript(TranscriptEvent.newBuilder()
.setMeetingId(runtime.sessionData.getMeetingId())

View File

@ -3,6 +3,7 @@ package com.imeeting.websocket;
import com.imeeting.dto.biz.RealtimeSocketSessionData;
import com.imeeting.service.biz.RealtimeMeetingSessionStateService;
import com.imeeting.service.biz.RealtimeMeetingSocketSessionService;
import com.imeeting.service.realtime.RealtimeMeetingAudioStorageService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
@ -50,6 +51,7 @@ public class RealtimeMeetingProxyWebSocketHandler extends AbstractWebSocketHandl
private final RealtimeMeetingSocketSessionService realtimeMeetingSocketSessionService;
private final RealtimeMeetingSessionStateService realtimeMeetingSessionStateService;
private final RealtimeMeetingAudioStorageService realtimeMeetingAudioStorageService;
@Override
public void afterConnectionEstablished(WebSocketSession session) throws Exception {
@ -71,6 +73,7 @@ public class RealtimeMeetingProxyWebSocketHandler extends AbstractWebSocketHandl
session.getAttributes().put(ATTR_UPSTREAM_SEND_CHAIN, COMPLETED);
session.getAttributes().put(ATTR_START_MESSAGE_SENT, Boolean.FALSE);
session.getAttributes().put(ATTR_PENDING_AUDIO_FRAMES, new ArrayList<byte[]>());
realtimeMeetingAudioStorageService.openSession(sessionData.getMeetingId(), session.getId());
log.info("Realtime websocket accepted: meetingId={}, sessionId={}, upstream={}",
sessionData.getMeetingId(), session.getId(), sessionData.getTargetWsUrl());
@ -92,11 +95,13 @@ public class RealtimeMeetingProxyWebSocketHandler extends AbstractWebSocketHandl
log.error("Realtime websocket upstream connect interrupted: meetingId={}, sessionId={}",
sessionData.getMeetingId(), session.getId(), ex);
sendFrontendError(frontendSession, "REALTIME_UPSTREAM_CONNECT_INTERRUPTED", "连接第三方识别服务时被中断");
realtimeMeetingAudioStorageService.closeSession(session.getId());
frontendSession.close(CloseStatus.SERVER_ERROR.withReason("Interrupted while connecting upstream"));
return;
} catch (ExecutionException | CompletionException ex) {
log.warn("Failed to connect upstream websocket, meetingId={}, target={}", sessionData.getMeetingId(), sessionData.getTargetWsUrl(), ex);
sendFrontendError(frontendSession, "REALTIME_UPSTREAM_CONNECT_FAILED", "连接第三方识别服务失败,请检查模型 WebSocket 配置或服务状态");
realtimeMeetingAudioStorageService.closeSession(session.getId());
frontendSession.close(CloseStatus.SERVER_ERROR.withReason("Failed to connect ASR websocket"));
return;
}
@ -137,6 +142,7 @@ public class RealtimeMeetingProxyWebSocketHandler extends AbstractWebSocketHandl
session.getAttributes().get(ATTR_MEETING_ID), session.getId(), count, bytes);
}
byte[] payload = toByteArray(message.getPayload());
realtimeMeetingAudioStorageService.append(session.getId(), payload);
if (!Boolean.TRUE.equals(session.getAttributes().get(ATTR_START_MESSAGE_SENT))) {
queuePendingAudioFrame(session, payload);
if (shouldLogBinaryFrame(count)) {
@ -175,6 +181,7 @@ public class RealtimeMeetingProxyWebSocketHandler extends AbstractWebSocketHandl
if (meetingIdValue instanceof Long meetingId) {
realtimeMeetingSessionStateService.pauseByDisconnect(meetingId, session.getId());
}
realtimeMeetingAudioStorageService.closeSession(session.getId());
closeUpstreamSocket(session, status);
}

View File

@ -42,7 +42,6 @@ unisbase:
permit-all-urls:
- /actuator/health
- /api/static/**
- /api/android/**
- /ws/**
internal-auth:
enabled: true

View File

@ -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());
}
}

View File

@ -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);
}
}
}

View File

@ -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());
}
}

View File

@ -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));
}
}

View File

@ -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"));
}
}
}

View File

@ -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;
}
}

View File

@ -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;
}
}

View File

@ -1,4 +1,4 @@
import http from "../http";
import http from "../http";
import axios from "axios";
export interface MeetingVO {
@ -6,12 +6,16 @@ export interface MeetingVO {
tenantId: number;
creatorId: number;
creatorName?: string;
hostUserId?: number;
hostName?: string;
title: string;
meetingTime: string;
participants: string;
participantIds?: number[];
tags: string;
audioUrl: string;
audioSaveStatus?: "NONE" | "SUCCESS" | "FAILED";
audioSaveMessage?: string;
summaryContent: string;
analysis?: {
overview?: string;
@ -33,6 +37,8 @@ export interface CreateMeetingCommand {
meetingTime: string;
participants: string;
tags: string;
hostUserId?: number;
hostName?: string;
audioUrl?: string;
asrModelId: number;
summaryModelId?: number;
@ -49,6 +55,8 @@ export interface CreateRealtimeMeetingCommand {
meetingTime: string;
participants: string;
tags: string;
hostUserId?: number;
hostName?: string;
asrModelId: number;
summaryModelId?: number;
promptId: number;
@ -179,7 +187,7 @@ export const openRealtimeMeetingSocketSession = (
);
};
export const completeRealtimeMeeting = (meetingId: number, data?: { audioUrl?: string }) => {
export const completeRealtimeMeeting = (meetingId: number, data?: { audioUrl?: string; overwriteAudio?: boolean }) => {
return http.post<any, { code: string; data: boolean; msg: string }>(
`/api/biz/meeting/${meetingId}/realtime/complete`,
data || {}

View File

@ -1,6 +1,7 @@
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useNavigate, useParams } from 'react-router-dom';
import {
Alert,
Avatar,
Breadcrumb,
Button,
@ -600,6 +601,12 @@ const MeetingDetail: React.FC = () => {
setSelectedKeywords((current) => current.filter((item) => analysis.keywords.includes(item)));
}, [analysis.keywords]);
useEffect(() => {
if (meeting?.audioSaveStatus === 'FAILED') {
message.warning(meeting.audioSaveMessage || '实时会议已完成,但音频保存失败,当前无法播放会议录音。转写和总结不受影响。');
}
}, [meeting?.id, meeting?.audioSaveStatus, meeting?.audioSaveMessage]);
useEffect(() => {
const audio = audioRef.current;
if (!audio) return undefined;
@ -1201,6 +1208,14 @@ const MeetingDetail: React.FC = () => {
<Card className="left-flow-card" bordered={false} title={<span><AudioOutlined /> </span>}>
{meeting.audioUrl && <audio ref={audioRef} src={meeting.audioUrl} style={{ display: 'none' }} preload="metadata" />}
{meeting.audioSaveStatus === 'FAILED' && (
<Alert
type="warning"
showIcon
style={{ marginBottom: 16 }}
message={meeting.audioSaveMessage || '实时会议已完成,但音频保存失败,当前无法播放会议录音。转写和总结不受影响。'}
/>
)}
<List
dataSource={transcripts}
renderItem={(item) => (
@ -1991,4 +2006,3 @@ const MeetingDetail: React.FC = () => {
};
export default MeetingDetail;

View File

@ -186,11 +186,16 @@ const MeetingCreateForm: React.FC<{
<Col span={12}><Form.Item name="meetingTime" label="会议时间" rules={[{ required: true }]} style={{ marginBottom: 12 }}><DatePicker showTime style={{ width: '100%' }} size="large" /></Form.Item></Col>
<Col span={12}><Form.Item name="tags" label="会议标签" style={{ marginBottom: 12 }}><Select mode="tags" placeholder="输入标签" size="large" /></Form.Item></Col>
</Row>
<Form.Item name="participants" label="参会人员" style={{ marginBottom: 0 }}>
<Form.Item name="participants" label="参会人员" style={{ marginBottom: 12 }}>
<Select mode="multiple" placeholder="选择人员" showSearch optionFilterProp="children" size="large">
{userList.map(u => (<Option key={u.userId} value={u.userId}><Space><Avatar size="small" icon={<UserOutlined />} />{u.displayName || u.username}</Space></Option>))}
</Select>
</Form.Item>
<Form.Item name="hostUserId" label="会议主持人" style={{ marginBottom: 0 }}>
<Select allowClear placeholder="不选择则默认为创建人" showSearch optionFilterProp="children" size="large">
{userList.map(u => (<Option key={u.userId} value={u.userId}><Space><Avatar size="small" icon={<UserOutlined />} />{u.displayName || u.username}</Space></Option>))}
</Select>
</Form.Item>
</Card>
{/* 录音上传卡片 - 占满剩余高度 */}
@ -487,14 +492,16 @@ const Meetings: React.FC = () => {
return;
}
const values = await form.validateFields();
const { hostUserId, ...meetingValues } = values;
setSubmitLoading(true);
try {
await createMeeting({
...values,
meetingTime: values.meetingTime.format('YYYY-MM-DD HH:mm:ss'),
...meetingValues,
...(hostUserId != null ? { hostUserId } : {}),
meetingTime: meetingValues.meetingTime.format('YYYY-MM-DD HH:mm:ss'),
audioUrl,
participants: values.participants?.join(','),
tags: values.tags?.join(',')
participants: meetingValues.participants?.join(','),
tags: meetingValues.tags?.join(',')
});
message.success('会议发起成功');
setCreateDrawerVisible(false);

View File

@ -171,19 +171,21 @@ export default function RealtimeAsr() {
weight: Number(item.weight || 2) / 10,
}));
const { hostUserId, ...meetingValues } = values;
const payload: CreateRealtimeMeetingCommand = {
...values,
meetingTime: values.meetingTime.format("YYYY-MM-DD HH:mm:ss"),
participants: values.participants?.join(",") || "",
tags: values.tags?.join(",") || "",
mode: values.mode || "2pass",
language: values.language || "auto",
useSpkId: values.useSpkId ? 1 : 0,
enablePunctuation: values.enablePunctuation !== false,
enableItn: values.enableItn !== false,
enableTextRefine: !!values.enableTextRefine,
saveAudio: !!values.saveAudio,
hotWords: values.hotWords,
...meetingValues,
...(hostUserId != null ? { hostUserId } : {}),
meetingTime: meetingValues.meetingTime.format("YYYY-MM-DD HH:mm:ss"),
participants: meetingValues.participants?.join(",") || "",
tags: meetingValues.tags?.join(",") || "",
mode: meetingValues.mode || "2pass",
language: meetingValues.language || "auto",
useSpkId: meetingValues.useSpkId ? 1 : 0,
enablePunctuation: meetingValues.enablePunctuation !== false,
enableItn: meetingValues.enableItn !== false,
enableTextRefine: !!meetingValues.enableTextRefine,
saveAudio: !!meetingValues.saveAudio,
hotWords: meetingValues.hotWords,
};
const res = await createRealtimeMeeting(payload);
@ -321,6 +323,23 @@ export default function RealtimeAsr() {
</Select>
</Form.Item>
</Col>
<Col xs={24} md={12}>
<Form.Item name="hostUserId" label="会议主持人">
<Select allowClear showSearch optionFilterProp="children" placeholder="不选择则默认为创建人">
{userList.map((user) => (
<Option key={user.userId} value={user.userId}>
<Space>
<Avatar size="small" icon={<UserOutlined />} />
{user.displayName || user.username}
</Space>
</Option>
))}
</Select>
</Form.Item>
</Col>
</Row>
<Row gutter={16}>
<Col xs={24} md={12}>
<Form.Item name="tags" label="会议标签">
<Select mode="tags" placeholder="输入标签后回车" />

View File

@ -596,10 +596,21 @@ export function RealtimeAsrSession() {
try {
await completeRealtimeMeeting(meetingId, {});
let savedMeeting: MeetingVO | null = null;
try {
const detailRes = await getMeetingDetail(meetingId);
savedMeeting = detailRes.data.data;
} catch {
// 会议完成已成功提交,详情刷新失败不应反向标记为结束失败。
}
sessionStorage.removeItem(getSessionKey(meetingId));
setSessionStatus((prev) => prev ? { ...prev, status: "COMPLETING", canResume: false, activeConnection: false } : prev);
setStatusText("已提交总结任务");
message.success("实时会议已结束,正在生成总结");
if (savedMeeting?.audioSaveStatus === "FAILED") {
message.warning(savedMeeting.audioSaveMessage || "实时会议已完成,但音频保存失败,当前无法播放会议录音。转写和总结不受影响。");
} else {
message.success("实时会议已结束,正在生成总结");
}
if (navigateAfterStop) {
navigate(`/meetings/${meetingId}`);
}

View File

@ -69,6 +69,7 @@ const SpeakerReg: React.FC = () => {
const [editingSpeaker, setEditingSpeaker] = useState<SpeakerVO | null>(null);
const [seconds, setSeconds] = useState(0);
const timerRef = useRef<any>(null);
const autoStopTimerRef = useRef<any>(null);
const mediaRecorderRef = useRef<MediaRecorder | null>(null);
const audioChunksRef = useRef<Blob[]>([]);
const { profile } = useAuth();
@ -165,16 +166,15 @@ const SpeakerReg: React.FC = () => {
};
const startTimer = () => {
stopTimer();
setSeconds(0);
timerRef.current = setInterval(() => {
setSeconds(prev => {
if (prev + 1 >= DEFAULT_DURATION) {
stopRecording();
return DEFAULT_DURATION;
}
return prev + 1;
});
setSeconds(prev => Math.min(prev + 1, DEFAULT_DURATION));
}, 1000);
autoStopTimerRef.current = setTimeout(() => {
setSeconds(DEFAULT_DURATION);
stopRecording();
}, DEFAULT_DURATION * 1000);
};
const stopTimer = () => {
@ -182,6 +182,10 @@ const SpeakerReg: React.FC = () => {
clearInterval(timerRef.current);
timerRef.current = null;
}
if (autoStopTimerRef.current) {
clearTimeout(autoStopTimerRef.current);
autoStopTimerRef.current = null;
}
};
const startRecording = async () => {