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
@@ -1,10 +1,5 @@
package com.funeat.review.application;

import static com.funeat.member.exception.MemberErrorCode.MEMBER_DUPLICATE_FAVORITE;
import static com.funeat.member.exception.MemberErrorCode.MEMBER_NOT_FOUND;
import static com.funeat.product.exception.ProductErrorCode.PRODUCT_NOT_FOUND;
import static com.funeat.review.exception.ReviewErrorCode.REVIEW_NOT_FOUND;

import com.funeat.common.ImageUploader;
import com.funeat.common.dto.PageDto;
import com.funeat.member.domain.Member;
Expand All @@ -31,9 +26,6 @@
import com.funeat.review.persistence.ReviewTagRepository;
import com.funeat.tag.domain.Tag;
import com.funeat.tag.persistence.TagRepository;
import java.util.List;
import java.util.Optional;
import java.util.stream.Collectors;
import org.springframework.dao.DataIntegrityViolationException;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageRequest;
Expand All @@ -42,13 +34,24 @@
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.multipart.MultipartFile;

import java.util.Comparator;
import java.util.List;
import java.util.Optional;
import java.util.stream.Collectors;

import static com.funeat.member.exception.MemberErrorCode.MEMBER_DUPLICATE_FAVORITE;
import static com.funeat.member.exception.MemberErrorCode.MEMBER_NOT_FOUND;
import static com.funeat.product.exception.ProductErrorCode.PRODUCT_NOT_FOUND;
import static com.funeat.review.exception.ReviewErrorCode.REVIEW_NOT_FOUND;

@Service
@Transactional(readOnly = true)
public class ReviewService {

private static final int TOP = 0;
private static final int ONE = 1;
private static final String EMPTY_URL = "";
private static final int RANKING_SIZE = 3;

private final ReviewRepository reviewRepository;
private final TagRepository tagRepository;
Expand Down Expand Up @@ -156,9 +159,10 @@ public SortingReviewsResponse sortingReviews(final Long productId, final Pageabl
}

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

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

Expand Down
29 changes: 25 additions & 4 deletions backend/src/main/java/com/funeat/review/domain/Review.java
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,7 @@
import com.funeat.member.domain.Member;
import com.funeat.member.domain.favorite.ReviewFavorite;
import com.funeat.product.domain.Product;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.List;
import java.util.Objects;

import javax.persistence.Column;
import javax.persistence.Entity;
import javax.persistence.FetchType;
Expand All @@ -16,10 +13,16 @@
import javax.persistence.JoinColumn;
import javax.persistence.ManyToOne;
import javax.persistence.OneToMany;
import java.time.LocalDateTime;
import java.time.temporal.ChronoUnit;
import java.util.ArrayList;
import java.util.List;

@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 Long getId() {
return id;
}
Expand Down
7 changes: 7 additions & 0 deletions backend/src/test/java/com/funeat/fixture/ReviewFixture.java
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@
import com.funeat.review.domain.Review;
import com.funeat.review.dto.ReviewCreateRequest;
import com.funeat.review.dto.ReviewFavoriteRequest;

import java.time.LocalDateTime;
import java.util.List;

@SuppressWarnings("NonAsciiCharacters")
Expand Down Expand Up @@ -65,6 +67,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 ReviewCreateRequest 리뷰추가요청_생성(final Long rating, final List<Long> tagIds, final String content,
final Boolean rebuy) {
return new ReviewCreateRequest(rating, tagIds, content, rebuy);
Expand Down
Original file line number Diff line number Diff line change
@@ -1,51 +1,37 @@
package com.funeat.review.application;

import static com.funeat.fixture.CategoryFixture.카테고리_즉석조리_생성;
import static com.funeat.fixture.ImageFixture.이미지_생성;
import static com.funeat.fixture.MemberFixture.멤버_멤버1_생성;
import static com.funeat.fixture.MemberFixture.멤버_멤버2_생성;
import static com.funeat.fixture.MemberFixture.멤버_멤버3_생성;
import static com.funeat.fixture.PageFixture.좋아요수_내림차순;
import static com.funeat.fixture.PageFixture.최신순;
import static com.funeat.fixture.PageFixture.페이지요청_기본_생성;
import static com.funeat.fixture.PageFixture.페이지요청_생성;
import static com.funeat.fixture.PageFixture.평점_내림차순;
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원_평점5점_생성;
import static com.funeat.fixture.ProductFixture.상품_삼각김밥_가격2000원_평점1점_생성;
import static com.funeat.fixture.ProductFixture.상품_삼각김밥_가격2000원_평점3점_생성;
import static com.funeat.fixture.ReviewFixture.리뷰_이미지test1_평점1점_재구매O_생성;
import static com.funeat.fixture.ReviewFixture.리뷰_이미지test1_평점1점_재구매X_생성;
import static com.funeat.fixture.ReviewFixture.리뷰_이미지test2_평점2점_재구매O_생성;
import static com.funeat.fixture.ReviewFixture.리뷰_이미지test2_평점2점_재구매X_생성;
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.리뷰_이미지없음_평점1점_재구매O_생성;
import static com.funeat.fixture.ReviewFixture.리뷰좋아요요청_생성;
import static com.funeat.fixture.ReviewFixture.리뷰추가요청_재구매O_생성;
import static com.funeat.fixture.TagFixture.태그_맛있어요_TASTE_생성;
import static com.funeat.fixture.TagFixture.태그_아침식사_ETC_생성;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.assertj.core.api.SoftAssertions.assertSoftly;

import com.funeat.common.ServiceTest;
import com.funeat.common.dto.PageDto;
import com.funeat.member.dto.MemberReviewDto;
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.SortingReviewDto;
import com.funeat.review.exception.ReviewException.ReviewNotFoundException;
import com.funeat.tag.domain.Tag;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;

import java.time.LocalDateTime;
import java.util.Collections;
import java.util.List;
import java.util.stream.Collectors;
import java.util.stream.Stream;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;

import static com.funeat.fixture.CategoryFixture.카테고리_간편식사_생성;
import static com.funeat.fixture.CategoryFixture.카테고리_즉석조리_생성;
import static com.funeat.fixture.ImageFixture.이미지_생성;
import static com.funeat.fixture.MemberFixture.*;
import static com.funeat.fixture.PageFixture.*;
import static com.funeat.fixture.ProductFixture.*;
import static com.funeat.fixture.ReviewFixture.*;
import static com.funeat.fixture.TagFixture.태그_맛있어요_TASTE_생성;
import static com.funeat.fixture.TagFixture.태그_아침식사_ETC_생성;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.assertj.core.api.SoftAssertions.assertSoftly;

@SuppressWarnings("NonAsciiCharacters")
class ReviewServiceTest extends ServiceTest {
Expand Down Expand Up @@ -793,6 +779,155 @@ class updateProductImage_실패_테스트 {
}
}

@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
Loading