Merge remote-tracking branch 'origin/dev_na' into dev_ymcg

dev_na
puz 2026-06-29 10:14:13 +08:00
commit 08d9024401
13 changed files with 1106 additions and 434 deletions

View File

@ -183,6 +183,11 @@
<artifactId>tencentcloud-speech-sdk-java</artifactId>
<version>1.0.67</version>
</dependency>
<dependency>
<groupId>com.tencentcloudapi</groupId>
<artifactId>tencentcloud-sdk-java-asr</artifactId>
<version>3.1.1470</version>
</dependency>
</dependencies>
<build>

View File

@ -2,6 +2,9 @@ package com.imeeting.config;
import com.imeeting.websocket.RealtimeMeetingProxyWebSocketHandler;
import lombok.RequiredArgsConstructor;
import org.springframework.boot.web.embedded.tomcat.TomcatServletWebServerFactory;
import org.springframework.boot.web.server.WebServerFactoryCustomizer;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.socket.config.annotation.EnableWebSocket;
import org.springframework.web.socket.config.annotation.WebSocketConfigurer;
@ -19,4 +22,25 @@ public class RealtimeMeetingWebSocketConfig implements WebSocketConfigurer {
registry.addHandler(realtimeMeetingProxyWebSocketHandler, "/ws/meeting/realtime")
.setAllowedOriginPatterns("*");
}
/**
* Tomcat WebSocket keepalive ping sessionIdleTimeout
* <p>
* Tomcat WebSocket session sessionIdleTimeout-1 -1
* 线 Ping Pong
* code=1011 "keepalive ping timeout"
* ASR
* sessionIdleTimeout -1
* </p>
*/
@Bean
public WebServerFactoryCustomizer<TomcatServletWebServerFactory> wsSessionIdleTimeoutCustomizer() {
return factory -> factory.addConnectorCustomizers(connector -> {
// 通过系统属性通知 Tomcat WS 容器关闭 sessionIdleTimeout 检查
// org.apache.tomcat.websocket.DEFAULT_SESSION_IDLE_TIMEOUT=-1
if (System.getProperty("org.apache.tomcat.websocket.DEFAULT_SESSION_IDLE_TIMEOUT") == null) {
System.setProperty("org.apache.tomcat.websocket.DEFAULT_SESSION_IDLE_TIMEOUT", "-1");
}
});
}
}

View File

@ -205,7 +205,7 @@ public class LegacyMeetingAdapterServiceImpl implements LegacyMeetingAdapterServ
null,
null,
null,
null,
true,
null,
null,
List.of()
@ -281,7 +281,7 @@ public class LegacyMeetingAdapterServiceImpl implements LegacyMeetingAdapterServ
null,
null,
null,
null,
true,
null,
null,
List.of()

View File

@ -49,6 +49,8 @@ public class AiModelServiceImpl implements AiModelService {
private static final String MEDIA_TENCENT_APP_ID = "tencentAppId";
private static final String MEDIA_TENCENT_SECRET_ID = "tencentSecretId";
private static final String MEDIA_TENCENT_SECRET_KEY = "tencentSecretKey";
private static final String MEDIA_TENCENT_OFFLINE_MODEL_CODE = "tencentOfflineModelCode";
private static final String MEDIA_TENCENT_REALTIME_MODEL_CODE = "tencentRealtimeModelCode";
private static final int DEFAULT_SORT_ORDER = 0;
private static final String DEFAULT_LLM_API_PATH = "/v1/chat/completions";
private static final String DEFAULT_ANTHROPIC_API_PATH = "/messages";
@ -956,16 +958,19 @@ public class AiModelServiceImpl implements AiModelService {
}
Map<String, Object> mediaConfig = dto.getMediaConfig() == null ? Collections.emptyMap() : dto.getMediaConfig();
if (readConfigString(mediaConfig.get(MEDIA_TENCENT_APP_ID)) == null) {
throw new RuntimeException("腾讯实时 ASR 模型必须配置 mediaConfig.tencentAppId");
throw new RuntimeException("腾讯 ASR 模型必须配置 mediaConfig.tencentAppId");
}
if (readConfigString(mediaConfig.get(MEDIA_TENCENT_SECRET_ID)) == null) {
throw new RuntimeException("腾讯实时 ASR 模型必须配置 mediaConfig.tencentSecretId");
throw new RuntimeException("腾讯 ASR 模型必须配置 mediaConfig.tencentSecretId");
}
if (readConfigString(mediaConfig.get(MEDIA_TENCENT_SECRET_KEY)) == null) {
throw new RuntimeException("腾讯实时 ASR 模型必须配置 mediaConfig.tencentSecretKey");
throw new RuntimeException("腾讯 ASR 模型必须配置 mediaConfig.tencentSecretKey");
}
if (dto.getModelCode() == null || dto.getModelCode().isBlank()) {
throw new RuntimeException("腾讯实时 ASR 模型必须配置 modelCode");
if (readConfigString(mediaConfig.get(MEDIA_TENCENT_OFFLINE_MODEL_CODE)) == null) {
throw new RuntimeException("腾讯 ASR 模型必须配置 mediaConfig.tencentOfflineModelCode");
}
if (readConfigString(mediaConfig.get(MEDIA_TENCENT_REALTIME_MODEL_CODE)) == null) {
throw new RuntimeException("腾讯 ASR 模型必须配置 mediaConfig.tencentRealtimeModelCode");
}
}

View File

@ -37,6 +37,16 @@ import com.imeeting.support.redis.MeetingLockCache;
import com.unisbase.entity.SysUser;
import com.unisbase.mapper.SysUserMapper;
import com.unisbase.service.SysParamService;
import com.tencentcloudapi.asr.v20190614.AsrClient;
import com.tencentcloudapi.asr.v20190614.models.CreateRecTaskRequest;
import com.tencentcloudapi.asr.v20190614.models.CreateRecTaskResponse;
import com.tencentcloudapi.asr.v20190614.models.DescribeTaskStatusRequest;
import com.tencentcloudapi.asr.v20190614.models.DescribeTaskStatusResponse;
import com.tencentcloudapi.asr.v20190614.models.SentenceDetail;
import com.tencentcloudapi.asr.v20190614.models.TaskStatus;
import com.tencentcloudapi.common.Credential;
import com.tencentcloudapi.common.exception.TencentCloudSDKException;
import com.tencentcloudapi.common.profile.ClientProfile;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Qualifier;
@ -69,6 +79,13 @@ public class AiTaskServiceImpl extends ServiceImpl<AiTaskMapper, AiTask> impleme
private static final Duration ASR_QUERY_REQUEST_TIMEOUT = Duration.ofSeconds(30);
private static final String DISPATCH_MODE_PARALLEL = "PARALLEL";
private static final String DISPATCH_MODE_SERIAL = "SERIAL";
private static final String TENCENT_PROVIDER = "tencent";
private static final String TENCENT_TASK_ID_KEY = "taskId";
private static final String MEDIA_TENCENT_APP_ID = "tencentAppId";
private static final String MEDIA_TENCENT_SECRET_ID = "tencentSecretId";
private static final String MEDIA_TENCENT_SECRET_KEY = "tencentSecretKey";
private static final String MEDIA_TENCENT_OFFLINE_MODEL_CODE = "tencentOfflineModelCode";
private static final String TENCENT_ASR_REGION = "ap-guangzhou";
private final MeetingMapper meetingMapper;
private final MeetingTranscriptMapper transcriptMapper;
@ -609,6 +626,14 @@ public class AiTaskServiceImpl extends ServiceImpl<AiTaskMapper, AiTask> impleme
log.info("[ASR-PROC] 解析ASR模型成功: meetingId={}, asrTaskId={}, asrModelId={}, baseUrl={}",
meeting.getId(), taskRecord.getId(), asrModelId, asrModel.getBaseUrl());
if ("tencent".equalsIgnoreCase(firstNonBlank(asrModel.getProvider()))) {
String transcriptText = processTencentOfflineAsr(meeting, taskRecord, asrModel);
log.info("[ASR-PROC] Tencent offline transcript persisted: meetingId={}, asrTaskId={}, transcriptLength={}",
meeting.getId(), taskRecord.getId(), transcriptText == null ? 0 : transcriptText.length());
meetingPointsService.recordAsrSuccessCharge(meeting, taskRecord);
return transcriptText;
}
String submitUrl = appendPath(asrModel.getBaseUrl(), "api/v1/asr/transcriptions");
String taskId = taskRecord.getResponseData() != null
? String.valueOf(taskRecord.getResponseData().getOrDefault("task_id", ""))
@ -702,6 +727,124 @@ public class AiTaskServiceImpl extends ServiceImpl<AiTaskMapper, AiTask> impleme
return transcriptText;
}
protected String processTencentOfflineAsr(Meeting meeting, AiTask taskRecord, AiModelVO asrModel) throws Exception {
Long taskId = readTencentOfflineTaskId(taskRecord);
if (taskId == null) {
taskId = submitTencentOfflineTask(meeting, taskRecord, asrModel);
}
TaskStatus taskStatus = null;
for (int i = 0; i < 600; i++) {
Thread.sleep(2000);
taskStatus = queryTencentOfflineTask(asrModel, taskId);
if (taskStatus == null) {
throw new RuntimeException("腾讯离线 ASR 查询结果为空");
}
String status = firstNonBlank(taskStatus.getStatusStr(), "");
if ("success".equalsIgnoreCase(status)) {
updateAiTaskSuccess(taskRecord, objectMapper.valueToTree(buildTencentTaskStatusSnapshot(taskStatus)));
return saveTencentOfflineTranscripts(meeting, taskStatus.getResultDetail());
}
if ("failed".equalsIgnoreCase(status)) {
String errorMsg = firstNonBlank(taskStatus.getErrorMsg(), "腾讯离线 ASR 识别失败");
updateAiTaskFail(taskRecord, errorMsg);
throw new RuntimeException(errorMsg);
}
updateProgress(meeting.getId(), 5, "腾讯离线 ASR 识别中...", 0);
}
throw new RuntimeException("腾讯离线 ASR 轮询超时");
}
protected Map<String, Object> buildTencentOfflineCreateRequest(Meeting meeting, AiTask taskRecord, AiModelVO asrModel) {
Map<String, Object> req = new HashMap<>();
req.put("engineModelType", resolveTencentOfflineModelCode(asrModel));
req.put("channelNum", 1L);
req.put("resTextFormat", 2L);
req.put("sourceType", 0L);
req.put("url", resolveTencentOfflineAudioUrl(meeting));
req.put("speakerDiarization", resolveTencentSpeakerDiarization(taskRecord));
req.put("speakerNumber", 0L);
String hotwordList = buildTencentHotwordList(taskRecord);
if (hotwordList != null) {
req.put("hotwordList", hotwordList);
}
return req;
}
protected Long submitTencentOfflineTask(Meeting meeting, AiTask taskRecord, AiModelVO asrModel) throws Exception {
updateProgress(meeting.getId(), 5, "提交腾讯离线 ASR 任务...", 0);
meetingPointsService.assertSufficientPointsBeforeAsrSubmit(meeting, taskRecord);
Map<String, Object> reqSnapshot = buildTencentOfflineCreateRequest(meeting, taskRecord, asrModel);
taskRecord.setRequestData(reqSnapshot);
this.updateById(taskRecord);
CreateRecTaskRequest request = new CreateRecTaskRequest();
request.setEngineModelType(String.valueOf(reqSnapshot.get("engineModelType")));
request.setChannelNum(longValue(reqSnapshot.get("channelNum")));
request.setResTextFormat(longValue(reqSnapshot.get("resTextFormat")));
request.setSourceType(longValue(reqSnapshot.get("sourceType")));
request.setUrl(String.valueOf(reqSnapshot.get("url")));
request.setSpeakerDiarization(longValue(reqSnapshot.get("speakerDiarization")));
request.setSpeakerNumber(longValue(reqSnapshot.get("speakerNumber")));
String hotwordList = stringValue(reqSnapshot.get("hotwordList"));
if (hotwordList != null && !hotwordList.isBlank()) {
request.setHotwordList(hotwordList);
}
CreateRecTaskResponse response = buildTencentOfflineAsrClient(asrModel).CreateRecTask(request);
if (response == null || response.getData() == null || response.getData().getTaskId() == null) {
throw new RuntimeException("腾讯离线 ASR 提交失败:未返回 TaskId");
}
Long taskId = response.getData().getTaskId();
writeTencentOfflineTaskId(taskRecord, taskId);
this.updateById(taskRecord);
return taskId;
}
protected TaskStatus queryTencentOfflineTask(AiModelVO asrModel, Long taskId) throws TencentCloudSDKException {
DescribeTaskStatusRequest request = new DescribeTaskStatusRequest();
request.setTaskId(taskId);
DescribeTaskStatusResponse response = buildTencentOfflineAsrClient(asrModel).DescribeTaskStatus(request);
return response == null ? null : response.getData();
}
protected String saveTencentOfflineTranscripts(Meeting meeting, SentenceDetail[] resultDetail) {
transcriptMapper.delete(new LambdaQueryWrapper<MeetingTranscript>().eq(MeetingTranscript::getMeetingId, meeting.getId()));
if (resultDetail == null || resultDetail.length == 0) {
return "";
}
StringBuilder sb = new StringBuilder();
int order = 0;
for (SentenceDetail detail : resultDetail) {
if (detail == null) {
continue;
}
String content = firstNonBlank(detail.getFinalSentence(), detail.getWrittenText(), "");
if (content == null || content.isBlank()) {
continue;
}
String speakerId = String.valueOf(detail.getSpeakerId() == null ? 0L : detail.getSpeakerId());
String speakerName = "未知说话人" + speakerId;
MeetingTranscript mt = new MeetingTranscript();
mt.setMeetingId(meeting.getId());
mt.setSpeakerId(speakerId);
mt.setSpeakerName(speakerName);
mt.setContent(content.trim());
fillTencentTranscriptTime(mt, detail);
mt.setSortOrder(order++);
transcriptMapper.insert(mt);
sb.append(speakerName).append(": ").append(mt.getContent()).append("\n");
}
if (order > 0) {
meetingTranscriptFileService.initializeTranscriptFileIfAbsent(meeting.getId());
}
return sb.toString();
}
private Map<String, Object> buildAsrRequest(Meeting meeting, AiTask taskRecord, AiModelVO asrModel) {
Map<String, Object> req = new HashMap<>();
String rawAudioUrl = meeting.getAudioUrl();
@ -1417,6 +1560,102 @@ public class AiTaskServiceImpl extends ServiceImpl<AiTaskMapper, AiTask> impleme
}
}
private AsrClient buildTencentOfflineAsrClient(AiModelVO asrModel) {
Map<String, Object> mediaConfig = asrModel.getMediaConfig() == null ? Map.of() : asrModel.getMediaConfig();
String secretId = requireTencentMediaConfig(mediaConfig, MEDIA_TENCENT_SECRET_ID);
String secretKey = requireTencentMediaConfig(mediaConfig, MEDIA_TENCENT_SECRET_KEY);
Credential credential = new Credential(secretId, secretKey);
ClientProfile profile = new ClientProfile();
return new AsrClient(credential, TENCENT_ASR_REGION, profile);
}
private String requireTencentMediaConfig(Map<String, Object> mediaConfig, String key) {
String value = stringValue(mediaConfig.get(key));
if (value == null || value.isBlank()) {
throw new RuntimeException("腾讯离线 ASR 缺少配置: " + key);
}
return value.trim();
}
private Long resolveTencentSpeakerDiarization(AiTask taskRecord) {
Object useSpkObj = taskRecord.getTaskConfig() == null ? null : taskRecord.getTaskConfig().get("useSpkId");
return "1".equals(String.valueOf(useSpkObj)) ? 1L : 0L;
}
private String buildTencentHotwordList(AiTask taskRecord) {
if (taskRecord == null || taskRecord.getTaskConfig() == null) {
return null;
}
Object hotWordsObj = taskRecord.getTaskConfig().get("hotWords");
if (!(hotWordsObj instanceof List<?> words) || words.isEmpty()) {
return null;
}
return words.stream()
.filter(Objects::nonNull)
.map(String::valueOf)
.map(String::trim)
.filter(word -> !word.isBlank())
.map(word -> word + "|5")
.collect(Collectors.joining(","));
}
private String resolveTencentOfflineAudioUrl(Meeting meeting) {
if (meeting == null || meeting.getAudioUrl() == null || meeting.getAudioUrl().isBlank()) {
throw new RuntimeException("腾讯离线 ASR 缺少音频地址");
}
String audioUrl = meeting.getAudioUrl().trim();
if (audioUrl.startsWith("http://") || audioUrl.startsWith("https://")) {
return audioUrl;
}
return serverBaseUrl + (audioUrl.startsWith("/") ? "" : "/") + audioUrl;
}
private void writeTencentOfflineTaskId(AiTask taskRecord, Long taskId) {
Map<String, Object> responseData = taskRecord.getResponseData() == null
? new HashMap<>()
: new HashMap<>(taskRecord.getResponseData());
responseData.put(TENCENT_TASK_ID_KEY, taskId);
taskRecord.setResponseData(responseData);
}
private Long readTencentOfflineTaskId(AiTask taskRecord) {
if (taskRecord == null || taskRecord.getResponseData() == null) {
return null;
}
Object taskId = taskRecord.getResponseData().get(TENCENT_TASK_ID_KEY);
return longValue(taskId);
}
private Map<String, Object> buildTencentTaskStatusSnapshot(TaskStatus taskStatus) {
Map<String, Object> snapshot = new HashMap<>();
snapshot.put("taskId", taskStatus.getTaskId());
snapshot.put("status", taskStatus.getStatus());
snapshot.put("statusStr", taskStatus.getStatusStr());
snapshot.put("result", taskStatus.getResult());
snapshot.put("errorMsg", taskStatus.getErrorMsg());
snapshot.put("audioDuration", taskStatus.getAudioDuration());
return snapshot;
}
private void fillTencentTranscriptTime(MeetingTranscript transcript, SentenceDetail detail) {
if (transcript == null || detail == null) {
return;
}
int startTime = detail.getStartMs() == null ? 0 : detail.getStartMs().intValue();
int endTime = detail.getEndMs() == null ? startTime : detail.getEndMs().intValue();
transcript.setStartTime(startTime);
transcript.setEndTime(endTime);
}
private String resolveTencentOfflineModelCode(AiModelVO asrModel) {
Map<String, Object> mediaConfig = asrModel == null || asrModel.getMediaConfig() == null ? Map.of() : asrModel.getMediaConfig();
String offlineModelCode = stringValue(mediaConfig.get(MEDIA_TENCENT_OFFLINE_MODEL_CODE));
if (offlineModelCode != null && !offlineModelCode.isBlank()) {
return offlineModelCode.trim();
}
return asrModel == null ? null : asrModel.getModelCode();
}
private AiModelVO resolveAsrModelForRevision(AiTask asrTask) {
if (asrTask == null || asrTask.getTaskConfig() == null) {
return null;

View File

@ -26,6 +26,8 @@ import java.util.UUID;
public class RealtimeMeetingSocketSessionServiceImpl implements RealtimeMeetingSocketSessionService {
private static final String WS_PATH = "/ws/meeting/realtime";
private static final String TENCENT_PROVIDER = "tencent";
private static final String MEDIA_TENCENT_REALTIME_MODEL_CODE = "tencentRealtimeModelCode";
private final RealtimeMeetingSocketSessionCache socketSessionCache;
private final MeetingAccessService meetingAccessService;
@ -87,7 +89,7 @@ public class RealtimeMeetingSocketSessionServiceImpl implements RealtimeMeetingS
sessionData.setAsrModelId(asrModelId);
sessionData.setProvider(realtimeAsrChannelFactory.normalizeProvider(asrModel.getProvider()));
sessionData.setTargetWsUrl(targetWsUrl);
sessionData.setModelCode(asrModel.getModelCode());
sessionData.setModelCode(resolveRealtimeModelCode(asrModel));
sessionData.setMediaConfig(asrModel.getMediaConfig());
String sessionToken = UUID.randomUUID().toString().replace("-", "");
@ -111,6 +113,25 @@ public class RealtimeMeetingSocketSessionServiceImpl implements RealtimeMeetingS
return vo;
}
private String resolveRealtimeModelCode(AiModelVO asrModel) {
if (asrModel == null) {
return null;
}
if (!TENCENT_PROVIDER.equalsIgnoreCase(asrModel.getProvider())) {
return asrModel.getModelCode();
}
Map<String, Object> mediaConfig = asrModel.getMediaConfig();
if (mediaConfig == null) {
return asrModel.getModelCode();
}
Object realtimeModelCode = mediaConfig.get(MEDIA_TENCENT_REALTIME_MODEL_CODE);
if (realtimeModelCode == null) {
return asrModel.getModelCode();
}
String value = String.valueOf(realtimeModelCode).trim();
return value.isEmpty() ? asrModel.getModelCode() : value;
}
@Override
public RealtimeSocketSessionData getSessionData(String sessionToken) {
return socketSessionCache.get(sessionToken);

View File

@ -513,7 +513,6 @@ public class LocalRealtimeAsrChannel implements RealtimeAsrChannel {
log.info("上游 ASR websocket 已关闭meetingId={}, sessionId={}, code={}, reason={}",
context.getMeetingId(), currentConnectionId(context), statusCode, reason);
context.getChannelState().remove(STATE_UPSTREAM_SOCKET);
context.getCallback().removeMeetingSession(context.getMeetingId());
context.getCallback().sendFrontendError(context.getMeetingId(),
"REALTIME_UPSTREAM_CLOSED",
reason == null || reason.isBlank() ? "上游 ASR WebSocket 已断开" : "上游 ASR WebSocket 已断开: " + reason);
@ -526,13 +525,13 @@ public class LocalRealtimeAsrChannel implements RealtimeAsrChannel {
log.error("上游 ASR websocket 异常meetingId={}, sessionId={}, upstream={}",
context.getMeetingId(), currentConnectionId(context), context.getTargetWsUrl(), error);
context.getChannelState().remove(STATE_UPSTREAM_SOCKET);
context.getCallback().removeMeetingSession(context.getMeetingId());
context.getCallback().sendFrontendError(context.getMeetingId(),
"REALTIME_UPSTREAM_ERROR",
error == null || error.getMessage() == null || error.getMessage().isBlank()
? "上游 ASR WebSocket 连接异常"
: "上游 ASR WebSocket 连接异常: " + error.getMessage());
context.getCallback().closeFrontend(context.getMeetingId(), CloseStatus.SERVER_ERROR);
context.getCallback().removeMeetingSession(context.getMeetingId());
}
}
}

View File

@ -206,7 +206,7 @@ public class RealtimeMeetingTranscriptCacheServiceImpl implements RealtimeMeetin
return item.getSpeakerName().trim();
}
String speakerId = resolveSpeakerId(item);
return speakerId == null || speakerId.isBlank() ? null : speakerId;
return speakerId == null || speakerId.isBlank() ? null : "未知说话人" + speakerId;
}
private String resolveSpeakerId(JsonNode sentence) {

View File

@ -42,25 +42,25 @@ public class RealtimeMeetingProxyWebSocketHandler extends AbstractWebSocketHandl
private static final String ATTR_MEETING_ID = "meetingId";
private static final String ATTR_TARGET_WS_URL = "targetWsUrl";
private static final String ATTR_PROVIDER = "provider";
private static final String ATTR_PROVIDER = "provider";
private static final String ATTR_FRONTEND_TEXT_COUNT = "frontendTextCount";
private static final String ATTR_FRONTEND_BINARY_COUNT = "frontendBinaryCount";
private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper();
private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper();
private final RealtimeMeetingSocketSessionService realtimeMeetingSocketSessionService;
private final RealtimeMeetingSessionStateService realtimeMeetingSessionStateService;
private final RealtimeMeetingAudioStorageService realtimeMeetingAudioStorageService;
private final RealtimeMeetingTranscriptCacheService realtimeMeetingTranscriptCacheService;
private final RealtimeAsrChannelFactory realtimeAsrChannelFactory;
private final ConcurrentMap<Long, MeetingChannelSession> meetingSessions = new ConcurrentHashMap<>();
private final ConcurrentMap<Long, Object> meetingLocks = new ConcurrentHashMap<>();
private final RealtimeMeetingTranscriptCacheService realtimeMeetingTranscriptCacheService;
private final RealtimeAsrChannelFactory realtimeAsrChannelFactory;
private final ConcurrentMap<Long, MeetingChannelSession> meetingSessions = new ConcurrentHashMap<>();
private final ConcurrentMap<Long, Object> meetingLocks = new ConcurrentHashMap<>();
@Override
public void afterConnectionEstablished(WebSocketSession session) throws Exception {
String sessionToken = extractQueryParam(session.getUri(), "sessionToken");
RealtimeSocketSessionData sessionData = realtimeMeetingSocketSessionService.getSessionData(sessionToken);
if (sessionData == null) {
log.warn("实时会议 websocket 拒绝连接会话令牌无效sessionId={}", session.getId());
log.warn("实时会议 websocket 拒绝连接会话令牌无效sessionId={}", session.getId());
session.close(CloseStatus.POLICY_VIOLATION.withReason("实时 Socket 会话无效"));
return;
}
@ -69,65 +69,70 @@ public class RealtimeMeetingProxyWebSocketHandler extends AbstractWebSocketHandl
new ConcurrentWebSocketSessionDecorator(session, (int) Duration.ofSeconds(15).toMillis(), 1024 * 1024);
session.getAttributes().put(ATTR_MEETING_ID, sessionData.getMeetingId());
session.getAttributes().put(ATTR_TARGET_WS_URL, sessionData.getTargetWsUrl());
session.getAttributes().put(ATTR_PROVIDER, sessionData.getProvider());
session.getAttributes().put(ATTR_PROVIDER, sessionData.getProvider());
session.getAttributes().put(ATTR_FRONTEND_TEXT_COUNT, new AtomicInteger());
session.getAttributes().put(ATTR_FRONTEND_BINARY_COUNT, new AtomicInteger());
realtimeMeetingAudioStorageService.openSession(sessionData.getMeetingId(), session.getId());
log.info("实时会议 websocket 已接入meetingId={}, sessionId={}, provider={}, upstream={}",
sessionData.getMeetingId(), session.getId(), sessionData.getProvider(), sessionData.getTargetWsUrl());
log.info("实时会议 websocket 已接入meetingId={}, sessionId={}, provider={}, upstream={}",
sessionData.getMeetingId(), session.getId(), sessionData.getProvider(), sessionData.getTargetWsUrl());
attachFrontendSession(sessionData, session, frontendSession);
attachFrontendSession(sessionData, session, frontendSession);
}
@Override
protected void handleTextMessage(WebSocketSession session, TextMessage message) {
MeetingChannelSession meetingSession = getMeetingSession(session);
if (meetingSession == null || !meetingSession.isChannelOpen()) {
log.warn("前端文本消息已忽略:上游 ASR 连接不可用meetingId={}, sessionId={}",
// 过滤前端发来的心跳保活消息,不转发给上游 ASR 服务
if (looksLikeKeepaliveMessage(message.getPayload())) {
log.debug("Frontend keepalive received, ignored: meetingId={}, sessionId={}",
session.getAttributes().get(ATTR_MEETING_ID), session.getId());
return;
}
MeetingChannelSession meetingSession = getMeetingSession(session);
if (meetingSession == null || !meetingSession.isChannelOpen()) {
log.warn("前端文本消息已忽略:上游 ASR 连接不可用meetingId={}, sessionId={}",
session.getAttributes().get(ATTR_MEETING_ID), session.getId());
return;
}
int count = nextCount(session, ATTR_FRONTEND_TEXT_COUNT);
String payload = message.getPayload();
log.info("前端文本 -> ASR 渠道meetingId={}, sessionId={}, provider={}, count={}, payload={}",
session.getAttributes().get(ATTR_MEETING_ID), session.getId(), session.getAttributes().get(ATTR_PROVIDER), count, summarizeText(payload));
meetingSession.channel.handleFrontendText(meetingSession.context, payload);
String payload = message.getPayload();
log.info("前端文本 -> ASR 渠道meetingId={}, sessionId={}, provider={}, count={}, payload={}",
session.getAttributes().get(ATTR_MEETING_ID), session.getId(), session.getAttributes().get(ATTR_PROVIDER), count, summarizeText(payload));
meetingSession.channel.handleFrontendText(meetingSession.context, payload);
}
@Override
protected void handleBinaryMessage(WebSocketSession session, BinaryMessage message) {
MeetingChannelSession meetingSession = getMeetingSession(session);
if (meetingSession == null || !meetingSession.isChannelOpen()) {
log.warn("前端音频帧已忽略:上游 ASR 连接不可用meetingId={}, sessionId={}",
MeetingChannelSession meetingSession = getMeetingSession(session);
if (meetingSession == null || !meetingSession.isChannelOpen()) {
log.warn("前端音频帧已忽略:上游 ASR 连接不可用meetingId={}, sessionId={}",
session.getAttributes().get(ATTR_MEETING_ID), session.getId());
return;
}
int count = nextCount(session, ATTR_FRONTEND_BINARY_COUNT);
int bytes = message.getPayloadLength();
if (shouldLogBinaryFrame(count)) {
log.info("前端音频帧 -> ASR 渠道meetingId={}, sessionId={}, provider={}, count={}, bytes={}",
session.getAttributes().get(ATTR_MEETING_ID), session.getId(), session.getAttributes().get(ATTR_PROVIDER), count, bytes);
log.info("前端音频帧 -> ASR 渠道meetingId={}, sessionId={}, provider={}, count={}, bytes={}",
session.getAttributes().get(ATTR_MEETING_ID), session.getId(), session.getAttributes().get(ATTR_PROVIDER), count, bytes);
}
byte[] payload = toByteArray(message.getPayload());
realtimeMeetingAudioStorageService.append(session.getId(), payload);
meetingSession.channel.handleFrontendBinary(meetingSession.context, payload);
meetingSession.channel.handleFrontendBinary(meetingSession.context, payload);
}
@Override
protected void handlePongMessage(WebSocketSession session, PongMessage message) {
if (getMeetingSession(session) == null) {
return;
}
log.debug("前端 pong 已在本地忽略meetingId={}, sessionId={}, bytes={}",
session.getAttributes().get(ATTR_MEETING_ID), session.getId(), message.getPayloadLength());
// Pong 是浏览器对服务端Tomcat发出 Ping 帧的回应,代理层在此消化即可,无需转发给上游 ASR。
// 转发 Pong 给上游没有语义意义,且可能引起上游协议混乱。
log.debug("Frontend pong received (keepalive): meetingId={}, sessionId={}",
session.getAttributes().get(ATTR_MEETING_ID), session.getId());
}
@Override
public void handleTransportError(WebSocketSession session, Throwable exception) throws Exception {
log.error("实时会议 websocket 传输异常meetingId={}, sessionId={}, upstream={}",
log.error("实时会议 websocket 传输异常meetingId={}, sessionId={}, upstream={}",
session.getAttributes().get(ATTR_MEETING_ID), session.getId(), session.getAttributes().get(ATTR_TARGET_WS_URL), exception);
detachFrontend(session);
realtimeMeetingAudioStorageService.closeSession(session.getId());
detachFrontend(session);
realtimeMeetingAudioStorageService.closeSession(session.getId());
if (session.isOpen()) {
session.close(CloseStatus.SERVER_ERROR);
}
@ -135,159 +140,159 @@ public class RealtimeMeetingProxyWebSocketHandler extends AbstractWebSocketHandl
@Override
public void afterConnectionClosed(WebSocketSession session, CloseStatus status) {
log.info("实时会议 websocket 已关闭meetingId={}, sessionId={}, code={}, reason={}",
log.info("实时会议 websocket 已关闭meetingId={}, sessionId={}, code={}, reason={}",
session.getAttributes().get(ATTR_MEETING_ID), session.getId(), status.getCode(), status.getReason());
Object meetingIdValue = session.getAttributes().get(ATTR_MEETING_ID);
if (meetingIdValue instanceof Long meetingId) {
detachFrontend(meetingId, session.getId());
detachFrontend(meetingId, session.getId());
realtimeMeetingSessionStateService.pauseByDisconnect(meetingId, session.getId());
}
realtimeMeetingAudioStorageService.closeSession(session.getId());
}
public void closeMeetingSession(Long meetingId) {
if (meetingId == null) {
return;
}
MeetingChannelSession meetingSession = meetingSessions.get(meetingId);
if (meetingSession == null || meetingSession.channel == null) {
return;
}
meetingSession.channel.closeMeeting(meetingSession.context);
}
private void attachFrontendSession(RealtimeSocketSessionData sessionData,
WebSocketSession rawSession,
ConcurrentWebSocketSessionDecorator frontendSession) throws Exception {
Long meetingId = sessionData.getMeetingId();
MeetingChannelSession meetingSession;
boolean reused = false;
synchronized (lockForMeeting(meetingId)) {
meetingSession = meetingSessions.get(meetingId);
if (meetingSession != null && meetingSession.isChannelOpen()) {
String previousSessionId = meetingSession.context.getRawSession() == null
? null
: meetingSession.context.getRawSession().getId();
meetingSession.clearFrontendIfClosed();
if (previousSessionId != null && !meetingSession.hasOpenFrontend()) {
realtimeMeetingSessionStateService.pauseByDisconnect(meetingId, previousSessionId);
public void closeMeetingSession(Long meetingId) {
if (meetingId == null) {
return;
}
if (meetingSession.hasOpenFrontend()) {
sendFrontendError(frontendSession, "REALTIME_ACTIVE_CONNECTION_EXISTS", "当前会议已有活跃前端连接");
frontendSession.close(CloseStatus.POLICY_VIOLATION.withReason("已存在活跃的前端连接"));
realtimeMeetingAudioStorageService.closeSession(rawSession.getId());
return;
MeetingChannelSession meetingSession = meetingSessions.get(meetingId);
if (meetingSession == null || meetingSession.channel == null) {
return;
}
if (!realtimeMeetingSessionStateService.activate(meetingId, rawSession.getId())) {
sendFrontendError(frontendSession, "REALTIME_ACTIVE_CONNECTION_REJECTED", "当前状态下无法继续会议");
frontendSession.close(CloseStatus.POLICY_VIOLATION.withReason("当前状态下无法继续会议"));
realtimeMeetingAudioStorageService.closeSession(rawSession.getId());
return;
meetingSession.channel.closeMeeting(meetingSession.context);
}
private void attachFrontendSession(RealtimeSocketSessionData sessionData,
WebSocketSession rawSession,
ConcurrentWebSocketSessionDecorator frontendSession) throws Exception {
Long meetingId = sessionData.getMeetingId();
MeetingChannelSession meetingSession;
boolean reused = false;
synchronized (lockForMeeting(meetingId)) {
meetingSession = meetingSessions.get(meetingId);
if (meetingSession != null && meetingSession.isChannelOpen()) {
String previousSessionId = meetingSession.context.getRawSession() == null
? null
: meetingSession.context.getRawSession().getId();
meetingSession.clearFrontendIfClosed();
if (previousSessionId != null && !meetingSession.hasOpenFrontend()) {
realtimeMeetingSessionStateService.pauseByDisconnect(meetingId, previousSessionId);
}
if (meetingSession.hasOpenFrontend()) {
sendFrontendError(frontendSession, "REALTIME_ACTIVE_CONNECTION_EXISTS", "当前会议已有活跃前端连接");
frontendSession.close(CloseStatus.POLICY_VIOLATION.withReason("已存在活跃的前端连接"));
realtimeMeetingAudioStorageService.closeSession(rawSession.getId());
return;
}
if (!realtimeMeetingSessionStateService.activate(meetingId, rawSession.getId())) {
sendFrontendError(frontendSession, "REALTIME_ACTIVE_CONNECTION_REJECTED", "当前状态下无法继续会议");
frontendSession.close(CloseStatus.POLICY_VIOLATION.withReason("当前状态下无法继续会议"));
realtimeMeetingAudioStorageService.closeSession(rawSession.getId());
return;
}
meetingSession.bindFrontend(rawSession, frontendSession);
reused = true;
} else {
RealtimeAsrChannel channel = realtimeAsrChannelFactory.getRequired(sessionData.getProvider());
RealtimeAsrChannelContext context = new RealtimeAsrChannelContext();
context.setMeetingId(meetingId);
context.setProvider(realtimeAsrChannelFactory.normalizeProvider(sessionData.getProvider()));
context.setTargetWsUrl(sessionData.getTargetWsUrl());
context.setCallback(new HandlerChannelCallback());
context.bindFrontendSession(rawSession, frontendSession);
context.getChannelState().put("modelCode", sessionData.getModelCode());
context.getChannelState().put("mediaConfig", sessionData.getMediaConfig());
meetingSession = new MeetingChannelSession(meetingId, channel, context);
meetingSessions.put(meetingId, meetingSession);
}
}
meetingSession.bindFrontend(rawSession, frontendSession);
reused = true;
} else {
RealtimeAsrChannel channel = realtimeAsrChannelFactory.getRequired(sessionData.getProvider());
RealtimeAsrChannelContext context = new RealtimeAsrChannelContext();
context.setMeetingId(meetingId);
context.setProvider(realtimeAsrChannelFactory.normalizeProvider(sessionData.getProvider()));
context.setTargetWsUrl(sessionData.getTargetWsUrl());
context.setCallback(new HandlerChannelCallback());
context.bindFrontendSession(rawSession, frontendSession);
context.getChannelState().put("modelCode", sessionData.getModelCode());
context.getChannelState().put("mediaConfig", sessionData.getMediaConfig());
meetingSession = new MeetingChannelSession(meetingId, channel, context);
meetingSessions.put(meetingId, meetingSession);
}
}
if (reused) {
sendProxyReady(frontendSession);
replayCachedMessages(meetingId, frontendSession);
return;
}
try {
meetingSession.channel.connect(meetingSession.context);
} catch (InterruptedException ex) {
Thread.currentThread().interrupt();
removeMeetingSession(meetingId, meetingSession);
log.error("连接上游 ASR websocket 时被中断meetingId={}, sessionId={}", meetingId, rawSession.getId(), ex);
sendFrontendError(frontendSession, "REALTIME_UPSTREAM_CONNECT_INTERRUPTED", "连接上游 ASR 服务时被中断");
realtimeMeetingAudioStorageService.closeSession(rawSession.getId());
frontendSession.close(CloseStatus.SERVER_ERROR.withReason("连接上游服务时被中断"));
} catch (Exception ex) {
removeMeetingSession(meetingId, meetingSession);
log.warn("连接上游 ASR websocket 失败meetingId={}, provider={}, target={}",
meetingId, sessionData.getProvider(), sessionData.getTargetWsUrl(), ex);
sendFrontendError(frontendSession, "REALTIME_UPSTREAM_CONNECT_FAILED", "连接上游 ASR 服务失败");
realtimeMeetingAudioStorageService.closeSession(rawSession.getId());
frontendSession.close(CloseStatus.SERVER_ERROR.withReason("连接 ASR WebSocket 失败"));
}
}
private void replayCachedMessages(Long meetingId, ConcurrentWebSocketSessionDecorator frontendSession) {
try {
if (!frontendSession.isOpen()) {
return;
}
for (RealtimeMeetingTranscriptCacheItem item : realtimeMeetingTranscriptCacheService.listOrderedItems(meetingId)) {
frontendSession.sendMessage(new TextMessage(LocalRealtimeAsrChannel.buildFrontendTranscriptMessage(item)));
}
} catch (Exception ex) {
log.warn("回放缓存转写消息失败meetingId={}", meetingId, ex);
}
}
private void sendProxyReady(ConcurrentWebSocketSessionDecorator frontendSession) throws Exception {
if (frontendSession.isOpen()) {
frontendSession.sendMessage(new TextMessage("{\"type\":\"proxy_ready\"}"));
}
}
private void detachFrontend(WebSocketSession session) {
Object meetingIdValue = session.getAttributes().get(ATTR_MEETING_ID);
if (meetingIdValue instanceof Long meetingId) {
detachFrontend(meetingId, session.getId());
}
}
private void detachFrontend(Long meetingId, String sessionId) {
MeetingChannelSession meetingSession = meetingSessions.get(meetingId);
if (meetingSession == null) {
return;
if (reused) {
sendProxyReady(frontendSession);
replayCachedMessages(meetingId, frontendSession);
return;
}
synchronized (lockForMeeting(meetingId)) {
if (meetingSession.context.getRawSession() != null && meetingSession.context.getRawSession().getId().equals(sessionId)) {
meetingSession.channel.onFrontendDetached(meetingSession.context);
}
meetingSession.detachFrontend(sessionId);
}
}
private MeetingChannelSession getMeetingSession(WebSocketSession session) {
Object meetingIdValue = session.getAttributes().get(ATTR_MEETING_ID);
if (!(meetingIdValue instanceof Long meetingId)) {
return null;
}
return meetingSessions.get(meetingId);
}
void removeMeetingSession(Long meetingId) {
synchronized (lockForMeeting(meetingId)) {
meetingSessions.remove(meetingId);
}
}
void removeMeetingSession(Long meetingId, MeetingChannelSession meetingSession) {
synchronized (lockForMeeting(meetingId)) {
meetingSessions.remove(meetingId, meetingSession);
try {
meetingSession.channel.connect(meetingSession.context);
} catch (InterruptedException ex) {
Thread.currentThread().interrupt();
removeMeetingSession(meetingId, meetingSession);
log.error("连接上游 ASR websocket 时被中断meetingId={}, sessionId={}", meetingId, rawSession.getId(), ex);
sendFrontendError(frontendSession, "REALTIME_UPSTREAM_CONNECT_INTERRUPTED", "连接上游 ASR 服务时被中断");
realtimeMeetingAudioStorageService.closeSession(rawSession.getId());
frontendSession.close(CloseStatus.SERVER_ERROR.withReason("连接上游服务时被中断"));
} catch (Exception ex) {
removeMeetingSession(meetingId, meetingSession);
log.warn("连接上游 ASR websocket 失败meetingId={}, provider={}, target={}",
meetingId, sessionData.getProvider(), sessionData.getTargetWsUrl(), ex);
sendFrontendError(frontendSession, "REALTIME_UPSTREAM_CONNECT_FAILED", "连接上游 ASR 服务失败");
realtimeMeetingAudioStorageService.closeSession(rawSession.getId());
frontendSession.close(CloseStatus.SERVER_ERROR.withReason("连接 ASR WebSocket 失败"));
}
}
private Object lockForMeeting(Long meetingId) {
return meetingLocks.computeIfAbsent(meetingId, ignored -> new Object());
}
private void replayCachedMessages(Long meetingId, ConcurrentWebSocketSessionDecorator frontendSession) {
try {
if (!frontendSession.isOpen()) {
return;
}
for (RealtimeMeetingTranscriptCacheItem item : realtimeMeetingTranscriptCacheService.listOrderedItems(meetingId)) {
frontendSession.sendMessage(new TextMessage(LocalRealtimeAsrChannel.buildFrontendTranscriptMessage(item)));
}
} catch (Exception ex) {
log.warn("回放缓存转写消息失败meetingId={}", meetingId, ex);
}
}
private void sendProxyReady(ConcurrentWebSocketSessionDecorator frontendSession) throws Exception {
if (frontendSession.isOpen()) {
frontendSession.sendMessage(new TextMessage("{\"type\":\"proxy_ready\"}"));
}
}
private void detachFrontend(WebSocketSession session) {
Object meetingIdValue = session.getAttributes().get(ATTR_MEETING_ID);
if (meetingIdValue instanceof Long meetingId) {
detachFrontend(meetingId, session.getId());
}
}
private void detachFrontend(Long meetingId, String sessionId) {
MeetingChannelSession meetingSession = meetingSessions.get(meetingId);
if (meetingSession == null) {
return;
}
synchronized (lockForMeeting(meetingId)) {
if (meetingSession.context.getRawSession() != null && meetingSession.context.getRawSession().getId().equals(sessionId)) {
meetingSession.channel.onFrontendDetached(meetingSession.context);
}
meetingSession.detachFrontend(sessionId);
}
}
private MeetingChannelSession getMeetingSession(WebSocketSession session) {
Object meetingIdValue = session.getAttributes().get(ATTR_MEETING_ID);
if (!(meetingIdValue instanceof Long meetingId)) {
return null;
}
return meetingSessions.get(meetingId);
}
void removeMeetingSession(Long meetingId) {
synchronized (lockForMeeting(meetingId)) {
meetingSessions.remove(meetingId);
}
}
void removeMeetingSession(Long meetingId, MeetingChannelSession meetingSession) {
synchronized (lockForMeeting(meetingId)) {
meetingSessions.remove(meetingId, meetingSession);
}
}
private Object lockForMeeting(Long meetingId) {
return meetingLocks.computeIfAbsent(meetingId, ignored -> new Object());
}
private String extractQueryParam(URI uri, String key) {
if (uri == null || uri.getQuery() == null || uri.getQuery().isBlank()) {
@ -316,18 +321,18 @@ public class RealtimeMeetingProxyWebSocketHandler extends AbstractWebSocketHandl
return 0;
}
private void sendFrontendError(ConcurrentWebSocketSessionDecorator frontendSession, String code, String message) {
try {
if (!frontendSession.isOpen()) {
return;
}
Map<String, Object> payload = new HashMap<>();
payload.put("type", "error");
payload.put("code", code);
payload.put("message", message);
frontendSession.sendMessage(new TextMessage(OBJECT_MAPPER.writeValueAsString(payload)));
} catch (Exception ex) {
log.warn("向前端发送实时代理错误消息失败code={}", code, ex);
private void sendFrontendError(ConcurrentWebSocketSessionDecorator frontendSession, String code, String message) {
try {
if (!frontendSession.isOpen()) {
return;
}
Map<String, Object> payload = new HashMap<>();
payload.put("type", "error");
payload.put("code", code);
payload.put("message", message);
frontendSession.sendMessage(new TextMessage(OBJECT_MAPPER.writeValueAsString(payload)));
} catch (Exception ex) {
log.warn("向前端发送实时代理错误消息失败code={}", code, ex);
}
}
@ -346,108 +351,116 @@ public class RealtimeMeetingProxyWebSocketHandler extends AbstractWebSocketHandl
return normalized.substring(0, 240) + "...";
}
static final class MeetingChannelSession {
private final Long meetingId;
private final RealtimeAsrChannel channel;
private final RealtimeAsrChannelContext context;
private MeetingChannelSession(Long meetingId, RealtimeAsrChannel channel, RealtimeAsrChannelContext context) {
this.meetingId = meetingId;
this.channel = channel;
this.context = context;
private boolean looksLikeKeepaliveMessage(String payload) {
if (payload == null || payload.isBlank()) {
return false;
}
String normalized = payload.replaceAll("\\s+", "");
return normalized.contains("\"type\":\"keepalive\"");
}
private void bindFrontend(WebSocketSession rawSession, ConcurrentWebSocketSessionDecorator frontendSession) {
context.bindFrontendSession(rawSession, frontendSession);
static final class MeetingChannelSession {
private final Long meetingId;
private final RealtimeAsrChannel channel;
private final RealtimeAsrChannelContext context;
private MeetingChannelSession(Long meetingId, RealtimeAsrChannel channel, RealtimeAsrChannelContext context) {
this.meetingId = meetingId;
this.channel = channel;
this.context = context;
}
private void detachFrontend(String sessionId) {
if (context.getRawSession() != null && context.getRawSession().getId().equals(sessionId)) {
context.bindFrontendSession(null, null);
}
private void bindFrontend(WebSocketSession rawSession, ConcurrentWebSocketSessionDecorator frontendSession) {
context.bindFrontendSession(rawSession, frontendSession);
}
private void detachFrontend(String sessionId) {
if (context.getRawSession() != null && context.getRawSession().getId().equals(sessionId)) {
context.bindFrontendSession(null, null);
}
}
private void clearFrontendIfClosed() {
if (context.getRawSession() != null && !context.getRawSession().isOpen()) {
context.bindFrontendSession(null, null);
}
}
private boolean hasOpenFrontend() {
return context.getFrontendSession() != null
&& context.getFrontendSession().isOpen()
&& context.getRawSession() != null
&& context.getRawSession().isOpen();
}
private boolean isChannelOpen() {
return channel != null && channel.isOpen(context);
}
}
private void clearFrontendIfClosed() {
if (context.getRawSession() != null && !context.getRawSession().isOpen()) {
context.bindFrontendSession(null, null);
}
}
private boolean hasOpenFrontend() {
return context.getFrontendSession() != null
&& context.getFrontendSession().isOpen()
&& context.getRawSession() != null
&& context.getRawSession().isOpen();
}
private boolean isChannelOpen() {
return channel != null && channel.isOpen(context);
}
}
private final class HandlerChannelCallback implements RealtimeAsrChannelCallback {
private final class HandlerChannelCallback implements RealtimeAsrChannelCallback {
@Override
public void onChannelOpen(Long meetingId) throws Exception {
MeetingChannelSession meetingSession = meetingSessions.get(meetingId);
if (meetingSession == null) {
MeetingChannelSession meetingSession = meetingSessions.get(meetingId);
if (meetingSession == null) {
return;
}
ConcurrentWebSocketSessionDecorator frontendSession = meetingSession.context.getFrontendSession();
if (frontendSession != null && frontendSession.isOpen()) {
sendProxyReady(frontendSession);
}
ConcurrentWebSocketSessionDecorator frontendSession = meetingSession.context.getFrontendSession();
if (frontendSession != null && frontendSession.isOpen()) {
sendProxyReady(frontendSession);
}
}
@Override
public void sendFrontendText(Long meetingId, String payload) throws Exception {
MeetingChannelSession meetingSession = meetingSessions.get(meetingId);
if (meetingSession == null) {
@Override
public void sendFrontendText(Long meetingId, String payload) throws Exception {
MeetingChannelSession meetingSession = meetingSessions.get(meetingId);
if (meetingSession == null) {
return;
}
ConcurrentWebSocketSessionDecorator frontendSession = meetingSession.context.getFrontendSession();
if (frontendSession != null && frontendSession.isOpen()) {
frontendSession.sendMessage(new TextMessage(payload));
}
ConcurrentWebSocketSessionDecorator frontendSession = meetingSession.context.getFrontendSession();
if (frontendSession != null && frontendSession.isOpen()) {
frontendSession.sendMessage(new TextMessage(payload));
}
}
@Override
public void sendFrontendBinary(Long meetingId, byte[] payload) throws Exception {
MeetingChannelSession meetingSession = meetingSessions.get(meetingId);
if (meetingSession == null) {
return;
}
ConcurrentWebSocketSessionDecorator frontendSession = meetingSession.context.getFrontendSession();
if (frontendSession != null && frontendSession.isOpen()) {
frontendSession.sendMessage(new BinaryMessage(payload));
}
MeetingChannelSession meetingSession = meetingSessions.get(meetingId);
if (meetingSession == null) {
return;
}
ConcurrentWebSocketSessionDecorator frontendSession = meetingSession.context.getFrontendSession();
if (frontendSession != null && frontendSession.isOpen()) {
frontendSession.sendMessage(new BinaryMessage(payload));
}
}
@Override
public void sendFrontendError(Long meetingId, String code, String message) {
MeetingChannelSession meetingSession = meetingSessions.get(meetingId);
if (meetingSession == null) {
return;
}
ConcurrentWebSocketSessionDecorator frontendSession = meetingSession.context.getFrontendSession();
if (frontendSession != null) {
RealtimeMeetingProxyWebSocketHandler.this.sendFrontendError(frontendSession, code, message);
}
MeetingChannelSession meetingSession = meetingSessions.get(meetingId);
if (meetingSession == null) {
return;
}
ConcurrentWebSocketSessionDecorator frontendSession = meetingSession.context.getFrontendSession();
if (frontendSession != null) {
RealtimeMeetingProxyWebSocketHandler.this.sendFrontendError(frontendSession, code, message);
}
}
@Override
public void removeMeetingSession(Long meetingId) {
RealtimeMeetingProxyWebSocketHandler.this.removeMeetingSession(meetingId);
RealtimeMeetingProxyWebSocketHandler.this.removeMeetingSession(meetingId);
}
@Override
public void closeFrontend(Long meetingId, CloseStatus status) {
MeetingChannelSession meetingSession = meetingSessions.get(meetingId);
if (meetingSession == null) {
return;
}
MeetingChannelSession meetingSession = meetingSessions.get(meetingId);
if (meetingSession == null) {
return;
}
try {
WebSocketSession rawSession = meetingSession.context.getRawSession();
if (rawSession != null && rawSession.isOpen()) {
WebSocketSession rawSession = meetingSession.context.getRawSession();
if (rawSession != null && rawSession.isOpen()) {
rawSession.close(status);
}
} catch (Exception ignored) {

View File

@ -349,6 +349,30 @@ class AiModelServiceImplTest {
assertNull(captor.getValue().getApiKey());
}
@Test
void saveModelShouldRejectTencentAsrWithoutAppId() {
AiModelServiceImpl service = new AiModelServiceImpl(
objectMapper,
mock(AsrModelMapper.class),
mock(LlmModelMapper.class)
);
AiModelDTO dto = new AiModelDTO();
dto.setModelType("ASR");
dto.setModelName("tencent-asr");
dto.setProvider("tencent");
dto.setModelCode("16k_zh");
dto.setIsDefault(0);
dto.setStatus(1);
dto.setMediaConfig(Map.of(
"tencentSecretId", "secret-id",
"tencentSecretKey", "secret-key"
));
RuntimeException ex = assertThrows(RuntimeException.class, () -> service.saveModel(dto));
assertEquals("腾讯 ASR 模型必须配置 mediaConfig.tencentAppId", ex.getMessage());
}
@Test
void saveModelShouldRejectTencentAsrWithoutSecretKey() {
AiModelServiceImpl service = new AiModelServiceImpl(
@ -370,7 +394,32 @@ class AiModelServiceImplTest {
));
RuntimeException ex = assertThrows(RuntimeException.class, () -> service.saveModel(dto));
assertEquals("腾讯实时 ASR 模型必须配置 mediaConfig.tencentSecretKey", ex.getMessage());
assertEquals("腾讯 ASR 模型必须配置 mediaConfig.tencentSecretKey", ex.getMessage());
}
@Test
void saveModelShouldRejectTencentAsrWithoutRealtimeModelCode() {
AiModelServiceImpl service = new AiModelServiceImpl(
objectMapper,
mock(AsrModelMapper.class),
mock(LlmModelMapper.class)
);
AiModelDTO dto = new AiModelDTO();
dto.setModelType("ASR");
dto.setModelName("tencent-asr");
dto.setProvider("tencent");
dto.setIsDefault(0);
dto.setStatus(1);
dto.setMediaConfig(Map.of(
"tencentAppId", "app-id",
"tencentSecretId", "secret-id",
"tencentSecretKey", "secret-key",
"tencentOfflineModelCode", "16k_zh"
));
RuntimeException ex = assertThrows(RuntimeException.class, () -> service.saveModel(dto));
assertEquals("腾讯 ASR 模型必须配置 mediaConfig.tencentRealtimeModelCode", ex.getMessage());
}
@Test
@ -388,13 +437,14 @@ class AiModelServiceImplTest {
dto.setModelType("ASR");
dto.setModelName("tencent-asr");
dto.setProvider("tencent");
dto.setModelCode("16k_zh");
dto.setIsDefault(0);
dto.setStatus(1);
dto.setMediaConfig(Map.of(
"tencentAppId", "app-id",
"tencentSecretId", "secret-id",
"tencentSecretKey", "secret-key"
"tencentSecretKey", "secret-key",
"tencentOfflineModelCode", "16k_zh",
"tencentRealtimeModelCode", "16k_zh_realtime"
));
service.saveModel(dto);
@ -402,7 +452,9 @@ class AiModelServiceImplTest {
ArgumentCaptor<AsrModel> captor = ArgumentCaptor.forClass(AsrModel.class);
verify(asrModelMapper, times(1)).insert(captor.capture());
assertEquals("tencent", captor.getValue().getProvider());
assertEquals("16k_zh", captor.getValue().getModelCode());
assertNull(captor.getValue().getModelCode());
assertEquals("16k_zh", captor.getValue().getMediaConfig().get("tencentOfflineModelCode"));
assertEquals("16k_zh_realtime", captor.getValue().getMediaConfig().get("tencentRealtimeModelCode"));
assertEquals("secret-key", captor.getValue().getMediaConfig().get("tencentSecretKey"));
assertNull(captor.getValue().getBaseUrl());
}

View File

@ -1,152 +1,308 @@
//package com.imeeting.service.biz.impl;
//
//import com.fasterxml.jackson.databind.ObjectMapper;
//import com.imeeting.dto.biz.AiModelVO;
//import com.imeeting.entity.biz.AiTask;
//import com.imeeting.entity.biz.HotWord;
//import com.imeeting.entity.biz.Meeting;
//import com.imeeting.mapper.biz.MeetingMapper;
//import com.imeeting.mapper.biz.MeetingTranscriptMapper;
//import com.imeeting.service.biz.AiModelService;
//import com.imeeting.service.biz.HotWordService;
//import com.imeeting.service.biz.MeetingProgressService;
//import com.imeeting.service.biz.MeetingSummaryFileService;
//import com.imeeting.service.biz.MeetingTranscriptChapterService;
//import com.imeeting.service.biz.MeetingTranscriptFileService;
//import com.imeeting.support.RedisValueSupport;
//import com.imeeting.support.TaskSecurityContextRunner;
//import com.unisbase.mapper.SysUserMapper;
//import com.unisbase.service.SysParamService;
//import org.junit.jupiter.api.Test;
//import org.springframework.test.util.ReflectionTestUtils;
//
//import java.util.HashMap;
//import java.util.List;
//import java.util.Map;
//
//import static org.junit.jupiter.api.Assertions.assertEquals;
//import static org.junit.jupiter.api.Assertions.assertFalse;
//import static org.junit.jupiter.api.Assertions.assertNull;
//import static org.junit.jupiter.api.Assertions.assertTrue;
//import static org.mockito.ArgumentMatchers.any;
//import static org.mockito.Mockito.mock;
//import static org.mockito.Mockito.when;
//
//class AiTaskServiceImplTest {
//
// @Test
// void buildAsrRequestShouldFollowCurrentOfflineAsrContract() {
// HotWordService hotWordService = mock(HotWordService.class);
// HotWord hotWord = new HotWord();
// hotWord.setWord("汇智");
// hotWord.setWeight(25);
// when(hotWordService.list(any())).thenReturn(List.of(hotWord));
//
// AiTaskServiceImpl service = new AiTaskServiceImpl(
// mock(MeetingMapper.class),
// mock(MeetingTranscriptMapper.class),
// mock(AiModelService.class),
// new ObjectMapper(),
// mock(SysUserMapper.class),
// hotWordService,
// mock(RedisValueSupport.class),
// mock(MeetingProgressService.class),
// mock(MeetingSummaryFileService.class),
// mock(MeetingTranscriptFileService.class),
// mock(MeetingTranscriptChapterService.class),
// mock(MeetingSummaryPromptAssembler.class),
// mock(TaskSecurityContextRunner.class),
// mock(MeetingExternalSummaryWebhookTrigger.class),
// mock(SysParamService.class)
// );
// ReflectionTestUtils.setField(service, "serverBaseUrl", "http://localhost:8080");
//
// Meeting meeting = new Meeting();
// meeting.setAudioUrl("/api/static/meetings/12/source audio.mp4");
//
// AiTask task = new AiTask();
// Map<String, Object> taskConfig = new HashMap<>();
// taskConfig.put("useSpkId", 1);
// taskConfig.put("enableTextRefine", true);
// taskConfig.put("hotWords", List.of("汇智"));
// task.setTaskConfig(taskConfig);
//
// AiModelVO asrModel = new AiModelVO();
// asrModel.setModelCode("legacy-model-code");
//
// @SuppressWarnings("unchecked")
// Map<String, Object> request = (Map<String, Object>) ReflectionTestUtils.invokeMethod(
// service,
// "buildAsrRequest",
// meeting,
// task,
// asrModel
// );
//
// assertEquals("http://localhost:8080/api/static/meetings/12/source%20audio.mp4", request.get("audio_address"));
// assertFalse(request.containsKey("file_url"));
//
// @SuppressWarnings("unchecked")
// Map<String, Object> config = (Map<String, Object>) request.get("config");
// assertEquals(Boolean.TRUE, config.get("enable_speaker"));
// assertEquals(Boolean.TRUE, config.get("match_speaker_registry"));
// assertEquals(Boolean.TRUE, config.get("enable_text_cleanup"));
// assertFalse(config.containsKey("enable_text_refine"));
// assertFalse(config.containsKey("enable_two_pass"));
// assertFalse(config.containsKey("model"));
//
// @SuppressWarnings("unchecked")
// List<Map<String, Object>> hotwords = (List<Map<String, Object>>) config.get("hotwords");
// assertEquals(1, hotwords.size());
// assertEquals("汇智", hotwords.get(0).get("hotword"));
// assertEquals(2.5, hotwords.get(0).get("weight"));
// }
//
// @Test
// void buildAsrRequestShouldDisableRegistryMatchWhenSpeakerSplitDisabled() {
// AiTaskServiceImpl service = new AiTaskServiceImpl(
// mock(MeetingMapper.class),
// mock(MeetingTranscriptMapper.class),
// mock(AiModelService.class),
// new ObjectMapper(),
// mock(SysUserMapper.class),
// mock(HotWordService.class),
// mock(RedisValueSupport.class),
// mock(MeetingProgressService.class),
// mock(MeetingSummaryFileService.class),
// mock(MeetingTranscriptFileService.class),
// mock(MeetingTranscriptChapterService.class),
// mock(MeetingSummaryPromptAssembler.class),
// mock(TaskSecurityContextRunner.class),
// mock(MeetingExternalSummaryWebhookTrigger.class),
// mock(SysParamService.class)
// );
// ReflectionTestUtils.setField(service, "serverBaseUrl", "http://localhost:8080");
//
// Meeting meeting = new Meeting();
// meeting.setAudioUrl("/api/static/audio/demo.wav");
//
// AiTask task = new AiTask();
// Map<String, Object> taskConfig = new HashMap<>();
// taskConfig.put("useSpkId", 0);
// taskConfig.put("enableTextRefine", false);
// task.setTaskConfig(taskConfig);
//
// @SuppressWarnings("unchecked")
// Map<String, Object> request = (Map<String, Object>) ReflectionTestUtils.invokeMethod(
// service,
// "buildAsrRequest",
// meeting,
// task,
// new AiModelVO()
// );
//
// @SuppressWarnings("unchecked")
// Map<String, Object> config = (Map<String, Object>) request.get("config");
// assertEquals(Boolean.FALSE, config.get("enable_speaker"));
// assertEquals(Boolean.FALSE, config.get("match_speaker_registry"));
// assertEquals(Boolean.FALSE, config.get("enable_text_cleanup"));
// assertTrue(((List<?>) config.get("hotwords")).isEmpty());
// assertNull(request.get("file_url"));
// }
//}
package com.imeeting.service.biz.impl;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.imeeting.dto.biz.AiModelVO;
import com.imeeting.entity.biz.AiTask;
import com.imeeting.entity.biz.Meeting;
import com.imeeting.mapper.biz.AiTaskMapper;
import com.imeeting.mapper.biz.MeetingMapper;
import com.imeeting.mapper.biz.MeetingTranscriptMapper;
import com.imeeting.service.android.AndroidMeetingPushService;
import com.imeeting.service.biz.AiModelService;
import com.imeeting.service.biz.HotWordService;
import com.imeeting.service.biz.MeetingPointsService;
import com.imeeting.service.biz.MeetingProgressService;
import com.imeeting.service.biz.MeetingSummaryFileService;
import com.imeeting.service.biz.MeetingTranscriptChapterService;
import com.imeeting.service.biz.MeetingTranscriptFileService;
import com.imeeting.support.TaskSecurityContextRunner;
import com.imeeting.support.redis.MeetingAsrPermitCache;
import com.imeeting.support.redis.MeetingLockCache;
import com.unisbase.mapper.SysUserMapper;
import com.unisbase.service.SysParamService;
import com.tencentcloudapi.asr.v20190614.models.SentenceDetail;
import com.tencentcloudapi.asr.v20190614.models.TaskStatus;
import org.junit.jupiter.api.Test;
import org.springframework.test.util.ReflectionTestUtils;
import java.util.HashMap;
import java.util.Map;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.doReturn;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.spy;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
class AiTaskServiceImplTest {
@Test
void processAsrTaskShouldUseTencentOfflineBranchWhenProviderIsTencent() throws Exception {
MeetingMapper meetingMapper = mock(MeetingMapper.class);
MeetingTranscriptMapper transcriptMapper = mock(MeetingTranscriptMapper.class);
AiModelService aiModelService = mock(AiModelService.class);
HotWordService hotWordService = mock(HotWordService.class);
MeetingLockCache meetingLockCache = mock(MeetingLockCache.class);
MeetingAsrPermitCache meetingAsrPermitCache = mock(MeetingAsrPermitCache.class);
MeetingProgressService meetingProgressService = mock(MeetingProgressService.class);
MeetingPointsService meetingPointsService = mock(MeetingPointsService.class);
MeetingSummaryFileService meetingSummaryFileService = mock(MeetingSummaryFileService.class);
MeetingTranscriptFileService meetingTranscriptFileService = mock(MeetingTranscriptFileService.class);
MeetingTranscriptChapterService meetingTranscriptChapterService = mock(MeetingTranscriptChapterService.class);
MeetingSummaryPromptAssembler meetingSummaryPromptAssembler = mock(MeetingSummaryPromptAssembler.class);
TaskSecurityContextRunner taskSecurityContextRunner = mock(TaskSecurityContextRunner.class);
MeetingExternalSummaryWebhookTrigger meetingExternalSummaryWebhookTrigger = mock(MeetingExternalSummaryWebhookTrigger.class);
SysParamService sysParamService = mock(SysParamService.class);
AiTaskServiceImpl service = spy(new AiTaskServiceImpl(
meetingMapper,
transcriptMapper,
aiModelService,
new ObjectMapper(),
mock(SysUserMapper.class),
hotWordService,
meetingLockCache,
meetingAsrPermitCache,
meetingProgressService,
meetingPointsService,
meetingSummaryFileService,
meetingTranscriptFileService,
meetingTranscriptChapterService,
meetingSummaryPromptAssembler,
taskSecurityContextRunner,
meetingExternalSummaryWebhookTrigger,
sysParamService
));
ReflectionTestUtils.setField(service, "baseMapper", mock(AiTaskMapper.class));
ReflectionTestUtils.setField(service, "androidMeetingPushService", mock(AndroidMeetingPushService.class));
Meeting meeting = new Meeting();
meeting.setId(1L);
meeting.setAudioUrl("https://cdn.example.com/audio/demo.m4a");
AiTask task = new AiTask();
task.setId(11L);
task.setMeetingId(1L);
task.setTaskType("ASR");
task.setTaskConfig(new HashMap<>(Map.of(
"asrModelId", 101L,
"useSpkId", 1,
"enableTextRefine", true
)));
AiModelVO model = new AiModelVO();
model.setId(101L);
model.setProvider("tencent");
model.setModelCode("16k_zh");
model.setMediaConfig(Map.of(
"tencentAppId", "123456",
"tencentSecretId", "secret-id",
"tencentSecretKey", "secret-key"
));
when(aiModelService.getModelById(101L, "ASR")).thenReturn(model);
doReturn(true).when(service).updateById(any(AiTask.class));
doReturn("腾讯离线转写结果").when(service).processTencentOfflineAsr(eq(meeting), eq(task), eq(model));
String result = ReflectionTestUtils.invokeMethod(service, "processAsrTask", meeting, task);
assertEquals("腾讯离线转写结果", result);
verify(service).processTencentOfflineAsr(meeting, task, model);
verify(meetingPointsService).recordAsrSuccessCharge(meeting, task);
}
@Test
void buildTencentOfflineCreateRequestShouldMapMeetingAndTaskConfig() {
AiTaskServiceImpl service = createService(mock(MeetingPointsService.class));
ReflectionTestUtils.setField(service, "serverBaseUrl", "https://server.example.com");
Meeting meeting = new Meeting();
meeting.setId(2L);
meeting.setAudioUrl("/upload/audio/demo.m4a");
AiTask task = new AiTask();
task.setTaskConfig(new HashMap<>(Map.of(
"useSpkId", 1,
"enableTextRefine", true,
"hotWords", java.util.List.of("腾讯会议", "离线转写")
)));
AiModelVO model = new AiModelVO();
model.setModelCode("legacy-model-code");
model.setMediaConfig(Map.of(
"tencentAppId", "123456",
"tencentSecretId", "secret-id",
"tencentSecretKey", "secret-key",
"tencentOfflineModelCode", "16k_zh",
"tencentRealtimeModelCode", "16k_zh_realtime"
));
@SuppressWarnings("unchecked")
Map<String, Object> request = (Map<String, Object>) ReflectionTestUtils.invokeMethod(
service,
"buildTencentOfflineCreateRequest",
meeting,
task,
model
);
assertEquals("16k_zh", request.get("engineModelType"));
assertEquals(1L, request.get("channelNum"));
assertEquals(2L, request.get("resTextFormat"));
assertEquals(0L, request.get("sourceType"));
assertEquals("https://server.example.com/upload/audio/demo.m4a", request.get("url"));
assertEquals(1L, request.get("speakerDiarization"));
assertEquals(0L, request.get("speakerNumber"));
assertEquals("腾讯会议|5,离线转写|5", request.get("hotwordList"));
}
@Test
void buildTencentOfflineCreateRequestShouldKeepAbsoluteAudioUrl() {
AiTaskServiceImpl service = createService(mock(MeetingPointsService.class));
ReflectionTestUtils.setField(service, "serverBaseUrl", "https://server.example.com");
Meeting meeting = new Meeting();
meeting.setId(22L);
meeting.setAudioUrl("https://cdn.example.com/audio/demo.m4a");
AiTask task = new AiTask();
task.setTaskConfig(new HashMap<>(Map.of("useSpkId", 0)));
AiModelVO model = new AiModelVO();
model.setModelCode("16k_zh");
model.setMediaConfig(Map.of(
"tencentAppId", "123456",
"tencentSecretId", "secret-id",
"tencentSecretKey", "secret-key"
));
@SuppressWarnings("unchecked")
Map<String, Object> request = (Map<String, Object>) ReflectionTestUtils.invokeMethod(
service,
"buildTencentOfflineCreateRequest",
meeting,
task,
model
);
assertEquals("https://cdn.example.com/audio/demo.m4a", request.get("url"));
}
@Test
void processTencentOfflineAsrShouldPollUntilSuccessAndReturnTranscriptText() throws Exception {
MeetingPointsService meetingPointsService = mock(MeetingPointsService.class);
AiTaskServiceImpl service = spy(createService(meetingPointsService));
Meeting meeting = new Meeting();
meeting.setId(3L);
meeting.setAudioUrl("https://cdn.example.com/audio/demo.m4a");
AiTask task = new AiTask();
task.setId(31L);
task.setMeetingId(3L);
task.setTaskType("ASR");
task.setTaskConfig(new HashMap<>(Map.of(
"asrModelId", 101L,
"useSpkId", 1
)));
AiModelVO model = new AiModelVO();
model.setProvider("tencent");
model.setModelCode("legacy-model-code");
model.setMediaConfig(Map.of(
"tencentAppId", "123456",
"tencentSecretId", "secret-id",
"tencentSecretKey", "secret-key",
"tencentOfflineModelCode", "16k_zh",
"tencentRealtimeModelCode", "16k_zh_realtime"
));
TaskStatus doing = new TaskStatus();
doing.setStatusStr("doing");
SentenceDetail sentence = new SentenceDetail();
sentence.setSpeakerId(3L);
sentence.setFinalSentence("测试文本");
sentence.setStartMs(1000L);
sentence.setEndMs(2500L);
TaskStatus success = new TaskStatus();
success.setStatusStr("success");
success.setResultDetail(new SentenceDetail[]{sentence});
doReturn(90001L).when(service).submitTencentOfflineTask(meeting, task, model);
doReturn(doing).doReturn(success).when(service).queryTencentOfflineTask(model, 90001L);
doReturn("未知说话人3: 测试文本\n").when(service).saveTencentOfflineTranscripts(meeting, success.getResultDetail());
doReturn(true).when(service).updateById(any(AiTask.class));
String text = service.processTencentOfflineAsr(meeting, task, model);
assertEquals("未知说话人3: 测试文本\n", text);
}
@Test
void saveTencentOfflineTranscriptsShouldOverwriteExistingRowsAndUseUnknownSpeakerName() {
MeetingPointsService meetingPointsService = mock(MeetingPointsService.class);
MeetingTranscriptMapper transcriptMapper = mock(MeetingTranscriptMapper.class);
MeetingTranscriptFileService transcriptFileService = mock(MeetingTranscriptFileService.class);
AiTaskServiceImpl service = createService(meetingPointsService, transcriptMapper, transcriptFileService);
Meeting meeting = new Meeting();
meeting.setId(5L);
SentenceDetail first = new SentenceDetail();
first.setSpeakerId(7L);
first.setFinalSentence("第一句");
first.setStartMs(0L);
first.setEndMs(1000L);
SentenceDetail second = new SentenceDetail();
second.setSpeakerId(7L);
second.setFinalSentence("第二句");
second.setStartMs(1000L);
second.setEndMs(2000L);
String text = service.saveTencentOfflineTranscripts(meeting, new SentenceDetail[]{first, second});
assertEquals("未知说话人7: 第一句\n未知说话人7: 第二句\n", text);
verify(transcriptMapper).delete(any());
verify(transcriptMapper, org.mockito.Mockito.times(2)).insert(any());
verify(transcriptFileService).initializeTranscriptFileIfAbsent(5L);
}
private AiTaskServiceImpl createService(MeetingPointsService meetingPointsService) {
return createService(meetingPointsService, mock(MeetingTranscriptMapper.class), mock(MeetingTranscriptFileService.class));
}
private AiTaskServiceImpl createService(MeetingPointsService meetingPointsService,
MeetingTranscriptMapper transcriptMapper,
MeetingTranscriptFileService transcriptFileService) {
AiTaskServiceImpl service = new AiTaskServiceImpl(
mock(MeetingMapper.class),
transcriptMapper,
mock(AiModelService.class),
new ObjectMapper(),
mock(SysUserMapper.class),
mock(HotWordService.class),
mock(MeetingLockCache.class),
mock(MeetingAsrPermitCache.class),
mock(MeetingProgressService.class),
meetingPointsService,
mock(MeetingSummaryFileService.class),
transcriptFileService,
mock(MeetingTranscriptChapterService.class),
mock(MeetingSummaryPromptAssembler.class),
mock(TaskSecurityContextRunner.class),
mock(MeetingExternalSummaryWebhookTrigger.class),
mock(SysParamService.class)
);
ReflectionTestUtils.setField(service, "baseMapper", mock(AiTaskMapper.class));
ReflectionTestUtils.setField(service, "androidMeetingPushService", mock(AndroidMeetingPushService.class));
return service;
}
}

View File

@ -24,6 +24,7 @@ import {
testLocalModelConnectivity,
updateAiModel,
} from "../../api/business/aimodel";
import {getMeetingCreateConfig, type MeetingCreateConfig} from "../../api/business/meeting";
import AppPagination from "../../components/shared/AppPagination";
const { Option } = Select;
@ -31,6 +32,12 @@ const { Title } = Typography;
type ModelType = "ASR" | "LLM";
const DEFAULT_CREATE_CONFIG: MeetingCreateConfig = {
offlineEnabled: true,
realtimeEnabled: false,
offlineAudioMaxSizeMb: 1024,
};
const PROVIDER_BASE_URL_MAP: Record<string, string> = {
openai: "https://api.openai.com",
deepseek: "https://api.deepseek.com",
@ -64,6 +71,7 @@ const AiModels: React.FC = () => {
const [connectivityLoading, setConnectivityLoading] = useState(false);
const [remoteModels, setRemoteModels] = useState<string[]>([]);
const [speakerModels, setSpeakerModels] = useState<string[]>([]);
const [createConfig, setCreateConfig] = useState<MeetingCreateConfig>(DEFAULT_CREATE_CONFIG);
const modelNameAutoFilledRef = useRef(false);
const localProfileLoadedRef = useRef(false);
@ -86,6 +94,20 @@ const AiModels: React.FC = () => {
void fetchData();
}, [current, size, searchName, activeType]);
useEffect(() => {
getMeetingCreateConfig()
.then((res) => {
const config = (res as any)?.data?.data ?? (res as any);
setCreateConfig({
...DEFAULT_CREATE_CONFIG,
...(config || {}),
});
})
.catch(() => {
setCreateConfig(DEFAULT_CREATE_CONFIG);
});
}, []);
useEffect(() => {
if (!drawerVisible || !provider) {
return;
@ -137,6 +159,8 @@ const AiModels: React.FC = () => {
const tencentAppId = record.mediaConfig?.tencentAppId;
const tencentSecretId = record.mediaConfig?.tencentSecretId;
const tencentSecretKey = record.mediaConfig?.tencentSecretKey;
const tencentOfflineModelCode = record.mediaConfig?.tencentOfflineModelCode || record.modelCode;
const tencentRealtimeModelCode = record.mediaConfig?.tencentRealtimeModelCode || record.modelCode;
form.setFieldsValue({
...record,
modelType: record.modelType,
@ -145,6 +169,8 @@ const AiModels: React.FC = () => {
tencentAppId,
tencentSecretId,
tencentSecretKey,
tencentOfflineModelCode,
tencentRealtimeModelCode,
isDefaultChecked: record.isDefault === 1,
statusChecked: record.status === 1,
});
@ -261,8 +287,8 @@ const AiModels: React.FC = () => {
baseUrl: values.baseUrl,
apiPath: values.apiPath,
apiKey: values.apiKey,
modelCode: values.modelCode,
wsUrl: values.wsUrl,
modelCode: activeType === "ASR" && isTencentProvider ? values.tencentOfflineModelCode : values.modelCode,
wsUrl: activeType === "ASR" ? values.wsUrl : undefined,
mediaConfig:
activeType === "ASR" && isLocalProvider
? {
@ -274,6 +300,8 @@ const AiModels: React.FC = () => {
tencentAppId: values.tencentAppId,
tencentSecretId: values.tencentSecretId,
tencentSecretKey: values.tencentSecretKey,
tencentOfflineModelCode: values.tencentOfflineModelCode,
tencentRealtimeModelCode: values.tencentRealtimeModelCode,
}
: undefined,
temperature: values.temperature,
@ -567,13 +595,14 @@ const AiModels: React.FC = () => {
<Form.Item
label="模型名称"
required={activeType === "LLM"}
hidden={activeType === "ASR" && isTencentProvider}
tooltip="可从远程列表选择,也可手动输入;值将作为模型 code 传给后端"
>
<Space.Compact style={{ width: "100%" }}>
<Form.Item
name="modelCode"
noStyle
rules={activeType === "LLM" || isTencentProvider ? [{
rules={activeType === "LLM" ? [{
required: true,
message: "请输入或选择模型名称"
}] : []}
@ -602,7 +631,8 @@ const AiModels: React.FC = () => {
</Space.Compact>
</Form.Item>
<Form.Item name="wsUrl" label="WebSocket 地址" hidden>
<Form.Item name="wsUrl" label="WebSocket 地址"
hidden={!(activeType === "ASR" && createConfig.realtimeEnabled)}>
<Input placeholder="wss://api.example.com/v1/ws" />
</Form.Item>
@ -660,6 +690,24 @@ const AiModels: React.FC = () => {
<Input.Password/>
</Form.Item>
</Col>
<Col span={12}>
<Form.Item
name="tencentOfflineModelCode"
label="离线识别模型名"
rules={[{required: true, message: "请输入离线识别模型名"}]}
>
<Input placeholder="例如16k_zh"/>
</Form.Item>
</Col>
<Col span={12}>
<Form.Item
name="tencentRealtimeModelCode"
label="实时识别模型名"
rules={[{required: true, message: "请输入实时识别模型名"}]}
>
<Input placeholder="例如16k_zh_realtime"/>
</Form.Item>
</Col>
</Row>
)}

View File

@ -244,6 +244,7 @@ export function RealtimeAsrSession() {
const [sessionStatus, setSessionStatus] = useState<RealtimeMeetingSessionStatus | null>(null);
const transcriptRef = useRef<HTMLDivElement | null>(null);
const wsRef = useRef<WebSocket | null>(null);
const wsHeartbeatRef = useRef<ReturnType<typeof setInterval> | null>(null);
const audioContextRef = useRef<AudioContext | null>(null);
const processorRef = useRef<ScriptProcessorNode | null>(null);
const audioSourceRef = useRef<MediaStreamAudioSourceNode | null>(null);
@ -373,6 +374,33 @@ export function RealtimeAsrSession() {
return () => window.removeEventListener("pagehide", handlePageHide);
}, [meetingId]);
// 组件卸载切换路由时统一清理所有资源防止定时器、WebSocket、音频管道泄漏
useEffect(() => {
return () => {
// 1. 清理心跳定时器
if (wsHeartbeatRef.current !== null) {
clearInterval(wsHeartbeatRef.current);
wsHeartbeatRef.current = null;
}
// 2. 关闭 WebSocket移除回调避免触发不必要的状态更新
if (wsRef.current) {
wsRef.current.onclose = null;
wsRef.current.onerror = null;
wsRef.current.onmessage = null;
wsRef.current.close();
wsRef.current = null;
}
// 3. 关闭音频管道(同步处理,无需 await
processorRef.current?.disconnect();
audioSourceRef.current?.disconnect();
streamRef.current?.getTracks().forEach((t) => t.stop());
if (audioContextRef.current && audioContextRef.current.state !== "closed") {
void audioContextRef.current.close();
}
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const shutdownAudioPipeline = async () => {
processorRef.current?.disconnect();
audioSourceRef.current?.disconnect();
@ -433,6 +461,10 @@ export function RealtimeAsrSession() {
setRecording(false);
setStatusText("连接失败");
sessionStartedRef.current = false;
if (wsHeartbeatRef.current !== null) {
clearInterval(wsHeartbeatRef.current);
wsHeartbeatRef.current = null;
}
wsRef.current?.close();
wsRef.current = null;
await shutdownAudioPipeline();
@ -442,6 +474,30 @@ export function RealtimeAsrSession() {
message.error(errorMessage);
};
const handleUpstreamPauseError = async (errorMessage: string) => {
if (recording && startedAtRef.current) {
elapsedOffsetRef.current += Math.floor((Date.now() - startedAtRef.current) / 1000);
}
setConnecting(false);
setRecording(false);
setStatusText("上游识别已断开,正在暂停会议...");
sessionStartedRef.current = false;
wsRef.current?.close();
wsRef.current = null;
await shutdownAudioPipeline();
startedAtRef.current = null;
try {
const pauseRes = await pauseRealtimeMeeting(meetingId);
setSessionStatus(pauseRes.data.data);
setElapsedSeconds(elapsedOffsetRef.current);
setStatusText("已暂停,可继续会议");
message.error(errorMessage);
} catch {
setStatusText("识别已断开");
message.error(errorMessage);
}
};
const startAudioPipeline = async () => {
if (!window.isSecureContext || !navigator.mediaDevices?.getUserMedia) {
throw new Error("当前浏览器环境不支持麦克风访问。请使用 localhost 或 HTTPS 域名访问系统。");
@ -506,6 +562,13 @@ export function RealtimeAsrSession() {
}
const pauseRes = await pauseRealtimeMeeting(meetingId);
await closeFrontendSocket(false);
if (wsHeartbeatRef.current !== null) {
clearInterval(wsHeartbeatRef.current);
wsHeartbeatRef.current = null;
}
wsRef.current?.close();
wsRef.current = null;
sessionStartedRef.current = false;
await shutdownAudioPipeline();
setSessionStatus(pauseRes.data.data);
setRecording(false);
@ -551,12 +614,41 @@ export function RealtimeAsrSession() {
hotwords: sessionDraft.hotwords || [],
});
const socketSession = socketSessionRes.data.data;
// 如果已有旧的 WebSocket比如重连先强制关闭并清理心跳
if (wsHeartbeatRef.current !== null) {
clearInterval(wsHeartbeatRef.current);
wsHeartbeatRef.current = null;
}
if (wsRef.current) {
wsRef.current.onclose = null;
wsRef.current.onerror = null;
wsRef.current.onmessage = null;
wsRef.current.close();
wsRef.current = null;
}
const socket = new WebSocket(buildRealtimeProxyWsUrl(socketSession));
socket.binaryType = "arraybuffer";
wsRef.current = socket;
socket.onopen = () => {
setStatusText("识别服务连接中,等待第三方服务就绪...");
// 先清除旧定时器(防止 onopen 被意外重复触发时叠加)
if (wsHeartbeatRef.current !== null) {
clearInterval(wsHeartbeatRef.current);
wsHeartbeatRef.current = null;
}
// 启动心跳保活:每 20 秒发送一次 keepalive JSON防止服务端 ping timeout 断开
wsHeartbeatRef.current = setInterval(() => {
if (socket.readyState === WebSocket.OPEN) {
try {
socket.send(JSON.stringify({ type: "keepalive" }));
} catch {
// ignore heartbeat send failure
}
}
}, 20000);
};
socket.onmessage = (event) => {
@ -585,7 +677,11 @@ export function RealtimeAsrSession() {
if ((payload.code || payload.type === "error") && payload.message) {
setStatusText(payload.message);
void handleFatalRealtimeError(payload.message);
if (payload.code === "REALTIME_UPSTREAM_CLOSED" || payload.code === "REALTIME_UPSTREAM_ERROR") {
void handleUpstreamPauseError(payload.message);
} else {
void handleFatalRealtimeError(payload.message);
}
return;
}
@ -613,6 +709,10 @@ export function RealtimeAsrSession() {
};
socket.onclose = () => {
if (wsHeartbeatRef.current !== null) {
clearInterval(wsHeartbeatRef.current);
wsHeartbeatRef.current = null;
}
setConnecting(false);
setRecording(false);
sessionStartedRef.current = false;
@ -640,6 +740,16 @@ export function RealtimeAsrSession() {
setStatusText("结束会议中...");
await closeFrontendSocket(true);
if (wsRef.current?.readyState === WebSocket.OPEN) {
wsRef.current.send(JSON.stringify({ is_speaking: false }));
}
if (wsHeartbeatRef.current !== null) {
clearInterval(wsHeartbeatRef.current);
wsHeartbeatRef.current = null;
}
wsRef.current?.close();
wsRef.current = null;
sessionStartedRef.current = false;
await shutdownAudioPipeline();
@ -804,17 +914,17 @@ export function RealtimeAsrSession() {
<Text type="secondary" style={{ fontSize: 13 }}>
<strong style={{ color: "#334155" }}>{totalTranscriptChars}</strong>
</Text>
<Text type="secondary" style={{ fontSize: 13 }}>
<strong style={{ color: "#334155" }}>{sessionDraft.asrModelName}</strong>
</Text>
<Text type="secondary" style={{ fontSize: 13 }}>
<strong style={{ color: "#334155" }}>{sessionDraft.mode}</strong>
</Text>
{sessionDraft.hotwords && sessionDraft.hotwords.length > 0 && (
<Text type="secondary" style={{ fontSize: 13 }}>
<strong style={{ color: "#334155" }}>{sessionDraft.hotwords.length}</strong>
</Text>
)}
{/*<Text type="secondary" style={{ fontSize: 13 }}>*/}
{/* 模型 <strong style={{ color: "#334155" }}>{sessionDraft.asrModelName}</strong>*/}
{/*</Text>*/}
{/*<Text type="secondary" style={{ fontSize: 13 }}>*/}
{/* 模式 <strong style={{ color: "#334155" }}>{sessionDraft.mode}</strong>*/}
{/*</Text>*/}
{/*{sessionDraft.hotwords && sessionDraft.hotwords.length > 0 && (*/}
{/* <Text type="secondary" style={{ fontSize: 13 }}>*/}
{/* 热词 <strong style={{ color: "#334155" }}>{sessionDraft.hotwords.length}</strong>*/}
{/* </Text>*/}
{/*)}*/}
</Space>
)}
</div>