feat(i18n): 번역 작업 큐와 언어 감지 캐시를 도입한다

조회 중 외부 번역 호출을 줄이고 누락 번역을 비동기 job으로 처리한다.
This commit is contained in:
2026-05-06 18:02:36 +09:00
parent dfb97fba80
commit 3a0c30e340
30 changed files with 1561 additions and 848 deletions

View File

@@ -14,8 +14,6 @@ import kr.co.vividnext.sodalive.chat.character.dto.RecentCharacter
import kr.co.vividnext.sodalive.chat.character.dto.RecentCharactersResponse
import kr.co.vividnext.sodalive.chat.character.service.ChatCharacterBannerService
import kr.co.vividnext.sodalive.chat.character.service.ChatCharacterService
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.TranslatedAiCharacterDetail
@@ -24,8 +22,8 @@ import kr.co.vividnext.sodalive.chat.room.service.ChatRoomService
import kr.co.vividnext.sodalive.common.ApiResponse
import kr.co.vividnext.sodalive.common.SodaException
import kr.co.vividnext.sodalive.i18n.LangContext
import kr.co.vividnext.sodalive.i18n.translation.PapagoTranslationService
import kr.co.vividnext.sodalive.i18n.translation.TranslateRequest
import kr.co.vividnext.sodalive.i18n.translation.LanguageTranslationTargetType
import kr.co.vividnext.sodalive.i18n.translation.ResourceTranslationJobScheduler
import kr.co.vividnext.sodalive.member.Member
import kr.co.vividnext.sodalive.member.contentpreference.MemberContentPreferenceService
import org.springframework.beans.factory.annotation.Value
@@ -46,7 +44,7 @@ class ChatCharacterController(
private val characterCommentService: CharacterCommentService,
private val curationQueryService: CharacterCurationQueryService,
private val translationService: PapagoTranslationService,
private val resourceTranslationJobScheduler: ResourceTranslationJobScheduler,
private val aiCharacterTranslationRepository: AiCharacterTranslationRepository,
private val langContext: LangContext,
@@ -212,89 +210,11 @@ class ChatCharacterController(
tags = payload.tags
)
} else {
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 = langContext.lang.code
)
resourceTranslationJobScheduler.scheduleResourceTranslation(
resourceType = LanguageTranslationTargetType.CHARACTER,
resourceId = character.id!!,
targetLanguage = langContext.lang.code
)
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 entity = AiCharacterTranslation(
characterId = character.id!!,
locale = langContext.lang.code,
renderedPayload = payload
)
aiCharacterTranslationRepository.save(entity)
translated = TranslatedAiCharacterDetail(
name = translatedName,
description = translatedDescription,
gender = translatedGender,
personality = translatedPersonality,
background = translatedBackground,
tags = translatedTags
)
}
}
}

View File

@@ -1,28 +1,22 @@
package kr.co.vividnext.sodalive.chat.original.service
import kr.co.vividnext.sodalive.chat.original.OriginalWork
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.chat.original.translation.TranslatedOriginalWork
import kr.co.vividnext.sodalive.i18n.translation.PapagoTranslationService
import kr.co.vividnext.sodalive.i18n.translation.TranslateRequest
import org.slf4j.LoggerFactory
import kr.co.vividnext.sodalive.i18n.translation.LanguageTranslationTargetType
import kr.co.vividnext.sodalive.i18n.translation.ResourceTranslationJobScheduler
import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional
@Service
class OriginalWorkTranslationService(
private val translationRepository: OriginalWorkTranslationRepository,
private val papagoTranslationService: PapagoTranslationService
private val resourceTranslationJobScheduler: ResourceTranslationJobScheduler
) {
private val log = LoggerFactory.getLogger(javaClass)
/**
* 원작의 언어와 요청 언어가 다를 때 번역 데이터를 확보하고 반환한다.
* - 기존 번역이 있으면 그대로 사용
* - 없으면 파파고 번역 수행 후 저장
* - 없으면 누락 번역 job 예약 후 null 반환
* - 실패/불필요 시 null 반환
*/
@Transactional
@@ -55,70 +49,11 @@ class OriginalWorkTranslationService(
}
}
// 파파고 번역 수행
return try {
val tags = originalWork.tagMappings.map { it.tag.tag }.filter { it.isNotBlank() }
val texts = buildList {
add(originalWork.title)
add(originalWork.contentType)
add(originalWork.category)
add(originalWork.description)
addAll(tags)
}
val response = papagoTranslationService.translate(
TranslateRequest(
texts = texts,
sourceLanguage = source,
targetLanguage = target
)
)
val out = response.translatedText
if (out.isEmpty()) return null
// 앞 4개는 필드, 나머지는 태그
val title = out.getOrNull(0)?.trim().orEmpty()
val contentType = out.getOrNull(1)?.trim().orEmpty()
val category = out.getOrNull(2)?.trim().orEmpty()
val description = out.getOrNull(3)?.trim().orEmpty()
val translatedTags = if (out.size > 4) {
out.drop(4).map { it.trim() }.filter { it.isNotEmpty() }
} else {
emptyList()
}
val hasAny = title.isNotEmpty() || contentType.isNotEmpty() ||
category.isNotEmpty() || description.isNotEmpty() || translatedTags.isNotEmpty()
if (!hasAny) return null
val payload = OriginalWorkTranslationPayload(
title = title,
contentType = contentType,
category = category,
description = description,
tags = translatedTags
)
val entity = existed?.apply { this.renderedPayload = payload }
?: OriginalWorkTranslation(
originalWorkId = originalWork.id!!,
locale = target,
renderedPayload = payload
)
translationRepository.save(entity)
TranslatedOriginalWork(
title = title,
contentType = contentType,
category = category,
description = description,
tags = translatedTags
)
} catch (e: Exception) {
log.warn("Failed to translate OriginalWork(id={}) from {} to {}: {}", originalWork.id, source, target, e.message)
null
}
resourceTranslationJobScheduler.scheduleResourceTranslation(
resourceType = LanguageTranslationTargetType.ORIGINAL_WORK,
resourceId = originalWork.id!!,
targetLanguage = target
)
return null
}
}

View File

@@ -22,8 +22,6 @@ import kr.co.vividnext.sodalive.content.pin.PinContent
import kr.co.vividnext.sodalive.content.pin.PinContentRepository
import kr.co.vividnext.sodalive.content.theme.AudioContentThemeQueryRepository
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.content.translation.TranslatedContent
import kr.co.vividnext.sodalive.explorer.ExplorerQueryRepository
@@ -36,8 +34,7 @@ import kr.co.vividnext.sodalive.i18n.LangContext
import kr.co.vividnext.sodalive.i18n.SodaMessageSource
import kr.co.vividnext.sodalive.i18n.translation.LanguageTranslationEvent
import kr.co.vividnext.sodalive.i18n.translation.LanguageTranslationTargetType
import kr.co.vividnext.sodalive.i18n.translation.PapagoTranslationService
import kr.co.vividnext.sodalive.i18n.translation.TranslateRequest
import kr.co.vividnext.sodalive.i18n.translation.ResourceTranslationJobScheduler
import kr.co.vividnext.sodalive.member.Member
import kr.co.vividnext.sodalive.member.block.BlockMemberRepository
import kr.co.vividnext.sodalive.member.contentpreference.isAdultVisibleByPolicy
@@ -70,7 +67,7 @@ class AudioContentService(
private val audioContentLikeRepository: AudioContentLikeRepository,
private val pinContentRepository: PinContentRepository,
private val translationService: PapagoTranslationService,
private val resourceTranslationJobScheduler: ResourceTranslationJobScheduler,
private val contentTranslationRepository: ContentTranslationRepository,
private val s3Uploader: S3Uploader,
@@ -770,7 +767,7 @@ class AudioContentService(
* TranslatedContent로 가공한다
*
* 번역 콘텐츠가 없으면
* 파파고 API를 통해 번역한 후 저장한다.
* 누락 번역 job을 예약하고 원문 응답을 유지한다.
*
* 번역 대상: title, detail, tags
*
@@ -792,49 +789,11 @@ class AudioContentService(
tags = payload.tags
)
} else {
val texts = mutableListOf<String>()
texts.add(audioContent.title)
texts.add(audioContent.detail)
texts.add(tag)
val sourceLanguage = audioContent.languageCode ?: "ko"
val response = translationService.translate(
request = TranslateRequest(
texts = texts,
sourceLanguage = sourceLanguage,
targetLanguage = langContext.lang.code
)
resourceTranslationJobScheduler.scheduleResourceTranslation(
resourceType = LanguageTranslationTargetType.CONTENT,
resourceId = audioContent.id!!,
targetLanguage = langContext.lang.code
)
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
)
contentTranslationRepository.save(
ContentTranslation(
contentId = audioContent.id!!,
locale = langContext.lang.code,
renderedPayload = payload
)
)
translated = TranslatedContent(
title = translatedTitle,
detail = translatedDetail,
tags = translatedTags
)
}
}
}

View File

@@ -60,6 +60,7 @@ class LanguageDetectListener(
private val seriesRepository: ContentSeriesRepository,
private val originalWorkRepository: OriginalWorkRepository,
private val categoryRepository: CategoryRepository,
private val languageDetectionCacheService: LanguageDetectionCacheService,
private val applicationEventPublisher: ApplicationEventPublisher,
@@ -116,7 +117,7 @@ class LanguageDetectListener(
return
}
val langCode = requestPapagoLanguageCode(event.query, characterId) ?: return
val langCode = detectLanguageCode(event, characterId) ?: return
character.languageCode = langCode
chatCharacterRepository.save(character)
@@ -154,7 +155,7 @@ class LanguageDetectListener(
return
}
val langCode = requestPapagoLanguageCode(event.query, contentId) ?: return
val langCode = detectLanguageCode(event, contentId) ?: return
audioContent.languageCode = langCode
@@ -194,7 +195,7 @@ class LanguageDetectListener(
return
}
val langCode = requestPapagoLanguageCode(event.query, commentId) ?: return
val langCode = detectLanguageCode(event, commentId) ?: return
comment.languageCode = langCode
audioContentCommentRepository.save(comment)
@@ -226,7 +227,7 @@ class LanguageDetectListener(
return
}
val langCode = requestPapagoLanguageCode(event.query, commentId) ?: return
val langCode = detectLanguageCode(event, commentId) ?: return
comment.languageCode = langCode
characterCommentRepository.save(comment)
@@ -257,7 +258,7 @@ class LanguageDetectListener(
return
}
val langCode = requestPapagoLanguageCode(event.query, cheersId) ?: return
val langCode = detectLanguageCode(event, cheersId) ?: return
cheers.languageCode = langCode
creatorCheersRepository.save(cheers)
@@ -288,7 +289,7 @@ class LanguageDetectListener(
return
}
val langCode = requestPapagoLanguageCode(event.query, seriesId) ?: return
val langCode = detectLanguageCode(event, seriesId) ?: return
series.languageCode = langCode
seriesRepository.save(series)
@@ -326,7 +327,7 @@ class LanguageDetectListener(
return
}
val langCode = requestPapagoLanguageCode(event.query, originalWorkId) ?: return
val langCode = detectLanguageCode(event, originalWorkId) ?: return
originalWork.languageCode = langCode
originalWorkRepository.save(originalWork)
@@ -352,7 +353,7 @@ class LanguageDetectListener(
val category = categoryRepository.findByIdOrNull(categoryId) ?: return
if (!category.languageCode.isNullOrBlank()) return
val langCode = requestPapagoLanguageCode(event.query, categoryId) ?: return
val langCode = detectLanguageCode(event, categoryId) ?: return
category.languageCode = langCode
categoryRepository.save(category)
@@ -365,6 +366,12 @@ class LanguageDetectListener(
)
}
private fun detectLanguageCode(event: LanguageDetectEvent, targetIdForLog: Long): String? {
return languageDetectionCacheService.detectWithCache(event.query) {
requestPapagoLanguageCode(event.query, targetIdForLog)
}
}
private fun requestPapagoLanguageCode(query: String, targetIdForLog: Long): String? {
return try {
val headers = HttpHeaders().apply {

View File

@@ -0,0 +1,46 @@
package kr.co.vividnext.sodalive.content
import kr.co.vividnext.sodalive.i18n.translation.SourceTextNormalizer
import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional
@Service
class LanguageDetectionCacheService(
private val languageDetectionResultRepository: LanguageDetectionResultRepository
) {
@Transactional
fun detectWithCache(
query: String,
provider: String = DEFAULT_PROVIDER,
detector: () -> String?
): String? {
val normalizedQuery = SourceTextNormalizer.normalize(query)
if (normalizedQuery.isBlank()) return null
val sourceHash = SourceTextNormalizer.hash(normalizedQuery)
val cached = languageDetectionResultRepository.findBySourceHashAndProviderAndNormalizationVersion(
sourceHash = sourceHash,
provider = provider,
normalizationVersion = SourceTextNormalizer.NORMALIZATION_VERSION
)
if (cached != null) return cached.detectedLanguage
val detectedLanguage = detector()?.takeIf { it.isNotBlank() } ?: return null
languageDetectionResultRepository.save(
LanguageDetectionResult(
sourceHash = sourceHash,
sourceTextSample = normalizedQuery.take(MAX_SAMPLE_LENGTH),
detectedLanguage = detectedLanguage.lowercase(),
provider = provider,
confidence = null,
normalizationVersion = SourceTextNormalizer.NORMALIZATION_VERSION
)
)
return detectedLanguage.lowercase()
}
companion object {
const val DEFAULT_PROVIDER = "papago"
private const val MAX_SAMPLE_LENGTH = 500
}
}

View File

@@ -0,0 +1,38 @@
package kr.co.vividnext.sodalive.content
import kr.co.vividnext.sodalive.common.BaseEntity
import kr.co.vividnext.sodalive.i18n.translation.SourceTextNormalizer
import javax.persistence.Column
import javax.persistence.Entity
import javax.persistence.Table
import javax.persistence.UniqueConstraint
@Entity
@Table(
name = "language_detection_result",
uniqueConstraints = [
UniqueConstraint(
name = "uk_language_detection_result_hash_provider_version",
columnNames = ["source_hash", "provider", "normalization_version"]
)
]
)
class LanguageDetectionResult(
@Column(name = "source_hash", nullable = false, length = 64)
val sourceHash: String,
@Column(name = "source_text_sample", nullable = false, length = 500)
val sourceTextSample: String,
@Column(name = "detected_language", nullable = false, length = 10)
val detectedLanguage: String,
@Column(name = "provider", nullable = false, length = 50)
val provider: String,
@Column(name = "confidence")
val confidence: Double? = null,
@Column(name = "normalization_version", nullable = false, length = 20)
val normalizationVersion: String = SourceTextNormalizer.NORMALIZATION_VERSION
) : BaseEntity()

View File

@@ -0,0 +1,11 @@
package kr.co.vividnext.sodalive.content
import org.springframework.data.jpa.repository.JpaRepository
interface LanguageDetectionResultRepository : JpaRepository<LanguageDetectionResult, Long> {
fun findBySourceHashAndProviderAndNormalizationVersion(
sourceHash: String,
provider: String,
normalizationVersion: String
): LanguageDetectionResult?
}

View File

@@ -7,8 +7,7 @@ import kr.co.vividnext.sodalive.content.LanguageDetectTargetType
import kr.co.vividnext.sodalive.i18n.LangContext
import kr.co.vividnext.sodalive.i18n.translation.LanguageTranslationEvent
import kr.co.vividnext.sodalive.i18n.translation.LanguageTranslationTargetType
import kr.co.vividnext.sodalive.i18n.translation.PapagoTranslationService
import kr.co.vividnext.sodalive.i18n.translation.TranslateRequest
import kr.co.vividnext.sodalive.i18n.translation.ResourceTranslationJobScheduler
import kr.co.vividnext.sodalive.member.Member
import kr.co.vividnext.sodalive.member.block.BlockMemberRepository
import org.springframework.context.ApplicationEventPublisher
@@ -25,7 +24,7 @@ class CategoryService(
private val langContext: LangContext,
private val applicationEventPublisher: ApplicationEventPublisher,
private val translationService: PapagoTranslationService
private val resourceTranslationJobScheduler: ResourceTranslationJobScheduler
) {
@Transactional
fun createCategory(request: CreateCategoryRequest, member: Member) {
@@ -148,7 +147,7 @@ class CategoryService(
.findByCategoryIdInAndLocale(categoryIds, locale)
.associateBy { it.categoryId }
// 각 항목에 대해 번역 적용. 없으면 Papago로 번역 저장 후 적용
// 각 항목에 대해 번역 적용. 없으면 누락 번역 job만 예약하고 원문을 반환한다.
val result = mutableListOf<GetCategoryListResponse>()
for (item in baseList) {
val entity = entityMap[item.categoryId]
@@ -165,38 +164,11 @@ class CategoryService(
continue
}
// 번역본이 없으면 Papago 번역 후 저장
val texts = listOf(entity.title)
val response = translationService.translate(
request = TranslateRequest(
texts = texts,
sourceLanguage = sourceLang,
targetLanguage = locale
)
resourceTranslationJobScheduler.scheduleResourceTranslation(
resourceType = LanguageTranslationTargetType.CREATOR_CONTENT_CATEGORY,
resourceId = entity.id!!,
targetLanguage = locale
)
val translatedTexts = response.translatedText
if (translatedTexts.size == texts.size) {
val translatedCategory = translatedTexts[0]
val existingOne = categoryTranslationRepository
.findByCategoryIdAndLocale(entity.id!!, locale)
if (existingOne == null) {
categoryTranslationRepository.save(
CategoryTranslation(
categoryId = entity.id!!,
locale = locale,
category = translatedCategory
)
)
} else {
existingOne.category = translatedCategory
categoryTranslationRepository.save(existingOne)
}
result.add(GetCategoryListResponse(categoryId = item.categoryId, category = translatedCategory))
continue
}
}
// 번역이 필요 없거나 실패한 경우 원본 사용

View File

@@ -7,8 +7,6 @@ import kr.co.vividnext.sodalive.content.order.OrderType
import kr.co.vividnext.sodalive.content.series.content.ContentSeriesContentRepository
import kr.co.vividnext.sodalive.content.series.content.GetSeriesContentListResponse
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.series.translation.TranslatedSeries
import kr.co.vividnext.sodalive.content.translation.ContentTranslationRepository
@@ -17,8 +15,8 @@ import kr.co.vividnext.sodalive.creator.admin.content.series.SeriesSortType
import kr.co.vividnext.sodalive.explorer.ExplorerQueryRepository
import kr.co.vividnext.sodalive.i18n.Lang
import kr.co.vividnext.sodalive.i18n.LangContext
import kr.co.vividnext.sodalive.i18n.translation.PapagoTranslationService
import kr.co.vividnext.sodalive.i18n.translation.TranslateRequest
import kr.co.vividnext.sodalive.i18n.translation.LanguageTranslationTargetType
import kr.co.vividnext.sodalive.i18n.translation.ResourceTranslationJobScheduler
import kr.co.vividnext.sodalive.member.Member
import kr.co.vividnext.sodalive.member.block.BlockMemberRepository
import kr.co.vividnext.sodalive.member.contentpreference.isAdultVisibleByPolicy
@@ -41,7 +39,7 @@ class ContentSeriesService(
private val seriesTranslationRepository: SeriesTranslationRepository,
private val seriesGenreTranslationRepository: SeriesGenreTranslationRepository,
private val contentTranslationRepository: ContentTranslationRepository,
private val translationService: PapagoTranslationService,
private val resourceTranslationJobScheduler: ResourceTranslationJobScheduler,
@Value("\${cloud.aws.cloud-front.host}")
private val coverImageHost: String
@@ -91,7 +89,7 @@ class ContentSeriesService(
fun getGenreList(memberId: Long, isAdult: Boolean, contentType: ContentType): List<GetSeriesGenreListResponse> {
/**
* langContext.lang == Lang.EN || Lang.JA 일 때 번역된 콘텐츠 테마 반환
* 번역이 없으면 번역 API 호출 후 저장하고 반환
* 번역이 없으면 누락 번역 job만 예약하고 원문을 반환
*/
val genres = repository.getGenreList(memberId = memberId, isAdult = isAdult, contentType = contentType)
@@ -120,32 +118,12 @@ class ContentSeriesService(
// 미번역 항목 수집
val untranslated = genres.filter { existingMap[it.id] == null }
if (untranslated.isNotEmpty()) {
val texts = untranslated.map { it.genre }
val response = translationService.translate(
request = TranslateRequest(
texts = texts,
sourceLanguage = "ko",
targetLanguage = targetLocale
)
untranslated.forEach { item ->
resourceTranslationJobScheduler.scheduleResourceTranslation(
resourceType = LanguageTranslationTargetType.SERIES_GENRE,
resourceId = item.id,
targetLanguage = targetLocale
)
val translatedTexts = response.translatedText
val toSave = mutableListOf<kr.co.vividnext.sodalive.content.series.translation.SeriesGenreTranslation>()
untranslated.forEachIndexed { index, item ->
val translated = translatedTexts.getOrNull(index) ?: item.genre
toSave.add(
kr.co.vividnext.sodalive.content.series.translation.SeriesGenreTranslation(
seriesGenreId = item.id,
locale = targetLocale,
genre = translated
)
)
}
if (toSave.isNotEmpty()) {
seriesGenreTranslationRepository.saveAll(toSave)
toSave.forEach { saved -> existingMap[saved.seriesGenreId] = saved }
}
}
// 원래 순서 보존하여 결과 조립
@@ -283,7 +261,7 @@ class ContentSeriesService(
* TranslatedSeries로 가공한다
*
* 번역 콘텐츠가 없으면
* 파파고 API를 통해 번역한 후 저장한다.
* 누락 번역 job을 예약하고 원문 응답을 유지한다.
*
* 번역 대상: title, introduction, keywordList
*
@@ -309,54 +287,11 @@ class ContentSeriesService(
keywords = kws
)
} else {
val texts = mutableListOf<String>()
texts.add(series.title)
texts.add(series.introduction)
// 키워드는 개별 항목으로 번역 요청하여 N회 호출을 방지한다.
val keywordListForTranslate = keywordList
texts.addAll(keywordListForTranslate)
val response = translationService.translate(
request = TranslateRequest(
texts = texts,
sourceLanguage = languageCode,
targetLanguage = locale
)
resourceTranslationJobScheduler.scheduleResourceTranslation(
resourceType = LanguageTranslationTargetType.SERIES,
resourceId = seriesId,
targetLanguage = locale
)
val translatedTexts = response.translatedText
if (translatedTexts.size == texts.size) {
var index = 0
val translatedTitle = translatedTexts[index++]
val translatedIntroduction = translatedTexts[index++]
val translatedKeywords = if (keywordListForTranslate.isNotEmpty()) {
translatedTexts.subList(index, translatedTexts.size)
} else {
// 번역할 키워드가 없으면 원본 키워드 반환 정책 적용
keywordList
}
val payload = SeriesTranslationPayload(
title = translatedTitle,
introduction = translatedIntroduction,
keywords = translatedKeywords
)
seriesTranslationRepository.save(
SeriesTranslation(
seriesId = seriesId,
locale = locale,
renderedPayload = payload
)
)
val kws = translatedKeywords.ifEmpty { keywordList }
translated = TranslatedSeries(
title = translatedTitle,
introduction = translatedIntroduction,
keywords = kws
)
}
}
}
}

View File

@@ -5,12 +5,11 @@ import kr.co.vividnext.sodalive.content.AudioContentRepository
import kr.co.vividnext.sodalive.content.ContentType
import kr.co.vividnext.sodalive.content.SortType
import kr.co.vividnext.sodalive.content.theme.content.GetContentByThemeResponse
import kr.co.vividnext.sodalive.content.theme.translation.ContentThemeTranslation
import kr.co.vividnext.sodalive.content.theme.translation.ContentThemeTranslationRepository
import kr.co.vividnext.sodalive.i18n.Lang
import kr.co.vividnext.sodalive.i18n.LangContext
import kr.co.vividnext.sodalive.i18n.translation.PapagoTranslationService
import kr.co.vividnext.sodalive.i18n.translation.TranslateRequest
import kr.co.vividnext.sodalive.i18n.translation.LanguageTranslationTargetType
import kr.co.vividnext.sodalive.i18n.translation.ResourceTranslationJobScheduler
import kr.co.vividnext.sodalive.member.Member
import kr.co.vividnext.sodalive.member.contentpreference.isAdultVisibleByPolicy
import org.springframework.stereotype.Service
@@ -22,7 +21,7 @@ class AudioContentThemeService(
private val contentRepository: AudioContentRepository,
private val contentThemeTranslationRepository: ContentThemeTranslationRepository,
private val papagoTranslationService: PapagoTranslationService,
private val resourceTranslationJobScheduler: ResourceTranslationJobScheduler,
private val langContext: LangContext
) {
@Transactional(readOnly = true)
@@ -51,7 +50,7 @@ class AudioContentThemeService(
/**
* langContext.lang == Lang.EN || Lang.JA 일 때 번역된 콘텐츠 테마 반환
* 번역이 없으면 번역 API 호출 후 저장하고 반환
* 번역이 없으면 누락 번역 job만 예약하고 원문을 반환
*/
val currentLang = langContext.lang
if (currentLang == Lang.EN || currentLang == Lang.JA) {
@@ -66,43 +65,14 @@ class AudioContentThemeService(
val existingMap = existingTranslations.associateBy { it.contentThemeId }
// 2) 미번역 항목만 수집하여 한 번에 번역 요청
// 2) 미번역 항목은 조회 스레드에서 번역하지 않고 job만 예약
val untranslatedPairs = themesWithIds.filter { existingMap[it.id] == null }
if (untranslatedPairs.isNotEmpty()) {
val texts = untranslatedPairs.map { it.theme }
val response = papagoTranslationService.translate(
request = TranslateRequest(
texts = texts,
sourceLanguage = "ko",
targetLanguage = targetLocale
)
untranslatedPairs.forEach { pair ->
resourceTranslationJobScheduler.scheduleResourceTranslation(
resourceType = LanguageTranslationTargetType.CONTENT_THEME,
resourceId = pair.id,
targetLanguage = targetLocale
)
val translatedTexts = response.translatedText
val entitiesToSave = mutableListOf<ContentThemeTranslation>()
// translatedTexts 크기가 다르면 안전하게 원문으로 대체
untranslatedPairs.forEachIndexed { index, pair ->
val translated = translatedTexts.getOrNull(index) ?: pair.theme
entitiesToSave.add(
ContentThemeTranslation(
contentThemeId = pair.id,
locale = targetLocale,
theme = translated
)
)
}
if (entitiesToSave.isNotEmpty()) {
contentThemeTranslationRepository.saveAll(entitiesToSave)
}
// 저장 후 맵을 갱신
entitiesToSave.forEach { entity ->
(existingMap as MutableMap)[entity.contentThemeId] = entity
}
}
// 3) 원래 순서대로 결과 조립 (번역 없으면 원문 fallback)

View File

@@ -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)
}
}

View File

@@ -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())
}

View File

@@ -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
)
}
}
}

View File

@@ -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) }
}
}

View File

@@ -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()

View File

@@ -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?
}

View File

@@ -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()
)
)
}
}

View File

@@ -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
}
}

View File

@@ -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()

View File

@@ -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?
}

View File

@@ -0,0 +1,8 @@
package kr.co.vividnext.sodalive.i18n.translation
interface TranslationProvider {
val providerName: String
val providerVersion: String
fun translate(request: TranslateRequest): TranslateResult
}

View File

@@ -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"
}
}

View File

@@ -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))
)
}
}

View File

@@ -11,7 +11,7 @@ import kr.co.vividnext.sodalive.chat.character.translate.AiCharacterTranslationR
import kr.co.vividnext.sodalive.chat.room.service.ChatRoomService
import kr.co.vividnext.sodalive.i18n.Lang
import kr.co.vividnext.sodalive.i18n.LangContext
import kr.co.vividnext.sodalive.i18n.translation.PapagoTranslationService
import kr.co.vividnext.sodalive.i18n.translation.ResourceTranslationJobScheduler
import kr.co.vividnext.sodalive.member.contentpreference.MemberContentPreferenceService
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertTrue
@@ -26,7 +26,7 @@ class ChatCharacterControllerTest {
private val chatRoomService = Mockito.mock(ChatRoomService::class.java)
private val characterCommentService = Mockito.mock(CharacterCommentService::class.java)
private val curationQueryService = Mockito.mock(CharacterCurationQueryService::class.java)
private val translationService = Mockito.mock(PapagoTranslationService::class.java)
private val resourceTranslationJobScheduler = Mockito.mock(ResourceTranslationJobScheduler::class.java)
private val aiCharacterTranslationRepository = Mockito.mock(AiCharacterTranslationRepository::class.java)
private val langContext = LangContext().apply { setLang(Lang.JA) }
private val memberContentPreferenceService = Mockito.mock(MemberContentPreferenceService::class.java)
@@ -36,7 +36,7 @@ class ChatCharacterControllerTest {
chatRoomService = chatRoomService,
characterCommentService = characterCommentService,
curationQueryService = curationQueryService,
translationService = translationService,
resourceTranslationJobScheduler = resourceTranslationJobScheduler,
aiCharacterTranslationRepository = aiCharacterTranslationRepository,
langContext = langContext,
memberContentPreferenceService = memberContentPreferenceService,
@@ -73,7 +73,7 @@ class ChatCharacterControllerTest {
chatRoomService = chatRoomService,
characterCommentService = characterCommentService,
curationQueryService = curationQueryService,
translationService = translationService,
resourceTranslationJobScheduler = resourceTranslationJobScheduler,
aiCharacterTranslationRepository = aiCharacterTranslationRepository,
langContext = LangContext().apply { setLang(Lang.EN) },
memberContentPreferenceService = memberContentPreferenceService,

View File

@@ -18,7 +18,7 @@ import kr.co.vividnext.sodalive.content.translation.ContentTranslationRepository
import kr.co.vividnext.sodalive.explorer.ExplorerQueryRepository
import kr.co.vividnext.sodalive.i18n.LangContext
import kr.co.vividnext.sodalive.i18n.SodaMessageSource
import kr.co.vividnext.sodalive.i18n.translation.PapagoTranslationService
import kr.co.vividnext.sodalive.i18n.translation.ResourceTranslationJobScheduler
import kr.co.vividnext.sodalive.member.Member
import kr.co.vividnext.sodalive.member.block.BlockMemberRepository
import org.junit.jupiter.api.Assertions.assertEquals
@@ -44,7 +44,7 @@ class AudioContentServiceTest {
private lateinit var commentRepository: AudioContentCommentRepository
private lateinit var audioContentLikeRepository: AudioContentLikeRepository
private lateinit var pinContentRepository: PinContentRepository
private lateinit var translationService: PapagoTranslationService
private lateinit var resourceTranslationJobScheduler: ResourceTranslationJobScheduler
private lateinit var contentTranslationRepository: ContentTranslationRepository
private lateinit var s3Uploader: S3Uploader
private lateinit var audioContentCloudFront: AudioContentCloudFront
@@ -66,7 +66,7 @@ class AudioContentServiceTest {
commentRepository = Mockito.mock(AudioContentCommentRepository::class.java)
audioContentLikeRepository = Mockito.mock(AudioContentLikeRepository::class.java)
pinContentRepository = Mockito.mock(PinContentRepository::class.java)
translationService = Mockito.mock(PapagoTranslationService::class.java)
resourceTranslationJobScheduler = Mockito.mock(ResourceTranslationJobScheduler::class.java)
contentTranslationRepository = Mockito.mock(ContentTranslationRepository::class.java)
s3Uploader = Mockito.mock(S3Uploader::class.java)
audioContentCloudFront = Mockito.mock(AudioContentCloudFront::class.java)
@@ -85,7 +85,7 @@ class AudioContentServiceTest {
commentRepository = commentRepository,
audioContentLikeRepository = audioContentLikeRepository,
pinContentRepository = pinContentRepository,
translationService = translationService,
resourceTranslationJobScheduler = resourceTranslationJobScheduler,
contentTranslationRepository = contentTranslationRepository,
s3Uploader = s3Uploader,
objectMapper = ObjectMapper(),

View File

@@ -0,0 +1,42 @@
package kr.co.vividnext.sodalive.content
import kr.co.vividnext.sodalive.i18n.translation.SourceTextNormalizer
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Test
import org.mockito.Mockito
class LanguageDetectionCacheServiceTest {
@Test
fun shouldReuseCachedLanguageDetectionForSameNormalizedText() {
val repository = Mockito.mock(LanguageDetectionResultRepository::class.java)
val service = LanguageDetectionCacheService(repository)
val sourceHash = SourceTextNormalizer.hash("Hello world")
Mockito.`when`(
repository.findBySourceHashAndProviderAndNormalizationVersion(
sourceHash = sourceHash,
provider = "papago",
normalizationVersion = SourceTextNormalizer.NORMALIZATION_VERSION
)
).thenReturn(
LanguageDetectionResult(
sourceHash = sourceHash,
sourceTextSample = "Hello world",
detectedLanguage = "en",
provider = "papago",
confidence = null,
normalizationVersion = SourceTextNormalizer.NORMALIZATION_VERSION
)
)
var providerCalls = 0
val detected = service.detectWithCache("Hello world") {
providerCalls++
"ko"
}
assertEquals("en", detected)
assertEquals(0, providerCalls)
Mockito.verify(repository, Mockito.never()).save(Mockito.any(LanguageDetectionResult::class.java))
}
}

View File

@@ -0,0 +1,15 @@
package kr.co.vividnext.sodalive.i18n.translation
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Test
class SourceTextNormalizerTest {
@Test
fun shouldNormalizeWhitespaceAndUnicodeBeforeHashing() {
val composed = "카페\n\t소개"
val decomposed = "카페 소개"
assertEquals("카페 소개", SourceTextNormalizer.normalize(composed))
assertEquals(SourceTextNormalizer.hash(composed), SourceTextNormalizer.hash(decomposed))
}
}

View File

@@ -0,0 +1,87 @@
package kr.co.vividnext.sodalive.i18n.translation
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertNotNull
import org.junit.jupiter.api.Test
import org.mockito.ArgumentCaptor
import org.mockito.Mockito
class TranslationJobSchedulerTest {
@Test
fun shouldCreateOnePendingJobForMissingNormalizedText() {
val jobRepository = Mockito.mock(TranslationJobRepository::class.java)
val scheduler = TranslationJobScheduler(jobRepository)
Mockito.`when`(
jobRepository.findActiveJob(
resourceType = LanguageTranslationTargetType.CONTENT,
resourceId = 10L,
fieldKey = "title",
targetLanguage = "en",
sourceHash = SourceTextNormalizer.hash("제목")
)
).thenReturn(null)
scheduler.scheduleMissingTranslation(
resourceType = LanguageTranslationTargetType.CONTENT,
resourceId = 10L,
fieldKey = "title",
sourceText = " 제목\n",
sourceLanguage = "ko",
targetLanguage = "EN"
)
val captor = ArgumentCaptor.forClass(TranslationJob::class.java)
Mockito.verify(jobRepository).save(captor.capture())
val saved = captor.value
assertEquals(LanguageTranslationTargetType.CONTENT, saved.resourceType)
assertEquals(10L, saved.resourceId)
assertEquals("title", saved.fieldKey)
assertEquals("제목", saved.sourceText)
assertEquals(SourceTextNormalizer.hash("제목"), saved.sourceHash)
assertEquals("ko", saved.sourceLanguage)
assertEquals("en", saved.targetLanguage)
assertEquals(TranslationJobStatus.PENDING, saved.status)
assertNotNull(saved.nextRetryAt)
}
@Test
fun shouldNotCreateDuplicateJobWhenSameCompletedJobAlreadyExists() {
val jobRepository = Mockito.mock(TranslationJobRepository::class.java)
val scheduler = TranslationJobScheduler(jobRepository)
val sourceHash = SourceTextNormalizer.hash("제목")
Mockito.`when`(
jobRepository.findByResourceTypeAndResourceIdAndFieldKeyAndTargetLanguageAndSourceHash(
resourceType = LanguageTranslationTargetType.CONTENT,
resourceId = 10L,
fieldKey = "title",
targetLanguage = "en",
sourceHash = sourceHash
)
).thenReturn(
TranslationJob(
resourceType = LanguageTranslationTargetType.CONTENT,
resourceId = 10L,
fieldKey = "title",
sourceHash = sourceHash,
sourceText = "제목",
sourceLanguage = "ko",
targetLanguage = "en",
status = TranslationJobStatus.COMPLETED
)
)
scheduler.scheduleMissingTranslation(
resourceType = LanguageTranslationTargetType.CONTENT,
resourceId = 10L,
fieldKey = "title",
sourceText = "제목",
sourceLanguage = "ko",
targetLanguage = "en"
)
Mockito.verify(jobRepository, Mockito.never()).save(Mockito.any(TranslationJob::class.java))
}
}

View File

@@ -0,0 +1,136 @@
package kr.co.vividnext.sodalive.i18n.translation
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Test
import org.mockito.Mockito
import org.springframework.scheduling.annotation.Scheduled
import org.springframework.transaction.support.AbstractPlatformTransactionManager
import org.springframework.transaction.support.DefaultTransactionStatus
import java.time.LocalDateTime
import java.util.Optional
class TranslationJobWorkerTest {
@Test
fun shouldRunEveryTenMinutesByDefault() {
val scheduled = TranslationJobWorker::class.java
.getDeclaredMethod("runPendingJobs")
.getAnnotation(Scheduled::class.java)
assertEquals("\${sodalive.translation-job.fixed-delay-ms:600000}", scheduled.fixedDelayString)
}
@Test
fun shouldClaimPendingJobByLockedRepositoryMethod() {
val jobRepository = Mockito.mock(TranslationJobRepository::class.java)
val memoryRepository = Mockito.mock(TranslationMemoryRepository::class.java)
val provider = successfulProvider()
val materializer = Mockito.mock(TranslationReadModelMaterializer::class.java)
val worker = TranslationJobWorker(
translationJobRepository = jobRepository,
translationMemoryRepository = memoryRepository,
translationProvider = provider,
materializer = materializer,
transactionManager = TestTransactionManager()
)
val job = translationJob()
job.id = 100L
Mockito.`when`(jobRepository.findNextPendingJobIdForUpdate(anyLocalDateTime())).thenReturn(100L)
Mockito.`when`(jobRepository.findById(100L)).thenReturn(Optional.of(job))
worker.processNextJob()
Mockito.verify(jobRepository).findNextPendingJobIdForUpdate(anyLocalDateTime())
Mockito.verify(jobRepository, Mockito.never())
.findFirstByStatusAndNextRetryAtLessThanEqualOrderByCreatedAtAsc(
anyTranslationJobStatus(),
anyLocalDateTime()
)
}
@Test
fun shouldRetryFailedJobWithBackoff() {
val jobRepository = Mockito.mock(TranslationJobRepository::class.java)
val memoryRepository = Mockito.mock(TranslationMemoryRepository::class.java)
val provider = failingProvider()
val materializer = Mockito.mock(TranslationReadModelMaterializer::class.java)
val worker = TranslationJobWorker(
translationJobRepository = jobRepository,
translationMemoryRepository = memoryRepository,
translationProvider = provider,
materializer = materializer,
transactionManager = TestTransactionManager()
)
val job = translationJob()
job.id = 200L
val beforeRetryAt = job.nextRetryAt
Mockito.`when`(jobRepository.findNextPendingJobIdForUpdate(anyLocalDateTime())).thenReturn(200L)
Mockito.`when`(jobRepository.findById(200L)).thenReturn(Optional.of(job))
worker.processNextJob()
assertEquals(TranslationJobStatus.PENDING, job.status)
assertEquals(1, job.retryCount)
assertEquals("provider down", job.lastErrorMessage)
assertTrue(job.nextRetryAt.isAfter(beforeRetryAt))
}
private fun translationJob(): TranslationJob {
return TranslationJob(
resourceType = LanguageTranslationTargetType.CONTENT,
resourceId = 10L,
fieldKey = "title",
sourceHash = SourceTextNormalizer.hash("제목"),
sourceText = "제목",
sourceLanguage = "ko",
targetLanguage = "en"
)
}
private fun successfulProvider(): TranslationProvider {
return object : TranslationProvider {
override val providerName: String = "papago"
override val providerVersion: String = "nmt-v1"
override fun translate(request: TranslateRequest): TranslateResult {
return TranslateResult(listOf("title"))
}
}
}
private fun failingProvider(): TranslationProvider {
return object : TranslationProvider {
override val providerName: String = "papago"
override val providerVersion: String = "nmt-v1"
override fun translate(request: TranslateRequest): TranslateResult {
throw IllegalStateException("provider down")
}
}
}
private fun anyLocalDateTime(): LocalDateTime {
return Mockito.any(LocalDateTime::class.java) ?: LocalDateTime.now()
}
private fun anyTranslationJobStatus(): TranslationJobStatus {
return Mockito.any(TranslationJobStatus::class.java) ?: TranslationJobStatus.PENDING
}
}
private class TestTransactionManager : AbstractPlatformTransactionManager() {
override fun doGetTransaction(): Any {
return Any()
}
override fun doBegin(transaction: Any, definition: org.springframework.transaction.TransactionDefinition) {
}
override fun doCommit(status: DefaultTransactionStatus) {
}
override fun doRollback(status: DefaultTransactionStatus) {
}
}