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,10 +1,5 @@
package com.funeat.recipe.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.recipe.exception.RecipeErrorCode.RECIPE_NOT_FOUND;

import com.funeat.common.ImageUploader;
import com.funeat.common.dto.PageDto;
import com.funeat.member.domain.Member;
Expand Down Expand Up @@ -36,23 +31,29 @@
import com.funeat.recipe.exception.RecipeException.RecipeNotFoundException;
import com.funeat.recipe.persistence.RecipeImageRepository;
import com.funeat.recipe.persistence.RecipeRepository;
import java.util.List;
import java.util.Objects;
import java.util.stream.Collectors;
import org.springframework.dao.DataIntegrityViolationException;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Pageable;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.multipart.MultipartFile;

import java.util.Comparator;
import java.util.List;
import java.util.Objects;
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.recipe.exception.RecipeErrorCode.RECIPE_NOT_FOUND;

@Service
@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 final MemberRepository memberRepository;
private final ProductRepository productRepository;
Expand Down Expand Up @@ -190,9 +191,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
21 changes: 20 additions & 1 deletion backend/src/main/java/com/funeat/recipe/domain/Recipe.java
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
package com.funeat.recipe.domain;

import com.funeat.member.domain.Member;
import java.time.LocalDateTime;

import javax.persistence.Column;
import javax.persistence.Entity;
import javax.persistence.FetchType;
Expand All @@ -10,10 +10,14 @@
import javax.persistence.Id;
import javax.persistence.JoinColumn;
import javax.persistence.ManyToOne;
import java.time.LocalDateTime;
import java.time.temporal.ChronoUnit;

@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 +52,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
@@ -1,19 +1,20 @@
package com.funeat.recipe.persistence;

import static javax.persistence.LockModeType.PESSIMISTIC_WRITE;

import com.funeat.member.domain.Member;
import com.funeat.product.domain.Product;
import com.funeat.recipe.domain.Recipe;
import java.util.List;
import java.util.Optional;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Lock;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;

import java.util.List;
import java.util.Optional;

import static javax.persistence.LockModeType.PESSIMISTIC_WRITE;

Go-Jaecheol marked this conversation as resolved.
Show resolved Hide resolved
public interface RecipeRepository extends JpaRepository<Recipe, Long> {

@Query("SELECT r FROM Recipe r "
Expand All @@ -37,4 +38,6 @@ public interface RecipeRepository extends JpaRepository<Recipe, Long> {
@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);
}
7 changes: 7 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,8 @@
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 +35,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
@@ -1,5 +1,32 @@
package com.funeat.recipe.application;

import com.funeat.common.ServiceTest;
import com.funeat.common.dto.PageDto;
import com.funeat.member.domain.Member;
import com.funeat.member.dto.MemberRecipeDto;
import com.funeat.member.dto.MemberRecipeProductDto;
import com.funeat.member.dto.MemberRecipesResponse;
import com.funeat.member.exception.MemberException.MemberNotFoundException;
import com.funeat.product.domain.Category;
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.RecipeCreateRequest;
import com.funeat.recipe.dto.RecipeDetailResponse;
import com.funeat.recipe.dto.RecipeDto;
import com.funeat.recipe.exception.RecipeException.RecipeNotFoundException;
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;

wugawuga marked this conversation as resolved.
Show resolved Hide resolved
import static com.funeat.fixture.CategoryFixture.카테고리_간편식사_생성;
import static com.funeat.fixture.CategoryFixture.카테고리_즉석조리_생성;
import static com.funeat.fixture.ImageFixture.여러_이미지_생성;
Expand All @@ -24,29 +51,6 @@
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.domain.Member;
import com.funeat.member.dto.MemberRecipeDto;
import com.funeat.member.dto.MemberRecipeProductDto;
import com.funeat.member.dto.MemberRecipesResponse;
import com.funeat.member.exception.MemberException.MemberNotFoundException;
import com.funeat.product.domain.Category;
import com.funeat.product.domain.CategoryType;
import com.funeat.product.domain.Product;
import com.funeat.product.exception.ProductException.ProductNotFoundException;
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 org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;

import java.util.Collections;
import java.util.List;
import java.util.stream.Collectors;
import java.util.stream.Stream;

@SuppressWarnings("NonAsciiCharacters")
class RecipeServiceTest extends ServiceTest {

Expand Down Expand Up @@ -545,6 +549,135 @@ 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개_이상_3개_미만이라도_꿀조합이_나와야한다() {
wugawuga marked this conversation as resolved.
Show resolved Hide resolved
// 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);
}
}
}

private <T> void 해당멤버의_꿀조합과_페이징_결과를_검증한다(final MemberRecipesResponse actual, final List<T> expectedRecipesDtos,
final PageDto expectedPage) {
assertSoftly(soft -> {
Expand Down
37 changes: 37 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,37 @@
package com.funeat.recipe.domain;

import org.junit.jupiter.api.DisplayNameGeneration;
import org.junit.jupiter.api.DisplayNameGenerator;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;

import java.time.LocalDateTime;

import static com.funeat.fixture.MemberFixture.멤버_멤버1_생성;
import static com.funeat.fixture.RecipeFixture.레시피_생성;
import static org.assertj.core.api.Assertions.assertThat;

@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