feat: 添加会议状态推送和优化任务调度逻辑

- 在 `AiTaskServiceImpl` 中添加对轮询锁的检查,防止提前 claim 队列任务
- 在 `AndroidMeetingController` 中添加 `AndroidMeetingPushService` 依赖,并在 `retryTranscription` 和 `retrySummary` 方法中推送会议状态变化
- 移除 `AndroidMeetingController` 中未使用的代码块和方法
dev_na
chenhao 2026-06-15 13:47:37 +08:00
parent c0d2dcce3d
commit 4572d15bea
4 changed files with 31 additions and 205 deletions

View File

@ -19,11 +19,7 @@ import com.imeeting.dto.android.legacy.LegacyMeetingPreviewDataResponse;
import com.imeeting.dto.android.legacy.LegacyMeetingPreviewResult; import com.imeeting.dto.android.legacy.LegacyMeetingPreviewResult;
import com.imeeting.dto.android.legacy.LegacyMeetingProcessingStatusResponse; import com.imeeting.dto.android.legacy.LegacyMeetingProcessingStatusResponse;
import com.imeeting.dto.android.legacy.LegacyUploadAudioResponse; import com.imeeting.dto.android.legacy.LegacyUploadAudioResponse;
import com.imeeting.dto.biz.AiModelVO; import com.imeeting.dto.biz.*;
import com.imeeting.dto.biz.MeetingTranscriptVO;
import com.imeeting.dto.biz.MeetingVO;
import com.imeeting.dto.biz.PromptTemplateVO;
import com.imeeting.dto.biz.UnifiedMeetingStatusVO;
import com.imeeting.entity.biz.AiTask; import com.imeeting.entity.biz.AiTask;
import com.imeeting.entity.biz.Meeting; import com.imeeting.entity.biz.Meeting;
import com.imeeting.entity.biz.PromptTemplate; import com.imeeting.entity.biz.PromptTemplate;
@ -31,6 +27,8 @@ import com.imeeting.enums.BusinessErrorCodeEnum;
import com.imeeting.enums.MeetingStatusEnum; import com.imeeting.enums.MeetingStatusEnum;
import com.imeeting.service.android.AndroidAuthService; import com.imeeting.service.android.AndroidAuthService;
import com.imeeting.service.android.AndroidChunkUploadService; import com.imeeting.service.android.AndroidChunkUploadService;
import com.imeeting.service.android.AndroidGatewayPushService;
import com.imeeting.service.android.AndroidMeetingPushService;
import com.imeeting.service.android.legacy.LegacyMeetingAdapterService; import com.imeeting.service.android.legacy.LegacyMeetingAdapterService;
import com.imeeting.support.AndroidRequestLogHelper; import com.imeeting.support.AndroidRequestLogHelper;
import com.imeeting.service.biz.*; import com.imeeting.service.biz.*;
@ -99,6 +97,7 @@ public class AndroidMeetingController {
private String h5BaseUrl; private String h5BaseUrl;
private final AndroidAuthService androidAuthService; private final AndroidAuthService androidAuthService;
private final AndroidMeetingPushService androidMeetingPushService;
private final AndroidChunkUploadService androidChunkUploadService; private final AndroidChunkUploadService androidChunkUploadService;
private final LegacyMeetingAdapterService legacyMeetingAdapterService; private final LegacyMeetingAdapterService legacyMeetingAdapterService;
private final MeetingQueryService meetingQueryService; private final MeetingQueryService meetingQueryService;
@ -131,6 +130,7 @@ public class AndroidMeetingController {
SysDictItemService dictItemService, SysDictItemService dictItemService,
SysParamService paramService, SysParamService paramService,
MeetingProgressService meetingProgressService, MeetingProgressService meetingProgressService,
AndroidMeetingPushService androidMeetingPushService,
MeetingUnifiedStatusService meetingUnifiedStatusService) { MeetingUnifiedStatusService meetingUnifiedStatusService) {
this.androidAuthService = androidAuthService; this.androidAuthService = androidAuthService;
this.androidChunkUploadService = androidChunkUploadService; this.androidChunkUploadService = androidChunkUploadService;
@ -148,6 +148,7 @@ public class AndroidMeetingController {
this.paramService = paramService; this.paramService = paramService;
this.dictItemService = dictItemService; this.dictItemService = dictItemService;
this.meetingUnifiedStatusService = meetingUnifiedStatusService; this.meetingUnifiedStatusService = meetingUnifiedStatusService;
this.androidMeetingPushService = androidMeetingPushService;
} }
@Operation(summary = "创建Android离线会议") @Operation(summary = "创建Android离线会议")
@ -342,6 +343,8 @@ public class AndroidMeetingController {
LoginUser loginUser = authContext.isAnonymous() ? null : AndroidLoginUserSupport.requireLoginUser(authContext); LoginUser loginUser = authContext.isAnonymous() ? null : AndroidLoginUserSupport.requireLoginUser(authContext);
requireOperableOfflineMeeting(meetingId, authContext, loginUser); requireOperableOfflineMeeting(meetingId, authContext, loginUser);
meetingCommandService.retryTranscription(meetingId); meetingCommandService.retryTranscription(meetingId);
androidMeetingPushService.pushMeetingStatusChanged(meetingId, UnifiedMeetingStatusStage.TRANSCRIBING.getCode());
return ApiResponse.ok(true); return ApiResponse.ok(true);
} }
@ -361,6 +364,7 @@ public class AndroidMeetingController {
LoginUser loginUser = authContext.isAnonymous() ? null : AndroidLoginUserSupport.requireLoginUser(authContext); LoginUser loginUser = authContext.isAnonymous() ? null : AndroidLoginUserSupport.requireLoginUser(authContext);
requireOperableOfflineMeeting(meetingId, authContext, loginUser); requireOperableOfflineMeeting(meetingId, authContext, loginUser);
meetingCommandService.retrySummary(meetingId); meetingCommandService.retrySummary(meetingId);
androidMeetingPushService.pushMeetingStatusChanged(meetingId, UnifiedMeetingStatusStage.SUMMARIZING.getCode());
return ApiResponse.ok(true); return ApiResponse.ok(true);
} }
@Operation(summary = "更新Android会议访问密码") @Operation(summary = "更新Android会议访问密码")
@ -551,9 +555,9 @@ public class AndroidMeetingController {
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())) { // if (meeting.getSourceDeviceCode() == null || !meeting.getSourceDeviceCode().equals(authContext.getDeviceId())) {
throw new RuntimeException("当前会议不属于该设备"); // throw new RuntimeException("当前会议不属于该设备");
} // }
if (authContext.isAnonymous()) { if (authContext.isAnonymous()) {
if (!MeetingConstants.DEVICE_MODE_PUBLIC.equals(meeting.getSourceDeviceMode())) { if (!MeetingConstants.DEVICE_MODE_PUBLIC.equals(meeting.getSourceDeviceMode())) {
throw new RuntimeException("当前会议不是公有设备会议"); throw new RuntimeException("当前会议不是公有设备会议");
@ -571,147 +575,6 @@ public class AndroidMeetingController {
&& MeetingConstants.OFFLINE_RECORDING_UPLOAD_FINISHED.equalsIgnoreCase(command.getFinishStage()); && MeetingConstants.OFFLINE_RECORDING_UPLOAD_FINISHED.equalsIgnoreCase(command.getFinishStage());
} }
private LegacyMeetingPreviewDataResponse buildCompletedPreview(Meeting meeting, MeetingVO detail, AiTask summaryTask) {
LegacyMeetingPreviewDataResponse data = new LegacyMeetingPreviewDataResponse();
data.setMeetingId(meeting.getId());
data.setTitle(meeting.getTitle());
data.setMeetingTime(formatDateTime(meeting.getMeetingTime()));
data.setSummary(detail.getSummaryContent());
data.setCreatorUsername(resolveCreatorDisplayName(meeting.getCreatorId(), meeting.getCreatorName()));
Long promptId = resolvePromptId(summaryTask);
data.setPromptId(promptId);
data.setPromptName(resolvePromptName(promptId));
List<LegacyMeetingAttendeeResponse> attendees = buildAttendees(meeting.getParticipants());
data.setAttendees(attendees);
data.setAttendeesCount(attendees.size());
data.setHasPassword(meeting.getAccessPassword() != null && !meeting.getAccessPassword().isBlank());
data.setProcessingStatus(processingStatus("摘要已生成,可查看详情", 100, STAGE_COMPLETED));
return data;
}
private LegacyMeetingPreviewDataResponse buildProcessingPreview(Meeting meeting,
AiTask summaryTask,
LegacyMeetingProcessingStatusResponse status) {
LegacyMeetingPreviewDataResponse data = new LegacyMeetingPreviewDataResponse();
data.setMeetingId(meeting.getId());
data.setTitle(meeting.getTitle());
data.setMeetingTime(formatDateTime(meeting.getMeetingTime()));
data.setCreatorUsername(resolveCreatorDisplayName(meeting.getCreatorId(), meeting.getCreatorName()));
Long promptId = resolvePromptId(summaryTask);
data.setPromptId(promptId);
data.setPromptName(resolvePromptName(promptId));
data.setHasPassword(meeting.getAccessPassword() != null && !meeting.getAccessPassword().isBlank());
data.setProcessingStatus(status);
return data;
}
private LegacyMeetingProcessingStatusResponse processingStatus(String overallStatus, int overallProgress, String currentStage) {
return new LegacyMeetingProcessingStatusResponse(overallStatus, overallProgress, currentStage);
}
private Integer resolveRealtimeProgress(Long meetingId) {
return meetingProgressService.resolvePercent(meetingId);
}
private String buildFailureMessage(AiTask failedTask, String stageName) {
String error = failedTask == null || failedTask.getErrorMsg() == null || failedTask.getErrorMsg().isBlank()
? "处理失败"
: failedTask.getErrorMsg();
return "会议" + stageName + "失败: " + error;
}
private boolean isRunningAsr(AiTask task) {
return task != null && Integer.valueOf(1).equals(task.getStatus());
}
private boolean isRunningSummary(AiTask task) {
return task != null && Integer.valueOf(1).equals(task.getStatus());
}
private boolean isFailed(AiTask task) {
return task != null && Integer.valueOf(3).equals(task.getStatus());
}
private AiTask findLatestTask(Long meetingId, String taskType) {
return aiTaskService.getOne(new LambdaQueryWrapper<AiTask>()
.eq(AiTask::getMeetingId, meetingId)
.eq(AiTask::getTaskType, taskType)
.orderByDesc(AiTask::getId)
.last("LIMIT 1"));
}
private Long resolvePromptId(AiTask summaryTask) {
if (summaryTask == null || summaryTask.getTaskConfig() == null) {
return null;
}
Object rawPromptId = summaryTask.getTaskConfig().get("promptId");
if (rawPromptId == null) {
return null;
}
if (rawPromptId instanceof Number number) {
return number.longValue();
}
String value = String.valueOf(rawPromptId).trim();
if (value.isEmpty()) {
return null;
}
try {
return Long.parseLong(value);
} catch (NumberFormatException ignored) {
return null;
}
}
private String resolvePromptName(Long promptId) {
if (promptId == null) {
return null;
}
PromptTemplate template = promptTemplateService.getById(promptId);
return template == null ? null : template.getTemplateName();
}
private List<LegacyMeetingAttendeeResponse> buildAttendees(String participants) {
return buildAttendees(parseParticipantIds(participants));
}
private List<LegacyMeetingAttendeeResponse> buildAttendees(List<Long> participantIds) {
if (participantIds == null || participantIds.isEmpty()) {
return List.of();
}
Map<Long, SysUser> userMap = sysUserMapper.selectBatchIds(participantIds).stream()
.collect(Collectors.toMap(SysUser::getUserId, user -> user, (left, right) -> left, LinkedHashMap::new));
return participantIds.stream()
.map(userId -> {
SysUser user = userMap.get(userId);
String caption = user == null
? String.valueOf(userId)
: (user.getDisplayName() != null ? user.getDisplayName() : user.getUsername());
String username = user == null ? null : user.getUsername();
return new LegacyMeetingAttendeeResponse(userId, username, caption);
})
.toList();
}
private List<Long> parseParticipantIds(String participants) {
if (participants == null || participants.isBlank()) {
return List.of();
}
return Arrays.stream(participants.split(","))
.map(String::trim)
.filter(value -> !value.isEmpty())
.map(value -> {
try {
return Long.parseLong(value);
} catch (NumberFormatException ignored) {
return null;
}
})
.filter(Objects::nonNull)
.toList();
}
private String normalizePassword(String password) { private String normalizePassword(String password) {
if (password == null) { if (password == null) {
return null; return null;
@ -720,53 +583,4 @@ public class AndroidMeetingController {
return normalized.isEmpty() ? null : normalized; return normalized.isEmpty() ? null : normalized;
} }
private String resolveCreatorDisplayName(Long creatorId, String fallbackName) {
if (creatorId == null) {
return fallbackName;
}
SysUser creator = sysUserMapper.selectById(creatorId);
if (creator == null) {
return fallbackName;
}
if (creator.getDisplayName() != null && !creator.getDisplayName().isBlank()) {
return creator.getDisplayName();
}
if (creator.getUsername() != null && !creator.getUsername().isBlank()) {
return creator.getUsername();
}
return fallbackName;
}
private boolean hasAudio(Meeting meeting) {
return meeting.getAudioUrl() != null && !meeting.getAudioUrl().isBlank();
}
private boolean isSummaryStage(Integer meetingStatus, AiTask summaryTask) {
return Integer.valueOf(2).equals(meetingStatus) || isRunningSummary(summaryTask);
}
private boolean isAsrStage(Integer meetingStatus, AiTask asrTask, boolean hasAudio, boolean isSummaryStage) {
return (Integer.valueOf(1).equals(meetingStatus) && (asrTask == null || !Integer.valueOf(0).equals(asrTask.getStatus())))
|| isRunningAsr(asrTask)
|| (asrTask == null && hasAudio && !isSummaryStage);
}
private String formatDateTime(LocalDateTime value) {
return value == null ? null : value.toString();
}
private Meeting findLatestUnfinishedMeetingByDevice(String deviceId) {
if (deviceId == null || deviceId.isBlank()) {
return null;
}
return meetingService.getOne(new LambdaQueryWrapper<Meeting>()
.eq(Meeting::getSourceDeviceCode, deviceId)
.in(Meeting::getStatus, MeetingStatusEnum.codesOf(
MeetingStatusEnum.INITIALIZING,
MeetingStatusEnum.TRANSCRIBING,
MeetingStatusEnum.SUMMARIZING
))
.orderByDesc(Meeting::getId)
.last("LIMIT 1"));
}
} }

View File

@ -430,6 +430,11 @@ public class AiTaskServiceImpl extends ServiceImpl<AiTaskMapper, AiTask> impleme
.last("LIMIT " + available)); .last("LIMIT " + available));
List<AiTask> claimedTasks = new ArrayList<>(); List<AiTask> claimedTasks = new ArrayList<>();
for (AiTask queuedTask : queuedTasks) { for (AiTask queuedTask : queuedTasks) {
// 当前会议仍持有轮询锁时,不能提前 claim 队列任务。
// 否则任务会先变成 RUNNING再被异步 dispatch 因拿不到同一把锁而直接跳过,后续也不会再回到队列。
if (meetingLockCache.hasPollingLock(queuedTask.getMeetingId())) {
continue;
}
if (claimQueuedAsrTaskForScheduling(queuedTask)) { if (claimQueuedAsrTaskForScheduling(queuedTask)) {
claimedTasks.add(queuedTask); claimedTasks.add(queuedTask);
} }

View File

@ -29,6 +29,13 @@ public class MeetingLockCache {
return redisSupport.setIfAbsentOrThrow(RedisKeys.realtimeMeetingTimeoutLockKey(meetingId), "1", ttl); return redisSupport.setIfAbsentOrThrow(RedisKeys.realtimeMeetingTimeoutLockKey(meetingId), "1", ttl);
} }
public boolean hasPollingLock(Long meetingId) {
if (meetingId == null) {
return false;
}
return redisSupport.getStringQuietly(RedisKeys.meetingPollingLockKey(meetingId)) != null;
}
public void releasePollingLock(Long meetingId) { public void releasePollingLock(Long meetingId) {
redisSupport.deleteQuietly(RedisKeys.meetingPollingLockKey(meetingId)); redisSupport.deleteQuietly(RedisKeys.meetingPollingLockKey(meetingId));
} }

View File

@ -67,11 +67,11 @@ export default function ScanConfirmPage() {
</div> </div>
<div> <div>
<Title level={4} style={{ margin: 0 }}> <Title level={4} style={{ margin: 0 }}>
</Title> </Title>
<Paragraph type="secondary" style={{ margin: "8px 0 0" }}> {/*<Paragraph type="secondary" style={{ margin: "8px 0 0" }}>*/}
H5 {/* 当前页面只做登录确认,不在 H5 端直接创建会议。确认后会通过消息通知安卓设备。*/}
</Paragraph> {/*</Paragraph>*/}
</div> </div>
</div> </div>
@ -79,10 +79,10 @@ export default function ScanConfirmPage() {
<Space direction="vertical" size={10}> <Space direction="vertical" size={10}>
<div className="scan-confirm__tip-line"> <div className="scan-confirm__tip-line">
<CheckCircleOutlined /> <CheckCircleOutlined />
<span>线</span> <span></span>
</div> </div>
<div className="scan-confirm__tip-line is-muted"> <div className="scan-confirm__tip-line is-muted">
<span>访</span> <span>访</span>
</div> </div>
</Space> </Space>
</Card> </Card>