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: 꿀조합 랭킹 알고리즘 개선 #755

Merged
merged 8 commits into from
Oct 18, 2023
Original file line number Diff line number Diff line change
@@ -1,6 +1,11 @@
package com.funeat.product.domain;

import javax.persistence.*;
import javax.persistence.Entity;
import javax.persistence.EnumType;
import javax.persistence.Enumerated;
import javax.persistence.GeneratedValue;
import javax.persistence.GenerationType;
import javax.persistence.Id;

@Entity
public class Category {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@
import com.funeat.recipe.persistence.RecipeImageRepository;
import com.funeat.recipe.persistence.RecipeRepository;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.List;
import java.util.Objects;
import java.util.stream.Collectors;
Expand All @@ -61,8 +62,8 @@
@Transactional(readOnly = true)
public class RecipeService {

private static final int THREE = 3;
private static final int TOP = 0;
private static final long RANKING_MINIMUM_FAVORITE_COUNT = 1L;
private static final int RANKING_SIZE = 3;
Go-Jaecheol marked this conversation as resolved.
Show resolved Hide resolved
private static final int RECIPE_COMMENT_PAGE_SIZE = 10;
private static final int DEFAULT_CURSOR_PAGINATION_SIZE = 11;

Expand Down Expand Up @@ -206,9 +207,11 @@ public SearchRecipeResultsResponse getSearchResults(final String query, final Pa
}

public RankingRecipesResponse getTop3Recipes() {
final List<Recipe> recipes = recipeRepository.findRecipesByOrderByFavoriteCountDesc(PageRequest.of(TOP, THREE));
final List<Recipe> recipes = recipeRepository.findRecipesByFavoriteCountGreaterThanEqual(RANKING_MINIMUM_FAVORITE_COUNT);

final List<RankingRecipeDto> dtos = recipes.stream()
.sorted(Comparator.comparing(Recipe::calculateRankingScore).reversed())
.limit(RANKING_SIZE)
.map(recipe -> {
final List<RecipeImage> findRecipeImages = recipeImageRepository.findByRecipe(recipe);
final RecipeAuthorDto author = RecipeAuthorDto.toDto(recipe.getMember());
Expand Down
18 changes: 18 additions & 0 deletions backend/src/main/java/com/funeat/recipe/domain/Recipe.java
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import com.funeat.member.domain.Member;
import java.time.LocalDateTime;
import java.time.temporal.ChronoUnit;
import javax.persistence.Column;
import javax.persistence.Entity;
import javax.persistence.FetchType;
Expand All @@ -14,6 +15,8 @@
@Entity
public class Recipe {

private static final double RANKING_GRAVITY = 0.1;
Go-Jaecheol marked this conversation as resolved.
Show resolved Hide resolved

@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
Expand Down Expand Up @@ -48,6 +51,21 @@ public Recipe(final String title, final String content, final Member member,
this.favoriteCount = favoriteCount;
}

public Recipe(final String title, final String content, final Member member, final Long favoriteCount,
final LocalDateTime createdAt) {
this.title = title;
this.content = content;
this.member = member;
this.favoriteCount = favoriteCount;
this.createdAt = createdAt;
}

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 void addFavoriteCount() {
this.favoriteCount++;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -32,9 +32,9 @@ public interface RecipeRepository extends JpaRepository<Recipe, Long> {
@Query("SELECT r FROM Recipe r LEFT JOIN ProductRecipe pr ON pr.product = :product WHERE pr.recipe.id = r.id")
Page<Recipe> findRecipesByProduct(final Product product, final Pageable pageable);

List<Recipe> findRecipesByOrderByFavoriteCountDesc(final Pageable pageable);

@Lock(PESSIMISTIC_WRITE)
@Query("SELECT r FROM Recipe r WHERE r.id=:id")
Optional<Recipe> findByIdForUpdate(final Long id);

List<Recipe> findRecipesByFavoriteCountGreaterThanEqual(final Long favoriteCount);
}
Original file line number Diff line number Diff line change
Expand Up @@ -11,14 +11,10 @@
import com.funeat.review.dto.SortingReviewRequest;
import com.funeat.review.dto.SortingReviewsResponse;
import java.net.URI;
import java.util.Objects;
import java.util.Optional;
import javax.validation.Valid;
import org.springframework.data.domain.Pageable;
import org.springframework.data.web.PageableDefault;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.ModelAttribute;
import org.springframework.web.bind.annotation.PatchMapping;
Expand Down
6 changes: 6 additions & 0 deletions backend/src/test/java/com/funeat/fixture/RecipeFixture.java
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
import com.funeat.recipe.domain.RecipeImage;
import com.funeat.recipe.dto.RecipeCreateRequest;
import com.funeat.recipe.dto.RecipeFavoriteRequest;
import java.time.LocalDateTime;
import java.util.List;

@SuppressWarnings("NonAsciiCharacters")
Expand Down Expand Up @@ -33,6 +34,11 @@ public class RecipeFixture {
return new Recipe("The most delicious recipes", "More rice, more rice, more rice.. Done!!", member, favoriteCount);
}

public static Recipe 레시피_생성(final Member member, final Long favoriteCount, final LocalDateTime createdAt) {
return new Recipe("The most delicious recipes", "More rice, more rice, more rice.. Done!!",
member, favoriteCount, createdAt);
}

public static RecipeFavorite 레시피_좋아요_생성(final Member member, final Recipe recipe, final Boolean favorite) {
return new RecipeFavorite(member, recipe, favorite);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -36,13 +36,17 @@
import com.funeat.product.domain.CategoryType;
import com.funeat.product.domain.Product;
import com.funeat.product.exception.ProductException.ProductNotFoundException;
import com.funeat.recipe.dto.RankingRecipeDto;
import com.funeat.recipe.dto.RankingRecipesResponse;
import com.funeat.recipe.dto.RecipeAuthorDto;
import com.funeat.recipe.dto.RecipeCommentCondition;
import com.funeat.recipe.dto.RecipeCommentCreateRequest;
import com.funeat.recipe.dto.RecipeCommentResponse;
import com.funeat.recipe.dto.RecipeCreateRequest;
import com.funeat.recipe.dto.RecipeDetailResponse;
import com.funeat.recipe.dto.RecipeDto;
import com.funeat.recipe.exception.RecipeException.RecipeNotFoundException;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
Expand Down Expand Up @@ -551,6 +555,158 @@ class likeRecipe_실패_테스트 {
}
}

@Nested
class getTop3Recipes_성공_테스트 {

@Nested
class 꿀조합_개수에_대한_테스트 {

@Test
void 전체_꿀조합이_하나도_없어도_반환값은_있어야한다() {
// given
final var expected = RankingRecipesResponse.toResponse(Collections.emptyList());

// when
final var actual = recipeService.getTop3Recipes();

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

@Test
void 랭킹_조건에_부합하는_꿀조합이_1개면_꿀조합이_1개_반환된다() {
// given
final var member = 멤버_멤버1_생성();
단일_멤버_저장(member);

final var now = LocalDateTime.now();
final var recipe = 레시피_생성(member, 2L, now);
단일_꿀조합_저장(recipe);

final var author = RecipeAuthorDto.toDto(member);
final var rankingRecipeDto = RankingRecipeDto.toDto(recipe, Collections.emptyList(), author);
final var rankingRecipesDtos = Collections.singletonList(rankingRecipeDto);
final var expected = RankingRecipesResponse.toResponse(rankingRecipesDtos);

// when
final var actual = recipeService.getTop3Recipes();

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

@Test
void 랭킹_조건에_부합하는_꿀조합이_2개면_꿀조합이_2개_반환된다() {
// given
final var member = 멤버_멤버1_생성();
단일_멤버_저장(member);

final var now = LocalDateTime.now();
final var recipe1 = 레시피_생성(member, 2L, now.minusDays(1L));
final var recipe2 = 레시피_생성(member, 2L, now);
복수_꿀조합_저장(recipe1, recipe2);

final var author = RecipeAuthorDto.toDto(member);
final var rankingRecipeDto1 = RankingRecipeDto.toDto(recipe1, Collections.emptyList(), author);
final var rankingRecipeDto2 = RankingRecipeDto.toDto(recipe2, Collections.emptyList(), author);
final var rankingRecipesDtos = List.of(rankingRecipeDto2, rankingRecipeDto1);
final var expected = RankingRecipesResponse.toResponse(rankingRecipesDtos);

// when
final var actual = recipeService.getTop3Recipes();

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

@Test
void 전체_꿀조합_중_랭킹이_높은_상위_3개_꿀조합을_구할_수_있다() {
// given
final var member = 멤버_멤버1_생성();
단일_멤버_저장(member);

final var now = LocalDateTime.now();
final var recipe1 = 레시피_생성(member, 4L, now.minusDays(10L));
final var recipe2 = 레시피_생성(member, 6L, now.minusDays(10L));
final var recipe3 = 레시피_생성(member, 5L, now);
final var recipe4 = 레시피_생성(member, 6L, now);
복수_꿀조합_저장(recipe1, recipe2, recipe3, recipe4);

final var author = RecipeAuthorDto.toDto(member);
final var rankingRecipeDto1 = RankingRecipeDto.toDto(recipe1, Collections.emptyList(), author);
final var rankingRecipeDto2 = RankingRecipeDto.toDto(recipe2, Collections.emptyList(), author);
final var rankingRecipeDto3 = RankingRecipeDto.toDto(recipe3, Collections.emptyList(), author);
final var rankingRecipeDto4 = RankingRecipeDto.toDto(recipe4, Collections.emptyList(), author);
final var rankingRecipesDtos = List.of(rankingRecipeDto4, rankingRecipeDto3, rankingRecipeDto2);
final var expected = RankingRecipesResponse.toResponse(rankingRecipesDtos);

// when
final var actual = recipeService.getTop3Recipes();

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

@Nested
class 꿀조합_랭킹_점수에_대한_테스트 {

@Test
void 꿀조합_좋아요_수가_같으면_최근_생성된_꿀조합의_랭킹을_더_높게_반환한다() {
// given
final var member = 멤버_멤버1_생성();
단일_멤버_저장(member);

final var now = LocalDateTime.now();
final var recipe1 = 레시피_생성(member, 10L, now.minusDays(9L));
final var recipe2 = 레시피_생성(member, 10L, now.minusDays(4L));
복수_꿀조합_저장(recipe1, recipe2);

final var author = RecipeAuthorDto.toDto(member);
final var rankingRecipeDto1 = RankingRecipeDto.toDto(recipe1, Collections.emptyList(), author);
final var rankingRecipeDto2 = RankingRecipeDto.toDto(recipe2, Collections.emptyList(), author);
final var rankingRecipesDtos = List.of(rankingRecipeDto2, rankingRecipeDto1);
final var expected = RankingRecipesResponse.toResponse(rankingRecipesDtos);

// when
final var actual = recipeService.getTop3Recipes();

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

@Test
void 꿀조합_생성_일자가_같으면_좋아요_수가_많은_꿀조합의_랭킹을_더_높게_반환한다() {
// given
final var member = 멤버_멤버1_생성();
단일_멤버_저장(member);

final var now = LocalDateTime.now();
final var recipe1 = 레시피_생성(member, 2L, now.minusDays(1L));
final var recipe2 = 레시피_생성(member, 4L, now.minusDays(1L));
복수_꿀조합_저장(recipe1, recipe2);

final var author = RecipeAuthorDto.toDto(member);
final var rankingRecipeDto1 = RankingRecipeDto.toDto(recipe1, Collections.emptyList(), author);
final var rankingRecipeDto2 = RankingRecipeDto.toDto(recipe2, Collections.emptyList(), author);
final var rankingRecipesDtos = List.of(rankingRecipeDto2, rankingRecipeDto1);
final var expected = RankingRecipesResponse.toResponse(rankingRecipesDtos);

// when
final var actual = recipeService.getTop3Recipes();

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

@Nested
class writeCommentOfRecipe_성공_테스트 {

Expand Down
36 changes: 36 additions & 0 deletions backend/src/test/java/com/funeat/recipe/domain/RecipeTest.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
package com.funeat.recipe.domain;

import static com.funeat.fixture.MemberFixture.멤버_멤버1_생성;
import static com.funeat.fixture.RecipeFixture.레시피_생성;
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 RecipeTest {

@Nested
class calculateRankingScore_성공_테스트 {

@Test
void 꿀조합_좋아요_수와_꿀조합_생성_시간으로_해당_꿀조합의_랭킹_점수를_구할_수_있다() {
// given
final var member = 멤버_멤버1_생성();
final var favoriteCount = 4L;
final var recipe = 레시피_생성(member, favoriteCount, LocalDateTime.now().minusDays(1L));

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

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

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