feat(i18n): 번역 작업 큐와 언어 감지 캐시를 도입한다
조회 중 외부 번역 호출을 줄이고 누락 번역을 비동기 job으로 처리한다.
This commit is contained in:
@@ -1,35 +1,6 @@
|
||||
package kr.co.vividnext.sodalive.i18n.translation
|
||||
|
||||
import kr.co.vividnext.sodalive.admin.content.series.AdminContentSeriesRepository
|
||||
import kr.co.vividnext.sodalive.admin.content.series.genre.AdminContentSeriesGenreRepository
|
||||
import kr.co.vividnext.sodalive.chat.character.repository.ChatCharacterRepository
|
||||
import kr.co.vividnext.sodalive.chat.character.translate.AiCharacterTranslation
|
||||
import kr.co.vividnext.sodalive.chat.character.translate.AiCharacterTranslationRenderedPayload
|
||||
import kr.co.vividnext.sodalive.chat.character.translate.AiCharacterTranslationRepository
|
||||
import kr.co.vividnext.sodalive.chat.character.translate.TranslatedAiCharacterBackground
|
||||
import kr.co.vividnext.sodalive.chat.character.translate.TranslatedAiCharacterPersonality
|
||||
import kr.co.vividnext.sodalive.chat.original.OriginalWorkRepository
|
||||
import kr.co.vividnext.sodalive.chat.original.translation.OriginalWorkTranslation
|
||||
import kr.co.vividnext.sodalive.chat.original.translation.OriginalWorkTranslationPayload
|
||||
import kr.co.vividnext.sodalive.chat.original.translation.OriginalWorkTranslationRepository
|
||||
import kr.co.vividnext.sodalive.content.AudioContentRepository
|
||||
import kr.co.vividnext.sodalive.content.category.CategoryRepository
|
||||
import kr.co.vividnext.sodalive.content.category.CategoryTranslation
|
||||
import kr.co.vividnext.sodalive.content.category.CategoryTranslationRepository
|
||||
import kr.co.vividnext.sodalive.content.series.translation.SeriesGenreTranslation
|
||||
import kr.co.vividnext.sodalive.content.series.translation.SeriesGenreTranslationRepository
|
||||
import kr.co.vividnext.sodalive.content.series.translation.SeriesTranslation
|
||||
import kr.co.vividnext.sodalive.content.series.translation.SeriesTranslationPayload
|
||||
import kr.co.vividnext.sodalive.content.series.translation.SeriesTranslationRepository
|
||||
import kr.co.vividnext.sodalive.content.theme.AudioContentThemeQueryRepository
|
||||
import kr.co.vividnext.sodalive.content.theme.translation.ContentThemeTranslation
|
||||
import kr.co.vividnext.sodalive.content.theme.translation.ContentThemeTranslationRepository
|
||||
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.i18n.translation.PapagoTranslationService.Companion.getTranslatableLanguageCodes
|
||||
import org.springframework.context.event.EventListener
|
||||
import org.springframework.data.repository.findByIdOrNull
|
||||
import org.springframework.scheduling.annotation.Async
|
||||
import org.springframework.stereotype.Component
|
||||
import org.springframework.transaction.annotation.Propagation
|
||||
@@ -58,24 +29,7 @@ class LanguageTranslationEvent(
|
||||
|
||||
@Component
|
||||
class LanguageTranslationListener(
|
||||
private val audioContentRepository: AudioContentRepository,
|
||||
private val chatCharacterRepository: ChatCharacterRepository,
|
||||
private val audioContentThemeRepository: AudioContentThemeQueryRepository,
|
||||
private val seriesRepository: AdminContentSeriesRepository,
|
||||
private val seriesGenreRepository: AdminContentSeriesGenreRepository,
|
||||
private val originalWorkRepository: OriginalWorkRepository,
|
||||
|
||||
private val contentTranslationRepository: ContentTranslationRepository,
|
||||
private val aiCharacterTranslationRepository: AiCharacterTranslationRepository,
|
||||
private val contentThemeTranslationRepository: ContentThemeTranslationRepository,
|
||||
private val seriesTranslationRepository: SeriesTranslationRepository,
|
||||
private val seriesGenreTranslationRepository: SeriesGenreTranslationRepository,
|
||||
private val originalWorkTranslationRepository: OriginalWorkTranslationRepository,
|
||||
|
||||
private val categoryRepository: CategoryRepository,
|
||||
private val categoryTranslationRepository: CategoryTranslationRepository,
|
||||
|
||||
private val translationService: PapagoTranslationService
|
||||
private val resourceTranslationJobScheduler: ResourceTranslationJobScheduler
|
||||
) {
|
||||
@Async
|
||||
@EventListener(condition = "!#event.waitTransactionCommit")
|
||||
@@ -92,424 +46,6 @@ class LanguageTranslationListener(
|
||||
}
|
||||
|
||||
private fun processTranslation(event: LanguageTranslationEvent) {
|
||||
when (event.targetType) {
|
||||
LanguageTranslationTargetType.CONTENT -> handleContentLanguageTranslation(event)
|
||||
LanguageTranslationTargetType.CHARACTER -> handleCharacterLanguageTranslation(event)
|
||||
LanguageTranslationTargetType.CONTENT_THEME -> handleContentThemeLanguageTranslation(event)
|
||||
LanguageTranslationTargetType.SERIES -> handleSeriesLanguageTranslation(event)
|
||||
LanguageTranslationTargetType.SERIES_GENRE -> handleSeriesGenreLanguageTranslation(event)
|
||||
LanguageTranslationTargetType.ORIGINAL_WORK -> handleOriginalWorkLanguageTranslation(event)
|
||||
LanguageTranslationTargetType.CREATOR_CONTENT_CATEGORY -> handleCreatorContentCategoryLanguageTranslation(event)
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleContentLanguageTranslation(event: LanguageTranslationEvent) {
|
||||
val audioContent = audioContentRepository.findByIdOrNull(event.id) ?: return
|
||||
val languageCode = audioContent.languageCode ?: return
|
||||
|
||||
getTranslatableLanguageCodes(languageCode).forEach { locale ->
|
||||
val tags = audioContent.audioContentHashTags
|
||||
.mapNotNull { it.hashTag?.tag }
|
||||
.joinToString(",")
|
||||
|
||||
val texts = mutableListOf<String>()
|
||||
texts.add(audioContent.title)
|
||||
texts.add(audioContent.detail)
|
||||
texts.add(tags)
|
||||
|
||||
val sourceLanguage = audioContent.languageCode ?: "ko"
|
||||
|
||||
val response = translationService.translate(
|
||||
request = TranslateRequest(
|
||||
texts = texts,
|
||||
sourceLanguage = sourceLanguage,
|
||||
targetLanguage = locale
|
||||
)
|
||||
)
|
||||
|
||||
val translatedTexts = response.translatedText
|
||||
if (translatedTexts.size == texts.size) {
|
||||
var index = 0
|
||||
|
||||
val translatedTitle = translatedTexts[index++]
|
||||
val translatedDetail = translatedTexts[index++]
|
||||
val translatedTags = translatedTexts[index]
|
||||
|
||||
val payload = ContentTranslationPayload(
|
||||
title = translatedTitle,
|
||||
detail = translatedDetail,
|
||||
tags = translatedTags
|
||||
)
|
||||
|
||||
val existing = contentTranslationRepository
|
||||
.findByContentIdAndLocale(audioContent.id!!, locale)
|
||||
|
||||
if (existing == null) {
|
||||
contentTranslationRepository.save(
|
||||
ContentTranslation(
|
||||
contentId = audioContent.id!!,
|
||||
locale = locale,
|
||||
renderedPayload = payload
|
||||
)
|
||||
)
|
||||
} else {
|
||||
existing.renderedPayload = payload
|
||||
contentTranslationRepository.save(existing)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleCharacterLanguageTranslation(event: LanguageTranslationEvent) {
|
||||
val character = chatCharacterRepository.findByIdOrNull(event.id) ?: return
|
||||
val languageCode = character.languageCode ?: return
|
||||
|
||||
getTranslatableLanguageCodes(languageCode).forEach { locale ->
|
||||
val personality = character.personalities.firstOrNull()
|
||||
val background = character.backgrounds.firstOrNull()
|
||||
|
||||
val tags = character.tagMappings.joinToString(",") { it.tag.tag }
|
||||
|
||||
val texts = mutableListOf<String>()
|
||||
texts.add(character.name)
|
||||
texts.add(character.description)
|
||||
texts.add(character.gender ?: "")
|
||||
|
||||
val hasPersonality = personality != null
|
||||
if (hasPersonality) {
|
||||
texts.add(personality!!.trait)
|
||||
texts.add(personality.description)
|
||||
}
|
||||
|
||||
val hasBackground = background != null
|
||||
if (hasBackground) {
|
||||
texts.add(background!!.topic)
|
||||
texts.add(background.description)
|
||||
}
|
||||
|
||||
texts.add(tags)
|
||||
|
||||
val sourceLanguage = character.languageCode ?: "ko"
|
||||
|
||||
val response = translationService.translate(
|
||||
request = TranslateRequest(
|
||||
texts = texts,
|
||||
sourceLanguage = sourceLanguage,
|
||||
targetLanguage = locale
|
||||
)
|
||||
)
|
||||
|
||||
val translatedTexts = response.translatedText
|
||||
if (translatedTexts.size == texts.size) {
|
||||
var index = 0
|
||||
|
||||
val translatedName = translatedTexts[index++]
|
||||
val translatedDescription = translatedTexts[index++]
|
||||
val translatedGender = translatedTexts[index++]
|
||||
|
||||
var translatedPersonality: TranslatedAiCharacterPersonality? = null
|
||||
if (hasPersonality) {
|
||||
translatedPersonality = TranslatedAiCharacterPersonality(
|
||||
trait = translatedTexts[index++],
|
||||
description = translatedTexts[index++]
|
||||
)
|
||||
}
|
||||
|
||||
var translatedBackground: TranslatedAiCharacterBackground? = null
|
||||
if (hasBackground) {
|
||||
translatedBackground = TranslatedAiCharacterBackground(
|
||||
topic = translatedTexts[index++],
|
||||
description = translatedTexts[index++]
|
||||
)
|
||||
}
|
||||
|
||||
val translatedTags = translatedTexts[index]
|
||||
|
||||
val payload = AiCharacterTranslationRenderedPayload(
|
||||
name = translatedName,
|
||||
description = translatedDescription,
|
||||
gender = translatedGender,
|
||||
personalityTrait = translatedPersonality?.trait ?: "",
|
||||
personalityDescription = translatedPersonality?.description ?: "",
|
||||
backgroundTopic = translatedBackground?.topic ?: "",
|
||||
backgroundDescription = translatedBackground?.description ?: "",
|
||||
tags = translatedTags
|
||||
)
|
||||
|
||||
val existing = aiCharacterTranslationRepository
|
||||
.findByCharacterIdAndLocale(character.id!!, locale)
|
||||
|
||||
if (existing == null) {
|
||||
val entity = AiCharacterTranslation(
|
||||
characterId = character.id!!,
|
||||
locale = locale,
|
||||
renderedPayload = payload
|
||||
)
|
||||
|
||||
aiCharacterTranslationRepository.save(entity)
|
||||
} else {
|
||||
existing.renderedPayload = payload
|
||||
aiCharacterTranslationRepository.save(existing)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleContentThemeLanguageTranslation(event: LanguageTranslationEvent) {
|
||||
val contentTheme = audioContentThemeRepository.findThemeByIdAndActive(event.id) ?: return
|
||||
|
||||
val sourceLanguage = "ko"
|
||||
getTranslatableLanguageCodes(sourceLanguage).forEach { locale ->
|
||||
val texts = mutableListOf<String>()
|
||||
texts.add(contentTheme.theme)
|
||||
|
||||
val response = translationService.translate(
|
||||
request = TranslateRequest(
|
||||
texts = texts,
|
||||
sourceLanguage = sourceLanguage,
|
||||
targetLanguage = locale
|
||||
)
|
||||
)
|
||||
|
||||
val translatedTexts = response.translatedText
|
||||
if (translatedTexts.size == texts.size) {
|
||||
val translatedTheme = translatedTexts[0]
|
||||
|
||||
val existing = contentThemeTranslationRepository
|
||||
.findByContentThemeIdAndLocale(contentTheme.id!!, locale)
|
||||
|
||||
if (existing == null) {
|
||||
contentThemeTranslationRepository.save(
|
||||
ContentThemeTranslation(
|
||||
contentThemeId = contentTheme.id!!,
|
||||
locale = locale,
|
||||
theme = translatedTheme
|
||||
)
|
||||
)
|
||||
} else {
|
||||
existing.theme = translatedTheme
|
||||
contentThemeTranslationRepository.save(existing)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleSeriesLanguageTranslation(event: LanguageTranslationEvent) {
|
||||
val series = seriesRepository.findByIdOrNull(event.id) ?: return
|
||||
val languageCode = series.languageCode ?: return
|
||||
|
||||
getTranslatableLanguageCodes(languageCode).forEach { locale ->
|
||||
val keywords = series.keywordList
|
||||
.mapNotNull { it.keyword?.tag }
|
||||
.joinToString(", ")
|
||||
val texts = mutableListOf<String>()
|
||||
texts.add(series.title)
|
||||
texts.add(series.introduction)
|
||||
texts.add(keywords)
|
||||
|
||||
val sourceLanguage = series.languageCode ?: "ko"
|
||||
|
||||
val response = translationService.translate(
|
||||
request = TranslateRequest(
|
||||
texts = texts,
|
||||
sourceLanguage = sourceLanguage,
|
||||
targetLanguage = locale
|
||||
)
|
||||
)
|
||||
|
||||
val translatedTexts = response.translatedText
|
||||
if (translatedTexts.size == texts.size) {
|
||||
var index = 0
|
||||
val translatedTitle = translatedTexts[index++]
|
||||
val translatedIntroduction = translatedTexts[index++]
|
||||
val translatedKeywordsJoined = translatedTexts[index]
|
||||
|
||||
val translatedKeywords = translatedKeywordsJoined
|
||||
.split(",")
|
||||
.map { it.trim() }
|
||||
.filter { it.isNotBlank() }
|
||||
|
||||
val payload = SeriesTranslationPayload(
|
||||
title = translatedTitle,
|
||||
introduction = translatedIntroduction,
|
||||
keywords = translatedKeywords
|
||||
)
|
||||
|
||||
val existing = seriesTranslationRepository
|
||||
.findBySeriesIdAndLocale(series.id!!, locale)
|
||||
|
||||
if (existing == null) {
|
||||
seriesTranslationRepository.save(
|
||||
SeriesTranslation(
|
||||
seriesId = series.id!!,
|
||||
locale = locale,
|
||||
renderedPayload = payload
|
||||
)
|
||||
)
|
||||
} else {
|
||||
existing.renderedPayload = payload
|
||||
seriesTranslationRepository.save(existing)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleSeriesGenreLanguageTranslation(event: LanguageTranslationEvent) {
|
||||
val seriesGenre = seriesGenreRepository.findActiveSeriesGenreById(event.id) ?: return
|
||||
|
||||
val sourceLanguage = "ko"
|
||||
getTranslatableLanguageCodes(sourceLanguage).forEach { locale ->
|
||||
val texts = mutableListOf<String>()
|
||||
texts.add(seriesGenre.genre)
|
||||
|
||||
val response = translationService.translate(
|
||||
request = TranslateRequest(
|
||||
texts = texts,
|
||||
sourceLanguage = sourceLanguage,
|
||||
targetLanguage = locale
|
||||
)
|
||||
)
|
||||
|
||||
val translatedTexts = response.translatedText
|
||||
if (translatedTexts.size == texts.size) {
|
||||
val translatedGenre = translatedTexts[0]
|
||||
|
||||
val existing = seriesGenreTranslationRepository
|
||||
.findBySeriesGenreIdAndLocale(seriesGenre.id!!, locale)
|
||||
|
||||
if (existing == null) {
|
||||
seriesGenreTranslationRepository.save(
|
||||
SeriesGenreTranslation(
|
||||
seriesGenreId = seriesGenre.id!!,
|
||||
locale = locale,
|
||||
genre = translatedGenre
|
||||
)
|
||||
)
|
||||
} else {
|
||||
existing.genre = translatedGenre
|
||||
seriesGenreTranslationRepository.save(existing)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleOriginalWorkLanguageTranslation(event: LanguageTranslationEvent) {
|
||||
val originalWork = originalWorkRepository.findByIdOrNull(event.id) ?: return
|
||||
val languageCode = originalWork.languageCode ?: return
|
||||
|
||||
/**
|
||||
* handleSeriesLanguageTranslation 참조하여 원작 번역 구현
|
||||
*
|
||||
* originalWorkTranslationRepository
|
||||
*
|
||||
* 번역대상
|
||||
* - title
|
||||
* - contentType
|
||||
* - category
|
||||
* - description
|
||||
* - tags
|
||||
*/
|
||||
getTranslatableLanguageCodes(languageCode).forEach { locale ->
|
||||
val tagsJoined = originalWork.tagMappings
|
||||
.mapNotNull { it.tag.tag }
|
||||
.joinToString(", ")
|
||||
|
||||
val texts = mutableListOf<String>()
|
||||
texts.add(originalWork.title)
|
||||
texts.add(originalWork.contentType)
|
||||
texts.add(originalWork.category)
|
||||
texts.add(originalWork.description)
|
||||
texts.add(tagsJoined)
|
||||
|
||||
val sourceLanguage = originalWork.languageCode ?: "ko"
|
||||
|
||||
val response = translationService.translate(
|
||||
request = TranslateRequest(
|
||||
texts = texts,
|
||||
sourceLanguage = sourceLanguage,
|
||||
targetLanguage = locale
|
||||
)
|
||||
)
|
||||
|
||||
val translatedTexts = response.translatedText
|
||||
if (translatedTexts.size == texts.size) {
|
||||
var index = 0
|
||||
val translatedTitle = translatedTexts[index++]
|
||||
val translatedContentType = translatedTexts[index++]
|
||||
val translatedCategory = translatedTexts[index++]
|
||||
val translatedDescription = translatedTexts[index++]
|
||||
val translatedTagsJoined = translatedTexts[index]
|
||||
|
||||
val translatedTags = translatedTagsJoined
|
||||
.split(",")
|
||||
.map { it.trim() }
|
||||
.filter { it.isNotBlank() }
|
||||
|
||||
val payload = OriginalWorkTranslationPayload(
|
||||
title = translatedTitle,
|
||||
contentType = translatedContentType,
|
||||
category = translatedCategory,
|
||||
description = translatedDescription,
|
||||
tags = translatedTags
|
||||
)
|
||||
|
||||
val existing = originalWorkTranslationRepository
|
||||
.findByOriginalWorkIdAndLocale(originalWork.id!!, locale)
|
||||
|
||||
if (existing == null) {
|
||||
originalWorkTranslationRepository.save(
|
||||
OriginalWorkTranslation(
|
||||
originalWorkId = originalWork.id!!,
|
||||
locale = locale,
|
||||
renderedPayload = payload
|
||||
)
|
||||
)
|
||||
} else {
|
||||
existing.renderedPayload = payload
|
||||
originalWorkTranslationRepository.save(existing)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleCreatorContentCategoryLanguageTranslation(event: LanguageTranslationEvent) {
|
||||
val category = categoryRepository.findByIdOrNull(event.id)
|
||||
|
||||
if (category == null || !category.isActive || category.languageCode.isNullOrBlank()) return
|
||||
|
||||
val sourceLanguage = category.languageCode ?: "ko"
|
||||
getTranslatableLanguageCodes(sourceLanguage).forEach { locale ->
|
||||
val texts = mutableListOf<String>()
|
||||
texts.add(category.title)
|
||||
|
||||
val response = translationService.translate(
|
||||
request = TranslateRequest(
|
||||
texts = texts,
|
||||
sourceLanguage = sourceLanguage,
|
||||
targetLanguage = locale
|
||||
)
|
||||
)
|
||||
|
||||
val translatedTexts = response.translatedText
|
||||
if (translatedTexts.size == texts.size) {
|
||||
val translatedCategory = translatedTexts[0]
|
||||
|
||||
val existing = categoryTranslationRepository
|
||||
.findByCategoryIdAndLocale(category.id!!, locale)
|
||||
|
||||
if (existing == null) {
|
||||
categoryTranslationRepository.save(
|
||||
CategoryTranslation(
|
||||
categoryId = category.id!!,
|
||||
locale = locale,
|
||||
category = translatedCategory
|
||||
)
|
||||
)
|
||||
} else {
|
||||
existing.category = translatedCategory
|
||||
categoryTranslationRepository.save(existing)
|
||||
}
|
||||
}
|
||||
}
|
||||
resourceTranslationJobScheduler.scheduleResourceTranslations(event.targetType, event.id)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,12 +15,16 @@ class PapagoTranslationService(
|
||||
|
||||
@Value("\${cloud.naver.papago-client-secret}")
|
||||
private val papagoClientSecret: String
|
||||
) {
|
||||
) : TranslationProvider {
|
||||
private val restTemplate: RestTemplate = RestTemplate()
|
||||
|
||||
private val papagoTranslateUrl = "https://papago.apigw.ntruss.com/nmt/v1/translation"
|
||||
|
||||
fun translate(request: TranslateRequest): TranslateResult {
|
||||
override val providerName: String = "papago"
|
||||
|
||||
override val providerVersion: String = "nmt-v1"
|
||||
|
||||
override fun translate(request: TranslateRequest): TranslateResult {
|
||||
if (request.texts.isEmpty() || request.sourceLanguage == request.targetLanguage) {
|
||||
return TranslateResult(emptyList())
|
||||
}
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
package kr.co.vividnext.sodalive.i18n.translation
|
||||
|
||||
import kr.co.vividnext.sodalive.i18n.translation.PapagoTranslationService.Companion.getTranslatableLanguageCodes
|
||||
import org.springframework.stereotype.Service
|
||||
|
||||
@Service
|
||||
class ResourceTranslationJobScheduler(
|
||||
private val sourceExtractor: TranslationSourceExtractor,
|
||||
private val translationJobScheduler: TranslationJobScheduler
|
||||
) {
|
||||
fun scheduleResourceTranslations(resourceType: LanguageTranslationTargetType, resourceId: Long) {
|
||||
val source = sourceExtractor.extract(resourceType, resourceId) ?: return
|
||||
getTranslatableLanguageCodes(source.sourceLanguage).forEach { targetLanguage ->
|
||||
scheduleSource(source, targetLanguage)
|
||||
}
|
||||
}
|
||||
|
||||
fun scheduleResourceTranslation(
|
||||
resourceType: LanguageTranslationTargetType,
|
||||
resourceId: Long,
|
||||
targetLanguage: String
|
||||
) {
|
||||
val source = sourceExtractor.extract(resourceType, resourceId) ?: return
|
||||
scheduleSource(source, targetLanguage)
|
||||
}
|
||||
|
||||
private fun scheduleSource(source: TranslationSource, targetLanguage: String) {
|
||||
source.fields.forEach { field ->
|
||||
translationJobScheduler.scheduleMissingTranslation(
|
||||
resourceType = source.resourceType,
|
||||
resourceId = source.resourceId,
|
||||
fieldKey = field.fieldKey,
|
||||
sourceText = field.sourceText,
|
||||
sourceLanguage = source.sourceLanguage,
|
||||
targetLanguage = targetLanguage
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
package kr.co.vividnext.sodalive.i18n.translation
|
||||
|
||||
import java.security.MessageDigest
|
||||
import java.text.Normalizer
|
||||
|
||||
object SourceTextNormalizer {
|
||||
const val NORMALIZATION_VERSION = "v1"
|
||||
|
||||
private val whitespaceRegex = Regex("\\s+")
|
||||
|
||||
fun normalize(sourceText: String): String {
|
||||
return Normalizer.normalize(sourceText, Normalizer.Form.NFC)
|
||||
.replace(whitespaceRegex, " ")
|
||||
.trim()
|
||||
}
|
||||
|
||||
fun hash(sourceText: String): String {
|
||||
val normalized = normalize(sourceText)
|
||||
val digest = MessageDigest.getInstance("SHA-256")
|
||||
.digest(normalized.toByteArray(Charsets.UTF_8))
|
||||
return digest.joinToString("") { "%02x".format(it) }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
package kr.co.vividnext.sodalive.i18n.translation
|
||||
|
||||
import kr.co.vividnext.sodalive.common.BaseEntity
|
||||
import java.time.LocalDateTime
|
||||
import javax.persistence.Column
|
||||
import javax.persistence.Entity
|
||||
import javax.persistence.EnumType
|
||||
import javax.persistence.Enumerated
|
||||
import javax.persistence.Table
|
||||
import javax.persistence.UniqueConstraint
|
||||
|
||||
enum class TranslationJobStatus {
|
||||
PENDING,
|
||||
RUNNING,
|
||||
COMPLETED,
|
||||
FAILED
|
||||
}
|
||||
|
||||
@Entity
|
||||
@Table(
|
||||
name = "translation_job",
|
||||
uniqueConstraints = [
|
||||
UniqueConstraint(
|
||||
name = "uk_translation_job_resource_field_target_hash",
|
||||
columnNames = ["resource_type", "resource_id", "field_key", "target_language", "source_hash"]
|
||||
)
|
||||
]
|
||||
)
|
||||
class TranslationJob(
|
||||
@Enumerated(EnumType.STRING)
|
||||
@Column(name = "resource_type", nullable = false, length = 50)
|
||||
val resourceType: LanguageTranslationTargetType,
|
||||
|
||||
@Column(name = "resource_id", nullable = false)
|
||||
val resourceId: Long,
|
||||
|
||||
@Column(name = "field_key", nullable = false, length = 80)
|
||||
val fieldKey: String,
|
||||
|
||||
@Column(name = "source_hash", nullable = false, length = 64)
|
||||
val sourceHash: String,
|
||||
|
||||
@Column(name = "source_text", nullable = false, columnDefinition = "text")
|
||||
val sourceText: String,
|
||||
|
||||
@Column(name = "source_language", nullable = false, length = 10)
|
||||
val sourceLanguage: String,
|
||||
|
||||
@Column(name = "target_language", nullable = false, length = 10)
|
||||
val targetLanguage: String,
|
||||
|
||||
@Enumerated(EnumType.STRING)
|
||||
@Column(name = "status", nullable = false, length = 20)
|
||||
var status: TranslationJobStatus = TranslationJobStatus.PENDING,
|
||||
|
||||
@Column(name = "retry_count", nullable = false)
|
||||
var retryCount: Int = 0,
|
||||
|
||||
@Column(name = "last_error_message", columnDefinition = "text")
|
||||
var lastErrorMessage: String? = null,
|
||||
|
||||
@Column(name = "next_retry_at", nullable = false)
|
||||
var nextRetryAt: LocalDateTime = LocalDateTime.now()
|
||||
) : BaseEntity()
|
||||
@@ -0,0 +1,54 @@
|
||||
package kr.co.vividnext.sodalive.i18n.translation
|
||||
|
||||
import org.springframework.data.jpa.repository.JpaRepository
|
||||
import org.springframework.data.jpa.repository.Query
|
||||
import org.springframework.data.repository.query.Param
|
||||
import java.time.LocalDateTime
|
||||
|
||||
interface TranslationJobRepository : JpaRepository<TranslationJob, Long> {
|
||||
fun findByResourceTypeAndResourceIdAndFieldKeyAndTargetLanguageAndSourceHash(
|
||||
resourceType: LanguageTranslationTargetType,
|
||||
resourceId: Long,
|
||||
fieldKey: String,
|
||||
targetLanguage: String,
|
||||
sourceHash: String
|
||||
): TranslationJob?
|
||||
|
||||
@Query(
|
||||
"""
|
||||
select j from TranslationJob j
|
||||
where j.resourceType = :resourceType
|
||||
and j.resourceId = :resourceId
|
||||
and j.fieldKey = :fieldKey
|
||||
and j.targetLanguage = :targetLanguage
|
||||
and j.sourceHash = :sourceHash
|
||||
and j.status in (kr.co.vividnext.sodalive.i18n.translation.TranslationJobStatus.PENDING, kr.co.vividnext.sodalive.i18n.translation.TranslationJobStatus.RUNNING)
|
||||
"""
|
||||
)
|
||||
fun findActiveJob(
|
||||
@Param("resourceType") resourceType: LanguageTranslationTargetType,
|
||||
@Param("resourceId") resourceId: Long,
|
||||
@Param("fieldKey") fieldKey: String,
|
||||
@Param("targetLanguage") targetLanguage: String,
|
||||
@Param("sourceHash") sourceHash: String
|
||||
): TranslationJob?
|
||||
|
||||
fun findFirstByStatusAndNextRetryAtLessThanEqualOrderByCreatedAtAsc(
|
||||
status: TranslationJobStatus,
|
||||
nextRetryAt: LocalDateTime
|
||||
): TranslationJob?
|
||||
|
||||
@Query(
|
||||
value = """
|
||||
select id
|
||||
from translation_job
|
||||
where status = 'PENDING'
|
||||
and next_retry_at <= :now
|
||||
order by created_at asc
|
||||
limit 1
|
||||
for update skip locked
|
||||
""",
|
||||
nativeQuery = true
|
||||
)
|
||||
fun findNextPendingJobIdForUpdate(@Param("now") now: LocalDateTime): Long?
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
package kr.co.vividnext.sodalive.i18n.translation
|
||||
|
||||
import org.springframework.stereotype.Service
|
||||
import org.springframework.transaction.annotation.Transactional
|
||||
import java.time.LocalDateTime
|
||||
|
||||
@Service
|
||||
class TranslationJobScheduler(
|
||||
private val translationJobRepository: TranslationJobRepository
|
||||
) {
|
||||
@Transactional
|
||||
fun scheduleMissingTranslation(
|
||||
resourceType: LanguageTranslationTargetType,
|
||||
resourceId: Long,
|
||||
fieldKey: String,
|
||||
sourceText: String,
|
||||
sourceLanguage: String,
|
||||
targetLanguage: String
|
||||
) {
|
||||
val normalizedText = SourceTextNormalizer.normalize(sourceText)
|
||||
if (normalizedText.isBlank()) return
|
||||
|
||||
val normalizedSourceLanguage = sourceLanguage.lowercase()
|
||||
val normalizedTargetLanguage = targetLanguage.lowercase()
|
||||
if (normalizedSourceLanguage == normalizedTargetLanguage) return
|
||||
|
||||
val sourceHash = SourceTextNormalizer.hash(normalizedText)
|
||||
val existingJob = translationJobRepository.findByResourceTypeAndResourceIdAndFieldKeyAndTargetLanguageAndSourceHash(
|
||||
resourceType = resourceType,
|
||||
resourceId = resourceId,
|
||||
fieldKey = fieldKey,
|
||||
targetLanguage = normalizedTargetLanguage,
|
||||
sourceHash = sourceHash
|
||||
)
|
||||
if (existingJob != null) return
|
||||
|
||||
translationJobRepository.save(
|
||||
TranslationJob(
|
||||
resourceType = resourceType,
|
||||
resourceId = resourceId,
|
||||
fieldKey = fieldKey,
|
||||
sourceHash = sourceHash,
|
||||
sourceText = normalizedText,
|
||||
sourceLanguage = normalizedSourceLanguage,
|
||||
targetLanguage = normalizedTargetLanguage,
|
||||
nextRetryAt = LocalDateTime.now()
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
package kr.co.vividnext.sodalive.i18n.translation
|
||||
|
||||
import org.slf4j.LoggerFactory
|
||||
import org.springframework.scheduling.annotation.Scheduled
|
||||
import org.springframework.stereotype.Component
|
||||
import org.springframework.transaction.PlatformTransactionManager
|
||||
import org.springframework.transaction.support.TransactionTemplate
|
||||
import java.time.LocalDateTime
|
||||
|
||||
@Component
|
||||
class TranslationJobWorker(
|
||||
private val translationJobRepository: TranslationJobRepository,
|
||||
private val translationMemoryRepository: TranslationMemoryRepository,
|
||||
private val translationProvider: TranslationProvider,
|
||||
private val materializer: TranslationReadModelMaterializer,
|
||||
transactionManager: PlatformTransactionManager
|
||||
) {
|
||||
private val log = LoggerFactory.getLogger(javaClass)
|
||||
private val transactionTemplate = TransactionTemplate(transactionManager)
|
||||
|
||||
@Scheduled(fixedDelayString = "\${sodalive.translation-job.fixed-delay-ms:600000}")
|
||||
fun runPendingJobs() {
|
||||
repeat(MAX_JOBS_PER_TICK) {
|
||||
if (!processNextJob()) return
|
||||
}
|
||||
}
|
||||
|
||||
fun processNextJob(): Boolean {
|
||||
val job = claimNextJob() ?: return false
|
||||
try {
|
||||
ensureMemory(job)
|
||||
materializer.materialize(job.resourceType, job.resourceId, job.targetLanguage)
|
||||
completeJob(job.id!!)
|
||||
} catch (ex: Exception) {
|
||||
failJob(job.id!!, ex)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
private fun claimNextJob(): TranslationJob? {
|
||||
return transactionTemplate.execute {
|
||||
val jobId = translationJobRepository.findNextPendingJobIdForUpdate(LocalDateTime.now())
|
||||
?: return@execute null
|
||||
val job = translationJobRepository.findById(jobId).orElse(null)
|
||||
?: return@execute null
|
||||
job.status = TranslationJobStatus.RUNNING
|
||||
translationJobRepository.save(job)
|
||||
job
|
||||
}
|
||||
}
|
||||
|
||||
private fun ensureMemory(job: TranslationJob) {
|
||||
val existing = translationMemoryRepository
|
||||
.findBySourceHashAndSourceLanguageAndTargetLanguageAndProviderAndNormalizationVersion(
|
||||
sourceHash = job.sourceHash,
|
||||
sourceLanguage = job.sourceLanguage,
|
||||
targetLanguage = job.targetLanguage,
|
||||
provider = translationProvider.providerName,
|
||||
normalizationVersion = SourceTextNormalizer.NORMALIZATION_VERSION
|
||||
)
|
||||
if (existing != null) return
|
||||
|
||||
val response = translationProvider.translate(
|
||||
TranslateRequest(
|
||||
texts = listOf(job.sourceText),
|
||||
sourceLanguage = job.sourceLanguage,
|
||||
targetLanguage = job.targetLanguage
|
||||
)
|
||||
)
|
||||
val translated = response.translatedText.firstOrNull()?.takeIf { it.isNotBlank() }
|
||||
?: throw IllegalStateException("empty translation result")
|
||||
|
||||
transactionTemplate.executeWithoutResult {
|
||||
val memory = translationMemoryRepository
|
||||
.findBySourceHashAndSourceLanguageAndTargetLanguageAndProviderAndNormalizationVersion(
|
||||
sourceHash = job.sourceHash,
|
||||
sourceLanguage = job.sourceLanguage,
|
||||
targetLanguage = job.targetLanguage,
|
||||
provider = translationProvider.providerName,
|
||||
normalizationVersion = SourceTextNormalizer.NORMALIZATION_VERSION
|
||||
)
|
||||
if (memory == null) {
|
||||
translationMemoryRepository.save(
|
||||
TranslationMemory(
|
||||
sourceHash = job.sourceHash,
|
||||
sourceText = job.sourceText,
|
||||
sourceLanguage = job.sourceLanguage,
|
||||
targetLanguage = job.targetLanguage,
|
||||
translatedText = translated,
|
||||
provider = translationProvider.providerName,
|
||||
providerVersion = translationProvider.providerVersion
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun completeJob(jobId: Long) {
|
||||
transactionTemplate.executeWithoutResult {
|
||||
val job = translationJobRepository.findById(jobId).orElse(null) ?: return@executeWithoutResult
|
||||
job.status = TranslationJobStatus.COMPLETED
|
||||
job.lastErrorMessage = null
|
||||
translationJobRepository.save(job)
|
||||
}
|
||||
}
|
||||
|
||||
private fun failJob(jobId: Long, ex: Exception) {
|
||||
log.warn("Failed to process translation job. jobId={}, error={}", jobId, ex.message)
|
||||
transactionTemplate.executeWithoutResult {
|
||||
val job = translationJobRepository.findById(jobId).orElse(null) ?: return@executeWithoutResult
|
||||
job.retryCount += 1
|
||||
job.lastErrorMessage = ex.message?.take(MAX_ERROR_LENGTH)
|
||||
if (job.retryCount >= MAX_RETRY_COUNT) {
|
||||
job.status = TranslationJobStatus.FAILED
|
||||
} else {
|
||||
job.status = TranslationJobStatus.PENDING
|
||||
job.nextRetryAt = LocalDateTime.now().plusMinutes(backoffMinutes(job.retryCount))
|
||||
}
|
||||
translationJobRepository.save(job)
|
||||
}
|
||||
}
|
||||
|
||||
private fun backoffMinutes(retryCount: Int): Long {
|
||||
return when (retryCount) {
|
||||
1 -> 1L
|
||||
2 -> 5L
|
||||
else -> 15L
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val MAX_JOBS_PER_TICK = 20
|
||||
private const val MAX_ERROR_LENGTH = 1000
|
||||
private const val MAX_RETRY_COUNT = 3
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
package kr.co.vividnext.sodalive.i18n.translation
|
||||
|
||||
import kr.co.vividnext.sodalive.common.BaseEntity
|
||||
import javax.persistence.Column
|
||||
import javax.persistence.Entity
|
||||
import javax.persistence.Table
|
||||
import javax.persistence.UniqueConstraint
|
||||
|
||||
@Entity
|
||||
@Table(
|
||||
name = "translation_memory",
|
||||
uniqueConstraints = [
|
||||
UniqueConstraint(
|
||||
name = "uk_translation_memory_source_target_provider",
|
||||
columnNames = ["source_hash", "source_language", "target_language", "provider", "normalization_version"]
|
||||
)
|
||||
]
|
||||
)
|
||||
class TranslationMemory(
|
||||
@Column(name = "source_hash", nullable = false, length = 64)
|
||||
val sourceHash: String,
|
||||
|
||||
@Column(name = "source_text", nullable = false, columnDefinition = "text")
|
||||
val sourceText: String,
|
||||
|
||||
@Column(name = "source_language", nullable = false, length = 10)
|
||||
val sourceLanguage: String,
|
||||
|
||||
@Column(name = "target_language", nullable = false, length = 10)
|
||||
val targetLanguage: String,
|
||||
|
||||
@Column(name = "translated_text", nullable = false, columnDefinition = "text")
|
||||
val translatedText: String,
|
||||
|
||||
@Column(name = "provider", nullable = false, length = 50)
|
||||
val provider: String,
|
||||
|
||||
@Column(name = "provider_version", nullable = false, length = 50)
|
||||
val providerVersion: String,
|
||||
|
||||
@Column(name = "normalization_version", nullable = false, length = 20)
|
||||
val normalizationVersion: String = SourceTextNormalizer.NORMALIZATION_VERSION
|
||||
) : BaseEntity()
|
||||
@@ -0,0 +1,13 @@
|
||||
package kr.co.vividnext.sodalive.i18n.translation
|
||||
|
||||
import org.springframework.data.jpa.repository.JpaRepository
|
||||
|
||||
interface TranslationMemoryRepository : JpaRepository<TranslationMemory, Long> {
|
||||
fun findBySourceHashAndSourceLanguageAndTargetLanguageAndProviderAndNormalizationVersion(
|
||||
sourceHash: String,
|
||||
sourceLanguage: String,
|
||||
targetLanguage: String,
|
||||
provider: String,
|
||||
normalizationVersion: String
|
||||
): TranslationMemory?
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
package kr.co.vividnext.sodalive.i18n.translation
|
||||
|
||||
interface TranslationProvider {
|
||||
val providerName: String
|
||||
val providerVersion: String
|
||||
|
||||
fun translate(request: TranslateRequest): TranslateResult
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
package kr.co.vividnext.sodalive.i18n.translation
|
||||
|
||||
import kr.co.vividnext.sodalive.chat.character.translate.AiCharacterTranslation
|
||||
import kr.co.vividnext.sodalive.chat.character.translate.AiCharacterTranslationRenderedPayload
|
||||
import kr.co.vividnext.sodalive.chat.character.translate.AiCharacterTranslationRepository
|
||||
import kr.co.vividnext.sodalive.chat.original.translation.OriginalWorkTranslation
|
||||
import kr.co.vividnext.sodalive.chat.original.translation.OriginalWorkTranslationPayload
|
||||
import kr.co.vividnext.sodalive.chat.original.translation.OriginalWorkTranslationRepository
|
||||
import kr.co.vividnext.sodalive.content.category.CategoryTranslation
|
||||
import kr.co.vividnext.sodalive.content.category.CategoryTranslationRepository
|
||||
import kr.co.vividnext.sodalive.content.series.translation.SeriesGenreTranslation
|
||||
import kr.co.vividnext.sodalive.content.series.translation.SeriesGenreTranslationRepository
|
||||
import kr.co.vividnext.sodalive.content.series.translation.SeriesTranslation
|
||||
import kr.co.vividnext.sodalive.content.series.translation.SeriesTranslationPayload
|
||||
import kr.co.vividnext.sodalive.content.series.translation.SeriesTranslationRepository
|
||||
import kr.co.vividnext.sodalive.content.theme.translation.ContentThemeTranslation
|
||||
import kr.co.vividnext.sodalive.content.theme.translation.ContentThemeTranslationRepository
|
||||
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 org.springframework.stereotype.Service
|
||||
import org.springframework.transaction.annotation.Transactional
|
||||
|
||||
@Service
|
||||
class TranslationReadModelMaterializer(
|
||||
private val sourceExtractor: TranslationSourceExtractor,
|
||||
private val translationMemoryRepository: TranslationMemoryRepository,
|
||||
private val contentTranslationRepository: ContentTranslationRepository,
|
||||
private val aiCharacterTranslationRepository: AiCharacterTranslationRepository,
|
||||
private val contentThemeTranslationRepository: ContentThemeTranslationRepository,
|
||||
private val seriesTranslationRepository: SeriesTranslationRepository,
|
||||
private val seriesGenreTranslationRepository: SeriesGenreTranslationRepository,
|
||||
private val originalWorkTranslationRepository: OriginalWorkTranslationRepository,
|
||||
private val categoryTranslationRepository: CategoryTranslationRepository
|
||||
) {
|
||||
@Transactional
|
||||
fun materialize(resourceType: LanguageTranslationTargetType, resourceId: Long, targetLanguage: String): Boolean {
|
||||
val source = sourceExtractor.extract(resourceType, resourceId) ?: return false
|
||||
val translations = resolveTranslatedFields(source, targetLanguage.lowercase()) ?: return false
|
||||
|
||||
when (resourceType) {
|
||||
LanguageTranslationTargetType.CONTENT -> upsertContent(resourceId, targetLanguage, translations)
|
||||
LanguageTranslationTargetType.CHARACTER -> upsertCharacter(resourceId, targetLanguage, translations)
|
||||
LanguageTranslationTargetType.CONTENT_THEME -> upsertContentTheme(resourceId, targetLanguage, translations)
|
||||
LanguageTranslationTargetType.SERIES -> upsertSeries(resourceId, targetLanguage, translations)
|
||||
LanguageTranslationTargetType.SERIES_GENRE -> upsertSeriesGenre(resourceId, targetLanguage, translations)
|
||||
LanguageTranslationTargetType.ORIGINAL_WORK -> upsertOriginalWork(resourceId, targetLanguage, translations)
|
||||
LanguageTranslationTargetType.CREATOR_CONTENT_CATEGORY -> upsertCategory(resourceId, targetLanguage, translations)
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
private fun resolveTranslatedFields(source: TranslationSource, targetLanguage: String): Map<String, String>? {
|
||||
val result = mutableMapOf<String, String>()
|
||||
source.fields.forEach { field ->
|
||||
val normalizedText = SourceTextNormalizer.normalize(field.sourceText)
|
||||
if (normalizedText.isBlank()) {
|
||||
result[field.fieldKey] = ""
|
||||
return@forEach
|
||||
}
|
||||
|
||||
val memory = translationMemoryRepository
|
||||
.findBySourceHashAndSourceLanguageAndTargetLanguageAndProviderAndNormalizationVersion(
|
||||
sourceHash = SourceTextNormalizer.hash(normalizedText),
|
||||
sourceLanguage = source.sourceLanguage.lowercase(),
|
||||
targetLanguage = targetLanguage,
|
||||
provider = DEFAULT_PROVIDER,
|
||||
normalizationVersion = SourceTextNormalizer.NORMALIZATION_VERSION
|
||||
) ?: return null
|
||||
|
||||
result[field.fieldKey] = memory.translatedText
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
private fun upsertContent(resourceId: Long, targetLanguage: String, translations: Map<String, String>) {
|
||||
val payload = ContentTranslationPayload(
|
||||
title = translations["title"].orEmpty(),
|
||||
detail = translations["detail"].orEmpty(),
|
||||
tags = translations["tags"].orEmpty()
|
||||
)
|
||||
val existing = contentTranslationRepository.findByContentIdAndLocale(resourceId, targetLanguage)
|
||||
if (existing == null) {
|
||||
contentTranslationRepository.save(ContentTranslation(resourceId, targetLanguage, payload))
|
||||
} else {
|
||||
existing.renderedPayload = payload
|
||||
contentTranslationRepository.save(existing)
|
||||
}
|
||||
}
|
||||
|
||||
private fun upsertCharacter(resourceId: Long, targetLanguage: String, translations: Map<String, String>) {
|
||||
val payload = AiCharacterTranslationRenderedPayload(
|
||||
name = translations["name"].orEmpty(),
|
||||
description = translations["description"].orEmpty(),
|
||||
gender = translations["gender"].orEmpty(),
|
||||
personalityTrait = translations["personalityTrait"].orEmpty(),
|
||||
personalityDescription = translations["personalityDescription"].orEmpty(),
|
||||
backgroundTopic = translations["backgroundTopic"].orEmpty(),
|
||||
backgroundDescription = translations["backgroundDescription"].orEmpty(),
|
||||
tags = translations["tags"].orEmpty()
|
||||
)
|
||||
val existing = aiCharacterTranslationRepository.findByCharacterIdAndLocale(resourceId, targetLanguage)
|
||||
if (existing == null) {
|
||||
aiCharacterTranslationRepository.save(AiCharacterTranslation(resourceId, targetLanguage, payload))
|
||||
} else {
|
||||
existing.renderedPayload = payload
|
||||
aiCharacterTranslationRepository.save(existing)
|
||||
}
|
||||
}
|
||||
|
||||
private fun upsertContentTheme(resourceId: Long, targetLanguage: String, translations: Map<String, String>) {
|
||||
val theme = translations["theme"].orEmpty()
|
||||
val existing = contentThemeTranslationRepository.findByContentThemeIdAndLocale(resourceId, targetLanguage)
|
||||
if (existing == null) {
|
||||
contentThemeTranslationRepository.save(ContentThemeTranslation(resourceId, targetLanguage, theme))
|
||||
} else {
|
||||
existing.theme = theme
|
||||
contentThemeTranslationRepository.save(existing)
|
||||
}
|
||||
}
|
||||
|
||||
private fun upsertSeries(resourceId: Long, targetLanguage: String, translations: Map<String, String>) {
|
||||
val payload = SeriesTranslationPayload(
|
||||
title = translations["title"].orEmpty(),
|
||||
introduction = translations["introduction"].orEmpty(),
|
||||
keywords = translations["keywords"].orEmpty()
|
||||
.split(",")
|
||||
.map { it.trim() }
|
||||
.filter { it.isNotBlank() }
|
||||
)
|
||||
val existing = seriesTranslationRepository.findBySeriesIdAndLocale(resourceId, targetLanguage)
|
||||
if (existing == null) {
|
||||
seriesTranslationRepository.save(SeriesTranslation(resourceId, targetLanguage, payload))
|
||||
} else {
|
||||
existing.renderedPayload = payload
|
||||
seriesTranslationRepository.save(existing)
|
||||
}
|
||||
}
|
||||
|
||||
private fun upsertSeriesGenre(resourceId: Long, targetLanguage: String, translations: Map<String, String>) {
|
||||
val genre = translations["genre"].orEmpty()
|
||||
val existing = seriesGenreTranslationRepository.findBySeriesGenreIdAndLocale(resourceId, targetLanguage)
|
||||
if (existing == null) {
|
||||
seriesGenreTranslationRepository.save(SeriesGenreTranslation(resourceId, targetLanguage, genre))
|
||||
} else {
|
||||
existing.genre = genre
|
||||
seriesGenreTranslationRepository.save(existing)
|
||||
}
|
||||
}
|
||||
|
||||
private fun upsertOriginalWork(resourceId: Long, targetLanguage: String, translations: Map<String, String>) {
|
||||
val payload = OriginalWorkTranslationPayload(
|
||||
title = translations["title"].orEmpty(),
|
||||
contentType = translations["contentType"].orEmpty(),
|
||||
category = translations["category"].orEmpty(),
|
||||
description = translations["description"].orEmpty(),
|
||||
tags = translations["tags"].orEmpty()
|
||||
.split(",")
|
||||
.map { it.trim() }
|
||||
.filter { it.isNotBlank() }
|
||||
)
|
||||
val existing = originalWorkTranslationRepository.findByOriginalWorkIdAndLocale(resourceId, targetLanguage)
|
||||
if (existing == null) {
|
||||
originalWorkTranslationRepository.save(OriginalWorkTranslation(resourceId, targetLanguage, payload))
|
||||
} else {
|
||||
existing.renderedPayload = payload
|
||||
originalWorkTranslationRepository.save(existing)
|
||||
}
|
||||
}
|
||||
|
||||
private fun upsertCategory(resourceId: Long, targetLanguage: String, translations: Map<String, String>) {
|
||||
val category = translations["category"].orEmpty()
|
||||
val existing = categoryTranslationRepository.findByCategoryIdAndLocale(resourceId, targetLanguage)
|
||||
if (existing == null) {
|
||||
categoryTranslationRepository.save(CategoryTranslation(resourceId, targetLanguage, category))
|
||||
} else {
|
||||
existing.category = category
|
||||
categoryTranslationRepository.save(existing)
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val DEFAULT_PROVIDER = "papago"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
package kr.co.vividnext.sodalive.i18n.translation
|
||||
|
||||
import kr.co.vividnext.sodalive.admin.content.series.AdminContentSeriesRepository
|
||||
import kr.co.vividnext.sodalive.admin.content.series.genre.AdminContentSeriesGenreRepository
|
||||
import kr.co.vividnext.sodalive.chat.character.repository.ChatCharacterRepository
|
||||
import kr.co.vividnext.sodalive.chat.original.OriginalWorkRepository
|
||||
import kr.co.vividnext.sodalive.content.AudioContentRepository
|
||||
import kr.co.vividnext.sodalive.content.category.CategoryRepository
|
||||
import kr.co.vividnext.sodalive.content.theme.AudioContentThemeQueryRepository
|
||||
import org.springframework.data.repository.findByIdOrNull
|
||||
import org.springframework.stereotype.Component
|
||||
|
||||
data class TranslationSourceField(
|
||||
val fieldKey: String,
|
||||
val sourceText: String
|
||||
)
|
||||
|
||||
data class TranslationSource(
|
||||
val resourceType: LanguageTranslationTargetType,
|
||||
val resourceId: Long,
|
||||
val sourceLanguage: String,
|
||||
val fields: List<TranslationSourceField>
|
||||
)
|
||||
|
||||
@Component
|
||||
class TranslationSourceExtractor(
|
||||
private val audioContentRepository: AudioContentRepository,
|
||||
private val chatCharacterRepository: ChatCharacterRepository,
|
||||
private val audioContentThemeRepository: AudioContentThemeQueryRepository,
|
||||
private val seriesRepository: AdminContentSeriesRepository,
|
||||
private val seriesGenreRepository: AdminContentSeriesGenreRepository,
|
||||
private val originalWorkRepository: OriginalWorkRepository,
|
||||
private val categoryRepository: CategoryRepository
|
||||
) {
|
||||
fun extract(resourceType: LanguageTranslationTargetType, resourceId: Long): TranslationSource? {
|
||||
return when (resourceType) {
|
||||
LanguageTranslationTargetType.CONTENT -> extractContent(resourceId)
|
||||
LanguageTranslationTargetType.CHARACTER -> extractCharacter(resourceId)
|
||||
LanguageTranslationTargetType.CONTENT_THEME -> extractContentTheme(resourceId)
|
||||
LanguageTranslationTargetType.SERIES -> extractSeries(resourceId)
|
||||
LanguageTranslationTargetType.SERIES_GENRE -> extractSeriesGenre(resourceId)
|
||||
LanguageTranslationTargetType.ORIGINAL_WORK -> extractOriginalWork(resourceId)
|
||||
LanguageTranslationTargetType.CREATOR_CONTENT_CATEGORY -> extractCategory(resourceId)
|
||||
}
|
||||
}
|
||||
|
||||
private fun extractContent(resourceId: Long): TranslationSource? {
|
||||
val content = audioContentRepository.findByIdOrNull(resourceId) ?: return null
|
||||
val sourceLanguage = content.languageCode?.takeIf { it.isNotBlank() } ?: return null
|
||||
val tags = content.audioContentHashTags
|
||||
.mapNotNull { it.hashTag?.tag }
|
||||
.joinToString(",")
|
||||
return TranslationSource(
|
||||
resourceType = LanguageTranslationTargetType.CONTENT,
|
||||
resourceId = resourceId,
|
||||
sourceLanguage = sourceLanguage,
|
||||
fields = listOf(
|
||||
TranslationSourceField("title", content.title),
|
||||
TranslationSourceField("detail", content.detail),
|
||||
TranslationSourceField("tags", tags)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
private fun extractCharacter(resourceId: Long): TranslationSource? {
|
||||
val character = chatCharacterRepository.findByIdOrNull(resourceId) ?: return null
|
||||
val sourceLanguage = character.languageCode?.takeIf { it.isNotBlank() } ?: return null
|
||||
val personality = character.personalities.firstOrNull()
|
||||
val background = character.backgrounds.firstOrNull()
|
||||
val tags = character.tagMappings.joinToString(",") { it.tag.tag }
|
||||
return TranslationSource(
|
||||
resourceType = LanguageTranslationTargetType.CHARACTER,
|
||||
resourceId = resourceId,
|
||||
sourceLanguage = sourceLanguage,
|
||||
fields = listOf(
|
||||
TranslationSourceField("name", character.name),
|
||||
TranslationSourceField("description", character.description),
|
||||
TranslationSourceField("gender", character.gender ?: ""),
|
||||
TranslationSourceField("personalityTrait", personality?.trait ?: ""),
|
||||
TranslationSourceField("personalityDescription", personality?.description ?: ""),
|
||||
TranslationSourceField("backgroundTopic", background?.topic ?: ""),
|
||||
TranslationSourceField("backgroundDescription", background?.description ?: ""),
|
||||
TranslationSourceField("tags", tags)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
private fun extractContentTheme(resourceId: Long): TranslationSource? {
|
||||
val contentTheme = audioContentThemeRepository.findThemeByIdAndActive(resourceId) ?: return null
|
||||
return TranslationSource(
|
||||
resourceType = LanguageTranslationTargetType.CONTENT_THEME,
|
||||
resourceId = resourceId,
|
||||
sourceLanguage = "ko",
|
||||
fields = listOf(TranslationSourceField("theme", contentTheme.theme))
|
||||
)
|
||||
}
|
||||
|
||||
private fun extractSeries(resourceId: Long): TranslationSource? {
|
||||
val series = seriesRepository.findByIdOrNull(resourceId) ?: return null
|
||||
val sourceLanguage = series.languageCode?.takeIf { it.isNotBlank() } ?: return null
|
||||
val keywords = series.keywordList
|
||||
.mapNotNull { it.keyword?.tag }
|
||||
.joinToString(", ")
|
||||
return TranslationSource(
|
||||
resourceType = LanguageTranslationTargetType.SERIES,
|
||||
resourceId = resourceId,
|
||||
sourceLanguage = sourceLanguage,
|
||||
fields = listOf(
|
||||
TranslationSourceField("title", series.title),
|
||||
TranslationSourceField("introduction", series.introduction),
|
||||
TranslationSourceField("keywords", keywords)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
private fun extractSeriesGenre(resourceId: Long): TranslationSource? {
|
||||
val seriesGenre = seriesGenreRepository.findActiveSeriesGenreById(resourceId) ?: return null
|
||||
return TranslationSource(
|
||||
resourceType = LanguageTranslationTargetType.SERIES_GENRE,
|
||||
resourceId = resourceId,
|
||||
sourceLanguage = "ko",
|
||||
fields = listOf(TranslationSourceField("genre", seriesGenre.genre))
|
||||
)
|
||||
}
|
||||
|
||||
private fun extractOriginalWork(resourceId: Long): TranslationSource? {
|
||||
val originalWork = originalWorkRepository.findByIdOrNull(resourceId) ?: return null
|
||||
val sourceLanguage = originalWork.languageCode?.takeIf { it.isNotBlank() } ?: return null
|
||||
val tags = originalWork.tagMappings.joinToString(", ") { it.tag.tag }
|
||||
return TranslationSource(
|
||||
resourceType = LanguageTranslationTargetType.ORIGINAL_WORK,
|
||||
resourceId = resourceId,
|
||||
sourceLanguage = sourceLanguage,
|
||||
fields = listOf(
|
||||
TranslationSourceField("title", originalWork.title),
|
||||
TranslationSourceField("contentType", originalWork.contentType),
|
||||
TranslationSourceField("category", originalWork.category),
|
||||
TranslationSourceField("description", originalWork.description),
|
||||
TranslationSourceField("tags", tags)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
private fun extractCategory(resourceId: Long): TranslationSource? {
|
||||
val category = categoryRepository.findByIdOrNull(resourceId) ?: return null
|
||||
val sourceLanguage = category.languageCode?.takeIf { it.isNotBlank() } ?: return null
|
||||
if (!category.isActive) return null
|
||||
return TranslationSource(
|
||||
resourceType = LanguageTranslationTargetType.CREATOR_CONTENT_CATEGORY,
|
||||
resourceId = resourceId,
|
||||
sourceLanguage = sourceLanguage,
|
||||
fields = listOf(TranslationSourceField("category", category.title))
|
||||
)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user