diff --git a/backend/src/main/java/com/funeat/product/application/ProductService.java b/backend/src/main/java/com/funeat/product/application/ProductService.java index 8f4ef46d6..f4c864df7 100644 --- a/backend/src/main/java/com/funeat/product/application/ProductService.java +++ b/backend/src/main/java/com/funeat/product/application/ProductService.java @@ -4,14 +4,18 @@ import com.funeat.product.domain.Product; import com.funeat.product.dto.ProductInCategoryDto; import com.funeat.product.dto.ProductResponse; +import com.funeat.product.dto.ProductReviewCountDto; import com.funeat.product.dto.ProductsInCategoryPageDto; import com.funeat.product.dto.ProductsInCategoryResponse; +import com.funeat.product.dto.RankingProductDto; +import com.funeat.product.dto.RankingProductsResponse; import com.funeat.product.persistence.CategoryRepository; import com.funeat.product.persistence.ProductRepository; -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; @@ -28,14 +32,12 @@ public class ProductService { private final CategoryRepository categoryRepository; private final ProductRepository productRepository; - private final ReviewRepository reviewRepository; private final ReviewTagRepository reviewTagRepository; public ProductService(final CategoryRepository categoryRepository, final ProductRepository productRepository, - final ReviewRepository reviewRepository, final ReviewTagRepository reviewTagRepository) { + final ReviewTagRepository reviewTagRepository) { this.categoryRepository = categoryRepository; this.productRepository = productRepository; - this.reviewRepository = reviewRepository; this.reviewTagRepository = reviewTagRepository; } @@ -68,4 +70,19 @@ public ProductResponse findProductDetail(final Long productId) { return ProductResponse.toResponse(product, tags); } + + public RankingProductsResponse getTop3Products() { + final List productsAndReviewCounts = productRepository.findAllByAverageRatingGreaterThan3(); + final Comparator rankingScoreComparator = Comparator.comparing( + (ProductReviewCountDto it) -> it.getProduct().calculateRankingScore(it.getReviewCount()) + ).reversed(); + + final List rankingProductDtos = productsAndReviewCounts.stream() + .sorted(rankingScoreComparator) + .limit(3) + .map(it -> RankingProductDto.toDto(it.getProduct())) + .collect(Collectors.toList()); + + return RankingProductsResponse.toResponse(rankingProductDtos); + } } diff --git a/backend/src/main/java/com/funeat/product/domain/Product.java b/backend/src/main/java/com/funeat/product/domain/Product.java index 7bb55838a..330576229 100644 --- a/backend/src/main/java/com/funeat/product/domain/Product.java +++ b/backend/src/main/java/com/funeat/product/domain/Product.java @@ -68,6 +68,12 @@ public void updateAverageRating(final Long rating, final Long count) { this.averageRating = Math.round(calculatedRating * 10.0) / 10.0; } + public Double calculateRankingScore(final Long reviewCount) { + final double exponent = -Math.log10(reviewCount + 1); + final double factor = Math.pow(2, exponent); + return averageRating - (averageRating - 3.0) * factor; + } + public Long getId() { return id; } diff --git a/backend/src/main/java/com/funeat/product/dto/ProductReviewCountDto.java b/backend/src/main/java/com/funeat/product/dto/ProductReviewCountDto.java new file mode 100644 index 000000000..f769b708d --- /dev/null +++ b/backend/src/main/java/com/funeat/product/dto/ProductReviewCountDto.java @@ -0,0 +1,22 @@ +package com.funeat.product.dto; + +import com.funeat.product.domain.Product; + +public class ProductReviewCountDto { + + private final Product product; + private final Long reviewCount; + + public ProductReviewCountDto(final Product product, final Long reviewCount) { + this.product = product; + this.reviewCount = reviewCount; + } + + public Product getProduct() { + return product; + } + + public Long getReviewCount() { + return reviewCount; + } +} diff --git a/backend/src/main/java/com/funeat/product/dto/RankingProductDto.java b/backend/src/main/java/com/funeat/product/dto/RankingProductDto.java new file mode 100644 index 000000000..060a8dcd3 --- /dev/null +++ b/backend/src/main/java/com/funeat/product/dto/RankingProductDto.java @@ -0,0 +1,32 @@ +package com.funeat.product.dto; + +import com.funeat.product.domain.Product; + +public class RankingProductDto { + + private final Long id; + private final String name; + private final String image; + + public RankingProductDto(final Long id, final String name, final String image) { + this.id = id; + this.name = name; + this.image = image; + } + + public static RankingProductDto toDto(final Product product) { + return new RankingProductDto(product.getId(), product.getName(), product.getImage()); + } + + public Long getId() { + return id; + } + + public String getName() { + return name; + } + + public String getImage() { + return image; + } +} diff --git a/backend/src/main/java/com/funeat/product/dto/RankingProductsResponse.java b/backend/src/main/java/com/funeat/product/dto/RankingProductsResponse.java new file mode 100644 index 000000000..14bfcf158 --- /dev/null +++ b/backend/src/main/java/com/funeat/product/dto/RankingProductsResponse.java @@ -0,0 +1,20 @@ +package com.funeat.product.dto; + +import java.util.List; + +public class RankingProductsResponse { + + private final List products; + + public RankingProductsResponse(final List products) { + this.products = products; + } + + public static RankingProductsResponse toResponse(final List products) { + return new RankingProductsResponse(products); + } + + public List getProducts() { + return products; + } +} diff --git a/backend/src/main/java/com/funeat/product/persistence/ProductRepository.java b/backend/src/main/java/com/funeat/product/persistence/ProductRepository.java index 2ebd219f5..c61166105 100644 --- a/backend/src/main/java/com/funeat/product/persistence/ProductRepository.java +++ b/backend/src/main/java/com/funeat/product/persistence/ProductRepository.java @@ -3,6 +3,8 @@ import com.funeat.product.domain.Category; import com.funeat.product.domain.Product; import com.funeat.product.dto.ProductInCategoryDto; +import com.funeat.product.dto.ProductReviewCountDto; +import java.util.List; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.JpaRepository; @@ -10,6 +12,7 @@ import org.springframework.data.repository.query.Param; public interface ProductRepository extends JpaRepository { + @Query(value = "SELECT new com.funeat.product.dto.ProductInCategoryDto(p.id, p.name, p.price, p.image, p.averageRating, COUNT(r)) " + "FROM Product p " + "LEFT JOIN p.reviews r " + @@ -26,4 +29,11 @@ public interface ProductRepository extends JpaRepository { "ORDER BY COUNT(r) DESC, p.id DESC ", countQuery = "SELECT COUNT(p) FROM Product p WHERE p.category = :category") Page findAllByCategoryOrderByReviewCountDesc(@Param("category") final Category category, final Pageable pageable); + + @Query("SELECT new com.funeat.product.dto.ProductReviewCountDto(p, COUNT(r.id)) " + + "FROM Product p " + + "LEFT JOIN Review r ON r.product.id = p.id " + + "WHERE p.averageRating > 3.0 " + + "GROUP BY p.id") + List findAllByAverageRatingGreaterThan3(); } diff --git a/backend/src/main/java/com/funeat/product/presentation/ProductApiController.java b/backend/src/main/java/com/funeat/product/presentation/ProductApiController.java index 83de49282..30c7f2139 100644 --- a/backend/src/main/java/com/funeat/product/presentation/ProductApiController.java +++ b/backend/src/main/java/com/funeat/product/presentation/ProductApiController.java @@ -3,6 +3,7 @@ import com.funeat.product.application.ProductService; import com.funeat.product.dto.ProductResponse; import com.funeat.product.dto.ProductsInCategoryResponse; +import com.funeat.product.dto.RankingProductsResponse; import org.springframework.data.domain.Pageable; import org.springframework.data.web.PageableDefault; import org.springframework.http.ResponseEntity; @@ -35,4 +36,10 @@ public ResponseEntity getProductDetail(@PathVariable final Long final ProductResponse response = productService.findProductDetail(productId); return ResponseEntity.ok(response); } + + @GetMapping("/ranks/products") + public ResponseEntity getRankingProducts() { + final RankingProductsResponse response = productService.getTop3Products(); + return ResponseEntity.ok(response); + } } diff --git a/backend/src/main/java/com/funeat/product/presentation/ProductController.java b/backend/src/main/java/com/funeat/product/presentation/ProductController.java index 7171d0dd3..891a9ceb8 100644 --- a/backend/src/main/java/com/funeat/product/presentation/ProductController.java +++ b/backend/src/main/java/com/funeat/product/presentation/ProductController.java @@ -2,6 +2,7 @@ import com.funeat.product.dto.ProductResponse; import com.funeat.product.dto.ProductsInCategoryResponse; +import com.funeat.product.dto.RankingProductsResponse; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.responses.ApiResponse; import io.swagger.v3.oas.annotations.tags.Tag; @@ -31,4 +32,12 @@ ResponseEntity getAllProductsInCategory( ) @GetMapping ResponseEntity getProductDetail(@PathVariable final Long productId); + + @Operation(summary = "전체 상품 랭킹 조회", description = "전체 상품들 중에서 랭킹 TOP3를 조회한다.") + @ApiResponse( + responseCode = "200", + description = "전체 상품 랭킹 조회 성공." + ) + @GetMapping + ResponseEntity getRankingProducts(); } diff --git a/backend/src/test/java/com/funeat/acceptance/product/ProductAcceptanceTest.java b/backend/src/test/java/com/funeat/acceptance/product/ProductAcceptanceTest.java index 3cc9d47ca..c76b346c4 100644 --- a/backend/src/test/java/com/funeat/acceptance/product/ProductAcceptanceTest.java +++ b/backend/src/test/java/com/funeat/acceptance/product/ProductAcceptanceTest.java @@ -6,6 +6,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.product.ProductSteps.상품_상세_조회_요청; import static com.funeat.acceptance.product.ProductSteps.즉석조리; import static com.funeat.acceptance.product.ProductSteps.카테고리별_상품_목록_조회_요청; @@ -21,6 +22,7 @@ import com.funeat.product.dto.ProductInCategoryDto; import com.funeat.product.dto.ProductResponse; import com.funeat.product.dto.ProductsInCategoryPageDto; +import com.funeat.product.dto.RankingProductDto; import com.funeat.review.domain.Review; import com.funeat.review.presentation.dto.ReviewCreateRequest; import com.funeat.tag.domain.Tag; @@ -31,6 +33,7 @@ import io.restassured.specification.MultiPartSpecification; import java.util.ArrayList; import java.util.List; +import java.util.stream.Collectors; import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; @@ -412,6 +415,38 @@ class 리뷰수_기준_내림차순으로_카테고리별_상품_목록_조회 { 공통_상품_카테고리_목록_조회_결과를_검증한다(response, List.of(간편식사, 즉석조리, 과자류)); } + @Test + void 전체_상품들_중에서_랭킹_TOP3를_조회할_수_있다() { + // given + 카테고리_추가_요청(간편식사); + final var product1 = new Product("삼각김밥1", 1000L, "image.png", "맛있는 삼각김밥1", 3.25, 간편식사); + final var product2 = new Product("삼각김밥2", 2000L, "image.png", "맛있는 삼각김밥2", 4.0, 간편식사); + final var product3 = new Product("삼각김밥3", 1500L, "image.png", "맛있는 삼각김밥3", 3.33, 간편식사); + final var product4 = new Product("삼각김밥4", 1200L, "image.png", "맛있는 삼각김밥4", 0.0, 간편식사); + 복수_상품_추가_요청(List.of(product1, product2, product3, product4)); + + final var member = 멤버_추가_요청(new Member("test", "image.png", "1")); + final var review1_1 = new Review(member, product1, "review.png", 3L, "이 삼각김밥은 맛있다", true); + final var review1_2 = new Review(member, product1, "review.png", 3L, "이 삼각김밥은 맛있다", true); + final var review1_3 = new Review(member, product1, "review.png", 4L, "이 삼각김밥은 좀 맛있다", true); + final var review1_4 = new Review(member, product1, "review.png", 3L, "이 삼각김밥은 최고!!", true); + final var review2_1 = new Review(member, product2, "review.png", 4L, "이 삼각김밥은 그럭저럭", false); + final var review2_2 = new Review(member, product2, "review.png", 4L, "이 삼각김밥은 굿", false); + final var review3_1 = new Review(member, product3, "review.png", 2L, "이 삼각김밥은 좀 맛없다", false); + final var review3_2 = new Review(member, product3, "review.png", 3L, "이 삼각김밥은 흠", false); + final var review3_3 = new Review(member, product3, "review.png", 5L, "이 삼각김밥은 굿굿", false); + final var reviews = List.of(review1_1, review1_2, review1_3, review1_4, review2_1, review2_2, + review3_1, review3_2, review3_3); + 복수_리뷰_추가_요청(reviews); + + // when + final var response = 상품_랭킹_조회_요청(); + + // then + STATUS_CODE를_검증한다(response, 정상_처리); + 상품_랭킹_조회_결과를_검증한다(response, List.of(product2, product3, product1)); + } + private Long 카테고리_추가_요청(final Category category) { return categoryRepository.save(category).getId(); } @@ -486,4 +521,15 @@ class 리뷰수_기준_내림차순으로_카테고리별_상품_목록_조회 { .ignoringFields("id") .isEqualTo(expected); } + + private void 상품_랭킹_조회_결과를_검증한다(final ExtractableResponse response, final List products) { + final var expected = products.stream() + .map(RankingProductDto::toDto) + .collect(Collectors.toList()); + final var actual = response.jsonPath() + .getList("products", RankingProductDto.class); + + assertThat(actual).usingRecursiveComparison() + .isEqualTo(expected); + } } diff --git a/backend/src/test/java/com/funeat/acceptance/product/ProductSteps.java b/backend/src/test/java/com/funeat/acceptance/product/ProductSteps.java index 85fcfc66e..3ca1e1e08 100644 --- a/backend/src/test/java/com/funeat/acceptance/product/ProductSteps.java +++ b/backend/src/test/java/com/funeat/acceptance/product/ProductSteps.java @@ -48,4 +48,12 @@ public class ProductSteps { .then() .extract(); } + + public static ExtractableResponse 상품_랭킹_조회_요청() { + return given() + .when() + .get("/api/ranks/products") + .then() + .extract(); + } } diff --git a/backend/src/test/java/com/funeat/product/application/ProductServiceTest.java b/backend/src/test/java/com/funeat/product/application/ProductServiceTest.java new file mode 100644 index 000000000..a3bbeb363 --- /dev/null +++ b/backend/src/test/java/com/funeat/product/application/ProductServiceTest.java @@ -0,0 +1,78 @@ +package com.funeat.product.application; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.funeat.common.DataClearExtension; +import com.funeat.member.domain.Member; +import com.funeat.member.persistence.MemberRepository; +import com.funeat.product.domain.Product; +import com.funeat.product.dto.RankingProductDto; +import com.funeat.product.dto.RankingProductsResponse; +import com.funeat.product.persistence.ProductRepository; +import com.funeat.review.domain.Review; +import com.funeat.review.persistence.ReviewRepository; +import java.util.List; +import org.junit.jupiter.api.DisplayNameGeneration; +import org.junit.jupiter.api.DisplayNameGenerator.ReplaceUnderscores; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.transaction.annotation.Transactional; + +@Transactional +@SpringBootTest +@ExtendWith(DataClearExtension.class) +@SuppressWarnings("NonAsciiCharacters") +@DisplayNameGeneration(ReplaceUnderscores.class) +class ProductServiceTest { + + @Autowired + private ProductService productService; + + @Autowired + private ProductRepository productRepository; + + @Autowired + private MemberRepository memberRepository; + + @Autowired + private ReviewRepository reviewRepository; + + @Test + void 전체_상품_중_랭킹이_높은_상위_3개_상품을_구할_수_있다() { + // given + final var product1 = new Product("삼각김밥1", 1000L, "image.png", "맛있는 삼각김밥1", 3.5, null); + final var product2 = new Product("삼각김밥2", 2000L, "image.png", "맛있는 삼각김밥2", 4.0, null); + final var product3 = new Product("삼각김밥3", 1500L, "image.png", "맛있는 삼각김밥3", 5.0, null); + final var product4 = new Product("삼각김밥4", 1200L, "image.png", "맛있는 삼각김밥4", 2.0, null); + productRepository.saveAll(List.of(product1, product2, product3, product4)); + + final var member = memberRepository.save(new Member("test", "image.png", "1")); + final var review1_1 = new Review(member, product1, "review.png", 5L, "이 삼각김밥은 최고!!", true); + final var review1_2 = new Review(member, product1, "review.png", 3L, "이 삼각김밥은 맛있다", true); + final var review1_3 = new Review(member, product1, "review.png", 3L, "이 삼각김밥은 맛있다", true); + final var review1_4 = new Review(member, product1, "review.png", 3L, "이 삼각김밥은 맛있다", true); + final var review2_1 = new Review(member, product2, "review.png", 4L, "이 삼각김밥은 맛있다", false); + final var review2_2 = new Review(member, product2, "review.png", 4L, "이 삼각김밥은 맛있다", false); + final var review3_1 = new Review(member, product3, "review.png", 5L, "이 삼각김밥은 굿굿", false); + final var review4_1 = new Review(member, product4, "review.png", 2L, "이 삼각김밥은 좀 맛없다", false); + final var review4_2 = new Review(member, product4, "review.png", 2L, "이 삼각김밥은 좀 맛없다", false); + final var review4_3 = new Review(member, product4, "review.png", 2L, "이 삼각김밥은 좀 맛없다", false); + reviewRepository.saveAll( + List.of(review1_1, review1_2, review1_3, review1_4, review2_1, review2_2, review3_1, review4_1, + review4_2, review4_3) + ); + + // when + final var actual = productService.getTop3Products(); + + // then + final var expected = RankingProductsResponse.toResponse( + List.of(RankingProductDto.toDto(product3), RankingProductDto.toDto(product2), + RankingProductDto.toDto(product1)) + ); + assertThat(actual).usingRecursiveComparison() + .isEqualTo(expected); + } +} diff --git a/backend/src/test/java/com/funeat/product/domain/ProductTest.java b/backend/src/test/java/com/funeat/product/domain/ProductTest.java index 1d167c124..0aa5dc975 100644 --- a/backend/src/test/java/com/funeat/product/domain/ProductTest.java +++ b/backend/src/test/java/com/funeat/product/domain/ProductTest.java @@ -24,4 +24,17 @@ class ProductTest { product.updateAverageRating(reviewRating2, reviewCount2); assertThat(product.getAverageRating()).isEqualTo(3.0); } + + @Test + void 평균_평점과_리뷰_수로_해당_상품의_랭킹_점수를_구할_수_있다() { + // given + final var product = new Product("testName", 1000L, "testImage", "testContent", 4.0, null); + final var reviewCount = 9L; + + // when + final var rankingScore = product.calculateRankingScore(reviewCount); + + // then + assertThat(rankingScore).isEqualTo(3.5); + } } diff --git a/backend/src/test/java/com/funeat/product/persistence/ProductRepositoryTest.java b/backend/src/test/java/com/funeat/product/persistence/ProductRepositoryTest.java index a3f38fb97..665003464 100644 --- a/backend/src/test/java/com/funeat/product/persistence/ProductRepositoryTest.java +++ b/backend/src/test/java/com/funeat/product/persistence/ProductRepositoryTest.java @@ -10,6 +10,7 @@ import com.funeat.product.domain.CategoryType; import com.funeat.product.domain.Product; import com.funeat.product.dto.ProductInCategoryDto; +import com.funeat.product.dto.ProductReviewCountDto; import com.funeat.review.domain.Review; import com.funeat.review.persistence.ReviewRepository; import java.util.List; @@ -186,4 +187,40 @@ class ProductRepositoryTest { assertThat(actual).usingRecursiveComparison() .isEqualTo(expected); } + + @Test + void 평점이_3보다_큰_모든_상품들과_리뷰_수를_조회한다() { + // given + final var category = categoryRepository.save(new Category("간편식사", CategoryType.FOOD)); + final var product1 = new Product("삼각김밥1", 1000L, "image.png", "맛있는 삼각김밥1", 3.75, category); + final var product2 = new Product("삼각김밥2", 2000L, "image.png", "맛있는 삼각김밥2", 1.0, category); + final var product3 = new Product("삼각김밥3", 1500L, "image.png", "맛있는 삼각김밥3", 5.0, category); + final var product4 = new Product("삼각김밥4", 1200L, "image.png", "맛있는 삼각김밥4", 2.0, category); + productRepository.saveAll(List.of(product1, product2, product3, product4)); + + final var member = memberRepository.save(new Member("test", "image.png", "1")); + + final var review1_1 = new Review(member, product1, "review.png", 5L, "이 삼각김밥은 최고!!", true); + final var review1_2 = new Review(member, product1, "review.png", 4L, "이 삼각김밥은 좀 맛있다", true); + final var review1_3 = new Review(member, product1, "review.png", 3L, "이 삼각김밥은 맛있다", true); + final var review1_4 = new Review(member, product1, "review.png", 3L, "이 삼각김밥은 맛있다", true); + final var review2_1 = new Review(member, product2, "review.png", 1L, "이 삼각김밥은 맛없다", false); + final var review2_2 = new Review(member, product2, "review.png", 1L, "이 삼각김밥은 맛없다", false); + final var review3_1 = new Review(member, product3, "review.png", 5L, "이 삼각김밥은 굿굿", false); + final var review4_1 = new Review(member, product4, "review.png", 2L, "이 삼각김밥은 좀 맛없다", false); + final var review4_2 = new Review(member, product4, "review.png", 2L, "이 삼각김밥은 좀 맛없다", false); + final var review4_3 = new Review(member, product4, "review.png", 2L, "이 삼각김밥은 좀 맛없다", false); + reviewRepository.saveAll( + List.of(review1_1, review1_2, review1_3, review1_4, review2_1, review2_2, review3_1, review4_1, + review4_2, review4_3) + ); + + // when + final var actual = productRepository.findAllByAverageRatingGreaterThan3(); + + // then + final var expected = List.of(new ProductReviewCountDto(product1, 4L), new ProductReviewCountDto(product3, 1L)); + assertThat(actual).usingRecursiveComparison() + .isEqualTo(expected); + } }