From 35698287deff0a0c8f714c6e9a17a1a75fe6feac Mon Sep 17 00:00:00 2001 From: chenhao Date: Tue, 28 Apr 2026 10:34:15 +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=E5=88=9D=E5=A7=8B=E5=8C=96?= =?UTF-8?q?=E5=92=8C=E4=B8=8B=E8=BD=BD=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 在 `MeetingCommandServiceImpl` 和 `AiTaskServiceImpl` 中添加 `initializeTranscriptFileIfAbsent` 方法调用 - 在 `MeetingController` 中添加 `exportTranscripts` 接口,支持下载会议转录 Markdown 文件 - 更新前端 `meeting.ts` 和 `MeetingDetail.tsx` 以支持会议转录文件的下载 - 在相关测试类中添加对 `MeetingTranscriptFileService` 的 mock --- .../controller/biz/MeetingController.java | 26 ++++++++ .../service/biz/impl/AiTaskServiceImpl.java | 7 +++ .../biz/impl/MeetingCommandServiceImpl.java | 5 ++ .../biz/impl/AiTaskServiceImplTest.java | 55 +++++++++++++++-- frontend/src/api/business/meeting.ts | 8 +++ frontend/src/layouts/AppLayout.tsx | 27 +++++++-- frontend/src/pages/auth/login/index.tsx | 3 + frontend/src/pages/business/HotWords.tsx | 4 ++ frontend/src/pages/business/MeetingDetail.tsx | 60 ++++++++++++++++++- frontend/src/pages/profile/index.tsx | 2 + 10 files changed, 185 insertions(+), 12 deletions(-) diff --git a/backend/src/main/java/com/imeeting/controller/biz/MeetingController.java b/backend/src/main/java/com/imeeting/controller/biz/MeetingController.java index 826dda0..f179700 100644 --- a/backend/src/main/java/com/imeeting/controller/biz/MeetingController.java +++ b/backend/src/main/java/com/imeeting/controller/biz/MeetingController.java @@ -7,6 +7,7 @@ import com.imeeting.dto.biz.CreateRealtimeMeetingCommand; import com.imeeting.dto.biz.MeetingResummaryDTO; import com.imeeting.dto.biz.MeetingSpeakerUpdateDTO; import com.imeeting.dto.biz.MeetingSummaryExportResult; +import com.imeeting.dto.biz.MeetingTranscriptExportResult; import com.imeeting.dto.biz.MeetingTranscriptVO; import com.imeeting.dto.biz.MeetingVO; import com.imeeting.dto.biz.OpenRealtimeSocketSessionCommand; @@ -23,6 +24,7 @@ import com.imeeting.service.biz.MeetingAccessService; import com.imeeting.service.biz.MeetingCommandService; import com.imeeting.service.biz.MeetingExportService; import com.imeeting.service.biz.MeetingQueryService; +import com.imeeting.service.biz.MeetingTranscriptFileService; import com.imeeting.service.biz.PromptTemplateService; import com.imeeting.service.biz.RealtimeMeetingSessionStateService; import com.imeeting.service.biz.RealtimeMeetingSocketSessionService; @@ -67,6 +69,7 @@ public class MeetingController { private final MeetingCommandService meetingCommandService; private final MeetingAccessService meetingAccessService; private final MeetingExportService meetingExportService; + private final MeetingTranscriptFileService meetingTranscriptFileService; private final PromptTemplateService promptTemplateService; private final RealtimeMeetingSocketSessionService realtimeMeetingSocketSessionService; private final RealtimeMeetingSessionStateService realtimeMeetingSessionStateService; @@ -77,6 +80,7 @@ public class MeetingController { MeetingCommandService meetingCommandService, MeetingAccessService meetingAccessService, MeetingExportService meetingExportService, + MeetingTranscriptFileService meetingTranscriptFileService, PromptTemplateService promptTemplateService, RealtimeMeetingSocketSessionService realtimeMeetingSocketSessionService, RealtimeMeetingSessionStateService realtimeMeetingSessionStateService, @@ -86,6 +90,7 @@ public class MeetingController { this.meetingCommandService = meetingCommandService; this.meetingAccessService = meetingAccessService; this.meetingExportService = meetingExportService; + this.meetingTranscriptFileService = meetingTranscriptFileService; this.promptTemplateService = promptTemplateService; this.realtimeMeetingSocketSessionService = realtimeMeetingSocketSessionService; this.realtimeMeetingSessionStateService = realtimeMeetingSessionStateService; @@ -227,6 +232,27 @@ public class MeetingController { return ApiResponse.ok(meetingQueryService.getTranscripts(id)); } + @Operation(summary = "下载会议转录 Markdown") + @GetMapping("/{id}/transcripts/export") + @PreAuthorize("isAuthenticated()") + public ResponseEntity exportTranscripts(@PathVariable Long id) { + LoginUser loginUser = currentLoginUser(); + Meeting meeting = meetingAccessService.requireMeeting(id); + meetingAccessService.assertCanExportMeeting(meeting, loginUser); + + MeetingVO meetingDetail = meetingQueryService.getDetail(id); + if (meetingDetail == null) { + throw new RuntimeException("会议不存在"); + } + + MeetingTranscriptExportResult exportResult = meetingTranscriptFileService.exportTranscript(meeting, meetingDetail); + String encodedFilename = URLEncoder.encode(exportResult.getFileName(), StandardCharsets.UTF_8).replace("+", "%20"); + return ResponseEntity.ok() + .header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename*=UTF-8''" + encodedFilename) + .contentType(MediaType.parseMediaType(exportResult.getContentType())) + .body(exportResult.getContent()); + } + @Operation(summary = "查询实时会议状态") @GetMapping("/{id}/realtime/session-status") @PreAuthorize("isAuthenticated()") diff --git a/backend/src/main/java/com/imeeting/service/biz/impl/AiTaskServiceImpl.java b/backend/src/main/java/com/imeeting/service/biz/impl/AiTaskServiceImpl.java index 886388b..f0c025b 100644 --- a/backend/src/main/java/com/imeeting/service/biz/impl/AiTaskServiceImpl.java +++ b/backend/src/main/java/com/imeeting/service/biz/impl/AiTaskServiceImpl.java @@ -19,6 +19,7 @@ import com.imeeting.service.biz.AiModelService; 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.unisbase.entity.SysUser; import com.unisbase.mapper.SysUserMapper; @@ -58,6 +59,7 @@ public class AiTaskServiceImpl extends ServiceImpl impleme private final HotWordService hotWordService; private final StringRedisTemplate redisTemplate; private final MeetingSummaryFileService meetingSummaryFileService; + private final MeetingTranscriptFileService meetingTranscriptFileService; private final MeetingSummaryPromptAssembler meetingSummaryPromptAssembler; private final TaskSecurityContextRunner taskSecurityContextRunner; @@ -310,6 +312,7 @@ public class AiTaskServiceImpl extends ServiceImpl impleme StringBuilder sb = new StringBuilder(); JsonNode segments = resultNode.path("segments"); + int savedCount = 0; if (segments.isArray()) { int order = 0; for (JsonNode seg : segments) { @@ -325,9 +328,13 @@ public class AiTaskServiceImpl extends ServiceImpl impleme fillTranscriptTime(mt, seg); mt.setSortOrder(order++); transcriptMapper.insert(mt); + savedCount++; sb.append(mt.getSpeakerName()).append(": ").append(mt.getContent()).append("\n"); } } + if (savedCount > 0) { + meetingTranscriptFileService.initializeTranscriptFileIfAbsent(meeting.getId()); + } return sb.toString(); } diff --git a/backend/src/main/java/com/imeeting/service/biz/impl/MeetingCommandServiceImpl.java b/backend/src/main/java/com/imeeting/service/biz/impl/MeetingCommandServiceImpl.java index e5de779..824374d 100644 --- a/backend/src/main/java/com/imeeting/service/biz/impl/MeetingCommandServiceImpl.java +++ b/backend/src/main/java/com/imeeting/service/biz/impl/MeetingCommandServiceImpl.java @@ -24,6 +24,7 @@ import com.imeeting.service.biz.MeetingCommandService; 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.RealtimeMeetingSessionStateService; import com.imeeting.service.realtime.RealtimeMeetingAudioStorageService; import lombok.extern.slf4j.Slf4j; @@ -53,6 +54,7 @@ public class MeetingCommandServiceImpl implements MeetingCommandService { private final HotWordService hotWordService; private final com.imeeting.mapper.biz.MeetingTranscriptMapper transcriptMapper; private final MeetingSummaryFileService meetingSummaryFileService; + private final MeetingTranscriptFileService meetingTranscriptFileService; private final MeetingDomainSupport meetingDomainSupport; private final MeetingRuntimeProfileResolver meetingRuntimeProfileResolver; private final RealtimeMeetingSessionStateService realtimeMeetingSessionStateService; @@ -194,6 +196,7 @@ public class MeetingCommandServiceImpl implements MeetingCommandService { } if (inserted) { + meetingTranscriptFileService.initializeTranscriptFileIfAbsent(meetingId); realtimeMeetingSessionStateService.refreshAfterTranscript(meetingId); } } @@ -222,6 +225,7 @@ public class MeetingCommandServiceImpl implements MeetingCommandService { .set(MeetingTranscript::getContent, content) .set(item.getStartTime() != null, MeetingTranscript::getStartTime, item.getStartTime()) .set(item.getEndTime() != null, MeetingTranscript::getEndTime, item.getEndTime())); + meetingTranscriptFileService.initializeTranscriptFileIfAbsent(meetingId); realtimeMeetingSessionStateService.refreshAfterTranscript(meetingId); return; } @@ -244,6 +248,7 @@ public class MeetingCommandServiceImpl implements MeetingCommandService { transcript.setEndTime(item.getEndTime()); transcript.setSortOrder(maxSortOrder == null ? 0 : maxSortOrder + 1); transcriptMapper.insert(transcript); + meetingTranscriptFileService.initializeTranscriptFileIfAbsent(meetingId); realtimeMeetingSessionStateService.refreshAfterTranscript(meetingId); } diff --git a/backend/src/test/java/com/imeeting/service/biz/impl/AiTaskServiceImplTest.java b/backend/src/test/java/com/imeeting/service/biz/impl/AiTaskServiceImplTest.java index c34cf10..d77f624 100644 --- a/backend/src/test/java/com/imeeting/service/biz/impl/AiTaskServiceImplTest.java +++ b/backend/src/test/java/com/imeeting/service/biz/impl/AiTaskServiceImplTest.java @@ -9,6 +9,7 @@ import com.imeeting.mapper.biz.MeetingTranscriptMapper; 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.support.TaskSecurityContextRunner; import com.unisbase.mapper.SysUserMapper; import org.junit.jupiter.api.Test; @@ -21,9 +22,11 @@ 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.Mockito.doReturn; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.never; @@ -90,7 +93,8 @@ class AiTaskServiceImplTest { transcriptMapper, aiModelService, redisTemplate, - new TaskSecurityContextRunner() + new TaskSecurityContextRunner(), + mock(MeetingTranscriptFileService.class) )); doReturn(true).when(service).updateById(any()); @@ -133,7 +137,8 @@ class AiTaskServiceImplTest { transcriptMapper, aiModelService, redisTemplate, - new TaskSecurityContextRunner() + new TaskSecurityContextRunner(), + mock(MeetingTranscriptFileService.class) )); doReturn(true).when(service).updateById(any()); @@ -160,13 +165,53 @@ class AiTaskServiceImplTest { verify(aiModelService, never()).getModelById(anyLong(), anyString()); } + @Test + void saveTranscriptsShouldInitializeTranscriptFileAfterFirstPersist() throws Exception { + MeetingMapper meetingMapper = mock(MeetingMapper.class); + MeetingTranscriptMapper transcriptMapper = mock(MeetingTranscriptMapper.class); + MeetingTranscriptFileService transcriptFileService = mock(MeetingTranscriptFileService.class); + AiTaskServiceImpl service = createService( + meetingMapper, + transcriptMapper, + mock(AiModelService.class), + mock(StringRedisTemplate.class), + mock(TaskSecurityContextRunner.class), + transcriptFileService + ); + + Meeting meeting = new Meeting(); + meeting.setId(88L); + + ReflectionTestUtils.invokeMethod( + service, + "saveTranscripts", + meeting, + new ObjectMapper().readTree(""" + { + "segments": [ + { + "speaker_id": "spk-1", + "speaker_name": "Alice", + "text": "第一段转录", + "timestamp": [0, 1200] + } + ] + } + """) + ); + + verify(transcriptMapper, times(1)).insert(any(MeetingTranscript.class)); + verify(transcriptFileService, times(1)).initializeTranscriptFileIfAbsent(eq(88L)); + } + private AiTaskServiceImpl createService() { return createService( mock(MeetingMapper.class), mock(MeetingTranscriptMapper.class), mock(AiModelService.class), mock(StringRedisTemplate.class), - mock(TaskSecurityContextRunner.class) + mock(TaskSecurityContextRunner.class), + mock(MeetingTranscriptFileService.class) ); } @@ -174,7 +219,8 @@ class AiTaskServiceImplTest { MeetingTranscriptMapper transcriptMapper, AiModelService aiModelService, StringRedisTemplate redisTemplate, - TaskSecurityContextRunner taskSecurityContextRunner) { + TaskSecurityContextRunner taskSecurityContextRunner, + MeetingTranscriptFileService meetingTranscriptFileService) { return new AiTaskServiceImpl( meetingMapper, transcriptMapper, @@ -184,6 +230,7 @@ class AiTaskServiceImplTest { mock(HotWordService.class), redisTemplate, mock(MeetingSummaryFileService.class), + meetingTranscriptFileService, mock(MeetingSummaryPromptAssembler.class), taskSecurityContextRunner ); diff --git a/frontend/src/api/business/meeting.ts b/frontend/src/api/business/meeting.ts index 1d003e0..fe3b41e 100644 --- a/frontend/src/api/business/meeting.ts +++ b/frontend/src/api/business/meeting.ts @@ -398,3 +398,11 @@ export const downloadMeetingSummary = (id: number, format: "pdf" | "word") => { headers: token ? { Authorization: `Bearer ${token}` } : {} }); }; + +export const downloadMeetingTranscript = (id: number) => { + const token = localStorage.getItem("accessToken"); + return axios.get(`/api/biz/meeting/${id}/transcripts/export`, { + responseType: "blob", + headers: token ? { Authorization: `Bearer ${token}` } : {} + }); +}; diff --git a/frontend/src/layouts/AppLayout.tsx b/frontend/src/layouts/AppLayout.tsx index 9e7e121..2e5bbe3 100644 --- a/frontend/src/layouts/AppLayout.tsx +++ b/frontend/src/layouts/AppLayout.tsx @@ -60,6 +60,10 @@ type CachedUserProfile = { displayName?: string; username?: string; avatarUrl?: function getAvatarUrl(profile?: CachedUserProfile | null) { return profile?.avatarUrl?.trim() || ""; } + +function getDisplayName(profile: CachedUserProfile | null | undefined, fallbackLabel: string) { + return profile?.displayName || profile?.username || localStorage.getItem("displayName") || localStorage.getItem("username") || fallbackLabel; +} export default function AppLayout() { const { message } = App.useApp(); const { t, i18n } = useTranslation(); @@ -79,19 +83,19 @@ export default function AppLayout() { const { load: loadPermissions, can } = usePermission(); const { layoutMode } = useThemeStore(); - const currentUserDisplayName = useMemo(() => { + const [currentUserDisplayName, setCurrentUserDisplayName] = useState(() => { try { const profileStr = sessionStorage.getItem("userProfile"); if (profileStr) { const profile = JSON.parse(profileStr) as CachedUserProfile; - return profile.displayName || profile.username || localStorage.getItem("displayName") || localStorage.getItem("username") || t("layout.admin"); + return getDisplayName(profile, t("layout.admin")); } } catch { // Ignore invalid cached profile and continue with storage fallback. } - return localStorage.getItem("displayName") || localStorage.getItem("username") || t("layout.admin"); - }, [t]); + return getDisplayName(null, t("layout.admin")); + }); const [currentUserAvatarUrl, setCurrentUserAvatarUrl] = useState(() => { try { const profileStr = sessionStorage.getItem("userProfile"); @@ -131,16 +135,26 @@ export default function AppLayout() { const syncUserProfile = () => { try { const profileStr = sessionStorage.getItem("userProfile"); - if (!profileStr) return; + if (!profileStr) { + setCurrentUserDisplayName(getDisplayName(null, t("layout.admin"))); + setCurrentUserAvatarUrl(""); + return; + } const profile = JSON.parse(profileStr) as CachedUserProfile; + localStorage.setItem("displayName", profile.displayName || profile.username || ""); + localStorage.setItem("username", profile.username || localStorage.getItem("username") || ""); + setCurrentUserDisplayName(getDisplayName(profile, t("layout.admin"))); setCurrentUserAvatarUrl(getAvatarUrl(profile)); } catch { + setCurrentUserDisplayName(getDisplayName(null, t("layout.admin"))); + setCurrentUserAvatarUrl(""); } }; + syncUserProfile(); window.addEventListener("user-profile-updated", syncUserProfile); return () => window.removeEventListener("user-profile-updated", syncUserProfile); - }, []); + }, [t]); useEffect(() => { const syncPlatformConfig = () => { const configStr = sessionStorage.getItem("platformConfig"); @@ -165,6 +179,7 @@ export default function AppLayout() { sessionStorage.setItem("userProfile", JSON.stringify(profile)); localStorage.setItem("displayName", profile.displayName || profile.username || ""); localStorage.setItem("username", profile.username || localStorage.getItem("username") || ""); + window.dispatchEvent(new Event("user-profile-updated")); message.success(t("common.success")); window.location.reload(); diff --git a/frontend/src/pages/auth/login/index.tsx b/frontend/src/pages/auth/login/index.tsx index c0df8cb..f96996a 100644 --- a/frontend/src/pages/auth/login/index.tsx +++ b/frontend/src/pages/auth/login/index.tsx @@ -86,8 +86,11 @@ export default function Login() { try { const profile = await getCurrentUser(); sessionStorage.setItem("userProfile", JSON.stringify(profile)); + localStorage.setItem("displayName", profile.displayName || profile.username || ""); + localStorage.setItem("username", profile.username || values.username); } catch { sessionStorage.removeItem("userProfile"); + localStorage.removeItem("displayName"); } message.success(t("common.success")); diff --git a/frontend/src/pages/business/HotWords.tsx b/frontend/src/pages/business/HotWords.tsx index 22b1d1e..d1701c6 100644 --- a/frontend/src/pages/business/HotWords.tsx +++ b/frontend/src/pages/business/HotWords.tsx @@ -596,6 +596,10 @@ const HotWords: React.FC = () => { + + diff --git a/frontend/src/pages/business/MeetingDetail.tsx b/frontend/src/pages/business/MeetingDetail.tsx index d00f993..4779e99 100644 --- a/frontend/src/pages/business/MeetingDetail.tsx +++ b/frontend/src/pages/business/MeetingDetail.tsx @@ -27,6 +27,7 @@ import { import dayjs from 'dayjs'; import ReactMarkdown from 'react-markdown'; import { + downloadMeetingTranscript, downloadMeetingSummary, getMeetingDetail, getMeetingProgress, @@ -651,7 +652,7 @@ const MeetingDetail: React.FC = () => { const [editVisible, setEditVisible] = useState(false); const [summaryVisible, setSummaryVisible] = useState(false); const [actionLoading, setActionLoading] = useState(false); - const [downloadLoading, setDownloadLoading] = useState<'pdf' | 'word' | null>(null); + const [downloadLoading, setDownloadLoading] = useState<'pdf' | 'word' | 'transcript' | null>(null); const [isEditingSummary, setIsEditingSummary] = useState(false); const [summaryDraft, setSummaryDraft] = useState(''); const [summaryTab, setSummaryTab] = useState<'chapters' | 'speakers' | 'actions' | 'todos'>('chapters'); @@ -1236,6 +1237,49 @@ const MeetingDetail: React.FC = () => { } }; + const handleDownloadTranscript = async () => { + if (!meeting) return; + if (transcripts.length === 0) { + message.warning('当前暂无可下载的会议转录'); + return; + } + + try { + setDownloadLoading('transcript'); + const res = await downloadMeetingTranscript(meeting.id); + const contentType = res.headers['content-type'] || 'text/markdown; charset=UTF-8'; + + if (contentType.includes('application/json')) { + const text = await (res.data as Blob).text(); + try { + const json = JSON.parse(text); + message.error(json?.msg || '下载失败'); + } catch { + message.error('下载失败'); + } + return; + } + + const blob = new Blob([res.data], { type: contentType }); + const url = window.URL.createObjectURL(blob); + const anchor = document.createElement('a'); + anchor.href = url; + anchor.download = getFileNameFromDisposition( + res.headers['content-disposition'], + `${sanitizeDownloadFileName(meeting.title, 'meeting-transcript')}-Transcript.md`, + ); + document.body.appendChild(anchor); + anchor.click(); + anchor.remove(); + window.URL.revokeObjectURL(url); + } catch (error) { + console.error(error); + message.error('转录下载失败'); + } finally { + setDownloadLoading(null); + } + }; + const handleDownloadAudio = () => { if (!playbackAudioUrl) { message.warning('当前暂无可下载的会议录音'); @@ -1460,7 +1504,7 @@ const MeetingDetail: React.FC = () => { 正在总结 )} - {(playbackAudioUrl || (meeting.status === 3 && !!meeting.summaryContent)) && ( + {(playbackAudioUrl || transcripts.length > 0 || (meeting.status === 3 && !!meeting.summaryContent)) && ( { icon: , onClick: handleDownloadAudio, }] : []), + ...(transcripts.length > 0 && !(meeting.status === 3 && !!meeting.summaryContent) ? [{ + key: 'transcript', + label: '下载转录 MD', + icon: , + onClick: handleDownloadTranscript, + disabled: downloadLoading === 'transcript' + }] : []), ...(meeting.status === 3 && !!meeting.summaryContent ? [ + { + key: 'transcript', + label: '下载转录 MD', + icon: , + }, { key: 'pdf', label: '下载 PDF', diff --git a/frontend/src/pages/profile/index.tsx b/frontend/src/pages/profile/index.tsx index 2d0e731..635218d 100644 --- a/frontend/src/pages/profile/index.tsx +++ b/frontend/src/pages/profile/index.tsx @@ -34,6 +34,8 @@ export default function Profile() { const data = await getCurrentUser(); setUser(data); sessionStorage.setItem("userProfile", JSON.stringify(data)); + localStorage.setItem("displayName", data.displayName || data.username || ""); + localStorage.setItem("username", data.username || localStorage.getItem("username") || ""); window.dispatchEvent(new Event("user-profile-updated")); profileForm.setFieldsValue(data); } finally {