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 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_CREATE_OFFLINE_ENABLED = "meeting.create.offline_enabled";
public static final String MEETING_CREATE_REALTIME_ENABLED = "meeting.create.realtime_enabled";

View File

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

View File

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

View File

@ -166,6 +166,43 @@ public class MeetingController {
return ApiResponse.ok(progress);
}
@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")
@PreAuthorize("isAuthenticated()")
@ -223,7 +260,8 @@ public class MeetingController {
@RequestParam(defaultValue = "1") Integer current,
@RequestParam(defaultValue = "10") Integer size,
@RequestParam(required = false) String title,
@RequestParam(defaultValue = "all") String viewType) {
@RequestParam(defaultValue = "all") String viewType,
@RequestParam(required = false) Integer status) {
LoginUser loginUser = currentLoginUser();
boolean isAdmin = Boolean.TRUE.equals(loginUser.getIsPlatformAdmin()) || Boolean.TRUE.equals(loginUser.getIsTenantAdmin());
@ -236,6 +274,7 @@ public class MeetingController {
loginUser.getUserId(),
resolveCreatorName(loginUser),
viewType,
status,
isAdmin
));
}

View File

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

View File

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

View File

@ -12,7 +12,7 @@ import java.util.Map;
public interface MeetingQueryService {
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);

View File

@ -9,13 +9,6 @@ import com.imeeting.entity.biz.Meeting;
import java.util.List;
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);
}

View File

@ -25,7 +25,7 @@ import com.imeeting.service.biz.MeetingProgressService;
import com.imeeting.service.biz.MeetingSummaryFileService;
import com.imeeting.service.biz.MeetingTranscriptChapterService;
import com.imeeting.service.biz.MeetingTranscriptFileService;
import com.imeeting.service.biz.MeetingTranscriptRevisionService;
import com.imeeting.support.RedisValueSupport;
import com.unisbase.entity.SysUser;
@ -71,7 +71,7 @@ public class AiTaskServiceImpl extends ServiceImpl<AiTaskMapper, AiTask> impleme
private final MeetingProgressService meetingProgressService;
private final MeetingSummaryFileService meetingSummaryFileService;
private final MeetingTranscriptFileService meetingTranscriptFileService;
private final MeetingTranscriptRevisionService meetingTranscriptRevisionService;
private final MeetingTranscriptChapterService meetingTranscriptChapterService;
private final MeetingSummaryPromptAssembler meetingSummaryPromptAssembler;
private final TaskSecurityContextRunner taskSecurityContextRunner;
@ -115,7 +115,6 @@ public class AiTaskServiceImpl extends ServiceImpl<AiTaskMapper, AiTask> impleme
MeetingProgressService meetingProgressService,
MeetingSummaryFileService meetingSummaryFileService,
MeetingTranscriptFileService meetingTranscriptFileService,
MeetingTranscriptRevisionService meetingTranscriptRevisionService,
MeetingTranscriptChapterService meetingTranscriptChapterService,
MeetingSummaryPromptAssembler meetingSummaryPromptAssembler,
TaskSecurityContextRunner taskSecurityContextRunner,
@ -131,7 +130,6 @@ public class AiTaskServiceImpl extends ServiceImpl<AiTaskMapper, AiTask> impleme
this.meetingProgressService = meetingProgressService;
this.meetingSummaryFileService = meetingSummaryFileService;
this.meetingTranscriptFileService = meetingTranscriptFileService;
this.meetingTranscriptRevisionService = meetingTranscriptRevisionService;
this.meetingTranscriptChapterService = meetingTranscriptChapterService;
this.meetingSummaryPromptAssembler = meetingSummaryPromptAssembler;
this.taskSecurityContextRunner = taskSecurityContextRunner;
@ -148,7 +146,7 @@ public class AiTaskServiceImpl extends ServiceImpl<AiTaskMapper, AiTask> impleme
StringRedisTemplate redisTemplate,
MeetingSummaryFileService meetingSummaryFileService,
MeetingTranscriptFileService meetingTranscriptFileService,
MeetingTranscriptRevisionService meetingTranscriptRevisionService,
MeetingTranscriptChapterService meetingTranscriptChapterService,
MeetingSummaryPromptAssembler meetingSummaryPromptAssembler,
TaskSecurityContextRunner taskSecurityContextRunner,
@ -164,7 +162,6 @@ public class AiTaskServiceImpl extends ServiceImpl<AiTaskMapper, AiTask> impleme
new RedisOnlyMeetingProgressServiceAdapter(redisTemplate, objectMapper),
meetingSummaryFileService,
meetingTranscriptFileService,
meetingTranscriptRevisionService,
meetingTranscriptChapterService,
meetingSummaryPromptAssembler,
taskSecurityContextRunner,
@ -213,7 +210,7 @@ public class AiTaskServiceImpl extends ServiceImpl<AiTaskMapper, AiTask> impleme
this.updateById(asrTask);
}
if (!claimQueuedAsrTask(asrTask)) {
meetingProgressService.markQueued(meetingId, asrTask, 1, "已进入 ASR 队列,等待执行");
meetingProgressService.markQueued(meetingId, asrTask, 1, "ASR queued and waiting for execution");
return;
}
}
@ -249,6 +246,7 @@ public class AiTaskServiceImpl extends ServiceImpl<AiTaskMapper, AiTask> impleme
}
if (!asrText.isBlank()) {
meetingProgressService.markStage(meetingId, asrTask, 1, MeetingProgressStage.ASR_COMPLETED, 80, "转写完成,准备生成总结", 0);
scheduleQueuedAsrTasks();
self.dispatchSummaryTask(meetingId, meeting.getTenantId(), meeting.getCreatorId());
return;
}
@ -268,7 +266,7 @@ public class AiTaskServiceImpl extends ServiceImpl<AiTaskMapper, AiTask> impleme
}
} catch (Exception e) {
log.error("Meeting {} AI Task Flow failed", meetingId, e);
failPendingSummaryTask(findLatestSummaryTask(meetingId), "转录失败,已跳过总结任务:" + e.getMessage());
failPendingSummaryTask(findLatestSummaryTask(meetingId), "转录失败,已跳过总结任务: " + e.getMessage());
updateMeetingStatus(meetingId, 4);
updateProgress(meetingId, -1, "分析失败: " + e.getMessage(), 0);
} finally {
@ -384,6 +382,7 @@ public class AiTaskServiceImpl extends ServiceImpl<AiTaskMapper, AiTask> impleme
task.setStatus(1);
task.setStartedAt(now);
meetingProgressService.markStage(task.getMeetingId(), task, 1, MeetingProgressStage.ASR_SUBMITTED, 5, "ASR 任务已开始执行", 0);
refreshQueuedAsrProgress();
}
return claimed;
} finally {
@ -409,6 +408,7 @@ public class AiTaskServiceImpl extends ServiceImpl<AiTaskMapper, AiTask> impleme
if (available <= 0) {
return;
}
refreshQueuedAsrProgress();
List<AiTask> queuedTasks = list(new LambdaQueryWrapper<AiTask>()
.eq(AiTask::getTaskType, "ASR")
.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() {
if (sysParamService == null) {
return 2;
@ -459,6 +473,7 @@ public class AiTaskServiceImpl extends ServiceImpl<AiTaskMapper, AiTask> impleme
task.setResponseData(responseData);
updateById(task);
meetingProgressService.markQueued(task.getMeetingId(), task, 1, reason == null || reason.isBlank() ? "已重新进入 ASR 队列" : reason);
refreshQueuedAsrProgress();
}
private Long extractAsrModelId(AiTask task) {
@ -565,20 +580,17 @@ public class AiTaskServiceImpl extends ServiceImpl<AiTaskMapper, AiTask> impleme
}
})
.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<>();
if (asrModel.getModelCode() != null && !asrModel.getModelCode().isBlank()) {
config.put("model", asrModel.getModelCode());
}
Object useSpkObj = taskRecord.getTaskConfig().get("useSpkId");
boolean useSpk = useSpkObj != null && useSpkObj.toString().equals("1");
config.put("enable_speaker", useSpk);
config.put("match_speaker_registry", useSpk);
Object enableTextRefineObj = taskRecord.getTaskConfig().get("enableTextRefine");
boolean enableTextRefine = enableTextRefineObj != null && Boolean.parseBoolean(enableTextRefineObj.toString());
config.put("enable_text_refine", enableTextRefine);
config.put("enable_two_pass", true);
config.put("enable_text_cleanup", enableTextRefine);
List<Map<String, Object>> hotwords = new ArrayList<>();
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());
JsonNode submitNode = objectMapper.readTree(respBody);
if (submitNode.path("code").asInt() != 0) {
updateAiTaskFail(taskRecord, "ASR识别失败: " + respBody);
throw new RuntimeException("ASR识别失败: " + submitNode.path("msg").asText());
updateAiTaskFail(taskRecord, "ASR识别失败 " + respBody);
throw new RuntimeException("ASR识别失败: " + firstNonBlank(
submitNode.path("message").asText(""),
submitNode.path("msg").asText(""),
"unknown error"
));
}
String taskId = submitNode.path("data").path("task_id").asText();
taskRecord.setResponseData(Map.of("task_id", taskId));
@ -657,11 +673,12 @@ public class AiTaskServiceImpl extends ServiceImpl<AiTaskMapper, AiTask> impleme
}
@Transactional(rollbackFor = Exception.class)
protected String saveTranscripts(Meeting meeting, JsonNode resultNode) {
// 关键:入库前清理旧记录,防止恢复任务导致数据重复
// 闂備胶顭堢换鎴炵箾婵犲洤鏋佹い鎾卞灪閺咁剚鎱ㄥ鍡楀鐎殿喗濞婇獮鏍偓娑櫳戠亸顓烆熆瑜忔慨鎾Υ閹烘宸濇い鏍ㄧ☉閳ь剛鍋ら弻锟犲礃閸曨偅锛嶉柛鐐插閹叉悂鎮ч崼鐔衡敍缂備浇椴哥换鍫濐潖婵傜鐭楀鑸得竟姗€姊虹拠鈥冲箲闁搞劌缍婅棟闁告瑥顦遍々鐑芥偣閸ャ劌绲绘い顐犲€濋幃妤佹媴閸愵煈妫堥梺鎼炰紘閸パ勭€梺缁橆殔閻楀棛绮婇敃鍌涒拺闁圭粯甯炲瓭濡?
transcriptMapper.delete(new LambdaQueryWrapper<MeetingTranscript>().eq(MeetingTranscript::getMeetingId, meeting.getId()));
StringBuilder sb = new StringBuilder();
JsonNode segments = resultNode.path("segments");
StringBuilder sb = new StringBuilder();
Map<String, String> resolvedUserNameCache = buildResolvedUserNameCache(segments);
int savedCount = 0;
if (segments.isArray()) {
int order = 0;
@ -670,7 +687,7 @@ public class AiTaskServiceImpl extends ServiceImpl<AiTaskMapper, AiTask> impleme
mt.setMeetingId(meeting.getId());
String spkId = extractSpeakerId(seg);
String spkName = resolveTranscriptSpeakerName(seg, spkId);
String spkName = resolveTranscriptSpeakerName(seg, spkId, resolvedUserNameCache);
mt.setSpeakerId(spkId);
mt.setSpeakerName(spkName);
@ -714,7 +731,7 @@ public class AiTaskServiceImpl extends ServiceImpl<AiTaskMapper, AiTask> impleme
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("");
if (speakerName == null || speakerName.isBlank()) {
JsonNode speakerNode = seg.path("speaker");
@ -728,13 +745,13 @@ public class AiTaskServiceImpl extends ServiceImpl<AiTaskMapper, AiTask> impleme
if (userId == null || userId.isBlank()) {
userId = seg.path("speaker").path("user_id").asText("");
}
String resolvedUserName = resolveUserName(userId);
String resolvedUserName = resolveUserName(userId, resolvedUserNameCache);
if (resolvedUserName != null) {
return resolvedUserName;
}
if (speakerId != null && speakerId.matches("\\d+")) {
String resolvedSpeakerName = resolveUserName(speakerId);
String resolvedSpeakerName = resolveUserName(speakerId, resolvedUserNameCache);
if (resolvedSpeakerName != null) {
return resolvedSpeakerName;
}
@ -746,10 +763,13 @@ public class AiTaskServiceImpl extends ServiceImpl<AiTaskMapper, AiTask> impleme
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+")) {
return null;
}
if (resolvedUserNameCache != null) {
return resolvedUserNameCache.get(userId);
}
SysUser user = sysUserMapper.selectById(Long.parseLong(userId));
if (user == null) {
return null;
@ -757,6 +777,45 @@ public class AiTaskServiceImpl extends ServiceImpl<AiTaskMapper, AiTask> impleme
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) {
JsonNode timestamp = seg.path("timestamp");
if (timestamp.isArray() && timestamp.size() >= 2) {
@ -955,6 +1014,8 @@ public class AiTaskServiceImpl extends ServiceImpl<AiTaskMapper, AiTask> impleme
? new HashMap<>()
: new HashMap<>(chapterTask.getResponseData());
responseData.put("summarySource", summarySource.toSnapshot());
responseData.put("summarySourceText", summarySource.getText());
responseData.put("rawTranscriptText", summarySource.getRawTranscriptText());
responseData.put("chapterOutlineText", summarySource.getChapterOutlineText());
responseData.put("sourceFingerprint", summarySource.getSourceFingerprint());
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 {
if (isExternalSummaryModeEnabled()) {
triggerExternalSummaryWebhook(meeting, sumTask, chapterTask, "AUTO_AFTER_TRANSCRIPT_READY", false);
@ -985,7 +1092,10 @@ public class AiTaskServiceImpl extends ServiceImpl<AiTaskMapper, AiTask> impleme
return;
}
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()) {
failPendingSummaryTask(sumTask, "没有转录内容");
updateMeetingStatus(meeting.getId(), 4);
@ -1031,7 +1141,7 @@ public class AiTaskServiceImpl extends ServiceImpl<AiTaskMapper, AiTask> impleme
return;
}
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;
}
updateMeetingStatus(meeting.getId(), 2);
@ -1041,7 +1151,7 @@ public class AiTaskServiceImpl extends ServiceImpl<AiTaskMapper, AiTask> impleme
updateProgress(meeting.getId(), 95, result.getMessage(), 0);
} catch (Exception ex) {
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);
}
}
@ -1052,6 +1162,56 @@ public class AiTaskServiceImpl extends ServiceImpl<AiTaskMapper, AiTask> impleme
&& !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) {
if (asrTask == null || asrTask.getTaskConfig() == null) {
return null;
@ -1138,7 +1298,7 @@ public class AiTaskServiceImpl extends ServiceImpl<AiTaskMapper, AiTask> impleme
private String normalizeUrlComponent(String value, String fieldName) {
if (value == null || value.isBlank()) {
throw new IllegalArgumentException(fieldName + "不能为空");
throw new IllegalArgumentException(fieldName + " must not be blank");
}
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 MeetingSummaryFileService meetingSummaryFileService;
private final MeetingTranscriptFileService meetingTranscriptFileService;
private final MeetingTranscriptRevisionService meetingTranscriptRevisionService;
private final MeetingTranscriptChapterService meetingTranscriptChapterService;
private final MeetingDomainSupport meetingDomainSupport;
private final MeetingRuntimeProfileResolver meetingRuntimeProfileResolver;
@ -86,7 +86,7 @@ public class MeetingCommandServiceImpl implements MeetingCommandService {
com.imeeting.mapper.biz.MeetingTranscriptMapper transcriptMapper,
MeetingSummaryFileService meetingSummaryFileService,
MeetingTranscriptFileService meetingTranscriptFileService,
MeetingTranscriptRevisionService meetingTranscriptRevisionService,
MeetingTranscriptChapterService meetingTranscriptChapterService,
MeetingDomainSupport meetingDomainSupport,
MeetingRuntimeProfileResolver meetingRuntimeProfileResolver,
@ -101,7 +101,7 @@ public class MeetingCommandServiceImpl implements MeetingCommandService {
this.transcriptMapper = transcriptMapper;
this.meetingSummaryFileService = meetingSummaryFileService;
this.meetingTranscriptFileService = meetingTranscriptFileService;
this.meetingTranscriptRevisionService = meetingTranscriptRevisionService;
this.meetingTranscriptChapterService = meetingTranscriptChapterService;
this.meetingDomainSupport = meetingDomainSupport;
this.meetingRuntimeProfileResolver = meetingRuntimeProfileResolver;
@ -118,7 +118,7 @@ public class MeetingCommandServiceImpl implements MeetingCommandService {
com.imeeting.mapper.biz.MeetingTranscriptMapper transcriptMapper,
MeetingSummaryFileService meetingSummaryFileService,
MeetingTranscriptFileService meetingTranscriptFileService,
MeetingTranscriptRevisionService meetingTranscriptRevisionService,
MeetingTranscriptChapterService meetingTranscriptChapterService,
MeetingDomainSupport meetingDomainSupport,
MeetingRuntimeProfileResolver meetingRuntimeProfileResolver,
@ -134,7 +134,7 @@ public class MeetingCommandServiceImpl implements MeetingCommandService {
transcriptMapper,
meetingSummaryFileService,
meetingTranscriptFileService,
meetingTranscriptRevisionService,
meetingTranscriptChapterService,
meetingDomainSupport,
meetingRuntimeProfileResolver,
@ -585,7 +585,6 @@ public class MeetingCommandServiceImpl implements MeetingCommandService {
if (updated <= 0) {
throw new RuntimeException("转录记录不存在");
}
meetingTranscriptRevisionService.invalidateCurrentRevision(command.getMeetingId());
meetingTranscriptChapterService.invalidateCurrentVersion(command.getMeetingId());
}

View File

@ -18,6 +18,8 @@ import org.springframework.transaction.support.TransactionSynchronizationManager
import java.time.LocalDateTime;
import java.util.HashMap;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.concurrent.TimeUnit;
@ -55,6 +57,21 @@ public class MeetingProgressServiceImpl implements MeetingProgressService {
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
public Integer resolvePercent(Long meetingId) {
MeetingProgressSnapshot snapshot = redisValueSupport.getJson(RedisKeys.meetingProgressKey(meetingId), MeetingProgressSnapshot.class);
@ -112,6 +129,7 @@ public class MeetingProgressServiceImpl implements MeetingProgressService {
int percent,
String message,
int eta) {
Integer queueAheadCount = resolveQueueAheadCount(task, stage);
String externalTaskId = null;
if (task != null && task.getResponseData() != null && task.getResponseData().get("task_id") != null) {
externalTaskId = String.valueOf(task.getResponseData().get("task_id"));
@ -125,8 +143,9 @@ public class MeetingProgressServiceImpl implements MeetingProgressService {
.stage(stage.getCode())
.stageOrder(stage.getOrder())
.percent(percent)
.message(message)
.message(resolveMessage(stage, message, queueAheadCount))
.eta(eta)
.queueAheadCount(queueAheadCount)
.externalTaskId(externalTaskId)
.queuedAt(task == null ? null : task.getQueuedAt())
.startedAt(task == null ? null : task.getStartedAt())
@ -185,6 +204,36 @@ public class MeetingProgressServiceImpl implements MeetingProgressService {
.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) {
if (candidate == null) {
return false;
@ -198,6 +247,9 @@ public class MeetingProgressServiceImpl implements MeetingProgressService {
}
if (isTerminal(existing) && !isTerminal(candidate)) {
if (isNewAttempt(existing, candidate)) {
return true;
}
return false;
}
if (isTerminal(candidate)) {
@ -239,6 +291,22 @@ public class MeetingProgressServiceImpl implements MeetingProgressService {
&& (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) {
if (!TransactionSynchronizationManager.isSynchronizationActive()) {
runnable.run();

View File

@ -40,7 +40,7 @@ public class MeetingQueryServiceImpl implements MeetingQueryService {
@Override
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>();
if (!isAdmin || !"all".equals(viewType)) {
@ -61,6 +61,10 @@ public class MeetingQueryServiceImpl implements MeetingQueryService {
wrapper.like(Meeting::getTitle, title);
}
if (status != null) {
wrapper.eq(Meeting::getStatus, status);
}
wrapper.orderByDesc(Meeting::getCreatedAt);
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 org.springframework.data.redis.core.StringRedisTemplate;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.concurrent.TimeUnit;
@ -38,6 +40,21 @@ public class RedisOnlyMeetingProgressServiceAdapter implements MeetingProgressSe
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
public Integer resolvePercent(Long meetingId) {
MeetingProgressSnapshot snapshot = readSnapshot(meetingId);
@ -108,6 +125,10 @@ public class RedisOnlyMeetingProgressServiceAdapter implements MeetingProgressSe
int percent,
String message,
int eta) {
String resolvedMessage = message;
if (stage == MeetingProgressStage.QUEUED && (resolvedMessage == null || resolvedMessage.isBlank())) {
resolvedMessage = "已进入 ASR 队列,等待执行";
}
return MeetingProgressSnapshot.builder()
.meetingId(meetingId)
.taskId(task == null ? null : task.getId())
@ -117,7 +138,7 @@ public class RedisOnlyMeetingProgressServiceAdapter implements MeetingProgressSe
.stage(stage.getCode())
.stageOrder(stage.getOrder())
.percent(percent)
.message(message)
.message(resolvedMessage)
.eta(eta)
.queuedAt(task == null ? null : task.getQueuedAt())
.startedAt(task == null ? null : task.getStartedAt())

View File

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

View File

@ -1,303 +1,152 @@
package com.imeeting.service.biz.impl;
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.HotWord;
import com.imeeting.entity.biz.Meeting;
import com.imeeting.entity.biz.MeetingTranscript;
import com.imeeting.mapper.biz.MeetingMapper;
import com.imeeting.mapper.biz.MeetingTranscriptMapper;
import com.imeeting.service.biz.AiModelService;
import com.imeeting.service.biz.HotWordService;
import com.imeeting.service.biz.MeetingProgressService;
import com.imeeting.service.biz.MeetingSummaryFileService;
import com.imeeting.service.biz.MeetingTranscriptChapterService;
import com.imeeting.service.biz.MeetingTranscriptFileService;
import com.imeeting.service.biz.MeetingTranscriptRevisionService;
import com.imeeting.support.RedisValueSupport;
import com.imeeting.support.TaskSecurityContextRunner;
import com.unisbase.mapper.SysUserMapper;
import com.unisbase.service.SysParamService;
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 java.net.URI;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
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.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.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;
class AiTaskServiceImplTest {
@Test
void appendPathShouldTrimWhitespaceAndNormalizeSlashBoundaries() {
AiTaskServiceImpl service = createService();
void buildAsrRequestShouldFollowCurrentOfflineAsrContract() {
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(
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(
AiTaskServiceImpl service = new AiTaskServiceImpl(
mock(MeetingMapper.class),
mock(MeetingTranscriptMapper.class),
mock(AiModelService.class),
mock(StringRedisTemplate.class),
mock(TaskSecurityContextRunner.class),
new ObjectMapper(),
mock(SysUserMapper.class),
hotWordService,
mock(RedisValueSupport.class),
mock(MeetingProgressService.class),
mock(MeetingSummaryFileService.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,
MeetingTranscriptMapper transcriptMapper,
AiModelService aiModelService,
StringRedisTemplate redisTemplate,
TaskSecurityContextRunner taskSecurityContextRunner,
MeetingTranscriptFileService meetingTranscriptFileService,
MeetingTranscriptRevisionService revisionService,
MeetingTranscriptChapterService chapterService) {
return new AiTaskServiceImpl(
meetingMapper,
transcriptMapper,
aiModelService,
@Test
void buildAsrRequestShouldDisableRegistryMatchWhenSpeakerSplitDisabled() {
AiTaskServiceImpl service = new AiTaskServiceImpl(
mock(MeetingMapper.class),
mock(MeetingTranscriptMapper.class),
mock(AiModelService.class),
new ObjectMapper(),
mock(SysUserMapper.class),
mock(HotWordService.class),
redisTemplate,
mock(RedisValueSupport.class),
mock(MeetingProgressService.class),
mock(MeetingSummaryFileService.class),
meetingTranscriptFileService,
revisionService,
chapterService,
mock(MeetingTranscriptFileService.class),
mock(MeetingTranscriptChapterService.class),
mock(MeetingSummaryPromptAssembler.class),
taskSecurityContextRunner,
mock(MeetingExternalSummaryWebhookTrigger.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/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;
title?: string;
viewType?: "all" | "created" | "involved";
status?: number;
}) => {
return http.get<{ code: string; data: { records: MeetingVO[]; total: number }; msg: string }>(
"/api/biz/meeting/page",
@ -426,6 +427,7 @@ export interface MeetingProgress {
message: string;
updateAt: number;
eta?: number;
queueAheadCount?: number;
}
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") => {
const token = localStorage.getItem("accessToken");
return axios.get(`/api/biz/meeting/${id}/summary/export`, {

View File

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

View File

@ -6,6 +6,7 @@ import {
CloudUploadOutlined,
DeleteOutlined,
EditOutlined,
FilterOutlined,
InfoCircleOutlined,
PauseCircleOutlined,
PlusOutlined,
@ -38,7 +39,7 @@ import {
Typography,
} from "antd";
import dayjs from "dayjs";
import React, { useEffect, useState } from "react";
import React, { useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { useNavigate, useSearchParams } from "react-router-dom";
@ -47,7 +48,7 @@ import {
deleteMeeting,
getMeetingCreateConfig,
getMeetingPage,
getMeetingProgress,
getMeetingProgressBatch,
getRealtimeMeetingSessionStatus,
getRealtimeMeetingSessionStatuses,
type MeetingCreateConfig,
@ -64,11 +65,21 @@ import PageContainer from "../../components/shared/PageContainer";
const { Title, Text } = Typography;
const { Option } = Select;
const { Search } = Input;
const CURRENT_PLATFORM = "WEB" as const;
const PAUSED_DISPLAY_STATUS = 5;
const REALTIME_ACTIVE_DISPLAY_STATUS = 6;
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 = {
offlineEnabled: true,
realtimeEnabled: true,
@ -95,9 +106,17 @@ const canOpenRealtimeSession = (status?: RealtimeMeetingSessionStatus["status"])
|| status === "ACTIVE"
|| 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) =>
item.status === 1
|| item.status === 2
shouldTrackGenerationProgress(item)
|| item.realtimeSessionStatus === "ACTIVE"
|| isPausedRealtimeSessionStatus(item.realtimeSessionStatus);
@ -130,35 +149,9 @@ const applyRealtimeSessionStatus = (item: MeetingVO, sessionStatus?: RealtimeMee
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 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 }> = {
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 /> },
@ -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 /> },
};
const config = statusConfig[effectiveStatus] || statusConfig[0];
const percent = meeting.status === 1 || meeting.status === 2 ? progress?.percent || 0 : 0;
const isProcessing = meeting.status === 1 || meeting.status === 2;
const isProcessing = shouldTrackGenerationProgress(meeting);
const percent = isProcessing ? progress?.percent || 0 : 0;
return (
<div
@ -213,20 +206,19 @@ const IntegratedStatusTag: React.FC<{ meeting: MeetingVO; progress: MeetingProgr
);
};
const TableStatusCell: React.FC<{ meeting: MeetingVO; fetchData: () => void }> = ({ meeting, fetchData }) => {
const progress = useMeetingProgress(meeting, fetchData);
const TableStatusCell: React.FC<{ meeting: MeetingVO; progress: MeetingProgress | null }> = ({ meeting, progress }) => {
return <IntegratedStatusTag meeting={meeting} progress={progress} />;
};
const MeetingCardItem: React.FC<{
item: MeetingVO;
config: { text: string; color: string; bgColor: string };
progress: MeetingProgress | null;
fetchData: () => void;
onOpenMeeting: (meeting: MeetingVO) => void;
}> = ({ item, config, fetchData, onOpenMeeting }) => {
const progress = useMeetingProgress(item, fetchData);
const effectiveStatus = item.displayStatus ?? item.status;
const isProcessing = item.status === 1 || item.status === 2;
}> = ({ item, config, progress, fetchData, onOpenMeeting }) => {
const effectiveStatus = hasLatestGenerationFailure(item) ? 4 : (item.displayStatus ?? item.status);
const isProcessing = shouldTrackGenerationProgress(item);
const isPaused = effectiveStatus === PAUSED_DISPLAY_STATUS;
const isRealtimeActive = effectiveStatus === REALTIME_ACTIVE_DISPLAY_STATUS;
const isRealtimeIdle = effectiveStatus === REALTIME_IDLE_DISPLAY_STATUS;
@ -403,12 +395,15 @@ const Meetings: React.FC = () => {
const [searchParams, setSearchParams] = useSearchParams();
const [loading, setLoading] = useState(false);
const [data, setData] = useState<MeetingVO[]>([]);
const [progressMap, setProgressMap] = useState<Record<number, MeetingProgress>>({});
const [total, setTotal] = useState(0);
const [current, setCurrent] = useState(1);
const [displayMode, setDisplayMode] = useState<"card" | "list">("card");
const [size, setSize] = useState(8);
const [searchTitle, setSearchTitle] = useState("");
const [searchKeyword, setSearchKeyword] = useState("");
const [viewType, setViewType] = useState<"all" | "created" | "involved">("all");
const [statusFilter, setStatusFilter] = useState<string>(ALL_STATUS_FILTER);
const [createDrawerVisible, setCreateDrawerVisible] = useState(false);
const [createDrawerType, setCreateDrawerType] = useState<MeetingCreateType>("upload");
const [configLoaded, setConfigLoaded] = useState(false);
@ -418,8 +413,9 @@ const Meetings: React.FC = () => {
offlineAudioMaxSizeMb: 1024,
});
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") => {
setDisplayMode(mode);
@ -427,6 +423,44 @@ const Meetings: React.FC = () => {
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(() => {
const action = searchParams.get("action");
const type = searchParams.get("type") as MeetingCreateType;
@ -439,15 +473,105 @@ const Meetings: React.FC = () => {
useEffect(() => {
void fetchData();
}, [current, size, searchTitle, viewType]);
}, [current, size, searchTitle, viewType, statusFilter]);
useEffect(() => {
if (!hasRunningTasks) {
const trackedMeetings = data.filter(shouldTrackGenerationProgress);
if (trackedMeetings.length === 0) {
progressTerminalRefreshRef.current.clear();
setProgressMap({});
return;
}
const timer = setInterval(() => void fetchData(true), 5000);
return () => clearInterval(timer);
}, [hasRunningTasks, current, size, searchTitle, viewType]);
let cancelled = false;
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(() => {
listUsers().then((users) => setUserList(users || [])).catch(() => setUserList([]));
@ -467,7 +591,13 @@ const Meetings: React.FC = () => {
setLoading(true);
}
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 || [];
let statusMap: Record<number, RealtimeMeetingSessionStatus> = {};
const realtimeCandidates = records.filter(isRealtimeMeetingCandidate);
@ -477,8 +607,10 @@ const Meetings: React.FC = () => {
statusMap = sessionRes.data?.data || {};
} 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);
await loadBatchProgress(nextData);
} finally {
if (!silent) {
setLoading(false);
@ -525,7 +657,7 @@ const Meetings: React.FC = () => {
title: "状态",
key: "status",
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: "会议时间",
@ -611,20 +743,70 @@ const Meetings: React.FC = () => {
</Space>
}
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.Button value="all"></Radio.Button>
<Radio.Button value="created"></Radio.Button>
<Radio.Button value="involved"></Radio.Button>
</Radio.Group>
<Input
placeholder="搜索标题"
prefix={<SearchOutlined />}
allowClear
onPressEnter={(e) => { setSearchTitle((e.target as HTMLInputElement).value); setCurrent(1); }}
style={{ width: 200 }}
<Space wrap size={10} align="center">
<Tag
color={activeFilterCount > 0 ? "processing" : "default"}
style={{ margin: 0, borderRadius: 999, paddingInline: 10, lineHeight: "24px" }}
>
<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
@ -639,8 +821,17 @@ const Meetings: React.FC = () => {
grid={{ gutter: [20, 20], xs: 1, sm: 2, md: 2, lg: 3, xl: 4, xxl: 4 }}
dataSource={data}
renderItem={(item) => {
const config = statusConfig[item.displayStatus ?? item.status] || statusConfig[0];
return <MeetingCardItem item={item} config={config} fetchData={() => void fetchData()} onOpenMeeting={handleOpenMeeting} />;
const visualStatus = hasLatestGenerationFailure(item) ? 4 : (item.displayStatus ?? item.status);
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="开启您的第一场会议分析" /> }}
/>

View File

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