feat: 添加会议转录文件服务

- 新增 `MeetingTranscriptFileServiceImpl` 实现会议转录文件的初始化和导出功能
- 定义 `MeetingTranscriptExportResult` 数据传输对象,用于封装导出结果
- 定义 `MeetingTranscriptFileService` 接口,提供初始化和导出会议转录文件的方法
dev_na
chenhao 2026-04-30 09:18:11 +08:00
parent a8b93a46f8
commit 4904526e09
23 changed files with 2095 additions and 67 deletions

View File

@ -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;
}

View File

@ -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";
}

View File

@ -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",
"会议正在处理中",

View File

@ -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))
);
}

View File

@ -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<String, Object> toSnapshot() {
Map<String, Object> 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;
}
}

View File

@ -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;
}

View File

@ -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;
}

View File

@ -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) {

View File

@ -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<MeetingTranscriptRevisionItem> {
}

View File

@ -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<MeetingTranscriptRevision> {
}

View File

@ -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<MeetingTranscriptVO> listEffectiveTranscripts(Long meetingId);
boolean updateCurrentRevisionContent(Long meetingId, Long operatorId, String content);
void invalidateCurrentRevision(Long meetingId);
}

View File

@ -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<AiTaskMapper, AiTask> 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<AiTaskMapper, AiTask> impleme
.last("limit 1"));
String asrText = "";
if (asrTask != null && asrTask.getStatus() == 0) {
if (asrTask != null && canExecuteTask(asrTask)) {
asrText = processAsrTask(meeting, asrTask);
} else {
List<MeetingTranscript> transcripts = transcriptMapper.selectList(new LambdaQueryWrapper<MeetingTranscript>()
@ -129,13 +132,11 @@ public class AiTaskServiceImpl extends ServiceImpl<AiTaskMapper, AiTask> 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<AiTaskMapper, AiTask> impleme
private void doDispatchSummaryTask(Long meetingId) {
Meeting meeting = meetingMapper.selectById(meetingId);
if (meeting == null) return;
AiTask sumTask = this.getOne(new LambdaQueryWrapper<AiTask>()
.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<MeetingTranscript> transcripts = transcriptMapper.selectList(new LambdaQueryWrapper<MeetingTranscript>()
.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<AiTaskMapper, AiTask> 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<String, Object> req = buildAsrRequest(meeting, taskRecord, asrModel);
taskRecord.setRequestData(req);
this.updateById(taskRecord);
if (taskId == null || taskId.isBlank()) {
updateProgress(meeting.getId(), 5, "正在提交识别请求...", 0);
Map<String, Object> 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<AiTaskMapper, AiTask> 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<String, Object> 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<AiTaskMapper, AiTask> impleme
taskRecord.setResultFilePath("meetings/" + meeting.getId() + "/summaries/" + fileName);
Map<String, Object> 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<AiTaskMapper, AiTask> 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<AiTask>()
.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<String, Object> progress = new HashMap<>();

View File

@ -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<MeetingTranscript>()
.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<MeetingTranscript>()
.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) {

View File

@ -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();
}

View File

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

View File

@ -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<String, String> 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());
}
}

View File

@ -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<String, String> 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
);

View File

@ -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<HotWordGroup> 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<LambdaQueryWrapper<HotWordGroup>> 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());
}
}

View File

@ -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));
}
}

View File

@ -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",
"<html>alert(1)</html>".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",
"<script>alert(1)</script>".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;
}
}

View File

@ -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);
}
}

View File

@ -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;
}
}

View File

@ -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<PromptTemplate> 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());
}
}