diff --git a/backend/src/main/java/com/imeeting/config/RealtimeMeetingWebSocketConfig.java b/backend/src/main/java/com/imeeting/config/RealtimeMeetingWebSocketConfig.java
index f940e26..e547594 100644
--- a/backend/src/main/java/com/imeeting/config/RealtimeMeetingWebSocketConfig.java
+++ b/backend/src/main/java/com/imeeting/config/RealtimeMeetingWebSocketConfig.java
@@ -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)。
+ *
+ * Tomcat 默认会对 WebSocket session 设置 sessionIdleTimeout(-1 表示无限,但某些版本默认非 -1),
+ * 并通过后台线程定期发送 Ping 帧,若在超时内未收到 Pong 响应,触发
+ * code=1011 "keepalive ping timeout" 强制断开。
+ * 实时 ASR 场景中,客户端持续发送音频帧,由前端心跳保活,
+ * 因此显式将 sessionIdleTimeout 设为 -1(无限)。
+ *
+ */
+ @Bean
+ public WebServerFactoryCustomizer 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");
+ }
+ });
+ }
}
diff --git a/backend/src/main/java/com/imeeting/service/android/legacy/impl/LegacyMeetingAdapterServiceImpl.java b/backend/src/main/java/com/imeeting/service/android/legacy/impl/LegacyMeetingAdapterServiceImpl.java
index 9d715d2..6f3c436 100644
--- a/backend/src/main/java/com/imeeting/service/android/legacy/impl/LegacyMeetingAdapterServiceImpl.java
+++ b/backend/src/main/java/com/imeeting/service/android/legacy/impl/LegacyMeetingAdapterServiceImpl.java
@@ -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()
diff --git a/backend/src/main/java/com/imeeting/service/biz/impl/AiModelServiceImpl.java b/backend/src/main/java/com/imeeting/service/biz/impl/AiModelServiceImpl.java
index 36e31a6..bdfe5e8 100644
--- a/backend/src/main/java/com/imeeting/service/biz/impl/AiModelServiceImpl.java
+++ b/backend/src/main/java/com/imeeting/service/biz/impl/AiModelServiceImpl.java
@@ -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 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");
}
}
diff --git a/backend/src/main/java/com/imeeting/service/biz/impl/AiTaskServiceImpl.java b/backend/src/main/java/com/imeeting/service/biz/impl/AiTaskServiceImpl.java
index d61460f..6e6e606 100644
--- a/backend/src/main/java/com/imeeting/service/biz/impl/AiTaskServiceImpl.java
+++ b/backend/src/main/java/com/imeeting/service/biz/impl/AiTaskServiceImpl.java
@@ -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 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 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 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 buildTencentOfflineCreateRequest(Meeting meeting, AiTask taskRecord, AiModelVO asrModel) {
+ Map 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 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().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 buildAsrRequest(Meeting meeting, AiTask taskRecord, AiModelVO asrModel) {
Map req = new HashMap<>();
String rawAudioUrl = meeting.getAudioUrl();
@@ -1417,6 +1560,102 @@ public class AiTaskServiceImpl extends ServiceImpl impleme
}
}
+ private AsrClient buildTencentOfflineAsrClient(AiModelVO asrModel) {
+ Map 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 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 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 buildTencentTaskStatusSnapshot(TaskStatus taskStatus) {
+ Map 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 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;
diff --git a/backend/src/main/java/com/imeeting/service/biz/impl/RealtimeMeetingSocketSessionServiceImpl.java b/backend/src/main/java/com/imeeting/service/biz/impl/RealtimeMeetingSocketSessionServiceImpl.java
index b15fcf7..e7603e2 100644
--- a/backend/src/main/java/com/imeeting/service/biz/impl/RealtimeMeetingSocketSessionServiceImpl.java
+++ b/backend/src/main/java/com/imeeting/service/biz/impl/RealtimeMeetingSocketSessionServiceImpl.java
@@ -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 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);
diff --git a/backend/src/main/java/com/imeeting/service/realtime/impl/LocalRealtimeAsrChannel.java b/backend/src/main/java/com/imeeting/service/realtime/impl/LocalRealtimeAsrChannel.java
index 5b03acc..bc7f758 100644
--- a/backend/src/main/java/com/imeeting/service/realtime/impl/LocalRealtimeAsrChannel.java
+++ b/backend/src/main/java/com/imeeting/service/realtime/impl/LocalRealtimeAsrChannel.java
@@ -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());
}
}
}
diff --git a/backend/src/main/java/com/imeeting/service/realtime/impl/RealtimeMeetingTranscriptCacheServiceImpl.java b/backend/src/main/java/com/imeeting/service/realtime/impl/RealtimeMeetingTranscriptCacheServiceImpl.java
index 235b14a..9ca0098 100644
--- a/backend/src/main/java/com/imeeting/service/realtime/impl/RealtimeMeetingTranscriptCacheServiceImpl.java
+++ b/backend/src/main/java/com/imeeting/service/realtime/impl/RealtimeMeetingTranscriptCacheServiceImpl.java
@@ -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) {
diff --git a/backend/src/main/java/com/imeeting/websocket/RealtimeMeetingProxyWebSocketHandler.java b/backend/src/main/java/com/imeeting/websocket/RealtimeMeetingProxyWebSocketHandler.java
index d19ac61..c5aba29 100644
--- a/backend/src/main/java/com/imeeting/websocket/RealtimeMeetingProxyWebSocketHandler.java
+++ b/backend/src/main/java/com/imeeting/websocket/RealtimeMeetingProxyWebSocketHandler.java
@@ -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 meetingSessions = new ConcurrentHashMap<>();
- private final ConcurrentMap meetingLocks = new ConcurrentHashMap<>();
+ private final RealtimeMeetingTranscriptCacheService realtimeMeetingTranscriptCacheService;
+ private final RealtimeAsrChannelFactory realtimeAsrChannelFactory;
+ private final ConcurrentMap meetingSessions = new ConcurrentHashMap<>();
+ private final ConcurrentMap 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 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 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) {
diff --git a/backend/src/test/java/com/imeeting/service/biz/impl/AiModelServiceImplTest.java b/backend/src/test/java/com/imeeting/service/biz/impl/AiModelServiceImplTest.java
index 6fad4b0..4fa68a6 100644
--- a/backend/src/test/java/com/imeeting/service/biz/impl/AiModelServiceImplTest.java
+++ b/backend/src/test/java/com/imeeting/service/biz/impl/AiModelServiceImplTest.java
@@ -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 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());
}
diff --git a/backend/src/test/java/com/imeeting/service/biz/impl/AiTaskServiceImplTest.java b/backend/src/test/java/com/imeeting/service/biz/impl/AiTaskServiceImplTest.java
index 8caac30..352332c 100644
--- a/backend/src/test/java/com/imeeting/service/biz/impl/AiTaskServiceImplTest.java
+++ b/backend/src/test/java/com/imeeting/service/biz/impl/AiTaskServiceImplTest.java
@@ -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 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 request = (Map) 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 config = (Map) 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