From a036c14673597339b64c421a0dafc141b4ed50a7 Mon Sep 17 00:00:00 2001 From: chenhao Date: Thu, 25 Jun 2026 18:59:47 +0800 Subject: [PATCH 1/2] =?UTF-8?q?feat:=20=E6=B7=BB=E5=8A=A0=E5=BC=82?= =?UTF-8?q?=E6=AD=A5=E5=88=86=E7=89=87=E5=90=88=E5=B9=B6=E5=92=8C=E4=BC=98?= =?UTF-8?q?=E5=8C=96=E9=9F=B3=E9=A2=91=E5=A4=84=E7=90=86=E9=80=BB=E8=BE=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 在 `AndroidChunkUploadServiceImpl` 中添加 `completeUploadAsync` 方法,实现异步分片合并和上传 - 优化 `MeetingAudioUploadSupport` 中的音频文件存储和验证逻辑 - 更新 `AndroidMeetingController` 和 `AndroidMeetingChunkUploadController` 中的响应构建和日志记录逻辑 - 在 `MeetingQueryServiceImpl` 中更新 `getDetailIgnoreTenant` 方法,支持是否包含音频的参数 - 在 `LegacyMeetingAdapterServiceImpl` 中调用 `prewarmPlaybackAudioAfterCommit` 方法进行预热处理 --- .../config/MeetingAsyncExecutorConfig.java | 12 ++ .../AndroidMeetingChunkUploadController.java | 3 +- .../android/AndroidMeetingController.java | 198 +++++++++--------- .../grpc/push/AndroidPushGrpcService.java | 15 +- .../android/AndroidChunkUploadService.java | 8 + .../impl/AndroidChunkUploadServiceImpl.java | 78 +++++-- .../impl/LegacyMeetingAdapterServiceImpl.java | 2 + .../service/biz/MeetingQueryService.java | 5 +- .../biz/impl/MeetingAudioUploadSupport.java | 50 +++++ .../biz/impl/MeetingDomainSupport.java | 1 + .../impl/MeetingPlaybackAudioResolver.java | 2 +- .../biz/impl/MeetingQueryServiceImpl.java | 9 +- 12 files changed, 259 insertions(+), 124 deletions(-) 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 From 033ffaacc9807547695299a447625f31f67934aa Mon Sep 17 00:00:00 2001 From: chenhao Date: Fri, 26 Jun 2026 11:09:52 +0800 Subject: [PATCH 2/2] =?UTF-8?q?feat:=20=E4=BC=98=E5=8C=96=E4=BC=9A?= =?UTF-8?q?=E8=AE=AE=E7=8A=B6=E6=80=81=E8=A7=A3=E6=9E=90=E5=92=8C=E7=AB=A0?= =?UTF-8?q?=E8=8A=82=E8=BE=B9=E7=95=8C=E8=AF=86=E5=88=AB=E9=80=BB=E8=BE=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 在 `MeetingUnifiedStatusServiceImpl` 中添加 `MeetingUnifiedStageContext` 参数,优化阶段解析逻辑 - 在 `MeetingTranscriptChapterServiceImpl` 中更新章节边界识别的系统提示,使其更清晰 - 在 `AiTaskServiceImpl` 和 `MeetingProgressServiceImpl` 中增加对 AI 目录启用状态的检查,优化任务调度逻辑 --- .../service/biz/impl/AiTaskServiceImpl.java | 4 +- .../biz/impl/MeetingProgressServiceImpl.java | 8 +++ .../MeetingTranscriptChapterServiceImpl.java | 51 ++++++++++--------- .../impl/MeetingUnifiedStatusServiceImpl.java | 22 ++++++-- 4 files changed, 54 insertions(+), 31 deletions(-) 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 e056711..d61460f 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 @@ -1121,7 +1121,7 @@ public class AiTaskServiceImpl extends ServiceImpl impleme meetingPointsService.recordSummarySuccessCharge(meeting, taskRecord); AiTask latestChapterTask = findLatestTask(meeting.getId(), "CHAPTER"); - if (latestChapterTask != null && Integer.valueOf(2).equals(latestChapterTask.getStatus())) { + if (!resolveAiCatalogEnabled() ||(latestChapterTask != null && Integer.valueOf(2).equals(latestChapterTask.getStatus()))) { updateProgress(meeting.getId(), 100, "全流程分析完成", 0); } else { updateProgress(meeting.getId(), 95, "总结生成完成,等待 AI 目录完成...", 0); @@ -1369,7 +1369,7 @@ public class AiTaskServiceImpl extends ServiceImpl impleme updateMeetingStatus(meetingId, 4); return; } - if (isTaskCompleted(chapterTask) && isTaskCompleted(summaryTask)) { + if ( isTaskCompleted(summaryTask) && (!resolveAiCatalogEnabled() || isTaskCompleted(chapterTask))) { updateMeetingStatus(meetingId, 3); return; } diff --git a/backend/src/main/java/com/imeeting/service/biz/impl/MeetingProgressServiceImpl.java b/backend/src/main/java/com/imeeting/service/biz/impl/MeetingProgressServiceImpl.java index 829bcf2..05c02d5 100644 --- a/backend/src/main/java/com/imeeting/service/biz/impl/MeetingProgressServiceImpl.java +++ b/backend/src/main/java/com/imeeting/service/biz/impl/MeetingProgressServiceImpl.java @@ -241,6 +241,14 @@ public class MeetingProgressServiceImpl implements MeetingProgressService { if (latestChapter != null && Integer.valueOf(1).equals(latestChapter.getStatus())) { return buildSnapshot(meetingId, latestChapter, meeting.getStatus(), MeetingProgressStage.CHAPTER_RUNNING, 85, "正在生成会议章节...", 0); } + if (MeetingStatusEnum.isCode(meeting.getStatus(), MeetingStatusEnum.SUMMARIZING)) { + if (latestSummary != null && Integer.valueOf(2).equals(latestSummary.getStatus())) { + return buildSnapshot(meetingId, latestSummary, meeting.getStatus(), MeetingProgressStage.SUMMARY_RUNNING, 90, "正在生成会议总结...", 0); + } + if (latestChapter != null && Integer.valueOf(0).equals(latestChapter.getStatus())) { + return buildSnapshot(meetingId, latestChapter, meeting.getStatus(), MeetingProgressStage.CHAPTER_RUNNING, 85, "正在生成会议章节...", 0); + } + } AiTask latestAsr = findLatestTask(meetingId, "ASR"); if (latestAsr != null) { if (Integer.valueOf(1).equals(latestAsr.getStatus())) { diff --git a/backend/src/main/java/com/imeeting/service/biz/impl/MeetingTranscriptChapterServiceImpl.java b/backend/src/main/java/com/imeeting/service/biz/impl/MeetingTranscriptChapterServiceImpl.java index 192140c..82c1e9f 100644 --- a/backend/src/main/java/com/imeeting/service/biz/impl/MeetingTranscriptChapterServiceImpl.java +++ b/backend/src/main/java/com/imeeting/service/biz/impl/MeetingTranscriptChapterServiceImpl.java @@ -369,28 +369,29 @@ public class MeetingTranscriptChapterServiceImpl implements MeetingTranscriptCha private String buildChapterSystemPrompt() { return """ - 你负责对会议转录分段做章节边界识别。 - 只允许返回 JSON。 - 只能返回 chapters 数组。 - JSON里面必须包含chapters 数组,就算只有一个章节 - 每个章节只允许包含: - chapterNo,title,summary,keywords,startTranscriptId,endTranscriptId,confidence - 不得改写原文。 - 不得输出章节正文。 - 不得归一化数字、日期、金额、时间点。 - 所有章节必须完整覆盖全部 transcript。 - 章节必须严格连续: - - 第一个章节 startTranscriptId 必须为 转录原文的起始transcriptId - - 下一个章节的 startTranscriptId 必须等于上一个章节的 endTranscriptId + 1 - - 最后一个章节必须覆盖最后一条 transcript - 禁止: - - transcript 遗漏 - - transcript 重复 - - 章节重叠 - - 跳跃式分段 - 章节标题、摘要、关键词必须基于对应章节原文生成,不得虚构。 - 若无法识别明确的话题边界,则将全部 transcript 作为一个章节返回. - + 你是会议转录分段任务中的“章节边界识别器”。 + 基于输入 transcript 列表,进行语义分段,输出章节结构。 + 输出格式(严格) + 只允许输出 JSON,且必须符合以下结构: + { + "chapters": [ + { + "chapterNo": number, + "title": string, + "summary": string, + "keywords": [string], + "startTranscriptId": number, + "endTranscriptId": number, + "confidence": number + } + ] + } + 规则: + 1. 必须按顺序分段,不允许交叉或跳跃 + 2. 必须覆盖全部 transcript + 3. 若无明显边界,则合并为一个章节 + 4. title 必须基于该段内容生成 + 5. 只输出 JSON,不要任何解释 """; } @@ -1017,9 +1018,9 @@ public class MeetingTranscriptChapterServiceImpl implements MeetingTranscriptCha Long startTranscriptId = longValue(item.path("startTranscriptId").asText(null)); Long endTranscriptId = longValue(item.path("endTranscriptId").asText(null)); Integer chapterNo = item.path("chapterNo").isInt() ? item.path("chapterNo").asInt() : null; - if (chapterNo == null || startTranscriptId == null || endTranscriptId == null) { - throw new RuntimeException("章节模型返回了不完整的章节边界"); - } +// if (chapterNo == null || startTranscriptId == null || endTranscriptId == null) { +// throw new RuntimeException("章节模型返回了不完整的章节边界"); +// } List keywords = new ArrayList<>(); if (item.path("keywords").isArray()) { for (JsonNode keyword : item.path("keywords")) { diff --git a/backend/src/main/java/com/imeeting/service/biz/impl/MeetingUnifiedStatusServiceImpl.java b/backend/src/main/java/com/imeeting/service/biz/impl/MeetingUnifiedStatusServiceImpl.java index 787d967..14435c8 100644 --- a/backend/src/main/java/com/imeeting/service/biz/impl/MeetingUnifiedStatusServiceImpl.java +++ b/backend/src/main/java/com/imeeting/service/biz/impl/MeetingUnifiedStatusServiceImpl.java @@ -90,12 +90,12 @@ public class MeetingUnifiedStatusServiceImpl implements MeetingUnifiedStatusServ if (isAndroidOfflineMeetingWaitingUpload(meeting)) { return UnifiedMeetingStatusStage.WAITING_UPLOAD; } - UnifiedMeetingStatusStage stageFromSnapshot = resolveStageFromSnapshot(snapshot); + MeetingUnifiedStageContext context = buildStageContext(meeting.getId(), snapshot); + UnifiedMeetingStatusStage stageFromSnapshot = resolveStageFromSnapshot(snapshot, context); if (stageFromSnapshot != null) { return stageFromSnapshot; } - MeetingUnifiedStageContext context = buildStageContext(meeting.getId(), snapshot); if (isTranscribing(context)) { return UnifiedMeetingStatusStage.TRANSCRIBING; } @@ -106,7 +106,8 @@ public class MeetingUnifiedStatusServiceImpl implements MeetingUnifiedStatusServ return UnifiedMeetingStatusStage.INITIALIZING; } - private UnifiedMeetingStatusStage resolveStageFromSnapshot(MeetingProgressSnapshot snapshot) { + private UnifiedMeetingStatusStage resolveStageFromSnapshot(MeetingProgressSnapshot snapshot, + MeetingUnifiedStageContext context) { if (snapshot == null || snapshot.getStage() == null || snapshot.getStage().isBlank()) { return null; } @@ -115,11 +116,24 @@ public class MeetingUnifiedStatusServiceImpl implements MeetingUnifiedStatusServ case "completed" -> UnifiedMeetingStatusStage.COMPLETED; case "summary_running", "chapter_running" -> UnifiedMeetingStatusStage.SUMMARIZING; case "asr_running", "asr_completed", "asr_submitted" -> UnifiedMeetingStatusStage.TRANSCRIBING; - case "queued" -> UnifiedMeetingStatusStage.INITIALIZING; + case "queued" -> resolveQueuedSnapshotStage(context); default -> null; }; } + private UnifiedMeetingStatusStage resolveQueuedSnapshotStage(MeetingUnifiedStageContext context) { + if (context == null) { + return UnifiedMeetingStatusStage.INITIALIZING; + } + if (isSummarizing(context)) { + return UnifiedMeetingStatusStage.SUMMARIZING; + } + if (isTranscribing(context)) { + return UnifiedMeetingStatusStage.TRANSCRIBING; + } + return UnifiedMeetingStatusStage.INITIALIZING; + } + private UnifiedMeetingStatusStage resolveFailedStage(MeetingVO meeting) { if (meeting == null || !MeetingStatusEnum.isCode(meeting.getStatus(), MeetingStatusEnum.FAILED)) { return null;