feat(home): 홈 추천 조회 컨트롤러를 추가한다

This commit is contained in:
2026-06-01 13:55:53 +09:00
parent 3df5614b7a
commit fb0f22070f
3 changed files with 299 additions and 2 deletions

View File

@@ -101,6 +101,9 @@ class SecurityConfig(
.antMatchers(HttpMethod.GET, "/api/chat/room/list").permitAll() .antMatchers(HttpMethod.GET, "/api/chat/room/list").permitAll()
.antMatchers(HttpMethod.GET, "/api/chat/original/list").permitAll() .antMatchers(HttpMethod.GET, "/api/chat/original/list").permitAll()
.antMatchers(HttpMethod.POST, "/charge/payverse/webhook").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() .anyRequest().authenticated()
.and() .and()
.build() .build()

View File

@@ -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.ApiResponse
import kr.co.vividnext.sodalive.common.SodaException import kr.co.vividnext.sodalive.common.SodaException
import kr.co.vividnext.sodalive.member.Member 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.api.home.dto.FollowRecommendedCreatorsRequest
import kr.co.vividnext.sodalive.v2.recommend.application.RecommendedCreatorFollowService import kr.co.vividnext.sodalive.v2.recommend.application.RecommendedCreatorFollowService
import org.springframework.security.core.annotation.AuthenticationPrincipal 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.PostMapping
import org.springframework.web.bind.annotation.RequestBody import org.springframework.web.bind.annotation.RequestBody
import org.springframework.web.bind.annotation.RequestMapping import org.springframework.web.bind.annotation.RequestMapping
import org.springframework.web.bind.annotation.RequestParam
import org.springframework.web.bind.annotation.RestController import org.springframework.web.bind.annotation.RestController
@RestController @RestController
@RequestMapping("/api/v2/home/recommendations") @RequestMapping("/api/v2/home/recommendations")
class HomeRecommendationController( class HomeRecommendationController(
private val homeRecommendationFacade: HomeRecommendationFacade,
private val recommendedCreatorFollowService: RecommendedCreatorFollowService 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") @PostMapping("/creators/follow")
fun followRecommendedCreators( fun followRecommendedCreators(
@RequestBody request: FollowRecommendedCreatorsRequest, @RequestBody request: FollowRecommendedCreatorsRequest,
@AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member? @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?
) = run { ) = run {
if (member == null) throw SodaException(messageKey = "common.error.bad_credentials") val authenticatedMember = requireMember(member)
val creatorIds = request.creatorIds val creatorIds = request.creatorIds
if (creatorIds.isNullOrEmpty() || creatorIds.size > MAX_CREATOR_IDS) { if (creatorIds.isNullOrEmpty() || creatorIds.size > MAX_CREATOR_IDS) {
throw SodaException(messageKey = "common.error.invalid_request") throw SodaException(messageKey = "common.error.invalid_request")
} }
recommendedCreatorFollowService.followCreators( recommendedCreatorFollowService.followCreators(
member = member, member = authenticatedMember,
creatorIds = creatorIds creatorIds = creatorIds
) )
ApiResponse.ok<Unit>() ApiResponse.ok<Unit>()
} }
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 { companion object {
private const val MAX_CREATOR_IDS = 50 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
} }
} }

View File

@@ -1,5 +1,6 @@
package kr.co.vividnext.sodalive.v2.api.home 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.Member
import kr.co.vividnext.sodalive.member.MemberAdapter import kr.co.vividnext.sodalive.member.MemberAdapter
import kr.co.vividnext.sodalive.member.MemberRepository 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.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.user
import org.springframework.test.context.ContextConfiguration import org.springframework.test.context.ContextConfiguration
import org.springframework.test.web.servlet.MockMvc 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.request.MockMvcRequestBuilders.post
import org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath import org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath
import org.springframework.test.web.servlet.result.MockMvcResultMatchers.status import org.springframework.test.web.servlet.result.MockMvcResultMatchers.status
import org.springframework.transaction.annotation.Transactional import org.springframework.transaction.annotation.Transactional
import java.time.LocalDateTime
import javax.persistence.EntityManager import javax.persistence.EntityManager
@SpringBootTest @SpringBootTest
@@ -192,6 +195,193 @@ class HomeRecommendationControllerTest @Autowired constructor(
assertEquals(0, creatorFollowingRepository.findAll().size) 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 { private fun saveMember(seed: String, role: MemberRole): Member {
return memberRepository.saveAndFlush( return memberRepository.saveAndFlush(
Member( 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
}
} }