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
2 changes: 2 additions & 0 deletions src/main/java/com/DecodEat/DecodEatApplication.java
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -64,4 +71,27 @@ public ApiResponse<ProductResponseDTO.ProductListResultDTO> getProductList(
return ApiResponse.onSuccess(productService.getProducts(cursorId));
}

@GetMapping("/search/autocomplete")
@Operation(summary = "상품 검색 자동완성", description = "사용자가 입력한 상품명 키워드를 기반으로 자동완성용 상품 리스트를 최대 10개까지 반환합니다.")
public ApiResponse<List<ProductSearchResponseDto.SearchResultPrevDto>> searchProducts(
@Parameter(description = "검색할 상품명")
@RequestParam String productName) {

return ApiResponse.onSuccess(productService.searchProducts(productName));
}

@GetMapping("/search")
@Operation(summary = "상품 검색 및 필터링", description = "상품명과 원재료 카테고리로 상품을 검색하고 필터링합니다.")
public ApiResponse<PageResponseDto<ProductSearchResponseDto.ProductPrevDto>> searchProducts(
@Parameter(description = "검색할 상품명")
@RequestParam(required = false) String productName,
@Parameter(description = "필터링할 세부영양소 카테고리 리스트")
@RequestParam(required = false) List<RawMaterialCategory> 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));
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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<Product> → ProductListResultDTO 변환
public static ProductResponseDTO.ProductListResultDTO toProductListResultDTO(Slice<Product> slice) {
List<ProductResponseDTO.ProductListItemDTO> productList = slice.getContent().stream()
Expand Down
Original file line number Diff line number Diff line change
@@ -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;
}

}
Original file line number Diff line number Diff line change
@@ -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<Product, Long> {
import java.util.List;

public interface ProductRepository extends JpaRepository<Product, Long>, JpaSpecificationExecutor<Product> {

// 최신순 (ID 기준 내림차순) + decode_status = 'COMPLETED'
@Query("SELECT p FROM Product p " +
Expand All @@ -16,4 +20,6 @@ public interface ProductRepository extends JpaRepository<Product, Long> {
"ORDER BY p.productId DESC")
Slice<Product> findCompletedProductsByCursor(@Param("cursorId") Long cursorId,
Pageable pageable);

void deleteByDecodeStatusIn(List<DecodeStatus> statuses);
}
Original file line number Diff line number Diff line change
@@ -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<Product> likeProductName(String productName) {
return (root, query, criteriaBuilder) ->
criteriaBuilder.like(root.get("productName"), "%" + productName + "%");
}

// 특정 원재료 카테고리를 포함하는 상품 검색 (핵심 로직)
public static Specification<Product> hasRawMaterialCategories(List<RawMaterialCategory> categories) {
return (root, query, criteriaBuilder) -> {
// ingredients(원재료명)으로 상품 & 상품 원재료 테이블 조인
Join<Product, ProductRawMaterial> productRawMaterialJoin = root.join("ingredients");

// 상품 원재료 & 원재료(세부 영양소 db) 테이블 조인
Join<ProductRawMaterial, RawMaterial> rawMaterialJoin = productRawMaterialJoin.join("rawMaterial");

// 원재료 DB의 세부영양소(category: ex)ALLERGENS, ANIMAL_PROTEIN...) 필터링
query.distinct(true); // 중복 처리
return rawMaterialJoin.get("category").in(categories);
};
}
}
Original file line number Diff line number Diff line change
@@ -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<DecodeStatus> 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);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand All @@ -99,4 +104,45 @@ public ProductResponseDTO.ProductListResultDTO getProducts(Long cursorId) {

return ProductConverter.toProductListResultDTO(slice);
}

public List<ProductSearchResponseDto.SearchResultPrevDto> searchProducts(String productName) {

Specification<Product> 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<ProductSearchResponseDto.ProductPrevDto> searchProducts(String productName, List<RawMaterialCategory> categories, Pageable pageable) {
// Specification을 조합
Specification<Product> 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<Product> pagedProducts = productRepository.findAll(spec, pageable);


if (pageable.getPageNumber() >= pagedProducts.getTotalPages() && pagedProducts.getTotalPages() > 0) {
throw new GeneralException(PAGE_OUT_OF_RANGE);
}

Page<ProductSearchResponseDto.ProductPrevDto> result = pagedProducts.map(ProductConverter::toProductPrevDto);

return new PageResponseDto<>(result);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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", "인증이 필요합니다."),
Expand Down
3 changes: 2 additions & 1 deletion src/main/java/com/DecodEat/global/config/CorsConfig.java
Original file line number Diff line number Diff line change
Expand Up @@ -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); // 쿠키/인증정보 포함 허용
Expand Down
38 changes: 38 additions & 0 deletions src/main/java/com/DecodEat/global/dto/PageResponseDto.java
Original file line number Diff line number Diff line change
@@ -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<T> { // 제네릭 타입으로 다른 리스트에도 재사용 가능

@Schema(description = "데이터 리스트")
private List<T> 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<T> 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();
}
}
Loading