From a8b93a46f88f3f5ad9e8342efcedbdfc518b269c Mon Sep 17 00:00:00 2001 From: chenhao Date: Tue, 28 Apr 2026 15:51:39 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=B7=BB=E5=8A=A0=E4=BC=9A=E8=AE=AE?= =?UTF-8?q?=E8=BD=AC=E5=BD=95=E6=96=87=E4=BB=B6=E6=9C=8D=E5=8A=A1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 `MeetingTranscriptFileServiceImpl` 实现会议转录文件的初始化和导出功能 - 定义 `MeetingTranscriptExportResult` 数据传输对象,用于封装导出结果 - 定义 `MeetingTranscriptFileService` 接口,提供初始化和导出会议转录文件的方法 --- .../biz/MeetingTranscriptExportResult.java | 12 ++ .../biz/MeetingTranscriptFileService.java | 11 ++ .../MeetingTranscriptFileServiceImpl.java | 180 ++++++++++++++++++ 3 files changed, 203 insertions(+) create mode 100644 backend/src/main/java/com/imeeting/dto/biz/MeetingTranscriptExportResult.java create mode 100644 backend/src/main/java/com/imeeting/service/biz/MeetingTranscriptFileService.java create mode 100644 backend/src/main/java/com/imeeting/service/biz/impl/MeetingTranscriptFileServiceImpl.java diff --git a/backend/src/main/java/com/imeeting/dto/biz/MeetingTranscriptExportResult.java b/backend/src/main/java/com/imeeting/dto/biz/MeetingTranscriptExportResult.java new file mode 100644 index 0000000..602c56e --- /dev/null +++ b/backend/src/main/java/com/imeeting/dto/biz/MeetingTranscriptExportResult.java @@ -0,0 +1,12 @@ +package com.imeeting.dto.biz; + +import lombok.AllArgsConstructor; +import lombok.Data; + +@Data +@AllArgsConstructor +public class MeetingTranscriptExportResult { + private byte[] content; + private String contentType; + private String fileName; +} diff --git a/backend/src/main/java/com/imeeting/service/biz/MeetingTranscriptFileService.java b/backend/src/main/java/com/imeeting/service/biz/MeetingTranscriptFileService.java new file mode 100644 index 0000000..5915ccb --- /dev/null +++ b/backend/src/main/java/com/imeeting/service/biz/MeetingTranscriptFileService.java @@ -0,0 +1,11 @@ +package com.imeeting.service.biz; + +import com.imeeting.dto.biz.MeetingTranscriptExportResult; +import com.imeeting.dto.biz.MeetingVO; +import com.imeeting.entity.biz.Meeting; + +public interface MeetingTranscriptFileService { + void initializeTranscriptFileIfAbsent(Long meetingId); + + MeetingTranscriptExportResult exportTranscript(Meeting meeting, MeetingVO meetingDetail); +} 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 new file mode 100644 index 0000000..a653594 --- /dev/null +++ b/backend/src/main/java/com/imeeting/service/biz/impl/MeetingTranscriptFileServiceImpl.java @@ -0,0 +1,180 @@ +package com.imeeting.service.biz.impl; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +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 com.imeeting.service.biz.MeetingTranscriptFileService; +import lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; +import java.util.List; + +@Service +@RequiredArgsConstructor +public class MeetingTranscriptFileServiceImpl implements MeetingTranscriptFileService { + + private static final String TRANSCRIPT_RELATIVE_PATH_TEMPLATE = "meetings/%s/transcripts/current.md"; + private static final String CONTENT_TYPE = "text/markdown; charset=UTF-8"; + private static final DateTimeFormatter DATE_TIME_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"); + + private final MeetingMapper meetingMapper; + private final MeetingTranscriptMapper meetingTranscriptMapper; + + @Value("${unisbase.app.upload-path}") + private String uploadPath; + + @Override + public void initializeTranscriptFileIfAbsent(Long meetingId) { + if (meetingId == null) { + return; + } + Path transcriptPath = buildTranscriptPath(meetingId); + if (Files.exists(transcriptPath)) { + return; + } + + Meeting meeting = meetingMapper.selectById(meetingId); + if (meeting == null) { + return; + } + writeTranscriptFile(meeting, null); + } + + @Override + public MeetingTranscriptExportResult exportTranscript(Meeting meeting, MeetingVO meetingDetail) { + if (meeting == null || meeting.getId() == null) { + throw new RuntimeException("会议不存在"); + } + byte[] content = writeTranscriptFile(meeting, meetingDetail); + String safeTitle = sanitizeFileName( + meetingDetail != null ? meetingDetail.getTitle() : meeting.getTitle(), + "meeting-transcript-" + meeting.getId() + ); + return new MeetingTranscriptExportResult(content, CONTENT_TYPE, safeTitle + "-Transcript.md"); + } + + private byte[] writeTranscriptFile(Meeting meeting, MeetingVO meetingDetail) { + try { + Path transcriptPath = buildTranscriptPath(meeting.getId()); + Path parent = transcriptPath.getParent(); + if (parent != null) { + Files.createDirectories(parent); + } + String markdown = buildTranscriptMarkdown(meeting, meetingDetail); + Files.writeString(transcriptPath, markdown, StandardCharsets.UTF_8); + return markdown.getBytes(StandardCharsets.UTF_8); + } catch (IOException ex) { + throw new RuntimeException("写入会议转录文件失败", ex); + } + } + + private String buildTranscriptMarkdown(Meeting meeting, MeetingVO meetingDetail) { + List transcripts = meetingTranscriptMapper.selectList(new LambdaQueryWrapper() + .eq(MeetingTranscript::getMeetingId, meeting.getId()) + .orderByAsc(MeetingTranscript::getSortOrder) + .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 meetingTime = formatDateTime(meetingDetail != null ? meetingDetail.getMeetingTime() : meeting.getMeetingTime()); + + StringBuilder builder = new StringBuilder(); + builder.append("# ").append(title).append(" 会议转录\n\n"); + builder.append("- 会议时间:").append(meetingTime).append("\n"); + builder.append("- 主持人:").append(hostName).append("\n"); + builder.append("- 导出时间:").append(formatDateTime(LocalDateTime.now())).append("\n\n"); + builder.append("## 转录正文\n\n"); + + if (transcripts.isEmpty()) { + builder.append("_当前暂无转录内容_\n"); + return builder.toString(); + } + + for (MeetingTranscript transcript : transcripts) { + String speaker = firstNonBlank(transcript.getSpeakerName(), transcript.getSpeakerId(), "未知发言人"); + 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"); + } + return builder.toString(); + } + + private Path buildTranscriptPath(Long meetingId) { + String basePath = uploadPath.endsWith("/") || uploadPath.endsWith("\\") ? uploadPath : uploadPath + "/"; + String relativePath = TRANSCRIPT_RELATIVE_PATH_TEMPLATE.formatted(meetingId); + return Paths.get(basePath, relativePath.replace("\\", "/")); + } + + private String buildTimeRange(Integer startTime, Integer endTime) { + if (startTime == null && endTime == null) { + return ""; + } + String start = formatMillis(startTime); + String end = formatMillis(endTime); + if (start.isBlank()) { + return "[" + end + "]"; + } + if (end.isBlank()) { + return "[" + start + "]"; + } + return "[" + start + " - " + end + "]"; + } + + private String formatMillis(Integer millis) { + if (millis == null || millis < 0) { + return ""; + } + int totalSeconds = millis / 1000; + int hours = totalSeconds / 3600; + int minutes = (totalSeconds % 3600) / 60; + int seconds = totalSeconds % 60; + return String.format("%02d:%02d:%02d", hours, minutes, seconds); + } + + private String normalizeTranscriptContent(String content) { + if (content == null || content.isBlank()) { + return ""; + } + return content.replace("\r\n", " ").replace('\n', ' ').trim(); + } + + private String sanitizeFileName(String value, String fallback) { + String normalized = value == null ? "" : value.replaceAll("[\\\\/:*?\"<>|\\r\\n]", "_").trim(); + return normalized.isEmpty() ? fallback : normalized; + } + + private String formatDateTime(LocalDateTime value) { + if (value == null) { + return "未知"; + } + return DATE_TIME_FORMATTER.format(value); + } + + private String firstNonBlank(String... values) { + if (values == null) { + return ""; + } + for (String value : values) { + if (value != null && !value.isBlank()) { + return value.trim(); + } + } + return ""; + } +}