Skip to content

Commit

Permalink
[BE] refactor: 리뷰 랭킹 알고리즘 개선 (#737)
Browse files Browse the repository at this point in the history
* feat: 리뷰의 랭킹 점수 계산 로직 추가

* test: 리뷰 랭킹 점수 계산 관련 테스트 추가

* refactor: 리뷰 랭킹 기능 수정

* test: 리뷰 랭킹 서비스 테스트 추가

* style: import 와일드카드 제거

* refactor: 좋아요 1개 이상인 리뷰만 랭킹에 들어갈 수 있도록 수정

* refactor: 사용하지 않는 메서드 및 테스트 삭제

* test: findReviewsByFavoriteCountGreaterThanEqual 테스트 추가

* style: ReviewServiceTest 와일드카드 제거

* style: import 정렬 순서 변경

* fix: 충돌 해결
  • Loading branch information
Go-Jaecheol authored Oct 18, 2023
1 parent 16de45c commit 9467d94
Show file tree
Hide file tree
Showing 7 changed files with 290 additions and 45 deletions.
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));
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

0 comments on commit 9467d94

Please sign in to comment.