From f58687ef3afc8c5e72b002dc86699a19a4d889f5 Mon Sep 17 00:00:00 2001 From: Klaus Date: Tue, 16 Dec 2025 00:25:24 +0900 Subject: [PATCH] =?UTF-8?q?=ED=81=AC=EB=A6=AC=EC=97=90=EC=9D=B4=ED=84=B0?= =?UTF-8?q?=20=EA=B4=80=EB=A6=AC=EC=9E=90=EC=97=90=EC=84=9C=20=EC=8B=9C?= =?UTF-8?q?=EB=A6=AC=EC=A6=88=20=EB=93=B1=EB=A1=9D/=EC=88=98=EC=A0=95?= =?UTF-8?q?=EC=8B=9C=20=EB=B2=88=EC=97=AD=EB=8D=B0=EC=9D=B4=ED=84=B0=20?= =?UTF-8?q?=EC=83=9D=EC=84=B1=20=EA=B8=B0=EB=8A=A5=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../sodalive/content/AudioContentService.kt | 14 +-- .../sodalive/content/LanguageDetectEvent.kt | 45 ++++++- .../translation/SeriesGenreTranslation.kt | 12 +- .../SeriesGenreTranslationRepository.kt | 7 ++ .../SeriesTranslationRepository.kt | 7 ++ .../CreatorAdminContentSeriesService.kt | 41 ++++++ .../creator/admin/content/series/Series.kt | 1 + .../translation/LanguageTranslationEvent.kt | 118 +++++++++++++++++- 8 files changed, 235 insertions(+), 10 deletions(-) create mode 100644 src/main/kotlin/kr/co/vividnext/sodalive/content/series/translation/SeriesGenreTranslationRepository.kt create mode 100644 src/main/kotlin/kr/co/vividnext/sodalive/content/series/translation/SeriesTranslationRepository.kt 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 7bbacb6..d3116a3 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/content/AudioContentService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/content/AudioContentService.kt @@ -372,14 +372,14 @@ class AudioContentService( query = papagoQuery ) ) - } - - applicationEventPublisher.publishEvent( - LanguageTranslationEvent( - id = audioContent.id!!, - targetType = LanguageTranslationTargetType.CONTENT + } else { + applicationEventPublisher.publishEvent( + LanguageTranslationEvent( + id = audioContent.id!!, + targetType = LanguageTranslationTargetType.CONTENT + ) ) - ) + } return CreateAudioContentResponse(contentId = audioContent.id!!) } 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 fc552f6..b111de7 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/content/LanguageDetectEvent.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/content/LanguageDetectEvent.kt @@ -3,12 +3,14 @@ package kr.co.vividnext.sodalive.content import kr.co.vividnext.sodalive.chat.character.comment.CharacterCommentRepository import kr.co.vividnext.sodalive.chat.character.repository.ChatCharacterRepository import kr.co.vividnext.sodalive.content.comment.AudioContentCommentRepository +import kr.co.vividnext.sodalive.content.series.ContentSeriesRepository 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.data.repository.findByIdOrNull import org.springframework.http.HttpEntity import org.springframework.http.HttpHeaders import org.springframework.http.MediaType @@ -29,7 +31,8 @@ enum class LanguageDetectTargetType { COMMENT, CHARACTER, CHARACTER_COMMENT, - CREATOR_CHEERS + CREATOR_CHEERS, + SERIES } class LanguageDetectEvent( @@ -49,6 +52,7 @@ class LanguageDetectListener( private val chatCharacterRepository: ChatCharacterRepository, private val characterCommentRepository: CharacterCommentRepository, private val creatorCheersRepository: CreatorCheersRepository, + private val seriesRepository: ContentSeriesRepository, private val applicationEventPublisher: ApplicationEventPublisher, @@ -80,6 +84,7 @@ class LanguageDetectListener( LanguageDetectTargetType.CHARACTER -> handleCharacterLanguageDetect(event) LanguageDetectTargetType.CHARACTER_COMMENT -> handleCharacterCommentLanguageDetect(event) LanguageDetectTargetType.CREATOR_CHEERS -> handleCreatorCheersLanguageDetect(event) + LanguageDetectTargetType.SERIES -> handleSeriesLanguageDetect(event) } } @@ -255,6 +260,44 @@ class LanguageDetectListener( ) } + private fun handleSeriesLanguageDetect(event: LanguageDetectEvent) { + val seriesId = event.id + + val series = seriesRepository.findByIdOrNull(seriesId) + if (series == null) { + log.warn("[PapagoLanguageDetect] Series not found. seriesId={}", seriesId) + return + } + + // 이미 언어 코드가 설정된 경우 호출하지 않음 + if (!series.languageCode.isNullOrBlank()) { + log.debug( + "[PapagoLanguageDetect] languageCode already set. Skip language detection. seriesId={}, languageCode={}", + seriesId, + series.languageCode + ) + return + } + + val langCode = requestPapagoLanguageCode(event.query, seriesId) ?: return + + series.languageCode = langCode + seriesRepository.save(series) + + applicationEventPublisher.publishEvent( + LanguageTranslationEvent( + id = seriesId, + targetType = LanguageTranslationTargetType.SERIES + ) + ) + + log.info( + "[PapagoLanguageDetect] languageCode updated from Papago. seriesId={}, langCode={}", + seriesId, + langCode + ) + } + private fun requestPapagoLanguageCode(query: String, targetIdForLog: Long): String? { return try { val headers = HttpHeaders().apply { diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/content/series/translation/SeriesGenreTranslation.kt b/src/main/kotlin/kr/co/vividnext/sodalive/content/series/translation/SeriesGenreTranslation.kt index 9de7c1c..a7f91a0 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/content/series/translation/SeriesGenreTranslation.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/content/series/translation/SeriesGenreTranslation.kt @@ -1,11 +1,21 @@ package kr.co.vividnext.sodalive.content.series.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( + uniqueConstraints = [ + UniqueConstraint(columnNames = ["series_genre_id", "locale"]) + ] +) class SeriesGenreTranslation( + @Column(name = "series_genre_id") val seriesGenreId: Long, + @Column(name = "locale") val locale: String, - val genre: String + var genre: String ) : BaseEntity() diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/content/series/translation/SeriesGenreTranslationRepository.kt b/src/main/kotlin/kr/co/vividnext/sodalive/content/series/translation/SeriesGenreTranslationRepository.kt new file mode 100644 index 0000000..e88589b --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/content/series/translation/SeriesGenreTranslationRepository.kt @@ -0,0 +1,7 @@ +package kr.co.vividnext.sodalive.content.series.translation + +import org.springframework.data.jpa.repository.JpaRepository + +interface SeriesGenreTranslationRepository : JpaRepository { + fun findBySeriesGenreIdAndLocale(seriesGenreId: Long, locale: String): SeriesGenreTranslation? +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/content/series/translation/SeriesTranslationRepository.kt b/src/main/kotlin/kr/co/vividnext/sodalive/content/series/translation/SeriesTranslationRepository.kt new file mode 100644 index 0000000..bfb3b93 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/content/series/translation/SeriesTranslationRepository.kt @@ -0,0 +1,7 @@ +package kr.co.vividnext.sodalive.content.series.translation + +import org.springframework.data.jpa.repository.JpaRepository + +interface SeriesTranslationRepository : JpaRepository { + fun findBySeriesIdAndLocale(seriesId: Long, locale: String): SeriesTranslation? +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/creator/admin/content/series/CreatorAdminContentSeriesService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/creator/admin/content/series/CreatorAdminContentSeriesService.kt index eba9469..739aed2 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/creator/admin/content/series/CreatorAdminContentSeriesService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/creator/admin/content/series/CreatorAdminContentSeriesService.kt @@ -5,6 +5,8 @@ import com.fasterxml.jackson.databind.ObjectMapper import kr.co.vividnext.sodalive.aws.s3.S3Uploader import kr.co.vividnext.sodalive.common.SodaException import kr.co.vividnext.sodalive.content.AudioContentRepository +import kr.co.vividnext.sodalive.content.LanguageDetectEvent +import kr.co.vividnext.sodalive.content.LanguageDetectTargetType import kr.co.vividnext.sodalive.content.hashtag.HashTag import kr.co.vividnext.sodalive.content.hashtag.HashTagRepository import kr.co.vividnext.sodalive.creator.admin.content.series.content.AddingContentToTheSeriesRequest @@ -12,9 +14,12 @@ import kr.co.vividnext.sodalive.creator.admin.content.series.content.RemoveConte import kr.co.vividnext.sodalive.creator.admin.content.series.content.SearchContentNotInSeriesResponse import kr.co.vividnext.sodalive.creator.admin.content.series.genre.CreatorAdminContentSeriesGenreRepository import kr.co.vividnext.sodalive.creator.admin.content.series.keyword.SeriesKeyword +import kr.co.vividnext.sodalive.i18n.translation.LanguageTranslationEvent +import kr.co.vividnext.sodalive.i18n.translation.LanguageTranslationTargetType import kr.co.vividnext.sodalive.member.Member import kr.co.vividnext.sodalive.utils.generateFileName import org.springframework.beans.factory.annotation.Value +import org.springframework.context.ApplicationEventPublisher import org.springframework.data.repository.findByIdOrNull import org.springframework.stereotype.Service import org.springframework.transaction.annotation.Transactional @@ -30,6 +35,8 @@ class CreatorAdminContentSeriesService( private val s3Uploader: S3Uploader, private val objectMapper: ObjectMapper, + private val applicationEventPublisher: ApplicationEventPublisher, + @Value("\${cloud.aws.s3.bucket}") private val coverImageBucket: String, @@ -89,6 +96,31 @@ class CreatorAdminContentSeriesService( ) series.coverImage = coverImagePath + + if (series.languageCode.isNullOrBlank()) { + val papagoQuery = listOf( + request.title.trim(), + request.introduction.trim(), + request.keyword.trim() + ) + .filter { it.isNotBlank() } + .joinToString(" ") + + applicationEventPublisher.publishEvent( + LanguageDetectEvent( + id = series.id!!, + query = papagoQuery, + targetType = LanguageDetectTargetType.SERIES + ) + ) + } else { + applicationEventPublisher.publishEvent( + LanguageTranslationEvent( + id = series.id!!, + targetType = LanguageTranslationTargetType.SERIES + ) + ) + } } @Transactional @@ -174,6 +206,15 @@ class CreatorAdminContentSeriesService( if (request.studio != null) { series.studio = request.studio } + + if (request.title != null || request.introduction != null) { + applicationEventPublisher.publishEvent( + LanguageTranslationEvent( + id = series.id!!, + targetType = LanguageTranslationTargetType.SERIES + ) + ) + } } fun getSeriesList(offset: Long, limit: Long, creatorId: Long): GetCreatorAdminContentSeriesListResponse { diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/creator/admin/content/series/Series.kt b/src/main/kotlin/kr/co/vividnext/sodalive/creator/admin/content/series/Series.kt index 1bd336d..49b0744 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/creator/admin/content/series/Series.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/creator/admin/content/series/Series.kt @@ -34,6 +34,7 @@ data class Series( var title: String, @Column(columnDefinition = "TEXT", nullable = false) var introduction: String, + var languageCode: String? = null, @Enumerated(value = EnumType.STRING) var state: SeriesState = SeriesState.PROCEEDING, var writer: String? = null, 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 index 62db600..67cdb64 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/i18n/translation/LanguageTranslationEvent.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/i18n/translation/LanguageTranslationEvent.kt @@ -1,5 +1,7 @@ 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 @@ -7,6 +9,11 @@ import kr.co.vividnext.sodalive.chat.character.translate.AiCharacterTranslationR 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.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 @@ -25,7 +32,10 @@ import org.springframework.transaction.event.TransactionalEventListener enum class LanguageTranslationTargetType { CONTENT, CHARACTER, - CONTENT_THEME + CONTENT_THEME, + + SERIES, + SERIES_GENRE } class LanguageTranslationEvent( @@ -38,10 +48,14 @@ class LanguageTranslationListener( private val audioContentRepository: AudioContentRepository, private val chatCharacterRepository: ChatCharacterRepository, private val audioContentThemeRepository: AudioContentThemeQueryRepository, + private val seriesRepository: AdminContentSeriesRepository, + private val seriesGenreRepository: AdminContentSeriesGenreRepository, private val contentTranslationRepository: ContentTranslationRepository, private val aiCharacterTranslationRepository: AiCharacterTranslationRepository, private val contentThemeTranslationRepository: ContentThemeTranslationRepository, + private val seriesTranslationRepository: SeriesTranslationRepository, + private val seriesGenreTranslationRepository: SeriesGenreTranslationRepository, private val translationService: PapagoTranslationService ) { @@ -53,6 +67,8 @@ class LanguageTranslationListener( LanguageTranslationTargetType.CONTENT -> handleContentLanguageTranslation(event) LanguageTranslationTargetType.CHARACTER -> handleCharacterLanguageTranslation(event) LanguageTranslationTargetType.CONTENT_THEME -> handleContentThemeLanguageTranslation(event) + LanguageTranslationTargetType.SERIES -> handleSeriesLanguageTranslation(event) + LanguageTranslationTargetType.SERIES_GENRE -> handleSeriesGenreLanguageTranslation(event) } } @@ -248,4 +264,104 @@ class LanguageTranslationListener( } } } + + private fun handleSeriesLanguageTranslation(event: LanguageTranslationEvent) { + val series = seriesRepository.findByIdOrNull(event.id) ?: return + val languageCode = series.languageCode + if (languageCode != null) return + + getTranslatableLanguageCodes(languageCode).forEach { locale -> + val keywords = series.keywordList + .mapNotNull { it.keyword?.tag } + .joinToString(", ") + val texts = mutableListOf() + 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() + 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) + } + } + } + } }