Merge branch 'refs/heads/dev_na' into dev_asr_local
commit
ba62c9e0c0
|
|
@ -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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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, "后台合并上传中"));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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会议接口")
|
||||||
|
|
@ -240,20 +242,22 @@ public class AndroidMeetingController {
|
||||||
"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会议")
|
||||||
|
|
@ -310,12 +314,13 @@ 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)
|
||||||
|
|
@ -369,6 +374,7 @@ public class AndroidMeetingController {
|
||||||
androidMeetingPushService.pushMeetingStatusChanged(meetingId, UnifiedMeetingStatusStage.SUMMARIZING.getCode());
|
androidMeetingPushService.pushMeetingStatusChanged(meetingId, UnifiedMeetingStatusStage.SUMMARIZING.getCode());
|
||||||
return ApiResponse.ok(true);
|
return ApiResponse.ok(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Operation(summary = "更新Android会议访问密码")
|
@Operation(summary = "更新Android会议访问密码")
|
||||||
@ApiResponses({
|
@ApiResponses({
|
||||||
@io.swagger.v3.oas.annotations.responses.ApiResponse(
|
@io.swagger.v3.oas.annotations.responses.ApiResponse(
|
||||||
|
|
@ -417,6 +423,7 @@ public class AndroidMeetingController {
|
||||||
meetingCommandService.deleteMeeting(meetingId);
|
meetingCommandService.deleteMeeting(meetingId);
|
||||||
return ApiResponse.ok(true);
|
return ApiResponse.ok(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
@GetMapping("/config")
|
@GetMapping("/config")
|
||||||
@Log(value = "获取会议配置", type = "Android会议管理")
|
@Log(value = "获取会议配置", type = "Android会议管理")
|
||||||
@Operation(summary = "获取会议配置")
|
@Operation(summary = "获取会议配置")
|
||||||
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -255,6 +255,19 @@ public class AndroidPushGrpcService extends PushServiceGrpc.PushServiceImplBase
|
||||||
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";
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1121,7 +1121,7 @@ public class AiTaskServiceImpl extends ServiceImpl<AiTaskMapper, AiTask> impleme
|
||||||
meetingPointsService.recordSummarySuccessCharge(meeting, taskRecord);
|
meetingPointsService.recordSummarySuccessCharge(meeting, taskRecord);
|
||||||
|
|
||||||
AiTask latestChapterTask = findLatestTask(meeting.getId(), "CHAPTER");
|
AiTask latestChapterTask = findLatestTask(meeting.getId(), "CHAPTER");
|
||||||
if (latestChapterTask != null && Integer.valueOf(2).equals(latestChapterTask.getStatus())) {
|
if (!resolveAiCatalogEnabled() ||(latestChapterTask != null && Integer.valueOf(2).equals(latestChapterTask.getStatus()))) {
|
||||||
updateProgress(meeting.getId(), 100, "全流程分析完成", 0);
|
updateProgress(meeting.getId(), 100, "全流程分析完成", 0);
|
||||||
} else {
|
} else {
|
||||||
updateProgress(meeting.getId(), 95, "总结生成完成,等待 AI 目录完成...", 0);
|
updateProgress(meeting.getId(), 95, "总结生成完成,等待 AI 目录完成...", 0);
|
||||||
|
|
@ -1369,7 +1369,7 @@ public class AiTaskServiceImpl extends ServiceImpl<AiTaskMapper, AiTask> impleme
|
||||||
updateMeetingStatus(meetingId, 4);
|
updateMeetingStatus(meetingId, 4);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (isTaskCompleted(chapterTask) && isTaskCompleted(summaryTask)) {
|
if ( isTaskCompleted(summaryTask) && (!resolveAiCatalogEnabled() || isTaskCompleted(chapterTask))) {
|
||||||
updateMeetingStatus(meetingId, 3);
|
updateMeetingStatus(meetingId, 3);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -241,6 +241,14 @@ public class MeetingProgressServiceImpl implements MeetingProgressService {
|
||||||
if (latestChapter != null && Integer.valueOf(1).equals(latestChapter.getStatus())) {
|
if (latestChapter != null && Integer.valueOf(1).equals(latestChapter.getStatus())) {
|
||||||
return buildSnapshot(meetingId, latestChapter, meeting.getStatus(), MeetingProgressStage.CHAPTER_RUNNING, 85, "正在生成会议章节...", 0);
|
return buildSnapshot(meetingId, latestChapter, meeting.getStatus(), MeetingProgressStage.CHAPTER_RUNNING, 85, "正在生成会议章节...", 0);
|
||||||
}
|
}
|
||||||
|
if (MeetingStatusEnum.isCode(meeting.getStatus(), MeetingStatusEnum.SUMMARIZING)) {
|
||||||
|
if (latestSummary != null && Integer.valueOf(2).equals(latestSummary.getStatus())) {
|
||||||
|
return buildSnapshot(meetingId, latestSummary, meeting.getStatus(), MeetingProgressStage.SUMMARY_RUNNING, 90, "正在生成会议总结...", 0);
|
||||||
|
}
|
||||||
|
if (latestChapter != null && Integer.valueOf(0).equals(latestChapter.getStatus())) {
|
||||||
|
return buildSnapshot(meetingId, latestChapter, meeting.getStatus(), MeetingProgressStage.CHAPTER_RUNNING, 85, "正在生成会议章节...", 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
AiTask latestAsr = findLatestTask(meetingId, "ASR");
|
AiTask latestAsr = findLatestTask(meetingId, "ASR");
|
||||||
if (latestAsr != null) {
|
if (latestAsr != null) {
|
||||||
if (Integer.valueOf(1).equals(latestAsr.getStatus())) {
|
if (Integer.valueOf(1).equals(latestAsr.getStatus())) {
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -369,28 +369,29 @@ public class MeetingTranscriptChapterServiceImpl implements MeetingTranscriptCha
|
||||||
|
|
||||||
private String buildChapterSystemPrompt() {
|
private String buildChapterSystemPrompt() {
|
||||||
return """
|
return """
|
||||||
你负责对会议转录分段做章节边界识别。
|
你是会议转录分段任务中的“章节边界识别器”。
|
||||||
只允许返回 JSON。
|
基于输入 transcript 列表,进行语义分段,输出章节结构。
|
||||||
只能返回 chapters 数组。
|
输出格式(严格)
|
||||||
JSON里面必须包含chapters 数组,就算只有一个章节
|
只允许输出 JSON,且必须符合以下结构:
|
||||||
每个章节只允许包含:
|
{
|
||||||
chapterNo,title,summary,keywords,startTranscriptId,endTranscriptId,confidence
|
"chapters": [
|
||||||
不得改写原文。
|
{
|
||||||
不得输出章节正文。
|
"chapterNo": number,
|
||||||
不得归一化数字、日期、金额、时间点。
|
"title": string,
|
||||||
所有章节必须完整覆盖全部 transcript。
|
"summary": string,
|
||||||
章节必须严格连续:
|
"keywords": [string],
|
||||||
- 第一个章节 startTranscriptId 必须为 转录原文的起始transcriptId
|
"startTranscriptId": number,
|
||||||
- 下一个章节的 startTranscriptId 必须等于上一个章节的 endTranscriptId + 1
|
"endTranscriptId": number,
|
||||||
- 最后一个章节必须覆盖最后一条 transcript
|
"confidence": number
|
||||||
禁止:
|
}
|
||||||
- transcript 遗漏
|
]
|
||||||
- transcript 重复
|
}
|
||||||
- 章节重叠
|
规则:
|
||||||
- 跳跃式分段
|
1. 必须按顺序分段,不允许交叉或跳跃
|
||||||
章节标题、摘要、关键词必须基于对应章节原文生成,不得虚构。
|
2. 必须覆盖全部 transcript
|
||||||
若无法识别明确的话题边界,则将全部 transcript 作为一个章节返回.
|
3. 若无明显边界,则合并为一个章节
|
||||||
|
4. title 必须基于该段内容生成
|
||||||
|
5. 只输出 JSON,不要任何解释
|
||||||
""";
|
""";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1017,9 +1018,9 @@ public class MeetingTranscriptChapterServiceImpl implements MeetingTranscriptCha
|
||||||
Long startTranscriptId = longValue(item.path("startTranscriptId").asText(null));
|
Long startTranscriptId = longValue(item.path("startTranscriptId").asText(null));
|
||||||
Long endTranscriptId = longValue(item.path("endTranscriptId").asText(null));
|
Long endTranscriptId = longValue(item.path("endTranscriptId").asText(null));
|
||||||
Integer chapterNo = item.path("chapterNo").isInt() ? item.path("chapterNo").asInt() : null;
|
Integer chapterNo = item.path("chapterNo").isInt() ? item.path("chapterNo").asInt() : null;
|
||||||
if (chapterNo == null || startTranscriptId == null || endTranscriptId == null) {
|
// if (chapterNo == null || startTranscriptId == null || endTranscriptId == null) {
|
||||||
throw new RuntimeException("章节模型返回了不完整的章节边界");
|
// throw new RuntimeException("章节模型返回了不完整的章节边界");
|
||||||
}
|
// }
|
||||||
List<String> keywords = new ArrayList<>();
|
List<String> keywords = new ArrayList<>();
|
||||||
if (item.path("keywords").isArray()) {
|
if (item.path("keywords").isArray()) {
|
||||||
for (JsonNode keyword : item.path("keywords")) {
|
for (JsonNode keyword : item.path("keywords")) {
|
||||||
|
|
|
||||||
|
|
@ -90,12 +90,12 @@ public class MeetingUnifiedStatusServiceImpl implements MeetingUnifiedStatusServ
|
||||||
if (isAndroidOfflineMeetingWaitingUpload(meeting)) {
|
if (isAndroidOfflineMeetingWaitingUpload(meeting)) {
|
||||||
return UnifiedMeetingStatusStage.WAITING_UPLOAD;
|
return UnifiedMeetingStatusStage.WAITING_UPLOAD;
|
||||||
}
|
}
|
||||||
UnifiedMeetingStatusStage stageFromSnapshot = resolveStageFromSnapshot(snapshot);
|
MeetingUnifiedStageContext context = buildStageContext(meeting.getId(), snapshot);
|
||||||
|
UnifiedMeetingStatusStage stageFromSnapshot = resolveStageFromSnapshot(snapshot, context);
|
||||||
if (stageFromSnapshot != null) {
|
if (stageFromSnapshot != null) {
|
||||||
return stageFromSnapshot;
|
return stageFromSnapshot;
|
||||||
}
|
}
|
||||||
|
|
||||||
MeetingUnifiedStageContext context = buildStageContext(meeting.getId(), snapshot);
|
|
||||||
if (isTranscribing(context)) {
|
if (isTranscribing(context)) {
|
||||||
return UnifiedMeetingStatusStage.TRANSCRIBING;
|
return UnifiedMeetingStatusStage.TRANSCRIBING;
|
||||||
}
|
}
|
||||||
|
|
@ -106,7 +106,8 @@ public class MeetingUnifiedStatusServiceImpl implements MeetingUnifiedStatusServ
|
||||||
return UnifiedMeetingStatusStage.INITIALIZING;
|
return UnifiedMeetingStatusStage.INITIALIZING;
|
||||||
}
|
}
|
||||||
|
|
||||||
private UnifiedMeetingStatusStage resolveStageFromSnapshot(MeetingProgressSnapshot snapshot) {
|
private UnifiedMeetingStatusStage resolveStageFromSnapshot(MeetingProgressSnapshot snapshot,
|
||||||
|
MeetingUnifiedStageContext context) {
|
||||||
if (snapshot == null || snapshot.getStage() == null || snapshot.getStage().isBlank()) {
|
if (snapshot == null || snapshot.getStage() == null || snapshot.getStage().isBlank()) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
@ -115,11 +116,24 @@ public class MeetingUnifiedStatusServiceImpl implements MeetingUnifiedStatusServ
|
||||||
case "completed" -> UnifiedMeetingStatusStage.COMPLETED;
|
case "completed" -> UnifiedMeetingStatusStage.COMPLETED;
|
||||||
case "summary_running", "chapter_running" -> UnifiedMeetingStatusStage.SUMMARIZING;
|
case "summary_running", "chapter_running" -> UnifiedMeetingStatusStage.SUMMARIZING;
|
||||||
case "asr_running", "asr_completed", "asr_submitted" -> UnifiedMeetingStatusStage.TRANSCRIBING;
|
case "asr_running", "asr_completed", "asr_submitted" -> UnifiedMeetingStatusStage.TRANSCRIBING;
|
||||||
case "queued" -> UnifiedMeetingStatusStage.INITIALIZING;
|
case "queued" -> resolveQueuedSnapshotStage(context);
|
||||||
default -> null;
|
default -> null;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private UnifiedMeetingStatusStage resolveQueuedSnapshotStage(MeetingUnifiedStageContext context) {
|
||||||
|
if (context == null) {
|
||||||
|
return UnifiedMeetingStatusStage.INITIALIZING;
|
||||||
|
}
|
||||||
|
if (isSummarizing(context)) {
|
||||||
|
return UnifiedMeetingStatusStage.SUMMARIZING;
|
||||||
|
}
|
||||||
|
if (isTranscribing(context)) {
|
||||||
|
return UnifiedMeetingStatusStage.TRANSCRIBING;
|
||||||
|
}
|
||||||
|
return UnifiedMeetingStatusStage.INITIALIZING;
|
||||||
|
}
|
||||||
|
|
||||||
private UnifiedMeetingStatusStage resolveFailedStage(MeetingVO meeting) {
|
private UnifiedMeetingStatusStage resolveFailedStage(MeetingVO meeting) {
|
||||||
if (meeting == null || !MeetingStatusEnum.isCode(meeting.getStatus(), MeetingStatusEnum.FAILED)) {
|
if (meeting == null || !MeetingStatusEnum.isCode(meeting.getStatus(), MeetingStatusEnum.FAILED)) {
|
||||||
return null;
|
return null;
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue