diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/chat/original/controller/OriginalWorkController.kt b/src/main/kotlin/kr/co/vividnext/sodalive/chat/original/controller/OriginalWorkController.kt index 6efc678..0cfbdef 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/chat/original/controller/OriginalWorkController.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/chat/original/controller/OriginalWorkController.kt @@ -3,10 +3,12 @@ package kr.co.vividnext.sodalive.chat.original.controller import kr.co.vividnext.sodalive.chat.character.ChatCharacter import kr.co.vividnext.sodalive.chat.character.dto.Character import kr.co.vividnext.sodalive.chat.character.image.CharacterImageRepository +import kr.co.vividnext.sodalive.chat.character.translate.AiCharacterTranslationRepository import kr.co.vividnext.sodalive.chat.original.dto.OriginalWorkDetailResponse import kr.co.vividnext.sodalive.chat.original.dto.OriginalWorkListItemResponse import kr.co.vividnext.sodalive.chat.original.dto.OriginalWorkListResponse import kr.co.vividnext.sodalive.chat.original.service.OriginalWorkQueryService +import kr.co.vividnext.sodalive.chat.original.service.OriginalWorkTranslationService import kr.co.vividnext.sodalive.chat.original.translation.OriginalWorkTranslationRepository import kr.co.vividnext.sodalive.common.ApiResponse import kr.co.vividnext.sodalive.common.SodaException @@ -34,7 +36,9 @@ class OriginalWorkController( private val langContext: LangContext, + private val originalWorkTranslationService: OriginalWorkTranslationService, private val originalWorkTranslationRepository: OriginalWorkTranslationRepository, + private val aiCharacterTranslationRepository: AiCharacterTranslationRepository, @Value("\${cloud.aws.cloud-front.host}") private val imageHost: String @@ -139,20 +143,56 @@ class OriginalWorkController( emptySet() } - ApiResponse.ok( - OriginalWorkDetailResponse.from( - ow, - imageHost, + val translatedOriginal = originalWorkTranslationService.ensureTranslated( + originalWork = ow, + targetLocale = langContext.lang.code + ) + + /** + * 캐릭터 리스트의 캐릭터 이름(name)과 캐릭터 한 줄 소개(description)를 현재 언어(locale)에 맞춰 일괄 번역한다. + * + * 처리 절차: + * - 입력된 콘텐츠들의 characterId 집합을 만들고, 요청 언어 코드(langContext.lang.code)로 + * AiCharacterTranslationRepository에서 번역 데이터를 한 번에 조회한다. + * - 각 항목에 대해 번역된 캐릭터 이름(name)과 캐릭터 한 줄 소개(description)가 존재하고 비어있지 않으면 번역 값으로 교체한다. + * - 번역이 없거나 공백이면 원본 항목을 그대로 반환한다. + * + * 성능: + * - N건의 항목을 1회의 조회로 해결하기 위해 IN 쿼리를 사용한다. + */ + val translatedCharacters = run { + if (chars.isEmpty()) { + emptyList() + } else { + val ids = chars.mapNotNull { it.id } + val translations = aiCharacterTranslationRepository + .findByCharacterIdInAndLocale(ids, langContext.lang.code) + .associateBy { it.characterId } + chars.map { val path = it.imagePath ?: "profile/default-profile.png" + val tr = translations[it.id!!]?.renderedPayload + val newName = tr?.name?.trim().orEmpty() + val newDesc = tr?.description?.trim().orEmpty() + val hasName = newName.isNotEmpty() + val hasDesc = newDesc.isNotEmpty() Character( characterId = it.id!!, - name = it.name, - description = it.description, + name = if (hasName) newName else it.name, + description = if (hasDesc) newDesc else it.description, imageUrl = "$imageHost/$path", new = recentSet.contains(it.id) ) } + } + } + + ApiResponse.ok( + OriginalWorkDetailResponse.from( + ow, + imageHost, + translatedCharacters, + translated = translatedOriginal ) ) } diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/chat/original/dto/OriginalWorkAppDtos.kt b/src/main/kotlin/kr/co/vividnext/sodalive/chat/original/dto/OriginalWorkAppDtos.kt index d1a652d..4b4a29f 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/chat/original/dto/OriginalWorkAppDtos.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/chat/original/dto/OriginalWorkAppDtos.kt @@ -3,6 +3,7 @@ package kr.co.vividnext.sodalive.chat.original.dto import com.fasterxml.jackson.annotation.JsonProperty import kr.co.vividnext.sodalive.chat.character.dto.Character import kr.co.vividnext.sodalive.chat.original.OriginalWork +import kr.co.vividnext.sodalive.chat.original.translation.TranslatedOriginalWork /** * 앱용 원작 목록 아이템 응답 DTO @@ -54,13 +55,15 @@ data class OriginalWorkDetailResponse( @JsonProperty("studio") val studio: String?, @JsonProperty("originalLinks") val originalLinks: List, @JsonProperty("tags") val tags: List, - @JsonProperty("characters") val characters: List + @JsonProperty("characters") val characters: List, + @JsonProperty("translated") val translated: TranslatedOriginalWork? ) { companion object { fun from( entity: OriginalWork, imageHost: String = "", - characters: List + characters: List, + translated: TranslatedOriginalWork? ): OriginalWorkDetailResponse { val fullImage = if (entity.imagePath != null && imageHost.isNotEmpty()) { "$imageHost/${entity.imagePath}" @@ -80,7 +83,8 @@ data class OriginalWorkDetailResponse( studio = entity.studio, originalLinks = entity.originalLinks.map { it.url }, tags = entity.tagMappings.map { it.tag.tag }, - characters = characters + characters = characters, + translated = translated ) } } diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/chat/original/service/OriginalWorkTranslationService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/chat/original/service/OriginalWorkTranslationService.kt new file mode 100644 index 0000000..3374997 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/chat/original/service/OriginalWorkTranslationService.kt @@ -0,0 +1,124 @@ +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 org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional + +@Service +class OriginalWorkTranslationService( + private val translationRepository: OriginalWorkTranslationRepository, + private val papagoTranslationService: PapagoTranslationService +) { + + private val log = LoggerFactory.getLogger(javaClass) + + /** + * 원작의 언어와 요청 언어가 다를 때 번역 데이터를 확보하고 반환한다. + * - 기존 번역이 있으면 그대로 사용 + * - 없으면 파파고 번역 수행 후 저장 + * - 실패/불필요 시 null 반환 + */ + @Transactional + fun ensureTranslated(originalWork: OriginalWork, targetLocale: String): TranslatedOriginalWork? { + val source = originalWork.languageCode?.lowercase() + val target = targetLocale.lowercase() + + if (source.isNullOrBlank() || source == target) { + return null + } + + // 기존 번역 조회 + val existed = translationRepository.findByOriginalWorkIdAndLocale(originalWork.id!!, target) + val existedPayload = existed?.renderedPayload + if (existedPayload != null) { + val t = existedPayload.title.trim() + val ct = existedPayload.contentType.trim() + val cat = existedPayload.category.trim() + val desc = existedPayload.description.trim() + val tags = existedPayload.tags + val hasAny = t.isNotEmpty() || ct.isNotEmpty() || cat.isNotEmpty() || desc.isNotEmpty() || tags.isNotEmpty() + if (hasAny) { + return TranslatedOriginalWork( + title = t, + contentType = ct, + category = cat, + description = desc, + tags = tags + ) + } + } + + // 파파고 번역 수행 + 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 + } + } +} 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 index bdc8533..d5d7b9c 100644 --- 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 @@ -35,6 +35,14 @@ data class OriginalWorkTranslationPayload( val tags: List ) +data class TranslatedOriginalWork( + val title: String, + val contentType: String, + val category: String, + val description: String, + val tags: List +) + @Converter(autoApply = false) class OriginalWorkTranslationPayloadConverter : AttributeConverter {