From 447735cad5e8bfdee6c543d201b79d7dbf21a892 Mon Sep 17 00:00:00 2001 From: Klaus Date: Tue, 24 Mar 2026 19:21:58 +0900 Subject: [PATCH] =?UTF-8?q?fix(content):=20=EC=B0=A8=EB=8B=A8=EB=90=9C=20?= =?UTF-8?q?=EA=B5=AC=EB=A7=A4=EC=9E=90=EC=9D=98=20=EC=98=A4=EB=94=94?= =?UTF-8?q?=EC=98=A4=20=EC=83=81=EC=84=B8=20=EC=A1=B0=ED=9A=8C=EB=A5=BC=20?= =?UTF-8?q?=ED=97=88=EC=9A=A9=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...0324_차단유저구매콘텐츠상세조회예외처리.md | 40 +++ .../sodalive/content/AudioContentService.kt | 26 +- .../content/AudioContentServiceTest.kt | 250 ++++++++++++++++++ 3 files changed, 307 insertions(+), 9 deletions(-) create mode 100644 docs/20260324_차단유저구매콘텐츠상세조회예외처리.md create mode 100644 src/test/kotlin/kr/co/vividnext/sodalive/content/AudioContentServiceTest.kt diff --git a/docs/20260324_차단유저구매콘텐츠상세조회예외처리.md b/docs/20260324_차단유저구매콘텐츠상세조회예외처리.md new file mode 100644 index 00000000..0c5d2fdb --- /dev/null +++ b/docs/20260324_차단유저구매콘텐츠상세조회예외처리.md @@ -0,0 +1,40 @@ +# 20260324 차단 유저 구매 콘텐츠 상세 조회 예외 처리 + +## 목적 +- 차단 관계가 있어도 조회자가 해당 콘텐츠를 구매한 경우에는 상세 조회를 허용한다. +- 차단 예외 경로에서는 댓글 및 시리즈 내 이전/다음 콘텐츠 정보를 노출하지 않는다. + +## 구현 체크리스트 +- [x] `AudioContentService.getDetail`에서 구매 여부(`isExistOrderedAndOrderType`)를 차단 판정보다 먼저 계산 +- [x] 차단 + 미구매인 경우 기존 `content.error.blocked_access` 예외 유지 +- [x] 차단 + 구매인 경우 상세 조회 허용 +- [x] 차단 + 구매인 경우 댓글 목록/댓글 수 조회 쿼리 미실행 및 응답을 `[]`, `0`으로 반환 +- [x] 차단 + 구매인 경우 `previousContent`, `nextContent` 조회 쿼리 미실행 및 응답을 `null`로 반환 +- [x] 정적 진단/테스트/빌드 검증 수행 + +## 완료 기준 (Pass/Fail) +- [x] AC1: 차단 + 미구매 요청 시 `SodaException(messageKey = "content.error.blocked_access")`가 발생해야 한다. + - QA: `getDetail` 분기 코드 확인 및 관련 테스트/빌드 통과 +- [x] AC2: 차단 + 구매 요청 시 상세 조회가 실패하지 않아야 한다. + - QA: `getDetail` 분기 코드 확인 및 관련 테스트/빌드 통과 +- [x] AC3: 차단 + 구매 요청 시 댓글/이전/다음 콘텐츠 조회 로직이 실행되지 않아야 한다. + - QA: 조건문 가드로 `commentRepository.findByContentId`, `totalCountCommentByContentId`, `findPreviousContent`, `findNextContent` 호출 차단 확인 + +## 검증 기록 +- 1차 구현: 진행 전 + - 무엇을: 요구사항 분석 및 기존 패턴 탐색 + - 왜: 차단/구매 예외 규칙을 기존 서비스 로직과 일관되게 반영하기 위해 + - 어떻게: `grep`, `ast-grep`, explore/librarian 백그라운드 탐색 수행 + +- 2차 구현: 기능 반영 및 시나리오 검증 + - 무엇을: `AudioContentService.getDetail`에서 차단+구매 예외를 허용하고, 해당 경로에서 댓글/이전·다음 조회를 생략하도록 분기 로직을 수정했다. 또한 `AudioContentServiceTest`를 추가해 차단+미구매/차단+구매 시나리오를 실제 메서드 호출로 검증했다. + - 왜: 요청사항(구매자 접근 허용 + 댓글/이전·다음 비조회)을 코드 레벨뿐 아니라 실행 가능한 테스트로 재현해 회귀를 방지하기 위해. + - 어떻게: + - 명령: `lsp_diagnostics` (`AudioContentService.kt`, `AudioContentServiceTest.kt`) + - 결과: 실패 (현재 실행 환경에 Kotlin LSP 미구성으로 `.kt` 진단 불가) + - 명령: `./gradlew test --tests "kr.co.vividnext.sodalive.content.AudioContentServiceTest"` + - 결과: 성공 (신규 2개 시나리오 테스트 통과) + - 명령: `./gradlew test` + - 결과: 성공 + - 명령: `./gradlew build` + - 결과: 성공 diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/content/AudioContentService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/content/AudioContentService.kt index c6a457b9..578f2ff3 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/content/AudioContentService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/content/AudioContentService.kt @@ -538,7 +538,15 @@ class AudioContentService( val creator = explorerQueryRepository.getMember(creatorId) ?: throw SodaException(messageKey = "content.error.user_not_found") - if (isBlockedBetweenMembers(memberId = member.id!!, creatorId = creatorId)) { + val (isExistsAudioContent, orderType) = orderRepository.isExistOrderedAndOrderType( + memberId = member.id!!, + contentId = audioContent.id!! + ) + + val isBlocked = isBlockedBetweenMembers(memberId = member.id!!, creatorId = creatorId) + val isBlockedAndPurchased = isBlocked && isExistsAudioContent + + if (isBlocked && !isExistsAudioContent) { throw SodaException(messageKey = "content.error.blocked_access") } @@ -547,11 +555,6 @@ class AudioContentService( memberId = member.id!! ) - val (isExistsAudioContent, orderType) = orderRepository.isExistOrderedAndOrderType( - memberId = member.id!!, - contentId = audioContent.id!! - ) - val orderSequence = if (isExistsAudioContent) { limitedEditionOrderRepository.getOrderSequence( contentId = audioContent.id!!, @@ -561,7 +564,12 @@ class AudioContentService( null } - val seriesId = repository.findSeriesIdByContentId(audioContent.id!!, isAdult) + val seriesId = if (isBlockedAndPurchased) { + null + } else { + repository.findSeriesIdByContentId(audioContent.id!!, isAdult) + } + val previousContent = if (seriesId != null) { repository.findPreviousContent( seriesId = seriesId, @@ -592,7 +600,7 @@ class AudioContentService( } // 댓글 - val commentList = if (audioContent.isCommentAvailable) { + val commentList = if (!isBlockedAndPurchased && audioContent.isCommentAvailable) { commentRepository.findByContentId( cloudFrontHost = coverImageHost, contentId = audioContent.id!!, @@ -607,7 +615,7 @@ class AudioContentService( } // 댓글 수 - val commentCount = if (audioContent.isCommentAvailable) { + val commentCount = if (!isBlockedAndPurchased && audioContent.isCommentAvailable) { commentRepository.totalCountCommentByContentId( contentId = audioContent.id!!, memberId = member.id!!, diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/content/AudioContentServiceTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/content/AudioContentServiceTest.kt new file mode 100644 index 00000000..e71efa89 --- /dev/null +++ b/src/test/kotlin/kr/co/vividnext/sodalive/content/AudioContentServiceTest.kt @@ -0,0 +1,250 @@ +package kr.co.vividnext.sodalive.content + +import com.fasterxml.jackson.databind.ObjectMapper +import kr.co.vividnext.sodalive.aws.cloudfront.AudioContentCloudFront +import kr.co.vividnext.sodalive.aws.s3.S3Uploader +import kr.co.vividnext.sodalive.common.SodaException +import kr.co.vividnext.sodalive.content.comment.AudioContentCommentRepository +import kr.co.vividnext.sodalive.content.hashtag.HashTagRepository +import kr.co.vividnext.sodalive.content.like.AudioContentLikeRepository +import kr.co.vividnext.sodalive.content.order.LimitedEditionOrderRepository +import kr.co.vividnext.sodalive.content.order.OrderRepository +import kr.co.vividnext.sodalive.content.order.OrderType +import kr.co.vividnext.sodalive.content.pin.PinContentRepository +import kr.co.vividnext.sodalive.content.theme.AudioContentTheme +import kr.co.vividnext.sodalive.content.theme.AudioContentThemeQueryRepository +import kr.co.vividnext.sodalive.content.theme.translation.ContentThemeTranslationRepository +import kr.co.vividnext.sodalive.content.translation.ContentTranslationRepository +import kr.co.vividnext.sodalive.explorer.ExplorerQueryRepository +import kr.co.vividnext.sodalive.i18n.LangContext +import kr.co.vividnext.sodalive.i18n.SodaMessageSource +import kr.co.vividnext.sodalive.i18n.translation.PapagoTranslationService +import kr.co.vividnext.sodalive.member.Member +import kr.co.vividnext.sodalive.member.block.BlockMemberRepository +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertNull +import org.junit.jupiter.api.Assertions.assertThrows +import org.junit.jupiter.api.Assertions.assertTrue +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 java.util.Optional + +class AudioContentServiceTest { + private lateinit var repository: AudioContentRepository + private lateinit var explorerQueryRepository: ExplorerQueryRepository + private lateinit var blockMemberRepository: BlockMemberRepository + private lateinit var hashTagRepository: HashTagRepository + private lateinit var orderRepository: OrderRepository + private lateinit var limitedEditionOrderRepository: LimitedEditionOrderRepository + private lateinit var themeQueryRepository: AudioContentThemeQueryRepository + private lateinit var playbackTrackingRepository: PlaybackTrackingRepository + private lateinit var commentRepository: AudioContentCommentRepository + private lateinit var audioContentLikeRepository: AudioContentLikeRepository + private lateinit var pinContentRepository: PinContentRepository + private lateinit var translationService: PapagoTranslationService + private lateinit var contentTranslationRepository: ContentTranslationRepository + private lateinit var s3Uploader: S3Uploader + private lateinit var audioContentCloudFront: AudioContentCloudFront + private lateinit var applicationEventPublisher: ApplicationEventPublisher + private lateinit var contentThemeTranslationRepository: ContentThemeTranslationRepository + + private lateinit var service: AudioContentService + + @BeforeEach + fun setUp() { + repository = Mockito.mock(AudioContentRepository::class.java) + explorerQueryRepository = Mockito.mock(ExplorerQueryRepository::class.java) + blockMemberRepository = Mockito.mock(BlockMemberRepository::class.java) + hashTagRepository = Mockito.mock(HashTagRepository::class.java) + orderRepository = Mockito.mock(OrderRepository::class.java) + limitedEditionOrderRepository = Mockito.mock(LimitedEditionOrderRepository::class.java) + themeQueryRepository = Mockito.mock(AudioContentThemeQueryRepository::class.java) + playbackTrackingRepository = Mockito.mock(PlaybackTrackingRepository::class.java) + commentRepository = Mockito.mock(AudioContentCommentRepository::class.java) + audioContentLikeRepository = Mockito.mock(AudioContentLikeRepository::class.java) + pinContentRepository = Mockito.mock(PinContentRepository::class.java) + translationService = Mockito.mock(PapagoTranslationService::class.java) + contentTranslationRepository = Mockito.mock(ContentTranslationRepository::class.java) + s3Uploader = Mockito.mock(S3Uploader::class.java) + audioContentCloudFront = Mockito.mock(AudioContentCloudFront::class.java) + applicationEventPublisher = Mockito.mock(ApplicationEventPublisher::class.java) + contentThemeTranslationRepository = Mockito.mock(ContentThemeTranslationRepository::class.java) + + service = AudioContentService( + repository = repository, + explorerQueryRepository = explorerQueryRepository, + blockMemberRepository = blockMemberRepository, + hashTagRepository = hashTagRepository, + orderRepository = orderRepository, + limitedEditionOrderRepository = limitedEditionOrderRepository, + themeQueryRepository = themeQueryRepository, + playbackTrackingRepository = playbackTrackingRepository, + commentRepository = commentRepository, + audioContentLikeRepository = audioContentLikeRepository, + pinContentRepository = pinContentRepository, + translationService = translationService, + contentTranslationRepository = contentTranslationRepository, + s3Uploader = s3Uploader, + objectMapper = ObjectMapper(), + audioContentCloudFront = audioContentCloudFront, + applicationEventPublisher = applicationEventPublisher, + messageSource = SodaMessageSource(), + langContext = LangContext(), + contentThemeTranslationRepository = contentThemeTranslationRepository, + audioContentBucket = "audio-bucket", + coverImageBucket = "cover-bucket", + coverImageHost = "https://cdn.test" + ) + } + + @Test + @DisplayName("차단 + 미구매 사용자 요청은 콘텐츠 상세에서 차단 예외를 반환한다") + fun shouldThrowBlockedAccessWhenBlockedAndNotPurchased() { + val viewer = createMember(id = 1000L, nickname = "viewer") + val creator = createMember(id = 2000L, nickname = "creator") + val audioContent = createAudioContent(creator) + + Mockito.`when`(repository.findById(audioContent.id!!)).thenReturn(Optional.of(audioContent)) + Mockito.`when`(explorerQueryRepository.getMember(creator.id!!)).thenReturn(creator) + Mockito.`when`( + orderRepository.isExistOrderedAndOrderType( + memberId = viewer.id!!, + contentId = audioContent.id!! + ) + ).thenReturn(Pair(false, null)) + Mockito.`when`(blockMemberRepository.isBlocked(blockedMemberId = viewer.id!!, memberId = creator.id!!)) + .thenReturn(true) + + val exception = assertThrows(SodaException::class.java) { + service.getDetail( + id = audioContent.id!!, + member = viewer, + isAdultContentVisible = false, + timezone = "Asia/Seoul" + ) + } + + assertEquals("content.error.blocked_access", exception.messageKey) + } + + @Test + @DisplayName("차단 + 구매 사용자 요청은 상세 조회를 허용하고 댓글/이전다음 조회를 생략한다") + fun shouldAllowDetailWhenBlockedAndPurchasedButSkipCommentAndNavigationQueries() { + val viewer = createMember(id = 1001L, nickname = "viewer") + val creator = createMember(id = 2001L, nickname = "creator") + val audioContent = createAudioContent(creator) + + Mockito.`when`(repository.findById(audioContent.id!!)).thenReturn(Optional.of(audioContent)) + Mockito.`when`(explorerQueryRepository.getMember(creator.id!!)).thenReturn(creator) + Mockito.`when`( + orderRepository.isExistOrderedAndOrderType( + memberId = viewer.id!!, + contentId = audioContent.id!! + ) + ).thenReturn(Pair(true, OrderType.KEEP)) + Mockito.`when`(blockMemberRepository.isBlocked(blockedMemberId = viewer.id!!, memberId = creator.id!!)) + .thenReturn(true) + Mockito.`when`(explorerQueryRepository.getCreatorFollowing(creator.id!!, viewer.id!!)).thenReturn(null) + Mockito.`when`( + limitedEditionOrderRepository.getOrderSequence( + contentId = audioContent.id!!, + memberId = viewer.id!! + ) + ).thenReturn(null) + Mockito.`when`( + audioContentCloudFront.generateSignedURL( + resourcePath = audioContent.content!!, + expirationTime = 7_200_000L + ) + ).thenReturn("https://signed.test/audio") + Mockito.`when`( + repository.getCreatorOtherContentList( + cloudfrontHost = "https://cdn.test", + contentId = audioContent.id!!, + creatorId = creator.id!!, + isAdult = false + ) + ).thenReturn(emptyList()) + Mockito.`when`( + repository.getSameThemeOtherContentList( + cloudfrontHost = "https://cdn.test", + contentId = audioContent.id!!, + themeId = audioContent.theme!!.id!!, + isAdult = false + ) + ).thenReturn(emptyList()) + Mockito.`when`(audioContentLikeRepository.totalCountAudioContentLike(audioContent.id!!)).thenReturn(0) + Mockito.`when`(audioContentLikeRepository.findByMemberIdAndContentId(viewer.id!!, audioContent.id!!)).thenReturn(null) + Mockito.`when`( + pinContentRepository.findByContentIdAndMemberId( + contentId = audioContent.id!!, + memberId = viewer.id!!, + active = true + ) + ).thenReturn(null) + Mockito.`when`(pinContentRepository.getPinContentList(memberId = viewer.id!!, active = true)).thenReturn(emptyList()) + Mockito.`when`( + contentThemeTranslationRepository.findByContentThemeIdAndLocale( + contentThemeId = audioContent.theme!!.id!!, + locale = "ko" + ) + ).thenReturn(null) + + val response = service.getDetail( + id = audioContent.id!!, + member = viewer, + isAdultContentVisible = false, + timezone = "Asia/Seoul" + ) + + assertTrue(response.existOrdered) + assertTrue(response.commentList.isEmpty()) + assertEquals(0, response.commentCount) + assertNull(response.previousContent) + assertNull(response.nextContent) + + Mockito.verify(repository, Mockito.never()).findSeriesIdByContentId(audioContent.id!!, false) + Mockito.verifyNoInteractions(commentRepository) + } + + private fun createMember(id: Long, nickname: String): Member { + val member = Member( + email = "$nickname@test.com", + password = "password", + nickname = nickname + ) + member.id = id + return member + } + + private fun createAudioContent(creator: Member): AudioContent { + val theme = AudioContentTheme(theme = "수면", image = "sleep.png") + theme.id = 300L + + val audioContent = AudioContent( + title = "테스트 제목", + detail = "테스트 상세 설명", + languageCode = null, + price = 100, + purchaseOption = PurchaseOption.BOTH, + isGeneratePreview = true, + isOnlyRental = false, + isAdult = false, + isPointAvailable = true, + isCommentAvailable = true, + isFullDetailVisible = true + ) + audioContent.id = 500L + audioContent.member = creator + audioContent.theme = theme + audioContent.content = "output/500/content.mp3" + audioContent.coverImage = "audio_content_cover/500/cover.jpg" + audioContent.duration = "00:10:00" + audioContent.isActive = true + + return audioContent + } +}