From 84137b478d349aff3dcc423cb57cae7e2f038fd5 Mon Sep 17 00:00:00 2001 From: junyong Date: Sat, 9 May 2026 18:01:10 +0900 Subject: [PATCH 1/9] =?UTF-8?q?feat:=20=EC=8B=A4=EC=8B=9C=EA=B0=84=20?= =?UTF-8?q?=EB=B0=9C=ED=99=94=20=EC=9E=84=EC=8B=9C=20=EC=A0=80=EC=9E=A5?= =?UTF-8?q?=EC=97=90=20interim=20=EA=B0=80=EB=93=9C=EC=99=80=20=EC=97=B0?= =?UTF-8?q?=EC=86=8D=20=EC=A4=91=EB=B3=B5=20=EC=A0=9C=EA=B1=B0=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../meeting/socket/MeetingSocketHandler.java | 29 ++++ .../socket/message/LiveMessageEntry.java | 16 ++ .../MeetingLiveMessageRepository.java | 49 ++++++ .../socket/MeetingSocketHandlerTest.java | 152 ++++++++++++++++++ .../MeetingLiveMessageRepositoryTest.java | 78 +++++++++ 5 files changed, 324 insertions(+) create mode 100644 src/main/java/com/whylog/server/domain/meeting/socket/message/LiveMessageEntry.java create mode 100644 src/main/java/com/whylog/server/domain/meeting/socket/repository/MeetingLiveMessageRepository.java create mode 100644 src/test/java/com/whylog/server/domain/meeting/socket/MeetingSocketHandlerTest.java create mode 100644 src/test/java/com/whylog/server/domain/meeting/socket/repository/MeetingLiveMessageRepositoryTest.java diff --git a/src/main/java/com/whylog/server/domain/meeting/socket/MeetingSocketHandler.java b/src/main/java/com/whylog/server/domain/meeting/socket/MeetingSocketHandler.java index b1c37b2..53a5d28 100644 --- a/src/main/java/com/whylog/server/domain/meeting/socket/MeetingSocketHandler.java +++ b/src/main/java/com/whylog/server/domain/meeting/socket/MeetingSocketHandler.java @@ -3,10 +3,12 @@ import com.fasterxml.jackson.core.JsonProcessingException; import com.whylog.server.domain.meeting.socket.message.*; import com.whylog.server.domain.meeting.service.MeetingCommandService; +import com.whylog.server.domain.meeting.socket.repository.MeetingLiveMessageRepository; import com.whylog.server.global.util.json.JsonConverter; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.jspecify.annotations.NonNull; +import com.fasterxml.jackson.databind.JsonNode; import org.springframework.stereotype.Component; import org.springframework.util.StringUtils; import org.springframework.web.socket.BinaryMessage; @@ -16,6 +18,7 @@ import org.springframework.web.socket.handler.ConcurrentWebSocketSessionDecorator; import org.springframework.web.socket.handler.BinaryWebSocketHandler; +import java.time.LocalDateTime; import java.time.Instant; import java.util.List; import java.util.Optional; @@ -31,6 +34,7 @@ public class MeetingSocketHandler extends BinaryWebSocketHandler { private final MeetingSocketRoomService meetingSocketRoomService; private final MeetingCommandService meetingCommandService; + private final MeetingLiveMessageRepository meetingLiveMessageRepository; // 웹소켓 연결 직후 참가자를 방에 등록하고 현재 참여자 목록과 입장 이벤트를 전파합니다. @Override @@ -202,6 +206,31 @@ private void broadcastTextMessage(MeetingParticipant participant, MeetingMessage incoming.payload() )) ); + if (type == MeetingMessageType.AUDIO_TEXT + && StringUtils.hasText(incoming.text()) + && !isInterim(incoming.payload())) { + meetingLiveMessageRepository.append( + participant.meetingId(), + new LiveMessageEntry( + participant.meetingId(), + participant.memberId(), + participant.name(), + incoming.targetMemberId(), + incoming.text(), + incoming.payload(), + LocalDateTime.now() + ) + ); + } + } + + private static boolean isInterim(JsonNode payload) { + if (payload == null) { + return false; + } + + JsonNode isFinal = payload.get("is_final"); + return isFinal != null && isFinal.isBoolean() && !isFinal.booleanValue(); } private void forwardSignal( diff --git a/src/main/java/com/whylog/server/domain/meeting/socket/message/LiveMessageEntry.java b/src/main/java/com/whylog/server/domain/meeting/socket/message/LiveMessageEntry.java new file mode 100644 index 0000000..d14b47b --- /dev/null +++ b/src/main/java/com/whylog/server/domain/meeting/socket/message/LiveMessageEntry.java @@ -0,0 +1,16 @@ +package com.whylog.server.domain.meeting.socket.message; + +import com.fasterxml.jackson.databind.JsonNode; +import java.time.LocalDateTime; + +// 실시간 회의 발화 한 건을 메모리에 임시 저장하는 단위입니다. +public record LiveMessageEntry( + Long meetingId, + Long fromMemberId, + String fromName, + Long targetMemberId, + String text, + JsonNode payload, + LocalDateTime receivedAt +) { +} diff --git a/src/main/java/com/whylog/server/domain/meeting/socket/repository/MeetingLiveMessageRepository.java b/src/main/java/com/whylog/server/domain/meeting/socket/repository/MeetingLiveMessageRepository.java new file mode 100644 index 0000000..3827675 --- /dev/null +++ b/src/main/java/com/whylog/server/domain/meeting/socket/repository/MeetingLiveMessageRepository.java @@ -0,0 +1,49 @@ +package com.whylog.server.domain.meeting.socket.repository; + +import com.whylog.server.domain.meeting.socket.message.LiveMessageEntry; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.CopyOnWriteArrayList; +import org.springframework.stereotype.Repository; + +// meetingId 기준으로 실시간 발화를 메모리에 임시 저장합니다. +@Repository +public class MeetingLiveMessageRepository { + + private final Map> liveMessagesByMeetingId = new ConcurrentHashMap<>(); + + public void append(Long meetingId, LiveMessageEntry entry) { + liveMessagesByMeetingId.compute(meetingId, (id, list) -> { + CopyOnWriteArrayList target = list != null ? list : new CopyOnWriteArrayList<>(); + if (!target.isEmpty()) { + LiveMessageEntry last = target.get(target.size() - 1); + if (isSameUtterance(last, entry)) { + return target; + } + } + + target.add(entry); + return target; + }); + } + + public List drain(Long meetingId) { + CopyOnWriteArrayList entries = liveMessagesByMeetingId.remove(meetingId); + if (entries == null) { + return List.of(); + } + + return List.copyOf(entries); + } + + public void clear(Long meetingId) { + liveMessagesByMeetingId.remove(meetingId); + } + + private static boolean isSameUtterance(LiveMessageEntry left, LiveMessageEntry right) { + return Objects.equals(left.fromMemberId(), right.fromMemberId()) + && Objects.equals(left.text(), right.text()); + } +} diff --git a/src/test/java/com/whylog/server/domain/meeting/socket/MeetingSocketHandlerTest.java b/src/test/java/com/whylog/server/domain/meeting/socket/MeetingSocketHandlerTest.java new file mode 100644 index 0000000..9a02379 --- /dev/null +++ b/src/test/java/com/whylog/server/domain/meeting/socket/MeetingSocketHandlerTest.java @@ -0,0 +1,152 @@ +package com.whylog.server.domain.meeting.socket; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.spy; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.whylog.server.domain.meeting.socket.message.LiveMessageEntry; +import com.whylog.server.domain.meeting.socket.message.MeetingMessageType; +import com.whylog.server.domain.meeting.socket.message.MeetingSocketMessage; +import com.whylog.server.domain.meeting.socket.repository.MeetingLiveMessageRepository; +import com.whylog.server.domain.meeting.service.MeetingCommandService; +import com.whylog.server.global.util.json.JsonConverter; +import java.util.Map; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.web.socket.TextMessage; + +@ExtendWith(MockitoExtension.class) +class MeetingSocketHandlerTest { + + private final ObjectMapper objectMapper = new ObjectMapper(); + + @Mock + private MeetingSocketRoomService meetingSocketRoomService; + + @Mock + private MeetingCommandService meetingCommandService; + + private MeetingLiveMessageRepository meetingLiveMessageRepository; + private MeetingSocketHandler meetingSocketHandler; + + @BeforeEach + void setUp() { + meetingLiveMessageRepository = spy(new MeetingLiveMessageRepository()); + meetingSocketHandler = new MeetingSocketHandler( + meetingSocketRoomService, + meetingCommandService, + meetingLiveMessageRepository + ); + } + + @Test + void audioTextMessageIsStoredInMemory() throws Exception { + org.springframework.web.socket.WebSocketSession session = mockSession(); + MeetingSocketMessage message = new MeetingSocketMessage( + MeetingMessageType.AUDIO_TEXT, + null, + "안녕하세요", + null + ); + + meetingSocketHandler.handleTextMessage(session, new TextMessage(JsonConverter.toJson(message))); + + verify(meetingLiveMessageRepository).append(eq(1L), any(LiveMessageEntry.class)); + verify(meetingSocketRoomService).broadcastText(eq(1L), any(String.class)); + } + + @Test + void chatAndSpeechAreNotStoredInMemory() throws Exception { + org.springframework.web.socket.WebSocketSession chatSession = mockSession(); + org.springframework.web.socket.WebSocketSession speechSession = mockSession(); + + meetingSocketHandler.handleTextMessage( + chatSession, + new TextMessage(JsonConverter.toJson(new MeetingSocketMessage(MeetingMessageType.CHAT, null, "chat", null))) + ); + meetingSocketHandler.handleTextMessage( + speechSession, + new TextMessage(JsonConverter.toJson(new MeetingSocketMessage(MeetingMessageType.SPEECH, null, "speech", null))) + ); + + verify(meetingLiveMessageRepository, never()).append(any(), any()); + } + + @Test + void interimAudioTextIsNotStoredInMemory() throws Exception { + org.springframework.web.socket.WebSocketSession session = mockSession(); + MeetingSocketMessage message = new MeetingSocketMessage( + MeetingMessageType.AUDIO_TEXT, + null, + "감", + objectMapper.valueToTree(Map.of("is_final", false)) + ); + + meetingSocketHandler.handleTextMessage(session, new TextMessage(JsonConverter.toJson(message))); + + verify(meetingLiveMessageRepository, never()).append(any(), any()); + } + + @Test + void finalAudioTextIsStoredInMemory() throws Exception { + org.springframework.web.socket.WebSocketSession session = mockSession(); + MeetingSocketMessage message = new MeetingSocketMessage( + MeetingMessageType.AUDIO_TEXT, + null, + "감사합니다", + objectMapper.valueToTree(Map.of("is_final", true)) + ); + + meetingSocketHandler.handleTextMessage(session, new TextMessage(JsonConverter.toJson(message))); + + verify(meetingLiveMessageRepository).append(eq(1L), any(LiveMessageEntry.class)); + } + + @Test + void audioTextWithoutIsFinalFieldIsStoredInMemory() throws Exception { + org.springframework.web.socket.WebSocketSession session = mockSession(); + MeetingSocketMessage message = new MeetingSocketMessage( + MeetingMessageType.AUDIO_TEXT, + null, + "감사합니다", + objectMapper.valueToTree(Map.of("confidence", 0.9)) + ); + + meetingSocketHandler.handleTextMessage(session, new TextMessage(JsonConverter.toJson(message))); + + verify(meetingLiveMessageRepository).append(eq(1L), any(LiveMessageEntry.class)); + } + + @Test + void audioTextWithNullPayloadIsStoredInMemory() throws Exception { + org.springframework.web.socket.WebSocketSession session = mockSession(); + MeetingSocketMessage message = new MeetingSocketMessage( + MeetingMessageType.AUDIO_TEXT, + null, + "감사합니다", + null + ); + + meetingSocketHandler.handleTextMessage(session, new TextMessage(JsonConverter.toJson(message))); + + verify(meetingLiveMessageRepository).append(eq(1L), any(LiveMessageEntry.class)); + } + + private org.springframework.web.socket.WebSocketSession mockSession() { + org.springframework.web.socket.WebSocketSession session = org.mockito.Mockito.mock(org.springframework.web.socket.WebSocketSession.class); + when(session.getId()).thenReturn("session-1"); + when(session.getAttributes()).thenReturn(Map.of( + MeetingSocketAuthInterceptor.MEETING_ID_ATTRIBUTE, 1L, + MeetingSocketAuthInterceptor.MEMBER_ID_ATTRIBUTE, 10L, + MeetingSocketAuthInterceptor.MEMBER_NAME_ATTRIBUTE, "홍길동" + )); + return session; + } +} diff --git a/src/test/java/com/whylog/server/domain/meeting/socket/repository/MeetingLiveMessageRepositoryTest.java b/src/test/java/com/whylog/server/domain/meeting/socket/repository/MeetingLiveMessageRepositoryTest.java new file mode 100644 index 0000000..3304c77 --- /dev/null +++ b/src/test/java/com/whylog/server/domain/meeting/socket/repository/MeetingLiveMessageRepositoryTest.java @@ -0,0 +1,78 @@ +package com.whylog.server.domain.meeting.socket.repository; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.whylog.server.domain.meeting.socket.message.LiveMessageEntry; +import java.time.LocalDateTime; +import java.util.Map; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +class MeetingLiveMessageRepositoryTest { + + private final ObjectMapper objectMapper = new ObjectMapper(); + private MeetingLiveMessageRepository repository; + + @BeforeEach + void setUp() { + repository = new MeetingLiveMessageRepository(); + } + + @Test + void appendingSameSpeakerAndSameTextConsecutivelyKeepsOneEntry() { + LiveMessageEntry first = entry(1L, 10L, "감사합니다"); + LiveMessageEntry duplicate = entry(1L, 10L, "감사합니다"); + + repository.append(1L, first); + repository.append(1L, duplicate); + + assertThat(repository.drain(1L)).hasSize(1); + } + + @Test + void sameTextFromDifferentSpeakerIsStoredSeparately() { + repository.append(1L, entry(1L, 10L, "감사합니다")); + repository.append(1L, entry(1L, 11L, "감사합니다")); + + assertThat(repository.drain(1L)).hasSize(2); + } + + @Test + void sameSpeakerWithDifferentTextIsStoredSeparately() { + repository.append(1L, entry(1L, 10L, "감사합니다")); + repository.append(1L, entry(1L, 10L, "네")); + + assertThat(repository.drain(1L)).hasSize(2); + } + + @Test + void dedupOnlyAppliesToImmediatelyPreviousEntry() { + repository.append(1L, entry(1L, 10L, "감사합니다")); + repository.append(1L, entry(1L, 11L, "네")); + repository.append(1L, entry(1L, 10L, "감사합니다")); + + assertThat(repository.drain(1L)).hasSize(3); + } + + @Test + void drainResetsDedupState() { + repository.append(1L, entry(1L, 10L, "감사합니다")); + assertThat(repository.drain(1L)).hasSize(1); + + repository.append(1L, entry(1L, 10L, "감사합니다")); + assertThat(repository.drain(1L)).hasSize(1); + } + + private LiveMessageEntry entry(Long meetingId, Long fromMemberId, String text) { + return new LiveMessageEntry( + meetingId, + fromMemberId, + "홍길동", + null, + text, + objectMapper.valueToTree(Map.of("is_final", true)), + LocalDateTime.of(2026, 1, 1, 10, 0, 0) + ); + } +} From 97cf0c4473d56daedba032486aaa2c3ffcc7da29 Mon Sep 17 00:00:00 2001 From: junyong Date: Sat, 9 May 2026 18:01:43 +0900 Subject: [PATCH 2/9] =?UTF-8?q?refactor:=20=EC=8B=A4=EC=8B=9C=EA=B0=84=20?= =?UTF-8?q?=EB=B0=9C=ED=99=94=20=EB=B2=88=EB=93=A4=EB=A7=81=20=EC=B1=85?= =?UTF-8?q?=EC=9E=84=20=EB=B6=84=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../service/MeetingAnalysisService.java | 5 +- .../MeetingLiveMessageBundleService.java | 40 ++++ .../fast/client/FastApiTranscribeClient.java | 25 ++- .../fast/dto/request/LiveMessagePayload.java | 58 ++++++ .../service/MeetingAnalysisServiceTest.java | 182 ++++++++++++++++++ .../MeetingLiveMessageBundleServiceTest.java | 71 +++++++ 6 files changed, 375 insertions(+), 6 deletions(-) create mode 100644 src/main/java/com/whylog/server/domain/meeting/service/MeetingLiveMessageBundleService.java create mode 100644 src/main/java/com/whylog/server/global/external/fast/dto/request/LiveMessagePayload.java create mode 100644 src/test/java/com/whylog/server/domain/meeting/service/MeetingAnalysisServiceTest.java create mode 100644 src/test/java/com/whylog/server/domain/meeting/service/MeetingLiveMessageBundleServiceTest.java diff --git a/src/main/java/com/whylog/server/domain/meeting/service/MeetingAnalysisService.java b/src/main/java/com/whylog/server/domain/meeting/service/MeetingAnalysisService.java index c69984a..9220fad 100644 --- a/src/main/java/com/whylog/server/domain/meeting/service/MeetingAnalysisService.java +++ b/src/main/java/com/whylog/server/domain/meeting/service/MeetingAnalysisService.java @@ -74,6 +74,7 @@ public class MeetingAnalysisService { private final MeetingAnalysisRepository meetingAnalysisRepository; private final DialogueRepository dialogueRepository; private final MeetingMemberRepository meetingMemberRepository; + private final MeetingLiveMessageBundleService meetingLiveMessageBundleService; private final TransactionTemplate transactionTemplate; private final ObjectMapper objectMapper; @@ -129,6 +130,7 @@ private String createTranscribeApplicationRun(Meeting meeting, MeetingResponse.A String audioUrl = audioResponse.getAudioUrl(); String audioFilename = meetingAudioFileService.extractFileName(audioKey); String contentType = meetingAudioFileService.resolveResponseContentType(audioKey); + String liveMessagesJson = meetingLiveMessageBundleService.buildLiveMessagesJson(meeting); FastApiResponse createResponse = fastApiTranscribeClient.createTranscribeApplicationRun( buildAudioResource(audioUrl), @@ -136,7 +138,8 @@ private String createTranscribeApplicationRun(Meeting meeting, MeetingResponse.A contentType, null, String.valueOf(meeting.getId()), - null + null, + liveMessagesJson ); String runId = requireResult(createResponse).runId(); diff --git a/src/main/java/com/whylog/server/domain/meeting/service/MeetingLiveMessageBundleService.java b/src/main/java/com/whylog/server/domain/meeting/service/MeetingLiveMessageBundleService.java new file mode 100644 index 0000000..828374b --- /dev/null +++ b/src/main/java/com/whylog/server/domain/meeting/service/MeetingLiveMessageBundleService.java @@ -0,0 +1,40 @@ +package com.whylog.server.domain.meeting.service; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.whylog.server.domain.meeting.entity.Meeting; +import com.whylog.server.domain.meeting.socket.message.LiveMessageEntry; +import com.whylog.server.domain.meeting.socket.repository.MeetingLiveMessageRepository; +import com.whylog.server.global.external.fast.dto.request.LiveMessagePayload; +import java.util.List; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; + +// 회의 종료 시 실시간 발화를 drain해서 FastAPI 전송용 JSON으로 묶습니다. +@Service +@Slf4j +@RequiredArgsConstructor +public class MeetingLiveMessageBundleService { + + private final MeetingLiveMessageRepository meetingLiveMessageRepository; + private final ObjectMapper objectMapper; + + public String buildLiveMessagesJson(Meeting meeting) { + List liveMessageEntries = meetingLiveMessageRepository.drain(meeting.getId()); + if (liveMessageEntries.isEmpty()) { + return null; + } + + List payloads = liveMessageEntries.stream() + .map(entry -> LiveMessagePayload.from(entry, meeting.getStartDateTime())) + .toList(); + + try { + return objectMapper.writeValueAsString(payloads); + } catch (JsonProcessingException exception) { + log.warn("실시간 발화 직렬화 실패: meetingId={}", meeting.getId(), exception); + return null; + } + } +} diff --git a/src/main/java/com/whylog/server/global/external/fast/client/FastApiTranscribeClient.java b/src/main/java/com/whylog/server/global/external/fast/client/FastApiTranscribeClient.java index 5243d3c..95fc688 100644 --- a/src/main/java/com/whylog/server/global/external/fast/client/FastApiTranscribeClient.java +++ b/src/main/java/com/whylog/server/global/external/fast/client/FastApiTranscribeClient.java @@ -10,6 +10,7 @@ import java.util.Map; import org.springframework.core.ParameterizedTypeReference; import org.springframework.core.io.Resource; +import org.springframework.util.StringUtils; import org.springframework.stereotype.Component; import org.springframework.web.client.RestClient; @@ -46,7 +47,7 @@ public FastApiResponse transcribeAudio(Resource audio, String projectId) { return postMultipart( FastApiInfo.TRANSCRIBE_AUDIO, - buildTextParts(numSpeakers, meetingId, projectId), + buildTextParts(numSpeakers, meetingId, projectId, null), new FastApiBinaryPart("audio", audio, filename, contentType), new ParameterizedTypeReference<>() { } @@ -61,7 +62,7 @@ public FastApiResponse transcribeApplications(Resource audio, String projectId) { return postMultipart( FastApiInfo.TRANSCRIBE_APPLICATIONS, - buildTextParts(numSpeakers, meetingId, projectId), + buildTextParts(numSpeakers, meetingId, projectId, null), new FastApiBinaryPart("audio", audio, filename, contentType), new ParameterizedTypeReference<>() { } @@ -85,9 +86,19 @@ public FastApiResponse createTranscribeA Integer numSpeakers, String meetingId, String projectId) { + return createTranscribeApplicationRun(audio, filename, contentType, numSpeakers, meetingId, projectId, null); + } + + public FastApiResponse createTranscribeApplicationRun(Resource audio, + String filename, + String contentType, + Integer numSpeakers, + String meetingId, + String projectId, + String liveMessages) { return postMultipart( FastApiInfo.TRANSCRIBE_APPLICATION_RUNS, - buildTextParts(numSpeakers, meetingId, projectId), + buildTextParts(numSpeakers, meetingId, projectId, liveMessages), new FastApiBinaryPart("audio", audio, filename, contentType), new ParameterizedTypeReference<>() { } @@ -101,7 +112,8 @@ public FastApiResponse createTranscribeA request.audio().contentType(), request.numSpeakers(), request.meetingId(), - request.projectId() + request.projectId(), + null ); } @@ -114,7 +126,7 @@ public FastApiResponse getTranscribeApplicatio ); } - private Map buildTextParts(Integer numSpeakers, String meetingId, String projectId) { + private Map buildTextParts(Integer numSpeakers, String meetingId, String projectId, String liveMessages) { Map parts = new LinkedHashMap<>(); if (numSpeakers != null) { parts.put("num_speakers", numSpeakers); @@ -125,6 +137,9 @@ private Map buildTextParts(Integer numSpeakers, String meetingId if (projectId != null) { parts.put("project_id", projectId); } + if (StringUtils.hasText(liveMessages)) { + parts.put("live_messages", liveMessages); + } return parts; } } diff --git a/src/main/java/com/whylog/server/global/external/fast/dto/request/LiveMessagePayload.java b/src/main/java/com/whylog/server/global/external/fast/dto/request/LiveMessagePayload.java new file mode 100644 index 0000000..258bc89 --- /dev/null +++ b/src/main/java/com/whylog/server/global/external/fast/dto/request/LiveMessagePayload.java @@ -0,0 +1,58 @@ +package com.whylog.server.global.external.fast.dto.request; + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.databind.JsonNode; +import com.whylog.server.domain.meeting.socket.message.LiveMessageEntry; +import java.time.Duration; +import java.time.LocalDateTime; + +// FastAPI로 전달할 실시간 발화 항목입니다. +@JsonInclude(JsonInclude.Include.NON_NULL) +public record LiveMessagePayload( + Long meetingId, + Long fromMemberId, + String fromName, + String timestamp, + Long targetMemberId, + String text, + JsonNode payload +) { + + @JsonProperty("type") + public String type() { + return "TEXT"; + } + + public static LiveMessagePayload from(LiveMessageEntry entry, LocalDateTime meetingStartDateTime) { + return new LiveMessagePayload( + entry.meetingId(), + entry.fromMemberId(), + entry.fromName(), + formatElapsed(meetingStartDateTime, entry.receivedAt()), + entry.targetMemberId(), + entry.text(), + entry.payload() + ); + } + + private static String formatElapsed(LocalDateTime startDateTime, LocalDateTime receivedAt) { + try { + if (startDateTime == null || receivedAt == null) { + return "00:00:00"; + } + + long elapsedSeconds = Duration.between(startDateTime, receivedAt).getSeconds(); + if (elapsedSeconds < 0) { + return "00:00:00"; + } + + long hours = elapsedSeconds / 3600; + long minutes = (elapsedSeconds % 3600) / 60; + long seconds = elapsedSeconds % 60; + return String.format("%02d:%02d:%02d", hours, minutes, seconds); + } catch (RuntimeException exception) { + return "00:00:00"; + } + } +} diff --git a/src/test/java/com/whylog/server/domain/meeting/service/MeetingAnalysisServiceTest.java b/src/test/java/com/whylog/server/domain/meeting/service/MeetingAnalysisServiceTest.java new file mode 100644 index 0000000..5c2ecad --- /dev/null +++ b/src/test/java/com/whylog/server/domain/meeting/service/MeetingAnalysisServiceTest.java @@ -0,0 +1,182 @@ +package com.whylog.server.domain.meeting.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.whylog.server.domain.decision.repository.ApplicationBaseRepository; +import com.whylog.server.domain.decision.repository.ApplicationRepository; +import com.whylog.server.domain.decision.repository.ApplicationTimelineRepository; +import com.whylog.server.domain.decision.repository.DecisionBaseRepository; +import com.whylog.server.domain.decision.repository.DecisionRepository; +import com.whylog.server.domain.decision.repository.DecisionTimelineRepository; +import com.whylog.server.domain.meeting.dto.MeetingRequest; +import com.whylog.server.domain.meeting.dto.MeetingResponse; +import com.whylog.server.domain.meeting.entity.Meeting; +import com.whylog.server.domain.meeting.repository.DialogueRepository; +import com.whylog.server.domain.meeting.repository.MeetingAnalysisRepository; +import com.whylog.server.domain.meeting.repository.MeetingMemberRepository; +import com.whylog.server.domain.team.entity.Team; +import com.whylog.server.global.external.fast.client.FastApiTranscribeClient; +import com.whylog.server.global.external.fast.dto.FastApiResponse; +import com.whylog.server.global.external.fast.dto.response.TranscribeApplicationRunCreateResponse; +import java.lang.reflect.Method; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.core.io.Resource; +import org.springframework.test.util.ReflectionTestUtils; +import org.springframework.transaction.support.TransactionTemplate; + +class MeetingAnalysisServiceTest { + + private FastApiTranscribeClient fastApiTranscribeClient; + private MeetingAudioReplayService meetingAudioReplayService; + private MeetingAudioFileService meetingAudioFileService; + private MeetingUseCase meetingUseCase; + private ApplicationRepository applicationRepository; + private ApplicationBaseRepository applicationBaseRepository; + private ApplicationTimelineRepository applicationTimelineRepository; + private DecisionBaseRepository decisionBaseRepository; + private DecisionTimelineRepository decisionTimelineRepository; + private DecisionRepository decisionRepository; + private MeetingAnalysisRepository meetingAnalysisRepository; + private DialogueRepository dialogueRepository; + private MeetingMemberRepository meetingMemberRepository; + private MeetingLiveMessageBundleService meetingLiveMessageBundleService; + private TransactionTemplate transactionTemplate; + private MeetingAnalysisService meetingAnalysisService; + + @BeforeEach + void setUp() { + fastApiTranscribeClient = mock(FastApiTranscribeClient.class); + meetingAudioReplayService = mock(MeetingAudioReplayService.class); + meetingAudioFileService = mock(MeetingAudioFileService.class); + meetingUseCase = mock(MeetingUseCase.class); + applicationRepository = mock(ApplicationRepository.class); + applicationBaseRepository = mock(ApplicationBaseRepository.class); + applicationTimelineRepository = mock(ApplicationTimelineRepository.class); + decisionBaseRepository = mock(DecisionBaseRepository.class); + decisionTimelineRepository = mock(DecisionTimelineRepository.class); + decisionRepository = mock(DecisionRepository.class); + meetingAnalysisRepository = mock(MeetingAnalysisRepository.class); + dialogueRepository = mock(DialogueRepository.class); + meetingMemberRepository = mock(MeetingMemberRepository.class); + meetingLiveMessageBundleService = mock(MeetingLiveMessageBundleService.class); + transactionTemplate = mock(TransactionTemplate.class); + + meetingAnalysisService = new MeetingAnalysisService( + meetingUseCase, + meetingAudioReplayService, + meetingAudioFileService, + fastApiTranscribeClient, + applicationRepository, + applicationBaseRepository, + applicationTimelineRepository, + decisionBaseRepository, + decisionTimelineRepository, + decisionRepository, + meetingAnalysisRepository, + dialogueRepository, + meetingMemberRepository, + meetingLiveMessageBundleService, + transactionTemplate, + new ObjectMapper() + ); + } + + @Test + void createTranscribeApplicationRunTransfersDrainedLiveMessages() throws Exception { + Meeting meeting = meeting(); + + when(meetingAudioFileService.extractFileName("audio-key")).thenReturn("audio.mp3"); + when(meetingAudioFileService.resolveResponseContentType("audio-key")).thenReturn("audio/mpeg"); + when(meetingLiveMessageBundleService.buildLiveMessagesJson(meeting)).thenReturn("[{\"type\":\"TEXT\",\"timestamp\":\"01:02:03\"}]"); + when(fastApiTranscribeClient.createTranscribeApplicationRun( + any(Resource.class), + eq("audio.mp3"), + eq("audio/mpeg"), + eq(null), + eq("123"), + eq(null), + anyString() + )).thenReturn(new FastApiResponse<>( + true, + "ok", + "ok", + new TranscribeApplicationRunCreateResponse("run-1", "queued", "queued", null, null) + )); + + String runId = invokeCreateTranscribeApplicationRun(meeting); + + assertThat(runId).isEqualTo("run-1"); + + org.mockito.ArgumentCaptor liveMessagesCaptor = org.mockito.ArgumentCaptor.forClass(String.class); + verify(fastApiTranscribeClient).createTranscribeApplicationRun( + any(Resource.class), + eq("audio.mp3"), + eq("audio/mpeg"), + eq(null), + eq("123"), + eq(null), + liveMessagesCaptor.capture() + ); + + String liveMessagesJson = liveMessagesCaptor.getValue(); + assertThat(liveMessagesJson).isEqualTo("[{\"type\":\"TEXT\",\"timestamp\":\"01:02:03\"}]"); + } + + @Test + void createTranscribeApplicationRunPassesNullWhenNoLiveMessagesExist() throws Exception { + Meeting meeting = meeting(); + + when(meetingAudioFileService.extractFileName("audio-key")).thenReturn("audio.mp3"); + when(meetingAudioFileService.resolveResponseContentType("audio-key")).thenReturn("audio/mpeg"); + when(meetingLiveMessageBundleService.buildLiveMessagesJson(meeting)).thenReturn(null); + when(fastApiTranscribeClient.createTranscribeApplicationRun( + any(Resource.class), + eq("audio.mp3"), + eq("audio/mpeg"), + eq(null), + eq("123"), + eq(null), + eq(null) + )).thenReturn(new FastApiResponse<>( + true, + "ok", + "ok", + new TranscribeApplicationRunCreateResponse("run-2", "queued", "queued", null, null) + )); + + String runId = invokeCreateTranscribeApplicationRun(meeting); + + assertThat(runId).isEqualTo("run-2"); + } + + private String invokeCreateTranscribeApplicationRun(Meeting meeting) throws Exception { + MeetingResponse.AudioDTO audioResponse = mock(MeetingResponse.AudioDTO.class); + when(audioResponse.getAudioKey()).thenReturn("audio-key"); + when(audioResponse.getAudioUrl()).thenReturn("https://example.com/audio.mp3"); + + Method method = MeetingAnalysisService.class.getDeclaredMethod( + "createTranscribeApplicationRun", + Meeting.class, + MeetingResponse.AudioDTO.class + ); + method.setAccessible(true); + return (String) method.invoke(meetingAnalysisService, meeting, audioResponse); + } + + private Meeting meeting() { + Meeting meeting = Meeting.create( + MeetingRequest.MeetingCreateDTO.builder().name("회의").build(), + mock(Team.class) + ); + ReflectionTestUtils.setField(meeting, "id", 123L); + return meeting; + } +} diff --git a/src/test/java/com/whylog/server/domain/meeting/service/MeetingLiveMessageBundleServiceTest.java b/src/test/java/com/whylog/server/domain/meeting/service/MeetingLiveMessageBundleServiceTest.java new file mode 100644 index 0000000..5b86f3d --- /dev/null +++ b/src/test/java/com/whylog/server/domain/meeting/service/MeetingLiveMessageBundleServiceTest.java @@ -0,0 +1,71 @@ +package com.whylog.server.domain.meeting.service; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.whylog.server.domain.meeting.entity.Meeting; +import com.whylog.server.domain.meeting.socket.message.LiveMessageEntry; +import com.whylog.server.domain.meeting.socket.repository.MeetingLiveMessageRepository; +import com.whylog.server.domain.team.entity.Team; +import java.time.LocalDateTime; +import java.util.Map; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.test.util.ReflectionTestUtils; + +class MeetingLiveMessageBundleServiceTest { + + private final ObjectMapper objectMapper = new ObjectMapper(); + private MeetingLiveMessageRepository meetingLiveMessageRepository; + private MeetingLiveMessageBundleService meetingLiveMessageBundleService; + + @BeforeEach + void setUp() { + meetingLiveMessageRepository = new MeetingLiveMessageRepository(); + meetingLiveMessageBundleService = new MeetingLiveMessageBundleService(meetingLiveMessageRepository, objectMapper); + } + + @Test + void buildLiveMessagesJsonSerializesDrainedEntries() throws Exception { + Meeting meeting = meeting(); + meetingLiveMessageRepository.append( + meeting.getId(), + new LiveMessageEntry( + meeting.getId(), + 2L, + "발화자", + null, + "안녕하세요", + objectMapper.valueToTree(Map.of("foo", "bar")), + LocalDateTime.of(2026, 1, 1, 10, 2, 3) + ) + ); + + String json = meetingLiveMessageBundleService.buildLiveMessagesJson(meeting); + + assertThat(json).isNotBlank(); + assertThat(objectMapper.readTree(json)).hasSize(1); + assertThat(objectMapper.readTree(json).get(0).get("type").asText()).isEqualTo("TEXT"); + assertThat(objectMapper.readTree(json).get(0).get("timestamp").asText()).isEqualTo("01:02:03"); + assertThat(meetingLiveMessageRepository.drain(meeting.getId())).isEmpty(); + } + + @Test + void buildLiveMessagesJsonReturnsNullWhenNoEntriesExist() { + Meeting meeting = meeting(); + + String json = meetingLiveMessageBundleService.buildLiveMessagesJson(meeting); + + assertThat(json).isNull(); + } + + private Meeting meeting() { + Meeting meeting = Meeting.create( + com.whylog.server.domain.meeting.dto.MeetingRequest.MeetingCreateDTO.builder().name("회의").build(), + org.mockito.Mockito.mock(Team.class) + ); + ReflectionTestUtils.setField(meeting, "id", 123L); + ReflectionTestUtils.setField(meeting, "startDateTime", LocalDateTime.of(2026, 1, 1, 9, 0, 0)); + return meeting; + } +} From c3269a5c537aac5d283468257d6961541be2453f Mon Sep 17 00:00:00 2001 From: junyong Date: Sat, 9 May 2026 18:02:01 +0900 Subject: [PATCH 3/9] =?UTF-8?q?fix:=20=ED=9A=8C=EC=9D=98=20=EB=B6=84?= =?UTF-8?q?=EC=84=9D=EA=B3=BC=20=EB=8C=80=ED=99=94=20=EC=A0=80=EC=9E=A5=20?= =?UTF-8?q?=EC=A4=91=EB=B3=B5=20=EB=B0=A9=EC=A7=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../repository/MeetingAnalysisRepository.java | 4 +++ .../service/MeetingAnalysisService.java | 33 +++++++++++++++++-- .../service/MeetingCommandService.java | 3 ++ 3 files changed, 38 insertions(+), 2 deletions(-) diff --git a/src/main/java/com/whylog/server/domain/meeting/repository/MeetingAnalysisRepository.java b/src/main/java/com/whylog/server/domain/meeting/repository/MeetingAnalysisRepository.java index 3b3d6fc..19db016 100644 --- a/src/main/java/com/whylog/server/domain/meeting/repository/MeetingAnalysisRepository.java +++ b/src/main/java/com/whylog/server/domain/meeting/repository/MeetingAnalysisRepository.java @@ -1,6 +1,7 @@ package com.whylog.server.domain.meeting.repository; import com.whylog.server.domain.meeting.entity.MeetingAnalysis; +import java.util.Optional; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.Modifying; import org.springframework.data.jpa.repository.Query; @@ -15,4 +16,7 @@ public interface MeetingAnalysisRepository extends JpaRepository findByMeetingId(@Param("meetingId") Long meetingId); } diff --git a/src/main/java/com/whylog/server/domain/meeting/service/MeetingAnalysisService.java b/src/main/java/com/whylog/server/domain/meeting/service/MeetingAnalysisService.java index 9220fad..cd57632 100644 --- a/src/main/java/com/whylog/server/domain/meeting/service/MeetingAnalysisService.java +++ b/src/main/java/com/whylog/server/domain/meeting/service/MeetingAnalysisService.java @@ -38,6 +38,8 @@ import java.util.List; import java.util.Locale; import java.util.Optional; +import java.util.HashSet; +import java.util.Set; import java.util.concurrent.TimeUnit; import java.util.regex.Matcher; import java.util.regex.Pattern; @@ -226,12 +228,17 @@ private void persistMeetingAnalysis(Meeting meeting, TranscribeApplicationRunRes transactionTemplate.executeWithoutResult(status -> { Meeting managedMeeting = meetingUseCase.findMeetingWithMembersById(meeting.getId()); - - MeetingAnalysis meetingAnalysis = MeetingAnalysis.create(managedMeeting, payload); + MeetingAnalysis meetingAnalysis = meetingAnalysisRepository.findByMeetingId(managedMeeting.getId()) + .map(existingMeetingAnalysis -> { + existingMeetingAnalysis.updateAnalysis(payload); + return existingMeetingAnalysis; + }) + .orElseGet(() -> MeetingAnalysis.create(managedMeeting, payload)); meetingAnalysisRepository.save(meetingAnalysis); managedMeeting.attachMeetingAnalysis(meetingAnalysis); List dialogues = buildDialogues(managedMeeting, transcriptSegments); + dialogueRepository.deleteByMeetingId(managedMeeting.getId()); if (!dialogues.isEmpty()) { dialogueRepository.saveAll(dialogues); dialogues.forEach(managedMeeting::addDialogue); @@ -420,6 +427,7 @@ private List buildDialogues(Meeting meeting, } List dialogues = new ArrayList<>(); + Set seenDialogueKeys = new HashSet<>(); for (int index = 0; index < transcriptSegments.size(); index++) { TranscribeApplicationRunResponse.TranscriptSegmentResponse segment = transcriptSegments.get(index); if (segment == null || segment.text() == null || segment.text().isBlank()) { @@ -428,12 +436,33 @@ private List buildDialogues(Meeting meeting, Member member = resolveMemberForSegment(members, segment.speaker(), index); LocalDateTime speechDateTime = resolveSpeechDateTime(meeting.getStartDateTime(), segment.startTime(), index); + String dialogueKey = buildDialogueKey(segment, member, speechDateTime); + if (!seenDialogueKeys.add(dialogueKey)) { + continue; + } dialogues.add(Dialogue.create(meeting, member, segment.text().trim(), speechDateTime)); } return dialogues; } + private String buildDialogueKey(TranscribeApplicationRunResponse.TranscriptSegmentResponse segment, + Member member, + LocalDateTime speechDateTime) { + if (segment.messageId() != null) { + return "messageId:" + segment.messageId(); + } + + return String.join("|", + String.valueOf(member.getId()), + speechDateTime != null ? speechDateTime.toString() : "", + segment.text() != null ? segment.text().trim() : "", + segment.speaker() != null ? segment.speaker().trim() : "", + segment.startTime() != null ? segment.startTime().trim() : "", + segment.endTime() != null ? segment.endTime().trim() : "" + ); + } + // 세그먼트의 화자 정보를 기반으로 회의 참여자를 선택한다. private Member resolveMemberForSegment(List members, String speaker, int index) { if (speaker != null && !speaker.isBlank()) { diff --git a/src/main/java/com/whylog/server/domain/meeting/service/MeetingCommandService.java b/src/main/java/com/whylog/server/domain/meeting/service/MeetingCommandService.java index b0d9910..be2bf1b 100644 --- a/src/main/java/com/whylog/server/domain/meeting/service/MeetingCommandService.java +++ b/src/main/java/com/whylog/server/domain/meeting/service/MeetingCommandService.java @@ -12,6 +12,7 @@ import com.whylog.server.domain.meeting.repository.MeetingMemberRepository; import com.whylog.server.domain.meeting.repository.MeetingRepository; import com.whylog.server.domain.meeting.socket.MeetingSocketRoomService; +import com.whylog.server.domain.meeting.socket.repository.MeetingLiveMessageRepository; import com.whylog.server.domain.meeting.service.MeetingAnalysisService; import com.whylog.server.domain.meeting.service.MeetingCleanupService; import com.whylog.server.domain.meeting.service.LiveKitTokenService; @@ -42,6 +43,7 @@ public class MeetingCommandService { private final MeetingCleanupService meetingCleanupService; private final MeetingAudioFileService meetingAudioFileService; private final MeetingAnalysisService meetingAnalysisService; + private final MeetingLiveMessageRepository meetingLiveMessageRepository; private final LiveKitTokenService liveKitTokenService; private final LiveKitEgressClient liveKitEgressClient; @@ -136,6 +138,7 @@ public MeetingResponse.MeetingDeleteResponseDTO deleteMeeting(Long memberId, Lon stopRecording(meeting); meetingCleanupService.deleteByMeetingId(meetingId); + meetingLiveMessageRepository.clear(meetingId); scheduleAfterCommit(() -> meetingSocketRoomService.closeRoom(meetingId)); return MeetingResponse.MeetingDeleteResponseDTO.builder() From 394db501db800367965778942f96929a88e519cc Mon Sep 17 00:00:00 2001 From: junyong Date: Sun, 10 May 2026 23:52:38 +0900 Subject: [PATCH 4/9] =?UTF-8?q?refactor:=20=ED=9A=8C=EC=9D=98=20=EB=B6=84?= =?UTF-8?q?=EC=84=9D=20=EC=9D=91=EB=8B=B5=20member=5Fid=20=EB=B0=98?= =?UTF-8?q?=EC=98=81=EA=B3=BC=20=EC=A0=80=EC=9E=A5=20=ED=9D=90=EB=A6=84=20?= =?UTF-8?q?=EC=A0=95=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../repository/ApplicationBaseRepository.java | 8 + .../ApplicationTimelineRepository.java | 8 + .../repository/DecisionBaseRepository.java | 10 + .../DecisionTimelineRepository.java | 10 + .../service/MeetingAnalysisService.java | 92 ++----- .../service/MeetingSpeakerResolver.java | 6 - .../meeting/service/MeetingUseCase.java | 7 +- .../TranscribeApplicationRunResponse.java | 8 +- .../service/MeetingAnalysisServiceTest.java | 248 +++++++++++------- 9 files changed, 219 insertions(+), 178 deletions(-) delete mode 100644 src/main/java/com/whylog/server/domain/meeting/service/MeetingSpeakerResolver.java diff --git a/src/main/java/com/whylog/server/domain/decision/repository/ApplicationBaseRepository.java b/src/main/java/com/whylog/server/domain/decision/repository/ApplicationBaseRepository.java index c9309d6..9866c2b 100644 --- a/src/main/java/com/whylog/server/domain/decision/repository/ApplicationBaseRepository.java +++ b/src/main/java/com/whylog/server/domain/decision/repository/ApplicationBaseRepository.java @@ -3,6 +3,7 @@ import com.whylog.server.domain.decision.entity.ApplicationBase; import com.whylog.server.domain.decision.entity.ApplicationBaseId; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; @@ -19,4 +20,11 @@ public interface ApplicationBaseRepository extends JpaRepository findByApplicationId(@Param("applicationId") Long applicationId); + + @Modifying + @Query(""" + DELETE FROM ApplicationBase ab + WHERE ab.application.decision.meeting.id = :meetingId + """) + void deleteByMeetingId(@Param("meetingId") Long meetingId); } diff --git a/src/main/java/com/whylog/server/domain/decision/repository/ApplicationTimelineRepository.java b/src/main/java/com/whylog/server/domain/decision/repository/ApplicationTimelineRepository.java index 7cb8950..99a998f 100644 --- a/src/main/java/com/whylog/server/domain/decision/repository/ApplicationTimelineRepository.java +++ b/src/main/java/com/whylog/server/domain/decision/repository/ApplicationTimelineRepository.java @@ -3,6 +3,7 @@ import com.whylog.server.domain.decision.entity.ApplicationTimeline; import com.whylog.server.domain.decision.entity.ApplicationTimelineId; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; @@ -19,4 +20,11 @@ public interface ApplicationTimelineRepository extends JpaRepository findByApplicationId(@Param("applicationId") Long applicationId); + + @Modifying + @Query(""" + DELETE FROM ApplicationTimeline at + WHERE at.application.decision.meeting.id = :meetingId + """) + void deleteByMeetingId(@Param("meetingId") Long meetingId); } diff --git a/src/main/java/com/whylog/server/domain/decision/repository/DecisionBaseRepository.java b/src/main/java/com/whylog/server/domain/decision/repository/DecisionBaseRepository.java index 4b5c554..e1edfb9 100644 --- a/src/main/java/com/whylog/server/domain/decision/repository/DecisionBaseRepository.java +++ b/src/main/java/com/whylog/server/domain/decision/repository/DecisionBaseRepository.java @@ -2,6 +2,16 @@ import com.whylog.server.domain.decision.entity.DecisionBase; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; public interface DecisionBaseRepository extends JpaRepository { + + @Modifying + @Query(""" + DELETE FROM DecisionBase db + WHERE db.decision.meeting.id = :meetingId + """) + void deleteByMeetingId(@Param("meetingId") Long meetingId); } diff --git a/src/main/java/com/whylog/server/domain/decision/repository/DecisionTimelineRepository.java b/src/main/java/com/whylog/server/domain/decision/repository/DecisionTimelineRepository.java index 488ed0d..7111c92 100644 --- a/src/main/java/com/whylog/server/domain/decision/repository/DecisionTimelineRepository.java +++ b/src/main/java/com/whylog/server/domain/decision/repository/DecisionTimelineRepository.java @@ -2,6 +2,16 @@ import com.whylog.server.domain.decision.entity.DecisionTimeline; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; public interface DecisionTimelineRepository extends JpaRepository { + + @Modifying + @Query(""" + DELETE FROM DecisionTimeline dt + WHERE dt.decision.meeting.id = :meetingId + """) + void deleteByMeetingId(@Param("meetingId") Long meetingId); } diff --git a/src/main/java/com/whylog/server/domain/meeting/service/MeetingAnalysisService.java b/src/main/java/com/whylog/server/domain/meeting/service/MeetingAnalysisService.java index cd57632..1cc6bb7 100644 --- a/src/main/java/com/whylog/server/domain/meeting/service/MeetingAnalysisService.java +++ b/src/main/java/com/whylog/server/domain/meeting/service/MeetingAnalysisService.java @@ -21,7 +21,6 @@ import com.whylog.server.domain.meeting.entity.MeetingMember; import com.whylog.server.domain.meeting.exception.MeetingInvalidMemberException; import com.whylog.server.domain.meeting.exception.MeetingAudioNotReadyException; -import com.whylog.server.domain.meeting.repository.DialogueRepository; import com.whylog.server.domain.meeting.repository.MeetingAnalysisRepository; import com.whylog.server.domain.meeting.repository.MeetingMemberRepository; import com.whylog.server.domain.user.entity.Member; @@ -34,15 +33,12 @@ import java.time.Duration; import java.time.LocalDateTime; import java.util.ArrayList; -import java.util.Comparator; +import java.util.Map; import java.util.List; -import java.util.Locale; +import java.util.function.Function; import java.util.Optional; -import java.util.HashSet; -import java.util.Set; import java.util.concurrent.TimeUnit; -import java.util.regex.Matcher; -import java.util.regex.Pattern; +import java.util.stream.Collectors; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.core.io.UrlResource; @@ -61,7 +57,6 @@ public class MeetingAnalysisService { private static final int MAX_AUDIO_READY_ATTEMPTS = 20; private static final Duration RUN_POLL_WAIT_INTERVAL = Duration.ofSeconds(3); private static final int MAX_RUN_POLL_ATTEMPTS = 120; - private static final Pattern SPEAKER_INDEX_PATTERN = Pattern.compile("(\\d+)"); private final MeetingUseCase meetingUseCase; private final MeetingAudioReplayService meetingAudioReplayService; @@ -74,7 +69,6 @@ public class MeetingAnalysisService { private final DecisionTimelineRepository decisionTimelineRepository; private final DecisionRepository decisionRepository; private final MeetingAnalysisRepository meetingAnalysisRepository; - private final DialogueRepository dialogueRepository; private final MeetingMemberRepository meetingMemberRepository; private final MeetingLiveMessageBundleService meetingLiveMessageBundleService; private final TransactionTemplate transactionTemplate; @@ -238,11 +232,8 @@ private void persistMeetingAnalysis(Meeting meeting, TranscribeApplicationRunRes managedMeeting.attachMeetingAnalysis(meetingAnalysis); List dialogues = buildDialogues(managedMeeting, transcriptSegments); - dialogueRepository.deleteByMeetingId(managedMeeting.getId()); - if (!dialogues.isEmpty()) { - dialogueRepository.saveAll(dialogues); - dialogues.forEach(managedMeeting::addDialogue); - } + managedMeeting.getDialogues().clear(); + managedMeeting.getDialogues().addAll(dialogues); Decision decision = createDecisionIfAbsent(managedMeeting); replaceApplications(managedMeeting.getId(), decision, applications); @@ -266,6 +257,12 @@ private Decision createDecisionIfAbsent(Meeting meeting) { private void replaceApplications(Long meetingId, Decision decision, List applications) { + applicationBaseRepository.deleteByMeetingId(meetingId); + applicationTimelineRepository.deleteByMeetingId(meetingId); + decisionBaseRepository.deleteByMeetingId(meetingId); + decisionTimelineRepository.deleteByMeetingId(meetingId); + applicationRepository.deleteByMeetingId(meetingId); + List validApplications = applications.stream() .filter(application -> application != null && application.applicationTitle() != null @@ -330,7 +327,7 @@ private void persistApplicationTimelines(Application application, timeline.timestamp(), timeline.step(), timeline.content(), - meetingUseCase.resolveMemberIdBySpeakerId(application.getDecision().getMeeting().getId(), timeline.speakerId()), + timeline.memberId(), timeline.utterance() )) .toList(); @@ -417,84 +414,33 @@ private List buildDialogues(Meeting meeting, return List.of(); } - List members = meeting.getMeetingMembers().stream() + Map membersById = meeting.getMeetingMembers().stream() .map(MeetingMember::getMember) - .sorted(Comparator.comparing(Member::getId)) - .toList(); + .collect(Collectors.toMap(Member::getId, Function.identity())); - if (members.isEmpty()) { + if (membersById.isEmpty()) { return List.of(); } List dialogues = new ArrayList<>(); - Set seenDialogueKeys = new HashSet<>(); for (int index = 0; index < transcriptSegments.size(); index++) { TranscribeApplicationRunResponse.TranscriptSegmentResponse segment = transcriptSegments.get(index); if (segment == null || segment.text() == null || segment.text().isBlank()) { continue; } - Member member = resolveMemberForSegment(members, segment.speaker(), index); - LocalDateTime speechDateTime = resolveSpeechDateTime(meeting.getStartDateTime(), segment.startTime(), index); - String dialogueKey = buildDialogueKey(segment, member, speechDateTime); - if (!seenDialogueKeys.add(dialogueKey)) { + Member member = segment.memberId() != null ? membersById.get(segment.memberId()) : null; + if (member == null) { continue; } + + LocalDateTime speechDateTime = resolveSpeechDateTime(meeting.getStartDateTime(), segment.startTime(), index); dialogues.add(Dialogue.create(meeting, member, segment.text().trim(), speechDateTime)); } return dialogues; } - private String buildDialogueKey(TranscribeApplicationRunResponse.TranscriptSegmentResponse segment, - Member member, - LocalDateTime speechDateTime) { - if (segment.messageId() != null) { - return "messageId:" + segment.messageId(); - } - - return String.join("|", - String.valueOf(member.getId()), - speechDateTime != null ? speechDateTime.toString() : "", - segment.text() != null ? segment.text().trim() : "", - segment.speaker() != null ? segment.speaker().trim() : "", - segment.startTime() != null ? segment.startTime().trim() : "", - segment.endTime() != null ? segment.endTime().trim() : "" - ); - } - - // 세그먼트의 화자 정보를 기반으로 회의 참여자를 선택한다. - private Member resolveMemberForSegment(List members, String speaker, int index) { - if (speaker != null && !speaker.isBlank()) { - String normalizedSpeaker = speaker.trim().toLowerCase(Locale.ROOT); - for (Member member : members) { - if (member.getName() != null && member.getName().trim().toLowerCase(Locale.ROOT).equals(normalizedSpeaker)) { - return member; - } - } - - Integer speakerIndex = extractSpeakerIndex(speaker); - if (speakerIndex != null && speakerIndex >= 0 && speakerIndex < members.size()) { - return members.get(speakerIndex); - } - } - - return members.get(Math.min(index, members.size() - 1)); - } - - // 화자 문자열에서 숫자 인덱스를 추출한다. - private Integer extractSpeakerIndex(String speaker) { - Matcher matcher = SPEAKER_INDEX_PATTERN.matcher(speaker); - if (matcher.find()) { - try { - return Integer.parseInt(matcher.group(1)); - } catch (NumberFormatException ignored) { - return null; - } - } - return null; - } - // 전사 시작 시각 offset을 회의 시작 시각 기준 LocalDateTime으로 변환한다. private LocalDateTime resolveSpeechDateTime(LocalDateTime meetingStartDateTime, String offset, int fallbackIndex) { if (meetingStartDateTime == null) { diff --git a/src/main/java/com/whylog/server/domain/meeting/service/MeetingSpeakerResolver.java b/src/main/java/com/whylog/server/domain/meeting/service/MeetingSpeakerResolver.java deleted file mode 100644 index 14b9fb5..0000000 --- a/src/main/java/com/whylog/server/domain/meeting/service/MeetingSpeakerResolver.java +++ /dev/null @@ -1,6 +0,0 @@ -package com.whylog.server.domain.meeting.service; - -public interface MeetingSpeakerResolver { - - Long resolveMemberIdBySpeakerId(Long meetingId, String speakerId); -} diff --git a/src/main/java/com/whylog/server/domain/meeting/service/MeetingUseCase.java b/src/main/java/com/whylog/server/domain/meeting/service/MeetingUseCase.java index 02bcf54..9ade6aa 100644 --- a/src/main/java/com/whylog/server/domain/meeting/service/MeetingUseCase.java +++ b/src/main/java/com/whylog/server/domain/meeting/service/MeetingUseCase.java @@ -12,7 +12,7 @@ @Service @RequiredArgsConstructor -public class MeetingUseCase implements MeetingSpeakerResolver { +public class MeetingUseCase{ private final MeetingRepository meetingRepository; @@ -46,9 +46,4 @@ public Meeting findWithAnalysisByMeetingId(Long meetingId) { return meetingRepository.findByMeetingId(meetingId) .orElseThrow(MeetingNotFoundException::new); } - @Override - public Long resolveMemberIdBySpeakerId(Long meetingId, String speakerId) { - // TODO: 대화 내역을 기준으로 speakerId와 실제 memberId를 매칭한다. - return 1L; - } } diff --git a/src/main/java/com/whylog/server/global/external/fast/dto/response/TranscribeApplicationRunResponse.java b/src/main/java/com/whylog/server/global/external/fast/dto/response/TranscribeApplicationRunResponse.java index d75cc33..53192b3 100644 --- a/src/main/java/com/whylog/server/global/external/fast/dto/response/TranscribeApplicationRunResponse.java +++ b/src/main/java/com/whylog/server/global/external/fast/dto/response/TranscribeApplicationRunResponse.java @@ -53,8 +53,8 @@ public record TranscribeApplicationRunResult( public record TranscriptSegmentResponse( @Schema(description = "메시지 ID", example = "1", nullable = true) Long messageId, - @Schema(description = "화자명", example = "Speaker 0", nullable = true) - String speaker, + @Schema(description = "발화자 멤버 ID", example = "1", nullable = true) + Long memberId, @Schema(description = "시작 시각", example = "00:00:00", nullable = true) String startTime, @Schema(description = "종료 시각", example = "00:00:04", nullable = true) @@ -132,8 +132,8 @@ public record TimelineResponse( String timestamp, @Schema(description = "단계", example = "이슈제기", nullable = true) String step, - @Schema(description = "화자 ID", example = "Speaker 0", nullable = true) - String speakerId, + @Schema(description = "발화자 멤버 ID", example = "1", nullable = true) + Long memberId, @Schema(description = "내용", nullable = true) String content, @Schema(description = "원문 발화", nullable = true) diff --git a/src/test/java/com/whylog/server/domain/meeting/service/MeetingAnalysisServiceTest.java b/src/test/java/com/whylog/server/domain/meeting/service/MeetingAnalysisServiceTest.java index 5c2ecad..19be484 100644 --- a/src/test/java/com/whylog/server/domain/meeting/service/MeetingAnalysisServiceTest.java +++ b/src/test/java/com/whylog/server/domain/meeting/service/MeetingAnalysisServiceTest.java @@ -2,13 +2,17 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.ArgumentMatchers.any; -import static org.mockito.ArgumentMatchers.anyString; -import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.ArgumentMatchers.anyList; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.Mockito.doAnswer; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; import com.fasterxml.jackson.databind.ObjectMapper; +import com.whylog.server.domain.decision.entity.Application; +import com.whylog.server.domain.decision.entity.Decision; +import com.whylog.server.domain.decision.entity.DecisionTimeline; import com.whylog.server.domain.decision.repository.ApplicationBaseRepository; import com.whylog.server.domain.decision.repository.ApplicationRepository; import com.whylog.server.domain.decision.repository.ApplicationTimelineRepository; @@ -16,28 +20,34 @@ import com.whylog.server.domain.decision.repository.DecisionRepository; import com.whylog.server.domain.decision.repository.DecisionTimelineRepository; import com.whylog.server.domain.meeting.dto.MeetingRequest; -import com.whylog.server.domain.meeting.dto.MeetingResponse; import com.whylog.server.domain.meeting.entity.Meeting; -import com.whylog.server.domain.meeting.repository.DialogueRepository; +import com.whylog.server.domain.meeting.entity.MeetingAnalysis; +import com.whylog.server.domain.meeting.entity.MeetingMember; +import com.whylog.server.domain.meeting.enums.MeetingRole; import com.whylog.server.domain.meeting.repository.MeetingAnalysisRepository; import com.whylog.server.domain.meeting.repository.MeetingMemberRepository; import com.whylog.server.domain.team.entity.Team; +import com.whylog.server.domain.user.entity.Member; +import com.whylog.server.domain.user.enums.Role; import com.whylog.server.global.external.fast.client.FastApiTranscribeClient; -import com.whylog.server.global.external.fast.dto.FastApiResponse; -import com.whylog.server.global.external.fast.dto.response.TranscribeApplicationRunCreateResponse; +import com.whylog.server.global.external.fast.dto.response.TranscribeApplicationRunResponse; import java.lang.reflect.Method; +import java.time.LocalDateTime; +import java.util.List; +import java.util.Optional; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; -import org.springframework.core.io.Resource; import org.springframework.test.util.ReflectionTestUtils; +import org.springframework.transaction.support.SimpleTransactionStatus; +import org.springframework.transaction.PlatformTransactionManager; import org.springframework.transaction.support.TransactionTemplate; class MeetingAnalysisServiceTest { - private FastApiTranscribeClient fastApiTranscribeClient; + private MeetingUseCase meetingUseCase; private MeetingAudioReplayService meetingAudioReplayService; private MeetingAudioFileService meetingAudioFileService; - private MeetingUseCase meetingUseCase; + private FastApiTranscribeClient fastApiTranscribeClient; private ApplicationRepository applicationRepository; private ApplicationBaseRepository applicationBaseRepository; private ApplicationTimelineRepository applicationTimelineRepository; @@ -45,18 +55,18 @@ class MeetingAnalysisServiceTest { private DecisionTimelineRepository decisionTimelineRepository; private DecisionRepository decisionRepository; private MeetingAnalysisRepository meetingAnalysisRepository; - private DialogueRepository dialogueRepository; private MeetingMemberRepository meetingMemberRepository; private MeetingLiveMessageBundleService meetingLiveMessageBundleService; + private PlatformTransactionManager transactionManager; private TransactionTemplate transactionTemplate; private MeetingAnalysisService meetingAnalysisService; @BeforeEach void setUp() { - fastApiTranscribeClient = mock(FastApiTranscribeClient.class); + meetingUseCase = mock(MeetingUseCase.class); meetingAudioReplayService = mock(MeetingAudioReplayService.class); meetingAudioFileService = mock(MeetingAudioFileService.class); - meetingUseCase = mock(MeetingUseCase.class); + fastApiTranscribeClient = mock(FastApiTranscribeClient.class); applicationRepository = mock(ApplicationRepository.class); applicationBaseRepository = mock(ApplicationBaseRepository.class); applicationTimelineRepository = mock(ApplicationTimelineRepository.class); @@ -64,10 +74,23 @@ void setUp() { decisionTimelineRepository = mock(DecisionTimelineRepository.class); decisionRepository = mock(DecisionRepository.class); meetingAnalysisRepository = mock(MeetingAnalysisRepository.class); - dialogueRepository = mock(DialogueRepository.class); meetingMemberRepository = mock(MeetingMemberRepository.class); meetingLiveMessageBundleService = mock(MeetingLiveMessageBundleService.class); - transactionTemplate = mock(TransactionTemplate.class); + transactionManager = mock(PlatformTransactionManager.class); + transactionTemplate = new TransactionTemplate(transactionManager); + + when(transactionManager.getTransaction(any())).thenReturn(new SimpleTransactionStatus()); + + when(meetingAnalysisRepository.findByMeetingId(anyLong())).thenReturn(Optional.empty()); + when(meetingAnalysisRepository.save(any())).thenAnswer(invocation -> invocation.getArgument(0)); + when(decisionRepository.findByMeetingId(anyLong())).thenReturn(Optional.empty()); + when(decisionRepository.save(any())).thenAnswer(invocation -> invocation.getArgument(0)); + when(applicationRepository.saveAllAndFlush(anyList())).thenAnswer(invocation -> invocation.getArgument(0)); + when(decisionBaseRepository.saveAllAndFlush(anyList())).thenAnswer(invocation -> invocation.getArgument(0)); + when(applicationBaseRepository.saveAllAndFlush(anyList())).thenAnswer(invocation -> invocation.getArgument(0)); + when(decisionTimelineRepository.saveAllAndFlush(anyList())).thenAnswer(invocation -> invocation.getArgument(0)); + when(applicationTimelineRepository.saveAllAndFlush(anyList())).thenAnswer(invocation -> invocation.getArgument(0)); + when(meetingLiveMessageBundleService.buildLiveMessagesJson(any())).thenReturn(null); meetingAnalysisService = new MeetingAnalysisService( meetingUseCase, @@ -81,7 +104,6 @@ void setUp() { decisionTimelineRepository, decisionRepository, meetingAnalysisRepository, - dialogueRepository, meetingMemberRepository, meetingLiveMessageBundleService, transactionTemplate, @@ -90,93 +112,141 @@ void setUp() { } @Test - void createTranscribeApplicationRunTransfersDrainedLiveMessages() throws Exception { - Meeting meeting = meeting(); - - when(meetingAudioFileService.extractFileName("audio-key")).thenReturn("audio.mp3"); - when(meetingAudioFileService.resolveResponseContentType("audio-key")).thenReturn("audio/mpeg"); - when(meetingLiveMessageBundleService.buildLiveMessagesJson(meeting)).thenReturn("[{\"type\":\"TEXT\",\"timestamp\":\"01:02:03\"}]"); - when(fastApiTranscribeClient.createTranscribeApplicationRun( - any(Resource.class), - eq("audio.mp3"), - eq("audio/mpeg"), - eq(null), - eq("123"), - eq(null), - anyString() - )).thenReturn(new FastApiResponse<>( - true, - "ok", - "ok", - new TranscribeApplicationRunCreateResponse("run-1", "queued", "queued", null, null) - )); - - String runId = invokeCreateTranscribeApplicationRun(meeting); - - assertThat(runId).isEqualTo("run-1"); - - org.mockito.ArgumentCaptor liveMessagesCaptor = org.mockito.ArgumentCaptor.forClass(String.class); - verify(fastApiTranscribeClient).createTranscribeApplicationRun( - any(Resource.class), - eq("audio.mp3"), - eq("audio/mpeg"), - eq(null), - eq("123"), - eq(null), - liveMessagesCaptor.capture() + void timelineMemberIdIsStoredDirectly() throws Exception { + Meeting meeting = meetingWithMembers(); + when(meetingMemberRepository.existsByMemberIdAndMeetingId(1L, 10L)).thenReturn(true); + when(meetingUseCase.findMeetingWithMembersById(10L)).thenReturn(meeting); + + TranscribeApplicationRunResponse response = response( + timeline(100L, 2L, "핵심", "발화"), + transcriptSegment(1L, 2L, "00:00:01", null, "안녕하세요"), + transcriptSegment(2L, null, "00:00:02", null, "스킵"), + transcriptSegment(3L, 999L, "00:00:03", null, "스킵2") ); - String liveMessagesJson = liveMessagesCaptor.getValue(); - assertThat(liveMessagesJson).isEqualTo("[{\"type\":\"TEXT\",\"timestamp\":\"01:02:03\"}]"); + meetingAnalysisService.persistTestMeetingAnalysis(1L, 10L, MeetingRequest.MeetingAnalysisTestDTO.builder() + .isSuccess(true) + .code("OK") + .message("ok") + .result(response) + .build()); + + @SuppressWarnings("unchecked") + var timelineCaptor = org.mockito.ArgumentCaptor.forClass(List.class); + verify(decisionTimelineRepository).saveAllAndFlush(timelineCaptor.capture()); + assertThat(timelineCaptor.getValue()).hasSize(1); + assertThat(((DecisionTimeline) timelineCaptor.getValue().get(0)).getMemberId()).isEqualTo(2L); } @Test - void createTranscribeApplicationRunPassesNullWhenNoLiveMessagesExist() throws Exception { - Meeting meeting = meeting(); - - when(meetingAudioFileService.extractFileName("audio-key")).thenReturn("audio.mp3"); - when(meetingAudioFileService.resolveResponseContentType("audio-key")).thenReturn("audio/mpeg"); - when(meetingLiveMessageBundleService.buildLiveMessagesJson(meeting)).thenReturn(null); - when(fastApiTranscribeClient.createTranscribeApplicationRun( - any(Resource.class), - eq("audio.mp3"), - eq("audio/mpeg"), - eq(null), - eq("123"), - eq(null), - eq(null) - )).thenReturn(new FastApiResponse<>( - true, - "ok", - "ok", - new TranscribeApplicationRunCreateResponse("run-2", "queued", "queued", null, null) - )); - - String runId = invokeCreateTranscribeApplicationRun(meeting); - - assertThat(runId).isEqualTo("run-2"); - } + void segmentMemberIdIsUsedAndInvalidSegmentsAreSkipped() throws Exception { + Meeting meeting = meetingWithMembers(); + when(meetingMemberRepository.existsByMemberIdAndMeetingId(1L, 10L)).thenReturn(true); + when(meetingUseCase.findMeetingWithMembersById(10L)).thenReturn(meeting); + + TranscribeApplicationRunResponse response = response( + timeline(100L, 2L, "핵심", "발화"), + transcriptSegment(1L, 2L, "00:00:01", null, "memberId 우선"), + transcriptSegment(2L, null, "00:00:02", null, "skip"), + transcriptSegment(3L, 999L, "00:00:03", null, "skip2") + ); - private String invokeCreateTranscribeApplicationRun(Meeting meeting) throws Exception { - MeetingResponse.AudioDTO audioResponse = mock(MeetingResponse.AudioDTO.class); - when(audioResponse.getAudioKey()).thenReturn("audio-key"); - when(audioResponse.getAudioUrl()).thenReturn("https://example.com/audio.mp3"); + meetingAnalysisService.persistTestMeetingAnalysis(1L, 10L, MeetingRequest.MeetingAnalysisTestDTO.builder() + .isSuccess(true) + .code("OK") + .message("ok") + .result(response) + .build()); - Method method = MeetingAnalysisService.class.getDeclaredMethod( - "createTranscribeApplicationRun", - Meeting.class, - MeetingResponse.AudioDTO.class - ); - method.setAccessible(true); - return (String) method.invoke(meetingAnalysisService, meeting, audioResponse); + assertThat(meeting.getDialogues()).hasSize(1); + assertThat(meeting.getDialogues().get(0).getMember().getId()).isEqualTo(2L); } - private Meeting meeting() { + private Meeting meetingWithMembers() { Meeting meeting = Meeting.create( MeetingRequest.MeetingCreateDTO.builder().name("회의").build(), mock(Team.class) ); - ReflectionTestUtils.setField(meeting, "id", 123L); + ReflectionTestUtils.setField(meeting, "id", 10L); + ReflectionTestUtils.setField(meeting, "startDateTime", LocalDateTime.of(2026, 1, 1, 9, 0, 0)); + + meeting.getMeetingMembers().add(MeetingMember.create(meeting, member(1L, "first"), MeetingRole.OWNER)); + meeting.getMeetingMembers().add(MeetingMember.create(meeting, member(2L, "second"), MeetingRole.GENERAL)); return meeting; } + + private Member member(Long id, String name) { + Member member = Member.builder() + .name(name) + .email(name + "@example.com") + .password("pw") + .role(Role.USER) + .build(); + ReflectionTestUtils.setField(member, "id", id); + return member; + } + + private TranscribeApplicationRunResponse response(TranscribeApplicationRunResponse.ApplicationResponse applicationResponse, + TranscribeApplicationRunResponse.TranscriptSegmentResponse... segments) { + return new TranscribeApplicationRunResponse( + "run-1", + "completed", + "applications_ready", + "10", + null, + null, + null, + null, + null, + new TranscribeApplicationRunResponse.TranscribeApplicationRunResult( + "10", + null, + List.of(segments), + new TranscribeApplicationRunResponse.AnalysisResultResponse( + new TranscribeApplicationRunResponse.OverallAnalysisResponse( + new TranscribeApplicationRunResponse.MeetingInfoResponse("제목", "목적", "00:10:00"), + List.of("토픽"), + List.of("맥락"), + List.of("적용사항"), + List.of("사유") + ), + List.of(applicationResponse), + List.of() + ) + ) + ); + } + + private TranscribeApplicationRunResponse.ApplicationResponse timeline(Long applicationId, + Long memberId, + String content, + String utterance) { + return new TranscribeApplicationRunResponse.ApplicationResponse( + applicationId, + "적용사항", + List.of("사유"), + List.of(new TranscribeApplicationRunResponse.TimelineResponse( + "00:01:00", + "step", + memberId, + content, + utterance + )) + ); + } + + private TranscribeApplicationRunResponse.TranscriptSegmentResponse transcriptSegment(Long messageId, + Long memberId, + String startTime, + String endTime, + String text) { + return new TranscribeApplicationRunResponse.TranscriptSegmentResponse( + messageId, + memberId, + startTime, + endTime, + text, + true + ); + } } From 1d05ec28c614a291174136c82e88b3f08ef336d8 Mon Sep 17 00:00:00 2001 From: junyong Date: Sun, 10 May 2026 23:55:31 +0900 Subject: [PATCH 5/9] =?UTF-8?q?remove:=20=ED=85=8C=EC=8A=A4=ED=8A=B8?= =?UTF-8?q?=EC=BD=94=EB=93=9C=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 1 + .../service/MeetingAnalysisServiceTest.java | 252 ------------------ .../MeetingLiveMessageBundleServiceTest.java | 71 ----- .../socket/MeetingSocketHandlerTest.java | 152 ----------- .../MeetingLiveMessageRepositoryTest.java | 78 ------ 5 files changed, 1 insertion(+), 553 deletions(-) delete mode 100644 src/test/java/com/whylog/server/domain/meeting/service/MeetingAnalysisServiceTest.java delete mode 100644 src/test/java/com/whylog/server/domain/meeting/service/MeetingLiveMessageBundleServiceTest.java delete mode 100644 src/test/java/com/whylog/server/domain/meeting/socket/MeetingSocketHandlerTest.java delete mode 100644 src/test/java/com/whylog/server/domain/meeting/socket/repository/MeetingLiveMessageRepositoryTest.java diff --git a/.gitignore b/.gitignore index 58ce205..403c2ab 100644 --- a/.gitignore +++ b/.gitignore @@ -39,3 +39,4 @@ out/ ## Agents AGENTS.md +CLAUDE.md diff --git a/src/test/java/com/whylog/server/domain/meeting/service/MeetingAnalysisServiceTest.java b/src/test/java/com/whylog/server/domain/meeting/service/MeetingAnalysisServiceTest.java deleted file mode 100644 index 19be484..0000000 --- a/src/test/java/com/whylog/server/domain/meeting/service/MeetingAnalysisServiceTest.java +++ /dev/null @@ -1,252 +0,0 @@ -package com.whylog.server.domain.meeting.service; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.ArgumentMatchers.anyList; -import static org.mockito.ArgumentMatchers.anyLong; -import static org.mockito.Mockito.doAnswer; -import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.verify; -import static org.mockito.Mockito.when; - -import com.fasterxml.jackson.databind.ObjectMapper; -import com.whylog.server.domain.decision.entity.Application; -import com.whylog.server.domain.decision.entity.Decision; -import com.whylog.server.domain.decision.entity.DecisionTimeline; -import com.whylog.server.domain.decision.repository.ApplicationBaseRepository; -import com.whylog.server.domain.decision.repository.ApplicationRepository; -import com.whylog.server.domain.decision.repository.ApplicationTimelineRepository; -import com.whylog.server.domain.decision.repository.DecisionBaseRepository; -import com.whylog.server.domain.decision.repository.DecisionRepository; -import com.whylog.server.domain.decision.repository.DecisionTimelineRepository; -import com.whylog.server.domain.meeting.dto.MeetingRequest; -import com.whylog.server.domain.meeting.entity.Meeting; -import com.whylog.server.domain.meeting.entity.MeetingAnalysis; -import com.whylog.server.domain.meeting.entity.MeetingMember; -import com.whylog.server.domain.meeting.enums.MeetingRole; -import com.whylog.server.domain.meeting.repository.MeetingAnalysisRepository; -import com.whylog.server.domain.meeting.repository.MeetingMemberRepository; -import com.whylog.server.domain.team.entity.Team; -import com.whylog.server.domain.user.entity.Member; -import com.whylog.server.domain.user.enums.Role; -import com.whylog.server.global.external.fast.client.FastApiTranscribeClient; -import com.whylog.server.global.external.fast.dto.response.TranscribeApplicationRunResponse; -import java.lang.reflect.Method; -import java.time.LocalDateTime; -import java.util.List; -import java.util.Optional; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; -import org.springframework.test.util.ReflectionTestUtils; -import org.springframework.transaction.support.SimpleTransactionStatus; -import org.springframework.transaction.PlatformTransactionManager; -import org.springframework.transaction.support.TransactionTemplate; - -class MeetingAnalysisServiceTest { - - private MeetingUseCase meetingUseCase; - private MeetingAudioReplayService meetingAudioReplayService; - private MeetingAudioFileService meetingAudioFileService; - private FastApiTranscribeClient fastApiTranscribeClient; - private ApplicationRepository applicationRepository; - private ApplicationBaseRepository applicationBaseRepository; - private ApplicationTimelineRepository applicationTimelineRepository; - private DecisionBaseRepository decisionBaseRepository; - private DecisionTimelineRepository decisionTimelineRepository; - private DecisionRepository decisionRepository; - private MeetingAnalysisRepository meetingAnalysisRepository; - private MeetingMemberRepository meetingMemberRepository; - private MeetingLiveMessageBundleService meetingLiveMessageBundleService; - private PlatformTransactionManager transactionManager; - private TransactionTemplate transactionTemplate; - private MeetingAnalysisService meetingAnalysisService; - - @BeforeEach - void setUp() { - meetingUseCase = mock(MeetingUseCase.class); - meetingAudioReplayService = mock(MeetingAudioReplayService.class); - meetingAudioFileService = mock(MeetingAudioFileService.class); - fastApiTranscribeClient = mock(FastApiTranscribeClient.class); - applicationRepository = mock(ApplicationRepository.class); - applicationBaseRepository = mock(ApplicationBaseRepository.class); - applicationTimelineRepository = mock(ApplicationTimelineRepository.class); - decisionBaseRepository = mock(DecisionBaseRepository.class); - decisionTimelineRepository = mock(DecisionTimelineRepository.class); - decisionRepository = mock(DecisionRepository.class); - meetingAnalysisRepository = mock(MeetingAnalysisRepository.class); - meetingMemberRepository = mock(MeetingMemberRepository.class); - meetingLiveMessageBundleService = mock(MeetingLiveMessageBundleService.class); - transactionManager = mock(PlatformTransactionManager.class); - transactionTemplate = new TransactionTemplate(transactionManager); - - when(transactionManager.getTransaction(any())).thenReturn(new SimpleTransactionStatus()); - - when(meetingAnalysisRepository.findByMeetingId(anyLong())).thenReturn(Optional.empty()); - when(meetingAnalysisRepository.save(any())).thenAnswer(invocation -> invocation.getArgument(0)); - when(decisionRepository.findByMeetingId(anyLong())).thenReturn(Optional.empty()); - when(decisionRepository.save(any())).thenAnswer(invocation -> invocation.getArgument(0)); - when(applicationRepository.saveAllAndFlush(anyList())).thenAnswer(invocation -> invocation.getArgument(0)); - when(decisionBaseRepository.saveAllAndFlush(anyList())).thenAnswer(invocation -> invocation.getArgument(0)); - when(applicationBaseRepository.saveAllAndFlush(anyList())).thenAnswer(invocation -> invocation.getArgument(0)); - when(decisionTimelineRepository.saveAllAndFlush(anyList())).thenAnswer(invocation -> invocation.getArgument(0)); - when(applicationTimelineRepository.saveAllAndFlush(anyList())).thenAnswer(invocation -> invocation.getArgument(0)); - when(meetingLiveMessageBundleService.buildLiveMessagesJson(any())).thenReturn(null); - - meetingAnalysisService = new MeetingAnalysisService( - meetingUseCase, - meetingAudioReplayService, - meetingAudioFileService, - fastApiTranscribeClient, - applicationRepository, - applicationBaseRepository, - applicationTimelineRepository, - decisionBaseRepository, - decisionTimelineRepository, - decisionRepository, - meetingAnalysisRepository, - meetingMemberRepository, - meetingLiveMessageBundleService, - transactionTemplate, - new ObjectMapper() - ); - } - - @Test - void timelineMemberIdIsStoredDirectly() throws Exception { - Meeting meeting = meetingWithMembers(); - when(meetingMemberRepository.existsByMemberIdAndMeetingId(1L, 10L)).thenReturn(true); - when(meetingUseCase.findMeetingWithMembersById(10L)).thenReturn(meeting); - - TranscribeApplicationRunResponse response = response( - timeline(100L, 2L, "핵심", "발화"), - transcriptSegment(1L, 2L, "00:00:01", null, "안녕하세요"), - transcriptSegment(2L, null, "00:00:02", null, "스킵"), - transcriptSegment(3L, 999L, "00:00:03", null, "스킵2") - ); - - meetingAnalysisService.persistTestMeetingAnalysis(1L, 10L, MeetingRequest.MeetingAnalysisTestDTO.builder() - .isSuccess(true) - .code("OK") - .message("ok") - .result(response) - .build()); - - @SuppressWarnings("unchecked") - var timelineCaptor = org.mockito.ArgumentCaptor.forClass(List.class); - verify(decisionTimelineRepository).saveAllAndFlush(timelineCaptor.capture()); - assertThat(timelineCaptor.getValue()).hasSize(1); - assertThat(((DecisionTimeline) timelineCaptor.getValue().get(0)).getMemberId()).isEqualTo(2L); - } - - @Test - void segmentMemberIdIsUsedAndInvalidSegmentsAreSkipped() throws Exception { - Meeting meeting = meetingWithMembers(); - when(meetingMemberRepository.existsByMemberIdAndMeetingId(1L, 10L)).thenReturn(true); - when(meetingUseCase.findMeetingWithMembersById(10L)).thenReturn(meeting); - - TranscribeApplicationRunResponse response = response( - timeline(100L, 2L, "핵심", "발화"), - transcriptSegment(1L, 2L, "00:00:01", null, "memberId 우선"), - transcriptSegment(2L, null, "00:00:02", null, "skip"), - transcriptSegment(3L, 999L, "00:00:03", null, "skip2") - ); - - meetingAnalysisService.persistTestMeetingAnalysis(1L, 10L, MeetingRequest.MeetingAnalysisTestDTO.builder() - .isSuccess(true) - .code("OK") - .message("ok") - .result(response) - .build()); - - assertThat(meeting.getDialogues()).hasSize(1); - assertThat(meeting.getDialogues().get(0).getMember().getId()).isEqualTo(2L); - } - - private Meeting meetingWithMembers() { - Meeting meeting = Meeting.create( - MeetingRequest.MeetingCreateDTO.builder().name("회의").build(), - mock(Team.class) - ); - ReflectionTestUtils.setField(meeting, "id", 10L); - ReflectionTestUtils.setField(meeting, "startDateTime", LocalDateTime.of(2026, 1, 1, 9, 0, 0)); - - meeting.getMeetingMembers().add(MeetingMember.create(meeting, member(1L, "first"), MeetingRole.OWNER)); - meeting.getMeetingMembers().add(MeetingMember.create(meeting, member(2L, "second"), MeetingRole.GENERAL)); - return meeting; - } - - private Member member(Long id, String name) { - Member member = Member.builder() - .name(name) - .email(name + "@example.com") - .password("pw") - .role(Role.USER) - .build(); - ReflectionTestUtils.setField(member, "id", id); - return member; - } - - private TranscribeApplicationRunResponse response(TranscribeApplicationRunResponse.ApplicationResponse applicationResponse, - TranscribeApplicationRunResponse.TranscriptSegmentResponse... segments) { - return new TranscribeApplicationRunResponse( - "run-1", - "completed", - "applications_ready", - "10", - null, - null, - null, - null, - null, - new TranscribeApplicationRunResponse.TranscribeApplicationRunResult( - "10", - null, - List.of(segments), - new TranscribeApplicationRunResponse.AnalysisResultResponse( - new TranscribeApplicationRunResponse.OverallAnalysisResponse( - new TranscribeApplicationRunResponse.MeetingInfoResponse("제목", "목적", "00:10:00"), - List.of("토픽"), - List.of("맥락"), - List.of("적용사항"), - List.of("사유") - ), - List.of(applicationResponse), - List.of() - ) - ) - ); - } - - private TranscribeApplicationRunResponse.ApplicationResponse timeline(Long applicationId, - Long memberId, - String content, - String utterance) { - return new TranscribeApplicationRunResponse.ApplicationResponse( - applicationId, - "적용사항", - List.of("사유"), - List.of(new TranscribeApplicationRunResponse.TimelineResponse( - "00:01:00", - "step", - memberId, - content, - utterance - )) - ); - } - - private TranscribeApplicationRunResponse.TranscriptSegmentResponse transcriptSegment(Long messageId, - Long memberId, - String startTime, - String endTime, - String text) { - return new TranscribeApplicationRunResponse.TranscriptSegmentResponse( - messageId, - memberId, - startTime, - endTime, - text, - true - ); - } -} diff --git a/src/test/java/com/whylog/server/domain/meeting/service/MeetingLiveMessageBundleServiceTest.java b/src/test/java/com/whylog/server/domain/meeting/service/MeetingLiveMessageBundleServiceTest.java deleted file mode 100644 index 5b86f3d..0000000 --- a/src/test/java/com/whylog/server/domain/meeting/service/MeetingLiveMessageBundleServiceTest.java +++ /dev/null @@ -1,71 +0,0 @@ -package com.whylog.server.domain.meeting.service; - -import static org.assertj.core.api.Assertions.assertThat; - -import com.fasterxml.jackson.databind.ObjectMapper; -import com.whylog.server.domain.meeting.entity.Meeting; -import com.whylog.server.domain.meeting.socket.message.LiveMessageEntry; -import com.whylog.server.domain.meeting.socket.repository.MeetingLiveMessageRepository; -import com.whylog.server.domain.team.entity.Team; -import java.time.LocalDateTime; -import java.util.Map; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; -import org.springframework.test.util.ReflectionTestUtils; - -class MeetingLiveMessageBundleServiceTest { - - private final ObjectMapper objectMapper = new ObjectMapper(); - private MeetingLiveMessageRepository meetingLiveMessageRepository; - private MeetingLiveMessageBundleService meetingLiveMessageBundleService; - - @BeforeEach - void setUp() { - meetingLiveMessageRepository = new MeetingLiveMessageRepository(); - meetingLiveMessageBundleService = new MeetingLiveMessageBundleService(meetingLiveMessageRepository, objectMapper); - } - - @Test - void buildLiveMessagesJsonSerializesDrainedEntries() throws Exception { - Meeting meeting = meeting(); - meetingLiveMessageRepository.append( - meeting.getId(), - new LiveMessageEntry( - meeting.getId(), - 2L, - "발화자", - null, - "안녕하세요", - objectMapper.valueToTree(Map.of("foo", "bar")), - LocalDateTime.of(2026, 1, 1, 10, 2, 3) - ) - ); - - String json = meetingLiveMessageBundleService.buildLiveMessagesJson(meeting); - - assertThat(json).isNotBlank(); - assertThat(objectMapper.readTree(json)).hasSize(1); - assertThat(objectMapper.readTree(json).get(0).get("type").asText()).isEqualTo("TEXT"); - assertThat(objectMapper.readTree(json).get(0).get("timestamp").asText()).isEqualTo("01:02:03"); - assertThat(meetingLiveMessageRepository.drain(meeting.getId())).isEmpty(); - } - - @Test - void buildLiveMessagesJsonReturnsNullWhenNoEntriesExist() { - Meeting meeting = meeting(); - - String json = meetingLiveMessageBundleService.buildLiveMessagesJson(meeting); - - assertThat(json).isNull(); - } - - private Meeting meeting() { - Meeting meeting = Meeting.create( - com.whylog.server.domain.meeting.dto.MeetingRequest.MeetingCreateDTO.builder().name("회의").build(), - org.mockito.Mockito.mock(Team.class) - ); - ReflectionTestUtils.setField(meeting, "id", 123L); - ReflectionTestUtils.setField(meeting, "startDateTime", LocalDateTime.of(2026, 1, 1, 9, 0, 0)); - return meeting; - } -} diff --git a/src/test/java/com/whylog/server/domain/meeting/socket/MeetingSocketHandlerTest.java b/src/test/java/com/whylog/server/domain/meeting/socket/MeetingSocketHandlerTest.java deleted file mode 100644 index 9a02379..0000000 --- a/src/test/java/com/whylog/server/domain/meeting/socket/MeetingSocketHandlerTest.java +++ /dev/null @@ -1,152 +0,0 @@ -package com.whylog.server.domain.meeting.socket; - -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.ArgumentMatchers.eq; -import static org.mockito.Mockito.never; -import static org.mockito.Mockito.spy; -import static org.mockito.Mockito.verify; -import static org.mockito.Mockito.when; - -import com.fasterxml.jackson.databind.ObjectMapper; -import com.whylog.server.domain.meeting.socket.message.LiveMessageEntry; -import com.whylog.server.domain.meeting.socket.message.MeetingMessageType; -import com.whylog.server.domain.meeting.socket.message.MeetingSocketMessage; -import com.whylog.server.domain.meeting.socket.repository.MeetingLiveMessageRepository; -import com.whylog.server.domain.meeting.service.MeetingCommandService; -import com.whylog.server.global.util.json.JsonConverter; -import java.util.Map; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.extension.ExtendWith; -import org.mockito.Mock; -import org.mockito.junit.jupiter.MockitoExtension; -import org.springframework.web.socket.TextMessage; - -@ExtendWith(MockitoExtension.class) -class MeetingSocketHandlerTest { - - private final ObjectMapper objectMapper = new ObjectMapper(); - - @Mock - private MeetingSocketRoomService meetingSocketRoomService; - - @Mock - private MeetingCommandService meetingCommandService; - - private MeetingLiveMessageRepository meetingLiveMessageRepository; - private MeetingSocketHandler meetingSocketHandler; - - @BeforeEach - void setUp() { - meetingLiveMessageRepository = spy(new MeetingLiveMessageRepository()); - meetingSocketHandler = new MeetingSocketHandler( - meetingSocketRoomService, - meetingCommandService, - meetingLiveMessageRepository - ); - } - - @Test - void audioTextMessageIsStoredInMemory() throws Exception { - org.springframework.web.socket.WebSocketSession session = mockSession(); - MeetingSocketMessage message = new MeetingSocketMessage( - MeetingMessageType.AUDIO_TEXT, - null, - "안녕하세요", - null - ); - - meetingSocketHandler.handleTextMessage(session, new TextMessage(JsonConverter.toJson(message))); - - verify(meetingLiveMessageRepository).append(eq(1L), any(LiveMessageEntry.class)); - verify(meetingSocketRoomService).broadcastText(eq(1L), any(String.class)); - } - - @Test - void chatAndSpeechAreNotStoredInMemory() throws Exception { - org.springframework.web.socket.WebSocketSession chatSession = mockSession(); - org.springframework.web.socket.WebSocketSession speechSession = mockSession(); - - meetingSocketHandler.handleTextMessage( - chatSession, - new TextMessage(JsonConverter.toJson(new MeetingSocketMessage(MeetingMessageType.CHAT, null, "chat", null))) - ); - meetingSocketHandler.handleTextMessage( - speechSession, - new TextMessage(JsonConverter.toJson(new MeetingSocketMessage(MeetingMessageType.SPEECH, null, "speech", null))) - ); - - verify(meetingLiveMessageRepository, never()).append(any(), any()); - } - - @Test - void interimAudioTextIsNotStoredInMemory() throws Exception { - org.springframework.web.socket.WebSocketSession session = mockSession(); - MeetingSocketMessage message = new MeetingSocketMessage( - MeetingMessageType.AUDIO_TEXT, - null, - "감", - objectMapper.valueToTree(Map.of("is_final", false)) - ); - - meetingSocketHandler.handleTextMessage(session, new TextMessage(JsonConverter.toJson(message))); - - verify(meetingLiveMessageRepository, never()).append(any(), any()); - } - - @Test - void finalAudioTextIsStoredInMemory() throws Exception { - org.springframework.web.socket.WebSocketSession session = mockSession(); - MeetingSocketMessage message = new MeetingSocketMessage( - MeetingMessageType.AUDIO_TEXT, - null, - "감사합니다", - objectMapper.valueToTree(Map.of("is_final", true)) - ); - - meetingSocketHandler.handleTextMessage(session, new TextMessage(JsonConverter.toJson(message))); - - verify(meetingLiveMessageRepository).append(eq(1L), any(LiveMessageEntry.class)); - } - - @Test - void audioTextWithoutIsFinalFieldIsStoredInMemory() throws Exception { - org.springframework.web.socket.WebSocketSession session = mockSession(); - MeetingSocketMessage message = new MeetingSocketMessage( - MeetingMessageType.AUDIO_TEXT, - null, - "감사합니다", - objectMapper.valueToTree(Map.of("confidence", 0.9)) - ); - - meetingSocketHandler.handleTextMessage(session, new TextMessage(JsonConverter.toJson(message))); - - verify(meetingLiveMessageRepository).append(eq(1L), any(LiveMessageEntry.class)); - } - - @Test - void audioTextWithNullPayloadIsStoredInMemory() throws Exception { - org.springframework.web.socket.WebSocketSession session = mockSession(); - MeetingSocketMessage message = new MeetingSocketMessage( - MeetingMessageType.AUDIO_TEXT, - null, - "감사합니다", - null - ); - - meetingSocketHandler.handleTextMessage(session, new TextMessage(JsonConverter.toJson(message))); - - verify(meetingLiveMessageRepository).append(eq(1L), any(LiveMessageEntry.class)); - } - - private org.springframework.web.socket.WebSocketSession mockSession() { - org.springframework.web.socket.WebSocketSession session = org.mockito.Mockito.mock(org.springframework.web.socket.WebSocketSession.class); - when(session.getId()).thenReturn("session-1"); - when(session.getAttributes()).thenReturn(Map.of( - MeetingSocketAuthInterceptor.MEETING_ID_ATTRIBUTE, 1L, - MeetingSocketAuthInterceptor.MEMBER_ID_ATTRIBUTE, 10L, - MeetingSocketAuthInterceptor.MEMBER_NAME_ATTRIBUTE, "홍길동" - )); - return session; - } -} diff --git a/src/test/java/com/whylog/server/domain/meeting/socket/repository/MeetingLiveMessageRepositoryTest.java b/src/test/java/com/whylog/server/domain/meeting/socket/repository/MeetingLiveMessageRepositoryTest.java deleted file mode 100644 index 3304c77..0000000 --- a/src/test/java/com/whylog/server/domain/meeting/socket/repository/MeetingLiveMessageRepositoryTest.java +++ /dev/null @@ -1,78 +0,0 @@ -package com.whylog.server.domain.meeting.socket.repository; - -import static org.assertj.core.api.Assertions.assertThat; - -import com.fasterxml.jackson.databind.ObjectMapper; -import com.whylog.server.domain.meeting.socket.message.LiveMessageEntry; -import java.time.LocalDateTime; -import java.util.Map; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; - -class MeetingLiveMessageRepositoryTest { - - private final ObjectMapper objectMapper = new ObjectMapper(); - private MeetingLiveMessageRepository repository; - - @BeforeEach - void setUp() { - repository = new MeetingLiveMessageRepository(); - } - - @Test - void appendingSameSpeakerAndSameTextConsecutivelyKeepsOneEntry() { - LiveMessageEntry first = entry(1L, 10L, "감사합니다"); - LiveMessageEntry duplicate = entry(1L, 10L, "감사합니다"); - - repository.append(1L, first); - repository.append(1L, duplicate); - - assertThat(repository.drain(1L)).hasSize(1); - } - - @Test - void sameTextFromDifferentSpeakerIsStoredSeparately() { - repository.append(1L, entry(1L, 10L, "감사합니다")); - repository.append(1L, entry(1L, 11L, "감사합니다")); - - assertThat(repository.drain(1L)).hasSize(2); - } - - @Test - void sameSpeakerWithDifferentTextIsStoredSeparately() { - repository.append(1L, entry(1L, 10L, "감사합니다")); - repository.append(1L, entry(1L, 10L, "네")); - - assertThat(repository.drain(1L)).hasSize(2); - } - - @Test - void dedupOnlyAppliesToImmediatelyPreviousEntry() { - repository.append(1L, entry(1L, 10L, "감사합니다")); - repository.append(1L, entry(1L, 11L, "네")); - repository.append(1L, entry(1L, 10L, "감사합니다")); - - assertThat(repository.drain(1L)).hasSize(3); - } - - @Test - void drainResetsDedupState() { - repository.append(1L, entry(1L, 10L, "감사합니다")); - assertThat(repository.drain(1L)).hasSize(1); - - repository.append(1L, entry(1L, 10L, "감사합니다")); - assertThat(repository.drain(1L)).hasSize(1); - } - - private LiveMessageEntry entry(Long meetingId, Long fromMemberId, String text) { - return new LiveMessageEntry( - meetingId, - fromMemberId, - "홍길동", - null, - text, - objectMapper.valueToTree(Map.of("is_final", true)), - LocalDateTime.of(2026, 1, 1, 10, 0, 0) - ); - } -} From 4b1d134b261123c61686071c3f1bd2c4f22ffe81 Mon Sep 17 00:00:00 2001 From: junyong Date: Mon, 11 May 2026 00:35:23 +0900 Subject: [PATCH 6/9] =?UTF-8?q?feat:=20=EC=A0=81=EC=9A=A9=EC=82=AC?= =?UTF-8?q?=ED=95=AD=20fastapi=20=EC=9E=84=EB=B2=A0=EB=94=A9=20=EC=A0=80?= =?UTF-8?q?=EC=9E=A5=20=ED=98=B8=EC=B6=9C=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../service/MeetingAnalysisService.java | 107 +++++++++++++++++- .../client/FastApiMeetingAnalysisClient.java | 6 +- .../request/ApplicationEmbeddingsRequest.java | 66 +++++++++++ .../ApplicationEmbeddingsResponse.java | 39 +++++++ 4 files changed, 209 insertions(+), 9 deletions(-) create mode 100644 src/main/java/com/whylog/server/global/external/fast/dto/request/ApplicationEmbeddingsRequest.java create mode 100644 src/main/java/com/whylog/server/global/external/fast/dto/response/ApplicationEmbeddingsResponse.java diff --git a/src/main/java/com/whylog/server/domain/meeting/service/MeetingAnalysisService.java b/src/main/java/com/whylog/server/domain/meeting/service/MeetingAnalysisService.java index 1cc6bb7..59665ab 100644 --- a/src/main/java/com/whylog/server/domain/meeting/service/MeetingAnalysisService.java +++ b/src/main/java/com/whylog/server/domain/meeting/service/MeetingAnalysisService.java @@ -25,7 +25,10 @@ import com.whylog.server.domain.meeting.repository.MeetingMemberRepository; import com.whylog.server.domain.user.entity.Member; import com.whylog.server.global.external.fast.client.FastApiTranscribeClient; +import com.whylog.server.global.external.fast.client.FastApiMeetingAnalysisClient; import com.whylog.server.global.external.fast.dto.FastApiResponse; +import com.whylog.server.global.external.fast.dto.request.ApplicationEmbeddingsRequest; +import com.whylog.server.global.external.fast.dto.response.ApplicationEmbeddingsResponse; import com.whylog.server.global.external.fast.dto.response.TranscribeApplicationRunCreateResponse; import com.whylog.server.global.external.fast.dto.response.TranscribeApplicationRunResponse; import com.whylog.server.global.external.fast.exception.FastApiErrorCode; @@ -62,6 +65,7 @@ public class MeetingAnalysisService { private final MeetingAudioReplayService meetingAudioReplayService; private final MeetingAudioFileService meetingAudioFileService; private final FastApiTranscribeClient fastApiTranscribeClient; + private final FastApiMeetingAnalysisClient fastApiMeetingAnalysisClient; private final ApplicationRepository applicationRepository; private final ApplicationBaseRepository applicationBaseRepository; private final ApplicationTimelineRepository applicationTimelineRepository; @@ -220,7 +224,7 @@ private void persistMeetingAnalysis(Meeting meeting, TranscribeApplicationRunRes analysisResult != null && analysisResult.applications() != null ? analysisResult.applications() : List.of(); MeetingAnalysis.MeetingAnalysisPayload payload = buildMeetingAnalysisPayload(overallAnalysis); - transactionTemplate.executeWithoutResult(status -> { + SavedApplications savedApplications = transactionTemplate.execute(status -> { Meeting managedMeeting = meetingUseCase.findMeetingWithMembersById(meeting.getId()); MeetingAnalysis meetingAnalysis = meetingAnalysisRepository.findByMeetingId(managedMeeting.getId()) .map(existingMeetingAnalysis -> { @@ -236,11 +240,15 @@ private void persistMeetingAnalysis(Meeting meeting, TranscribeApplicationRunRes managedMeeting.getDialogues().addAll(dialogues); Decision decision = createDecisionIfAbsent(managedMeeting); - replaceApplications(managedMeeting.getId(), decision, applications); + return replaceApplications(managedMeeting.getId(), decision, applications); }); + if (savedApplications == null) { + savedApplications = SavedApplications.empty(); + } log.info("회의 오디오 분석 저장 완료: meetingId={}, transcriptSegmentCount={}", meeting.getId(), transcriptSegments.size()); - // TODO: applications 저장 후 applicationId를 발급해 /api/meeting-analysis/embeddings로 전달한다. + sendApplicationEmbeddingsSafely(meeting, response, savedApplications); + } // Decision이 없을 때 새로 생성한다. @@ -254,9 +262,9 @@ private Decision createDecisionIfAbsent(Meeting meeting) { } // 분석 결과의 적용사항 제목 목록을 저장한다. - private void replaceApplications(Long meetingId, - Decision decision, - List applications) { + private SavedApplications replaceApplications(Long meetingId, + Decision decision, + List applications) { applicationBaseRepository.deleteByMeetingId(meetingId); applicationTimelineRepository.deleteByMeetingId(meetingId); decisionBaseRepository.deleteByMeetingId(meetingId); @@ -281,7 +289,10 @@ private void replaceApplications(Long meetingId, persistApplicationDetails(savedApplications, validApplications); log.info("적용사항 저장 완료: meetingId={}, decisionId={}, applicationCount={}", meetingId, decision.getId(), newApplications.size()); + return new SavedApplications(savedApplications, validApplications); } + + return SavedApplications.empty(); } // 저장된 적용사항 엔티티에 reason/timeline 세부 정보를 순서대로 연결 저장한다. @@ -343,6 +354,82 @@ private void persistApplicationTimelines(Application application, applicationTimelineRepository.saveAllAndFlush(applicationTimelines); } + // 저장된 적용사항을 FastAPI 임베딩 요청으로 전달한다. + private void sendApplicationEmbeddingsSafely(Meeting meeting, + TranscribeApplicationRunResponse runResponse, + SavedApplications savedApplications) { + if (savedApplications.savedApplications().isEmpty()) { + log.info("저장된 적용사항이 없어 임베딩 호출을 생략한다: meetingId={}", meeting.getId()); + return; + } + + try { + ApplicationEmbeddingsRequest request = buildEmbeddingsRequest(meeting, runResponse, savedApplications); + FastApiResponse response = fastApiMeetingAnalysisClient.createApplicationEmbeddings(request); + Integer totalDocuments = response != null && response.result() != null ? response.result().totalDocuments() : null; + log.info("적용사항 임베딩 저장 완료: meetingId={}, totalDocuments={}", meeting.getId(), totalDocuments); + } catch (Exception exception) { + log.error("적용사항 임베딩 호출 실패: meetingId={}", meeting.getId(), exception); + } + } + + // FastAPI 임베딩 요청을 생성한다. + private ApplicationEmbeddingsRequest buildEmbeddingsRequest(Meeting meeting, + TranscribeApplicationRunResponse runResponse, + SavedApplications savedApplications) { + TranscribeApplicationRunResponse.TranscribeApplicationRunResult runResult = runResponse.result(); + TranscribeApplicationRunResponse.AnalysisResultResponse analysisResult = + runResult != null ? runResult.analysisResult() : null; + TranscribeApplicationRunResponse.OverallAnalysisResponse overallAnalysis = + analysisResult != null ? analysisResult.overallAnalysis() : null; + List otherMentions = + analysisResult != null && analysisResult.otherMentions() != null ? analysisResult.otherMentions() : List.of(); + + List applicationPayloads = new ArrayList<>(); + List applications = savedApplications.savedApplications(); + List sourceApplications = savedApplications.sourceApplications(); + for (int index = 0; index < applications.size(); index++) { + Application savedApplication = applications.get(index); + TranscribeApplicationRunResponse.ApplicationResponse sourceApplication = sourceApplications.get(index); + applicationPayloads.add(new ApplicationEmbeddingsRequest.ApplicationPayload( + savedApplication.getId(), + sourceApplication.applicationTitle(), + sourceApplication.applicationReasons(), + mapTimelinePayloads(sourceApplication.timeline()) + )); + } + + return new ApplicationEmbeddingsRequest( + String.valueOf(meeting.getId()), + null, + new ApplicationEmbeddingsRequest.AnalysisResultPayload( + overallAnalysis, + applicationPayloads, + otherMentions + ) + ); + } + + // FastAPI 응답 타임라인을 임베딩 요청 payload로 변환한다. + private List mapTimelinePayloads( + List timelines + ) { + if (timelines == null || timelines.isEmpty()) { + return List.of(); + } + + return timelines.stream() + .filter(timeline -> timeline != null) + .map(timeline -> new ApplicationEmbeddingsRequest.TimelinePayload( + timeline.timestamp(), + timeline.step(), + timeline.memberId(), + timeline.content(), + timeline.utterance() + )) + .toList(); + } + // null 이거나 비어 있는 문자열을 제외한 값만 반환한다. private List safeStrings(List values) { if (values == null || values.isEmpty()) { @@ -478,6 +565,14 @@ private Duration parseDuration(String offset) { } } + private record SavedApplications(List savedApplications, + List sourceApplications) { + + private static SavedApplications empty() { + return new SavedApplications(List.of(), List.of()); + } + } + // FastAPI 응답의 result가 비어 있으면 예외를 던진다. private T requireResult(FastApiResponse response) { if (response == null || response.result() == null) { diff --git a/src/main/java/com/whylog/server/global/external/fast/client/FastApiMeetingAnalysisClient.java b/src/main/java/com/whylog/server/global/external/fast/client/FastApiMeetingAnalysisClient.java index 10f0003..68b08ac 100644 --- a/src/main/java/com/whylog/server/global/external/fast/client/FastApiMeetingAnalysisClient.java +++ b/src/main/java/com/whylog/server/global/external/fast/client/FastApiMeetingAnalysisClient.java @@ -1,11 +1,11 @@ package com.whylog.server.global.external.fast.client; import com.fasterxml.jackson.databind.JsonNode; -import java.util.Map; - import com.whylog.server.global.external.fast.FastApiInfo; import com.whylog.server.global.external.fast.dto.FastApiResponse; +import com.whylog.server.global.external.fast.dto.request.ApplicationEmbeddingsRequest; import com.whylog.server.global.external.fast.dto.request.MeetingAnalysisRequest; +import com.whylog.server.global.external.fast.dto.response.ApplicationEmbeddingsResponse; import org.springframework.core.ParameterizedTypeReference; import org.springframework.stereotype.Component; import org.springframework.web.client.RestClient; @@ -26,7 +26,7 @@ public FastApiResponse extractMeetingAnalysis(MeetingAnalysisRequest r ); } - public FastApiResponse createApplicationEmbeddings(Map request) { + public FastApiResponse createApplicationEmbeddings(ApplicationEmbeddingsRequest request) { return postJson( FastApiInfo.MEETING_ANALYSIS_EMBEDDINGS, request, diff --git a/src/main/java/com/whylog/server/global/external/fast/dto/request/ApplicationEmbeddingsRequest.java b/src/main/java/com/whylog/server/global/external/fast/dto/request/ApplicationEmbeddingsRequest.java new file mode 100644 index 0000000..ac7d584 --- /dev/null +++ b/src/main/java/com/whylog/server/global/external/fast/dto/request/ApplicationEmbeddingsRequest.java @@ -0,0 +1,66 @@ +package com.whylog.server.global.external.fast.dto.request; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.databind.PropertyNamingStrategies; +import com.fasterxml.jackson.databind.annotation.JsonNaming; +import com.whylog.server.global.external.fast.dto.response.TranscribeApplicationRunResponse; +import io.swagger.v3.oas.annotations.media.Schema; +import java.util.List; + +@JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class) +@JsonIgnoreProperties(ignoreUnknown = true) +@Schema(description = "FastAPI 회의 분석 임베딩 요청") +public record ApplicationEmbeddingsRequest( + @Schema(description = "회의 ID", nullable = true) + String meetingId, + @Schema(description = "프로젝트 ID", nullable = true) + String projectId, + @Schema(description = "회의 분석 결과", nullable = true) + AnalysisResultPayload analysisResult +) { + + @JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class) + @JsonIgnoreProperties(ignoreUnknown = true) + @Schema(description = "회의 분석 결과 payload") + public record AnalysisResultPayload( + @Schema(description = "전체 분석 결과", nullable = true) + TranscribeApplicationRunResponse.OverallAnalysisResponse overallAnalysis, + @Schema(description = "적용사항 목록", nullable = true) + List applications, + @Schema(description = "기타 언급 목록", nullable = true) + List otherMentions + ) { + } + + @JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class) + @JsonIgnoreProperties(ignoreUnknown = true) + @Schema(description = "적용사항 payload") + public record ApplicationPayload( + @Schema(description = "적용사항 ID", nullable = true) + Long applicationId, + @Schema(description = "적용사항 제목", nullable = true) + String applicationTitle, + @Schema(description = "적용사항 사유 목록", nullable = true) + List applicationReasons, + @Schema(description = "적용사항 타임라인", nullable = true) + List timeline + ) { + } + + @JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class) + @JsonIgnoreProperties(ignoreUnknown = true) + @Schema(description = "적용사항 타임라인 payload") + public record TimelinePayload( + @Schema(description = "타임스탬프", nullable = true) + String timestamp, + @Schema(description = "단계", nullable = true) + String step, + @Schema(description = "발화자 멤버 ID", nullable = true) + Long memberId, + @Schema(description = "내용", nullable = true) + String content, + @Schema(description = "원문 발화", nullable = true) + String utterance + ) { + } +} diff --git a/src/main/java/com/whylog/server/global/external/fast/dto/response/ApplicationEmbeddingsResponse.java b/src/main/java/com/whylog/server/global/external/fast/dto/response/ApplicationEmbeddingsResponse.java new file mode 100644 index 0000000..070436a --- /dev/null +++ b/src/main/java/com/whylog/server/global/external/fast/dto/response/ApplicationEmbeddingsResponse.java @@ -0,0 +1,39 @@ +package com.whylog.server.global.external.fast.dto.response; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.databind.PropertyNamingStrategies; +import com.fasterxml.jackson.databind.annotation.JsonNaming; +import io.swagger.v3.oas.annotations.media.Schema; +import java.util.List; + +@JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class) +@JsonIgnoreProperties(ignoreUnknown = true) +@Schema(description = "FastAPI 회의 분석 임베딩 응답") +public record ApplicationEmbeddingsResponse( + @Schema(description = "회의 ID", nullable = true) + String meetingId, + @Schema(description = "프로젝트 ID", nullable = true) + String projectId, + @Schema(description = "문서 수", nullable = true) + Integer totalDocuments, + @Schema(description = "문서 ID 목록", nullable = true) + List documentIds, + @Schema(description = "문서 목록", nullable = true) + List documents +) { + + @JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class) + @JsonIgnoreProperties(ignoreUnknown = true) + @Schema(description = "임베딩 문서") + public record DocumentResponse( + @Schema(description = "문서 ID", nullable = true) + String documentId, + @Schema(description = "문서 본문", nullable = true) + String text, + @Schema(description = "적용사항 ID", nullable = true) + Long applicationId, + @Schema(description = "적용사항 제목", nullable = true) + String applicationTitle + ) { + } +} From caa84de9c2c4c2ee0dc9d3fd451c1d7373df3989 Mon Sep 17 00:00:00 2001 From: junyong Date: Mon, 11 May 2026 01:39:28 +0900 Subject: [PATCH 7/9] =?UTF-8?q?refactor:=20meetinusecase=EC=97=90=EC=84=9C?= =?UTF-8?q?=20=EC=A1=B0=ED=9A=8C=20=EA=B0=80=EB=8A=A5=ED=95=9C=20=ED=9A=8C?= =?UTF-8?q?=EC=9D=98=EB=A7=8C=20=EC=A1=B0=ED=9A=8C=ED=95=98=EB=8F=84?= =?UTF-8?q?=EB=A1=9D=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../meeting/service/MeetingUseCase.java | 43 +++++++++++++++++-- 1 file changed, 39 insertions(+), 4 deletions(-) diff --git a/src/main/java/com/whylog/server/domain/meeting/service/MeetingUseCase.java b/src/main/java/com/whylog/server/domain/meeting/service/MeetingUseCase.java index 9ade6aa..c7ce42d 100644 --- a/src/main/java/com/whylog/server/domain/meeting/service/MeetingUseCase.java +++ b/src/main/java/com/whylog/server/domain/meeting/service/MeetingUseCase.java @@ -2,9 +2,11 @@ import com.whylog.server.domain.meeting.entity.Meeting; import com.whylog.server.domain.meeting.entity.MeetingMember; +import com.whylog.server.domain.meeting.exception.MeetingErrorCode; import com.whylog.server.domain.meeting.exception.MeetingNotFoundException; import com.whylog.server.domain.meeting.repository.MeetingRepository; import com.whylog.server.domain.user.entity.Member; +import com.whylog.server.global.apiPayload.exception.GeneralException; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; @@ -17,17 +19,23 @@ public class MeetingUseCase{ private final MeetingRepository meetingRepository; public Meeting findMeetingById(Long id) { - return meetingRepository.findById(id) + Meeting meeting = meetingRepository.findById(id) .orElseThrow(MeetingNotFoundException::new); + checkMeeting(meeting); + return meeting; } public Meeting findMeetingWithMembersById(Long id) { - return meetingRepository.findWithMembers(id) + Meeting meeting = meetingRepository.findWithMembers(id) .orElseThrow(MeetingNotFoundException::new); + checkMeeting(meeting); + return meeting; } public List findMeetingByTeamId(Long teamId) { - return meetingRepository.findWithAnalysis(teamId); + List meetings = meetingRepository.findWithAnalysis(teamId); + checkMeeting(meetings); + return meetings; } // 회의 참여자 수 @@ -43,7 +51,34 @@ public List getParticipantsInfo(Meeting meeting) { } public Meeting findWithAnalysisByMeetingId(Long meetingId) { - return meetingRepository.findByMeetingId(meetingId) + Meeting meeting = meetingRepository.findByMeetingId(meetingId) .orElseThrow(MeetingNotFoundException::new); + checkMeeting(meeting); + return meeting; } + + public Meeting findWithDialogue(Long meetingId) { + Meeting meeting = meetingRepository.findByMeetingId(meetingId) + .orElseThrow(MeetingNotFoundException::new); + checkMeeting(meeting); + return meeting; + } + + private void checkMeeting(Meeting meeting) { + if (meeting.getEndDateTime() == null) { + throw new GeneralException(MeetingErrorCode.MEETING_NOT_END); + } + + if ( !meeting.getIsNormallyEnded() ){ + throw new GeneralException(MeetingErrorCode.MEETING_UNNORMAL_END); + } + } + + private void checkMeeting(List meetings) { + meetings.removeIf(meeting -> + meeting.getEndDateTime() == null + || !meeting.getIsNormallyEnded() + ); + } + } From a72dcdace666adec5650f6a1f12165149d10acad Mon Sep 17 00:00:00 2001 From: junyong Date: Mon, 11 May 2026 01:39:39 +0900 Subject: [PATCH 8/9] =?UTF-8?q?feat:=20=ED=9A=8C=EC=9D=98=20=EB=8C=80?= =?UTF-8?q?=ED=99=94=EB=82=B4=EC=97=AD=20=EC=A1=B0=ED=9A=8C=20api=20?= =?UTF-8?q?=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../meeting/controller/MeetingController.java | 6 ++- .../domain/meeting/dto/MeetingResponse.java | 54 +++++++++++++++++++ .../meeting/exception/MeetingErrorCode.java | 2 + .../meeting/repository/MeetingRepository.java | 8 +++ .../meeting/service/MeetingQueryService.java | 16 ++++++ 5 files changed, 84 insertions(+), 2 deletions(-) diff --git a/src/main/java/com/whylog/server/domain/meeting/controller/MeetingController.java b/src/main/java/com/whylog/server/domain/meeting/controller/MeetingController.java index 45c1504..160b3ec 100644 --- a/src/main/java/com/whylog/server/domain/meeting/controller/MeetingController.java +++ b/src/main/java/com/whylog/server/domain/meeting/controller/MeetingController.java @@ -190,11 +190,13 @@ public ApiResponse getMeetingDetail( @ApiErrorCodeExamples({ @ApiErrorCodeExample(value = ErrorStatus.class, name = "_UNAUTHORIZED"), @ApiErrorCodeExample(value = ErrorStatus.class, name = "_BAD_REQUEST"), - @ApiErrorCodeExample(value = MeetingErrorCode.class, name = "MEETING_NOT_FOUND") + @ApiErrorCodeExample(value = MeetingErrorCode.class, name = "MEETING_NOT_FOUND"), + @ApiErrorCodeExample(value = MeetingErrorCode.class, name = "MEETING_NOT_END"), + @ApiErrorCodeExample(value = MeetingErrorCode.class, name = "MEETING_UNNORMAL_END") }) public ApiResponse getHistory( @PathVariable Long meetingId) { - return ApiResponse.onSuccess(null); + return ApiResponse.onSuccess(meetingQueryService.getDialogueHistory(meetingId)); } @GetMapping("/meetings/{meetingId}/analysis") diff --git a/src/main/java/com/whylog/server/domain/meeting/dto/MeetingResponse.java b/src/main/java/com/whylog/server/domain/meeting/dto/MeetingResponse.java index d5d77e7..97e9951 100644 --- a/src/main/java/com/whylog/server/domain/meeting/dto/MeetingResponse.java +++ b/src/main/java/com/whylog/server/domain/meeting/dto/MeetingResponse.java @@ -1,13 +1,17 @@ package com.whylog.server.domain.meeting.dto; +import com.whylog.server.domain.meeting.entity.Dialogue; +import com.whylog.server.domain.meeting.entity.Meeting; import com.whylog.server.domain.meeting.entity.MeetingAnalysis; import com.whylog.server.domain.meeting.enums.MeetingStatus; import com.whylog.server.domain.meeting.socket.MeetingParticipant; +import com.whylog.server.domain.user.entity.Member; import io.swagger.v3.oas.annotations.media.Schema; import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Getter; import lombok.NoArgsConstructor; +import java.time.Duration; import java.time.LocalDateTime; import java.util.List; @@ -191,6 +195,16 @@ public static class ParticipantDTO { @Schema(description = "참여자 프로필 사진 URL", example = "https://example.com/profile/user-1.jpg") private String profileImage; + + public static List create(List members) { + return members.stream().map(member -> ParticipantDTO.builder() + .memberId(member.getId()) + .name(member.getName()) + .profileImage(member.getProfileImage()) + .build() + ).toList(); + } + } @Getter @@ -208,7 +222,47 @@ public static class DialogueDTO { @Schema(description = "말한 시간", example = "00:22") private String timestamp; + + public static List create(Meeting meeting, List dialogues) { + LocalDateTime startDateTime = meeting.getStartDateTime(); + + return dialogues.stream().map(dialogue -> DialogueDTO.builder() + .memberId(dialogue.getMember().getId()) + .content(dialogue.getContent()) + .timestamp(formatElapsed(startDateTime, dialogue.getSpeechDateTime())) + .build() + ).toList(); + } + + private static String formatElapsed(LocalDateTime startDateTime, LocalDateTime speechDateTime) { + if (startDateTime == null || speechDateTime == null) { + return null; + } + + Duration elapsed = Duration.between(startDateTime, speechDateTime); + if (elapsed.isNegative()) { + return "00:00"; + } + + long totalSeconds = elapsed.getSeconds(); + long minutes = totalSeconds / 60; + long seconds = totalSeconds % 60; + return String.format("%02d:%02d", minutes, seconds); + } + + } + + public static HistoryListDTO create(Meeting meeting, List dialogues, List participants ) { + + List participantDtoList = ParticipantDTO.create(participants); + List dialogueDtoList = DialogueDTO.create(meeting, dialogues); + return HistoryListDTO.builder() + .participants(participantDtoList) + .dialogues(dialogueDtoList) + .build(); + } + } @Getter diff --git a/src/main/java/com/whylog/server/domain/meeting/exception/MeetingErrorCode.java b/src/main/java/com/whylog/server/domain/meeting/exception/MeetingErrorCode.java index 31b10ff..470e677 100644 --- a/src/main/java/com/whylog/server/domain/meeting/exception/MeetingErrorCode.java +++ b/src/main/java/com/whylog/server/domain/meeting/exception/MeetingErrorCode.java @@ -16,6 +16,8 @@ public enum MeetingErrorCode implements BaseErrorCode { MEETING_NOT_OWNER(HttpStatus.FORBIDDEN, "MEETING_403", "회의 삭제 권한이 없습니다."), MEETING_AUDIO_NOT_READY(HttpStatus.CONFLICT, "MEETING_411", "회의 녹음본이 아직 생성되지 않았거나 업로드가 완료되지 않았습니다."), MEETING_ALREADY_PARTICIPATING(HttpStatus.CONFLICT, "MEETING_412", "이미 실시간으로 참여 중인 회의입니다."), + MEETING_NOT_END(HttpStatus.CONFLICT, "MEETING413", "아직 종료되지 않은 회의입니다."), + MEETING_UNNORMAL_END(HttpStatus.CONFLICT, "MEETING414", "비정상 종료된 회의입니다."), ; private final HttpStatus httpStatus; diff --git a/src/main/java/com/whylog/server/domain/meeting/repository/MeetingRepository.java b/src/main/java/com/whylog/server/domain/meeting/repository/MeetingRepository.java index 7982b49..9eeefa0 100644 --- a/src/main/java/com/whylog/server/domain/meeting/repository/MeetingRepository.java +++ b/src/main/java/com/whylog/server/domain/meeting/repository/MeetingRepository.java @@ -59,5 +59,13 @@ public interface MeetingRepository extends JpaRepository { """) int markAllOngoingMeetingsAsEnded(@Param("endedAt") LocalDateTime endedAt); + @Query(""" + SELECT m FROM Meeting m + LEFT JOIN FETCH m.dialogues d + LEFT JOIN FETCH m.meetingMembers mm + JOIN FETCH d.member dm + WHERE m.id = :meetingId + """) + Optional findWithDialogue(@Param("meetingId") Long meetingId); } diff --git a/src/main/java/com/whylog/server/domain/meeting/service/MeetingQueryService.java b/src/main/java/com/whylog/server/domain/meeting/service/MeetingQueryService.java index f2a1495..528460f 100644 --- a/src/main/java/com/whylog/server/domain/meeting/service/MeetingQueryService.java +++ b/src/main/java/com/whylog/server/domain/meeting/service/MeetingQueryService.java @@ -1,8 +1,10 @@ package com.whylog.server.domain.meeting.service; import com.whylog.server.domain.meeting.dto.MeetingResponse; +import com.whylog.server.domain.meeting.entity.Dialogue; import com.whylog.server.domain.meeting.entity.Meeting; import com.whylog.server.domain.meeting.entity.MeetingAnalysis; +import com.whylog.server.domain.meeting.entity.MeetingMember; import com.whylog.server.domain.meeting.enums.MeetingStatus; import com.whylog.server.domain.user.entity.Member; import com.whylog.server.global.apiPayload.exception.ParameterRequiredException; @@ -96,4 +98,18 @@ public MeetingResponse.AnalysisResultDTO getAnalysis(Long meetingId) { return MeetingResponse.AnalysisResultDTO.create(meetingAnalysis, audioDuration); } + @Transactional(readOnly = true) + public MeetingResponse.HistoryListDTO getDialogueHistory(Long meetingId) { + + // 회의 정보 조회 -> 대화 정보, 참여자 같이 조회 + Meeting meeting = meetingUseCase.findWithDialogue(meetingId); + List dialogues = meeting.getDialogues(); + List members = meeting.getMeetingMembers().stream() + .map(MeetingMember::getMember) + .toList(); + + // dto 생성 및 반환 + return MeetingResponse.HistoryListDTO.create(meeting, dialogues, members); + } + } From cf03829b055a955b31eac8ef05f7f479b08872fe Mon Sep 17 00:00:00 2001 From: junyong Date: Mon, 11 May 2026 05:51:52 +0900 Subject: [PATCH 9/9] =?UTF-8?q?fix:=20meetingusecase=EC=97=90=EC=84=9C=20c?= =?UTF-8?q?heckmeeting=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../meeting/service/MeetingUseCase.java | 36 +++---------------- 1 file changed, 5 insertions(+), 31 deletions(-) diff --git a/src/main/java/com/whylog/server/domain/meeting/service/MeetingUseCase.java b/src/main/java/com/whylog/server/domain/meeting/service/MeetingUseCase.java index c7ce42d..6b9a383 100644 --- a/src/main/java/com/whylog/server/domain/meeting/service/MeetingUseCase.java +++ b/src/main/java/com/whylog/server/domain/meeting/service/MeetingUseCase.java @@ -19,23 +19,17 @@ public class MeetingUseCase{ private final MeetingRepository meetingRepository; public Meeting findMeetingById(Long id) { - Meeting meeting = meetingRepository.findById(id) + return meetingRepository.findById(id) .orElseThrow(MeetingNotFoundException::new); - checkMeeting(meeting); - return meeting; } public Meeting findMeetingWithMembersById(Long id) { - Meeting meeting = meetingRepository.findWithMembers(id) + return meetingRepository.findWithMembers(id) .orElseThrow(MeetingNotFoundException::new); - checkMeeting(meeting); - return meeting; } public List findMeetingByTeamId(Long teamId) { - List meetings = meetingRepository.findWithAnalysis(teamId); - checkMeeting(meetings); - return meetings; + return meetingRepository.findWithAnalysis(teamId); } // 회의 참여자 수 @@ -51,34 +45,14 @@ public List getParticipantsInfo(Meeting meeting) { } public Meeting findWithAnalysisByMeetingId(Long meetingId) { - Meeting meeting = meetingRepository.findByMeetingId(meetingId) + return meetingRepository.findByMeetingId(meetingId) .orElseThrow(MeetingNotFoundException::new); - checkMeeting(meeting); - return meeting; } public Meeting findWithDialogue(Long meetingId) { - Meeting meeting = meetingRepository.findByMeetingId(meetingId) + return meetingRepository.findByMeetingId(meetingId) .orElseThrow(MeetingNotFoundException::new); - checkMeeting(meeting); - return meeting; } - private void checkMeeting(Meeting meeting) { - if (meeting.getEndDateTime() == null) { - throw new GeneralException(MeetingErrorCode.MEETING_NOT_END); - } - - if ( !meeting.getIsNormallyEnded() ){ - throw new GeneralException(MeetingErrorCode.MEETING_UNNORMAL_END); - } - } - - private void checkMeeting(List meetings) { - meetings.removeIf(meeting -> - meeting.getEndDateTime() == null - || !meeting.getIsNormallyEnded() - ); - } }