feat: 添加实时会议配置选项和会话状态批量查询功能
- 在 `CreateRealtimeMeetingCommand` 中添加 `mode`, `language`, `enablePunctuation`, `enableItn`, `enableTextRefine`, 和 `saveAudio` 字段 - 更新 `MeetingCommandServiceImpl` 以支持新的实时会议配置选项 - 添加 `getRealtimeSessionStatuses` 接口,支持批量查询实时会议会话状态 - 更新前端API和组件,支持新的配置选项和批量查询功能dev_na
parent
ff47c34349
commit
24c3835b79
|
|
@ -28,6 +28,7 @@ import com.imeeting.service.biz.RealtimeMeetingSocketSessionService;
|
||||||
import com.unisbase.common.ApiResponse;
|
import com.unisbase.common.ApiResponse;
|
||||||
import com.unisbase.dto.PageResult;
|
import com.unisbase.dto.PageResult;
|
||||||
import com.unisbase.security.LoginUser;
|
import com.unisbase.security.LoginUser;
|
||||||
|
import jakarta.validation.Valid;
|
||||||
import org.springframework.beans.factory.annotation.Value;
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
import org.springframework.data.redis.core.StringRedisTemplate;
|
import org.springframework.data.redis.core.StringRedisTemplate;
|
||||||
import org.springframework.http.HttpHeaders;
|
import org.springframework.http.HttpHeaders;
|
||||||
|
|
@ -50,6 +51,7 @@ import java.io.File;
|
||||||
import java.io.IOException;
|
import java.io.IOException;
|
||||||
import java.net.URLEncoder;
|
import java.net.URLEncoder;
|
||||||
import java.nio.charset.StandardCharsets;
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.util.LinkedHashMap;
|
||||||
import java.util.HashMap;
|
import java.util.HashMap;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
|
|
@ -141,7 +143,7 @@ public class MeetingController {
|
||||||
|
|
||||||
@PostMapping
|
@PostMapping
|
||||||
@PreAuthorize("isAuthenticated()")
|
@PreAuthorize("isAuthenticated()")
|
||||||
public ApiResponse<MeetingVO> create(@RequestBody CreateMeetingCommand command) {
|
public ApiResponse<MeetingVO> create(@Valid @RequestBody CreateMeetingCommand command) {
|
||||||
LoginUser loginUser = currentLoginUser();
|
LoginUser loginUser = currentLoginUser();
|
||||||
assertPromptAvailable(command.getPromptId(), loginUser);
|
assertPromptAvailable(command.getPromptId(), loginUser);
|
||||||
return ApiResponse.ok(meetingCommandService.createMeeting(
|
return ApiResponse.ok(meetingCommandService.createMeeting(
|
||||||
|
|
@ -154,7 +156,7 @@ public class MeetingController {
|
||||||
|
|
||||||
@PostMapping("/realtime/start")
|
@PostMapping("/realtime/start")
|
||||||
@PreAuthorize("isAuthenticated()")
|
@PreAuthorize("isAuthenticated()")
|
||||||
public ApiResponse<MeetingVO> createRealtime(@RequestBody CreateRealtimeMeetingCommand command) {
|
public ApiResponse<MeetingVO> createRealtime(@Valid @RequestBody CreateRealtimeMeetingCommand command) {
|
||||||
LoginUser loginUser = currentLoginUser();
|
LoginUser loginUser = currentLoginUser();
|
||||||
assertPromptAvailable(command.getPromptId(), loginUser);
|
assertPromptAvailable(command.getPromptId(), loginUser);
|
||||||
return ApiResponse.ok(meetingCommandService.createRealtimeMeeting(
|
return ApiResponse.ok(meetingCommandService.createRealtimeMeeting(
|
||||||
|
|
@ -235,6 +237,34 @@ public class MeetingController {
|
||||||
return ApiResponse.ok(realtimeMeetingSessionStateService.getStatus(id));
|
return ApiResponse.ok(realtimeMeetingSessionStateService.getStatus(id));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@PostMapping("/realtime/session-status/batch")
|
||||||
|
@PreAuthorize("isAuthenticated()")
|
||||||
|
public ApiResponse<Map<Long, RealtimeMeetingSessionStatusVO>> getRealtimeSessionStatuses(@RequestBody List<Long> ids) {
|
||||||
|
LoginUser loginUser = currentLoginUser();
|
||||||
|
Map<Long, RealtimeMeetingSessionStatusVO> result = new LinkedHashMap<>();
|
||||||
|
if (ids == null || ids.isEmpty()) {
|
||||||
|
return ApiResponse.ok(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<Long, RealtimeMeetingSessionStatusVO> statuses = realtimeMeetingSessionStateService.getStatuses(ids);
|
||||||
|
for (Long id : ids) {
|
||||||
|
if (id == null) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
Meeting meeting = meetingAccessService.requireMeeting(id);
|
||||||
|
meetingAccessService.assertCanManageRealtimeMeeting(meeting, loginUser);
|
||||||
|
RealtimeMeetingSessionStatusVO status = statuses.get(id);
|
||||||
|
if (status != null) {
|
||||||
|
result.put(id, status);
|
||||||
|
}
|
||||||
|
} catch (RuntimeException ignored) {
|
||||||
|
// Preserve previous per-item fallback behavior for inaccessible meetings.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ApiResponse.ok(result);
|
||||||
|
}
|
||||||
|
|
||||||
@PostMapping("/{id}/realtime/transcripts")
|
@PostMapping("/{id}/realtime/transcripts")
|
||||||
@PreAuthorize("isAuthenticated()")
|
@PreAuthorize("isAuthenticated()")
|
||||||
public ApiResponse<Boolean> appendRealtimeTranscripts(@PathVariable Long id, @RequestBody List<RealtimeTranscriptItemDTO> items) {
|
public ApiResponse<Boolean> appendRealtimeTranscripts(@PathVariable Long id, @RequestBody List<RealtimeTranscriptItemDTO> items) {
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,8 @@
|
||||||
package com.imeeting.dto.biz;
|
package com.imeeting.dto.biz;
|
||||||
|
|
||||||
import com.fasterxml.jackson.annotation.JsonFormat;
|
import com.fasterxml.jackson.annotation.JsonFormat;
|
||||||
|
import jakarta.validation.constraints.NotBlank;
|
||||||
|
import jakarta.validation.constraints.NotNull;
|
||||||
import lombok.Data;
|
import lombok.Data;
|
||||||
|
|
||||||
import java.time.LocalDateTime;
|
import java.time.LocalDateTime;
|
||||||
|
|
@ -8,16 +10,22 @@ import java.util.List;
|
||||||
|
|
||||||
@Data
|
@Data
|
||||||
public class CreateMeetingCommand {
|
public class CreateMeetingCommand {
|
||||||
|
@NotBlank(message = "会议标题不能为空")
|
||||||
private String title;
|
private String title;
|
||||||
|
|
||||||
|
@NotNull(message = "会议时间不能为空")
|
||||||
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
|
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
|
||||||
private LocalDateTime meetingTime;
|
private LocalDateTime meetingTime;
|
||||||
|
|
||||||
private String participants;
|
private String participants;
|
||||||
private String tags;
|
private String tags;
|
||||||
|
@NotBlank(message = "音频地址不能为空")
|
||||||
private String audioUrl;
|
private String audioUrl;
|
||||||
|
@NotNull(message = "识别模型不能为空")
|
||||||
private Long asrModelId;
|
private Long asrModelId;
|
||||||
|
@NotNull(message = "总结模型不能为空")
|
||||||
private Long summaryModelId;
|
private Long summaryModelId;
|
||||||
|
@NotNull(message = "提示词模板不能为空")
|
||||||
private Long promptId;
|
private Long promptId;
|
||||||
private Integer useSpkId;
|
private Integer useSpkId;
|
||||||
private Boolean enableTextRefine;
|
private Boolean enableTextRefine;
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,8 @@
|
||||||
package com.imeeting.dto.biz;
|
package com.imeeting.dto.biz;
|
||||||
|
|
||||||
import com.fasterxml.jackson.annotation.JsonFormat;
|
import com.fasterxml.jackson.annotation.JsonFormat;
|
||||||
|
import jakarta.validation.constraints.NotBlank;
|
||||||
|
import jakarta.validation.constraints.NotNull;
|
||||||
import lombok.Data;
|
import lombok.Data;
|
||||||
|
|
||||||
import java.time.LocalDateTime;
|
import java.time.LocalDateTime;
|
||||||
|
|
@ -8,16 +10,27 @@ import java.util.List;
|
||||||
|
|
||||||
@Data
|
@Data
|
||||||
public class CreateRealtimeMeetingCommand {
|
public class CreateRealtimeMeetingCommand {
|
||||||
|
@NotBlank(message = "会议标题不能为空")
|
||||||
private String title;
|
private String title;
|
||||||
|
|
||||||
|
@NotNull(message = "会议时间不能为空")
|
||||||
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
|
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
|
||||||
private LocalDateTime meetingTime;
|
private LocalDateTime meetingTime;
|
||||||
|
|
||||||
private String participants;
|
private String participants;
|
||||||
private String tags;
|
private String tags;
|
||||||
|
@NotNull(message = "识别模型不能为空")
|
||||||
private Long asrModelId;
|
private Long asrModelId;
|
||||||
|
@NotNull(message = "总结模型不能为空")
|
||||||
private Long summaryModelId;
|
private Long summaryModelId;
|
||||||
|
@NotNull(message = "提示词模板不能为空")
|
||||||
private Long promptId;
|
private Long promptId;
|
||||||
|
private String mode;
|
||||||
|
private String language;
|
||||||
private Integer useSpkId;
|
private Integer useSpkId;
|
||||||
|
private Boolean enablePunctuation;
|
||||||
|
private Boolean enableItn;
|
||||||
|
private Boolean enableTextRefine;
|
||||||
|
private Boolean saveAudio;
|
||||||
private List<String> hotWords;
|
private List<String> hotWords;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,9 @@ package com.imeeting.service.biz;
|
||||||
import com.imeeting.dto.biz.RealtimeMeetingResumeConfig;
|
import com.imeeting.dto.biz.RealtimeMeetingResumeConfig;
|
||||||
import com.imeeting.dto.biz.RealtimeMeetingSessionStatusVO;
|
import com.imeeting.dto.biz.RealtimeMeetingSessionStatusVO;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
public interface RealtimeMeetingSessionStateService {
|
public interface RealtimeMeetingSessionStateService {
|
||||||
void initSessionIfAbsent(Long meetingId, Long tenantId, Long userId);
|
void initSessionIfAbsent(Long meetingId, Long tenantId, Long userId);
|
||||||
|
|
||||||
|
|
@ -14,6 +17,8 @@ public interface RealtimeMeetingSessionStateService {
|
||||||
|
|
||||||
RealtimeMeetingSessionStatusVO getStatus(Long meetingId);
|
RealtimeMeetingSessionStatusVO getStatus(Long meetingId);
|
||||||
|
|
||||||
|
Map<Long, RealtimeMeetingSessionStatusVO> getStatuses(List<Long> meetingIds);
|
||||||
|
|
||||||
RealtimeMeetingSessionStatusVO pause(Long meetingId);
|
RealtimeMeetingSessionStatusVO pause(Long meetingId);
|
||||||
|
|
||||||
void pauseByDisconnect(Long meetingId, String connectionId);
|
void pauseByDisconnect(Long meetingId, String connectionId);
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ import com.imeeting.common.RedisKeys;
|
||||||
import com.imeeting.dto.biz.CreateMeetingCommand;
|
import com.imeeting.dto.biz.CreateMeetingCommand;
|
||||||
import com.imeeting.dto.biz.CreateRealtimeMeetingCommand;
|
import com.imeeting.dto.biz.CreateRealtimeMeetingCommand;
|
||||||
import com.imeeting.dto.biz.MeetingVO;
|
import com.imeeting.dto.biz.MeetingVO;
|
||||||
|
import com.imeeting.dto.biz.RealtimeMeetingResumeConfig;
|
||||||
import com.imeeting.dto.biz.RealtimeTranscriptItemDTO;
|
import com.imeeting.dto.biz.RealtimeTranscriptItemDTO;
|
||||||
import com.imeeting.dto.biz.UpdateMeetingBasicCommand;
|
import com.imeeting.dto.biz.UpdateMeetingBasicCommand;
|
||||||
import com.imeeting.dto.biz.UpdateMeetingTranscriptCommand;
|
import com.imeeting.dto.biz.UpdateMeetingTranscriptCommand;
|
||||||
|
|
@ -25,9 +26,12 @@ import org.springframework.data.redis.core.StringRedisTemplate;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
import org.springframework.transaction.annotation.Transactional;
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
import java.math.RoundingMode;
|
||||||
import java.util.HashMap;
|
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.concurrent.TimeUnit;
|
import java.util.concurrent.TimeUnit;
|
||||||
import java.util.stream.Collectors;
|
import java.util.stream.Collectors;
|
||||||
|
|
||||||
|
|
@ -51,8 +55,6 @@ public class MeetingCommandServiceImpl implements MeetingCommandService {
|
||||||
Meeting meeting = meetingDomainSupport.initMeeting(command.getTitle(), command.getMeetingTime(), command.getParticipants(), command.getTags(),
|
Meeting meeting = meetingDomainSupport.initMeeting(command.getTitle(), command.getMeetingTime(), command.getParticipants(), command.getTags(),
|
||||||
command.getAudioUrl(), tenantId, creatorId, creatorName, 0);
|
command.getAudioUrl(), tenantId, creatorId, creatorName, 0);
|
||||||
meetingService.save(meeting);
|
meetingService.save(meeting);
|
||||||
meeting.setAudioUrl(meetingDomainSupport.relocateAudioUrl(meeting.getId(), command.getAudioUrl()));
|
|
||||||
meetingService.updateById(meeting);
|
|
||||||
|
|
||||||
AiTask asrTask = new AiTask();
|
AiTask asrTask = new AiTask();
|
||||||
asrTask.setMeetingId(meeting.getId());
|
asrTask.setMeetingId(meeting.getId());
|
||||||
|
|
@ -78,6 +80,8 @@ public class MeetingCommandServiceImpl implements MeetingCommandService {
|
||||||
aiTaskService.save(asrTask);
|
aiTaskService.save(asrTask);
|
||||||
|
|
||||||
meetingDomainSupport.createSummaryTask(meeting.getId(), command.getSummaryModelId(), command.getPromptId());
|
meetingDomainSupport.createSummaryTask(meeting.getId(), command.getSummaryModelId(), command.getPromptId());
|
||||||
|
meeting.setAudioUrl(meetingDomainSupport.relocateAudioUrl(meeting.getId(), command.getAudioUrl()));
|
||||||
|
meetingService.updateById(meeting);
|
||||||
meetingDomainSupport.publishMeetingCreated(meeting.getId());
|
meetingDomainSupport.publishMeetingCreated(meeting.getId());
|
||||||
|
|
||||||
MeetingVO vo = new MeetingVO();
|
MeetingVO vo = new MeetingVO();
|
||||||
|
|
@ -89,10 +93,11 @@ public class MeetingCommandServiceImpl implements MeetingCommandService {
|
||||||
@Transactional(rollbackFor = Exception.class)
|
@Transactional(rollbackFor = Exception.class)
|
||||||
public MeetingVO createRealtimeMeeting(CreateRealtimeMeetingCommand command, Long tenantId, Long creatorId, String creatorName) {
|
public MeetingVO createRealtimeMeeting(CreateRealtimeMeetingCommand command, Long tenantId, Long creatorId, String creatorName) {
|
||||||
Meeting meeting = meetingDomainSupport.initMeeting(command.getTitle(), command.getMeetingTime(), command.getParticipants(), command.getTags(),
|
Meeting meeting = meetingDomainSupport.initMeeting(command.getTitle(), command.getMeetingTime(), command.getParticipants(), command.getTags(),
|
||||||
null, tenantId, creatorId, creatorName, 1);
|
null, tenantId, creatorId, creatorName, 0);
|
||||||
meetingService.save(meeting);
|
meetingService.save(meeting);
|
||||||
meetingDomainSupport.createSummaryTask(meeting.getId(), command.getSummaryModelId(), command.getPromptId());
|
meetingDomainSupport.createSummaryTask(meeting.getId(), command.getSummaryModelId(), command.getPromptId());
|
||||||
realtimeMeetingSessionStateService.initSessionIfAbsent(meeting.getId(), tenantId, creatorId);
|
realtimeMeetingSessionStateService.initSessionIfAbsent(meeting.getId(), tenantId, creatorId);
|
||||||
|
realtimeMeetingSessionStateService.rememberResumeConfig(meeting.getId(), buildRealtimeResumeConfig(command, tenantId));
|
||||||
|
|
||||||
MeetingVO vo = new MeetingVO();
|
MeetingVO vo = new MeetingVO();
|
||||||
meetingDomainSupport.fillMeetingVO(meeting, vo, false);
|
meetingDomainSupport.fillMeetingVO(meeting, vo, false);
|
||||||
|
|
@ -270,4 +275,48 @@ public class MeetingCommandServiceImpl implements MeetingCommandService {
|
||||||
// Ignore progress write failures.
|
// Ignore progress write failures.
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
private RealtimeMeetingResumeConfig buildRealtimeResumeConfig(CreateRealtimeMeetingCommand command, Long tenantId) {
|
||||||
|
RealtimeMeetingResumeConfig resumeConfig = new RealtimeMeetingResumeConfig();
|
||||||
|
resumeConfig.setAsrModelId(command.getAsrModelId());
|
||||||
|
resumeConfig.setMode(command.getMode() == null || command.getMode().isBlank() ? "2pass" : command.getMode().trim());
|
||||||
|
resumeConfig.setLanguage(command.getLanguage() == null || command.getLanguage().isBlank() ? "auto" : command.getLanguage().trim());
|
||||||
|
resumeConfig.setUseSpkId(command.getUseSpkId() != null ? command.getUseSpkId() : 0);
|
||||||
|
resumeConfig.setEnablePunctuation(command.getEnablePunctuation() != null ? command.getEnablePunctuation() : Boolean.TRUE);
|
||||||
|
resumeConfig.setEnableItn(command.getEnableItn() != null ? command.getEnableItn() : Boolean.TRUE);
|
||||||
|
resumeConfig.setEnableTextRefine(Boolean.TRUE.equals(command.getEnableTextRefine()));
|
||||||
|
resumeConfig.setSaveAudio(Boolean.TRUE.equals(command.getSaveAudio()));
|
||||||
|
resumeConfig.setHotwords(resolveRealtimeHotwords(command.getHotWords(), tenantId));
|
||||||
|
return resumeConfig;
|
||||||
|
}
|
||||||
|
|
||||||
|
private List<Map<String, Object>> resolveRealtimeHotwords(List<String> selectedWords, Long tenantId) {
|
||||||
|
List<HotWord> tenantHotwords = hotWordService.list(new LambdaQueryWrapper<HotWord>()
|
||||||
|
.eq(HotWord::getTenantId, tenantId)
|
||||||
|
.eq(HotWord::getStatus, 1));
|
||||||
|
if (tenantHotwords == null || tenantHotwords.isEmpty()) {
|
||||||
|
return List.of();
|
||||||
|
}
|
||||||
|
|
||||||
|
List<String> effectiveWords = selectedWords == null
|
||||||
|
? List.of()
|
||||||
|
: selectedWords.stream()
|
||||||
|
.filter(Objects::nonNull)
|
||||||
|
.map(String::trim)
|
||||||
|
.filter(word -> !word.isEmpty())
|
||||||
|
.toList();
|
||||||
|
|
||||||
|
return tenantHotwords.stream()
|
||||||
|
.filter(item -> effectiveWords.isEmpty() || effectiveWords.contains(item.getWord()))
|
||||||
|
.map(this::toRealtimeHotword)
|
||||||
|
.collect(Collectors.toList());
|
||||||
|
}
|
||||||
|
|
||||||
|
private Map<String, Object> toRealtimeHotword(HotWord hotWord) {
|
||||||
|
Map<String, Object> item = new HashMap<>();
|
||||||
|
item.put("hotword", hotWord.getWord());
|
||||||
|
int rawWeight = hotWord.getWeight() == null ? 20 : hotWord.getWeight();
|
||||||
|
item.put("weight", BigDecimal.valueOf(rawWeight).divide(BigDecimal.TEN, 2, RoundingMode.HALF_UP).doubleValue());
|
||||||
|
return item;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,8 @@ import lombok.extern.slf4j.Slf4j;
|
||||||
import org.springframework.beans.factory.annotation.Value;
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
import org.springframework.context.ApplicationEventPublisher;
|
import org.springframework.context.ApplicationEventPublisher;
|
||||||
import org.springframework.stereotype.Component;
|
import org.springframework.stereotype.Component;
|
||||||
|
import org.springframework.transaction.support.TransactionSynchronization;
|
||||||
|
import org.springframework.transaction.support.TransactionSynchronizationManager;
|
||||||
|
|
||||||
import java.nio.file.Files;
|
import java.nio.file.Files;
|
||||||
import java.nio.file.Path;
|
import java.nio.file.Path;
|
||||||
|
|
@ -28,6 +30,7 @@ import java.util.Collections;
|
||||||
import java.util.HashMap;
|
import java.util.HashMap;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
|
import java.util.UUID;
|
||||||
import java.util.stream.Collectors;
|
import java.util.stream.Collectors;
|
||||||
|
|
||||||
@Slf4j
|
@Slf4j
|
||||||
|
|
@ -91,26 +94,89 @@ public class MeetingDomainSupport {
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
String fileName = audioUrl.substring(audioUrl.lastIndexOf("/") + 1);
|
AudioRelocationPlan plan = buildAudioRelocationPlan(meetingId, audioUrl);
|
||||||
String basePath = uploadPath.endsWith("/") ? uploadPath : uploadPath + "/";
|
if (plan == null || !Files.exists(plan.sourcePath())) {
|
||||||
Path sourcePath = Paths.get(basePath, "audio", fileName);
|
|
||||||
if (!Files.exists(sourcePath)) {
|
|
||||||
return audioUrl;
|
return audioUrl;
|
||||||
}
|
}
|
||||||
|
|
||||||
String ext = "";
|
Files.createDirectories(plan.targetPath().getParent());
|
||||||
int dotIdx = fileName.lastIndexOf('.');
|
if (plan.backupPath() != null && Files.exists(plan.targetPath())) {
|
||||||
if (dotIdx > 0) {
|
Files.move(plan.targetPath(), plan.backupPath(), StandardCopyOption.REPLACE_EXISTING);
|
||||||
ext = fileName.substring(dotIdx);
|
|
||||||
}
|
}
|
||||||
Path targetDir = Paths.get(basePath, "meetings", String.valueOf(meetingId));
|
Files.move(plan.sourcePath(), plan.targetPath(), StandardCopyOption.REPLACE_EXISTING);
|
||||||
Files.createDirectories(targetDir);
|
registerAudioRelocationCompensation(meetingId, plan);
|
||||||
Path targetPath = targetDir.resolve("source_audio" + ext);
|
return plan.relocatedUrl();
|
||||||
Files.move(sourcePath, targetPath, StandardCopyOption.REPLACE_EXISTING);
|
|
||||||
return "/api/static/meetings/" + meetingId + "/source_audio" + ext;
|
|
||||||
} catch (Exception ex) {
|
} catch (Exception ex) {
|
||||||
log.error("Failed to move audio file for meeting {}", meetingId, ex);
|
log.error("Failed to move audio file for meeting {}", meetingId, ex);
|
||||||
throw new RuntimeException("鏂囦欢澶勭悊澶辫触: " + ex.getMessage());
|
throw new RuntimeException("File relocation failed: " + ex.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private AudioRelocationPlan buildAudioRelocationPlan(Long meetingId, String audioUrl) {
|
||||||
|
String fileName = audioUrl.substring(audioUrl.lastIndexOf("/") + 1);
|
||||||
|
String basePath = uploadPath.endsWith("/") ? uploadPath : uploadPath + "/";
|
||||||
|
Path sourcePath = Paths.get(basePath, "audio", fileName);
|
||||||
|
|
||||||
|
String ext = "";
|
||||||
|
int dotIdx = fileName.lastIndexOf('.');
|
||||||
|
if (dotIdx > 0) {
|
||||||
|
ext = fileName.substring(dotIdx);
|
||||||
|
}
|
||||||
|
Path targetDir = Paths.get(basePath, "meetings", String.valueOf(meetingId));
|
||||||
|
Path targetPath = targetDir.resolve("source_audio" + ext);
|
||||||
|
Path backupPath = Files.exists(targetPath)
|
||||||
|
? targetDir.resolve("source_audio" + ext + ".rollback-" + UUID.randomUUID() + ".bak")
|
||||||
|
: null;
|
||||||
|
return new AudioRelocationPlan(
|
||||||
|
sourcePath,
|
||||||
|
targetPath,
|
||||||
|
backupPath,
|
||||||
|
"/api/static/meetings/" + meetingId + "/source_audio" + ext
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void registerAudioRelocationCompensation(Long meetingId, AudioRelocationPlan plan) {
|
||||||
|
if (!TransactionSynchronizationManager.isSynchronizationActive()) {
|
||||||
|
log.warn("Audio relocation compensation skipped because transaction synchronization is inactive, meetingId={}", meetingId);
|
||||||
|
cleanupBackupFile(plan.backupPath(), meetingId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() {
|
||||||
|
@Override
|
||||||
|
public void afterCompletion(int status) {
|
||||||
|
if (status == STATUS_COMMITTED) {
|
||||||
|
cleanupBackupFile(plan.backupPath(), meetingId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
compensateAudioRelocation(meetingId, plan);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private void compensateAudioRelocation(Long meetingId, AudioRelocationPlan plan) {
|
||||||
|
try {
|
||||||
|
if (Files.exists(plan.targetPath())) {
|
||||||
|
Files.createDirectories(plan.sourcePath().getParent());
|
||||||
|
Files.move(plan.targetPath(), plan.sourcePath(), StandardCopyOption.REPLACE_EXISTING);
|
||||||
|
}
|
||||||
|
if (plan.backupPath() != null && Files.exists(plan.backupPath())) {
|
||||||
|
Files.createDirectories(plan.targetPath().getParent());
|
||||||
|
Files.move(plan.backupPath(), plan.targetPath(), StandardCopyOption.REPLACE_EXISTING);
|
||||||
|
}
|
||||||
|
} catch (Exception ex) {
|
||||||
|
log.error("Failed to compensate audio relocation for meeting {}", meetingId, ex);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void cleanupBackupFile(Path backupPath, Long meetingId) {
|
||||||
|
if (backupPath == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
Files.deleteIfExists(backupPath);
|
||||||
|
} catch (Exception ex) {
|
||||||
|
log.warn("Failed to clean audio relocation backup for meeting {}", meetingId, ex);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -196,4 +262,7 @@ public class MeetingDomainSupport {
|
||||||
vo.setAnalysis(meetingSummaryFileService.loadSummaryAnalysis(meeting));
|
vo.setAnalysis(meetingSummaryFileService.loadSummaryAnalysis(meeting));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private record AudioRelocationPlan(Path sourcePath, Path targetPath, Path backupPath, String relocatedUrl) {
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,9 @@ import org.springframework.data.redis.core.StringRedisTemplate;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
import java.time.Duration;
|
import java.time.Duration;
|
||||||
|
import java.util.LinkedHashMap;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
@Service
|
@Service
|
||||||
@Slf4j
|
@Slf4j
|
||||||
|
|
@ -124,6 +127,21 @@ public class RealtimeMeetingSessionStateServiceImpl implements RealtimeMeetingSe
|
||||||
return toStatusVO(state);
|
return toStatusVO(state);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Map<Long, RealtimeMeetingSessionStatusVO> getStatuses(List<Long> meetingIds) {
|
||||||
|
Map<Long, RealtimeMeetingSessionStatusVO> statuses = new LinkedHashMap<>();
|
||||||
|
if (meetingIds == null || meetingIds.isEmpty()) {
|
||||||
|
return statuses;
|
||||||
|
}
|
||||||
|
for (Long meetingId : meetingIds) {
|
||||||
|
if (meetingId == null) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
statuses.put(meetingId, getStatus(meetingId));
|
||||||
|
}
|
||||||
|
return statuses;
|
||||||
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public RealtimeMeetingSessionStatusVO pause(Long meetingId) {
|
public RealtimeMeetingSessionStatusVO pause(Long meetingId) {
|
||||||
RealtimeMeetingSessionState state = getOrCreateState(meetingId);
|
RealtimeMeetingSessionState state = getOrCreateState(meetingId);
|
||||||
|
|
|
||||||
|
|
@ -44,6 +44,24 @@ export interface CreateMeetingCommand {
|
||||||
|
|
||||||
export type MeetingDTO = CreateMeetingCommand;
|
export type MeetingDTO = CreateMeetingCommand;
|
||||||
|
|
||||||
|
export interface CreateRealtimeMeetingCommand {
|
||||||
|
title: string;
|
||||||
|
meetingTime: string;
|
||||||
|
participants: string;
|
||||||
|
tags: string;
|
||||||
|
asrModelId: number;
|
||||||
|
summaryModelId?: number;
|
||||||
|
promptId: number;
|
||||||
|
mode?: string;
|
||||||
|
language?: string;
|
||||||
|
useSpkId?: number;
|
||||||
|
enablePunctuation?: boolean;
|
||||||
|
enableItn?: boolean;
|
||||||
|
enableTextRefine?: boolean;
|
||||||
|
saveAudio?: boolean;
|
||||||
|
hotWords?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
export interface UpdateMeetingBasicCommand {
|
export interface UpdateMeetingBasicCommand {
|
||||||
meetingId: number;
|
meetingId: number;
|
||||||
title?: string;
|
title?: string;
|
||||||
|
|
@ -117,7 +135,7 @@ export interface RealtimeMeetingSessionStatus {
|
||||||
resumeConfig?: RealtimeSocketSessionRequest;
|
resumeConfig?: RealtimeSocketSessionRequest;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const createRealtimeMeeting = (data: CreateMeetingCommand) => {
|
export const createRealtimeMeeting = (data: CreateRealtimeMeetingCommand) => {
|
||||||
return http.post<any, { code: string; data: MeetingVO; msg: string }>(
|
return http.post<any, { code: string; data: MeetingVO; msg: string }>(
|
||||||
"/api/biz/meeting/realtime/start",
|
"/api/biz/meeting/realtime/start",
|
||||||
data
|
data
|
||||||
|
|
@ -137,6 +155,13 @@ export const getRealtimeMeetingSessionStatus = (meetingId: number) => {
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const getRealtimeMeetingSessionStatuses = (meetingIds: number[]) => {
|
||||||
|
return http.post<any, { code: string; data: Record<number, RealtimeMeetingSessionStatus>; msg: string }>(
|
||||||
|
"/api/biz/meeting/realtime/session-status/batch",
|
||||||
|
meetingIds
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
export const pauseRealtimeMeeting = (meetingId: number) => {
|
export const pauseRealtimeMeeting = (meetingId: number) => {
|
||||||
return http.post<any, { code: string; data: RealtimeMeetingSessionStatus; msg: string }>(
|
return http.post<any, { code: string; data: RealtimeMeetingSessionStatus; msg: string }>(
|
||||||
`/api/biz/meeting/${meetingId}/realtime/pause`,
|
`/api/biz/meeting/${meetingId}/realtime/pause`,
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,7 @@ import {
|
||||||
} from '@ant-design/icons';
|
} from '@ant-design/icons';
|
||||||
import { useNavigate, useSearchParams } from 'react-router-dom';
|
import { useNavigate, useSearchParams } from 'react-router-dom';
|
||||||
import { usePermission } from '../../hooks/usePermission';
|
import { usePermission } from '../../hooks/usePermission';
|
||||||
import { getMeetingPage, deleteMeeting, MeetingVO, getMeetingProgress, MeetingProgress, createMeeting, uploadAudio, updateMeetingParticipants, getRealtimeMeetingSessionStatus } from '../../api/business/meeting';
|
import { getMeetingPage, deleteMeeting, MeetingVO, getMeetingProgress, MeetingProgress, createMeeting, uploadAudio, updateMeetingParticipants, getRealtimeMeetingSessionStatus, getRealtimeMeetingSessionStatuses, RealtimeMeetingSessionStatus } from '../../api/business/meeting';
|
||||||
import { getAiModelPage, getAiModelDefault, AiModelVO } from '../../api/business/aimodel';
|
import { getAiModelPage, getAiModelDefault, AiModelVO } from '../../api/business/aimodel';
|
||||||
import { getPromptPage, PromptTemplateVO } from '../../api/business/prompt';
|
import { getPromptPage, PromptTemplateVO } from '../../api/business/prompt';
|
||||||
import { getHotWordPage, HotWordVO } from '../../api/business/hotword';
|
import { getHotWordPage, HotWordVO } from '../../api/business/hotword';
|
||||||
|
|
@ -24,6 +24,37 @@ const { Dragger } = Upload;
|
||||||
const { Option } = Select;
|
const { Option } = Select;
|
||||||
const PAUSED_DISPLAY_STATUS = 5;
|
const PAUSED_DISPLAY_STATUS = 5;
|
||||||
|
|
||||||
|
const applyRealtimeSessionStatus = (item: MeetingVO, sessionStatus?: RealtimeMeetingSessionStatus): MeetingVO => {
|
||||||
|
if (!sessionStatus) {
|
||||||
|
return item;
|
||||||
|
}
|
||||||
|
if (sessionStatus.status === 'PAUSED_EMPTY' || sessionStatus.status === 'PAUSED_RESUMABLE') {
|
||||||
|
return {
|
||||||
|
...item,
|
||||||
|
displayStatus: PAUSED_DISPLAY_STATUS,
|
||||||
|
realtimeSessionStatus: sessionStatus.status
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (sessionStatus.status === 'ACTIVE') {
|
||||||
|
return {
|
||||||
|
...item,
|
||||||
|
displayStatus: 1,
|
||||||
|
realtimeSessionStatus: sessionStatus.status
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (sessionStatus.status === 'IDLE' && !item.audioUrl) {
|
||||||
|
return {
|
||||||
|
...item,
|
||||||
|
displayStatus: 0,
|
||||||
|
realtimeSessionStatus: sessionStatus.status
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
...item,
|
||||||
|
realtimeSessionStatus: sessionStatus.status
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
// --- 进度感知 Hook ---
|
// --- 进度感知 Hook ---
|
||||||
const useMeetingProgress = (meeting: MeetingVO, onComplete?: () => void) => {
|
const useMeetingProgress = (meeting: MeetingVO, onComplete?: () => void) => {
|
||||||
const [progress, setProgress] = useState<MeetingProgress | null>(null);
|
const [progress, setProgress] = useState<MeetingProgress | null>(null);
|
||||||
|
|
@ -421,32 +452,12 @@ const Meetings: React.FC = () => {
|
||||||
const res = await getMeetingPage({ current, size, title: searchTitle, viewType });
|
const res = await getMeetingPage({ current, size, title: searchTitle, viewType });
|
||||||
if (res.data && res.data.data) {
|
if (res.data && res.data.data) {
|
||||||
const records = res.data.data.records || [];
|
const records = res.data.data.records || [];
|
||||||
const withDisplayStatus = await Promise.all(records.map(async (item) => {
|
let statusMap: Record<number, RealtimeMeetingSessionStatus> = {};
|
||||||
try {
|
try {
|
||||||
const sessionRes = await getRealtimeMeetingSessionStatus(item.id);
|
const sessionRes = await getRealtimeMeetingSessionStatuses(records.map((item) => item.id));
|
||||||
const sessionStatus = sessionRes.data?.data;
|
statusMap = sessionRes.data?.data || {};
|
||||||
if (sessionStatus?.status === 'PAUSED_EMPTY' || sessionStatus?.status === 'PAUSED_RESUMABLE') {
|
} catch {}
|
||||||
return {
|
const withDisplayStatus = records.map((item) => applyRealtimeSessionStatus(item, statusMap[item.id]));
|
||||||
...item,
|
|
||||||
displayStatus: PAUSED_DISPLAY_STATUS,
|
|
||||||
realtimeSessionStatus: sessionStatus.status
|
|
||||||
};
|
|
||||||
}
|
|
||||||
if (sessionStatus?.status === 'ACTIVE') {
|
|
||||||
return {
|
|
||||||
...item,
|
|
||||||
displayStatus: 1,
|
|
||||||
realtimeSessionStatus: sessionStatus.status
|
|
||||||
};
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
...item,
|
|
||||||
realtimeSessionStatus: sessionStatus?.status
|
|
||||||
};
|
|
||||||
} catch {
|
|
||||||
return item;
|
|
||||||
}
|
|
||||||
}));
|
|
||||||
setData(withDisplayStatus);
|
setData(withDisplayStatus);
|
||||||
setTotal(res.data.data.total);
|
setTotal(res.data.data.total);
|
||||||
}
|
}
|
||||||
|
|
@ -457,7 +468,12 @@ const Meetings: React.FC = () => {
|
||||||
try {
|
try {
|
||||||
const res = await getRealtimeMeetingSessionStatus(meeting.id);
|
const res = await getRealtimeMeetingSessionStatus(meeting.id);
|
||||||
const sessionStatus = res.data?.data;
|
const sessionStatus = res.data?.data;
|
||||||
if (sessionStatus && (sessionStatus.status === 'PAUSED_EMPTY' || sessionStatus.status === 'PAUSED_RESUMABLE' || sessionStatus.status === 'ACTIVE')) {
|
if (sessionStatus && !meeting.audioUrl && (
|
||||||
|
sessionStatus.status === 'PAUSED_EMPTY'
|
||||||
|
|| sessionStatus.status === 'PAUSED_RESUMABLE'
|
||||||
|
|| sessionStatus.status === 'ACTIVE'
|
||||||
|
|| sessionStatus.status === 'IDLE'
|
||||||
|
)) {
|
||||||
navigate(`/meeting-live-session/${meeting.id}`);
|
navigate(`/meeting-live-session/${meeting.id}`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
@ -544,7 +560,7 @@ const Meetings: React.FC = () => {
|
||||||
setFileList([]);
|
setFileList([]);
|
||||||
setCreateDrawerVisible(true);
|
setCreateDrawerVisible(true);
|
||||||
}} style={{ borderRadius: 8, height: 36, fontWeight: 500 }}>上传文件会议</Button>
|
}} style={{ borderRadius: 8, height: 36, fontWeight: 500 }}>上传文件会议</Button>
|
||||||
{can("meeting:create:realtime") && <Button icon={<AudioOutlined />} onClick={() => navigate('/meeting-live-create')} style={{ borderRadius: 8, height: 36, fontWeight: 500 }}>实时识别会议</Button>}
|
{can("menu:meeting") && <Button icon={<AudioOutlined />} onClick={() => navigate('/meeting-live-create')} style={{ borderRadius: 8, height: 36, fontWeight: 500 }}>实时识别会议</Button>}
|
||||||
</Space>
|
</Space>
|
||||||
</Col>
|
</Col>
|
||||||
</Row>
|
</Row>
|
||||||
|
|
|
||||||
|
|
@ -36,7 +36,7 @@ import { listUsers } from "../../api";
|
||||||
import { getAiModelDefault, getAiModelPage, type AiModelVO } from "../../api/business/aimodel";
|
import { getAiModelDefault, getAiModelPage, type AiModelVO } from "../../api/business/aimodel";
|
||||||
import { getHotWordPage, type HotWordVO } from "../../api/business/hotword";
|
import { getHotWordPage, type HotWordVO } from "../../api/business/hotword";
|
||||||
import { getPromptPage, type PromptTemplateVO } from "../../api/business/prompt";
|
import { getPromptPage, type PromptTemplateVO } from "../../api/business/prompt";
|
||||||
import { createRealtimeMeeting, type MeetingDTO } from "../../api/business/meeting";
|
import { createRealtimeMeeting, type CreateRealtimeMeetingCommand } from "../../api/business/meeting";
|
||||||
import type { SysUser } from "../../types";
|
import type { SysUser } from "../../types";
|
||||||
|
|
||||||
const { Title, Text } = Typography;
|
const { Title, Text } = Typography;
|
||||||
|
|
@ -171,12 +171,18 @@ export default function RealtimeAsr() {
|
||||||
weight: Number(item.weight || 2) / 10,
|
weight: Number(item.weight || 2) / 10,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const payload: MeetingDTO = {
|
const payload: CreateRealtimeMeetingCommand = {
|
||||||
...values,
|
...values,
|
||||||
meetingTime: values.meetingTime.format("YYYY-MM-DD HH:mm:ss"),
|
meetingTime: values.meetingTime.format("YYYY-MM-DD HH:mm:ss"),
|
||||||
participants: values.participants?.join(",") || "",
|
participants: values.participants?.join(",") || "",
|
||||||
tags: values.tags?.join(",") || "",
|
tags: values.tags?.join(",") || "",
|
||||||
|
mode: values.mode || "2pass",
|
||||||
|
language: values.language || "auto",
|
||||||
useSpkId: values.useSpkId ? 1 : 0,
|
useSpkId: values.useSpkId ? 1 : 0,
|
||||||
|
enablePunctuation: values.enablePunctuation !== false,
|
||||||
|
enableItn: values.enableItn !== false,
|
||||||
|
enableTextRefine: !!values.enableTextRefine,
|
||||||
|
saveAudio: !!values.saveAudio,
|
||||||
hotWords: values.hotWords,
|
hotWords: values.hotWords,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -151,7 +151,7 @@ export default function HomePage() {
|
||||||
|
|
||||||
<section className="home-recent-section">
|
<section className="home-recent-section">
|
||||||
<div className="home-section-header">
|
<div className="home-section-header">
|
||||||
<Title level={3}>快速入门 / 最近活动</Title>
|
<Title level={3}>最近</Title>
|
||||||
<Button type="link" onClick={() => navigate("/meetings")} className="home-view-all">
|
<Button type="link" onClick={() => navigate("/meetings")} className="home-view-all">
|
||||||
查看全部 <ArrowRightOutlined />
|
查看全部 <ArrowRightOutlined />
|
||||||
</Button>
|
</Button>
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue