feat(realtime): 实现实时会议订阅与ASR功能

后端新增实时会议事件分发、订阅限流及WebSocket处理器,完善Redis缓存机制与实时ASR通道支持。前端同步更新实时ASR会话组件及会议预览页面,完成前后端实时数据链路对接。
dev_na
chenhao 2026-09-14 16:15:19 +08:00
parent 907a9755c0
commit 36c1461c81
36 changed files with 1546 additions and 127 deletions

View File

@ -71,6 +71,18 @@ public final class RedisKeys {
return "biz:meeting:realtime:socket:" + sessionToken; return "biz:meeting:realtime:socket:" + sessionToken;
} }
public static String realtimeMeetingSubscriptionSessionKey(String sessionToken) {
return "biz:meeting:realtime:subscribe:" + sessionToken;
}
public static String realtimeMeetingSubscriptionRateLimitKey(String scope) {
return "biz:meeting:realtime:subscribe:rate:" + scope;
}
public static String realtimeMeetingSubscriberSetKey(Long meetingId) {
return "biz:meeting:realtime:subscribers:" + meetingId;
}
public static String realtimeMeetingSessionStateKey(Long meetingId) { public static String realtimeMeetingSessionStateKey(Long meetingId) {
return "biz:meeting:realtime:state:" + meetingId; return "biz:meeting:realtime:state:" + meetingId;
} }
@ -91,6 +103,10 @@ public final class RedisKeys {
return "biz:meeting:realtime:timeout:lock:" + meetingId; return "biz:meeting:realtime:timeout:lock:" + meetingId;
} }
public static String realtimeMeetingPublisherLeaseKey(Long meetingId) {
return "biz:meeting:realtime:publisher:" + meetingId;
}
public static String realtimeMeetingResumeTimeoutPrefix() { public static String realtimeMeetingResumeTimeoutPrefix() {
return "biz:meeting:realtime:resume-timeout:"; return "biz:meeting:realtime:resume-timeout:";
} }

View File

@ -1,6 +1,7 @@
package com.imeeting.config; package com.imeeting.config;
import com.imeeting.websocket.RealtimeMeetingProxyWebSocketHandler; import com.imeeting.websocket.RealtimeMeetingProxyWebSocketHandler;
import com.imeeting.websocket.RealtimeMeetingSubscriptionWebSocketHandler;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import org.springframework.boot.web.embedded.tomcat.TomcatServletWebServerFactory; import org.springframework.boot.web.embedded.tomcat.TomcatServletWebServerFactory;
import org.springframework.boot.web.server.WebServerFactoryCustomizer; import org.springframework.boot.web.server.WebServerFactoryCustomizer;
@ -20,11 +21,14 @@ public class RealtimeMeetingWebSocketConfig implements WebSocketConfigurer {
private static final String WS_TEXT_BUFFER_SIZE = "1048576"; private static final String WS_TEXT_BUFFER_SIZE = "1048576";
private final RealtimeMeetingProxyWebSocketHandler realtimeMeetingProxyWebSocketHandler; private final RealtimeMeetingProxyWebSocketHandler realtimeMeetingProxyWebSocketHandler;
private final RealtimeMeetingSubscriptionWebSocketHandler realtimeMeetingSubscriptionWebSocketHandler;
@Override @Override
public void registerWebSocketHandlers(WebSocketHandlerRegistry registry) { public void registerWebSocketHandlers(WebSocketHandlerRegistry registry) {
registry.addHandler(realtimeMeetingProxyWebSocketHandler, "/ws/meeting/realtime") registry.addHandler(realtimeMeetingProxyWebSocketHandler, "/ws/meeting/realtime")
.setAllowedOriginPatterns("*"); .setAllowedOriginPatterns("*");
registry.addHandler(realtimeMeetingSubscriptionWebSocketHandler, "/ws/meeting/realtime/subscribe")
.setAllowedOriginPatterns("*");
} }
/** /**

View File

@ -17,6 +17,7 @@ import com.imeeting.dto.biz.MeetingVO;
import com.imeeting.dto.biz.OpenRealtimeSocketSessionCommand; import com.imeeting.dto.biz.OpenRealtimeSocketSessionCommand;
import com.imeeting.dto.biz.RealtimeMeetingCompleteDTO; import com.imeeting.dto.biz.RealtimeMeetingCompleteDTO;
import com.imeeting.dto.biz.RealtimeMeetingSessionStatusVO; import com.imeeting.dto.biz.RealtimeMeetingSessionStatusVO;
import com.imeeting.dto.biz.RealtimeMeetingSubscriptionSessionVO;
import com.imeeting.dto.biz.RealtimeSocketSessionVO; import com.imeeting.dto.biz.RealtimeSocketSessionVO;
import com.imeeting.dto.biz.RealtimeTranscriptItemDTO; import com.imeeting.dto.biz.RealtimeTranscriptItemDTO;
import com.imeeting.dto.biz.UpdateMeetingBasicCommand; import com.imeeting.dto.biz.UpdateMeetingBasicCommand;
@ -37,6 +38,7 @@ import com.imeeting.service.biz.MeetingUnifiedStatusService;
import com.imeeting.service.biz.PromptTemplateService; import com.imeeting.service.biz.PromptTemplateService;
import com.imeeting.service.biz.RealtimeMeetingSessionStateService; import com.imeeting.service.biz.RealtimeMeetingSessionStateService;
import com.imeeting.service.biz.RealtimeMeetingSocketSessionService; import com.imeeting.service.biz.RealtimeMeetingSocketSessionService;
import com.imeeting.service.biz.RealtimeMeetingSubscriptionSessionService;
import com.imeeting.service.biz.impl.MeetingAudioUploadSupport; import com.imeeting.service.biz.impl.MeetingAudioUploadSupport;
import com.unisbase.common.ApiResponse; import com.unisbase.common.ApiResponse;
import com.unisbase.common.annotation.Log; import com.unisbase.common.annotation.Log;
@ -46,6 +48,7 @@ import com.unisbase.service.SysParamService;
import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag; import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid; import jakarta.validation.Valid;
import jakarta.servlet.http.HttpServletRequest;
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value; import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.HttpHeaders; import org.springframework.http.HttpHeaders;
@ -85,6 +88,7 @@ public class MeetingController {
private final MeetingTranscriptFileService meetingTranscriptFileService; private final MeetingTranscriptFileService meetingTranscriptFileService;
private final PromptTemplateService promptTemplateService; private final PromptTemplateService promptTemplateService;
private final RealtimeMeetingSocketSessionService realtimeMeetingSocketSessionService; private final RealtimeMeetingSocketSessionService realtimeMeetingSocketSessionService;
private final RealtimeMeetingSubscriptionSessionService realtimeMeetingSubscriptionSessionService;
private final RealtimeMeetingSessionStateService realtimeMeetingSessionStateService; private final RealtimeMeetingSessionStateService realtimeMeetingSessionStateService;
private final MeetingAudioUploadSupport meetingAudioUploadSupport; private final MeetingAudioUploadSupport meetingAudioUploadSupport;
private final MeetingProgressService meetingProgressService; private final MeetingProgressService meetingProgressService;
@ -103,6 +107,7 @@ public class MeetingController {
MeetingTranscriptFileService meetingTranscriptFileService, MeetingTranscriptFileService meetingTranscriptFileService,
PromptTemplateService promptTemplateService, PromptTemplateService promptTemplateService,
RealtimeMeetingSocketSessionService realtimeMeetingSocketSessionService, RealtimeMeetingSocketSessionService realtimeMeetingSocketSessionService,
RealtimeMeetingSubscriptionSessionService realtimeMeetingSubscriptionSessionService,
RealtimeMeetingSessionStateService realtimeMeetingSessionStateService, RealtimeMeetingSessionStateService realtimeMeetingSessionStateService,
MeetingAudioUploadSupport meetingAudioUploadSupport, MeetingAudioUploadSupport meetingAudioUploadSupport,
MeetingProgressService meetingProgressService, MeetingProgressService meetingProgressService,
@ -116,6 +121,7 @@ public class MeetingController {
this.meetingTranscriptFileService = meetingTranscriptFileService; this.meetingTranscriptFileService = meetingTranscriptFileService;
this.promptTemplateService = promptTemplateService; this.promptTemplateService = promptTemplateService;
this.realtimeMeetingSocketSessionService = realtimeMeetingSocketSessionService; this.realtimeMeetingSocketSessionService = realtimeMeetingSocketSessionService;
this.realtimeMeetingSubscriptionSessionService = realtimeMeetingSubscriptionSessionService;
this.realtimeMeetingSessionStateService = realtimeMeetingSessionStateService; this.realtimeMeetingSessionStateService = realtimeMeetingSessionStateService;
this.meetingAudioUploadSupport = meetingAudioUploadSupport; this.meetingAudioUploadSupport = meetingAudioUploadSupport;
this.meetingProgressService = meetingProgressService; this.meetingProgressService = meetingProgressService;
@ -303,6 +309,15 @@ public class MeetingController {
return ApiResponse.ok(meetingQueryService.getDetail(id)); return ApiResponse.ok(meetingQueryService.getDetail(id));
} }
@Operation(summary = "创建实时转写订阅会话")
@PostMapping("/{id}/realtime/subscription-session")
@PreAuthorize("isAuthenticated()")
public ApiResponse<RealtimeMeetingSubscriptionSessionVO> createRealtimeSubscriptionSession(@PathVariable Long id,
HttpServletRequest request) {
return ApiResponse.ok(realtimeMeetingSubscriptionSessionService.createForUser(
id, currentLoginUser(), request.getRemoteAddr()));
}
@Operation(summary = "导出会议摘要") @Operation(summary = "导出会议摘要")
@GetMapping("/{id}/summary/export") @GetMapping("/{id}/summary/export")
@PreAuthorize("isAuthenticated()") @PreAuthorize("isAuthenticated()")
@ -371,7 +386,7 @@ public class MeetingController {
public ApiResponse<RealtimeMeetingSessionStatusVO> getRealtimeSessionStatus(@PathVariable Long id) { public ApiResponse<RealtimeMeetingSessionStatusVO> getRealtimeSessionStatus(@PathVariable Long id) {
LoginUser loginUser = currentLoginUser(); LoginUser loginUser = currentLoginUser();
Meeting meeting = meetingAccessService.requireMeeting(id); Meeting meeting = meetingAccessService.requireMeeting(id);
meetingAccessService.assertCanManageRealtimeMeeting(meeting, loginUser); meetingAccessService.assertCanViewMeeting(meeting, loginUser);
return ApiResponse.ok(realtimeMeetingSessionStateService.getStatus(id)); return ApiResponse.ok(realtimeMeetingSessionStateService.getStatus(id));
} }
@ -392,7 +407,7 @@ public class MeetingController {
} }
try { try {
Meeting meeting = meetingAccessService.requireMeeting(id); Meeting meeting = meetingAccessService.requireMeeting(id);
meetingAccessService.assertCanManageRealtimeMeeting(meeting, loginUser); meetingAccessService.assertCanViewMeeting(meeting, loginUser);
RealtimeMeetingSessionStatusVO status = statuses.get(id); RealtimeMeetingSessionStatusVO status = statuses.get(id);
if (status != null) { if (status != null) {
result.put(id, status); result.put(id, status);

View File

@ -2,17 +2,23 @@ package com.imeeting.controller.biz;
import com.imeeting.dto.biz.MeetingPreviewAccessVO; import com.imeeting.dto.biz.MeetingPreviewAccessVO;
import com.imeeting.dto.biz.PublicMeetingPreviewVO; import com.imeeting.dto.biz.PublicMeetingPreviewVO;
import com.imeeting.dto.biz.RealtimeMeetingSessionStatusVO;
import com.imeeting.dto.biz.RealtimeMeetingSubscriptionSessionVO;
import com.imeeting.entity.biz.Meeting; import com.imeeting.entity.biz.Meeting;
import com.imeeting.service.biz.MeetingAccessService; import com.imeeting.service.biz.MeetingAccessService;
import com.imeeting.service.biz.MeetingQueryService; import com.imeeting.service.biz.MeetingQueryService;
import com.imeeting.service.biz.RealtimeMeetingSessionStateService;
import com.imeeting.service.biz.RealtimeMeetingSubscriptionSessionService;
import com.unisbase.common.ApiResponse; import com.unisbase.common.ApiResponse;
import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag; import io.swagger.v3.oas.annotations.tags.Tag;
import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController; import org.springframework.web.bind.annotation.RestController;
import jakarta.servlet.http.HttpServletRequest;
@Tag(name = "会议公开预览") @Tag(name = "会议公开预览")
@RestController @RestController
@ -21,11 +27,17 @@ public class MeetingPublicPreviewController {
private final MeetingQueryService meetingQueryService; private final MeetingQueryService meetingQueryService;
private final MeetingAccessService meetingAccessService; private final MeetingAccessService meetingAccessService;
private final RealtimeMeetingSessionStateService realtimeMeetingSessionStateService;
private final RealtimeMeetingSubscriptionSessionService realtimeMeetingSubscriptionSessionService;
public MeetingPublicPreviewController(MeetingQueryService meetingQueryService, public MeetingPublicPreviewController(MeetingQueryService meetingQueryService,
MeetingAccessService meetingAccessService) { MeetingAccessService meetingAccessService,
RealtimeMeetingSessionStateService realtimeMeetingSessionStateService,
RealtimeMeetingSubscriptionSessionService realtimeMeetingSubscriptionSessionService) {
this.meetingQueryService = meetingQueryService; this.meetingQueryService = meetingQueryService;
this.meetingAccessService = meetingAccessService; this.meetingAccessService = meetingAccessService;
this.realtimeMeetingSessionStateService = realtimeMeetingSessionStateService;
this.realtimeMeetingSubscriptionSessionService = realtimeMeetingSubscriptionSessionService;
} }
@Operation(summary = "查询会议预览访问要求") @Operation(summary = "查询会议预览访问要求")
@ -59,4 +71,29 @@ public class MeetingPublicPreviewController {
return ApiResponse.error(ex.getMessage()); return ApiResponse.error(ex.getMessage());
} }
} }
@Operation(summary = "查询公开预览实时会议状态")
@GetMapping("/{id}/preview/realtime/session-status")
public ApiResponse<RealtimeMeetingSessionStatusVO> getRealtimeSessionStatus(
@PathVariable Long id, @RequestParam(required = false) String accessPassword) {
try {
Meeting meeting = meetingAccessService.requireMeetingIgnoreTenant(id);
meetingAccessService.assertCanPreviewMeeting(meeting, accessPassword);
return ApiResponse.ok(realtimeMeetingSessionStateService.getStatus(id));
} catch (RuntimeException ex) {
return ApiResponse.error(ex.getMessage());
}
}
@Operation(summary = "创建公开实时转写订阅会话")
@PostMapping("/{id}/preview/realtime-subscription-session")
public ApiResponse<RealtimeMeetingSubscriptionSessionVO> createRealtimeSubscriptionSession(
@PathVariable Long id, @RequestParam(required = false) String accessPassword, HttpServletRequest request) {
try {
return ApiResponse.ok(realtimeMeetingSubscriptionSessionService.createForPreview(
id, accessPassword, request.getRemoteAddr()));
} catch (RuntimeException ex) {
return ApiResponse.error(ex.getMessage());
}
}
} }

View File

@ -0,0 +1,8 @@
package com.imeeting.dto.biz;
import lombok.Data;
@Data
public class RealtimeMeetingSubscriptionSessionData {
private Long meetingId;
}

View File

@ -0,0 +1,10 @@
package com.imeeting.dto.biz;
import lombok.Data;
@Data
public class RealtimeMeetingSubscriptionSessionVO {
private String sessionToken;
private String path;
private Long expiresInSeconds;
}

View File

@ -2,7 +2,6 @@ package com.imeeting.grpc.realtime;
import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.ObjectMapper;
import com.imeeting.common.MeetingConstants; import com.imeeting.common.MeetingConstants;
import com.imeeting.config.grpc.AndroidGrpcAuthProperties;
import com.imeeting.dto.android.AndroidAuthContext; import com.imeeting.dto.android.AndroidAuthContext;
import com.imeeting.dto.biz.AiModelVO; import com.imeeting.dto.biz.AiModelVO;
import com.imeeting.dto.biz.RealtimeMeetingResumeConfig; import com.imeeting.dto.biz.RealtimeMeetingResumeConfig;
@ -13,19 +12,29 @@ import com.imeeting.service.biz.AiModelService;
import com.imeeting.service.biz.MeetingAccessService; import com.imeeting.service.biz.MeetingAccessService;
import com.imeeting.service.biz.MeetingAuthorizationService; import com.imeeting.service.biz.MeetingAuthorizationService;
import com.imeeting.service.biz.RealtimeMeetingSessionStateService; import com.imeeting.service.biz.RealtimeMeetingSessionStateService;
import com.imeeting.service.android.AndroidAuthService;
import com.imeeting.service.realtime.RealtimeAsrChannel; import com.imeeting.service.realtime.RealtimeAsrChannel;
import com.imeeting.service.realtime.RealtimeAsrChannelContext; import com.imeeting.service.realtime.RealtimeAsrChannelContext;
import com.imeeting.service.realtime.RealtimeAsrChannelFactory; import com.imeeting.service.realtime.RealtimeAsrChannelFactory;
import com.imeeting.service.realtime.RealtimeMeetingAudioStorageService; import com.imeeting.service.realtime.RealtimeMeetingAudioStorageService;
import com.imeeting.service.realtime.RealtimeMeetingTranscriptCacheService;
import com.imeeting.service.realtime.RealtimeMeetingEventHub;
import com.imeeting.service.realtime.RealtimeMeetingEventRelay;
import com.imeeting.service.realtime.RealtimeMeetingSubscriptionQuota;
import com.imeeting.service.realtime.RealtimeMeetingMetrics;
import com.imeeting.support.redis.RealtimeMeetingPublisherLease;
import io.grpc.Status; import io.grpc.Status;
import io.grpc.stub.StreamObserver; import io.grpc.stub.StreamObserver;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.web.socket.CloseStatus; import org.springframework.web.socket.CloseStatus;
import java.util.Map; import java.util.Map;
import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ConcurrentHashMap;
import java.util.function.Consumer;
/** /**
* Android REST complete * Android REST complete
@ -38,14 +47,32 @@ public class AndroidRealtimeMeetingGrpcService
private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper(); private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper();
private final AndroidGrpcAuthProperties authProperties; private final AndroidAuthService androidAuthService;
private final MeetingAccessService meetingAccessService; private final MeetingAccessService meetingAccessService;
private final MeetingAuthorizationService meetingAuthorizationService; private final MeetingAuthorizationService meetingAuthorizationService;
private final RealtimeMeetingSessionStateService sessionStateService; private final RealtimeMeetingSessionStateService sessionStateService;
private final RealtimeAsrChannelFactory channelFactory; private final RealtimeAsrChannelFactory channelFactory;
private final RealtimeMeetingAudioStorageService audioStorageService; private final RealtimeMeetingAudioStorageService audioStorageService;
private final AiModelService aiModelService; private final AiModelService aiModelService;
private final RealtimeMeetingPublisherLease publisherLease;
private final RealtimeMeetingEventHub eventHub;
private final RealtimeMeetingEventRelay eventRelay;
private final RealtimeMeetingSubscriptionQuota subscriptionQuota;
private final RealtimeMeetingTranscriptCacheService transcriptCacheService;
private final Map<Long, GrpcSession> sessions = new ConcurrentHashMap<>(); private final Map<Long, GrpcSession> sessions = new ConcurrentHashMap<>();
private final Map<String, GrpcSession> subscriberSessions = new ConcurrentHashMap<>();
@Value("${imeeting.realtime.max-subscribers-per-meeting:200}")
private int maxSubscribersPerMeeting = 200;
@Scheduled(fixedDelay = 60_000)
public void renewSubscriberQuotaSlots() {
subscriberSessions.values().forEach(session -> {
if (!session.cleaned) {
subscriptionQuota.renew(session.meetingId, session.subscriptionMember);
}
});
}
@Override @Override
public StreamObserver<ClientMessage> stream(StreamObserver<ServerMessage> responseObserver) { public StreamObserver<ClientMessage> stream(StreamObserver<ServerMessage> responseObserver) {
@ -102,18 +129,98 @@ public class AndroidRealtimeMeetingGrpcService
if (frame.getPcm().isEmpty()) { if (frame.getPcm().isEmpty()) {
return; return;
} }
if (!session.publisher) {
try {
if (!activatePublisher()) {
return;
}
} catch (Exception ex) {
throw new IllegalStateException("Realtime publisher initialization failed", ex);
}
}
long sequence = frame.getSequence(); long sequence = frame.getSequence();
if (session.lastSequence >= 0 && sequence <= session.lastSequence) { if (session.lastSequence >= 0 && sequence <= session.lastSequence) {
fail("REALTIME_AUDIO_SEQUENCE_INVALID", "音频帧 sequence 必须严格递增", false); fail("REALTIME_AUDIO_SEQUENCE_INVALID", "音频帧 sequence 必须严格递增", false);
return; return;
} }
session.lastSequence = sequence; session.lastSequence = sequence;
if (!publisherLease.renew(session.meetingId, session.publisherFenceToken)
|| !sessionStateService.isActiveConnection(session.meetingId, session.publisherFenceToken)) {
fail("REALTIME_PUBLISHER_LEASE_LOST", "发布连接已失效,请重新连接", true);
return;
}
byte[] pcm = frame.getPcm().toByteArray(); byte[] pcm = frame.getPcm().toByteArray();
audioStorageService.append(session.connectionId, pcm); audioStorageService.append(session.connectionId, pcm);
session.channel.handleFrontendBinary(session.context, pcm); session.channel.handleFrontendBinary(session.context, pcm);
} }
private boolean activatePublisher() throws Exception {
if (!session.canPublish) {
fail("REALTIME_AUDIO_PUBLISH_FORBIDDEN", "Subscriber cannot publish audio", false);
return false;
}
sessionStateService.assertCanOpenSession(session.meetingId);
RealtimeMeetingSessionStatusVO status = sessionStateService.getStatus(session.meetingId);
RealtimeMeetingResumeConfig config = status == null ? null : status.getResumeConfig();
if (config == null || config.getAsrModelId() == null) {
throw new IllegalStateException("Realtime meeting ASR configuration is missing");
}
AiModelVO model = aiModelService.getModelById(config.getAsrModelId(), "ASR");
if (model == null) {
throw new IllegalStateException("Realtime ASR model does not exist");
}
RealtimeAsrChannel channel = channelFactory.getRequired(model.getProvider());
String publisherFenceToken = java.util.UUID.randomUUID().toString();
if (!publisherLease.tryAcquire(session.meetingId, publisherFenceToken)) {
RealtimeMeetingMetrics.publisherRejected("grpc");
fail("REALTIME_PUBLISHER_EXISTS", "Realtime publisher already exists", false);
return false;
}
if (!sessionStateService.activate(session.meetingId, publisherFenceToken)) {
publisherLease.release(session.meetingId, publisherFenceToken);
fail("REALTIME_PUBLISHER_EXISTS", "Realtime publisher already exists", false);
return false;
}
if (sessions.putIfAbsent(session.meetingId, session) != null) {
publisherLease.release(session.meetingId, publisherFenceToken);
sessionStateService.pauseByDisconnect(session.meetingId, publisherFenceToken);
fail("REALTIME_PUBLISHER_EXISTS", "Realtime publisher already exists", false);
return false;
}
session.publisherFenceToken = publisherFenceToken;
session.channel = channel;
session.context.setMeetingId(session.meetingId);
session.context.setProvider(channelFactory.normalizeProvider(model.getProvider()));
session.context.setTargetWsUrl(channel.resolveTargetWsUrl(model));
session.context.bindTransport(session.connectionId);
session.context.setConnectionId(publisherFenceToken);
session.context.getChannelState().put("modelCode", model.getModelCode());
session.context.getChannelState().put("mediaConfig", model.getMediaConfig());
session.context.setCallback(new GrpcChannelCallback(session));
detachSubscriber(session);
session.publisher = true;
session.cleanupAction = this::cleanup;
try {
RealtimeMeetingMetrics.publisherOpened("grpc");
audioStorageService.openSession(session.meetingId, session.connectionId);
channel.connect(session.context);
String start = OBJECT_MAPPER.writeValueAsString(channel.buildStartMessage(
model, config.getMode(), config.getLanguage(), config.getUseSpkId(),
config.getEnablePunctuation(), config.getEnableItn(), config.getEnableTextRefine(),
config.getSaveAudio(), config.getHotwords()));
channel.handleFrontendText(session.context, start);
return true;
} catch (Exception ex) {
cleanup();
throw ex;
}
}
private void handleStop(StopRequest stop) { private void handleStop(StopRequest stop) {
if (!session.publisher) {
fail("REALTIME_STOP_PUBLISH_FORBIDDEN", "Subscriber cannot stop publisher audio", false);
return;
}
if (!stop.getConnectionId().isBlank() if (!stop.getConnectionId().isBlank()
&& !session.connectionId.equals(stop.getConnectionId())) { && !session.connectionId.equals(stop.getConnectionId())) {
fail("REALTIME_CONNECTION_ID_MISMATCH", "stop 的 connection_id 与连接不一致", false); fail("REALTIME_CONNECTION_ID_MISMATCH", "stop 的 connection_id 与连接不一致", false);
@ -146,24 +253,50 @@ public class AndroidRealtimeMeetingGrpcService
if (session == null || !session.cleaned) { if (session == null || !session.cleaned) {
if (session != null) { if (session != null) {
session.cleaned = true; session.cleaned = true;
try { if (session.publisher) {
session.channel.closeMeeting(session.context); try {
} catch (Exception ex) { session.channel.closeMeeting(session.context);
log.debug("Failed to close realtime upstream channel", ex); } catch (Exception ex) {
} log.debug("Failed to close realtime upstream channel", ex);
try { }
session.channel.onFrontendDetached(session.context); try {
} catch (Exception ex) { session.channel.onFrontendDetached(session.context);
log.debug("Failed to detach realtime gRPC channel", ex); } catch (Exception ex) {
log.debug("Failed to detach realtime gRPC channel", ex);
}
} }
session.context.closeTransport(); session.context.closeTransport();
audioStorageService.closeSession(session.connectionId); if (session.publisher) {
sessionStateService.pauseByDisconnect(session.meetingId, session.connectionId); audioStorageService.closeSession(session.connectionId);
sessions.remove(session.meetingId, session); }
if (session.publisher) {
publisherLease.release(session.meetingId, session.publisherFenceToken);
RealtimeMeetingMetrics.publisherClosed("grpc");
}
if (!session.publisher) {
detachSubscriber(session);
}
if (session.publisher) {
sessionStateService.pauseByDisconnect(session.meetingId, session.publisherFenceToken);
sessions.remove(session.meetingId, session);
}
} }
} }
} }
private void detachSubscriber(GrpcSession target) {
if (target.eventConsumer != null) {
eventHub.unsubscribe(target.meetingId, target.eventConsumer);
target.eventConsumer = null;
}
if (target.subscriptionMember != null) {
subscriptionQuota.release(target.meetingId, target.subscriptionMember);
target.subscriptionMember = null;
RealtimeMeetingMetrics.subscriberClosed("grpc");
}
subscriberSessions.remove(target.connectionId, target);
}
private GrpcSession open(ConnectRequest connect, StreamObserver<ServerMessage> observer) throws Exception { private GrpcSession open(ConnectRequest connect, StreamObserver<ServerMessage> observer) throws Exception {
if (connect.getDeviceId().isBlank()) { if (connect.getDeviceId().isBlank()) {
fail("REALTIME_DEVICE_ID_REQUIRED", "device_id 不能为空", false); fail("REALTIME_DEVICE_ID_REQUIRED", "device_id 不能为空", false);
@ -177,25 +310,70 @@ public class AndroidRealtimeMeetingGrpcService
// fail("REALTIME_PLATFORM_UNSUPPORTED", "实时会议 gRPC 当前仅支持 Android 平台", false); // fail("REALTIME_PLATFORM_UNSUPPORTED", "实时会议 gRPC 当前仅支持 Android 平台", false);
// return null; // return null;
// } // }
if (authProperties.isEnabled() && !authProperties.isAllowAnonymous()) {
fail("REALTIME_AUTH_REQUIRED", "当前 gRPC 配置要求认证", false);
return null;
}
Long meetingId = parseId(connect.getMeetingId(), "meeting_id"); Long meetingId = parseId(connect.getMeetingId(), "meeting_id");
Meeting meeting = meetingAccessService.requireMeetingIgnoreTenant(meetingId); Meeting meeting = meetingAccessService.requireMeetingIgnoreTenant(meetingId);
Long requestedTenantId = parseOptionalId(connect.getTenantId()); Long requestedTenantId = parseOptionalId(connect.getTenantId());
if (requestedTenantId != null && !requestedTenantId.equals(meeting.getTenantId())) { if (requestedTenantId != null && !requestedTenantId.equals(meeting.getTenantId())) {
throw new SecurityException("tenant_id 与会议租户不一致"); throw new SecurityException("tenant_id 与会议租户不一致");
} }
AndroidAuthContext authContext = buildAuthContext(connect); AndroidAuthContext authContext = androidAuthService.authenticateGrpc(
meetingAuthorizationService.assertCanControlRealtimeMeeting( connect.getDeviceId(), connect.getAppVersion(), "android",
meeting, authContext, MeetingTerminalEnum.CUSTOM_TERMINAL.getCode()); connect.getUserId(), connect.getTenantId());
boolean publisher = false;
boolean canPublish = authContext.getUserId() != null && authContext.getUserId().equals(meeting.getCreatorId());
if (publisher) {
if (authContext.getUserId() == null || !authContext.getUserId().equals(meeting.getCreatorId())) {
throw new SecurityException("只有会议创建人可以发布实时音频");
}
meetingAuthorizationService.assertCanControlRealtimeMeeting(
meeting, authContext, MeetingTerminalEnum.CUSTOM_TERMINAL.getCode());
} else {
meetingAuthorizationService.assertCanViewMeeting(meeting, authContext);
RealtimeMeetingSessionStatusVO currentStatus = sessionStateService.getStatus(meetingId);
if ((currentStatus == null || !"ACTIVE".equals(currentStatus.getStatus())) && !canPublish) {
throw new IllegalStateException("会议尚未开始,暂不能订阅实时转写");
}
}
if (!MeetingConstants.TYPE_REALTIME.equalsIgnoreCase(meeting.getMeetingType())) { if (!MeetingConstants.TYPE_REALTIME.equalsIgnoreCase(meeting.getMeetingType())) {
throw new IllegalStateException("当前会议不是实时会议"); throw new IllegalStateException("当前会议不是实时会议");
} }
sessionStateService.initSessionIfAbsent(meetingId, meeting.getTenantId(), parseOptionalId(connect.getUserId())); sessionStateService.initSessionIfAbsent(meetingId, meeting.getTenantId(), authContext.getUserId());
sessionStateService.assertCanOpenSession(meetingId); if (publisher) {
sessionStateService.assertCanOpenSession(meetingId);
}
String connectionId = connect.getConnectionId().isBlank()
? "grpc-" + java.util.UUID.randomUUID().toString().replace("-", "")
: connect.getConnectionId().trim();
if (!publisher) {
GrpcSession next = new GrpcSession(meetingId, connectionId, null, observer, null);
next.publisher = false;
next.canPublish = canPublish;
next.subscriptionMember = subscriptionQuota.member(connectionId);
if (!subscriptionQuota.tryAcquire(meetingId, next.subscriptionMember)) {
RealtimeMeetingMetrics.subscriberRejected("grpc");
throw new IllegalStateException("会议实时订阅人数已达上限");
}
next.eventConsumer = next::sendTranscript;
if (!eventHub.trySubscribe(meetingId, next.eventConsumer, maxSubscribersPerMeeting)) {
subscriptionQuota.release(meetingId, next.subscriptionMember);
RealtimeMeetingMetrics.subscriberRejected("grpc");
throw new IllegalStateException("会议实时订阅人数已达上限");
}
try {
observer.onNext(ServerMessage.newBuilder()
.setConnectAck(ConnectResponse.newBuilder().setSuccess(true).setMessage("实时转写订阅成功"))
.build());
replayCachedTranscripts(meetingId, next);
subscriberSessions.put(connectionId, next);
RealtimeMeetingMetrics.subscriberOpened("grpc");
return next;
} catch (RuntimeException ex) {
eventHub.unsubscribe(meetingId, next.eventConsumer);
subscriptionQuota.release(meetingId, next.subscriptionMember);
throw ex;
}
}
RealtimeMeetingSessionStatusVO status = sessionStateService.getStatus(meetingId); RealtimeMeetingSessionStatusVO status = sessionStateService.getStatus(meetingId);
RealtimeMeetingResumeConfig config = status == null ? null : status.getResumeConfig(); RealtimeMeetingResumeConfig config = status == null ? null : status.getResumeConfig();
@ -207,26 +385,35 @@ public class AndroidRealtimeMeetingGrpcService
throw new IllegalStateException("实时 ASR 模型不存在"); throw new IllegalStateException("实时 ASR 模型不存在");
} }
RealtimeAsrChannel channel = channelFactory.getRequired(model.getProvider()); RealtimeAsrChannel channel = channelFactory.getRequired(model.getProvider());
String connectionId = connect.getConnectionId().isBlank() String publisherFenceToken = java.util.UUID.randomUUID().toString();
? "grpc-" + java.util.UUID.randomUUID().toString().replace("-", "") GrpcSession next = new GrpcSession(meetingId, connectionId, channel, observer, publisherFenceToken);
: connect.getConnectionId().trim(); next.publisher = publisher;
GrpcSession next = new GrpcSession(meetingId, connectionId, channel, observer);
next.context.setMeetingId(meetingId); next.context.setMeetingId(meetingId);
next.context.setProvider(channelFactory.normalizeProvider(model.getProvider())); next.context.setProvider(channelFactory.normalizeProvider(model.getProvider()));
next.context.setTargetWsUrl(channel.resolveTargetWsUrl(model)); next.context.setTargetWsUrl(channel.resolveTargetWsUrl(model));
next.context.bindTransport(connectionId); next.context.bindTransport(connectionId);
next.context.setConnectionId(publisherFenceToken);
next.context.getChannelState().put("modelCode", model.getModelCode()); next.context.getChannelState().put("modelCode", model.getModelCode());
next.context.getChannelState().put("mediaConfig", model.getMediaConfig()); next.context.getChannelState().put("mediaConfig", model.getMediaConfig());
next.context.setCallback(new GrpcChannelCallback(next)); next.context.setCallback(new GrpcChannelCallback(next));
if (!sessionStateService.activate(meetingId, connectionId)) { if (!publisherLease.tryAcquire(meetingId, publisherFenceToken)) {
RealtimeMeetingMetrics.publisherRejected("grpc");
throw new IllegalStateException("当前会议已有活跃发布连接");
}
if (!sessionStateService.activate(meetingId, publisherFenceToken)) {
publisherLease.release(meetingId, publisherFenceToken);
throw new IllegalStateException("当前会议已有活跃连接"); throw new IllegalStateException("当前会议已有活跃连接");
} }
GrpcSession previous = sessions.putIfAbsent(meetingId, next); GrpcSession previous = sessions.putIfAbsent(meetingId, next);
if (previous != null) { if (previous != null) {
sessionStateService.pauseByDisconnect(meetingId, connectionId); publisherLease.release(meetingId, publisherFenceToken);
sessionStateService.pauseByDisconnect(meetingId, publisherFenceToken);
throw new IllegalStateException("当前会议已有活跃连接"); throw new IllegalStateException("当前会议已有活跃连接");
} }
try { try {
session = next;
next.cleanupAction = this::cleanup;
RealtimeMeetingMetrics.publisherOpened("grpc");
audioStorageService.openSession(meetingId, connectionId); audioStorageService.openSession(meetingId, connectionId);
channel.connect(next.context); channel.connect(next.context);
String start = OBJECT_MAPPER.writeValueAsString(channel.buildStartMessage( String start = OBJECT_MAPPER.writeValueAsString(channel.buildStartMessage(
@ -240,6 +427,8 @@ public class AndroidRealtimeMeetingGrpcService
return next; return next;
} catch (Exception ex) { } catch (Exception ex) {
sessions.remove(meetingId, next); sessions.remove(meetingId, next);
publisherLease.release(meetingId, publisherFenceToken);
eventHub.unsubscribe(meetingId, next.eventConsumer);
try { try {
channel.closeMeeting(next.context); channel.closeMeeting(next.context);
} catch (Exception closeEx) { } catch (Exception closeEx) {
@ -247,23 +436,13 @@ public class AndroidRealtimeMeetingGrpcService
} }
next.context.closeTransport(); next.context.closeTransport();
audioStorageService.closeSession(connectionId); audioStorageService.closeSession(connectionId);
sessionStateService.pauseByDisconnect(meetingId, connectionId); sessionStateService.pauseByDisconnect(meetingId, publisherFenceToken);
throw ex; throw ex;
} }
} }
}; };
} }
private AndroidAuthContext buildAuthContext(ConnectRequest connect) {
AndroidAuthContext context = new AndroidAuthContext();
context.setDeviceId(connect.getDeviceId());
context.setUserId(parseOptionalId(connect.getUserId()));
context.setTenantId(parseOptionalId(connect.getTenantId()));
context.setPlatform("android");
context.setAnonymous(true);
return context;
}
private Long parseId(String value, String field) { private Long parseId(String value, String field) {
Long id = parseOptionalId(value); Long id = parseOptionalId(value);
if (id == null) { if (id == null) {
@ -287,22 +466,54 @@ public class AndroidRealtimeMeetingGrpcService
return message == null || message.isBlank() ? fallback : message; return message == null || message.isBlank() ? fallback : message;
} }
private void replayCachedTranscripts(Long meetingId, GrpcSession session) {
try {
for (var item : transcriptCacheService.listOrderedItems(meetingId)) {
session.send(ServerMessage.newBuilder()
.setTranscript(TextMessage.newBuilder()
.setText(com.imeeting.service.realtime.impl.LocalRealtimeAsrChannel.buildFrontendTranscriptMessage(item)))
.build());
}
} catch (Exception ex) {
log.warn("Failed to replay realtime transcripts to subscriber, meetingId={}", meetingId, ex);
}
}
private static final class GrpcSession { private static final class GrpcSession {
private final Long meetingId; private final Long meetingId;
private final String connectionId; private final String connectionId;
private final RealtimeAsrChannel channel; private String publisherFenceToken;
private RealtimeAsrChannel channel;
private final StreamObserver<ServerMessage> observer; private final StreamObserver<ServerMessage> observer;
private final RealtimeAsrChannelContext context = new RealtimeAsrChannelContext(); private final RealtimeAsrChannelContext context = new RealtimeAsrChannelContext();
private volatile long lastSequence = -1; private volatile long lastSequence = -1;
private volatile boolean cleaned; private volatile boolean cleaned;
private volatile boolean publisher;
private boolean canPublish;
private Runnable cleanupAction;
private Consumer<String> eventConsumer;
private String subscriptionMember;
private GrpcSession(Long meetingId, String connectionId, RealtimeAsrChannel channel, private GrpcSession(Long meetingId, String connectionId, RealtimeAsrChannel channel,
StreamObserver<ServerMessage> observer) { StreamObserver<ServerMessage> observer, String publisherFenceToken) {
this.meetingId = meetingId; this.meetingId = meetingId;
this.connectionId = connectionId; this.connectionId = connectionId;
this.channel = channel; this.channel = channel;
this.publisherFenceToken = publisherFenceToken;
this.observer = observer; this.observer = observer;
} }
private void send(ServerMessage message) {
if (!cleaned && message != null) {
observer.onNext(message);
}
}
private void sendTranscript(String payload) {
send(ServerMessage.newBuilder()
.setTranscript(TextMessage.newBuilder().setText(payload == null ? "" : payload))
.build());
}
} }
private final class GrpcChannelCallback implements com.imeeting.service.realtime.RealtimeAsrChannelCallback { private final class GrpcChannelCallback implements com.imeeting.service.realtime.RealtimeAsrChannelCallback {
@ -319,11 +530,8 @@ public class AndroidRealtimeMeetingGrpcService
@Override @Override
public void sendFrontendText(Long meetingId, String payload) { public void sendFrontendText(Long meetingId, String payload) {
if (!session.cleaned) { session.sendTranscript(payload);
session.observer.onNext(ServerMessage.newBuilder() eventRelay.publish(meetingId, payload == null ? "" : payload);
.setTranscript(TextMessage.newBuilder().setText(payload == null ? "" : payload))
.build());
}
} }
@Override @Override
@ -342,7 +550,9 @@ public class AndroidRealtimeMeetingGrpcService
@Override @Override
public void removeMeetingSession(Long meetingId) { public void removeMeetingSession(Long meetingId) {
sessions.remove(meetingId, session); if (session.cleanupAction != null) {
session.cleanupAction.run();
}
} }
@Override @Override

View File

@ -6,6 +6,11 @@ import jakarta.servlet.http.HttpServletRequest;
public interface AndroidAuthService { public interface AndroidAuthService {
AndroidAuthContext authenticateGrpc(String deviceId, String appVersion, String platform, String userId, String tenantId); AndroidAuthContext authenticateGrpc(String deviceId, String appVersion, String platform, String userId, String tenantId);
default AndroidAuthContext authenticateRealtimeGrpc(String deviceId, String appVersion, String platform,
String userId, String tenantId, String accessToken) {
return authenticateGrpc(deviceId, appVersion, platform, userId, tenantId);
}
AndroidAuthContext authenticateHttp(HttpServletRequest request); AndroidAuthContext authenticateHttp(HttpServletRequest request);
AndroidAuthContext authenticateHttp(HttpServletRequest request, boolean requireRegistered); AndroidAuthContext authenticateHttp(HttpServletRequest request, boolean requireRegistered);

View File

@ -66,6 +66,31 @@ public class AndroidAuthServiceImpl implements AndroidAuthService {
return context; return context;
} }
@Override
public AndroidAuthContext authenticateRealtimeGrpc(String deviceId, String appVersion, String platform,
String userId, String tenantId, String accessToken) {
LicenseEntity license = licenseService.requireValidBoundLicense(deviceId);
DeviceInfoEntity device = requireRegisteredDevice(deviceId);
assertDeviceEnabled(device);
if (!StringUtils.hasText(accessToken)) {
throw new BusinessException(ErrorCodeEnum.UNAUTHORIZED.getCode(), "实时会议 gRPC 缺少访问令牌");
}
InternalAuthCheckResponse authResult = validateToken(accessToken);
if (!license.getTenantId().equals(authResult.getTenantId())) {
throw new BusinessException(ErrorCodeEnum.UNAUTHORIZED.getCode(), "登录租户与设备授权租户不一致");
}
Long requestedUserId = parseOptionalLong(userId, "实时会议 gRPC userId");
Long requestedTenantId = parseOptionalLong(tenantId, "实时会议 gRPC tenantId");
if (requestedUserId != null && !requestedUserId.equals(authResult.getUserId())) {
throw new BusinessException(ErrorCodeEnum.UNAUTHORIZED.getCode(), "实时会议 gRPC userId 与登录用户不一致");
}
if (requestedTenantId != null && !requestedTenantId.equals(authResult.getTenantId())) {
throw new BusinessException(ErrorCodeEnum.UNAUTHORIZED.getCode(), "实时会议 gRPC tenantId 与登录租户不一致");
}
return buildContext("USER_JWT", false, deviceId, null, appVersion, platform, accessToken,
null, authResult, null);
}
@Override @Override
public AndroidAuthContext authenticateHttp(HttpServletRequest request) { public AndroidAuthContext authenticateHttp(HttpServletRequest request) {
return authenticateHttp(request, true, false, false); return authenticateHttp(request, true, false, false);

View File

@ -90,7 +90,7 @@ public class AndroidMeetingPushServiceImpl implements AndroidMeetingPushService
return; return;
} }
Meeting meeting = meetingMapper.selectByIdIgnoreTenant(meetingId); Meeting meeting = meetingMapper.selectByIdIgnoreTenant(meetingId);
if (meeting == null || meeting.getSourceDeviceCode() == null || meeting.getSourceDeviceCode().isBlank()) { if (meeting == null || meeting.getTenantId() == null || meeting.getCreatorId() == null) {
return; return;
} }
PushMessage message = PushMessage.newBuilder() PushMessage message = PushMessage.newBuilder()
@ -101,9 +101,9 @@ public class AndroidMeetingPushServiceImpl implements AndroidMeetingPushService
.setContent(buildStatusChangedContent(meetingId, statusCode)) .setContent(buildStatusChangedContent(meetingId, statusCode))
.setNeedAck(false) .setNeedAck(false)
.build(); .build();
int pushed = androidGatewayPushService.pushToDevice(meeting.getSourceDeviceCode(), message); int pushed = androidGatewayPushService.pushToUser(meeting.getTenantId(), meeting.getCreatorId(), message);
log.info("Android meeting status change push finished, meetingId={}, deviceId={}, statusCode={}, pushedConnections={}", log.info("Android meeting status change push finished, meetingId={}, tenantId={}, userId={}, statusCode={}, pushedConnections={}",
meetingId, meeting.getSourceDeviceCode(), statusCode, pushed); meetingId, meeting.getTenantId(), meeting.getCreatorId(), statusCode, pushed);
} }
private String resolvePendingTitle(Meeting meeting) { private String resolvePendingTitle(Meeting meeting) {

View File

@ -19,6 +19,8 @@ public interface RealtimeMeetingSessionStateService {
boolean activate(Long meetingId, String connectionId); boolean activate(Long meetingId, String connectionId);
boolean isActiveConnection(Long meetingId, String connectionId);
RealtimeMeetingSessionStatusVO getStatus(Long meetingId); RealtimeMeetingSessionStatusVO getStatus(Long meetingId);
Map<Long, RealtimeMeetingSessionStatusVO> getStatuses(List<Long> meetingIds); Map<Long, RealtimeMeetingSessionStatusVO> getStatuses(List<Long> meetingIds);

View File

@ -0,0 +1,10 @@
package com.imeeting.service.biz;
import com.imeeting.dto.biz.RealtimeMeetingSubscriptionSessionVO;
import com.unisbase.security.LoginUser;
public interface RealtimeMeetingSubscriptionSessionService {
RealtimeMeetingSubscriptionSessionVO createForUser(Long meetingId, LoginUser loginUser, String clientIp);
RealtimeMeetingSubscriptionSessionVO createForPreview(Long meetingId, String accessPassword, String clientIp);
}

View File

@ -12,6 +12,7 @@ import com.imeeting.mapper.biz.MeetingMapper;
import com.imeeting.mapper.biz.MeetingTranscriptMapper; import com.imeeting.mapper.biz.MeetingTranscriptMapper;
import com.imeeting.service.biz.RealtimeMeetingSessionStateService; import com.imeeting.service.biz.RealtimeMeetingSessionStateService;
import com.imeeting.support.redis.MeetingLockCache; import com.imeeting.support.redis.MeetingLockCache;
import com.imeeting.support.redis.RealtimeMeetingPublisherLease;
import com.imeeting.support.redis.RealtimeMeetingSessionCache; import com.imeeting.support.redis.RealtimeMeetingSessionCache;
import com.imeeting.support.redis.RealtimeMeetingTranscriptCache; import com.imeeting.support.redis.RealtimeMeetingTranscriptCache;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
@ -34,6 +35,7 @@ public class RealtimeMeetingSessionStateServiceImpl implements RealtimeMeetingSe
private final MeetingTranscriptMapper transcriptMapper; private final MeetingTranscriptMapper transcriptMapper;
private final MeetingMapper meetingMapper; private final MeetingMapper meetingMapper;
private final RealtimeMeetingTranscriptCache realtimeMeetingTranscriptCache; private final RealtimeMeetingTranscriptCache realtimeMeetingTranscriptCache;
private final RealtimeMeetingPublisherLease publisherLease;
@Value("${imeeting.realtime.resume-window-minutes:30}") @Value("${imeeting.realtime.resume-window-minutes:30}")
private String resumeWindowMinutesValue; private String resumeWindowMinutesValue;
@ -114,6 +116,13 @@ public class RealtimeMeetingSessionStateServiceImpl implements RealtimeMeetingSe
throw new RuntimeException("Realtime meeting is already completed"); throw new RuntimeException("Realtime meeting is already completed");
} }
if ("ACTIVE".equals(currentStatus) || Boolean.TRUE.equals(status.getActiveConnection())) { if ("ACTIVE".equals(currentStatus) || Boolean.TRUE.equals(status.getActiveConnection())) {
RealtimeMeetingSessionState state = readState(meetingId);
String activeConnectionId = state == null ? null : state.getActiveConnectionId();
if (activeConnectionId != null && !activeConnectionId.isBlank()
&& !publisherLease.isHeldBy(meetingId, activeConnectionId)) {
pauseState(meetingId, state);
return;
}
throw new RuntimeException("Realtime meeting already has an active connection"); throw new RuntimeException("Realtime meeting already has an active connection");
} }
if ("PAUSED_RESUMABLE".equals(currentStatus) && !Boolean.TRUE.equals(status.getCanResume())) { if ("PAUSED_RESUMABLE".equals(currentStatus) && !Boolean.TRUE.equals(status.getCanResume())) {
@ -126,6 +135,9 @@ public class RealtimeMeetingSessionStateServiceImpl implements RealtimeMeetingSe
if (meetingId == null || connectionId == null || connectionId.isBlank()) { if (meetingId == null || connectionId == null || connectionId.isBlank()) {
return false; return false;
} }
if (!publisherLease.isHeldBy(meetingId, connectionId)) {
return false;
}
RealtimeMeetingSessionState state = getOrCreateState(meetingId); RealtimeMeetingSessionState state = getOrCreateState(meetingId);
if ("COMPLETING".equals(state.getStatus()) || "COMPLETED".equals(state.getStatus())) { if ("COMPLETING".equals(state.getStatus()) || "COMPLETED".equals(state.getStatus())) {
@ -154,6 +166,18 @@ public class RealtimeMeetingSessionStateServiceImpl implements RealtimeMeetingSe
return true; return true;
} }
@Override
public boolean isActiveConnection(Long meetingId, String connectionId) {
if (meetingId == null || connectionId == null || connectionId.isBlank()) {
return false;
}
RealtimeMeetingSessionState state = readState(meetingId);
return state != null
&& "ACTIVE".equals(state.getStatus())
&& connectionId.equals(state.getActiveConnectionId())
&& publisherLease.isHeldBy(meetingId, connectionId);
}
@Override @Override
public RealtimeMeetingSessionStatusVO getStatus(Long meetingId) { public RealtimeMeetingSessionStatusVO getStatus(Long meetingId) {
RealtimeMeetingSessionState state = readState(meetingId); RealtimeMeetingSessionState state = readState(meetingId);
@ -193,7 +217,12 @@ public class RealtimeMeetingSessionStateServiceImpl implements RealtimeMeetingSe
if ("COMPLETING".equals(state.getStatus()) || "COMPLETED".equals(state.getStatus())) { if ("COMPLETING".equals(state.getStatus()) || "COMPLETED".equals(state.getStatus())) {
return toStatusVO(state); return toStatusVO(state);
} }
return pauseState(meetingId, state); String publisherFenceToken = state.getActiveConnectionId();
RealtimeMeetingSessionStatusVO status = pauseState(meetingId, state);
if (publisherFenceToken != null && !publisherFenceToken.isBlank()) {
publisherLease.release(meetingId, publisherFenceToken);
}
return status;
} }
@Override @Override

View File

@ -59,6 +59,9 @@ public class RealtimeMeetingSocketSessionServiceImpl implements RealtimeMeetingS
Meeting meeting = meetingAccessService.requireMeeting(meetingId); Meeting meeting = meetingAccessService.requireMeeting(meetingId);
meetingAccessService.assertCanControlRealtimeMeeting(meeting, loginUser, MeetingTerminalEnum.WEB.getCode()); meetingAccessService.assertCanControlRealtimeMeeting(meeting, loginUser, MeetingTerminalEnum.WEB.getCode());
if (meeting.getCreatorId() == null || !meeting.getCreatorId().equals(loginUser.getUserId())) {
throw new RuntimeException("只有会议创建人可以发布实时音频");
}
realtimeMeetingSessionStateService.initSessionIfAbsent(meetingId, loginUser.getTenantId(), loginUser.getUserId()); realtimeMeetingSessionStateService.initSessionIfAbsent(meetingId, loginUser.getTenantId(), loginUser.getUserId());
realtimeMeetingSessionStateService.assertCanOpenSession(meetingId); realtimeMeetingSessionStateService.assertCanOpenSession(meetingId);

View File

@ -0,0 +1,77 @@
package com.imeeting.service.biz.impl;
import com.imeeting.common.MeetingConstants;
import com.imeeting.dto.biz.RealtimeMeetingSessionStatusVO;
import com.imeeting.dto.biz.RealtimeMeetingSubscriptionSessionData;
import com.imeeting.dto.biz.RealtimeMeetingSubscriptionSessionVO;
import com.imeeting.entity.biz.Meeting;
import com.imeeting.service.biz.MeetingAccessService;
import com.imeeting.service.biz.RealtimeMeetingSessionStateService;
import com.imeeting.service.biz.RealtimeMeetingSubscriptionSessionService;
import com.imeeting.service.realtime.RealtimeMeetingSubscriptionRateLimiter;
import com.imeeting.support.redis.RealtimeMeetingSubscriptionSessionCache;
import com.unisbase.security.LoginUser;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.beans.factory.annotation.Value;
import java.util.UUID;
@Service
@RequiredArgsConstructor
public class RealtimeMeetingSubscriptionSessionServiceImpl implements RealtimeMeetingSubscriptionSessionService {
private static final String SUBSCRIPTION_PATH = "/ws/meeting/realtime/subscribe";
private final MeetingAccessService meetingAccessService;
private final RealtimeMeetingSessionStateService sessionStateService;
private final RealtimeMeetingSubscriptionSessionCache subscriptionSessionCache;
private final RealtimeMeetingSubscriptionRateLimiter rateLimiter;
@Value("${imeeting.realtime.max-subscription-requests-per-user-per-minute:5}")
private int maxSubscriptionRequestsPerUser = 5;
@Value("${imeeting.realtime.max-subscription-requests-per-ip-per-minute:20}")
private int maxSubscriptionRequestsPerIp = 20;
@Override
public RealtimeMeetingSubscriptionSessionVO createForUser(Long meetingId, LoginUser loginUser, String clientIp) {
rateLimiter.check("user:" + loginUser.getUserId(), maxSubscriptionRequestsPerUser);
rateLimiter.check("ip:" + normalizeIp(clientIp), maxSubscriptionRequestsPerIp);
Meeting meeting = meetingAccessService.requireMeeting(meetingId);
meetingAccessService.assertCanViewMeeting(meeting, loginUser);
return create(meeting);
}
@Override
public RealtimeMeetingSubscriptionSessionVO createForPreview(Long meetingId, String accessPassword, String clientIp) {
rateLimiter.check("ip:" + normalizeIp(clientIp), maxSubscriptionRequestsPerIp);
Meeting meeting = meetingAccessService.requireMeetingIgnoreTenant(meetingId);
meetingAccessService.assertCanPreviewMeeting(meeting, accessPassword);
return create(meeting);
}
private RealtimeMeetingSubscriptionSessionVO create(Meeting meeting) {
if (!MeetingConstants.TYPE_REALTIME.equalsIgnoreCase(meeting.getMeetingType())) {
throw new RuntimeException("当前会议不是实时会议");
}
RealtimeMeetingSessionStatusVO status = sessionStateService.getStatus(meeting.getId());
if (status == null || !"ACTIVE".equals(status.getStatus())) {
throw new RuntimeException("会议尚未开始实时转写");
}
String token = UUID.randomUUID().toString().replace("-", "");
RealtimeMeetingSubscriptionSessionData data = new RealtimeMeetingSubscriptionSessionData();
data.setMeetingId(meeting.getId());
subscriptionSessionCache.save(token, data);
RealtimeMeetingSubscriptionSessionVO result = new RealtimeMeetingSubscriptionSessionVO();
result.setSessionToken(token);
result.setPath(SUBSCRIPTION_PATH);
result.setExpiresInSeconds(subscriptionSessionCache.getSessionTtlSeconds());
return result;
}
private String normalizeIp(String clientIp) {
return clientIp == null || clientIp.isBlank() ? "unknown" : clientIp.trim();
}
}

View File

@ -0,0 +1,73 @@
package com.imeeting.service.realtime;
import org.springframework.stereotype.Component;
import java.util.List;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.CopyOnWriteArrayList;
import java.util.concurrent.ConcurrentMap;
import java.util.function.Consumer;
/**
* 线
*/
@Component
public class RealtimeMeetingEventHub {
private final ConcurrentMap<Long, CopyOnWriteArrayList<Consumer<String>>> subscribers = new ConcurrentHashMap<>();
public void subscribe(Long meetingId, Consumer<String> consumer) {
if (meetingId == null || consumer == null) {
return;
}
subscribers.computeIfAbsent(meetingId, ignored -> new CopyOnWriteArrayList<>()).add(consumer);
}
public boolean trySubscribe(Long meetingId, Consumer<String> consumer, int maxSubscribers) {
if (meetingId == null || consumer == null || maxSubscribers < 1) {
return false;
}
CopyOnWriteArrayList<Consumer<String>> meetingSubscribers = subscribers.computeIfAbsent(
meetingId, ignored -> new CopyOnWriteArrayList<>());
synchronized (meetingSubscribers) {
if (meetingSubscribers.size() >= maxSubscribers) {
return false;
}
meetingSubscribers.add(consumer);
return true;
}
}
public void unsubscribe(Long meetingId, Consumer<String> consumer) {
if (meetingId == null || consumer == null) {
return;
}
List<Consumer<String>> meetingSubscribers = subscribers.get(meetingId);
if (meetingSubscribers == null) {
return;
}
meetingSubscribers.remove(consumer);
if (meetingSubscribers.isEmpty()) {
subscribers.remove(meetingId, meetingSubscribers);
}
}
public void publish(Long meetingId, String payload) {
if (meetingId == null || payload == null) {
return;
}
RealtimeMeetingMetrics.transcriptBroadcast();
List<Consumer<String>> meetingSubscribers = subscribers.get(meetingId);
if (meetingSubscribers == null) {
return;
}
for (Consumer<String> subscriber : meetingSubscribers) {
try {
subscriber.accept(payload);
} catch (RuntimeException ignored) {
RealtimeMeetingMetrics.subscriberDeliveryFailed();
// 单个失效订阅者不能阻塞同一会议的其他订阅者。
}
}
}
}

View File

@ -0,0 +1,62 @@
package com.imeeting.service.realtime;
import com.imeeting.support.RedisSupport;
import jakarta.annotation.PostConstruct;
import lombok.RequiredArgsConstructor;
import org.springframework.data.redis.connection.Message;
import org.springframework.data.redis.connection.MessageListener;
import org.springframework.data.redis.listener.RedisMessageListenerContainer;
import org.springframework.data.redis.listener.ChannelTopic;
import org.springframework.stereotype.Component;
import java.nio.charset.StandardCharsets;
import java.util.Base64;
import java.util.UUID;
/**
* Redis Pub/Sub REST
*/
@Component
@RequiredArgsConstructor
public class RealtimeMeetingEventRelay implements MessageListener {
private static final String CHANNEL = "biz:meeting:realtime:events";
private final RedisSupport redisSupport;
private final RealtimeMeetingEventHub eventHub;
private final RedisMessageListenerContainer listenerContainer;
private final String instanceId = UUID.randomUUID().toString();
@PostConstruct
public void subscribe() {
listenerContainer.addMessageListener(this, ChannelTopic.of(CHANNEL));
}
public void publish(Long meetingId, String payload) {
eventHub.publish(meetingId, payload);
if (meetingId == null || payload == null) {
return;
}
String encodedPayload = Base64.getEncoder().encodeToString(payload.getBytes(StandardCharsets.UTF_8));
redisSupport.publish(CHANNEL, instanceId + "|" + meetingId + "|" + encodedPayload);
}
@Override
public void onMessage(Message message, byte[] pattern) {
if (message == null || message.getBody() == null) {
return;
}
String raw = new String(message.getBody(), StandardCharsets.UTF_8);
String[] parts = raw.split("\\|", 3);
if (parts.length != 3 || instanceId.equals(parts[0])) {
return;
}
try {
Long meetingId = Long.valueOf(parts[1]);
String payload = new String(Base64.getDecoder().decode(parts[2]), StandardCharsets.UTF_8);
eventHub.publish(meetingId, payload);
} catch (IllegalArgumentException ignored) {
// 非法广播消息不应影响 Redis 监听线程。
}
}
}

View File

@ -0,0 +1,44 @@
package com.imeeting.service.realtime;
import io.micrometer.core.instrument.Metrics;
/**
* 广使 meetingId
*/
public final class RealtimeMeetingMetrics {
private RealtimeMeetingMetrics() {
}
public static void publisherOpened(String transport) {
Metrics.counter("imeeting.realtime.publisher.opened", "transport", transport).increment();
}
public static void publisherRejected(String transport) {
Metrics.counter("imeeting.realtime.publisher.rejected", "transport", transport).increment();
}
public static void publisherClosed(String transport) {
Metrics.counter("imeeting.realtime.publisher.closed", "transport", transport).increment();
}
public static void subscriberOpened(String transport) {
Metrics.counter("imeeting.realtime.subscriber.opened", "transport", transport).increment();
}
public static void subscriberRejected(String transport) {
Metrics.counter("imeeting.realtime.subscriber.rejected", "transport", transport).increment();
}
public static void subscriberClosed(String transport) {
Metrics.counter("imeeting.realtime.subscriber.closed", "transport", transport).increment();
}
public static void transcriptBroadcast() {
Metrics.counter("imeeting.realtime.transcript.broadcast").increment();
}
public static void subscriberDeliveryFailed() {
Metrics.counter("imeeting.realtime.subscriber.delivery.failed").increment();
}
}

View File

@ -0,0 +1,55 @@
package com.imeeting.service.realtime;
import com.imeeting.common.RedisKeys;
import com.imeeting.support.RedisSupport;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Component;
import org.springframework.beans.factory.annotation.Value;
import java.time.Duration;
import java.util.UUID;
/**
* Redis
*/
@Component
@RequiredArgsConstructor
public class RealtimeMeetingSubscriptionQuota {
private final RedisSupport redisSupport;
private final String instanceId = UUID.randomUUID().toString();
@Value("${imeeting.realtime.max-subscribers-per-meeting:200}")
private int maxSubscribersPerMeeting = 200;
@Value("${imeeting.realtime.global-subscriber-slot-ttl-seconds:120}")
private long slotTtlSeconds = 120;
public String member(String connectionId) {
return instanceId + ":" + connectionId;
}
public boolean tryAcquire(Long meetingId, String member) {
if (meetingId == null || member == null || member.isBlank()) {
return false;
}
return redisSupport.tryAcquireExpiringSlot(
RedisKeys.realtimeMeetingSubscriberSetKey(meetingId),
member,
maxSubscribersPerMeeting,
Duration.ofSeconds(slotTtlSeconds)
);
}
public boolean release(Long meetingId, String member) {
if (meetingId == null || member == null || member.isBlank()) {
return false;
}
return redisSupport.removeExpiringSlot(
RedisKeys.realtimeMeetingSubscriberSetKey(meetingId), member);
}
public boolean renew(Long meetingId, String member) {
return tryAcquire(meetingId, member);
}
}

View File

@ -0,0 +1,30 @@
package com.imeeting.service.realtime;
import com.imeeting.common.RedisKeys;
import com.imeeting.support.RedisSupport;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Component;
import org.springframework.beans.factory.annotation.Value;
import java.time.Duration;
@Component
@RequiredArgsConstructor
public class RealtimeMeetingSubscriptionRateLimiter {
private final RedisSupport redisSupport;
@Value("${imeeting.realtime.subscription-rate-window-seconds:60}")
private long windowSeconds = 60;
public void check(String scope, int maxRequests) {
if (scope == null || scope.isBlank() || maxRequests < 1) {
throw new IllegalArgumentException("Invalid realtime subscription rate limit scope");
}
long current = redisSupport.incrementWithTtl(
RedisKeys.realtimeMeetingSubscriptionRateLimitKey(scope), Duration.ofSeconds(windowSeconds));
if (current > maxRequests) {
throw new RuntimeException("实时订阅请求过于频繁,请稍后重试");
}
}
}

View File

@ -124,6 +124,9 @@ public class LocalRealtimeAsrChannel implements RealtimeAsrChannel {
@Override @Override
public void handleFrontendText(RealtimeAsrChannelContext context, String payload) { public void handleFrontendText(RealtimeAsrChannelContext context, String payload) {
if (!looksLikeStopMessage(payload) && !isActivePublisher(context)) {
return;
}
java.net.http.WebSocket upstreamSocket = getUpstreamSocket(context); java.net.http.WebSocket upstreamSocket = getUpstreamSocket(context);
if (upstreamSocket == null) { if (upstreamSocket == null) {
return; return;
@ -148,6 +151,9 @@ public class LocalRealtimeAsrChannel implements RealtimeAsrChannel {
@Override @Override
public void handleFrontendBinary(RealtimeAsrChannelContext context, byte[] payload) { public void handleFrontendBinary(RealtimeAsrChannelContext context, byte[] payload) {
if (!isActivePublisher(context)) {
return;
}
java.net.http.WebSocket upstreamSocket = getUpstreamSocket(context); java.net.http.WebSocket upstreamSocket = getUpstreamSocket(context);
if (upstreamSocket == null) { if (upstreamSocket == null) {
return; return;
@ -293,6 +299,9 @@ public class LocalRealtimeAsrChannel implements RealtimeAsrChannel {
log.info("start 后开始补发排队音频帧meetingId={}, sessionId={}, frameCount={}", log.info("start 后开始补发排队音频帧meetingId={}, sessionId={}, frameCount={}",
context.getMeetingId(), currentConnectionId(context), pendingFrames.size()); context.getMeetingId(), currentConnectionId(context), pendingFrames.size());
for (byte[] frame : pendingFrames) { for (byte[] frame : pendingFrames) {
if (!isActivePublisher(context)) {
return;
}
sendUpstreamOrdered(context, () -> upstreamSocket.sendBinary(ByteBuffer.wrap(frame), true), "binary-flush"); sendUpstreamOrdered(context, () -> upstreamSocket.sendBinary(ByteBuffer.wrap(frame), true), "binary-flush");
} }
} }
@ -301,6 +310,12 @@ public class LocalRealtimeAsrChannel implements RealtimeAsrChannel {
return context.getConnectionId(); return context.getConnectionId();
} }
private boolean isActivePublisher(RealtimeAsrChannelContext context) {
String connectionId = currentConnectionId(context);
return connectionId != null
&& realtimeMeetingSessionStateService.isActiveConnection(context.getMeetingId(), connectionId);
}
private String ensureStartMessageSessionId(RealtimeAsrChannelContext context, String payload) { private String ensureStartMessageSessionId(RealtimeAsrChannelContext context, String payload) {
try { try {
JsonNode root = OBJECT_MAPPER.readTree(payload); JsonNode root = OBJECT_MAPPER.readTree(payload);
@ -342,7 +357,8 @@ public class LocalRealtimeAsrChannel implements RealtimeAsrChannel {
} }
private boolean tryReconnect(RealtimeAsrChannelContext context, String reason) { private boolean tryReconnect(RealtimeAsrChannelContext context, String reason) {
if (Boolean.TRUE.equals(context.getChannelState().get(STATE_CLOSE_AFTER_END)) || !isFrontendOpen(context)) { if (Boolean.TRUE.equals(context.getChannelState().get(STATE_CLOSE_AFTER_END))
|| !isFrontendOpen(context) || !isActivePublisher(context)) {
return false; return false;
} }
String lastStartMessage = context.getChannelState().get(STATE_LAST_START_MESSAGE) instanceof String value ? value : null; String lastStartMessage = context.getChannelState().get(STATE_LAST_START_MESSAGE) instanceof String value ? value : null;
@ -538,7 +554,7 @@ public class LocalRealtimeAsrChannel implements RealtimeAsrChannel {
log.info("上游 ASR websocket 已打开meetingId={}, sessionId={}, upstream={}", log.info("上游 ASR websocket 已打开meetingId={}, sessionId={}, upstream={}",
context.getMeetingId(), currentConnectionId(context), context.getTargetWsUrl()); context.getMeetingId(), currentConnectionId(context), context.getTargetWsUrl());
String connectionId = currentConnectionId(context); String connectionId = currentConnectionId(context);
if (connectionId == null || !realtimeMeetingSessionStateService.activate(context.getMeetingId(), connectionId)) { if (connectionId == null || !isActivePublisher(context)) {
context.getCallback().sendFrontendError(context.getMeetingId(), "REALTIME_ACTIVE_CONNECTION_EXISTS", "当前会议无法激活这条前端连接"); context.getCallback().sendFrontendError(context.getMeetingId(), "REALTIME_ACTIVE_CONNECTION_EXISTS", "当前会议无法激活这条前端连接");
webSocket.sendClose(CloseStatus.POLICY_VIOLATION.getCode(), "当前会议无法激活这条前端连接"); webSocket.sendClose(CloseStatus.POLICY_VIOLATION.getCode(), "当前会议无法激活这条前端连接");
context.getCallback().closeFrontend(context.getMeetingId(), CloseStatus.POLICY_VIOLATION.withReason("当前会议无法激活这条前端连接")); context.getCallback().closeFrontend(context.getMeetingId(), CloseStatus.POLICY_VIOLATION.withReason("当前会议无法激活这条前端连接"));
@ -558,6 +574,11 @@ public class LocalRealtimeAsrChannel implements RealtimeAsrChannel {
public java.util.concurrent.CompletionStage<?> onText(java.net.http.WebSocket webSocket, CharSequence data, boolean last) { public java.util.concurrent.CompletionStage<?> onText(java.net.http.WebSocket webSocket, CharSequence data, boolean last) {
textBuffer.append(data); textBuffer.append(data);
if (last) { if (last) {
if (!isActivePublisher(context)) {
textBuffer.setLength(0);
webSocket.request(1);
return COMPLETED;
}
int count = upstreamTextCount.incrementAndGet(); int count = upstreamTextCount.incrementAndGet();
String upstreamPayload = textBuffer.toString(); String upstreamPayload = textBuffer.toString();
realtimeMeetingTranscriptCacheService.mergeUpstreamMessage(context.getMeetingId(), upstreamPayload); realtimeMeetingTranscriptCacheService.mergeUpstreamMessage(context.getMeetingId(), upstreamPayload);
@ -588,6 +609,11 @@ public class LocalRealtimeAsrChannel implements RealtimeAsrChannel {
data.get(chunk); data.get(chunk);
binaryBuffer.writeBytes(chunk); binaryBuffer.writeBytes(chunk);
if (last) { if (last) {
if (!isActivePublisher(context)) {
binaryBuffer.reset();
webSocket.request(1);
return COMPLETED;
}
int count = upstreamBinaryCount.incrementAndGet(); int count = upstreamBinaryCount.incrementAndGet();
try { try {
context.getCallback().sendFrontendBinary(context.getMeetingId(), binaryBuffer.toByteArray()); context.getCallback().sendFrontendBinary(context.getMeetingId(), binaryBuffer.toByteArray());

View File

@ -91,7 +91,7 @@ public class TencentRealtimeAsrChannel implements RealtimeAsrChannel {
@Override @Override
public void connect(RealtimeAsrChannelContext context) throws Exception { public void connect(RealtimeAsrChannelContext context) throws Exception {
String connectionId = currentConnectionId(context); String connectionId = currentConnectionId(context);
if (connectionId == null || !realtimeMeetingSessionStateService.activate(context.getMeetingId(), connectionId)) { if (connectionId == null || !isActivePublisher(context)) {
context.getCallback().sendFrontendError(context.getMeetingId(), "REALTIME_ACTIVE_CONNECTION_EXISTS", "当前会议无法激活这条前端连接"); context.getCallback().sendFrontendError(context.getMeetingId(), "REALTIME_ACTIVE_CONNECTION_EXISTS", "当前会议无法激活这条前端连接");
context.getCallback().closeFrontend(context.getMeetingId(), CloseStatus.POLICY_VIOLATION.withReason("当前会议无法激活这条前端连接")); context.getCallback().closeFrontend(context.getMeetingId(), CloseStatus.POLICY_VIOLATION.withReason("当前会议无法激活这条前端连接"));
return; return;
@ -107,6 +107,9 @@ public class TencentRealtimeAsrChannel implements RealtimeAsrChannel {
@Override @Override
public void handleFrontendText(RealtimeAsrChannelContext context, String payload) { public void handleFrontendText(RealtimeAsrChannelContext context, String payload) {
if (!looksLikeStopMessage(payload) && !isActivePublisher(context)) {
return;
}
if (looksLikeStartMessage(payload)) { if (looksLikeStartMessage(payload)) {
startRecognizerIfNecessary(context); startRecognizerIfNecessary(context);
return; return;
@ -119,6 +122,9 @@ public class TencentRealtimeAsrChannel implements RealtimeAsrChannel {
@Override @Override
public void handleFrontendBinary(RealtimeAsrChannelContext context, byte[] payload) { public void handleFrontendBinary(RealtimeAsrChannelContext context, byte[] payload) {
if (!isActivePublisher(context)) {
return;
}
SpeakerRecognizer recognizer = getRecognizer(context); SpeakerRecognizer recognizer = getRecognizer(context);
if (payload == null || payload.length == 0) { if (payload == null || payload.length == 0) {
return; return;
@ -184,6 +190,9 @@ public class TencentRealtimeAsrChannel implements RealtimeAsrChannel {
} }
private void startRecognizerIfNecessary(RealtimeAsrChannelContext context) { private void startRecognizerIfNecessary(RealtimeAsrChannelContext context) {
if (!isActivePublisher(context)) {
return;
}
synchronized (context.getChannelState()) { synchronized (context.getChannelState()) {
if (Boolean.TRUE.equals(context.getChannelState().get(STATE_STARTED))) { if (Boolean.TRUE.equals(context.getChannelState().get(STATE_STARTED))) {
return; return;
@ -350,6 +359,9 @@ public class TencentRealtimeAsrChannel implements RealtimeAsrChannel {
if (response == null || response.getSentences() == null || response.getSentences().getSentenceList() == null) { if (response == null || response.getSentences() == null || response.getSentences().getSentenceList() == null) {
return; return;
} }
if (!isActivePublisher(context)) {
return;
}
try { try {
rememberSpeakerContext(context, response); rememberSpeakerContext(context, response);
String cachePayload = buildCachePayload(response, forceFinal); String cachePayload = buildCachePayload(response, forceFinal);
@ -414,6 +426,10 @@ public class TencentRealtimeAsrChannel implements RealtimeAsrChannel {
} }
List<byte[]> pendingFrames = (List<byte[]>) list; List<byte[]> pendingFrames = (List<byte[]>) list;
for (byte[] frame : pendingFrames) { for (byte[] frame : pendingFrames) {
if (!isActivePublisher(context)) {
pendingFrames.clear();
return;
}
if (frame != null && frame.length > 0) { if (frame != null && frame.length > 0) {
recognizer.write(frame); recognizer.write(frame);
} }
@ -454,6 +470,12 @@ public class TencentRealtimeAsrChannel implements RealtimeAsrChannel {
return context.getConnectionId(); return context.getConnectionId();
} }
private boolean isActivePublisher(RealtimeAsrChannelContext context) {
String connectionId = currentConnectionId(context);
return connectionId != null
&& realtimeMeetingSessionStateService.isActiveConnection(context.getMeetingId(), connectionId);
}
private static boolean looksLikeStartMessage(String payload) { private static boolean looksLikeStartMessage(String payload) {
if (payload == null || payload.isBlank()) { if (payload == null || payload.isBlank()) {
return false; return false;

View File

@ -2,6 +2,7 @@ package com.imeeting.support;
import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.ObjectMapper;
import io.lettuce.core.SetArgs; import io.lettuce.core.SetArgs;
import io.lettuce.core.ScriptOutputType;
import io.lettuce.core.api.StatefulRedisConnection; import io.lettuce.core.api.StatefulRedisConnection;
import io.lettuce.core.api.sync.RedisCommands; import io.lettuce.core.api.sync.RedisCommands;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
@ -103,6 +104,104 @@ public class RedisSupport {
} }
} }
public boolean deleteIfValue(String key, String expectedValue) {
if (key == null || key.isBlank() || expectedValue == null) {
return false;
}
try {
Long deleted = commands().eval(
"if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end",
ScriptOutputType.INTEGER,
new String[]{key},
expectedValue
);
return deleted != null && deleted > 0;
} catch (Exception ex) {
log.warn("Conditional Redis delete failed, key={}", key, ex);
return false;
}
}
public boolean expireIfValue(String key, String expectedValue, Duration ttl) {
if (key == null || key.isBlank() || expectedValue == null || ttl == null) {
return false;
}
try {
Long updated = commands().eval(
"if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('pexpire', KEYS[1], ARGV[2]) else return 0 end",
ScriptOutputType.INTEGER,
new String[]{key},
expectedValue,
String.valueOf(ttl.toMillis())
);
return updated != null && updated > 0;
} catch (Exception ex) {
log.warn("Conditional Redis expire failed, key={}", key, ex);
return false;
}
}
public void publish(String channel, String message) {
try {
commands().publish(channel, message);
} catch (Exception ex) {
log.warn("Publish Redis message failed, channel={}", channel, ex);
}
}
public long incrementWithTtl(String key, Duration ttl) {
try {
Long value = commands().eval(
"local value = redis.call('incr', KEYS[1]); if value == 1 then redis.call('pexpire', KEYS[1], ARGV[1]); end; return value",
ScriptOutputType.INTEGER,
new String[]{key},
String.valueOf(ttl.toMillis())
);
return value == null ? 0L : value;
} catch (Exception ex) {
throw new RuntimeException("Redis rate limit increment failed, key=" + key, ex);
}
}
public boolean tryAcquireExpiringSlot(String key, String member, int maxMembers, Duration ttl) {
if (key == null || key.isBlank() || member == null || member.isBlank() || maxMembers < 1 || ttl == null) {
return false;
}
try {
Long acquired = commands().eval(
"local now = tonumber(ARGV[1]); local ttl = tonumber(ARGV[2]); "
+ "redis.call('zremrangebyscore', KEYS[1], '-inf', now); "
+ "if redis.call('zscore', KEYS[1], ARGV[4]) then "
+ "redis.call('zadd', KEYS[1], now + ttl, ARGV[4]); redis.call('pexpire', KEYS[1], ttl); return 1 end; "
+ "if redis.call('zcard', KEYS[1]) >= tonumber(ARGV[3]) then return 0 end; "
+ "redis.call('zadd', KEYS[1], now + ttl, ARGV[4]); redis.call('pexpire', KEYS[1], ttl); return 1",
ScriptOutputType.INTEGER,
new String[]{key},
String.valueOf(System.currentTimeMillis()),
String.valueOf(ttl.toMillis()),
String.valueOf(maxMembers),
member
);
return acquired != null && acquired > 0;
} catch (Exception ex) {
log.warn("Acquire Redis set slot failed, key={}", key, ex);
return false;
}
}
public boolean removeExpiringSlot(String key, String member) {
if (key == null || key.isBlank() || member == null || member.isBlank()) {
return false;
}
try {
Long removed = commands().zrem(key, member);
return removed != null && removed > 0;
} catch (Exception ex) {
log.warn("Remove Redis set member failed, key={}", key, ex);
return false;
}
}
public void removeFromSetQuietly(String key, String... members) { public void removeFromSetQuietly(String key, String... members) {
if (members == null || members.length == 0) { if (members == null || members.length == 0) {
return; return;

View File

@ -0,0 +1,72 @@
package com.imeeting.support.redis;
import com.imeeting.common.RedisKeys;
import com.imeeting.support.RedisSupport;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import java.time.Duration;
/**
* Web gRPC
*/
@Component
public class RealtimeMeetingPublisherLease {
private static final long DEFAULT_LEASE_TTL_SECONDS = 120L;
private final RedisSupport redisSupport;
@Value("${imeeting.realtime.publisher-lease-ttl-seconds:120}")
private long leaseTtlSeconds = DEFAULT_LEASE_TTL_SECONDS;
public RealtimeMeetingPublisherLease(RedisSupport redisSupport) {
this.redisSupport = redisSupport;
}
public boolean tryAcquire(Long meetingId, String publisherFenceToken) {
if (meetingId == null || publisherFenceToken == null || publisherFenceToken.isBlank()) {
return false;
}
return redisSupport.setIfAbsentOrThrow(
RedisKeys.realtimeMeetingPublisherLeaseKey(meetingId),
publisherFenceToken,
leaseTtl()
);
}
public boolean release(Long meetingId, String publisherFenceToken) {
if (meetingId == null || publisherFenceToken == null || publisherFenceToken.isBlank()) {
return false;
}
return redisSupport.deleteIfValue(
RedisKeys.realtimeMeetingPublisherLeaseKey(meetingId),
publisherFenceToken
);
}
public boolean renew(Long meetingId, String publisherFenceToken) {
if (meetingId == null || publisherFenceToken == null || publisherFenceToken.isBlank()) {
return false;
}
return redisSupport.expireIfValue(
RedisKeys.realtimeMeetingPublisherLeaseKey(meetingId),
publisherFenceToken,
leaseTtl()
);
}
public boolean isHeldBy(Long meetingId, String publisherFenceToken) {
if (meetingId == null || publisherFenceToken == null || publisherFenceToken.isBlank()) {
return false;
}
return publisherFenceToken.equals(redisSupport.getStringQuietly(
RedisKeys.realtimeMeetingPublisherLeaseKey(meetingId)
));
}
private Duration leaseTtl() {
long seconds = leaseTtlSeconds > 0 ? leaseTtlSeconds : DEFAULT_LEASE_TTL_SECONDS;
return Duration.ofSeconds(seconds);
}
}

View File

@ -0,0 +1,39 @@
package com.imeeting.support.redis;
import com.imeeting.common.RedisKeys;
import com.imeeting.dto.biz.RealtimeMeetingSubscriptionSessionData;
import com.imeeting.support.RedisSupport;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Component;
import java.time.Duration;
@Component
@RequiredArgsConstructor
public class RealtimeMeetingSubscriptionSessionCache {
private static final Duration SESSION_TTL = Duration.ofMinutes(10);
private final RedisSupport redisSupport;
public void save(String sessionToken, RealtimeMeetingSubscriptionSessionData data) {
if (sessionToken == null || sessionToken.isBlank() || data == null || data.getMeetingId() == null) {
return;
}
redisSupport.setJson(RedisKeys.realtimeMeetingSubscriptionSessionKey(sessionToken), data, SESSION_TTL);
}
public RealtimeMeetingSubscriptionSessionData get(String sessionToken) {
if (sessionToken == null || sessionToken.isBlank()) {
return null;
}
return redisSupport.getJsonQuietly(
RedisKeys.realtimeMeetingSubscriptionSessionKey(sessionToken),
RealtimeMeetingSubscriptionSessionData.class
);
}
public long getSessionTtlSeconds() {
return SESSION_TTL.toSeconds();
}
}

View File

@ -10,8 +10,12 @@ import com.imeeting.service.realtime.RealtimeAsrChannelCallback;
import com.imeeting.service.realtime.RealtimeAsrChannelContext; import com.imeeting.service.realtime.RealtimeAsrChannelContext;
import com.imeeting.service.realtime.RealtimeAsrChannelFactory; import com.imeeting.service.realtime.RealtimeAsrChannelFactory;
import com.imeeting.service.realtime.RealtimeMeetingAudioStorageService; import com.imeeting.service.realtime.RealtimeMeetingAudioStorageService;
import com.imeeting.service.realtime.RealtimeMeetingEventHub;
import com.imeeting.service.realtime.RealtimeMeetingEventRelay;
import com.imeeting.service.realtime.RealtimeMeetingMetrics;
import com.imeeting.service.realtime.RealtimeMeetingTranscriptCacheService; import com.imeeting.service.realtime.RealtimeMeetingTranscriptCacheService;
import com.imeeting.service.realtime.impl.LocalRealtimeAsrChannel; import com.imeeting.service.realtime.impl.LocalRealtimeAsrChannel;
import com.imeeting.support.redis.RealtimeMeetingPublisherLease;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component; import org.springframework.stereotype.Component;
@ -31,6 +35,7 @@ import java.time.Duration;
import java.util.Arrays; import java.util.Arrays;
import java.util.HashMap; import java.util.HashMap;
import java.util.Map; import java.util.Map;
import java.util.UUID;
import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ConcurrentMap; import java.util.concurrent.ConcurrentMap;
import java.util.concurrent.atomic.AtomicInteger; import java.util.concurrent.atomic.AtomicInteger;
@ -52,6 +57,9 @@ public class RealtimeMeetingProxyWebSocketHandler extends AbstractWebSocketHandl
private final RealtimeMeetingAudioStorageService realtimeMeetingAudioStorageService; private final RealtimeMeetingAudioStorageService realtimeMeetingAudioStorageService;
private final RealtimeMeetingTranscriptCacheService realtimeMeetingTranscriptCacheService; private final RealtimeMeetingTranscriptCacheService realtimeMeetingTranscriptCacheService;
private final RealtimeAsrChannelFactory realtimeAsrChannelFactory; private final RealtimeAsrChannelFactory realtimeAsrChannelFactory;
private final RealtimeMeetingPublisherLease publisherLease;
private final RealtimeMeetingEventHub eventHub;
private final RealtimeMeetingEventRelay eventRelay;
private final ConcurrentMap<Long, MeetingChannelSession> meetingSessions = new ConcurrentHashMap<>(); private final ConcurrentMap<Long, MeetingChannelSession> meetingSessions = new ConcurrentHashMap<>();
private final ConcurrentMap<Long, Object> meetingLocks = new ConcurrentHashMap<>(); private final ConcurrentMap<Long, Object> meetingLocks = new ConcurrentHashMap<>();
@ -81,14 +89,19 @@ public class RealtimeMeetingProxyWebSocketHandler extends AbstractWebSocketHandl
@Override @Override
protected void handleTextMessage(WebSocketSession session, TextMessage message) { protected void handleTextMessage(WebSocketSession session, TextMessage message) {
MeetingChannelSession meetingSession = getMeetingSession(session);
// 过滤前端发来的心跳保活消息,不转发给上游 ASR 服务 // 过滤前端发来的心跳保活消息,不转发给上游 ASR 服务
if (looksLikeKeepaliveMessage(message.getPayload())) { if (looksLikeKeepaliveMessage(message.getPayload())) {
if (!renewAndCheckActivePublisher(session, meetingSession)) {
closeStalePublisher(session);
return;
}
log.debug("Frontend keepalive received, ignored: meetingId={}, sessionId={}", log.debug("Frontend keepalive received, ignored: meetingId={}, sessionId={}",
session.getAttributes().get(ATTR_MEETING_ID), session.getId()); session.getAttributes().get(ATTR_MEETING_ID), session.getId());
return; return;
} }
MeetingChannelSession meetingSession = getMeetingSession(session); if (meetingSession == null || !meetingSession.isChannelOpen()
if (meetingSession == null || !meetingSession.isChannelOpen()) { || !renewAndCheckActivePublisher(session, meetingSession)) {
log.warn("前端文本消息已忽略:上游 ASR 连接不可用meetingId={}, sessionId={}", log.warn("前端文本消息已忽略:上游 ASR 连接不可用meetingId={}, sessionId={}",
session.getAttributes().get(ATTR_MEETING_ID), session.getId()); session.getAttributes().get(ATTR_MEETING_ID), session.getId());
return; return;
@ -103,7 +116,8 @@ public class RealtimeMeetingProxyWebSocketHandler extends AbstractWebSocketHandl
@Override @Override
protected void handleBinaryMessage(WebSocketSession session, BinaryMessage message) { protected void handleBinaryMessage(WebSocketSession session, BinaryMessage message) {
MeetingChannelSession meetingSession = getMeetingSession(session); MeetingChannelSession meetingSession = getMeetingSession(session);
if (meetingSession == null || !meetingSession.isChannelOpen()) { if (meetingSession == null || !meetingSession.isChannelOpen()
|| !renewAndCheckActivePublisher(session, meetingSession)) {
log.warn("前端音频帧已忽略:上游 ASR 连接不可用meetingId={}, sessionId={}", log.warn("前端音频帧已忽略:上游 ASR 连接不可用meetingId={}, sessionId={}",
session.getAttributes().get(ATTR_MEETING_ID), session.getId()); session.getAttributes().get(ATTR_MEETING_ID), session.getId());
return; return;
@ -145,7 +159,7 @@ public class RealtimeMeetingProxyWebSocketHandler extends AbstractWebSocketHandl
Object meetingIdValue = session.getAttributes().get(ATTR_MEETING_ID); Object meetingIdValue = session.getAttributes().get(ATTR_MEETING_ID);
if (meetingIdValue instanceof Long meetingId) { if (meetingIdValue instanceof Long meetingId) {
detachFrontend(meetingId, session.getId()); detachFrontend(meetingId, session.getId());
realtimeMeetingSessionStateService.pauseByDisconnect(meetingId, session.getId()); RealtimeMeetingMetrics.publisherClosed("websocket");
} }
realtimeMeetingAudioStorageService.closeSession(session.getId()); realtimeMeetingAudioStorageService.closeSession(session.getId());
} }
@ -159,6 +173,7 @@ public class RealtimeMeetingProxyWebSocketHandler extends AbstractWebSocketHandl
return; return;
} }
meetingSession.channel.closeMeeting(meetingSession.context); meetingSession.channel.closeMeeting(meetingSession.context);
publisherLease.release(meetingId, meetingSession.publisherFenceToken);
} }
private void attachFrontendSession(RealtimeSocketSessionData sessionData, private void attachFrontendSession(RealtimeSocketSessionData sessionData,
@ -173,23 +188,36 @@ public class RealtimeMeetingProxyWebSocketHandler extends AbstractWebSocketHandl
String previousSessionId = meetingSession.context.getRawSession() == null String previousSessionId = meetingSession.context.getRawSession() == null
? null ? null
: meetingSession.context.getRawSession().getId(); : meetingSession.context.getRawSession().getId();
String previousFenceToken = meetingSession.publisherFenceToken;
meetingSession.clearFrontendIfClosed(); meetingSession.clearFrontendIfClosed();
if (previousSessionId != null && !meetingSession.hasOpenFrontend()) { if (previousSessionId != null && !meetingSession.hasOpenFrontend()) {
realtimeMeetingSessionStateService.pauseByDisconnect(meetingId, previousSessionId); realtimeMeetingSessionStateService.pauseByDisconnect(meetingId, previousFenceToken);
publisherLease.release(meetingId, previousFenceToken);
} }
if (meetingSession.hasOpenFrontend()) { if (meetingSession.hasOpenFrontend()) {
RealtimeMeetingMetrics.publisherRejected("websocket");
sendFrontendError(frontendSession, "REALTIME_ACTIVE_CONNECTION_EXISTS", "当前会议已有活跃前端连接"); sendFrontendError(frontendSession, "REALTIME_ACTIVE_CONNECTION_EXISTS", "当前会议已有活跃前端连接");
frontendSession.close(CloseStatus.POLICY_VIOLATION.withReason("已存在活跃的前端连接")); frontendSession.close(CloseStatus.POLICY_VIOLATION.withReason("已存在活跃的前端连接"));
realtimeMeetingAudioStorageService.closeSession(rawSession.getId()); realtimeMeetingAudioStorageService.closeSession(rawSession.getId());
return; return;
} }
if (!realtimeMeetingSessionStateService.activate(meetingId, rawSession.getId())) { String publisherFenceToken = UUID.randomUUID().toString();
if (!publisherLease.tryAcquire(meetingId, publisherFenceToken)) {
RealtimeMeetingMetrics.publisherRejected("websocket");
sendFrontendError(frontendSession, "REALTIME_ACTIVE_CONNECTION_EXISTS", "当前会议已有活跃发布连接");
frontendSession.close(CloseStatus.POLICY_VIOLATION.withReason("已有活跃发布连接"));
realtimeMeetingAudioStorageService.closeSession(rawSession.getId());
return;
}
if (!realtimeMeetingSessionStateService.activate(meetingId, publisherFenceToken)) {
publisherLease.release(meetingId, publisherFenceToken);
sendFrontendError(frontendSession, "REALTIME_ACTIVE_CONNECTION_REJECTED", "当前状态下无法继续会议"); sendFrontendError(frontendSession, "REALTIME_ACTIVE_CONNECTION_REJECTED", "当前状态下无法继续会议");
frontendSession.close(CloseStatus.POLICY_VIOLATION.withReason("当前状态下无法继续会议")); frontendSession.close(CloseStatus.POLICY_VIOLATION.withReason("当前状态下无法继续会议"));
realtimeMeetingAudioStorageService.closeSession(rawSession.getId()); realtimeMeetingAudioStorageService.closeSession(rawSession.getId());
return; return;
} }
meetingSession.bindFrontend(rawSession, frontendSession); meetingSession.bindFrontend(rawSession, frontendSession, publisherFenceToken);
RealtimeMeetingMetrics.publisherOpened("websocket");
reused = true; reused = true;
} else { } else {
RealtimeAsrChannel channel = realtimeAsrChannelFactory.getRequired(sessionData.getProvider()); RealtimeAsrChannel channel = realtimeAsrChannelFactory.getRequired(sessionData.getProvider());
@ -197,12 +225,31 @@ public class RealtimeMeetingProxyWebSocketHandler extends AbstractWebSocketHandl
context.setMeetingId(meetingId); context.setMeetingId(meetingId);
context.setProvider(realtimeAsrChannelFactory.normalizeProvider(sessionData.getProvider())); context.setProvider(realtimeAsrChannelFactory.normalizeProvider(sessionData.getProvider()));
context.setTargetWsUrl(sessionData.getTargetWsUrl()); context.setTargetWsUrl(sessionData.getTargetWsUrl());
context.setCallback(new HandlerChannelCallback()); context.setCallback(new HandlerChannelCallback(context));
context.bindFrontendSession(rawSession, frontendSession); context.bindFrontendSession(rawSession, frontendSession);
String publisherFenceToken = UUID.randomUUID().toString();
context.setConnectionId(publisherFenceToken);
context.getChannelState().put("modelCode", sessionData.getModelCode()); context.getChannelState().put("modelCode", sessionData.getModelCode());
context.getChannelState().put("mediaConfig", sessionData.getMediaConfig()); context.getChannelState().put("mediaConfig", sessionData.getMediaConfig());
meetingSession = new MeetingChannelSession(meetingId, channel, context); meetingSession = new MeetingChannelSession(meetingId, channel, context, publisherFenceToken);
meetingSessions.put(meetingId, meetingSession); meetingSessions.put(meetingId, meetingSession);
if (!publisherLease.tryAcquire(meetingId, publisherFenceToken)) {
meetingSessions.remove(meetingId, meetingSession);
RealtimeMeetingMetrics.publisherRejected("websocket");
sendFrontendError(frontendSession, "REALTIME_ACTIVE_CONNECTION_EXISTS", "当前会议已有活跃发布连接");
frontendSession.close(CloseStatus.POLICY_VIOLATION.withReason("已有活跃发布连接"));
realtimeMeetingAudioStorageService.closeSession(rawSession.getId());
return;
}
if (!realtimeMeetingSessionStateService.activate(meetingId, publisherFenceToken)) {
publisherLease.release(meetingId, publisherFenceToken);
meetingSessions.remove(meetingId, meetingSession);
sendFrontendError(frontendSession, "REALTIME_ACTIVE_CONNECTION_REJECTED", "当前状态下无法继续会议");
frontendSession.close(CloseStatus.POLICY_VIOLATION.withReason("当前状态下无法继续会议"));
realtimeMeetingAudioStorageService.closeSession(rawSession.getId());
return;
}
RealtimeMeetingMetrics.publisherOpened("websocket");
} }
} }
@ -217,12 +264,16 @@ public class RealtimeMeetingProxyWebSocketHandler extends AbstractWebSocketHandl
} catch (InterruptedException ex) { } catch (InterruptedException ex) {
Thread.currentThread().interrupt(); Thread.currentThread().interrupt();
removeMeetingSession(meetingId, meetingSession); removeMeetingSession(meetingId, meetingSession);
publisherLease.release(meetingId, meetingSession.publisherFenceToken);
realtimeMeetingSessionStateService.pauseByDisconnect(meetingId, meetingSession.publisherFenceToken);
log.error("连接上游 ASR websocket 时被中断meetingId={}, sessionId={}", meetingId, rawSession.getId(), ex); log.error("连接上游 ASR websocket 时被中断meetingId={}, sessionId={}", meetingId, rawSession.getId(), ex);
sendFrontendError(frontendSession, "REALTIME_UPSTREAM_CONNECT_INTERRUPTED", "连接上游 ASR 服务时被中断"); sendFrontendError(frontendSession, "REALTIME_UPSTREAM_CONNECT_INTERRUPTED", "连接上游 ASR 服务时被中断");
realtimeMeetingAudioStorageService.closeSession(rawSession.getId()); realtimeMeetingAudioStorageService.closeSession(rawSession.getId());
frontendSession.close(CloseStatus.SERVER_ERROR.withReason("连接上游服务时被中断")); frontendSession.close(CloseStatus.SERVER_ERROR.withReason("连接上游服务时被中断"));
} catch (Exception ex) { } catch (Exception ex) {
removeMeetingSession(meetingId, meetingSession); removeMeetingSession(meetingId, meetingSession);
publisherLease.release(meetingId, meetingSession.publisherFenceToken);
realtimeMeetingSessionStateService.pauseByDisconnect(meetingId, meetingSession.publisherFenceToken);
log.warn("连接上游 ASR websocket 失败meetingId={}, provider={}, target={}", log.warn("连接上游 ASR websocket 失败meetingId={}, provider={}, target={}",
meetingId, sessionData.getProvider(), sessionData.getTargetWsUrl(), ex); meetingId, sessionData.getProvider(), sessionData.getTargetWsUrl(), ex);
sendFrontendError(frontendSession, "REALTIME_UPSTREAM_CONNECT_FAILED", "连接上游 ASR 服务失败"); sendFrontendError(frontendSession, "REALTIME_UPSTREAM_CONNECT_FAILED", "连接上游 ASR 服务失败");
@ -265,8 +316,10 @@ public class RealtimeMeetingProxyWebSocketHandler extends AbstractWebSocketHandl
synchronized (lockForMeeting(meetingId)) { synchronized (lockForMeeting(meetingId)) {
if (meetingSession.context.getRawSession() != null && meetingSession.context.getRawSession().getId().equals(sessionId)) { if (meetingSession.context.getRawSession() != null && meetingSession.context.getRawSession().getId().equals(sessionId)) {
meetingSession.channel.onFrontendDetached(meetingSession.context); meetingSession.channel.onFrontendDetached(meetingSession.context);
publisherLease.release(meetingId, meetingSession.publisherFenceToken);
realtimeMeetingSessionStateService.pauseByDisconnect(meetingId, meetingSession.publisherFenceToken);
meetingSession.detachFrontend(sessionId);
} }
meetingSession.detachFrontend(sessionId);
} }
} }
@ -278,6 +331,26 @@ public class RealtimeMeetingProxyWebSocketHandler extends AbstractWebSocketHandl
return meetingSessions.get(meetingId); return meetingSessions.get(meetingId);
} }
private boolean renewAndCheckActivePublisher(WebSocketSession session, MeetingChannelSession meetingSession) {
if (meetingSession == null || meetingSession.context.getRawSession() == null
|| !session.getId().equals(meetingSession.context.getRawSession().getId())) {
return false;
}
return publisherLease.renew(meetingSession.meetingId, meetingSession.publisherFenceToken)
&& realtimeMeetingSessionStateService.isActiveConnection(
meetingSession.meetingId, meetingSession.publisherFenceToken);
}
private void closeStalePublisher(WebSocketSession session) {
try {
if (session.isOpen()) {
session.close(CloseStatus.POLICY_VIOLATION.withReason("发布连接已失效"));
}
} catch (Exception ex) {
log.debug("关闭失效发布连接失败sessionId={}", session.getId(), ex);
}
}
void removeMeetingSession(Long meetingId) { void removeMeetingSession(Long meetingId) {
synchronized (lockForMeeting(meetingId)) { synchronized (lockForMeeting(meetingId)) {
meetingSessions.remove(meetingId); meetingSessions.remove(meetingId);
@ -290,6 +363,18 @@ public class RealtimeMeetingProxyWebSocketHandler extends AbstractWebSocketHandl
} }
} }
private void removeMeetingSession(Long meetingId, RealtimeAsrChannelContext context) {
synchronized (lockForMeeting(meetingId)) {
MeetingChannelSession meetingSession = meetingSessions.get(meetingId);
if (meetingSession == null || meetingSession.context != context) {
return;
}
realtimeMeetingSessionStateService.pauseByDisconnect(meetingId, meetingSession.publisherFenceToken);
publisherLease.release(meetingId, meetingSession.publisherFenceToken);
meetingSessions.remove(meetingId, meetingSession);
}
}
private Object lockForMeeting(Long meetingId) { private Object lockForMeeting(Long meetingId) {
return meetingLocks.computeIfAbsent(meetingId, ignored -> new Object()); return meetingLocks.computeIfAbsent(meetingId, ignored -> new Object());
} }
@ -363,15 +448,21 @@ public class RealtimeMeetingProxyWebSocketHandler extends AbstractWebSocketHandl
private final Long meetingId; private final Long meetingId;
private final RealtimeAsrChannel channel; private final RealtimeAsrChannel channel;
private final RealtimeAsrChannelContext context; private final RealtimeAsrChannelContext context;
private String publisherFenceToken;
private MeetingChannelSession(Long meetingId, RealtimeAsrChannel channel, RealtimeAsrChannelContext context) { private MeetingChannelSession(Long meetingId, RealtimeAsrChannel channel,
RealtimeAsrChannelContext context, String publisherFenceToken) {
this.meetingId = meetingId; this.meetingId = meetingId;
this.channel = channel; this.channel = channel;
this.context = context; this.context = context;
this.publisherFenceToken = publisherFenceToken;
} }
private void bindFrontend(WebSocketSession rawSession, ConcurrentWebSocketSessionDecorator frontendSession) { private void bindFrontend(WebSocketSession rawSession, ConcurrentWebSocketSessionDecorator frontendSession,
String publisherFenceToken) {
context.bindFrontendSession(rawSession, frontendSession); context.bindFrontendSession(rawSession, frontendSession);
context.setConnectionId(publisherFenceToken);
this.publisherFenceToken = publisherFenceToken;
} }
private void detachFrontend(String sessionId) { private void detachFrontend(String sessionId) {
@ -399,6 +490,12 @@ public class RealtimeMeetingProxyWebSocketHandler extends AbstractWebSocketHandl
} }
private final class HandlerChannelCallback implements RealtimeAsrChannelCallback { private final class HandlerChannelCallback implements RealtimeAsrChannelCallback {
private final RealtimeAsrChannelContext context;
private HandlerChannelCallback(RealtimeAsrChannelContext context) {
this.context = context;
}
@Override @Override
public void onChannelOpen(Long meetingId) throws Exception { public void onChannelOpen(Long meetingId) throws Exception {
MeetingChannelSession meetingSession = meetingSessions.get(meetingId); MeetingChannelSession meetingSession = meetingSessions.get(meetingId);
@ -421,6 +518,7 @@ public class RealtimeMeetingProxyWebSocketHandler extends AbstractWebSocketHandl
if (frontendSession != null && frontendSession.isOpen()) { if (frontendSession != null && frontendSession.isOpen()) {
frontendSession.sendMessage(new TextMessage(payload)); frontendSession.sendMessage(new TextMessage(payload));
} }
eventRelay.publish(meetingId, payload == null ? "" : payload);
} }
@Override @Override
@ -449,7 +547,7 @@ public class RealtimeMeetingProxyWebSocketHandler extends AbstractWebSocketHandl
@Override @Override
public void removeMeetingSession(Long meetingId) { public void removeMeetingSession(Long meetingId) {
RealtimeMeetingProxyWebSocketHandler.this.removeMeetingSession(meetingId); RealtimeMeetingProxyWebSocketHandler.this.removeMeetingSession(meetingId, context);
} }
@Override @Override

View File

@ -0,0 +1,133 @@
package com.imeeting.websocket;
import com.imeeting.dto.biz.RealtimeMeetingSessionStatusVO;
import com.imeeting.dto.biz.RealtimeMeetingSubscriptionSessionData;
import com.imeeting.service.biz.RealtimeMeetingSessionStateService;
import com.imeeting.service.realtime.RealtimeMeetingEventHub;
import com.imeeting.service.realtime.RealtimeMeetingTranscriptCacheService;
import com.imeeting.service.realtime.RealtimeMeetingSubscriptionQuota;
import com.imeeting.service.realtime.RealtimeMeetingMetrics;
import com.imeeting.service.realtime.impl.LocalRealtimeAsrChannel;
import com.imeeting.support.redis.RealtimeMeetingSubscriptionSessionCache;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Component;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.web.socket.CloseStatus;
import org.springframework.web.socket.TextMessage;
import org.springframework.web.socket.WebSocketSession;
import org.springframework.web.socket.handler.AbstractWebSocketHandler;
import org.springframework.web.socket.handler.ConcurrentWebSocketSessionDecorator;
import java.net.URI;
import java.net.URLDecoder;
import java.nio.charset.StandardCharsets;
import java.util.Arrays;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.function.Consumer;
@Component
@RequiredArgsConstructor
public class RealtimeMeetingSubscriptionWebSocketHandler extends AbstractWebSocketHandler {
private final RealtimeMeetingSubscriptionSessionCache subscriptionSessionCache;
private final RealtimeMeetingSessionStateService sessionStateService;
private final RealtimeMeetingTranscriptCacheService transcriptCacheService;
private final RealtimeMeetingEventHub eventHub;
private final RealtimeMeetingSubscriptionQuota subscriptionQuota;
private final Map<String, Subscription> subscriptions = new ConcurrentHashMap<>();
@Value("${imeeting.realtime.max-subscribers-per-meeting:200}")
private int maxSubscribersPerMeeting = 200;
@Override
public void afterConnectionEstablished(WebSocketSession session) throws Exception {
String token = extractQueryParam(session.getUri(), "sessionToken");
RealtimeMeetingSubscriptionSessionData data = subscriptionSessionCache.get(token);
if (data == null || data.getMeetingId() == null) {
session.close(CloseStatus.POLICY_VIOLATION.withReason("实时订阅会话无效"));
return;
}
RealtimeMeetingSessionStatusVO status = sessionStateService.getStatus(data.getMeetingId());
if (status == null || !"ACTIVE".equals(status.getStatus())) {
session.close(CloseStatus.POLICY_VIOLATION.withReason("会议尚未开始"));
return;
}
ConcurrentWebSocketSessionDecorator decorated = new ConcurrentWebSocketSessionDecorator(session, 15_000, 1_048_576);
Consumer<String> consumer = payload -> send(decorated, payload);
String member = subscriptionQuota.member(session.getId());
if (!subscriptionQuota.tryAcquire(data.getMeetingId(), member)) {
RealtimeMeetingMetrics.subscriberRejected("websocket");
session.close(CloseStatus.POLICY_VIOLATION.withReason("会议实时订阅人数已达上限"));
return;
}
if (!eventHub.trySubscribe(data.getMeetingId(), consumer, maxSubscribersPerMeeting)) {
subscriptionQuota.release(data.getMeetingId(), member);
RealtimeMeetingMetrics.subscriberRejected("websocket");
session.close(CloseStatus.POLICY_VIOLATION.withReason("会议实时订阅人数已达上限"));
return;
}
Subscription subscription = new Subscription(data.getMeetingId(), consumer, member);
subscriptions.put(session.getId(), subscription);
RealtimeMeetingMetrics.subscriberOpened("websocket");
send(decorated, "{\"type\":\"state\",\"data\":{\"status\":\"ACTIVE\"}}");
replayFinalTranscripts(data.getMeetingId(), decorated);
}
@Override
public void afterConnectionClosed(WebSocketSession session, CloseStatus status) {
Subscription subscription = subscriptions.remove(session.getId());
if (subscription != null) {
eventHub.unsubscribe(subscription.meetingId(), subscription.consumer());
subscriptionQuota.release(subscription.meetingId(), subscription.member());
RealtimeMeetingMetrics.subscriberClosed("websocket");
}
}
@Override
protected void handleTextMessage(WebSocketSession session, TextMessage message) {
// 只读订阅通道不处理任何客户端业务消息。
}
@Scheduled(fixedDelay = 60_000)
public void renewGlobalQuotaSlots() {
subscriptions.values().forEach(subscription ->
subscriptionQuota.renew(subscription.meetingId(), subscription.member()));
}
private void replayFinalTranscripts(Long meetingId, ConcurrentWebSocketSessionDecorator session) {
try {
for (var item : transcriptCacheService.listOrderedItems(meetingId)) {
send(session, LocalRealtimeAsrChannel.buildFrontendTranscriptMessage(item));
}
} catch (Exception ignored) {
// 历史回放失败不影响后续实时事件订阅。
}
}
private void send(ConcurrentWebSocketSessionDecorator session, String payload) {
try {
if (session.isOpen()) {
session.sendMessage(new TextMessage(payload == null ? "" : payload));
}
} catch (Exception ignored) {
// 后续关闭回调会移除失效订阅者。
}
}
private String extractQueryParam(URI uri, String key) {
if (uri == null || uri.getQuery() == null || uri.getQuery().isBlank()) {
return null;
}
return Arrays.stream(uri.getQuery().split("&"))
.map(item -> item.split("=", 2))
.filter(parts -> parts.length == 2 && key.equals(parts[0]))
.map(parts -> URLDecoder.decode(parts[1], StandardCharsets.UTF_8))
.findFirst()
.orElse(null);
}
private record Subscription(Long meetingId, Consumer<String> consumer, String member) {
}
}

View File

@ -230,6 +230,12 @@ export interface RealtimeSocketSessionVO {
startMessage: Record<string, any>; startMessage: Record<string, any>;
} }
export interface RealtimeMeetingSubscriptionSessionVO {
sessionToken: string;
path: string;
expiresInSeconds: number;
}
export interface RealtimeSocketSessionRequest { export interface RealtimeSocketSessionRequest {
asrModelId: number; asrModelId: number;
mode?: string; mode?: string;
@ -261,9 +267,10 @@ export const createRealtimeMeeting = (data: CreateRealtimeMeetingCommand) => {
}; };
export const getRealtimeMeetingSessionStatus = (meetingId: number) => { export const getRealtimeMeetingSessionStatus = (meetingId: number, options?: { suppressErrorToast?: boolean }) => {
return http.get<{ code: string; data: RealtimeMeetingSessionStatus; msg: string }>( return http.get<{ code: string; data: RealtimeMeetingSessionStatus; msg: string }>(
`/api/biz/meeting/${meetingId}/realtime/session-status` `/api/biz/meeting/${meetingId}/realtime/session-status`,
{suppressErrorToast: options?.suppressErrorToast}
); );
}; };
@ -298,6 +305,16 @@ export const completeRealtimeMeeting = (meetingId: number, data?: { audioUrl?: s
); );
}; };
export const createRealtimeMeetingSubscriptionSession = (meetingId: number, options?: {
suppressErrorToast?: boolean
}) => {
return http.post<{ code: string; data: RealtimeMeetingSubscriptionSessionVO; msg: string }>(
`/api/biz/meeting/${meetingId}/realtime/subscription-session`,
{},
{suppressErrorToast: options?.suppressErrorToast},
);
};
export const deleteMeeting = (id: number) => { export const deleteMeeting = (id: number) => {
return http.delete<{ code: string; data: boolean; msg: string }>( return http.delete<{ code: string; data: boolean; msg: string }>(
`/api/biz/meeting/${id}` `/api/biz/meeting/${id}`

View File

@ -30,6 +30,7 @@ import {
getMeetingChapters, getMeetingChapters,
getMeetingProgress, getMeetingProgress,
getMeetingShareConfig, getMeetingShareConfig,
getRealtimeMeetingSessionStatus,
getTranscripts, getTranscripts,
MeetingChapterVO, MeetingChapterVO,
MeetingProgress, MeetingProgress,
@ -1438,7 +1439,6 @@ const MeetingDetail: React.FC = () => {
} }
return false; return false;
}, [meeting]); }, [meeting]);
const aiCatalogEnabled = meeting?.aiCatalogEnabled !== false; const aiCatalogEnabled = meeting?.aiCatalogEnabled !== false;
const canRetrySummary = isOwner const canRetrySummary = isOwner
&& transcripts.length > 0 && transcripts.length > 0
@ -1597,6 +1597,28 @@ const MeetingDetail: React.FC = () => {
loadUsers(); loadUsers();
}, [id, fetchData]); }, [id, fetchData]);
useEffect(() => {
if (!meeting || meeting.meetingType !== 'REALTIME') {
return;
}
let disposed = false;
const redirectToRealtimeSession = async () => {
try {
const response = await getRealtimeMeetingSessionStatus(meeting.id, {suppressErrorToast: true});
const status = response.data.data?.status;
if (!disposed && status !== 'COMPLETING' && status !== 'COMPLETED') {
navigate(`/meeting-live-session/${meeting.id}`, {replace: true});
}
} catch {
// Redis 状态暂时不可用时保留详情页,避免错误跳转。
}
};
void redirectToRealtimeSession();
return () => {
disposed = true;
};
}, [meeting?.id, meeting?.meetingType, navigate]);
useEffect(() => { useEffect(() => {
setSelectedKeywords((current) => current.filter((item) => analysis.keywords.includes(item))); setSelectedKeywords((current) => current.filter((item) => analysis.keywords.includes(item)));
}, [analysis.keywords]); }, [analysis.keywords]);
@ -2488,7 +2510,7 @@ const MeetingDetail: React.FC = () => {
)} )}
> >
<div className="meeting-detail-workspace"> <div className="meeting-detail-workspace">
{meeting.status === 0 || meeting.status === 1 ? ( {(meeting.status === 0 || meeting.status === 1) ? (
<> <>
<div style={{ display: 'none' }}> <div style={{ display: 'none' }}>
<MeetingProgressDisplay <MeetingProgressDisplay

View File

@ -776,19 +776,10 @@ const Meetings: React.FC = () => {
navigate("/meetings/" + meeting.id); navigate("/meetings/" + meeting.id);
return; return;
} }
if (!canControlRealtimeFromCurrentPlatform(meeting)) {
message.info(`该实时会议需在${getRealtimeSourceLabel(meeting)}继续,当前仅支持查看详情`);
navigate("/meetings/" + meeting.id);
return;
}
if (canOpenRealtimeSession(meeting.realtimeSessionStatus)) { if (canOpenRealtimeSession(meeting.realtimeSessionStatus)) {
navigate("/meeting-live-session/" + meeting.id); navigate("/meeting-live-session/" + meeting.id);
return; return;
} }
if (!canManageMeeting(meeting)) {
navigate("/meetings/" + meeting.id);
return;
}
try { try {
const res = await getRealtimeMeetingSessionStatus(meeting.id); const res = await getRealtimeMeetingSessionStatus(meeting.id);
if (canOpenRealtimeSession(res.data?.data?.status)) { if (canOpenRealtimeSession(res.data?.data?.status)) {

View File

@ -13,9 +13,11 @@ import {
import { useNavigate, useParams } from "react-router-dom"; import { useNavigate, useParams } from "react-router-dom";
import PageContainer from "@/components/shared/PageContainer"; import PageContainer from "@/components/shared/PageContainer";
import SectionCard from "@/components/shared/SectionCard"; import SectionCard from "@/components/shared/SectionCard";
import {isRealtimeMeetingPublisher} from "../../utils/realtimePublisher";
import "./RealtimeAsrSession.css"; import "./RealtimeAsrSession.css";
import { import {
completeRealtimeMeeting, completeRealtimeMeeting,
createRealtimeMeetingSubscriptionSession,
getMeetingDetail, getMeetingDetail,
getRealtimeMeetingSessionStatus, getRealtimeMeetingSessionStatus,
getTranscripts, getTranscripts,
@ -29,16 +31,6 @@ import {
const SAMPLE_RATE = 16000; const SAMPLE_RATE = 16000;
const CHUNK_SIZE = 1280; const CHUNK_SIZE = 1280;
const CURRENT_PLATFORM = "WEB" as const; const CURRENT_PLATFORM = "WEB" as const;
const MEETING_SOURCE_LABELS: Record<string, string> = {
WINDOWS: "Windows",
MACOS: "macOS",
KYLIN: "麒麟",
UOS: "统信",
HARMONYOS: "鸿蒙",
WEB: "Web端",
CUSTOM_TERMINAL: "定制终端",
ANDROID: "定制终端",
};
type WsSpeaker = string | { name?: string; user_id?: string | number } | undefined; type WsSpeaker = string | { name?: string; user_id?: string | number } | undefined;
type WsMessage = { type WsMessage = {
@ -193,7 +185,16 @@ function buildTranscriptCardId(sentenceKey?: string, sentenceId?: number) {
return `sentence-${sentenceId}`; return `sentence-${sentenceId}`;
} }
function buildRealtimeProxyWsUrl(socketSession: RealtimeSocketSessionVO) { function getCurrentUserId() {
try {
const profileStr = sessionStorage.getItem("userProfile");
return profileStr ? JSON.parse(profileStr).userId : undefined;
} catch {
return undefined;
}
}
function buildRealtimeProxyWsUrl(socketSession: Pick<RealtimeSocketSessionVO, "path" | "sessionToken">) {
const protocol = window.location.protocol === "https:" ? "wss" : "ws"; const protocol = window.location.protocol === "https:" ? "wss" : "ws";
return `${protocol}://${window.location.host}${socketSession.path}?sessionToken=${encodeURIComponent(socketSession.sessionToken)}`; return `${protocol}://${window.location.host}${socketSession.path}?sessionToken=${encodeURIComponent(socketSession.sessionToken)}`;
} }
@ -250,6 +251,7 @@ export function RealtimeAsrSession() {
const [sessionStatus, setSessionStatus] = useState<RealtimeMeetingSessionStatus | null>(null); const [sessionStatus, setSessionStatus] = useState<RealtimeMeetingSessionStatus | null>(null);
const transcriptRef = useRef<HTMLDivElement | null>(null); const transcriptRef = useRef<HTMLDivElement | null>(null);
const wsRef = useRef<WebSocket | null>(null); const wsRef = useRef<WebSocket | null>(null);
const subscriptionWsRef = useRef<WebSocket | null>(null);
const wsHeartbeatRef = useRef<ReturnType<typeof setInterval> | null>(null); const wsHeartbeatRef = useRef<ReturnType<typeof setInterval> | null>(null);
const audioContextRef = useRef<AudioContext | null>(null); const audioContextRef = useRef<AudioContext | null>(null);
const processorRef = useRef<ScriptProcessorNode | null>(null); const processorRef = useRef<ScriptProcessorNode | null>(null);
@ -267,7 +269,8 @@ export function RealtimeAsrSession() {
); );
const statusColor = recording ? "#1677ff" : connecting || finishing ? "#faad14" : "#94a3b8"; const statusColor = recording ? "#1677ff" : connecting || finishing ? "#faad14" : "#94a3b8";
const hasRemoteActiveConnection = Boolean(sessionStatus?.activeConnection) && !recording && !connecting; const hasRemoteActiveConnection = Boolean(sessionStatus?.activeConnection) && !recording && !connecting;
const canControlCurrentMeeting = !meeting?.meetingSource || meeting.meetingSource === CURRENT_PLATFORM; const canControlCurrentMeeting = isRealtimeMeetingPublisher(meeting?.creatorId, getCurrentUserId())
&& (!meeting?.meetingSource || meeting.meetingSource === CURRENT_PLATFORM);
useEffect(() => { useEffect(() => {
if (!meetingId || Number.isNaN(meetingId)) { if (!meetingId || Number.isNaN(meetingId)) {
@ -280,22 +283,20 @@ export function RealtimeAsrSession() {
const stored = sessionStorage.getItem(getSessionKey(meetingId)); const stored = sessionStorage.getItem(getSessionKey(meetingId));
const parsedDraft = stored ? JSON.parse(stored) : null; const parsedDraft = stored ? JSON.parse(stored) : null;
const [detailRes, transcriptRes, statusRes] = await Promise.all([ const [detailRes, transcriptRes] = await Promise.all([
getMeetingDetail(meetingId), getMeetingDetail(meetingId),
getTranscripts(meetingId), getTranscripts(meetingId),
getRealtimeMeetingSessionStatus(meetingId),
]); ]);
const detail = detailRes.data.data; const detail = detailRes.data.data;
const realtimeStatus = statusRes.data.data;
if (detail.meetingType && detail.meetingType !== "REALTIME") { if (detail.meetingType && detail.meetingType !== "REALTIME") {
message.warning("当前会议不是实时会议,无法进入实时控制页"); message.warning("当前会议不是实时会议,无法进入实时控制页");
navigate(`/meetings/${meetingId}`); navigate(`/meetings/${meetingId}`);
return; return;
} }
if (detail.meetingSource && detail.meetingSource !== CURRENT_PLATFORM) { const statusRes = await getRealtimeMeetingSessionStatus(meetingId);
const sourceLabel = MEETING_SOURCE_LABELS[detail.meetingSource] ?? detail.meetingSource; const realtimeStatus = statusRes.data.data;
message.warning(`该实时会议需在${sourceLabel}继续,当前仅支持查看详情`); if (realtimeStatus?.status === "COMPLETING" || realtimeStatus?.status === "COMPLETED") {
navigate(`/meetings/${meetingId}`); navigate(`/meetings/${meetingId}`, {replace: true});
return; return;
} }
setMeeting(detail); setMeeting(detail);
@ -306,7 +307,15 @@ export function RealtimeAsrSession() {
if (resolvedDraft) { if (resolvedDraft) {
sessionStorage.setItem(getSessionKey(meetingId), JSON.stringify(resolvedDraft)); sessionStorage.setItem(getSessionKey(meetingId), JSON.stringify(resolvedDraft));
} }
if (realtimeStatus?.status === "PAUSED_RESUMABLE") { const canControl = isRealtimeMeetingPublisher(detail.creatorId, getCurrentUserId())
&& (!detail.meetingSource || detail.meetingSource === CURRENT_PLATFORM);
if (!canControl && realtimeStatus?.status === "ACTIVE") {
setStatusText("实时转写中");
} else if (!canControl && (realtimeStatus?.status === "PAUSED_RESUMABLE" || realtimeStatus?.status === "PAUSED_EMPTY")) {
setStatusText("实时转写已暂停,等待主持人继续");
} else if (!canControl && realtimeStatus?.status === "IDLE") {
setStatusText("等待主持人开始实时转写");
} else if (realtimeStatus?.status === "PAUSED_RESUMABLE") {
setStatusText(`已暂停,可在 ${Math.max(1, Math.ceil((realtimeStatus.remainingSeconds || 0) / 60))} 分钟内继续`); setStatusText(`已暂停,可在 ${Math.max(1, Math.ceil((realtimeStatus.remainingSeconds || 0) / 60))} 分钟内继续`);
} else if (realtimeStatus?.status === "PAUSED_EMPTY") { } else if (realtimeStatus?.status === "PAUSED_EMPTY") {
setStatusText("已暂停,可继续识别"); setStatusText("已暂停,可继续识别");
@ -356,6 +365,9 @@ export function RealtimeAsrSession() {
}, [streamingText, transcripts]); }, [streamingText, transcripts]);
useEffect(() => { useEffect(() => {
if (!canControlCurrentMeeting) {
return;
}
const handlePageHide = () => { const handlePageHide = () => {
if (!meetingId || completeOnceRef.current) { if (!meetingId || completeOnceRef.current) {
return; return;
@ -377,7 +389,7 @@ export function RealtimeAsrSession() {
window.addEventListener("pagehide", handlePageHide); window.addEventListener("pagehide", handlePageHide);
return () => window.removeEventListener("pagehide", handlePageHide); return () => window.removeEventListener("pagehide", handlePageHide);
}, [meetingId]); }, [canControlCurrentMeeting, meetingId]);
// 组件卸载切换路由时统一清理所有资源防止定时器、WebSocket、音频管道泄漏 // 组件卸载切换路由时统一清理所有资源防止定时器、WebSocket、音频管道泄漏
useEffect(() => { useEffect(() => {
@ -449,18 +461,123 @@ export function RealtimeAsrSession() {
endTime: normalized.endTime, endTime: normalized.endTime,
final: true, final: true,
}; };
if (normalized.sentenceKey || (normalized.sentenceId !== undefined && normalized.sentenceId !== null)) { const index = next.findIndex((item) =>
const index = next.findIndex((item) => item.id === cardId); item.id === cardId
if (index >= 0) { || (item.final
next[index] = {...next[index], ...nextCard}; && item.text === nextCard.text
return next; && item.startTime === nextCard.startTime
} && item.endTime === nextCard.endTime),
);
if (index >= 0) {
next[index] = {...next[index], ...nextCard, id: next[index].id};
} else {
next.push(nextCard);
} }
next.push(nextCard); return next.sort((left, right) => (left.startTime ?? Number.MAX_SAFE_INTEGER) - (right.startTime ?? Number.MAX_SAFE_INTEGER));
return next;
}); });
}; };
useEffect(() => {
if (!meeting || canControlCurrentMeeting) {
return;
}
let disposed = false;
let timer: ReturnType<typeof window.setTimeout> | null = null;
let subscriptionOpening = false;
const closeSubscription = () => {
const socket = subscriptionWsRef.current;
if (socket) {
socket.onclose = null;
socket.close();
subscriptionWsRef.current = null;
}
};
const connect = async () => {
if (subscriptionOpening || subscriptionWsRef.current?.readyState === WebSocket.OPEN
|| subscriptionWsRef.current?.readyState === WebSocket.CONNECTING) {
return;
}
subscriptionOpening = true;
try {
const response = await createRealtimeMeetingSubscriptionSession(meeting.id, {suppressErrorToast: true});
const session = response.data.data;
if (disposed || !session?.sessionToken) {
return;
}
const socket = new WebSocket(buildRealtimeProxyWsUrl(session));
subscriptionWsRef.current = socket;
socket.onmessage = (event) => {
try {
const normalized = normalizeWsMessage(JSON.parse(String(event.data)) as WsMessage);
if (!normalized) {
return;
}
const speaker = resolveSpeaker(normalized.speaker);
if (normalized.isFinal) {
upsertTranscriptCard(normalized, speaker);
setStreamingText("");
setStreamingSpeaker("Unknown");
} else {
setStreamingText(normalized.text);
setStreamingSpeaker(speaker.speakerName);
}
} catch {
// 忽略订阅通道的非转写消息与异常数据。
}
};
socket.onclose = () => {
if (subscriptionWsRef.current === socket) {
subscriptionWsRef.current = null;
}
};
} catch {
// 状态轮询会在发布端恢复 ACTIVE 后自动重新订阅。
} finally {
subscriptionOpening = false;
}
};
const poll = async () => {
let status: RealtimeMeetingSessionStatus | undefined;
try {
const response = await getRealtimeMeetingSessionStatus(meeting.id, {suppressErrorToast: true});
status = response.data.data;
if (disposed || !status) {
return;
}
setSessionStatus(status);
if (status.status === "COMPLETING" || status.status === "COMPLETED") {
navigate(`/meetings/${meeting.id}`, {replace: true});
return;
}
if (status.status === "ACTIVE") {
setStatusText("实时转写中");
await connect();
} else {
closeSubscription();
setStreamingText("");
setStatusText(status.status === "IDLE" ? "等待主持人开始实时转写" : "实时转写已暂停,等待主持人继续");
}
} catch {
// 保持只读页面可见,下一次轮询会重试状态和订阅连接。
} finally {
if (!disposed && status?.status !== "COMPLETING" && status?.status !== "COMPLETED") {
timer = window.setTimeout(() => {
void poll();
}, 3000);
}
}
};
void poll();
return () => {
disposed = true;
if (timer) {
window.clearTimeout(timer);
}
closeSubscription();
};
}, [canControlCurrentMeeting, meeting, navigate]);
const handleFatalRealtimeError = async (errorMessage: string) => { const handleFatalRealtimeError = async (errorMessage: string) => {
setConnecting(false); setConnecting(false);
setRecording(false); setRecording(false);
@ -838,13 +955,13 @@ export function RealtimeAsrSession() {
description={ description={
<span className="realtime-session-meta"> <span className="realtime-session-meta">
<Badge color={statusColor} text={<span>{statusText}</span>} /> <Badge color={statusColor} text={<span>{statusText}</span>} />
{sessionDraft ? <span> <strong>{totalTranscriptChars}</strong></span> : null} <span> <strong>{totalTranscriptChars}</strong></span>
</span> </span>
} }
contentClassName="realtime-session-section-content" contentClassName="realtime-session-section-content"
> >
<div className="realtime-session-body"> <div className="realtime-session-body">
{!sessionDraft ? ( {canControlCurrentMeeting && !sessionDraft ? (
<div className="realtime-session-alert-wrap"> <div className="realtime-session-alert-wrap">
<Alert <Alert
type="warning" type="warning"
@ -861,7 +978,9 @@ export function RealtimeAsrSession() {
<div className="realtime-session-empty"> <div className="realtime-session-empty">
<Empty <Empty
image={Empty.PRESENTED_IMAGE_SIMPLE} image={Empty.PRESENTED_IMAGE_SIMPLE}
description={hasRemoteActiveConnection ? "当前会议已有活跃连接,请先关闭旧连接后再继续。" : "会议已就绪,点击下方按钮开始识别。"} description={canControlCurrentMeeting
? (hasRemoteActiveConnection ? "当前会议已有活跃连接,请先关闭旧连接后再继续。" : "会议已就绪,点击下方按钮开始识别。")
: (sessionStatus?.status === "ACTIVE" ? "正在等待实时转写内容。" : "等待主持人开始或恢复实时转写。")}
/> />
</div> </div>
) : ( ) : (
@ -899,7 +1018,7 @@ export function RealtimeAsrSession() {
)} )}
</div> </div>
<div className="realtime-control-bar"> {canControlCurrentMeeting && <div className="realtime-control-bar">
<div className="realtime-control-status"> <div className="realtime-control-status">
<div <div
className={recording ? "realtime-control-orb recording-orb" : "realtime-control-orb"} className={recording ? "realtime-control-orb recording-orb" : "realtime-control-orb"}
@ -950,7 +1069,7 @@ export function RealtimeAsrSession() {
/> />
)} )}
</Space> </Space>
</div> </div>}
</div> </div>
)} )}
</div> </div>

View File

@ -11,6 +11,7 @@ const ForgotPasswordPage = lazy(() => import("@/pages/forgot-password"));
const MeetingsPage = lazy(() => import("@/pages/meetings")); const MeetingsPage = lazy(() => import("@/pages/meetings"));
const MeetingDetailPage = lazy(() => import("@/pages/meeting-detail")); const MeetingDetailPage = lazy(() => import("@/pages/meeting-detail"));
const MeetingPreviewPage = lazy(() => import("@/pages/meeting-preview")); const MeetingPreviewPage = lazy(() => import("@/pages/meeting-preview"));
const RealtimeMeetingPreviewPage = lazy(() => import("@/pages/realtime-meeting-preview"));
const ProfilePage = lazy(() => import("@/pages/profile")); const ProfilePage = lazy(() => import("@/pages/profile"));
const PasswordPage = lazy(() => import("@/pages/password")); const PasswordPage = lazy(() => import("@/pages/password"));
const ScanConfirmPage = lazy(() => import("@/pages/scan-confirm")); const ScanConfirmPage = lazy(() => import("@/pages/scan-confirm"));
@ -27,6 +28,7 @@ export default function App() {
<Route path="/login" element={<LoginPage />} /> <Route path="/login" element={<LoginPage />} />
<Route path="/forgot-password" element={<ForgotPasswordPage/>}/> <Route path="/forgot-password" element={<ForgotPasswordPage/>}/>
<Route path="/meetings/:id/preview" element={<MeetingPreviewPage />} /> <Route path="/meetings/:id/preview" element={<MeetingPreviewPage />} />
<Route path="/meetings/:id/realtime-preview" element={<RealtimeMeetingPreviewPage/>}/>
<Route <Route
path="/meetings" path="/meetings"

View File

@ -3,6 +3,8 @@ import type {
MeetingChapterVO, MeetingChapterVO,
MeetingPageResult, MeetingPageResult,
MeetingPreviewAccessVO, MeetingPreviewAccessVO,
RealtimeMeetingSessionStatusVO,
RealtimeMeetingSubscriptionSessionVO,
MeetingTranscriptVO, MeetingTranscriptVO,
MeetingVO, MeetingVO,
PublicMeetingPreviewVO, PublicMeetingPreviewVO,
@ -67,6 +69,31 @@ export const getPublicMeetingPreview = (id: number, accessPassword?: string) =>
}); });
}; };
export const createPublicRealtimeSubscriptionSession = (
id: number,
accessPassword?: string,
options?: { suppressErrorToast?: boolean },
) => {
return http.post<{ code: string; data: RealtimeMeetingSubscriptionSessionVO; msg: string }>(
`/api/public/meetings/${id}/preview/realtime-subscription-session`,
undefined,
{
params: accessPassword ? {accessPassword} : undefined,
suppressErrorToast: options?.suppressErrorToast,
},
);
};
export const getPublicRealtimeMeetingSessionStatus = (id: number, accessPassword?: string) => {
return http.get<{ code: string; data: RealtimeMeetingSessionStatusVO; msg: string }>(
`/api/public/meetings/${id}/preview/realtime/session-status`,
{
params: accessPassword ? {accessPassword} : undefined,
suppressErrorToast: true,
},
);
};
export const createPublicDeviceMeetingBySession = (sessionId: string) => { export const createPublicDeviceMeetingBySession = (sessionId: string) => {
return http.post<{ code: string; data: boolean; msg: string }>(`/api/biz/public-device-meetings/sessions/${sessionId}/create`); return http.post<{ code: string; data: boolean; msg: string }>(`/api/biz/public-device-meetings/sessions/${sessionId}/create`);
}; };

View File

@ -2,7 +2,7 @@ import { App, Button, Card, Input, Space, Typography } from "antd";
import { useEffect, useMemo, useState } from "react"; import { useEffect, useMemo, useState } from "react";
import { useNavigate, useParams, useSearchParams } from "react-router-dom"; import { useNavigate, useParams, useSearchParams } from "react-router-dom";
import { getMeetingPreviewAccess, getPublicMeetingPreview } from "@/api/meeting"; import {getMeetingPreviewAccess, getPublicMeetingPreview, getPublicRealtimeMeetingSessionStatus} from "@/api/meeting";
import LoadingScreen from "@/components/LoadingScreen"; import LoadingScreen from "@/components/LoadingScreen";
import PageHeader from "@/components/PageHeader"; import PageHeader from "@/components/PageHeader";
import MeetingPreviewView from "@/components/preview/MeetingPreviewView"; import MeetingPreviewView from "@/components/preview/MeetingPreviewView";
@ -36,10 +36,25 @@ export default function MeetingPreviewPage() {
const loadPreview = async (password?: string) => { const loadPreview = async (password?: string) => {
const previewResp = await getPublicMeetingPreview(meetingId, password); const previewResp = await getPublicMeetingPreview(meetingId, password);
setMeeting(previewResp.data.data.meeting); const preview = previewResp.data.data;
setTranscripts(previewResp.data.data.transcripts || []); if (preview.meeting.meetingType === "REALTIME") {
setChapters(previewResp.data.data.chapters || []); try {
const statusResp = await getPublicRealtimeMeetingSessionStatus(meetingId, password);
const status = statusResp.data.data.status;
if (status !== "COMPLETING" && status !== "COMPLETED") {
const query = password ? `?accessPassword=${encodeURIComponent(password)}` : "";
navigate(`/meetings/${meetingId}/realtime-preview${query}`, {replace: true});
return true;
}
} catch {
// 状态查询异常时继续进入原预览页。
}
}
setMeeting(preview.meeting);
setTranscripts(preview.transcripts || []);
setChapters(preview.chapters || []);
setPasswordVerified(true); setPasswordVerified(true);
return false;
}; };
useEffect(() => { useEffect(() => {
@ -63,7 +78,10 @@ export default function MeetingPreviewPage() {
if (presetAccessPassword) { if (presetAccessPassword) {
try { try {
await loadPreview(presetAccessPassword); const redirected = await loadPreview(presetAccessPassword);
if (redirected) {
return;
}
setAccessPassword(presetAccessPassword); setAccessPassword(presetAccessPassword);
return; return;
} catch { } catch {
@ -84,7 +102,10 @@ export default function MeetingPreviewPage() {
} }
setLoading(true); setLoading(true);
try { try {
await loadPreview(accessPassword.trim()); const redirected = await loadPreview(accessPassword.trim());
if (redirected) {
return;
}
message.success("访问校验通过"); message.success("访问校验通过");
} catch { } catch {
message.error("访问密码错误"); message.error("访问密码错误");

View File

@ -107,6 +107,22 @@ export interface MeetingPreviewAccessVO {
passwordRequired: boolean; passwordRequired: boolean;
} }
export interface RealtimeMeetingSubscriptionSessionVO {
sessionToken: string;
path: string;
expiresInSeconds: number;
}
export interface RealtimeMeetingSessionStatusVO {
meetingId: number;
status: "IDLE" | "ACTIVE" | "PAUSED_EMPTY" | "PAUSED_RESUMABLE" | "COMPLETING" | "COMPLETED";
hasTranscript?: boolean;
canResume?: boolean;
remainingSeconds?: number;
resumeExpireAt?: number;
activeConnection?: boolean;
}
export interface UpdateMeetingBasicCommand { export interface UpdateMeetingBasicCommand {
meetingId: number; meetingId: number;
title?: string; title?: string;

View File

@ -1 +1 @@
{"root":["./src/app.tsx","./src/main.tsx","./src/api/auth.ts","./src/api/http.ts","./src/api/meeting.ts","./src/api/platform.ts","./src/api/user.ts","./src/components/bottomnav.tsx","./src/components/loadingscreen.tsx","./src/components/pageheader.tsx","./src/components/platformconfigprovider.tsx","./src/components/preview/meetingpreviewview.tsx","./src/components/preview/meetinganalysis.ts","./src/hooks/usepagetitle.ts","./src/layouts/mainlayout.tsx","./src/pages/forgot-password/index.tsx","./src/pages/login/index.tsx","./src/pages/meeting-detail/index.tsx","./src/pages/meeting-preview/index.tsx","./src/pages/meetings/index.tsx","./src/pages/password/index.tsx","./src/pages/profile/index.tsx","./src/pages/scan-confirm/index.tsx","./src/routes/protectedroute.tsx","./src/types/index.ts","./src/types/platform.ts","./src/utils/auth.ts","./src/utils/meeting.ts","./src/utils/password.ts"],"version":"5.9.3"} {"root":["./src/app.tsx","./src/main.tsx","./src/api/auth.ts","./src/api/http.ts","./src/api/meeting.ts","./src/api/platform.ts","./src/api/user.ts","./src/components/bottomnav.tsx","./src/components/loadingscreen.tsx","./src/components/pageheader.tsx","./src/components/platformconfigprovider.tsx","./src/components/preview/meetingpreviewview.tsx","./src/components/preview/meetinganalysis.ts","./src/hooks/usepagetitle.ts","./src/layouts/mainlayout.tsx","./src/pages/forgot-password/index.tsx","./src/pages/login/index.tsx","./src/pages/meeting-detail/index.tsx","./src/pages/meeting-preview/index.tsx","./src/pages/meetings/index.tsx","./src/pages/password/index.tsx","./src/pages/profile/index.tsx","./src/pages/realtime-meeting-preview/index.tsx","./src/pages/scan-confirm/index.tsx","./src/routes/protectedroute.tsx","./src/types/index.ts","./src/types/platform.ts","./src/utils/auth.ts","./src/utils/meeting.ts","./src/utils/password.ts"],"version":"5.9.3"}