diff --git a/.claude/settings.local.json b/.claude/settings.local.json new file mode 100644 index 0000000..a5ed1b2 --- /dev/null +++ b/.claude/settings.local.json @@ -0,0 +1,9 @@ +{ + "permissions": { + "allow": [ + "Bash(./gradlew test:*)", + "Bash(./gradlew:*)" + ], + "deny": [] + } +} \ No newline at end of file diff --git a/.gitignore b/.gitignore index 527681e..ea5f888 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,6 @@ HELP.md .gradle +.env build/ !gradle/wrapper/gradle-wrapper.jar !**/src/main/**/build/ @@ -48,8 +49,11 @@ src/main/resources/static/AuthKey.p8 ### AWS Settings ### src/main/resources/application-aws.yml +### AI Settings ### +src/main/resources/application-ai.yml + ### Claude Code ### .claude/ ### Firebase ### -src/main/resources/firebase.json \ No newline at end of file +src/main/resources/firebase.json diff --git a/build.gradle b/build.gradle index bf58a4c..d6da616 100644 --- a/build.gradle +++ b/build.gradle @@ -30,6 +30,7 @@ repositories { ext { set('snippetsDir', file("build/generated-snippets")) set('springCloudVersion', "2024.0.0") + springAiVersion = "1.0.0" } dependencies { @@ -65,6 +66,7 @@ dependencies { dependencyManagement { imports { mavenBom "org.springframework.cloud:spring-cloud-dependencies:${springCloudVersion}" + mavenBom "org.springframework.ai:spring-ai-bom:$springAiVersion" } } diff --git a/src/main/java/kr/swyp/backend/chatbot/client/ChatClient.java b/src/main/java/kr/swyp/backend/chatbot/client/ChatClient.java new file mode 100644 index 0000000..40bc186 --- /dev/null +++ b/src/main/java/kr/swyp/backend/chatbot/client/ChatClient.java @@ -0,0 +1,25 @@ +package kr.swyp.backend.chatbot.client; + +import kr.swyp.backend.chatbot.client.dto.ChatDto.ChatRequest; +import kr.swyp.backend.chatbot.client.dto.ChatDto.ChatResponse; +import org.springframework.cloud.openfeign.FeignClient; +import org.springframework.http.MediaType; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestHeader; + +@FeignClient( + name = "openai-client", + url = "https://api.openai.com" +) +public interface ChatClient { + + @PostMapping(value = "/v1/chat/completions", + consumes = MediaType.APPLICATION_JSON_VALUE, // Content-Type 고정 + produces = MediaType.APPLICATION_JSON_VALUE) + ChatResponse createChatCompletion( + @RequestHeader("Authorization") String authorization, + @RequestBody ChatRequest request + ); + +} diff --git a/src/main/java/kr/swyp/backend/chatbot/client/dto/ChatDto.java b/src/main/java/kr/swyp/backend/chatbot/client/dto/ChatDto.java new file mode 100644 index 0000000..be17650 --- /dev/null +++ b/src/main/java/kr/swyp/backend/chatbot/client/dto/ChatDto.java @@ -0,0 +1,65 @@ +package kr.swyp.backend.chatbot.client.dto; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import java.util.List; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +public class ChatDto { + + @Getter + @Builder + @NoArgsConstructor + @AllArgsConstructor + public static class ChatRequest { + + private String model; + private List messages; + private Double temperature; + + @Getter + @Builder + @NoArgsConstructor + @AllArgsConstructor + public static class Message { + + private String role; + private String content; + } + } + + @Getter + @NoArgsConstructor + @AllArgsConstructor + @JsonIgnoreProperties(ignoreUnknown = true) + @Builder + public static class ChatResponse { + + private String id; + private List choices; + + @Getter + @NoArgsConstructor + @AllArgsConstructor + @Builder + @JsonIgnoreProperties(ignoreUnknown = true) + public static class Choice { + + private Message message; + + @Getter + @NoArgsConstructor + @AllArgsConstructor + @Builder + @JsonIgnoreProperties(ignoreUnknown = true) + public static class Message { + + private String role; + private String content; + } + } + } + +} diff --git a/src/main/java/kr/swyp/backend/chatbot/controller/ChatController.java b/src/main/java/kr/swyp/backend/chatbot/controller/ChatController.java new file mode 100644 index 0000000..db3e84b --- /dev/null +++ b/src/main/java/kr/swyp/backend/chatbot/controller/ChatController.java @@ -0,0 +1,72 @@ +package kr.swyp.backend.chatbot.controller; + +import jakarta.validation.constraints.NotBlank; +import java.util.List; +import kr.swyp.backend.chatbot.dto.ChatDto.ChatHistoryDto; +import kr.swyp.backend.chatbot.dto.ChatDto.ChatRequestDto; +import kr.swyp.backend.chatbot.dto.ChatDto.ChatResponseDto; +import kr.swyp.backend.chatbot.dto.ChatDto.ChatSessionDto; +import kr.swyp.backend.chatbot.dto.ChatDto.ConversationRequestDto; +import kr.swyp.backend.chatbot.dto.ChatDto.ConversationResponseDto; +import kr.swyp.backend.chatbot.service.ChatService; +import kr.swyp.backend.member.dto.MemberDetails; +import lombok.RequiredArgsConstructor; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +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.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/chat") +public class ChatController { + + private final ChatService chatService; + + @PostMapping("/ask") + public ChatResponseDto ask(@RequestBody ChatRequestDto question, + @AuthenticationPrincipal MemberDetails memberDetails) { + return chatService.ask(memberDetails.getMemberId(), question); + } + + @GetMapping("/history") + public List getChatHistory( + @AuthenticationPrincipal MemberDetails memberDetails) { + return chatService.getChatHistory(memberDetails.getMemberId()); + } + + // 새로운 채팅 세션 시작 + @PostMapping("/sessions/start") + public ChatSessionDto startNewSession( + @RequestParam @NotBlank String initialMessage, + @AuthenticationPrincipal MemberDetails memberDetails) { + return chatService.startNewSession(memberDetails.getMemberId(), initialMessage); + } + + // 기존 세션에서 대화 계속 + @PostMapping("/sessions/continue") + public ConversationResponseDto continueConversation( + @RequestBody ConversationRequestDto request, + @AuthenticationPrincipal MemberDetails memberDetails) { + return chatService.continueConversation(memberDetails.getMemberId(), request); + } + + // 사용자의 채팅 세션 목록 조회 + @GetMapping("/sessions") + public List getUserSessions( + @AuthenticationPrincipal MemberDetails memberDetails) { + return chatService.getUserSessions(memberDetails.getMemberId()); + } + + // 특정 세션의 대화 기록 조회 + @GetMapping("/sessions/{sessionId}/history") + public List getSessionHistory( + @PathVariable String sessionId, + @AuthenticationPrincipal MemberDetails memberDetails) { + return chatService.getSessionHistory(memberDetails.getMemberId(), sessionId); + } +} diff --git a/src/main/java/kr/swyp/backend/chatbot/domain/ChatHistory.java b/src/main/java/kr/swyp/backend/chatbot/domain/ChatHistory.java new file mode 100644 index 0000000..f815e88 --- /dev/null +++ b/src/main/java/kr/swyp/backend/chatbot/domain/ChatHistory.java @@ -0,0 +1,58 @@ +package kr.swyp.backend.chatbot.domain; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import java.util.UUID; +import kr.swyp.backend.common.domain.BaseEntity; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import org.hibernate.annotations.Comment; + +@Entity +@Getter +@Builder +@AllArgsConstructor(access = AccessLevel.PROTECTED) +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Table(name = "CHAT_HISTORY") +public class ChatHistory extends BaseEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "ID") + private Long id; + + @Column(name = "MEMBER_ID") + @Comment("유저 아이디") + private UUID memberId; + + @Column(name = "TARGET") + @Comment("대상") + private String target; + + @Column(name = "TOPIC") + @Comment("주제") + private String topic; + + @Column(name = "QUESTION", columnDefinition = "TEXT") + @Comment("질문") + private String question; + + @Column(name = "RESPONSE", columnDefinition = "TEXT") + @Comment("AI 응답") + private String response; + + @Column(name = "SESSION_ID") + @Comment("채팅 세션 ID") + private String sessionId; + + @Column(name = "MESSAGE_TYPE") + @Comment("메시지 타입 (USER/BOT)") + private String messageType; +} diff --git a/src/main/java/kr/swyp/backend/chatbot/domain/ChatPrompt.java b/src/main/java/kr/swyp/backend/chatbot/domain/ChatPrompt.java new file mode 100644 index 0000000..79655e6 --- /dev/null +++ b/src/main/java/kr/swyp/backend/chatbot/domain/ChatPrompt.java @@ -0,0 +1,41 @@ +package kr.swyp.backend.chatbot.domain; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import kr.swyp.backend.common.domain.BaseEntity; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import org.hibernate.annotations.Comment; + +@Entity +@Table(name = "CHAT_PROMPT") +@Getter +@Builder +@AllArgsConstructor(access = AccessLevel.PROTECTED) +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class ChatPrompt extends BaseEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "prompt_key", nullable = false, unique = true) + @Comment("프롬프트 식별자") + private String promptKey; + + @Column(name = "prompt_content", nullable = false, columnDefinition = "TEXT") + @Comment("프롬프트 텍스트") + private String promptContent; + + @Column(name = "is_active", nullable = false) + @Comment("활성 상태") + private Boolean isActive = true; + +} diff --git a/src/main/java/kr/swyp/backend/chatbot/domain/ChatSession.java b/src/main/java/kr/swyp/backend/chatbot/domain/ChatSession.java new file mode 100644 index 0000000..97d7c24 --- /dev/null +++ b/src/main/java/kr/swyp/backend/chatbot/domain/ChatSession.java @@ -0,0 +1,47 @@ +package kr.swyp.backend.chatbot.domain; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import java.util.UUID; +import kr.swyp.backend.common.domain.BaseEntity; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import org.hibernate.annotations.Comment; + +@Entity +@Getter +@Builder +@AllArgsConstructor(access = AccessLevel.PROTECTED) +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Table(name = "CHAT_SESSION") +public class ChatSession extends BaseEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "ID") + private Long id; + + @Column(name = "SESSION_ID", unique = true) + @Comment("세션 고유 식별자") + private String sessionId; + + @Column(name = "MEMBER_ID") + @Comment("사용자 아이디") + private UUID memberId; + + @Column(name = "TITLE") + @Comment("채팅 세션 제목") + private String title; + + @Column(name = "IS_ACTIVE") + @Comment("활성 상태") + @Builder.Default + private Boolean isActive = true; +} \ No newline at end of file diff --git a/src/main/java/kr/swyp/backend/chatbot/dto/ChatDto.java b/src/main/java/kr/swyp/backend/chatbot/dto/ChatDto.java new file mode 100644 index 0000000..0e57bb7 --- /dev/null +++ b/src/main/java/kr/swyp/backend/chatbot/dto/ChatDto.java @@ -0,0 +1,88 @@ +package kr.swyp.backend.chatbot.dto; + +import java.time.LocalDateTime; +import java.util.List; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +public class ChatDto { + + @Getter + @AllArgsConstructor + @NoArgsConstructor + @Builder + public static class ChatExtractionResultDto { + + private String target; // 대화를 걸 상대 + private String topic; // 대화 주제 + private List answers; // 대화 응답들 + } + + @Builder + @Getter + @AllArgsConstructor + @NoArgsConstructor + public static class ChatRequestDto { + + private String sessionId; + private String message; // 질문하는 메시지 + } + + @Getter + @Builder + public static class ChatResponseDto { + + private List contents; // 응답 답변들 + private String sender; // 답변하는 사람 + } + + @Getter + @Builder + @AllArgsConstructor + @NoArgsConstructor + public static class ChatHistoryDto { + + private Long id; + private String target; + private String topic; + private String question; + private String response; + private LocalDateTime createdAt; + } + + @Getter + @Builder + @AllArgsConstructor + @NoArgsConstructor + public static class ChatSessionDto { + + private String sessionId; + private String title; + private LocalDateTime createdAt; + private Boolean isActive; + } + + @Getter + @Builder + @AllArgsConstructor + @NoArgsConstructor + public static class ConversationRequestDto { + + private String sessionId; + private String message; + } + + @Getter + @Builder + @AllArgsConstructor + @NoArgsConstructor + public static class ConversationResponseDto { + + private String sessionId; + private String message; + private String sender; + private LocalDateTime timestamp; + } +} diff --git a/src/main/java/kr/swyp/backend/chatbot/repository/ChatHistoryRepository.java b/src/main/java/kr/swyp/backend/chatbot/repository/ChatHistoryRepository.java new file mode 100644 index 0000000..3e592ea --- /dev/null +++ b/src/main/java/kr/swyp/backend/chatbot/repository/ChatHistoryRepository.java @@ -0,0 +1,15 @@ +package kr.swyp.backend.chatbot.repository; + +import java.util.List; +import java.util.UUID; +import kr.swyp.backend.chatbot.domain.ChatHistory; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface ChatHistoryRepository extends JpaRepository { + + List findTop5ByMemberIdAndSessionIdOrderByIdDesc(UUID memberId, String sessionId); + + List findTop5ByMemberIdOrderByIdDesc(UUID memberId); + + List findBySessionIdOrderByCreatedAtAsc(String sessionId); +} diff --git a/src/main/java/kr/swyp/backend/chatbot/repository/ChatPromptRepository.java b/src/main/java/kr/swyp/backend/chatbot/repository/ChatPromptRepository.java new file mode 100644 index 0000000..ff23636 --- /dev/null +++ b/src/main/java/kr/swyp/backend/chatbot/repository/ChatPromptRepository.java @@ -0,0 +1,11 @@ +package kr.swyp.backend.chatbot.repository; + +import java.util.Optional; +import kr.swyp.backend.chatbot.domain.ChatPrompt; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface ChatPromptRepository extends JpaRepository { + + // 활성화된 프롬프트만 key로 조회 + Optional findByPromptKeyAndIsActiveTrue(String promptKey); +} diff --git a/src/main/java/kr/swyp/backend/chatbot/repository/ChatSessionRepository.java b/src/main/java/kr/swyp/backend/chatbot/repository/ChatSessionRepository.java new file mode 100644 index 0000000..3432199 --- /dev/null +++ b/src/main/java/kr/swyp/backend/chatbot/repository/ChatSessionRepository.java @@ -0,0 +1,16 @@ +package kr.swyp.backend.chatbot.repository; + +import java.util.List; +import java.util.Optional; +import java.util.UUID; +import kr.swyp.backend.chatbot.domain.ChatSession; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface ChatSessionRepository extends JpaRepository { + + Optional findBySessionIdAndIsActiveTrue(String sessionId); + + List findByMemberIdAndIsActiveTrueOrderByCreatedAtDesc(UUID memberId); + + Optional findBySessionId(String sessionId); +} \ No newline at end of file diff --git a/src/main/java/kr/swyp/backend/chatbot/service/ChatClientService.java b/src/main/java/kr/swyp/backend/chatbot/service/ChatClientService.java new file mode 100644 index 0000000..bd862b0 --- /dev/null +++ b/src/main/java/kr/swyp/backend/chatbot/service/ChatClientService.java @@ -0,0 +1,9 @@ +package kr.swyp.backend.chatbot.service; + +import kr.swyp.backend.chatbot.client.dto.ChatDto.ChatRequest; +import kr.swyp.backend.chatbot.client.dto.ChatDto.ChatResponse; + +public interface ChatClientService { + + ChatResponse createChatCompletion(String authorization, ChatRequest request); +} diff --git a/src/main/java/kr/swyp/backend/chatbot/service/ChatClientServiceImpl.java b/src/main/java/kr/swyp/backend/chatbot/service/ChatClientServiceImpl.java new file mode 100644 index 0000000..6ffeaed --- /dev/null +++ b/src/main/java/kr/swyp/backend/chatbot/service/ChatClientServiceImpl.java @@ -0,0 +1,20 @@ +package kr.swyp.backend.chatbot.service; + +import kr.swyp.backend.chatbot.client.ChatClient; +import kr.swyp.backend.chatbot.client.dto.ChatDto.ChatRequest; +import kr.swyp.backend.chatbot.client.dto.ChatDto.ChatResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +public class ChatClientServiceImpl implements ChatClientService { + + private final ChatClient chatClient; + + @Override + public ChatResponse createChatCompletion(String authorization, + ChatRequest request) { + return chatClient.createChatCompletion(authorization, request); + } +} diff --git a/src/main/java/kr/swyp/backend/chatbot/service/ChatPromptService.java b/src/main/java/kr/swyp/backend/chatbot/service/ChatPromptService.java new file mode 100644 index 0000000..a057eaf --- /dev/null +++ b/src/main/java/kr/swyp/backend/chatbot/service/ChatPromptService.java @@ -0,0 +1,8 @@ +package kr.swyp.backend.chatbot.service; + +public interface ChatPromptService { + + String createMessageExtractionPrompt(String userMessage); + + String createConversationPrompt(String context); +} \ No newline at end of file diff --git a/src/main/java/kr/swyp/backend/chatbot/service/ChatPromptServiceImpl.java b/src/main/java/kr/swyp/backend/chatbot/service/ChatPromptServiceImpl.java new file mode 100644 index 0000000..a5f6013 --- /dev/null +++ b/src/main/java/kr/swyp/backend/chatbot/service/ChatPromptServiceImpl.java @@ -0,0 +1,62 @@ +package kr.swyp.backend.chatbot.service; + +import kr.swyp.backend.chatbot.domain.ChatPrompt; +import kr.swyp.backend.chatbot.repository.ChatPromptRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; + +@Slf4j +@Service +@RequiredArgsConstructor +public class ChatPromptServiceImpl implements ChatPromptService { + + private final ChatPromptRepository chatPromptRepository; + + @Override + public String createMessageExtractionPrompt(String userMessage) { + log.info("[프롬프트] MESSAGE_EXTRACTION 프롬프트 조회 시작"); + + // DB에서 프롬프트 조회 + ChatPrompt prompt = chatPromptRepository + .findByPromptKeyAndIsActiveTrue("MESSAGE_EXTRACTION") + .orElseThrow(() -> { + log.error("[프롬프트] MESSAGE_EXTRACTION 프롬프트를 찾을 수 없습니다"); + return new IllegalStateException("메시지 추출 프롬프트를 찾을 수 없습니다"); + }); + + log.info("[프롬프트] DB에서 조회된 프롬프트 ID: {}, Key: {}", prompt.getId(), prompt.getPromptKey()); + log.debug("[프롬프트] 원본 프롬프트 내용:\n{}", prompt.getPromptContent()); + + // %s를 실제 메시지로 치환 + String formattedPrompt = prompt.getPromptContent().formatted(userMessage); + + log.info("[프롬프트] 사용자 메시지로 치환 완료"); + log.debug("[프롬프트] 최종 프롬프트:\n{}", formattedPrompt); + + return formattedPrompt; + } + + @Override + public String createConversationPrompt(String context) { + log.info("[프롬프트] CONVERSATION 프롬프트 조회 시작"); + + ChatPrompt prompt = chatPromptRepository + .findByPromptKeyAndIsActiveTrue("CONVERSATION") + .orElseThrow(() -> { + log.error("[프롬프트] CONVERSATION 프롬프트를 찾을 수 없습니다"); + return new IllegalStateException("대화 프롬프트를 찾을 수 없습니다"); + }); + + log.info("[프롬프트] DB에서 조회된 프롬프트 ID: {}, Key: {}", prompt.getId(), prompt.getPromptKey()); + log.debug("[프롬프트] 원본 프롬프트 내용:\n{}", prompt.getPromptContent()); + + String formattedPrompt = prompt.getPromptContent().formatted(context); + + log.info("[프롬프트] 컨텍스트로 치환 완료"); + log.debug("[프롬프트] 최종 프롬프트:\n{}", formattedPrompt); + + return formattedPrompt; + } + +} \ No newline at end of file diff --git a/src/main/java/kr/swyp/backend/chatbot/service/ChatService.java b/src/main/java/kr/swyp/backend/chatbot/service/ChatService.java new file mode 100644 index 0000000..a6b423a --- /dev/null +++ b/src/main/java/kr/swyp/backend/chatbot/service/ChatService.java @@ -0,0 +1,29 @@ +package kr.swyp.backend.chatbot.service; + +import java.util.List; +import java.util.UUID; +import kr.swyp.backend.chatbot.dto.ChatDto.ChatHistoryDto; +import kr.swyp.backend.chatbot.dto.ChatDto.ChatRequestDto; +import kr.swyp.backend.chatbot.dto.ChatDto.ChatResponseDto; +import kr.swyp.backend.chatbot.dto.ChatDto.ChatSessionDto; +import kr.swyp.backend.chatbot.dto.ChatDto.ConversationRequestDto; +import kr.swyp.backend.chatbot.dto.ChatDto.ConversationResponseDto; + +public interface ChatService { + + ChatResponseDto ask(UUID memberId, ChatRequestDto request); + + List getChatHistory(UUID memberId); + + // 새로운 채팅 세션 시작 + ChatSessionDto startNewSession(UUID memberId, String initialMessage); + + // 기존 세션에서 대화 계속 + ConversationResponseDto continueConversation(UUID memberId, ConversationRequestDto request); + + // 사용자의 채팅 세션 목록 조회 + List getUserSessions(UUID memberId); + + // 특정 세션의 대화 기록 조회 + List getSessionHistory(UUID memberId, String sessionId); +} diff --git a/src/main/java/kr/swyp/backend/chatbot/service/ChatServiceImpl.java b/src/main/java/kr/swyp/backend/chatbot/service/ChatServiceImpl.java new file mode 100644 index 0000000..fa32af1 --- /dev/null +++ b/src/main/java/kr/swyp/backend/chatbot/service/ChatServiceImpl.java @@ -0,0 +1,389 @@ +package kr.swyp.backend.chatbot.service; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import java.util.Collections; +import java.util.List; +import java.util.UUID; +import java.util.stream.IntStream; +import kr.swyp.backend.chatbot.client.dto.ChatDto.ChatRequest; +import kr.swyp.backend.chatbot.client.dto.ChatDto.ChatResponse; +import kr.swyp.backend.chatbot.domain.ChatHistory; +import kr.swyp.backend.chatbot.domain.ChatSession; +import kr.swyp.backend.chatbot.dto.ChatDto.ChatExtractionResultDto; +import kr.swyp.backend.chatbot.dto.ChatDto.ChatHistoryDto; +import kr.swyp.backend.chatbot.dto.ChatDto.ChatRequestDto; +import kr.swyp.backend.chatbot.dto.ChatDto.ChatResponseDto; +import kr.swyp.backend.chatbot.dto.ChatDto.ChatSessionDto; +import kr.swyp.backend.chatbot.dto.ChatDto.ConversationRequestDto; +import kr.swyp.backend.chatbot.dto.ChatDto.ConversationResponseDto; +import kr.swyp.backend.chatbot.repository.ChatHistoryRepository; +import kr.swyp.backend.chatbot.repository.ChatSessionRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.util.StringUtils; + +@Slf4j +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class ChatServiceImpl implements ChatService { + + @Value("${openai.api.key}") + private String apiKey; + + private final ChatClientService chatClientService; + private final ChatHistoryRepository chatHistoryRepository; + private final ChatSessionRepository chatSessionRepository; + private final ChatPromptService chatPromptService; + private final ObjectMapper objectMapper; + + @Override + @Transactional + public ChatResponseDto ask(UUID memberId, ChatRequestDto request) { + String message = request.getMessage(); + String sessionId = request.getSessionId(); + + if (!StringUtils.hasText(sessionId)) { + // 세션이 없으면 새 세션 생성 및 저장 + sessionId = UUID.randomUUID().toString(); + + // 세션 제목은 초기 메시지로 짧게 생성 + String title = generateSessionTitle(message); + + ChatSession newSession = ChatSession.builder() + .sessionId(sessionId) + .memberId(memberId) + .title(title) + .build(); + + chatSessionRepository.save(newSession); + log.info("[챗봇] 사용자 {} 새로운 세션 생성: {}", memberId, sessionId); + } + + log.info("[챗봇] 사용자 {} (세션 {}) 질문: {}", memberId, sessionId, message); + + try { + + // 최근 대화 기록 조회 (세션 단위) + List recentHistory = chatHistoryRepository + .findTop5ByMemberIdAndSessionIdOrderByIdDesc(memberId, sessionId); + + String contextualPrompt = buildContextualPrompt(message, recentHistory); + + // ChatClientApi 호출 + String responseContent = callChatClientApi( + chatPromptService.createMessageExtractionPrompt(message), contextualPrompt); + + log.info("[챗봇] OpenAI 응답: {}", responseContent); + + ChatExtractionResultDto result = parseResponse(responseContent); + + // 대화 기록 저장 + saveChatHistory(memberId, message, result, sessionId); + + return ChatResponseDto.builder() + .contents(result.getAnswers()) + .sender("Near") + .build(); + + } catch (Exception e) { + log.error("[챗봇] 사용자 {} 요청 처리 중 오류 발생: {}", memberId, e.getMessage(), e); + return createErrorResponse(); + } + } + + private String callChatClientApi(String chatPromptService, String contextualPrompt) { + // Request DTO 생성 + ChatRequest chatRequest = ChatRequest.builder() + .model("gpt-4") + .messages(List.of( + ChatRequest.Message.builder() + .role("system") + .content(chatPromptService) + .build(), + ChatRequest.Message.builder() + .role("user") + .content(contextualPrompt) + .build() + )) + .temperature(0.7) + .build(); + + // OpenFeign 호출 + ChatResponse chatResponse = chatClientService.createChatCompletion( + "Bearer " + apiKey, + chatRequest); + + // 응답 추출 + String responseContent = chatResponse.getChoices().get(0) + .getMessage() + .getContent(); + return responseContent; + } + + private String buildContextualPrompt(String message, List history) { + if (history.isEmpty()) { + return message; + } + + StringBuilder context = new StringBuilder(); + context.append("이전 대화 기록:\n"); + + // 최신 대화부터 역순으로 보여주기 (가장 최근 대화가 더 중요) + IntStream.range(0, history.size()) + .boxed() + .sorted(Collections.reverseOrder()) + .forEach(i -> { + ChatHistory h = history.get(i); + context.append(String.format("[대화 %d]\n", history.size() - i)); + context.append("사용자: ").append(h.getQuestion()).append("\n"); + context.append("대상: ").append(h.getTarget()).append(", 주제: ") + .append(h.getTopic()).append("\n"); + if (h.getResponse() != null) { + context.append("AI 응답: ").append(h.getResponse()).append("\n"); + } + context.append("\n"); + }); + + context.append("현재 사용자 메시지: ").append(message); + context.append("\n\n위의 대화 기록을 참고하여, 사용자의 대화 패턴과 선호도를 이해하고 더 개인화된 응답을 제공해주세요."); + + return context.toString(); + } + + private ChatExtractionResultDto parseResponse(String responseJson) + throws JsonProcessingException { + try { + return objectMapper.readValue(responseJson, ChatExtractionResultDto.class); + } catch (JsonProcessingException e) { + log.warn("[챗봇] AI 응답 파싱 실패: {}", responseJson); + throw e; + } + } + + private void saveChatHistory(UUID memberId, String message, ChatExtractionResultDto result, + String sessionId) { + // AI 응답을 문자열로 변환 (JSON 배열을 읽기 좋은 형태로) + String formattedResponse = String.join("\n", result.getAnswers()); + + ChatHistory history = ChatHistory.builder() + .memberId(memberId) + .target(result.getTarget()) + .question(message) + .topic(result.getTopic()) + .sessionId(sessionId) + .response(formattedResponse) + .build(); + + chatHistoryRepository.save(history); + log.debug("[챗봇] 사용자 {} 대화 기록 저장 완료", memberId); + } + + private ChatResponseDto createErrorResponse() { + return ChatResponseDto.builder() + .contents(List.of( + "죄송해요, 요청을 처리하는 중에 문제가 발생했어요.", + "다시 한 번 시도해 주시겠어요?" + )) + .sender("Near") + .build(); + } + + @Override + public List getChatHistory(UUID memberId) { + log.info("[챗봇] 사용자 {} 대화 기록 조회 중", memberId); + + List histories = chatHistoryRepository + .findTop5ByMemberIdOrderByIdDesc(memberId); + + return histories.stream() + .map(history -> ChatHistoryDto.builder() + .id(history.getId()) + .target(history.getTarget()) + .topic(history.getTopic()) + .question(history.getQuestion()) + .response(history.getResponse()) + .createdAt(history.getCreatedAt()) + .build()) + .toList(); + } + + @Override + @Transactional + public ChatSessionDto startNewSession(UUID memberId, String initialMessage) { + log.info("[챗봇] 사용자 {}의 새로운 채팅 세션 시작", memberId); + + String sessionId = UUID.randomUUID().toString(); + String title = generateSessionTitle(initialMessage); + + ChatSession session = ChatSession.builder() + .sessionId(sessionId) + .memberId(memberId) + .title(title) + .isActive(true) + .build(); + + chatSessionRepository.save(session); + + return ChatSessionDto.builder() + .sessionId(sessionId) + .title(title) + .createdAt(session.getCreatedAt()) + .isActive(session.getIsActive()) + .build(); + } + + @Override + @Transactional + public ConversationResponseDto continueConversation(UUID memberId, + ConversationRequestDto request) { + String sessionId = request.getSessionId(); + String userMessage = request.getMessage(); + + log.info("[챗봇] 세션 {}에서 사용자 {}의 연속 대화", sessionId, memberId); + + // 세션 검증 + ChatSession session = chatSessionRepository.findBySessionIdAndIsActiveTrue(sessionId) + .orElseThrow( + () -> new IllegalArgumentException("활성화된 세션을 찾을 수 없습니다: " + sessionId)); + + if (!session.getMemberId().equals(memberId)) { + throw new IllegalArgumentException("해당 세션에 접근할 권한이 없습니다."); + } + + try { + // 사용자 메시지 저장 + saveConversationMessage(sessionId, memberId, userMessage, "USER"); + + // 세션 내 대화 기록 조회 + List sessionHistory = chatHistoryRepository + .findBySessionIdOrderByCreatedAtAsc(sessionId); + + String conversationContext = buildConversationContext(sessionHistory, userMessage); + + // ChatClientApi 호출 + String responseContent = callChatClientApi(chatPromptService.createConversationPrompt( + conversationContext), userMessage); + + log.debug("[챗봇] OpenAI 응답: {}", responseContent); + + // AI 응답 저장 + ChatHistory botMessage = saveConversationMessage(sessionId, memberId, responseContent, + "BOT"); + + return ConversationResponseDto.builder() + .sessionId(sessionId) + .message(responseContent) + .sender("Near") + .timestamp(botMessage.getCreatedAt()) + .build(); + + } catch (Exception e) { + log.error("[챗봇] 세션 {} 대화 처리 중 오류: {}", sessionId, e.getMessage(), e); + + // 오류 시에도 응답 반환 + String errorResponse = "죄송해요, 응답을 생성하는 중에 문제가 발생했어요. 다시 시도해 주세요."; + ChatHistory botMessage = saveConversationMessage(sessionId, memberId, errorResponse, + "BOT"); + + return ConversationResponseDto.builder() + .sessionId(sessionId) + .message(errorResponse) + .sender("Near") + .timestamp(botMessage.getCreatedAt()) + .build(); + } + } + + @Override + public List getUserSessions(UUID memberId) { + log.info("[챗봇] 사용자 {}의 채팅 세션 목록 조회", memberId); + + return chatSessionRepository.findByMemberIdAndIsActiveTrueOrderByCreatedAtDesc(memberId) + .stream() + .map(session -> ChatSessionDto.builder() + .sessionId(session.getSessionId()) + .title(session.getTitle()) + .createdAt(session.getCreatedAt()) + .isActive(session.getIsActive()) + .build()) + .toList(); + } + + @Override + public List getSessionHistory(UUID memberId, String sessionId) { + log.info("[챗봇] 사용자 {}의 세션 {} 대화 기록 조회", memberId, sessionId); + + // 세션 접근 권한 검증 + ChatSession session = chatSessionRepository.findBySessionId(sessionId) + .orElseThrow(() -> new IllegalArgumentException("세션을 찾을 수 없습니다: " + sessionId)); + + if (!session.getMemberId().equals(memberId)) { + throw new IllegalArgumentException("해당 세션에 접근할 권한이 없습니다."); + } + + return chatHistoryRepository.findBySessionIdOrderByCreatedAtAsc(sessionId) + .stream() + .map(history -> ConversationResponseDto.builder() + .sessionId(sessionId) + .message("USER".equals(history.getMessageType()) + ? history.getQuestion() : history.getResponse()) + .sender("USER".equals(history.getMessageType()) ? "User" : "Near") + .timestamp(history.getCreatedAt()) + .build()) + .toList(); + } + + private String generateSessionTitle(String initialMessage) { + // 첫 메시지에서 제목 생성 (최대 50자) + if (initialMessage.length() > 50) { + return initialMessage.substring(0, 47) + "..."; + } + return initialMessage; + } + + private ChatHistory saveConversationMessage(String sessionId, UUID memberId, String message, + String messageType) { + ChatHistory.ChatHistoryBuilder builder = ChatHistory.builder() + .sessionId(sessionId) + .memberId(memberId) + .messageType(messageType); + + if ("USER".equals(messageType)) { + builder.question(message); + } else { + builder.response(message); + } + + return chatHistoryRepository.save(builder.build()); + } + + private String buildConversationContext(List sessionHistory, + String currentMessage) { + if (sessionHistory.isEmpty()) { + return "새로운 대화입니다. 사용자: " + currentMessage; + } + + StringBuilder context = new StringBuilder(); + context.append("이전 대화 내용:\n"); + + sessionHistory.stream() + .forEach(history -> { + if ("USER".equals(history.getMessageType()) && history.getQuestion() != null) { + context.append("사용자: ").append(history.getQuestion()).append("\n"); + } else if ("BOT".equals(history.getMessageType()) + && history.getResponse() != null) { + context.append("Near: ").append(history.getResponse()).append("\n"); + } + }); + + context.append("\n현재 사용자 메시지: ").append(currentMessage); + context.append("\n\n위 대화 맥락을 고려하여 자연스럽고 도움이 되는 응답을 해주세요."); + + return context.toString(); + } +} diff --git a/src/main/java/kr/swyp/backend/member/dto/MemberDetails.java b/src/main/java/kr/swyp/backend/member/dto/MemberDetails.java index 6e2e075..06c0e51 100644 --- a/src/main/java/kr/swyp/backend/member/dto/MemberDetails.java +++ b/src/main/java/kr/swyp/backend/member/dto/MemberDetails.java @@ -3,6 +3,7 @@ import java.util.Collection; import java.util.UUID; import lombok.Getter; +import lombok.RequiredArgsConstructor; import org.springframework.security.core.GrantedAuthority; import org.springframework.security.core.userdetails.User; diff --git a/src/main/resources/application-local.yml b/src/main/resources/application-local.yml index 174a8fb..63f2158 100644 --- a/src/main/resources/application-local.yml +++ b/src/main/resources/application-local.yml @@ -24,7 +24,7 @@ spring: import: - classpath:/application-social.yml - classpath:/application-aws.yml - + - classpath:/application-ai.yml swyp: jwt: secret: fJm76UJ3g8JL5P9tM3vUZbpuNDKVwGD8RQeMbwu2T15iZpvTM0LQCqERiXqK4Xc0 diff --git a/src/main/resources/application-test.yml b/src/main/resources/application-test.yml index a1f590f..91820c8 100644 --- a/src/main/resources/application-test.yml +++ b/src/main/resources/application-test.yml @@ -23,6 +23,7 @@ spring: config: import: - classpath:/application-social.yml + - classpath:/application-ai.yml swyp: jwt: diff --git a/src/test/java/kr/swyp/backend/chatbot/controller/ChatControllerTest.java b/src/test/java/kr/swyp/backend/chatbot/controller/ChatControllerTest.java new file mode 100644 index 0000000..33dc26f --- /dev/null +++ b/src/test/java/kr/swyp/backend/chatbot/controller/ChatControllerTest.java @@ -0,0 +1,363 @@ +package kr.swyp.backend.chatbot.controller; + +import static com.epages.restdocs.apispec.MockMvcRestDocumentationWrapper.document; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.when; +import static org.springframework.restdocs.headers.HeaderDocumentation.headerWithName; +import static org.springframework.restdocs.headers.HeaderDocumentation.requestHeaders; +import static org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders.get; +import static org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders.post; +import static org.springframework.restdocs.operation.preprocess.Preprocessors.preprocessRequest; +import static org.springframework.restdocs.operation.preprocess.Preprocessors.preprocessResponse; +import static org.springframework.restdocs.operation.preprocess.Preprocessors.prettyPrint; +import static org.springframework.restdocs.payload.PayloadDocumentation.fieldWithPath; +import static org.springframework.restdocs.payload.PayloadDocumentation.requestFields; +import static org.springframework.restdocs.payload.PayloadDocumentation.responseFields; +import static org.springframework.restdocs.request.RequestDocumentation.parameterWithName; +import static org.springframework.restdocs.request.RequestDocumentation.pathParameters; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import com.fasterxml.jackson.databind.ObjectMapper; +import jakarta.transaction.Transactional; +import java.time.LocalDateTime; +import java.util.List; +import kr.swyp.backend.authentication.provider.TokenProvider; +import kr.swyp.backend.chatbot.dto.ChatDto.ChatHistoryDto; +import kr.swyp.backend.chatbot.dto.ChatDto.ChatRequestDto; +import kr.swyp.backend.chatbot.dto.ChatDto.ChatResponseDto; +import kr.swyp.backend.chatbot.dto.ChatDto.ChatSessionDto; +import kr.swyp.backend.chatbot.dto.ChatDto.ConversationRequestDto; +import kr.swyp.backend.chatbot.dto.ChatDto.ConversationResponseDto; +import kr.swyp.backend.chatbot.service.ChatService; +import kr.swyp.backend.member.domain.Member; +import kr.swyp.backend.member.dto.MemberDetails; +import kr.swyp.backend.member.enums.RoleType; +import kr.swyp.backend.member.repository.MemberRepository; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.restdocs.AutoConfigureRestDocs; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.http.MediaType; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.ResultActions; + +@SpringBootTest +@ActiveProfiles("test") +@AutoConfigureMockMvc +@AutoConfigureRestDocs +@Transactional +class ChatControllerTest { + + @Autowired + private MockMvc mockMvc; + + @Autowired + private ObjectMapper objectMapper; + + @Autowired + private TokenProvider tokenProvider; + + @Autowired + private MemberRepository memberRepository; + + @MockitoBean + private ChatService chatService; + + private Member testMember; + private String accessToken; + + @BeforeEach + void setUp() { + // 테스트 멤버 생성 + testMember = Member.builder() + .username("test@example.com") + .nickname("테스트유저") + .build(); + testMember.addRole(RoleType.USER); + memberRepository.save(testMember); + + // JWT 토큰 생성 + MemberDetails memberDetails = new MemberDetails( + testMember.getMemberId(), + testMember.getUsername(), + "", + List.of(new SimpleGrantedAuthority("USER")) + ); + UsernamePasswordAuthenticationToken authentication = + new UsernamePasswordAuthenticationToken( + memberDetails, + null, + List.of(new SimpleGrantedAuthority("USER")) + ); + accessToken = tokenProvider.generateAccessToken(authentication); + } + + @Test + @DisplayName("메시지 추천을 할 수 있어야 한다.") + void 메시지_추천을_할_수_있어야_한다() throws Exception { + // given + ChatRequestDto request = ChatRequestDto.builder() + .message("상사에게 회의 일정 변경 요청하고 싶어") + .build(); + + ChatResponseDto mockResponse = ChatResponseDto.builder() + .sender("Near") + .contents(List.of("안녕하세요, 회의 일정 변경 요청드립니다.", "혹시 가능하시다면 시간 조정 부탁드려요.")) + .build(); + + when(chatService.ask(eq(testMember.getMemberId()), any(ChatRequestDto.class))) + .thenReturn(mockResponse); + + // when & then + ResultActions result = mockMvc.perform(post("/chat/ask") + .header("Authorization", "Bearer " + accessToken) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))); + + result.andExpect(status().isOk()) + .andExpect(jsonPath("$.sender").value("Near")) + .andExpect(jsonPath("$.contents").isArray()) + .andDo(document("chat-ask-message", + preprocessRequest(prettyPrint()), + preprocessResponse(prettyPrint()), + requestHeaders( + headerWithName("Authorization").description("Bearer 토큰") + ), + requestFields( + fieldWithPath("message").description("추천받고 싶은 메시지 상황") + ), + responseFields( + fieldWithPath("contents").description("추천 메시지 목록"), + fieldWithPath("sender").description("응답자 (Near)") + ) + )); + } + + @Test + @DisplayName("새로운 채팅 세션 시작을 할 수 있어야 한다.") + void 새로운_채팅_세션_시작을_할_수_있어야_한다() throws Exception { + // given + String initialMessage = "안녕하세요! 챗봇과 대화를 시작합니다."; + + ChatSessionDto mockResponse = ChatSessionDto.builder() + .sessionId("test-session-id") + .title(initialMessage) + .createdAt(LocalDateTime.now()) + .isActive(true) + .build(); + + when(chatService.startNewSession(eq(testMember.getMemberId()), eq(initialMessage))) + .thenReturn(mockResponse); + + // when & then + ResultActions result = mockMvc.perform(post("/chat/sessions/start") + .header("Authorization", "Bearer " + accessToken) + .param("initialMessage", initialMessage)); + + result.andExpect(status().isOk()) + .andExpect(jsonPath("$.sessionId").exists()) + .andExpect(jsonPath("$.title").value(initialMessage)) + .andExpect(jsonPath("$.isActive").value(true)) + ; + } + + @Test + @DisplayName("연속 대화를 할 수 있어야 한다.") + void 연속_대화를_할_수_있어야_한다() throws Exception { + // given + ConversationRequestDto request = ConversationRequestDto.builder() + .sessionId("test-session-id") + .message("오늘 날씨가 어때?") + .build(); + + ConversationResponseDto mockResponse = ConversationResponseDto.builder() + .sessionId("test-session-id") + .message("오늘 날씨는 맑고 좋습니다!") + .sender("Near") + .timestamp(LocalDateTime.now()) + .build(); + + when(chatService.continueConversation(eq(testMember.getMemberId()), + any(ConversationRequestDto.class))) + .thenReturn(mockResponse); + + // when & then + ResultActions result = mockMvc.perform(post("/chat/sessions/continue") + .header("Authorization", "Bearer " + accessToken) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))); + + result.andExpect(status().isOk()) + .andExpect(jsonPath("$.sessionId").value("test-session-id")) + .andExpect(jsonPath("$.message").exists()) + .andExpect(jsonPath("$.sender").value("Near")) + .andExpect(jsonPath("$.timestamp").exists()) + .andDo(document("chat-continue-conversation", + preprocessRequest(prettyPrint()), + preprocessResponse(prettyPrint()), + requestHeaders( + headerWithName("Authorization").description("Bearer 토큰") + ), + requestFields( + fieldWithPath("sessionId").description("세션 ID"), + fieldWithPath("message").description("사용자 메시지") + ), + responseFields( + fieldWithPath("sessionId").description("세션 ID"), + fieldWithPath("message").description("챗봇 응답 메시지"), + fieldWithPath("sender").description("응답자 (Near)"), + fieldWithPath("timestamp").description("응답 시간") + ) + )); + } + + @Test + @DisplayName("사용자 채팅 세션 목록을 조회할 수 있어야 한다.") + void 사용자_채팅_세션_목록을_조회할_수_있어야_한다() throws Exception { + // given + List mockSessions = List.of( + ChatSessionDto.builder() + .sessionId("test-session-id") + .title("테스트 세션") + .createdAt(LocalDateTime.now()) + .isActive(true) + .build() + ); + + when(chatService.getUserSessions(eq(testMember.getMemberId()))) + .thenReturn(mockSessions); + + // when & then + ResultActions result = mockMvc.perform(get("/chat/sessions") + .header("Authorization", "Bearer " + accessToken)); + + result.andExpect(status().isOk()) + .andExpect(jsonPath("$").isArray()) + .andExpect(jsonPath("$[0].sessionId").value("test-session-id")) + .andExpect(jsonPath("$[0].title").value("테스트 세션")) + .andDo(document("chat-get-user-sessions", + preprocessRequest(prettyPrint()), + preprocessResponse(prettyPrint()), + requestHeaders( + headerWithName("Authorization").description("Bearer 토큰") + ), + responseFields( + fieldWithPath("[].sessionId").description("세션 ID"), + fieldWithPath("[].title").description("세션 제목"), + fieldWithPath("[].createdAt").description("세션 생성 시간"), + fieldWithPath("[].isActive").description("세션 활성 상태") + ) + )); + } + + @Test + @DisplayName("특정 세션의 대화 기록을 조회할 수 있어야 한다.") + void 특정_세션의_대화_기록을_조회할_수_있어야_한다() throws Exception { + // given + String sessionId = "test-session-id"; + List mockHistory = List.of( + ConversationResponseDto.builder() + .sessionId(sessionId) + .message("친구에게 생일축하 메시지를 보내고 싶어") + .sender("User") + .timestamp(LocalDateTime.now()) + .build() + ); + + when(chatService.getSessionHistory(eq(testMember.getMemberId()), eq(sessionId))) + .thenReturn(mockHistory); + + // when & then + ResultActions result = mockMvc.perform(get("/chat/sessions/{sessionId}/history", sessionId) + .header("Authorization", "Bearer " + accessToken)); + + result.andExpect(status().isOk()) + .andExpect(jsonPath("$").isArray()) + .andExpect(jsonPath("$[0].sessionId").value(sessionId)) + .andExpect(jsonPath("$[0].message").value("친구에게 생일축하 메시지를 보내고 싶어")) + .andExpect(jsonPath("$[0].sender").value("User")) + .andDo(document("chat-get-session-history", + preprocessRequest(prettyPrint()), + preprocessResponse(prettyPrint()), + requestHeaders( + headerWithName("Authorization").description("Bearer 토큰") + ), + pathParameters( + parameterWithName("sessionId").description("조회할 세션 ID") + ), + responseFields( + fieldWithPath("[].sessionId").description("세션 ID"), + fieldWithPath("[].message").description("메시지 내용"), + fieldWithPath("[].sender").description("발신자 (User 또는 Near)"), + fieldWithPath("[].timestamp").description("메시지 시간") + ) + )); + } + + @Test + @DisplayName("대화 기록을 조회할 수 있어야 한다.") + void 대화_기록을_조회할_수_있어야_한다() throws Exception { + // given + List mockHistory = List.of( + ChatHistoryDto.builder() + .id(1L) + .target("친구") + .topic("생일축하") + .question("친구에게 생일축하 메시지를 보내고 싶어") + .response("생일 축하해! 오늘 하루 행복한 하루 보내!") + .createdAt(LocalDateTime.now()) + .build() + ); + + when(chatService.getChatHistory(eq(testMember.getMemberId()))) + .thenReturn(mockHistory); + + // when & then + ResultActions result = mockMvc.perform(get("/chat/history") + .header("Authorization", "Bearer " + accessToken)); + + result.andExpect(status().isOk()) + .andExpect(jsonPath("$").isArray()) + .andExpect(jsonPath("$[0].target").value("친구")) + .andExpect(jsonPath("$[0].topic").value("생일축하")) + .andDo(document("chat-get-history", + preprocessRequest(prettyPrint()), + preprocessResponse(prettyPrint()), + requestHeaders( + headerWithName("Authorization").description("Bearer 토큰") + ), + responseFields( + fieldWithPath("[].id").description("기록 ID"), + fieldWithPath("[].target").description("메시지 대상"), + fieldWithPath("[].topic").description("메시지 주제"), + fieldWithPath("[].question").description("사용자 질문"), + fieldWithPath("[].response").description("AI 응답"), + fieldWithPath("[].createdAt").description("생성 시간") + ) + )); + } + + @Test + @DisplayName("인증 없이 요청 시 401 오류를 반환해야 한다.") + void 인증_없이_요청_시_401_오류를_반환해야_한다() throws Exception { + // given + ChatRequestDto request = ChatRequestDto.builder() + .message("테스트 메시지") + .build(); + + // when & then + mockMvc.perform(post("/chat/ask") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isUnauthorized()); + } +} \ No newline at end of file diff --git a/src/test/java/kr/swyp/backend/chatbot/service/ChatServiceImplTest.java b/src/test/java/kr/swyp/backend/chatbot/service/ChatServiceImplTest.java new file mode 100644 index 0000000..7d1dbe8 --- /dev/null +++ b/src/test/java/kr/swyp/backend/chatbot/service/ChatServiceImplTest.java @@ -0,0 +1,294 @@ +package kr.swyp.backend.chatbot.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import java.util.List; +import java.util.Optional; +import java.util.UUID; +import kr.swyp.backend.chatbot.client.dto.ChatDto.ChatResponse; +import kr.swyp.backend.chatbot.domain.ChatHistory; +import kr.swyp.backend.chatbot.domain.ChatSession; +import kr.swyp.backend.chatbot.dto.ChatDto.ChatExtractionResultDto; +import kr.swyp.backend.chatbot.dto.ChatDto.ChatRequestDto; +import kr.swyp.backend.chatbot.dto.ChatDto.ChatResponseDto; +import kr.swyp.backend.chatbot.dto.ChatDto.ChatSessionDto; +import kr.swyp.backend.chatbot.dto.ChatDto.ConversationRequestDto; +import kr.swyp.backend.chatbot.dto.ChatDto.ConversationResponseDto; +import kr.swyp.backend.chatbot.repository.ChatHistoryRepository; +import kr.swyp.backend.chatbot.repository.ChatSessionRepository; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.test.context.ActiveProfiles; + +@ExtendWith(MockitoExtension.class) +@ActiveProfiles("test") +class ChatServiceImplTest { + + @Mock + private ChatClientService chatClientService; + + @Mock + private ChatHistoryRepository chatHistoryRepository; + + @Mock + private ChatSessionRepository chatSessionRepository; + + @Mock + private ChatPromptService chatPromptService; + + @Mock + private ObjectMapper objectMapper; + + @InjectMocks + private ChatServiceImpl chatService; + + private UUID memberId; + private String sessionId; + private ChatHistory chatHistory; + private ChatSession chatSession; + + @BeforeEach + void setUp() { + memberId = UUID.randomUUID(); + sessionId = UUID.randomUUID().toString(); + + chatHistory = ChatHistory.builder() + .id(1L) + .memberId(memberId) + .sessionId(sessionId) + .target("친구") + .topic("생일축하") + .question("친구에게 생일축하 메시지를 보내고 싶어") + .response("생일 축하해! 오늘 하루 행복한 하루 보내!") + .messageType("USER") + .build(); + + chatSession = ChatSession.builder() + .id(1L) + .sessionId(sessionId) + .memberId(memberId) + .title("친구에게 생일축하 메시지를 보내고 싶어") + .isActive(true) + .build(); + } + + @Test + @DisplayName("메시지 추천 요청 시 정상적으로 응답을 반환한다.") + void 메시지_추천_요청을_할_수_있어야_한다() throws JsonProcessingException { + // given + ChatRequestDto request = ChatRequestDto.builder() + .message("친구에게 생일축하 메시지를 보내고 싶어") + .build(); + + String aiResponseJson = """ + { + "target": "친구", + "topic": "생일축하", + "answers": [ + "생일 축하해! 오늘 하루 행복한 하루 보내!", + "또 한 살 먹었네! 생일 축하하고 맛있는 거 많이 먹어~", + "생일 축하합니다! 새로운 한 해도 건강하고 행복하게 보내세요" + ] + } + """; + + ChatExtractionResultDto extractionResult = ChatExtractionResultDto.builder() + .target("친구") + .topic("생일축하") + .answers(List.of( + "생일 축하해! 오늘 하루 행복한 하루 보내!", + "또 한 살 먹었네! 생일 축하하고 맛있는 거 많이 먹어~", + "생일 축하합니다! 새로운 한 해도 건강하고 행복하게 보내세요" + )) + .build(); + + ChatResponse mockChatResponse = ChatResponse.builder() + .choices(List.of( + ChatResponse.Choice.builder() + .message(ChatResponse.Choice.Message.builder() + .content(aiResponseJson) + .build()) + .build() + )) + .build(); + + given(chatHistoryRepository.findTop5ByMemberIdAndSessionIdOrderByIdDesc(any(UUID.class), + anyString())) + .willReturn(List.of()); + given(chatPromptService.createMessageExtractionPrompt(anyString())) + .willReturn("test prompt"); + given(chatClientService.createChatCompletion(anyString(), any())) + .willReturn(mockChatResponse); + given(objectMapper.readValue(aiResponseJson, ChatExtractionResultDto.class)) + .willReturn(extractionResult); + given(chatSessionRepository.save(any(ChatSession.class))) + .willReturn(chatSession); + given(chatHistoryRepository.save(any(ChatHistory.class))) + .willReturn(chatHistory); + + // when + ChatResponseDto response = chatService.ask(memberId, request); + + // then + assertThat(response).isNotNull(); + assertThat(response.getContents()).hasSize(3); + assertThat(response.getSender()).isEqualTo("Near"); + assertThat(response.getContents()).contains("생일 축하해! 오늘 하루 행복한 하루 보내!"); + + verify(chatSessionRepository).save(any(ChatSession.class)); + verify(chatHistoryRepository).save(any(ChatHistory.class)); + } + + @Test + @DisplayName("새로운 채팅 세션을 시작할 수 있다.") + void 새로운_채팅_세션을_시작할_수_있어야_한다() { + // given + String initialMessage = "안녕하세요! 챗봇과 대화를 시작합니다."; + + given(chatSessionRepository.save(any(ChatSession.class))) + .willReturn(chatSession); + + // when + ChatSessionDto result = chatService.startNewSession(memberId, initialMessage); + + // then + assertThat(result).isNotNull(); + assertThat(result.getSessionId()).isNotNull(); + assertThat(result.getTitle()).isEqualTo(initialMessage); + assertThat(result.getIsActive()).isTrue(); + + verify(chatSessionRepository).save(any(ChatSession.class)); + } + + @Test + @DisplayName("기존 세션에서 대화를 계속할 수 있다.") + void 기존_세션에서_대화를_계속할_수_있어야_한다() { + // given + ConversationRequestDto request = ConversationRequestDto.builder() + .sessionId(sessionId) + .message("오늘 날씨가 어때?") + .build(); + + String aiResponse = "오늘 날씨에 대해 구체적으로 알려드리기 어렵지만, 날씨 앱을 확인해보시는 것은 어떨까요?"; + + ChatResponse mockChatResponse = ChatResponse.builder() + .choices(List.of( + ChatResponse.Choice.builder() + .message(ChatResponse.Choice.Message.builder() + .content(aiResponse) + .build()) + .build() + )) + .build(); + + given(chatSessionRepository.findBySessionIdAndIsActiveTrue(sessionId)) + .willReturn(Optional.of(chatSession)); + given(chatHistoryRepository.findBySessionIdOrderByCreatedAtAsc(sessionId)) + .willReturn(List.of()); + given(chatPromptService.createConversationPrompt(anyString())) + .willReturn("conversation prompt"); + given(chatClientService.createChatCompletion(anyString(), any())) + .willReturn(mockChatResponse); + given(chatHistoryRepository.save(any(ChatHistory.class))) + .willReturn(chatHistory); + + // when + ConversationResponseDto response = chatService.continueConversation(memberId, request); + + // then + assertThat(response).isNotNull(); + assertThat(response.getSessionId()).isEqualTo(sessionId); + assertThat(response.getMessage()).isEqualTo(aiResponse); + assertThat(response.getSender()).isEqualTo("Near"); + + verify(chatHistoryRepository, times(2)).save(any(ChatHistory.class)); + } + + @Test + @DisplayName("존재하지 않는 세션으로 대화 시 예외가 발생한다.") + void 존재하지_않는_세션으로_대화_시_예외가_발생시켜야_한다() { + // given + ConversationRequestDto request = ConversationRequestDto.builder() + .sessionId("nonexistent-session") + .message("안녕하세요") + .build(); + + given(chatSessionRepository.findBySessionIdAndIsActiveTrue("nonexistent-session")) + .willReturn(Optional.empty()); + + // when & then + assertThatThrownBy(() -> chatService.continueConversation(memberId, request)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("활성화된 세션을 찾을 수 없습니다"); + } + + @Test + @DisplayName("다른 사용자의 세션에 접근 시 예외가 발생한다.") + void 다른_사용자의_세션에_접근_시_예외가_발생시켜야_한다() { + // given + UUID otherMemberId = UUID.randomUUID(); + ConversationRequestDto request = ConversationRequestDto.builder() + .sessionId(sessionId) + .message("안녕하세요") + .build(); + + given(chatSessionRepository.findBySessionIdAndIsActiveTrue(sessionId)) + .willReturn(Optional.of(chatSession)); + + // when & then + assertThatThrownBy(() -> chatService.continueConversation(otherMemberId, request)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("해당 세션에 접근할 권한이 없습니다"); + } + + @Test + @DisplayName("사용자의 채팅 세션 목록을 조회할 수 있다.") + void 사용자의_채팅_세션_목록을_조회할_수_있어야_한다() { + // given + List sessions = List.of(chatSession); + given(chatSessionRepository.findByMemberIdAndIsActiveTrueOrderByCreatedAtDesc(memberId)) + .willReturn(sessions); + + // when + List result = chatService.getUserSessions(memberId); + + // then + assertThat(result).hasSize(1); + assertThat(result.get(0).getSessionId()).isEqualTo(sessionId); + assertThat(result.get(0).getTitle()).isEqualTo("친구에게 생일축하 메시지를 보내고 싶어"); + } + + @Test + @DisplayName("특정 세션의 대화 기록을 조회할 수 있다.") + void 특정_세션의_대화_기록을_조회할_수_있어야_한다() { + // given + List histories = List.of(chatHistory); + given(chatSessionRepository.findBySessionId(sessionId)) + .willReturn(Optional.of(chatSession)); + given(chatHistoryRepository.findBySessionIdOrderByCreatedAtAsc(sessionId)) + .willReturn(histories); + + // when + List result = chatService.getSessionHistory(memberId, sessionId); + + // then + assertThat(result).hasSize(1); + assertThat(result.get(0).getSessionId()).isEqualTo(sessionId); + assertThat(result.get(0).getMessage()).isEqualTo("친구에게 생일축하 메시지를 보내고 싶어"); + assertThat(result.get(0).getSender()).isEqualTo("User"); + } + +} \ No newline at end of file