From 58a602b3c30eb32f033d591e0e7c3f085e0616db Mon Sep 17 00:00:00 2001 From: Kim Kyeongho Date: Mon, 1 Sep 2025 11:51:25 +0900 Subject: [PATCH 1/3] =?UTF-8?q?fix:=20TransactionalEventListener=EC=9D=84?= =?UTF-8?q?=20=EB=8F=84=EC=9E=85=ED=95=98=EC=97=AC=20=EB=A6=AC=ED=8C=A9?= =?UTF-8?q?=ED=86=A0=EB=A7=81=20#19?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../question/event/AnswerSavedEvent.java | 6 +++ .../question/event/FeedbackEventListener.java | 44 +++++++++++++++++++ .../service/impl/AnswerServiceImpl.java | 6 ++- 3 files changed, 55 insertions(+), 1 deletion(-) create mode 100644 src/main/java/com/project/InsightPrep/domain/question/event/AnswerSavedEvent.java create mode 100644 src/main/java/com/project/InsightPrep/domain/question/event/FeedbackEventListener.java diff --git a/src/main/java/com/project/InsightPrep/domain/question/event/AnswerSavedEvent.java b/src/main/java/com/project/InsightPrep/domain/question/event/AnswerSavedEvent.java new file mode 100644 index 0000000..ffa98f6 --- /dev/null +++ b/src/main/java/com/project/InsightPrep/domain/question/event/AnswerSavedEvent.java @@ -0,0 +1,6 @@ +package com.project.InsightPrep.domain.question.event; + +import com.project.InsightPrep.domain.question.entity.Answer; + +public record AnswerSavedEvent(Answer answer) { +} diff --git a/src/main/java/com/project/InsightPrep/domain/question/event/FeedbackEventListener.java b/src/main/java/com/project/InsightPrep/domain/question/event/FeedbackEventListener.java new file mode 100644 index 0000000..5a7c886 --- /dev/null +++ b/src/main/java/com/project/InsightPrep/domain/question/event/FeedbackEventListener.java @@ -0,0 +1,44 @@ +package com.project.InsightPrep.domain.question.event; + +import com.project.InsightPrep.domain.question.dto.response.FeedbackResponse; +import com.project.InsightPrep.domain.question.entity.Answer; +import com.project.InsightPrep.domain.question.entity.AnswerFeedback; +import com.project.InsightPrep.domain.question.mapper.FeedbackMapper; +import com.project.InsightPrep.global.gpt.prompt.PromptFactory; +import com.project.InsightPrep.global.gpt.service.GptResponseType; +import com.project.InsightPrep.global.gpt.service.GptService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.scheduling.annotation.Async; +import org.springframework.stereotype.Component; +import org.springframework.transaction.event.TransactionPhase; +import org.springframework.transaction.event.TransactionalEventListener; + +@Slf4j +@RequiredArgsConstructor +@Component +public class FeedbackEventListener { + + private final GptService gptService; + private final FeedbackMapper feedbackMapper; + + @Async + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) + public void handleAnswerSaved(AnswerSavedEvent event) { + Answer answer = event.answer(); + + String question = answer.getQuestion().getContent(); + String userAnswer = answer.getContent(); + + FeedbackResponse gptResult = gptService.callOpenAI(PromptFactory.forFeedbackGeneration(question, userAnswer), 1000, 0.4, GptResponseType.FEEDBACK); + AnswerFeedback feedback = AnswerFeedback.builder() + .answer(answer) + .score(gptResult.getScore()) + .modelAnswer(gptResult.getModelAnswer()) + .improvement(gptResult.getImprovement()) + .build(); + + feedbackMapper.insertFeedback(feedback); + log.info("Feedback saved for Answer id = {}", answer.getId()); + } +} diff --git a/src/main/java/com/project/InsightPrep/domain/question/service/impl/AnswerServiceImpl.java b/src/main/java/com/project/InsightPrep/domain/question/service/impl/AnswerServiceImpl.java index 188f9bb..5f02800 100644 --- a/src/main/java/com/project/InsightPrep/domain/question/service/impl/AnswerServiceImpl.java +++ b/src/main/java/com/project/InsightPrep/domain/question/service/impl/AnswerServiceImpl.java @@ -7,6 +7,7 @@ import com.project.InsightPrep.domain.question.entity.Answer; import com.project.InsightPrep.domain.question.entity.AnswerStatus; import com.project.InsightPrep.domain.question.entity.Question; +import com.project.InsightPrep.domain.question.event.AnswerSavedEvent; import com.project.InsightPrep.domain.question.exception.QuestionErrorCode; import com.project.InsightPrep.domain.question.exception.QuestionException; import com.project.InsightPrep.domain.question.mapper.AnswerMapper; @@ -16,6 +17,7 @@ import com.project.InsightPrep.global.auth.util.SecurityUtil; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import org.springframework.context.ApplicationEventPublisher; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -28,6 +30,7 @@ public class AnswerServiceImpl implements AnswerService { private final QuestionMapper questionMapper; private final AnswerMapper answerMapper; private final FeedbackService feedbackService; + private final ApplicationEventPublisher eventPublisher; @Override @Transactional @@ -43,7 +46,8 @@ public AnswerResponse.AnswerDto saveAnswer(AnswerDto dto, Long questionId) { questionMapper.updateStatus(questionId, AnswerStatus.ANSWERED.name()); answerMapper.insertAnswer(answer); - feedbackService.saveFeedback(answer); + //feedbackService.saveFeedback(answer); + eventPublisher.publishEvent(new AnswerSavedEvent(answer)); return AnswerResponse.AnswerDto.builder() .answerId(answer.getId()).build(); } From e63be37aa65417c1d61bc3af00a1600ac3ff499e Mon Sep 17 00:00:00 2001 From: Kim Kyeongho Date: Mon, 1 Sep 2025 11:51:47 +0900 Subject: [PATCH 2/3] =?UTF-8?q?fix:=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20?= =?UTF-8?q?=EC=BD=94=EB=93=9C=20=EC=9E=91=EC=84=B1=20#19?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../service/impl/FeedbackServiceImpl.java | 3 -- .../service/impl/AnswerServiceImplTest.java | 47 ++++++++++++++++--- 2 files changed, 40 insertions(+), 10 deletions(-) diff --git a/src/main/java/com/project/InsightPrep/domain/question/service/impl/FeedbackServiceImpl.java b/src/main/java/com/project/InsightPrep/domain/question/service/impl/FeedbackServiceImpl.java index 3178c1a..59ac5ed 100644 --- a/src/main/java/com/project/InsightPrep/domain/question/service/impl/FeedbackServiceImpl.java +++ b/src/main/java/com/project/InsightPrep/domain/question/service/impl/FeedbackServiceImpl.java @@ -30,9 +30,6 @@ public void saveFeedback(Answer answer) { String userAnswer = answer.getContent(); FeedbackResponse gptResult = gptService.callOpenAI(PromptFactory.forFeedbackGeneration(question, userAnswer), 1000, 0.4, GptResponseType.FEEDBACK); - System.out.println(gptResult.getScore()); - System.out.println(gptResult.getImprovement()); - System.out.println(gptResult.getModelAnswer()); AnswerFeedback feedback = AnswerFeedback.builder() .answer(answer) .score(gptResult.getScore()) diff --git a/src/test/java/com/project/InsightPrep/domain/question/service/impl/AnswerServiceImplTest.java b/src/test/java/com/project/InsightPrep/domain/question/service/impl/AnswerServiceImplTest.java index 89c935b..ccbdf33 100644 --- a/src/test/java/com/project/InsightPrep/domain/question/service/impl/AnswerServiceImplTest.java +++ b/src/test/java/com/project/InsightPrep/domain/question/service/impl/AnswerServiceImplTest.java @@ -9,6 +9,7 @@ import static org.mockito.Mockito.doAnswer; import static org.mockito.Mockito.doNothing; import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.verifyNoMoreInteractions; import static org.mockito.Mockito.when; @@ -19,12 +20,15 @@ import com.project.InsightPrep.domain.question.entity.Answer; import com.project.InsightPrep.domain.question.entity.AnswerStatus; import com.project.InsightPrep.domain.question.entity.Question; +import com.project.InsightPrep.domain.question.event.AnswerSavedEvent; import com.project.InsightPrep.domain.question.exception.QuestionErrorCode; import com.project.InsightPrep.domain.question.exception.QuestionException; import com.project.InsightPrep.domain.question.mapper.AnswerMapper; import com.project.InsightPrep.domain.question.mapper.QuestionMapper; import com.project.InsightPrep.global.auth.util.SecurityUtil; import java.lang.reflect.Field; +import java.util.concurrent.TimeUnit; +import org.awaitility.Awaitility; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; @@ -32,6 +36,7 @@ import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.context.ApplicationEventPublisher; @ExtendWith(MockitoExtension.class) class AnswerServiceImplTest { @@ -51,6 +56,11 @@ class AnswerServiceImplTest { @Mock private FeedbackServiceImpl feedbackService; + @Mock + private ApplicationEventPublisher eventPublisher; + + + @DisplayName("답변 저장 - 정상 동작") @Test void saveAnswer_success() { @@ -83,9 +93,6 @@ void saveAnswer_success() { return null; }).when(answerMapper).insertAnswer(any(Answer.class)); - // feedback 호출 자체만 확인; 내용은 captor로 검증 - doNothing().when(feedbackService).saveFeedback(any(Answer.class)); - // when AnswerResponse.AnswerDto res = answerService.saveAnswer(dto, questionId); @@ -95,10 +102,13 @@ void saveAnswer_success() { verify(questionMapper).updateStatus(eq(questionId), eq(AnswerStatus.ANSWERED.name())); verify(answerMapper).insertAnswer(any(Answer.class)); - // feedbackService로 전달된 Answer에 id가 채워졌는지 검증 - ArgumentCaptor answerCaptor = ArgumentCaptor.forClass(Answer.class); - verify(feedbackService).saveFeedback(answerCaptor.capture()); - Answer savedForFeedback = answerCaptor.getValue(); + // 이벤트 객체 캡처 + ArgumentCaptor eventCaptor = ArgumentCaptor.forClass(AnswerSavedEvent.class); + verify(eventPublisher).publishEvent(eventCaptor.capture()); + + AnswerSavedEvent publishedEvent = eventCaptor.getValue(); + Answer savedForFeedback = publishedEvent.answer(); // 이벤트 안에서 Answer 꺼내기 + assertThat(savedForFeedback.getId()).isEqualTo(100L); assertThat(savedForFeedback.getMember().getId()).isEqualTo(1L); assertThat(savedForFeedback.getQuestion().getId()).isEqualTo(questionId); @@ -155,4 +165,27 @@ void deleteAnswer_alreadyDeleted() { verify(answerMapper, never()).resetQuestionStatusIfNoAnswers(anyLong(), anyString()); verifyNoMoreInteractions(answerMapper, securityUtil); } + + @Test + void saveAnswer_shouldPublishEvent_andTriggerFeedbackListener() { + // given + Long questionId = 1L; + AnswerDto dto = new AnswerDto("테스트 답변"); + + doAnswer(invocation -> { + Answer arg = invocation.getArgument(0); + Field idField = Answer.class.getDeclaredField("id"); + idField.setAccessible(true); + idField.set(arg, 123L); // PK 강제 설정 + return null; + }).when(answerMapper).insertAnswer(any(Answer.class)); + + // when + answerService.saveAnswer(dto, questionId); + + // then (비동기라 약간 대기 필요) + Awaitility.await().atMost(3, TimeUnit.SECONDS).untilAsserted(() -> + verify(eventPublisher, times(1)).publishEvent(any(AnswerSavedEvent.class)) + ); + } } \ No newline at end of file From dbbe6c5b4763abf411c1596e4d47aa564374b400 Mon Sep 17 00:00:00 2001 From: Kim Kyeongho Date: Mon, 1 Sep 2025 12:16:12 +0900 Subject: [PATCH 3/3] =?UTF-8?q?fix:=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20?= =?UTF-8?q?=EC=BD=94=EB=93=9C=20=EC=9E=91=EC=84=B1=20#19?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../question/event/FeedbackEventListener.java | 5 + .../event/FeedbackEventListenerTest.java | 110 ++++++++++++++++++ 2 files changed, 115 insertions(+) create mode 100644 src/test/java/com/project/InsightPrep/domain/question/event/FeedbackEventListenerTest.java diff --git a/src/main/java/com/project/InsightPrep/domain/question/event/FeedbackEventListener.java b/src/main/java/com/project/InsightPrep/domain/question/event/FeedbackEventListener.java index 5a7c886..98646b9 100644 --- a/src/main/java/com/project/InsightPrep/domain/question/event/FeedbackEventListener.java +++ b/src/main/java/com/project/InsightPrep/domain/question/event/FeedbackEventListener.java @@ -3,6 +3,8 @@ import com.project.InsightPrep.domain.question.dto.response.FeedbackResponse; import com.project.InsightPrep.domain.question.entity.Answer; import com.project.InsightPrep.domain.question.entity.AnswerFeedback; +import com.project.InsightPrep.domain.question.exception.QuestionErrorCode; +import com.project.InsightPrep.domain.question.exception.QuestionException; import com.project.InsightPrep.domain.question.mapper.FeedbackMapper; import com.project.InsightPrep.global.gpt.prompt.PromptFactory; import com.project.InsightPrep.global.gpt.service.GptResponseType; @@ -26,9 +28,12 @@ public class FeedbackEventListener { @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) public void handleAnswerSaved(AnswerSavedEvent event) { Answer answer = event.answer(); + if (answer == null) throw new QuestionException(QuestionErrorCode.ANSWER_NOT_FOUND); + if (answer.getQuestion() == null) throw new QuestionException(QuestionErrorCode.QUESTION_NOT_FOUND); String question = answer.getQuestion().getContent(); String userAnswer = answer.getContent(); + if (userAnswer == null) throw new QuestionException(QuestionErrorCode.ANSWER_NOT_FOUND); FeedbackResponse gptResult = gptService.callOpenAI(PromptFactory.forFeedbackGeneration(question, userAnswer), 1000, 0.4, GptResponseType.FEEDBACK); AnswerFeedback feedback = AnswerFeedback.builder() diff --git a/src/test/java/com/project/InsightPrep/domain/question/event/FeedbackEventListenerTest.java b/src/test/java/com/project/InsightPrep/domain/question/event/FeedbackEventListenerTest.java new file mode 100644 index 0000000..7a5d5d0 --- /dev/null +++ b/src/test/java/com/project/InsightPrep/domain/question/event/FeedbackEventListenerTest.java @@ -0,0 +1,110 @@ +package com.project.InsightPrep.domain.question.event; + +import com.project.InsightPrep.domain.question.dto.response.FeedbackResponse; +import com.project.InsightPrep.domain.question.entity.Answer; +import com.project.InsightPrep.domain.question.entity.AnswerFeedback; +import com.project.InsightPrep.domain.question.entity.Question; +import com.project.InsightPrep.domain.question.exception.QuestionErrorCode; +import com.project.InsightPrep.domain.question.exception.QuestionException; +import com.project.InsightPrep.domain.question.mapper.FeedbackMapper; +import com.project.InsightPrep.global.gpt.service.GptResponseType; +import com.project.InsightPrep.global.gpt.service.GptService; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.MockitoAnnotations; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +class FeedbackEventListenerTest { + + @Mock + private GptService gptService; + + @Mock + private FeedbackMapper feedbackMapper; + + @InjectMocks + private FeedbackEventListener feedbackEventListener; + + private Answer answer; + + @BeforeEach + void setUp() { + MockitoAnnotations.openMocks(this); + + Question question = Question.builder() + .id(1L) + .content("OS에서 스레드와 프로세스의 차이점은?") + .build(); + + answer = Answer.builder() + .id(10L) + .question(question) + .content("스레드는 프로세스 내 실행 단위입니다.") + .build(); + } + + @Test + @DisplayName("정상적인 AnswerSavedEvent가 주어지면 Feedback이 저장된다") + void handleAnswerSaved_success() { + // given + FeedbackResponse mockResponse = FeedbackResponse.builder() + .score(5) + .modelAnswer("모델 답변") + .improvement("개선 필요") + .build(); + when(gptService.callOpenAI(anyList(), anyInt(), anyDouble(), eq(GptResponseType.FEEDBACK))) + .thenReturn(mockResponse); + + AnswerSavedEvent event = new AnswerSavedEvent(answer); + + // when + feedbackEventListener.handleAnswerSaved(event); + + // then + ArgumentCaptor captor = ArgumentCaptor.forClass(AnswerFeedback.class); + verify(feedbackMapper, times(1)).insertFeedback(captor.capture()); + + AnswerFeedback saved = captor.getValue(); + assertThat(saved.getAnswer()).isEqualTo(answer); + assertThat(saved.getScore()).isEqualTo(5); + assertThat(saved.getModelAnswer()).isEqualTo("모델 답변"); + assertThat(saved.getImprovement()).isEqualTo("개선 필요"); + } + + @Test + @DisplayName("Answer가 null이면 예외가 발생한다") + void handleAnswerSaved_nullAnswer() { + // given + AnswerSavedEvent event = new AnswerSavedEvent(null); + + // when & then + assertThrows(QuestionException.class, () -> feedbackEventListener.handleAnswerSaved(event)); + verify(feedbackMapper, never()).insertFeedback(any()); + } + + @Test + @DisplayName("Answer에 Question 또는 content가 null이면 예외가 발생한다") + void handleAnswerSaved_invalidAnswer() { + Answer invalidAnswer = Answer.builder() + .id(20L) + .question(null) // 질문 없음 + .content(null) // 답변 없음 + .build(); + + AnswerSavedEvent event = new AnswerSavedEvent(invalidAnswer); + + + + + assertThrows(QuestionException.class, () -> feedbackEventListener.handleAnswerSaved(event)); + verify(feedbackMapper, never()).insertFeedback(any()); + } +} \ No newline at end of file