Skip to content

Commit

Permalink
Merge pull request #69 from Weit-2nd/feature/ROFO-181
Browse files Browse the repository at this point in the history
�ROFO 181 리포트 랭킹 구현, 조회 api 개발
  • Loading branch information
hyunjungkimm authored Aug 29, 2024
2 parents 41ad630 + 2d1b24f commit d4fac45
Show file tree
Hide file tree
Showing 13 changed files with 317 additions and 25 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -408,3 +408,10 @@ data class ReviewAggregatedInfoResponse(
reviewCount ?: 0,
)
}

data class UserReportCount(
@Schema(description = "유저 닉네임", example = "로디푸디유저")
val userNickname: String,
@Schema(description = "유저가 작성한 Report 개수", example = "1")
val reportCount: Long,
)
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import kr.weit.roadyfoody.common.exception.ErrorCode
import kr.weit.roadyfoody.common.exception.RoadyFoodyBadRequestException
import kr.weit.roadyfoody.foodSpots.application.dto.FoodSpotsUpdateRequest
import kr.weit.roadyfoody.foodSpots.application.dto.ReportRequest
import kr.weit.roadyfoody.foodSpots.application.dto.UserReportCount
import kr.weit.roadyfoody.foodSpots.application.service.event.ReportErrorCompensatingTxSync
import kr.weit.roadyfoody.foodSpots.domain.FoodSpots
import kr.weit.roadyfoody.foodSpots.domain.FoodSpotsFoodCategory
Expand Down Expand Up @@ -37,6 +38,7 @@ import kr.weit.roadyfoody.rewards.domain.RewardType
import kr.weit.roadyfoody.rewards.domain.Rewards
import kr.weit.roadyfoody.user.application.service.UserCommandService
import kr.weit.roadyfoody.user.domain.User
import org.redisson.api.RLock
import org.redisson.api.RedissonClient
import org.springframework.data.redis.core.RedisTemplate
import org.springframework.scheduling.annotation.Scheduled
Expand All @@ -52,6 +54,7 @@ import java.time.ZoneId
import java.util.Date
import java.util.concurrent.CompletableFuture
import java.util.concurrent.ExecutorService
import java.util.concurrent.TimeUnit

@Service
class FoodSpotsCommandService(
Expand Down Expand Up @@ -79,6 +82,9 @@ class FoodSpotsCommandService(
const val FOOD_SPOTS_REPORT_LIMIT_COUNT = 5

fun getFoodSpotsReportCountKey(userId: Long) = "$FOOD_SPOTS_REPORT_LIMIT_PREFIX$userId"

private const val REPORT_RANKING_KEY = "rofo:user-report-ranking"
private const val REPORT_RANKING_UPDATE_LOCK = "updateReportRankingLock"
}

@DistributedLock(lockName = USER_ENTITY_LOCK_KEY, identifier = "user")
Expand Down Expand Up @@ -437,4 +443,43 @@ class FoodSpotsCommandService(
}, executor)
}.forEach { it.join() }
}

fun getReportRanking(size: Long): List<UserReportCount> {
val typedTuple =
redisTemplate.opsForZSet().reverseRangeWithScores(
REPORT_RANKING_KEY,
0,
size - 1,
) ?: emptySet()

return typedTuple.map { tuple ->
val userNickname = tuple.value ?: ""
val score = tuple.score ?: 0.0

UserReportCount(
userNickname = userNickname,
reportCount = score.toLong(),
)
}
}

@Scheduled(cron = "0 0 5 * * *")
fun updateReportRanking() {
val lock: RLock = redissonClient.getLock(REPORT_RANKING_UPDATE_LOCK)
if (lock.tryLock(0, 10, TimeUnit.MINUTES)) {
redisTemplate.delete(REPORT_RANKING_KEY)

val userReports = foodSpotsHistoryRepository.findAllUserReportCount()

userReports.forEach {
redisTemplate
.opsForZSet()
.add(
REPORT_RANKING_KEY,
it.userNickname,
it.reportCount.toDouble(),
)
}
}
}
}
Original file line number Diff line number Diff line change
@@ -1,10 +1,13 @@
package kr.weit.roadyfoody.foodSpots.repository

import com.linecorp.kotlinjdsl.support.spring.data.jpa.repository.KotlinJdslJpqlExecutor
import kr.weit.roadyfoody.foodSpots.application.dto.UserReportCount
import kr.weit.roadyfoody.foodSpots.domain.FoodSpots
import kr.weit.roadyfoody.foodSpots.domain.FoodSpotsHistory
import kr.weit.roadyfoody.foodSpots.exception.FoodSpotsHistoryNotFoundException
import kr.weit.roadyfoody.global.utils.findList
import kr.weit.roadyfoody.global.utils.getSlice
import kr.weit.roadyfoody.user.domain.Profile
import kr.weit.roadyfoody.user.domain.User
import org.springframework.data.domain.Pageable
import org.springframework.data.domain.Slice
Expand Down Expand Up @@ -35,6 +38,8 @@ interface CustomFoodSpotsHistoryRepository {
size: Int,
lastId: Long?,
): Slice<FoodSpotsHistory>

fun findAllUserReportCount(): List<UserReportCount>
}

class CustomFoodSpotsHistoryRepositoryImpl(
Expand All @@ -59,4 +64,23 @@ class CustomFoodSpotsHistoryRepositoryImpl(
).orderBy(path(FoodSpotsHistory::id).desc())
}
}

override fun findAllUserReportCount(): List<UserReportCount> =
kotlinJdslJpqlExecutor
.findList {
val userIdPath = path(FoodSpotsHistory::user).path(User::id)
val userNicknamePath = path(FoodSpotsHistory::user).path(User::profile).path(Profile::nickname)
val historyIdPath = path(FoodSpotsHistory::id)
val createdAtPath = path(FoodSpotsHistory::createdDateTime)

selectNew<UserReportCount>(
userNicknamePath,
count(historyIdPath),
).from(entity(FoodSpotsHistory::class))
.groupBy(userIdPath, userNicknamePath)
.orderBy(
count(historyIdPath).desc(),
max(createdAtPath).asc(),
)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
package kr.weit.roadyfoody.global.config

import org.springframework.context.annotation.Configuration
import org.springframework.scheduling.annotation.EnableScheduling

@EnableScheduling
@Configuration
class SchedulingConfig
Original file line number Diff line number Diff line change
Expand Up @@ -10,5 +10,6 @@ object SwaggerTag {
const val FOOD_CATEGORIES: String = "F. 음식점 카테고리 API"
const val USER: String = "G. 유저 API"
const val REWARDS: String = "H. 리워드 API"
const val RANKING: String = "I. 랭킹 API"
const val ADMIN: String = "Z. 관리자 API"
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
package kr.weit.roadyfoody.ranking.presentation.api

import jakarta.validation.constraints.Positive
import kr.weit.roadyfoody.foodSpots.application.dto.UserReportCount
import kr.weit.roadyfoody.foodSpots.application.service.FoodSpotsCommandService
import kr.weit.roadyfoody.ranking.presentation.spec.RankingControllerSpec
import org.springframework.web.bind.annotation.GetMapping
import org.springframework.web.bind.annotation.RequestMapping
import org.springframework.web.bind.annotation.RequestParam
import org.springframework.web.bind.annotation.RestController

@RestController
@RequestMapping("/api/v1/ranking")
class RankingController(
private val foodSpotsCommandService: FoodSpotsCommandService,
) : RankingControllerSpec {
@GetMapping("/report")
override fun getReportRanking(
@Positive(message = "size는 양수여야 합니다.")
@RequestParam(defaultValue = "10")
size: Long,
): List<UserReportCount> = foodSpotsCommandService.getReportRanking(size)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
package kr.weit.roadyfoody.ranking.presentation.spec

import io.swagger.v3.oas.annotations.Operation
import io.swagger.v3.oas.annotations.media.Content
import io.swagger.v3.oas.annotations.media.Schema
import io.swagger.v3.oas.annotations.responses.ApiResponse
import io.swagger.v3.oas.annotations.tags.Tag
import jakarta.validation.constraints.Positive
import kr.weit.roadyfoody.foodSpots.application.dto.UserReportCount
import kr.weit.roadyfoody.global.swagger.v1.SwaggerTag
import org.springframework.http.MediaType
import org.springframework.web.bind.annotation.RequestParam

@Tag(name = SwaggerTag.RANKING)
interface RankingControllerSpec {
@Operation(
description = "리포트 랭킹 조회 API",
responses = [
ApiResponse(
responseCode = "200",
description = "리포트 랭킹 조회 성공",
content = [
Content(
mediaType = MediaType.APPLICATION_JSON_VALUE,
schema =
Schema(
implementation = UserReportCount::class,
),
),
],
),

],
)
fun getReportRanking(
@Positive(message = "size는 양수여야 합니다.")
@RequestParam(defaultValue = "10")
size: Long,
): List<UserReportCount>
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
DROP INDEX FOOD_SPOTS_HISTORIES_ID_USER_ID_INDEX;

CREATE INDEX FOOD_SPOTS_HISTORIES_USER_ID_CREATED_DATETIME_INDEX
ON FOOD_SPOTS_HISTORIES(USER_ID, CREATED_DATETIME)
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ import kr.weit.roadyfoody.foodSpots.fixture.createTestFoodSpotsUpdateRequestFrom
import kr.weit.roadyfoody.foodSpots.fixture.createTestReportFoodCategory
import kr.weit.roadyfoody.foodSpots.fixture.createTestReportOperationHours
import kr.weit.roadyfoody.foodSpots.fixture.createTestReportRequest
import kr.weit.roadyfoody.foodSpots.fixture.createUserRankingResponse
import kr.weit.roadyfoody.foodSpots.repository.FoodCategoryRepository
import kr.weit.roadyfoody.foodSpots.repository.FoodSpotsFoodCategoryRepository
import kr.weit.roadyfoody.foodSpots.repository.FoodSpotsHistoryRepository
Expand All @@ -61,11 +62,14 @@ import kr.weit.roadyfoody.user.fixture.TEST_OTHER_USER_ID
import kr.weit.roadyfoody.user.fixture.TEST_USER_ID
import kr.weit.roadyfoody.user.fixture.createTestUser
import kr.weit.roadyfoody.user.repository.UserRepository
import org.redisson.api.RLock
import org.redisson.api.RedissonClient
import org.springframework.data.redis.core.RedisTemplate
import org.springframework.data.redis.core.ZSetOperations
import org.springframework.transaction.support.TransactionSynchronizationManager
import java.util.Optional
import java.util.concurrent.ExecutorService
import java.util.concurrent.TimeUnit

class FoodSpotsCommandServiceTest :
BehaviorSpec(
Expand Down Expand Up @@ -682,5 +686,72 @@ class FoodSpotsCommandServiceTest :
}
}
}

given("updateReportRanking 테스트") {
val mockLock = mockk<RLock>()
val zSetOperations = mockk<ZSetOperations<String, String>>()
val mockTypedTuple: Set<ZSetOperations.TypedTuple<String>> =
setOf(
mockk<ZSetOperations.TypedTuple<String>>().apply {
every { value } returns "user1"
every { score } returns 10.0
},
mockk<ZSetOperations.TypedTuple<String>>().apply {
every { value } returns "user2"
every { score } returns 5.0
},
)

every { redissonClient.getLock(any<String>()) } returns mockLock

`when`("Lock을 획득한 경우") {
every { mockLock.tryLock(0, 10, TimeUnit.MINUTES) } returns true

every { redisTemplate.delete("rofo:user-report-ranking") } returns true
every { foodSpotsHistoryRepository.findAllUserReportCount() } returns createUserRankingResponse()
every { redisTemplate.opsForZSet() } returns zSetOperations
every { zSetOperations.reverseRangeWithScores(any(), any(), any()) } returns mockTypedTuple
every { zSetOperations.add("rofo:user-report-ranking", "existentNick", 10.0) } returns true

then("레디스의 데이터가 정상적으로 업데이트된다.") {
foodSpotsCommandService.updateReportRanking()
verify(exactly = 1) { foodSpotsHistoryRepository.findAllUserReportCount() }
}
}

`when`("Lock을 획득하지 못한 경우") {
every { mockLock.tryLock(0, 10, TimeUnit.MINUTES) } returns false

then("레디스의 데이터가 업데이트되지 않는다.") {
foodSpotsCommandService.updateReportRanking()
verify(exactly = 1) { foodSpotsHistoryRepository.findAllUserReportCount() }
}
}
}

given("getReportRanking 테스트") {
val zSetOperations = mockk<ZSetOperations<String, String>>()
val typedTupleSet =
setOf(
mockk<ZSetOperations.TypedTuple<String>> {
every { value } returns "user1"
every { score } returns 10.0
},
mockk<ZSetOperations.TypedTuple<String>> {
every { value } returns "user2"
every { score } returns 20.0
},
)

`when`("레디스의 데이터를 조회한 경우") {
every { redisTemplate.opsForZSet() } returns zSetOperations
every { zSetOperations.reverseRangeWithScores(any(), any(), any()) } returns typedTupleSet

then("리포트 랭킹이 조회된다.") {
foodSpotsCommandService.getReportRanking(10)
verify(exactly = 1) { zSetOperations.reverseRangeWithScores(any(), any(), any()) }
}
}
}
},
)
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import kr.weit.roadyfoody.foodSpots.application.dto.ReportOperationHoursResponse
import kr.weit.roadyfoody.foodSpots.application.dto.ReportPhotoResponse
import kr.weit.roadyfoody.foodSpots.application.dto.ReportRequest
import kr.weit.roadyfoody.foodSpots.application.dto.ReviewAggregatedInfoResponse
import kr.weit.roadyfoody.foodSpots.application.dto.UserReportCount
import kr.weit.roadyfoody.foodSpots.domain.DayOfWeek
import kr.weit.roadyfoody.foodSpots.domain.FoodCategory
import kr.weit.roadyfoody.foodSpots.domain.FoodSpots
Expand Down Expand Up @@ -458,3 +459,14 @@ fun createTestAggregatedInfoResponse(): ReviewAggregatedInfoResponse =
TEST_AVERAGE_RATE,
TEST_REVIEW_COUNT,
)

fun createUserReportCountResponse(
user: User = createTestUser(),
reportCount: Long = 10,
): UserReportCount =
UserReportCount(
userNickname = user.profile.nickname,
reportCount = reportCount,
)

fun createUserRankingResponse(): List<UserReportCount> = listOf(createUserReportCountResponse())
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,6 @@ import kr.weit.roadyfoody.foodSpots.fixture.TEST_INVALID_TIME_FORMAT
import kr.weit.roadyfoody.foodSpots.fixture.createMockPhotoList
import kr.weit.roadyfoody.foodSpots.fixture.createOperationHoursRequest
import kr.weit.roadyfoody.foodSpots.fixture.createReportHistoryDetailResponse
import kr.weit.roadyfoody.foodSpots.fixture.createTestFoodSpotsDetailResponse
import kr.weit.roadyfoody.foodSpots.fixture.createTestFoodSpotsUpdateRequest
import kr.weit.roadyfoody.foodSpots.fixture.createTestReportRequest
import kr.weit.roadyfoody.foodSpots.fixture.createTestSliceFoodSpotsReviewResponse
Expand Down Expand Up @@ -642,29 +641,5 @@ class FoodSpotsControllerTest(
}
}
}

given("GET $requestPath/{foodSpotsId} Test") {
val response = createTestFoodSpotsDetailResponse()
every {
foodSpotsQueryService.getFoodSpotsDetail(any())
} returns response
`when`("정상적인 데이터가 들어올 경우") {
then("음식점의 리뷰 리스트가 조회된다.") {
mockMvc
.perform(
getWithAuth("$requestPath/$TEST_FOOD_SPOT_ID"),
).andExpect(status().isOk)
}
}

`when`("음식점 ID가 양수가 아닌 경우") {
then("400 반환") {
mockMvc
.perform(
getWithAuth("$requestPath/$TEST_INVALID_FOOD_SPOT_ID"),
).andExpect(status().isBadRequest)
}
}
}
},
)
Loading

0 comments on commit d4fac45

Please sign in to comment.