Skip to content
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -43,3 +43,6 @@ db_dev.trace.db
### Environment Variables ###
.env

### Claude AI ###
CLAUDE.md
.claude/
1 change: 1 addition & 0 deletions build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
Original file line number Diff line number Diff line change
@@ -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<RsData<ChatResponseDto>> 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<RsData<List<ChatConversation>>> getChatHistory(@PathVariable String sessionId) {
try {
List<ChatConversation> 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<RsData<List<ChatConversation>>> getUserChatHistory(
@PathVariable Long userId,
@PathVariable String sessionId) {
try {
List<ChatConversation> history = chatbotService.getUserChatHistory(userId, sessionId);
return ResponseEntity.ok(RsData.successOf(history));
} catch (Exception e) {
log.error("사용자 채팅 기록 조회 중 오류 발생: ", e);
return ResponseEntity.internalServerError()
.body(RsData.failOf("서버 오류가 발생했습니다."));
}
}
}
19 changes: 19 additions & 0 deletions src/main/java/com/back/domain/chatbot/dto/ChatRequestDto.java
Original file line number Diff line number Diff line change
@@ -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;
}
25 changes: 25 additions & 0 deletions src/main/java/com/back/domain/chatbot/dto/ChatResponseDto.java
Original file line number Diff line number Diff line change
@@ -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();
}
}
37 changes: 37 additions & 0 deletions src/main/java/com/back/domain/chatbot/dto/GeminiRequestDto.java
Original file line number Diff line number Diff line change
@@ -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<Content> contents;

@Getter
@Setter
public static class Content {
private List<Part> 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));
}
}
41 changes: 41 additions & 0 deletions src/main/java/com/back/domain/chatbot/dto/GeminiResponseDto.java
Original file line number Diff line number Diff line change
@@ -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<Candidate> candidates;

@Getter
@Setter
public static class Candidate {
private Content content;
}

@Getter
@Setter
public static class Content {
private List<Part> 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 "응답을 생성할 수 없습니다.";
}
}
38 changes: 38 additions & 0 deletions src/main/java/com/back/domain/chatbot/entity/ChatConversation.java
Original file line number Diff line number Diff line change
@@ -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();
}
}
Original file line number Diff line number Diff line change
@@ -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<ChatConversation, Long> {

List<ChatConversation> findBySessionIdOrderByCreatedAtAsc(String sessionId);

Page<ChatConversation> findByUserIdOrderByCreatedAtDesc(Long userId, Pageable pageable);

List<ChatConversation> findByUserIdAndSessionIdOrderByCreatedAtAsc(Long userId, String sessionId);
}
85 changes: 85 additions & 0 deletions src/main/java/com/back/domain/chatbot/service/ChatbotService.java
Original file line number Diff line number Diff line change
@@ -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<ChatConversation> 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<ChatConversation> getChatHistory(String sessionId) {
return chatConversationRepository.findBySessionIdOrderByCreatedAtAsc(sessionId);
}

@Transactional(readOnly = true)
public List<ChatConversation> getUserChatHistory(Long userId, String sessionId) {
return chatConversationRepository.findByUserIdAndSessionIdOrderByCreatedAtAsc(userId, sessionId);
}
}
Original file line number Diff line number Diff line change
@@ -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<String> 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("죄송합니다. 현재 응답을 생성할 수 없습니다. 잠시 후 다시 시도해주세요.");
}
}
Loading