feat(creator-community): 게시물 번역 서비스를 추가한다

This commit is contained in:
2026-09-10 15:11:22 +09:00
parent 3e9ba0b23e
commit 6efecf5495
3 changed files with 1044 additions and 1 deletions
@@ -18,8 +18,13 @@ import kr.co.vividnext.sodalive.content.theme.translation.ContentThemeTranslatio
import kr.co.vividnext.sodalive.content.translation.ContentTranslation
import kr.co.vividnext.sodalive.content.translation.ContentTranslationPayload
import kr.co.vividnext.sodalive.content.translation.ContentTranslationRepository
import kr.co.vividnext.sodalive.explorer.profile.creatorCommunity.CreatorCommunityRepository
import kr.co.vividnext.sodalive.v2.creator.channel.community.translation.adapter.out.persistence.CreatorCommunityTranslation
import kr.co.vividnext.sodalive.v2.creator.channel.community.translation.adapter.out.persistence.CreatorCommunityTranslationRepository
import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional
import javax.persistence.EntityManager
import javax.persistence.LockModeType
@Service
class TranslationReadModelMaterializer(
@@ -31,7 +36,10 @@ class TranslationReadModelMaterializer(
private val seriesTranslationRepository: SeriesTranslationRepository,
private val seriesGenreTranslationRepository: SeriesGenreTranslationRepository,
private val originalWorkTranslationRepository: OriginalWorkTranslationRepository,
private val categoryTranslationRepository: CategoryTranslationRepository
private val categoryTranslationRepository: CategoryTranslationRepository,
private val creatorCommunityRepository: CreatorCommunityRepository,
private val creatorCommunityTranslationRepository: CreatorCommunityTranslationRepository,
private val entityManager: EntityManager
) {
@Transactional
fun materialize(resourceType: LanguageTranslationTargetType, resourceId: Long, targetLanguage: String): Boolean {
@@ -46,6 +54,9 @@ class TranslationReadModelMaterializer(
LanguageTranslationTargetType.SERIES_GENRE -> upsertSeriesGenre(resourceId, targetLanguage, translations)
LanguageTranslationTargetType.ORIGINAL_WORK -> upsertOriginalWork(resourceId, targetLanguage, translations)
LanguageTranslationTargetType.CREATOR_CONTENT_CATEGORY -> upsertCategory(resourceId, targetLanguage, translations)
LanguageTranslationTargetType.CREATOR_COMMUNITY -> {
return upsertCreatorCommunity(source, targetLanguage.lowercase(), translations)
}
}
return true
@@ -180,6 +191,47 @@ class TranslationReadModelMaterializer(
}
}
private fun upsertCreatorCommunity(
source: TranslationSource,
targetLanguage: String,
translations: Map<String, String>
): Boolean {
val sourceRevision = source.sourceRevision ?: return false
val sourceHash = SourceTextNormalizer.hash(source.fields.single().sourceText)
val creatorCommunity = creatorCommunityRepository.findByIdAndIsActiveTrueForUpdate(source.resourceId) ?: return false
entityManager.refresh(creatorCommunity, LockModeType.PESSIMISTIC_WRITE)
if (
!creatorCommunity.isActive ||
creatorCommunity.contentRevision != sourceRevision ||
creatorCommunity.languageCode?.lowercase() != source.sourceLanguage.lowercase() ||
SourceTextNormalizer.hash(creatorCommunity.content) != sourceHash
) {
return false
}
val existing = creatorCommunityTranslationRepository
.findByCreatorCommunityIdAndLocaleForUpdate(source.resourceId, targetLanguage)
if (existing == null) {
creatorCommunityTranslationRepository.save(
CreatorCommunityTranslation(
creatorCommunityId = source.resourceId,
locale = targetLanguage,
content = translations["content"].orEmpty(),
sourceRevision = sourceRevision,
sourceHash = sourceHash,
sourceLanguage = source.sourceLanguage.lowercase()
)
)
} else {
existing.content = translations["content"].orEmpty()
existing.sourceRevision = sourceRevision
existing.sourceHash = sourceHash
existing.sourceLanguage = source.sourceLanguage.lowercase()
creatorCommunityTranslationRepository.save(existing)
}
return true
}
companion object {
const val DEFAULT_PROVIDER = "papago"
}
@@ -0,0 +1,113 @@
package kr.co.vividnext.sodalive.v2.creator.channel.community.translation.application
import kr.co.vividnext.sodalive.content.LanguageDetectEvent
import kr.co.vividnext.sodalive.content.LanguageDetectTargetType
import kr.co.vividnext.sodalive.explorer.profile.creatorCommunity.CreatorCommunity
import kr.co.vividnext.sodalive.explorer.profile.creatorCommunity.CreatorCommunityRepository
import kr.co.vividnext.sodalive.i18n.translation.LanguageTranslationTargetType
import kr.co.vividnext.sodalive.i18n.translation.PapagoTranslationService.Companion.getTranslatableLanguageCodes
import kr.co.vividnext.sodalive.i18n.translation.ResourceTranslationJobScheduler
import kr.co.vividnext.sodalive.i18n.translation.SourceTextNormalizer
import kr.co.vividnext.sodalive.i18n.translation.TranslationReadModelMaterializer
import kr.co.vividnext.sodalive.i18n.translation.TranslationSourceExtractor
import kr.co.vividnext.sodalive.v2.creator.channel.community.translation.adapter.out.persistence.CreatorCommunityTranslation
import kr.co.vividnext.sodalive.v2.creator.channel.community.translation.adapter.out.persistence.CreatorCommunityTranslationRepository
import org.springframework.context.ApplicationEventPublisher
import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Propagation
import org.springframework.transaction.annotation.Transactional
import javax.persistence.EntityManager
import javax.persistence.LockModeType
@Service
class CreatorCommunityTranslationService(
private val creatorCommunityRepository: CreatorCommunityRepository,
private val creatorCommunityTranslationRepository: CreatorCommunityTranslationRepository,
private val sourceExtractor: TranslationSourceExtractor,
private val materializer: TranslationReadModelMaterializer,
private val resourceTranslationJobScheduler: ResourceTranslationJobScheduler,
private val applicationEventPublisher: ApplicationEventPublisher,
private val entityManager: EntityManager
) {
@Transactional(readOnly = true)
fun findDisplayContents(postIds: List<Long>, locale: String): Map<Long, CreatorCommunityDisplayContent> {
if (postIds.isEmpty()) return emptyMap()
val normalizedLocale = locale.lowercase()
val posts = creatorCommunityRepository.findAllById(postIds).filter { it.isActive }
val translations = creatorCommunityTranslationRepository
.findByCreatorCommunityIdInAndLocale(posts.mapNotNull { it.id }, normalizedLocale)
.associateBy { it.creatorCommunityId }
return posts.associate { post ->
post.id!! to translations[post.id].displayContentOr(post, normalizedLocale)
}
}
@Transactional(propagation = Propagation.REQUIRES_NEW)
fun requestTranslations(postId: Long, targetLanguage: String? = null) {
val creatorCommunity = creatorCommunityRepository.findByIdAndIsActiveTrueForUpdate(postId) ?: return
entityManager.refresh(creatorCommunity, LockModeType.PESSIMISTIC_WRITE)
val source = sourceExtractor.extract(LanguageTranslationTargetType.CREATOR_COMMUNITY, postId)
if (source == null) {
requestLanguageDetection(postId, targetLanguage)
return
}
val sourceLanguage = source.sourceLanguage.lowercase()
val targetLanguages = targetLanguage?.let { setOf(it.lowercase()) }
?: getTranslatableLanguageCodes(sourceLanguage)
targetLanguages
.filter { it != sourceLanguage }
.forEach { language ->
if (!materializer.materialize(LanguageTranslationTargetType.CREATOR_COMMUNITY, postId, language)) {
resourceTranslationJobScheduler.scheduleResourceTranslation(
resourceType = LanguageTranslationTargetType.CREATOR_COMMUNITY,
resourceId = postId,
targetLanguage = language
)
}
}
}
private fun requestLanguageDetection(postId: Long, targetLanguage: String?) {
val creatorCommunity = creatorCommunityRepository.findByIdAndIsActiveTrue(postId) ?: return
if (!creatorCommunity.languageCode.isNullOrBlank() || creatorCommunity.content.isBlank()) return
applicationEventPublisher.publishEvent(
LanguageDetectEvent(
id = postId,
query = creatorCommunity.content,
targetType = LanguageDetectTargetType.CREATOR_COMMUNITY,
sourceRevision = creatorCommunity.contentRevision,
targetLanguage = targetLanguage
)
)
}
private fun CreatorCommunityTranslation?.displayContentOr(
creatorCommunity: CreatorCommunity,
locale: String
): CreatorCommunityDisplayContent {
val sourceLanguage = creatorCommunity.languageCode?.lowercase()
return if (
this != null &&
sourceLanguage != null &&
sourceLanguage != locale &&
sourceRevision == creatorCommunity.contentRevision &&
sourceLanguage == this.sourceLanguage.lowercase() &&
sourceHash == SourceTextNormalizer.hash(creatorCommunity.content)
) {
CreatorCommunityDisplayContent(content = content, hasValidTranslation = true)
} else {
CreatorCommunityDisplayContent(content = creatorCommunity.content, hasValidTranslation = false)
}
}
}
data class CreatorCommunityDisplayContent(
val content: String,
val hasValidTranslation: Boolean
)