feat(realtime): 实现实时会议订阅与ASR功能
后端新增实时会议事件分发、订阅限流及WebSocket处理器,完善Redis缓存机制与实时ASR通道支持。前端同步更新实时ASR会话组件及会议预览页面,完成前后端实时数据链路对接。dev_na
parent
907a9755c0
commit
36c1461c81
|
|
@ -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:";
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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("*");
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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());
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,8 @@
|
||||||
|
package com.imeeting.dto.biz;
|
||||||
|
|
||||||
|
import lombok.Data;
|
||||||
|
|
||||||
|
@Data
|
||||||
|
public class RealtimeMeetingSubscriptionSessionData {
|
||||||
|
private Long meetingId;
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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();
|
||||||
|
// 单个失效订阅者不能阻塞同一会议的其他订阅者。
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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 监听线程。
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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("实时订阅请求过于频繁,请稍后重试");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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());
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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}`
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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)) {
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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`);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -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("访问密码错误");
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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"}
|
||||||
Loading…
Reference in New Issue