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 0d618b5..06614c1 100644 --- a/src/main/java/com/finz/service/CoachService.java +++ b/src/main/java/com/finz/service/CoachService.java @@ -10,6 +10,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; @@ -21,8 +22,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 @@ -36,6 +35,21 @@ public class CoachService { private final ExpenseRepository expenseRepository; private final GeminiApiClient geminiClient; + // 목표 상담 요청 + @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); @@ -144,25 +158,18 @@ public CoachResponseDto generateResponse(Long userId, MessageRequest request) { .build(); 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(); } // 개인화된 목표 설정 시스템 프롬프트 생성 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"); // 사용자 개인 정보 포함 @@ -233,9 +240,9 @@ 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())); prompt.append(String.format("- 연령대: %s\n", user.getAgeGroup().getDescription())); @@ -258,36 +265,6 @@ private List convertToGeminiFormat(List messages) { .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; - } - @Transactional public void processNewExpenseRecord(Long userId, Expense expense) {