diff --git a/src/main/java/com/DecodEat/DecodEatApplication.java b/src/main/java/com/DecodEat/DecodEatApplication.java index 746da48..341133b 100644 --- a/src/main/java/com/DecodEat/DecodEatApplication.java +++ b/src/main/java/com/DecodEat/DecodEatApplication.java @@ -3,9 +3,11 @@ import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; import org.springframework.data.jpa.repository.config.EnableJpaAuditing; +import org.springframework.scheduling.annotation.EnableScheduling; @SpringBootApplication @EnableJpaAuditing +@EnableScheduling public class DecodEatApplication { public static void main(String[] args) { 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 f03f510..fc0f8d2 100644 --- a/src/main/java/com/DecodEat/domain/products/controller/ProductController.java +++ b/src/main/java/com/DecodEat/domain/products/controller/ProductController.java @@ -4,15 +4,22 @@ import com.DecodEat.domain.products.dto.request.ProductRegisterRequestDto; import com.DecodEat.domain.products.dto.response.ProductRegisterResponseDto; import com.DecodEat.domain.products.dto.response.ProductResponseDTO; +import com.DecodEat.domain.products.dto.response.ProductSearchResponseDto; +import com.DecodEat.domain.products.entity.RawMaterial.RawMaterialCategory; import com.DecodEat.domain.products.service.ProductService; import com.DecodEat.domain.users.entity.User; import com.DecodEat.global.apiPayload.ApiResponse; import com.DecodEat.global.common.annotation.CurrentUser; +import com.DecodEat.global.dto.PageResponseDto; import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; import org.springdoc.core.annotations.ParameterObject; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Sort; import org.springframework.http.MediaType; import org.springframework.web.bind.annotation.*; import org.springframework.web.multipart.MultipartFile; @@ -64,4 +71,27 @@ public ApiResponse getProductList( return ApiResponse.onSuccess(productService.getProducts(cursorId)); } + @GetMapping("/search/autocomplete") + @Operation(summary = "상품 검색 자동완성", description = "사용자가 입력한 상품명 키워드를 기반으로 자동완성용 상품 리스트를 최대 10개까지 반환합니다.") + public ApiResponse> searchProducts( + @Parameter(description = "검색할 상품명") + @RequestParam String productName) { + + return ApiResponse.onSuccess(productService.searchProducts(productName)); + } + + @GetMapping("/search") + @Operation(summary = "상품 검색 및 필터링", description = "상품명과 원재료 카테고리로 상품을 검색하고 필터링합니다.") + public ApiResponse> searchProducts( + @Parameter(description = "검색할 상품명") + @RequestParam(required = false) String productName, + @Parameter(description = "필터링할 세부영양소 카테고리 리스트") + @RequestParam(required = false) List categories, + @RequestParam(defaultValue = "1") int page, + @RequestParam(defaultValue = "20") int size) { + + Pageable pageable = PageRequest.of(page-1, size, Sort.by("productName").ascending()); // 0-based + return ApiResponse.onSuccess(productService.searchProducts(productName, categories, pageable)); + } + } diff --git a/src/main/java/com/DecodEat/domain/products/converter/ProductConverter.java b/src/main/java/com/DecodEat/domain/products/converter/ProductConverter.java index e4f07bf..a19683d 100644 --- a/src/main/java/com/DecodEat/domain/products/converter/ProductConverter.java +++ b/src/main/java/com/DecodEat/domain/products/converter/ProductConverter.java @@ -3,6 +3,7 @@ import com.DecodEat.domain.products.dto.response.ProductDetailDto; import com.DecodEat.domain.products.dto.response.ProductRegisterResponseDto; import com.DecodEat.domain.products.dto.response.ProductResponseDTO; +import com.DecodEat.domain.products.dto.response.ProductSearchResponseDto; import com.DecodEat.domain.products.entity.Product; import com.DecodEat.domain.products.entity.ProductNutrition; import com.DecodEat.domain.products.entity.RawMaterial.RawMaterialCategory; @@ -76,6 +77,23 @@ public static ProductResponseDTO.ProductListItemDTO toProductListItemDTO(Product .build(); } + + public static ProductSearchResponseDto.SearchResultPrevDto toSearchResultPrevDto(Product product){ + return ProductSearchResponseDto.SearchResultPrevDto.builder() + .productId(product.getProductId()) + .productName(product.getProductName()) + .build(); + } + + public static ProductSearchResponseDto.ProductPrevDto toProductPrevDto(Product product){ + return ProductSearchResponseDto.ProductPrevDto.builder() + .productId(product.getProductId()) + .manufacturer(product.getManufacturer()) + .productName(product.getProductName()) + .productImage(product.getProductImage()) + .build(); + } + // Slice → ProductListResultDTO 변환 public static ProductResponseDTO.ProductListResultDTO toProductListResultDTO(Slice slice) { List productList = slice.getContent().stream() diff --git a/src/main/java/com/DecodEat/domain/products/dto/response/ProductSearchResponseDto.java b/src/main/java/com/DecodEat/domain/products/dto/response/ProductSearchResponseDto.java new file mode 100644 index 0000000..8193e55 --- /dev/null +++ b/src/main/java/com/DecodEat/domain/products/dto/response/ProductSearchResponseDto.java @@ -0,0 +1,44 @@ +package com.DecodEat.domain.products.dto.response; + +import com.DecodEat.domain.products.entity.DecodeStatus; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.util.List; + +public class ProductSearchResponseDto { + @Getter + @Builder + public static class SearchResultPrevDto { + + @Schema(description = "상품 ID", example = "1") + private Long productId; + + @Schema(description = "상품명", example = "곰곰 육개장") + private String productName; + } + + @Getter + @Builder + @NoArgsConstructor + @AllArgsConstructor + @Schema(description = "상품 리스트 아이템") + public static class ProductPrevDto { + + @Schema(description = "상품 ID", example = "1") + private Long productId; + + @Schema(description = "제조사", example = "곰곰") + private String manufacturer; + + @Schema(description = "상품명", example = "곰곰 육개장") + private String productName; + + @Schema(description = "상품 이미지", example = "https://example.com/image.jpg") + private String productImage; + } + +} diff --git a/src/main/java/com/DecodEat/domain/products/repository/ProductRepository.java b/src/main/java/com/DecodEat/domain/products/repository/ProductRepository.java index fa0fdf9..ee011bc 100644 --- a/src/main/java/com/DecodEat/domain/products/repository/ProductRepository.java +++ b/src/main/java/com/DecodEat/domain/products/repository/ProductRepository.java @@ -1,13 +1,17 @@ package com.DecodEat.domain.products.repository; +import com.DecodEat.domain.products.entity.DecodeStatus; import com.DecodEat.domain.products.entity.Product; import org.springframework.data.domain.Pageable; import org.springframework.data.domain.Slice; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.JpaSpecificationExecutor; import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; -public interface ProductRepository extends JpaRepository { +import java.util.List; + +public interface ProductRepository extends JpaRepository, JpaSpecificationExecutor { // 최신순 (ID 기준 내림차순) + decode_status = 'COMPLETED' @Query("SELECT p FROM Product p " + @@ -16,4 +20,6 @@ public interface ProductRepository extends JpaRepository { "ORDER BY p.productId DESC") Slice findCompletedProductsByCursor(@Param("cursorId") Long cursorId, Pageable pageable); + + void deleteByDecodeStatusIn(List statuses); } diff --git a/src/main/java/com/DecodEat/domain/products/repository/ProductSpecification.java b/src/main/java/com/DecodEat/domain/products/repository/ProductSpecification.java new file mode 100644 index 0000000..380ee0f --- /dev/null +++ b/src/main/java/com/DecodEat/domain/products/repository/ProductSpecification.java @@ -0,0 +1,36 @@ +package com.DecodEat.domain.products.repository; + +import com.DecodEat.domain.products.entity.Product; +import com.DecodEat.domain.products.entity.ProductNutrition; +import com.DecodEat.domain.products.entity.ProductRawMaterial; +import com.DecodEat.domain.products.entity.RawMaterial.RawMaterial; +import com.DecodEat.domain.products.entity.RawMaterial.RawMaterialCategory; +import jakarta.persistence.criteria.Join; +import jakarta.persistence.criteria.Predicate; +import org.springframework.data.jpa.domain.Specification; + +import java.util.ArrayList; +import java.util.List; + +public class ProductSpecification { + // 상품 이름으로 검색 + public static Specification likeProductName(String productName) { + return (root, query, criteriaBuilder) -> + criteriaBuilder.like(root.get("productName"), "%" + productName + "%"); + } + + // 특정 원재료 카테고리를 포함하는 상품 검색 (핵심 로직) + public static Specification hasRawMaterialCategories(List categories) { + return (root, query, criteriaBuilder) -> { + // ingredients(원재료명)으로 상품 & 상품 원재료 테이블 조인 + Join productRawMaterialJoin = root.join("ingredients"); + + // 상품 원재료 & 원재료(세부 영양소 db) 테이블 조인 + Join rawMaterialJoin = productRawMaterialJoin.join("rawMaterial"); + + // 원재료 DB의 세부영양소(category: ex)ALLERGENS, ANIMAL_PROTEIN...) 필터링 + query.distinct(true); // 중복 처리 + return rawMaterialJoin.get("category").in(categories); + }; + } +} diff --git a/src/main/java/com/DecodEat/domain/products/scheduler/ProductCleanupScheduler.java b/src/main/java/com/DecodEat/domain/products/scheduler/ProductCleanupScheduler.java new file mode 100644 index 0000000..f2a323c --- /dev/null +++ b/src/main/java/com/DecodEat/domain/products/scheduler/ProductCleanupScheduler.java @@ -0,0 +1,32 @@ +package com.DecodEat.domain.products.scheduler; + +import com.DecodEat.domain.products.entity.DecodeStatus; +import com.DecodEat.domain.products.repository.ProductRepository; +import jakarta.transaction.TransactionScoped; +import jakarta.transaction.Transactional; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; + +import java.util.Arrays; +import java.util.List; + +@Slf4j +@Component +@RequiredArgsConstructor +public class ProductCleanupScheduler { + private final ProductRepository productRepository; + + @Scheduled(cron = "* * 3 * * *") // 초 분 시 일 월 요일 + @Transactional + public void cleanupFailedAndCanceledProducts(){ + + List targetStatuses = Arrays.asList( + DecodeStatus.FAILED, + DecodeStatus.CANCELLED); + + productRepository.deleteByDecodeStatusIn(targetStatuses); + log.atInfo().log("Product Cleanup Scheduler: Deleted {} products with decode status in {}", targetStatuses.size(), targetStatuses); + } +} 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 3a1744d..c9f542d 100644 --- a/src/main/java/com/DecodEat/domain/products/service/ProductService.java +++ b/src/main/java/com/DecodEat/domain/products/service/ProductService.java @@ -5,30 +5,35 @@ import com.DecodEat.domain.products.dto.response.ProductDetailDto; import com.DecodEat.domain.products.dto.response.ProductRegisterResponseDto; import com.DecodEat.domain.products.dto.response.ProductResponseDTO; +import com.DecodEat.domain.products.dto.response.ProductSearchResponseDto; import com.DecodEat.domain.products.entity.DecodeStatus; import com.DecodEat.domain.products.entity.Product; import com.DecodEat.domain.products.entity.ProductInfoImage; import com.DecodEat.domain.products.entity.ProductNutrition; +import com.DecodEat.domain.products.entity.RawMaterial.RawMaterialCategory; import com.DecodEat.domain.products.repository.ProductImageRepository; import com.DecodEat.domain.products.repository.ProductNutritionRepository; import com.DecodEat.domain.products.repository.ProductRepository; +import com.DecodEat.domain.products.repository.ProductSpecification; import com.DecodEat.domain.users.entity.User; import com.DecodEat.global.aws.s3.AmazonS3Manager; +import com.DecodEat.global.dto.PageResponseDto; import com.DecodEat.global.exception.GeneralException; import lombok.RequiredArgsConstructor; -import org.springframework.data.domain.PageRequest; -import org.springframework.data.domain.Pageable; -import org.springframework.data.domain.Slice; +import org.springframework.data.domain.*; +import org.springframework.data.jpa.domain.Specification; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import org.springframework.util.StringUtils; import org.springframework.web.multipart.MultipartFile; +import javax.swing.*; +import java.util.ArrayList; import java.util.List; import java.util.UUID; import java.util.stream.Collectors; -import static com.DecodEat.global.apiPayload.code.status.ErrorStatus.PRODUCT_NOT_EXISTED; -import static com.DecodEat.global.apiPayload.code.status.ErrorStatus.PRODUCT_NUTRITION_NOT_EXISTED; +import static com.DecodEat.global.apiPayload.code.status.ErrorStatus.*; @Service @RequiredArgsConstructor @@ -89,7 +94,7 @@ public ProductRegisterResponseDto addProduct(User user, ProductRegisterRequestDt productInfoImageUrls = infoImages.stream().map(ProductInfoImage::getImageUrl).toList(); } - return ProductConverter.toProductRegisterDto(savedProduct,productInfoImageUrls) ; + return ProductConverter.toProductRegisterDto(savedProduct, productInfoImageUrls); } @Transactional(readOnly = true) @@ -99,4 +104,45 @@ public ProductResponseDTO.ProductListResultDTO getProducts(Long cursorId) { return ProductConverter.toProductListResultDTO(slice); } + + public List searchProducts(String productName) { + + Specification spec = Specification.where(null); + + if (StringUtils.hasText(productName)) { + spec = spec.and(ProductSpecification.likeProductName(productName)); + } + + Pageable pageable = PageRequest.of(0, 10, Sort.by("productName").ascending()); + + return productRepository.findAll(spec, pageable) + .stream() + .map(ProductConverter::toSearchResultPrevDto) + .toList(); + } + + public PageResponseDto searchProducts(String productName, List categories, Pageable pageable) { + // Specification을 조합 + Specification spec = Specification.where(null); + + if (StringUtils.hasText(productName)) { + spec = spec.and(ProductSpecification.likeProductName(productName)); + } + + if (categories != null && !categories.isEmpty()) { + spec = spec.and(ProductSpecification.hasRawMaterialCategories(categories)); + } + + // Specification과 Pageable을 사용하여 데이터 조회 + Page pagedProducts = productRepository.findAll(spec, pageable); + + + if (pageable.getPageNumber() >= pagedProducts.getTotalPages() && pagedProducts.getTotalPages() > 0) { + throw new GeneralException(PAGE_OUT_OF_RANGE); + } + + Page result = pagedProducts.map(ProductConverter::toProductPrevDto); + + return new PageResponseDto<>(result); + } } \ No newline at end of file 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 1fdc7d5..ad01464 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 @@ -18,6 +18,10 @@ public enum ErrorStatus implements BaseErrorCode { PRODUCT_NOT_EXISTED(HttpStatus.NOT_FOUND,"PRODUCT_400","존재하지 않는 상품 입니다"), PRODUCT_NUTRITION_NOT_EXISTED(HttpStatus.CONFLICT,"PRODUCT_401","분석이 완료되지 않은 상품입니다."), + // 검색 + PAGE_OUT_OF_RANGE(HttpStatus.BAD_REQUEST,"SEARCH_400","요청한 페이지가 전체 페이지 수를 초과합니다."), + NO_RESULT(HttpStatus.NOT_FOUND,"SEARCH_401","검색 결과가 없습니다."), + // 기본 에러 _BAD_REQUEST(HttpStatus.BAD_REQUEST, "COMMON_400", "잘못된 요청입니다."), _UNAUTHORIZED(HttpStatus.UNAUTHORIZED, "COMMON_401", "인증이 필요합니다."), diff --git a/src/main/java/com/DecodEat/global/config/CorsConfig.java b/src/main/java/com/DecodEat/global/config/CorsConfig.java index e2a60e3..05910b5 100644 --- a/src/main/java/com/DecodEat/global/config/CorsConfig.java +++ b/src/main/java/com/DecodEat/global/config/CorsConfig.java @@ -15,7 +15,8 @@ public class CorsConfig { public CorsConfigurationSource corsConfigurationSource() { CorsConfiguration configuration = new CorsConfiguration(); - configuration.setAllowedOriginPatterns(List.of("*")); + configuration.setAllowedOriginPatterns(List.of( "https://decodeat.netlify.app", + "http://localhost:8080" )); configuration.setAllowedMethods(List.of("GET", "POST", "PUT", "DELETE", "OPTIONS")); configuration.setAllowedHeaders(List.of("*")); configuration.setAllowCredentials(true); // 쿠키/인증정보 포함 허용 diff --git a/src/main/java/com/DecodEat/global/dto/PageResponseDto.java b/src/main/java/com/DecodEat/global/dto/PageResponseDto.java new file mode 100644 index 0000000..01e179f --- /dev/null +++ b/src/main/java/com/DecodEat/global/dto/PageResponseDto.java @@ -0,0 +1,38 @@ +package com.DecodEat.global.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Getter; +import org.springframework.data.domain.Page; + +import java.util.List; + +@Getter +public class PageResponseDto { // 제네릭 타입으로 다른 리스트에도 재사용 가능 + + @Schema(description = "데이터 리스트") + private List content; + + @Schema(description = "현재 페이지 번호", example = "0") + private int pageNumber; + + @Schema(description = "페이지 크기", example = "10") + private int pageSize; + + @Schema(description = "전체 페이지 수", example = "15") + private int totalPages; + + @Schema(description = "전체 요소 개수", example = "145") + private long totalElements; + + @Schema(description = "마지막 페이지 여부", example = "false") + private boolean last; + + public PageResponseDto(Page page) { + this.content = page.getContent(); + this.pageNumber = page.getNumber()+1; // 0-based + this.pageSize = page.getSize(); + this.totalPages = page.getTotalPages(); + this.totalElements = page.getTotalElements(); + this.last = page.isLast(); + } +} \ No newline at end of file