From 4466252fefdf2d87d1f1cab34a4635c13375b126 Mon Sep 17 00:00:00 2001 From: bigwaveBigwave Date: Sat, 8 Aug 2026 01:45:21 +0900 Subject: [PATCH 1/2] =?UTF-8?q?feature:=20account-service=20=EB=B6=84?= =?UTF-8?q?=EB=A5=98=20=EB=8C=80=EC=83=81=20=EA=B1=B0=EB=9E=98=20=EC=A1=B0?= =?UTF-8?q?=ED=9A=8C=20=EB=B0=8F=20TXN=5FANALYSIS=20=EC=A0=80=EC=9E=A5=20?= =?UTF-8?q?=EC=97=B0=EB=8F=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../AccountTransactionAnalysisClient.java | 31 ++++ .../ClassificationTargetTransaction.java | 22 +++ .../account/TransactionAnalysisSaveItem.java | 19 +++ .../TransactionAnalysisSaveRequest.java | 20 +++ ...LocalAccountTransactionAnalysisClient.java | 36 +++++ .../ntropy/account/domain/TxnAnalysis.java | 21 +++ .../account/mapper/TxnAnalysisMapper.java | 26 ++++ .../account/service/TxnAnalysisService.java | 63 ++++++++ .../mapper/account/TxnAnalysisMapper.xml | 58 +++++++ .../MonthlyAiReportOrchestrationService.java | 144 +++++++++++------- ...nthlyAiReportOrchestrationServiceTest.java | 2 +- 11 files changed, 389 insertions(+), 53 deletions(-) create mode 100644 common/src/main/java/com/ntropy/common/client/AccountTransactionAnalysisClient.java create mode 100644 common/src/main/java/com/ntropy/common/dto/account/ClassificationTargetTransaction.java create mode 100644 common/src/main/java/com/ntropy/common/dto/account/TransactionAnalysisSaveItem.java create mode 100644 common/src/main/java/com/ntropy/common/dto/account/TransactionAnalysisSaveRequest.java create mode 100644 services/account-service/src/main/java/com/ntropy/account/client/LocalAccountTransactionAnalysisClient.java create mode 100644 services/account-service/src/main/java/com/ntropy/account/domain/TxnAnalysis.java create mode 100644 services/account-service/src/main/java/com/ntropy/account/mapper/TxnAnalysisMapper.java create mode 100644 services/account-service/src/main/java/com/ntropy/account/service/TxnAnalysisService.java create mode 100644 services/account-service/src/main/resources/mapper/account/TxnAnalysisMapper.xml diff --git a/common/src/main/java/com/ntropy/common/client/AccountTransactionAnalysisClient.java b/common/src/main/java/com/ntropy/common/client/AccountTransactionAnalysisClient.java new file mode 100644 index 0000000..fafebf9 --- /dev/null +++ b/common/src/main/java/com/ntropy/common/client/AccountTransactionAnalysisClient.java @@ -0,0 +1,31 @@ +package com.ntropy.common.client; + +import java.util.List; + +import com.ntropy.common.dto.account.ClassificationTargetTransaction; +import com.ntropy.common.dto.account.TransactionAnalysisSaveRequest; + +/** + * AI-service가 account-service의 거래 분석 기능을 호출하기 위한 내부 Client 인터페이스입니다. + */ +public interface AccountTransactionAnalysisClient { + + /** + * 특정 사용자와 연월 기준으로 AI 분류 대상 출금 거래를 조회합니다. + * + * @param userId 사용자 ID + * @param yearMonth 조회 대상 연월. 예: "2026-07" + * @return FastAPI 소비 분류 요청에 사용할 거래 목록 + */ + List findClassificationTargets( + Long userId, + String yearMonth + ); + + /** + * FastAPI 소비 분류 결과를 account-service의 TXN_ANALYSIS에 저장합니다. + * + * @param request 저장할 분류 결과 요청 + */ + void saveTransactionAnalyses(TransactionAnalysisSaveRequest request); +} \ No newline at end of file diff --git a/common/src/main/java/com/ntropy/common/dto/account/ClassificationTargetTransaction.java b/common/src/main/java/com/ntropy/common/dto/account/ClassificationTargetTransaction.java new file mode 100644 index 0000000..12f86e1 --- /dev/null +++ b/common/src/main/java/com/ntropy/common/dto/account/ClassificationTargetTransaction.java @@ -0,0 +1,22 @@ +package com.ntropy.common.dto.account; + +import java.time.LocalDateTime; + +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; + +/** + * FastAPI 소비 분류 대상 거래 DTO입니다. + */ +@Getter +@NoArgsConstructor +@AllArgsConstructor +public class ClassificationTargetTransaction { + + private Long transactionId; + private String merchantName; + private String description; + private Long amount; + private LocalDateTime transactionDate; +} \ No newline at end of file diff --git a/common/src/main/java/com/ntropy/common/dto/account/TransactionAnalysisSaveItem.java b/common/src/main/java/com/ntropy/common/dto/account/TransactionAnalysisSaveItem.java new file mode 100644 index 0000000..2bd575c --- /dev/null +++ b/common/src/main/java/com/ntropy/common/dto/account/TransactionAnalysisSaveItem.java @@ -0,0 +1,19 @@ +package com.ntropy.common.dto.account; + +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; + +/** + * 거래 1건의 소비 분류 저장 DTO입니다. + */ +@Getter +@NoArgsConstructor +@AllArgsConstructor +public class TransactionAnalysisSaveItem { + + private Long transactionId; + private Boolean isConsumption; + private String category; + private String expenseType; +} \ No newline at end of file diff --git a/common/src/main/java/com/ntropy/common/dto/account/TransactionAnalysisSaveRequest.java b/common/src/main/java/com/ntropy/common/dto/account/TransactionAnalysisSaveRequest.java new file mode 100644 index 0000000..7502584 --- /dev/null +++ b/common/src/main/java/com/ntropy/common/dto/account/TransactionAnalysisSaveRequest.java @@ -0,0 +1,20 @@ +package com.ntropy.common.dto.account; + +import java.util.List; + +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; + +/** + * 거래 분석 결과 저장 요청 DTO입니다. + */ +@Getter +@NoArgsConstructor +@AllArgsConstructor +public class TransactionAnalysisSaveRequest { + + private Long userId; + private String yearMonth; + private List results; +} \ No newline at end of file diff --git a/services/account-service/src/main/java/com/ntropy/account/client/LocalAccountTransactionAnalysisClient.java b/services/account-service/src/main/java/com/ntropy/account/client/LocalAccountTransactionAnalysisClient.java new file mode 100644 index 0000000..909f717 --- /dev/null +++ b/services/account-service/src/main/java/com/ntropy/account/client/LocalAccountTransactionAnalysisClient.java @@ -0,0 +1,36 @@ +package com.ntropy.account.client; + +import java.util.List; + +import org.springframework.stereotype.Component; + +import com.ntropy.account.service.TxnAnalysisService; +import com.ntropy.common.client.AccountTransactionAnalysisClient; +import com.ntropy.common.dto.account.ClassificationTargetTransaction; +import com.ntropy.common.dto.account.TransactionAnalysisSaveRequest; + +import lombok.RequiredArgsConstructor; + +/** + * AccountTransactionAnalysisClient의 account-service 내부 구현체입니다. + */ +@Component +@RequiredArgsConstructor +public class LocalAccountTransactionAnalysisClient + implements AccountTransactionAnalysisClient { + + private final TxnAnalysisService txnAnalysisService; + + @Override + public List findClassificationTargets( + Long userId, + String yearMonth + ) { + return txnAnalysisService.findClassificationTargets(userId, yearMonth); + } + + @Override + public void saveTransactionAnalyses(TransactionAnalysisSaveRequest request) { + txnAnalysisService.saveTransactionAnalyses(request); + } +} diff --git a/services/account-service/src/main/java/com/ntropy/account/domain/TxnAnalysis.java b/services/account-service/src/main/java/com/ntropy/account/domain/TxnAnalysis.java new file mode 100644 index 0000000..2ab59f5 --- /dev/null +++ b/services/account-service/src/main/java/com/ntropy/account/domain/TxnAnalysis.java @@ -0,0 +1,21 @@ +package com.ntropy.account.domain; + +import java.time.LocalDateTime; + +import lombok.Getter; +import lombok.Setter; + +/** + * TXN_ANALYSIS 테이블과 매핑되는 도메인 객체입니다. + */ +@Getter +@Setter +public class TxnAnalysis { + + private Long analysisId; + private Long accountTransactionId; + private Boolean isConsumption; + private String category; + private String expenseType; + private LocalDateTime classifiedAt; +} \ No newline at end of file diff --git a/services/account-service/src/main/java/com/ntropy/account/mapper/TxnAnalysisMapper.java b/services/account-service/src/main/java/com/ntropy/account/mapper/TxnAnalysisMapper.java new file mode 100644 index 0000000..e4b98de --- /dev/null +++ b/services/account-service/src/main/java/com/ntropy/account/mapper/TxnAnalysisMapper.java @@ -0,0 +1,26 @@ +package com.ntropy.account.mapper; + +import java.util.List; + +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; + +import com.ntropy.account.domain.TxnAnalysis; +import com.ntropy.common.dto.account.ClassificationTargetTransaction; + +@Mapper +public interface TxnAnalysisMapper { + + /** + * 특정 사용자와 연월 기준으로 분류 대상 출금 거래를 조회합니다. + */ + List findClassificationTargets( + @Param("userId") Long userId, + @Param("yearMonth") String yearMonth + ); + + /** + * 거래 분석 결과를 저장하거나 갱신합니다. + */ + int upsert(TxnAnalysis txnAnalysis); +} \ No newline at end of file diff --git a/services/account-service/src/main/java/com/ntropy/account/service/TxnAnalysisService.java b/services/account-service/src/main/java/com/ntropy/account/service/TxnAnalysisService.java new file mode 100644 index 0000000..26099e1 --- /dev/null +++ b/services/account-service/src/main/java/com/ntropy/account/service/TxnAnalysisService.java @@ -0,0 +1,63 @@ +package com.ntropy.account.service; + +import java.util.List; + +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import com.ntropy.account.domain.TxnAnalysis; +import com.ntropy.account.mapper.TxnAnalysisMapper; +import com.ntropy.common.dto.account.ClassificationTargetTransaction; +import com.ntropy.common.dto.account.TransactionAnalysisSaveItem; +import com.ntropy.common.dto.account.TransactionAnalysisSaveRequest; + +import lombok.RequiredArgsConstructor; + +@Service +@RequiredArgsConstructor +public class TxnAnalysisService { + + private final TxnAnalysisMapper txnAnalysisMapper; + + public List findClassificationTargets( + Long userId, + String yearMonth + ) { + validateUserId(userId); + validateYearMonth(yearMonth); + + return txnAnalysisMapper.findClassificationTargets(userId, yearMonth); + } + + @Transactional + public void saveTransactionAnalyses(TransactionAnalysisSaveRequest request) { + validateUserId(request.getUserId()); + validateYearMonth(request.getYearMonth()); + + if (request.getResults() == null || request.getResults().isEmpty()) { + return; + } + + for (TransactionAnalysisSaveItem item : request.getResults()) { + TxnAnalysis txnAnalysis = new TxnAnalysis(); + txnAnalysis.setAccountTransactionId(item.getTransactionId()); + txnAnalysis.setIsConsumption(item.getIsConsumption()); + txnAnalysis.setCategory(item.getCategory()); + txnAnalysis.setExpenseType(item.getExpenseType()); + + txnAnalysisMapper.upsert(txnAnalysis); + } + } + + private void validateUserId(Long userId) { + if (userId == null || userId <= 0) { + throw new IllegalArgumentException("userId는 양수여야 합니다."); + } + } + + private void validateYearMonth(String yearMonth) { + if (yearMonth == null || !yearMonth.matches("\\d{4}-\\d{2}")) { + throw new IllegalArgumentException("yearMonth는 YYYY-MM 형식이어야 합니다."); + } + } +} \ No newline at end of file diff --git a/services/account-service/src/main/resources/mapper/account/TxnAnalysisMapper.xml b/services/account-service/src/main/resources/mapper/account/TxnAnalysisMapper.xml new file mode 100644 index 0000000..2368567 --- /dev/null +++ b/services/account-service/src/main/resources/mapper/account/TxnAnalysisMapper.xml @@ -0,0 +1,58 @@ + + + + + + + + + + + + + + + + + INSERT INTO TXN_ANALYSIS ( + account_transaction_id, + is_consumption, + category, + expense_type, + classified_at + ) VALUES ( + #{accountTransactionId}, + #{isConsumption}, + #{category}, + #{expenseType}, + NOW() + ) + ON DUPLICATE KEY UPDATE + is_consumption = VALUES(is_consumption), + category = VALUES(category), + expense_type = VALUES(expense_type), + classified_at = NOW() + + + \ 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 index 06f12bc..82a3b8c 100644 --- 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 @@ -4,12 +4,19 @@ import java.time.ZoneId; import java.util.Collections; import java.util.List; +import java.util.Set; +import java.util.stream.Collectors; import org.springframework.stereotype.Service; import com.ntropy.ai.client.fastapi.FastApiTransactionClassificationClient; import com.ntropy.ai.dto.fastapi.TransactionClassificationResponse; +import com.ntropy.ai.dto.fastapi.TransactionClassificationResult; import com.ntropy.ai.dto.fastapi.TransactionForClassification; +import com.ntropy.common.client.AccountTransactionAnalysisClient; +import com.ntropy.common.dto.account.ClassificationTargetTransaction; +import com.ntropy.common.dto.account.TransactionAnalysisSaveItem; +import com.ntropy.common.dto.account.TransactionAnalysisSaveRequest; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -17,25 +24,20 @@ /** * 월간 AI 리포트 생성 파이프라인 전체를 조정하는 서비스입니다. * - * 현재는 account-service, diagnosis-service 연동 계약이 일부 확정되지 않았으므로 - * 배치 대상 사용자 조회와 거래 조회는 안전한 빈 목록으로 유지합니다. - * - * FastAPI 소비 분류 API 호출 구조만 먼저 연결해두고, - * 계약 확정 후 processSingleUser() 내부 TODO를 실제 Client 호출로 교체합니다. + * 현재는 대상 사용자 조회 계약이 완전히 연결되지 않아, + * 사용자 목록 조회는 placeholder 상태입니다. + * 다만 사용자 1명 단위의 거래 조회 -> FastAPI 분류 -> account-service 저장 흐름은 연결했습니다. */ @Slf4j @Service @RequiredArgsConstructor public class MonthlyAiReportOrchestrationService { - // FastAPI 소비 분류 API 호출 Client입니다. private final FastApiTransactionClassificationClient classificationClient; + private final AccountTransactionAnalysisClient accountTransactionAnalysisClient; /** - * 매월 1일 스케줄러에서 호출됩니다. - * - * 예를 들어 2026년 8월 1일에 실행되면 - * 2026년 7월 리포트를 생성 대상으로 처리합니다. + * 매월 1일 실행될 때 지난달 기준으로 배치를 돌립니다. */ public void runLastMonthBatch() { YearMonth lastMonth = YearMonth.now( @@ -48,9 +50,7 @@ public void runLastMonthBatch() { /** * 지정한 연월의 AI 리포트 배치를 실행합니다. * - * 테스트에서는 현재 날짜와 관계없이 원하는 연월을 직접 전달할 수 있습니다. - * - * @param targetYearMonth 리포트 생성 대상 연월 + * @param targetYearMonth 배치 대상 연월 */ public void runBatch(YearMonth targetYearMonth) { String yearMonth = targetYearMonth.toString(); @@ -61,24 +61,20 @@ public void runBatch(YearMonth targetYearMonth) { 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: {}", + "[AI 리포트 배치] 사용자 처리 실패 - userId: {}, yearMonth: {}", userId, yearMonth, exception @@ -101,8 +97,8 @@ public void runBatch(YearMonth targetYearMonth) { /** * 배치 대상 사용자 ID 목록을 조회합니다. * - * 아직 사용자 조회 Client 계약이 없으므로 빈 목록을 반환합니다. - * 따라서 현재 단계에서 실제 데이터 저장이나 외부 API 호출은 일어나지 않습니다. + * 아직 사용자 목록 조회 Client 계약이 완전히 연결되지 않았으므로 + * 현재는 빈 목록을 반환합니다. */ private List findTargetUserIds(String yearMonth) { log.info( @@ -114,32 +110,28 @@ private List findTargetUserIds(String yearMonth) { } /** - * 사용자 한 명의 월간 AI 리포트를 생성합니다. + * 사용자 1명의 월간 AI 리포트 생성 흐름을 처리합니다. * - * 현재는 account-service 거래 조회 계약이 확정되지 않았으므로 - * 분류 대상 거래 목록은 빈 목록으로 둡니다. - * - * 이후 account-service 거래 조회 Client가 생기면 - * transactions 변수에 실제 분류 대상 출금 거래를 넣으면 됩니다. + * 순서: + * 1. account-service에서 분류 대상 거래 조회 + * 2. FastAPI 소비 분류 요청 + * 3. account-service에 TXN_ANALYSIS 저장 + * 4. 이후 diagnosis-service / AI_REPORT / 알림 발송은 TODO */ - private void processSingleUser( - Long userId, - String yearMonth - ) { + private void processSingleUser(Long userId, String yearMonth) { log.info( "[AI 리포트 배치] 사용자 리포트 처리 시작 - userId: {}, yearMonth: {}", userId, yearMonth ); - /* - * TODO 1. account-service에서 분류 대상 거래 조회 - * - * 현재는 거래 조회 Client 계약이 없으므로 빈 목록으로 둡니다. - */ - List transactions = List.of(); + List classificationTargets = + accountTransactionAnalysisClient.findClassificationTargets( + userId, + yearMonth + ); - if (transactions.isEmpty()) { + if (classificationTargets.isEmpty()) { log.info( "[AI 리포트 배치] 분류 대상 거래 없음 - userId: {}, yearMonth: {}", userId, @@ -148,45 +140,93 @@ private void processSingleUser( return; } - /* - * TODO 2. FastAPI에 소비 내역 분류 요청 - */ + List transactions = + classificationTargets.stream() + .map(target -> new TransactionForClassification( + target.getTransactionId(), + target.getMerchantName(), + target.getDescription(), + target.getAmount(), + target.getTransactionDate() + )) + .toList(); + TransactionClassificationResponse classificationResponse = classificationClient.classifyTransactions(transactions); - if ( - classificationResponse == null - || !Boolean.TRUE.equals(classificationResponse.getSuccess()) - || classificationResponse.getData() == null - ) { + if (classificationResponse == null + || !Boolean.TRUE.equals(classificationResponse.getSuccess()) + || classificationResponse.getData() == null + || classificationResponse.getData().getResults() == null) { throw new IllegalStateException( "FastAPI 소비 분류 응답이 올바르지 않습니다." ); } + List results = + classificationResponse.getData().getResults(); + + if (results.size() != transactions.size()) { + throw new IllegalStateException( + "FastAPI 분류 결과 수가 요청 거래 수와 일치하지 않습니다." + ); + } + + Set requestedTransactionIds = + transactions.stream() + .map(TransactionForClassification::getTransactionId) + .collect(Collectors.toSet()); + + Set responseTransactionIds = + results.stream() + .map(TransactionClassificationResult::getTransactionId) + .collect(Collectors.toSet()); + + if (!requestedTransactionIds.equals(responseTransactionIds)) { + throw new IllegalStateException( + "FastAPI 분류 결과의 transactionId가 요청 거래와 일치하지 않습니다." + ); + } + + List saveItems = + results.stream() + .map(result -> new TransactionAnalysisSaveItem( + result.getTransactionId(), + result.getIsConsumption(), + result.getCategory(), + result.getExpenseType() + )) + .toList(); + + accountTransactionAnalysisClient.saveTransactionAnalyses( + new TransactionAnalysisSaveRequest( + userId, + yearMonth, + saveItems + ) + ); + log.info( - "[AI 리포트 배치] 소비 분류 완료 - userId: {}, yearMonth: {}, resultCount: {}", + "[AI 리포트 배치] 거래 분석 결과 저장 완료 - userId: {}, yearMonth: {}, savedCount: {}", userId, yearMonth, - classificationResponse.getData().getResults().size() + saveItems.size() ); /* - * 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 7. AI_REPORT 저장 또는 갱신 * - * TODO 8. notification-service에 알림 발송 요청 + * TODO 8. 이메일·카카오톡 발송 요청 */ log.info( - "[AI 리포트 배치] 사용자 리포트 처리 뼈대 완료 - userId: {}, yearMonth: {}", + "[AI 리포트 배치] 사용자 리포트 처리 완료 - userId: {}, yearMonth: {}", userId, yearMonth ); diff --git a/services/ai-service/src/test/java/com/ntropy/ai/service/MonthlyAiReportOrchestrationServiceTest.java b/services/ai-service/src/test/java/com/ntropy/ai/service/MonthlyAiReportOrchestrationServiceTest.java index 4ae8b14..e0b1b34 100644 --- a/services/ai-service/src/test/java/com/ntropy/ai/service/MonthlyAiReportOrchestrationServiceTest.java +++ b/services/ai-service/src/test/java/com/ntropy/ai/service/MonthlyAiReportOrchestrationServiceTest.java @@ -21,7 +21,7 @@ class MonthlyAiReportOrchestrationServiceTest { void runBatch_whenTargetUserListIsEmpty_completesWithoutException() { // 현재 오케스트레이터는 외부 Client 의존성이 없는 뼈대 상태입니다. MonthlyAiReportOrchestrationService orchestrationService = - new MonthlyAiReportOrchestrationService(null); + new MonthlyAiReportOrchestrationService(null, null); // 테스트에서 원하는 리포트 대상 연월을 직접 전달합니다. YearMonth targetYearMonth = YearMonth.of(2026, 7); From 51702ba5b2f33a6ee0e8594a1df72a250f6935bd Mon Sep 17 00:00:00 2001 From: bigwaveBigwave Date: Sat, 8 Aug 2026 17:54:35 +0900 Subject: [PATCH 2/2] =?UTF-8?q?feature:=20account-service=20=EA=B1=B0?= =?UTF-8?q?=EB=9E=98=20=EB=B6=84=EB=A5=98=20=EB=B0=8F=20TXN=5FANALYSIS=20?= =?UTF-8?q?=EC=A0=80=EC=9E=A5=20=EC=97=B0=EB=8F=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../TransactionAnalysisSaveRequest.java | 2 +- ...LocalAccountTransactionAnalysisClient.java | 16 ++- .../domain/{ => entity}/TxnAnalysis.java | 7 +- .../mapper/FinancialDataQueryMapper.java | 6 + .../account/mapper/TxnAnalysisMapper.java | 5 +- .../account/service/TxnAnalysisService.java | 43 ++---- .../resources/db/account-service-schema.sql | 19 +++ .../account/FinancialDataQueryMapper.xml | 30 +++++ .../mapper/account/TxnAnalysisMapper.xml | 29 ++++- .../client/LocalAccountQueryClientTest.java | 9 ++ .../MonthlyAiReportOrchestrationService.java | 123 ++++++++++-------- .../bff/controller/ai/AiReportController.java | 20 ++- 12 files changed, 198 insertions(+), 111 deletions(-) rename services/account-service/src/main/java/com/ntropy/account/domain/{ => entity}/TxnAnalysis.java (72%) diff --git a/common/src/main/java/com/ntropy/common/dto/account/TransactionAnalysisSaveRequest.java b/common/src/main/java/com/ntropy/common/dto/account/TransactionAnalysisSaveRequest.java index 7502584..87c954d 100644 --- a/common/src/main/java/com/ntropy/common/dto/account/TransactionAnalysisSaveRequest.java +++ b/common/src/main/java/com/ntropy/common/dto/account/TransactionAnalysisSaveRequest.java @@ -16,5 +16,5 @@ public class TransactionAnalysisSaveRequest { private Long userId; private String yearMonth; - private List results; + private List analyses; } \ No newline at end of file diff --git a/services/account-service/src/main/java/com/ntropy/account/client/LocalAccountTransactionAnalysisClient.java b/services/account-service/src/main/java/com/ntropy/account/client/LocalAccountTransactionAnalysisClient.java index 909f717..3914b59 100644 --- a/services/account-service/src/main/java/com/ntropy/account/client/LocalAccountTransactionAnalysisClient.java +++ b/services/account-service/src/main/java/com/ntropy/account/client/LocalAccountTransactionAnalysisClient.java @@ -11,9 +11,6 @@ import lombok.RequiredArgsConstructor; -/** - * AccountTransactionAnalysisClient의 account-service 내부 구현체입니다. - */ @Component @RequiredArgsConstructor public class LocalAccountTransactionAnalysisClient @@ -26,11 +23,16 @@ public List findClassificationTargets( Long userId, String yearMonth ) { - return txnAnalysisService.findClassificationTargets(userId, yearMonth); + return txnAnalysisService.findClassificationTargets( + userId, + yearMonth + ); } @Override - public void saveTransactionAnalyses(TransactionAnalysisSaveRequest request) { - txnAnalysisService.saveTransactionAnalyses(request); + public void saveTransactionAnalyses( + TransactionAnalysisSaveRequest request + ) { + txnAnalysisService.saveAnalyses(request); } -} +} \ No newline at end of file diff --git a/services/account-service/src/main/java/com/ntropy/account/domain/TxnAnalysis.java b/services/account-service/src/main/java/com/ntropy/account/domain/entity/TxnAnalysis.java similarity index 72% rename from services/account-service/src/main/java/com/ntropy/account/domain/TxnAnalysis.java rename to services/account-service/src/main/java/com/ntropy/account/domain/entity/TxnAnalysis.java index 2ab59f5..bc5ce6d 100644 --- a/services/account-service/src/main/java/com/ntropy/account/domain/TxnAnalysis.java +++ b/services/account-service/src/main/java/com/ntropy/account/domain/entity/TxnAnalysis.java @@ -1,15 +1,18 @@ -package com.ntropy.account.domain; +package com.ntropy.account.domain.entity; import java.time.LocalDateTime; +import lombok.AllArgsConstructor; import lombok.Getter; +import lombok.NoArgsConstructor; import lombok.Setter; /** * TXN_ANALYSIS 테이블과 매핑되는 도메인 객체입니다. */ @Getter -@Setter +@NoArgsConstructor +@AllArgsConstructor public class TxnAnalysis { private Long analysisId; diff --git a/services/account-service/src/main/java/com/ntropy/account/mapper/FinancialDataQueryMapper.java b/services/account-service/src/main/java/com/ntropy/account/mapper/FinancialDataQueryMapper.java index fd0ee9d..51dc5f2 100644 --- a/services/account-service/src/main/java/com/ntropy/account/mapper/FinancialDataQueryMapper.java +++ b/services/account-service/src/main/java/com/ntropy/account/mapper/FinancialDataQueryMapper.java @@ -3,6 +3,7 @@ import java.time.LocalDate; import java.util.List; +import com.ntropy.common.dto.account.ClassificationTargetTransaction; import org.apache.ibatis.annotations.Mapper; import org.apache.ibatis.annotations.Param; @@ -33,4 +34,9 @@ List findTransactionsByAccountIdAndUserId( @Param("limit") int limit, @Param("offset") int offset ); + + List findClassificationTargets( + @Param("userId") Long userId, + @Param("yearMonth") String yearMonth + ); } diff --git a/services/account-service/src/main/java/com/ntropy/account/mapper/TxnAnalysisMapper.java b/services/account-service/src/main/java/com/ntropy/account/mapper/TxnAnalysisMapper.java index e4b98de..85a16e2 100644 --- a/services/account-service/src/main/java/com/ntropy/account/mapper/TxnAnalysisMapper.java +++ b/services/account-service/src/main/java/com/ntropy/account/mapper/TxnAnalysisMapper.java @@ -2,10 +2,11 @@ import java.util.List; +import com.ntropy.common.dto.account.TransactionAnalysisSaveRequest; import org.apache.ibatis.annotations.Mapper; import org.apache.ibatis.annotations.Param; -import com.ntropy.account.domain.TxnAnalysis; +import com.ntropy.account.domain.entity.TxnAnalysis; import com.ntropy.common.dto.account.ClassificationTargetTransaction; @Mapper @@ -23,4 +24,6 @@ List findClassificationTargets( * 거래 분석 결과를 저장하거나 갱신합니다. */ int upsert(TxnAnalysis txnAnalysis); + + int upsertAnalyses(TransactionAnalysisSaveRequest request); } \ No newline at end of file diff --git a/services/account-service/src/main/java/com/ntropy/account/service/TxnAnalysisService.java b/services/account-service/src/main/java/com/ntropy/account/service/TxnAnalysisService.java index 26099e1..377c915 100644 --- a/services/account-service/src/main/java/com/ntropy/account/service/TxnAnalysisService.java +++ b/services/account-service/src/main/java/com/ntropy/account/service/TxnAnalysisService.java @@ -2,10 +2,11 @@ import java.util.List; +import com.ntropy.account.mapper.FinancialDataQueryMapper; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; -import com.ntropy.account.domain.TxnAnalysis; +import com.ntropy.account.domain.entity.TxnAnalysis; import com.ntropy.account.mapper.TxnAnalysisMapper; import com.ntropy.common.dto.account.ClassificationTargetTransaction; import com.ntropy.common.dto.account.TransactionAnalysisSaveItem; @@ -18,46 +19,26 @@ public class TxnAnalysisService { private final TxnAnalysisMapper txnAnalysisMapper; + private final FinancialDataQueryMapper financialDataQueryMapper; public List findClassificationTargets( Long userId, String yearMonth ) { - validateUserId(userId); - validateYearMonth(yearMonth); - - return txnAnalysisMapper.findClassificationTargets(userId, yearMonth); + return financialDataQueryMapper.findClassificationTargets( + userId, + yearMonth + ); } @Transactional - public void saveTransactionAnalyses(TransactionAnalysisSaveRequest request) { - validateUserId(request.getUserId()); - validateYearMonth(request.getYearMonth()); - - if (request.getResults() == null || request.getResults().isEmpty()) { + public void saveAnalyses(TransactionAnalysisSaveRequest request) { + if (request == null + || request.getAnalyses() == null + || request.getAnalyses().isEmpty()) { return; } - for (TransactionAnalysisSaveItem item : request.getResults()) { - TxnAnalysis txnAnalysis = new TxnAnalysis(); - txnAnalysis.setAccountTransactionId(item.getTransactionId()); - txnAnalysis.setIsConsumption(item.getIsConsumption()); - txnAnalysis.setCategory(item.getCategory()); - txnAnalysis.setExpenseType(item.getExpenseType()); - - txnAnalysisMapper.upsert(txnAnalysis); - } - } - - private void validateUserId(Long userId) { - if (userId == null || userId <= 0) { - throw new IllegalArgumentException("userId는 양수여야 합니다."); - } - } - - private void validateYearMonth(String yearMonth) { - if (yearMonth == null || !yearMonth.matches("\\d{4}-\\d{2}")) { - throw new IllegalArgumentException("yearMonth는 YYYY-MM 형식이어야 합니다."); - } + txnAnalysisMapper.upsertAnalyses(request); } } \ No newline at end of file diff --git a/services/account-service/src/main/resources/db/account-service-schema.sql b/services/account-service/src/main/resources/db/account-service-schema.sql index a76a8c0..ca8eef6 100644 --- a/services/account-service/src/main/resources/db/account-service-schema.sql +++ b/services/account-service/src/main/resources/db/account-service-schema.sql @@ -95,3 +95,22 @@ CREATE TABLE IF NOT EXISTS ACCOUNT_TRANSACTION CONSTRAINT fk_account_transaction_account FOREIGN KEY (account_id) REFERENCES ACCOUNT (account_id) ) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4; + +CREATE TABLE IF NOT EXISTS TXN_ANALYSIS +( + txn_analysis_id BIGINT AUTO_INCREMENT PRIMARY KEY, + account_transaction_id BIGINT NOT NULL, + is_consumption BOOLEAN NOT NULL, + category VARCHAR(32) NULL, + expense_type VARCHAR(16) NULL, + classified_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + + UNIQUE KEY uk_txn_analysis_transaction ( + account_transaction_id + ), + + CONSTRAINT fk_txn_analysis_transaction + FOREIGN KEY (account_transaction_id) + REFERENCES ACCOUNT_TRANSACTION (account_transaction_id) + ) ENGINE = InnoDB + DEFAULT CHARSET = utf8mb4; \ No newline at end of file diff --git a/services/account-service/src/main/resources/mapper/account/FinancialDataQueryMapper.xml b/services/account-service/src/main/resources/mapper/account/FinancialDataQueryMapper.xml index 0495709..1626ec7 100644 --- a/services/account-service/src/main/resources/mapper/account/FinancialDataQueryMapper.xml +++ b/services/account-service/src/main/resources/mapper/account/FinancialDataQueryMapper.xml @@ -93,4 +93,34 @@ LIMIT #{limit} OFFSET #{offset} + diff --git a/services/account-service/src/main/resources/mapper/account/TxnAnalysisMapper.xml b/services/account-service/src/main/resources/mapper/account/TxnAnalysisMapper.xml index 2368567..7200cd2 100644 --- a/services/account-service/src/main/resources/mapper/account/TxnAnalysisMapper.xml +++ b/services/account-service/src/main/resources/mapper/account/TxnAnalysisMapper.xml @@ -34,7 +34,7 @@ AND at.out_amount > 0 - + INSERT INTO TXN_ANALYSIS ( account_transaction_id, is_consumption, @@ -55,4 +55,31 @@ classified_at = NOW() + + + INSERT INTO TXN_ANALYSIS ( + account_transaction_id, + is_consumption, + category, + expense_type, + classified_at + ) + VALUES + + ( + #{item.transactionId}, + #{item.isConsumption}, + #{item.category}, + #{item.expenseType}, + CURRENT_TIMESTAMP + ) + + ON DUPLICATE KEY UPDATE + is_consumption = VALUES(is_consumption), + category = VALUES(category), + expense_type = VALUES(expense_type), + classified_at = CURRENT_TIMESTAMP + + \ No newline at end of file diff --git a/services/account-service/src/test/java/com/ntropy/account/client/LocalAccountQueryClientTest.java b/services/account-service/src/test/java/com/ntropy/account/client/LocalAccountQueryClientTest.java index 8feb18d..4571849 100644 --- a/services/account-service/src/test/java/com/ntropy/account/client/LocalAccountQueryClientTest.java +++ b/services/account-service/src/test/java/com/ntropy/account/client/LocalAccountQueryClientTest.java @@ -9,6 +9,7 @@ import java.util.ArrayList; import java.util.List; +import com.ntropy.common.dto.account.ClassificationTargetTransaction; import org.junit.jupiter.api.Test; import com.ntropy.account.domain.AccountGroup; @@ -231,5 +232,13 @@ public List findTransactionsByAccountIdAndUserId( int to = Math.min(from + limit, rows.size()); return rows.subList(from, to); } + + @Override + public List findClassificationTargets( + Long userId, + String yearMonth + ) { + return List.of(); + } } } 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 index 82a3b8c..1eebe3f 100644 --- 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 @@ -21,23 +21,18 @@ import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; -/** - * 월간 AI 리포트 생성 파이프라인 전체를 조정하는 서비스입니다. - * - * 현재는 대상 사용자 조회 계약이 완전히 연결되지 않아, - * 사용자 목록 조회는 placeholder 상태입니다. - * 다만 사용자 1명 단위의 거래 조회 -> FastAPI 분류 -> account-service 저장 흐름은 연결했습니다. - */ @Slf4j @Service @RequiredArgsConstructor public class MonthlyAiReportOrchestrationService { private final FastApiTransactionClassificationClient classificationClient; - private final AccountTransactionAnalysisClient accountTransactionAnalysisClient; + + private final AccountTransactionAnalysisClient + accountTransactionAnalysisClient; /** - * 매월 1일 실행될 때 지난달 기준으로 배치를 돌립니다. + * 매월 1일 실행되며 지난달 데이터를 처리합니다. */ public void runLastMonthBatch() { YearMonth lastMonth = YearMonth.now( @@ -48,11 +43,15 @@ public void runLastMonthBatch() { } /** - * 지정한 연월의 AI 리포트 배치를 실행합니다. - * - * @param targetYearMonth 배치 대상 연월 + * 지정한 연월의 배치를 실행합니다. */ public void runBatch(YearMonth targetYearMonth) { + if (targetYearMonth == null) { + throw new IllegalArgumentException( + "배치 대상 연월은 필수입니다." + ); + } + String yearMonth = targetYearMonth.toString(); long startedAt = System.currentTimeMillis(); @@ -82,7 +81,8 @@ public void runBatch(YearMonth targetYearMonth) { } } - long elapsedMillis = System.currentTimeMillis() - startedAt; + long elapsedMillis = + System.currentTimeMillis() - startedAt; log.info( "[AI 리포트 배치] 완료 - yearMonth: {}, 대상: {}명, 성공: {}명, 실패: {}명, 실행 시간: {}ms", @@ -95,10 +95,7 @@ public void runBatch(YearMonth targetYearMonth) { } /** - * 배치 대상 사용자 ID 목록을 조회합니다. - * - * 아직 사용자 목록 조회 Client 계약이 완전히 연결되지 않았으므로 - * 현재는 빈 목록을 반환합니다. + * 현재는 사용자 조회 Client가 연결되지 않아 빈 목록을 반환합니다. */ private List findTargetUserIds(String yearMonth) { log.info( @@ -110,28 +107,28 @@ private List findTargetUserIds(String yearMonth) { } /** - * 사용자 1명의 월간 AI 리포트 생성 흐름을 처리합니다. - * - * 순서: - * 1. account-service에서 분류 대상 거래 조회 - * 2. FastAPI 소비 분류 요청 - * 3. account-service에 TXN_ANALYSIS 저장 - * 4. 이후 diagnosis-service / AI_REPORT / 알림 발송은 TODO + * 한 사용자의 거래 조회 및 소비 분류 결과 저장을 처리합니다. */ - private void processSingleUser(Long userId, String yearMonth) { + private void processSingleUser( + Long userId, + String yearMonth + ) { log.info( - "[AI 리포트 배치] 사용자 리포트 처리 시작 - userId: {}, yearMonth: {}", + "[AI 리포트 배치] 사용자 처리 시작 - userId: {}, yearMonth: {}", userId, yearMonth ); - List classificationTargets = - accountTransactionAnalysisClient.findClassificationTargets( - userId, - yearMonth - ); + List + classificationTargets = + accountTransactionAnalysisClient + .findClassificationTargets( + userId, + yearMonth + ); - if (classificationTargets.isEmpty()) { + if (classificationTargets == null + || classificationTargets.isEmpty()) { log.info( "[AI 리포트 배치] 분류 대상 거래 없음 - userId: {}, yearMonth: {}", userId, @@ -151,13 +148,20 @@ private void processSingleUser(Long userId, String yearMonth) { )) .toList(); - TransactionClassificationResponse classificationResponse = - classificationClient.classifyTransactions(transactions); + // FastAPI 소비 분류 요청 + TransactionClassificationResponse + classificationResponse = + classificationClient.classifyTransactions( + transactions + ); if (classificationResponse == null - || !Boolean.TRUE.equals(classificationResponse.getSuccess()) + || !Boolean.TRUE.equals( + classificationResponse.getSuccess() + ) || classificationResponse.getData() == null - || classificationResponse.getData().getResults() == null) { + || classificationResponse.getData() + .getResults() == null) { throw new IllegalStateException( "FastAPI 소비 분류 응답이 올바르지 않습니다." ); @@ -166,28 +170,35 @@ private void processSingleUser(Long userId, String yearMonth) { List results = classificationResponse.getData().getResults(); + // 입력 거래 수와 응답 결과 수 검증 if (results.size() != transactions.size()) { throw new IllegalStateException( "FastAPI 분류 결과 수가 요청 거래 수와 일치하지 않습니다." ); } + // transactionId 1:1 매핑 검증 Set requestedTransactionIds = transactions.stream() - .map(TransactionForClassification::getTransactionId) + .map(TransactionForClassification + ::getTransactionId) .collect(Collectors.toSet()); Set responseTransactionIds = results.stream() - .map(TransactionClassificationResult::getTransactionId) + .map(TransactionClassificationResult + ::getTransactionId) .collect(Collectors.toSet()); - if (!requestedTransactionIds.equals(responseTransactionIds)) { + if (!requestedTransactionIds.equals( + responseTransactionIds + )) { throw new IllegalStateException( "FastAPI 분류 결과의 transactionId가 요청 거래와 일치하지 않습니다." ); } + // account-service 저장용 DTO로 변환 List saveItems = results.stream() .map(result -> new TransactionAnalysisSaveItem( @@ -198,35 +209,33 @@ private void processSingleUser(Long userId, String yearMonth) { )) .toList(); - accountTransactionAnalysisClient.saveTransactionAnalyses( - new TransactionAnalysisSaveRequest( - userId, - yearMonth, - saveItems - ) - ); + // TXN_ANALYSIS upsert 저장 + accountTransactionAnalysisClient + .saveTransactionAnalyses( + new TransactionAnalysisSaveRequest( + userId, + yearMonth, + saveItems + ) + ); log.info( - "[AI 리포트 배치] 거래 분석 결과 저장 완료 - userId: {}, yearMonth: {}, savedCount: {}", + "[AI 리포트 배치] TXN_ANALYSIS 저장 완료 - userId: {}, yearMonth: {}, savedCount: {}", userId, yearMonth, saveItems.size() ); /* - * TODO 4. diagnosis-service에 재무진단 생성 요청 - * - * TODO 5. diagnosis-service에서 DIAGNOSIS_RESULT 조회 - * - * TODO 6. FastAPI에 금융상품 추천 및 리포트 문구 생성 요청 - * - * TODO 7. AI_REPORT 저장 또는 갱신 - * - * TODO 8. 이메일·카카오톡 발송 요청 + * TODO 1. diagnosis-service 진단 재계산 요청 + * TODO 2. DIAGNOSIS_RESULT 조회 + * TODO 3. FastAPI 종합 분석 및 추천 요청 + * TODO 4. AI_REPORT upsert 저장 + * TODO 5. 이메일·카카오톡 알림 발송 */ log.info( - "[AI 리포트 배치] 사용자 리포트 처리 완료 - userId: {}, yearMonth: {}", + "[AI 리포트 배치] 사용자 처리 완료 - userId: {}, yearMonth: {}", userId, yearMonth ); diff --git a/services/bff-service/src/main/java/com/ntropy/bff/controller/ai/AiReportController.java b/services/bff-service/src/main/java/com/ntropy/bff/controller/ai/AiReportController.java index 44d4836..4fdeb94 100644 --- a/services/bff-service/src/main/java/com/ntropy/bff/controller/ai/AiReportController.java +++ b/services/bff-service/src/main/java/com/ntropy/bff/controller/ai/AiReportController.java @@ -33,14 +33,10 @@ public class AiReportController { private final AuthenticatedUserIdResolver authenticatedUserIdResolver; /** - * 특정 월의 AI 리포트를 조회합니다. + * 인증된 사용자의 특정 월 AI 리포트를 조회합니다. * * 요청 예시: - * GET /api/ai-reports/2026-07?userId=1 - * - * @param authentication 인증 정보. 사용자 ID를 추출하는 데 사용합니다. - * @param yearMonth 조회할 리포트 대상 연월. 예: "2026-07" - * @return 공통 응답 형식으로 감싼 AI 리포트 상세 데이터 + * GET /api/ai-reports/2026-07 */ @GetMapping("/{yearMonth}") public ResponseEntity> getAiReport( @@ -69,18 +65,20 @@ public ResponseEntity> getAiReport( } /** - * 특정 사용자의 전체 AI 리포트 목록을 최신 연월순으로 조회합니다. + * 인증된 사용자의 전체 AI 리포트 목록을 최신 연월순으로 조회합니다. * * 요청 예시: - * GET /api/ai-reports?userId=1 + * GET /api/ai-reports * - * @param userId 조회할 사용자 ID - * @return 공통 응답 형식으로 감싼 AI 리포트 목록 데이터 + * JWT의 사용자 ID를 사용하므로 userId 쿼리 파라미터는 받지 않습니다. */ @GetMapping public ResponseEntity> getAiReports( - @RequestParam Long userId + @ApiParam(hidden = true) Authentication authentication ) { + + Long userId = authenticatedUserIdResolver.resolve(authentication); + // BFF는 인터페이스를 통해 ai-service에 전체 AI 리포트 목록을 요청합니다. List summaries = aiReportQueryClient.findAllByUserId(userId);