From 804f35c8b25a4ada2f4f7c671de8f24ba54cf181 Mon Sep 17 00:00:00 2001 From: 1Seob Date: Fri, 29 May 2026 18:39:39 +0900 Subject: [PATCH 1/6] =?UTF-8?q?refactor(report):=20=EC=9B=94=EA=B0=84=20?= =?UTF-8?q?=EB=A6=AC=ED=8F=AC=ED=8A=B8=20v1/v2=20=EB=A1=9C=EC=A7=81=20?= =?UTF-8?q?=EB=B6=84=EB=A6=AC=20=EB=B0=8F=20v1=20=EB=B3=B5=EA=B5=AC=20?= =?UTF-8?q?=EA=B8=B0=EB=B0=98=20=EC=A0=95=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit v2 전용 Service/TxService/Listener/EventDto/PendingService/LlmClient 클래스 분리, 기존 v1 컴포넌트는 v1 흐름으로 복구할 수 있게 책임 재정렬 --- AGENTS.md | 11 + .../api/MonthlyReportControllerV2.java | 6 +- .../application/MonthlyReportServiceV2.java | 49 +++ .../application/MonthlyReportTxService.java | 96 ++--- .../application/MonthlyReportTxServiceV2.java | 178 ++++++++++ .../MonthlyReportGenerationListener.java | 122 +------ .../MonthlyReportGenerationListenerV2.java | 197 +++++++++++ ...thlyReportGenerationRequestedEventDto.java | 3 +- ...lyReportGenerationRequestedEventDtoV2.java | 9 + .../service/PendingMonthlyReportService.java | 22 +- .../PendingMonthlyReportServiceV2.java | 56 +++ .../infra/MonthlyReportLlmClient.java | 97 +++-- .../infra/MonthlyReportLlmClientV2.java | 332 ++++++++++++++++++ .../LocalMonthlyReportPromptLoader.java | 30 +- .../monthly/MonthlyReportPromptLoader.java | 4 +- .../SecretMonthlyReportPromptLoader.java | 25 +- 16 files changed, 972 insertions(+), 265 deletions(-) create mode 100644 AGENTS.md create mode 100644 src/main/java/com/devkor/ifive/nadab/domain/monthlyreport/application/MonthlyReportServiceV2.java create mode 100644 src/main/java/com/devkor/ifive/nadab/domain/monthlyreport/application/MonthlyReportTxServiceV2.java create mode 100644 src/main/java/com/devkor/ifive/nadab/domain/monthlyreport/application/listener/MonthlyReportGenerationListenerV2.java create mode 100644 src/main/java/com/devkor/ifive/nadab/domain/monthlyreport/core/dto/MonthlyReportGenerationRequestedEventDtoV2.java create mode 100644 src/main/java/com/devkor/ifive/nadab/domain/monthlyreport/core/service/PendingMonthlyReportServiceV2.java create mode 100644 src/main/java/com/devkor/ifive/nadab/domain/monthlyreport/infra/MonthlyReportLlmClientV2.java diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 00000000..2b4b483b --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,11 @@ +# AGENTS.md + +## Editing policy + +- Prefer minimal, targeted edits over large rewrites. +- Do not delete and recreate files unless explicitly requested. +- Do not reformat unrelated code. +- Preserve existing structure, naming, comments, and style. +- When fixing a bug, change only the smallest necessary scope. +- Before replacing an entire file, explain why a small patch is not sufficient. +- Avoid touching files unrelated to the user's request. \ No newline at end of file diff --git a/src/main/java/com/devkor/ifive/nadab/domain/monthlyreport/api/MonthlyReportControllerV2.java b/src/main/java/com/devkor/ifive/nadab/domain/monthlyreport/api/MonthlyReportControllerV2.java index 32ea5a3c..9706a12d 100644 --- a/src/main/java/com/devkor/ifive/nadab/domain/monthlyreport/api/MonthlyReportControllerV2.java +++ b/src/main/java/com/devkor/ifive/nadab/domain/monthlyreport/api/MonthlyReportControllerV2.java @@ -6,7 +6,7 @@ import com.devkor.ifive.nadab.domain.monthlyreport.api.dto.response.MonthlyReportStartResponse; import com.devkor.ifive.nadab.domain.monthlyreport.api.dto.response.ReportListTypeV2; import com.devkor.ifive.nadab.domain.monthlyreport.application.MonthlyReportQueryServiceV2; -import com.devkor.ifive.nadab.domain.monthlyreport.application.MonthlyReportService; +import com.devkor.ifive.nadab.domain.monthlyreport.application.MonthlyReportServiceV2; import com.devkor.ifive.nadab.domain.weeklyreport.api.dto.response.CompletedCountResponse; import com.devkor.ifive.nadab.global.core.response.ApiResponseDto; import com.devkor.ifive.nadab.global.core.response.ApiResponseEntity; @@ -31,7 +31,7 @@ @RequiredArgsConstructor public class MonthlyReportControllerV2 { - private final MonthlyReportService monthlyReportService; + private final MonthlyReportServiceV2 monthlyReportServiceV2; private final MonthlyReportQueryServiceV2 monthlyReportQueryServiceV2; @PostMapping("/start") @@ -83,7 +83,7 @@ public class MonthlyReportControllerV2 { public ResponseEntity> startMonthlyReport( @AuthenticationPrincipal UserPrincipal principal ) { - MonthlyReportStartResponse response = monthlyReportService.startMonthlyReport(principal.getId()); + MonthlyReportStartResponse response = monthlyReportServiceV2.startMonthlyReport(principal.getId()); return ApiResponseEntity.ok(response); } diff --git a/src/main/java/com/devkor/ifive/nadab/domain/monthlyreport/application/MonthlyReportServiceV2.java b/src/main/java/com/devkor/ifive/nadab/domain/monthlyreport/application/MonthlyReportServiceV2.java new file mode 100644 index 00000000..2296a93d --- /dev/null +++ b/src/main/java/com/devkor/ifive/nadab/domain/monthlyreport/application/MonthlyReportServiceV2.java @@ -0,0 +1,49 @@ +package com.devkor.ifive.nadab.domain.monthlyreport.application; + +import com.devkor.ifive.nadab.domain.dailyreport.core.repository.DailyReportRepository; +import com.devkor.ifive.nadab.domain.monthlyreport.api.dto.response.MonthlyReportStartResponse; +import com.devkor.ifive.nadab.domain.monthlyreport.core.dto.MonthlyReserveResultDto; +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.domain.weeklyreport.api.dto.response.CompletedCountResponse; +import com.devkor.ifive.nadab.global.core.response.ErrorCode; +import com.devkor.ifive.nadab.global.exception.NotFoundException; +import com.devkor.ifive.nadab.global.exception.report.MonthlyReportNotEligibleException; +import com.devkor.ifive.nadab.global.shared.util.MonthRangeCalculator; +import com.devkor.ifive.nadab.global.shared.util.dto.MonthRangeDto; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +public class MonthlyReportServiceV2 { + + private final UserRepository userRepository; + private final DailyReportRepository dailyReportRepository; + + private final MonthlyReportTxServiceV2 monthlyReportTxServiceV2; + + /** + * 비동기 시작 API: 즉시 reportId 반환 + */ + public MonthlyReportStartResponse startMonthlyReport(Long userId) { + User user = userRepository.findById(userId) + .orElseThrow(() -> new NotFoundException(ErrorCode.USER_NOT_FOUND)); + + // 월간 리포트 작성 자격 확인 (저번 달에 15회 이상 완료) + MonthRangeDto range = MonthRangeCalculator.getLastMonthRange(); + + long completedCount = dailyReportRepository.countCompletedInMonth(userId, range.monthStartDate(), range.monthEndDate()); + boolean eligible = completedCount >= 15; + + if (!eligible) { + CompletedCountResponse response = new CompletedCountResponse(completedCount); + throw new MonthlyReportNotEligibleException(ErrorCode.MONTHLY_REPORT_NOT_ENOUGH_REPORTS, response); + } + + // (Tx) Report(PENDING) + reserve consume + log(PENDING) + MonthlyReserveResultDto reserve = monthlyReportTxServiceV2.reserveMonthlyAndPublish(user); + + return new MonthlyReportStartResponse(reserve.reportId(), "PENDING", reserve.balanceAfter()); + } +} diff --git a/src/main/java/com/devkor/ifive/nadab/domain/monthlyreport/application/MonthlyReportTxService.java b/src/main/java/com/devkor/ifive/nadab/domain/monthlyreport/application/MonthlyReportTxService.java index a81c0872..cd6c31db 100644 --- a/src/main/java/com/devkor/ifive/nadab/domain/monthlyreport/application/MonthlyReportTxService.java +++ b/src/main/java/com/devkor/ifive/nadab/domain/monthlyreport/application/MonthlyReportTxService.java @@ -1,15 +1,12 @@ package com.devkor.ifive.nadab.domain.monthlyreport.application; -import com.devkor.ifive.nadab.domain.monthlyreport.core.content.InterestStatsContent; import com.devkor.ifive.nadab.domain.monthlyreport.core.dto.MonthlyReportGenerationRequestedEventDto; import com.devkor.ifive.nadab.domain.monthlyreport.core.dto.MonthlyReserveResultDto; -import com.devkor.ifive.nadab.domain.monthlyreport.core.entity.*; +import com.devkor.ifive.nadab.domain.monthlyreport.core.entity.MonthlyReport; +import com.devkor.ifive.nadab.domain.monthlyreport.core.entity.MonthlyReportStatus; import com.devkor.ifive.nadab.domain.monthlyreport.core.repository.MonthlyReportRepository; -import com.devkor.ifive.nadab.domain.monthlyreport.core.repository.MonthlyReportV2Repository; import com.devkor.ifive.nadab.domain.monthlyreport.core.service.PendingMonthlyReportService; -import com.devkor.ifive.nadab.domain.typereport.core.content.TypeEmotionStatsContent; -import com.devkor.ifive.nadab.domain.typereport.core.content.TypeTextContent; import com.devkor.ifive.nadab.domain.user.core.entity.User; import com.devkor.ifive.nadab.domain.wallet.core.entity.CrystalLog; import com.devkor.ifive.nadab.domain.wallet.core.entity.CrystalLogReason; @@ -20,6 +17,7 @@ import com.devkor.ifive.nadab.global.exception.NotEnoughCrystalException; import com.devkor.ifive.nadab.global.exception.NotFoundException; import com.devkor.ifive.nadab.global.exception.ai.AiResponseParseException; +import com.devkor.ifive.nadab.global.shared.reportcontent.ReportContent; import com.fasterxml.jackson.databind.ObjectMapper; import lombok.RequiredArgsConstructor; import org.springframework.context.ApplicationEventPublisher; @@ -34,7 +32,6 @@ public class MonthlyReportTxService { private final PendingMonthlyReportService pendingMonthlyReportService; private final MonthlyReportRepository monthlyReportRepository; - private final MonthlyReportV2Repository monthlyReportV2Repository; private final UserWalletRepository userWalletRepository; private final CrystalLogRepository crystalLogRepository; @@ -48,10 +45,10 @@ public class MonthlyReportTxService { * (Tx) MonthlyReport(PENDING) + reserve consume + CrystalLog(PENDING) * 커밋되면 리포트 생성 작업을 시작할 준비가 완료됨 */ - public MonthlyReserveResultDto reserveMonthly(User user, boolean exists) { + public MonthlyReserveResultDto reserveMonthly(User user) { // Report: 있으면 기존 사용, 없으면 새로 PENDING 생성 - MonthlyReportV2 report = pendingMonthlyReportService.getOrCreatePendingMonthlyReport(user, exists); + MonthlyReport report = pendingMonthlyReportService.getOrCreatePendingMonthlyReport(user); // 선차감(원자적) + balanceAfter 확보 int updated = userWalletRepository.tryConsume(user.getId(), MONTHLY_REPORT_COST); @@ -80,79 +77,43 @@ public MonthlyReserveResultDto reserveMonthly(User user, boolean exists) { } public MonthlyReserveResultDto reserveMonthlyAndPublish(User user) { + MonthlyReserveResultDto reserve = this.reserveMonthly(user); - boolean exists = monthlyReportV2Repository.existsByUserIdAndStatus(user.getId(), MonthlyReportStatus.COMPLETED); - - MonthlyReserveResultDto reserve = this.reserveMonthly(user, exists); - - monthlyReportV2Repository.updateStatus(reserve.reportId(), MonthlyReportStatus.IN_PROGRESS); + monthlyReportRepository.updateStatus(reserve.reportId(), MonthlyReportStatus.IN_PROGRESS); // 트랜잭션 안에서 publish (AFTER_COMMIT 트리거 보장) eventPublisher.publishEvent(new MonthlyReportGenerationRequestedEventDto( reserve.reportId(), user.getId(), - reserve.crystalLogId(), - exists + reserve.crystalLogId() )); return reserve; } - public void confirmMonthly(Long reportId, Long logId, String imageKey) { - monthlyReportV2Repository.completeWithImage( - reportId, - imageKey, - MonthlyReportImageStatus.COMPLETED, - MonthlyReportStatus.COMPLETED - ); + public void confirmMonthly(Long reportId, Long logId, ReportContent content) { + ReportContent normalized = content.normalized(); - // log를 CONFIRMED로 - crystalLogRepository.markConfirmed(logId); - } - - public void confirmMonthlyText( - Long reportId, - MonthlyReportV2Content content, - TypeTextContent emotionSummaryContent, - TypeEmotionStatsContent emotionStats, - InterestStatsContent interestStats - ) { - MonthlyReportV2Content contentNormalized = content.normalized(); - TypeTextContent emotionSummaryContentNormalized = emotionSummaryContent.normalized(); - InterestStatsContent interestStatsNormalized = interestStats.normalized(); - - String summary = contentNormalized.summary(); - String commentSummary = contentNormalized.commentSummary(); - String dominantKeyword = contentNormalized.dominantKeyword(); + String summary = normalized.summary(); + String discovered = normalized.discovered().plainText(); + String improve = normalized.improve().plainText(); + // report를 COMPLETED로 String contentJson; - String emotionSummaryContentJson; - String emotionStatsJson; - String interestStatsJson; - try { - contentJson = objectMapper.writeValueAsString(contentNormalized); - emotionSummaryContentJson = objectMapper.writeValueAsString(emotionSummaryContentNormalized); - emotionStatsJson = objectMapper.writeValueAsString(emotionStats.normalized()); - interestStatsJson = objectMapper.writeValueAsString(interestStatsNormalized); + contentJson = objectMapper.writeValueAsString(normalized); } catch (Exception e) { throw new AiResponseParseException(ErrorCode.AI_RESPONSE_PARSE_FAILED); } - monthlyReportV2Repository.updateContent( - reportId, - contentJson, - emotionSummaryContentJson, - summary, - commentSummary, - dominantKeyword, - emotionStatsJson, - interestStatsJson, - MonthlyReportStatus.TEXT_COMPLETED.name() - ); + monthlyReportRepository.markCompleted( + reportId, MonthlyReportStatus.COMPLETED.name(), contentJson, discovered, improve, summary); + + // log를 CONFIRMED로 + crystalLogRepository.markConfirmed(logId); } public void failAndRefundMonthly(Long userId, Long reportId, Long logId) { - monthlyReportV2Repository.markFailed(reportId, MonthlyReportStatus.FAILED); + monthlyReportRepository.markFailed(reportId, MonthlyReportStatus.FAILED); // 환불(+cost) int updated = userWalletRepository.refund(userId, MONTHLY_REPORT_COST); @@ -164,17 +125,4 @@ public void failAndRefundMonthly(Long userId, Long reportId, Long logId) { // log를 REFUNDED로 crystalLogRepository.markRefunded(logId); } - - public void markMonthlyImageProcessing(Long reportId) { - monthlyReportV2Repository.updateImageStatus(reportId, MonthlyReportImageStatus.PROCESSING); - } - - public void failMonthlyImage(Long reportId) { - monthlyReportV2Repository.updateImageStatus(reportId, MonthlyReportImageStatus.FAILED); - } - - public void failAndRefundMonthlyWithImage(Long userId, Long reportId, Long logId) { - this.failAndRefundMonthly(userId, reportId, logId); - this.failMonthlyImage(reportId); - } -} +} \ No newline at end of file diff --git a/src/main/java/com/devkor/ifive/nadab/domain/monthlyreport/application/MonthlyReportTxServiceV2.java b/src/main/java/com/devkor/ifive/nadab/domain/monthlyreport/application/MonthlyReportTxServiceV2.java new file mode 100644 index 00000000..be32ae0c --- /dev/null +++ b/src/main/java/com/devkor/ifive/nadab/domain/monthlyreport/application/MonthlyReportTxServiceV2.java @@ -0,0 +1,178 @@ +package com.devkor.ifive.nadab.domain.monthlyreport.application; + + +import com.devkor.ifive.nadab.domain.monthlyreport.core.content.InterestStatsContent; +import com.devkor.ifive.nadab.domain.monthlyreport.core.dto.MonthlyReportGenerationRequestedEventDtoV2; +import com.devkor.ifive.nadab.domain.monthlyreport.core.dto.MonthlyReserveResultDto; +import com.devkor.ifive.nadab.domain.monthlyreport.core.entity.*; +import com.devkor.ifive.nadab.domain.monthlyreport.core.repository.MonthlyReportV2Repository; +import com.devkor.ifive.nadab.domain.monthlyreport.core.service.PendingMonthlyReportServiceV2; +import com.devkor.ifive.nadab.domain.typereport.core.content.TypeEmotionStatsContent; +import com.devkor.ifive.nadab.domain.typereport.core.content.TypeTextContent; +import com.devkor.ifive.nadab.domain.user.core.entity.User; +import com.devkor.ifive.nadab.domain.wallet.core.entity.CrystalLog; +import com.devkor.ifive.nadab.domain.wallet.core.entity.CrystalLogReason; +import com.devkor.ifive.nadab.domain.wallet.core.entity.UserWallet; +import com.devkor.ifive.nadab.domain.wallet.core.repository.CrystalLogRepository; +import com.devkor.ifive.nadab.domain.wallet.core.repository.UserWalletRepository; +import com.devkor.ifive.nadab.global.core.response.ErrorCode; +import com.devkor.ifive.nadab.global.exception.NotEnoughCrystalException; +import com.devkor.ifive.nadab.global.exception.NotFoundException; +import com.devkor.ifive.nadab.global.exception.ai.AiResponseParseException; +import com.fasterxml.jackson.databind.ObjectMapper; +import lombok.RequiredArgsConstructor; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +@Transactional +public class MonthlyReportTxServiceV2 { + + private final PendingMonthlyReportServiceV2 pendingMonthlyReportServiceV2; + + private final MonthlyReportV2Repository monthlyReportV2Repository; + private final UserWalletRepository userWalletRepository; + private final CrystalLogRepository crystalLogRepository; + + private final ApplicationEventPublisher eventPublisher; + private final ObjectMapper objectMapper; + + + private static final long MONTHLY_REPORT_COST = 40L; + + /** + * (Tx) MonthlyReport(PENDING) + reserve consume + CrystalLog(PENDING) + * 커밋되면 리포트 생성 작업을 시작할 준비가 완료됨 + */ + public MonthlyReserveResultDto reserveMonthly(User user, boolean exists) { + + // Report: 있으면 기존 사용, 없으면 새로 PENDING 생성 + MonthlyReportV2 report = pendingMonthlyReportServiceV2.getOrCreatePendingMonthlyReport(user, exists); + + // 선차감(원자적) + balanceAfter 확보 + int updated = userWalletRepository.tryConsume(user.getId(), MONTHLY_REPORT_COST); + if (updated == 0) { + throw new NotEnoughCrystalException(ErrorCode.WALLET_INSUFFICIENT_BALANCE); + } + + UserWallet wallet = userWalletRepository.findByUserId(user.getId()) + .orElseThrow(() -> new NotFoundException(ErrorCode.WALLET_NOT_FOUND)); + long balanceAfter = wallet.getCrystalBalance(); + + + // 로그(PENDING) + CrystalLog log = crystalLogRepository.save( + CrystalLog.createPending( + user, + -MONTHLY_REPORT_COST, + balanceAfter, + CrystalLogReason.REPORT_GENERATE_MONTHLY, + "MONTHLY_REPORT_V2", + report.getId() + ) + ); + + return new MonthlyReserveResultDto(report.getId(), log.getId(), user.getId(), balanceAfter); + } + + public MonthlyReserveResultDto reserveMonthlyAndPublish(User user) { + + boolean exists = monthlyReportV2Repository.existsByUserIdAndStatus(user.getId(), MonthlyReportStatus.COMPLETED); + + MonthlyReserveResultDto reserve = this.reserveMonthly(user, exists); + + monthlyReportV2Repository.updateStatus(reserve.reportId(), MonthlyReportStatus.IN_PROGRESS); + + // 트랜잭션 안에서 publish (AFTER_COMMIT 트리거 보장) + eventPublisher.publishEvent(new MonthlyReportGenerationRequestedEventDtoV2( + reserve.reportId(), + user.getId(), + reserve.crystalLogId(), + exists + )); + + return reserve; + } + + public void confirmMonthly(Long reportId, Long logId, String imageKey) { + monthlyReportV2Repository.completeWithImage( + reportId, + imageKey, + MonthlyReportImageStatus.COMPLETED, + MonthlyReportStatus.COMPLETED + ); + + // log를 CONFIRMED로 + crystalLogRepository.markConfirmed(logId); + } + + public void confirmMonthlyText( + Long reportId, + MonthlyReportV2Content content, + TypeTextContent emotionSummaryContent, + TypeEmotionStatsContent emotionStats, + InterestStatsContent interestStats + ) { + MonthlyReportV2Content contentNormalized = content.normalized(); + TypeTextContent emotionSummaryContentNormalized = emotionSummaryContent.normalized(); + InterestStatsContent interestStatsNormalized = interestStats.normalized(); + + String summary = contentNormalized.summary(); + String commentSummary = contentNormalized.commentSummary(); + String dominantKeyword = contentNormalized.dominantKeyword(); + + String contentJson; + String emotionSummaryContentJson; + String emotionStatsJson; + String interestStatsJson; + + try { + contentJson = objectMapper.writeValueAsString(contentNormalized); + emotionSummaryContentJson = objectMapper.writeValueAsString(emotionSummaryContentNormalized); + emotionStatsJson = objectMapper.writeValueAsString(emotionStats.normalized()); + interestStatsJson = objectMapper.writeValueAsString(interestStatsNormalized); + } catch (Exception e) { + throw new AiResponseParseException(ErrorCode.AI_RESPONSE_PARSE_FAILED); + } + monthlyReportV2Repository.updateContent( + reportId, + contentJson, + emotionSummaryContentJson, + summary, + commentSummary, + dominantKeyword, + emotionStatsJson, + interestStatsJson, + MonthlyReportStatus.TEXT_COMPLETED.name() + ); + } + + public void failAndRefundMonthly(Long userId, Long reportId, Long logId) { + monthlyReportV2Repository.markFailed(reportId, MonthlyReportStatus.FAILED); + + // 환불(+cost) + int updated = userWalletRepository.refund(userId, MONTHLY_REPORT_COST); + if (updated == 0) { + // wallet이 없을 수 있는 상황 + throw new NotFoundException(ErrorCode.WALLET_NOT_FOUND); + } + + // log를 REFUNDED로 + crystalLogRepository.markRefunded(logId); + } + + public void markMonthlyImageProcessing(Long reportId) { + monthlyReportV2Repository.updateImageStatus(reportId, MonthlyReportImageStatus.PROCESSING); + } + + public void failMonthlyImage(Long reportId) { + monthlyReportV2Repository.updateImageStatus(reportId, MonthlyReportImageStatus.FAILED); + } + + public void failAndRefundMonthlyWithImage(Long userId, Long reportId, Long logId) { + this.failAndRefundMonthly(userId, reportId, logId); + this.failMonthlyImage(reportId); + } +} 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 abe20aa7..ea1ab71d 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 @@ -1,22 +1,15 @@ package com.devkor.ifive.nadab.domain.monthlyreport.application.listener; -import com.devkor.ifive.nadab.domain.dailyreport.core.entity.DailyReportStatus; import com.devkor.ifive.nadab.domain.monthlyreport.application.MonthlyReportTxService; import com.devkor.ifive.nadab.domain.monthlyreport.application.event.MonthlyReportCompletedEvent; -import com.devkor.ifive.nadab.domain.monthlyreport.application.helper.MonthlyInterestStatsCalculator; import com.devkor.ifive.nadab.domain.monthlyreport.application.helper.MonthlyRepresentativePicker; -import com.devkor.ifive.nadab.domain.monthlyreport.core.content.InterestStatsContent; -import com.devkor.ifive.nadab.domain.monthlyreport.core.dto.AiMonthlyReportResultDto; import com.devkor.ifive.nadab.domain.monthlyreport.core.dto.MonthlyReportGenerationRequestedEventDto; 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.MonthlyReportImageStorage; import com.devkor.ifive.nadab.domain.monthlyreport.infra.MonthlyReportLlmClient; -import com.devkor.ifive.nadab.domain.monthlyreport.infra.OpenAiImageClient; -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.shared.reportcontent.AiReportResultDto; import com.devkor.ifive.nadab.global.shared.util.MonthRangeCalculator; import com.devkor.ifive.nadab.global.shared.util.dto.MonthRangeDto; import lombok.RequiredArgsConstructor; @@ -37,13 +30,12 @@ public class MonthlyReportGenerationListener { private final MonthlyQueryRepository monthlyQueryRepository; private final MonthlyReportLlmClient monthlyReportLlmClient; - private final OpenAiImageClient openAiImageClient; - private final MonthlyReportImageStorage monthlyReportImageStorage; - private final MonthlyReportTxService monthlyReportTxService; private final MonthlyWeeklySummariesService monthlyWeeklySummariesService; private final ApplicationEventPublisher eventPublisher; + private static final int MAX_LEN = 245; + @Async("monthlyReportTaskExecutor") @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) @@ -59,59 +51,11 @@ public void handle(MonthlyReportGenerationRequestedEventDto event) { // 2. 주간 리포트 선택 String weeklySummaries = monthlyWeeklySummariesService.buildWeeklySummaries(event.userId(), range); - // 3. 해당 월(COMPLETED DailyReport) 감정 통계 집계 - TypeEmotionStatsContent emotionStats; - try { - emotionStats = TypeEmotionStatsCalculator.calculate( - monthlyQueryRepository.countCompletedEmotionStatsByRange( - event.userId(), - DailyReportStatus.COMPLETED, - range.monthStartDate(), - range.monthEndDate() - ) - ); - } catch (Exception e) { - log.error("[MONTHLY_REPORT][EMOTION_STATS_FAILED] userId={}, reportId={}", - event.userId(), event.reportId(), e); - monthlyReportTxService.failAndRefundMonthly( - event.userId(), - event.reportId(), - event.crystalLogId() - ); - return; - } - - InterestStatsContent interestStats; - try { - interestStats = MonthlyInterestStatsCalculator.calculate( - monthlyQueryRepository.countCompletedInterestStatsByRange( - event.userId(), - DailyReportStatus.COMPLETED, - range.monthStartDate(), - range.monthEndDate() - ) - ); - } catch (Exception e) { - log.error("[MONTHLY_REPORT][INTEREST_STATS_FAILED] userId={}, reportId={}", - event.userId(), event.reportId(), e); - monthlyReportTxService.failAndRefundMonthly( - event.userId(), - event.reportId(), - event.crystalLogId() - ); - return; - } - - AiMonthlyReportResultDto dto; + AiReportResultDto dto; try { // 트랜잭션 밖(백그라운드)에서 LLM 호출 dto = monthlyReportLlmClient.generate( - range.monthStartDate().toString(), - range.monthEndDate().toString(), - weeklySummaries, - representativeEntries, - emotionStats, - event.exists()); + range.monthStartDate().toString(), range.monthEndDate().toString(), weeklySummaries, representativeEntries); } catch (Exception e) { log.error("[MONTHLY_REPORT][LLM_FAILED] userId={}, reportId={}", event.userId(), event.reportId(), e); @@ -125,55 +69,12 @@ public void handle(MonthlyReportGenerationRequestedEventDto event) { return; } - // 텍스트 생성 성공 확정(별도 트랜잭션) - try { - monthlyReportTxService.confirmMonthlyText( - event.reportId(), - dto.content(), - dto.emotionSummaryContent(), - emotionStats, - interestStats - ); - - } catch (Exception e) { - log.error("[MONTHLY_REPORT][TEXT_CONFIRM_FAILED] userId={}, reportId={}, crystalLogId={}", - event.userId(), event.reportId(), event.crystalLogId(), e); - - // 저장 실패면 결과를 못 주는 거니까 "실패 확정 + 환불"로 처리 - monthlyReportTxService.failAndRefundMonthly( - event.userId(), - event.reportId(), - event.crystalLogId() - ); - return; - } - - String imageKey = ""; - try { - String base64Image = openAiImageClient.generateBase64Image(event.userId(), dto, range); - imageKey = monthlyReportImageStorage.uploadBase64Webp( - event.userId(), - event.reportId(), - base64Image - ); - - } catch (Exception e) { - log.error("[MONTHLY_REPORT][IMAGE_FAILED] userId={}, reportId={}, crystalLogId={}", - event.userId(), event.reportId(), event.crystalLogId(), e); - - monthlyReportTxService.failAndRefundMonthlyWithImage( - event.userId(), - event.reportId(), - event.crystalLogId() - ); - return; - } - + // 성공 확정(별도 트랜잭션) try { monthlyReportTxService.confirmMonthly( event.reportId(), event.crystalLogId(), - imageKey + dto.content() ); // 월간 리포트 완성 이벤트 발행 @@ -192,6 +93,13 @@ 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 new file mode 100644 index 00000000..16ff49d6 --- /dev/null +++ b/src/main/java/com/devkor/ifive/nadab/domain/monthlyreport/application/listener/MonthlyReportGenerationListenerV2.java @@ -0,0 +1,197 @@ +package com.devkor.ifive.nadab.domain.monthlyreport.application.listener; + +import com.devkor.ifive.nadab.domain.dailyreport.core.entity.DailyReportStatus; +import com.devkor.ifive.nadab.domain.monthlyreport.application.MonthlyReportTxServiceV2; +import com.devkor.ifive.nadab.domain.monthlyreport.application.event.MonthlyReportCompletedEvent; +import com.devkor.ifive.nadab.domain.monthlyreport.application.helper.MonthlyInterestStatsCalculator; +import com.devkor.ifive.nadab.domain.monthlyreport.application.helper.MonthlyRepresentativePicker; +import com.devkor.ifive.nadab.domain.monthlyreport.core.content.InterestStatsContent; +import com.devkor.ifive.nadab.domain.monthlyreport.core.dto.AiMonthlyReportResultDto; +import com.devkor.ifive.nadab.domain.monthlyreport.core.dto.MonthlyReportGenerationRequestedEventDtoV2; +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.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.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.shared.util.MonthRangeCalculator; +import com.devkor.ifive.nadab.global.shared.util.dto.MonthRangeDto; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.scheduling.annotation.Async; +import org.springframework.stereotype.Component; +import org.springframework.transaction.event.TransactionPhase; +import org.springframework.transaction.event.TransactionalEventListener; + +import java.util.List; + +@Component +@RequiredArgsConstructor +@Slf4j +public class MonthlyReportGenerationListenerV2 { + + private final MonthlyQueryRepository monthlyQueryRepository; + + private final MonthlyReportLlmClientV2 monthlyReportLlmClientV2; + private final OpenAiImageClient openAiImageClient; + private final MonthlyReportImageStorage monthlyReportImageStorage; + + private final MonthlyReportTxServiceV2 monthlyReportTxServiceV2; + private final MonthlyWeeklySummariesService monthlyWeeklySummariesService; + private final ApplicationEventPublisher eventPublisher; + + @Async("monthlyReportTaskExecutor") + @TransactionalEventListener(phase = + TransactionPhase.AFTER_COMMIT) + public void handle(MonthlyReportGenerationRequestedEventDtoV2 event) { + + MonthRangeDto range = MonthRangeCalculator.getLastMonthRange(); + + // 1. 일간 리포트 대표 항목 선택 + List rows = monthlyQueryRepository.findMonthlyInputs(event.userId(), range.monthStartDate(), range.monthEndDate()); + List entries = MonthlyRepresentativePicker.pick(rows, 6); + String representativeEntries = WeeklyEntriesAssembler.assemble(entries); + + // 2. 주간 리포트 선택 + String weeklySummaries = monthlyWeeklySummariesService.buildWeeklySummaries(event.userId(), range); + + // 3. 해당 월(COMPLETED DailyReport) 감정 통계 집계 + TypeEmotionStatsContent emotionStats; + try { + emotionStats = TypeEmotionStatsCalculator.calculate( + monthlyQueryRepository.countCompletedEmotionStatsByRange( + event.userId(), + DailyReportStatus.COMPLETED, + range.monthStartDate(), + range.monthEndDate() + ) + ); + } catch (Exception e) { + log.error("[MONTHLY_REPORT][EMOTION_STATS_FAILED] userId={}, reportId={}", + event.userId(), event.reportId(), e); + monthlyReportTxServiceV2.failAndRefundMonthly( + event.userId(), + event.reportId(), + event.crystalLogId() + ); + return; + } + + InterestStatsContent interestStats; + try { + interestStats = MonthlyInterestStatsCalculator.calculate( + monthlyQueryRepository.countCompletedInterestStatsByRange( + event.userId(), + DailyReportStatus.COMPLETED, + range.monthStartDate(), + range.monthEndDate() + ) + ); + } catch (Exception e) { + log.error("[MONTHLY_REPORT][INTEREST_STATS_FAILED] userId={}, reportId={}", + event.userId(), event.reportId(), e); + monthlyReportTxServiceV2.failAndRefundMonthly( + event.userId(), + event.reportId(), + event.crystalLogId() + ); + return; + } + + AiMonthlyReportResultDto dto; + try { + // 트랜잭션 밖(백그라운드)에서 LLM 호출 + dto = monthlyReportLlmClientV2.generate( + range.monthStartDate().toString(), + range.monthEndDate().toString(), + weeklySummaries, + representativeEntries, + emotionStats, + event.exists()); + } catch (Exception e) { + log.error("[MONTHLY_REPORT][LLM_FAILED] userId={}, reportId={}", + event.userId(), event.reportId(), e); + + // 실패 확정 + 환불은 별도 트랜잭션에서 + monthlyReportTxServiceV2.failAndRefundMonthly( + event.userId(), + event.reportId(), + event.crystalLogId() + ); + return; + } + + // 텍스트 생성 성공 확정(별도 트랜잭션) + try { + monthlyReportTxServiceV2.confirmMonthlyText( + event.reportId(), + dto.content(), + dto.emotionSummaryContent(), + emotionStats, + interestStats + ); + + } catch (Exception e) { + log.error("[MONTHLY_REPORT][TEXT_CONFIRM_FAILED] userId={}, reportId={}, crystalLogId={}", + event.userId(), event.reportId(), event.crystalLogId(), e); + + // 저장 실패면 결과를 못 주는 거니까 "실패 확정 + 환불"로 처리 + monthlyReportTxServiceV2.failAndRefundMonthly( + event.userId(), + event.reportId(), + event.crystalLogId() + ); + return; + } + + String imageKey = ""; + try { + String base64Image = openAiImageClient.generateBase64Image(event.userId(), dto, range); + imageKey = monthlyReportImageStorage.uploadBase64Webp( + event.userId(), + event.reportId(), + base64Image + ); + + } catch (Exception e) { + log.error("[MONTHLY_REPORT][IMAGE_FAILED] userId={}, reportId={}, crystalLogId={}", + event.userId(), event.reportId(), event.crystalLogId(), e); + + monthlyReportTxServiceV2.failAndRefundMonthlyWithImage( + event.userId(), + event.reportId(), + event.crystalLogId() + ); + return; + } + + try { + monthlyReportTxServiceV2.confirmMonthly( + event.reportId(), + event.crystalLogId(), + imageKey + ); + + // 월간 리포트 완성 이벤트 발행 + eventPublisher.publishEvent( + new MonthlyReportCompletedEvent(event.reportId(), event.userId()) + ); + + } catch (Exception e) { + log.error("[MONTHLY_REPORT][CONFIRM_FAILED] userId={}, reportId={}, crystalLogId={}", + event.userId(), event.reportId(), event.crystalLogId(), e); + + // 저장 실패면 결과를 못 주는 거니까 "실패 확정 + 환불"로 처리 + monthlyReportTxServiceV2.failAndRefundMonthly( + event.userId(), + event.reportId(), + event.crystalLogId() + ); + } + } +} + diff --git a/src/main/java/com/devkor/ifive/nadab/domain/monthlyreport/core/dto/MonthlyReportGenerationRequestedEventDto.java b/src/main/java/com/devkor/ifive/nadab/domain/monthlyreport/core/dto/MonthlyReportGenerationRequestedEventDto.java index 04eeb1e4..e8bf6635 100644 --- a/src/main/java/com/devkor/ifive/nadab/domain/monthlyreport/core/dto/MonthlyReportGenerationRequestedEventDto.java +++ b/src/main/java/com/devkor/ifive/nadab/domain/monthlyreport/core/dto/MonthlyReportGenerationRequestedEventDto.java @@ -3,7 +3,6 @@ public record MonthlyReportGenerationRequestedEventDto( Long reportId, Long userId, - Long crystalLogId, - boolean exists + Long crystalLogId ) { } diff --git a/src/main/java/com/devkor/ifive/nadab/domain/monthlyreport/core/dto/MonthlyReportGenerationRequestedEventDtoV2.java b/src/main/java/com/devkor/ifive/nadab/domain/monthlyreport/core/dto/MonthlyReportGenerationRequestedEventDtoV2.java new file mode 100644 index 00000000..a93f3358 --- /dev/null +++ b/src/main/java/com/devkor/ifive/nadab/domain/monthlyreport/core/dto/MonthlyReportGenerationRequestedEventDtoV2.java @@ -0,0 +1,9 @@ +package com.devkor.ifive.nadab.domain.monthlyreport.core.dto; + +public record MonthlyReportGenerationRequestedEventDtoV2( + Long reportId, + Long userId, + Long crystalLogId, + boolean exists +) { +} diff --git a/src/main/java/com/devkor/ifive/nadab/domain/monthlyreport/core/service/PendingMonthlyReportService.java b/src/main/java/com/devkor/ifive/nadab/domain/monthlyreport/core/service/PendingMonthlyReportService.java index 5c919776..f16e00b8 100644 --- a/src/main/java/com/devkor/ifive/nadab/domain/monthlyreport/core/service/PendingMonthlyReportService.java +++ b/src/main/java/com/devkor/ifive/nadab/domain/monthlyreport/core/service/PendingMonthlyReportService.java @@ -1,9 +1,8 @@ package com.devkor.ifive.nadab.domain.monthlyreport.core.service; -import com.devkor.ifive.nadab.domain.monthlyreport.core.entity.MonthlyReportComparisonType; +import com.devkor.ifive.nadab.domain.monthlyreport.core.entity.MonthlyReport; import com.devkor.ifive.nadab.domain.monthlyreport.core.entity.MonthlyReportStatus; -import com.devkor.ifive.nadab.domain.monthlyreport.core.entity.MonthlyReportV2; -import com.devkor.ifive.nadab.domain.monthlyreport.core.repository.MonthlyReportV2Repository; +import com.devkor.ifive.nadab.domain.monthlyreport.core.repository.MonthlyReportRepository; import com.devkor.ifive.nadab.domain.user.core.entity.User; import com.devkor.ifive.nadab.global.core.response.ErrorCode; import com.devkor.ifive.nadab.global.exception.ConflictException; @@ -20,23 +19,20 @@ @RequiredArgsConstructor public class PendingMonthlyReportService { - private final MonthlyReportV2Repository monthlyReportV2Repository; + private final MonthlyReportRepository monthlyReportRepository; @Transactional - public MonthlyReportV2 getOrCreatePendingMonthlyReport(User user, boolean exists) { + public MonthlyReport getOrCreatePendingMonthlyReport(User user) { MonthRangeDto range = MonthRangeCalculator.getLastMonthRange(); LocalDate today = TodayDateTimeProvider.getTodayDate(); - MonthlyReportComparisonType comparisonType = exists ? MonthlyReportComparisonType.COMPARISON : MonthlyReportComparisonType.BASELINE; - - MonthlyReportV2 report = monthlyReportV2Repository.findByUserIdAndMonthStartDate(user.getId(), range.monthStartDate()) - .orElseGet(() -> monthlyReportV2Repository.save(MonthlyReportV2.createPending( + MonthlyReport report = monthlyReportRepository.findByUserIdAndMonthStartDate(user.getId(), range.monthStartDate()) + .orElseGet(() -> monthlyReportRepository.save(MonthlyReport.createPending( user, range.monthStartDate(), range.monthEndDate(), - today, - comparisonType + today ))); if (report.getStatus() == MonthlyReportStatus.COMPLETED) { @@ -48,9 +44,9 @@ public MonthlyReportV2 getOrCreatePendingMonthlyReport(User user, boolean exists } if (report.getStatus() == MonthlyReportStatus.FAILED) { - monthlyReportV2Repository.updateStatus(report.getId(), MonthlyReportStatus.PENDING); + monthlyReportRepository.updateStatus(report.getId(), MonthlyReportStatus.PENDING); } return report; } -} +} \ No newline at end of file diff --git a/src/main/java/com/devkor/ifive/nadab/domain/monthlyreport/core/service/PendingMonthlyReportServiceV2.java b/src/main/java/com/devkor/ifive/nadab/domain/monthlyreport/core/service/PendingMonthlyReportServiceV2.java new file mode 100644 index 00000000..d6997a5a --- /dev/null +++ b/src/main/java/com/devkor/ifive/nadab/domain/monthlyreport/core/service/PendingMonthlyReportServiceV2.java @@ -0,0 +1,56 @@ +package com.devkor.ifive.nadab.domain.monthlyreport.core.service; + +import com.devkor.ifive.nadab.domain.monthlyreport.core.entity.MonthlyReportComparisonType; +import com.devkor.ifive.nadab.domain.monthlyreport.core.entity.MonthlyReportStatus; +import com.devkor.ifive.nadab.domain.monthlyreport.core.entity.MonthlyReportV2; +import com.devkor.ifive.nadab.domain.monthlyreport.core.repository.MonthlyReportV2Repository; +import com.devkor.ifive.nadab.domain.user.core.entity.User; +import com.devkor.ifive.nadab.global.core.response.ErrorCode; +import com.devkor.ifive.nadab.global.exception.ConflictException; +import com.devkor.ifive.nadab.global.shared.util.MonthRangeCalculator; +import com.devkor.ifive.nadab.global.shared.util.TodayDateTimeProvider; +import com.devkor.ifive.nadab.global.shared.util.dto.MonthRangeDto; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDate; + +@Service +@RequiredArgsConstructor +public class PendingMonthlyReportServiceV2 { + + private final MonthlyReportV2Repository monthlyReportV2Repository; + + @Transactional + public MonthlyReportV2 getOrCreatePendingMonthlyReport(User user, boolean exists) { + + MonthRangeDto range = MonthRangeCalculator.getLastMonthRange(); + LocalDate today = TodayDateTimeProvider.getTodayDate(); + + MonthlyReportComparisonType comparisonType = exists ? MonthlyReportComparisonType.COMPARISON : MonthlyReportComparisonType.BASELINE; + + MonthlyReportV2 report = monthlyReportV2Repository.findByUserIdAndMonthStartDate(user.getId(), range.monthStartDate()) + .orElseGet(() -> monthlyReportV2Repository.save(MonthlyReportV2.createPending( + user, + range.monthStartDate(), + range.monthEndDate(), + today, + comparisonType + ))); + + if (report.getStatus() == MonthlyReportStatus.COMPLETED) { + throw new ConflictException(ErrorCode.MONTHLY_REPORT_ALREADY_COMPLETED); + } + + if (report.getStatus() == MonthlyReportStatus.IN_PROGRESS) { + throw new ConflictException(ErrorCode.MONTHLY_REPORT_IN_PROGRESS); + } + + if (report.getStatus() == MonthlyReportStatus.FAILED) { + monthlyReportV2Repository.updateStatus(report.getId(), MonthlyReportStatus.PENDING); + } + + return report; + } +} 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 bd2fb451..50f66e8c 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 @@ -1,10 +1,5 @@ package com.devkor.ifive.nadab.domain.monthlyreport.infra; -import com.devkor.ifive.nadab.domain.monthlyreport.core.dto.AiMonthlyReportResultDto; -import com.devkor.ifive.nadab.domain.monthlyreport.core.entity.MonthlyReportV2Content; -import com.devkor.ifive.nadab.domain.typereport.application.helper.TypeReportInputAssembler; -import com.devkor.ifive.nadab.domain.typereport.core.content.TypeEmotionStatsContent; -import com.devkor.ifive.nadab.domain.typereport.core.content.TypeTextContent; import com.devkor.ifive.nadab.global.core.prompt.monthly.MonthlyReportPromptLoader; import com.devkor.ifive.nadab.global.core.response.ErrorCode; import com.devkor.ifive.nadab.global.exception.ai.AiResponseParseException; @@ -38,28 +33,26 @@ public class MonthlyReportLlmClient { private final LlmProvider provider = LlmProvider.GEMINI; private static final LlmProvider REWRITE_PROVIDER = LlmProvider.GEMINI; - private static final int MAX_DISCOVERED = 400; - private static final int MIN_DISCOVERED = 150; + private static final int MAX_DISCOVERED = 220; + private static final int MIN_DISCOVERED = 140; - private static final int MAX_EMOTION = 200; - private static final int MIN_EMOTION = 50; + private static final int MAX_IMPROVE = 120; + private static final int MIN_IMPROVE = 60; + + private static final int MAX_HL_DISCOVERED = 2; + private static final int MAX_HL_IMPROVE = 1; + private static final int MAX_HL_SEG_LEN = 30; private static final int MIN_SUMMARY = 8; private static final int MAX_SUMMARY = 30; - public AiMonthlyReportResultDto generate( - String monthStartDate, - String monthEndDate, - String weeklySummaries, - String representativeEntries, - TypeEmotionStatsContent emotionStats, - boolean exists) { - String prompt = monthlyReportPromptLoader.loadPrompt() + public AiReportResultDto generate( + String monthStartDate, String monthEndDate, String weeklySummaries, String representativeEntries) { + String prompt = monthlyReportPromptLoader.loadV1Prompt() .replace("{monthStartDate}", monthStartDate) .replace("{monthEndDate}", monthEndDate) .replace("{weeklySummaries}", weeklySummaries) - .replace("{representativeEntries}", representativeEntries == null ? "" : representativeEntries) - .replace("{emotionStats}", TypeReportInputAssembler.assembleEmotionStats(emotionStats)); + .replace("{representativeEntries}", representativeEntries == null ? "" : representativeEntries); ChatClient client = llmRouter.route(provider); @@ -74,48 +67,40 @@ public AiMonthlyReportResultDto generate( } try { - AiMonthlyReportResultDto result; + LlmResultDto result; try { - result = objectMapper.readValue(content, AiMonthlyReportResultDto.class); + result = objectMapper.readValue(content, LlmResultDto.class); } catch (Exception e) { throw new AiResponseParseException(ErrorCode.MONTHLY_REPORT_AI_JSON_MAPPING_FAILED); } - MonthlyReportV2Content monthlyReportContent = result.content(); - - StyledText discoveredStyled = monthlyReportContent.discovered(); - StyledText commentStyled = monthlyReportContent.comment(); - String summary = monthlyReportContent.summary(); - String commentSummary = monthlyReportContent.commentSummary(); - String dominantKeyword = monthlyReportContent.dominantKeyword(); - String emotionTrend = monthlyReportContent.emotionTrend(); + StyledText discoveredStyled = result.discovered(); + StyledText improveStyled = result.improve(); + String summary = result.summary(); - TypeTextContent emotionStatsContent = result.emotionSummaryContent(); - StyledText styledText = emotionStatsContent.styledText(); - - if (discoveredStyled == null || commentStyled == null || styledText == null - || isBlank(summary) || isBlank(commentSummary) || isBlank(dominantKeyword) || isBlank(emotionTrend)) { + if (discoveredStyled == null || improveStyled == null || isBlank(summary)) { throw new AiResponseParseException(ErrorCode.MONTHLY_REPORT_AI_JSON_MISSING_FIELDS); } validateSummary(summary); - validateSummary(commentSummary); validateStyledText(discoveredStyled, true); - validateStyledText(commentStyled, true); - validateStyledText(styledText, false); + validateStyledText(improveStyled, false); + + ReportContent reportContent = new ReportContent(summary.trim(), discoveredStyled, improveStyled); - String discovered = monthlyReportContent.discovered().plainText(); - String comment = monthlyReportContent.comment().plainText(); - String emotion = emotionStatsContent.styledText().plainText(); + String discovered = reportContent.discovered().plainText(); + String improve = reportContent.improve().plainText(); - if (isBlank(discovered) || isBlank(comment) || isBlank(emotion)) { + if (isBlank(discovered) || isBlank(improve)) { throw new AiResponseParseException(ErrorCode.MONTHLY_REPORT_AI_JSON_MISSING_FIELDS); } - validateLength(discovered, comment, emotion); + AiReportResultDto dto = enforceLength(new AiReportResultDto(reportContent, discovered, improve)); - return result; + validateLength(dto); + + return dto; } catch (AiResponseParseException e) { throw e; @@ -165,7 +150,6 @@ private String callGemini(ChatClient client, String prompt) { .content(); } - /* private AiReportResultDto enforceLength(AiReportResultDto dto) { ReportContent c = dto.content(); @@ -251,8 +235,6 @@ private StyledText rewriteStyled(ChatClient client, StyledText in, boolean isDis } } - */ - private boolean isBlank(String s) { return s == null || s.trim().isEmpty(); } @@ -262,6 +244,8 @@ private void validateStyledText(StyledText st, boolean isDiscovered) { throw new AiResponseParseException(ErrorCode.MONTHLY_REPORT_AI_JSON_MISSING_FIELDS); } + int highlightCount = 0; + for (Segment seg : st.segments()) { if (seg == null || seg.text() == null || seg.text().isBlank()) { throw new AiResponseParseException(ErrorCode.MONTHLY_REPORT_AI_SEGMENT_INVALID); @@ -287,22 +271,27 @@ private void validateStyledText(StyledText st, boolean isDiscovered) { if (!set.contains(Mark.BOLD)) { throw new AiResponseParseException(ErrorCode.MONTHLY_REPORT_HIGHLIGHT_WITHOUT_BOLD); } + highlightCount++; + if (t.length() > MAX_HL_SEG_LEN) { + throw new AiResponseParseException(ErrorCode.MONTHLY_REPORT_HIGHLIGHT_SEGMENT_TOO_LONG); + } } } + + int maxHl = isDiscovered ? MAX_HL_DISCOVERED : MAX_HL_IMPROVE; + if (highlightCount > maxHl) { + throw new AiResponseParseException(ErrorCode.MONTHLY_REPORT_HIGHLIGHT_COUNT_EXCEEDED); + } } - private void validateLength(String discovered, String comment, String emotion) { - int dLen = discovered.length(); - int cLen = comment.length(); - int eLen = emotion.length(); + private void validateLength(AiReportResultDto dto) { + int dLen = dto.discovered().length(); + int iLen = dto.improve().length(); if (dLen < MIN_DISCOVERED || dLen > MAX_DISCOVERED) { throw new AiResponseParseException(ErrorCode.MONTHLY_REPORT_DISCOVERED_LENGTH_INVALID); } - if (cLen < MIN_DISCOVERED || cLen > MAX_DISCOVERED) { - throw new AiResponseParseException(ErrorCode.MONTHLY_REPORT_DISCOVERED_LENGTH_INVALID); - } - if (eLen < MIN_EMOTION || eLen > MAX_EMOTION) { + if (iLen < MIN_IMPROVE || iLen > MAX_IMPROVE) { throw new AiResponseParseException(ErrorCode.MONTHLY_REPORT_IMPROVE_LENGTH_INVALID); } } 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 new file mode 100644 index 00000000..8df31110 --- /dev/null +++ b/src/main/java/com/devkor/ifive/nadab/domain/monthlyreport/infra/MonthlyReportLlmClientV2.java @@ -0,0 +1,332 @@ +package com.devkor.ifive.nadab.domain.monthlyreport.infra; + +import com.devkor.ifive.nadab.domain.monthlyreport.core.dto.AiMonthlyReportResultDto; +import com.devkor.ifive.nadab.domain.monthlyreport.core.entity.MonthlyReportV2Content; +import com.devkor.ifive.nadab.domain.typereport.application.helper.TypeReportInputAssembler; +import com.devkor.ifive.nadab.domain.typereport.core.content.TypeEmotionStatsContent; +import com.devkor.ifive.nadab.domain.typereport.core.content.TypeTextContent; +import com.devkor.ifive.nadab.global.core.prompt.monthly.MonthlyReportPromptLoader; +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.LlmProvider; +import com.devkor.ifive.nadab.global.infra.llm.LlmRouter; +import com.devkor.ifive.nadab.global.shared.reportcontent.*; +import com.fasterxml.jackson.databind.ObjectMapper; +import lombok.RequiredArgsConstructor; +import org.springframework.ai.anthropic.AnthropicChatOptions; +import org.springframework.ai.anthropic.api.AnthropicApi; +import org.springframework.ai.chat.client.ChatClient; +import org.springframework.ai.google.genai.GoogleGenAiChatModel; +import org.springframework.ai.google.genai.GoogleGenAiChatOptions; +import org.springframework.ai.openai.OpenAiChatOptions; +import org.springframework.ai.openai.api.OpenAiApi; +import org.springframework.stereotype.Component; + +import java.util.EnumSet; +import java.util.List; + +@Component +@RequiredArgsConstructor +public class MonthlyReportLlmClientV2 { + + private final MonthlyReportPromptLoader monthlyReportPromptLoader; + private final ObjectMapper objectMapper; + private final LlmRouter llmRouter; + + private final LlmProvider provider = LlmProvider.GEMINI; + private static final LlmProvider REWRITE_PROVIDER = LlmProvider.GEMINI; + + private static final int MAX_DISCOVERED = 400; + private static final int MIN_DISCOVERED = 150; + + private static final int MAX_EMOTION = 200; + private static final int MIN_EMOTION = 50; + + private static final int MIN_SUMMARY = 8; + private static final int MAX_SUMMARY = 30; + + public AiMonthlyReportResultDto generate( + String monthStartDate, + String monthEndDate, + String weeklySummaries, + String representativeEntries, + TypeEmotionStatsContent emotionStats, + boolean exists) { + String prompt = monthlyReportPromptLoader.loadV2BaselinePrompt() + .replace("{monthStartDate}", monthStartDate) + .replace("{monthEndDate}", monthEndDate) + .replace("{weeklySummaries}", weeklySummaries) + .replace("{representativeEntries}", representativeEntries == null ? "" : representativeEntries) + .replace("{emotionStats}", TypeReportInputAssembler.assembleEmotionStats(emotionStats)); + + ChatClient client = llmRouter.route(provider); + + String content = switch (provider) { + case OPENAI -> callOpenAi(client, prompt); + case CLAUDE -> callClaude(client, prompt); + case GEMINI -> callGemini(client, prompt); + }; + + if (content == null || content.trim().isEmpty()) { + throw new AiServiceUnavailableException(ErrorCode.AI_NO_RESPONSE); + } + + try { + AiMonthlyReportResultDto result; + try { + result = objectMapper.readValue(content, AiMonthlyReportResultDto.class); + } catch (Exception e) { + throw new AiResponseParseException(ErrorCode.MONTHLY_REPORT_AI_JSON_MAPPING_FAILED); + } + + MonthlyReportV2Content monthlyReportContent = result.content(); + + StyledText discoveredStyled = monthlyReportContent.discovered(); + StyledText commentStyled = monthlyReportContent.comment(); + String summary = monthlyReportContent.summary(); + String commentSummary = monthlyReportContent.commentSummary(); + String dominantKeyword = monthlyReportContent.dominantKeyword(); + String emotionTrend = monthlyReportContent.emotionTrend(); + + TypeTextContent emotionStatsContent = result.emotionSummaryContent(); + StyledText styledText = emotionStatsContent.styledText(); + + if (discoveredStyled == null || commentStyled == null || styledText == null + || isBlank(summary) || isBlank(commentSummary) || isBlank(dominantKeyword) || isBlank(emotionTrend)) { + throw new AiResponseParseException(ErrorCode.MONTHLY_REPORT_AI_JSON_MISSING_FIELDS); + } + + validateSummary(summary); + validateSummary(commentSummary); + + validateStyledText(discoveredStyled, true); + validateStyledText(commentStyled, true); + validateStyledText(styledText, false); + + String discovered = monthlyReportContent.discovered().plainText(); + String comment = monthlyReportContent.comment().plainText(); + String emotion = emotionStatsContent.styledText().plainText(); + + if (isBlank(discovered) || isBlank(comment) || isBlank(emotion)) { + throw new AiResponseParseException(ErrorCode.MONTHLY_REPORT_AI_JSON_MISSING_FIELDS); + } + + validateLength(discovered, comment, emotion); + + return result; + + } catch (AiResponseParseException e) { + throw e; + } catch (Exception e) { + throw new AiResponseParseException(ErrorCode.INTERNAL_SERVER_ERROR); + } + } + + private String callOpenAi(ChatClient client, String prompt) { + OpenAiChatOptions options = OpenAiChatOptions.builder() + .model(OpenAiApi.ChatModel.GPT_5_MINI) + .reasoningEffort("medium") + .temperature(1.0) + .build(); + + return client.prompt() + .user(prompt) + .options(options) + .call() + .content(); + } + + private String callClaude(ChatClient client, String prompt) { + AnthropicChatOptions options = AnthropicChatOptions.builder() + .model(AnthropicApi.ChatModel.CLAUDE_3_HAIKU) + .temperature(0.3) + .build(); + + return client.prompt() + .user(prompt) + .options(options) + .call() + .content(); + } + + private String callGemini(ChatClient client, String prompt) { + GoogleGenAiChatOptions options = GoogleGenAiChatOptions.builder() + .model(GoogleGenAiChatModel.ChatModel.GEMINI_2_5_FLASH) + .responseMimeType("application/json") + .temperature(0.3) + .build(); + + return client.prompt() + .user(prompt) + .options(options) + .call() + .content(); + } + + /* + private AiReportResultDto enforceLength(AiReportResultDto dto) { + ReportContent c = dto.content(); + + int dLen = dto.discovered().length(); + int iLen = dto.improve().length(); + + boolean badD = dLen < MIN_DISCOVERED || dLen > MAX_DISCOVERED; + boolean badI = iLen < MIN_IMPROVE || iLen > MAX_IMPROVE; + + if (!badD && !badI) return dto; + + ChatClient rewriteClient = llmRouter.route(REWRITE_PROVIDER); + + StyledText d = c.discovered(); + StyledText i = c.improve(); + + if (badD) d = rewriteStyled(rewriteClient, d, true); + if (badI) i = rewriteStyled(rewriteClient, i, false); + + ReportContent newContent = new ReportContent(c.summary(), d, i); + String newD = newContent.discovered().plainText(); + String newI = newContent.improve().plainText(); + + try { + validateStyledText(newContent.discovered(), true); + validateStyledText(newContent.improve(), false); + } catch (AiResponseParseException e) { + throw new AiResponseParseException(ErrorCode.MONTHLY_REPORT_REWRITE_FORMAT_INVALID); + } + + return new AiReportResultDto(newContent, newD, newI); + } + + private StyledText rewriteStyled(ChatClient client, StyledText in, boolean isDiscovered) { + int min = isDiscovered ? MIN_DISCOVERED : MIN_IMPROVE; + int max = isDiscovered ? MAX_DISCOVERED : MAX_IMPROVE; + int maxHl = isDiscovered ? MAX_HL_DISCOVERED : MAX_HL_IMPROVE; + + try { + String jsonInput = objectMapper.writeValueAsString(in); + + String prompt = """ + 아래 JSON의 segments[].text를 이어 붙인 '순수 텍스트'의 의미는 유지하면서, + 글자수(공백 포함)를 최소 %d자 ~ 최대 %d자로 맞춰 같은 구조로 다시 만들어 주세요. + + [반드시 지킬 것] + - 출력은 JSON 1개만: {"segments":[{"text":"...","marks":[...]} ... ]} + - marks는 "BOLD", "HIGHLIGHT"만 사용 + - "HIGHLIGHT"가 있으면 같은 segment에 "BOLD"도 반드시 포함 + - HIGHLIGHT segment 개수는 최대 %d개 + - 줄바꿈 금지(\\n, \\r 금지) + - 숫자/시간/빈도 표현 금지 + - 해요체 유지 + - 문학적 비유/감정 과잉/훈수/응원 나열 금지 + - 세그먼트는 3~8개 정도로 자연스럽게 구성 + + [입력 JSON] + %s + """.formatted(min + 10, max - 10, maxHl, jsonInput); + + GoogleGenAiChatOptions options = GoogleGenAiChatOptions.builder() + .model(GoogleGenAiChatModel.ChatModel.GEMINI_2_5_FLASH) + .responseMimeType("application/json") + .temperature(0.0) + .build(); + + String out = client.prompt().user(prompt).options(options).call().content(); + + if (out == null || out.isBlank()) { + throw new AiServiceUnavailableException(ErrorCode.AI_REWRITE_NO_RESPONSE); + } + + try { + return objectMapper.readValue(out, StyledText.class); + } catch (Exception e) { + throw new AiResponseParseException(ErrorCode.MONTHLY_REPORT_REWRITE_JSON_MAPPING_FAILED); + } + + } catch (AiServiceUnavailableException e) { + throw e; + } catch (JsonProcessingException e) { + throw new AiResponseParseException(ErrorCode.INTERNAL_SERVER_ERROR); + } + } + + */ + + private boolean isBlank(String s) { + return s == null || s.trim().isEmpty(); + } + + private void validateStyledText(StyledText st, boolean isDiscovered) { + if (st.segments() == null || st.segments().isEmpty()) { + throw new AiResponseParseException(ErrorCode.MONTHLY_REPORT_AI_JSON_MISSING_FIELDS); + } + + for (Segment seg : st.segments()) { + if (seg == null || seg.text() == null || seg.text().isBlank()) { + throw new AiResponseParseException(ErrorCode.MONTHLY_REPORT_AI_SEGMENT_INVALID); + } + String t = seg.text(); + if (t.isBlank()) { + throw new AiResponseParseException(ErrorCode.MONTHLY_REPORT_AI_SEGMENT_INVALID); + } + if (t.contains("\n") || t.contains("\r")) { + throw new AiResponseParseException(ErrorCode.MONTHLY_REPORT_AI_SEGMENT_INVALID); + } + + List marks = seg.marks() == null ? List.of() : seg.marks(); + EnumSet set = EnumSet.noneOf(Mark.class); + for (Mark m : marks) { + if (m == null) { + throw new AiResponseParseException(ErrorCode.MONTHLY_REPORT_AI_SEGMENT_INVALID); + } + set.add(m); + } + + if (set.contains(Mark.HIGHLIGHT)) { + if (!set.contains(Mark.BOLD)) { + throw new AiResponseParseException(ErrorCode.MONTHLY_REPORT_HIGHLIGHT_WITHOUT_BOLD); + } + } + } + } + + private void validateLength(String discovered, String comment, String emotion) { + int dLen = discovered.length(); + int cLen = comment.length(); + int eLen = emotion.length(); + + if (dLen < MIN_DISCOVERED || dLen > MAX_DISCOVERED) { + throw new AiResponseParseException(ErrorCode.MONTHLY_REPORT_DISCOVERED_LENGTH_INVALID); + } + if (cLen < MIN_DISCOVERED || cLen > MAX_DISCOVERED) { + throw new AiResponseParseException(ErrorCode.MONTHLY_REPORT_DISCOVERED_LENGTH_INVALID); + } + if (eLen < MIN_EMOTION || eLen > MAX_EMOTION) { + throw new AiResponseParseException(ErrorCode.MONTHLY_REPORT_IMPROVE_LENGTH_INVALID); + } + } + + private void validateSummary(String summary) { + if (summary == null) { + throw new AiResponseParseException(ErrorCode.MONTHLY_REPORT_SUMMARY_INVALID); + } + String s = summary.trim(); + if (s.length() < MIN_SUMMARY || s.length() > MAX_SUMMARY) { + throw new AiResponseParseException(ErrorCode.MONTHLY_REPORT_SUMMARY_INVALID); + } + // 문장부호로 끝내지 않기 + if (s.endsWith(".") || s.endsWith("!") || s.endsWith("?")) { + throw new AiResponseParseException(ErrorCode.MONTHLY_REPORT_SUMMARY_INVALID); + } + // 따옴표 금지(프론트가 처리) + if (s.contains("\"") || s.contains("'")) { + throw new AiResponseParseException(ErrorCode.MONTHLY_REPORT_SUMMARY_INVALID); + } + // 숫자 금지(시간/빈도 방지) + for (int k = 0; k < s.length(); k++) { + if (Character.isDigit(s.charAt(k))) { + throw new AiResponseParseException(ErrorCode.MONTHLY_REPORT_SUMMARY_INVALID); + } + } + } +} \ No newline at end of file diff --git a/src/main/java/com/devkor/ifive/nadab/global/core/prompt/monthly/LocalMonthlyReportPromptLoader.java b/src/main/java/com/devkor/ifive/nadab/global/core/prompt/monthly/LocalMonthlyReportPromptLoader.java index 8817fe52..12a48576 100644 --- a/src/main/java/com/devkor/ifive/nadab/global/core/prompt/monthly/LocalMonthlyReportPromptLoader.java +++ b/src/main/java/com/devkor/ifive/nadab/global/core/prompt/monthly/LocalMonthlyReportPromptLoader.java @@ -15,15 +15,16 @@ @Slf4j public class LocalMonthlyReportPromptLoader implements MonthlyReportPromptLoader { - private static final String PROMPT_PATH = "secret/monthly-prompt-local.txt"; + private static final String V1_PROMPT_PATH = "secret/monthly-prompt-v1-local.txt"; + private static final String V2_BASELINE_PROMPT_PATH = "secret/monthly-prompt-v2-baseline-local.txt"; @Override - public String loadPrompt() { + public String loadV1Prompt() { try { - ClassPathResource resource = new ClassPathResource(PROMPT_PATH); + ClassPathResource resource = new ClassPathResource(V1_PROMPT_PATH); if (!resource.exists()) { - log.error("월간 리포트 프롬프트 파일이 존재하지 않습니다: {}", PROMPT_PATH); + log.error("월간 리포트 프롬프트 파일이 존재하지 않습니다: {}", V1_PROMPT_PATH); throw new BadRequestException(ErrorCode.PROMPT_MONTHLY_FILE_NOT_FOUND); } @@ -31,7 +32,26 @@ public String loadPrompt() { return new String(bytes, StandardCharsets.UTF_8); } catch (IOException e) { - log.error("로컬 월간 리포트 프롬프트 파일 읽기 실패: {}", PROMPT_PATH, e); + log.error("로컬 월간 리포트 프롬프트 파일 읽기 실패: {}", V1_PROMPT_PATH, e); + throw new BadRequestException(ErrorCode.PROMPT_MONTHLY_FILE_READ_FAILED); + } + } + + @Override + public String loadV2BaselinePrompt() { + try { + ClassPathResource resource = new ClassPathResource(V2_BASELINE_PROMPT_PATH); + + if (!resource.exists()) { + log.error("월간 리포트 프롬프트 파일이 존재하지 않습니다: {}", V2_BASELINE_PROMPT_PATH); + throw new BadRequestException(ErrorCode.PROMPT_MONTHLY_FILE_NOT_FOUND); + } + + byte[] bytes = resource.getContentAsByteArray(); + return new String(bytes, StandardCharsets.UTF_8); + + } catch (IOException e) { + log.error("로컬 월간 리포트 프롬프트 파일 읽기 실패: {}", V2_BASELINE_PROMPT_PATH, e); throw new BadRequestException(ErrorCode.PROMPT_MONTHLY_FILE_READ_FAILED); } } diff --git a/src/main/java/com/devkor/ifive/nadab/global/core/prompt/monthly/MonthlyReportPromptLoader.java b/src/main/java/com/devkor/ifive/nadab/global/core/prompt/monthly/MonthlyReportPromptLoader.java index 4ace5783..68898a41 100644 --- a/src/main/java/com/devkor/ifive/nadab/global/core/prompt/monthly/MonthlyReportPromptLoader.java +++ b/src/main/java/com/devkor/ifive/nadab/global/core/prompt/monthly/MonthlyReportPromptLoader.java @@ -1,7 +1,9 @@ package com.devkor.ifive.nadab.global.core.prompt.monthly; public interface MonthlyReportPromptLoader { - String loadPrompt(); + String loadV1Prompt(); + + String loadV2BaselinePrompt(); String loadImagePrompt(); } diff --git a/src/main/java/com/devkor/ifive/nadab/global/core/prompt/monthly/SecretMonthlyReportPromptLoader.java b/src/main/java/com/devkor/ifive/nadab/global/core/prompt/monthly/SecretMonthlyReportPromptLoader.java index 73ddf493..728368a2 100644 --- a/src/main/java/com/devkor/ifive/nadab/global/core/prompt/monthly/SecretMonthlyReportPromptLoader.java +++ b/src/main/java/com/devkor/ifive/nadab/global/core/prompt/monthly/SecretMonthlyReportPromptLoader.java @@ -14,17 +14,30 @@ @RequiredArgsConstructor public class SecretMonthlyReportPromptLoader implements MonthlyReportPromptLoader { - @Value("${MONTHLY_PROMPT}") - private String rawPrompt; + @Value("${MONTHLY_V1_PROMPT}") + private String monthlyV1Prompt; + + @Value("${MONTHLY_V2_BASELINE_PROMPT}") + private String monthlyV2BaselinePrompt; + + @Override + public String loadV1Prompt() { + if (monthlyV1Prompt == null || monthlyV1Prompt.isBlank()) { + log.error("환경 변수 MONTHLY_V1_PROMPT가 비어있습니다."); + throw new BadRequestException(ErrorCode.PROMPT_MONTHLY_ENV_VAR_NOT_SET); + } + + return monthlyV1Prompt; + } @Override - public String loadPrompt() { - if (rawPrompt == null || rawPrompt.isBlank()) { - log.error("환경 변수 MONTHLY_PROMPT가 비어있습니다."); + public String loadV2BaselinePrompt() { + if (monthlyV2BaselinePrompt == null || monthlyV2BaselinePrompt.isBlank()) { + log.error("환경 변수 MONTHLY_V2_BASELINE_PROMPT가 비어있습니다."); throw new BadRequestException(ErrorCode.PROMPT_MONTHLY_ENV_VAR_NOT_SET); } - return rawPrompt; + return monthlyV2BaselinePrompt; } @Override From 442817abc90d5420f493f761a1adcff08dd42422 Mon Sep 17 00:00:00 2001 From: 1Seob Date: Fri, 29 May 2026 18:58:57 +0900 Subject: [PATCH 2/6] =?UTF-8?q?feat(report):=20v1/v2=20=EC=9B=94=EA=B0=84?= =?UTF-8?q?=20=EB=A6=AC=ED=8F=AC=ED=8A=B8=20=EC=83=9D=EC=84=B1=20=EA=B5=90?= =?UTF-8?q?=EC=B0=A8=20=EC=B0=A8=EB=8B=A8=20=EB=A1=9C=EC=A7=81=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit MonthlyReportCrossVersionGuardService 추가 --- ...MonthlyReportCrossVersionGuardService.java | 45 +++++++++++++++++++ .../service/PendingMonthlyReportService.java | 5 ++- .../PendingMonthlyReportServiceV2.java | 3 ++ 3 files changed, 52 insertions(+), 1 deletion(-) create mode 100644 src/main/java/com/devkor/ifive/nadab/domain/monthlyreport/core/service/MonthlyReportCrossVersionGuardService.java diff --git a/src/main/java/com/devkor/ifive/nadab/domain/monthlyreport/core/service/MonthlyReportCrossVersionGuardService.java b/src/main/java/com/devkor/ifive/nadab/domain/monthlyreport/core/service/MonthlyReportCrossVersionGuardService.java new file mode 100644 index 00000000..a9bb2403 --- /dev/null +++ b/src/main/java/com/devkor/ifive/nadab/domain/monthlyreport/core/service/MonthlyReportCrossVersionGuardService.java @@ -0,0 +1,45 @@ +package com.devkor.ifive.nadab.domain.monthlyreport.core.service; + +import com.devkor.ifive.nadab.domain.monthlyreport.core.entity.MonthlyReportStatus; +import com.devkor.ifive.nadab.domain.monthlyreport.core.repository.MonthlyReportRepository; +import com.devkor.ifive.nadab.domain.monthlyreport.core.repository.MonthlyReportV2Repository; +import com.devkor.ifive.nadab.global.core.response.ErrorCode; +import com.devkor.ifive.nadab.global.exception.ConflictException; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +import java.time.LocalDate; + +@Service +@RequiredArgsConstructor +public class MonthlyReportCrossVersionGuardService { + + private final MonthlyReportRepository monthlyReportRepository; + private final MonthlyReportV2Repository monthlyReportV2Repository; + + public void validateCreatableForV1(Long userId, LocalDate monthStartDate) { + monthlyReportV2Repository.findByUserIdAndMonthStartDate(userId, monthStartDate) + .ifPresent(reportV2 -> { + if (reportV2.getStatus() == MonthlyReportStatus.COMPLETED) { + throw new ConflictException(ErrorCode.MONTHLY_REPORT_ALREADY_COMPLETED); + } + + if (reportV2.getStatus() == MonthlyReportStatus.IN_PROGRESS) { + throw new ConflictException(ErrorCode.MONTHLY_REPORT_IN_PROGRESS); + } + }); + } + + public void validateCreatableForV2(Long userId, LocalDate monthStartDate) { + monthlyReportRepository.findByUserIdAndMonthStartDate(userId, monthStartDate) + .ifPresent(report -> { + if (report.getStatus() == MonthlyReportStatus.COMPLETED) { + throw new ConflictException(ErrorCode.MONTHLY_REPORT_ALREADY_COMPLETED); + } + + if (report.getStatus() == MonthlyReportStatus.IN_PROGRESS) { + throw new ConflictException(ErrorCode.MONTHLY_REPORT_IN_PROGRESS); + } + }); + } +} diff --git a/src/main/java/com/devkor/ifive/nadab/domain/monthlyreport/core/service/PendingMonthlyReportService.java b/src/main/java/com/devkor/ifive/nadab/domain/monthlyreport/core/service/PendingMonthlyReportService.java index f16e00b8..eab8e963 100644 --- a/src/main/java/com/devkor/ifive/nadab/domain/monthlyreport/core/service/PendingMonthlyReportService.java +++ b/src/main/java/com/devkor/ifive/nadab/domain/monthlyreport/core/service/PendingMonthlyReportService.java @@ -20,6 +20,7 @@ public class PendingMonthlyReportService { private final MonthlyReportRepository monthlyReportRepository; + private final MonthlyReportCrossVersionGuardService monthlyReportCrossVersionGuardService; @Transactional public MonthlyReport getOrCreatePendingMonthlyReport(User user) { @@ -27,6 +28,8 @@ public MonthlyReport getOrCreatePendingMonthlyReport(User user) { MonthRangeDto range = MonthRangeCalculator.getLastMonthRange(); LocalDate today = TodayDateTimeProvider.getTodayDate(); + monthlyReportCrossVersionGuardService.validateCreatableForV1(user.getId(), range.monthStartDate()); + MonthlyReport report = monthlyReportRepository.findByUserIdAndMonthStartDate(user.getId(), range.monthStartDate()) .orElseGet(() -> monthlyReportRepository.save(MonthlyReport.createPending( user, @@ -49,4 +52,4 @@ public MonthlyReport getOrCreatePendingMonthlyReport(User user) { return report; } -} \ No newline at end of file +} diff --git a/src/main/java/com/devkor/ifive/nadab/domain/monthlyreport/core/service/PendingMonthlyReportServiceV2.java b/src/main/java/com/devkor/ifive/nadab/domain/monthlyreport/core/service/PendingMonthlyReportServiceV2.java index d6997a5a..206d6d21 100644 --- a/src/main/java/com/devkor/ifive/nadab/domain/monthlyreport/core/service/PendingMonthlyReportServiceV2.java +++ b/src/main/java/com/devkor/ifive/nadab/domain/monthlyreport/core/service/PendingMonthlyReportServiceV2.java @@ -21,6 +21,7 @@ public class PendingMonthlyReportServiceV2 { private final MonthlyReportV2Repository monthlyReportV2Repository; + private final MonthlyReportCrossVersionGuardService monthlyReportCrossVersionGuardService; @Transactional public MonthlyReportV2 getOrCreatePendingMonthlyReport(User user, boolean exists) { @@ -28,6 +29,8 @@ public MonthlyReportV2 getOrCreatePendingMonthlyReport(User user, boolean exists MonthRangeDto range = MonthRangeCalculator.getLastMonthRange(); LocalDate today = TodayDateTimeProvider.getTodayDate(); + monthlyReportCrossVersionGuardService.validateCreatableForV2(user.getId(), range.monthStartDate()); + MonthlyReportComparisonType comparisonType = exists ? MonthlyReportComparisonType.COMPARISON : MonthlyReportComparisonType.BASELINE; MonthlyReportV2 report = monthlyReportV2Repository.findByUserIdAndMonthStartDate(user.getId(), range.monthStartDate()) From b80406e729d3ce93f43af6165ac0b6db296ee63d Mon Sep 17 00:00:00 2001 From: 1Seob Date: Fri, 29 May 2026 19:31:00 +0900 Subject: [PATCH 3/6] =?UTF-8?q?feat:=20=ED=85=8C=EC=8A=A4=ED=8A=B8?= =?UTF-8?q?=EC=9A=A9=20=EC=9B=94=EA=B0=84=20=EB=A6=AC=ED=8F=AC=ED=8A=B8=20?= =?UTF-8?q?v1=20=EC=82=AD=EC=A0=9C=20api=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../api/MonthlyReportControllerV2.java | 1 + .../nadab/domain/test/api/TestController.java | 10 ++++ .../test/application/TestReportService.java | 50 ++++++++++++++++++- 3 files changed, 59 insertions(+), 2 deletions(-) diff --git a/src/main/java/com/devkor/ifive/nadab/domain/monthlyreport/api/MonthlyReportControllerV2.java b/src/main/java/com/devkor/ifive/nadab/domain/monthlyreport/api/MonthlyReportControllerV2.java index 9706a12d..f229dc6f 100644 --- a/src/main/java/com/devkor/ifive/nadab/domain/monthlyreport/api/MonthlyReportControllerV2.java +++ b/src/main/java/com/devkor/ifive/nadab/domain/monthlyreport/api/MonthlyReportControllerV2.java @@ -181,6 +181,7 @@ public ResponseEntity> getMyMont 월간 리포트 V2를 id로 조회합니다.
생성 대기 중인 경우 ```status = "PENDING"``` 으로 반환됩니다.
생성 진행 중인 경우 ```status = "IN_PROGRESS"``` 로 반환됩니다.
+ 텍스트만 생성 완료한 경우 ```status = "TEXT_COMPLETED"```로 반환됩니다.
생성에 성공한 경우 ```status = "COMPLETED"``` 로 반환됩니다.
생성에 실패한 경우 ```status = "FAILED"``` 로 반환됩니다. 이때 크리스탈이 환불되기 때문에 잔액 조회를 해야합니다. diff --git a/src/main/java/com/devkor/ifive/nadab/domain/test/api/TestController.java b/src/main/java/com/devkor/ifive/nadab/domain/test/api/TestController.java index 9f8ad3cd..c7e125ef 100644 --- a/src/main/java/com/devkor/ifive/nadab/domain/test/api/TestController.java +++ b/src/main/java/com/devkor/ifive/nadab/domain/test/api/TestController.java @@ -9,6 +9,7 @@ import com.devkor.ifive.nadab.global.core.response.ApiResponseDto; import com.devkor.ifive.nadab.global.core.response.ApiResponseEntity; import com.devkor.ifive.nadab.global.security.principal.UserPrincipal; +import io.swagger.v3.oas.annotations.Hidden; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.media.Content; import io.swagger.v3.oas.annotations.media.Schema; @@ -209,4 +210,13 @@ public ResponseEntity> deleteTypeReport( testReportService.deleteTypeReport(principal.getId(), interestCode); return ApiResponseEntity.noContent(); } + + @Hidden + @PostMapping("/delete/monthly-report-v1") + public ResponseEntity> deleteMonthlyReportV1( + @RequestParam String email + ) { + testReportService.deleteMonthMonthlyReportV1(email); + return ApiResponseEntity.noContent(); + } } diff --git a/src/main/java/com/devkor/ifive/nadab/domain/test/application/TestReportService.java b/src/main/java/com/devkor/ifive/nadab/domain/test/application/TestReportService.java index 89413bbd..65b9d50d 100644 --- a/src/main/java/com/devkor/ifive/nadab/domain/test/application/TestReportService.java +++ b/src/main/java/com/devkor/ifive/nadab/domain/test/application/TestReportService.java @@ -1,7 +1,9 @@ package com.devkor.ifive.nadab.domain.test.application; +import com.devkor.ifive.nadab.domain.monthlyreport.core.entity.MonthlyReport; import com.devkor.ifive.nadab.domain.monthlyreport.core.entity.MonthlyReportStatus; import com.devkor.ifive.nadab.domain.monthlyreport.core.entity.MonthlyReportV2; +import com.devkor.ifive.nadab.domain.monthlyreport.core.repository.MonthlyReportRepository; import com.devkor.ifive.nadab.domain.monthlyreport.core.repository.MonthlyReportV2Repository; import com.devkor.ifive.nadab.domain.test.api.dto.request.PromptTestDailyReportRequest; import com.devkor.ifive.nadab.domain.test.api.dto.request.TestDailyReportRequest; @@ -55,6 +57,7 @@ public class TestReportService { private final UserRepository userRepository; private final WeeklyReportRepository weeklyReportRepository; + private final MonthlyReportRepository monthlyReportRepository; private final MonthlyReportV2Repository monthlyReportV2Repository; private final TypeReportRepository typeReportRepository; private final TestCrystalLogRepository testCrystalLogRepository; @@ -220,7 +223,7 @@ public void deleteThisMonthMonthlyReport(Long userId) { CrystalLog purchaseLog = testCrystalLogRepository .findByUserIdAndRefTypeAndRefIdAndReasonAndStatus( userId, - "MONTHLY_REPORT", + "MONTHLY_REPORT_V2", report.getId(), CrystalLogReason.REPORT_GENERATE_MONTHLY, CrystalLogStatus.CONFIRMED @@ -238,7 +241,7 @@ public void deleteThisMonthMonthlyReport(Long userId) { CrystalLog refundLog = CrystalLog.createConfirmed(user, MONTHLY_REPORT_COST, balanceAfter, - CrystalLogReason.TEST_DELETE_REPORT_REFUND_MONTHLY, "MONTHLY_REPORT_REFUND", report.getId()); + CrystalLogReason.TEST_DELETE_REPORT_REFUND_MONTHLY, "MONTHLY_REPORT_V2_REFUND", report.getId()); testCrystalLogRepository.save(refundLog); testCrystalLogRepository.markRefunded(purchaseLog.getId()); @@ -287,4 +290,47 @@ public void deleteTypeReport(Long userId, String interestCode) { typeReportRepository.delete(report); } + + @Transactional + public void deleteMonthMonthlyReportV1(String email) { + User user = userRepository.findByEmail(email) + .orElseThrow(() -> new NotFoundException(ErrorCode.USER_NOT_FOUND)); + + MonthRangeDto range = MonthRangeCalculator.getLastMonthRange(); + + MonthlyReport report = monthlyReportRepository.findByUserIdAndMonthStartDate(user.getId(), range.monthStartDate()) + .orElseThrow(() -> new NotFoundException(ErrorCode.MONTHLY_REPORT_NOT_FOUND)); + + if (report.getStatus() != MonthlyReportStatus.COMPLETED) { + throw new BadRequestException(ErrorCode.MONTHLY_REPORT_NOT_COMPLETED); + } + + CrystalLog purchaseLog = testCrystalLogRepository + .findByUserIdAndRefTypeAndRefIdAndReasonAndStatus( + user.getId(), + "MONTHLY_REPORT", + report.getId(), + CrystalLogReason.REPORT_GENERATE_MONTHLY, + CrystalLogStatus.CONFIRMED + ) + .orElseThrow(() -> new BadRequestException(ErrorCode.CRYSTAL_LOG_NOT_FOUND)); + + int updated = userWalletRepository.refund(user.getId(), MONTHLY_REPORT_COST); + if (updated == 0) { + throw new NotFoundException(ErrorCode.WALLET_NOT_FOUND); + } + + UserWallet wallet = userWalletRepository.findByUserId(user.getId()) + .orElseThrow(() -> new NotFoundException(ErrorCode.WALLET_NOT_FOUND)); + long balanceAfter = wallet.getCrystalBalance(); + + + CrystalLog refundLog = CrystalLog.createConfirmed(user, MONTHLY_REPORT_COST, balanceAfter, + CrystalLogReason.TEST_DELETE_REPORT_REFUND_MONTHLY, "MONTHLY_REPORT_REFUND", report.getId()); + testCrystalLogRepository.save(refundLog); + + testCrystalLogRepository.markRefunded(purchaseLog.getId()); + + monthlyReportRepository.delete(report); + } } From 44f41469ef8bf9b3675291e263b6b65329ead708 Mon Sep 17 00:00:00 2001 From: 1Seob Date: Fri, 29 May 2026 19:35:50 +0900 Subject: [PATCH 4/6] =?UTF-8?q?ci(infra):=20=EC=9B=94=EA=B0=84=20=ED=94=84?= =?UTF-8?q?=EB=A1=AC=ED=94=84=ED=8A=B8=20=EC=8B=9C=ED=81=AC=EB=A6=BF?= =?UTF-8?q?=EC=9D=84=20v1/v2=20=EB=B2=A0=EC=9D=B4=EC=8A=A4=EB=9D=BC?= =?UTF-8?q?=EC=9D=B8=EC=9C=BC=EB=A1=9C=20=EB=B6=84=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit dev/prod 배포 워크플로우에서 MONTHLY_PROMPT 제거하고 MONTHLY_V1_PROMPT, MONTHLY_V2_BASELINE_PROMPT 시크릿 디코딩/환경변수 export 추가 --- .github/workflows/deploy-to-dev-ec2.yml | 10 +++++++--- .github/workflows/deploy-to-prod-ec2.yml | 10 +++++++--- 2 files changed, 14 insertions(+), 6 deletions(-) diff --git a/.github/workflows/deploy-to-dev-ec2.yml b/.github/workflows/deploy-to-dev-ec2.yml index 8d7af749..84b57c76 100644 --- a/.github/workflows/deploy-to-dev-ec2.yml +++ b/.github/workflows/deploy-to-dev-ec2.yml @@ -79,9 +79,13 @@ jobs: WEEKLY_PROMPT="$(printf '%s' "$WEEKLY_PROMPT_B64" | base64 -d | tr -d '\r')" export WEEKLY_PROMPT - MONTHLY_PROMPT_B64="${{ secrets.MONTHLY_PROMPT_B64 }}" - MONTHLY_PROMPT="$(printf '%s' "$MONTHLY_PROMPT_B64" | base64 -d | tr -d '\r')" - export MONTHLY_PROMPT + MONTHLY_V1_PROMPT_B64="${{ secrets.MONTHLY_V1_PROMPT_B64 }}" + MONTHLY_V1_PROMPT="$(printf '%s' "$MONTHLY_V1_PROMPT_B64" | base64 -d | tr -d '\r')" + export MONTHLY_V1_PROMPT + + MONTHLY_V2_BASELINE_PROMPT_B64="${{ secrets.MONTHLY_V2_BASELINE_PROMPT_B64 }}" + MONTHLY_V2_BASELINE_PROMPT="$(printf '%s' "$MONTHLY_V2_BASELINE_PROMPT_B64" | base64 -d | tr -d '\r')" + export MONTHLY_V2_BASELINE_PROMPT EVIDENCE_CARD_PROMPT_B64="${{ secrets.EVIDENCE_CARD_PROMPT_B64 }}" EVIDENCE_CARD_PROMPT="$(printf '%s' "$EVIDENCE_CARD_PROMPT_B64" | base64 -d | tr -d '\r')" diff --git a/.github/workflows/deploy-to-prod-ec2.yml b/.github/workflows/deploy-to-prod-ec2.yml index cafd8c57..8f0220fd 100644 --- a/.github/workflows/deploy-to-prod-ec2.yml +++ b/.github/workflows/deploy-to-prod-ec2.yml @@ -79,9 +79,13 @@ jobs: WEEKLY_PROMPT="$(printf '%s' "$WEEKLY_PROMPT_B64" | base64 -d | tr -d '\r')" export WEEKLY_PROMPT - MONTHLY_PROMPT_B64="${{ secrets.MONTHLY_PROMPT_B64 }}" - MONTHLY_PROMPT="$(printf '%s' "$MONTHLY_PROMPT_B64" | base64 -d | tr -d '\r')" - export MONTHLY_PROMPT + MONTHLY_V1_PROMPT_B64="${{ secrets.MONTHLY_V1_PROMPT_B64 }}" + MONTHLY_V1_PROMPT="$(printf '%s' "$MONTHLY_V1_PROMPT_B64" | base64 -d | tr -d '\r')" + export MONTHLY_V1_PROMPT + + MONTHLY_V2_BASELINE_PROMPT_B64="${{ secrets.MONTHLY_V2_BASELINE_PROMPT_B64 }}" + MONTHLY_V2_BASELINE_PROMPT="$(printf '%s' "$MONTHLY_V2_BASELINE_PROMPT_B64" | base64 -d | tr -d '\r')" + export MONTHLY_V2_BASELINE_PROMPT EVIDENCE_CARD_PROMPT_B64="${{ secrets.EVIDENCE_CARD_PROMPT_B64 }}" EVIDENCE_CARD_PROMPT="$(printf '%s' "$EVIDENCE_CARD_PROMPT_B64" | base64 -d | tr -d '\r')" From 6c18e40c6626897618668626ba7a676bf16b54e6 Mon Sep 17 00:00:00 2001 From: 1Seob Date: Fri, 29 May 2026 19:39:04 +0900 Subject: [PATCH 5/6] =?UTF-8?q?chore:=20.gitignore=EC=97=90=20AGENTS.md=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 4 +++- AGENTS.md | 11 ----------- 2 files changed, 3 insertions(+), 12 deletions(-) delete mode 100644 AGENTS.md diff --git a/.gitignore b/.gitignore index 6fa19711..66d84356 100644 --- a/.gitignore +++ b/.gitignore @@ -43,4 +43,6 @@ out/ node_modules # 오늘의 리포트 프롬프트 -/src/main/resources/secret/ \ No newline at end of file +/src/main/resources/secret/ + +AGENTS.md \ No newline at end of file diff --git a/AGENTS.md b/AGENTS.md deleted file mode 100644 index 2b4b483b..00000000 --- a/AGENTS.md +++ /dev/null @@ -1,11 +0,0 @@ -# AGENTS.md - -## Editing policy - -- Prefer minimal, targeted edits over large rewrites. -- Do not delete and recreate files unless explicitly requested. -- Do not reformat unrelated code. -- Preserve existing structure, naming, comments, and style. -- When fixing a bug, change only the smallest necessary scope. -- Before replacing an entire file, explain why a small patch is not sufficient. -- Avoid touching files unrelated to the user's request. \ No newline at end of file From 0e3bdd4356f46f9fbc844d05404213587183cc15 Mon Sep 17 00:00:00 2001 From: 1Seob Date: Fri, 29 May 2026 20:00:48 +0900 Subject: [PATCH 6/6] =?UTF-8?q?fix(report):=20MonthlyReportCrossVersionGua?= =?UTF-8?q?rdService=EA=B3=BC=20PendingMonthlyReportServiceV2=20=EC=88=98?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit MonthlyReportCrossVersionGuardService - status가 COMPLETED와 FAILED가 아닌 모든 경우에 생성 중 예외 처리를 던지도록 수정, PendingMonthlyReportServiceV2 - status = "TEXT_COMPLETED"인 경우에도 생성 중 예외 처리를 던지도록 수정 --- .../core/service/MonthlyReportCrossVersionGuardService.java | 4 ++-- .../core/service/PendingMonthlyReportServiceV2.java | 3 ++- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/src/main/java/com/devkor/ifive/nadab/domain/monthlyreport/core/service/MonthlyReportCrossVersionGuardService.java b/src/main/java/com/devkor/ifive/nadab/domain/monthlyreport/core/service/MonthlyReportCrossVersionGuardService.java index a9bb2403..461e3076 100644 --- a/src/main/java/com/devkor/ifive/nadab/domain/monthlyreport/core/service/MonthlyReportCrossVersionGuardService.java +++ b/src/main/java/com/devkor/ifive/nadab/domain/monthlyreport/core/service/MonthlyReportCrossVersionGuardService.java @@ -24,7 +24,7 @@ public void validateCreatableForV1(Long userId, LocalDate monthStartDate) { throw new ConflictException(ErrorCode.MONTHLY_REPORT_ALREADY_COMPLETED); } - if (reportV2.getStatus() == MonthlyReportStatus.IN_PROGRESS) { + if (reportV2.getStatus() != MonthlyReportStatus.FAILED) { throw new ConflictException(ErrorCode.MONTHLY_REPORT_IN_PROGRESS); } }); @@ -37,7 +37,7 @@ public void validateCreatableForV2(Long userId, LocalDate monthStartDate) { throw new ConflictException(ErrorCode.MONTHLY_REPORT_ALREADY_COMPLETED); } - if (report.getStatus() == MonthlyReportStatus.IN_PROGRESS) { + if (report.getStatus() != MonthlyReportStatus.FAILED) { throw new ConflictException(ErrorCode.MONTHLY_REPORT_IN_PROGRESS); } }); diff --git a/src/main/java/com/devkor/ifive/nadab/domain/monthlyreport/core/service/PendingMonthlyReportServiceV2.java b/src/main/java/com/devkor/ifive/nadab/domain/monthlyreport/core/service/PendingMonthlyReportServiceV2.java index 206d6d21..7e6bf9de 100644 --- a/src/main/java/com/devkor/ifive/nadab/domain/monthlyreport/core/service/PendingMonthlyReportServiceV2.java +++ b/src/main/java/com/devkor/ifive/nadab/domain/monthlyreport/core/service/PendingMonthlyReportServiceV2.java @@ -46,7 +46,8 @@ public MonthlyReportV2 getOrCreatePendingMonthlyReport(User user, boolean exists throw new ConflictException(ErrorCode.MONTHLY_REPORT_ALREADY_COMPLETED); } - if (report.getStatus() == MonthlyReportStatus.IN_PROGRESS) { + if (report.getStatus() == MonthlyReportStatus.IN_PROGRESS + || report.getStatus() == MonthlyReportStatus.TEXT_COMPLETED) { throw new ConflictException(ErrorCode.MONTHLY_REPORT_IN_PROGRESS); }