Merge branch 'dev_na' of http://111.10.202.195:3000/chenh/imeeting into dev_ymcg
commit
d36beec479
|
|
@ -112,9 +112,9 @@ public class AiModelController {
|
|||
if (dto.getBaseUrl() == null || dto.getBaseUrl().isBlank()) {
|
||||
return ApiResponse.error("基础地址不能为空");
|
||||
}
|
||||
if (dto.getApiKey() == null || dto.getApiKey().isBlank()) {
|
||||
return ApiResponse.error("API 密钥不能为空");
|
||||
}
|
||||
// if (dto.getApiKey() == null || dto.getApiKey().isBlank()) {
|
||||
// return ApiResponse.error("API 密钥不能为空");
|
||||
// }
|
||||
return ApiResponse.ok(aiModelService.testLocalConnectivity(dto.getBaseUrl(), dto.getApiKey()));
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -30,6 +30,8 @@ import com.imeeting.service.biz.MeetingPointsService;
|
|||
import com.imeeting.service.biz.MeetingSummaryFileService;
|
||||
import com.imeeting.service.biz.MeetingTranscriptChapterService;
|
||||
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.redis.MeetingAsrPermitCache;
|
||||
|
|
@ -105,6 +107,7 @@ public class AiTaskServiceImpl extends ServiceImpl<AiTaskMapper, AiTask> impleme
|
|||
private final TaskSecurityContextRunner taskSecurityContextRunner;
|
||||
private final MeetingExternalSummaryWebhookTrigger meetingExternalSummaryWebhookTrigger;
|
||||
private final SysParamService sysParamService;
|
||||
private final RetryExecutor retryExecutor;
|
||||
|
||||
@Autowired
|
||||
@Qualifier("asrTaskExecutor")
|
||||
|
|
@ -152,6 +155,31 @@ public class AiTaskServiceImpl extends ServiceImpl<AiTaskMapper, AiTask> impleme
|
|||
TaskSecurityContextRunner taskSecurityContextRunner,
|
||||
MeetingExternalSummaryWebhookTrigger meetingExternalSummaryWebhookTrigger,
|
||||
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.transcriptMapper = transcriptMapper;
|
||||
this.aiModelService = aiModelService;
|
||||
|
|
@ -169,6 +197,7 @@ public class AiTaskServiceImpl extends ServiceImpl<AiTaskMapper, AiTask> impleme
|
|||
this.taskSecurityContextRunner = taskSecurityContextRunner;
|
||||
this.meetingExternalSummaryWebhookTrigger = meetingExternalSummaryWebhookTrigger;
|
||||
this.sysParamService = sysParamService;
|
||||
this.retryExecutor = retryExecutor;
|
||||
}
|
||||
|
||||
@Override
|
||||
|
|
@ -668,7 +697,17 @@ public class AiTaskServiceImpl extends ServiceImpl<AiTaskMapper, AiTask> impleme
|
|||
|
||||
for (int i = 0; i < 600; i++) {
|
||||
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);
|
||||
int code = statusNode.path("code").asInt(500);
|
||||
if (code!=0){
|
||||
|
|
@ -736,7 +775,16 @@ public class AiTaskServiceImpl extends ServiceImpl<AiTaskMapper, AiTask> impleme
|
|||
TaskStatus taskStatus = null;
|
||||
for (int i = 0; i < 600; i++) {
|
||||
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) {
|
||||
throw new RuntimeException("腾讯离线 ASR 查询结果为空");
|
||||
}
|
||||
|
|
@ -865,8 +913,10 @@ public class AiTaskServiceImpl extends ServiceImpl<AiTaskMapper, AiTask> impleme
|
|||
boolean useSpk = useSpkObj != null && useSpkObj.toString().equals("1");
|
||||
config.put("enable_speaker", useSpk);
|
||||
config.put("match_speaker_registry", useSpk);
|
||||
if (asrModel.getMediaConfig() != null) {
|
||||
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());
|
||||
config.put("enable_text_cleanup", enableTextRefine);
|
||||
|
||||
|
|
@ -935,7 +985,18 @@ public class AiTaskServiceImpl extends ServiceImpl<AiTaskMapper, AiTask> impleme
|
|||
this.updateById(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);
|
||||
if (submitNode.path("code")==null||submitNode.path("code").asInt() != 0) {
|
||||
updateAiTaskFail(taskRecord, "ASR识别失败 " + respBody);
|
||||
|
|
|
|||
|
|
@ -333,15 +333,11 @@ public class MeetingSummaryFileServiceImpl implements MeetingSummaryFileService
|
|||
return parsed;
|
||||
}
|
||||
|
||||
int fenceStart = text.indexOf("```");
|
||||
if (fenceStart >= 0) {
|
||||
int firstBreak = text.indexOf('\n', fenceStart);
|
||||
int lastFence = text.lastIndexOf("```");
|
||||
if (firstBreak > fenceStart && lastFence > firstBreak) {
|
||||
parsed = tryReadMap(text.substring(firstBreak + 1, lastFence).trim());
|
||||
if (parsed != null) {
|
||||
return parsed;
|
||||
}
|
||||
String fenced = unwrapCodeFence(text);
|
||||
if (fenced != null) {
|
||||
parsed = tryReadMap(fenced);
|
||||
if (parsed != null) {
|
||||
return parsed;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -353,6 +349,25 @@ public class MeetingSummaryFileServiceImpl implements MeetingSummaryFileService
|
|||
return null;
|
||||
}
|
||||
|
||||
private String unwrapCodeFence(String text) {
|
||||
if (text == null) {
|
||||
return null;
|
||||
}
|
||||
String normalized = text.trim();
|
||||
if (!normalized.startsWith("```")) {
|
||||
return null;
|
||||
}
|
||||
int firstBreak = normalized.indexOf('\n');
|
||||
if (firstBreak < 0) {
|
||||
return null;
|
||||
}
|
||||
int lastFence = normalized.lastIndexOf("\n```");
|
||||
if (lastFence <= firstBreak) {
|
||||
return normalized.substring(firstBreak + 1).trim();
|
||||
}
|
||||
return normalized.substring(firstBreak + 1, lastFence).trim();
|
||||
}
|
||||
|
||||
private Map<String, Object> tryReadMap(String text) {
|
||||
try {
|
||||
return objectMapper.readValue(text, new TypeReference<Map<String, Object>>() {});
|
||||
|
|
|
|||
|
|
@ -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.MeetingTranscriptFileService;
|
||||
import com.imeeting.support.TaskSecurityContextRunner;
|
||||
import com.imeeting.support.retry.RetryExecutor;
|
||||
import com.imeeting.support.redis.MeetingAsrPermitCache;
|
||||
import com.imeeting.support.redis.MeetingLockCache;
|
||||
import com.unisbase.mapper.SysUserMapper;
|
||||
|
|
@ -25,22 +26,215 @@ import com.tencentcloudapi.asr.v20190614.models.TaskStatus;
|
|||
import org.junit.jupiter.api.Test;
|
||||
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.Map;
|
||||
|
||||
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.anyString;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.Mockito.doThrow;
|
||||
import static org.mockito.Mockito.doReturn;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.spy;
|
||||
import static org.mockito.Mockito.times;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
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
|
||||
void processAsrTaskShouldUseTencentOfflineBranchWhenProviderIsTencent() throws Exception {
|
||||
MeetingMapper meetingMapper = mock(MeetingMapper.class);
|
||||
|
|
@ -76,7 +270,9 @@ class AiTaskServiceImplTest {
|
|||
meetingSummaryPromptAssembler,
|
||||
taskSecurityContextRunner,
|
||||
meetingExternalSummaryWebhookTrigger,
|
||||
sysParamService
|
||||
sysParamService,
|
||||
new RetryExecutor(delayMs -> {
|
||||
})
|
||||
));
|
||||
ReflectionTestUtils.setField(service, "baseMapper", mock(AiTaskMapper.class));
|
||||
ReflectionTestUtils.setField(service, "androidMeetingPushService", mock(AndroidMeetingPushService.class));
|
||||
|
|
@ -299,10 +495,16 @@ class AiTaskServiceImplTest {
|
|||
mock(MeetingSummaryPromptAssembler.class),
|
||||
mock(TaskSecurityContextRunner.class),
|
||||
mock(MeetingExternalSummaryWebhookTrigger.class),
|
||||
mock(SysParamService.class)
|
||||
mock(SysParamService.class),
|
||||
new RetryExecutor(delayMs -> {
|
||||
})
|
||||
);
|
||||
ReflectionTestUtils.setField(service, "baseMapper", mock(AiTaskMapper.class));
|
||||
ReflectionTestUtils.setField(service, "androidMeetingPushService", mock(AndroidMeetingPushService.class));
|
||||
return service;
|
||||
}
|
||||
|
||||
private AiModelService extractAiModelService(AiTaskServiceImpl service) {
|
||||
return (AiModelService) ReflectionTestUtils.getField(service, "aiModelService");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -462,17 +462,17 @@
|
|||
bottom: 24px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
width: calc(100% - 32px);
|
||||
max-width: 720px;
|
||||
background: rgba(255, 255, 255, 0.92);
|
||||
width: calc(100% - 88px);
|
||||
max-width: 912px;
|
||||
background: rgba(255, 255, 255, 0.88);
|
||||
backdrop-filter: blur(24px);
|
||||
border: 1px solid rgba(255, 255, 255, 0.4);
|
||||
border: 1px solid rgba(95, 81, 255, 0.15);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 12px 20px;
|
||||
border-radius: 24px;
|
||||
z-index: 1000;
|
||||
box-shadow: 0 25px 50px -12px rgba(95, 81, 255, 0.25);
|
||||
box-shadow: 0 20px 40px -15px rgba(95, 81, 255, 0.2);
|
||||
}
|
||||
|
||||
.audio-player-content {
|
||||
|
|
@ -509,12 +509,73 @@
|
|||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
color: var(--text-secondary);
|
||||
width: 44px;
|
||||
min-width: 38px;
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.audio-range {
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
flex: 1;
|
||||
height: 4px;
|
||||
border-radius: 2px;
|
||||
background: #e6e8f5;
|
||||
outline: none;
|
||||
margin: 0;
|
||||
cursor: pointer;
|
||||
display: block;
|
||||
}
|
||||
|
||||
/* Webkit (Chrome, Safari, Edge, Opera) */
|
||||
.audio-range::-webkit-slider-runnable-track {
|
||||
width: 100%;
|
||||
height: 4px;
|
||||
cursor: pointer;
|
||||
background: transparent;
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
.audio-range::-webkit-slider-thumb {
|
||||
height: 12px;
|
||||
width: 12px;
|
||||
border-radius: 50%;
|
||||
background: var(--primary-blue);
|
||||
cursor: pointer;
|
||||
-webkit-appearance: none;
|
||||
margin-top: -4px;
|
||||
border: 2px solid #ffffff;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.15);
|
||||
transition: transform 0.1s ease;
|
||||
}
|
||||
|
||||
.audio-range:active::-webkit-slider-thumb {
|
||||
transform: scale(1.25);
|
||||
}
|
||||
|
||||
/* Firefox */
|
||||
.audio-range::-moz-range-track {
|
||||
width: 100%;
|
||||
height: 4px;
|
||||
cursor: pointer;
|
||||
background: #e6e8f5;
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
.audio-range::-moz-range-thumb {
|
||||
height: 12px;
|
||||
width: 12px;
|
||||
border-radius: 50%;
|
||||
background: var(--primary-blue);
|
||||
cursor: pointer;
|
||||
border: 2px solid #ffffff;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.15);
|
||||
transition: transform 0.1s ease;
|
||||
}
|
||||
|
||||
.audio-range:active::-moz-range-thumb {
|
||||
transform: scale(1.25);
|
||||
}
|
||||
|
||||
.audio-speed-btn {
|
||||
|
|
@ -578,4 +639,39 @@
|
|||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
/* Audio player adjustment for mobile screens */
|
||||
.meeting-preview-audio-player-inline {
|
||||
padding: 10px 14px;
|
||||
bottom: calc(16px + env(safe-area-inset-bottom, 0px));
|
||||
width: calc(100% - 64px);
|
||||
border-radius: 18px;
|
||||
}
|
||||
|
||||
.audio-player-content {
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.audio-progress-container {
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.audio-play-btn {
|
||||
width: 38px;
|
||||
height: 38px;
|
||||
border-radius: 10px;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.audio-speed-btn {
|
||||
width: 38px;
|
||||
height: 30px;
|
||||
border-radius: 8px;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.audio-time {
|
||||
font-size: 10px;
|
||||
min-width: 30px;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -274,6 +274,24 @@ export default function MeetingPreviewView({
|
|||
}
|
||||
}, [aiCatalogEnabled, pageTab]);
|
||||
|
||||
// Reset audio state when URL changes
|
||||
useEffect(() => {
|
||||
setAudioCurrentTime(0);
|
||||
setAudioDuration(meetingDuration > 0 ? meetingDuration / 1000 : 0);
|
||||
setAudioPlaying(false);
|
||||
if (audioRef.current) {
|
||||
audioRef.current.playbackRate = 1;
|
||||
setAudioPlaybackRate(1);
|
||||
}
|
||||
}, [playbackAudioUrl]);
|
||||
|
||||
// Fallback sync when meetingDuration becomes available
|
||||
useEffect(() => {
|
||||
if (meetingDuration > 0 && audioDuration === 0) {
|
||||
setAudioDuration(meetingDuration / 1000);
|
||||
}
|
||||
}, [meetingDuration, audioDuration]);
|
||||
|
||||
const handleTranscriptSeek = (item: MeetingTranscriptVO) => {
|
||||
if (!audioRef.current) return;
|
||||
audioRef.current.currentTime = Math.max(0, (item.startTime || 0) / 1000);
|
||||
|
|
@ -300,12 +318,24 @@ export default function MeetingPreviewView({
|
|||
}
|
||||
};
|
||||
|
||||
const handleAudioDurationChange = () => {
|
||||
if (audioRef.current) {
|
||||
const dur = audioRef.current.duration;
|
||||
if (dur && Number.isFinite(dur) && dur > 0) {
|
||||
setAudioDuration(dur);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleAudioTimeUpdate = () => {
|
||||
if (!audioRef.current) return;
|
||||
const currentSeconds = audioRef.current.currentTime;
|
||||
setAudioCurrentTime(currentSeconds);
|
||||
if (audioRef.current.duration && audioDuration !== audioRef.current.duration) {
|
||||
setAudioDuration(audioRef.current.duration);
|
||||
if (audioRef.current.duration) {
|
||||
const dur = audioRef.current.duration;
|
||||
if (dur && Number.isFinite(dur) && dur > 0 && audioDuration !== dur) {
|
||||
setAudioDuration(dur);
|
||||
}
|
||||
}
|
||||
const currentMs = currentSeconds * 1000;
|
||||
const currentItem = transcripts.find(
|
||||
|
|
@ -636,7 +666,9 @@ export default function MeetingPreviewView({
|
|||
<audio
|
||||
ref={audioRef}
|
||||
onTimeUpdate={handleAudioTimeUpdate}
|
||||
onLoadedMetadata={() => setAudioDuration(audioRef.current?.duration || 0)}
|
||||
onLoadedMetadata={handleAudioDurationChange}
|
||||
onDurationChange={handleAudioDurationChange}
|
||||
onCanPlay={handleAudioDurationChange}
|
||||
onPlay={() => setAudioPlaying(true)}
|
||||
onPause={() => setAudioPlaying(false)}
|
||||
onEnded={() => setAudioPlaying(false)}
|
||||
|
|
|
|||
Loading…
Reference in New Issue