Skip to content

Commit c6b0f56

Browse files
committed
✨ feat(rank): rank 기능 구현 완료 및 테스트 완료
1 parent 49fb73e commit c6b0f56

36 files changed

+1806
-91
lines changed

server/src/main/kotlin/com/app/server/challenge/application/service/ChallengeService.kt

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ package com.app.server.challenge.application.service
22

33
import com.app.server.challenge.application.repository.ChallengeRepository
44
import com.app.server.challenge.domain.model.Challenge
5+
import com.app.server.challenge.exception.ChallengeException
56
import com.app.server.common.enums.CommonResultCode
67
import com.app.server.common.exception.NotFoundException
78
import org.springframework.stereotype.Service
@@ -13,7 +14,7 @@ class ChallengeService(
1314

1415
fun findById(challengeId: Long): Challenge {
1516
return challengeRepository.findById(challengeId).orElseThrow {
16-
throw NotFoundException(CommonResultCode.NOT_FOUND)
17+
throw NotFoundException(ChallengeException.NOT_FOUND)
1718
}
1819
}
1920

server/src/main/kotlin/com/app/server/challenge/exception/ChallengeException.kt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,5 +7,6 @@ enum class ChallengeException(
77
override val message: String
88
) : ResultCode {
99

10+
NOT_FOUND("CHA000", "챌린지를 찾을 수 없습니다."),
1011
NOT_FOUND_CHALLENGE_CATEGORY("CHA001", "해당하는 챌린지 카테고리를 찾을 수 없습니다."),
1112
}
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
package com.app.server.common.config
2+
3+
import org.springframework.beans.factory.annotation.Value
4+
import org.springframework.context.annotation.Bean
5+
import org.springframework.context.annotation.Configuration
6+
import org.springframework.data.redis.connection.RedisConnectionFactory
7+
import org.springframework.data.redis.connection.lettuce.LettuceConnectionFactory
8+
import org.springframework.data.redis.core.RedisTemplate
9+
import org.springframework.data.redis.repository.configuration.EnableRedisRepositories
10+
import org.springframework.data.redis.serializer.GenericJackson2JsonRedisSerializer
11+
import org.springframework.data.redis.serializer.StringRedisSerializer
12+
13+
@Configuration
14+
@EnableRedisRepositories
15+
class RedisConfig(
16+
@Value("\${spring.data.redis.host}")
17+
private val host: String,
18+
@Value("\${spring.data.redis.port}")
19+
private val port: Int
20+
) {
21+
22+
@Bean
23+
fun redisConnectionFactory(): RedisConnectionFactory {
24+
return LettuceConnectionFactory(this.host, port)
25+
}
26+
27+
@Bean
28+
fun redisTemplate(): RedisTemplate<String, Any> {
29+
val redisTemplate = RedisTemplate<String, Any>()
30+
redisTemplate.connectionFactory = redisConnectionFactory()
31+
redisTemplate.keySerializer = StringRedisSerializer()
32+
redisTemplate.valueSerializer = GenericJackson2JsonRedisSerializer()
33+
redisTemplate.hashKeySerializer = StringRedisSerializer()
34+
redisTemplate.hashValueSerializer = GenericJackson2JsonRedisSerializer()
35+
return redisTemplate
36+
}
37+
}
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
package com.app.server.core.scheduler
2+
3+
import org.springframework.context.annotation.Configuration
4+
5+
// TODO : MongoDB로 랭킹 주기적 마이그레이션 필요
6+
@Configuration
7+
class RankScheduler {
8+
}

server/src/main/kotlin/com/app/server/feed/ui/dto/FeedListResponseDto.kt

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,9 @@ data class FeedListResponseDto(
88
val feedList: List<FeedDto>,
99
val page: Int,
1010
val size: Int,
11-
val hasNext: Boolean
11+
val hasNext: Boolean,
12+
val totalElements: Long,
13+
val totalPages: Int
1214
){
1315
companion object {
1416
fun fromPage(feedProjectionPage: Page<FeedProjection>): FeedListResponseDto {
@@ -31,7 +33,9 @@ data class FeedListResponseDto(
3133
feedList = feedList,
3234
page = feedProjectionPage.number + 1,
3335
size = feedProjectionPage.size,
34-
hasNext = feedProjectionPage.hasNext()
36+
hasNext = feedProjectionPage.hasNext(),
37+
totalElements = feedProjectionPage.totalElements,
38+
totalPages = feedProjectionPage.totalPages
3539
)
3640
}
3741
}
Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
package com.app.server.rank.application.service
2+
3+
import com.app.server.common.exception.InternalServerErrorException
4+
import com.app.server.rank.enums.RankState
5+
import com.app.server.rank.exception.RankException
6+
import com.app.server.user_challenge.application.service.UserChallengeService
7+
import com.app.server.user_challenge.domain.event.SavedTodayUserChallengeCertificationEvent
8+
import kotlinx.coroutines.CoroutineScope
9+
import kotlinx.coroutines.Dispatchers
10+
import kotlinx.coroutines.SupervisorJob
11+
import kotlinx.coroutines.launch
12+
import org.springframework.context.event.EventListener
13+
import org.springframework.stereotype.Component
14+
15+
@Component
16+
class RankEventListener(
17+
private val rankService: RankService,
18+
private val userChallengeService: UserChallengeService
19+
) {
20+
21+
val scope = CoroutineScope(Dispatchers.Default + SupervisorJob())
22+
23+
@EventListener
24+
fun handleSavedTodayUserChallengeCertificationEventForUpdateSpecificChallengeRank(
25+
event: SavedTodayUserChallengeCertificationEvent
26+
) {
27+
try {
28+
scope.launch {
29+
updateSpecificChallengeRank(
30+
event.userChallengeId,
31+
event.totalParticipationDayCount
32+
)
33+
}
34+
} catch (e: Exception) {
35+
// TODO : 실패 시 보상 트랜잭션 이벤트 제공 필요
36+
throw e
37+
}
38+
}
39+
40+
@EventListener
41+
fun handleSavedTodayUserChallengeCertificationEventForUpdateTotalUserRank(
42+
event: SavedTodayUserChallengeCertificationEvent
43+
) {
44+
try {
45+
scope.launch {
46+
updateTotalRank(
47+
event.userChallengeId,
48+
event.maxConsecutiveParticipationDayCount
49+
)
50+
}
51+
} catch (e: Exception) {
52+
// TODO : 실패 시 보상 트랜잭션 이벤트 제공 필요
53+
throw e
54+
}
55+
}
56+
57+
suspend fun updateSpecificChallengeRank(userChallengeId: Long, totalParticipationDayCount: Long): RankState {
58+
59+
val userChallenge = userChallengeService.findById(userChallengeId)
60+
val key = userChallenge.userId
61+
val challenge = userChallenge.challenge
62+
63+
val slot = "rank:challenge:${challenge.id}"
64+
65+
val existScore = rankService.getScore(slot, key)
66+
67+
return updateRankScoreIfNeeded(existScore, totalParticipationDayCount, slot, key)
68+
}
69+
70+
suspend fun updateTotalRank(userChallengeId: Long, maxConsecutiveParticipationDayCount: Long): RankState {
71+
val userChallenge = userChallengeService.findById(userChallengeId)
72+
val key = userChallenge.userId
73+
74+
val slot = "rank:total"
75+
76+
val existScore = rankService.getScore(slot, key)
77+
78+
return updateRankScoreIfNeeded(existScore, maxConsecutiveParticipationDayCount, slot, key)
79+
}
80+
81+
private fun updateRankScoreIfNeeded(
82+
existScore: Double,
83+
score: Long,
84+
slot: String,
85+
key: Long
86+
): RankState {
87+
if (existScore.toLong() < score) {
88+
val isProcess: Boolean? = rankService.updateValue(slot, key, score)
89+
90+
return isUpdatedRankScore(isProcess)
91+
}
92+
93+
return RankState.RANK_NOT_UPDATE
94+
}
95+
96+
private fun isUpdatedRankScore(isProcess: Boolean?): RankState {
97+
if (isProcess != null && isProcess)
98+
return RankState.RANK_UPDATE_SUCCESS
99+
else if (isProcess != null)
100+
return RankState.RANK_UPDATE_FAIL
101+
throw InternalServerErrorException(RankException.CANNOT_UPDATE_RANK)
102+
}
103+
}
Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,120 @@
1+
package com.app.server.rank.application.service
2+
3+
import com.app.server.challenge.application.service.ChallengeService
4+
import com.app.server.rank.ui.dto.SpecificChallengeTotalRankResponseDto
5+
import com.app.server.rank.ui.dto.TotalRankResponseDto
6+
import com.app.server.rank.ui.dto.UserRankForSpecificChallengeDto
7+
import com.app.server.rank.ui.dto.UserRankForTotalDto
8+
import com.app.server.rank.ui.dto.UserRankInSpecificChallengeTotalRankResponseDto
9+
import com.app.server.rank.ui.dto.UserRankInTotalRankResponseDto
10+
import com.app.server.rank.ui.usecase.GetSpecificChallengeTotalRankUseCase
11+
import com.app.server.rank.ui.usecase.GetTotalRankUseCase
12+
import com.app.server.rank.ui.usecase.GetUserSpecificChallengeTotalRankUseCase
13+
import com.app.server.rank.ui.usecase.GetUserTotalRankUseCase
14+
import com.app.server.user.application.service.UserService
15+
import org.springframework.stereotype.Service
16+
import org.springframework.transaction.annotation.Transactional
17+
18+
@Service
19+
@Transactional(readOnly = true)
20+
class RankQueryService(
21+
private val rankService: RankService,
22+
private val userService: UserService,
23+
private val challengeService: ChallengeService,
24+
) : GetTotalRankUseCase,
25+
GetSpecificChallengeTotalRankUseCase,
26+
GetUserTotalRankUseCase,
27+
GetUserSpecificChallengeTotalRankUseCase {
28+
29+
override fun execute(): TotalRankResponseDto {
30+
31+
val key = "rank:total"
32+
33+
val totalParticipants = rankService.count(key) ?: 0
34+
35+
val pairListOfUserIdAndLongConsecutiveDayCount = rankService.getTop100UserInfo(key)
36+
37+
val topParticipantsRankInfoList = pairListOfUserIdAndLongConsecutiveDayCount.mapIndexed { index, pair ->
38+
val userId : Long = pair.first.toString().toLong()
39+
val consecutiveDayCount = pair.second
40+
41+
val user = userService.findById(userId)
42+
43+
UserRankForTotalDto.from(
44+
rank = index + 1,
45+
nickname = user.nickname,
46+
profileImageUrl = user.profileImageUrl,
47+
longestConsecutiveParticipationCount = consecutiveDayCount.toLong()
48+
)
49+
}
50+
51+
return TotalRankResponseDto.from(
52+
totalParticipants,
53+
topParticipantsRankInfoList
54+
)
55+
}
56+
57+
override fun execute(challengeId: Long): SpecificChallengeTotalRankResponseDto {
58+
val key = "rank:challenge:$challengeId"
59+
val challengeTitle = challengeService.findById(challengeId).title
60+
val totalParticipants = rankService.count(key) ?: 0
61+
62+
val pairListOfUserIdAndLongConsecutiveDayCount = rankService.getTop100UserInfo(key)
63+
64+
val topParticipantsRankInfoList = pairListOfUserIdAndLongConsecutiveDayCount.mapIndexed { index, pair ->
65+
val userId = pair.first.toString().toLong()
66+
val totalParticipantDayCount = pair.second
67+
68+
val user = userService.findById(userId)
69+
70+
UserRankForSpecificChallengeDto.from(
71+
rank = index + 1,
72+
nickname = user.nickname,
73+
profileImageUrl = user.profileImageUrl,
74+
totalParticipationCount = totalParticipantDayCount.toLong()
75+
)
76+
}
77+
78+
return SpecificChallengeTotalRankResponseDto.from(
79+
challengeTitle,
80+
totalParticipants,
81+
topParticipantsRankInfoList
82+
)
83+
}
84+
85+
override fun executeOfUserIs(userId: Long): UserRankInTotalRankResponseDto {
86+
val key = "rank:total"
87+
val value = userId
88+
89+
val rank = rankService.getRank(key, value)
90+
val score = rankService.getScore(key, value)
91+
92+
val user = userService.findById(userId)
93+
94+
return UserRankInTotalRankResponseDto.from(
95+
rank = rank + 1,
96+
score = score,
97+
userNickname = user.nickname,
98+
userProfileImageUrl = user.profileImageUrl
99+
)
100+
}
101+
102+
override fun execute(challengeId: Long, userId: Long): UserRankInSpecificChallengeTotalRankResponseDto {
103+
val key = "rank:challenge:$challengeId"
104+
val value = userId
105+
106+
val rank = rankService.getRank(key, value)
107+
val score = rankService.getScore(key, value)
108+
109+
val user = userService.findById(userId)
110+
val challengeTitle = challengeService.findById(challengeId).title
111+
112+
return UserRankInSpecificChallengeTotalRankResponseDto.from(
113+
challengeTitle,
114+
rank + 1,
115+
score,
116+
user.nickname,
117+
user.profileImageUrl
118+
)
119+
}
120+
}
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
package com.app.server.rank.application.service
2+
3+
import com.app.server.common.exception.BadRequestException
4+
import com.app.server.rank.exception.RankException
5+
import org.springframework.data.redis.core.RedisTemplate
6+
import org.springframework.stereotype.Service
7+
8+
@Service
9+
class RankService (
10+
private val redisTemplate: RedisTemplate<String, Any>
11+
){
12+
13+
fun updateValue(key: String, value: Long, score: Long): Boolean? {
14+
redisTemplate.opsForZSet().remove(key, value)
15+
return redisTemplate.opsForZSet().add(key, value, score.toDouble())
16+
}
17+
18+
fun getScore(key: String, value: Long): Double {
19+
return redisTemplate.opsForZSet().score(key, value)
20+
?: 0.0
21+
}
22+
23+
fun getRank(key: String, value: Long): Long {
24+
return redisTemplate.opsForZSet().reverseRank(key, value)
25+
?: throw BadRequestException(RankException.NOT_FOUND_RANK)
26+
}
27+
28+
fun getTop100UserInfo(key: String): List<Pair<Any, Double>> {
29+
val userIdAndScores = redisTemplate.opsForZSet().reverseRangeWithScores(key, 0, 99)
30+
?: return emptyList()
31+
32+
return userIdAndScores.map { userIdAndScore ->
33+
val userId = userIdAndScore.value
34+
?: throw BadRequestException(RankException.NOT_FOUND_USER_RANK)
35+
val score = userIdAndScore.score ?: 0.0
36+
userId to score
37+
}
38+
}
39+
40+
fun count(key: String): Long? {
41+
return redisTemplate.opsForZSet().size(key)
42+
?:0
43+
}
44+
45+
}
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
package com.app.server.rank.enums
2+
3+
enum class RankState {
4+
5+
RANK_UPDATE_SUCCESS,
6+
RANK_NOT_UPDATE,
7+
RANK_UPDATE_FAIL,
8+
}
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
package com.app.server.rank.exception
2+
3+
import com.app.server.common.enums.ResultCode
4+
5+
enum class RankException (
6+
override val code: String,
7+
override val message: String,
8+
) : ResultCode {
9+
CANNOT_UPDATE_RANK("RANK_001", "랭크 업데이트에 실패했습니다."),
10+
NOT_FOUND_RANK("RANK_002", "랭크를 찾을 수 없습니다."),
11+
NOT_FOUND_USER_RANK("RANK_003", "유저 랭크를 찾을 수 없습니다."),
12+
NOT_FOUND_SCORE("RANK_004", "점수를 찾을 수 없습니다."),
13+
}

0 commit comments

Comments
 (0)