diff --git a/services/ai-service/build.gradle b/services/ai-service/build.gradle index 56330a30..b6ad722b 100644 --- a/services/ai-service/build.gradle +++ b/services/ai-service/build.gradle @@ -19,4 +19,6 @@ dependencies { // AI_REPORT의 JSON 문자열을 JsonNode 객체로 변환하기 위해 사용 implementation 'com.fasterxml.jackson.core:jackson-databind:2.9.4' + + implementation 'org.slf4j:slf4j-api:1.7.36' } \ No newline at end of file diff --git a/services/ai-service/src/main/java/com/ntropy/ai/config/SchedulingConfig.java b/services/ai-service/src/main/java/com/ntropy/ai/config/SchedulingConfig.java new file mode 100644 index 00000000..058854b0 --- /dev/null +++ b/services/ai-service/src/main/java/com/ntropy/ai/config/SchedulingConfig.java @@ -0,0 +1,13 @@ +package com.ntropy.ai.config; + +import org.springframework.context.annotation.Configuration; +import org.springframework.scheduling.annotation.EnableScheduling; + +/** + * ai-service에서 @Scheduled 어노테이션을 사용할 수 있도록 + * Spring 스케줄링 기능을 활성화하는 설정 클래스입니다. + */ +@Configuration +@EnableScheduling +public class SchedulingConfig { +} \ No newline at end of file diff --git a/services/ai-service/src/main/java/com/ntropy/ai/mapper/AiReportMapper.java b/services/ai-service/src/main/java/com/ntropy/ai/mapper/AiReportMapper.java index 9fdc5b94..0fe56a25 100644 --- a/services/ai-service/src/main/java/com/ntropy/ai/mapper/AiReportMapper.java +++ b/services/ai-service/src/main/java/com/ntropy/ai/mapper/AiReportMapper.java @@ -20,6 +20,19 @@ public interface AiReportMapper { */ int insert(AiReport aiReport); + /** + * 사용자·연월 기준으로 AI 리포트를 저장하거나 갱신합니다. + * + * (user_id, year_month) 유니크 제약조건이 충돌하면 + * 기존 리포트의 JSON 데이터만 갱신합니다. + * + * @param aiReport 저장 또는 갱신할 AI 리포트 객체 + * @return 영향받은 행 수 + */ + int upsert(AiReport aiReport); + + + /** * 리포트 고유 ID(PK) 기준으로 단건 조회 * @param reportId 조회할 리포트 PK diff --git a/services/ai-service/src/main/java/com/ntropy/ai/scheduler/MonthlyAiReportScheduler.java b/services/ai-service/src/main/java/com/ntropy/ai/scheduler/MonthlyAiReportScheduler.java new file mode 100644 index 00000000..7a66223e --- /dev/null +++ b/services/ai-service/src/main/java/com/ntropy/ai/scheduler/MonthlyAiReportScheduler.java @@ -0,0 +1,51 @@ +package com.ntropy.ai.scheduler; + +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; + +import com.ntropy.ai.service.MonthlyAiReportOrchestrationService; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +/** + * 매월 AI 리포트 배치를 실행하는 스케줄러입니다. + * + * 실제 처리 순서는 MonthlyAiReportOrchestrationService에 맡기고, + * 이 클래스는 정해진 시각에 배치를 시작하는 역할만 담당합니다. + */ +@Slf4j +@Component +@RequiredArgsConstructor +public class MonthlyAiReportScheduler { + + // 월간 리포트 전체 흐름을 처리하는 서비스입니다. + private final MonthlyAiReportOrchestrationService orchestrationService; + + /** + * 매월 1일 자정, 한국 시간 기준으로 실행됩니다. + * + * 기본 크론 표현식: + * 초 분 시 일 월 요일 + * 0 0 0 1 * ? + */ + @Scheduled( + cron = "${ai-report.scheduler.monthly-cron:0 0 0 1 * ?}", + zone = "Asia/Seoul" + ) + public void runMonthlyAiReportBatch() { + log.info("[AI 리포트 배치] 월간 배치 스케줄 실행 시작"); + + try { + orchestrationService.runLastMonthBatch(); + + log.info("[AI 리포트 배치] 월간 배치 스케줄 실행 완료"); + } catch (Exception exception) { + // 예상하지 못한 예외가 발생해도 스케줄러 스레드가 죽지 않도록 기록합니다. + log.error( + "[AI 리포트 배치] 월간 배치 스케줄 실행 중 예상하지 못한 오류 발생", + exception + ); + } + } +} \ No newline at end of file diff --git a/services/ai-service/src/main/java/com/ntropy/ai/service/AiReportService.java b/services/ai-service/src/main/java/com/ntropy/ai/service/AiReportService.java index 124c9653..b5707afe 100644 --- a/services/ai-service/src/main/java/com/ntropy/ai/service/AiReportService.java +++ b/services/ai-service/src/main/java/com/ntropy/ai/service/AiReportService.java @@ -86,4 +86,40 @@ public List findAllByUserId(Long userId) { // 조회 결과가 없으면 MyBatis는 빈 List를 반환합니다. return aiReportMapper.findAllByUserId(userId); } + + /** + * 사용자·연월 기준으로 AI 리포트를 저장하거나 갱신합니다. + * + * 같은 사용자의 같은 달 리포트가 이미 있으면 + * 유니크 인덱스를 기준으로 JSON 데이터가 갱신됩니다. + * + * @param aiReport 저장 또는 갱신할 AI 리포트 객체 + */ + public void upsert(AiReport aiReport) { + if (aiReport == null) { + throw new ServiceException( + AiReportErrorCode.INVALID_REQUEST, + "AI 리포트는 필수입니다." + ); + } + + if (aiReport.getUserId() == null || aiReport.getUserId() <= 0) { + throw new ServiceException( + AiReportErrorCode.INVALID_REQUEST, + "userId는 양수여야 합니다." + ); + } + + if ( + aiReport.getYearMonth() == null + || !aiReport.getYearMonth().matches("\\d{4}-\\d{2}") + ) { + throw new ServiceException( + AiReportErrorCode.INVALID_REQUEST, + "yearMonth는 YYYY-MM 형식이어야 합니다." + ); + } + + aiReportMapper.upsert(aiReport); + } } \ No newline at end of file diff --git a/services/ai-service/src/main/java/com/ntropy/ai/service/MonthlyAiReportOrchestrationService.java b/services/ai-service/src/main/java/com/ntropy/ai/service/MonthlyAiReportOrchestrationService.java new file mode 100644 index 00000000..462d9a80 --- /dev/null +++ b/services/ai-service/src/main/java/com/ntropy/ai/service/MonthlyAiReportOrchestrationService.java @@ -0,0 +1,145 @@ +package com.ntropy.ai.service; + +import java.time.YearMonth; +import java.util.Collections; +import java.util.List; + +import org.springframework.stereotype.Service; + +import lombok.extern.slf4j.Slf4j; +import java.time.ZoneId; + +/** + * 월간 AI 리포트 생성 파이프라인 전체를 조정하는 서비스입니다. + * + * 현재는 account-service, diagnosis-service, FastAPI 연동 계약이 + * 확정되지 않았으므로 안전한 실행 뼈대와 로그만 구현합니다. + * + * 계약 확정 후 processSingleUser() 내부의 TODO를 실제 Client 호출로 교체합니다. + */ +@Slf4j +@Service +public class MonthlyAiReportOrchestrationService { + + /** + * 매월 1일에 호출됩니다. + * + * 예를 들어 2026년 8월 1일에 실행되면 + * 2026년 7월 리포트를 생성 대상으로 처리합니다. + */ + public void runLastMonthBatch() { + YearMonth lastMonth = YearMonth.now( + ZoneId.of("Asia/Seoul") + ).minusMonths(1); + + runBatch(lastMonth); + } + + /** + * 지정한 연월의 AI 리포트 배치를 실행합니다. + * + * 테스트에서는 현재 날짜와 관계없이 원하는 연월을 직접 전달할 수 있습니다. + * + * @param targetYearMonth 리포트 생성 대상 연월 + */ + public void runBatch(YearMonth targetYearMonth) { + String yearMonth = targetYearMonth.toString(); + long startedAt = System.currentTimeMillis(); + + log.info( + "[AI 리포트 배치] 대상 연월: {}, 배치 시작", + yearMonth + ); + + // 추후 user-service 또는 account-service Client를 통해 + // 배치 대상 사용자 목록을 조회하도록 교체합니다. + List targetUserIds = findTargetUserIds(yearMonth); + + int successCount = 0; + int failedCount = 0; + + // 사용자 한 명의 실패가 전체 배치를 중단시키지 않도록 개별 예외를 분리합니다. + for (Long userId : targetUserIds) { + try { + processSingleUser(userId, yearMonth); + successCount++; + + } catch (Exception exception) { + failedCount++; + + log.error( + "[AI 리포트 배치] 사용자 리포트 생성 실패 - userId: {}, yearMonth: {}", + userId, + yearMonth, + exception + ); + } + } + + long elapsedMillis = System.currentTimeMillis() - startedAt; + + log.info( + "[AI 리포트 배치] 완료 - yearMonth: {}, 대상: {}명, 성공: {}명, 실패: {}명, 실행 시간: {}ms", + yearMonth, + targetUserIds.size(), + successCount, + failedCount, + elapsedMillis + ); + } + + /** + * 배치 대상 사용자 ID 목록을 조회합니다. + * + * 아직 사용자 조회 Client 계약이 없으므로 빈 목록을 반환합니다. + * 따라서 현재 단계에서 실제 데이터 저장이나 외부 API 호출은 일어나지 않습니다. + */ + private List findTargetUserIds(String yearMonth) { + log.info( + "[AI 리포트 배치] 대상 사용자 조회 연동 대기 중 - yearMonth: {}", + yearMonth + ); + + return Collections.emptyList(); + } + + /** + * 사용자 한 명의 월간 AI 리포트를 생성합니다. + * + * 아래 순서는 최종 구현 예정 파이프라인입니다. + */ + private void processSingleUser( + Long userId, + String yearMonth + ) { + log.info( + "[AI 리포트 배치] 사용자 리포트 처리 시작 - userId: {}, yearMonth: {}", + userId, + yearMonth + ); + + /* + * TODO 1. account-service에서 분류 대상 거래 조회 + * + * TODO 2. FastAPI에 소비 내역 분류 요청 + * + * TODO 3. account-service에 TXN_ANALYSIS 저장 요청 + * + * TODO 4. diagnosis-service에 재무진단 생성 요청 + * + * TODO 5. diagnosis-service에서 DIAGNOSIS_RESULT 조회 + * + * TODO 6. FastAPI에 금융상품 추천 및 리포트 문구 생성 요청 + * + * TODO 7. AiReportService.upsert()로 AI_REPORT 저장 또는 갱신 + * + * TODO 8. notification-service에 알림 발송 요청 + */ + + log.info( + "[AI 리포트 배치] 사용자 리포트 처리 뼈대 완료 - userId: {}, yearMonth: {}", + userId, + yearMonth + ); + } +} \ No newline at end of file diff --git a/services/ai-service/src/main/resources/mapper/ai/AiReportMapper.xml b/services/ai-service/src/main/resources/mapper/ai/AiReportMapper.xml index 4613dbe1..ef811ea7 100644 --- a/services/ai-service/src/main/resources/mapper/ai/AiReportMapper.xml +++ b/services/ai-service/src/main/resources/mapper/ai/AiReportMapper.xml @@ -32,6 +32,26 @@ ) + + + + INSERT INTO `AI_REPORT` ( + `user_id`, + `year_month`, + `financial_summary_json`, + `recommendation_json` + ) VALUES ( + #{userId}, + #{yearMonth}, + #{financialSummaryJson}, + #{recommendationJson} + ) + ON DUPLICATE KEY UPDATE + `financial_summary_json` = VALUES(`financial_summary_json`), + `recommendation_json` = VALUES(`recommendation_json`) + +