diff --git a/src/main/kotlin/kr/weit/roadyfoody/ranking/application/service/RankingCommandService.kt b/src/main/kotlin/kr/weit/roadyfoody/ranking/application/service/RankingCommandService.kt index e832effc..9db5a48c 100644 --- a/src/main/kotlin/kr/weit/roadyfoody/ranking/application/service/RankingCommandService.kt +++ b/src/main/kotlin/kr/weit/roadyfoody/ranking/application/service/RankingCommandService.kt @@ -8,6 +8,8 @@ import kr.weit.roadyfoody.ranking.utils.REPORT_RANKING_KEY import kr.weit.roadyfoody.ranking.utils.REPORT_RANKING_UPDATE_LOCK import kr.weit.roadyfoody.ranking.utils.REVIEW_RANKING_KEY import kr.weit.roadyfoody.ranking.utils.REVIEW_RANKING_UPDATE_LOCK +import kr.weit.roadyfoody.ranking.utils.TOTAL_RANKING_KEY +import kr.weit.roadyfoody.ranking.utils.TOTAL_RANKING_UPDATE_LOCK import kr.weit.roadyfoody.review.repository.FoodSpotsReviewRepository import org.redisson.api.RLock import org.redisson.api.RedissonClient @@ -54,6 +56,16 @@ class RankingCommandService( ) } + @Async("asyncTask") + @Scheduled(cron = "0 0 5 * * *") + fun updateTotalRanking() { + updateRanking( + lockName = TOTAL_RANKING_UPDATE_LOCK, + key = TOTAL_RANKING_KEY, + dataProvider = reviewRepository::findAllUserTotalCount, + ) + } + fun updateRanking( lockName: String, key: String, diff --git a/src/main/kotlin/kr/weit/roadyfoody/ranking/application/service/RankingQueryService.kt b/src/main/kotlin/kr/weit/roadyfoody/ranking/application/service/RankingQueryService.kt index bc23f22c..787b8c9f 100644 --- a/src/main/kotlin/kr/weit/roadyfoody/ranking/application/service/RankingQueryService.kt +++ b/src/main/kotlin/kr/weit/roadyfoody/ranking/application/service/RankingQueryService.kt @@ -9,10 +9,13 @@ import kr.weit.roadyfoody.ranking.utils.REPORT_RANKING_KEY import kr.weit.roadyfoody.ranking.utils.REPORT_RANKING_UPDATE_LOCK import kr.weit.roadyfoody.ranking.utils.REVIEW_RANKING_KEY import kr.weit.roadyfoody.ranking.utils.REVIEW_RANKING_UPDATE_LOCK +import kr.weit.roadyfoody.ranking.utils.TOTAL_RANKING_KEY +import kr.weit.roadyfoody.ranking.utils.TOTAL_RANKING_UPDATE_LOCK import kr.weit.roadyfoody.review.repository.FoodSpotsReviewRepository import org.springframework.data.redis.core.RedisTemplate import org.springframework.stereotype.Service import java.util.concurrent.CompletableFuture +import java.util.concurrent.ExecutorService @Service class RankingQueryService( @@ -20,6 +23,7 @@ class RankingQueryService( private val foodSpotsHistoryRepository: FoodSpotsHistoryRepository, private val reviewRepository: FoodSpotsReviewRepository, private val rankingCommandService: RankingCommandService, + private val executor: ExecutorService, ) { fun getReportRanking(size: Long): List = getRanking( @@ -45,6 +49,14 @@ class RankingQueryService( dataProvider = reviewRepository::findAllUserLikeCount, ) + fun getTotalRanking(size: Long): List = + getRanking( + lockName = TOTAL_RANKING_UPDATE_LOCK, + size = size, + key = TOTAL_RANKING_KEY, + dataProvider = reviewRepository::findAllUserTotalCount, + ) + private fun getRanking( lockName: String, size: Long, @@ -57,13 +69,13 @@ class RankingQueryService( .range(key, 0, size - 1) if (ranking.isNullOrEmpty()) { - CompletableFuture.runAsync { + CompletableFuture.runAsync({ rankingCommandService.updateRanking( lockName = lockName, key = key, dataProvider = dataProvider, ) - } + }, executor) throw RankingNotFoundException() } return ranking.map { score -> diff --git a/src/main/kotlin/kr/weit/roadyfoody/ranking/presentation/api/RankingController.kt b/src/main/kotlin/kr/weit/roadyfoody/ranking/presentation/api/RankingController.kt index f26a1713..94722faa 100644 --- a/src/main/kotlin/kr/weit/roadyfoody/ranking/presentation/api/RankingController.kt +++ b/src/main/kotlin/kr/weit/roadyfoody/ranking/presentation/api/RankingController.kt @@ -34,4 +34,11 @@ class RankingController( @RequestParam(defaultValue = "10") size: Long, ): List = rankingQueryService.getLikeRanking(size) + + @GetMapping("total") + override fun getTotalRanking( + @Positive(message = "size는 양수여야 합니다") + @RequestParam(defaultValue = "10") + size: Long, + ): List = rankingQueryService.getTotalRanking(size) } diff --git a/src/main/kotlin/kr/weit/roadyfoody/ranking/presentation/spec/RankingControllerSpec.kt b/src/main/kotlin/kr/weit/roadyfoody/ranking/presentation/spec/RankingControllerSpec.kt index 25bdad71..55b53e5f 100644 --- a/src/main/kotlin/kr/weit/roadyfoody/ranking/presentation/spec/RankingControllerSpec.kt +++ b/src/main/kotlin/kr/weit/roadyfoody/ranking/presentation/spec/RankingControllerSpec.kt @@ -102,4 +102,33 @@ interface RankingControllerSpec { @RequestParam(defaultValue = "10") size: Long, ): List + + @ApiErrorCodeExamples( + [ + ErrorCode.SIZE_NON_POSITIVE, + ], + ) + @Operation( + description = "종합 랭킹 조회 API", + responses = [ + ApiResponse( + responseCode = "200", + description = "종합 랭킹 조회 성공", + content = [ + Content( + mediaType = MediaType.APPLICATION_JSON_VALUE, + schema = + Schema( + implementation = UserRanking::class, + ), + ), + ], + ), + ], + ) + fun getTotalRanking( + @Positive(message = "size는 양수여야 합니다") + @RequestParam(defaultValue = "10") + size: Long, + ): List } diff --git a/src/main/kotlin/kr/weit/roadyfoody/ranking/utils/RankingConstans.kt b/src/main/kotlin/kr/weit/roadyfoody/ranking/utils/RankingConstans.kt index 9a987db2..7f75ad4a 100644 --- a/src/main/kotlin/kr/weit/roadyfoody/ranking/utils/RankingConstans.kt +++ b/src/main/kotlin/kr/weit/roadyfoody/ranking/utils/RankingConstans.kt @@ -3,6 +3,8 @@ package kr.weit.roadyfoody.ranking.utils const val REPORT_RANKING_KEY = "rofo:user-report-ranking" const val REVIEW_RANKING_KEY = "rofo:user-review-ranking" const val LIKE_RANKING_KEY = "rofo:user-like-ranking" +const val TOTAL_RANKING_KEY = "rofo:user-total-ranking" const val REPORT_RANKING_UPDATE_LOCK = "updateReportRankingLock" const val REVIEW_RANKING_UPDATE_LOCK = "updateReviewRankingLock" const val LIKE_RANKING_UPDATE_LOCK = "updateLikeRankingLock" +const val TOTAL_RANKING_UPDATE_LOCK = "totalLikeRankingLock" diff --git a/src/main/kotlin/kr/weit/roadyfoody/review/repository/FoodSpotsReviewRepository.kt b/src/main/kotlin/kr/weit/roadyfoody/review/repository/FoodSpotsReviewRepository.kt index 9136ac61..c496d409 100644 --- a/src/main/kotlin/kr/weit/roadyfoody/review/repository/FoodSpotsReviewRepository.kt +++ b/src/main/kotlin/kr/weit/roadyfoody/review/repository/FoodSpotsReviewRepository.kt @@ -1,6 +1,7 @@ package kr.weit.roadyfoody.review.repository import com.linecorp.kotlinjdsl.dsl.jpql.Jpql +import com.linecorp.kotlinjdsl.querymodel.jpql.expression.Expressions import com.linecorp.kotlinjdsl.querymodel.jpql.predicate.Predicate import com.linecorp.kotlinjdsl.querymodel.jpql.sort.Sortable import com.linecorp.kotlinjdsl.support.spring.data.jpa.repository.KotlinJdslJpqlExecutor @@ -8,6 +9,7 @@ import kr.weit.roadyfoody.badge.domain.Badge import kr.weit.roadyfoody.foodSpots.application.dto.CountRate import kr.weit.roadyfoody.foodSpots.application.dto.ReviewAggregatedInfoResponse import kr.weit.roadyfoody.foodSpots.domain.FoodSpots +import kr.weit.roadyfoody.foodSpots.domain.FoodSpotsHistory import kr.weit.roadyfoody.global.utils.findList import kr.weit.roadyfoody.global.utils.findMutableList import kr.weit.roadyfoody.global.utils.getSlice @@ -55,6 +57,8 @@ interface CustomFoodSpotsReviewRepository { fun findAllUserLikeCount(): List fun getRatingCount(foodSpotsId: Long): List + + fun findAllUserTotalCount(): List } class CustomFoodSpotsReviewRepositoryImpl( @@ -184,6 +188,72 @@ class CustomFoodSpotsReviewRepositoryImpl( ) } + override fun findAllUserTotalCount(): List = + kotlinJdslJpqlExecutor + .findList { + val foodSpotsReview = entity(FoodSpotsReview::class, "foodSpotsReview") + val foodSpotsHistory = entity(FoodSpotsHistory::class, "foodSpotsHistory") + val reviewLike = entity(ReviewLike::class, "reviewLike") + + val subquery = + select( + coalesce( + count( + foodSpotsReview(FoodSpotsReview::id), + ).plus(sum(foodSpotsReview(FoodSpotsReview::likeTotal))), + 0, + ), + ).from( + foodSpotsReview, + ).where(foodSpotsReview(FoodSpotsReview::user)(User::id).eq(entity(User::class)(User::id))) + .asSubquery() + + val subquery2 = + select( + coalesce(count(foodSpotsHistory(FoodSpotsHistory::id)), 0), + ).from( + foodSpotsHistory, + ).where(foodSpotsHistory(FoodSpotsHistory::user)(User::id).eq(entity(User::class)(User::id))) + .asSubquery() + + val defaultDate = LocalDateTime.parse("1970-12-31T00:00:00") + + val maxReviewDate = coalesce(max(foodSpotsReview(FoodSpotsReview::createdDateTime)), defaultDate) + val maxHistoryDate = coalesce(max(foodSpotsHistory(FoodSpotsHistory::createdDateTime)), defaultDate) + val maxLikeDate = coalesce(max(reviewLike(ReviewLike::createdDateTime)), defaultDate) + + val greatestDateExpression = + Expressions.customExpression( + LocalDateTime::class, + "GREATEST({0}, {1}, {2})", + listOf( + maxReviewDate, + maxHistoryDate, + maxLikeDate, + ), + ) + val total = expression(Long::class, "total") + selectNew( + path(User::profile)(Profile::nickname), + subquery2.plus(subquery).`as`(total), + ).from( + entity(User::class), + leftJoin(foodSpotsHistory).on(foodSpotsHistory(FoodSpotsHistory::user)(User::id).eq(path(User::id))), + leftJoin( + foodSpotsReview, + ).on(foodSpotsReview(FoodSpotsReview::user)(User::id).eq(path(User::id))), + leftJoin( + reviewLike, + ).on(reviewLike(ReviewLike::review)(FoodSpotsReview::id).eq(foodSpotsReview(FoodSpotsReview::id))), + ).groupBy( + path(User::id), + path(User::profile)(Profile::nickname), + ).orderBy( + total.desc(), + greatestDateExpression.asc(), + ) + } + private fun Jpql.dynamicOrder(sortType: ReviewSortType): Array = when (sortType) { ReviewSortType.LATEST -> arrayOf(path(FoodSpotsReview::id).desc()) diff --git a/src/test/kotlin/kr/weit/roadyfoody/ranking/application/service/RankingCommandServiceTest.kt b/src/test/kotlin/kr/weit/roadyfoody/ranking/application/service/RankingCommandServiceTest.kt index ab90d8c6..fcebaddf 100644 --- a/src/test/kotlin/kr/weit/roadyfoody/ranking/application/service/RankingCommandServiceTest.kt +++ b/src/test/kotlin/kr/weit/roadyfoody/ranking/application/service/RankingCommandServiceTest.kt @@ -7,6 +7,10 @@ import io.mockk.mockk import io.mockk.verify import kr.weit.roadyfoody.foodSpots.repository.FoodSpotsHistoryRepository import kr.weit.roadyfoody.ranking.fixture.createUserRankingResponse +import kr.weit.roadyfoody.ranking.utils.LIKE_RANKING_KEY +import kr.weit.roadyfoody.ranking.utils.REPORT_RANKING_KEY +import kr.weit.roadyfoody.ranking.utils.REVIEW_RANKING_KEY +import kr.weit.roadyfoody.ranking.utils.TOTAL_RANKING_KEY import kr.weit.roadyfoody.review.repository.FoodSpotsReviewRepository import org.redisson.api.RLock import org.redisson.api.RedissonClient @@ -39,7 +43,7 @@ class RankingCommandServiceTest : `when`("Lock을 획득한 경우") { every { lock.tryLock(0, 10, TimeUnit.MINUTES) } returns true - every { redisTemplate.delete("rofo:user-report-ranking") } returns true + every { redisTemplate.delete(REPORT_RANKING_KEY) } returns true every { foodSpotsHistoryRepository.findAllUserReportCount() } returns createUserRankingResponse() every { redisTemplate.opsForList() } returns list every { list.rightPushAll(any(), any>()) } returns 1L @@ -67,7 +71,7 @@ class RankingCommandServiceTest : `when`("Lock을 획득한 경우") { every { lock.tryLock(0, 10, TimeUnit.MINUTES) } returns true - every { redisTemplate.delete("rofo:user-review-ranking") } returns true + every { redisTemplate.delete(REVIEW_RANKING_KEY) } returns true every { reviewRepository.findAllUserReviewCount() } returns createUserRankingResponse() every { redisTemplate.opsForList() } returns list every { list.rightPushAll(any(), any>()) } returns 1L @@ -95,7 +99,7 @@ class RankingCommandServiceTest : `when`("Lock을 획득한 경우") { every { lock.tryLock(0, 10, TimeUnit.MINUTES) } returns true - every { redisTemplate.delete("rofo:user-like-ranking") } returns true + every { redisTemplate.delete(LIKE_RANKING_KEY) } returns true every { reviewRepository.findAllUserLikeCount() } returns createUserRankingResponse() every { redisTemplate.opsForList() } returns list every { list.rightPushAll(any(), any>()) } returns 1L @@ -115,5 +119,33 @@ class RankingCommandServiceTest : } } } + + given("updateTotalRanking 테스트") { + every { redissonClient.getLock(any()) } returns lock + afterEach { clearMocks(reviewRepository) } + + `when`("Lock을 획득한 경우") { + every { lock.tryLock(0, 10, TimeUnit.MINUTES) } returns true + + every { redisTemplate.delete(TOTAL_RANKING_KEY) } returns true + every { reviewRepository.findAllUserTotalCount() } returns createUserRankingResponse() + every { redisTemplate.opsForList() } returns list + every { list.rightPushAll(any(), any>()) } returns 1L + + then("레디스의 데이터가 정상적으로 업데이트된다.") { + rankingCommandService.updateTotalRanking() + verify(exactly = 1) { reviewRepository.findAllUserTotalCount() } + } + } + + `when`("Lock을 획득하지 못한 경우") { + every { lock.tryLock(0, 10, TimeUnit.MINUTES) } returns false + + then("레디스의 데이터가 업데이트되지 않는다.") { + rankingCommandService.updateTotalRanking() + verify(exactly = 0) { reviewRepository.findAllUserTotalCount() } + } + } + } }, ) diff --git a/src/test/kotlin/kr/weit/roadyfoody/ranking/application/service/RankingQueryServiceTest.kt b/src/test/kotlin/kr/weit/roadyfoody/ranking/application/service/RankingQueryServiceTest.kt index f1201949..4d6a5cdc 100644 --- a/src/test/kotlin/kr/weit/roadyfoody/ranking/application/service/RankingQueryServiceTest.kt +++ b/src/test/kotlin/kr/weit/roadyfoody/ranking/application/service/RankingQueryServiceTest.kt @@ -2,8 +2,10 @@ package kr.weit.roadyfoody.ranking.application.service import io.kotest.assertions.throwables.shouldThrow import io.kotest.core.spec.style.BehaviorSpec +import io.mockk.Runs import io.mockk.clearMocks import io.mockk.every +import io.mockk.just import io.mockk.mockk import io.mockk.verify import kr.weit.roadyfoody.foodSpots.repository.FoodSpotsHistoryRepository @@ -12,23 +14,28 @@ import kr.weit.roadyfoody.ranking.fixture.createUserRankingResponse import kr.weit.roadyfoody.review.repository.FoodSpotsReviewRepository import org.springframework.data.redis.core.ListOperations import org.springframework.data.redis.core.RedisTemplate +import java.util.concurrent.ExecutorService class RankingQueryServiceTest : BehaviorSpec( { - val redisTemplate = mockk>() val foodSpotsHistoryRepository = mockk() val reviewRepository = mockk() val rankingCommandService = mockk() + val executor = mockk() val rankingQueryService = - RankingQueryService(redisTemplate, foodSpotsHistoryRepository, reviewRepository, rankingCommandService) + RankingQueryService(redisTemplate, foodSpotsHistoryRepository, reviewRepository, rankingCommandService, executor) val listOperation = mockk>() val list = listOf("user1:10", "user2:20", "user3:15") afterEach { clearMocks(reviewRepository) } afterEach { clearMocks(listOperation) } + afterEach { clearMocks(rankingCommandService) } + every { executor.execute(any()) } answers { + firstArg().run() + } given("getReportRanking 테스트") { `when`("레디스의 데이터를 조회한 경우") { @@ -108,5 +115,67 @@ class RankingQueryServiceTest : } } } + given("getTotalRanking 테스트") { + + `when`("레디스의 데이터를 조회한 경우") { + every { redisTemplate.opsForList() } returns listOperation + every { listOperation.range(any(), any(), any()) } returns list + + then("종합 랭킹이 조회된다.") { + rankingQueryService.getTotalRanking(10) + verify(exactly = 1) { listOperation.range(any(), any(), any()) } + } + } + + `when`("레디스의 데이터가 null인 경우") { + every { redisTemplate.opsForList() } returns listOperation + every { listOperation.range(any(), any(), any()) } returns null + every { listOperation.rightPushAll(any(), any>()) } returns 1L + every { reviewRepository.findAllUserTotalCount() } returns createUserRankingResponse() + every { + rankingCommandService.updateRanking( + any(), + any(), + any(), + ) + } just Runs + then("예외가 발생한다.") { + + shouldThrow { rankingQueryService.getTotalRanking(10) } + + verify(exactly = 1) { + rankingCommandService.updateRanking( + any(), + any(), + any(), + ) + } + } + } + + `when`("레디스의 데이터가 빈값인 경우") { + every { redisTemplate.opsForList() } returns listOperation + every { listOperation.range(any(), any(), any()) } returns listOf() + every { listOperation.rightPushAll(any(), any>()) } returns 1L + every { reviewRepository.findAllUserTotalCount() } returns createUserRankingResponse() + every { + rankingCommandService.updateRanking( + any(), + any(), + any(), + ) + } just Runs + then("예외가 발생한다.") { + shouldThrow { rankingQueryService.getTotalRanking(10) } + verify(exactly = 1) { + rankingCommandService.updateRanking( + any(), + any(), + any(), + ) + } + } + } + } }, ) diff --git a/src/test/kotlin/kr/weit/roadyfoody/ranking/presentation/api/RankingControllerTest.kt b/src/test/kotlin/kr/weit/roadyfoody/ranking/presentation/api/RankingControllerTest.kt index 9d0c3712..86807741 100644 --- a/src/test/kotlin/kr/weit/roadyfoody/ranking/presentation/api/RankingControllerTest.kt +++ b/src/test/kotlin/kr/weit/roadyfoody/ranking/presentation/api/RankingControllerTest.kt @@ -98,5 +98,31 @@ class RankingControllerTest( } } } + + given("GET $requestPath/total") { + val response = createUserRankingResponse() + every { + rankingQueryService.getTotalRanking(any()) + } returns response + `when`("정상적인 데이터가 들어올 경우") { + then("좋아요 랭킹 리스트가 조회된다.") { + mockMvc + .perform( + getWithAuth("$requestPath/total") + .param("size", "$TEST_PAGE_SIZE"), + ).andExpect(status().isOk) + } + } + + `when`("size가 음수가 들어올 경우") { + then("400을 반환한다") { + mockMvc + .perform( + getWithAuth("$requestPath/total") + .param("size", "-1"), + ).andExpect(status().isBadRequest) + } + } + } }, ) diff --git a/src/test/kotlin/kr/weit/roadyfoody/review/repository/FoodSpotsReviewRepositoryTest.kt b/src/test/kotlin/kr/weit/roadyfoody/review/repository/FoodSpotsReviewRepositoryTest.kt index cf9d38cb..13c968c8 100644 --- a/src/test/kotlin/kr/weit/roadyfoody/review/repository/FoodSpotsReviewRepositoryTest.kt +++ b/src/test/kotlin/kr/weit/roadyfoody/review/repository/FoodSpotsReviewRepositoryTest.kt @@ -253,5 +253,21 @@ class FoodSpotsReviewRepositoryTest( } } } + + describe("findAllUserTotalCount 메소드는") { + it("전체 회원의 종합 랭킹을 리스트로 반환한다") { + val userTotalCounts = reviewRepository.findAllUserTotalCount() + userTotalCounts.size shouldBe 3 + + userTotalCounts[0].userNickname shouldBe "existentNick" + userTotalCounts[0].total shouldBe 6 + + userTotalCounts[1].userNickname shouldBe "otherUser" + userTotalCounts[1].total shouldBe 4 + + userTotalCounts[2].userNickname shouldBe "testUser" + userTotalCounts[2].total shouldBe 4 + } + } }, )