Compare commits

..

8 Commits

16 changed files with 810 additions and 27 deletions

View File

@@ -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.OriginalWorkTagMapping
import kr.co.vividnext.sodalive.chat.original.repository.OriginalWorkTagRepository import kr.co.vividnext.sodalive.chat.original.repository.OriginalWorkTagRepository
import kr.co.vividnext.sodalive.common.SodaException 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.Page
import org.springframework.data.domain.PageRequest import org.springframework.data.domain.PageRequest
import org.springframework.data.domain.Sort import org.springframework.data.domain.Sort
@@ -24,7 +29,9 @@ import org.springframework.transaction.annotation.Transactional
class AdminOriginalWorkService( class AdminOriginalWorkService(
private val originalWorkRepository: OriginalWorkRepository, private val originalWorkRepository: OriginalWorkRepository,
private val chatCharacterRepository: ChatCharacterRepository, 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)) 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) { if (imagePath != null) {
ow.imagePath = imagePath 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) return originalWorkRepository.save(ow)
} }

View File

@@ -348,12 +348,14 @@ class HomeService(
val memberId = member?.id val memberId = member?.id
val isAdult = member?.auth != null && isAdultContentVisible val isAdult = member?.auth != null && isAdultContentVisible
return seriesService.getDayOfWeekSeriesList( val dayOfWeekSeriesList = seriesService.getDayOfWeekSeriesList(
memberId = memberId, memberId = memberId,
isAdult = isAdult, isAdult = isAdult,
contentType = contentType, contentType = contentType,
dayOfWeek = dayOfWeek dayOfWeek = dayOfWeek
) )
return getTranslatedSeriesList(seriesList = dayOfWeekSeriesList)
} }
fun getContentRankingBySort( fun getContentRankingBySort(

View File

@@ -33,6 +33,10 @@ class OriginalWork(
@Column(columnDefinition = "TEXT") @Column(columnDefinition = "TEXT")
var description: String = "", var description: String = "",
/** 언어 코드 */
@Column(nullable = true)
var languageCode: String? = null,
/** 원천 원작 */ /** 원천 원작 */
@Column(nullable = true) @Column(nullable = true)
var originalWork: String? = null, var originalWork: String? = null,

View File

@@ -3,12 +3,16 @@ package kr.co.vividnext.sodalive.chat.original.controller
import kr.co.vividnext.sodalive.chat.character.ChatCharacter import kr.co.vividnext.sodalive.chat.character.ChatCharacter
import kr.co.vividnext.sodalive.chat.character.dto.Character 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.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.OriginalWorkDetailResponse
import kr.co.vividnext.sodalive.chat.original.dto.OriginalWorkListItemResponse 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.dto.OriginalWorkListResponse
import kr.co.vividnext.sodalive.chat.original.service.OriginalWorkQueryService 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.ApiResponse
import kr.co.vividnext.sodalive.common.SodaException import kr.co.vividnext.sodalive.common.SodaException
import kr.co.vividnext.sodalive.i18n.LangContext
import kr.co.vividnext.sodalive.member.Member import kr.co.vividnext.sodalive.member.Member
import org.springframework.beans.factory.annotation.Value import org.springframework.beans.factory.annotation.Value
import org.springframework.security.core.annotation.AuthenticationPrincipal import org.springframework.security.core.annotation.AuthenticationPrincipal
@@ -30,6 +34,12 @@ class OriginalWorkController(
private val queryService: OriginalWorkQueryService, private val queryService: OriginalWorkQueryService,
private val characterImageRepository: CharacterImageRepository, private val characterImageRepository: CharacterImageRepository,
private val langContext: LangContext,
private val originalWorkTranslationService: OriginalWorkTranslationService,
private val originalWorkTranslationRepository: OriginalWorkTranslationRepository,
private val aiCharacterTranslationRepository: AiCharacterTranslationRepository,
@Value("\${cloud.aws.cloud-front.host}") @Value("\${cloud.aws.cloud-front.host}")
private val imageHost: String private val imageHost: String
) { ) {
@@ -51,7 +61,57 @@ class OriginalWorkController(
val includeAdult = member?.auth != null val includeAdult = member?.auth != null
val pageRes = queryService.listForAppPage(includeAdult, page, size) val pageRes = queryService.listForAppPage(includeAdult, page, size)
val content = pageRes.content.map { OriginalWorkListItemResponse.from(it, imageHost) } val content = pageRes.content.map { OriginalWorkListItemResponse.from(it, imageHost) }
ApiResponse.ok(OriginalWorkListResponse(totalCount = pageRes.totalElements, content = content))
/**
* 원작 목록의 제목과 콘텐츠 타입을 현재 언어(locale)에 맞춰 일괄 번역한다.
*
* 처리 절차:
* - 입력된 원작들의 originalWorkId 집합을 만들고, 요청 언어 코드(langContext.lang.code)로
* originalWorkTranslationRepository 번역 데이터를 한 번에 조회한다.
* - 각 항목에 대해 번역된 제목과 콘텐츠 타입이 존재하고 비어있지 않으면 title과 contentType을 번역 값으로 교체한다.
* - 번역이 없거나 공백이면 원본 항목을 그대로 반환한다.
*
* 성능:
* - N건의 항목을 1회의 조회로 해결하기 위해 IN 쿼리를 사용한다.
*/
val translatedContent = run {
if (content.isEmpty()) {
content
} else {
val ids = content.map { it.id }.toSet()
val locale = langContext.lang.code
val translations = originalWorkTranslationRepository
.findByOriginalWorkIdInAndLocale(ids, locale)
.associateBy { it.originalWorkId }
content.map { item ->
val payload = translations[item.id]?.renderedPayload
if (payload != null) {
val newTitle = payload.title.trim()
val newContentType = payload.contentType.trim()
val hasTitle = newTitle.isNotEmpty()
val hasContentType = newContentType.isNotEmpty()
if (hasTitle || hasContentType) {
item.copy(
title = if (hasTitle) newTitle else item.title,
contentType = if (hasContentType) newContentType else item.contentType
)
} else {
item
}
} else {
item
}
}
}
}
ApiResponse.ok(
OriginalWorkListResponse(
totalCount = pageRes.totalElements,
content = translatedContent
)
)
} }
/** /**
@@ -83,20 +143,56 @@ class OriginalWorkController(
emptySet() emptySet()
} }
ApiResponse.ok( val translatedOriginal = originalWorkTranslationService.ensureTranslated(
OriginalWorkDetailResponse.from( originalWork = ow,
ow, targetLocale = langContext.lang.code
imageHost, )
/**
* 캐릭터 리스트의 캐릭터 이름(name)과 캐릭터 한 줄 소개(description)를 현재 언어(locale)에 맞춰 일괄 번역한다.
*
* 처리 절차:
* - 입력된 콘텐츠들의 characterId 집합을 만들고, 요청 언어 코드(langContext.lang.code)로
* AiCharacterTranslationRepository에서 번역 데이터를 한 번에 조회한다.
* - 각 항목에 대해 번역된 캐릭터 이름(name)과 캐릭터 한 줄 소개(description)가 존재하고 비어있지 않으면 번역 값으로 교체한다.
* - 번역이 없거나 공백이면 원본 항목을 그대로 반환한다.
*
* 성능:
* - N건의 항목을 1회의 조회로 해결하기 위해 IN 쿼리를 사용한다.
*/
val translatedCharacters = run {
if (chars.isEmpty()) {
emptyList<Character>()
} else {
val ids = chars.mapNotNull { it.id }
val translations = aiCharacterTranslationRepository
.findByCharacterIdInAndLocale(ids, langContext.lang.code)
.associateBy { it.characterId }
chars.map<ChatCharacter, Character> { chars.map<ChatCharacter, Character> {
val path = it.imagePath ?: "profile/default-profile.png" 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( Character(
characterId = it.id!!, characterId = it.id!!,
name = it.name, name = if (hasName) newName else it.name,
description = it.description, description = if (hasDesc) newDesc else it.description,
imageUrl = "$imageHost/$path", imageUrl = "$imageHost/$path",
new = recentSet.contains(it.id) new = recentSet.contains(it.id)
) )
} }
}
}
ApiResponse.ok(
OriginalWorkDetailResponse.from(
ow,
imageHost,
translatedCharacters,
translated = translatedOriginal
) )
) )
} }

View File

@@ -3,6 +3,7 @@ package kr.co.vividnext.sodalive.chat.original.dto
import com.fasterxml.jackson.annotation.JsonProperty import com.fasterxml.jackson.annotation.JsonProperty
import kr.co.vividnext.sodalive.chat.character.dto.Character import kr.co.vividnext.sodalive.chat.character.dto.Character
import kr.co.vividnext.sodalive.chat.original.OriginalWork import kr.co.vividnext.sodalive.chat.original.OriginalWork
import kr.co.vividnext.sodalive.chat.original.translation.TranslatedOriginalWork
/** /**
* 앱용 원작 목록 아이템 응답 DTO * 앱용 원작 목록 아이템 응답 DTO
@@ -54,13 +55,15 @@ data class OriginalWorkDetailResponse(
@JsonProperty("studio") val studio: String?, @JsonProperty("studio") val studio: String?,
@JsonProperty("originalLinks") val originalLinks: List<String>, @JsonProperty("originalLinks") val originalLinks: List<String>,
@JsonProperty("tags") val tags: List<String>, @JsonProperty("tags") val tags: List<String>,
@JsonProperty("characters") val characters: List<Character> @JsonProperty("characters") val characters: List<Character>,
@JsonProperty("translated") val translated: TranslatedOriginalWork?
) { ) {
companion object { companion object {
fun from( fun from(
entity: OriginalWork, entity: OriginalWork,
imageHost: String = "", imageHost: String = "",
characters: List<Character> characters: List<Character>,
translated: TranslatedOriginalWork?
): OriginalWorkDetailResponse { ): OriginalWorkDetailResponse {
val fullImage = if (entity.imagePath != null && imageHost.isNotEmpty()) { val fullImage = if (entity.imagePath != null && imageHost.isNotEmpty()) {
"$imageHost/${entity.imagePath}" "$imageHost/${entity.imagePath}"
@@ -80,7 +83,8 @@ data class OriginalWorkDetailResponse(
studio = entity.studio, studio = entity.studio,
originalLinks = entity.originalLinks.map { it.url }, originalLinks = entity.originalLinks.map { it.url },
tags = entity.tagMappings.map { it.tag.tag }, tags = entity.tagMappings.map { it.tag.tag },
characters = characters characters = characters,
translated = translated
) )
} }
} }

View File

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

View File

@@ -0,0 +1,102 @@
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<String>
)
data class TranslatedOriginalWork(
val title: String,
val contentType: String,
val category: String,
val description: String,
val tags: List<String>
)
@Converter(autoApply = false)
class OriginalWorkTranslationPayloadConverter : AttributeConverter<OriginalWorkTranslationPayload, String> {
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<String> = 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()
}
}

View File

@@ -0,0 +1,9 @@
package kr.co.vividnext.sodalive.chat.original.translation
import org.springframework.data.jpa.repository.JpaRepository
interface OriginalWorkTranslationRepository : JpaRepository<OriginalWorkTranslation, Long> {
fun findByOriginalWorkIdAndLocale(originalWorkId: Long, locale: String): OriginalWorkTranslation?
fun findByOriginalWorkIdInAndLocale(originalWorkIds: Set<Long>, locale: String): List<OriginalWorkTranslation>
}

View File

@@ -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.comment.CharacterCommentRepository
import kr.co.vividnext.sodalive.chat.character.repository.ChatCharacterRepository 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.comment.AudioContentCommentRepository
import kr.co.vividnext.sodalive.content.series.ContentSeriesRepository import kr.co.vividnext.sodalive.content.series.ContentSeriesRepository
import kr.co.vividnext.sodalive.explorer.profile.CreatorCheersRepository import kr.co.vividnext.sodalive.explorer.profile.CreatorCheersRepository
@@ -32,7 +33,8 @@ enum class LanguageDetectTargetType {
CHARACTER, CHARACTER,
CHARACTER_COMMENT, CHARACTER_COMMENT,
CREATOR_CHEERS, CREATOR_CHEERS,
SERIES SERIES,
ORIGINAL_WORK
} }
class LanguageDetectEvent( class LanguageDetectEvent(
@@ -53,6 +55,7 @@ class LanguageDetectListener(
private val characterCommentRepository: CharacterCommentRepository, private val characterCommentRepository: CharacterCommentRepository,
private val creatorCheersRepository: CreatorCheersRepository, private val creatorCheersRepository: CreatorCheersRepository,
private val seriesRepository: ContentSeriesRepository, private val seriesRepository: ContentSeriesRepository,
private val originalWorkRepository: OriginalWorkRepository,
private val applicationEventPublisher: ApplicationEventPublisher, private val applicationEventPublisher: ApplicationEventPublisher,
@@ -85,6 +88,7 @@ class LanguageDetectListener(
LanguageDetectTargetType.CHARACTER_COMMENT -> handleCharacterCommentLanguageDetect(event) LanguageDetectTargetType.CHARACTER_COMMENT -> handleCharacterCommentLanguageDetect(event)
LanguageDetectTargetType.CREATOR_CHEERS -> handleCreatorCheersLanguageDetect(event) LanguageDetectTargetType.CREATOR_CHEERS -> handleCreatorCheersLanguageDetect(event)
LanguageDetectTargetType.SERIES -> handleSeriesLanguageDetect(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? { private fun requestPapagoLanguageCode(query: String, targetIdForLog: Long): String? {
return try { return try {
val headers = HttpHeaders().apply { val headers = HttpHeaders().apply {

View File

@@ -6,7 +6,12 @@ import kr.co.vividnext.sodalive.content.order.OrderRepository
import kr.co.vividnext.sodalive.content.order.OrderType 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.ContentSeriesContentRepository
import kr.co.vividnext.sodalive.content.series.content.GetSeriesContentListResponse 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.SeriesTranslationRepository
import kr.co.vividnext.sodalive.content.series.translation.TranslatedSeries
import kr.co.vividnext.sodalive.content.translation.ContentTranslationRepository
import kr.co.vividnext.sodalive.creator.admin.content.series.Series import kr.co.vividnext.sodalive.creator.admin.content.series.Series
import kr.co.vividnext.sodalive.creator.admin.content.series.SeriesPublishedDaysOfWeek import kr.co.vividnext.sodalive.creator.admin.content.series.SeriesPublishedDaysOfWeek
import kr.co.vividnext.sodalive.creator.admin.content.series.SeriesSortType import kr.co.vividnext.sodalive.creator.admin.content.series.SeriesSortType
@@ -14,10 +19,13 @@ import kr.co.vividnext.sodalive.creator.admin.content.series.SeriesState
import kr.co.vividnext.sodalive.explorer.ExplorerQueryRepository import kr.co.vividnext.sodalive.explorer.ExplorerQueryRepository
import kr.co.vividnext.sodalive.i18n.Lang import kr.co.vividnext.sodalive.i18n.Lang
import kr.co.vividnext.sodalive.i18n.LangContext 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.member.Member import kr.co.vividnext.sodalive.member.Member
import kr.co.vividnext.sodalive.member.block.BlockMemberRepository import kr.co.vividnext.sodalive.member.block.BlockMemberRepository
import org.springframework.beans.factory.annotation.Value import org.springframework.beans.factory.annotation.Value
import org.springframework.stereotype.Service import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional
import java.time.LocalDateTime import java.time.LocalDateTime
import java.time.ZoneId import java.time.ZoneId
import java.time.format.DateTimeFormatter import java.time.format.DateTimeFormatter
@@ -29,8 +37,13 @@ class ContentSeriesService(
private val blockMemberRepository: BlockMemberRepository, private val blockMemberRepository: BlockMemberRepository,
private val explorerQueryRepository: ExplorerQueryRepository, private val explorerQueryRepository: ExplorerQueryRepository,
private val seriesContentRepository: ContentSeriesContentRepository, private val seriesContentRepository: ContentSeriesContentRepository,
private val langContext: LangContext, private val langContext: LangContext,
private val seriesTranslationRepository: SeriesTranslationRepository, private val seriesTranslationRepository: SeriesTranslationRepository,
private val seriesGenreTranslationRepository: SeriesGenreTranslationRepository,
private val contentTranslationRepository: ContentTranslationRepository,
private val translationService: PapagoTranslationService,
@Value("\${cloud.aws.cloud-front.host}") @Value("\${cloud.aws.cloud-front.host}")
private val coverImageHost: String private val coverImageHost: String
@@ -47,11 +60,77 @@ class ContentSeriesService(
limit: Long = 20 limit: Long = 20
): List<GetSeriesListResponse.SeriesListItem> { ): List<GetSeriesListResponse.SeriesListItem> {
val originalAudioDramaList = repository.getOriginalAudioDramaList(isAdult, contentType, orderByRandom, offset, limit) val originalAudioDramaList = repository.getOriginalAudioDramaList(isAdult, contentType, orderByRandom, offset, limit)
return seriesToSeriesListItem(originalAudioDramaList, isAdult, contentType) return getTranslatedSeriesList(seriesToSeriesListItem(originalAudioDramaList, isAdult, contentType))
} }
fun getGenreList(memberId: Long, isAdult: Boolean, contentType: ContentType): List<GetSeriesGenreListResponse> { fun getGenreList(memberId: Long, isAdult: Boolean, contentType: ContentType): List<GetSeriesGenreListResponse> {
return repository.getGenreList(memberId = memberId, isAdult = isAdult, contentType = contentType) /**
* langContext.lang == Lang.EN || Lang.JA 일 때 번역된 콘텐츠 테마 반환
* 번역이 없으면 번역 API 호출 후 저장하고 반환
*/
val genres = repository.getGenreList(memberId = memberId, isAdult = isAdult, contentType = contentType)
val currentLang = langContext.lang
if (currentLang == Lang.EN || currentLang == Lang.JA) {
val targetLocale = currentLang.code
val ids = genres.map { it.id }
// 기존 번역 일괄 조회
val existing = if (ids.isNotEmpty()) {
// 메서드가 없을 경우 개별 조회로 대체되지만, 성능을 위해 우선 시도
try {
seriesGenreTranslationRepository
.findBySeriesGenreIdInAndLocale(ids, targetLocale)
} catch (_: Exception) {
// Spring Data 메서드 미존재 시 안전하게 개별 조회로 폴백
ids.mapNotNull { id ->
seriesGenreTranslationRepository.findBySeriesGenreIdAndLocale(id, targetLocale)
}
}
} else {
emptyList()
}
val existingMap = existing.associateBy { it.seriesGenreId }.toMutableMap()
// 미번역 항목 수집
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
)
)
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 }
}
}
// 원래 순서 보존하여 결과 조립
return genres.map { g ->
val translated = existingMap[g.id]?.genre ?: g.genre
GetSeriesGenreListResponse(id = g.id, genre = translated)
}
}
return genres
} }
fun getSeriesList( fun getSeriesList(
@@ -117,9 +196,10 @@ class ContentSeriesService(
).filter { !blockMemberRepository.isBlocked(blockedMemberId = member.id!!, memberId = it.member!!.id!!) } ).filter { !blockMemberRepository.isBlocked(blockedMemberId = member.id!!, memberId = it.member!!.id!!) }
val items = seriesToSeriesListItem(seriesList = rawItems, isAdult = isAuth, contentType = contentType) val items = seriesToSeriesListItem(seriesList = rawItems, isAdult = isAuth, contentType = contentType)
return GetSeriesListResponse(totalCount, items) return GetSeriesListResponse(totalCount, getTranslatedSeriesList(items))
} }
@Transactional
fun getSeriesDetail( fun getSeriesDetail(
seriesId: Long, seriesId: Long,
isAdultContentVisible: Boolean, isAdultContentVisible: Boolean,
@@ -161,7 +241,115 @@ class ContentSeriesService(
limit = 5 limit = 5
) )
/**
* series.languageCode != null && series.languageCode != languageCode
*
* 번역 시리즈를 조회한다. - series, locale
* 번역 콘텐츠가 있으면
* TranslatedSeries로 가공한다
*
* 번역 콘텐츠가 없으면
* 파파고 API를 통해 번역한 후 저장한다.
*
* 번역 대상: title, introduction, keywordList
*
* 파파고로 번역한 데이터를 TranslatedSeries 가공한다
*/
val dateTimeFormatter = DateTimeFormatter.ofPattern("yyyy.MM.dd") val dateTimeFormatter = DateTimeFormatter.ofPattern("yyyy.MM.dd")
// 요청된 언어(locale)에 대한 시리즈 번역을 조회하거나, 없으면 동기 번역 후 저장한다.
var translated: TranslatedSeries? = null
run {
val locale = langContext.lang.code
val languageCode = series.languageCode
// 원본 언어가 존재하고, 요청 언어와 다를 때만 번역 처리
if (!languageCode.isNullOrBlank() && languageCode != locale) {
val existing = seriesTranslationRepository.findBySeriesIdAndLocale(seriesId = seriesId, locale = locale)
if (existing != null) {
val payload = existing.renderedPayload
val kws = payload.keywords.ifEmpty { keywordList }
translated = TranslatedSeries(
title = payload.title,
introduction = payload.introduction,
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
)
)
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
)
}
}
}
}
// 장르 번역 조회 (있으면 반환)
val translatedGenre: String? = run {
val genreId = series.genre?.id
if (genreId != null) {
val locale = langContext.lang.code
val found = seriesGenreTranslationRepository.findBySeriesGenreIdAndLocale(genreId, locale)
val text = found?.genre
if (!text.isNullOrBlank()) {
text
} else {
null
}
} else {
null
}
}
// publishedDateUtc는 ISO8601(Z 포함)로 반환
val publishedDateUtc = series.createdAt!!
.atZone(ZoneId.of("UTC"))
.toInstant()
.toString()
return GetSeriesDetailResponse( return GetSeriesDetailResponse(
seriesId = seriesId, seriesId = seriesId,
title = series.title, title = series.title,
@@ -176,6 +364,7 @@ class ContentSeriesService(
.withZoneSameInstant(ZoneId.of("Asia/Seoul")) .withZoneSameInstant(ZoneId.of("Asia/Seoul"))
.toLocalDateTime() .toLocalDateTime()
.format(dateTimeFormatter), .format(dateTimeFormatter),
publishedDateUtc = publishedDateUtc,
creator = GetSeriesDetailResponse.GetSeriesDetailCreator( creator = GetSeriesDetailResponse.GetSeriesDetailCreator(
creatorId = series.member!!.id!!, creatorId = series.member!!.id!!,
nickname = series.member!!.nickname, nickname = series.member!!.nickname,
@@ -191,7 +380,9 @@ class ContentSeriesService(
keywordList = keywordList, keywordList = keywordList,
publishedDaysOfWeek = publishedDaysOfWeekText(series.publishedDaysOfWeek), publishedDaysOfWeek = publishedDaysOfWeekText(series.publishedDaysOfWeek),
contentList = seriesContentList.items, contentList = seriesContentList.items,
contentCount = seriesContentList.totalCount contentCount = seriesContentList.totalCount,
translated = translated,
translatedGenre = translatedGenre
) )
} }
@@ -233,7 +424,33 @@ class ContentSeriesService(
it it
} }
return GetSeriesContentListResponse(totalCount, contentList) /**
* 콘텐츠 리스트의 제목을 현재 언어(locale)에 맞춰 일괄 번역한다.
*
* 처리 절차:
* - 입력된 콘텐츠들의 contentId 집합을 만들고, 요청 언어 코드(langContext.lang.code)로
* contentTranslationRepository에서 번역 데이터를 한 번에 조회한다.
* - 각 항목에 대해 번역된 제목이 존재하고 비어있지 않으면 title만 번역 값으로 교체한다.
* - 번역이 없거나 공백이면 원본 항목을 그대로 반환한다.
*
* 성능:
* - N건의 항목을 1회의 조회로 해결하기 위해 IN 쿼리를 사용한다.
*/
val contentIds = contentList.map { it.contentId }
val translatedItems = if (contentIds.isNotEmpty()) {
val translations = contentTranslationRepository
.findByContentIdInAndLocale(contentIds = contentIds, locale = langContext.lang.code)
.associateBy { it.contentId }
contentList.map { item ->
val translatedTitle = translations[item.contentId]?.renderedPayload?.title
if (translatedTitle.isNullOrBlank()) item else item.copy(title = translatedTitle)
}
} else {
contentList
}
return GetSeriesContentListResponse(totalCount, translatedItems)
} }
fun getRecommendSeriesList( fun getRecommendSeriesList(
@@ -248,7 +465,13 @@ class ContentSeriesService(
limit = 20 limit = 20
).filter { !blockMemberRepository.isBlocked(blockedMemberId = member.id!!, memberId = it.member!!.id!!) } ).filter { !blockMemberRepository.isBlocked(blockedMemberId = member.id!!, memberId = it.member!!.id!!) }
return seriesToSeriesListItem(seriesList = seriesList, isAdult = isAuth, contentType = contentType) return getTranslatedSeriesList(
seriesToSeriesListItem(
seriesList = seriesList,
isAdult = isAuth,
contentType = contentType
)
)
} }
fun fetchSeriesByCurationId( fun fetchSeriesByCurationId(
@@ -263,7 +486,7 @@ class ContentSeriesService(
isAdult = isAdult, isAdult = isAdult,
contentType = contentType contentType = contentType
) )
return seriesToSeriesListItem(seriesList, isAdult, contentType) return getTranslatedSeriesList(seriesToSeriesListItem(seriesList, isAdult, contentType))
} }
fun getDayOfWeekSeriesList( fun getDayOfWeekSeriesList(
@@ -293,7 +516,7 @@ class ContentSeriesService(
seriesList seriesList
} }
return seriesToSeriesListItem(seriesList, isAdult, contentType) return getTranslatedSeriesList(seriesToSeriesListItem(seriesList, isAdult, contentType))
} }
private fun seriesToSeriesListItem( private fun seriesToSeriesListItem(

View File

@@ -1,6 +1,7 @@
package kr.co.vividnext.sodalive.content.series package kr.co.vividnext.sodalive.content.series
import kr.co.vividnext.sodalive.content.series.content.GetSeriesContentListItem import kr.co.vividnext.sodalive.content.series.content.GetSeriesContentListItem
import kr.co.vividnext.sodalive.content.series.translation.TranslatedSeries
data class GetSeriesDetailResponse( data class GetSeriesDetailResponse(
val seriesId: Long, val seriesId: Long,
@@ -12,6 +13,7 @@ data class GetSeriesDetailResponse(
val writer: String?, val writer: String?,
val studio: String?, val studio: String?,
val publishedDate: String, val publishedDate: String,
val publishedDateUtc: String,
val creator: GetSeriesDetailCreator, val creator: GetSeriesDetailCreator,
var rentalMinPrice: Int, var rentalMinPrice: Int,
var rentalMaxPrice: Int, var rentalMaxPrice: Int,
@@ -21,7 +23,9 @@ data class GetSeriesDetailResponse(
val keywordList: List<String>, val keywordList: List<String>,
val publishedDaysOfWeek: String, val publishedDaysOfWeek: String,
val contentList: List<GetSeriesContentListItem>, val contentList: List<GetSeriesContentListItem>,
val contentCount: Int val contentCount: Int,
val translated: TranslatedSeries?,
val translatedGenre: String?
) { ) {
data class GetSeriesDetailCreator( data class GetSeriesDetailCreator(
val creatorId: Long, val creatorId: Long,

View File

@@ -4,4 +4,6 @@ import org.springframework.data.jpa.repository.JpaRepository
interface SeriesGenreTranslationRepository : JpaRepository<SeriesGenreTranslation, Long> { interface SeriesGenreTranslationRepository : JpaRepository<SeriesGenreTranslation, Long> {
fun findBySeriesGenreIdAndLocale(seriesGenreId: Long, locale: String): SeriesGenreTranslation? fun findBySeriesGenreIdAndLocale(seriesGenreId: Long, locale: String): SeriesGenreTranslation?
fun findBySeriesGenreIdInAndLocale(seriesGenreIds: List<Long>, locale: String): List<SeriesGenreTranslation>
} }

View File

@@ -71,3 +71,9 @@ class SeriesTranslationPayloadConverter : AttributeConverter<SeriesTranslationPa
private val objectMapper = jacksonObjectMapper() private val objectMapper = jacksonObjectMapper()
} }
} }
data class TranslatedSeries(
val title: String,
val introduction: String,
val keywords: List<String>
)

View File

@@ -10,9 +10,12 @@ import kr.co.vividnext.sodalive.content.ContentPriceChangeLogRepository
import kr.co.vividnext.sodalive.content.hashtag.AudioContentHashTag import kr.co.vividnext.sodalive.content.hashtag.AudioContentHashTag
import kr.co.vividnext.sodalive.content.hashtag.HashTag import kr.co.vividnext.sodalive.content.hashtag.HashTag
import kr.co.vividnext.sodalive.content.hashtag.HashTagRepository import kr.co.vividnext.sodalive.content.hashtag.HashTagRepository
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.member.Member
import kr.co.vividnext.sodalive.utils.generateFileName import kr.co.vividnext.sodalive.utils.generateFileName
import org.springframework.beans.factory.annotation.Value import org.springframework.beans.factory.annotation.Value
import org.springframework.context.ApplicationEventPublisher
import org.springframework.data.domain.Pageable import org.springframework.data.domain.Pageable
import org.springframework.stereotype.Service import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional import org.springframework.transaction.annotation.Transactional
@@ -27,6 +30,8 @@ class CreatorAdminContentService(
private val objectMapper: ObjectMapper, private val objectMapper: ObjectMapper,
private val s3Uploader: S3Uploader, private val s3Uploader: S3Uploader,
private val applicationEventPublisher: ApplicationEventPublisher,
@Value("\${cloud.aws.s3.bucket}") @Value("\${cloud.aws.s3.bucket}")
private val bucket: String, private val bucket: String,
@@ -194,6 +199,13 @@ class CreatorAdminContentService(
} }
audioContent.audioContentHashTags.addAll(newAudioContentHashTagList) audioContent.audioContentHashTags.addAll(newAudioContentHashTagList)
applicationEventPublisher.publishEvent(
LanguageTranslationEvent(
id = request.id,
targetType = LanguageTranslationTargetType.CONTENT
)
)
} }
} }
} }

View File

@@ -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.AiCharacterTranslationRepository
import kr.co.vividnext.sodalive.chat.character.translate.TranslatedAiCharacterBackground import kr.co.vividnext.sodalive.chat.character.translate.TranslatedAiCharacterBackground
import kr.co.vividnext.sodalive.chat.character.translate.TranslatedAiCharacterPersonality 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.AudioContentRepository
import kr.co.vividnext.sodalive.content.series.translation.SeriesGenreTranslation 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.SeriesGenreTranslationRepository
@@ -35,7 +39,9 @@ enum class LanguageTranslationTargetType {
CONTENT_THEME, CONTENT_THEME,
SERIES, SERIES,
SERIES_GENRE SERIES_GENRE,
ORIGINAL_WORK
} }
class LanguageTranslationEvent( class LanguageTranslationEvent(
@@ -50,12 +56,14 @@ class LanguageTranslationListener(
private val audioContentThemeRepository: AudioContentThemeQueryRepository, private val audioContentThemeRepository: AudioContentThemeQueryRepository,
private val seriesRepository: AdminContentSeriesRepository, private val seriesRepository: AdminContentSeriesRepository,
private val seriesGenreRepository: AdminContentSeriesGenreRepository, private val seriesGenreRepository: AdminContentSeriesGenreRepository,
private val originalWorkRepository: OriginalWorkRepository,
private val contentTranslationRepository: ContentTranslationRepository, private val contentTranslationRepository: ContentTranslationRepository,
private val aiCharacterTranslationRepository: AiCharacterTranslationRepository, private val aiCharacterTranslationRepository: AiCharacterTranslationRepository,
private val contentThemeTranslationRepository: ContentThemeTranslationRepository, private val contentThemeTranslationRepository: ContentThemeTranslationRepository,
private val seriesTranslationRepository: SeriesTranslationRepository, private val seriesTranslationRepository: SeriesTranslationRepository,
private val seriesGenreTranslationRepository: SeriesGenreTranslationRepository, private val seriesGenreTranslationRepository: SeriesGenreTranslationRepository,
private val originalWorkTranslationRepository: OriginalWorkTranslationRepository,
private val translationService: PapagoTranslationService private val translationService: PapagoTranslationService
) { ) {
@@ -69,6 +77,7 @@ class LanguageTranslationListener(
LanguageTranslationTargetType.CONTENT_THEME -> handleContentThemeLanguageTranslation(event) LanguageTranslationTargetType.CONTENT_THEME -> handleContentThemeLanguageTranslation(event)
LanguageTranslationTargetType.SERIES -> handleSeriesLanguageTranslation(event) LanguageTranslationTargetType.SERIES -> handleSeriesLanguageTranslation(event)
LanguageTranslationTargetType.SERIES_GENRE -> handleSeriesGenreLanguageTranslation(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<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)
}
}
}
}
} }

View File

@@ -6,6 +6,7 @@ import org.springframework.http.HttpHeaders
import org.springframework.http.MediaType import org.springframework.http.MediaType
import org.springframework.stereotype.Service import org.springframework.stereotype.Service
import org.springframework.web.client.RestTemplate import org.springframework.web.client.RestTemplate
import org.springframework.web.client.postForEntity
@Service @Service
class PapagoTranslationService( class PapagoTranslationService(
@@ -46,10 +47,9 @@ class PapagoTranslationService(
val requestEntity = HttpEntity(body, headers) val requestEntity = HttpEntity(body, headers)
val response = restTemplate.postForEntity( val response = restTemplate.postForEntity<PapagoTranslationResponse>(
papagoTranslateUrl, papagoTranslateUrl,
requestEntity, requestEntity
PapagoTranslationResponse::class.java
) )
if (!response.statusCode.is2xxSuccessful) { if (!response.statusCode.is2xxSuccessful) {