Compare commits
3 Commits
8ae6943c2a
...
test
| Author | SHA1 | Date | |
|---|---|---|---|
| 67a8de9e7a | |||
| 0c52804f06 | |||
| 7955be45da |
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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.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
|
||||
import kr.co.vividnext.sodalive.i18n.LangContext
|
||||
import kr.co.vividnext.sodalive.member.Member
|
||||
import org.springframework.beans.factory.annotation.Value
|
||||
import org.springframework.security.core.annotation.AuthenticationPrincipal
|
||||
@@ -30,6 +34,12 @@ class OriginalWorkController(
|
||||
private val queryService: OriginalWorkQueryService,
|
||||
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}")
|
||||
private val imageHost: String
|
||||
) {
|
||||
@@ -51,7 +61,57 @@ class OriginalWorkController(
|
||||
val includeAdult = member?.auth != null
|
||||
val pageRes = queryService.listForAppPage(includeAdult, page, size)
|
||||
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()
|
||||
}
|
||||
|
||||
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<Character>()
|
||||
} else {
|
||||
val ids = chars.mapNotNull { it.id }
|
||||
val translations = aiCharacterTranslationRepository
|
||||
.findByCharacterIdInAndLocale(ids, langContext.lang.code)
|
||||
.associateBy { it.characterId }
|
||||
|
||||
chars.map<ChatCharacter, Character> {
|
||||
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
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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<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 {
|
||||
fun from(
|
||||
entity: OriginalWork,
|
||||
imageHost: String = "",
|
||||
characters: List<Character>
|
||||
characters: List<Character>,
|
||||
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
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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<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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user