diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/admin/chat/original/service/AdminOriginalWorkService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/admin/chat/original/service/AdminOriginalWorkService.kt index e02e632..22b67b6 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/admin/chat/original/service/AdminOriginalWorkService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/admin/chat/original/service/AdminOriginalWorkService.kt @@ -10,6 +10,11 @@ import kr.co.vividnext.sodalive.chat.original.OriginalWorkTag import kr.co.vividnext.sodalive.chat.original.OriginalWorkTagMapping import kr.co.vividnext.sodalive.chat.original.repository.OriginalWorkTagRepository 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 org.springframework.context.ApplicationEventPublisher import org.springframework.data.domain.Page import org.springframework.data.domain.PageRequest import org.springframework.data.domain.Sort @@ -24,7 +29,9 @@ import org.springframework.transaction.annotation.Transactional class AdminOriginalWorkService( private val originalWorkRepository: OriginalWorkRepository, private val chatCharacterRepository: ChatCharacterRepository, - private val originalWorkTagRepository: OriginalWorkTagRepository + private val originalWorkTagRepository: OriginalWorkTagRepository, + + private val applicationEventPublisher: ApplicationEventPublisher ) { /** 원작 등록 (중복 제목 방지 포함) */ @@ -56,7 +63,44 @@ class AdminOriginalWorkService( entity.tagMappings.add(OriginalWorkTagMapping(originalWork = entity, tag = tagEntity)) } } - return originalWorkRepository.save(entity) + + val originalWork = originalWorkRepository.save(entity) + + /** + * 저장이 완료된 후 + * originalWork의 + * + * languageCode == null이면 언어 감지 이벤트 호출 + * languageCode != null이면 번역 이벤트 호출 + * + */ + if (originalWork.languageCode == null) { + val papagoQuery = listOf( + originalWork.title, + originalWork.contentType, + originalWork.category, + originalWork.description + ) + .filter { it.isNotBlank() } + .joinToString(" ") + + applicationEventPublisher.publishEvent( + LanguageDetectEvent( + id = originalWork.id!!, + query = papagoQuery, + targetType = LanguageDetectTargetType.ORIGINAL_WORK + ) + ) + } else { + applicationEventPublisher.publishEvent( + LanguageTranslationEvent( + id = originalWork.id!!, + targetType = LanguageTranslationTargetType.ORIGINAL_WORK + ) + ) + } + + return originalWork } /** 원작 수정 (이미지 경로 포함 선택적 변경) */ @@ -107,6 +151,25 @@ class AdminOriginalWorkService( if (imagePath != null) { ow.imagePath = imagePath } + + /** + * 번역 이벤트 호출 + */ + if ( + request.title != null || + request.contentType != null || + request.category != null || + request.description != null || + request.tags != null + ) { + applicationEventPublisher.publishEvent( + LanguageTranslationEvent( + id = ow.id!!, + targetType = LanguageTranslationTargetType.ORIGINAL_WORK + ) + ) + } + return originalWorkRepository.save(ow) } diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/chat/original/OriginalWork.kt b/src/main/kotlin/kr/co/vividnext/sodalive/chat/original/OriginalWork.kt index 543c635..ceb19d2 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/chat/original/OriginalWork.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/chat/original/OriginalWork.kt @@ -33,6 +33,10 @@ class OriginalWork( @Column(columnDefinition = "TEXT") var description: String = "", + /** 언어 코드 */ + @Column(nullable = true) + var languageCode: String? = null, + /** 원천 원작 */ @Column(nullable = true) var originalWork: String? = null, diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/chat/original/translation/OriginalWorkTranslation.kt b/src/main/kotlin/kr/co/vividnext/sodalive/chat/original/translation/OriginalWorkTranslation.kt new file mode 100644 index 0000000..bdc8533 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/chat/original/translation/OriginalWorkTranslation.kt @@ -0,0 +1,94 @@ +package kr.co.vividnext.sodalive.chat.original.translation + +import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper +import kr.co.vividnext.sodalive.common.BaseEntity +import javax.persistence.AttributeConverter +import javax.persistence.Column +import javax.persistence.Convert +import javax.persistence.Converter +import javax.persistence.Entity +import javax.persistence.Table +import javax.persistence.UniqueConstraint + +@Entity +@Table( + uniqueConstraints = [ + UniqueConstraint(columnNames = ["original_work_id", "locale"]) + ] +) +class OriginalWorkTranslation( + @Column(name = "original_work_id") + val originalWorkId: Long, + @Column(name = "locale") + val locale: String, + + @Column(columnDefinition = "json") + @Convert(converter = OriginalWorkTranslationPayloadConverter::class) + var renderedPayload: OriginalWorkTranslationPayload +) : BaseEntity() + +data class OriginalWorkTranslationPayload( + val title: String, + val contentType: String, + val category: String, + val description: String, + val tags: List +) + +@Converter(autoApply = false) +class OriginalWorkTranslationPayloadConverter : AttributeConverter { + + override fun convertToDatabaseColumn(attribute: OriginalWorkTranslationPayload?): String { + if (attribute == null) return "{}" + return objectMapper.writeValueAsString(attribute) + } + + override fun convertToEntityAttribute(dbData: String?): OriginalWorkTranslationPayload { + if (dbData.isNullOrBlank()) { + return OriginalWorkTranslationPayload( + title = "", + contentType = "", + category = "", + description = "", + tags = emptyList() + ) + } + return try { + val node = objectMapper.readTree(dbData) + val title = node.get("title")?.asText() ?: "" + val contentType = node.get("contentType")?.asText() ?: "" + val category = node.get("category")?.asText() ?: "" + val description = node.get("description")?.asText() ?: "" + val tagsNode = node.get("tags") + val tags: List = when { + tagsNode == null || tagsNode.isNull -> emptyList() + tagsNode.isArray -> tagsNode.mapNotNull { it.asText(null) }.filter { it.isNotBlank() } + tagsNode.isTextual -> tagsNode.asText() + .split(',') + .map { it.trim() } + .filter { it.isNotEmpty() } + + else -> emptyList() + } + OriginalWorkTranslationPayload( + title = title, + contentType = contentType, + category = category, + description = description, + tags = tags + ) + } catch (_: Exception) { + OriginalWorkTranslationPayload( + title = "", + contentType = "", + category = "", + description = "", + tags = emptyList() + ) + } + } + + companion object { + private val objectMapper = jacksonObjectMapper() + } +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/chat/original/translation/OriginalWorkTranslationRepository.kt b/src/main/kotlin/kr/co/vividnext/sodalive/chat/original/translation/OriginalWorkTranslationRepository.kt new file mode 100644 index 0000000..e63fbeb --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/chat/original/translation/OriginalWorkTranslationRepository.kt @@ -0,0 +1,7 @@ +package kr.co.vividnext.sodalive.chat.original.translation + +import org.springframework.data.jpa.repository.JpaRepository + +interface OriginalWorkTranslationRepository : JpaRepository { + fun findByOriginalWorkIdAndLocale(originalWorkId: Long, locale: String): OriginalWorkTranslation? +} 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 b111de7..9a95002 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/content/LanguageDetectEvent.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/content/LanguageDetectEvent.kt @@ -2,6 +2,7 @@ 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.chat.original.OriginalWorkRepository import kr.co.vividnext.sodalive.content.comment.AudioContentCommentRepository import kr.co.vividnext.sodalive.content.series.ContentSeriesRepository import kr.co.vividnext.sodalive.explorer.profile.CreatorCheersRepository @@ -32,7 +33,8 @@ enum class LanguageDetectTargetType { CHARACTER, CHARACTER_COMMENT, CREATOR_CHEERS, - SERIES + SERIES, + ORIGINAL_WORK } class LanguageDetectEvent( @@ -53,6 +55,7 @@ class LanguageDetectListener( private val characterCommentRepository: CharacterCommentRepository, private val creatorCheersRepository: CreatorCheersRepository, private val seriesRepository: ContentSeriesRepository, + private val originalWorkRepository: OriginalWorkRepository, private val applicationEventPublisher: ApplicationEventPublisher, @@ -85,6 +88,7 @@ class LanguageDetectListener( LanguageDetectTargetType.CHARACTER_COMMENT -> handleCharacterCommentLanguageDetect(event) LanguageDetectTargetType.CREATOR_CHEERS -> handleCreatorCheersLanguageDetect(event) LanguageDetectTargetType.SERIES -> handleSeriesLanguageDetect(event) + LanguageDetectTargetType.ORIGINAL_WORK -> handleOriginalWorkLanguageDetect(event) } } @@ -298,6 +302,45 @@ class LanguageDetectListener( ) } + private fun handleOriginalWorkLanguageDetect(event: LanguageDetectEvent) { + val originalWorkId = event.id + + val originalWork = originalWorkRepository.findByIdOrNull(originalWorkId) + if (originalWork == null) { + log.warn("[PapagoLanguageDetect] OriginalWork not found. originalWorkId={}", originalWorkId) + return + } + + // 이미 언어 코드가 설정된 경우 호출하지 않음 + if (!originalWork.languageCode.isNullOrBlank()) { + log.debug( + "[PapagoLanguageDetect] languageCode already set. Skip language detection. originalWorkId={}, languageCode={}", + originalWorkId, + originalWork.languageCode + ) + return + } + + val langCode = requestPapagoLanguageCode(event.query, originalWorkId) ?: return + + originalWork.languageCode = langCode + originalWorkRepository.save(originalWork) + + // 언어 감지가 완료된 후 언어 번역 이벤트 호출 + applicationEventPublisher.publishEvent( + LanguageTranslationEvent( + id = originalWorkId, + targetType = LanguageTranslationTargetType.ORIGINAL_WORK + ) + ) + + log.info( + "[PapagoLanguageDetect] languageCode updated from Papago. originalWorkId={}, langCode={}", + originalWorkId, + 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/i18n/translation/LanguageTranslationEvent.kt b/src/main/kotlin/kr/co/vividnext/sodalive/i18n/translation/LanguageTranslationEvent.kt index 67cdb64..30796a0 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 @@ -8,6 +8,10 @@ import kr.co.vividnext.sodalive.chat.character.translate.AiCharacterTranslationR 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.series.translation.SeriesGenreTranslation import kr.co.vividnext.sodalive.content.series.translation.SeriesGenreTranslationRepository @@ -35,7 +39,9 @@ enum class LanguageTranslationTargetType { CONTENT_THEME, SERIES, - SERIES_GENRE + SERIES_GENRE, + + ORIGINAL_WORK } class LanguageTranslationEvent( @@ -50,12 +56,14 @@ class LanguageTranslationListener( 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 translationService: PapagoTranslationService ) { @@ -69,6 +77,7 @@ class LanguageTranslationListener( LanguageTranslationTargetType.CONTENT_THEME -> handleContentThemeLanguageTranslation(event) LanguageTranslationTargetType.SERIES -> handleSeriesLanguageTranslation(event) LanguageTranslationTargetType.SERIES_GENRE -> handleSeriesGenreLanguageTranslation(event) + LanguageTranslationTargetType.ORIGINAL_WORK -> handleOriginalWorkLanguageTranslation(event) } } @@ -364,4 +373,84 @@ class LanguageTranslationListener( } } } + + private fun handleOriginalWorkLanguageTranslation(event: LanguageTranslationEvent) { + val originalWork = originalWorkRepository.findByIdOrNull(event.id) ?: return + val languageCode = originalWork.languageCode + if (languageCode != null) return + + /** + * handleSeriesLanguageTranslation 참조하여 원작 번역 구현 + * + * originalWorkTranslationRepository + * + * 번역대상 + * - title + * - contentType + * - category + * - description + * - tags + */ + getTranslatableLanguageCodes(languageCode).forEach { locale -> + val tagsJoined = originalWork.tagMappings + .mapNotNull { it.tag.tag } + .joinToString(", ") + + val texts = mutableListOf() + 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) + } + } + } + } }