feat: 添加异步分片合并和优化音频处理逻辑

- 在 `AndroidChunkUploadServiceImpl` 中添加 `completeUploadAsync` 方法,实现异步分片合并和上传
- 优化 `MeetingAudioUploadSupport` 中的音频文件存储和验证逻辑
- 更新 `AndroidMeetingController` 和 `AndroidMeetingChunkUploadController` 中的响应构建和日志记录逻辑
- 在 `MeetingQueryServiceImpl` 中更新 `getDetailIgnoreTenant` 方法,支持是否包含音频的参数
- 在 `LegacyMeetingAdapterServiceImpl` 中调用 `prewarmPlaybackAudioAfterCommit` 方法进行预热处理
dev_na
chenhao 2026-06-25 18:59:47 +08:00
parent 2bab042ca0
commit a036c14673
12 changed files with 259 additions and 124 deletions

View File

@ -36,4 +36,16 @@ public class MeetingAsyncExecutorConfig {
executor.initialize(); executor.initialize();
return executor; 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;
}
} }

View File

@ -71,6 +71,7 @@ public class AndroidMeetingChunkUploadController {
"meetingId", meetingId, "meetingId", meetingId,
"totalChunks", totalChunks); "totalChunks", totalChunks);
AndroidAuthContext authContext = androidAuthService.authenticateHttp(request); 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, "后台合并上传中"));
} }
} }

View File

@ -56,6 +56,7 @@ import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.BeanUtils; import org.springframework.beans.BeanUtils;
import org.springframework.beans.factory.annotation.Value; import org.springframework.beans.factory.annotation.Value;
import org.springframework.util.StopWatch;
import org.springframework.util.StringUtils; import org.springframework.util.StringUtils;
import org.springframework.web.bind.annotation.DeleteMapping; import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.GetMapping;
@ -79,6 +80,7 @@ import java.util.LinkedHashMap;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.Objects; import java.util.Objects;
import java.util.concurrent.CompletableFuture;
import java.util.stream.Collectors; import java.util.stream.Collectors;
@Tag(name = "Android会议接口") @Tag(name = "Android会议接口")
@ -97,7 +99,7 @@ public class AndroidMeetingController {
private String h5BaseUrl; private String h5BaseUrl;
private final AndroidAuthService androidAuthService; private final AndroidAuthService androidAuthService;
private final AndroidMeetingPushService androidMeetingPushService; private final AndroidMeetingPushService androidMeetingPushService;
private final AndroidChunkUploadService androidChunkUploadService; private final AndroidChunkUploadService androidChunkUploadService;
private final LegacyMeetingAdapterService legacyMeetingAdapterService; private final LegacyMeetingAdapterService legacyMeetingAdapterService;
private final MeetingQueryService meetingQueryService; private final MeetingQueryService meetingQueryService;
@ -148,7 +150,7 @@ public class AndroidMeetingController {
this.paramService = paramService; this.paramService = paramService;
this.dictItemService = dictItemService; this.dictItemService = dictItemService;
this.meetingUnifiedStatusService = meetingUnifiedStatusService; this.meetingUnifiedStatusService = meetingUnifiedStatusService;
this.androidMeetingPushService = androidMeetingPushService; this.androidMeetingPushService = androidMeetingPushService;
} }
@Operation(summary = "创建Android离线会议") @Operation(summary = "创建Android离线会议")
@ -238,22 +240,24 @@ public class AndroidMeetingController {
AndroidRequestLogHelper.logRequest(log, "Android会议", "结束离线会议录音阶段", AndroidRequestLogHelper.logRequest(log, "Android会议", "结束离线会议录音阶段",
"meetingId", meetingId, "meetingId", meetingId,
"request", command); "request", command);
AndroidAuthContext authContext = androidAuthService.authenticateHttpIgnoreToken(request,true); AndroidAuthContext authContext = androidAuthService.authenticateHttpIgnoreToken(request, true);
LoginUser loginUser = authContext.isAnonymous() ? null : AndroidLoginUserSupport.requireLoginUser(authContext); LoginUser loginUser = authContext.isAnonymous() ? null : AndroidLoginUserSupport.requireLoginUser(authContext);
MeetingVO meeting = requireOperableOfflineMeeting(meetingId, authContext, loginUser); requireOperableOfflineMeeting(meetingId, authContext, loginUser);
LegacyUploadAudioResponse uploadResult = null; MeetingVO meeting = meetingQueryService.getDetailIgnoreTenant(meetingId);
LegacyUploadAudioResponse uploadResult = new LegacyUploadAudioResponse();
if (isUploadFinishedStage(command)) { if (isUploadFinishedStage(command)) {
uploadResult = androidChunkUploadService.completeUpload( androidChunkUploadService.completeUploadAsync(
meeting.getId(), meeting.getId(),
command == null ? null : command.getTotalChunks(), command.getTotalChunks(),
authContext authContext
); );
if (uploadResult == null) { // if (uploadResult == null) {
throw new RuntimeException("分片上传完成后未生成结果"); // throw new RuntimeException("分片上传完成后未生成结果");
} // }
uploadResult.setMeetingId(meetingId);
} }
meetingCommandService.finishOfflineMeeting(meeting.getId(), command == null ? null : command.getFinishStage()); meetingCommandService.finishOfflineMeeting(meeting.getId(), command == null ? null : command.getFinishStage());
return ApiResponse.ok(uploadResult != null ? uploadResult : true); return ApiResponse.ok(uploadResult);
} }
@Operation(summary = "分页查询Android会议") @Operation(summary = "分页查询Android会议")
@ -292,7 +296,7 @@ public class AndroidMeetingController {
} }
@Operation(summary = "查询Android会议统一状态") @Operation(summary = "查询Android会议统一状态")
@ApiResponses({ @ApiResponses({
@io.swagger.v3.oas.annotations.responses.ApiResponse( @io.swagger.v3.oas.annotations.responses.ApiResponse(
responseCode = "200", responseCode = "200",
@ -310,23 +314,24 @@ public class AndroidMeetingController {
"request", command); "request", command);
AndroidAuthContext authContext = androidAuthService.authenticateHttp(request); AndroidAuthContext authContext = androidAuthService.authenticateHttp(request);
LoginUser loginUser = authContext.isAnonymous() ? null : AndroidLoginUserSupport.requireLoginUser(authContext); 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); UnifiedMeetingStatusVO status = meetingUnifiedStatusService.resolve(meetingId);
boolean includeTranscript = Boolean.TRUE.equals(command == null ? null : command.getIncludeTranscript()); boolean includeTranscript = Boolean.TRUE.equals(command == null ? null : command.getIncludeTranscript());
boolean includeSummary = Boolean.TRUE.equals(command == null ? null : command.getIncludeSummary()); boolean includeSummary = Boolean.TRUE.equals(command == null ? null : command.getIncludeSummary());
List<MeetingTranscriptVO> transcripts = includeTranscript ? meetingQueryService.getTranscripts(meetingId) : null; List<MeetingTranscriptVO> transcripts = includeTranscript ? meetingQueryService.getTranscripts(meetingId) : null;
String summaryContent = includeSummary ? meetingQueryService.getDetailIgnoreTenant(meetingId).getSummaryContent() : null; String summaryContent = includeSummary ? meeting.getSummaryContent() : null;
AndroidUnifiedMeetingStatusResponse build = AndroidUnifiedMeetingStatusResponse.builder() AndroidUnifiedMeetingStatusResponse build = AndroidUnifiedMeetingStatusResponse.builder()
.meetingId(meetingId) .meetingId(meetingId)
.status(status) .status(status)
.meeting(meeting) .meeting(meeting)
.includesTranscript(includeTranscript) .includesTranscript(includeTranscript)
.transcripts(transcripts) .transcripts(transcripts)
.includesSummary(includeSummary) .includesSummary(includeSummary)
.summaryContent(summaryContent) .summaryContent(summaryContent)
.build(); .build();
log.info("[{}]{}.返回数据:[{}]","Android会议","查询会议统一状态",build); log.info("[{}]{}.返回数据:[{}]", "Android会议", "查询会议统一状态", build);
return ApiResponse.ok(build); return ApiResponse.ok(build);
} }
@Operation(summary = "重试 Android 会议 ASR 识别") @Operation(summary = "重试 Android 会议 ASR 识别")
@ -345,7 +350,7 @@ public class AndroidMeetingController {
LoginUser loginUser = authContext.isAnonymous() ? null : AndroidLoginUserSupport.requireLoginUser(authContext); LoginUser loginUser = authContext.isAnonymous() ? null : AndroidLoginUserSupport.requireLoginUser(authContext);
requireOperableOfflineMeeting(meetingId, authContext, loginUser); requireOperableOfflineMeeting(meetingId, authContext, loginUser);
meetingCommandService.retryTranscription(meetingId); meetingCommandService.retryTranscription(meetingId);
androidMeetingPushService.pushMeetingStatusChanged(meetingId, UnifiedMeetingStatusStage.TRANSCRIBING.getCode()); androidMeetingPushService.pushMeetingStatusChanged(meetingId, UnifiedMeetingStatusStage.TRANSCRIBING.getCode());
return ApiResponse.ok(true); return ApiResponse.ok(true);
} }
@ -366,17 +371,18 @@ public class AndroidMeetingController {
LoginUser loginUser = authContext.isAnonymous() ? null : AndroidLoginUserSupport.requireLoginUser(authContext); LoginUser loginUser = authContext.isAnonymous() ? null : AndroidLoginUserSupport.requireLoginUser(authContext);
requireOperableOfflineMeeting(meetingId, authContext, loginUser); requireOperableOfflineMeeting(meetingId, authContext, loginUser);
meetingCommandService.retrySummary(meetingId); meetingCommandService.retrySummary(meetingId);
androidMeetingPushService.pushMeetingStatusChanged(meetingId, UnifiedMeetingStatusStage.SUMMARIZING.getCode()); androidMeetingPushService.pushMeetingStatusChanged(meetingId, UnifiedMeetingStatusStage.SUMMARIZING.getCode());
return ApiResponse.ok(true); return ApiResponse.ok(true);
} }
@Operation(summary = "更新Android会议访问密码")
@ApiResponses({ @Operation(summary = "更新Android会议访问密码")
@io.swagger.v3.oas.annotations.responses.ApiResponse( @ApiResponses({
responseCode = "200", @io.swagger.v3.oas.annotations.responses.ApiResponse(
description = "返回更新后的会议访问密码,传空时表示清空访问密码", responseCode = "200",
content = @Content(schema = @Schema(implementation = String.class)) description = "返回更新后的会议访问密码,传空时表示清空访问密码",
) content = @Content(schema = @Schema(implementation = String.class))
}) )
})
@PutMapping("/{meetingId}/access-password") @PutMapping("/{meetingId}/access-password")
@Log(value = "修改Android会议访问密码", type = "Android会议管理") @Log(value = "修改Android会议访问密码", type = "Android会议管理")
public ApiResponse<String> updateAccessPassword(HttpServletRequest request, public ApiResponse<String> updateAccessPassword(HttpServletRequest request,
@ -392,9 +398,9 @@ public class AndroidMeetingController {
return ApiResponse.error("仅会议创建人可设置访问密码"); return ApiResponse.error("仅会议创建人可设置访问密码");
} }
String password = normalizePassword(command == null ? null : command.getPassword()); String password = normalizePassword(command == null ? null : command.getPassword());
meetingService.update(new LambdaUpdateWrapper<Meeting>() meetingService.update(new LambdaUpdateWrapper<Meeting>()
.eq(Meeting::getId,meeting.getId()) .eq(Meeting::getId, meeting.getId())
.set(Meeting::getAccessPassword, password)); .set(Meeting::getAccessPassword, password));
return ApiResponse.ok(password); return ApiResponse.ok(password);
} }
@ -417,54 +423,55 @@ public class AndroidMeetingController {
meetingCommandService.deleteMeeting(meetingId); meetingCommandService.deleteMeeting(meetingId);
return ApiResponse.ok(true); return ApiResponse.ok(true);
} }
@GetMapping("/config")
@Log(value = "获取会议配置", type = "Android会议管理")
@Operation(summary = "获取会议配置")
@Anonymous
public ApiResponse<AndroidMeetingConfigVo> 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<List<PromptTemplateVO>> promptTemplateList = promptTemplateService.pageTemplates(
1,
1000,
null,
null,
tenantId,
userId,
isPlatformAdmin,
isTenantAdmin
);
List<PromptTemplateVO> enabledTemplates = promptTemplateList.getRecords() == null
? List.of()
: promptTemplateList.getRecords().stream()
.filter(item -> Integer.valueOf(1).equals(item.getStatus()))
.toList();
resultVo.setTemplateList(enabledTemplates);
PageResult<List<AiModelVO>> modelList = aiModelService.pageModels(1, 1000, null, "LLM", tenantId);
List<AiModelVO> 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<AndroidMeetingConfigVo> 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<List<PromptTemplateVO>> promptTemplateList = promptTemplateService.pageTemplates(
1,
1000,
null,
null,
tenantId,
userId,
isPlatformAdmin,
isTenantAdmin
);
List<PromptTemplateVO> enabledTemplates = promptTemplateList.getRecords() == null
? List.of()
: promptTemplateList.getRecords().stream()
.filter(item -> Integer.valueOf(1).equals(item.getStatus()))
.toList();
resultVo.setTemplateList(enabledTemplates);
PageResult<List<AiModelVO>> modelList = aiModelService.pageModels(1, 1000, null, "LLM", tenantId);
List<AiModelVO> 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, private void resolvePublicDeviceTenantId(HttpServletRequest request,
AndroidOfflineMeetingCreateCommand command, AndroidOfflineMeetingCreateCommand command,
@ -547,29 +554,14 @@ public class AndroidMeetingController {
return ChronoUnit.DAYS.between(LocalDate.now(), meetingTime.toLocalDate()); return ChronoUnit.DAYS.between(LocalDate.now(), meetingTime.toLocalDate());
} }
private MeetingVO requireOperableOfflineMeeting(Long meetingId, AndroidAuthContext authContext, LoginUser loginUser) { private Meeting requireOperableOfflineMeeting(Long meetingId, AndroidAuthContext authContext, LoginUser loginUser) {
MeetingVO meeting = meetingQueryService.getDetailIgnoreTenant(meetingId); Meeting meeting = meetingAccessService.requireMeetingIgnoreTenant(meetingId);
if (meeting == null) {
throw new BusinessException(BusinessErrorCodeEnum.MEETING_NOT_FOUND.getCode(), "会议不存在");
}
if (!MeetingConstants.TYPE_OFFLINE.equals(meeting.getMeetingType())) { if (!MeetingConstants.TYPE_OFFLINE.equals(meeting.getMeetingType())) {
throw new RuntimeException("当前会议不是离线会议"); throw new RuntimeException("当前会议不是离线会议");
} }
if (authContext == null || authContext.getDeviceId() == null || authContext.getDeviceId().isBlank()) { if (authContext == null || authContext.getDeviceId() == null || authContext.getDeviceId().isBlank()) {
throw new RuntimeException("设备ID不能为空"); 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; return meeting;
} }

View File

@ -254,7 +254,20 @@ public class AndroidPushGrpcService extends PushServiceGrpc.PushServiceImplBase
return switch (platform) { return switch (platform) {
case IOS -> "ios"; case IOS -> "ios";
case ANDROID -> "android"; 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";
}; };
} }
} }

View File

@ -15,4 +15,12 @@ public interface AndroidChunkUploadService {
LegacyUploadAudioResponse completeUpload(Long meetingId, LegacyUploadAudioResponse completeUpload(Long meetingId,
Integer totalChunks, Integer totalChunks,
AndroidAuthContext authContext) throws IOException; AndroidAuthContext authContext) throws IOException;
/**
* + + 线
* 线Tomcat failOfflineTranscription
*/
void completeUploadAsync(Long meetingId,
Integer totalChunks,
AndroidAuthContext authContext);
} }

View File

@ -6,13 +6,15 @@ import com.imeeting.dto.android.legacy.LegacyUploadAudioResponse;
import com.imeeting.service.android.AndroidChunkUploadService; import com.imeeting.service.android.AndroidChunkUploadService;
import com.imeeting.service.android.legacy.LegacyMeetingAdapterService; import com.imeeting.service.android.legacy.LegacyMeetingAdapterService;
import com.imeeting.service.biz.MeetingCommandService; import com.imeeting.service.biz.MeetingCommandService;
import com.imeeting.service.biz.MeetingQueryService;
import com.imeeting.support.TaskSecurityContextRunner;
import com.imeeting.support.redis.AndroidChunkUploadSessionCache; 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.beans.factory.annotation.Value;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.springframework.web.multipart.MultipartFile; import org.springframework.web.multipart.MultipartFile;
import java.io.ByteArrayInputStream;
import java.io.IOException; import java.io.IOException;
import java.io.InputStream; import java.io.InputStream;
import java.nio.charset.StandardCharsets; import java.nio.charset.StandardCharsets;
@ -30,15 +32,16 @@ import java.util.regex.Matcher;
import java.util.regex.Pattern; import java.util.regex.Pattern;
@Service @Service
@RequiredArgsConstructor @Slf4j
public class AndroidChunkUploadServiceImpl implements AndroidChunkUploadService { public class AndroidChunkUploadServiceImpl implements AndroidChunkUploadService {
private static final Pattern LEGACY_CHUNK_FILE_NAME_PATTERN = Pattern.compile("^chunk-(\\d+)(\\..+)?$"); 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 Pattern CHUNK_DIR_NAME_PATTERN = Pattern.compile("^chunk-(\\d+)$");
private static final String CHUNK_ROOT_DIR = "chunks"; private static final String CHUNK_ROOT_DIR = "chunks";
private final TaskSecurityContextRunner taskSecurityContextRunner;
private final AndroidChunkUploadSessionCache sessionCache; private final AndroidChunkUploadSessionCache sessionCache;
private final LegacyMeetingAdapterService legacyMeetingAdapterService; private final LegacyMeetingAdapterService legacyMeetingAdapterService;
private final MeetingCommandService meetingCommandService; private final MeetingCommandService meetingCommandService;
private final java.util.concurrent.Executor chunkMergeExecutor;
@Value("${unisbase.app.upload-path}") @Value("${unisbase.app.upload-path}")
private String uploadPath; private String uploadPath;
@ -46,6 +49,18 @@ public class AndroidChunkUploadServiceImpl implements AndroidChunkUploadService
@Value("${imeeting.audio.ffmpeg-path:ffmpeg}") @Value("${imeeting.audio.ffmpeg-path:ffmpeg}")
private String ffmpegPath; 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 @Override
public void saveChunk(Long meetingId, public void saveChunk(Long meetingId,
Integer chunkIndex, Integer chunkIndex,
@ -116,7 +131,7 @@ public class AndroidChunkUploadServiceImpl implements AndroidChunkUploadService
MultipartFile mergedMultipart = new LocalMultipartFile( MultipartFile mergedMultipart = new LocalMultipartFile(
resolveMergedOriginalFilename(state, orderedChunkPaths, mergedFile), resolveMergedOriginalFilename(state, orderedChunkPaths, mergedFile),
state.getContentType(), state.getContentType(),
Files.readAllBytes(mergedFile) mergedFile
); );
LegacyUploadAudioResponse response = legacyMeetingAdapterService.uploadAndTriggerOfflineProcessForPublicDevice( LegacyUploadAudioResponse response = legacyMeetingAdapterService.uploadAndTriggerOfflineProcessForPublicDevice(
@ -133,6 +148,31 @@ public class AndroidChunkUploadServiceImpl implements AndroidChunkUploadService
return response; 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, private AndroidChunkUploadSessionState getOrCreateState(Long meetingId,
MultipartFile chunkFile, MultipartFile chunkFile,
AndroidAuthContext authContext) { AndroidAuthContext authContext) {
@ -454,12 +494,12 @@ public class AndroidChunkUploadServiceImpl implements AndroidChunkUploadService
private static final class LocalMultipartFile implements MultipartFile { private static final class LocalMultipartFile implements MultipartFile {
private final String originalFilename; private final String originalFilename;
private final String contentType; 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.originalFilename = originalFilename;
this.contentType = contentType; this.contentType = contentType;
this.bytes = bytes == null ? new byte[0] : bytes; this.filePath = filePath;
} }
@Override @Override
@ -479,27 +519,35 @@ public class AndroidChunkUploadServiceImpl implements AndroidChunkUploadService
@Override @Override
public boolean isEmpty() { public boolean isEmpty() {
return bytes.length == 0; try {
return Files.size(filePath) == 0;
} catch (IOException ex) {
return true;
}
} }
@Override @Override
public long getSize() { public long getSize() {
return bytes.length; try {
return Files.size(filePath);
} catch (IOException ex) {
return 0;
}
} }
@Override @Override
public byte[] getBytes() { public byte[] getBytes() throws IOException {
return bytes; return Files.readAllBytes(filePath);
} }
@Override @Override
public InputStream getInputStream() { public InputStream getInputStream() throws IOException {
return new ByteArrayInputStream(bytes); return Files.newInputStream(filePath);
} }
@Override @Override
public void transferTo(java.io.File dest) throws IOException, IllegalStateException { 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);
} }
} }
} }

View File

@ -44,6 +44,7 @@ import java.util.HashMap;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.Objects; import java.util.Objects;
import java.util.concurrent.CompletableFuture;
import java.util.stream.Collectors; import java.util.stream.Collectors;
@Service @Service
@ -289,6 +290,7 @@ public class LegacyMeetingAdapterServiceImpl implements LegacyMeetingAdapterServ
String relocatedUrl = meetingDomainSupport.relocateAudioUrl(meetingId, stagingUrl); String relocatedUrl = meetingDomainSupport.relocateAudioUrl(meetingId, stagingUrl);
taskSecurityContextRunner.runAsTenantUser(meeting.getTenantId(), meeting.getCreatorId(), () -> { taskSecurityContextRunner.runAsTenantUser(meeting.getTenantId(), meeting.getCreatorId(), () -> {
meetingDomainSupport.applyMeetingAudioMetadata(meeting, relocatedUrl); meetingDomainSupport.applyMeetingAudioMetadata(meeting, relocatedUrl);
meetingDomainSupport.prewarmPlaybackAudioAfterCommit(relocatedUrl);
meeting.setSummaryModelId(profile.getResolvedSummaryModelId()); meeting.setSummaryModelId(profile.getResolvedSummaryModelId());
meeting.setPromptId(profile.getResolvedPromptId()); meeting.setPromptId(profile.getResolvedPromptId());
meeting.setAudioSaveStatus(RealtimeMeetingAudioStorageService.STATUS_SUCCESS); meeting.setAudioSaveStatus(RealtimeMeetingAudioStorageService.STATUS_SUCCESS);

View File

@ -16,7 +16,10 @@ public interface MeetingQueryService {
MeetingVO getDetail(Long id); MeetingVO getDetail(Long id);
MeetingVO getDetailIgnoreTenant(Long id); default MeetingVO getDetailIgnoreTenant(Long id){
return getDetailIgnoreTenant(id,true);
};
MeetingVO getDetailIgnoreTenant(Long id,Boolean includeAudio);
List<MeetingTranscriptVO> getTranscripts(Long meetingId); List<MeetingTranscriptVO> getTranscripts(Long meetingId);

View File

@ -62,6 +62,7 @@ public class MeetingAudioUploadSupport {
Files.copy(inputStream, targetPath, StandardCopyOption.REPLACE_EXISTING); Files.copy(inputStream, targetPath, StandardCopyOption.REPLACE_EXISTING);
} }
validateStoredAudio(targetPath, extension); validateStoredAudio(targetPath, extension);
} catch (Exception ex) { } catch (Exception ex) {
Files.deleteIfExists(targetPath); Files.deleteIfExists(targetPath);
throw ex; throw ex;
@ -69,6 +70,55 @@ public class MeetingAudioUploadSupport {
return buildStagingAudioToken(storedFileName); 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) { public static boolean isStagingAudioToken(String audioUrl) {
return StringUtils.hasText(audioUrl) && audioUrl.startsWith(STAGING_AUDIO_TOKEN_PREFIX); return StringUtils.hasText(audioUrl) && audioUrl.startsWith(STAGING_AUDIO_TOKEN_PREFIX);
} }

View File

@ -1,5 +1,6 @@
package com.imeeting.service.biz.impl; package com.imeeting.service.biz.impl;
import cn.hutool.core.date.StopWatch;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.imeeting.common.MeetingConstants; import com.imeeting.common.MeetingConstants;
import com.imeeting.common.SysParamKeys; import com.imeeting.common.SysParamKeys;

View File

@ -485,7 +485,7 @@ public class MeetingPlaybackAudioResolver {
"-vn", "-vn",
"-ar", String.valueOf(BROWSER_SAMPLE_RATE), "-ar", String.valueOf(BROWSER_SAMPLE_RATE),
"-c:a", "aac", "-c:a", "aac",
"-f", "mp4", "-ac", "1",
targetPath.toString() targetPath.toString()
); );
executeCommand(command, targetPath); executeCommand(command, targetPath);

View File

@ -1,5 +1,6 @@
package com.imeeting.service.biz.impl; package com.imeeting.service.biz.impl;
import cn.hutool.core.date.StopWatch;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page; import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.imeeting.dto.biz.MeetingSummaryPromptContextRequestDTO; import com.imeeting.dto.biz.MeetingSummaryPromptContextRequestDTO;
@ -19,6 +20,7 @@ import com.imeeting.service.biz.MeetingService;
import com.imeeting.service.biz.MeetingTranscriptChapterService; import com.imeeting.service.biz.MeetingTranscriptChapterService;
import com.unisbase.dto.PageResult; import com.unisbase.dto.PageResult;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import java.time.LocalDateTime; import java.time.LocalDateTime;
@ -27,6 +29,7 @@ import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.stream.Collectors; import java.util.stream.Collectors;
@Slf4j
@Service @Service
@RequiredArgsConstructor @RequiredArgsConstructor
public class MeetingQueryServiceImpl implements MeetingQueryService { public class MeetingQueryServiceImpl implements MeetingQueryService {
@ -83,10 +86,12 @@ public class MeetingQueryServiceImpl implements MeetingQueryService {
return meeting != null ? toVO(meeting, true) : null; return meeting != null ? toVO(meeting, true) : null;
} }
@Override @Override
public MeetingVO getDetailIgnoreTenant(Long id) { public MeetingVO getDetailIgnoreTenant(Long id, Boolean includeAudio) {
Meeting meeting = meetingMapper.selectByIdIgnoreTenant(id); Meeting meeting = meetingMapper.selectByIdIgnoreTenant(id);
return meeting != null ? toVO(meeting, true) : null; return meeting != null ? toVO(meeting, includeAudio) : null;
} }
@Override @Override