diff --git a/backend/src/main/java/com/funeat/product/domain/Category.java b/backend/src/main/java/com/funeat/product/domain/Category.java index 5d6c62a08..7702a087e 100644 --- a/backend/src/main/java/com/funeat/product/domain/Category.java +++ b/backend/src/main/java/com/funeat/product/domain/Category.java @@ -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 { diff --git a/backend/src/main/java/com/funeat/recipe/application/RecipeService.java b/backend/src/main/java/com/funeat/recipe/application/RecipeService.java index d9ede67c3..19545b20b 100644 --- a/backend/src/main/java/com/funeat/recipe/application/RecipeService.java +++ b/backend/src/main/java/com/funeat/recipe/application/RecipeService.java @@ -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; @@ -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; private static final int RECIPE_COMMENT_PAGE_SIZE = 10; private static final int DEFAULT_CURSOR_PAGINATION_SIZE = 11; @@ -206,9 +207,11 @@ public SearchRecipeResultsResponse getSearchResults(final String query, final Pa } public RankingRecipesResponse getTop3Recipes() { - final List recipes = recipeRepository.findRecipesByOrderByFavoriteCountDesc(PageRequest.of(TOP, THREE)); + final List recipes = recipeRepository.findRecipesByFavoriteCountGreaterThanEqual(RANKING_MINIMUM_FAVORITE_COUNT); final List dtos = recipes.stream() + .sorted(Comparator.comparing(Recipe::calculateRankingScore).reversed()) + .limit(RANKING_SIZE) .map(recipe -> { final List findRecipeImages = recipeImageRepository.findByRecipe(recipe); final RecipeAuthorDto author = RecipeAuthorDto.toDto(recipe.getMember()); diff --git a/backend/src/main/java/com/funeat/recipe/domain/Recipe.java b/backend/src/main/java/com/funeat/recipe/domain/Recipe.java index dcb607148..5ffb0438b 100644 --- a/backend/src/main/java/com/funeat/recipe/domain/Recipe.java +++ b/backend/src/main/java/com/funeat/recipe/domain/Recipe.java @@ -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; @@ -14,6 +15,8 @@ @Entity public class Recipe { + private static final double RANKING_GRAVITY = 0.1; + @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; @@ -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++; } diff --git a/backend/src/main/java/com/funeat/recipe/persistence/RecipeRepository.java b/backend/src/main/java/com/funeat/recipe/persistence/RecipeRepository.java index 4d1a3a306..ce5ef3c31 100644 --- a/backend/src/main/java/com/funeat/recipe/persistence/RecipeRepository.java +++ b/backend/src/main/java/com/funeat/recipe/persistence/RecipeRepository.java @@ -32,9 +32,9 @@ public interface RecipeRepository extends JpaRepository { @Query("SELECT r FROM Recipe r LEFT JOIN ProductRecipe pr ON pr.product = :product WHERE pr.recipe.id = r.id") Page findRecipesByProduct(final Product product, final Pageable pageable); - List findRecipesByOrderByFavoriteCountDesc(final Pageable pageable); - @Lock(PESSIMISTIC_WRITE) @Query("SELECT r FROM Recipe r WHERE r.id=:id") Optional findByIdForUpdate(final Long id); + + List findRecipesByFavoriteCountGreaterThanEqual(final Long favoriteCount); } diff --git a/backend/src/main/java/com/funeat/review/presentation/ReviewApiController.java b/backend/src/main/java/com/funeat/review/presentation/ReviewApiController.java index cb68d6e6b..0249b6967 100644 --- a/backend/src/main/java/com/funeat/review/presentation/ReviewApiController.java +++ b/backend/src/main/java/com/funeat/review/presentation/ReviewApiController.java @@ -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; diff --git a/backend/src/test/java/com/funeat/fixture/RecipeFixture.java b/backend/src/test/java/com/funeat/fixture/RecipeFixture.java index 2050727f3..2d3bb3deb 100644 --- a/backend/src/test/java/com/funeat/fixture/RecipeFixture.java +++ b/backend/src/test/java/com/funeat/fixture/RecipeFixture.java @@ -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") @@ -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); } diff --git a/backend/src/test/java/com/funeat/recipe/application/RecipeServiceTest.java b/backend/src/test/java/com/funeat/recipe/application/RecipeServiceTest.java index c0f68e789..81badf4f6 100644 --- a/backend/src/test/java/com/funeat/recipe/application/RecipeServiceTest.java +++ b/backend/src/test/java/com/funeat/recipe/application/RecipeServiceTest.java @@ -36,6 +36,9 @@ 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; @@ -43,6 +46,7 @@ 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; @@ -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_성공_테스트 { diff --git a/backend/src/test/java/com/funeat/recipe/domain/RecipeTest.java b/backend/src/test/java/com/funeat/recipe/domain/RecipeTest.java new file mode 100644 index 000000000..7a0d28030 --- /dev/null +++ b/backend/src/test/java/com/funeat/recipe/domain/RecipeTest.java @@ -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); + } + } +} diff --git a/backend/src/test/java/com/funeat/recipe/persistence/RecipeRepositoryTest.java b/backend/src/test/java/com/funeat/recipe/persistence/RecipeRepositoryTest.java index 494f7b215..9c53177db 100644 --- a/backend/src/test/java/com/funeat/recipe/persistence/RecipeRepositoryTest.java +++ b/backend/src/test/java/com/funeat/recipe/persistence/RecipeRepositoryTest.java @@ -21,6 +21,7 @@ import static org.assertj.core.api.Assertions.assertThat; import com.funeat.common.RepositoryTest; +import java.util.Collections; import java.util.List; import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; @@ -259,25 +260,44 @@ class findRecipesByProduct_성공_테스트 { } @Nested - class findRecipesByOrderByFavoriteCountDesc_성공_테스트 { + class findRecipesByFavoriteCountGreaterThanEqual_성공_테스트 { @Test - void 좋아요순으로_상위_3개의_레시피들을_조회한다() { + void 특정_좋아요_수_이상인_모든_꿀조합들을_조회한다() { // given final var member = 멤버_멤버1_생성(); 단일_멤버_저장(member); - final var recipe1 = 레시피_생성(member, 1L); - final var recipe2 = 레시피_생성(member, 2L); - final var recipe3 = 레시피_생성(member, 3L); - final var recipe4 = 레시피_생성(member, 4L); + final var recipe1 = 레시피_생성(member, 0L); + final var recipe2 = 레시피_생성(member, 1L); + final var recipe3 = 레시피_생성(member, 10L); + final var recipe4 = 레시피_생성(member, 100L); 복수_꿀조합_저장(recipe1, recipe2, recipe3, recipe4); - final var page = 페이지요청_기본_생성(0, 3); - final var expected = List.of(recipe4, recipe3, recipe2); + final var expected = List.of(recipe2, recipe3, recipe4); + + // when + final var actual = recipeRepository.findRecipesByFavoriteCountGreaterThanEqual(1L); + + // then + assertThat(actual).usingRecursiveComparison() + .isEqualTo(expected); + } + + @Test + void 특정_좋아요_수_이상인_꿀조합이_없으면_빈_리스트를_반환한다() { + // given + final var member = 멤버_멤버1_생성(); + 단일_멤버_저장(member); + + final var recipe1 = 레시피_생성(member, 0L); + final var recipe2 = 레시피_생성(member, 0L); + 복수_꿀조합_저장(recipe1, recipe2); + + final var expected = Collections.emptyList(); // when - final var actual = recipeRepository.findRecipesByOrderByFavoriteCountDesc(page); + final var actual = recipeRepository.findRecipesByFavoriteCountGreaterThanEqual(1L); // then assertThat(actual).usingRecursiveComparison()