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..98646b9 --- /dev/null +++ b/src/main/java/com/project/InsightPrep/domain/question/event/FeedbackEventListener.java @@ -0,0 +1,49 @@ +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.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; +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(); + 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() + .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(); } 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/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 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