From 2160e7b9ddd113d67e8b50efb90def9296e4bacd Mon Sep 17 00:00:00 2001 From: Klaus Date: Sat, 28 Mar 2026 22:53:44 +0900 Subject: [PATCH] =?UTF-8?q?fix(live-room):=20=EC=A7=84=ED=96=89=EC=A4=91?= =?UTF-8?q?=20=EB=AA=A9=EB=A1=9D=20=EC=84=B1=EC=9D=B8=20=EB=85=B8=EC=B6=9C?= =?UTF-8?q?=20=EC=A0=95=EC=B1=85=EA=B3=BC=20JP=20=EA=B0=95=EC=A0=9C=20?= =?UTF-8?q?=EB=A7=A4=ED=95=91=20=EA=B2=80=EC=A6=9D=EC=9D=84=20=EC=A0=95?= =?UTF-8?q?=EB=A6=AC=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...260328_라이브진행중목록19금노출정책수정.md | 50 ++++ .../sodalive/live/room/LiveRoomService.kt | 2 +- .../MemberContentPreferenceCountryResolver.kt | 2 +- ...iveRoomServiceAdultVisibilityPolicyTest.kt | 265 ++++++++++++++++++ .../MemberContentPreferenceIntegrationTest.kt | 11 +- .../MemberContentPreferencePolicyTest.kt | 2 + 6 files changed, 327 insertions(+), 5 deletions(-) create mode 100644 docs/20260328_라이브진행중목록19금노출정책수정.md create mode 100644 src/test/kotlin/kr/co/vividnext/sodalive/live/room/LiveRoomServiceAdultVisibilityPolicyTest.kt diff --git a/docs/20260328_라이브진행중목록19금노출정책수정.md b/docs/20260328_라이브진행중목록19금노출정책수정.md new file mode 100644 index 00000000..5aa06701 --- /dev/null +++ b/docs/20260328_라이브진행중목록19금노출정책수정.md @@ -0,0 +1,50 @@ +# 라이브 진행중 목록 19금 노출 정책 수정 + +## 완료 기준 (Pass/Fail) +- [x] `LiveRoomStatus.NOW` 조회 시 사용자 성인 설정과 무관하게 19금 라이브 방이 포함된다. +- [x] 예약 조회(`getLiveRoomListReservationWithDate`, `getLiveRoomListReservationWithoutDate`)의 성인 설정 필터 동작은 기존과 동일하다. +- [x] 기존 코드 패턴을 유지하며 최소 범위로 변경된다. +- [x] 변경 파일 LSP 진단 에러가 0건이다. *(Kotlin LSP 미지원 환경으로 `lsp_diagnostics` 실행 불가, 테스트/빌드 성공으로 대체 검증)* +- [x] 관련 테스트/빌드 검증 명령이 성공한다. + +## 구현 체크리스트 +- [x] NOW/예약 목록 분기 및 성인 필터 전달 경로를 확인한다. +- [x] NOW 목록 조회 경로만 정책에 맞게 수정한다. *(QA: NOW 경로 호출 인자 검증)* +- [x] 예약 목록 조회 경로가 기존 로직을 유지하는지 검증한다. *(QA: 예약 경로 호출 인자/쿼리 유지 확인)* +- [x] 익명 사용자(member=null) NOW 조회에서 성인 필터 우회 범위가 과도하지 않도록 조건을 보강한다. *(2차 가정, 3차에서 정책 정정됨)* +- [x] 정책 정정 반영: NOW 목록은 익명 사용자도 노출 대상이며, 후속 상세/입장 단계에서 인증/성인 검증을 수행하도록 분기와 테스트를 재정렬한다. +- [x] `FORCED_JP_MEMBER_IDS`의 `37543L` 강제 매핑 회귀 테스트를 추가한다. *(QA: 정책/통합 테스트에 ID 37543L 검증 추가)* +- [x] 관련 테스트와 빌드 검증을 수행하고 결과를 문서에 기록한다. + +## 검증 기록 +### 1차 구현 +- 무엇을: `LiveRoomService.getRoomList`의 NOW 분기에서 `isAdult = true`를 전달하도록 수정하고, 예약 분기는 기존 `isAdult` 전달을 유지했다. 또한 NOW/예약 전달 정책을 검증하는 `LiveRoomServiceAdultVisibilityPolicyTest`를 추가했다. +- 왜: 진행 중 라이브 목록은 사용자 성인 설정과 무관하게 19금 방을 노출하고, 예약 목록은 기존 정책대로 사용자 설정을 반영해야 하기 때문이다. +- 어떻게: + - 전달값 확인: `grep`으로 NOW/예약 분기의 `isAdult` 전달값 확인 (`isAdult = true` / `isAdult = isAdult`). + - LSP 진단 시도: `lsp_diagnostics` for `LiveRoomService.kt`, `LiveRoomServiceAdultVisibilityPolicyTest.kt` → **불가(환경에 Kotlin LSP 서버 미구성)** + - 정책 단위 테스트: `./gradlew test --tests "kr.co.vividnext.sodalive.live.room.LiveRoomServiceAdultVisibilityPolicyTest"` → **성공(BUILD SUCCESSFUL)** + - 관련 선호도 테스트: `./gradlew test --tests "kr.co.vividnext.sodalive.member.contentpreference.MemberContentPreferenceServiceTest" --tests "kr.co.vividnext.sodalive.member.contentpreference.MemberContentPreferencePolicyTest" --tests "kr.co.vividnext.sodalive.member.contentpreference.MemberContentPreferenceIntegrationTest"` → **성공(BUILD SUCCESSFUL)** + - 전체 빌드: `./gradlew build` → **성공(BUILD SUCCESSFUL)** + +### 2차 수정 (리뷰 피드백 반영) +- 무엇을: `LiveRoomService.getRoomList`의 NOW 분기에서 `isAdult` 전달값을 `member != null || isAdult`로 조정해 로그인 사용자에게만 우회가 적용되도록 보강했다. 또한 `LiveRoomServiceAdultVisibilityPolicyTest`에 비로그인 NOW 조회 회귀 케이스를 추가하고, `MemberContentPreferencePolicyTest`/`MemberContentPreferenceIntegrationTest`에 `37543L -> JP` 강제 매핑 검증을 추가했다. +- 왜: 기존 `isAdult = true` 고정은 익명 사용자까지 성인 진행중 라이브를 노출할 수 있어 정책 범위가 과도해질 수 있으며, 강제 JP ID 추가(`37543L`)는 테스트로 고정해 회귀를 방지해야 하기 때문이다. +- 어떻게: + - LSP 진단 시도: `lsp_diagnostics` for 변경된 Kotlin 파일 4개 → **불가(환경에 Kotlin LSP 서버 미구성)** + - 정책/회귀 테스트: `./gradlew test --tests "kr.co.vividnext.sodalive.live.room.LiveRoomServiceAdultVisibilityPolicyTest" --tests "kr.co.vividnext.sodalive.member.contentpreference.MemberContentPreferencePolicyTest" --tests "kr.co.vividnext.sodalive.member.contentpreference.MemberContentPreferenceIntegrationTest"` → **성공(BUILD SUCCESSFUL)** + - 전체 빌드(ktlint 포함): `./gradlew build` → **성공(BUILD SUCCESSFUL)** + +### 정정 +- 정정 대상: `2차 수정 (리뷰 피드백 반영)`의 정책 가정(익명 NOW 노출 제한) +- 사유: 요구사항 재확인 결과, NOW 목록에서 익명 사용자 노출은 의도된 기능이며 상세/입장 단계에서 인증 및 성인 검증을 수행하는 정책으로 확정되었다. +- 변경 내용: NOW 분기의 익명 제한 보강(`member != null || isAdult`)을 제거하고, 익명 포함 우회(`isAdult = true`)로 복원했다. 관련 회귀 테스트도 익명 우회 기대값으로 정렬했다. + +### 3차 수정 (정책 정정 반영) +- 무엇을: `LiveRoomService.getRoomList` NOW 분기의 `isAdult` 전달값을 `isAdult = true`로 복원했다. `LiveRoomServiceAdultVisibilityPolicyTest`의 익명 NOW 케이스를 `isAdult = true` 기대로 수정하고, 테스트명/DisplayName을 정책 의미에 맞게 변경했다. +- 왜: NOW 목록은 익명 사용자에게도 노출하되, 실제 터치 후 상세/입장 단계에서 인증 및 성인 검증(`live.room.adult_verification_required`)을 수행하는 것이 의도된 정책이기 때문이다. +- 어떻게: + - 탐색 근거 수집: Explore/Librarian + `grep` + `sg`로 NOW 노출 경로, 후속 인증 가드, 테스트 기대값을 재확인했다. (`rg`는 실행 환경에 미설치로 대체 탐색 수행) + - LSP 진단 시도: `lsp_diagnostics` for 변경된 Kotlin 파일들 → **불가(환경에 Kotlin LSP 서버 미구성)** + - 정책/회귀 테스트: `./gradlew test --tests "kr.co.vividnext.sodalive.live.room.LiveRoomServiceAdultVisibilityPolicyTest" --tests "kr.co.vividnext.sodalive.member.contentpreference.MemberContentPreferencePolicyTest" --tests "kr.co.vividnext.sodalive.member.contentpreference.MemberContentPreferenceIntegrationTest"` → **성공(BUILD SUCCESSFUL)** + - 전체 빌드(ktlint 포함): `./gradlew build` → **성공(BUILD SUCCESSFUL)** diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/live/room/LiveRoomService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/live/room/LiveRoomService.kt index 6628337e..ded3c47f 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/live/room/LiveRoomService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/live/room/LiveRoomService.kt @@ -224,7 +224,7 @@ class LiveRoomService( timezone, memberId = member?.id, isCreator = member?.role == MemberRole.CREATOR, - isAdult = isAdult, + isAdult = true, effectiveGender = effectiveGender ) } else if (dateString != null) { diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/member/contentpreference/MemberContentPreferenceCountryResolver.kt b/src/main/kotlin/kr/co/vividnext/sodalive/member/contentpreference/MemberContentPreferenceCountryResolver.kt index e615b725..3c28694e 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/member/contentpreference/MemberContentPreferenceCountryResolver.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/member/contentpreference/MemberContentPreferenceCountryResolver.kt @@ -3,7 +3,7 @@ package kr.co.vividnext.sodalive.member.contentpreference import kr.co.vividnext.sodalive.member.Member private val FORCED_KR_MEMBER_IDS = setOf(16L, 17L) -private val FORCED_JP_MEMBER_IDS = setOf(2L, 29721L, 32050L, 40850L) +private val FORCED_JP_MEMBER_IDS = setOf(2L, 29721L, 32050L, 37543L, 40850L) fun resolveCountryCodeWithForcedMapping(member: Member, requestCountryCode: String?): String { val memberId = member.id diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/live/room/LiveRoomServiceAdultVisibilityPolicyTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/live/room/LiveRoomServiceAdultVisibilityPolicyTest.kt new file mode 100644 index 00000000..acc0daae --- /dev/null +++ b/src/test/kotlin/kr/co/vividnext/sodalive/live/room/LiveRoomServiceAdultVisibilityPolicyTest.kt @@ -0,0 +1,265 @@ +package kr.co.vividnext.sodalive.live.room + +import com.fasterxml.jackson.databind.ObjectMapper +import kr.co.vividnext.sodalive.agora.RtcTokenBuilder +import kr.co.vividnext.sodalive.agora.RtmTokenBuilder +import kr.co.vividnext.sodalive.aws.s3.S3Uploader +import kr.co.vividnext.sodalive.can.CanRepository +import kr.co.vividnext.sodalive.can.charge.ChargeRepository +import kr.co.vividnext.sodalive.can.payment.CanPaymentService +import kr.co.vividnext.sodalive.can.use.UseCanCalculateRepository +import kr.co.vividnext.sodalive.content.ContentType +import kr.co.vividnext.sodalive.explorer.ExplorerQueryRepository +import kr.co.vividnext.sodalive.explorer.profile.CreatorDonationRankingService +import kr.co.vividnext.sodalive.fcm.PushTokenRepository +import kr.co.vividnext.sodalive.i18n.LangContext +import kr.co.vividnext.sodalive.i18n.SodaMessageSource +import kr.co.vividnext.sodalive.live.reservation.LiveReservationRepository +import kr.co.vividnext.sodalive.live.room.cancel.LiveRoomCancelRepository +import kr.co.vividnext.sodalive.live.room.info.LiveRoomInfoRedisRepository +import kr.co.vividnext.sodalive.live.room.kickout.LiveRoomKickOutService +import kr.co.vividnext.sodalive.live.room.menu.LiveRoomMenuService +import kr.co.vividnext.sodalive.live.room.visit.LiveRoomVisitService +import kr.co.vividnext.sodalive.live.roulette.NewRouletteRepository +import kr.co.vividnext.sodalive.live.signature.SignatureCanRepository +import kr.co.vividnext.sodalive.live.tag.LiveTagRepository +import kr.co.vividnext.sodalive.member.Gender +import kr.co.vividnext.sodalive.member.Member +import kr.co.vividnext.sodalive.member.MemberRepository +import kr.co.vividnext.sodalive.member.block.BlockMemberRepository +import kr.co.vividnext.sodalive.member.contentpreference.MemberContentPreferenceService +import kr.co.vividnext.sodalive.member.contentpreference.ViewerContentPreference +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Test +import org.mockito.Mockito +import org.springframework.context.ApplicationEventPublisher +import org.springframework.data.domain.PageRequest + +class LiveRoomServiceAdultVisibilityPolicyTest { + private lateinit var menuService: LiveRoomMenuService + private lateinit var messageSource: SodaMessageSource + private lateinit var langContext: LangContext + private lateinit var repository: LiveRoomRepository + private lateinit var rouletteRepository: NewRouletteRepository + private lateinit var roomInfoRepository: LiveRoomInfoRedisRepository + private lateinit var roomCancelRepository: LiveRoomCancelRepository + private lateinit var kickOutService: LiveRoomKickOutService + private lateinit var blockMemberRepository: BlockMemberRepository + private lateinit var signatureCanRepository: SignatureCanRepository + private lateinit var applicationEventPublisher: ApplicationEventPublisher + private lateinit var useCanCalculateRepository: UseCanCalculateRepository + private lateinit var reservationRepository: LiveReservationRepository + private lateinit var explorerQueryRepository: ExplorerQueryRepository + private lateinit var creatorDonationRankingService: CreatorDonationRankingService + private lateinit var roomVisitService: LiveRoomVisitService + private lateinit var canPaymentService: CanPaymentService + private lateinit var chargeRepository: ChargeRepository + private lateinit var pushTokenRepository: PushTokenRepository + private lateinit var memberRepository: MemberRepository + private lateinit var tagRepository: LiveTagRepository + private lateinit var memberContentPreferenceService: MemberContentPreferenceService + private lateinit var canRepository: CanRepository + private lateinit var objectMapper: ObjectMapper + private lateinit var s3Uploader: S3Uploader + private lateinit var rtcTokenBuilder: RtcTokenBuilder + private lateinit var rtmTokenBuilder: RtmTokenBuilder + + private lateinit var service: LiveRoomService + + @BeforeEach + fun setup() { + menuService = mock() + messageSource = mock() + langContext = LangContext() + repository = mock() + rouletteRepository = mock() + roomInfoRepository = mock() + roomCancelRepository = mock() + kickOutService = mock() + blockMemberRepository = mock() + signatureCanRepository = mock() + applicationEventPublisher = mock() + useCanCalculateRepository = mock() + reservationRepository = mock() + explorerQueryRepository = mock() + creatorDonationRankingService = mock() + roomVisitService = mock() + canPaymentService = mock() + chargeRepository = mock() + pushTokenRepository = mock() + memberRepository = mock() + tagRepository = mock() + memberContentPreferenceService = mock() + canRepository = mock() + objectMapper = mock() + s3Uploader = mock() + rtcTokenBuilder = mock() + rtmTokenBuilder = mock() + + service = LiveRoomService( + menuService = menuService, + messageSource = messageSource, + langContext = langContext, + repository = repository, + rouletteRepository = rouletteRepository, + roomInfoRepository = roomInfoRepository, + roomCancelRepository = roomCancelRepository, + kickOutService = kickOutService, + blockMemberRepository = blockMemberRepository, + signatureCanRepository = signatureCanRepository, + applicationEventPublisher = applicationEventPublisher, + useCanCalculateRepository = useCanCalculateRepository, + reservationRepository = reservationRepository, + explorerQueryRepository = explorerQueryRepository, + creatorDonationRankingService = creatorDonationRankingService, + roomVisitService = roomVisitService, + canPaymentService = canPaymentService, + chargeRepository = chargeRepository, + pushTokenRepository = pushTokenRepository, + memberRepository = memberRepository, + tagRepository = tagRepository, + memberContentPreferenceService = memberContentPreferenceService, + canRepository = canRepository, + objectMapper = objectMapper, + s3Uploader = s3Uploader, + rtcTokenBuilder = rtcTokenBuilder, + rtmTokenBuilder = rtmTokenBuilder, + agoraAppId = "test-agora-app-id", + agoraAppCertificate = "test-agora-app-certificate", + coverImageBucket = "test-cover-image-bucket", + cloudFrontHost = "https://test-cloudfront-host" + ) + + Mockito.`when`(pushTokenRepository.findByMemberIds(listOf())).thenReturn(listOf()) + } + + @Test + @DisplayName("NOW 목록 조회는 사용자 성인 설정이 false여도 성인 방 필터를 적용하지 않는다") + fun shouldBypassAdultPreferenceForNowRooms() { + val member = createMember(id = 100L) + Mockito.`when`(memberContentPreferenceService.resolveForQuery(member)).thenReturn(createPreference(isAdult = false)) + Mockito.`when`( + repository.getLiveRoomListNow( + offset = 0L, + limit = 20L, + timezone = "Asia/Seoul", + memberId = 100L, + isCreator = false, + isAdult = true, + effectiveGender = Gender.NONE + ) + ).thenReturn(emptyList()) + + val response = service.getRoomList( + dateString = null, + status = LiveRoomStatus.NOW, + pageable = PageRequest.of(0, 20), + member = member, + timezone = "Asia/Seoul" + ) + + assertEquals(0, response.size) + Mockito.verify(repository).getLiveRoomListNow( + offset = 0L, + limit = 20L, + timezone = "Asia/Seoul", + memberId = 100L, + isCreator = false, + isAdult = true, + effectiveGender = Gender.NONE + ) + } + + @Test + @DisplayName("NOW 목록 조회는 비로그인 사용자도 성인 방 필터를 우회한다") + fun shouldBypassAdultPreferenceForAnonymousNowRooms() { + Mockito.`when`( + repository.getLiveRoomListNow( + offset = 0L, + limit = 20L, + timezone = "Asia/Seoul", + memberId = null, + isCreator = false, + isAdult = true, + effectiveGender = null + ) + ).thenReturn(emptyList()) + + val response = service.getRoomList( + dateString = null, + status = LiveRoomStatus.NOW, + pageable = PageRequest.of(0, 20), + member = null, + timezone = "Asia/Seoul" + ) + + assertEquals(0, response.size) + Mockito.verify(repository).getLiveRoomListNow( + offset = 0L, + limit = 20L, + timezone = "Asia/Seoul", + memberId = null, + isCreator = false, + isAdult = true, + effectiveGender = null + ) + } + + @Test + @DisplayName("예약 목록 조회는 기존처럼 사용자 성인 설정값을 유지한다") + fun shouldKeepAdultPreferenceForReservationRooms() { + val member = createMember(id = 200L) + Mockito.`when`(memberContentPreferenceService.resolveForQuery(member)).thenReturn(createPreference(isAdult = false)) + Mockito.`when`( + repository.getLiveRoomListReservationWithoutDate( + timezone = "Asia/Seoul", + memberId = 200L, + isCreator = false, + isAdult = false, + effectiveGender = Gender.NONE + ) + ).thenReturn(emptyList()) + + val response = service.getRoomList( + dateString = null, + status = LiveRoomStatus.RESERVATION, + pageable = PageRequest.of(0, 20), + member = member, + timezone = "Asia/Seoul" + ) + + assertEquals(0, response.size) + Mockito.verify(repository).getLiveRoomListReservationWithoutDate( + timezone = "Asia/Seoul", + memberId = 200L, + isCreator = false, + isAdult = false, + effectiveGender = Gender.NONE + ) + } + + private fun createMember(id: Long): Member { + val member = Member( + email = "member$id@test.com", + password = "password", + nickname = "member$id" + ) + member.id = id + return member + } + + private fun createPreference(isAdult: Boolean): ViewerContentPreference { + return ViewerContentPreference( + countryCode = "KR", + isAdultContentVisible = isAdult, + contentType = ContentType.ALL, + isAdult = isAdult + ) + } + + private inline fun mock(): T { + return Mockito.mock(T::class.java) + } +} diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/member/contentpreference/MemberContentPreferenceIntegrationTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/member/contentpreference/MemberContentPreferenceIntegrationTest.kt index 76dab580..c75ec2ff 100644 --- a/src/test/kotlin/kr/co/vividnext/sodalive/member/contentpreference/MemberContentPreferenceIntegrationTest.kt +++ b/src/test/kotlin/kr/co/vividnext/sodalive/member/contentpreference/MemberContentPreferenceIntegrationTest.kt @@ -29,7 +29,7 @@ class MemberContentPreferenceIntegrationTest @Autowired constructor( private val entityManager: EntityManager ) { companion object { - private val FORCED_MEMBER_IDS = setOf(2L, 16L, 17L, 29721L, 32050L, 40850L) + private val FORCED_MEMBER_IDS = setOf(2L, 16L, 17L, 29721L, 32050L, 37543L, 40850L) } private lateinit var service: MemberContentPreferenceService @@ -183,10 +183,15 @@ class MemberContentPreferenceIntegrationTest @Autowired constructor( fun shouldReturnForcedCountryCodeRegardlessOfHeader() { countryContext.setCountryCode("US") - val jpMember = Member(email = "jp@test.com", password = "password", nickname = "jp-member").apply { id = 2L } - val krMember = Member(email = "kr@test.com", password = "password", nickname = "kr-member").apply { id = 16L } + val jpMember = Member(email = "jp@test.com", password = "password", nickname = "jp-member") + .apply { id = 2L } + val jpMemberNew = Member(email = "jp-new@test.com", password = "password", nickname = "jp-member-new") + .apply { id = 37543L } + val krMember = Member(email = "kr@test.com", password = "password", nickname = "kr-member") + .apply { id = 16L } assertEquals("JP", service.resolveCountryCode(jpMember)) + assertEquals("JP", service.resolveCountryCode(jpMemberNew)) assertEquals("KR", service.resolveCountryCode(krMember)) } diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/member/contentpreference/MemberContentPreferencePolicyTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/member/contentpreference/MemberContentPreferencePolicyTest.kt index 287c78b3..2af0aa4e 100644 --- a/src/test/kotlin/kr/co/vividnext/sodalive/member/contentpreference/MemberContentPreferencePolicyTest.kt +++ b/src/test/kotlin/kr/co/vividnext/sodalive/member/contentpreference/MemberContentPreferencePolicyTest.kt @@ -32,9 +32,11 @@ class MemberContentPreferencePolicyTest { setRequestCountry("US") val forcedJpMember = createMember(id = 2L, countryCode = "KR") + val forcedJpMemberNew = createMember(id = 37543L, countryCode = "KR") val forcedKrMember = createMember(id = 16L, countryCode = "US") assertEquals("JP", resolveCountryCodeByPolicy(forcedJpMember)) + assertEquals("JP", resolveCountryCodeByPolicy(forcedJpMemberNew)) assertEquals("KR", resolveCountryCodeByPolicy(forcedKrMember)) }