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 6c7d7ef6..5a24658f 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/configs/SecurityConfig.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/configs/SecurityConfig.kt @@ -101,6 +101,9 @@ class SecurityConfig( .antMatchers(HttpMethod.GET, "/api/chat/room/list").permitAll() .antMatchers(HttpMethod.GET, "/api/chat/original/list").permitAll() .antMatchers(HttpMethod.POST, "/charge/payverse/webhook").permitAll() + .antMatchers(HttpMethod.GET, "/api/v2/home/recommendations").permitAll() + // 페이지네이션 하위 경로(/lives, /debut-creators 등)는 인증 필수 + .antMatchers(HttpMethod.GET, "/api/v2/home/recommendations/**").authenticated() .anyRequest().authenticated() .and() .build() diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/home/adapter/in/web/HomeRecommendationController.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/home/adapter/in/web/HomeRecommendationController.kt index bd741a9d..20d4f98d 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/home/adapter/in/web/HomeRecommendationController.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/home/adapter/in/web/HomeRecommendationController.kt @@ -3,38 +3,123 @@ package kr.co.vividnext.sodalive.v2.api.home.adapter.`in`.web import kr.co.vividnext.sodalive.common.ApiResponse import kr.co.vividnext.sodalive.common.SodaException import kr.co.vividnext.sodalive.member.Member +import kr.co.vividnext.sodalive.v2.api.home.application.HomeRecommendationFacade import kr.co.vividnext.sodalive.v2.api.home.dto.FollowRecommendedCreatorsRequest import kr.co.vividnext.sodalive.v2.recommend.application.RecommendedCreatorFollowService import org.springframework.security.core.annotation.AuthenticationPrincipal +import org.springframework.web.bind.annotation.GetMapping import org.springframework.web.bind.annotation.PostMapping import org.springframework.web.bind.annotation.RequestBody import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.RequestParam import org.springframework.web.bind.annotation.RestController @RestController @RequestMapping("/api/v2/home/recommendations") class HomeRecommendationController( + private val homeRecommendationFacade: HomeRecommendationFacade, private val recommendedCreatorFollowService: RecommendedCreatorFollowService ) { + @GetMapping + fun getHomeRecommendations( + @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member? + ) = run { + ApiResponse.ok(homeRecommendationFacade.getHomeRecommendations(member)) + } + + @GetMapping("/lives") + fun getLives( + @RequestParam(defaultValue = "0") page: Int, + @RequestParam(defaultValue = "$DEFAULT_PAGE_SIZE") size: Int, + @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member? + ) = run { + ApiResponse.ok( + homeRecommendationFacade.getLives( + requireMember(member), + normalizePage(page), + normalizeSize(size) + ) + ) + } + + @GetMapping("/debut-creators") + fun getDebutCreators( + @RequestParam(defaultValue = "0") page: Int, + @RequestParam(defaultValue = "$DEFAULT_PAGE_SIZE") size: Int, + @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member? + ) = run { + ApiResponse.ok( + homeRecommendationFacade.getRecentDebutCreators( + requireMember(member), + normalizePage(page), + normalizeSize(size) + ) + ) + } + + @GetMapping("/first-audio-contents") + fun getFirstAudioContents( + @RequestParam(defaultValue = "0") page: Int, + @RequestParam(defaultValue = "$DEFAULT_PAGE_SIZE") size: Int, + @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member? + ) = run { + ApiResponse.ok( + homeRecommendationFacade.getFirstAudioContents( + requireMember(member), + normalizePage(page), + normalizeSize(size) + ) + ) + } + + @GetMapping("/ai-characters") + fun getAiCharacters( + @RequestParam(defaultValue = "0") page: Int, + @RequestParam(defaultValue = "$DEFAULT_PAGE_SIZE") size: Int, + @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member? + ) = run { + ApiResponse.ok( + homeRecommendationFacade.getAiCharacters( + requireMember(member), + normalizePage(page), + normalizeSize(size) + ) + ) + } + @PostMapping("/creators/follow") fun followRecommendedCreators( @RequestBody request: FollowRecommendedCreatorsRequest, @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member? ) = run { - if (member == null) throw SodaException(messageKey = "common.error.bad_credentials") + val authenticatedMember = requireMember(member) val creatorIds = request.creatorIds if (creatorIds.isNullOrEmpty() || creatorIds.size > MAX_CREATOR_IDS) { throw SodaException(messageKey = "common.error.invalid_request") } recommendedCreatorFollowService.followCreators( - member = member, + member = authenticatedMember, creatorIds = creatorIds ) ApiResponse.ok() } + private fun requireMember(member: Member?): Member { + return member ?: throw SodaException(messageKey = "common.error.bad_credentials") + } + + private fun normalizePage(page: Int): Int = page.coerceIn(0, MAX_PAGE) + + private fun normalizeSize(size: Int): Int { + if (size < 1) return DEFAULT_PAGE_SIZE + return minOf(size, MAX_PAGE_SIZE) + } + companion object { private const val MAX_CREATOR_IDS = 50 + private const val DEFAULT_PAGE_SIZE = 20 + private const val MAX_PAGE_SIZE = 50 + private const val MAX_PAGE = 10_000 } } diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/home/HomeRecommendationControllerTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/home/HomeRecommendationControllerTest.kt index b9b258ff..23706608 100644 --- a/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/home/HomeRecommendationControllerTest.kt +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/home/HomeRecommendationControllerTest.kt @@ -1,5 +1,6 @@ package kr.co.vividnext.sodalive.v2.api.home +import kr.co.vividnext.sodalive.live.room.LiveRoom import kr.co.vividnext.sodalive.member.Member import kr.co.vividnext.sodalive.member.MemberAdapter import kr.co.vividnext.sodalive.member.MemberRepository @@ -19,10 +20,12 @@ import org.springframework.http.MediaType 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.request.MockMvcRequestBuilders.post 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 @@ -192,6 +195,193 @@ class HomeRecommendationControllerTest @Autowired constructor( assertEquals(0, creatorFollowingRepository.findAll().size) } + @Test + @DisplayName("메인 홈 통합 조회는 비회원도 호출 가능하고 섹션별 빈 배열을 포함해 성공 응답한다") + fun shouldReturnHomeRecommendationsForAnonymous() { + mockMvc.perform(get("/api/v2/home/recommendations")) + .andExpect(status().isOk) + .andExpect(jsonPath("$.success").value(true)) + .andExpect(jsonPath("$.data.lives").isArray) + .andExpect(jsonPath("$.data.banners").isArray) + .andExpect(jsonPath("$.data.recentlyActiveCreators").isArray) + .andExpect(jsonPath("$.data.recentDebutCreators").isArray) + .andExpect(jsonPath("$.data.firstAudioContents").isArray) + .andExpect(jsonPath("$.data.aiCharacters").isArray) + .andExpect(jsonPath("$.data.genreCreators").isArray) + .andExpect(jsonPath("$.data.cheerCreators").isArray) + .andExpect(jsonPath("$.data.popularCommunities").isArray) + } + + @Test + @DisplayName("메인 홈 통합 조회는 인증 회원도 호출 가능하고 성공 응답한다") + fun shouldReturnHomeRecommendationsForMember() { + val member = saveMember("home-viewer", MemberRole.USER) + entityManager.flush() + entityManager.clear() + + mockMvc.perform( + get("/api/v2/home/recommendations").with(user(MemberAdapter(member))) + ) + .andExpect(status().isOk) + .andExpect(jsonPath("$.success").value(true)) + .andExpect(jsonPath("$.data.lives").isArray) + } + + @Test + @DisplayName("라이브 전체보기는 page/size를 포함한 페이징 응답 형식으로 반환한다") + fun shouldReturnPagedLives() { + val member = saveMember("paged-live-viewer", MemberRole.USER) + entityManager.flush() + entityManager.clear() + + mockMvc.perform(get("/api/v2/home/recommendations/lives").with(user(MemberAdapter(member)))) + .andExpect(status().isOk) + .andExpect(jsonPath("$.success").value(true)) + .andExpect(jsonPath("$.data.items").isArray) + .andExpect(jsonPath("$.data.page").value(0)) + .andExpect(jsonPath("$.data.size").value(20)) + .andExpect(jsonPath("$.data.hasNext").value(false)) + } + + @Test + @DisplayName("섹션별 전체보기는 size 최대값 50으로 제한한다") + fun shouldCapPageSizeAtFifty() { + val member = saveMember("paged-debut-viewer", MemberRole.USER) + entityManager.flush() + entityManager.clear() + + mockMvc.perform( + get("/api/v2/home/recommendations/debut-creators") + .with(user(MemberAdapter(member))) + .param("size", "100") + ) + .andExpect(status().isOk) + .andExpect(jsonPath("$.data.size").value(50)) + } + + @Test + @DisplayName("첫 오디오/AI 캐릭터 전체보기도 같은 페이징 응답 형식을 사용한다") + fun shouldReturnPagedSectionsWithSameFormat() { + val member = saveMember("paged-section-viewer", MemberRole.USER) + entityManager.flush() + entityManager.clear() + + for (path in listOf("/first-audio-contents", "/ai-characters")) { + mockMvc.perform( + get("/api/v2/home/recommendations$path") + .with(user(MemberAdapter(member))) + .param("page", "1") + .param("size", "10") + ) + .andExpect(status().isOk) + .andExpect(jsonPath("$.data.items").isArray) + .andExpect(jsonPath("$.data.page").value(1)) + .andExpect(jsonPath("$.data.size").value(10)) + .andExpect(jsonPath("$.data.hasNext").isBoolean) + } + } + + @Test + @DisplayName("세부 전체보기 API는 비회원 요청을 거부한다") + fun shouldRejectAnonymousSectionPages() { + for (path in listOf("/lives", "/debut-creators", "/first-audio-contents", "/ai-characters")) { + mockMvc.perform(get("/api/v2/home/recommendations$path")) + .andExpect(status().isUnauthorized) + } + } + + @Test + @DisplayName("세부 전체보기 API는 음수 page를 0으로 보정한다") + fun shouldNormalizeNegativePageToZero() { + val member = saveMember("negative-page-viewer", MemberRole.USER) + entityManager.flush() + entityManager.clear() + + mockMvc.perform( + get("/api/v2/home/recommendations/lives") + .with(user(MemberAdapter(member))) + .param("page", "-1") + ) + .andExpect(status().isOk) + .andExpect(jsonPath("$.data.page").value(0)) + } + + @Test + @DisplayName("커뮤니티 전체보기 API는 인증 회원에게도 제공하지 않는다") + fun shouldNotExposeCommunitiesFullViewEndpoint() { + val member = saveMember("removed-community-viewer", MemberRole.USER) + entityManager.flush() + entityManager.clear() + + mockMvc.perform( + get("/api/v2/home/recommendations/communities") + .with(user(MemberAdapter(member))) + ) + .andExpect(status().isNotFound) + } + + @Test + @DisplayName("라이브 전체보기 page=0에서 성인 라이브를 제외하고 최신순 첫 항목과 hasNext=true를 반환한다") + fun shouldReturnFirstPageLivesExcludingAdult() { + val member = saveMember("adult-hidden-live-viewer-p0", MemberRole.USER) + val creator = saveMember("adult-hidden-live-creator-p0", MemberRole.CREATOR) + val baseAt = LocalDateTime.of(2026, 5, 31, 10, 0) + val newest = saveLiveRoom(creator, baseAt.plusMinutes(2), "normal-newest-p0", isAdult = false) + saveLiveRoom(creator, baseAt.plusMinutes(1), "adult-hidden-p0", isAdult = true) + saveLiveRoom(creator, baseAt, "normal-oldest-p0", isAdult = false) + entityManager.flush() + entityManager.clear() + + mockMvc.perform( + get("/api/v2/home/recommendations/lives") + .with(user(MemberAdapter(member))) + .param("page", "0") + .param("size", "1") + ) + .andExpect(status().isOk) + .andExpect(jsonPath("$.data.items[0].liveRoomId").value(newest.id)) + .andExpect(jsonPath("$.data.hasNext").value(true)) + } + + @Test + @DisplayName("라이브 전체보기 page=1에서 성인 라이브를 제외하고 두 번째 항목과 hasNext=false를 반환한다") + fun shouldReturnSecondPageLivesExcludingAdult() { + val member = saveMember("adult-hidden-live-viewer-p1", MemberRole.USER) + val creator = saveMember("adult-hidden-live-creator-p1", MemberRole.CREATOR) + val baseAt = LocalDateTime.of(2026, 5, 31, 10, 0) + saveLiveRoom(creator, baseAt.plusMinutes(2), "normal-newest-p1", isAdult = false) + saveLiveRoom(creator, baseAt.plusMinutes(1), "adult-hidden-p1", isAdult = true) + val oldest = saveLiveRoom(creator, baseAt, "normal-oldest-p1", isAdult = false) + entityManager.flush() + entityManager.clear() + + mockMvc.perform( + get("/api/v2/home/recommendations/lives") + .with(user(MemberAdapter(member))) + .param("page", "1") + .param("size", "1") + ) + .andExpect(status().isOk) + .andExpect(jsonPath("$.data.items[0].liveRoomId").value(oldest.id)) + .andExpect(jsonPath("$.data.hasNext").value(false)) + } + + @Test + @DisplayName("세부 전체보기 API는 size=0을 기본값 20으로 보정한다") + fun shouldNormalizeZeroSizeToDefault() { + val member = saveMember("zero-size-viewer", MemberRole.USER) + entityManager.flush() + entityManager.clear() + + mockMvc.perform( + get("/api/v2/home/recommendations/lives") + .with(user(MemberAdapter(member))) + .param("size", "0") + ) + .andExpect(status().isOk) + .andExpect(jsonPath("$.data.size").value(20)) + } + private fun saveMember(seed: String, role: MemberRole): Member { return memberRepository.saveAndFlush( Member( @@ -211,4 +401,23 @@ class HomeRecommendationControllerTest @Autowired constructor( } ) } + + private fun saveLiveRoom( + creator: Member, + beginDateTime: LocalDateTime, + channelName: String, + isAdult: Boolean + ): LiveRoom { + val room = LiveRoom( + title = "live-${creator.nickname}-$channelName", + notice = "notice", + beginDateTime = beginDateTime, + numberOfPeople = 0, + isAdult = isAdult + ) + room.member = creator + room.channelName = channelName + entityManager.persist(room) + return room + } }