feat: 添加会议转录文件初始化和下载功能

- 在 `MeetingCommandServiceImpl` 和 `AiTaskServiceImpl` 中添加 `initializeTranscriptFileIfAbsent` 方法调用
- 在 `MeetingController` 中添加 `exportTranscripts` 接口,支持下载会议转录 Markdown 文件
- 更新前端 `meeting.ts` 和 `MeetingDetail.tsx` 以支持会议转录文件的下载
- 在相关测试类中添加对 `MeetingTranscriptFileService` 的 mock
dev_na
chenhao 2026-04-28 10:34:15 +08:00
parent aaa2624fe2
commit 35698287de
10 changed files with 185 additions and 12 deletions

View File

@ -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<byte[]> 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()")

View File

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

View File

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

View File

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

View File

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

View File

@ -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<string>(() => {
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();

View File

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

View File

@ -596,6 +596,10 @@ const HotWords: React.FC = () => {
<Input placeholder="输入识别关键词" onBlur={handleWordBlur} />
</Form.Item>
<Form.Item name="pinyin" hidden>
<Input />
</Form.Item>
<Row gutter={16}>
<Col span={12}>
<Form.Item name="category" label="热词分类">

View File

@ -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 = () => {
</Button>
)}
{(playbackAudioUrl || (meeting.status === 3 && !!meeting.summaryContent)) && (
{(playbackAudioUrl || transcripts.length > 0 || (meeting.status === 3 && !!meeting.summaryContent)) && (
<Dropdown
menu={{
items: [
@ -1470,7 +1514,19 @@ const MeetingDetail: React.FC = () => {
icon: <AudioOutlined />,
onClick: handleDownloadAudio,
}] : []),
...(transcripts.length > 0 && !(meeting.status === 3 && !!meeting.summaryContent) ? [{
key: 'transcript',
label: '下载转录 MD',
icon: <DownloadOutlined />,
onClick: handleDownloadTranscript,
disabled: downloadLoading === 'transcript'
}] : []),
...(meeting.status === 3 && !!meeting.summaryContent ? [
{
key: 'transcript',
label: '下载转录 MD',
icon: <DownloadOutlined />,
},
{
key: 'pdf',
label: '下载 PDF',

View File

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