Skip to content

Commit f3e72c5

Browse files
authored
✨ feat: Fast API와 연동
✨ feat: Fast API와 연동
2 parents 04b367d + 2a98e96 commit f3e72c5

File tree

19 files changed

+442
-16
lines changed

19 files changed

+442
-16
lines changed
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
package com.example.Centralthon.domain.menu.entity.enums;
2+
3+
import com.fasterxml.jackson.annotation.JsonCreator;
4+
import com.fasterxml.jackson.annotation.JsonValue;
5+
import lombok.Getter;
6+
import lombok.RequiredArgsConstructor;
7+
8+
@Getter
9+
@RequiredArgsConstructor
10+
public enum Concept {
11+
DIET("diet"),
12+
KETO("keto"),
13+
LOW_SODIUM("low_sodium"),
14+
GLYCEMIC("glycemic"),
15+
BULKING("bulking");
16+
17+
private final String value;
18+
19+
@JsonValue
20+
public String getValue(){
21+
return value;
22+
}
23+
24+
@JsonCreator
25+
public static Concept fromValue(String value){
26+
for (Concept concept : Concept.values()){
27+
if (concept.getValue().equals(value)){
28+
return concept;
29+
}
30+
}
31+
throw new IllegalArgumentException();
32+
}
33+
}

src/main/java/com/example/Centralthon/domain/menu/service/MenuService.java

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,10 @@
33
import com.example.Centralthon.domain.menu.web.dto.*;
44
import com.example.Centralthon.domain.menu.web.dto.NearbyMenusRes;
55
import com.example.Centralthon.domain.menu.web.dto.StoresByMenuRes;
6+
import com.example.Centralthon.domain.menu.web.dto.GetRecommendedMenusReq;
7+
import com.example.Centralthon.global.external.ai.web.dto.GetTipReq;
8+
import com.example.Centralthon.global.external.ai.web.dto.GetTipRes;
9+
610
import java.util.List;
711

812
public interface MenuService {
@@ -11,4 +15,8 @@ public interface MenuService {
1115
List<StoresByMenuRes> storesByMenu(String name, double lat, double lng);
1216

1317
List<MenuDetailsRes> details(MenuIdsReq menus);
18+
19+
List<NearbyMenusRes> getRecommendedMenus(GetRecommendedMenusReq getRecommendedMenusReq);
20+
21+
List<GetTipRes> getTips(GetTipReq getTipReq);
1422
}

src/main/java/com/example/Centralthon/domain/menu/service/MenuServiceImpl.java

Lines changed: 50 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -4,28 +4,31 @@
44
import com.example.Centralthon.domain.menu.exception.MenuNotFoundException;
55
import com.example.Centralthon.domain.menu.repository.MenuRepository;
66
import com.example.Centralthon.domain.menu.web.dto.*;
7-
import com.example.Centralthon.domain.store.entity.Store;
7+
import com.example.Centralthon.domain.menu.web.dto.GetRecommendedMenusReq;
8+
import com.example.Centralthon.global.external.ai.service.AiService;
9+
import com.example.Centralthon.global.external.ai.web.dto.GetMenusByConceptReq;
10+
import com.example.Centralthon.global.external.ai.web.dto.GetMenusByConceptRes;
11+
import com.example.Centralthon.global.external.ai.web.dto.GetTipReq;
12+
import com.example.Centralthon.global.external.ai.web.dto.GetTipRes;
13+
import com.example.Centralthon.global.external.exception.AiCommunicationFailedException;
814
import com.example.Centralthon.global.util.geo.BoundingBox;
915
import com.example.Centralthon.global.util.geo.GeoUtils;
1016
import lombok.RequiredArgsConstructor;
17+
import lombok.extern.slf4j.Slf4j;
1118
import org.springframework.stereotype.Service;
1219
import org.springframework.transaction.annotation.Transactional;
1320

1421
import java.time.LocalDateTime;
1522
import java.util.*;
16-
import java.util.stream.Collectors;
17-
import java.util.Comparator;
18-
import java.util.LinkedHashMap;
19-
import java.util.List;
20-
import java.util.Map;
2123

2224
import static com.example.Centralthon.global.util.geo.GeoUtils.calculateBoundingBox;
23-
import static com.example.Centralthon.global.util.geo.GeoUtils.calculateDistance;
2425

2526
@Service
27+
@Slf4j
2628
@RequiredArgsConstructor
2729
public class MenuServiceImpl implements MenuService {
2830
private final MenuRepository menuRepository;
31+
private final AiService aiService;
2932

3033
// 주어진 위도, 경도를 중심으로 반경 2km 이내의 메뉴 조회
3134
private List<Menu> findMenusWithinRadius(double latitude, double longitude) {
@@ -89,4 +92,44 @@ public List<MenuDetailsRes> details(MenuIdsReq menus) {
8992
.toList();
9093
}
9194

95+
@Override
96+
public List<NearbyMenusRes> getRecommendedMenus(GetRecommendedMenusReq getRecommendedMenusReq){
97+
// 2km 이내 메뉴 조회
98+
List<Menu> menus = findMenusWithinRadius(getRecommendedMenusReq.getLatitude(), getRecommendedMenusReq.getLongitude());
99+
100+
// 중복 제거한 메뉴 목록
101+
Map<String, Menu> uniqueMenus = new LinkedHashMap<>();
102+
for (Menu menu : menus) {
103+
uniqueMenus.putIfAbsent(menu.getName(), menu);
104+
}
105+
106+
List<String> menuList = new ArrayList<>(uniqueMenus.keySet());
107+
List<GetMenusByConceptRes> getMenusByConceptResList = aiService.getMenuByConceptFromAi(
108+
GetMenusByConceptReq.builder()
109+
.concept(getRecommendedMenusReq.getConcept())
110+
.count(getRecommendedMenusReq.getCount())
111+
.items(menuList)
112+
.build()
113+
);
114+
115+
return getMenusByConceptResList.stream()
116+
.map(res -> {
117+
Menu matchedMenu = uniqueMenus.get(res.inputMenu());
118+
return NearbyMenusRes.from(matchedMenu);
119+
})
120+
.toList();
121+
}
122+
123+
@Override
124+
public List<GetTipRes> getTips(GetTipReq getTipReq) {
125+
try{
126+
return aiService.getTipFromAi(getTipReq);
127+
} catch (Exception e){
128+
log.error("AI 서버 호출 실패 {}", e.getMessage());
129+
throw new AiCommunicationFailedException();
130+
}
131+
}
132+
133+
134+
92135
}

src/main/java/com/example/Centralthon/domain/menu/web/controller/MenuApi.java

Lines changed: 114 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,8 @@
11
package com.example.Centralthon.domain.menu.web.controller;
22

3-
import com.example.Centralthon.domain.menu.web.dto.MenuDetailsRes;
4-
import com.example.Centralthon.domain.menu.web.dto.MenuIdsReq;
5-
import com.example.Centralthon.domain.menu.web.dto.NearbyMenusRes;
6-
import com.example.Centralthon.domain.menu.web.dto.StoresByMenuRes;
3+
import com.example.Centralthon.domain.menu.web.dto.*;
4+
import com.example.Centralthon.global.external.ai.web.dto.GetTipReq;
5+
import com.example.Centralthon.global.external.ai.web.dto.GetTipRes;
76
import com.example.Centralthon.global.response.SuccessResponse;
87
import io.swagger.v3.oas.annotations.Operation;
98
import io.swagger.v3.oas.annotations.Parameter;
@@ -155,4 +154,115 @@ ResponseEntity<SuccessResponse<List<StoresByMenuRes>>> storesByMenu(
155154
)
156155
)
157156
ResponseEntity<SuccessResponse<List<MenuDetailsRes>>> details(@RequestBody @Valid MenuIdsReq menus);
157+
158+
159+
160+
@Operation(
161+
summary = "메뉴 Tip 조회",
162+
description = "입력한 메뉴 이름 배열을 기반으로 AI 추천 Tip(활용법/조리법 등)을 반환합니다.<br>" +
163+
"{title, content} 리스트를 반환합니다."
164+
)
165+
@ApiResponse(
166+
responseCode = "200",
167+
description = "메뉴 Tip 조회 성공",
168+
content = @Content(
169+
mediaType = "application/json",
170+
schema = @Schema(implementation = SuccessResponse.class),
171+
examples = @ExampleObject(
172+
name = "SUCCESS_200",
173+
value = """
174+
{
175+
"timestamp": "2025-08-22 03:11:13",
176+
"code": "SUCCESS_200",
177+
"httpStatus": 200,
178+
"message": "호출에 성공하였습니다.",
179+
"data": [
180+
{
181+
"title": "콩나물의 변신, 국물 한 스푼",
182+
"content": "콩나물무침을 육수에 넣고 끓이면 시원한 콩나물국이 됩니다."
183+
},
184+
{
185+
"title": "감자볶음, 크리스피한 감자튀김으로",
186+
"content": "감자볶음을 잘게 썰어 튀김가루에 묻혀서 튀기면 바삭한 감자튀김이 됩니다."
187+
},
188+
{
189+
"title": "애호박 볶음, 달콤한 애호박전으로",
190+
"content": "애호박볶음을 반죽에 섞어 팬에 부치면 맛있는 애호박전이 됩니다."
191+
}
192+
],
193+
"isSuccess": true
194+
}
195+
"""
196+
)
197+
)
198+
)
199+
ResponseEntity<SuccessResponse<List<GetTipRes>>> getTips(
200+
@io.swagger.v3.oas.annotations.parameters.RequestBody(
201+
description = "메뉴 이름 배열 요청",
202+
required = true,
203+
content = @Content(
204+
schema = @Schema(implementation = GetTipReq.class),
205+
examples = @ExampleObject(
206+
value = """
207+
{
208+
"menus": ["콩나물무침", "감자볶음", "애호박볶음"]
209+
}
210+
"""
211+
)
212+
)
213+
)
214+
@Valid @RequestBody GetTipReq getTipReq);
215+
216+
@Operation(
217+
summary = "메뉴 추천",
218+
description = "사용자 위치(latitude, longitude)와 컨셉(concept)을 기반으로 맞춤 메뉴를 추천합니다.<br>" +
219+
"concept는 다음 중 하나만 선택할 수 있습니다: `diet`, `keto`, `low_sodium`, `bulking`, `glycemic`"
220+
)
221+
@ApiResponse(
222+
responseCode = "200",
223+
description = "메뉴 추천 성공",
224+
content = @Content(
225+
mediaType = "application/json",
226+
schema = @Schema(implementation = SuccessResponse.class),
227+
examples = @ExampleObject(
228+
name = "SUCCESS_200",
229+
value = """
230+
{
231+
"timestamp": "2025-08-22 14:36:04",
232+
"code": "SUCCESS_200",
233+
"httpStatus": 200,
234+
"message": "호출에 성공하였습니다.",
235+
"data": [
236+
{
237+
"name": "돼지 목살 스테이크",
238+
"category": "STIR_FRY"
239+
},
240+
...
241+
],
242+
"isSuccess": true
243+
}
244+
"""
245+
)
246+
)
247+
)
248+
ResponseEntity<SuccessResponse<List<NearbyMenusRes>>> recommend(
249+
@io.swagger.v3.oas.annotations.parameters.RequestBody(
250+
description = "사용자 위치와 추천 컨셉 요청",
251+
required = true,
252+
content = @Content(
253+
schema = @Schema(implementation = GetRecommendedMenusReq.class),
254+
examples = @ExampleObject(
255+
value = """
256+
{
257+
"latitude": 37.4752,
258+
"longitude": 127.050,
259+
"concept": "keto",
260+
"count": 15
261+
}
262+
"""
263+
)
264+
)
265+
)
266+
@Valid @RequestBody GetRecommendedMenusReq getRecommendedMenusReq);
267+
158268
}

src/main/java/com/example/Centralthon/domain/menu/web/controller/MenuController.java

Lines changed: 19 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,21 +3,21 @@
33
import com.example.Centralthon.domain.menu.service.MenuService;
44

55
import com.example.Centralthon.domain.menu.web.dto.*;
6-
import com.example.Centralthon.domain.order.web.controller.OrderApi;
6+
import com.example.Centralthon.domain.menu.web.dto.GetRecommendedMenusReq;
7+
import com.example.Centralthon.global.external.ai.web.dto.GetTipReq;
8+
import com.example.Centralthon.global.external.ai.web.dto.GetTipRes;
79
import com.example.Centralthon.global.response.SuccessResponse;
810
import jakarta.validation.Valid;
911

1012
import com.example.Centralthon.domain.menu.web.dto.NearbyMenusRes;
1113
import com.example.Centralthon.domain.menu.web.dto.StoresByMenuRes;
12-
import com.example.Centralthon.global.response.SuccessResponse;
1314

1415
import lombok.RequiredArgsConstructor;
1516
import org.springframework.http.HttpStatus;
1617
import org.springframework.http.ResponseEntity;
1718
import org.springframework.web.bind.annotation.*;
1819

1920
import java.util.List;
20-
import java.util.Map;
2121

2222
@RestController
2323
@RequestMapping("/api/menus")
@@ -58,4 +58,20 @@ public ResponseEntity<SuccessResponse<List<MenuDetailsRes>>> details(@RequestBod
5858

5959
return ResponseEntity.status(HttpStatus.OK).body(SuccessResponse.from(menuList));
6060
}
61+
62+
// 컨셉별 메뉴 추천
63+
@PostMapping("/recommend")
64+
@Override
65+
public ResponseEntity<SuccessResponse<List<NearbyMenusRes>>> recommend(@RequestBody @Valid GetRecommendedMenusReq getRecommendedMenusReq){
66+
List<NearbyMenusRes> menusList = menuService.getRecommendedMenus(getRecommendedMenusReq);
67+
return ResponseEntity.status(HttpStatus.OK).body(SuccessResponse.from(menusList));
68+
}
69+
70+
// 알뜰 반찬 팁 조회
71+
@PostMapping("/tips")
72+
@Override
73+
public ResponseEntity<SuccessResponse<List<GetTipRes>>> getTips(@RequestBody @Valid GetTipReq getTipReq){
74+
List<GetTipRes> tips = menuService.getTips(getTipReq);
75+
return ResponseEntity.status(HttpStatus.OK).body(SuccessResponse.from(tips));
76+
}
6177
}
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
package com.example.Centralthon.domain.menu.web.dto;
2+
3+
import com.example.Centralthon.domain.menu.entity.enums.Concept;
4+
import jakarta.validation.constraints.*;
5+
import lombok.Getter;
6+
import lombok.Setter;
7+
8+
@Getter
9+
@Setter
10+
public class GetRecommendedMenusReq {
11+
@DecimalMin(value = "-90.0", message = "위도는 -90 이상이어야 합니다.")
12+
@DecimalMax(value = "90.0", message = "위도는 90 이하이어야 합니다.")
13+
@NotNull(message = "위도는 필수값입니다.")
14+
double latitude;
15+
16+
@DecimalMin(value = "-180.0", message = "경도는 -180 이상이어야 합니다.")
17+
@DecimalMax(value = "180.0", message = "경도는 180 이하이어야 합니다.")
18+
@NotNull(message = "경도는 필수값입니다.")
19+
double longitude;
20+
21+
@NotNull(message = "컨셉은 필수 값입니다.")
22+
Concept concept;
23+
24+
@Min(value = 1, message = "최소 1개 이상 조회해야 합니다.")
25+
@Max(value = 50, message = "최대 50개까지 조회할 수 있습니다.")
26+
@NotNull(message = "숫자는 필수 값입니다.")
27+
int count; // 몇 개 조회할지
28+
}
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
package com.example.Centralthon.global.config;
2+
3+
import org.springframework.boot.web.client.RestTemplateBuilder;
4+
import org.springframework.context.annotation.Bean;
5+
import org.springframework.context.annotation.Configuration;
6+
import org.springframework.web.client.RestTemplate;
7+
8+
@Configuration
9+
public class RestTemplateConfig {
10+
11+
@Bean
12+
public RestTemplate restTemplate(RestTemplateBuilder restTemplateBuilder) {
13+
return restTemplateBuilder.build();
14+
}
15+
}

src/main/java/com/example/Centralthon/global/exception/GlobalExceptionHandler.java

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,7 @@ public ResponseEntity<ErrorResponse<?>> handleBindException(BindException e) {
4242
return ResponseEntity.status(errorREsponse.getHttpStatus()).body(errorREsponse);
4343
}
4444

45-
//ReqeustBody 등으로 전달 받은 JSON 바디의 파싱이 실패 했을 때
45+
// ReqeustBody 등으로 전달 받은 JSON 바디의 파싱이 실패 했을 때
4646
@ExceptionHandler(HttpMessageNotReadableException.class)
4747
public ResponseEntity<ErrorResponse<?>> handleHttpMessageNotReadableException(HttpMessageNotReadableException e) {
4848
log.error("HttpMessageNotReadableException : {}", e.getMessage(), e);
@@ -66,7 +66,7 @@ public ResponseEntity<ErrorResponse<?>> handleMissingServletRequestPartException
6666
return ResponseEntity.status(errorResponse.getHttpStatus()).body(errorResponse);
6767
}
6868

69-
//지원하지 않는 HTTP 메소드를 호출할 경우
69+
// 지원하지 않는 HTTP 메소드를 호출할 경우
7070
@ExceptionHandler(HttpRequestMethodNotSupportedException.class)
7171
public ResponseEntity<ErrorResponse<?>> handleHttpRequestMethodNotSupportedException(HttpRequestMethodNotSupportedException e) {
7272
log.error("HttpRequestMethodNotSupportedException : {}", e.getMessage(), e);
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
package com.example.Centralthon.global.external.ai.client;
2+
3+
import com.example.Centralthon.global.external.exception.AiCommunicationFailedException;
4+
import lombok.RequiredArgsConstructor;
5+
import org.springframework.stereotype.Component;
6+
import org.springframework.web.client.RestTemplate;
7+
8+
@Component
9+
@RequiredArgsConstructor
10+
public class AiClient {
11+
private final RestTemplate restTemplate;
12+
13+
public <T> T postForObject(String url, Object request, Class<T> responseType) {
14+
try{
15+
return restTemplate.postForObject(url, request, responseType);
16+
} catch (Exception e){
17+
throw new AiCommunicationFailedException();
18+
}
19+
20+
}
21+
}

0 commit comments

Comments
 (0)