Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions services/ai-service/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -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'
}
Original file line number Diff line number Diff line change
@@ -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 {
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -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
);
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -86,4 +86,40 @@ public List<AiReport> 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);
}
}
Original file line number Diff line number Diff line change
@@ -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<Long> 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<Long> 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
);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,26 @@
)
</insert>

<!-- 사용자·연월 기준 AI 리포트 저장 또는 갱신 -->
<insert id="upsert"
parameterType="com.ntropy.ai.domain.AiReport">

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`)
</insert>

<!-- 2. PK(report_id) 기준 단건 조회 -->
<select id="findById" resultMap="aiReportResultMap">
SELECT <include refid="aiReportColumns"/>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
package com.ntropy.ai.service;

import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;

import java.time.YearMonth;

import org.junit.jupiter.api.Test;

/**
* 월간 AI 리포트 오케스트레이터의 기본 동작을 검증하는 테스트입니다.
*
* 아직 배치 대상 사용자 조회 Client가 연결되지 않았으므로,
* 빈 사용자 목록을 처리할 때 예외 없이 정상 종료되는지를 확인합니다.
*/
class MonthlyAiReportOrchestrationServiceTest {

/**
* 대상 사용자가 없는 경우에도 배치 전체가 실패하지 않아야 합니다.
*/
@Test
void runBatch_whenTargetUserListIsEmpty_completesWithoutException() {
// 현재 오케스트레이터는 외부 Client 의존성이 없는 뼈대 상태입니다.
MonthlyAiReportOrchestrationService orchestrationService =
new MonthlyAiReportOrchestrationService();

// 테스트에서 원하는 리포트 대상 연월을 직접 전달합니다.
YearMonth targetYearMonth = YearMonth.of(2026, 7);

// 대상 사용자가 0명이어도 예외 없이 완료되어야 합니다.
assertDoesNotThrow(
() -> orchestrationService.runBatch(targetYearMonth)
);
}
}
Loading