diff --git a/backend/src/main/java/com/imeeting/config/MeetingAsyncExecutorConfig.java b/backend/src/main/java/com/imeeting/config/MeetingAsyncExecutorConfig.java index eeb8fbe..9aa9342 100644 --- a/backend/src/main/java/com/imeeting/config/MeetingAsyncExecutorConfig.java +++ b/backend/src/main/java/com/imeeting/config/MeetingAsyncExecutorConfig.java @@ -36,4 +36,16 @@ public class MeetingAsyncExecutorConfig { executor.initialize(); return executor; } + + @Bean("chunkMergeExecutor") + public Executor chunkMergeExecutor() { + ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor(); + executor.setCorePoolSize(2); + executor.setMaxPoolSize(4); + executor.setQueueCapacity(10); + executor.setThreadNamePrefix("imeeting-chunk-merge-"); + executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy()); + executor.initialize(); + return executor; + } } 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 6d3ceec..4db3e38 100644 --- a/backend/src/main/java/com/imeeting/controller/android/AndroidMeetingChunkUploadController.java +++ b/backend/src/main/java/com/imeeting/controller/android/AndroidMeetingChunkUploadController.java @@ -71,6 +71,7 @@ public class AndroidMeetingChunkUploadController { "meetingId", meetingId, "totalChunks", totalChunks); AndroidAuthContext authContext = androidAuthService.authenticateHttp(request); - return ApiResponse.ok(androidChunkUploadService.completeUpload(meetingId, totalChunks, authContext)); + androidChunkUploadService.completeUploadAsync(meetingId, totalChunks, authContext); + return ApiResponse.ok(new LegacyUploadAudioResponse(meetingId, null, "后台合并上传中")); } } 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 b5c242f..0519e41 100644 --- a/backend/src/main/java/com/imeeting/controller/android/AndroidMeetingController.java +++ b/backend/src/main/java/com/imeeting/controller/android/AndroidMeetingController.java @@ -56,6 +56,7 @@ import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.BeanUtils; import org.springframework.beans.factory.annotation.Value; +import org.springframework.util.StopWatch; import org.springframework.util.StringUtils; import org.springframework.web.bind.annotation.DeleteMapping; import org.springframework.web.bind.annotation.GetMapping; @@ -79,6 +80,7 @@ import java.util.LinkedHashMap; import java.util.List; import java.util.Map; import java.util.Objects; +import java.util.concurrent.CompletableFuture; import java.util.stream.Collectors; @Tag(name = "Android会议接口") @@ -97,7 +99,7 @@ public class AndroidMeetingController { private String h5BaseUrl; private final AndroidAuthService androidAuthService; - private final AndroidMeetingPushService androidMeetingPushService; + private final AndroidMeetingPushService androidMeetingPushService; private final AndroidChunkUploadService androidChunkUploadService; private final LegacyMeetingAdapterService legacyMeetingAdapterService; private final MeetingQueryService meetingQueryService; @@ -148,7 +150,7 @@ public class AndroidMeetingController { this.paramService = paramService; this.dictItemService = dictItemService; this.meetingUnifiedStatusService = meetingUnifiedStatusService; - this.androidMeetingPushService = androidMeetingPushService; + this.androidMeetingPushService = androidMeetingPushService; } @Operation(summary = "创建Android离线会议") @@ -238,22 +240,24 @@ public class AndroidMeetingController { AndroidRequestLogHelper.logRequest(log, "Android会议", "结束离线会议录音阶段", "meetingId", meetingId, "request", command); - AndroidAuthContext authContext = androidAuthService.authenticateHttpIgnoreToken(request,true); + AndroidAuthContext authContext = androidAuthService.authenticateHttpIgnoreToken(request, true); LoginUser loginUser = authContext.isAnonymous() ? null : AndroidLoginUserSupport.requireLoginUser(authContext); - MeetingVO meeting = requireOperableOfflineMeeting(meetingId, authContext, loginUser); - LegacyUploadAudioResponse uploadResult = null; + requireOperableOfflineMeeting(meetingId, authContext, loginUser); + MeetingVO meeting = meetingQueryService.getDetailIgnoreTenant(meetingId); + LegacyUploadAudioResponse uploadResult = new LegacyUploadAudioResponse(); if (isUploadFinishedStage(command)) { - uploadResult = androidChunkUploadService.completeUpload( + androidChunkUploadService.completeUploadAsync( meeting.getId(), - command == null ? null : command.getTotalChunks(), + command.getTotalChunks(), authContext ); - if (uploadResult == null) { - throw new RuntimeException("分片上传完成后未生成结果"); - } +// if (uploadResult == null) { +// throw new RuntimeException("分片上传完成后未生成结果"); +// } + uploadResult.setMeetingId(meetingId); } meetingCommandService.finishOfflineMeeting(meeting.getId(), command == null ? null : command.getFinishStage()); - return ApiResponse.ok(uploadResult != null ? uploadResult : true); + return ApiResponse.ok(uploadResult); } @Operation(summary = "分页查询Android会议") @@ -292,7 +296,7 @@ public class AndroidMeetingController { } - @Operation(summary = "查询Android会议统一状态") + @Operation(summary = "查询Android会议统一状态") @ApiResponses({ @io.swagger.v3.oas.annotations.responses.ApiResponse( responseCode = "200", @@ -310,23 +314,24 @@ public class AndroidMeetingController { "request", command); AndroidAuthContext authContext = androidAuthService.authenticateHttp(request); LoginUser loginUser = authContext.isAnonymous() ? null : AndroidLoginUserSupport.requireLoginUser(authContext); - MeetingVO meeting = requireOperableOfflineMeeting(meetingId, authContext, loginUser); + requireOperableOfflineMeeting(meetingId, authContext, loginUser); + MeetingVO meeting = meetingQueryService.getDetailIgnoreTenant(meetingId,false); UnifiedMeetingStatusVO status = meetingUnifiedStatusService.resolve(meetingId); boolean includeTranscript = Boolean.TRUE.equals(command == null ? null : command.getIncludeTranscript()); boolean includeSummary = Boolean.TRUE.equals(command == null ? null : command.getIncludeSummary()); List transcripts = includeTranscript ? meetingQueryService.getTranscripts(meetingId) : null; - String summaryContent = includeSummary ? meetingQueryService.getDetailIgnoreTenant(meetingId).getSummaryContent() : null; - AndroidUnifiedMeetingStatusResponse build = AndroidUnifiedMeetingStatusResponse.builder() - .meetingId(meetingId) - .status(status) - .meeting(meeting) - .includesTranscript(includeTranscript) - .transcripts(transcripts) - .includesSummary(includeSummary) - .summaryContent(summaryContent) - .build(); - log.info("[{}]{}.返回数据:[{}]","Android会议","查询会议统一状态",build); - return ApiResponse.ok(build); + String summaryContent = includeSummary ? meeting.getSummaryContent() : null; + AndroidUnifiedMeetingStatusResponse build = AndroidUnifiedMeetingStatusResponse.builder() + .meetingId(meetingId) + .status(status) + .meeting(meeting) + .includesTranscript(includeTranscript) + .transcripts(transcripts) + .includesSummary(includeSummary) + .summaryContent(summaryContent) + .build(); + log.info("[{}]{}.返回数据:[{}]", "Android会议", "查询会议统一状态", build); + return ApiResponse.ok(build); } @Operation(summary = "重试 Android 会议 ASR 识别") @@ -345,7 +350,7 @@ public class AndroidMeetingController { LoginUser loginUser = authContext.isAnonymous() ? null : AndroidLoginUserSupport.requireLoginUser(authContext); requireOperableOfflineMeeting(meetingId, authContext, loginUser); meetingCommandService.retryTranscription(meetingId); - androidMeetingPushService.pushMeetingStatusChanged(meetingId, UnifiedMeetingStatusStage.TRANSCRIBING.getCode()); + androidMeetingPushService.pushMeetingStatusChanged(meetingId, UnifiedMeetingStatusStage.TRANSCRIBING.getCode()); return ApiResponse.ok(true); } @@ -366,17 +371,18 @@ public class AndroidMeetingController { LoginUser loginUser = authContext.isAnonymous() ? null : AndroidLoginUserSupport.requireLoginUser(authContext); requireOperableOfflineMeeting(meetingId, authContext, loginUser); meetingCommandService.retrySummary(meetingId); - androidMeetingPushService.pushMeetingStatusChanged(meetingId, UnifiedMeetingStatusStage.SUMMARIZING.getCode()); - return ApiResponse.ok(true); + androidMeetingPushService.pushMeetingStatusChanged(meetingId, UnifiedMeetingStatusStage.SUMMARIZING.getCode()); + return ApiResponse.ok(true); } - @Operation(summary = "更新Android会议访问密码") - @ApiResponses({ - @io.swagger.v3.oas.annotations.responses.ApiResponse( - responseCode = "200", - description = "返回更新后的会议访问密码,传空时表示清空访问密码", - content = @Content(schema = @Schema(implementation = String.class)) - ) - }) + + @Operation(summary = "更新Android会议访问密码") + @ApiResponses({ + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "200", + description = "返回更新后的会议访问密码,传空时表示清空访问密码", + content = @Content(schema = @Schema(implementation = String.class)) + ) + }) @PutMapping("/{meetingId}/access-password") @Log(value = "修改Android会议访问密码", type = "Android会议管理") public ApiResponse updateAccessPassword(HttpServletRequest request, @@ -392,9 +398,9 @@ public class AndroidMeetingController { return ApiResponse.error("仅会议创建人可设置访问密码"); } String password = normalizePassword(command == null ? null : command.getPassword()); - meetingService.update(new LambdaUpdateWrapper() - .eq(Meeting::getId,meeting.getId()) - .set(Meeting::getAccessPassword, password)); + meetingService.update(new LambdaUpdateWrapper() + .eq(Meeting::getId, meeting.getId()) + .set(Meeting::getAccessPassword, password)); return ApiResponse.ok(password); } @@ -417,54 +423,55 @@ public class AndroidMeetingController { meetingCommandService.deleteMeeting(meetingId); return ApiResponse.ok(true); } - @GetMapping("/config") - @Log(value = "获取会议配置", type = "Android会议管理") - @Operation(summary = "获取会议配置") - @Anonymous - public ApiResponse config(HttpServletRequest request) { - AndroidRequestLogHelper.logRequest(log, "Android会议", "获取会议配置接口"); - AndroidAuthContext authContext = androidAuthService.authenticateHttp(request); - LoginUser loginUser = AndroidLoginUserSupport.toLoginUser(authContext); - Long tenantId = loginUser != null ? loginUser.getTenantId() : authContext.getTenantId(); - Long userId = loginUser != null ? loginUser.getUserId() : null; - boolean isPlatformAdmin = loginUser != null && Boolean.TRUE.equals(loginUser.getIsPlatformAdmin()); - boolean isTenantAdmin = loginUser != null && Boolean.TRUE.equals(loginUser.getIsTenantAdmin()); - AndroidMeetingConfigVo resultVo = new AndroidMeetingConfigVo(); - PageResult> promptTemplateList = promptTemplateService.pageTemplates( - 1, - 1000, - null, - null, - tenantId, - userId, - isPlatformAdmin, - isTenantAdmin - ); - List enabledTemplates = promptTemplateList.getRecords() == null - ? List.of() - : promptTemplateList.getRecords().stream() - .filter(item -> Integer.valueOf(1).equals(item.getStatus())) - .toList(); - resultVo.setTemplateList(enabledTemplates); - PageResult> modelList = aiModelService.pageModels(1, 1000, null, "LLM", tenantId); - List enabledModels = modelList.getRecords() == null - ? List.of() - : modelList.getRecords().stream() - .filter(item -> Integer.valueOf(1).equals(item.getStatus())) - .toList(); - resultVo.setModelsList(enabledModels); - resultVo.setSummaryDegreeOfDetail(dictItemService.getItemsByTypeCode("summary_degree_detail")); - resultVo.setMaxMeetingDuration(Integer.valueOf(paramService.getParamValue(SysParamKeys.MEETING_MAX_MEETING_DURATION,"30"))); - resultVo.setMinMeetingDuration(Integer.valueOf(paramService.getParamValue(SysParamKeys.MEETING_MIN_MEETING_DURATION, "10"))); - resultVo.setMaxPauseDuration(Integer.valueOf(paramService.getParamValue(SysParamKeys.MEETING_MAX_PAUSE_DURATION,String.valueOf(60*4)))); - BigDecimal bigDecimal = new BigDecimal(paramService.getParamValue(SysParamKeys.MEETING_MAX_PAUSE_DURATION, "99")); - bigDecimal = bigDecimal.setScale(2, RoundingMode.HALF_UP); - resultVo.setPacketLossRate(bigDecimal ); - resultVo.setChunkUploadEnabled(Boolean.parseBoolean(paramService.getParamValue(SysParamKeys.MEETING_ANDROID_AUDIO_CHUNK_UPLOAD_ENABLED, "false"))); - resultVo.setChunkDurationSeconds(Integer.valueOf(paramService.getParamValue(SysParamKeys.MEETING_ANDROID_AUDIO_CHUNK_DURATION_SECONDS, "60"))); - return ApiResponse.ok(resultVo); - } + @GetMapping("/config") + @Log(value = "获取会议配置", type = "Android会议管理") + @Operation(summary = "获取会议配置") + @Anonymous + public ApiResponse config(HttpServletRequest request) { + AndroidRequestLogHelper.logRequest(log, "Android会议", "获取会议配置接口"); + AndroidAuthContext authContext = androidAuthService.authenticateHttp(request); + LoginUser loginUser = AndroidLoginUserSupport.toLoginUser(authContext); + Long tenantId = loginUser != null ? loginUser.getTenantId() : authContext.getTenantId(); + Long userId = loginUser != null ? loginUser.getUserId() : null; + boolean isPlatformAdmin = loginUser != null && Boolean.TRUE.equals(loginUser.getIsPlatformAdmin()); + boolean isTenantAdmin = loginUser != null && Boolean.TRUE.equals(loginUser.getIsTenantAdmin()); + AndroidMeetingConfigVo resultVo = new AndroidMeetingConfigVo(); + PageResult> promptTemplateList = promptTemplateService.pageTemplates( + 1, + 1000, + null, + null, + tenantId, + userId, + isPlatformAdmin, + isTenantAdmin + ); + List enabledTemplates = promptTemplateList.getRecords() == null + ? List.of() + : promptTemplateList.getRecords().stream() + .filter(item -> Integer.valueOf(1).equals(item.getStatus())) + .toList(); + resultVo.setTemplateList(enabledTemplates); + PageResult> modelList = aiModelService.pageModels(1, 1000, null, "LLM", tenantId); + List enabledModels = modelList.getRecords() == null + ? List.of() + : modelList.getRecords().stream() + .filter(item -> Integer.valueOf(1).equals(item.getStatus())) + .toList(); + resultVo.setModelsList(enabledModels); + resultVo.setSummaryDegreeOfDetail(dictItemService.getItemsByTypeCode("summary_degree_detail")); + resultVo.setMaxMeetingDuration(Integer.valueOf(paramService.getParamValue(SysParamKeys.MEETING_MAX_MEETING_DURATION, "30"))); + resultVo.setMinMeetingDuration(Integer.valueOf(paramService.getParamValue(SysParamKeys.MEETING_MIN_MEETING_DURATION, "10"))); + resultVo.setMaxPauseDuration(Integer.valueOf(paramService.getParamValue(SysParamKeys.MEETING_MAX_PAUSE_DURATION, String.valueOf(60 * 4)))); + BigDecimal bigDecimal = new BigDecimal(paramService.getParamValue(SysParamKeys.MEETING_MAX_PAUSE_DURATION, "99")); + bigDecimal = bigDecimal.setScale(2, RoundingMode.HALF_UP); + resultVo.setPacketLossRate(bigDecimal); + resultVo.setChunkUploadEnabled(Boolean.parseBoolean(paramService.getParamValue(SysParamKeys.MEETING_ANDROID_AUDIO_CHUNK_UPLOAD_ENABLED, "false"))); + resultVo.setChunkDurationSeconds(Integer.valueOf(paramService.getParamValue(SysParamKeys.MEETING_ANDROID_AUDIO_CHUNK_DURATION_SECONDS, "60"))); + + return ApiResponse.ok(resultVo); + } private void resolvePublicDeviceTenantId(HttpServletRequest request, AndroidOfflineMeetingCreateCommand command, @@ -547,29 +554,14 @@ public class AndroidMeetingController { return ChronoUnit.DAYS.between(LocalDate.now(), meetingTime.toLocalDate()); } - private MeetingVO requireOperableOfflineMeeting(Long meetingId, AndroidAuthContext authContext, LoginUser loginUser) { - MeetingVO meeting = meetingQueryService.getDetailIgnoreTenant(meetingId); - if (meeting == null) { - throw new BusinessException(BusinessErrorCodeEnum.MEETING_NOT_FOUND.getCode(), "会议不存在"); - } + private Meeting requireOperableOfflineMeeting(Long meetingId, AndroidAuthContext authContext, LoginUser loginUser) { + Meeting meeting = meetingAccessService.requireMeetingIgnoreTenant(meetingId); if (!MeetingConstants.TYPE_OFFLINE.equals(meeting.getMeetingType())) { throw new RuntimeException("当前会议不是离线会议"); } if (authContext == null || authContext.getDeviceId() == null || authContext.getDeviceId().isBlank()) { throw new RuntimeException("设备ID不能为空"); } -// if (meeting.getSourceDeviceCode() == null || !meeting.getSourceDeviceCode().equals(authContext.getDeviceId())) { -// throw new RuntimeException("当前会议不属于该设备"); -// } -// if (authContext.isAnonymous()) { -// if (!MeetingConstants.DEVICE_MODE_PUBLIC.equals(meeting.getSourceDeviceMode())) { -// throw new RuntimeException("当前会议不是公有设备会议"); -// } -// return meeting; -// } -// if (loginUser == null || !Objects.equals(meeting.getCreatorId(), loginUser.getUserId())) { -// throw new RuntimeException("仅会议创建人可操作当前会议"); -// } return meeting; } diff --git a/backend/src/main/java/com/imeeting/grpc/push/AndroidPushGrpcService.java b/backend/src/main/java/com/imeeting/grpc/push/AndroidPushGrpcService.java index 9102f0a..9451917 100644 --- a/backend/src/main/java/com/imeeting/grpc/push/AndroidPushGrpcService.java +++ b/backend/src/main/java/com/imeeting/grpc/push/AndroidPushGrpcService.java @@ -254,7 +254,20 @@ public class AndroidPushGrpcService extends PushServiceGrpc.PushServiceImplBase return switch (platform) { case IOS -> "ios"; case ANDROID -> "android"; - case PLATFORM_UNKNOWN, UNRECOGNIZED -> "android"; + case PLATFORM_UNKNOWN,UNRECOGNIZED -> "android"; + case HARMONY_MOBILE ->"harmony_mobile"; + + // Desktop + case WINDOWS ->"windows"; + case MACOS->"macos"; + case LINUX -> "linux"; + + // Linux发行版(可选) + case KYLIN ->"kylin"; + case UOS ->"uos"; + + // Harmony PC + case HARMONY_PC ->"harmony_pc"; }; } } 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 6917bd9..807f7df 100644 --- a/backend/src/main/java/com/imeeting/service/android/AndroidChunkUploadService.java +++ b/backend/src/main/java/com/imeeting/service/android/AndroidChunkUploadService.java @@ -15,4 +15,12 @@ public interface AndroidChunkUploadService { LegacyUploadAudioResponse completeUpload(Long meetingId, Integer totalChunks, AndroidAuthContext authContext) throws IOException; + + /** + * 异步执行分片合并 + 音频上传 + 触发离线处理。 + * 不阻塞调用线程(Tomcat),错误通过 failOfflineTranscription 回写会议状态。 + */ + void completeUploadAsync(Long meetingId, + Integer totalChunks, + AndroidAuthContext authContext); } 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 2b9544c..ffe23b2 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 @@ -6,13 +6,15 @@ import com.imeeting.dto.android.legacy.LegacyUploadAudioResponse; import com.imeeting.service.android.AndroidChunkUploadService; import com.imeeting.service.android.legacy.LegacyMeetingAdapterService; import com.imeeting.service.biz.MeetingCommandService; +import com.imeeting.service.biz.MeetingQueryService; +import com.imeeting.support.TaskSecurityContextRunner; import com.imeeting.support.redis.AndroidChunkUploadSessionCache; -import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Qualifier; import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Service; import org.springframework.web.multipart.MultipartFile; -import java.io.ByteArrayInputStream; import java.io.IOException; import java.io.InputStream; import java.nio.charset.StandardCharsets; @@ -30,15 +32,16 @@ import java.util.regex.Matcher; import java.util.regex.Pattern; @Service -@RequiredArgsConstructor +@Slf4j public class AndroidChunkUploadServiceImpl implements AndroidChunkUploadService { private static final Pattern LEGACY_CHUNK_FILE_NAME_PATTERN = Pattern.compile("^chunk-(\\d+)(\\..+)?$"); private static final Pattern CHUNK_DIR_NAME_PATTERN = Pattern.compile("^chunk-(\\d+)$"); private static final String CHUNK_ROOT_DIR = "chunks"; - + private final TaskSecurityContextRunner taskSecurityContextRunner; private final AndroidChunkUploadSessionCache sessionCache; private final LegacyMeetingAdapterService legacyMeetingAdapterService; private final MeetingCommandService meetingCommandService; + private final java.util.concurrent.Executor chunkMergeExecutor; @Value("${unisbase.app.upload-path}") private String uploadPath; @@ -46,6 +49,18 @@ public class AndroidChunkUploadServiceImpl implements AndroidChunkUploadService @Value("${imeeting.audio.ffmpeg-path:ffmpeg}") private String ffmpegPath; + public AndroidChunkUploadServiceImpl(AndroidChunkUploadSessionCache sessionCache, + LegacyMeetingAdapterService legacyMeetingAdapterService, + MeetingCommandService meetingCommandService, + @Qualifier("chunkMergeExecutor") java.util.concurrent.Executor chunkMergeExecutor, + TaskSecurityContextRunner taskSecurityContextRunner) { + this.sessionCache = sessionCache; + this.legacyMeetingAdapterService = legacyMeetingAdapterService; + this.meetingCommandService = meetingCommandService; + this.chunkMergeExecutor = chunkMergeExecutor; + this.taskSecurityContextRunner = taskSecurityContextRunner; + } + @Override public void saveChunk(Long meetingId, Integer chunkIndex, @@ -116,7 +131,7 @@ public class AndroidChunkUploadServiceImpl implements AndroidChunkUploadService MultipartFile mergedMultipart = new LocalMultipartFile( resolveMergedOriginalFilename(state, orderedChunkPaths, mergedFile), state.getContentType(), - Files.readAllBytes(mergedFile) + mergedFile ); LegacyUploadAudioResponse response = legacyMeetingAdapterService.uploadAndTriggerOfflineProcessForPublicDevice( @@ -133,6 +148,31 @@ public class AndroidChunkUploadServiceImpl implements AndroidChunkUploadService return response; } + @Override + public void completeUploadAsync(Long meetingId, + Integer totalChunks, + AndroidAuthContext authContext) { + if (meetingId == null) { + throw new RuntimeException("meeting_id不能为空"); + } + if (totalChunks == null || totalChunks <= 0) { + throw new RuntimeException("total_chunks不能为空且必须大于0"); + } + + chunkMergeExecutor.execute( ()->taskSecurityContextRunner.runAsTenantUser( authContext.getTenantId(), authContext.getUserId(), () -> { + try { + completeUpload(meetingId, totalChunks, authContext); + } catch (Exception ex) { + log.error("[分片合并] 会议{}异步合并上传失败: {}", meetingId, ex.getMessage(), ex); + try { + meetingCommandService.failOfflineTranscription(meetingId, "音频合并上传失败: " + ex.getMessage()); + } catch (Exception inner) { + log.error("[分片合并] 会议{}标记失败状态异常: {}", meetingId, inner.getMessage(), inner); + } + } + })); + } + private AndroidChunkUploadSessionState getOrCreateState(Long meetingId, MultipartFile chunkFile, AndroidAuthContext authContext) { @@ -454,12 +494,12 @@ public class AndroidChunkUploadServiceImpl implements AndroidChunkUploadService private static final class LocalMultipartFile implements MultipartFile { private final String originalFilename; private final String contentType; - private final byte[] bytes; + private final Path filePath; - private LocalMultipartFile(String originalFilename, String contentType, byte[] bytes) { + private LocalMultipartFile(String originalFilename, String contentType, Path filePath) { this.originalFilename = originalFilename; this.contentType = contentType; - this.bytes = bytes == null ? new byte[0] : bytes; + this.filePath = filePath; } @Override @@ -479,27 +519,35 @@ public class AndroidChunkUploadServiceImpl implements AndroidChunkUploadService @Override public boolean isEmpty() { - return bytes.length == 0; + try { + return Files.size(filePath) == 0; + } catch (IOException ex) { + return true; + } } @Override public long getSize() { - return bytes.length; + try { + return Files.size(filePath); + } catch (IOException ex) { + return 0; + } } @Override - public byte[] getBytes() { - return bytes; + public byte[] getBytes() throws IOException { + return Files.readAllBytes(filePath); } @Override - public InputStream getInputStream() { - return new ByteArrayInputStream(bytes); + public InputStream getInputStream() throws IOException { + return Files.newInputStream(filePath); } @Override public void transferTo(java.io.File dest) throws IOException, IllegalStateException { - Files.write(dest.toPath(), bytes); + Files.copy(filePath, dest.toPath(), java.nio.file.StandardCopyOption.REPLACE_EXISTING); } } } 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 40779e9..9d715d2 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 @@ -44,6 +44,7 @@ import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.Objects; +import java.util.concurrent.CompletableFuture; import java.util.stream.Collectors; @Service @@ -289,6 +290,7 @@ public class LegacyMeetingAdapterServiceImpl implements LegacyMeetingAdapterServ String relocatedUrl = meetingDomainSupport.relocateAudioUrl(meetingId, stagingUrl); taskSecurityContextRunner.runAsTenantUser(meeting.getTenantId(), meeting.getCreatorId(), () -> { meetingDomainSupport.applyMeetingAudioMetadata(meeting, relocatedUrl); + meetingDomainSupport.prewarmPlaybackAudioAfterCommit(relocatedUrl); meeting.setSummaryModelId(profile.getResolvedSummaryModelId()); meeting.setPromptId(profile.getResolvedPromptId()); meeting.setAudioSaveStatus(RealtimeMeetingAudioStorageService.STATUS_SUCCESS); diff --git a/backend/src/main/java/com/imeeting/service/biz/MeetingQueryService.java b/backend/src/main/java/com/imeeting/service/biz/MeetingQueryService.java index 8d494c7..f883d44 100644 --- a/backend/src/main/java/com/imeeting/service/biz/MeetingQueryService.java +++ b/backend/src/main/java/com/imeeting/service/biz/MeetingQueryService.java @@ -16,7 +16,10 @@ public interface MeetingQueryService { MeetingVO getDetail(Long id); - MeetingVO getDetailIgnoreTenant(Long id); + default MeetingVO getDetailIgnoreTenant(Long id){ + return getDetailIgnoreTenant(id,true); + }; + MeetingVO getDetailIgnoreTenant(Long id,Boolean includeAudio); List getTranscripts(Long meetingId); diff --git a/backend/src/main/java/com/imeeting/service/biz/impl/MeetingAudioUploadSupport.java b/backend/src/main/java/com/imeeting/service/biz/impl/MeetingAudioUploadSupport.java index 5b04fda..5179b68 100644 --- a/backend/src/main/java/com/imeeting/service/biz/impl/MeetingAudioUploadSupport.java +++ b/backend/src/main/java/com/imeeting/service/biz/impl/MeetingAudioUploadSupport.java @@ -62,6 +62,7 @@ public class MeetingAudioUploadSupport { Files.copy(inputStream, targetPath, StandardCopyOption.REPLACE_EXISTING); } validateStoredAudio(targetPath, extension); + } catch (Exception ex) { Files.deleteIfExists(targetPath); throw ex; @@ -69,6 +70,55 @@ public class MeetingAudioUploadSupport { return buildStagingAudioToken(storedFileName); } + public String storeUploadedAudioFromPath(Path sourceFile, String originalFilename) throws IOException { + if (sourceFile == null || !Files.exists(sourceFile)) { + throw new RuntimeException("音频文件不能为空"); + } + + long fileSize = Files.size(sourceFile); + long maxUploadSizeMb = resolveMaxUploadSizeMb(); + long maxUploadSizeBytes = maxUploadSizeMb * 1024 * 1024; + if (fileSize > maxUploadSizeBytes) { + throw new RuntimeException("音频文件大小不能超过 " + maxUploadSizeMb + "MB"); + } + + String extension = resolveExtension(originalFilename); + validateFileHeaderFromPath(sourceFile, extension); + + Path stagingDir = resolveStagingAudioDirectory(uploadPath); + Files.createDirectories(stagingDir); + + String storedFileName = UUID.randomUUID() + "." + extension; + Path targetPath = stagingDir.resolve(storedFileName); + try { + Files.move(sourceFile, targetPath, StandardCopyOption.REPLACE_EXISTING); + validateStoredAudio(targetPath, extension); + } catch (Exception ex) { + Files.deleteIfExists(targetPath); + throw ex; + } + return buildStagingAudioToken(storedFileName); + } + + private void validateFileHeaderFromPath(Path sourceFile, String extension) throws IOException { + if (Files.size(sourceFile) <= 0) { + return; + } + byte[] header; + try (InputStream inputStream = Files.newInputStream(sourceFile)) { + header = inputStream.readNBytes(HEADER_SIZE); + } + boolean valid = switch (extension) { + case "wav" -> isWav(header); + case "mp3" -> isMp3(header); + case "m4a" -> isM4a(header); + default -> false; + }; + if (!valid) { + throw new RuntimeException("上传文件内容与音频格式不匹配,仅支持 mp3、wav、m4a"); + } + } + public static boolean isStagingAudioToken(String audioUrl) { return StringUtils.hasText(audioUrl) && audioUrl.startsWith(STAGING_AUDIO_TOKEN_PREFIX); } diff --git a/backend/src/main/java/com/imeeting/service/biz/impl/MeetingDomainSupport.java b/backend/src/main/java/com/imeeting/service/biz/impl/MeetingDomainSupport.java index 15e3c9e..0789854 100644 --- a/backend/src/main/java/com/imeeting/service/biz/impl/MeetingDomainSupport.java +++ b/backend/src/main/java/com/imeeting/service/biz/impl/MeetingDomainSupport.java @@ -1,5 +1,6 @@ package com.imeeting.service.biz.impl; +import cn.hutool.core.date.StopWatch; import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; import com.imeeting.common.MeetingConstants; import com.imeeting.common.SysParamKeys; diff --git a/backend/src/main/java/com/imeeting/service/biz/impl/MeetingPlaybackAudioResolver.java b/backend/src/main/java/com/imeeting/service/biz/impl/MeetingPlaybackAudioResolver.java index 729980d..865b842 100644 --- a/backend/src/main/java/com/imeeting/service/biz/impl/MeetingPlaybackAudioResolver.java +++ b/backend/src/main/java/com/imeeting/service/biz/impl/MeetingPlaybackAudioResolver.java @@ -485,7 +485,7 @@ public class MeetingPlaybackAudioResolver { "-vn", "-ar", String.valueOf(BROWSER_SAMPLE_RATE), "-c:a", "aac", - "-f", "mp4", + "-ac", "1", targetPath.toString() ); executeCommand(command, targetPath); diff --git a/backend/src/main/java/com/imeeting/service/biz/impl/MeetingQueryServiceImpl.java b/backend/src/main/java/com/imeeting/service/biz/impl/MeetingQueryServiceImpl.java index f263c0e..67e825f 100644 --- a/backend/src/main/java/com/imeeting/service/biz/impl/MeetingQueryServiceImpl.java +++ b/backend/src/main/java/com/imeeting/service/biz/impl/MeetingQueryServiceImpl.java @@ -1,5 +1,6 @@ package com.imeeting.service.biz.impl; +import cn.hutool.core.date.StopWatch; import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; import com.baomidou.mybatisplus.extension.plugins.pagination.Page; import com.imeeting.dto.biz.MeetingSummaryPromptContextRequestDTO; @@ -19,6 +20,7 @@ import com.imeeting.service.biz.MeetingService; import com.imeeting.service.biz.MeetingTranscriptChapterService; import com.unisbase.dto.PageResult; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; import java.time.LocalDateTime; @@ -27,6 +29,7 @@ import java.util.List; import java.util.Map; import java.util.stream.Collectors; +@Slf4j @Service @RequiredArgsConstructor public class MeetingQueryServiceImpl implements MeetingQueryService { @@ -83,10 +86,12 @@ public class MeetingQueryServiceImpl implements MeetingQueryService { return meeting != null ? toVO(meeting, true) : null; } + + @Override - public MeetingVO getDetailIgnoreTenant(Long id) { + public MeetingVO getDetailIgnoreTenant(Long id, Boolean includeAudio) { Meeting meeting = meetingMapper.selectByIdIgnoreTenant(id); - return meeting != null ? toVO(meeting, true) : null; + return meeting != null ? toVO(meeting, includeAudio) : null; } @Override