diff --git a/src/main/java/com/DecodEat/domain/products/client/PythonAnalysisClient.java b/src/main/java/com/DecodEat/domain/products/client/PythonAnalysisClient.java index 66969dc..3dc13db 100644 --- a/src/main/java/com/DecodEat/domain/products/client/PythonAnalysisClient.java +++ b/src/main/java/com/DecodEat/domain/products/client/PythonAnalysisClient.java @@ -1,7 +1,9 @@ package com.DecodEat.domain.products.client; import com.DecodEat.domain.products.dto.request.AnalysisRequestDto; +import com.DecodEat.domain.products.dto.request.ProductBasedRecommendationRequestDto; import com.DecodEat.domain.products.dto.response.AnalysisResponseDto; +import com.DecodEat.domain.products.dto.response.ProductBasedRecommendationResponseDto; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Value; @@ -33,4 +35,18 @@ public Mono analyzeProduct(AnalysisRequestDto request) { .doOnSuccess(response -> log.info("Analysis completed with status: {}", response.getDecodeStatus())) .doOnError(error -> log.error("Analysis request failed: {}", error.getMessage())); } + + public Mono getProductBasedRecommendation( + ProductBasedRecommendationRequestDto request){ + log.info("Sending analysis request to Python server: {}", pythonServerUrl); + + return webClient.post() + .uri(pythonServerUrl + "api/v1/recommend/product-based") + .bodyValue(request) + .retrieve() + .bodyToMono(ProductBasedRecommendationResponseDto.class) + .timeout(Duration.ofMinutes(2)) + .doOnSuccess(response -> log.info("Success to get recommendation(product-based): {}",request.getProduct_id())) + .doOnError(error -> log.error("Recommendation request failed: {}", error.getMessage())); + } } \ No newline at end of file diff --git a/src/main/java/com/DecodEat/domain/products/controller/ProductController.java b/src/main/java/com/DecodEat/domain/products/controller/ProductController.java index 842b92f..a5534c9 100644 --- a/src/main/java/com/DecodEat/domain/products/controller/ProductController.java +++ b/src/main/java/com/DecodEat/domain/products/controller/ProductController.java @@ -113,4 +113,12 @@ public ApiResponse addOrUpdateLike( ) { return ApiResponse.onSuccess(productService.addOrUpdateLike(user.getId(), productId)); } + + @GetMapping("/recommendation/product-based") + @Operation(summary = "상품 기반 추천", description = "상품 영양성분, 원재료명 기반 추천") + public ApiResponse> getProductBasedRecommendation(@RequestParam Long productId, + @RequestParam(defaultValue = "5") int limit) { + return ApiResponse.onSuccess(productService.getProductBasedRecommendation(productId, limit)); + } + } diff --git a/src/main/java/com/DecodEat/domain/products/dto/request/ProductBasedRecommendationRequestDto.java b/src/main/java/com/DecodEat/domain/products/dto/request/ProductBasedRecommendationRequestDto.java new file mode 100644 index 0000000..502524d --- /dev/null +++ b/src/main/java/com/DecodEat/domain/products/dto/request/ProductBasedRecommendationRequestDto.java @@ -0,0 +1,15 @@ +package com.DecodEat.domain.products.dto.request; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@Builder +@AllArgsConstructor +@NoArgsConstructor +public class ProductBasedRecommendationRequestDto { + private int product_id; + private int limit; +} diff --git a/src/main/java/com/DecodEat/domain/products/dto/response/ProductBasedRecommendationResponseDto.java b/src/main/java/com/DecodEat/domain/products/dto/response/ProductBasedRecommendationResponseDto.java new file mode 100644 index 0000000..74e5508 --- /dev/null +++ b/src/main/java/com/DecodEat/domain/products/dto/response/ProductBasedRecommendationResponseDto.java @@ -0,0 +1,29 @@ +package com.DecodEat.domain.products.dto.response; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.util.List; + +@Getter +@NoArgsConstructor // JSON -> Java 객체 변환 시 Jackson 라이브러리가 사용하기 위함 +public class ProductBasedRecommendationResponseDto { + + private List recommendations; + private int totalCount; + private Long userId; + private Long referenceProductId; + private String recommendationType; + private String dataQuality; + private String message; + + @Getter + @NoArgsConstructor + public static class RecommendationDetailDto { + private Long productId; + private double similarityScore; + private String recommendationReason; + } +} \ No newline at end of file diff --git a/src/main/java/com/DecodEat/domain/products/service/ProductService.java b/src/main/java/com/DecodEat/domain/products/service/ProductService.java index 33b61cb..ff36734 100644 --- a/src/main/java/com/DecodEat/domain/products/service/ProductService.java +++ b/src/main/java/com/DecodEat/domain/products/service/ProductService.java @@ -3,6 +3,7 @@ import com.DecodEat.domain.products.client.PythonAnalysisClient; import com.DecodEat.domain.products.converter.ProductConverter; import com.DecodEat.domain.products.dto.request.AnalysisRequestDto; +import com.DecodEat.domain.products.dto.request.ProductBasedRecommendationRequestDto; import com.DecodEat.domain.products.dto.request.ProductRegisterRequestDto; import com.DecodEat.domain.products.dto.response.*; import com.DecodEat.domain.products.entity.*; @@ -13,9 +14,11 @@ import com.DecodEat.domain.users.entity.User; import com.DecodEat.domain.users.repository.UserRepository; import com.DecodEat.domain.users.service.UserBehaviorService; +import com.DecodEat.global.apiPayload.code.status.ErrorStatus; import com.DecodEat.global.aws.s3.AmazonS3Manager; import com.DecodEat.global.dto.PageResponseDto; import com.DecodEat.global.exception.GeneralException; +import jdk.jfr.Frequency; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.data.domain.*; @@ -51,8 +54,8 @@ public class ProductService { public ProductDetailDto getDetail(Long id, User user) { Product product = productRepository.findById(id).orElseThrow(() -> new GeneralException(PRODUCT_NOT_EXISTED)); - if(user != null) - userBehaviorService.saveUserBehavior(user,product, Behavior.VIEW); + if (user != null) + userBehaviorService.saveUserBehavior(user, product, Behavior.VIEW); List images = productImageRepository.findByProduct(product); List imageUrls = images.stream().map(ProductInfoImage::getImageUrl).toList(); @@ -61,8 +64,8 @@ public ProductDetailDto getDetail(Long id, User user) { // 좋아요 여부 확인 boolean isLiked = false; - if(user != null){ - isLiked = productLikeRepository.existsByUserAndProduct(user,product); + if (user != null) { + isLiked = productLikeRepository.existsByUserAndProduct(user, product); } return ProductConverter.toProductDetailDto(product, imageUrls, productNutrition, isLiked); } @@ -106,7 +109,7 @@ public ProductRegisterResponseDto addProduct(User user, ProductRegisterRequestDt // 파이썬 서버에 비동기로 분석 요청 requestAnalysisAsync(savedProduct.getProductId(), productInfoImageUrls); - userBehaviorService.saveUserBehavior(user,savedProduct,Behavior.REGISTER); // todo: 만약에 분석 실패? + userBehaviorService.saveUserBehavior(user, savedProduct, Behavior.REGISTER); // todo: 만약에 분석 실패? return ProductConverter.toProductRegisterDto(savedProduct, productInfoImageUrls); } @@ -175,7 +178,7 @@ public PageResponseDto searchProducts(S return new PageResponseDto<>(result); } - public PageResponseDto getRegisterHistory(User user, Pageable pageable){ + public PageResponseDto getRegisterHistory(User user, Pageable pageable) { Long userId = user.getId(); @@ -185,10 +188,32 @@ public PageResponseDto getRegisterHistory(User user, return new PageResponseDto<>(result); } + public List getProductBasedRecommendation(Long productId, int limit) { + + ProductBasedRecommendationRequestDto request = + new ProductBasedRecommendationRequestDto(productId.intValue(), limit); + + ProductBasedRecommendationResponseDto response = + pythonAnalysisClient.getProductBasedRecommendation(request).block(); + + if (response == null) { + log.warn("No recommendation response for product ID: {}", productId); + throw new GeneralException(NO_RECOMMENDATION_PRODUCT_BASED); + } + + List productList = response.getRecommendations().stream() + .map(r -> productRepository.findById(r.getProductId()) + .orElseThrow(() -> new GeneralException(PRODUCT_NOT_EXISTED))) + .toList(); + + return productList.stream().map(ProductConverter::toProductPrevDto).toList(); + } + + @Async public void requestAnalysisAsync(Long productId, List imageUrls) { log.info("Starting async analysis for product ID: {}", productId); - + if (imageUrls == null || imageUrls.isEmpty()) { log.warn("No images to analyze for product ID: {}", productId); updateProductStatus(productId, DecodeStatus.FAILED, "No images provided for analysis"); @@ -218,7 +243,7 @@ public void requestAnalysisAsync(Long productId, List imageUrls) { @Transactional public void processAnalysisResult(Long productId, AnalysisResponseDto response) { log.info("Processing analysis result for product ID: {} with status: {}", productId, response.getDecodeStatus()); - + try { Product product = productRepository.findById(productId) .orElseThrow(() -> new GeneralException(PRODUCT_NOT_EXISTED)); @@ -244,10 +269,10 @@ public void updateProductStatus(Long productId, DecodeStatus status, String mess try { Product product = productRepository.findById(productId) .orElseThrow(() -> new GeneralException(PRODUCT_NOT_EXISTED)); - + product.setDecodeStatus(status); productRepository.save(product); - + log.info("Updated product ID: {} status to: {} - {}", productId, status, message); } catch (Exception e) { log.error("Failed to update product status for ID: {}", productId, e); @@ -256,7 +281,7 @@ public void updateProductStatus(Long productId, DecodeStatus status, String mess private void saveNutritionInfo(Long productId, AnalysisResponseDto response) { log.info("Saving nutrition info for product ID: {}", productId); - + try { Product product = productRepository.findById(productId) .orElseThrow(() -> new GeneralException(PRODUCT_NOT_EXISTED)); @@ -277,7 +302,7 @@ private void saveNutritionInfo(Long productId, AnalysisResponseDto response) { .sugar(parseDouble(response.getNutrition_info().getSugar())) .transFat(parseDouble(response.getNutrition_info().getTrans_fat())) .build(); - + productNutritionRepository.save(nutrition); log.info("Saved nutrition info for product ID: {}", productId); } @@ -366,7 +391,7 @@ public ProductLikeResponseDTO addOrUpdateLike(Long userId, Long productId) { // 이미 눌렀으면 → 좋아요 취소 productLikeRepository.delete(existingLike.get()); isLiked = false; - userBehaviorService.deleteUserBehavior(user,product, Behavior.LIKE); + userBehaviorService.deleteUserBehavior(user, product, Behavior.LIKE); } else { // 처음 누르면 → 좋아요 추가 @@ -376,7 +401,7 @@ public ProductLikeResponseDTO addOrUpdateLike(Long userId, Long productId) { .build(); productLikeRepository.save(productLike); isLiked = true; - userBehaviorService.saveUserBehavior(user,product, Behavior.LIKE); + userBehaviorService.saveUserBehavior(user, product, Behavior.LIKE); } return ProductConverter.toProductLikeDTO(productId, isLiked); } diff --git a/src/main/java/com/DecodEat/global/apiPayload/code/status/ErrorStatus.java b/src/main/java/com/DecodEat/global/apiPayload/code/status/ErrorStatus.java index 9d1ebc0..d039f27 100644 --- a/src/main/java/com/DecodEat/global/apiPayload/code/status/ErrorStatus.java +++ b/src/main/java/com/DecodEat/global/apiPayload/code/status/ErrorStatus.java @@ -22,6 +22,9 @@ public enum ErrorStatus implements BaseErrorCode { PAGE_OUT_OF_RANGE(HttpStatus.BAD_REQUEST,"SEARCH_400","요청한 페이지가 전체 페이지 수를 초과합니다."), NO_RESULT(HttpStatus.NOT_FOUND,"SEARCH_401","검색 결과가 없습니다."), + // 추천 + NO_RECOMMENDATION_PRODUCT_BASED(HttpStatus.NOT_FOUND,"RECOMMENDATION_400","유사한 상품이 존재하지 않습니다."), + // 기본 에러 _BAD_REQUEST(HttpStatus.BAD_REQUEST, "COMMON_400", "잘못된 요청입니다."), _UNAUTHORIZED(HttpStatus.UNAUTHORIZED, "COMMON_401", "인증이 필요합니다."),