diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/admin/chat/character/AdminChatCharacterController.kt b/src/main/kotlin/kr/co/vividnext/sodalive/admin/chat/character/AdminChatCharacterController.kt index 21a7bda..85c4298 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/admin/chat/character/AdminChatCharacterController.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/admin/chat/character/AdminChatCharacterController.kt @@ -15,6 +15,8 @@ import kr.co.vividnext.sodalive.common.ApiResponse import kr.co.vividnext.sodalive.common.SodaException import kr.co.vividnext.sodalive.content.LanguageDetectEvent import kr.co.vividnext.sodalive.content.LanguageDetectTargetType +import kr.co.vividnext.sodalive.i18n.translation.LanguageTranslationEvent +import kr.co.vividnext.sodalive.i18n.translation.LanguageTranslationTargetType import kr.co.vividnext.sodalive.utils.generateFileName import org.springframework.beans.factory.annotation.Value import org.springframework.context.ApplicationEventPublisher @@ -331,6 +333,13 @@ class AdminChatCharacterController( request = request ) + applicationEventPublisher.publishEvent( + LanguageTranslationEvent( + id = request.id, + targetType = LanguageTranslationTargetType.CHARACTER + ) + ) + // 원작 연결: originalWorkId가 있으면 서비스 계층을 통해 배정 if (request.originalWorkId != null) { // 서비스에서 유효성 검증 및 저장까지 처리 diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/translate/AiCharacterTranslation.kt b/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/translate/AiCharacterTranslation.kt index 8d05dfb..3b1e7fc 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/translate/AiCharacterTranslation.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/translate/AiCharacterTranslation.kt @@ -23,7 +23,7 @@ class AiCharacterTranslation( @Column(columnDefinition = "json") @Convert(converter = AiCharacterTranslationRenderedPayloadConverter::class) - val renderedPayload: AiCharacterTranslationRenderedPayload + var renderedPayload: AiCharacterTranslationRenderedPayload ) : BaseEntity() data class AiCharacterTranslationRenderedPayload( diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/content/AudioContentService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/content/AudioContentService.kt index 4dfedb5..54a0533 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/content/AudioContentService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/content/AudioContentService.kt @@ -21,12 +21,16 @@ import kr.co.vividnext.sodalive.content.order.OrderType 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.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 import kr.co.vividnext.sodalive.extensions.convertLocalDateTime import kr.co.vividnext.sodalive.fcm.FcmEvent import kr.co.vividnext.sodalive.fcm.FcmEventType +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.member.Member @@ -167,6 +171,13 @@ class AudioContentService( audioContent.audioContentHashTags.addAll(newAudioContentHashTagList) } + + applicationEventPublisher.publishEvent( + LanguageTranslationEvent( + id = request.contentId, + targetType = LanguageTranslationTargetType.CONTENT + ) + ) } @Transactional @@ -357,6 +368,13 @@ class AudioContentService( ) } + applicationEventPublisher.publishEvent( + LanguageTranslationEvent( + id = audioContent.id!!, + targetType = LanguageTranslationTargetType.CONTENT + ) + ) + return CreateAudioContentResponse(contentId = audioContent.id!!) } @@ -785,14 +803,14 @@ class AudioContentService( val translatedDetail = translatedTexts[index++] val translatedTags = translatedTexts[index] - val payload = kr.co.vividnext.sodalive.content.translation.ContentTranslationPayload( + val payload = ContentTranslationPayload( title = translatedTitle, detail = translatedDetail, tags = translatedTags ) contentTranslationRepository.save( - kr.co.vividnext.sodalive.content.translation.ContentTranslation( + ContentTranslation( contentId = audioContent.id!!, locale = locale, renderedPayload = payload diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/content/LanguageDetectEvent.kt b/src/main/kotlin/kr/co/vividnext/sodalive/content/LanguageDetectEvent.kt index 64a3bfb..fc552f6 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/content/LanguageDetectEvent.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/content/LanguageDetectEvent.kt @@ -4,8 +4,11 @@ import kr.co.vividnext.sodalive.chat.character.comment.CharacterCommentRepositor import kr.co.vividnext.sodalive.chat.character.repository.ChatCharacterRepository import kr.co.vividnext.sodalive.content.comment.AudioContentCommentRepository import kr.co.vividnext.sodalive.explorer.profile.CreatorCheersRepository +import kr.co.vividnext.sodalive.i18n.translation.LanguageTranslationEvent +import kr.co.vividnext.sodalive.i18n.translation.LanguageTranslationTargetType import org.slf4j.LoggerFactory import org.springframework.beans.factory.annotation.Value +import org.springframework.context.ApplicationEventPublisher import org.springframework.http.HttpEntity import org.springframework.http.HttpHeaders import org.springframework.http.MediaType @@ -47,6 +50,8 @@ class LanguageDetectListener( private val characterCommentRepository: CharacterCommentRepository, private val creatorCheersRepository: CreatorCheersRepository, + private val applicationEventPublisher: ApplicationEventPublisher, + @Value("\${cloud.naver.papago-client-id}") private val papagoClientId: String, @@ -102,6 +107,13 @@ class LanguageDetectListener( character.languageCode = langCode chatCharacterRepository.save(character) + applicationEventPublisher.publishEvent( + LanguageTranslationEvent( + id = characterId, + targetType = LanguageTranslationTargetType.CHARACTER + ) + ) + log.info( "[PapagoLanguageDetect] languageCode updated from Papago. characterId={}, langCode={}", characterId, @@ -135,6 +147,13 @@ class LanguageDetectListener( // REQUIRES_NEW 트랜잭션 내에서 변경 내용을 저장한다. audioContentRepository.save(audioContent) + applicationEventPublisher.publishEvent( + LanguageTranslationEvent( + id = contentId, + targetType = LanguageTranslationTargetType.CONTENT + ) + ) + log.info( "[PapagoLanguageDetect] languageCode updated from Papago. contentId={}, langCode={}", contentId, diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/content/translation/ContentTranslation.kt b/src/main/kotlin/kr/co/vividnext/sodalive/content/translation/ContentTranslation.kt index 66f9579..75d7a6b 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/content/translation/ContentTranslation.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/content/translation/ContentTranslation.kt @@ -23,7 +23,7 @@ class ContentTranslation( @Column(columnDefinition = "json") @Convert(converter = ContentTranslationPayloadConverter::class) - val renderedPayload: ContentTranslationPayload + var renderedPayload: ContentTranslationPayload ) : BaseEntity() data class ContentTranslationPayload( diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/i18n/translation/LanguageTranslationEvent.kt b/src/main/kotlin/kr/co/vividnext/sodalive/i18n/translation/LanguageTranslationEvent.kt new file mode 100644 index 0000000..f0ba1cf --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/i18n/translation/LanguageTranslationEvent.kt @@ -0,0 +1,205 @@ +package kr.co.vividnext.sodalive.i18n.translation + +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.content.AudioContentRepository +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.data.repository.findByIdOrNull +import org.springframework.scheduling.annotation.Async +import org.springframework.stereotype.Component +import org.springframework.transaction.annotation.Propagation +import org.springframework.transaction.annotation.Transactional +import org.springframework.transaction.event.TransactionPhase +import org.springframework.transaction.event.TransactionalEventListener + +enum class LanguageTranslationTargetType { + CONTENT, + CHARACTER +} + +class LanguageTranslationEvent( + val id: Long, + val targetType: LanguageTranslationTargetType +) + +@Component +class LanguageTranslationListener( + private val audioContentRepository: AudioContentRepository, + private val chatCharacterRepository: ChatCharacterRepository, + + private val contentTranslationRepository: ContentTranslationRepository, + private val aiCharacterTranslationRepository: AiCharacterTranslationRepository, + + private val translationService: PapagoTranslationService +) { + @Async + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) + @Transactional(propagation = Propagation.REQUIRES_NEW) + fun translation(event: LanguageTranslationEvent) { + when (event.targetType) { + LanguageTranslationTargetType.CONTENT -> handleContentLanguageTranslation(event) + LanguageTranslationTargetType.CHARACTER -> handleCharacterLanguageTranslation(event) + } + } + + private fun handleContentLanguageTranslation(event: LanguageTranslationEvent) { + val audioContent = audioContentRepository.findByIdOrNull(event.id) ?: return + val languageCode = audioContent.languageCode + if (languageCode != null) return + + getTranslatableLanguageCodes(languageCode).forEach { locale -> + val tags = audioContent.audioContentHashTags + .mapNotNull { it.hashTag?.tag } + .joinToString(",") + + val texts = mutableListOf() + 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 + if (languageCode != null) 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() + 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) + } + } + } + } +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/i18n/translation/PapagoTranslationService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/i18n/translation/PapagoTranslationService.kt index 878bc51..29a76b5 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/i18n/translation/PapagoTranslationService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/i18n/translation/PapagoTranslationService.kt @@ -80,5 +80,16 @@ class PapagoTranslationService( "en", "ja" ) + + /** + * 번역 대상 언어 코드 집합을 반환한다. + * + * @param excludedLanguageCode 번역 대상에서 제외할 언어 코드(대소문자 무시) + * @return 지원되는 언어 코드 중 [excludedLanguageCode]를 제외한 집합 + */ + fun getTranslatableLanguageCodes(excludedLanguageCode: String?): Set { + val normalized = excludedLanguageCode?.lowercase() + return SUPPORTED_LANGUAGE_CODES.filterNot { it == normalized }.toSet() + } } }