Skip to content

Commit 3446abc

Browse files
authored
Merge pull request #11 from Decodeat/feat/10-products-search
[Feat] 상품 검색 api
2 parents 81ea77c + c5a598f commit 3446abc

File tree

11 files changed

+265
-8
lines changed

11 files changed

+265
-8
lines changed

src/main/java/com/DecodEat/DecodEatApplication.java

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,11 @@
33
import org.springframework.boot.SpringApplication;
44
import org.springframework.boot.autoconfigure.SpringBootApplication;
55
import org.springframework.data.jpa.repository.config.EnableJpaAuditing;
6+
import org.springframework.scheduling.annotation.EnableScheduling;
67

78
@SpringBootApplication
89
@EnableJpaAuditing
10+
@EnableScheduling
911
public class DecodEatApplication {
1012

1113
public static void main(String[] args) {

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

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,15 +4,22 @@
44
import com.DecodEat.domain.products.dto.request.ProductRegisterRequestDto;
55
import com.DecodEat.domain.products.dto.response.ProductRegisterResponseDto;
66
import com.DecodEat.domain.products.dto.response.ProductResponseDTO;
7+
import com.DecodEat.domain.products.dto.response.ProductSearchResponseDto;
8+
import com.DecodEat.domain.products.entity.RawMaterial.RawMaterialCategory;
79
import com.DecodEat.domain.products.service.ProductService;
810
import com.DecodEat.domain.users.entity.User;
911
import com.DecodEat.global.apiPayload.ApiResponse;
1012
import com.DecodEat.global.common.annotation.CurrentUser;
13+
import com.DecodEat.global.dto.PageResponseDto;
1114
import io.swagger.v3.oas.annotations.Operation;
15+
import io.swagger.v3.oas.annotations.Parameter;
1216
import io.swagger.v3.oas.annotations.tags.Tag;
1317
import jakarta.validation.Valid;
1418
import lombok.RequiredArgsConstructor;
1519
import org.springdoc.core.annotations.ParameterObject;
20+
import org.springframework.data.domain.PageRequest;
21+
import org.springframework.data.domain.Pageable;
22+
import org.springframework.data.domain.Sort;
1623
import org.springframework.http.MediaType;
1724
import org.springframework.web.bind.annotation.*;
1825
import org.springframework.web.multipart.MultipartFile;
@@ -64,4 +71,27 @@ public ApiResponse<ProductResponseDTO.ProductListResultDTO> getProductList(
6471
return ApiResponse.onSuccess(productService.getProducts(cursorId));
6572
}
6673

74+
@GetMapping("/search/autocomplete")
75+
@Operation(summary = "상품 검색 자동완성", description = "사용자가 입력한 상품명 키워드를 기반으로 자동완성용 상품 리스트를 최대 10개까지 반환합니다.")
76+
public ApiResponse<List<ProductSearchResponseDto.SearchResultPrevDto>> searchProducts(
77+
@Parameter(description = "검색할 상품명")
78+
@RequestParam String productName) {
79+
80+
return ApiResponse.onSuccess(productService.searchProducts(productName));
81+
}
82+
83+
@GetMapping("/search")
84+
@Operation(summary = "상품 검색 및 필터링", description = "상품명과 원재료 카테고리로 상품을 검색하고 필터링합니다.")
85+
public ApiResponse<PageResponseDto<ProductSearchResponseDto.ProductPrevDto>> searchProducts(
86+
@Parameter(description = "검색할 상품명")
87+
@RequestParam(required = false) String productName,
88+
@Parameter(description = "필터링할 세부영양소 카테고리 리스트")
89+
@RequestParam(required = false) List<RawMaterialCategory> categories,
90+
@RequestParam(defaultValue = "1") int page,
91+
@RequestParam(defaultValue = "20") int size) {
92+
93+
Pageable pageable = PageRequest.of(page-1, size, Sort.by("productName").ascending()); // 0-based
94+
return ApiResponse.onSuccess(productService.searchProducts(productName, categories, pageable));
95+
}
96+
6797
}

src/main/java/com/DecodEat/domain/products/converter/ProductConverter.java

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
import com.DecodEat.domain.products.dto.response.ProductDetailDto;
44
import com.DecodEat.domain.products.dto.response.ProductRegisterResponseDto;
55
import com.DecodEat.domain.products.dto.response.ProductResponseDTO;
6+
import com.DecodEat.domain.products.dto.response.ProductSearchResponseDto;
67
import com.DecodEat.domain.products.entity.Product;
78
import com.DecodEat.domain.products.entity.ProductNutrition;
89
import com.DecodEat.domain.products.entity.RawMaterial.RawMaterialCategory;
@@ -76,6 +77,23 @@ public static ProductResponseDTO.ProductListItemDTO toProductListItemDTO(Product
7677
.build();
7778
}
7879

80+
81+
public static ProductSearchResponseDto.SearchResultPrevDto toSearchResultPrevDto(Product product){
82+
return ProductSearchResponseDto.SearchResultPrevDto.builder()
83+
.productId(product.getProductId())
84+
.productName(product.getProductName())
85+
.build();
86+
}
87+
88+
public static ProductSearchResponseDto.ProductPrevDto toProductPrevDto(Product product){
89+
return ProductSearchResponseDto.ProductPrevDto.builder()
90+
.productId(product.getProductId())
91+
.manufacturer(product.getManufacturer())
92+
.productName(product.getProductName())
93+
.productImage(product.getProductImage())
94+
.build();
95+
}
96+
7997
// Slice<Product> → ProductListResultDTO 변환
8098
public static ProductResponseDTO.ProductListResultDTO toProductListResultDTO(Slice<Product> slice) {
8199
List<ProductResponseDTO.ProductListItemDTO> productList = slice.getContent().stream()
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
package com.DecodEat.domain.products.dto.response;
2+
3+
import com.DecodEat.domain.products.entity.DecodeStatus;
4+
import io.swagger.v3.oas.annotations.media.Schema;
5+
import lombok.AllArgsConstructor;
6+
import lombok.Builder;
7+
import lombok.Getter;
8+
import lombok.NoArgsConstructor;
9+
10+
import java.util.List;
11+
12+
public class ProductSearchResponseDto {
13+
@Getter
14+
@Builder
15+
public static class SearchResultPrevDto {
16+
17+
@Schema(description = "상품 ID", example = "1")
18+
private Long productId;
19+
20+
@Schema(description = "상품명", example = "곰곰 육개장")
21+
private String productName;
22+
}
23+
24+
@Getter
25+
@Builder
26+
@NoArgsConstructor
27+
@AllArgsConstructor
28+
@Schema(description = "상품 리스트 아이템")
29+
public static class ProductPrevDto {
30+
31+
@Schema(description = "상품 ID", example = "1")
32+
private Long productId;
33+
34+
@Schema(description = "제조사", example = "곰곰")
35+
private String manufacturer;
36+
37+
@Schema(description = "상품명", example = "곰곰 육개장")
38+
private String productName;
39+
40+
@Schema(description = "상품 이미지", example = "https://example.com/image.jpg")
41+
private String productImage;
42+
}
43+
44+
}
Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,17 @@
11
package com.DecodEat.domain.products.repository;
22

3+
import com.DecodEat.domain.products.entity.DecodeStatus;
34
import com.DecodEat.domain.products.entity.Product;
45
import org.springframework.data.domain.Pageable;
56
import org.springframework.data.domain.Slice;
67
import org.springframework.data.jpa.repository.JpaRepository;
8+
import org.springframework.data.jpa.repository.JpaSpecificationExecutor;
79
import org.springframework.data.jpa.repository.Query;
810
import org.springframework.data.repository.query.Param;
911

10-
public interface ProductRepository extends JpaRepository<Product, Long> {
12+
import java.util.List;
13+
14+
public interface ProductRepository extends JpaRepository<Product, Long>, JpaSpecificationExecutor<Product> {
1115

1216
// 최신순 (ID 기준 내림차순) + decode_status = 'COMPLETED'
1317
@Query("SELECT p FROM Product p " +
@@ -16,4 +20,6 @@ public interface ProductRepository extends JpaRepository<Product, Long> {
1620
"ORDER BY p.productId DESC")
1721
Slice<Product> findCompletedProductsByCursor(@Param("cursorId") Long cursorId,
1822
Pageable pageable);
23+
24+
void deleteByDecodeStatusIn(List<DecodeStatus> statuses);
1925
}
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
package com.DecodEat.domain.products.repository;
2+
3+
import com.DecodEat.domain.products.entity.Product;
4+
import com.DecodEat.domain.products.entity.ProductNutrition;
5+
import com.DecodEat.domain.products.entity.ProductRawMaterial;
6+
import com.DecodEat.domain.products.entity.RawMaterial.RawMaterial;
7+
import com.DecodEat.domain.products.entity.RawMaterial.RawMaterialCategory;
8+
import jakarta.persistence.criteria.Join;
9+
import jakarta.persistence.criteria.Predicate;
10+
import org.springframework.data.jpa.domain.Specification;
11+
12+
import java.util.ArrayList;
13+
import java.util.List;
14+
15+
public class ProductSpecification {
16+
// 상품 이름으로 검색
17+
public static Specification<Product> likeProductName(String productName) {
18+
return (root, query, criteriaBuilder) ->
19+
criteriaBuilder.like(root.get("productName"), "%" + productName + "%");
20+
}
21+
22+
// 특정 원재료 카테고리를 포함하는 상품 검색 (핵심 로직)
23+
public static Specification<Product> hasRawMaterialCategories(List<RawMaterialCategory> categories) {
24+
return (root, query, criteriaBuilder) -> {
25+
// ingredients(원재료명)으로 상품 & 상품 원재료 테이블 조인
26+
Join<Product, ProductRawMaterial> productRawMaterialJoin = root.join("ingredients");
27+
28+
// 상품 원재료 & 원재료(세부 영양소 db) 테이블 조인
29+
Join<ProductRawMaterial, RawMaterial> rawMaterialJoin = productRawMaterialJoin.join("rawMaterial");
30+
31+
// 원재료 DB의 세부영양소(category: ex)ALLERGENS, ANIMAL_PROTEIN...) 필터링
32+
query.distinct(true); // 중복 처리
33+
return rawMaterialJoin.get("category").in(categories);
34+
};
35+
}
36+
}
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
package com.DecodEat.domain.products.scheduler;
2+
3+
import com.DecodEat.domain.products.entity.DecodeStatus;
4+
import com.DecodEat.domain.products.repository.ProductRepository;
5+
import jakarta.transaction.TransactionScoped;
6+
import jakarta.transaction.Transactional;
7+
import lombok.RequiredArgsConstructor;
8+
import lombok.extern.slf4j.Slf4j;
9+
import org.springframework.scheduling.annotation.Scheduled;
10+
import org.springframework.stereotype.Component;
11+
12+
import java.util.Arrays;
13+
import java.util.List;
14+
15+
@Slf4j
16+
@Component
17+
@RequiredArgsConstructor
18+
public class ProductCleanupScheduler {
19+
private final ProductRepository productRepository;
20+
21+
@Scheduled(cron = "* * 3 * * *") // 초 분 시 일 월 요일
22+
@Transactional
23+
public void cleanupFailedAndCanceledProducts(){
24+
25+
List<DecodeStatus> targetStatuses = Arrays.asList(
26+
DecodeStatus.FAILED,
27+
DecodeStatus.CANCELLED);
28+
29+
productRepository.deleteByDecodeStatusIn(targetStatuses);
30+
log.atInfo().log("Product Cleanup Scheduler: Deleted {} products with decode status in {}", targetStatuses.size(), targetStatuses);
31+
}
32+
}

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

Lines changed: 52 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -5,30 +5,35 @@
55
import com.DecodEat.domain.products.dto.response.ProductDetailDto;
66
import com.DecodEat.domain.products.dto.response.ProductRegisterResponseDto;
77
import com.DecodEat.domain.products.dto.response.ProductResponseDTO;
8+
import com.DecodEat.domain.products.dto.response.ProductSearchResponseDto;
89
import com.DecodEat.domain.products.entity.DecodeStatus;
910
import com.DecodEat.domain.products.entity.Product;
1011
import com.DecodEat.domain.products.entity.ProductInfoImage;
1112
import com.DecodEat.domain.products.entity.ProductNutrition;
13+
import com.DecodEat.domain.products.entity.RawMaterial.RawMaterialCategory;
1214
import com.DecodEat.domain.products.repository.ProductImageRepository;
1315
import com.DecodEat.domain.products.repository.ProductNutritionRepository;
1416
import com.DecodEat.domain.products.repository.ProductRepository;
17+
import com.DecodEat.domain.products.repository.ProductSpecification;
1518
import com.DecodEat.domain.users.entity.User;
1619
import com.DecodEat.global.aws.s3.AmazonS3Manager;
20+
import com.DecodEat.global.dto.PageResponseDto;
1721
import com.DecodEat.global.exception.GeneralException;
1822
import lombok.RequiredArgsConstructor;
19-
import org.springframework.data.domain.PageRequest;
20-
import org.springframework.data.domain.Pageable;
21-
import org.springframework.data.domain.Slice;
23+
import org.springframework.data.domain.*;
24+
import org.springframework.data.jpa.domain.Specification;
2225
import org.springframework.stereotype.Service;
2326
import org.springframework.transaction.annotation.Transactional;
27+
import org.springframework.util.StringUtils;
2428
import org.springframework.web.multipart.MultipartFile;
2529

30+
import javax.swing.*;
31+
import java.util.ArrayList;
2632
import java.util.List;
2733
import java.util.UUID;
2834
import java.util.stream.Collectors;
2935

30-
import static com.DecodEat.global.apiPayload.code.status.ErrorStatus.PRODUCT_NOT_EXISTED;
31-
import static com.DecodEat.global.apiPayload.code.status.ErrorStatus.PRODUCT_NUTRITION_NOT_EXISTED;
36+
import static com.DecodEat.global.apiPayload.code.status.ErrorStatus.*;
3237

3338
@Service
3439
@RequiredArgsConstructor
@@ -89,7 +94,7 @@ public ProductRegisterResponseDto addProduct(User user, ProductRegisterRequestDt
8994
productInfoImageUrls = infoImages.stream().map(ProductInfoImage::getImageUrl).toList();
9095
}
9196

92-
return ProductConverter.toProductRegisterDto(savedProduct,productInfoImageUrls) ;
97+
return ProductConverter.toProductRegisterDto(savedProduct, productInfoImageUrls);
9398
}
9499

95100
@Transactional(readOnly = true)
@@ -99,4 +104,45 @@ public ProductResponseDTO.ProductListResultDTO getProducts(Long cursorId) {
99104

100105
return ProductConverter.toProductListResultDTO(slice);
101106
}
107+
108+
public List<ProductSearchResponseDto.SearchResultPrevDto> searchProducts(String productName) {
109+
110+
Specification<Product> spec = Specification.where(null);
111+
112+
if (StringUtils.hasText(productName)) {
113+
spec = spec.and(ProductSpecification.likeProductName(productName));
114+
}
115+
116+
Pageable pageable = PageRequest.of(0, 10, Sort.by("productName").ascending());
117+
118+
return productRepository.findAll(spec, pageable)
119+
.stream()
120+
.map(ProductConverter::toSearchResultPrevDto)
121+
.toList();
122+
}
123+
124+
public PageResponseDto<ProductSearchResponseDto.ProductPrevDto> searchProducts(String productName, List<RawMaterialCategory> categories, Pageable pageable) {
125+
// Specification을 조합
126+
Specification<Product> spec = Specification.where(null);
127+
128+
if (StringUtils.hasText(productName)) {
129+
spec = spec.and(ProductSpecification.likeProductName(productName));
130+
}
131+
132+
if (categories != null && !categories.isEmpty()) {
133+
spec = spec.and(ProductSpecification.hasRawMaterialCategories(categories));
134+
}
135+
136+
// Specification과 Pageable을 사용하여 데이터 조회
137+
Page<Product> pagedProducts = productRepository.findAll(spec, pageable);
138+
139+
140+
if (pageable.getPageNumber() >= pagedProducts.getTotalPages() && pagedProducts.getTotalPages() > 0) {
141+
throw new GeneralException(PAGE_OUT_OF_RANGE);
142+
}
143+
144+
Page<ProductSearchResponseDto.ProductPrevDto> result = pagedProducts.map(ProductConverter::toProductPrevDto);
145+
146+
return new PageResponseDto<>(result);
147+
}
102148
}

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

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,10 @@ public enum ErrorStatus implements BaseErrorCode {
1818
PRODUCT_NOT_EXISTED(HttpStatus.NOT_FOUND,"PRODUCT_400","존재하지 않는 상품 입니다"),
1919
PRODUCT_NUTRITION_NOT_EXISTED(HttpStatus.CONFLICT,"PRODUCT_401","분석이 완료되지 않은 상품입니다."),
2020

21+
// 검색
22+
PAGE_OUT_OF_RANGE(HttpStatus.BAD_REQUEST,"SEARCH_400","요청한 페이지가 전체 페이지 수를 초과합니다."),
23+
NO_RESULT(HttpStatus.NOT_FOUND,"SEARCH_401","검색 결과가 없습니다."),
24+
2125
// 기본 에러
2226
_BAD_REQUEST(HttpStatus.BAD_REQUEST, "COMMON_400", "잘못된 요청입니다."),
2327
_UNAUTHORIZED(HttpStatus.UNAUTHORIZED, "COMMON_401", "인증이 필요합니다."),

src/main/java/com/DecodEat/global/config/CorsConfig.java

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,8 @@ public class CorsConfig {
1515
public CorsConfigurationSource corsConfigurationSource() {
1616
CorsConfiguration configuration = new CorsConfiguration();
1717

18-
configuration.setAllowedOriginPatterns(List.of("*"));
18+
configuration.setAllowedOriginPatterns(List.of( "https://decodeat.netlify.app",
19+
"http://localhost:8080" ));
1920
configuration.setAllowedMethods(List.of("GET", "POST", "PUT", "DELETE", "OPTIONS"));
2021
configuration.setAllowedHeaders(List.of("*"));
2122
configuration.setAllowCredentials(true); // 쿠키/인증정보 포함 허용

0 commit comments

Comments
 (0)