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 0d53836..5729293 100644 --- a/backend/src/main/java/com/imeeting/controller/android/AndroidMeetingController.java +++ b/backend/src/main/java/com/imeeting/controller/android/AndroidMeetingController.java @@ -559,15 +559,15 @@ public class AndroidMeetingController { // 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("仅会议创建人可操作当前会议"); - } +// 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/service/android/impl/AndroidChunkUploadServiceImpl.java b/backend/src/main/java/com/imeeting/service/android/impl/AndroidChunkUploadServiceImpl.java index d05450c..6e6de3f 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 @@ -24,11 +24,16 @@ import java.util.ArrayList; import java.util.List; import java.util.Map; import java.util.Objects; +import java.util.TreeMap; import java.util.concurrent.TimeUnit; +import java.util.regex.Matcher; +import java.util.regex.Pattern; @Service @RequiredArgsConstructor public class AndroidChunkUploadServiceImpl implements AndroidChunkUploadService { + private static final Pattern CHUNK_FILE_NAME_PATTERN = Pattern.compile("^chunk-(\\d+)(\\..+)?$"); + private final AndroidChunkUploadSessionCache sessionCache; private final LegacyMeetingAdapterService legacyMeetingAdapterService; @@ -49,9 +54,10 @@ public class AndroidChunkUploadServiceImpl implements AndroidChunkUploadService if (chunkIndex == null || chunkIndex < 0) { throw new RuntimeException("分片参数无效"); } - if (chunkFile == null || chunkFile.isEmpty()) { + if (chunkFile == null) { throw new RuntimeException("chunk_file不能为空"); } + AndroidChunkUploadSessionState state = getOrCreateState(meetingId, chunkFile, authContext); if (!Objects.equals(state.getMeetingId(), meetingId) || !Objects.equals(state.getDeviceId(), authContext.getDeviceId())) { throw new RuntimeException("分片上传会话与当前设备或会议不匹配"); @@ -85,25 +91,25 @@ public class AndroidChunkUploadServiceImpl implements AndroidChunkUploadService throw new RuntimeException("total_chunks不能为空且必须大于0"); } - AndroidChunkUploadSessionState state = requireState(meetingId); + AndroidChunkUploadSessionState state = loadStateForCompletion(meetingId, authContext); if (!Objects.equals(state.getMeetingId(), meetingId) || !Objects.equals(state.getDeviceId(), authContext.getDeviceId())) { throw new RuntimeException("分片上传会话与当前设备或会议不匹配"); } - state.setTotalChunks(totalChunks); - saveState(meetingId, state); - for (int i = 0; i < totalChunks; i++) { - if (!state.getReceivedChunks().contains(i) || !state.getChunkFileNames().containsKey(i)) { - throw new RuntimeException("分片未上传完整"); - } - } + Path meetingDir = sessionDir(meetingId); + Files.createDirectories(meetingDir); - Path mergedFile = mergeChunks(state); + state.setTotalChunks(totalChunks); + List orderedChunkPaths = rebuildChunkStateFromDisk(state, meetingDir, totalChunks); + saveState(meetingId, state); + + Path mergedFile = mergeChunks(state, orderedChunkPaths); MultipartFile mergedMultipart = new LocalMultipartFile( buildMergedOriginalFilename(state, mergedFile), state.getContentType(), Files.readAllBytes(mergedFile) ); + LegacyUploadAudioResponse response; if (authContext.isAnonymous()) { response = legacyMeetingAdapterService.uploadAndTriggerOfflineProcessForPublicDevice( @@ -148,40 +154,149 @@ public class AndroidChunkUploadServiceImpl implements AndroidChunkUploadService return state; } - private Path mergeChunks(AndroidChunkUploadSessionState state) throws IOException { + private AndroidChunkUploadSessionState loadStateForCompletion(Long meetingId, AndroidAuthContext authContext) { + AndroidChunkUploadSessionState state = getState(meetingId); + if (state != null) { + return state; + } + AndroidChunkUploadSessionState rebuiltState = new AndroidChunkUploadSessionState(); + rebuiltState.setMeetingId(meetingId); + rebuiltState.setDeviceId(authContext.getDeviceId()); + return rebuiltState; + } + + private List rebuildChunkStateFromDisk(AndroidChunkUploadSessionState state, + Path meetingDir, + int totalChunks) throws IOException { + Map validatedChunkFiles = scanChunkFiles(meetingDir, true); + Map mergeChunkFiles = scanChunkFiles(meetingDir, false); + state.getReceivedChunks().clear(); + state.getUploadedChunkFileNames().clear(); + state.getChunkFileNames().clear(); + + for (Map.Entry entry : validatedChunkFiles.entrySet()) { + Integer chunkIndex = entry.getKey(); + Path stateChunkPath = mergeChunkFiles.getOrDefault(chunkIndex, entry.getValue()); + String fileName = stateChunkPath.getFileName().toString(); + state.getReceivedChunks().add(chunkIndex); + state.getUploadedChunkFileNames().add(fileName); + state.getChunkFileNames().put(chunkIndex, fileName); + } + + List orderedChunkPaths = new ArrayList<>(totalChunks); + for (int i = 0; i < totalChunks; i++) { + Path validatedChunkPath = validatedChunkFiles.get(i); + if (validatedChunkPath == null) { + throw new RuntimeException("分片未上传完整"); + } + Path chunkPath = mergeChunkFiles.get(i); + if (chunkPath != null) { + orderedChunkPaths.add(chunkPath); + } + } + return orderedChunkPaths; + } + + private Map scanChunkFiles(Path meetingDir, boolean includePending) throws IOException { + Map chunkFiles = new TreeMap<>(); + if (!Files.exists(meetingDir)) { + return chunkFiles; + } + try (var paths = Files.list(meetingDir)) { + paths.filter(Files::isRegularFile) + .filter(path -> isUsableChunkFile(path, includePending)) + .forEach(path -> { + Integer chunkIndex = parseChunkIndex(path.getFileName().toString(), includePending); + if (chunkIndex == null) { + return; + } + Path existing = chunkFiles.get(chunkIndex); + if (existing == null || isPreferredChunkFile(path, existing)) { + chunkFiles.put(chunkIndex, path); + } + }); + } + return chunkFiles; + } + + private boolean isUsableChunkFile(Path path, boolean includePending) { + if (path == null || path.getFileName() == null) { + return false; + } + String fileName = normalizeChunkStorageFileName(path.getFileName().toString(), includePending); + if (fileName == null) { + return false; + } + return CHUNK_FILE_NAME_PATTERN.matcher(fileName).matches(); + } + + private Integer parseChunkIndex(String fileName, boolean includePending) { + String normalizedFileName = normalizeChunkStorageFileName(fileName, includePending); + if (normalizedFileName == null) { + return null; + } + Matcher matcher = CHUNK_FILE_NAME_PATTERN.matcher(normalizedFileName); + if (!matcher.matches()) { + return null; + } + return Integer.parseInt(matcher.group(1)); + } + + private boolean isPreferredChunkFile(Path candidate, Path existing) { + boolean candidatePending = isPendingChunkFile(candidate); + boolean existingPending = isPendingChunkFile(existing); + if (candidatePending != existingPending) { + return !candidatePending; + } + try { + return Files.getLastModifiedTime(candidate).compareTo(Files.getLastModifiedTime(existing)) >= 0; + } catch (IOException ex) { + return candidate.getFileName().toString().compareTo(existing.getFileName().toString()) >= 0; + } + } + + private String normalizeChunkStorageFileName(String fileName, boolean includePending) { + if (fileName == null) { + return null; + } + if (fileName.endsWith(".pending")) { + return includePending ? fileName.substring(0, fileName.length() - ".pending".length()) : null; + } + return fileName; + } + + private boolean isPendingChunkFile(Path path) { + return path != null && path.getFileName() != null && path.getFileName().toString().endsWith(".pending"); + } + + private Path mergeChunks(AndroidChunkUploadSessionState state, List chunkPaths) throws IOException { Path meetingDir = sessionDir(state.getMeetingId()); - Files.createDirectories(meetingDir); - - List chunkPaths = new ArrayList<>(); - for (Map.Entry entry : state.getChunkFileNames().entrySet()) { - Path chunkPath = meetingDir.resolve(entry.getValue()); - if (!Files.exists(chunkPath)) { - throw new RuntimeException("分片文件不存在: " + entry.getValue()); - } - chunkPaths.add(chunkPath); - } - if (chunkPaths.isEmpty()) { - throw new RuntimeException("没有可合并的分片文件"); - } - if (chunkPaths.size() == 1) { - return chunkPaths.get(0); - } - - String mergedExtension = resolveMergedExtension(chunkPaths.get(0)); + String mergedExtension = resolveMergedExtension(state, chunkPaths); Path mergedOutput = meetingDir.resolve("merged" + mergedExtension); Path concatList = meetingDir.resolve("concat-inputs.txt"); Files.deleteIfExists(mergedOutput); + + if (chunkPaths.isEmpty() || allChunkFilesEmpty(chunkPaths)) { + Files.write(mergedOutput, new byte[0], StandardOpenOption.CREATE, StandardOpenOption.TRUNCATE_EXISTING); + return mergedOutput; + } + + if (chunkPaths.size() == 1) { + return chunkPaths.get(0); + } + writeConcatListFile(concatList, chunkPaths); executeFfmpegConcat(concatList, mergedOutput); return mergedOutput; } - private AndroidChunkUploadSessionState requireState(Long meetingId) { - AndroidChunkUploadSessionState state = getState(meetingId); - if (state == null) { - throw new RuntimeException("分片上传会话不存在或已过期"); - } - return state; + private boolean allChunkFilesEmpty(List chunkPaths) throws IOException { + for (Path chunkPath : chunkPaths) { + if (Files.size(chunkPath) > 0) { + return false; + } + } + return true; } private AndroidChunkUploadSessionState getState(Long meetingId) { @@ -243,6 +358,19 @@ public class AndroidChunkUploadServiceImpl implements AndroidChunkUploadService return extensionIndex >= 0 ? fileName.substring(extensionIndex) : ".bin"; } + private String resolveMergedExtension(AndroidChunkUploadSessionState state, List chunkPaths) { + if (chunkPaths != null && !chunkPaths.isEmpty()) { + return resolveMergedExtension(chunkPaths.get(0)); + } + if (state != null && state.getFileName() != null && !state.getFileName().isBlank()) { + int extensionIndex = state.getFileName().lastIndexOf('.'); + if (extensionIndex >= 0) { + return state.getFileName().substring(extensionIndex); + } + } + return ".bin"; + } + private String normalizeChunkSourceFileName(String fileName) { if (fileName == null) { return ""; @@ -257,6 +385,9 @@ public class AndroidChunkUploadServiceImpl implements AndroidChunkUploadService private void writeConcatListFile(Path concatList, List chunkPaths) throws IOException { List lines = new ArrayList<>(chunkPaths.size()); for (Path chunkPath : chunkPaths) { + if (chunkPath.getFileName() != null && chunkPath.getFileName().toString().endsWith(".pending")) { + continue; + } String normalizedPath = chunkPath.toAbsolutePath().toString().replace("'", "'\\''"); lines.add("file '" + normalizedPath + "'"); } @@ -293,7 +424,7 @@ public class AndroidChunkUploadServiceImpl implements AndroidChunkUploadService if (process.exitValue() != 0) { throw new IOException("音频分片合并失败: " + new String(output, StandardCharsets.UTF_8)); } - if (!Files.exists(mergedOutput) || Files.size(mergedOutput) <= 0) { + if (!Files.exists(mergedOutput)) { throw new IOException("音频分片合并结果为空"); } } 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 6e630ea..40779e9 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 @@ -165,7 +165,7 @@ public class LegacyMeetingAdapterServiceImpl implements LegacyMeetingAdapterServ if (meetingId == null) { throw new RuntimeException("meeting_id 不能为空"); } - if (audioFile == null || audioFile.isEmpty()) { + if (audioFile == null) { throw new RuntimeException("audio_file 不能为空"); } @@ -241,14 +241,14 @@ public class LegacyMeetingAdapterServiceImpl implements LegacyMeetingAdapterServ if (meetingId == null) { throw new RuntimeException("meeting_id 不能为空"); } - if (audioFile == null || audioFile.isEmpty()) { + if (audioFile == null) { throw new RuntimeException("audio_file 不能为空"); } Meeting meeting = meetingAccessService.requireMeeting(meetingId); assertDeviceOwnsMeeting(meeting, authContext); - if (!MeetingConstants.DEVICE_MODE_PUBLIC.equals(meeting.getSourceDeviceMode())) { - throw new RuntimeException("当前会议不是公有设备会议"); - } +// if (!MeetingConstants.DEVICE_MODE_PUBLIC.equals(meeting.getSourceDeviceMode())) { +// throw new RuntimeException("当前会议不是公有设备会议"); +// } if (!forceReplace && meeting.getAudioUrl() != null && !meeting.getAudioUrl().isBlank()) { throw new RuntimeException("当前会议已存在音频,如需替换请设置 force_replace=true"); } 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 0d279f0..5b04fda 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 @@ -43,7 +43,7 @@ public class MeetingAudioUploadSupport { private final SysParamService sysParamService; public String storeUploadedAudio(MultipartFile file) throws IOException { - if (file == null || file.isEmpty()) { + if (file == null) { throw new RuntimeException("音频文件不能为空"); } @@ -169,6 +169,9 @@ public class MeetingAudioUploadSupport { } private void validateFileHeader(MultipartFile file, String extension) throws IOException { + if (file.getSize() <= 0) { + return; + } byte[] header; try (InputStream inputStream = file.getInputStream()) { header = inputStream.readNBytes(HEADER_SIZE); @@ -214,6 +217,9 @@ public class MeetingAudioUploadSupport { if (!"m4a".equals(extension)) { return; } + if (Files.size(audioPath) <= 0) { + return; + } String sampleEntryType = resolveM4aSampleEntryType(audioPath); if (!StringUtils.hasText(sampleEntryType)) { throw new RuntimeException("当前 m4a 文件未找到可识别的音频轨道,无法在网页中播放,请转为 mp3、wav 或 AAC 编码的 m4a 后重试");