From 1cb0b171d0fd0f6391db9cf0a73396309ce0e4c4 Mon Sep 17 00:00:00 2001 From: Klaus Date: Mon, 8 Jun 2026 22:40:19 +0900 Subject: [PATCH] =?UTF-8?q?feat(ranking):=20=ED=81=AC=EB=A6=AC=EC=97=90?= =?UTF-8?q?=EC=9D=B4=ED=84=B0=20=EB=9E=AD=ED=82=B9=20=ED=99=88=20API?= =?UTF-8?q?=EB=A5=BC=20=EC=B6=94=EA=B0=80=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../sodalive/configs/SecurityConfig.kt | 1 + .../in/web/CreatorRankingController.kt | 22 +++ .../application/HomeCreatorRankingFacade.kt | 17 ++ .../dto/ranking/CreatorRankingResponse.kt | 42 +++++ .../api/home/CreatorRankingControllerTest.kt | 151 ++++++++++++++++++ 5 files changed, 233 insertions(+) create mode 100644 src/main/kotlin/kr/co/vividnext/sodalive/v2/api/home/adapter/in/web/CreatorRankingController.kt create mode 100644 src/main/kotlin/kr/co/vividnext/sodalive/v2/api/home/application/HomeCreatorRankingFacade.kt create mode 100644 src/main/kotlin/kr/co/vividnext/sodalive/v2/api/home/dto/ranking/CreatorRankingResponse.kt create mode 100644 src/test/kotlin/kr/co/vividnext/sodalive/v2/api/home/CreatorRankingControllerTest.kt diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/configs/SecurityConfig.kt b/src/main/kotlin/kr/co/vividnext/sodalive/configs/SecurityConfig.kt index 5a24658f..d7620480 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/configs/SecurityConfig.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/configs/SecurityConfig.kt @@ -102,6 +102,7 @@ class SecurityConfig( .antMatchers(HttpMethod.GET, "/api/chat/original/list").permitAll() .antMatchers(HttpMethod.POST, "/charge/payverse/webhook").permitAll() .antMatchers(HttpMethod.GET, "/api/v2/home/recommendations").permitAll() + .antMatchers(HttpMethod.GET, "/api/v2/home/rankings/creators").permitAll() // 페이지네이션 하위 경로(/lives, /debut-creators 등)는 인증 필수 .antMatchers(HttpMethod.GET, "/api/v2/home/recommendations/**").authenticated() .anyRequest().authenticated() diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/home/adapter/in/web/CreatorRankingController.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/home/adapter/in/web/CreatorRankingController.kt new file mode 100644 index 00000000..664c20a1 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/home/adapter/in/web/CreatorRankingController.kt @@ -0,0 +1,22 @@ +package kr.co.vividnext.sodalive.v2.api.home.adapter.`in`.web + +import kr.co.vividnext.sodalive.common.ApiResponse +import kr.co.vividnext.sodalive.member.Member +import kr.co.vividnext.sodalive.v2.api.home.application.HomeCreatorRankingFacade +import org.springframework.security.core.annotation.AuthenticationPrincipal +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.RestController + +@RestController +@RequestMapping("/api/v2/home/rankings") +class CreatorRankingController( + private val homeCreatorRankingFacade: HomeCreatorRankingFacade +) { + @GetMapping("/creators") + fun getCreatorRankings( + @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member? + ) = run { + ApiResponse.ok(homeCreatorRankingFacade.getCreatorRankings(member)) + } +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/home/application/HomeCreatorRankingFacade.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/home/application/HomeCreatorRankingFacade.kt new file mode 100644 index 00000000..c0618a44 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/home/application/HomeCreatorRankingFacade.kt @@ -0,0 +1,17 @@ +package kr.co.vividnext.sodalive.v2.api.home.application + +import kr.co.vividnext.sodalive.member.Member +import kr.co.vividnext.sodalive.v2.api.home.dto.ranking.CreatorRankingResponse +import kr.co.vividnext.sodalive.v2.ranking.application.CreatorRankingQueryService +import org.springframework.stereotype.Component + +@Component +class HomeCreatorRankingFacade( + private val creatorRankingQueryService: CreatorRankingQueryService +) { + fun getCreatorRankings(member: Member?): CreatorRankingResponse { + return CreatorRankingResponse.from( + creatorRankingQueryService.getCreatorRankings(viewerMemberId = member?.id) + ) + } +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/home/dto/ranking/CreatorRankingResponse.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/home/dto/ranking/CreatorRankingResponse.kt new file mode 100644 index 00000000..4300b9cc --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/home/dto/ranking/CreatorRankingResponse.kt @@ -0,0 +1,42 @@ +package kr.co.vividnext.sodalive.v2.api.home.dto.ranking + +import com.fasterxml.jackson.annotation.JsonProperty +import kr.co.vividnext.sodalive.v2.ranking.application.CreatorRankingResult +import kr.co.vividnext.sodalive.v2.ranking.domain.CreatorRankingItem + +data class CreatorRankingResponse( + val showRankChange: Boolean, + val items: List +) { + companion object { + fun from(result: CreatorRankingResult): CreatorRankingResponse { + return CreatorRankingResponse( + showRankChange = result.showRankChange, + items = result.items.map { CreatorRankingResponseItem.from(it) } + ) + } + } +} + +data class CreatorRankingResponseItem( + val rank: Int, + val rankChange: Int?, + @JsonProperty("isNew") + val isNew: Boolean, + val creatorId: Long, + val nickname: String, + val profileImageUrl: String? +) { + companion object { + fun from(item: CreatorRankingItem): CreatorRankingResponseItem { + return CreatorRankingResponseItem( + rank = item.rank, + rankChange = item.rankChange, + isNew = item.isNew, + creatorId = item.creatorId, + nickname = item.nickname, + profileImageUrl = item.profileImageUrl + ) + } + } +} diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/home/CreatorRankingControllerTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/home/CreatorRankingControllerTest.kt new file mode 100644 index 00000000..f3607fe1 --- /dev/null +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/home/CreatorRankingControllerTest.kt @@ -0,0 +1,151 @@ +package kr.co.vividnext.sodalive.v2.api.home + +import kr.co.vividnext.sodalive.member.Member +import kr.co.vividnext.sodalive.member.MemberAdapter +import kr.co.vividnext.sodalive.member.MemberRepository +import kr.co.vividnext.sodalive.member.MemberRole +import kr.co.vividnext.sodalive.member.block.BlockMember +import kr.co.vividnext.sodalive.support.EmbeddedRedisInitializer +import kr.co.vividnext.sodalive.v2.ranking.adapter.out.persistence.CreatorRankingSnapshot +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Test +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc +import org.springframework.boot.test.context.SpringBootTest +import org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.user +import org.springframework.test.context.ContextConfiguration +import org.springframework.test.web.servlet.MockMvc +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get +import org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath +import org.springframework.test.web.servlet.result.MockMvcResultMatchers.status +import org.springframework.transaction.annotation.Transactional +import java.time.LocalDateTime +import javax.persistence.EntityManager + +@SpringBootTest +@AutoConfigureMockMvc +@Transactional +@ContextConfiguration(initializers = [EmbeddedRedisInitializer::class]) +class CreatorRankingControllerTest @Autowired constructor( + private val mockMvc: MockMvc, + private val memberRepository: MemberRepository, + private val entityManager: EntityManager +) { + @Test + @DisplayName("크리에이터 랭킹 조회는 허용된 응답 필드만 반환하고 점수와 기간은 노출하지 않는다") + fun shouldReturnCreatorRankingSchemaWithoutScoreAndPeriodFields() { + saveSnapshot( + creatorId = 1L, + nickname = "creator-one", + profileImageUrl = "profile-one.png", + finalScore = 100.0 + ) + entityManager.flush() + entityManager.clear() + + mockMvc.perform(get("/api/v2/home/rankings/creators")) + .andExpect(status().isOk) + .andExpect(jsonPath("$.success").value(true)) + .andExpect(jsonPath("$.data.showRankChange").value(false)) + .andExpect(jsonPath("$.data.items[0].rank").value(1)) + .andExpect(jsonPath("$.data.items[0].rankChange").doesNotExist()) + .andExpect(jsonPath("$.data.items[0].isNew").value(false)) + .andExpect(jsonPath("$.data.items[0].creatorId").value(1L)) + .andExpect(jsonPath("$.data.items[0].nickname").value("creator-one")) + .andExpect(jsonPath("$.data.items[0].profileImageUrl").value("profile-one.png")) + .andExpect(jsonPath("$.data.items[0].finalScore").doesNotExist()) + .andExpect(jsonPath("$.data.items[0].aggregationStartAtUtc").doesNotExist()) + .andExpect(jsonPath("$.data.items[0].aggregationEndAtUtc").doesNotExist()) + .andExpect(jsonPath("$.data.aggregationStartAtUtc").doesNotExist()) + .andExpect(jsonPath("$.data.aggregationEndAtUtc").doesNotExist()) + } + + @Test + @DisplayName("크리에이터 랭킹 조회는 비회원도 호출 가능하고 빈 랭킹을 성공 응답으로 반환한다") + fun shouldReturnEmptyCreatorRankingsForAnonymous() { + mockMvc.perform(get("/api/v2/home/rankings/creators")) + .andExpect(status().isOk) + .andExpect(jsonPath("$.success").value(true)) + .andExpect(jsonPath("$.data.showRankChange").value(false)) + .andExpect(jsonPath("$.data.items").isArray) + .andExpect(jsonPath("$.data.items.length()").value(0)) + } + + @Test + @DisplayName("크리에이터 랭킹 조회는 인증 회원 id를 전달해 차단 크리에이터 정보를 마스킹한다") + fun shouldMaskBlockedCreatorForAuthenticatedMember() { + val viewer = saveMember("ranking-viewer", MemberRole.USER) + val blockedCreator = saveMember("blocked-creator", MemberRole.CREATOR) + saveSnapshot( + creatorId = blockedCreator.id!!, + nickname = blockedCreator.nickname, + profileImageUrl = "blocked-profile.png", + finalScore = 100.0 + ) + saveBlock(member = viewer, blockedMember = blockedCreator) + entityManager.flush() + entityManager.clear() + + mockMvc.perform( + get("/api/v2/home/rankings/creators") + .with(user(MemberAdapter(viewer))) + ) + .andExpect(status().isOk) + .andExpect(jsonPath("$.success").value(true)) + .andExpect(jsonPath("$.data.items[0].rank").value(1)) + .andExpect(jsonPath("$.data.items[0].creatorId").value(0L)) + .andExpect(jsonPath("$.data.items[0].nickname").value("")) + .andExpect(jsonPath("$.data.items[0].profileImageUrl").value("/profile/default-profile.png")) + } + + private fun saveMember(seed: String, role: MemberRole): Member { + return memberRepository.saveAndFlush( + Member( + email = "$seed@test.com", + password = "password", + nickname = seed, + role = role + ) + ) + } + + private fun saveBlock(member: Member, blockedMember: Member) { + entityManager.persist( + BlockMember().apply { + this.member = member + this.blockedMember = blockedMember + } + ) + } + + private fun saveSnapshot( + creatorId: Long, + nickname: String, + profileImageUrl: String?, + finalScore: Double + ) { + entityManager.persist( + CreatorRankingSnapshot( + aggregationStartAtUtc = LocalDateTime.of(2026, 5, 31, 15, 0, 0), + aggregationEndAtUtc = LocalDateTime.of(2026, 6, 7, 15, 0, 0), + creatorId = creatorId, + nickname = nickname, + profileImageUrl = profileImageUrl, + finalScore = finalScore, + contentLiveScore = 0.0, + engagementScore = 0.0, + supportScore = 0.0, + fanLoyaltyScore = 0.0, + liveCanAmount = 0, + contentPurchaseCanAmount = 0, + contentLikeCount = 0, + contentCommentCount = 0, + channelDonationCanAmount = 0, + channelDonationCount = 0, + fanTalkCount = 0, + finalFollowerCount = 0, + followIncrease = 0 + ) + ) + } +}