refactor: 删除 MeetingTranscriptRevisionServiceImpl 类

- 移除 `MeetingTranscriptRevisionServiceImpl` 类及其相关方法和逻辑
- 该类涉及会议转录修订的生成、解析和更新等功能
dev_na
chenhao 2026-05-22 17:28:59 +08:00
parent 188809305e
commit a046ecf05b
20 changed files with 788 additions and 1243 deletions

View File

@ -5,8 +5,6 @@ public final class SysParamKeys {
public static final String CAPTCHA_ENABLED = "security.captcha.enabled"; public static final String CAPTCHA_ENABLED = "security.captcha.enabled";
public static final String MEETING_SUMMARY_SYSTEM_PROMPT = "meeting.summary.system_prompt"; public static final String MEETING_SUMMARY_SYSTEM_PROMPT = "meeting.summary.system_prompt";
public static final String MEETING_TRANSCRIPT_CLEANUP_FILLER_WORDS = "meeting.transcript.cleanup.filler_words";
public static final String MEETING_TRANSCRIPT_CLEANUP_REPLACEMENTS = "meeting.transcript.cleanup.replacements";
public static final String MEETING_OFFLINE_AUDIO_MAX_SIZE_MB = "meeting.offline_audio.max_size_mb"; public static final String MEETING_OFFLINE_AUDIO_MAX_SIZE_MB = "meeting.offline_audio.max_size_mb";
public static final String MEETING_CREATE_OFFLINE_ENABLED = "meeting.create.offline_enabled"; public static final String MEETING_CREATE_OFFLINE_ENABLED = "meeting.create.offline_enabled";
public static final String MEETING_CREATE_REALTIME_ENABLED = "meeting.create.realtime_enabled"; public static final String MEETING_CREATE_REALTIME_ENABLED = "meeting.create.realtime_enabled";

View File

@ -202,6 +202,7 @@ public class AndroidMeetingController {
loginUser.getUserId(), loginUser.getUserId(),
AndroidLoginUserSupport.resolveDisplayName(authContext), AndroidLoginUserSupport.resolveDisplayName(authContext),
"all", "all",
null,
AndroidLoginUserSupport.isAdmin(authContext) AndroidLoginUserSupport.isAdmin(authContext)
)); ));
} }

View File

@ -203,6 +203,7 @@ public class LegacyMeetingController {
loginUser.getUserId(), loginUser.getUserId(),
resolveCreatorName(loginUser), resolveCreatorName(loginUser),
"all", "all",
null,
isAdmin isAdmin
); );

View File

@ -166,7 +166,44 @@ public class MeetingController {
return ApiResponse.ok(progress); return ApiResponse.ok(progress);
} }
@Operation(summary = "上传会议音频")
@Operation(summary = "批量查询会议处理进度")
@PostMapping("/progress/batch")
@PreAuthorize("isAuthenticated()")
public ApiResponse<Map<Long, Map<String, Object>>> getProgressBatch(@RequestBody List<Long> ids) {
LoginUser loginUser = currentLoginUser();
Map<Long, Map<String, Object>> result = new LinkedHashMap<>();
if (ids == null || ids.isEmpty()) {
return ApiResponse.ok(result);
}
for (Long id : ids) {
if (id == null) {
continue;
}
try {
Meeting meeting = meetingAccessService.requireMeeting(id);
meetingAccessService.assertCanViewMeeting(meeting, loginUser);
Map<String, Object> progress = meetingProgressService.getProgressMap(id);
if (compatibilityAiTaskService != null && "Waiting...".equals(progress.get("message"))) {
AiTask asrTask = compatibilityAiTaskService.getOne(new LambdaQueryWrapper<AiTask>()
.eq(AiTask::getMeetingId, id)
.eq(AiTask::getTaskType, "ASR")
.orderByDesc(AiTask::getId)
.last("LIMIT 1"));
if (asrTask != null && Integer.valueOf(0).equals(asrTask.getStatus())) {
progress = Map.of("percent", 0, "message", "排队中,等待 ASR 执行名额...");
} else if (asrTask != null && Integer.valueOf(1).equals(asrTask.getStatus())) {
progress = Map.of("percent", 5, "message", "识别中,等待进度刷新...");
}
}
result.put(id, progress);
} catch (RuntimeException ignored) {
// Ignore inaccessible meetings in batch mode.
}
}
return ApiResponse.ok(result);
}
@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 {
@ -223,7 +260,8 @@ public class MeetingController {
@RequestParam(defaultValue = "1") Integer current, @RequestParam(defaultValue = "1") Integer current,
@RequestParam(defaultValue = "10") Integer size, @RequestParam(defaultValue = "10") Integer size,
@RequestParam(required = false) String title, @RequestParam(required = false) String title,
@RequestParam(defaultValue = "all") String viewType) { @RequestParam(defaultValue = "all") String viewType,
@RequestParam(required = false) Integer status) {
LoginUser loginUser = currentLoginUser(); LoginUser loginUser = currentLoginUser();
boolean isAdmin = Boolean.TRUE.equals(loginUser.getIsPlatformAdmin()) || Boolean.TRUE.equals(loginUser.getIsTenantAdmin()); boolean isAdmin = Boolean.TRUE.equals(loginUser.getIsPlatformAdmin()) || Boolean.TRUE.equals(loginUser.getIsTenantAdmin());
@ -236,6 +274,7 @@ public class MeetingController {
loginUser.getUserId(), loginUser.getUserId(),
resolveCreatorName(loginUser), resolveCreatorName(loginUser),
viewType, viewType,
status,
isAdmin isAdmin
)); ));
} }

View File

@ -22,6 +22,7 @@ public class MeetingProgressSnapshot {
private Integer percent; private Integer percent;
private String message; private String message;
private Integer eta; private Integer eta;
private Integer queueAheadCount;
private String externalTaskId; private String externalTaskId;
private LocalDateTime queuedAt; private LocalDateTime queuedAt;
private LocalDateTime startedAt; private LocalDateTime startedAt;

View File

@ -4,6 +4,7 @@ import com.imeeting.common.MeetingProgressStage;
import com.imeeting.dto.biz.MeetingProgressSnapshot; import com.imeeting.dto.biz.MeetingProgressSnapshot;
import com.imeeting.entity.biz.AiTask; import com.imeeting.entity.biz.AiTask;
import java.util.List;
import java.util.Map; import java.util.Map;
public interface MeetingProgressService { public interface MeetingProgressService {
@ -11,6 +12,8 @@ public interface MeetingProgressService {
Map<String, Object> getProgressMap(Long meetingId); Map<String, Object> getProgressMap(Long meetingId);
Map<Long, Map<String, Object>> getProgressMaps(List<Long> meetingIds);
Integer resolvePercent(Long meetingId); Integer resolvePercent(Long meetingId);
void markQueued(Long meetingId, AiTask task, Integer meetingStatus, String message); void markQueued(Long meetingId, AiTask task, Integer meetingStatus, String message);

View File

@ -12,7 +12,7 @@ import java.util.Map;
public interface MeetingQueryService { public interface MeetingQueryService {
PageResult<List<MeetingVO>> pageMeetings(Integer current, Integer size, String title, Long tenantId, PageResult<List<MeetingVO>> pageMeetings(Integer current, Integer size, String title, Long tenantId,
Long userId, String userName, String viewType, boolean isAdmin); Long userId, String userName, String viewType, Integer status, boolean isAdmin);
MeetingVO getDetail(Long id); MeetingVO getDetail(Long id);

View File

@ -9,13 +9,6 @@ import com.imeeting.entity.biz.Meeting;
import java.util.List; import java.util.List;
public interface MeetingTranscriptRevisionService { public interface MeetingTranscriptRevisionService {
String generateOfflineCurrentRevision(Meeting meeting, AiTask task, AiModelVO asrModel);
MeetingSummarySource resolveSummarySource(Meeting meeting, AiTask summaryTask, AiModelVO asrModel);
List<MeetingTranscriptVO> listEffectiveTranscripts(Long meetingId);
boolean updateCurrentRevisionContent(Long meetingId, Long operatorId, String content);
void invalidateCurrentRevision(Long meetingId); void invalidateCurrentRevision(Long meetingId);
} }

View File

@ -25,7 +25,7 @@ import com.imeeting.service.biz.MeetingProgressService;
import com.imeeting.service.biz.MeetingSummaryFileService; import com.imeeting.service.biz.MeetingSummaryFileService;
import com.imeeting.service.biz.MeetingTranscriptChapterService; import com.imeeting.service.biz.MeetingTranscriptChapterService;
import com.imeeting.service.biz.MeetingTranscriptFileService; import com.imeeting.service.biz.MeetingTranscriptFileService;
import com.imeeting.service.biz.MeetingTranscriptRevisionService;
import com.imeeting.support.RedisValueSupport; import com.imeeting.support.RedisValueSupport;
import com.unisbase.entity.SysUser; import com.unisbase.entity.SysUser;
@ -71,7 +71,7 @@ public class AiTaskServiceImpl extends ServiceImpl<AiTaskMapper, AiTask> impleme
private final MeetingProgressService meetingProgressService; private final MeetingProgressService meetingProgressService;
private final MeetingSummaryFileService meetingSummaryFileService; private final MeetingSummaryFileService meetingSummaryFileService;
private final MeetingTranscriptFileService meetingTranscriptFileService; private final MeetingTranscriptFileService meetingTranscriptFileService;
private final MeetingTranscriptRevisionService meetingTranscriptRevisionService;
private final MeetingTranscriptChapterService meetingTranscriptChapterService; private final MeetingTranscriptChapterService meetingTranscriptChapterService;
private final MeetingSummaryPromptAssembler meetingSummaryPromptAssembler; private final MeetingSummaryPromptAssembler meetingSummaryPromptAssembler;
private final TaskSecurityContextRunner taskSecurityContextRunner; private final TaskSecurityContextRunner taskSecurityContextRunner;
@ -115,7 +115,6 @@ public class AiTaskServiceImpl extends ServiceImpl<AiTaskMapper, AiTask> impleme
MeetingProgressService meetingProgressService, MeetingProgressService meetingProgressService,
MeetingSummaryFileService meetingSummaryFileService, MeetingSummaryFileService meetingSummaryFileService,
MeetingTranscriptFileService meetingTranscriptFileService, MeetingTranscriptFileService meetingTranscriptFileService,
MeetingTranscriptRevisionService meetingTranscriptRevisionService,
MeetingTranscriptChapterService meetingTranscriptChapterService, MeetingTranscriptChapterService meetingTranscriptChapterService,
MeetingSummaryPromptAssembler meetingSummaryPromptAssembler, MeetingSummaryPromptAssembler meetingSummaryPromptAssembler,
TaskSecurityContextRunner taskSecurityContextRunner, TaskSecurityContextRunner taskSecurityContextRunner,
@ -131,7 +130,6 @@ public class AiTaskServiceImpl extends ServiceImpl<AiTaskMapper, AiTask> impleme
this.meetingProgressService = meetingProgressService; this.meetingProgressService = meetingProgressService;
this.meetingSummaryFileService = meetingSummaryFileService; this.meetingSummaryFileService = meetingSummaryFileService;
this.meetingTranscriptFileService = meetingTranscriptFileService; this.meetingTranscriptFileService = meetingTranscriptFileService;
this.meetingTranscriptRevisionService = meetingTranscriptRevisionService;
this.meetingTranscriptChapterService = meetingTranscriptChapterService; this.meetingTranscriptChapterService = meetingTranscriptChapterService;
this.meetingSummaryPromptAssembler = meetingSummaryPromptAssembler; this.meetingSummaryPromptAssembler = meetingSummaryPromptAssembler;
this.taskSecurityContextRunner = taskSecurityContextRunner; this.taskSecurityContextRunner = taskSecurityContextRunner;
@ -148,7 +146,7 @@ public class AiTaskServiceImpl extends ServiceImpl<AiTaskMapper, AiTask> impleme
StringRedisTemplate redisTemplate, StringRedisTemplate redisTemplate,
MeetingSummaryFileService meetingSummaryFileService, MeetingSummaryFileService meetingSummaryFileService,
MeetingTranscriptFileService meetingTranscriptFileService, MeetingTranscriptFileService meetingTranscriptFileService,
MeetingTranscriptRevisionService meetingTranscriptRevisionService,
MeetingTranscriptChapterService meetingTranscriptChapterService, MeetingTranscriptChapterService meetingTranscriptChapterService,
MeetingSummaryPromptAssembler meetingSummaryPromptAssembler, MeetingSummaryPromptAssembler meetingSummaryPromptAssembler,
TaskSecurityContextRunner taskSecurityContextRunner, TaskSecurityContextRunner taskSecurityContextRunner,
@ -164,7 +162,6 @@ public class AiTaskServiceImpl extends ServiceImpl<AiTaskMapper, AiTask> impleme
new RedisOnlyMeetingProgressServiceAdapter(redisTemplate, objectMapper), new RedisOnlyMeetingProgressServiceAdapter(redisTemplate, objectMapper),
meetingSummaryFileService, meetingSummaryFileService,
meetingTranscriptFileService, meetingTranscriptFileService,
meetingTranscriptRevisionService,
meetingTranscriptChapterService, meetingTranscriptChapterService,
meetingSummaryPromptAssembler, meetingSummaryPromptAssembler,
taskSecurityContextRunner, taskSecurityContextRunner,
@ -213,7 +210,7 @@ public class AiTaskServiceImpl extends ServiceImpl<AiTaskMapper, AiTask> impleme
this.updateById(asrTask); this.updateById(asrTask);
} }
if (!claimQueuedAsrTask(asrTask)) { if (!claimQueuedAsrTask(asrTask)) {
meetingProgressService.markQueued(meetingId, asrTask, 1, "已进入 ASR 队列,等待执行"); meetingProgressService.markQueued(meetingId, asrTask, 1, "ASR queued and waiting for execution");
return; return;
} }
} }
@ -249,6 +246,7 @@ public class AiTaskServiceImpl extends ServiceImpl<AiTaskMapper, AiTask> impleme
} }
if (!asrText.isBlank()) { if (!asrText.isBlank()) {
meetingProgressService.markStage(meetingId, asrTask, 1, MeetingProgressStage.ASR_COMPLETED, 80, "转写完成,准备生成总结", 0); meetingProgressService.markStage(meetingId, asrTask, 1, MeetingProgressStage.ASR_COMPLETED, 80, "转写完成,准备生成总结", 0);
scheduleQueuedAsrTasks();
self.dispatchSummaryTask(meetingId, meeting.getTenantId(), meeting.getCreatorId()); self.dispatchSummaryTask(meetingId, meeting.getTenantId(), meeting.getCreatorId());
return; return;
} }
@ -268,7 +266,7 @@ public class AiTaskServiceImpl extends ServiceImpl<AiTaskMapper, AiTask> impleme
} }
} catch (Exception e) { } catch (Exception e) {
log.error("Meeting {} AI Task Flow failed", meetingId, e); log.error("Meeting {} AI Task Flow failed", meetingId, e);
failPendingSummaryTask(findLatestSummaryTask(meetingId), "转录失败,已跳过总结任务:" + e.getMessage()); failPendingSummaryTask(findLatestSummaryTask(meetingId), "转录失败,已跳过总结任务: " + e.getMessage());
updateMeetingStatus(meetingId, 4); updateMeetingStatus(meetingId, 4);
updateProgress(meetingId, -1, "分析失败: " + e.getMessage(), 0); updateProgress(meetingId, -1, "分析失败: " + e.getMessage(), 0);
} finally { } finally {
@ -384,6 +382,7 @@ public class AiTaskServiceImpl extends ServiceImpl<AiTaskMapper, AiTask> impleme
task.setStatus(1); task.setStatus(1);
task.setStartedAt(now); task.setStartedAt(now);
meetingProgressService.markStage(task.getMeetingId(), task, 1, MeetingProgressStage.ASR_SUBMITTED, 5, "ASR 任务已开始执行", 0); meetingProgressService.markStage(task.getMeetingId(), task, 1, MeetingProgressStage.ASR_SUBMITTED, 5, "ASR 任务已开始执行", 0);
refreshQueuedAsrProgress();
} }
return claimed; return claimed;
} finally { } finally {
@ -409,6 +408,7 @@ 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)
@ -427,6 +427,20 @@ public class AiTaskServiceImpl extends ServiceImpl<AiTaskMapper, AiTask> impleme
} }
} }
private void refreshQueuedAsrProgress() {
List<AiTask> queuedTasks = list(new LambdaQueryWrapper<AiTask>()
.eq(AiTask::getTaskType, "ASR")
.eq(AiTask::getStatus, 0)
.orderByAsc(AiTask::getQueuedAt)
.orderByAsc(AiTask::getId));
for (AiTask queuedTask : queuedTasks) {
if (queuedTask.getMeetingId() == null) {
continue;
}
meetingProgressService.markQueued(queuedTask.getMeetingId(), queuedTask, 1, null);
}
}
private int resolveAsrMaxConcurrent() { private int resolveAsrMaxConcurrent() {
if (sysParamService == null) { if (sysParamService == null) {
return 2; return 2;
@ -459,6 +473,7 @@ public class AiTaskServiceImpl extends ServiceImpl<AiTaskMapper, AiTask> impleme
task.setResponseData(responseData); task.setResponseData(responseData);
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();
} }
private Long extractAsrModelId(AiTask task) { private Long extractAsrModelId(AiTask task) {
@ -565,20 +580,17 @@ public class AiTaskServiceImpl extends ServiceImpl<AiTaskMapper, AiTask> impleme
} }
}) })
.collect(Collectors.joining("/")); .collect(Collectors.joining("/"));
req.put("file_url", serverBaseUrl + (encodedAudioUrl.startsWith("/") ? "" : "/") + encodedAudioUrl); req.put("audio_address", serverBaseUrl + (encodedAudioUrl.startsWith("/") ? "" : "/") + encodedAudioUrl);
Map<String, Object> config = new HashMap<>(); Map<String, Object> config = new HashMap<>();
if (asrModel.getModelCode() != null && !asrModel.getModelCode().isBlank()) {
config.put("model", asrModel.getModelCode());
}
Object useSpkObj = taskRecord.getTaskConfig().get("useSpkId"); Object useSpkObj = taskRecord.getTaskConfig().get("useSpkId");
boolean useSpk = useSpkObj != null && useSpkObj.toString().equals("1"); boolean useSpk = useSpkObj != null && useSpkObj.toString().equals("1");
config.put("enable_speaker", useSpk); config.put("enable_speaker", useSpk);
config.put("match_speaker_registry", useSpk);
Object enableTextRefineObj = taskRecord.getTaskConfig().get("enableTextRefine"); Object enableTextRefineObj = taskRecord.getTaskConfig().get("enableTextRefine");
boolean enableTextRefine = enableTextRefineObj != null && Boolean.parseBoolean(enableTextRefineObj.toString()); boolean enableTextRefine = enableTextRefineObj != null && Boolean.parseBoolean(enableTextRefineObj.toString());
config.put("enable_text_refine", enableTextRefine); config.put("enable_text_cleanup", enableTextRefine);
config.put("enable_two_pass", true);
List<Map<String, Object>> hotwords = new ArrayList<>(); List<Map<String, Object>> hotwords = new ArrayList<>();
Object hotWordsObj = taskRecord.getTaskConfig().get("hotWords"); Object hotWordsObj = taskRecord.getTaskConfig().get("hotWords");
@ -647,8 +659,12 @@ public class AiTaskServiceImpl extends ServiceImpl<AiTaskMapper, AiTask> impleme
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").asInt() != 0) {
updateAiTaskFail(taskRecord, "ASR识别失败: " + respBody); updateAiTaskFail(taskRecord, "ASR识别失败 " + respBody);
throw new RuntimeException("ASR识别失败: " + submitNode.path("msg").asText()); throw new RuntimeException("ASR识别失败: " + firstNonBlank(
submitNode.path("message").asText(""),
submitNode.path("msg").asText(""),
"unknown error"
));
} }
String taskId = submitNode.path("data").path("task_id").asText(); String taskId = submitNode.path("data").path("task_id").asText();
taskRecord.setResponseData(Map.of("task_id", taskId)); taskRecord.setResponseData(Map.of("task_id", taskId));
@ -657,11 +673,12 @@ public class AiTaskServiceImpl extends ServiceImpl<AiTaskMapper, AiTask> impleme
} }
@Transactional(rollbackFor = Exception.class) @Transactional(rollbackFor = Exception.class)
protected String saveTranscripts(Meeting meeting, JsonNode resultNode) { protected String saveTranscripts(Meeting meeting, JsonNode resultNode) {
// 关键:入库前清理旧记录,防止恢复任务导致数据重复 // 闂備胶顭堢换鎴炵箾婵犲洤鏋佹い鎾卞灪閺咁剚鎱ㄥ鍡楀鐎殿喗濞婇獮鏍偓娑櫳戠亸顓烆熆瑜忔慨鎾Υ閹烘宸濇い鏍ㄧ☉閳ь剛鍋ら弻锟犲礃閸曨偅锛嶉柛鐐插閹叉悂鎮ч崼鐔衡敍缂備浇椴哥换鍫濐潖婵傜鐭楀鑸得竟姗€姊虹拠鈥冲箲闁搞劌缍婅棟闁告瑥顦遍々鐑芥偣閸ャ劌绲绘い顐犲€濋幃妤佹媴閸愵煈妫堥梺鎼炰紘閸パ勭€梺缁橆殔閻楀棛绮婇敃鍌涒拺闁圭粯甯炲瓭濡?
transcriptMapper.delete(new LambdaQueryWrapper<MeetingTranscript>().eq(MeetingTranscript::getMeetingId, meeting.getId())); transcriptMapper.delete(new LambdaQueryWrapper<MeetingTranscript>().eq(MeetingTranscript::getMeetingId, meeting.getId()));
StringBuilder sb = new StringBuilder();
JsonNode segments = resultNode.path("segments"); JsonNode segments = resultNode.path("segments");
StringBuilder sb = new StringBuilder();
Map<String, String> resolvedUserNameCache = buildResolvedUserNameCache(segments);
int savedCount = 0; int savedCount = 0;
if (segments.isArray()) { if (segments.isArray()) {
int order = 0; int order = 0;
@ -670,7 +687,7 @@ public class AiTaskServiceImpl extends ServiceImpl<AiTaskMapper, AiTask> impleme
mt.setMeetingId(meeting.getId()); mt.setMeetingId(meeting.getId());
String spkId = extractSpeakerId(seg); String spkId = extractSpeakerId(seg);
String spkName = resolveTranscriptSpeakerName(seg, spkId); String spkName = resolveTranscriptSpeakerName(seg, spkId, resolvedUserNameCache);
mt.setSpeakerId(spkId); mt.setSpeakerId(spkId);
mt.setSpeakerName(spkName); mt.setSpeakerName(spkName);
@ -714,7 +731,7 @@ public class AiTaskServiceImpl extends ServiceImpl<AiTaskMapper, AiTask> impleme
return speakerId.trim(); return speakerId.trim();
} }
private String resolveTranscriptSpeakerName(JsonNode seg, String speakerId) { private String resolveTranscriptSpeakerName(JsonNode seg, String speakerId, Map<String, String> resolvedUserNameCache) {
String speakerName = seg.path("speaker_name").asText(""); String speakerName = seg.path("speaker_name").asText("");
if (speakerName == null || speakerName.isBlank()) { if (speakerName == null || speakerName.isBlank()) {
JsonNode speakerNode = seg.path("speaker"); JsonNode speakerNode = seg.path("speaker");
@ -728,13 +745,13 @@ public class AiTaskServiceImpl extends ServiceImpl<AiTaskMapper, AiTask> impleme
if (userId == null || userId.isBlank()) { if (userId == null || userId.isBlank()) {
userId = seg.path("speaker").path("user_id").asText(""); userId = seg.path("speaker").path("user_id").asText("");
} }
String resolvedUserName = resolveUserName(userId); String resolvedUserName = resolveUserName(userId, resolvedUserNameCache);
if (resolvedUserName != null) { if (resolvedUserName != null) {
return resolvedUserName; return resolvedUserName;
} }
if (speakerId != null && speakerId.matches("\\d+")) { if (speakerId != null && speakerId.matches("\\d+")) {
String resolvedSpeakerName = resolveUserName(speakerId); String resolvedSpeakerName = resolveUserName(speakerId, resolvedUserNameCache);
if (resolvedSpeakerName != null) { if (resolvedSpeakerName != null) {
return resolvedSpeakerName; return resolvedSpeakerName;
} }
@ -746,10 +763,13 @@ public class AiTaskServiceImpl extends ServiceImpl<AiTaskMapper, AiTask> impleme
return speakerName.trim(); return speakerName.trim();
} }
private String resolveUserName(String userId) { private String resolveUserName(String userId, Map<String, String> resolvedUserNameCache) {
if (userId == null || userId.isBlank() || !userId.matches("\\d+")) { if (userId == null || userId.isBlank() || !userId.matches("\\d+")) {
return null; return null;
} }
if (resolvedUserNameCache != null) {
return resolvedUserNameCache.get(userId);
}
SysUser user = sysUserMapper.selectById(Long.parseLong(userId)); SysUser user = sysUserMapper.selectById(Long.parseLong(userId));
if (user == null) { if (user == null) {
return null; return null;
@ -757,6 +777,45 @@ public class AiTaskServiceImpl extends ServiceImpl<AiTaskMapper, AiTask> impleme
return user.getDisplayName() != null ? user.getDisplayName() : user.getUsername(); return user.getDisplayName() != null ? user.getDisplayName() : user.getUsername();
} }
private Map<String, String> buildResolvedUserNameCache(JsonNode segments) {
if (segments == null || !segments.isArray()) {
return Map.of();
}
Set<Long> userIds = new LinkedHashSet<>();
for (JsonNode seg : segments) {
collectNumericUserId(userIds, seg.path("user_id").asText(""));
JsonNode speakerNode = seg.path("speaker");
collectNumericUserId(userIds, speakerNode.path("user_id").asText(""));
collectNumericUserId(userIds, speakerNode.path("id").asText(""));
collectNumericUserId(userIds, seg.path("speaker_id").asText(""));
}
if (userIds.isEmpty()) {
return Map.of();
}
return sysUserMapper.selectBatchIds(userIds).stream()
.filter(Objects::nonNull)
.collect(Collectors.toMap(
item -> String.valueOf(item.getUserId()),
item -> item.getDisplayName() != null ? item.getDisplayName() : item.getUsername(),
(left, right) -> left,
LinkedHashMap::new
));
}
private void collectNumericUserId(Set<Long> userIds, String candidate) {
if (candidate == null) {
return;
}
String normalized = candidate.trim();
if (!normalized.matches("\\d+")) {
return;
}
try {
userIds.add(Long.parseLong(normalized));
} catch (NumberFormatException ignored) {
}
}
private void fillTranscriptTime(MeetingTranscript transcript, JsonNode seg) { private void fillTranscriptTime(MeetingTranscript transcript, JsonNode seg) {
JsonNode timestamp = seg.path("timestamp"); JsonNode timestamp = seg.path("timestamp");
if (timestamp.isArray() && timestamp.size() >= 2) { if (timestamp.isArray() && timestamp.size() >= 2) {
@ -955,6 +1014,8 @@ public class AiTaskServiceImpl extends ServiceImpl<AiTaskMapper, AiTask> impleme
? new HashMap<>() ? new HashMap<>()
: new HashMap<>(chapterTask.getResponseData()); : new HashMap<>(chapterTask.getResponseData());
responseData.put("summarySource", summarySource.toSnapshot()); responseData.put("summarySource", summarySource.toSnapshot());
responseData.put("summarySourceText", summarySource.getText());
responseData.put("rawTranscriptText", summarySource.getRawTranscriptText());
responseData.put("chapterOutlineText", summarySource.getChapterOutlineText()); responseData.put("chapterOutlineText", summarySource.getChapterOutlineText());
responseData.put("sourceFingerprint", summarySource.getSourceFingerprint()); responseData.put("sourceFingerprint", summarySource.getSourceFingerprint());
responseData.put("chapterVersionId", summarySource.getChapterVersionId()); responseData.put("chapterVersionId", summarySource.getChapterVersionId());
@ -973,6 +1034,52 @@ public class AiTaskServiceImpl extends ServiceImpl<AiTaskMapper, AiTask> impleme
} }
} }
private MeetingSummarySource restorePreparedSummarySource(AiTask chapterTask) {
if (chapterTask == null || chapterTask.getResponseData() == null) {
return null;
}
String rawTranscriptText = stringValue(chapterTask.getResponseData().get("rawTranscriptText"));
String chapterOutlineText = stringValue(chapterTask.getResponseData().get("chapterOutlineText"));
String text = stringValue(chapterTask.getResponseData().get("summarySourceText"));
if ((rawTranscriptText == null || rawTranscriptText.isBlank())
&& (text == null || text.isBlank())) {
return null;
}
Object summarySourceSnapshot = chapterTask.getResponseData().get("summarySource");
Map<?, ?> snapshot = summarySourceSnapshot instanceof Map<?, ?> map ? map : Map.of();
if (text == null || text.isBlank()) {
text = rawTranscriptText != null && !rawTranscriptText.isBlank()
? rawTranscriptText
: chapterOutlineText;
}
return MeetingSummarySource.builder()
.text(text)
.sourceType(stringValue(snapshot.get("sourceType")))
.fallbackUsed(Boolean.TRUE.equals(snapshot.get("fallbackUsed")))
.sourceFingerprint(firstNonBlank(
stringValue(snapshot.get("sourceFingerprint")),
stringValue(chapterTask.getResponseData().get("sourceFingerprint"))
))
.chapterVersionId(longValue(firstNonNull(
snapshot.get("chapterVersionId"),
chapterTask.getResponseData().get("chapterVersionId")
)))
.chapterCount(intValue(firstNonNull(
snapshot.get("chapterCount"),
chapterTask.getResponseData().get("chapterCount")
)))
.algorithmVersion(stringValue(snapshot.get("algorithmVersion")))
.generationMode(stringValue(snapshot.get("generationMode")))
.rawTranscriptText(rawTranscriptText)
.chapterOutlineText(chapterOutlineText)
.chapterFilePath(firstNonBlank(
stringValue(snapshot.get("chapterFilePath")),
stringValue(chapterTask.getResponseData().get("chapterFilePath"))
))
.build();
}
private void executeSummaryFlow(Meeting meeting, AiTask sumTask, AiTask chapterTask) throws Exception { private void executeSummaryFlow(Meeting meeting, AiTask sumTask, AiTask chapterTask) throws Exception {
if (isExternalSummaryModeEnabled()) { if (isExternalSummaryModeEnabled()) {
triggerExternalSummaryWebhook(meeting, sumTask, chapterTask, "AUTO_AFTER_TRANSCRIPT_READY", false); triggerExternalSummaryWebhook(meeting, sumTask, chapterTask, "AUTO_AFTER_TRANSCRIPT_READY", false);
@ -985,7 +1092,10 @@ public class AiTaskServiceImpl extends ServiceImpl<AiTaskMapper, AiTask> impleme
return; return;
} }
try { try {
MeetingSummarySource summarySource = meetingTranscriptChapterService.resolveSummarySource(meeting, chapterTask != null ? chapterTask : sumTask); MeetingSummarySource summarySource = restorePreparedSummarySource(chapterTask);
if (summarySource == null || summarySource.getText() == null || summarySource.getText().isBlank()) {
summarySource = meetingTranscriptChapterService.resolveSummarySource(meeting, chapterTask != null ? chapterTask : sumTask);
}
if (summarySource.getText() == null || summarySource.getText().isBlank()) { if (summarySource.getText() == null || summarySource.getText().isBlank()) {
failPendingSummaryTask(sumTask, "没有转录内容"); failPendingSummaryTask(sumTask, "没有转录内容");
updateMeetingStatus(meeting.getId(), 4); updateMeetingStatus(meeting.getId(), 4);
@ -1031,7 +1141,7 @@ public class AiTaskServiceImpl extends ServiceImpl<AiTaskMapper, AiTask> impleme
return; return;
} }
if (summaryTask == null) { if (summaryTask == null) {
updateProgress(meeting.getId(), -1, "缺少总结任务,无法触发外部 n8n 编排", 0); updateProgress(meeting.getId(), -1, "Summary task is missing, external n8n orchestration cannot be triggered", 0);
return; return;
} }
updateMeetingStatus(meeting.getId(), 2); updateMeetingStatus(meeting.getId(), 2);
@ -1041,7 +1151,7 @@ public class AiTaskServiceImpl extends ServiceImpl<AiTaskMapper, AiTask> impleme
updateProgress(meeting.getId(), 95, result.getMessage(), 0); updateProgress(meeting.getId(), 95, result.getMessage(), 0);
} catch (Exception ex) { } catch (Exception ex) {
this.updateById(summaryTask); this.updateById(summaryTask);
updateProgress(meeting.getId(), -1, "触发外部 n8n 编排失败: " + ex.getMessage(), 0); updateProgress(meeting.getId(), -1, "闂佽崵鍠愰悷杈╃不閹达絻浜归柛灞剧☉缁剁偤鏌″搴″箹闁?n8n 缂傚倸鍊搁崐褰掓偋濡ゅ啯鏆滈柟鐐綑缁剁偤寮堕崼顐函鐞? " + ex.getMessage(), 0);
log.error("Failed to trigger external n8n webhook for meeting {}", meeting.getId(), ex); log.error("Failed to trigger external n8n webhook for meeting {}", meeting.getId(), ex);
} }
} }
@ -1052,6 +1162,56 @@ public class AiTaskServiceImpl extends ServiceImpl<AiTaskMapper, AiTask> impleme
&& !Integer.valueOf(3).equals(task.getStatus()); && !Integer.valueOf(3).equals(task.getStatus());
} }
private String stringValue(Object value) {
return value == null ? null : String.valueOf(value);
}
private String firstNonBlank(String... values) {
if (values == null) {
return null;
}
for (String value : values) {
if (value != null && !value.isBlank()) {
return value.trim();
}
}
return null;
}
private Object firstNonNull(Object... values) {
if (values == null) {
return null;
}
for (Object value : values) {
if (value != null) {
return value;
}
}
return null;
}
private Long longValue(Object value) {
if (value == null) {
return null;
}
try {
return Long.parseLong(String.valueOf(value).trim());
} catch (Exception ex) {
return null;
}
}
private Integer intValue(Object value) {
if (value == null) {
return null;
}
try {
return Integer.parseInt(String.valueOf(value).trim());
} catch (Exception ex) {
return null;
}
}
private AiModelVO resolveAsrModelForRevision(AiTask asrTask) { private AiModelVO resolveAsrModelForRevision(AiTask asrTask) {
if (asrTask == null || asrTask.getTaskConfig() == null) { if (asrTask == null || asrTask.getTaskConfig() == null) {
return null; return null;
@ -1138,7 +1298,7 @@ public class AiTaskServiceImpl extends ServiceImpl<AiTaskMapper, AiTask> impleme
private String normalizeUrlComponent(String value, String fieldName) { private String normalizeUrlComponent(String value, String fieldName) {
if (value == null || value.isBlank()) { if (value == null || value.isBlank()) {
throw new IllegalArgumentException(fieldName + "不能为空"); throw new IllegalArgumentException(fieldName + " must not be blank");
} }
return value.trim(); return value.trim();
} }
@ -1214,3 +1374,4 @@ public class AiTaskServiceImpl extends ServiceImpl<AiTaskMapper, AiTask> impleme
} }
} }

View File

@ -65,7 +65,7 @@ public class MeetingCommandServiceImpl implements MeetingCommandService {
private final com.imeeting.mapper.biz.MeetingTranscriptMapper transcriptMapper; private final com.imeeting.mapper.biz.MeetingTranscriptMapper transcriptMapper;
private final MeetingSummaryFileService meetingSummaryFileService; private final MeetingSummaryFileService meetingSummaryFileService;
private final MeetingTranscriptFileService meetingTranscriptFileService; private final MeetingTranscriptFileService meetingTranscriptFileService;
private final MeetingTranscriptRevisionService meetingTranscriptRevisionService;
private final MeetingTranscriptChapterService meetingTranscriptChapterService; private final MeetingTranscriptChapterService meetingTranscriptChapterService;
private final MeetingDomainSupport meetingDomainSupport; private final MeetingDomainSupport meetingDomainSupport;
private final MeetingRuntimeProfileResolver meetingRuntimeProfileResolver; private final MeetingRuntimeProfileResolver meetingRuntimeProfileResolver;
@ -86,7 +86,7 @@ public class MeetingCommandServiceImpl implements MeetingCommandService {
com.imeeting.mapper.biz.MeetingTranscriptMapper transcriptMapper, com.imeeting.mapper.biz.MeetingTranscriptMapper transcriptMapper,
MeetingSummaryFileService meetingSummaryFileService, MeetingSummaryFileService meetingSummaryFileService,
MeetingTranscriptFileService meetingTranscriptFileService, MeetingTranscriptFileService meetingTranscriptFileService,
MeetingTranscriptRevisionService meetingTranscriptRevisionService,
MeetingTranscriptChapterService meetingTranscriptChapterService, MeetingTranscriptChapterService meetingTranscriptChapterService,
MeetingDomainSupport meetingDomainSupport, MeetingDomainSupport meetingDomainSupport,
MeetingRuntimeProfileResolver meetingRuntimeProfileResolver, MeetingRuntimeProfileResolver meetingRuntimeProfileResolver,
@ -101,7 +101,7 @@ public class MeetingCommandServiceImpl implements MeetingCommandService {
this.transcriptMapper = transcriptMapper; this.transcriptMapper = transcriptMapper;
this.meetingSummaryFileService = meetingSummaryFileService; this.meetingSummaryFileService = meetingSummaryFileService;
this.meetingTranscriptFileService = meetingTranscriptFileService; this.meetingTranscriptFileService = meetingTranscriptFileService;
this.meetingTranscriptRevisionService = meetingTranscriptRevisionService;
this.meetingTranscriptChapterService = meetingTranscriptChapterService; this.meetingTranscriptChapterService = meetingTranscriptChapterService;
this.meetingDomainSupport = meetingDomainSupport; this.meetingDomainSupport = meetingDomainSupport;
this.meetingRuntimeProfileResolver = meetingRuntimeProfileResolver; this.meetingRuntimeProfileResolver = meetingRuntimeProfileResolver;
@ -118,7 +118,7 @@ public class MeetingCommandServiceImpl implements MeetingCommandService {
com.imeeting.mapper.biz.MeetingTranscriptMapper transcriptMapper, com.imeeting.mapper.biz.MeetingTranscriptMapper transcriptMapper,
MeetingSummaryFileService meetingSummaryFileService, MeetingSummaryFileService meetingSummaryFileService,
MeetingTranscriptFileService meetingTranscriptFileService, MeetingTranscriptFileService meetingTranscriptFileService,
MeetingTranscriptRevisionService meetingTranscriptRevisionService,
MeetingTranscriptChapterService meetingTranscriptChapterService, MeetingTranscriptChapterService meetingTranscriptChapterService,
MeetingDomainSupport meetingDomainSupport, MeetingDomainSupport meetingDomainSupport,
MeetingRuntimeProfileResolver meetingRuntimeProfileResolver, MeetingRuntimeProfileResolver meetingRuntimeProfileResolver,
@ -134,7 +134,7 @@ public class MeetingCommandServiceImpl implements MeetingCommandService {
transcriptMapper, transcriptMapper,
meetingSummaryFileService, meetingSummaryFileService,
meetingTranscriptFileService, meetingTranscriptFileService,
meetingTranscriptRevisionService,
meetingTranscriptChapterService, meetingTranscriptChapterService,
meetingDomainSupport, meetingDomainSupport,
meetingRuntimeProfileResolver, meetingRuntimeProfileResolver,
@ -585,7 +585,6 @@ public class MeetingCommandServiceImpl implements MeetingCommandService {
if (updated <= 0) { if (updated <= 0) {
throw new RuntimeException("转录记录不存在"); throw new RuntimeException("转录记录不存在");
} }
meetingTranscriptRevisionService.invalidateCurrentRevision(command.getMeetingId());
meetingTranscriptChapterService.invalidateCurrentVersion(command.getMeetingId()); meetingTranscriptChapterService.invalidateCurrentVersion(command.getMeetingId());
} }

View File

@ -18,6 +18,8 @@ import org.springframework.transaction.support.TransactionSynchronizationManager
import java.time.LocalDateTime; import java.time.LocalDateTime;
import java.util.HashMap; import java.util.HashMap;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.concurrent.TimeUnit; import java.util.concurrent.TimeUnit;
@ -55,6 +57,21 @@ public class MeetingProgressServiceImpl implements MeetingProgressService {
return objectMapper.convertValue(snapshot, Map.class); return objectMapper.convertValue(snapshot, Map.class);
} }
@Override
public Map<Long, Map<String, Object>> getProgressMaps(List<Long> meetingIds) {
Map<Long, Map<String, Object>> result = new LinkedHashMap<>();
if (meetingIds == null || meetingIds.isEmpty()) {
return result;
}
for (Long meetingId : meetingIds) {
if (meetingId == null) {
continue;
}
result.put(meetingId, getProgressMap(meetingId));
}
return result;
}
@Override @Override
public Integer resolvePercent(Long meetingId) { public Integer resolvePercent(Long meetingId) {
MeetingProgressSnapshot snapshot = redisValueSupport.getJson(RedisKeys.meetingProgressKey(meetingId), MeetingProgressSnapshot.class); MeetingProgressSnapshot snapshot = redisValueSupport.getJson(RedisKeys.meetingProgressKey(meetingId), MeetingProgressSnapshot.class);
@ -112,6 +129,7 @@ public class MeetingProgressServiceImpl implements MeetingProgressService {
int percent, int percent,
String message, String message,
int eta) { int eta) {
Integer queueAheadCount = resolveQueueAheadCount(task, stage);
String externalTaskId = null; String externalTaskId = null;
if (task != null && task.getResponseData() != null && task.getResponseData().get("task_id") != null) { if (task != null && task.getResponseData() != null && task.getResponseData().get("task_id") != null) {
externalTaskId = String.valueOf(task.getResponseData().get("task_id")); externalTaskId = String.valueOf(task.getResponseData().get("task_id"));
@ -125,8 +143,9 @@ public class MeetingProgressServiceImpl implements MeetingProgressService {
.stage(stage.getCode()) .stage(stage.getCode())
.stageOrder(stage.getOrder()) .stageOrder(stage.getOrder())
.percent(percent) .percent(percent)
.message(message) .message(resolveMessage(stage, message, queueAheadCount))
.eta(eta) .eta(eta)
.queueAheadCount(queueAheadCount)
.externalTaskId(externalTaskId) .externalTaskId(externalTaskId)
.queuedAt(task == null ? null : task.getQueuedAt()) .queuedAt(task == null ? null : task.getQueuedAt())
.startedAt(task == null ? null : task.getStartedAt()) .startedAt(task == null ? null : task.getStartedAt())
@ -185,6 +204,36 @@ public class MeetingProgressServiceImpl implements MeetingProgressService {
.last("LIMIT 1")); .last("LIMIT 1"));
} }
private Integer resolveQueueAheadCount(AiTask task, MeetingProgressStage stage) {
if (task == null
|| task.getId() == null
|| task.getQueuedAt() == null
|| stage != MeetingProgressStage.QUEUED
|| !"ASR".equals(task.getTaskType())
|| !Integer.valueOf(0).equals(task.getStatus())) {
return null;
}
return Math.toIntExact(aiTaskMapper.selectCount(new LambdaQueryWrapper<AiTask>()
.eq(AiTask::getTaskType, "ASR")
.eq(AiTask::getStatus, 0)
.and(wrapper -> wrapper
.lt(AiTask::getQueuedAt, task.getQueuedAt())
.or(orWrapper -> orWrapper
.eq(AiTask::getQueuedAt, task.getQueuedAt())
.lt(AiTask::getId, task.getId())))));
}
private String resolveMessage(MeetingProgressStage stage, String message, Integer queueAheadCount) {
if (stage != MeetingProgressStage.QUEUED) {
return message;
}
String baseMessage = (message == null || message.isBlank()) ? "已进入 ASR 队列,等待执行" : message.trim();
if (queueAheadCount == null || baseMessage.contains("前面还有")) {
return baseMessage;
}
return baseMessage + ",前面还有 " + queueAheadCount + " 个任务";
}
private boolean shouldReplace(MeetingProgressSnapshot existing, MeetingProgressSnapshot candidate) { private boolean shouldReplace(MeetingProgressSnapshot existing, MeetingProgressSnapshot candidate) {
if (candidate == null) { if (candidate == null) {
return false; return false;
@ -198,6 +247,9 @@ public class MeetingProgressServiceImpl implements MeetingProgressService {
} }
if (isTerminal(existing) && !isTerminal(candidate)) { if (isTerminal(existing) && !isTerminal(candidate)) {
if (isNewAttempt(existing, candidate)) {
return true;
}
return false; return false;
} }
if (isTerminal(candidate)) { if (isTerminal(candidate)) {
@ -239,6 +291,22 @@ public class MeetingProgressServiceImpl implements MeetingProgressService {
&& (existing.getQueuedAt() == null || candidate.getQueuedAt().isAfter(existing.getQueuedAt())); && (existing.getQueuedAt() == null || candidate.getQueuedAt().isAfter(existing.getQueuedAt()));
} }
private boolean isNewAttempt(MeetingProgressSnapshot existing, MeetingProgressSnapshot candidate) {
Long existingUpdateAt = existing.getUpdateAt() == null ? 0L : existing.getUpdateAt();
Long candidateUpdateAt = candidate.getUpdateAt() == null ? 0L : candidate.getUpdateAt();
if (candidateUpdateAt < existingUpdateAt) {
return false;
}
if (candidate.getTaskId() != null && !candidate.getTaskId().equals(existing.getTaskId())) {
return true;
}
Integer existingMeetingStatus = existing.getMeetingStatus();
Integer candidateMeetingStatus = candidate.getMeetingStatus();
return candidateMeetingStatus != null
&& existingMeetingStatus != null
&& !candidateMeetingStatus.equals(existingMeetingStatus);
}
private void afterCommitOrNow(Runnable runnable) { private void afterCommitOrNow(Runnable runnable) {
if (!TransactionSynchronizationManager.isSynchronizationActive()) { if (!TransactionSynchronizationManager.isSynchronizationActive()) {
runnable.run(); runnable.run();

View File

@ -40,7 +40,7 @@ public class MeetingQueryServiceImpl implements MeetingQueryService {
@Override @Override
public PageResult<List<MeetingVO>> pageMeetings(Integer current, Integer size, String title, Long tenantId, public PageResult<List<MeetingVO>> pageMeetings(Integer current, Integer size, String title, Long tenantId,
Long userId, String userName, String viewType, boolean isAdmin) { Long userId, String userName, String viewType, Integer status, boolean isAdmin) {
LambdaQueryWrapper<Meeting> wrapper = new LambdaQueryWrapper<Meeting>(); LambdaQueryWrapper<Meeting> wrapper = new LambdaQueryWrapper<Meeting>();
if (!isAdmin || !"all".equals(viewType)) { if (!isAdmin || !"all".equals(viewType)) {
@ -61,6 +61,10 @@ public class MeetingQueryServiceImpl implements MeetingQueryService {
wrapper.like(Meeting::getTitle, title); wrapper.like(Meeting::getTitle, title);
} }
if (status != null) {
wrapper.eq(Meeting::getStatus, status);
}
wrapper.orderByDesc(Meeting::getCreatedAt); wrapper.orderByDesc(Meeting::getCreatedAt);
Page<Meeting> page = meetingService.page(new Page<>(current, size), wrapper); Page<Meeting> page = meetingService.page(new Page<>(current, size), wrapper);

View File

@ -1,833 +0,0 @@
package com.imeeting.service.biz.impl;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.imeeting.common.SysParamKeys;
import com.imeeting.dto.biz.AiModelVO;
import com.imeeting.dto.biz.MeetingSummarySource;
import com.imeeting.dto.biz.MeetingTranscriptVO;
import com.imeeting.entity.biz.AiTask;
import com.imeeting.entity.biz.Meeting;
import com.imeeting.entity.biz.MeetingTranscript;
import com.imeeting.entity.biz.MeetingTranscriptRevision;
import com.imeeting.entity.biz.MeetingTranscriptRevisionItem;
import com.imeeting.mapper.biz.MeetingTranscriptMapper;
import com.imeeting.mapper.biz.MeetingTranscriptRevisionItemMapper;
import com.imeeting.mapper.biz.MeetingTranscriptRevisionMapper;
import com.imeeting.service.biz.MeetingTranscriptRevisionService;
import com.unisbase.service.SysParamService;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.math.BigDecimal;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.TreeMap;
import java.util.UUID;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import java.util.stream.Collectors;
@Service
@RequiredArgsConstructor
public class MeetingTranscriptRevisionServiceImpl implements MeetingTranscriptRevisionService {
private static final String RULE_PROFILE_VERSION = "v1";
private static final String TRIGGER_TASK_TYPE = "SUMMARY";
private static final String SEMANTIC_CORRECTOR = "NONE_V1";
private static final String SOURCE_TYPE_REVISION = "REVISION";
private static final String SOURCE_TYPE_RAW_FALLBACK = "RAW_FALLBACK";
private static final int MERGE_GAP_THRESHOLD_MS = 3000;
private final MeetingTranscriptMapper transcriptMapper;
private final MeetingTranscriptRevisionMapper revisionMapper;
private final MeetingTranscriptRevisionItemMapper itemMapper;
private final ObjectMapper objectMapper;
private final SysParamService sysParamService;
@Override
@Transactional(rollbackFor = Exception.class)
public String generateOfflineCurrentRevision(Meeting meeting, AiTask task, AiModelVO asrModel) {
MeetingTranscriptRevision revision = createCurrentRevision(meeting, task, asrModel);
return revision.getCleanedFullText();
}
@Override
@Transactional(rollbackFor = Exception.class)
public MeetingSummarySource resolveSummarySource(Meeting meeting, AiTask summaryTask, AiModelVO asrModel) {
List<MeetingTranscript> transcripts = loadRawTranscripts(meeting.getId());
String rawText = buildRawTranscriptText(transcripts);
String fingerprint = buildSourceFingerprint(transcripts);
if (transcripts.isEmpty() || rawText.isBlank()) {
return buildFallbackSource(rawText, fingerprint);
}
Map<String, Object> ruleProfile = buildRuleProfile(fingerprint, asrModel);
String ruleProfileJson = toJson(ruleProfile);
MeetingTranscriptRevision current = findCurrentRevision(meeting.getId());
if (isReusableCurrentRevision(current, ruleProfileJson)) {
return MeetingSummarySource.builder()
.text(current.getCleanedFullText())
.sourceType(SOURCE_TYPE_REVISION)
.revisionId(current.getId())
.fallbackUsed(false)
.sourceFingerprint(fingerprint)
.triggerTaskType(TRIGGER_TASK_TYPE)
.semanticCorrector(SEMANTIC_CORRECTOR)
.ruleProfileVersion(RULE_PROFILE_VERSION)
.build();
}
MeetingTranscriptRevision revision = createCurrentRevision(meeting, summaryTask, asrModel);
return MeetingSummarySource.builder()
.text(revision.getCleanedFullText())
.sourceType(SOURCE_TYPE_REVISION)
.revisionId(revision.getId())
.fallbackUsed(false)
.sourceFingerprint(fingerprint)
.triggerTaskType(TRIGGER_TASK_TYPE)
.semanticCorrector(SEMANTIC_CORRECTOR)
.ruleProfileVersion(RULE_PROFILE_VERSION)
.build();
}
@Override
public List<MeetingTranscriptVO> listEffectiveTranscripts(Long meetingId) {
List<MeetingTranscript> transcripts = loadRawTranscripts(meetingId);
MeetingTranscriptRevision current = findCurrentRevision(meetingId);
Map<Long, MeetingTranscriptRevisionItem> itemByTranscriptId = new LinkedHashMap<>();
if (current != null) {
itemByTranscriptId = itemMapper.selectList(new LambdaQueryWrapper<MeetingTranscriptRevisionItem>()
.eq(MeetingTranscriptRevisionItem::getRevisionId, current.getId())
.orderByAsc(MeetingTranscriptRevisionItem::getSourceSortOrder)
.orderByAsc(MeetingTranscriptRevisionItem::getId))
.stream()
.collect(Collectors.toMap(
MeetingTranscriptRevisionItem::getSourceTranscriptId,
item -> item,
(left, right) -> right.getCleanedContent() != null && !right.getCleanedContent().isBlank() ? right : left,
LinkedHashMap::new
));
}
List<MeetingTranscriptVO> result = new ArrayList<>();
for (MeetingTranscript transcript : transcripts) {
MeetingTranscriptRevisionItem item = itemByTranscriptId.get(transcript.getId());
if (item != null && isSuppressedAction(item.getActionType())) {
continue;
}
MeetingTranscriptVO vo = new MeetingTranscriptVO();
vo.setId(transcript.getId());
vo.setSpeakerId(transcript.getSpeakerId());
vo.setSpeakerName(transcript.getSpeakerName());
vo.setSpeakerLabel(transcript.getSpeakerLabel());
vo.setStartTime(transcript.getStartTime());
vo.setEndTime(transcript.getEndTime());
vo.setContent(resolveEffectiveContent(transcript, item));
result.add(vo);
}
return result;
}
@Override
@Transactional(rollbackFor = Exception.class)
public boolean updateCurrentRevisionContent(Long meetingId, Long operatorId, String content) {
MeetingTranscriptRevision current = findCurrentRevision(meetingId);
if (current == null) {
return false;
}
current.setCleanedFullText(content);
revisionMapper.updateById(current);
return true;
}
@Override
@Transactional(rollbackFor = Exception.class)
public void invalidateCurrentRevision(Long meetingId) {
revisionMapper.update(null, new LambdaUpdateWrapper<MeetingTranscriptRevision>()
.eq(MeetingTranscriptRevision::getMeetingId, meetingId)
.eq(MeetingTranscriptRevision::getIsCurrent, 1)
.set(MeetingTranscriptRevision::getIsCurrent, 0));
}
@Transactional(rollbackFor = Exception.class)
protected MeetingTranscriptRevision createCurrentRevision(Meeting meeting, AiTask task, AiModelVO asrModel) {
List<MeetingTranscript> transcripts = loadRawTranscripts(meeting.getId());
String fingerprint = buildSourceFingerprint(transcripts);
Map<String, Object> ruleProfile = buildRuleProfile(fingerprint, asrModel);
CleaningResult cleaningResult = cleanTranscripts(transcripts, ruleProfile);
MeetingTranscriptRevision current = findCurrentRevision(meeting.getId());
int nextRevisionNo = resolveNextRevisionNo(meeting.getId(), current);
MeetingTranscriptRevision draft = new MeetingTranscriptRevision();
draft.setMeetingId(meeting.getId());
draft.setSourceTaskId(task != null ? task.getId() : null);
draft.setRevisionNo(nextRevisionNo);
draft.setStatus(1);
draft.setCleanedFullText(cleaningResult.fullText());
draft.setRuleProfile(toJson(ruleProfile));
draft.setSegmentCount(transcripts.size());
draft.setDroppedSegmentCount(cleaningResult.droppedCount());
draft.setMergedGroupCount(cleaningResult.mergedGroupCount());
draft.setIsCurrent(0);
revisionMapper.insert(draft);
if (current != null) {
revisionMapper.update(null, new LambdaUpdateWrapper<MeetingTranscriptRevision>()
.eq(MeetingTranscriptRevision::getMeetingId, meeting.getId())
.eq(MeetingTranscriptRevision::getIsCurrent, 1)
.set(MeetingTranscriptRevision::getIsCurrent, 0));
}
MeetingTranscriptRevision finalRevision = new MeetingTranscriptRevision();
finalRevision.setMeetingId(meeting.getId());
finalRevision.setSourceTaskId(task != null ? task.getId() : null);
finalRevision.setRevisionNo(nextRevisionNo + 1);
finalRevision.setStatus(2);
finalRevision.setCleanedFullText(cleaningResult.fullText());
finalRevision.setRuleProfile(toJson(ruleProfile));
finalRevision.setSegmentCount(transcripts.size());
finalRevision.setDroppedSegmentCount(cleaningResult.droppedCount());
finalRevision.setMergedGroupCount(cleaningResult.mergedGroupCount());
finalRevision.setIsCurrent(1);
revisionMapper.insert(finalRevision);
for (MeetingTranscriptRevisionItem item : cleaningResult.items()) {
item.setRevisionId(finalRevision.getId());
itemMapper.insert(item);
}
return finalRevision;
}
private List<MeetingTranscript> loadRawTranscripts(Long meetingId) {
return transcriptMapper.selectList(new LambdaQueryWrapper<MeetingTranscript>()
.eq(MeetingTranscript::getMeetingId, meetingId)
.orderByAsc(MeetingTranscript::getSortOrder)
.orderByAsc(MeetingTranscript::getStartTime)
.orderByAsc(MeetingTranscript::getId));
}
private MeetingTranscriptRevision findCurrentRevision(Long meetingId) {
return revisionMapper.selectOne(new LambdaQueryWrapper<MeetingTranscriptRevision>()
.eq(MeetingTranscriptRevision::getMeetingId, meetingId)
.eq(MeetingTranscriptRevision::getIsCurrent, 1)
.orderByDesc(MeetingTranscriptRevision::getRevisionNo)
.last("limit 1"));
}
private int resolveNextRevisionNo(Long meetingId, MeetingTranscriptRevision current) {
if (current != null && current.getRevisionNo() != null) {
return current.getRevisionNo() + 1;
}
MeetingTranscriptRevision latest = revisionMapper.selectOne(new LambdaQueryWrapper<MeetingTranscriptRevision>()
.eq(MeetingTranscriptRevision::getMeetingId, meetingId)
.orderByDesc(MeetingTranscriptRevision::getRevisionNo)
.last("limit 1"));
return latest == null || latest.getRevisionNo() == null ? 1 : latest.getRevisionNo() + 1;
}
private boolean isReusableCurrentRevision(MeetingTranscriptRevision current, String expectedRuleProfile) {
return current != null
&& Integer.valueOf(1).equals(current.getIsCurrent())
&& Integer.valueOf(2).equals(current.getStatus())
&& current.getCleanedFullText() != null
&& !current.getCleanedFullText().isBlank()
&& Objects.equals(expectedRuleProfile, current.getRuleProfile());
}
private Map<String, Object> buildRuleProfile(String fingerprint, AiModelVO asrModel) {
Map<String, Object> profile = new LinkedHashMap<>();
profile.put("ruleProfileVersion", RULE_PROFILE_VERSION);
profile.put("sourceFingerprint", fingerprint);
profile.put("triggerTaskType", TRIGGER_TASK_TYPE);
profile.put("semanticCorrector", SEMANTIC_CORRECTOR);
profile.put("transcriptRuleFillerWords", resolveFillerWords(asrModel));
profile.put("transcriptRuleReplacements", resolveReplacementRules(asrModel));
return profile;
}
private List<String> resolveFillerWords(AiModelVO asrModel) {
List<String> configured = parseCleanupWords(sysParamService.getCachedParamValue(
SysParamKeys.MEETING_TRANSCRIPT_CLEANUP_FILLER_WORDS,
""
));
if (!configured.isEmpty()) {
return configured;
}
if (asrModel == null || asrModel.getMediaConfig() == null) {
return List.of();
}
Object raw = asrModel.getMediaConfig().get("transcriptRuleFillerWords");
if (!(raw instanceof List<?> list)) {
return List.of();
}
return list.stream()
.filter(Objects::nonNull)
.map(String::valueOf)
.map(String::trim)
.filter(value -> !value.isBlank())
.distinct()
.sorted()
.toList();
}
private Map<String, String> resolveReplacementRules(AiModelVO asrModel) {
Map<String, String> configured = parseCleanupReplacements(sysParamService.getCachedParamValue(
SysParamKeys.MEETING_TRANSCRIPT_CLEANUP_REPLACEMENTS,
""
));
if (!configured.isEmpty()) {
return configured;
}
if (asrModel == null || asrModel.getMediaConfig() == null) {
return Map.of();
}
Object raw = asrModel.getMediaConfig().get("transcriptRuleReplacements");
if (!(raw instanceof Map<?, ?> map)) {
return Map.of();
}
Map<String, String> result = new TreeMap<>();
for (Map.Entry<?, ?> entry : map.entrySet()) {
if (entry.getKey() == null || entry.getValue() == null) {
continue;
}
String key = String.valueOf(entry.getKey()).trim();
String value = String.valueOf(entry.getValue()).trim();
if (!key.isBlank() && !value.isBlank()) {
result.put(key, value);
}
}
return result;
}
private List<String> parseCleanupWords(String raw) {
if (raw == null || raw.isBlank()) {
return List.of();
}
String normalized = raw.trim();
try {
if (normalized.startsWith("[")) {
List<?> parsed = objectMapper.readValue(normalized, new TypeReference<List<?>>() {});
return parsed.stream()
.filter(Objects::nonNull)
.map(String::valueOf)
.map(String::trim)
.filter(value -> !value.isBlank())
.distinct()
.sorted()
.toList();
}
} catch (Exception ignored) {
// Fall back to plain-text parsing.
}
return java.util.Arrays.stream(normalized.split("[,;\\r\\n]+"))
.map(String::trim)
.filter(value -> !value.isBlank())
.distinct()
.sorted()
.toList();
}
private Map<String, String> parseCleanupReplacements(String raw) {
if (raw == null || raw.isBlank()) {
return Map.of();
}
String normalized = raw.trim();
try {
if (normalized.startsWith("{")) {
Map<String, Object> parsed = objectMapper.readValue(normalized, new TypeReference<Map<String, Object>>() {});
Map<String, String> result = new TreeMap<>();
for (Map.Entry<String, Object> entry : parsed.entrySet()) {
if (entry.getKey() == null || entry.getValue() == null) {
continue;
}
String key = entry.getKey().trim();
String value = String.valueOf(entry.getValue()).trim();
if (!key.isBlank() && !value.isBlank()) {
result.put(key, value);
}
}
return result;
}
} catch (Exception ignored) {
// Fall back to line-based parsing.
}
Map<String, String> result = new TreeMap<>();
for (String line : normalized.split("[\\r\\n]+")) {
String candidate = line == null ? "" : line.trim();
if (candidate.isBlank()) {
continue;
}
String[] separatorCandidates = {"=>", "=", ":"};
for (String separator : separatorCandidates) {
int index = candidate.indexOf(separator);
if (index <= 0 || index >= candidate.length() - separator.length()) {
continue;
}
String key = candidate.substring(0, index).trim();
String value = candidate.substring(index + separator.length()).trim();
if (!key.isBlank() && !value.isBlank()) {
result.put(key, value);
}
break;
}
}
return result;
}
private CleaningResult cleanTranscripts(List<MeetingTranscript> transcripts, Map<String, Object> ruleProfile) {
@SuppressWarnings("unchecked")
List<String> fillerWords = (List<String>) ruleProfile.getOrDefault("transcriptRuleFillerWords", List.of());
@SuppressWarnings("unchecked")
Map<String, String> replacements = (Map<String, String>) ruleProfile.getOrDefault("transcriptRuleReplacements", Map.of());
List<String> orderedFillerWords = fillerWords.stream()
.filter(Objects::nonNull)
.map(String::trim)
.filter(value -> !value.isBlank())
.distinct()
.sorted(Comparator.comparingInt(String::length).reversed().thenComparing(String::compareTo))
.toList();
List<Map.Entry<String, String>> orderedReplacementRules = replacements.entrySet().stream()
.filter(entry -> entry.getKey() != null && entry.getValue() != null)
.sorted(Comparator.<Map.Entry<String, String>>comparingInt(entry -> entry.getKey().length()).reversed()
.thenComparing(Map.Entry::getKey))
.toList();
List<MeetingTranscriptRevisionItem> items = new ArrayList<>();
List<SegmentGroupState> groups = new ArrayList<>();
MeetingTranscript previousTranscript = null;
SegmentGroupState currentGroup = null;
int droppedCount = 0;
for (int index = 0; index < transcripts.size(); index++) {
MeetingTranscript transcript = transcripts.get(index);
String normalizedSource = normalizeRawContent(transcript.getContent());
MeetingTranscriptRevisionItem item = createRevisionItem(transcript, transcripts, index);
if (previousTranscript != null
&& currentGroup != null
&& isAdjacentDuplicate(previousTranscript, transcript)) {
item.setActionType("DROP_DUPLICATE");
item.setCleanedContent("");
item.setCleanedSpeakerName(transcript.getSpeakerName());
item.setMergeGroupId(currentGroup.getGroupId());
item.setConfidence(confidenceForAction("DROP_DUPLICATE"));
item.setRuleHits(toJson(buildRuleHits(List.of(), List.of())));
items.add(item);
droppedCount++;
previousTranscript = transcript;
continue;
}
if (currentGroup != null && shouldMergeIntoCurrentGroup(currentGroup, transcript)) {
currentGroup.appendSourceContent(normalizedSource, transcript);
currentGroup.incrementSourceSegmentCount();
currentGroup.getRepresentativeItem().setMergeGroupId(currentGroup.getGroupId());
item.setActionType("MERGE_INTO_PREV");
item.setCleanedContent("");
item.setCleanedSpeakerName(transcript.getSpeakerName());
item.setMergeGroupId(currentGroup.getGroupId());
item.setConfidence(confidenceForAction("MERGE_INTO_PREV"));
item.setRuleHits(toJson(buildRuleHits(List.of(), List.of())));
items.add(item);
previousTranscript = transcript;
continue;
}
item.setMergeGroupId("");
items.add(item);
currentGroup = SegmentGroupState.start("merge-" + transcript.getId() + "-" + UUID.randomUUID(), transcript, item, normalizedSource);
groups.add(currentGroup);
previousTranscript = transcript;
}
List<String> finalLines = new ArrayList<>();
int mergedGroupCount = 0;
for (SegmentGroupState group : groups) {
if (group.getSourceSegmentCount() > 1) {
mergedGroupCount++;
group.getRepresentativeItem().setMergeGroupId(group.getGroupId());
}
TextCleanupResult cleanupResult = cleanTranscriptContent(group.getMergedSourceContent(), orderedFillerWords, orderedReplacementRules);
String cleaned = cleanupResult.cleanedContent();
MeetingTranscriptRevisionItem representativeItem = group.getRepresentativeItem();
representativeItem.setCleanedContent(cleaned);
representativeItem.setCleanedSpeakerName(group.getRepresentativeTranscript().getSpeakerName());
representativeItem.setActionType(resolveActionType(group.getMergedSourceContent(), cleaned,
cleanupResult.matchedFillerWords(), cleanupResult.matchedReplacementRules()));
representativeItem.setRuleHits(toJson(buildRuleHits(
cleanupResult.matchedFillerWords(),
cleanupResult.matchedReplacementRules()
)));
representativeItem.setConfidence(confidenceForAction(representativeItem.getActionType()));
if (cleaned.isBlank()) {
droppedCount += group.getSourceSegmentCount();
continue;
}
finalLines.add(formatTranscriptLine(group.getRepresentativeTranscript(), cleaned));
}
String fullText = finalLines.stream()
.filter(line -> line != null && !line.isBlank())
.collect(Collectors.joining("\n"));
return new CleaningResult(fullText, items, droppedCount, mergedGroupCount);
}
private String buildSourceFingerprint(List<MeetingTranscript> transcripts) {
String raw = transcripts.stream()
.sorted(Comparator.comparing(MeetingTranscript::getSortOrder, Comparator.nullsLast(Integer::compareTo))
.thenComparing(MeetingTranscript::getStartTime, Comparator.nullsLast(Integer::compareTo))
.thenComparing(MeetingTranscript::getId, Comparator.nullsLast(Long::compareTo)))
.map(transcript -> String.join("|",
String.valueOf(transcript.getId()),
nullSafe(transcript.getSpeakerId()),
nullSafe(transcript.getSpeakerName()),
nullSafe(transcript.getContent()),
String.valueOf(transcript.getStartTime()),
String.valueOf(transcript.getEndTime()),
String.valueOf(transcript.getSortOrder())))
.collect(Collectors.joining("\n"));
try {
MessageDigest digest = MessageDigest.getInstance("SHA-256");
byte[] hashed = digest.digest(raw.getBytes(StandardCharsets.UTF_8));
StringBuilder builder = new StringBuilder();
for (byte b : hashed) {
builder.append(String.format("%02x", b));
}
return builder.toString();
} catch (Exception ex) {
throw new RuntimeException("计算转录内容失败", ex);
}
}
private String buildRawTranscriptText(List<MeetingTranscript> transcripts) {
return transcripts.stream()
.filter(Objects::nonNull)
.map(transcript -> formatTranscriptLine(transcript, normalizeRawContent(transcript.getContent())))
.filter(line -> line != null && !line.isBlank())
.collect(Collectors.joining("\n"));
}
private MeetingSummarySource buildFallbackSource(String rawText, String fingerprint) {
return MeetingSummarySource.builder()
.text(rawText)
.sourceType(SOURCE_TYPE_RAW_FALLBACK)
.revisionId(null)
.fallbackUsed(true)
.sourceFingerprint(fingerprint)
.triggerTaskType(TRIGGER_TASK_TYPE)
.semanticCorrector(SEMANTIC_CORRECTOR)
.ruleProfileVersion(RULE_PROFILE_VERSION)
.build();
}
private String formatTranscriptLine(MeetingTranscript transcript, String content) {
if (content == null || content.isBlank()) {
return null;
}
String speaker = transcript.getSpeakerName();
if (speaker == null || speaker.isBlank()) {
speaker = transcript.getSpeakerId();
}
if (speaker == null || speaker.isBlank()) {
return content.trim();
}
return speaker.trim() + ": " + content.trim();
}
private String resolveEffectiveContent(MeetingTranscript transcript, MeetingTranscriptRevisionItem item) {
if (item == null) {
return transcript.getContent();
}
if (item.getCleanedContent() != null) {
return item.getCleanedContent();
}
return transcript.getContent();
}
private boolean isSuppressedAction(String actionType) {
return "MERGE_INTO_PREV".equals(actionType)
|| "DROP_FILLER".equals(actionType)
|| "DROP_DUPLICATE".equals(actionType);
}
private String resolveActionType(String normalizedSource,
String cleaned,
List<String> matchedFillerWords,
List<String> matchedReplacementRules) {
if (cleaned == null || cleaned.isBlank()) {
return !matchedFillerWords.isEmpty() ? "DROP_FILLER" : "EDIT";
}
if (Objects.equals(cleaned, normalizedSource)) {
return "KEEP";
}
return "RULE_REPLACED";
}
private Map<String, Object> buildRuleHits(List<String> matchedFillerWords, List<String> matchedReplacementRules) {
Map<String, Object> ruleHits = new LinkedHashMap<>();
ruleHits.put("fillerWords", matchedFillerWords == null ? List.of() : List.copyOf(matchedFillerWords));
ruleHits.put("replacements", matchedReplacementRules == null ? List.of() : List.copyOf(matchedReplacementRules));
return ruleHits;
}
private MeetingTranscriptRevisionItem createRevisionItem(MeetingTranscript transcript,
List<MeetingTranscript> transcripts,
int index) {
MeetingTranscriptRevisionItem item = new MeetingTranscriptRevisionItem();
item.setSourceTranscriptId(transcript.getId());
item.setSourceSortOrder(transcript.getSortOrder());
item.setSourceSpeakerId(transcript.getSpeakerId());
item.setSourceSpeakerName(transcript.getSpeakerName());
item.setSourceContent(transcript.getContent());
item.setCleanedSpeakerName(transcript.getSpeakerName());
item.setContextSnapshot(buildContextSnapshot(transcripts, index));
return item;
}
private String buildContextSnapshot(List<MeetingTranscript> transcripts, int index) {
Map<String, Object> snapshot = new LinkedHashMap<>();
snapshot.put("prevContent", index > 0 ? normalizeRawContent(transcripts.get(index - 1).getContent()) : "");
snapshot.put("currentContent", normalizeRawContent(transcripts.get(index).getContent()));
snapshot.put("nextContent", index + 1 < transcripts.size()
? normalizeRawContent(transcripts.get(index + 1).getContent())
: "");
return toJson(snapshot);
}
private boolean isAdjacentDuplicate(MeetingTranscript previousTranscript, MeetingTranscript currentTranscript) {
return sameSpeaker(previousTranscript, currentTranscript)
&& !normalizeComparisonText(previousTranscript.getContent()).isBlank()
&& Objects.equals(
normalizeComparisonText(previousTranscript.getContent()),
normalizeComparisonText(currentTranscript.getContent())
);
}
private boolean shouldMergeIntoCurrentGroup(SegmentGroupState currentGroup, MeetingTranscript currentTranscript) {
if (currentGroup == null || currentTranscript == null) {
return false;
}
if (!sameSpeaker(currentGroup.getLastTranscript(), currentTranscript)) {
return false;
}
Integer previousEndTime = currentGroup.getLastTranscript().getEndTime();
Integer currentStartTime = currentTranscript.getStartTime();
if (previousEndTime == null || currentStartTime == null) {
return false;
}
int gap = currentStartTime - previousEndTime;
return gap >= 0 && gap <= MERGE_GAP_THRESHOLD_MS;
}
private boolean sameSpeaker(MeetingTranscript left, MeetingTranscript right) {
if (left == null || right == null) {
return false;
}
return Objects.equals(nullSafe(left.getSpeakerId()), nullSafe(right.getSpeakerId()))
&& Objects.equals(nullSafe(left.getSpeakerName()), nullSafe(right.getSpeakerName()));
}
private String normalizeComparisonText(String content) {
return normalizeRawContent(content)
.replaceAll("\\s+", "")
.replaceAll("[,。?!;:、,.!?;:]", "");
}
private TextCleanupResult cleanTranscriptContent(String content,
List<String> fillerWords,
List<Map.Entry<String, String>> replacementRules) {
String cleaned = normalizeRawContent(content);
List<String> matchedFillerWords = new ArrayList<>();
List<String> matchedReplacementRules = new ArrayList<>();
for (String fillerWord : fillerWords) {
String updated = removeFillerWord(cleaned, fillerWord);
if (!Objects.equals(updated, cleaned)) {
matchedFillerWords.add(fillerWord);
cleaned = updated;
}
}
for (Map.Entry<String, String> entry : replacementRules) {
if (cleaned.contains(entry.getKey())) {
matchedReplacementRules.add(entry.getKey() + "->" + entry.getValue());
cleaned = cleaned.replace(entry.getKey(), entry.getValue());
}
}
cleaned = normalizeCleanedContent(cleaned);
return new TextCleanupResult(cleaned, matchedFillerWords, matchedReplacementRules);
}
private String removeFillerWord(String content, String fillerWord) {
if (content == null || content.isBlank() || fillerWord == null || fillerWord.isBlank()) {
return content == null ? "" : content;
}
Pattern pattern = Pattern.compile("(^|[\\s、,.!?;:])(" + Pattern.quote(fillerWord) + ")(?=($|[\\s、,.!?;:]))");
Matcher matcher = pattern.matcher(content);
StringBuffer buffer = new StringBuffer();
boolean changed = false;
while (matcher.find()) {
String prefix = matcher.group(1);
matcher.appendReplacement(buffer, Matcher.quoteReplacement(prefix == null ? "" : prefix));
changed = true;
}
matcher.appendTail(buffer);
return changed ? buffer.toString() : content;
}
private String normalizeRawContent(String content) {
if (content == null || content.isBlank()) {
return "";
}
return content.trim();
}
private String normalizeCleanedContent(String content) {
if (content == null || content.isBlank()) {
return "";
}
String normalized = content.replace("\r\n", " ")
.replace("\n", " ")
.replaceAll("\\s+", " ")
.trim();
normalized = normalized.replaceAll("\\s*([,。?!;:、,.!?;:])\\s*", "$1");
normalized = normalized.replaceAll("([,。?!;:、,.!?;:])[,。?!;:、,.!?;:]+", "$1");
normalized = normalized.replaceAll("^[,。?!;:、,.!?;:]+", "");
normalized = normalized.replaceAll("\\(\\s+\\)", "()");
return normalized.trim();
}
private BigDecimal confidenceForAction(String actionType) {
return switch (actionType) {
case "DROP_DUPLICATE" -> BigDecimal.valueOf(0.98D);
case "DROP_FILLER" -> BigDecimal.valueOf(0.95D);
case "MERGE_INTO_PREV" -> BigDecimal.valueOf(0.90D);
case "RULE_REPLACED" -> BigDecimal.valueOf(0.88D);
default -> BigDecimal.ONE;
};
}
private String nullSafe(String value) {
return value == null ? "" : value;
}
private String toJson(Object value) {
try {
return objectMapper.writeValueAsString(value);
} catch (JsonProcessingException ex) {
throw new RuntimeException("序列化修正版元数据失败", ex);
}
}
@SuppressWarnings("unused")
private Map<String, Object> readRuleProfile(String raw) {
if (raw == null || raw.isBlank()) {
return Map.of();
}
try {
return objectMapper.readValue(raw, new TypeReference<>() {});
} catch (JsonProcessingException ex) {
return Map.of();
}
}
private record CleaningResult(String fullText,
List<MeetingTranscriptRevisionItem> items,
int droppedCount,
int mergedGroupCount) {
}
private record TextCleanupResult(String cleanedContent,
List<String> matchedFillerWords,
List<String> matchedReplacementRules) {
}
private static final class SegmentGroupState {
private final String groupId;
private final MeetingTranscript representativeTranscript;
private final MeetingTranscriptRevisionItem representativeItem;
private MeetingTranscript lastTranscript;
private String mergedSourceContent;
private int sourceSegmentCount;
private SegmentGroupState(String groupId,
MeetingTranscript representativeTranscript,
MeetingTranscriptRevisionItem representativeItem,
String mergedSourceContent) {
this.groupId = groupId;
this.representativeTranscript = representativeTranscript;
this.representativeItem = representativeItem;
this.lastTranscript = representativeTranscript;
this.mergedSourceContent = mergedSourceContent;
this.sourceSegmentCount = 1;
}
private static SegmentGroupState start(String groupId,
MeetingTranscript representativeTranscript,
MeetingTranscriptRevisionItem representativeItem,
String mergedSourceContent) {
return new SegmentGroupState(groupId, representativeTranscript, representativeItem, mergedSourceContent);
}
private void appendSourceContent(String nextContent, MeetingTranscript transcript) {
this.mergedSourceContent = joinTranscriptContent(this.mergedSourceContent, nextContent);
this.lastTranscript = transcript;
}
private void incrementSourceSegmentCount() {
this.sourceSegmentCount++;
}
private static String joinTranscriptContent(String left, String right) {
if (left == null || left.isBlank()) {
return right == null ? "" : right;
}
if (right == null || right.isBlank()) {
return left;
}
boolean leftAsciiTail = Character.isLetterOrDigit(left.charAt(left.length() - 1));
boolean rightAsciiHead = Character.isLetterOrDigit(right.charAt(0));
if (leftAsciiTail && rightAsciiHead) {
return left + " " + right;
}
return left + right;
}
private String getGroupId() {
return groupId;
}
private MeetingTranscript getRepresentativeTranscript() {
return representativeTranscript;
}
private MeetingTranscriptRevisionItem getRepresentativeItem() {
return representativeItem;
}
private MeetingTranscript getLastTranscript() {
return lastTranscript;
}
private String getMergedSourceContent() {
return mergedSourceContent;
}
private int getSourceSegmentCount() {
return sourceSegmentCount;
}
}
}

View File

@ -8,6 +8,8 @@ import com.imeeting.entity.biz.AiTask;
import com.imeeting.service.biz.MeetingProgressService; import com.imeeting.service.biz.MeetingProgressService;
import org.springframework.data.redis.core.StringRedisTemplate; import org.springframework.data.redis.core.StringRedisTemplate;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.concurrent.TimeUnit; import java.util.concurrent.TimeUnit;
@ -38,6 +40,21 @@ public class RedisOnlyMeetingProgressServiceAdapter implements MeetingProgressSe
return objectMapper.convertValue(snapshot, Map.class); return objectMapper.convertValue(snapshot, Map.class);
} }
@Override
public Map<Long, Map<String, Object>> getProgressMaps(List<Long> meetingIds) {
Map<Long, Map<String, Object>> result = new LinkedHashMap<>();
if (meetingIds == null || meetingIds.isEmpty()) {
return result;
}
for (Long meetingId : meetingIds) {
if (meetingId == null) {
continue;
}
result.put(meetingId, getProgressMap(meetingId));
}
return result;
}
@Override @Override
public Integer resolvePercent(Long meetingId) { public Integer resolvePercent(Long meetingId) {
MeetingProgressSnapshot snapshot = readSnapshot(meetingId); MeetingProgressSnapshot snapshot = readSnapshot(meetingId);
@ -108,6 +125,10 @@ public class RedisOnlyMeetingProgressServiceAdapter implements MeetingProgressSe
int percent, int percent,
String message, String message,
int eta) { int eta) {
String resolvedMessage = message;
if (stage == MeetingProgressStage.QUEUED && (resolvedMessage == null || resolvedMessage.isBlank())) {
resolvedMessage = "已进入 ASR 队列,等待执行";
}
return MeetingProgressSnapshot.builder() return MeetingProgressSnapshot.builder()
.meetingId(meetingId) .meetingId(meetingId)
.taskId(task == null ? null : task.getId()) .taskId(task == null ? null : task.getId())
@ -117,7 +138,7 @@ public class RedisOnlyMeetingProgressServiceAdapter implements MeetingProgressSe
.stage(stage.getCode()) .stage(stage.getCode())
.stageOrder(stage.getOrder()) .stageOrder(stage.getOrder())
.percent(percent) .percent(percent)
.message(message) .message(resolvedMessage)
.eta(eta) .eta(eta)
.queuedAt(task == null ? null : task.getQueuedAt()) .queuedAt(task == null ? null : task.getQueuedAt())
.startedAt(task == null ? null : task.getStartedAt()) .startedAt(task == null ? null : task.getStartedAt())

View File

@ -129,6 +129,7 @@ public class MeetingMcpToolService {
loginUser.getUserId(), loginUser.getUserId(),
resolveCreatorName(loginUser), resolveCreatorName(loginUser),
"all", "all",
null,
isAdmin isAdmin
); );

View File

@ -1,303 +1,152 @@
package com.imeeting.service.biz.impl; package com.imeeting.service.biz.impl;
import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.ObjectMapper;
import com.imeeting.dto.biz.MeetingSummarySource; import com.imeeting.dto.biz.AiModelVO;
import com.imeeting.entity.biz.AiTask; import com.imeeting.entity.biz.AiTask;
import com.imeeting.entity.biz.HotWord;
import com.imeeting.entity.biz.Meeting; import com.imeeting.entity.biz.Meeting;
import com.imeeting.entity.biz.MeetingTranscript;
import com.imeeting.mapper.biz.MeetingMapper; import com.imeeting.mapper.biz.MeetingMapper;
import com.imeeting.mapper.biz.MeetingTranscriptMapper; import com.imeeting.mapper.biz.MeetingTranscriptMapper;
import com.imeeting.service.biz.AiModelService; import com.imeeting.service.biz.AiModelService;
import com.imeeting.service.biz.HotWordService; import com.imeeting.service.biz.HotWordService;
import com.imeeting.service.biz.MeetingProgressService;
import com.imeeting.service.biz.MeetingSummaryFileService; import com.imeeting.service.biz.MeetingSummaryFileService;
import com.imeeting.service.biz.MeetingTranscriptChapterService; import com.imeeting.service.biz.MeetingTranscriptChapterService;
import com.imeeting.service.biz.MeetingTranscriptFileService; import com.imeeting.service.biz.MeetingTranscriptFileService;
import com.imeeting.service.biz.MeetingTranscriptRevisionService; import com.imeeting.support.RedisValueSupport;
import com.imeeting.support.TaskSecurityContextRunner; import com.imeeting.support.TaskSecurityContextRunner;
import com.unisbase.mapper.SysUserMapper; import com.unisbase.mapper.SysUserMapper;
import com.unisbase.service.SysParamService;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.data.redis.core.ValueOperations;
import org.springframework.test.util.ReflectionTestUtils; import org.springframework.test.util.ReflectionTestUtils;
import java.net.URI; import java.util.HashMap;
import java.util.List; import java.util.List;
import java.util.Map;
import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertTrue; import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyLong;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.doReturn;
import static org.mockito.Mockito.mock; import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.spy;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when; import static org.mockito.Mockito.when;
class AiTaskServiceImplTest { class AiTaskServiceImplTest {
@Test @Test
void appendPathShouldTrimWhitespaceAndNormalizeSlashBoundaries() { void buildAsrRequestShouldFollowCurrentOfflineAsrContract() {
AiTaskServiceImpl service = createService(); HotWordService hotWordService = mock(HotWordService.class);
HotWord hotWord = new HotWord();
hotWord.setWord("汇智");
hotWord.setWeight(25);
when(hotWordService.list(any())).thenReturn(List.of(hotWord));
String url = ReflectionTestUtils.invokeMethod( AiTaskServiceImpl service = new AiTaskServiceImpl(
service,
"appendPath",
" http://10.100.52.43:1234/ ",
" /v1/chat/completions "
);
assertEquals("http://10.100.52.43:1234/v1/chat/completions", url);
}
@Test
void appendPathShouldReturnAbsolutePathWhenApiPathIsFullUrl() {
AiTaskServiceImpl service = createService();
String url = ReflectionTestUtils.invokeMethod(
service,
"appendPath",
" http://10.100.52.43:1234/ ",
" https://example.com/custom-endpoint "
);
assertEquals("https://example.com/custom-endpoint", url);
}
@Test
void buildUriShouldTrimWhitespaceBeforeUriCreate() {
AiTaskServiceImpl service = createService();
URI uri = ReflectionTestUtils.invokeMethod(
service,
"buildUri",
" http://10.100.52.43:1234/v1/chat/completions "
);
assertEquals("http://10.100.52.43:1234/v1/chat/completions", uri.toString());
}
@Test
void dispatchTasksShouldFailSummaryTaskWhenTranscriptContentIsBlank() {
MeetingMapper meetingMapper = mock(MeetingMapper.class);
MeetingTranscriptMapper transcriptMapper = mock(MeetingTranscriptMapper.class);
AiModelService aiModelService = mock(AiModelService.class);
StringRedisTemplate redisTemplate = mock(StringRedisTemplate.class);
@SuppressWarnings("unchecked")
ValueOperations<String, String> valueOperations = mock(ValueOperations.class);
when(redisTemplate.opsForValue()).thenReturn(valueOperations);
when(valueOperations.setIfAbsent(anyString(), anyString(), anyLong(), any())).thenReturn(true);
AiTaskServiceImpl service = spy(createService(
meetingMapper,
transcriptMapper,
aiModelService,
redisTemplate,
new TaskSecurityContextRunner(),
mock(MeetingTranscriptFileService.class),
mock(MeetingTranscriptRevisionService.class),
mock(MeetingTranscriptChapterService.class)
));
doReturn(true).when(service).updateById(any());
Meeting meeting = new Meeting();
meeting.setId(66L);
meeting.setAudioUrl("/audio/demo.wav");
when(meetingMapper.selectById(66L)).thenReturn(meeting);
MeetingTranscript transcript = new MeetingTranscript();
transcript.setSpeakerName("Alice");
transcript.setContent(" ");
when(transcriptMapper.selectList(any())).thenReturn(List.of(transcript));
AiTask summaryTask = new AiTask();
summaryTask.setId(99L);
summaryTask.setMeetingId(66L);
summaryTask.setTaskType("SUMMARY");
summaryTask.setStatus(0);
doReturn(null, null, summaryTask).when(service).getOne(any());
service.dispatchTasks(66L, 1L, 2L);
assertEquals(3, summaryTask.getStatus());
assertTrue(summaryTask.getErrorMsg() != null && !summaryTask.getErrorMsg().isBlank());
verify(aiModelService, never()).getModelById(anyLong(), anyString());
}
@Test
void dispatchSummaryTaskShouldFailWhenTranscriptContentIsBlank() {
MeetingMapper meetingMapper = mock(MeetingMapper.class);
MeetingTranscriptMapper transcriptMapper = mock(MeetingTranscriptMapper.class);
AiModelService aiModelService = mock(AiModelService.class);
StringRedisTemplate redisTemplate = mock(StringRedisTemplate.class);
MeetingTranscriptChapterService chapterService = mock(MeetingTranscriptChapterService.class);
@SuppressWarnings("unchecked")
ValueOperations<String, String> valueOperations = mock(ValueOperations.class);
when(redisTemplate.opsForValue()).thenReturn(valueOperations);
when(valueOperations.setIfAbsent(anyString(), anyString(), anyLong(), any())).thenReturn(true);
when(chapterService.resolveSummarySource(any(), any())).thenReturn(MeetingSummarySource.builder()
.text(" ")
.sourceType("RAW_FALLBACK")
.fallbackUsed(true)
.algorithmVersion("cohesion-v1")
.build());
AiTaskServiceImpl service = spy(createService(
meetingMapper,
transcriptMapper,
aiModelService,
redisTemplate,
new TaskSecurityContextRunner(),
mock(MeetingTranscriptFileService.class),
mock(MeetingTranscriptRevisionService.class),
chapterService
));
doReturn(true).when(service).updateById(any());
Meeting meeting = new Meeting();
meeting.setId(77L);
when(meetingMapper.selectById(77L)).thenReturn(meeting);
AiTask chapterTask = new AiTask();
chapterTask.setId(88L);
chapterTask.setMeetingId(77L);
chapterTask.setTaskType("CHAPTER");
chapterTask.setStatus(0);
AiTask summaryTask = new AiTask();
summaryTask.setId(100L);
summaryTask.setMeetingId(77L);
summaryTask.setTaskType("SUMMARY");
summaryTask.setStatus(0);
doReturn(chapterTask, summaryTask).when(service).getOne(any());
service.dispatchSummaryTask(77L, 1L, 2L);
assertEquals(3, summaryTask.getStatus());
assertTrue(summaryTask.getErrorMsg() != null && !summaryTask.getErrorMsg().isBlank());
verify(aiModelService, never()).getModelById(anyLong(), anyString());
}
@Test
void dispatchSummaryTaskShouldWaitForExternalOrchestrationWhenExternalModeEnabled() {
MeetingMapper meetingMapper = mock(MeetingMapper.class);
MeetingTranscriptMapper transcriptMapper = mock(MeetingTranscriptMapper.class);
AiModelService aiModelService = mock(AiModelService.class);
StringRedisTemplate redisTemplate = mock(StringRedisTemplate.class);
MeetingTranscriptChapterService chapterService = mock(MeetingTranscriptChapterService.class);
@SuppressWarnings("unchecked")
ValueOperations<String, String> valueOperations = mock(ValueOperations.class);
when(redisTemplate.opsForValue()).thenReturn(valueOperations);
when(valueOperations.setIfAbsent(anyString(), anyString(), anyLong(), any())).thenReturn(true);
AiTaskServiceImpl service = spy(createService(
meetingMapper,
transcriptMapper,
aiModelService,
redisTemplate,
new TaskSecurityContextRunner(),
mock(MeetingTranscriptFileService.class),
mock(MeetingTranscriptRevisionService.class),
chapterService
));
ReflectionTestUtils.setField(service, "summaryOrchestrationMode", "EXTERNAL_N8N");
Meeting meeting = new Meeting();
meeting.setId(78L);
when(meetingMapper.selectById(78L)).thenReturn(meeting);
AiTask summaryTask = new AiTask();
summaryTask.setId(101L);
summaryTask.setMeetingId(78L);
summaryTask.setTaskType("SUMMARY");
summaryTask.setStatus(0);
doReturn(summaryTask, null).when(service).getOne(any());
service.dispatchSummaryTask(78L, 1L, 2L);
verify(chapterService, never()).resolveSummarySource(any(), any());
verify(aiModelService, never()).getModelById(anyLong(), anyString());
}
@Test
void saveTranscriptsShouldInitializeTranscriptFileAfterFirstPersist() throws Exception {
MeetingMapper meetingMapper = mock(MeetingMapper.class);
MeetingTranscriptMapper transcriptMapper = mock(MeetingTranscriptMapper.class);
MeetingTranscriptFileService transcriptFileService = mock(MeetingTranscriptFileService.class);
AiTaskServiceImpl service = createService(
meetingMapper,
transcriptMapper,
mock(AiModelService.class),
mock(StringRedisTemplate.class),
mock(TaskSecurityContextRunner.class),
transcriptFileService,
mock(MeetingTranscriptRevisionService.class),
mock(MeetingTranscriptChapterService.class)
);
Meeting meeting = new Meeting();
meeting.setId(88L);
ReflectionTestUtils.invokeMethod(
service,
"saveTranscripts",
meeting,
new ObjectMapper().readTree("""
{
"segments": [
{
"speaker_id": "123",
"speaker_name": "Alice",
"text": "hello world",
"timestamp": [0, 1200]
}
]
}
""")
);
verify(transcriptMapper, times(1)).insert(any(MeetingTranscript.class));
verify(transcriptFileService, times(1)).initializeTranscriptFileIfAbsent(eq(88L));
}
private AiTaskServiceImpl createService() {
return createService(
mock(MeetingMapper.class), mock(MeetingMapper.class),
mock(MeetingTranscriptMapper.class), mock(MeetingTranscriptMapper.class),
mock(AiModelService.class), mock(AiModelService.class),
mock(StringRedisTemplate.class), new ObjectMapper(),
mock(TaskSecurityContextRunner.class), mock(SysUserMapper.class),
hotWordService,
mock(RedisValueSupport.class),
mock(MeetingProgressService.class),
mock(MeetingSummaryFileService.class),
mock(MeetingTranscriptFileService.class), mock(MeetingTranscriptFileService.class),
mock(MeetingTranscriptRevisionService.class), mock(MeetingTranscriptChapterService.class),
mock(MeetingTranscriptChapterService.class) mock(MeetingSummaryPromptAssembler.class),
mock(TaskSecurityContextRunner.class),
mock(MeetingExternalSummaryWebhookTrigger.class),
mock(SysParamService.class)
); );
ReflectionTestUtils.setField(service, "serverBaseUrl", "http://localhost:8080");
Meeting meeting = new Meeting();
meeting.setAudioUrl("/api/static/meetings/12/source audio.mp4");
AiTask task = new AiTask();
Map<String, Object> taskConfig = new HashMap<>();
taskConfig.put("useSpkId", 1);
taskConfig.put("enableTextRefine", true);
taskConfig.put("hotWords", List.of("汇智"));
task.setTaskConfig(taskConfig);
AiModelVO asrModel = new AiModelVO();
asrModel.setModelCode("legacy-model-code");
@SuppressWarnings("unchecked")
Map<String, Object> request = (Map<String, Object>) ReflectionTestUtils.invokeMethod(
service,
"buildAsrRequest",
meeting,
task,
asrModel
);
assertEquals("http://localhost:8080/api/static/meetings/12/source%20audio.mp4", request.get("audio_address"));
assertFalse(request.containsKey("file_url"));
@SuppressWarnings("unchecked")
Map<String, Object> config = (Map<String, Object>) request.get("config");
assertEquals(Boolean.TRUE, config.get("enable_speaker"));
assertEquals(Boolean.TRUE, config.get("match_speaker_registry"));
assertEquals(Boolean.TRUE, config.get("enable_text_cleanup"));
assertFalse(config.containsKey("enable_text_refine"));
assertFalse(config.containsKey("enable_two_pass"));
assertFalse(config.containsKey("model"));
@SuppressWarnings("unchecked")
List<Map<String, Object>> hotwords = (List<Map<String, Object>>) config.get("hotwords");
assertEquals(1, hotwords.size());
assertEquals("汇智", hotwords.get(0).get("hotword"));
assertEquals(2.5, hotwords.get(0).get("weight"));
} }
private AiTaskServiceImpl createService(MeetingMapper meetingMapper, @Test
MeetingTranscriptMapper transcriptMapper, void buildAsrRequestShouldDisableRegistryMatchWhenSpeakerSplitDisabled() {
AiModelService aiModelService, AiTaskServiceImpl service = new AiTaskServiceImpl(
StringRedisTemplate redisTemplate, mock(MeetingMapper.class),
TaskSecurityContextRunner taskSecurityContextRunner, mock(MeetingTranscriptMapper.class),
MeetingTranscriptFileService meetingTranscriptFileService, mock(AiModelService.class),
MeetingTranscriptRevisionService revisionService,
MeetingTranscriptChapterService chapterService) {
return new AiTaskServiceImpl(
meetingMapper,
transcriptMapper,
aiModelService,
new ObjectMapper(), new ObjectMapper(),
mock(SysUserMapper.class), mock(SysUserMapper.class),
mock(HotWordService.class), mock(HotWordService.class),
redisTemplate, mock(RedisValueSupport.class),
mock(MeetingProgressService.class),
mock(MeetingSummaryFileService.class), mock(MeetingSummaryFileService.class),
meetingTranscriptFileService, mock(MeetingTranscriptFileService.class),
revisionService, mock(MeetingTranscriptChapterService.class),
chapterService,
mock(MeetingSummaryPromptAssembler.class), mock(MeetingSummaryPromptAssembler.class),
taskSecurityContextRunner, mock(TaskSecurityContextRunner.class),
mock(MeetingExternalSummaryWebhookTrigger.class) mock(MeetingExternalSummaryWebhookTrigger.class),
mock(SysParamService.class)
); );
ReflectionTestUtils.setField(service, "serverBaseUrl", "http://localhost:8080");
Meeting meeting = new Meeting();
meeting.setAudioUrl("/api/static/audio/demo.wav");
AiTask task = new AiTask();
Map<String, Object> taskConfig = new HashMap<>();
taskConfig.put("useSpkId", 0);
taskConfig.put("enableTextRefine", false);
task.setTaskConfig(taskConfig);
@SuppressWarnings("unchecked")
Map<String, Object> request = (Map<String, Object>) ReflectionTestUtils.invokeMethod(
service,
"buildAsrRequest",
meeting,
task,
new AiModelVO()
);
@SuppressWarnings("unchecked")
Map<String, Object> config = (Map<String, Object>) request.get("config");
assertEquals(Boolean.FALSE, config.get("enable_speaker"));
assertEquals(Boolean.FALSE, config.get("match_speaker_registry"));
assertEquals(Boolean.FALSE, config.get("enable_text_cleanup"));
assertTrue(((List<?>) config.get("hotwords")).isEmpty());
assertNull(request.get("file_url"));
} }
} }

View File

@ -142,6 +142,7 @@ export const getMeetingPage = (params: {
size: number; size: number;
title?: string; title?: string;
viewType?: "all" | "created" | "involved"; viewType?: "all" | "created" | "involved";
status?: number;
}) => { }) => {
return http.get<{ code: string; data: { records: MeetingVO[]; total: number }; msg: string }>( return http.get<{ code: string; data: { records: MeetingVO[]; total: number }; msg: string }>(
"/api/biz/meeting/page", "/api/biz/meeting/page",
@ -426,6 +427,7 @@ export interface MeetingProgress {
message: string; message: string;
updateAt: number; updateAt: number;
eta?: number; eta?: number;
queueAheadCount?: number;
} }
export const getMeetingProgress = (id: number, options?: { suppressErrorToast?: boolean }) => { export const getMeetingProgress = (id: number, options?: { suppressErrorToast?: boolean }) => {
@ -437,6 +439,16 @@ export const getMeetingProgress = (id: number, options?: { suppressErrorToast?:
); );
}; };
export const getMeetingProgressBatch = (ids: number[], options?: { suppressErrorToast?: boolean }) => {
return http.post<{ code: string; data: Record<number, MeetingProgress>; msg: string }>(
"/api/biz/meeting/progress/batch",
ids,
{
suppressErrorToast: options?.suppressErrorToast,
}
);
};
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`, {

View File

@ -359,67 +359,101 @@ function parseChapterTimeToMs(value?: string) {
return totalSeconds * 1000; return totalSeconds * 1000;
} }
type MeetingProgressPhase = 'queued' | 'asr' | 'chapter' | 'summary' | 'terminal';
const meetingProgressTerminalRefreshCache = new Map<number, string>();
const meetingProgressPhaseRefreshCache = new Map<number, MeetingProgressPhase>();
const resolveProgressPhase = (progress: MeetingProgress | null | undefined): MeetingProgressPhase => {
const percent = progress?.percent ?? 0;
if (percent < 0 || percent >= 100) {
return 'terminal';
}
if (percent >= 90) {
return 'summary';
}
if (percent >= 85) {
return 'chapter';
}
if (percent >= 5) {
return 'asr';
}
return 'queued';
};
const MeetingProgressDisplay: React.FC<{ const MeetingProgressDisplay: React.FC<{
meetingId: number; meetingId: number;
onComplete: () => void; onComplete: () => void;
onProgressUpdate?: (meeting: MeetingVO) => void; onRefreshNeeded?: (phase: MeetingProgressPhase) => void;
onProgressChange?: (progress: MeetingProgress | null) => void; onProgressChange?: (progress: MeetingProgress | null) => void;
compact?: boolean; compact?: boolean;
inline?: boolean; inline?: boolean;
}> = ({ meetingId, onComplete, onProgressUpdate, onProgressChange, compact, inline }) => { }> = ({ meetingId, onComplete, onRefreshNeeded, onProgressChange, compact, inline }) => {
const [progress, setProgress] = useState<MeetingProgress | null>(null); const [progress, setProgress] = useState<MeetingProgress | null>(null);
const onCompleteRef = useRef(onComplete); const onCompleteRef = useRef(onComplete);
const onProgressUpdateRef = useRef(onProgressUpdate); const onRefreshNeededRef = useRef(onRefreshNeeded);
const onProgressChangeRef = useRef(onProgressChange); const onProgressChangeRef = useRef(onProgressChange);
useEffect(() => { useEffect(() => {
onCompleteRef.current = onComplete; onCompleteRef.current = onComplete;
onProgressUpdateRef.current = onProgressUpdate; onRefreshNeededRef.current = onRefreshNeeded;
onProgressChangeRef.current = onProgressChange; onProgressChangeRef.current = onProgressChange;
}, [onComplete, onProgressUpdate, onProgressChange]); }, [onComplete, onRefreshNeeded, onProgressChange]);
useEffect(() => { useEffect(() => {
let completed = false; let completed = false;
let timer: ReturnType<typeof setTimeout> | null = null;
let requesting = false;
const fetchProgress = async () => { const fetchProgress = async () => {
if (completed) { if (completed || requesting) {
return; return;
} }
requesting = true;
let shouldContinue = true;
try { try {
const [progressRes, detailRes] = await Promise.all([ const progressRes = await getMeetingProgress(meetingId, { suppressErrorToast: true });
getMeetingProgress(meetingId, { suppressErrorToast: true }),
getMeetingDetail(meetingId, { suppressErrorToast: true }),
]);
if (detailRes.data?.data) {
onProgressUpdateRef.current?.(detailRes.data.data);
if (detailRes.data.data.status !== 1 && detailRes.data.data.status !== 2) {
completed = true;
onCompleteRef.current?.();
return;
}
}
if (progressRes.data?.data) { if (progressRes.data?.data) {
const nextProgress = progressRes.data.data; const nextProgress = progressRes.data.data;
setProgress(nextProgress); setProgress(nextProgress);
onProgressChangeRef.current?.(nextProgress); onProgressChangeRef.current?.(nextProgress);
const phase = resolveProgressPhase(nextProgress);
const previousPhase = meetingProgressPhaseRefreshCache.get(meetingId);
meetingProgressPhaseRefreshCache.set(meetingId, phase);
if ((phase === 'chapter' || phase === 'summary') && previousPhase !== phase) {
onRefreshNeededRef.current?.(phase);
}
if (nextProgress.percent === 100 || nextProgress.percent < 0) { if (nextProgress.percent === 100 || nextProgress.percent < 0) {
completed = true; completed = true;
onCompleteRef.current?.(); shouldContinue = false;
const terminalKey = `${nextProgress.updateAt}:${nextProgress.percent}`;
if (meetingProgressTerminalRefreshCache.get(meetingId) !== terminalKey) {
meetingProgressTerminalRefreshCache.set(meetingId, terminalKey);
onCompleteRef.current?.();
}
} else {
meetingProgressTerminalRefreshCache.delete(meetingId);
} }
} }
} catch { } catch {
// ignore // ignore
} finally {
requesting = false;
if (!completed && shouldContinue) {
timer = setTimeout(() => {
void fetchProgress();
}, 3000);
}
} }
}; };
fetchProgress(); void fetchProgress();
const timer = setInterval(fetchProgress, 3000);
return () => { return () => {
completed = true; completed = true;
clearInterval(timer); if (timer) {
clearTimeout(timer);
}
}; };
}, [meetingId]); }, [meetingId]);
@ -1325,9 +1359,22 @@ const MeetingDetail: React.FC = () => {
userPrompt: values.userPrompt, userPrompt: values.userPrompt,
summaryDetailLevel: values.summaryDetailLevel as SummaryDetailLevel, summaryDetailLevel: values.summaryDetailLevel as SummaryDetailLevel,
}); });
message.success('已重新发起总结任务'); setMeeting((current) => current ? {
setSummaryVisible(false); ...current,
fetchData(Number(id)); status: 2,
latestChapterAttemptStatus: 0,
latestChapterAttemptErrorMsg: undefined,
latestSummaryAttemptStatus: 0,
latestSummaryAttemptErrorMsg: undefined,
} : current);
setGenerationProgress({
percent: 85,
message: '已重新发起总结任务',
updateAt: Date.now(),
eta: 0,
});
message.success('操作成功');
await fetchData(Number(id));
} catch (error) { } catch (error) {
console.error(error); console.error(error);
} finally { } finally {
@ -2000,15 +2047,11 @@ const MeetingDetail: React.FC = () => {
/> />
<div className="meeting-detail-workspace"> <div className="meeting-detail-workspace">
{meeting.status === 1 ? ( {meeting.status === 0 || meeting.status === 1 ? (
<MeetingProgressDisplay <MeetingProgressDisplay
meetingId={meeting.id} meetingId={meeting.id}
onComplete={() => fetchData(meeting.id)} onComplete={() => fetchData(meeting.id)}
onProgressUpdate={(updated) => { onRefreshNeeded={() => { void fetchData(meeting.id); }}
if (updated.status !== meeting.status) {
void fetchData(updated.id);
}
}}
onProgressChange={setGenerationProgress} onProgressChange={setGenerationProgress}
/> />
) : ( ) : (
@ -2051,11 +2094,7 @@ const MeetingDetail: React.FC = () => {
<MeetingProgressDisplay <MeetingProgressDisplay
meetingId={meeting.id} meetingId={meeting.id}
onComplete={() => fetchData(meeting.id)} onComplete={() => fetchData(meeting.id)}
onProgressUpdate={(updated) => { onRefreshNeeded={() => { void fetchData(meeting.id); }}
if (updated.status === 2 || updated.status !== meeting.status) {
void fetchData(updated.id);
}
}}
onProgressChange={setGenerationProgress} onProgressChange={setGenerationProgress}
compact compact
/> />
@ -2075,11 +2114,7 @@ const MeetingDetail: React.FC = () => {
<MeetingProgressDisplay <MeetingProgressDisplay
meetingId={meeting.id} meetingId={meeting.id}
onComplete={() => fetchData(meeting.id)} onComplete={() => fetchData(meeting.id)}
onProgressUpdate={(updated) => { onRefreshNeeded={() => { void fetchData(meeting.id); }}
if (updated.status !== meeting.status) {
void fetchData(updated.id);
}
}}
onProgressChange={setGenerationProgress} onProgressChange={setGenerationProgress}
inline inline
/> />

View File

@ -6,6 +6,7 @@ import {
CloudUploadOutlined, CloudUploadOutlined,
DeleteOutlined, DeleteOutlined,
EditOutlined, EditOutlined,
FilterOutlined,
InfoCircleOutlined, InfoCircleOutlined,
PauseCircleOutlined, PauseCircleOutlined,
PlusOutlined, PlusOutlined,
@ -38,7 +39,7 @@ import {
Typography, Typography,
} from "antd"; } from "antd";
import dayjs from "dayjs"; import dayjs from "dayjs";
import React, { useEffect, useState } from "react"; import React, { useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useNavigate, useSearchParams } from "react-router-dom"; import { useNavigate, useSearchParams } from "react-router-dom";
@ -47,7 +48,7 @@ import {
deleteMeeting, deleteMeeting,
getMeetingCreateConfig, getMeetingCreateConfig,
getMeetingPage, getMeetingPage,
getMeetingProgress, getMeetingProgressBatch,
getRealtimeMeetingSessionStatus, getRealtimeMeetingSessionStatus,
getRealtimeMeetingSessionStatuses, getRealtimeMeetingSessionStatuses,
type MeetingCreateConfig, type MeetingCreateConfig,
@ -64,11 +65,21 @@ import PageContainer from "../../components/shared/PageContainer";
const { Title, Text } = Typography; const { Title, Text } = Typography;
const { Option } = Select; const { Option } = Select;
const { Search } = Input;
const CURRENT_PLATFORM = "WEB" as const; const CURRENT_PLATFORM = "WEB" as const;
const PAUSED_DISPLAY_STATUS = 5; 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 MEETING_STATUS_FILTER_OPTIONS = [
{ label: "全部状态", value: ALL_STATUS_FILTER, color: "#8c8c8c", bgColor: "#f5f5f5" },
{ label: "排队中", value: "0", color: "#8c8c8c", bgColor: "#f5f5f5" },
{ label: "识别中", value: "1", color: "#1890ff", bgColor: "#e6f7ff" },
{ label: "总结中", value: "2", color: "#faad14", bgColor: "#fff7e6" },
{ label: "已完成", value: "3", color: "#52c41a", bgColor: "#f6ffed" },
{ label: "失败", value: "4", color: "#ff4d4f", bgColor: "#fff1f0" },
] as const;
const DEFAULT_CREATE_CONFIG: MeetingCreateConfig = { const DEFAULT_CREATE_CONFIG: MeetingCreateConfig = {
offlineEnabled: true, offlineEnabled: true,
realtimeEnabled: true, realtimeEnabled: true,
@ -95,9 +106,17 @@ const canOpenRealtimeSession = (status?: RealtimeMeetingSessionStatus["status"])
|| status === "ACTIVE" || status === "ACTIVE"
|| status === "IDLE"; || status === "IDLE";
const hasLatestGenerationFailure = (item: MeetingVO) =>
item.latestChapterAttemptStatus === 3 || item.latestSummaryAttemptStatus === 3;
const shouldTrackGenerationProgress = (item: MeetingVO) =>
!hasLatestGenerationFailure(item) && (item.status === 0 || item.status === 1 || item.status === 2);
const isTerminalMeetingProgress = (progress?: MeetingProgress | null) =>
!!progress && (progress.percent === 100 || progress.percent < 0);
const shouldPollMeetingCard = (item: MeetingVO) => const shouldPollMeetingCard = (item: MeetingVO) =>
item.status === 1 shouldTrackGenerationProgress(item)
|| item.status === 2
|| item.realtimeSessionStatus === "ACTIVE" || item.realtimeSessionStatus === "ACTIVE"
|| isPausedRealtimeSessionStatus(item.realtimeSessionStatus); || isPausedRealtimeSessionStatus(item.realtimeSessionStatus);
@ -130,35 +149,9 @@ const applyRealtimeSessionStatus = (item: MeetingVO, sessionStatus?: RealtimeMee
return { ...item, realtimeSessionStatus: sessionStatus.status }; return { ...item, realtimeSessionStatus: sessionStatus.status };
}; };
const useMeetingProgress = (meeting: MeetingVO, onComplete?: () => void) => {
const [progress, setProgress] = useState<MeetingProgress | null>(null);
useEffect(() => {
if (meeting.status !== 1 && meeting.status !== 2) {
return;
}
const fetchProgress = async () => {
try {
const res = await getMeetingProgress(meeting.id, { suppressErrorToast: true });
const nextProgress = res.data?.data;
if (nextProgress) {
setProgress(nextProgress);
if ((nextProgress.percent === 100 || nextProgress.percent < 0) && onComplete) {
onComplete();
}
}
} catch {}
};
void fetchProgress();
const timer = setInterval(fetchProgress, 3000);
return () => clearInterval(timer);
}, [meeting.id, meeting.status, onComplete]);
return progress;
};
const IntegratedStatusTag: React.FC<{ meeting: MeetingVO; progress: MeetingProgress | null }> = ({ meeting, progress }) => { const IntegratedStatusTag: React.FC<{ meeting: MeetingVO; progress: MeetingProgress | null }> = ({ meeting, progress }) => {
const effectiveStatus = meeting.displayStatus ?? meeting.status; const failedByLatestAttempt = hasLatestGenerationFailure(meeting);
const effectiveStatus = failedByLatestAttempt ? 4 : (meeting.displayStatus ?? meeting.status);
const statusConfig: Record<number, { text: string; color: string; bgColor: string; icon: React.ReactNode }> = { const statusConfig: Record<number, { text: string; color: string; bgColor: string; icon: React.ReactNode }> = {
0: { text: "排队中", color: "#8c8c8c", bgColor: "rgba(140, 140, 140, 0.1)", icon: <SyncOutlined spin /> }, 0: { text: "排队中", color: "#8c8c8c", bgColor: "rgba(140, 140, 140, 0.1)", icon: <SyncOutlined spin /> },
1: { text: "识别中", color: "#1890ff", bgColor: "rgba(24, 144, 255, 0.1)", icon: <SyncOutlined spin /> }, 1: { text: "识别中", color: "#1890ff", bgColor: "rgba(24, 144, 255, 0.1)", icon: <SyncOutlined spin /> },
@ -170,8 +163,8 @@ const IntegratedStatusTag: React.FC<{ meeting: MeetingVO; progress: MeetingProgr
7: { text: "待开始", color: "#595959", bgColor: "rgba(89, 89, 89, 0.1)", icon: <InfoCircleOutlined /> }, 7: { text: "待开始", color: "#595959", bgColor: "rgba(89, 89, 89, 0.1)", icon: <InfoCircleOutlined /> },
}; };
const config = statusConfig[effectiveStatus] || statusConfig[0]; const config = statusConfig[effectiveStatus] || statusConfig[0];
const percent = meeting.status === 1 || meeting.status === 2 ? progress?.percent || 0 : 0; const isProcessing = shouldTrackGenerationProgress(meeting);
const isProcessing = meeting.status === 1 || meeting.status === 2; const percent = isProcessing ? progress?.percent || 0 : 0;
return ( return (
<div <div
@ -213,20 +206,19 @@ const IntegratedStatusTag: React.FC<{ meeting: MeetingVO; progress: MeetingProgr
); );
}; };
const TableStatusCell: React.FC<{ meeting: MeetingVO; fetchData: () => void }> = ({ meeting, fetchData }) => { const TableStatusCell: React.FC<{ meeting: MeetingVO; progress: MeetingProgress | null }> = ({ meeting, progress }) => {
const progress = useMeetingProgress(meeting, fetchData);
return <IntegratedStatusTag meeting={meeting} progress={progress} />; return <IntegratedStatusTag meeting={meeting} progress={progress} />;
}; };
const MeetingCardItem: React.FC<{ 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;
fetchData: () => void; fetchData: () => void;
onOpenMeeting: (meeting: MeetingVO) => void; onOpenMeeting: (meeting: MeetingVO) => void;
}> = ({ item, config, fetchData, onOpenMeeting }) => { }> = ({ item, config, progress, fetchData, onOpenMeeting }) => {
const progress = useMeetingProgress(item, fetchData); const effectiveStatus = hasLatestGenerationFailure(item) ? 4 : (item.displayStatus ?? item.status);
const effectiveStatus = item.displayStatus ?? item.status; const isProcessing = shouldTrackGenerationProgress(item);
const isProcessing = item.status === 1 || item.status === 2;
const isPaused = effectiveStatus === PAUSED_DISPLAY_STATUS; const isPaused = effectiveStatus === PAUSED_DISPLAY_STATUS;
const isRealtimeActive = effectiveStatus === REALTIME_ACTIVE_DISPLAY_STATUS; const isRealtimeActive = effectiveStatus === REALTIME_ACTIVE_DISPLAY_STATUS;
const isRealtimeIdle = effectiveStatus === REALTIME_IDLE_DISPLAY_STATUS; const isRealtimeIdle = effectiveStatus === REALTIME_IDLE_DISPLAY_STATUS;
@ -403,12 +395,15 @@ const Meetings: React.FC = () => {
const [searchParams, setSearchParams] = useSearchParams(); const [searchParams, setSearchParams] = useSearchParams();
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [data, setData] = useState<MeetingVO[]>([]); const [data, setData] = useState<MeetingVO[]>([]);
const [progressMap, setProgressMap] = useState<Record<number, MeetingProgress>>({});
const [total, setTotal] = useState(0); const [total, setTotal] = useState(0);
const [current, setCurrent] = useState(1); const [current, setCurrent] = useState(1);
const [displayMode, setDisplayMode] = useState<"card" | "list">("card"); const [displayMode, setDisplayMode] = useState<"card" | "list">("card");
const [size, setSize] = useState(8); const [size, setSize] = useState(8);
const [searchTitle, setSearchTitle] = useState(""); const [searchTitle, setSearchTitle] = useState("");
const [searchKeyword, setSearchKeyword] = useState("");
const [viewType, setViewType] = useState<"all" | "created" | "involved">("all"); const [viewType, setViewType] = useState<"all" | "created" | "involved">("all");
const [statusFilter, setStatusFilter] = useState<string>(ALL_STATUS_FILTER);
const [createDrawerVisible, setCreateDrawerVisible] = useState(false); const [createDrawerVisible, setCreateDrawerVisible] = useState(false);
const [createDrawerType, setCreateDrawerType] = useState<MeetingCreateType>("upload"); const [createDrawerType, setCreateDrawerType] = useState<MeetingCreateType>("upload");
const [configLoaded, setConfigLoaded] = useState(false); const [configLoaded, setConfigLoaded] = useState(false);
@ -418,8 +413,9 @@ const Meetings: React.FC = () => {
offlineAudioMaxSizeMb: 1024, offlineAudioMaxSizeMb: 1024,
}); });
const [userList, setUserList] = useState<SysUser[]>([]); const [userList, setUserList] = useState<SysUser[]>([]);
const progressTerminalRefreshRef = useRef<Map<number, string>>(new Map());
const hasRunningTasks = data.some(shouldPollMeetingCard); const activeFilterCount = (statusFilter !== ALL_STATUS_FILTER ? 1 : 0) + (searchTitle ? 1 : 0);
const handleDisplayModeChange = (mode: "card" | "list") => { const handleDisplayModeChange = (mode: "card" | "list") => {
setDisplayMode(mode); setDisplayMode(mode);
@ -427,6 +423,44 @@ const Meetings: React.FC = () => {
setCurrent(1); setCurrent(1);
}; };
const handleSearch = (value?: string) => {
const nextValue = (value ?? searchKeyword).trim();
setSearchKeyword(value ?? searchKeyword);
setSearchTitle(nextValue);
setCurrent(1);
};
const handleResetFilters = () => {
setSearchKeyword("");
setSearchTitle("");
setStatusFilter(ALL_STATUS_FILTER);
setCurrent(1);
};
const loadBatchProgress = async (meetings: MeetingVO[]) => {
const trackedIds = meetings.filter(shouldTrackGenerationProgress).map((item) => item.id);
if (trackedIds.length === 0) {
progressTerminalRefreshRef.current.clear();
setProgressMap({});
return {} as Record<number, MeetingProgress>;
}
try {
const progressRes = await getMeetingProgressBatch(trackedIds, { suppressErrorToast: true });
const nextProgressMap = progressRes.data?.data || {};
const activeIds = new Set(trackedIds);
progressTerminalRefreshRef.current.forEach((_, id) => {
if (!activeIds.has(id)) {
progressTerminalRefreshRef.current.delete(id);
}
});
setProgressMap(nextProgressMap);
return nextProgressMap;
} catch {
setProgressMap({});
return {} as Record<number, MeetingProgress>;
}
};
useEffect(() => { useEffect(() => {
const action = searchParams.get("action"); const action = searchParams.get("action");
const type = searchParams.get("type") as MeetingCreateType; const type = searchParams.get("type") as MeetingCreateType;
@ -439,15 +473,105 @@ const Meetings: React.FC = () => {
useEffect(() => { useEffect(() => {
void fetchData(); void fetchData();
}, [current, size, searchTitle, viewType]); }, [current, size, searchTitle, viewType, statusFilter]);
useEffect(() => { useEffect(() => {
if (!hasRunningTasks) { const trackedMeetings = data.filter(shouldTrackGenerationProgress);
if (trackedMeetings.length === 0) {
progressTerminalRefreshRef.current.clear();
setProgressMap({});
return; return;
} }
const timer = setInterval(() => void fetchData(true), 5000);
return () => clearInterval(timer); let cancelled = false;
}, [hasRunningTasks, current, size, searchTitle, viewType]); let timer: ReturnType<typeof setTimeout> | null = null;
let requesting = false;
const poll = async () => {
if (cancelled || requesting) {
return;
}
requesting = true;
try {
const nextProgressMap = await loadBatchProgress(trackedMeetings);
if (cancelled) {
return;
}
let shouldRefresh = false;
for (const meeting of trackedMeetings) {
const progress = nextProgressMap[meeting.id];
if (!isTerminalMeetingProgress(progress)) {
progressTerminalRefreshRef.current.delete(meeting.id);
continue;
}
const terminalKey = `${progress.updateAt}:${progress.percent}`;
if (progressTerminalRefreshRef.current.get(meeting.id) !== terminalKey) {
progressTerminalRefreshRef.current.set(meeting.id, terminalKey);
shouldRefresh = true;
}
}
if (shouldRefresh) {
await fetchData(true);
}
} finally {
requesting = false;
if (!cancelled) {
timer = setTimeout(() => {
void poll();
}, 3000);
}
}
};
timer = setTimeout(() => {
void poll();
}, 3000);
return () => {
cancelled = true;
if (timer) {
clearTimeout(timer);
}
};
}, [data, current, size, searchTitle, viewType, statusFilter]);
useEffect(() => {
const hasRealtimeSessionsToPoll = data.some(
(item) => item.realtimeSessionStatus === "ACTIVE" || isPausedRealtimeSessionStatus(item.realtimeSessionStatus),
);
if (!hasRealtimeSessionsToPoll) {
return;
}
let cancelled = false;
let timer: ReturnType<typeof setTimeout> | null = null;
let requesting = false;
const poll = async () => {
if (cancelled || requesting) {
return;
}
requesting = true;
try {
await fetchData(true);
} finally {
requesting = false;
if (!cancelled) {
timer = setTimeout(() => {
void poll();
}, 5000);
}
}
};
timer = setTimeout(() => {
void poll();
}, 5000);
return () => {
cancelled = true;
if (timer) {
clearTimeout(timer);
}
};
}, [data, current, size, searchTitle, viewType, statusFilter]);
useEffect(() => { useEffect(() => {
listUsers().then((users) => setUserList(users || [])).catch(() => setUserList([])); listUsers().then((users) => setUserList(users || [])).catch(() => setUserList([]));
@ -467,7 +591,13 @@ const Meetings: React.FC = () => {
setLoading(true); setLoading(true);
} }
try { try {
const res = await getMeetingPage({ current, size, title: searchTitle, viewType }); const res = await getMeetingPage({
current,
size,
title: searchTitle,
viewType,
status: statusFilter === ALL_STATUS_FILTER ? undefined : Number(statusFilter),
});
const records = res.data?.data?.records || []; const records = res.data?.data?.records || [];
let statusMap: Record<number, RealtimeMeetingSessionStatus> = {}; let statusMap: Record<number, RealtimeMeetingSessionStatus> = {};
const realtimeCandidates = records.filter(isRealtimeMeetingCandidate); const realtimeCandidates = records.filter(isRealtimeMeetingCandidate);
@ -477,8 +607,10 @@ const Meetings: React.FC = () => {
statusMap = sessionRes.data?.data || {}; statusMap = sessionRes.data?.data || {};
} catch {} } catch {}
} }
setData(records.map((item) => applyRealtimeSessionStatus(item, statusMap[item.id]))); const nextData = records.map((item) => applyRealtimeSessionStatus(item, statusMap[item.id]));
setData(nextData);
setTotal(res.data?.data?.total || 0); setTotal(res.data?.data?.total || 0);
await loadBatchProgress(nextData);
} finally { } finally {
if (!silent) { if (!silent) {
setLoading(false); setLoading(false);
@ -525,7 +657,7 @@ const Meetings: React.FC = () => {
title: "状态", title: "状态",
key: "status", key: "status",
width: 150, width: 150,
render: (_: unknown, record: MeetingVO) => <TableStatusCell meeting={record} fetchData={() => void fetchData()} />, render: (_: unknown, record: MeetingVO) => <TableStatusCell meeting={record} progress={progressMap[record.id] || null} />,
}, },
{ {
title: "会议时间", title: "会议时间",
@ -611,20 +743,70 @@ const Meetings: React.FC = () => {
</Space> </Space>
} }
toolbar={ toolbar={
<> <Space wrap size={12} style={{ width: "100%", justifyContent: "space-between" }}>
<Radio.Group value={viewType} onChange={(e) => { setViewType(e.target.value); setCurrent(1); }} buttonStyle="solid"> <Radio.Group value={viewType} onChange={(e) => { setViewType(e.target.value); setCurrent(1); }} buttonStyle="solid">
<Radio.Button value="all"></Radio.Button> <Radio.Button value="all"></Radio.Button>
<Radio.Button value="created"></Radio.Button> <Radio.Button value="created"></Radio.Button>
<Radio.Button value="involved"></Radio.Button> <Radio.Button value="involved"></Radio.Button>
</Radio.Group> </Radio.Group>
<Input <Space wrap size={10} align="center">
placeholder="搜索标题" <Tag
prefix={<SearchOutlined />} color={activeFilterCount > 0 ? "processing" : "default"}
allowClear style={{ margin: 0, borderRadius: 999, paddingInline: 10, lineHeight: "24px" }}
onPressEnter={(e) => { setSearchTitle((e.target as HTMLInputElement).value); setCurrent(1); }} >
style={{ width: 200 }} <Space size={6}>
/> <FilterOutlined />
</> <span>{activeFilterCount > 0 ? `已筛选 ${activeFilterCount}` : "未筛选"}</span>
</Space>
</Tag>
<Select
value={statusFilter}
onChange={(value) => { setStatusFilter(value); setCurrent(1); }}
style={{ width: 170 }}
popupMatchSelectWidth={false}
suffixIcon={<FilterOutlined style={{ color: "#8c8c8c" }} />}
options={MEETING_STATUS_FILTER_OPTIONS.map((option) => ({
value: option.value,
label: (
<Space size={8}>
<span
style={{
width: 8,
height: 8,
borderRadius: "50%",
background: option.color,
boxShadow: `0 0 0 4px ${option.bgColor}`,
flexShrink: 0,
}}
/>
<span>{option.label}</span>
</Space>
),
}))}
/>
<Search
placeholder="搜索会议标题"
value={searchKeyword}
allowClear
enterButton={false}
onChange={(e) => {
const nextValue = e.target.value;
setSearchKeyword(nextValue);
if (!nextValue) {
setSearchTitle("");
setCurrent(1);
}
}}
onSearch={handleSearch}
style={{ width: 240 }}
/>
{activeFilterCount > 0 && (
<Button onClick={handleResetFilters}>
</Button>
)}
</Space>
</Space>
} }
> >
<Card <Card
@ -639,8 +821,17 @@ const Meetings: React.FC = () => {
grid={{ gutter: [20, 20], xs: 1, sm: 2, md: 2, lg: 3, xl: 4, xxl: 4 }} grid={{ gutter: [20, 20], xs: 1, sm: 2, md: 2, lg: 3, xl: 4, xxl: 4 }}
dataSource={data} dataSource={data}
renderItem={(item) => { renderItem={(item) => {
const config = statusConfig[item.displayStatus ?? item.status] || statusConfig[0]; const visualStatus = hasLatestGenerationFailure(item) ? 4 : (item.displayStatus ?? item.status);
return <MeetingCardItem item={item} config={config} fetchData={() => void fetchData()} onOpenMeeting={handleOpenMeeting} />; const config = statusConfig[visualStatus] || statusConfig[0];
return (
<MeetingCardItem
item={item}
config={config}
progress={progressMap[item.id] || null}
fetchData={() => void fetchData()}
onOpenMeeting={handleOpenMeeting}
/>
);
}} }}
locale={{ emptyText: <Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="开启您的第一场会议分析" /> }} locale={{ emptyText: <Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="开启您的第一场会议分析" /> }}
/> />

View File

@ -89,6 +89,7 @@ const SpeakerReg: React.FC = () => {
return; return;
} }
form.setFieldValue('userId', profile.userId); form.setFieldValue('userId', profile.userId);
form.setFieldValue('name', profile.displayName);
}, [form, isAdmin, profile?.userId]); }, [form, isAdmin, profile?.userId]);
const fetchSpeakers = async (page = current, size = pageSize, name = queryName) => { const fetchSpeakers = async (page = current, size = pageSize, name = queryName) => {