diff --git a/src/main/java/com/funeat/product/application/ProductService.java b/src/main/java/com/funeat/product/application/ProductService.java index 7efa1781..64c710b8 100644 --- a/src/main/java/com/funeat/product/application/ProductService.java +++ b/src/main/java/com/funeat/product/application/ProductService.java @@ -34,12 +34,13 @@ import com.funeat.recipe.dto.SortingRecipesResponse; import com.funeat.recipe.persistence.RecipeImageRepository; import com.funeat.recipe.persistence.RecipeRepository; -import com.funeat.review.persistence.ReviewRepository; import com.funeat.review.persistence.ReviewTagRepository; import com.funeat.tag.domain.Tag; + import java.util.Comparator; import java.util.List; import java.util.stream.Collectors; + import org.springframework.data.domain.Page; import org.springframework.data.domain.PageRequest; import org.springframework.data.domain.Pageable; @@ -61,7 +62,6 @@ public class ProductService { private final CategoryRepository categoryRepository; private final ProductRepository productRepository; private final ReviewTagRepository reviewTagRepository; - private final ReviewRepository reviewRepository; private final ProductRecipeRepository productRecipeRepository; private final RecipeImageRepository recipeImageRepository; private final RecipeRepository recipeRepository; @@ -69,15 +69,13 @@ public class ProductService { private final RecipeFavoriteRepository recipeFavoriteRepository; public ProductService(final CategoryRepository categoryRepository, final ProductRepository productRepository, - final ReviewTagRepository reviewTagRepository, final ReviewRepository reviewRepository, + final ReviewTagRepository reviewTagRepository, final ProductRecipeRepository productRecipeRepository, - final RecipeImageRepository recipeImageRepository, - final RecipeRepository recipeRepository, final MemberRepository memberRepository, - final RecipeFavoriteRepository recipeFavoriteRepository) { + final RecipeImageRepository recipeImageRepository, final RecipeRepository recipeRepository, + final MemberRepository memberRepository, final RecipeFavoriteRepository recipeFavoriteRepository) { this.categoryRepository = categoryRepository; this.productRepository = productRepository; this.reviewTagRepository = reviewTagRepository; - this.reviewRepository = reviewRepository; this.productRecipeRepository = productRecipeRepository; this.recipeImageRepository = recipeImageRepository; this.recipeRepository = recipeRepository; @@ -211,4 +209,25 @@ private RecipeDto createRecipeDto(final Long memberId, final Recipe recipe) { final Boolean favorite = recipeFavoriteRepository.existsByMemberAndRecipeAndFavoriteTrue(member, recipe); return RecipeDto.toDto(recipe, images, products, favorite); } + + public SearchProductsResponse getSearchResultsByTag(final Long tagId, final Long lastProductId) { + final List findProducts = findAllByTag(tagId, lastProductId); + final int resultSize = getResultSize(findProducts); + final List products = findProducts.subList(0, resultSize); + + final boolean hasNext = hasNextPage(findProducts); + final List productDtos = products.stream() + .map(SearchProductDto::toDto) + .toList(); + + return SearchProductsResponse.toResponse(hasNext, productDtos); + } + + private List findAllByTag(Long tagId, Long lastProductId) { + final PageRequest size = PageRequest.ofSize(DEFAULT_CURSOR_PAGINATION_SIZE); + if (lastProductId == 0) { + return productRepository.searchProductsByTopTagsFirst(tagId, size); + } + return productRepository.searchProductsByTopTags(tagId, lastProductId, size); + } } diff --git a/src/main/java/com/funeat/product/persistence/ProductRepository.java b/src/main/java/com/funeat/product/persistence/ProductRepository.java index 7d4e2161..90e714d0 100644 --- a/src/main/java/com/funeat/product/persistence/ProductRepository.java +++ b/src/main/java/com/funeat/product/persistence/ProductRepository.java @@ -4,11 +4,13 @@ import com.funeat.product.domain.Product; import com.funeat.product.dto.ProductReviewCountDto; import java.util.List; + +import org.springframework.data.domain.PageRequest; import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; -public interface ProductRepository extends BaseRepository { +public interface ProductRepository extends BaseRepository, ProductRepositoryCustom { @Query("SELECT new com.funeat.product.dto.ProductReviewCountDto(p, COUNT(r.id)) " + "FROM Product p " @@ -53,4 +55,19 @@ List findAllWithReviewCountByNameContainingFirst(@Param(" + "ORDER BY (CASE WHEN p.name LIKE CONCAT(:name, '%') THEN 1 ELSE 2 END), p.id DESC") List findAllWithReviewCountByNameContaining(@Param("name") final String name, final Long lastId, final Pageable pageable); + + @Query("SELECT DISTINCT p FROM Product p " + + "JOIN Review r on r.product.id = p.id " + + "LEFT JOIN ReviewTag rt on rt.review.id = r.id " + + "WHERE rt.tag.id = :tagId " + + "ORDER BY p.id DESC") + List findAllByTagFirst(@Param("tagId") Long tagId, Pageable pageable); + + @Query("SELECT DISTINCT p FROM Product p " + + "JOIN Review r on r.product.id = p.id " + + "LEFT JOIN ReviewTag rt on rt.review.id = r.id " + + "WHERE rt.tag.id = :tagId " + + "AND p.id < :lastId " + + "ORDER BY p.id DESC") + List findAllByTag(@Param("tagId") final Long tagId, @Param("lastId") final Long lastId, final Pageable pageable); } diff --git a/src/main/java/com/funeat/product/persistence/ProductRepositoryCustom.java b/src/main/java/com/funeat/product/persistence/ProductRepositoryCustom.java new file mode 100644 index 00000000..57824a99 --- /dev/null +++ b/src/main/java/com/funeat/product/persistence/ProductRepositoryCustom.java @@ -0,0 +1,13 @@ +package com.funeat.product.persistence; + +import com.funeat.product.domain.Product; +import org.springframework.data.domain.Pageable; + +import java.util.List; + +public interface ProductRepositoryCustom { + + List searchProductsByTopTagsFirst(final Long tagId, final Pageable pageable); + + List searchProductsByTopTags(final Long tagId, final Long lastProductId, final Pageable pageable); +} diff --git a/src/main/java/com/funeat/product/persistence/ProductRepositoryImpl.java b/src/main/java/com/funeat/product/persistence/ProductRepositoryImpl.java new file mode 100644 index 00000000..0903074f --- /dev/null +++ b/src/main/java/com/funeat/product/persistence/ProductRepositoryImpl.java @@ -0,0 +1,73 @@ +package com.funeat.product.persistence; + +import com.funeat.product.domain.Product; +import com.funeat.tag.domain.Tag; +import jakarta.persistence.EntityManager; +import jakarta.persistence.PersistenceContext; +import jakarta.persistence.TypedQuery; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Repository; + +import java.util.List; + +@Repository +public class ProductRepositoryImpl implements ProductRepositoryCustom { + + @PersistenceContext + private EntityManager entityManager; + + @Override + public List searchProductsByTopTagsFirst(final Long tagId, final Pageable pageable) { + String jpql = "SELECT DISTINCT p FROM Product p " + + "WHERE p.id IN ( " + + " SELECT p2.id FROM Product p2 " + + " JOIN p2.reviews r2 " + + " JOIN r2.reviewTags rt2 " + + " WHERE rt2.tag.id = :tagId AND rt2.tag.id IN ( " + + " SELECT rt3.tag.id FROM Review r3 " + + " JOIN r3.reviewTags rt3 " + + " WHERE r3.product.id = p2.id " + + " GROUP BY rt3.tag.id " + + " ORDER BY COUNT(rt3.tag.id) DESC " + + " ) " + + " GROUP BY p2.id " + + " HAVING COUNT(DISTINCT rt2.tag.id) <= 3 " + + ") " + + "ORDER BY p.id DESC"; + + TypedQuery query = entityManager.createQuery(jpql, Product.class); + query.setParameter("tagId", tagId); + query.setFirstResult((int) pageable.getOffset()); + query.setMaxResults(pageable.getPageSize()); + + return query.getResultList(); + } + + @Override + public List searchProductsByTopTags(Long tagId, Long lastProductId, Pageable pageable) { + String jpql = "SELECT DISTINCT p FROM Product p " + + "WHERE p.id < :lastProductId AND p.id IN ( " + + " SELECT p2.id FROM Product p2 " + + " JOIN p2.reviews r2 " + + " JOIN r2.reviewTags rt2 " + + " WHERE rt2.tag.id = :tagId AND rt2.tag.id IN ( " + + " SELECT rt3.tag.id FROM Review r3 " + + " JOIN r3.reviewTags rt3 " + + " WHERE r3.product.id = p2.id " + + " GROUP BY rt3.tag.id " + + " ORDER BY COUNT(rt3.tag.id) DESC " + + " ) " + + " GROUP BY p2.id " + + " HAVING COUNT(DISTINCT rt2.tag.id) <= 3 " + + ") " + + "ORDER BY p.id DESC"; + + TypedQuery query = entityManager.createQuery(jpql, Product.class); + query.setParameter("tagId", tagId); + query.setParameter("lastProductId", lastProductId); + query.setFirstResult((int) pageable.getOffset()); + query.setMaxResults(pageable.getPageSize()); + + return query.getResultList(); + } +} diff --git a/src/main/java/com/funeat/product/presentation/ProductApiController.java b/src/main/java/com/funeat/product/presentation/ProductApiController.java index cfdd20dc..235876d5 100644 --- a/src/main/java/com/funeat/product/presentation/ProductApiController.java +++ b/src/main/java/com/funeat/product/presentation/ProductApiController.java @@ -71,4 +71,11 @@ public ResponseEntity getProductRecipes(@AuthenticationP final SortingRecipesResponse response = productService.getProductRecipes(loginInfo.getId(), productId, pageable); return ResponseEntity.ok(response); } + + @GetMapping("/search/tags/results") + public ResponseEntity getSearchResultByTag(@RequestParam final Long tagId, + @RequestParam final Long lastProductId) { + final SearchProductsResponse response = productService.getSearchResultsByTag(tagId, lastProductId); + return ResponseEntity.ok(response); + } } diff --git a/src/main/java/com/funeat/product/presentation/ProductController.java b/src/main/java/com/funeat/product/presentation/ProductController.java index 87065f24..42d755e1 100644 --- a/src/main/java/com/funeat/product/presentation/ProductController.java +++ b/src/main/java/com/funeat/product/presentation/ProductController.java @@ -76,4 +76,13 @@ ResponseEntity getSearchResults(@RequestParam fina ResponseEntity getProductRecipes(@AuthenticationPrincipal final LoginInfo loginInfo, @PathVariable final Long productId, @PageableDefault final Pageable pageable); + + @Operation(summary = "해당 태그 상품 목록 조회", description = "해당 태그가 포함된 상품 목록을 조회한다.") + @ApiResponse( + responseCode = "200", + description = "해당 태그 상품 목록 조회 성공." + ) + @GetMapping("/search/tags/results") + public ResponseEntity getSearchResultByTag(@RequestParam final Long tagId, + @RequestParam final Long lastProductId); } diff --git a/src/test/java/com/funeat/acceptance/product/ProductAcceptanceTest.java b/src/test/java/com/funeat/acceptance/product/ProductAcceptanceTest.java index dc68565d..f541531e 100644 --- a/src/test/java/com/funeat/acceptance/product/ProductAcceptanceTest.java +++ b/src/test/java/com/funeat/acceptance/product/ProductAcceptanceTest.java @@ -8,12 +8,7 @@ import static com.funeat.acceptance.common.CommonSteps.정상_처리; import static com.funeat.acceptance.common.CommonSteps.찾을수_없음; import static com.funeat.acceptance.common.CommonSteps.페이지를_검증한다; -import static com.funeat.acceptance.product.ProductSteps.상품_검색_결과_조회_요청; -import static com.funeat.acceptance.product.ProductSteps.상품_랭킹_조회_요청; -import static com.funeat.acceptance.product.ProductSteps.상품_레시피_목록_요청; -import static com.funeat.acceptance.product.ProductSteps.상품_상세_조회_요청; -import static com.funeat.acceptance.product.ProductSteps.상품_자동_완성_검색_요청; -import static com.funeat.acceptance.product.ProductSteps.카테고리별_상품_목록_조회_요청; +import static com.funeat.acceptance.product.ProductSteps.*; import static com.funeat.acceptance.recipe.RecipeSteps.레시피_작성_요청; import static com.funeat.acceptance.recipe.RecipeSteps.여러명이_레시피_좋아요_요청; import static com.funeat.acceptance.review.ReviewSteps.리뷰_작성_요청; @@ -657,6 +652,32 @@ class getProductRecipes_성공_테스트 { } } + @Nested + class getSearchResultsByTag_성공_테스트 { + + @Test + void 상품_상세_정보를_조회한다() { + // given + final var 카테고리 = 카테고리_간편식사_생성(); + 단일_카테고리_저장(카테고리); + final var 상품 = 단일_상품_저장(상품_삼각김밥_가격1000원_평점3점_생성(카테고리)); + final var 상품2 = 단일_상품_저장(상품_삼각김밥_가격1000원_평점3점_생성(카테고리)); + final var 태그1 = 단일_태그_저장(태그_맛있어요_TASTE_생성()); + final var 태그2 = 단일_태그_저장(태그_단짠단짠_TASTE_생성()); + + 리뷰_작성_요청(로그인_쿠키_획득(멤버1), 상품, 사진_명세_요청(이미지1), 리뷰추가요청_재구매X_생성(점수_4점, List.of(태그1, 태그2))); + 리뷰_작성_요청(로그인_쿠키_획득(멤버1), 상품, 사진_명세_요청(이미지2), 리뷰추가요청_재구매X_생성(점수_4점, List.of(태그2))); + 리뷰_작성_요청(로그인_쿠키_획득(멤버1), 상품2, 사진_명세_요청(이미지3), 리뷰추가요청_재구매X_생성(점수_1점, List.of(태그2))); + + // when + final var 응답 = 태그_상품_검색_결과_조회_요청(태그2, 0L); + + // then + STATUS_CODE를_검증한다(응답, 정상_처리); + 상품_검색_결과를_검증한다(응답, false, List.of(상품2, 상품1)); + } + } + private void 카테고리별_상품_목록_조회_결과를_검증한다(final ExtractableResponse response, final List productIds) { final var actual = response.jsonPath() .getList("products", ProductInCategoryDto.class); diff --git a/src/test/java/com/funeat/acceptance/product/ProductSteps.java b/src/test/java/com/funeat/acceptance/product/ProductSteps.java index 8c989c2a..4b001813 100644 --- a/src/test/java/com/funeat/acceptance/product/ProductSteps.java +++ b/src/test/java/com/funeat/acceptance/product/ProductSteps.java @@ -66,4 +66,15 @@ public class ProductSteps { .then() .extract(); } + + public static ExtractableResponse 태그_상품_검색_결과_조회_요청(final Long tagId, final Long lastProductId) { + return given() + .log().all() + .queryParam("tagId", tagId) + .queryParam("lastProductId", lastProductId) + .when() + .get("/api/search/tags/results") + .then().log().all() + .extract(); + } } diff --git a/src/test/java/com/funeat/fixture/ReviewFixture.java b/src/test/java/com/funeat/fixture/ReviewFixture.java index fee2b0b7..362e7d2f 100644 --- a/src/test/java/com/funeat/fixture/ReviewFixture.java +++ b/src/test/java/com/funeat/fixture/ReviewFixture.java @@ -8,9 +8,12 @@ import com.funeat.member.domain.Member; import com.funeat.product.domain.Product; import com.funeat.review.domain.Review; +import com.funeat.review.domain.ReviewTag; import com.funeat.review.dto.ReviewCreateRequest; import com.funeat.review.dto.ReviewFavoriteRequest; import com.funeat.review.dto.SortingReviewRequest; +import com.funeat.tag.domain.Tag; + import java.time.LocalDateTime; import java.util.List; @@ -121,4 +124,11 @@ public class ReviewFixture { public static SortingReviewRequest 리뷰정렬요청_존재하지않는정렬_생성() { return new SortingReviewRequest("test,test", 1L); } + + public static class ReviewTagFixture { + + public static ReviewTag 리뷰태그_생성(final Review review, final Tag tag) { + return ReviewTag.createReviewTag(review, tag); + } + } } diff --git a/src/test/java/com/funeat/product/persistence/ProductRepositoryTest.java b/src/test/java/com/funeat/product/persistence/ProductRepositoryTest.java index 9cdced56..0bdd4f8f 100644 --- a/src/test/java/com/funeat/product/persistence/ProductRepositoryTest.java +++ b/src/test/java/com/funeat/product/persistence/ProductRepositoryTest.java @@ -10,16 +10,20 @@ import static com.funeat.fixture.ProductFixture.상품_삼각김밥_가격3000원_평점5점_생성; import static com.funeat.fixture.ProductFixture.상품_삼각김밥_가격4000원_평점2점_생성; import static com.funeat.fixture.ProductFixture.상품_애플망고_가격3000원_평점5점_생성; +import static com.funeat.fixture.ReviewFixture.ReviewTagFixture.리뷰태그_생성; import static com.funeat.fixture.ReviewFixture.리뷰_이미지test1_평점1점_재구매X_생성; import static com.funeat.fixture.ReviewFixture.리뷰_이미지test3_평점3점_재구매O_생성; import static com.funeat.fixture.ReviewFixture.리뷰_이미지test5_평점5점_재구매O_생성; import static com.funeat.fixture.ReviewFixture.리뷰_이미지test5_평점5점_재구매X_생성; +import static com.funeat.fixture.TagFixture.*; import static org.assertj.core.api.Assertions.assertThat; import com.funeat.common.RepositoryTest; import com.funeat.product.dto.ProductReviewCountDto; + import java.time.LocalDateTime; import java.util.List; + import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; import org.springframework.data.domain.PageRequest; @@ -189,4 +193,100 @@ class findAllWithReviewCountByNameContaining_성공_테스트 { .isEqualTo(expected); } } + + @Nested + class findAllByTagFirst_성공_테스트 { + + @Test + void 특정_태그가_포함된_상품들을_조회한다() { + // given + final var category = 카테고리_간편식사_생성(); + 단일_카테고리_저장(category); + + final var 태그_맛있어요 = 태그_맛있어요_TASTE_생성(); + final var 태그_단짠단짠 = 태그_맛있어요_TASTE_생성(); + final var 태그_갓성비 = 태그_맛있어요_TASTE_생성(); + + final var 태그1 = 단일_태그_저장(태그_맛있어요); + final var 태그2 = 단일_태그_저장(태그_단짠단짠); + final var 태그3 = 단일_태그_저장(태그_갓성비); + + final var product1 = 상품_애플망고_가격3000원_평점5점_생성(category); + final var product2 = 상품_망고빙수_가격5000원_평점4점_생성(category); + final var product3 = 상품_망고빙수_가격5000원_평점4점_생성(category); + final var product4 = 상품_망고빙수_가격5000원_평점4점_생성(category); + 복수_상품_저장(product1, product2, product3, product4); + + final var member1 = 멤버_멤버1_생성(); + final var member2 = 멤버_멤버2_생성(); + 복수_멤버_저장(member1, member2); + + final var review1_1 = 리뷰_이미지test1_평점1점_재구매X_생성(member1, product1, 0L); + final var review1_2 = 리뷰_이미지test5_평점5점_재구매O_생성(member2, product1, 0L); + final var review2_1 = 리뷰_이미지test3_평점3점_재구매O_생성(member1, product2, 0L); + 복수_리뷰_저장(review1_1, review1_2, review2_1); + + 복수_리뷰_태그_저장(리뷰태그_생성(review1_1, 태그_맛있어요), 리뷰태그_생성(review1_1, 태그_단짠단짠), + 리뷰태그_생성(review1_1, 태그_갓성비), 리뷰태그_생성(review1_1, 태그_맛있어요), 리뷰태그_생성(review1_2, 태그_단짠단짠), + 리뷰태그_생성(review2_1, 태그_맛있어요)); + + final var expected = List.of(product2, product1); + final var expected2 = List.of(product1); + + // when + final var actual = productRepository.searchProductsByTopTagsFirst(태그1, PageRequest.of(0, 10)); + final var actual2 = productRepository.searchProductsByTopTagsFirst(태그2, PageRequest.of(0, 10)); + + // then + assertThat(actual).usingRecursiveComparison() + .isEqualTo(expected); + + // then + assertThat(actual2).usingRecursiveComparison() + .isEqualTo(expected2); + } + + @Test + void 특정_태그와_마지막_상품아이디_이후_상품들을_조회한다() { + // given + final var category = 카테고리_간편식사_생성(); + 단일_카테고리_저장(category); + + final var 태그_맛있어요 = 태그_맛있어요_TASTE_생성(); + final var 태그_단짠단짠 = 태그_맛있어요_TASTE_생성(); + final var 태그_갓성비 = 태그_맛있어요_TASTE_생성(); + + final var 태그1 = 단일_태그_저장(태그_맛있어요); + final var 태그2 = 단일_태그_저장(태그_단짠단짠); + final var 태그3 = 단일_태그_저장(태그_갓성비); + + final var product1 = 상품_애플망고_가격3000원_평점5점_생성(category); + final var product2 = 상품_망고빙수_가격5000원_평점4점_생성(category); + final var product3 = 상품_망고빙수_가격5000원_평점4점_생성(category); + final var product4 = 상품_망고빙수_가격5000원_평점4점_생성(category); + 복수_상품_저장(product1, product2, product3, product4); + + final var member1 = 멤버_멤버1_생성(); + final var member2 = 멤버_멤버2_생성(); + 복수_멤버_저장(member1, member2); + + final var review1_1 = 리뷰_이미지test1_평점1점_재구매X_생성(member1, product1, 0L); + final var review1_2 = 리뷰_이미지test5_평점5점_재구매O_생성(member2, product1, 0L); + final var review2_1 = 리뷰_이미지test3_평점3점_재구매O_생성(member1, product2, 0L); + 복수_리뷰_저장(review1_1, review1_2, review2_1); + + 복수_리뷰_태그_저장(리뷰태그_생성(review1_1, 태그_맛있어요), 리뷰태그_생성(review1_1, 태그_단짠단짠), + 리뷰태그_생성(review1_1, 태그_갓성비), 리뷰태그_생성(review1_1, 태그_맛있어요), 리뷰태그_생성(review1_2, 태그_단짠단짠), + 리뷰태그_생성(review2_1, 태그_맛있어요)); + + final var expected = List.of(product1); + + // when + final var actual = productRepository.searchProductsByTopTags(태그1, product2.getId(), PageRequest.of(0, 10)); + + // then + assertThat(actual).usingRecursiveComparison() + .isEqualTo(expected); + } + } }