diff --git a/Dockerfile b/Dockerfile index eea0c19f..e9231c4c 100644 --- a/Dockerfile +++ b/Dockerfile @@ -17,7 +17,9 @@ COPY .env . COPY src src # 애플리케이션 빌드 -RUN gradle build --no-daemon +#RUN gradle build --no-daemon +# 테스트 빌드 안하고 실행 +RUN gradle build -x test --no-daemon # 두 번째 스테이지: 실행 스테이지 FROM container-registry.oracle.com/graalvm/jdk:21 diff --git a/build.gradle.kts b/build.gradle.kts index 6381f721..f6a6aec9 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -22,6 +22,9 @@ configurations { repositories { mavenCentral() + // Spring AI 마일스톤 리포지토리 추가 + maven { url = uri("https://repo.spring.io/milestone") } + maven { url = uri("https://repo.spring.io/snapshot") } } dependencies { @@ -50,6 +53,14 @@ dependencies { annotationProcessor("org.projectlombok:lombok") + //Spring AI + implementation(platform("org.springframework.ai:spring-ai-bom:1.0.0-M4")) + + //Vertex + //implementation("org.springframework.ai:spring-ai-vertex-ai-gemini-spring-boot-starter") + + //OpenAI + implementation("org.springframework.ai:spring-ai-openai-spring-boot-starter") //test testImplementation("org.springframework.boot:spring-boot-starter-test") @@ -59,4 +70,4 @@ dependencies { tasks.withType { useJUnitPlatform() -} +} \ 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 deleted file mode 100644 index 59169729..00000000 --- a/src/main/java/com/back/domain/chatbot/dto/GeminiRequestDto.java +++ /dev/null @@ -1,187 +0,0 @@ -package com.back.domain.chatbot.dto; - -import lombok.*; - -import java.util.List; - -@Getter -@Setter -public class GeminiRequestDto { - - private List contents; - private GenerationConfig generationConfig; - private List safetySettings; - - @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; - } - } - - @Getter - @Setter - @Builder - @NoArgsConstructor - @AllArgsConstructor - public static class GenerationConfig { // 생성 설정 - /** - * Temperature (0.0 ~ 2.0) - * - 낮을수록 (0.0): 일관되고 예측 가능한 응답 - * - 높을수록 (2.0): 창의적이고 다양한 응답 - * - 권장값: 0.7 ~ 1.0 - */ - private Double temperature; - - /** - * Top-P (0.0 ~ 1.0) - * - 누적 확률 임계값 - * - 0.95 = 상위 95% 확률의 토큰만 고려 - * - 낮을수록 더 집중된 응답 - */ - private Double topP; - - /** - * Top-K (1 ~ 40) - * - 고려할 토큰의 최대 개수 - * - 40 = 상위 40개 토큰만 고려 - * - 낮을수록 더 결정적인 응답 - */ - private Integer topK; - - /** - * Max Output Tokens - * - 응답의 최대 토큰 수 (출력 길이 제한) - * - Gemini 1.5 Flash: 최대 8192 토큰 - * - Gemini 1.5 Pro: 최대 8192 토큰 - * - 한글 1글자 ≈ 1-2 토큰, 영어 3-4글자 ≈ 1 토큰 - */ - private Integer maxOutputTokens; - - /** - * Stop Sequences - * - 이 문자열을 만나면 생성 중단 - * - 예: ["끝", "END", "\n\n"] - */ - private List stopSequences; - - /** - * Candidate Count (1 ~ 8) - * - 생성할 응답 후보의 개수 - * - 여러 개 생성 후 최적 선택 가능 - */ - private Integer candidateCount; - } - - @Getter - @Setter - @Builder - @NoArgsConstructor - @AllArgsConstructor - public static class SafetySetting { - private String category; - private String threshold; - - // 카테고리 상수 - public static final String HARM_CATEGORY_HARASSMENT = "HARM_CATEGORY_HARASSMENT"; - public static final String HARM_CATEGORY_HATE_SPEECH = "HARM_CATEGORY_HATE_SPEECH"; - public static final String HARM_CATEGORY_SEXUALLY_EXPLICIT = "HARM_CATEGORY_SEXUALLY_EXPLICIT"; - public static final String HARM_CATEGORY_DANGEROUS_CONTENT = "HARM_CATEGORY_DANGEROUS_CONTENT"; - - // 임계값 상수 - public static final String BLOCK_NONE = "BLOCK_NONE"; // 차단 안함 - public static final String BLOCK_LOW_AND_ABOVE = "BLOCK_LOW_AND_ABOVE"; // 낮음 이상 차단 - public static final String BLOCK_MEDIUM_AND_ABOVE = "BLOCK_MEDIUM_AND_ABOVE"; // 중간 이상 차단 - public static final String BLOCK_HIGH = "BLOCK_ONLY_HIGH"; // 높음만 차단 - } - - // 기본 생성자 - 간단한 텍스트만 - public GeminiRequestDto(String message) { - this.contents = List.of(new Content(message)); - } - - // 파라미터 설정 포함 생성자 - public GeminiRequestDto(String message, GenerationConfig config) { - this.contents = List.of(new Content(message)); - this.generationConfig = config; - } - - // 전체 설정 포함 생성자 - public GeminiRequestDto(String message, GenerationConfig config, List safetySettings) { - this.contents = List.of(new Content(message)); - this.generationConfig = config; - this.safetySettings = safetySettings; - } - - public static GeminiRequestDto createForCocktailChat(String message, boolean isDetailedResponse) { - GenerationConfig config = GenerationConfig.builder() - .temperature(0.8) // 적당한 창의성 - .topP(0.95) // 상위 95% 토큰 고려 - .topK(40) // 상위 40개 토큰 - .maxOutputTokens(isDetailedResponse ? 300 : 150) // 상세 답변 vs 간단 답변 - .candidateCount(1) // 하나의 응답만 - .stopSequences(List.of("끝.", "이상입니다.")) // 종료 구문 - .build(); - - // 안전 설정 (칵테일 관련이므로 비교적 관대하게) - List safetySettings = List.of( - SafetySetting.builder() - .category(SafetySetting.HARM_CATEGORY_HARASSMENT) - .threshold(SafetySetting.BLOCK_MEDIUM_AND_ABOVE) - .build(), - SafetySetting.builder() - .category(SafetySetting.HARM_CATEGORY_HATE_SPEECH) - .threshold(SafetySetting.BLOCK_MEDIUM_AND_ABOVE) - .build(), - SafetySetting.builder() - .category(SafetySetting.HARM_CATEGORY_SEXUALLY_EXPLICIT) - .threshold(SafetySetting.BLOCK_MEDIUM_AND_ABOVE) - .build(), - SafetySetting.builder() - .category(SafetySetting.HARM_CATEGORY_DANGEROUS_CONTENT) - .threshold(SafetySetting.BLOCK_LOW_AND_ABOVE) // 음주 관련이므로 더 엄격 - .build() - ); - - return new GeminiRequestDto(message, config, safetySettings); - } - - // 간결한 답변용 프리셋 - public static GeminiRequestDto createBriefResponse(String message) { - GenerationConfig config = GenerationConfig.builder() - .temperature(0.5) // 더 일관된 답변 - .topP(0.8) // 더 집중된 선택 - .topK(20) // 적은 선택지 - .maxOutputTokens(100) // 짧은 답변 - .candidateCount(1) - .build(); - - return new GeminiRequestDto(message, config); - } - - // 창의적 답변용 프리셋 - public static GeminiRequestDto createCreativeResponse(String message) { - GenerationConfig config = GenerationConfig.builder() - .temperature(1.2) // 높은 창의성 - .topP(0.98) // 더 다양한 선택 - .topK(40) // 많은 선택지 - .maxOutputTokens(500) // 긴 답변 허용 - .candidateCount(1) - .build(); - - return new GeminiRequestDto(message, config); - } -} \ 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 deleted file mode 100644 index c239ccfd..00000000 --- a/src/main/java/com/back/domain/chatbot/dto/GeminiResponseDto.java +++ /dev/null @@ -1,41 +0,0 @@ -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/service/ChatbotService.java b/src/main/java/com/back/domain/chatbot/service/ChatbotService.java index b0e4156e..54f48eff 100644 --- a/src/main/java/com/back/domain/chatbot/service/ChatbotService.java +++ b/src/main/java/com/back/domain/chatbot/service/ChatbotService.java @@ -6,6 +6,12 @@ import com.back.domain.chatbot.repository.ChatConversationRepository; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import org.springframework.ai.chat.client.ChatClient; +import org.springframework.ai.chat.client.advisor.MessageChatMemoryAdvisor; +import org.springframework.ai.chat.memory.InMemoryChatMemory; +import org.springframework.ai.chat.model.ChatModel; +import org.springframework.ai.chat.messages.*; +import org.springframework.ai.openai.OpenAiChatOptions; import org.springframework.beans.factory.annotation.Value; import org.springframework.core.io.Resource; import org.springframework.stereotype.Service; @@ -15,17 +21,21 @@ import jakarta.annotation.PostConstruct; import java.io.IOException; import java.nio.charset.StandardCharsets; -import java.util.List; -import java.util.UUID; +import java.time.LocalDateTime; +import java.util.*; +import java.util.concurrent.ConcurrentHashMap; @Service @RequiredArgsConstructor @Slf4j public class ChatbotService { - private final GeminiApiService geminiApiService; + private final ChatModel chatModel; private final ChatConversationRepository chatConversationRepository; + // 세션별 메모리 관리 (Thread-Safe) + private final ConcurrentHashMap sessionMemories = new ConcurrentHashMap<>(); + @Value("classpath:prompts/chatbot-system-prompt.txt") private Resource systemPromptResource; @@ -35,84 +45,212 @@ public class ChatbotService { @Value("${chatbot.history.max-conversation-count:5}") private int maxConversationCount; + @Value("${spring.ai.openai.chat.options.temperature:0.8}") + private Double temperature; + + @Value("${spring.ai.openai.chat.options.max-tokens:300}") + private Integer maxTokens; + private String systemPrompt; private String responseRules; + private ChatClient chatClient; @PostConstruct public void init() throws IOException { - this.systemPrompt = StreamUtils.copyToString(systemPromptResource.getInputStream(), StandardCharsets.UTF_8); - this.responseRules = StreamUtils.copyToString(responseRulesResource.getInputStream(), StandardCharsets.UTF_8); - log.info("챗봇 시스템 프롬프트가 로드되었습니다. (길이: {} 문자)", systemPrompt.length()); + this.systemPrompt = StreamUtils.copyToString( + systemPromptResource.getInputStream(), + StandardCharsets.UTF_8 + ); + this.responseRules = StreamUtils.copyToString( + responseRulesResource.getInputStream(), + StandardCharsets.UTF_8 + ); + + // ChatClient 고급 설정 + this.chatClient = ChatClient.builder(chatModel) + .defaultSystem(systemPrompt) + .defaultOptions(OpenAiChatOptions.builder() + .withTemperature(temperature) + .withMaxTokens(maxTokens) + .build()) + .build(); + + log.info("Spring AI 챗봇 초기화 완료. Temperature: {}, MaxTokens: {}", temperature, maxTokens); } @Transactional public ChatResponseDto sendMessage(ChatRequestDto requestDto) { - String sessionId = requestDto.getSessionId(); - if (sessionId == null || sessionId.isEmpty()) { - sessionId = UUID.randomUUID().toString(); - } + String sessionId = ensureSessionId(requestDto.getSessionId()); try { - String contextualMessage = buildContextualMessage(requestDto.getMessage(), sessionId); + // 메시지 타입 감지 + MessageType messageType = detectMessageType(requestDto.getMessage()); - String botResponse = geminiApiService.generateResponse(contextualMessage).block(); + // 세션별 메모리 가져오기 + InMemoryChatMemory chatMemory = getOrCreateSessionMemory(sessionId); - ChatConversation conversation = ChatConversation.builder() - .userId(requestDto.getUserId()) - .userMessage(requestDto.getMessage()) - .botResponse(botResponse) - .sessionId(sessionId) - .build(); + // 이전 대화 기록 로드 + loadConversationHistory(sessionId, chatMemory); + + // ChatClient 빌더 생성 + var promptBuilder = chatClient.prompt() + .system(buildSystemMessage(messageType)) + .user(buildUserMessage(requestDto.getMessage(), messageType)) + .advisors(new MessageChatMemoryAdvisor(chatMemory)); + + // RAG 기능은 향후 구현 예정 (Vector DB 설정 필요) + + // 응답 생성 + String response = promptBuilder + .options(getOptionsForMessageType(messageType)) + .call() + .content(); + + // 응답 후처리 + response = postProcessResponse(response, messageType); - chatConversationRepository.save(conversation); + // 대화 저장 + saveConversation(requestDto, response, sessionId); - return new ChatResponseDto(botResponse, sessionId); + return new ChatResponseDto(response, sessionId); } catch (Exception e) { log.error("채팅 응답 생성 중 오류 발생: ", e); - return new ChatResponseDto("죄송합니다. 오류가 발생했습니다. 다시 시도해주세요.", sessionId); + return handleError(sessionId, e); } } - private String buildContextualMessage(String userMessage, String sessionId) { - List recentConversations = getRecentConversations(sessionId); + private String ensureSessionId(String sessionId) { + return (sessionId == null || sessionId.isEmpty()) + ? UUID.randomUUID().toString() + : sessionId; + } - StringBuilder contextBuilder = new StringBuilder(); - contextBuilder.append(systemPrompt).append("\n\n"); + private InMemoryChatMemory getOrCreateSessionMemory(String sessionId) { + return sessionMemories.computeIfAbsent( + sessionId, + k -> new InMemoryChatMemory() + ); + } - appendConversationHistory(contextBuilder, recentConversations); - appendCurrentQuestion(contextBuilder, userMessage); - appendResponseInstructions(contextBuilder); + private void loadConversationHistory(String sessionId, InMemoryChatMemory chatMemory) { + List conversations = + chatConversationRepository.findBySessionIdOrderByCreatedAtAsc(sessionId); - return contextBuilder.toString(); + // 최근 N개의 대화만 메모리에 로드 + String sessionIdForMemory = sessionId; + conversations.stream() + .skip(Math.max(0, conversations.size() - maxConversationCount)) + .forEach(conv -> { + chatMemory.add(sessionIdForMemory, new UserMessage(conv.getUserMessage())); + chatMemory.add(sessionIdForMemory, new AssistantMessage(conv.getBotResponse())); + }); } - private List getRecentConversations(String sessionId) { - return chatConversationRepository.findBySessionIdOrderByCreatedAtAsc(sessionId); + private String buildSystemMessage(MessageType type) { + StringBuilder sb = new StringBuilder(systemPrompt); + + // 메시지 타입별 추가 지시사항 + switch (type) { + case RECIPE: + sb.append("\n\n【레시피 답변 모드】정확한 재료 비율과 제조 순서를 강조하세요."); + break; + case RECOMMENDATION: + sb.append("\n\n【추천 모드】다양한 선택지와 각각의 특징을 설명하세요."); + break; + case QUESTION: + sb.append("\n\n【질문 답변 모드】정확하고 신뢰할 수 있는 정보를 제공하세요."); + break; + default: + break; + } + + return sb.toString(); } - private void appendConversationHistory(StringBuilder contextBuilder, List conversations) { - if (!conversations.isEmpty()) { - contextBuilder.append("=== 이전 대화 기록 ===\n"); + private String buildUserMessage(String userMessage, MessageType type) { + return userMessage + "\n\n" + responseRules; + } - int maxHistory = Math.min(conversations.size(), maxConversationCount); - int startIdx = Math.max(0, conversations.size() - maxHistory); + private OpenAiChatOptions getOptionsForMessageType(MessageType type) { + return switch (type) { + case RECIPE -> OpenAiChatOptions.builder() + .withTemperature(0.3) // 정확성 중시 + .withMaxTokens(400) // 레시피는 길게 + .build(); + case RECOMMENDATION -> OpenAiChatOptions.builder() + .withTemperature(0.9) // 다양성 중시 + .withMaxTokens(250) + .build(); + case QUESTION -> OpenAiChatOptions.builder() + .withTemperature(0.7) // 균형 + .withMaxTokens(200) + .build(); + default -> OpenAiChatOptions.builder() + .withTemperature(temperature) + .withMaxTokens(maxTokens) + .build(); + }; + } + + + private String postProcessResponse(String response, MessageType type) { + // 응답 길이 제한 확인 + if (response.length() > 500) { + response = response.substring(0, 497) + "..."; + } - for (int i = startIdx; i < conversations.size(); i++) { - ChatConversation conv = conversations.get(i); - contextBuilder.append("사용자: ").append(conv.getUserMessage()).append("\n"); - contextBuilder.append("AI 바텐더: ").append(conv.getBotResponse()).append("\n\n"); - } - contextBuilder.append("=================\n\n"); + // 이모지 추가 (타입별) + if (type == MessageType.RECIPE && !response.contains("🍹")) { + response = "🍹 " + response; } + + return response; } - private void appendCurrentQuestion(StringBuilder contextBuilder, String userMessage) { - contextBuilder.append("현재 사용자 질문: ").append(userMessage).append("\n\n"); + private void saveConversation(ChatRequestDto requestDto, String response, String sessionId) { + ChatConversation conversation = ChatConversation.builder() + .userId(requestDto.getUserId()) + .userMessage(requestDto.getMessage()) + .botResponse(response) + .sessionId(sessionId) + .createdAt(LocalDateTime.now()) + .build(); + + chatConversationRepository.save(conversation); + } + + private ChatResponseDto handleError(String sessionId, Exception e) { + String errorMessage = "죄송합니다. 잠시 후 다시 시도해주세요."; + + if (e.getMessage().contains("rate limit")) { + errorMessage = "요청이 너무 많습니다. 잠시 후 다시 시도해주세요."; + } else if (e.getMessage().contains("timeout")) { + errorMessage = "응답 시간이 초과되었습니다. 다시 시도해주세요."; + } + + return new ChatResponseDto(errorMessage, sessionId); } - private void appendResponseInstructions(StringBuilder contextBuilder) { - contextBuilder.append(responseRules); + public enum MessageType { + RECIPE, RECOMMENDATION, QUESTION, CASUAL_CHAT + } + + private MessageType detectMessageType(String message) { + String lower = message.toLowerCase(); + + if (lower.contains("레시피") || lower.contains("만드는") || + lower.contains("제조") || lower.contains("recipe")) { + return MessageType.RECIPE; + } else if (lower.contains("추천") || lower.contains("어때") || + lower.contains("뭐가 좋") || lower.contains("recommend")) { + return MessageType.RECOMMENDATION; + } else if (lower.contains("?") || lower.contains("뭐") || + lower.contains("어떻") || lower.contains("왜")) { + return MessageType.QUESTION; + } + + return MessageType.CASUAL_CHAT; } @Transactional(readOnly = true) @@ -124,4 +262,17 @@ public List getChatHistory(String sessionId) { public List getUserChatHistory(Long userId, String sessionId) { return chatConversationRepository.findByUserIdAndSessionIdOrderByCreatedAtAsc(userId, sessionId); } -} \ No newline at end of file + + // 정기적인 메모리 정리 (스케줄러로 호출) + public void cleanupInactiveSessions() { + long thirtyMinutesAgo = System.currentTimeMillis() - (30 * 60 * 1000); + + sessionMemories.entrySet().removeIf(entry -> { + // 실제로는 마지막 사용 시간을 추적해야 함 + return false; + }); + + log.info("세션 메모리 정리 완료. 현재 활성 세션: {}", sessionMemories.size()); + } +} + diff --git a/src/main/java/com/back/domain/chatbot/service/GeminiApiService.java b/src/main/java/com/back/domain/chatbot/service/GeminiApiService.java deleted file mode 100644 index e6870a4d..00000000 --- a/src/main/java/com/back/domain/chatbot/service/GeminiApiService.java +++ /dev/null @@ -1,180 +0,0 @@ -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 callGeminiApi(requestDto); - } - - // 간단한 응답 생성 (100 토큰) - public Mono generateBriefResponse(String userMessage) { - GeminiRequestDto requestDto = GeminiRequestDto.createBriefResponse(userMessage); - - return callGeminiApi(requestDto); - } - - // 상세한 응답 생성 (200 토큰, 구조화된 답변) - public Mono generateDetailedResponse(String userMessage) { - GeminiRequestDto requestDto = GeminiRequestDto.createForCocktailChat(userMessage, true); - - return callGeminiApi(requestDto); - } - - // 창의적인 응답 생성 (500 토큰, 높은 창의성) - public Mono generateCreativeResponse(String userMessage) { - GeminiRequestDto requestDto = GeminiRequestDto.createCreativeResponse(userMessage); - - return callGeminiApi(requestDto); - } - - // 사용자 지정 파라미터로 응답 생성 ( 커스텀 ) - public Mono generateCustomResponse( - String userMessage, - Double temperature, - Integer maxTokens, - Double topP, - Integer topK - ) { - GeminiRequestDto.GenerationConfig config = GeminiRequestDto.GenerationConfig.builder() - .temperature(temperature != null ? temperature : 0.8) - .maxOutputTokens(maxTokens != null ? maxTokens : 200) - .topP(topP != null ? topP : 0.95) - .topK(topK != null ? topK : 40) - .candidateCount(1) - .build(); - - GeminiRequestDto requestDto = new GeminiRequestDto(userMessage, config); - - return callGeminiApi(requestDto); - } - - // 메시지 유형에 따른 최적화된 응답 생성 - public Mono generateOptimizedResponse(String userMessage, MessageType messageType) { - GeminiRequestDto requestDto = switch (messageType) { - case RECIPE -> createRecipeRequest(userMessage); - case RECOMMENDATION -> createRecommendationRequest(userMessage); - case QUESTION -> createQuestionRequest(userMessage); - case CASUAL_CHAT -> createCasualChatRequest(userMessage); - default -> new GeminiRequestDto(userMessage); - }; - - return callGeminiApi(requestDto); - } - - // 레시피 요청 (구조화된 답변 필요) - private GeminiRequestDto createRecipeRequest(String message) { - GeminiRequestDto.GenerationConfig config = GeminiRequestDto.GenerationConfig.builder() - .temperature(0.3) // 낮은 temperature로 정확성 향상 - .topP(0.8) - .topK(20) - .maxOutputTokens(400) // 레시피는 좀 더 길게 - .candidateCount(1) - .build(); - - return new GeminiRequestDto(message, config); - } - - // 추천 요청 (적당한 창의성) - private GeminiRequestDto createRecommendationRequest(String message) { - GeminiRequestDto.GenerationConfig config = GeminiRequestDto.GenerationConfig.builder() - .temperature(0.9) // 다양한 추천을 위해 높게 - .topP(0.95) - .topK(40) - .maxOutputTokens(250) - .candidateCount(1) - .build(); - - return new GeminiRequestDto(message, config); - } - - // 일반 질문 (균형잡힌 설정) - private GeminiRequestDto createQuestionRequest(String message) { - GeminiRequestDto.GenerationConfig config = GeminiRequestDto.GenerationConfig.builder() - .temperature(0.7) - .topP(0.9) - .topK(30) - .maxOutputTokens(200) - .candidateCount(1) - .build(); - - return new GeminiRequestDto(message, config); - } - - // 캐주얼한 대화 (자연스러움 중시) - private GeminiRequestDto createCasualChatRequest(String message) { - GeminiRequestDto.GenerationConfig config = GeminiRequestDto.GenerationConfig.builder() - .temperature(1.0) - .topP(0.95) - .topK(40) - .maxOutputTokens(150) - .candidateCount(1) - .build(); - - return new GeminiRequestDto(message, config); - } - - // Gemini API 호출 공통 메서드 - private Mono callGeminiApi(GeminiRequestDto requestDto) { - log.debug("Gemini API 호출 - Temperature: {}, MaxTokens: {}", - requestDto.getGenerationConfig() != null ? requestDto.getGenerationConfig().getTemperature() : "default", - requestDto.getGenerationConfig() != null ? requestDto.getGenerationConfig().getMaxOutputTokens() : "default" - ); - - return geminiWebClient.post() - .uri("?key=" + apiKey) - .bodyValue(requestDto) - .retrieve() - .bodyToMono(GeminiResponseDto.class) - .map(GeminiResponseDto::getGeneratedText) - .doOnSuccess(response -> log.debug("응답 길이: {} 글자", response != null ? response.length() : 0)) - .doOnError(error -> log.error("Gemini API 호출 실패: ", error)) - .onErrorReturn("죄송합니다. 현재 응답을 생성할 수 없습니다. 잠시 후 다시 시도해주세요."); - } - - // 유형 ENUM 정의 - public enum MessageType { - RECIPE, // 레시피 요청 - RECOMMENDATION, // 추천 요청 - QUESTION, // 일반 질문 - CASUAL_CHAT, // 캐주얼 대화 - UNKNOWN // 분류 불가 - } - - // 메시지 유형 감지 (간단한 키워드 기반) - public static MessageType detectMessageType(String message) { - String lowerMessage = message.toLowerCase(); - - if (lowerMessage.contains("레시피") || lowerMessage.contains("만드는") || - lowerMessage.contains("제조") || lowerMessage.contains("recipe")) { - return MessageType.RECIPE; - } else if (lowerMessage.contains("추천") || lowerMessage.contains("어때") || - lowerMessage.contains("뭐가 좋") || lowerMessage.contains("recommend")) { - return MessageType.RECOMMENDATION; - } else if (lowerMessage.contains("?") || lowerMessage.contains("뭐") || - lowerMessage.contains("어떻") || lowerMessage.contains("왜")) { - return MessageType.QUESTION; - } else { - return MessageType.CASUAL_CHAT; - } - } -} \ No newline at end of file diff --git a/src/main/java/com/back/domain/cocktail/comment/controller/CocktailCommentController.java b/src/main/java/com/back/domain/cocktail/comment/controller/CocktailCommentController.java new file mode 100644 index 00000000..0f29ac05 --- /dev/null +++ b/src/main/java/com/back/domain/cocktail/comment/controller/CocktailCommentController.java @@ -0,0 +1,107 @@ +package com.back.domain.cocktail.comment.controller; + +import com.back.domain.cocktail.comment.dto.CocktailCommentCreateRequestDto; +import com.back.domain.cocktail.comment.dto.CocktailCommentResponseDto; +import com.back.domain.cocktail.comment.dto.CocktailCommentUpdateRequestDto; +import com.back.domain.cocktail.comment.service.CocktailCommentService; +import com.back.global.rsData.RsData; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +@RestController +@RequestMapping("/api/cocktails/{cocktailId}/comments") +@Tag(name = "ApiCocktailCommentController", description = "API 칵테일댓글 컨트롤러") +@RequiredArgsConstructor +public class CocktailCommentController { + + private final CocktailCommentService cocktailCommentService; + + /** + * 칵테일댓글 작성 API + * + * @param cocktailId 칵테일댓글을 작성할 칵테일 ID + * @param reqBody 칵테일댓글 작성 요청 DTO + * @return 작성된 칵테일댓글 정보 + */ + @PostMapping + @Operation(summary = "칵테일댓글 작성") + public RsData createCocktailComment( + @PathVariable Long cocktailId, + @Valid @RequestBody CocktailCommentCreateRequestDto reqBody + ) { + return RsData.successOf(cocktailCommentService.createCocktailComment(cocktailId, reqBody)); // code=200, message="success" + } + + /** + * 칵테일댓글 다건조회 API + * + * @param cocktailId 칵테일댓글 작성된 게시글 ID + * @param lastId 마지막으로 조회한 칵테일댓글 ID (페이징 처리용, optional) + * @return 칵테일댓글 목록 + */ + @GetMapping + @Operation(summary = "댓글 다건 조회") + public RsData> getCocktailComments( + @PathVariable Long cocktailId, + @RequestParam(required = false) Long lastId + ) { + return RsData.successOf(cocktailCommentService.getCocktailComments(cocktailId, lastId)); // code=200, message="success" + } + + /** + * 칵테일댓글 단건 조회 API + * + * @param cocktailId 칵테일댓글이 작성된 칵테일 ID + * @param cocktailCommentId 조회할 칵테일댓글 ID + * @return 해당 ID의 칵테일댓글 정보 + */ + @GetMapping("/{cocktailCommentId}") + @Operation(summary = "칵테일 댓글 단건 조회") + public RsData getCocktailComment( + @PathVariable Long cocktailId, + @PathVariable Long cocktailCommentId + ) { + return RsData.successOf(cocktailCommentService.getCocktailComment(cocktailId, cocktailCommentId)); // code=200, message="success" + } + + /** + * 칵테일댓글 수정 API + * + * @param cocktailId 칵테일댓글 작성된 칵테일 ID + * @param cocktailCommentId 수정할 칵테일댓글 ID + * @param reqBody 칵테일댓글 수정 요청 DTO + * @return 수정된 칵테일댓글 정보 + */ + @PatchMapping("/{cocktailCommentId}") + @Operation(summary = "칵테일댓글 수정") + public RsData updateComment( + @PathVariable Long cocktailId, + @PathVariable Long cocktailCommentId, + @Valid @RequestBody CocktailCommentUpdateRequestDto reqBody + ) { + return RsData.successOf(cocktailCommentService.updateCocktailComment(cocktailId, cocktailCommentId, reqBody)); // code=200, message="success" + } + + /** + * 칵테일댓글 삭제 API + * + * @param cocktailId 칵테일댓글 작성된 칵테일 ID + * @param cocktailCommentId 삭제할 칵테일댓글 ID + * @return 삭제 성공 메시지 + */ + @DeleteMapping("/{cocktailCommentId}") + @Operation(summary = "댓글 삭제") + public RsData deleteComment( + @PathVariable Long cocktailId, + @PathVariable Long cocktailCommentId + ) { + cocktailCommentService.deleteCocktailComment(cocktailId, cocktailCommentId); + return RsData.successOf(null); // code=200, message="success" + } +} + diff --git a/src/main/java/com/back/domain/cocktail/comment/repository/CocktailCommentRepository.java b/src/main/java/com/back/domain/cocktail/comment/repository/CocktailCommentRepository.java index b439eec3..6f376622 100644 --- a/src/main/java/com/back/domain/cocktail/comment/repository/CocktailCommentRepository.java +++ b/src/main/java/com/back/domain/cocktail/comment/repository/CocktailCommentRepository.java @@ -1,8 +1,15 @@ package com.back.domain.cocktail.comment.repository; -import com.back.domain.post.comment.entity.Comment; +import com.back.domain.cocktail.comment.entity.CocktailComment; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; -public interface CocktailCommentRepository extends JpaRepository { +import java.util.List; +@Repository +public interface CocktailCommentRepository extends JpaRepository { + + List findTop10ByCocktailIdOrderByIdDesc(Long cocktailId); + + List findTop10ByCocktailIdAndIdLessThanOrderByIdDesc(Long cocktailId, Long lastId); } diff --git a/src/main/java/com/back/domain/cocktail/comment/service/CocktailCommentService.java b/src/main/java/com/back/domain/cocktail/comment/service/CocktailCommentService.java new file mode 100644 index 00000000..bf2aaf78 --- /dev/null +++ b/src/main/java/com/back/domain/cocktail/comment/service/CocktailCommentService.java @@ -0,0 +1,112 @@ +package com.back.domain.cocktail.comment.service; + +import com.back.domain.cocktail.comment.dto.CocktailCommentCreateRequestDto; +import com.back.domain.cocktail.comment.dto.CocktailCommentResponseDto; +import com.back.domain.cocktail.comment.dto.CocktailCommentUpdateRequestDto; +import com.back.domain.cocktail.comment.entity.CocktailComment; +import com.back.domain.cocktail.comment.repository.CocktailCommentRepository; +import com.back.domain.cocktail.entity.Cocktail; +import com.back.domain.cocktail.repository.CocktailRepository; +import com.back.domain.post.comment.enums.CommentStatus; +import com.back.domain.user.entity.User; +import com.back.global.rq.Rq; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; + +@Service +@RequiredArgsConstructor +public class CocktailCommentService { + private final CocktailCommentRepository cocktailCommentRepository; + private final CocktailRepository cocktailRepository; + private final Rq rq; + + // 칵테일 댓글 작성 로직 + @Transactional + public CocktailCommentResponseDto createCocktailComment(Long cocktailId, CocktailCommentCreateRequestDto reqBody) { + User user = rq.getActor(); + + Cocktail cocktail = cocktailRepository.findById(cocktailId) + .orElseThrow(() -> new IllegalArgumentException("칵테일이 존재하지 않습니다. id=" + cocktailId)); + + CocktailComment cocktailComment = CocktailComment.builder() + .cocktail(cocktail) + .user(user) + .content(reqBody.content()) + .build(); + + return new CocktailCommentResponseDto(cocktailCommentRepository.save(cocktailComment)); + } + + // 칵테일 댓글 다건 조회 로직 (무한스크롤) + @Transactional(readOnly = true) + public List getCocktailComments(Long cocktailId, Long lastId) { + if (lastId == null) { + return cocktailCommentRepository.findTop10ByCocktailIdOrderByIdDesc(cocktailId) + .stream() + .map(CocktailCommentResponseDto::new) + .toList(); + } else { + return cocktailCommentRepository.findTop10ByCocktailIdAndIdLessThanOrderByIdDesc(cocktailId, lastId) + .stream() + .map(CocktailCommentResponseDto::new) + .toList(); + } + } + + // 칵테일 댓글 단건 조회 로직 + @Transactional(readOnly = true) + public CocktailCommentResponseDto getCocktailComment(Long cocktailId, Long cocktailCommentId) { + CocktailComment cocktailComment = findByIdAndValidateCocktail(cocktailId, cocktailCommentId); + + return new CocktailCommentResponseDto(cocktailComment); + } + + // 칵테일댓글 ID로 찾고, 칵테일과의 관계를 검증 + private CocktailComment findByIdAndValidateCocktail(Long cocktailId, Long cocktailCommentId) { + CocktailComment cocktailComment = cocktailCommentRepository.findById(cocktailCommentId) + .orElseThrow(() -> new IllegalArgumentException("댓글이 존재하지 않습니다. id=" + cocktailCommentId)); + + if (!cocktailComment.getCocktail().getId().equals(cocktailId)) { + throw new IllegalStateException("댓글이 해당 게시글에 속하지 않습니다."); + } + return cocktailComment; + } + + // 칵테일댓글 수정 로직 + @Transactional + public CocktailCommentResponseDto updateCocktailComment(Long cocktailId, Long cocktailCommentId, CocktailCommentUpdateRequestDto requestDto) { + User user = rq.getActor(); + + CocktailComment cocktailComment = findByIdAndValidateCocktail(cocktailId, cocktailCommentId); + + if (!cocktailComment.getUser().getId().equals(user.getId())) { + throw new IllegalStateException("본인의 댓글만 수정할 수 있습니다."); + } + + cocktailComment.updateContent(requestDto.content()); + return new CocktailCommentResponseDto(cocktailComment); + } + + // 칵테일댓글 삭제 로직 + @Transactional + public void deleteCocktailComment(Long cocktailId, Long cocktailCommentId) { + User user = rq.getActor(); + + CocktailComment cocktailComment = findByIdAndValidateCocktail(cocktailId, cocktailCommentId); + + if (!cocktailComment.getUser().getId().equals(user.getId())) { + throw new IllegalStateException("본인의 댓글만 삭제할 수 있습니다."); + } + + cocktailComment.updateStatus(CommentStatus.DELETED); // soft delete 사용. + } +} + + + + + + diff --git a/src/main/java/com/back/domain/cocktail/controller/CocktailController.java b/src/main/java/com/back/domain/cocktail/controller/CocktailController.java index f1e0ba35..80296edc 100644 --- a/src/main/java/com/back/domain/cocktail/controller/CocktailController.java +++ b/src/main/java/com/back/domain/cocktail/controller/CocktailController.java @@ -7,6 +7,7 @@ import com.back.domain.cocktail.service.CocktailService; import com.back.global.rsData.RsData; import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; import lombok.RequiredArgsConstructor; import org.springframework.transaction.annotation.Transactional; import org.springframework.web.bind.annotation.*; @@ -15,6 +16,7 @@ @RestController @RequestMapping("api/cocktails") +@Tag(name = "ApiCocktailController", description = "API 칵테일 컨트롤러") @RequiredArgsConstructor public class CocktailController { diff --git a/src/main/java/com/back/domain/cocktail/entity/Cocktail.java b/src/main/java/com/back/domain/cocktail/entity/Cocktail.java index a2e473ae..00a9e941 100644 --- a/src/main/java/com/back/domain/cocktail/entity/Cocktail.java +++ b/src/main/java/com/back/domain/cocktail/entity/Cocktail.java @@ -23,7 +23,7 @@ public class Cocktail { @Id @GeneratedValue(strategy = IDENTITY) - private long id; + private Long id; private String cocktailName; diff --git a/src/main/java/com/back/domain/mybar/controller/MyBarController.java b/src/main/java/com/back/domain/mybar/controller/MyBarController.java index a0cf3632..45bb5190 100644 --- a/src/main/java/com/back/domain/mybar/controller/MyBarController.java +++ b/src/main/java/com/back/domain/mybar/controller/MyBarController.java @@ -3,6 +3,7 @@ import com.back.domain.mybar.dto.MyBarListResponseDto; import com.back.domain.mybar.service.MyBarService; import com.back.global.rsData.RsData; +import io.swagger.v3.oas.annotations.Operation; import jakarta.validation.constraints.Max; import jakarta.validation.constraints.Min; import lombok.RequiredArgsConstructor; @@ -18,9 +19,23 @@ @Validated public class MyBarController { + /** + * 내 바(킵) API 컨트롤러. + * 내가 킵한 칵테일 목록 조회, 킵 추가/복원, 킵 해제를 제공합니다. + */ + private final MyBarService myBarService; + /** + * 내 바 목록 조회(무한스크롤) + * @param userId 인증된 사용자 ID + * @param lastKeptAt 이전 페이지 마지막 keptAt (옵션) + * @param lastId 이전 페이지 마지막 id (옵션) + * @param limit 페이지 크기(1~100) + * @return 킵 아이템 목록과 다음 페이지 커서 + */ @GetMapping + @Operation(summary = "내 바 목록", description = "내가 킵한 칵테일 목록 조회. 무한스크롤 파라미터 지원") public RsData getMyBarList( @AuthenticationPrincipal(expression = "id") Long userId, @RequestParam(required = false) @@ -32,8 +47,14 @@ public RsData getMyBarList( return RsData.successOf(body); // code=200, message="success" } - /** 킵 추가(생성/복원/재킵) */ + /** + * 킵 추가(생성/복원/재킵) + * @param userId 인증된 사용자 ID + * @param cocktailId 칵테일 ID + * @return 201 kept + */ @PostMapping("/{cocktailId}/keep") + @Operation(summary = "킵 추가/복원", description = "해당 칵테일을 내 바에 킵합니다. 이미 삭제된 경우 복원") public RsData keep( @AuthenticationPrincipal(expression = "id") Long userId, @PathVariable Long cocktailId @@ -42,8 +63,14 @@ public RsData keep( return RsData.of(201, "kept"); // Aspect가 HTTP 201로 설정 } - /** 킵 해제(소프트 삭제) — 멱등 */ + /** + * 킵 해제(소프트 삭제) — 멱등 + * @param userId 인증된 사용자 ID + * @param cocktailId 칵테일 ID + * @return 200 deleted + */ @DeleteMapping("/{cocktailId}/keep") + @Operation(summary = "킵 해제", description = "내 바에서 해당 칵테일 킵을 해제합니다(소프트 삭제, 멱등)") public RsData unkeep( @AuthenticationPrincipal(expression = "id") Long userId, @PathVariable Long cocktailId diff --git a/src/main/java/com/back/domain/mybar/service/MyBarService.java b/src/main/java/com/back/domain/mybar/service/MyBarService.java index 0527112c..2eacf9b5 100644 --- a/src/main/java/com/back/domain/mybar/service/MyBarService.java +++ b/src/main/java/com/back/domain/mybar/service/MyBarService.java @@ -27,7 +27,9 @@ public class MyBarService { private final UserRepository userRepository; private final CocktailRepository cocktailRepository; - // 커서: lastKeptAt + lastId를 그대로 파라미터로 사용 + // 내 바 목록 조회 (무한스크롤) + // - 커서: lastKeptAt + lastId 조합으로 안정적인 정렬/페이지네이션 + // - 첫 페이지: 가장 최근 keptAt 기준으로 최신순 @Transactional(readOnly = true) public MyBarListResponseDto getMyBar(Long userId, LocalDateTime lastKeptAt, Long lastId, int limit) { int safeLimit = Math.max(1, Math.min(limit, 100)); @@ -44,6 +46,7 @@ public MyBarListResponseDto getMyBar(Long userId, LocalDateTime lastKeptAt, Long rows = myBarRepository.findSliceByCursor(userId, KeepStatus.ACTIVE, lastKeptAt, lastId, pageable); } + // +1 로우가 있으면 다음 페이지가 존재 boolean hasNext = rows.size() > safeLimit; if (hasNext) rows = rows.subList(0, safeLimit); @@ -61,6 +64,10 @@ public MyBarListResponseDto getMyBar(Long userId, LocalDateTime lastKeptAt, Long return new MyBarListResponseDto(items, hasNext, nextKeptAt, nextId); } + // 킵 추가/복원 + // - 이미 존재하면 keptAt 갱신 (정렬 최신화) + // - DELETED 상태였다면 ACTIVE로 복원 + // - 없으면 새로 생성 @Transactional public void keep(Long userId, Long cocktailId) { Optional existingMyBar = @@ -90,7 +97,7 @@ public void keep(Long userId, Long cocktailId) { myBarRepository.save(myBar); } - /** 킵 해제(소프트 삭제) */ + /** 킵 해제(소프트 삭제) — 멱등 처리 */ @Transactional public void unkeep(Long userId, Long cocktailId) { myBarRepository.softDeleteByUserAndCocktail(userId, cocktailId); diff --git a/src/main/java/com/back/domain/myhistory/controller/MyHistoryController.java b/src/main/java/com/back/domain/myhistory/controller/MyHistoryController.java index bf925240..819b4a6a 100644 --- a/src/main/java/com/back/domain/myhistory/controller/MyHistoryController.java +++ b/src/main/java/com/back/domain/myhistory/controller/MyHistoryController.java @@ -6,6 +6,7 @@ import com.back.domain.myhistory.dto.MyHistoryLikedPostListDto; import com.back.domain.myhistory.service.MyHistoryService; import com.back.global.rsData.RsData; +import io.swagger.v3.oas.annotations.Operation; import jakarta.validation.constraints.Max; import jakarta.validation.constraints.Min; import lombok.RequiredArgsConstructor; @@ -24,7 +25,16 @@ public class MyHistoryController { private final MyHistoryService myHistoryService; + /** + * 내가 작성한 게시글 목록(무한스크롤) + * @param userId 인증된 사용자 ID + * @param lastCreatedAt 이전 페이지 마지막 createdAt (옵션) + * @param lastId 이전 페이지 마지막 id (옵션) + * @param limit 페이지 크기(1~100) + * @return 게시글 아이템 목록과 다음 페이지 커서 + */ @GetMapping("/posts") + @Operation(summary = "내 게시글 목록", description = "내가 작성한 게시글 최신순 목록. 무한스크롤 파라미터 지원") public RsData getMyPosts( @AuthenticationPrincipal(expression = "id") Long userId, @RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) LocalDateTime lastCreatedAt, @@ -35,7 +45,14 @@ public RsData getMyPosts( return RsData.successOf(body); } + /** + * 내 게시글 이동 링크 + * @param userId 인증된 사용자 ID + * @param postId 게시글 ID + * @return 게시글 상세 이동 링크 정보 + */ @GetMapping("/posts/{id}") + @Operation(summary = "내 게시글로 이동", description = "내가 작성한 게시글 상세 링크 정보 반환") public RsData goFromPost( @AuthenticationPrincipal(expression = "id") Long userId, @PathVariable("id") Long postId @@ -44,7 +61,16 @@ public RsData goFromPo return RsData.successOf(body); } + /** + * 내가 작성한 댓글 목록(무한스크롤) + * @param userId 인증된 사용자 ID + * @param lastCreatedAt 이전 페이지 마지막 createdAt (옵션) + * @param lastId 이전 페이지 마지막 id (옵션) + * @param limit 페이지 크기(1~100) + * @return 댓글 아이템 목록과 다음 페이지 커서 + */ @GetMapping("/comments") + @Operation(summary = "내 댓글 목록", description = "내가 작성한 댓글 최신순 목록. 무한스크롤 파라미터 지원") public RsData getMyComments( @AuthenticationPrincipal(expression = "id") Long userId, @RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) LocalDateTime lastCreatedAt, @@ -55,7 +81,16 @@ public RsData getMyComments( return RsData.successOf(body); } + /** + * 내가 좋아요한 게시글 목록(무한스크롤) + * @param userId 인증된 사용자 ID + * @param lastCreatedAt 이전 페이지 마지막 createdAt (옵션) + * @param lastId 이전 페이지 마지막 id (옵션) + * @param limit 페이지 크기(1~100) + * @return 좋아요 게시글 아이템 목록과 다음 페이지 커서 + */ @GetMapping("/likes") + @Operation(summary = "좋아요한 게시글 목록", description = "좋아요한 게시글 최신순 목록. 무한스크롤 파라미터 지원") public RsData getMyLikedPosts( @AuthenticationPrincipal(expression = "id") Long userId, @RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) LocalDateTime lastCreatedAt, @@ -66,7 +101,14 @@ public RsData getMyLikedPosts( return RsData.successOf(body); } + /** + * 댓글에서 게시글 이동 링크 + * @param userId 인증된 사용자 ID + * @param commentId 댓글 ID + * @return 댓글이 달린 게시글 상세 이동 링크 정보 + */ @GetMapping("/comments/{id}") + @Operation(summary = "댓글에서 게시글 이동", description = "내 댓글이 달린 게시글 상세 링크 정보 반환") public RsData goFromComment( @AuthenticationPrincipal(expression = "id") Long userId, @PathVariable("id") Long commentId @@ -75,7 +117,14 @@ public RsData goFromComment( return RsData.successOf(body); } + /** + * 좋아요 목록에서 게시글 이동 링크 + * @param userId 인증된 사용자 ID + * @param postId 게시글 ID + * @return 좋아요한 게시글 상세 이동 링크 정보 + */ @GetMapping("/likes/{id}") + @Operation(summary = "좋아요 목록에서 이동", description = "좋아요한 게시글 상세 링크 정보 반환") public RsData goFromLikedPost( @AuthenticationPrincipal(expression = "id") Long userId, @PathVariable("id") Long postId diff --git a/src/main/java/com/back/domain/myhistory/service/MyHistoryService.java b/src/main/java/com/back/domain/myhistory/service/MyHistoryService.java index 3f7d9978..0758df28 100644 --- a/src/main/java/com/back/domain/myhistory/service/MyHistoryService.java +++ b/src/main/java/com/back/domain/myhistory/service/MyHistoryService.java @@ -25,6 +25,8 @@ public class MyHistoryService { private final MyHistoryCommentRepository myHistoryCommentRepository; private final MyHistoryLikedPostRepository myHistoryLikedPostRepository; + // 내가 작성한 게시글 목록 (무한스크롤) + // - 삭제(DELETED)된 글은 제외, 최신순(createdAt desc, id desc) @Transactional(readOnly = true) public MyHistoryPostListDto getMyPosts(Long userId, LocalDateTime lastCreatedAt, Long lastId, int limit) { int safeLimit = Math.max(1, Math.min(limit, 100)); @@ -37,6 +39,7 @@ public MyHistoryPostListDto getMyPosts(Long userId, LocalDateTime lastCreatedAt, rows = myHistoryPostRepository.findMyPostsAfter(userId, PostStatus.DELETED, lastCreatedAt, lastId, PageRequest.of(0, fetchSize)); } + // +1개 초과 여부로 다음 페이지 유무 판단 boolean hasNext = rows.size() > safeLimit; if (hasNext) rows = rows.subList(0, safeLimit); @@ -54,6 +57,8 @@ public MyHistoryPostListDto getMyPosts(Long userId, LocalDateTime lastCreatedAt, return new MyHistoryPostListDto(items, hasNext, nextCreatedAt, nextId); } + // 내가 작성한 댓글 목록 (무한스크롤) + // - 댓글과 게시글을 함께 조회(join fetch)하여 N+1 방지 @Transactional(readOnly = true) public MyHistoryCommentListDto getMyComments(Long userId, LocalDateTime lastCreatedAt, Long lastId, int limit) { int safeLimit = Math.max(1, Math.min(limit, 100)); @@ -83,6 +88,9 @@ public MyHistoryCommentListDto getMyComments(Long userId, LocalDateTime lastCrea return new MyHistoryCommentListDto(items, hasNext, nextCreatedAt, nextId); } + // 내 댓글에서 게시글로 이동 링크 생성 + // - 권한 확인: 해당 댓글이 내 댓글인지 검사 + // - 게시글 상태가 삭제면 이동 불가(410) @Transactional(readOnly = true) public MyHistoryCommentGoResponseDto getPostLinkFromMyComment(Long userId, Long commentId) { Comment c = myHistoryCommentRepository.findByIdAndUserId(commentId, userId); @@ -98,6 +106,7 @@ public MyHistoryCommentGoResponseDto getPostLinkFromMyComment(Long userId, Long return new MyHistoryCommentGoResponseDto(postId, apiUrl); } + // 내가 작성한 게시글에서 이동 링크 생성 (권한/상태 검증 포함) @Transactional(readOnly = true) public MyHistoryPostGoResponseDto getPostLinkFromMyPost(Long userId, Long postId) { Post p = myHistoryPostRepository.findByIdAndUserId(postId, userId); @@ -111,6 +120,8 @@ public MyHistoryPostGoResponseDto getPostLinkFromMyPost(Long userId, Long postId return new MyHistoryPostGoResponseDto(p.getId(), apiUrl); } + // 내가 좋아요(추천)한 게시글 목록 (무한스크롤) + // - PostLike.createdAt 기준 최신순, 삭제된 게시글 제외 @Transactional(readOnly = true) public MyHistoryLikedPostListDto getMyLikedPosts(Long userId, LocalDateTime lastCreatedAt, Long lastId, int limit) { int safeLimit = Math.max(1, Math.min(limit, 100)); diff --git a/src/main/java/com/back/domain/notification/controller/NotificationController.java b/src/main/java/com/back/domain/notification/controller/NotificationController.java index a4fc78c5..9b843278 100644 --- a/src/main/java/com/back/domain/notification/controller/NotificationController.java +++ b/src/main/java/com/back/domain/notification/controller/NotificationController.java @@ -10,6 +10,7 @@ import jakarta.validation.Valid; import jakarta.validation.constraints.Max; import jakarta.validation.constraints.Min; +import io.swagger.v3.oas.annotations.Operation; import java.time.LocalDateTime; import lombok.RequiredArgsConstructor; import org.springframework.format.annotation.DateTimeFormat; @@ -31,17 +32,38 @@ @Validated public class NotificationController { + /** + * 알림 API 컨트롤러. + * 알림 목록 조회, 읽음 처리 후 이동 정보, 알림 설정 조회/변경, SSE 구독을 제공합니다. + */ + private final NotificationService notificationService; private final NotificationSettingService notificationSettingService; // SSE 연결 // produces = "text/event-stream": 응답 형식이 SSE임을 명시 + /** + * 알림 SSE 구독 + * + * @return SSE 스트림 핸들러(SseEmitter) + */ @GetMapping(value = "/subscribe", produces = "text/event-stream") + @Operation(summary = "알림 SSE 구독", description = "Server-Sent Events로 실시간 알림 스트림 구독") public SseEmitter subscribe() { return notificationService.subscribe(); } + /** + * 알림 목록 조회(무한스크롤) + * + * @param userId 인증된 사용자 ID + * @param lastCreatedAt 이전 페이지 마지막 createdAt (옵션) + * @param lastId 이전 페이지 마지막 id (옵션) + * @param limit 페이지 크기(1~100) + * @return 알림 아이템 목록과 다음 페이지 커서 + */ @GetMapping("/notifications") + @Operation(summary = "알림 목록 조회", description = "무한스크롤(nextCreatedAt, nextId) 기반 최신순 조회. limit 1~100") public RsData getNotifications( @AuthenticationPrincipal(expression = "id") Long userId, @RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) LocalDateTime lastCreatedAt, @@ -52,7 +74,14 @@ public RsData getNotifications( return RsData.successOf(body); } + /** + * 알림 설정 조회 + * + * @param userId 인증된 사용자 ID + * @return enabled 상태 (없으면 기본 true) + */ @GetMapping("/notification-setting") + @Operation(summary = "알림 설정 조회", description = "사용자 알림 on/off 상태 조회. 미생성 시 기본 true 반환") public RsData getMyNotificationSetting( @AuthenticationPrincipal(expression = "id") Long userId ) { @@ -60,7 +89,15 @@ public RsData getMyNotificationSetting( return RsData.successOf(body); } + /** + * 알림 설정 변경(멱등) + * + * @param userId 인증된 사용자 ID + * @param req enabled true/false + * @return 변경된 enabled 상태 + */ @PatchMapping("/notification-setting") + @Operation(summary = "알림 설정 변경", description = "enabled 값을 true/false로 설정(멱등)") public RsData setMyNotificationSetting( @AuthenticationPrincipal(expression = "id") Long userId, @Valid @RequestBody NotificationSettingUpdateRequestDto req @@ -69,7 +106,15 @@ public RsData setMyNotificationSetting( return RsData.successOf(body); } + /** + * 알림 읽음 처리 후 이동 정보 반환 + * + * @param userId 인증된 사용자 ID + * @param notificationId 알림 ID + * @return 게시글 ID와 게시글 API URL + */ @PostMapping("/notifications/{id}") + @Operation(summary = "읽음 처리 후 이동 정보", description = "알림을 읽음 처리하고 해당 게시글 ID와 API URL 반환") public RsData goPostLink( @AuthenticationPrincipal(expression = "id") Long userId, @PathVariable("id") Long notificationId diff --git a/src/main/java/com/back/domain/notification/service/NotificationService.java b/src/main/java/com/back/domain/notification/service/NotificationService.java index fb806ed3..674c9311 100644 --- a/src/main/java/com/back/domain/notification/service/NotificationService.java +++ b/src/main/java/com/back/domain/notification/service/NotificationService.java @@ -48,7 +48,7 @@ public SseEmitter subscribe() { @Transactional(readOnly = true) public NotificationListResponseDto getNotifications(Long userId, LocalDateTime lastCreatedAt, Long lastId, int limit) { int safeLimit = Math.max(1, Math.min(limit, 100)); - int fetchSize = safeLimit + 1; + int fetchSize = safeLimit + 1; // 다음 페이지가 있는지 판단하기 위해 1건 더 조회 List rows; if (lastCreatedAt == null || lastId == null) { @@ -57,7 +57,7 @@ public NotificationListResponseDto getNotifications(Long userId, LocalDateTime l rows = notificationRepository.findMyNotificationsAfter(userId, lastCreatedAt, lastId, PageRequest.of(0, fetchSize)); } - boolean hasNext = rows.size() > safeLimit; + boolean hasNext = rows.size() > safeLimit; // +1 개가 있으면 다음 페이지 존재 if (hasNext) rows = rows.subList(0, safeLimit); List items = new ArrayList<>(); @@ -74,6 +74,7 @@ public NotificationListResponseDto getNotifications(Long userId, LocalDateTime l return new NotificationListResponseDto(items, hasNext, nextCreatedAt, nextId); } + // 읽음 처리 + 게시글 링크 반환 @Transactional public NotificationGoResponseDto markAsReadAndGetPostLink(Long userId, Long notificationId) { Notification notification = notificationRepository.findByIdAndUserId(notificationId, userId); diff --git a/src/main/java/com/back/domain/notification/service/NotificationSettingService.java b/src/main/java/com/back/domain/notification/service/NotificationSettingService.java index 6128c5a2..d6b03c58 100644 --- a/src/main/java/com/back/domain/notification/service/NotificationSettingService.java +++ b/src/main/java/com/back/domain/notification/service/NotificationSettingService.java @@ -17,6 +17,8 @@ public class NotificationSettingService { private final NotificationSettingRepository notificationSettingRepository; private final UserRepository userRepository; + // 알림 설정 조회 + // - 아직 생성 전이면 기본값(true)로 동작 @Transactional(readOnly = true) public NotificationSettingDto getMySetting(Long userId) { NotificationSetting s = notificationSettingRepository.findByUserId(userId); @@ -27,6 +29,8 @@ public NotificationSettingDto getMySetting(Long userId) { return NotificationSettingDto.from(s); } + // 알림 설정 저장(멱등) + // - 없으면 생성, 있으면 enabled 값만 갱신 @Transactional public NotificationSettingDto setMySetting(Long userId, boolean enabled) { NotificationSetting s = notificationSettingRepository.findByUserId(userId); diff --git a/src/main/java/com/back/domain/profile/controller/ProfileController.java b/src/main/java/com/back/domain/profile/controller/ProfileController.java index 686bfbbb..7e23676e 100644 --- a/src/main/java/com/back/domain/profile/controller/ProfileController.java +++ b/src/main/java/com/back/domain/profile/controller/ProfileController.java @@ -6,6 +6,7 @@ import com.back.domain.user.service.UserService; import com.back.global.rsData.RsData; import jakarta.validation.Valid; +import io.swagger.v3.oas.annotations.Operation; import lombok.RequiredArgsConstructor; import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.web.bind.annotation.*; @@ -15,10 +16,23 @@ @RequiredArgsConstructor public class ProfileController { + /** + * 내 프로필 요약 API 컨트롤러. + * 닉네임, 알콜도수(등급/라벨), 내가 작성/댓글/좋아요한 수 등을 조회·수정합니다. + */ + private final UserService userService; private final ProfileService profileService; + /** + * 내 프로필 요약 조회 + * + * @param userId 인증된 사용자 ID (SecurityContext) + * @return RsData wrapping ProfileResponseDto + */ @GetMapping + @Operation(summary = "내 프로필 요약 조회", + description = "닉네임, 알콜도수(등급/라벨), 작성/댓글/좋아요 카운트를 반환") public RsData getProfile(@AuthenticationPrincipal(expression = "id") Long userId) { ProfileResponseDto body = profileService.getProfile(userId); return RsData.successOf(body); // code=200, message="success" @@ -26,7 +40,15 @@ public RsData getProfile(@AuthenticationPrincipal(expression // PUT 제거: PATCH 전용으로 운영 + /** + * 프로필 수정(닉네임) + * + * @param userId 인증된 사용자 ID (SecurityContext) + * @param request 닉네임(1~10자) + * @return 수정된 프로필 요약 + */ @PatchMapping + @Operation(summary = "프로필 수정(닉네임)", description = "닉네임은 1~10자, 중복 불가") public RsData patchNickname( @AuthenticationPrincipal(expression = "id") Long userId, @Valid @RequestBody ProfileUpdateRequestDto request diff --git a/src/main/java/com/back/domain/profile/dto/ProfileResponseDto.java b/src/main/java/com/back/domain/profile/dto/ProfileResponseDto.java index f42277f1..998f38f2 100644 --- a/src/main/java/com/back/domain/profile/dto/ProfileResponseDto.java +++ b/src/main/java/com/back/domain/profile/dto/ProfileResponseDto.java @@ -1,5 +1,8 @@ package com.back.domain.profile.dto; +import com.back.domain.user.entity.User; +import com.back.domain.user.enums.AbvLevel; +import com.back.domain.user.support.AbvView; import lombok.Builder; import lombok.Getter; @@ -15,4 +18,33 @@ public class ProfileResponseDto { // 표현용(서버에서 계산) private Integer abvLevel; // 1~6 private String abvLabel; // "83.2%" + + // 요약 카운트 + private Long myPostCount; + private Long myCommentCount; + private Long myLikedPostCount; + + public static ProfileResponseDto of(User user, + long myPostCount, + long myCommentCount, + long myLikedPostCount) { + // 신규 사용자는 기본 5%로 시작하도록 뷰 레벨에서 기본값 적용 + Double percent = user.getAbvDegree(); + if (percent == null) percent = 5.0; + int percentInt = Math.max(0, Math.min(100, percent.intValue())); + int level = AbvLevel.of(percentInt).code; + String label = AbvView.percentLabel(percent); + + return ProfileResponseDto.builder() + .id(user.getId()) + .nickname(user.getNickname()) + .email(user.getEmail()) + .abvDegree(percent) + .abvLevel(level) + .abvLabel(label) + .myPostCount(myPostCount) + .myCommentCount(myCommentCount) + .myLikedPostCount(myLikedPostCount) + .build(); + } } diff --git a/src/main/java/com/back/domain/profile/repository/ProfileQueryRepository.java b/src/main/java/com/back/domain/profile/repository/ProfileQueryRepository.java new file mode 100644 index 00000000..9e063c27 --- /dev/null +++ b/src/main/java/com/back/domain/profile/repository/ProfileQueryRepository.java @@ -0,0 +1,36 @@ +package com.back.domain.profile.repository; + +import com.back.domain.post.comment.enums.CommentStatus; +import com.back.domain.post.post.enums.PostLikeStatus; +import com.back.domain.post.post.enums.PostStatus; +import com.back.domain.user.entity.User; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import org.springframework.stereotype.Repository; + +@Repository +public interface ProfileQueryRepository extends JpaRepository { + + @Query(""" + select count(p) from Post p + where p.user.id = :userId + and p.status <> :deleted + """) + long countMyPosts(@Param("userId") Long userId, @Param("deleted") PostStatus deleted); + + @Query(""" + select count(c) from Comment c + where c.user.id = :userId + and c.status <> :deleted + """) + long countMyComments(@Param("userId") Long userId, @Param("deleted") CommentStatus deleted); + + @Query(""" + select count(pl) from PostLike pl + where pl.user.id = :userId + and pl.status = :like + """) + long countMyLikedPosts(@Param("userId") Long userId, @Param("like") PostLikeStatus like); +} + diff --git a/src/main/java/com/back/domain/profile/service/ProfileService.java b/src/main/java/com/back/domain/profile/service/ProfileService.java index 5e307c4f..314f9f7d 100644 --- a/src/main/java/com/back/domain/profile/service/ProfileService.java +++ b/src/main/java/com/back/domain/profile/service/ProfileService.java @@ -4,7 +4,11 @@ import com.back.domain.profile.dto.ProfileUpdateRequestDto; import com.back.domain.user.entity.User; import com.back.domain.user.repository.UserRepository; -import com.back.domain.user.support.AbvView; +// moved level/label computation into DTO factory +import com.back.domain.post.post.enums.PostStatus; +import com.back.domain.post.comment.enums.CommentStatus; +import com.back.domain.post.post.enums.PostLikeStatus; +import com.back.domain.profile.repository.ProfileQueryRepository; import com.back.global.exception.ServiceException; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; @@ -15,24 +19,29 @@ public class ProfileService { private final UserRepository userRepository; + private final ProfileQueryRepository profileQueryRepository; + // 내 프로필 요약 조회 + // - 카운트 3종(내 글/내 댓글/내가 좋아요한 글) 조회 후 + // - DTO 정적 팩토리(of)로 레벨/라벨 계산과 함께 응답 조립 @Transactional(readOnly = true) public ProfileResponseDto getProfile(Long id) { User user = userRepository.findById(id).orElseThrow(() -> new ServiceException(404, "사용자를 찾을 수 없습니다.")); - Double percent = user.getAbvDegree(); - int level = AbvView.levelOf(percent); - String label = AbvView.percentLabel(percent); - - return ProfileResponseDto.builder() - .id(user.getId()) - .nickname(user.getNickname()) - .abvDegree(percent) - .abvLevel(level) - .abvLabel(label) - .build(); + long postCount = profileQueryRepository.countMyPosts(user.getId(), PostStatus.DELETED); + long commentCount = profileQueryRepository.countMyComments(user.getId(), CommentStatus.DELETED); + long likedPostCount = profileQueryRepository.countMyLikedPosts(user.getId(), PostLikeStatus.LIKE); + + return ProfileResponseDto.of( + user, + postCount, + commentCount, + likedPostCount + ); } + // 프로필 수정 (닉네임) + // - 길이/중복 검사 후 반영, 이후 최신 프로필 다시 조회 @Transactional public ProfileResponseDto updateProfile(Long id, ProfileUpdateRequestDto profileUpdateRequestDto) { User user = userRepository.findById(id).orElseThrow(() -> new ServiceException(404, "사용자를 찾을 수 없습니다.")); diff --git a/src/main/java/com/back/domain/user/controller/UserAuthController.java b/src/main/java/com/back/domain/user/controller/UserAuthController.java index 6b587e7f..cf3e00cd 100644 --- a/src/main/java/com/back/domain/user/controller/UserAuthController.java +++ b/src/main/java/com/back/domain/user/controller/UserAuthController.java @@ -1,5 +1,6 @@ package com.back.domain.user.controller; +import com.back.domain.user.dto.RefreshTokenResDto; import com.back.domain.user.service.UserAuthService; import com.back.global.rsData.RsData; import io.swagger.v3.oas.annotations.Operation; @@ -17,7 +18,7 @@ @Tag(name = "UserAuth", description = "사용자 인증 API") @Slf4j @RestController -@RequestMapping("/api/user/auth") +@RequestMapping("/user/auth") @RequiredArgsConstructor public class UserAuthController { @@ -32,11 +33,11 @@ public class UserAuthController { @ApiResponse(responseCode = "401", description = "토큰이 유효하지 않거나 만료됨") }) @PostMapping("/refresh") - public RsData refreshToken(HttpServletRequest request, HttpServletResponse response) { - boolean success = userAuthService.refreshTokens(request, response); + public RsData refreshToken(HttpServletRequest request, HttpServletResponse response) { + RefreshTokenResDto refreshToken = userAuthService.refreshTokens(request, response); - if (success) { - return RsData.of(200, "토큰이 성공적으로 갱신되었습니다."); + if (refreshToken != null) { + return RsData.of(200, "토큰이 갱신 성공.", refreshToken); } else { return RsData.of(401, "토큰 갱신에 실패했습니다. 다시 로그인해주세요."); } diff --git a/src/main/java/com/back/domain/user/dto/RefreshTokenResDto.java b/src/main/java/com/back/domain/user/dto/RefreshTokenResDto.java new file mode 100644 index 00000000..3e705940 --- /dev/null +++ b/src/main/java/com/back/domain/user/dto/RefreshTokenResDto.java @@ -0,0 +1,21 @@ +package com.back.domain.user.dto; + +import lombok.Builder; +import lombok.Getter; + +@Getter +@Builder +public class RefreshTokenResDto { + private final String accessToken; + private final UserInfoDto user; + + @Getter + @Builder + public static class UserInfoDto { + private final String id; + private final String nickname; + private final Boolean isFirstLogin; + private final Double abvDegree; + + } +} diff --git a/src/main/java/com/back/domain/user/dto/UserDto.java b/src/main/java/com/back/domain/user/dto/UserDto.java index 00f870aa..5c293ce2 100644 --- a/src/main/java/com/back/domain/user/dto/UserDto.java +++ b/src/main/java/com/back/domain/user/dto/UserDto.java @@ -17,7 +17,6 @@ public class UserDto { private Long id; private String email; private String nickname; - private String profileImgUrl; private Double abvDegree; private LocalDateTime createdAt; private LocalDateTime updatedAt; @@ -29,7 +28,6 @@ public static UserDto from(User user) { .id(user.getId()) .email(user.getEmail()) .nickname(user.getNickname()) -// .profileImgUrl(user.getProfileImgUrl()) .abvDegree(user.getAbvDegree()) .createdAt(user.getCreatedAt()) .updatedAt(user.getUpdatedAt()) diff --git a/src/main/java/com/back/domain/user/enums/AbvLevel.java b/src/main/java/com/back/domain/user/enums/AbvLevel.java index f9289607..1e5066de 100644 --- a/src/main/java/com/back/domain/user/enums/AbvLevel.java +++ b/src/main/java/com/back/domain/user/enums/AbvLevel.java @@ -1,22 +1,20 @@ package com.back.domain.user.enums; public enum AbvLevel { - L1(1, 5, 10, "/img/grade/1.png"), - L2(2, 11, 25, "/img/grade/2.png"), - L3(3, 26, 45, "/img/grade/3.png"), - L4(4, 46, 65, "/img/grade/4.png"), - L5(5, 66, 85, "/img/grade/5.png"), - L6(6, 86, 100, "/img/grade/6.png"); + L1(1, 5, 10), + L2(2, 11, 25), + L3(3, 26, 45), + L4(4, 46, 65), + L5(5, 66, 85), + L6(6, 86, 100); public final int code; public final int min, max; - public final String imagePath; - AbvLevel(int code, int min, int max, String imagePath) { + AbvLevel(int code, int min, int max) { this.code = code; this.min = min; this.max = max; - this.imagePath = imagePath; } /** @@ -29,4 +27,4 @@ public static AbvLevel of(int percent) { } return L1; // 5% 미만도 기본적으로 L1 처리 } -} \ No newline at end of file +} diff --git a/src/main/java/com/back/domain/user/service/UserAuthService.java b/src/main/java/com/back/domain/user/service/UserAuthService.java index eeb28888..72b5bc72 100644 --- a/src/main/java/com/back/domain/user/service/UserAuthService.java +++ b/src/main/java/com/back/domain/user/service/UserAuthService.java @@ -1,5 +1,6 @@ package com.back.domain.user.service; +import com.back.domain.user.dto.RefreshTokenResDto; import com.back.domain.user.entity.User; import com.back.domain.user.repository.UserRepository; import com.back.global.exception.ServiceException; @@ -24,25 +25,25 @@ @RequiredArgsConstructor public class UserAuthService { - static Set param1 = Set.of("두둑한", "날씬한", "만취한", "알딸딸한", "얼큰한", "시트러스한", "도수높은", "톡쏘는", "거품가득한", "하이볼한", - "앙증맞은", "쓸쓸한", "거만한", "산만한", "귀찮은", "삐딱한", "맛이간", "저세상급", "시궁창스러운", "기묘한", - "졸린", "센치한", "철학적인", "무중력의", "뽀송한", "전투적인", "배부른", "대충한", "쩌는", "철지난", - "절규하는", "맞춤형", "다급한", "찌뿌둥한", "구수한", "문어발적인", "자포자기한", "터무니없는", "귀여운척하는", - "심드렁한", "무심한", "번쩍이는", "붉그레한", "밤새는", "좌절한", "의기양양한", "비굴한", "터프한", "흘러내린", - "공허한", "허무한", "헛기침하는", "뿜어대는", "질척한", "기어다니는", "헤매는", "삐죽한", "악에받친", "격렬한", - "삐까번쩍한", "오지랖넓은", "쪼르르거리는", "꿀꺽거리는", "머쓱한", "휘청대는", "추접스러운", "천방지축인", "어리둥절한", "질주하는", - "겸연쩍은", "뿌연", "썩은", "짠내나는", "철썩같은", "흥건한", "안간힘쓰는", "뜨끈한", "꾸덕한", "동공지진난", - "덕지덕지한", "비밀스러운", "개운한", "심란한", "음울한", "터질듯한", "달달한", "사악한", "기괴한", "용맹한", - "껄끄러운", "헐떡이는", "허둥대는", "분란스러운", "애매한", "찐득한", "허기진", "쩔어버린", "몽롱한", "허세떠는", - "황당한", "거대하고작은", "대차게구린", "어이없는", "두통약", "지갑", "이쑤시개", "돌침대", "고무장갑", "손수건", - "바람개비", "지하철표", "송진가루", "철가방", "머리끈", "양말한짝", "라이터", "숟가락", "스티커", "드럼통", - "열쇠꾸러미", "벼락", "대걸레", "파리채", "앙금빵", "선풍기날개", "스티로폼", "건전지", "껌종이", "소화전", - "비닐우산", "고드름", "전등갓", "양초", "지우개가루", "국자", "밥솥", "연필심", "비둘기깃털", "찜질팩", - "청테이프", "김밥말이", "곰팡이", "청소기", "밤송이", "옥수수수염", "철창살", "휴지심", "선반", "곽티슈", - "스프링노트", "고향집된장", "머드팩", "장독대", "뒤꿈치각질", "어묵꼬치", "환풍기", "군고구마", "카세트테이프", - "빨래건조대", "박카스병", "우체통", "주차권", "털실뭉치", "지하수", "깃털베개", "추리닝", "이불각", "육포", - "빨대", "지렁이분양소", "김칫국", "오징어채", "전기장판", "꽃병", "도시락통", "구급상자", "양배추잎", "고무줄", - "망치", "유통기한", "알람시계", "방범창", "신발깔창"); + static Set param1 = Set.of("두둑한", "날씬한", "만취한", "알딸딸", "얼큰한", "시트러스", "도수높은", "톡쏘는", "거품가득", "하이볼한", + "앙증맞은", "쓸쓸한", "거만한", "산만한", "귀찮은", "삐딱한", "맛이간", "저세상급", "시궁창", "기묘한", + "졸린", "센치한", "철학적인", "무중력", "뽀송한", "전투적인", "배부른", "대충한", "쩌는", "철지난", + "절규하는", "맞춤형", "다급한", "찌뿌둥한", "구수한", "문어발", "자포자기", "터무니", "귀척", "심드렁한", + "무심한", "번쩍이는", "붉그레한", "밤새는", "좌절한", "의기양양", "비굴한", "터프한", "흘러내린", "공허한", + "허무한", "헛기침", "뿜어대는", "질척한", "기어다님", "헤매는", "삐죽한", "악에받친", "격렬한", "삐까번쩍", + "오지랖", "쪼르르", "꿀꺽", "머쓱한", "휘청대는", "추접", "천방지축", "어리둥절", "질주하는", "겸연쩍은", + "뿌연", "썩은", "짠내나는", "철썩", "흥건한", "안간힘", "뜨끈한", "꾸덕한", "동공지진", "덕지덕지", + "비밀", "개운한", "심란한", "음울한", "터질듯한", "달달한", "사악한", "기괴한", "용맹한", "껄끄러운", + "헐떡이는", "허둥대는", "분란", "애매한", "찐득한", "허기진", "쩔어버린", "몽롱한", "허세", "황당한", + "거대작음", "대차게구림", "어이없음", "두통약", "지갑", "이쑤시개", "돌침대", "고무장갑", "손수건", "바람개비", + "지하철표", "송진가루", "철가방", "머리끈", "양말한짝", "라이터", "숟가락", "스티커", "드럼통", "열쇠", + "벼락", "대걸레", "파리채", "앙금빵", "날개", "스티로폼", "건전지", "껌종이", "소화전", "비닐우산", + "고드름", "전등갓", "양초", "지우개", "국자", "밥솥", "연필심", "깃털", "찜질팩", "청테이프", + "김밥말이", "곰팡이", "청소기", "밤송이", "옥수수", "철창살", "휴지심", "선반", "곽티슈", "스프링", + "고향된장", "머드팩", "장독대", "각질", "어묵꼬치", "환풍기", "군고구마", "카세트", "건조대", "박카스병", + "우체통", "주차권", "털실뭉치", "지하수", "추리닝", "이불각", "육포", "빨대", "지렁이", "김칫국", + "오징어채", "전기장판", "꽃병", "도시락통", "구급상자", "양배추잎", "고무줄", "망치", "유통기한", "알람시계", + "방범창", "깔창", "만취육포", "날씬국자", "터프각질", "음울밥솥", "사악김치", "허세숟갈", "삐딱곰팡"); static Set param2 = Set.of("도토리딱개구리", "아프리카들개", "강남성인군자", "술고래", "알코올러버", "겨자잎", "청개구리", "산수유", "맥주문어", "칵테일앵무새", "보드카수달", "진토닉거북이", "테킬라코요테", "럼펭귄", "사케고양이", "막걸리두꺼비", @@ -134,46 +135,56 @@ public String generateNickname(String baseNickname) { public void issueTokens(HttpServletResponse response, Long userId, String email, String nickname) { String accessToken = jwtUtil.generateAccessToken(userId, email, nickname); - String refreshToken = refreshTokenService.generateRefreshToken(userId, email); + String refreshToken = refreshTokenService.generateRefreshToken(userId); jwtUtil.addAccessTokenToCookie(response, accessToken); jwtUtil.addRefreshTokenToCookie(response, refreshToken); } - public boolean refreshTokens(HttpServletRequest request, HttpServletResponse response) { + public RefreshTokenResDto refreshTokens(HttpServletRequest request, HttpServletResponse response) { try { String oldRefreshToken = jwtUtil.getRefreshTokenFromCookie(request); if (oldRefreshToken == null || !refreshTokenService.validateToken(oldRefreshToken)) { - return false; + return null; } Optional tokenData = refreshTokenRepository.findByToken(oldRefreshToken); if (tokenData.isEmpty()) { - return false; + return null; } RefreshToken refreshTokenEntity = tokenData.get(); Long userId = refreshTokenEntity.getUserId(); - String email = refreshTokenEntity.getEmail(); - // DB에서 현재 nickname 조회 - Optional user = userRepository.findById(userId); - if (user.isEmpty()) { - return false; + // DB에서 사용자 정보 조회 + Optional userOpt = userRepository.findById(userId); + if (userOpt.isEmpty()) { + return null; } - String nickname = user.get().getNickname(); + + User user = userOpt.get(); String newRefreshToken = refreshTokenService.rotateToken(oldRefreshToken); - String newAccessToken = jwtUtil.generateAccessToken(userId, email, nickname); + String newAccessToken = jwtUtil.generateAccessToken(userId, user.getEmail(), user.getNickname()); jwtUtil.addAccessTokenToCookie(response, newAccessToken); jwtUtil.addRefreshTokenToCookie(response, newRefreshToken); - return true; + return RefreshTokenResDto.builder() + .accessToken(newAccessToken) + .user( + RefreshTokenResDto.UserInfoDto.builder() + .id(user.getId().toString()) + .nickname(user.getNickname()) + .isFirstLogin(user.isFirstLogin()) + .abvDegree(user.getAbvDegree()) + .build() + ) + .build(); } catch (Exception e) { log.error("토큰 갱신 중 오류 발생: {}", e.getMessage()); - return false; + return null; } } diff --git a/src/main/java/com/back/global/appConfig/GeminiConfig.java b/src/main/java/com/back/global/appConfig/GeminiConfig.java deleted file mode 100644 index 9695ed7b..00000000 --- a/src/main/java/com/back/global/appConfig/GeminiConfig.java +++ /dev/null @@ -1,24 +0,0 @@ -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/controller/HomeController.java b/src/main/java/com/back/global/controller/HomeController.java index 87d1dbf0..e7a2f936 100644 --- a/src/main/java/com/back/global/controller/HomeController.java +++ b/src/main/java/com/back/global/controller/HomeController.java @@ -1,7 +1,6 @@ package com.back.global.controller; import org.springframework.beans.factory.annotation.Value; -import org.springframework.context.annotation.Profile; import org.springframework.stereotype.Controller; import org.springframework.web.bind.annotation.GetMapping; @@ -11,15 +10,14 @@ public class HomeController { @Value("${custom.site.frontUrl}") private String frontUrl; - @GetMapping("/") - @Profile("dev") - public String redirectToSwagger() { - return "redirect:/swagger-ui/index.html"; - } + @Value("${spring.profiles.active}") + private String activeProfile; @GetMapping("/") - @Profile("prod") - public String redirectToFrontend() { - return "redirect:" + frontUrl; + public String redirect() { + if("prod".equals(activeProfile)){ + return "redirect:" + frontUrl; + } + return "redirect:/swagger-ui/index.html"; } } \ No newline at end of file diff --git a/src/main/java/com/back/global/jwt/JwtUtil.java b/src/main/java/com/back/global/jwt/JwtUtil.java index e49108fb..36c19201 100644 --- a/src/main/java/com/back/global/jwt/JwtUtil.java +++ b/src/main/java/com/back/global/jwt/JwtUtil.java @@ -42,6 +42,7 @@ public String generateAccessToken(Long userId, String email, String nickname) { .compact(); } + public void addAccessTokenToCookie(HttpServletResponse response, String accessToken) { Cookie cookie = new Cookie(ACCESS_TOKEN_COOKIE_NAME, accessToken); cookie.setHttpOnly(true); diff --git a/src/main/java/com/back/global/jwt/refreshToken/entity/RefreshToken.java b/src/main/java/com/back/global/jwt/refreshToken/entity/RefreshToken.java index a1f450c1..b139a66e 100644 --- a/src/main/java/com/back/global/jwt/refreshToken/entity/RefreshToken.java +++ b/src/main/java/com/back/global/jwt/refreshToken/entity/RefreshToken.java @@ -20,21 +20,18 @@ public class RefreshToken { @Column(nullable = false) private Long userId; - @Column(nullable = false) - private String email; - @Column(nullable = false) private LocalDateTime createdAt; @Column(nullable = false) private LocalDateTime expiresAt; - public static RefreshToken create(String token, Long userId, String email, long ttlSeconds) { + + public static RefreshToken create(String token, Long userId, long ttlSeconds) { LocalDateTime now = LocalDateTime.now(); return RefreshToken.builder() .token(token) .userId(userId) - .email(email) .createdAt(now) .expiresAt(now.plusSeconds(ttlSeconds)) .build(); diff --git a/src/main/java/com/back/global/jwt/refreshToken/service/RefreshTokenService.java b/src/main/java/com/back/global/jwt/refreshToken/service/RefreshTokenService.java index fa6db9cb..c7972977 100644 --- a/src/main/java/com/back/global/jwt/refreshToken/service/RefreshTokenService.java +++ b/src/main/java/com/back/global/jwt/refreshToken/service/RefreshTokenService.java @@ -5,14 +5,14 @@ import com.back.global.jwt.refreshToken.repository.RefreshTokenRepository; import lombok.RequiredArgsConstructor; import org.springframework.beans.factory.annotation.Value; +import org.springframework.scheduling.annotation.Scheduled; import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; import java.security.SecureRandom; import java.time.LocalDateTime; import java.util.Base64; import java.util.Optional; -import org.springframework.scheduling.annotation.Scheduled; -import org.springframework.transaction.annotation.Transactional; @Service @RequiredArgsConstructor @@ -26,12 +26,12 @@ public class RefreshTokenService { // 기존 리프레시 토큰 삭제하고 생성 @Transactional - public String generateRefreshToken(Long userId, String email) { + public String generateRefreshToken(Long userId) { // 기존 토큰 삭제 refreshTokenRepository.deleteByUserId(userId); String token = generateSecureToken(); - RefreshToken refreshToken = RefreshToken.create(token, userId, email, refreshTokenExpiration); + RefreshToken refreshToken = RefreshToken.create(token, userId, refreshTokenExpiration); refreshTokenRepository.save(refreshToken); return token; @@ -65,7 +65,7 @@ public String rotateToken(String oldToken) { RefreshToken tokenData = oldRefreshToken.get(); revokeToken(oldToken); - return generateRefreshToken(tokenData.getUserId(), tokenData.getEmail()); + return generateRefreshToken(tokenData.getUserId()); } //삭제 diff --git a/src/main/java/com/back/global/security/CustomOAuth2LoginSuccessHandler.java b/src/main/java/com/back/global/security/CustomOAuth2LoginSuccessHandler.java index e5f6a433..f7ea019b 100644 --- a/src/main/java/com/back/global/security/CustomOAuth2LoginSuccessHandler.java +++ b/src/main/java/com/back/global/security/CustomOAuth2LoginSuccessHandler.java @@ -24,19 +24,15 @@ public class CustomOAuth2LoginSuccessHandler implements AuthenticationSuccessHan @Override public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException, ServletException { SecurityUser securityUser = (SecurityUser) authentication.getPrincipal(); + // Access Token과 Refresh Token 발급 userAuthService.issueTokens(response, securityUser.getId(), securityUser.getEmail(), securityUser.getNickname()); - // 첫 로그인 여부에 따라 리다이렉트 분기 - String redirectUrl; - if (securityUser.isFirstLogin()) { - redirectUrl = frontendUrl + "/oauth/success/welcome"; userAuthService.setFirstLoginFalse(securityUser.getId()); + response.sendRedirect(frontendUrl + "/login/first-user"); } else { - redirectUrl = frontendUrl + "/oauth/success"; + response.sendRedirect(frontendUrl + "/login/success"); } - - response.sendRedirect(redirectUrl); } } \ No newline at end of file diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 67d50086..10a0200d 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -48,6 +48,17 @@ spring: user-info-uri: https://openapi.naver.com/v1/nid/me user-name-attribute: response + ai: + openai: + api-key: ${GEMINI_API_KEY} + chat: + base-url: "https://generativelanguage.googleapis.com/v1beta/openai/" + options: + model: "gemini-2.0-flash-exp" + temperature: 0.8 + max-tokens: 800 + completions-path: "/chat/completions" + springdoc: default-produces-media-type: application/json;charset=UTF-8 @@ -60,6 +71,7 @@ server: enabled: true force: true +# 추후 삭제 예정 gemini: api: key: ${GEMINI_API_KEY} @@ -104,4 +116,7 @@ management: chatbot: history: - max-conversation-count: 5 \ No newline at end of file + max-conversation-count: 5 + system: + prompt-file: classpath:prompts/chatbot-system-prompt.txt + rules-file: classpath:prompts/chatbot-response-rules.txt \ No newline at end of file