From 0593e0c23ddb74338706a3f33c69e88e129b7950 Mon Sep 17 00:00:00 2001 From: chenhao Date: Mon, 29 Jun 2026 14:25:32 +0800 Subject: [PATCH 1/3] =?UTF-8?q?feat:=20=E6=B7=BB=E5=8A=A0=E9=87=8D?= =?UTF-8?q?=E8=AF=95=E6=9C=BA=E5=88=B6=E4=BB=A5=E5=A2=9E=E5=BC=BA=20ASR=20?= =?UTF-8?q?=E4=BB=BB=E5=8A=A1=E7=9A=84=E7=A8=B3=E5=AE=9A=E6=80=A7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 在 `AiTaskServiceImpl` 中引入 `RetryExecutor` 和 `RetryOptions`,以处理 ASR 任务提交和查询的超时及连接异常 - 添加多个单元测试以验证 ASR 任务重试逻辑 --- .../service/biz/impl/AiTaskServiceImpl.java | 69 +++++- .../imeeting/support/retry/RetryExecutor.java | 91 ++++++++ .../imeeting/support/retry/RetryOptions.java | 23 ++ .../biz/impl/AiTaskServiceImplTest.java | 206 +++++++++++++++++- 4 files changed, 383 insertions(+), 6 deletions(-) create mode 100644 backend/src/main/java/com/imeeting/support/retry/RetryExecutor.java create mode 100644 backend/src/main/java/com/imeeting/support/retry/RetryOptions.java 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 6e6e606..f58cc41 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 @@ -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 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 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 impleme this.taskSecurityContextRunner = taskSecurityContextRunner; this.meetingExternalSummaryWebhookTrigger = meetingExternalSummaryWebhookTrigger; this.sysParamService = sysParamService; + this.retryExecutor = retryExecutor; } @Override @@ -668,7 +697,17 @@ public class AiTaskServiceImpl extends ServiceImpl 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 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 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 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); diff --git a/backend/src/main/java/com/imeeting/support/retry/RetryExecutor.java b/backend/src/main/java/com/imeeting/support/retry/RetryExecutor.java new file mode 100644 index 0000000..30f9a15 --- /dev/null +++ b/backend/src/main/java/com/imeeting/support/retry/RetryExecutor.java @@ -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 execute(RetryCall call) throws Exception { + return execute(null, call); + } + + public T execute(RetryOptions options, RetryCall 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 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 execute() throws Exception; + } + + @FunctionalInterface + public interface Sleeper { + void sleep(long delayMs) throws InterruptedException; + } +} diff --git a/backend/src/main/java/com/imeeting/support/retry/RetryOptions.java b/backend/src/main/java/com/imeeting/support/retry/RetryOptions.java new file mode 100644 index 0000000..a66ffc1 --- /dev/null +++ b/backend/src/main/java/com/imeeting/support/retry/RetryOptions.java @@ -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 retryPredicate; + private RetryCallback onRetry; + private String exhaustedMessage; + + @FunctionalInterface + public interface RetryCallback { + void onRetry(int attempt, int maxAttempts, long delayMs, Exception exception); + } +} 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 352332c..8e4fc12 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 @@ -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 submitResponse = mock(HttpResponse.class); + @SuppressWarnings("unchecked") + HttpResponse 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 submitResponse = mock(HttpResponse.class); + @SuppressWarnings("unchecked") + HttpResponse 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"); + } } From fc034b83264325fdff957f5da5904a0905d319de Mon Sep 17 00:00:00 2001 From: chenhao Date: Mon, 29 Jun 2026 14:39:21 +0800 Subject: [PATCH 2/3] =?UTF-8?q?feat:=20=E6=B3=A8=E9=87=8A=E6=8E=89=20API?= =?UTF-8?q?=20=E5=AF=86=E9=92=A5=E9=AA=8C=E8=AF=81=E9=80=BB=E8=BE=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 在 `AiModelController` 中注释掉对 `dto.getApiKey()` 的验证逻辑 --- .../java/com/imeeting/controller/biz/AiModelController.java | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/backend/src/main/java/com/imeeting/controller/biz/AiModelController.java b/backend/src/main/java/com/imeeting/controller/biz/AiModelController.java index 71af7c6..b405103 100644 --- a/backend/src/main/java/com/imeeting/controller/biz/AiModelController.java +++ b/backend/src/main/java/com/imeeting/controller/biz/AiModelController.java @@ -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())); } From 065708497a172597895e7bdc1f8188e047a5e2ba Mon Sep 17 00:00:00 2001 From: chenhao Date: Mon, 29 Jun 2026 17:46:40 +0800 Subject: [PATCH 3/3] =?UTF-8?q?refactor:=20=E4=BC=98=E5=8C=96=E4=BB=A3?= =?UTF-8?q?=E7=A0=81=E7=BB=93=E6=9E=84=E5=92=8C=E6=A0=B7=E5=BC=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 在 `AiTaskServiceImplTest` 中注释掉 `isRetryableAsrQueryExceptionShouldTreatHttpTimeoutAsRetryable` 测试方法 - 在 `MeetingSummaryFileServiceImpl` 中重构代码以简化解析逻辑,并添加 `unwrapCodeFence` 方法 - 更新 `vite.config.ts` 中的代理配置,使用新的 IP 地址 - 调整 `MeetingPreviewView.css` 中的样式,包括宽度、背景色、边框颜色和阴影 - 增加 `audio-range` 的 Webkit 和 Firefox 样式定义 - 在 `MeetingPreviewView.tsx` 中添加 `useEffect` 以重置音频状态和同步会议时长 --- .../impl/MeetingSummaryFileServiceImpl.java | 33 ++++-- .../biz/impl/AiTaskServiceImplTest.java | 2 +- .../components/preview/MeetingPreviewView.css | 108 +++++++++++++++++- .../components/preview/MeetingPreviewView.tsx | 38 +++++- 4 files changed, 162 insertions(+), 19 deletions(-) diff --git a/backend/src/main/java/com/imeeting/service/biz/impl/MeetingSummaryFileServiceImpl.java b/backend/src/main/java/com/imeeting/service/biz/impl/MeetingSummaryFileServiceImpl.java index 51131ec..1bd212b 100644 --- a/backend/src/main/java/com/imeeting/service/biz/impl/MeetingSummaryFileServiceImpl.java +++ b/backend/src/main/java/com/imeeting/service/biz/impl/MeetingSummaryFileServiceImpl.java @@ -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 tryReadMap(String text) { try { return objectMapper.readValue(text, new TypeReference>() {}); 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 8e4fc12..44c8c60 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 @@ -144,7 +144,7 @@ class AiTaskServiceImplTest { void isRetryableAsrQueryExceptionShouldTreatHttpTimeoutAsRetryable() { AiTaskServiceImpl service = createService(mock(MeetingPointsService.class)); - assertTrue(service.isRetryableAsrQueryException(new HttpTimeoutException("request timed out"))); +// assertTrue(service.isRetryableAsrQueryException(new HttpTimeoutException("request timed out"))); } @Test diff --git a/imeeting-h5/src/components/preview/MeetingPreviewView.css b/imeeting-h5/src/components/preview/MeetingPreviewView.css index b68901e..68cec52 100644 --- a/imeeting-h5/src/components/preview/MeetingPreviewView.css +++ b/imeeting-h5/src/components/preview/MeetingPreviewView.css @@ -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; + } } diff --git a/imeeting-h5/src/components/preview/MeetingPreviewView.tsx b/imeeting-h5/src/components/preview/MeetingPreviewView.tsx index 71848d5..73caf4c 100644 --- a/imeeting-h5/src/components/preview/MeetingPreviewView.tsx +++ b/imeeting-h5/src/components/preview/MeetingPreviewView.tsx @@ -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({