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 495787c0..4d761123 100644 --- a/src/main/java/com/back/domain/chatbot/controller/ChatbotController.java +++ b/src/main/java/com/back/domain/chatbot/controller/ChatbotController.java @@ -2,9 +2,11 @@ import com.back.domain.chatbot.dto.ChatRequestDto; import com.back.domain.chatbot.dto.ChatResponseDto; +import com.back.domain.chatbot.dto.SaveBotMessageDto; import com.back.domain.chatbot.entity.ChatConversation; import com.back.domain.chatbot.service.ChatbotService; import com.back.global.rsData.RsData; +import io.swagger.v3.oas.annotations.Operation; import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -22,6 +24,7 @@ public class ChatbotController { private final ChatbotService chatbotService; @PostMapping("/chat") + @Operation(summary = "채팅 메시지 보내기", description = "자율형 대화 및 단계별 추천 두가지 모드 지원") public ResponseEntity> sendMessage(@Valid @RequestBody ChatRequestDto requestDto) { try { ChatResponseDto response = chatbotService.sendMessage(requestDto); @@ -34,6 +37,7 @@ public ResponseEntity> sendMessage(@Valid @RequestBody C } @GetMapping("/history/user/{userId}") + @Operation(summary = "유저 대화 히스토리", description = "사용자 채팅 기록 조회") public ResponseEntity>> getUserChatHistory(@PathVariable Long userId) { try { List history = chatbotService.getUserChatHistory(userId); @@ -45,4 +49,30 @@ public ResponseEntity>> getUserChatHistory(@PathVa } } + @PostMapping("/bot-message") + @Operation(summary = "봇 메시지 저장", description = "FE에서 생성한 봇 메시지(인사말 등)를 DB에 저장") + public ResponseEntity> saveBotMessage(@Valid @RequestBody SaveBotMessageDto requestDto) { + try { + ChatConversation savedMessage = chatbotService.saveBotMessage(requestDto); + return ResponseEntity.ok(RsData.successOf(savedMessage)); + } catch (Exception e) { + log.error("봇 메시지 저장 중 오류 발생: ", e); + return ResponseEntity.internalServerError() + .body(RsData.failOf("서버 오류가 발생했습니다.")); + } + } + + @PostMapping("/greeting/{userId}") + @Operation(summary = "인사말 생성", description = "사용자가 채팅을 시작할 때 기본 인사말을 생성하고 저장") + public ResponseEntity> createGreeting(@PathVariable Long userId) { + try { + ChatConversation greeting = chatbotService.createGreetingMessage(userId); + return ResponseEntity.ok(RsData.successOf(greeting)); + } catch (Exception e) { + log.error("인사말 생성 중 오류 발생: ", e); + return ResponseEntity.internalServerError() + .body(RsData.failOf("서버 오류가 발생했습니다.")); + } + } + } \ No newline at end of file diff --git a/src/main/java/com/back/domain/chatbot/dto/SaveBotMessageDto.java b/src/main/java/com/back/domain/chatbot/dto/SaveBotMessageDto.java new file mode 100644 index 00000000..ef22c12c --- /dev/null +++ b/src/main/java/com/back/domain/chatbot/dto/SaveBotMessageDto.java @@ -0,0 +1,25 @@ + +package com.back.domain.chatbot.dto; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +public class SaveBotMessageDto { + + @NotNull(message = "사용자 ID는 필수입니다.") + private Long userId; + + @NotBlank(message = "메시지 내용은 필수입니다.") + private String message; + + // 선택적: 메시지 타입 (GREETING, HELP, ERROR 등) + private String messageType; +} \ No newline at end of file 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 d6a12ad5..b6b572f2 100644 --- a/src/main/java/com/back/domain/chatbot/entity/ChatConversation.java +++ b/src/main/java/com/back/domain/chatbot/entity/ChatConversation.java @@ -1,7 +1,9 @@ package com.back.domain.chatbot.entity; +import com.back.domain.chatbot.enums.MessageSender; import jakarta.persistence.*; import lombok.*; +import org.springframework.data.annotation.CreatedDate; import java.time.LocalDateTime; @@ -19,18 +21,17 @@ public class ChatConversation { @GeneratedValue(strategy = IDENTITY) private Long id; + @Column(nullable = false) private Long userId; - @Column(columnDefinition = "TEXT") - private String userMessage; + @Column(columnDefinition = "TEXT", nullable = false) + private String message; - @Column(columnDefinition = "TEXT") - private String botResponse; + @Enumerated(EnumType.STRING) + @Column(nullable = false, length = 20) + @Builder.Default + private MessageSender sender = MessageSender.USER; + @CreatedDate private LocalDateTime createdAt; - - @PrePersist - protected void onCreate() { - createdAt = LocalDateTime.now(); - } } \ No newline at end of file diff --git a/src/main/java/com/back/domain/chatbot/enums/MessageSender.java b/src/main/java/com/back/domain/chatbot/enums/MessageSender.java new file mode 100644 index 00000000..47bb924f --- /dev/null +++ b/src/main/java/com/back/domain/chatbot/enums/MessageSender.java @@ -0,0 +1,16 @@ +package com.back.domain.chatbot.enums; + +public enum MessageSender { + USER("사용자"), + CHATBOT("챗봇"); + + private final String description; + + MessageSender(String description) { + this.description = description; + } + + public String getDescription() { + return description; + } +} \ No newline at end of file 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 b0532ff6..d412cab0 100644 --- a/src/main/java/com/back/domain/chatbot/repository/ChatConversationRepository.java +++ b/src/main/java/com/back/domain/chatbot/repository/ChatConversationRepository.java @@ -1,8 +1,6 @@ package com.back.domain.chatbot.repository; import com.back.domain.chatbot.entity.ChatConversation; -import org.springframework.data.domain.Page; -import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.stereotype.Repository; @@ -11,7 +9,7 @@ @Repository public interface ChatConversationRepository extends JpaRepository { - Page findByUserIdOrderByCreatedAtDesc(Long userId, Pageable pageable); + List findByUserIdOrderByCreatedAtDesc(Long userId); - List findTop5ByUserIdOrderByCreatedAtDesc(Long userId); + List findTop20ByUserIdOrderByCreatedAtDesc(Long userId); } \ 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 f0345115..6c6b55d0 100644 --- a/src/main/java/com/back/domain/chatbot/service/ChatbotService.java +++ b/src/main/java/com/back/domain/chatbot/service/ChatbotService.java @@ -2,8 +2,10 @@ import com.back.domain.chatbot.dto.ChatRequestDto; import com.back.domain.chatbot.dto.ChatResponseDto; +import com.back.domain.chatbot.dto.SaveBotMessageDto; import com.back.domain.chatbot.dto.StepRecommendationResponseDto; import com.back.domain.chatbot.entity.ChatConversation; +import com.back.domain.chatbot.enums.MessageSender; import com.back.domain.chatbot.repository.ChatConversationRepository; import com.back.domain.cocktail.dto.CocktailSummaryResponseDto; import com.back.domain.cocktail.entity.Cocktail; @@ -21,7 +23,6 @@ import org.springframework.core.io.Resource; import org.springframework.data.domain.Page; import org.springframework.data.domain.PageRequest; -import org.springframework.data.domain.Pageable; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import org.springframework.util.StreamUtils; @@ -30,7 +31,6 @@ import java.nio.charset.StandardCharsets; import java.time.LocalDateTime; import java.util.ArrayList; -import java.util.Collections; import java.util.List; import java.util.stream.Collectors; @@ -43,7 +43,6 @@ public class ChatbotService { private final ChatConversationRepository chatConversationRepository; private final CocktailRepository cocktailRepository; - @Value("classpath:prompts/chatbot-system-prompt.txt") private Resource systemPromptResource; @@ -91,8 +90,8 @@ public ChatResponseDto sendMessage(ChatRequestDto requestDto) { try { // 단계별 추천 모드 확인 (currentStep이 있으면 무조건 단계별 추천 모드) if (requestDto.isStepRecommendation() || - requestDto.getCurrentStep() != null || - isStepRecommendationTrigger(requestDto.getMessage())) { + requestDto.getCurrentStep() != null || + isStepRecommendationTrigger(requestDto.getMessage())) { log.info("Recommendation chat mode for userId: {}", requestDto.getUserId()); return handleStepRecommendation(requestDto); } @@ -102,23 +101,18 @@ public ChatResponseDto sendMessage(ChatRequestDto requestDto) { // 메시지 타입 감지 MessageType messageType = detectMessageType(requestDto.getMessage()); - // 최근 대화 기록 조회 (최신 5개) + // 최근 대화 기록 조회 (최신 10개 메시지 - USER와 CHATBOT 메시지 모두 포함) List recentChats = - chatConversationRepository.findTop5ByUserIdOrderByCreatedAtDesc(requestDto.getUserId()); - - // 대화 히스토리를 시간순으로 정렬 (오래된 것부터) - Collections.reverse(recentChats); + chatConversationRepository.findTop20ByUserIdOrderByCreatedAtDesc(requestDto.getUserId()); // 대화 컨텍스트 생성 String conversationContext = buildConversationContext(recentChats); - // ChatClient 빌더 생성 + // ChatClient 빌더 생성 - .message 체인 방식 포기 var promptBuilder = chatClient.prompt() .system(buildSystemMessage(messageType) + conversationContext) .user(buildUserMessage(requestDto.getMessage(), messageType)); - // RAG 기능은 향후 구현 예정 (Vector DB 설정 필요) - // 응답 생성 String response = promptBuilder .options(getOptionsForMessageType(messageType)) @@ -128,7 +122,7 @@ public ChatResponseDto sendMessage(ChatRequestDto requestDto) { // 응답 후처리 response = postProcessResponse(response, messageType); - // 대화 저장 (sessionId 없이) + // 대화 저장 - 사용자 메시지와 봇 응답을 각각 저장 saveConversation(requestDto, response); return new ChatResponseDto(response); @@ -139,22 +133,113 @@ public ChatResponseDto sendMessage(ChatRequestDto requestDto) { } } + // ============ 수정된 메서드들 ============ + /** + * 대화 컨텍스트 빌드 - 변경사항: sender로 구분하여 대화 재구성 + */ private String buildConversationContext(List recentChats) { if (recentChats.isEmpty()) { return ""; } StringBuilder context = new StringBuilder("\n\n【최근 대화 기록】\n"); - for (ChatConversation chat : recentChats) { - context.append("사용자: ").append(chat.getUserMessage()).append("\n"); - context.append("봇: ").append(chat.getBotResponse()).append("\n\n"); + + // 시간 역순으로 정렬된 리스트를 시간순으로 재정렬 + List orderedChats = new ArrayList<>(recentChats); + orderedChats.sort((a, b) -> a.getCreatedAt().compareTo(b.getCreatedAt())); + + for (ChatConversation chat : orderedChats) { + if (chat.getSender() == MessageSender.USER) { + context.append("사용자: ").append(chat.getMessage()).append("\n"); + } else { + context.append("봇: ").append(chat.getMessage()).append("\n"); + } } - context.append("위 대화를 참고하여 자연스럽게 이어지는 답변을 해주세요.\n"); + context.append("\n위 대화를 참고하여 자연스럽게 이어지는 답변을 해주세요.\n"); return context.toString(); } + /** + * 대화 저장 - 변경사항: 사용자 메시지와 봇 응답을 각각 별도로 저장 + */ + @Transactional + public void saveConversation(ChatRequestDto requestDto, String response) { + // 1. 사용자 메시지 저장 + ChatConversation userMessage = ChatConversation.builder() + .userId(requestDto.getUserId()) + .message(requestDto.getMessage()) + .sender(MessageSender.USER) + .createdAt(LocalDateTime.now()) + .build(); + chatConversationRepository.save(userMessage); + + // 2. 봇 응답 저장 + ChatConversation botResponse = ChatConversation.builder() + .userId(requestDto.getUserId()) + .message(response) + .sender(MessageSender.CHATBOT) + .createdAt(LocalDateTime.now()) + .build(); + chatConversationRepository.save(botResponse); + } + + /** + * 사용자 채팅 기록 조회 - 변경사항: sender 구분 없이 모든 메시지 시간순으로 조회 + */ + @Transactional(readOnly = true) + public List getUserChatHistory(Long userId) { + return chatConversationRepository.findByUserIdOrderByCreatedAtDesc(userId); + } + + /** + * FE에서 생성한 봇 메시지를 DB에 저장 + * 예: 인사말, 안내 메시지, 에러 메시지 등 + */ + @Transactional + public ChatConversation saveBotMessage(SaveBotMessageDto dto) { + ChatConversation botMessage = ChatConversation.builder() + .userId(dto.getUserId()) + .message(dto.getMessage()) + .sender(MessageSender.CHATBOT) + .createdAt(LocalDateTime.now()) + .build(); + + return chatConversationRepository.save(botMessage); + } + + /** + * 기본 인사말 생성 및 저장 + * 채팅 시작 시 호출하여 인사말을 DB에 저장 + */ + @Transactional + public ChatConversation createGreetingMessage(Long userId) { + String greetingMessage = "안녕하세요! 🍹 바텐더 '쑤리'에요.\n" + + "취향에 맞는 칵테일을 추천해드릴게요!\n" + + "어떤 유형으로 찾아드릴까요?"; + + ChatConversation greeting = ChatConversation.builder() + .userId(userId) + .message(greetingMessage) + .sender(MessageSender.CHATBOT) + .createdAt(LocalDateTime.now()) + .build(); + + return chatConversationRepository.save(greeting); + } + + /** + * 사용자의 첫 대화 여부 확인 + * 첫 대화인 경우 인사말 자동 생성에 활용 가능 + */ + @Transactional(readOnly = true) + public boolean isFirstConversation(Long userId) { + return chatConversationRepository.findTop20ByUserIdOrderByCreatedAtDesc(userId).isEmpty(); + } + + // ============ 기존 메서드들 (변경 없음) ============ + private String buildSystemMessage(MessageType type) { StringBuilder sb = new StringBuilder(systemPrompt); @@ -201,7 +286,6 @@ private OpenAiChatOptions getOptionsForMessageType(MessageType type) { }; } - private String postProcessResponse(String response, MessageType type) { // 응답 길이 제한 확인 if (response.length() > 500) { @@ -216,17 +300,6 @@ private String postProcessResponse(String response, MessageType type) { return response; } - private void saveConversation(ChatRequestDto requestDto, String response) { - ChatConversation conversation = ChatConversation.builder() - .userId(requestDto.getUserId()) - .userMessage(requestDto.getMessage()) - .botResponse(response) - .createdAt(LocalDateTime.now()) - .build(); - - chatConversationRepository.save(conversation); - } - private ChatResponseDto handleError(Exception e) { String errorMessage = "죄송합니다. 잠시 후 다시 시도해주세요."; @@ -266,7 +339,7 @@ private boolean isStepRecommendationTrigger(String message) { return lower.contains("단계별 추천"); } - // 단계별 추천 처리 통합 메서드 + // 단계별 추천 처리 통합 메서드 - 변경사항: 대화 저장 방식 변경 private ChatResponseDto handleStepRecommendation(ChatRequestDto requestDto) { Integer currentStep = requestDto.getCurrentStep(); @@ -281,7 +354,9 @@ private ChatResponseDto handleStepRecommendation(ChatRequestDto requestDto) { switch (currentStep) { case 1: stepRecommendation = getAlcoholStrengthOptions(); - chatResponse = "단계별 맞춤 추천을 시작합니다! 🎯\n원하시는 도수를 선택해주세요!"; + chatResponse = "단계별로 취향을 찾아드릴게요! 🎯\n" + + "원하시는 도수를 선택해주세요! \n" + + "잘 모르는 항목은 '전체'로 체크하셔도 괜찮아요."; break; case 2: stepRecommendation = getAlcoholBaseTypeOptions(requestDto.getSelectedAlcoholStrength()); @@ -293,9 +368,9 @@ private ChatResponseDto handleStepRecommendation(ChatRequestDto requestDto) { break; case 4: stepRecommendation = getFinalRecommendations( - requestDto.getSelectedAlcoholStrength(), - requestDto.getSelectedAlcoholBaseType(), - requestDto.getSelectedCocktailType() + requestDto.getSelectedAlcoholStrength(), + requestDto.getSelectedAlcoholBaseType(), + requestDto.getSelectedCocktailType() ); chatResponse = stepRecommendation.getStepTitle(); break; @@ -304,35 +379,31 @@ private ChatResponseDto handleStepRecommendation(ChatRequestDto requestDto) { chatResponse = "단계별 맞춤 추천을 시작합니다! 🎯"; } - // 대화 기록 저장 + // 대화 기록 저장 - 변경된 방식으로 저장 saveConversation(requestDto, chatResponse); return new ChatResponseDto(chatResponse, stepRecommendation); } - @Transactional(readOnly = true) - public List getUserChatHistory(Long userId) { - return chatConversationRepository.findByUserIdOrderByCreatedAtDesc(userId, Pageable.unpaged()).getContent(); - } - + // ============ 단계별 추천 관련 메서드들 (변경 없음) ============ private StepRecommendationResponseDto getAlcoholStrengthOptions() { List options = new ArrayList<>(); for (AlcoholStrength strength : AlcoholStrength.values()) { options.add(new StepRecommendationResponseDto.StepOption( - strength.name(), - strength.getDescription(), - null + strength.name(), + strength.getDescription(), + null )); } return new StepRecommendationResponseDto( - 1, - "원하시는 도수를 선택해주세요!", - options, - null, - false + 1, + "원하시는 도수를 선택해주세요!", + options, + null, + false ); } @@ -341,18 +412,18 @@ private StepRecommendationResponseDto getAlcoholBaseTypeOptions(AlcoholStrength for (AlcoholBaseType baseType : AlcoholBaseType.values()) { options.add(new StepRecommendationResponseDto.StepOption( - baseType.name(), - baseType.getDescription(), - null + baseType.name(), + baseType.getDescription(), + null )); } return new StepRecommendationResponseDto( - 2, - "베이스가 될 술을 선택해주세요!", - options, - null, - false + 2, + "베이스가 될 술을 선택해주세요!", + options, + null, + false ); } @@ -361,18 +432,18 @@ private StepRecommendationResponseDto getCocktailTypeOptions(AlcoholStrength alc for (CocktailType cocktailType : CocktailType.values()) { options.add(new StepRecommendationResponseDto.StepOption( - cocktailType.name(), - cocktailType.getDescription(), - null + cocktailType.name(), + cocktailType.getDescription(), + null )); } return new StepRecommendationResponseDto( - 3, - "어떤 종류의 잔으로 드시겠어요?", - options, - null, - false + 3, + "어떤 종류의 잔으로 드시겠어요?", + options, + null, + false ); } @@ -386,34 +457,35 @@ private StepRecommendationResponseDto getFinalRecommendations( List cocktailTypes = List.of(cocktailType); Page cocktailPage = cocktailRepository.searchWithFilters( - null, // 키워드 없음 - strengths, - cocktailTypes, - baseTypes, - PageRequest.of(0, 5) // 최대 5개 추천 + null, // 키워드 없음 + strengths, + cocktailTypes, + baseTypes, + PageRequest.of(0, 5) // 최대 5개 추천 ); List recommendations = cocktailPage.getContent().stream() - .map(cocktail -> new CocktailSummaryResponseDto( - cocktail.getId(), - cocktail.getCocktailName(), - cocktail.getCocktailImgUrl(), - cocktail.getAlcoholStrength() - )) - .collect(Collectors.toList()); - + .map(cocktail -> new CocktailSummaryResponseDto( + cocktail.getId(), + cocktail.getCocktailName(), + cocktail.getCocktailImgUrl(), + cocktail.getAlcoholStrength() + )) + .collect(Collectors.toList()); + + // 추천 이유는 각 칵테일별 설명으로 들어가도록 유도 String stepTitle = recommendations.isEmpty() - ? "조건에 맞는 칵테일을 찾을 수 없습니다 😢" - : "당신을 위한 맞춤 칵테일 추천입니다! 🍹"; + ? "조건에 맞는 칵테일을 찾을 수 없습니다 😢" + : "짠🎉🎉\n" + + "칵테일의 자세한 정보는 '상세보기'를 클릭해서 확인할 수 있어요.\n" + + "마음에 드는 칵테일은 '킵' 버튼을 눌러 나만의 Bar에 저장해보세요!"; return new StepRecommendationResponseDto( - 4, - stepTitle, - null, - recommendations, - true + 4, + stepTitle, + null, + recommendations, + true ); } - -} - +} \ No newline at end of file