diff --git a/.github/workflows/deploy-to-dev-ec2.yml b/.github/workflows/deploy-to-dev-ec2.yml index c91c5710..690bd76f 100644 --- a/.github/workflows/deploy-to-dev-ec2.yml +++ b/.github/workflows/deploy-to-dev-ec2.yml @@ -147,7 +147,7 @@ jobs: ANTHROPIC_API_KEY="${{ secrets.ANTHROPIC_API_KEY }}" \ GOOGLE_GENAI_API_KEY="${{ secrets.GOOGLE_GENAI_API_KEY }}" \ FIREBASE_ADMIN_KEY="${{ secrets.FIREBASE_ADMIN_KEY }}" \ - ADMIN_PAGE_PASSWORD="${{ secrets.ADMIN_PAGE_PASSWORD }}" \ + ADMIN_PAGE_PASSWORD='${{ secrets.ADMIN_PAGE_PASSWORD }}' \ DEV_TEST_ACCOUNT_PASSWORD="${{ secrets.DEV_TEST_ACCOUNT_PASSWORD }}" \ nohup java -jar "$JAR_PATH" \ --spring.profiles.active=dev > app.log 2>&1 & diff --git a/.github/workflows/deploy-to-prod-ec2.yml b/.github/workflows/deploy-to-prod-ec2.yml index bf2f3125..9dd4ea0c 100644 --- a/.github/workflows/deploy-to-prod-ec2.yml +++ b/.github/workflows/deploy-to-prod-ec2.yml @@ -144,7 +144,7 @@ jobs: ANTHROPIC_API_KEY="${{ secrets.ANTHROPIC_API_KEY }}" \ GOOGLE_GENAI_API_KEY="${{ secrets.GOOGLE_GENAI_API_KEY }}" \ FIREBASE_ADMIN_KEY="${{ secrets.FIREBASE_ADMIN_KEY }}" \ - ADMIN_PAGE_PASSWORD="${{ secrets.ADMIN_PAGE_PASSWORD }}" \ + ADMIN_PAGE_PASSWORD='${{ secrets.ADMIN_PAGE_PASSWORD }}' \ nohup java -jar "$JAR_PATH" \ --spring.profiles.active=prod > app-prod.log 2>&1 & diff --git a/src/main/java/com/devkor/ifive/nadab/domain/admin/application/AdminPageAuthCommandService.java b/src/main/java/com/devkor/ifive/nadab/domain/admin/application/AdminPageAuthCommandService.java index 4e236b3e..0183c081 100644 --- a/src/main/java/com/devkor/ifive/nadab/domain/admin/application/AdminPageAuthCommandService.java +++ b/src/main/java/com/devkor/ifive/nadab/domain/admin/application/AdminPageAuthCommandService.java @@ -16,8 +16,8 @@ public class AdminPageAuthCommandService { private final AdminPageProperties adminPageProperties; public void validatePassword(String rawPassword) { - byte[] input = rawPassword.getBytes(StandardCharsets.UTF_8); - byte[] expected = adminPageProperties.getPassword().getBytes(StandardCharsets.UTF_8); + byte[] input = rawPassword.strip().getBytes(StandardCharsets.UTF_8); + byte[] expected = adminPageProperties.getPassword().strip().getBytes(StandardCharsets.UTF_8); if (!MessageDigest.isEqual(input, expected)) { throw new UnauthorizedException(ErrorCode.ADMIN_PAGE_INVALID_PASSWORD); diff --git a/src/main/java/com/devkor/ifive/nadab/domain/dailyreport/application/DailyReportService.java b/src/main/java/com/devkor/ifive/nadab/domain/dailyreport/application/DailyReportService.java index 72b462c2..78d133a2 100644 --- a/src/main/java/com/devkor/ifive/nadab/domain/dailyreport/application/DailyReportService.java +++ b/src/main/java/com/devkor/ifive/nadab/domain/dailyreport/application/DailyReportService.java @@ -16,6 +16,9 @@ import com.devkor.ifive.nadab.domain.question.core.entity.UserDailyQuestion; import com.devkor.ifive.nadab.domain.question.core.repository.DailyQuestionRepository; import com.devkor.ifive.nadab.domain.question.core.repository.UserDailyQuestionRepository; +import com.devkor.ifive.nadab.domain.reportlog.application.ReportGenerationLogRecorder; +import com.devkor.ifive.nadab.domain.reportlog.core.entity.ReportGenerationStep; +import com.devkor.ifive.nadab.domain.reportlog.core.entity.ReportGenerationType; import com.devkor.ifive.nadab.domain.user.core.entity.InterestCode; import com.devkor.ifive.nadab.domain.user.core.entity.User; import com.devkor.ifive.nadab.domain.user.core.repository.UserRepository; @@ -25,6 +28,7 @@ import com.devkor.ifive.nadab.global.exception.BadRequestException; import com.devkor.ifive.nadab.global.exception.NotFoundException; +import com.devkor.ifive.nadab.global.infra.llm.LlmProvider; import com.devkor.ifive.nadab.global.shared.util.TodayDateTimeProvider; import lombok.RequiredArgsConstructor; import org.springframework.beans.factory.annotation.Value; @@ -38,6 +42,8 @@ @RequiredArgsConstructor public class DailyReportService { + private static final String DAILY_REPORT_LLM_MODEL = "GPT_4_O_MINI"; + private final UserRepository userRepository; private final DailyQuestionRepository dailyQuestionRepository; private final UserDailyQuestionRepository userDailyQuestionRepository; @@ -46,6 +52,7 @@ public class DailyReportService { private final ProfileImageService profileImageService; private final DailyReportLlmClient dailyReportLlmClient; + private final ReportGenerationLogRecorder reportGenerationLogRecorder; private final ApplicationEventPublisher eventPublisher; @@ -86,11 +93,21 @@ public CreateDailyReportResponse generateDailyReport(Long userId, DailyReportReq PrepareDailyResultDto prep = dailyReportTxService.prepareDaily(user, question, request.answer(), isDayPassed, request.objectKey()); AnswerEntry answerEntry = prep.entry(); + Long generationLogId = reportGenerationLogRecorder.start( + userId, + ReportGenerationType.DAILY, + prep.reportId(), + ReportGenerationStep.DAILY_GENERATE, + LlmProvider.OPENAI, + DAILY_REPORT_LLM_MODEL + ); AiDailyReportResultDto dto; try { dto = dailyReportLlmClient.generate(question.getQuestionText(), answerEntry); + reportGenerationLogRecorder.succeed(generationLogId); } catch (Exception e) { + reportGenerationLogRecorder.fail(generationLogId, e); dailyReportTxService.failDaily(prep.reportId()); throw e; } @@ -174,4 +191,5 @@ private void validateWebpKey(String key, Long userId) { private boolean isBlank(String s) { return s == null || s.trim().isEmpty(); } + } diff --git a/src/main/java/com/devkor/ifive/nadab/domain/dailyreport/infra/DailyReportLlmClient.java b/src/main/java/com/devkor/ifive/nadab/domain/dailyreport/infra/DailyReportLlmClient.java index 5ddf8234..5f3d06d3 100644 --- a/src/main/java/com/devkor/ifive/nadab/domain/dailyreport/infra/DailyReportLlmClient.java +++ b/src/main/java/com/devkor/ifive/nadab/domain/dailyreport/infra/DailyReportLlmClient.java @@ -9,6 +9,7 @@ import com.devkor.ifive.nadab.global.exception.BadRequestException; import com.devkor.ifive.nadab.global.exception.ai.AiResponseParseException; import com.devkor.ifive.nadab.global.exception.ai.AiServiceUnavailableException; +import com.devkor.ifive.nadab.global.infra.llm.LlmExceptionMapper; import com.devkor.ifive.nadab.global.infra.llm.LlmProvider; import com.devkor.ifive.nadab.global.infra.llm.LlmRouter; import com.fasterxml.jackson.databind.ObjectMapper; @@ -57,11 +58,16 @@ public AiDailyReportResultDto generate(String question, AnswerEntry answerEntry) UserMessage userMessage = buildUserMessage(prompt, withImagePrompt,answerEntry); - String content = chatClient.prompt() - .messages(userMessage) - .options(options) - .call() - .content(); + String content; + try { + content = chatClient.prompt() + .messages(userMessage) + .options(options) + .call() + .content(); + } catch (Exception e) { + throw LlmExceptionMapper.toUnavailable(ErrorCode.AI_NO_RESPONSE, e); + } if (content == null || content.trim().isEmpty()) { throw new AiServiceUnavailableException(ErrorCode.AI_NO_RESPONSE); diff --git a/src/main/java/com/devkor/ifive/nadab/domain/monthlyreport/application/listener/MonthlyReportGenerationListener.java b/src/main/java/com/devkor/ifive/nadab/domain/monthlyreport/application/listener/MonthlyReportGenerationListener.java index ea1ab71d..1871b5f8 100644 --- a/src/main/java/com/devkor/ifive/nadab/domain/monthlyreport/application/listener/MonthlyReportGenerationListener.java +++ b/src/main/java/com/devkor/ifive/nadab/domain/monthlyreport/application/listener/MonthlyReportGenerationListener.java @@ -7,8 +7,12 @@ import com.devkor.ifive.nadab.domain.monthlyreport.core.repository.MonthlyQueryRepository; import com.devkor.ifive.nadab.domain.monthlyreport.core.service.MonthlyWeeklySummariesService; import com.devkor.ifive.nadab.domain.monthlyreport.infra.MonthlyReportLlmClient; +import com.devkor.ifive.nadab.domain.reportlog.application.ReportGenerationLogRecorder; +import com.devkor.ifive.nadab.domain.reportlog.core.entity.ReportGenerationStep; +import com.devkor.ifive.nadab.domain.reportlog.core.entity.ReportGenerationType; import com.devkor.ifive.nadab.domain.weeklyreport.application.helper.WeeklyEntriesAssembler; import com.devkor.ifive.nadab.domain.weeklyreport.core.dto.DailyEntryDto; +import com.devkor.ifive.nadab.global.infra.llm.LlmProvider; import com.devkor.ifive.nadab.global.shared.reportcontent.AiReportResultDto; import com.devkor.ifive.nadab.global.shared.util.MonthRangeCalculator; import com.devkor.ifive.nadab.global.shared.util.dto.MonthRangeDto; @@ -27,18 +31,18 @@ @Slf4j public class MonthlyReportGenerationListener { + private static final String MONTHLY_REPORT_LLM_MODEL = "GEMINI_2_5_FLASH"; + private final MonthlyQueryRepository monthlyQueryRepository; private final MonthlyReportLlmClient monthlyReportLlmClient; private final MonthlyReportTxService monthlyReportTxService; private final MonthlyWeeklySummariesService monthlyWeeklySummariesService; + private final ReportGenerationLogRecorder reportGenerationLogRecorder; private final ApplicationEventPublisher eventPublisher; - private static final int MAX_LEN = 245; - @Async("monthlyReportTaskExecutor") - @TransactionalEventListener(phase = - TransactionPhase.AFTER_COMMIT) + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) public void handle(MonthlyReportGenerationRequestedEventDto event) { MonthRangeDto range = MonthRangeCalculator.getLastMonthRange(); @@ -52,11 +56,25 @@ public void handle(MonthlyReportGenerationRequestedEventDto event) { String weeklySummaries = monthlyWeeklySummariesService.buildWeeklySummaries(event.userId(), range); AiReportResultDto dto; + Long generationLogId = reportGenerationLogRecorder.start( + event.userId(), + ReportGenerationType.MONTHLY, + event.reportId(), + ReportGenerationStep.MONTHLY_GENERATE, + LlmProvider.GEMINI, + MONTHLY_REPORT_LLM_MODEL + ); try { // 트랜잭션 밖(백그라운드)에서 LLM 호출 dto = monthlyReportLlmClient.generate( - range.monthStartDate().toString(), range.monthEndDate().toString(), weeklySummaries, representativeEntries); + range.monthStartDate().toString(), + range.monthEndDate().toString(), + weeklySummaries, + representativeEntries + ); + reportGenerationLogRecorder.succeed(generationLogId); } catch (Exception e) { + reportGenerationLogRecorder.fail(generationLogId, e); log.error("[MONTHLY_REPORT][LLM_FAILED] userId={}, reportId={}", event.userId(), event.reportId(), e); @@ -69,7 +87,14 @@ public void handle(MonthlyReportGenerationRequestedEventDto event) { return; } - // 성공 확정(별도 트랜잭션) + Long confirmLogId = reportGenerationLogRecorder.start( + event.userId(), + ReportGenerationType.MONTHLY, + event.reportId(), + ReportGenerationStep.MONTHLY_CONFIRM, + null, + null + ); try { monthlyReportTxService.confirmMonthly( event.reportId(), @@ -81,8 +106,10 @@ public void handle(MonthlyReportGenerationRequestedEventDto event) { eventPublisher.publishEvent( new MonthlyReportCompletedEvent(event.reportId(), event.userId()) ); + reportGenerationLogRecorder.succeed(confirmLogId); } catch (Exception e) { + reportGenerationLogRecorder.fail(confirmLogId, e); log.error("[MONTHLY_REPORT][CONFIRM_FAILED] userId={}, reportId={}, crystalLogId={}", event.userId(), event.reportId(), event.crystalLogId(), e); @@ -93,13 +120,5 @@ public void handle(MonthlyReportGenerationRequestedEventDto event) { event.crystalLogId() ); } - - } - - // 최대 길이 자르기 - private String cut(String s) { - if (s == null) return null; - s = s.trim(); - return (s.length() <= MAX_LEN) ? s : s.substring(0, MAX_LEN); } } diff --git a/src/main/java/com/devkor/ifive/nadab/domain/monthlyreport/application/listener/MonthlyReportGenerationListenerV2.java b/src/main/java/com/devkor/ifive/nadab/domain/monthlyreport/application/listener/MonthlyReportGenerationListenerV2.java index 16ff49d6..df0f33ad 100644 --- a/src/main/java/com/devkor/ifive/nadab/domain/monthlyreport/application/listener/MonthlyReportGenerationListenerV2.java +++ b/src/main/java/com/devkor/ifive/nadab/domain/monthlyreport/application/listener/MonthlyReportGenerationListenerV2.java @@ -13,10 +13,14 @@ import com.devkor.ifive.nadab.domain.monthlyreport.infra.MonthlyReportImageStorage; import com.devkor.ifive.nadab.domain.monthlyreport.infra.MonthlyReportLlmClientV2; import com.devkor.ifive.nadab.domain.monthlyreport.infra.OpenAiImageClient; +import com.devkor.ifive.nadab.domain.reportlog.application.ReportGenerationLogRecorder; +import com.devkor.ifive.nadab.domain.reportlog.core.entity.ReportGenerationStep; +import com.devkor.ifive.nadab.domain.reportlog.core.entity.ReportGenerationType; import com.devkor.ifive.nadab.domain.typereport.application.helper.TypeEmotionStatsCalculator; import com.devkor.ifive.nadab.domain.typereport.core.content.TypeEmotionStatsContent; import com.devkor.ifive.nadab.domain.weeklyreport.application.helper.WeeklyEntriesAssembler; import com.devkor.ifive.nadab.domain.weeklyreport.core.dto.DailyEntryDto; +import com.devkor.ifive.nadab.global.infra.llm.LlmProvider; import com.devkor.ifive.nadab.global.shared.util.MonthRangeCalculator; import com.devkor.ifive.nadab.global.shared.util.dto.MonthRangeDto; import lombok.RequiredArgsConstructor; @@ -34,6 +38,8 @@ @Slf4j public class MonthlyReportGenerationListenerV2 { + private static final String MONTHLY_REPORT_V2_LLM_MODEL = "GEMINI_2_5_FLASH"; + private final MonthlyQueryRepository monthlyQueryRepository; private final MonthlyReportLlmClientV2 monthlyReportLlmClientV2; @@ -42,11 +48,11 @@ public class MonthlyReportGenerationListenerV2 { private final MonthlyReportTxServiceV2 monthlyReportTxServiceV2; private final MonthlyWeeklySummariesService monthlyWeeklySummariesService; + private final ReportGenerationLogRecorder reportGenerationLogRecorder; private final ApplicationEventPublisher eventPublisher; @Async("monthlyReportTaskExecutor") - @TransactionalEventListener(phase = - TransactionPhase.AFTER_COMMIT) + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) public void handle(MonthlyReportGenerationRequestedEventDtoV2 event) { MonthRangeDto range = MonthRangeCalculator.getLastMonthRange(); @@ -103,6 +109,14 @@ public void handle(MonthlyReportGenerationRequestedEventDtoV2 event) { } AiMonthlyReportResultDto dto; + Long generationLogId = reportGenerationLogRecorder.start( + event.userId(), + ReportGenerationType.MONTHLY_V2, + event.reportId(), + ReportGenerationStep.MONTHLY_V2_GENERATE, + LlmProvider.GEMINI, + MONTHLY_REPORT_V2_LLM_MODEL + ); try { // 트랜잭션 밖(백그라운드)에서 LLM 호출 dto = monthlyReportLlmClientV2.generate( @@ -111,8 +125,11 @@ public void handle(MonthlyReportGenerationRequestedEventDtoV2 event) { weeklySummaries, representativeEntries, emotionStats, - event.exists()); + event.exists() + ); + reportGenerationLogRecorder.succeed(generationLogId); } catch (Exception e) { + reportGenerationLogRecorder.fail(generationLogId, e); log.error("[MONTHLY_REPORT][LLM_FAILED] userId={}, reportId={}", event.userId(), event.reportId(), e); @@ -125,7 +142,14 @@ public void handle(MonthlyReportGenerationRequestedEventDtoV2 event) { return; } - // 텍스트 생성 성공 확정(별도 트랜잭션) + Long textConfirmLogId = reportGenerationLogRecorder.start( + event.userId(), + ReportGenerationType.MONTHLY_V2, + event.reportId(), + ReportGenerationStep.MONTHLY_V2_TEXT_CONFIRM, + null, + null + ); try { monthlyReportTxServiceV2.confirmMonthlyText( event.reportId(), @@ -134,8 +158,10 @@ public void handle(MonthlyReportGenerationRequestedEventDtoV2 event) { emotionStats, interestStats ); + reportGenerationLogRecorder.succeed(textConfirmLogId); } catch (Exception e) { + reportGenerationLogRecorder.fail(textConfirmLogId, e); log.error("[MONTHLY_REPORT][TEXT_CONFIRM_FAILED] userId={}, reportId={}, crystalLogId={}", event.userId(), event.reportId(), event.crystalLogId(), e); @@ -148,7 +174,15 @@ public void handle(MonthlyReportGenerationRequestedEventDtoV2 event) { return; } - String imageKey = ""; + String imageKey; + Long imageLogId = reportGenerationLogRecorder.start( + event.userId(), + ReportGenerationType.MONTHLY_V2, + event.reportId(), + ReportGenerationStep.MONTHLY_V2_IMAGE_GENERATE, + LlmProvider.OPENAI, + null + ); try { String base64Image = openAiImageClient.generateBase64Image(event.userId(), dto, range); imageKey = monthlyReportImageStorage.uploadBase64Webp( @@ -156,8 +190,10 @@ public void handle(MonthlyReportGenerationRequestedEventDtoV2 event) { event.reportId(), base64Image ); + reportGenerationLogRecorder.succeed(imageLogId); } catch (Exception e) { + reportGenerationLogRecorder.fail(imageLogId, e); log.error("[MONTHLY_REPORT][IMAGE_FAILED] userId={}, reportId={}, crystalLogId={}", event.userId(), event.reportId(), event.crystalLogId(), e); @@ -169,6 +205,14 @@ public void handle(MonthlyReportGenerationRequestedEventDtoV2 event) { return; } + Long confirmLogId = reportGenerationLogRecorder.start( + event.userId(), + ReportGenerationType.MONTHLY_V2, + event.reportId(), + ReportGenerationStep.MONTHLY_V2_CONFIRM, + null, + null + ); try { monthlyReportTxServiceV2.confirmMonthly( event.reportId(), @@ -180,8 +224,10 @@ public void handle(MonthlyReportGenerationRequestedEventDtoV2 event) { eventPublisher.publishEvent( new MonthlyReportCompletedEvent(event.reportId(), event.userId()) ); + reportGenerationLogRecorder.succeed(confirmLogId); } catch (Exception e) { + reportGenerationLogRecorder.fail(confirmLogId, e); log.error("[MONTHLY_REPORT][CONFIRM_FAILED] userId={}, reportId={}, crystalLogId={}", event.userId(), event.reportId(), event.crystalLogId(), e); @@ -194,4 +240,3 @@ public void handle(MonthlyReportGenerationRequestedEventDtoV2 event) { } } } - diff --git a/src/main/java/com/devkor/ifive/nadab/domain/monthlyreport/infra/MonthlyReportLlmClient.java b/src/main/java/com/devkor/ifive/nadab/domain/monthlyreport/infra/MonthlyReportLlmClient.java index 50f66e8c..f2c44c90 100644 --- a/src/main/java/com/devkor/ifive/nadab/domain/monthlyreport/infra/MonthlyReportLlmClient.java +++ b/src/main/java/com/devkor/ifive/nadab/domain/monthlyreport/infra/MonthlyReportLlmClient.java @@ -4,6 +4,7 @@ import com.devkor.ifive.nadab.global.core.response.ErrorCode; import com.devkor.ifive.nadab.global.exception.ai.AiResponseParseException; import com.devkor.ifive.nadab.global.exception.ai.AiServiceUnavailableException; +import com.devkor.ifive.nadab.global.infra.llm.LlmExceptionMapper; import com.devkor.ifive.nadab.global.infra.llm.LlmProvider; import com.devkor.ifive.nadab.global.infra.llm.LlmRouter; import com.devkor.ifive.nadab.global.shared.reportcontent.*; @@ -116,11 +117,15 @@ private String callOpenAi(ChatClient client, String prompt) { .temperature(1.0) .build(); - return client.prompt() - .user(prompt) - .options(options) - .call() - .content(); + try { + return client.prompt() + .user(prompt) + .options(options) + .call() + .content(); + } catch (Exception e) { + throw LlmExceptionMapper.toUnavailable(ErrorCode.AI_NO_RESPONSE, e); + } } private String callClaude(ChatClient client, String prompt) { @@ -129,11 +134,15 @@ private String callClaude(ChatClient client, String prompt) { .temperature(0.3) .build(); - return client.prompt() - .user(prompt) - .options(options) - .call() - .content(); + try { + return client.prompt() + .user(prompt) + .options(options) + .call() + .content(); + } catch (Exception e) { + throw LlmExceptionMapper.toUnavailable(ErrorCode.AI_NO_RESPONSE, e); + } } private String callGemini(ChatClient client, String prompt) { @@ -143,11 +152,15 @@ private String callGemini(ChatClient client, String prompt) { .temperature(0.3) .build(); - return client.prompt() - .user(prompt) - .options(options) - .call() - .content(); + try { + return client.prompt() + .user(prompt) + .options(options) + .call() + .content(); + } catch (Exception e) { + throw LlmExceptionMapper.toUnavailable(ErrorCode.AI_NO_RESPONSE, e); + } } private AiReportResultDto enforceLength(AiReportResultDto dto) { @@ -216,7 +229,12 @@ private StyledText rewriteStyled(ChatClient client, StyledText in, boolean isDis .temperature(0.0) .build(); - String out = client.prompt().user(prompt).options(options).call().content(); + String out; + try { + out = client.prompt().user(prompt).options(options).call().content(); + } catch (Exception e) { + throw LlmExceptionMapper.toUnavailable(ErrorCode.AI_REWRITE_NO_RESPONSE, e); + } if (out == null || out.isBlank()) { throw new AiServiceUnavailableException(ErrorCode.AI_REWRITE_NO_RESPONSE); @@ -319,4 +337,4 @@ private void validateSummary(String summary) { } } } -} \ No newline at end of file +} diff --git a/src/main/java/com/devkor/ifive/nadab/domain/monthlyreport/infra/MonthlyReportLlmClientV2.java b/src/main/java/com/devkor/ifive/nadab/domain/monthlyreport/infra/MonthlyReportLlmClientV2.java index 8df31110..9b5623ca 100644 --- a/src/main/java/com/devkor/ifive/nadab/domain/monthlyreport/infra/MonthlyReportLlmClientV2.java +++ b/src/main/java/com/devkor/ifive/nadab/domain/monthlyreport/infra/MonthlyReportLlmClientV2.java @@ -9,6 +9,7 @@ import com.devkor.ifive.nadab.global.core.response.ErrorCode; import com.devkor.ifive.nadab.global.exception.ai.AiResponseParseException; import com.devkor.ifive.nadab.global.exception.ai.AiServiceUnavailableException; +import com.devkor.ifive.nadab.global.infra.llm.LlmExceptionMapper; import com.devkor.ifive.nadab.global.infra.llm.LlmProvider; import com.devkor.ifive.nadab.global.infra.llm.LlmRouter; import com.devkor.ifive.nadab.global.shared.reportcontent.*; @@ -130,11 +131,15 @@ private String callOpenAi(ChatClient client, String prompt) { .temperature(1.0) .build(); - return client.prompt() - .user(prompt) - .options(options) - .call() - .content(); + try { + return client.prompt() + .user(prompt) + .options(options) + .call() + .content(); + } catch (Exception e) { + throw LlmExceptionMapper.toUnavailable(ErrorCode.AI_NO_RESPONSE, e); + } } private String callClaude(ChatClient client, String prompt) { @@ -143,11 +148,15 @@ private String callClaude(ChatClient client, String prompt) { .temperature(0.3) .build(); - return client.prompt() - .user(prompt) - .options(options) - .call() - .content(); + try { + return client.prompt() + .user(prompt) + .options(options) + .call() + .content(); + } catch (Exception e) { + throw LlmExceptionMapper.toUnavailable(ErrorCode.AI_NO_RESPONSE, e); + } } private String callGemini(ChatClient client, String prompt) { @@ -157,11 +166,15 @@ private String callGemini(ChatClient client, String prompt) { .temperature(0.3) .build(); - return client.prompt() - .user(prompt) - .options(options) - .call() - .content(); + try { + return client.prompt() + .user(prompt) + .options(options) + .call() + .content(); + } catch (Exception e) { + throw LlmExceptionMapper.toUnavailable(ErrorCode.AI_NO_RESPONSE, e); + } } /* @@ -231,7 +244,12 @@ private StyledText rewriteStyled(ChatClient client, StyledText in, boolean isDis .temperature(0.0) .build(); - String out = client.prompt().user(prompt).options(options).call().content(); + String out; + try { + out = client.prompt().user(prompt).options(options).call().content(); + } catch (Exception e) { + throw LlmExceptionMapper.toUnavailable(ErrorCode.AI_REWRITE_NO_RESPONSE, e); + } if (out == null || out.isBlank()) { throw new AiServiceUnavailableException(ErrorCode.AI_REWRITE_NO_RESPONSE); @@ -329,4 +347,4 @@ private void validateSummary(String summary) { } } } -} \ No newline at end of file +} diff --git a/src/main/java/com/devkor/ifive/nadab/domain/monthlyreport/infra/OpenAiImageClient.java b/src/main/java/com/devkor/ifive/nadab/domain/monthlyreport/infra/OpenAiImageClient.java index 57a9d551..f17d7ed3 100644 --- a/src/main/java/com/devkor/ifive/nadab/domain/monthlyreport/infra/OpenAiImageClient.java +++ b/src/main/java/com/devkor/ifive/nadab/domain/monthlyreport/infra/OpenAiImageClient.java @@ -8,6 +8,7 @@ import com.devkor.ifive.nadab.global.core.response.ErrorCode; import com.devkor.ifive.nadab.global.exception.ai.AiResponseParseException; import com.devkor.ifive.nadab.global.exception.ai.AiServiceUnavailableException; +import com.devkor.ifive.nadab.global.infra.llm.LlmExceptionMapper; import com.devkor.ifive.nadab.global.shared.util.dto.MonthRangeDto; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -86,10 +87,10 @@ public String generateBase64Image(Long userId, AiMonthlyReportResultDto dto, Mon } catch (WebClientResponseException e) { log.error("[OPENAI_IMAGE][HTTP_ERROR] userId={}, status={}, responseBody={}", userId, e.getStatusCode().value(), truncate(e.getResponseBodyAsString()), e); - throw new AiServiceUnavailableException(ErrorCode.AI_NO_RESPONSE); + throw LlmExceptionMapper.toUnavailable(ErrorCode.AI_NO_RESPONSE, e); } catch (Exception e) { log.error("[OPENAI_IMAGE][CALL_FAILED] userId={}, message={}", userId, e.getMessage(), e); - throw new AiServiceUnavailableException(ErrorCode.AI_NO_RESPONSE); + throw LlmExceptionMapper.toUnavailable(ErrorCode.AI_NO_RESPONSE, e); } if (response == null) { diff --git a/src/main/java/com/devkor/ifive/nadab/domain/reportlog/application/ReportGenerationLogRecorder.java b/src/main/java/com/devkor/ifive/nadab/domain/reportlog/application/ReportGenerationLogRecorder.java new file mode 100644 index 00000000..f511328b --- /dev/null +++ b/src/main/java/com/devkor/ifive/nadab/domain/reportlog/application/ReportGenerationLogRecorder.java @@ -0,0 +1,66 @@ +package com.devkor.ifive.nadab.domain.reportlog.application; + +import com.devkor.ifive.nadab.domain.reportlog.core.entity.ReportGenerationLog; +import com.devkor.ifive.nadab.domain.reportlog.core.entity.ReportGenerationStep; +import com.devkor.ifive.nadab.domain.reportlog.core.entity.ReportGenerationType; +import com.devkor.ifive.nadab.global.infra.llm.LlmProvider; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +@Slf4j +public class ReportGenerationLogRecorder { + + private final ReportGenerationLogService reportGenerationLogService; + + public Long start( + Long userId, + ReportGenerationType reportType, + Long reportId, + ReportGenerationStep step, + LlmProvider llmProvider, + String llmModel + ) { + try { + ReportGenerationLog log = reportGenerationLogService.start( + userId, + reportType, + reportId, + step, + llmProvider, + llmModel + ); + return log.getId(); + } catch (Exception e) { + log.warn("[REPORT_GENERATION_LOG][START_FAILED] reportType={}, step={}, userId={}, reportId={}", + reportType, step, userId, reportId, e); + return null; + } + } + + public void succeed(Long logId) { + if (logId == null) { + return; + } + + try { + reportGenerationLogService.succeed(logId); + } catch (Exception e) { + log.warn("[REPORT_GENERATION_LOG][SUCCEED_FAILED] logId={}", logId, e); + } + } + + public void fail(Long logId, Exception exception) { + if (logId == null) { + return; + } + + try { + reportGenerationLogService.fail(logId, exception); + } catch (Exception e) { + log.warn("[REPORT_GENERATION_LOG][FAIL_FAILED] logId={}", logId, e); + } + } +} diff --git a/src/main/java/com/devkor/ifive/nadab/domain/reportlog/application/ReportGenerationLogService.java b/src/main/java/com/devkor/ifive/nadab/domain/reportlog/application/ReportGenerationLogService.java new file mode 100644 index 00000000..b1a8eeda --- /dev/null +++ b/src/main/java/com/devkor/ifive/nadab/domain/reportlog/application/ReportGenerationLogService.java @@ -0,0 +1,111 @@ +package com.devkor.ifive.nadab.domain.reportlog.application; + +import com.devkor.ifive.nadab.domain.reportlog.core.entity.ReportGenerationLog; +import com.devkor.ifive.nadab.domain.reportlog.core.entity.ReportGenerationStep; +import com.devkor.ifive.nadab.domain.reportlog.core.entity.ReportGenerationType; +import com.devkor.ifive.nadab.domain.reportlog.core.repository.ReportGenerationLogRepository; +import com.devkor.ifive.nadab.domain.user.core.entity.User; +import com.devkor.ifive.nadab.domain.user.core.repository.UserRepository; +import com.devkor.ifive.nadab.global.core.response.ErrorCode; +import com.devkor.ifive.nadab.global.exception.BusinessException; +import com.devkor.ifive.nadab.global.exception.ai.AiServiceUnavailableException; +import com.devkor.ifive.nadab.global.infra.llm.LlmProvider; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Propagation; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +public class ReportGenerationLogService { + + private final ReportGenerationLogRepository reportGenerationLogRepository; + private final UserRepository userRepository; + + @Transactional(propagation = Propagation.REQUIRES_NEW) + public ReportGenerationLog start( + Long userId, + ReportGenerationType reportType, + Long reportId, + ReportGenerationStep step, + LlmProvider llmProvider, + String llmModel + ) { + User user = userId == null ? null : userRepository.findById(userId).orElse(null); + + ReportGenerationLog log = ReportGenerationLog.start( + user, + reportType, + reportId, + step, + llmProvider, + llmModel + ); + + return reportGenerationLogRepository.save(log); + } + + @Transactional(propagation = Propagation.REQUIRES_NEW) + public void succeed(Long logId) { + reportGenerationLogRepository.findById(logId) + .ifPresent(ReportGenerationLog::succeed); + } + + @Transactional(propagation = Propagation.REQUIRES_NEW) + public void fail(Long logId, Exception exception) { + Integer externalHttpStatus = extractExternalHttpStatus(exception); + String externalErrorCode = extractExternalErrorCode(exception); + fail(logId, exception, externalHttpStatus, externalErrorCode); + } + + @Transactional(propagation = Propagation.REQUIRES_NEW) + public void fail(Long logId, Exception exception, Integer httpStatus, String externalErrorCode) { + reportGenerationLogRepository.findById(logId) + .ifPresent(log -> { + ErrorCode errorCode = extractErrorCode(exception); + Integer resolvedHttpStatus = httpStatus != null ? httpStatus : extractHttpStatus(errorCode); + + log.fail( + errorCode == null ? null : errorCode.getCode(), + exception == null ? null : cut(exception.getClass().getName(), 255), + resolvedHttpStatus, + externalErrorCode + ); + }); + } + + private ErrorCode extractErrorCode(Exception exception) { + if (exception instanceof BusinessException businessException) { + return businessException.getErrorCode(); + } + return null; + } + + private Integer extractHttpStatus(ErrorCode errorCode) { + if (errorCode == null) { + return null; + } + return errorCode.getHttpStatus().value(); + } + + private Integer extractExternalHttpStatus(Exception exception) { + if (exception instanceof AiServiceUnavailableException aiException) { + return aiException.getExternalHttpStatus(); + } + return null; + } + + private String extractExternalErrorCode(Exception exception) { + if (exception instanceof AiServiceUnavailableException aiException) { + return aiException.getExternalErrorCode(); + } + return null; + } + + private String cut(String value, int maxLength) { + if (value == null || value.length() <= maxLength) { + return value; + } + return value.substring(0, maxLength); + } +} diff --git a/src/main/java/com/devkor/ifive/nadab/domain/reportlog/core/entity/ReportGenerationLog.java b/src/main/java/com/devkor/ifive/nadab/domain/reportlog/core/entity/ReportGenerationLog.java new file mode 100644 index 00000000..36efa075 --- /dev/null +++ b/src/main/java/com/devkor/ifive/nadab/domain/reportlog/core/entity/ReportGenerationLog.java @@ -0,0 +1,128 @@ +package com.devkor.ifive.nadab.domain.reportlog.core.entity; + +import com.devkor.ifive.nadab.domain.user.core.entity.User; +import com.devkor.ifive.nadab.global.infra.llm.LlmProvider; +import com.devkor.ifive.nadab.global.shared.entity.CreatableEntity; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.FetchType; +import jakarta.persistence.ForeignKey; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.time.Duration; +import java.time.OffsetDateTime; + +@Entity +@Table(name = "report_generation_logs") +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class ReportGenerationLog extends CreatableEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn( + name = "user_id", + foreignKey = @ForeignKey(name = "fk_report_generation_logs_user") + ) + private User user; + + @Enumerated(EnumType.STRING) + @Column(name = "report_type", nullable = false, length = 32) + private ReportGenerationType reportType; + + @Column(name = "report_id") + private Long reportId; + + @Enumerated(EnumType.STRING) + @Column(name = "step", nullable = false, length = 64) + private ReportGenerationStep step; + + @Enumerated(EnumType.STRING) + @Column(name = "status", nullable = false, length = 32) + private ReportGenerationLogStatus status; + + @Enumerated(EnumType.STRING) + @Column(name = "llm_provider", length = 32) + private LlmProvider llmProvider; + + @Column(name = "llm_model", length = 128) + private String llmModel; + + @Column(name = "error_code", length = 128) + private String errorCode; + + @Column(name = "exception_class", length = 255) + private String exceptionClass; + + @Column(name = "http_status") + private Integer httpStatus; + + @Column(name = "external_error_code", length = 128) + private String externalErrorCode; + + @Column(name = "elapsed_ms") + private Long elapsedMs; + + @Column(name = "started_at", nullable = false) + private OffsetDateTime startedAt; + + @Column(name = "ended_at") + private OffsetDateTime endedAt; + + public static ReportGenerationLog start( + User user, + ReportGenerationType reportType, + Long reportId, + ReportGenerationStep step, + LlmProvider llmProvider, + String llmModel + ) { + ReportGenerationLog log = new ReportGenerationLog(); + log.user = user; + log.reportType = reportType; + log.reportId = reportId; + log.step = step; + log.status = ReportGenerationLogStatus.STARTED; + log.llmProvider = llmProvider; + log.llmModel = llmModel; + log.startedAt = OffsetDateTime.now(); + return log; + } + + public void succeed() { + this.status = ReportGenerationLogStatus.SUCCEEDED; + finish(); + } + + public void fail( + String errorCode, + String exceptionClass, + Integer httpStatus, + String externalErrorCode + ) { + this.status = ReportGenerationLogStatus.FAILED; + this.errorCode = errorCode; + this.exceptionClass = exceptionClass; + this.httpStatus = httpStatus; + this.externalErrorCode = externalErrorCode; + finish(); + } + + private void finish() { + this.endedAt = OffsetDateTime.now(); + this.elapsedMs = Duration.between(this.startedAt, this.endedAt).toMillis(); + } +} diff --git a/src/main/java/com/devkor/ifive/nadab/domain/reportlog/core/entity/ReportGenerationLogStatus.java b/src/main/java/com/devkor/ifive/nadab/domain/reportlog/core/entity/ReportGenerationLogStatus.java new file mode 100644 index 00000000..53c3c820 --- /dev/null +++ b/src/main/java/com/devkor/ifive/nadab/domain/reportlog/core/entity/ReportGenerationLogStatus.java @@ -0,0 +1,7 @@ +package com.devkor.ifive.nadab.domain.reportlog.core.entity; + +public enum ReportGenerationLogStatus { + STARTED, + SUCCEEDED, + FAILED +} diff --git a/src/main/java/com/devkor/ifive/nadab/domain/reportlog/core/entity/ReportGenerationStep.java b/src/main/java/com/devkor/ifive/nadab/domain/reportlog/core/entity/ReportGenerationStep.java new file mode 100644 index 00000000..592641df --- /dev/null +++ b/src/main/java/com/devkor/ifive/nadab/domain/reportlog/core/entity/ReportGenerationStep.java @@ -0,0 +1,19 @@ +package com.devkor.ifive.nadab.domain.reportlog.core.entity; + +public enum ReportGenerationStep { + DAILY_GENERATE, + WEEKLY_GENERATE, + WEEKLY_CONFIRM, + MONTHLY_GENERATE, + MONTHLY_V2_GENERATE, + MONTHLY_CONFIRM, + MONTHLY_V2_TEXT_CONFIRM, + MONTHLY_V2_IMAGE_GENERATE, + MONTHLY_V2_CONFIRM, + TYPE_EVIDENCE_CARDS, + TYPE_PATTERN_EXTRACTION, + TYPE_SELECTION, + TYPE_CONTENT_GENERATION, + TYPE_CONFIRM, + REWRITE +} diff --git a/src/main/java/com/devkor/ifive/nadab/domain/reportlog/core/entity/ReportGenerationType.java b/src/main/java/com/devkor/ifive/nadab/domain/reportlog/core/entity/ReportGenerationType.java new file mode 100644 index 00000000..768a1a72 --- /dev/null +++ b/src/main/java/com/devkor/ifive/nadab/domain/reportlog/core/entity/ReportGenerationType.java @@ -0,0 +1,9 @@ +package com.devkor.ifive.nadab.domain.reportlog.core.entity; + +public enum ReportGenerationType { + DAILY, + WEEKLY, + MONTHLY, + MONTHLY_V2, + TYPE +} diff --git a/src/main/java/com/devkor/ifive/nadab/domain/reportlog/core/repository/ReportGenerationLogRepository.java b/src/main/java/com/devkor/ifive/nadab/domain/reportlog/core/repository/ReportGenerationLogRepository.java new file mode 100644 index 00000000..8609da43 --- /dev/null +++ b/src/main/java/com/devkor/ifive/nadab/domain/reportlog/core/repository/ReportGenerationLogRepository.java @@ -0,0 +1,20 @@ +package com.devkor.ifive.nadab.domain.reportlog.core.repository; + +import com.devkor.ifive.nadab.domain.reportlog.core.entity.ReportGenerationLog; +import com.devkor.ifive.nadab.domain.reportlog.core.entity.ReportGenerationLogStatus; +import com.devkor.ifive.nadab.domain.reportlog.core.entity.ReportGenerationType; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; + +public interface ReportGenerationLogRepository extends JpaRepository { + + List findAllByReportTypeAndReportIdOrderByCreatedAtDesc( + ReportGenerationType reportType, + Long reportId + ); + + List findAllByUserIdOrderByCreatedAtDesc(Long userId); + + List findAllByStatusOrderByCreatedAtDesc(ReportGenerationLogStatus status); +} diff --git a/src/main/java/com/devkor/ifive/nadab/domain/typereport/application/listener/TypeReportGenerationListener.java b/src/main/java/com/devkor/ifive/nadab/domain/typereport/application/listener/TypeReportGenerationListener.java index ea9da948..75400229 100644 --- a/src/main/java/com/devkor/ifive/nadab/domain/typereport/application/listener/TypeReportGenerationListener.java +++ b/src/main/java/com/devkor/ifive/nadab/domain/typereport/application/listener/TypeReportGenerationListener.java @@ -1,6 +1,9 @@ package com.devkor.ifive.nadab.domain.typereport.application.listener; import com.devkor.ifive.nadab.domain.dailyreport.core.entity.DailyReportStatus; +import com.devkor.ifive.nadab.domain.reportlog.application.ReportGenerationLogRecorder; +import com.devkor.ifive.nadab.domain.reportlog.core.entity.ReportGenerationStep; +import com.devkor.ifive.nadab.domain.reportlog.core.entity.ReportGenerationType; import com.devkor.ifive.nadab.domain.typereport.application.TypeReportTxService; import com.devkor.ifive.nadab.domain.typereport.application.event.TypeReportCompletedEvent; import com.devkor.ifive.nadab.domain.typereport.application.helper.TypeEmotionStatsCalculator; @@ -21,6 +24,7 @@ import com.devkor.ifive.nadab.domain.typereport.core.service.TypeSelectionService; import com.devkor.ifive.nadab.domain.user.core.entity.InterestCode; import com.devkor.ifive.nadab.domain.weeklyreport.core.dto.DailyEntryDto; +import com.devkor.ifive.nadab.global.infra.llm.LlmProvider; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.context.ApplicationEventPublisher; @@ -37,6 +41,10 @@ @Slf4j public class TypeReportGenerationListener { + private static final String TYPE_REPORT_OPENAI_MODEL = "GPT_4_O_MINI"; + private static final String TYPE_REPORT_GEMINI_MODEL = "GEMINI_2_5_FLASH"; + private static final int RECENT_N = 30; + private final TypeReportTxService typeReportTxService; // reportId -> interestCode 얻기 위해 필요 (PENDING row에 interest_code 있음) @@ -52,10 +60,9 @@ public class TypeReportGenerationListener { private final TypeSelectionService typeSelectionService; // Step3 private final TypeReportContentGenerationService typeReportContentGenerationService; // Step4 + private final ReportGenerationLogRecorder reportGenerationLogRecorder; private final ApplicationEventPublisher eventPublisher; - private static final int RECENT_N = 30; - @Async("typeReportTaskExecutor") @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) public void handle(TypeReportGenerationRequestedEventDto event) { @@ -109,9 +116,19 @@ public void handle(TypeReportGenerationRequestedEventDto event) { // 2) Step1: Evidence Cards 생성 List cards; + Long evidenceLogId = reportGenerationLogRecorder.start( + event.userId(), + ReportGenerationType.TYPE, + event.reportId(), + ReportGenerationStep.TYPE_EVIDENCE_CARDS, + LlmProvider.OPENAI, + TYPE_REPORT_OPENAI_MODEL + ); try { cards = evidenceCardGenerationService.generate(recentEntries); + reportGenerationLogRecorder.succeed(evidenceLogId); } catch (Exception e) { + reportGenerationLogRecorder.fail(evidenceLogId, e); log.error("[TYPE_REPORT][STEP1_FAILED] userId={}, reportId={}, interest={}", event.userId(), event.reportId(), interestCode, e); typeReportTxService.failAndRefundType(event.userId(), event.reportId(), event.crystalLogId()); @@ -120,9 +137,19 @@ public void handle(TypeReportGenerationRequestedEventDto event) { // 3) Step2 Patterns Extraction PatternExtractionResultDto patterns; + Long patternLogId = reportGenerationLogRecorder.start( + event.userId(), + ReportGenerationType.TYPE, + event.reportId(), + ReportGenerationStep.TYPE_PATTERN_EXTRACTION, + LlmProvider.OPENAI, + TYPE_REPORT_OPENAI_MODEL + ); try { patterns = patternExtractionService.extract(cards); + reportGenerationLogRecorder.succeed(patternLogId); } catch (Exception e) { + reportGenerationLogRecorder.fail(patternLogId, e); log.error("[TYPE_REPORT][STEP2_FAILED] userId={}, reportId={}, interest={}", event.userId(), event.reportId(), interestCode, e); typeReportTxService.failAndRefundType(event.userId(), event.reportId(), event.crystalLogId()); @@ -143,9 +170,19 @@ public void handle(TypeReportGenerationRequestedEventDto event) { // 5) Step3: 유형 선택 TypeSelectionResultDto selection; + Long selectionLogId = reportGenerationLogRecorder.start( + event.userId(), + ReportGenerationType.TYPE, + event.reportId(), + ReportGenerationStep.TYPE_SELECTION, + LlmProvider.OPENAI, + TYPE_REPORT_OPENAI_MODEL + ); try { selection = typeSelectionService.select(candidates, patterns); + reportGenerationLogRecorder.succeed(selectionLogId); } catch (Exception e) { + reportGenerationLogRecorder.fail(selectionLogId, e); log.error("[TYPE_REPORT][STEP3_FAILED] userId={}, reportId={}, interest={}", event.userId(), event.reportId(), interestCode, e); typeReportTxService.failAndRefundType(event.userId(), event.reportId(), event.crystalLogId()); @@ -167,6 +204,14 @@ public void handle(TypeReportGenerationRequestedEventDto event) { // 6) Step4: 최종 생성 TypeReportContentDto content; + Long contentLogId = reportGenerationLogRecorder.start( + event.userId(), + ReportGenerationType.TYPE, + event.reportId(), + ReportGenerationStep.TYPE_CONTENT_GENERATION, + LlmProvider.GEMINI, + TYPE_REPORT_GEMINI_MODEL + ); try { content = typeReportContentGenerationService.generate( selectedType, @@ -175,7 +220,9 @@ public void handle(TypeReportGenerationRequestedEventDto event) { emotionStats, selection.analysisTypeCode() ); + reportGenerationLogRecorder.succeed(contentLogId); } catch (Exception e) { + reportGenerationLogRecorder.fail(contentLogId, e); log.error("[TYPE_REPORT][STEP4_FAILED] userId={}, reportId={}, interest={}, code={}", event.userId(), event.reportId(), interestCode, selection.analysisTypeCode(), e); typeReportTxService.failAndRefundType(event.userId(), event.reportId(), event.crystalLogId()); @@ -183,6 +230,14 @@ public void handle(TypeReportGenerationRequestedEventDto event) { } // 7) confirm & 저장 + Long confirmLogId = reportGenerationLogRecorder.start( + event.userId(), + ReportGenerationType.TYPE, + event.reportId(), + ReportGenerationStep.TYPE_CONFIRM, + null, + null + ); try { typeReportTxService.confirmType( event.reportId(), @@ -204,8 +259,10 @@ public void handle(TypeReportGenerationRequestedEventDto event) { eventPublisher.publishEvent( new TypeReportCompletedEvent(event.reportId(), event.userId(), categoryName) ); + reportGenerationLogRecorder.succeed(confirmLogId); } catch (Exception e) { + reportGenerationLogRecorder.fail(confirmLogId, e); log.error("[TYPE_REPORT][CONFIRM_FAILED] userId={}, reportId={}, crystalLogId={}", event.userId(), event.reportId(), event.crystalLogId(), e); typeReportTxService.failAndRefundType(event.userId(), event.reportId(), event.crystalLogId()); diff --git a/src/main/java/com/devkor/ifive/nadab/domain/typereport/infra/TypeEvidenceCardLlmClient.java b/src/main/java/com/devkor/ifive/nadab/domain/typereport/infra/TypeEvidenceCardLlmClient.java index 9144d776..3df6a0a9 100644 --- a/src/main/java/com/devkor/ifive/nadab/domain/typereport/infra/TypeEvidenceCardLlmClient.java +++ b/src/main/java/com/devkor/ifive/nadab/domain/typereport/infra/TypeEvidenceCardLlmClient.java @@ -4,6 +4,7 @@ import com.devkor.ifive.nadab.global.core.response.ErrorCode; import com.devkor.ifive.nadab.global.exception.ai.AiResponseParseException; import com.devkor.ifive.nadab.global.exception.ai.AiServiceUnavailableException; +import com.devkor.ifive.nadab.global.infra.llm.LlmExceptionMapper; import com.devkor.ifive.nadab.global.infra.llm.LlmProvider; import com.devkor.ifive.nadab.global.infra.llm.LlmRouter; import com.fasterxml.jackson.core.type.TypeReference; @@ -33,14 +34,19 @@ public List> generateRawCardsJsonArray(String entriesText) { ChatClient client = llmRouter.route(provider); - String content = client.prompt() - .user(prompt) - .options(OpenAiChatOptions.builder() - .model(OpenAiApi.ChatModel.GPT_4_O_MINI) - .temperature(0.0) - .build()) - .call() - .content(); + String content; + try { + content = client.prompt() + .user(prompt) + .options(OpenAiChatOptions.builder() + .model(OpenAiApi.ChatModel.GPT_4_O_MINI) + .temperature(0.0) + .build()) + .call() + .content(); + } catch (Exception e) { + throw LlmExceptionMapper.toUnavailable(ErrorCode.AI_EVIDENCE_CARD_NO_RESPONSE, e); + } if (content == null || content.trim().isEmpty()) { throw new AiServiceUnavailableException(ErrorCode.AI_EVIDENCE_CARD_NO_RESPONSE); diff --git a/src/main/java/com/devkor/ifive/nadab/domain/typereport/infra/TypePatternExtractLlmClient.java b/src/main/java/com/devkor/ifive/nadab/domain/typereport/infra/TypePatternExtractLlmClient.java index b0a3fdb8..625b9dee 100644 --- a/src/main/java/com/devkor/ifive/nadab/domain/typereport/infra/TypePatternExtractLlmClient.java +++ b/src/main/java/com/devkor/ifive/nadab/domain/typereport/infra/TypePatternExtractLlmClient.java @@ -4,6 +4,7 @@ import com.devkor.ifive.nadab.global.core.response.ErrorCode; import com.devkor.ifive.nadab.global.exception.ai.AiResponseParseException; import com.devkor.ifive.nadab.global.exception.ai.AiServiceUnavailableException; +import com.devkor.ifive.nadab.global.infra.llm.LlmExceptionMapper; import com.devkor.ifive.nadab.global.infra.llm.LlmProvider; import com.devkor.ifive.nadab.global.infra.llm.LlmRouter; import com.fasterxml.jackson.databind.JsonNode; @@ -34,11 +35,16 @@ public JsonNode extractPatternsRawJson(String cardsText) { .temperature(0.0) .build(); - String content = client.prompt() - .user(prompt) - .options(options) - .call() - .content(); + String content; + try { + content = client.prompt() + .user(prompt) + .options(options) + .call() + .content(); + } catch (Exception e) { + throw LlmExceptionMapper.toUnavailable(ErrorCode.AI_PATTERN_EXTRACT_NO_RESPONSE, e); + } if (content == null || content.trim().isEmpty()) { throw new AiServiceUnavailableException(ErrorCode.AI_PATTERN_EXTRACT_NO_RESPONSE); diff --git a/src/main/java/com/devkor/ifive/nadab/domain/typereport/infra/TypeReportLlmClient.java b/src/main/java/com/devkor/ifive/nadab/domain/typereport/infra/TypeReportLlmClient.java index 7dc74508..2d3f37b9 100644 --- a/src/main/java/com/devkor/ifive/nadab/domain/typereport/infra/TypeReportLlmClient.java +++ b/src/main/java/com/devkor/ifive/nadab/domain/typereport/infra/TypeReportLlmClient.java @@ -4,6 +4,7 @@ import com.devkor.ifive.nadab.global.core.response.ErrorCode; import com.devkor.ifive.nadab.global.exception.ai.AiResponseParseException; import com.devkor.ifive.nadab.global.exception.ai.AiServiceUnavailableException; +import com.devkor.ifive.nadab.global.infra.llm.LlmExceptionMapper; import com.devkor.ifive.nadab.global.infra.llm.LlmProvider; import com.devkor.ifive.nadab.global.infra.llm.LlmRouter; import com.devkor.ifive.nadab.global.shared.reportcontent.Mark; @@ -74,11 +75,16 @@ public JsonNode generateRaw(String selectedType, String patterns, String evidenc .temperature(0.0) .build(); - String content = client.prompt() - .user(prompt) - .options(options) - .call() - .content(); + String content; + try { + content = client.prompt() + .user(prompt) + .options(options) + .call() + .content(); + } catch (Exception e) { + throw LlmExceptionMapper.toUnavailable(ErrorCode.AI_NO_RESPONSE, e); + } if (content == null || content.trim().isEmpty()) { throw new AiServiceUnavailableException(ErrorCode.AI_NO_RESPONSE); @@ -306,7 +312,12 @@ private StyledText rewriteTypeAnalysis(ChatClient client, StyledText in) { .temperature(0.0) .build(); - String out = client.prompt().user(prompt).options(options).call().content(); + String out; + try { + out = client.prompt().user(prompt).options(options).call().content(); + } catch (Exception e) { + throw LlmExceptionMapper.toUnavailable(ErrorCode.TYPE_REPORT_REWRITE_NO_RESPONSE, e); + } if (out == null || out.isBlank()) { throw new AiServiceUnavailableException(ErrorCode.TYPE_REPORT_REWRITE_NO_RESPONSE); @@ -395,7 +406,12 @@ private String rewritePersonaContent(ChatClient client, String in, int personaIn .temperature(0.0) .build(); - String out = client.prompt().user(prompt).options(options).call().content(); + String out; + try { + out = client.prompt().user(prompt).options(options).call().content(); + } catch (Exception e) { + throw LlmExceptionMapper.toUnavailable(ErrorCode.TYPE_REPORT_REWRITE_NO_RESPONSE, e); + } if (out == null || out.isBlank()) { throw new AiServiceUnavailableException(ErrorCode.TYPE_REPORT_REWRITE_NO_RESPONSE); diff --git a/src/main/java/com/devkor/ifive/nadab/domain/typereport/infra/TypeSelectLlmClient.java b/src/main/java/com/devkor/ifive/nadab/domain/typereport/infra/TypeSelectLlmClient.java index 731d8c35..a1fe2265 100644 --- a/src/main/java/com/devkor/ifive/nadab/domain/typereport/infra/TypeSelectLlmClient.java +++ b/src/main/java/com/devkor/ifive/nadab/domain/typereport/infra/TypeSelectLlmClient.java @@ -4,6 +4,7 @@ import com.devkor.ifive.nadab.global.core.response.ErrorCode; import com.devkor.ifive.nadab.global.exception.ai.AiResponseParseException; import com.devkor.ifive.nadab.global.exception.ai.AiServiceUnavailableException; +import com.devkor.ifive.nadab.global.infra.llm.LlmExceptionMapper; import com.devkor.ifive.nadab.global.infra.llm.LlmProvider; import com.devkor.ifive.nadab.global.infra.llm.LlmRouter; import com.fasterxml.jackson.databind.JsonNode; @@ -40,11 +41,16 @@ public JsonNode selectTypeRawJson(String candidatesText, String patternsText) { .temperature(0.0) .build(); - String content = client.prompt() - .user(prompt) - .options(options) - .call() - .content(); + String content; + try { + content = client.prompt() + .user(prompt) + .options(options) + .call() + .content(); + } catch (Exception e) { + throw LlmExceptionMapper.toUnavailable(ErrorCode.AI_TYPE_SELECT_NO_RESPONSE, e); + } if (content == null || content.trim().isEmpty()) { throw new AiServiceUnavailableException(ErrorCode.AI_TYPE_SELECT_NO_RESPONSE); diff --git a/src/main/java/com/devkor/ifive/nadab/domain/weeklyreport/application/listener/WeeklyReportGenerationListener.java b/src/main/java/com/devkor/ifive/nadab/domain/weeklyreport/application/listener/WeeklyReportGenerationListener.java index c87e2333..1e0a11f5 100644 --- a/src/main/java/com/devkor/ifive/nadab/domain/weeklyreport/application/listener/WeeklyReportGenerationListener.java +++ b/src/main/java/com/devkor/ifive/nadab/domain/weeklyreport/application/listener/WeeklyReportGenerationListener.java @@ -1,12 +1,16 @@ package com.devkor.ifive.nadab.domain.weeklyreport.application.listener; +import com.devkor.ifive.nadab.domain.reportlog.application.ReportGenerationLogRecorder; +import com.devkor.ifive.nadab.domain.reportlog.core.entity.ReportGenerationStep; +import com.devkor.ifive.nadab.domain.reportlog.core.entity.ReportGenerationType; import com.devkor.ifive.nadab.domain.weeklyreport.application.WeeklyReportTxService; import com.devkor.ifive.nadab.domain.weeklyreport.application.event.WeeklyReportCompletedEvent; import com.devkor.ifive.nadab.domain.weeklyreport.application.helper.WeeklyEntriesAssembler; import com.devkor.ifive.nadab.domain.weeklyreport.core.dto.DailyEntryDto; import com.devkor.ifive.nadab.domain.weeklyreport.core.dto.WeeklyReportGenerationRequestedEventDto; -import com.devkor.ifive.nadab.domain.weeklyreport.infra.WeeklyReportLlmClient; import com.devkor.ifive.nadab.domain.weeklyreport.core.repository.WeeklyQueryRepository; +import com.devkor.ifive.nadab.domain.weeklyreport.infra.WeeklyReportLlmClient; +import com.devkor.ifive.nadab.global.infra.llm.LlmProvider; import com.devkor.ifive.nadab.global.shared.reportcontent.AiReportResultDto; import com.devkor.ifive.nadab.global.shared.util.WeekRangeCalculator; import com.devkor.ifive.nadab.global.shared.util.dto.WeekRangeDto; @@ -25,17 +29,17 @@ @Slf4j public class WeeklyReportGenerationListener { + private static final String WEEKLY_REPORT_LLM_MODEL = "GEMINI_2_5_FLASH"; + private final WeeklyQueryRepository weeklyQueryRepository; private final WeeklyReportLlmClient weeklyReportLlmClient; private final WeeklyReportTxService weeklyReportTxService; + private final ReportGenerationLogRecorder reportGenerationLogRecorder; private final ApplicationEventPublisher eventPublisher; - private static final int MAX_LEN = 245; - @Async("weeklyReportTaskExecutor") - @TransactionalEventListener(phase = - TransactionPhase.AFTER_COMMIT) + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) public void handle(WeeklyReportGenerationRequestedEventDto event) { WeekRangeDto range = WeekRangeCalculator.getLastWeekRange(); @@ -44,10 +48,20 @@ public void handle(WeeklyReportGenerationRequestedEventDto event) { String entries = WeeklyEntriesAssembler.assemble(rows); AiReportResultDto dto; + Long generationLogId = reportGenerationLogRecorder.start( + event.userId(), + ReportGenerationType.WEEKLY, + event.reportId(), + ReportGenerationStep.WEEKLY_GENERATE, + LlmProvider.GEMINI, + WEEKLY_REPORT_LLM_MODEL + ); try { // 트랜잭션 밖(백그라운드)에서 LLM 호출 dto = weeklyReportLlmClient.generate(range.weekStartDate().toString(), range.weekEndDate().toString(), entries); + reportGenerationLogRecorder.succeed(generationLogId); } catch (Exception e) { + reportGenerationLogRecorder.fail(generationLogId, e); log.error("[WEEKLY_REPORT][LLM_FAILED] userId={}, reportId={}", event.userId(), event.reportId(), e); @@ -60,7 +74,14 @@ public void handle(WeeklyReportGenerationRequestedEventDto event) { return; } - // 성공 확정(별도 트랜잭션) + Long confirmLogId = reportGenerationLogRecorder.start( + event.userId(), + ReportGenerationType.WEEKLY, + event.reportId(), + ReportGenerationStep.WEEKLY_CONFIRM, + null, + null + ); try { weeklyReportTxService.confirmWeekly( event.reportId(), @@ -70,10 +91,12 @@ public void handle(WeeklyReportGenerationRequestedEventDto event) { // 주간 리포트 완성 이벤트 발행 eventPublisher.publishEvent( - new WeeklyReportCompletedEvent(event.reportId(), event.userId()) + new WeeklyReportCompletedEvent(event.reportId(), event.userId()) ); + reportGenerationLogRecorder.succeed(confirmLogId); } catch (Exception e) { + reportGenerationLogRecorder.fail(confirmLogId, e); log.error("[WEEKLY_REPORT][CONFIRM_FAILED] userId={}, reportId={}, crystalLogId={}", event.userId(), event.reportId(), event.crystalLogId(), e); @@ -84,14 +107,5 @@ public void handle(WeeklyReportGenerationRequestedEventDto event) { event.crystalLogId() ); } - - } - - // 최대 길이 자르기 - private String cut(String s) { - if (s == null) return null; - s = s.trim(); - return (s.length() <= MAX_LEN) ? s : s.substring(0, MAX_LEN); } } - diff --git a/src/main/java/com/devkor/ifive/nadab/domain/weeklyreport/infra/WeeklyReportLlmClient.java b/src/main/java/com/devkor/ifive/nadab/domain/weeklyreport/infra/WeeklyReportLlmClient.java index db178cb3..e3dd40f2 100644 --- a/src/main/java/com/devkor/ifive/nadab/domain/weeklyreport/infra/WeeklyReportLlmClient.java +++ b/src/main/java/com/devkor/ifive/nadab/domain/weeklyreport/infra/WeeklyReportLlmClient.java @@ -4,6 +4,7 @@ import com.devkor.ifive.nadab.global.core.response.ErrorCode; import com.devkor.ifive.nadab.global.exception.ai.AiResponseParseException; import com.devkor.ifive.nadab.global.exception.ai.AiServiceUnavailableException; +import com.devkor.ifive.nadab.global.infra.llm.LlmExceptionMapper; import com.devkor.ifive.nadab.global.infra.llm.LlmProvider; import com.devkor.ifive.nadab.global.infra.llm.LlmRouter; import com.devkor.ifive.nadab.global.shared.reportcontent.*; @@ -129,11 +130,15 @@ private String callOpenAi(ChatClient client, String prompt) { .temperature(1.0) .build(); - return client.prompt() - .user(prompt) - .options(options) - .call() - .content(); + try { + return client.prompt() + .user(prompt) + .options(options) + .call() + .content(); + } catch (Exception e) { + throw LlmExceptionMapper.toUnavailable(ErrorCode.AI_NO_RESPONSE, e); + } } private String callClaude(ChatClient client, String prompt) { @@ -142,11 +147,15 @@ private String callClaude(ChatClient client, String prompt) { .temperature(0.3) .build(); - return client.prompt() - .user(prompt) - .options(options) - .call() - .content(); + try { + return client.prompt() + .user(prompt) + .options(options) + .call() + .content(); + } catch (Exception e) { + throw LlmExceptionMapper.toUnavailable(ErrorCode.AI_NO_RESPONSE, e); + } } private String callGemini(ChatClient client, String prompt) { @@ -156,11 +165,15 @@ private String callGemini(ChatClient client, String prompt) { .temperature(0.3) .build(); - return client.prompt() - .user(prompt) - .options(options) - .call() - .content(); + try { + return client.prompt() + .user(prompt) + .options(options) + .call() + .content(); + } catch (Exception e) { + throw LlmExceptionMapper.toUnavailable(ErrorCode.AI_NO_RESPONSE, e); + } } private AiReportResultDto enforceLength(AiReportResultDto dto) { @@ -229,7 +242,12 @@ private StyledText rewriteStyled(ChatClient client, StyledText in, boolean isDis .temperature(0.0) .build(); - String out = client.prompt().user(prompt).options(options).call().content(); + String out; + try { + out = client.prompt().user(prompt).options(options).call().content(); + } catch (Exception e) { + throw LlmExceptionMapper.toUnavailable(ErrorCode.AI_REWRITE_NO_RESPONSE, e); + } if (out == null || out.isBlank()) { throw new AiServiceUnavailableException(ErrorCode.AI_REWRITE_NO_RESPONSE); diff --git a/src/main/java/com/devkor/ifive/nadab/global/exception/BusinessException.java b/src/main/java/com/devkor/ifive/nadab/global/exception/BusinessException.java index 0b0ab823..0c4323e4 100644 --- a/src/main/java/com/devkor/ifive/nadab/global/exception/BusinessException.java +++ b/src/main/java/com/devkor/ifive/nadab/global/exception/BusinessException.java @@ -10,10 +10,15 @@ */ @Getter public abstract class BusinessException extends RuntimeException { - private ErrorCode errorCode; + private final ErrorCode errorCode; protected BusinessException(ErrorCode errorCode) { super(errorCode.getMessage()); this.errorCode = errorCode; } -} \ No newline at end of file + + protected BusinessException(ErrorCode errorCode, Throwable cause) { + super(errorCode.getMessage(), cause); + this.errorCode = errorCode; + } +} diff --git a/src/main/java/com/devkor/ifive/nadab/global/exception/ai/AiServiceException.java b/src/main/java/com/devkor/ifive/nadab/global/exception/ai/AiServiceException.java index 9ee15802..629162fe 100644 --- a/src/main/java/com/devkor/ifive/nadab/global/exception/ai/AiServiceException.java +++ b/src/main/java/com/devkor/ifive/nadab/global/exception/ai/AiServiceException.java @@ -8,5 +8,9 @@ public class AiServiceException extends BusinessException { public AiServiceException(ErrorCode errorCode) { super(errorCode); } + + public AiServiceException(ErrorCode errorCode, Throwable cause) { + super(errorCode, cause); + } } diff --git a/src/main/java/com/devkor/ifive/nadab/global/exception/ai/AiServiceUnavailableException.java b/src/main/java/com/devkor/ifive/nadab/global/exception/ai/AiServiceUnavailableException.java index 32d1e7f0..01c7c303 100644 --- a/src/main/java/com/devkor/ifive/nadab/global/exception/ai/AiServiceUnavailableException.java +++ b/src/main/java/com/devkor/ifive/nadab/global/exception/ai/AiServiceUnavailableException.java @@ -1,10 +1,34 @@ package com.devkor.ifive.nadab.global.exception.ai; import com.devkor.ifive.nadab.global.core.response.ErrorCode; +import lombok.Getter; +@Getter public class AiServiceUnavailableException extends AiServiceException { + private final Integer externalHttpStatus; + private final String externalErrorCode; + public AiServiceUnavailableException(ErrorCode errorCode) { super(errorCode); + this.externalHttpStatus = null; + this.externalErrorCode = null; + } + + public AiServiceUnavailableException(ErrorCode errorCode, Throwable cause) { + super(errorCode, cause); + this.externalHttpStatus = null; + this.externalErrorCode = null; + } + + public AiServiceUnavailableException( + ErrorCode errorCode, + Integer externalHttpStatus, + String externalErrorCode, + Throwable cause + ) { + super(errorCode, cause); + this.externalHttpStatus = externalHttpStatus; + this.externalErrorCode = externalErrorCode; } } diff --git a/src/main/java/com/devkor/ifive/nadab/global/infra/llm/LlmExceptionMapper.java b/src/main/java/com/devkor/ifive/nadab/global/infra/llm/LlmExceptionMapper.java new file mode 100644 index 00000000..4774574c --- /dev/null +++ b/src/main/java/com/devkor/ifive/nadab/global/infra/llm/LlmExceptionMapper.java @@ -0,0 +1,55 @@ +package com.devkor.ifive.nadab.global.infra.llm; + +import com.devkor.ifive.nadab.global.core.response.ErrorCode; +import com.devkor.ifive.nadab.global.exception.ai.AiServiceUnavailableException; +import org.springframework.http.HttpStatusCode; +import org.springframework.web.client.HttpStatusCodeException; +import org.springframework.web.client.RestClientResponseException; +import org.springframework.web.reactive.function.client.WebClientResponseException; + +public final class LlmExceptionMapper { + + private LlmExceptionMapper() { + } + + public static AiServiceUnavailableException toUnavailable(ErrorCode errorCode, Exception exception) { + ExternalError externalError = extractExternalError(exception); + return new AiServiceUnavailableException( + errorCode, + externalError == null ? null : externalError.httpStatus(), + externalError == null ? null : externalError.externalErrorCode(), + exception + ); + } + + private static ExternalError extractExternalError(Throwable throwable) { + Throwable current = throwable; + while (current != null) { + if (current instanceof WebClientResponseException webClientException) { + int status = webClientException.getStatusCode().value(); + return new ExternalError(status, toExternalErrorCode(status)); + } + if (current instanceof HttpStatusCodeException httpStatusException) { + HttpStatusCode statusCode = httpStatusException.getStatusCode(); + int status = statusCode.value(); + return new ExternalError(status, toExternalErrorCode(status)); + } + if (current instanceof RestClientResponseException restClientException) { + int status = restClientException.getStatusCode().value(); + return new ExternalError(status, toExternalErrorCode(status)); + } + current = current.getCause(); + } + return null; + } + + private static String toExternalErrorCode(int httpStatus) { + return "HTTP_" + httpStatus; + } + + private record ExternalError( + Integer httpStatus, + String externalErrorCode + ) { + } +} diff --git a/src/main/resources/db/migration/V20260616_1200__IS_create_report_generation_logs_table.sql b/src/main/resources/db/migration/V20260616_1200__IS_create_report_generation_logs_table.sql new file mode 100644 index 00000000..848d4101 --- /dev/null +++ b/src/main/resources/db/migration/V20260616_1200__IS_create_report_generation_logs_table.sql @@ -0,0 +1,26 @@ +CREATE TABLE report_generation_logs ( + id BIGSERIAL PRIMARY KEY, + user_id BIGINT, + report_type VARCHAR(32) NOT NULL, + report_id BIGINT, + step VARCHAR(64) NOT NULL, + status VARCHAR(32) NOT NULL, + llm_provider VARCHAR(32), + llm_model VARCHAR(128), + error_code VARCHAR(128), + exception_class VARCHAR(255), + http_status INTEGER, + external_error_code VARCHAR(128), + elapsed_ms BIGINT, + started_at TIMESTAMPTZ NOT NULL, + ended_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +ALTER TABLE report_generation_logs + ADD CONSTRAINT fk_report_generation_logs_user + FOREIGN KEY (user_id) REFERENCES users(id) + ON DELETE SET NULL; + +CREATE INDEX idx_report_generation_logs_report + ON report_generation_logs(report_type, report_id, created_at DESC); diff --git a/src/main/resources/templates/admin/login.html b/src/main/resources/templates/admin/login.html index 0858d5c0..b2051dfa 100644 --- a/src/main/resources/templates/admin/login.html +++ b/src/main/resources/templates/admin/login.html @@ -133,7 +133,7 @@

관리자 로그인

return; } - let message = '로그인에 실패했습니다.'; + let message = `로그인에 실패했습니다. (HTTP ${response.status})`; try { const errorBody = await response.json(); if (errorBody && errorBody.message) { diff --git a/src/test/java/com/devkor/ifive/nadab/domain/reportlog/ReportGenerationLogRepositoryTest.java b/src/test/java/com/devkor/ifive/nadab/domain/reportlog/ReportGenerationLogRepositoryTest.java new file mode 100644 index 00000000..ecd85e8e --- /dev/null +++ b/src/test/java/com/devkor/ifive/nadab/domain/reportlog/ReportGenerationLogRepositoryTest.java @@ -0,0 +1,99 @@ +package com.devkor.ifive.nadab.domain.reportlog; + +import com.devkor.ifive.nadab.domain.reportlog.core.entity.ReportGenerationLog; +import com.devkor.ifive.nadab.domain.reportlog.core.entity.ReportGenerationLogStatus; +import com.devkor.ifive.nadab.domain.reportlog.core.entity.ReportGenerationStep; +import com.devkor.ifive.nadab.domain.reportlog.core.entity.ReportGenerationType; +import com.devkor.ifive.nadab.domain.reportlog.core.repository.ReportGenerationLogRepository; +import com.devkor.ifive.nadab.domain.user.core.entity.User; +import com.devkor.ifive.nadab.global.infra.llm.LlmProvider; +import com.devkor.ifive.nadab.infra.builder.UserBuilder; +import com.devkor.ifive.nadab.infra.db.PostgresIntegrationTestSupport; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; +import org.springframework.boot.test.autoconfigure.orm.jpa.TestEntityManager; +import org.springframework.test.context.ActiveProfiles; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +@DataJpaTest +@ActiveProfiles("test") +class ReportGenerationLogRepositoryTest extends PostgresIntegrationTestSupport { + + @Autowired + ReportGenerationLogRepository reportGenerationLogRepository; + + @Autowired + TestEntityManager em; + + @Test + void find_by_report_type_and_report_id() { + // given + User user = new UserBuilder(em).build(); + ReportGenerationLog target = reportGenerationLogRepository.save( + startLog(user, ReportGenerationType.DAILY, 101L, ReportGenerationStep.DAILY_GENERATE) + ); + reportGenerationLogRepository.save( + startLog(user, ReportGenerationType.WEEKLY, 202L, ReportGenerationStep.WEEKLY_GENERATE) + ); + + em.flush(); + em.clear(); + + // when + List logs = reportGenerationLogRepository + .findAllByReportTypeAndReportIdOrderByCreatedAtDesc(ReportGenerationType.DAILY, 101L); + + // then + assertThat(logs).hasSize(1); + assertThat(logs.get(0).getId()).isEqualTo(target.getId()); + assertThat(logs.get(0).getStatus()).isEqualTo(ReportGenerationLogStatus.STARTED); + } + + @Test + void find_by_status() { + // given + User user = new UserBuilder(em).build(); + ReportGenerationLog succeeded = startLog(user, ReportGenerationType.DAILY, 101L, ReportGenerationStep.DAILY_GENERATE); + succeeded.succeed(); + reportGenerationLogRepository.save(succeeded); + + ReportGenerationLog failed = startLog(user, ReportGenerationType.WEEKLY, 202L, ReportGenerationStep.WEEKLY_GENERATE); + failed.fail("AI_NO_RESPONSE", "test.Exception", 503, "HTTP_503"); + reportGenerationLogRepository.save(failed); + + em.flush(); + em.clear(); + + // when + List logs = reportGenerationLogRepository + .findAllByStatusOrderByCreatedAtDesc(ReportGenerationLogStatus.FAILED); + + // then + assertThat(logs) + .extracting(ReportGenerationLog::getReportId) + .contains(202L); + assertThat(logs) + .extracting(ReportGenerationLog::getStatus) + .containsOnly(ReportGenerationLogStatus.FAILED); + } + + private ReportGenerationLog startLog( + User user, + ReportGenerationType reportType, + Long reportId, + ReportGenerationStep step + ) { + return ReportGenerationLog.start( + user, + reportType, + reportId, + step, + LlmProvider.OPENAI, + "GPT_4_O_MINI" + ); + } +} diff --git a/src/test/java/com/devkor/ifive/nadab/domain/reportlog/application/ReportGenerationLogServiceTest.java b/src/test/java/com/devkor/ifive/nadab/domain/reportlog/application/ReportGenerationLogServiceTest.java new file mode 100644 index 00000000..019aa218 --- /dev/null +++ b/src/test/java/com/devkor/ifive/nadab/domain/reportlog/application/ReportGenerationLogServiceTest.java @@ -0,0 +1,120 @@ +package com.devkor.ifive.nadab.domain.reportlog.application; + +import com.devkor.ifive.nadab.domain.reportlog.core.entity.ReportGenerationLog; +import com.devkor.ifive.nadab.domain.reportlog.core.entity.ReportGenerationLogStatus; +import com.devkor.ifive.nadab.domain.reportlog.core.entity.ReportGenerationStep; +import com.devkor.ifive.nadab.domain.reportlog.core.entity.ReportGenerationType; +import com.devkor.ifive.nadab.domain.reportlog.core.repository.ReportGenerationLogRepository; +import com.devkor.ifive.nadab.domain.user.core.entity.User; +import com.devkor.ifive.nadab.domain.user.core.repository.UserRepository; +import com.devkor.ifive.nadab.global.core.response.ErrorCode; +import com.devkor.ifive.nadab.global.exception.ai.AiServiceUnavailableException; +import com.devkor.ifive.nadab.global.infra.llm.LlmProvider; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class ReportGenerationLogServiceTest { + + @Mock + ReportGenerationLogRepository reportGenerationLogRepository; + + @Mock + UserRepository userRepository; + + @InjectMocks + ReportGenerationLogService reportGenerationLogService; + + @Test + void start_saves_started_log() { + // given + User user = User.createUser("test@test.com", "hashed_password"); + when(userRepository.findById(1L)).thenReturn(Optional.of(user)); + when(reportGenerationLogRepository.save(org.mockito.ArgumentMatchers.any(ReportGenerationLog.class))) + .thenAnswer(invocation -> invocation.getArgument(0)); + + // when + ReportGenerationLog saved = reportGenerationLogService.start( + 1L, + ReportGenerationType.DAILY, + 100L, + ReportGenerationStep.DAILY_GENERATE, + LlmProvider.OPENAI, + "GPT_4_O_MINI" + ); + + // then + ArgumentCaptor captor = ArgumentCaptor.forClass(ReportGenerationLog.class); + verify(reportGenerationLogRepository).save(captor.capture()); + + assertThat(saved).isSameAs(captor.getValue()); + assertThat(saved.getUser()).isSameAs(user); + assertThat(saved.getReportType()).isEqualTo(ReportGenerationType.DAILY); + assertThat(saved.getReportId()).isEqualTo(100L); + assertThat(saved.getStep()).isEqualTo(ReportGenerationStep.DAILY_GENERATE); + assertThat(saved.getStatus()).isEqualTo(ReportGenerationLogStatus.STARTED); + assertThat(saved.getStartedAt()).isNotNull(); + } + + @Test + void succeed_marks_log_succeeded() { + // given + ReportGenerationLog log = startLog(); + when(reportGenerationLogRepository.findById(1L)).thenReturn(Optional.of(log)); + + // when + reportGenerationLogService.succeed(1L); + + // then + assertThat(log.getStatus()).isEqualTo(ReportGenerationLogStatus.SUCCEEDED); + assertThat(log.getEndedAt()).isNotNull(); + assertThat(log.getElapsedMs()).isNotNull(); + } + + @Test + void fail_marks_log_failed_with_external_status() { + // given + ReportGenerationLog log = startLog(); + when(reportGenerationLogRepository.findById(1L)).thenReturn(Optional.of(log)); + + AiServiceUnavailableException exception = new AiServiceUnavailableException( + ErrorCode.AI_NO_RESPONSE, + 503, + "HTTP_503", + new RuntimeException("provider failed") + ); + + // when + reportGenerationLogService.fail(1L, exception); + + // then + assertThat(log.getStatus()).isEqualTo(ReportGenerationLogStatus.FAILED); + assertThat(log.getErrorCode()).isEqualTo(ErrorCode.AI_NO_RESPONSE.getCode()); + assertThat(log.getExceptionClass()).isEqualTo(AiServiceUnavailableException.class.getName()); + assertThat(log.getHttpStatus()).isEqualTo(503); + assertThat(log.getExternalErrorCode()).isEqualTo("HTTP_503"); + assertThat(log.getEndedAt()).isNotNull(); + assertThat(log.getElapsedMs()).isNotNull(); + } + + private ReportGenerationLog startLog() { + return ReportGenerationLog.start( + null, + ReportGenerationType.DAILY, + 100L, + ReportGenerationStep.DAILY_GENERATE, + LlmProvider.OPENAI, + "GPT_4_O_MINI" + ); + } +}