From 56fe1f3341e62f3b5af36cc51bc1a89bc31a3771 Mon Sep 17 00:00:00 2001 From: MEOHIN <96607465+MEOHIN@users.noreply.github.com> Date: Thu, 2 Oct 2025 10:46:52 +0900 Subject: [PATCH 01/45] =?UTF-8?q?[fix]=20=EB=8C=93=EA=B8=80=20=EC=88=98?= =?UTF-8?q?=EC=A0=95/=EC=82=AD=EC=A0=9C=20500=20=EC=98=A4=EB=A5=98=20?= =?UTF-8?q?=ED=95=B4=EA=B2=B0=20#222=20(#224)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Revert "chore: initData용 이미지 추가" This reverts commit ef30eef4df03b0fbb52b376d8d576d7b5d38f1be. * . * fix: 댓글 수정/삭제 시 작성자 비교 로직 개선 댓글 수정 및 삭제 로직에서 현재 로그인한 사용자와 댓글 작성자를 비교할 때, 기존에 객체(`comment.getUser().equals(user)`)를 비교하던 방식에서 ID 값(`comment.getUser().getId().equals(user.getId())`)을 비교하는 방식으로 변경 * fix: JWT 인증 토큰 획득 로직 개선 (Header 우선 적용) --- .../post/comment/service/CommentService.java | 4 +- .../security/CustomAuthenticationFilter.java | 143 +++++++++++------- 2 files changed, 90 insertions(+), 57 deletions(-) diff --git a/src/main/java/com/back/domain/post/comment/service/CommentService.java b/src/main/java/com/back/domain/post/comment/service/CommentService.java index e8f5e8a7..dff24687 100644 --- a/src/main/java/com/back/domain/post/comment/service/CommentService.java +++ b/src/main/java/com/back/domain/post/comment/service/CommentService.java @@ -95,7 +95,7 @@ public CommentResponseDto updateComment(Long postId, Long commentId, CommentUpda Comment comment = findCommentWithValidation(postId, commentId); - if (!comment.getUser().equals(user)) { + if (!comment.getUser().getId().equals(user.getId())) { throw new IllegalStateException("본인의 댓글만 수정할 수 있습니다."); } @@ -113,7 +113,7 @@ public void deleteComment(Long postId, Long commentId) { Comment comment = findCommentWithValidation(postId, commentId); - if (!comment.getUser().equals(user)) { + if (!comment.getUser().getId().equals(user.getId())) { throw new IllegalStateException("본인의 댓글만 삭제할 수 있습니다."); } diff --git a/src/main/java/com/back/global/security/CustomAuthenticationFilter.java b/src/main/java/com/back/global/security/CustomAuthenticationFilter.java index 062ed8b8..ade7a695 100644 --- a/src/main/java/com/back/global/security/CustomAuthenticationFilter.java +++ b/src/main/java/com/back/global/security/CustomAuthenticationFilter.java @@ -55,87 +55,120 @@ private void work(HttpServletRequest request, HttpServletResponse response, Filt String uri = request.getRequestURI(); String method = request.getMethod(); - // 개발 편의성을 위해 모든 요청 통과 (SecurityConfig에서 모든 요청 permitAll) - /* - if ( - uri.startsWith("/h2-console") || - uri.startsWith("/login/oauth2/") || - uri.startsWith("/oauth2/") || - uri.startsWith("/actuator/") || - uri.startsWith("/swagger-ui/") || - uri.startsWith("/api-docs/") || - uri.equals("/") || - // 조회 API들 - 권한 불필요 - (method.equals("GET") && uri.startsWith("/cocktails")) || - (method.equals("POST") && uri.equals("/cocktails/search")) || - (method.equals("GET") && uri.startsWith("/posts")) || - (method.equals("GET") && uri.contains("/comments")) - ) { - filterChain.doFilter(request, response); - return; - } - */ + log.debug("===== Authentication Filter Start ====="); + log.debug("Request: {} {}", method, uri); - // 쿠키에서 accessToken 가져오기 - String accessToken = rq.getCookieValue("accessToken", ""); + String accessToken = null; + + // 1. 먼저 Authorization 헤더에서 토큰 가져오기 시도 + String authHeader = request.getHeader("Authorization"); + if (authHeader != null && authHeader.startsWith("Bearer ")) { + accessToken = authHeader.substring(7); + log.debug("Token found in Authorization header"); + } - logger.debug("accessToken : " + accessToken); + // 2. Authorization 헤더에 없으면 쿠키에서 가져오기 + if (accessToken == null || accessToken.isBlank()) { + accessToken = rq.getCookieValue("accessToken", ""); + if (!accessToken.isBlank()) { + log.debug("Token found in Cookie"); + } + } boolean isAccessTokenExists = !accessToken.isBlank(); if (!isAccessTokenExists) { + log.debug("No access token found - proceeding without authentication"); filterChain.doFilter(request, response); return; } User user = null; - boolean isAccessTokenValid = false; // accessToken 검증 - if (isAccessTokenExists) { - if (jwtUtil.validateAccessToken(accessToken)) { + if (jwtUtil.validateAccessToken(accessToken)) { + log.debug("Access token is valid"); + + try { Long userId = jwtUtil.getUserIdFromToken(accessToken); String email = jwtUtil.getEmailFromToken(accessToken); String nickname = jwtUtil.getNicknameFromToken(accessToken); - user = User.builder() - .id(userId) - .email(email) - .nickname(nickname) - .role("USER") - .build(); - isAccessTokenValid = true; + if (userId != null) { + user = User.builder() + .id(userId) + .email(email) + .nickname(nickname) + .role("USER") + .build(); + + log.debug("User extracted - ID: {}, Email: {}, Nickname: {}", userId, email, nickname); + } else { + log.warn("User ID is null in token"); + } + } catch (Exception e) { + log.error("Error extracting user info from token", e); + } + } else { + log.warn("Access token validation failed"); + + // 토큰이 만료된 경우에도 정보 추출 시도 (선택적) + try { + Long userId = jwtUtil.getUserIdFromToken(accessToken); + String email = jwtUtil.getEmailFromToken(accessToken); + String nickname = jwtUtil.getNicknameFromToken(accessToken); + + if (userId != null && email != null && nickname != null) { + user = User.builder() + .id(userId) + .email(email) + .nickname(nickname) + .role("USER") + .build(); + + // 새 토큰 발급 (쿠키 방식을 사용하는 경우만) + if (authHeader == null) { + String newAccessToken = jwtUtil.generateAccessToken(userId, email, nickname); + rq.setCrossDomainCookie("accessToken", newAccessToken, accessTokenExpiration); + log.info("New access token issued for user: {}", userId); + } + } + } catch (Exception e) { + log.error("Failed to extract user info from expired token", e); } } + // user가 null이면 인증 실패 if (user == null) { + log.warn("Authentication failed - user is null"); filterChain.doFilter(request, response); return; } - // accessToken이 만료됐으면 새로 발급 - if (isAccessTokenExists && !isAccessTokenValid) { - String newAccessToken = jwtUtil.generateAccessToken(user.getId(), user.getEmail(), user.getNickname()); - rq.setCrossDomainCookie("accessToken", newAccessToken, accessTokenExpiration); - } - // SecurityContext에 인증 정보 저장 - UserDetails userDetails = new SecurityUser( - user.getId(), - user.getEmail(), - user.getNickname(), - user.isFirstLogin(), - user.getAuthorities(), - Map.of() // JWT 인증에서는 빈 attributes - ); - Authentication authentication = new UsernamePasswordAuthenticationToken( - userDetails, - userDetails.getPassword(), - userDetails.getAuthorities() - ); - SecurityContextHolder - .getContext() - .setAuthentication(authentication); + try { + UserDetails userDetails = new SecurityUser( + user.getId(), + user.getEmail(), + user.getNickname(), + user.isFirstLogin(), + user.getAuthorities(), + Map.of() // JWT 인증에서는 빈 attributes + ); + + Authentication authentication = new UsernamePasswordAuthenticationToken( + userDetails, + userDetails.getPassword(), + userDetails.getAuthorities() + ); + + SecurityContextHolder.getContext().setAuthentication(authentication); + + log.info("✅ Authentication SUCCESS - User ID: {}, Nickname: {}", user.getId(), user.getNickname()); + log.debug("===== Authentication Filter End ====="); + } catch (Exception e) { + log.error("Error setting authentication in SecurityContext", e); + } filterChain.doFilter(request, response); } From 93d4cece4c0e027fc063510c8f970f587bb835e3 Mon Sep 17 00:00:00 2001 From: GerHerMo Date: Thu, 2 Oct 2025 11:25:58 +0900 Subject: [PATCH 02/45] init --- src/main/java/com/back/domain/chatbot/dto/ChatRequestDto.java | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/main/java/com/back/domain/chatbot/dto/ChatRequestDto.java b/src/main/java/com/back/domain/chatbot/dto/ChatRequestDto.java index 8045d01e..7ad1ef44 100644 --- a/src/main/java/com/back/domain/chatbot/dto/ChatRequestDto.java +++ b/src/main/java/com/back/domain/chatbot/dto/ChatRequestDto.java @@ -18,7 +18,8 @@ public class ChatRequestDto { // 단계별 추천 관련 필드들 private boolean isStepRecommendation = false; private Integer currentStep; - private String selectedAlcoholStrength; // "ALL" 처리를 위해 스텝 3개 String으로 변경 + // "ALL" 처리를 위해 스텝 3개 String으로 변경 + private String selectedAlcoholStrength; private String selectedAlcoholBaseType; private String selectedCocktailType; } \ No newline at end of file From febe7c03739455ae6167b2e6275f179043030ad0 Mon Sep 17 00:00:00 2001 From: GerHerMo Date: Thu, 2 Oct 2025 11:34:30 +0900 Subject: [PATCH 03/45] fix: currentStep logic is modified --- .../domain/chatbot/dto/ChatRequestDto.java | 5 + .../chatbot/service/ChatbotService.java | 92 +++++++++++++++---- 2 files changed, 79 insertions(+), 18 deletions(-) diff --git a/src/main/java/com/back/domain/chatbot/dto/ChatRequestDto.java b/src/main/java/com/back/domain/chatbot/dto/ChatRequestDto.java index 7ad1ef44..d544b4d0 100644 --- a/src/main/java/com/back/domain/chatbot/dto/ChatRequestDto.java +++ b/src/main/java/com/back/domain/chatbot/dto/ChatRequestDto.java @@ -16,7 +16,12 @@ public class ChatRequestDto { private Long userId; // 단계별 추천 관련 필드들 + /** + * @deprecated currentStep 필드를 사용하세요. 이 필드는 하위 호환성을 위해 유지됩니다. + */ + @Deprecated private boolean isStepRecommendation = false; + private Integer currentStep; // "ALL" 처리를 위해 스텝 3개 String으로 변경 private String selectedAlcoholStrength; diff --git a/src/main/java/com/back/domain/chatbot/service/ChatbotService.java b/src/main/java/com/back/domain/chatbot/service/ChatbotService.java index ea4abb39..2e35555e 100644 --- a/src/main/java/com/back/domain/chatbot/service/ChatbotService.java +++ b/src/main/java/com/back/domain/chatbot/service/ChatbotService.java @@ -89,18 +89,45 @@ public void init() throws IOException { @Transactional public ChatResponseDto sendMessage(ChatRequestDto requestDto) { try { - // 단계별 추천 모드 확인 (currentStep이 있으면 무조건 단계별 추천 모드) - if (requestDto.isStepRecommendation() || - requestDto.getCurrentStep() != null || - isStepRecommendationTrigger(requestDto.getMessage())) { - log.info("Recommendation chat mode for userId: {}", requestDto.getUserId()); + Integer currentStep = requestDto.getCurrentStep(); + + // ========== 1순위: currentStep 명시적 제어 ========== + if (currentStep != null) { + log.info("[EXPLICIT] currentStep={}, userId={}, mode={}", + currentStep, requestDto.getUserId(), + currentStep == 0 ? "QA" : "STEP"); + + if (currentStep == 0) { + // 질문형 추천 (일반 AI 대화) + log.info("질문형 추천 모드 진입 - userId: {}", requestDto.getUserId()); + return generateAIResponseWithContext(requestDto, "질문형 추천"); + } + else if (currentStep >= 1 && currentStep <= 4) { + // 단계별 추천 + log.info("단계별 추천 모드 진입 - Step: {}, userId: {}", + currentStep, requestDto.getUserId()); + return handleStepRecommendation(requestDto); + } + else { + // 유효하지 않은 step 값 + log.warn("유효하지 않은 currentStep: {}, userId: {}", currentStep, requestDto.getUserId()); + return createErrorResponse("잘못된 단계 정보입니다."); + } + } + + // ========== 2순위: 키워드 감지 (하위 호환성) ========== + if (isStepRecommendationTrigger(requestDto.getMessage())) { + log.info("[LEGACY] 키워드 기반 단계별 추천 감지 - userId: {}", requestDto.getUserId()); + + // FE에서 currentStep을 보내지 않았을 때 자동 설정 + requestDto.setCurrentStep(1); return handleStepRecommendation(requestDto); } - // 일반 대화 모드 + // ========== 3순위: 기본 일반 대화 ========== + log.info("[DEFAULT] 일반 대화 모드 - userId: {}", requestDto.getUserId()); String response = generateAIResponse(requestDto); - // 일반 텍스트 응답 생성 (type이 자동으로 TEXT로 설정됨) return ChatResponseDto.builder() .message(response) .type(MessageType.TEXT) @@ -109,13 +136,7 @@ public ChatResponseDto sendMessage(ChatRequestDto requestDto) { } catch (Exception e) { log.error("채팅 응답 생성 중 오류 발생: ", e); - - // 에러 응답 - return ChatResponseDto.builder() - .message("죄송합니다. 일시적인 오류가 발생했습니다.") - .type(MessageType.ERROR) - .timestamp(LocalDateTime.now()) - .build(); + return createErrorResponse("죄송합니다. 일시적인 오류가 발생했습니다."); } } @@ -398,10 +419,45 @@ private InternalMessageType detectMessageType(String message) { return InternalMessageType.CASUAL_CHAT; } - // 단계별 추천 시작 키워드 감지 + /** + * 단계별 추천 시작 키워드 감지 (레거시 지원) + * @deprecated currentStep 명시적 전달 방식을 사용하세요. 이 메서드는 하위 호환성을 위해 유지됩니다. + */ + @Deprecated private boolean isStepRecommendationTrigger(String message) { + log.warn("레거시 키워드 감지 사용됨. currentStep 사용 권장. message: {}", message); String lower = message.toLowerCase().trim(); - return lower.contains("단계별 추천"); + return lower.contains("단계별 취향 찾기"); + } + + /** + * 질문형 추천 전용 AI 응답 생성 + * 일반 대화와 구분하여 추천에 특화된 응답 생성 + */ + private ChatResponseDto generateAIResponseWithContext(ChatRequestDto requestDto, String mode) { + String response = generateAIResponse(requestDto); + + return ChatResponseDto.builder() + .message(response) + .type(MessageType.TEXT) + .timestamp(LocalDateTime.now()) + .metaData(ChatResponseDto.MetaData.builder() + .actionType(mode) + .currentStep(0) + .totalSteps(0) + .build()) + .build(); + } + + /** + * 에러 응답 생성 + */ + private ChatResponseDto createErrorResponse(String errorMessage) { + return ChatResponseDto.builder() + .message(errorMessage) + .type(MessageType.ERROR) + .timestamp(LocalDateTime.now()) + .build(); } private ChatResponseDto handleStepRecommendation(ChatRequestDto requestDto) { @@ -417,7 +473,7 @@ private ChatResponseDto handleStepRecommendation(ChatRequestDto requestDto) { switch (currentStep) { case 1: stepData = getAlcoholStrengthOptions(); - message = "단계별 맞춤 추천을 시작합니다! 🎯\n원하시는 도수를 선택해주세요!"; + message = "단계별 맞춤 취향 추천을 시작합니다! 🎯\n원하시는 도수를 선택해주세요!"; type = MessageType.RADIO_OPTIONS; break; @@ -448,7 +504,7 @@ private ChatResponseDto handleStepRecommendation(ChatRequestDto requestDto) { default: stepData = getAlcoholStrengthOptions(); - message = "단계별 맞춤 추천을 시작합니다! 🎯"; + message = "단계별 맞춤 취향 추천을 시작합니다! 🎯"; type = MessageType.RADIO_OPTIONS; } From 14b67e7a8aee435ba650938df44aa2a7f26ce726 Mon Sep 17 00:00:00 2001 From: seungwookc97 Date: Thu, 2 Oct 2025 11:56:21 +0900 Subject: [PATCH 04/45] =?UTF-8?q?feat=20:=20user=20=EC=97=94=ED=8B=B0?= =?UTF-8?q?=ED=8B=B0=20=ED=95=B4=EC=8B=9C=EC=BD=94=EB=93=9C=20=EC=98=A4?= =?UTF-8?q?=EB=B2=84=EB=9D=BC=EC=9D=B4=EB=94=A9=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/java/com/back/domain/user/entity/User.java | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/src/main/java/com/back/domain/user/entity/User.java b/src/main/java/com/back/domain/user/entity/User.java index c746c241..4ad1a26d 100644 --- a/src/main/java/com/back/domain/user/entity/User.java +++ b/src/main/java/com/back/domain/user/entity/User.java @@ -14,6 +14,7 @@ import java.util.ArrayList; import java.util.Collection; import java.util.List; +import java.util.Objects; @Entity @Table(name = "users") // 예약어 충돌 방지를 위해 "users" 권장 @@ -70,6 +71,17 @@ public class User { @OneToMany(mappedBy = "user", cascade = CascadeType.ALL, orphanRemoval = true) private List postLikes = new ArrayList<>(); + @Override + public boolean equals(Object o) { + if (o == null || getClass() != o.getClass()) return false; + User user = (User) o; + return Objects.equals(id, user.id); + } + + @Override + public int hashCode() { + return Objects.hashCode(id); + } public boolean isAdmin() { return "ADMIN".equalsIgnoreCase(role); From e8a08dbf1a7e8740504c5c4825b2ff4299466132 Mon Sep 17 00:00:00 2001 From: MEOHIN <96607465+MEOHIN@users.noreply.github.com> Date: Thu, 2 Oct 2025 12:15:10 +0900 Subject: [PATCH 05/45] =?UTF-8?q?[fix]=20=EC=82=AD=EC=A0=9C=EB=90=9C=20?= =?UTF-8?q?=EB=8C=93=EA=B8=80=EC=9D=B4=20=EC=A1=B0=ED=9A=8C=EB=90=98?= =?UTF-8?q?=EB=8A=94=20=EC=98=A4=EB=A5=98=20=ED=95=B4=EA=B2=B0=20#230=20(#?= =?UTF-8?q?231)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Revert "chore: initData용 이미지 추가" This reverts commit ef30eef4df03b0fbb52b376d8d576d7b5d38f1be. * . * chore: 수정 사항 없음 * fix: 삭제되지 않은 댓글만 조회하는 기능 추가 * fix: Soft Delete 정책에 따른 댓글 조회 로직 수정 - 댓글 조회 시 논리적으로 삭제된 댓글(`CommentStatus.DELETED`)을 제외하도록 Service 로직을 수정 --- .../comment/repository/CommentRepository.java | 9 +++++- .../post/comment/service/CommentService.java | 28 +++++++++++++------ 2 files changed, 27 insertions(+), 10 deletions(-) diff --git a/src/main/java/com/back/domain/post/comment/repository/CommentRepository.java b/src/main/java/com/back/domain/post/comment/repository/CommentRepository.java index 238ee02b..d482be37 100644 --- a/src/main/java/com/back/domain/post/comment/repository/CommentRepository.java +++ b/src/main/java/com/back/domain/post/comment/repository/CommentRepository.java @@ -1,14 +1,21 @@ package com.back.domain.post.comment.repository; import com.back.domain.post.comment.entity.Comment; -import java.util.List; +import com.back.domain.post.comment.enums.CommentStatus; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.stereotype.Repository; +import java.util.List; + @Repository public interface CommentRepository extends JpaRepository { List findTop10ByPostIdOrderByIdDesc(Long postId); List findTop10ByPostIdAndIdLessThanOrderByIdDesc(Long postId, Long lastId); + + // DELETED가 아닌 댓글만 조회하는 메서드 추가 + List findTop10ByPostIdAndStatusNotOrderByIdDesc(Long postId, CommentStatus status); + + List findTop10ByPostIdAndStatusNotAndIdLessThanOrderByIdDesc(Long postId, CommentStatus status, Long lastId); } diff --git a/src/main/java/com/back/domain/post/comment/service/CommentService.java b/src/main/java/com/back/domain/post/comment/service/CommentService.java index dff24687..b2b85fa4 100644 --- a/src/main/java/com/back/domain/post/comment/service/CommentService.java +++ b/src/main/java/com/back/domain/post/comment/service/CommentService.java @@ -68,15 +68,15 @@ public CommentResponseDto createComment(Long postId, CommentCreateRequestDto req @Transactional(readOnly = true) public List getComments(Long postId, Long lastId) { if (lastId == null) { - return commentRepository.findTop10ByPostIdOrderByIdDesc(postId) - .stream() - .map(CommentResponseDto::new) - .toList(); + return commentRepository.findTop10ByPostIdAndStatusNotOrderByIdDesc(postId, CommentStatus.DELETED) + .stream() + .map(CommentResponseDto::new) + .toList(); } else { - return commentRepository.findTop10ByPostIdAndIdLessThanOrderByIdDesc(postId, lastId) - .stream() - .map(CommentResponseDto::new) - .toList(); + return commentRepository.findTop10ByPostIdAndStatusNotAndIdLessThanOrderByIdDesc(postId, CommentStatus.DELETED, lastId) + .stream() + .map(CommentResponseDto::new) + .toList(); } } @@ -85,6 +85,11 @@ public List getComments(Long postId, Long lastId) { public CommentResponseDto getComment(Long postId, Long commentId) { Comment comment = findCommentWithValidation(postId, commentId); + // 삭제된 댓글 조회 방지 + if (comment.getStatus() == CommentStatus.DELETED) { + throw new IllegalArgumentException("삭제된 댓글입니다."); + } + return new CommentResponseDto(comment); } @@ -131,12 +136,17 @@ public void deleteComment(Long postId, Long commentId) { // 댓글과 게시글의 연관관계 검증 private Comment findCommentWithValidation(Long postId, Long commentId) { Comment comment = commentRepository.findById(commentId) - .orElseThrow(() -> new IllegalArgumentException("댓글이 존재하지 않습니다. id=" + commentId)); + .orElseThrow(() -> new IllegalArgumentException("댓글이 존재하지 않습니다. id=" + commentId)); if (!comment.getPost().getId().equals(postId)) { throw new IllegalStateException("댓글이 해당 게시글에 속하지 않습니다."); } + // 삭제된 댓글 접근 방지 (수정/삭제 시) + if (comment.getStatus() == CommentStatus.DELETED) { + throw new IllegalStateException("삭제된 댓글은 수정하거나 삭제할 수 없습니다."); + } + return comment; } } From a460569233c56dc6bbf8d00b3d8ec14fd853e49d Mon Sep 17 00:00:00 2001 From: MEOHIN <96607465+MEOHIN@users.noreply.github.com> Date: Thu, 2 Oct 2025 12:32:24 +0900 Subject: [PATCH 06/45] =?UTF-8?q?[chore]=20=EB=B6=88=ED=95=84=EC=9A=94?= =?UTF-8?q?=ED=95=9C=20=EC=88=98=EC=A0=95=20=EB=90=98=EB=8F=8C=EB=A6=AC?= =?UTF-8?q?=EA=B8=B0=20#230=20(#233)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Revert "chore: initData용 이미지 추가" This reverts commit ef30eef4df03b0fbb52b376d8d576d7b5d38f1be. * . * chore: 수정 사항 없음 * fix: 삭제되지 않은 댓글만 조회하는 기능 추가 * chore: 불필요한 fix 롤백 * chore: 수정사항 없음 --- .../comment/repository/CommentRepository.java | 6 ---- .../post/comment/service/CommentService.java | 28 ++++++------------- 2 files changed, 9 insertions(+), 25 deletions(-) diff --git a/src/main/java/com/back/domain/post/comment/repository/CommentRepository.java b/src/main/java/com/back/domain/post/comment/repository/CommentRepository.java index d482be37..97fa3c8a 100644 --- a/src/main/java/com/back/domain/post/comment/repository/CommentRepository.java +++ b/src/main/java/com/back/domain/post/comment/repository/CommentRepository.java @@ -1,7 +1,6 @@ package com.back.domain.post.comment.repository; import com.back.domain.post.comment.entity.Comment; -import com.back.domain.post.comment.enums.CommentStatus; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.stereotype.Repository; @@ -13,9 +12,4 @@ public interface CommentRepository extends JpaRepository { List findTop10ByPostIdOrderByIdDesc(Long postId); List findTop10ByPostIdAndIdLessThanOrderByIdDesc(Long postId, Long lastId); - - // DELETED가 아닌 댓글만 조회하는 메서드 추가 - List findTop10ByPostIdAndStatusNotOrderByIdDesc(Long postId, CommentStatus status); - - List findTop10ByPostIdAndStatusNotAndIdLessThanOrderByIdDesc(Long postId, CommentStatus status, Long lastId); } diff --git a/src/main/java/com/back/domain/post/comment/service/CommentService.java b/src/main/java/com/back/domain/post/comment/service/CommentService.java index b2b85fa4..dff24687 100644 --- a/src/main/java/com/back/domain/post/comment/service/CommentService.java +++ b/src/main/java/com/back/domain/post/comment/service/CommentService.java @@ -68,15 +68,15 @@ public CommentResponseDto createComment(Long postId, CommentCreateRequestDto req @Transactional(readOnly = true) public List getComments(Long postId, Long lastId) { if (lastId == null) { - return commentRepository.findTop10ByPostIdAndStatusNotOrderByIdDesc(postId, CommentStatus.DELETED) - .stream() - .map(CommentResponseDto::new) - .toList(); + return commentRepository.findTop10ByPostIdOrderByIdDesc(postId) + .stream() + .map(CommentResponseDto::new) + .toList(); } else { - return commentRepository.findTop10ByPostIdAndStatusNotAndIdLessThanOrderByIdDesc(postId, CommentStatus.DELETED, lastId) - .stream() - .map(CommentResponseDto::new) - .toList(); + return commentRepository.findTop10ByPostIdAndIdLessThanOrderByIdDesc(postId, lastId) + .stream() + .map(CommentResponseDto::new) + .toList(); } } @@ -85,11 +85,6 @@ public List getComments(Long postId, Long lastId) { public CommentResponseDto getComment(Long postId, Long commentId) { Comment comment = findCommentWithValidation(postId, commentId); - // 삭제된 댓글 조회 방지 - if (comment.getStatus() == CommentStatus.DELETED) { - throw new IllegalArgumentException("삭제된 댓글입니다."); - } - return new CommentResponseDto(comment); } @@ -136,17 +131,12 @@ public void deleteComment(Long postId, Long commentId) { // 댓글과 게시글의 연관관계 검증 private Comment findCommentWithValidation(Long postId, Long commentId) { Comment comment = commentRepository.findById(commentId) - .orElseThrow(() -> new IllegalArgumentException("댓글이 존재하지 않습니다. id=" + commentId)); + .orElseThrow(() -> new IllegalArgumentException("댓글이 존재하지 않습니다. id=" + commentId)); if (!comment.getPost().getId().equals(postId)) { throw new IllegalStateException("댓글이 해당 게시글에 속하지 않습니다."); } - // 삭제된 댓글 접근 방지 (수정/삭제 시) - if (comment.getStatus() == CommentStatus.DELETED) { - throw new IllegalStateException("삭제된 댓글은 수정하거나 삭제할 수 없습니다."); - } - return comment; } } From 52cc72a46fb3dde85c125e61b2a82a4e71f336bd Mon Sep 17 00:00:00 2001 From: GerHerMo Date: Thu, 2 Oct 2025 12:47:24 +0900 Subject: [PATCH 07/45] refactor: edit Dto & Service --- .../domain/chatbot/dto/ChatResponseDto.java | 12 ++++++-- .../chatbot/service/ChatbotService.java | 28 +++++++++++++------ 2 files changed, 29 insertions(+), 11 deletions(-) diff --git a/src/main/java/com/back/domain/chatbot/dto/ChatResponseDto.java b/src/main/java/com/back/domain/chatbot/dto/ChatResponseDto.java index 59170e87..533eab5f 100644 --- a/src/main/java/com/back/domain/chatbot/dto/ChatResponseDto.java +++ b/src/main/java/com/back/domain/chatbot/dto/ChatResponseDto.java @@ -1,5 +1,6 @@ package com.back.domain.chatbot.dto; +import com.back.domain.chatbot.enums.MessageSender; import com.back.domain.chatbot.enums.MessageType; import lombok.AllArgsConstructor; import lombok.Builder; @@ -16,9 +17,12 @@ @Builder public class ChatResponseDto { + private Long id; // 메시지 ID (DB 저장 후 생성) + private Long userId; // 사용자 ID private String message; // 텍스트 메시지 + private MessageSender sender; // 메시지 발신자 (USER / CHATBOT) private MessageType type; // 메시지 표시 타입 - private LocalDateTime timestamp; + private LocalDateTime createdAt; // 생성 시간 (timestamp → createdAt으로 변경) // 단계별 추천 관련 데이터 (type이 RADIO_OPTIONS 또는 CARD_LIST일 때 사용) private StepRecommendationResponseDto stepData; @@ -30,12 +34,14 @@ public class ChatResponseDto { public ChatResponseDto(String message) { this.message = message; this.type = MessageType.TEXT; - this.timestamp = LocalDateTime.now(); + this.sender = MessageSender.CHATBOT; + this.createdAt = LocalDateTime.now(); } public ChatResponseDto(String message, StepRecommendationResponseDto stepData) { this.message = message; - this.timestamp = LocalDateTime.now(); + this.sender = MessageSender.CHATBOT; + this.createdAt = LocalDateTime.now(); this.stepData = stepData; // stepData 내용에 따라 type 자동 설정 diff --git a/src/main/java/com/back/domain/chatbot/service/ChatbotService.java b/src/main/java/com/back/domain/chatbot/service/ChatbotService.java index 2e35555e..4d2b57a4 100644 --- a/src/main/java/com/back/domain/chatbot/service/ChatbotService.java +++ b/src/main/java/com/back/domain/chatbot/service/ChatbotService.java @@ -129,9 +129,11 @@ else if (currentStep >= 1 && currentStep <= 4) { String response = generateAIResponse(requestDto); return ChatResponseDto.builder() + .userId(requestDto.getUserId()) .message(response) + .sender(MessageSender.CHATBOT) .type(MessageType.TEXT) - .timestamp(LocalDateTime.now()) + .createdAt(LocalDateTime.now()) .build(); } catch (Exception e) { @@ -254,6 +256,7 @@ public ChatResponseDto createGreetingMessage(Long userId) { // 중복 확인: 동일한 인사말이 이미 존재하는지 확인 boolean greetingExists = chatConversationRepository.existsByUserIdAndMessage(userId, greetingMessage); + ChatConversation savedGreeting = null; // 중복되지 않을 경우에만 DB에 저장 if (!greetingExists) { ChatConversation greeting = ChatConversation.builder() @@ -262,18 +265,21 @@ public ChatResponseDto createGreetingMessage(Long userId) { .sender(MessageSender.CHATBOT) .createdAt(LocalDateTime.now()) .build(); - chatConversationRepository.save(greeting); + savedGreeting = chatConversationRepository.save(greeting); log.info("인사말 저장 완료 - userId: {}", userId); } else { log.info("이미 인사말이 존재하여 저장 생략 - userId: {}", userId); } - // ChatResponseDto 반환 + // ChatResponseDto 반환 (요청된 형식에 맞춰 id, userId, sender, type, createdAt 포함) return ChatResponseDto.builder() + .id(savedGreeting != null ? savedGreeting.getId() : null) + .userId(userId) .message(greetingMessage) + .sender(MessageSender.CHATBOT) .type(MessageType.RADIO_OPTIONS) .stepData(stepData) - .timestamp(LocalDateTime.now()) + .createdAt(LocalDateTime.now()) .build(); } @@ -390,8 +396,9 @@ private String generateAIResponse(ChatRequestDto requestDto) { public ChatResponseDto createLoadingMessage() { return ChatResponseDto.builder() .message("응답을 생성하는 중...") + .sender(MessageSender.CHATBOT) .type(MessageType.LOADING) - .timestamp(LocalDateTime.now()) + .createdAt(LocalDateTime.now()) .metaData(ChatResponseDto.MetaData.builder() .isTyping(true) .build()) @@ -438,9 +445,11 @@ private ChatResponseDto generateAIResponseWithContext(ChatRequestDto requestDto, String response = generateAIResponse(requestDto); return ChatResponseDto.builder() + .userId(requestDto.getUserId()) .message(response) + .sender(MessageSender.CHATBOT) .type(MessageType.TEXT) - .timestamp(LocalDateTime.now()) + .createdAt(LocalDateTime.now()) .metaData(ChatResponseDto.MetaData.builder() .actionType(mode) .currentStep(0) @@ -455,8 +464,9 @@ private ChatResponseDto generateAIResponseWithContext(ChatRequestDto requestDto, private ChatResponseDto createErrorResponse(String errorMessage) { return ChatResponseDto.builder() .message(errorMessage) + .sender(MessageSender.CHATBOT) .type(MessageType.ERROR) - .timestamp(LocalDateTime.now()) + .createdAt(LocalDateTime.now()) .build(); } @@ -517,11 +527,13 @@ private ChatResponseDto handleStepRecommendation(ChatRequestDto requestDto) { .build(); return ChatResponseDto.builder() + .userId(requestDto.getUserId()) .message(message) + .sender(MessageSender.CHATBOT) .type(type) .stepData(stepData) .metaData(metaData) - .timestamp(LocalDateTime.now()) + .createdAt(LocalDateTime.now()) .build(); } From 085112c2403d5c310ee1652f2d33cd341aacc470 Mon Sep 17 00:00:00 2001 From: GerHerMo Date: Thu, 2 Oct 2025 12:55:37 +0900 Subject: [PATCH 08/45] fix: return inner data on chatbot/chat --- .../chatbot/service/ChatbotService.java | 49 +++++++++++++------ 1 file changed, 35 insertions(+), 14 deletions(-) diff --git a/src/main/java/com/back/domain/chatbot/service/ChatbotService.java b/src/main/java/com/back/domain/chatbot/service/ChatbotService.java index 4d2b57a4..cd4ce25c 100644 --- a/src/main/java/com/back/domain/chatbot/service/ChatbotService.java +++ b/src/main/java/com/back/domain/chatbot/service/ChatbotService.java @@ -126,14 +126,15 @@ else if (currentStep >= 1 && currentStep <= 4) { // ========== 3순위: 기본 일반 대화 ========== log.info("[DEFAULT] 일반 대화 모드 - userId: {}", requestDto.getUserId()); - String response = generateAIResponse(requestDto); + ChatConversation savedResponse = generateAIResponse(requestDto); return ChatResponseDto.builder() + .id(savedResponse.getId()) .userId(requestDto.getUserId()) - .message(response) + .message(savedResponse.getMessage()) .sender(MessageSender.CHATBOT) .type(MessageType.TEXT) - .createdAt(LocalDateTime.now()) + .createdAt(savedResponse.getCreatedAt()) .build(); } catch (Exception e) { @@ -172,9 +173,10 @@ private String buildConversationContext(List recentChats) { /** * 대화 저장 - 변경사항: 사용자 메시지와 봇 응답을 각각 별도로 저장 + * @return 저장된 봇 응답 엔티티 (id 포함) */ @Transactional - public void saveConversation(ChatRequestDto requestDto, String response) { + public ChatConversation saveConversation(ChatRequestDto requestDto, String response) { // 1. 사용자 메시지 저장 ChatConversation userMessage = ChatConversation.builder() .userId(requestDto.getUserId()) @@ -191,7 +193,7 @@ public void saveConversation(ChatRequestDto requestDto, String response) { .sender(MessageSender.CHATBOT) .createdAt(LocalDateTime.now()) .build(); - chatConversationRepository.save(botResponse); + return chatConversationRepository.save(botResponse); } /** @@ -356,8 +358,9 @@ private String postProcessResponse(String response, InternalMessageType type) { /** * AI 응답 생성 + * @return 저장된 봇 응답 엔티티 (id 포함) */ - private String generateAIResponse(ChatRequestDto requestDto) { + private ChatConversation generateAIResponse(ChatRequestDto requestDto) { log.info("Normal chat mode for userId: {}", requestDto.getUserId()); // 메시지 타입 감지 (내부 enum 사용) @@ -384,10 +387,8 @@ private String generateAIResponse(ChatRequestDto requestDto) { // 응답 후처리 response = postProcessResponse(response, messageType); - // 대화 저장 - 사용자 메시지와 봇 응답을 각각 저장 - saveConversation(requestDto, response); - - return response; + // 대화 저장 - 사용자 메시지와 봇 응답을 각각 저장하고 저장된 봇 응답 반환 + return saveConversation(requestDto, response); } /** @@ -442,14 +443,15 @@ private boolean isStepRecommendationTrigger(String message) { * 일반 대화와 구분하여 추천에 특화된 응답 생성 */ private ChatResponseDto generateAIResponseWithContext(ChatRequestDto requestDto, String mode) { - String response = generateAIResponse(requestDto); + ChatConversation savedResponse = generateAIResponse(requestDto); return ChatResponseDto.builder() + .id(savedResponse.getId()) .userId(requestDto.getUserId()) - .message(response) + .message(savedResponse.getMessage()) .sender(MessageSender.CHATBOT) .type(MessageType.TEXT) - .createdAt(LocalDateTime.now()) + .createdAt(savedResponse.getCreatedAt()) .metaData(ChatResponseDto.MetaData.builder() .actionType(mode) .currentStep(0) @@ -518,6 +520,24 @@ private ChatResponseDto handleStepRecommendation(ChatRequestDto requestDto) { type = MessageType.RADIO_OPTIONS; } + // 사용자 메시지 저장 (단계별 추천 요청) + ChatConversation userMessage = ChatConversation.builder() + .userId(requestDto.getUserId()) + .message(requestDto.getMessage()) + .sender(MessageSender.USER) + .createdAt(LocalDateTime.now()) + .build(); + chatConversationRepository.save(userMessage); + + // 봇 응답 저장 + ChatConversation botResponse = ChatConversation.builder() + .userId(requestDto.getUserId()) + .message(message) + .sender(MessageSender.CHATBOT) + .createdAt(LocalDateTime.now()) + .build(); + ChatConversation savedResponse = chatConversationRepository.save(botResponse); + // 메타데이터 포함 ChatResponseDto.MetaData metaData = ChatResponseDto.MetaData.builder() .currentStep(currentStep) @@ -527,13 +547,14 @@ private ChatResponseDto handleStepRecommendation(ChatRequestDto requestDto) { .build(); return ChatResponseDto.builder() + .id(savedResponse.getId()) .userId(requestDto.getUserId()) .message(message) .sender(MessageSender.CHATBOT) .type(type) .stepData(stepData) .metaData(metaData) - .createdAt(LocalDateTime.now()) + .createdAt(savedResponse.getCreatedAt()) .build(); } From ecdb8dca3c738f5c1ad584dc7322fb199c054bc6 Mon Sep 17 00:00:00 2001 From: GerHerMo Date: Thu, 2 Oct 2025 16:03:46 +0900 Subject: [PATCH 09/45] refactor: delete selectedCocktailType level --- .../domain/chatbot/dto/ChatRequestDto.java | 1 - .../chatbot/service/ChatbotService.java | 54 ++----------------- 2 files changed, 4 insertions(+), 51 deletions(-) diff --git a/src/main/java/com/back/domain/chatbot/dto/ChatRequestDto.java b/src/main/java/com/back/domain/chatbot/dto/ChatRequestDto.java index d544b4d0..a3e6d472 100644 --- a/src/main/java/com/back/domain/chatbot/dto/ChatRequestDto.java +++ b/src/main/java/com/back/domain/chatbot/dto/ChatRequestDto.java @@ -26,5 +26,4 @@ public class ChatRequestDto { // "ALL" 처리를 위해 스텝 3개 String으로 변경 private String selectedAlcoholStrength; private String selectedAlcoholBaseType; - private String selectedCocktailType; } \ No newline at end of file diff --git a/src/main/java/com/back/domain/chatbot/service/ChatbotService.java b/src/main/java/com/back/domain/chatbot/service/ChatbotService.java index cd4ce25c..5e24f31d 100644 --- a/src/main/java/com/back/domain/chatbot/service/ChatbotService.java +++ b/src/main/java/com/back/domain/chatbot/service/ChatbotService.java @@ -102,7 +102,7 @@ public ChatResponseDto sendMessage(ChatRequestDto requestDto) { log.info("질문형 추천 모드 진입 - userId: {}", requestDto.getUserId()); return generateAIResponseWithContext(requestDto, "질문형 추천"); } - else if (currentStep >= 1 && currentStep <= 4) { + else if (currentStep >= 1 && currentStep <= 3) { // 단계별 추천 log.info("단계별 추천 모드 진입 - Step: {}, userId: {}", currentStep, requestDto.getUserId()); @@ -496,19 +496,10 @@ private ChatResponseDto handleStepRecommendation(ChatRequestDto requestDto) { break; case 3: - stepData = getCocktailTypeOptions( - parseAlcoholStrength(requestDto.getSelectedAlcoholStrength()), - parseAlcoholBaseType(requestDto.getSelectedAlcoholBaseType()) - ); - message = "완벽해요! 마지막으로 어떤 스타일로 즐기실 건가요? 🥃"; - type = MessageType.RADIO_OPTIONS; - break; - - case 4: stepData = getFinalRecommendations( parseAlcoholStrength(requestDto.getSelectedAlcoholStrength()), parseAlcoholBaseType(requestDto.getSelectedAlcoholBaseType()), - parseCocktailType(requestDto.getSelectedCocktailType()) + null ); message = stepData.getStepTitle(); type = MessageType.CARD_LIST; // 최종 추천은 카드 리스트 @@ -541,7 +532,7 @@ private ChatResponseDto handleStepRecommendation(ChatRequestDto requestDto) { // 메타데이터 포함 ChatResponseDto.MetaData metaData = ChatResponseDto.MetaData.builder() .currentStep(currentStep) - .totalSteps(4) + .totalSteps(3) .isTyping(true) .delay(300) .build(); @@ -585,17 +576,6 @@ private AlcoholBaseType parseAlcoholBaseType(String value) { } } - private CocktailType parseCocktailType(String value) { - if (value == null || value.trim().isEmpty() || "ALL".equalsIgnoreCase(value)) { - return null; - } - try { - return CocktailType.valueOf(value); - } catch (IllegalArgumentException e) { - log.warn("Invalid CocktailType value: {}", value); - return null; - } - } private StepRecommendationResponseDto getAlcoholStrengthOptions() { List options = new ArrayList<>(); @@ -651,32 +631,6 @@ private StepRecommendationResponseDto getAlcoholBaseTypeOptions(AlcoholStrength ); } - private StepRecommendationResponseDto getCocktailTypeOptions(AlcoholStrength alcoholStrength, AlcoholBaseType alcoholBaseType) { - List options = new ArrayList<>(); - - // "전체" 옵션 추가 - options.add(new StepRecommendationResponseDto.StepOption( - "ALL", - "전체", - null - )); - - for (CocktailType cocktailType : CocktailType.values()) { - options.add(new StepRecommendationResponseDto.StepOption( - cocktailType.name(), - cocktailType.getDescription(), - null - )); - } - - return new StepRecommendationResponseDto( - 3, - "어떤 종류의 잔으로 드시겠어요?", - options, - null, - false - ); - } private StepRecommendationResponseDto getFinalRecommendations( AlcoholStrength alcoholStrength, @@ -714,7 +668,7 @@ private StepRecommendationResponseDto getFinalRecommendations( "마음에 드는 칵테일은 '킵' 버튼을 눌러 나만의 Bar에 저장해보세요!"; return new StepRecommendationResponseDto( - 4, + 3, stepTitle, null, recommendations, From 107f3338fbd873338b90461b84f05e7ca79ba213 Mon Sep 17 00:00:00 2001 From: GerHerMo Date: Thu, 2 Oct 2025 16:14:42 +0900 Subject: [PATCH 10/45] feat: final input add --- .../domain/chatbot/dto/ChatRequestDto.java | 3 ++- .../domain/chatbot/enums/MessageType.java | 3 ++- .../chatbot/service/ChatbotService.java | 27 +++++++++++-------- 3 files changed, 20 insertions(+), 13 deletions(-) diff --git a/src/main/java/com/back/domain/chatbot/dto/ChatRequestDto.java b/src/main/java/com/back/domain/chatbot/dto/ChatRequestDto.java index a3e6d472..83f26e15 100644 --- a/src/main/java/com/back/domain/chatbot/dto/ChatRequestDto.java +++ b/src/main/java/com/back/domain/chatbot/dto/ChatRequestDto.java @@ -23,7 +23,8 @@ public class ChatRequestDto { private boolean isStepRecommendation = false; private Integer currentStep; - // "ALL" 처리를 위해 스텝 3개 String으로 변경 + // "ALL" 처리를 위해 스텝 2개 String으로 변경 private String selectedAlcoholStrength; private String selectedAlcoholBaseType; + // selectedCocktailType 삭제 } \ No newline at end of file diff --git a/src/main/java/com/back/domain/chatbot/enums/MessageType.java b/src/main/java/com/back/domain/chatbot/enums/MessageType.java index 11e71ace..bf21c6c2 100644 --- a/src/main/java/com/back/domain/chatbot/enums/MessageType.java +++ b/src/main/java/com/back/domain/chatbot/enums/MessageType.java @@ -5,7 +5,8 @@ public enum MessageType { RADIO_OPTIONS("라디오옵션"), // 라디오 버튼 선택지 CARD_LIST("카드리스트"), // 칵테일 추천 카드 리스트 LOADING("로딩중"), // 로딩 메시지 - ERROR("에러"); // 에러 메시지 + ERROR("에러"), // 에러 메시지 + INPUT("입력"); // 텍스트 입력 요청 private final String description; diff --git a/src/main/java/com/back/domain/chatbot/service/ChatbotService.java b/src/main/java/com/back/domain/chatbot/service/ChatbotService.java index 5e24f31d..8da9230c 100644 --- a/src/main/java/com/back/domain/chatbot/service/ChatbotService.java +++ b/src/main/java/com/back/domain/chatbot/service/ChatbotService.java @@ -12,7 +12,6 @@ import com.back.domain.cocktail.entity.Cocktail; import com.back.domain.cocktail.enums.AlcoholBaseType; import com.back.domain.cocktail.enums.AlcoholStrength; -import com.back.domain.cocktail.enums.CocktailType; import com.back.domain.cocktail.repository.CocktailRepository; import jakarta.annotation.PostConstruct; import lombok.RequiredArgsConstructor; @@ -102,7 +101,7 @@ public ChatResponseDto sendMessage(ChatRequestDto requestDto) { log.info("질문형 추천 모드 진입 - userId: {}", requestDto.getUserId()); return generateAIResponseWithContext(requestDto, "질문형 추천"); } - else if (currentStep >= 1 && currentStep <= 3) { + else if (currentStep >= 1 && currentStep <= 4) { // 단계별 추천 log.info("단계별 추천 모드 진입 - Step: {}, userId: {}", currentStep, requestDto.getUserId()); @@ -496,10 +495,16 @@ private ChatResponseDto handleStepRecommendation(ChatRequestDto requestDto) { break; case 3: - stepData = getFinalRecommendations( + stepData = null; + message = "좋아요! 이제 원하는 칵테일 스타일을 자유롭게 말씀해주세요 💬\n 없으면 'x', 또는 '없음' 과 같이 입력해주세요!"; + type = MessageType.INPUT; + break; + + case 4: + stepData = getFinalRecommendationsWithMessage( parseAlcoholStrength(requestDto.getSelectedAlcoholStrength()), parseAlcoholBaseType(requestDto.getSelectedAlcoholBaseType()), - null + requestDto.getMessage() ); message = stepData.getStepTitle(); type = MessageType.CARD_LIST; // 최종 추천은 카드 리스트 @@ -532,7 +537,7 @@ private ChatResponseDto handleStepRecommendation(ChatRequestDto requestDto) { // 메타데이터 포함 ChatResponseDto.MetaData metaData = ChatResponseDto.MetaData.builder() .currentStep(currentStep) - .totalSteps(3) + .totalSteps(4) .isTyping(true) .delay(300) .build(); @@ -632,20 +637,20 @@ private StepRecommendationResponseDto getAlcoholBaseTypeOptions(AlcoholStrength } - private StepRecommendationResponseDto getFinalRecommendations( + private StepRecommendationResponseDto getFinalRecommendationsWithMessage( AlcoholStrength alcoholStrength, AlcoholBaseType alcoholBaseType, - CocktailType cocktailType) { + String userMessage) { // 필터링 조건에 맞는 칵테일 검색 // "ALL" 선택 시 해당 필터를 null로 처리하여 전체 검색 List strengths = (alcoholStrength == null) ? null : List.of(alcoholStrength); List baseTypes = (alcoholBaseType == null) ? null : List.of(alcoholBaseType); - List cocktailTypes = (cocktailType == null) ? null : List.of(cocktailType); + // userMessage를 키워드로 사용하여 검색 Page cocktailPage = cocktailRepository.searchWithFilters( - null, // 키워드 없음 + userMessage, // 사용자 입력 메시지를 키워드로 사용 strengths, - cocktailTypes, + null, // cocktailType 사용 안 함 baseTypes, PageRequest.of(0, 3) // 최대 3개 추천 ); @@ -668,7 +673,7 @@ private StepRecommendationResponseDto getFinalRecommendations( "마음에 드는 칵테일은 '킵' 버튼을 눌러 나만의 Bar에 저장해보세요!"; return new StepRecommendationResponseDto( - 3, + 4, stepTitle, null, recommendations, From 683c0157325e0e0ac7c573d3f97bf6dbefe62370 Mon Sep 17 00:00:00 2001 From: seungwookc97 Date: Thu, 2 Oct 2025 16:46:21 +0900 Subject: [PATCH 11/45] =?UTF-8?q?chore=20:=20=ED=85=8C=EB=9D=BC=ED=8F=BC?= =?UTF-8?q?=20ingress=20=EC=84=A4=EC=A0=95=20http,https,nginx=20=EA=B4=80?= =?UTF-8?q?=EB=A6=AC=EC=9E=90=20=ED=8E=98=EC=9D=B4=EC=A7=80=EB=A7=8C=20?= =?UTF-8?q?=ED=97=88=EC=9A=A9=ED=95=98=EB=8F=84=EB=A1=9D=20=EB=B3=80?= =?UTF-8?q?=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- terraform/main.tf | 30 ++++++++++++++++++++++++------ 1 file changed, 24 insertions(+), 6 deletions(-) diff --git a/terraform/main.tf b/terraform/main.tf index 2e43504c..93093b37 100644 --- a/terraform/main.tf +++ b/terraform/main.tf @@ -113,17 +113,35 @@ resource "aws_route_table_association" "association_4" { resource "aws_security_group" "sg_1" { name = "${var.prefix}-sg-1" + # HTTP 허용 ingress { - from_port = 0 - to_port = 0 - protocol = "all" + from_port = 80 + to_port = 80 + protocol = "tcp" cidr_blocks = ["0.0.0.0/0"] } + # HTTPS 허용 + ingress { + from_port = 443 + to_port = 443 + protocol = "tcp" + cidr_blocks = ["0.0.0.0/0"] + } + + # Nginx Proxy Manager 관리자 페이지 + ingress { + from_port = 81 + to_port = 81 + protocol = "tcp" + cidr_blocks = ["0.0.0.0/0"] + } + + # Egress - 외부로 나가는 트래픽은 허용 (패키지 다운로드, API 호출 등) egress { - from_port = 0 - to_port = 0 - protocol = "all" + from_port = 0 + to_port = 0 + protocol = "-1" cidr_blocks = ["0.0.0.0/0"] } From 9c18e5f056dfb84916a54dede63c9c7bc31e6315 Mon Sep 17 00:00:00 2001 From: seungwookc97 Date: Thu, 2 Oct 2025 17:37:19 +0900 Subject: [PATCH 12/45] Create README.md --- README.md | 230 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 230 insertions(+) create mode 100644 README.md diff --git a/README.md b/README.md new file mode 100644 index 00000000..9df7777e --- /dev/null +++ b/README.md @@ -0,0 +1,230 @@ +# 🍹 Ssoul - 칵테일 레시피 공유 플랫폼 + +## 🔗 서비스 링크 +**홈페이지**: [https://ssoul.life](https://ssoul.life) + +## 📌 개요 +본 시스템은 사용자가 칵테일 레시피를 공유하고, AI 바텐더 '쑤리'를 통해 맞춤형 칵테일을 추천받으며, 칵테일 문화를 즐길 수 있는 웹 서비스입니다. +칵테일 입문자부터 애호가까지 모든 사용자를 대상으로, 단순한 레시피 제공을 넘어 AI 챗봇을 통한 인터랙티브한 칵테일 추천과 커뮤니티 기능을 제공합니다. +또한 사용자 활동 기반 등급 시스템(ABV 도수)과 MyBar(킵) 기능을 통해 꾸준한 참여를 유도하도록 설계했습니다. +Spring Boot, Spring AI, OAuth2, SSE, AWS S3 등을 통합해 운영하며, +칵테일 문화 확산과 사용자 간 레시피 공유를 통한 커뮤니티 활성화를 목표로 합니다. + +--- + +## 👥 팀원 및 역할 + +| 팀원 | 역할 | 담당 업무 | +|------|------|-----------| +| 정용진 | Backend(PM) | AI 챗봇 '쑤리', 단계별 추천 시스템 | +| 이광원 | Backend(팀장) / Frontend | 칵테일 도메인, 검색/필터링, 상세 조회 | +| 석근호 | Backend / Frontend | 커뮤니티(게시판/댓글), S3, 파일 업로드 | +| 최승욱 | Backend / Frontend | 인증/인가, 소셜로그인(OAuth2), JWT, 테라폼 | +| 홍민애 | Backend | MyBar(킵) 기능, 알림 시스템(SSE) | + +--- + +## 🛠 기술 스택 +- **Backend**: Java 21, Spring Boot 3.5.5 +- **Database**: MySQL 8.x / JPA / H2 (개발) +- **AI 연동**: Spring AI, Gemini API +- **인증**: Spring Security, OAuth2 (Kakao, Google, Naver), JWT +- **파일 저장**: AWS S3 +- **실시간 통신**: SSE (Server-Sent Events) +- **캐싱**: Redis +- **API 문서**: Swagger (SpringDoc OpenAPI) + +--- + +## 🔄 핵심 기능 프로세스 + +### 1️⃣ AI 챗봇 '쑤리' 칵테일 추천 프로세스 + +```mermaid +sequenceDiagram + participant User as 사용자 + participant Controller as ChatbotController + participant Service as ChatbotService + participant AI as Spring AI (Gemini) + participant DB as Database + participant Cocktail as CocktailRepository + + User->>Controller: 대화 시작/메시지 전송 + Controller->>Service: 메시지 처리 요청 + + alt 단계별 추천 모드 + Service->>Service: 추천 단계 확인 + Service->>Cocktail: 필터 조건별 칵테일 조회 + Cocktail-->>Service: 추천 칵테일 목록 + Service->>DB: 대화 이력 저장 + else 일반 대화 모드 + Service->>DB: 최근 대화 이력 조회 (5건) + DB-->>Service: 대화 컨텍스트 + Service->>AI: 프롬프트 + 컨텍스트 전송 + AI-->>Service: AI 응답 생성 + Service->>DB: 응답 저장 + end + + Service-->>Controller: 응답 DTO + Controller-->>User: 칵테일 추천/정보 제공 +``` + +### 2️⃣ MyBar (킵) 기능 프로세스 + +```mermaid +sequenceDiagram + participant User as 사용자 + participant Controller as MyBarController + participant Service as MyBarService + participant ABV as AbvScoreService + participant DB as MyBarRepository + + User->>Controller: 칵테일 킵 요청 + Controller->>Service: keep(userId, cocktailId) + + Service->>DB: 기존 킵 확인 + DB-->>Service: 킵 상태 반환 + + alt 신규 킵 + Service->>DB: MyBar 엔티티 생성 + Service->>ABV: 활동 점수 +0.1 + else 킵 복원 (DELETED → ACTIVE) + Service->>DB: 상태 변경 & keptAt 갱신 + Service->>ABV: 활동 점수 +0.1 + else 이미 킵된 상태 + Service->>DB: keptAt만 갱신 + end + + DB-->>Service: 저장 완료 + Service-->>Controller: 성공 응답 + Controller-->>User: 201 Created +``` + +### 3️⃣ 실시간 알림 시스템 (SSE) + +```mermaid +sequenceDiagram + participant Client as 클라이언트 + participant Controller as NotificationController + participant Service as NotificationService + participant Emitter as SseEmitter + participant EventBus as ApplicationEventPublisher + participant PostService as PostService + + Client->>Controller: SSE 구독 요청 + Controller->>Service: subscribe() + Service->>Service: 사용자별 Emitter 생성 + Service->>Emitter: 연결 이벤트 전송 + Service-->>Controller: SseEmitter 반환 + Controller-->>Client: SSE 스트림 연결 + + Note over Client,Emitter: SSE 연결 유지 + + PostService->>EventBus: 댓글/좋아요 이벤트 발행 + EventBus-->>Service: 이벤트 수신 + Service->>Service: 알림 생성 및 저장 + Service->>Emitter: 실시간 알림 전송 + Emitter-->>Client: 알림 수신 +``` + +## 📂 디렉토리 구조 +```plaintext +src +└── main + ├── java + │ └── com.back + │ ├── domain # 도메인별 핵심 비즈니스 로직 + │ │ ├── user # 사용자 관련 + │ │ ├── cocktail # 칵테일 레시피 + │ │ ├── chatbot # AI 챗봇 '쑤리' + │ │ ├── mybar # MyBar (킵) 기능 + │ │ ├── post # 게시판 관련 + │ │ │ ├── category # 카테고리 + │ │ │ ├── comment # 댓글 + │ │ │ └── post # 게시글 + │ │ └── notification # 알림 시스템 + │ └── global # 전역 모듈 + │ ├── ai # Spring AI 설정 + │ ├── exception # 예외 처리 + │ ├── file # 파일 업로드 (S3) + │ ├── jwt # JWT 인증 + │ ├── oauth2 # OAuth2 소셜 로그인 + │ ├── rq # Request 컨텍스트 + │ ├── rsData # Response 표준화 + │ ├── security # Spring Security 설정 + │ └── util # 유틸리티 + └── resources + ├── prompts # AI 프롬프트 + │ ├── chatbot-system-prompt.txt + │ └── chatbot-response-rules.txt + ├── application.yml # 메인 설정 + ├── application-dev.yml # 개발 환경 + ├── application-prod.yml # 운영 환경 + └── cocktails.csv # 칵테일 초기 데이터 +``` + +--- + +## 🎯 주요 기능 + +### 1. 칵테일 도메인 +- **칵테일 조회**: 무한스크롤 기반 목록 조회 +- **상세 정보**: 레시피, 재료, 제조법, 스토리 +- **검색/필터링**: 도수, 베이스, 타입별 필터링 +- **공유 기능**: 칵테일 레시피 공유 링크 생성 + +### 2. AI 챗봇 '쑤리' +- **자연어 대화**: 칵테일 관련 질문 응답 +- **맞춤 추천**: 기분, 상황, 취향별 칵테일 추천 +- **단계별 추천**: 도수 → 베이스 → 스타일 선택 +- **대화 컨텍스트**: 최근 5개 대화 기반 응답 + +### 3. MyBar (킵) +- **칵테일 킵**: 좋아하는 칵테일 저장 +- **무한스크롤**: 커서 기반 페이지네이션 +- **소프트 삭제**: 킵 해제 후 복원 가능 +- **활동 점수**: 킵/언킵 시 ABV 점수 변동 + +### 4. 커뮤니티 +- **게시판**: 카테고리별 게시글 CRUD +- **댓글**: 게시글 댓글 작성/조회 +- **좋아요**: 게시글 추천 기능 +- **태그**: 해시태그 기반 분류 + +### 5. 알림 시스템 +- **실시간 알림**: SSE 기반 실시간 푸시 +- **알림 타입**: 댓글, 좋아요, 팔로우 등 +- **읽음 처리**: 알림 확인 후 자동 이동 +- **무한스크롤**: 알림 목록 페이지네이션 + +### 6. 인증/인가 +- **소셜 로그인**: Kakao, Google, Naver OAuth2 +- **JWT 토큰**: Access/Refresh Token 관리 +- **Spring Security**: 권한 기반 접근 제어 +- **쿠키 인증**: Secure, HttpOnly, SameSite + +--- + +## 🔐 보안 설정 +- **CORS**: 프론트엔드 도메인만 허용 +- **JWT**: 15분 Access, 30일 Refresh +- **쿠키**: Secure(HTTPS), HttpOnly, SameSite +- **OAuth2**: 소셜 로그인 프로바이더별 설정 +- **예외 처리**: 전역 예외 핸들러 + +--- + +## 📈 성능 최적화 +- **비동기 처리**: CompletableFuture 활용 +- **캐싱**: Redis 세션 스토어 +- **커서 페이징**: Offset 대신 커서 기반 +- **Lazy Loading**: JPA 지연 로딩 +- **인덱싱**: 검색 필드 DB 인덱스 + +--- + +## 📊 데이터베이스 스키마 + +image + +--- From 5de540a337c70e2e9fcbe9b03f7e6cb29182e551 Mon Sep 17 00:00:00 2001 From: GerHerMo <138726125+GerHerMo@users.noreply.github.com> Date: Sat, 4 Oct 2025 15:33:37 +0900 Subject: [PATCH 13/45] =?UTF-8?q?[fix]=20=EC=B1=97=EB=B4=87=20input=20?= =?UTF-8?q?=EB=8D=B0=EC=9D=B4=ED=84=B0=20=EC=9E=91=EB=8F=99=20=EC=A0=95?= =?UTF-8?q?=EC=83=81=ED=99=94=20#240=20(#242)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: stepData modified * feat: add 'x' to keyword set null * refactor: step3 msg edit --- .../chatbot/service/ChatbotService.java | 21 ++++++++++++++++--- 1 file changed, 18 insertions(+), 3 deletions(-) diff --git a/src/main/java/com/back/domain/chatbot/service/ChatbotService.java b/src/main/java/com/back/domain/chatbot/service/ChatbotService.java index 8da9230c..e364df38 100644 --- a/src/main/java/com/back/domain/chatbot/service/ChatbotService.java +++ b/src/main/java/com/back/domain/chatbot/service/ChatbotService.java @@ -495,8 +495,14 @@ private ChatResponseDto handleStepRecommendation(ChatRequestDto requestDto) { break; case 3: - stepData = null; - message = "좋아요! 이제 원하는 칵테일 스타일을 자유롭게 말씀해주세요 💬\n 없으면 'x', 또는 '없음' 과 같이 입력해주세요!"; + stepData = new StepRecommendationResponseDto( + 3, + null, + null, + null, + false + ); + message = "좋아요! 이제 원하는 칵테일 스타일을 자유롭게 말씀해주세요 💬\n 없으면 'x', 또는 '없음' 을 입력해주세요!"; type = MessageType.INPUT; break; @@ -646,9 +652,18 @@ private StepRecommendationResponseDto getFinalRecommendationsWithMessage( List strengths = (alcoholStrength == null) ? null : List.of(alcoholStrength); List baseTypes = (alcoholBaseType == null) ? null : List.of(alcoholBaseType); + // 'x', '없음' 입력 시 키워드 조건 무시 + String keyword = null; + if (userMessage != null && !userMessage.trim().isEmpty()) { + String trimmed = userMessage.trim().toLowerCase(); + if (!trimmed.equals("x") && !trimmed.equals("없음")) { + keyword = userMessage; + } + } + // userMessage를 키워드로 사용하여 검색 Page cocktailPage = cocktailRepository.searchWithFilters( - userMessage, // 사용자 입력 메시지를 키워드로 사용 + keyword, // 'x', '없음'이면 null, 아니면 사용자 입력 메시지 strengths, null, // cocktailType 사용 안 함 baseTypes, From a9ccd1aaebd9ba35d9161403e272e38fac666bab Mon Sep 17 00:00:00 2001 From: SeokGeunHo <45616952+seok6555@users.noreply.github.com> Date: Sat, 4 Oct 2025 15:33:52 +0900 Subject: [PATCH 14/45] =?UTF-8?q?[fix]=20=EC=82=AD=EC=A0=9C=EB=90=9C=20?= =?UTF-8?q?=EB=8C=93=EA=B8=80=20=ED=95=84=ED=84=B0=EB=A7=81,=20=EC=A1=B0?= =?UTF-8?q?=ED=9A=8C=EC=88=98=20=EC=A6=9D=EA=B0=80=20=EB=A1=9C=EC=A7=81=20?= =?UTF-8?q?=EB=B2=84=EA=B7=B8=20=EC=88=98=EC=A0=95=20(#244)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../post/comment/repository/CommentRepository.java | 10 ++++++---- .../domain/post/comment/service/CommentService.java | 4 ++-- .../com/back/domain/post/post/service/PostService.java | 2 +- 3 files changed, 9 insertions(+), 7 deletions(-) diff --git a/src/main/java/com/back/domain/post/comment/repository/CommentRepository.java b/src/main/java/com/back/domain/post/comment/repository/CommentRepository.java index 97fa3c8a..f141c1ee 100644 --- a/src/main/java/com/back/domain/post/comment/repository/CommentRepository.java +++ b/src/main/java/com/back/domain/post/comment/repository/CommentRepository.java @@ -1,15 +1,17 @@ package com.back.domain.post.comment.repository; import com.back.domain.post.comment.entity.Comment; +import com.back.domain.post.comment.enums.CommentStatus; +import java.util.List; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.stereotype.Repository; -import java.util.List; - @Repository public interface CommentRepository extends JpaRepository { - List findTop10ByPostIdOrderByIdDesc(Long postId); + // 첫 페이지 (lastId == null) + List findTop10ByPostIdAndStatusNotOrderByIdDesc(Long postId, CommentStatus status); - List findTop10ByPostIdAndIdLessThanOrderByIdDesc(Long postId, Long lastId); + // 무한스크롤 (lastId != null) + List findTop10ByPostIdAndIdLessThanAndStatusNotOrderByIdDesc(Long postId, Long id, CommentStatus status); } diff --git a/src/main/java/com/back/domain/post/comment/service/CommentService.java b/src/main/java/com/back/domain/post/comment/service/CommentService.java index dff24687..1d0aaca6 100644 --- a/src/main/java/com/back/domain/post/comment/service/CommentService.java +++ b/src/main/java/com/back/domain/post/comment/service/CommentService.java @@ -68,12 +68,12 @@ public CommentResponseDto createComment(Long postId, CommentCreateRequestDto req @Transactional(readOnly = true) public List getComments(Long postId, Long lastId) { if (lastId == null) { - return commentRepository.findTop10ByPostIdOrderByIdDesc(postId) + return commentRepository.findTop10ByPostIdAndStatusNotOrderByIdDesc(postId, CommentStatus.DELETED) .stream() .map(CommentResponseDto::new) .toList(); } else { - return commentRepository.findTop10ByPostIdAndIdLessThanOrderByIdDesc(postId, lastId) + return commentRepository.findTop10ByPostIdAndIdLessThanAndStatusNotOrderByIdDesc(postId, lastId, CommentStatus.DELETED) .stream() .map(CommentResponseDto::new) .toList(); diff --git a/src/main/java/com/back/domain/post/post/service/PostService.java b/src/main/java/com/back/domain/post/post/service/PostService.java index 5dbbe264..b5a2161f 100644 --- a/src/main/java/com/back/domain/post/post/service/PostService.java +++ b/src/main/java/com/back/domain/post/post/service/PostService.java @@ -116,7 +116,7 @@ public List getPosts(PostSortScrollRequestDto reqBody) { } // 게시글 단건 조회 로직 - @Transactional(readOnly = true) + @Transactional public PostResponseDto getPost(Long postId) { Post post = postRepository.findById(postId) .orElseThrow(() -> new NoSuchElementException("해당 게시글을 찾을 수 없습니다. ID: " + postId)); From aa496f67a6f7453ad825aedaa435000cab556c51 Mon Sep 17 00:00:00 2001 From: flatCheese <71163228+lkw9241@users.noreply.github.com> Date: Thu, 9 Oct 2025 10:23:54 +0900 Subject: [PATCH 15/45] =?UTF-8?q?[Refactor]=EC=B9=B5=ED=85=8C=EC=9D=BC=20?= =?UTF-8?q?=EC=A1=B0=ED=9A=8C=20=EC=A1=B0=EA=B1=B4=20=EC=B6=94=EA=B0=80,?= =?UTF-8?q?=20cocktail=20Story=20=EA=B4=80=EB=A0=A8=20cocktailPreview=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80=20(#247)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix : bugs of testCase, init data * fix : bug * refactor : add cocktailPreview field in cocktailSearchResponseDto * refactor : add getCocktails conditions --- .../controller/CocktailController.java | 7 +- .../dto/CocktailSearchResponseDto.java | 54 +++++++-------- .../repository/CocktailRepository.java | 48 ++++++++++++-- .../cocktail/service/CocktailService.java | 65 ++++++++----------- 4 files changed, 104 insertions(+), 70 deletions(-) diff --git a/src/main/java/com/back/domain/cocktail/controller/CocktailController.java b/src/main/java/com/back/domain/cocktail/controller/CocktailController.java index a389f05a..6c2268af 100644 --- a/src/main/java/com/back/domain/cocktail/controller/CocktailController.java +++ b/src/main/java/com/back/domain/cocktail/controller/CocktailController.java @@ -31,6 +31,7 @@ public RsData getCocktailDetailById(@PathVariable lon return RsData.successOf(cocktailDetailResponseDto); } + // @param lastValue 다음 페이지에서 이 값보다 작은 항목만 가져오기 위해 사용 // @param lastId 마지막으로 가져온 칵테일 ID (첫 요청 null 가능) // @param size 가져올 데이터 개수 (기본값 DEFAULT_SIZE) // @return RsData 형태의 칵테일 요약 정보 리스트 @@ -38,10 +39,12 @@ public RsData getCocktailDetailById(@PathVariable lon @Transactional @Operation(summary = "칵테일 다건 조회") public RsData> getCocktails( + @RequestParam(value = "lastValue", required = false) Long lastValue, @RequestParam(value = "lastId", required = false) Long lastId, - @RequestParam(value = "size", required = false) Integer size + @RequestParam(value = "size", required = false) Integer size, + @RequestParam(value = "sortBy", required = false, defaultValue = "recent") String sortBy ) { - List cocktails = cocktailService.getCocktails(lastId, size); + List cocktails = cocktailService.getCocktails(lastValue, lastId, size, sortBy); return RsData.successOf(cocktails); } diff --git a/src/main/java/com/back/domain/cocktail/dto/CocktailSearchResponseDto.java b/src/main/java/com/back/domain/cocktail/dto/CocktailSearchResponseDto.java index 06078a19..63e10c87 100644 --- a/src/main/java/com/back/domain/cocktail/dto/CocktailSearchResponseDto.java +++ b/src/main/java/com/back/domain/cocktail/dto/CocktailSearchResponseDto.java @@ -1,34 +1,34 @@ package com.back.domain.cocktail.dto; -import lombok.Getter; -import lombok.NoArgsConstructor; -import lombok.Setter; +import com.back.domain.cocktail.entity.Cocktail; -@Getter -@Setter -@NoArgsConstructor -public class CocktailSearchResponseDto { +public record CocktailSearchResponseDto ( - private long cocktailId; - private String cocktailName; - private String cocktailNameKo; - private String alcoholStrength; - private String cocktailType; - private String alcoholBaseType; - private String cocktailImgUrl; - private String cocktailStory; + Long cocktailId, + String cocktailName, + String cocktailNameKo, + String alcoholStrength, + String cocktailType, + String alcoholBaseType, + String cocktailImgUrl, + String cocktailStory, + String cocktailPreview +){ + public static CocktailSearchResponseDto from(Cocktail cocktail){ + String preview =cocktail.getCocktailStory().length() >80 ? + cocktail.getCocktailStory().substring(0,80)+"..." + : cocktail.getCocktailStory(); - public CocktailSearchResponseDto(long cocktailId, String cocktailName, String cocktailNameKo, - String alcoholStrength, String cocktailType, - String alcoholBaseType, String cocktailImgUrl, - String cocktailStory) { - this.cocktailId = cocktailId; - this.cocktailName = cocktailName; - this.cocktailNameKo = cocktailNameKo; - this.alcoholStrength = alcoholStrength; - this.cocktailType = cocktailType; - this.alcoholBaseType = alcoholBaseType; - this.cocktailImgUrl = cocktailImgUrl; - this.cocktailStory = cocktailStory; + return new CocktailSearchResponseDto( + cocktail.getId(), + cocktail.getCocktailName(), + cocktail.getCocktailNameKo(), + cocktail.getAlcoholStrength().getDescription(), + cocktail.getCocktailType().getDescription(), + cocktail.getAlcoholBaseType().getDescription(), + cocktail.getCocktailImgUrl(), + cocktail.getCocktailStory(), + preview + ); } } \ No newline at end of file diff --git a/src/main/java/com/back/domain/cocktail/repository/CocktailRepository.java b/src/main/java/com/back/domain/cocktail/repository/CocktailRepository.java index 0695c469..a3973f98 100644 --- a/src/main/java/com/back/domain/cocktail/repository/CocktailRepository.java +++ b/src/main/java/com/back/domain/cocktail/repository/CocktailRepository.java @@ -16,14 +16,54 @@ @Repository public interface CocktailRepository extends JpaRepository { - // 첫 요청 → 최신순(내림차순)으로 정렬해서 가져오기 + // 전체조회 : 최신순 List findAllByOrderByIdDesc(Pageable pageable); - - // 무한스크롤 → lastId보다 작은 ID들 가져오기 List findByIdLessThanOrderByIdDesc(Long lastId, Pageable pageable); - List findByCocktailNameContainingIgnoreCaseOrIngredientContainingIgnoreCase(String cocktailName, String ingredient); + // 전체 조회: keepsCount 기준 내림차순 + @Query(""" + SELECT c FROM Cocktail c + LEFT JOIN MyBar m ON m.cocktail = c AND m.status = 'ACTIVE' + GROUP BY c.id + ORDER BY COUNT(m) DESC, c.id DESC + """) + List findAllOrderByKeepCountDesc(Pageable pageable); + + // 무한스크롤 조회: lastKeepCount 이하 + @Query(""" + SELECT c FROM Cocktail c + LEFT JOIN MyBar m ON m.cocktail = c AND m.status = 'ACTIVE' + GROUP BY c.id + HAVING COUNT(m) < :lastKeepCount OR (COUNT(m) = :lastKeepCount AND c.id < :lastId) + ORDER BY COUNT(m) DESC, c.id DESC +""") + List findByKeepCountLessThanOrderByKeepCountDesc( + @Param("lastKeepCount") Long lastKeepCount, + @Param("lastId") Long lastId, + Pageable pageable + ); + + // 댓글순 + @Query("SELECT c FROM Cocktail c " + + "LEFT JOIN CocktailComment cm ON cm.cocktail = c " + + "GROUP BY c.id " + + "ORDER BY COUNT(cm) DESC, c.id DESC") + List findAllOrderByCommentsCountDesc(Pageable pageable); + + @Query(""" + SELECT c FROM Cocktail c + LEFT JOIN CocktailComment cm ON cm.cocktail = c + GROUP BY c.id + HAVING COUNT(cm) < :lastCommentsCount OR (COUNT(cm) = :lastCommentsCount AND c.id < :lastId) + ORDER BY COUNT(cm) DESC, c.id DESC + """) + List findByCommentsCountLessThanOrderByCommentsCountDesc( + @Param("lastCommentsCount") Long lastCommentsCount, + @Param("lastId") Long lastId, + Pageable pageable + ); + //검색, 필터 @Query("SELECT c FROM Cocktail c " + "WHERE (:keyword IS NULL OR :keyword = '' OR " + " LOWER(c.cocktailName) LIKE LOWER(CONCAT('%', :keyword, '%')) OR " + diff --git a/src/main/java/com/back/domain/cocktail/service/CocktailService.java b/src/main/java/com/back/domain/cocktail/service/CocktailService.java index 4865fcec..f302310b 100644 --- a/src/main/java/com/back/domain/cocktail/service/CocktailService.java +++ b/src/main/java/com/back/domain/cocktail/service/CocktailService.java @@ -37,37 +37,41 @@ public Cocktail getCocktailById(Long id) { .orElseThrow(() -> new IllegalArgumentException("Cocktail not found. id=" + id)); } - // 칵테일 무한스크롤 조회 @Transactional(readOnly = true) - public List getCocktails(Long lastId, Integer size) { // 무한스크롤 조회, 클라이언트 쪽에서 lastId와 size 정보를 받음.(스크롤 이벤트) + public List getCocktails(Long lastValue, Long lastId, Integer size, String sortBy) { int fetchSize = (size != null) ? size : DEFAULT_SIZE; - + Pageable pageable = PageRequest.of(0, fetchSize); List cocktails; - if (lastId == null) { - // 첫 요청 → 최신 데이터부터 - cocktails = cocktailRepository.findAllByOrderByIdDesc(PageRequest.of(0, fetchSize)); - } else { - // 무한스크롤 → 마지막 ID보다 작은 데이터 조회 - cocktails = cocktailRepository.findByIdLessThanOrderByIdDesc(lastId, PageRequest.of(0, fetchSize)); + + switch (sortBy != null ? sortBy.toLowerCase() : "") { + case "keeps": + cocktails = (lastValue == null) + ? cocktailRepository.findAllOrderByKeepCountDesc(pageable) + : cocktailRepository.findByKeepCountLessThanOrderByKeepCountDesc(lastValue, lastId, pageable); + break; + case "comments": + cocktails = (lastValue == null) + ? cocktailRepository.findAllOrderByCommentsCountDesc(pageable) + : cocktailRepository.findByCommentsCountLessThanOrderByCommentsCountDesc(lastValue, lastId, pageable); + break; + default: + cocktails = (lastValue == null) + ? cocktailRepository.findAllByOrderByIdDesc(pageable) + : cocktailRepository.findByIdLessThanOrderByIdDesc(lastValue, pageable); + break; } + return cocktails.stream() - .map(c -> new CocktailSummaryResponseDto(c.getId(), c.getCocktailName(), c.getCocktailNameKo(), c.getCocktailImgUrl(), c.getAlcoholStrength().getDescription())) + .map(c -> new CocktailSummaryResponseDto( + c.getId(), + c.getCocktailName(), + c.getCocktailNameKo(), + c.getCocktailImgUrl(), + c.getAlcoholStrength().getDescription() + )) .collect(Collectors.toList()); } - // 칵테일 검색기능 - @Transactional(readOnly = true) - public List cocktailSearch(String keyword) { - // cockTailName, ingredient이 하나만 있을 수도 있고 둘 다 있을 수도 있음 - if (keyword == null || keyword.trim().isEmpty()) { - // 아무 검색어 없으면 전체 반환 처리 - return cocktailRepository.findAll(); - } else { - // 이름 또는 재료 둘 중 하나라도 매칭되면 결과 반환 - return cocktailRepository.findByCocktailNameContainingIgnoreCaseOrIngredientContainingIgnoreCase(keyword, keyword); - } - } - // 칵테일 검색,필터기능 @Transactional(readOnly = true) public List searchAndFilter(CocktailSearchRequestDto cocktailSearchRequestDto) { @@ -105,25 +109,12 @@ public List searchAndFilter(CocktailSearchRequestDto //Cocktail 엔티티 → CocktailResponseDto 응답 DTO로 바꿔주는 과정 List resultDtos = pageResult.stream() - .map(c -> new CocktailSearchResponseDto( - c.getId(), - c.getCocktailName(), - c.getCocktailNameKo(), - c.getAlcoholStrength().getDescription(), - c.getCocktailType().getDescription(), - c.getAlcoholBaseType().getDescription(), - c.getCocktailImgUrl(), - c.getCocktailStory() - )) + .map(CocktailSearchResponseDto::from) .collect(Collectors.toList()); return resultDtos; } -// private List nullIfEmpty(List list) { -// return CollectionUtils.isEmpty(list) ? null : list; -// } - // 칵테일 상세조회 @Transactional(readOnly = true) public CocktailDetailResponseDto getCocktailDetailById(Long cocktailId) { From ec11adeff52c9f51ca6b4776a60d0f55be86a061 Mon Sep 17 00:00:00 2001 From: flatCheese <71163228+lkw9241@users.noreply.github.com> Date: Thu, 9 Oct 2025 10:25:14 +0900 Subject: [PATCH 16/45] =?UTF-8?q?[refactor]=20=EC=B9=B5=ED=85=8C=EC=9D=BC?= =?UTF-8?q?=20=ED=95=9C=EA=B8=80=EC=9D=B4=EB=A6=84=20=EA=B2=80=EC=83=89=20?= =?UTF-8?q?=EA=B8=B0=EB=8A=A5=EC=B6=94=EA=B0=80,=20=EA=B2=80=EC=83=89?= =?UTF-8?q?=EA=B2=B0=EA=B3=BC=20=EC=9D=B4=ED=9B=84=20=EC=A0=84=EC=B2=B4=20?= =?UTF-8?q?=EB=A6=AC=EC=8A=A4=ED=8A=B8=20=EB=82=98=EC=98=A4=EB=8A=94=20?= =?UTF-8?q?=EC=9B=90=EC=9D=B8=20=EA=B2=80=ED=86=A0.#248=20(#249)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix : bugs of testCase, init data * fix : bug * refactor : add cocktailPreview field in cocktailSearchResponseDto * refactor : add getCocktails conditions * refactor : add function of searching korean name --- .../com/back/domain/cocktail/repository/CocktailRepository.java | 1 + 1 file changed, 1 insertion(+) diff --git a/src/main/java/com/back/domain/cocktail/repository/CocktailRepository.java b/src/main/java/com/back/domain/cocktail/repository/CocktailRepository.java index a3973f98..8187f9c3 100644 --- a/src/main/java/com/back/domain/cocktail/repository/CocktailRepository.java +++ b/src/main/java/com/back/domain/cocktail/repository/CocktailRepository.java @@ -67,6 +67,7 @@ List findByCommentsCountLessThanOrderByCommentsCountDesc( @Query("SELECT c FROM Cocktail c " + "WHERE (:keyword IS NULL OR :keyword = '' OR " + " LOWER(c.cocktailName) LIKE LOWER(CONCAT('%', :keyword, '%')) OR " + + " LOWER(c.cocktailNameKo) LIKE LOWER(CONCAT('%', :keyword, '%')) OR " + " LOWER(c.ingredient) LIKE LOWER(CONCAT('%', :keyword, '%')))" + " AND (:strengths IS NULL OR c.alcoholStrength IN :strengths) " + // 알코올 도수 필터를 담당 " AND (:types IS NULL OR c.cocktailType IN :types) " + // 칵테일 타입 필터를 담당 From cf4dd96efd41c26969221d2a29dd50ea115208a0 Mon Sep 17 00:00:00 2001 From: develosopher Date: Thu, 9 Oct 2025 10:46:12 +0900 Subject: [PATCH 17/45] =?UTF-8?q?feat:=20MyBarItemResponseDto=EC=97=90=20?= =?UTF-8?q?=EC=B9=B5=ED=85=8C=EC=9D=BC=20=ED=95=9C=EA=B8=80=20=EC=9D=B4?= =?UTF-8?q?=EB=A6=84=20=EB=B0=8F=20=EB=8F=84=EC=88=98=20=ED=95=84=EB=93=9C?= =?UTF-8?q?=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/com/back/domain/mybar/dto/MyBarItemResponseDto.java | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/main/java/com/back/domain/mybar/dto/MyBarItemResponseDto.java b/src/main/java/com/back/domain/mybar/dto/MyBarItemResponseDto.java index 8603cf6f..eea56894 100644 --- a/src/main/java/com/back/domain/mybar/dto/MyBarItemResponseDto.java +++ b/src/main/java/com/back/domain/mybar/dto/MyBarItemResponseDto.java @@ -1,5 +1,6 @@ package com.back.domain.mybar.dto; +import com.back.domain.cocktail.enums.AlcoholStrength; import com.back.domain.mybar.entity.MyBar; import lombok.Builder; import lombok.Getter; @@ -12,6 +13,8 @@ public class MyBarItemResponseDto { private Long id; private Long cocktailId; private String cocktailName; + private String cocktailNameKo; // 칵테일의 한글 표기 이름 + private AlcoholStrength alcoholStrength; // 도수 레이블로 쓰이는 알코올 강도 private String imageUrl; private LocalDateTime createdAt; private LocalDateTime keptAt; @@ -21,6 +24,8 @@ public static MyBarItemResponseDto from(MyBar m) { .id(m.getId()) .cocktailId(m.getCocktail().getId()) .cocktailName(m.getCocktail().getCocktailName()) + .cocktailNameKo(m.getCocktail().getCocktailNameKo()) + .alcoholStrength(m.getCocktail().getAlcoholStrength()) .imageUrl(m.getCocktail().getCocktailImgUrl()) .createdAt(m.getCreatedAt()) .keptAt(m.getKeptAt()) From fdcc97e4b038996b29dbd3a901e6547578a4001d Mon Sep 17 00:00:00 2001 From: develosopher Date: Thu, 9 Oct 2025 10:47:32 +0900 Subject: [PATCH 18/45] =?UTF-8?q?test:=20MyBarControllerTest=EC=97=90=20?= =?UTF-8?q?=EB=B3=80=EA=B2=BD=EB=90=9C=20MyBarItemResponseDto=20=ED=95=84?= =?UTF-8?q?=EB=93=9C=20=EB=B0=98=EC=98=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 테스트의 `MyBarItemResponseDto` 목(Mock) 객체에 `cocktailNameKo` 및 `alcoholStrength` 필드를 추가 - 응답 데이터 직렬화 및 형식 검증을 위해 JSON 응답 검증(`jsonPath`) 로직에 새로운 필드 검증을 추가 --- .../domain/mybar/controller/MyBarControllerTest.java | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/src/test/java/com/back/domain/mybar/controller/MyBarControllerTest.java b/src/test/java/com/back/domain/mybar/controller/MyBarControllerTest.java index 463ba129..b006e39a 100644 --- a/src/test/java/com/back/domain/mybar/controller/MyBarControllerTest.java +++ b/src/test/java/com/back/domain/mybar/controller/MyBarControllerTest.java @@ -1,5 +1,6 @@ package com.back.domain.mybar.controller; +import com.back.domain.cocktail.enums.AlcoholStrength; import com.back.domain.mybar.dto.MyBarItemResponseDto; import com.back.domain.mybar.dto.MyBarListResponseDto; import com.back.domain.mybar.service.MyBarService; @@ -105,6 +106,8 @@ void getMyBarList_withoutCursor() throws Exception { .id(3L) .cocktailId(10L) .cocktailName("Margarita") + .cocktailNameKo("留덇?由ы?") + .alcoholStrength(AlcoholStrength.LIGHT) .imageUrl("https://example.com/margarita.jpg") .createdAt(createdAt) .keptAt(keptAt) @@ -133,6 +136,8 @@ void getMyBarList_withoutCursor() throws Exception { .andExpect(jsonPath("$.data.items[0].id").value(3L)) .andExpect(jsonPath("$.data.items[0].cocktailId").value(10L)) .andExpect(jsonPath("$.data.items[0].cocktailName").value("Margarita")) + .andExpect(jsonPath("$.data.items[0].cocktailNameKo").value("留덇?由ы?")) + .andExpect(jsonPath("$.data.items[0].alcoholStrength").value("LIGHT")) .andExpect(jsonPath("$.data.items[0].imageUrl").value("https://example.com/margarita.jpg")) .andExpect(jsonPath("$.data.items[0].createdAt").value(ISO_WITH_SECONDS.format(createdAt))) .andExpect(jsonPath("$.data.items[0].keptAt").value(ISO_WITH_SECONDS.format(keptAt))) @@ -160,6 +165,8 @@ void getMyBarList_withCursor() throws Exception { .id(20L) .cocktailId(33L) .cocktailName("Negroni") + .cocktailNameKo("?ㅺ렇濡쒕땲") + .alcoholStrength(AlcoholStrength.STRONG) .imageUrl("https://example.com/negroni.jpg") .createdAt(itemCreatedAt) .keptAt(itemKeptAt) @@ -190,6 +197,8 @@ void getMyBarList_withCursor() throws Exception { .andExpect(jsonPath("$.message").value("success")) .andExpect(jsonPath("$.data.items[0].id").value(20L)) .andExpect(jsonPath("$.data.items[0].cocktailName").value("Negroni")) + .andExpect(jsonPath("$.data.items[0].cocktailNameKo").value("?ㅺ렇濡쒕땲")) + .andExpect(jsonPath("$.data.items[0].alcoholStrength").value("STRONG")) .andExpect(jsonPath("$.data.hasNext").value(false)) .andExpect(jsonPath("$.data.nextKeptAt").doesNotExist()); From aec426eaa7b80518e73259b04df521fce04b4795 Mon Sep 17 00:00:00 2001 From: GerHerMo Date: Thu, 9 Oct 2025 18:11:26 +0900 Subject: [PATCH 19/45] feat: QA guide msg add --- .../chatbot/service/ChatbotService.java | 76 +++++++++++++++---- 1 file changed, 62 insertions(+), 14 deletions(-) diff --git a/src/main/java/com/back/domain/chatbot/service/ChatbotService.java b/src/main/java/com/back/domain/chatbot/service/ChatbotService.java index e364df38..3dca13e4 100644 --- a/src/main/java/com/back/domain/chatbot/service/ChatbotService.java +++ b/src/main/java/com/back/domain/chatbot/service/ChatbotService.java @@ -95,9 +95,55 @@ public ChatResponseDto sendMessage(ChatRequestDto requestDto) { log.info("[EXPLICIT] currentStep={}, userId={}, mode={}", currentStep, requestDto.getUserId(), currentStep == 0 ? "QA" : "STEP"); - if (currentStep == 0) { - // 질문형 추천 (일반 AI 대화) + // 질문형 추천 선택 시 안내 메시지와 INPUT 타입 반환 + if ("QA".equalsIgnoreCase(requestDto.getMessage()) || + requestDto.getMessage().contains("질문형")) { + + log.info("질문형 추천 시작 - userId: {}", requestDto.getUserId()); + + // 사용자 선택 메시지 저장 + ChatConversation userChoice = ChatConversation.builder() + .userId(requestDto.getUserId()) + .message("질문형 취향 찾기") + .sender(MessageSender.USER) + .createdAt(LocalDateTime.now()) + .build(); + chatConversationRepository.save(userChoice); + + String guideMessage = "칵테일에 관련된 질문을 입력해주세요!"; + + /* + + String guideMessage = "좋아요! 질문형 추천을 시작할게요 🎯\n" + + "칵테일에 관련된 질문을 자유롭게 입력해주세요!\n" + + "예시: 달콤한 칵테일 추천해줘, 파티용 칵테일이 필요해, 초보자용 칵테일 알려줘"; + */ + + ChatConversation botGuide = ChatConversation.builder() + .userId(requestDto.getUserId()) + .message(guideMessage) + .sender(MessageSender.CHATBOT) + .createdAt(LocalDateTime.now()) + .build(); + ChatConversation savedGuide = chatConversationRepository.save(botGuide); + + // INPUT 타입으로 반환하여 사용자 입력 유도 + return ChatResponseDto.builder() + .id(savedGuide.getId()) + .userId(requestDto.getUserId()) + .message(guideMessage) + .sender(MessageSender.CHATBOT) + .type(MessageType.INPUT) + .createdAt(savedGuide.getCreatedAt()) + .metaData(ChatResponseDto.MetaData.builder() + .currentStep(0) + .actionType("질문형 추천") + .build()) + .build(); + } + + // 실제 질문이 들어온 경우 AI 응답 생성 log.info("질문형 추천 모드 진입 - userId: {}", requestDto.getUserId()); return generateAIResponseWithContext(requestDto, "질문형 추천"); } @@ -473,10 +519,22 @@ private ChatResponseDto createErrorResponse(String errorMessage) { private ChatResponseDto handleStepRecommendation(ChatRequestDto requestDto) { Integer currentStep = requestDto.getCurrentStep(); + + // 단계별 추천 선택 시 처리 + if (currentStep == 1 && "STEP".equalsIgnoreCase(requestDto.getMessage())) { + // 사용자 선택 메시지 저장 + ChatConversation userChoice = ChatConversation.builder() + .userId(requestDto.getUserId()) + .message("단계별 취향 찾기") + .sender(MessageSender.USER) + .createdAt(LocalDateTime.now()) + .build(); + chatConversationRepository.save(userChoice); + } + if (currentStep == null || currentStep <= 0) { currentStep = 1; } - StepRecommendationResponseDto stepData; String message; MessageType type; @@ -521,17 +579,7 @@ private ChatResponseDto handleStepRecommendation(ChatRequestDto requestDto) { message = "단계별 맞춤 취향 추천을 시작합니다! 🎯"; type = MessageType.RADIO_OPTIONS; } - - // 사용자 메시지 저장 (단계별 추천 요청) - ChatConversation userMessage = ChatConversation.builder() - .userId(requestDto.getUserId()) - .message(requestDto.getMessage()) - .sender(MessageSender.USER) - .createdAt(LocalDateTime.now()) - .build(); - chatConversationRepository.save(userMessage); - - // 봇 응답 저장 + // 봇 응답 저장 (사용자 메시지는 이미 위에서 저장) ChatConversation botResponse = ChatConversation.builder() .userId(requestDto.getUserId()) .message(message) From af9b3dcfa12fe2d507d61ca55d8a92075a19a0e1 Mon Sep 17 00:00:00 2001 From: GerHerMo Date: Thu, 9 Oct 2025 23:23:37 +0900 Subject: [PATCH 20/45] feat: STEP loading msg --- .../chatbot/service/ChatbotService.java | 119 ++++++++++++++++-- 1 file changed, 107 insertions(+), 12 deletions(-) diff --git a/src/main/java/com/back/domain/chatbot/service/ChatbotService.java b/src/main/java/com/back/domain/chatbot/service/ChatbotService.java index 3dca13e4..1d92cfa3 100644 --- a/src/main/java/com/back/domain/chatbot/service/ChatbotService.java +++ b/src/main/java/com/back/domain/chatbot/service/ChatbotService.java @@ -62,6 +62,14 @@ public class ChatbotService { private String responseRules; private ChatClient chatClient; + // 로딩 메시지 상수 + private static final String RECOMMENDATION_LOADING_MESSAGE = + "당신에게 어울리는 칵테일은? 두구❤️두구💛두구💚두구💙두구💜두구🖤두구🤍두구🤎"; + + // 처리 완료 플래그 키워드 + private static final String PROCESS_STEP_RECOMMENDATION = "PROCESS_STEP_RECOMMENDATION"; + private static final String PROCESS_QA_RECOMMENDATION = "PROCESS_QA_RECOMMENDATION"; + @PostConstruct public void init() throws IOException { this.systemPrompt = StreamUtils.copyToString( @@ -95,6 +103,7 @@ public ChatResponseDto sendMessage(ChatRequestDto requestDto) { log.info("[EXPLICIT] currentStep={}, userId={}, mode={}", currentStep, requestDto.getUserId(), currentStep == 0 ? "QA" : "STEP"); + if (currentStep == 0) { // 질문형 추천 선택 시 안내 메시지와 INPUT 타입 반환 if ("QA".equalsIgnoreCase(requestDto.getMessage()) || @@ -112,9 +121,7 @@ public ChatResponseDto sendMessage(ChatRequestDto requestDto) { chatConversationRepository.save(userChoice); String guideMessage = "칵테일에 관련된 질문을 입력해주세요!"; - /* - String guideMessage = "좋아요! 질문형 추천을 시작할게요 🎯\n" + "칵테일에 관련된 질문을 자유롭게 입력해주세요!\n" + "예시: 달콤한 칵테일 추천해줘, 파티용 칵테일이 필요해, 초보자용 칵테일 알려줘"; @@ -143,9 +150,50 @@ public ChatResponseDto sendMessage(ChatRequestDto requestDto) { .build(); } - // 실제 질문이 들어온 경우 AI 응답 생성 - log.info("질문형 추천 모드 진입 - userId: {}", requestDto.getUserId()); - return generateAIResponseWithContext(requestDto, "질문형 추천"); + // 실제 질문이 들어온 경우 - 먼저 로딩 메시지 반환 + if (requestDto.getMessage() != null && !requestDto.getMessage().trim().isEmpty()) { + // 로딩 메시지인지 확인 (두구두구 메시지 이후의 실제 처리 요청) + if (requestDto.getMessage().contains("PROCESS_RECOMMENDATION")) { + log.info("질문형 추천 실제 처리 - userId: {}", requestDto.getUserId()); + return generateAIResponseWithContext(requestDto, "질문형 추천"); + } + + // 사용자 질문 저장 + ChatConversation userQuestion = ChatConversation.builder() + .userId(requestDto.getUserId()) + .message(requestDto.getMessage()) + .sender(MessageSender.USER) + .createdAt(LocalDateTime.now()) + .build(); + chatConversationRepository.save(userQuestion); + + // 고정 로딩 메시지 + String loadingMessage = "당신에게 어울리는 칵테일은?\n 두구❤️두구💛두구💚두구💙두구💜두구🖤두구🤍두구🤎"; + + ChatConversation loadingBot = ChatConversation.builder() + .userId(requestDto.getUserId()) + .message(loadingMessage) + .sender(MessageSender.CHATBOT) + .createdAt(LocalDateTime.now()) + .build(); + ChatConversation savedLoading = chatConversationRepository.save(loadingBot); + + // 로딩 메시지 반환 (FE에서 이후 자동으로 실제 추천 요청) + return ChatResponseDto.builder() + .id(savedLoading.getId()) + .userId(requestDto.getUserId()) + .message(loadingMessage) + .sender(MessageSender.CHATBOT) + .type(MessageType.LOADING) + .createdAt(savedLoading.getCreatedAt()) + .metaData(ChatResponseDto.MetaData.builder() + .currentStep(0) + .actionType("LOADING_QA") + .isTyping(true) + .delay(2000) // 2초 후 자동 요청 + .build()) + .build(); + } } else if (currentStep >= 1 && currentStep <= 4) { // 단계별 추천 @@ -163,8 +211,6 @@ else if (currentStep >= 1 && currentStep <= 4) { // ========== 2순위: 키워드 감지 (하위 호환성) ========== if (isStepRecommendationTrigger(requestDto.getMessage())) { log.info("[LEGACY] 키워드 기반 단계별 추천 감지 - userId: {}", requestDto.getUserId()); - - // FE에서 currentStep을 보내지 않았을 때 자동 설정 requestDto.setCurrentStep(1); return handleStepRecommendation(requestDto); } @@ -563,15 +609,64 @@ private ChatResponseDto handleStepRecommendation(ChatRequestDto requestDto) { message = "좋아요! 이제 원하는 칵테일 스타일을 자유롭게 말씀해주세요 💬\n 없으면 'x', 또는 '없음' 을 입력해주세요!"; type = MessageType.INPUT; break; - case 4: + // Step 4에서 로딩 메시지 처리 + if (!"PROCESS_STEP_RECOMMENDATION".equals(requestDto.getMessage())) { + // 사용자 입력 저장 (Step 3의 답변) + if (requestDto.getMessage() != null && !requestDto.getMessage().trim().isEmpty()) { + ChatConversation userInput = ChatConversation.builder() + .userId(requestDto.getUserId()) + .message(requestDto.getMessage()) + .sender(MessageSender.USER) + .createdAt(LocalDateTime.now()) + .build(); + chatConversationRepository.save(userInput); + } + + // 로딩 메시지 생성 - userstyle 삭제 + String loadingMessage = "당신의 취향에 맞는 칵테일은?\n 두구❤️두구💛두구💚두구💙두구💜두구🖤두구🤍두구🤎"; + + ChatConversation loadingBot = ChatConversation.builder() + .userId(requestDto.getUserId()) + .message(loadingMessage) + .sender(MessageSender.CHATBOT) + .createdAt(LocalDateTime.now()) + .build(); + ChatConversation savedLoading = chatConversationRepository.save(loadingBot); + + // 로딩 메시지 반환 + return ChatResponseDto.builder() + .id(savedLoading.getId()) + .userId(requestDto.getUserId()) + .message(loadingMessage) + .sender(MessageSender.CHATBOT) + .type(MessageType.LOADING) + .createdAt(savedLoading.getCreatedAt()) + .metaData(ChatResponseDto.MetaData.builder() + .currentStep(4) + .totalSteps(4) + .actionType("LOADING_STEP") + .isTyping(true) + .delay(2000) // 2초 후 자동 요청 + .build()) + .stepData(new StepRecommendationResponseDto( + 4, + null, + null, + null, + false + )) + .build(); + } + + // 실제 추천 처리 stepData = getFinalRecommendationsWithMessage( - parseAlcoholStrength(requestDto.getSelectedAlcoholStrength()), - parseAlcoholBaseType(requestDto.getSelectedAlcoholBaseType()), - requestDto.getMessage() + parseAlcoholStrength(requestDto.getSelectedAlcoholStrength()), + parseAlcoholBaseType(requestDto.getSelectedAlcoholBaseType()), + requestDto.getMessage() ); message = stepData.getStepTitle(); - type = MessageType.CARD_LIST; // 최종 추천은 카드 리스트 + type = MessageType.CARD_LIST; break; default: From cc52ac7d300fd6f13cf33d516e6185c91206ab78 Mon Sep 17 00:00:00 2001 From: GerHerMo Date: Thu, 9 Oct 2025 23:26:18 +0900 Subject: [PATCH 21/45] feat: STEP & QA loading msg --- .../chatbot/service/ChatbotService.java | 25 ++++++++++--------- 1 file changed, 13 insertions(+), 12 deletions(-) diff --git a/src/main/java/com/back/domain/chatbot/service/ChatbotService.java b/src/main/java/com/back/domain/chatbot/service/ChatbotService.java index 1d92cfa3..ca6b4ae2 100644 --- a/src/main/java/com/back/domain/chatbot/service/ChatbotService.java +++ b/src/main/java/com/back/domain/chatbot/service/ChatbotService.java @@ -562,7 +562,6 @@ private ChatResponseDto createErrorResponse(String errorMessage) { .createdAt(LocalDateTime.now()) .build(); } - private ChatResponseDto handleStepRecommendation(ChatRequestDto requestDto) { Integer currentStep = requestDto.getCurrentStep(); @@ -581,6 +580,7 @@ private ChatResponseDto handleStepRecommendation(ChatRequestDto requestDto) { if (currentStep == null || currentStep <= 0) { currentStep = 1; } + StepRecommendationResponseDto stepData; String message; MessageType type; @@ -600,15 +600,16 @@ private ChatResponseDto handleStepRecommendation(ChatRequestDto requestDto) { case 3: stepData = new StepRecommendationResponseDto( - 3, - null, - null, - null, - false + 3, + null, + null, + null, + false ); - message = "좋아요! 이제 원하는 칵테일 스타일을 자유롭게 말씀해주세요 💬\n 없으면 'x', 또는 '없음' 을 입력해주세요!"; + message = "좋아요! 이제 원하는 칵테일 스타일을 자유롭게 말씀해주세요 💬\n없으면 'x', 또는 '없음'을 입력해주세요!"; type = MessageType.INPUT; break; + case 4: // Step 4에서 로딩 메시지 처리 if (!"PROCESS_STEP_RECOMMENDATION".equals(requestDto.getMessage())) { @@ -623,8 +624,8 @@ private ChatResponseDto handleStepRecommendation(ChatRequestDto requestDto) { chatConversationRepository.save(userInput); } - // 로딩 메시지 생성 - userstyle 삭제 - String loadingMessage = "당신의 취향에 맞는 칵테일은?\n 두구❤️두구💛두구💚두구💙두구💜두구🖤두구🤍두구🤎"; + // 고정 로딩 메시지 + String loadingMessage = "당신에게 어울리는 칵테일은?\n 두구❤️두구💛두구💚두구💙두구💜두구🖤두구🤍두구🤎"; ChatConversation loadingBot = ChatConversation.builder() .userId(requestDto.getUserId()) @@ -674,7 +675,8 @@ private ChatResponseDto handleStepRecommendation(ChatRequestDto requestDto) { message = "단계별 맞춤 취향 추천을 시작합니다! 🎯"; type = MessageType.RADIO_OPTIONS; } - // 봇 응답 저장 (사용자 메시지는 이미 위에서 저장) + + // 봇 응답 저장 ChatConversation botResponse = ChatConversation.builder() .userId(requestDto.getUserId()) .message(message) @@ -687,7 +689,7 @@ private ChatResponseDto handleStepRecommendation(ChatRequestDto requestDto) { ChatResponseDto.MetaData metaData = ChatResponseDto.MetaData.builder() .currentStep(currentStep) .totalSteps(4) - .isTyping(true) + .isTyping(type != MessageType.CARD_LIST) // 카드리스트는 타이핑 애니메이션 불필요 .delay(300) .build(); @@ -702,7 +704,6 @@ private ChatResponseDto handleStepRecommendation(ChatRequestDto requestDto) { .createdAt(savedResponse.getCreatedAt()) .build(); } - // ============ 단계별 추천 관련 메서드들 ============ // "ALL" 또는 null/빈값은 null로 처리하여 전체 선택 의미 From 5158628f7543a5068da128612dd7e063681c7b12 Mon Sep 17 00:00:00 2001 From: GerHerMo Date: Thu, 9 Oct 2025 23:55:54 +0900 Subject: [PATCH 22/45] fix: userStyleInput --- .../com/back/domain/chatbot/dto/ChatRequestDto.java | 3 +++ .../back/domain/chatbot/service/ChatbotService.java | 11 ++++++++--- 2 files changed, 11 insertions(+), 3 deletions(-) diff --git a/src/main/java/com/back/domain/chatbot/dto/ChatRequestDto.java b/src/main/java/com/back/domain/chatbot/dto/ChatRequestDto.java index 83f26e15..6b265c70 100644 --- a/src/main/java/com/back/domain/chatbot/dto/ChatRequestDto.java +++ b/src/main/java/com/back/domain/chatbot/dto/ChatRequestDto.java @@ -27,4 +27,7 @@ public class ChatRequestDto { private String selectedAlcoholStrength; private String selectedAlcoholBaseType; // selectedCocktailType 삭제 + + // Step 3에서 사용자가 입력한 칵테일 스타일 (검색 키워드로 사용) + private String userStyleInput; } \ No newline at end of file diff --git a/src/main/java/com/back/domain/chatbot/service/ChatbotService.java b/src/main/java/com/back/domain/chatbot/service/ChatbotService.java index ca6b4ae2..13990f50 100644 --- a/src/main/java/com/back/domain/chatbot/service/ChatbotService.java +++ b/src/main/java/com/back/domain/chatbot/service/ChatbotService.java @@ -613,8 +613,9 @@ private ChatResponseDto handleStepRecommendation(ChatRequestDto requestDto) { case 4: // Step 4에서 로딩 메시지 처리 if (!"PROCESS_STEP_RECOMMENDATION".equals(requestDto.getMessage())) { - // 사용자 입력 저장 (Step 3의 답변) + // 사용자 입력 저장 (Step 3의 답변) 및 userStyleInput에 저장 if (requestDto.getMessage() != null && !requestDto.getMessage().trim().isEmpty()) { + // DB에 저장 ChatConversation userInput = ChatConversation.builder() .userId(requestDto.getUserId()) .message(requestDto.getMessage()) @@ -622,6 +623,10 @@ private ChatResponseDto handleStepRecommendation(ChatRequestDto requestDto) { .createdAt(LocalDateTime.now()) .build(); chatConversationRepository.save(userInput); + + // userStyleInput에 저장 (다음 요청에서 사용) + requestDto.setUserStyleInput(requestDto.getMessage()); + log.info("Step 3 사용자 입력 저장: {}", requestDto.getMessage()); } // 고정 로딩 메시지 @@ -660,11 +665,11 @@ private ChatResponseDto handleStepRecommendation(ChatRequestDto requestDto) { .build(); } - // 실제 추천 처리 + // 실제 추천 처리 - userStyleInput 사용 (PROCESS_STEP_RECOMMENDATION 키워드 아님) stepData = getFinalRecommendationsWithMessage( parseAlcoholStrength(requestDto.getSelectedAlcoholStrength()), parseAlcoholBaseType(requestDto.getSelectedAlcoholBaseType()), - requestDto.getMessage() + requestDto.getUserStyleInput() // message 대신 userStyleInput 사용 ); message = stepData.getStepTitle(); type = MessageType.CARD_LIST; From 6c4a48d58654dd4e957250d6ea99238c10d49fe8 Mon Sep 17 00:00:00 2001 From: GerHerMo Date: Fri, 10 Oct 2025 09:49:37 +0900 Subject: [PATCH 23/45] fix: checkout version before loadingmsg add --- .../java/com/back/domain/chatbot/service/ChatbotService.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/com/back/domain/chatbot/service/ChatbotService.java b/src/main/java/com/back/domain/chatbot/service/ChatbotService.java index 3dca13e4..f7f0a084 100644 --- a/src/main/java/com/back/domain/chatbot/service/ChatbotService.java +++ b/src/main/java/com/back/domain/chatbot/service/ChatbotService.java @@ -548,7 +548,7 @@ private ChatResponseDto handleStepRecommendation(ChatRequestDto requestDto) { case 2: stepData = getAlcoholBaseTypeOptions(parseAlcoholStrength(requestDto.getSelectedAlcoholStrength())); - message = "좋은 선택이네요! 이제 베이스가 될 술을 선택해주세요 🍸"; + message = "좋은 선택이네요!\n 이제 베이스가 될 술을 선택해주세요 🍸"; type = MessageType.RADIO_OPTIONS; break; From c5689cf807519031be135d8227c1a6e7ebda2df5 Mon Sep 17 00:00:00 2001 From: GerHerMo Date: Fri, 10 Oct 2025 10:09:17 +0900 Subject: [PATCH 24/45] fix: del userStyle on DTO --- .../domain/chatbot/dto/ChatRequestDto.java | 2 - .../chatbot/service/ChatbotService.java | 118 ++---------------- 2 files changed, 7 insertions(+), 113 deletions(-) diff --git a/src/main/java/com/back/domain/chatbot/dto/ChatRequestDto.java b/src/main/java/com/back/domain/chatbot/dto/ChatRequestDto.java index 6b265c70..74a59474 100644 --- a/src/main/java/com/back/domain/chatbot/dto/ChatRequestDto.java +++ b/src/main/java/com/back/domain/chatbot/dto/ChatRequestDto.java @@ -28,6 +28,4 @@ public class ChatRequestDto { private String selectedAlcoholBaseType; // selectedCocktailType 삭제 - // Step 3에서 사용자가 입력한 칵테일 스타일 (검색 키워드로 사용) - private String userStyleInput; } \ No newline at end of file diff --git a/src/main/java/com/back/domain/chatbot/service/ChatbotService.java b/src/main/java/com/back/domain/chatbot/service/ChatbotService.java index 1d11b245..e4c18200 100644 --- a/src/main/java/com/back/domain/chatbot/service/ChatbotService.java +++ b/src/main/java/com/back/domain/chatbot/service/ChatbotService.java @@ -62,14 +62,6 @@ public class ChatbotService { private String responseRules; private ChatClient chatClient; - // 로딩 메시지 상수 - private static final String RECOMMENDATION_LOADING_MESSAGE = - "당신에게 어울리는 칵테일은? 두구❤️두구💛두구💚두구💙두구💜두구🖤두구🤍두구🤎"; - - // 처리 완료 플래그 키워드 - private static final String PROCESS_STEP_RECOMMENDATION = "PROCESS_STEP_RECOMMENDATION"; - private static final String PROCESS_QA_RECOMMENDATION = "PROCESS_QA_RECOMMENDATION"; - @PostConstruct public void init() throws IOException { this.systemPrompt = StreamUtils.copyToString( @@ -150,50 +142,9 @@ public ChatResponseDto sendMessage(ChatRequestDto requestDto) { .build(); } - // 실제 질문이 들어온 경우 - 먼저 로딩 메시지 반환 - if (requestDto.getMessage() != null && !requestDto.getMessage().trim().isEmpty()) { - // 로딩 메시지인지 확인 (두구두구 메시지 이후의 실제 처리 요청) - if (requestDto.getMessage().contains("PROCESS_RECOMMENDATION")) { - log.info("질문형 추천 실제 처리 - userId: {}", requestDto.getUserId()); - return generateAIResponseWithContext(requestDto, "질문형 추천"); - } - - // 사용자 질문 저장 - ChatConversation userQuestion = ChatConversation.builder() - .userId(requestDto.getUserId()) - .message(requestDto.getMessage()) - .sender(MessageSender.USER) - .createdAt(LocalDateTime.now()) - .build(); - chatConversationRepository.save(userQuestion); - - // 고정 로딩 메시지 - String loadingMessage = "당신에게 어울리는 칵테일은?\n 두구❤️두구💛두구💚두구💙두구💜두구🖤두구🤍두구🤎"; - - ChatConversation loadingBot = ChatConversation.builder() - .userId(requestDto.getUserId()) - .message(loadingMessage) - .sender(MessageSender.CHATBOT) - .createdAt(LocalDateTime.now()) - .build(); - ChatConversation savedLoading = chatConversationRepository.save(loadingBot); - - // 로딩 메시지 반환 (FE에서 이후 자동으로 실제 추천 요청) - return ChatResponseDto.builder() - .id(savedLoading.getId()) - .userId(requestDto.getUserId()) - .message(loadingMessage) - .sender(MessageSender.CHATBOT) - .type(MessageType.LOADING) - .createdAt(savedLoading.getCreatedAt()) - .metaData(ChatResponseDto.MetaData.builder() - .currentStep(0) - .actionType("LOADING_QA") - .isTyping(true) - .delay(2000) // 2초 후 자동 요청 - .build()) - .build(); - } + // 실제 질문이 들어온 경우 AI 응답 생성 + log.info("질문형 추천 모드 진입 - userId: {}", requestDto.getUserId()); + return generateAIResponseWithContext(requestDto, "질문형 추천"); } else if (currentStep >= 1 && currentStep <= 4) { // 단계별 추천 @@ -594,7 +545,7 @@ private ChatResponseDto handleStepRecommendation(ChatRequestDto requestDto) { case 2: stepData = getAlcoholBaseTypeOptions(parseAlcoholStrength(requestDto.getSelectedAlcoholStrength())); - message = "좋은 선택이네요!\n 이제 베이스가 될 술을 선택해주세요 🍸"; + message = "좋은 선택이네요! \n이제 베이스가 될 술을 선택해주세요 🍸"; type = MessageType.RADIO_OPTIONS; break; @@ -611,65 +562,10 @@ private ChatResponseDto handleStepRecommendation(ChatRequestDto requestDto) { break; case 4: - // Step 4에서 로딩 메시지 처리 - if (!"PROCESS_STEP_RECOMMENDATION".equals(requestDto.getMessage())) { - // 사용자 입력 저장 (Step 3의 답변) 및 userStyleInput에 저장 - if (requestDto.getMessage() != null && !requestDto.getMessage().trim().isEmpty()) { - // DB에 저장 - ChatConversation userInput = ChatConversation.builder() - .userId(requestDto.getUserId()) - .message(requestDto.getMessage()) - .sender(MessageSender.USER) - .createdAt(LocalDateTime.now()) - .build(); - chatConversationRepository.save(userInput); - - // userStyleInput에 저장 (다음 요청에서 사용) - requestDto.setUserStyleInput(requestDto.getMessage()); - log.info("Step 3 사용자 입력 저장: {}", requestDto.getMessage()); - } - - // 고정 로딩 메시지 - String loadingMessage = "당신에게 어울리는 칵테일은?\n 두구❤️두구💛두구💚두구💙두구💜두구🖤두구🤍두구🤎"; - - ChatConversation loadingBot = ChatConversation.builder() - .userId(requestDto.getUserId()) - .message(loadingMessage) - .sender(MessageSender.CHATBOT) - .createdAt(LocalDateTime.now()) - .build(); - ChatConversation savedLoading = chatConversationRepository.save(loadingBot); - - // 로딩 메시지 반환 - return ChatResponseDto.builder() - .id(savedLoading.getId()) - .userId(requestDto.getUserId()) - .message(loadingMessage) - .sender(MessageSender.CHATBOT) - .type(MessageType.LOADING) - .createdAt(savedLoading.getCreatedAt()) - .metaData(ChatResponseDto.MetaData.builder() - .currentStep(4) - .totalSteps(4) - .actionType("LOADING_STEP") - .isTyping(true) - .delay(2000) // 2초 후 자동 요청 - .build()) - .stepData(new StepRecommendationResponseDto( - 4, - null, - null, - null, - false - )) - .build(); - } - - // 실제 추천 처리 - userStyleInput 사용 (PROCESS_STEP_RECOMMENDATION 키워드 아님) stepData = getFinalRecommendationsWithMessage( - parseAlcoholStrength(requestDto.getSelectedAlcoholStrength()), - parseAlcoholBaseType(requestDto.getSelectedAlcoholBaseType()), - requestDto.getUserStyleInput() // message 대신 userStyleInput 사용 + parseAlcoholStrength(requestDto.getSelectedAlcoholStrength()), + parseAlcoholBaseType(requestDto.getSelectedAlcoholBaseType()), + requestDto.getMessage() ); message = stepData.getStepTitle(); type = MessageType.CARD_LIST; From 2d33189cdf8f1c93390b655a0ea37755b738afdd Mon Sep 17 00:00:00 2001 From: GerHerMo Date: Fri, 10 Oct 2025 10:37:26 +0900 Subject: [PATCH 25/45] refactor: add Entity-metadata / Dto-selectedValue --- src/main/java/com/back/domain/chatbot/dto/ChatRequestDto.java | 1 + .../java/com/back/domain/chatbot/entity/ChatConversation.java | 4 ++++ 2 files changed, 5 insertions(+) diff --git a/src/main/java/com/back/domain/chatbot/dto/ChatRequestDto.java b/src/main/java/com/back/domain/chatbot/dto/ChatRequestDto.java index 74a59474..534ad861 100644 --- a/src/main/java/com/back/domain/chatbot/dto/ChatRequestDto.java +++ b/src/main/java/com/back/domain/chatbot/dto/ChatRequestDto.java @@ -15,6 +15,7 @@ public class ChatRequestDto { private Long userId; + private String selectedValue; // 예: "NON_ALCOHOLIC" // 단계별 추천 관련 필드들 /** * @deprecated currentStep 필드를 사용하세요. 이 필드는 하위 호환성을 위해 유지됩니다. diff --git a/src/main/java/com/back/domain/chatbot/entity/ChatConversation.java b/src/main/java/com/back/domain/chatbot/entity/ChatConversation.java index b6b572f2..dc34a450 100644 --- a/src/main/java/com/back/domain/chatbot/entity/ChatConversation.java +++ b/src/main/java/com/back/domain/chatbot/entity/ChatConversation.java @@ -34,4 +34,8 @@ public class ChatConversation { @CreatedDate private LocalDateTime createdAt; + + // refactor#256 - metadata 필드 추가 + @Column(columnDefinition = "TEXT") + private String metadata; } \ No newline at end of file From 2861a24397e71fb2ff06230d1b5424e45b2fda8d Mon Sep 17 00:00:00 2001 From: GerHerMo Date: Fri, 10 Oct 2025 11:09:07 +0900 Subject: [PATCH 26/45] feat: add saveUserMessage/saveBotResponse/getUserChatHistory --- .../chatbot/service/ChatbotService.java | 93 +++++++++++++++++-- 1 file changed, 87 insertions(+), 6 deletions(-) diff --git a/src/main/java/com/back/domain/chatbot/service/ChatbotService.java b/src/main/java/com/back/domain/chatbot/service/ChatbotService.java index e4c18200..fc83f702 100644 --- a/src/main/java/com/back/domain/chatbot/service/ChatbotService.java +++ b/src/main/java/com/back/domain/chatbot/service/ChatbotService.java @@ -13,6 +13,7 @@ import com.back.domain.cocktail.enums.AlcoholBaseType; import com.back.domain.cocktail.enums.AlcoholStrength; import com.back.domain.cocktail.repository.CocktailRepository; +import com.fasterxml.jackson.databind.ObjectMapper; import jakarta.annotation.PostConstruct; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -32,6 +33,7 @@ import java.time.LocalDateTime; import java.util.ArrayList; import java.util.List; +import java.util.Map; import java.util.stream.Collectors; @Service @@ -43,6 +45,8 @@ public class ChatbotService { private final ChatConversationRepository chatConversationRepository; private final CocktailRepository cocktailRepository; + private final ObjectMapper objectMapper = new ObjectMapper(); // JSON 변환용 + @Value("classpath:prompts/chatbot-system-prompt.txt") private Resource systemPromptResource; @@ -87,6 +91,8 @@ public void init() throws IOException { @Transactional public ChatResponseDto sendMessage(ChatRequestDto requestDto) { + saveUserMessage(requestDto); + try { Integer currentStep = requestDto.getCurrentStep(); @@ -185,8 +191,47 @@ else if (currentStep >= 1 && currentStep <= 4) { } } - // ============ 수정된 메서드들 ============ + private void saveUserMessage(ChatRequestDto requestDto) { + String metadata = null; + if (requestDto.getSelectedValue() != null) { + try { + // 사용자가 선택한 실제 값(value)을 JSON으로 저장 + metadata = objectMapper.writeValueAsString(Map.of("selectedValue", requestDto.getSelectedValue())); + } catch (JsonProcessingException e) { + log.error("사용자 선택 값 JSON 직렬화 실패", e); + } + } + + ChatConversation userMessage = ChatConversation.builder() + .userId(requestDto.getUserId()) + .message(requestDto.getMessage()) // 사용자가 본 텍스트(label) + .sender(MessageSender.USER) + .createdAt(LocalDateTime.now()) + .metadata(metadata) // 선택한 실제 값(value) + .build(); + chatConversationRepository.save(userMessage); + } + private ChatConversation saveBotResponse(Long userId, String message, Object stepData) { + String metadata = null; + if (stepData != null) { + try { + // 봇이 보낸 옵션, 카드 등 구조화된 데이터를 JSON으로 저장 + metadata = objectMapper.writeValueAsString(stepData); + } catch (JsonProcessingException e) { + log.error("봇 응답 메타데이터 JSON 직렬화 실패", e); + } + } + + ChatConversation botResponse = ChatConversation.builder() + .userId(userId) + .message(message) + .sender(MessageSender.CHATBOT) + .createdAt(LocalDateTime.now()) + .metadata(metadata) + .build(); + return chatConversationRepository.save(botResponse); + } /** * 대화 컨텍스트 빌드 - 변경사항: sender로 구분하여 대화 재구성 */ @@ -242,8 +287,44 @@ public ChatConversation saveConversation(ChatRequestDto requestDto, String respo * 사용자 채팅 기록 조회 - 변경사항: sender 구분 없이 모든 메시지 시간순으로 조회 */ @Transactional(readOnly = true) - public List getUserChatHistory(Long userId) { - return chatConversationRepository.findByUserIdOrderByCreatedAtDesc(userId); + public List getUserChatHistory(Long userId) { + List history = chatConversationRepository.findByUserIdOrderByCreatedAtAsc(userId); // 시간순으로 변경 + + return history.stream().map(conversation -> { + ChatResponseDto.ChatResponseDtoBuilder builder = ChatResponseDto.builder() + .id(conversation.getId()) + .userId(conversation.getUserId()) + .message(conversation.getMessage()) + .sender(conversation.getSender()) + .createdAt(conversation.getCreatedAt()); + + String metadata = conversation.getMetadata(); + if (metadata != null && !metadata.isEmpty()) { + try { + if (conversation.getSender() == MessageSender.CHATBOT) { + StepRecommendationResponseDto stepData = objectMapper.readValue(metadata, StepRecommendationResponseDto.class); + builder.stepData(stepData); + + if (stepData.getOptions() != null && !stepData.getOptions().isEmpty()) { + builder.type(MessageType.RADIO_OPTIONS); + } else if (stepData.getRecommendations() != null && !stepData.getRecommendations().isEmpty()) { + builder.type(MessageType.CARD_LIST); + } else { + builder.type(MessageType.TEXT); + } + } else { // sender == USER + // 사용자 메시지의 메타데이터는 FE에서 선택 처리 등에 활용 가능 + builder.type(MessageType.TEXT); + } + } catch (JsonProcessingException e) { + log.error("대화 기록 metadata 역직렬화 실패 [ID: {}]: {}", conversation.getId(), e.getMessage()); + builder.type(MessageType.TEXT); + } + } else { + builder.type(MessageType.TEXT); + } + return builder.build(); + }).collect(Collectors.toList()); } /** @@ -429,8 +510,8 @@ private ChatConversation generateAIResponse(ChatRequestDto requestDto) { // 응답 후처리 response = postProcessResponse(response, messageType); - // 대화 저장 - 사용자 메시지와 봇 응답을 각각 저장하고 저장된 봇 응답 반환 - return saveConversation(requestDto, response); + // 봇 응답만 저장 -> 사용자 메시지는 sendmessage()에서 이미 저장됨 + return saveBotResponse(requestDto.getUserId(), response, null); } /** @@ -584,7 +665,7 @@ private ChatResponseDto handleStepRecommendation(ChatRequestDto requestDto) { .sender(MessageSender.CHATBOT) .createdAt(LocalDateTime.now()) .build(); - ChatConversation savedResponse = chatConversationRepository.save(botResponse); + ChatConversation savedResponse = saveBotResponse(requestDto.getUserId(), message, stepData); // 메타데이터 포함 ChatResponseDto.MetaData metaData = ChatResponseDto.MetaData.builder() From 47b89c1d65c1bf9e4c2f4a38d96bcc4318c05121 Mon Sep 17 00:00:00 2001 From: GerHerMo Date: Fri, 10 Oct 2025 11:10:47 +0900 Subject: [PATCH 27/45] refactor: Endpoint method edit --- .../com/back/domain/chatbot/controller/ChatbotController.java | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/main/java/com/back/domain/chatbot/controller/ChatbotController.java b/src/main/java/com/back/domain/chatbot/controller/ChatbotController.java index e274d012..27b10971 100644 --- a/src/main/java/com/back/domain/chatbot/controller/ChatbotController.java +++ b/src/main/java/com/back/domain/chatbot/controller/ChatbotController.java @@ -38,9 +38,9 @@ public ResponseEntity> sendMessage(@Valid @RequestBody C @GetMapping("/history/user/{userId}") @Operation(summary = "유저 대화 히스토리", description = "사용자 채팅 기록 조회") - public ResponseEntity>> getUserChatHistory(@PathVariable Long userId) { + public ResponseEntity>> getUserChatHistory(@PathVariable Long userId) { try { - List history = chatbotService.getUserChatHistory(userId); + List history = chatbotService.getUserChatHistory(userId); return ResponseEntity.ok(RsData.successOf(history)); } catch (Exception e) { log.error("사용자 채팅 기록 조회 중 오류 발생: ", e); From 44ba6047ceb6af0fd63958769aebfa7792d25d20 Mon Sep 17 00:00:00 2001 From: GerHerMo Date: Fri, 10 Oct 2025 11:14:20 +0900 Subject: [PATCH 28/45] fix: import & repo add --- .../domain/chatbot/repository/ChatConversationRepository.java | 2 ++ .../java/com/back/domain/chatbot/service/ChatbotService.java | 1 + 2 files changed, 3 insertions(+) diff --git a/src/main/java/com/back/domain/chatbot/repository/ChatConversationRepository.java b/src/main/java/com/back/domain/chatbot/repository/ChatConversationRepository.java index 6e8dd106..ceac44a6 100644 --- a/src/main/java/com/back/domain/chatbot/repository/ChatConversationRepository.java +++ b/src/main/java/com/back/domain/chatbot/repository/ChatConversationRepository.java @@ -11,6 +11,8 @@ public interface ChatConversationRepository extends JpaRepository findByUserIdOrderByCreatedAtDesc(Long userId); + List findByUserIdOrderByCreatedAtAsc(Long userId); + List findTop20ByUserIdOrderByCreatedAtDesc(Long userId); boolean existsByUserIdAndMessage(Long userId, String message); diff --git a/src/main/java/com/back/domain/chatbot/service/ChatbotService.java b/src/main/java/com/back/domain/chatbot/service/ChatbotService.java index fc83f702..470c0ac6 100644 --- a/src/main/java/com/back/domain/chatbot/service/ChatbotService.java +++ b/src/main/java/com/back/domain/chatbot/service/ChatbotService.java @@ -27,6 +27,7 @@ import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import org.springframework.util.StreamUtils; +import com.fasterxml.jackson.core.JsonProcessingException; import java.io.IOException; import java.nio.charset.StandardCharsets; From 540cb66b7f930238c039e7736a539777b06cda2c Mon Sep 17 00:00:00 2001 From: seungwookc97 Date: Fri, 10 Oct 2025 11:17:34 +0900 Subject: [PATCH 29/45] =?UTF-8?q?refactor:=EB=8B=89=EB=84=A4=EC=9E=84=20?= =?UTF-8?q?=EC=B5=9C=EB=8C=80=208=EA=B8=80=EC=9E=90=EB=A1=9C=20=EC=88=98?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/user/service/UserAuthService.java | 76 +++++++++---------- 1 file changed, 36 insertions(+), 40 deletions(-) diff --git a/src/main/java/com/back/domain/user/service/UserAuthService.java b/src/main/java/com/back/domain/user/service/UserAuthService.java index 22b32a5c..6e58b8cf 100644 --- a/src/main/java/com/back/domain/user/service/UserAuthService.java +++ b/src/main/java/com/back/domain/user/service/UserAuthService.java @@ -25,7 +25,8 @@ @RequiredArgsConstructor public class UserAuthService { - static Set param1 = Set.of("두둑한", "날씬한", "만취한", "알딸딸", "얼큰한", "시트러스", "도수높은", "톡쏘는", "거품가득", "하이볼한", + static Set param1 = Set.of( + "두둑한", "날씬한", "만취한", "알딸딸", "얼큰한", "시트러스", "도수높은", "톡쏘는", "거품가득", "하이볼한", "앙증맞은", "쓸쓸한", "거만한", "산만한", "귀찮은", "삐딱한", "맛이간", "저세상급", "시궁창", "기묘한", "졸린", "센치한", "철학적인", "무중력", "뽀송한", "전투적인", "배부른", "대충한", "쩌는", "철지난", "절규하는", "맞춤형", "다급한", "찌뿌둥한", "구수한", "문어발", "자포자기", "터무니", "귀척", "심드렁한", @@ -33,48 +34,43 @@ public class UserAuthService { "허무한", "헛기침", "뿜어대는", "질척한", "기어다님", "헤매는", "삐죽한", "악에받친", "격렬한", "삐까번쩍", "오지랖", "쪼르르", "꿀꺽", "머쓱한", "휘청대는", "추접", "천방지축", "어리둥절", "질주하는", "겸연쩍은", "뿌연", "썩은", "짠내나는", "철썩", "흥건한", "안간힘", "뜨끈한", "꾸덕한", "동공지진", "덕지덕지", - "비밀", "개운한", "심란한", "음울한", "터질듯한", "달달한", "사악한", "기괴한", "용맹한", "껄끄러운", + "개운한", "심란한", "음울한", "터질듯한", "달달한", "사악한", "기괴한", "용맹한", "껄끄러운", "헐떡이는", "허둥대는", "분란", "애매한", "찐득한", "허기진", "쩔어버린", "몽롱한", "허세", "황당한", - "거대작음", "대차게구림", "어이없음", "두통약", "지갑", "이쑤시개", "돌침대", "고무장갑", "손수건", "바람개비", - "지하철표", "송진가루", "철가방", "머리끈", "양말한짝", "라이터", "숟가락", "스티커", "드럼통", "열쇠", - "벼락", "대걸레", "파리채", "앙금빵", "날개", "스티로폼", "건전지", "껌종이", "소화전", "비닐우산", - "고드름", "전등갓", "양초", "지우개", "국자", "밥솥", "연필심", "깃털", "찜질팩", "청테이프", - "김밥말이", "곰팡이", "청소기", "밤송이", "옥수수", "철창살", "휴지심", "선반", "곽티슈", "스프링", + "거대작음", "수상한", "어이없는", "두통약", "이쑤시개", "돌침대", "고무장갑", "손수건", "바람개비", + "지하철표", "송진가루", "철가방", "머리끈", "양말한짝", "파리채", "앙금빵", "날개", "스티로폼", "건전지", + "껌종이", "소화전", "비닐우산", "고드름", "전등갓", "양초", "지우개", "국자", "밥솥", "연필심", "깃털", + "찜질팩", "청테이프", "곰팡이", "청소기", "밤송이", "옥수수", "철창살", "휴지심", "선반", "곽티슈", "스프링", "고향된장", "머드팩", "장독대", "각질", "어묵꼬치", "환풍기", "군고구마", "카세트", "건조대", "박카스병", - "우체통", "주차권", "털실뭉치", "지하수", "추리닝", "이불각", "육포", "빨대", "지렁이", "김칫국", + "우체통", "주차권", "털실뭉치", "지하수", "추리닝", "이불킥", "육포", "빨대", "지렁이", "김칫국", "오징어채", "전기장판", "꽃병", "도시락통", "구급상자", "양배추잎", "고무줄", "망치", "유통기한", "알람시계", - "방범창", "깔창", "만취육포", "날씬국자", "터프각질", "음울밥솥", "사악김치", "허세숟갈", "삐딱곰팡"); - - static Set param2 = Set.of("도토리딱개구리", "아프리카들개", "강남성인군자", "술고래", "알코올러버", "겨자잎", "청개구리", "산수유", - "맥주문어", "칵테일앵무새", "보드카수달", "진토닉거북이", "테킬라코요테", "럼펭귄", "사케고양이", "막걸리두꺼비", - "하이볼판다", "모히토돌고래", "피냐콜라다곰", "샴페인펭귄", "홍초원숭이", "네그로니청년", "IPA성기사", - "블러디메리여사", "위스키호랑이", "쌍화차토끼", "유자도롱뇽", "복분자여우", "국화주해적단", "소맥언덕", - "전통주공룡", "파전악어", "오징어숙취단", "민트라쿤", "땅콩버터공작새", "은행나무너구리", "고량주펭귄", - "비빔밥바다표범", "돼지껍데기참새", "소주잔기린", "대왕쥐포코끼리", "군만두얼룩말", "마라탕너구리", - "삼겹살청년", "곱창수달", "치킨도사", "라면위즈", "내복토끼", "냉면불사조", "젤리곰해파리", "아이스링곰", - "젓가락토네이도", "기름떡볶이수달", "고구마바람개비", "파인애플악마", "번데기기사단", "곰탕판다", - "마늘빵펠리컨", "옥수수수염신", "뿌링클드래곤", "껌딱지원숭이", "곤드레라쿤", "스티커헤라클레스", - "삼색볼펜치타", "오렌지문어국수", "간장게장거북", "카스테라바퀴", "초코송이타조", "건빵악어", - "너구리비상대책본부", "대하구이천사", "골뱅이버팔로", "라떼마라톤선수", "딸기생크림코알라", - "찹쌀떡고래", "꿀꿀선비", "번개치킨집사", "고칼슘청새치", "가그린도마뱀", "소화제악마", "민트초코귀신", - "통닭의무대장", "반건조오징어군단", "참깨부엉이", "바나나해커", "복숭아도둑너구리", "나쵸껍데기", - "돌솥비버", "전자레인지곰", "냄비펭귄", "주전자사냥개", "콘치즈히드라", "우유팩할배", "막걸리도롱뇽", - "짬뽕기린", "김치만두여신", "오이나무늘보", "버터쿠키살쾡이", "동치미해골", "청양고추돌고래", - "다슬기시민", "와사비드래곤", "분식집카멜레온", "곰젤리술사", "귤껍질기사", "멸치왕국", "생맥바이킹", - "병따개도마뱀", "굴튀김달팽이", "카레호랑이", "파슬리늑대", "오코노미야끼판다", "꽈배기늑대", - "밀크티돌고래", "고기국수캥거루", "초코파이여단", "해장국곰", "쓰레기통요정", "달고나도깨비", - "삼다수거북", "헛개차도마뱀", "카누호수악마", "치킨발바닥", "뱀술수호자", "파전너구리", "콩나물카멜레온", - "대패삼겹돌고래", "굴비강아지", "막창펭귄", "감자튀김친구", "어묵사자", "부추말벌", "탕수육햄스터", - "매운탕비둘기", "마라전골토끼", "돼지껍데기개구리", "술국호랑이", "두부오리", "깍두기코끼리", - "라볶이사슴", "양파링문어", "피자청개구리", "고등어펭귄", "국밥파충류", "닭털마을", "바나나우럭", - "김말이치타", "젓가락말미잘", "물회거북이", "한치하이에나", "청하상어", "참치꽁치", "해장라면매머드", - "양꼬치토끼", "소떡소떡나방", "달걀말이원숭이", "김밥펭귄", "참외멍게", "고추전갈", "치즈덮밥여우", - "닭껍질곰", "깻잎무당벌레", "갈비찜도마뱀", "미역국돌고래", "쌈채소사자", "두루치기청새치", "계란후라이늑대", - "김치찌개토끼", "칼국수라쿤", "찌개나방", "해물탕코뿔소", "쌀국수표범", "떡꼬치상어", "날치알까마귀", - "라멘수달", "나베공룡", "다시마돌고래", "곱창수사슴", "콜라북극곰", "된장찌개강아지", "젤리호랑이", - "칵테일참새", "버블티치킨", "오렌지맥주드래곤", "구운치즈기린", "마늘빵거북이", "양고기판다", - "초코우유너구리", "요플레거미", "옥수수탕기린", "피자토스트족제비", "떡갈비수달", "케이크맘모스", - "스시참새", "광어버터캣", "황태국라쿤", "가래떡펭귄"); + "방범창", "깔창", "만취육포", "날씬국자", "터프각질", "음울밥솥", "사악김치", "허세숟갈", "삐딱곰팡,", + "킹받는", "뇌절하는", "뻘쭘한", "영혼없는", "근본없는", "정신나간", "골때리는", "띠꺼운", "오지는", "지리는", + "힙스터", "처량한", "아련한", "새초롬한", "능글맞은", "요염한", "흐물한", "말랑한", "미끈한", "푸석한", "눅눅한", + "바삭한", "맨들한", "오싹한", "후련한", "나른한", "시크한", "쿨한", "힙한", "아방궁", "급발진", "알록달록", + "뇌맑은", "핵인싸", "아싸", "무념무상", "만사귀찮" + ); + + static Set param2 = Set.of( + "술고래", "겨자잎", "청개구리", "산수유", "맥주문어","소맥언덕", "파전악어", "민트라쿤", "내복토끼", "곰탕판다", "꿀꿀선비", "돌솥비버", "냄비펭귄", + "짬뽕기린", "멸치왕국", "해장국곰", "막창펭귄", "어묵사자", "부추말벌", "두부오리", "닭털마을", "청하상어", + "참치꽁치", "펭귄짱", "참외멍게", "고추전갈", "닭껍질곰", "찌개나방", "라멘수달", "나베공룡", "스시참새", + "두꺼비", "너구리", "호랑이", "도깨비", "유령", "요정", "해적", "닌자", "악마", "천사", "비둘기", "참새", + "고양이", "강아지", "문어", "오징어", "하이에나", "치와와", "불도저", "로켓", "우주선", + "막걸리", "와인", "고량주", "피자곰", "핫도그", "계란빵", "붕어빵", + "호떡맨", "마늘곰", "양파맨", "망치곰", "병따개", "유리컵", "벽돌맨", "전봇대", "철가방", "주전자", "핵주먹", + "불방망", "돌멩이", "똥파리", "방구쟁이", "잠만보", "도시락", "고무줄", "지우개", "알람시계", "망치", + "연필심", "라이터", "파리채", "날개", "지하철", "국밥요정", "소주요정", "안주킬러", "스틸러", + "김치도둑", "김밥도사", "파전전사", "치킨귀족", "라면냄새", "도인", "신선", "무당", "광대", "사또", "망나니", "무법자", "몽상가", "한량", + "칼잽이", "총잡이", "도굴꾼", "장사치", "협객", "자객", "조폭", "선비", + "마왕", "용사", "좀비", "강시", "구미호", "늑대", "여우", "불곰", "흑표", "승냥이", "삵", "해태", + "가물치", "메기", "미꾸리", "쏘가리", "날치", "가오리", "해마", "불가사", "성게", "멍게", "해삼", + "장작", "아궁이", "부뚜막", "가마솥", "물레방", "초가집", "기왓장", "대청", "마루", "장독", + "막장", "염전", "몽둥이", "곡괭이", "삽자루", "낫", "호미", "지게", "대못", "나사", "너트", "볼트", + "핵폭탄", "수류탄", "대포", "미사일", "탱크", "전함", "항공모", "잠수함", "전투기", "레이더", + "보드카", "데킬라", "사케", "위스키", "깔루아", "진토닉", "모히또", "왕짱", + "찐빵","누네띠네", "뻥튀기", "아메바", "해캄", "플랑크", "건달", "양아치", "깡패", "백수", "꼰대", + "잼민이", "틀딱", "급식", "학식", "아재", "이모", "삼촌", "돌맹이" + ); private final JwtUtil jwtUtil; private final UserRepository userRepository; From 812a6de3b25cc44b9990cfe784782b1b4530323d Mon Sep 17 00:00:00 2001 From: flatCheese <71163228+lkw9241@users.noreply.github.com> Date: Fri, 10 Oct 2025 11:34:18 +0900 Subject: [PATCH 30/45] =?UTF-8?q?[refactor]=EC=B9=B5=ED=85=8C=EC=9D=BC=20?= =?UTF-8?q?=EB=94=94=ED=85=8C=EC=9D=BC=20=EC=A1=B0=ED=9A=8C=20=EA=B8=B0?= =?UTF-8?q?=EB=8A=A5=EC=97=90=20=ED=94=84=EB=A6=AC=EB=B7=B0=20=EA=B8=B0?= =?UTF-8?q?=EB=8A=A5=EC=B6=94=EA=B0=80=20(#261)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix : bugs of testCase, init data * fix : bug * refactor : add cocktail preview in DetailResponseDto --- .../dto/CocktailDetailResponseDto.java | 23 ++++++++++++++++++- .../cocktail/service/CocktailService.java | 13 +---------- 2 files changed, 23 insertions(+), 13 deletions(-) diff --git a/src/main/java/com/back/domain/cocktail/dto/CocktailDetailResponseDto.java b/src/main/java/com/back/domain/cocktail/dto/CocktailDetailResponseDto.java index f6fb8591..ddcac3f5 100644 --- a/src/main/java/com/back/domain/cocktail/dto/CocktailDetailResponseDto.java +++ b/src/main/java/com/back/domain/cocktail/dto/CocktailDetailResponseDto.java @@ -1,5 +1,6 @@ package com.back.domain.cocktail.dto; +import com.back.domain.cocktail.entity.Cocktail; import com.back.domain.cocktail.service.CocktailService; import java.util.List; @@ -14,6 +15,26 @@ public record CocktailDetailResponseDto( String cocktailImgUrl, String cocktailStory, List ingredient, - String recipe + String recipe, + String cocktailPreview ) { + public static CocktailDetailResponseDto from(Cocktail cocktail, List ingredients){ + String preview =cocktail.getCocktailStory().length() >80 ? + cocktail.getCocktailStory().substring(0,80)+"..." + : cocktail.getCocktailStory(); + + return new CocktailDetailResponseDto( + cocktail.getId(), + cocktail.getCocktailName(), + cocktail.getCocktailNameKo(), + cocktail.getAlcoholStrength().getDescription(), + cocktail.getCocktailType().getDescription(), + cocktail.getAlcoholBaseType().getDescription(), + cocktail.getCocktailImgUrl(), + cocktail.getCocktailStory(), + ingredients, + cocktail.getRecipe(), + preview + ); + } } diff --git a/src/main/java/com/back/domain/cocktail/service/CocktailService.java b/src/main/java/com/back/domain/cocktail/service/CocktailService.java index f302310b..d64b6a26 100644 --- a/src/main/java/com/back/domain/cocktail/service/CocktailService.java +++ b/src/main/java/com/back/domain/cocktail/service/CocktailService.java @@ -124,18 +124,7 @@ public CocktailDetailResponseDto getCocktailDetailById(Long cocktailId) { // ingredient 분수 변환 List formattedIngredient = parseIngredients(convertFractions(cocktail.getIngredient())); - return new CocktailDetailResponseDto( - cocktail.getId(), - cocktail.getCocktailName(), - cocktail.getCocktailNameKo(), - cocktail.getAlcoholStrength().getDescription(), - cocktail.getCocktailType().getDescription(), - cocktail.getAlcoholBaseType().getDescription(), - cocktail.getCocktailImgUrl(), - cocktail.getCocktailStory(), - formattedIngredient, - cocktail.getRecipe() - ); + return CocktailDetailResponseDto.from(cocktail, formattedIngredient); } private String convertFractions(String ingredient) { From f774a46e08968510501e730bd33439e7c714ffe3 Mon Sep 17 00:00:00 2001 From: SeokGeunHo Date: Fri, 10 Oct 2025 15:18:09 +0900 Subject: [PATCH 31/45] =?UTF-8?q?[fix]=20=EA=B2=8C=EC=8B=9C=EA=B8=80=20?= =?UTF-8?q?=EC=9E=91=EC=84=B1=20=EC=98=A4=EB=A5=98=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/java/com/back/domain/post/post/entity/Post.java | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/main/java/com/back/domain/post/post/entity/Post.java b/src/main/java/com/back/domain/post/post/entity/Post.java index c0734789..70791888 100644 --- a/src/main/java/com/back/domain/post/post/entity/Post.java +++ b/src/main/java/com/back/domain/post/post/entity/Post.java @@ -68,6 +68,7 @@ public class Post { private List comments = new ArrayList<>(); // Post → PostImage = 1:N + @Builder.Default @OneToMany(mappedBy = "post", cascade = CascadeType.ALL, orphanRemoval = true) @OrderBy("sortOrder ASC") // 조회 시 순서대로 정렬 private List images = new ArrayList<>(); @@ -76,6 +77,7 @@ public class Post { @Column(name = "video_url") private String videoUrl; + @Builder.Default @OneToMany(mappedBy = "post", cascade = CascadeType.ALL, orphanRemoval = true) private List postTags = new ArrayList<>(); From 4fd0240090baeccb7cdc36f07e8903f83c6f51ff Mon Sep 17 00:00:00 2001 From: MEOHIN <96607465+MEOHIN@users.noreply.github.com> Date: Fri, 10 Oct 2025 15:19:29 +0900 Subject: [PATCH 32/45] =?UTF-8?q?[feat/refactor]=20=EB=82=98=EB=A7=8C?= =?UTF-8?q?=EC=9D=98=20bar=20API=20=EB=B6=84=EB=A6=AC=20=EB=B0=8F=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80=20#258=20(#264)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: MyBarIdResponseDto 추가 및 필드 정의 * feat: 마이바(MyBar) 목록 전체 조회 메서드 추가 (페이징 X) * refactor: MyBar 목록 조회를 ID 목록과 상세 목록으로 분리 * feat: MyBar Controller에 경량 ID 목록 조회 API 추가 및 상세 목록 분리 * test: MyBar 목록 조회 API 분리 및 테스트 코드 업데이트 --------- Co-authored-by: develosopher --- .../mybar/controller/MyBarController.java | 58 ++++---- .../domain/mybar/dto/MyBarIdResponseDto.java | 22 +++ .../mybar/repository/MyBarRepository.java | 4 +- .../domain/mybar/service/MyBarService.java | 29 ++-- .../mybar/controller/MyBarControllerTest.java | 133 +++++++----------- 5 files changed, 123 insertions(+), 123 deletions(-) create mode 100644 src/main/java/com/back/domain/mybar/dto/MyBarIdResponseDto.java diff --git a/src/main/java/com/back/domain/mybar/controller/MyBarController.java b/src/main/java/com/back/domain/mybar/controller/MyBarController.java index d8047ffd..808dbb77 100644 --- a/src/main/java/com/back/domain/mybar/controller/MyBarController.java +++ b/src/main/java/com/back/domain/mybar/controller/MyBarController.java @@ -1,5 +1,6 @@ package com.back.domain.mybar.controller; +import com.back.domain.mybar.dto.MyBarIdResponseDto; import com.back.domain.mybar.dto.MyBarListResponseDto; import com.back.domain.mybar.service.MyBarService; import com.back.global.rsData.RsData; @@ -7,14 +8,20 @@ import io.swagger.v3.oas.annotations.Operation; import jakarta.validation.constraints.Max; import jakarta.validation.constraints.Min; +import java.time.LocalDateTime; +import java.util.List; import lombok.RequiredArgsConstructor; import org.springframework.format.annotation.DateTimeFormat; import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.validation.annotation.Validated; -import org.springframework.web.bind.annotation.*; - -import java.time.LocalDateTime; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; @RestController @RequestMapping("/me/bar") @@ -23,41 +30,32 @@ @PreAuthorize("isAuthenticated()") public class MyBarController { - /** - * 내 바(킵) API 컨트롤러. - * 내가 킵한 칵테일 목록 조회, 킵 추가/복원, 킵 해제를 제공합니다. - */ - private final MyBarService myBarService; - /** - * 내 바 목록 조회(무한스크롤) - * @param userId 인증된 사용자 ID - * @param lastKeptAt 이전 페이지 마지막 keptAt (옵션) - * @param lastId 이전 페이지 마지막 id (옵션) - * @param limit 페이지 크기(1~100) - * @return 킵 아이템 목록과 다음 페이지 커서 - */ @GetMapping - @Operation(summary = "내 바 목록", description = "내가 킵한 칵테일 목록 조회. 무한 스크롤 커서 지원") - public RsData getMyBarList( + @Operation(summary = "내 바 경량 목록", description = "찜 ID 목록을 반환합니다.") + public RsData> getMyBarIds( + @AuthenticationPrincipal SecurityUser principal + ) { + Long userId = principal.getId(); + List body = myBarService.getMyBarIds(userId); + return RsData.successOf(body); + } + + @GetMapping("/detail") + @Operation(summary = "내 바 상세 목록", description = "커서 기반으로 상세 찜 정보를 반환합니다.") + public RsData getMyBarDetail( @AuthenticationPrincipal SecurityUser principal, @RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) LocalDateTime lastKeptAt, @RequestParam(required = false) Long lastId, - @RequestParam(defaultValue = "20") @Min(1) @Max(100) int limit + @RequestParam(defaultValue = "50") @Min(1) @Max(100) int limit ) { Long userId = principal.getId(); - MyBarListResponseDto body = myBarService.getMyBar(userId, lastKeptAt, lastId, limit); + MyBarListResponseDto body = myBarService.getMyBarDetail(userId, lastKeptAt, lastId, limit); return RsData.successOf(body); } - /** - * 킵 추가(생성/복원/재킵) - * @param userId 인증된 사용자 ID - * @param cocktailId 칵테일 ID - * @return 201 kept - */ @PostMapping("/{cocktailId}/keep") @Operation(summary = "킵 추가/복원", description = "해당 칵테일을 내 바에 킵합니다. 이미 삭제 상태면 복원") public RsData keep( @@ -66,15 +64,9 @@ public RsData keep( ) { Long userId = principal.getId(); myBarService.keep(userId, cocktailId); - return RsData.of(201, "kept"); // Aspect가 HTTP 201로 설정 + return RsData.of(201, "kept"); } - /** - * 킵 해제(소프트 삭제) — 멱등 - * @param userId 인증된 사용자 ID - * @param cocktailId 칵테일 ID - * @return 200 deleted - */ @DeleteMapping("/{cocktailId}/keep") @Operation(summary = "킵 해제", description = "내 바에서 해당 칵테일을 삭제(소프트 삭제, 멱등)") public RsData unkeep( diff --git a/src/main/java/com/back/domain/mybar/dto/MyBarIdResponseDto.java b/src/main/java/com/back/domain/mybar/dto/MyBarIdResponseDto.java new file mode 100644 index 00000000..90bc0634 --- /dev/null +++ b/src/main/java/com/back/domain/mybar/dto/MyBarIdResponseDto.java @@ -0,0 +1,22 @@ +package com.back.domain.mybar.dto; + +import com.back.domain.mybar.entity.MyBar; +import java.time.LocalDateTime; +import lombok.Builder; +import lombok.Getter; + +@Getter +@Builder +public class MyBarIdResponseDto { + private Long id; + private Long cocktailId; + private LocalDateTime keptAt; + + public static MyBarIdResponseDto from(MyBar myBar) { + return MyBarIdResponseDto.builder() + .id(myBar.getId()) + .cocktailId(myBar.getCocktail().getId()) + .keptAt(myBar.getKeptAt()) + .build(); + } +} diff --git a/src/main/java/com/back/domain/mybar/repository/MyBarRepository.java b/src/main/java/com/back/domain/mybar/repository/MyBarRepository.java index 84c45b96..f4c858b3 100644 --- a/src/main/java/com/back/domain/mybar/repository/MyBarRepository.java +++ b/src/main/java/com/back/domain/mybar/repository/MyBarRepository.java @@ -15,9 +15,11 @@ @Repository public interface MyBarRepository extends JpaRepository { - /** 나만의 bar(킵) 목록: ACTIVE만, id desc */ + /** 나만의 bar(킵) 목록: ACTIVE만, keptAt desc + id desc */ Page findByUser_IdAndStatusOrderByKeptAtDescIdDesc(Long userId, KeepStatus status, Pageable pageable); + List findByUser_IdAndStatusOrderByKeptAtDescIdDesc(Long userId, KeepStatus status); + @Query(""" select m from MyBar m where m.user.id = :userId diff --git a/src/main/java/com/back/domain/mybar/service/MyBarService.java b/src/main/java/com/back/domain/mybar/service/MyBarService.java index c93d22b9..6f7ced4a 100644 --- a/src/main/java/com/back/domain/mybar/service/MyBarService.java +++ b/src/main/java/com/back/domain/mybar/service/MyBarService.java @@ -1,6 +1,7 @@ package com.back.domain.mybar.service; import com.back.domain.cocktail.repository.CocktailRepository; +import com.back.domain.mybar.dto.MyBarIdResponseDto; import com.back.domain.mybar.dto.MyBarItemResponseDto; import com.back.domain.mybar.dto.MyBarListResponseDto; import com.back.domain.mybar.entity.MyBar; @@ -8,6 +9,10 @@ import com.back.domain.mybar.repository.MyBarRepository; import com.back.domain.user.repository.UserRepository; import com.back.domain.user.service.AbvScoreService; +import java.time.LocalDateTime; +import java.util.List; +import java.util.Optional; +import java.util.stream.Collectors; import lombok.RequiredArgsConstructor; import org.springframework.data.domain.Page; import org.springframework.data.domain.PageRequest; @@ -15,11 +20,6 @@ import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; -import java.time.LocalDateTime; -import java.util.ArrayList; -import java.util.List; -import java.util.Optional; - @Service @RequiredArgsConstructor public class MyBarService { @@ -33,7 +33,15 @@ public class MyBarService { // - 커서: lastKeptAt + lastId 조합으로 안정적인 정렬/페이지네이션 // - 첫 페이지: 가장 최근 keptAt 기준으로 최신순 @Transactional(readOnly = true) - public MyBarListResponseDto getMyBar(Long userId, LocalDateTime lastKeptAt, Long lastId, int limit) { + public List getMyBarIds(Long userId) { + List rows = myBarRepository.findByUser_IdAndStatusOrderByKeptAtDescIdDesc(userId, KeepStatus.ACTIVE); + return rows.stream() + .map(MyBarIdResponseDto::from) + .collect(Collectors.toList()); + } + + @Transactional(readOnly = true) + public MyBarListResponseDto getMyBarDetail(Long userId, LocalDateTime lastKeptAt, Long lastId, int limit) { int safeLimit = Math.max(1, Math.min(limit, 100)); int fetchSize = safeLimit + 1; // 다음 페이지 여부 판단용으로 1개 더 조회 @@ -50,10 +58,13 @@ public MyBarListResponseDto getMyBar(Long userId, LocalDateTime lastKeptAt, Long // +1 로우가 있으면 다음 페이지가 존재 boolean hasNext = rows.size() > safeLimit; - if (hasNext) rows = rows.subList(0, safeLimit); + if (hasNext) { + rows = rows.subList(0, safeLimit); + } - List items = new ArrayList<>(); - for (MyBar myBar : rows) items.add(MyBarItemResponseDto.from(myBar)); + List items = rows.stream() + .map(MyBarItemResponseDto::from) + .collect(Collectors.toList()); LocalDateTime nextKeptAt = null; Long nextId = null; diff --git a/src/test/java/com/back/domain/mybar/controller/MyBarControllerTest.java b/src/test/java/com/back/domain/mybar/controller/MyBarControllerTest.java index b006e39a..8c1a8854 100644 --- a/src/test/java/com/back/domain/mybar/controller/MyBarControllerTest.java +++ b/src/test/java/com/back/domain/mybar/controller/MyBarControllerTest.java @@ -1,6 +1,7 @@ package com.back.domain.mybar.controller; import com.back.domain.cocktail.enums.AlcoholStrength; +import com.back.domain.mybar.dto.MyBarIdResponseDto; import com.back.domain.mybar.dto.MyBarItemResponseDto; import com.back.domain.mybar.dto.MyBarListResponseDto; import com.back.domain.mybar.service.MyBarService; @@ -96,36 +97,24 @@ private RequestPostProcessor withPrincipal(SecurityUser principal) { } @Test - @DisplayName("Get my bar list - first page") - void getMyBarList_withoutCursor() throws Exception { - SecurityUser principal = createPrincipal(1L); - LocalDateTime keptAt = LocalDateTime.of(2025, 1, 1, 10, 0); - LocalDateTime createdAt = keptAt.minusDays(1); - - MyBarItemResponseDto item = MyBarItemResponseDto.builder() - .id(3L) - .cocktailId(10L) - .cocktailName("Margarita") - .cocktailNameKo("留덇?由ы?") - .alcoholStrength(AlcoholStrength.LIGHT) - .imageUrl("https://example.com/margarita.jpg") - .createdAt(createdAt) - .keptAt(keptAt) - .build(); - - MyBarListResponseDto responseDto = new MyBarListResponseDto( - List.of(item), - true, - keptAt.minusMinutes(5), - 2L + @DisplayName("경량 내 바 목록을 조회한다") + void getMyBarIds() throws Exception { + SecurityUser principal = createPrincipal(5L); + + List response = List.of( + MyBarIdResponseDto.builder() + .id(123L) + .cocktailId(1L) + .keptAt(LocalDateTime.of(2025, 10, 10, 12, 0)) + .build(), + MyBarIdResponseDto.builder() + .id(124L) + .cocktailId(5L) + .keptAt(LocalDateTime.of(2025, 10, 9, 15, 30)) + .build() ); - given(myBarService.getMyBar( - eq(principal.getId()), - isNull(LocalDateTime.class), - isNull(Long.class), - eq(20) - )).willReturn(responseDto); + given(myBarService.getMyBarIds(principal.getId())).willReturn(response); mockMvc.perform(get("/me/bar") .with(withPrincipal(principal)) @@ -133,85 +122,69 @@ void getMyBarList_withoutCursor() throws Exception { .andExpect(status().isOk()) .andExpect(jsonPath("$.code").value(200)) .andExpect(jsonPath("$.message").value("success")) - .andExpect(jsonPath("$.data.items[0].id").value(3L)) - .andExpect(jsonPath("$.data.items[0].cocktailId").value(10L)) - .andExpect(jsonPath("$.data.items[0].cocktailName").value("Margarita")) - .andExpect(jsonPath("$.data.items[0].cocktailNameKo").value("留덇?由ы?")) - .andExpect(jsonPath("$.data.items[0].alcoholStrength").value("LIGHT")) - .andExpect(jsonPath("$.data.items[0].imageUrl").value("https://example.com/margarita.jpg")) - .andExpect(jsonPath("$.data.items[0].createdAt").value(ISO_WITH_SECONDS.format(createdAt))) - .andExpect(jsonPath("$.data.items[0].keptAt").value(ISO_WITH_SECONDS.format(keptAt))) - .andExpect(jsonPath("$.data.hasNext").value(true)) - .andExpect(jsonPath("$.data.nextKeptAt").value(ISO_WITH_SECONDS.format(keptAt.minusMinutes(5)))) - .andExpect(jsonPath("$.data.nextId").value(2L)); + .andExpect(jsonPath("$.data[0].id").value(123L)) + .andExpect(jsonPath("$.data[0].cocktailId").value(1L)) + .andExpect(jsonPath("$.data[0].keptAt").value("2025-10-10T12:00:00")) + .andExpect(jsonPath("$.data[1].cocktailId").value(5L)); - verify(myBarService).getMyBar( - eq(principal.getId()), - isNull(LocalDateTime.class), - isNull(Long.class), - eq(20) - ); + verify(myBarService).getMyBarIds(principal.getId()); } @Test - @DisplayName("Get my bar list - next page") - void getMyBarList_withCursor() throws Exception { - SecurityUser principal = createPrincipal(7L); - LocalDateTime cursorKeptAt = LocalDateTime.of(2025, 2, 10, 9, 30, 15); - LocalDateTime itemKeptAt = cursorKeptAt.minusMinutes(1); - LocalDateTime itemCreatedAt = itemKeptAt.minusDays(2); + @DisplayName("상세 내 바 목록을 조회한다") + void getMyBarDetail() throws Exception { + SecurityUser principal = createPrincipal(9L); + LocalDateTime keptAt = LocalDateTime.of(2025, 10, 1, 10, 0); + LocalDateTime createdAt = keptAt.minusDays(1); MyBarItemResponseDto item = MyBarItemResponseDto.builder() - .id(20L) - .cocktailId(33L) - .cocktailName("Negroni") - .cocktailNameKo("?ㅺ렇濡쒕땲") - .alcoholStrength(AlcoholStrength.STRONG) - .imageUrl("https://example.com/negroni.jpg") - .createdAt(itemCreatedAt) - .keptAt(itemKeptAt) + .id(123L) + .cocktailId(1L) + .cocktailName("Mojito") + .cocktailNameKo("모히또") + .alcoholStrength(AlcoholStrength.MEDIUM) + .imageUrl("https://example.com/mojito.jpg") + .createdAt(createdAt) + .keptAt(keptAt) .build(); - MyBarListResponseDto responseDto = new MyBarListResponseDto( + MyBarListResponseDto response = new MyBarListResponseDto( List.of(item), false, null, null ); - given(myBarService.getMyBar( + given(myBarService.getMyBarDetail( eq(principal.getId()), - eq(cursorKeptAt), - eq(99L), - eq(5) - )).willReturn(responseDto); + isNull(LocalDateTime.class), + isNull(Long.class), + eq(50) + )).willReturn(response); - mockMvc.perform(get("/me/bar") + mockMvc.perform(get("/me/bar/detail") .with(withPrincipal(principal)) - .param("lastKeptAt", cursorKeptAt.toString()) - .param("lastId", "99") - .param("limit", "5") + .param("limit", "50") .accept(MediaType.APPLICATION_JSON)) .andExpect(status().isOk()) .andExpect(jsonPath("$.code").value(200)) .andExpect(jsonPath("$.message").value("success")) - .andExpect(jsonPath("$.data.items[0].id").value(20L)) - .andExpect(jsonPath("$.data.items[0].cocktailName").value("Negroni")) - .andExpect(jsonPath("$.data.items[0].cocktailNameKo").value("?ㅺ렇濡쒕땲")) - .andExpect(jsonPath("$.data.items[0].alcoholStrength").value("STRONG")) + .andExpect(jsonPath("$.data.items[0].cocktailName").value("Mojito")) + .andExpect(jsonPath("$.data.items[0].cocktailNameKo").value("모히또")) + .andExpect(jsonPath("$.data.items[0].keptAt").value(ISO_WITH_SECONDS.format(keptAt))) .andExpect(jsonPath("$.data.hasNext").value(false)) .andExpect(jsonPath("$.data.nextKeptAt").doesNotExist()); - verify(myBarService).getMyBar( + verify(myBarService).getMyBarDetail( eq(principal.getId()), - eq(cursorKeptAt), - eq(99L), - eq(5) + isNull(LocalDateTime.class), + isNull(Long.class), + eq(50) ); } @Test - @DisplayName("Keep cocktail") + @DisplayName("킵 추가") void keepCocktail() throws Exception { SecurityUser principal = createPrincipal(11L); Long cocktailId = 42L; @@ -230,7 +203,7 @@ void keepCocktail() throws Exception { } @Test - @DisplayName("Unkeep cocktail") + @DisplayName("킵 해제") void unkeepCocktail() throws Exception { SecurityUser principal = createPrincipal(12L); Long cocktailId = 77L; @@ -247,4 +220,4 @@ void unkeepCocktail() throws Exception { verify(myBarService).unkeep(principal.getId(), cocktailId); } -} \ No newline at end of file +} From 2e775864235571b921d59a4b32ed5d4ed881186a Mon Sep 17 00:00:00 2001 From: flatCheese <71163228+lkw9241@users.noreply.github.com> Date: Fri, 10 Oct 2025 15:26:40 +0900 Subject: [PATCH 33/45] =?UTF-8?q?[fix]=EC=B9=B5=ED=85=8C=EC=9D=BC=20?= =?UTF-8?q?=EB=8B=A4=EA=B1=B4=EC=A1=B0=ED=9A=8C=20=EB=AC=B4=ED=95=9C?= =?UTF-8?q?=EC=8A=A4=ED=81=AC=EB=A1=A4=20=EB=B2=84=EA=B7=B8=20=EC=88=98?= =?UTF-8?q?=EC=A0=95=20(#268)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix : bugs of testCase, init data * fix : bug * fix : parameter name * fix: bug --- .../domain/cocktail/service/CocktailService.java | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/src/main/java/com/back/domain/cocktail/service/CocktailService.java b/src/main/java/com/back/domain/cocktail/service/CocktailService.java index d64b6a26..82b8039d 100644 --- a/src/main/java/com/back/domain/cocktail/service/CocktailService.java +++ b/src/main/java/com/back/domain/cocktail/service/CocktailService.java @@ -43,21 +43,23 @@ public List getCocktails(Long lastValue, Long lastId Pageable pageable = PageRequest.of(0, fetchSize); List cocktails; + Long cursor = (lastValue != null) ? lastValue : lastId; + switch (sortBy != null ? sortBy.toLowerCase() : "") { case "keeps": - cocktails = (lastValue == null) + cocktails = (cursor == null) ? cocktailRepository.findAllOrderByKeepCountDesc(pageable) - : cocktailRepository.findByKeepCountLessThanOrderByKeepCountDesc(lastValue, lastId, pageable); + : cocktailRepository.findByKeepCountLessThanOrderByKeepCountDesc(cursor, lastId, pageable); break; case "comments": - cocktails = (lastValue == null) + cocktails = (cursor == null) ? cocktailRepository.findAllOrderByCommentsCountDesc(pageable) - : cocktailRepository.findByCommentsCountLessThanOrderByCommentsCountDesc(lastValue, lastId, pageable); + : cocktailRepository.findByCommentsCountLessThanOrderByCommentsCountDesc(cursor, lastId, pageable); break; default: - cocktails = (lastValue == null) + cocktails = (cursor == null) ? cocktailRepository.findAllByOrderByIdDesc(pageable) - : cocktailRepository.findByIdLessThanOrderByIdDesc(lastValue, pageable); + : cocktailRepository.findByIdLessThanOrderByIdDesc(cursor, pageable); break; } From 856f0a1ae304e548a4378c196574b8f220f6a710 Mon Sep 17 00:00:00 2001 From: GerHerMo Date: Fri, 10 Oct 2025 16:29:41 +0900 Subject: [PATCH 34/45] refactor: CRAD_LIST on QA mode --- .../chatbot/service/ChatbotService.java | 348 +++++++++++------- 1 file changed, 213 insertions(+), 135 deletions(-) diff --git a/src/main/java/com/back/domain/chatbot/service/ChatbotService.java b/src/main/java/com/back/domain/chatbot/service/ChatbotService.java index 470c0ac6..d544c275 100644 --- a/src/main/java/com/back/domain/chatbot/service/ChatbotService.java +++ b/src/main/java/com/back/domain/chatbot/service/ChatbotService.java @@ -46,7 +46,7 @@ public class ChatbotService { private final ChatConversationRepository chatConversationRepository; private final CocktailRepository cocktailRepository; - private final ObjectMapper objectMapper = new ObjectMapper(); // JSON 변환용 + private final ObjectMapper objectMapper = new ObjectMapper(); @Value("classpath:prompts/chatbot-system-prompt.txt") private Resource systemPromptResource; @@ -120,11 +120,6 @@ public ChatResponseDto sendMessage(ChatRequestDto requestDto) { chatConversationRepository.save(userChoice); String guideMessage = "칵테일에 관련된 질문을 입력해주세요!"; - /* - String guideMessage = "좋아요! 질문형 추천을 시작할게요 🎯\n" + - "칵테일에 관련된 질문을 자유롭게 입력해주세요!\n" + - "예시: 달콤한 칵테일 추천해줘, 파티용 칵테일이 필요해, 초보자용 칵테일 알려줘"; - */ ChatConversation botGuide = ChatConversation.builder() .userId(requestDto.getUserId()) @@ -149,9 +144,9 @@ public ChatResponseDto sendMessage(ChatRequestDto requestDto) { .build(); } - // 실제 질문이 들어온 경우 AI 응답 생성 + // 실제 질문이 들어온 경우 - AI 기반 칵테일 추천 log.info("질문형 추천 모드 진입 - userId: {}", requestDto.getUserId()); - return generateAIResponseWithContext(requestDto, "질문형 추천"); + return generateQARecommendation(requestDto); } else if (currentStep >= 1 && currentStep <= 4) { // 단계별 추천 @@ -192,11 +187,191 @@ else if (currentStep >= 1 && currentStep <= 4) { } } + /** + * 질문형 추천 - AI가 질문을 분석하여 칵테일 추천 + */ + private ChatResponseDto generateQARecommendation(ChatRequestDto requestDto) { + String userQuestion = requestDto.getMessage(); + + // 1. AI를 통해 사용자 질문 분석 및 추천 칵테일 목록 생성 + List recommendedCocktailNames = analyzeCocktailRequest(userQuestion); + + // 2. DB에서 칵테일 검색 (최대 7개 검색하여 3개 선택) + List recommendations = new ArrayList<>(); + for (String cocktailName : recommendedCocktailNames) { + if (recommendations.size() >= 3) break; + + // 칵테일 이름으로 검색 + Page cocktailPage = cocktailRepository.searchWithFilters( + cocktailName, + null, + null, + null, + PageRequest.of(0, 1) + ); + + if (!cocktailPage.isEmpty()) { + Cocktail cocktail = cocktailPage.getContent().get(0); + recommendations.add(new CocktailSummaryResponseDto( + cocktail.getId(), + cocktail.getCocktailName(), + cocktail.getCocktailNameKo(), + cocktail.getCocktailImgUrl(), + cocktail.getAlcoholStrength().getDescription() + )); + } + } + + // 3. 추천 결과가 없으면 일반 텍스트 응답 + if (recommendations.isEmpty()) { + return generateTextResponse(requestDto, userQuestion); + } + + // 4. AI를 통해 추천 메시지 생성 + String recommendationMessage = generateRecommendationMessage(userQuestion, recommendations); + + // 5. StepRecommendationResponseDto 생성 + StepRecommendationResponseDto stepData = new StepRecommendationResponseDto( + 0, // 질문형은 step 0 + recommendationMessage, + null, + recommendations, + true + ); + + // 6. 봇 응답 저장 + ChatConversation savedResponse = saveBotResponse( + requestDto.getUserId(), + recommendationMessage, + stepData + ); + + // 7. ChatResponseDto 반환 + return ChatResponseDto.builder() + .id(savedResponse.getId()) + .userId(requestDto.getUserId()) + .message(recommendationMessage) + .sender(MessageSender.CHATBOT) + .type(MessageType.CARD_LIST) + .stepData(stepData) + .createdAt(savedResponse.getCreatedAt()) + .metaData(ChatResponseDto.MetaData.builder() + .currentStep(0) + .actionType("질문형 추천") + .build()) + .build(); + } + + /** + * AI를 통해 사용자 질문 분석하여 추천할 칵테일 이름 목록 반환 + */ + private List analyzeCocktailRequest(String userQuestion) { + String analysisPrompt = """ + 사용자가 다음과 같은 칵테일 관련 질문을 했습니다: + "%s" + + 이 질문에 가장 적합한 칵테일을 최대 7개까지 추천해주세요. + 다음 형식으로만 응답하세요 (칵테일 이름만, 한 줄에 하나씩): + 칵테일이름1 + 칵테일이름2 + 칵테일이름3 + ... + + 주의사항: + - 영문 칵테일 이름만 작성 + - 부가 설명 없이 칵테일 이름만 + - 실제 존재하는 유명한 칵테일만 추천 + """.formatted(userQuestion); + + try { + String response = chatClient.prompt() + .system("당신은 칵테일 전문가입니다. 사용자 질문에 맞는 칵테일을 추천합니다.") + .user(analysisPrompt) + .options(OpenAiChatOptions.builder() + .withTemperature(0.7) + .withMaxTokens(150) + .build()) + .call() + .content(); + + // 응답을 줄 단위로 파싱하여 칵테일 이름 목록 생성 + List cocktailNames = response.lines() + .map(String::trim) + .filter(line -> !line.isEmpty()) + .limit(7) + .collect(Collectors.toList()); + + log.info("AI 추천 칵테일 목록: {}", cocktailNames); + return cocktailNames; + + } catch (Exception e) { + log.error("칵테일 분석 중 오류: ", e); + // 오류 시 기본 칵테일 목록 반환 + return List.of("Mojito", "Margarita", "Cosmopolitan", "Martini", "Daiquiri"); + } + } + + /** + * AI를 통해 추천 메시지 생성 + */ + private String generateRecommendationMessage(String userQuestion, List recommendations) { + String cocktailList = recommendations.stream() + .map(c -> c.cocktailNameKo() != null ? c.cocktailNameKo() : c.cocktailName()) + .collect(Collectors.joining(", ")); + + String messagePrompt = """ + 사용자가 "%s"라고 질문했습니다. + + 다음 칵테일들을 추천합니다: %s + + 사용자의 질문을 반영한 친근한 추천 메시지를 100자 이내로 작성해주세요. + '쑤리'라는 바텐더 캐릭터로 답변하며, 사용자 질문의 핵심을 언급하면서 칵테일 추천을 자연스럽게 연결하세요. + 이모지를 1-2개 포함하세요. + """.formatted(userQuestion, cocktailList); + + try { + String message = chatClient.prompt() + .system(systemPrompt) + .user(messagePrompt) + .options(OpenAiChatOptions.builder() + .withTemperature(0.8) + .withMaxTokens(100) + .build()) + .call() + .content(); + + return message.trim(); + + } catch (Exception e) { + log.error("추천 메시지 생성 중 오류: ", e); + return "🍹 요청하신 칵테일을 찾아봤어요! 쑤리가 엄선한 칵테일들을 추천해드릴게요."; + } + } + + /** + * 추천할 칵테일이 없을 경우 일반 텍스트 응답 생성 + */ + private ChatResponseDto generateTextResponse(ChatRequestDto requestDto, String userQuestion) { + ChatConversation savedResponse = generateAIResponse(requestDto); + + return ChatResponseDto.builder() + .id(savedResponse.getId()) + .userId(requestDto.getUserId()) + .message(savedResponse.getMessage()) + .sender(MessageSender.CHATBOT) + .type(MessageType.TEXT) + .createdAt(savedResponse.getCreatedAt()) + .metaData(ChatResponseDto.MetaData.builder() + .currentStep(0) + .actionType("질문형 추천") + .build()) + .build(); + } + private void saveUserMessage(ChatRequestDto requestDto) { String metadata = null; if (requestDto.getSelectedValue() != null) { try { - // 사용자가 선택한 실제 값(value)을 JSON으로 저장 metadata = objectMapper.writeValueAsString(Map.of("selectedValue", requestDto.getSelectedValue())); } catch (JsonProcessingException e) { log.error("사용자 선택 값 JSON 직렬화 실패", e); @@ -205,10 +380,10 @@ private void saveUserMessage(ChatRequestDto requestDto) { ChatConversation userMessage = ChatConversation.builder() .userId(requestDto.getUserId()) - .message(requestDto.getMessage()) // 사용자가 본 텍스트(label) + .message(requestDto.getMessage()) .sender(MessageSender.USER) .createdAt(LocalDateTime.now()) - .metadata(metadata) // 선택한 실제 값(value) + .metadata(metadata) .build(); chatConversationRepository.save(userMessage); } @@ -217,7 +392,6 @@ private ChatConversation saveBotResponse(Long userId, String message, Object ste String metadata = null; if (stepData != null) { try { - // 봇이 보낸 옵션, 카드 등 구조화된 데이터를 JSON으로 저장 metadata = objectMapper.writeValueAsString(stepData); } catch (JsonProcessingException e) { log.error("봇 응답 메타데이터 JSON 직렬화 실패", e); @@ -233,8 +407,9 @@ private ChatConversation saveBotResponse(Long userId, String message, Object ste .build(); return chatConversationRepository.save(botResponse); } + /** - * 대화 컨텍스트 빌드 - 변경사항: sender로 구분하여 대화 재구성 + * 대화 컨텍스트 빌드 */ private String buildConversationContext(List recentChats) { if (recentChats.isEmpty()) { @@ -243,7 +418,6 @@ private String buildConversationContext(List recentChats) { StringBuilder context = new StringBuilder("\n\n【최근 대화 기록】\n"); - // 시간 역순으로 정렬된 리스트를 시간순으로 재정렬 List orderedChats = new ArrayList<>(recentChats); orderedChats.sort((a, b) -> a.getCreatedAt().compareTo(b.getCreatedAt())); @@ -259,13 +433,8 @@ private String buildConversationContext(List recentChats) { return context.toString(); } - /** - * 대화 저장 - 변경사항: 사용자 메시지와 봇 응답을 각각 별도로 저장 - * @return 저장된 봇 응답 엔티티 (id 포함) - */ @Transactional public ChatConversation saveConversation(ChatRequestDto requestDto, String response) { - // 1. 사용자 메시지 저장 ChatConversation userMessage = ChatConversation.builder() .userId(requestDto.getUserId()) .message(requestDto.getMessage()) @@ -274,7 +443,6 @@ public ChatConversation saveConversation(ChatRequestDto requestDto, String respo .build(); chatConversationRepository.save(userMessage); - // 2. 봇 응답 저장 ChatConversation botResponse = ChatConversation.builder() .userId(requestDto.getUserId()) .message(response) @@ -284,12 +452,9 @@ public ChatConversation saveConversation(ChatRequestDto requestDto, String respo return chatConversationRepository.save(botResponse); } - /** - * 사용자 채팅 기록 조회 - 변경사항: sender 구분 없이 모든 메시지 시간순으로 조회 - */ @Transactional(readOnly = true) public List getUserChatHistory(Long userId) { - List history = chatConversationRepository.findByUserIdOrderByCreatedAtAsc(userId); // 시간순으로 변경 + List history = chatConversationRepository.findByUserIdOrderByCreatedAtAsc(userId); return history.stream().map(conversation -> { ChatResponseDto.ChatResponseDtoBuilder builder = ChatResponseDto.builder() @@ -313,8 +478,7 @@ public List getUserChatHistory(Long userId) { } else { builder.type(MessageType.TEXT); } - } else { // sender == USER - // 사용자 메시지의 메타데이터는 FE에서 선택 처리 등에 활용 가능 + } else { builder.type(MessageType.TEXT); } } catch (JsonProcessingException e) { @@ -328,10 +492,6 @@ public List getUserChatHistory(Long userId) { }).collect(Collectors.toList()); } - /** - * FE에서 생성한 봇 메시지를 DB에 저장 - * 예: 인사말, 안내 메시지, 에러 메시지 등 - */ @Transactional public ChatConversation saveBotMessage(SaveBotMessageDto dto) { ChatConversation botMessage = ChatConversation.builder() @@ -344,19 +504,12 @@ public ChatConversation saveBotMessage(SaveBotMessageDto dto) { return chatConversationRepository.save(botMessage); } - /** - * 기본 인사말 생성 및 저장 - * 채팅 시작 시 호출하여 인사말을 DB에 저장 - * 이미 동일한 인사말이 존재하면 중복 저장하지 않음 - * MessageType.RADIO_OPTIONS와 options 데이터를 포함한 ChatResponseDto 반환 - */ @Transactional public ChatResponseDto createGreetingMessage(Long userId) { String greetingMessage = "안녕하세요! 🍹 바텐더 '쑤리'에요.\n" + "취향에 맞는 칵테일을 추천해드릴게요!\n" + "어떤 유형으로 찾아드릴까요?"; - // 선택 옵션 생성 List options = List.of( new StepRecommendationResponseDto.StepOption( "QA", @@ -370,20 +523,17 @@ public ChatResponseDto createGreetingMessage(Long userId) { ) ); - // StepRecommendationResponseDto 생성 StepRecommendationResponseDto stepData = new StepRecommendationResponseDto( - 0, // 인사말은 step 0 + 0, greetingMessage, options, null, false ); - // 중복 확인: 동일한 인사말이 이미 존재하는지 확인 boolean greetingExists = chatConversationRepository.existsByUserIdAndMessage(userId, greetingMessage); ChatConversation savedGreeting = null; - // 중복되지 않을 경우에만 DB에 저장 if (!greetingExists) { ChatConversation greeting = ChatConversation.builder() .userId(userId) @@ -397,7 +547,6 @@ public ChatResponseDto createGreetingMessage(Long userId) { log.info("이미 인사말이 존재하여 저장 생략 - userId: {}", userId); } - // ChatResponseDto 반환 (요청된 형식에 맞춰 id, userId, sender, type, createdAt 포함) return ChatResponseDto.builder() .id(savedGreeting != null ? savedGreeting.getId() : null) .userId(userId) @@ -409,21 +558,14 @@ public ChatResponseDto createGreetingMessage(Long userId) { .build(); } - /** - * 사용자의 첫 대화 여부 확인 - * 첫 대화인 경우 인사말 자동 생성에 활용 가능 - */ @Transactional(readOnly = true) public boolean isFirstConversation(Long userId) { return chatConversationRepository.findTop20ByUserIdOrderByCreatedAtDesc(userId).isEmpty(); } - // ============ 기존 메서드들 (변경 없음) ============ - private String buildSystemMessage(InternalMessageType type) { StringBuilder sb = new StringBuilder(systemPrompt); - // 메시지 타입별 추가 지시사항 switch (type) { case RECIPE: sb.append("\n\n【레시피 답변 모드】정확한 재료 비율과 제조 순서를 강조하세요."); @@ -448,15 +590,15 @@ private String buildUserMessage(String userMessage, InternalMessageType type) { private OpenAiChatOptions getOptionsForMessageType(InternalMessageType type) { return switch (type) { case RECIPE -> OpenAiChatOptions.builder() - .withTemperature(0.3) // 정확성 중시 - .withMaxTokens(400) // 레시피는 길게 + .withTemperature(0.3) + .withMaxTokens(400) .build(); case RECOMMENDATION -> OpenAiChatOptions.builder() - .withTemperature(0.9) // 다양성 중시 + .withTemperature(0.9) .withMaxTokens(250) .build(); case QUESTION -> OpenAiChatOptions.builder() - .withTemperature(0.7) // 균형 + .withTemperature(0.7) .withMaxTokens(200) .build(); default -> OpenAiChatOptions.builder() @@ -467,12 +609,10 @@ private OpenAiChatOptions getOptionsForMessageType(InternalMessageType type) { } private String postProcessResponse(String response, InternalMessageType type) { - // 응답 길이 제한 확인 if (response.length() > 500) { response = response.substring(0, 497) + "..."; } - // 이모지 추가 (타입별) if (type == InternalMessageType.RECIPE && !response.contains("🍹")) { response = "🍹 " + response; } @@ -480,44 +620,30 @@ private String postProcessResponse(String response, InternalMessageType type) { return response; } - /** - * AI 응답 생성 - * @return 저장된 봇 응답 엔티티 (id 포함) - */ private ChatConversation generateAIResponse(ChatRequestDto requestDto) { log.info("Normal chat mode for userId: {}", requestDto.getUserId()); - // 메시지 타입 감지 (내부 enum 사용) InternalMessageType messageType = detectMessageType(requestDto.getMessage()); - // 최근 대화 기록 조회 (최신 20개 메시지 - USER와 CHATBOT 메시지 모두 포함) List recentChats = chatConversationRepository.findTop20ByUserIdOrderByCreatedAtDesc(requestDto.getUserId()); - // 대화 컨텍스트 생성 String conversationContext = buildConversationContext(recentChats); - // ChatClient 빌더 생성 var promptBuilder = chatClient.prompt() .system(buildSystemMessage(messageType) + conversationContext) .user(buildUserMessage(requestDto.getMessage(), messageType)); - // 응답 생성 String response = promptBuilder .options(getOptionsForMessageType(messageType)) .call() .content(); - // 응답 후처리 response = postProcessResponse(response, messageType); - // 봇 응답만 저장 -> 사용자 메시지는 sendmessage()에서 이미 저장됨 return saveBotResponse(requestDto.getUserId(), response, null); } - /** - * 로딩 메시지 생성 - */ public ChatResponseDto createLoadingMessage() { return ChatResponseDto.builder() .message("응답을 생성하는 중...") @@ -551,10 +677,6 @@ private InternalMessageType detectMessageType(String message) { return InternalMessageType.CASUAL_CHAT; } - /** - * 단계별 추천 시작 키워드 감지 (레거시 지원) - * @deprecated currentStep 명시적 전달 방식을 사용하세요. 이 메서드는 하위 호환성을 위해 유지됩니다. - */ @Deprecated private boolean isStepRecommendationTrigger(String message) { log.warn("레거시 키워드 감지 사용됨. currentStep 사용 권장. message: {}", message); @@ -562,31 +684,6 @@ private boolean isStepRecommendationTrigger(String message) { return lower.contains("단계별 취향 찾기"); } - /** - * 질문형 추천 전용 AI 응답 생성 - * 일반 대화와 구분하여 추천에 특화된 응답 생성 - */ - private ChatResponseDto generateAIResponseWithContext(ChatRequestDto requestDto, String mode) { - ChatConversation savedResponse = generateAIResponse(requestDto); - - return ChatResponseDto.builder() - .id(savedResponse.getId()) - .userId(requestDto.getUserId()) - .message(savedResponse.getMessage()) - .sender(MessageSender.CHATBOT) - .type(MessageType.TEXT) - .createdAt(savedResponse.getCreatedAt()) - .metaData(ChatResponseDto.MetaData.builder() - .actionType(mode) - .currentStep(0) - .totalSteps(0) - .build()) - .build(); - } - - /** - * 에러 응답 생성 - */ private ChatResponseDto createErrorResponse(String errorMessage) { return ChatResponseDto.builder() .message(errorMessage) @@ -595,12 +692,11 @@ private ChatResponseDto createErrorResponse(String errorMessage) { .createdAt(LocalDateTime.now()) .build(); } + private ChatResponseDto handleStepRecommendation(ChatRequestDto requestDto) { Integer currentStep = requestDto.getCurrentStep(); - // 단계별 추천 선택 시 처리 if (currentStep == 1 && "STEP".equalsIgnoreCase(requestDto.getMessage())) { - // 사용자 선택 메시지 저장 ChatConversation userChoice = ChatConversation.builder() .userId(requestDto.getUserId()) .message("단계별 취향 찾기") @@ -645,9 +741,9 @@ private ChatResponseDto handleStepRecommendation(ChatRequestDto requestDto) { case 4: stepData = getFinalRecommendationsWithMessage( - parseAlcoholStrength(requestDto.getSelectedAlcoholStrength()), - parseAlcoholBaseType(requestDto.getSelectedAlcoholBaseType()), - requestDto.getMessage() + parseAlcoholStrength(requestDto.getSelectedAlcoholStrength()), + parseAlcoholBaseType(requestDto.getSelectedAlcoholBaseType()), + requestDto.getMessage() ); message = stepData.getStepTitle(); type = MessageType.CARD_LIST; @@ -659,20 +755,12 @@ private ChatResponseDto handleStepRecommendation(ChatRequestDto requestDto) { type = MessageType.RADIO_OPTIONS; } - // 봇 응답 저장 - ChatConversation botResponse = ChatConversation.builder() - .userId(requestDto.getUserId()) - .message(message) - .sender(MessageSender.CHATBOT) - .createdAt(LocalDateTime.now()) - .build(); ChatConversation savedResponse = saveBotResponse(requestDto.getUserId(), message, stepData); - // 메타데이터 포함 ChatResponseDto.MetaData metaData = ChatResponseDto.MetaData.builder() .currentStep(currentStep) .totalSteps(4) - .isTyping(type != MessageType.CARD_LIST) // 카드리스트는 타이핑 애니메이션 불필요 + .isTyping(type != MessageType.CARD_LIST) .delay(300) .build(); @@ -687,8 +775,6 @@ private ChatResponseDto handleStepRecommendation(ChatRequestDto requestDto) { .createdAt(savedResponse.getCreatedAt()) .build(); } - // ============ 단계별 추천 관련 메서드들 ============ - // "ALL" 또는 null/빈값은 null로 처리하여 전체 선택 의미 private AlcoholStrength parseAlcoholStrength(String value) { if (value == null || value.trim().isEmpty() || "ALL".equalsIgnoreCase(value)) { @@ -714,11 +800,9 @@ private AlcoholBaseType parseAlcoholBaseType(String value) { } } - private StepRecommendationResponseDto getAlcoholStrengthOptions() { List options = new ArrayList<>(); - // "전체" 옵션 추가 options.add(new StepRecommendationResponseDto.StepOption( "ALL", "전체", @@ -745,7 +829,6 @@ private StepRecommendationResponseDto getAlcoholStrengthOptions() { private StepRecommendationResponseDto getAlcoholBaseTypeOptions(AlcoholStrength alcoholStrength) { List options = new ArrayList<>(); - // "전체" 옵션 추가 options.add(new StepRecommendationResponseDto.StepOption( "ALL", "전체", @@ -769,17 +852,14 @@ private StepRecommendationResponseDto getAlcoholBaseTypeOptions(AlcoholStrength ); } - private StepRecommendationResponseDto getFinalRecommendationsWithMessage( AlcoholStrength alcoholStrength, AlcoholBaseType alcoholBaseType, String userMessage) { - // 필터링 조건에 맞는 칵테일 검색 - // "ALL" 선택 시 해당 필터를 null로 처리하여 전체 검색 + List strengths = (alcoholStrength == null) ? null : List.of(alcoholStrength); List baseTypes = (alcoholBaseType == null) ? null : List.of(alcoholBaseType); - // 'x', '없음' 입력 시 키워드 조건 무시 String keyword = null; if (userMessage != null && !userMessage.trim().isEmpty()) { String trimmed = userMessage.trim().toLowerCase(); @@ -788,26 +868,24 @@ private StepRecommendationResponseDto getFinalRecommendationsWithMessage( } } - // userMessage를 키워드로 사용하여 검색 Page cocktailPage = cocktailRepository.searchWithFilters( - keyword, // 'x', '없음'이면 null, 아니면 사용자 입력 메시지 + keyword, strengths, - null, // cocktailType 사용 안 함 + null, baseTypes, - PageRequest.of(0, 3) // 최대 3개 추천 + PageRequest.of(0, 3) ); List recommendations = cocktailPage.getContent().stream() - .map(cocktail -> new CocktailSummaryResponseDto( - cocktail.getId(), - cocktail.getCocktailName(), - cocktail.getCocktailNameKo(), - cocktail.getCocktailImgUrl(), - cocktail.getAlcoholStrength().getDescription() - )) - .collect(Collectors.toList()); - - // 추천 이유는 각 칵테일별 설명으로 들어가도록 유도 + .map(cocktail -> new CocktailSummaryResponseDto( + cocktail.getId(), + cocktail.getCocktailName(), + cocktail.getCocktailNameKo(), + cocktail.getCocktailImgUrl(), + cocktail.getAlcoholStrength().getDescription() + )) + .collect(Collectors.toList()); + String stepTitle = recommendations.isEmpty() ? "조건에 맞는 칵테일을 찾을 수 없습니다 😢" : "짠🎉🎉\n" + From da2b1d26bdd87d3ace4ae55156604a4752c99b8c Mon Sep 17 00:00:00 2001 From: GerHerMo <138726125+GerHerMo@users.noreply.github.com> Date: Sun, 12 Oct 2025 18:59:36 +0900 Subject: [PATCH 35/45] =?UTF-8?q?[feat]=20=EB=8B=A8=EA=B3=84=EB=B3=84=20?= =?UTF-8?q?=EC=B6=94=EC=B2=9C=20=EB=85=BC=EC=95=8C=EC=BD=9C=20=EC=84=A0?= =?UTF-8?q?=ED=83=9D=EC=8B=9C=20=EA=B8=80=EB=9D=BC=EC=8A=A4=20=ED=83=80?= =?UTF-8?q?=EC=9E=85=20=EC=84=A0=ED=83=9D=20#271=20(#272)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: selectedCocktailType add * feat: NON_ALCOHOLIC step add --- .../domain/chatbot/dto/ChatRequestDto.java | 2 +- .../chatbot/service/ChatbotService.java | 127 ++++++++++++++++-- 2 files changed, 119 insertions(+), 10 deletions(-) diff --git a/src/main/java/com/back/domain/chatbot/dto/ChatRequestDto.java b/src/main/java/com/back/domain/chatbot/dto/ChatRequestDto.java index 534ad861..cd080d2e 100644 --- a/src/main/java/com/back/domain/chatbot/dto/ChatRequestDto.java +++ b/src/main/java/com/back/domain/chatbot/dto/ChatRequestDto.java @@ -27,6 +27,6 @@ public class ChatRequestDto { // "ALL" 처리를 위해 스텝 2개 String으로 변경 private String selectedAlcoholStrength; private String selectedAlcoholBaseType; - // selectedCocktailType 삭제 + private String selectedCocktailType; } \ No newline at end of file diff --git a/src/main/java/com/back/domain/chatbot/service/ChatbotService.java b/src/main/java/com/back/domain/chatbot/service/ChatbotService.java index d544c275..dc8bfda1 100644 --- a/src/main/java/com/back/domain/chatbot/service/ChatbotService.java +++ b/src/main/java/com/back/domain/chatbot/service/ChatbotService.java @@ -12,7 +12,9 @@ import com.back.domain.cocktail.entity.Cocktail; import com.back.domain.cocktail.enums.AlcoholBaseType; import com.back.domain.cocktail.enums.AlcoholStrength; +import com.back.domain.cocktail.enums.CocktailType; import com.back.domain.cocktail.repository.CocktailRepository; +import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.ObjectMapper; import jakarta.annotation.PostConstruct; import lombok.RequiredArgsConstructor; @@ -27,7 +29,6 @@ import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import org.springframework.util.StreamUtils; -import com.fasterxml.jackson.core.JsonProcessingException; import java.io.IOException; import java.nio.charset.StandardCharsets; @@ -722,8 +723,18 @@ private ChatResponseDto handleStepRecommendation(ChatRequestDto requestDto) { break; case 2: - stepData = getAlcoholBaseTypeOptions(parseAlcoholStrength(requestDto.getSelectedAlcoholStrength())); - message = "좋은 선택이네요! \n이제 베이스가 될 술을 선택해주세요 🍸"; + // 논알콜 선택 여부에 따라 다른 옵션 제공 + boolean isNonAlcoholic = "NON_ALCOHOLIC".equals(requestDto.getSelectedAlcoholStrength()); + + if (isNonAlcoholic) { + // 논알콜인 경우: 글라스 타입 선택 + stepData = getCocktailTypeOptions(); + message = "논알콜 칵테일이네요! 🥤\n어떤 스타일의 칵테일을 원하시나요?"; + } else { + // 알콜인 경우: 베이스 타입 선택 + stepData = getAlcoholBaseTypeOptions(parseAlcoholStrength(requestDto.getSelectedAlcoholStrength())); + message = "좋은 선택이네요! \n이제 베이스가 될 술을 선택해주세요 🍸"; + } type = MessageType.RADIO_OPTIONS; break; @@ -740,11 +751,23 @@ private ChatResponseDto handleStepRecommendation(ChatRequestDto requestDto) { break; case 4: - stepData = getFinalRecommendationsWithMessage( - parseAlcoholStrength(requestDto.getSelectedAlcoholStrength()), - parseAlcoholBaseType(requestDto.getSelectedAlcoholBaseType()), - requestDto.getMessage() - ); + // 논알콜 여부 다시 확인 + boolean isNonAlcoholicFinal = "NON_ALCOHOLIC".equals(requestDto.getSelectedAlcoholStrength()); + + if (isNonAlcoholicFinal) { + // 논알콜: 도수와 칵테일 타입으로 검색 + stepData = getFinalRecommendationsForNonAlcoholic( + parseCocktailType(requestDto.getSelectedCocktailType()), + requestDto.getMessage() + ); + } else { + // 알콜: 도수와 베이스 타입으로 검색 + stepData = getFinalRecommendationsWithMessage( + parseAlcoholStrength(requestDto.getSelectedAlcoholStrength()), + parseAlcoholBaseType(requestDto.getSelectedAlcoholBaseType()), + requestDto.getMessage() + ); + } message = stepData.getStepTitle(); type = MessageType.CARD_LIST; break; @@ -776,6 +799,44 @@ private ChatResponseDto handleStepRecommendation(ChatRequestDto requestDto) { .build(); } + private StepRecommendationResponseDto getCocktailTypeOptions() { + List options = new ArrayList<>(); + + options.add(new StepRecommendationResponseDto.StepOption( + "ALL", + "전체", + null + )); + + for (CocktailType type : CocktailType.values()) { + options.add(new StepRecommendationResponseDto.StepOption( + type.name(), + type.getDescription(), + null + )); + } + + return new StepRecommendationResponseDto( + 2, + "어떤 스타일의 칵테일을 원하시나요?", + options, + null, + false + ); + } + + private CocktailType parseCocktailType(String value) { + if (value == null || value.trim().isEmpty() || "ALL".equalsIgnoreCase(value)) { + return null; + } + try { + return CocktailType.valueOf(value); + } catch (IllegalArgumentException e) { + log.warn("Invalid CocktailType value: {}", value); + return null; + } + } + private AlcoholStrength parseAlcoholStrength(String value) { if (value == null || value.trim().isEmpty() || "ALL".equalsIgnoreCase(value)) { return null; @@ -900,4 +961,52 @@ private StepRecommendationResponseDto getFinalRecommendationsWithMessage( true ); } -} \ No newline at end of file + private StepRecommendationResponseDto getFinalRecommendationsForNonAlcoholic( + CocktailType cocktailType, + String userMessage) { + + // 논알콜 도수만 필터링 + List strengths = List.of(AlcoholStrength.NON_ALCOHOLIC); + List types = (cocktailType == null) ? null : List.of(cocktailType); + + String keyword = null; + if (userMessage != null && !userMessage.trim().isEmpty()) { + String trimmed = userMessage.trim().toLowerCase(); + if (!trimmed.equals("x") && !trimmed.equals("없음")) { + keyword = userMessage; + } + } + + Page cocktailPage = cocktailRepository.searchWithFilters( + keyword, + strengths, + types, // 칵테일 타입 필터 적용 + null, // 베이스 타입은 null + PageRequest.of(0, 3) + ); + + List recommendations = cocktailPage.getContent().stream() + .map(cocktail -> new CocktailSummaryResponseDto( + cocktail.getId(), + cocktail.getCocktailName(), + cocktail.getCocktailNameKo(), + cocktail.getCocktailImgUrl(), + cocktail.getAlcoholStrength().getDescription() + )) + .collect(Collectors.toList()); + + String stepTitle = recommendations.isEmpty() + ? "조건에 맞는 논알콜 칵테일을 찾을 수 없습니다 😢" + : "짠🎉🎉 논알콜 칵테일 추천!\n" + + "칵테일의 자세한 정보는 '상세보기'를 클릭해서 확인할 수 있어요.\n" + + "마음에 드는 칵테일은 '킵' 버튼을 눌러 나만의 Bar에 저장해보세요!"; + + return new StepRecommendationResponseDto( + 4, + stepTitle, + null, + recommendations, + true + ); + } +} From 0d619ae8c7a09c77949a3c2036cd3b4724dba3fa Mon Sep 17 00:00:00 2001 From: LeeKW Date: Mon, 29 Sep 2025 11:29:08 +0900 Subject: [PATCH 36/45] fix : bugs of testCase, init data --- src/main/java/com/back/global/init/DevInitData.java | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/src/main/java/com/back/global/init/DevInitData.java b/src/main/java/com/back/global/init/DevInitData.java index a36d2a5f..48233c55 100644 --- a/src/main/java/com/back/global/init/DevInitData.java +++ b/src/main/java/com/back/global/init/DevInitData.java @@ -58,6 +58,18 @@ ApplicationRunner devInitDataApplicationRunner() { }; } + @Transactional + public void cocktailInit() throws Exception { + // H2 DB에 이미 데이터가 들어가 있는지 확인 + if (cocktailRepository.count() > 0) { + System.out.println("Cocktail 데이터가 이미 존재합니다."); + return; + } + + // data-h2.sql에서 자동 삽입되므로 여기서는 추가하지 않음. + System.out.println("Cocktail 초기화: CSV에서 데이터를 이미 로드합니다."); + } + @Transactional public void userInit() { userRepository.findByNickname("사용자A").orElseGet(() -> From 836d4b6242af67ed75adfa237dd53fa91588e06e Mon Sep 17 00:00:00 2001 From: LeeKW Date: Mon, 29 Sep 2025 11:47:34 +0900 Subject: [PATCH 37/45] fix : bug --- src/main/java/com/back/global/init/DevInitData.java | 12 ------------ 1 file changed, 12 deletions(-) diff --git a/src/main/java/com/back/global/init/DevInitData.java b/src/main/java/com/back/global/init/DevInitData.java index 48233c55..a36d2a5f 100644 --- a/src/main/java/com/back/global/init/DevInitData.java +++ b/src/main/java/com/back/global/init/DevInitData.java @@ -58,18 +58,6 @@ ApplicationRunner devInitDataApplicationRunner() { }; } - @Transactional - public void cocktailInit() throws Exception { - // H2 DB에 이미 데이터가 들어가 있는지 확인 - if (cocktailRepository.count() > 0) { - System.out.println("Cocktail 데이터가 이미 존재합니다."); - return; - } - - // data-h2.sql에서 자동 삽입되므로 여기서는 추가하지 않음. - System.out.println("Cocktail 초기화: CSV에서 데이터를 이미 로드합니다."); - } - @Transactional public void userInit() { userRepository.findByNickname("사용자A").orElseGet(() -> From 10c283f619c9ebfae58edcd22872223a7719c368 Mon Sep 17 00:00:00 2001 From: LeeKW Date: Fri, 10 Oct 2025 15:15:50 +0900 Subject: [PATCH 38/45] fix : parameter name --- .../cocktail/history/CocktailKeepHistory.java | 29 +++++++++++++++++++ .../CocktailKeepHistoryRepository.java | 8 +++++ 2 files changed, 37 insertions(+) create mode 100644 src/main/java/com/back/domain/cocktail/history/CocktailKeepHistory.java create mode 100644 src/main/java/com/back/domain/cocktail/repository/CocktailKeepHistoryRepository.java diff --git a/src/main/java/com/back/domain/cocktail/history/CocktailKeepHistory.java b/src/main/java/com/back/domain/cocktail/history/CocktailKeepHistory.java new file mode 100644 index 00000000..0277e4c0 --- /dev/null +++ b/src/main/java/com/back/domain/cocktail/history/CocktailKeepHistory.java @@ -0,0 +1,29 @@ +package com.back.domain.cocktail.history; + +import com.back.domain.cocktail.entity.Cocktail; +import jakarta.persistence.*; +import lombok.*; + +import java.time.LocalDateTime; + +@Entity +@Table(name = "cocktail_keep_history") +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class CocktailKeepHistory { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne + @JoinColumn(name = "cocktail_id") + private Cocktail cocktail; + + private int keepCount; + + private LocalDateTime recordedAt; +} \ No newline at end of file diff --git a/src/main/java/com/back/domain/cocktail/repository/CocktailKeepHistoryRepository.java b/src/main/java/com/back/domain/cocktail/repository/CocktailKeepHistoryRepository.java new file mode 100644 index 00000000..fd584c49 --- /dev/null +++ b/src/main/java/com/back/domain/cocktail/repository/CocktailKeepHistoryRepository.java @@ -0,0 +1,8 @@ +package com.back.domain.cocktail.repository; + +import com.back.domain.cocktail.history.CocktailKeepHistory; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface CocktailKeepHistoryRepository extends JpaRepository { + +} From 317f7cd50e736d11073bc8f4e89a15d1a59f6eda Mon Sep 17 00:00:00 2001 From: LeeKW Date: Fri, 10 Oct 2025 15:18:09 +0900 Subject: [PATCH 39/45] fix: bug --- .../cocktail/history/CocktailKeepHistory.java | 29 ------------------- .../CocktailKeepHistoryRepository.java | 8 ----- 2 files changed, 37 deletions(-) delete mode 100644 src/main/java/com/back/domain/cocktail/history/CocktailKeepHistory.java delete mode 100644 src/main/java/com/back/domain/cocktail/repository/CocktailKeepHistoryRepository.java diff --git a/src/main/java/com/back/domain/cocktail/history/CocktailKeepHistory.java b/src/main/java/com/back/domain/cocktail/history/CocktailKeepHistory.java deleted file mode 100644 index 0277e4c0..00000000 --- a/src/main/java/com/back/domain/cocktail/history/CocktailKeepHistory.java +++ /dev/null @@ -1,29 +0,0 @@ -package com.back.domain.cocktail.history; - -import com.back.domain.cocktail.entity.Cocktail; -import jakarta.persistence.*; -import lombok.*; - -import java.time.LocalDateTime; - -@Entity -@Table(name = "cocktail_keep_history") -@Getter -@Setter -@NoArgsConstructor -@AllArgsConstructor -@Builder -public class CocktailKeepHistory { - - @Id - @GeneratedValue(strategy = GenerationType.IDENTITY) - private Long id; - - @ManyToOne - @JoinColumn(name = "cocktail_id") - private Cocktail cocktail; - - private int keepCount; - - private LocalDateTime recordedAt; -} \ No newline at end of file diff --git a/src/main/java/com/back/domain/cocktail/repository/CocktailKeepHistoryRepository.java b/src/main/java/com/back/domain/cocktail/repository/CocktailKeepHistoryRepository.java deleted file mode 100644 index fd584c49..00000000 --- a/src/main/java/com/back/domain/cocktail/repository/CocktailKeepHistoryRepository.java +++ /dev/null @@ -1,8 +0,0 @@ -package com.back.domain.cocktail.repository; - -import com.back.domain.cocktail.history.CocktailKeepHistory; -import org.springframework.data.jpa.repository.JpaRepository; - -public interface CocktailKeepHistoryRepository extends JpaRepository { - -} From dc0d207e176aee6ed778e71193425978e8ea2c07 Mon Sep 17 00:00:00 2001 From: LeeKW Date: Sun, 12 Oct 2025 18:51:51 +0900 Subject: [PATCH 40/45] refactor : soft Delete --- build.gradle.kts | 2 ++ .../domain/cocktail/comment/entity/CocktailComment.java | 5 +---- .../comment/repository/CocktailCommentRepository.java | 3 ++- .../cocktail/comment/service/CocktailCommentService.java | 7 ++++++- 4 files changed, 11 insertions(+), 6 deletions(-) diff --git a/build.gradle.kts b/build.gradle.kts index dc4cc1d1..e865456e 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -38,6 +38,8 @@ dependencies { implementation("org.springdoc:springdoc-openapi-starter-webmvc-ui:2.7.0") implementation("io.jsonwebtoken:jjwt-api:0.12.3") + implementation("org.springframework.boot:spring-boot-starter-batch") + testImplementation("org.springframework.batch:spring-batch-test") runtimeOnly("io.jsonwebtoken:jjwt-impl:0.12.3") runtimeOnly("io.jsonwebtoken:jjwt-jackson:0.12.3") compileOnly("org.projectlombok:lombok") diff --git a/src/main/java/com/back/domain/cocktail/comment/entity/CocktailComment.java b/src/main/java/com/back/domain/cocktail/comment/entity/CocktailComment.java index 5f0a5e2a..967b8b0e 100644 --- a/src/main/java/com/back/domain/cocktail/comment/entity/CocktailComment.java +++ b/src/main/java/com/back/domain/cocktail/comment/entity/CocktailComment.java @@ -15,10 +15,7 @@ import java.time.LocalDateTime; @Entity -@Table( - name = "cocktail_comment", - uniqueConstraints = @UniqueConstraint(columnNames = {"cocktail_id", "user_id"}) // 사용자 1개 댓글 제한 -) +@Table(name = "cocktail_comment") @Getter @EntityListeners(AuditingEntityListener.class) @NoArgsConstructor(access = lombok.AccessLevel.PROTECTED) diff --git a/src/main/java/com/back/domain/cocktail/comment/repository/CocktailCommentRepository.java b/src/main/java/com/back/domain/cocktail/comment/repository/CocktailCommentRepository.java index 758012ee..ad747111 100644 --- a/src/main/java/com/back/domain/cocktail/comment/repository/CocktailCommentRepository.java +++ b/src/main/java/com/back/domain/cocktail/comment/repository/CocktailCommentRepository.java @@ -1,6 +1,7 @@ package com.back.domain.cocktail.comment.repository; import com.back.domain.cocktail.comment.entity.CocktailComment; +import com.back.domain.post.comment.enums.CommentStatus; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.stereotype.Repository; @@ -13,5 +14,5 @@ public interface CocktailCommentRepository extends JpaRepository findTop10ByCocktailIdAndIdLessThanOrderByIdDesc(Long cocktailId, Long lastId); - boolean existsByCocktailIdAndUserId(Long cocktailId, Long id); + boolean existsByCocktailIdAndUserIdAndStatusNot(Long cocktailId, Long id, CommentStatus status); } diff --git a/src/main/java/com/back/domain/cocktail/comment/service/CocktailCommentService.java b/src/main/java/com/back/domain/cocktail/comment/service/CocktailCommentService.java index 8cf0d20c..fbb2d9af 100644 --- a/src/main/java/com/back/domain/cocktail/comment/service/CocktailCommentService.java +++ b/src/main/java/com/back/domain/cocktail/comment/service/CocktailCommentService.java @@ -32,7 +32,12 @@ public CocktailCommentResponseDto createCocktailComment(Long cocktailId, Cocktai .orElseThrow(() -> new IllegalArgumentException("칵테일이 존재하지 않습니다. id=" + cocktailId)); // 사용자당 댓글 1개 제한 체크 - boolean exists = cocktailCommentRepository.existsByCocktailIdAndUserId(cocktailId, user.getId()); + boolean exists = cocktailCommentRepository.existsByCocktailIdAndUserIdAndStatusNot( + cocktailId, + user.getId(), + CommentStatus.DELETED // DELETED 상태는 제외하고 검사 + ); + if (exists) { throw new IllegalArgumentException("이미 댓글을 작성하셨습니다."); } From 1840ca39cc749f3d3634179aa3bd5c1d50e70494 Mon Sep 17 00:00:00 2001 From: GerHerMo Date: Sun, 12 Oct 2025 21:40:35 +0900 Subject: [PATCH 41/45] feat: add option on card_list about restart --- .../chatbot/service/ChatbotService.java | 39 ++++++++++++++++--- 1 file changed, 33 insertions(+), 6 deletions(-) diff --git a/src/main/java/com/back/domain/chatbot/service/ChatbotService.java b/src/main/java/com/back/domain/chatbot/service/ChatbotService.java index dc8bfda1..5dbb2916 100644 --- a/src/main/java/com/back/domain/chatbot/service/ChatbotService.java +++ b/src/main/java/com/back/domain/chatbot/service/ChatbotService.java @@ -231,23 +231,32 @@ private ChatResponseDto generateQARecommendation(ChatRequestDto requestDto) { // 4. AI를 통해 추천 메시지 생성 String recommendationMessage = generateRecommendationMessage(userQuestion, recommendations); - // 5. StepRecommendationResponseDto 생성 + // 5. RESTART 옵션 추가 + List restartOption = List.of( + new StepRecommendationResponseDto.StepOption( + "RESTART", + "다시 시작하기", + null + ) + ); + + // 6. StepRecommendationResponseDto 생성 StepRecommendationResponseDto stepData = new StepRecommendationResponseDto( 0, // 질문형은 step 0 recommendationMessage, - null, + restartOption, // RESTART 옵션 추가 recommendations, true ); - // 6. 봇 응답 저장 + // 7. 봇 응답 저장 ChatConversation savedResponse = saveBotResponse( requestDto.getUserId(), recommendationMessage, stepData ); - // 7. ChatResponseDto 반환 + // 8. ChatResponseDto 반환 return ChatResponseDto.builder() .id(savedResponse.getId()) .userId(requestDto.getUserId()) @@ -953,10 +962,19 @@ private StepRecommendationResponseDto getFinalRecommendationsWithMessage( "칵테일의 자세한 정보는 '상세보기'를 클릭해서 확인할 수 있어요.\n" + "마음에 드는 칵테일은 '킵' 버튼을 눌러 나만의 Bar에 저장해보세요!"; + // RESTART 옵션 추가 + List restartOption = List.of( + new StepRecommendationResponseDto.StepOption( + "RESTART", + "다시 시작하기", + null + ) + ); + return new StepRecommendationResponseDto( 4, stepTitle, - null, + restartOption, // RESTART 옵션 추가 recommendations, true ); @@ -1001,10 +1019,19 @@ private StepRecommendationResponseDto getFinalRecommendationsForNonAlcoholic( "칵테일의 자세한 정보는 '상세보기'를 클릭해서 확인할 수 있어요.\n" + "마음에 드는 칵테일은 '킵' 버튼을 눌러 나만의 Bar에 저장해보세요!"; + // RESTART 옵션 추가 + List restartOption = List.of( + new StepRecommendationResponseDto.StepOption( + "RESTART", + "다시 시작하기", + null + ) + ); + return new StepRecommendationResponseDto( 4, stepTitle, - null, + restartOption, // RESTART 옵션 추가 recommendations, true ); From 13cdc064c1a684b7d488d360ffae6b74ce366146 Mon Sep 17 00:00:00 2001 From: GerHerMo Date: Mon, 13 Oct 2025 09:33:29 +0900 Subject: [PATCH 42/45] refactor: gemini -> openai name set --- src/main/resources/application.yml | 10 +--------- 1 file changed, 1 insertion(+), 9 deletions(-) diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 4f164228..8b4570f2 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -61,7 +61,7 @@ spring: ai: openai: - api-key: ${GEMINI_API_KEY} + api-key: ${OPEN_API_KEY} chat: base-url: "https://generativelanguage.googleapis.com/v1beta/openai/" options: @@ -82,14 +82,6 @@ server: enabled: true force: true -# 추후 삭제 예정 -gemini: - api: - key: ${GEMINI_API_KEY} - model-name: "gemini-2.5-flash" - url: "https://generativelanguage.googleapis.com/v1beta/models" - - custom: dev: cookieDomain: localhost From cde488ad16ef46ca9e27628813b35d12e06fe5d6 Mon Sep 17 00:00:00 2001 From: GerHerMo Date: Mon, 13 Oct 2025 09:50:29 +0900 Subject: [PATCH 43/45] refactor: version-prompt --- .../java/com/back/domain/chatbot/service/ChatbotService.java | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/main/java/com/back/domain/chatbot/service/ChatbotService.java b/src/main/java/com/back/domain/chatbot/service/ChatbotService.java index 5dbb2916..f47801dc 100644 --- a/src/main/java/com/back/domain/chatbot/service/ChatbotService.java +++ b/src/main/java/com/back/domain/chatbot/service/ChatbotService.java @@ -337,6 +337,10 @@ private String generateRecommendationMessage(String userQuestion, List Date: Mon, 13 Oct 2025 10:05:18 +0900 Subject: [PATCH 44/45] refactor: version-add raw message --- .../com/back/domain/chatbot/service/ChatbotService.java | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/src/main/java/com/back/domain/chatbot/service/ChatbotService.java b/src/main/java/com/back/domain/chatbot/service/ChatbotService.java index f47801dc..c8e2a4dd 100644 --- a/src/main/java/com/back/domain/chatbot/service/ChatbotService.java +++ b/src/main/java/com/back/domain/chatbot/service/ChatbotService.java @@ -337,10 +337,6 @@ private String generateRecommendationMessage(String userQuestion, List Date: Mon, 13 Oct 2025 10:29:31 +0900 Subject: [PATCH 45/45] =?UTF-8?q?chore:main=20=EB=B8=8C=EB=9E=9C=EC=B9=98?= =?UTF-8?q?=20=EB=B3=91=ED=95=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/java/com/back/domain/user/dto/RefreshTokenResDto.java | 1 - 1 file changed, 1 deletion(-) diff --git a/src/main/java/com/back/domain/user/dto/RefreshTokenResDto.java b/src/main/java/com/back/domain/user/dto/RefreshTokenResDto.java index 3e705940..5131ea5d 100644 --- a/src/main/java/com/back/domain/user/dto/RefreshTokenResDto.java +++ b/src/main/java/com/back/domain/user/dto/RefreshTokenResDto.java @@ -16,6 +16,5 @@ public static class UserInfoDto { private final String nickname; private final Boolean isFirstLogin; private final Double abvDegree; - } }