diff --git a/.github/workflows/deploy-to-dev-ec2.yml b/.github/workflows/deploy-to-dev-ec2.yml index 5fae574..e949c54 100644 --- a/.github/workflows/deploy-to-dev-ec2.yml +++ b/.github/workflows/deploy-to-dev-ec2.yml @@ -75,6 +75,10 @@ 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 + DB_URL="${{ secrets.DB_URL }}" \ DB_USERNAME="${{ secrets.DB_USERNAME }}" \ DB_PASSWORD="${{ secrets.DB_PASSWORD }}" \ diff --git a/src/main/java/com/devkor/ifive/nadab/domain/dailyreport/core/repository/AnswerEntryQueryRepository.java b/src/main/java/com/devkor/ifive/nadab/domain/dailyreport/core/repository/AnswerEntryQueryRepository.java index fbaa5d5..13e97bf 100644 --- a/src/main/java/com/devkor/ifive/nadab/domain/dailyreport/core/repository/AnswerEntryQueryRepository.java +++ b/src/main/java/com/devkor/ifive/nadab/domain/dailyreport/core/repository/AnswerEntryQueryRepository.java @@ -10,7 +10,6 @@ import java.time.LocalDate; import java.util.List; -import java.util.Optional; public interface AnswerEntryQueryRepository extends Repository { diff --git a/src/main/java/com/devkor/ifive/nadab/domain/dailyreport/core/repository/DailyReportRepository.java b/src/main/java/com/devkor/ifive/nadab/domain/dailyreport/core/repository/DailyReportRepository.java index eca6a27..9d497e4 100644 --- a/src/main/java/com/devkor/ifive/nadab/domain/dailyreport/core/repository/DailyReportRepository.java +++ b/src/main/java/com/devkor/ifive/nadab/domain/dailyreport/core/repository/DailyReportRepository.java @@ -58,6 +58,15 @@ default long countCompletedInWeek(Long userId, LocalDate weekStartDate, LocalDat ); } + default long countCompletedInMonth(Long userId, LocalDate monthStartDate, LocalDate monthEndDate) { + return countByAnswerEntry_User_IdAndStatusAndDateBetween( + userId, + DailyReportStatus.COMPLETED, + monthStartDate, + monthEndDate + ); + } + @Modifying(clearAutomatically = true, flushAutomatically = true) @Query("UPDATE DailyReport w SET w.status = :status WHERE w.id = :id") int updateStatus( diff --git a/src/main/java/com/devkor/ifive/nadab/domain/monthlyreport/api/MonthlyReportController.java b/src/main/java/com/devkor/ifive/nadab/domain/monthlyreport/api/MonthlyReportController.java new file mode 100644 index 0000000..0c2ca90 --- /dev/null +++ b/src/main/java/com/devkor/ifive/nadab/domain/monthlyreport/api/MonthlyReportController.java @@ -0,0 +1,151 @@ +package com.devkor.ifive.nadab.domain.monthlyreport.api; + +import com.devkor.ifive.nadab.domain.monthlyreport.api.dto.response.MonthlyReportResponse; +import com.devkor.ifive.nadab.domain.monthlyreport.api.dto.response.MonthlyReportStartResponse; +import com.devkor.ifive.nadab.domain.monthlyreport.application.MonthlyReportQueryService; +import com.devkor.ifive.nadab.domain.monthlyreport.application.MonthlyReportService; +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; +import com.devkor.ifive.nadab.global.security.principal.UserPrincipal; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.security.SecurityRequirement; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.*; + +@Tag(name = "월간 리포트 API", description = "월간 리포트 생성 및 조회 관련 API") +@RestController +@RequestMapping("${api_prefix}/monthly-report") +@RequiredArgsConstructor +public class MonthlyReportController { + + private final MonthlyReportService monthlyReportService; + private final MonthlyReportQueryService monthlyReportQueryService; + + @PostMapping("/start") + @PreAuthorize("isAuthenticated()") + @Operation( + summary = "월간 리포트 생성 시작", + description = """ + 사용자의 (지난 달에 대한) 월간 리포트 생성을 시작합니다.
+ 비동기로 처리되기 때문에, id로 월간 리포트 조회 API를 폴링하여 상태를 확인할 수 있습니다. + """, + security = @SecurityRequirement(name = "bearerAuth"), + responses = { + @ApiResponse( + responseCode = "200", + description = "월간 리포트 생성 시작 성공", + content = @Content(schema = @Schema(implementation = MonthlyReportStartResponse.class), mediaType = "application/json") + ), + @ApiResponse( + responseCode = "400", + description = """ + - ErrorCode: MONTHLY_REPORT_NOT_ENOUGH_REPORTS - 월간 리포트 작성 자격 미달 **(이 경우 data의 completedCount 필드에 지난 주에 작성된 오늘의 리포트 수가 포함됩니다.)** + - ErrorCode: WALLET_INSUFFICIENT_BALANCE - 크리스탈 잔액 부족 + """, + content = @Content(schema = @Schema(implementation = CompletedCountResponse.class), mediaType = "application/json") + ), + @ApiResponse( + responseCode = "401", + description = "인증 실패", + content = @Content + ), + @ApiResponse( + responseCode = "404", + description = """ + - ErrorCode: USER_NOT_FOUND - 사용자를 찾을 수 없음 + - ErrorCode: WALLET_NOT_FOUND - 지갑을 찾을 수 없음 + """, + content = @Content + ), + @ApiResponse( + responseCode = "409", + description = """ + - ErrorCode: MONTHLY_REPORT_ALREADY_COMPLETED - 이미 작성된 월간 리포트가 존재함 + - ErrorCode: MONTHLY_REPORT_IN_PROGRESS - 현재 월간 리포트를 생성 중임 + """, + content = @Content + ) + } + ) + public ResponseEntity> startMonthlyReport( + @AuthenticationPrincipal UserPrincipal principal + ) { + MonthlyReportStartResponse response = monthlyReportService.startMonthlyReport(principal.getId()); + return ApiResponseEntity.ok(response); + } + + @GetMapping + @PreAuthorize("isAuthenticated()") + @Operation( + summary = "나의 월간 리포트 조회", + description = "사용자의 (지난 달) 월간 리포트를 조회합니다.", + security = @SecurityRequirement(name = "bearerAuth"), + responses = { + @ApiResponse( + responseCode = "200", + description = "나의 월간 리포트 조회 성공", + content = @Content(schema = @Schema(implementation = MonthlyReportResponse.class), mediaType = "application/json") + ), + @ApiResponse( + responseCode = "401", + description = "인증 실패", + content = @Content + ), + @ApiResponse( + responseCode = "404", + description = """ + - ErrorCode: USER_NOT_FOUND - 사용자를 찾을 수 없음 + - ErrorCode: MONTHLY_REPORT_NOT_FOUND - 월간 리포트를 찾을 수 없음 + - ErrorCode: MONTHLY_REPORT_NOT_COMPLETED - 해당 월간 리포트가 아직 생성 완료되지 않음 + """, + content = @Content + ) + } + ) + public ResponseEntity> getLastMonthMonthlyReport( + @AuthenticationPrincipal UserPrincipal principal + ) { + MonthlyReportResponse response = monthlyReportQueryService.getLastMonthMonthlyReport(principal.getId()); + return ApiResponseEntity.ok(response); + } + + @GetMapping("/{id}") + @PreAuthorize("isAuthenticated()") + @Operation( + summary = "id로 월간 리포트 조회", + description = """ + 월간 리포트를 id로 조회합니다.
+ 생성 대기 중인 경우 ```status = "PENDING"``` 으로 반환됩니다.
+ 생성 진행 중인 경우 ```status = "IN_PROGRESS"``` 로 반환됩니다.
+ 생성에 성공한 경우 ```status = "COMPLETED"``` 로 반환됩니다.
+ 생성에 실패한 경우 ```status = "FAILED"``` 로 반환됩니다. 이때 크리스탈이 환불되기 때문에 잔액 조회를 해야합니다. + """, + security = @SecurityRequirement(name = "bearerAuth"), + responses = { + @ApiResponse( + responseCode = "200", + description = "월간 리포트 조회 성공", + content = @Content(schema = @Schema(implementation = MonthlyReportResponse.class), mediaType = "application/json") + ), + @ApiResponse( + responseCode = "404", + description = "- ErrorCode: MONTHLY_REPORT_NOT_FOUND - 월간 리포트를 찾을 수 없음", + content = @Content + ) + } + ) + public ResponseEntity> getMonthlyReportById( + @PathVariable Long id + ) { + MonthlyReportResponse response = monthlyReportQueryService.getMonthlyReportById(id); + return ApiResponseEntity.ok(response); + } +} diff --git a/src/main/java/com/devkor/ifive/nadab/domain/monthlyreport/api/dto/response/MonthlyReportResponse.java b/src/main/java/com/devkor/ifive/nadab/domain/monthlyreport/api/dto/response/MonthlyReportResponse.java new file mode 100644 index 0000000..93d6875 --- /dev/null +++ b/src/main/java/com/devkor/ifive/nadab/domain/monthlyreport/api/dto/response/MonthlyReportResponse.java @@ -0,0 +1,23 @@ +package com.devkor.ifive.nadab.domain.monthlyreport.api.dto.response; + +import io.swagger.v3.oas.annotations.media.Schema; + +@Schema(description = "월간 리포트 조회 응답") +public record MonthlyReportResponse( + + @Schema(description = "리포트가 작성된 달") + int month, + + @Schema(description = "이런 면도 발견되었어요") + String discovered, + + @Schema(description = "이런 점이 좋았어요") + String good, + + @Schema(description = "다음엔 이렇게 보완해볼까요?") + String improve, + + @Schema(description = "상태", example = "PENDING") + String status +) { +} diff --git a/src/main/java/com/devkor/ifive/nadab/domain/monthlyreport/api/dto/response/MonthlyReportStartResponse.java b/src/main/java/com/devkor/ifive/nadab/domain/monthlyreport/api/dto/response/MonthlyReportStartResponse.java new file mode 100644 index 0000000..6dcff7b --- /dev/null +++ b/src/main/java/com/devkor/ifive/nadab/domain/monthlyreport/api/dto/response/MonthlyReportStartResponse.java @@ -0,0 +1,15 @@ +package com.devkor.ifive.nadab.domain.monthlyreport.api.dto.response; + +import io.swagger.v3.oas.annotations.media.Schema; + +@Schema(description = "월간 리포트 생성 시작 응답") +public record MonthlyReportStartResponse( + @Schema(description = "생성 예정 월간 리포트 ID", example = "1") + Long reportId, + + @Schema(description = "상태", example = "PENDING") + String status, + + @Schema(description = "리포트 작성 후 크리스탈 잔액", example = "100") + Long balanceAfter +) {} diff --git a/src/main/java/com/devkor/ifive/nadab/domain/monthlyreport/application/MonthlyReportQueryService.java b/src/main/java/com/devkor/ifive/nadab/domain/monthlyreport/application/MonthlyReportQueryService.java new file mode 100644 index 0000000..8108c7f --- /dev/null +++ b/src/main/java/com/devkor/ifive/nadab/domain/monthlyreport/application/MonthlyReportQueryService.java @@ -0,0 +1,62 @@ +package com.devkor.ifive.nadab.domain.monthlyreport.application; + +import com.devkor.ifive.nadab.domain.monthlyreport.api.dto.response.MonthlyReportResponse; +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.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.NotFoundException; +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; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class MonthlyReportQueryService { + + private final MonthlyReportRepository monthlyReportRepository; + private final UserRepository userRepository; + + public MonthlyReportResponse getLastMonthMonthlyReport(Long userId) { + User user = userRepository.findById(userId) + .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 NotFoundException(ErrorCode.MONTHLY_REPORT_NOT_COMPLETED); + } + + return new MonthlyReportResponse( + range.monthStartDate().getMonthValue(), + report.getDiscovered(), + report.getGood(), + report.getImprove(), + report.getStatus().name() + ); + } + + public MonthlyReportResponse getMonthlyReportById(Long id) { + MonthlyReport report = monthlyReportRepository.findById(id) + .orElseThrow(() -> new NotFoundException(ErrorCode.MONTHLY_REPORT_NOT_FOUND)); + + MonthRangeDto range = MonthRangeCalculator.monthRangeOf(report.getMonthStartDate()); + + return new MonthlyReportResponse( + range.monthStartDate().getMonthValue(), + report.getDiscovered(), + report.getGood(), + report.getImprove(), + report.getStatus().name() + ); + } +} + diff --git a/src/main/java/com/devkor/ifive/nadab/domain/monthlyreport/application/MonthlyReportService.java b/src/main/java/com/devkor/ifive/nadab/domain/monthlyreport/application/MonthlyReportService.java new file mode 100644 index 0000000..0f97a8e --- /dev/null +++ b/src/main/java/com/devkor/ifive/nadab/domain/monthlyreport/application/MonthlyReportService.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 MonthlyReportService { + + private final UserRepository userRepository; + private final DailyReportRepository dailyReportRepository; + + private final MonthlyReportTxService monthlyReportTxService; + + /** + * 비동기 시작 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 = monthlyReportTxService.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 new file mode 100644 index 0000000..c5a4601 --- /dev/null +++ b/src/main/java/com/devkor/ifive/nadab/domain/monthlyreport/application/MonthlyReportTxService.java @@ -0,0 +1,110 @@ +package com.devkor.ifive.nadab.domain.monthlyreport.application; + + +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.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.service.PendingMonthlyReportService; +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 lombok.RequiredArgsConstructor; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +@Transactional +public class MonthlyReportTxService { + + private final PendingMonthlyReportService pendingMonthlyReportService; + + private final MonthlyReportRepository monthlyReportRepository; + private final UserWalletRepository userWalletRepository; + private final CrystalLogRepository crystalLogRepository; + + private final ApplicationEventPublisher eventPublisher; + + private static final long MONTHLY_REPORT_COST = 40L; + + /** + * (Tx) MonthlyReport(PENDING) + reserve consume + CrystalLog(PENDING) + * 커밋되면 리포트 생성 작업을 시작할 준비가 완료됨 + */ + public MonthlyReserveResultDto reserveMonthly(User user) { + + // Report: 있으면 기존 사용, 없으면 새로 PENDING 생성 + MonthlyReport report = pendingMonthlyReportService.getOrCreatePendingMonthlyReport(user); + + // 선차감(원자적) + 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", + report.getId() + ) + ); + + return new MonthlyReserveResultDto(report.getId(), log.getId(), user.getId(), balanceAfter); + } + + public MonthlyReserveResultDto reserveMonthlyAndPublish(User user) { + MonthlyReserveResultDto reserve = this.reserveMonthly(user); + + monthlyReportRepository.updateStatus(reserve.reportId(), MonthlyReportStatus.IN_PROGRESS); + + // 트랜잭션 안에서 publish (AFTER_COMMIT 트리거 보장) + eventPublisher.publishEvent(new MonthlyReportGenerationRequestedEventDto( + reserve.reportId(), + user.getId(), + reserve.crystalLogId() + )); + + return reserve; + } + + public void confirmMonthly(Long reportId, Long logId, String discovered, String good, String improve) { + // report를 COMPLETED로 + monthlyReportRepository.markCompleted(reportId, MonthlyReportStatus.COMPLETED, discovered, good, improve); + + // log를 CONFIRMED로 + crystalLogRepository.markConfirmed(logId); + } + + public void failAndRefundMonthly(Long userId, Long reportId, Long logId) { + monthlyReportRepository.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); + } +} diff --git a/src/main/java/com/devkor/ifive/nadab/domain/monthlyreport/application/helper/MonthlyRepresentativePicker.java b/src/main/java/com/devkor/ifive/nadab/domain/monthlyreport/application/helper/MonthlyRepresentativePicker.java new file mode 100644 index 0000000..940cada --- /dev/null +++ b/src/main/java/com/devkor/ifive/nadab/domain/monthlyreport/application/helper/MonthlyRepresentativePicker.java @@ -0,0 +1,174 @@ +package com.devkor.ifive.nadab.domain.monthlyreport.application.helper; + +import com.devkor.ifive.nadab.domain.dailyreport.core.entity.EmotionName; +import com.devkor.ifive.nadab.domain.weeklyreport.core.dto.DailyEntryDto; + +import java.time.DayOfWeek; +import java.time.LocalDate; +import java.time.temporal.WeekFields; +import java.util.*; +import java.util.stream.Collectors; + +/** + * 월간 리포트 프롬프트에 넣을 "대표 일일 리포트 샘플"을 선정합니다. + * + * 목표: + * 1) 감정 분포 상위 감정 반영(월간 톤/패턴) + * 2) 주차 커버리지 확보(특정 며칠에 쏠림 방지) + * 3) 정보량(답변/일일리포트 길이) 큰 날 우선 + * + * 출력: 날짜 오름차순 정렬된 대표 샘플 리스트 + */ +public final class MonthlyRepresentativePicker { + + private MonthlyRepresentativePicker() { + } + + public static List pick(List entries, int maxSamples) { + if (entries == null || entries.isEmpty() || maxSamples <= 0) { + return List.of(); + } + + // 날짜순 정렬(전환점 계산 및 결과 정렬에도 사용) + List sorted = entries.stream() + .filter(Objects::nonNull) + .sorted(Comparator.comparing(DailyEntryDto::date, Comparator.nullsLast(Comparator.naturalOrder()))) + .toList(); + + // 감정 빈도 집계 (null emotion은 OTHER로 취급) + Map emotionCount = sorted.stream() + .map(e -> defaultEmotion(e.emotion())) + .collect(Collectors.groupingBy(e -> e, Collectors.counting())); + + Map scoreByDate = computeScores(sorted, emotionCount); + + // (1) 감정 분포 상위 2개에서 각각 대표 1개씩 + List topEmotions = emotionCount.entrySet().stream() + .sorted(Map.Entry.comparingByValue().reversed()) + .limit(2) + .map(Map.Entry::getKey) + .toList(); + + LinkedHashMap selected = new LinkedHashMap<>(); + + for (EmotionName emo : topEmotions) { + pickBestByEmotion(sorted, emo, scoreByDate) + .ifPresent(e -> selected.putIfAbsent(e.date(), e)); + if (selected.size() >= maxSamples) { + return selected.values().stream() + .sorted(Comparator.comparing(DailyEntryDto::date)) + .toList(); + } + } + + // (2) 주차(week-of-month)별로 최고점 1개씩 추가해 커버리지 확보 + WeekFields wf = WeekFields.of(DayOfWeek.MONDAY, 4); // 월요일 시작(한국 기준) + + Map> byWeek = sorted.stream() + .filter(e -> e.date() != null) + .collect(Collectors.groupingBy(e -> e.date().get(wf.weekOfMonth()))); + + List weeks = byWeek.keySet().stream().sorted().toList(); + for (Integer w : weeks) { + if (selected.size() >= maxSamples) break; + + Optional bestOfWeek = byWeek.get(w).stream() + .filter(e -> !selected.containsKey(e.date())) + .max(Comparator.comparingDouble(e -> scoreByDate.getOrDefault(e.date(), 0.0))); + + bestOfWeek.ifPresent(e -> selected.putIfAbsent(e.date(), e)); + } + + // (3) 남으면 전체 점수 높은 순으로 채우기 + if (selected.size() < maxSamples) { + List rest = sorted.stream() + .filter(e -> e.date() != null) + .filter(e -> !selected.containsKey(e.date())) + .sorted((a, b) -> Double.compare( + scoreByDate.getOrDefault(b.date(), 0.0), + scoreByDate.getOrDefault(a.date(), 0.0) + )) + .toList(); + + for (DailyEntryDto e : rest) { + selected.putIfAbsent(e.date(), e); + if (selected.size() >= maxSamples) break; + } + } + + // 결과는 날짜 오름차순으로(LLM 입력 안정성) + return selected.values().stream() + .sorted(Comparator.comparing(DailyEntryDto::date)) + .toList(); + } + + private static EmotionName defaultEmotion(EmotionName emotion) { + return (emotion != null) ? emotion : EmotionName.기타; + } + + /** + * 점수 구성: + * - 정보량: answer/dailyReport 길이 기반 + * - 희귀 감정 보정: 빈도 낮을수록 가산(inverse) + * - 전환점: 전날과 감정이 다르면 가산 + */ + private static Map computeScores( + List sorted, + Map emotionCount + ) { + long total = emotionCount.values().stream().mapToLong(v -> v).sum(); + if (total <= 0) total = 1; + + // 희귀도: 빈도 낮을수록 가산 + Map rarity = new EnumMap<>(EmotionName.class); + for (var e : emotionCount.entrySet()) { + double freq = (double) e.getValue() / (double) total; // 0~1 + rarity.put(e.getKey(), 1.0 + (1.0 - freq) * 1.5); // 1.0~2.5 정도 + } + + Map score = new HashMap<>(); + DailyEntryDto prev = null; + + for (DailyEntryDto cur : sorted) { + if (cur.date() == null) continue; + + int answerLen = safeLen(cur.answer()); + int reportLen = safeLen(cur.dailyReport()); + + // 정보량(로그로 완만하게) + double info = Math.log1p(answerLen) * 2.0 + Math.log1p(reportLen) * 1.2; + + EmotionName emo = defaultEmotion(cur.emotion()); + double rare = rarity.getOrDefault(emo, 1.0); + + // 전환점(전날과 감정이 다르면 +) + double change = 0.0; + if (prev != null && prev.date() != null) { + EmotionName prevEmo = defaultEmotion(prev.emotion()); + if (!Objects.equals(prevEmo, emo)) { + change = 0.8; + } + } + + score.put(cur.date(), info * rare + change); + prev = cur; + } + + return score; + } + + private static int safeLen(String s) { + return (s == null) ? 0 : s.trim().length(); + } + + private static Optional pickBestByEmotion( + List sorted, + EmotionName emotion, + Map scoreByDate + ) { + return sorted.stream() + .filter(e -> e.date() != null) + .filter(e -> Objects.equals(defaultEmotion(e.emotion()), emotion)) + .max(Comparator.comparingDouble(e -> scoreByDate.getOrDefault(e.date(), 0.0))); + } +} \ No newline at end of file diff --git a/src/main/java/com/devkor/ifive/nadab/domain/monthlyreport/application/helper/MonthlyWeeklySummariesAssembler.java b/src/main/java/com/devkor/ifive/nadab/domain/monthlyreport/application/helper/MonthlyWeeklySummariesAssembler.java new file mode 100644 index 0000000..bb49d2f --- /dev/null +++ b/src/main/java/com/devkor/ifive/nadab/domain/monthlyreport/application/helper/MonthlyWeeklySummariesAssembler.java @@ -0,0 +1,56 @@ +package com.devkor.ifive.nadab.domain.monthlyreport.application.helper; + +import com.devkor.ifive.nadab.domain.monthlyreport.core.dto.MonthlyWeeklySummaryInputDto; + +import java.util.Comparator; +import java.util.List; +import java.util.Objects; + +public final class MonthlyWeeklySummariesAssembler { + + private static final String NA = "N/A"; + + private MonthlyWeeklySummariesAssembler() { + } + + /** + * 프롬프트에 넣을 weeklySummaries 텍스트를 생성합니다. + * + * 예) + * - W1(2026-01-01 ~ 2026-01-07) + * discovered: ... + * good: ... + * improve: ... + */ + public static String assemble(List inputs) { + if (inputs == null || inputs.isEmpty()) return ""; + + List sorted = inputs.stream() + .filter(Objects::nonNull) + .sorted(Comparator.comparing(MonthlyWeeklySummaryInputDto::weekStartDate, + Comparator.nullsLast(Comparator.naturalOrder()))) + .toList(); + + StringBuilder sb = new StringBuilder(); + int idx = 1; + + for (MonthlyWeeklySummaryInputDto w : sorted) { + sb.append("- W").append(idx) + .append("(").append(valueOrNa(w.weekStartDate() == null ? null : w.weekStartDate().toString())) + .append(" ~ ").append(valueOrNa(w.weekEndDate() == null ? null : w.weekEndDate().toString())) + .append(")\n") + .append(" discovered: ").append(valueOrNa(w.discovered())).append("\n") + .append(" good: ").append(valueOrNa(w.good())).append("\n") + .append(" improve: ").append(valueOrNa(w.improve())).append("\n\n"); + idx++; + } + + return sb.toString().trim(); + } + + private static String valueOrNa(String s) { + if (s == null) return NA; + String t = s.trim(); + return t.isEmpty() ? NA : t; + } +} 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 new file mode 100644 index 0000000..ba7c121 --- /dev/null +++ b/src/main/java/com/devkor/ifive/nadab/domain/monthlyreport/application/listener/MonthlyReportGenerationListener.java @@ -0,0 +1,99 @@ +package com.devkor.ifive.nadab.domain.monthlyreport.application.listener; + +import com.devkor.ifive.nadab.domain.monthlyreport.application.MonthlyReportTxService; +import com.devkor.ifive.nadab.domain.monthlyreport.application.helper.MonthlyRepresentativePicker; +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.MonthlyReportLlmClient; +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.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 MonthlyReportGenerationListener { + + private final MonthlyQueryRepository monthlyQueryRepository; + + private final MonthlyReportLlmClient monthlyReportLlmClient; + private final MonthlyReportTxService monthlyReportTxService; + private final MonthlyWeeklySummariesService monthlyWeeklySummariesService; + + private static final int MAX_LEN = 240; + + @Async("monthlyReportTaskExecutor") + @TransactionalEventListener(phase = + TransactionPhase.AFTER_COMMIT) + public void handle(MonthlyReportGenerationRequestedEventDto 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); + + AiMonthlyReportResultDto dto; + try { + // 트랜잭션 밖(백그라운드)에서 LLM 호출 + dto = monthlyReportLlmClient.generate( + range.monthStartDate().toString(), range.monthEndDate().toString(), weeklySummaries, representativeEntries); + } catch (Exception e) { + log.error("[MONTHLY_REPORT][LLM_FAILED] userId={}, reportId={}", + event.userId(), event.reportId(), e); + + // 실패 확정 + 환불은 별도 트랜잭션에서 + monthlyReportTxService.failAndRefundMonthly( + event.userId(), + event.reportId(), + event.crystalLogId() + ); + return; + } + + // 성공 확정(별도 트랜잭션) + try { + monthlyReportTxService.confirmMonthly( + event.reportId(), + event.crystalLogId(), + cut(dto.discovered()), + cut(dto.good()), + cut(dto.improve()) + ); + } catch (Exception e) { + log.error("[MONTHLY_REPORT][CONFIRM_FAILED] userId={}, reportId={}, crystalLogId={}", + event.userId(), event.reportId(), event.crystalLogId(), e); + + // 저장 실패면 결과를 못 주는 거니까 "실패 확정 + 환불"로 처리 + monthlyReportTxService.failAndRefundMonthly( + event.userId(), + event.reportId(), + 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/core/dto/AiMonthlyReportResultDto.java b/src/main/java/com/devkor/ifive/nadab/domain/monthlyreport/core/dto/AiMonthlyReportResultDto.java new file mode 100644 index 0000000..e930976 --- /dev/null +++ b/src/main/java/com/devkor/ifive/nadab/domain/monthlyreport/core/dto/AiMonthlyReportResultDto.java @@ -0,0 +1,8 @@ +package com.devkor.ifive.nadab.domain.monthlyreport.core.dto; + +public record AiMonthlyReportResultDto( + String discovered, + String good, + String improve +) { +} diff --git a/src/main/java/com/devkor/ifive/nadab/domain/monthlyreport/core/dto/LlmMonthlyResultDto.java b/src/main/java/com/devkor/ifive/nadab/domain/monthlyreport/core/dto/LlmMonthlyResultDto.java new file mode 100644 index 0000000..ddcdacc --- /dev/null +++ b/src/main/java/com/devkor/ifive/nadab/domain/monthlyreport/core/dto/LlmMonthlyResultDto.java @@ -0,0 +1,8 @@ +package com.devkor.ifive.nadab.domain.monthlyreport.core.dto; + +public record LlmMonthlyResultDto( + String discovered, + String good, + String improve +) { +} 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 new file mode 100644 index 0000000..e8bf663 --- /dev/null +++ b/src/main/java/com/devkor/ifive/nadab/domain/monthlyreport/core/dto/MonthlyReportGenerationRequestedEventDto.java @@ -0,0 +1,8 @@ +package com.devkor.ifive.nadab.domain.monthlyreport.core.dto; + +public record MonthlyReportGenerationRequestedEventDto( + Long reportId, + Long userId, + Long crystalLogId +) { +} diff --git a/src/main/java/com/devkor/ifive/nadab/domain/monthlyreport/core/dto/MonthlyReserveResultDto.java b/src/main/java/com/devkor/ifive/nadab/domain/monthlyreport/core/dto/MonthlyReserveResultDto.java new file mode 100644 index 0000000..432913a --- /dev/null +++ b/src/main/java/com/devkor/ifive/nadab/domain/monthlyreport/core/dto/MonthlyReserveResultDto.java @@ -0,0 +1,9 @@ +package com.devkor.ifive.nadab.domain.monthlyreport.core.dto; + +public record MonthlyReserveResultDto( + Long reportId, + Long crystalLogId, + Long userId, + Long balanceAfter +) { +} diff --git a/src/main/java/com/devkor/ifive/nadab/domain/monthlyreport/core/dto/MonthlyWeeklySummaryInputDto.java b/src/main/java/com/devkor/ifive/nadab/domain/monthlyreport/core/dto/MonthlyWeeklySummaryInputDto.java new file mode 100644 index 0000000..291d383 --- /dev/null +++ b/src/main/java/com/devkor/ifive/nadab/domain/monthlyreport/core/dto/MonthlyWeeklySummaryInputDto.java @@ -0,0 +1,12 @@ +package com.devkor.ifive.nadab.domain.monthlyreport.core.dto; + +import java.time.LocalDate; + +public record MonthlyWeeklySummaryInputDto( + LocalDate weekStartDate, + LocalDate weekEndDate, + String discovered, + String good, + String improve +) { +} diff --git a/src/main/java/com/devkor/ifive/nadab/domain/monthlyreport/core/entity/MonthlyReport.java b/src/main/java/com/devkor/ifive/nadab/domain/monthlyreport/core/entity/MonthlyReport.java new file mode 100644 index 0000000..8b19db1 --- /dev/null +++ b/src/main/java/com/devkor/ifive/nadab/domain/monthlyreport/core/entity/MonthlyReport.java @@ -0,0 +1,75 @@ +package com.devkor.ifive.nadab.domain.monthlyreport.core.entity; + +import com.devkor.ifive.nadab.domain.user.core.entity.User; +import jakarta.persistence.*; +import lombok.*; + +import java.time.LocalDate; +import java.time.OffsetDateTime; + +@Entity +@Table( + name = "monthly_reports", + uniqueConstraints = { + @UniqueConstraint( + name = "uq_monthly_reports_user_month", + columnNames = {"user_id", "month_start_date"} + ) + } +) +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class MonthlyReport { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(fetch = FetchType.LAZY, optional = false) + @JoinColumn(name = "user_id", nullable = false) + private User user; + + @Column(name = "month_start_date", nullable = false) + private LocalDate monthStartDate; + + @Column(name = "month_end_date", nullable = false) + private LocalDate monthEndDate; + + @Column(name = "discovered", length = 250) + private String discovered; + + @Column(name = "good", length = 250) + private String good; + + @Column(name = "improve", length = 250) + private String improve; + + @Enumerated(EnumType.STRING) + @Column(name = "status", nullable = false, length = 16) + private MonthlyReportStatus status; + + @Column(name = "date", nullable = false) + private LocalDate date; + + @Column(name = "analyzed_at") + private OffsetDateTime analyzedAt; + + public static MonthlyReport create(User user, LocalDate monthStartDate, LocalDate monthEndDate, + String discovered, String good, String improve, + LocalDate date, MonthlyReportStatus status) { + MonthlyReport mr = new MonthlyReport(); + mr.user = user; + mr.monthStartDate = monthStartDate; + mr.monthEndDate = monthEndDate; + mr.discovered = discovered; + mr.good = good; + mr.improve = improve; + mr.date = date; + mr.status = status; + return mr; + } + + public static MonthlyReport createPending(User user, LocalDate monthStartDate, LocalDate monthEndDate, LocalDate date) { + return create(user, monthStartDate, monthEndDate, null, null, null, date, MonthlyReportStatus.PENDING); + } +} diff --git a/src/main/java/com/devkor/ifive/nadab/domain/monthlyreport/core/entity/MonthlyReportStatus.java b/src/main/java/com/devkor/ifive/nadab/domain/monthlyreport/core/entity/MonthlyReportStatus.java new file mode 100644 index 0000000..ed88e74 --- /dev/null +++ b/src/main/java/com/devkor/ifive/nadab/domain/monthlyreport/core/entity/MonthlyReportStatus.java @@ -0,0 +1,8 @@ +package com.devkor.ifive.nadab.domain.monthlyreport.core.entity; + +public enum MonthlyReportStatus { + PENDING, + COMPLETED, + FAILED, + IN_PROGRESS +} diff --git a/src/main/java/com/devkor/ifive/nadab/domain/monthlyreport/core/repository/MonthlyQueryRepository.java b/src/main/java/com/devkor/ifive/nadab/domain/monthlyreport/core/repository/MonthlyQueryRepository.java new file mode 100644 index 0000000..cc82e6f --- /dev/null +++ b/src/main/java/com/devkor/ifive/nadab/domain/monthlyreport/core/repository/MonthlyQueryRepository.java @@ -0,0 +1,40 @@ +package com.devkor.ifive.nadab.domain.monthlyreport.core.repository; + +import com.devkor.ifive.nadab.domain.dailyreport.core.entity.AnswerEntry; +import com.devkor.ifive.nadab.domain.weeklyreport.core.dto.DailyEntryDto; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import java.time.LocalDate; +import java.util.List; + +public interface MonthlyQueryRepository extends JpaRepository { + + /** + * 월간 리포트 작성을 위한 입력 데이터 조회 + */ + @Query(""" + select new com.devkor.ifive.nadab.domain.weeklyreport.core.dto.DailyEntryDto( + ae.date, + dq.questionText, + ae.content, + dr.content, + em.name + ) + from AnswerEntry ae + join ae.question dq + left join DailyReport dr + on dr.answerEntry = ae + and dr.status = com.devkor.ifive.nadab.domain.dailyreport.core.entity.DailyReportStatus.COMPLETED + left join dr.emotion em + where ae.user.id = :userId + and ae.date between :monthStart and :monthEnd + order by ae.date asc +""") + List findMonthlyInputs( + @Param("userId") Long userId, + @Param("monthStart") LocalDate monthStart, + @Param("monthEnd") LocalDate monthEnd + ); +} diff --git a/src/main/java/com/devkor/ifive/nadab/domain/monthlyreport/core/repository/MonthlyReportRepository.java b/src/main/java/com/devkor/ifive/nadab/domain/monthlyreport/core/repository/MonthlyReportRepository.java new file mode 100644 index 0000000..b6b5b17 --- /dev/null +++ b/src/main/java/com/devkor/ifive/nadab/domain/monthlyreport/core/repository/MonthlyReportRepository.java @@ -0,0 +1,61 @@ +package com.devkor.ifive.nadab.domain.monthlyreport.core.repository; + +import com.devkor.ifive.nadab.domain.monthlyreport.core.entity.MonthlyReport; +import com.devkor.ifive.nadab.domain.monthlyreport.core.entity.MonthlyReportStatus; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import java.time.LocalDate; +import java.util.Optional; + +public interface MonthlyReportRepository extends JpaRepository { + + Optional findByUserIdAndMonthStartDate(Long userId, LocalDate monthStartDate); + + /** + * PENDING -> COMPLETED 확정 + * - 분석 결과(discovered/good/improve) 저장 + * - analyzedAt 기록 + */ + @Modifying(clearAutomatically = true, flushAutomatically = true) + @Query(""" + UPDATE MonthlyReport mr + SET mr.status = :status, + mr.discovered = :discovered, + mr.good = :good, + mr.improve = :improve, + mr.analyzedAt = CURRENT_TIMESTAMP + WHERE mr.id = :reportId +""") + int markCompleted( + @Param("reportId") Long reportId, + @Param("status") MonthlyReportStatus status, + @Param("discovered") String discovered, + @Param("good") String good, + @Param("improve") String improve + ); + + /** + * PENDING -> FAILED 확정 + */ + @Modifying(clearAutomatically = true, flushAutomatically = true) + @Query(""" + UPDATE MonthlyReport mr + SET mr.status = :status, + mr.analyzedAt = CURRENT_TIMESTAMP + WHERE mr.id = :reportId +""") + int markFailed( + @Param("reportId") Long reportId, + @Param("status") MonthlyReportStatus status + ); + + @Modifying(clearAutomatically = true, flushAutomatically = true) + @Query("UPDATE MonthlyReport m SET m.status = :status WHERE m.id = :id") + int updateStatus( + @Param("id") Long id, + @Param("status") MonthlyReportStatus status + ); +} diff --git a/src/main/java/com/devkor/ifive/nadab/domain/monthlyreport/core/service/MonthlyWeeklySummariesService.java b/src/main/java/com/devkor/ifive/nadab/domain/monthlyreport/core/service/MonthlyWeeklySummariesService.java new file mode 100644 index 0000000..c9f5df0 --- /dev/null +++ b/src/main/java/com/devkor/ifive/nadab/domain/monthlyreport/core/service/MonthlyWeeklySummariesService.java @@ -0,0 +1,43 @@ +package com.devkor.ifive.nadab.domain.monthlyreport.core.service; + +import com.devkor.ifive.nadab.domain.monthlyreport.application.helper.MonthlyWeeklySummariesAssembler; +import com.devkor.ifive.nadab.domain.monthlyreport.core.dto.MonthlyWeeklySummaryInputDto; +import com.devkor.ifive.nadab.domain.weeklyreport.core.entity.WeeklyReport; +import com.devkor.ifive.nadab.domain.weeklyreport.core.entity.WeeklyReportStatus; +import com.devkor.ifive.nadab.domain.weeklyreport.core.repository.WeeklyReportRepository; +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.util.List; + +@Service +@RequiredArgsConstructor +@Transactional +public class MonthlyWeeklySummariesService { + + private final WeeklyReportRepository weeklyReportRepository; + + public String buildWeeklySummaries(Long userId, MonthRangeDto monthRange) { + List weeklyReports = + weeklyReportRepository.findMonthlyOverlappedWeeklyReports( + userId, + monthRange.monthStartDate(), + monthRange.monthEndDate(), + WeeklyReportStatus.COMPLETED + ); + + List inputs = weeklyReports.stream() + .map(wr -> new MonthlyWeeklySummaryInputDto( + wr.getWeekStartDate(), + wr.getWeekEndDate(), + wr.getDiscovered(), + wr.getGood(), + wr.getImprove() + )) + .toList(); + + return MonthlyWeeklySummariesAssembler.assemble(inputs); + } +} \ No newline at end of file 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 new file mode 100644 index 0000000..7d6707a --- /dev/null +++ b/src/main/java/com/devkor/ifive/nadab/domain/monthlyreport/core/service/PendingMonthlyReportService.java @@ -0,0 +1,52 @@ +package com.devkor.ifive.nadab.domain.monthlyreport.core.service; + +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.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 PendingMonthlyReportService { + + private final MonthlyReportRepository monthlyReportRepository; + + @Transactional + public MonthlyReport getOrCreatePendingMonthlyReport(User user) { + + MonthRangeDto range = MonthRangeCalculator.getLastMonthRange(); + LocalDate today = TodayDateTimeProvider.getTodayDate(); + + MonthlyReport report = monthlyReportRepository.findByUserIdAndMonthStartDate(user.getId(), range.monthStartDate()) + .orElseGet(() -> monthlyReportRepository.save(MonthlyReport.createPending( + user, + range.monthStartDate(), + range.monthEndDate(), + today + ))); + + 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) { + monthlyReportRepository.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 new file mode 100644 index 0000000..e9e19d8 --- /dev/null +++ b/src/main/java/com/devkor/ifive/nadab/domain/monthlyreport/infra/MonthlyReportLlmClient.java @@ -0,0 +1,71 @@ +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.dto.LlmMonthlyResultDto; +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.fasterxml.jackson.databind.ObjectMapper; +import lombok.RequiredArgsConstructor; +import org.springframework.ai.chat.client.ChatClient; +import org.springframework.ai.openai.OpenAiChatOptions; +import org.springframework.ai.openai.api.OpenAiApi; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +public class MonthlyReportLlmClient { + + private final ChatClient chatClient; + private final MonthlyReportPromptLoader monthlyReportPromptLoader; + private final ObjectMapper objectMapper; + + public AiMonthlyReportResultDto generate( + String monthStartDate, String monthEndDate, String weeklySummaries, String representativeEntries) { + String prompt = monthlyReportPromptLoader.loadPrompt() + .replace("{monthStartDate}", monthStartDate) + .replace("{monthEndDate}", monthEndDate) + .replace("{weeklySummaries}", weeklySummaries) + .replace("{representativeEntries}", representativeEntries); + + OpenAiChatOptions options = OpenAiChatOptions.builder() + .model(OpenAiApi.ChatModel.GPT_5_MINI) + .reasoningEffort("low") + .temperature(1.0) + .build(); + + String content = chatClient.prompt() + .user(prompt) + .options(options) + .call() + .content(); + + if (content == null || content.trim().isEmpty()) { + throw new AiServiceUnavailableException(ErrorCode.AI_NO_RESPONSE); + } + + try { + LlmMonthlyResultDto result = objectMapper.readValue(content, LlmMonthlyResultDto.class); + + String discovered = result.discovered(); + String good = result.good(); + String improve = result.improve(); + + if (isBlank(discovered) || isBlank(good) || isBlank(improve)) { + throw new AiResponseParseException(ErrorCode.AI_RESPONSE_FORMAT_INVALID); + } + + return new AiMonthlyReportResultDto(discovered, good, improve); + + } catch (AiResponseParseException e) { + throw e; + } catch (Exception e) { + throw new AiResponseParseException(ErrorCode.AI_RESPONSE_PARSE_FAILED); + } + } + + private boolean isBlank(String s) { + return s == null || s.trim().isEmpty(); + } +} \ No newline at end of file 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 19434e4..6a8081b 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 @@ -146,4 +146,34 @@ public ResponseEntity> deleteWeeklyReport( testReportService.deleteThisWeekWeeklyReport(principal.getId()); return ApiResponseEntity.noContent(); } + + @PostMapping("/delete/monthly-report") + @PreAuthorize("isAuthenticated()") + @Operation( + summary = "(테스트용) 월간 리포트 삭제 API", + description = """ + 이번 달에 생성된 월간 리포트를 삭제합니다.
+ 생성된 리포트만 삭제 가능합니다.
+ 크리스탈 또한 환불됩니다. + """, + security = @SecurityRequirement(name = "bearerAuth"), + responses = { + @ApiResponse( + responseCode = "204", + description = "테스트용 월간 리포트 삭제 성공", + content = @Content + ), + @ApiResponse( + responseCode = "401", + description = "인증 실패", + content = @Content + ) + } + ) + public ResponseEntity> deleteMonthlyReport( + @AuthenticationPrincipal UserPrincipal principal + ) { + testReportService.deleteThisMonthMonthlyReport(principal.getId()); + 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 85536a7..95e7adf 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,5 +1,8 @@ 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.repository.MonthlyReportRepository; import com.devkor.ifive.nadab.domain.test.api.dto.request.PromptTestDailyReportRequest; import com.devkor.ifive.nadab.domain.test.api.dto.request.TestDailyReportRequest; import com.devkor.ifive.nadab.domain.test.api.dto.response.TestDailyReportResponse; @@ -21,7 +24,9 @@ import com.devkor.ifive.nadab.global.exception.NotFoundException; import com.devkor.ifive.nadab.global.exception.ai.AiResponseParseException; import com.devkor.ifive.nadab.global.exception.ai.AiServiceUnavailableException; +import com.devkor.ifive.nadab.global.shared.util.MonthRangeCalculator; import com.devkor.ifive.nadab.global.shared.util.WeekRangeCalculator; +import com.devkor.ifive.nadab.global.shared.util.dto.MonthRangeDto; import com.devkor.ifive.nadab.global.shared.util.dto.WeekRangeDto; import com.fasterxml.jackson.databind.ObjectMapper; import lombok.RequiredArgsConstructor; @@ -40,10 +45,12 @@ public class TestReportService { private final UserRepository userRepository; private final WeeklyReportRepository weeklyReportRepository; + private final MonthlyReportRepository monthlyReportRepository; private final TestCrystalLogRepository testCrystalLogRepository; private final UserWalletRepository userWalletRepository; - private static final long WEEKLY_REPORT_COST = 30L; + private static final long WEEKLY_REPORT_COST = 20L; + private static final long MONTHLY_REPORT_COST = 40L; @Transactional public TestDailyReportResponse generateTestDailyReport(TestDailyReportRequest request) { @@ -175,4 +182,47 @@ public void deleteThisWeekWeeklyReport(Long userId) { weeklyReportRepository.delete(report); } + + @Transactional + public void deleteThisMonthMonthlyReport(Long userId) { + User user = userRepository.findById(userId) + .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( + userId, + "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(userId) + .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); + } } diff --git a/src/main/java/com/devkor/ifive/nadab/domain/wallet/core/entity/CrystalLogReason.java b/src/main/java/com/devkor/ifive/nadab/domain/wallet/core/entity/CrystalLogReason.java index 074f518..63e76c9 100644 --- a/src/main/java/com/devkor/ifive/nadab/domain/wallet/core/entity/CrystalLogReason.java +++ b/src/main/java/com/devkor/ifive/nadab/domain/wallet/core/entity/CrystalLogReason.java @@ -9,6 +9,7 @@ public enum CrystalLogReason { REPORT_GENERATE_ALL, TEST_DELETE_REPORT_REFUND_WEEKLY, + TEST_DELETE_REPORT_REFUND_MONTHLY, ADMIN_ADJUST } diff --git a/src/main/java/com/devkor/ifive/nadab/domain/weeklyreport/application/WeeklyReportService.java b/src/main/java/com/devkor/ifive/nadab/domain/weeklyreport/application/WeeklyReportService.java index 7fb33fe..8bcc2af 100644 --- a/src/main/java/com/devkor/ifive/nadab/domain/weeklyreport/application/WeeklyReportService.java +++ b/src/main/java/com/devkor/ifive/nadab/domain/weeklyreport/application/WeeklyReportService.java @@ -3,13 +3,10 @@ import com.devkor.ifive.nadab.domain.dailyreport.core.repository.DailyReportRepository; 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.wallet.core.entity.UserWallet; -import com.devkor.ifive.nadab.domain.wallet.core.repository.UserWalletRepository; import com.devkor.ifive.nadab.domain.weeklyreport.api.dto.response.CompletedCountResponse; import com.devkor.ifive.nadab.domain.weeklyreport.api.dto.response.WeeklyReportStartResponse; import com.devkor.ifive.nadab.domain.weeklyreport.core.dto.WeeklyReserveResultDto; import com.devkor.ifive.nadab.global.core.response.ErrorCode; -import com.devkor.ifive.nadab.global.exception.BadRequestException; import com.devkor.ifive.nadab.global.exception.NotFoundException; import com.devkor.ifive.nadab.global.exception.report.WeeklyReportNotEligibleException; import com.devkor.ifive.nadab.global.shared.util.WeekRangeCalculator; @@ -23,27 +20,21 @@ public class WeeklyReportService { private final UserRepository userRepository; private final DailyReportRepository dailyReportRepository; - private final UserWalletRepository userWalletRepository; private final WeeklyReportTxService weeklyReportTxService; - private static final long WEEKLY_REPORT_COST = 30L; - /** * 비동기 시작 API: 즉시 reportId 반환 */ public WeeklyReportStartResponse startWeeklyReport(Long userId) { User user = userRepository.findById(userId) .orElseThrow(() -> new NotFoundException(ErrorCode.USER_NOT_FOUND)); - UserWallet wallet = userWalletRepository.findByUserId(userId) - .orElseThrow(() -> new NotFoundException(ErrorCode.WALLET_NOT_FOUND)); - - // 주간 리포트 작성 자격 확인 (저번 주에 4회 이상 완료) + // 주간 리포트 작성 자격 확인 (저번 주에 3회 이상 완료) WeekRangeDto range = WeekRangeCalculator.getLastWeekRange(); long completedCount = dailyReportRepository.countCompletedInWeek(userId, range.weekStartDate(), range.weekEndDate()); - boolean eligible = completedCount >= 4; + boolean eligible = completedCount >= 3; if (!eligible) { CompletedCountResponse response = new CompletedCountResponse(completedCount); diff --git a/src/main/java/com/devkor/ifive/nadab/domain/weeklyreport/application/WeeklyReportTxService.java b/src/main/java/com/devkor/ifive/nadab/domain/weeklyreport/application/WeeklyReportTxService.java index 6090801..0b18c4d 100644 --- a/src/main/java/com/devkor/ifive/nadab/domain/weeklyreport/application/WeeklyReportTxService.java +++ b/src/main/java/com/devkor/ifive/nadab/domain/weeklyreport/application/WeeklyReportTxService.java @@ -33,7 +33,7 @@ public class WeeklyReportTxService { private final ApplicationEventPublisher eventPublisher; - private static final long WEEKLY_REPORT_COST = 30L; + private static final long WEEKLY_REPORT_COST = 20L; /** * (Tx) WeeklyReport(PENDING) + reserve consume + CrystalLog(PENDING) @@ -86,7 +86,7 @@ public WeeklyReserveResultDto reserveWeeklyAndPublish(User user) { } public void confirmWeekly(Long reportId, Long logId, String discovered, String good, String improve) { - // 네 weekly_report schema에 맞춰 markCompleted 파라미터 변경 + // report를 COMPLETED로 weeklyReportRepository.markCompleted(reportId, WeeklyReportStatus.COMPLETED, discovered, good, improve); // log를 CONFIRMED로 diff --git a/src/main/java/com/devkor/ifive/nadab/domain/weeklyreport/application/helper/WeeklyEntriesAssembler.java b/src/main/java/com/devkor/ifive/nadab/domain/weeklyreport/application/helper/WeeklyEntriesAssembler.java index daadbf8..a37301b 100644 --- a/src/main/java/com/devkor/ifive/nadab/domain/weeklyreport/application/helper/WeeklyEntriesAssembler.java +++ b/src/main/java/com/devkor/ifive/nadab/domain/weeklyreport/application/helper/WeeklyEntriesAssembler.java @@ -1,7 +1,7 @@ package com.devkor.ifive.nadab.domain.weeklyreport.application.helper; import com.devkor.ifive.nadab.domain.dailyreport.core.entity.EmotionName; -import com.devkor.ifive.nadab.domain.weeklyreport.core.dto.WeeklyReportEntryInputDto; +import com.devkor.ifive.nadab.domain.weeklyreport.core.dto.DailyEntryDto; import java.util.Comparator; import java.util.List; @@ -14,20 +14,26 @@ public final class WeeklyEntriesAssembler { private WeeklyEntriesAssembler() { } - public static String assemble(List inputs) { + /** + * 프롬프트에 넣을 entries 문자열을 생성합니다. + * - date 오름차순 정렬 + * - D1, D2 ... 번호 부여 + * - null/blank 값은 N/A로 대체 + */ + public static String assemble(List inputs) { if (inputs == null || inputs.isEmpty()) { return ""; } - List sorted = inputs.stream() + List sorted = inputs.stream() .filter(Objects::nonNull) - .sorted(Comparator.comparing(WeeklyReportEntryInputDto::date, Comparator.nullsLast(Comparator.naturalOrder()))) + .sorted(Comparator.comparing(DailyEntryDto::date, Comparator.nullsLast(Comparator.naturalOrder()))) .toList(); StringBuilder sb = new StringBuilder(); int idx = 1; - for (WeeklyReportEntryInputDto e : sorted) { + for (DailyEntryDto e : sorted) { sb.append("- D").append(idx).append("(").append(valueOrNa(e.date() == null ? null : e.date().toString())).append(")\n") .append(" question: ").append(valueOrNa(e.question())).append("\n") .append(" answer: ").append(valueOrNa(e.answer())).append("\n") diff --git a/src/main/java/com/devkor/ifive/nadab/domain/weeklyreport/application/helper/WeeklyReportGenerationListener.java b/src/main/java/com/devkor/ifive/nadab/domain/weeklyreport/application/listener/WeeklyReportGenerationListener.java similarity index 86% rename from src/main/java/com/devkor/ifive/nadab/domain/weeklyreport/application/helper/WeeklyReportGenerationListener.java rename to src/main/java/com/devkor/ifive/nadab/domain/weeklyreport/application/listener/WeeklyReportGenerationListener.java index d7a09c4..f37a2e0 100644 --- a/src/main/java/com/devkor/ifive/nadab/domain/weeklyreport/application/helper/WeeklyReportGenerationListener.java +++ b/src/main/java/com/devkor/ifive/nadab/domain/weeklyreport/application/listener/WeeklyReportGenerationListener.java @@ -1,10 +1,11 @@ -package com.devkor.ifive.nadab.domain.weeklyreport.application.helper; +package com.devkor.ifive.nadab.domain.weeklyreport.application.listener; import com.devkor.ifive.nadab.domain.weeklyreport.application.WeeklyReportTxService; +import com.devkor.ifive.nadab.domain.weeklyreport.application.helper.WeeklyEntriesAssembler; import com.devkor.ifive.nadab.domain.weeklyreport.core.dto.AiWeeklyReportResultDto; -import com.devkor.ifive.nadab.domain.weeklyreport.core.dto.WeeklyReportEntryInputDto; +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.core.infra.WeeklyReportLlmClient; +import com.devkor.ifive.nadab.domain.weeklyreport.infra.WeeklyReportLlmClient; import com.devkor.ifive.nadab.domain.weeklyreport.core.repository.WeeklyQueryRepository; import com.devkor.ifive.nadab.global.shared.util.WeekRangeCalculator; import com.devkor.ifive.nadab.global.shared.util.dto.WeekRangeDto; @@ -27,7 +28,7 @@ public class WeeklyReportGenerationListener { private final WeeklyReportLlmClient weeklyReportLlmClient; private final WeeklyReportTxService weeklyReportTxService; - private static final int MAX_LEN = 150; + private static final int MAX_LEN = 240; @Async("weeklyReportTaskExecutor") @TransactionalEventListener(phase = @@ -36,7 +37,7 @@ public void handle(WeeklyReportGenerationRequestedEventDto event) { WeekRangeDto range = WeekRangeCalculator.getLastWeekRange(); - List rows = weeklyQueryRepository.findWeeklyInputs(event.userId(), range.weekStartDate(), range.weekEndDate()); + List rows = weeklyQueryRepository.findWeeklyInputs(event.userId(), range.weekStartDate(), range.weekEndDate()); String entries = WeeklyEntriesAssembler.assemble(rows); AiWeeklyReportResultDto dto; diff --git a/src/main/java/com/devkor/ifive/nadab/domain/weeklyreport/core/dto/WeeklyReportEntryInputDto.java b/src/main/java/com/devkor/ifive/nadab/domain/weeklyreport/core/dto/DailyEntryDto.java similarity index 87% rename from src/main/java/com/devkor/ifive/nadab/domain/weeklyreport/core/dto/WeeklyReportEntryInputDto.java rename to src/main/java/com/devkor/ifive/nadab/domain/weeklyreport/core/dto/DailyEntryDto.java index e01ea88..224dd81 100644 --- a/src/main/java/com/devkor/ifive/nadab/domain/weeklyreport/core/dto/WeeklyReportEntryInputDto.java +++ b/src/main/java/com/devkor/ifive/nadab/domain/weeklyreport/core/dto/DailyEntryDto.java @@ -4,7 +4,7 @@ import java.time.LocalDate; -public record WeeklyReportEntryInputDto( +public record DailyEntryDto( LocalDate date, String question, String answer, diff --git a/src/main/java/com/devkor/ifive/nadab/domain/weeklyreport/core/repository/WeeklyQueryRepository.java b/src/main/java/com/devkor/ifive/nadab/domain/weeklyreport/core/repository/WeeklyQueryRepository.java index e053070..8b4391b 100644 --- a/src/main/java/com/devkor/ifive/nadab/domain/weeklyreport/core/repository/WeeklyQueryRepository.java +++ b/src/main/java/com/devkor/ifive/nadab/domain/weeklyreport/core/repository/WeeklyQueryRepository.java @@ -1,7 +1,7 @@ package com.devkor.ifive.nadab.domain.weeklyreport.core.repository; import com.devkor.ifive.nadab.domain.dailyreport.core.entity.AnswerEntry; -import com.devkor.ifive.nadab.domain.weeklyreport.core.dto.WeeklyReportEntryInputDto; +import com.devkor.ifive.nadab.domain.weeklyreport.core.dto.DailyEntryDto; import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.Repository; import org.springframework.data.repository.query.Param; @@ -15,7 +15,7 @@ public interface WeeklyQueryRepository extends Repository { * 주간 리포트 작성을 위한 입력 데이터 조회 */ @Query(""" - select new com.devkor.ifive.nadab.domain.weeklyreport.core.dto.WeeklyReportEntryInputDto( + select new com.devkor.ifive.nadab.domain.weeklyreport.core.dto.DailyEntryDto( ae.date, dq.questionText, ae.content, @@ -33,7 +33,7 @@ public interface WeeklyQueryRepository extends Repository { order by ae.date asc """) - List findWeeklyInputs( + List findWeeklyInputs( @Param("userId") Long userId, @Param("weekStart") LocalDate weekStart, @Param("weekEnd") LocalDate weekEnd diff --git a/src/main/java/com/devkor/ifive/nadab/domain/weeklyreport/core/repository/WeeklyReportRepository.java b/src/main/java/com/devkor/ifive/nadab/domain/weeklyreport/core/repository/WeeklyReportRepository.java index f224ab2..a0c7ef4 100644 --- a/src/main/java/com/devkor/ifive/nadab/domain/weeklyreport/core/repository/WeeklyReportRepository.java +++ b/src/main/java/com/devkor/ifive/nadab/domain/weeklyreport/core/repository/WeeklyReportRepository.java @@ -8,6 +8,7 @@ import org.springframework.data.repository.query.Param; import java.time.LocalDate; +import java.util.List; import java.util.Optional; public interface WeeklyReportRepository extends JpaRepository { @@ -62,4 +63,24 @@ int updateStatus( @Param("id") Long id, @Param("status") WeeklyReportStatus status ); + + /** + * 특정 월(monthStart ~ monthEnd)과 겹치는 주간 리포트 조회 + * 월간 리포트 생성을 위해 사용 + */ + @Query(""" + select wr + from WeeklyReport wr + where wr.user.id = :userId + and wr.weekStartDate <= :monthEnd + and wr.weekEndDate >= :monthStart + and wr.status = :status + order by wr.weekStartDate asc + """) + List findMonthlyOverlappedWeeklyReports( + @Param("userId") Long userId, + @Param("monthStart") LocalDate monthStart, + @Param("monthEnd") LocalDate monthEnd, + @Param("status") WeeklyReportStatus status + ); } \ No newline at end of file diff --git a/src/main/java/com/devkor/ifive/nadab/domain/weeklyreport/core/service/PendingWeeklyReportService.java b/src/main/java/com/devkor/ifive/nadab/domain/weeklyreport/core/service/PendingWeeklyReportService.java index 256c23c..4597e67 100644 --- a/src/main/java/com/devkor/ifive/nadab/domain/weeklyreport/core/service/PendingWeeklyReportService.java +++ b/src/main/java/com/devkor/ifive/nadab/domain/weeklyreport/core/service/PendingWeeklyReportService.java @@ -11,7 +11,6 @@ import com.devkor.ifive.nadab.global.shared.util.dto.WeekRangeDto; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Propagation; import org.springframework.transaction.annotation.Transactional; import java.time.LocalDate; diff --git a/src/main/java/com/devkor/ifive/nadab/domain/weeklyreport/core/infra/WeeklyReportLlmClient.java b/src/main/java/com/devkor/ifive/nadab/domain/weeklyreport/infra/WeeklyReportLlmClient.java similarity index 96% rename from src/main/java/com/devkor/ifive/nadab/domain/weeklyreport/core/infra/WeeklyReportLlmClient.java rename to src/main/java/com/devkor/ifive/nadab/domain/weeklyreport/infra/WeeklyReportLlmClient.java index fd53a51..d666b44 100644 --- a/src/main/java/com/devkor/ifive/nadab/domain/weeklyreport/core/infra/WeeklyReportLlmClient.java +++ b/src/main/java/com/devkor/ifive/nadab/domain/weeklyreport/infra/WeeklyReportLlmClient.java @@ -1,4 +1,4 @@ -package com.devkor.ifive.nadab.domain.weeklyreport.core.infra; +package com.devkor.ifive.nadab.domain.weeklyreport.infra; import com.devkor.ifive.nadab.domain.weeklyreport.core.dto.AiWeeklyReportResultDto; import com.devkor.ifive.nadab.domain.weeklyreport.core.dto.LlmWeeklyResultDto; @@ -34,7 +34,7 @@ public AiWeeklyReportResultDto generate(String weekStartDate, String weekEndDate OpenAiChatOptions options = OpenAiChatOptions.builder() .model(OpenAiApi.ChatModel.GPT_5_MINI) - .reasoningEffort("low") + .reasoningEffort("medium") .temperature(1.0) .build(); diff --git a/src/main/java/com/devkor/ifive/nadab/global/core/config/infra/AsyncConfig.java b/src/main/java/com/devkor/ifive/nadab/global/core/config/infra/AsyncConfig.java index 000dd13..074aafc 100644 --- a/src/main/java/com/devkor/ifive/nadab/global/core/config/infra/AsyncConfig.java +++ b/src/main/java/com/devkor/ifive/nadab/global/core/config/infra/AsyncConfig.java @@ -55,4 +55,19 @@ public ThreadPoolTaskExecutor weeklyReportTaskExecutor() { executor.initialize(); return executor; } + + /** + * 월간 리포트(LLM 호출) 전용 실행기 + * - LLM은 외부 I/O라 동시성 높게 주되, 무한 큐 적체는 막기 위해 큐 제한 + */ + @Bean(name = "monthlyReportTaskExecutor") + public ThreadPoolTaskExecutor monthlyReportTaskExecutor() { + ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor(); + executor.setCorePoolSize(4); + executor.setMaxPoolSize(8); + executor.setQueueCapacity(50); + executor.setThreadNamePrefix("async-monthly-report-"); + executor.initialize(); + return executor; + } } \ 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 new file mode 100644 index 0000000..d500040 --- /dev/null +++ b/src/main/java/com/devkor/ifive/nadab/global/core/prompt/monthly/LocalMonthlyReportPromptLoader.java @@ -0,0 +1,38 @@ +package com.devkor.ifive.nadab.global.core.prompt.monthly; + +import com.devkor.ifive.nadab.global.core.response.ErrorCode; +import com.devkor.ifive.nadab.global.exception.BadRequestException; +import lombok.extern.slf4j.Slf4j; +import org.springframework.context.annotation.Profile; +import org.springframework.core.io.ClassPathResource; +import org.springframework.stereotype.Component; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; + +@Component +@Profile("local") +@Slf4j +public class LocalMonthlyReportPromptLoader implements MonthlyReportPromptLoader { + + private static final String PROMPT_PATH = "secret/monthly-prompt-local.txt"; + + @Override + public String loadPrompt() { + try { + ClassPathResource resource = new ClassPathResource(PROMPT_PATH); + + if (!resource.exists()) { + log.error("월간 리포트 프롬프트 파일이 존재하지 않습니다: {}", 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("로컬 월간 리포트 프롬프트 파일 읽기 실패: {}", 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 new file mode 100644 index 0000000..2092eba --- /dev/null +++ b/src/main/java/com/devkor/ifive/nadab/global/core/prompt/monthly/MonthlyReportPromptLoader.java @@ -0,0 +1,5 @@ +package com.devkor.ifive.nadab.global.core.prompt.monthly; + +public interface MonthlyReportPromptLoader { + String loadPrompt(); +} 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 new file mode 100644 index 0000000..d48e4af --- /dev/null +++ b/src/main/java/com/devkor/ifive/nadab/global/core/prompt/monthly/SecretMonthlyReportPromptLoader.java @@ -0,0 +1,29 @@ +package com.devkor.ifive.nadab.global.core.prompt.monthly; + +import com.devkor.ifive.nadab.global.core.response.ErrorCode; +import com.devkor.ifive.nadab.global.exception.BadRequestException; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Profile; +import org.springframework.stereotype.Component; + +@Component +@Profile("dev") +@Slf4j +@RequiredArgsConstructor +public class SecretMonthlyReportPromptLoader implements MonthlyReportPromptLoader { + + @Value("${MONTHLY_PROMPT}") + private String rawPrompt; + + @Override + public String loadPrompt() { + if (rawPrompt == null || rawPrompt.isBlank()) { + log.error("환경 변수 MONTHLY_PROMPT가 비어있습니다."); + throw new BadRequestException(ErrorCode.PROMPT_MONTHLY_ENV_VAR_NOT_SET); + } + + return rawPrompt; + } +} diff --git a/src/main/java/com/devkor/ifive/nadab/global/core/response/ErrorCode.java b/src/main/java/com/devkor/ifive/nadab/global/core/response/ErrorCode.java index c6acc5b..3295249 100644 --- a/src/main/java/com/devkor/ifive/nadab/global/core/response/ErrorCode.java +++ b/src/main/java/com/devkor/ifive/nadab/global/core/response/ErrorCode.java @@ -133,6 +133,18 @@ public enum ErrorCode { WEEKLY_REPORT_ALREADY_COMPLETED(HttpStatus.CONFLICT, "이미 작성된 주간 리포트가 존재합니다"), WEEKLY_REPORT_IN_PROGRESS(HttpStatus.CONFLICT, "현재 주간 리포트를 생성 중입니다"), + // ==================== MONTHLY_REPORT (월간 리포트) ==================== + // 400 Bad Request + MONTHLY_REPORT_NOT_ENOUGH_REPORTS(HttpStatus.BAD_REQUEST, "월간 리포트 작성 자격이 없습니다. (지난 달 15회 이상 완료 필요)"), + + // 404 Not Found + MONTHLY_REPORT_NOT_FOUND(HttpStatus.NOT_FOUND, "월간 리포트를 찾을 수 없습니다"), + MONTHLY_REPORT_NOT_COMPLETED(HttpStatus.NOT_FOUND, "해당 월간 리포트가 아직 생성 완료되지 않았습니다"), + + // 409 Conflict + MONTHLY_REPORT_ALREADY_COMPLETED(HttpStatus.CONFLICT, "이미 작성된 월간 리포트가 존재합니다"), + MONTHLY_REPORT_IN_PROGRESS(HttpStatus.CONFLICT, "현재 월간 리포트를 생성 중입니다"), + // ==================== AI (인공지능) ==================== // 502 Bad Gateway AI_RESPONSE_PARSE_FAILED(HttpStatus.BAD_GATEWAY, "AI 응답 형식을 해석할 수 없습니다"), @@ -164,10 +176,15 @@ public enum ErrorCode { PROMPT_DAILY_FILE_NOT_FOUND(HttpStatus.BAD_REQUEST, "일간 리포트 프롬프트 파일이 존재하지 않습니다"), PROMPT_DAILY_FILE_READ_FAILED(HttpStatus.BAD_REQUEST, "로컬 일간 리포트 프롬프트 파일을 읽을 수 없습니다"), PROMPT_DAILY_ENV_VAR_NOT_SET(HttpStatus.BAD_REQUEST, "INSIGHT_PROMPT 환경 변수에 프롬프트가 설정되어 있지 않습니다"), + PROMPT_WEEKLY_FILE_NOT_FOUND(HttpStatus.BAD_REQUEST, "주간 리포트 프롬프트 파일이 존재하지 않습니다"), PROMPT_WEEKLY_FILE_READ_FAILED(HttpStatus.BAD_REQUEST, "로컬 주간 리포트 프롬프트 파일을 읽을 수 없습니다"), PROMPT_WEEKLY_ENV_VAR_NOT_SET(HttpStatus.BAD_REQUEST, "WEEKLY_PROMPT 환경 변수에 프롬프트가 설정되어 있지 않습니다"), + PROMPT_MONTHLY_FILE_NOT_FOUND(HttpStatus.BAD_REQUEST, "월간 리포트 프롬프트 파일이 존재하지 않습니다"), + PROMPT_MONTHLY_FILE_READ_FAILED(HttpStatus.BAD_REQUEST, "로컬 월간 리포트 프롬프트 파일을 읽을 수 없습니다"), + PROMPT_MONTHLY_ENV_VAR_NOT_SET(HttpStatus.BAD_REQUEST, "MONTHLY_PROMPT 환경 변수에 프롬프트가 설정되어 있지 않습니다"), + // ==================== NICKNAME (닉네임) ==================== // 400 Bad Request NICKNAME_CHANGE_LIMIT_EXCEEDED(HttpStatus.BAD_REQUEST, "닉네임 변경 가능 횟수를 초과했습니다 (14일 내 최대 2회)"); diff --git a/src/main/java/com/devkor/ifive/nadab/global/exception/ExceptionController.java b/src/main/java/com/devkor/ifive/nadab/global/exception/ExceptionController.java index 9696525..e50e715 100644 --- a/src/main/java/com/devkor/ifive/nadab/global/exception/ExceptionController.java +++ b/src/main/java/com/devkor/ifive/nadab/global/exception/ExceptionController.java @@ -7,6 +7,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.exception.report.MonthlyReportNotEligibleException; import com.devkor.ifive.nadab.global.exception.report.WeeklyReportNotEligibleException; import lombok.extern.slf4j.Slf4j; import org.springframework.http.ResponseEntity; @@ -116,6 +117,12 @@ public ResponseEntity> handleWeeklyR return ApiResponseEntity.error(ex.getErrorCode(), ex.getCompletedCountResponse()); } + @ExceptionHandler(MonthlyReportNotEligibleException.class) + public ResponseEntity> handleMonthlyReportNotEligibleException(MonthlyReportNotEligibleException ex) { + log.warn("MonthlyReportNotEligibleException: {}", ex.getMessage(), ex); + return ApiResponseEntity.error(ex.getErrorCode(), ex.getCompletedCountResponse()); + } + @ExceptionHandler(RuntimeException.class) public ResponseEntity> handleRuntimeException(RuntimeException ex) { if (ex instanceof AuthenticationException || ex instanceof AccessDeniedException) { diff --git a/src/main/java/com/devkor/ifive/nadab/global/exception/report/MonthlyReportNotEligibleException.java b/src/main/java/com/devkor/ifive/nadab/global/exception/report/MonthlyReportNotEligibleException.java new file mode 100644 index 0000000..b2ad00d --- /dev/null +++ b/src/main/java/com/devkor/ifive/nadab/global/exception/report/MonthlyReportNotEligibleException.java @@ -0,0 +1,16 @@ +package com.devkor.ifive.nadab.global.exception.report; + +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.BadRequestException; +import lombok.Getter; + +@Getter +public class MonthlyReportNotEligibleException extends BadRequestException { + private final CompletedCountResponse completedCountResponse; + + public MonthlyReportNotEligibleException(ErrorCode errorCode, CompletedCountResponse completedCount) { + super(errorCode); + this.completedCountResponse = completedCount; + } +} diff --git a/src/main/java/com/devkor/ifive/nadab/global/shared/util/MonthRangeCalculator.java b/src/main/java/com/devkor/ifive/nadab/global/shared/util/MonthRangeCalculator.java new file mode 100644 index 0000000..db7be2e --- /dev/null +++ b/src/main/java/com/devkor/ifive/nadab/global/shared/util/MonthRangeCalculator.java @@ -0,0 +1,42 @@ +package com.devkor.ifive.nadab.global.shared.util; + +import com.devkor.ifive.nadab.global.shared.util.dto.MonthRangeDto; + +import java.time.LocalDate; +import java.time.YearMonth; +import java.time.ZoneId; + +public final class MonthRangeCalculator { + + private static final ZoneId KST = ZoneId.of("Asia/Seoul"); + + private MonthRangeCalculator() { + } + + /** + * 주어진 날짜가 속한 월(1일 ~ 말일) 범위를 반환합니다. + */ + public static MonthRangeDto monthRangeOf(LocalDate date) { + YearMonth ym = YearMonth.from(date); + LocalDate start = ym.atDay(1); + LocalDate end = ym.atEndOfMonth(); + return new MonthRangeDto(start, end); + } + + /** + * KST 기준 "저번 달" 범위를 반환합니다. + */ + public static MonthRangeDto getLastMonthRange() { + LocalDate today = LocalDate.now(KST); + LocalDate lastMonthDate = today.minusMonths(1); + return monthRangeOf(lastMonthDate); + } + + /** + * KST 기준 "이번 달" 범위를 반환합니다. + */ + public static MonthRangeDto getThisMonthRange() { + LocalDate today = LocalDate.now(KST); + return monthRangeOf(today); + } +} diff --git a/src/main/java/com/devkor/ifive/nadab/global/shared/util/dto/MonthRangeDto.java b/src/main/java/com/devkor/ifive/nadab/global/shared/util/dto/MonthRangeDto.java new file mode 100644 index 0000000..1b8b065 --- /dev/null +++ b/src/main/java/com/devkor/ifive/nadab/global/shared/util/dto/MonthRangeDto.java @@ -0,0 +1,9 @@ +package com.devkor.ifive.nadab.global.shared.util.dto; + +import java.time.LocalDate; + +public record MonthRangeDto( + LocalDate monthStartDate, + LocalDate monthEndDate +) { +} diff --git a/src/main/resources/db/migration/V20260110_1638__IS_create_monthly_reports_table.sql b/src/main/resources/db/migration/V20260110_1638__IS_create_monthly_reports_table.sql new file mode 100644 index 0000000..a31f40e --- /dev/null +++ b/src/main/resources/db/migration/V20260110_1638__IS_create_monthly_reports_table.sql @@ -0,0 +1,26 @@ +CREATE TABLE monthly_reports ( + id BIGSERIAL PRIMARY KEY, + user_id BIGINT NOT NULL, + + month_start_date DATE NOT NULL, + month_end_date DATE NOT NULL, + + discovered VARCHAR(250), + good VARCHAR(250), + improve VARCHAR(250), + + status VARCHAR(16) NOT NULL, + date DATE NOT NULL, + analyzed_at TIMESTAMPTZ, + + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + + CONSTRAINT fk_monthly_reports_user + FOREIGN KEY (user_id) REFERENCES users(id), + + CONSTRAINT uq_monthly_reports_user_month + UNIQUE (user_id, month_start_date) +); + +CREATE INDEX idx_monthly_reports_user_month_start + ON monthly_reports(user_id, month_start_date DESC); \ No newline at end of file