diff --git a/src/main/java/org/umc/valuedi/domain/mbti/service/FinanceMbtiService.java b/src/main/java/org/umc/valuedi/domain/mbti/service/FinanceMbtiService.java index b675c65e..50bc1c1c 100644 --- a/src/main/java/org/umc/valuedi/domain/mbti/service/FinanceMbtiService.java +++ b/src/main/java/org/umc/valuedi/domain/mbti/service/FinanceMbtiService.java @@ -1,6 +1,7 @@ package org.umc.valuedi.domain.mbti.service; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import org.umc.valuedi.domain.mbti.converter.FinanceMbtiTestConverter; @@ -12,11 +13,13 @@ import org.umc.valuedi.domain.mbti.validator.FinanceMbtiTestValidator; import org.umc.valuedi.domain.member.entity.Member; import org.umc.valuedi.domain.member.repository.MemberRepository; +import org.umc.valuedi.domain.savings.service.RecommendationService; import java.util.List; import java.util.Map; import java.util.stream.Collectors; +@Slf4j @Service @RequiredArgsConstructor @Transactional @@ -28,6 +31,7 @@ public class FinanceMbtiService { private final FinanceMbtiScoringService scoringService; private final FinanceMbtiTestValidator financeMbtiTestValidator; private final FinanceMbtiTestConverter financeMbtiTestConverter; + private final RecommendationService recommendationService; public MemberMbtiTest submitTest(Long memberId, FinanceMbtiTestRequestDto req) { @@ -46,9 +50,17 @@ public MemberMbtiTest submitTest(Long memberId, FinanceMbtiTestRequestDto req) { )); FinanceMbtiScoringService.ScoreResult score = scoringService.score(activeQuestions, answersByQuestionId); - MemberMbtiTest test = financeMbtiTestConverter.toEntity(member, req, score, activeQuestionMap); - return memberMbtiTestRepository.save(test); + MemberMbtiTest savedTest = memberMbtiTestRepository.save(test); + + try { + recommendationService.generateAndSaveRecommendations(memberId); + log.info("[Recommend] MBTI 검사 후 자동 추천 생성 성공. memberId={}", memberId); + } catch (Exception e) { + // 제미나이 호출이 실패해도 MBTI 저장은 유지되도록 예외를 삼키고 로그만 남김 + log.error("[Recommend] MBTI 저장에는 성공했으나, 자동 추천 생성 중 오류 발생. memberId={}: {}", memberId, e.getMessage()); + } + return savedTest; } } diff --git a/src/main/java/org/umc/valuedi/domain/savings/controller/RecommendationController.java b/src/main/java/org/umc/valuedi/domain/savings/controller/RecommendationController.java index 8f30b05a..57e1e2ef 100644 --- a/src/main/java/org/umc/valuedi/domain/savings/controller/RecommendationController.java +++ b/src/main/java/org/umc/valuedi/domain/savings/controller/RecommendationController.java @@ -3,11 +3,7 @@ import lombok.RequiredArgsConstructor; import org.springframework.web.bind.annotation.*; import org.umc.valuedi.domain.savings.dto.response.SavingsResponseDTO; -import org.umc.valuedi.domain.savings.entity.RecommendationBatch; -import org.umc.valuedi.domain.savings.enums.RecommendationStatus; -import org.umc.valuedi.domain.savings.service.RecommendationAsyncWorker; import org.umc.valuedi.domain.savings.service.RecommendationService; -import org.umc.valuedi.domain.savings.service.RecommendationTxService; import org.umc.valuedi.global.apiPayload.ApiResponse; import org.umc.valuedi.global.apiPayload.code.GeneralSuccessCode; import org.umc.valuedi.global.security.annotation.CurrentMember; @@ -18,28 +14,14 @@ public class RecommendationController implements RecommendationControllerDocs { private final RecommendationService recommendationService; - private final RecommendationTxService recommendationTxService; - private final RecommendationAsyncWorker recommendationAsyncWorker; - // 추천 생성 트리거(비동기) + // 추천 생성 @PostMapping - public ApiResponse recommend( + public ApiResponse recommend( @CurrentMember Long memberId ) { - SavingsResponseDTO.TriggerDecision triggerDecision = recommendationTxService.triggerRecommendation(memberId); - - // 진행 중이면 새로 실행하지 않음 - if (triggerDecision.shouldStartAsync()) { - recommendationAsyncWorker.generateAndSaveAsync(memberId, triggerDecision.batchId()); - } - - return ApiResponse.onSuccess(GeneralSuccessCode.ACCEPTED, - SavingsResponseDTO.TriggerResponse.builder() - .batchId(triggerDecision.batchId()) - .status(triggerDecision.status()) - .message(triggerDecision.message()) - .build() - ); + SavingsResponseDTO.SavingsListResponse result = recommendationService.generateAndSaveRecommendations(memberId); + return ApiResponse.onSuccess(GeneralSuccessCode.OK, result); } // 최신 추천 15개 조회 diff --git a/src/main/java/org/umc/valuedi/domain/savings/controller/RecommendationControllerDocs.java b/src/main/java/org/umc/valuedi/domain/savings/controller/RecommendationControllerDocs.java index 866ad3e9..79a1caf5 100644 --- a/src/main/java/org/umc/valuedi/domain/savings/controller/RecommendationControllerDocs.java +++ b/src/main/java/org/umc/valuedi/domain/savings/controller/RecommendationControllerDocs.java @@ -16,68 +16,42 @@ public interface RecommendationControllerDocs { @Operation( - summary = "적금 추천 생성 트리거 API (비동기)", + summary = "[개발/수동 갱신용] 적금 추천 생성 API", description = """ - 로그인 사용자(JWT)의 금융 MBTI 결과를 바탕으로, Gemini 추천 생성 작업을 비동기로 트리거 + **주의: 일반적인 서비스 흐름에서는 MBTI 검사 완료 시 서버 내부에서 자동 호출됩니다.** - - 최초 요청 시: recommendation_batch를 PENDING으로 생성하고 202(ACCEPTED) 반환 - - 이미 추천 생성 중(PENDING/PROCESSING)인 경우: 기존 batchId를 반환하며 재실행하지 않음 - - 이미 최신 추천이 존재(SUCCESS)하고 MBTI 테스트가 동일한 경우: 재생성하지 않고 안내 메시지 반환 - - 추천 결과는 조회 API(GET)로 확인 + - 로그인 사용자(JWT)의 현재 MBTI를 바탕으로 Gemini 추천을 생성하고 DB를 갱신합니다. + - 응답 속도는 Gemini API 호출을 포함하므로 약 3~7초 정도 소요될 수 있습니다 """, responses = { @io.swagger.v3.oas.annotations.responses.ApiResponse( - responseCode = "COMMON202", - description = "요청이 접수되었습니다.", + responseCode = "COMMON200", + description = "추천 상품 생성 및 저장 성공", content = @Content( mediaType = "application/json", schema = @Schema(implementation = ApiResponse.class), examples = { @ExampleObject( - name = "pending", - summary = "신규 트리거(PENDING)", - value = """ - { - "isSuccess": true, - "code": "COMMON202", - "message": "요청이 접수되었습니다.", - "result": { - "batchId": 12, - "status": "PENDING", - "message": "추천 상품을 생성 중입니다. 잠시 후 조회 API로 확인해 주세요." - } - } - """ - ), - @ExampleObject( - name = "alreadyProcessing", - summary = "이미 생성 중(PENDING/PROCESSING)", - value = """ - { - "isSuccess": true, - "code": "COMMON202", - "message": "요청이 접수되었습니다.", - "result": { - "batchId": 12, - "status": "PROCESSING", - "message": "추천을 생성 중입니다. 잠시 후 조회 API로 확인해 주세요." - } - } - """ - ), - @ExampleObject( - name = "alreadySuccess", - summary = "이미 최신 추천 존재(SUCCESS)", + name = "success", + summary = "추천 생성 성공 예시", value = """ { "isSuccess": true, - "code": "COMMON202", - "message": "요청이 접수되었습니다.", + "code": "COMMON200", + "message": "요청이 성공적으로 처리되었습니다.", "result": { - "batchId": 10, - "status": "SUCCESS", - "message": "이미 최신 추천이 존재합니다. 조회 API로 확인해 주세요." + "totalCount": 15, + "maxPageNo": 1, + "nowPageNo": 1, + "products": [ + { + "korCoNm": "OO은행", + "finPrdtCd": "ABC123", + "finPrdtNm": "OO정기적금", + "rsrvType": "S", + "rsrvTypeNm": "정액적립식" + } + ] } } """ @@ -86,50 +60,26 @@ public interface RecommendationControllerDocs { ) ), @io.swagger.v3.oas.annotations.responses.ApiResponse( - responseCode = "MBTI404_3", - description = "해당 MBTI 유형 정보가 존재하지 않습니다.", + responseCode = "SAVINGS500_1", + description = "AI 추천 생성 실패 (Gemini 호출 실패 등)", content = @Content( mediaType = "application/json", - examples = { - @ExampleObject( - name = "typeInfoNotFound", - summary = "MBTI 타입 정보 미존재", - value = """ - { - "isSuccess": false, - "code": "MBTI404_3", - "message": "해당 MBTI 유형 정보가 존재하지 않습니다.", - "result": null - } - """ - ) - } - ) - ), - @io.swagger.v3.oas.annotations.responses.ApiResponse( - responseCode = "GEMINI502_2", - description = "Gemini API 호출에 실패했습니다.", - content = @Content( - mediaType = "application/json", - examples = { - @ExampleObject( - name = "geminiFail", - summary = "Gemini 호출 실패", - value = """ - { - "isSuccess": false, - "code": "GEMINI502_2", - "message": "Gemini API 호출에 실패했습니다.", - "result": null - } - """ - ) - } + examples = @ExampleObject( + name = "fail", + value = """ + { + "isSuccess": false, + "code": "SAVINGS500_1", + "message": "AI 추천 생성에 실패했습니다. 다시 시도해 주세요.", + "result": null + } + """ + ) ) ) } ) - ApiResponse recommend( + ApiResponse recommend( @CurrentMember Long memberId ); @@ -138,9 +88,6 @@ ApiResponse recommend( description = """ 로그인 사용자(JWT)의 '현재 활성 MBTI 테스트' 기준으로 DB에 저장된 최신 추천 15개를 조회 (Gemini 호출 X) - - - 추천이 아직 생성되지 않았으면 status=PENDING, message에 안내 문구 포함 - - 추천 생성에 실패한 경우 status=FAILED, message에 실패 안내 포함 """, responses = { @io.swagger.v3.oas.annotations.responses.ApiResponse( @@ -170,68 +117,19 @@ ApiResponse recommend( "rsrvType": "S", "rsrvTypeNm": "정액적립식" } - ], - "status": "SUCCESS", - "message": null - } - } - """ - ), - @ExampleObject( - name = "pending", - summary = "추천 생성 중(PENDING)", - value = """ - { - "isSuccess": true, - "code": "COMMON200", - "message": "요청이 성공적으로 처리되었습니다.", - "result": { - "totalCount": 0, - "maxPageNo": 1, - "nowPageNo": 1, - "products": [], - "status": "PENDING", - "message": "추천 상품을 추천 중입니다. 잠시 후 다시 조회해 주세요." + ] } } """ ), @ExampleObject( - name = "noResultForType", - summary = "해당 적립유형 결과 없음(필터로 인해 비어있음)", - value = """ - { - "isSuccess": true, - "code": "COMMON200", - "message": "요청이 성공적으로 처리되었습니다.", - "result": { - "totalCount": 0, - "maxPageNo": 1, - "nowPageNo": 1, - "products": [], - "status": "SUCCESS", - "message": "조건에 맞는 추천 결과가 없습니다." - } - } - """ - ) - } - ) - ), - @io.swagger.v3.oas.annotations.responses.ApiResponse( - responseCode = "MBTI404_3", - description = "해당 MBTI 유형 정보가 존재하지 않습니다.", - content = @Content( - mediaType = "application/json", - examples = { - @ExampleObject( - name = "typeInfoNotFound", - summary = "MBTI 타입 정보 미존재", + name = "noHistory", + summary = "추천 내역 없음 (MBTI 검사 미실행 등)", value = """ { "isSuccess": false, - "code": "MBTI404_3", - "message": "해당 MBTI 유형 정보가 존재하지 않습니다.", + "code": "SAVINGS404_2", + "message": "아직 추천받은 내역이 없습니다. 먼저 상품 추천을 진행해 주세요.", "result": null } """ @@ -255,9 +153,6 @@ ApiResponse latest15( description = """ 로그인 사용자(JWT)의 '현재 활성 MBTI 테스트' 기준으로 DB에 저장된 최신 추천 Top3를 조회 (Gemini 호출 X) - - - 추천이 아직 생성되지 않았으면 status=PENDING, message에 안내 문구 포함 - - 추천 생성에 실패한 경우 status=FAILED, message에 실패 안내 포함 """, responses = { @io.swagger.v3.oas.annotations.responses.ApiResponse( @@ -287,29 +182,20 @@ ApiResponse latest15( "rsrvType": "S", "rsrvTypeNm": "정액적립식" } - ], - "status": "SUCCESS", - "message": null + ] } } """ ), @ExampleObject( - name = "pending", - summary = "추천 생성 중(PENDING)", + name = "noHistory", + summary = "추천 내역 없음 (SAVINGS404_2)", value = """ { - "isSuccess": true, - "code": "COMMON200", - "message": "요청이 성공적으로 처리되었습니다.", - "result": { - "totalCount": 0, - "maxPageNo": 1, - "nowPageNo": 1, - "products": [], - "status": "PENDING", - "message": "추천 상품을 추천 중입니다. 잠시 후 다시 조회해 주세요." - } + "isSuccess": false, + "code": "SAVINGS404_2", + "message": "아직 추천받은 내역이 없습니다. 먼저 상품 추천을 진행해 주세요.", + "result": null } """ ) @@ -382,7 +268,6 @@ ApiResponse latestTop3( examples = { @ExampleObject( name = "notFound", - summary = "상품 미존재 예시", value = """ { "isSuccess": false, diff --git a/src/main/java/org/umc/valuedi/domain/savings/converter/SavingsConverter.java b/src/main/java/org/umc/valuedi/domain/savings/converter/SavingsConverter.java index cc26df79..575abbd4 100644 --- a/src/main/java/org/umc/valuedi/domain/savings/converter/SavingsConverter.java +++ b/src/main/java/org/umc/valuedi/domain/savings/converter/SavingsConverter.java @@ -89,8 +89,8 @@ public static SavingsResponseDTO.SavingsListResponse toSavingsListResponseDTO(Li return SavingsResponseDTO.SavingsListResponse.builder() .totalCount(totalCount) - .maxPageNo(maxPageNo) .nowPageNo(nowPageNo) + .hasNext(nowPageNo < maxPageNo) .products(products) .build(); } @@ -109,22 +109,31 @@ public static SavingsResponseDTO.SavingsDetailResponse toSavingsDetailResponseDT )) .toList(); - SavingsResponseDTO.SavingsDetailResponse.SavingProductDetail product = new SavingsResponseDTO.SavingsDetailResponse.SavingProductDetail( - savings.getKorCoNm(), - savings.getFinPrdtCd(), - savings.getFinPrdtNm(), - savings.getJoinWay(), - savings.getMtrtInt(), - savings.getSpclCnd(), - savings.getJoinDeny(), - savings.getJoinMember(), - savings.getEtcNote(), - savings.getMaxLimit() == null ? null : String.valueOf(savings.getMaxLimit()), - options - ); + SavingsOption representativeOption = savings.getSavingsOptionList().stream() + .filter(o -> Integer.valueOf(12).equals(o.getSaveTrm())) + .findFirst() + .orElseGet(() -> savings.getSavingsOptionList().stream() + .filter(o -> o.getIntrRate2() != null) + .max(java.util.Comparator.comparing(SavingsOption::getIntrRate2)) + .orElse(null)); + + Double basicRate = (representativeOption != null) ? representativeOption.getIntrRate() : null; + Double maxRate = (representativeOption != null) ? representativeOption.getIntrRate2() : null; return SavingsResponseDTO.SavingsDetailResponse.builder() - .product(product) + .korCoNm(savings.getKorCoNm()) + .finPrdtCd(savings.getFinPrdtCd()) + .finPrdtNm(savings.getFinPrdtNm()) + .basicRate(basicRate) + .maxRate(maxRate) + .joinWay(savings.getJoinWay()) + .mtrtInt(savings.getMtrtInt()) + .spclCnd(savings.getSpclCnd()) + .joinDeny(savings.getJoinDeny()) + .joinMember(savings.getJoinMember()) + .etcNote(savings.getEtcNote()) + .maxLimit(savings.getMaxLimit() == null ? null : String.valueOf(savings.getMaxLimit())) + .options(options) .build(); } diff --git a/src/main/java/org/umc/valuedi/domain/savings/dto/response/SavingsResponseDTO.java b/src/main/java/org/umc/valuedi/domain/savings/dto/response/SavingsResponseDTO.java index 0f753bec..e4048b4f 100644 --- a/src/main/java/org/umc/valuedi/domain/savings/dto/response/SavingsResponseDTO.java +++ b/src/main/java/org/umc/valuedi/domain/savings/dto/response/SavingsResponseDTO.java @@ -11,11 +11,9 @@ public class SavingsResponseDTO { @Builder public record SavingsListResponse( int totalCount, // 총 상품건수 - int maxPageNo, // 총 페이지 건수 - int nowPageNo, // 현재 조회 페이지 번호 - List products, // 상품 목록 - RecommendationStatus status, // 상품 추천 상태 (PENDING | SUCCESS | FAILED) - String message // 상품 추천 상태 메시지 + int nowPageNo, // 현재 페이지 + boolean hasNext, // 다음 페이지 존재 여부 + List products // 상품 목록 ) { // 상품 목록 조회 public record RecommendedSavingProduct( @@ -29,23 +27,20 @@ public record RecommendedSavingProduct( @Builder public record SavingsDetailResponse( - SavingProductDetail product // 상품 + String korCoNm, + String finPrdtCd, + String finPrdtNm, + Double basicRate, + Double maxRate, + String joinWay, + String mtrtInt, + String spclCnd, + String joinDeny, + String joinMember, + String etcNote, + String maxLimit, + List