From 2848f07573f6d2a6eb163e30ba43453982245add Mon Sep 17 00:00:00 2001 From: Klaus Date: Mon, 22 Jun 2026 15:51:47 +0900 Subject: [PATCH] =?UTF-8?q?feat(creator-channel):=20FanTalk=20=ED=83=AD=20?= =?UTF-8?q?=EC=A1=B0=ED=9A=8C=20=EC=84=9C=EB=B9=84=EC=8A=A4=EB=A5=BC=20?= =?UTF-8?q?=EA=B5=AC=ED=98=84=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../CreatorChannelFanTalkQueryService.kt | 99 ++++++- .../CreatorChannelFanTalkQueryServiceTest.kt | 277 ++++++++++++++++++ 2 files changed, 374 insertions(+), 2 deletions(-) create mode 100644 src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/fantalk/application/CreatorChannelFanTalkQueryServiceTest.kt diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/fantalk/application/CreatorChannelFanTalkQueryService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/fantalk/application/CreatorChannelFanTalkQueryService.kt index ac26ae26..a5b43316 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/fantalk/application/CreatorChannelFanTalkQueryService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/fantalk/application/CreatorChannelFanTalkQueryService.kt @@ -1,14 +1,36 @@ package kr.co.vividnext.sodalive.v2.creator.channel.fantalk.application +import kr.co.vividnext.sodalive.common.SodaException +import kr.co.vividnext.sodalive.extensions.removeDeletedNicknamePrefix +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.v2.common.domain.toCdnUrl +import kr.co.vividnext.sodalive.v2.creator.channel.fantalk.domain.CreatorChannelFanTalk +import kr.co.vividnext.sodalive.v2.creator.channel.fantalk.domain.CreatorChannelFanTalkQueryPolicy +import kr.co.vividnext.sodalive.v2.creator.channel.fantalk.domain.CreatorChannelFanTalkReply import kr.co.vividnext.sodalive.v2.creator.channel.fantalk.domain.CreatorChannelFanTalkTab +import kr.co.vividnext.sodalive.v2.creator.channel.fantalk.port.out.CreatorChannelFanTalkCreatorRecord +import kr.co.vividnext.sodalive.v2.creator.channel.fantalk.port.out.CreatorChannelFanTalkQueryPort +import kr.co.vividnext.sodalive.v2.creator.channel.fantalk.port.out.CreatorChannelFanTalkRecord +import kr.co.vividnext.sodalive.v2.creator.channel.fantalk.port.out.CreatorChannelFanTalkReplyRecord +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 CreatorChannelFanTalkQueryService { +class CreatorChannelFanTalkQueryService( + private val queryPortProvider: ObjectProvider, + private val queryPolicy: CreatorChannelFanTalkQueryPolicy, + private val messageSource: SodaMessageSource, + private val langContext: LangContext, + @Value("\${cloud.aws.cloud-front.host}") + private val cloudFrontHost: String +) { fun getFanTalkTab( creatorId: Long, viewer: Member, @@ -16,6 +38,79 @@ class CreatorChannelFanTalkQueryService { size: Int?, now: LocalDateTime = LocalDateTime.now() ): CreatorChannelFanTalkTab { - throw UnsupportedOperationException("CreatorChannelFanTalkQueryService is implemented in Phase 3") + val fanTalkPage = 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 fetchedFanTalks = queryPort.findFanTalks( + creatorId = creatorId, + viewerId = viewerId, + offset = fanTalkPage.offset, + limit = fanTalkPage.fetchLimit + ) + val fanTalkRecords = queryPolicy.limitItems(fetchedFanTalks, fanTalkPage) + val repliesByParentId = findRepliesByParentId(queryPort, creatorId, fanTalkRecords) + + return CreatorChannelFanTalkTab( + fanTalkCount = queryPort.countFanTalks(creatorId, viewerId), + fanTalks = fanTalkRecords.map { it.toDomain(repliesByParentId[it.fanTalkId].orEmpty()) }, + page = fanTalkPage, + hasNext = queryPolicy.hasNext(fetchedFanTalks, fanTalkPage) + ) } + + private fun validateCreatorRole(creator: CreatorChannelFanTalkCreatorRecord) { + when (creator.role) { + MemberRole.CREATOR -> return + else -> throw SodaException(messageKey = "member.validation.creator_not_found") + } + } + + private fun findRepliesByParentId( + queryPort: CreatorChannelFanTalkQueryPort, + creatorId: Long, + fanTalkRecords: List + ): Map> { + val parentFanTalkIds = fanTalkRecords.map { it.fanTalkId } + if (parentFanTalkIds.isEmpty()) return emptyMap() + return queryPort.findCreatorReplies(creatorId, parentFanTalkIds) + .groupBy( + keySelector = { it.parentFanTalkId }, + valueTransform = { it.toDomain() } + ) + } + + private fun CreatorChannelFanTalkRecord.toDomain( + creatorReplies: List + ) = CreatorChannelFanTalk( + fanTalkId = fanTalkId, + writerId = writerId, + writerNickname = writerNickname.removeDeletedNicknamePrefix(), + writerProfileImageUrl = writerProfileImagePath.toCdnUrl(cloudFrontHost) ?: defaultProfileImageUrl(), + content = content, + createdAt = createdAt, + creatorReplies = creatorReplies + ) + + private fun CreatorChannelFanTalkReplyRecord.toDomain() = CreatorChannelFanTalkReply( + fanTalkId = fanTalkId, + writerId = writerId, + writerNickname = writerNickname.removeDeletedNicknamePrefix(), + writerProfileImageUrl = writerProfileImagePath.toCdnUrl(cloudFrontHost) ?: defaultProfileImageUrl(), + content = content, + createdAt = createdAt + ) + + private fun defaultProfileImageUrl(): String = "$cloudFrontHost/profile/default-profile.png" } diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/fantalk/application/CreatorChannelFanTalkQueryServiceTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/fantalk/application/CreatorChannelFanTalkQueryServiceTest.kt new file mode 100644 index 00000000..67ea009c --- /dev/null +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/fantalk/application/CreatorChannelFanTalkQueryServiceTest.kt @@ -0,0 +1,277 @@ +package kr.co.vividnext.sodalive.v2.creator.channel.fantalk.application + +import kr.co.vividnext.sodalive.common.SodaException +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.v2.creator.channel.fantalk.domain.CreatorChannelFanTalkQueryPolicy +import kr.co.vividnext.sodalive.v2.creator.channel.fantalk.port.out.CreatorChannelFanTalkCreatorRecord +import kr.co.vividnext.sodalive.v2.creator.channel.fantalk.port.out.CreatorChannelFanTalkQueryPort +import kr.co.vividnext.sodalive.v2.creator.channel.fantalk.port.out.CreatorChannelFanTalkRecord +import kr.co.vividnext.sodalive.v2.creator.channel.fantalk.port.out.CreatorChannelFanTalkReplyRecord +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.springframework.beans.factory.ObjectProvider +import java.time.LocalDateTime + +class CreatorChannelFanTalkQueryServiceTest { + @Test + @DisplayName("creatorId에 해당하는 회원이 없으면 user_not_found 예외를 던진다") + fun shouldThrowUserNotFoundWhenCreatorMemberDoesNotExist() { + val port = FakeCreatorChannelFanTalkQueryPort().apply { creator = null } + val service = createService(port) + val viewer = createMember(id = 10L) + + val exception = assertThrows(SodaException::class.java) { + service.getFanTalkTab(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 = FakeCreatorChannelFanTalkQueryPort().apply { creator = creator?.copy(role = MemberRole.USER) } + val service = createService(port) + val viewer = createMember(id = 10L) + + val exception = assertThrows(SodaException::class.java) { + service.getFanTalkTab(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 = FakeCreatorChannelFanTalkQueryPort().apply { blocked = true } + val service = createService(port) + val viewer = createMember(id = 10L) + + val exception = assertThrows(SodaException::class.java) { + service.getFanTalkTab(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("FanTalk 탭 서비스는 요청 fallback과 조회 컨텍스트를 port에 전달하고 탭을 조립한다") + fun shouldResolveRequestFallbacksAndAssembleFanTalkTab() { + val port = FakeCreatorChannelFanTalkQueryPort().apply { + fanTalkCount = 60 + fanTalks = (1L..21L).map { fanTalkRecord(it) } + creatorReplies = listOf( + fanTalkReplyRecord(fanTalkId = 101L, parentFanTalkId = 1L), + fanTalkReplyRecord(fanTalkId = 102L, parentFanTalkId = 2L), + fanTalkReplyRecord(fanTalkId = 103L, parentFanTalkId = 21L) + ) + } + val service = createService(port) + val viewer = createMember(id = 10L) + + val tab = service.getFanTalkTab( + creatorId = 1L, + viewer = viewer, + page = -1, + size = 10, + now = LocalDateTime.of(2026, 6, 21, 10, 0) + ) + + assertEquals(60, tab.fanTalkCount) + assertEquals(0, tab.page.page) + assertEquals(20, tab.page.size) + assertEquals(0L, port.listOffset) + assertEquals(21, port.listLimit) + assertEquals(20, tab.fanTalks.size) + assertTrue(tab.hasNext) + assertEquals( + (1L..20L).toList(), + port.replyParentFanTalkIds + ) + assertEquals(101L, tab.fanTalks[0].creatorReplies.single().fanTalkId) + assertEquals(102L, tab.fanTalks[1].creatorReplies.single().fanTalkId) + assertEquals(emptyList(), tab.fanTalks[19].creatorReplies.map { it.fanTalkId }) + } + + @Test + @DisplayName("FanTalk 목록이 비어 있으면 답글 조회 없이 빈 목록과 hasNext=false를 반환한다") + fun shouldReturnEmptyFanTalksWithoutFindingReplies() { + val port = FakeCreatorChannelFanTalkQueryPort().apply { + fanTalkCount = 5 + fanTalks = emptyList() + } + val service = createService(port) + val viewer = createMember(id = 10L) + + val tab = service.getFanTalkTab(1L, viewer, 3, 20, LocalDateTime.of(2026, 6, 21, 10, 0)) + + assertEquals(5, tab.fanTalkCount) + assertEquals(emptyList(), tab.fanTalks.map { it.fanTalkId }) + assertEquals(false, tab.hasNext) + assertNull(port.replyParentFanTalkIds) + } + + @Test + @DisplayName("FanTalk과 creator reply 작성자의 프로필 URL과 탈퇴 닉네임 prefix를 변환한다") + fun shouldConvertWriterProfileUrlsAndDeletedNicknamePrefixes() { + val port = FakeCreatorChannelFanTalkQueryPort().apply { + fanTalks = listOf( + fanTalkRecord( + fanTalkId = 1L, + writerNickname = "deleted_fan", + writerProfileImagePath = "profile/fan.png" + ), + fanTalkRecord( + fanTalkId = 2L, + writerNickname = "normal", + writerProfileImagePath = null + ) + ) + creatorReplies = listOf( + fanTalkReplyRecord( + fanTalkId = 101L, + parentFanTalkId = 1L, + writerNickname = "deleted_creator", + writerProfileImagePath = "https://images.test/creator.png" + ), + fanTalkReplyRecord( + fanTalkId = 102L, + parentFanTalkId = 2L, + writerNickname = "creator", + writerProfileImagePath = " " + ) + ) + } + val service = createService(port) + val viewer = createMember(id = 10L) + + val fanTalks = service.getFanTalkTab(1L, viewer, null, null, LocalDateTime.of(2026, 6, 21, 10, 0)) + .fanTalks + + assertEquals("fan", fanTalks[0].writerNickname) + assertEquals("https://cdn.test/profile/fan.png", fanTalks[0].writerProfileImageUrl) + assertEquals("creator", fanTalks[0].creatorReplies.single().writerNickname) + assertEquals("https://images.test/creator.png", fanTalks[0].creatorReplies.single().writerProfileImageUrl) + assertEquals("normal", fanTalks[1].writerNickname) + assertEquals("https://cdn.test/profile/default-profile.png", fanTalks[1].writerProfileImageUrl) + assertEquals("https://cdn.test/profile/default-profile.png", fanTalks[1].creatorReplies.single().writerProfileImageUrl) + } + + private fun createService(port: FakeCreatorChannelFanTalkQueryPort): CreatorChannelFanTalkQueryService { + val langContext = LangContext() + langContext.setLang(Lang.EN) + return CreatorChannelFanTalkQueryService( + queryPortProvider = FixedCreatorChannelFanTalkQueryPortProvider(port), + queryPolicy = CreatorChannelFanTalkQueryPolicy(), + 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 FixedCreatorChannelFanTalkQueryPortProvider( + private val port: CreatorChannelFanTalkQueryPort +) : ObjectProvider { + override fun getObject(vararg args: Any?): CreatorChannelFanTalkQueryPort = port + + override fun getIfAvailable(): CreatorChannelFanTalkQueryPort = port + + override fun getIfUnique(): CreatorChannelFanTalkQueryPort = port + + override fun getObject(): CreatorChannelFanTalkQueryPort = port +} + +private class FakeCreatorChannelFanTalkQueryPort : CreatorChannelFanTalkQueryPort { + var creator: CreatorChannelFanTalkCreatorRecord? = CreatorChannelFanTalkCreatorRecord( + creatorId = 1L, + role = MemberRole.CREATOR, + nickname = "creator" + ) + var blocked = false + var fanTalkCount = 1 + var fanTalks = listOf(fanTalkRecord(1L)) + var creatorReplies = emptyList() + var listOffset: Long? = null + var listLimit: Int? = null + var replyParentFanTalkIds: List? = null + + override fun findCreator(creatorId: Long, viewerId: Long?): CreatorChannelFanTalkCreatorRecord? = creator + + override fun existsBlockedBetween(viewerId: Long, creatorId: Long): Boolean = blocked + + override fun countFanTalks(creatorId: Long, viewerId: Long): Int = fanTalkCount + + override fun findFanTalks( + creatorId: Long, + viewerId: Long, + offset: Long, + limit: Int + ): List { + listOffset = offset + listLimit = limit + return fanTalks + } + + override fun findCreatorReplies( + creatorId: Long, + parentFanTalkIds: List + ): List { + replyParentFanTalkIds = parentFanTalkIds + return creatorReplies + } +} + +private fun fanTalkRecord( + fanTalkId: Long, + writerId: Long = 10L + fanTalkId, + writerNickname: String = "fan-$fanTalkId", + writerProfileImagePath: String? = "profile/$fanTalkId.png" +): CreatorChannelFanTalkRecord { + return CreatorChannelFanTalkRecord( + fanTalkId = fanTalkId, + writerId = writerId, + writerNickname = writerNickname, + writerProfileImagePath = writerProfileImagePath, + content = "content-$fanTalkId", + createdAt = LocalDateTime.of(2026, 6, 21, 10, 0).plusMinutes(fanTalkId) + ) +} + +private fun fanTalkReplyRecord( + fanTalkId: Long, + parentFanTalkId: Long, + writerId: Long = 1L, + writerNickname: String = "creator", + writerProfileImagePath: String? = "profile/creator.png" +): CreatorChannelFanTalkReplyRecord { + return CreatorChannelFanTalkReplyRecord( + fanTalkId = fanTalkId, + parentFanTalkId = parentFanTalkId, + writerId = writerId, + writerNickname = writerNickname, + writerProfileImagePath = writerProfileImagePath, + content = "reply-$fanTalkId", + createdAt = LocalDateTime.of(2026, 6, 21, 11, 0).plusMinutes(fanTalkId) + ) +}