Skip to content

Commit f6daed5

Browse files
authored
상위 랭크에 대한 API를 구현한다. (#105)
* feat: user ranking * refactor: getUserRanking result * test: UserControllerTest getUserRanking * fix: naming
1 parent 630c7b7 commit f6daed5

File tree

11 files changed

+183
-4
lines changed

11 files changed

+183
-4
lines changed
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
package com.gotchai.api.presentation.v1.user
2+
3+
import com.gotchai.api.global.annotation.ApiV1Controller
4+
import com.gotchai.api.presentation.v1.user.response.UserRankingResponse
5+
import com.gotchai.domain.user.port.`in`.UserQueryUseCase
6+
import org.springframework.security.core.annotation.AuthenticationPrincipal
7+
import org.springframework.web.bind.annotation.GetMapping
8+
9+
@ApiV1Controller
10+
class UserController(
11+
private val userQueryUseCase: UserQueryUseCase
12+
) {
13+
@GetMapping("/users/ranking")
14+
fun getUserRanking(
15+
@AuthenticationPrincipal
16+
userId: Long
17+
): UserRankingResponse = UserRankingResponse.from(userQueryUseCase.getUserRanking(userId))
18+
}
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
package com.gotchai.api.presentation.v1.user.response
2+
3+
import com.gotchai.domain.user.dto.result.GetUserRankingResult
4+
5+
data class UserRankingResponse(
6+
val name: String,
7+
val rating: Double
8+
) {
9+
companion object {
10+
fun from(result: GetUserRankingResult) =
11+
with(result) {
12+
UserRankingResponse(
13+
name = name,
14+
rating = rating
15+
)
16+
}
17+
}
18+
}
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
package com.gotchai.api.presentation.v1.user
2+
3+
import com.gotchai.api.common.ControllerTest
4+
import com.gotchai.api.docs.userRankingResponseFields
5+
import com.gotchai.api.global.dto.ApiResponse
6+
import com.gotchai.api.presentation.v1.user.response.UserRankingResponse
7+
import com.gotchai.api.util.document
8+
import com.gotchai.domain.fixture.ID
9+
import com.gotchai.domain.fixture.createGetUserRankingResult
10+
import com.gotchai.domain.user.port.`in`.UserQueryUseCase
11+
import com.ninjasquad.springmockk.MockkBean
12+
import io.mockk.every
13+
import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest
14+
import org.springframework.test.web.reactive.server.expectBody
15+
16+
@WebMvcTest(UserController::class)
17+
class UserControllerTest : ControllerTest() {
18+
@MockkBean
19+
private lateinit var userQueryUseCase: UserQueryUseCase
20+
21+
init {
22+
describe("getUserRanking()는") {
23+
every { userQueryUseCase.getUserRanking(ID) } returns createGetUserRankingResult()
24+
25+
it("상태 코드 200과 UserRankingResponse를 반환한다.") {
26+
webClient
27+
.get()
28+
.uri("/api/v1/users/ranking")
29+
.exchange()
30+
.expectStatus()
31+
.isOk
32+
.expectBody<ApiResponse<UserRankingResponse>>()
33+
.document("사용자 랭킹 조회 성공(200)") {
34+
responseBody(userRankingResponseFields)
35+
}
36+
}
37+
}
38+
}
39+
}
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
package com.gotchai.api.docs
2+
3+
import com.gotchai.api.presentation.v1.user.response.UserRankingResponse
4+
import com.gotchai.api.util.desc
5+
import com.gotchai.api.util.fieldsOf
6+
7+
val userRankingResponseFields =
8+
fieldsOf(
9+
UserRankingResponse::name desc "사용자 이름",
10+
UserRankingResponse::rating desc "상위 퍼센트 (0-100)"
11+
)

domain/src/main/kotlin/com/gotchai/domain/exam/port/out/ExamHistoryQueryPort.kt

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@ package com.gotchai.domain.exam.port.out
33
import com.gotchai.domain.exam.entity.ExamHistory
44

55
interface ExamHistoryQueryPort {
6+
fun getAllExamHistoriesWithQuizIds(): List<ExamHistory>
7+
68
fun getExamHistoryByExamIdAndUserId(
79
examId: Long,
810
userId: Long
@@ -12,4 +14,6 @@ interface ExamHistoryQueryPort {
1214
examId: Long,
1315
isSolved: Boolean
1416
): List<ExamHistory>
17+
18+
fun getExamHistories(): List<ExamHistory>
1519
}
Lines changed: 39 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,45 @@
11
package com.gotchai.domain.user.adapter.`in`
22

3+
import com.gotchai.domain.exam.entity.ExamHistory
4+
import com.gotchai.domain.exam.port.out.ExamHistoryQueryPort
5+
import com.gotchai.domain.user.dto.result.GetUserRankingResult
6+
import com.gotchai.domain.user.exception.ProfileNotFoundException
37
import com.gotchai.domain.user.port.`in`.UserQueryUseCase
8+
import com.gotchai.domain.user.port.out.ProfileQueryPort
49
import org.springframework.stereotype.Service
510

611
@Service
7-
class UserQueryService : UserQueryUseCase
12+
class UserQueryService(
13+
private val profileQueryPort: ProfileQueryPort,
14+
private val examHistoryQueryPort: ExamHistoryQueryPort
15+
) : UserQueryUseCase {
16+
override fun getUserRanking(userId: Long): GetUserRankingResult {
17+
val profile = profileQueryPort.getProfileByUserId(userId) ?: throw ProfileNotFoundException()
18+
val allHistories = examHistoryQueryPort.getAllExamHistoriesWithQuizIds()
19+
20+
val rating = calculateUserRating(userId, allHistories)
21+
return GetUserRankingResult.of(profile, rating)
22+
}
23+
24+
private fun calculateUserRating(
25+
userId: Long,
26+
allHistories: List<ExamHistory>
27+
): Double {
28+
if (allHistories.isEmpty() || !allHistories.any { it.userId == userId }) return 100.0
29+
30+
val userScores =
31+
allHistories
32+
.groupBy { it.userId }
33+
.mapValues { (_, histories) ->
34+
val totalCorrect = histories.sumOf { it.correctAnswerCount }
35+
val totalQuizzes = histories.sumOf { it.quizIds.size }
36+
if (totalQuizzes == 0) 0.0 else totalCorrect.toDouble() / totalQuizzes.toDouble()
37+
}
38+
39+
val userScore = userScores[userId] ?: return 100.0
40+
if (userScores.size == 1) return 0.0
41+
42+
val usersWithHigherScore = userScores.values.count { it > userScore }
43+
return (usersWithHigherScore.toDouble() / userScores.size.toDouble()) * 100
44+
}
45+
}
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
package com.gotchai.domain.user.dto.result
2+
3+
import com.gotchai.domain.user.entity.Profile
4+
5+
data class GetUserRankingResult(
6+
val name: String,
7+
val rating: Double
8+
) {
9+
companion object {
10+
fun of(
11+
profile: Profile,
12+
rating: Double
13+
) = GetUserRankingResult(
14+
name = profile.nickname,
15+
rating = rating
16+
)
17+
}
18+
}
Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,7 @@
11
package com.gotchai.domain.user.port.`in`
22

3-
interface UserQueryUseCase
3+
import com.gotchai.domain.user.dto.result.GetUserRankingResult
4+
5+
interface UserQueryUseCase {
6+
fun getUserRanking(userId: Long): GetUserRankingResult
7+
}

domain/src/testFixtures/kotlin/com/gotchai/domain/fixture/UserFixture.kt

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
package com.gotchai.domain.fixture
22

3+
import com.gotchai.domain.user.dto.result.GetUserRankingResult
34
import com.gotchai.domain.user.entity.*
45
import java.time.LocalDateTime
56

@@ -52,3 +53,12 @@ fun createUserSocial(
5253
provider = provider,
5354
createdAt = createdAt
5455
)
56+
57+
fun createGetUserRankingResult(
58+
name: String = NICKNAME,
59+
rating: Double = 25.0
60+
): GetUserRankingResult =
61+
GetUserRankingResult(
62+
name = name,
63+
rating = rating
64+
)

storage/rdb/src/main/kotlin/com/gotchai/storage/rdb/exam/adapter/out/ExamHistoryQueryAdapter.kt

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,13 @@ import com.gotchai.storage.rdb.global.annotation.ReadOnlyTransactional
1010
class ExamHistoryQueryAdapter(
1111
private val examHistoryJpaRepository: ExamHistoryJpaRepository
1212
) : ExamHistoryQueryPort {
13+
@ReadOnlyTransactional
14+
override fun getExamHistories(): List<ExamHistory> = examHistoryJpaRepository.findAll().map { it.toExamHistory() }
15+
16+
@ReadOnlyTransactional
17+
override fun getAllExamHistoriesWithQuizIds(): List<ExamHistory> =
18+
examHistoryJpaRepository.findAllWithQuizIds().map { it.toExamHistory() }
19+
1320
@ReadOnlyTransactional
1421
override fun getExamHistoryByExamIdAndUserId(
1522
examId: Long,

0 commit comments

Comments
 (0)