Skip to content

Commit

Permalink
challenge rank application layer 구현 (#216)
Browse files Browse the repository at this point in the history
## Checklist
- [ ] 충분한 양의 자동화 테스트를 작성했는가?
  - 계단정복지도 서비스는 사이드 프로젝트로 진행되는 만큼 충분한 QA 없이 배포되는 경우가 많습니다. 따라서 자동화 테스트를 꼼꼼하게 작성하는 것이 서비스 품질을 유지하는 데 매우 중요합니다.
  • Loading branch information
bellatoris authored Oct 29, 2023
1 parent d7cf704 commit 1a13f2f
Show file tree
Hide file tree
Showing 6 changed files with 207 additions and 0 deletions.
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
package club.staircrusher.challenge.application.port.`in`.use_case

import club.staircrusher.challenge.application.port.out.persistence.ChallengeRankRepository
import club.staircrusher.challenge.application.port.out.persistence.ChallengeRepository
import club.staircrusher.challenge.domain.model.ChallengeRank
import club.staircrusher.stdlib.di.annotation.Component
import club.staircrusher.stdlib.domain.SccDomainException
import club.staircrusher.stdlib.persistence.TransactionManager

/**
* Get the leaderboard of a challenge which shows only the top 10 users.
* If there are more than 2 users with the same score, those users' ranks
* are the same and the next rank is skipped. For example, if there are
* 3 users with the same score, the ranks are 1, 1, 1, 4, 5, 6, 7, 8, 9, 10.
*/
@Component
class GetChallengeLeaderboardUseCase(
private val transactionManager: TransactionManager,
private val challengeRepository: ChallengeRepository,
private val challengeRankRepository: ChallengeRankRepository,
) {
companion object {
const val NUMBER_OF_TOP_RANKER = 10
}

fun handle(challengeId: String): List<ChallengeRank> = transactionManager.doInTransaction {
val challenge = challengeRepository.findByIdOrNull(challengeId) ?: throw SccDomainException("잘못된 챌린지입니다.")
val leaderboards = challengeRankRepository.findTopNUsers(challenge.id, NUMBER_OF_TOP_RANKER)

leaderboards
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
package club.staircrusher.challenge.application.port.`in`.use_case

import club.staircrusher.challenge.application.port.`in`.ChallengeService
import club.staircrusher.challenge.application.port.out.persistence.ChallengeRankRepository
import club.staircrusher.challenge.application.port.out.persistence.ChallengeRepository
import club.staircrusher.challenge.domain.model.ChallengeRank
import club.staircrusher.stdlib.di.annotation.Component
import club.staircrusher.stdlib.domain.SccDomainException
import club.staircrusher.stdlib.persistence.TransactionManager
import club.staircrusher.user.application.port.`in`.UserApplicationService

@Component
class GetChallengeRankUseCase(
private val transactionManager: TransactionManager,
private val userApplicationService: UserApplicationService,
private val challengeRepository: ChallengeRepository,
private val challengeRankRepository: ChallengeRankRepository,
private val challengeService: ChallengeService,
) {
fun handle(challengeId: String, userId: String): ChallengeRank? = transactionManager.doInTransaction {
userApplicationService.getUser(userId) ?: throw SccDomainException("잘못된 계정입니다.")

val challenge = challengeRepository.findByIdOrNull(challengeId) ?: throw SccDomainException("잘못된 챌린지입니다.")
if (!challengeService.hasJoined(userId, challengeId)) {
throw SccDomainException("참여하지 않은 챌린지 입니다.")
}

// if the user does not have rank yet, return null and let the user know that the rank will be updated soon
challengeRankRepository.findByUserId(challenge.id, userId)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
package club.staircrusher.challenge.application.port.`in`.use_case

import club.staircrusher.challenge.application.port.`in`.ChallengeService
import club.staircrusher.challenge.application.port.out.persistence.ChallengeRankRepository
import club.staircrusher.challenge.application.port.out.persistence.ChallengeRepository
import club.staircrusher.stdlib.di.annotation.Component
import club.staircrusher.stdlib.domain.SccDomainException
import club.staircrusher.stdlib.persistence.TransactionManager
import club.staircrusher.user.application.port.`in`.UserApplicationService

/**
* Get the number of contributions needed to reach the next rank.
* If the user is already at the highest rank, return 0.
*/
@Component
class GetCountForNextChallengeRankUseCase(
private val transactionManager: TransactionManager,
private val userApplicationService: UserApplicationService,
private val challengeRepository: ChallengeRepository,
private val challengeRankRepository: ChallengeRankRepository,
private val challengeService: ChallengeService,
) {
fun handle(challengeId: String, userId: String): Int? = transactionManager.doInTransaction {
userApplicationService.getUser(userId) ?: throw SccDomainException("잘못된 계정입니다.")
val challenge = challengeRepository.findByIdOrNull(challengeId) ?: throw SccDomainException("잘못된 챌린지입니다.")
if (!challengeService.hasJoined(userId, challengeId)) {
throw SccDomainException("참여하지 않은 챌린지 입니다.")
}

// if the user does not have rank yet, return null and let the user know that the rank will be updated soon
val challengeRank = challengeRankRepository.findByUserId(challenge.id, userId)
val rank = challengeRank?.rank ?: return@doInTransaction null

if (rank == 1L) {
0
} else {
val nextRank = challengeRankRepository.findNextRank(challenge.id, rank)!!
nextRank.contributionCount - challengeRank.contributionCount
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
package club.staircrusher.challenge.application.port.`in`.use_case

import club.staircrusher.challenge.application.port.out.persistence.ChallengeContributionRepository
import club.staircrusher.challenge.application.port.out.persistence.ChallengeParticipationRepository
import club.staircrusher.challenge.application.port.out.persistence.ChallengeRankRepository
import club.staircrusher.challenge.application.port.out.persistence.ChallengeRepository
import club.staircrusher.challenge.domain.model.ChallengeContribution
import club.staircrusher.challenge.domain.model.ChallengeRank
import club.staircrusher.stdlib.clock.SccClock
import club.staircrusher.stdlib.di.annotation.Component
import club.staircrusher.stdlib.domain.entity.EntityIdGenerator
import club.staircrusher.stdlib.persistence.TransactionIsolationLevel
import club.staircrusher.stdlib.persistence.TransactionManager
import java.time.Duration
import java.time.Instant

/**
* Update the users' ranks of all challenges.
*/
@Component
class UpdateChallengeRanksUseCase(
private val transactionManager: TransactionManager,
private val challengeRepository: ChallengeRepository,
private val challengeRankRepository: ChallengeRankRepository,
private val challengeContributionRepository: ChallengeContributionRepository,
private val challengeParticipationRepository: ChallengeParticipationRepository,
) {
fun handle() {
val challenges = challengeRepository.findAllOrderByCreatedAtDesc()
.filter { (it.endsAt ?: Instant.MAX) > SccClock.instant() - Duration.ofDays(1) }
challenges.forEach { challenge ->
val now = SccClock.instant()
transactionManager.doInTransaction(TransactionIsolationLevel.SERIALIZABLE) {
val contributions = challengeContributionRepository.findByChallengeId(challenge.id)
val participants = challengeParticipationRepository.findByChallengeId(challenge.id)

val userIds = participants.map { it.userId }.toSet()
val contributionUserIds = contributions.map { it.userId }.toSet()
val missingUserIds = userIds - contributionUserIds
val userWithNoContribution = missingUserIds.map { userId -> userId to emptyList<ChallengeContribution>() }

val ranks = (contributions.groupBy { it.userId } + userWithNoContribution)
.map { (userId, contributions) ->
val challengeRank = challengeRankRepository.findByUserId(challenge.id, userId) ?: ChallengeRank(
id = EntityIdGenerator.generateRandom(),
challengeId = challenge.id,
userId = userId,
contributionCount = contributions.size,
rank = -1,
createdAt = now,
updatedAt = now,
)

challengeRank.apply {
contributionCount = contributions.size
updatedAt = now
}
}

var nextRank = 1L
val updatedRanks = ranks.groupBy { it.contributionCount }
.toSortedMap(compareByDescending { it })
.flatMap { (_, ranks) ->
val currentRank = nextRank
nextRank += ranks.size
ranks.map {
it.apply { rank = currentRank; updatedAt = now }
}
}
challengeRankRepository.removeAll(challenge.id)
challengeRankRepository.saveAll(updatedRanks)
}
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
package club.staircrusher.challenge.application.port.out.persistence

import club.staircrusher.challenge.domain.model.ChallengeRank
import club.staircrusher.stdlib.domain.repository.EntityRepository

interface ChallengeRankRepository : EntityRepository<ChallengeRank, String> {
fun findTopNUsers(challengeId: String, n: Int): List<ChallengeRank>
fun findByUserId(challengeId: String, userId: String): ChallengeRank?
fun findByRank(challengeId: String, rank: Long): ChallengeRank?
fun findNextRank(challengeId: String, rank: Long): ChallengeRank?
fun findByContributionCount(challengeId: String, contributionCount: Int): ChallengeRank?
fun findLastRank(challengeId: String): Long?
fun findAll(challengeId: String): List<ChallengeRank>
fun removeAll(challengeId: String): Unit
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
package club.staircrusher.challenge.domain.model

import java.time.Instant

class ChallengeRank(
val id: String,
val challengeId: String,
val userId: String,
var contributionCount: Int,
var rank: Long,
var createdAt: Instant,
var updatedAt: Instant,
)

0 comments on commit 1a13f2f

Please sign in to comment.