From ec68d827a649c686e2af6f4941421dc0d7b41dce Mon Sep 17 00:00:00 2001 From: Klaus Date: Sat, 13 Jun 2026 18:52:10 +0900 Subject: [PATCH] =?UTF-8?q?feat(creator):=20=EC=B1=84=EB=84=90=20=ED=99=88?= =?UTF-8?q?=20=EC=A1=B0=ED=9A=8C=20=EC=84=9C=EB=B9=84=EC=8A=A4=EB=A5=BC=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../CreatorChannelHomeQueryService.kt | 265 +++++++++++ .../domain/CreatorChannelHomeQueryPolicy.kt | 2 + .../CreatorChannelHomeQueryServiceTest.kt | 415 ++++++++++++++++++ 3 files changed, 682 insertions(+) create mode 100644 src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/application/CreatorChannelHomeQueryService.kt diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/application/CreatorChannelHomeQueryService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/application/CreatorChannelHomeQueryService.kt new file mode 100644 index 00000000..758c3875 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/application/CreatorChannelHomeQueryService.kt @@ -0,0 +1,265 @@ +package kr.co.vividnext.sodalive.v2.creator.channel.application + +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.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.member.contentpreference.isAdultVisibleByPolicy +import kr.co.vividnext.sodalive.v2.creator.channel.domain.CreatorChannelActivity +import kr.co.vividnext.sodalive.v2.creator.channel.domain.CreatorChannelAudioContent +import kr.co.vividnext.sodalive.v2.creator.channel.domain.CreatorChannelCommunityPost +import kr.co.vividnext.sodalive.v2.creator.channel.domain.CreatorChannelCreator +import kr.co.vividnext.sodalive.v2.creator.channel.domain.CreatorChannelDonation +import kr.co.vividnext.sodalive.v2.creator.channel.domain.CreatorChannelFanTalk +import kr.co.vividnext.sodalive.v2.creator.channel.domain.CreatorChannelFanTalkSummary +import kr.co.vividnext.sodalive.v2.creator.channel.domain.CreatorChannelHome +import kr.co.vividnext.sodalive.v2.creator.channel.domain.CreatorChannelHomeQueryPolicy +import kr.co.vividnext.sodalive.v2.creator.channel.domain.CreatorChannelLive +import kr.co.vividnext.sodalive.v2.creator.channel.domain.CreatorChannelSchedule +import kr.co.vividnext.sodalive.v2.creator.channel.domain.CreatorChannelSeries +import kr.co.vividnext.sodalive.v2.creator.channel.domain.CreatorChannelSns +import kr.co.vividnext.sodalive.v2.creator.channel.port.out.CreatorChannelActivityRecord +import kr.co.vividnext.sodalive.v2.creator.channel.port.out.CreatorChannelAudioContentRecord +import kr.co.vividnext.sodalive.v2.creator.channel.port.out.CreatorChannelCommunityPostRecord +import kr.co.vividnext.sodalive.v2.creator.channel.port.out.CreatorChannelCreatorRecord +import kr.co.vividnext.sodalive.v2.creator.channel.port.out.CreatorChannelDonationRecord +import kr.co.vividnext.sodalive.v2.creator.channel.port.out.CreatorChannelFanTalkRecord +import kr.co.vividnext.sodalive.v2.creator.channel.port.out.CreatorChannelFanTalkSummaryRecord +import kr.co.vividnext.sodalive.v2.creator.channel.port.out.CreatorChannelHomeQueryPort +import kr.co.vividnext.sodalive.v2.creator.channel.port.out.CreatorChannelLiveRecord +import kr.co.vividnext.sodalive.v2.creator.channel.port.out.CreatorChannelScheduleRecord +import kr.co.vividnext.sodalive.v2.creator.channel.port.out.CreatorChannelSeriesRecord +import kr.co.vividnext.sodalive.v2.creator.channel.port.out.CreatorChannelSnsRecord +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 CreatorChannelHomeQueryService( + private val queryPort: CreatorChannelHomeQueryPort, + private val queryPolicy: CreatorChannelHomeQueryPolicy, + private val memberContentPreferenceService: MemberContentPreferenceService, + private val messageSource: SodaMessageSource, + private val langContext: LangContext, + @Value("\${cloud.aws.cloud-front.host}") + private val cloudFrontHost: String +) { + fun getHome( + creatorId: Long, + viewer: Member, + now: LocalDateTime = LocalDateTime.now() + ): CreatorChannelHome { + 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 isViewerCreator = viewerId == creatorId + val effectiveViewerGender = viewer.effectiveGender() + val latestAudioContent = queryPort + .findLatestAudioContent(creatorId, now, canViewAdultContent) + ?.toDomain() + val audioContents = queryPolicy.excludeLatestAudioContent( + queryPort.findAudioContents( + creatorId = creatorId, + now = now, + latestAudioContentId = latestAudioContent?.audioContentId, + canViewAdultContent = canViewAdultContent + ).map { it.toDomain() }, + latestAudioContent?.audioContentId + ) + + return CreatorChannelHome( + creator = creator.toDomain(), + currentLive = queryPort.findCurrentLive( + creatorId = creatorId, + now = now, + canViewAdultContent = canViewAdultContent, + viewerId = viewerId, + isViewerCreator = isViewerCreator, + effectiveViewerGender = effectiveViewerGender + )?.toDomain(), + latestAudioContent = latestAudioContent, + channelDonations = queryPort.findChannelDonations(creatorId, viewerId, now).map { it.toDomain() }, + notices = queryPort.findCommunityPosts( + creatorId = creatorId, + viewerId = viewerId, + isFixed = true, + canViewAdultContent = canViewAdultContent + ).map { it.toDomain() }, + schedules = queryPolicy.limitSchedules( + queryPort.findSchedules( + creatorId = creatorId, + now = now, + canViewAdultContent = canViewAdultContent, + viewerId = viewerId, + isViewerCreator = isViewerCreator, + effectiveViewerGender = effectiveViewerGender + ).map { it.toDomain() }, + now, + canViewAdultContent + ), + audioContents = audioContents, + series = queryPort.findSeries( + creatorId = creatorId, + viewerId = viewerId, + now = now, + canViewAdultContent = canViewAdultContent, + contentType = preference.contentType + ).map { it.toDomain() }, + communities = queryPort.findCommunityPosts( + creatorId = creatorId, + viewerId = viewerId, + isFixed = false, + canViewAdultContent = canViewAdultContent + ).map { it.toDomain() }, + fanTalk = queryPort.findFanTalkSummary(creatorId, viewerId).toDomain(), + introduce = creator.introduce, + activity = queryPort.findActivity(creatorId, now).toDomain(), + sns = queryPort.findSns(creatorId).toDomain() + ) + } + + private fun validateCreatorRole(creator: CreatorChannelCreatorRecord) { + when (creator.role) { + MemberRole.CREATOR -> return + else -> throw SodaException(messageKey = "member.validation.creator_not_found") + } + } + + private fun Member.effectiveGender(): Gender { + auth?.let { return if (it.gender == 1) Gender.MALE else Gender.FEMALE } + return gender + } + + private fun CreatorChannelCreatorRecord.toDomain() = CreatorChannelCreator( + creatorId = creatorId, + characterId = characterId, + nickname = nickname, + profileImageUrl = profileImagePath.toCdnUrl() ?: defaultProfileImageUrl(), + followerCount = followerCount, + isAiChatAvailable = isAiChatAvailable, + isDmAvailable = isDmAvailable, + isFollow = isFollow, + isNotify = isNotify + ) + + private fun CreatorChannelLiveRecord.toDomain() = CreatorChannelLive( + liveId = liveId, + title = title, + coverImageUrl = coverImagePath.toCdnUrl(), + beginDateTime = beginDateTime, + price = price, + isAdult = isAdult + ) + + private fun CreatorChannelAudioContentRecord.toDomain() = CreatorChannelAudioContent( + audioContentId = audioContentId, + title = title, + duration = duration, + imageUrl = imagePath.toCdnUrl(), + price = price, + isAdult = isAdult, + isPointAvailable = isPointAvailable, + isFirstContent = isFirstContent, + publishedAt = publishedAt, + seriesName = seriesName, + isOriginalSeries = isOriginalSeries + ) + + private fun CreatorChannelDonationRecord.toDomain() = CreatorChannelDonation( + nickname = nickname, + profileImageUrl = profileImagePath.toCdnUrl() ?: defaultProfileImageUrl(), + can = can, + message = message, + createdAt = createdAt + ) + + private fun CreatorChannelScheduleRecord.toDomain() = CreatorChannelSchedule( + scheduledAt = scheduledAt, + title = title, + type = type, + targetId = targetId, + isAdult = isAdult + ) + + private fun CreatorChannelSeriesRecord.toDomain() = CreatorChannelSeries( + seriesId = seriesId, + title = title, + coverImageUrl = coverImagePath.toCdnUrl().orEmpty(), + numberOfContent = numberOfContent, + isNew = isNew, + isOriginal = isOriginal + ) + + private fun CreatorChannelCommunityPostRecord.toDomain() = CreatorChannelCommunityPost( + postId = postId, + creatorId = creatorId, + creatorNickname = creatorNickname, + creatorProfileUrl = creatorProfilePath.toCdnUrl() ?: defaultProfileImageUrl(), + imageUrl = imagePath.toCdnUrl(), + audioUrl = audioPath.toCdnUrl(), + content = content, + price = price, + date = date, + existOrdered = existOrdered, + likeCount = likeCount, + commentCount = commentCount + ) + + private fun CreatorChannelFanTalkSummaryRecord.toDomain() = CreatorChannelFanTalkSummary( + totalCount = totalCount, + latestFanTalk = latestFanTalk?.toDomain() + ) + + private fun CreatorChannelFanTalkRecord.toDomain() = CreatorChannelFanTalk( + fanTalkId = fanTalkId, + memberId = memberId, + nickname = nickname, + profileImageUrl = profileImagePath.toCdnUrl() ?: defaultProfileImageUrl(), + content = content, + languageCode = languageCode, + createdAt = createdAt + ) + + private fun CreatorChannelActivityRecord.toDomain() = CreatorChannelActivity( + debutDate = debutDate, + dDay = dDay, + liveCount = liveCount, + liveDurationHours = liveDurationHours, + liveContributorCount = liveContributorCount, + audioContentCount = audioContentCount, + seriesCount = seriesCount + ) + + private fun CreatorChannelSnsRecord.toDomain() = CreatorChannelSns( + instagramUrl = instagramUrl, + fancimmUrl = fancimmUrl, + xUrl = xUrl, + youtubeUrl = youtubeUrl, + kakaoOpenChatUrl = kakaoOpenChatUrl + ) + + private fun String?.toCdnUrl(): String? { + if (isNullOrBlank()) return null + if (startsWith("https://") || startsWith("http://")) return this + return "$cloudFrontHost/$this" + } + + private fun defaultProfileImageUrl(): String = "$cloudFrontHost/profile/default-profile.png" +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/domain/CreatorChannelHomeQueryPolicy.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/domain/CreatorChannelHomeQueryPolicy.kt index 862aa3f7..682f76de 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/domain/CreatorChannelHomeQueryPolicy.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/domain/CreatorChannelHomeQueryPolicy.kt @@ -1,8 +1,10 @@ package kr.co.vividnext.sodalive.v2.creator.channel.domain import kr.co.vividnext.sodalive.v2.common.domain.CreatorActivityType +import org.springframework.stereotype.Component import java.time.LocalDateTime +@Component class CreatorChannelHomeQueryPolicy { fun limitSchedules( schedules: List, diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/application/CreatorChannelHomeQueryServiceTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/application/CreatorChannelHomeQueryServiceTest.kt index 819e2ab9..2a6e0cdb 100644 --- a/src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/application/CreatorChannelHomeQueryServiceTest.kt +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/application/CreatorChannelHomeQueryServiceTest.kt @@ -1,6 +1,18 @@ package kr.co.vividnext.sodalive.v2.creator.channel.application import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper +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.Gender +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.auth.Auth +import kr.co.vividnext.sodalive.member.contentpreference.MemberContentPreferenceService +import kr.co.vividnext.sodalive.member.contentpreference.ViewerContentPreference import kr.co.vividnext.sodalive.v2.common.domain.CreatorActivityType import kr.co.vividnext.sodalive.v2.creator.channel.domain.CreatorChannelActivity import kr.co.vividnext.sodalive.v2.creator.channel.domain.CreatorChannelAudioContent @@ -10,22 +22,152 @@ import kr.co.vividnext.sodalive.v2.creator.channel.domain.CreatorChannelDonation import kr.co.vividnext.sodalive.v2.creator.channel.domain.CreatorChannelFanTalk import kr.co.vividnext.sodalive.v2.creator.channel.domain.CreatorChannelFanTalkSummary import kr.co.vividnext.sodalive.v2.creator.channel.domain.CreatorChannelHome +import kr.co.vividnext.sodalive.v2.creator.channel.domain.CreatorChannelHomeQueryPolicy import kr.co.vividnext.sodalive.v2.creator.channel.domain.CreatorChannelLive import kr.co.vividnext.sodalive.v2.creator.channel.domain.CreatorChannelSchedule import kr.co.vividnext.sodalive.v2.creator.channel.domain.CreatorChannelSeries import kr.co.vividnext.sodalive.v2.creator.channel.domain.CreatorChannelSns import kr.co.vividnext.sodalive.v2.creator.channel.dto.CreatorChannelHomeResponse +import kr.co.vividnext.sodalive.v2.creator.channel.port.out.CreatorChannelActivityRecord +import kr.co.vividnext.sodalive.v2.creator.channel.port.out.CreatorChannelAudioContentRecord +import kr.co.vividnext.sodalive.v2.creator.channel.port.out.CreatorChannelCommunityPostRecord +import kr.co.vividnext.sodalive.v2.creator.channel.port.out.CreatorChannelCreatorRecord +import kr.co.vividnext.sodalive.v2.creator.channel.port.out.CreatorChannelDonationRecord +import kr.co.vividnext.sodalive.v2.creator.channel.port.out.CreatorChannelFanTalkRecord +import kr.co.vividnext.sodalive.v2.creator.channel.port.out.CreatorChannelFanTalkSummaryRecord +import kr.co.vividnext.sodalive.v2.creator.channel.port.out.CreatorChannelHomeQueryPort +import kr.co.vividnext.sodalive.v2.creator.channel.port.out.CreatorChannelLiveRecord +import kr.co.vividnext.sodalive.v2.creator.channel.port.out.CreatorChannelScheduleRecord +import kr.co.vividnext.sodalive.v2.creator.channel.port.out.CreatorChannelSeriesRecord +import kr.co.vividnext.sodalive.v2.creator.channel.port.out.CreatorChannelSnsRecord import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Assertions.assertFalse import org.junit.jupiter.api.Assertions.assertNotNull +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 java.time.LocalDateTime class CreatorChannelHomeQueryServiceTest { private val objectMapper = jacksonObjectMapper() + @Test + @DisplayName("크리에이터 채널 홈 서비스는 모든 섹션을 조립하고 최종 정책을 적용한다") + fun shouldAssembleCreatorChannelHomeWithFinalPolicies() { + val port = FakeCreatorChannelHomeQueryPort() + val service = createService(port, canViewAdultContent = false) + val viewer = createMember(id = 10L, gender = Gender.FEMALE, authGender = 1) + val now = LocalDateTime.of(2026, 6, 13, 10, 0) + + val home = service.getHome(creatorId = 1L, viewer = viewer, now = now) + + assertEquals(1L, home.creator.creatorId) + assertEquals("https://cdn.test/profile/creator.png", home.creator.profileImageUrl) + assertEquals("https://cdn.test/live.png", home.currentLive?.coverImageUrl) + assertEquals("https://cdn.test/audio/latest.png", home.latestAudioContent?.imageUrl) + assertEquals(listOf(203L, 202L), home.audioContents.map { it.audioContentId }) + assertEquals(listOf(402L, 401L, 404L), home.schedules.map { it.targetId }) + assertFalse(home.schedules.any { it.isAdult }) + assertEquals("https://cdn.test/profile/fan.png", home.channelDonations.first().profileImageUrl) + assertEquals("https://cdn.test/community.png", home.notices.first().imageUrl) + assertEquals("https://cdn.test/series.png", home.series.first().coverImageUrl) + assertEquals("introduce", home.introduce) + assertEquals(Gender.MALE, port.currentLiveEffectiveViewerGender) + assertEquals(Gender.MALE, port.schedulesEffectiveViewerGender) + assertEquals(10L, port.currentLiveViewerId) + assertEquals(10L, port.schedulesViewerId) + assertFalse(port.currentLiveIsViewerCreator == true) + assertFalse(port.schedulesIsViewerCreator == true) + assertEquals(false, port.currentLiveCanViewAdultContent) + assertEquals(false, port.schedulesCanViewAdultContent) + assertEquals(201L, port.audioContentsLatestAudioContentId) + } + + @Test + @DisplayName("조회자가 크리에이터 본인이면 라이브 조회 정책 컨텍스트에 본인 여부를 전달한다") + fun shouldPassViewerCreatorFlagToLivePolicyQueries() { + val port = FakeCreatorChannelHomeQueryPort() + val service = createService(port) + val viewer = createMember(id = 1L, gender = Gender.FEMALE, authGender = null) + + service.getHome(creatorId = 1L, viewer = viewer, now = LocalDateTime.of(2026, 6, 13, 10, 0)) + + assertTrue(port.currentLiveIsViewerCreator == true) + assertTrue(port.schedulesIsViewerCreator == true) + assertEquals(Gender.FEMALE, port.currentLiveEffectiveViewerGender) + assertEquals(Gender.FEMALE, port.schedulesEffectiveViewerGender) + } + + @Test + @DisplayName("creatorId에 해당하는 회원이 없으면 user_not_found 예외를 던진다") + fun shouldThrowUserNotFoundWhenCreatorMemberDoesNotExist() { + val port = FakeCreatorChannelHomeQueryPort().apply { + creator = null + } + val service = createService(port) + val viewer = createMember(id = 10L) + + val exception = assertThrows(SodaException::class.java) { + service.getHome(creatorId = 999L, viewer = viewer, now = LocalDateTime.of(2026, 6, 13, 10, 0)) + } + + assertEquals("member.validation.user_not_found", exception.messageKey) + } + + @Test + @DisplayName("대상 회원 role이 CREATOR가 아니면 creator_not_found 예외를 던진다") + fun shouldThrowCreatorNotFoundWhenMemberIsNotCreator() { + val port = FakeCreatorChannelHomeQueryPort().apply { + creator = creator?.copy(role = MemberRole.USER) + } + val service = createService(port) + val viewer = createMember(id = 10L) + + val exception = assertThrows(SodaException::class.java) { + service.getHome(creatorId = 1L, viewer = viewer, now = LocalDateTime.of(2026, 6, 13, 10, 0)) + } + + assertEquals("member.validation.creator_not_found", exception.messageKey) + } + + @Test + @DisplayName("차단 관계가 있으면 대상 회원이 크리에이터가 아니어도 접근 차단 예외를 먼저 던진다") + fun shouldThrowBlockedAccessBeforeCreatorNotFoundWhenViewerAndTargetAreBlocked() { + val port = FakeCreatorChannelHomeQueryPort().apply { + creator = creator?.copy(role = MemberRole.USER) + blocked = true + } + val service = createService(port) + val viewer = createMember(id = 10L) + + val exception = assertThrows(SodaException::class.java) { + service.getHome(creatorId = 1L, viewer = viewer, now = LocalDateTime.of(2026, 6, 13, 10, 0)) + } + + assertNull(exception.messageKey) + assertEquals("creator님의 요청으로 채널 접근이 제한됩니다.", exception.message) + } + + @Test + @DisplayName("조회자와 크리에이터 사이에 차단 관계가 있으면 기존 채널 접근 차단 메시지를 던진다") + fun shouldThrowBlockedAccessWhenViewerAndCreatorAreBlocked() { + val port = FakeCreatorChannelHomeQueryPort().apply { + blocked = true + } + val service = createService(port) + val viewer = createMember(id = 10L) + + val exception = assertThrows(SodaException::class.java) { + service.getHome(creatorId = 1L, viewer = viewer, now = LocalDateTime.of(2026, 6, 13, 10, 0)) + } + + assertNull(exception.messageKey) + assertEquals("creator님의 요청으로 채널 접근이 제한됩니다.", exception.message) + } + @Test @DisplayName("크리에이터 채널 홈 모델은 홈 탭 전체 섹션을 담고 응답 DTO로 변환된다") fun shouldConvertCreatorChannelHomeToResponse() { @@ -233,4 +375,277 @@ class CreatorChannelHomeQueryServiceTest { ) ) } + + private fun createService( + port: FakeCreatorChannelHomeQueryPort, + canViewAdultContent: Boolean = true + ): CreatorChannelHomeQueryService { + 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 messageSource = SodaMessageSource() + val langContext = LangContext() + langContext.setLang(Lang.KO) + return CreatorChannelHomeQueryService( + queryPort = port, + queryPolicy = CreatorChannelHomeQueryPolicy(), + memberContentPreferenceService = preferenceService, + messageSource = messageSource, + langContext = langContext, + cloudFrontHost = "https://cdn.test" + ) + } + + private fun createMember( + id: Long, + gender: Gender = Gender.NONE, + authGender: Int? = null + ): Member { + val member = Member( + email = "member$id@test.com", + password = "password", + nickname = "member$id", + provider = MemberProvider.EMAIL, + gender = gender + ) + member.id = id + authGender?.let { + Auth( + name = "name", + birth = "19900101", + uniqueCi = "ci$id", + di = "di$id", + gender = it + ).member = member + } + return member + } +} + +private class FakeCreatorChannelHomeQueryPort : CreatorChannelHomeQueryPort { + var creator: CreatorChannelCreatorRecord? = CreatorChannelCreatorRecord( + creatorId = 1L, + role = MemberRole.CREATOR, + characterId = 11L, + nickname = "creator", + profileImagePath = "profile/creator.png", + introduce = "introduce", + followerCount = 100, + isAiChatAvailable = true, + isDmAvailable = true, + isFollow = true, + isNotify = false + ) + var blocked = false + var currentLiveViewerId: Long? = null + var currentLiveIsViewerCreator: Boolean? = null + var currentLiveEffectiveViewerGender: Gender? = null + var currentLiveCanViewAdultContent: Boolean? = null + var schedulesViewerId: Long? = null + var schedulesIsViewerCreator: Boolean? = null + var schedulesEffectiveViewerGender: Gender? = null + var schedulesCanViewAdultContent: Boolean? = null + var audioContentsLatestAudioContentId: Long? = null + + override fun findCreator(creatorId: Long, viewerId: Long?): CreatorChannelCreatorRecord? = creator + + override fun existsBlockedBetween(viewerId: Long, creatorId: Long): Boolean = blocked + + override fun findCurrentLive( + creatorId: Long, + now: LocalDateTime, + canViewAdultContent: Boolean, + viewerId: Long?, + isViewerCreator: Boolean, + effectiveViewerGender: Gender? + ): CreatorChannelLiveRecord? { + currentLiveViewerId = viewerId + currentLiveIsViewerCreator = isViewerCreator + currentLiveEffectiveViewerGender = effectiveViewerGender + currentLiveCanViewAdultContent = canViewAdultContent + return CreatorChannelLiveRecord( + liveId = 101L, + title = "live", + coverImagePath = "live.png", + beginDateTime = LocalDateTime.of(2026, 6, 13, 9, 0), + price = 10, + isAdult = false + ) + } + + override fun findLatestAudioContent( + creatorId: Long, + now: LocalDateTime, + canViewAdultContent: Boolean + ): CreatorChannelAudioContentRecord? = audioContentRecord(201L, "audio/latest.png") + + override fun findChannelDonations( + creatorId: Long, + viewerId: Long?, + now: LocalDateTime, + limit: Int + ): List = listOf( + CreatorChannelDonationRecord( + nickname = "fan", + profileImagePath = "profile/fan.png", + can = 30, + message = "thanks", + createdAt = LocalDateTime.of(2026, 6, 13, 8, 0) + ) + ) + + override fun findCommunityPosts( + creatorId: Long, + viewerId: Long?, + isFixed: Boolean, + canViewAdultContent: Boolean, + limit: Int + ): List = listOf( + CreatorChannelCommunityPostRecord( + postId = if (isFixed) 301L else 302L, + creatorId = creatorId, + creatorNickname = "creator", + creatorProfilePath = "profile/creator.png", + imagePath = "community.png", + audioPath = "community.mp3", + content = if (isFixed) "notice" else "community", + price = 0, + date = LocalDateTime.of(2026, 6, 13, 7, 0), + existOrdered = false, + likeCount = 3, + commentCount = 4 + ) + ) + + override fun findSchedules( + creatorId: Long, + now: LocalDateTime, + canViewAdultContent: Boolean, + viewerId: Long?, + isViewerCreator: Boolean, + effectiveViewerGender: Gender?, + limit: Int + ): List { + schedulesViewerId = viewerId + schedulesIsViewerCreator = isViewerCreator + schedulesEffectiveViewerGender = effectiveViewerGender + schedulesCanViewAdultContent = canViewAdultContent + return listOf( + scheduleRecord(401L, LocalDateTime.of(2026, 6, 13, 12, 0), CreatorActivityType.AUDIO, false), + scheduleRecord(402L, LocalDateTime.of(2026, 6, 13, 12, 0), CreatorActivityType.LIVE, false), + scheduleRecord(403L, LocalDateTime.of(2026, 6, 13, 13, 0), CreatorActivityType.LIVE, true), + scheduleRecord(404L, LocalDateTime.of(2026, 6, 13, 14, 0), CreatorActivityType.AUDIO, false), + scheduleRecord(405L, LocalDateTime.of(2026, 6, 13, 15, 0), CreatorActivityType.LIVE, false), + scheduleRecord(406L, LocalDateTime.of(2026, 6, 13, 9, 0), CreatorActivityType.LIVE, false) + ) + } + + override fun findAudioContents( + creatorId: Long, + now: LocalDateTime, + latestAudioContentId: Long?, + canViewAdultContent: Boolean, + limit: Int + ): List { + audioContentsLatestAudioContentId = latestAudioContentId + return listOf( + audioContentRecord(201L, "audio/latest.png"), + audioContentRecord(203L, "audio/203.png"), + audioContentRecord(202L, "audio/202.png") + ) + } + + override fun findSeries( + creatorId: Long, + viewerId: Long?, + now: LocalDateTime, + canViewAdultContent: Boolean, + contentType: ContentType, + limit: Int + ): List = listOf( + CreatorChannelSeriesRecord( + seriesId = 501L, + title = "series", + coverImagePath = "series.png", + numberOfContent = 2, + isNew = true, + isOriginal = false + ) + ) + + override fun findFanTalkSummary(creatorId: Long, viewerId: Long?): CreatorChannelFanTalkSummaryRecord { + return CreatorChannelFanTalkSummaryRecord( + totalCount = 1, + latestFanTalk = CreatorChannelFanTalkRecord( + fanTalkId = 601L, + memberId = 10L, + nickname = "fan", + profileImagePath = "profile/fan.png", + content = "hello", + languageCode = "ko", + createdAt = LocalDateTime.of(2026, 6, 13, 6, 0) + ) + ) + } + + override fun findActivity(creatorId: Long, now: LocalDateTime): CreatorChannelActivityRecord { + return CreatorChannelActivityRecord( + debutDate = LocalDateTime.of(2026, 6, 1, 0, 0), + dDay = "D+12", + liveCount = 1, + liveDurationHours = 2, + liveContributorCount = 3, + audioContentCount = 4, + seriesCount = 5 + ) + } + + override fun findSns(creatorId: Long): CreatorChannelSnsRecord { + return CreatorChannelSnsRecord( + instagramUrl = "instagram", + fancimmUrl = "fancimm", + xUrl = "x", + youtubeUrl = "youtube", + kakaoOpenChatUrl = "kakao" + ) + } + + private fun audioContentRecord(audioContentId: Long, imagePath: String): CreatorChannelAudioContentRecord { + return CreatorChannelAudioContentRecord( + audioContentId = audioContentId, + title = "audio-$audioContentId", + duration = "00:10:00", + imagePath = imagePath, + price = 10, + isAdult = false, + isPointAvailable = true, + isFirstContent = false, + publishedAt = LocalDateTime.of(2026, 6, 10, 0, audioContentId.toInt() % 60), + seriesName = null, + isOriginalSeries = null + ) + } + + private fun scheduleRecord( + targetId: Long, + scheduledAt: LocalDateTime, + type: CreatorActivityType, + isAdult: Boolean + ): CreatorChannelScheduleRecord { + return CreatorChannelScheduleRecord( + scheduledAt = scheduledAt, + title = "schedule-$targetId", + type = type, + targetId = targetId, + isAdult = isAdult + ) + } }