From 90bf4c770c2311aece08dd2ce3305c0f7252fd5b Mon Sep 17 00:00:00 2001 From: Klaus Date: Mon, 22 Jun 2026 14:51:44 +0900 Subject: [PATCH] =?UTF-8?q?feat(creator-channel):=20FanTalk=20=ED=83=AD=20?= =?UTF-8?q?=EC=9D=91=EB=8B=B5=20=EC=A1=B0=EB=A6=BD=EC=9D=84=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../CreatorChannelFanTalkFacade.kt | 32 +++++ .../dto/CreatorChannelFanTalkTabResponse.kt | 74 +++++++++++ .../CreatorChannelFanTalkQueryService.kt | 21 ++++ .../CreatorChannelFanTalkFacadeTest.kt | 118 ++++++++++++++++++ 4 files changed, 245 insertions(+) create mode 100644 src/main/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/fantalk/application/CreatorChannelFanTalkFacade.kt create mode 100644 src/main/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/fantalk/dto/CreatorChannelFanTalkTabResponse.kt create mode 100644 src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/fantalk/application/CreatorChannelFanTalkQueryService.kt create mode 100644 src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/fantalk/application/CreatorChannelFanTalkFacadeTest.kt diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/fantalk/application/CreatorChannelFanTalkFacade.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/fantalk/application/CreatorChannelFanTalkFacade.kt new file mode 100644 index 00000000..079abb9d --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/fantalk/application/CreatorChannelFanTalkFacade.kt @@ -0,0 +1,32 @@ +package kr.co.vividnext.sodalive.v2.api.creator.channel.fantalk.application + +import kr.co.vividnext.sodalive.member.Member +import kr.co.vividnext.sodalive.v2.api.creator.channel.fantalk.dto.CreatorChannelFanTalkTabResponse +import kr.co.vividnext.sodalive.v2.creator.channel.fantalk.application.CreatorChannelFanTalkQueryService +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional +import java.time.LocalDateTime + +@Service +@Transactional(readOnly = true) +class CreatorChannelFanTalkFacade( + private val creatorChannelFanTalkQueryService: CreatorChannelFanTalkQueryService +) { + fun getFanTalkTab( + creatorId: Long, + viewer: Member, + page: Int?, + size: Int?, + now: LocalDateTime = LocalDateTime.now() + ): CreatorChannelFanTalkTabResponse { + return CreatorChannelFanTalkTabResponse.from( + creatorChannelFanTalkQueryService.getFanTalkTab( + creatorId = creatorId, + viewer = viewer, + page = page, + size = size, + now = now + ) + ) + } +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/fantalk/dto/CreatorChannelFanTalkTabResponse.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/fantalk/dto/CreatorChannelFanTalkTabResponse.kt new file mode 100644 index 00000000..b8ef31d0 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/fantalk/dto/CreatorChannelFanTalkTabResponse.kt @@ -0,0 +1,74 @@ +package kr.co.vividnext.sodalive.v2.api.creator.channel.fantalk.dto + +import com.fasterxml.jackson.annotation.JsonProperty +import kr.co.vividnext.sodalive.extensions.toUtcIso +import kr.co.vividnext.sodalive.v2.creator.channel.fantalk.domain.CreatorChannelFanTalk +import kr.co.vividnext.sodalive.v2.creator.channel.fantalk.domain.CreatorChannelFanTalkReply +import kr.co.vividnext.sodalive.v2.creator.channel.fantalk.domain.CreatorChannelFanTalkTab + +data class CreatorChannelFanTalkTabResponse( + val fanTalkCount: Int, + val fanTalks: List, + val page: Int, + val size: Int, + @JsonProperty("hasNext") + val hasNext: Boolean +) { + companion object { + fun from(tab: CreatorChannelFanTalkTab): CreatorChannelFanTalkTabResponse { + return CreatorChannelFanTalkTabResponse( + fanTalkCount = tab.fanTalkCount, + fanTalks = tab.fanTalks.map(CreatorChannelFanTalkResponse::from), + page = tab.page.page, + size = tab.page.size, + hasNext = tab.hasNext + ) + } + } +} + +data class CreatorChannelFanTalkResponse( + val fanTalkId: Long, + val writerId: Long, + val writerNickname: String, + val writerProfileImageUrl: String, + val content: String, + val createdAtUtc: String, + val creatorReplies: List +) { + companion object { + fun from(fanTalk: CreatorChannelFanTalk): CreatorChannelFanTalkResponse { + return CreatorChannelFanTalkResponse( + fanTalkId = fanTalk.fanTalkId, + writerId = fanTalk.writerId, + writerNickname = fanTalk.writerNickname, + writerProfileImageUrl = fanTalk.writerProfileImageUrl, + content = fanTalk.content, + createdAtUtc = fanTalk.createdAt.toUtcIso(), + creatorReplies = fanTalk.creatorReplies.map(CreatorChannelFanTalkReplyResponse::from) + ) + } + } +} + +data class CreatorChannelFanTalkReplyResponse( + val fanTalkId: Long, + val writerId: Long, + val writerNickname: String, + val writerProfileImageUrl: String, + val content: String, + val createdAtUtc: String +) { + companion object { + fun from(reply: CreatorChannelFanTalkReply): CreatorChannelFanTalkReplyResponse { + return CreatorChannelFanTalkReplyResponse( + fanTalkId = reply.fanTalkId, + writerId = reply.writerId, + writerNickname = reply.writerNickname, + writerProfileImageUrl = reply.writerProfileImageUrl, + content = reply.content, + createdAtUtc = reply.createdAt.toUtcIso() + ) + } + } +} 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 new file mode 100644 index 00000000..ac26ae26 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/fantalk/application/CreatorChannelFanTalkQueryService.kt @@ -0,0 +1,21 @@ +package kr.co.vividnext.sodalive.v2.creator.channel.fantalk.application + +import kr.co.vividnext.sodalive.member.Member +import kr.co.vividnext.sodalive.v2.creator.channel.fantalk.domain.CreatorChannelFanTalkTab +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional +import java.time.LocalDateTime + +@Service +@Transactional(readOnly = true) +class CreatorChannelFanTalkQueryService { + fun getFanTalkTab( + creatorId: Long, + viewer: Member, + page: Int?, + size: Int?, + now: LocalDateTime = LocalDateTime.now() + ): CreatorChannelFanTalkTab { + throw UnsupportedOperationException("CreatorChannelFanTalkQueryService is implemented in Phase 3") + } +} diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/fantalk/application/CreatorChannelFanTalkFacadeTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/fantalk/application/CreatorChannelFanTalkFacadeTest.kt new file mode 100644 index 00000000..f4272a21 --- /dev/null +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/fantalk/application/CreatorChannelFanTalkFacadeTest.kt @@ -0,0 +1,118 @@ +package kr.co.vividnext.sodalive.v2.api.creator.channel.fantalk.application + +import com.fasterxml.jackson.databind.ObjectMapper +import com.fasterxml.jackson.module.kotlin.KotlinModule +import kr.co.vividnext.sodalive.member.Member +import kr.co.vividnext.sodalive.member.MemberRole +import kr.co.vividnext.sodalive.v2.api.creator.channel.fantalk.dto.CreatorChannelFanTalkTabResponse +import kr.co.vividnext.sodalive.v2.creator.channel.fantalk.application.CreatorChannelFanTalkQueryService +import kr.co.vividnext.sodalive.v2.creator.channel.fantalk.domain.CreatorChannelFanTalk +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.live.domain.CreatorChannelPage +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertFalse +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 CreatorChannelFanTalkFacadeTest { + @Test + @DisplayName("FanTalk 탭 응답 DTO는 domain tab 값을 공개 응답 필드와 UTC 문자열로 매핑한다") + fun shouldMapFanTalkTabDomainToPublicResponse() { + val response = CreatorChannelFanTalkTabResponse.from(createTab()) + + assertEquals(2, response.fanTalkCount) + assertEquals(101L, response.fanTalks.first().fanTalkId) + assertEquals(10L, response.fanTalks.first().writerId) + assertEquals("fan", response.fanTalks.first().writerNickname) + assertEquals("https://cdn.test/fan.png", response.fanTalks.first().writerProfileImageUrl) + assertEquals("fan talk", response.fanTalks.first().content) + assertEquals("2026-06-21T03:30:00Z", response.fanTalks.first().createdAtUtc) + assertEquals(201L, response.fanTalks.first().creatorReplies.first().fanTalkId) + assertEquals(1L, response.fanTalks.first().creatorReplies.first().writerId) + assertEquals("creator", response.fanTalks.first().creatorReplies.first().writerNickname) + assertEquals("https://cdn.test/creator.png", response.fanTalks.first().creatorReplies.first().writerProfileImageUrl) + assertEquals("creator reply", response.fanTalks.first().creatorReplies.first().content) + assertEquals("2026-06-21T03:35:00Z", response.fanTalks.first().creatorReplies.first().createdAtUtc) + assertEquals(1, response.page) + assertEquals(20, response.size) + assertTrue(response.hasNext) + + val mapper = ObjectMapper().registerModule(KotlinModule.Builder().build()) + val json = mapper.readTree(mapper.writeValueAsString(response)) + assertTrue(json["hasNext"].asBoolean()) + assertFalse(json.has("languageCode")) + } + + @Test + @DisplayName("FanTalk 탭 facade는 query service 결과를 공개 응답 DTO로 변환한다") + fun shouldMapFanTalkTabQueryResultToPublicResponse() { + val service = Mockito.mock(CreatorChannelFanTalkQueryService::class.java) + val facade = CreatorChannelFanTalkFacade(service) + val viewer = createMember(id = 10L) + val now = LocalDateTime.of(2026, 6, 21, 12, 0) + Mockito.doReturn(createTab()).`when`(service).getFanTalkTab( + creatorId = 1L, + viewer = viewer, + page = -1, + size = 100, + now = now + ) + + val response = facade.getFanTalkTab( + creatorId = 1L, + viewer = viewer, + page = -1, + size = 100, + now = now + ) + + assertEquals(2, response.fanTalkCount) + assertEquals(101L, response.fanTalks.first().fanTalkId) + assertEquals("https://cdn.test/fan.png", response.fanTalks.first().writerProfileImageUrl) + assertEquals(201L, response.fanTalks.first().creatorReplies.first().fanTalkId) + assertEquals(1, response.page) + assertEquals(20, response.size) + assertTrue(response.hasNext) + } + + private fun createMember(id: Long): Member { + return Member( + email = "viewer$id@test.com", + password = "password", + nickname = "viewer$id", + role = MemberRole.USER + ).apply { this.id = id } + } + + private fun createTab(): CreatorChannelFanTalkTab { + return CreatorChannelFanTalkTab( + fanTalkCount = 2, + fanTalks = listOf( + CreatorChannelFanTalk( + fanTalkId = 101L, + writerId = 10L, + writerNickname = "fan", + writerProfileImageUrl = "https://cdn.test/fan.png", + content = "fan talk", + createdAt = LocalDateTime.of(2026, 6, 21, 3, 30), + creatorReplies = listOf( + CreatorChannelFanTalkReply( + fanTalkId = 201L, + writerId = 1L, + writerNickname = "creator", + writerProfileImageUrl = "https://cdn.test/creator.png", + content = "creator reply", + createdAt = LocalDateTime.of(2026, 6, 21, 3, 35) + ) + ) + ) + ), + page = CreatorChannelPage(page = 1, size = 20), + hasNext = true + ) + } +}