Skip to content

Commit 95899bb

Browse files
authored
Merge pull request #9 from hck2025/feature/gemini+stt+board
fork 레포지토리로부터 원본에 브랜치 pull request
2 parents a2851e8 + 123496a commit 95899bb

23 files changed

Lines changed: 1206 additions & 79 deletions

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,4 +36,5 @@ out/
3636
### VS Code ###
3737
.vscode/
3838

39+
### api key ###
3940
src/main/resources/application.properties

README.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
gemini 호출과 응답 코드
2+
3+
gitignore에 application.properties 포함되어 있음
4+
application.properties에 api 키 작성되어 있음
5+

build.gradle

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,13 +33,16 @@ dependencies {
3333
implementation 'io.jsonwebtoken:jjwt-api:0.11.5'
3434
compileOnly 'org.projectlombok:lombok'
3535
developmentOnly 'org.springframework.boot:spring-boot-devtools'
36+
implementation 'com.google.cloud:google-cloud-speech:4.40.0' // google stt
3637
runtimeOnly 'com.mysql:mysql-connector-j'
3738
runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.11.5'
3839
runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.11.5'
3940
annotationProcessor 'org.projectlombok:lombok'
4041
testImplementation 'org.springframework.boot:spring-boot-starter-test'
4142
testImplementation 'org.springframework.security:spring-security-test'
4243
testRuntimeOnly 'org.junit.platform:junit-platform-launcher'
44+
implementation 'com.google.genai:google-genai:1.0.0' // gemini api
45+
implementation 'org.springframework.boot:spring-boot-starter-webflux' // gemini 검색기능
4346
}
4447

4548
tasks.named('test') {
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
package com.webservice.algorithmchef.config;
2+
3+
import com.google.genai.Client;
4+
import org.springframework.beans.factory.annotation.Value;
5+
import org.springframework.context.annotation.Bean;
6+
import org.springframework.context.annotation.Configuration;
7+
8+
@Configuration
9+
public class GeminiConfig {
10+
11+
@Value("${gemini.api.key}")
12+
private String apiKey;
13+
14+
@Bean
15+
public Client geminiClient() {
16+
17+
return Client.builder()
18+
.apiKey(apiKey)
19+
.build();
20+
}
21+
}
Lines changed: 155 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,155 @@
1+
package com.webservice.algorithmchef.controller;
2+
3+
import com.webservice.algorithmchef.dto.board.BoardCommentRequest;
4+
import com.webservice.algorithmchef.dto.board.BoardPostResponse;
5+
import com.webservice.algorithmchef.dto.board.BoardPostListResponse;
6+
import com.webservice.algorithmchef.dto.board.BoardPostRequest;
7+
import com.webservice.algorithmchef.dto.board.CommentReplyListResponse;
8+
import com.webservice.algorithmchef.service.BoardService;
9+
import lombok.RequiredArgsConstructor;
10+
import lombok.extern.slf4j.Slf4j;
11+
import org.springframework.http.HttpStatus;
12+
import org.springframework.http.ResponseEntity;
13+
import org.springframework.security.core.annotation.AuthenticationPrincipal;
14+
import org.springframework.security.core.userdetails.UserDetails;
15+
import org.springframework.web.bind.annotation.*;
16+
17+
import java.util.Map;
18+
19+
@Slf4j
20+
@RestController
21+
@RequestMapping("/board")
22+
@RequiredArgsConstructor
23+
public class BoardController {
24+
25+
private final BoardService boardService;
26+
27+
// 게시글 목록 조회(게시판)
28+
@GetMapping("/posts")
29+
public ResponseEntity<BoardPostListResponse> getPostList(
30+
@RequestParam(defaultValue = "0") int page,
31+
@RequestParam(defaultValue = "20") int size,
32+
@RequestParam(defaultValue = "createdAt,desc") String sort,
33+
@RequestParam(required = false) String filter
34+
) {
35+
BoardPostListResponse response = boardService.getPostList(page, size, sort, filter);
36+
return ResponseEntity.ok(response);
37+
}
38+
39+
// 게시글 작성
40+
@PostMapping("/post")
41+
public ResponseEntity<Map<String, String>> createPost(
42+
@RequestBody BoardPostRequest requestDto,
43+
@AuthenticationPrincipal UserDetails userDetails
44+
) {
45+
if (userDetails == null) {
46+
return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build();
47+
}
48+
49+
boardService.createPost(requestDto, userDetails.getUsername());
50+
51+
return ResponseEntity.status(HttpStatus.CREATED)
52+
.body(Map.of("message", "게시글이 작성되었습니다."));
53+
}
54+
55+
// 게시글 조회
56+
@GetMapping("/post/{postId}")
57+
public ResponseEntity<BoardPostResponse> getPostDetail(
58+
@PathVariable Long postId,
59+
@RequestParam(defaultValue = "0") int page,
60+
@RequestParam(defaultValue = "20") int size,
61+
@RequestParam(defaultValue = "createdAt,asc") String sort
62+
) {
63+
try {
64+
BoardPostResponse response = boardService.getPostDetail(postId, page, size, sort);
65+
return ResponseEntity.ok(response);
66+
} catch (IllegalArgumentException e) {
67+
return ResponseEntity.status(HttpStatus.NOT_FOUND).build();
68+
}
69+
}
70+
71+
// 댓글 작성
72+
@PostMapping("/post/{postId}/comment")
73+
public ResponseEntity<Map<String, String>> createComment(
74+
@PathVariable Long postId,
75+
@RequestBody BoardCommentRequest requestDto,
76+
@AuthenticationPrincipal UserDetails userDetails
77+
) {
78+
if (userDetails == null) {
79+
return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build();
80+
}
81+
82+
try {
83+
boardService.createComment(postId, requestDto, userDetails.getUsername());
84+
return ResponseEntity.status(HttpStatus.CREATED)
85+
.body(Map.of("message", "댓글 작성완료"));
86+
} catch (IllegalArgumentException e) {
87+
return ResponseEntity.badRequest().body(Map.of("error", e.getMessage()));
88+
}
89+
}
90+
91+
// 대댓글 조회
92+
@GetMapping("/comments/{commentId}/replies")
93+
public ResponseEntity<CommentReplyListResponse> getReplies(
94+
@PathVariable Long commentId,
95+
@RequestParam(defaultValue = "0") int page,
96+
@RequestParam(defaultValue = "20") int size,
97+
@RequestParam(defaultValue = "createdAt,asc") String sort
98+
) {
99+
try {
100+
CommentReplyListResponse response = boardService.getReplies(commentId, page, size, sort);
101+
return ResponseEntity.ok(response);
102+
} catch (IllegalArgumentException e) {
103+
return ResponseEntity.status(HttpStatus.NOT_FOUND).build();
104+
}
105+
}
106+
107+
// 게시글 수정
108+
@PutMapping("/post/{postId}")
109+
public ResponseEntity<Map<String, String>> updatePost(
110+
@PathVariable Long postId,
111+
@RequestBody BoardPostRequest requestDto,
112+
@AuthenticationPrincipal UserDetails userDetails
113+
) {
114+
if (userDetails == null) {
115+
return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build();
116+
// 401 Unauthorized
117+
}
118+
119+
try {
120+
// userDetails.getUsername()은 JWT에서 추출한 userId(String)
121+
boardService.updatePost(postId, requestDto, userDetails.getUsername());
122+
123+
return ResponseEntity.ok(Map.of("message", "게시글이 수정되었습니다."));
124+
} catch (IllegalArgumentException e) {
125+
// 게시글이 없거나, 권한이 없는 경우
126+
return ResponseEntity.badRequest().body(Map.of("error", e.getMessage()));
127+
} catch (Exception e) {
128+
log.error("게시글 수정 오류", e);
129+
return ResponseEntity.internalServerError().body(Map.of("error", "서버 오류가 발생했습니다."));
130+
}
131+
}
132+
133+
// 게시글 삭제
134+
@DeleteMapping("/post/{postId}")
135+
public ResponseEntity<Map<String, String>> deletePost(
136+
@PathVariable Long postId,
137+
@AuthenticationPrincipal UserDetails userDetails
138+
) {
139+
if (userDetails == null) {
140+
return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build();
141+
}
142+
143+
try {
144+
boardService.deletePost(postId, userDetails.getUsername());
145+
146+
return ResponseEntity.ok(Map.of("message", "게시글이 삭제되었습니다."));
147+
} catch (IllegalArgumentException e) {
148+
// 게시글이 없거나, 권한이 없는 경우
149+
return ResponseEntity.badRequest().body(Map.of("error", e.getMessage()));
150+
} catch (Exception e) {
151+
log.error("게시글 삭제 오류", e);
152+
return ResponseEntity.internalServerError().body(Map.of("error", "서버 오류가 발생했습니다."));
153+
}
154+
}
155+
}
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
package com.webservice.algorithmchef.controller;
2+
3+
import com.webservice.algorithmchef.dto.gemini.GeminiRecipeRequest.*;
4+
import com.webservice.algorithmchef.dto.gemini.GeminiRecipeResponse;
5+
import com.webservice.algorithmchef.service.GeminiRecipeService;
6+
import lombok.RequiredArgsConstructor;
7+
import lombok.extern.slf4j.Slf4j;
8+
import org.springframework.http.ResponseEntity;
9+
import org.springframework.web.bind.annotation.PostMapping;
10+
import org.springframework.web.bind.annotation.RequestBody;
11+
import org.springframework.web.bind.annotation.RequestMapping;
12+
import org.springframework.web.bind.annotation.RestController;
13+
14+
import java.util.List;
15+
16+
@Slf4j
17+
@RestController
18+
@RequestMapping("/recipes/recommend")
19+
@RequiredArgsConstructor
20+
public class GeminiRecipeController {
21+
22+
private final GeminiRecipeService recipeService;
23+
24+
// 1. 유저 성향 및 식습관 기반 추천 (요청주소 /recipes/recommend/prefer)
25+
@PostMapping("/prefer")
26+
public ResponseEntity<List<GeminiRecipeResponse>> recommendPrefer(@RequestBody PreferRequest request) {
27+
log.info("성향 추천 요청 - UserPkId: {}", request.userPkId());
28+
return ResponseEntity.ok(
29+
recipeService.recommendPrefer(request.userPkId(), request.excludedTitles())
30+
);
31+
}
32+
33+
// 2. speech to text 조건 기반 추천 (요청주소 /recipes/recommend/condition)
34+
@PostMapping("/condition")
35+
public ResponseEntity<List<GeminiRecipeResponse>> recommendCondition(@RequestBody ConditionRequest request) {
36+
log.info("조건 추천 요청 - UserPkId: {}, Condition: {}", request.userPkId(), request.condition());
37+
return ResponseEntity.ok(
38+
recipeService.recommendCondition(request.userPkId(), request.condition(), request.excludedTitles())
39+
);
40+
}
41+
42+
// 3. 임박재료 기반 추천 (요청주소 /recipes/recommend/expir)
43+
@PostMapping("/expir")
44+
public ResponseEntity<List<GeminiRecipeResponse>> recommendExpir(@RequestBody ExpirRequest request) {
45+
log.info("재료 추천 요청 - UserPkId: {}", request.userPkId());
46+
return ResponseEntity.ok(
47+
recipeService.recommendExpir(request.userPkId(), request.ingredients(), request.excludedTitles())
48+
);
49+
}
50+
}
Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
package com.webservice.algorithmchef.controller;
2+
3+
import com.webservice.algorithmchef.dto.gemini.GeminiRecipeResponse;
4+
import com.webservice.algorithmchef.service.GeminiRecipeService;
5+
import com.webservice.algorithmchef.service.SpeechService;
6+
import lombok.RequiredArgsConstructor;
7+
import lombok.extern.slf4j.Slf4j;
8+
import org.springframework.http.HttpStatus;
9+
import org.springframework.http.ResponseEntity;
10+
import org.springframework.web.bind.annotation.*;
11+
import org.springframework.web.multipart.MultipartFile;
12+
13+
import java.util.Collections;
14+
import java.util.List;
15+
16+
@Slf4j
17+
@RestController
18+
@RequestMapping("/api")
19+
@CrossOrigin(origins = "http://localhost:3000")
20+
@RequiredArgsConstructor
21+
public class SttController {
22+
23+
private final SpeechService speechService;
24+
private final GeminiRecipeService geminiRecipeService; // 레시피 서비스 객체
25+
26+
@PostMapping("/stt")
27+
public ResponseEntity<?> stt(
28+
@RequestParam("audio") MultipartFile audioFile,
29+
@RequestParam("userId") String userIdStr
30+
) {
31+
log.info("음성 기반 레시피 추천 요청 - UserId: {}, 파일크기: {} bytes", userIdStr, audioFile.getSize());
32+
33+
try {
34+
// 1. 음성 -> 텍스트 변환 (STT)
35+
String convertedText = speechService.stt(audioFile);
36+
37+
if (convertedText == null || convertedText.isEmpty()) {
38+
log.warn("STT 변환 실패: 음성 인식 결과 없음");
39+
return ResponseEntity.badRequest().body("음성을 인식하지 못했습니다. 다시 말씀해 주세요.");
40+
}
41+
42+
log.info("STT 변환 결과: \"{}\"", convertedText);
43+
44+
// userId String -> Long 변환
45+
Long userId = Long.parseLong(userIdStr);
46+
47+
// 음성 검색은 보통 '새로운 검색'이므로 excludedTitles는 빈 리스트로 전달
48+
List<String> excludedTitles = Collections.emptyList();
49+
50+
// recipeservice 호출
51+
List<GeminiRecipeResponse> recipes = geminiRecipeService.recommendCondition(
52+
userId,
53+
convertedText,
54+
excludedTitles
55+
);
56+
57+
log.info("음성 추천 완료 - {}개의 레시피 반환", recipes.size());
58+
59+
// 3. 레시피 리스트 반환
60+
return ResponseEntity.ok(recipes);
61+
62+
} catch (NumberFormatException e) {
63+
log.error("UserId 형식 오류: {}", userIdStr, e);
64+
return ResponseEntity.badRequest().body("유효하지 않은 사용자 ID입니다.");
65+
} catch (Exception e) {
66+
log.error("음성 레시피 추천 중 오류 발생", e);
67+
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
68+
.body("처리 중 서버 오류가 발생했습니다: " + e.getMessage());
69+
}
70+
}
71+
}
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
package com.webservice.algorithmchef.dto.board;
2+
3+
import lombok.AllArgsConstructor;
4+
import lombok.Builder;
5+
import lombok.Getter;
6+
import lombok.NoArgsConstructor;
7+
8+
@Getter
9+
@NoArgsConstructor
10+
@AllArgsConstructor
11+
@Builder
12+
public class BoardCommentRequest {
13+
14+
// 댓글이 달릴 게시글 ID
15+
private Long postId;
16+
17+
// 부모 댓글 ID (대댓글일 경우 필수, 최상위 댓글이면 null)
18+
private Long parentCommentId;
19+
20+
// 댓글 내용
21+
private String content;
22+
}

0 commit comments

Comments
 (0)