refactor: 更新 ASR 本地模型连接测试和分片上传逻辑
- 修改 `AiModelServiceImpl` 中的 `fetchLocalProfile` 方法,更新目标 URL 和解析逻辑 - 添加新的 `extractStringArray` 方法以提取字符串数组 - 更新 `AndroidChunkUploadService` 接口,移除 `totalChunks` 参数 - 修改 `LegacyMeetingAdapterServiceImpl` 和 `AndroidChunkUploadServiceImpl` 中的分片上传逻辑,支持合并音频文件 - 更新 `AndroidMeetingController` 中的 `finishOfflineMeeting` 方法,处理分片上传完成后的逻辑 - 优化 `MeetingServiceImpl` 中的 `getById` 方法,忽略租户信息 - 更新前端 `AiModels.tsx` 中的表单验证规则和隐藏部分字段dev_na
parent
2e20799b4b
commit
1b41693597
|
|
@ -44,15 +44,13 @@ public class AndroidMeetingChunkUploadController {
|
||||||
public ApiResponse<Boolean> uploadChunk(HttpServletRequest request,
|
public ApiResponse<Boolean> uploadChunk(HttpServletRequest request,
|
||||||
@RequestParam("meeting_id") Long meetingId,
|
@RequestParam("meeting_id") Long meetingId,
|
||||||
@RequestParam("chunk_index") Integer chunkIndex,
|
@RequestParam("chunk_index") Integer chunkIndex,
|
||||||
@RequestParam("total_chunks") Integer totalChunks,
|
|
||||||
@RequestParam("chunk_file") MultipartFile chunkFile) throws IOException {
|
@RequestParam("chunk_file") MultipartFile chunkFile) throws IOException {
|
||||||
AndroidRequestLogHelper.logRequest(log, "Android会议", "上传会议音频分片",
|
AndroidRequestLogHelper.logRequest(log, "Android会议", "上传会议音频分片",
|
||||||
"meetingId", meetingId,
|
"meetingId", meetingId,
|
||||||
"chunkIndex", chunkIndex,
|
"chunkIndex", chunkIndex,
|
||||||
"totalChunks", totalChunks,
|
|
||||||
"chunkFile", chunkFile);
|
"chunkFile", chunkFile);
|
||||||
AndroidAuthContext authContext = androidAuthService.authenticateHttp(request);
|
AndroidAuthContext authContext = androidAuthService.authenticateHttp(request);
|
||||||
androidChunkUploadService.saveChunk(meetingId, chunkIndex, totalChunks, chunkFile, authContext);
|
androidChunkUploadService.saveChunk(meetingId, chunkIndex, chunkFile, authContext);
|
||||||
return ApiResponse.ok(true);
|
return ApiResponse.ok(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -67,10 +65,12 @@ public class AndroidMeetingChunkUploadController {
|
||||||
@PostMapping("/complete")
|
@PostMapping("/complete")
|
||||||
@Anonymous
|
@Anonymous
|
||||||
public ApiResponse<LegacyUploadAudioResponse> completeUpload(HttpServletRequest request,
|
public ApiResponse<LegacyUploadAudioResponse> completeUpload(HttpServletRequest request,
|
||||||
@RequestParam("meeting_id") Long meetingId) throws IOException {
|
@RequestParam("meeting_id") Long meetingId,
|
||||||
|
@RequestParam("total_chunks") Integer totalChunks) throws IOException {
|
||||||
AndroidRequestLogHelper.logRequest(log, "Android会议", "完成分片上传",
|
AndroidRequestLogHelper.logRequest(log, "Android会议", "完成分片上传",
|
||||||
"meetingId", meetingId);
|
"meetingId", meetingId,
|
||||||
|
"totalChunks", totalChunks);
|
||||||
AndroidAuthContext authContext = androidAuthService.authenticateHttp(request);
|
AndroidAuthContext authContext = androidAuthService.authenticateHttp(request);
|
||||||
return ApiResponse.ok(androidChunkUploadService.completeUpload(meetingId, authContext));
|
return ApiResponse.ok(androidChunkUploadService.completeUpload(meetingId, totalChunks, authContext));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -206,20 +206,28 @@ public class AndroidMeetingController {
|
||||||
})
|
})
|
||||||
@PostMapping("/{meetingId}/finish")
|
@PostMapping("/{meetingId}/finish")
|
||||||
@Anonymous
|
@Anonymous
|
||||||
public ApiResponse<Boolean> finishOfflineMeeting(HttpServletRequest request,
|
public ApiResponse<Object> finishOfflineMeeting(HttpServletRequest request,
|
||||||
@PathVariable Long meetingId,
|
@PathVariable Long meetingId,
|
||||||
@RequestBody(required = false) AndroidOfflineMeetingFinishRequest command) throws IOException {
|
@RequestBody(required = false) AndroidOfflineMeetingFinishRequest command) throws IOException {
|
||||||
AndroidRequestLogHelper.logRequest(log, "Android会议", "结束离线会议录音阶段",
|
AndroidRequestLogHelper.logRequest(log, "Android会议", "结束离线会议录音阶段",
|
||||||
"meetingId", meetingId,
|
"meetingId", meetingId,
|
||||||
"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);
|
||||||
Meeting meeting = requireOperableOfflineMeeting(meetingId, authContext, loginUser);
|
MeetingVO meeting = requireOperableOfflineMeeting(meetingId, authContext, loginUser);
|
||||||
|
LegacyUploadAudioResponse uploadResult = null;
|
||||||
if (isUploadFinishedStage(command)) {
|
if (isUploadFinishedStage(command)) {
|
||||||
androidChunkUploadService.completeUpload(meeting.getId(), authContext);
|
uploadResult = androidChunkUploadService.completeUpload(
|
||||||
|
meeting.getId(),
|
||||||
|
command == null ? null : command.getTotalChunks(),
|
||||||
|
authContext
|
||||||
|
);
|
||||||
|
if (uploadResult == null || uploadResult.getAudioUrl() == null || uploadResult.getAudioUrl().isBlank()) {
|
||||||
|
throw new RuntimeException("分片上传完成后未生成 audio_url");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
meetingCommandService.finishOfflineMeeting(meeting.getId(), command == null ? null : command.getFinishStage());
|
meetingCommandService.finishOfflineMeeting(meeting.getId(), command == null ? null : command.getFinishStage());
|
||||||
return ApiResponse.ok(true);
|
return ApiResponse.ok(uploadResult != null ? uploadResult : true);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Operation(summary = "分页查询Android会议")
|
@Operation(summary = "分页查询Android会议")
|
||||||
|
|
@ -363,8 +371,8 @@ public class AndroidMeetingController {
|
||||||
return ApiResponse.ok(resultVo);
|
return ApiResponse.ok(resultVo);
|
||||||
}
|
}
|
||||||
|
|
||||||
private Meeting requireOperableOfflineMeeting(Long meetingId, AndroidAuthContext authContext, LoginUser loginUser) {
|
private MeetingVO requireOperableOfflineMeeting(Long meetingId, AndroidAuthContext authContext, LoginUser loginUser) {
|
||||||
Meeting meeting = meetingService.getById(meetingId);
|
MeetingVO meeting = meetingQueryService.getDetailIgnoreTenant(meetingId);
|
||||||
if (meeting == null) {
|
if (meeting == null) {
|
||||||
throw new RuntimeException("会议不存在");
|
throw new RuntimeException("会议不存在");
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -9,10 +9,10 @@ import java.io.IOException;
|
||||||
public interface AndroidChunkUploadService {
|
public interface AndroidChunkUploadService {
|
||||||
void saveChunk(Long meetingId,
|
void saveChunk(Long meetingId,
|
||||||
Integer chunkIndex,
|
Integer chunkIndex,
|
||||||
Integer totalChunks,
|
|
||||||
MultipartFile chunkFile,
|
MultipartFile chunkFile,
|
||||||
AndroidAuthContext authContext) throws IOException;
|
AndroidAuthContext authContext) throws IOException;
|
||||||
|
|
||||||
LegacyUploadAudioResponse completeUpload(Long meetingId,
|
LegacyUploadAudioResponse completeUpload(Long meetingId,
|
||||||
|
Integer totalChunks,
|
||||||
AndroidAuthContext authContext) throws IOException;
|
AndroidAuthContext authContext) throws IOException;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -15,12 +15,16 @@ import org.springframework.web.multipart.MultipartFile;
|
||||||
import java.io.ByteArrayInputStream;
|
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.file.Files;
|
import java.nio.file.Files;
|
||||||
import java.nio.file.Path;
|
import java.nio.file.Path;
|
||||||
import java.nio.file.Paths;
|
import java.nio.file.Paths;
|
||||||
import java.nio.file.StandardOpenOption;
|
import java.nio.file.StandardOpenOption;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
import java.util.Objects;
|
import java.util.Objects;
|
||||||
|
import java.util.concurrent.TimeUnit;
|
||||||
|
|
||||||
@Service
|
@Service
|
||||||
@RequiredArgsConstructor
|
@RequiredArgsConstructor
|
||||||
|
|
@ -31,27 +35,29 @@ public class AndroidChunkUploadServiceImpl implements AndroidChunkUploadService
|
||||||
@Value("${unisbase.app.upload-path}")
|
@Value("${unisbase.app.upload-path}")
|
||||||
private String uploadPath;
|
private String uploadPath;
|
||||||
|
|
||||||
|
@Value("${imeeting.audio.ffmpeg-path:ffmpeg}")
|
||||||
|
private String ffmpegPath;
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void saveChunk(Long meetingId,
|
public void saveChunk(Long meetingId,
|
||||||
Integer chunkIndex,
|
Integer chunkIndex,
|
||||||
Integer totalChunks,
|
|
||||||
MultipartFile chunkFile,
|
MultipartFile chunkFile,
|
||||||
AndroidAuthContext authContext) throws IOException {
|
AndroidAuthContext authContext) throws IOException {
|
||||||
if (meetingId == null) {
|
if (meetingId == null) {
|
||||||
throw new RuntimeException("meeting_id不能为空");
|
throw new RuntimeException("meeting_id不能为空");
|
||||||
}
|
}
|
||||||
if (chunkIndex == null || totalChunks == null || chunkIndex < 0 || totalChunks <= 0) {
|
if (chunkIndex == null || chunkIndex < 0) {
|
||||||
throw new RuntimeException("分片参数无效");
|
throw new RuntimeException("分片参数无效");
|
||||||
}
|
}
|
||||||
if (chunkFile == null || chunkFile.isEmpty()) {
|
if (chunkFile == null || chunkFile.isEmpty()) {
|
||||||
throw new RuntimeException("chunk_file不能为空");
|
throw new RuntimeException("chunk_file不能为空");
|
||||||
}
|
}
|
||||||
AndroidChunkUploadSessionState state = getOrCreateState(meetingId, totalChunks, chunkFile, authContext);
|
AndroidChunkUploadSessionState state = getOrCreateState(meetingId, chunkFile, 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("分片上传会话与当前设备或会议不匹配");
|
||||||
}
|
}
|
||||||
|
|
||||||
String chunkFileName = normalizeChunkFileName(chunkFile.getOriginalFilename(), chunkIndex);
|
String chunkFileName = buildStoredChunkFileName(chunkIndex, chunkFile.getOriginalFilename());
|
||||||
String previousFileName = state.getChunkFileNames().get(chunkIndex);
|
String previousFileName = state.getChunkFileNames().get(chunkIndex);
|
||||||
Path meetingDir = sessionDir(meetingId);
|
Path meetingDir = sessionDir(meetingId);
|
||||||
Files.createDirectories(meetingDir);
|
Files.createDirectories(meetingDir);
|
||||||
|
|
@ -60,12 +66,6 @@ public class AndroidChunkUploadServiceImpl implements AndroidChunkUploadService
|
||||||
deleteQuietly(meetingDir.resolve(previousFileName));
|
deleteQuietly(meetingDir.resolve(previousFileName));
|
||||||
state.getUploadedChunkFileNames().remove(previousFileName);
|
state.getUploadedChunkFileNames().remove(previousFileName);
|
||||||
}
|
}
|
||||||
if (state.getUploadedChunkFileNames().contains(chunkFileName)) {
|
|
||||||
state.getChunkFileNames().put(chunkIndex, chunkFileName);
|
|
||||||
state.getReceivedChunks().add(chunkIndex);
|
|
||||||
saveState(meetingId, state);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
Files.write(meetingDir.resolve(chunkFileName), chunkFile.getBytes(), StandardOpenOption.CREATE, StandardOpenOption.TRUNCATE_EXISTING);
|
Files.write(meetingDir.resolve(chunkFileName), chunkFile.getBytes(), StandardOpenOption.CREATE, StandardOpenOption.TRUNCATE_EXISTING);
|
||||||
state.getUploadedChunkFileNames().add(chunkFileName);
|
state.getUploadedChunkFileNames().add(chunkFileName);
|
||||||
|
|
@ -76,12 +76,23 @@ public class AndroidChunkUploadServiceImpl implements AndroidChunkUploadService
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public LegacyUploadAudioResponse completeUpload(Long meetingId,
|
public LegacyUploadAudioResponse completeUpload(Long meetingId,
|
||||||
|
Integer totalChunks,
|
||||||
AndroidAuthContext authContext) throws IOException {
|
AndroidAuthContext authContext) throws IOException {
|
||||||
|
if (meetingId == null) {
|
||||||
|
throw new RuntimeException("meeting_id不能为空");
|
||||||
|
}
|
||||||
|
if (totalChunks == null || totalChunks <= 0) {
|
||||||
|
throw new RuntimeException("total_chunks不能为空且必须大于0");
|
||||||
|
}
|
||||||
|
|
||||||
AndroidChunkUploadSessionState state = requireState(meetingId);
|
AndroidChunkUploadSessionState state = requireState(meetingId);
|
||||||
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("分片上传会话与当前设备或会议不匹配");
|
||||||
}
|
}
|
||||||
for (int i = 0; i < state.getTotalChunks(); i++) {
|
|
||||||
|
state.setTotalChunks(totalChunks);
|
||||||
|
saveState(meetingId, state);
|
||||||
|
for (int i = 0; i < totalChunks; i++) {
|
||||||
if (!state.getReceivedChunks().contains(i) || !state.getChunkFileNames().containsKey(i)) {
|
if (!state.getReceivedChunks().contains(i) || !state.getChunkFileNames().containsKey(i)) {
|
||||||
throw new RuntimeException("分片未上传完整");
|
throw new RuntimeException("分片未上传完整");
|
||||||
}
|
}
|
||||||
|
|
@ -90,7 +101,7 @@ public class AndroidChunkUploadServiceImpl implements AndroidChunkUploadService
|
||||||
Path mergedFile = mergeChunks(state);
|
Path mergedFile = mergeChunks(state);
|
||||||
try {
|
try {
|
||||||
MultipartFile mergedMultipart = new LocalMultipartFile(
|
MultipartFile mergedMultipart = new LocalMultipartFile(
|
||||||
state.getFileName() == null ? "meeting-audio.bin" : state.getFileName(),
|
buildMergedOriginalFilename(state, mergedFile),
|
||||||
state.getContentType(),
|
state.getContentType(),
|
||||||
Files.readAllBytes(mergedFile)
|
Files.readAllBytes(mergedFile)
|
||||||
);
|
);
|
||||||
|
|
@ -120,23 +131,15 @@ public class AndroidChunkUploadServiceImpl implements AndroidChunkUploadService
|
||||||
}
|
}
|
||||||
|
|
||||||
private AndroidChunkUploadSessionState getOrCreateState(Long meetingId,
|
private AndroidChunkUploadSessionState getOrCreateState(Long meetingId,
|
||||||
Integer totalChunks,
|
|
||||||
MultipartFile chunkFile,
|
MultipartFile chunkFile,
|
||||||
AndroidAuthContext authContext) throws IOException {
|
AndroidAuthContext authContext) {
|
||||||
AndroidChunkUploadSessionState existing = getState(meetingId);
|
AndroidChunkUploadSessionState existing = getState(meetingId);
|
||||||
if (existing != null) {
|
if (existing != null) {
|
||||||
String currentFileName = chunkFile == null ? null : chunkFile.getOriginalFilename();
|
return existing;
|
||||||
boolean sameTotalChunks = Objects.equals(existing.getTotalChunks(), totalChunks);
|
|
||||||
boolean sameFileName = Objects.equals(existing.getFileName(), currentFileName);
|
|
||||||
if (sameTotalChunks && sameFileName) {
|
|
||||||
return existing;
|
|
||||||
}
|
|
||||||
cleanup(meetingId);
|
|
||||||
}
|
}
|
||||||
AndroidChunkUploadSessionState state = new AndroidChunkUploadSessionState();
|
AndroidChunkUploadSessionState state = new AndroidChunkUploadSessionState();
|
||||||
state.setMeetingId(meetingId);
|
state.setMeetingId(meetingId);
|
||||||
state.setDeviceId(authContext.getDeviceId());
|
state.setDeviceId(authContext.getDeviceId());
|
||||||
state.setTotalChunks(totalChunks);
|
|
||||||
state.setFileName(chunkFile.getOriginalFilename());
|
state.setFileName(chunkFile.getOriginalFilename());
|
||||||
state.setContentType(chunkFile.getContentType());
|
state.setContentType(chunkFile.getContentType());
|
||||||
saveState(meetingId, state);
|
saveState(meetingId, state);
|
||||||
|
|
@ -145,13 +148,30 @@ public class AndroidChunkUploadServiceImpl implements AndroidChunkUploadService
|
||||||
|
|
||||||
private Path mergeChunks(AndroidChunkUploadSessionState state) throws IOException {
|
private Path mergeChunks(AndroidChunkUploadSessionState state) throws IOException {
|
||||||
Path meetingDir = sessionDir(state.getMeetingId());
|
Path meetingDir = sessionDir(state.getMeetingId());
|
||||||
Path merged = meetingDir.resolve("merged.bin");
|
Files.createDirectories(meetingDir);
|
||||||
Files.deleteIfExists(merged);
|
|
||||||
Files.createFile(merged);
|
List<Path> chunkPaths = new ArrayList<>();
|
||||||
for (Map.Entry<Integer, String> entry : state.getChunkFileNames().entrySet()) {
|
for (Map.Entry<Integer, String> entry : state.getChunkFileNames().entrySet()) {
|
||||||
Files.write(merged, Files.readAllBytes(meetingDir.resolve(entry.getValue())), StandardOpenOption.APPEND);
|
Path chunkPath = meetingDir.resolve(entry.getValue());
|
||||||
|
if (!Files.exists(chunkPath)) {
|
||||||
|
throw new RuntimeException("分片文件不存在: " + entry.getValue());
|
||||||
|
}
|
||||||
|
chunkPaths.add(chunkPath);
|
||||||
}
|
}
|
||||||
return merged;
|
if (chunkPaths.isEmpty()) {
|
||||||
|
throw new RuntimeException("没有可合并的分片文件");
|
||||||
|
}
|
||||||
|
if (chunkPaths.size() == 1) {
|
||||||
|
return chunkPaths.get(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
String mergedExtension = resolveMergedExtension(chunkPaths.get(0));
|
||||||
|
Path mergedOutput = meetingDir.resolve("merged" + mergedExtension);
|
||||||
|
Path concatList = meetingDir.resolve("concat-inputs.txt");
|
||||||
|
Files.deleteIfExists(mergedOutput);
|
||||||
|
writeConcatListFile(concatList, chunkPaths);
|
||||||
|
executeFfmpegConcat(concatList, mergedOutput);
|
||||||
|
return mergedOutput;
|
||||||
}
|
}
|
||||||
|
|
||||||
private AndroidChunkUploadSessionState requireState(Long meetingId) {
|
private AndroidChunkUploadSessionState requireState(Long meetingId) {
|
||||||
|
|
@ -192,11 +212,77 @@ 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 normalizeChunkFileName(String originalFileName, Integer chunkIndex) {
|
private String buildStoredChunkFileName(Integer chunkIndex, String originalFileName) {
|
||||||
String fallback = "chunk-" + (chunkIndex == null ? "unknown" : chunkIndex);
|
String normalizedSourceName = originalFileName == null ? "" : Paths.get(originalFileName.trim()).getFileName().toString();
|
||||||
String fileName = originalFileName == null || originalFileName.isBlank() ? fallback : originalFileName.trim();
|
int extensionIndex = normalizedSourceName.lastIndexOf('.');
|
||||||
fileName = Paths.get(fileName).getFileName().toString();
|
String extension = extensionIndex >= 0 ? normalizedSourceName.substring(extensionIndex) : "";
|
||||||
return fileName.isBlank() ? fallback : fileName;
|
String safeExtension = extension.isBlank() ? ".bin" : extension;
|
||||||
|
return "chunk-" + (chunkIndex == null ? "unknown" : chunkIndex) + safeExtension;
|
||||||
|
}
|
||||||
|
|
||||||
|
private String buildMergedOriginalFilename(AndroidChunkUploadSessionState state, Path mergedFile) {
|
||||||
|
if (state.getFileName() != null && !state.getFileName().isBlank()) {
|
||||||
|
String normalizedSourceName = Paths.get(state.getFileName().trim()).getFileName().toString();
|
||||||
|
int extensionIndex = normalizedSourceName.lastIndexOf('.');
|
||||||
|
if (extensionIndex >= 0) {
|
||||||
|
return "merged" + normalizedSourceName.substring(extensionIndex);
|
||||||
|
}
|
||||||
|
return normalizedSourceName;
|
||||||
|
}
|
||||||
|
return mergedFile == null || mergedFile.getFileName() == null ? "meeting-audio.bin" : mergedFile.getFileName().toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
private String resolveMergedExtension(Path chunkPath) {
|
||||||
|
if (chunkPath == null || chunkPath.getFileName() == null) {
|
||||||
|
return ".bin";
|
||||||
|
}
|
||||||
|
String fileName = chunkPath.getFileName().toString();
|
||||||
|
int extensionIndex = fileName.lastIndexOf('.');
|
||||||
|
return extensionIndex >= 0 ? fileName.substring(extensionIndex) : ".bin";
|
||||||
|
}
|
||||||
|
|
||||||
|
private void writeConcatListFile(Path concatList, List<Path> chunkPaths) throws IOException {
|
||||||
|
List<String> lines = new ArrayList<>(chunkPaths.size());
|
||||||
|
for (Path chunkPath : chunkPaths) {
|
||||||
|
String normalizedPath = chunkPath.toAbsolutePath().toString().replace("'", "'\\''");
|
||||||
|
lines.add("file '" + normalizedPath + "'");
|
||||||
|
}
|
||||||
|
Files.write(concatList, lines, StandardCharsets.UTF_8, StandardOpenOption.CREATE, StandardOpenOption.TRUNCATE_EXISTING);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void executeFfmpegConcat(Path concatList, Path mergedOutput) throws IOException {
|
||||||
|
List<String> command = List.of(
|
||||||
|
ffmpegPath,
|
||||||
|
"-v", "error",
|
||||||
|
"-y",
|
||||||
|
"-f", "concat",
|
||||||
|
"-safe", "0",
|
||||||
|
"-i", concatList.toString(),
|
||||||
|
"-c", "copy",
|
||||||
|
mergedOutput.toString()
|
||||||
|
);
|
||||||
|
ProcessBuilder processBuilder = new ProcessBuilder(command);
|
||||||
|
processBuilder.redirectErrorStream(true);
|
||||||
|
Process process = processBuilder.start();
|
||||||
|
byte[] output;
|
||||||
|
try (InputStream processStream = process.getInputStream()) {
|
||||||
|
output = processStream.readAllBytes();
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
if (!process.waitFor(120, TimeUnit.SECONDS)) {
|
||||||
|
process.destroyForcibly();
|
||||||
|
throw new IOException("音频分片合并超时");
|
||||||
|
}
|
||||||
|
} catch (InterruptedException ex) {
|
||||||
|
Thread.currentThread().interrupt();
|
||||||
|
throw new IOException("音频分片合并被中断", ex);
|
||||||
|
}
|
||||||
|
if (process.exitValue() != 0) {
|
||||||
|
throw new IOException("音频分片合并失败: " + new String(output, StandardCharsets.UTF_8));
|
||||||
|
}
|
||||||
|
if (!Files.exists(mergedOutput) || Files.size(mergedOutput) <= 0) {
|
||||||
|
throw new IOException("音频分片合并结果为空");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private void deleteQuietly(Path path) {
|
private void deleteQuietly(Path path) {
|
||||||
|
|
|
||||||
|
|
@ -24,6 +24,7 @@ import com.imeeting.service.biz.impl.MeetingAudioUploadSupport;
|
||||||
import com.imeeting.service.biz.impl.MeetingDomainSupport;
|
import com.imeeting.service.biz.impl.MeetingDomainSupport;
|
||||||
import com.imeeting.service.biz.impl.MeetingSummaryPromptAssembler;
|
import com.imeeting.service.biz.impl.MeetingSummaryPromptAssembler;
|
||||||
import com.imeeting.service.realtime.RealtimeMeetingAudioStorageService;
|
import com.imeeting.service.realtime.RealtimeMeetingAudioStorageService;
|
||||||
|
import com.imeeting.support.TaskSecurityContextRunner;
|
||||||
import com.unisbase.security.LoginUser;
|
import com.unisbase.security.LoginUser;
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
|
|
@ -57,6 +58,7 @@ public class LegacyMeetingAdapterServiceImpl implements LegacyMeetingAdapterServ
|
||||||
private final MeetingTranscriptMapper transcriptMapper;
|
private final MeetingTranscriptMapper transcriptMapper;
|
||||||
private final LlmModelMapper llmModelMapper;
|
private final LlmModelMapper llmModelMapper;
|
||||||
private final MeetingAudioUploadSupport meetingAudioUploadSupport;
|
private final MeetingAudioUploadSupport meetingAudioUploadSupport;
|
||||||
|
private final TaskSecurityContextRunner taskSecurityContextRunner;
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
@Transactional(rollbackFor = Exception.class)
|
@Transactional(rollbackFor = Exception.class)
|
||||||
|
|
@ -137,7 +139,7 @@ public class LegacyMeetingAdapterServiceImpl implements LegacyMeetingAdapterServ
|
||||||
throw new RuntimeException("audio_file 不能为空");
|
throw new RuntimeException("audio_file 不能为空");
|
||||||
}
|
}
|
||||||
|
|
||||||
Meeting meeting = meetingAccessService.requireMeeting(meetingId);
|
Meeting meeting = meetingAccessService.requireMeetingIgnoreTenant(meetingId);
|
||||||
meetingAccessService.assertCanEditMeeting(meeting, loginUser);
|
meetingAccessService.assertCanEditMeeting(meeting, loginUser);
|
||||||
assertDeviceOwnsMeeting(meeting, authContext);
|
assertDeviceOwnsMeeting(meeting, authContext);
|
||||||
|
|
||||||
|
|
@ -176,6 +178,7 @@ public class LegacyMeetingAdapterServiceImpl implements LegacyMeetingAdapterServ
|
||||||
|
|
||||||
String stagingUrl = storeStagingAudio(audioFile);
|
String stagingUrl = storeStagingAudio(audioFile);
|
||||||
String relocatedUrl = meetingDomainSupport.relocateAudioUrl(meetingId, stagingUrl);
|
String relocatedUrl = meetingDomainSupport.relocateAudioUrl(meetingId, stagingUrl);
|
||||||
|
taskSecurityContextRunner.runAsTenantUser(meeting.getTenantId(), meeting.getCreatorId(), () -> {
|
||||||
meeting.setAudioUrl(relocatedUrl);
|
meeting.setAudioUrl(relocatedUrl);
|
||||||
meeting.setAudioSaveStatus(RealtimeMeetingAudioStorageService.STATUS_SUCCESS);
|
meeting.setAudioSaveStatus(RealtimeMeetingAudioStorageService.STATUS_SUCCESS);
|
||||||
meeting.setAudioSaveMessage(null);
|
meeting.setAudioSaveMessage(null);
|
||||||
|
|
@ -187,7 +190,7 @@ public class LegacyMeetingAdapterServiceImpl implements LegacyMeetingAdapterServ
|
||||||
resetOrCreateChapterTask(meetingId, profile);
|
resetOrCreateChapterTask(meetingId, profile);
|
||||||
resetOrCreateSummaryTask(meetingId, profile);
|
resetOrCreateSummaryTask(meetingId, profile);
|
||||||
dispatchTasksAfterCommit(meetingId, meeting.getTenantId(), meeting.getCreatorId());
|
dispatchTasksAfterCommit(meetingId, meeting.getTenantId(), meeting.getCreatorId());
|
||||||
|
});
|
||||||
return new LegacyUploadAudioResponse(meetingId, relocatedUrl);
|
return new LegacyUploadAudioResponse(meetingId, relocatedUrl);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -244,17 +247,19 @@ public class LegacyMeetingAdapterServiceImpl implements LegacyMeetingAdapterServ
|
||||||
);
|
);
|
||||||
String stagingUrl = storeStagingAudio(audioFile);
|
String stagingUrl = storeStagingAudio(audioFile);
|
||||||
String relocatedUrl = meetingDomainSupport.relocateAudioUrl(meetingId, stagingUrl);
|
String relocatedUrl = meetingDomainSupport.relocateAudioUrl(meetingId, stagingUrl);
|
||||||
meeting.setAudioUrl(relocatedUrl);
|
taskSecurityContextRunner.runAsTenantUser(meeting.getTenantId(), meeting.getCreatorId(), () -> {
|
||||||
meeting.setAudioSaveStatus(RealtimeMeetingAudioStorageService.STATUS_SUCCESS);
|
meeting.setAudioUrl(relocatedUrl);
|
||||||
meeting.setAudioSaveMessage(null);
|
meeting.setAudioSaveStatus(RealtimeMeetingAudioStorageService.STATUS_SUCCESS);
|
||||||
meeting.setOfflineRecordingStatus(MeetingConstants.OFFLINE_RECORDING_UPLOAD_FINISHED);
|
meeting.setAudioSaveMessage(null);
|
||||||
meeting.setStatus(1);
|
meeting.setOfflineRecordingStatus(MeetingConstants.OFFLINE_RECORDING_UPLOAD_FINISHED);
|
||||||
meetingService.updateById(meeting);
|
meeting.setStatus(1);
|
||||||
|
meetingService.updateById(meeting);
|
||||||
|
|
||||||
resetOrCreateAsrTask(meetingId, profile);
|
resetOrCreateAsrTask(meetingId, profile);
|
||||||
resetOrCreateChapterTask(meetingId, profile);
|
resetOrCreateChapterTask(meetingId, profile);
|
||||||
resetOrCreateSummaryTask(meetingId, profile);
|
resetOrCreateSummaryTask(meetingId, profile);
|
||||||
dispatchTasksAfterCommit(meetingId, meeting.getTenantId(), meeting.getCreatorId());
|
dispatchTasksAfterCommit(meetingId, meeting.getTenantId(), meeting.getCreatorId());
|
||||||
|
});
|
||||||
return new LegacyUploadAudioResponse(meetingId, relocatedUrl);
|
return new LegacyUploadAudioResponse(meetingId, relocatedUrl);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -4,5 +4,7 @@ import com.baomidou.mybatisplus.extension.service.IService;
|
||||||
|
|
||||||
import com.imeeting.entity.biz.Meeting;
|
import com.imeeting.entity.biz.Meeting;
|
||||||
|
|
||||||
|
import java.io.Serializable;
|
||||||
|
|
||||||
public interface MeetingService extends IService<Meeting> {
|
public interface MeetingService extends IService<Meeting> {
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -374,7 +374,7 @@ public class AiModelServiceImpl implements AiModelService {
|
||||||
}
|
}
|
||||||
|
|
||||||
private AiLocalProfileVO fetchLocalProfile(String baseUrl, String apiKey) {
|
private AiLocalProfileVO fetchLocalProfile(String baseUrl, String apiKey) {
|
||||||
String targetUrl = appendPath(baseUrl, "api/v1/system/profile");
|
String targetUrl = appendPath(baseUrl, "stream/v1/asr/health");
|
||||||
try {
|
try {
|
||||||
HttpRequest request = HttpRequest.newBuilder()
|
HttpRequest request = HttpRequest.newBuilder()
|
||||||
.uri(URI.create(targetUrl))
|
.uri(URI.create(targetUrl))
|
||||||
|
|
@ -389,21 +389,18 @@ public class AiModelServiceImpl implements AiModelService {
|
||||||
}
|
}
|
||||||
|
|
||||||
JsonNode root = objectMapper.readTree(response.body());
|
JsonNode root = objectMapper.readTree(response.body());
|
||||||
JsonNode dataNode = root.path("data");
|
String healthStatus = readText(root.path("status"));
|
||||||
|
if (healthStatus != null && !"healthy".equalsIgnoreCase(healthStatus)) {
|
||||||
|
throw new RuntimeException("本地模型连通性测试失败: status=" + healthStatus);
|
||||||
|
}
|
||||||
|
|
||||||
|
List<String> loadedModels = extractStringArray(root.path("loaded_models"));
|
||||||
AiLocalProfileVO profile = new AiLocalProfileVO();
|
AiLocalProfileVO profile = new AiLocalProfileVO();
|
||||||
profile.setAsrModels(extractModelNames(dataNode.path("models").path("asr")));
|
profile.setAsrModels(loadedModels);
|
||||||
JsonNode speakerNode = dataNode.path("models").path("speaker");
|
profile.setSpeakerModels(Collections.emptyList());
|
||||||
if (speakerNode.isMissingNode() || speakerNode.isNull()) {
|
if (root.path("model_loaded").asBoolean(false) && !loadedModels.isEmpty()) {
|
||||||
speakerNode = dataNode.path("model").path("speaker");
|
profile.setActiveAsrModel(loadedModels.get(0));
|
||||||
}
|
}
|
||||||
profile.setSpeakerModels(extractModelNames(speakerNode));
|
|
||||||
profile.setActiveAsrModel(readText(dataNode.path("runtime").path("asr_model")));
|
|
||||||
profile.setActiveSpeakerModel(readText(dataNode.path("runtime").path("speaker_model")));
|
|
||||||
if (dataNode.path("runtime").has("sv_threshold")) {
|
|
||||||
profile.setSvThreshold(dataNode.path("runtime").path("sv_threshold").decimalValue());
|
|
||||||
}
|
|
||||||
profile.setWsEndpoint(readText(dataNode.path("ws_endpoint")));
|
|
||||||
return profile;
|
return profile;
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
throw new RuntimeException("本地模型连通性测试失败: " + e.getMessage(), e);
|
throw new RuntimeException("本地模型连通性测试失败: " + e.getMessage(), e);
|
||||||
|
|
@ -907,6 +904,20 @@ public class AiModelServiceImpl implements AiModelService {
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private List<String> extractStringArray(JsonNode node) {
|
||||||
|
if (node == null || !node.isArray()) {
|
||||||
|
return Collections.emptyList();
|
||||||
|
}
|
||||||
|
List<String> result = new ArrayList<>();
|
||||||
|
for (JsonNode item : node) {
|
||||||
|
String value = readText(item);
|
||||||
|
if (value != null) {
|
||||||
|
result.add(value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
private String readText(JsonNode node) {
|
private String readText(JsonNode node) {
|
||||||
if (node == null || node.isMissingNode() || node.isNull()) {
|
if (node == null || node.isMissingNode() || node.isNull()) {
|
||||||
return null;
|
return null;
|
||||||
|
|
|
||||||
|
|
@ -572,6 +572,7 @@ public class AiTaskServiceImpl extends ServiceImpl<AiTaskMapper, AiTask> impleme
|
||||||
boolean useSpk = useSpkObj != null && useSpkObj.toString().equals("1");
|
boolean useSpk = useSpkObj != null && useSpkObj.toString().equals("1");
|
||||||
config.put("enable_speaker", useSpk);
|
config.put("enable_speaker", useSpk);
|
||||||
config.put("match_speaker_registry", useSpk);
|
config.put("match_speaker_registry", useSpk);
|
||||||
|
config.put("speaker_threshold", asrModel.getMediaConfig().get("svThreshold"));
|
||||||
Object enableTextRefineObj = taskRecord.getTaskConfig().get("enableTextRefine");
|
Object enableTextRefineObj = taskRecord.getTaskConfig().get("enableTextRefine");
|
||||||
boolean enableTextRefine = enableTextRefineObj != null && Boolean.parseBoolean(enableTextRefineObj.toString());
|
boolean enableTextRefine = enableTextRefineObj != null && Boolean.parseBoolean(enableTextRefineObj.toString());
|
||||||
config.put("enable_text_cleanup", enableTextRefine);
|
config.put("enable_text_cleanup", enableTextRefine);
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,24 @@
|
||||||
package com.imeeting.service.biz.impl;
|
package com.imeeting.service.biz.impl;
|
||||||
|
|
||||||
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
|
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
|
||||||
|
import com.imeeting.dto.biz.MeetingVO;
|
||||||
import com.imeeting.entity.biz.Meeting;
|
import com.imeeting.entity.biz.Meeting;
|
||||||
import com.imeeting.mapper.biz.MeetingMapper;
|
import com.imeeting.mapper.biz.MeetingMapper;
|
||||||
import com.imeeting.service.biz.MeetingService;
|
import com.imeeting.service.biz.MeetingService;
|
||||||
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
|
import java.io.Serializable;
|
||||||
|
|
||||||
@Service
|
@Service
|
||||||
public class MeetingServiceImpl extends ServiceImpl<MeetingMapper, Meeting> implements MeetingService {
|
public class MeetingServiceImpl extends ServiceImpl<MeetingMapper, Meeting> implements MeetingService {
|
||||||
|
@Autowired
|
||||||
|
private MeetingMapper meetingMapper;
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Meeting getById(Serializable id) {
|
||||||
|
return meetingMapper.selectByIdIgnoreTenant((Long) id);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ package com.imeeting.service.biz.impl;
|
||||||
import com.fasterxml.jackson.databind.JsonNode;
|
import com.fasterxml.jackson.databind.JsonNode;
|
||||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
import com.imeeting.dto.biz.AiModelDTO;
|
import com.imeeting.dto.biz.AiModelDTO;
|
||||||
|
import com.imeeting.dto.biz.AiLocalProfileVO;
|
||||||
import com.imeeting.entity.biz.AsrModel;
|
import com.imeeting.entity.biz.AsrModel;
|
||||||
import com.imeeting.entity.biz.LlmModel;
|
import com.imeeting.entity.biz.LlmModel;
|
||||||
import com.imeeting.mapper.biz.AsrModelMapper;
|
import com.imeeting.mapper.biz.AsrModelMapper;
|
||||||
|
|
@ -188,6 +189,54 @@ class AiModelServiceImplTest {
|
||||||
assertEquals(HttpClient.Version.HTTP_1_1, httpClient.version());
|
assertEquals(HttpClient.Version.HTTP_1_1, httpClient.version());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void testLocalConnectivityShouldCallNewAsrHealthEndpointAndMapLoadedModels() throws Exception {
|
||||||
|
AtomicReference<String> requestPath = new AtomicReference<>();
|
||||||
|
AtomicReference<String> authorization = new AtomicReference<>();
|
||||||
|
|
||||||
|
server = HttpServer.create(new InetSocketAddress(0), 0);
|
||||||
|
server.createContext("/stream/v1/asr/health", exchange -> {
|
||||||
|
captureRequest(exchange, requestPath, authorization, new AtomicReference<>());
|
||||||
|
writeJson(exchange, 200, """
|
||||||
|
{
|
||||||
|
"status": "healthy",
|
||||||
|
"version": "1.0.1",
|
||||||
|
"message": "ASR service is running normally",
|
||||||
|
"model_loaded": true,
|
||||||
|
"device": "cuda:0",
|
||||||
|
"loaded_models": [
|
||||||
|
"qwen3-asr-0.6b"
|
||||||
|
],
|
||||||
|
"memory_usage": {
|
||||||
|
"allocated": "0.18GB",
|
||||||
|
"cached": "0.32GB",
|
||||||
|
"max_allocated": "0.38GB"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
""");
|
||||||
|
});
|
||||||
|
server.start();
|
||||||
|
|
||||||
|
AiModelServiceImpl service = new AiModelServiceImpl(
|
||||||
|
objectMapper,
|
||||||
|
mock(AsrModelMapper.class),
|
||||||
|
mock(LlmModelMapper.class)
|
||||||
|
);
|
||||||
|
|
||||||
|
AiLocalProfileVO profile = service.testLocalConnectivity(
|
||||||
|
"http://127.0.0.1:" + server.getAddress().getPort(),
|
||||||
|
"test-key"
|
||||||
|
);
|
||||||
|
|
||||||
|
assertEquals("/stream/v1/asr/health", requestPath.get());
|
||||||
|
assertEquals("Bearer test-key", authorization.get());
|
||||||
|
assertEquals(1, profile.getAsrModels().size());
|
||||||
|
assertEquals("qwen3-asr-0.6b", profile.getAsrModels().get(0));
|
||||||
|
assertEquals("qwen3-asr-0.6b", profile.getActiveAsrModel());
|
||||||
|
assertEquals(0, profile.getSpeakerModels().size());
|
||||||
|
assertNull(profile.getWsEndpoint());
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void saveModelShouldAllowCustomLlmWithoutApiKey() {
|
void saveModelShouldAllowCustomLlmWithoutApiKey() {
|
||||||
AsrModelMapper asrModelMapper = mock(AsrModelMapper.class);
|
AsrModelMapper asrModelMapper = mock(AsrModelMapper.class);
|
||||||
|
|
|
||||||
|
|
@ -549,14 +549,14 @@ const AiModels: React.FC = () => {
|
||||||
|
|
||||||
<Form.Item
|
<Form.Item
|
||||||
label="模型名称"
|
label="模型名称"
|
||||||
required
|
required={activeType === "LLM"}
|
||||||
tooltip="可从远程列表选择,也可手动输入;值将作为模型 code 传给后端"
|
tooltip="可从远程列表选择,也可手动输入;值将作为模型 code 传给后端"
|
||||||
>
|
>
|
||||||
<Space.Compact style={{ width: "100%" }}>
|
<Space.Compact style={{ width: "100%" }}>
|
||||||
<Form.Item
|
<Form.Item
|
||||||
name="modelCode"
|
name="modelCode"
|
||||||
noStyle
|
noStyle
|
||||||
rules={[{ required: true, message: "请输入或选择模型名称" }]}
|
rules={activeType === "LLM" ? [{ required: true, message: "请输入或选择模型名称" }] : []}
|
||||||
>
|
>
|
||||||
<AutoComplete
|
<AutoComplete
|
||||||
allowClear
|
allowClear
|
||||||
|
|
@ -582,19 +582,16 @@ const AiModels: React.FC = () => {
|
||||||
</Space.Compact>
|
</Space.Compact>
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
|
|
||||||
{activeType === "ASR" && (
|
<Form.Item name="wsUrl" label="WebSocket 地址" hidden>
|
||||||
<Form.Item name="wsUrl" label="WebSocket 地址">
|
<Input placeholder="wss://api.example.com/v1/ws" />
|
||||||
<Input placeholder="wss://api.example.com/v1/ws" />
|
</Form.Item>
|
||||||
</Form.Item>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{activeType === "ASR" && isLocalProvider && (
|
{activeType === "ASR" && isLocalProvider && (
|
||||||
<Row gutter={16}>
|
<Row gutter={16} hidden>
|
||||||
<Col span={12}>
|
<Col span={12}>
|
||||||
<Form.Item
|
<Form.Item
|
||||||
name="speakerModel"
|
name="speakerModel"
|
||||||
label="声纹模型"
|
label="声纹模型"
|
||||||
rules={[{ required: true, message: "请选择声纹模型" }]}
|
|
||||||
>
|
>
|
||||||
<Select
|
<Select
|
||||||
allowClear
|
allowClear
|
||||||
|
|
@ -607,7 +604,6 @@ const AiModels: React.FC = () => {
|
||||||
<Form.Item
|
<Form.Item
|
||||||
name="svThreshold"
|
name="svThreshold"
|
||||||
label="声纹阈值"
|
label="声纹阈值"
|
||||||
rules={[{ required: true, message: "请输入声纹阈值" }]}
|
|
||||||
>
|
>
|
||||||
<InputNumber min={0} max={1} step={0.01} style={{ width: "100%" }} />
|
<InputNumber min={0} max={1} step={0.01} style={{ width: "100%" }} />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue