feat(home-following): 커뮤니티 소식 본문 번역을 적용한다

This commit is contained in:
2026-09-10 15:14:35 +09:00
parent 02fceca353
commit 0d44258a61
3 changed files with 143 additions and 4 deletions
@@ -1,9 +1,12 @@
package kr.co.vividnext.sodalive.v2.home.following.application package kr.co.vividnext.sodalive.v2.home.following.application
import kr.co.vividnext.sodalive.i18n.LangContext
import kr.co.vividnext.sodalive.member.Gender import kr.co.vividnext.sodalive.member.Gender
import kr.co.vividnext.sodalive.member.Member import kr.co.vividnext.sodalive.member.Member
import kr.co.vividnext.sodalive.member.MemberRole import kr.co.vividnext.sodalive.member.MemberRole
import kr.co.vividnext.sodalive.member.contentpreference.MemberContentPreferenceService import kr.co.vividnext.sodalive.member.contentpreference.MemberContentPreferenceService
import kr.co.vividnext.sodalive.v2.creator.channel.community.translation.application.CreatorCommunityTranslationService
import kr.co.vividnext.sodalive.v2.home.following.domain.FollowingNewsType
import kr.co.vividnext.sodalive.v2.home.following.domain.HomeFollowing import kr.co.vividnext.sodalive.v2.home.following.domain.HomeFollowing
import kr.co.vividnext.sodalive.v2.home.following.port.out.HomeFollowingQueryPort import kr.co.vividnext.sodalive.v2.home.following.port.out.HomeFollowingQueryPort
import org.springframework.stereotype.Service import org.springframework.stereotype.Service
@@ -16,6 +19,8 @@ import java.time.LocalDateTime
class HomeFollowingQueryService( class HomeFollowingQueryService(
private val queryPort: HomeFollowingQueryPort, private val queryPort: HomeFollowingQueryPort,
private val memberContentPreferenceService: MemberContentPreferenceService, private val memberContentPreferenceService: MemberContentPreferenceService,
private val langContext: LangContext,
private val creatorCommunityTranslationService: CreatorCommunityTranslationService,
private val nowProvider: () -> LocalDateTime = { LocalDateTime.now(Clock.systemUTC()) } private val nowProvider: () -> LocalDateTime = { LocalDateTime.now(Clock.systemUTC()) }
) { ) {
fun findHomeFollowing(member: Member): HomeFollowing { fun findHomeFollowing(member: Member): HomeFollowing {
@@ -24,6 +29,15 @@ class HomeFollowingQueryService(
val effectiveViewerGender = member.effectiveGender() val effectiveViewerGender = member.effectiveGender()
val isViewerCreator = member.role == MemberRole.CREATOR val isViewerCreator = member.role == MemberRole.CREATOR
val now = nowProvider() val now = nowProvider()
val recentNews = queryPort.findRecentNews(memberId, canViewAdultContent, now, RECENT_NEWS_LIMIT)
val communityPostIds = recentNews.mapNotNull { news ->
if (news.type == FollowingNewsType.COMMUNITY_POST) news.communityPost?.postId else null
}
val displayContents = if (communityPostIds.isEmpty()) {
emptyMap()
} else {
creatorCommunityTranslationService.findDisplayContents(communityPostIds, langContext.lang.code)
}
return HomeFollowing( return HomeFollowing(
followingCreators = queryPort.findFollowingCreators(memberId, FOLLOWING_CREATORS_LIMIT), followingCreators = queryPort.findFollowingCreators(memberId, FOLLOWING_CREATORS_LIMIT),
@@ -43,7 +57,14 @@ class HomeFollowingQueryService(
now, now,
MONTHLY_SCHEDULES_LIMIT MONTHLY_SCHEDULES_LIMIT
), ),
recentNews = queryPort.findRecentNews(memberId, canViewAdultContent, now, RECENT_NEWS_LIMIT) recentNews = recentNews.map { news ->
if (news.type != FollowingNewsType.COMMUNITY_POST) return@map news
val communityPost = news.communityPost ?: return@map news
displayContents[communityPost.postId]
?.let { displayContent -> news.copy(communityPost = communityPost.copy(content = displayContent.content)) }
?: news
}
) )
} }
@@ -18,6 +18,8 @@ import kr.co.vividnext.sodalive.member.MemberKind
import kr.co.vividnext.sodalive.member.MemberRole import kr.co.vividnext.sodalive.member.MemberRole
import kr.co.vividnext.sodalive.member.following.CreatorFollowing import kr.co.vividnext.sodalive.member.following.CreatorFollowing
import kr.co.vividnext.sodalive.support.EmbeddedRedisInitializer 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 kr.co.vividnext.sodalive.v2.home.following.adapter.out.persistence.HomeFollowingNewsInbox import kr.co.vividnext.sodalive.v2.home.following.adapter.out.persistence.HomeFollowingNewsInbox
import kr.co.vividnext.sodalive.v2.home.following.domain.FollowingNewsType import kr.co.vividnext.sodalive.v2.home.following.domain.FollowingNewsType
import kr.co.vividnext.sodalive.v2.ranking.adapter.out.persistence.CreatorRankingSnapshot import kr.co.vividnext.sodalive.v2.ranking.adapter.out.persistence.CreatorRankingSnapshot
@@ -63,6 +65,9 @@ class HomeFollowingEndToEndTest @Autowired constructor(
@MockBean @MockBean
private lateinit var countryContext: CountryContext private lateinit var countryContext: CountryContext
@MockBean
private lateinit var creatorCommunityTranslationService: CreatorCommunityTranslationService
@Test @Test
@DisplayName("팔로잉 탭 API는 비회원에게 200 OK와 로그인 필요 빈 섹션 응답을 반환한다") @DisplayName("팔로잉 탭 API는 비회원에게 200 OK와 로그인 필요 빈 섹션 응답을 반환한다")
fun shouldReturnLoginRequiredForAnonymous() { fun shouldReturnLoginRequiredForAnonymous() {
@@ -137,6 +142,56 @@ class HomeFollowingEndToEndTest @Autowired constructor(
.andExpect(jsonPath("$.data.recentNews[?(@.communityPost.postId == ${fixture.paidPostId})]").isEmpty) .andExpect(jsonPath("$.data.recentNews[?(@.communityPost.postId == ${fixture.paidPostId})]").isEmpty)
} }
@Test
@DisplayName("인증 회원 팔로잉 탭은 노출된 커뮤니티 소식만 영어 표시 본문으로 반환하고 다른 소식은 유지한다")
fun shouldReturnEnglishCommunityTranslationForAuthenticatedFollowingTab() {
Mockito.doReturn("US").`when`(countryContext).countryCode
val fixture = createFixture()
val fallbackPostId = transactionTemplate.execute {
val creator = entityManager.find(Member::class.java, fixture.creatorId)
val fallbackPost = saveCommunityPost(
creator,
"historical following fallback body",
price = 0,
imagePath = "community/fallback.png"
)
saveNews(
memberId = fixture.viewer.id!!,
creatorId = fixture.creatorId,
sourceKey = "fallback-community-news",
visibleFromAtUtc = LocalDateTime.now(ZoneOffset.UTC).minusMinutes(10),
newsType = FollowingNewsType.COMMUNITY_POST,
targetId = fallbackPost.id!!
)
entityManager.flush()
fallbackPost.id!!
}!!
Mockito.`when`(creatorCommunityTranslationService.findDisplayContents(listOf(fallbackPostId, fixture.freePostId), "en"))
.thenReturn(
mapOf(
fallbackPostId to CreatorCommunityDisplayContent("current following fallback body en", false),
fixture.freePostId to CreatorCommunityDisplayContent("translated following body en", true)
)
)
mockMvc.perform(
get("/api/v2/home/following")
.header("Accept-Language", "en")
.with(user(MemberAdapter(fixture.viewer)))
)
.andExpect(status().isOk)
.andExpect(jsonPath("$.data.recentNews[0].type").value("COMMUNITY_POST"))
.andExpect(jsonPath("$.data.recentNews[0].communityPost.content").value("current following fallback body en"))
.andExpect(jsonPath("$.data.recentNews[1].type").value("CREATOR_RANKING"))
.andExpect(jsonPath("$.data.recentNews[2].type").value("COMMUNITY_POST"))
.andExpect(jsonPath("$.data.recentNews[2].communityPost.content").value("translated following body en"))
.andExpect(jsonPath("$.data.recentNews[3].type").value("AUDIO_CONTENT"))
.andExpect(jsonPath("$.data.recentNews[3].audioContent.title").value("home-following-audio"))
Mockito.verify(creatorCommunityTranslationService).findDisplayContents(listOf(fallbackPostId, fixture.freePostId), "en")
Mockito.verifyNoMoreInteractions(creatorCommunityTranslationService)
}
private fun createFixture(): Fixture { private fun createFixture(): Fixture {
return transactionTemplate.execute { return transactionTemplate.execute {
val now = LocalDateTime.now(ZoneOffset.UTC) val now = LocalDateTime.now(ZoneOffset.UTC)
@@ -1,10 +1,16 @@
package kr.co.vividnext.sodalive.v2.home.following.application package kr.co.vividnext.sodalive.v2.home.following.application
import kr.co.vividnext.sodalive.i18n.Lang
import kr.co.vividnext.sodalive.i18n.LangContext
import kr.co.vividnext.sodalive.member.Gender import kr.co.vividnext.sodalive.member.Gender
import kr.co.vividnext.sodalive.member.Member import kr.co.vividnext.sodalive.member.Member
import kr.co.vividnext.sodalive.member.MemberRole import kr.co.vividnext.sodalive.member.MemberRole
import kr.co.vividnext.sodalive.member.auth.Auth import kr.co.vividnext.sodalive.member.auth.Auth
import kr.co.vividnext.sodalive.member.contentpreference.MemberContentPreferenceService import kr.co.vividnext.sodalive.member.contentpreference.MemberContentPreferenceService
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 kr.co.vividnext.sodalive.v2.home.following.domain.FollowingNewsType
import kr.co.vividnext.sodalive.v2.home.following.domain.HomeFollowingCommunityPostNews
import kr.co.vividnext.sodalive.v2.home.following.domain.HomeFollowingCreator import kr.co.vividnext.sodalive.v2.home.following.domain.HomeFollowingCreator
import kr.co.vividnext.sodalive.v2.home.following.domain.HomeFollowingLive import kr.co.vividnext.sodalive.v2.home.following.domain.HomeFollowingLive
import kr.co.vividnext.sodalive.v2.home.following.domain.HomeFollowingNews import kr.co.vividnext.sodalive.v2.home.following.domain.HomeFollowingNews
@@ -23,10 +29,14 @@ import java.util.TimeZone
class HomeFollowingQueryServiceTest { class HomeFollowingQueryServiceTest {
private val queryPort = RecordingHomeFollowingQueryPort() private val queryPort = RecordingHomeFollowingQueryPort()
private val memberContentPreferenceService = Mockito.mock(MemberContentPreferenceService::class.java) private val memberContentPreferenceService = Mockito.mock(MemberContentPreferenceService::class.java)
private val langContext = LangContext()
private val creatorCommunityTranslationService = Mockito.mock(CreatorCommunityTranslationService::class.java)
private val now = LocalDateTime.of(2026, 6, 25, 12, 0) private val now = LocalDateTime.of(2026, 6, 25, 12, 0)
private val service = HomeFollowingQueryService( private val service = HomeFollowingQueryService(
queryPort, queryPort,
memberContentPreferenceService memberContentPreferenceService,
langContext,
creatorCommunityTranslationService
) { now } ) { now }
@Test @Test
@@ -86,7 +96,12 @@ class HomeFollowingQueryServiceTest {
try { try {
TimeZone.setDefault(TimeZone.getTimeZone("Asia/Seoul")) TimeZone.setDefault(TimeZone.getTimeZone("Asia/Seoul"))
val defaultQueryPort = RecordingHomeFollowingQueryPort() val defaultQueryPort = RecordingHomeFollowingQueryPort()
val defaultService = HomeFollowingQueryService(defaultQueryPort, memberContentPreferenceService) val defaultService = HomeFollowingQueryService(
defaultQueryPort,
memberContentPreferenceService,
langContext,
creatorCommunityTranslationService
)
val member = member(12L) val member = member(12L)
Mockito.`when`(memberContentPreferenceService.canViewAdultContent(member)).thenReturn(true) Mockito.`when`(memberContentPreferenceService.canViewAdultContent(member)).thenReturn(true)
val beforeUtc = LocalDateTime.now(ZoneOffset.UTC).minusSeconds(1) val beforeUtc = LocalDateTime.now(ZoneOffset.UTC).minusSeconds(1)
@@ -103,10 +118,57 @@ class HomeFollowingQueryServiceTest {
} }
} }
@Test
@DisplayName("팔로잉 최근 소식은 커뮤니티 게시물만 영어 표시 본문으로 일괄 치환하고 다른 소식과 원문 폴백을 유지한다")
fun shouldTranslateOnlyCommunityPostNewsInOneEnglishBatch() {
langContext.setLang(Lang.EN)
val translatedNews = communityNews("community-1", 101L, "original-1")
val audioNews = HomeFollowingNews(
newsId = "audio-1",
type = FollowingNewsType.AUDIO_CONTENT,
visibleFromAtUtc = "2026-06-25T11:59:00Z"
)
val fallbackNews = communityNews("community-2", 102L, "historical-original-2")
queryPort.recentNews = listOf(translatedNews, audioNews, fallbackNews)
Mockito.`when`(creatorCommunityTranslationService.findDisplayContents(listOf(101L, 102L), "en"))
.thenReturn(
mapOf(
101L to CreatorCommunityDisplayContent("translated-1-en", true),
102L to CreatorCommunityDisplayContent("current-original-2", false)
)
)
val home = service.findHomeFollowing(member(14L))
assertEquals("translated-1-en", home.recentNews[0].communityPost?.content)
assertEquals(audioNews, home.recentNews[1])
assertEquals("current-original-2", home.recentNews[2].communityPost?.content)
Mockito.verify(creatorCommunityTranslationService).findDisplayContents(listOf(101L, 102L), "en")
Mockito.verifyNoMoreInteractions(creatorCommunityTranslationService)
}
private fun member(id: Long): Member { private fun member(id: Long): Member {
return Member(email = "member-$id@test.com", password = "password", nickname = "member-$id").apply { this.id = id } return Member(email = "member-$id@test.com", password = "password", nickname = "member-$id").apply { this.id = id }
} }
private fun communityNews(newsId: String, postId: Long, content: String): HomeFollowingNews {
return HomeFollowingNews(
newsId = newsId,
type = FollowingNewsType.COMMUNITY_POST,
visibleFromAtUtc = "2026-06-25T12:00:00Z",
communityPost = HomeFollowingCommunityPostNews(
postId = postId,
creatorProfileImage = "profile.png",
creatorNickname = "creator",
imageUrl = "image.png",
content = content,
createdAt = "2026-06-25T12:00:00Z",
likeCount = 1,
commentCount = 2
)
)
}
private class RecordingHomeFollowingQueryPort : HomeFollowingQueryPort { private class RecordingHomeFollowingQueryPort : HomeFollowingQueryPort {
var memberId: Long? = null var memberId: Long? = null
var followingCreatorsLimit: Int? = null var followingCreatorsLimit: Int? = null
@@ -122,6 +184,7 @@ class HomeFollowingQueryServiceTest {
var recentNewsLimit: Int? = null var recentNewsLimit: Int? = null
var recentNewsNow: LocalDateTime? = null var recentNewsNow: LocalDateTime? = null
var recentNewsCanViewAdultContent: Boolean? = null var recentNewsCanViewAdultContent: Boolean? = null
var recentNews: List<HomeFollowingNews> = emptyList()
override fun findFollowingCreators(memberId: Long, limit: Int): List<HomeFollowingCreator> { override fun findFollowingCreators(memberId: Long, limit: Int): List<HomeFollowingCreator> {
this.memberId = memberId this.memberId = memberId
@@ -168,7 +231,7 @@ class HomeFollowingQueryServiceTest {
recentNewsCanViewAdultContent = canViewAdultContent recentNewsCanViewAdultContent = canViewAdultContent
recentNewsNow = nowUtc recentNewsNow = nowUtc
recentNewsLimit = limit recentNewsLimit = limit
return emptyList() return recentNews
} }
} }
} }