diff --git a/backend/pom.xml b/backend/pom.xml
index a392a88..4996a39 100644
--- a/backend/pom.xml
+++ b/backend/pom.xml
@@ -183,6 +183,11 @@
tencentcloud-speech-sdk-java
1.0.67
+
+ com.tencentcloudapi
+ tencentcloud-sdk-java-asr
+ 3.1.1470
+
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..836ad3d 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
@@ -956,16 +956,16 @@ 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");
+ throw new RuntimeException("腾讯 ASR 模型必须配置 modelCode");
}
}
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..a24b717 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,12 @@ 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 TENCENT_ASR_REGION = "ap-guangzhou";
private final MeetingMapper meetingMapper;
private final MeetingTranscriptMapper transcriptMapper;
@@ -609,6 +625,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 +726,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", asrModel.getModelCode());
+ 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 +1559,93 @@ 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 AiModelVO resolveAsrModelForRevision(AiTask asrTask) {
if (asrTask == null || asrTask.getTaskConfig() == null) {
return null;
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 cddc277..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
@@ -525,7 +525,6 @@ 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()
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..15aac02 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,7 @@ class AiModelServiceImplTest {
));
RuntimeException ex = assertThrows(RuntimeException.class, () -> service.saveModel(dto));
- assertEquals("腾讯实时 ASR 模型必须配置 mediaConfig.tencentSecretKey", ex.getMessage());
+ assertEquals("腾讯 ASR 模型必须配置 mediaConfig.tencentSecretKey", ex.getMessage());
}
@Test
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..112c888 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,304 @@
-//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