diff --git a/backend/src/main/java/com/imeeting/common/RedisKeys.java b/backend/src/main/java/com/imeeting/common/RedisKeys.java index 789a06e..27921f2 100644 --- a/backend/src/main/java/com/imeeting/common/RedisKeys.java +++ b/backend/src/main/java/com/imeeting/common/RedisKeys.java @@ -43,6 +43,10 @@ public final class RedisKeys { return "biz:meeting:polling:lock:" + meetingId; } + public static String meetingSummaryLockKey(Long meetingId) { + return "biz:meeting:summary:lock:" + meetingId; + } + public static String realtimeMeetingSocketSessionKey(String sessionToken) { return "biz:meeting:realtime:socket:" + sessionToken; } diff --git a/backend/src/main/java/com/imeeting/common/SysParamKeys.java b/backend/src/main/java/com/imeeting/common/SysParamKeys.java index d016b75..840109f 100644 --- a/backend/src/main/java/com/imeeting/common/SysParamKeys.java +++ b/backend/src/main/java/com/imeeting/common/SysParamKeys.java @@ -5,5 +5,7 @@ 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"; } diff --git a/backend/src/main/java/com/imeeting/controller/android/AndroidMeetingController.java b/backend/src/main/java/com/imeeting/controller/android/AndroidMeetingController.java index ec7538a..86df845 100644 --- a/backend/src/main/java/com/imeeting/controller/android/AndroidMeetingController.java +++ b/backend/src/main/java/com/imeeting/controller/android/AndroidMeetingController.java @@ -251,6 +251,20 @@ public class AndroidMeetingController { Integer realtimeProgress = resolveRealtimeProgress(meetingId); if (realtimeProgress != null) { + if (realtimeProgress >= 100) { + MeetingVO completedDetail = detail != null ? detail : meetingQueryService.getDetail(meetingId); + boolean completedHasSummary = completedDetail != null + && completedDetail.getSummaryContent() != null + && !completedDetail.getSummaryContent().isBlank(); + if (completedHasSummary) { + return new LegacyMeetingPreviewResult("200", "success", buildCompletedPreview(meeting, completedDetail, summaryTask)); + } + return new LegacyMeetingPreviewResult( + "504", + "处理已完成,但摘要尚未同步,请稍后重试", + buildProcessingPreview(meeting, summaryTask, processingStatus("摘要已生成,可查看详情", 100, STAGE_COMPLETED)) + ); + } if (realtimeProgress < 90) { return new LegacyMeetingPreviewResult( "400", @@ -258,7 +272,7 @@ public class AndroidMeetingController { buildProcessingPreview(meeting, summaryTask, processingStatus("正在转写音频", 50, STAGE_AUDIO_TRANSCRIPTION)) ); } - if (realtimeProgress == 90) { + if (realtimeProgress >= 90) { return new LegacyMeetingPreviewResult( "400", "会议正在处理中", diff --git a/backend/src/main/java/com/imeeting/controller/android/legacy/LegacyMeetingController.java b/backend/src/main/java/com/imeeting/controller/android/legacy/LegacyMeetingController.java index 0dd2767..bb09202 100644 --- a/backend/src/main/java/com/imeeting/controller/android/legacy/LegacyMeetingController.java +++ b/backend/src/main/java/com/imeeting/controller/android/legacy/LegacyMeetingController.java @@ -237,6 +237,9 @@ public class LegacyMeetingController { : null; boolean hasSummary = detail != null && detail.getSummaryContent() != null && !detail.getSummaryContent().isBlank(); + if (hasSummary) { + return new LegacyMeetingPreviewResult("200", "success", buildCompletedPreview(meeting, detail, summaryTask)); + } if (summaryCompleted) { return new LegacyMeetingPreviewResult("200", "success", buildCompletedPreview(meeting, detail, summaryTask)); } @@ -264,17 +267,31 @@ public class LegacyMeetingController { Integer realtimeProgress = resolveRealtimeProgress(meetingId); if (realtimeProgress != null) { + if (realtimeProgress >= 100) { + MeetingVO completedDetail = detail != null ? detail : meetingQueryService.getDetail(meetingId); + boolean completedHasSummary = completedDetail != null + && completedDetail.getSummaryContent() != null + && !completedDetail.getSummaryContent().isBlank(); + if (completedHasSummary) { + return new LegacyMeetingPreviewResult("200", "success", buildCompletedPreview(meeting, completedDetail, summaryTask)); + } + return new LegacyMeetingPreviewResult( + "504", + "处理已完成,但摘要尚未同步,请稍后重试", + buildProcessingPreview(meeting, summaryTask, processingStatus("摘要已生成,可扫码查看", 100, STAGE_COMPLETED)) + ); + } if (realtimeProgress < 90) { return new LegacyMeetingPreviewResult( "400", - "浼氳姝e湪澶勭悊涓?", + "会议正在处理中", buildProcessingPreview(meeting, summaryTask, processingStatus("姝e湪杞瘧闊抽", 50, STAGE_AUDIO_TRANSCRIPTION)) ); } - if (realtimeProgress == 90) { + if (realtimeProgress >= 90) { return new LegacyMeetingPreviewResult( "400", - "浼氳姝e湪澶勭悊涓?", + "会议正在处理中", buildProcessingPreview(meeting, summaryTask, processingStatus("姝e湪鐢熸垚鎬荤粨", 75, STAGE_SUMMARY_GENERATION)) ); } diff --git a/backend/src/main/java/com/imeeting/dto/biz/MeetingSummarySource.java b/backend/src/main/java/com/imeeting/dto/biz/MeetingSummarySource.java new file mode 100644 index 0000000..ac8ed96 --- /dev/null +++ b/backend/src/main/java/com/imeeting/dto/biz/MeetingSummarySource.java @@ -0,0 +1,32 @@ +package com.imeeting.dto.biz; + +import lombok.Builder; +import lombok.Data; + +import java.util.LinkedHashMap; +import java.util.Map; + +@Data +@Builder +public class MeetingSummarySource { + private String text; + private String sourceType; + private Long revisionId; + private boolean fallbackUsed; + private String sourceFingerprint; + private String triggerTaskType; + private String semanticCorrector; + private String ruleProfileVersion; + + public Map toSnapshot() { + Map snapshot = new LinkedHashMap<>(); + snapshot.put("sourceType", sourceType); + snapshot.put("revisionId", revisionId); + snapshot.put("fallbackUsed", fallbackUsed); + snapshot.put("sourceFingerprint", sourceFingerprint); + snapshot.put("triggerTaskType", triggerTaskType); + snapshot.put("semanticCorrector", semanticCorrector); + snapshot.put("ruleProfileVersion", ruleProfileVersion); + return snapshot; + } +} diff --git a/backend/src/main/java/com/imeeting/entity/biz/MeetingTranscriptRevision.java b/backend/src/main/java/com/imeeting/entity/biz/MeetingTranscriptRevision.java new file mode 100644 index 0000000..eddd78d --- /dev/null +++ b/backend/src/main/java/com/imeeting/entity/biz/MeetingTranscriptRevision.java @@ -0,0 +1,57 @@ +package com.imeeting.entity.biz; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import java.time.LocalDateTime; + +@Data +@Schema(description = "会议转录修正版") +@TableName("biz_meeting_transcript_revisions") +public class MeetingTranscriptRevision { + @TableId(value = "id", type = IdType.AUTO) + @Schema(description = "修正版ID") + private Long id; + + @Schema(description = "会议ID") + private Long meetingId; + + @Schema(description = "触发本次修正版生成的任务ID") + private Long sourceTaskId; + + @Schema(description = "版本号") + private Integer revisionNo; + + @Schema(description = "状态") + private Integer status; + + @Schema(description = "修正版全文") + private String cleanedFullText; + + @Schema(description = "结果文件路径") + private String resultFilePath; + + @Schema(description = "规则配置快照") + private String ruleProfile; + + @Schema(description = "片段数") + private Integer segmentCount; + + @Schema(description = "删除片段数") + private Integer droppedSegmentCount; + + @Schema(description = "合并组数") + private Integer mergedGroupCount; + + @Schema(description = "是否当前生效") + private Integer isCurrent; + + @Schema(description = "创建时间") + private LocalDateTime createdAt; + + @Schema(description = "更新时间") + private LocalDateTime updatedAt; +} diff --git a/backend/src/main/java/com/imeeting/entity/biz/MeetingTranscriptRevisionItem.java b/backend/src/main/java/com/imeeting/entity/biz/MeetingTranscriptRevisionItem.java new file mode 100644 index 0000000..b1b7c11 --- /dev/null +++ b/backend/src/main/java/com/imeeting/entity/biz/MeetingTranscriptRevisionItem.java @@ -0,0 +1,60 @@ +package com.imeeting.entity.biz; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import java.time.LocalDateTime; + +@Data +@Schema(description = "会议转录修正版明细") +@TableName("biz_meeting_transcript_revision_items") +public class MeetingTranscriptRevisionItem { + @TableId(value = "id", type = IdType.AUTO) + @Schema(description = "修正版明细ID") + private Long id; + + @Schema(description = "修正版ID") + private Long revisionId; + + @Schema(description = "原始转录ID") + private Long sourceTranscriptId; + + @Schema(description = "原始排序值") + private Integer sourceSortOrder; + + @Schema(description = "原始说话人ID") + private String sourceSpeakerId; + + @Schema(description = "原始说话人名称") + private String sourceSpeakerName; + + @Schema(description = "原始内容") + private String sourceContent; + + @Schema(description = "清洗后内容") + private String cleanedContent; + + @Schema(description = "清洗后说话人名称") + private String cleanedSpeakerName; + + @Schema(description = "动作类型") + private String actionType; + + @Schema(description = "合并组ID") + private String mergeGroupId; + + @Schema(description = "置信度") + private java.math.BigDecimal confidence; + + @Schema(description = "命中规则") + private String ruleHits; + + @Schema(description = "上下文快照") + private String contextSnapshot; + + @Schema(description = "创建时间") + private LocalDateTime createdAt; +} diff --git a/backend/src/main/java/com/imeeting/listener/MeetingTaskRecoveryListener.java b/backend/src/main/java/com/imeeting/listener/MeetingTaskRecoveryListener.java index f485fb3..d2261d5 100644 --- a/backend/src/main/java/com/imeeting/listener/MeetingTaskRecoveryListener.java +++ b/backend/src/main/java/com/imeeting/listener/MeetingTaskRecoveryListener.java @@ -59,6 +59,7 @@ public class MeetingTaskRecoveryListener implements ApplicationRunner { // 2. 清理旧的 Redis 锁和进度缓存,确保恢复线程能拿到控制权 redisTemplate.delete(RedisKeys.meetingPollingLockKey(meeting.getId())); + redisTemplate.delete(RedisKeys.meetingSummaryLockKey(meeting.getId())); // 3. 根据状态重新派发任务 (平滑拉起) if (meeting.getStatus() == 1) { diff --git a/backend/src/main/java/com/imeeting/mapper/biz/MeetingTranscriptRevisionItemMapper.java b/backend/src/main/java/com/imeeting/mapper/biz/MeetingTranscriptRevisionItemMapper.java new file mode 100644 index 0000000..6ec8629 --- /dev/null +++ b/backend/src/main/java/com/imeeting/mapper/biz/MeetingTranscriptRevisionItemMapper.java @@ -0,0 +1,9 @@ +package com.imeeting.mapper.biz; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.imeeting.entity.biz.MeetingTranscriptRevisionItem; +import org.apache.ibatis.annotations.Mapper; + +@Mapper +public interface MeetingTranscriptRevisionItemMapper extends BaseMapper { +} diff --git a/backend/src/main/java/com/imeeting/mapper/biz/MeetingTranscriptRevisionMapper.java b/backend/src/main/java/com/imeeting/mapper/biz/MeetingTranscriptRevisionMapper.java new file mode 100644 index 0000000..d67cc06 --- /dev/null +++ b/backend/src/main/java/com/imeeting/mapper/biz/MeetingTranscriptRevisionMapper.java @@ -0,0 +1,9 @@ +package com.imeeting.mapper.biz; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.imeeting.entity.biz.MeetingTranscriptRevision; +import org.apache.ibatis.annotations.Mapper; + +@Mapper +public interface MeetingTranscriptRevisionMapper extends BaseMapper { +} diff --git a/backend/src/main/java/com/imeeting/service/biz/MeetingTranscriptRevisionService.java b/backend/src/main/java/com/imeeting/service/biz/MeetingTranscriptRevisionService.java new file mode 100644 index 0000000..f9a89d5 --- /dev/null +++ b/backend/src/main/java/com/imeeting/service/biz/MeetingTranscriptRevisionService.java @@ -0,0 +1,21 @@ +package com.imeeting.service.biz; + +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 java.util.List; + +public interface MeetingTranscriptRevisionService { + String generateOfflineCurrentRevision(Meeting meeting, AiTask task, AiModelVO asrModel); + + MeetingSummarySource resolveSummarySource(Meeting meeting, AiTask summaryTask, AiModelVO asrModel); + + List listEffectiveTranscripts(Long meetingId); + + boolean updateCurrentRevisionContent(Long meetingId, Long operatorId, String content); + + void invalidateCurrentRevision(Long meetingId); +} diff --git a/backend/src/main/java/com/imeeting/service/biz/impl/AiTaskServiceImpl.java b/backend/src/main/java/com/imeeting/service/biz/impl/AiTaskServiceImpl.java index f0c025b..771231e 100644 --- a/backend/src/main/java/com/imeeting/service/biz/impl/AiTaskServiceImpl.java +++ b/backend/src/main/java/com/imeeting/service/biz/impl/AiTaskServiceImpl.java @@ -7,6 +7,7 @@ import com.fasterxml.jackson.databind.ObjectMapper; import com.imeeting.common.RedisKeys; import com.imeeting.dto.biz.AiModelVO; +import com.imeeting.dto.biz.MeetingSummarySource; import com.imeeting.entity.biz.AiTask; import com.imeeting.entity.biz.HotWord; import com.imeeting.entity.biz.Meeting; @@ -20,6 +21,7 @@ import com.imeeting.service.biz.AiTaskService; import com.imeeting.service.biz.HotWordService; import com.imeeting.service.biz.MeetingSummaryFileService; import com.imeeting.service.biz.MeetingTranscriptFileService; +import com.imeeting.service.biz.MeetingTranscriptRevisionService; import com.unisbase.entity.SysUser; import com.unisbase.mapper.SysUserMapper; @@ -60,6 +62,7 @@ public class AiTaskServiceImpl extends ServiceImpl impleme private final StringRedisTemplate redisTemplate; private final MeetingSummaryFileService meetingSummaryFileService; private final MeetingTranscriptFileService meetingTranscriptFileService; + private final MeetingTranscriptRevisionService meetingTranscriptRevisionService; private final MeetingSummaryPromptAssembler meetingSummaryPromptAssembler; private final TaskSecurityContextRunner taskSecurityContextRunner; @@ -99,7 +102,7 @@ public class AiTaskServiceImpl extends ServiceImpl impleme .last("limit 1")); String asrText = ""; - if (asrTask != null && asrTask.getStatus() == 0) { + if (asrTask != null && canExecuteTask(asrTask)) { asrText = processAsrTask(meeting, asrTask); } else { List transcripts = transcriptMapper.selectList(new LambdaQueryWrapper() @@ -129,13 +132,11 @@ public class AiTaskServiceImpl extends ServiceImpl impleme updateProgress(meetingId, -1, "未识别到可用于总结的转录内容", 0); return; } - if (sumTask != null && sumTask.getStatus() == 0) { - processSummaryTask(meeting, asrText, sumTask); + if (sumTask != null && canExecuteTask(sumTask)) { + executeSummaryFlow(meeting, sumTask, resolveAsrModelForRevision(asrTask)); } else if (meeting.getStatus() != 3) { updateMeetingStatus(meetingId, 3); } - - redisTemplate.delete(RedisKeys.meetingProgressKey(meetingId)); } catch (Exception e) { log.error("Meeting {} AI Task Flow failed", meetingId, e); failPendingSummaryTask(findLatestSummaryTask(meetingId), "转录失败,已跳过总结任务:" + e.getMessage()); @@ -154,34 +155,19 @@ public class AiTaskServiceImpl extends ServiceImpl impleme private void doDispatchSummaryTask(Long meetingId) { Meeting meeting = meetingMapper.selectById(meetingId); - if (meeting == null) return; - AiTask sumTask = this.getOne(new LambdaQueryWrapper() - .eq(AiTask::getMeetingId, meetingId) - .eq(AiTask::getTaskType, "SUMMARY") - .orderByDesc(AiTask::getId) - .last("limit 1")); + if (meeting == null) { + return; + } + AiTask sumTask = findLatestTask(meetingId, "SUMMARY"); + AiTask asrTask = findLatestTask(meetingId, "ASR"); try { - List transcripts = transcriptMapper.selectList(new LambdaQueryWrapper() - .eq(MeetingTranscript::getMeetingId, meetingId) - .orderByAsc(MeetingTranscript::getStartTime)); - - if (transcripts.isEmpty()) { - failPendingSummaryTask(sumTask, "没有可用于总结的转录内容"); - throw new RuntimeException("没有找到可用的转录文本,无法生成总结"); - } - - String asrText = buildTranscriptText(transcripts); - if (asrText == null || asrText.isBlank()) { - failPendingSummaryTask(sumTask, "没有可用于总结的转录内容"); - throw new RuntimeException("没有可用于总结的转录内容"); - } - if (sumTask != null && sumTask.getStatus() == 0) { - processSummaryTask(meeting, asrText, sumTask); + if (sumTask != null && canExecuteTask(sumTask)) { + executeSummaryFlow(meeting, sumTask, resolveAsrModelForRevision(asrTask)); } } catch (Exception e) { log.error("Re-summary failed for meeting {}", meetingId, e); updateMeetingStatus(meetingId, 4); - updateProgress(meetingId, -1, "总结失败: " + e.getMessage(), 0); + updateProgress(meetingId, -1, "Summary flow failed: " + e.getMessage(), 0); } } @@ -197,21 +183,28 @@ public class AiTaskServiceImpl extends ServiceImpl impleme if (asrModel == null) throw new RuntimeException("ASR模型配置不存在"); String submitUrl = appendPath(asrModel.getBaseUrl(), "api/v1/asr/transcriptions"); - String taskId = null; + String taskId = taskRecord.getResponseData() != null + ? String.valueOf(taskRecord.getResponseData().getOrDefault("task_id", "")) + : ""; - updateProgress(meeting.getId(), 5, "正在提交识别请求...", 0); - Map req = buildAsrRequest(meeting, taskRecord, asrModel); - taskRecord.setRequestData(req); - this.updateById(taskRecord); + if (taskId == null || taskId.isBlank()) { + updateProgress(meeting.getId(), 5, "正在提交识别请求...", 0); + Map req = buildAsrRequest(meeting, taskRecord, asrModel); + taskRecord.setRequestData(req); + this.updateById(taskRecord); - String respBody = postJson(submitUrl, req, asrModel.getApiKey()); - JsonNode submitNode = objectMapper.readTree(respBody); - if (submitNode.path("code").asInt() != 0) { - updateAiTaskFail(taskRecord, "提交失败:" + respBody); - throw new RuntimeException("ASR引擎拒绝请求: " + submitNode.path("msg").asText()); + String respBody = postJson(submitUrl, req, asrModel.getApiKey()); + JsonNode submitNode = objectMapper.readTree(respBody); + if (submitNode.path("code").asInt() != 0) { + updateAiTaskFail(taskRecord, "提交失败: " + respBody); + throw new RuntimeException("ASR引擎拒绝请求: " + submitNode.path("msg").asText()); + } + taskId = submitNode.path("data").path("task_id").asText(); + taskRecord.setResponseData(Map.of("task_id", taskId)); + this.updateById(taskRecord); + } else { + updateProgress(meeting.getId(), 5, "Resuming ASR polling...", 0); } - taskId = submitNode.path("data").path("task_id").asText(); - taskRecord.setResponseData(Map.of("task_id", taskId)); this.updateById(taskRecord); String queryUrl = appendPath(asrModel.getBaseUrl(), "api/v1/asr/transcriptions/" + taskId); @@ -473,12 +466,16 @@ public class AiTaskServiceImpl extends ServiceImpl impleme .last("limit 1")); } - private void processSummaryTask(Meeting meeting, String asrText, AiTask taskRecord) throws Exception { + private void processSummaryTask(Meeting meeting, MeetingSummarySource summarySource, AiTask taskRecord) throws Exception { + String asrText = summarySource.getText(); updateMeetingStatus(meeting.getId(), 2); updateProgress(meeting.getId(), 90, "正在生成智能总结纪要...", 0); taskRecord.setStatus(1); taskRecord.setStartedAt(LocalDateTime.now()); + Map initialResponseData = new HashMap<>(); + initialResponseData.put("summarySource", summarySource.toSnapshot()); + taskRecord.setResponseData(initialResponseData); this.updateById(taskRecord); Long summaryModelId = Long.valueOf(taskRecord.getTaskConfig().get("summaryModelId").toString()); @@ -555,6 +552,7 @@ public class AiTaskServiceImpl extends ServiceImpl impleme taskRecord.setResultFilePath("meetings/" + meeting.getId() + "/summaries/" + fileName); Map responseData = objectMapper.convertValue(respNode, Map.class); + responseData.put("summarySource", summarySource.toSnapshot()); if (summaryBundle != null) { responseData.put("summaryBundle", summaryBundle); } @@ -572,11 +570,62 @@ public class AiTaskServiceImpl extends ServiceImpl impleme updateProgress(meeting.getId(), 100, "全流程分析完成", 0); } else { - updateAiTaskFail(taskRecord, "LLM 总结失败:" + response.body()); + updateAiTaskFail(taskRecord, "LLM 总结失败: " + response.body()); throw new RuntimeException("AI总结生成异常"); } } + private void executeSummaryFlow(Meeting meeting, AiTask sumTask, AiModelVO asrModel) throws Exception { + String summaryLockKey = RedisKeys.meetingSummaryLockKey(meeting.getId()); + Boolean acquired = redisTemplate.opsForValue().setIfAbsent(summaryLockKey, "locked", 30, TimeUnit.MINUTES); + if (Boolean.FALSE.equals(acquired)) { + log.warn("Meeting {} summary is already being processed", meeting.getId()); + return; + } + try { + MeetingSummarySource summarySource = meetingTranscriptRevisionService.resolveSummarySource(meeting, sumTask, asrModel); + if (summarySource.getText() == null || summarySource.getText().isBlank()) { + failPendingSummaryTask(sumTask, "没有转录内容"); + updateMeetingStatus(meeting.getId(), 4); + updateProgress(meeting.getId(), -1, "没有转录内容", 0); + return; + } + processSummaryTask(meeting, summarySource, sumTask); + } finally { + redisTemplate.delete(summaryLockKey); + } + } + + private AiTask findLatestTask(Long meetingId, String taskType) { + return this.getOne(new LambdaQueryWrapper() + .eq(AiTask::getMeetingId, meetingId) + .eq(AiTask::getTaskType, taskType) + .orderByDesc(AiTask::getId) + .last("limit 1")); + } + + private boolean canExecuteTask(AiTask task) { + return task != null + && !Integer.valueOf(2).equals(task.getStatus()) + && !Integer.valueOf(3).equals(task.getStatus()); + } + + private AiModelVO resolveAsrModelForRevision(AiTask asrTask) { + if (asrTask == null || asrTask.getTaskConfig() == null) { + return null; + } + Object asrModelId = asrTask.getTaskConfig().get("asrModelId"); + if (asrModelId == null) { + return null; + } + try { + return aiModelService.getModelById(Long.parseLong(String.valueOf(asrModelId)), "ASR"); + } catch (Exception ex) { + log.warn("Failed to resolve ASR model for transcript revision, taskId={}", asrTask.getId(), ex); + return null; + } + } + private void updateProgress(Long meetingId, int percent, String msg, int eta) { try { Map progress = new HashMap<>(); diff --git a/backend/src/main/java/com/imeeting/service/biz/impl/MeetingCommandServiceImpl.java b/backend/src/main/java/com/imeeting/service/biz/impl/MeetingCommandServiceImpl.java index 824374d..f6f72a3 100644 --- a/backend/src/main/java/com/imeeting/service/biz/impl/MeetingCommandServiceImpl.java +++ b/backend/src/main/java/com/imeeting/service/biz/impl/MeetingCommandServiceImpl.java @@ -25,6 +25,7 @@ import com.imeeting.service.biz.MeetingRuntimeProfileResolver; import com.imeeting.service.biz.MeetingService; import com.imeeting.service.biz.MeetingSummaryFileService; import com.imeeting.service.biz.MeetingTranscriptFileService; +import com.imeeting.service.biz.MeetingTranscriptRevisionService; import com.imeeting.service.biz.RealtimeMeetingSessionStateService; import com.imeeting.service.realtime.RealtimeMeetingAudioStorageService; import lombok.extern.slf4j.Slf4j; @@ -55,6 +56,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 MeetingDomainSupport meetingDomainSupport; private final MeetingRuntimeProfileResolver meetingRuntimeProfileResolver; private final RealtimeMeetingSessionStateService realtimeMeetingSessionStateService; @@ -436,6 +438,17 @@ public class MeetingCommandServiceImpl implements MeetingCommandService { throw new RuntimeException("转录内容不能为空"); } + MeetingTranscript existing = transcriptMapper.selectOne(new LambdaQueryWrapper() + .eq(MeetingTranscript::getMeetingId, command.getMeetingId()) + .eq(MeetingTranscript::getId, command.getTranscriptId()) + .last("LIMIT 1")); + if (existing == null) { + throw new RuntimeException("转录记录不存在"); + } + if (Objects.equals(normalizeTranscriptContent(existing.getContent()), content)) { + return; + } + int updated = transcriptMapper.update(null, new LambdaUpdateWrapper() .eq(MeetingTranscript::getMeetingId, command.getMeetingId()) .eq(MeetingTranscript::getId, command.getTranscriptId()) @@ -443,6 +456,7 @@ public class MeetingCommandServiceImpl implements MeetingCommandService { if (updated <= 0) { throw new RuntimeException("转录记录不存在"); } + meetingTranscriptRevisionService.invalidateCurrentRevision(command.getMeetingId()); } @Override @@ -464,6 +478,10 @@ public class MeetingCommandServiceImpl implements MeetingCommandService { return normalized.isEmpty() ? null : normalized; } + private String normalizeTranscriptContent(String content) { + return content == null ? "" : content.trim(); + } + @Override @Transactional(rollbackFor = Exception.class) public void updateMeetingParticipants(Long meetingId, String participants) { diff --git a/backend/src/main/java/com/imeeting/service/biz/impl/MeetingTranscriptFileServiceImpl.java b/backend/src/main/java/com/imeeting/service/biz/impl/MeetingTranscriptFileServiceImpl.java index a653594..e476b66 100644 --- a/backend/src/main/java/com/imeeting/service/biz/impl/MeetingTranscriptFileServiceImpl.java +++ b/backend/src/main/java/com/imeeting/service/biz/impl/MeetingTranscriptFileServiceImpl.java @@ -87,8 +87,8 @@ public class MeetingTranscriptFileServiceImpl implements MeetingTranscriptFileSe .orderByAsc(MeetingTranscript::getStartTime) .orderByAsc(MeetingTranscript::getId)); - String title = firstNonBlank(meetingDetail != null ? meetingDetail.getTitle() : null, meeting.getTitle(), "未命名会议"); - String hostName = firstNonBlank(meetingDetail != null ? meetingDetail.getHostName() : null, meeting.getHostName(), "未知"); + String title = firstNonBlank(meetingDetail != null ? meetingDetail.getTitle() : null, meeting.getTitle(), "Untitled Meeting"); + String hostName = firstNonBlank(meetingDetail != null ? meetingDetail.getHostName() : null, meeting.getHostName(), "Unknown"); String meetingTime = formatDateTime(meetingDetail != null ? meetingDetail.getMeetingTime() : meeting.getMeetingTime()); StringBuilder builder = new StringBuilder(); @@ -104,13 +104,13 @@ public class MeetingTranscriptFileServiceImpl implements MeetingTranscriptFileSe } for (MeetingTranscript transcript : transcripts) { - String speaker = firstNonBlank(transcript.getSpeakerName(), transcript.getSpeakerId(), "未知发言人"); + String speaker = firstNonBlank(transcript.getSpeakerName(), transcript.getSpeakerId(), "Unknown Speaker"); builder.append("- "); String timeRange = buildTimeRange(transcript.getStartTime(), transcript.getEndTime()); if (!timeRange.isBlank()) { builder.append(timeRange).append(' '); } - builder.append(speaker).append(":").append(normalizeTranscriptContent(transcript.getContent())).append("\n"); + builder.append(speaker).append(": ").append(normalizeTranscriptContent(transcript.getContent())).append("\n"); } return builder.toString(); } diff --git a/backend/src/main/java/com/imeeting/service/biz/impl/MeetingTranscriptRevisionServiceImpl.java b/backend/src/main/java/com/imeeting/service/biz/impl/MeetingTranscriptRevisionServiceImpl.java new file mode 100644 index 0000000..a63cd18 --- /dev/null +++ b/backend/src/main/java/com/imeeting/service/biz/impl/MeetingTranscriptRevisionServiceImpl.java @@ -0,0 +1,833 @@ +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 transcripts = loadRawTranscripts(meeting.getId()); + String rawText = buildRawTranscriptText(transcripts); + String fingerprint = buildSourceFingerprint(transcripts); + if (transcripts.isEmpty() || rawText.isBlank()) { + return buildFallbackSource(rawText, fingerprint); + } + + Map 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 listEffectiveTranscripts(Long meetingId) { + List transcripts = loadRawTranscripts(meetingId); + MeetingTranscriptRevision current = findCurrentRevision(meetingId); + Map itemByTranscriptId = new LinkedHashMap<>(); + if (current != null) { + itemByTranscriptId = itemMapper.selectList(new LambdaQueryWrapper() + .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 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() + .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 transcripts = loadRawTranscripts(meeting.getId()); + String fingerprint = buildSourceFingerprint(transcripts); + Map 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() + .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 loadRawTranscripts(Long meetingId) { + return transcriptMapper.selectList(new LambdaQueryWrapper() + .eq(MeetingTranscript::getMeetingId, meetingId) + .orderByAsc(MeetingTranscript::getSortOrder) + .orderByAsc(MeetingTranscript::getStartTime) + .orderByAsc(MeetingTranscript::getId)); + } + + private MeetingTranscriptRevision findCurrentRevision(Long meetingId) { + return revisionMapper.selectOne(new LambdaQueryWrapper() + .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() + .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 buildRuleProfile(String fingerprint, AiModelVO asrModel) { + Map 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 resolveFillerWords(AiModelVO asrModel) { + List 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 resolveReplacementRules(AiModelVO asrModel) { + Map 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 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 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>() {}); + 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 parseCleanupReplacements(String raw) { + if (raw == null || raw.isBlank()) { + return Map.of(); + } + String normalized = raw.trim(); + try { + if (normalized.startsWith("{")) { + Map parsed = objectMapper.readValue(normalized, new TypeReference>() {}); + Map result = new TreeMap<>(); + for (Map.Entry 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 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 transcripts, Map ruleProfile) { + @SuppressWarnings("unchecked") + List fillerWords = (List) ruleProfile.getOrDefault("transcriptRuleFillerWords", List.of()); + @SuppressWarnings("unchecked") + Map replacements = (Map) ruleProfile.getOrDefault("transcriptRuleReplacements", Map.of()); + + List 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> orderedReplacementRules = replacements.entrySet().stream() + .filter(entry -> entry.getKey() != null && entry.getValue() != null) + .sorted(Comparator.>comparingInt(entry -> entry.getKey().length()).reversed() + .thenComparing(Map.Entry::getKey)) + .toList(); + + List items = new ArrayList<>(); + List 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 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 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 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 matchedFillerWords, + List matchedReplacementRules) { + if (cleaned == null || cleaned.isBlank()) { + return !matchedFillerWords.isEmpty() ? "DROP_FILLER" : "EDIT"; + } + if (Objects.equals(cleaned, normalizedSource)) { + return "KEEP"; + } + return "RULE_REPLACED"; + } + + private Map buildRuleHits(List matchedFillerWords, List matchedReplacementRules) { + Map 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 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 transcripts, int index) { + Map 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 fillerWords, + List> replacementRules) { + String cleaned = normalizeRawContent(content); + List matchedFillerWords = new ArrayList<>(); + List 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 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 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 items, + int droppedCount, + int mergedGroupCount) { + } + + private record TextCleanupResult(String cleanedContent, + List matchedFillerWords, + List 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; + } + } +} diff --git a/backend/src/test/java/com/imeeting/controller/android/legacy/LegacyMeetingControllerTest.java b/backend/src/test/java/com/imeeting/controller/android/legacy/LegacyMeetingControllerTest.java index 3db7262..31d66c4 100644 --- a/backend/src/test/java/com/imeeting/controller/android/legacy/LegacyMeetingControllerTest.java +++ b/backend/src/test/java/com/imeeting/controller/android/legacy/LegacyMeetingControllerTest.java @@ -614,4 +614,47 @@ class LegacyMeetingControllerTest { assertEquals(75, data.getProcessingStatus().getOverallProgress()); assertEquals("summary_generation", data.getProcessingStatus().getCurrentStage()); } + + @Test + void previewDataShouldTreatHundredPercentProgressAsCompletedWhenSummaryAlreadyReadable() { + MeetingService meetingService = mock(MeetingService.class); + AiTaskService aiTaskService = mock(AiTaskService.class); + MeetingQueryService meetingQueryService = mock(MeetingQueryService.class); + MeetingTranscriptMapper transcriptMapper = mock(MeetingTranscriptMapper.class); + StringRedisTemplate redisTemplate = mock(StringRedisTemplate.class); + @SuppressWarnings("unchecked") + ValueOperations valueOperations = mock(ValueOperations.class); + + Meeting meeting = new Meeting(); + meeting.setId(28L); + meeting.setTitle("completed by progress"); + meeting.setStatus(0); + when(meetingService.getById(28L)).thenReturn(meeting); + when(aiTaskService.getOne(any())).thenReturn((AiTask) null, (AiTask) null); + when(redisTemplate.opsForValue()).thenReturn(valueOperations); + when(valueOperations.get("biz:meeting:progress:28")).thenReturn("{\"percent\":100}"); + + MeetingVO detail = new MeetingVO(); + detail.setSummaryContent("done"); + when(meetingQueryService.getDetail(28L)).thenReturn(detail); + + LegacyMeetingController controller = new LegacyMeetingController( + mock(LegacyMeetingAdapterService.class), + meetingQueryService, + mock(MeetingAccessService.class), + mock(MeetingCommandService.class), + meetingService, + aiTaskService, + mock(PromptTemplateService.class), + transcriptMapper, + mock(SysUserMapper.class), + redisTemplate, + new ObjectMapper() + ); + + LegacyApiResponse response = controller.previewData(28L); + + assertEquals("200", response.getCode()); + assertNotNull(response.getData()); + } } diff --git a/backend/src/test/java/com/imeeting/service/biz/impl/AiTaskServiceImplTest.java b/backend/src/test/java/com/imeeting/service/biz/impl/AiTaskServiceImplTest.java index d77f624..667fa7d 100644 --- a/backend/src/test/java/com/imeeting/service/biz/impl/AiTaskServiceImplTest.java +++ b/backend/src/test/java/com/imeeting/service/biz/impl/AiTaskServiceImplTest.java @@ -1,6 +1,7 @@ package com.imeeting.service.biz.impl; import com.fasterxml.jackson.databind.ObjectMapper; +import com.imeeting.dto.biz.MeetingSummarySource; import com.imeeting.entity.biz.AiTask; import com.imeeting.entity.biz.Meeting; import com.imeeting.entity.biz.MeetingTranscript; @@ -10,6 +11,7 @@ import com.imeeting.service.biz.AiModelService; import com.imeeting.service.biz.HotWordService; import com.imeeting.service.biz.MeetingSummaryFileService; import com.imeeting.service.biz.MeetingTranscriptFileService; +import com.imeeting.service.biz.MeetingTranscriptRevisionService; import com.imeeting.support.TaskSecurityContextRunner; import com.unisbase.mapper.SysUserMapper; import org.junit.jupiter.api.Test; @@ -22,15 +24,15 @@ import java.util.List; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertTrue; -import static org.mockito.ArgumentMatchers.eq; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyLong; import static org.mockito.ArgumentMatchers.anyString; -import static org.mockito.Mockito.times; +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; @@ -94,7 +96,8 @@ class AiTaskServiceImplTest { aiModelService, redisTemplate, new TaskSecurityContextRunner(), - mock(MeetingTranscriptFileService.class) + mock(MeetingTranscriptFileService.class), + mock(MeetingTranscriptRevisionService.class) )); doReturn(true).when(service).updateById(any()); @@ -118,7 +121,7 @@ class AiTaskServiceImplTest { service.dispatchTasks(66L, 1L, 2L); assertEquals(3, summaryTask.getStatus()); - assertTrue(summaryTask.getErrorMsg().contains("没有可用于总结的转录内容")); + assertTrue(summaryTask.getErrorMsg() != null && !summaryTask.getErrorMsg().isBlank()); verify(aiModelService, never()).getModelById(anyLong(), anyString()); } @@ -128,9 +131,19 @@ class AiTaskServiceImplTest { MeetingTranscriptMapper transcriptMapper = mock(MeetingTranscriptMapper.class); AiModelService aiModelService = mock(AiModelService.class); StringRedisTemplate redisTemplate = mock(StringRedisTemplate.class); + MeetingTranscriptRevisionService revisionService = mock(MeetingTranscriptRevisionService.class); @SuppressWarnings("unchecked") ValueOperations valueOperations = mock(ValueOperations.class); when(redisTemplate.opsForValue()).thenReturn(valueOperations); + when(valueOperations.setIfAbsent(anyString(), anyString(), anyLong(), any())).thenReturn(true); + when(revisionService.resolveSummarySource(any(), any(), any())).thenReturn(MeetingSummarySource.builder() + .text(" ") + .sourceType("RAW_FALLBACK") + .fallbackUsed(true) + .triggerTaskType("SUMMARY") + .semanticCorrector("NONE_V1") + .ruleProfileVersion("v1") + .build()); AiTaskServiceImpl service = spy(createService( meetingMapper, @@ -138,7 +151,8 @@ class AiTaskServiceImplTest { aiModelService, redisTemplate, new TaskSecurityContextRunner(), - mock(MeetingTranscriptFileService.class) + mock(MeetingTranscriptFileService.class), + revisionService )); doReturn(true).when(service).updateById(any()); @@ -146,22 +160,17 @@ class AiTaskServiceImplTest { meeting.setId(77L); when(meetingMapper.selectById(77L)).thenReturn(meeting); - MeetingTranscript transcript = new MeetingTranscript(); - transcript.setSpeakerId("spk-1"); - transcript.setContent(" "); - when(transcriptMapper.selectList(any())).thenReturn(List.of(transcript)); - AiTask summaryTask = new AiTask(); summaryTask.setId(100L); summaryTask.setMeetingId(77L); summaryTask.setTaskType("SUMMARY"); summaryTask.setStatus(0); - doReturn(summaryTask).when(service).getOne(any()); + doReturn(summaryTask, null).when(service).getOne(any()); service.dispatchSummaryTask(77L, 1L, 2L); assertEquals(3, summaryTask.getStatus()); - assertTrue(summaryTask.getErrorMsg().contains("没有可用于总结的转录内容")); + assertTrue(summaryTask.getErrorMsg() != null && !summaryTask.getErrorMsg().isBlank()); verify(aiModelService, never()).getModelById(anyLong(), anyString()); } @@ -176,7 +185,8 @@ class AiTaskServiceImplTest { mock(AiModelService.class), mock(StringRedisTemplate.class), mock(TaskSecurityContextRunner.class), - transcriptFileService + transcriptFileService, + mock(MeetingTranscriptRevisionService.class) ); Meeting meeting = new Meeting(); @@ -190,9 +200,9 @@ class AiTaskServiceImplTest { { "segments": [ { - "speaker_id": "spk-1", + "speaker_id": "123", "speaker_name": "Alice", - "text": "第一段转录", + "text": "hello world", "timestamp": [0, 1200] } ] @@ -211,7 +221,8 @@ class AiTaskServiceImplTest { mock(AiModelService.class), mock(StringRedisTemplate.class), mock(TaskSecurityContextRunner.class), - mock(MeetingTranscriptFileService.class) + mock(MeetingTranscriptFileService.class), + mock(MeetingTranscriptRevisionService.class) ); } @@ -220,7 +231,8 @@ class AiTaskServiceImplTest { AiModelService aiModelService, StringRedisTemplate redisTemplate, TaskSecurityContextRunner taskSecurityContextRunner, - MeetingTranscriptFileService meetingTranscriptFileService) { + MeetingTranscriptFileService meetingTranscriptFileService, + MeetingTranscriptRevisionService revisionService) { return new AiTaskServiceImpl( meetingMapper, transcriptMapper, @@ -231,6 +243,7 @@ class AiTaskServiceImplTest { redisTemplate, mock(MeetingSummaryFileService.class), meetingTranscriptFileService, + revisionService, mock(MeetingSummaryPromptAssembler.class), taskSecurityContextRunner ); diff --git a/backend/src/test/java/com/imeeting/service/biz/impl/HotWordGroupServiceImplTest.java b/backend/src/test/java/com/imeeting/service/biz/impl/HotWordGroupServiceImplTest.java new file mode 100644 index 0000000..a6efa57 --- /dev/null +++ b/backend/src/test/java/com/imeeting/service/biz/impl/HotWordGroupServiceImplTest.java @@ -0,0 +1,66 @@ +package com.imeeting.service.biz.impl; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import com.imeeting.entity.biz.HotWordGroup; +import com.imeeting.mapper.biz.HotWordMapper; +import com.imeeting.mapper.biz.PromptTemplateMapper; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.doReturn; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.spy; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +class HotWordGroupServiceImplTest { + + @Test + void pageGroupsShouldApplyNameAndStatusFilters() { + HotWordMapper hotWordMapper = mock(HotWordMapper.class); + PromptTemplateMapper promptTemplateMapper = mock(PromptTemplateMapper.class); + when(hotWordMapper.selectList(any())).thenReturn(java.util.List.of()); + + HotWordGroupServiceImpl service = spy(new HotWordGroupServiceImpl(hotWordMapper, promptTemplateMapper)); + Page page = new Page<>(2, 8); + page.setRecords(java.util.List.of()); + page.setTotal(0); + doReturn(page).when(service).page(any(Page.class), any(LambdaQueryWrapper.class)); + + service.pageGroups(2, 8, "项目", 1, 9L); + + ArgumentCaptor> wrapperCaptor = ArgumentCaptor.forClass(LambdaQueryWrapper.class); + verify(service).page(any(Page.class), wrapperCaptor.capture()); + String sqlSegment = wrapperCaptor.getValue().getSqlSegment().toLowerCase(); + assertTrue(sqlSegment.contains("group_name")); + assertTrue(sqlSegment.contains("status")); + assertTrue(sqlSegment.contains("tenant")); + } + + @Test + void removeGroupByIdShouldRejectWhenPromptTemplateStillReferencesGroup() { + HotWordMapper hotWordMapper = mock(HotWordMapper.class); + PromptTemplateMapper promptTemplateMapper = mock(PromptTemplateMapper.class); + when(promptTemplateMapper.selectCount(any())).thenReturn(1L); + + HotWordGroup group = new HotWordGroup(); + group.setId(9L); + group.setTenantId(9L); + HotWordGroupServiceImpl service = new HotWordGroupServiceImpl(hotWordMapper, promptTemplateMapper) { + @Override + public HotWordGroup getById(java.io.Serializable id) { + return Long.valueOf(9L).equals(id) ? group : null; + } + }; + + IllegalArgumentException exception = assertThrows(IllegalArgumentException.class, + () -> service.removeGroupById(9L, 9L)); + + assertEquals("该热词组已被会议总结模板引用,无法删除", exception.getMessage()); + } +} diff --git a/backend/src/test/java/com/imeeting/service/biz/impl/HotWordServiceImplTest.java b/backend/src/test/java/com/imeeting/service/biz/impl/HotWordServiceImplTest.java new file mode 100644 index 0000000..72311b7 --- /dev/null +++ b/backend/src/test/java/com/imeeting/service/biz/impl/HotWordServiceImplTest.java @@ -0,0 +1,71 @@ +package com.imeeting.service.biz.impl; + +import com.imeeting.dto.biz.HotWordDTO; +import com.imeeting.dto.biz.HotWordVO; +import com.imeeting.entity.biz.HotWord; +import com.imeeting.entity.biz.HotWordGroup; +import com.imeeting.mapper.biz.HotWordGroupMapper; +import org.junit.jupiter.api.Test; + +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.doAnswer; +import static org.mockito.Mockito.doReturn; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.spy; +import static org.mockito.Mockito.when; + +class HotWordServiceImplTest { + + @Test + void saveHotWordShouldRejectWhenGroupLimitReached() { + HotWordGroupMapper hotWordGroupMapper = mock(HotWordGroupMapper.class); + HotWordServiceImpl service = spy(new HotWordServiceImpl(hotWordGroupMapper)); + doReturn(200L).when(service).count(any()); + + HotWordGroup group = new HotWordGroup(); + group.setId(5L); + group.setTenantId(9L); + group.setGroupName("客户名单"); + group.setStatus(1); + when(hotWordGroupMapper.selectById(5L)).thenReturn(group); + + HotWordDTO dto = new HotWordDTO(); + dto.setWord("阿里"); + dto.setMatchStrategy(1); + dto.setWeight(2); + dto.setStatus(1); + dto.setHotWordGroupId(5L); + + IllegalArgumentException exception = assertThrows(IllegalArgumentException.class, + () -> service.saveHotWord(dto, 7L, 9L)); + + assertEquals("热词组最多只能包含 200 个热词", exception.getMessage()); + } + @Test + void saveHotWordShouldGeneratePinyinWhenRequestDoesNotProvideIt() { + HotWordGroupMapper hotWordGroupMapper = mock(HotWordGroupMapper.class); + HotWordServiceImpl service = spy(new HotWordServiceImpl(hotWordGroupMapper)); + doAnswer(invocation -> { + HotWord entity = invocation.getArgument(0); + entity.setId(11L); + return true; + }).when(service).save(any(HotWord.class)); + + HotWordDTO dto = new HotWordDTO(); + dto.setWord("会议"); + dto.setMatchStrategy(1); + dto.setWeight(2); + dto.setStatus(1); + dto.setPinyinList(List.of()); + + HotWordVO result = service.saveHotWord(dto, 7L, 9L); + + assertFalse(result.getPinyinList().isEmpty()); + assertEquals("hui yi", result.getPinyinList().get(0)); + } +} diff --git a/backend/src/test/java/com/imeeting/service/biz/impl/MeetingAudioUploadSupportTest.java b/backend/src/test/java/com/imeeting/service/biz/impl/MeetingAudioUploadSupportTest.java new file mode 100644 index 0000000..11181b9 --- /dev/null +++ b/backend/src/test/java/com/imeeting/service/biz/impl/MeetingAudioUploadSupportTest.java @@ -0,0 +1,157 @@ +package com.imeeting.service.biz.impl; + +import com.imeeting.common.SysParamKeys; +import com.unisbase.service.SysParamService; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import org.springframework.mock.web.MockMultipartFile; +import org.springframework.test.util.ReflectionTestUtils; + +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Arrays; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +class MeetingAudioUploadSupportTest { + + @TempDir + Path tempDir; + + @Test + void shouldStoreValidatedWavInPrivateStagingDirectory() throws Exception { + MeetingAudioUploadSupport support = createSupport(null); + + MockMultipartFile file = new MockMultipartFile("file", "demo.wav", "audio/wav", buildWavHeader()); + + String token = support.storeUploadedAudio(file); + + assertTrue(MeetingAudioUploadSupport.isStagingAudioToken(token)); + Path storedPath = MeetingAudioUploadSupport.resolveStagingAudioPath(tempDir.resolve("uploads").toString(), token); + assertTrue(Files.exists(storedPath)); + assertFalse(storedPath.startsWith(tempDir.resolve("uploads"))); + } + + @Test + void shouldStoreAacM4aInPrivateStagingDirectory() throws Exception { + MeetingAudioUploadSupport support = createSupport(null); + + MockMultipartFile file = new MockMultipartFile("file", "demo.m4a", "audio/mp4", buildM4a("mp4a")); + + String token = support.storeUploadedAudio(file); + + assertTrue(MeetingAudioUploadSupport.isStagingAudioToken(token)); + Path storedPath = MeetingAudioUploadSupport.resolveStagingAudioPath(tempDir.resolve("uploads").toString(), token); + assertTrue(Files.exists(storedPath)); + } + + @Test + void shouldRejectM4aWithBrowserIncompatibleCodec() { + MeetingAudioUploadSupport support = createSupport(null); + + MockMultipartFile file = new MockMultipartFile("file", "demo.m4a", "audio/mp4", buildM4a("samr")); + + RuntimeException ex = assertThrows(RuntimeException.class, () -> support.storeUploadedAudio(file)); + assertTrue(ex.getMessage().contains("AAC")); + } + + @Test + void shouldRejectUnsupportedExtension() { + MeetingAudioUploadSupport support = createSupport(null); + + MockMultipartFile file = new MockMultipartFile( + "file", + "demo.html", + "text/html", + "alert(1)".getBytes(StandardCharsets.UTF_8) + ); + + assertThrows(RuntimeException.class, () -> support.storeUploadedAudio(file)); + } + + @Test + void shouldRejectFakeMp3Payload() { + MeetingAudioUploadSupport support = createSupport(null); + + MockMultipartFile file = new MockMultipartFile( + "file", + "fake.mp3", + "audio/mpeg", + "".getBytes(StandardCharsets.UTF_8) + ); + + assertThrows(RuntimeException.class, () -> support.storeUploadedAudio(file)); + } + + @Test + void shouldRejectAudioLargerThanConfiguredSystemParamLimit() { + MeetingAudioUploadSupport support = createSupport("1"); + byte[] payload = new byte[2 * 1024 * 1024]; + byte[] header = buildWavHeader(); + System.arraycopy(header, 0, payload, 0, header.length); + MockMultipartFile file = new MockMultipartFile("file", "large.wav", "audio/wav", payload); + + assertThrows(RuntimeException.class, () -> support.storeUploadedAudio(file)); + } + + private MeetingAudioUploadSupport createSupport(String maxSizeMb) { + SysParamService sysParamService = mock(SysParamService.class); + when(sysParamService.getCachedParamValue( + eq(SysParamKeys.MEETING_OFFLINE_AUDIO_MAX_SIZE_MB), + eq("1024") + )).thenReturn(maxSizeMb); + MeetingAudioUploadSupport support = new MeetingAudioUploadSupport(sysParamService); + ReflectionTestUtils.setField(support, "uploadPath", tempDir.resolve("uploads").toString()); + return support; + } + + private byte[] buildWavHeader() { + byte[] header = new byte[16]; + System.arraycopy("RIFF".getBytes(StandardCharsets.US_ASCII), 0, header, 0, 4); + System.arraycopy("WAVE".getBytes(StandardCharsets.US_ASCII), 0, header, 8, 4); + return header; + } + + private byte[] buildM4a(String sampleEntryType) { + byte[] sampleEntry = atom(sampleEntryType, new byte[8]); + byte[] stsdPayload = concat(new byte[4], intBytes(1), sampleEntry); + byte[] stsd = atom("stsd", stsdPayload); + byte[] stbl = atom("stbl", stsd); + byte[] minf = atom("minf", stbl); + byte[] mdia = atom("mdia", minf); + byte[] trak = atom("trak", mdia); + byte[] moov = atom("moov", trak); + byte[] ftyp = atom("ftyp", concat("M4A ".getBytes(StandardCharsets.US_ASCII), new byte[8])); + return concat(ftyp, moov); + } + + private byte[] atom(String type, byte[] payload) { + return concat(intBytes(payload.length + 8), type.getBytes(StandardCharsets.US_ASCII), payload); + } + + private byte[] intBytes(int value) { + return new byte[]{ + (byte) ((value >> 24) & 0xFF), + (byte) ((value >> 16) & 0xFF), + (byte) ((value >> 8) & 0xFF), + (byte) (value & 0xFF) + }; + } + + private byte[] concat(byte[]... parts) { + int totalLength = Arrays.stream(parts).mapToInt(part -> part.length).sum(); + byte[] result = new byte[totalLength]; + int offset = 0; + for (byte[] part : parts) { + System.arraycopy(part, 0, result, offset, part.length); + offset += part.length; + } + return result; + } +} diff --git a/backend/src/test/java/com/imeeting/service/biz/impl/MeetingPlaybackAudioResolverTest.java b/backend/src/test/java/com/imeeting/service/biz/impl/MeetingPlaybackAudioResolverTest.java new file mode 100644 index 0000000..a62c3df --- /dev/null +++ b/backend/src/test/java/com/imeeting/service/biz/impl/MeetingPlaybackAudioResolverTest.java @@ -0,0 +1,229 @@ +package com.imeeting.service.biz.impl; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import org.springframework.test.util.ReflectionTestUtils; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.attribute.FileTime; +import java.util.Arrays; + +import static org.junit.jupiter.api.Assertions.assertArrayEquals; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class MeetingPlaybackAudioResolverTest { + + @TempDir + Path tempDir; + + @Test + void shouldGenerateBrowserPlaybackWavFor16000Source() throws Exception { + MeetingPlaybackAudioResolver resolver = newResolver(); + Path sourcePath = tempDir.resolve("uploads/meetings/101/source_audio.wav"); + byte[] sourceFrames = new byte[]{1, 2, 3, 4}; + Files.createDirectories(sourcePath.getParent()); + Files.write(sourcePath, buildWav(16_000, sourceFrames)); + + String playbackUrl = resolver.resolveBrowserPlaybackAudioUrl("/api/static/meetings/101/source_audio.wav"); + + assertEquals("/api/static/meetings/101/source_audio_browser_48000.wav", playbackUrl); + Path convertedPath = tempDir.resolve("uploads/meetings/101/source_audio_browser_48000.wav"); + assertTrue(Files.exists(convertedPath)); + + byte[] converted = Files.readAllBytes(convertedPath); + assertEquals(48_000, readIntLe(converted, 24)); + assertEquals(12, readIntLe(converted, 40)); + assertArrayEquals( + new byte[]{1, 2, 1, 2, 1, 2, 3, 4, 3, 4, 3, 4}, + Arrays.copyOfRange(converted, 44, 56) + ); + } + + @Test + void shouldReuseExistingBrowserPlaybackWavFile() throws Exception { + MeetingPlaybackAudioResolver resolver = newResolver(); + Path meetingDir = tempDir.resolve("uploads/meetings/102"); + Path sourcePath = meetingDir.resolve("source_audio.wav"); + Path convertedPath = meetingDir.resolve("source_audio_browser_48000.wav"); + Files.createDirectories(meetingDir); + Files.write(sourcePath, buildWav(16_000, new byte[]{10, 20, 30, 40})); + Files.write(convertedPath, buildWav(48_000, new byte[]{9, 9, 8, 8, 7, 7})); + Files.setLastModifiedTime(sourcePath, FileTime.fromMillis(1_000)); + Files.setLastModifiedTime(convertedPath, FileTime.fromMillis(2_000)); + + String playbackUrl = resolver.resolveBrowserPlaybackAudioUrl("/api/static/meetings/102/source_audio.wav"); + + assertEquals("/api/static/meetings/102/source_audio_browser_48000.wav", playbackUrl); + byte[] converted = Files.readAllBytes(convertedPath); + assertEquals(48_000, readIntLe(converted, 24)); + assertArrayEquals(new byte[]{9, 9, 8, 8, 7, 7}, Arrays.copyOfRange(converted, 44, 50)); + } + + @Test + void shouldKeep44100WavSourceForBrowserPlayback() throws Exception { + MeetingPlaybackAudioResolver resolver = newResolver(); + Path sourcePath = tempDir.resolve("uploads/meetings/103/source_audio.wav"); + Files.createDirectories(sourcePath.getParent()); + Files.write(sourcePath, buildWav(44_100, new byte[]{1, 1, 2, 2})); + + String playbackUrl = resolver.resolveBrowserPlaybackAudioUrl("/api/static/meetings/103/source_audio.wav"); + + assertEquals("/api/static/meetings/103/source_audio.wav", playbackUrl); + assertFalse(Files.exists(tempDir.resolve("uploads/meetings/103/source_audio_browser_48000.wav"))); + } + + @Test + void shouldKeep44100M4aSourceForBrowserPlayback() throws Exception { + MeetingPlaybackAudioResolver resolver = newResolver(); + Path sourcePath = tempDir.resolve("uploads/meetings/104/source_audio.m4a"); + Files.createDirectories(sourcePath.getParent()); + Files.write(sourcePath, buildM4a("mp4a", 44_100)); + + String playbackUrl = resolver.resolveBrowserPlaybackAudioUrl("/api/static/meetings/104/source_audio.m4a"); + + assertEquals("/api/static/meetings/104/source_audio.m4a", playbackUrl); + assertFalse(Files.exists(tempDir.resolve("uploads/meetings/104/source_audio_browser_48000.m4a"))); + } + + @Test + void shouldReuseExistingBrowserPlaybackM4aFile() throws Exception { + MeetingPlaybackAudioResolver resolver = newResolver(); + Path meetingDir = tempDir.resolve("uploads/meetings/105"); + Path sourcePath = meetingDir.resolve("source_audio.m4a"); + Path convertedPath = meetingDir.resolve("source_audio_browser_48000.m4a"); + Files.createDirectories(meetingDir); + Files.write(sourcePath, buildM4a("mp4a", 16_000)); + Files.write(convertedPath, buildM4a("mp4a", 48_000)); + Files.setLastModifiedTime(sourcePath, FileTime.fromMillis(1_000)); + Files.setLastModifiedTime(convertedPath, FileTime.fromMillis(2_000)); + + String playbackUrl = resolver.resolveBrowserPlaybackAudioUrl("/api/static/meetings/105/source_audio.m4a"); + + assertEquals("/api/static/meetings/105/source_audio_browser_48000.m4a", playbackUrl); + assertTrue(Files.exists(convertedPath)); + } + + @Test + void shouldFallbackWhenM4aNeedsConversionButFfmpegIsUnavailable() throws Exception { + MeetingPlaybackAudioResolver resolver = newResolver(); + Path sourcePath = tempDir.resolve("uploads/meetings/106/source_audio.m4a"); + Files.createDirectories(sourcePath.getParent()); + Files.write(sourcePath, buildM4a("mp4a", 16_000)); + + String playbackUrl = resolver.resolveBrowserPlaybackAudioUrl("/api/static/meetings/106/source_audio.m4a"); + + assertEquals("/api/static/meetings/106/source_audio.m4a", playbackUrl); + assertFalse(Files.exists(tempDir.resolve("uploads/meetings/106/source_audio_browser_48000.m4a"))); + } + + @Test + void shouldKeepOriginalExtensionForTemporaryM4aOutput() { + MeetingPlaybackAudioResolver resolver = newResolver(); + Path targetPath = tempDir.resolve("uploads/meetings/107/source_audio_browser_48000.m4a"); + + Path tempPath = ReflectionTestUtils.invokeMethod(resolver, "buildTemporaryOutputPath", targetPath); + + assertEquals("source_audio_browser_48000.tmp.m4a", tempPath.getFileName().toString()); + } + + private MeetingPlaybackAudioResolver newResolver() { + MeetingPlaybackAudioResolver resolver = new MeetingPlaybackAudioResolver(); + ReflectionTestUtils.setField(resolver, "uploadPath", tempDir.resolve("uploads").toString()); + ReflectionTestUtils.setField(resolver, "resourcePrefix", "/api/static/"); + ReflectionTestUtils.setField(resolver, "ffmpegPath", "ffmpeg"); + return resolver; + } + + private byte[] buildWav(int sampleRate, byte[] data) throws IOException { + ByteArrayOutputStream output = new ByteArrayOutputStream(); + int channels = 1; + int bitsPerSample = 16; + int blockAlign = channels * bitsPerSample / 8; + long byteRate = (long) sampleRate * blockAlign; + + output.write(new byte[]{'R', 'I', 'F', 'F'}); + writeIntLe(output, 36 + data.length); + output.write(new byte[]{'W', 'A', 'V', 'E'}); + output.write(new byte[]{'f', 'm', 't', ' '}); + writeIntLe(output, 16); + writeShortLe(output, 1); + writeShortLe(output, channels); + writeIntLe(output, sampleRate); + writeIntLe(output, byteRate); + writeShortLe(output, blockAlign); + writeShortLe(output, bitsPerSample); + output.write(new byte[]{'d', 'a', 't', 'a'}); + writeIntLe(output, data.length); + output.write(data); + return output.toByteArray(); + } + + private byte[] buildM4a(String sampleEntryType, int sampleRate) { + byte[] sampleEntryPayload = new byte[28]; + int fixedPointSampleRate = sampleRate << 16; + sampleEntryPayload[24] = (byte) ((fixedPointSampleRate >> 24) & 0xFF); + sampleEntryPayload[25] = (byte) ((fixedPointSampleRate >> 16) & 0xFF); + sampleEntryPayload[26] = (byte) ((fixedPointSampleRate >> 8) & 0xFF); + sampleEntryPayload[27] = (byte) (fixedPointSampleRate & 0xFF); + + byte[] sampleEntry = atom(sampleEntryType, sampleEntryPayload); + byte[] stsdPayload = concat(new byte[4], intBytes(1), sampleEntry); + byte[] stsd = atom("stsd", stsdPayload); + byte[] stbl = atom("stbl", stsd); + byte[] minf = atom("minf", stbl); + byte[] mdia = atom("mdia", minf); + byte[] trak = atom("trak", mdia); + byte[] moov = atom("moov", trak); + byte[] ftyp = atom("ftyp", concat("M4A ".getBytes(StandardCharsets.US_ASCII), new byte[8])); + return concat(ftyp, moov); + } + + private byte[] atom(String type, byte[] payload) { + return concat(intBytes(payload.length + 8), type.getBytes(StandardCharsets.US_ASCII), payload); + } + + private byte[] intBytes(int value) { + return new byte[]{ + (byte) ((value >> 24) & 0xFF), + (byte) ((value >> 16) & 0xFF), + (byte) ((value >> 8) & 0xFF), + (byte) (value & 0xFF) + }; + } + + private byte[] concat(byte[]... parts) { + int totalLength = Arrays.stream(parts).mapToInt(part -> part.length).sum(); + byte[] result = new byte[totalLength]; + int offset = 0; + for (byte[] part : parts) { + System.arraycopy(part, 0, result, offset, part.length); + offset += part.length; + } + return result; + } + + private void writeShortLe(ByteArrayOutputStream output, int value) { + output.write(value & 0xff); + output.write((value >> 8) & 0xff); + } + + private void writeIntLe(ByteArrayOutputStream output, long value) { + output.write((int) (value & 0xff)); + output.write((int) ((value >> 8) & 0xff)); + output.write((int) ((value >> 16) & 0xff)); + output.write((int) ((value >> 24) & 0xff)); + } + + private int readIntLe(byte[] bytes, int offset) { + return (bytes[offset] & 0xff) + | ((bytes[offset + 1] & 0xff) << 8) + | ((bytes[offset + 2] & 0xff) << 16) + | ((bytes[offset + 3] & 0xff) << 24); + } +} diff --git a/backend/src/test/java/com/imeeting/service/biz/impl/MeetingTranscriptFileServiceImplTest.java b/backend/src/test/java/com/imeeting/service/biz/impl/MeetingTranscriptFileServiceImplTest.java new file mode 100644 index 0000000..d490cfb --- /dev/null +++ b/backend/src/test/java/com/imeeting/service/biz/impl/MeetingTranscriptFileServiceImplTest.java @@ -0,0 +1,106 @@ +package com.imeeting.service.biz.impl; + +import com.imeeting.dto.biz.MeetingTranscriptExportResult; +import com.imeeting.dto.biz.MeetingVO; +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 org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import org.springframework.test.util.ReflectionTestUtils; + +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.time.LocalDateTime; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +class MeetingTranscriptFileServiceImplTest { + + @TempDir + Path tempDir; + + @Test + void initializeTranscriptFileIfAbsentShouldCreateMarkdownFile() throws Exception { + MeetingMapper meetingMapper = mock(MeetingMapper.class); + MeetingTranscriptMapper transcriptMapper = mock(MeetingTranscriptMapper.class); + MeetingTranscriptFileServiceImpl service = newService(meetingMapper, transcriptMapper); + + Meeting meeting = new Meeting(); + meeting.setId(1001L); + meeting.setTitle("Weekly Sync"); + meeting.setHostName("Alice"); + meeting.setMeetingTime(LocalDateTime.of(2026, 4, 27, 10, 0, 0)); + + MeetingTranscript transcript = new MeetingTranscript(); + transcript.setSpeakerName("Bob"); + transcript.setContent("Confirm this week's release plan"); + transcript.setStartTime(0); + transcript.setEndTime(5000); + + when(meetingMapper.selectById(1001L)).thenReturn(meeting); + when(transcriptMapper.selectList(any())).thenReturn(List.of(transcript)); + + service.initializeTranscriptFileIfAbsent(1001L); + + Path transcriptPath = tempDir.resolve("uploads/meetings/1001/transcripts/current.md"); + assertTrue(Files.exists(transcriptPath)); + String markdown = Files.readString(transcriptPath, StandardCharsets.UTF_8); + assertTrue(markdown.contains("# Weekly Sync Transcript")); + assertTrue(markdown.contains("Bob: Confirm this week's release plan")); + verify(meetingMapper, times(1)).selectById(1001L); + } + + @Test + void exportTranscriptShouldRewriteFileWithLatestTranscriptContent() throws Exception { + MeetingMapper meetingMapper = mock(MeetingMapper.class); + MeetingTranscriptMapper transcriptMapper = mock(MeetingTranscriptMapper.class); + MeetingTranscriptFileServiceImpl service = newService(meetingMapper, transcriptMapper); + + Meeting meeting = new Meeting(); + meeting.setId(1002L); + meeting.setTitle("Architecture Review"); + meeting.setHostName("Carol"); + meeting.setMeetingTime(LocalDateTime.of(2026, 4, 27, 14, 30, 0)); + + MeetingVO meetingVO = new MeetingVO(); + meetingVO.setTitle("Architecture Review"); + meetingVO.setHostName("Carol"); + meetingVO.setMeetingTime(LocalDateTime.of(2026, 4, 27, 14, 30, 0)); + + MeetingTranscript transcript = new MeetingTranscript(); + transcript.setSpeakerName("Dave"); + transcript.setContent("Ship download support first"); + transcript.setStartTime(1000); + transcript.setEndTime(4000); + + when(transcriptMapper.selectList(any())).thenReturn(List.of(transcript)); + + Path transcriptPath = tempDir.resolve("uploads/meetings/1002/transcripts/current.md"); + Files.createDirectories(transcriptPath.getParent()); + Files.writeString(transcriptPath, "old content", StandardCharsets.UTF_8); + + MeetingTranscriptExportResult result = service.exportTranscript(meeting, meetingVO); + + String markdown = Files.readString(transcriptPath, StandardCharsets.UTF_8); + assertTrue(markdown.contains("Dave: Ship download support first")); + assertEquals("text/markdown; charset=UTF-8", result.getContentType()); + assertEquals("Architecture Review-Transcript.md", result.getFileName()); + assertTrue(new String(result.getContent(), StandardCharsets.UTF_8).contains("Ship download support first")); + } + + private MeetingTranscriptFileServiceImpl newService(MeetingMapper meetingMapper, MeetingTranscriptMapper transcriptMapper) { + MeetingTranscriptFileServiceImpl service = new MeetingTranscriptFileServiceImpl(meetingMapper, transcriptMapper); + ReflectionTestUtils.setField(service, "uploadPath", tempDir.resolve("uploads").toString()); + return service; + } +} diff --git a/backend/src/test/java/com/imeeting/service/biz/impl/PromptTemplateServiceImplTest.java b/backend/src/test/java/com/imeeting/service/biz/impl/PromptTemplateServiceImplTest.java new file mode 100644 index 0000000..f6c10d9 --- /dev/null +++ b/backend/src/test/java/com/imeeting/service/biz/impl/PromptTemplateServiceImplTest.java @@ -0,0 +1,217 @@ +package com.imeeting.service.biz.impl; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import com.imeeting.dto.biz.PromptTemplateDTO; +import com.imeeting.dto.biz.PromptTemplateVO; +import com.imeeting.entity.biz.HotWord; +import com.imeeting.entity.biz.HotWordGroup; +import com.imeeting.entity.biz.PromptTemplate; +import com.imeeting.mapper.biz.HotWordGroupMapper; +import com.imeeting.mapper.biz.PromptTemplateUserConfigMapper; +import com.imeeting.service.biz.HotWordService; +import org.junit.jupiter.api.Test; + +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.doReturn; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.spy; +import static org.mockito.Mockito.when; + +class PromptTemplateServiceImplTest { + + @Test + void saveTemplateShouldRejectPlatformTemplateBindingTenantGroup() { + PromptTemplateUserConfigMapper userConfigMapper = mock(PromptTemplateUserConfigMapper.class); + HotWordGroupMapper hotWordGroupMapper = mock(HotWordGroupMapper.class); + HotWordService hotWordService = mock(HotWordService.class); + PromptTemplateServiceImpl service = spy(new PromptTemplateServiceImpl(userConfigMapper, hotWordGroupMapper, hotWordService)); + + HotWordGroup tenantGroup = new HotWordGroup(); + tenantGroup.setId(11L); + tenantGroup.setTenantId(9L); + tenantGroup.setGroupName("租户组"); + tenantGroup.setStatus(1); + when(hotWordGroupMapper.selectById(11L)).thenReturn(tenantGroup); + + PromptTemplateDTO dto = new PromptTemplateDTO(); + dto.setTenantId(0L); + dto.setTemplateName("平台模板"); + dto.setCategory("default"); + dto.setIsSystem(1); + dto.setPromptContent("content"); + dto.setStatus(1); + dto.setHotWordGroupId(11L); + + IllegalArgumentException exception = assertThrows(IllegalArgumentException.class, + () -> service.saveTemplate(dto, 7L, 9L)); + + assertEquals("平台级模板只能绑定平台级热词组", exception.getMessage()); + } + + @Test + void saveTemplateShouldReturnBoundGroupInfo() { + PromptTemplateUserConfigMapper userConfigMapper = mock(PromptTemplateUserConfigMapper.class); + HotWordGroupMapper hotWordGroupMapper = mock(HotWordGroupMapper.class); + HotWordService hotWordService = mock(HotWordService.class); + PromptTemplateServiceImpl service = spy(new PromptTemplateServiceImpl(userConfigMapper, hotWordGroupMapper, hotWordService)); + doReturn(true).when(service).save(any(PromptTemplate.class)); + + HotWordGroup group = new HotWordGroup(); + group.setId(11L); + group.setTenantId(9L); + group.setGroupName("项目术语"); + group.setStatus(1); + when(hotWordGroupMapper.selectById(11L)).thenReturn(group); + when(hotWordGroupMapper.selectByIdsIgnoreTenant(any())).thenReturn(java.util.List.of(group)); + + PromptTemplateDTO dto = new PromptTemplateDTO(); + dto.setTemplateName("租户模板"); + dto.setCategory("default"); + dto.setIsSystem(0); + dto.setPromptContent("content"); + dto.setStatus(1); + dto.setHotWordGroupId(11L); + + PromptTemplateVO result = service.saveTemplate(dto, 7L, 9L); + + assertEquals(11L, result.getHotWordGroupId()); + assertEquals("项目术语", result.getHotWordGroupName()); + } + + @Test + void saveTemplateShouldAllowPlatformTemplateBindingPlatformGroup() { + PromptTemplateUserConfigMapper userConfigMapper = mock(PromptTemplateUserConfigMapper.class); + HotWordGroupMapper hotWordGroupMapper = mock(HotWordGroupMapper.class); + HotWordService hotWordService = mock(HotWordService.class); + PromptTemplateServiceImpl service = spy(new PromptTemplateServiceImpl(userConfigMapper, hotWordGroupMapper, hotWordService)); + doReturn(true).when(service).save(any(PromptTemplate.class)); + + HotWordGroup group = new HotWordGroup(); + group.setId(11L); + group.setTenantId(0L); + group.setGroupName("平台术语"); + group.setStatus(1); + when(hotWordGroupMapper.selectById(11L)).thenReturn(group); + when(hotWordGroupMapper.selectByIdsIgnoreTenant(any())).thenReturn(java.util.List.of(group)); + + PromptTemplateDTO dto = new PromptTemplateDTO(); + dto.setTenantId(0L); + dto.setTemplateName("平台模板"); + dto.setCategory("default"); + dto.setIsSystem(1); + dto.setPromptContent("content"); + dto.setStatus(1); + dto.setHotWordGroupId(11L); + + PromptTemplateVO result = service.saveTemplate(dto, 7L, 9L); + + assertEquals(11L, result.getHotWordGroupId()); + assertEquals("平台术语", result.getHotWordGroupName()); + } + + @Test + void pageTemplatesShouldHandleTemplateWithoutBoundGroup() { + PromptTemplateUserConfigMapper userConfigMapper = mock(PromptTemplateUserConfigMapper.class); + HotWordGroupMapper hotWordGroupMapper = mock(HotWordGroupMapper.class); + HotWordService hotWordService = mock(HotWordService.class); + PromptTemplateServiceImpl service = spy(new PromptTemplateServiceImpl(userConfigMapper, hotWordGroupMapper, hotWordService)); + + PromptTemplate template = new PromptTemplate(); + template.setId(21L); + template.setTenantId(9L); + template.setCreatorId(7L); + template.setTemplateName("未绑定模板"); + template.setCategory("default"); + template.setIsSystem(0); + template.setPromptContent("content"); + template.setStatus(1); + template.setHotWordGroupId(null); + + Page page = new Page<>(1, 10); + page.setRecords(List.of(template)); + page.setTotal(1); + + doReturn(page).when(service).page(any(Page.class), any(LambdaQueryWrapper.class)); + when(userConfigMapper.selectList(any())).thenReturn(List.of()); + + PromptTemplateVO result = service.pageTemplates(1, 10, null, null, 9L, 7L, false, false) + .getRecords() + .get(0); + + assertEquals(21L, result.getId()); + assertNull(result.getHotWordGroupId()); + assertNull(result.getHotWordGroupName()); + } + + @Test + void getTemplateDetailShouldReturnBoundHotWords() { + PromptTemplateUserConfigMapper userConfigMapper = mock(PromptTemplateUserConfigMapper.class); + HotWordGroupMapper hotWordGroupMapper = mock(HotWordGroupMapper.class); + HotWordService hotWordService = mock(HotWordService.class); + PromptTemplateServiceImpl service = spy(new PromptTemplateServiceImpl(userConfigMapper, hotWordGroupMapper, hotWordService)); + + PromptTemplate template = new PromptTemplate(); + template.setId(31L); + template.setTenantId(0L); + template.setCreatorId(1L); + template.setTemplateName("平台模板"); + template.setCategory("default"); + template.setIsSystem(1); + template.setPromptContent("content"); + template.setStatus(1); + template.setHotWordGroupId(11L); + + HotWordGroup group = new HotWordGroup(); + group.setId(11L); + group.setTenantId(0L); + group.setGroupName("平台热词组"); + + HotWord word1 = new HotWord(); + word1.setWord("OpenAI"); + HotWord word2 = new HotWord(); + word2.setWord("Codex"); + + doReturn(template).when(service).getOne(any(LambdaQueryWrapper.class)); + when(hotWordGroupMapper.selectByIdsIgnoreTenant(any())).thenReturn(List.of(group)); + when(hotWordService.listEnabledByGroupIdIgnoreTenant(11L)).thenReturn(List.of(word1, word2)); + + PromptTemplateVO result = service.getTemplateDetail(31L, 9L, 7L, false, false); + + assertEquals("平台热词组", result.getHotWordGroupName()); + assertEquals(List.of("OpenAI", "Codex"), result.getHotWords()); + } + + @Test + void getTemplateDetailShouldHandleTemplateWithoutBoundGroup() { + PromptTemplateUserConfigMapper userConfigMapper = mock(PromptTemplateUserConfigMapper.class); + HotWordGroupMapper hotWordGroupMapper = mock(HotWordGroupMapper.class); + HotWordService hotWordService = mock(HotWordService.class); + PromptTemplateServiceImpl service = spy(new PromptTemplateServiceImpl(userConfigMapper, hotWordGroupMapper, hotWordService)); + + PromptTemplate template = new PromptTemplate(); + template.setId(32L); + template.setTenantId(9L); + template.setCreatorId(7L); + template.setTemplateName("detail-without-group"); + template.setCategory("default"); + template.setIsSystem(0); + template.setPromptContent("content"); + template.setStatus(1); + template.setHotWordGroupId(null); + + doReturn(template).when(service).getOne(any(LambdaQueryWrapper.class)); + + PromptTemplateVO result = service.getTemplateDetail(32L, 9L, 7L, false, false); + + assertEquals(32L, result.getId()); + assertNull(result.getHotWordGroupId()); + assertNull(result.getHotWordGroupName()); + assertEquals(List.of(), result.getHotWords()); + } +}