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 3dbb29f..f03f510 100644 --- a/src/main/java/com/DecodEat/domain/products/controller/ProductController.java +++ b/src/main/java/com/DecodEat/domain/products/controller/ProductController.java @@ -3,6 +3,7 @@ import com.DecodEat.domain.products.dto.response.ProductDetailDto; 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.service.ProductService; import com.DecodEat.domain.users.entity.User; import com.DecodEat.global.apiPayload.ApiResponse; @@ -52,5 +53,15 @@ public ApiResponse registerProduct( return ApiResponse.onSuccess(productService.addProduct(user, requestDto, productImage, productInfoImages)); } + @Operation( + summary = "홈화면 상품 추천 (최신순)", + description = "무한스크롤 방식으로 decode_status(분석 상태)가 COMPLETED인 상품만 최신순 정렬된 상품 목록을 조회합니다.\n" + + "cursorId가 없으면 첫 페이지, 있으면 해당 ID보다 작은 상품들을 불러옵니다." + ) + @GetMapping("/latest") + public ApiResponse getProductList( + @RequestParam(required = false) Long cursorId) { + return ApiResponse.onSuccess(productService.getProducts(cursorId)); + } } 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 5c3e2ce..e4f07bf 100644 --- a/src/main/java/com/DecodEat/domain/products/converter/ProductConverter.java +++ b/src/main/java/com/DecodEat/domain/products/converter/ProductConverter.java @@ -2,9 +2,11 @@ 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.entity.Product; import com.DecodEat.domain.products.entity.ProductNutrition; import com.DecodEat.domain.products.entity.RawMaterial.RawMaterialCategory; +import org.springframework.data.domain.Slice; import java.util.List; import java.util.Map; @@ -63,4 +65,33 @@ public static ProductRegisterResponseDto toProductRegisterDto(Product product, L .productInfoImages(productInfoImageUrls) .build(); } + + // 단일 Product → ProductListItemDTO 변환 + public static ProductResponseDTO.ProductListItemDTO toProductListItemDTO(Product product){ + return ProductResponseDTO.ProductListItemDTO.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() + .map(ProductConverter::toProductListItemDTO) + .toList(); + + Long nextCursorId = (slice.hasNext() && !productList.isEmpty()) + ? productList.get(productList.size() - 1).getProductId() + : null; + + return ProductResponseDTO.ProductListResultDTO.builder() + .productList(productList) + .productListSize(productList.size()) + .isFirst(slice.isFirst()) + .hasNext(slice.hasNext()) + .nextCursorId(nextCursorId) + .build(); + } } diff --git a/src/main/java/com/DecodEat/domain/products/dto/response/ProductResponseDTO.java b/src/main/java/com/DecodEat/domain/products/dto/response/ProductResponseDTO.java new file mode 100644 index 0000000..6a951ce --- /dev/null +++ b/src/main/java/com/DecodEat/domain/products/dto/response/ProductResponseDTO.java @@ -0,0 +1,60 @@ +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 ProductResponseDTO { + + @Getter + @Builder + @AllArgsConstructor + @NoArgsConstructor + @Schema(description = "상품 리스트 (무한스크롤)") + public static class ProductListResultDTO { + + @Schema(description = "상품 목록") + private List productList; + + @Schema(description = "현재 페이지 상품 개수", example = "10") + private Integer productListSize; + + @Schema(description = "페이지 처음 여부", example = "true") + private Boolean isFirst; + + @Schema(description = "다음 페이지 여부", example = "true") + private Boolean hasNext; + + @Schema(description = "다음 커서 ID (무한스크롤용)", example = "11") + private Long nextCursorId; + } + + @Getter + @Builder + @NoArgsConstructor + @AllArgsConstructor + @Schema(description = "상품 리스트 아이템") + public static class ProductListItemDTO { + + @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; + + @Schema(description = "뷴석 상태", example = "COMPLETED") + private DecodeStatus decodeStatus; + } + +} diff --git a/src/main/java/com/DecodEat/domain/products/entity/ProductInfoImage.java b/src/main/java/com/DecodEat/domain/products/entity/ProductInfoImage.java index 41a75c1..103b0bf 100644 --- a/src/main/java/com/DecodEat/domain/products/entity/ProductInfoImage.java +++ b/src/main/java/com/DecodEat/domain/products/entity/ProductInfoImage.java @@ -5,7 +5,7 @@ import lombok.*; @Entity -@Table(name = "product_image") +@Table(name = "product_info_image") @Getter @Setter @NoArgsConstructor 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 35edc07..fa0fdf9 100644 --- a/src/main/java/com/DecodEat/domain/products/repository/ProductRepository.java +++ b/src/main/java/com/DecodEat/domain/products/repository/ProductRepository.java @@ -1,7 +1,19 @@ package com.DecodEat.domain.products.repository; 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.Query; +import org.springframework.data.repository.query.Param; public interface ProductRepository extends JpaRepository { + + // 최신순 (ID 기준 내림차순) + decode_status = 'COMPLETED' + @Query("SELECT p FROM Product p " + + "WHERE p.decodeStatus = 'COMPLETED' " + + "AND (:cursorId IS NULL OR p.productId < :cursorId) " + + "ORDER BY p.productId DESC") + Slice findCompletedProductsByCursor(@Param("cursorId") Long cursorId, + Pageable pageable); } 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 2f582fa..3a1744d 100644 --- a/src/main/java/com/DecodEat/domain/products/service/ProductService.java +++ b/src/main/java/com/DecodEat/domain/products/service/ProductService.java @@ -4,6 +4,7 @@ import com.DecodEat.domain.products.dto.request.ProductRegisterRequestDto; 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.entity.DecodeStatus; import com.DecodEat.domain.products.entity.Product; import com.DecodEat.domain.products.entity.ProductInfoImage; @@ -15,6 +16,9 @@ import com.DecodEat.global.aws.s3.AmazonS3Manager; 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.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import org.springframework.web.multipart.MultipartFile; @@ -35,6 +39,9 @@ public class ProductService { private final ProductNutritionRepository productNutritionRepository; private final AmazonS3Manager amazonS3Manager; + + private static final int PAGE_SIZE = 12; + public ProductDetailDto getDetail(Long id) { Product product = productRepository.findById(id).orElseThrow(() -> new GeneralException(PRODUCT_NOT_EXISTED)); @@ -84,4 +91,12 @@ public ProductRegisterResponseDto addProduct(User user, ProductRegisterRequestDt return ProductConverter.toProductRegisterDto(savedProduct,productInfoImageUrls) ; } + + @Transactional(readOnly = true) + public ProductResponseDTO.ProductListResultDTO getProducts(Long cursorId) { + Pageable pageable = PageRequest.of(0, PAGE_SIZE); + Slice slice = productRepository.findCompletedProductsByCursor(cursorId, pageable); + + return ProductConverter.toProductListResultDTO(slice); + } } \ 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 86fc5f1..1fdc7d5 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 @@ -16,7 +16,7 @@ public enum ErrorStatus implements BaseErrorCode { // 상품 PRODUCT_NOT_EXISTED(HttpStatus.NOT_FOUND,"PRODUCT_400","존재하지 않는 상품 입니다"), - PRODUCT_NUTRITION_NOT_EXISTED(HttpStatus.NOT_FOUND,"PRODUCT_401","분석이 완료되지 않은 상품입니다."), + PRODUCT_NUTRITION_NOT_EXISTED(HttpStatus.CONFLICT,"PRODUCT_401","분석이 완료되지 않은 상품입니다."), // 기본 에러 _BAD_REQUEST(HttpStatus.BAD_REQUEST, "COMMON_400", "잘못된 요청입니다."),