Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
package com.example.Centralthon.domain.menu.entity.enums;

import com.fasterxml.jackson.annotation.JsonCreator;
import com.fasterxml.jackson.annotation.JsonValue;
import lombok.Getter;
import lombok.RequiredArgsConstructor;

@Getter
@RequiredArgsConstructor
public enum Concept {
DIET("diet"),
KETO("keto"),
LOW_SODIUM("low_sodium"),
GLYCEMIC("glycemic"),
BULKING("bulking");

private final String value;

@JsonValue
public String getValue(){
return value;
}

@JsonCreator
public static Concept fromValue(String value){
for (Concept concept : Concept.values()){
if (concept.getValue().equals(value)){
return concept;
}
}
throw new IllegalArgumentException();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,10 @@
import com.example.Centralthon.domain.menu.web.dto.*;
import com.example.Centralthon.domain.menu.web.dto.NearbyMenusRes;
import com.example.Centralthon.domain.menu.web.dto.StoresByMenuRes;
import com.example.Centralthon.domain.menu.web.dto.GetRecommendedMenusReq;
import com.example.Centralthon.global.external.ai.web.dto.GetTipReq;
import com.example.Centralthon.global.external.ai.web.dto.GetTipRes;

import java.util.List;

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

List<MenuDetailsRes> details(MenuIdsReq menus);

List<NearbyMenusRes> getRecommendedMenus(GetRecommendedMenusReq getRecommendedMenusReq);

List<GetTipRes> getTips(GetTipReq getTipReq);
}
Original file line number Diff line number Diff line change
Expand Up @@ -4,28 +4,31 @@
import com.example.Centralthon.domain.menu.exception.MenuNotFoundException;
import com.example.Centralthon.domain.menu.repository.MenuRepository;
import com.example.Centralthon.domain.menu.web.dto.*;
import com.example.Centralthon.domain.store.entity.Store;
import com.example.Centralthon.domain.menu.web.dto.GetRecommendedMenusReq;
import com.example.Centralthon.global.external.ai.service.AiService;
import com.example.Centralthon.global.external.ai.web.dto.GetMenusByConceptReq;
import com.example.Centralthon.global.external.ai.web.dto.GetMenusByConceptRes;
import com.example.Centralthon.global.external.ai.web.dto.GetTipReq;
import com.example.Centralthon.global.external.ai.web.dto.GetTipRes;
import com.example.Centralthon.global.external.exception.AiCommunicationFailedException;
import com.example.Centralthon.global.util.geo.BoundingBox;
import com.example.Centralthon.global.util.geo.GeoUtils;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import java.time.LocalDateTime;
import java.util.*;
import java.util.stream.Collectors;
import java.util.Comparator;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;

import static com.example.Centralthon.global.util.geo.GeoUtils.calculateBoundingBox;
import static com.example.Centralthon.global.util.geo.GeoUtils.calculateDistance;

@Service
@Slf4j
@RequiredArgsConstructor
public class MenuServiceImpl implements MenuService {
private final MenuRepository menuRepository;
private final AiService aiService;

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

@Override
public List<NearbyMenusRes> getRecommendedMenus(GetRecommendedMenusReq getRecommendedMenusReq){
// 2km 이내 메뉴 조회
List<Menu> menus = findMenusWithinRadius(getRecommendedMenusReq.getLatitude(), getRecommendedMenusReq.getLongitude());

// 중복 제거한 메뉴 목록
Map<String, Menu> uniqueMenus = new LinkedHashMap<>();
for (Menu menu : menus) {
uniqueMenus.putIfAbsent(menu.getName(), menu);
}

List<String> menuList = new ArrayList<>(uniqueMenus.keySet());
List<GetMenusByConceptRes> getMenusByConceptResList = aiService.getMenuByConceptFromAi(
GetMenusByConceptReq.builder()
.concept(getRecommendedMenusReq.getConcept())
.count(getRecommendedMenusReq.getCount())
.items(menuList)
.build()
);

return getMenusByConceptResList.stream()
.map(res -> {
Menu matchedMenu = uniqueMenus.get(res.inputMenu());
return NearbyMenusRes.from(matchedMenu);
})
.toList();
}

@Override
public List<GetTipRes> getTips(GetTipReq getTipReq) {
try{
return aiService.getTipFromAi(getTipReq);
} catch (Exception e){
log.error("AI 서버 호출 실패 {}", e.getMessage());
throw new AiCommunicationFailedException();
}
}



}
Original file line number Diff line number Diff line change
@@ -1,9 +1,8 @@
package com.example.Centralthon.domain.menu.web.controller;

import com.example.Centralthon.domain.menu.web.dto.MenuDetailsRes;
import com.example.Centralthon.domain.menu.web.dto.MenuIdsReq;
import com.example.Centralthon.domain.menu.web.dto.NearbyMenusRes;
import com.example.Centralthon.domain.menu.web.dto.StoresByMenuRes;
import com.example.Centralthon.domain.menu.web.dto.*;
import com.example.Centralthon.global.external.ai.web.dto.GetTipReq;
import com.example.Centralthon.global.external.ai.web.dto.GetTipRes;
import com.example.Centralthon.global.response.SuccessResponse;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
Expand Down Expand Up @@ -155,4 +154,115 @@ ResponseEntity<SuccessResponse<List<StoresByMenuRes>>> storesByMenu(
)
)
ResponseEntity<SuccessResponse<List<MenuDetailsRes>>> details(@RequestBody @Valid MenuIdsReq menus);



@Operation(
summary = "메뉴 Tip 조회",
description = "입력한 메뉴 이름 배열을 기반으로 AI 추천 Tip(활용법/조리법 등)을 반환합니다.<br>" +
"{title, content} 리스트를 반환합니다."
)
@ApiResponse(
responseCode = "200",
description = "메뉴 Tip 조회 성공",
content = @Content(
mediaType = "application/json",
schema = @Schema(implementation = SuccessResponse.class),
examples = @ExampleObject(
name = "SUCCESS_200",
value = """
{
"timestamp": "2025-08-22 03:11:13",
"code": "SUCCESS_200",
"httpStatus": 200,
"message": "호출에 성공하였습니다.",
"data": [
{
"title": "콩나물의 변신, 국물 한 스푼",
"content": "콩나물무침을 육수에 넣고 끓이면 시원한 콩나물국이 됩니다."
},
{
"title": "감자볶음, 크리스피한 감자튀김으로",
"content": "감자볶음을 잘게 썰어 튀김가루에 묻혀서 튀기면 바삭한 감자튀김이 됩니다."
},
{
"title": "애호박 볶음, 달콤한 애호박전으로",
"content": "애호박볶음을 반죽에 섞어 팬에 부치면 맛있는 애호박전이 됩니다."
}
],
"isSuccess": true
}
"""
)
)
)
ResponseEntity<SuccessResponse<List<GetTipRes>>> getTips(
@io.swagger.v3.oas.annotations.parameters.RequestBody(
description = "메뉴 이름 배열 요청",
required = true,
content = @Content(
schema = @Schema(implementation = GetTipReq.class),
examples = @ExampleObject(
value = """
{
"menus": ["콩나물무침", "감자볶음", "애호박볶음"]
}
"""
)
)
)
@Valid @RequestBody GetTipReq getTipReq);

@Operation(
summary = "메뉴 추천",
description = "사용자 위치(latitude, longitude)와 컨셉(concept)을 기반으로 맞춤 메뉴를 추천합니다.<br>" +
"concept는 다음 중 하나만 선택할 수 있습니다: `diet`, `keto`, `low_sodium`, `bulking`, `glycemic`"
)
@ApiResponse(
responseCode = "200",
description = "메뉴 추천 성공",
content = @Content(
mediaType = "application/json",
schema = @Schema(implementation = SuccessResponse.class),
examples = @ExampleObject(
name = "SUCCESS_200",
value = """
{
"timestamp": "2025-08-22 14:36:04",
"code": "SUCCESS_200",
"httpStatus": 200,
"message": "호출에 성공하였습니다.",
"data": [
{
"name": "돼지 목살 스테이크",
"category": "STIR_FRY"
},
...
],
"isSuccess": true
}
"""
)
)
)
ResponseEntity<SuccessResponse<List<NearbyMenusRes>>> recommend(
@io.swagger.v3.oas.annotations.parameters.RequestBody(
description = "사용자 위치와 추천 컨셉 요청",
required = true,
content = @Content(
schema = @Schema(implementation = GetRecommendedMenusReq.class),
examples = @ExampleObject(
value = """
{
"latitude": 37.4752,
"longitude": 127.050,
"concept": "keto",
"count": 15
}
"""
)
)
)
@Valid @RequestBody GetRecommendedMenusReq getRecommendedMenusReq);

}
Original file line number Diff line number Diff line change
Expand Up @@ -3,21 +3,21 @@
import com.example.Centralthon.domain.menu.service.MenuService;

import com.example.Centralthon.domain.menu.web.dto.*;
import com.example.Centralthon.domain.order.web.controller.OrderApi;
import com.example.Centralthon.domain.menu.web.dto.GetRecommendedMenusReq;
import com.example.Centralthon.global.external.ai.web.dto.GetTipReq;
import com.example.Centralthon.global.external.ai.web.dto.GetTipRes;
import com.example.Centralthon.global.response.SuccessResponse;
import jakarta.validation.Valid;

import com.example.Centralthon.domain.menu.web.dto.NearbyMenusRes;
import com.example.Centralthon.domain.menu.web.dto.StoresByMenuRes;
import com.example.Centralthon.global.response.SuccessResponse;

import lombok.RequiredArgsConstructor;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;

import java.util.List;
import java.util.Map;

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

return ResponseEntity.status(HttpStatus.OK).body(SuccessResponse.from(menuList));
}

// 컨셉별 메뉴 추천
@PostMapping("/recommend")
@Override
public ResponseEntity<SuccessResponse<List<NearbyMenusRes>>> recommend(@RequestBody @Valid GetRecommendedMenusReq getRecommendedMenusReq){
List<NearbyMenusRes> menusList = menuService.getRecommendedMenus(getRecommendedMenusReq);
return ResponseEntity.status(HttpStatus.OK).body(SuccessResponse.from(menusList));
}

// 알뜰 반찬 팁 조회
@PostMapping("/tips")
@Override
public ResponseEntity<SuccessResponse<List<GetTipRes>>> getTips(@RequestBody @Valid GetTipReq getTipReq){
List<GetTipRes> tips = menuService.getTips(getTipReq);
return ResponseEntity.status(HttpStatus.OK).body(SuccessResponse.from(tips));
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
package com.example.Centralthon.domain.menu.web.dto;

import com.example.Centralthon.domain.menu.entity.enums.Concept;
import jakarta.validation.constraints.*;
import lombok.Getter;
import lombok.Setter;

@Getter
@Setter
public class GetRecommendedMenusReq {
@DecimalMin(value = "-90.0", message = "위도는 -90 이상이어야 합니다.")
@DecimalMax(value = "90.0", message = "위도는 90 이하이어야 합니다.")
@NotNull(message = "위도는 필수값입니다.")
double latitude;

@DecimalMin(value = "-180.0", message = "경도는 -180 이상이어야 합니다.")
@DecimalMax(value = "180.0", message = "경도는 180 이하이어야 합니다.")
@NotNull(message = "경도는 필수값입니다.")
double longitude;

@NotNull(message = "컨셉은 필수 값입니다.")
Concept concept;

@Min(value = 1, message = "최소 1개 이상 조회해야 합니다.")
@Max(value = 50, message = "최대 50개까지 조회할 수 있습니다.")
@NotNull(message = "숫자는 필수 값입니다.")
int count; // 몇 개 조회할지
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
package com.example.Centralthon.global.config;

import org.springframework.boot.web.client.RestTemplateBuilder;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.client.RestTemplate;

@Configuration
public class RestTemplateConfig {

@Bean
public RestTemplate restTemplate(RestTemplateBuilder restTemplateBuilder) {
return restTemplateBuilder.build();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ public ResponseEntity<ErrorResponse<?>> handleBindException(BindException e) {
return ResponseEntity.status(errorREsponse.getHttpStatus()).body(errorREsponse);
}

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

//지원하지 않는 HTTP 메소드를 호출할 경우
// 지원하지 않는 HTTP 메소드를 호출할 경우
@ExceptionHandler(HttpRequestMethodNotSupportedException.class)
public ResponseEntity<ErrorResponse<?>> handleHttpRequestMethodNotSupportedException(HttpRequestMethodNotSupportedException e) {
log.error("HttpRequestMethodNotSupportedException : {}", e.getMessage(), e);
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
package com.example.Centralthon.global.external.ai.client;

import com.example.Centralthon.global.external.exception.AiCommunicationFailedException;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Component;
import org.springframework.web.client.RestTemplate;

@Component
@RequiredArgsConstructor
public class AiClient {
private final RestTemplate restTemplate;

public <T> T postForObject(String url, Object request, Class<T> responseType) {
try{
return restTemplate.postForObject(url, request, responseType);
} catch (Exception e){
throw new AiCommunicationFailedException();
}

}
}
Loading