From 5a11c2e53388c61060ef32f6598a59a0a9c78ea7 Mon Sep 17 00:00:00 2001 From: Klaus Date: Thu, 10 Sep 2026 15:14:16 +0900 Subject: [PATCH] =?UTF-8?q?feat(creator-channel):=20=EC=BB=A4=EB=AE=A4?= =?UTF-8?q?=EB=8B=88=ED=8B=B0=20=EC=A1=B0=ED=9A=8C=EC=97=90=20=EB=B3=B8?= =?UTF-8?q?=EB=AC=B8=20=EB=B2=88=EC=97=AD=EC=9D=84=20=EC=A0=81=EC=9A=A9?= =?UTF-8?q?=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../CreatorChannelCommunityQueryService.kt | 27 +++- .../CreatorChannelCommunityEndToEndTest.kt | 152 ++++++++++++++++++ ...CreatorChannelCommunityQueryServiceTest.kt | 109 ++++++++++++- 3 files changed, 283 insertions(+), 5 deletions(-) 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 index 1c3f3c5f..785d6015 100644 --- 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 @@ -21,6 +21,7 @@ import kr.co.vividnext.sodalive.v2.creator.channel.community.port.out.CreatorCha 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 kr.co.vividnext.sodalive.v2.creator.channel.community.translation.application.CreatorCommunityTranslationService import org.springframework.beans.factory.ObjectProvider import org.springframework.beans.factory.annotation.Value import org.springframework.stereotype.Service @@ -36,6 +37,7 @@ class CreatorChannelCommunityQueryService( private val audioContentCloudFront: AudioContentCloudFront, private val messageSource: SodaMessageSource, private val langContext: LangContext, + private val creatorCommunityTranslationService: CreatorCommunityTranslationService, @Value("\${cloud.aws.cloud-front.host}") private val cloudFrontHost: String ) { @@ -69,6 +71,9 @@ class CreatorChannelCommunityQueryService( offset = communityPage.offset, limit = communityPage.fetchLimit ) + val communityPosts = queryPolicy.limitItems(fetchedPosts, communityPage) + val displayContents = creatorCommunityTranslationService + .findDisplayContents(communityPosts.map { it.postId }, langContext.lang.code) return CreatorChannelCommunityTab( communityPostCount = queryPort.countCommunityPosts( @@ -76,7 +81,9 @@ class CreatorChannelCommunityQueryService( viewerId = viewerId, canViewAdultContent = canViewAdultContent ), - communityPosts = queryPolicy.limitItems(fetchedPosts, communityPage).map { it.toDomain(viewerId) }, + communityPosts = communityPosts.map { + it.copy(content = displayContents[it.postId]?.content ?: it.content).toDomain(viewerId) + }, page = communityPage, hasNext = queryPolicy.hasNext(fetchedPosts, communityPage) ) @@ -89,7 +96,7 @@ class CreatorChannelCommunityQueryService( canViewAdultContent: Boolean, limit: Int ): List { - return queryPortProvider.getObject() + val posts = queryPortProvider.getObject() .findHomeCommunityPosts( creatorId = creatorId, viewerId = viewerId, @@ -97,7 +104,12 @@ class CreatorChannelCommunityQueryService( canViewAdultContent = canViewAdultContent, limit = limit ) - .map { it.toDomain(viewerId) } + val displayContents = creatorCommunityTranslationService + .findDisplayContents(posts.map { it.postId }, langContext.lang.code) + + return posts.map { + it.copy(content = displayContents[it.postId]?.content ?: it.content).toDomain(viewerId) + } } fun getCommunityPostDetail( @@ -113,8 +125,15 @@ class CreatorChannelCommunityQueryService( validateNotBlocked(queryPort, viewerId, postRecord.creatorId) + val locale = langContext.lang.code + val displayContent = creatorCommunityTranslationService + .findDisplayContents(listOf(postRecord.postId), locale)[postRecord.postId] + if (displayContent?.hasValidTranslation != true) { + creatorCommunityTranslationService.requestTranslations(postRecord.postId, locale) + } + return CreatorChannelCommunityPostDetail( - post = postRecord.toDomain(viewerId), + post = postRecord.copy(content = displayContent?.content ?: postRecord.content).toDomain(viewerId), comments = getCommunityComments(postId, viewer, page = 0, size = 20, now = now) ) } diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/community/adapter/in/web/CreatorChannelCommunityEndToEndTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/community/adapter/in/web/CreatorChannelCommunityEndToEndTest.kt index c805fb01..f0303992 100644 --- a/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/community/adapter/in/web/CreatorChannelCommunityEndToEndTest.kt +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/community/adapter/in/web/CreatorChannelCommunityEndToEndTest.kt @@ -13,6 +13,8 @@ import kr.co.vividnext.sodalive.member.MemberRole import kr.co.vividnext.sodalive.member.block.BlockMember import kr.co.vividnext.sodalive.member.contentpreference.MemberContentPreference import kr.co.vividnext.sodalive.support.EmbeddedRedisInitializer +import kr.co.vividnext.sodalive.v2.creator.channel.community.translation.application.CreatorCommunityDisplayContent +import kr.co.vividnext.sodalive.v2.creator.channel.community.translation.application.CreatorCommunityTranslationService import org.junit.jupiter.api.DisplayName import org.junit.jupiter.api.Test import org.mockito.Mockito @@ -47,6 +49,9 @@ class CreatorChannelCommunityEndToEndTest @Autowired constructor( @MockBean private lateinit var audioContentCloudFront: AudioContentCloudFront + @MockBean + private lateinit var creatorCommunityTranslationService: CreatorCommunityTranslationService + @Test @DisplayName("커뮤니티 탭 API는 E2E로 정렬, fallback, 성인 필터, 유료 미디어 접근 정책을 반환한다") fun shouldReturnCommunityTabThroughControllerServiceAndRepository() { @@ -97,6 +102,41 @@ class CreatorChannelCommunityEndToEndTest @Autowired constructor( Mockito.verifyNoMoreInteractions(audioContentCloudFront) } + @Test + @DisplayName("커뮤니티 탭 API는 저장 번역을 페이지 단위로 선택하고 유료 미리보기를 유지한다") + fun shouldUseStoredTranslationsForCommunityTabWithoutScheduling() { + val fixture = createFixture() + val postIds = listOf( + fixture.pinnedPostId, + fixture.purchasedPaidPostId, + fixture.unpurchasedPaidPostId, + fixture.noImagePostId + ) + Mockito.`when`(creatorCommunityTranslationService.findDisplayContents(postIds, "ja")) + .thenReturn( + mapOf( + fixture.pinnedPostId to CreatorCommunityDisplayContent("translated pinned", true), + fixture.purchasedPaidPostId to CreatorCommunityDisplayContent("translated purchased", true), + fixture.unpurchasedPaidPostId to CreatorCommunityDisplayContent("가나다라마바사아자차카타파하🙂끝", true), + fixture.noImagePostId to CreatorCommunityDisplayContent("no image community", false) + ) + ) + + mockMvc.perform( + get("/api/v2/creator-channels/${fixture.creatorId}/community") + .header("Accept-Language", "ja") + .with(user(MemberAdapter(fixture.viewer))) + ) + .andExpect(status().isOk) + .andExpect(jsonPath("$.data.communityPosts[0].content").value("translated pinned")) + .andExpect(jsonPath("$.data.communityPosts[1].content").value("translated purchased")) + .andExpect(jsonPath("$.data.communityPosts[2].content").value("가나다라마바사아자차카타파하🙂...")) + .andExpect(jsonPath("$.data.communityPosts[3].content").value("no image community")) + + Mockito.verify(creatorCommunityTranslationService).findDisplayContents(postIds, "ja") + Mockito.verifyNoMoreInteractions(creatorCommunityTranslationService) + } + @Test @DisplayName("커뮤니티 상세 API는 초기 댓글 20개와 최신 답글을 반환한다") fun shouldReturnCommunityPostDetailWithInitialComments() { @@ -115,6 +155,74 @@ class CreatorChannelCommunityEndToEndTest @Autowired constructor( .andExpect(jsonPath("$.data.comments.comments[0].createdAtUtc").value(org.hamcrest.Matchers.endsWith("Z"))) } + @Test + @DisplayName("커뮤니티 상세 API는 저장 번역문에 유료 미리보기를 적용하고 요청 언어만 예약한다") + fun shouldSelectTranslatedContentBeforePaidMaskingAndRequestCurrentLanguage() { + val fixture = createTranslatedPaidDetailFixture(purchased = false) + Mockito.`when`(creatorCommunityTranslationService.findDisplayContents(listOf(fixture.postId), "ja")) + .thenReturn(mapOf(fixture.postId to CreatorCommunityDisplayContent("translated paid content", true))) + + mockMvc.perform( + get("/api/v2/creator-channels/community-posts/${fixture.postId}") + .header("Accept-Language", "ja") + .with(user(MemberAdapter(fixture.viewer))) + ) + .andExpect(status().isOk) + .andExpect(jsonPath("$.data.content").value("translated paid...")) + + Mockito.verify(creatorCommunityTranslationService, Mockito.never()).requestTranslations(fixture.postId, "ja") + } + + @Test + @DisplayName("커뮤니티 상세 API는 구매자에게 선택한 유료 번역문 전체를 반환한다") + fun shouldReturnFullTranslatedContentToBuyer() { + val fixture = createTranslatedPaidDetailFixture(purchased = true) + Mockito.`when`(creatorCommunityTranslationService.findDisplayContents(listOf(fixture.postId), "ja")) + .thenReturn(mapOf(fixture.postId to CreatorCommunityDisplayContent("translated paid content", true))) + + mockMvc.perform( + get("/api/v2/creator-channels/community-posts/${fixture.postId}") + .header("Accept-Language", "ja") + .with(user(MemberAdapter(fixture.viewer))) + ) + .andExpect(status().isOk) + .andExpect(jsonPath("$.data.content").value("translated paid content")) + } + + @Test + @DisplayName("커뮤니티 상세 API는 소유자에게 선택한 유료 번역문 전체를 반환한다") + fun shouldReturnFullTranslatedContentToOwner() { + val fixture = createTranslatedPaidOwnerDetailFixture() + Mockito.`when`(creatorCommunityTranslationService.findDisplayContents(listOf(fixture.postId), "ja")) + .thenReturn(mapOf(fixture.postId to CreatorCommunityDisplayContent("translated paid content", true))) + + mockMvc.perform( + get("/api/v2/creator-channels/community-posts/${fixture.postId}") + .header("Accept-Language", "ja") + .with(user(MemberAdapter(fixture.viewer))) + ) + .andExpect(status().isOk) + .andExpect(jsonPath("$.data.content").value("translated paid content")) + } + + @Test + @DisplayName("커뮤니티 상세 API는 반복 요청을 모두 성공시키고 같은 요청 언어만 전달한다") + fun shouldKeepRepeatedCommunityDetailRequestsSuccessful() { + val fixture = createTranslatedPaidDetailFixture(purchased = false) + Mockito.`when`(creatorCommunityTranslationService.findDisplayContents(listOf(fixture.postId), "ja")) + .thenReturn(emptyMap()) + + repeat(2) { + mockMvc.perform( + get("/api/v2/creator-channels/community-posts/${fixture.postId}") + .header("Accept-Language", "ja") + .with(user(MemberAdapter(fixture.viewer))) + ).andExpect(status().isOk) + } + + Mockito.verify(creatorCommunityTranslationService, Mockito.times(2)).requestTranslations(fixture.postId, "ja") + } + @Test @DisplayName("커뮤니티 댓글 API는 댓글 페이지와 최신 답글을 반환한다") fun shouldReturnPagedCommunityCommentsWithLatestReply() { @@ -131,6 +239,8 @@ class CreatorChannelCommunityEndToEndTest @Autowired constructor( .andExpect(jsonPath("$.data.comments.length()").value(20)) .andExpect(jsonPath("$.data.hasNext").value(true)) .andExpect(jsonPath("$.data.comments[0].latestReply.commentId").value(fixture.latestReplyId)) + + Mockito.verifyNoInteractions(creatorCommunityTranslationService) } @Test @@ -148,6 +258,8 @@ class CreatorChannelCommunityEndToEndTest @Autowired constructor( .andExpect(jsonPath("$.data.replyCount").value(1)) .andExpect(jsonPath("$.data.replies[0].commentId").value(fixture.latestReplyId)) .andExpect(jsonPath("$.data.replies[0].latestReply").doesNotExist()) + + Mockito.verifyNoInteractions(creatorCommunityTranslationService) } @Test @@ -176,6 +288,8 @@ class CreatorChannelCommunityEndToEndTest @Autowired constructor( ) .andExpect(status().isOk) .andExpect(jsonPath("$.success").value(false)) + + Mockito.verifyNoInteractions(creatorCommunityTranslationService) } @Test @@ -189,6 +303,8 @@ class CreatorChannelCommunityEndToEndTest @Autowired constructor( ) .andExpect(status().isOk) .andExpect(jsonPath("$.success").value(false)) + + Mockito.verifyNoInteractions(creatorCommunityTranslationService) } @Test @@ -435,6 +551,42 @@ class CreatorChannelCommunityEndToEndTest @Autowired constructor( }!! } + private fun createTranslatedPaidDetailFixture(purchased: Boolean): PostFixture { + return transactionTemplate.execute { + val viewer = saveMember("community-translated-viewer-$purchased", MemberRole.USER) + val creator = saveMember("community-translated-creator-$purchased", MemberRole.CREATOR) + savePreference(viewer, isAdultContentVisible = true) + val post = saveCommunity( + creator = creator, + isFixed = false, + price = 100, + content = "source paid content" + ) + if (purchased) { + saveCommunityOrder(viewer, post) + } + entityManager.flush() + entityManager.clear() + PostFixture(viewer = viewer, postId = post.id!!) + }!! + } + + private fun createTranslatedPaidOwnerDetailFixture(): PostFixture { + return transactionTemplate.execute { + val creator = saveMember("community-translated-owner", MemberRole.CREATOR) + savePreference(creator, isAdultContentVisible = true) + val post = saveCommunity( + creator = creator, + isFixed = false, + price = 100, + content = "source paid content" + ) + entityManager.flush() + entityManager.clear() + PostFixture(viewer = creator, postId = post.id!!) + }!! + } + private fun createCommentUnavailableFixture(): PostFixture { return transactionTemplate.execute { val viewer = saveMember("community-unavailable-viewer", MemberRole.USER) 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 index 6fd723c6..2a6b8dc9 100644 --- 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 @@ -15,6 +15,8 @@ import kr.co.vividnext.sodalive.v2.creator.channel.community.port.out.CreatorCha 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 kr.co.vividnext.sodalive.v2.creator.channel.community.translation.application.CreatorCommunityDisplayContent +import kr.co.vividnext.sodalive.v2.creator.channel.community.translation.application.CreatorCommunityTranslationService import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Assertions.assertNull import org.junit.jupiter.api.Assertions.assertThrows @@ -175,6 +177,92 @@ class CreatorChannelCommunityQueryServiceTest { assertEquals(true, posts.single().isLiked) } + @Test + @DisplayName("커뮤니티 탭은 페이지 본문을 한 번에 번역 선택한 뒤 유료 미리보기를 적용한다") + fun shouldUseStoredTranslationsOnceForCommunityTabBeforePaidMasking() { + val port = FakeCreatorChannelCommunityQueryPort().apply { + communityPosts = listOf( + communityPostRecord(11L, price = 0).copy(content = "source free"), + communityPostRecord(12L, price = 100).copy(content = "source paid"), + communityPostRecord(13L, price = 100, existOrdered = true).copy(content = "source buyer"), + communityPostRecord(14L, creatorId = 10L, price = 100).copy(content = "source owner"), + communityPostRecord(15L, price = 0).copy(content = "source stale") + ) + } + val translationService = Mockito.mock(CreatorCommunityTranslationService::class.java) + val service = createService(port, creatorCommunityTranslationService = translationService) + val postIds = port.communityPosts.map { it.postId } + Mockito.`when`(translationService.findDisplayContents(postIds, "en")) + .thenReturn( + mapOf( + 11L to CreatorCommunityDisplayContent("translated free", true), + 12L to CreatorCommunityDisplayContent("가나다라마바사아자차카타파하🙂끝", true), + 13L to CreatorCommunityDisplayContent("translated buyer", true), + 14L to CreatorCommunityDisplayContent("translated owner", true), + 15L to CreatorCommunityDisplayContent("source stale", false) + ) + ) + + val posts = service.getCommunityTab(1L, createMember(id = 10L), 0, 20).communityPosts + + assertEquals( + listOf( + "translated free", + "가나다라마바사아자차카타파하🙂...", + "translated buyer", + "translated owner", + "source stale" + ), + posts.map { it.content } + ) + Mockito.verify(translationService).findDisplayContents(postIds, "en") + Mockito.verifyNoMoreInteractions(translationService) + } + + @Test + @DisplayName("커뮤니티 탭 번역 조회는 fetchLimit 다음 게시글을 제외한 반환 페이지 ID만 사용한다") + fun shouldExcludeFetchLimitPlusOnePostFromCommunityTabTranslationLookup() { + val port = FakeCreatorChannelCommunityQueryPort().apply { + communityPosts = (1L..51L).map { communityPostRecord(it, price = 0) } + } + val translationService = Mockito.mock(CreatorCommunityTranslationService::class.java) + val service = createService(port, creatorCommunityTranslationService = translationService) + val returnedPostIds = (1L..50L).toList() + Mockito.`when`(translationService.findDisplayContents(returnedPostIds, "en")).thenReturn(emptyMap()) + + val tab = service.getCommunityTab(1L, createMember(id = 10L), page = 0, size = 50) + + assertEquals(50, tab.communityPosts.size) + assertTrue(tab.hasNext) + Mockito.verify(translationService).findDisplayContents(returnedPostIds, "en") + Mockito.verifyNoMoreInteractions(translationService) + } + + @Test + @DisplayName("채널 홈의 고정과 일반 본문은 각 그룹에서 번역 선택만 하고 작업을 만들지 않는다") + fun shouldUseStoredTranslationsForPinnedAndNormalHomeCommunityPostsWithoutScheduling() { + val port = FakeCreatorChannelCommunityQueryPort() + val translationService = Mockito.mock(CreatorCommunityTranslationService::class.java) + val service = createService(port, creatorCommunityTranslationService = translationService) + port.homeCommunityPosts = listOf(communityPostRecord(21L, price = 0).copy(content = "source pinned")) + Mockito.`when`(translationService.findDisplayContents(listOf(21L), "en")) + .thenReturn(mapOf(21L to CreatorCommunityDisplayContent("translated pinned", true))) + + val pinnedPosts = service.findHomeCommunityPosts(1L, 10L, true, false, 3) + + port.homeCommunityPosts = listOf(communityPostRecord(22L, price = 0).copy(content = "source normal")) + Mockito.`when`(translationService.findDisplayContents(listOf(22L), "en")) + .thenReturn(mapOf(22L to CreatorCommunityDisplayContent("translated normal", true))) + + val normalPosts = service.findHomeCommunityPosts(1L, 10L, false, false, 3) + + assertEquals("translated pinned", pinnedPosts.single().content) + assertEquals("translated normal", normalPosts.single().content) + Mockito.verify(translationService).findDisplayContents(listOf(21L), "en") + Mockito.verify(translationService).findDisplayContents(listOf(22L), "en") + Mockito.verifyNoMoreInteractions(translationService) + } + @Test @DisplayName("커뮤니티 상세는 게시글과 초기 댓글 및 최신 답글을 조립한다") fun shouldAssembleCommunityPostDetailWithInitialComments() { @@ -201,6 +289,20 @@ class CreatorChannelCommunityQueryServiceTest { assertEquals("https://cdn.test/profile/default-profile.png", detail.comments.comments.first().writerProfileImageUrl) } + @Test + @DisplayName("v2 상세는 원문과 같은 유효 번역문에도 누락 예약을 요청하지 않는다") + fun shouldNotRequestTranslationsWhenV2DetailHasValidTranslationMatchingSourceText() { + val port = FakeCreatorChannelCommunityQueryPort() + val translationService = Mockito.mock(CreatorCommunityTranslationService::class.java) + val service = createService(port, creatorCommunityTranslationService = translationService) + Mockito.`when`(translationService.findDisplayContents(listOf(1L), "en")) + .thenReturn(mapOf(1L to CreatorCommunityDisplayContent("content-1", true))) + + service.getCommunityPostDetail(1L, createMember(id = 10L), LocalDateTime.of(2026, 7, 6, 10, 0)) + + Mockito.verify(translationService, Mockito.never()).requestTranslations(1L, "en") + } + @Test @DisplayName("댓글 불가 게시글은 댓글 port를 호출하지 않고 빈 댓글 응답을 반환한다") fun shouldReturnEmptyCommentsWhenCommentUnavailable() { @@ -298,7 +400,9 @@ class CreatorChannelCommunityQueryServiceTest { private fun createService( port: FakeCreatorChannelCommunityQueryPort, audioContentCloudFront: AudioContentCloudFront = Mockito.mock(AudioContentCloudFront::class.java), - canViewAdultContent: Boolean = true + canViewAdultContent: Boolean = true, + creatorCommunityTranslationService: CreatorCommunityTranslationService = + Mockito.mock(CreatorCommunityTranslationService::class.java) ): CreatorChannelCommunityQueryService { val preferenceService = Mockito.mock(MemberContentPreferenceService::class.java) Mockito.`when`( @@ -306,6 +410,8 @@ class CreatorChannelCommunityQueryServiceTest { ).thenReturn(canViewAdultContent) val langContext = LangContext() langContext.setLang(Lang.EN) + Mockito.`when`(creatorCommunityTranslationService.findDisplayContents(Mockito.anyList(), Mockito.anyString())) + .thenReturn(emptyMap()) return CreatorChannelCommunityQueryService( queryPortProvider = FixedCreatorChannelCommunityQueryPortProvider(port), queryPolicy = CreatorChannelCommunityQueryPolicy(), @@ -313,6 +419,7 @@ class CreatorChannelCommunityQueryServiceTest { audioContentCloudFront = audioContentCloudFront, messageSource = SodaMessageSource(), langContext = langContext, + creatorCommunityTranslationService = creatorCommunityTranslationService, cloudFrontHost = "https://cdn.test" ) }