Skip to content

Commit 270b78b

Browse files
authored
Merge pull request #76 from Decodeat/feat/75-product-based-recommendation
[Feat] 상품 기반 추천 api
2 parents 3d9b0b8 + 107c7c9 commit 270b78b

File tree

6 files changed

+110
-14
lines changed

6 files changed

+110
-14
lines changed

src/main/java/com/DecodEat/domain/products/client/PythonAnalysisClient.java

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
11
package com.DecodEat.domain.products.client;
22

33
import com.DecodEat.domain.products.dto.request.AnalysisRequestDto;
4+
import com.DecodEat.domain.products.dto.request.ProductBasedRecommendationRequestDto;
45
import com.DecodEat.domain.products.dto.response.AnalysisResponseDto;
6+
import com.DecodEat.domain.products.dto.response.ProductBasedRecommendationResponseDto;
57
import lombok.RequiredArgsConstructor;
68
import lombok.extern.slf4j.Slf4j;
79
import org.springframework.beans.factory.annotation.Value;
@@ -33,4 +35,18 @@ public Mono<AnalysisResponseDto> analyzeProduct(AnalysisRequestDto request) {
3335
.doOnSuccess(response -> log.info("Analysis completed with status: {}", response.getDecodeStatus()))
3436
.doOnError(error -> log.error("Analysis request failed: {}", error.getMessage()));
3537
}
38+
39+
public Mono<ProductBasedRecommendationResponseDto> getProductBasedRecommendation(
40+
ProductBasedRecommendationRequestDto request){
41+
log.info("Sending analysis request to Python server: {}", pythonServerUrl);
42+
43+
return webClient.post()
44+
.uri(pythonServerUrl + "api/v1/recommend/product-based")
45+
.bodyValue(request)
46+
.retrieve()
47+
.bodyToMono(ProductBasedRecommendationResponseDto.class)
48+
.timeout(Duration.ofMinutes(2))
49+
.doOnSuccess(response -> log.info("Success to get recommendation(product-based): {}",request.getProduct_id()))
50+
.doOnError(error -> log.error("Recommendation request failed: {}", error.getMessage()));
51+
}
3652
}

src/main/java/com/DecodEat/domain/products/controller/ProductController.java

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -113,4 +113,12 @@ public ApiResponse<ProductLikeResponseDTO> addOrUpdateLike(
113113
) {
114114
return ApiResponse.onSuccess(productService.addOrUpdateLike(user.getId(), productId));
115115
}
116+
117+
@GetMapping("/recommendation/product-based")
118+
@Operation(summary = "상품 기반 추천", description = "상품 영양성분, 원재료명 기반 추천")
119+
public ApiResponse<List<ProductSearchResponseDto.ProductPrevDto>> getProductBasedRecommendation(@RequestParam Long productId,
120+
@RequestParam(defaultValue = "5") int limit) {
121+
return ApiResponse.onSuccess(productService.getProductBasedRecommendation(productId, limit));
122+
}
123+
116124
}
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
package com.DecodEat.domain.products.dto.request;
2+
3+
import lombok.AllArgsConstructor;
4+
import lombok.Builder;
5+
import lombok.Getter;
6+
import lombok.NoArgsConstructor;
7+
8+
@Getter
9+
@Builder
10+
@AllArgsConstructor
11+
@NoArgsConstructor
12+
public class ProductBasedRecommendationRequestDto {
13+
private int product_id;
14+
private int limit;
15+
}
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
package com.DecodEat.domain.products.dto.response;
2+
3+
import lombok.AllArgsConstructor;
4+
import lombok.Builder;
5+
import lombok.Getter;
6+
import lombok.NoArgsConstructor;
7+
8+
import java.util.List;
9+
10+
@Getter
11+
@NoArgsConstructor // JSON -> Java 객체 변환 시 Jackson 라이브러리가 사용하기 위함
12+
public class ProductBasedRecommendationResponseDto {
13+
14+
private List<RecommendationDetailDto> recommendations;
15+
private int totalCount;
16+
private Long userId;
17+
private Long referenceProductId;
18+
private String recommendationType;
19+
private String dataQuality;
20+
private String message;
21+
22+
@Getter
23+
@NoArgsConstructor
24+
public static class RecommendationDetailDto {
25+
private Long productId;
26+
private double similarityScore;
27+
private String recommendationReason;
28+
}
29+
}

src/main/java/com/DecodEat/domain/products/service/ProductService.java

Lines changed: 39 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
import com.DecodEat.domain.products.client.PythonAnalysisClient;
44
import com.DecodEat.domain.products.converter.ProductConverter;
55
import com.DecodEat.domain.products.dto.request.AnalysisRequestDto;
6+
import com.DecodEat.domain.products.dto.request.ProductBasedRecommendationRequestDto;
67
import com.DecodEat.domain.products.dto.request.ProductRegisterRequestDto;
78
import com.DecodEat.domain.products.dto.response.*;
89
import com.DecodEat.domain.products.entity.*;
@@ -13,9 +14,11 @@
1314
import com.DecodEat.domain.users.entity.User;
1415
import com.DecodEat.domain.users.repository.UserRepository;
1516
import com.DecodEat.domain.users.service.UserBehaviorService;
17+
import com.DecodEat.global.apiPayload.code.status.ErrorStatus;
1618
import com.DecodEat.global.aws.s3.AmazonS3Manager;
1719
import com.DecodEat.global.dto.PageResponseDto;
1820
import com.DecodEat.global.exception.GeneralException;
21+
import jdk.jfr.Frequency;
1922
import lombok.RequiredArgsConstructor;
2023
import lombok.extern.slf4j.Slf4j;
2124
import org.springframework.data.domain.*;
@@ -51,8 +54,8 @@ public class ProductService {
5154

5255
public ProductDetailDto getDetail(Long id, User user) {
5356
Product product = productRepository.findById(id).orElseThrow(() -> new GeneralException(PRODUCT_NOT_EXISTED));
54-
if(user != null)
55-
userBehaviorService.saveUserBehavior(user,product, Behavior.VIEW);
57+
if (user != null)
58+
userBehaviorService.saveUserBehavior(user, product, Behavior.VIEW);
5659

5760
List<ProductInfoImage> images = productImageRepository.findByProduct(product);
5861
List<String> imageUrls = images.stream().map(ProductInfoImage::getImageUrl).toList();
@@ -61,8 +64,8 @@ public ProductDetailDto getDetail(Long id, User user) {
6164

6265
// 좋아요 여부 확인
6366
boolean isLiked = false;
64-
if(user != null){
65-
isLiked = productLikeRepository.existsByUserAndProduct(user,product);
67+
if (user != null) {
68+
isLiked = productLikeRepository.existsByUserAndProduct(user, product);
6669
}
6770
return ProductConverter.toProductDetailDto(product, imageUrls, productNutrition, isLiked);
6871
}
@@ -106,7 +109,7 @@ public ProductRegisterResponseDto addProduct(User user, ProductRegisterRequestDt
106109
// 파이썬 서버에 비동기로 분석 요청
107110
requestAnalysisAsync(savedProduct.getProductId(), productInfoImageUrls);
108111

109-
userBehaviorService.saveUserBehavior(user,savedProduct,Behavior.REGISTER); // todo: 만약에 분석 실패?
112+
userBehaviorService.saveUserBehavior(user, savedProduct, Behavior.REGISTER); // todo: 만약에 분석 실패?
110113

111114
return ProductConverter.toProductRegisterDto(savedProduct, productInfoImageUrls);
112115
}
@@ -175,7 +178,7 @@ public PageResponseDto<ProductSearchResponseDto.ProductPrevDto> searchProducts(S
175178
return new PageResponseDto<>(result);
176179
}
177180

178-
public PageResponseDto<ProductRegisterHistoryDto> getRegisterHistory(User user, Pageable pageable){
181+
public PageResponseDto<ProductRegisterHistoryDto> getRegisterHistory(User user, Pageable pageable) {
179182

180183
Long userId = user.getId();
181184

@@ -185,10 +188,32 @@ public PageResponseDto<ProductRegisterHistoryDto> getRegisterHistory(User user,
185188
return new PageResponseDto<>(result);
186189
}
187190

191+
public List<ProductSearchResponseDto.ProductPrevDto> getProductBasedRecommendation(Long productId, int limit) {
192+
193+
ProductBasedRecommendationRequestDto request =
194+
new ProductBasedRecommendationRequestDto(productId.intValue(), limit);
195+
196+
ProductBasedRecommendationResponseDto response =
197+
pythonAnalysisClient.getProductBasedRecommendation(request).block();
198+
199+
if (response == null) {
200+
log.warn("No recommendation response for product ID: {}", productId);
201+
throw new GeneralException(NO_RECOMMENDATION_PRODUCT_BASED);
202+
}
203+
204+
List<Product> productList = response.getRecommendations().stream()
205+
.map(r -> productRepository.findById(r.getProductId())
206+
.orElseThrow(() -> new GeneralException(PRODUCT_NOT_EXISTED)))
207+
.toList();
208+
209+
return productList.stream().map(ProductConverter::toProductPrevDto).toList();
210+
}
211+
212+
188213
@Async
189214
public void requestAnalysisAsync(Long productId, List<String> imageUrls) {
190215
log.info("Starting async analysis for product ID: {}", productId);
191-
216+
192217
if (imageUrls == null || imageUrls.isEmpty()) {
193218
log.warn("No images to analyze for product ID: {}", productId);
194219
updateProductStatus(productId, DecodeStatus.FAILED, "No images provided for analysis");
@@ -218,7 +243,7 @@ public void requestAnalysisAsync(Long productId, List<String> imageUrls) {
218243
@Transactional
219244
public void processAnalysisResult(Long productId, AnalysisResponseDto response) {
220245
log.info("Processing analysis result for product ID: {} with status: {}", productId, response.getDecodeStatus());
221-
246+
222247
try {
223248
Product product = productRepository.findById(productId)
224249
.orElseThrow(() -> new GeneralException(PRODUCT_NOT_EXISTED));
@@ -244,10 +269,10 @@ public void updateProductStatus(Long productId, DecodeStatus status, String mess
244269
try {
245270
Product product = productRepository.findById(productId)
246271
.orElseThrow(() -> new GeneralException(PRODUCT_NOT_EXISTED));
247-
272+
248273
product.setDecodeStatus(status);
249274
productRepository.save(product);
250-
275+
251276
log.info("Updated product ID: {} status to: {} - {}", productId, status, message);
252277
} catch (Exception e) {
253278
log.error("Failed to update product status for ID: {}", productId, e);
@@ -256,7 +281,7 @@ public void updateProductStatus(Long productId, DecodeStatus status, String mess
256281

257282
private void saveNutritionInfo(Long productId, AnalysisResponseDto response) {
258283
log.info("Saving nutrition info for product ID: {}", productId);
259-
284+
260285
try {
261286
Product product = productRepository.findById(productId)
262287
.orElseThrow(() -> new GeneralException(PRODUCT_NOT_EXISTED));
@@ -277,7 +302,7 @@ private void saveNutritionInfo(Long productId, AnalysisResponseDto response) {
277302
.sugar(parseDouble(response.getNutrition_info().getSugar()))
278303
.transFat(parseDouble(response.getNutrition_info().getTrans_fat()))
279304
.build();
280-
305+
281306
productNutritionRepository.save(nutrition);
282307
log.info("Saved nutrition info for product ID: {}", productId);
283308
}
@@ -366,7 +391,7 @@ public ProductLikeResponseDTO addOrUpdateLike(Long userId, Long productId) {
366391
// 이미 눌렀으면 → 좋아요 취소
367392
productLikeRepository.delete(existingLike.get());
368393
isLiked = false;
369-
userBehaviorService.deleteUserBehavior(user,product, Behavior.LIKE);
394+
userBehaviorService.deleteUserBehavior(user, product, Behavior.LIKE);
370395

371396
} else {
372397
// 처음 누르면 → 좋아요 추가
@@ -376,7 +401,7 @@ public ProductLikeResponseDTO addOrUpdateLike(Long userId, Long productId) {
376401
.build();
377402
productLikeRepository.save(productLike);
378403
isLiked = true;
379-
userBehaviorService.saveUserBehavior(user,product, Behavior.LIKE);
404+
userBehaviorService.saveUserBehavior(user, product, Behavior.LIKE);
380405
}
381406
return ProductConverter.toProductLikeDTO(productId, isLiked);
382407
}

src/main/java/com/DecodEat/global/apiPayload/code/status/ErrorStatus.java

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,9 @@ public enum ErrorStatus implements BaseErrorCode {
2222
PAGE_OUT_OF_RANGE(HttpStatus.BAD_REQUEST,"SEARCH_400","요청한 페이지가 전체 페이지 수를 초과합니다."),
2323
NO_RESULT(HttpStatus.NOT_FOUND,"SEARCH_401","검색 결과가 없습니다."),
2424

25+
// 추천
26+
NO_RECOMMENDATION_PRODUCT_BASED(HttpStatus.NOT_FOUND,"RECOMMENDATION_400","유사한 상품이 존재하지 않습니다."),
27+
2528
// 기본 에러
2629
_BAD_REQUEST(HttpStatus.BAD_REQUEST, "COMMON_400", "잘못된 요청입니다."),
2730
_UNAUTHORIZED(HttpStatus.UNAUTHORIZED, "COMMON_401", "인증이 필요합니다."),

0 commit comments

Comments
 (0)