diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/home/application/HomeRecommendationFacade.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/home/application/HomeRecommendationFacade.kt index f6fccb19..1e36c20a 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/home/application/HomeRecommendationFacade.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/home/application/HomeRecommendationFacade.kt @@ -2,7 +2,9 @@ package kr.co.vividnext.sodalive.v2.api.home.application import kr.co.vividnext.sodalive.event.EventItem import kr.co.vividnext.sodalive.i18n.Lang +import kr.co.vividnext.sodalive.member.Gender import kr.co.vividnext.sodalive.member.Member +import kr.co.vividnext.sodalive.member.MemberRole import kr.co.vividnext.sodalive.member.contentpreference.MemberContentPreferenceService import kr.co.vividnext.sodalive.v2.api.common.dto.RecommendationBannerResponse import kr.co.vividnext.sodalive.v2.api.home.dto.recommendation.HomeActiveCreatorItem @@ -53,7 +55,9 @@ class HomeRecommendationFacade( lives = queryService.findLiveRecommendations( limit = HOME_LIVE_LIMIT, memberId = member?.id, - includeAdultLives = includeAdult + includeAdultLives = includeAdult, + isViewerCreator = member?.role == MemberRole.CREATOR, + effectiveViewerGender = member?.effectiveGender() ).map { it.toItem() }, banners = queryService.findHomeBanners( limit = HOME_BANNER_LIMIT, @@ -125,7 +129,9 @@ class HomeRecommendationFacade( offset = page.toOffset(size), limit = size + 1, memberId = member.id, - includeAdultLives = resolveAdultVisibility(member) + includeAdultLives = resolveAdultVisibility(member), + isViewerCreator = member.role == MemberRole.CREATOR, + effectiveViewerGender = member.effectiveGender() ) fetched.toPage(page, size) { it.toItem() } }.onSuccess { @@ -209,6 +215,10 @@ class HomeRecommendationFacade( return memberContentPreferenceService.canViewAdultContent(member) } + private fun Member.effectiveGender(): Gender { + return auth?.let { if (it.gender == 1) Gender.MALE else Gender.FEMALE } ?: gender + } + private fun Int.toOffset(size: Int): Long = this.toLong() * size private fun List.toPage( diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/home/live/application/HomeOnAirLiveFacade.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/home/live/application/HomeOnAirLiveFacade.kt index a55f9078..4328d852 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/home/live/application/HomeOnAirLiveFacade.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/home/live/application/HomeOnAirLiveFacade.kt @@ -1,6 +1,8 @@ package kr.co.vividnext.sodalive.v2.api.home.live.application +import kr.co.vividnext.sodalive.member.Gender import kr.co.vividnext.sodalive.member.Member +import kr.co.vividnext.sodalive.member.MemberRole import kr.co.vividnext.sodalive.member.contentpreference.MemberContentPreferenceService import kr.co.vividnext.sodalive.v2.api.home.live.dto.HomeOnAirLivePageResponse import kr.co.vividnext.sodalive.v2.api.home.live.dto.HomeOnAirLiveResponse @@ -24,7 +26,9 @@ class HomeOnAirLiveFacade( offset = normalizedPage.toLong() * PAGE_SIZE, limit = PAGE_SIZE + 1, memberId = member.id, - includeAdultLives = memberContentPreferenceService.canViewAdultContent(member) + includeAdultLives = memberContentPreferenceService.canViewAdultContent(member), + isViewerCreator = member.role == MemberRole.CREATOR, + effectiveViewerGender = member.effectiveGender() ) val items = fetched.take(PAGE_SIZE).map { it.toResponse() } @@ -57,6 +61,10 @@ class HomeOnAirLiveFacade( return atOffset(ZoneOffset.UTC).toInstant().toString() } + private fun Member.effectiveGender(): Gender { + return auth?.let { if (it.gender == 1) Gender.MALE else Gender.FEMALE } ?: gender + } + companion object { private const val PAGE_SIZE = 20 private const val MAX_PAGE = 10_000 diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/recommendation/adapter/out/persistence/DefaultHomeRecommendationQueryRepository.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/recommendation/adapter/out/persistence/DefaultHomeRecommendationQueryRepository.kt index 9c129fd2..00d64eb1 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/v2/recommendation/adapter/out/persistence/DefaultHomeRecommendationQueryRepository.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/recommendation/adapter/out/persistence/DefaultHomeRecommendationQueryRepository.kt @@ -22,7 +22,9 @@ import kr.co.vividnext.sodalive.explorer.profile.creatorCommunity.QCreatorCommun import kr.co.vividnext.sodalive.explorer.profile.creatorCommunity.comment.QCreatorCommunityComment.creatorCommunityComment import kr.co.vividnext.sodalive.explorer.profile.creatorCommunity.like.QCreatorCommunityLike.creatorCommunityLike import kr.co.vividnext.sodalive.i18n.Lang +import kr.co.vividnext.sodalive.live.room.GenderRestriction import kr.co.vividnext.sodalive.live.room.QLiveRoom.liveRoom +import kr.co.vividnext.sodalive.member.Gender import kr.co.vividnext.sodalive.member.MemberKind import kr.co.vividnext.sodalive.member.MemberRole import kr.co.vividnext.sodalive.member.QMember @@ -58,7 +60,9 @@ class DefaultHomeRecommendationQueryRepository( offset: Long, limit: Int, memberId: Long?, - includeAdultLives: Boolean + includeAdultLives: Boolean, + isViewerCreator: Boolean, + effectiveViewerGender: Gender? ): List { return queryFactory .select( @@ -79,6 +83,8 @@ class DefaultHomeRecommendationQueryRepository( liveRoom.channelName.isNotNull, liveRoom.channelName.isNotEmpty, includeAdultLiveCondition(includeAdultLives), + genderLiveCondition(memberId, effectiveViewerGender), + creatorJoinLiveCondition(memberId, isViewerCreator), notBlockedCreatorCondition(memberId, member.id), member.isActive.isTrue ) @@ -1294,6 +1300,21 @@ class DefaultHomeRecommendationQueryRepository( return if (includeAdultLives) null else liveRoom.isAdult.isFalse } + private fun genderLiveCondition(viewerId: Long?, effectiveViewerGender: Gender?): BooleanExpression? { + if (effectiveViewerGender == null || effectiveViewerGender == Gender.NONE) return null + val genderCondition = when (effectiveViewerGender) { + Gender.MALE -> liveRoom.genderRestriction.`in`(GenderRestriction.ALL, GenderRestriction.MALE_ONLY) + Gender.FEMALE -> liveRoom.genderRestriction.`in`(GenderRestriction.ALL, GenderRestriction.FEMALE_ONLY) + Gender.NONE -> return null + } + return viewerId?.let { genderCondition.or(liveRoom.member.id.eq(it)) } ?: genderCondition + } + + private fun creatorJoinLiveCondition(viewerId: Long?, isViewerCreator: Boolean): BooleanExpression? { + if (!isViewerCreator || viewerId == null) return null + return liveRoom.isAvailableJoinCreator.isTrue.or(liveRoom.member.id.eq(viewerId)) + } + private fun excludeAiCharacterCondition(excludeCharacterIds: List): BooleanExpression? { return if (excludeCharacterIds.isEmpty()) null else chatCharacter.id.notIn(excludeCharacterIds) } diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/recommendation/application/HomeRecommendationQueryService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/recommendation/application/HomeRecommendationQueryService.kt index d9c42f1c..3e83f618 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/v2/recommendation/application/HomeRecommendationQueryService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/recommendation/application/HomeRecommendationQueryService.kt @@ -2,6 +2,7 @@ package kr.co.vividnext.sodalive.v2.recommendation.application import kr.co.vividnext.sodalive.i18n.Lang import kr.co.vividnext.sodalive.i18n.LangContext +import kr.co.vividnext.sodalive.member.Gender import kr.co.vividnext.sodalive.v2.common.domain.CreatorActivityType import kr.co.vividnext.sodalive.v2.creator.channel.community.translation.application.CreatorCommunityTranslationService import kr.co.vividnext.sodalive.v2.recommendation.domain.RecommendationSnapshotWindowPolicy @@ -41,9 +42,18 @@ class HomeRecommendationQueryService( offset: Long = 0, limit: Int = DEFAULT_LIVE_LIMIT, memberId: Long? = null, - includeAdultLives: Boolean = false + includeAdultLives: Boolean = false, + isViewerCreator: Boolean = false, + effectiveViewerGender: Gender? = null ): List { - return queryPort.findLiveRecommendations(offset, limit, memberId, includeAdultLives) + return queryPort.findLiveRecommendations( + offset, + limit, + memberId, + includeAdultLives, + isViewerCreator, + effectiveViewerGender + ) } fun findHomeBanners( diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/recommendation/port/out/HomeRecommendationQueryPort.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/recommendation/port/out/HomeRecommendationQueryPort.kt index bd4d6fed..a27605bc 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/v2/recommendation/port/out/HomeRecommendationQueryPort.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/recommendation/port/out/HomeRecommendationQueryPort.kt @@ -1,6 +1,7 @@ package kr.co.vividnext.sodalive.v2.recommendation.port.out import kr.co.vividnext.sodalive.i18n.Lang +import kr.co.vividnext.sodalive.member.Gender import kr.co.vividnext.sodalive.v2.common.domain.CreatorActivityType import java.time.LocalDateTime @@ -9,7 +10,9 @@ interface HomeRecommendationQueryPort { offset: Long = 0, limit: Int, memberId: Long? = null, - includeAdultLives: Boolean = false + includeAdultLives: Boolean = false, + isViewerCreator: Boolean = false, + effectiveViewerGender: Gender? = null ): List fun findHomeBanners( 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 26c98681..e46538da 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 @@ -3,6 +3,7 @@ package kr.co.vividnext.sodalive.v2.api.home import kr.co.vividnext.sodalive.chat.character.ChatCharacter import kr.co.vividnext.sodalive.explorer.profile.creatorCommunity.CreatorCommunity import kr.co.vividnext.sodalive.live.room.LiveRoom +import kr.co.vividnext.sodalive.member.Gender import kr.co.vividnext.sodalive.member.Member import kr.co.vividnext.sodalive.member.MemberAdapter import kr.co.vividnext.sodalive.member.MemberKind @@ -372,7 +373,9 @@ class HomeRecommendationControllerTest @Autowired constructor( offset = 0, limit = 21, memberId = member.id, - includeAdultLives = false + includeAdultLives = false, + isViewerCreator = false, + effectiveViewerGender = Gender.NONE ) ) .thenThrow(IllegalStateException("page query failed")) diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/home/application/HomeRecommendationFacadeTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/home/application/HomeRecommendationFacadeTest.kt index 398e70a1..cd286c06 100644 --- a/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/home/application/HomeRecommendationFacadeTest.kt +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/home/application/HomeRecommendationFacadeTest.kt @@ -2,7 +2,10 @@ package kr.co.vividnext.sodalive.v2.api.home.application import kr.co.vividnext.sodalive.i18n.Lang import kr.co.vividnext.sodalive.i18n.LangContext +import kr.co.vividnext.sodalive.member.Gender import kr.co.vividnext.sodalive.member.Member +import kr.co.vividnext.sodalive.member.MemberRole +import kr.co.vividnext.sodalive.member.auth.Auth import kr.co.vividnext.sodalive.member.contentpreference.MemberContentPreferenceService import kr.co.vividnext.sodalive.v2.creator.channel.community.translation.application.CreatorCommunityTranslationService import kr.co.vividnext.sodalive.v2.recommendation.application.HomeRecommendationQueryService @@ -86,6 +89,42 @@ class HomeRecommendationFacadeTest { assertTrue(queryPort.bannerLang == Lang.KO) } + @Test + @DisplayName("홈 추천은 크리에이터 여부와 인증 성별 우선 유효 성별을 라이브 조회에 전달한다") + fun shouldPassCreatorRoleAndAuthGenderToHomeLiveQuery() { + val member = createMember(id = 12L, role = MemberRole.CREATOR, gender = Gender.FEMALE, authGender = 1) + + facade.getHomeRecommendations(member) + + assertTrue(queryPort.liveIsViewerCreator == true) + assertTrue(queryPort.liveEffectiveViewerGender == Gender.MALE) + } + + @Test + @DisplayName("홈 라이브 목록은 크리에이터 여부와 회원 성별 fallback을 라이브 조회에 전달한다") + fun shouldPassCreatorRoleAndMemberGenderToLivePageQuery() { + val member = createMember(id = 13L, role = MemberRole.CREATOR, gender = Gender.FEMALE) + + facade.getLives(member, page = 0, size = 20) + + assertTrue(queryPort.liveIsViewerCreator == true) + assertTrue(queryPort.liveEffectiveViewerGender == Gender.FEMALE) + } + + private fun createMember( + id: Long, + role: MemberRole = MemberRole.USER, + gender: Gender = Gender.NONE, + authGender: Int? = null + ): Member { + return Member(password = "password", nickname = "member$id", role = role, gender = gender).apply { + this.id = id + authGender?.let { + Auth(name = "name", birth = "19900101", uniqueCi = "ci$id", di = "di$id", gender = it).member = this + } + } + } + private fun firstAudio(price: Int, isPointAvailable: Boolean): HomeFirstAudioContentRecord { return HomeFirstAudioContentRecord( contentId = price.toLong() + 1, @@ -105,13 +144,21 @@ class HomeRecommendationFacadeTest { var firstAudioContents: List = emptyList() var bannerIncludeAdultBanners: Boolean = false var bannerLang: Lang? = null + var liveIsViewerCreator: Boolean? = null + var liveEffectiveViewerGender: Gender? = null override fun findLiveRecommendations( offset: Long, limit: Int, memberId: Long?, - includeAdultLives: Boolean - ): List = emptyList() + includeAdultLives: Boolean, + isViewerCreator: Boolean, + effectiveViewerGender: Gender? + ): List { + liveIsViewerCreator = isViewerCreator + liveEffectiveViewerGender = effectiveViewerGender + return emptyList() + } override fun findHomeBanners( limit: Int, diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/home/live/application/HomeOnAirLiveFacadeTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/home/live/application/HomeOnAirLiveFacadeTest.kt index 569921d0..db138bec 100644 --- a/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/home/live/application/HomeOnAirLiveFacadeTest.kt +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/home/live/application/HomeOnAirLiveFacadeTest.kt @@ -1,7 +1,9 @@ package kr.co.vividnext.sodalive.v2.api.home.live.application +import kr.co.vividnext.sodalive.member.Gender import kr.co.vividnext.sodalive.member.Member import kr.co.vividnext.sodalive.member.MemberRole +import kr.co.vividnext.sodalive.member.auth.Auth import kr.co.vividnext.sodalive.member.contentpreference.MemberContentPreferenceService import kr.co.vividnext.sodalive.v2.recommendation.application.HomeRecommendationQueryService import kr.co.vividnext.sodalive.v2.recommendation.port.out.HomeLiveRecommendationRecord @@ -25,7 +27,9 @@ class HomeOnAirLiveFacadeTest { eqValue(0L), eqValue(21), eqValue(member.id), - eqValue(true) + eqValue(true), + eqValue(false), + eqValue(Gender.NONE) ) val response = facade.getOnAirLives(member, page = 0) @@ -34,7 +38,14 @@ class HomeOnAirLiveFacadeTest { assertEquals(20, response.size) assertEquals(true, response.hasNext) assertEquals(20, response.items.size) - Mockito.verify(queryService).findLiveRecommendations(eqValue(0L), eqValue(21), eqValue(member.id), eqValue(true)) + Mockito.verify(queryService).findLiveRecommendations( + eqValue(0L), + eqValue(21), + eqValue(member.id), + eqValue(true), + eqValue(false), + eqValue(Gender.NONE) + ) } @Test @@ -46,7 +57,9 @@ class HomeOnAirLiveFacadeTest { eqValue(0L), eqValue(21), eqValue(member.id), - eqValue(false) + eqValue(false), + eqValue(false), + eqValue(Gender.NONE) ) val response = facade.getOnAirLives(member, page = 0) @@ -60,13 +73,46 @@ class HomeOnAirLiveFacadeTest { val member = createMember(100L) Mockito.doReturn(false).`when`(preferenceService).canViewAdultContent(member) Mockito.doReturn(listOf(record(1L, beginDateTime = LocalDateTime.of(2026, 6, 26, 12, 30)))).`when`(queryService) - .findLiveRecommendations(eqValue(0L), eqValue(21), eqValue(member.id), eqValue(false)) + .findLiveRecommendations( + eqValue(0L), + eqValue(21), + eqValue(member.id), + eqValue(false), + eqValue(false), + eqValue(Gender.NONE) + ) val response = facade.getOnAirLives(member, page = 0) assertEquals("2026-06-26T12:30:00Z", response.items.single().beginDateTimeUtc) } + @Test + @DisplayName("현재 진행 중인 라이브 facade는 크리에이터 여부와 인증 성별 우선 유효 성별을 전달한다") + fun shouldPassCreatorRoleAndAuthGenderToLiveRecommendationQuery() { + val member = createMember(id = 100L, role = MemberRole.CREATOR, gender = Gender.FEMALE, authGender = 1) + Mockito.doReturn(true).`when`(preferenceService).canViewAdultContent(member) + Mockito.doReturn(emptyList()).`when`(queryService).findLiveRecommendations( + eqValue(0L), + eqValue(21), + eqValue(member.id), + eqValue(true), + eqValue(true), + eqValue(Gender.MALE) + ) + + facade.getOnAirLives(member, page = 0) + + Mockito.verify(queryService).findLiveRecommendations( + eqValue(0L), + eqValue(21), + eqValue(member.id), + eqValue(true), + eqValue(true), + eqValue(Gender.MALE) + ) + } + private fun record( id: Long, creatorProfileImage: String? = "profile.png", @@ -80,13 +126,24 @@ class HomeOnAirLiveFacadeTest { beginDateTime = beginDateTime ) - private fun createMember(id: Long): Member { + private fun createMember( + id: Long, + role: MemberRole = MemberRole.USER, + gender: Gender = Gender.NONE, + authGender: Int? = null + ): Member { return Member( email = "viewer$id@test.com", password = "password", nickname = "viewer$id", - role = MemberRole.USER - ).apply { this.id = id } + role = role, + gender = gender + ).apply { + this.id = id + authGender?.let { + Auth(name = "name", birth = "19900101", uniqueCi = "ci$id", di = "di$id", gender = it).member = this + } + } } private fun eqValue(value: T): T { diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/v2/recommendation/adapter/out/persistence/DefaultHomeRecommendationQueryRepositoryTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/recommendation/adapter/out/persistence/DefaultHomeRecommendationQueryRepositoryTest.kt index 56221dbf..05ee5da9 100644 --- a/src/test/kotlin/kr/co/vividnext/sodalive/v2/recommendation/adapter/out/persistence/DefaultHomeRecommendationQueryRepositoryTest.kt +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/recommendation/adapter/out/persistence/DefaultHomeRecommendationQueryRepositoryTest.kt @@ -32,7 +32,9 @@ import kr.co.vividnext.sodalive.explorer.profile.creatorCommunity.CreatorCommuni import kr.co.vividnext.sodalive.explorer.profile.creatorCommunity.comment.CreatorCommunityComment import kr.co.vividnext.sodalive.explorer.profile.creatorCommunity.like.CreatorCommunityLike import kr.co.vividnext.sodalive.i18n.Lang +import kr.co.vividnext.sodalive.live.room.GenderRestriction import kr.co.vividnext.sodalive.live.room.LiveRoom +import kr.co.vividnext.sodalive.member.Gender import kr.co.vividnext.sodalive.member.Member import kr.co.vividnext.sodalive.member.MemberKind import kr.co.vividnext.sodalive.member.MemberRole @@ -194,6 +196,194 @@ class DefaultHomeRecommendationQueryRepositoryTest @Autowired constructor( assertEquals(listOf(visibleLive.id), lives.map { it.liveRoomId }) } + @Test + @DisplayName("라이브 추천은 다른 크리에이터에게 크리에이터 입장 제한 라이브를 제외하고 방 생성자는 유지한다") + fun shouldExcludeCreatorJoinRestrictedLiveForOtherCreatorButKeepRoomOwner() { + val baseAt = LocalDateTime.of(2026, 9, 17, 10, 0) + val viewer = saveMember("creator-viewer", MemberRole.CREATOR) + val otherCreator = saveMember("restricted-live-creator", MemberRole.CREATOR) + val availableLive = saveLiveRoom(otherCreator, baseAt, channelName = "available-live") + saveLiveRoom( + otherCreator, + baseAt.plusMinutes(1), + channelName = "creator-restricted-live", + isAvailableJoinCreator = false + ) + val ownerLive = saveLiveRoom( + viewer, + baseAt.plusMinutes(2), + channelName = "owner-restricted-live", + isAvailableJoinCreator = false + ) + flushAndClear() + + val lives = repository.findLiveRecommendations( + limit = 10, + memberId = viewer.id, + isViewerCreator = true, + effectiveViewerGender = Gender.NONE + ) + + assertEquals(listOf(ownerLive.id, availableLive.id), lives.map { it.liveRoomId }) + } + + @Test + @DisplayName("라이브 추천은 일반 사용자에게 크리에이터 입장 제한 라이브를 노출한다") + fun shouldKeepCreatorJoinRestrictedLiveVisibleForUserViewer() { + val viewer = saveMember("user-viewer", MemberRole.USER) + val creator = saveMember("creator-restricted-live-creator", MemberRole.CREATOR) + val restrictedLive = saveLiveRoom( + creator, + LocalDateTime.of(2026, 9, 17, 10, 0), + channelName = "creator-restricted-live", + isAvailableJoinCreator = false + ) + flushAndClear() + + val lives = repository.findLiveRecommendations( + limit = 10, + memberId = viewer.id, + isViewerCreator = false, + effectiveViewerGender = Gender.NONE + ) + + assertEquals(listOf(restrictedLive.id), lives.map { it.liveRoomId }) + } + + @Test + @DisplayName("라이브 추천은 남성에게 남성 전용과 전체 라이브를 노출하고 방 생성자는 성별 제한에서 예외 처리한다") + fun shouldFilterFemaleOnlyLiveForMaleViewerButKeepOwnerLive() { + val baseAt = LocalDateTime.of(2026, 9, 17, 10, 0) + val viewer = saveMember("male-viewer", MemberRole.CREATOR) + val creator = saveMember("gender-live-creator", MemberRole.CREATOR) + val maleOnlyLive = saveLiveRoom( + creator, + baseAt, + channelName = "male-only-live", + genderRestriction = GenderRestriction.MALE_ONLY + ) + val allLive = saveLiveRoom(creator, baseAt.plusMinutes(1), channelName = "all-live") + saveLiveRoom( + creator, + baseAt.plusMinutes(2), + channelName = "female-only-live", + genderRestriction = GenderRestriction.FEMALE_ONLY + ) + val ownerLive = saveLiveRoom( + viewer, + baseAt.plusMinutes(3), + channelName = "owner-female-only-live", + genderRestriction = GenderRestriction.FEMALE_ONLY + ) + flushAndClear() + + val lives = repository.findLiveRecommendations( + limit = 10, + memberId = viewer.id, + isViewerCreator = true, + effectiveViewerGender = Gender.MALE + ) + + assertEquals(listOf(ownerLive.id, allLive.id, maleOnlyLive.id), lives.map { it.liveRoomId }) + } + + @Test + @DisplayName("라이브 추천은 여성 방 생성자에게 남성 전용인 자신의 라이브를 노출한다") + fun shouldKeepMaleOnlyLiveVisibleForFemaleRoomOwner() { + val baseAt = LocalDateTime.of(2026, 9, 17, 10, 0) + val viewer = saveMember("female-owner", MemberRole.CREATOR) + val creator = saveMember("female-owner-filter-creator", MemberRole.CREATOR) + val femaleOnlyLive = saveLiveRoom( + creator, + baseAt.plusMinutes(1), + channelName = "female-only-live", + genderRestriction = GenderRestriction.FEMALE_ONLY + ) + val ownerLive = saveLiveRoom( + viewer, + baseAt.plusMinutes(2), + channelName = "owner-male-only-live", + genderRestriction = GenderRestriction.MALE_ONLY + ) + flushAndClear() + + val lives = repository.findLiveRecommendations( + limit = 10, + memberId = viewer.id, + isViewerCreator = true, + effectiveViewerGender = Gender.FEMALE + ) + + assertEquals(listOf(ownerLive.id, femaleOnlyLive.id), lives.map { it.liveRoomId }) + } + + @Test + @DisplayName("라이브 추천은 여성에게 여성 전용과 전체 라이브만 노출한다") + fun shouldFilterMaleOnlyLiveForFemaleViewer() { + val baseAt = LocalDateTime.of(2026, 9, 17, 10, 0) + val viewer = saveMember("female-viewer", MemberRole.USER) + val creator = saveMember("female-filter-creator", MemberRole.CREATOR) + val femaleOnlyLive = saveLiveRoom( + creator, + baseAt.plusMinutes(2), + channelName = "female-only-live", + genderRestriction = GenderRestriction.FEMALE_ONLY + ) + val allLive = saveLiveRoom(creator, baseAt.plusMinutes(1), channelName = "all-live") + saveLiveRoom( + creator, + baseAt, + channelName = "male-only-live", + genderRestriction = GenderRestriction.MALE_ONLY + ) + flushAndClear() + + val lives = repository.findLiveRecommendations( + limit = 10, + memberId = viewer.id, + effectiveViewerGender = Gender.FEMALE + ) + + assertEquals(listOf(femaleOnlyLive.id, allLive.id), lives.map { it.liveRoomId }) + } + + @Test + @DisplayName("라이브 추천은 성별 미설정 또는 유효 성별이 없으면 성별 제한을 적용하지 않는다") + fun shouldNotApplyGenderRestrictionForNoneOrNullGender() { + val baseAt = LocalDateTime.of(2026, 9, 17, 10, 0) + val viewer = saveMember("unrestricted-viewer", MemberRole.USER) + val creator = saveMember("unrestricted-creator", MemberRole.CREATOR) + val maleOnlyLive = saveLiveRoom( + creator, + baseAt, + channelName = "male-only-live", + genderRestriction = GenderRestriction.MALE_ONLY + ) + val femaleOnlyLive = saveLiveRoom( + creator, + baseAt.plusMinutes(1), + channelName = "female-only-live", + genderRestriction = GenderRestriction.FEMALE_ONLY + ) + val allLive = saveLiveRoom(creator, baseAt.plusMinutes(2), channelName = "all-live") + flushAndClear() + + val noneGenderLives = repository.findLiveRecommendations( + limit = 10, + memberId = viewer.id, + effectiveViewerGender = Gender.NONE + ) + val nullGenderLives = repository.findLiveRecommendations( + limit = 10, + memberId = viewer.id, + effectiveViewerGender = null + ) + + val expectedIds = listOf(allLive.id, femaleOnlyLive.id, maleOnlyLive.id) + assertEquals(expectedIds, noneGenderLives.map { it.liveRoomId }) + assertEquals(expectedIds, nullGenderLives.map { it.liveRoomId }) + } + @Test @DisplayName("홈 배너는 활성 배너를 orders 오름차순 최대 20개로 조회하고 동일 orders는 랜덤 tie-breaker를 적용한다") fun shouldFindActiveHomeBannersWithOrderLimitAndRandomTieBreaker() { @@ -2856,7 +3046,9 @@ class DefaultHomeRecommendationQueryRepositoryTest @Autowired constructor( isAdult: Boolean = false, title: String = "live-${creator.nickname}-$beginDateTime", price: Int = 0, - isActive: Boolean = true + isActive: Boolean = true, + isAvailableJoinCreator: Boolean = true, + genderRestriction: GenderRestriction = GenderRestriction.ALL ): LiveRoom { val room = LiveRoom( title = title, @@ -2864,7 +3056,9 @@ class DefaultHomeRecommendationQueryRepositoryTest @Autowired constructor( beginDateTime = beginDateTime, numberOfPeople = 0, isAdult = isAdult, - price = price + price = price, + isAvailableJoinCreator = isAvailableJoinCreator, + genderRestriction = genderRestriction ) room.member = creator room.channelName = channelName diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/v2/recommendation/application/HomeRecommendationQueryServiceTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/recommendation/application/HomeRecommendationQueryServiceTest.kt index 6ad3d381..bbc96032 100644 --- a/src/test/kotlin/kr/co/vividnext/sodalive/v2/recommendation/application/HomeRecommendationQueryServiceTest.kt +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/recommendation/application/HomeRecommendationQueryServiceTest.kt @@ -2,6 +2,7 @@ package kr.co.vividnext.sodalive.v2.recommendation.application import kr.co.vividnext.sodalive.i18n.Lang import kr.co.vividnext.sodalive.i18n.LangContext +import kr.co.vividnext.sodalive.member.Gender import kr.co.vividnext.sodalive.v2.common.domain.CreatorActivityType import kr.co.vividnext.sodalive.v2.creator.channel.community.translation.application.CreatorCommunityDisplayContent import kr.co.vividnext.sodalive.v2.creator.channel.community.translation.application.CreatorCommunityTranslationService @@ -86,19 +87,23 @@ class HomeRecommendationQueryServiceTest { } @Test - @DisplayName("라이브 추천 조회는 paging과 성인 노출 여부를 조회 포트에 그대로 위임한다") - fun shouldDelegateLiveRecommendationQueryWithPagingAndAdultFlag() { + @DisplayName("라이브 추천 조회는 paging, 성인 노출, 크리에이터 여부와 유효 성별을 조회 포트에 그대로 위임한다") + fun shouldDelegateLiveRecommendationQueryWithViewerContext() { val recommendations = service.findLiveRecommendations( offset = 40, limit = 21, memberId = 100L, - includeAdultLives = true + includeAdultLives = true, + isViewerCreator = true, + effectiveViewerGender = Gender.MALE ) assertEquals(40, port.liveOffset) assertEquals(21, port.liveLimit) assertEquals(100L, port.liveMemberId) assertEquals(true, port.liveIncludeAdultLives) + assertEquals(true, port.liveIsViewerCreator) + assertEquals(Gender.MALE, port.liveEffectiveViewerGender) assertEquals(port.liveRecommendations, recommendations) } @@ -1092,6 +1097,8 @@ class HomeRecommendationQueryServiceTest { var liveOffset: Long? = null var liveMemberId: Long? = null var liveIncludeAdultLives: Boolean? = null + var liveIsViewerCreator: Boolean? = null + var liveEffectiveViewerGender: Gender? = null var bannerLimit: Int? = null var bannerMemberId: Long? = null var bannerIncludeAdultBanners: Boolean? = null @@ -1187,12 +1194,16 @@ class HomeRecommendationQueryServiceTest { offset: Long, limit: Int, memberId: Long?, - includeAdultLives: Boolean + includeAdultLives: Boolean, + isViewerCreator: Boolean, + effectiveViewerGender: Gender? ): List { liveOffset = offset liveLimit = limit liveMemberId = memberId liveIncludeAdultLives = includeAdultLives + liveIsViewerCreator = isViewerCreator + liveEffectiveViewerGender = effectiveViewerGender return liveRecommendations }