From 15fe8d75f6e74810a52da4c6e0eb4e7e0e56da6b Mon Sep 17 00:00:00 2001 From: kghc4232 Date: Thu, 20 Nov 2025 10:56:38 +0900 Subject: [PATCH 01/11] =?UTF-8?q?25.11.20=20develop=20=EB=B8=8C=EB=9E=9C?= =?UTF-8?q?=EC=B9=98=20pull=20=ED=9B=84=20feature/gemini=20=EB=B8=8C?= =?UTF-8?q?=EB=9E=9C=EC=B9=98=20=EB=B6=84=EA=B8=B0=ED=95=98=EC=97=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 기본적인 호출 로직 작성 후 첫 commit --- .gitignore | 1 + build.gradle | 2 + .../algorithmchef/config/GeminiConfig.java | 21 ++ .../controller/GeminiRecipeController.java | 50 +++++ .../dto/gemini/GeminiRecipeRequest.java | 26 +++ .../dto/gemini/GeminiRecipeResponse.java | 15 ++ .../service/GeminiRecipeService.java | 205 ++++++++++++++++++ 7 files changed, 320 insertions(+) create mode 100644 src/main/java/com/webservice/algorithmchef/config/GeminiConfig.java create mode 100644 src/main/java/com/webservice/algorithmchef/controller/GeminiRecipeController.java create mode 100644 src/main/java/com/webservice/algorithmchef/dto/gemini/GeminiRecipeRequest.java create mode 100644 src/main/java/com/webservice/algorithmchef/dto/gemini/GeminiRecipeResponse.java create mode 100644 src/main/java/com/webservice/algorithmchef/service/GeminiRecipeService.java diff --git a/.gitignore b/.gitignore index 14f8293..ef96aa4 100644 --- a/.gitignore +++ b/.gitignore @@ -36,4 +36,5 @@ out/ ### VS Code ### .vscode/ +### api key ### src/main/resources/application.properties \ No newline at end of file diff --git a/build.gradle b/build.gradle index 6b88ec9..d001a99 100644 --- a/build.gradle +++ b/build.gradle @@ -40,6 +40,8 @@ dependencies { testImplementation 'org.springframework.boot:spring-boot-starter-test' testImplementation 'org.springframework.security:spring-security-test' testRuntimeOnly 'org.junit.platform:junit-platform-launcher' + implementation 'com.google.genai:google-genai:1.0.0' // gemini api + implementation 'org.springframework.boot:spring-boot-starter-webflux' // gemini 검색기능 } tasks.named('test') { diff --git a/src/main/java/com/webservice/algorithmchef/config/GeminiConfig.java b/src/main/java/com/webservice/algorithmchef/config/GeminiConfig.java new file mode 100644 index 0000000..76fe084 --- /dev/null +++ b/src/main/java/com/webservice/algorithmchef/config/GeminiConfig.java @@ -0,0 +1,21 @@ +package com.webservice.algorithmchef.config; + +import com.google.genai.Client; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration +public class GeminiConfig { + + @Value("${gemini.api.key}") + private String apiKey; + + @Bean + public Client geminiClient() { + // Google Gen AI SDK V1 Client + return Client.builder() + .apiKey(apiKey) + .build(); + } +} diff --git a/src/main/java/com/webservice/algorithmchef/controller/GeminiRecipeController.java b/src/main/java/com/webservice/algorithmchef/controller/GeminiRecipeController.java new file mode 100644 index 0000000..a5034c8 --- /dev/null +++ b/src/main/java/com/webservice/algorithmchef/controller/GeminiRecipeController.java @@ -0,0 +1,50 @@ +package com.webservice.algorithmchef.controller; + +import com.webservice.algorithmchef.dto.gemini.GeminiRecipeRequest.*; +import com.webservice.algorithmchef.dto.gemini.GeminiRecipeResponse; +import com.webservice.algorithmchef.service.GeminiRecipeService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import java.util.List; + +@Slf4j +@RestController +@RequestMapping("/recipes/recommend") +@RequiredArgsConstructor +public class GeminiRecipeController { + + private final GeminiRecipeService recipeService; + + // 1. 유저 성향 및 식습관 기반 추천 (요청주소 /recipes/recommend/prefer) + @PostMapping("/prefer") + public ResponseEntity> recommendPrefer(@RequestBody PreferRequest request) { + log.info("성향 추천 요청 - UserPkId: {}", request.userPkId()); + return ResponseEntity.ok( + recipeService.recommendPrefer(request.userPkId(), request.excludedTitles()) + ); + } + + // 2. speech to text 조건 기반 추천 (요청주소 /recipes/recommend/condition) + @PostMapping("/condition") + public ResponseEntity> recommendCondition(@RequestBody ConditionRequest request) { + log.info("조건 추천 요청 - UserPkId: {}, Condition: {}", request.userPkId(), request.condition()); + return ResponseEntity.ok( + recipeService.recommendCondition(request.userPkId(), request.condition(), request.excludedTitles()) + ); + } + + // 3. 임박재료 기반 추천 (요청주소 /recipes/recommend/expir) + @PostMapping("/expir") + public ResponseEntity> recommendExpir(@RequestBody ExpirRequest request) { + log.info("재료 추천 요청 - UserPkId: {}", request.userPkId()); + return ResponseEntity.ok( + recipeService.recommendExpir(request.userPkId(), request.ingredients(), request.excludedTitles()) + ); + } +} diff --git a/src/main/java/com/webservice/algorithmchef/dto/gemini/GeminiRecipeRequest.java b/src/main/java/com/webservice/algorithmchef/dto/gemini/GeminiRecipeRequest.java new file mode 100644 index 0000000..2cabfd3 --- /dev/null +++ b/src/main/java/com/webservice/algorithmchef/dto/gemini/GeminiRecipeRequest.java @@ -0,0 +1,26 @@ +package com.webservice.algorithmchef.dto.gemini; + +import java.util.List; + +public class GeminiRecipeRequest { + + // 1. 성향 기반 추천 요청 + public record PreferRequest( + Long userPkId, // DB의 User PK (id) + List excludedTitles + ) {} + + // 2. Speech-to-Text 조건 기반 추천 요청 + public record ConditionRequest( + Long userPkId, + List excludedTitles, + String condition // 기분, 상황 + ) {} + + // 3. 임박한 재료 추천 요청 + public record ExpirRequest( + Long userPkId, + List excludedTitles, + List ingredients // 냉장고 재료 + ) {} +} diff --git a/src/main/java/com/webservice/algorithmchef/dto/gemini/GeminiRecipeResponse.java b/src/main/java/com/webservice/algorithmchef/dto/gemini/GeminiRecipeResponse.java new file mode 100644 index 0000000..3de4f28 --- /dev/null +++ b/src/main/java/com/webservice/algorithmchef/dto/gemini/GeminiRecipeResponse.java @@ -0,0 +1,15 @@ +package com.webservice.algorithmchef.dto.gemini; + +import java.util.List; + +public record GeminiRecipeResponse ( + String name, + String description, + String kcal, + String portions, + String time, + List ingredients, + List instructions, + String tip, + String type +) {} diff --git a/src/main/java/com/webservice/algorithmchef/service/GeminiRecipeService.java b/src/main/java/com/webservice/algorithmchef/service/GeminiRecipeService.java new file mode 100644 index 0000000..2cdbe84 --- /dev/null +++ b/src/main/java/com/webservice/algorithmchef/service/GeminiRecipeService.java @@ -0,0 +1,205 @@ +package com.webservice.algorithmchef.service; + +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.google.genai.Client; +import com.google.genai.types.*; +import com.webservice.algorithmchef.dto.gemini.GeminiRecipeResponse; +import com.webservice.algorithmchef.model.User; +import com.webservice.algorithmchef.model.UserAllergy; +import com.webservice.algorithmchef.model.UserHealthGoal; +import com.webservice.algorithmchef.repository.UserRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.Collections; +import java.util.List; +import java.util.stream.Collectors; + +@Slf4j +@Service +@RequiredArgsConstructor +public class GeminiRecipeService { + + private final Client client; + private final ObjectMapper objectMapper; + private final UserRepository userRepository; + + // 검색 사이트 제한 (오키친, 만개의레시피) + private static final String TARGET_SITES = "site:10000recipe.com OR site:okitchen.co.kr"; + + // 1. [주소 1] 유저 성향 및 식습관 기반 추천 (prefer) + @Transactional(readOnly = true) + public List recommendPrefer(Long userPkId, List excludedTitles) { + String[] profile = extractUserProfile(userPkId); + + String specificPrompt = """ + [요청 타입: 사용자 맞춤형 건강/성향 추천] + 사용자의 알레르기 정보와 건강 목표를 최우선으로 고려하여, + 가장 적합하고 실패 없는 '정석적인 건강 레시피' 3가지를 추천해줘. + """; + + return generateAndCallGemini(specificPrompt, profile, excludedTitles, false); + } + + // 2. [주소 2] speech-to-text 조건 기반 추천 (condition) + @Transactional(readOnly = true) + public List recommendCondition(Long userPkId, String condition, List excludedTitles) { + String[] profile = extractUserProfile(userPkId); + + String specificPrompt = String.format(""" + [요청 타입: 상황 및 기분 등의 조건에 기반한 추천] + 사용자가 입력한 상황: "%s" + + 위 상황에 적절하고 사용자의 기분을 맞춰줄 수 있는 레시피를 3가지 추천해줘. + (단, 사용자의 알레르기 재료는 절대 포함하면 안 돼. 사용자의 건강 목표에 반하는 레시피도 절대 추천해서는 안 돼.) + """, condition); + + // 기분 등 정성적 요소에 기반한 추천은 검색의 유연성을 부여 (isFlexible = true) + return generateAndCallGemini(specificPrompt, profile, excludedTitles, true); + } + + // 3. [주소 3] 임박재료 기반 추천 (expir) + @Transactional(readOnly = true) + public List recommendExpir(Long userPkId, List ingredients, List excludedTitles) { + String[] profile = extractUserProfile(userPkId); + String ingredientStr = String.join(", ", ingredients); + + String specificPrompt = String.format(""" + [요청 타입: 냉장고 재료털기 (소비기한이 임박한 재료 소진)] + 사용자가 반드시 사용해야 하는 재료: [%s] + + 1. 위 재료들을 메인으로 활용하는 레시피 3가지를 추천해줘. + 2. 사용자의 알레르기 유발 재료는 절대 사용하지 마. + 3. 추가 구매가 필요한 재료를 최소화해줘. + """, ingredientStr); + + return generateAndCallGemini(specificPrompt, profile, excludedTitles, false); + } + + + // DB 정보 조회 및 문자열 변환 + private String[] extractUserProfile(Long userPkId) { + // 1. User 조회 + User user = userRepository.findById(userPkId) + .orElseThrow(() -> new RuntimeException("User not found: " + userPkId)); + + // 2. 알레르기 정보 추출 (User -> UserAllergy -> Allergy -> Name) + String allergyInfo = "없음"; + if (user.getUserAllergyList() != null && !user.getUserAllergyList().isEmpty()) { + allergyInfo = user.getUserAllergyList().stream() + .map(UserAllergy::getAllergy) // Allergy 객체 가져오기 + .map(allergy -> allergy.getName()) // 이름 추출 + .collect(Collectors.joining(", ")); + } + + // 3. 건강 목표 정보 추출 (User -> UserHealthGoal -> HealthGoal -> Description) + String goalInfo = "없음"; + if (user.getUserHealthGoals() != null && !user.getUserHealthGoals().isEmpty()) { + goalInfo = user.getUserHealthGoals().stream() + .map(UserHealthGoal::getHealthGoal) // HealthGoal 객체 가져오기 + .map(goal -> goal.getName() + "(" + goal.getDescription() + ")") // 이름+설명 추출 + .collect(Collectors.joining(", ")); + } + + return new String[]{allergyInfo, goalInfo}; + } + + + // 프롬프트 생성 및 Gemini 호출 + private List generateAndCallGemini( + String specificTask, + String[] profile, // [0]:알레르기, [1]:목표 + List excludedTitles, + boolean isFlexible + ) { + String allergyInfo = profile[0]; + String goalInfo = profile[1]; + + // 재요청시의 기추천 레시피 제외 필터 + String exclusionPrompt = ""; + if (excludedTitles != null && !excludedTitles.isEmpty()) { + exclusionPrompt = String.format(""" + [제외 메뉴] 이전에 추천한 [%s]와 동일한 메뉴는 절대 추천하지 마. + """, String.join(", ", excludedTitles)); + } + + // 검색 제약 조건 + String searchInstruction = String.format("검색 쿼리 뒤에 반드시 `%s`를 붙여서 검색해.", TARGET_SITES); + if (isFlexible) { + searchInstruction += "\n(기분이나 느낌과 같은 정성적 요소에 대한 해석에는 네 지식을 써도 되지만, 레시피 재료와 조리법은 반드시 위 사이트 검색 결과만 인용해야 해.)"; + } + + // 최종 프롬프트 + String finalPrompt = String.format(""" + 너는 '팩트 기반' 레시피 추천 AI야. 절대롤 상상하거나 지어내지 마. 아래 정보를 바탕으로 레시피를 추천해줘. + + === 1. 사용자 프로필 (DB 정보) === + - **알레르기(절대 포함 금지)**: %s + - **식습관/건강 목표**: %s + + === 2. 요청 사항 === + %s + + === 3. 필터링 === + %s + + === 4. 검색 규칙 === + 1. %s + 2. **[Grounding 원칙]**: 네가 알고 있는 사전 지식을 사용하여 레시피를 창조하지 마. + 3. 오직 **Google Search 도구가 반환한 검색 결과(Snippet)**에 있는 내용만 정리해서 답변해. + 4. 만약 위 사이트에서 적절한 레시피를 찾을 수 없다면, 차라리 추천하지 마. + + === 5. 출력 형식 (JSON Only) === + 인사말, 설명, 마크다운(```) 없이 오직 아래 JSON 배열만 반환해. + [ + { + "name": "요리명", + "description": "요리에 대한 간단한 개요" + "kcal": "몇 칼로리" + "portions": "몇 인분" + "time": "예상 조리 시간", + "ingredients": ["재료1", "재료2"], + "instructions": ["1. ...", "2. ..."], + "tip": "레시피에 관한 기타 참고사항 및 추천사항", + "type": "gemini" + } + ] + """, allergyInfo, goalInfo, specificTask, exclusionPrompt, searchInstruction); + + return callGeminiWithRetry(finalPrompt); + } + + private List callGeminiWithRetry(String prompt) { + Tool searchTool = Tool.builder() + .googleSearch(GoogleSearch.builder().build()) + .build(); + + GenerateContentConfig config = GenerateContentConfig.builder() + .tools(Collections.singletonList(searchTool)) + .build(); + + // gemini 모델 설정, api 호출이 한번에 안 될 경우 반복요청 + String modelName = "gemini-2.5-flash-lite"; + int maxRetries = 3; + + for (int i = 0; i < maxRetries; i++) { + try { + log.info("Gemini 호출 시도 {}/{}", i + 1, maxRetries); + GenerateContentResponse response = client.models.generateContent(modelName, prompt, config); + String text = response.text(); + + if (text != null) { + text = text.replace("```json", "").replace("```", "").trim(); + return objectMapper.readValue(text, new TypeReference>() {}); + } + } catch (Exception e) { + log.warn("호출 실패: {}", e.getMessage()); + try { Thread.sleep(1500); } catch (InterruptedException ignored) {} + } + } + throw new RuntimeException("Gemini API 응답 실패"); + } +} From f38249857c418811f21477d6c849390fecd20ec1 Mon Sep 17 00:00:00 2001 From: hck2025 Date: Thu, 20 Nov 2025 14:29:35 +0900 Subject: [PATCH 02/11] Add initial README content with instructions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit gitignore와 api 키 관련 정보 작성 --- README.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 README.md diff --git a/README.md b/README.md new file mode 100644 index 0000000..ce690aa --- /dev/null +++ b/README.md @@ -0,0 +1,5 @@ +gemini 호출과 응답 코드 + +gitignore에 application.properties 포함되어 있음 +application.properties에 api 키 작성되어 있음 + From ab6a1c5e4ec47facc103aaaf142c9376211d695c Mon Sep 17 00:00:00 2001 From: kghc4232 Date: Thu, 20 Nov 2025 14:33:10 +0900 Subject: [PATCH 03/11] 25.11.20 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit fork 레포지토리에 분기 후 첫 commit --- .../webservice/algorithmchef/service/GeminiRecipeService.java | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/main/java/com/webservice/algorithmchef/service/GeminiRecipeService.java b/src/main/java/com/webservice/algorithmchef/service/GeminiRecipeService.java index 2cdbe84..42f403e 100644 --- a/src/main/java/com/webservice/algorithmchef/service/GeminiRecipeService.java +++ b/src/main/java/com/webservice/algorithmchef/service/GeminiRecipeService.java @@ -73,7 +73,8 @@ public List recommendExpir(Long userPkId, List ing 1. 위 재료들을 메인으로 활용하는 레시피 3가지를 추천해줘. 2. 사용자의 알레르기 유발 재료는 절대 사용하지 마. - 3. 추가 구매가 필요한 재료를 최소화해줘. + 3. 사용자의 건강 목표에 반하는 레시피도 절대 추천하지 마. + 4. 추가 구매가 필요한 재료를 최소화해줘. """, ingredientStr); return generateAndCallGemini(specificPrompt, profile, excludedTitles, false); From 6404a1bad6f16c270948d88d9d395ff5281c41ac Mon Sep 17 00:00:00 2001 From: kghc4232 Date: Thu, 20 Nov 2025 15:11:37 +0900 Subject: [PATCH 04/11] 25.11.20 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit fork 레포지토리에 분기 후 첫 commit --- .../algorithmchef/dto/gemini/GeminiRecipeRequest.java | 6 ++++-- .../algorithmchef/service/GeminiRecipeService.java | 5 +++-- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/src/main/java/com/webservice/algorithmchef/dto/gemini/GeminiRecipeRequest.java b/src/main/java/com/webservice/algorithmchef/dto/gemini/GeminiRecipeRequest.java index 2cabfd3..775d23d 100644 --- a/src/main/java/com/webservice/algorithmchef/dto/gemini/GeminiRecipeRequest.java +++ b/src/main/java/com/webservice/algorithmchef/dto/gemini/GeminiRecipeRequest.java @@ -8,19 +8,21 @@ public class GeminiRecipeRequest { public record PreferRequest( Long userPkId, // DB의 User PK (id) List excludedTitles + // client의 첫 요청시 gemini는 비어있는 string 리스트 반환. + // 재요청 시 front에서 요리명만 해당 리스트에 담아 전송. 그러면 gemini는 해당 레시피는 제외하여 탐색. ) {} // 2. Speech-to-Text 조건 기반 추천 요청 public record ConditionRequest( Long userPkId, List excludedTitles, - String condition // 기분, 상황 + String condition // 기분, 상황 등 조건 ) {} // 3. 임박한 재료 추천 요청 public record ExpirRequest( Long userPkId, List excludedTitles, - List ingredients // 냉장고 재료 + List ingredients // 소비기한 임박 재료 ) {} } diff --git a/src/main/java/com/webservice/algorithmchef/service/GeminiRecipeService.java b/src/main/java/com/webservice/algorithmchef/service/GeminiRecipeService.java index 42f403e..efd101a 100644 --- a/src/main/java/com/webservice/algorithmchef/service/GeminiRecipeService.java +++ b/src/main/java/com/webservice/algorithmchef/service/GeminiRecipeService.java @@ -154,14 +154,15 @@ private List generateAndCallGemini( 4. 만약 위 사이트에서 적절한 레시피를 찾을 수 없다면, 차라리 추천하지 마. === 5. 출력 형식 (JSON Only) === - 인사말, 설명, 마크다운(```) 없이 오직 아래 JSON 배열만 반환해. + 인사말, 설명, 마크다운(```) 없이 오직 아래 JSON 배열만 반환해. 찾을 수 없는 항목은 자료형에 맞게 0.0 혹은 "정보 없음"으로 반환해. [ { "name": "요리명", "description": "요리에 대한 간단한 개요" - "kcal": "몇 칼로리" + "kcal": 실수, 소숫점 첫째자리까지(예시: 365.0) "portions": "몇 인분" "time": "예상 조리 시간", + "imageUrl" : "완성된 요리 사진 url 주소" "ingredients": ["재료1", "재료2"], "instructions": ["1. ...", "2. ..."], "tip": "레시피에 관한 기타 참고사항 및 추천사항", From 6b60467e95d9c18a15b3b2039976fb69ed997039 Mon Sep 17 00:00:00 2001 From: kghc4232 Date: Thu, 20 Nov 2025 15:32:03 +0900 Subject: [PATCH 05/11] 25.11.20 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit fork 레포지토리에 분기 후 첫 commit --- .../algorithmchef/dto/gemini/GeminiRecipeRequest.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/com/webservice/algorithmchef/dto/gemini/GeminiRecipeRequest.java b/src/main/java/com/webservice/algorithmchef/dto/gemini/GeminiRecipeRequest.java index 775d23d..c9ea517 100644 --- a/src/main/java/com/webservice/algorithmchef/dto/gemini/GeminiRecipeRequest.java +++ b/src/main/java/com/webservice/algorithmchef/dto/gemini/GeminiRecipeRequest.java @@ -8,7 +8,7 @@ public class GeminiRecipeRequest { public record PreferRequest( Long userPkId, // DB의 User PK (id) List excludedTitles - // client의 첫 요청시 gemini는 비어있는 string 리스트 반환. + // client의 첫 요청시 string 리스트는 비어있음. // 재요청 시 front에서 요리명만 해당 리스트에 담아 전송. 그러면 gemini는 해당 레시피는 제외하여 탐색. ) {} From 0000d63bb730857ecfa90792734b513eeb930693 Mon Sep 17 00:00:00 2001 From: kghc4232 Date: Thu, 20 Nov 2025 15:48:51 +0900 Subject: [PATCH 06/11] 25.11.20 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GeminiRecipeResponse 자료형 수정 --- .../algorithmchef/dto/gemini/GeminiRecipeResponse.java | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/main/java/com/webservice/algorithmchef/dto/gemini/GeminiRecipeResponse.java b/src/main/java/com/webservice/algorithmchef/dto/gemini/GeminiRecipeResponse.java index 3de4f28..64c1382 100644 --- a/src/main/java/com/webservice/algorithmchef/dto/gemini/GeminiRecipeResponse.java +++ b/src/main/java/com/webservice/algorithmchef/dto/gemini/GeminiRecipeResponse.java @@ -5,9 +5,10 @@ public record GeminiRecipeResponse ( String name, String description, - String kcal, + double kcal, String portions, String time, + String imageUrl, List ingredients, List instructions, String tip, From 17cf7c9c9a902f605b5000d2deb500824ad4eaf3 Mon Sep 17 00:00:00 2001 From: kghc4232 Date: Tue, 25 Nov 2025 17:17:43 +0900 Subject: [PATCH 07/11] =?UTF-8?q?11.25=2017=EC=8B=9C=20STT=20=EC=BB=A8?= =?UTF-8?q?=ED=8A=B8=EB=A1=A4=EB=9F=AC=EA=B0=80=20=ED=85=8D=EC=8A=A4?= =?UTF-8?q?=ED=8A=B8=20=EB=B3=80=ED=99=98=20=EA=B2=B0=EA=B3=BC=EB=A5=BC=20?= =?UTF-8?q?=ED=94=84=EB=9F=B0=ED=8A=B8=EA=B0=80=20=EC=95=84=EB=8B=8C=20gem?= =?UTF-8?q?ini=EC=97=90=EA=B2=8C=20=EC=A0=84=EB=8B=AC=ED=95=9C=20=ED=9B=84?= =?UTF-8?q?=20=EB=A0=88=EC=8B=9C=ED=94=BC=20=EA=B2=B0=EA=B3=BC=EB=A5=BC=20?= =?UTF-8?q?=EB=B0=98=ED=99=98=ED=95=98=EB=8F=84=EB=A1=9D=20=EC=88=98?= =?UTF-8?q?=EC=A0=95=ED=95=98=EA=B3=A0=20stt=EC=99=80=20gemini=20=EB=8F=84?= =?UTF-8?q?=EB=A9=94=EC=9D=B8=20=ED=86=B5=ED=95=A9=ED=95=A8.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- build.gradle | 1 + .../controller/SttController.java | 71 +++++++++++++++++++ .../dto/gemini/GeminiRecipeResponse.java | 4 +- .../service/GeminiRecipeService.java | 4 +- .../algorithmchef/service/SpeechService.java | 58 +++++++++++++++ 5 files changed, 134 insertions(+), 4 deletions(-) create mode 100644 src/main/java/com/webservice/algorithmchef/controller/SttController.java create mode 100644 src/main/java/com/webservice/algorithmchef/service/SpeechService.java diff --git a/build.gradle b/build.gradle index d001a99..3e365ad 100644 --- a/build.gradle +++ b/build.gradle @@ -33,6 +33,7 @@ dependencies { implementation 'io.jsonwebtoken:jjwt-api:0.11.5' compileOnly 'org.projectlombok:lombok' developmentOnly 'org.springframework.boot:spring-boot-devtools' + implementation 'com.google.cloud:google-cloud-speech:4.40.0' // google stt runtimeOnly 'com.mysql:mysql-connector-j' runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.11.5' runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.11.5' diff --git a/src/main/java/com/webservice/algorithmchef/controller/SttController.java b/src/main/java/com/webservice/algorithmchef/controller/SttController.java new file mode 100644 index 0000000..e21c94e --- /dev/null +++ b/src/main/java/com/webservice/algorithmchef/controller/SttController.java @@ -0,0 +1,71 @@ +package com.webservice.algorithmchef.controller; + +import com.webservice.algorithmchef.dto.gemini.GeminiRecipeResponse; +import com.webservice.algorithmchef.service.GeminiRecipeService; +import com.webservice.algorithmchef.service.SpeechService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; +import org.springframework.web.multipart.MultipartFile; + +import java.util.Collections; +import java.util.List; + +@Slf4j +@RestController +@RequestMapping("/api") +@CrossOrigin(origins = "http://localhost:3000") +@RequiredArgsConstructor +public class SttController { + + private final SpeechService speechService; + private final GeminiRecipeService geminiRecipeService; // 레시피 서비스 객체 + + @PostMapping("/stt") + public ResponseEntity stt( + @RequestParam("audio") MultipartFile audioFile, + @RequestParam("userId") String userIdStr + ) { + log.info("음성 기반 레시피 추천 요청 - UserId: {}, 파일크기: {} bytes", userIdStr, audioFile.getSize()); + + try { + // 1. 음성 -> 텍스트 변환 (STT) + String convertedText = speechService.stt(audioFile); + + if (convertedText == null || convertedText.isEmpty()) { + log.warn("STT 변환 실패: 음성 인식 결과 없음"); + return ResponseEntity.badRequest().body("음성을 인식하지 못했습니다. 다시 말씀해 주세요."); + } + + log.info("STT 변환 결과: \"{}\"", convertedText); + + // userId String -> Long 변환 + Long userId = Long.parseLong(userIdStr); + + // 음성 검색은 보통 '새로운 검색'이므로 excludedTitles는 빈 리스트로 전달 + List excludedTitles = Collections.emptyList(); + + // recipeservice 호출 + List recipes = geminiRecipeService.recommendCondition( + userId, + convertedText, + excludedTitles + ); + + log.info("음성 추천 완료 - {}개의 레시피 반환", recipes.size()); + + // 3. 레시피 리스트 반환 + return ResponseEntity.ok(recipes); + + } catch (NumberFormatException e) { + log.error("UserId 형식 오류: {}", userIdStr, e); + return ResponseEntity.badRequest().body("유효하지 않은 사용자 ID입니다."); + } catch (Exception e) { + log.error("음성 레시피 추천 중 오류 발생", e); + return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR) + .body("처리 중 서버 오류가 발생했습니다: " + e.getMessage()); + } + } +} diff --git a/src/main/java/com/webservice/algorithmchef/dto/gemini/GeminiRecipeResponse.java b/src/main/java/com/webservice/algorithmchef/dto/gemini/GeminiRecipeResponse.java index 64c1382..9c7d104 100644 --- a/src/main/java/com/webservice/algorithmchef/dto/gemini/GeminiRecipeResponse.java +++ b/src/main/java/com/webservice/algorithmchef/dto/gemini/GeminiRecipeResponse.java @@ -9,8 +9,8 @@ public record GeminiRecipeResponse ( String portions, String time, String imageUrl, - List ingredients, - List instructions, + String ingredients, + String instructions, String tip, String type ) {} diff --git a/src/main/java/com/webservice/algorithmchef/service/GeminiRecipeService.java b/src/main/java/com/webservice/algorithmchef/service/GeminiRecipeService.java index efd101a..99fc7b5 100644 --- a/src/main/java/com/webservice/algorithmchef/service/GeminiRecipeService.java +++ b/src/main/java/com/webservice/algorithmchef/service/GeminiRecipeService.java @@ -163,8 +163,8 @@ private List generateAndCallGemini( "portions": "몇 인분" "time": "예상 조리 시간", "imageUrl" : "완성된 요리 사진 url 주소" - "ingredients": ["재료1", "재료2"], - "instructions": ["1. ...", "2. ..."], + "ingredients": "재료1", "재료2", + "instructions": "1. ..., 2. ...", "tip": "레시피에 관한 기타 참고사항 및 추천사항", "type": "gemini" } diff --git a/src/main/java/com/webservice/algorithmchef/service/SpeechService.java b/src/main/java/com/webservice/algorithmchef/service/SpeechService.java new file mode 100644 index 0000000..360cc0b --- /dev/null +++ b/src/main/java/com/webservice/algorithmchef/service/SpeechService.java @@ -0,0 +1,58 @@ +package com.webservice.algorithmchef.service; + +import com.google.cloud.speech.v1.*; +import com.google.protobuf.ByteString; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.web.multipart.MultipartFile; + +import java.io.IOException; +import java.util.List; + +@Slf4j +@Service +public class SpeechService { + + private final SpeechClient speechClient; + + public SpeechService() throws IOException { + this.speechClient = SpeechClient.create(); + } + + public String stt(MultipartFile audioFile) throws IOException { + byte[] data = audioFile.getBytes(); + ByteString audioBytes = ByteString.copyFrom(data); + + RecognitionConfig config = RecognitionConfig.newBuilder() + .setEncoding(RecognitionConfig.AudioEncoding.LINEAR16) + .setSampleRateHertz(16000) + .setLanguageCode("ko-KR") + .build(); + + RecognitionAudio audio = RecognitionAudio.newBuilder() + .setContent(audioBytes) + .build(); + + try { + RecognizeResponse response = speechClient.recognize(config, audio); + List results = response.getResultsList(); + + if (results.isEmpty()) { + log.warn("STT 결과 없음 (Google API 응답이 비어있음)"); + return ""; + } + + StringBuilder sb = new StringBuilder(); + for (SpeechRecognitionResult result : results) { + SpeechRecognitionAlternative alternative = result.getAlternativesList().get(0); + sb.append(alternative.getTranscript()); + } + + return sb.toString(); + + } catch (Exception e) { + log.error("Google Cloud Speech API 호출 실패", e); + throw new IOException("Speech-to-Text fail", e); + } + } +} From bf95b7590af3fc00280ddfb028cf2a69b4768fe4 Mon Sep 17 00:00:00 2001 From: kghc4232 Date: Tue, 25 Nov 2025 17:19:50 +0900 Subject: [PATCH 08/11] =?UTF-8?q?stt=20+=20gemini=20=EB=8F=84=EB=A9=94?= =?UTF-8?q?=EC=9D=B8=201=EC=B0=A8=20=ED=86=B5=ED=95=A9=20=ED=9B=84=20?= =?UTF-8?q?=EC=9E=90=EC=9E=98=ED=95=9C=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../webservice/algorithmchef/service/GeminiRecipeService.java | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/main/java/com/webservice/algorithmchef/service/GeminiRecipeService.java b/src/main/java/com/webservice/algorithmchef/service/GeminiRecipeService.java index 99fc7b5..ed0a691 100644 --- a/src/main/java/com/webservice/algorithmchef/service/GeminiRecipeService.java +++ b/src/main/java/com/webservice/algorithmchef/service/GeminiRecipeService.java @@ -163,8 +163,8 @@ private List generateAndCallGemini( "portions": "몇 인분" "time": "예상 조리 시간", "imageUrl" : "완성된 요리 사진 url 주소" - "ingredients": "재료1", "재료2", - "instructions": "1. ..., 2. ...", + "ingredients": "재료1, 재료2, 재료3", + "instructions": "1. ..., 2. ..., 3. ...", "tip": "레시피에 관한 기타 참고사항 및 추천사항", "type": "gemini" } From 400d2af143198f9ec9722ad34df62ed410b123be Mon Sep 17 00:00:00 2001 From: kghc4232 Date: Wed, 26 Nov 2025 13:30:01 +0900 Subject: [PATCH 09/11] =?UTF-8?q?gemini+stt=20=EB=B8=8C=EB=9E=9C=EC=B9=98?= =?UTF-8?q?=EC=97=90=EC=84=9C=20gemini+stt+board=20=EB=B6=84=EA=B8=B0=20?= =?UTF-8?q?=ED=9B=84=20model,=20dto,=20repository=20=EC=9E=91=EC=84=B1?= =?UTF-8?q?=ED=95=98=EC=97=AC=20first=20commit?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../dto/board/BoardCommentRequest.java | 22 ++++ .../dto/board/BoardCommentResponse.java | 50 ++++++++ .../dto/board/BoardPostRequest.java | 24 ++++ .../dto/board/BoardPostResponse.java | 44 +++++++ .../algorithmchef/model/BoardComment.java | 110 ++++++++++-------- .../algorithmchef/model/BoardPost.java | 69 ++++++----- .../repository/BoardCommentRepository.java | 36 ++++++ .../repository/BoardPostRepository.java | 29 +++++ 8 files changed, 305 insertions(+), 79 deletions(-) create mode 100644 src/main/java/com/webservice/algorithmchef/dto/board/BoardCommentRequest.java create mode 100644 src/main/java/com/webservice/algorithmchef/dto/board/BoardCommentResponse.java create mode 100644 src/main/java/com/webservice/algorithmchef/dto/board/BoardPostRequest.java create mode 100644 src/main/java/com/webservice/algorithmchef/dto/board/BoardPostResponse.java create mode 100644 src/main/java/com/webservice/algorithmchef/repository/BoardCommentRepository.java create mode 100644 src/main/java/com/webservice/algorithmchef/repository/BoardPostRepository.java diff --git a/src/main/java/com/webservice/algorithmchef/dto/board/BoardCommentRequest.java b/src/main/java/com/webservice/algorithmchef/dto/board/BoardCommentRequest.java new file mode 100644 index 0000000..04489e2 --- /dev/null +++ b/src/main/java/com/webservice/algorithmchef/dto/board/BoardCommentRequest.java @@ -0,0 +1,22 @@ +package com.webservice.algorithmchef.dto.board; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class BoardCommentRequest { + + // 댓글이 달릴 게시글 ID + private Long postId; + + // 부모 댓글 ID (대댓글일 경우 필수, 최상위 댓글이면 null) + private Long parentCommentId; + + // 댓글 내용 + private String content; +} diff --git a/src/main/java/com/webservice/algorithmchef/dto/board/BoardCommentResponse.java b/src/main/java/com/webservice/algorithmchef/dto/board/BoardCommentResponse.java new file mode 100644 index 0000000..b260acf --- /dev/null +++ b/src/main/java/com/webservice/algorithmchef/dto/board/BoardCommentResponse.java @@ -0,0 +1,50 @@ +package com.webservice.algorithmchef.dto.board; + +import com.webservice.algorithmchef.model.BoardComment; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.List; + +@Getter +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class BoardCommentResponse { + + private Long commentId; + private Long postId; + private Long userId; // 댓글 작성자 ID + private String writerName; // 댓글 작성자 이름 + private Long parentCommentId; // 부모 댓글 ID (없으면 null) + private int depth; // 대댓글 깊이 (0: 원댓글, 1: 대댓글 ...) + private String content; // "삭제된 댓글입니다" 처리를 위해 필요 + private LocalDateTime createdAt; + private LocalDateTime modifiedDate; + private boolean isDeleted; // 프론트에서 "삭제됨" 표시 여부 판단용 + + // 자식 댓글(대댓글) 목록 - 계층형 구조 표현 + @Builder.Default + private List children = new ArrayList<>(); + + // Entity -> DTO 변환 편의 메서드 + public static BoardCommentResponse from(BoardComment comment, String writerName) { + return BoardCommentResponse.builder() + .commentId(comment.getCommentId()) + .postId(comment.getPost().getPostId()) + .userId(comment.getUser().getId()) + .writerName(writerName) + .parentCommentId(comment.getParentComment() != null ? comment.getParentComment().getCommentId() : null) + .depth(comment.getDepth()) + // 삭제된 댓글일 경우 내용을 변경해서 보낼 수도 있고, isDeleted 플래그만 보낼 수도 있음 + .content(comment.isDeleted() ? "삭제된 댓글입니다." : comment.getContent()) + .createdAt(comment.getCreatedAt()) + .modifiedDate(comment.getModifiedDate()) + .isDeleted(comment.isDeleted()) + .build(); + } +} diff --git a/src/main/java/com/webservice/algorithmchef/dto/board/BoardPostRequest.java b/src/main/java/com/webservice/algorithmchef/dto/board/BoardPostRequest.java new file mode 100644 index 0000000..2fa0329 --- /dev/null +++ b/src/main/java/com/webservice/algorithmchef/dto/board/BoardPostRequest.java @@ -0,0 +1,24 @@ +package com.webservice.algorithmchef.dto.board; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class BoardPostRequest { + + // 게시글 카테고리 (나눔, 질문 등) + private String category; + + // 게시글 제목 + private String title; + + // 게시글 내용 (DB에서는 BLOB이지만, JSON 전송 시 String으로 처리) + private String content; + + // 참고: user_id는 보통 Controller에서 @AuthenticationPrincipal을 통해 주입받으므로 DTO에서 제외합니다. +} diff --git a/src/main/java/com/webservice/algorithmchef/dto/board/BoardPostResponse.java b/src/main/java/com/webservice/algorithmchef/dto/board/BoardPostResponse.java new file mode 100644 index 0000000..dd71763 --- /dev/null +++ b/src/main/java/com/webservice/algorithmchef/dto/board/BoardPostResponse.java @@ -0,0 +1,44 @@ +package com.webservice.algorithmchef.dto.board; + +import com.webservice.algorithmchef.model.BoardPost; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; +import java.util.List; + +@Getter +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class BoardPostResponse { + + private Long postId; + private Long userId; // 작성자 식별자 + private String writerName; // 작성자 닉네임/이름 (User 테이블 조인 필요 시) + private String category; + private String title; + private String content; // BLOB -> String 변환된 내용 + private LocalDateTime createdAt; + private LocalDateTime modifiedDate; + + // 해당 게시글에 달린 댓글 목록 (선택사항: 상세 조회 시 같이 내려줄 경우 필요) + private List comments; + + // Entity -> DTO 변환 편의 메서드 + public static BoardPostResponse from(BoardPost post, String writerName, List comments) { + return BoardPostResponse.builder() + .postId(post.getPostId()) + .userId(post.getUser().getId()) // User 객체에서 ID 추출 가정 + .writerName(writerName) + .category(post.getCategory()) + .title(post.getTitle()) + .content(post.getContent()) // post.getContent()가 String을 반환한다고 가정 + .createdAt(post.getCreatedAt()) + .modifiedDate(post.getModifiedDate()) + .comments(comments) + .build(); + } +} diff --git a/src/main/java/com/webservice/algorithmchef/model/BoardComment.java b/src/main/java/com/webservice/algorithmchef/model/BoardComment.java index 9659cc1..27a3a27 100644 --- a/src/main/java/com/webservice/algorithmchef/model/BoardComment.java +++ b/src/main/java/com/webservice/algorithmchef/model/BoardComment.java @@ -1,71 +1,81 @@ package com.webservice.algorithmchef.model; -import java.time.LocalDateTime; -import java.util.List; - -import org.hibernate.annotations.ColumnDefault; -import org.hibernate.annotations.CreationTimestamp; - -import jakarta.persistence.Column; -import jakarta.persistence.Entity; -import jakarta.persistence.FetchType; -import jakarta.persistence.GeneratedValue; -import jakarta.persistence.GenerationType; -import jakarta.persistence.Id; -import jakarta.persistence.JoinColumn; -import jakarta.persistence.Lob; -import jakarta.persistence.ManyToOne; -import jakarta.persistence.OneToMany; +import jakarta.persistence.*; import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Getter; import lombok.NoArgsConstructor; -import lombok.Setter; -import lombok.ToString; +import org.hibernate.annotations.ColumnDefault; +import org.hibernate.annotations.CreationTimestamp; +import org.hibernate.annotations.UpdateTimestamp; + +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.List; @Entity -@AllArgsConstructor +@Getter @NoArgsConstructor +@AllArgsConstructor @Builder -@Getter -@Setter -@ToString +@Table(name = "board_comment") public class BoardComment { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) - @Column(name="comment_id") - private Long id; - + @Column(name = "comment_id") + private Long commentId; + + // 댓글이 달린 게시글 + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "post_id", nullable = false) + private BoardPost post; + + // 댓글 작성자 (제공해주신 User 엔티티와 연결) @ManyToOne(fetch = FetchType.LAZY) - @JoinColumn(name = "user_id" ,nullable=false) + @JoinColumn(name = "user_id", nullable = false) private User user; - + + // 대댓글 기능을 위한 자기 참조 (부모 댓글) + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "parent_comment_id") + private BoardComment parentComment; + + // 대댓글 목록 (자식 댓글) + @OneToMany(mappedBy = "parentComment", orphanRemoval = true) + @Builder.Default + private List children = new ArrayList<>(); + + @Column(nullable = false) + @ColumnDefault("0") + private int depth; + + @Lob + @Column(nullable = false, columnDefinition = "LONGTEXT") + private String content; + + // 삭제 여부 (Soft Delete) + @Column(nullable = false) + @ColumnDefault("false") + private boolean isDeleted; + + // 생성 시간 (Hibernate) @CreationTimestamp + @Column(name = "created_at", updatable = false) private LocalDateTime createdAt; - + + // 수정 시간 (Hibernate) + @UpdateTimestamp + @Column(name = "modified_date") private LocalDateTime modifiedDate; - - @Lob - @Column(nullable=false) - private String content; - @Column(columnDefinition = "boolean default false") - private boolean isDeleted; - - @ManyToOne(fetch = FetchType.LAZY) - @JoinColumn(name = "post_id") - private BoardPost post; - - @ManyToOne(fetch = FetchType.LAZY) - @JoinColumn(name = "parent_id") - @ToString.Exclude - private BoardComment parent; - - @OneToMany(mappedBy = "parent") - @ToString.Exclude - private List comments; - - @ColumnDefault("0") - private int depth; + // 댓글 내용 수정 + public void updateContent(String content) { + this.content = content; + } + + // 댓글 삭제 처리 (상태값 변경) + public void changeIsDeleted(boolean isDeleted) { + this.isDeleted = isDeleted; + } } \ No newline at end of file diff --git a/src/main/java/com/webservice/algorithmchef/model/BoardPost.java b/src/main/java/com/webservice/algorithmchef/model/BoardPost.java index 62a1c21..a417a9e 100644 --- a/src/main/java/com/webservice/algorithmchef/model/BoardPost.java +++ b/src/main/java/com/webservice/algorithmchef/model/BoardPost.java @@ -1,55 +1,66 @@ package com.webservice.algorithmchef.model; -import java.time.LocalDateTime; - -import org.hibernate.annotations.CreationTimestamp; - -import jakarta.persistence.Column; -import jakarta.persistence.Entity; -import jakarta.persistence.FetchType; -import jakarta.persistence.GeneratedValue; -import jakarta.persistence.GenerationType; -import jakarta.persistence.Id; -import jakarta.persistence.JoinColumn; -import jakarta.persistence.Lob; -import jakarta.persistence.ManyToOne; +import com.webservice.algorithmchef.dto.board.BoardPostRequest; +import jakarta.persistence.*; import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Getter; import lombok.NoArgsConstructor; -import lombok.Setter; -import lombok.ToString; +import org.hibernate.annotations.CreationTimestamp; +import org.hibernate.annotations.UpdateTimestamp; + +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.List; @Entity -@AllArgsConstructor +@Getter @NoArgsConstructor +@AllArgsConstructor @Builder -@Getter -@Setter -@ToString +@Table(name = "board_post") public class BoardPost { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) - @Column(name="post_id") - private Long id; - + @Column(name = "post_id") + private Long postId; + + // User와의 다대일 관계 (작성자) @ManyToOne(fetch = FetchType.LAZY) - @JoinColumn(name="user_id") + @JoinColumn(name = "user_id", nullable = false) private User user; - + @Column(nullable = false) private String category; - + @Column(nullable = false) private String title; - + + // DB의 BLOB 타입에 대응. String으로 처리하되 대용량 데이터임을 명시 @Lob - @Column(nullable = false) + @Column(nullable = false, columnDefinition = "LONGTEXT") private String content; - + + // 게시글 삭제 시 댓글도 함께 삭제되도록 Cascade 설정 + @OneToMany(mappedBy = "post", cascade = CascadeType.ALL, orphanRemoval = true) + @Builder.Default + private List comments = new ArrayList<>(); + + // 생성 시간 (Hibernate) @CreationTimestamp + @Column(name = "created_at", updatable = false) private LocalDateTime createdAt; - + + // 수정 시간 (Hibernate) + @UpdateTimestamp + @Column(name = "modified_date") private LocalDateTime modifiedDate; + + // 게시글 수정 메서드 (Dirty Checking 용) + public void update(String title, String content, String category) { + this.title = title; + this.content = content; + this.category = category; + } } diff --git a/src/main/java/com/webservice/algorithmchef/repository/BoardCommentRepository.java b/src/main/java/com/webservice/algorithmchef/repository/BoardCommentRepository.java new file mode 100644 index 0000000..241d980 --- /dev/null +++ b/src/main/java/com/webservice/algorithmchef/repository/BoardCommentRepository.java @@ -0,0 +1,36 @@ +package com.webservice.algorithmchef.repository; + +import com.webservice.algorithmchef.model.BoardComment; +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; + +import java.util.List; + +@Repository +public interface BoardCommentRepository extends JpaRepository { + + /** + * 특정 게시글(PostId)에 달린 모든 댓글을 조회합니다. + * 대댓글 계층 구조를 서비스 로직에서 조립하기 위해, + * 부모/자식 관계없이 해당 글의 모든 댓글을 작성일 순서(오래된 순)로 가져옵니다. + * + * @param postId 조회할 게시글의 ID + * @return 해당 게시글의 댓글 리스트 + */ + List findByPost_PostIdOrderByCreatedAtAsc(Long postId); + + /** + * (선택 사항) 특정 게시글의 '최상위 댓글(부모가 없는 댓글)'만 조회합니다. + * 대댓글을 지연 로딩(Lazy Loading)으로 가져오거나 페이징이 필요할 때 사용합니다. + */ + List findByPost_PostIdAndParentCommentIsNullOrderByCreatedAtAsc(Long postId); + + /** + * (성능 최적화용) 게시글의 댓글을 가져올 때 작성자(User) 정보까지 한 번에 가져옵니다 (Fetch Join). + * N+1 문제를 방지하여 조회 성능을 높입니다. + */ + @Query("SELECT c FROM BoardComment c JOIN FETCH c.user WHERE c.post.postId = :postId ORDER BY c.createdAt ASC") + List findCommentsByPostIdWithUser(@Param("postId") Long postId); +} diff --git a/src/main/java/com/webservice/algorithmchef/repository/BoardPostRepository.java b/src/main/java/com/webservice/algorithmchef/repository/BoardPostRepository.java new file mode 100644 index 0000000..7a90925 --- /dev/null +++ b/src/main/java/com/webservice/algorithmchef/repository/BoardPostRepository.java @@ -0,0 +1,29 @@ +package com.webservice.algorithmchef.repository; + +import com.webservice.algorithmchef.model.BoardPost; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import java.util.List; + +@Repository +public interface BoardPostRepository extends JpaRepository { + + /** + * 게시글 전체 목록을 작성일 기준 내림차순(최신순)으로 조회합니다. + * 사용처: 게시판 메인 화면 + */ + List findAllByOrderByCreatedAtDesc(); + + /** + * 특정 카테고리의 게시글 목록을 작성일 기준 내림차순으로 조회합니다. + * 사용처: 카테고리별 필터링 (나눔, 질문 등) + */ + List findByCategoryOrderByCreatedAtDesc(String category); + + /** + * 특정 사용자가 작성한 게시글 목록을 조회합니다. + * 사용처: 마이페이지 - 내가 쓴 글 + */ + List findByUser_IdOrderByCreatedAtDesc(Long userId); +} From e1fee85bf576c42928d9716e04232eced1861120 Mon Sep 17 00:00:00 2001 From: kghc4232 Date: Wed, 26 Nov 2025 16:20:14 +0900 Subject: [PATCH 10/11] =?UTF-8?q?1.=EA=B2=8C=EC=8B=9C=EA=B8=80=20=EC=9E=91?= =?UTF-8?q?=EC=84=B1,=202.=EA=B2=8C=EC=8B=9C=EA=B8=80=20=EB=AA=A9=EB=A1=9D?= =?UTF-8?q?=20=EC=A1=B0=ED=9A=8C(=EA=B2=8C=EC=8B=9C=ED=8C=90),=203.?= =?UTF-8?q?=EA=B2=8C=EC=8B=9C=EA=B8=80=20=EC=A1=B0=ED=9A=8C,=204.=EA=B2=8C?= =?UTF-8?q?=EC=8B=9C=EA=B8=80=EC=97=90=20=EB=8C=93=EA=B8=80=20=EC=9E=91?= =?UTF-8?q?=EC=84=B1,=205.=EB=8C=93=EA=B8=80=EC=97=90=20=EB=8C=80=EB=8C=93?= =?UTF-8?q?=EA=B8=80=20=EC=9E=91=EC=84=B1,=206.=EB=8C=80=EB=8C=93=EA=B8=80?= =?UTF-8?q?=20=EC=A1=B0=ED=9A=8C=20=EA=B8=B0=EB=8A=A5=20=EC=B4=88=EC=95=88?= =?UTF-8?q?=20=EA=B5=AC=ED=98=84=ED=95=98=EC=97=AC=20second=20commit?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../controller/BoardController.java | 121 ++++++++++++ .../dto/board/BoardPostListResponse.java | 42 +++++ .../dto/board/BoardPostResponse.java | 49 +++-- .../dto/board/CommentReplyListResponse.java | 18 ++ .../algorithmchef/dto/board/PageInfo.java | 27 +++ .../repository/BoardCommentRepository.java | 18 +- .../repository/BoardPostRepository.java | 24 +-- .../algorithmchef/service/BoardService.java | 173 ++++++++++++++++++ 8 files changed, 437 insertions(+), 35 deletions(-) create mode 100644 src/main/java/com/webservice/algorithmchef/controller/BoardController.java create mode 100644 src/main/java/com/webservice/algorithmchef/dto/board/BoardPostListResponse.java create mode 100644 src/main/java/com/webservice/algorithmchef/dto/board/CommentReplyListResponse.java create mode 100644 src/main/java/com/webservice/algorithmchef/dto/board/PageInfo.java create mode 100644 src/main/java/com/webservice/algorithmchef/service/BoardService.java diff --git a/src/main/java/com/webservice/algorithmchef/controller/BoardController.java b/src/main/java/com/webservice/algorithmchef/controller/BoardController.java new file mode 100644 index 0000000..3292c1e --- /dev/null +++ b/src/main/java/com/webservice/algorithmchef/controller/BoardController.java @@ -0,0 +1,121 @@ +package com.webservice.algorithmchef.controller; + +import com.webservice.algorithmchef.dto.board.BoardCommentRequest; +import com.webservice.algorithmchef.dto.board.BoardPostResponse; +import com.webservice.algorithmchef.dto.board.BoardPostListResponse; +import com.webservice.algorithmchef.dto.board.BoardPostRequest; +import com.webservice.algorithmchef.dto.board.CommentReplyListResponse; +import com.webservice.algorithmchef.service.BoardService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.web.bind.annotation.*; + +import java.util.Map; + +@Slf4j +@RestController +@RequestMapping("/board") +@RequiredArgsConstructor +public class BoardController { + + private final BoardService boardService; + + /** + * 1. 게시글 목록 조회 + * GET /board/posts + */ + @GetMapping("/posts") + public ResponseEntity getPostList( + @RequestParam(defaultValue = "0") int page, + @RequestParam(defaultValue = "20") int size, + @RequestParam(defaultValue = "createdAt,desc") String sort, + @RequestParam(required = false) String filter + ) { + BoardPostListResponse response = boardService.getPostList(page, size, sort, filter); + return ResponseEntity.ok(response); + } + + /** + * 2. 새 게시글 작성 + * POST /board/post + */ + @PostMapping("/post") + public ResponseEntity> createPost( + @RequestBody BoardPostRequest requestDto, + @AuthenticationPrincipal UserDetails userDetails + ) { + if (userDetails == null) { + return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build(); + } + + boardService.createPost(requestDto, userDetails.getUsername()); + + return ResponseEntity.status(HttpStatus.CREATED) + .body(Map.of("message", "새 게시글 작성완료 되었습니다.")); + } + + /** + * 3. 게시글 상세 조회 + * GET /board/post/{postId} + */ + @GetMapping("/post/{postId}") + public ResponseEntity getPostDetail( + @PathVariable Long postId, + @RequestParam(defaultValue = "0") int page, + @RequestParam(defaultValue = "20") int size, + @RequestParam(defaultValue = "createdAt,asc") String sort + ) { + try { + BoardPostResponse response = boardService.getPostDetail(postId, page, size, sort); + return ResponseEntity.ok(response); + } catch (IllegalArgumentException e) { + return ResponseEntity.status(HttpStatus.NOT_FOUND).build(); + } + } + + /** + * 6. 댓글 작성 + * POST /board/post/{postId}/comment + */ + @PostMapping("/post/{postId}/comment") + public ResponseEntity> createComment( + @PathVariable Long postId, + @RequestBody BoardCommentRequest requestDto, + @AuthenticationPrincipal UserDetails userDetails + ) { + if (userDetails == null) { + return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build(); + } + + try { + boardService.createComment(postId, requestDto, userDetails.getUsername()); + return ResponseEntity.status(HttpStatus.CREATED) + .body(Map.of("message", "댓글 작성완료")); + } catch (IllegalArgumentException e) { + return ResponseEntity.badRequest().body(Map.of("error", e.getMessage())); + } + } + + /** + * 5. 대댓글 조회 + * GET /board/comments/{commentId}/replies + */ + @GetMapping("/comments/{commentId}/replies") + public ResponseEntity getReplies( + @PathVariable Long commentId, + @RequestParam(defaultValue = "0") int page, + @RequestParam(defaultValue = "20") int size, + @RequestParam(defaultValue = "createdAt,asc") String sort + ) { + try { + CommentReplyListResponse response = boardService.getReplies(commentId, page, size, sort); + return ResponseEntity.ok(response); + } catch (IllegalArgumentException e) { + return ResponseEntity.status(HttpStatus.NOT_FOUND).build(); + } + } +} \ No newline at end of file diff --git a/src/main/java/com/webservice/algorithmchef/dto/board/BoardPostListResponse.java b/src/main/java/com/webservice/algorithmchef/dto/board/BoardPostListResponse.java new file mode 100644 index 0000000..8a03819 --- /dev/null +++ b/src/main/java/com/webservice/algorithmchef/dto/board/BoardPostListResponse.java @@ -0,0 +1,42 @@ +package com.webservice.algorithmchef.dto.board; + +import com.webservice.algorithmchef.model.BoardPost; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.time.format.DateTimeFormatter; +import java.util.List; + +@Getter +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class BoardPostListResponse { + // 목록 조회 시 반환할 래퍼 클래스 + private List posts; + private PageInfo pageInfo; + + @Getter + @Builder + public static class BoardPostSimple { + private String title; + private String userId; + private String createdAt; + private String category; + private String content; // 목록 미리보기용 + + public static BoardPostSimple from(BoardPost post) { + return BoardPostSimple.builder() + .title(post.getTitle()) + // User 엔티티의 userId (로그인 아이디) 사용 + .userId(post.getUser().getUserId()) + // 날짜 포맷팅 (yyyy-MM-dd) + .createdAt(post.getCreatedAt().format(DateTimeFormatter.ofPattern("yyyy-MM-dd"))) + .category(post.getCategory()) + .content(post.getContent()) + .build(); + } + } +} diff --git a/src/main/java/com/webservice/algorithmchef/dto/board/BoardPostResponse.java b/src/main/java/com/webservice/algorithmchef/dto/board/BoardPostResponse.java index dd71763..477bd17 100644 --- a/src/main/java/com/webservice/algorithmchef/dto/board/BoardPostResponse.java +++ b/src/main/java/com/webservice/algorithmchef/dto/board/BoardPostResponse.java @@ -1,5 +1,6 @@ package com.webservice.algorithmchef.dto.board; +import com.webservice.algorithmchef.model.BoardComment; import com.webservice.algorithmchef.model.BoardPost; import lombok.AllArgsConstructor; import lombok.Builder; @@ -14,31 +15,53 @@ @AllArgsConstructor @Builder public class BoardPostResponse { - private Long postId; - private Long userId; // 작성자 식별자 - private String writerName; // 작성자 닉네임/이름 (User 테이블 조인 필요 시) + private String userId; private String category; private String title; - private String content; // BLOB -> String 변환된 내용 + private String content; private LocalDateTime createdAt; - private LocalDateTime modifiedDate; + private List comments; + private PageInfo pageInfo; + + @Getter + @Builder + public static class CommentSimple { + private Long commentId; + private String userId; + private String content; + private LocalDateTime createdAt; + private boolean isDeleted; + private int replyCount; - // 해당 게시글에 달린 댓글 목록 (선택사항: 상세 조회 시 같이 내려줄 경우 필요) - private List comments; + public static CommentSimple from(BoardComment comment) { + // 삭제된 댓글 처리 + String displayContent = comment.isDeleted() ? "삭제된 댓글입니다." : comment.getContent(); + // 유저 처리 (삭제된 유저일 경우 로직 추가 가능, 여기선 기본 처리) + String displayUserId = comment.getUser() != null ? comment.getUser().getUserId() : "(삭제된 사용자)"; + + return CommentSimple.builder() + .commentId(comment.getCommentId()) + .userId(displayUserId) + .content(displayContent) + .createdAt(comment.getCreatedAt()) + .isDeleted(comment.isDeleted()) + // 대댓글 개수 (Children 리스트 사이즈) + .replyCount(comment.getChildren().size()) + .build(); + } + } - // Entity -> DTO 변환 편의 메서드 - public static BoardPostResponse from(BoardPost post, String writerName, List comments) { + public static BoardPostResponse of(BoardPost post, List comments, PageInfo pageInfo) { return BoardPostResponse.builder() .postId(post.getPostId()) - .userId(post.getUser().getId()) // User 객체에서 ID 추출 가정 - .writerName(writerName) + .userId(post.getUser().getUserId()) .category(post.getCategory()) .title(post.getTitle()) - .content(post.getContent()) // post.getContent()가 String을 반환한다고 가정 + .content(post.getContent()) .createdAt(post.getCreatedAt()) - .modifiedDate(post.getModifiedDate()) .comments(comments) + .pageInfo(pageInfo) .build(); } } diff --git a/src/main/java/com/webservice/algorithmchef/dto/board/CommentReplyListResponse.java b/src/main/java/com/webservice/algorithmchef/dto/board/CommentReplyListResponse.java new file mode 100644 index 0000000..6955129 --- /dev/null +++ b/src/main/java/com/webservice/algorithmchef/dto/board/CommentReplyListResponse.java @@ -0,0 +1,18 @@ +package com.webservice.algorithmchef.dto.board; + +import com.webservice.algorithmchef.dto.board.BoardPostResponse.CommentSimple; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.util.List; + +@Getter +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class CommentReplyListResponse { + private List replies; + private PageInfo pageInfo; +} diff --git a/src/main/java/com/webservice/algorithmchef/dto/board/PageInfo.java b/src/main/java/com/webservice/algorithmchef/dto/board/PageInfo.java new file mode 100644 index 0000000..b173ea8 --- /dev/null +++ b/src/main/java/com/webservice/algorithmchef/dto/board/PageInfo.java @@ -0,0 +1,27 @@ +package com.webservice.algorithmchef.dto.board; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import org.springframework.data.domain.Page; + +@Getter +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class PageInfo { + private int page; + private int size; + private long totalElements; + private int totalPages; + + public static PageInfo from(Page page) { + return PageInfo.builder() + .page(page.getNumber()) + .size(page.getSize()) + .totalElements(page.getTotalElements()) + .totalPages(page.getTotalPages()) + .build(); + } +} diff --git a/src/main/java/com/webservice/algorithmchef/repository/BoardCommentRepository.java b/src/main/java/com/webservice/algorithmchef/repository/BoardCommentRepository.java index 241d980..1e5f510 100644 --- a/src/main/java/com/webservice/algorithmchef/repository/BoardCommentRepository.java +++ b/src/main/java/com/webservice/algorithmchef/repository/BoardCommentRepository.java @@ -1,6 +1,8 @@ package com.webservice.algorithmchef.repository; import com.webservice.algorithmchef.model.BoardComment; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; @@ -22,10 +24,10 @@ public interface BoardCommentRepository extends JpaRepository findByPost_PostIdOrderByCreatedAtAsc(Long postId); /** - * (선택 사항) 특정 게시글의 '최상위 댓글(부모가 없는 댓글)'만 조회합니다. - * 대댓글을 지연 로딩(Lazy Loading)으로 가져오거나 페이징이 필요할 때 사용합니다. + * 특정 게시글의 '최상위 댓글(부모가 없는 댓글)'만 페이징 조회합니다. + * 게시글 상세 조회 시 댓글 목록 페이징에 사용됩니다. */ - List findByPost_PostIdAndParentCommentIsNullOrderByCreatedAtAsc(Long postId); + Page findByPost_PostIdAndParentCommentIsNull(Long postId, Pageable pageable); /** * (성능 최적화용) 게시글의 댓글을 가져올 때 작성자(User) 정보까지 한 번에 가져옵니다 (Fetch Join). @@ -33,4 +35,14 @@ public interface BoardCommentRepository extends JpaRepository findCommentsByPostIdWithUser(@Param("postId") Long postId); + + /** + * 특정 부모 댓글의 자식 댓글(대댓글)을 페이징하여 조회합니다. + * 대댓글 더보기 기능 등에 사용됩니다. + * * @param parentCommentId 부모 댓글 ID (CommentId) + * + * @param pageable 페이징 정보 + * @return 대댓글 Page 객체 + */ + Page findByParentComment_CommentId(Long parentCommentId, Pageable pageable); } diff --git a/src/main/java/com/webservice/algorithmchef/repository/BoardPostRepository.java b/src/main/java/com/webservice/algorithmchef/repository/BoardPostRepository.java index 7a90925..b7b84ce 100644 --- a/src/main/java/com/webservice/algorithmchef/repository/BoardPostRepository.java +++ b/src/main/java/com/webservice/algorithmchef/repository/BoardPostRepository.java @@ -1,29 +1,15 @@ package com.webservice.algorithmchef.repository; import com.webservice.algorithmchef.model.BoardPost; +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 BoardPostRepository extends JpaRepository { + // 카테고리 필터링 + 페이징 + Page findByCategory(String category, Pageable pageable); - /** - * 게시글 전체 목록을 작성일 기준 내림차순(최신순)으로 조회합니다. - * 사용처: 게시판 메인 화면 - */ - List findAllByOrderByCreatedAtDesc(); - - /** - * 특정 카테고리의 게시글 목록을 작성일 기준 내림차순으로 조회합니다. - * 사용처: 카테고리별 필터링 (나눔, 질문 등) - */ - List findByCategoryOrderByCreatedAtDesc(String category); - - /** - * 특정 사용자가 작성한 게시글 목록을 조회합니다. - * 사용처: 마이페이지 - 내가 쓴 글 - */ - List findByUser_IdOrderByCreatedAtDesc(Long userId); + // 전체 페이징 (필터 없을 때) - findAll(Pageable)은 JpaRepository에 기본 내장됨 } diff --git a/src/main/java/com/webservice/algorithmchef/service/BoardService.java b/src/main/java/com/webservice/algorithmchef/service/BoardService.java new file mode 100644 index 0000000..50034a1 --- /dev/null +++ b/src/main/java/com/webservice/algorithmchef/service/BoardService.java @@ -0,0 +1,173 @@ +package com.webservice.algorithmchef.service; + +import com.webservice.algorithmchef.dto.board.*; +import com.webservice.algorithmchef.model.BoardComment; +import com.webservice.algorithmchef.model.BoardPost; +import com.webservice.algorithmchef.model.User; +import com.webservice.algorithmchef.repository.BoardCommentRepository; +import com.webservice.algorithmchef.repository.BoardPostRepository; +import com.webservice.algorithmchef.repository.UserRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Sort; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; +import java.util.stream.Collectors; + +@Slf4j +@Service +@RequiredArgsConstructor +public class BoardService { + + private final BoardPostRepository boardPostRepository; + private final BoardCommentRepository boardCommentRepository; + private final UserRepository userRepository; + + /** + * 1. 게시글 목록 조회 + */ + @Transactional(readOnly = true) + public BoardPostListResponse getPostList(int page, int size, String sortStr, String filter) { + Pageable pageable = createPageable(page, size, sortStr); + + Page postPage; + if (filter != null && !filter.isEmpty()) { + postPage = boardPostRepository.findByCategory(filter, pageable); + } else { + postPage = boardPostRepository.findAll(pageable); + } + + List postDtos = postPage.getContent().stream() + .map(BoardPostListResponse.BoardPostSimple::from) + .collect(Collectors.toList()); + + PageInfo pageInfo = PageInfo.from(postPage); + + return BoardPostListResponse.builder() + .posts(postDtos) + .pageInfo(pageInfo) + .build(); + } + + /** + * 2. 새 게시글 작성 + */ + @Transactional + public void createPost(BoardPostRequest requestDto, String userId) { + User user = userRepository.findByUserId(userId) + .orElseThrow(() -> new IllegalArgumentException("해당 사용자가 존재하지 않습니다. userId=" + userId)); + + BoardPost post = BoardPost.builder() + .user(user) + .category(requestDto.getCategory()) + .title(requestDto.getTitle()) + .content(requestDto.getContent()) + .build(); + + boardPostRepository.save(post); + } + + /** + * 3. 게시글 상세 조회 (댓글 페이징 포함) + */ + @Transactional(readOnly = true) + public BoardPostResponse getPostDetail(Long postId, int page, int size, String sortStr) { + BoardPost post = boardPostRepository.findById(postId) + .orElseThrow(() -> new IllegalArgumentException("해당 게시글이 존재하지 않습니다. postId=" + postId)); + + // 부모 댓글만 조회 + Pageable pageable = createPageable(page, size, sortStr); + Page commentPage = boardCommentRepository.findByPost_PostIdAndParentCommentIsNull(postId, pageable); + + List commentDtos = commentPage.getContent().stream() + .map(BoardPostResponse.CommentSimple::from) + .collect(Collectors.toList()); + + PageInfo pageInfo = PageInfo.from(commentPage); + + return BoardPostResponse.of(post, commentDtos, pageInfo); + } + + /** + * 6. 댓글 작성 + */ + @Transactional + public void createComment(Long postId, BoardCommentRequest requestDto, String userId) { + User user = userRepository.findByUserId(userId) + .orElseThrow(() -> new IllegalArgumentException("해당 사용자가 존재하지 않습니다. userId=" + userId)); + + BoardPost post = boardPostRepository.findById(postId) + .orElseThrow(() -> new IllegalArgumentException("해당 게시글이 존재하지 않습니다. postId=" + postId)); + + BoardComment parentComment = null; + int depth = 0; + + // 대댓글일 경우 부모 댓글 조회 + if (requestDto.getParentCommentId() != null) { + parentComment = boardCommentRepository.findById(requestDto.getParentCommentId()) + .orElseThrow(() -> new IllegalArgumentException("부모 댓글이 존재하지 않습니다. id=" + requestDto.getParentCommentId())); + depth = parentComment.getDepth() + 1; + } + + BoardComment comment = BoardComment.builder() + .user(user) + .post(post) + .content(requestDto.getContent()) + .parentComment(parentComment) + .depth(depth) + .isDeleted(false) + .build(); + + boardCommentRepository.save(comment); + } + + /** + * 5. 대댓글 조회 + */ + @Transactional(readOnly = true) + public CommentReplyListResponse getReplies(Long commentId, int page, int size, String sortStr) { + // 부모 댓글 존재 여부 확인 (선택사항) + if (!boardCommentRepository.existsById(commentId)) { + throw new IllegalArgumentException("해당 댓글이 존재하지 않습니다. commentId=" + commentId); + } + + Pageable pageable = createPageable(page, size, sortStr); + + // 부모 댓글 ID를 기준으로 자식 댓글 페이징 조회 + // Repository에 findByParentComment_CommentId 메서드가 필요하다고 가정합니다. + Page replyPage = boardCommentRepository.findByParentComment_CommentId(commentId, pageable); + + List replyDtos = replyPage.getContent().stream() + .map(BoardPostResponse.CommentSimple::from) + .collect(Collectors.toList()); + + PageInfo pageInfo = PageInfo.from(replyPage); + + return CommentReplyListResponse.builder() + .replies(replyDtos) + .pageInfo(pageInfo) + .build(); + } + + // Pageable 생성 헬퍼 메서드 + private Pageable createPageable(int page, int size, String sortStr) { + Sort sort = Sort.by(Sort.Direction.DESC, "createdAt"); // 기본값 + + if (sortStr != null && !sortStr.isEmpty()) { + String[] sortParams = sortStr.split(","); + if (sortParams.length == 2) { + String property = sortParams[0]; + String direction = sortParams[1]; + sort = direction.equalsIgnoreCase("asc") ? + Sort.by(Sort.Direction.ASC, property) : + Sort.by(Sort.Direction.DESC, property); + } + } + return PageRequest.of(page, size, sort); + } +} From 798cd0095fcb8e90f9e2d2b9e73576688526f81a Mon Sep 17 00:00:00 2001 From: kghc4232 Date: Wed, 26 Nov 2025 19:00:44 +0900 Subject: [PATCH 11/11] =?UTF-8?q?=EA=B2=8C=EC=8B=9C=EA=B8=80=20=EC=88=98?= =?UTF-8?q?=EC=A0=95=20=EB=B0=8F=20=EA=B2=8C=EC=8B=9C=EA=B8=80=20=EC=82=AD?= =?UTF-8?q?=EC=A0=9C=20=EA=B8=B0=EB=8A=A5=20=EC=B6=94=EA=B0=80=20=ED=9B=84?= =?UTF-8?q?=20third=20commit?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../algorithmchef/config/GeminiConfig.java | 2 +- .../controller/BoardController.java | 76 ++++++++++++++----- .../dto/board/BoardPostListResponse.java | 3 +- .../dto/board/BoardPostRequest.java | 1 - .../dto/board/CommentReplyListResponse.java | 1 + .../algorithmchef/model/BoardComment.java | 8 +- .../algorithmchef/model/BoardPost.java | 4 +- .../repository/BoardCommentRepository.java | 23 +----- .../repository/BoardPostRepository.java | 10 ++- .../algorithmchef/service/BoardService.java | 55 +++++++++----- 10 files changed, 111 insertions(+), 72 deletions(-) diff --git a/src/main/java/com/webservice/algorithmchef/config/GeminiConfig.java b/src/main/java/com/webservice/algorithmchef/config/GeminiConfig.java index 76fe084..aac8e99 100644 --- a/src/main/java/com/webservice/algorithmchef/config/GeminiConfig.java +++ b/src/main/java/com/webservice/algorithmchef/config/GeminiConfig.java @@ -13,7 +13,7 @@ public class GeminiConfig { @Bean public Client geminiClient() { - // Google Gen AI SDK V1 Client + return Client.builder() .apiKey(apiKey) .build(); diff --git a/src/main/java/com/webservice/algorithmchef/controller/BoardController.java b/src/main/java/com/webservice/algorithmchef/controller/BoardController.java index 3292c1e..2bb0913 100644 --- a/src/main/java/com/webservice/algorithmchef/controller/BoardController.java +++ b/src/main/java/com/webservice/algorithmchef/controller/BoardController.java @@ -24,10 +24,7 @@ public class BoardController { private final BoardService boardService; - /** - * 1. 게시글 목록 조회 - * GET /board/posts - */ + // 게시글 목록 조회(게시판) @GetMapping("/posts") public ResponseEntity getPostList( @RequestParam(defaultValue = "0") int page, @@ -39,10 +36,7 @@ public ResponseEntity getPostList( return ResponseEntity.ok(response); } - /** - * 2. 새 게시글 작성 - * POST /board/post - */ + // 게시글 작성 @PostMapping("/post") public ResponseEntity> createPost( @RequestBody BoardPostRequest requestDto, @@ -55,13 +49,10 @@ public ResponseEntity> createPost( boardService.createPost(requestDto, userDetails.getUsername()); return ResponseEntity.status(HttpStatus.CREATED) - .body(Map.of("message", "새 게시글 작성완료 되었습니다.")); + .body(Map.of("message", "게시글이 작성되었습니다.")); } - /** - * 3. 게시글 상세 조회 - * GET /board/post/{postId} - */ + // 게시글 조회 @GetMapping("/post/{postId}") public ResponseEntity getPostDetail( @PathVariable Long postId, @@ -77,10 +68,7 @@ public ResponseEntity getPostDetail( } } - /** - * 6. 댓글 작성 - * POST /board/post/{postId}/comment - */ + // 댓글 작성 @PostMapping("/post/{postId}/comment") public ResponseEntity> createComment( @PathVariable Long postId, @@ -100,10 +88,7 @@ public ResponseEntity> createComment( } } - /** - * 5. 대댓글 조회 - * GET /board/comments/{commentId}/replies - */ + // 대댓글 조회 @GetMapping("/comments/{commentId}/replies") public ResponseEntity getReplies( @PathVariable Long commentId, @@ -118,4 +103,53 @@ public ResponseEntity getReplies( return ResponseEntity.status(HttpStatus.NOT_FOUND).build(); } } + + // 게시글 수정 + @PutMapping("/post/{postId}") + public ResponseEntity> updatePost( + @PathVariable Long postId, + @RequestBody BoardPostRequest requestDto, + @AuthenticationPrincipal UserDetails userDetails + ) { + if (userDetails == null) { + return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build(); + // 401 Unauthorized + } + + try { + // userDetails.getUsername()은 JWT에서 추출한 userId(String) + boardService.updatePost(postId, requestDto, userDetails.getUsername()); + + return ResponseEntity.ok(Map.of("message", "게시글이 수정되었습니다.")); + } catch (IllegalArgumentException e) { + // 게시글이 없거나, 권한이 없는 경우 + return ResponseEntity.badRequest().body(Map.of("error", e.getMessage())); + } catch (Exception e) { + log.error("게시글 수정 오류", e); + return ResponseEntity.internalServerError().body(Map.of("error", "서버 오류가 발생했습니다.")); + } + } + + // 게시글 삭제 + @DeleteMapping("/post/{postId}") + public ResponseEntity> deletePost( + @PathVariable Long postId, + @AuthenticationPrincipal UserDetails userDetails + ) { + if (userDetails == null) { + return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build(); + } + + try { + boardService.deletePost(postId, userDetails.getUsername()); + + return ResponseEntity.ok(Map.of("message", "게시글이 삭제되었습니다.")); + } catch (IllegalArgumentException e) { + // 게시글이 없거나, 권한이 없는 경우 + return ResponseEntity.badRequest().body(Map.of("error", e.getMessage())); + } catch (Exception e) { + log.error("게시글 삭제 오류", e); + return ResponseEntity.internalServerError().body(Map.of("error", "서버 오류가 발생했습니다.")); + } + } } \ No newline at end of file diff --git a/src/main/java/com/webservice/algorithmchef/dto/board/BoardPostListResponse.java b/src/main/java/com/webservice/algorithmchef/dto/board/BoardPostListResponse.java index 8a03819..78e53cc 100644 --- a/src/main/java/com/webservice/algorithmchef/dto/board/BoardPostListResponse.java +++ b/src/main/java/com/webservice/algorithmchef/dto/board/BoardPostListResponse.java @@ -13,8 +13,9 @@ @NoArgsConstructor @AllArgsConstructor @Builder +// 게시글 목록 반환 객체 public class BoardPostListResponse { - // 목록 조회 시 반환할 래퍼 클래스 + // 목록 조회 시 반환할 Wrapper 클래스 private List posts; private PageInfo pageInfo; diff --git a/src/main/java/com/webservice/algorithmchef/dto/board/BoardPostRequest.java b/src/main/java/com/webservice/algorithmchef/dto/board/BoardPostRequest.java index 2fa0329..cfac6e2 100644 --- a/src/main/java/com/webservice/algorithmchef/dto/board/BoardPostRequest.java +++ b/src/main/java/com/webservice/algorithmchef/dto/board/BoardPostRequest.java @@ -20,5 +20,4 @@ public class BoardPostRequest { // 게시글 내용 (DB에서는 BLOB이지만, JSON 전송 시 String으로 처리) private String content; - // 참고: user_id는 보통 Controller에서 @AuthenticationPrincipal을 통해 주입받으므로 DTO에서 제외합니다. } diff --git a/src/main/java/com/webservice/algorithmchef/dto/board/CommentReplyListResponse.java b/src/main/java/com/webservice/algorithmchef/dto/board/CommentReplyListResponse.java index 6955129..93b6685 100644 --- a/src/main/java/com/webservice/algorithmchef/dto/board/CommentReplyListResponse.java +++ b/src/main/java/com/webservice/algorithmchef/dto/board/CommentReplyListResponse.java @@ -12,6 +12,7 @@ @NoArgsConstructor @AllArgsConstructor @Builder +// 대댓글 목록 반환 객체 public class CommentReplyListResponse { private List replies; private PageInfo pageInfo; diff --git a/src/main/java/com/webservice/algorithmchef/model/BoardComment.java b/src/main/java/com/webservice/algorithmchef/model/BoardComment.java index 27a3a27..23fe8cf 100644 --- a/src/main/java/com/webservice/algorithmchef/model/BoardComment.java +++ b/src/main/java/com/webservice/algorithmchef/model/BoardComment.java @@ -31,7 +31,7 @@ public class BoardComment { @JoinColumn(name = "post_id", nullable = false) private BoardPost post; - // 댓글 작성자 (제공해주신 User 엔티티와 연결) + // 댓글 작성자 @ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name = "user_id", nullable = false) private User user; @@ -54,17 +54,15 @@ public class BoardComment { @Column(nullable = false, columnDefinition = "LONGTEXT") private String content; - // 삭제 여부 (Soft Delete) + // 삭제 여부 @Column(nullable = false) @ColumnDefault("false") private boolean isDeleted; - // 생성 시간 (Hibernate) @CreationTimestamp @Column(name = "created_at", updatable = false) private LocalDateTime createdAt; - // 수정 시간 (Hibernate) @UpdateTimestamp @Column(name = "modified_date") private LocalDateTime modifiedDate; @@ -74,7 +72,7 @@ public void updateContent(String content) { this.content = content; } - // 댓글 삭제 처리 (상태값 변경) + // 댓글 삭제 처리 public void changeIsDeleted(boolean isDeleted) { this.isDeleted = isDeleted; } diff --git a/src/main/java/com/webservice/algorithmchef/model/BoardPost.java b/src/main/java/com/webservice/algorithmchef/model/BoardPost.java index a417a9e..c2e557f 100644 --- a/src/main/java/com/webservice/algorithmchef/model/BoardPost.java +++ b/src/main/java/com/webservice/algorithmchef/model/BoardPost.java @@ -47,17 +47,15 @@ public class BoardPost { @Builder.Default private List comments = new ArrayList<>(); - // 생성 시간 (Hibernate) @CreationTimestamp @Column(name = "created_at", updatable = false) private LocalDateTime createdAt; - // 수정 시간 (Hibernate) @UpdateTimestamp @Column(name = "modified_date") private LocalDateTime modifiedDate; - // 게시글 수정 메서드 (Dirty Checking 용) + // 게시글 수정 메서드 (Dirty Checking) public void update(String title, String content, String category) { this.title = title; this.content = content; diff --git a/src/main/java/com/webservice/algorithmchef/repository/BoardCommentRepository.java b/src/main/java/com/webservice/algorithmchef/repository/BoardCommentRepository.java index 1e5f510..61903bd 100644 --- a/src/main/java/com/webservice/algorithmchef/repository/BoardCommentRepository.java +++ b/src/main/java/com/webservice/algorithmchef/repository/BoardCommentRepository.java @@ -13,20 +13,10 @@ @Repository public interface BoardCommentRepository extends JpaRepository { - /** - * 특정 게시글(PostId)에 달린 모든 댓글을 조회합니다. - * 대댓글 계층 구조를 서비스 로직에서 조립하기 위해, - * 부모/자식 관계없이 해당 글의 모든 댓글을 작성일 순서(오래된 순)로 가져옵니다. - * - * @param postId 조회할 게시글의 ID - * @return 해당 게시글의 댓글 리스트 - */ + // 해당 postId에 달린 모든 댓글 조회 List findByPost_PostIdOrderByCreatedAtAsc(Long postId); - /** - * 특정 게시글의 '최상위 댓글(부모가 없는 댓글)'만 페이징 조회합니다. - * 게시글 상세 조회 시 댓글 목록 페이징에 사용됩니다. - */ + // 최상위 부모 댓글만 조회 Page findByPost_PostIdAndParentCommentIsNull(Long postId, Pageable pageable); /** @@ -36,13 +26,6 @@ public interface BoardCommentRepository extends JpaRepository findCommentsByPostIdWithUser(@Param("postId") Long postId); - /** - * 특정 부모 댓글의 자식 댓글(대댓글)을 페이징하여 조회합니다. - * 대댓글 더보기 기능 등에 사용됩니다. - * * @param parentCommentId 부모 댓글 ID (CommentId) - * - * @param pageable 페이징 정보 - * @return 대댓글 Page 객체 - */ + // 대댓글 페이징 및 조회 Page findByParentComment_CommentId(Long parentCommentId, Pageable pageable); } diff --git a/src/main/java/com/webservice/algorithmchef/repository/BoardPostRepository.java b/src/main/java/com/webservice/algorithmchef/repository/BoardPostRepository.java index b7b84ce..185807c 100644 --- a/src/main/java/com/webservice/algorithmchef/repository/BoardPostRepository.java +++ b/src/main/java/com/webservice/algorithmchef/repository/BoardPostRepository.java @@ -3,13 +3,17 @@ import com.webservice.algorithmchef.model.BoardPost; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.EntityGraph; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.stereotype.Repository; @Repository public interface BoardPostRepository extends JpaRepository { - // 카테고리 필터링 + 페이징 - Page findByCategory(String category, Pageable pageable); - // 전체 페이징 (필터 없을 때) - findAll(Pageable)은 JpaRepository에 기본 내장됨 + @EntityGraph(attributePaths = {"user"}) + Page findAll(Pageable pageable); + + // 카테고리별 조회 + @EntityGraph(attributePaths = {"user"}) + Page findByCategory(String category, Pageable pageable); } diff --git a/src/main/java/com/webservice/algorithmchef/service/BoardService.java b/src/main/java/com/webservice/algorithmchef/service/BoardService.java index 50034a1..6c70c0d 100644 --- a/src/main/java/com/webservice/algorithmchef/service/BoardService.java +++ b/src/main/java/com/webservice/algorithmchef/service/BoardService.java @@ -28,9 +28,7 @@ public class BoardService { private final BoardCommentRepository boardCommentRepository; private final UserRepository userRepository; - /** - * 1. 게시글 목록 조회 - */ + // 게시글 목록 조회(게시판) @Transactional(readOnly = true) public BoardPostListResponse getPostList(int page, int size, String sortStr, String filter) { Pageable pageable = createPageable(page, size, sortStr); @@ -54,9 +52,7 @@ public BoardPostListResponse getPostList(int page, int size, String sortStr, Str .build(); } - /** - * 2. 새 게시글 작성 - */ + // 게시글 작성 @Transactional public void createPost(BoardPostRequest requestDto, String userId) { User user = userRepository.findByUserId(userId) @@ -72,9 +68,7 @@ public void createPost(BoardPostRequest requestDto, String userId) { boardPostRepository.save(post); } - /** - * 3. 게시글 상세 조회 (댓글 페이징 포함) - */ + // 게시글 조회 @Transactional(readOnly = true) public BoardPostResponse getPostDetail(Long postId, int page, int size, String sortStr) { BoardPost post = boardPostRepository.findById(postId) @@ -93,9 +87,7 @@ public BoardPostResponse getPostDetail(Long postId, int page, int size, String s return BoardPostResponse.of(post, commentDtos, pageInfo); } - /** - * 6. 댓글 작성 - */ + // 댓글 작성 @Transactional public void createComment(Long postId, BoardCommentRequest requestDto, String userId) { User user = userRepository.findByUserId(userId) @@ -126,12 +118,10 @@ public void createComment(Long postId, BoardCommentRequest requestDto, String us boardCommentRepository.save(comment); } - /** - * 5. 대댓글 조회 - */ + // 대댓글 조회 @Transactional(readOnly = true) public CommentReplyListResponse getReplies(Long commentId, int page, int size, String sortStr) { - // 부모 댓글 존재 여부 확인 (선택사항) + // 조회 전, 부모 댓글 존재 여부 확인 if (!boardCommentRepository.existsById(commentId)) { throw new IllegalArgumentException("해당 댓글이 존재하지 않습니다. commentId=" + commentId); } @@ -139,7 +129,6 @@ public CommentReplyListResponse getReplies(Long commentId, int page, int size, S Pageable pageable = createPageable(page, size, sortStr); // 부모 댓글 ID를 기준으로 자식 댓글 페이징 조회 - // Repository에 findByParentComment_CommentId 메서드가 필요하다고 가정합니다. Page replyPage = boardCommentRepository.findByParentComment_CommentId(commentId, pageable); List replyDtos = replyPage.getContent().stream() @@ -154,6 +143,38 @@ public CommentReplyListResponse getReplies(Long commentId, int page, int size, S .build(); } + // 게시글 수정 + @Transactional + public void updatePost(Long postId, BoardPostRequest requestDto, String currentUserId) { + // 1. 게시글 조회 + BoardPost post = boardPostRepository.findById(postId) + .orElseThrow(() -> new IllegalArgumentException("해당 게시글이 존재하지 않습니다. postId=" + postId)); + + // 2. 작성자 검증 (게시글 작성자 ID vs 현재 로그인한 유저 ID) + if (!post.getUser().getUserId().equals(currentUserId)) { + throw new IllegalArgumentException("게시글 수정 권한이 없습니다. 본인이 작성한 글만 수정 가능합니다."); + } + + // 3. 게시글 업데이트 (JPA Dirty Checking) + post.update(requestDto.getTitle(), requestDto.getContent(), requestDto.getCategory()); + } + + // 게시글 삭제 + @Transactional + public void deletePost(Long postId, String currentUserId) { + // 1. 게시글 조회 + BoardPost post = boardPostRepository.findById(postId) + .orElseThrow(() -> new IllegalArgumentException("해당 게시글이 존재하지 않습니다. postId=" + postId)); + + // 2. 작성자 검증 + if (!post.getUser().getUserId().equals(currentUserId)) { + throw new IllegalArgumentException("본인이 작성한 글만 삭제 가능합니다."); + } + + // 3. 게시글 삭제 (Cascade 옵션으로 인해 댓글도 자동 삭제됨) + boardPostRepository.delete(post); + } + // Pageable 생성 헬퍼 메서드 private Pageable createPageable(int page, int size, String sortStr) { Sort sort = Sort.by(Sort.Direction.DESC, "createdAt"); // 기본값