Compare commits

...

5 Commits

Author SHA1 Message Date
0eed29eadc 시리즈 리스트 - 번역 데이터 조회 기능 추가 2025-12-16 01:07:20 +09:00
db18d5c8b5 홈 - 오직 보이스온에서만, 요일별 시리즈 번역 데이터 조회 기능 추가 2025-12-16 00:43:36 +09:00
f58687ef3a 크리에이터 관리자에서 시리즈 등록/수정시 번역데이터 생성 기능 추가 2025-12-16 00:25:24 +09:00
9b2b156d40 SeriesTranslationPayload 키워드 리스트 변환 및 수정
- `SeriesTranslationPayload.keywords` 타입을 `String`에서 `List<String>`으로 변경했습니다.
- `SeriesTranslationPayloadConverter`의 `convertToEntityAttribute`를 하위 호환 가능하도록 수정했습니다.
  - DB에 저장된 JSON에서 `keywords`가 과거 스키마(String)인 경우와 신규 스키마(List)를 모두 안전하게 파싱합니다.
  - 파싱 실패 또는 공백 입력 시 기본값을 사용합니다(`keywords = []`).
- `convertToDatabaseColumn`은 변경 없이 `ObjectMapper`로 직렬화하여 `keywords`가 배열로 저장됩니다.
2025-12-15 23:55:50 +09:00
e00a9ccff5 시리즈 상세, 시리즈 키워드 번역 엔티티 추가 2025-12-15 16:32:21 +09:00
11 changed files with 465 additions and 27 deletions

View File

@@ -12,6 +12,7 @@ import kr.co.vividnext.sodalive.content.main.banner.AudioContentBannerService
import kr.co.vividnext.sodalive.content.main.curation.AudioContentCurationService import kr.co.vividnext.sodalive.content.main.curation.AudioContentCurationService
import kr.co.vividnext.sodalive.content.series.ContentSeriesService import kr.co.vividnext.sodalive.content.series.ContentSeriesService
import kr.co.vividnext.sodalive.content.series.GetSeriesListResponse import kr.co.vividnext.sodalive.content.series.GetSeriesListResponse
import kr.co.vividnext.sodalive.content.series.translation.SeriesTranslationRepository
import kr.co.vividnext.sodalive.content.theme.AudioContentThemeService import kr.co.vividnext.sodalive.content.theme.AudioContentThemeService
import kr.co.vividnext.sodalive.content.translation.ContentTranslationRepository import kr.co.vividnext.sodalive.content.translation.ContentTranslationRepository
import kr.co.vividnext.sodalive.creator.admin.content.series.SeriesPublishedDaysOfWeek import kr.co.vividnext.sodalive.creator.admin.content.series.SeriesPublishedDaysOfWeek
@@ -53,6 +54,7 @@ class HomeService(
private val contentTranslationRepository: ContentTranslationRepository, private val contentTranslationRepository: ContentTranslationRepository,
private val aiCharacterTranslationRepository: AiCharacterTranslationRepository, private val aiCharacterTranslationRepository: AiCharacterTranslationRepository,
private val seriesTranslationRepository: SeriesTranslationRepository,
private val langContext: LangContext, private val langContext: LangContext,
@@ -133,20 +135,25 @@ class HomeService(
isAdult = isAdult isAdult = isAdult
) )
// 오직 보이스온에서만
val originalAudioDramaList = seriesService.getOriginalAudioDramaList( val originalAudioDramaList = seriesService.getOriginalAudioDramaList(
isAdult = isAdult, isAdult = isAdult,
contentType = contentType, contentType = contentType,
orderByRandom = true orderByRandom = true
) )
val translatedOriginalAudioDramaList = getTranslatedSeriesList(seriesList = originalAudioDramaList)
val auditionList = auditionService.getInProgressAuditionList(isAdult = isAdult) val auditionList = auditionService.getInProgressAuditionList(isAdult = isAdult)
// 요일별 시리즈
val dayOfWeekSeriesList = seriesService.getDayOfWeekSeriesList( val dayOfWeekSeriesList = seriesService.getDayOfWeekSeriesList(
memberId = memberId, memberId = memberId,
isAdult = isAdult, isAdult = isAdult,
contentType = contentType, contentType = contentType,
dayOfWeek = getDayOfWeekByTimezone(timezone) dayOfWeek = getDayOfWeekByTimezone(timezone)
) )
val translatedDayOfWeekSeriesList = getTranslatedSeriesList(seriesList = dayOfWeekSeriesList)
// 인기 캐릭터 조회 // 인기 캐릭터 조회
val translatedPopularCharacters = getTranslatedAiCharacterList(aiCharacterList = characterService.getPopularCharacters()) val translatedPopularCharacters = getTranslatedAiCharacterList(aiCharacterList = characterService.getPopularCharacters())
@@ -280,9 +287,9 @@ class HomeService(
latestContentList = translatedLatestContentList, latestContentList = translatedLatestContentList,
bannerList = bannerList, bannerList = bannerList,
eventBannerList = eventBannerList, eventBannerList = eventBannerList,
originalAudioDramaList = originalAudioDramaList, originalAudioDramaList = translatedOriginalAudioDramaList,
auditionList = auditionList, auditionList = auditionList,
dayOfWeekSeriesList = dayOfWeekSeriesList, dayOfWeekSeriesList = translatedDayOfWeekSeriesList,
popularCharacters = translatedPopularCharacters, popularCharacters = translatedPopularCharacters,
contentRanking = translatedContentRanking, contentRanking = translatedContentRanking,
recommendChannelList = translatedRecommendChannelList, recommendChannelList = translatedRecommendChannelList,
@@ -479,6 +486,44 @@ class HomeService(
} }
} }
/**
* 시리즈 리스트의 제목을 현재 언어(locale)에 맞춰 일괄 번역한다.
*
* 처리 절차:
* - 입력된 시리즈들의 seriesId 집합을 만들고, 요청 언어 코드(langContext.lang.code)로
* seriesTranslationRepository에서 번역 데이터를 한 번에 조회한다.
* - 각 항목에 대해 번역된 제목이 존재하고 비어있지 않으면 title만 번역 값으로 교체한다.
* - 번역이 없거나 공백이면 원본 항목을 그대로 반환한다.
*
* 성능:
* - N건의 항목을 1회의 조회로 해결하기 위해 IN 쿼리를 사용한다.
*
* @param seriesList 번역 대상 SeriesListItem 목록
* @return 제목이 가능한 항목은 번역된 목록(불변 사본), 그 외는 원본 항목 유지
*/
private fun getTranslatedSeriesList(
seriesList: List<GetSeriesListResponse.SeriesListItem>
): List<GetSeriesListResponse.SeriesListItem> {
val seriesIds = seriesList.map { it.seriesId }
return if (seriesIds.isNotEmpty()) {
val translations = seriesTranslationRepository
.findBySeriesIdInAndLocale(seriesIds = seriesIds, locale = langContext.lang.code)
.associateBy { it.seriesId }
seriesList.map { item ->
val translatedTitle = translations[item.seriesId]?.renderedPayload?.title
if (translatedTitle.isNullOrBlank()) {
item
} else {
item.copy(title = translatedTitle)
}
}
} else {
seriesList
}
}
/** /**
* AI 캐릭터 리스트의 이름/설명을 현재 언어(locale)에 맞춰 일괄 번역한다. * AI 캐릭터 리스트의 이름/설명을 현재 언어(locale)에 맞춰 일괄 번역한다.
* *

View File

@@ -372,14 +372,14 @@ class AudioContentService(
query = papagoQuery query = papagoQuery
) )
) )
} } else {
applicationEventPublisher.publishEvent(
applicationEventPublisher.publishEvent( LanguageTranslationEvent(
LanguageTranslationEvent( id = audioContent.id!!,
id = audioContent.id!!, targetType = LanguageTranslationTargetType.CONTENT
targetType = LanguageTranslationTargetType.CONTENT )
) )
) }
return CreateAudioContentResponse(contentId = audioContent.id!!) return CreateAudioContentResponse(contentId = audioContent.id!!)
} }

View File

@@ -3,12 +3,14 @@ package kr.co.vividnext.sodalive.content
import kr.co.vividnext.sodalive.chat.character.comment.CharacterCommentRepository import kr.co.vividnext.sodalive.chat.character.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.content.comment.AudioContentCommentRepository import kr.co.vividnext.sodalive.content.comment.AudioContentCommentRepository
import kr.co.vividnext.sodalive.content.series.ContentSeriesRepository
import kr.co.vividnext.sodalive.explorer.profile.CreatorCheersRepository import kr.co.vividnext.sodalive.explorer.profile.CreatorCheersRepository
import kr.co.vividnext.sodalive.i18n.translation.LanguageTranslationEvent import kr.co.vividnext.sodalive.i18n.translation.LanguageTranslationEvent
import kr.co.vividnext.sodalive.i18n.translation.LanguageTranslationTargetType import kr.co.vividnext.sodalive.i18n.translation.LanguageTranslationTargetType
import org.slf4j.LoggerFactory import org.slf4j.LoggerFactory
import org.springframework.beans.factory.annotation.Value import org.springframework.beans.factory.annotation.Value
import org.springframework.context.ApplicationEventPublisher import org.springframework.context.ApplicationEventPublisher
import org.springframework.data.repository.findByIdOrNull
import org.springframework.http.HttpEntity import org.springframework.http.HttpEntity
import org.springframework.http.HttpHeaders import org.springframework.http.HttpHeaders
import org.springframework.http.MediaType import org.springframework.http.MediaType
@@ -29,7 +31,8 @@ enum class LanguageDetectTargetType {
COMMENT, COMMENT,
CHARACTER, CHARACTER,
CHARACTER_COMMENT, CHARACTER_COMMENT,
CREATOR_CHEERS CREATOR_CHEERS,
SERIES
} }
class LanguageDetectEvent( class LanguageDetectEvent(
@@ -49,6 +52,7 @@ class LanguageDetectListener(
private val chatCharacterRepository: ChatCharacterRepository, private val chatCharacterRepository: ChatCharacterRepository,
private val characterCommentRepository: CharacterCommentRepository, private val characterCommentRepository: CharacterCommentRepository,
private val creatorCheersRepository: CreatorCheersRepository, private val creatorCheersRepository: CreatorCheersRepository,
private val seriesRepository: ContentSeriesRepository,
private val applicationEventPublisher: ApplicationEventPublisher, private val applicationEventPublisher: ApplicationEventPublisher,
@@ -80,6 +84,7 @@ class LanguageDetectListener(
LanguageDetectTargetType.CHARACTER -> handleCharacterLanguageDetect(event) LanguageDetectTargetType.CHARACTER -> handleCharacterLanguageDetect(event)
LanguageDetectTargetType.CHARACTER_COMMENT -> handleCharacterCommentLanguageDetect(event) LanguageDetectTargetType.CHARACTER_COMMENT -> handleCharacterCommentLanguageDetect(event)
LanguageDetectTargetType.CREATOR_CHEERS -> handleCreatorCheersLanguageDetect(event) LanguageDetectTargetType.CREATOR_CHEERS -> handleCreatorCheersLanguageDetect(event)
LanguageDetectTargetType.SERIES -> handleSeriesLanguageDetect(event)
} }
} }
@@ -255,6 +260,44 @@ class LanguageDetectListener(
) )
} }
private fun handleSeriesLanguageDetect(event: LanguageDetectEvent) {
val seriesId = event.id
val series = seriesRepository.findByIdOrNull(seriesId)
if (series == null) {
log.warn("[PapagoLanguageDetect] Series not found. seriesId={}", seriesId)
return
}
// 이미 언어 코드가 설정된 경우 호출하지 않음
if (!series.languageCode.isNullOrBlank()) {
log.debug(
"[PapagoLanguageDetect] languageCode already set. Skip language detection. seriesId={}, languageCode={}",
seriesId,
series.languageCode
)
return
}
val langCode = requestPapagoLanguageCode(event.query, seriesId) ?: return
series.languageCode = langCode
seriesRepository.save(series)
applicationEventPublisher.publishEvent(
LanguageTranslationEvent(
id = seriesId,
targetType = LanguageTranslationTargetType.SERIES
)
)
log.info(
"[PapagoLanguageDetect] languageCode updated from Papago. seriesId={}, langCode={}",
seriesId,
langCode
)
}
private fun requestPapagoLanguageCode(query: String, targetIdForLog: Long): String? { private fun requestPapagoLanguageCode(query: String, targetIdForLog: Long): String? {
return try { return try {
val headers = HttpHeaders().apply { val headers = HttpHeaders().apply {

View File

@@ -6,11 +6,14 @@ 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.SeriesTranslationRepository
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
import kr.co.vividnext.sodalive.creator.admin.content.series.SeriesState 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.LangContext
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
@@ -26,6 +29,8 @@ 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 seriesTranslationRepository: SeriesTranslationRepository,
@Value("\${cloud.aws.cloud-front.host}") @Value("\${cloud.aws.cloud-front.host}")
private val coverImageHost: String private val coverImageHost: String
@@ -83,7 +88,7 @@ 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))
} }
fun getSeriesListByGenre( fun getSeriesListByGenre(
@@ -338,27 +343,105 @@ class ContentSeriesService(
} }
private fun publishedDaysOfWeekText(publishedDaysOfWeek: Set<SeriesPublishedDaysOfWeek>): String { private fun publishedDaysOfWeekText(publishedDaysOfWeek: Set<SeriesPublishedDaysOfWeek>): String {
/**
* i18n을 적용하여 언어별로 요일 표시를 변경한다.
*/
val lang = langContext.lang
val labelRandom = when (lang) {
Lang.EN -> "Random"
Lang.JA -> "ランダム"
else -> "랜덤"
}
val labels = when (lang) {
Lang.EN -> mapOf(
SeriesPublishedDaysOfWeek.SUN to "Sun",
SeriesPublishedDaysOfWeek.MON to "Mon",
SeriesPublishedDaysOfWeek.TUE to "Tue",
SeriesPublishedDaysOfWeek.WED to "Wed",
SeriesPublishedDaysOfWeek.THU to "Thu",
SeriesPublishedDaysOfWeek.FRI to "Fri",
SeriesPublishedDaysOfWeek.SAT to "Sat",
SeriesPublishedDaysOfWeek.RANDOM to labelRandom
)
Lang.JA -> mapOf(
SeriesPublishedDaysOfWeek.SUN to "",
SeriesPublishedDaysOfWeek.MON to "",
SeriesPublishedDaysOfWeek.TUE to "",
SeriesPublishedDaysOfWeek.WED to "",
SeriesPublishedDaysOfWeek.THU to "",
SeriesPublishedDaysOfWeek.FRI to "",
SeriesPublishedDaysOfWeek.SAT to "",
SeriesPublishedDaysOfWeek.RANDOM to labelRandom
)
else -> mapOf(
SeriesPublishedDaysOfWeek.SUN to "",
SeriesPublishedDaysOfWeek.MON to "",
SeriesPublishedDaysOfWeek.TUE to "",
SeriesPublishedDaysOfWeek.WED to "",
SeriesPublishedDaysOfWeek.THU to "",
SeriesPublishedDaysOfWeek.FRI to "",
SeriesPublishedDaysOfWeek.SAT to "",
SeriesPublishedDaysOfWeek.RANDOM to labelRandom
)
}
val dayOfWeekText = publishedDaysOfWeek.toList().sortedBy { it.ordinal } val dayOfWeekText = publishedDaysOfWeek.toList().sortedBy { it.ordinal }
.map { .map { labels[it] ?: it.name }
when (it) {
SeriesPublishedDaysOfWeek.SUN -> ""
SeriesPublishedDaysOfWeek.MON -> ""
SeriesPublishedDaysOfWeek.TUE -> ""
SeriesPublishedDaysOfWeek.WED -> ""
SeriesPublishedDaysOfWeek.THU -> ""
SeriesPublishedDaysOfWeek.FRI -> ""
SeriesPublishedDaysOfWeek.SAT -> ""
SeriesPublishedDaysOfWeek.RANDOM -> "랜덤"
}
}
.joinToString(", ") { it } .joinToString(", ") { it }
return if (publishedDaysOfWeek.contains(SeriesPublishedDaysOfWeek.RANDOM)) { val containsRandom = publishedDaysOfWeek.contains(SeriesPublishedDaysOfWeek.RANDOM)
return if (containsRandom) {
dayOfWeekText dayOfWeekText
} else if (publishedDaysOfWeek.size < 7) { } else if (publishedDaysOfWeek.size < 7) {
"매주 $dayOfWeekText" when (lang) {
Lang.EN -> "Every $dayOfWeekText"
Lang.JA -> "毎週 $dayOfWeekText"
else -> "매주 $dayOfWeekText"
}
} else { } else {
"매일" when (lang) {
Lang.EN -> "Daily"
Lang.JA -> "毎日"
else -> "매일"
}
}
}
/**
* 시리즈 리스트의 제목을 현재 언어(locale)에 맞춰 일괄 번역한다.
*
* 처리 절차:
* - 입력된 시리즈들의 seriesId 집합을 만들고, 요청 언어 코드(langContext.lang.code)로
* seriesTranslationRepository에서 번역 데이터를 한 번에 조회한다.
* - 각 항목에 대해 번역된 제목이 존재하고 비어있지 않으면 title만 번역 값으로 교체한다.
* - 번역이 없거나 공백이면 원본 항목을 그대로 반환한다.
*
* 성능:
* - N건의 항목을 1회의 조회로 해결하기 위해 IN 쿼리를 사용한다.
*
* @param seriesList 번역 대상 SeriesListItem 목록
* @return 제목이 가능한 항목은 번역된 목록(불변 사본), 그 외는 원본 항목 유지
*/
private fun getTranslatedSeriesList(
seriesList: List<GetSeriesListResponse.SeriesListItem>
): List<GetSeriesListResponse.SeriesListItem> {
val seriesIds = seriesList.map { it.seriesId }
if (seriesIds.isEmpty()) return seriesList
val translations = seriesTranslationRepository
.findBySeriesIdInAndLocale(seriesIds = seriesIds, locale = langContext.lang.code)
.associateBy { it.seriesId }
return seriesList.map { item ->
val translatedTitle = translations[item.seriesId]?.renderedPayload?.title
if (translatedTitle.isNullOrBlank()) {
item
} else {
item.copy(title = translatedTitle)
}
} }
} }
} }

View File

@@ -0,0 +1,21 @@
package kr.co.vividnext.sodalive.content.series.translation
import kr.co.vividnext.sodalive.common.BaseEntity
import javax.persistence.Column
import javax.persistence.Entity
import javax.persistence.Table
import javax.persistence.UniqueConstraint
@Entity
@Table(
uniqueConstraints = [
UniqueConstraint(columnNames = ["series_genre_id", "locale"])
]
)
class SeriesGenreTranslation(
@Column(name = "series_genre_id")
val seriesGenreId: Long,
@Column(name = "locale")
val locale: String,
var genre: String
) : BaseEntity()

View File

@@ -0,0 +1,7 @@
package kr.co.vividnext.sodalive.content.series.translation
import org.springframework.data.jpa.repository.JpaRepository
interface SeriesGenreTranslationRepository : JpaRepository<SeriesGenreTranslation, Long> {
fun findBySeriesGenreIdAndLocale(seriesGenreId: Long, locale: String): SeriesGenreTranslation?
}

View File

@@ -0,0 +1,73 @@
package kr.co.vividnext.sodalive.content.series.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
@Entity
class SeriesTranslation(
val seriesId: Long,
val locale: String,
@Column(columnDefinition = "json")
@Convert(converter = SeriesTranslationPayloadConverter::class)
var renderedPayload: SeriesTranslationPayload
) : BaseEntity()
data class SeriesTranslationPayload(
val title: String,
val introduction: String,
val keywords: List<String>
)
@Converter(autoApply = false)
class SeriesTranslationPayloadConverter : AttributeConverter<SeriesTranslationPayload, String> {
override fun convertToDatabaseColumn(attribute: SeriesTranslationPayload?): String {
if (attribute == null) return "{}"
return objectMapper.writeValueAsString(attribute)
}
override fun convertToEntityAttribute(dbData: String?): SeriesTranslationPayload {
if (dbData.isNullOrBlank()) {
return SeriesTranslationPayload(
title = "",
introduction = "",
keywords = emptyList()
)
}
// 호환 처리: 과거 스키마에서 keywords가 String 이었을 수 있으므로 유연하게 파싱한다.
return try {
val node = objectMapper.readTree(dbData)
val title = node.get("title")?.asText() ?: ""
val introduction = node.get("introduction")?.asText() ?: ""
val keywordsNode = node.get("keywords")
val keywords: List<String> = when {
keywordsNode == null || keywordsNode.isNull -> emptyList()
keywordsNode.isArray -> keywordsNode.mapNotNull { it.asText(null) }.filter { it.isNotBlank() }
keywordsNode.isTextual -> listOfNotNull(keywordsNode.asText()).filter { it.isNotBlank() }
else -> emptyList()
}
SeriesTranslationPayload(
title = title,
introduction = introduction,
keywords = keywords
)
} catch (_: Exception) {
// 파싱 실패 시 안전한 기본값 반환
SeriesTranslationPayload(
title = "",
introduction = "",
keywords = emptyList()
)
}
}
companion object {
private val objectMapper = jacksonObjectMapper()
}
}

View File

@@ -0,0 +1,8 @@
package kr.co.vividnext.sodalive.content.series.translation
import org.springframework.data.jpa.repository.JpaRepository
interface SeriesTranslationRepository : JpaRepository<SeriesTranslation, Long> {
fun findBySeriesIdAndLocale(seriesId: Long, locale: String): SeriesTranslation?
fun findBySeriesIdInAndLocale(seriesIds: List<Long>, locale: String): List<SeriesTranslation>
}

View File

@@ -5,6 +5,8 @@ import com.fasterxml.jackson.databind.ObjectMapper
import kr.co.vividnext.sodalive.aws.s3.S3Uploader import kr.co.vividnext.sodalive.aws.s3.S3Uploader
import kr.co.vividnext.sodalive.common.SodaException import kr.co.vividnext.sodalive.common.SodaException
import kr.co.vividnext.sodalive.content.AudioContentRepository import kr.co.vividnext.sodalive.content.AudioContentRepository
import kr.co.vividnext.sodalive.content.LanguageDetectEvent
import kr.co.vividnext.sodalive.content.LanguageDetectTargetType
import kr.co.vividnext.sodalive.content.hashtag.HashTag import kr.co.vividnext.sodalive.content.hashtag.HashTag
import kr.co.vividnext.sodalive.content.hashtag.HashTagRepository import kr.co.vividnext.sodalive.content.hashtag.HashTagRepository
import kr.co.vividnext.sodalive.creator.admin.content.series.content.AddingContentToTheSeriesRequest import kr.co.vividnext.sodalive.creator.admin.content.series.content.AddingContentToTheSeriesRequest
@@ -12,9 +14,12 @@ import kr.co.vividnext.sodalive.creator.admin.content.series.content.RemoveConte
import kr.co.vividnext.sodalive.creator.admin.content.series.content.SearchContentNotInSeriesResponse import kr.co.vividnext.sodalive.creator.admin.content.series.content.SearchContentNotInSeriesResponse
import kr.co.vividnext.sodalive.creator.admin.content.series.genre.CreatorAdminContentSeriesGenreRepository import kr.co.vividnext.sodalive.creator.admin.content.series.genre.CreatorAdminContentSeriesGenreRepository
import kr.co.vividnext.sodalive.creator.admin.content.series.keyword.SeriesKeyword import kr.co.vividnext.sodalive.creator.admin.content.series.keyword.SeriesKeyword
import kr.co.vividnext.sodalive.i18n.translation.LanguageTranslationEvent
import kr.co.vividnext.sodalive.i18n.translation.LanguageTranslationTargetType
import kr.co.vividnext.sodalive.member.Member import kr.co.vividnext.sodalive.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.repository.findByIdOrNull import org.springframework.data.repository.findByIdOrNull
import org.springframework.stereotype.Service import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional import org.springframework.transaction.annotation.Transactional
@@ -30,6 +35,8 @@ class CreatorAdminContentSeriesService(
private val s3Uploader: S3Uploader, private val s3Uploader: S3Uploader,
private val objectMapper: ObjectMapper, private val objectMapper: ObjectMapper,
private val applicationEventPublisher: ApplicationEventPublisher,
@Value("\${cloud.aws.s3.bucket}") @Value("\${cloud.aws.s3.bucket}")
private val coverImageBucket: String, private val coverImageBucket: String,
@@ -89,6 +96,31 @@ class CreatorAdminContentSeriesService(
) )
series.coverImage = coverImagePath series.coverImage = coverImagePath
if (series.languageCode.isNullOrBlank()) {
val papagoQuery = listOf(
request.title.trim(),
request.introduction.trim(),
request.keyword.trim()
)
.filter { it.isNotBlank() }
.joinToString(" ")
applicationEventPublisher.publishEvent(
LanguageDetectEvent(
id = series.id!!,
query = papagoQuery,
targetType = LanguageDetectTargetType.SERIES
)
)
} else {
applicationEventPublisher.publishEvent(
LanguageTranslationEvent(
id = series.id!!,
targetType = LanguageTranslationTargetType.SERIES
)
)
}
} }
@Transactional @Transactional
@@ -174,6 +206,15 @@ class CreatorAdminContentSeriesService(
if (request.studio != null) { if (request.studio != null) {
series.studio = request.studio series.studio = request.studio
} }
if (request.title != null || request.introduction != null) {
applicationEventPublisher.publishEvent(
LanguageTranslationEvent(
id = series.id!!,
targetType = LanguageTranslationTargetType.SERIES
)
)
}
} }
fun getSeriesList(offset: Long, limit: Long, creatorId: Long): GetCreatorAdminContentSeriesListResponse { fun getSeriesList(offset: Long, limit: Long, creatorId: Long): GetCreatorAdminContentSeriesListResponse {

View File

@@ -34,6 +34,7 @@ data class Series(
var title: String, var title: String,
@Column(columnDefinition = "TEXT", nullable = false) @Column(columnDefinition = "TEXT", nullable = false)
var introduction: String, var introduction: String,
var languageCode: String? = null,
@Enumerated(value = EnumType.STRING) @Enumerated(value = EnumType.STRING)
var state: SeriesState = SeriesState.PROCEEDING, var state: SeriesState = SeriesState.PROCEEDING,
var writer: String? = null, var writer: String? = null,

View File

@@ -1,5 +1,7 @@
package kr.co.vividnext.sodalive.i18n.translation package kr.co.vividnext.sodalive.i18n.translation
import kr.co.vividnext.sodalive.admin.content.series.AdminContentSeriesRepository
import kr.co.vividnext.sodalive.admin.content.series.genre.AdminContentSeriesGenreRepository
import kr.co.vividnext.sodalive.chat.character.repository.ChatCharacterRepository import kr.co.vividnext.sodalive.chat.character.repository.ChatCharacterRepository
import kr.co.vividnext.sodalive.chat.character.translate.AiCharacterTranslation import kr.co.vividnext.sodalive.chat.character.translate.AiCharacterTranslation
import kr.co.vividnext.sodalive.chat.character.translate.AiCharacterTranslationRenderedPayload import kr.co.vividnext.sodalive.chat.character.translate.AiCharacterTranslationRenderedPayload
@@ -7,6 +9,11 @@ import kr.co.vividnext.sodalive.chat.character.translate.AiCharacterTranslationR
import kr.co.vividnext.sodalive.chat.character.translate.TranslatedAiCharacterBackground import kr.co.vividnext.sodalive.chat.character.translate.TranslatedAiCharacterBackground
import kr.co.vividnext.sodalive.chat.character.translate.TranslatedAiCharacterPersonality import kr.co.vividnext.sodalive.chat.character.translate.TranslatedAiCharacterPersonality
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.SeriesGenreTranslationRepository
import kr.co.vividnext.sodalive.content.series.translation.SeriesTranslation
import kr.co.vividnext.sodalive.content.series.translation.SeriesTranslationPayload
import kr.co.vividnext.sodalive.content.series.translation.SeriesTranslationRepository
import kr.co.vividnext.sodalive.content.theme.AudioContentThemeQueryRepository import kr.co.vividnext.sodalive.content.theme.AudioContentThemeQueryRepository
import kr.co.vividnext.sodalive.content.theme.translation.ContentThemeTranslation import kr.co.vividnext.sodalive.content.theme.translation.ContentThemeTranslation
import kr.co.vividnext.sodalive.content.theme.translation.ContentThemeTranslationRepository import kr.co.vividnext.sodalive.content.theme.translation.ContentThemeTranslationRepository
@@ -25,7 +32,10 @@ import org.springframework.transaction.event.TransactionalEventListener
enum class LanguageTranslationTargetType { enum class LanguageTranslationTargetType {
CONTENT, CONTENT,
CHARACTER, CHARACTER,
CONTENT_THEME CONTENT_THEME,
SERIES,
SERIES_GENRE
} }
class LanguageTranslationEvent( class LanguageTranslationEvent(
@@ -38,10 +48,14 @@ class LanguageTranslationListener(
private val audioContentRepository: AudioContentRepository, private val audioContentRepository: AudioContentRepository,
private val chatCharacterRepository: ChatCharacterRepository, private val chatCharacterRepository: ChatCharacterRepository,
private val audioContentThemeRepository: AudioContentThemeQueryRepository, private val audioContentThemeRepository: AudioContentThemeQueryRepository,
private val seriesRepository: AdminContentSeriesRepository,
private val seriesGenreRepository: AdminContentSeriesGenreRepository,
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 seriesGenreTranslationRepository: SeriesGenreTranslationRepository,
private val translationService: PapagoTranslationService private val translationService: PapagoTranslationService
) { ) {
@@ -53,6 +67,8 @@ class LanguageTranslationListener(
LanguageTranslationTargetType.CONTENT -> handleContentLanguageTranslation(event) LanguageTranslationTargetType.CONTENT -> handleContentLanguageTranslation(event)
LanguageTranslationTargetType.CHARACTER -> handleCharacterLanguageTranslation(event) LanguageTranslationTargetType.CHARACTER -> handleCharacterLanguageTranslation(event)
LanguageTranslationTargetType.CONTENT_THEME -> handleContentThemeLanguageTranslation(event) LanguageTranslationTargetType.CONTENT_THEME -> handleContentThemeLanguageTranslation(event)
LanguageTranslationTargetType.SERIES -> handleSeriesLanguageTranslation(event)
LanguageTranslationTargetType.SERIES_GENRE -> handleSeriesGenreLanguageTranslation(event)
} }
} }
@@ -248,4 +264,104 @@ class LanguageTranslationListener(
} }
} }
} }
private fun handleSeriesLanguageTranslation(event: LanguageTranslationEvent) {
val series = seriesRepository.findByIdOrNull(event.id) ?: return
val languageCode = series.languageCode
if (languageCode != null) return
getTranslatableLanguageCodes(languageCode).forEach { locale ->
val keywords = series.keywordList
.mapNotNull { it.keyword?.tag }
.joinToString(", ")
val texts = mutableListOf<String>()
texts.add(series.title)
texts.add(series.introduction)
texts.add(keywords)
val sourceLanguage = series.languageCode ?: "ko"
val response = translationService.translate(
request = TranslateRequest(
texts = texts,
sourceLanguage = sourceLanguage,
targetLanguage = locale
)
)
val translatedTexts = response.translatedText
if (translatedTexts.size == texts.size) {
var index = 0
val translatedTitle = translatedTexts[index++]
val translatedIntroduction = translatedTexts[index++]
val translatedKeywordsJoined = translatedTexts[index]
val translatedKeywords = translatedKeywordsJoined
.split(",")
.map { it.trim() }
.filter { it.isNotBlank() }
val payload = SeriesTranslationPayload(
title = translatedTitle,
introduction = translatedIntroduction,
keywords = translatedKeywords
)
val existing = seriesTranslationRepository
.findBySeriesIdAndLocale(series.id!!, locale)
if (existing == null) {
seriesTranslationRepository.save(
SeriesTranslation(
seriesId = series.id!!,
locale = locale,
renderedPayload = payload
)
)
} else {
existing.renderedPayload = payload
seriesTranslationRepository.save(existing)
}
}
}
}
private fun handleSeriesGenreLanguageTranslation(event: LanguageTranslationEvent) {
val seriesGenre = seriesGenreRepository.findActiveSeriesGenreById(event.id) ?: return
val sourceLanguage = "ko"
getTranslatableLanguageCodes(sourceLanguage).forEach { locale ->
val texts = mutableListOf<String>()
texts.add(seriesGenre.genre)
val response = translationService.translate(
request = TranslateRequest(
texts = texts,
sourceLanguage = sourceLanguage,
targetLanguage = locale
)
)
val translatedTexts = response.translatedText
if (translatedTexts.size == texts.size) {
val translatedGenre = translatedTexts[0]
val existing = seriesGenreTranslationRepository
.findBySeriesGenreIdAndLocale(seriesGenre.id!!, locale)
if (existing == null) {
seriesGenreTranslationRepository.save(
SeriesGenreTranslation(
seriesGenreId = seriesGenre.id!!,
locale = locale,
genre = translatedGenre
)
)
} else {
existing.genre = translatedGenre
seriesGenreTranslationRepository.save(existing)
}
}
}
}
} }