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] refactor: 리뷰 랭킹 알고리즘 개선 #737

Merged
merged 12 commits into from
Oct 18, 2023
Merged
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@
import com.funeat.review.specification.SortingReviewSpecification;
import com.funeat.tag.domain.Tag;
import com.funeat.tag.persistence.TagRepository;
import java.util.Comparator;
import java.util.List;
import java.util.Optional;
import java.util.stream.Collectors;
Expand All @@ -58,6 +59,8 @@ public class ReviewService {
private static final int START_INDEX = 0;
private static final int ONE = 1;
private static final String EMPTY_URL = "";
private static final int RANKING_SIZE = 3;
private static final long RANKING_MINIMUM_FAVORITE_COUNT = 1L;
private static final int REVIEW_PAGE_SIZE = 10;

private final ReviewRepository reviewRepository;
Expand Down Expand Up @@ -208,9 +211,10 @@ private Boolean hasNextPage(final List<SortingReviewDto> sortingReviews) {
}

public RankingReviewsResponse getTopReviews() {
final List<Review> rankingReviews = reviewRepository.findTop3ByOrderByFavoriteCountDescIdDesc();

final List<RankingReviewDto> dtos = rankingReviews.stream()
final List<Review> reviews = reviewRepository.findReviewsByFavoriteCountGreaterThanEqual(RANKING_MINIMUM_FAVORITE_COUNT);
final List<RankingReviewDto> dtos = reviews.stream()
.sorted(Comparator.comparing(Review::calculateRankingScore).reversed())
.limit(RANKING_SIZE)
.map(RankingReviewDto::toDto)
.collect(Collectors.toList());

Expand Down
21 changes: 21 additions & 0 deletions backend/src/main/java/com/funeat/review/domain/Review.java
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
import com.funeat.member.domain.favorite.ReviewFavorite;
import com.funeat.product.domain.Product;
import java.time.LocalDateTime;
import java.time.temporal.ChronoUnit;
import java.util.ArrayList;
import java.util.List;
import java.util.Objects;
Expand All @@ -20,6 +21,8 @@
@Entity
public class Review {

private static final double RANKING_GRAVITY = 0.5;

@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
Expand Down Expand Up @@ -80,6 +83,18 @@ public Review(final Member member, final Product findProduct, final String image
this.favoriteCount = favoriteCount;
}

public Review(final Member member, final Product findProduct, final String image, final Long rating,
final String content, final Boolean reBuy, final Long favoriteCount, final LocalDateTime createdAt) {
this.member = member;
this.product = findProduct;
this.image = image;
this.rating = rating;
this.content = content;
this.reBuy = reBuy;
this.favoriteCount = favoriteCount;
this.createdAt = createdAt;
}

public void addFavoriteCount() {
this.favoriteCount++;
}
Expand All @@ -88,6 +103,12 @@ public void minusFavoriteCount() {
this.favoriteCount--;
}

public Double calculateRankingScore() {
final long age = ChronoUnit.DAYS.between(createdAt, LocalDateTime.now());
final double denominator = Math.pow(age + 1.0, RANKING_GRAVITY);
return favoriteCount / denominator;
}

public boolean checkAuthor(final Member member) {
return Objects.equals(this.member, member);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,6 @@
import com.funeat.member.domain.Member;
import com.funeat.product.domain.Product;
import com.funeat.review.domain.Review;
import com.funeat.review.dto.SortingReviewDto;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Optional;
import org.springframework.data.domain.Page;
Expand All @@ -18,8 +16,6 @@

public interface ReviewRepository extends JpaRepository<Review, Long>, ReviewCustomRepository {

List<Review> findTop3ByOrderByFavoriteCountDescIdDesc();

Long countByProduct(final Product product);

Page<Review> findReviewsByMember(final Member findMember, final Pageable pageable);
Expand All @@ -36,4 +32,6 @@ public interface ReviewRepository extends JpaRepository<Review, Long>, ReviewCus
List<Review> findPopularReviewWithImage(@Param("id") final Long productId, final Pageable pageable);

Optional<Review> findTopByProductOrderByFavoriteCountDescIdDesc(final Product product);

List<Review> findReviewsByFavoriteCountGreaterThanEqual(final Long favoriteCount);
}
6 changes: 6 additions & 0 deletions backend/src/test/java/com/funeat/fixture/ReviewFixture.java
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
import com.funeat.review.dto.ReviewCreateRequest;
import com.funeat.review.dto.ReviewFavoriteRequest;
import com.funeat.review.dto.SortingReviewRequest;
import java.time.LocalDateTime;
import java.util.List;

@SuppressWarnings("NonAsciiCharacters")
Expand Down Expand Up @@ -75,6 +76,11 @@ public class ReviewFixture {
return new Review(member, product, "test5", 5L, "test", false, count);
}

public static Review 리뷰_이미지test5_평점5점_재구매X_생성(final Member member, final Product product, final Long count,
final LocalDateTime createdAt) {
return new Review(member, product, "test5", 5L, "test", false, count, createdAt);
}

public static Review 리뷰_이미지없음_평점1점_재구매X_생성(final Member member, final Product product, final Long count) {
return new Review(member, product, "", 1L, "test", false, count);
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
package com.funeat.review.application;

import static com.funeat.fixture.CategoryFixture.카테고리_간편식사_생성;
import static com.funeat.fixture.CategoryFixture.카테고리_즉석조리_생성;
import static com.funeat.fixture.ImageFixture.이미지_생성;
import static com.funeat.fixture.MemberFixture.멤버_멤버1_생성;
Expand All @@ -8,6 +9,7 @@
import static com.funeat.fixture.PageFixture.페이지요청_생성;
import static com.funeat.fixture.ProductFixture.상품_삼각김밥_가격1000원_평점2점_생성;
import static com.funeat.fixture.ProductFixture.상품_삼각김밥_가격1000원_평점3점_생성;
import static com.funeat.fixture.ProductFixture.상품_삼각김밥_가격1000원_평점4점_생성;
import static com.funeat.fixture.ProductFixture.상품_삼각김밥_가격1000원_평점5점_생성;
import static com.funeat.fixture.ProductFixture.상품_삼각김밥_가격2000원_평점1점_생성;
import static com.funeat.fixture.ProductFixture.상품_삼각김밥_가격2000원_평점3점_생성;
Expand All @@ -18,6 +20,7 @@
import static com.funeat.fixture.ReviewFixture.리뷰_이미지test3_평점3점_재구매O_생성;
import static com.funeat.fixture.ReviewFixture.리뷰_이미지test3_평점3점_재구매X_생성;
import static com.funeat.fixture.ReviewFixture.리뷰_이미지test4_평점4점_재구매O_생성;
import static com.funeat.fixture.ReviewFixture.리뷰_이미지test5_평점5점_재구매X_생성;
import static com.funeat.fixture.ReviewFixture.리뷰_이미지없음_평점1점_재구매O_생성;
import static com.funeat.fixture.ReviewFixture.리뷰정렬요청_좋아요수_내림차순_생성;
import static com.funeat.fixture.ReviewFixture.리뷰정렬요청_최신순_생성;
Expand All @@ -37,11 +40,15 @@
import com.funeat.member.exception.MemberException.MemberNotFoundException;
import com.funeat.product.exception.ProductException.ProductNotFoundException;
import com.funeat.review.domain.Review;
import com.funeat.review.dto.RankingReviewDto;
import com.funeat.review.dto.RankingReviewsResponse;
import com.funeat.review.dto.MostFavoriteReviewResponse;
import com.funeat.review.dto.SortingReviewDto;
import com.funeat.review.exception.ReviewException.NotAuthorOfReviewException;
import com.funeat.review.exception.ReviewException.ReviewNotFoundException;
import com.funeat.tag.domain.Tag;
import java.time.LocalDateTime;
import java.util.Collections;
import java.util.List;
import java.util.Optional;
import java.util.stream.Collectors;
Expand Down Expand Up @@ -1007,6 +1014,155 @@ class getMostFavoriteReview_실패_테스트 {
}
}

@Nested
class getTopReviews_성공_테스트 {

@Nested
class 리뷰_개수에_대한_테스트 {

@Test
void 전체_리뷰가_하나도_없어도_반환값은_있어야한다() {
// given
final var expected = RankingReviewsResponse.toResponse(Collections.emptyList());

// when
final var actual = reviewService.getTopReviews();

// then
assertThat(actual).usingRecursiveComparison()
.isEqualTo(expected);
}

@Test
void 전체_리뷰가_1개_이상_3개_미만이라도_리뷰가_나와야한다() {
// given
final var category = 카테고리_간편식사_생성();
단일_카테고리_저장(category);

final var product = 상품_삼각김밥_가격1000원_평점4점_생성(category);
단일_상품_저장(product);

final var member = 멤버_멤버1_생성();
단일_멤버_저장(member);

final var now = LocalDateTime.now();
final var review1 = 리뷰_이미지test5_평점5점_재구매X_생성(member, product, 2L, now.minusDays(1L));
final var review2 = 리뷰_이미지test5_평점5점_재구매X_생성(member, product, 2L, now);
복수_리뷰_저장(review1, review2);

final var rankingReviewDto1 = RankingReviewDto.toDto(review1);
final var rankingReviewDto2 = RankingReviewDto.toDto(review2);
final var rankingReviewDtos = List.of(rankingReviewDto2, rankingReviewDto1);
final var expected = RankingReviewsResponse.toResponse(rankingReviewDtos);

// when
final var actual = reviewService.getTopReviews();

// then
assertThat(actual).usingRecursiveComparison()
.isEqualTo(expected);
}

@Test
void 전체_리뷰_중_랭킹이_높은_상위_3개_리뷰를_구할_수_있다() {
// given
final var category = 카테고리_간편식사_생성();
단일_카테고리_저장(category);

final var product = 상품_삼각김밥_가격1000원_평점4점_생성(category);
단일_상품_저장(product);

final var member = 멤버_멤버1_생성();
단일_멤버_저장(member);

final var now = LocalDateTime.now();
final var review1 = 리뷰_이미지test5_평점5점_재구매X_생성(member, product, 4L, now.minusDays(3L));
70825 marked this conversation as resolved.
Show resolved Hide resolved
final var review2 = 리뷰_이미지test5_평점5점_재구매X_생성(member, product, 6L, now.minusDays(2L));
final var review3 = 리뷰_이미지test5_평점5점_재구매X_생성(member, product, 4L, now);
final var review4 = 리뷰_이미지test5_평점5점_재구매X_생성(member, product, 5L, now);
복수_리뷰_저장(review1, review2, review3, review4);

final var rankingReviewDto1 = RankingReviewDto.toDto(review1);
final var rankingReviewDto2 = RankingReviewDto.toDto(review2);
final var rankingReviewDto3 = RankingReviewDto.toDto(review3);
final var rankingReviewDto4 = RankingReviewDto.toDto(review4);
final var rankingReviewDtos = List.of(rankingReviewDto4, rankingReviewDto3, rankingReviewDto2);
final var expected = RankingReviewsResponse.toResponse(rankingReviewDtos);

// when
final var actual = reviewService.getTopReviews();

// then
assertThat(actual).usingRecursiveComparison()
.isEqualTo(expected);
}
}

@Nested
class 리뷰_랭킹_점수에_대한_테스트 {

@Test
void 리뷰_좋아요_수가_같으면_최근_생성된_리뷰의_랭킹을_더_높게_반환한다() {
// given
final var category = 카테고리_간편식사_생성();
단일_카테고리_저장(category);

final var product = 상품_삼각김밥_가격1000원_평점4점_생성(category);
단일_상품_저장(product);

final var member = 멤버_멤버1_생성();
단일_멤버_저장(member);

final var now = LocalDateTime.now();
final var review1 = 리뷰_이미지test5_평점5점_재구매X_생성(member, product, 10L, now.minusDays(9L));
final var review2 = 리뷰_이미지test5_평점5점_재구매X_생성(member, product, 10L, now.minusDays(4L));
복수_리뷰_저장(review1, review2);

final var rankingReviewDto1 = RankingReviewDto.toDto(review1);
final var rankingReviewDto2 = RankingReviewDto.toDto(review2);
final var rankingReviewDtos = List.of(rankingReviewDto2, rankingReviewDto1);
final var expected = RankingReviewsResponse.toResponse(rankingReviewDtos);

// when
final var actual = reviewService.getTopReviews();

// then
assertThat(actual).usingRecursiveComparison()
.isEqualTo(expected);
}

@Test
void 리뷰_생성_일자가_같으면_좋아요_수가_많은_리뷰의_랭킹을_더_높게_반환한다() {
// given
final var category = 카테고리_간편식사_생성();
단일_카테고리_저장(category);

final var product = 상품_삼각김밥_가격1000원_평점4점_생성(category);
단일_상품_저장(product);

final var member = 멤버_멤버1_생성();
단일_멤버_저장(member);

final var now = LocalDateTime.now();
final var review1 = 리뷰_이미지test5_평점5점_재구매X_생성(member, product, 2L, now.minusDays(1L));
final var review2 = 리뷰_이미지test5_평점5점_재구매X_생성(member, product, 4L, now.minusDays(1L));
복수_리뷰_저장(review1, review2);

final var rankingReviewDto1 = RankingReviewDto.toDto(review1);
final var rankingReviewDto2 = RankingReviewDto.toDto(review2);
final var rankingReviewDtos = List.of(rankingReviewDto2, rankingReviewDto1);
final var expected = RankingReviewsResponse.toResponse(rankingReviewDtos);

// when
final var actual = reviewService.getTopReviews();

// then
assertThat(actual).usingRecursiveComparison()
.isEqualTo(expected);
}
}
}

private List<Long> 태그_아이디_변환(final Tag... tags) {
return Stream.of(tags)
.map(Tag::getId)
Expand Down
40 changes: 40 additions & 0 deletions backend/src/test/java/com/funeat/review/domain/ReviewTest.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
package com.funeat.review.domain;

import static com.funeat.fixture.CategoryFixture.카테고리_간편식사_생성;
import static com.funeat.fixture.MemberFixture.멤버_멤버1_생성;
import static com.funeat.fixture.ProductFixture.상품_삼각김밥_가격1000원_평점1점_생성;
import static com.funeat.fixture.ReviewFixture.리뷰_이미지test5_평점5점_재구매X_생성;
import static org.assertj.core.api.Assertions.assertThat;

import java.time.LocalDateTime;
import org.junit.jupiter.api.DisplayNameGeneration;
import org.junit.jupiter.api.DisplayNameGenerator;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;

@SuppressWarnings("NonAsciiCharacters")
@DisplayNameGeneration(DisplayNameGenerator.ReplaceUnderscores.class)
class ReviewTest {

@Nested
class calculateRankingScore_성공_테스트 {

@Test
void 리뷰_좋아요_수와_리뷰_생성_시간으로_해당_리뷰의_랭킹_점수를_구할_수_있다() {
// given
final var member = 멤버_멤버1_생성();
final var category = 카테고리_간편식사_생성();
final var product = 상품_삼각김밥_가격1000원_평점1점_생성(category);
final var favoriteCount = 4L;
final var review = 리뷰_이미지test5_평점5점_재구매X_생성(member, product, favoriteCount, LocalDateTime.now().minusDays(1L));

final var expected = favoriteCount / Math.pow(2.0, 0.5);

// when
final var actual = review.calculateRankingScore();

// then
assertThat(actual).isEqualTo(expected);
}
}
}
Loading