feat(home-following): 커뮤니티 소식 본문 번역을 적용한다
This commit is contained in:
+22
-1
@@ -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
|
||||||
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+55
@@ -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)
|
||||||
|
|||||||
+66
-3
@@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user