feat: 添加 ASR 任务重新调度功能并优化代理配置
- 在 `AiTaskService` 中添加 `triggerQueuedAsrScheduling` 和 `retryScheduleMeeting` 方法 - 更新多个服务实现类以使用新的 ASR 任务调度方法 - 在前端 `MeetingDetail.tsx` 和 `Meetings.tsx` 中添加重新调度按钮和相关逻辑dev_na
parent
92a12c4c81
commit
8a082732df
|
|
@ -3,9 +3,11 @@ package com.imeeting;
|
||||||
import org.springframework.boot.SpringApplication;
|
import org.springframework.boot.SpringApplication;
|
||||||
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
||||||
import org.springframework.scheduling.annotation.EnableAsync;
|
import org.springframework.scheduling.annotation.EnableAsync;
|
||||||
|
import org.springframework.scheduling.annotation.EnableScheduling;
|
||||||
|
|
||||||
@SpringBootApplication
|
@SpringBootApplication
|
||||||
@EnableAsync
|
@EnableAsync
|
||||||
|
@EnableScheduling
|
||||||
public class ImeetingApplication {
|
public class ImeetingApplication {
|
||||||
public static void main(String[] args) {
|
public static void main(String[] args) {
|
||||||
SpringApplication.run(ImeetingApplication.class, args);
|
SpringApplication.run(ImeetingApplication.class, args);
|
||||||
|
|
|
||||||
|
|
@ -87,6 +87,7 @@ public class MeetingController {
|
||||||
private final MeetingAudioUploadSupport meetingAudioUploadSupport;
|
private final MeetingAudioUploadSupport meetingAudioUploadSupport;
|
||||||
private final MeetingProgressService meetingProgressService;
|
private final MeetingProgressService meetingProgressService;
|
||||||
private final SysParamService sysParamService;
|
private final SysParamService sysParamService;
|
||||||
|
private final AiTaskService aiTaskService;
|
||||||
private AiTaskService compatibilityAiTaskService;
|
private AiTaskService compatibilityAiTaskService;
|
||||||
|
|
||||||
@Autowired
|
@Autowired
|
||||||
|
|
@ -100,7 +101,8 @@ public class MeetingController {
|
||||||
RealtimeMeetingSessionStateService realtimeMeetingSessionStateService,
|
RealtimeMeetingSessionStateService realtimeMeetingSessionStateService,
|
||||||
MeetingAudioUploadSupport meetingAudioUploadSupport,
|
MeetingAudioUploadSupport meetingAudioUploadSupport,
|
||||||
MeetingProgressService meetingProgressService,
|
MeetingProgressService meetingProgressService,
|
||||||
SysParamService sysParamService) {
|
SysParamService sysParamService,
|
||||||
|
AiTaskService aiTaskService) {
|
||||||
this.meetingQueryService = meetingQueryService;
|
this.meetingQueryService = meetingQueryService;
|
||||||
this.meetingCommandService = meetingCommandService;
|
this.meetingCommandService = meetingCommandService;
|
||||||
this.meetingAccessService = meetingAccessService;
|
this.meetingAccessService = meetingAccessService;
|
||||||
|
|
@ -112,6 +114,7 @@ public class MeetingController {
|
||||||
this.meetingAudioUploadSupport = meetingAudioUploadSupport;
|
this.meetingAudioUploadSupport = meetingAudioUploadSupport;
|
||||||
this.meetingProgressService = meetingProgressService;
|
this.meetingProgressService = meetingProgressService;
|
||||||
this.sysParamService = sysParamService;
|
this.sysParamService = sysParamService;
|
||||||
|
this.aiTaskService = aiTaskService;
|
||||||
}
|
}
|
||||||
|
|
||||||
public MeetingController(MeetingQueryService meetingQueryService,
|
public MeetingController(MeetingQueryService meetingQueryService,
|
||||||
|
|
@ -137,7 +140,8 @@ public class MeetingController {
|
||||||
realtimeMeetingSessionStateService,
|
realtimeMeetingSessionStateService,
|
||||||
meetingAudioUploadSupport,
|
meetingAudioUploadSupport,
|
||||||
new RedisOnlyMeetingProgressServiceAdapter(redisTemplate, new com.fasterxml.jackson.databind.ObjectMapper()),
|
new RedisOnlyMeetingProgressServiceAdapter(redisTemplate, new com.fasterxml.jackson.databind.ObjectMapper()),
|
||||||
sysParamService
|
sysParamService,
|
||||||
|
unusedAiTaskService
|
||||||
);
|
);
|
||||||
this.compatibilityAiTaskService = unusedAiTaskService;
|
this.compatibilityAiTaskService = unusedAiTaskService;
|
||||||
}
|
}
|
||||||
|
|
@ -203,7 +207,18 @@ public class MeetingController {
|
||||||
}
|
}
|
||||||
return ApiResponse.ok(result);
|
return ApiResponse.ok(result);
|
||||||
}
|
}
|
||||||
@Operation(summary = "上传会议音频")
|
|
||||||
|
@Operation(summary = "重新调度排队中的会议 ASR 任务")
|
||||||
|
@PostMapping("/{id}/retry-schedule")
|
||||||
|
@PreAuthorize("isAuthenticated()")
|
||||||
|
public ApiResponse<Boolean> retrySchedule(@PathVariable Long id) {
|
||||||
|
LoginUser loginUser = currentLoginUser();
|
||||||
|
Meeting meeting = meetingAccessService.requireMeeting(id);
|
||||||
|
meetingAccessService.assertCanEditMeeting(meeting, loginUser);
|
||||||
|
return ApiResponse.ok(aiTaskService.retryScheduleMeeting(id));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Operation(summary = "上传会议音频")
|
||||||
@PostMapping("/upload")
|
@PostMapping("/upload")
|
||||||
@PreAuthorize("isAuthenticated()")
|
@PreAuthorize("isAuthenticated()")
|
||||||
public ApiResponse<String> upload(@RequestParam("file") MultipartFile file) throws IOException {
|
public ApiResponse<String> upload(@RequestParam("file") MultipartFile file) throws IOException {
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,6 @@ public class MeetingTaskDispatchListener {
|
||||||
|
|
||||||
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
|
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
|
||||||
public void onMeetingCreated(MeetingCreatedEvent event) {
|
public void onMeetingCreated(MeetingCreatedEvent event) {
|
||||||
aiTaskService.dispatchTasks(event.getMeetingId(), event.getTenantId(), event.getUserId());
|
aiTaskService.triggerQueuedAsrScheduling();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -301,13 +301,13 @@ public class LegacyMeetingAdapterServiceImpl implements LegacyMeetingAdapterServ
|
||||||
|
|
||||||
private void dispatchTasksAfterCommit(Long meetingId, Long tenantId, Long userId) {
|
private void dispatchTasksAfterCommit(Long meetingId, Long tenantId, Long userId) {
|
||||||
if (!TransactionSynchronizationManager.isSynchronizationActive()) {
|
if (!TransactionSynchronizationManager.isSynchronizationActive()) {
|
||||||
aiTaskService.dispatchTasks(meetingId, tenantId, userId);
|
aiTaskService.triggerQueuedAsrScheduling();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() {
|
TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() {
|
||||||
@Override
|
@Override
|
||||||
public void afterCommit() {
|
public void afterCommit() {
|
||||||
aiTaskService.dispatchTasks(meetingId, tenantId, userId);
|
aiTaskService.triggerQueuedAsrScheduling();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,8 @@ import com.imeeting.entity.biz.AiTask;
|
||||||
|
|
||||||
public interface AiTaskService extends IService<AiTask> {
|
public interface AiTaskService extends IService<AiTask> {
|
||||||
void dispatchTasks(Long meetingId, Long tenantId, Long userId);
|
void dispatchTasks(Long meetingId, Long tenantId, Long userId);
|
||||||
|
void triggerQueuedAsrScheduling();
|
||||||
|
boolean retryScheduleMeeting(Long meetingId);
|
||||||
void dispatchChapterTask(Long meetingId, Long tenantId, Long userId);
|
void dispatchChapterTask(Long meetingId, Long tenantId, Long userId);
|
||||||
void dispatchSummaryTask(Long meetingId, Long tenantId, Long userId);
|
void dispatchSummaryTask(Long meetingId, Long tenantId, Long userId);
|
||||||
void reconcileMeetingStatus(Long meetingId);
|
void reconcileMeetingStatus(Long meetingId);
|
||||||
|
|
|
||||||
|
|
@ -40,6 +40,7 @@ import org.springframework.beans.factory.annotation.Value;
|
||||||
import org.springframework.context.annotation.Lazy;
|
import org.springframework.context.annotation.Lazy;
|
||||||
import org.springframework.data.redis.core.StringRedisTemplate;
|
import org.springframework.data.redis.core.StringRedisTemplate;
|
||||||
import org.springframework.scheduling.annotation.Async;
|
import org.springframework.scheduling.annotation.Async;
|
||||||
|
import org.springframework.scheduling.annotation.Scheduled;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
import org.springframework.transaction.annotation.Transactional;
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
import org.springframework.transaction.support.TransactionSynchronization;
|
import org.springframework.transaction.support.TransactionSynchronization;
|
||||||
|
|
@ -190,6 +191,30 @@ public class AiTaskServiceImpl extends ServiceImpl<AiTaskMapper, AiTask> impleme
|
||||||
asrTaskExecutor.execute(task);
|
asrTaskExecutor.execute(task);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void triggerQueuedAsrScheduling() {
|
||||||
|
scheduleQueuedAsrTasks();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public boolean retryScheduleMeeting(Long meetingId) {
|
||||||
|
if (meetingId == null) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
AiTask asrTask = findLatestTask(meetingId, "ASR");
|
||||||
|
if (asrTask == null || !Integer.valueOf(0).equals(asrTask.getStatus())) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (asrTask.getQueuedAt() == null) {
|
||||||
|
asrTask.setQueuedAt(LocalDateTime.now());
|
||||||
|
updateById(asrTask);
|
||||||
|
}
|
||||||
|
meetingProgressService.markQueued(meetingId, asrTask, 1, "已触发重新调度");
|
||||||
|
triggerQueuedAsrScheduling();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
private void doDispatchTasks(Long meetingId) {
|
private void doDispatchTasks(Long meetingId) {
|
||||||
String lockKey = RedisKeys.meetingPollingLockKey(meetingId);
|
String lockKey = RedisKeys.meetingPollingLockKey(meetingId);
|
||||||
Boolean acquired = redisValueSupport.setIfAbsent(lockKey, "locked", 30, TimeUnit.MINUTES);
|
Boolean acquired = redisValueSupport.setIfAbsent(lockKey, "locked", 30, TimeUnit.MINUTES);
|
||||||
|
|
@ -218,10 +243,8 @@ public class AiTaskServiceImpl extends ServiceImpl<AiTaskMapper, AiTask> impleme
|
||||||
asrTask.setQueuedAt(LocalDateTime.now());
|
asrTask.setQueuedAt(LocalDateTime.now());
|
||||||
this.updateById(asrTask);
|
this.updateById(asrTask);
|
||||||
}
|
}
|
||||||
if (!claimQueuedAsrTask(asrTask)) {
|
meetingProgressService.markQueued(meetingId, asrTask, 1, "ASR queued and waiting for execution");
|
||||||
meetingProgressService.markQueued(meetingId, asrTask, 1, "ASR queued and waiting for execution");
|
return;
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -340,9 +363,14 @@ public class AiTaskServiceImpl extends ServiceImpl<AiTaskMapper, AiTask> impleme
|
||||||
}
|
}
|
||||||
Long asrModelId = extractAsrModelId(asrTask);
|
Long asrModelId = extractAsrModelId(asrTask);
|
||||||
String externalTaskId = extractExternalTaskId(asrTask);
|
String externalTaskId = extractExternalTaskId(asrTask);
|
||||||
if (asrModelId == null || externalTaskId == null || externalTaskId.isBlank()) {
|
// Freshly re-scheduled tasks are also claimed as RUNNING before they get a new external task id.
|
||||||
requeueAsrTask(asrTask, "恢复时缺少有效外部 ASR 任务,已重新排队", true);
|
// In that case we should continue into processAsrTask() and submit a brand-new ASR job instead of
|
||||||
return false;
|
// treating them as a broken recovery candidate and requeueing forever.
|
||||||
|
if (externalTaskId == null || externalTaskId.isBlank()) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (asrModelId == null) {
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
AiModelVO asrModel = aiModelService.getModelById(asrModelId, "ASR");
|
AiModelVO asrModel = aiModelService.getModelById(asrModelId, "ASR");
|
||||||
if (asrModel == null || !canResumeAsrTask(asrModel, meeting.getId(), externalTaskId)) {
|
if (asrModel == null || !canResumeAsrTask(asrModel, meeting.getId(), externalTaskId)) {
|
||||||
|
|
@ -352,63 +380,16 @@ public class AiTaskServiceImpl extends ServiceImpl<AiTaskMapper, AiTask> impleme
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
private boolean claimQueuedAsrTask(AiTask task) {
|
|
||||||
if (task == null || task.getId() == null || !Integer.valueOf(0).equals(task.getStatus())) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
if (getBaseMapper() == null) {
|
|
||||||
task.setStatus(1);
|
|
||||||
task.setStartedAt(LocalDateTime.now());
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
String scheduleLockKey = RedisKeys.meetingAsrScheduleLockKey();
|
|
||||||
Boolean acquired = redisValueSupport.setIfAbsent(scheduleLockKey, "locked", 30, TimeUnit.SECONDS);
|
|
||||||
if (Boolean.FALSE.equals(acquired)) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
int maxConcurrent = resolveAsrMaxConcurrent();
|
|
||||||
long runningCount = count(new LambdaQueryWrapper<AiTask>()
|
|
||||||
.eq(AiTask::getTaskType, "ASR")
|
|
||||||
.eq(AiTask::getStatus, 1));
|
|
||||||
if (runningCount >= maxConcurrent) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
int available = (int) Math.max(1, maxConcurrent - runningCount);
|
|
||||||
List<AiTask> dispatchable = list(new LambdaQueryWrapper<AiTask>()
|
|
||||||
.eq(AiTask::getTaskType, "ASR")
|
|
||||||
.eq(AiTask::getStatus, 0)
|
|
||||||
.orderByAsc(AiTask::getQueuedAt)
|
|
||||||
.orderByAsc(AiTask::getId)
|
|
||||||
.last("LIMIT " + available));
|
|
||||||
boolean eligible = dispatchable.stream().anyMatch(item -> Objects.equals(item.getId(), task.getId()));
|
|
||||||
if (!eligible) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
LocalDateTime now = LocalDateTime.now();
|
|
||||||
boolean claimed = update(new LambdaUpdateWrapper<AiTask>()
|
|
||||||
.eq(AiTask::getId, task.getId())
|
|
||||||
.eq(AiTask::getStatus, 0)
|
|
||||||
.set(AiTask::getStatus, 1)
|
|
||||||
.set(AiTask::getStartedAt, now)
|
|
||||||
.set(AiTask::getCompletedAt, null)
|
|
||||||
.set(AiTask::getErrorMsg, null));
|
|
||||||
if (claimed) {
|
|
||||||
task.setStatus(1);
|
|
||||||
task.setStartedAt(now);
|
|
||||||
meetingProgressService.markStage(task.getMeetingId(), task, 1, MeetingProgressStage.ASR_SUBMITTED, 5, "ASR 任务已开始执行", 0);
|
|
||||||
refreshQueuedAsrProgress();
|
|
||||||
}
|
|
||||||
return claimed;
|
|
||||||
} finally {
|
|
||||||
redisValueSupport.delete(scheduleLockKey);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private void scheduleQueuedAsrTasks() {
|
private void scheduleQueuedAsrTasks() {
|
||||||
if (getBaseMapper() == null) {
|
if (getBaseMapper() == null) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
long queuedCount = count(new LambdaQueryWrapper<AiTask>()
|
||||||
|
.eq(AiTask::getTaskType, "ASR")
|
||||||
|
.eq(AiTask::getStatus, 0));
|
||||||
|
if (queuedCount <= 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
String scheduleLockKey = RedisKeys.meetingAsrScheduleLockKey();
|
String scheduleLockKey = RedisKeys.meetingAsrScheduleLockKey();
|
||||||
Boolean acquired = redisValueSupport.setIfAbsent(scheduleLockKey, "locked", 30, TimeUnit.SECONDS);
|
Boolean acquired = redisValueSupport.setIfAbsent(scheduleLockKey, "locked", 30, TimeUnit.SECONDS);
|
||||||
if (Boolean.FALSE.equals(acquired)) {
|
if (Boolean.FALSE.equals(acquired)) {
|
||||||
|
|
@ -423,15 +404,21 @@ public class AiTaskServiceImpl extends ServiceImpl<AiTaskMapper, AiTask> impleme
|
||||||
if (available <= 0) {
|
if (available <= 0) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
refreshQueuedAsrProgress();
|
|
||||||
List<AiTask> queuedTasks = list(new LambdaQueryWrapper<AiTask>()
|
List<AiTask> queuedTasks = list(new LambdaQueryWrapper<AiTask>()
|
||||||
.eq(AiTask::getTaskType, "ASR")
|
.eq(AiTask::getTaskType, "ASR")
|
||||||
.eq(AiTask::getStatus, 0)
|
.eq(AiTask::getStatus, 0)
|
||||||
.orderByAsc(AiTask::getQueuedAt)
|
.orderByAsc(AiTask::getQueuedAt)
|
||||||
.orderByAsc(AiTask::getId)
|
.orderByAsc(AiTask::getId)
|
||||||
.last("LIMIT " + available));
|
.last("LIMIT " + available));
|
||||||
|
List<AiTask> claimedTasks = new ArrayList<>();
|
||||||
for (AiTask queuedTask : queuedTasks) {
|
for (AiTask queuedTask : queuedTasks) {
|
||||||
Meeting queuedMeeting = meetingMapper.selectById(queuedTask.getMeetingId());
|
if (claimQueuedAsrTaskForScheduling(queuedTask)) {
|
||||||
|
claimedTasks.add(queuedTask);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
refreshQueuedAsrProgress();
|
||||||
|
for (AiTask queuedTask : claimedTasks) {
|
||||||
|
Meeting queuedMeeting = meetingMapper.selectByIdIgnoreTenant(queuedTask.getMeetingId());
|
||||||
if (queuedMeeting == null) {
|
if (queuedMeeting == null) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
@ -456,6 +443,27 @@ public class AiTaskServiceImpl extends ServiceImpl<AiTaskMapper, AiTask> impleme
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private boolean claimQueuedAsrTaskForScheduling(AiTask task) {
|
||||||
|
if (task == null || task.getId() == null || !Integer.valueOf(0).equals(task.getStatus())) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
LocalDateTime now = LocalDateTime.now();
|
||||||
|
boolean claimed = update(new LambdaUpdateWrapper<AiTask>()
|
||||||
|
.eq(AiTask::getId, task.getId())
|
||||||
|
.eq(AiTask::getStatus, 0)
|
||||||
|
.set(AiTask::getStatus, 1)
|
||||||
|
.set(AiTask::getStartedAt, now)
|
||||||
|
.set(AiTask::getCompletedAt, null)
|
||||||
|
.set(AiTask::getErrorMsg, null));
|
||||||
|
if (!claimed) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
task.setStatus(1);
|
||||||
|
task.setStartedAt(now);
|
||||||
|
meetingProgressService.markStage(task.getMeetingId(), task, 1, MeetingProgressStage.ASR_SUBMITTED, 5, "ASR 任务已开始执行", 0);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
private int resolveAsrMaxConcurrent() {
|
private int resolveAsrMaxConcurrent() {
|
||||||
if (sysParamService == null) {
|
if (sysParamService == null) {
|
||||||
return 2;
|
return 2;
|
||||||
|
|
@ -489,6 +497,7 @@ public class AiTaskServiceImpl extends ServiceImpl<AiTaskMapper, AiTask> impleme
|
||||||
updateById(task);
|
updateById(task);
|
||||||
meetingProgressService.markQueued(task.getMeetingId(), task, 1, reason == null || reason.isBlank() ? "已重新进入 ASR 队列" : reason);
|
meetingProgressService.markQueued(task.getMeetingId(), task, 1, reason == null || reason.isBlank() ? "已重新进入 ASR 队列" : reason);
|
||||||
refreshQueuedAsrProgress();
|
refreshQueuedAsrProgress();
|
||||||
|
triggerQueuedAsrScheduling();
|
||||||
}
|
}
|
||||||
|
|
||||||
private Long extractAsrModelId(AiTask task) {
|
private Long extractAsrModelId(AiTask task) {
|
||||||
|
|
@ -666,14 +675,14 @@ public class AiTaskServiceImpl extends ServiceImpl<AiTaskMapper, AiTask> impleme
|
||||||
}
|
}
|
||||||
|
|
||||||
private String submitAsrTask(Meeting meeting, AiTask taskRecord, AiModelVO asrModel, String submitUrl) throws Exception {
|
private String submitAsrTask(Meeting meeting, AiTask taskRecord, AiModelVO asrModel, String submitUrl) throws Exception {
|
||||||
updateProgress(meeting.getId(), 5, "重新提交任务...", 0);
|
updateProgress(meeting.getId(), 5, "提交任务...", 0);
|
||||||
Map<String, Object> req = buildAsrRequest(meeting, taskRecord, asrModel);
|
Map<String, Object> req = buildAsrRequest(meeting, taskRecord, asrModel);
|
||||||
taskRecord.setRequestData(req);
|
taskRecord.setRequestData(req);
|
||||||
this.updateById(taskRecord);
|
this.updateById(taskRecord);
|
||||||
|
|
||||||
String respBody = postJson(submitUrl, req, asrModel.getApiKey());
|
String respBody = postJson(submitUrl, req, asrModel.getApiKey());
|
||||||
JsonNode submitNode = objectMapper.readTree(respBody);
|
JsonNode submitNode = objectMapper.readTree(respBody);
|
||||||
if (submitNode.path("code").asInt() != 0) {
|
if (submitNode.path("code")==null||submitNode.path("code").asInt() != 0) {
|
||||||
updateAiTaskFail(taskRecord, "ASR识别失败 " + respBody);
|
updateAiTaskFail(taskRecord, "ASR识别失败 " + respBody);
|
||||||
throw new RuntimeException("ASR识别失败: " + firstNonBlank(
|
throw new RuntimeException("ASR识别失败: " + firstNonBlank(
|
||||||
submitNode.path("message").asText(""),
|
submitNode.path("message").asText(""),
|
||||||
|
|
|
||||||
|
|
@ -409,7 +409,7 @@ public class MeetingCommandServiceImpl implements MeetingCommandService {
|
||||||
prepareOfflineReprocessTasks(meetingId, currentStatus);
|
prepareOfflineReprocessTasks(meetingId, currentStatus);
|
||||||
realtimeMeetingSessionStateService.clear(meetingId);
|
realtimeMeetingSessionStateService.clear(meetingId);
|
||||||
updateMeetingProgress(meetingId, 0, "正在转入离线音频识别流程...", 0);
|
updateMeetingProgress(meetingId, 0, "正在转入离线音频识别流程...", 0);
|
||||||
aiTaskService.dispatchTasks(meetingId, meeting.getTenantId(), meeting.getCreatorId());
|
aiTaskService.triggerQueuedAsrScheduling();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1196,13 +1196,13 @@ public class MeetingCommandServiceImpl implements MeetingCommandService {
|
||||||
|
|
||||||
private void dispatchTasksAfterCommit(Long meetingId, Long tenantId, Long userId) {
|
private void dispatchTasksAfterCommit(Long meetingId, Long tenantId, Long userId) {
|
||||||
if (!TransactionSynchronizationManager.isSynchronizationActive()) {
|
if (!TransactionSynchronizationManager.isSynchronizationActive()) {
|
||||||
aiTaskService.dispatchTasks(meetingId, tenantId, userId);
|
aiTaskService.triggerQueuedAsrScheduling();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() {
|
TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() {
|
||||||
@Override
|
@Override
|
||||||
public void afterCommit() {
|
public void afterCommit() {
|
||||||
aiTaskService.dispatchTasks(meetingId, tenantId, userId);
|
aiTaskService.triggerQueuedAsrScheduling();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,33 @@
|
||||||
|
package com.imeeting.task;
|
||||||
|
|
||||||
|
|
||||||
|
import com.imeeting.service.biz.AiTaskService;
|
||||||
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
|
import org.springframework.scheduling.annotation.Scheduled;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @author : ch
|
||||||
|
* @version : 1.0
|
||||||
|
* @ClassName : AiTaskConfig
|
||||||
|
* @Description :
|
||||||
|
* @DATE : Created in 10:29 2026/6/1
|
||||||
|
* <pre> Copyright: Copyright(c) 2026 </pre>
|
||||||
|
* <pre> Company : 紫光汇智信息技术有限公司 </pre>
|
||||||
|
* Modification History:
|
||||||
|
* Date Author Version Discription
|
||||||
|
* --------------------------------------------------------------------------
|
||||||
|
* 2026/06/01 ch 1.0 Why & What is modified: <修改原因描述> *
|
||||||
|
*/
|
||||||
|
@Component
|
||||||
|
public class AiTaskConfig {
|
||||||
|
@Autowired
|
||||||
|
private AiTaskService aiTaskService;
|
||||||
|
@Scheduled(
|
||||||
|
fixedDelayString = "${imeeting.asr-schedule-fixed-delay-ms:15000}",
|
||||||
|
initialDelayString = "${imeeting.asr-schedule-initial-delay-ms:15000}"
|
||||||
|
)
|
||||||
|
public void compensateQueuedAsrScheduling() {
|
||||||
|
aiTaskService.triggerQueuedAsrScheduling();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -442,6 +442,7 @@ export interface MeetingProgress {
|
||||||
updateAt: number;
|
updateAt: number;
|
||||||
eta?: number;
|
eta?: number;
|
||||||
queueAheadCount?: number;
|
queueAheadCount?: number;
|
||||||
|
queuedAt?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const getMeetingProgress = (id: number, options?: { suppressErrorToast?: boolean }) => {
|
export const getMeetingProgress = (id: number, options?: { suppressErrorToast?: boolean }) => {
|
||||||
|
|
@ -463,6 +464,12 @@ export const getMeetingProgressBatch = (ids: number[], options?: { suppressError
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const retryScheduleMeeting = (id: number) => {
|
||||||
|
return http.post<{ code: string; data: boolean; msg: string }>(
|
||||||
|
`/api/biz/meeting/${id}/retry-schedule`
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
export const downloadMeetingSummary = (id: number, format: "pdf" | "word") => {
|
export const downloadMeetingSummary = (id: number, format: "pdf" | "word") => {
|
||||||
const token = localStorage.getItem("accessToken");
|
const token = localStorage.getItem("accessToken");
|
||||||
return axios.get(`/api/biz/meeting/${id}/summary/export`, {
|
return axios.get(`/api/biz/meeting/${id}/summary/export`, {
|
||||||
|
|
|
||||||
|
|
@ -43,6 +43,7 @@ import {
|
||||||
retryMeetingChapter,
|
retryMeetingChapter,
|
||||||
retryMeetingSummary,
|
retryMeetingSummary,
|
||||||
retryMeetingTranscription,
|
retryMeetingTranscription,
|
||||||
|
retryScheduleMeeting,
|
||||||
updateMeetingBasic,
|
updateMeetingBasic,
|
||||||
updateMeetingTranscript,
|
updateMeetingTranscript,
|
||||||
updateMeetingSummary,
|
updateMeetingSummary,
|
||||||
|
|
@ -106,6 +107,8 @@ type MeetingStateNotice = {
|
||||||
scope: 'summary' | 'catalog' | 'global';
|
scope: 'summary' | 'catalog' | 'global';
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const QUEUED_RETRY_THRESHOLD_MS = 2 * 60 * 1000;
|
||||||
|
|
||||||
const ANALYSIS_EMPTY: MeetingAnalysis = {
|
const ANALYSIS_EMPTY: MeetingAnalysis = {
|
||||||
overview: '',
|
overview: '',
|
||||||
keywords: [],
|
keywords: [],
|
||||||
|
|
@ -1026,6 +1029,27 @@ const MeetingDetail: React.FC = () => {
|
||||||
const canRetryTranscription = isOwner && meeting?.status === 4 && transcripts.length === 0 && !!meeting?.audioUrl;
|
const canRetryTranscription = isOwner && meeting?.status === 4 && transcripts.length === 0 && !!meeting?.audioUrl;
|
||||||
const canRetryFailedSummaryTask = isOwner && meeting?.latestSummaryAttemptStatus === 3 && meeting?.status !== 2;
|
const canRetryFailedSummaryTask = isOwner && meeting?.latestSummaryAttemptStatus === 3 && meeting?.status !== 2;
|
||||||
const canRetryFailedChapterTask = isOwner && meeting?.latestChapterAttemptStatus === 3 && meeting?.status !== 2;
|
const canRetryFailedChapterTask = isOwner && meeting?.latestChapterAttemptStatus === 3 && meeting?.status !== 2;
|
||||||
|
|
||||||
|
const canRetrySchedule = isOwner && meeting?.status === 0 && (!generationProgress || generationProgress.percent <= 0) && !!generationProgress?.queuedAt && dayjs().diff(dayjs(generationProgress.queuedAt)) >= QUEUED_RETRY_THRESHOLD_MS;
|
||||||
|
|
||||||
|
const handleRetrySchedule = async () => {
|
||||||
|
if (!meeting) return;
|
||||||
|
setActionLoading(true);
|
||||||
|
try {
|
||||||
|
const response = await retryScheduleMeeting(meeting.id);
|
||||||
|
if (response.data?.data) {
|
||||||
|
message.success("已触发重新调度");
|
||||||
|
} else {
|
||||||
|
message.info("当前没有可重新调度的排队任务");
|
||||||
|
}
|
||||||
|
await fetchData(meeting.id);
|
||||||
|
} catch {
|
||||||
|
message.error("重新调度失败");
|
||||||
|
} finally {
|
||||||
|
setActionLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const hasSummaryContent = Boolean(meeting?.summaryContent?.trim());
|
const hasSummaryContent = Boolean(meeting?.summaryContent?.trim());
|
||||||
const hasCatalogContent = catalogChapterLinks.length > 0;
|
const hasCatalogContent = catalogChapterLinks.length > 0;
|
||||||
const generationFailureNotice = useMemo<MeetingStateNotice | null>(() => {
|
const generationFailureNotice = useMemo<MeetingStateNotice | null>(() => {
|
||||||
|
|
@ -2016,6 +2040,11 @@ const MeetingDetail: React.FC = () => {
|
||||||
<Button icon={<LeftOutlined />} onClick={() => navigate('/meetings')}>
|
<Button icon={<LeftOutlined />} onClick={() => navigate('/meetings')}>
|
||||||
返回
|
返回
|
||||||
</Button>
|
</Button>
|
||||||
|
{canRetrySchedule && (
|
||||||
|
<Button icon={<SyncOutlined />} onClick={handleRetrySchedule} loading={actionLoading}>
|
||||||
|
重新调度
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
{canRetrySummary && (
|
{canRetrySummary && (
|
||||||
<Button icon={<SyncOutlined />} onClick={handleOpenSummaryDrawer} disabled={actionLoading}>
|
<Button icon={<SyncOutlined />} onClick={handleOpenSummaryDrawer} disabled={actionLoading}>
|
||||||
重新总结
|
重新总结
|
||||||
|
|
|
||||||
|
|
@ -55,6 +55,7 @@ import {
|
||||||
type MeetingProgress,
|
type MeetingProgress,
|
||||||
type MeetingVO,
|
type MeetingVO,
|
||||||
type RealtimeMeetingSessionStatus,
|
type RealtimeMeetingSessionStatus,
|
||||||
|
retryScheduleMeeting,
|
||||||
updateMeetingParticipants,
|
updateMeetingParticipants,
|
||||||
} from "../../api/business/meeting";
|
} from "../../api/business/meeting";
|
||||||
import { MeetingCreateDrawer, type MeetingCreateType } from "../../components/business/MeetingCreateDrawer";
|
import { MeetingCreateDrawer, type MeetingCreateType } from "../../components/business/MeetingCreateDrawer";
|
||||||
|
|
@ -72,6 +73,7 @@ const PAUSED_DISPLAY_STATUS = 5;
|
||||||
const REALTIME_ACTIVE_DISPLAY_STATUS = 6;
|
const REALTIME_ACTIVE_DISPLAY_STATUS = 6;
|
||||||
const REALTIME_IDLE_DISPLAY_STATUS = 7;
|
const REALTIME_IDLE_DISPLAY_STATUS = 7;
|
||||||
const ALL_STATUS_FILTER = "all";
|
const ALL_STATUS_FILTER = "all";
|
||||||
|
const QUEUED_RETRY_THRESHOLD_MS = 2 * 60 * 1000;
|
||||||
const MEETING_STATUS_FILTER_OPTIONS = [
|
const MEETING_STATUS_FILTER_OPTIONS = [
|
||||||
{ label: "全部状态", value: ALL_STATUS_FILTER, color: "#8c8c8c", bgColor: "#f5f5f5" },
|
{ label: "全部状态", value: ALL_STATUS_FILTER, color: "#8c8c8c", bgColor: "#f5f5f5" },
|
||||||
{ label: "排队中", value: "0", color: "#8c8c8c", bgColor: "#f5f5f5" },
|
{ label: "排队中", value: "0", color: "#8c8c8c", bgColor: "#f5f5f5" },
|
||||||
|
|
@ -145,6 +147,16 @@ const canManageMeeting = (meeting: MeetingVO) => {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const canRetryQueuedMeeting = (meeting: MeetingVO, progress: MeetingProgress | null) => {
|
||||||
|
if (!canManageMeeting(meeting) || getEffectiveStatus(meeting, progress) !== 0) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (!progress?.queuedAt) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return dayjs().diff(dayjs(progress.queuedAt)) >= QUEUED_RETRY_THRESHOLD_MS;
|
||||||
|
};
|
||||||
|
|
||||||
const applyRealtimeSessionStatus = (item: MeetingVO, sessionStatus?: RealtimeMeetingSessionStatus): MeetingVO => {
|
const applyRealtimeSessionStatus = (item: MeetingVO, sessionStatus?: RealtimeMeetingSessionStatus): MeetingVO => {
|
||||||
if (!sessionStatus) {
|
if (!sessionStatus) {
|
||||||
return item;
|
return item;
|
||||||
|
|
@ -225,9 +237,10 @@ const MeetingCardItem: React.FC<{
|
||||||
item: MeetingVO;
|
item: MeetingVO;
|
||||||
config: { text: string; color: string; bgColor: string };
|
config: { text: string; color: string; bgColor: string };
|
||||||
progress: MeetingProgress | null;
|
progress: MeetingProgress | null;
|
||||||
fetchData: () => void;
|
|
||||||
onOpenMeeting: (meeting: MeetingVO) => void;
|
onOpenMeeting: (meeting: MeetingVO) => void;
|
||||||
}> = ({ item, config, progress, fetchData, onOpenMeeting }) => {
|
onRetrySchedule: (meeting: MeetingVO) => void;
|
||||||
|
retrying: boolean;
|
||||||
|
}> = ({ item, config, progress, onOpenMeeting, onRetrySchedule, retrying }) => {
|
||||||
const effectiveStatus = getEffectiveStatus(item, progress);
|
const effectiveStatus = getEffectiveStatus(item, progress);
|
||||||
const isProcessing = shouldTrackGenerationProgress(item);
|
const isProcessing = shouldTrackGenerationProgress(item);
|
||||||
const isPaused = effectiveStatus === PAUSED_DISPLAY_STATUS;
|
const isPaused = effectiveStatus === PAUSED_DISPLAY_STATUS;
|
||||||
|
|
@ -235,6 +248,7 @@ const MeetingCardItem: React.FC<{
|
||||||
const isRealtimeIdle = effectiveStatus === REALTIME_IDLE_DISPLAY_STATUS;
|
const isRealtimeIdle = effectiveStatus === REALTIME_IDLE_DISPLAY_STATUS;
|
||||||
const isCrossPlatformRealtime = (isPaused || isRealtimeActive || isRealtimeIdle) && !canControlRealtimeFromCurrentPlatform(item);
|
const isCrossPlatformRealtime = (isPaused || isRealtimeActive || isRealtimeIdle) && !canControlRealtimeFromCurrentPlatform(item);
|
||||||
const crossPlatformHint = `在${getRealtimeSourceLabel(item)}继续`;
|
const crossPlatformHint = `在${getRealtimeSourceLabel(item)}继续`;
|
||||||
|
const canRetry = canRetryQueuedMeeting(item, progress);
|
||||||
|
|
||||||
const sourceColor = item.meetingSource === "ANDROID" ? "#10b981" : "#3b82f6";
|
const sourceColor = item.meetingSource === "ANDROID" ? "#10b981" : "#3b82f6";
|
||||||
|
|
||||||
|
|
@ -325,6 +339,20 @@ const MeetingCardItem: React.FC<{
|
||||||
{isProcessing ? (progress?.message || "深度分析中...") : (isCrossPlatformRealtime ? crossPlatformHint : config.text)}
|
{isProcessing ? (progress?.message || "深度分析中...") : (isCrossPlatformRealtime ? crossPlatformHint : config.text)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
{canRetry && (
|
||||||
|
<Button
|
||||||
|
type="link"
|
||||||
|
size="small"
|
||||||
|
loading={retrying}
|
||||||
|
onClick={(event) => {
|
||||||
|
event.stopPropagation();
|
||||||
|
onRetrySchedule(item);
|
||||||
|
}}
|
||||||
|
style={{ paddingInline: 0, height: "auto" }}
|
||||||
|
>
|
||||||
|
重新调度
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div style={{ display: "flex", flexWrap: "wrap", gap: "12px", color: "#8c8c8c", fontSize: "12px" }}>
|
<div style={{ display: "flex", flexWrap: "wrap", gap: "12px", color: "#8c8c8c", fontSize: "12px" }}>
|
||||||
|
|
@ -425,6 +453,7 @@ const Meetings: React.FC = () => {
|
||||||
});
|
});
|
||||||
const [userList, setUserList] = useState<SysUser[]>([]);
|
const [userList, setUserList] = useState<SysUser[]>([]);
|
||||||
const progressTerminalRefreshRef = useRef<Map<number, string>>(new Map());
|
const progressTerminalRefreshRef = useRef<Map<number, string>>(new Map());
|
||||||
|
const [retryingMeetingIds, setRetryingMeetingIds] = useState<Record<number, boolean>>({});
|
||||||
|
|
||||||
const activeFilterCount = (statusFilter !== ALL_STATUS_FILTER ? 1 : 0) + (searchTitle ? 1 : 0);
|
const activeFilterCount = (statusFilter !== ALL_STATUS_FILTER ? 1 : 0) + (searchTitle ? 1 : 0);
|
||||||
|
|
||||||
|
|
@ -629,6 +658,27 @@ const Meetings: React.FC = () => {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleRetrySchedule = async (meeting: MeetingVO) => {
|
||||||
|
setRetryingMeetingIds((currentMap) => ({ ...currentMap, [meeting.id]: true }));
|
||||||
|
try {
|
||||||
|
const response = await retryScheduleMeeting(meeting.id);
|
||||||
|
if (response.data?.data) {
|
||||||
|
message.success("已触发重新调度");
|
||||||
|
} else {
|
||||||
|
message.info("当前没有可重新调度的排队任务");
|
||||||
|
}
|
||||||
|
await fetchData(true);
|
||||||
|
} catch {
|
||||||
|
message.error("重新调度失败");
|
||||||
|
} finally {
|
||||||
|
setRetryingMeetingIds((currentMap) => {
|
||||||
|
const nextMap = { ...currentMap };
|
||||||
|
delete nextMap[meeting.id];
|
||||||
|
return nextMap;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const handleOpenMeeting = async (meeting: MeetingVO) => {
|
const handleOpenMeeting = async (meeting: MeetingVO) => {
|
||||||
if (!isRealtimeMeetingCandidate(meeting)) {
|
if (!isRealtimeMeetingCandidate(meeting)) {
|
||||||
navigate("/meetings/" + meeting.id);
|
navigate("/meetings/" + meeting.id);
|
||||||
|
|
@ -700,10 +750,23 @@ const Meetings: React.FC = () => {
|
||||||
{
|
{
|
||||||
title: "操作",
|
title: "操作",
|
||||||
key: "action",
|
key: "action",
|
||||||
width: 160,
|
width: 220,
|
||||||
render: (_: unknown, record: MeetingVO) => (
|
render: (_: unknown, record: MeetingVO) => (
|
||||||
<Space size="middle">
|
<Space size="middle">
|
||||||
<Button type="link" size="small" onClick={(e) => { e.stopPropagation(); handleOpenMeeting(record); }}>查看详情</Button>
|
<Button type="link" size="small" onClick={(e) => { e.stopPropagation(); handleOpenMeeting(record); }}>查看详情</Button>
|
||||||
|
{canRetryQueuedMeeting(record, progressMap[record.id] || null) && (
|
||||||
|
<Button
|
||||||
|
type="link"
|
||||||
|
size="small"
|
||||||
|
loading={!!retryingMeetingIds[record.id]}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
void handleRetrySchedule(record);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
重新调度
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
{canManageMeeting(record) && (
|
{canManageMeeting(record) && (
|
||||||
<Popconfirm title="确定删除吗?" onConfirm={() => deleteMeeting(record.id).then(() => fetchData())}>
|
<Popconfirm title="确定删除吗?" onConfirm={() => deleteMeeting(record.id).then(() => fetchData())}>
|
||||||
<Button type="link" danger size="small" onClick={(e) => e.stopPropagation()}>删除</Button>
|
<Button type="link" danger size="small" onClick={(e) => e.stopPropagation()}>删除</Button>
|
||||||
|
|
@ -840,8 +903,9 @@ const Meetings: React.FC = () => {
|
||||||
item={item}
|
item={item}
|
||||||
config={config}
|
config={config}
|
||||||
progress={progress}
|
progress={progress}
|
||||||
fetchData={() => void fetchData()}
|
|
||||||
onOpenMeeting={handleOpenMeeting}
|
onOpenMeeting={handleOpenMeeting}
|
||||||
|
onRetrySchedule={(meeting) => { void handleRetrySchedule(meeting); }}
|
||||||
|
retrying={!!retryingMeetingIds[item.id]}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}}
|
}}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue