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..b519e7d2 100644 --- a/src/main/java/com/funeat/product/persistence/ProductRepository.java +++ b/src/main/java/com/funeat/product/persistence/ProductRepository.java @@ -4,11 +4,12 @@ import com.funeat.product.domain.Product; import com.funeat.product.dto.ProductReviewCountDto; import java.util.List; + 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 " 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..eb624ade --- /dev/null +++ b/src/main/java/com/funeat/product/persistence/ProductRepositoryImpl.java @@ -0,0 +1,86 @@ +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) { + final 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 + LIMIT 3 + ) + ) + ORDER BY p.id DESC + """; + + final 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(final Long tagId, final Long lastProductId, final Pageable pageable) { + final 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 + LIMIT 3 + ) + GROUP BY p2.id + HAVING COUNT(DISTINCT rt2.tag.id) <= 3 + ) + ORDER BY p.id DESC + """; + + final 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..e35b0292 100644 --- a/src/test/java/com/funeat/acceptance/product/ProductAcceptanceTest.java +++ b/src/test/java/com/funeat/acceptance/product/ProductAcceptanceTest.java @@ -14,6 +14,7 @@ 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.리뷰_작성_요청; @@ -99,7 +100,6 @@ import com.funeat.product.dto.SearchProductResultDto; import com.funeat.product.dto.SearchProductResultsResponse; import com.funeat.product.dto.SearchProductsResponse; -import com.funeat.product.dto.CategoryDto; import com.funeat.recipe.dto.RecipeDto; import com.funeat.tag.dto.TagDto; import io.restassured.response.ExtractableResponse; @@ -657,6 +657,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..cc952cc3 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() + .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..b38f918d 100644 --- a/src/test/java/com/funeat/product/persistence/ProductRepositoryTest.java +++ b/src/test/java/com/funeat/product/persistence/ProductRepositoryTest.java @@ -10,16 +10,24 @@ 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.태그_맛있어요_TASTE_생성; +import static com.funeat.fixture.TagFixture.태그_단짠단짠_TASTE_생성; +import static com.funeat.fixture.TagFixture.태그_갓성비_PRICE_생성; +import static com.funeat.fixture.TagFixture.태그_간식_ETC_생성; import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.SoftAssertions.assertSoftly; 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 +197,158 @@ class findAllWithReviewCountByNameContaining_성공_테스트 { .isEqualTo(expected); } } + + @Nested + class searchProductsByTopTagsFirst_성공_테스트 { + + @Test + void 특정_태그가_포함된_상품들을_조회한다() { + // given + final var category = 카테고리_간편식사_생성(); + 단일_카테고리_저장(category); + + final var 태그_맛있어요 = 태그_맛있어요_TASTE_생성(); + final var 태그_단짠단짠 = 태그_단짠단짠_TASTE_생성(); + final var 태그_갓성비 = 태그_갓성비_PRICE_생성(); + + 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 + assertSoftly(soft -> { + soft.assertThat(actual) + .isEqualTo(expected); + soft.assertThat(actual2) + .isEqualTo(expected2); + }); + } + } + + @Nested + class searchProductsByTopTagsFirst_실패_테스트 { + + @Test + void 간식_태그는_4위이므로_검색되지_말아야한다() { + // given + final var category = 카테고리_간편식사_생성(); + 단일_카테고리_저장(category); + + final var 태그_맛있어요 = 태그_맛있어요_TASTE_생성(); + final var 태그_단짠단짠 = 태그_단짠단짠_TASTE_생성(); + final var 태그_갓성비 = 태그_갓성비_PRICE_생성(); + final var 태그_간식 = 태그_간식_ETC_생성(); + + final var 태그1 = 단일_태그_저장(태그_맛있어요); + final var 태그2 = 단일_태그_저장(태그_단짠단짠); + final var 태그3 = 단일_태그_저장(태그_갓성비); + final var 태그4 = 단일_태그_저장(태그_간식); + + 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_1, 태그_갓성비), + 리뷰태그_생성(review1_1, 태그_갓성비), + 리뷰태그_생성(review1_1, 태그_간식), + 리뷰태그_생성(review1_1, 태그_맛있어요), + 리뷰태그_생성(review1_2, 태그_단짠단짠), + 리뷰태그_생성(review2_1, 태그_맛있어요) + ); + + // when + final var actual = productRepository.searchProductsByTopTagsFirst(태그4, PageRequest.of(0, 10)); + + // then + assertThat(actual).isEmpty(); + } + } + + @Nested + class searchProductsByTopTags_성공_테스트 { + + @Test + void 특정_태그와_마지막_상품아이디_이후_상품들을_조회한다() { + // given + final var category = 카테고리_간편식사_생성(); + 단일_카테고리_저장(category); + + final var 태그_맛있어요 = 태그_맛있어요_TASTE_생성(); + final var 태그_단짠단짠 = 태그_단짠단짠_TASTE_생성(); + final var 태그_갓성비 = 태그_갓성비_PRICE_생성(); + + 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); + 복수_상품_저장(product1, product2, product3); + + 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); + } + } }