diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/community/application/CreatorChannelCommunityQueryService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/community/application/CreatorChannelCommunityQueryService.kt new file mode 100644 index 00000000..7769b621 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/community/application/CreatorChannelCommunityQueryService.kt @@ -0,0 +1,140 @@ +package kr.co.vividnext.sodalive.v2.creator.channel.community.application + +import kr.co.vividnext.sodalive.aws.cloudfront.AudioContentCloudFront +import kr.co.vividnext.sodalive.common.SodaException +import kr.co.vividnext.sodalive.i18n.LangContext +import kr.co.vividnext.sodalive.i18n.SodaMessageSource +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.member.contentpreference.isAdultVisibleByPolicy +import kr.co.vividnext.sodalive.v2.common.domain.toCdnUrl +import kr.co.vividnext.sodalive.v2.creator.channel.community.domain.CreatorChannelCommunityPost +import kr.co.vividnext.sodalive.v2.creator.channel.community.domain.CreatorChannelCommunityQueryPolicy +import kr.co.vividnext.sodalive.v2.creator.channel.community.domain.CreatorChannelCommunityTab +import kr.co.vividnext.sodalive.v2.creator.channel.community.port.out.CreatorChannelCommunityCreatorRecord +import kr.co.vividnext.sodalive.v2.creator.channel.community.port.out.CreatorChannelCommunityPostRecord +import kr.co.vividnext.sodalive.v2.creator.channel.community.port.out.CreatorChannelCommunityQueryPort +import org.springframework.beans.factory.ObjectProvider +import org.springframework.beans.factory.annotation.Value +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional +import java.time.LocalDateTime + +@Service +@Transactional(readOnly = true) +class CreatorChannelCommunityQueryService( + private val queryPortProvider: ObjectProvider, + private val queryPolicy: CreatorChannelCommunityQueryPolicy, + private val memberContentPreferenceService: MemberContentPreferenceService, + private val audioContentCloudFront: AudioContentCloudFront, + private val messageSource: SodaMessageSource, + private val langContext: LangContext, + @Value("\${cloud.aws.cloud-front.host}") + private val cloudFrontHost: String +) { + fun getCommunityTab( + creatorId: Long, + viewer: Member, + page: Int?, + size: Int?, + now: LocalDateTime = LocalDateTime.now() + ): CreatorChannelCommunityTab { + val communityPage = queryPolicy.createPage(page, size) + val queryPort = queryPortProvider.getObject() + val viewerId = viewer.id!! + val creator = queryPort.findCreator(creatorId, viewerId) + ?: throw SodaException(messageKey = "member.validation.user_not_found") + + if (queryPort.existsBlockedBetween(viewerId, creatorId)) { + val messageTemplate = messageSource + .getMessage("explorer.creator.blocked_access", langContext.lang) + .orEmpty() + throw SodaException(message = String.format(messageTemplate, creator.nickname)) + } + + validateCreatorRole(creator) + + val preference = memberContentPreferenceService.getStoredPreference(viewer) + val canViewAdultContent = isAdultVisibleByPolicy(viewer, preference.isAdultContentVisible) + val fetchedPosts = queryPort.findCommunityPosts( + creatorId = creatorId, + viewerId = viewerId, + canViewAdultContent = canViewAdultContent, + offset = communityPage.offset, + limit = communityPage.fetchLimit + ) + + return CreatorChannelCommunityTab( + communityPostCount = queryPort.countCommunityPosts( + creatorId = creatorId, + viewerId = viewerId, + canViewAdultContent = canViewAdultContent + ), + communityPosts = queryPolicy.limitItems(fetchedPosts, communityPage).map { it.toDomain(viewerId) }, + page = communityPage, + hasNext = queryPolicy.hasNext(fetchedPosts, communityPage) + ) + } + + fun findHomeCommunityPosts( + creatorId: Long, + viewerId: Long, + isPinned: Boolean, + canViewAdultContent: Boolean, + limit: Int + ): List { + return queryPortProvider.getObject() + .findHomeCommunityPosts( + creatorId = creatorId, + viewerId = viewerId, + isPinned = isPinned, + canViewAdultContent = canViewAdultContent, + limit = limit + ) + .map { it.toDomain(viewerId) } + } + + private fun validateCreatorRole(creator: CreatorChannelCommunityCreatorRecord) { + when (creator.role) { + MemberRole.CREATOR -> return + else -> throw SodaException(messageKey = "member.validation.creator_not_found") + } + } + + private fun CreatorChannelCommunityPostRecord.toDomain(viewerId: Long): CreatorChannelCommunityPost { + val canAccessPaidContent = price <= 0 || viewerId == creatorId || existOrdered + return CreatorChannelCommunityPost( + postId = postId, + creatorId = creatorId, + creatorNickname = creatorNickname, + creatorProfileUrl = creatorProfilePath.toCdnUrl(cloudFrontHost) ?: defaultProfileImageUrl(), + imageUrl = if (canAccessPaidContent) imagePath.toCdnUrl(cloudFrontHost) else null, + audioUrl = if (canAccessPaidContent) audioPath.toSignedAudioUrl() else null, + content = queryPolicy.maskPaidContent( + content = content, + price = price, + isCreatorSelf = viewerId == creatorId, + existOrdered = existOrdered + ), + price = price, + createdAt = createdAt, + existOrdered = existOrdered || viewerId == creatorId, + isCommentAvailable = isCommentAvailable, + likeCount = likeCount, + commentCount = commentCount, + isPinned = isPinned + ) + } + + private fun String?.toSignedAudioUrl(): String? { + if (isNullOrBlank()) return null + return audioContentCloudFront.generateSignedURL(this, AUDIO_SIGNED_URL_EXPIRATION_MILLIS) + } + + private fun defaultProfileImageUrl(): String = "$cloudFrontHost/profile/default-profile.png" + + companion object { + private const val AUDIO_SIGNED_URL_EXPIRATION_MILLIS = 1000L * 60 * 30 + } +} diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/community/application/CreatorChannelCommunityQueryServiceTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/community/application/CreatorChannelCommunityQueryServiceTest.kt new file mode 100644 index 00000000..d8bbaac5 --- /dev/null +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/community/application/CreatorChannelCommunityQueryServiceTest.kt @@ -0,0 +1,314 @@ +package kr.co.vividnext.sodalive.v2.creator.channel.community.application + +import kr.co.vividnext.sodalive.aws.cloudfront.AudioContentCloudFront +import kr.co.vividnext.sodalive.common.SodaException +import kr.co.vividnext.sodalive.content.ContentType +import kr.co.vividnext.sodalive.i18n.Lang +import kr.co.vividnext.sodalive.i18n.LangContext +import kr.co.vividnext.sodalive.i18n.SodaMessageSource +import kr.co.vividnext.sodalive.member.Member +import kr.co.vividnext.sodalive.member.MemberProvider +import kr.co.vividnext.sodalive.member.MemberRole +import kr.co.vividnext.sodalive.member.contentpreference.MemberContentPreferenceService +import kr.co.vividnext.sodalive.member.contentpreference.ViewerContentPreference +import kr.co.vividnext.sodalive.v2.creator.channel.community.domain.CreatorChannelCommunityQueryPolicy +import kr.co.vividnext.sodalive.v2.creator.channel.community.port.out.CreatorChannelCommunityCreatorRecord +import kr.co.vividnext.sodalive.v2.creator.channel.community.port.out.CreatorChannelCommunityPostRecord +import kr.co.vividnext.sodalive.v2.creator.channel.community.port.out.CreatorChannelCommunityQueryPort +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.DisplayName +import org.junit.jupiter.api.Test +import org.mockito.Mockito +import org.springframework.beans.factory.ObjectProvider +import java.time.LocalDateTime + +class CreatorChannelCommunityQueryServiceTest { + @Test + @DisplayName("커뮤니티 탭 서비스는 요청 fallback과 조회 컨텍스트를 port에 전달하고 탭을 조립한다") + fun shouldResolveRequestFallbacksAndAssembleCommunityTab() { + val port = FakeCreatorChannelCommunityQueryPort().apply { + communityPostCount = 60 + communityPosts = (1L..51L).map { communityPostRecord(it, price = 0) } + } + val audioContentCloudFront = Mockito.mock(AudioContentCloudFront::class.java) + Mockito.`when`(audioContentCloudFront.generateSignedURL("audio/1.mp3", 1000 * 60 * 30)) + .thenReturn("https://signed.test/audio/1.mp3") + val service = createService(port, audioContentCloudFront, canViewAdultContent = false) + val viewer = createMember(id = 10L) + val now = LocalDateTime.of(2026, 6, 21, 10, 0) + + val tab = service.getCommunityTab( + creatorId = 1L, + viewer = viewer, + page = -1, + size = 100, + now = now + ) + + assertEquals(60, tab.communityPostCount) + assertEquals(0, tab.page.page) + assertEquals(50, tab.page.size) + assertEquals(0L, port.listOffset) + assertEquals(51, port.listLimit) + assertEquals(false, port.listCanViewAdultContent) + assertEquals(false, port.countCanViewAdultContent) + assertEquals(50, tab.communityPosts.size) + assertTrue(tab.hasNext) + assertEquals("https://cdn.test/profile/1.png", tab.communityPosts.first().creatorProfileUrl) + assertEquals("https://cdn.test/image/1.png", tab.communityPosts.first().imageUrl) + assertEquals("https://signed.test/audio/1.mp3", tab.communityPosts.first().audioUrl) + } + + @Test + @DisplayName("creatorId에 해당하는 회원이 없으면 user_not_found 예외를 던진다") + fun shouldThrowUserNotFoundWhenCreatorMemberDoesNotExist() { + val port = FakeCreatorChannelCommunityQueryPort().apply { creator = null } + val service = createService(port) + val viewer = createMember(id = 10L) + + val exception = assertThrows(SodaException::class.java) { + service.getCommunityTab(1L, viewer, null, null, LocalDateTime.of(2026, 6, 21, 10, 0)) + } + + assertEquals("member.validation.user_not_found", exception.messageKey) + } + + @Test + @DisplayName("대상 회원 role이 CREATOR가 아니면 creator_not_found 예외를 던진다") + fun shouldThrowCreatorNotFoundWhenMemberIsNotCreator() { + val port = FakeCreatorChannelCommunityQueryPort().apply { creator = creator?.copy(role = MemberRole.USER) } + val service = createService(port) + val viewer = createMember(id = 10L) + + val exception = assertThrows(SodaException::class.java) { + service.getCommunityTab(1L, viewer, null, null, LocalDateTime.of(2026, 6, 21, 10, 0)) + } + + assertEquals("member.validation.creator_not_found", exception.messageKey) + } + + @Test + @DisplayName("차단 관계가 있으면 기존 차단 메시지 예외를 던진다") + fun shouldThrowBlockedAccessWhenViewerAndTargetAreBlocked() { + val port = FakeCreatorChannelCommunityQueryPort().apply { blocked = true } + val service = createService(port) + val viewer = createMember(id = 10L) + + val exception = assertThrows(SodaException::class.java) { + service.getCommunityTab(1L, viewer, null, null, LocalDateTime.of(2026, 6, 21, 10, 0)) + } + + assertNull(exception.messageKey) + assertEquals("Channel access is restricted at creator's request.", exception.message) + } + + @Test + @DisplayName("커뮤니티 게시글은 접근 권한에 따라 이미지와 오디오와 본문을 조립한다") + fun shouldAssembleCommunityPostAssetsByAccessPolicy() { + val port = FakeCreatorChannelCommunityQueryPort().apply { + communityPosts = listOf( + communityPostRecord(1L, price = 0, existOrdered = false), + communityPostRecord(2L, price = 100, existOrdered = true), + communityPostRecord(3L, price = 100, existOrdered = false), + communityPostRecord(4L, creatorId = 10L, price = 100, existOrdered = false, creatorProfilePath = null), + communityPostRecord(5L, price = 0, imagePath = " ", audioPath = null) + ) + } + val audioContentCloudFront = Mockito.mock(AudioContentCloudFront::class.java) + Mockito.`when`(audioContentCloudFront.generateSignedURL("audio/1.mp3", 1000 * 60 * 30)) + .thenReturn("https://signed.test/audio/1.mp3") + Mockito.`when`(audioContentCloudFront.generateSignedURL("audio/2.mp3", 1000 * 60 * 30)) + .thenReturn("https://signed.test/audio/2.mp3") + Mockito.`when`(audioContentCloudFront.generateSignedURL("audio/4.mp3", 1000 * 60 * 30)) + .thenReturn("https://signed.test/audio/4.mp3") + val service = createService(port, audioContentCloudFront) + val viewer = createMember(id = 10L) + + val posts = service.getCommunityTab(1L, viewer, null, null, LocalDateTime.of(2026, 6, 21, 10, 0)) + .communityPosts + + assertEquals("https://cdn.test/image/1.png", posts[0].imageUrl) + assertEquals("https://signed.test/audio/1.mp3", posts[0].audioUrl) + assertEquals("content-1", posts[0].content) + assertEquals("https://cdn.test/image/2.png", posts[1].imageUrl) + assertEquals("https://signed.test/audio/2.mp3", posts[1].audioUrl) + assertNull(posts[2].imageUrl) + assertNull(posts[2].audioUrl) + assertEquals("cont...", posts[2].content) + assertEquals("https://cdn.test/image/4.png", posts[3].imageUrl) + assertEquals("https://signed.test/audio/4.mp3", posts[3].audioUrl) + assertEquals("https://cdn.test/profile/default-profile.png", posts[3].creatorProfileUrl) + assertEquals(true, posts[3].existOrdered) + assertNull(posts[4].imageUrl) + assertNull(posts[4].audioUrl) + Mockito.verify(audioContentCloudFront, Mockito.never()).generateSignedURL("audio/3.mp3", 1000 * 60 * 30) + } + + @Test + @DisplayName("홈 커뮤니티 요약 조회는 탭 전체 검증 없이 받은 조건으로 목록을 조립한다") + fun shouldAssembleHomeCommunityPostsWithoutTabValidation() { + val port = FakeCreatorChannelCommunityQueryPort().apply { + creator = null + blocked = true + homeCommunityPosts = listOf(communityPostRecord(1L, price = 0)) + } + val service = createService(port) + + val posts = service.findHomeCommunityPosts( + creatorId = 1L, + viewerId = 10L, + isPinned = true, + canViewAdultContent = false, + limit = 3 + ) + + assertEquals(1, posts.size) + assertEquals(1L, port.homeCreatorId) + assertEquals(10L, port.homeViewerId) + assertEquals(true, port.homeIsPinned) + assertEquals(false, port.homeCanViewAdultContent) + assertEquals(3, port.homeLimit) + } + + private fun createService( + port: FakeCreatorChannelCommunityQueryPort, + audioContentCloudFront: AudioContentCloudFront = Mockito.mock(AudioContentCloudFront::class.java), + canViewAdultContent: Boolean = true + ): CreatorChannelCommunityQueryService { + val preferenceService = Mockito.mock(MemberContentPreferenceService::class.java) + Mockito.`when`( + preferenceService.getStoredPreference(Mockito.any(Member::class.java) ?: createMember(id = 0L)) + ).thenReturn( + ViewerContentPreference( + countryCode = "US", + isAdultContentVisible = canViewAdultContent, + contentType = ContentType.ALL, + isAdult = canViewAdultContent + ) + ) + val langContext = LangContext() + langContext.setLang(Lang.EN) + return CreatorChannelCommunityQueryService( + queryPortProvider = FixedCreatorChannelCommunityQueryPortProvider(port), + queryPolicy = CreatorChannelCommunityQueryPolicy(), + memberContentPreferenceService = preferenceService, + audioContentCloudFront = audioContentCloudFront, + messageSource = SodaMessageSource(), + langContext = langContext, + cloudFrontHost = "https://cdn.test" + ) + } + + private fun createMember(id: Long): Member { + return Member( + email = "member$id@test.com", + password = "password", + nickname = "member$id", + provider = MemberProvider.EMAIL + ).apply { this.id = id } + } +} + +private class FixedCreatorChannelCommunityQueryPortProvider( + private val port: CreatorChannelCommunityQueryPort +) : ObjectProvider { + override fun getObject(vararg args: Any?): CreatorChannelCommunityQueryPort = port + + override fun getIfAvailable(): CreatorChannelCommunityQueryPort = port + + override fun getIfUnique(): CreatorChannelCommunityQueryPort = port + + override fun getObject(): CreatorChannelCommunityQueryPort = port +} + +private class FakeCreatorChannelCommunityQueryPort : CreatorChannelCommunityQueryPort { + var creator: CreatorChannelCommunityCreatorRecord? = CreatorChannelCommunityCreatorRecord( + creatorId = 1L, + role = MemberRole.CREATOR, + nickname = "creator" + ) + var blocked = false + var communityPostCount = 1 + var communityPosts = listOf(communityPostRecord(1L, price = 0)) + var homeCommunityPosts = listOf(communityPostRecord(1L, price = 0)) + var countCanViewAdultContent: Boolean? = null + var listCanViewAdultContent: Boolean? = null + var listOffset: Long? = null + var listLimit: Int? = null + var homeCreatorId: Long? = null + var homeViewerId: Long? = null + var homeIsPinned: Boolean? = null + var homeCanViewAdultContent: Boolean? = null + var homeLimit: Int? = null + + override fun findCreator(creatorId: Long, viewerId: Long?): CreatorChannelCommunityCreatorRecord? = creator + + override fun existsBlockedBetween(viewerId: Long, creatorId: Long): Boolean = blocked + + override fun countCommunityPosts( + creatorId: Long, + viewerId: Long, + canViewAdultContent: Boolean + ): Int { + countCanViewAdultContent = canViewAdultContent + return communityPostCount + } + + override fun findCommunityPosts( + creatorId: Long, + viewerId: Long, + canViewAdultContent: Boolean, + offset: Long, + limit: Int + ): List { + listCanViewAdultContent = canViewAdultContent + listOffset = offset + listLimit = limit + return communityPosts + } + + override fun findHomeCommunityPosts( + creatorId: Long, + viewerId: Long, + isPinned: Boolean, + canViewAdultContent: Boolean, + limit: Int + ): List { + homeCreatorId = creatorId + homeViewerId = viewerId + homeIsPinned = isPinned + homeCanViewAdultContent = canViewAdultContent + homeLimit = limit + return homeCommunityPosts + } +} + +private fun communityPostRecord( + postId: Long, + creatorId: Long = 1L, + price: Int, + existOrdered: Boolean = false, + creatorProfilePath: String? = "profile/$postId.png", + imagePath: String? = "image/$postId.png", + audioPath: String? = "audio/$postId.mp3" +): CreatorChannelCommunityPostRecord { + return CreatorChannelCommunityPostRecord( + postId = postId, + creatorId = creatorId, + creatorNickname = "creator-$creatorId", + creatorProfilePath = creatorProfilePath, + imagePath = imagePath, + audioPath = audioPath, + content = "content-$postId", + price = price, + createdAt = LocalDateTime.of(2026, 6, 21, 10, 0).plusMinutes(postId), + existOrdered = existOrdered, + isCommentAvailable = true, + likeCount = postId.toInt(), + commentCount = postId.toInt() + 1, + isPinned = postId == 1L + ) +}