diff --git a/.gitignore b/.gitignore index 791b46b9..5551411e 100644 --- a/.gitignore +++ b/.gitignore @@ -43,3 +43,6 @@ db_dev.trace.db ### Environment Variables ### .env +### Claude AI ### +CLAUDE.md +.claude/ diff --git a/build.gradle.kts b/build.gradle.kts index e3ef2cd4..e67e9233 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -28,6 +28,7 @@ dependencies { implementation("org.springframework.boot:spring-boot-starter-data-jpa") implementation("org.springframework.boot:spring-boot-starter-validation") implementation("org.springframework.boot:spring-boot-starter-web") + implementation("org.springframework.boot:spring-boot-starter-webflux") implementation("io.github.cdimascio:java-dotenv:5.2.2") implementation("org.springframework.boot:spring-boot-starter-security") implementation("org.springframework.boot:spring-boot-starter-oauth2-client") diff --git a/src/main/java/com/back/domain/chatbot/controller/ChatbotController.java b/src/main/java/com/back/domain/chatbot/controller/ChatbotController.java new file mode 100644 index 00000000..b9e12684 --- /dev/null +++ b/src/main/java/com/back/domain/chatbot/controller/ChatbotController.java @@ -0,0 +1,61 @@ +package com.back.domain.chatbot.controller; + +import com.back.domain.chatbot.dto.ChatRequestDto; +import com.back.domain.chatbot.dto.ChatResponseDto; +import com.back.domain.chatbot.entity.ChatConversation; +import com.back.domain.chatbot.service.ChatbotService; +import com.back.global.rsData.RsData; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +@RestController +@RequestMapping("/api/chatbot") +@RequiredArgsConstructor +@Slf4j +public class ChatbotController { + + private final ChatbotService chatbotService; + + @PostMapping("/chat") + public ResponseEntity> sendMessage(@Valid @RequestBody ChatRequestDto requestDto) { + try { + ChatResponseDto response = chatbotService.sendMessage(requestDto); + return ResponseEntity.ok(RsData.successOf(response)); + } catch (Exception e) { + log.error("채팅 메시지 처리 중 오류 발생: ", e); + return ResponseEntity.internalServerError() + .body(RsData.failOf("서버 오류가 발생했습니다.")); + } + } + + @GetMapping("/history/{sessionId}") + public ResponseEntity>> getChatHistory(@PathVariable String sessionId) { + try { + List history = chatbotService.getChatHistory(sessionId); + return ResponseEntity.ok(RsData.successOf(history)); + } catch (Exception e) { + log.error("채팅 기록 조회 중 오류 발생: ", e); + return ResponseEntity.internalServerError() + .body(RsData.failOf("서버 오류가 발생했습니다.")); + } + } + + @GetMapping("/history/user/{userId}/session/{sessionId}") + public ResponseEntity>> getUserChatHistory( + @PathVariable Long userId, + @PathVariable String sessionId) { + try { + List history = chatbotService.getUserChatHistory(userId, sessionId); + return ResponseEntity.ok(RsData.successOf(history)); + } 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/ChatRequestDto.java b/src/main/java/com/back/domain/chatbot/dto/ChatRequestDto.java new file mode 100644 index 00000000..9b840dfb --- /dev/null +++ b/src/main/java/com/back/domain/chatbot/dto/ChatRequestDto.java @@ -0,0 +1,19 @@ +package com.back.domain.chatbot.dto; + +import jakarta.validation.constraints.NotBlank; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +@Getter +@Setter +@NoArgsConstructor +public class ChatRequestDto { + + @NotBlank(message = "메시지는 필수입니다.") + private String message; + + private String sessionId; + + private Long userId; +} \ No newline at end of file diff --git a/src/main/java/com/back/domain/chatbot/dto/ChatResponseDto.java b/src/main/java/com/back/domain/chatbot/dto/ChatResponseDto.java new file mode 100644 index 00000000..c2203e7c --- /dev/null +++ b/src/main/java/com/back/domain/chatbot/dto/ChatResponseDto.java @@ -0,0 +1,25 @@ +package com.back.domain.chatbot.dto; + +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +import java.time.LocalDateTime; + +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +public class ChatResponseDto { + + private String response; + private String sessionId; + private LocalDateTime timestamp; + + public ChatResponseDto(String response, String sessionId) { + this.response = response; + this.sessionId = sessionId; + this.timestamp = LocalDateTime.now(); + } +} \ No newline at end of file diff --git a/src/main/java/com/back/domain/chatbot/dto/GeminiRequestDto.java b/src/main/java/com/back/domain/chatbot/dto/GeminiRequestDto.java new file mode 100644 index 00000000..2b22892e --- /dev/null +++ b/src/main/java/com/back/domain/chatbot/dto/GeminiRequestDto.java @@ -0,0 +1,37 @@ +package com.back.domain.chatbot.dto; + +import lombok.Getter; +import lombok.Setter; + +import java.util.List; + +@Getter +@Setter +public class GeminiRequestDto { + + private List contents; + + @Getter + @Setter + public static class Content { + private List parts; + + public Content(String text) { + this.parts = List.of(new Part(text)); + } + } + + @Getter + @Setter + public static class Part { + private String text; + + public Part(String text) { + this.text = text; + } + } + + public GeminiRequestDto(String message) { + this.contents = List.of(new Content(message)); + } +} \ No newline at end of file diff --git a/src/main/java/com/back/domain/chatbot/dto/GeminiResponseDto.java b/src/main/java/com/back/domain/chatbot/dto/GeminiResponseDto.java new file mode 100644 index 00000000..c239ccfd --- /dev/null +++ b/src/main/java/com/back/domain/chatbot/dto/GeminiResponseDto.java @@ -0,0 +1,41 @@ +package com.back.domain.chatbot.dto; + +import lombok.Getter; +import lombok.Setter; + +import java.util.List; + +@Getter +@Setter +public class GeminiResponseDto { + + private List candidates; + + @Getter + @Setter + public static class Candidate { + private Content content; + } + + @Getter + @Setter + public static class Content { + private List parts; + } + + @Getter + @Setter + public static class Part { + private String text; + } + + public String getGeneratedText() { + if (candidates != null && !candidates.isEmpty() && + candidates.get(0).content != null && + candidates.get(0).content.parts != null && + !candidates.get(0).content.parts.isEmpty()) { + return candidates.get(0).content.parts.get(0).text; + } + return "응답을 생성할 수 없습니다."; + } +} \ 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 new file mode 100644 index 00000000..1274010b --- /dev/null +++ b/src/main/java/com/back/domain/chatbot/entity/ChatConversation.java @@ -0,0 +1,38 @@ +package com.back.domain.chatbot.entity; + +import jakarta.persistence.*; +import lombok.*; + +import java.time.LocalDateTime; + +import static jakarta.persistence.GenerationType.IDENTITY; + +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +@Builder +@ToString +@Entity +public class ChatConversation { + @Id + @GeneratedValue(strategy = IDENTITY) + private Long id; + + private Long userId; + + @Column(columnDefinition = "TEXT") + private String userMessage; + + @Column(columnDefinition = "TEXT") + private String botResponse; + + private String sessionId; + + 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/repository/ChatConversationRepository.java b/src/main/java/com/back/domain/chatbot/repository/ChatConversationRepository.java new file mode 100644 index 00000000..cb139bea --- /dev/null +++ b/src/main/java/com/back/domain/chatbot/repository/ChatConversationRepository.java @@ -0,0 +1,19 @@ +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; + +import java.util.List; + +@Repository +public interface ChatConversationRepository extends JpaRepository { + + List findBySessionIdOrderByCreatedAtAsc(String sessionId); + + Page findByUserIdOrderByCreatedAtDesc(Long userId, Pageable pageable); + + List findByUserIdAndSessionIdOrderByCreatedAtAsc(Long userId, String sessionId); +} \ 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 new file mode 100644 index 00000000..b4308104 --- /dev/null +++ b/src/main/java/com/back/domain/chatbot/service/ChatbotService.java @@ -0,0 +1,85 @@ +package com.back.domain.chatbot.service; + +import com.back.domain.chatbot.dto.ChatRequestDto; +import com.back.domain.chatbot.dto.ChatResponseDto; +import com.back.domain.chatbot.entity.ChatConversation; +import com.back.domain.chatbot.repository.ChatConversationRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; +import java.util.UUID; + +@Service +@RequiredArgsConstructor +@Slf4j +public class ChatbotService { + + private final GeminiApiService geminiApiService; + private final ChatConversationRepository chatConversationRepository; + + @Transactional + public ChatResponseDto sendMessage(ChatRequestDto requestDto) { + String sessionId = requestDto.getSessionId(); + if (sessionId == null || sessionId.isEmpty()) { + sessionId = UUID.randomUUID().toString(); + } + + try { + String contextualMessage = buildContextualMessage(requestDto.getMessage(), sessionId); + + String botResponse = geminiApiService.generateResponse(contextualMessage).block(); + + ChatConversation conversation = ChatConversation.builder() + .userId(requestDto.getUserId()) + .userMessage(requestDto.getMessage()) + .botResponse(botResponse) + .sessionId(sessionId) + .build(); + + chatConversationRepository.save(conversation); + + return new ChatResponseDto(botResponse, sessionId); + + } catch (Exception e) { + log.error("채팅 응답 생성 중 오류 발생: ", e); + return new ChatResponseDto("죄송합니다. 오류가 발생했습니다. 다시 시도해주세요.", sessionId); + } + } + + private String buildContextualMessage(String userMessage, String sessionId) { + List recentConversations = chatConversationRepository + .findBySessionIdOrderByCreatedAtAsc(sessionId); + + if (recentConversations.isEmpty()) { + return "당신은 칵테일 전문 챗봇입니다. 칵테일에 관련된 질문에 친근하고 도움이 되는 답변을 해주세요. 질문: " + userMessage; + } + + StringBuilder contextBuilder = new StringBuilder(); + contextBuilder.append("당신은 칵테일 전문 챗봇입니다. 다음은 이전 대화 내용입니다:\n\n"); + + int maxHistory = Math.min(recentConversations.size(), 5); + for (int i = Math.max(0, recentConversations.size() - maxHistory); i < recentConversations.size(); i++) { + ChatConversation conv = recentConversations.get(i); + contextBuilder.append("사용자: ").append(conv.getUserMessage()).append("\n"); + contextBuilder.append("챗봇: ").append(conv.getBotResponse()).append("\n\n"); + } + + contextBuilder.append("새로운 질문: ").append(userMessage); + contextBuilder.append("\n\n이전 대화 맥락을 고려하여 친근하고 도움이 되는 답변을 해주세요."); + + return contextBuilder.toString(); + } + + @Transactional(readOnly = true) + public List getChatHistory(String sessionId) { + return chatConversationRepository.findBySessionIdOrderByCreatedAtAsc(sessionId); + } + + @Transactional(readOnly = true) + public List getUserChatHistory(Long userId, String sessionId) { + return chatConversationRepository.findByUserIdAndSessionIdOrderByCreatedAtAsc(userId, sessionId); + } +} \ No newline at end of file diff --git a/src/main/java/com/back/domain/chatbot/service/GeminiApiService.java b/src/main/java/com/back/domain/chatbot/service/GeminiApiService.java new file mode 100644 index 00000000..160e93fa --- /dev/null +++ b/src/main/java/com/back/domain/chatbot/service/GeminiApiService.java @@ -0,0 +1,34 @@ +package com.back.domain.chatbot.service; + +import com.back.domain.chatbot.dto.GeminiRequestDto; +import com.back.domain.chatbot.dto.GeminiResponseDto; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; +import org.springframework.web.reactive.function.client.WebClient; +import reactor.core.publisher.Mono; + +@Service +@RequiredArgsConstructor +@Slf4j +public class GeminiApiService { + + private final WebClient geminiWebClient; + + @Value("${gemini.api.key}") + private String apiKey; + + public Mono generateResponse(String userMessage) { + GeminiRequestDto requestDto = new GeminiRequestDto(userMessage); + + return geminiWebClient.post() + .uri("?key=" + apiKey) + .bodyValue(requestDto) + .retrieve() + .bodyToMono(GeminiResponseDto.class) + .map(GeminiResponseDto::getGeneratedText) + .doOnError(error -> log.error("Gemini API 호출 실패: ", error)) + .onErrorReturn("죄송합니다. 현재 응답을 생성할 수 없습니다. 잠시 후 다시 시도해주세요."); + } +} \ No newline at end of file diff --git a/src/main/java/com/back/global/appConfig/GeminiConfig.java b/src/main/java/com/back/global/appConfig/GeminiConfig.java new file mode 100644 index 00000000..9695ed7b --- /dev/null +++ b/src/main/java/com/back/global/appConfig/GeminiConfig.java @@ -0,0 +1,24 @@ +package com.back.global.appConfig; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.web.reactive.function.client.WebClient; + +@Configuration +public class GeminiConfig { + + @Value("${gemini.api.url}") + private String geminiApiUrl; + + @Value("${gemini.api.model-name}") + private String modelName; + + @Bean + public WebClient geminiWebClient() { + return WebClient.builder() + .baseUrl(geminiApiUrl + "/" + modelName + ":generateContent") + .defaultHeader("Content-Type", "application/json") + .build(); + } +} \ No newline at end of file diff --git a/src/main/java/com/back/global/security/SecurityConfig.java b/src/main/java/com/back/global/security/SecurityConfig.java index a0cf9f55..8c8364d9 100644 --- a/src/main/java/com/back/global/security/SecurityConfig.java +++ b/src/main/java/com/back/global/security/SecurityConfig.java @@ -46,6 +46,8 @@ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { .requestMatchers("/login/oauth2/**").permitAll() .requestMatchers("/swagger-ui/**", "/api-docs/**").permitAll() .requestMatchers("/api/user/**").permitAll() + .requestMatchers("/api/cocktail/**").permitAll() + .requestMatchers("/api/chatbot/**").permitAll() .requestMatchers("/api/cocktails/**").permitAll() diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 75bc07f9..020dcd2f 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -65,7 +65,11 @@ server: enabled: true force: true - +gemini: + api: + key: ${GEMINI_API_KEY} + model-name: "gemini-1.5-flash-latest" + url: "https://generativelanguage.googleapis.com/v1beta/models" custom: jwt: