diff --git a/backend/src/main/java/com/imeeting/controller/android/AndroidMeetingChunkUploadController.java b/backend/src/main/java/com/imeeting/controller/android/AndroidMeetingChunkUploadController.java index 883f36e..e48ac49 100644 --- a/backend/src/main/java/com/imeeting/controller/android/AndroidMeetingChunkUploadController.java +++ b/backend/src/main/java/com/imeeting/controller/android/AndroidMeetingChunkUploadController.java @@ -44,15 +44,13 @@ public class AndroidMeetingChunkUploadController { public ApiResponse uploadChunk(HttpServletRequest request, @RequestParam("meeting_id") Long meetingId, @RequestParam("chunk_index") Integer chunkIndex, - @RequestParam("total_chunks") Integer totalChunks, @RequestParam("chunk_file") MultipartFile chunkFile) throws IOException { AndroidRequestLogHelper.logRequest(log, "Android会议", "上传会议音频分片", "meetingId", meetingId, "chunkIndex", chunkIndex, - "totalChunks", totalChunks, "chunkFile", chunkFile); AndroidAuthContext authContext = androidAuthService.authenticateHttp(request); - androidChunkUploadService.saveChunk(meetingId, chunkIndex, totalChunks, chunkFile, authContext); + androidChunkUploadService.saveChunk(meetingId, chunkIndex, chunkFile, authContext); return ApiResponse.ok(true); } @@ -67,10 +65,12 @@ public class AndroidMeetingChunkUploadController { @PostMapping("/complete") @Anonymous public ApiResponse completeUpload(HttpServletRequest request, - @RequestParam("meeting_id") Long meetingId) throws IOException { + @RequestParam("meeting_id") Long meetingId, + @RequestParam("total_chunks") Integer totalChunks) throws IOException { AndroidRequestLogHelper.logRequest(log, "Android会议", "完成分片上传", - "meetingId", meetingId); + "meetingId", meetingId, + "totalChunks", totalChunks); AndroidAuthContext authContext = androidAuthService.authenticateHttp(request); - return ApiResponse.ok(androidChunkUploadService.completeUpload(meetingId, authContext)); + return ApiResponse.ok(androidChunkUploadService.completeUpload(meetingId, totalChunks, authContext)); } } diff --git a/backend/src/main/java/com/imeeting/controller/android/AndroidMeetingController.java b/backend/src/main/java/com/imeeting/controller/android/AndroidMeetingController.java index ac8a903..2c6b072 100644 --- a/backend/src/main/java/com/imeeting/controller/android/AndroidMeetingController.java +++ b/backend/src/main/java/com/imeeting/controller/android/AndroidMeetingController.java @@ -206,20 +206,28 @@ public class AndroidMeetingController { }) @PostMapping("/{meetingId}/finish") @Anonymous - public ApiResponse finishOfflineMeeting(HttpServletRequest request, - @PathVariable Long meetingId, - @RequestBody(required = false) AndroidOfflineMeetingFinishRequest command) throws IOException { + public ApiResponse finishOfflineMeeting(HttpServletRequest request, + @PathVariable Long meetingId, + @RequestBody(required = false) AndroidOfflineMeetingFinishRequest command) throws IOException { AndroidRequestLogHelper.logRequest(log, "Android会议", "结束离线会议录音阶段", "meetingId", meetingId, "request", command); AndroidAuthContext authContext = androidAuthService.authenticateHttp(request); LoginUser loginUser = authContext.isAnonymous() ? null : AndroidLoginUserSupport.requireLoginUser(authContext); - Meeting meeting = requireOperableOfflineMeeting(meetingId, authContext, loginUser); + MeetingVO meeting = requireOperableOfflineMeeting(meetingId, authContext, loginUser); + LegacyUploadAudioResponse uploadResult = null; if (isUploadFinishedStage(command)) { - androidChunkUploadService.completeUpload(meeting.getId(), authContext); + uploadResult = androidChunkUploadService.completeUpload( + meeting.getId(), + command == null ? null : command.getTotalChunks(), + authContext + ); + if (uploadResult == null || uploadResult.getAudioUrl() == null || uploadResult.getAudioUrl().isBlank()) { + throw new RuntimeException("分片上传完成后未生成 audio_url"); + } } meetingCommandService.finishOfflineMeeting(meeting.getId(), command == null ? null : command.getFinishStage()); - return ApiResponse.ok(true); + return ApiResponse.ok(uploadResult != null ? uploadResult : true); } @Operation(summary = "分页查询Android会议") @@ -363,8 +371,8 @@ public class AndroidMeetingController { return ApiResponse.ok(resultVo); } - private Meeting requireOperableOfflineMeeting(Long meetingId, AndroidAuthContext authContext, LoginUser loginUser) { - Meeting meeting = meetingService.getById(meetingId); + private MeetingVO requireOperableOfflineMeeting(Long meetingId, AndroidAuthContext authContext, LoginUser loginUser) { + MeetingVO meeting = meetingQueryService.getDetailIgnoreTenant(meetingId); if (meeting == null) { throw new RuntimeException("会议不存在"); } diff --git a/backend/src/main/java/com/imeeting/service/android/AndroidChunkUploadService.java b/backend/src/main/java/com/imeeting/service/android/AndroidChunkUploadService.java index b003e43..6917bd9 100644 --- a/backend/src/main/java/com/imeeting/service/android/AndroidChunkUploadService.java +++ b/backend/src/main/java/com/imeeting/service/android/AndroidChunkUploadService.java @@ -9,10 +9,10 @@ import java.io.IOException; public interface AndroidChunkUploadService { void saveChunk(Long meetingId, Integer chunkIndex, - Integer totalChunks, MultipartFile chunkFile, AndroidAuthContext authContext) throws IOException; LegacyUploadAudioResponse completeUpload(Long meetingId, + Integer totalChunks, AndroidAuthContext authContext) throws IOException; } diff --git a/backend/src/main/java/com/imeeting/service/android/impl/AndroidChunkUploadServiceImpl.java b/backend/src/main/java/com/imeeting/service/android/impl/AndroidChunkUploadServiceImpl.java index 3d472ea..8402c22 100644 --- a/backend/src/main/java/com/imeeting/service/android/impl/AndroidChunkUploadServiceImpl.java +++ b/backend/src/main/java/com/imeeting/service/android/impl/AndroidChunkUploadServiceImpl.java @@ -15,12 +15,16 @@ import org.springframework.web.multipart.MultipartFile; import java.io.ByteArrayInputStream; import java.io.IOException; import java.io.InputStream; +import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.Paths; import java.nio.file.StandardOpenOption; +import java.util.ArrayList; +import java.util.List; import java.util.Map; import java.util.Objects; +import java.util.concurrent.TimeUnit; @Service @RequiredArgsConstructor @@ -31,27 +35,29 @@ public class AndroidChunkUploadServiceImpl implements AndroidChunkUploadService @Value("${unisbase.app.upload-path}") private String uploadPath; + @Value("${imeeting.audio.ffmpeg-path:ffmpeg}") + private String ffmpegPath; + @Override public void saveChunk(Long meetingId, Integer chunkIndex, - Integer totalChunks, MultipartFile chunkFile, AndroidAuthContext authContext) throws IOException { if (meetingId == null) { throw new RuntimeException("meeting_id不能为空"); } - if (chunkIndex == null || totalChunks == null || chunkIndex < 0 || totalChunks <= 0) { + if (chunkIndex == null || chunkIndex < 0) { throw new RuntimeException("分片参数无效"); } if (chunkFile == null || chunkFile.isEmpty()) { throw new RuntimeException("chunk_file不能为空"); } - AndroidChunkUploadSessionState state = getOrCreateState(meetingId, totalChunks, chunkFile, authContext); + AndroidChunkUploadSessionState state = getOrCreateState(meetingId, chunkFile, authContext); if (!Objects.equals(state.getMeetingId(), meetingId) || !Objects.equals(state.getDeviceId(), authContext.getDeviceId())) { throw new RuntimeException("分片上传会话与当前设备或会议不匹配"); } - String chunkFileName = normalizeChunkFileName(chunkFile.getOriginalFilename(), chunkIndex); + String chunkFileName = buildStoredChunkFileName(chunkIndex, chunkFile.getOriginalFilename()); String previousFileName = state.getChunkFileNames().get(chunkIndex); Path meetingDir = sessionDir(meetingId); Files.createDirectories(meetingDir); @@ -60,12 +66,6 @@ public class AndroidChunkUploadServiceImpl implements AndroidChunkUploadService deleteQuietly(meetingDir.resolve(previousFileName)); state.getUploadedChunkFileNames().remove(previousFileName); } - if (state.getUploadedChunkFileNames().contains(chunkFileName)) { - state.getChunkFileNames().put(chunkIndex, chunkFileName); - state.getReceivedChunks().add(chunkIndex); - saveState(meetingId, state); - return; - } Files.write(meetingDir.resolve(chunkFileName), chunkFile.getBytes(), StandardOpenOption.CREATE, StandardOpenOption.TRUNCATE_EXISTING); state.getUploadedChunkFileNames().add(chunkFileName); @@ -76,12 +76,23 @@ public class AndroidChunkUploadServiceImpl implements AndroidChunkUploadService @Override public LegacyUploadAudioResponse completeUpload(Long meetingId, + Integer totalChunks, AndroidAuthContext authContext) throws IOException { + if (meetingId == null) { + throw new RuntimeException("meeting_id不能为空"); + } + if (totalChunks == null || totalChunks <= 0) { + throw new RuntimeException("total_chunks不能为空且必须大于0"); + } + AndroidChunkUploadSessionState state = requireState(meetingId); if (!Objects.equals(state.getMeetingId(), meetingId) || !Objects.equals(state.getDeviceId(), authContext.getDeviceId())) { throw new RuntimeException("分片上传会话与当前设备或会议不匹配"); } - for (int i = 0; i < state.getTotalChunks(); i++) { + + state.setTotalChunks(totalChunks); + saveState(meetingId, state); + for (int i = 0; i < totalChunks; i++) { if (!state.getReceivedChunks().contains(i) || !state.getChunkFileNames().containsKey(i)) { throw new RuntimeException("分片未上传完整"); } @@ -90,7 +101,7 @@ public class AndroidChunkUploadServiceImpl implements AndroidChunkUploadService Path mergedFile = mergeChunks(state); try { MultipartFile mergedMultipart = new LocalMultipartFile( - state.getFileName() == null ? "meeting-audio.bin" : state.getFileName(), + buildMergedOriginalFilename(state, mergedFile), state.getContentType(), Files.readAllBytes(mergedFile) ); @@ -120,23 +131,15 @@ public class AndroidChunkUploadServiceImpl implements AndroidChunkUploadService } private AndroidChunkUploadSessionState getOrCreateState(Long meetingId, - Integer totalChunks, MultipartFile chunkFile, - AndroidAuthContext authContext) throws IOException { + AndroidAuthContext authContext) { AndroidChunkUploadSessionState existing = getState(meetingId); if (existing != null) { - String currentFileName = chunkFile == null ? null : chunkFile.getOriginalFilename(); - boolean sameTotalChunks = Objects.equals(existing.getTotalChunks(), totalChunks); - boolean sameFileName = Objects.equals(existing.getFileName(), currentFileName); - if (sameTotalChunks && sameFileName) { - return existing; - } - cleanup(meetingId); + return existing; } AndroidChunkUploadSessionState state = new AndroidChunkUploadSessionState(); state.setMeetingId(meetingId); state.setDeviceId(authContext.getDeviceId()); - state.setTotalChunks(totalChunks); state.setFileName(chunkFile.getOriginalFilename()); state.setContentType(chunkFile.getContentType()); saveState(meetingId, state); @@ -145,13 +148,30 @@ public class AndroidChunkUploadServiceImpl implements AndroidChunkUploadService private Path mergeChunks(AndroidChunkUploadSessionState state) throws IOException { Path meetingDir = sessionDir(state.getMeetingId()); - Path merged = meetingDir.resolve("merged.bin"); - Files.deleteIfExists(merged); - Files.createFile(merged); + Files.createDirectories(meetingDir); + + List chunkPaths = new ArrayList<>(); for (Map.Entry entry : state.getChunkFileNames().entrySet()) { - Files.write(merged, Files.readAllBytes(meetingDir.resolve(entry.getValue())), StandardOpenOption.APPEND); + Path chunkPath = meetingDir.resolve(entry.getValue()); + if (!Files.exists(chunkPath)) { + throw new RuntimeException("分片文件不存在: " + entry.getValue()); + } + chunkPaths.add(chunkPath); } - return merged; + if (chunkPaths.isEmpty()) { + throw new RuntimeException("没有可合并的分片文件"); + } + if (chunkPaths.size() == 1) { + return chunkPaths.get(0); + } + + String mergedExtension = resolveMergedExtension(chunkPaths.get(0)); + Path mergedOutput = meetingDir.resolve("merged" + mergedExtension); + Path concatList = meetingDir.resolve("concat-inputs.txt"); + Files.deleteIfExists(mergedOutput); + writeConcatListFile(concatList, chunkPaths); + executeFfmpegConcat(concatList, mergedOutput); + return mergedOutput; } private AndroidChunkUploadSessionState requireState(Long meetingId) { @@ -192,11 +212,77 @@ public class AndroidChunkUploadServiceImpl implements AndroidChunkUploadService return Paths.get(normalizedBasePath, "android-chunks", String.valueOf(meetingId)); } - private String normalizeChunkFileName(String originalFileName, Integer chunkIndex) { - String fallback = "chunk-" + (chunkIndex == null ? "unknown" : chunkIndex); - String fileName = originalFileName == null || originalFileName.isBlank() ? fallback : originalFileName.trim(); - fileName = Paths.get(fileName).getFileName().toString(); - return fileName.isBlank() ? fallback : fileName; + private String buildStoredChunkFileName(Integer chunkIndex, String originalFileName) { + String normalizedSourceName = originalFileName == null ? "" : Paths.get(originalFileName.trim()).getFileName().toString(); + int extensionIndex = normalizedSourceName.lastIndexOf('.'); + String extension = extensionIndex >= 0 ? normalizedSourceName.substring(extensionIndex) : ""; + String safeExtension = extension.isBlank() ? ".bin" : extension; + return "chunk-" + (chunkIndex == null ? "unknown" : chunkIndex) + safeExtension; + } + + private String buildMergedOriginalFilename(AndroidChunkUploadSessionState state, Path mergedFile) { + if (state.getFileName() != null && !state.getFileName().isBlank()) { + String normalizedSourceName = Paths.get(state.getFileName().trim()).getFileName().toString(); + int extensionIndex = normalizedSourceName.lastIndexOf('.'); + if (extensionIndex >= 0) { + return "merged" + normalizedSourceName.substring(extensionIndex); + } + return normalizedSourceName; + } + return mergedFile == null || mergedFile.getFileName() == null ? "meeting-audio.bin" : mergedFile.getFileName().toString(); + } + + private String resolveMergedExtension(Path chunkPath) { + if (chunkPath == null || chunkPath.getFileName() == null) { + return ".bin"; + } + String fileName = chunkPath.getFileName().toString(); + int extensionIndex = fileName.lastIndexOf('.'); + return extensionIndex >= 0 ? fileName.substring(extensionIndex) : ".bin"; + } + + private void writeConcatListFile(Path concatList, List chunkPaths) throws IOException { + List lines = new ArrayList<>(chunkPaths.size()); + for (Path chunkPath : chunkPaths) { + String normalizedPath = chunkPath.toAbsolutePath().toString().replace("'", "'\\''"); + lines.add("file '" + normalizedPath + "'"); + } + Files.write(concatList, lines, StandardCharsets.UTF_8, StandardOpenOption.CREATE, StandardOpenOption.TRUNCATE_EXISTING); + } + + private void executeFfmpegConcat(Path concatList, Path mergedOutput) throws IOException { + List command = List.of( + ffmpegPath, + "-v", "error", + "-y", + "-f", "concat", + "-safe", "0", + "-i", concatList.toString(), + "-c", "copy", + mergedOutput.toString() + ); + ProcessBuilder processBuilder = new ProcessBuilder(command); + processBuilder.redirectErrorStream(true); + Process process = processBuilder.start(); + byte[] output; + try (InputStream processStream = process.getInputStream()) { + output = processStream.readAllBytes(); + } + try { + if (!process.waitFor(120, TimeUnit.SECONDS)) { + process.destroyForcibly(); + throw new IOException("音频分片合并超时"); + } + } catch (InterruptedException ex) { + Thread.currentThread().interrupt(); + throw new IOException("音频分片合并被中断", ex); + } + if (process.exitValue() != 0) { + throw new IOException("音频分片合并失败: " + new String(output, StandardCharsets.UTF_8)); + } + if (!Files.exists(mergedOutput) || Files.size(mergedOutput) <= 0) { + throw new IOException("音频分片合并结果为空"); + } } private void deleteQuietly(Path path) { diff --git a/backend/src/main/java/com/imeeting/service/android/legacy/impl/LegacyMeetingAdapterServiceImpl.java b/backend/src/main/java/com/imeeting/service/android/legacy/impl/LegacyMeetingAdapterServiceImpl.java index 0261cbf..df0d6fc 100644 --- a/backend/src/main/java/com/imeeting/service/android/legacy/impl/LegacyMeetingAdapterServiceImpl.java +++ b/backend/src/main/java/com/imeeting/service/android/legacy/impl/LegacyMeetingAdapterServiceImpl.java @@ -24,6 +24,7 @@ import com.imeeting.service.biz.impl.MeetingAudioUploadSupport; import com.imeeting.service.biz.impl.MeetingDomainSupport; import com.imeeting.service.biz.impl.MeetingSummaryPromptAssembler; import com.imeeting.service.realtime.RealtimeMeetingAudioStorageService; +import com.imeeting.support.TaskSecurityContextRunner; import com.unisbase.security.LoginUser; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; @@ -57,6 +58,7 @@ public class LegacyMeetingAdapterServiceImpl implements LegacyMeetingAdapterServ private final MeetingTranscriptMapper transcriptMapper; private final LlmModelMapper llmModelMapper; private final MeetingAudioUploadSupport meetingAudioUploadSupport; + private final TaskSecurityContextRunner taskSecurityContextRunner; @Override @Transactional(rollbackFor = Exception.class) @@ -137,7 +139,7 @@ public class LegacyMeetingAdapterServiceImpl implements LegacyMeetingAdapterServ throw new RuntimeException("audio_file 不能为空"); } - Meeting meeting = meetingAccessService.requireMeeting(meetingId); + Meeting meeting = meetingAccessService.requireMeetingIgnoreTenant(meetingId); meetingAccessService.assertCanEditMeeting(meeting, loginUser); assertDeviceOwnsMeeting(meeting, authContext); @@ -176,6 +178,7 @@ public class LegacyMeetingAdapterServiceImpl implements LegacyMeetingAdapterServ String stagingUrl = storeStagingAudio(audioFile); String relocatedUrl = meetingDomainSupport.relocateAudioUrl(meetingId, stagingUrl); + taskSecurityContextRunner.runAsTenantUser(meeting.getTenantId(), meeting.getCreatorId(), () -> { meeting.setAudioUrl(relocatedUrl); meeting.setAudioSaveStatus(RealtimeMeetingAudioStorageService.STATUS_SUCCESS); meeting.setAudioSaveMessage(null); @@ -187,7 +190,7 @@ public class LegacyMeetingAdapterServiceImpl implements LegacyMeetingAdapterServ resetOrCreateChapterTask(meetingId, profile); resetOrCreateSummaryTask(meetingId, profile); dispatchTasksAfterCommit(meetingId, meeting.getTenantId(), meeting.getCreatorId()); - + }); return new LegacyUploadAudioResponse(meetingId, relocatedUrl); } @@ -244,17 +247,19 @@ public class LegacyMeetingAdapterServiceImpl implements LegacyMeetingAdapterServ ); String stagingUrl = storeStagingAudio(audioFile); String relocatedUrl = meetingDomainSupport.relocateAudioUrl(meetingId, stagingUrl); - meeting.setAudioUrl(relocatedUrl); - meeting.setAudioSaveStatus(RealtimeMeetingAudioStorageService.STATUS_SUCCESS); - meeting.setAudioSaveMessage(null); - meeting.setOfflineRecordingStatus(MeetingConstants.OFFLINE_RECORDING_UPLOAD_FINISHED); - meeting.setStatus(1); - meetingService.updateById(meeting); + taskSecurityContextRunner.runAsTenantUser(meeting.getTenantId(), meeting.getCreatorId(), () -> { + meeting.setAudioUrl(relocatedUrl); + meeting.setAudioSaveStatus(RealtimeMeetingAudioStorageService.STATUS_SUCCESS); + meeting.setAudioSaveMessage(null); + meeting.setOfflineRecordingStatus(MeetingConstants.OFFLINE_RECORDING_UPLOAD_FINISHED); + meeting.setStatus(1); + meetingService.updateById(meeting); - resetOrCreateAsrTask(meetingId, profile); - resetOrCreateChapterTask(meetingId, profile); - resetOrCreateSummaryTask(meetingId, profile); - dispatchTasksAfterCommit(meetingId, meeting.getTenantId(), meeting.getCreatorId()); + resetOrCreateAsrTask(meetingId, profile); + resetOrCreateChapterTask(meetingId, profile); + resetOrCreateSummaryTask(meetingId, profile); + dispatchTasksAfterCommit(meetingId, meeting.getTenantId(), meeting.getCreatorId()); + }); return new LegacyUploadAudioResponse(meetingId, relocatedUrl); } diff --git a/backend/src/main/java/com/imeeting/service/biz/MeetingService.java b/backend/src/main/java/com/imeeting/service/biz/MeetingService.java index 4d14533..4f20e43 100644 --- a/backend/src/main/java/com/imeeting/service/biz/MeetingService.java +++ b/backend/src/main/java/com/imeeting/service/biz/MeetingService.java @@ -4,5 +4,7 @@ import com.baomidou.mybatisplus.extension.service.IService; import com.imeeting.entity.biz.Meeting; +import java.io.Serializable; + public interface MeetingService extends IService { } 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 2dc459f..4189090 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 @@ -374,7 +374,7 @@ public class AiModelServiceImpl implements AiModelService { } private AiLocalProfileVO fetchLocalProfile(String baseUrl, String apiKey) { - String targetUrl = appendPath(baseUrl, "api/v1/system/profile"); + String targetUrl = appendPath(baseUrl, "stream/v1/asr/health"); try { HttpRequest request = HttpRequest.newBuilder() .uri(URI.create(targetUrl)) @@ -389,21 +389,18 @@ public class AiModelServiceImpl implements AiModelService { } JsonNode root = objectMapper.readTree(response.body()); - JsonNode dataNode = root.path("data"); + String healthStatus = readText(root.path("status")); + if (healthStatus != null && !"healthy".equalsIgnoreCase(healthStatus)) { + throw new RuntimeException("本地模型连通性测试失败: status=" + healthStatus); + } + List loadedModels = extractStringArray(root.path("loaded_models")); AiLocalProfileVO profile = new AiLocalProfileVO(); - profile.setAsrModels(extractModelNames(dataNode.path("models").path("asr"))); - JsonNode speakerNode = dataNode.path("models").path("speaker"); - if (speakerNode.isMissingNode() || speakerNode.isNull()) { - speakerNode = dataNode.path("model").path("speaker"); + profile.setAsrModels(loadedModels); + profile.setSpeakerModels(Collections.emptyList()); + if (root.path("model_loaded").asBoolean(false) && !loadedModels.isEmpty()) { + profile.setActiveAsrModel(loadedModels.get(0)); } - profile.setSpeakerModels(extractModelNames(speakerNode)); - profile.setActiveAsrModel(readText(dataNode.path("runtime").path("asr_model"))); - profile.setActiveSpeakerModel(readText(dataNode.path("runtime").path("speaker_model"))); - if (dataNode.path("runtime").has("sv_threshold")) { - profile.setSvThreshold(dataNode.path("runtime").path("sv_threshold").decimalValue()); - } - profile.setWsEndpoint(readText(dataNode.path("ws_endpoint"))); return profile; } catch (Exception e) { throw new RuntimeException("本地模型连通性测试失败: " + e.getMessage(), e); @@ -907,6 +904,20 @@ public class AiModelServiceImpl implements AiModelService { return result; } + private List extractStringArray(JsonNode node) { + if (node == null || !node.isArray()) { + return Collections.emptyList(); + } + List result = new ArrayList<>(); + for (JsonNode item : node) { + String value = readText(item); + if (value != null) { + result.add(value); + } + } + return result; + } + private String readText(JsonNode node) { if (node == null || node.isMissingNode() || node.isNull()) { return null; 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 614f9cd..808e069 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 @@ -572,6 +572,7 @@ 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); + config.put("speaker_threshold", asrModel.getMediaConfig().get("svThreshold")); Object enableTextRefineObj = taskRecord.getTaskConfig().get("enableTextRefine"); boolean enableTextRefine = enableTextRefineObj != null && Boolean.parseBoolean(enableTextRefineObj.toString()); config.put("enable_text_cleanup", enableTextRefine); diff --git a/backend/src/main/java/com/imeeting/service/biz/impl/MeetingServiceImpl.java b/backend/src/main/java/com/imeeting/service/biz/impl/MeetingServiceImpl.java index e429dfd..339141d 100644 --- a/backend/src/main/java/com/imeeting/service/biz/impl/MeetingServiceImpl.java +++ b/backend/src/main/java/com/imeeting/service/biz/impl/MeetingServiceImpl.java @@ -1,11 +1,24 @@ package com.imeeting.service.biz.impl; import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl; +import com.imeeting.dto.biz.MeetingVO; import com.imeeting.entity.biz.Meeting; import com.imeeting.mapper.biz.MeetingMapper; import com.imeeting.service.biz.MeetingService; +import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Service; +import java.io.Serializable; + @Service public class MeetingServiceImpl extends ServiceImpl implements MeetingService { + @Autowired + private MeetingMapper meetingMapper; + + @Override + public Meeting getById(Serializable id) { + return meetingMapper.selectByIdIgnoreTenant((Long) id); + } + + } 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 48429c9..65a57ce 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 @@ -3,6 +3,7 @@ package com.imeeting.service.biz.impl; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; import com.imeeting.dto.biz.AiModelDTO; +import com.imeeting.dto.biz.AiLocalProfileVO; import com.imeeting.entity.biz.AsrModel; import com.imeeting.entity.biz.LlmModel; import com.imeeting.mapper.biz.AsrModelMapper; @@ -188,6 +189,54 @@ class AiModelServiceImplTest { assertEquals(HttpClient.Version.HTTP_1_1, httpClient.version()); } + @Test + void testLocalConnectivityShouldCallNewAsrHealthEndpointAndMapLoadedModels() throws Exception { + AtomicReference requestPath = new AtomicReference<>(); + AtomicReference authorization = new AtomicReference<>(); + + server = HttpServer.create(new InetSocketAddress(0), 0); + server.createContext("/stream/v1/asr/health", exchange -> { + captureRequest(exchange, requestPath, authorization, new AtomicReference<>()); + writeJson(exchange, 200, """ + { + "status": "healthy", + "version": "1.0.1", + "message": "ASR service is running normally", + "model_loaded": true, + "device": "cuda:0", + "loaded_models": [ + "qwen3-asr-0.6b" + ], + "memory_usage": { + "allocated": "0.18GB", + "cached": "0.32GB", + "max_allocated": "0.38GB" + } + } + """); + }); + server.start(); + + AiModelServiceImpl service = new AiModelServiceImpl( + objectMapper, + mock(AsrModelMapper.class), + mock(LlmModelMapper.class) + ); + + AiLocalProfileVO profile = service.testLocalConnectivity( + "http://127.0.0.1:" + server.getAddress().getPort(), + "test-key" + ); + + assertEquals("/stream/v1/asr/health", requestPath.get()); + assertEquals("Bearer test-key", authorization.get()); + assertEquals(1, profile.getAsrModels().size()); + assertEquals("qwen3-asr-0.6b", profile.getAsrModels().get(0)); + assertEquals("qwen3-asr-0.6b", profile.getActiveAsrModel()); + assertEquals(0, profile.getSpeakerModels().size()); + assertNull(profile.getWsEndpoint()); + } + @Test void saveModelShouldAllowCustomLlmWithoutApiKey() { AsrModelMapper asrModelMapper = mock(AsrModelMapper.class); diff --git a/frontend/src/pages/business/AiModels.tsx b/frontend/src/pages/business/AiModels.tsx index 907e49a..6282af5 100644 --- a/frontend/src/pages/business/AiModels.tsx +++ b/frontend/src/pages/business/AiModels.tsx @@ -549,14 +549,14 @@ const AiModels: React.FC = () => { { - {activeType === "ASR" && ( - - - - )} + {activeType === "ASR" && isLocalProvider && ( - +