feat: 添加离线会议转录失败处理逻辑和优化分片上传

- 在 `MeetingUnifiedStatusServiceImpl` 中添加 `isAndroidOfflineEmptyUploadFailure` 方法,处理安卓离线会议空上传失败情况
- 在 `MeetingCommandService` 和 `MeetingCommandServiceImpl` 中添加 `failOfflineTranscription` 方法,处理离线会议转录失败
- 优化 `AndroidChunkUploadServiceImpl` 中的分片上传和合并逻辑,增加对空文件的处理
- 更新 `AndroidMeetingController` 中的分片上传结果处理逻辑
dev_na
chenhao 2026-06-24 16:59:33 +08:00
parent d38acf5ccc
commit 97065c68a6
7 changed files with 269 additions and 226 deletions

View File

@ -248,8 +248,8 @@ public class AndroidMeetingController {
command == null ? null : command.getTotalChunks(), command == null ? null : command.getTotalChunks(),
authContext authContext
); );
if (uploadResult == null || uploadResult.getAudioUrl() == null || uploadResult.getAudioUrl().isBlank()) { if (uploadResult == null) {
throw new RuntimeException("分片上传完成后未生成 audio_url"); throw new RuntimeException("分片上传完成后未生成结果");
} }
} }
meetingCommandService.finishOfflineMeeting(meeting.getId(), command == null ? null : command.getFinishStage()); meetingCommandService.finishOfflineMeeting(meeting.getId(), command == null ? null : command.getFinishStage());

View File

@ -18,4 +18,12 @@ public class LegacyUploadAudioResponse {
@JsonProperty("audio_url") @JsonProperty("audio_url")
@Schema(description = "上传后的音频访问地址") @Schema(description = "上传后的音频访问地址")
private String audioUrl; private String audioUrl;
@JsonProperty("message")
@Schema(description = "结果提示")
private String message;
public LegacyUploadAudioResponse(Long meetingId, String audioUrl) {
this(meetingId, audioUrl, null);
}
} }

View File

@ -5,8 +5,8 @@ import com.imeeting.dto.android.AndroidChunkUploadSessionState;
import com.imeeting.dto.android.legacy.LegacyUploadAudioResponse; 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.support.redis.AndroidChunkUploadSessionCache; import com.imeeting.support.redis.AndroidChunkUploadSessionCache;
import com.unisbase.security.LoginUser;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import org.springframework.beans.factory.annotation.Value; import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
@ -32,10 +32,13 @@ import java.util.regex.Pattern;
@Service @Service
@RequiredArgsConstructor @RequiredArgsConstructor
public class AndroidChunkUploadServiceImpl implements AndroidChunkUploadService { public class AndroidChunkUploadServiceImpl implements AndroidChunkUploadService {
private static final Pattern 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 String CHUNK_ROOT_DIR = "chunks";
private final AndroidChunkUploadSessionCache sessionCache; private final AndroidChunkUploadSessionCache sessionCache;
private final LegacyMeetingAdapterService legacyMeetingAdapterService; private final LegacyMeetingAdapterService legacyMeetingAdapterService;
private final MeetingCommandService meetingCommandService;
@Value("${unisbase.app.upload-path}") @Value("${unisbase.app.upload-path}")
private String uploadPath; private String uploadPath;
@ -54,7 +57,7 @@ public class AndroidChunkUploadServiceImpl implements AndroidChunkUploadService
if (chunkIndex == null || chunkIndex < 0) { if (chunkIndex == null || chunkIndex < 0) {
throw new RuntimeException("分片参数无效"); throw new RuntimeException("分片参数无效");
} }
if (chunkFile == null) { if (chunkFile == null) {
throw new RuntimeException("chunk_file不能为空"); throw new RuntimeException("chunk_file不能为空");
} }
@ -63,19 +66,19 @@ public class AndroidChunkUploadServiceImpl implements AndroidChunkUploadService
throw new RuntimeException("分片上传会话与当前设备或会议不匹配"); throw new RuntimeException("分片上传会话与当前设备或会议不匹配");
} }
String chunkFileName = buildStoredChunkFileName(chunkIndex, chunkFile.getOriginalFilename()); String originalFileName = resolveOriginalFileName(chunkFile.getOriginalFilename());
String previousFileName = state.getChunkFileNames().get(chunkIndex); Path chunkFilePath = resolveChunkFilePath(meetingId, chunkIndex, originalFileName);
Path meetingDir = sessionDir(meetingId); Path chunkDir = chunkFilePath.getParent();
Files.createDirectories(meetingDir); clearChunkDirectory(chunkDir);
Files.createDirectories(chunkDir);
if (previousFileName != null && !previousFileName.equals(chunkFileName)) { Files.write(chunkFilePath, chunkFile.getBytes(), StandardOpenOption.CREATE, StandardOpenOption.TRUNCATE_EXISTING);
deleteQuietly(meetingDir.resolve(previousFileName));
String previousFileName = state.getChunkFileNames().put(chunkIndex, originalFileName);
if (previousFileName != null && !previousFileName.equals(originalFileName)) {
state.getUploadedChunkFileNames().remove(previousFileName); state.getUploadedChunkFileNames().remove(previousFileName);
} }
state.getUploadedChunkFileNames().add(originalFileName);
Files.write(meetingDir.resolve(chunkFileName), chunkFile.getBytes(), StandardOpenOption.CREATE, StandardOpenOption.TRUNCATE_EXISTING);
state.getUploadedChunkFileNames().add(chunkFileName);
state.getChunkFileNames().put(chunkIndex, chunkFileName);
state.getReceivedChunks().add(chunkIndex); state.getReceivedChunks().add(chunkIndex);
saveState(meetingId, state); saveState(meetingId, state);
} }
@ -91,47 +94,39 @@ public class AndroidChunkUploadServiceImpl implements AndroidChunkUploadService
throw new RuntimeException("total_chunks不能为空且必须大于0"); throw new RuntimeException("total_chunks不能为空且必须大于0");
} }
AndroidChunkUploadSessionState state = loadStateForCompletion(meetingId, authContext); AndroidChunkUploadSessionState state = loadStateForCompletion(meetingId, authContext);
if (!Objects.equals(state.getMeetingId(), meetingId) || !Objects.equals(state.getDeviceId(), authContext.getDeviceId())) { if (!Objects.equals(state.getMeetingId(), meetingId) || !Objects.equals(state.getDeviceId(), authContext.getDeviceId())) {
throw new RuntimeException("分片上传会话与当前设备或会议不匹配"); throw new RuntimeException("分片上传会话与当前设备或会议不匹配");
} }
Path meetingDir = sessionDir(meetingId); Path meetingDir = sessionDir(meetingId);
Files.createDirectories(meetingDir); Files.createDirectories(meetingDir);
state.setTotalChunks(totalChunks); state.setTotalChunks(totalChunks);
List<Path> orderedChunkPaths = rebuildChunkStateFromDisk(state, meetingDir, totalChunks); List<Path> orderedChunkPaths = rebuildChunkStateFromDisk(state, meetingDir, totalChunks);
saveState(meetingId, state); saveState(meetingId, state);
Path mergedFile = mergeChunks(state, orderedChunkPaths); Path mergedFile = mergeChunks(state, orderedChunkPaths);
if (mergedFile == null) {
meetingCommandService.failOfflineTranscription(meetingId, "安卓上传文件为空");
cleanup(meetingId);
return new LegacyUploadAudioResponse(meetingId, null, "无可合并音频");
}
MultipartFile mergedMultipart = new LocalMultipartFile( MultipartFile mergedMultipart = new LocalMultipartFile(
buildMergedOriginalFilename(state, mergedFile), resolveMergedOriginalFilename(state, orderedChunkPaths, mergedFile),
state.getContentType(), state.getContentType(),
Files.readAllBytes(mergedFile) Files.readAllBytes(mergedFile)
); );
LegacyUploadAudioResponse response; LegacyUploadAudioResponse response = legacyMeetingAdapterService.uploadAndTriggerOfflineProcessForPublicDevice(
if (authContext.isAnonymous()) { meetingId,
response = legacyMeetingAdapterService.uploadAndTriggerOfflineProcessForPublicDevice( null,
meetingId, null,
null, false,
null, mergedMultipart,
false, authContext
mergedMultipart, );
authContext
);
} else {
LoginUser loginUser = toLoginUser(authContext);
response = legacyMeetingAdapterService.uploadAndTriggerOfflineProcess(
meetingId,
null,
null,
false,
mergedMultipart,
authContext,
loginUser
);
}
if (response != null) { if (response != null) {
cleanup(meetingId); cleanup(meetingId);
} }
@ -148,155 +143,170 @@ public class AndroidChunkUploadServiceImpl implements AndroidChunkUploadService
AndroidChunkUploadSessionState state = new AndroidChunkUploadSessionState(); AndroidChunkUploadSessionState state = new AndroidChunkUploadSessionState();
state.setMeetingId(meetingId); state.setMeetingId(meetingId);
state.setDeviceId(authContext.getDeviceId()); state.setDeviceId(authContext.getDeviceId());
state.setFileName(normalizeChunkSourceFileName(chunkFile.getOriginalFilename())); state.setFileName(resolveOriginalFileName(chunkFile.getOriginalFilename()));
state.setContentType(chunkFile.getContentType()); state.setContentType(chunkFile.getContentType());
saveState(meetingId, state); saveState(meetingId, state);
return state; return state;
} }
private AndroidChunkUploadSessionState loadStateForCompletion(Long meetingId, AndroidAuthContext authContext) { private AndroidChunkUploadSessionState loadStateForCompletion(Long meetingId, AndroidAuthContext authContext) {
AndroidChunkUploadSessionState state = getState(meetingId); AndroidChunkUploadSessionState state = getState(meetingId);
if (state != null) { if (state != null) {
return state; return state;
} }
AndroidChunkUploadSessionState rebuiltState = new AndroidChunkUploadSessionState(); AndroidChunkUploadSessionState rebuiltState = new AndroidChunkUploadSessionState();
rebuiltState.setMeetingId(meetingId); rebuiltState.setMeetingId(meetingId);
rebuiltState.setDeviceId(authContext.getDeviceId()); rebuiltState.setDeviceId(authContext.getDeviceId());
return rebuiltState; return rebuiltState;
}
private List<Path> rebuildChunkStateFromDisk(AndroidChunkUploadSessionState state,
Path meetingDir,
int totalChunks) throws IOException {
Map<Integer, Path> validatedChunkFiles = scanChunkFiles(meetingDir, true);
Map<Integer, Path> mergeChunkFiles = scanChunkFiles(meetingDir, false);
state.getReceivedChunks().clear();
state.getUploadedChunkFileNames().clear();
state.getChunkFileNames().clear();
for (Map.Entry<Integer, Path> 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<Path> orderedChunkPaths = new ArrayList<>(totalChunks); private List<Path> rebuildChunkStateFromDisk(AndroidChunkUploadSessionState state,
for (int i = 0; i < totalChunks; i++) { Path meetingDir,
Path validatedChunkPath = validatedChunkFiles.get(i); int totalChunks) throws IOException {
if (validatedChunkPath == null) { Map<Integer, Path> chunkFiles = scanChunkFiles(meetingDir, true);
throw new RuntimeException("分片未上传完整"); state.getReceivedChunks().clear();
} state.getUploadedChunkFileNames().clear();
Path chunkPath = mergeChunkFiles.get(i); state.getChunkFileNames().clear();
if (chunkPath != null) {
orderedChunkPaths.add(chunkPath);
}
}
return orderedChunkPaths;
}
private Map<Integer, Path> scanChunkFiles(Path meetingDir, boolean includePending) throws IOException { for (Map.Entry<Integer, Path> entry : chunkFiles.entrySet()) {
Map<Integer, Path> chunkFiles = new TreeMap<>(); Integer chunkIndex = entry.getKey();
if (!Files.exists(meetingDir)) { Path chunkPath = entry.getValue();
return chunkFiles; String fileName = chunkPath.getFileName().toString();
} state.getReceivedChunks().add(chunkIndex);
try (var paths = Files.list(meetingDir)) { state.getUploadedChunkFileNames().add(fileName);
paths.filter(Files::isRegularFile) state.getChunkFileNames().put(chunkIndex, fileName);
.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) { List<Path> orderedChunkPaths = new ArrayList<>(totalChunks);
if (path == null || path.getFileName() == null) { for (int i = 0; i < totalChunks; i++) {
return false; Path chunkPath = chunkFiles.get(i);
if (chunkPath == null) {
throw new RuntimeException("分片未上传完整");
}
orderedChunkPaths.add(chunkPath);
}
return orderedChunkPaths;
} }
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) { private Map<Integer, Path> scanChunkFiles(Path meetingDir, boolean includePending) throws IOException {
String normalizedFileName = normalizeChunkStorageFileName(fileName, includePending); Map<Integer, Path> chunkFiles = new TreeMap<>();
if (normalizedFileName == null) { if (!Files.exists(meetingDir)) {
return null; return chunkFiles;
} }
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) { Path chunkRoot = meetingDir.resolve(CHUNK_ROOT_DIR);
boolean candidatePending = isPendingChunkFile(candidate); if (Files.exists(chunkRoot)) {
boolean existingPending = isPendingChunkFile(existing); try (var dirs = Files.list(chunkRoot)) {
if (candidatePending != existingPending) { dirs.filter(Files::isDirectory).forEach(dir -> {
return !candidatePending; Integer chunkIndex = parseChunkDirIndex(dir.getFileName().toString());
} if (chunkIndex == null || chunkFiles.containsKey(chunkIndex)) {
try { return;
return Files.getLastModifiedTime(candidate).compareTo(Files.getLastModifiedTime(existing)) >= 0; }
} catch (IOException ex) { Path chunkPath = pickChunkFile(dir, includePending);
return candidate.getFileName().toString().compareTo(existing.getFileName().toString()) >= 0; if (chunkPath != null) {
} chunkFiles.put(chunkIndex, chunkPath);
} }
});
}
if (!chunkFiles.isEmpty()) {
return chunkFiles;
}
}
private String normalizeChunkStorageFileName(String fileName, boolean includePending) { try (var paths = Files.list(meetingDir)) {
if (fileName == null) { paths.filter(Files::isRegularFile)
return null; .forEach(path -> {
Integer chunkIndex = parseLegacyChunkIndex(path.getFileName().toString());
if (chunkIndex != null) {
chunkFiles.put(chunkIndex, path);
}
});
}
return chunkFiles;
} }
if (fileName.endsWith(".pending")) {
return includePending ? fileName.substring(0, fileName.length() - ".pending".length()) : null; private Integer parseChunkDirIndex(String directoryName) {
if (directoryName == null) {
return null;
}
Matcher matcher = CHUNK_DIR_NAME_PATTERN.matcher(directoryName);
if (!matcher.matches()) {
return null;
}
return Integer.parseInt(matcher.group(1));
} }
return fileName;
}
private boolean isPendingChunkFile(Path path) { private Integer parseLegacyChunkIndex(String fileName) {
return path != null && path.getFileName() != null && path.getFileName().toString().endsWith(".pending"); if (fileName == null) {
} return null;
}
Matcher matcher = LEGACY_CHUNK_FILE_NAME_PATTERN.matcher(fileName);
if (!matcher.matches()) {
return null;
}
return Integer.parseInt(matcher.group(1));
}
private Path mergeChunks(AndroidChunkUploadSessionState state, List<Path> chunkPaths) throws IOException { private Path pickChunkFile(Path chunkDir, boolean includePending) {
if (chunkDir == null || !Files.isDirectory(chunkDir)) {
return null;
}
try (var files = Files.list(chunkDir)) {
Path preferred = files
.filter(Files::isRegularFile)
.filter(path -> includePending || !isPendingChunkFile(path))
.findFirst()
.orElse(null);
if (preferred != null) {
return preferred;
}
} catch (IOException ex) {
return null;
}
try (var files = Files.list(chunkDir)) {
return files.filter(Files::isRegularFile).findFirst().orElse(null);
} catch (IOException ex) {
return null;
}
}
private Path mergeChunks(AndroidChunkUploadSessionState state, List<Path> chunkPaths) throws IOException {
List<Path> mergeableChunkPaths = filterMergeableChunkPaths(chunkPaths);
Path meetingDir = sessionDir(state.getMeetingId()); Path meetingDir = sessionDir(state.getMeetingId());
String mergedExtension = resolveMergedExtension(state, chunkPaths); String mergedExtension = resolveMergedExtension(state, mergeableChunkPaths);
Path mergedOutput = meetingDir.resolve("merged" + mergedExtension); Path mergedOutput = meetingDir.resolve("merged" + mergedExtension);
Path concatList = meetingDir.resolve("concat-inputs.txt"); Path concatList = meetingDir.resolve("concat-inputs.txt");
Files.deleteIfExists(mergedOutput); Files.deleteIfExists(mergedOutput);
if (chunkPaths.isEmpty() || allChunkFilesEmpty(chunkPaths)) { if (mergeableChunkPaths.isEmpty() || allChunkFilesEmpty(mergeableChunkPaths)) {
Files.write(mergedOutput, new byte[0], StandardOpenOption.CREATE, StandardOpenOption.TRUNCATE_EXISTING); return null;
return mergedOutput; }
}
if (chunkPaths.size() == 1) { if (mergeableChunkPaths.size() == 1) {
return chunkPaths.get(0); return mergeableChunkPaths.get(0);
} }
writeConcatListFile(concatList, chunkPaths); writeConcatListFile(concatList, mergeableChunkPaths);
executeFfmpegConcat(concatList, mergedOutput); executeFfmpegConcat(concatList, mergedOutput);
return mergedOutput; return mergedOutput;
} }
private boolean allChunkFilesEmpty(List<Path> chunkPaths) throws IOException { private boolean allChunkFilesEmpty(List<Path> chunkPaths) throws IOException {
for (Path chunkPath : chunkPaths) { for (Path chunkPath : chunkPaths) {
if (Files.size(chunkPath) > 0) { if (Files.size(chunkPath) > 0) {
return false; return false;
} }
}
return true;
} }
return true;
private List<Path> filterMergeableChunkPaths(List<Path> chunkPaths) {
if (chunkPaths == null || chunkPaths.isEmpty()) {
return List.of();
}
return chunkPaths.stream()
.filter(path -> path != null && !isPendingChunkFile(path))
.toList();
} }
private AndroidChunkUploadSessionState getState(Long meetingId) { private AndroidChunkUploadSessionState getState(Long meetingId) {
@ -329,22 +339,19 @@ public class AndroidChunkUploadServiceImpl implements AndroidChunkUploadService
return Paths.get(normalizedBasePath, "android-chunks", String.valueOf(meetingId)); return Paths.get(normalizedBasePath, "android-chunks", String.valueOf(meetingId));
} }
private String buildStoredChunkFileName(Integer chunkIndex, String originalFileName) { private Path resolveChunkFilePath(Long meetingId, Integer chunkIndex, String originalFileName) {
String normalizedSourceName = normalizeChunkSourceFileName(originalFileName); return sessionDir(meetingId)
int extensionIndex = normalizedSourceName.lastIndexOf('.'); .resolve(CHUNK_ROOT_DIR)
String extension = extensionIndex >= 0 ? normalizedSourceName.substring(extensionIndex) : ""; .resolve("chunk-" + chunkIndex)
String safeExtension = extension.isBlank() ? ".bin" : extension; .resolve(originalFileName);
return "chunk-" + (chunkIndex == null ? "unknown" : chunkIndex) + safeExtension;
} }
private String buildMergedOriginalFilename(AndroidChunkUploadSessionState state, Path mergedFile) { private String resolveMergedOriginalFilename(AndroidChunkUploadSessionState state, List<Path> chunkPaths, Path mergedFile) {
if (state.getFileName() != null && !state.getFileName().isBlank()) { if (state != null && state.getFileName() != null && !state.getFileName().isBlank()) {
String normalizedSourceName = normalizeChunkSourceFileName(state.getFileName()); return state.getFileName();
int extensionIndex = normalizedSourceName.lastIndexOf('.'); }
if (extensionIndex >= 0) { if (chunkPaths != null && !chunkPaths.isEmpty() && chunkPaths.get(0).getFileName() != null) {
return "merged" + normalizedSourceName.substring(extensionIndex); return chunkPaths.get(0).getFileName().toString();
}
return normalizedSourceName;
} }
return mergedFile == null || mergedFile.getFileName() == null ? "meeting-audio.bin" : mergedFile.getFileName().toString(); return mergedFile == null || mergedFile.getFileName() == null ? "meeting-audio.bin" : mergedFile.getFileName().toString();
} }
@ -353,41 +360,56 @@ public class AndroidChunkUploadServiceImpl implements AndroidChunkUploadService
if (chunkPath == null || chunkPath.getFileName() == null) { if (chunkPath == null || chunkPath.getFileName() == null) {
return ".bin"; return ".bin";
} }
String fileName = normalizeChunkSourceFileName(chunkPath.getFileName().toString()); String fileName = chunkPath.getFileName().toString();
int extensionIndex = fileName.lastIndexOf('.'); int extensionIndex = fileName.lastIndexOf('.');
return extensionIndex >= 0 ? fileName.substring(extensionIndex) : ".bin"; return extensionIndex >= 0 ? fileName.substring(extensionIndex) : ".bin";
} }
private String resolveMergedExtension(AndroidChunkUploadSessionState state, List<Path> chunkPaths) { private String resolveMergedExtension(AndroidChunkUploadSessionState state, List<Path> chunkPaths) {
if (chunkPaths != null && !chunkPaths.isEmpty()) { if (chunkPaths != null && !chunkPaths.isEmpty()) {
return resolveMergedExtension(chunkPaths.get(0)); return resolveMergedExtension(chunkPaths.get(0));
} }
if (state != null && state.getFileName() != null && !state.getFileName().isBlank()) { if (state != null && state.getFileName() != null && !state.getFileName().isBlank()) {
int extensionIndex = state.getFileName().lastIndexOf('.'); int extensionIndex = state.getFileName().lastIndexOf('.');
if (extensionIndex >= 0) { if (extensionIndex >= 0) {
return state.getFileName().substring(extensionIndex); return state.getFileName().substring(extensionIndex);
} }
} }
return ".bin"; return ".bin";
} }
private String normalizeChunkSourceFileName(String fileName) { private boolean isPendingChunkFile(Path path) {
if (fileName == null) { return path != null && path.getFileName() != null && path.getFileName().toString().endsWith(".pending");
return ""; }
private String resolveOriginalFileName(String originalFileName) {
if (originalFileName == null || originalFileName.trim().isEmpty()) {
throw new RuntimeException("chunk_file原始文件名不能为空");
} }
String normalized = Paths.get(fileName.trim()).getFileName().toString(); if (originalFileName.contains("/") || originalFileName.contains("\\")) {
if (normalized.endsWith(".pending")) { throw new RuntimeException("chunk_file文件名不合法");
return normalized.substring(0, normalized.length() - ".pending".length()); }
return originalFileName;
}
private void clearChunkDirectory(Path chunkDir) throws IOException {
if (chunkDir == null || !Files.exists(chunkDir)) {
return;
}
try (var paths = Files.walk(chunkDir)) {
paths.sorted((left, right) -> right.compareTo(left)).forEach(path -> {
try {
Files.deleteIfExists(path);
} catch (IOException ex) {
throw new RuntimeException(ex);
}
});
} }
return normalized;
} }
private void writeConcatListFile(Path concatList, List<Path> chunkPaths) throws IOException { private void writeConcatListFile(Path concatList, List<Path> chunkPaths) throws IOException {
List<String> lines = new ArrayList<>(chunkPaths.size()); List<String> lines = new ArrayList<>(chunkPaths.size());
for (Path chunkPath : chunkPaths) { for (Path chunkPath : chunkPaths) {
if (chunkPath.getFileName() != null && chunkPath.getFileName().toString().endsWith(".pending")) {
continue;
}
String normalizedPath = chunkPath.toAbsolutePath().toString().replace("'", "'\\''"); String normalizedPath = chunkPath.toAbsolutePath().toString().replace("'", "'\\''");
lines.add("file '" + normalizedPath + "'"); lines.add("file '" + normalizedPath + "'");
} }
@ -424,37 +446,11 @@ public class AndroidChunkUploadServiceImpl implements AndroidChunkUploadService
if (process.exitValue() != 0) { if (process.exitValue() != 0) {
throw new IOException("音频分片合并失败: " + new String(output, StandardCharsets.UTF_8)); throw new IOException("音频分片合并失败: " + new String(output, StandardCharsets.UTF_8));
} }
if (!Files.exists(mergedOutput)) { if (!Files.exists(mergedOutput)) {
throw new IOException("音频分片合并结果为空"); throw new IOException("音频分片合并结果为空");
} }
} }
private void deleteQuietly(Path path) {
if (path == null) {
return;
}
try {
Files.deleteIfExists(path);
} catch (IOException ignored) {
}
}
private LoginUser toLoginUser(AndroidAuthContext authContext) {
if (authContext == null || authContext.isAnonymous() || authContext.getUserId() == null || authContext.getTenantId() == null) {
throw new RuntimeException("安卓用户未登录或认证无效");
}
LoginUser loginUser = new LoginUser(
authContext.getUserId(),
authContext.getTenantId(),
authContext.getUsername(),
authContext.getPlatformAdmin(),
authContext.getTenantAdmin(),
authContext.getPermissions()
);
loginUser.setDisplayName(authContext.getDisplayName());
return loginUser;
}
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;

View File

@ -36,6 +36,8 @@ public interface MeetingCommandService {
void finishOfflineMeeting(Long meetingId, String finishStage); void finishOfflineMeeting(Long meetingId, String finishStage);
void failOfflineTranscription(Long meetingId, String failureMessage);
void updateSpeakerInfo(Long meetingId, String speakerId, String newName, String label); void updateSpeakerInfo(Long meetingId, String speakerId, String newName, String label);
void updateMeetingTranscript(UpdateMeetingTranscriptCommand command); void updateMeetingTranscript(UpdateMeetingTranscriptCommand command);

View File

@ -278,6 +278,7 @@ public class AiTaskServiceImpl extends ServiceImpl<AiTaskMapper, AiTask> impleme
failPendingSummaryTask(sumTask, "没有可用于总结的转录内容"); failPendingSummaryTask(sumTask, "没有可用于总结的转录内容");
updateMeetingStatus(meetingId, 4); updateMeetingStatus(meetingId, 4);
updateProgress(meetingId, -1, "未识别到可用于总结的转录内容", 0); updateProgress(meetingId, -1, "未识别到可用于总结的转录内容", 0);
androidMeetingPushService.pushMeetingStatusChanged(meetingId, UnifiedMeetingStatusStage.FAILED_TRANSCRIBING.getCode());
return; return;
} }
if (!asrText.isBlank()) { if (!asrText.isBlank()) {
@ -1488,6 +1489,7 @@ public class AiTaskServiceImpl extends ServiceImpl<AiTaskMapper, AiTask> impleme
if ("SUMMARY".equals(task.getTaskType())) { if ("SUMMARY".equals(task.getTaskType())) {
meetingPointsService.markSummaryChargeFailed(task.getId(), error); meetingPointsService.markSummaryChargeFailed(task.getId(), error);
} }
androidMeetingPushService.pushMeetingStatusChanged(task.getMeetingId(), UnifiedMeetingStatusStage.FAILED_SUMMARIZING.getCode());
} }
private String buildAsrFailureMessage(Exception ex) { private String buildAsrFailureMessage(Exception ex) {

View File

@ -27,6 +27,7 @@ import com.imeeting.entity.biz.HotWord;
import com.imeeting.entity.biz.Meeting; import com.imeeting.entity.biz.Meeting;
import com.imeeting.entity.biz.MeetingTranscript; import com.imeeting.entity.biz.MeetingTranscript;
import com.imeeting.entity.biz.MeetingTranscriptChapterVersion; import com.imeeting.entity.biz.MeetingTranscriptChapterVersion;
import com.imeeting.enums.BusinessErrorCodeEnum;
import com.imeeting.enums.MeetingStatusEnum; import com.imeeting.enums.MeetingStatusEnum;
import com.imeeting.service.android.AndroidPendingMeetingDraftService; import com.imeeting.service.android.AndroidPendingMeetingDraftService;
import com.imeeting.service.android.AndroidPushMessageService; import com.imeeting.service.android.AndroidPushMessageService;
@ -45,6 +46,8 @@ import com.imeeting.service.biz.RealtimeMeetingSessionStateService;
import com.imeeting.service.realtime.RealtimeMeetingAudioStorageService; import com.imeeting.service.realtime.RealtimeMeetingAudioStorageService;
import com.imeeting.support.redis.MeetingAsrPermitCache; import com.imeeting.support.redis.MeetingAsrPermitCache;
import com.imeeting.support.redis.MeetingLockCache; import com.imeeting.support.redis.MeetingLockCache;
import com.unisbase.common.exception.BusinessException;
import com.unisbase.common.exception.ErrorCodeEnum;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value; import org.springframework.beans.factory.annotation.Value;
@ -522,6 +525,22 @@ public class MeetingCommandServiceImpl implements MeetingCommandService {
meetingService.updateById(meeting); meetingService.updateById(meeting);
} }
@Override
@Transactional(rollbackFor = Exception.class)
public void failOfflineTranscription(Long meetingId, String failureMessage) {
Meeting meeting = meetingService.getById(meetingId);
if (meeting == null) {
throw new BusinessException(BusinessErrorCodeEnum.MEETING_NOT_FOUND.getCode(),"会议不存在");
}
if (!MeetingConstants.TYPE_OFFLINE.equals(meeting.getMeetingType())) {
throw new RuntimeException("会议不是离线会议");
}
meeting.setStatus(MeetingStatusEnum.FAILED.getCode());
meeting.setOfflineRecordingStatus(MeetingConstants.OFFLINE_RECORDING_UPLOAD_FINISHED);
markAudioSaveFailure(meeting, failureMessage);
meetingService.updateById(meeting);
}
private void applyRealtimeAudioFinalizeResult(Meeting meeting, RealtimeMeetingAudioStorageService.FinalizeResult result) { private void applyRealtimeAudioFinalizeResult(Meeting meeting, RealtimeMeetingAudioStorageService.FinalizeResult result) {
if (result == null) { if (result == null) {
markAudioSaveFailure(meeting, RealtimeMeetingAudioStorageService.DEFAULT_FAILURE_MESSAGE); markAudioSaveFailure(meeting, RealtimeMeetingAudioStorageService.DEFAULT_FAILURE_MESSAGE);

View File

@ -121,6 +121,9 @@ public class MeetingUnifiedStatusServiceImpl implements MeetingUnifiedStatusServ
if (meeting == null || !MeetingStatusEnum.isCode(meeting.getStatus(), MeetingStatusEnum.FAILED)) { if (meeting == null || !MeetingStatusEnum.isCode(meeting.getStatus(), MeetingStatusEnum.FAILED)) {
return null; return null;
} }
if (isAndroidOfflineEmptyUploadFailure(meeting)) {
return UnifiedMeetingStatusStage.FAILED_TRANSCRIBING;
}
AiTask asrTask = findLatestTask(meeting.getId(), "ASR"); AiTask asrTask = findLatestTask(meeting.getId(), "ASR");
if (isTaskFailed(asrTask)) { if (isTaskFailed(asrTask)) {
return UnifiedMeetingStatusStage.FAILED_TRANSCRIBING; return UnifiedMeetingStatusStage.FAILED_TRANSCRIBING;
@ -140,6 +143,13 @@ public class MeetingUnifiedStatusServiceImpl implements MeetingUnifiedStatusServ
return UnifiedMeetingStatusStage.FAILED_INITIALIZING; return UnifiedMeetingStatusStage.FAILED_INITIALIZING;
} }
private boolean isAndroidOfflineEmptyUploadFailure(MeetingVO meeting) {
return meeting != null
&& MeetingConstants.TYPE_OFFLINE.equalsIgnoreCase(meeting.getMeetingType())
&& MeetingConstants.SOURCE_ANDROID.equalsIgnoreCase(meeting.getMeetingSource())
&& "FAILED".equalsIgnoreCase(meeting.getAudioSaveStatus());
}
private boolean isAndroidOfflineMeetingWaitingUpload(MeetingVO meeting) { private boolean isAndroidOfflineMeetingWaitingUpload(MeetingVO meeting) {
return meeting != null return meeting != null
&& MeetingConstants.TYPE_OFFLINE.equalsIgnoreCase(meeting.getMeetingType()) && MeetingConstants.TYPE_OFFLINE.equalsIgnoreCase(meeting.getMeetingType())
@ -162,6 +172,9 @@ public class MeetingUnifiedStatusServiceImpl implements MeetingUnifiedStatusServ
} }
private Integer resolvePercent(MeetingProgressSnapshot snapshot, UnifiedMeetingStatusStage stage) { private Integer resolvePercent(MeetingProgressSnapshot snapshot, UnifiedMeetingStatusStage stage) {
if (stage != null && stage.getCode().startsWith("FAILED_")) {
return -1;
}
if (snapshot != null && snapshot.getPercent() != null) { if (snapshot != null && snapshot.getPercent() != null) {
return snapshot.getPercent(); return snapshot.getPercent();
} }
@ -203,6 +216,9 @@ public class MeetingUnifiedStatusServiceImpl implements MeetingUnifiedStatusServ
if (meeting == null) { if (meeting == null) {
return "处理失败"; return "处理失败";
} }
if (meeting.getAudioSaveMessage() != null && !meeting.getAudioSaveMessage().isBlank()) {
return meeting.getAudioSaveMessage();
}
if (meeting.getLatestSummaryAttemptErrorMsg() != null && !meeting.getLatestSummaryAttemptErrorMsg().isBlank()) { if (meeting.getLatestSummaryAttemptErrorMsg() != null && !meeting.getLatestSummaryAttemptErrorMsg().isBlank()) {
return meeting.getLatestSummaryAttemptErrorMsg(); return meeting.getLatestSummaryAttemptErrorMsg();
} }