Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[BE] feat: 상품 랭킹 기능 추가 #204

Merged
merged 11 commits into from
Aug 2, 2023
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
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
}

Expand Down Expand Up @@ -68,4 +70,19 @@ public ProductResponse findProductDetail(final Long productId) {

return ProductResponse.toResponse(product, tags);
}

public RankingProductsResponse getTop3Products() {
final List<ProductReviewCountDto> productsAndReviewCounts = productRepository.findAllByAverageRatingGreaterThan3();
final Comparator<ProductReviewCountDto> rankingScoreComparator = Comparator.comparing(
(ProductReviewCountDto it) -> it.getProduct().calculateRankingScore(it.getReviewCount())
).reversed();

final List<RankingProductDto> rankingProductDtos = productsAndReviewCounts.stream()
.sorted(rankingScoreComparator)
.limit(3)
.map(it -> RankingProductDto.toDto(it.getProduct()))
.collect(Collectors.toList());

return RankingProductsResponse.toResponse(rankingProductDtos);
}
}
6 changes: 6 additions & 0 deletions backend/src/main/java/com/funeat/product/domain/Product.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
package com.funeat.product.dto;

import com.funeat.product.domain.Product;

public class ProductReviewCountDto {

private final Product product;
wugawuga marked this conversation as resolved.
Show resolved Hide resolved
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;
}
}
Original file line number Diff line number Diff line change
@@ -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());
}
70825 marked this conversation as resolved.
Show resolved Hide resolved

public Long getId() {
return id;
}

public String getName() {
return name;
}

public String getImage() {
return image;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
package com.funeat.product.dto;

import java.util.List;

public class RankingProductsResponse {

private final List<RankingProductDto> products;

public RankingProductsResponse(final List<RankingProductDto> products) {
this.products = products;
}

public static RankingProductsResponse toResponse(final List<RankingProductDto> products) {
return new RankingProductsResponse(products);
}

public List<RankingProductDto> getProducts() {
return products;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,16 @@
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;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;

public interface ProductRepository extends JpaRepository<Product, Long> {

@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 " +
Expand All @@ -26,4 +29,11 @@ public interface ProductRepository extends JpaRepository<Product, Long> {
"ORDER BY COUNT(r) DESC, p.id DESC ",
countQuery = "SELECT COUNT(p) FROM Product p WHERE p.category = :category")
Page<ProductInCategoryDto> 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<ProductReviewCountDto> findAllByAverageRatingGreaterThan3();
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -35,4 +36,10 @@ public ResponseEntity<ProductResponse> getProductDetail(@PathVariable final Long
final ProductResponse response = productService.findProductDetail(productId);
return ResponseEntity.ok(response);
}

@GetMapping("/ranks/products")
public ResponseEntity<RankingProductsResponse> getRankingProducts() {
final RankingProductsResponse response = productService.getTop3Products();
return ResponseEntity.ok(response);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -31,4 +32,12 @@ ResponseEntity<ProductsInCategoryResponse> getAllProductsInCategory(
)
@GetMapping
ResponseEntity<ProductResponse> getProductDetail(@PathVariable final Long productId);

@Operation(summary = "전체 상품 랭킹 조회", description = "전체 상품들 중에서 랭킹 TOP3를 조회한다.")
@ApiResponse(
responseCode = "200",
description = "전체 상품 랭킹 조회 성공."
)
@GetMapping
ResponseEntity<RankingProductsResponse> getRankingProducts();
}
Original file line number Diff line number Diff line change
Expand Up @@ -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.카테고리별_상품_목록_조회_요청;
Expand All @@ -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;
Expand All @@ -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;

Expand Down Expand Up @@ -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();
}
Expand Down Expand Up @@ -486,4 +521,15 @@ class 리뷰수_기준_내림차순으로_카테고리별_상품_목록_조회 {
.ignoringFields("id")
.isEqualTo(expected);
}

private void 상품_랭킹_조회_결과를_검증한다(final ExtractableResponse<Response> response, final List<Product> 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);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -48,4 +48,12 @@ public class ProductSteps {
.then()
.extract();
}

public static ExtractableResponse<Response> 상품_랭킹_조회_요청() {
return given()
.when()
.get("/api/ranks/products")
.then()
.extract();
}
}
Original file line number Diff line number Diff line change
@@ -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);
}
}
Loading
Loading