From 2dfbd75039cf92a5c60cdb7b693130cee8eda586 Mon Sep 17 00:00:00 2001 From: ouob123 Date: Fri, 31 Oct 2025 01:56:41 +0900 Subject: [PATCH 1/2] =?UTF-8?q?refactor:=20=EB=AA=A9=ED=91=9C=20=EC=B1=84?= =?UTF-8?q?=ED=8C=85=20=EB=A6=AC=ED=8C=A9=ED=86=A0=EB=A7=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/finz/controller/CoachController.java | 43 ++++++----- .../com/finz/dto/coach/CoachResponseDto.java | 1 - .../finz/dto/coach/GoalConsultRequest.java | 16 ++++ .../gemini/GeminiApiClient.java | 76 +++++++++++++------ .../java/com/finz/service/CoachService.java | 59 +++++--------- 5 files changed, 109 insertions(+), 86 deletions(-) create mode 100644 src/main/java/com/finz/dto/coach/GoalConsultRequest.java diff --git a/src/main/java/com/finz/controller/CoachController.java b/src/main/java/com/finz/controller/CoachController.java index 6aab351..4084346 100644 --- a/src/main/java/com/finz/controller/CoachController.java +++ b/src/main/java/com/finz/controller/CoachController.java @@ -1,5 +1,7 @@ package com.finz.controller; +import com.finz.dto.GlobalResponseDto; +import com.finz.dto.coach.CoachMessageDto; import com.finz.dto.coach.CoachResponseDto; import com.finz.dto.coach.MessageRequest; import com.finz.service.CoachService; @@ -9,48 +11,49 @@ import lombok.extern.slf4j.Slf4j; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.*; -import com.finz.dto.coach.CoachMessageDto; + import java.util.List; @Slf4j @RestController @RequestMapping("/api/coach") @RequiredArgsConstructor -@Tag(name = "Coach", description = "AI 코치 대화 API") +@Tag(name = "Coach", description = "AI 코치 상담 API") public class CoachController { private final CoachService coachService; - // 1인 사용자용 고정 ID - private static final Long DEFAULT_USER_ID = 1L; - - // 빠른 제안: 목표 설정 대화 시작 - @PostMapping("/start-goal-setting") - @Operation(summary = "목표 설정 대화 시작", description = "AI 코치가 사용자의 정보를 바탕으로 목표 설정 대화를 시작합니다.") - public ResponseEntity startGoalSetting() { - log.info("목표 설정 시작 요청"); + // 목표 상담 요청 + @PostMapping("/goal-consult/{userId}") + @Operation(summary = "목표 상담 요청", description = "AI 코치와 목표 설정 대화를 시작합니다.") + public ResponseEntity> startGoalConsult( + @PathVariable Long userId) { + log.info("목표 상담 요청 - userId: {}", userId); - CoachResponseDto response = coachService.startGoalSettingConversation(DEFAULT_USER_ID); - return ResponseEntity.ok(response); + return ResponseEntity.ok( + coachService.requestGoalConsult(userId) + ); } // 메시지 전송 (대화 진행) - @PostMapping("/message") + @PostMapping("/message/{userId}") @Operation(summary = "메시지 전송", description = "사용자 메시지를 전송하고 AI 코치의 응답을 받습니다.") - public ResponseEntity sendMessage(@RequestBody MessageRequest request) { - log.info("메시지 전송 - type: {}", request.getMessageType()); + public ResponseEntity sendMessage( + @PathVariable Long userId, + @RequestBody MessageRequest request) { + log.info("메시지 전송 - userId: {}, type: {}", userId, request.getMessageType()); - CoachResponseDto response = coachService.generateResponse(DEFAULT_USER_ID, request); + CoachResponseDto response = coachService.generateResponse(userId, request); return ResponseEntity.ok(response); } // 그간의 대화 내역 조회 - @GetMapping("/history") + @GetMapping("/history/{userId}") @Operation(summary = "대화 내역 조회", description = "AI 코치와의 전체 대화 내역을 조회합니다.") - public ResponseEntity> getChatHistory() { - log.info("대화 내역 조회 요청 - userId: {}", DEFAULT_USER_ID); + public ResponseEntity> getChatHistory(@PathVariable Long userId) { + log.info("대화 내역 조회 요청 - userId: {}", userId); - List history = coachService.getChatHistory(DEFAULT_USER_ID); + List history = coachService.getChatHistory(userId); return ResponseEntity.ok(history); } } diff --git a/src/main/java/com/finz/dto/coach/CoachResponseDto.java b/src/main/java/com/finz/dto/coach/CoachResponseDto.java index de502d5..f001756 100644 --- a/src/main/java/com/finz/dto/coach/CoachResponseDto.java +++ b/src/main/java/com/finz/dto/coach/CoachResponseDto.java @@ -13,5 +13,4 @@ public class CoachResponseDto { private String message; private MessageType messageType; - private GoalSuggestion suggestedGoal; // nullable } diff --git a/src/main/java/com/finz/dto/coach/GoalConsultRequest.java b/src/main/java/com/finz/dto/coach/GoalConsultRequest.java new file mode 100644 index 0000000..5c815b2 --- /dev/null +++ b/src/main/java/com/finz/dto/coach/GoalConsultRequest.java @@ -0,0 +1,16 @@ +package com.finz.dto.coach; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@NoArgsConstructor +@AllArgsConstructor +@Schema(description = "목표 상담 요청") +public class GoalConsultRequest { + + @Schema(description = "사용자 ID", example = "1") + private Long userId; +} diff --git a/src/main/java/com/finz/infrastructure/gemini/GeminiApiClient.java b/src/main/java/com/finz/infrastructure/gemini/GeminiApiClient.java index e5fd293..b823c12 100644 --- a/src/main/java/com/finz/infrastructure/gemini/GeminiApiClient.java +++ b/src/main/java/com/finz/infrastructure/gemini/GeminiApiClient.java @@ -76,7 +76,7 @@ public String chat(String systemPrompt, List history, String user return callGeminiApi(contents); } - // Gemini API 호출 + // Gemini API 호출 (재시도 로직 포함) private String callGeminiApi(List contents) { String url = apiUrl + "?key=" + apiKey; @@ -90,31 +90,59 @@ private String callGeminiApi(List contents) { HttpEntity entity = new HttpEntity<>(request, headers); - try { - log.debug("Gemini API 호출 - URL: {}", url); - - GeminiResponse response = restTemplate.postForObject( - url, - entity, - GeminiResponse.class - ); - - if (response != null && !response.getCandidates().isEmpty()) { - String text = response.getCandidates().get(0) - .getContent() - .getParts() - .get(0) - .getText(); + // 최대 3번 재시도 + int maxRetries = 3; + int retryCount = 0; + + while (retryCount < maxRetries) { + try { + log.debug("Gemini API 호출 시도 {}/{} - URL: {}", retryCount + 1, maxRetries, url); + + GeminiResponse response = restTemplate.postForObject( + url, + entity, + GeminiResponse.class + ); + + if (response != null && !response.getCandidates().isEmpty()) { + String text = response.getCandidates().get(0) + .getContent() + .getParts() + .get(0) + .getText(); + + log.debug("Gemini API 응답 성공"); + return text; + } + + throw new RuntimeException("Gemini API 응답이 비어있습니다."); - log.debug("Gemini API 응답: {}", text); - return text; + } catch (Exception e) { + retryCount++; + + // 503 에러인 경우 + if (e.getMessage() != null && e.getMessage().contains("503")) { + if (retryCount < maxRetries) { + log.warn("Gemini API 과부하 (503) - {}초 후 재시도 {}/{}", + retryCount * 2, retryCount, maxRetries); + try { + Thread.sleep(retryCount * 2000L); // 2초, 4초, 6초 + } catch (InterruptedException ie) { + Thread.currentThread().interrupt(); + } + continue; + } else { + log.error("Gemini API 재시도 초과 - 최대 {}번 시도 완료", maxRetries); + throw new RuntimeException("AI 서비스가 현재 사용량이 많습니다. 잠시 후 다시 시도해주세요."); + } + } + + // 다른 에러는 즉시 throw + log.error("Gemini API 호출 실패", e); + throw new RuntimeException("AI 응답 생성 중 오류가 발생했습니다: " + e.getMessage()); } - - throw new RuntimeException("Gemini API 응답이 비어있습니다."); - - } catch (Exception e) { - log.error("Gemini API 호출 실패", e); - throw new RuntimeException("AI 응답 생성 중 오류가 발생했습니다: " + e.getMessage()); } + + throw new RuntimeException("AI 서비스가 현재 사용량이 많습니다. 잠시 후 다시 시도해주세요."); } } diff --git a/src/main/java/com/finz/service/CoachService.java b/src/main/java/com/finz/service/CoachService.java index 576dc5c..f3800ed 100644 --- a/src/main/java/com/finz/service/CoachService.java +++ b/src/main/java/com/finz/service/CoachService.java @@ -9,6 +9,7 @@ import com.finz.domain.user.User; import com.finz.domain.user.UserRepository; import com.finz.dto.coach.*; +import com.finz.dto.GlobalResponseDto; import com.finz.infrastructure.gemini.GeminiApiClient; import com.finz.infrastructure.gemini.dto.GeminiMessage; import lombok.RequiredArgsConstructor; @@ -19,8 +20,6 @@ import java.time.LocalDate; import java.util.Collections; import java.util.List; -import java.util.regex.Matcher; -import java.util.regex.Pattern; import java.util.stream.Collectors; @Slf4j @@ -34,6 +33,21 @@ public class CoachService { private final ExpenseRepository expenseRepository; private final GeminiApiClient geminiClient; + // 목표 상담 요청 (Controller용) + @Transactional + public GlobalResponseDto requestGoalConsult(Long userId) { + log.info("목표 상담 요청 - userId: {}", userId); + + CoachResponseDto data = startGoalSettingConversation(userId); + + return GlobalResponseDto.builder() + .status(200) + .success(true) + .message("목표 상담이 시작되었습니다.") + .data(data) + .build(); + } + @Transactional(readOnly = true) // 데이터 변경이 없는 조회 작업 public List getChatHistory(Long userId) { log.info("대화 내역 조회 - userId: {}", userId); @@ -143,16 +157,9 @@ public CoachResponseDto generateResponse(Long userId, MessageRequest request) { messageRepository.save(aiMsg); - // 7. 목표 제안 추출 - GoalSuggestion suggestion = null; - if (request.getMessageType() == MessageType.GOAL_SETTING) { - suggestion = extractGoalFromResponse(aiResponse); - } - return CoachResponseDto.builder() .message(aiResponse) .messageType(request.getMessageType()) - .suggestedGoal(suggestion) .build(); } @@ -160,7 +167,7 @@ public CoachResponseDto generateResponse(Long userId, MessageRequest request) { private String buildGoalSettingPrompt(User user, List goals, List expenses) { StringBuilder prompt = new StringBuilder(); - prompt.append("당신은 FiNZ의 친근한 AI 재무 코치입니다.\n"); + prompt.append("당신은 Finz의 친근한 AI 재무 코치입니다.\n"); prompt.append("사용자가 '목표 설정' 버튼을 눌러서 대화를 시작했습니다.\n\n"); // 사용자 개인 정보 포함 @@ -232,7 +239,7 @@ private String buildGoalSettingPrompt(User user, List goals, List goals, List expenses) { StringBuilder prompt = new StringBuilder(); - prompt.append("당신은 FiNZ의 친근한 AI 재무 코치입니다.\n\n"); + prompt.append("당신은 Finz의 친근한 AI 재무 코치입니다.\n\n"); prompt.append("## 사용자 정보\n"); prompt.append(String.format("- 이름: %s\n", user.getNickname())); @@ -255,34 +262,4 @@ private List convertToGeminiFormat(List messages) { .build()) .collect(Collectors.toList()); } - - // AI 응답에서 목표 정보 추출 - private GoalSuggestion extractGoalFromResponse(String response) { - Pattern amountPattern = Pattern.compile("(\\d{1,3}(?:,\\d{3})*|\\d+)\\s*(?:만\\s*)?원"); - Matcher matcher = amountPattern.matcher(response); - - if (matcher.find()) { - String amountStr = matcher.group(1).replace(",", ""); - int amount = Integer.parseInt(amountStr); - - if (response.contains("만원") || response.contains("만 원")) { - amount *= 10000; - } - - String goalType = "저축"; - if (response.contains("줄이") || response.contains("절약")) { - goalType = "지출 줄이기"; - } else if (response.contains("투자")) { - goalType = "투자"; - } - - return GoalSuggestion.builder() - .goalType(goalType) - .targetAmount(amount) - .durationMonths(1) - .build(); - } - - return null; - } } From 2b3682b45e9915b5a83b70f6df3c3c4a4ccb346f Mon Sep 17 00:00:00 2001 From: ouob123 Date: Fri, 31 Oct 2025 02:18:48 +0900 Subject: [PATCH 2/2] =?UTF-8?q?bug:=20ci=20=EC=97=90=EB=9F=AC=20=ED=95=B4?= =?UTF-8?q?=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/com/finz/service/CoachService.java | 139 +++++++++++++++++- 1 file changed, 138 insertions(+), 1 deletion(-) diff --git a/src/main/java/com/finz/service/CoachService.java b/src/main/java/com/finz/service/CoachService.java index 01940de..06614c1 100644 --- a/src/main/java/com/finz/service/CoachService.java +++ b/src/main/java/com/finz/service/CoachService.java @@ -35,7 +35,7 @@ public class CoachService { private final ExpenseRepository expenseRepository; private final GeminiApiClient geminiClient; - // 목표 상담 요청 (Controller용) + // 목표 상담 요청 @Transactional public GlobalResponseDto requestGoalConsult(Long userId) { log.info("목표 상담 요청 - userId: {}", userId); @@ -264,4 +264,141 @@ private List convertToGeminiFormat(List messages) { .build()) .collect(Collectors.toList()); } + + @Transactional + public void processNewExpenseRecord(Long userId, Expense expense) { + + log.info("[User: {}] 신규 지출 기록 처리 시작 - ExpenseId: {}", userId, expense.getId()); + + // 1. 지출 내역을 "USER" 메시지로 변환하여 DB 저장 + String userContent = String.format( + "[지출 기록 📝] %s | %s | %,d원 (태그: #%s)", + expense.getCategory().getDescription(), + expense.getExpenseName(), + expense.getAmount(), + expense.getExpenseTag() != null ? expense.getExpenseTag() : "없음" + ); + + CoachMessage userMsg = CoachMessage.builder() + .userId(userId) + .sender(MessageSender.USER) + .messageType(MessageType.EXPENSE_RECORD) + .content(userContent) + .build(); + messageRepository.save(userMsg); + + // 2. AI 피드백 생성을 위한 컨텍스트(사용자) 수집 + User user = userRepository.findById(userId) + .orElseThrow(() -> new IllegalArgumentException("사용자를 찾을 수 없습니다.")); + + // --- (컨텍스트 수집 고도화) --- + // 3. "이번 달"의 시작일 계산 + LocalDate startOfMonth = expense.getExpenseDate().withDayOfMonth(1); + + // 4. "이번 달"의 총 지출액 및 남은 예산 계산 + Integer totalSpentThisMonth = expenseRepository.findTotalAmountByUserIdAndDateAfter(userId, startOfMonth); + Integer remainingBudget = user.getMonthlyBudget() - totalSpentThisMonth; + + // 5. (핵심) 태그 기반 심층 분석 + String currentTag = expense.getExpenseTag(); + TagExpenseSummary tagSummary = null; // 기본값 null + + if (currentTag != null && !currentTag.isEmpty()) { + // 이번 달에 이 태그를 몇 번 썼는지, 총 얼마 썼는지 조회 + tagSummary = expenseRepository.findTagSummaryByUserIdAndTagAfter( + userId, + currentTag, + startOfMonth + ); + log.info("[User: {}] 태그 '#{}' 분석: {}회 / {}원", userId, currentTag, tagSummary.getCount(), tagSummary.getTotalAmount()); + } + // --- (고도화 끝) --- + + // 6. 지출 피드백 전용 시스템 프롬프트 생성 (모든 정보 전달) + String systemPrompt = buildExpenseFeedbackPrompt( + user, + expense, + totalSpentThisMonth, + remainingBudget, + tagSummary // 5번에서 조회한 태그 정보 (null일 수 있음) + ); + + // 7. Gemini API 호출 + String aiResponse = geminiClient.chat( + systemPrompt, + Collections.emptyList(), + userContent // (chat 메서드 형식을 맞추기 위해 전달) + ); + + // 8. AI 응답 DB 저장 + CoachMessage aiMsg = CoachMessage.builder() + .userId(userId) + .sender(MessageSender.AI) + .messageType(MessageType.EXPENSE_RECORD) + .content(aiResponse) + .build(); + messageRepository.save(aiMsg); + + log.info("[User: {}] 지출 기록 피드백 생성 완료 - MessageId: {}", userId, aiMsg.getMessageId()); + } + + + private String buildExpenseFeedbackPrompt( + User user, + Expense expense, + Integer totalSpentThisMonth, + Integer remainingBudget, + TagExpenseSummary tagSummary // <-- 파라미터 추가 + ) { + StringBuilder prompt = new StringBuilder(); + + prompt.append("당신은 FiNZ의 긍정적이고 격려하는 AI 재무 코치입니다.\n"); + prompt.append("사용자가 방금 앱에 지출 내역을 기록했으며, 당신은 이 지출에 대해 **즉각적이고 짧은 피드백**을 제공해야 합니다.\n\n"); + + prompt.append("## 1. 사용자 정보\n"); + prompt.append(String.format("- 이름: %s\n", user.getNickname())); + prompt.append(String.format("- 월 목표 예산: %,d원\n\n", user.getMonthlyBudget())); + + prompt.append("## 2. 방금 기록된 지출 (분석 대상)\n"); + prompt.append(String.format("- 카테고리: %s\n", expense.getCategory().getDescription())); + prompt.append(String.format("- 금액: %,d원\n", expense.getAmount())); + prompt.append(String.format("- 내용: %s\n", expense.getExpenseName())); + if (expense.getExpenseTag() != null && !expense.getExpenseTag().isEmpty()) { + prompt.append(String.format("- 태그: #%s\n", expense.getExpenseTag())); + } + prompt.append("\n"); + + prompt.append("## 3. 현재 재무 상태 (중요 맥락)\n"); + prompt.append(String.format("- 이번 달 총 지출액: %,d원\n", totalSpentThisMonth)); + prompt.append(String.format("- 남은 예산: %,d원\n\n", remainingBudget)); + + // --- (핵심 수정) --- + prompt.append("## 4. 태그 심층 분석 (Contextual Insight)\n"); + if (tagSummary != null) { + prompt.append(String.format("- 사용자는 '#%s' 태그를 이번 달에 %d회 사용했습니다.\n", + expense.getExpenseTag(), tagSummary.getCount())); + prompt.append(String.format("- 이 태그로만 총 %,d원을 지출했습니다.\n\n", + tagSummary.getTotalAmount())); + } else { + prompt.append("- 이 지출에는 태그가 없습니다.\n\n"); + } + // --- (수정 끝) --- + + prompt.append("## 5. 당신의 임무 (매우 중요)\n"); + prompt.append("당신은 **두 부분**으로 구성된 **매우 짧은** 피드백을 생성해야 합니다.\n"); + prompt.append("1. **(코멘트)**: '방금 기록된 지출(2번)'에 대해 1~2문장으로 긍정적/중립적 코멘트를 하세요.\n"); + prompt.append("2. **(브리핑)**: '현재 재무 상태(3번)'와 **특히 '태그 분석(4번)'**을 결합하여 **남은 예산**과 **태그 사용 현황**을 간결하게 브리핑하세요.\n\n"); + + prompt.append("## 6. 말투 및 제약사항\n"); + prompt.append("- **절대 비난 금지.** (나쁜 예: '또 돈을 쓰셨네요.')\n"); + prompt.append("- 긍정적/격려하는 톤, 친근한 존댓말, 이모지 1~2개 사용.\n"); + prompt.append("- **반드시 한두 문장으로 매우 짧게** 요약하세요.\n"); + prompt.append(String.format("- UI 예시 (태그 O): '기분 전환 간식이군요! 🧁 이번 달 '#스트레스' 태그로 %s번째 지출이네요. 남은 예산은 %,d원입니다! 🔥'\n", + (tagSummary != null ? tagSummary.getCount() : 1), remainingBudget)); // 예시도 동적으로 + prompt.append(String.format("- UI 예시 (태그 X): '기록 완료! 꼼꼼하시네요 👍. 남은 예산은 %,d원입니다!'\n\n", remainingBudget)); + + prompt.append("위 모든 정보를 바탕으로, 사용자의 방금 지출(2번)에 대한 '코멘트'와 '브리핑'을 포함한 피드백을 작성하세요:"); + + return prompt.toString(); + } }