feat(home): 홈 추천 조회 컨트롤러를 추가한다
This commit is contained in:
@@ -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()
|
||||
|
||||
@@ -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<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 {
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user