Compare commits
21 Commits
165640201f
...
test
| Author | SHA1 | Date | |
|---|---|---|---|
| 67a8de9e7a | |||
| 0c52804f06 | |||
| 7955be45da | |||
| 8ae6943c2a | |||
| 82f53ed8ab | |||
| 4e4235369c | |||
| 30a104981c | |||
| 4c0be733d0 | |||
| 0eed29eadc | |||
| db18d5c8b5 | |||
| f58687ef3a | |||
| 9b2b156d40 | |||
| e00a9ccff5 | |||
| 45ee55028f | |||
| dc0df81232 | |||
| c0c61da44b | |||
| 13029ab8d2 | |||
| 6f0619e482 | |||
| 920a866ae0 | |||
| de60a70733 | |||
| 59949e5aee |
@@ -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)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,8 +5,11 @@ 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.theme.AudioContentTheme
|
import kr.co.vividnext.sodalive.content.theme.AudioContentTheme
|
||||||
import kr.co.vividnext.sodalive.content.theme.GetAudioContentThemeResponse
|
import kr.co.vividnext.sodalive.content.theme.GetAudioContentThemeResponse
|
||||||
|
import kr.co.vividnext.sodalive.i18n.translation.LanguageTranslationEvent
|
||||||
|
import kr.co.vividnext.sodalive.i18n.translation.LanguageTranslationTargetType
|
||||||
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
|
||||||
@@ -18,6 +21,8 @@ class AdminContentThemeService(
|
|||||||
private val objectMapper: ObjectMapper,
|
private val objectMapper: ObjectMapper,
|
||||||
private val repository: AdminContentThemeRepository,
|
private val repository: AdminContentThemeRepository,
|
||||||
|
|
||||||
|
private val applicationEventPublisher: ApplicationEventPublisher,
|
||||||
|
|
||||||
@Value("\${cloud.aws.s3.bucket}")
|
@Value("\${cloud.aws.s3.bucket}")
|
||||||
private val bucket: String
|
private val bucket: String
|
||||||
) {
|
) {
|
||||||
@@ -37,7 +42,14 @@ class AdminContentThemeService(
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun createTheme(theme: String, imagePath: String) {
|
fun createTheme(theme: String, imagePath: String) {
|
||||||
repository.save(AudioContentTheme(theme = theme, image = imagePath))
|
val savedTheme = repository.save(AudioContentTheme(theme = theme, image = imagePath))
|
||||||
|
|
||||||
|
applicationEventPublisher.publishEvent(
|
||||||
|
LanguageTranslationEvent(
|
||||||
|
id = savedTheme.id!!,
|
||||||
|
targetType = LanguageTranslationTargetType.CONTENT_THEME
|
||||||
|
)
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun themeExistCheck(request: CreateContentThemeRequest) {
|
fun themeExistCheck(request: CreateContentThemeRequest) {
|
||||||
|
|||||||
@@ -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,
|
||||||
@@ -341,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(
|
||||||
@@ -479,6 +488,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)에 맞춰 일괄 번역한다.
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
}
|
||||||
@@ -108,7 +108,6 @@ class AudioContentController(private val service: AudioContentService) {
|
|||||||
@RequestParam("category-id", required = false) categoryId: Long? = 0,
|
@RequestParam("category-id", required = false) categoryId: Long? = 0,
|
||||||
@RequestParam("isAdultContentVisible", required = false) isAdultContentVisible: Boolean? = null,
|
@RequestParam("isAdultContentVisible", required = false) isAdultContentVisible: Boolean? = null,
|
||||||
@RequestParam("contentType", required = false) contentType: ContentType? = null,
|
@RequestParam("contentType", required = false) contentType: ContentType? = null,
|
||||||
@RequestParam("languageCode", required = false) languageCode: String? = null,
|
|
||||||
@AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?,
|
@AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?,
|
||||||
pageable: Pageable
|
pageable: Pageable
|
||||||
) = run {
|
) = run {
|
||||||
@@ -121,7 +120,6 @@ class AudioContentController(private val service: AudioContentService) {
|
|||||||
categoryId = categoryId ?: 0,
|
categoryId = categoryId ?: 0,
|
||||||
isAdultContentVisible = isAdultContentVisible ?: true,
|
isAdultContentVisible = isAdultContentVisible ?: true,
|
||||||
contentType = contentType ?: ContentType.ALL,
|
contentType = contentType ?: ContentType.ALL,
|
||||||
languageCode = languageCode,
|
|
||||||
member = member,
|
member = member,
|
||||||
offset = pageable.offset,
|
offset = pageable.offset,
|
||||||
limit = pageable.pageSize.toLong()
|
limit = pageable.pageSize.toLong()
|
||||||
@@ -133,7 +131,6 @@ class AudioContentController(private val service: AudioContentService) {
|
|||||||
fun getDetail(
|
fun getDetail(
|
||||||
@PathVariable id: Long,
|
@PathVariable id: Long,
|
||||||
@RequestParam timezone: String,
|
@RequestParam timezone: String,
|
||||||
@RequestParam(required = false) languageCode: String? = null,
|
|
||||||
@RequestParam("isAdultContentVisible", required = false) isAdultContentVisible: Boolean? = null,
|
@RequestParam("isAdultContentVisible", required = false) isAdultContentVisible: Boolean? = null,
|
||||||
@AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?
|
@AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?
|
||||||
) = run {
|
) = run {
|
||||||
@@ -144,8 +141,7 @@ class AudioContentController(private val service: AudioContentService) {
|
|||||||
id = id,
|
id = id,
|
||||||
member = member,
|
member = member,
|
||||||
isAdultContentVisible = isAdultContentVisible ?: true,
|
isAdultContentVisible = isAdultContentVisible ?: true,
|
||||||
timezone = timezone,
|
timezone = timezone
|
||||||
languageCode = languageCode
|
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -243,7 +239,6 @@ class AudioContentController(private val service: AudioContentService) {
|
|||||||
|
|
||||||
@GetMapping("/all")
|
@GetMapping("/all")
|
||||||
fun getAllContents(
|
fun getAllContents(
|
||||||
@RequestParam("languageCode", required = false) languageCode: String? = null,
|
|
||||||
@RequestParam("isAdultContentVisible", required = false) isAdultContentVisible: Boolean? = null,
|
@RequestParam("isAdultContentVisible", required = false) isAdultContentVisible: Boolean? = null,
|
||||||
@RequestParam("contentType", required = false) contentType: ContentType? = null,
|
@RequestParam("contentType", required = false) contentType: ContentType? = null,
|
||||||
@RequestParam("isFree", required = false) isFree: Boolean? = null,
|
@RequestParam("isFree", required = false) isFree: Boolean? = null,
|
||||||
@@ -264,8 +259,7 @@ class AudioContentController(private val service: AudioContentService) {
|
|||||||
sortType = sortType ?: SortType.NEWEST,
|
sortType = sortType ?: SortType.NEWEST,
|
||||||
isFree = isFree ?: false,
|
isFree = isFree ?: false,
|
||||||
isAdult = (isAdultContentVisible ?: true) && member.auth != null,
|
isAdult = (isAdultContentVisible ?: true) && member.auth != null,
|
||||||
isPointAvailableOnly = isPointAvailableOnly ?: false,
|
isPointAvailableOnly = isPointAvailableOnly ?: false
|
||||||
languageCode = languageCode
|
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import kr.co.vividnext.sodalive.content.order.OrderType
|
|||||||
import kr.co.vividnext.sodalive.content.pin.PinContent
|
import kr.co.vividnext.sodalive.content.pin.PinContent
|
||||||
import kr.co.vividnext.sodalive.content.pin.PinContentRepository
|
import kr.co.vividnext.sodalive.content.pin.PinContentRepository
|
||||||
import kr.co.vividnext.sodalive.content.theme.AudioContentThemeQueryRepository
|
import kr.co.vividnext.sodalive.content.theme.AudioContentThemeQueryRepository
|
||||||
|
import kr.co.vividnext.sodalive.content.theme.translation.ContentThemeTranslationRepository
|
||||||
import kr.co.vividnext.sodalive.content.translation.ContentTranslation
|
import kr.co.vividnext.sodalive.content.translation.ContentTranslation
|
||||||
import kr.co.vividnext.sodalive.content.translation.ContentTranslationPayload
|
import kr.co.vividnext.sodalive.content.translation.ContentTranslationPayload
|
||||||
import kr.co.vividnext.sodalive.content.translation.ContentTranslationRepository
|
import kr.co.vividnext.sodalive.content.translation.ContentTranslationRepository
|
||||||
@@ -29,6 +30,7 @@ import kr.co.vividnext.sodalive.explorer.ExplorerQueryRepository
|
|||||||
import kr.co.vividnext.sodalive.extensions.convertLocalDateTime
|
import kr.co.vividnext.sodalive.extensions.convertLocalDateTime
|
||||||
import kr.co.vividnext.sodalive.fcm.FcmEvent
|
import kr.co.vividnext.sodalive.fcm.FcmEvent
|
||||||
import kr.co.vividnext.sodalive.fcm.FcmEventType
|
import kr.co.vividnext.sodalive.fcm.FcmEventType
|
||||||
|
import kr.co.vividnext.sodalive.i18n.LangContext
|
||||||
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 kr.co.vividnext.sodalive.i18n.translation.PapagoTranslationService
|
import kr.co.vividnext.sodalive.i18n.translation.PapagoTranslationService
|
||||||
@@ -72,6 +74,10 @@ class AudioContentService(
|
|||||||
private val audioContentCloudFront: AudioContentCloudFront,
|
private val audioContentCloudFront: AudioContentCloudFront,
|
||||||
private val applicationEventPublisher: ApplicationEventPublisher,
|
private val applicationEventPublisher: ApplicationEventPublisher,
|
||||||
|
|
||||||
|
private val langContext: LangContext,
|
||||||
|
|
||||||
|
private val contentThemeTranslationRepository: ContentThemeTranslationRepository,
|
||||||
|
|
||||||
@Value("\${cloud.aws.s3.content-bucket}")
|
@Value("\${cloud.aws.s3.content-bucket}")
|
||||||
private val audioContentBucket: String,
|
private val audioContentBucket: String,
|
||||||
|
|
||||||
@@ -366,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!!)
|
||||||
}
|
}
|
||||||
@@ -526,8 +532,7 @@ class AudioContentService(
|
|||||||
id: Long,
|
id: Long,
|
||||||
member: Member,
|
member: Member,
|
||||||
isAdultContentVisible: Boolean,
|
isAdultContentVisible: Boolean,
|
||||||
timezone: String,
|
timezone: String
|
||||||
languageCode: String?
|
|
||||||
): GetAudioContentDetailResponse {
|
): GetAudioContentDetailResponse {
|
||||||
val isAdult = member.auth != null && isAdultContentVisible
|
val isAdult = member.auth != null && isAdultContentVisible
|
||||||
|
|
||||||
@@ -764,13 +769,10 @@ class AudioContentService(
|
|||||||
if (
|
if (
|
||||||
audioContent.languageCode != null &&
|
audioContent.languageCode != null &&
|
||||||
audioContent.languageCode!!.isNotBlank() &&
|
audioContent.languageCode!!.isNotBlank() &&
|
||||||
!languageCode.isNullOrBlank() &&
|
audioContent.languageCode != langContext.lang.code
|
||||||
audioContent.languageCode != languageCode
|
|
||||||
) {
|
) {
|
||||||
val locale = languageCode.lowercase()
|
|
||||||
|
|
||||||
val existing = contentTranslationRepository
|
val existing = contentTranslationRepository
|
||||||
.findByContentIdAndLocale(audioContent.id!!, locale)
|
.findByContentIdAndLocale(audioContent.id!!, langContext.lang.code)
|
||||||
|
|
||||||
if (existing != null) {
|
if (existing != null) {
|
||||||
val payload = existing.renderedPayload
|
val payload = existing.renderedPayload
|
||||||
@@ -791,7 +793,7 @@ class AudioContentService(
|
|||||||
request = TranslateRequest(
|
request = TranslateRequest(
|
||||||
texts = texts,
|
texts = texts,
|
||||||
sourceLanguage = sourceLanguage,
|
sourceLanguage = sourceLanguage,
|
||||||
targetLanguage = locale
|
targetLanguage = langContext.lang.code
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -812,7 +814,7 @@ class AudioContentService(
|
|||||||
contentTranslationRepository.save(
|
contentTranslationRepository.save(
|
||||||
ContentTranslation(
|
ContentTranslation(
|
||||||
contentId = audioContent.id!!,
|
contentId = audioContent.id!!,
|
||||||
locale = locale,
|
locale = langContext.lang.code,
|
||||||
renderedPayload = payload
|
renderedPayload = payload
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
@@ -826,6 +828,22 @@ class AudioContentService(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* themeStr 번역 처리
|
||||||
|
*/
|
||||||
|
val themeStrTranslated = run {
|
||||||
|
val theme = audioContent.theme
|
||||||
|
if (theme?.id != null) {
|
||||||
|
val locale = langContext.lang.code
|
||||||
|
val translated = contentThemeTranslationRepository
|
||||||
|
.findByContentThemeIdAndLocale(theme.id!!, locale)
|
||||||
|
val text = translated?.theme
|
||||||
|
if (!text.isNullOrBlank()) text else theme.theme
|
||||||
|
} else {
|
||||||
|
audioContent.theme!!.theme
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return GetAudioContentDetailResponse(
|
return GetAudioContentDetailResponse(
|
||||||
contentId = audioContent.id!!,
|
contentId = audioContent.id!!,
|
||||||
title = audioContent.title,
|
title = audioContent.title,
|
||||||
@@ -833,7 +851,7 @@ class AudioContentService(
|
|||||||
languageCode = audioContent.languageCode,
|
languageCode = audioContent.languageCode,
|
||||||
coverImageUrl = "$coverImageHost/${audioContent.coverImage!!}",
|
coverImageUrl = "$coverImageHost/${audioContent.coverImage!!}",
|
||||||
contentUrl = audioContentUrl,
|
contentUrl = audioContentUrl,
|
||||||
themeStr = audioContent.theme!!.theme,
|
themeStr = themeStrTranslated,
|
||||||
tag = tag,
|
tag = tag,
|
||||||
price = audioContent.price,
|
price = audioContent.price,
|
||||||
duration = audioContent.duration ?: "",
|
duration = audioContent.duration ?: "",
|
||||||
@@ -928,7 +946,6 @@ class AudioContentService(
|
|||||||
categoryId: Long = 0,
|
categoryId: Long = 0,
|
||||||
isAdultContentVisible: Boolean,
|
isAdultContentVisible: Boolean,
|
||||||
contentType: ContentType,
|
contentType: ContentType,
|
||||||
languageCode: String?,
|
|
||||||
offset: Long,
|
offset: Long,
|
||||||
limit: Long
|
limit: Long
|
||||||
): GetAudioContentListResponse {
|
): GetAudioContentListResponse {
|
||||||
@@ -982,12 +999,10 @@ class AudioContentService(
|
|||||||
it
|
it
|
||||||
}
|
}
|
||||||
|
|
||||||
val translatedContentList = if (!languageCode.isNullOrBlank()) {
|
|
||||||
val contentIds = items.map { it.contentId }
|
val contentIds = items.map { it.contentId }
|
||||||
|
val translatedContentList = if (contentIds.isNotEmpty()) {
|
||||||
if (contentIds.isNotEmpty()) {
|
|
||||||
val translations = contentTranslationRepository
|
val translations = contentTranslationRepository
|
||||||
.findByContentIdInAndLocale(contentIds = contentIds, locale = languageCode)
|
.findByContentIdInAndLocale(contentIds = contentIds, locale = langContext.lang.code)
|
||||||
.associateBy { it.contentId }
|
.associateBy { it.contentId }
|
||||||
|
|
||||||
items.map { item ->
|
items.map { item ->
|
||||||
@@ -1001,9 +1016,6 @@ class AudioContentService(
|
|||||||
} else {
|
} else {
|
||||||
items
|
items
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
items
|
|
||||||
}
|
|
||||||
|
|
||||||
return GetAudioContentListResponse(
|
return GetAudioContentListResponse(
|
||||||
totalCount = totalCount,
|
totalCount = totalCount,
|
||||||
@@ -1145,11 +1157,22 @@ class AudioContentService(
|
|||||||
isFree: Boolean = false,
|
isFree: Boolean = false,
|
||||||
isAdult: Boolean = false,
|
isAdult: Boolean = false,
|
||||||
orderByRandom: Boolean = false,
|
orderByRandom: Boolean = false,
|
||||||
isPointAvailableOnly: Boolean = false,
|
isPointAvailableOnly: Boolean = false
|
||||||
languageCode: String? = null
|
|
||||||
): List<AudioContentMainItem> {
|
): List<AudioContentMainItem> {
|
||||||
|
/**
|
||||||
|
* - AS-IS theme은 한글만 처리하도록 되어 있음
|
||||||
|
* - TO-BE 번역된 theme이 들어와도 동일한 동작을 하도록 처리
|
||||||
|
*/
|
||||||
|
val normalizedTheme = normalizeThemeForQuery(
|
||||||
|
themes = theme,
|
||||||
|
contentType = contentType,
|
||||||
|
isFree = isFree,
|
||||||
|
isAdult = isAdult,
|
||||||
|
isPointAvailableOnly = isPointAvailableOnly
|
||||||
|
)
|
||||||
|
|
||||||
val contentList = repository.getLatestContentByTheme(
|
val contentList = repository.getLatestContentByTheme(
|
||||||
theme = theme,
|
theme = normalizedTheme,
|
||||||
contentType = contentType,
|
contentType = contentType,
|
||||||
offset = offset,
|
offset = offset,
|
||||||
limit = limit,
|
limit = limit,
|
||||||
@@ -1160,12 +1183,10 @@ class AudioContentService(
|
|||||||
isPointAvailableOnly = isPointAvailableOnly
|
isPointAvailableOnly = isPointAvailableOnly
|
||||||
)
|
)
|
||||||
|
|
||||||
return if (!languageCode.isNullOrBlank()) {
|
|
||||||
val contentIds = contentList.map { it.contentId }
|
val contentIds = contentList.map { it.contentId }
|
||||||
|
return if (contentIds.isNotEmpty()) {
|
||||||
if (contentIds.isNotEmpty()) {
|
|
||||||
val translations = contentTranslationRepository
|
val translations = contentTranslationRepository
|
||||||
.findByContentIdInAndLocale(contentIds = contentIds, locale = languageCode)
|
.findByContentIdInAndLocale(contentIds = contentIds, locale = langContext.lang.code)
|
||||||
.associateBy { it.contentId }
|
.associateBy { it.contentId }
|
||||||
|
|
||||||
contentList.map { item ->
|
contentList.map { item ->
|
||||||
@@ -1179,8 +1200,61 @@ class AudioContentService(
|
|||||||
} else {
|
} else {
|
||||||
contentList
|
contentList
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* theme 파라미터로 번역된 테마명이 들어와도 한글 원문 테마로 조회되도록 정규화한다.
|
||||||
|
* - 현재 언어(locale)에 해당하는 테마 번역 목록을 활성 테마 집합과 매칭하여 역매핑한다.
|
||||||
|
* - 입력이 이미 한글인 경우 그대로 유지한다.
|
||||||
|
* - 매칭 실패 시 원본 값을 유지한다.
|
||||||
|
*/
|
||||||
|
private fun normalizeThemeForQuery(
|
||||||
|
themes: List<String>,
|
||||||
|
contentType: ContentType,
|
||||||
|
isFree: Boolean,
|
||||||
|
isAdult: Boolean,
|
||||||
|
isPointAvailableOnly: Boolean
|
||||||
|
): List<String> {
|
||||||
|
if (themes.isEmpty()) return themes
|
||||||
|
|
||||||
|
val themesWithIds = themeQueryRepository.getActiveThemeWithIdsOfContent(
|
||||||
|
isAdult = isAdult,
|
||||||
|
isFree = isFree,
|
||||||
|
isPointAvailableOnly = isPointAvailableOnly,
|
||||||
|
contentType = contentType
|
||||||
|
)
|
||||||
|
|
||||||
|
if (themesWithIds.isEmpty()) return themes
|
||||||
|
|
||||||
|
val idByKorean = themesWithIds.associate { it.theme to it.id }
|
||||||
|
val koreanById = themesWithIds.associate { it.id to it.theme }
|
||||||
|
|
||||||
|
val locale = langContext.lang.code
|
||||||
|
// 번역 테마를 역매핑하기 위해 현재 locale의 번역 목록을 조회
|
||||||
|
val translatedByTextToId = run {
|
||||||
|
val ids = themesWithIds.map { it.id }
|
||||||
|
if (ids.isEmpty()) {
|
||||||
|
emptyMap()
|
||||||
} else {
|
} else {
|
||||||
contentList
|
contentThemeTranslationRepository
|
||||||
|
.findByContentThemeIdInAndLocale(ids, locale)
|
||||||
|
.associate { it.theme to it.contentThemeId }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return themes.asSequence()
|
||||||
|
.map { input ->
|
||||||
|
when {
|
||||||
|
idByKorean.containsKey(input) -> input // 이미 한글 원문
|
||||||
|
translatedByTextToId.containsKey(input) -> {
|
||||||
|
val id = translatedByTextToId[input]!!
|
||||||
|
koreanById[id] ?: input
|
||||||
|
}
|
||||||
|
|
||||||
|
else -> input
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.distinct()
|
||||||
|
.toList()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,13 +2,16 @@ 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.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 +32,9 @@ enum class LanguageDetectTargetType {
|
|||||||
COMMENT,
|
COMMENT,
|
||||||
CHARACTER,
|
CHARACTER,
|
||||||
CHARACTER_COMMENT,
|
CHARACTER_COMMENT,
|
||||||
CREATOR_CHEERS
|
CREATOR_CHEERS,
|
||||||
|
SERIES,
|
||||||
|
ORIGINAL_WORK
|
||||||
}
|
}
|
||||||
|
|
||||||
class LanguageDetectEvent(
|
class LanguageDetectEvent(
|
||||||
@@ -49,6 +54,8 @@ 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 originalWorkRepository: OriginalWorkRepository,
|
||||||
|
|
||||||
private val applicationEventPublisher: ApplicationEventPublisher,
|
private val applicationEventPublisher: ApplicationEventPublisher,
|
||||||
|
|
||||||
@@ -80,6 +87,8 @@ 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)
|
||||||
|
LanguageDetectTargetType.ORIGINAL_WORK -> handleOriginalWorkLanguageDetect(event)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -255,6 +264,83 @@ 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 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 {
|
||||||
|
|||||||
@@ -100,7 +100,6 @@ class AudioContentMainController(
|
|||||||
@GetMapping("/new/all")
|
@GetMapping("/new/all")
|
||||||
fun getNewContentAllByTheme(
|
fun getNewContentAllByTheme(
|
||||||
@RequestParam("theme") theme: String,
|
@RequestParam("theme") theme: String,
|
||||||
@RequestParam("languageCode", required = false) languageCode: String? = null,
|
|
||||||
@RequestParam("isAdultContentVisible", required = false) isAdultContentVisible: Boolean? = null,
|
@RequestParam("isAdultContentVisible", required = false) isAdultContentVisible: Boolean? = null,
|
||||||
@RequestParam("contentType", required = false) contentType: ContentType? = null,
|
@RequestParam("contentType", required = false) contentType: ContentType? = null,
|
||||||
@AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?,
|
@AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?,
|
||||||
@@ -113,7 +112,6 @@ class AudioContentMainController(
|
|||||||
theme = theme,
|
theme = theme,
|
||||||
isAdultContentVisible = isAdultContentVisible ?: true,
|
isAdultContentVisible = isAdultContentVisible ?: true,
|
||||||
contentType = contentType ?: ContentType.ALL,
|
contentType = contentType ?: ContentType.ALL,
|
||||||
languageCode = languageCode,
|
|
||||||
member = member,
|
member = member,
|
||||||
pageable = pageable
|
pageable = pageable
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -6,8 +6,11 @@ import kr.co.vividnext.sodalive.content.main.banner.AudioContentBannerType
|
|||||||
import kr.co.vividnext.sodalive.content.main.banner.GetAudioContentBannerResponse
|
import kr.co.vividnext.sodalive.content.main.banner.GetAudioContentBannerResponse
|
||||||
import kr.co.vividnext.sodalive.content.main.curation.GetAudioContentCurationResponse
|
import kr.co.vividnext.sodalive.content.main.curation.GetAudioContentCurationResponse
|
||||||
import kr.co.vividnext.sodalive.content.theme.AudioContentThemeQueryRepository
|
import kr.co.vividnext.sodalive.content.theme.AudioContentThemeQueryRepository
|
||||||
|
import kr.co.vividnext.sodalive.content.theme.AudioContentThemeService
|
||||||
|
import kr.co.vividnext.sodalive.content.theme.translation.ContentThemeTranslationRepository
|
||||||
import kr.co.vividnext.sodalive.content.translation.ContentTranslationRepository
|
import kr.co.vividnext.sodalive.content.translation.ContentTranslationRepository
|
||||||
import kr.co.vividnext.sodalive.event.EventItem
|
import kr.co.vividnext.sodalive.event.EventItem
|
||||||
|
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
|
||||||
@@ -21,16 +24,33 @@ class AudioContentMainService(
|
|||||||
private val repository: AudioContentRepository,
|
private val repository: AudioContentRepository,
|
||||||
private val blockMemberRepository: BlockMemberRepository,
|
private val blockMemberRepository: BlockMemberRepository,
|
||||||
private val audioContentThemeRepository: AudioContentThemeQueryRepository,
|
private val audioContentThemeRepository: AudioContentThemeQueryRepository,
|
||||||
|
private val audioContentThemeService: AudioContentThemeService,
|
||||||
|
|
||||||
|
private val contentThemeTranslationRepository: ContentThemeTranslationRepository,
|
||||||
|
|
||||||
private val contentTranslationRepository: ContentTranslationRepository,
|
private val contentTranslationRepository: ContentTranslationRepository,
|
||||||
|
|
||||||
|
private val langContext: LangContext,
|
||||||
|
|
||||||
@Value("\${cloud.aws.cloud-front.host}")
|
@Value("\${cloud.aws.cloud-front.host}")
|
||||||
private val imageHost: String
|
private val imageHost: String
|
||||||
) {
|
) {
|
||||||
@Transactional(readOnly = true)
|
@Transactional(readOnly = true)
|
||||||
@Cacheable(cacheNames = ["default"], key = "'themeList:' + ':' + #isAdult")
|
|
||||||
fun getThemeList(isAdult: Boolean, contentType: ContentType): List<String> {
|
fun getThemeList(isAdult: Boolean, contentType: ContentType): List<String> {
|
||||||
return audioContentThemeRepository.getActiveThemeOfContent(isAdult = isAdult, contentType = contentType)
|
/**
|
||||||
|
* 콘텐츠 테마 조회
|
||||||
|
*
|
||||||
|
* - langContext에 따라 기본 한국어 데이터 혹은 번역된 콘텐츠 테마를 조회해야 함
|
||||||
|
*
|
||||||
|
* - 번역된 테마 데이터가 없다면 번역하여 반환
|
||||||
|
* - 번역된 데이터가 있다면 번역된 데이터를 조회하여 반환
|
||||||
|
*/
|
||||||
|
// 표시용 테마 목록은 언어 컨텍스트에 따라 번역된 값을 반환해야 한다.
|
||||||
|
// AudioContentThemeService가 번역/저장을 처리하므로 이를 사용한다.
|
||||||
|
return audioContentThemeService.getActiveThemeOfContent(
|
||||||
|
isAdult = isAdult,
|
||||||
|
contentType = contentType
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Transactional(readOnly = true)
|
@Transactional(readOnly = true)
|
||||||
@@ -60,12 +80,15 @@ class AudioContentMainService(
|
|||||||
theme: String,
|
theme: String,
|
||||||
isAdultContentVisible: Boolean,
|
isAdultContentVisible: Boolean,
|
||||||
contentType: ContentType,
|
contentType: ContentType,
|
||||||
languageCode: String?,
|
|
||||||
member: Member,
|
member: Member,
|
||||||
pageable: Pageable
|
pageable: Pageable
|
||||||
): GetNewContentAllResponse {
|
): GetNewContentAllResponse {
|
||||||
|
/**
|
||||||
|
* - AS-IS theme은 한글만 처리하도록 되어 있음
|
||||||
|
* - TO-BE 번역된 theme이 들어와도 동일한 동작을 하도록 처리
|
||||||
|
*/
|
||||||
val isAdult = member.auth != null && isAdultContentVisible
|
val isAdult = member.auth != null && isAdultContentVisible
|
||||||
val themeList = if (theme.isBlank()) {
|
val themeListRaw = if (theme.isBlank()) {
|
||||||
audioContentThemeRepository.getActiveThemeOfContent(
|
audioContentThemeRepository.getActiveThemeOfContent(
|
||||||
isAdult = isAdult,
|
isAdult = isAdult,
|
||||||
contentType = contentType
|
contentType = contentType
|
||||||
@@ -74,6 +97,12 @@ class AudioContentMainService(
|
|||||||
listOf(theme)
|
listOf(theme)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
val themeList = normalizeThemeForQuery(
|
||||||
|
themes = themeListRaw,
|
||||||
|
contentType = contentType,
|
||||||
|
isAdult = isAdult
|
||||||
|
)
|
||||||
|
|
||||||
val totalCount = repository.totalCountNewContentFor2Weeks(
|
val totalCount = repository.totalCountNewContentFor2Weeks(
|
||||||
themeList,
|
themeList,
|
||||||
memberId = member.id!!,
|
memberId = member.id!!,
|
||||||
@@ -91,12 +120,10 @@ class AudioContentMainService(
|
|||||||
)
|
)
|
||||||
.filter { !blockMemberRepository.isBlocked(blockedMemberId = member.id!!, memberId = it.creatorId) }
|
.filter { !blockMemberRepository.isBlocked(blockedMemberId = member.id!!, memberId = it.creatorId) }
|
||||||
|
|
||||||
val translatedContentList = if (!languageCode.isNullOrBlank()) {
|
|
||||||
val contentIds = contentList.map { it.contentId }
|
val contentIds = contentList.map { it.contentId }
|
||||||
|
val translatedContentList = if (contentIds.isNotEmpty()) {
|
||||||
if (contentIds.isNotEmpty()) {
|
|
||||||
val translations = contentTranslationRepository
|
val translations = contentTranslationRepository
|
||||||
.findByContentIdInAndLocale(contentIds = contentIds, locale = languageCode)
|
.findByContentIdInAndLocale(contentIds = contentIds, locale = langContext.lang.code)
|
||||||
.associateBy { it.contentId }
|
.associateBy { it.contentId }
|
||||||
|
|
||||||
contentList.map { item ->
|
contentList.map { item ->
|
||||||
@@ -110,13 +137,60 @@ class AudioContentMainService(
|
|||||||
} else {
|
} else {
|
||||||
contentList
|
contentList
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
contentList
|
|
||||||
}
|
|
||||||
|
|
||||||
return GetNewContentAllResponse(totalCount, translatedContentList)
|
return GetNewContentAllResponse(totalCount, translatedContentList)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 번역된 테마명이 들어와도 한글 원문 테마로 조회되도록 정규화한다.
|
||||||
|
*/
|
||||||
|
private fun normalizeThemeForQuery(
|
||||||
|
themes: List<String>,
|
||||||
|
contentType: ContentType,
|
||||||
|
isAdult: Boolean
|
||||||
|
): List<String> {
|
||||||
|
if (themes.isEmpty()) return themes
|
||||||
|
|
||||||
|
val themesWithIds = audioContentThemeRepository.getActiveThemeWithIdsOfContent(
|
||||||
|
isAdult = isAdult,
|
||||||
|
isFree = false,
|
||||||
|
isPointAvailableOnly = false,
|
||||||
|
contentType = contentType
|
||||||
|
)
|
||||||
|
|
||||||
|
if (themesWithIds.isEmpty()) return themes
|
||||||
|
|
||||||
|
val idByKorean = themesWithIds.associate { it.theme to it.id }
|
||||||
|
val koreanById = themesWithIds.associate { it.id to it.theme }
|
||||||
|
|
||||||
|
val locale = langContext.lang.code
|
||||||
|
val translatedByTextToId = run {
|
||||||
|
val ids = themesWithIds.map { it.id }
|
||||||
|
if (ids.isEmpty()) {
|
||||||
|
emptyMap()
|
||||||
|
} else {
|
||||||
|
contentThemeTranslationRepository
|
||||||
|
.findByContentThemeIdInAndLocale(ids, locale)
|
||||||
|
.associate { it.theme to it.contentThemeId }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return themes.asSequence()
|
||||||
|
.map { input ->
|
||||||
|
when {
|
||||||
|
idByKorean.containsKey(input) -> input
|
||||||
|
translatedByTextToId.containsKey(input) -> {
|
||||||
|
val id = translatedByTextToId[input]!!
|
||||||
|
koreanById[id] ?: input
|
||||||
|
}
|
||||||
|
|
||||||
|
else -> input
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.distinct()
|
||||||
|
.toList()
|
||||||
|
}
|
||||||
|
|
||||||
@Transactional(readOnly = true)
|
@Transactional(readOnly = true)
|
||||||
@Cacheable(cacheNames = ["default"], key = "'newContentUploadCreatorList:' + #memberId + ':' + #isAdult")
|
@Cacheable(cacheNames = ["default"], key = "'newContentUploadCreatorList:' + #memberId + ':' + #isAdult")
|
||||||
fun getNewContentUploadCreatorList(memberId: Long, isAdult: Boolean): List<ContentCreatorResponse> {
|
fun getNewContentUploadCreatorList(memberId: Long, isAdult: Boolean): List<ContentCreatorResponse> {
|
||||||
|
|||||||
@@ -6,15 +6,26 @@ 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.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
|
||||||
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.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
|
||||||
@@ -27,6 +38,13 @@ class ContentSeriesService(
|
|||||||
private val explorerQueryRepository: ExplorerQueryRepository,
|
private val explorerQueryRepository: ExplorerQueryRepository,
|
||||||
private val seriesContentRepository: ContentSeriesContentRepository,
|
private val seriesContentRepository: ContentSeriesContentRepository,
|
||||||
|
|
||||||
|
private val langContext: LangContext,
|
||||||
|
|
||||||
|
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
|
||||||
) {
|
) {
|
||||||
@@ -42,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(
|
||||||
@@ -83,7 +167,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(
|
||||||
@@ -112,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,
|
||||||
@@ -156,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,
|
||||||
@@ -171,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,
|
||||||
@@ -186,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
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -228,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(
|
||||||
@@ -243,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(
|
||||||
@@ -258,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(
|
||||||
@@ -288,7 +516,7 @@ class ContentSeriesService(
|
|||||||
seriesList
|
seriesList
|
||||||
}
|
}
|
||||||
|
|
||||||
return seriesToSeriesListItem(seriesList, isAdult, contentType)
|
return getTranslatedSeriesList(seriesToSeriesListItem(seriesList, isAdult, contentType))
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun seriesToSeriesListItem(
|
private fun seriesToSeriesListItem(
|
||||||
@@ -338,27 +566,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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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()
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
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?
|
||||||
|
|
||||||
|
fun findBySeriesGenreIdInAndLocale(seriesGenreIds: List<Long>, locale: String): List<SeriesGenreTranslation>
|
||||||
|
}
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
data class TranslatedSeries(
|
||||||
|
val title: String,
|
||||||
|
val introduction: String,
|
||||||
|
val keywords: List<String>
|
||||||
|
)
|
||||||
@@ -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>
|
||||||
|
}
|
||||||
@@ -7,7 +7,7 @@ import javax.persistence.Table
|
|||||||
|
|
||||||
@Entity
|
@Entity
|
||||||
@Table(name = "content_theme")
|
@Table(name = "content_theme")
|
||||||
data class AudioContentTheme(
|
class AudioContentTheme(
|
||||||
@Column(nullable = false)
|
@Column(nullable = false)
|
||||||
var theme: String,
|
var theme: String,
|
||||||
@Column(nullable = false)
|
@Column(nullable = false)
|
||||||
|
|||||||
@@ -15,6 +15,10 @@ class AudioContentThemeQueryRepository(
|
|||||||
@Value("\${cloud.aws.cloud-front.host}")
|
@Value("\${cloud.aws.cloud-front.host}")
|
||||||
private val cloudFrontHost: String
|
private val cloudFrontHost: String
|
||||||
) {
|
) {
|
||||||
|
data class ThemeIdAndName(
|
||||||
|
val id: Long,
|
||||||
|
val theme: String
|
||||||
|
)
|
||||||
fun getActiveThemes(): List<GetAudioContentThemeResponse> {
|
fun getActiveThemes(): List<GetAudioContentThemeResponse> {
|
||||||
return queryFactory
|
return queryFactory
|
||||||
.select(
|
.select(
|
||||||
@@ -88,6 +92,69 @@ class AudioContentThemeQueryRepository(
|
|||||||
return query.fetch()
|
return query.fetch()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun getActiveThemeWithIdsOfContent(
|
||||||
|
isAdult: Boolean = false,
|
||||||
|
isFree: Boolean = false,
|
||||||
|
isPointAvailableOnly: Boolean = false,
|
||||||
|
contentType: ContentType
|
||||||
|
): List<ThemeIdAndName> {
|
||||||
|
var where = audioContent.isActive.isTrue
|
||||||
|
.and(audioContentTheme.isActive.isTrue)
|
||||||
|
|
||||||
|
if (!isAdult) {
|
||||||
|
where = where.and(audioContent.isAdult.isFalse)
|
||||||
|
} else {
|
||||||
|
if (contentType != ContentType.ALL) {
|
||||||
|
where = where.and(
|
||||||
|
audioContent.member.isNull.or(
|
||||||
|
audioContent.member.auth.gender.eq(
|
||||||
|
if (contentType == ContentType.MALE) {
|
||||||
|
0
|
||||||
|
} else {
|
||||||
|
1
|
||||||
|
}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isFree) {
|
||||||
|
where = where.and(audioContent.price.loe(0))
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isPointAvailableOnly) {
|
||||||
|
where = where.and(audioContent.isPointAvailable.isTrue)
|
||||||
|
}
|
||||||
|
|
||||||
|
val query = queryFactory
|
||||||
|
.select(audioContentTheme.id, audioContentTheme.theme)
|
||||||
|
.from(audioContent)
|
||||||
|
.innerJoin(audioContent.member, member)
|
||||||
|
.innerJoin(audioContent.theme, audioContentTheme)
|
||||||
|
.where(where)
|
||||||
|
.groupBy(audioContentTheme.id)
|
||||||
|
|
||||||
|
if (isFree) {
|
||||||
|
query.orderBy(
|
||||||
|
CaseBuilder()
|
||||||
|
.`when`(audioContentTheme.theme.eq("자기소개")).then(0)
|
||||||
|
.otherwise(1)
|
||||||
|
.asc(),
|
||||||
|
audioContentTheme.orders.asc()
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
query.orderBy(audioContentTheme.orders.asc())
|
||||||
|
}
|
||||||
|
|
||||||
|
return query.fetch().map { tuple ->
|
||||||
|
ThemeIdAndName(
|
||||||
|
id = tuple.get(audioContentTheme.id)!!,
|
||||||
|
theme = tuple.get(audioContentTheme.theme)!!
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fun findThemeByIdAndActive(id: Long): AudioContentTheme? {
|
fun findThemeByIdAndActive(id: Long): AudioContentTheme? {
|
||||||
return queryFactory
|
return queryFactory
|
||||||
.selectFrom(audioContentTheme)
|
.selectFrom(audioContentTheme)
|
||||||
|
|||||||
@@ -5,6 +5,12 @@ import kr.co.vividnext.sodalive.content.AudioContentRepository
|
|||||||
import kr.co.vividnext.sodalive.content.ContentType
|
import kr.co.vividnext.sodalive.content.ContentType
|
||||||
import kr.co.vividnext.sodalive.content.SortType
|
import kr.co.vividnext.sodalive.content.SortType
|
||||||
import kr.co.vividnext.sodalive.content.theme.content.GetContentByThemeResponse
|
import kr.co.vividnext.sodalive.content.theme.content.GetContentByThemeResponse
|
||||||
|
import kr.co.vividnext.sodalive.content.theme.translation.ContentThemeTranslation
|
||||||
|
import kr.co.vividnext.sodalive.content.theme.translation.ContentThemeTranslationRepository
|
||||||
|
import kr.co.vividnext.sodalive.i18n.Lang
|
||||||
|
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 org.springframework.stereotype.Service
|
import org.springframework.stereotype.Service
|
||||||
import org.springframework.transaction.annotation.Transactional
|
import org.springframework.transaction.annotation.Transactional
|
||||||
@@ -12,26 +18,94 @@ import org.springframework.transaction.annotation.Transactional
|
|||||||
@Service
|
@Service
|
||||||
class AudioContentThemeService(
|
class AudioContentThemeService(
|
||||||
private val queryRepository: AudioContentThemeQueryRepository,
|
private val queryRepository: AudioContentThemeQueryRepository,
|
||||||
private val contentRepository: AudioContentRepository
|
private val contentRepository: AudioContentRepository,
|
||||||
|
private val contentThemeTranslationRepository: ContentThemeTranslationRepository,
|
||||||
|
|
||||||
|
private val papagoTranslationService: PapagoTranslationService,
|
||||||
|
private val langContext: LangContext
|
||||||
) {
|
) {
|
||||||
@Transactional(readOnly = true)
|
@Transactional(readOnly = true)
|
||||||
fun getThemes(): List<GetAudioContentThemeResponse> {
|
fun getThemes(): List<GetAudioContentThemeResponse> {
|
||||||
return queryRepository.getActiveThemes()
|
return queryRepository.getActiveThemes()
|
||||||
}
|
}
|
||||||
|
|
||||||
@Transactional(readOnly = true)
|
@Transactional
|
||||||
fun getActiveThemeOfContent(
|
fun getActiveThemeOfContent(
|
||||||
isAdult: Boolean = false,
|
isAdult: Boolean = false,
|
||||||
isFree: Boolean = false,
|
isFree: Boolean = false,
|
||||||
isPointAvailableOnly: Boolean = false,
|
isPointAvailableOnly: Boolean = false,
|
||||||
contentType: ContentType
|
contentType: ContentType
|
||||||
): List<String> {
|
): List<String> {
|
||||||
return queryRepository.getActiveThemeOfContent(
|
val themesWithIds = queryRepository.getActiveThemeWithIdsOfContent(
|
||||||
isAdult = isAdult,
|
isAdult = isAdult,
|
||||||
isFree = isFree,
|
isFree = isFree,
|
||||||
isPointAvailableOnly = isPointAvailableOnly,
|
isPointAvailableOnly = isPointAvailableOnly,
|
||||||
contentType = contentType
|
contentType = contentType
|
||||||
)
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* langContext.lang == Lang.EN || Lang.JA 일 때 번역된 콘텐츠 테마 반환
|
||||||
|
* 번역이 없으면 번역 API 호출 후 저장하고 반환
|
||||||
|
*/
|
||||||
|
val currentLang = langContext.lang
|
||||||
|
if (currentLang == Lang.EN || currentLang == Lang.JA) {
|
||||||
|
val targetLocale = currentLang.code
|
||||||
|
// 1) 기존 번역을 한 번에 조회
|
||||||
|
val ids = themesWithIds.map { it.id }
|
||||||
|
val existingTranslations = if (ids.isNotEmpty()) {
|
||||||
|
contentThemeTranslationRepository.findByContentThemeIdInAndLocale(ids, targetLocale)
|
||||||
|
} else {
|
||||||
|
emptyList()
|
||||||
|
}
|
||||||
|
|
||||||
|
val existingMap = existingTranslations.associateBy { it.contentThemeId }
|
||||||
|
|
||||||
|
// 2) 미번역 항목만 수집하여 한 번에 번역 요청
|
||||||
|
val untranslatedPairs = themesWithIds.filter { existingMap[it.id] == null }
|
||||||
|
|
||||||
|
if (untranslatedPairs.isNotEmpty()) {
|
||||||
|
val texts = untranslatedPairs.map { it.theme }
|
||||||
|
|
||||||
|
val response = papagoTranslationService.translate(
|
||||||
|
request = TranslateRequest(
|
||||||
|
texts = texts,
|
||||||
|
sourceLanguage = "ko",
|
||||||
|
targetLanguage = targetLocale
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
val translatedTexts = response.translatedText
|
||||||
|
val entitiesToSave = mutableListOf<ContentThemeTranslation>()
|
||||||
|
|
||||||
|
// translatedTexts 크기가 다르면 안전하게 원문으로 대체
|
||||||
|
untranslatedPairs.forEachIndexed { index, pair ->
|
||||||
|
val translated = translatedTexts.getOrNull(index) ?: pair.theme
|
||||||
|
entitiesToSave.add(
|
||||||
|
ContentThemeTranslation(
|
||||||
|
contentThemeId = pair.id,
|
||||||
|
locale = targetLocale,
|
||||||
|
theme = translated
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (entitiesToSave.isNotEmpty()) {
|
||||||
|
contentThemeTranslationRepository.saveAll(entitiesToSave)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 저장 후 맵을 갱신
|
||||||
|
entitiesToSave.forEach { entity ->
|
||||||
|
(existingMap as MutableMap)[entity.contentThemeId] = entity
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3) 원래 순서대로 결과 조립 (번역 없으면 원문 fallback)
|
||||||
|
return themesWithIds.map { pair ->
|
||||||
|
existingMap[pair.id]?.theme ?: pair.theme
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return themesWithIds.map { it.theme }
|
||||||
}
|
}
|
||||||
|
|
||||||
@Transactional(readOnly = true)
|
@Transactional(readOnly = true)
|
||||||
|
|||||||
@@ -0,0 +1,11 @@
|
|||||||
|
package kr.co.vividnext.sodalive.content.theme.translation
|
||||||
|
|
||||||
|
import kr.co.vividnext.sodalive.common.BaseEntity
|
||||||
|
import javax.persistence.Entity
|
||||||
|
|
||||||
|
@Entity
|
||||||
|
class ContentThemeTranslation(
|
||||||
|
val contentThemeId: Long,
|
||||||
|
val locale: String,
|
||||||
|
var theme: String
|
||||||
|
) : BaseEntity()
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
package kr.co.vividnext.sodalive.content.theme.translation
|
||||||
|
|
||||||
|
import org.springframework.data.jpa.repository.JpaRepository
|
||||||
|
|
||||||
|
interface ContentThemeTranslationRepository : JpaRepository<ContentThemeTranslation, Long> {
|
||||||
|
fun findByContentThemeIdAndLocale(contentThemeId: Long, locale: String): ContentThemeTranslation?
|
||||||
|
|
||||||
|
fun findByContentThemeIdInAndLocale(contentThemeIds: Collection<Long>, locale: String): List<ContentThemeTranslation>
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
)
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -52,7 +52,6 @@ class ExplorerController(private val service: ExplorerService) {
|
|||||||
fun getCreatorProfile(
|
fun getCreatorProfile(
|
||||||
@PathVariable("id") creatorId: Long,
|
@PathVariable("id") creatorId: Long,
|
||||||
@RequestParam timezone: String,
|
@RequestParam timezone: String,
|
||||||
@RequestParam("languageCode", required = false) languageCode: String? = null,
|
|
||||||
@RequestParam("isAdultContentVisible", required = false) isAdultContentVisible: Boolean? = null,
|
@RequestParam("isAdultContentVisible", required = false) isAdultContentVisible: Boolean? = null,
|
||||||
@AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?
|
@AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?
|
||||||
) = run {
|
) = run {
|
||||||
@@ -61,7 +60,6 @@ class ExplorerController(private val service: ExplorerService) {
|
|||||||
service.getCreatorProfile(
|
service.getCreatorProfile(
|
||||||
creatorId = creatorId,
|
creatorId = creatorId,
|
||||||
timezone = timezone,
|
timezone = timezone,
|
||||||
languageCode = languageCode,
|
|
||||||
isAdultContentVisible = isAdultContentVisible ?: true,
|
isAdultContentVisible = isAdultContentVisible ?: true,
|
||||||
member = member
|
member = member
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import kr.co.vividnext.sodalive.explorer.profile.PutWriteCheersRequest
|
|||||||
import kr.co.vividnext.sodalive.explorer.profile.creatorCommunity.CreatorCommunityService
|
import kr.co.vividnext.sodalive.explorer.profile.creatorCommunity.CreatorCommunityService
|
||||||
import kr.co.vividnext.sodalive.fcm.FcmEvent
|
import kr.co.vividnext.sodalive.fcm.FcmEvent
|
||||||
import kr.co.vividnext.sodalive.fcm.FcmEventType
|
import kr.co.vividnext.sodalive.fcm.FcmEventType
|
||||||
|
import kr.co.vividnext.sodalive.i18n.LangContext
|
||||||
import kr.co.vividnext.sodalive.live.room.detail.GetRoomDetailUser
|
import kr.co.vividnext.sodalive.live.room.detail.GetRoomDetailUser
|
||||||
import kr.co.vividnext.sodalive.member.Member
|
import kr.co.vividnext.sodalive.member.Member
|
||||||
import kr.co.vividnext.sodalive.member.MemberRole
|
import kr.co.vividnext.sodalive.member.MemberRole
|
||||||
@@ -49,6 +50,8 @@ class ExplorerService(
|
|||||||
private val applicationEventPublisher: ApplicationEventPublisher,
|
private val applicationEventPublisher: ApplicationEventPublisher,
|
||||||
private val contentTranslationRepository: ContentTranslationRepository,
|
private val contentTranslationRepository: ContentTranslationRepository,
|
||||||
|
|
||||||
|
private val langContext: LangContext,
|
||||||
|
|
||||||
@Value("\${cloud.aws.cloud-front.host}")
|
@Value("\${cloud.aws.cloud-front.host}")
|
||||||
private val cloudFrontHost: String
|
private val cloudFrontHost: String
|
||||||
) {
|
) {
|
||||||
@@ -172,7 +175,6 @@ class ExplorerService(
|
|||||||
fun getCreatorProfile(
|
fun getCreatorProfile(
|
||||||
creatorId: Long,
|
creatorId: Long,
|
||||||
timezone: String,
|
timezone: String,
|
||||||
languageCode: String?,
|
|
||||||
isAdultContentVisible: Boolean,
|
isAdultContentVisible: Boolean,
|
||||||
member: Member
|
member: Member
|
||||||
): GetCreatorProfileResponse {
|
): GetCreatorProfileResponse {
|
||||||
@@ -227,7 +229,6 @@ class ExplorerService(
|
|||||||
sortType = SortType.NEWEST,
|
sortType = SortType.NEWEST,
|
||||||
isAdultContentVisible = isAdultContentVisible,
|
isAdultContentVisible = isAdultContentVisible,
|
||||||
contentType = ContentType.ALL,
|
contentType = ContentType.ALL,
|
||||||
languageCode = null,
|
|
||||||
member = member,
|
member = member,
|
||||||
offset = 0,
|
offset = 0,
|
||||||
limit = 3
|
limit = 3
|
||||||
@@ -236,12 +237,10 @@ class ExplorerService(
|
|||||||
listOf()
|
listOf()
|
||||||
}
|
}
|
||||||
|
|
||||||
val translatedContentList = if (!languageCode.isNullOrBlank() && contentList.isNotEmpty()) {
|
|
||||||
val contentIds = contentList.map { it.contentId }
|
val contentIds = contentList.map { it.contentId }
|
||||||
|
val translatedContentList = if (contentIds.isNotEmpty()) {
|
||||||
if (contentIds.isNotEmpty()) {
|
|
||||||
val translations = contentTranslationRepository
|
val translations = contentTranslationRepository
|
||||||
.findByContentIdInAndLocale(contentIds = contentIds, locale = languageCode)
|
.findByContentIdInAndLocale(contentIds = contentIds, locale = langContext.lang.code)
|
||||||
.associateBy { it.contentId }
|
.associateBy { it.contentId }
|
||||||
|
|
||||||
contentList.map { item ->
|
contentList.map { item ->
|
||||||
@@ -255,9 +254,6 @@ class ExplorerService(
|
|||||||
} else {
|
} else {
|
||||||
contentList
|
contentList
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
contentList
|
|
||||||
}
|
|
||||||
|
|
||||||
// 크리에이터의 최신 오디오 콘텐츠 1개
|
// 크리에이터의 최신 오디오 콘텐츠 1개
|
||||||
val latestContent = if (isCreator) {
|
val latestContent = if (isCreator) {
|
||||||
|
|||||||
@@ -1,12 +1,26 @@
|
|||||||
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
|
||||||
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.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.translation.ContentThemeTranslation
|
||||||
|
import kr.co.vividnext.sodalive.content.theme.translation.ContentThemeTranslationRepository
|
||||||
import kr.co.vividnext.sodalive.content.translation.ContentTranslation
|
import kr.co.vividnext.sodalive.content.translation.ContentTranslation
|
||||||
import kr.co.vividnext.sodalive.content.translation.ContentTranslationPayload
|
import kr.co.vividnext.sodalive.content.translation.ContentTranslationPayload
|
||||||
import kr.co.vividnext.sodalive.content.translation.ContentTranslationRepository
|
import kr.co.vividnext.sodalive.content.translation.ContentTranslationRepository
|
||||||
@@ -21,7 +35,13 @@ import org.springframework.transaction.event.TransactionalEventListener
|
|||||||
|
|
||||||
enum class LanguageTranslationTargetType {
|
enum class LanguageTranslationTargetType {
|
||||||
CONTENT,
|
CONTENT,
|
||||||
CHARACTER
|
CHARACTER,
|
||||||
|
CONTENT_THEME,
|
||||||
|
|
||||||
|
SERIES,
|
||||||
|
SERIES_GENRE,
|
||||||
|
|
||||||
|
ORIGINAL_WORK
|
||||||
}
|
}
|
||||||
|
|
||||||
class LanguageTranslationEvent(
|
class LanguageTranslationEvent(
|
||||||
@@ -33,9 +53,17 @@ class LanguageTranslationEvent(
|
|||||||
class LanguageTranslationListener(
|
class LanguageTranslationListener(
|
||||||
private val audioContentRepository: AudioContentRepository,
|
private val audioContentRepository: AudioContentRepository,
|
||||||
private val chatCharacterRepository: ChatCharacterRepository,
|
private val chatCharacterRepository: ChatCharacterRepository,
|
||||||
|
private val audioContentThemeRepository: AudioContentThemeQueryRepository,
|
||||||
|
private val seriesRepository: AdminContentSeriesRepository,
|
||||||
|
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 seriesTranslationRepository: SeriesTranslationRepository,
|
||||||
|
private val seriesGenreTranslationRepository: SeriesGenreTranslationRepository,
|
||||||
|
private val originalWorkTranslationRepository: OriginalWorkTranslationRepository,
|
||||||
|
|
||||||
private val translationService: PapagoTranslationService
|
private val translationService: PapagoTranslationService
|
||||||
) {
|
) {
|
||||||
@@ -46,6 +74,10 @@ class LanguageTranslationListener(
|
|||||||
when (event.targetType) {
|
when (event.targetType) {
|
||||||
LanguageTranslationTargetType.CONTENT -> handleContentLanguageTranslation(event)
|
LanguageTranslationTargetType.CONTENT -> handleContentLanguageTranslation(event)
|
||||||
LanguageTranslationTargetType.CHARACTER -> handleCharacterLanguageTranslation(event)
|
LanguageTranslationTargetType.CHARACTER -> handleCharacterLanguageTranslation(event)
|
||||||
|
LanguageTranslationTargetType.CONTENT_THEME -> handleContentThemeLanguageTranslation(event)
|
||||||
|
LanguageTranslationTargetType.SERIES -> handleSeriesLanguageTranslation(event)
|
||||||
|
LanguageTranslationTargetType.SERIES_GENRE -> handleSeriesGenreLanguageTranslation(event)
|
||||||
|
LanguageTranslationTargetType.ORIGINAL_WORK -> handleOriginalWorkLanguageTranslation(event)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -202,4 +234,223 @@ class LanguageTranslationListener(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun handleContentThemeLanguageTranslation(event: LanguageTranslationEvent) {
|
||||||
|
val contentTheme = audioContentThemeRepository.findThemeByIdAndActive(event.id) ?: return
|
||||||
|
|
||||||
|
val sourceLanguage = "ko"
|
||||||
|
getTranslatableLanguageCodes(sourceLanguage).forEach { locale ->
|
||||||
|
val texts = mutableListOf<String>()
|
||||||
|
texts.add(contentTheme.theme)
|
||||||
|
|
||||||
|
val response = translationService.translate(
|
||||||
|
request = TranslateRequest(
|
||||||
|
texts = texts,
|
||||||
|
sourceLanguage = sourceLanguage,
|
||||||
|
targetLanguage = locale
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
val translatedTexts = response.translatedText
|
||||||
|
if (translatedTexts.size == texts.size) {
|
||||||
|
val translatedTheme = translatedTexts[0]
|
||||||
|
|
||||||
|
val existing = contentThemeTranslationRepository
|
||||||
|
.findByContentThemeIdAndLocale(contentTheme.id!!, locale)
|
||||||
|
|
||||||
|
if (existing == null) {
|
||||||
|
contentThemeTranslationRepository.save(
|
||||||
|
ContentThemeTranslation(
|
||||||
|
contentThemeId = contentTheme.id!!,
|
||||||
|
locale = locale,
|
||||||
|
theme = translatedTheme
|
||||||
|
)
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
existing.theme = translatedTheme
|
||||||
|
contentThemeTranslationRepository.save(existing)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
Reference in New Issue
Block a user