Compare commits
45 Commits
main
...
dc0df81232
| Author | SHA1 | Date | |
|---|---|---|---|
| dc0df81232 | |||
| c0c61da44b | |||
| 13029ab8d2 | |||
| 6f0619e482 | |||
| 920a866ae0 | |||
| de60a70733 | |||
| 59949e5aee | |||
| 165640201f | |||
| ba1844a6c2 | |||
| 082f255773 | |||
| 04281817a5 | |||
| 236394e148 | |||
| 7ab25470b6 | |||
| 8fec60db11 | |||
| 5d925e98e0 | |||
| 2355aa7c75 | |||
| 5bdb6d20a5 | |||
| 143ba2fbb2 | |||
| 28fbdd7826 | |||
| 25169aaac3 | |||
| 608898eb0c | |||
| 1748b26318 | |||
| 3ff38bb73a | |||
| 4498af4509 | |||
| 8636a8cac0 | |||
| 304c001a27 | |||
| fdac55ebdf | |||
| 668d4f28cd | |||
| 7b0644cb66 | |||
| 503802bcce | |||
| 899f2865b3 | |||
| e0dcbd16fc | |||
| 62ec994069 | |||
| 8ec6d50dd8 | |||
| ddd46d585e | |||
| c5fa260a0d | |||
| 412c52e754 | |||
| 8f4544ad71 | |||
| 619ceeea24 | |||
| a2998002e5 | |||
| da9b89a6cf | |||
| 5ee5107364 | |||
| ae2c699748 | |||
| 93ccb666c4 | |||
| edaea84a5b |
@@ -13,8 +13,13 @@ import kr.co.vividnext.sodalive.chat.character.CharacterType
|
|||||||
import kr.co.vividnext.sodalive.chat.character.service.ChatCharacterService
|
import kr.co.vividnext.sodalive.chat.character.service.ChatCharacterService
|
||||||
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.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 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.http.HttpEntity
|
import org.springframework.http.HttpEntity
|
||||||
import org.springframework.http.HttpHeaders
|
import org.springframework.http.HttpHeaders
|
||||||
import org.springframework.http.HttpMethod
|
import org.springframework.http.HttpMethod
|
||||||
@@ -40,6 +45,7 @@ class AdminChatCharacterController(
|
|||||||
private val adminService: AdminChatCharacterService,
|
private val adminService: AdminChatCharacterService,
|
||||||
private val s3Uploader: S3Uploader,
|
private val s3Uploader: S3Uploader,
|
||||||
private val originalWorkService: AdminOriginalWorkService,
|
private val originalWorkService: AdminOriginalWorkService,
|
||||||
|
private val applicationEventPublisher: ApplicationEventPublisher,
|
||||||
|
|
||||||
@Value("\${weraser.api-key}")
|
@Value("\${weraser.api-key}")
|
||||||
private val apiKey: String,
|
private val apiKey: String,
|
||||||
@@ -165,6 +171,18 @@ class AdminChatCharacterController(
|
|||||||
originalWorkService.assignOneCharacter(request.originalWorkId, chatCharacter.id!!)
|
originalWorkService.assignOneCharacter(request.originalWorkId, chatCharacter.id!!)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 5. 언어 코드가 지정되지 않은 경우, 파파고 언어 감지 API를 통해 비동기로 언어를 식별한다.
|
||||||
|
// 언어 감지에 사용할 내용은 chatCharacter.description 만 사용한다.
|
||||||
|
if (chatCharacter.languageCode.isNullOrBlank() && chatCharacter.description.isNotBlank()) {
|
||||||
|
applicationEventPublisher.publishEvent(
|
||||||
|
LanguageDetectEvent(
|
||||||
|
id = chatCharacter.id!!,
|
||||||
|
query = chatCharacter.description,
|
||||||
|
targetType = LanguageDetectTargetType.CHARACTER
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
ApiResponse.ok(null)
|
ApiResponse.ok(null)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -315,6 +333,13 @@ class AdminChatCharacterController(
|
|||||||
request = request
|
request = request
|
||||||
)
|
)
|
||||||
|
|
||||||
|
applicationEventPublisher.publishEvent(
|
||||||
|
LanguageTranslationEvent(
|
||||||
|
id = request.id,
|
||||||
|
targetType = LanguageTranslationTargetType.CHARACTER
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
// 원작 연결: originalWorkId가 있으면 서비스 계층을 통해 배정
|
// 원작 연결: originalWorkId가 있으면 서비스 계층을 통해 배정
|
||||||
if (request.originalWorkId != null) {
|
if (request.originalWorkId != null) {
|
||||||
// 서비스에서 유효성 검증 및 저장까지 처리
|
// 서비스에서 유효성 검증 및 저장까지 처리
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
package kr.co.vividnext.sodalive.api.home
|
package kr.co.vividnext.sodalive.api.home
|
||||||
|
|
||||||
import kr.co.vividnext.sodalive.audition.AuditionService
|
import kr.co.vividnext.sodalive.audition.AuditionService
|
||||||
|
import kr.co.vividnext.sodalive.chat.character.dto.Character
|
||||||
import kr.co.vividnext.sodalive.chat.character.service.ChatCharacterService
|
import kr.co.vividnext.sodalive.chat.character.service.ChatCharacterService
|
||||||
|
import kr.co.vividnext.sodalive.chat.character.translate.AiCharacterTranslationRepository
|
||||||
import kr.co.vividnext.sodalive.content.AudioContentMainItem
|
import kr.co.vividnext.sodalive.content.AudioContentMainItem
|
||||||
import kr.co.vividnext.sodalive.content.AudioContentService
|
import kr.co.vividnext.sodalive.content.AudioContentService
|
||||||
import kr.co.vividnext.sodalive.content.ContentType
|
import kr.co.vividnext.sodalive.content.ContentType
|
||||||
@@ -11,9 +13,11 @@ import kr.co.vividnext.sodalive.content.main.curation.AudioContentCurationServic
|
|||||||
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.theme.AudioContentThemeService
|
import kr.co.vividnext.sodalive.content.theme.AudioContentThemeService
|
||||||
|
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
|
||||||
import kr.co.vividnext.sodalive.event.GetEventResponse
|
import kr.co.vividnext.sodalive.event.GetEventResponse
|
||||||
import kr.co.vividnext.sodalive.explorer.ExplorerQueryRepository
|
import kr.co.vividnext.sodalive.explorer.ExplorerQueryRepository
|
||||||
|
import kr.co.vividnext.sodalive.i18n.LangContext
|
||||||
import kr.co.vividnext.sodalive.live.room.LiveRoomService
|
import kr.co.vividnext.sodalive.live.room.LiveRoomService
|
||||||
import kr.co.vividnext.sodalive.live.room.LiveRoomStatus
|
import kr.co.vividnext.sodalive.live.room.LiveRoomStatus
|
||||||
import kr.co.vividnext.sodalive.member.Member
|
import kr.co.vividnext.sodalive.member.Member
|
||||||
@@ -47,6 +51,11 @@ class HomeService(
|
|||||||
private val rankingRepository: RankingRepository,
|
private val rankingRepository: RankingRepository,
|
||||||
private val explorerQueryRepository: ExplorerQueryRepository,
|
private val explorerQueryRepository: ExplorerQueryRepository,
|
||||||
|
|
||||||
|
private val contentTranslationRepository: ContentTranslationRepository,
|
||||||
|
private val aiCharacterTranslationRepository: AiCharacterTranslationRepository,
|
||||||
|
|
||||||
|
private val langContext: LangContext,
|
||||||
|
|
||||||
@Value("\${cloud.aws.cloud-front.host}")
|
@Value("\${cloud.aws.cloud-front.host}")
|
||||||
private val imageHost: String
|
private val imageHost: String
|
||||||
) {
|
) {
|
||||||
@@ -111,6 +120,8 @@ class HomeService(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
val translatedLatestContentList = getTranslatedContentList(contentList = latestContentList)
|
||||||
|
|
||||||
val eventBannerList = GetEventResponse(
|
val eventBannerList = GetEventResponse(
|
||||||
totalCount = 0,
|
totalCount = 0,
|
||||||
eventList = emptyList()
|
eventList = emptyList()
|
||||||
@@ -138,7 +149,7 @@ class HomeService(
|
|||||||
)
|
)
|
||||||
|
|
||||||
// 인기 캐릭터 조회
|
// 인기 캐릭터 조회
|
||||||
val popularCharacters = characterService.getPopularCharacters()
|
val translatedPopularCharacters = getTranslatedAiCharacterList(aiCharacterList = characterService.getPopularCharacters())
|
||||||
|
|
||||||
val currentDateTime = LocalDateTime.now()
|
val currentDateTime = LocalDateTime.now()
|
||||||
val startDate = currentDateTime
|
val startDate = currentDateTime
|
||||||
@@ -159,12 +170,64 @@ class HomeService(
|
|||||||
sort = ContentRankingSortType.REVENUE
|
sort = ContentRankingSortType.REVENUE
|
||||||
)
|
)
|
||||||
|
|
||||||
|
val contentRankingContentIds = contentRanking.map { it.contentId }
|
||||||
|
val translatedContentRanking = if (contentRankingContentIds.isNotEmpty()) {
|
||||||
|
val translations = contentTranslationRepository
|
||||||
|
.findByContentIdInAndLocale(contentIds = contentRankingContentIds, locale = langContext.lang.code)
|
||||||
|
.associateBy { it.contentId }
|
||||||
|
|
||||||
|
contentRanking.map { item ->
|
||||||
|
val translatedTitle = translations[item.contentId]?.renderedPayload?.title
|
||||||
|
if (translatedTitle.isNullOrBlank()) {
|
||||||
|
item
|
||||||
|
} else {
|
||||||
|
item.copy(title = translatedTitle)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
contentRanking
|
||||||
|
}
|
||||||
|
|
||||||
val recommendChannelList = recommendChannelService.getRecommendChannel(
|
val recommendChannelList = recommendChannelService.getRecommendChannel(
|
||||||
memberId = memberId,
|
memberId = memberId,
|
||||||
isAdult = isAdult,
|
isAdult = isAdult,
|
||||||
contentType = contentType
|
contentType = contentType
|
||||||
)
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* recommendChannelList의 콘텐츠 번역 데이터 조회
|
||||||
|
*
|
||||||
|
* languageCode != null
|
||||||
|
* contentTranslationRepository를 이용해 번역 콘텐츠를 조회한다. - contentId, locale
|
||||||
|
*
|
||||||
|
* 한 번에 조회하고 contentId를 매핑하여 recommendChannelList의 콘텐츠 title을 번역 데이터로 변경한다
|
||||||
|
*/
|
||||||
|
val channelContentIds = recommendChannelList
|
||||||
|
.flatMap { it.contentList }
|
||||||
|
.map { it.contentId }
|
||||||
|
.distinct()
|
||||||
|
|
||||||
|
val translatedRecommendChannelList = if (channelContentIds.isNotEmpty()) {
|
||||||
|
val translations = contentTranslationRepository
|
||||||
|
.findByContentIdInAndLocale(contentIds = channelContentIds, locale = langContext.lang.code)
|
||||||
|
.associateBy { it.contentId }
|
||||||
|
|
||||||
|
recommendChannelList.map { channel ->
|
||||||
|
val translatedContentList = channel.contentList.map { item ->
|
||||||
|
val translatedTitle = translations[item.contentId]?.renderedPayload?.title
|
||||||
|
if (translatedTitle.isNullOrBlank()) {
|
||||||
|
item
|
||||||
|
} else {
|
||||||
|
item.copy(title = translatedTitle)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
channel.copy(contentList = translatedContentList)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
recommendChannelList
|
||||||
|
}
|
||||||
|
|
||||||
val freeContentList = contentService.getLatestContentByTheme(
|
val freeContentList = contentService.getLatestContentByTheme(
|
||||||
theme = contentThemeService.getActiveThemeOfContent(
|
theme = contentThemeService.getActiveThemeOfContent(
|
||||||
isAdult = isAdult,
|
isAdult = isAdult,
|
||||||
@@ -183,6 +246,8 @@ class HomeService(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
val translatedFreeContentList = getTranslatedContentList(contentList = freeContentList)
|
||||||
|
|
||||||
// 포인트 사용가능 콘텐츠 리스트 - 랜덤으로 가져오기 (DB에서 isPointAvailable 조건 적용)
|
// 포인트 사용가능 콘텐츠 리스트 - 랜덤으로 가져오기 (DB에서 isPointAvailable 조건 적용)
|
||||||
val pointAvailableContentList = contentService.getLatestContentByTheme(
|
val pointAvailableContentList = contentService.getLatestContentByTheme(
|
||||||
theme = emptyList(),
|
theme = emptyList(),
|
||||||
@@ -199,6 +264,8 @@ class HomeService(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
val translatedPointAvailableContentList = getTranslatedContentList(contentList = pointAvailableContentList)
|
||||||
|
|
||||||
val curationList = curationService.getContentCurationList(
|
val curationList = curationService.getContentCurationList(
|
||||||
tabId = 3L, // 기존에 사용하던 단편 탭의 큐레이션을 사용
|
tabId = 3L, // 기존에 사용하던 단편 탭의 큐레이션을 사용
|
||||||
isAdult = isAdult,
|
isAdult = isAdult,
|
||||||
@@ -210,17 +277,17 @@ class HomeService(
|
|||||||
liveList = liveList,
|
liveList = liveList,
|
||||||
creatorRanking = creatorRanking,
|
creatorRanking = creatorRanking,
|
||||||
latestContentThemeList = latestContentThemeList,
|
latestContentThemeList = latestContentThemeList,
|
||||||
latestContentList = latestContentList,
|
latestContentList = translatedLatestContentList,
|
||||||
bannerList = bannerList,
|
bannerList = bannerList,
|
||||||
eventBannerList = eventBannerList,
|
eventBannerList = eventBannerList,
|
||||||
originalAudioDramaList = originalAudioDramaList,
|
originalAudioDramaList = originalAudioDramaList,
|
||||||
auditionList = auditionList,
|
auditionList = auditionList,
|
||||||
dayOfWeekSeriesList = dayOfWeekSeriesList,
|
dayOfWeekSeriesList = dayOfWeekSeriesList,
|
||||||
popularCharacters = popularCharacters,
|
popularCharacters = translatedPopularCharacters,
|
||||||
contentRanking = contentRanking,
|
contentRanking = translatedContentRanking,
|
||||||
recommendChannelList = recommendChannelList,
|
recommendChannelList = translatedRecommendChannelList,
|
||||||
freeContentList = freeContentList,
|
freeContentList = translatedFreeContentList,
|
||||||
pointAvailableContentList = pointAvailableContentList,
|
pointAvailableContentList = translatedPointAvailableContentList,
|
||||||
recommendContentList = getRecommendContentList(
|
recommendContentList = getRecommendContentList(
|
||||||
isAdultContentVisible = isAdultContentVisible,
|
isAdultContentVisible = isAdultContentVisible,
|
||||||
contentType = contentType,
|
contentType = contentType,
|
||||||
@@ -249,7 +316,7 @@ class HomeService(
|
|||||||
listOf(theme)
|
listOf(theme)
|
||||||
}
|
}
|
||||||
|
|
||||||
return contentService.getLatestContentByTheme(
|
val contentList = contentService.getLatestContentByTheme(
|
||||||
theme = themeList,
|
theme = themeList,
|
||||||
contentType = contentType,
|
contentType = contentType,
|
||||||
isFree = false,
|
isFree = false,
|
||||||
@@ -261,6 +328,8 @@ class HomeService(
|
|||||||
true
|
true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return getTranslatedContentList(contentList = contentList)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun getDayOfWeekSeriesList(
|
fun getDayOfWeekSeriesList(
|
||||||
@@ -371,6 +440,76 @@ class HomeService(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return result
|
return getTranslatedContentList(contentList = result)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 콘텐츠 리스트의 제목을 현재 언어(locale)에 맞춰 일괄 번역한다.
|
||||||
|
*
|
||||||
|
* 처리 절차:
|
||||||
|
* - 입력된 콘텐츠들의 contentId 집합을 만들고, 요청 언어 코드(langContext.lang.code)로
|
||||||
|
* contentTranslationRepository에서 번역 데이터를 한 번에 조회한다.
|
||||||
|
* - 각 항목에 대해 번역된 제목이 존재하고 비어있지 않으면 title만 번역 값으로 교체한다.
|
||||||
|
* - 번역이 없거나 공백이면 원본 항목을 그대로 반환한다.
|
||||||
|
*
|
||||||
|
* 성능:
|
||||||
|
* - N건의 항목을 1회의 조회로 해결하기 위해 IN 쿼리를 사용한다.
|
||||||
|
*
|
||||||
|
* @param contentList 번역 대상 AudioContentMainItem 목록
|
||||||
|
* @return 제목이 가능한 항목은 번역된 목록(불변 사본), 그 외는 원본 항목 유지
|
||||||
|
*/
|
||||||
|
private fun getTranslatedContentList(contentList: List<AudioContentMainItem>): List<AudioContentMainItem> {
|
||||||
|
val contentIds = contentList.map { it.contentId }
|
||||||
|
|
||||||
|
return 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* AI 캐릭터 리스트의 이름/설명을 현재 언어(locale)에 맞춰 일괄 번역한다.
|
||||||
|
*
|
||||||
|
* 처리 절차:
|
||||||
|
* - characterId 목록을 추출하고, 요청 언어 코드로 aiCharacterTranslationRepository에서
|
||||||
|
* 번역 데이터를 한 번에 조회한다.
|
||||||
|
* - 각 캐릭터에 대해 name과 description 모두 번역 값이 존재하고 비어있지 않을 때에만
|
||||||
|
* 해당 필드를 교체한다. 둘 중 하나라도 없으면 원본 캐릭터를 그대로 유지한다.
|
||||||
|
*
|
||||||
|
* @param aiCharacterList 번역 대상 캐릭터 목록
|
||||||
|
* @return 가능한 경우 name/description이 번역된 캐릭터 목록, 그 외는 원본 유지
|
||||||
|
*/
|
||||||
|
private fun getTranslatedAiCharacterList(aiCharacterList: List<Character>): List<Character> {
|
||||||
|
val characterIds = aiCharacterList.map { it.characterId }
|
||||||
|
|
||||||
|
return if (characterIds.isNotEmpty()) {
|
||||||
|
val translations = aiCharacterTranslationRepository
|
||||||
|
.findByCharacterIdInAndLocale(characterIds = characterIds, locale = langContext.lang.code)
|
||||||
|
.associateBy { it.characterId }
|
||||||
|
|
||||||
|
aiCharacterList.map { character ->
|
||||||
|
val translatedName = translations[character.characterId]?.renderedPayload?.name
|
||||||
|
val translatedDesc = translations[character.characterId]?.renderedPayload?.description
|
||||||
|
if (translatedName.isNullOrBlank() || translatedDesc.isNullOrBlank()) {
|
||||||
|
character
|
||||||
|
} else {
|
||||||
|
character.copy(name = translatedName, description = translatedDesc)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
aiCharacterList
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,6 +22,8 @@ class ChatCharacter(
|
|||||||
// 캐릭터 한 줄 소개
|
// 캐릭터 한 줄 소개
|
||||||
var description: String,
|
var description: String,
|
||||||
|
|
||||||
|
var languageCode: String? = null,
|
||||||
|
|
||||||
// AI 시스템 프롬프트
|
// AI 시스템 프롬프트
|
||||||
@Column(columnDefinition = "TEXT", nullable = false)
|
@Column(columnDefinition = "TEXT", nullable = false)
|
||||||
var systemPrompt: String,
|
var systemPrompt: String,
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import javax.persistence.Table
|
|||||||
data class CharacterComment(
|
data class CharacterComment(
|
||||||
@Column(columnDefinition = "TEXT", nullable = false)
|
@Column(columnDefinition = "TEXT", nullable = false)
|
||||||
var comment: String,
|
var comment: String,
|
||||||
|
var languageCode: String?,
|
||||||
var isActive: Boolean = true
|
var isActive: Boolean = true
|
||||||
) : BaseEntity() {
|
) : BaseEntity() {
|
||||||
@ManyToOne(fetch = FetchType.LAZY)
|
@ManyToOne(fetch = FetchType.LAZY)
|
||||||
|
|||||||
@@ -47,7 +47,7 @@ class CharacterCommentController(
|
|||||||
if (member.auth == null) throw SodaException("본인인증을 하셔야 합니다.")
|
if (member.auth == null) throw SodaException("본인인증을 하셔야 합니다.")
|
||||||
if (request.comment.isBlank()) throw SodaException("댓글 내용을 입력해주세요.")
|
if (request.comment.isBlank()) throw SodaException("댓글 내용을 입력해주세요.")
|
||||||
|
|
||||||
val id = service.addReply(characterId, commentId, member, request.comment)
|
val id = service.addReply(characterId, commentId, member, request.comment, request.languageCode)
|
||||||
ApiResponse.ok(id)
|
ApiResponse.ok(id)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,8 @@ package kr.co.vividnext.sodalive.chat.character.comment
|
|||||||
|
|
||||||
// Request DTOs
|
// Request DTOs
|
||||||
data class CreateCharacterCommentRequest(
|
data class CreateCharacterCommentRequest(
|
||||||
val comment: String
|
val comment: String,
|
||||||
|
val languageCode: String? = null
|
||||||
)
|
)
|
||||||
|
|
||||||
// Response DTOs
|
// Response DTOs
|
||||||
@@ -20,7 +21,8 @@ data class CharacterCommentResponse(
|
|||||||
val memberNickname: String,
|
val memberNickname: String,
|
||||||
val createdAt: Long,
|
val createdAt: Long,
|
||||||
val replyCount: Int,
|
val replyCount: Int,
|
||||||
val comment: String
|
val comment: String,
|
||||||
|
val languageCode: String?
|
||||||
)
|
)
|
||||||
|
|
||||||
// 답글 Response 단건(목록 원소)
|
// 답글 Response 단건(목록 원소)
|
||||||
@@ -35,7 +37,8 @@ data class CharacterReplyResponse(
|
|||||||
val memberProfileImage: String,
|
val memberProfileImage: String,
|
||||||
val memberNickname: String,
|
val memberNickname: String,
|
||||||
val createdAt: Long,
|
val createdAt: Long,
|
||||||
val comment: String
|
val comment: String,
|
||||||
|
val languageCode: String?
|
||||||
)
|
)
|
||||||
|
|
||||||
// 댓글의 답글 조회 Response 컨테이너
|
// 댓글의 답글 조회 Response 컨테이너
|
||||||
|
|||||||
@@ -2,7 +2,10 @@ package kr.co.vividnext.sodalive.chat.character.comment
|
|||||||
|
|
||||||
import kr.co.vividnext.sodalive.chat.character.repository.ChatCharacterRepository
|
import kr.co.vividnext.sodalive.chat.character.repository.ChatCharacterRepository
|
||||||
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.member.Member
|
import kr.co.vividnext.sodalive.member.Member
|
||||||
|
import org.springframework.context.ApplicationEventPublisher
|
||||||
import org.springframework.data.domain.PageRequest
|
import org.springframework.data.domain.PageRequest
|
||||||
import org.springframework.stereotype.Service
|
import org.springframework.stereotype.Service
|
||||||
import org.springframework.transaction.annotation.Transactional
|
import org.springframework.transaction.annotation.Transactional
|
||||||
@@ -12,7 +15,8 @@ import java.time.ZoneId
|
|||||||
class CharacterCommentService(
|
class CharacterCommentService(
|
||||||
private val chatCharacterRepository: ChatCharacterRepository,
|
private val chatCharacterRepository: ChatCharacterRepository,
|
||||||
private val commentRepository: CharacterCommentRepository,
|
private val commentRepository: CharacterCommentRepository,
|
||||||
private val reportRepository: CharacterCommentReportRepository
|
private val reportRepository: CharacterCommentReportRepository,
|
||||||
|
private val applicationEventPublisher: ApplicationEventPublisher
|
||||||
) {
|
) {
|
||||||
|
|
||||||
private fun profileUrl(imageHost: String, profileImage: String?): String {
|
private fun profileUrl(imageHost: String, profileImage: String?): String {
|
||||||
@@ -40,7 +44,8 @@ class CharacterCommentService(
|
|||||||
memberNickname = member.nickname,
|
memberNickname = member.nickname,
|
||||||
createdAt = toEpochMilli(entity.createdAt),
|
createdAt = toEpochMilli(entity.createdAt),
|
||||||
replyCount = replyCountOverride ?: commentRepository.countByParent_IdAndIsActiveTrue(entity.id!!),
|
replyCount = replyCountOverride ?: commentRepository.countByParent_IdAndIsActiveTrue(entity.id!!),
|
||||||
comment = entity.comment
|
comment = entity.comment,
|
||||||
|
languageCode = entity.languageCode
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -52,25 +57,44 @@ class CharacterCommentService(
|
|||||||
memberProfileImage = profileUrl(imageHost, member.profileImage),
|
memberProfileImage = profileUrl(imageHost, member.profileImage),
|
||||||
memberNickname = member.nickname,
|
memberNickname = member.nickname,
|
||||||
createdAt = toEpochMilli(entity.createdAt),
|
createdAt = toEpochMilli(entity.createdAt),
|
||||||
comment = entity.comment
|
comment = entity.comment,
|
||||||
|
languageCode = entity.languageCode
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Transactional
|
@Transactional
|
||||||
fun addComment(characterId: Long, member: Member, text: String): Long {
|
fun addComment(characterId: Long, member: Member, text: String, languageCode: String? = null): Long {
|
||||||
val character = chatCharacterRepository.findById(characterId).orElseThrow { SodaException("캐릭터를 찾을 수 없습니다.") }
|
val character = chatCharacterRepository.findById(characterId).orElseThrow { SodaException("캐릭터를 찾을 수 없습니다.") }
|
||||||
if (!character.isActive) throw SodaException("비활성화된 캐릭터입니다.")
|
if (!character.isActive) throw SodaException("비활성화된 캐릭터입니다.")
|
||||||
if (text.isBlank()) throw SodaException("댓글 내용을 입력해주세요.")
|
if (text.isBlank()) throw SodaException("댓글 내용을 입력해주세요.")
|
||||||
|
|
||||||
val entity = CharacterComment(comment = text)
|
val entity = CharacterComment(comment = text, languageCode = languageCode)
|
||||||
entity.chatCharacter = character
|
entity.chatCharacter = character
|
||||||
entity.member = member
|
entity.member = member
|
||||||
commentRepository.save(entity)
|
commentRepository.save(entity)
|
||||||
|
|
||||||
|
// 언어 코드가 지정되지 않은 경우, 파파고 언어 감지 API를 통해 비동기로 언어를 식별한다.
|
||||||
|
if (languageCode.isNullOrBlank()) {
|
||||||
|
applicationEventPublisher.publishEvent(
|
||||||
|
LanguageDetectEvent(
|
||||||
|
id = entity.id!!,
|
||||||
|
query = text,
|
||||||
|
targetType = LanguageDetectTargetType.CHARACTER_COMMENT
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
return entity.id!!
|
return entity.id!!
|
||||||
}
|
}
|
||||||
|
|
||||||
@Transactional
|
@Transactional
|
||||||
fun addReply(characterId: Long, parentCommentId: Long, member: Member, text: String): Long {
|
fun addReply(
|
||||||
|
characterId: Long,
|
||||||
|
parentCommentId: Long,
|
||||||
|
member: Member,
|
||||||
|
text: String,
|
||||||
|
languageCode: String? = null
|
||||||
|
): Long {
|
||||||
val character = chatCharacterRepository.findById(characterId).orElseThrow { SodaException("캐릭터를 찾을 수 없습니다.") }
|
val character = chatCharacterRepository.findById(characterId).orElseThrow { SodaException("캐릭터를 찾을 수 없습니다.") }
|
||||||
if (!character.isActive) throw SodaException("비활성화된 캐릭터입니다.")
|
if (!character.isActive) throw SodaException("비활성화된 캐릭터입니다.")
|
||||||
val parent = commentRepository.findById(parentCommentId).orElseThrow { SodaException("댓글을 찾을 수 없습니다.") }
|
val parent = commentRepository.findById(parentCommentId).orElseThrow { SodaException("댓글을 찾을 수 없습니다.") }
|
||||||
@@ -78,11 +102,23 @@ class CharacterCommentService(
|
|||||||
if (!parent.isActive) throw SodaException("비활성화된 댓글입니다.")
|
if (!parent.isActive) throw SodaException("비활성화된 댓글입니다.")
|
||||||
if (text.isBlank()) throw SodaException("댓글 내용을 입력해주세요.")
|
if (text.isBlank()) throw SodaException("댓글 내용을 입력해주세요.")
|
||||||
|
|
||||||
val entity = CharacterComment(comment = text)
|
val entity = CharacterComment(comment = text, languageCode = languageCode)
|
||||||
entity.chatCharacter = character
|
entity.chatCharacter = character
|
||||||
entity.member = member
|
entity.member = member
|
||||||
entity.parent = parent
|
entity.parent = parent
|
||||||
commentRepository.save(entity)
|
commentRepository.save(entity)
|
||||||
|
|
||||||
|
// 언어 코드가 지정되지 않은 경우, 파파고 언어 감지 API를 통해 비동기로 언어를 식별한다.
|
||||||
|
if (languageCode.isNullOrBlank()) {
|
||||||
|
applicationEventPublisher.publishEvent(
|
||||||
|
LanguageDetectEvent(
|
||||||
|
id = entity.id!!,
|
||||||
|
query = text,
|
||||||
|
targetType = LanguageDetectTargetType.CHARACTER_COMMENT
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
return entity.id!!
|
return entity.id!!
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package kr.co.vividnext.sodalive.chat.character.controller
|
package kr.co.vividnext.sodalive.chat.character.controller
|
||||||
|
|
||||||
import kr.co.vividnext.sodalive.chat.character.comment.CharacterCommentService
|
import kr.co.vividnext.sodalive.chat.character.comment.CharacterCommentService
|
||||||
|
import kr.co.vividnext.sodalive.chat.character.curation.CharacterCurationQueryService
|
||||||
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.dto.CharacterBackgroundResponse
|
import kr.co.vividnext.sodalive.chat.character.dto.CharacterBackgroundResponse
|
||||||
import kr.co.vividnext.sodalive.chat.character.dto.CharacterBannerResponse
|
import kr.co.vividnext.sodalive.chat.character.dto.CharacterBannerResponse
|
||||||
@@ -10,11 +11,21 @@ import kr.co.vividnext.sodalive.chat.character.dto.CharacterPersonalityResponse
|
|||||||
import kr.co.vividnext.sodalive.chat.character.dto.CurationSection
|
import kr.co.vividnext.sodalive.chat.character.dto.CurationSection
|
||||||
import kr.co.vividnext.sodalive.chat.character.dto.OtherCharacter
|
import kr.co.vividnext.sodalive.chat.character.dto.OtherCharacter
|
||||||
import kr.co.vividnext.sodalive.chat.character.dto.RecentCharacter
|
import kr.co.vividnext.sodalive.chat.character.dto.RecentCharacter
|
||||||
|
import kr.co.vividnext.sodalive.chat.character.dto.RecentCharactersResponse
|
||||||
import kr.co.vividnext.sodalive.chat.character.service.ChatCharacterBannerService
|
import kr.co.vividnext.sodalive.chat.character.service.ChatCharacterBannerService
|
||||||
import kr.co.vividnext.sodalive.chat.character.service.ChatCharacterService
|
import kr.co.vividnext.sodalive.chat.character.service.ChatCharacterService
|
||||||
|
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.AiCharacterTranslationRepository
|
||||||
|
import kr.co.vividnext.sodalive.chat.character.translate.TranslatedAiCharacterBackground
|
||||||
|
import kr.co.vividnext.sodalive.chat.character.translate.TranslatedAiCharacterDetail
|
||||||
|
import kr.co.vividnext.sodalive.chat.character.translate.TranslatedAiCharacterPersonality
|
||||||
import kr.co.vividnext.sodalive.chat.room.service.ChatRoomService
|
import kr.co.vividnext.sodalive.chat.room.service.ChatRoomService
|
||||||
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.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.beans.factory.annotation.Value
|
import org.springframework.beans.factory.annotation.Value
|
||||||
import org.springframework.data.domain.PageRequest
|
import org.springframework.data.domain.PageRequest
|
||||||
@@ -32,7 +43,12 @@ class ChatCharacterController(
|
|||||||
private val bannerService: ChatCharacterBannerService,
|
private val bannerService: ChatCharacterBannerService,
|
||||||
private val chatRoomService: ChatRoomService,
|
private val chatRoomService: ChatRoomService,
|
||||||
private val characterCommentService: CharacterCommentService,
|
private val characterCommentService: CharacterCommentService,
|
||||||
private val curationQueryService: kr.co.vividnext.sodalive.chat.character.curation.CharacterCurationQueryService,
|
private val curationQueryService: CharacterCurationQueryService,
|
||||||
|
|
||||||
|
private val translationService: PapagoTranslationService,
|
||||||
|
private val aiCharacterTranslationRepository: AiCharacterTranslationRepository,
|
||||||
|
|
||||||
|
private val langContext: LangContext,
|
||||||
|
|
||||||
@Value("\${cloud.aws.cloud-front.host}")
|
@Value("\${cloud.aws.cloud-front.host}")
|
||||||
private val imageHost: String
|
private val imageHost: String
|
||||||
@@ -65,6 +81,24 @@ class ChatCharacterController(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
val characterIds = recentCharacters.map { it.characterId }
|
||||||
|
val translatedRecentCharacters = if (characterIds.isNotEmpty()) {
|
||||||
|
val translations = aiCharacterTranslationRepository
|
||||||
|
.findByCharacterIdInAndLocale(characterIds = characterIds, locale = langContext.lang.code)
|
||||||
|
.associateBy { it.characterId }
|
||||||
|
|
||||||
|
recentCharacters.map { character ->
|
||||||
|
val translatedName = translations[character.characterId]?.renderedPayload?.name
|
||||||
|
if (translatedName.isNullOrBlank()) {
|
||||||
|
character
|
||||||
|
} else {
|
||||||
|
character.copy(name = translatedName)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
recentCharacters
|
||||||
|
}
|
||||||
|
|
||||||
// 인기 캐릭터 조회
|
// 인기 캐릭터 조회
|
||||||
val popularCharacters = service.getPopularCharacters()
|
val popularCharacters = service.getPopularCharacters()
|
||||||
|
|
||||||
@@ -103,10 +137,10 @@ class ChatCharacterController(
|
|||||||
ApiResponse.ok(
|
ApiResponse.ok(
|
||||||
CharacterMainResponse(
|
CharacterMainResponse(
|
||||||
banners = banners,
|
banners = banners,
|
||||||
recentCharacters = recentCharacters,
|
recentCharacters = translatedRecentCharacters,
|
||||||
popularCharacters = popularCharacters,
|
popularCharacters = getTranslatedAiCharacterList(popularCharacters),
|
||||||
newCharacters = newCharacters,
|
newCharacters = getTranslatedAiCharacterList(newCharacters),
|
||||||
recommendCharacters = recommendCharacters,
|
recommendCharacters = getTranslatedAiCharacterList(recommendCharacters),
|
||||||
curationSections = curationSections
|
curationSections = curationSections
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
@@ -148,6 +182,118 @@ class ChatCharacterController(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var translated: TranslatedAiCharacterDetail? = null
|
||||||
|
if (langContext.lang.code != character.languageCode) {
|
||||||
|
val existing = aiCharacterTranslationRepository
|
||||||
|
.findByCharacterIdAndLocale(character.id!!, langContext.lang.code)
|
||||||
|
|
||||||
|
if (existing != null) {
|
||||||
|
val payload = existing.renderedPayload
|
||||||
|
translated = TranslatedAiCharacterDetail(
|
||||||
|
name = payload.name,
|
||||||
|
description = payload.description,
|
||||||
|
gender = payload.gender,
|
||||||
|
personality = TranslatedAiCharacterPersonality(
|
||||||
|
trait = payload.personalityTrait,
|
||||||
|
description = payload.personalityDescription
|
||||||
|
).takeIf {
|
||||||
|
(it.trait?.isNotBlank() == true) || (it.description?.isNotBlank() == true)
|
||||||
|
},
|
||||||
|
background = TranslatedAiCharacterBackground(
|
||||||
|
topic = payload.backgroundTopic,
|
||||||
|
description = payload.backgroundDescription
|
||||||
|
).takeIf {
|
||||||
|
(it.topic?.isNotBlank() == true) || (it.description?.isNotBlank() == true)
|
||||||
|
},
|
||||||
|
tags = payload.tags
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
val texts = mutableListOf<String>()
|
||||||
|
texts.add(character.name)
|
||||||
|
texts.add(character.description)
|
||||||
|
texts.add(character.gender ?: "")
|
||||||
|
|
||||||
|
val hasPersonality = personality != null
|
||||||
|
if (hasPersonality) {
|
||||||
|
texts.add(personality!!.trait)
|
||||||
|
texts.add(personality.description)
|
||||||
|
}
|
||||||
|
|
||||||
|
val hasBackground = background != null
|
||||||
|
if (hasBackground) {
|
||||||
|
texts.add(background!!.topic)
|
||||||
|
texts.add(background.description)
|
||||||
|
}
|
||||||
|
|
||||||
|
texts.add(tags)
|
||||||
|
|
||||||
|
val sourceLanguage = character.languageCode ?: "ko"
|
||||||
|
|
||||||
|
val response = translationService.translate(
|
||||||
|
request = TranslateRequest(
|
||||||
|
texts = texts,
|
||||||
|
sourceLanguage = sourceLanguage,
|
||||||
|
targetLanguage = langContext.lang.code
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
val translatedTexts = response.translatedText
|
||||||
|
if (translatedTexts.size == texts.size) {
|
||||||
|
var index = 0
|
||||||
|
|
||||||
|
val translatedName = translatedTexts[index++]
|
||||||
|
val translatedDescription = translatedTexts[index++]
|
||||||
|
val translatedGender = translatedTexts[index++]
|
||||||
|
|
||||||
|
var translatedPersonality: TranslatedAiCharacterPersonality? = null
|
||||||
|
if (hasPersonality) {
|
||||||
|
translatedPersonality = TranslatedAiCharacterPersonality(
|
||||||
|
trait = translatedTexts[index++],
|
||||||
|
description = translatedTexts[index++]
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
var translatedBackground: TranslatedAiCharacterBackground? = null
|
||||||
|
if (hasBackground) {
|
||||||
|
translatedBackground = TranslatedAiCharacterBackground(
|
||||||
|
topic = translatedTexts[index++],
|
||||||
|
description = translatedTexts[index++]
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
val translatedTags = translatedTexts[index]
|
||||||
|
|
||||||
|
val payload = AiCharacterTranslationRenderedPayload(
|
||||||
|
name = translatedName,
|
||||||
|
description = translatedDescription,
|
||||||
|
gender = translatedGender,
|
||||||
|
personalityTrait = translatedPersonality?.trait ?: "",
|
||||||
|
personalityDescription = translatedPersonality?.description ?: "",
|
||||||
|
backgroundTopic = translatedBackground?.topic ?: "",
|
||||||
|
backgroundDescription = translatedBackground?.description ?: "",
|
||||||
|
tags = translatedTags
|
||||||
|
)
|
||||||
|
|
||||||
|
val entity = AiCharacterTranslation(
|
||||||
|
characterId = character.id!!,
|
||||||
|
locale = langContext.lang.code,
|
||||||
|
renderedPayload = payload
|
||||||
|
)
|
||||||
|
|
||||||
|
aiCharacterTranslationRepository.save(entity)
|
||||||
|
|
||||||
|
translated = TranslatedAiCharacterDetail(
|
||||||
|
name = translatedName,
|
||||||
|
description = translatedDescription,
|
||||||
|
gender = translatedGender,
|
||||||
|
personality = translatedPersonality,
|
||||||
|
background = translatedBackground,
|
||||||
|
tags = translatedTags
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 다른 캐릭터 조회 (태그 기반, 랜덤 10개, 현재 캐릭터 제외)
|
// 다른 캐릭터 조회 (태그 기반, 랜덤 10개, 현재 캐릭터 제외)
|
||||||
val others = service.getOtherCharactersBySharedTags(characterId, 10)
|
val others = service.getOtherCharactersBySharedTags(characterId, 10)
|
||||||
.map { other ->
|
.map { other ->
|
||||||
@@ -162,6 +308,35 @@ class ChatCharacterController(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 다른 캐릭터 이름, 태그 번역 데이터 조회
|
||||||
|
*
|
||||||
|
* languageCode != null
|
||||||
|
* aiCharacterTranslationRepository 이용해 번역 콘텐츠를 조회한다. - characterId, locale
|
||||||
|
*
|
||||||
|
* 한 번에 조회하고 characterId 매핑하여 others 캐릭터 이름과 tags 번역 데이터로 변경한다
|
||||||
|
*/
|
||||||
|
val characterIds = others.map { it.characterId }
|
||||||
|
val translatedOthers = if (characterIds.isNotEmpty()) {
|
||||||
|
val translations = aiCharacterTranslationRepository
|
||||||
|
.findByCharacterIdInAndLocale(characterIds = characterIds, locale = langContext.lang.code)
|
||||||
|
.associateBy { it.characterId }
|
||||||
|
|
||||||
|
others.map { other ->
|
||||||
|
val payload = translations[other.characterId]?.renderedPayload
|
||||||
|
val translatedName = payload?.name
|
||||||
|
val translatedTags = payload?.tags
|
||||||
|
|
||||||
|
if (translatedName.isNullOrBlank() || translatedTags.isNullOrBlank()) {
|
||||||
|
other
|
||||||
|
} else {
|
||||||
|
other.copy(name = translatedName, tags = translatedTags)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
others
|
||||||
|
}
|
||||||
|
|
||||||
// 최신 댓글 1개 조회
|
// 최신 댓글 1개 조회
|
||||||
val latestComment = characterCommentService.getLatestComment(imageHost, character.id!!)
|
val latestComment = characterCommentService.getLatestComment(imageHost, character.id!!)
|
||||||
|
|
||||||
@@ -171,6 +346,7 @@ class ChatCharacterController(
|
|||||||
characterId = character.id!!,
|
characterId = character.id!!,
|
||||||
name = character.name,
|
name = character.name,
|
||||||
description = character.description,
|
description = character.description,
|
||||||
|
languageCode = character.languageCode,
|
||||||
mbti = character.mbti,
|
mbti = character.mbti,
|
||||||
gender = character.gender,
|
gender = character.gender,
|
||||||
age = character.age,
|
age = character.age,
|
||||||
@@ -181,9 +357,10 @@ class ChatCharacterController(
|
|||||||
originalTitle = character.originalTitle,
|
originalTitle = character.originalTitle,
|
||||||
originalLink = character.originalLink,
|
originalLink = character.originalLink,
|
||||||
characterType = character.characterType,
|
characterType = character.characterType,
|
||||||
others = others,
|
others = translatedOthers,
|
||||||
latestComment = latestComment,
|
latestComment = latestComment,
|
||||||
totalComments = characterCommentService.getTotalCommentCount(character.id!!)
|
totalComments = characterCommentService.getTotalCommentCount(character.id!!),
|
||||||
|
translated = translated
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -194,13 +371,20 @@ class ChatCharacterController(
|
|||||||
* - 예외: 2주 이내 캐릭터가 0개인 경우, 최근 등록한 캐릭터 20개만 제공
|
* - 예외: 2주 이내 캐릭터가 0개인 경우, 최근 등록한 캐릭터 20개만 제공
|
||||||
*/
|
*/
|
||||||
@GetMapping("/recent")
|
@GetMapping("/recent")
|
||||||
fun getRecentCharacters(@RequestParam("page", required = false) page: Int?) = run {
|
fun getRecentCharacters(
|
||||||
ApiResponse.ok(
|
@RequestParam("page", required = false) page: Int?
|
||||||
service.getRecentCharactersPage(
|
): ApiResponse<RecentCharactersResponse> = run {
|
||||||
page = page ?: 0,
|
val characterPage = service.getRecentCharactersPage(
|
||||||
size = 20
|
page = page ?: 0,
|
||||||
)
|
size = 20
|
||||||
)
|
)
|
||||||
|
|
||||||
|
val translatedCharacterPage = RecentCharactersResponse(
|
||||||
|
totalCount = characterPage.totalCount,
|
||||||
|
content = getTranslatedAiCharacterList(characterPage.content)
|
||||||
|
)
|
||||||
|
|
||||||
|
ApiResponse.ok(translatedCharacterPage)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -219,6 +403,48 @@ class ChatCharacterController(
|
|||||||
.listMyChatRooms(member, 0, 50) // 최근 기록은 최대 50개까지만 제외 대상으로 고려
|
.listMyChatRooms(member, 0, 50) // 최근 기록은 최대 50개까지만 제외 대상으로 고려
|
||||||
.map { it.characterId }
|
.map { it.characterId }
|
||||||
}
|
}
|
||||||
ApiResponse.ok(service.getRecommendCharacters(recent, 20))
|
|
||||||
|
ApiResponse.ok(
|
||||||
|
getTranslatedAiCharacterList(
|
||||||
|
service.getRecommendCharacters(
|
||||||
|
recent,
|
||||||
|
20
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* AI 캐릭터 리스트의 이름/설명을 현재 언어(locale)에 맞춰 일괄 번역한다.
|
||||||
|
*
|
||||||
|
* 처리 절차:
|
||||||
|
* - characterId 목록을 추출하고, 요청 언어 코드로 aiCharacterTranslationRepository에서
|
||||||
|
* 번역 데이터를 한 번에 조회한다.
|
||||||
|
* - 각 캐릭터에 대해 name과 description 모두 번역 값이 존재하고 비어있지 않을 때에만
|
||||||
|
* 해당 필드를 교체한다. 둘 중 하나라도 없으면 원본 캐릭터를 그대로 유지한다.
|
||||||
|
*
|
||||||
|
* @param aiCharacterList 번역 대상 캐릭터 목록
|
||||||
|
* @return 가능한 경우 name/description이 번역된 캐릭터 목록, 그 외는 원본 유지
|
||||||
|
*/
|
||||||
|
private fun getTranslatedAiCharacterList(aiCharacterList: List<Character>): List<Character> {
|
||||||
|
val characterIds = aiCharacterList.map { it.characterId }
|
||||||
|
|
||||||
|
return if (characterIds.isNotEmpty()) {
|
||||||
|
val translations = aiCharacterTranslationRepository
|
||||||
|
.findByCharacterIdInAndLocale(characterIds = characterIds, locale = langContext.lang.code)
|
||||||
|
.associateBy { it.characterId }
|
||||||
|
|
||||||
|
aiCharacterList.map { character ->
|
||||||
|
val translatedName = translations[character.characterId]?.renderedPayload?.name
|
||||||
|
val translatedDesc = translations[character.characterId]?.renderedPayload?.description
|
||||||
|
if (translatedName.isNullOrBlank() || translatedDesc.isNullOrBlank()) {
|
||||||
|
character
|
||||||
|
} else {
|
||||||
|
character.copy(name = translatedName, description = translatedDesc)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
aiCharacterList
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,11 +2,13 @@ package kr.co.vividnext.sodalive.chat.character.dto
|
|||||||
|
|
||||||
import kr.co.vividnext.sodalive.chat.character.CharacterType
|
import kr.co.vividnext.sodalive.chat.character.CharacterType
|
||||||
import kr.co.vividnext.sodalive.chat.character.comment.CharacterCommentResponse
|
import kr.co.vividnext.sodalive.chat.character.comment.CharacterCommentResponse
|
||||||
|
import kr.co.vividnext.sodalive.chat.character.translate.TranslatedAiCharacterDetail
|
||||||
|
|
||||||
data class CharacterDetailResponse(
|
data class CharacterDetailResponse(
|
||||||
val characterId: Long,
|
val characterId: Long,
|
||||||
val name: String,
|
val name: String,
|
||||||
val description: String,
|
val description: String,
|
||||||
|
val languageCode: String?,
|
||||||
val mbti: String?,
|
val mbti: String?,
|
||||||
val gender: String?,
|
val gender: String?,
|
||||||
val age: Int?,
|
val age: Int?,
|
||||||
@@ -19,7 +21,8 @@ data class CharacterDetailResponse(
|
|||||||
val characterType: CharacterType,
|
val characterType: CharacterType,
|
||||||
val others: List<OtherCharacter>,
|
val others: List<OtherCharacter>,
|
||||||
val latestComment: CharacterCommentResponse?,
|
val latestComment: CharacterCommentResponse?,
|
||||||
val totalComments: Int
|
val totalComments: Int,
|
||||||
|
val translated: TranslatedAiCharacterDetail?
|
||||||
)
|
)
|
||||||
|
|
||||||
data class OtherCharacter(
|
data class OtherCharacter(
|
||||||
|
|||||||
@@ -0,0 +1,87 @@
|
|||||||
|
package kr.co.vividnext.sodalive.chat.character.translate
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
|
||||||
|
import com.fasterxml.jackson.module.kotlin.readValue
|
||||||
|
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 = ["characterId", "locale"])
|
||||||
|
]
|
||||||
|
)
|
||||||
|
class AiCharacterTranslation(
|
||||||
|
val characterId: Long,
|
||||||
|
val locale: String,
|
||||||
|
|
||||||
|
@Column(columnDefinition = "json")
|
||||||
|
@Convert(converter = AiCharacterTranslationRenderedPayloadConverter::class)
|
||||||
|
var renderedPayload: AiCharacterTranslationRenderedPayload
|
||||||
|
) : BaseEntity()
|
||||||
|
|
||||||
|
data class AiCharacterTranslationRenderedPayload(
|
||||||
|
val name: String,
|
||||||
|
val description: String,
|
||||||
|
val gender: String,
|
||||||
|
val personalityTrait: String,
|
||||||
|
val personalityDescription: String,
|
||||||
|
val backgroundTopic: String,
|
||||||
|
val backgroundDescription: String,
|
||||||
|
val tags: String
|
||||||
|
)
|
||||||
|
|
||||||
|
@Converter(autoApply = false)
|
||||||
|
class AiCharacterTranslationRenderedPayloadConverter :
|
||||||
|
AttributeConverter<AiCharacterTranslationRenderedPayload, String> {
|
||||||
|
|
||||||
|
override fun convertToDatabaseColumn(attribute: AiCharacterTranslationRenderedPayload?): String {
|
||||||
|
if (attribute == null) return "{}"
|
||||||
|
return objectMapper.writeValueAsString(attribute)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun convertToEntityAttribute(dbData: String?): AiCharacterTranslationRenderedPayload {
|
||||||
|
if (dbData.isNullOrBlank()) {
|
||||||
|
return AiCharacterTranslationRenderedPayload(
|
||||||
|
name = "",
|
||||||
|
description = "",
|
||||||
|
gender = "",
|
||||||
|
personalityTrait = "",
|
||||||
|
personalityDescription = "",
|
||||||
|
backgroundTopic = "",
|
||||||
|
backgroundDescription = "",
|
||||||
|
tags = ""
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return objectMapper.readValue(dbData)
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private val objectMapper = jacksonObjectMapper()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
data class TranslatedAiCharacterDetail(
|
||||||
|
val name: String?,
|
||||||
|
val description: String?,
|
||||||
|
val gender: String?,
|
||||||
|
val personality: TranslatedAiCharacterPersonality?,
|
||||||
|
val background: TranslatedAiCharacterBackground?,
|
||||||
|
val tags: String?
|
||||||
|
)
|
||||||
|
|
||||||
|
data class TranslatedAiCharacterPersonality(
|
||||||
|
val trait: String?,
|
||||||
|
val description: String?
|
||||||
|
)
|
||||||
|
|
||||||
|
data class TranslatedAiCharacterBackground(
|
||||||
|
val topic: String?,
|
||||||
|
val description: String?
|
||||||
|
)
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
package kr.co.vividnext.sodalive.chat.character.translate
|
||||||
|
|
||||||
|
import org.springframework.data.jpa.repository.JpaRepository
|
||||||
|
|
||||||
|
interface AiCharacterTranslationRepository : JpaRepository<AiCharacterTranslation, Long> {
|
||||||
|
fun findByCharacterIdAndLocale(characterId: Long, locale: String): AiCharacterTranslation?
|
||||||
|
|
||||||
|
fun findByCharacterIdInAndLocale(characterIds: List<Long>, locale: String): List<AiCharacterTranslation>
|
||||||
|
}
|
||||||
@@ -1,11 +1,19 @@
|
|||||||
package kr.co.vividnext.sodalive.configs
|
package kr.co.vividnext.sodalive.configs
|
||||||
|
|
||||||
|
import kr.co.vividnext.sodalive.i18n.LangInterceptor
|
||||||
import org.springframework.context.annotation.Configuration
|
import org.springframework.context.annotation.Configuration
|
||||||
import org.springframework.web.servlet.config.annotation.CorsRegistry
|
import org.springframework.web.servlet.config.annotation.CorsRegistry
|
||||||
|
import org.springframework.web.servlet.config.annotation.InterceptorRegistry
|
||||||
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer
|
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer
|
||||||
|
|
||||||
@Configuration
|
@Configuration
|
||||||
class WebConfig : WebMvcConfigurer {
|
class WebConfig(
|
||||||
|
private val langInterceptor: LangInterceptor
|
||||||
|
) : WebMvcConfigurer {
|
||||||
|
override fun addInterceptors(registry: InterceptorRegistry) {
|
||||||
|
registry.addInterceptor(langInterceptor).addPathPatterns("/**")
|
||||||
|
}
|
||||||
|
|
||||||
override fun addCorsMappings(registry: CorsRegistry) {
|
override fun addCorsMappings(registry: CorsRegistry) {
|
||||||
registry.addMapping("/**")
|
registry.addMapping("/**")
|
||||||
.allowedOrigins(
|
.allowedOrigins(
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ data class AudioContent(
|
|||||||
var title: String,
|
var title: String,
|
||||||
@Column(columnDefinition = "TEXT", nullable = false)
|
@Column(columnDefinition = "TEXT", nullable = false)
|
||||||
var detail: String,
|
var detail: String,
|
||||||
|
var languageCode: String?,
|
||||||
var playCount: Long = 0,
|
var playCount: Long = 0,
|
||||||
var price: Int = 0,
|
var price: Int = 0,
|
||||||
var releaseDate: LocalDateTime? = null,
|
var releaseDate: LocalDateTime? = null,
|
||||||
|
|||||||
@@ -21,10 +21,20 @@ 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.ContentTranslationPayload
|
||||||
|
import kr.co.vividnext.sodalive.content.translation.ContentTranslationRepository
|
||||||
|
import kr.co.vividnext.sodalive.content.translation.TranslatedContent
|
||||||
import kr.co.vividnext.sodalive.explorer.ExplorerQueryRepository
|
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.LanguageTranslationTargetType
|
||||||
|
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 kr.co.vividnext.sodalive.utils.generateFileName
|
import kr.co.vividnext.sodalive.utils.generateFileName
|
||||||
@@ -56,11 +66,18 @@ class AudioContentService(
|
|||||||
private val audioContentLikeRepository: AudioContentLikeRepository,
|
private val audioContentLikeRepository: AudioContentLikeRepository,
|
||||||
private val pinContentRepository: PinContentRepository,
|
private val pinContentRepository: PinContentRepository,
|
||||||
|
|
||||||
|
private val translationService: PapagoTranslationService,
|
||||||
|
private val contentTranslationRepository: ContentTranslationRepository,
|
||||||
|
|
||||||
private val s3Uploader: S3Uploader,
|
private val s3Uploader: S3Uploader,
|
||||||
private val objectMapper: ObjectMapper,
|
private val objectMapper: ObjectMapper,
|
||||||
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,
|
||||||
|
|
||||||
@@ -160,6 +177,13 @@ class AudioContentService(
|
|||||||
|
|
||||||
audioContent.audioContentHashTags.addAll(newAudioContentHashTagList)
|
audioContent.audioContentHashTags.addAll(newAudioContentHashTagList)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
applicationEventPublisher.publishEvent(
|
||||||
|
LanguageTranslationEvent(
|
||||||
|
id = request.contentId,
|
||||||
|
targetType = LanguageTranslationTargetType.CONTENT
|
||||||
|
)
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Transactional
|
@Transactional
|
||||||
@@ -238,6 +262,7 @@ class AudioContentService(
|
|||||||
val audioContent = AudioContent(
|
val audioContent = AudioContent(
|
||||||
title = request.title.trim(),
|
title = request.title.trim(),
|
||||||
detail = request.detail.trim(),
|
detail = request.detail.trim(),
|
||||||
|
languageCode = request.languageCode,
|
||||||
price = if (request.price > 0) {
|
price = if (request.price > 0) {
|
||||||
request.price
|
request.price
|
||||||
} else {
|
} else {
|
||||||
@@ -331,6 +356,31 @@ class AudioContentService(
|
|||||||
|
|
||||||
audioContent.content = contentPath
|
audioContent.content = contentPath
|
||||||
|
|
||||||
|
// 언어 코드가 지정되지 않은 경우, 파파고 언어 감지 API를 통해 비동기로 언어를 식별한다.
|
||||||
|
if (audioContent.languageCode.isNullOrBlank()) {
|
||||||
|
val papagoQuery = listOf(
|
||||||
|
request.title.trim(),
|
||||||
|
request.detail.trim(),
|
||||||
|
request.tags.trim()
|
||||||
|
)
|
||||||
|
.filter { it.isNotBlank() }
|
||||||
|
.joinToString(" ")
|
||||||
|
|
||||||
|
applicationEventPublisher.publishEvent(
|
||||||
|
LanguageDetectEvent(
|
||||||
|
id = audioContent.id!!,
|
||||||
|
query = papagoQuery
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
applicationEventPublisher.publishEvent(
|
||||||
|
LanguageTranslationEvent(
|
||||||
|
id = audioContent.id!!,
|
||||||
|
targetType = LanguageTranslationTargetType.CONTENT
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
return CreateAudioContentResponse(contentId = audioContent.id!!)
|
return CreateAudioContentResponse(contentId = audioContent.id!!)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -477,6 +527,7 @@ class AudioContentService(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
fun getDetail(
|
fun getDetail(
|
||||||
id: Long,
|
id: Long,
|
||||||
member: Member,
|
member: Member,
|
||||||
@@ -699,10 +750,89 @@ class AudioContentService(
|
|||||||
listOf()
|
listOf()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var translated: TranslatedContent? = null
|
||||||
|
|
||||||
|
/**
|
||||||
|
* audioContent.languageCode != languageCode
|
||||||
|
*
|
||||||
|
* 번역 콘텐츠를 조회한다. - contentId, locale
|
||||||
|
* 번역 콘텐츠가 있으면
|
||||||
|
* TranslatedContent로 가공한다
|
||||||
|
*
|
||||||
|
* 번역 콘텐츠가 없으면
|
||||||
|
* 파파고 API를 통해 번역한 후 저장한다.
|
||||||
|
*
|
||||||
|
* 번역 대상: title, detail, tags
|
||||||
|
*
|
||||||
|
* 파파고로 번역한 데이터를 TranslatedContent로 가공한다
|
||||||
|
*/
|
||||||
|
if (
|
||||||
|
audioContent.languageCode != null &&
|
||||||
|
audioContent.languageCode!!.isNotBlank() &&
|
||||||
|
audioContent.languageCode != langContext.lang.code
|
||||||
|
) {
|
||||||
|
val existing = contentTranslationRepository
|
||||||
|
.findByContentIdAndLocale(audioContent.id!!, langContext.lang.code)
|
||||||
|
|
||||||
|
if (existing != null) {
|
||||||
|
val payload = existing.renderedPayload
|
||||||
|
translated = TranslatedContent(
|
||||||
|
title = payload.title,
|
||||||
|
detail = payload.detail,
|
||||||
|
tags = payload.tags
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
val texts = mutableListOf<String>()
|
||||||
|
texts.add(audioContent.title)
|
||||||
|
texts.add(audioContent.detail)
|
||||||
|
texts.add(tag)
|
||||||
|
|
||||||
|
val sourceLanguage = audioContent.languageCode ?: "ko"
|
||||||
|
|
||||||
|
val response = translationService.translate(
|
||||||
|
request = TranslateRequest(
|
||||||
|
texts = texts,
|
||||||
|
sourceLanguage = sourceLanguage,
|
||||||
|
targetLanguage = langContext.lang.code
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
val translatedTexts = response.translatedText
|
||||||
|
if (translatedTexts.size == texts.size) {
|
||||||
|
var index = 0
|
||||||
|
|
||||||
|
val translatedTitle = translatedTexts[index++]
|
||||||
|
val translatedDetail = translatedTexts[index++]
|
||||||
|
val translatedTags = translatedTexts[index]
|
||||||
|
|
||||||
|
val payload = ContentTranslationPayload(
|
||||||
|
title = translatedTitle,
|
||||||
|
detail = translatedDetail,
|
||||||
|
tags = translatedTags
|
||||||
|
)
|
||||||
|
|
||||||
|
contentTranslationRepository.save(
|
||||||
|
ContentTranslation(
|
||||||
|
contentId = audioContent.id!!,
|
||||||
|
locale = langContext.lang.code,
|
||||||
|
renderedPayload = payload
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
translated = TranslatedContent(
|
||||||
|
title = translatedTitle,
|
||||||
|
detail = translatedDetail,
|
||||||
|
tags = translatedTags
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return GetAudioContentDetailResponse(
|
return GetAudioContentDetailResponse(
|
||||||
contentId = audioContent.id!!,
|
contentId = audioContent.id!!,
|
||||||
title = audioContent.title,
|
title = audioContent.title,
|
||||||
detail = contentDetail,
|
detail = contentDetail,
|
||||||
|
languageCode = audioContent.languageCode,
|
||||||
coverImageUrl = "$coverImageHost/${audioContent.coverImage!!}",
|
coverImageUrl = "$coverImageHost/${audioContent.coverImage!!}",
|
||||||
contentUrl = audioContentUrl,
|
contentUrl = audioContentUrl,
|
||||||
themeStr = audioContent.theme!!.theme,
|
themeStr = audioContent.theme!!.theme,
|
||||||
@@ -745,7 +875,8 @@ class AudioContentService(
|
|||||||
previousContent = previousContent,
|
previousContent = previousContent,
|
||||||
nextContent = nextContent,
|
nextContent = nextContent,
|
||||||
buyerList = buyerList,
|
buyerList = buyerList,
|
||||||
isAvailableUsePoint = audioContent.isPointAvailable
|
isAvailableUsePoint = audioContent.isPointAvailable,
|
||||||
|
translated = translated
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -852,9 +983,27 @@ class AudioContentService(
|
|||||||
it
|
it
|
||||||
}
|
}
|
||||||
|
|
||||||
|
val contentIds = items.map { it.contentId }
|
||||||
|
val translatedContentList = if (contentIds.isNotEmpty()) {
|
||||||
|
val translations = contentTranslationRepository
|
||||||
|
.findByContentIdInAndLocale(contentIds = contentIds, locale = langContext.lang.code)
|
||||||
|
.associateBy { it.contentId }
|
||||||
|
|
||||||
|
items.map { item ->
|
||||||
|
val translatedTitle = translations[item.contentId]?.renderedPayload?.title
|
||||||
|
if (translatedTitle.isNullOrBlank()) {
|
||||||
|
item
|
||||||
|
} else {
|
||||||
|
item.copy(title = translatedTitle)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
items
|
||||||
|
}
|
||||||
|
|
||||||
return GetAudioContentListResponse(
|
return GetAudioContentListResponse(
|
||||||
totalCount = totalCount,
|
totalCount = totalCount,
|
||||||
items = items
|
items = translatedContentList
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -994,8 +1143,20 @@ class AudioContentService(
|
|||||||
orderByRandom: Boolean = false,
|
orderByRandom: Boolean = false,
|
||||||
isPointAvailableOnly: Boolean = false
|
isPointAvailableOnly: Boolean = false
|
||||||
): List<AudioContentMainItem> {
|
): List<AudioContentMainItem> {
|
||||||
return repository.getLatestContentByTheme(
|
/**
|
||||||
theme = theme,
|
* - AS-IS theme은 한글만 처리하도록 되어 있음
|
||||||
|
* - TO-BE 번역된 theme이 들어와도 동일한 동작을 하도록 처리
|
||||||
|
*/
|
||||||
|
val normalizedTheme = normalizeThemeForQuery(
|
||||||
|
themes = theme,
|
||||||
|
contentType = contentType,
|
||||||
|
isFree = isFree,
|
||||||
|
isAdult = isAdult,
|
||||||
|
isPointAvailableOnly = isPointAvailableOnly
|
||||||
|
)
|
||||||
|
|
||||||
|
val contentList = repository.getLatestContentByTheme(
|
||||||
|
theme = normalizedTheme,
|
||||||
contentType = contentType,
|
contentType = contentType,
|
||||||
offset = offset,
|
offset = offset,
|
||||||
limit = limit,
|
limit = limit,
|
||||||
@@ -1005,5 +1166,79 @@ class AudioContentService(
|
|||||||
orderByRandom = orderByRandom,
|
orderByRandom = orderByRandom,
|
||||||
isPointAvailableOnly = isPointAvailableOnly
|
isPointAvailableOnly = isPointAvailableOnly
|
||||||
)
|
)
|
||||||
|
|
||||||
|
val contentIds = contentList.map { it.contentId }
|
||||||
|
return 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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 {
|
||||||
|
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()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,5 +17,6 @@ data class CreateAudioContentRequest(
|
|||||||
val isCommentAvailable: Boolean = false,
|
val isCommentAvailable: Boolean = false,
|
||||||
val isFullDetailVisible: Boolean = true,
|
val isFullDetailVisible: Boolean = true,
|
||||||
val previewStartTime: String? = null,
|
val previewStartTime: String? = null,
|
||||||
val previewEndTime: String? = null
|
val previewEndTime: String? = null,
|
||||||
|
val languageCode: String? = null
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -3,11 +3,13 @@ package kr.co.vividnext.sodalive.content
|
|||||||
import com.querydsl.core.annotations.QueryProjection
|
import com.querydsl.core.annotations.QueryProjection
|
||||||
import kr.co.vividnext.sodalive.content.comment.GetAudioContentCommentListItem
|
import kr.co.vividnext.sodalive.content.comment.GetAudioContentCommentListItem
|
||||||
import kr.co.vividnext.sodalive.content.order.OrderType
|
import kr.co.vividnext.sodalive.content.order.OrderType
|
||||||
|
import kr.co.vividnext.sodalive.content.translation.TranslatedContent
|
||||||
|
|
||||||
data class GetAudioContentDetailResponse(
|
data class GetAudioContentDetailResponse(
|
||||||
val contentId: Long,
|
val contentId: Long,
|
||||||
val title: String,
|
val title: String,
|
||||||
val detail: String,
|
val detail: String,
|
||||||
|
val languageCode: String?,
|
||||||
val coverImageUrl: String,
|
val coverImageUrl: String,
|
||||||
val contentUrl: String,
|
val contentUrl: String,
|
||||||
val themeStr: String,
|
val themeStr: String,
|
||||||
@@ -39,7 +41,8 @@ data class GetAudioContentDetailResponse(
|
|||||||
val previousContent: OtherContentResponse?,
|
val previousContent: OtherContentResponse?,
|
||||||
val nextContent: OtherContentResponse?,
|
val nextContent: OtherContentResponse?,
|
||||||
val buyerList: List<ContentBuyer>,
|
val buyerList: List<ContentBuyer>,
|
||||||
val isAvailableUsePoint: Boolean
|
val isAvailableUsePoint: Boolean,
|
||||||
|
val translated: TranslatedContent?
|
||||||
)
|
)
|
||||||
|
|
||||||
data class OtherContentResponse @QueryProjection constructor(
|
data class OtherContentResponse @QueryProjection constructor(
|
||||||
|
|||||||
@@ -0,0 +1,308 @@
|
|||||||
|
package kr.co.vividnext.sodalive.content
|
||||||
|
|
||||||
|
import kr.co.vividnext.sodalive.chat.character.comment.CharacterCommentRepository
|
||||||
|
import kr.co.vividnext.sodalive.chat.character.repository.ChatCharacterRepository
|
||||||
|
import kr.co.vividnext.sodalive.content.comment.AudioContentCommentRepository
|
||||||
|
import kr.co.vividnext.sodalive.explorer.profile.CreatorCheersRepository
|
||||||
|
import kr.co.vividnext.sodalive.i18n.translation.LanguageTranslationEvent
|
||||||
|
import kr.co.vividnext.sodalive.i18n.translation.LanguageTranslationTargetType
|
||||||
|
import org.slf4j.LoggerFactory
|
||||||
|
import org.springframework.beans.factory.annotation.Value
|
||||||
|
import org.springframework.context.ApplicationEventPublisher
|
||||||
|
import org.springframework.http.HttpEntity
|
||||||
|
import org.springframework.http.HttpHeaders
|
||||||
|
import org.springframework.http.MediaType
|
||||||
|
import org.springframework.scheduling.annotation.Async
|
||||||
|
import org.springframework.stereotype.Component
|
||||||
|
import org.springframework.transaction.annotation.Propagation
|
||||||
|
import org.springframework.transaction.annotation.Transactional
|
||||||
|
import org.springframework.transaction.event.TransactionPhase
|
||||||
|
import org.springframework.transaction.event.TransactionalEventListener
|
||||||
|
import org.springframework.util.LinkedMultiValueMap
|
||||||
|
import org.springframework.web.client.RestTemplate
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 텍스트 기반 데이터(콘텐츠, 댓글 등)에 대해 파파고 언어 감지를 요청하기 위한 이벤트.
|
||||||
|
*/
|
||||||
|
enum class LanguageDetectTargetType {
|
||||||
|
CONTENT,
|
||||||
|
COMMENT,
|
||||||
|
CHARACTER,
|
||||||
|
CHARACTER_COMMENT,
|
||||||
|
CREATOR_CHEERS
|
||||||
|
}
|
||||||
|
|
||||||
|
class LanguageDetectEvent(
|
||||||
|
val id: Long,
|
||||||
|
val query: String,
|
||||||
|
val targetType: LanguageDetectTargetType = LanguageDetectTargetType.CONTENT
|
||||||
|
)
|
||||||
|
|
||||||
|
data class PapagoLanguageDetectResponse(
|
||||||
|
val langCode: String?
|
||||||
|
)
|
||||||
|
|
||||||
|
@Component
|
||||||
|
class LanguageDetectListener(
|
||||||
|
private val audioContentRepository: AudioContentRepository,
|
||||||
|
private val audioContentCommentRepository: AudioContentCommentRepository,
|
||||||
|
private val chatCharacterRepository: ChatCharacterRepository,
|
||||||
|
private val characterCommentRepository: CharacterCommentRepository,
|
||||||
|
private val creatorCheersRepository: CreatorCheersRepository,
|
||||||
|
|
||||||
|
private val applicationEventPublisher: ApplicationEventPublisher,
|
||||||
|
|
||||||
|
@Value("\${cloud.naver.papago-client-id}")
|
||||||
|
private val papagoClientId: String,
|
||||||
|
|
||||||
|
@Value("\${cloud.naver.papago-client-secret}")
|
||||||
|
private val papagoClientSecret: String
|
||||||
|
) {
|
||||||
|
|
||||||
|
private val log = LoggerFactory.getLogger(LanguageDetectListener::class.java)
|
||||||
|
|
||||||
|
private val restTemplate: RestTemplate = RestTemplate()
|
||||||
|
|
||||||
|
private val papagoDetectUrl = "https://papago.apigw.ntruss.com/langs/v1/dect"
|
||||||
|
|
||||||
|
@Async
|
||||||
|
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
|
||||||
|
@Transactional(propagation = Propagation.REQUIRES_NEW)
|
||||||
|
fun detectLanguage(event: LanguageDetectEvent) {
|
||||||
|
if (event.query.isBlank()) {
|
||||||
|
log.debug("[PapagoLanguageDetect] query is blank. Skip language detection. event={}", event)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
when (event.targetType) {
|
||||||
|
LanguageDetectTargetType.CONTENT -> handleContentLanguageDetect(event)
|
||||||
|
LanguageDetectTargetType.COMMENT -> handleCommentLanguageDetect(event)
|
||||||
|
LanguageDetectTargetType.CHARACTER -> handleCharacterLanguageDetect(event)
|
||||||
|
LanguageDetectTargetType.CHARACTER_COMMENT -> handleCharacterCommentLanguageDetect(event)
|
||||||
|
LanguageDetectTargetType.CREATOR_CHEERS -> handleCreatorCheersLanguageDetect(event)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun handleCharacterLanguageDetect(event: LanguageDetectEvent) {
|
||||||
|
val characterId = event.id
|
||||||
|
|
||||||
|
val character = chatCharacterRepository.findById(characterId).orElse(null)
|
||||||
|
if (character == null) {
|
||||||
|
log.warn("[PapagoLanguageDetect] ChatCharacter not found. characterId={}", characterId)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 이미 언어 코드가 설정된 경우 호출하지 않음
|
||||||
|
if (!character.languageCode.isNullOrBlank()) {
|
||||||
|
log.debug(
|
||||||
|
"[PapagoLanguageDetect] languageCode already set. Skip language detection. characterId={}, languageCode={}",
|
||||||
|
characterId,
|
||||||
|
character.languageCode
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
val langCode = requestPapagoLanguageCode(event.query, characterId) ?: return
|
||||||
|
|
||||||
|
character.languageCode = langCode
|
||||||
|
chatCharacterRepository.save(character)
|
||||||
|
|
||||||
|
applicationEventPublisher.publishEvent(
|
||||||
|
LanguageTranslationEvent(
|
||||||
|
id = characterId,
|
||||||
|
targetType = LanguageTranslationTargetType.CHARACTER
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
log.info(
|
||||||
|
"[PapagoLanguageDetect] languageCode updated from Papago. characterId={}, langCode={}",
|
||||||
|
characterId,
|
||||||
|
langCode
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun handleContentLanguageDetect(event: LanguageDetectEvent) {
|
||||||
|
val contentId = event.id
|
||||||
|
|
||||||
|
val audioContent = audioContentRepository.findById(contentId).orElse(null)
|
||||||
|
if (audioContent == null) {
|
||||||
|
log.warn("[PapagoLanguageDetect] AudioContent not found. contentId={}", contentId)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 이미 언어 코드가 설정된 경우 호출하지 않음
|
||||||
|
if (!audioContent.languageCode.isNullOrBlank()) {
|
||||||
|
log.debug(
|
||||||
|
"[PapagoLanguageDetect] languageCode already set. Skip language detection. contentId={}, languageCode={}",
|
||||||
|
contentId,
|
||||||
|
audioContent.languageCode
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
val langCode = requestPapagoLanguageCode(event.query, contentId) ?: return
|
||||||
|
|
||||||
|
audioContent.languageCode = langCode
|
||||||
|
|
||||||
|
// REQUIRES_NEW 트랜잭션 내에서 변경 내용을 저장한다.
|
||||||
|
audioContentRepository.save(audioContent)
|
||||||
|
|
||||||
|
applicationEventPublisher.publishEvent(
|
||||||
|
LanguageTranslationEvent(
|
||||||
|
id = contentId,
|
||||||
|
targetType = LanguageTranslationTargetType.CONTENT
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
log.info(
|
||||||
|
"[PapagoLanguageDetect] languageCode updated from Papago. contentId={}, langCode={}",
|
||||||
|
contentId,
|
||||||
|
langCode
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun handleCommentLanguageDetect(event: LanguageDetectEvent) {
|
||||||
|
val commentId = event.id
|
||||||
|
|
||||||
|
val comment = audioContentCommentRepository.findById(commentId).orElse(null)
|
||||||
|
if (comment == null) {
|
||||||
|
log.warn("[PapagoLanguageDetect] AudioContentComment not found. commentId={}", commentId)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 이미 언어 코드가 설정된 경우 호출하지 않음
|
||||||
|
if (!comment.languageCode.isNullOrBlank()) {
|
||||||
|
log.debug(
|
||||||
|
"[PapagoLanguageDetect] languageCode already set. Skip language detection. commentId={}, languageCode={}",
|
||||||
|
commentId,
|
||||||
|
comment.languageCode
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
val langCode = requestPapagoLanguageCode(event.query, commentId) ?: return
|
||||||
|
|
||||||
|
comment.languageCode = langCode
|
||||||
|
audioContentCommentRepository.save(comment)
|
||||||
|
|
||||||
|
log.info(
|
||||||
|
"[PapagoLanguageDetect] languageCode updated from Papago. commentId={}, langCode={}",
|
||||||
|
commentId,
|
||||||
|
langCode
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun handleCharacterCommentLanguageDetect(event: LanguageDetectEvent) {
|
||||||
|
val commentId = event.id
|
||||||
|
|
||||||
|
val comment = characterCommentRepository.findById(commentId).orElse(null)
|
||||||
|
if (comment == null) {
|
||||||
|
log.warn("[PapagoLanguageDetect] CharacterComment not found. commentId={}", commentId)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 이미 언어 코드가 설정된 경우 호출하지 않음
|
||||||
|
if (!comment.languageCode.isNullOrBlank()) {
|
||||||
|
log.debug(
|
||||||
|
"[PapagoLanguageDetect] languageCode already set. Skip language detection. " +
|
||||||
|
"characterCommentId={}, languageCode={}",
|
||||||
|
commentId,
|
||||||
|
comment.languageCode
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
val langCode = requestPapagoLanguageCode(event.query, commentId) ?: return
|
||||||
|
|
||||||
|
comment.languageCode = langCode
|
||||||
|
characterCommentRepository.save(comment)
|
||||||
|
|
||||||
|
log.info(
|
||||||
|
"[PapagoLanguageDetect] languageCode updated from Papago. characterCommentId={}, langCode={}",
|
||||||
|
commentId,
|
||||||
|
langCode
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun handleCreatorCheersLanguageDetect(event: LanguageDetectEvent) {
|
||||||
|
val cheersId = event.id
|
||||||
|
|
||||||
|
val cheers = creatorCheersRepository.findById(cheersId).orElse(null)
|
||||||
|
if (cheers == null) {
|
||||||
|
log.warn("[PapagoLanguageDetect] CreatorCheers not found. cheersId={}", cheersId)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 이미 언어 코드가 설정된 경우 호출하지 않음
|
||||||
|
if (!cheers.languageCode.isNullOrBlank()) {
|
||||||
|
log.debug(
|
||||||
|
"[PapagoLanguageDetect] languageCode already set. Skip language detection. cheersId={}, languageCode={}",
|
||||||
|
cheersId,
|
||||||
|
cheers.languageCode
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
val langCode = requestPapagoLanguageCode(event.query, cheersId) ?: return
|
||||||
|
|
||||||
|
cheers.languageCode = langCode
|
||||||
|
creatorCheersRepository.save(cheers)
|
||||||
|
|
||||||
|
log.info(
|
||||||
|
"[PapagoLanguageDetect] languageCode updated from Papago. cheersId={}, langCode={}",
|
||||||
|
cheersId,
|
||||||
|
langCode
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun requestPapagoLanguageCode(query: String, targetIdForLog: Long): String? {
|
||||||
|
return try {
|
||||||
|
val headers = HttpHeaders().apply {
|
||||||
|
contentType = MediaType.APPLICATION_FORM_URLENCODED
|
||||||
|
set("X-NCP-APIGW-API-KEY-ID", papagoClientId)
|
||||||
|
set("X-NCP-APIGW-API-KEY", papagoClientSecret)
|
||||||
|
}
|
||||||
|
|
||||||
|
val body = LinkedMultiValueMap<String, String>().apply {
|
||||||
|
// 파파고 스펙에 따라 query 파라미터에 텍스트를 공백으로 구분하여 전달
|
||||||
|
add("query", query)
|
||||||
|
}
|
||||||
|
|
||||||
|
val requestEntity = HttpEntity(body, headers)
|
||||||
|
|
||||||
|
val response = restTemplate.postForEntity(
|
||||||
|
papagoDetectUrl,
|
||||||
|
requestEntity,
|
||||||
|
PapagoLanguageDetectResponse::class.java
|
||||||
|
)
|
||||||
|
|
||||||
|
if (!response.statusCode.is2xxSuccessful) {
|
||||||
|
log.warn(
|
||||||
|
"[PapagoLanguageDetect] Non-success status from Papago. status={}, targetId={}",
|
||||||
|
response.statusCode,
|
||||||
|
targetIdForLog
|
||||||
|
)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
val langCode = response.body?.langCode?.takeIf { it.isNotBlank() }
|
||||||
|
if (langCode == null) {
|
||||||
|
log.warn(
|
||||||
|
"[PapagoLanguageDetect] langCode is null or blank in Papago response. targetId={}",
|
||||||
|
targetIdForLog
|
||||||
|
)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
langCode
|
||||||
|
} catch (ex: Exception) {
|
||||||
|
// 언어 감지는 부가 기능이므로, 실패 시 예외를 전파하지 않고 로그만 남긴다.
|
||||||
|
log.error(
|
||||||
|
"[PapagoLanguageDetect] Failed to detect language via Papago. targetId={}",
|
||||||
|
targetIdForLog,
|
||||||
|
ex
|
||||||
|
)
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -16,6 +16,7 @@ import javax.persistence.Table
|
|||||||
data class AudioContentComment(
|
data class AudioContentComment(
|
||||||
@Column(columnDefinition = "TEXT", nullable = false)
|
@Column(columnDefinition = "TEXT", nullable = false)
|
||||||
var comment: String,
|
var comment: String,
|
||||||
|
var languageCode: String?,
|
||||||
@Column(nullable = true)
|
@Column(nullable = true)
|
||||||
var donationCan: Int? = null,
|
var donationCan: Int? = null,
|
||||||
val isSecret: Boolean = false,
|
val isSecret: Boolean = false,
|
||||||
|
|||||||
@@ -32,7 +32,8 @@ class AudioContentCommentController(
|
|||||||
audioContentId = request.contentId,
|
audioContentId = request.contentId,
|
||||||
parentId = request.parentId,
|
parentId = request.parentId,
|
||||||
isSecret = request.isSecret,
|
isSecret = request.isSecret,
|
||||||
member = member
|
member = member,
|
||||||
|
languageCode = request.languageCode
|
||||||
)
|
)
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -85,6 +85,7 @@ class AudioContentCommentQueryRepositoryImpl(
|
|||||||
audioContentComment.member.nickname,
|
audioContentComment.member.nickname,
|
||||||
audioContentComment.member.profileImage.prepend("/").prepend(cloudFrontHost),
|
audioContentComment.member.profileImage.prepend("/").prepend(cloudFrontHost),
|
||||||
audioContentComment.comment,
|
audioContentComment.comment,
|
||||||
|
audioContentComment.languageCode,
|
||||||
audioContentComment.isSecret,
|
audioContentComment.isSecret,
|
||||||
audioContentComment.donationCan.coalesce(0),
|
audioContentComment.donationCan.coalesce(0),
|
||||||
formattedDate,
|
formattedDate,
|
||||||
@@ -166,6 +167,7 @@ class AudioContentCommentQueryRepositoryImpl(
|
|||||||
audioContentComment.member.nickname,
|
audioContentComment.member.nickname,
|
||||||
audioContentComment.member.profileImage.prepend("/").prepend(cloudFrontHost),
|
audioContentComment.member.profileImage.prepend("/").prepend(cloudFrontHost),
|
||||||
audioContentComment.comment,
|
audioContentComment.comment,
|
||||||
|
audioContentComment.languageCode,
|
||||||
audioContentComment.isSecret,
|
audioContentComment.isSecret,
|
||||||
audioContentComment.donationCan.coalesce(0),
|
audioContentComment.donationCan.coalesce(0),
|
||||||
formattedDate,
|
formattedDate,
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ package kr.co.vividnext.sodalive.content.comment
|
|||||||
|
|
||||||
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.order.OrderRepository
|
import kr.co.vividnext.sodalive.content.order.OrderRepository
|
||||||
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
|
||||||
@@ -32,7 +34,8 @@ class AudioContentCommentService(
|
|||||||
comment: String,
|
comment: String,
|
||||||
audioContentId: Long,
|
audioContentId: Long,
|
||||||
parentId: Long? = null,
|
parentId: Long? = null,
|
||||||
isSecret: Boolean = false
|
isSecret: Boolean = false,
|
||||||
|
languageCode: String?
|
||||||
): Long {
|
): Long {
|
||||||
val audioContent = audioContentRepository.findByIdOrNull(id = audioContentId)
|
val audioContent = audioContentRepository.findByIdOrNull(id = audioContentId)
|
||||||
?: throw SodaException("잘못된 콘텐츠 입니다.\n다시 시도해 주세요.")
|
?: throw SodaException("잘못된 콘텐츠 입니다.\n다시 시도해 주세요.")
|
||||||
@@ -50,7 +53,7 @@ class AudioContentCommentService(
|
|||||||
throw SodaException("콘텐츠 구매 후 비밀댓글을 등록할 수 있습니다.")
|
throw SodaException("콘텐츠 구매 후 비밀댓글을 등록할 수 있습니다.")
|
||||||
}
|
}
|
||||||
|
|
||||||
val audioContentComment = AudioContentComment(comment = comment, isSecret = isSecret)
|
val audioContentComment = AudioContentComment(comment = comment, languageCode = languageCode, isSecret = isSecret)
|
||||||
audioContentComment.audioContent = audioContent
|
audioContentComment.audioContent = audioContent
|
||||||
audioContentComment.member = member
|
audioContentComment.member = member
|
||||||
|
|
||||||
@@ -85,6 +88,17 @@ class AudioContentCommentService(
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// 언어 코드가 지정되지 않은 경우, 파파고 언어 감지 API를 통해 비동기로 언어를 식별한다.
|
||||||
|
if (languageCode.isNullOrBlank()) {
|
||||||
|
applicationEventPublisher.publishEvent(
|
||||||
|
LanguageDetectEvent(
|
||||||
|
id = savedContentComment.id!!,
|
||||||
|
query = comment,
|
||||||
|
targetType = LanguageDetectTargetType.COMMENT
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
return savedContentComment.id!!
|
return savedContentComment.id!!
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ data class GetAudioContentCommentListItem @QueryProjection constructor(
|
|||||||
val nickname: String,
|
val nickname: String,
|
||||||
val profileUrl: String,
|
val profileUrl: String,
|
||||||
val comment: String,
|
val comment: String,
|
||||||
|
val languageCode: String?,
|
||||||
val isSecret: Boolean,
|
val isSecret: Boolean,
|
||||||
val donationCan: Int,
|
val donationCan: Int,
|
||||||
val date: String,
|
val date: String,
|
||||||
|
|||||||
@@ -4,5 +4,6 @@ data class RegisterCommentRequest(
|
|||||||
val comment: String,
|
val comment: String,
|
||||||
val contentId: Long,
|
val contentId: Long,
|
||||||
val parentId: Long?,
|
val parentId: Long?,
|
||||||
val isSecret: Boolean = false
|
val isSecret: Boolean = false,
|
||||||
|
val languageCode: String? = null
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -4,5 +4,6 @@ data class AudioContentDonationRequest(
|
|||||||
val contentId: Long,
|
val contentId: Long,
|
||||||
val donationCan: Int,
|
val donationCan: Int,
|
||||||
val comment: String,
|
val comment: String,
|
||||||
val container: String
|
val container: String,
|
||||||
|
val languageCode: String? = null
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -4,9 +4,12 @@ import kr.co.vividnext.sodalive.can.payment.CanPaymentService
|
|||||||
import kr.co.vividnext.sodalive.can.use.CanUsage
|
import kr.co.vividnext.sodalive.can.use.CanUsage
|
||||||
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.comment.AudioContentComment
|
import kr.co.vividnext.sodalive.content.comment.AudioContentComment
|
||||||
import kr.co.vividnext.sodalive.content.comment.AudioContentCommentRepository
|
import kr.co.vividnext.sodalive.content.comment.AudioContentCommentRepository
|
||||||
import kr.co.vividnext.sodalive.member.Member
|
import kr.co.vividnext.sodalive.member.Member
|
||||||
|
import org.springframework.context.ApplicationEventPublisher
|
||||||
import org.springframework.stereotype.Service
|
import org.springframework.stereotype.Service
|
||||||
import org.springframework.transaction.annotation.Transactional
|
import org.springframework.transaction.annotation.Transactional
|
||||||
|
|
||||||
@@ -14,7 +17,8 @@ import org.springframework.transaction.annotation.Transactional
|
|||||||
class AudioContentDonationService(
|
class AudioContentDonationService(
|
||||||
private val canPaymentService: CanPaymentService,
|
private val canPaymentService: CanPaymentService,
|
||||||
private val queryRepository: AudioContentRepository,
|
private val queryRepository: AudioContentRepository,
|
||||||
private val commentRepository: AudioContentCommentRepository
|
private val commentRepository: AudioContentCommentRepository,
|
||||||
|
private val applicationEventPublisher: ApplicationEventPublisher
|
||||||
) {
|
) {
|
||||||
@Transactional
|
@Transactional
|
||||||
fun donation(request: AudioContentDonationRequest, member: Member) {
|
fun donation(request: AudioContentDonationRequest, member: Member) {
|
||||||
@@ -34,10 +38,23 @@ class AudioContentDonationService(
|
|||||||
|
|
||||||
val audioContentComment = AudioContentComment(
|
val audioContentComment = AudioContentComment(
|
||||||
comment = request.comment,
|
comment = request.comment,
|
||||||
|
languageCode = request.languageCode,
|
||||||
donationCan = request.donationCan
|
donationCan = request.donationCan
|
||||||
)
|
)
|
||||||
audioContentComment.audioContent = audioContent
|
audioContentComment.audioContent = audioContent
|
||||||
audioContentComment.member = member
|
audioContentComment.member = member
|
||||||
commentRepository.save(audioContentComment)
|
|
||||||
|
val savedComment = commentRepository.save(audioContentComment)
|
||||||
|
|
||||||
|
// 언어 코드가 지정되지 않은 경우, 파파고 언어 감지 API를 통해 비동기로 언어를 식별한다.
|
||||||
|
if (request.languageCode.isNullOrBlank()) {
|
||||||
|
applicationEventPublisher.publishEvent(
|
||||||
|
LanguageDetectEvent(
|
||||||
|
id = savedComment.id!!,
|
||||||
|
query = request.comment,
|
||||||
|
targetType = LanguageDetectTargetType.COMMENT
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,7 +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.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
|
||||||
@@ -20,14 +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 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,8 +83,12 @@ class AudioContentMainService(
|
|||||||
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
|
||||||
@@ -70,13 +97,19 @@ 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!!,
|
||||||
isAdult = isAdult,
|
isAdult = isAdult,
|
||||||
contentType = contentType
|
contentType = contentType
|
||||||
)
|
)
|
||||||
val items = repository.findByThemeFor2Weeks(
|
val contentList = repository.findByThemeFor2Weeks(
|
||||||
cloudfrontHost = imageHost,
|
cloudfrontHost = imageHost,
|
||||||
memberId = member.id!!,
|
memberId = member.id!!,
|
||||||
theme = themeList,
|
theme = themeList,
|
||||||
@@ -87,7 +120,75 @@ class AudioContentMainService(
|
|||||||
)
|
)
|
||||||
.filter { !blockMemberRepository.isBlocked(blockedMemberId = member.id!!, memberId = it.creatorId) }
|
.filter { !blockMemberRepository.isBlocked(blockedMemberId = member.id!!, memberId = it.creatorId) }
|
||||||
|
|
||||||
return GetNewContentAllResponse(totalCount, items)
|
val contentIds = contentList.map { it.contentId }
|
||||||
|
val translatedContentList = 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 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)
|
||||||
|
|||||||
@@ -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>
|
||||||
|
}
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
package kr.co.vividnext.sodalive.content.translation
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
|
||||||
|
import com.fasterxml.jackson.module.kotlin.readValue
|
||||||
|
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 = ["contentId", "locale"])
|
||||||
|
]
|
||||||
|
)
|
||||||
|
class ContentTranslation(
|
||||||
|
val contentId: Long,
|
||||||
|
val locale: String,
|
||||||
|
|
||||||
|
@Column(columnDefinition = "json")
|
||||||
|
@Convert(converter = ContentTranslationPayloadConverter::class)
|
||||||
|
var renderedPayload: ContentTranslationPayload
|
||||||
|
) : BaseEntity()
|
||||||
|
|
||||||
|
data class ContentTranslationPayload(
|
||||||
|
val title: String,
|
||||||
|
val detail: String,
|
||||||
|
val tags: String
|
||||||
|
)
|
||||||
|
|
||||||
|
@Converter(autoApply = false)
|
||||||
|
class ContentTranslationPayloadConverter : AttributeConverter<ContentTranslationPayload, String> {
|
||||||
|
|
||||||
|
override fun convertToDatabaseColumn(attribute: ContentTranslationPayload?): String {
|
||||||
|
if (attribute == null) return "{}"
|
||||||
|
return objectMapper.writeValueAsString(attribute)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun convertToEntityAttribute(dbData: String?): ContentTranslationPayload {
|
||||||
|
if (dbData.isNullOrBlank()) {
|
||||||
|
return ContentTranslationPayload(
|
||||||
|
title = "",
|
||||||
|
detail = "",
|
||||||
|
tags = ""
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return objectMapper.readValue(dbData)
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private val objectMapper = jacksonObjectMapper()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
data class TranslatedContent(
|
||||||
|
val title: String?,
|
||||||
|
val detail: String?,
|
||||||
|
val tags: String?
|
||||||
|
)
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
package kr.co.vividnext.sodalive.content.translation
|
||||||
|
|
||||||
|
import org.springframework.data.jpa.repository.JpaRepository
|
||||||
|
|
||||||
|
interface ContentTranslationRepository : JpaRepository<ContentTranslation, Long> {
|
||||||
|
fun findByContentIdAndLocale(contentId: Long, locale: String): ContentTranslation?
|
||||||
|
|
||||||
|
fun findByContentIdInAndLocale(contentIds: List<Long>, locale: String): List<ContentTranslation>
|
||||||
|
}
|
||||||
@@ -488,6 +488,7 @@ class ExplorerQueryRepository(
|
|||||||
"$cloudFrontHost/profile/default-profile.png"
|
"$cloudFrontHost/profile/default-profile.png"
|
||||||
},
|
},
|
||||||
content = it.cheers,
|
content = it.cheers,
|
||||||
|
languageCode = it.languageCode,
|
||||||
date = date.format(DateTimeFormatter.ofPattern("yyyy.MM.dd E hh:mm a")),
|
date = date.format(DateTimeFormatter.ofPattern("yyyy.MM.dd E hh:mm a")),
|
||||||
replyList = it.children.asSequence()
|
replyList = it.children.asSequence()
|
||||||
.map { cheers ->
|
.map { cheers ->
|
||||||
@@ -505,6 +506,7 @@ class ExplorerQueryRepository(
|
|||||||
"$cloudFrontHost/profile/default-profile.png"
|
"$cloudFrontHost/profile/default-profile.png"
|
||||||
},
|
},
|
||||||
content = cheers.cheers,
|
content = cheers.cheers,
|
||||||
|
languageCode = cheers.languageCode,
|
||||||
date = replyDate.format(DateTimeFormatter.ofPattern("yyyy.MM.dd E hh:mm a")),
|
date = replyDate.format(DateTimeFormatter.ofPattern("yyyy.MM.dd E hh:mm a")),
|
||||||
replyList = listOf()
|
replyList = listOf()
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -3,8 +3,11 @@ package kr.co.vividnext.sodalive.explorer
|
|||||||
import kr.co.vividnext.sodalive.common.SodaException
|
import kr.co.vividnext.sodalive.common.SodaException
|
||||||
import kr.co.vividnext.sodalive.content.AudioContentService
|
import kr.co.vividnext.sodalive.content.AudioContentService
|
||||||
import kr.co.vividnext.sodalive.content.ContentType
|
import kr.co.vividnext.sodalive.content.ContentType
|
||||||
|
import kr.co.vividnext.sodalive.content.LanguageDetectEvent
|
||||||
|
import kr.co.vividnext.sodalive.content.LanguageDetectTargetType
|
||||||
import kr.co.vividnext.sodalive.content.SortType
|
import kr.co.vividnext.sodalive.content.SortType
|
||||||
import kr.co.vividnext.sodalive.content.series.ContentSeriesService
|
import kr.co.vividnext.sodalive.content.series.ContentSeriesService
|
||||||
|
import kr.co.vividnext.sodalive.content.translation.ContentTranslationRepository
|
||||||
import kr.co.vividnext.sodalive.explorer.follower.GetFollowerListResponse
|
import kr.co.vividnext.sodalive.explorer.follower.GetFollowerListResponse
|
||||||
import kr.co.vividnext.sodalive.explorer.follower.GetFollowerListResponseItem
|
import kr.co.vividnext.sodalive.explorer.follower.GetFollowerListResponseItem
|
||||||
import kr.co.vividnext.sodalive.explorer.profile.ChannelNotice
|
import kr.co.vividnext.sodalive.explorer.profile.ChannelNotice
|
||||||
@@ -16,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
|
||||||
@@ -44,6 +48,9 @@ class ExplorerService(
|
|||||||
private val seriesService: ContentSeriesService,
|
private val seriesService: ContentSeriesService,
|
||||||
|
|
||||||
private val applicationEventPublisher: ApplicationEventPublisher,
|
private val applicationEventPublisher: ApplicationEventPublisher,
|
||||||
|
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
|
||||||
@@ -230,6 +237,24 @@ class ExplorerService(
|
|||||||
listOf()
|
listOf()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
val contentIds = contentList.map { it.contentId }
|
||||||
|
val translatedContentList = 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
|
||||||
|
}
|
||||||
|
|
||||||
// 크리에이터의 최신 오디오 콘텐츠 1개
|
// 크리에이터의 최신 오디오 콘텐츠 1개
|
||||||
val latestContent = if (isCreator) {
|
val latestContent = if (isCreator) {
|
||||||
audioContentService.getLatestCreatorAudioContent(creatorId, member, isAdultContentVisible)
|
audioContentService.getLatestCreatorAudioContent(creatorId, member, isAdultContentVisible)
|
||||||
@@ -330,7 +355,7 @@ class ExplorerService(
|
|||||||
userDonationRanking = memberDonationRanking,
|
userDonationRanking = memberDonationRanking,
|
||||||
similarCreatorList = similarCreatorList,
|
similarCreatorList = similarCreatorList,
|
||||||
liveRoomList = liveRoomList,
|
liveRoomList = liveRoomList,
|
||||||
contentList = contentList,
|
contentList = translatedContentList,
|
||||||
latestContent = latestContent,
|
latestContent = latestContent,
|
||||||
totalContentCount = totalContentCount,
|
totalContentCount = totalContentCount,
|
||||||
ownedContentCount = ownedContentCount,
|
ownedContentCount = ownedContentCount,
|
||||||
@@ -441,7 +466,7 @@ class ExplorerService(
|
|||||||
val isBlocked = memberService.isBlocked(blockedMemberId = member.id!!, memberId = request.creatorId)
|
val isBlocked = memberService.isBlocked(blockedMemberId = member.id!!, memberId = request.creatorId)
|
||||||
if (isBlocked) throw SodaException("${creator.nickname}님의 요청으로 팬토크 작성이 제한됩니다.")
|
if (isBlocked) throw SodaException("${creator.nickname}님의 요청으로 팬토크 작성이 제한됩니다.")
|
||||||
|
|
||||||
val cheers = CreatorCheers(cheers = request.content)
|
val cheers = CreatorCheers(cheers = request.content, languageCode = request.languageCode)
|
||||||
cheers.member = member
|
cheers.member = member
|
||||||
cheers.creator = creator
|
cheers.creator = creator
|
||||||
|
|
||||||
@@ -456,6 +481,17 @@ class ExplorerService(
|
|||||||
}
|
}
|
||||||
|
|
||||||
cheersRepository.save(cheers)
|
cheersRepository.save(cheers)
|
||||||
|
|
||||||
|
// 언어 코드가 지정되지 않은 경우, 파파고 언어 감지 API를 통해 비동기로 언어를 식별한다.
|
||||||
|
if (request.languageCode.isNullOrBlank()) {
|
||||||
|
applicationEventPublisher.publishEvent(
|
||||||
|
LanguageDetectEvent(
|
||||||
|
id = cheers.id!!,
|
||||||
|
query = request.content,
|
||||||
|
targetType = LanguageDetectTargetType.CREATOR_CHEERS
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun getCreatorProfileCheers(
|
fun getCreatorProfileCheers(
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ data class GetCheersResponseItem(
|
|||||||
val nickname: String,
|
val nickname: String,
|
||||||
val profileUrl: String,
|
val profileUrl: String,
|
||||||
val content: String,
|
val content: String,
|
||||||
|
val languageCode: String?,
|
||||||
val date: String,
|
val date: String,
|
||||||
val replyList: List<GetCheersResponseItem>
|
val replyList: List<GetCheersResponseItem>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import javax.persistence.OneToMany
|
|||||||
data class CreatorCheers(
|
data class CreatorCheers(
|
||||||
@Column(columnDefinition = "TEXT", nullable = false)
|
@Column(columnDefinition = "TEXT", nullable = false)
|
||||||
var cheers: String,
|
var cheers: String,
|
||||||
|
var languageCode: String?,
|
||||||
var isActive: Boolean = true
|
var isActive: Boolean = true
|
||||||
) : BaseEntity() {
|
) : BaseEntity() {
|
||||||
@ManyToOne(fetch = FetchType.LAZY)
|
@ManyToOne(fetch = FetchType.LAZY)
|
||||||
|
|||||||
@@ -3,5 +3,6 @@ package kr.co.vividnext.sodalive.explorer.profile
|
|||||||
data class PostWriteCheersRequest(
|
data class PostWriteCheersRequest(
|
||||||
val parentId: Long? = null,
|
val parentId: Long? = null,
|
||||||
val creatorId: Long,
|
val creatorId: Long,
|
||||||
val content: String
|
val content: String,
|
||||||
|
val languageCode: String? = null
|
||||||
)
|
)
|
||||||
|
|||||||
22
src/main/kotlin/kr/co/vividnext/sodalive/i18n/Lang.kt
Normal file
22
src/main/kotlin/kr/co/vividnext/sodalive/i18n/Lang.kt
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
package kr.co.vividnext.sodalive.i18n
|
||||||
|
|
||||||
|
import java.util.Locale
|
||||||
|
|
||||||
|
enum class Lang(val code: String, val locale: Locale) {
|
||||||
|
KO("ko", Locale.KOREAN),
|
||||||
|
EN("en", Locale.ENGLISH),
|
||||||
|
JA("ja", Locale.JAPANESE);
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
fun fromAcceptLanguage(header: String?): Lang {
|
||||||
|
if (header.isNullOrBlank()) return KO
|
||||||
|
val two = header.trim().lowercase().take(2) // 앱은 2자리만 보내지만 안전하게 처리
|
||||||
|
return when (two) {
|
||||||
|
"ko" -> KO
|
||||||
|
"en" -> EN
|
||||||
|
"ja" -> JA
|
||||||
|
else -> KO
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
15
src/main/kotlin/kr/co/vividnext/sodalive/i18n/LangContext.kt
Normal file
15
src/main/kotlin/kr/co/vividnext/sodalive/i18n/LangContext.kt
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
package kr.co.vividnext.sodalive.i18n
|
||||||
|
|
||||||
|
import org.springframework.stereotype.Component
|
||||||
|
import org.springframework.web.context.annotation.RequestScope
|
||||||
|
|
||||||
|
@Component
|
||||||
|
@RequestScope
|
||||||
|
class LangContext {
|
||||||
|
var lang: Lang = Lang.KO
|
||||||
|
internal set
|
||||||
|
|
||||||
|
fun setLang(lang: Lang) {
|
||||||
|
this.lang = lang
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
package kr.co.vividnext.sodalive.i18n
|
||||||
|
|
||||||
|
import org.springframework.stereotype.Component
|
||||||
|
import org.springframework.web.servlet.HandlerInterceptor
|
||||||
|
import javax.servlet.http.HttpServletRequest
|
||||||
|
import javax.servlet.http.HttpServletResponse
|
||||||
|
|
||||||
|
@Component
|
||||||
|
class LangInterceptor(
|
||||||
|
private val langContext: LangContext
|
||||||
|
) : HandlerInterceptor {
|
||||||
|
override fun preHandle(
|
||||||
|
request: HttpServletRequest,
|
||||||
|
response: HttpServletResponse,
|
||||||
|
handler: Any
|
||||||
|
): Boolean {
|
||||||
|
val acceptLanguage = request.getHeader("Accept-Language")
|
||||||
|
langContext.setLang(Lang.fromAcceptLanguage(acceptLanguage))
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,251 @@
|
|||||||
|
package kr.co.vividnext.sodalive.i18n.translation
|
||||||
|
|
||||||
|
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.AiCharacterTranslationRenderedPayload
|
||||||
|
import kr.co.vividnext.sodalive.chat.character.translate.AiCharacterTranslationRepository
|
||||||
|
import kr.co.vividnext.sodalive.chat.character.translate.TranslatedAiCharacterBackground
|
||||||
|
import kr.co.vividnext.sodalive.chat.character.translate.TranslatedAiCharacterPersonality
|
||||||
|
import kr.co.vividnext.sodalive.content.AudioContentRepository
|
||||||
|
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.ContentTranslationPayload
|
||||||
|
import kr.co.vividnext.sodalive.content.translation.ContentTranslationRepository
|
||||||
|
import kr.co.vividnext.sodalive.i18n.translation.PapagoTranslationService.Companion.getTranslatableLanguageCodes
|
||||||
|
import org.springframework.data.repository.findByIdOrNull
|
||||||
|
import org.springframework.scheduling.annotation.Async
|
||||||
|
import org.springframework.stereotype.Component
|
||||||
|
import org.springframework.transaction.annotation.Propagation
|
||||||
|
import org.springframework.transaction.annotation.Transactional
|
||||||
|
import org.springframework.transaction.event.TransactionPhase
|
||||||
|
import org.springframework.transaction.event.TransactionalEventListener
|
||||||
|
|
||||||
|
enum class LanguageTranslationTargetType {
|
||||||
|
CONTENT,
|
||||||
|
CHARACTER,
|
||||||
|
CONTENT_THEME
|
||||||
|
}
|
||||||
|
|
||||||
|
class LanguageTranslationEvent(
|
||||||
|
val id: Long,
|
||||||
|
val targetType: LanguageTranslationTargetType
|
||||||
|
)
|
||||||
|
|
||||||
|
@Component
|
||||||
|
class LanguageTranslationListener(
|
||||||
|
private val audioContentRepository: AudioContentRepository,
|
||||||
|
private val chatCharacterRepository: ChatCharacterRepository,
|
||||||
|
private val audioContentThemeRepository: AudioContentThemeQueryRepository,
|
||||||
|
|
||||||
|
private val contentTranslationRepository: ContentTranslationRepository,
|
||||||
|
private val aiCharacterTranslationRepository: AiCharacterTranslationRepository,
|
||||||
|
private val contentThemeTranslationRepository: ContentThemeTranslationRepository,
|
||||||
|
|
||||||
|
private val translationService: PapagoTranslationService
|
||||||
|
) {
|
||||||
|
@Async
|
||||||
|
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
|
||||||
|
@Transactional(propagation = Propagation.REQUIRES_NEW)
|
||||||
|
fun translation(event: LanguageTranslationEvent) {
|
||||||
|
when (event.targetType) {
|
||||||
|
LanguageTranslationTargetType.CONTENT -> handleContentLanguageTranslation(event)
|
||||||
|
LanguageTranslationTargetType.CHARACTER -> handleCharacterLanguageTranslation(event)
|
||||||
|
LanguageTranslationTargetType.CONTENT_THEME -> handleContentThemeLanguageTranslation(event)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun handleContentLanguageTranslation(event: LanguageTranslationEvent) {
|
||||||
|
val audioContent = audioContentRepository.findByIdOrNull(event.id) ?: return
|
||||||
|
val languageCode = audioContent.languageCode
|
||||||
|
if (languageCode != null) return
|
||||||
|
|
||||||
|
getTranslatableLanguageCodes(languageCode).forEach { locale ->
|
||||||
|
val tags = audioContent.audioContentHashTags
|
||||||
|
.mapNotNull { it.hashTag?.tag }
|
||||||
|
.joinToString(",")
|
||||||
|
|
||||||
|
val texts = mutableListOf<String>()
|
||||||
|
texts.add(audioContent.title)
|
||||||
|
texts.add(audioContent.detail)
|
||||||
|
texts.add(tags)
|
||||||
|
|
||||||
|
val sourceLanguage = audioContent.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 translatedDetail = translatedTexts[index++]
|
||||||
|
val translatedTags = translatedTexts[index]
|
||||||
|
|
||||||
|
val payload = ContentTranslationPayload(
|
||||||
|
title = translatedTitle,
|
||||||
|
detail = translatedDetail,
|
||||||
|
tags = translatedTags
|
||||||
|
)
|
||||||
|
|
||||||
|
val existing = contentTranslationRepository
|
||||||
|
.findByContentIdAndLocale(audioContent.id!!, locale)
|
||||||
|
|
||||||
|
if (existing == null) {
|
||||||
|
contentTranslationRepository.save(
|
||||||
|
ContentTranslation(
|
||||||
|
contentId = audioContent.id!!,
|
||||||
|
locale = locale,
|
||||||
|
renderedPayload = payload
|
||||||
|
)
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
existing.renderedPayload = payload
|
||||||
|
contentTranslationRepository.save(existing)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun handleCharacterLanguageTranslation(event: LanguageTranslationEvent) {
|
||||||
|
val character = chatCharacterRepository.findByIdOrNull(event.id) ?: return
|
||||||
|
val languageCode = character.languageCode
|
||||||
|
if (languageCode != null) return
|
||||||
|
|
||||||
|
getTranslatableLanguageCodes(languageCode).forEach { locale ->
|
||||||
|
val personality = character.personalities.firstOrNull()
|
||||||
|
val background = character.backgrounds.firstOrNull()
|
||||||
|
|
||||||
|
val tags = character.tagMappings.joinToString(",") { it.tag.tag }
|
||||||
|
|
||||||
|
val texts = mutableListOf<String>()
|
||||||
|
texts.add(character.name)
|
||||||
|
texts.add(character.description)
|
||||||
|
texts.add(character.gender ?: "")
|
||||||
|
|
||||||
|
val hasPersonality = personality != null
|
||||||
|
if (hasPersonality) {
|
||||||
|
texts.add(personality!!.trait)
|
||||||
|
texts.add(personality.description)
|
||||||
|
}
|
||||||
|
|
||||||
|
val hasBackground = background != null
|
||||||
|
if (hasBackground) {
|
||||||
|
texts.add(background!!.topic)
|
||||||
|
texts.add(background.description)
|
||||||
|
}
|
||||||
|
|
||||||
|
texts.add(tags)
|
||||||
|
|
||||||
|
val sourceLanguage = character.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 translatedName = translatedTexts[index++]
|
||||||
|
val translatedDescription = translatedTexts[index++]
|
||||||
|
val translatedGender = translatedTexts[index++]
|
||||||
|
|
||||||
|
var translatedPersonality: TranslatedAiCharacterPersonality? = null
|
||||||
|
if (hasPersonality) {
|
||||||
|
translatedPersonality = TranslatedAiCharacterPersonality(
|
||||||
|
trait = translatedTexts[index++],
|
||||||
|
description = translatedTexts[index++]
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
var translatedBackground: TranslatedAiCharacterBackground? = null
|
||||||
|
if (hasBackground) {
|
||||||
|
translatedBackground = TranslatedAiCharacterBackground(
|
||||||
|
topic = translatedTexts[index++],
|
||||||
|
description = translatedTexts[index++]
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
val translatedTags = translatedTexts[index]
|
||||||
|
|
||||||
|
val payload = AiCharacterTranslationRenderedPayload(
|
||||||
|
name = translatedName,
|
||||||
|
description = translatedDescription,
|
||||||
|
gender = translatedGender,
|
||||||
|
personalityTrait = translatedPersonality?.trait ?: "",
|
||||||
|
personalityDescription = translatedPersonality?.description ?: "",
|
||||||
|
backgroundTopic = translatedBackground?.topic ?: "",
|
||||||
|
backgroundDescription = translatedBackground?.description ?: "",
|
||||||
|
tags = translatedTags
|
||||||
|
)
|
||||||
|
|
||||||
|
val existing = aiCharacterTranslationRepository
|
||||||
|
.findByCharacterIdAndLocale(character.id!!, locale)
|
||||||
|
|
||||||
|
if (existing == null) {
|
||||||
|
val entity = AiCharacterTranslation(
|
||||||
|
characterId = character.id!!,
|
||||||
|
locale = locale,
|
||||||
|
renderedPayload = payload
|
||||||
|
)
|
||||||
|
|
||||||
|
aiCharacterTranslationRepository.save(entity)
|
||||||
|
} else {
|
||||||
|
existing.renderedPayload = payload
|
||||||
|
aiCharacterTranslationRepository.save(existing)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
package kr.co.vividnext.sodalive.i18n.translation
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Papago 번역 API 응답 예시
|
||||||
|
*
|
||||||
|
* ```json
|
||||||
|
* {
|
||||||
|
* "message": {
|
||||||
|
* "result": {
|
||||||
|
* "srcLangType": "ko",
|
||||||
|
* "tarLangType": "en",
|
||||||
|
* "translatedText": "Hello, I like to eat apple while riding a bicycle."
|
||||||
|
* }
|
||||||
|
* }
|
||||||
|
* }
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 위 JSON 구조에 대응하는 최상위 응답 모델
|
||||||
|
*/
|
||||||
|
data class PapagoTranslationResponse(
|
||||||
|
val message: Message
|
||||||
|
) {
|
||||||
|
/**
|
||||||
|
* message 필드 내부 구조
|
||||||
|
*/
|
||||||
|
data class Message(
|
||||||
|
val result: Result
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 실제 번역 결과 데이터
|
||||||
|
*/
|
||||||
|
data class Result(
|
||||||
|
val srcLangType: String,
|
||||||
|
val tarLangType: String,
|
||||||
|
val translatedText: String
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
package kr.co.vividnext.sodalive.i18n.translation
|
||||||
|
|
||||||
|
import org.springframework.beans.factory.annotation.Value
|
||||||
|
import org.springframework.http.HttpEntity
|
||||||
|
import org.springframework.http.HttpHeaders
|
||||||
|
import org.springframework.http.MediaType
|
||||||
|
import org.springframework.stereotype.Service
|
||||||
|
import org.springframework.web.client.RestTemplate
|
||||||
|
|
||||||
|
@Service
|
||||||
|
class PapagoTranslationService(
|
||||||
|
@Value("\${cloud.naver.papago-client-id}")
|
||||||
|
private val papagoClientId: String,
|
||||||
|
|
||||||
|
@Value("\${cloud.naver.papago-client-secret}")
|
||||||
|
private val papagoClientSecret: String
|
||||||
|
) {
|
||||||
|
private val restTemplate: RestTemplate = RestTemplate()
|
||||||
|
|
||||||
|
private val papagoTranslateUrl = "https://papago.apigw.ntruss.com/nmt/v1/translation"
|
||||||
|
|
||||||
|
fun translate(request: TranslateRequest): TranslateResult {
|
||||||
|
if (request.texts.isEmpty() || request.sourceLanguage == request.targetLanguage) {
|
||||||
|
return TranslateResult(emptyList())
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!validateLanguages(request.sourceLanguage, request.targetLanguage)) {
|
||||||
|
return TranslateResult(emptyList())
|
||||||
|
}
|
||||||
|
|
||||||
|
val headers = HttpHeaders().apply {
|
||||||
|
contentType = MediaType.APPLICATION_JSON
|
||||||
|
set("X-NCP-APIGW-API-KEY-ID", papagoClientId)
|
||||||
|
set("X-NCP-APIGW-API-KEY", papagoClientSecret)
|
||||||
|
}
|
||||||
|
|
||||||
|
val translatedTexts = mutableListOf<String>()
|
||||||
|
|
||||||
|
request.texts.forEach { text ->
|
||||||
|
try {
|
||||||
|
val body = mapOf(
|
||||||
|
"source" to request.sourceLanguage,
|
||||||
|
"target" to request.targetLanguage,
|
||||||
|
"text" to text
|
||||||
|
)
|
||||||
|
|
||||||
|
val requestEntity = HttpEntity(body, headers)
|
||||||
|
|
||||||
|
val response = restTemplate.postForEntity(
|
||||||
|
papagoTranslateUrl,
|
||||||
|
requestEntity,
|
||||||
|
PapagoTranslationResponse::class.java
|
||||||
|
)
|
||||||
|
|
||||||
|
if (!response.statusCode.is2xxSuccessful) {
|
||||||
|
return@forEach
|
||||||
|
}
|
||||||
|
|
||||||
|
val translated = response.body?.message?.result?.translatedText
|
||||||
|
translatedTexts.add(translated ?: "")
|
||||||
|
} catch (_: Exception) {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return TranslateResult(translatedTexts)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun validateLanguages(sourceLanguage: String, targetLanguage: String): Boolean {
|
||||||
|
return requireSupportedLanguage(sourceLanguage) && requireSupportedLanguage(targetLanguage)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun requireSupportedLanguage(language: String): Boolean {
|
||||||
|
val normalized = language.lowercase()
|
||||||
|
return SUPPORTED_LANGUAGE_CODES.contains(normalized)
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private val SUPPORTED_LANGUAGE_CODES = setOf(
|
||||||
|
"ko",
|
||||||
|
"en",
|
||||||
|
"ja"
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 번역 대상 언어 코드 집합을 반환한다.
|
||||||
|
*
|
||||||
|
* @param excludedLanguageCode 번역 대상에서 제외할 언어 코드(대소문자 무시)
|
||||||
|
* @return 지원되는 언어 코드 중 [excludedLanguageCode]를 제외한 집합
|
||||||
|
*/
|
||||||
|
fun getTranslatableLanguageCodes(excludedLanguageCode: String?): Set<String> {
|
||||||
|
val normalized = excludedLanguageCode?.lowercase()
|
||||||
|
return SUPPORTED_LANGUAGE_CODES.filterNot { it == normalized }.toSet()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
package kr.co.vividnext.sodalive.i18n.translation
|
||||||
|
|
||||||
|
data class TranslateRequest(
|
||||||
|
val texts: List<String>,
|
||||||
|
val sourceLanguage: String,
|
||||||
|
val targetLanguage: String
|
||||||
|
)
|
||||||
|
|
||||||
|
data class TranslateResult(
|
||||||
|
val translatedText: List<String>
|
||||||
|
)
|
||||||
@@ -45,6 +45,9 @@ google:
|
|||||||
webClientId: ${GOOGLE_WEB_CLIENT_ID}
|
webClientId: ${GOOGLE_WEB_CLIENT_ID}
|
||||||
|
|
||||||
cloud:
|
cloud:
|
||||||
|
naver:
|
||||||
|
papagoClientId: ${NCLOUD_PAPAGO_CLIENT_ID}
|
||||||
|
papagoClientSecret: ${NCLOUD_PAPAGO_CLIENT_SECRET}
|
||||||
aws:
|
aws:
|
||||||
credentials:
|
credentials:
|
||||||
accessKey: ${APP_AWS_ACCESS_KEY}
|
accessKey: ${APP_AWS_ACCESS_KEY}
|
||||||
|
|||||||
71
work/scripts/check-commit-message-rules.sh
Executable file
71
work/scripts/check-commit-message-rules.sh
Executable file
@@ -0,0 +1,71 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
# Check if a commit message follows project rules
|
||||||
|
# Rules: 50/72 formatting, no advertisements/branding
|
||||||
|
# Usage: ./check-commit-message-rules.sh [commit-hash]
|
||||||
|
# If no commit-hash is provided, checks the latest commit
|
||||||
|
|
||||||
|
# Determine which commit to check
|
||||||
|
if [ $# -eq 0 ]; then
|
||||||
|
commit_ref="HEAD"
|
||||||
|
echo "Checking latest commit..."
|
||||||
|
else
|
||||||
|
commit_ref="$1"
|
||||||
|
echo "Checking commit: $commit_ref"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Get the commit message
|
||||||
|
commit_message=$(git log -1 --pretty=format:"%s%n%b" "$commit_ref")
|
||||||
|
|
||||||
|
# Split into subject and body
|
||||||
|
subject=$(echo "$commit_message" | head -n1)
|
||||||
|
body=$(echo "$commit_message" | tail -n +2 | sed '/^$/d')
|
||||||
|
|
||||||
|
echo "Checking commit message format..."
|
||||||
|
echo "Subject: $subject"
|
||||||
|
|
||||||
|
# Check subject line length
|
||||||
|
subject_length=${#subject}
|
||||||
|
if [ $subject_length -gt 50 ]; then
|
||||||
|
echo "[FAIL] Subject line too long: $subject_length characters (max 50)"
|
||||||
|
exit_code=1
|
||||||
|
else
|
||||||
|
echo "[PASS] Subject line length OK: $subject_length characters"
|
||||||
|
exit_code=0
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Check body line lengths if body exists
|
||||||
|
if [ -n "$body" ]; then
|
||||||
|
echo "Checking body line lengths..."
|
||||||
|
while IFS= read -r line; do
|
||||||
|
line_length=${#line}
|
||||||
|
if [ $line_length -gt 72 ]; then
|
||||||
|
echo "[FAIL] Body line too long: $line_length characters (max 72)"
|
||||||
|
echo "Line: $line"
|
||||||
|
exit_code=1
|
||||||
|
fi
|
||||||
|
done <<< "$body"
|
||||||
|
|
||||||
|
if [ $exit_code -eq 0 ]; then
|
||||||
|
echo "[PASS] All body lines within 72 characters"
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
echo "[INFO] No body content to check"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Check for advertisements, branding, or promotional content
|
||||||
|
echo "Checking for advertisements and branding..."
|
||||||
|
if echo "$commit_message" | grep -qi "generated with\|claude code\|anthropic\|co-authored-by.*claude\|🤖"; then
|
||||||
|
echo "[FAIL] Commit message contains advertisements, branding, or promotional content"
|
||||||
|
exit_code=1
|
||||||
|
else
|
||||||
|
echo "[PASS] No advertisements or branding detected"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ $exit_code -eq 0 ]; then
|
||||||
|
echo "[PASS] Commit message follows all rules"
|
||||||
|
else
|
||||||
|
echo "[FAIL] Commit message violates project rules"
|
||||||
|
fi
|
||||||
|
|
||||||
|
exit $exit_code
|
||||||
Reference in New Issue
Block a user