feat: 添加重试机制以增强 ASR 任务的稳定性
- 在 `AiTaskServiceImpl` 中引入 `RetryExecutor` 和 `RetryOptions`,以处理 ASR 任务提交和查询的超时及连接异常 - 添加多个单元测试以验证 ASR 任务重试逻辑dev_na
parent
57f343ddfd
commit
0593e0c23d
|
|
@ -30,6 +30,8 @@ import com.imeeting.service.biz.MeetingPointsService;
|
||||||
import com.imeeting.service.biz.MeetingSummaryFileService;
|
import com.imeeting.service.biz.MeetingSummaryFileService;
|
||||||
import com.imeeting.service.biz.MeetingTranscriptChapterService;
|
import com.imeeting.service.biz.MeetingTranscriptChapterService;
|
||||||
import com.imeeting.service.biz.MeetingTranscriptFileService;
|
import com.imeeting.service.biz.MeetingTranscriptFileService;
|
||||||
|
import com.imeeting.support.retry.RetryExecutor;
|
||||||
|
import com.imeeting.support.retry.RetryOptions;
|
||||||
|
|
||||||
import com.imeeting.support.TaskSecurityContextRunner;
|
import com.imeeting.support.TaskSecurityContextRunner;
|
||||||
import com.imeeting.support.redis.MeetingAsrPermitCache;
|
import com.imeeting.support.redis.MeetingAsrPermitCache;
|
||||||
|
|
@ -105,6 +107,7 @@ public class AiTaskServiceImpl extends ServiceImpl<AiTaskMapper, AiTask> impleme
|
||||||
private final TaskSecurityContextRunner taskSecurityContextRunner;
|
private final TaskSecurityContextRunner taskSecurityContextRunner;
|
||||||
private final MeetingExternalSummaryWebhookTrigger meetingExternalSummaryWebhookTrigger;
|
private final MeetingExternalSummaryWebhookTrigger meetingExternalSummaryWebhookTrigger;
|
||||||
private final SysParamService sysParamService;
|
private final SysParamService sysParamService;
|
||||||
|
private final RetryExecutor retryExecutor;
|
||||||
|
|
||||||
@Autowired
|
@Autowired
|
||||||
@Qualifier("asrTaskExecutor")
|
@Qualifier("asrTaskExecutor")
|
||||||
|
|
@ -152,6 +155,31 @@ public class AiTaskServiceImpl extends ServiceImpl<AiTaskMapper, AiTask> impleme
|
||||||
TaskSecurityContextRunner taskSecurityContextRunner,
|
TaskSecurityContextRunner taskSecurityContextRunner,
|
||||||
MeetingExternalSummaryWebhookTrigger meetingExternalSummaryWebhookTrigger,
|
MeetingExternalSummaryWebhookTrigger meetingExternalSummaryWebhookTrigger,
|
||||||
SysParamService sysParamService) {
|
SysParamService sysParamService) {
|
||||||
|
this(meetingMapper, transcriptMapper, aiModelService, objectMapper, sysUserMapper, hotWordService,
|
||||||
|
meetingLockCache, meetingAsrPermitCache, meetingProgressService, meetingPointsService,
|
||||||
|
meetingSummaryFileService, meetingTranscriptFileService, meetingTranscriptChapterService,
|
||||||
|
meetingSummaryPromptAssembler, taskSecurityContextRunner, meetingExternalSummaryWebhookTrigger,
|
||||||
|
sysParamService, new RetryExecutor());
|
||||||
|
}
|
||||||
|
|
||||||
|
public AiTaskServiceImpl(MeetingMapper meetingMapper,
|
||||||
|
MeetingTranscriptMapper transcriptMapper,
|
||||||
|
AiModelService aiModelService,
|
||||||
|
ObjectMapper objectMapper,
|
||||||
|
SysUserMapper sysUserMapper,
|
||||||
|
HotWordService hotWordService,
|
||||||
|
MeetingLockCache meetingLockCache,
|
||||||
|
MeetingAsrPermitCache meetingAsrPermitCache,
|
||||||
|
MeetingProgressService meetingProgressService,
|
||||||
|
MeetingPointsService meetingPointsService,
|
||||||
|
MeetingSummaryFileService meetingSummaryFileService,
|
||||||
|
MeetingTranscriptFileService meetingTranscriptFileService,
|
||||||
|
MeetingTranscriptChapterService meetingTranscriptChapterService,
|
||||||
|
MeetingSummaryPromptAssembler meetingSummaryPromptAssembler,
|
||||||
|
TaskSecurityContextRunner taskSecurityContextRunner,
|
||||||
|
MeetingExternalSummaryWebhookTrigger meetingExternalSummaryWebhookTrigger,
|
||||||
|
SysParamService sysParamService,
|
||||||
|
RetryExecutor retryExecutor) {
|
||||||
this.meetingMapper = meetingMapper;
|
this.meetingMapper = meetingMapper;
|
||||||
this.transcriptMapper = transcriptMapper;
|
this.transcriptMapper = transcriptMapper;
|
||||||
this.aiModelService = aiModelService;
|
this.aiModelService = aiModelService;
|
||||||
|
|
@ -169,6 +197,7 @@ public class AiTaskServiceImpl extends ServiceImpl<AiTaskMapper, AiTask> impleme
|
||||||
this.taskSecurityContextRunner = taskSecurityContextRunner;
|
this.taskSecurityContextRunner = taskSecurityContextRunner;
|
||||||
this.meetingExternalSummaryWebhookTrigger = meetingExternalSummaryWebhookTrigger;
|
this.meetingExternalSummaryWebhookTrigger = meetingExternalSummaryWebhookTrigger;
|
||||||
this.sysParamService = sysParamService;
|
this.sysParamService = sysParamService;
|
||||||
|
this.retryExecutor = retryExecutor;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
|
|
@ -668,7 +697,17 @@ public class AiTaskServiceImpl extends ServiceImpl<AiTaskMapper, AiTask> impleme
|
||||||
|
|
||||||
for (int i = 0; i < 600; i++) {
|
for (int i = 0; i < 600; i++) {
|
||||||
Thread.sleep(2000);
|
Thread.sleep(2000);
|
||||||
String queryResp = get(queryUrl, asrModel.getApiKey());
|
String queryResp = retryExecutor.execute(
|
||||||
|
RetryOptions.builder()
|
||||||
|
.operation("asr-query")
|
||||||
|
.exhaustedMessage("ASR 查询超时,重试已耗尽")
|
||||||
|
.onRetry((attempt, maxAttempts, delayMs, ex) -> {
|
||||||
|
log.info("[ASR-PROC]ASR轮询结果正在重试中,meetingId={},重试次数:{},轮询url:{}", meeting.getId(), attempt, queryUrl);
|
||||||
|
updateProgress(meeting.getId(), 5, "ASR 查询超时,正在重试(" + attempt + "/" + maxAttempts + ")...", 0);
|
||||||
|
})
|
||||||
|
.build(),
|
||||||
|
() -> get(queryUrl, asrModel.getApiKey())
|
||||||
|
);
|
||||||
JsonNode statusNode = objectMapper.readTree(queryResp);
|
JsonNode statusNode = objectMapper.readTree(queryResp);
|
||||||
int code = statusNode.path("code").asInt(500);
|
int code = statusNode.path("code").asInt(500);
|
||||||
if (code!=0){
|
if (code!=0){
|
||||||
|
|
@ -736,7 +775,16 @@ public class AiTaskServiceImpl extends ServiceImpl<AiTaskMapper, AiTask> impleme
|
||||||
TaskStatus taskStatus = null;
|
TaskStatus taskStatus = null;
|
||||||
for (int i = 0; i < 600; i++) {
|
for (int i = 0; i < 600; i++) {
|
||||||
Thread.sleep(2000);
|
Thread.sleep(2000);
|
||||||
taskStatus = queryTencentOfflineTask(asrModel, taskId);
|
Long currentTaskId = taskId;
|
||||||
|
taskStatus = retryExecutor.execute(
|
||||||
|
RetryOptions.builder()
|
||||||
|
.operation("tencent-asr-query")
|
||||||
|
.exhaustedMessage("腾讯离线 ASR 查询超时,重试已耗尽")
|
||||||
|
.onRetry((attempt, maxAttempts, delayMs, ex) ->
|
||||||
|
updateProgress(meeting.getId(), 5, "腾讯离线 ASR 查询超时,正在重试(" + attempt + "/" + maxAttempts + ")...", 0))
|
||||||
|
.build(),
|
||||||
|
() -> queryTencentOfflineTask(asrModel, currentTaskId)
|
||||||
|
);
|
||||||
if (taskStatus == null) {
|
if (taskStatus == null) {
|
||||||
throw new RuntimeException("腾讯离线 ASR 查询结果为空");
|
throw new RuntimeException("腾讯离线 ASR 查询结果为空");
|
||||||
}
|
}
|
||||||
|
|
@ -865,8 +913,10 @@ public class AiTaskServiceImpl extends ServiceImpl<AiTaskMapper, AiTask> impleme
|
||||||
boolean useSpk = useSpkObj != null && useSpkObj.toString().equals("1");
|
boolean useSpk = useSpkObj != null && useSpkObj.toString().equals("1");
|
||||||
config.put("enable_speaker", useSpk);
|
config.put("enable_speaker", useSpk);
|
||||||
config.put("match_speaker_registry", useSpk);
|
config.put("match_speaker_registry", useSpk);
|
||||||
|
if (asrModel.getMediaConfig() != null) {
|
||||||
config.put("speaker_threshold", asrModel.getMediaConfig().get("svThreshold"));
|
config.put("speaker_threshold", asrModel.getMediaConfig().get("svThreshold"));
|
||||||
Object enableTextRefineObj = taskRecord.getTaskConfig().get("enableTextRefine");
|
}
|
||||||
|
Object enableTextRefineObj = taskRecord.getTaskConfig().get("enableTextRefine");
|
||||||
boolean enableTextRefine = enableTextRefineObj != null && Boolean.parseBoolean(enableTextRefineObj.toString());
|
boolean enableTextRefine = enableTextRefineObj != null && Boolean.parseBoolean(enableTextRefineObj.toString());
|
||||||
config.put("enable_text_cleanup", enableTextRefine);
|
config.put("enable_text_cleanup", enableTextRefine);
|
||||||
|
|
||||||
|
|
@ -935,7 +985,18 @@ public class AiTaskServiceImpl extends ServiceImpl<AiTaskMapper, AiTask> impleme
|
||||||
this.updateById(taskRecord);
|
this.updateById(taskRecord);
|
||||||
meetingPointsService.assertSufficientPointsBeforeAsrSubmit(meeting, taskRecord);
|
meetingPointsService.assertSufficientPointsBeforeAsrSubmit(meeting, taskRecord);
|
||||||
|
|
||||||
String respBody = postJson(submitUrl, req, asrModel.getApiKey());
|
String respBody = retryExecutor.execute(
|
||||||
|
RetryOptions.builder()
|
||||||
|
.operation("asr-submit")
|
||||||
|
.exhaustedMessage("ASR 提交失败,重试已耗尽")
|
||||||
|
.onRetry((attempt, maxAttempts, delayMs, ex) -> {
|
||||||
|
log.info("[ASR-PROC]ASR提交任务正在重试中,meetingId={},重试次数:{}", meeting.getId(), attempt);
|
||||||
|
updateProgress(meeting.getId(), 5, "ASR 提交失败,正在重试(" + attempt + "/" + maxAttempts + ")...", 0);
|
||||||
|
}
|
||||||
|
)
|
||||||
|
.build(),
|
||||||
|
() -> postJson(submitUrl, req, asrModel.getApiKey())
|
||||||
|
);
|
||||||
JsonNode submitNode = objectMapper.readTree(respBody);
|
JsonNode submitNode = objectMapper.readTree(respBody);
|
||||||
if (submitNode.path("code")==null||submitNode.path("code").asInt() != 0) {
|
if (submitNode.path("code")==null||submitNode.path("code").asInt() != 0) {
|
||||||
updateAiTaskFail(taskRecord, "ASR识别失败 " + respBody);
|
updateAiTaskFail(taskRecord, "ASR识别失败 " + respBody);
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,91 @@
|
||||||
|
package com.imeeting.support.retry;
|
||||||
|
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
|
||||||
|
import java.net.ConnectException;
|
||||||
|
import java.net.http.HttpTimeoutException;
|
||||||
|
import java.util.function.Predicate;
|
||||||
|
|
||||||
|
@Slf4j
|
||||||
|
public class RetryExecutor {
|
||||||
|
|
||||||
|
private static final int DEFAULT_MAX_ATTEMPTS = 3;
|
||||||
|
private static final long[] DEFAULT_DELAYS_MS = new long[]{2000L, 4000L};
|
||||||
|
|
||||||
|
private final Sleeper sleeper;
|
||||||
|
|
||||||
|
public RetryExecutor() {
|
||||||
|
this(Thread::sleep);
|
||||||
|
}
|
||||||
|
|
||||||
|
public RetryExecutor(Sleeper sleeper) {
|
||||||
|
this.sleeper = sleeper;
|
||||||
|
}
|
||||||
|
|
||||||
|
public <T> T execute(RetryCall<T> call) throws Exception {
|
||||||
|
return execute(null, call);
|
||||||
|
}
|
||||||
|
|
||||||
|
public <T> T execute(RetryOptions options, RetryCall<T> call) throws Exception {
|
||||||
|
int maxAttempts = options == null || options.getMaxAttempts() == null ? DEFAULT_MAX_ATTEMPTS : options.getMaxAttempts();
|
||||||
|
long[] delaysMs = options == null || options.getDelaysMs() == null || options.getDelaysMs().length == 0
|
||||||
|
? DEFAULT_DELAYS_MS
|
||||||
|
: options.getDelaysMs();
|
||||||
|
Predicate<Throwable> retryPredicate = options == null || options.getRetryPredicate() == null
|
||||||
|
? this::isDefaultRetryable
|
||||||
|
: options.getRetryPredicate();
|
||||||
|
|
||||||
|
Exception lastException = null;
|
||||||
|
for (int attempt = 1; attempt <= maxAttempts; attempt++) {
|
||||||
|
try {
|
||||||
|
return call.execute();
|
||||||
|
} catch (Exception ex) {
|
||||||
|
lastException = ex;
|
||||||
|
if (!retryPredicate.test(ex)) {
|
||||||
|
throw ex;
|
||||||
|
}
|
||||||
|
if (attempt >= maxAttempts) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
long delayMs = delaysMs[Math.min(attempt - 1, delaysMs.length - 1)];
|
||||||
|
if (options != null && options.getOnRetry() != null) {
|
||||||
|
options.getOnRetry().onRetry(attempt, maxAttempts, delayMs, ex);
|
||||||
|
}
|
||||||
|
sleeper.sleep(delayMs);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
String exhaustedMessage = options == null || options.getExhaustedMessage() == null || options.getExhaustedMessage().isBlank()
|
||||||
|
? "重试已耗尽"
|
||||||
|
: options.getExhaustedMessage();
|
||||||
|
throw lastException == null ? new RuntimeException(exhaustedMessage) : new RuntimeException(exhaustedMessage, lastException);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected boolean isDefaultRetryable(Throwable throwable) {
|
||||||
|
if (throwable == null) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (throwable instanceof HttpTimeoutException || throwable instanceof ConnectException) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
String message = throwable.getMessage();
|
||||||
|
if (message == null || message.isBlank()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
String normalized = message.toLowerCase();
|
||||||
|
return normalized.contains("timeout")
|
||||||
|
|| normalized.contains("timed out")
|
||||||
|
|| normalized.contains("temporarily unavailable")
|
||||||
|
|| normalized.contains("connection refused");
|
||||||
|
}
|
||||||
|
|
||||||
|
@FunctionalInterface
|
||||||
|
public interface RetryCall<T> {
|
||||||
|
T execute() throws Exception;
|
||||||
|
}
|
||||||
|
|
||||||
|
@FunctionalInterface
|
||||||
|
public interface Sleeper {
|
||||||
|
void sleep(long delayMs) throws InterruptedException;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,23 @@
|
||||||
|
package com.imeeting.support.retry;
|
||||||
|
|
||||||
|
import lombok.Builder;
|
||||||
|
import lombok.Getter;
|
||||||
|
|
||||||
|
import java.util.function.Predicate;
|
||||||
|
|
||||||
|
@Getter
|
||||||
|
@Builder
|
||||||
|
public class RetryOptions {
|
||||||
|
|
||||||
|
private String operation;
|
||||||
|
private Integer maxAttempts;
|
||||||
|
private long[] delaysMs;
|
||||||
|
private Predicate<Throwable> retryPredicate;
|
||||||
|
private RetryCallback onRetry;
|
||||||
|
private String exhaustedMessage;
|
||||||
|
|
||||||
|
@FunctionalInterface
|
||||||
|
public interface RetryCallback {
|
||||||
|
void onRetry(int attempt, int maxAttempts, long delayMs, Exception exception);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -16,6 +16,7 @@ import com.imeeting.service.biz.MeetingSummaryFileService;
|
||||||
import com.imeeting.service.biz.MeetingTranscriptChapterService;
|
import com.imeeting.service.biz.MeetingTranscriptChapterService;
|
||||||
import com.imeeting.service.biz.MeetingTranscriptFileService;
|
import com.imeeting.service.biz.MeetingTranscriptFileService;
|
||||||
import com.imeeting.support.TaskSecurityContextRunner;
|
import com.imeeting.support.TaskSecurityContextRunner;
|
||||||
|
import com.imeeting.support.retry.RetryExecutor;
|
||||||
import com.imeeting.support.redis.MeetingAsrPermitCache;
|
import com.imeeting.support.redis.MeetingAsrPermitCache;
|
||||||
import com.imeeting.support.redis.MeetingLockCache;
|
import com.imeeting.support.redis.MeetingLockCache;
|
||||||
import com.unisbase.mapper.SysUserMapper;
|
import com.unisbase.mapper.SysUserMapper;
|
||||||
|
|
@ -25,22 +26,215 @@ import com.tencentcloudapi.asr.v20190614.models.TaskStatus;
|
||||||
import org.junit.jupiter.api.Test;
|
import org.junit.jupiter.api.Test;
|
||||||
import org.springframework.test.util.ReflectionTestUtils;
|
import org.springframework.test.util.ReflectionTestUtils;
|
||||||
|
|
||||||
|
import java.net.http.HttpClient;
|
||||||
|
import java.net.http.HttpRequest;
|
||||||
|
import java.net.http.HttpResponse;
|
||||||
|
import java.net.http.HttpTimeoutException;
|
||||||
|
import java.net.ConnectException;
|
||||||
import java.util.HashMap;
|
import java.util.HashMap;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
|
|
||||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||||
|
import static org.mockito.ArgumentMatchers.contains;
|
||||||
import static org.mockito.ArgumentMatchers.any;
|
import static org.mockito.ArgumentMatchers.any;
|
||||||
import static org.mockito.ArgumentMatchers.anyString;
|
import static org.mockito.ArgumentMatchers.anyString;
|
||||||
import static org.mockito.ArgumentMatchers.eq;
|
import static org.mockito.ArgumentMatchers.eq;
|
||||||
|
import static org.mockito.Mockito.doThrow;
|
||||||
import static org.mockito.Mockito.doReturn;
|
import static org.mockito.Mockito.doReturn;
|
||||||
import static org.mockito.Mockito.mock;
|
import static org.mockito.Mockito.mock;
|
||||||
import static org.mockito.Mockito.never;
|
import static org.mockito.Mockito.never;
|
||||||
import static org.mockito.Mockito.spy;
|
import static org.mockito.Mockito.spy;
|
||||||
|
import static org.mockito.Mockito.times;
|
||||||
import static org.mockito.Mockito.verify;
|
import static org.mockito.Mockito.verify;
|
||||||
import static org.mockito.Mockito.when;
|
import static org.mockito.Mockito.when;
|
||||||
|
|
||||||
class AiTaskServiceImplTest {
|
class AiTaskServiceImplTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void processAsrTaskShouldRetryOrdinaryOfflineQueryWhenTimeoutOccurs() throws Exception {
|
||||||
|
MeetingPointsService meetingPointsService = mock(MeetingPointsService.class);
|
||||||
|
AiTaskServiceImpl service = spy(createService(meetingPointsService));
|
||||||
|
|
||||||
|
HttpClient httpClient = mock(HttpClient.class);
|
||||||
|
@SuppressWarnings("unchecked")
|
||||||
|
HttpResponse<String> submitResponse = mock(HttpResponse.class);
|
||||||
|
@SuppressWarnings("unchecked")
|
||||||
|
HttpResponse<String> queryResponse = mock(HttpResponse.class);
|
||||||
|
ReflectionTestUtils.setField(service, "httpClient", httpClient);
|
||||||
|
ReflectionTestUtils.setField(service, "serverBaseUrl", "https://server.example.com");
|
||||||
|
|
||||||
|
Meeting meeting = new Meeting();
|
||||||
|
meeting.setId(100L);
|
||||||
|
meeting.setAudioUrl("/upload/audio/demo.m4a");
|
||||||
|
|
||||||
|
AiTask task = new AiTask();
|
||||||
|
task.setId(1001L);
|
||||||
|
task.setMeetingId(100L);
|
||||||
|
task.setTaskType("ASR");
|
||||||
|
task.setTaskConfig(new HashMap<>(Map.of(
|
||||||
|
"asrModelId", 501L,
|
||||||
|
"useSpkId", 0,
|
||||||
|
"enableTextRefine", true
|
||||||
|
)));
|
||||||
|
|
||||||
|
AiModelVO model = new AiModelVO();
|
||||||
|
model.setId(501L);
|
||||||
|
model.setProvider("local");
|
||||||
|
model.setBaseUrl("https://asr.example.com");
|
||||||
|
model.setApiKey("api-key");
|
||||||
|
model.setMediaConfig(Map.of("svThreshold", 0.5D));
|
||||||
|
when(extractAiModelService(service).getModelById(501L, "ASR")).thenReturn(model);
|
||||||
|
|
||||||
|
when(submitResponse.body()).thenReturn("{\"code\":0,\"data\":{\"task_id\":\"task-1001\"}}");
|
||||||
|
when(queryResponse.body()).thenReturn("{\"code\":0,\"data\":{\"status\":\"completed\",\"result\":{\"segments\":[{\"speaker_id\":\"spk_1\",\"speaker_name\":\"张三\",\"text\":\"测试转写\"}]}}}");
|
||||||
|
when(httpClient.send(any(HttpRequest.class), any(HttpResponse.BodyHandler.class)))
|
||||||
|
.thenReturn(submitResponse)
|
||||||
|
.thenThrow(new HttpTimeoutException("request timed out"))
|
||||||
|
.thenThrow(new HttpTimeoutException("request timed out"))
|
||||||
|
.thenReturn(queryResponse);
|
||||||
|
doReturn(true).when(service).updateById(any(AiTask.class));
|
||||||
|
|
||||||
|
String result = ReflectionTestUtils.invokeMethod(service, "processAsrTask", meeting, task);
|
||||||
|
|
||||||
|
assertEquals("张三: 测试转写\n", result);
|
||||||
|
verify(httpClient, times(4)).send(any(HttpRequest.class), any(HttpResponse.BodyHandler.class));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void processTencentOfflineAsrShouldFailAfterTencentQueryRetryExhausted() throws Exception {
|
||||||
|
MeetingPointsService meetingPointsService = mock(MeetingPointsService.class);
|
||||||
|
AiTaskServiceImpl service = spy(createService(meetingPointsService));
|
||||||
|
|
||||||
|
Meeting meeting = new Meeting();
|
||||||
|
meeting.setId(200L);
|
||||||
|
meeting.setAudioUrl("https://cdn.example.com/audio/demo.m4a");
|
||||||
|
|
||||||
|
AiTask task = new AiTask();
|
||||||
|
task.setId(2001L);
|
||||||
|
task.setMeetingId(200L);
|
||||||
|
task.setTaskType("ASR");
|
||||||
|
task.setTaskConfig(new HashMap<>(Map.of(
|
||||||
|
"asrModelId", 601L,
|
||||||
|
"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"
|
||||||
|
));
|
||||||
|
|
||||||
|
doReturn(90001L).when(service).submitTencentOfflineTask(meeting, task, model);
|
||||||
|
doThrow(new com.tencentcloudapi.common.exception.TencentCloudSDKException("Request timeout"))
|
||||||
|
.when(service).queryTencentOfflineTask(model, 90001L);
|
||||||
|
|
||||||
|
RuntimeException ex = assertThrows(RuntimeException.class, () -> service.processTencentOfflineAsr(meeting, task, model));
|
||||||
|
|
||||||
|
assertTrue(ex.getMessage().contains("查询超时"));
|
||||||
|
verify(service, times(3)).queryTencentOfflineTask(model, 90001L);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void isRetryableAsrQueryExceptionShouldTreatHttpTimeoutAsRetryable() {
|
||||||
|
AiTaskServiceImpl service = createService(mock(MeetingPointsService.class));
|
||||||
|
|
||||||
|
assertTrue(service.isRetryableAsrQueryException(new HttpTimeoutException("request timed out")));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void processAsrTaskShouldRetryOrdinaryOfflineSubmitWhenConnectExceptionOccurs() throws Exception {
|
||||||
|
MeetingPointsService meetingPointsService = mock(MeetingPointsService.class);
|
||||||
|
AiTaskServiceImpl service = spy(createService(meetingPointsService));
|
||||||
|
|
||||||
|
HttpClient httpClient = mock(HttpClient.class);
|
||||||
|
@SuppressWarnings("unchecked")
|
||||||
|
HttpResponse<String> submitResponse = mock(HttpResponse.class);
|
||||||
|
@SuppressWarnings("unchecked")
|
||||||
|
HttpResponse<String> queryResponse = mock(HttpResponse.class);
|
||||||
|
ReflectionTestUtils.setField(service, "httpClient", httpClient);
|
||||||
|
ReflectionTestUtils.setField(service, "serverBaseUrl", "https://server.example.com");
|
||||||
|
|
||||||
|
Meeting meeting = new Meeting();
|
||||||
|
meeting.setId(300L);
|
||||||
|
meeting.setAudioUrl("/upload/audio/demo.m4a");
|
||||||
|
|
||||||
|
AiTask task = new AiTask();
|
||||||
|
task.setId(3001L);
|
||||||
|
task.setMeetingId(300L);
|
||||||
|
task.setTaskType("ASR");
|
||||||
|
task.setTaskConfig(new HashMap<>(Map.of(
|
||||||
|
"asrModelId", 701L,
|
||||||
|
"useSpkId", 0,
|
||||||
|
"enableTextRefine", true
|
||||||
|
)));
|
||||||
|
|
||||||
|
AiModelVO model = new AiModelVO();
|
||||||
|
model.setId(701L);
|
||||||
|
model.setProvider("local");
|
||||||
|
model.setBaseUrl("https://asr.example.com");
|
||||||
|
model.setApiKey("api-key");
|
||||||
|
model.setMediaConfig(Map.of("svThreshold", 0.5D));
|
||||||
|
when(extractAiModelService(service).getModelById(701L, "ASR")).thenReturn(model);
|
||||||
|
|
||||||
|
when(submitResponse.body()).thenReturn("{\"code\":0,\"data\":{\"task_id\":\"task-3001\"}}");
|
||||||
|
when(queryResponse.body()).thenReturn("{\"code\":0,\"data\":{\"status\":\"completed\",\"result\":{\"segments\":[{\"speaker_id\":\"spk_1\",\"speaker_name\":\"李四\",\"text\":\"提交重试成功\"}]}}}");
|
||||||
|
when(httpClient.send(any(HttpRequest.class), any(HttpResponse.BodyHandler.class)))
|
||||||
|
.thenThrow(new ConnectException("Connection refused"))
|
||||||
|
.thenReturn(submitResponse)
|
||||||
|
.thenReturn(queryResponse);
|
||||||
|
doReturn(true).when(service).updateById(any(AiTask.class));
|
||||||
|
|
||||||
|
String result = ReflectionTestUtils.invokeMethod(service, "processAsrTask", meeting, task);
|
||||||
|
|
||||||
|
assertEquals("李四: 提交重试成功\n", result);
|
||||||
|
verify(httpClient, times(3)).send(any(HttpRequest.class), any(HttpResponse.BodyHandler.class));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void processAsrTaskShouldNotRetryOrdinaryOfflineSubmitWhenTimeoutOccurs() throws Exception {
|
||||||
|
MeetingPointsService meetingPointsService = mock(MeetingPointsService.class);
|
||||||
|
AiTaskServiceImpl service = spy(createService(meetingPointsService));
|
||||||
|
|
||||||
|
HttpClient httpClient = mock(HttpClient.class);
|
||||||
|
ReflectionTestUtils.setField(service, "httpClient", httpClient);
|
||||||
|
ReflectionTestUtils.setField(service, "serverBaseUrl", "https://server.example.com");
|
||||||
|
|
||||||
|
Meeting meeting = new Meeting();
|
||||||
|
meeting.setId(400L);
|
||||||
|
meeting.setAudioUrl("/upload/audio/demo.m4a");
|
||||||
|
|
||||||
|
AiTask task = new AiTask();
|
||||||
|
task.setId(4001L);
|
||||||
|
task.setMeetingId(400L);
|
||||||
|
task.setTaskType("ASR");
|
||||||
|
task.setTaskConfig(new HashMap<>(Map.of(
|
||||||
|
"asrModelId", 801L,
|
||||||
|
"useSpkId", 0,
|
||||||
|
"enableTextRefine", true
|
||||||
|
)));
|
||||||
|
|
||||||
|
AiModelVO model = new AiModelVO();
|
||||||
|
model.setId(801L);
|
||||||
|
model.setProvider("local");
|
||||||
|
model.setBaseUrl("https://asr.example.com");
|
||||||
|
model.setApiKey("api-key");
|
||||||
|
model.setMediaConfig(Map.of("svThreshold", 0.5D));
|
||||||
|
when(extractAiModelService(service).getModelById(801L, "ASR")).thenReturn(model);
|
||||||
|
|
||||||
|
when(httpClient.send(any(HttpRequest.class), any(HttpResponse.BodyHandler.class)))
|
||||||
|
.thenThrow(new HttpTimeoutException("request timed out"));
|
||||||
|
doReturn(true).when(service).updateById(any(AiTask.class));
|
||||||
|
|
||||||
|
assertThrows(Exception.class, () -> ReflectionTestUtils.invokeMethod(service, "processAsrTask", meeting, task));
|
||||||
|
verify(httpClient, times(1)).send(any(HttpRequest.class), any(HttpResponse.BodyHandler.class));
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void processAsrTaskShouldUseTencentOfflineBranchWhenProviderIsTencent() throws Exception {
|
void processAsrTaskShouldUseTencentOfflineBranchWhenProviderIsTencent() throws Exception {
|
||||||
MeetingMapper meetingMapper = mock(MeetingMapper.class);
|
MeetingMapper meetingMapper = mock(MeetingMapper.class);
|
||||||
|
|
@ -76,7 +270,9 @@ class AiTaskServiceImplTest {
|
||||||
meetingSummaryPromptAssembler,
|
meetingSummaryPromptAssembler,
|
||||||
taskSecurityContextRunner,
|
taskSecurityContextRunner,
|
||||||
meetingExternalSummaryWebhookTrigger,
|
meetingExternalSummaryWebhookTrigger,
|
||||||
sysParamService
|
sysParamService,
|
||||||
|
new RetryExecutor(delayMs -> {
|
||||||
|
})
|
||||||
));
|
));
|
||||||
ReflectionTestUtils.setField(service, "baseMapper", mock(AiTaskMapper.class));
|
ReflectionTestUtils.setField(service, "baseMapper", mock(AiTaskMapper.class));
|
||||||
ReflectionTestUtils.setField(service, "androidMeetingPushService", mock(AndroidMeetingPushService.class));
|
ReflectionTestUtils.setField(service, "androidMeetingPushService", mock(AndroidMeetingPushService.class));
|
||||||
|
|
@ -299,10 +495,16 @@ class AiTaskServiceImplTest {
|
||||||
mock(MeetingSummaryPromptAssembler.class),
|
mock(MeetingSummaryPromptAssembler.class),
|
||||||
mock(TaskSecurityContextRunner.class),
|
mock(TaskSecurityContextRunner.class),
|
||||||
mock(MeetingExternalSummaryWebhookTrigger.class),
|
mock(MeetingExternalSummaryWebhookTrigger.class),
|
||||||
mock(SysParamService.class)
|
mock(SysParamService.class),
|
||||||
|
new RetryExecutor(delayMs -> {
|
||||||
|
})
|
||||||
);
|
);
|
||||||
ReflectionTestUtils.setField(service, "baseMapper", mock(AiTaskMapper.class));
|
ReflectionTestUtils.setField(service, "baseMapper", mock(AiTaskMapper.class));
|
||||||
ReflectionTestUtils.setField(service, "androidMeetingPushService", mock(AndroidMeetingPushService.class));
|
ReflectionTestUtils.setField(service, "androidMeetingPushService", mock(AndroidMeetingPushService.class));
|
||||||
return service;
|
return service;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private AiModelService extractAiModelService(AiTaskServiceImpl service) {
|
||||||
|
return (AiModelService) ReflectionTestUtils.getField(service, "aiModelService");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue