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/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 키 작성되어 있음 + diff --git a/build.gradle b/build.gradle index 6b88ec9..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' @@ -40,6 +41,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..aac8e99 --- /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() { + + 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 new file mode 100644 index 0000000..2bb0913 --- /dev/null +++ b/src/main/java/com/webservice/algorithmchef/controller/BoardController.java @@ -0,0 +1,155 @@ +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; + + // 게시글 목록 조회(게시판) + @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); + } + + // 게시글 작성 + @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", "게시글이 작성되었습니다.")); + } + + // 게시글 조회 + @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(); + } + } + + // 댓글 작성 + @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())); + } + } + + // 대댓글 조회 + @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(); + } + } + + // 게시글 수정 + @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/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/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/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/BoardPostListResponse.java b/src/main/java/com/webservice/algorithmchef/dto/board/BoardPostListResponse.java new file mode 100644 index 0000000..78e53cc --- /dev/null +++ b/src/main/java/com/webservice/algorithmchef/dto/board/BoardPostListResponse.java @@ -0,0 +1,43 @@ +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 { + // 목록 조회 시 반환할 Wrapper 클래스 + 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/BoardPostRequest.java b/src/main/java/com/webservice/algorithmchef/dto/board/BoardPostRequest.java new file mode 100644 index 0000000..cfac6e2 --- /dev/null +++ b/src/main/java/com/webservice/algorithmchef/dto/board/BoardPostRequest.java @@ -0,0 +1,23 @@ +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; + +} 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..477bd17 --- /dev/null +++ b/src/main/java/com/webservice/algorithmchef/dto/board/BoardPostResponse.java @@ -0,0 +1,67 @@ +package com.webservice.algorithmchef.dto.board; + +import com.webservice.algorithmchef.model.BoardComment; +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 String userId; + private String category; + private String title; + private String content; + private LocalDateTime createdAt; + 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; + + 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(); + } + } + + public static BoardPostResponse of(BoardPost post, List comments, PageInfo pageInfo) { + return BoardPostResponse.builder() + .postId(post.getPostId()) + .userId(post.getUser().getUserId()) + .category(post.getCategory()) + .title(post.getTitle()) + .content(post.getContent()) + .createdAt(post.getCreatedAt()) + .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..93b6685 --- /dev/null +++ b/src/main/java/com/webservice/algorithmchef/dto/board/CommentReplyListResponse.java @@ -0,0 +1,19 @@ +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/dto/gemini/GeminiRecipeRequest.java b/src/main/java/com/webservice/algorithmchef/dto/gemini/GeminiRecipeRequest.java new file mode 100644 index 0000000..c9ea517 --- /dev/null +++ b/src/main/java/com/webservice/algorithmchef/dto/gemini/GeminiRecipeRequest.java @@ -0,0 +1,28 @@ +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 + // client의 첫 요청시 string 리스트는 비어있음. + // 재요청 시 front에서 요리명만 해당 리스트에 담아 전송. 그러면 gemini는 해당 레시피는 제외하여 탐색. + ) {} + + // 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..9c7d104 --- /dev/null +++ b/src/main/java/com/webservice/algorithmchef/dto/gemini/GeminiRecipeResponse.java @@ -0,0 +1,16 @@ +package com.webservice.algorithmchef.dto.gemini; + +import java.util.List; + +public record GeminiRecipeResponse ( + String name, + String description, + double kcal, + String portions, + String time, + String imageUrl, + String ingredients, + String instructions, + String tip, + String type +) {} diff --git a/src/main/java/com/webservice/algorithmchef/model/BoardComment.java b/src/main/java/com/webservice/algorithmchef/model/BoardComment.java index 9659cc1..23fe8cf 100644 --- a/src/main/java/com/webservice/algorithmchef/model/BoardComment.java +++ b/src/main/java/com/webservice/algorithmchef/model/BoardComment.java @@ -1,71 +1,79 @@ 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; + + // 댓글 작성자 @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; + + // 삭제 여부 + @Column(nullable = false) + @ColumnDefault("false") + private boolean isDeleted; + @CreationTimestamp + @Column(name = "created_at", updatable = false) private LocalDateTime createdAt; - + + @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..c2e557f 100644 --- a/src/main/java/com/webservice/algorithmchef/model/BoardPost.java +++ b/src/main/java/com/webservice/algorithmchef/model/BoardPost.java @@ -1,55 +1,64 @@ 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<>(); + @CreationTimestamp + @Column(name = "created_at", updatable = false) private LocalDateTime createdAt; - + + @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..61903bd --- /dev/null +++ b/src/main/java/com/webservice/algorithmchef/repository/BoardCommentRepository.java @@ -0,0 +1,31 @@ +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; +import org.springframework.stereotype.Repository; + +import java.util.List; + +@Repository +public interface BoardCommentRepository extends JpaRepository { + + // 해당 postId에 달린 모든 댓글 조회 + List findByPost_PostIdOrderByCreatedAtAsc(Long postId); + + // 최상위 부모 댓글만 조회 + Page findByPost_PostIdAndParentCommentIsNull(Long postId, Pageable pageable); + + /** + * (성능 최적화용) 게시글의 댓글을 가져올 때 작성자(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); + + // 대댓글 페이징 및 조회 + 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 new file mode 100644 index 0000000..185807c --- /dev/null +++ b/src/main/java/com/webservice/algorithmchef/repository/BoardPostRepository.java @@ -0,0 +1,19 @@ +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.EntityGraph; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +@Repository +public interface BoardPostRepository extends 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 new file mode 100644 index 0000000..6c70c0d --- /dev/null +++ b/src/main/java/com/webservice/algorithmchef/service/BoardService.java @@ -0,0 +1,194 @@ +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; + + // 게시글 목록 조회(게시판) + @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(); + } + + // 게시글 작성 + @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); + } + + // 게시글 조회 + @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); + } + + // 댓글 작성 + @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); + } + + // 대댓글 조회 + @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를 기준으로 자식 댓글 페이징 조회 + 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(); + } + + // 게시글 수정 + @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"); // 기본값 + + 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); + } +} 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..ed0a691 --- /dev/null +++ b/src/main/java/com/webservice/algorithmchef/service/GeminiRecipeService.java @@ -0,0 +1,207 @@ +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. 사용자의 건강 목표에 반하는 레시피도 절대 추천하지 마. + 4. 추가 구매가 필요한 재료를 최소화해줘. + """, 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 배열만 반환해. 찾을 수 없는 항목은 자료형에 맞게 0.0 혹은 "정보 없음"으로 반환해. + [ + { + "name": "요리명", + "description": "요리에 대한 간단한 개요" + "kcal": 실수, 소숫점 첫째자리까지(예시: 365.0) + "portions": "몇 인분" + "time": "예상 조리 시간", + "imageUrl" : "완성된 요리 사진 url 주소" + "ingredients": "재료1, 재료2, 재료3", + "instructions": "1. ..., 2. ..., 3. ...", + "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 응답 실패"); + } +} 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); + } + } +}