Compare commits

...

63 Commits

Author SHA1 Message Date
67a8de9e7a 캐릭터 상세 조회 - 원작 번역 및 캐릭터 소개 일괄 번역 기능 구현 2025-12-16 06:52:48 +09:00
0c52804f06 원작 목록의 제목과 콘텐츠 타입을 현재 언어(locale)에 맞춰 일괄 번역 기능 추가 2025-12-16 06:19:15 +09:00
7955be45da 원작 등록/수정시 번역 API 호출 2025-12-16 06:10:18 +09:00
8ae6943c2a 크리에이터 관리자에서 콘텐츠 수정시 번역 이벤트 호출하도록 수정 2025-12-16 04:14:13 +09:00
82f53ed8ab 콘텐츠 제목, 시리즈 장르 번역 반환 구현 2025-12-16 04:09:25 +09:00
4e4235369c 홈 요일별 시리즈 - 번역 데이터 조회 기능 적용 2025-12-16 03:40:28 +09:00
30a104981c 시리즈 상세 - 번역 데이터 조회 기능 추가 2025-12-16 03:29:02 +09:00
4c0be733d0 시리즈 상세 - 번역 데이터 조회 기능 추가 2025-12-16 02:52:14 +09:00
0eed29eadc 시리즈 리스트 - 번역 데이터 조회 기능 추가 2025-12-16 01:07:20 +09:00
db18d5c8b5 홈 - 오직 보이스온에서만, 요일별 시리즈 번역 데이터 조회 기능 추가 2025-12-16 00:43:36 +09:00
f58687ef3a 크리에이터 관리자에서 시리즈 등록/수정시 번역데이터 생성 기능 추가 2025-12-16 00:25:24 +09:00
9b2b156d40 SeriesTranslationPayload 키워드 리스트 변환 및 수정
- `SeriesTranslationPayload.keywords` 타입을 `String`에서 `List<String>`으로 변경했습니다.
- `SeriesTranslationPayloadConverter`의 `convertToEntityAttribute`를 하위 호환 가능하도록 수정했습니다.
  - DB에 저장된 JSON에서 `keywords`가 과거 스키마(String)인 경우와 신규 스키마(List)를 모두 안전하게 파싱합니다.
  - 파싱 실패 또는 공백 입력 시 기본값을 사용합니다(`keywords = []`).
- `convertToDatabaseColumn`은 변경 없이 `ObjectMapper`로 직렬화하여 `keywords`가 배열로 저장됩니다.
2025-12-15 23:55:50 +09:00
e00a9ccff5 시리즈 상세, 시리즈 키워드 번역 엔티티 추가 2025-12-15 16:32:21 +09:00
45ee55028f 콘텐츠 상세 - themeStr 언어별 번역 제공 기능 수정 2025-12-15 12:25:10 +09:00
dc0df81232 번역된 테마로 콘텐츠를 조회해도 한글 테마처럼 처리하기 2025-12-15 12:15:31 +09:00
c0c61da44b 콘텐츠 테마 조회 로직 수정 2025-12-15 11:45:56 +09:00
13029ab8d2 콘텐츠 테마 번역 N+1 제거
- 온라인 경로에서 콘텐츠 테마 번역을 배치 조회/번역/저장으로 처리.
- 기존 번역은 IN 조회, 미번역만 한 번의 번역 요청 후 저장.
- 결과 순서 보전, 번역 누락/실패 시 원문으로 폴백.
- 공개 API 변경 없음.
2025-12-13 00:51:07 +09:00
6f0619e482 콘텐츠 테마 저장시 번역 API로 자동 번역 하는 기능 추가 2025-12-13 00:19:48 +09:00
920a866ae0 신규 콘텐츠 조회 API에서 languageCode를 별도로 받던 것을 LangContext를 사용하도록 리팩토링 2025-12-12 19:46:29 +09:00
de60a70733 크리에이터 프로필 조회 API에서 languageCode를 별도로 받던 것을 LangContext를 사용하도록 리팩토링 2025-12-12 19:44:08 +09:00
59949e5aee AudioContent 조회 API에서 api 마다 languageCode를 별도로 받던 것을 LangContext를 사용하도록 리팩토링 2025-12-12 19:40:21 +09:00
165640201f AI Character API에서 api 마다 languageCode를 별도로 받던 것을 LangContext를 사용하도록 리팩토링 2025-12-12 19:09:33 +09:00
ba1844a6c2 Home API에서 api 마다 languageCode를 별도로 받던 것을 LangContext를 사용하도록 리팩토링 2025-12-12 17:22:50 +09:00
082f255773 요청 스코프 언어 컨텍스트와 인터셉터 추가
- Interceptor에서 Accept-Language 헤더를 파싱
- 요청 단위 LangContext에 언어 정보 저장
- 서비스 및 예외 처리 계층에서 언어 컨텍스트 주입
- enum 및 when 기반 언어 정책을 한 곳으로 통합
2025-12-12 16:57:34 +09:00
04281817a5 크리에이터 채널 - languageCode에 따라 콘텐츠 번역 데이터 조회 2025-12-12 13:58:49 +09:00
236394e148 콘텐츠 전체보기 API - languageCode에 따라 번역 데이터 조회 2025-12-12 06:04:26 +09:00
7ab25470b6 콘텐츠 전체보기 API - languageCode에 따라 번역 데이터 조회 2025-12-12 05:57:04 +09:00
8fec60db11 AI 캐릭터, 콘텐츠 등록/수정 시 번역 데이터 생성 2025-12-12 04:52:02 +09:00
5d925e98e0 AI 캐릭터 번역 데이터, 콘텐츠 번역 데이터 엔티티에서 사용하지 않는 필드 제거 2025-12-12 03:05:50 +09:00
2355aa7c75 AI 캐릭터 리스트에 번역 데이터 제공 기능 추가 2025-12-12 01:32:02 +09:00
5bdb6d20a5 번역 - 지원되지 않는 언어이면 API를 호출하지 않고 빈 값을 반환하도록 수정 2025-12-12 01:00:41 +09:00
143ba2fbb2 HomeApi - languageCode에 따라 콘텐츠, 캐릭터의 번역 데이터를 제공하도록 수정 2025-12-11 23:58:17 +09:00
28fbdd7826 getDetail에 languageCode를 optional로 변경하여 languageCode가 없어도 정상 조회 되도록 수정 2025-12-11 22:33:26 +09:00
25169aaac3 getDetail에 @Transactional 을 추가하여 데이터 저장이 가능하도록 수정 2025-12-11 22:14:18 +09:00
608898eb0c Add translation support for audio content detail 2025-12-11 22:00:30 +09:00
1748b26318 파파고 번역 시 내용을 합쳐서 한번에 처리하지 않고 개별로 API를 호출해서 번역 처리 2025-12-11 19:35:05 +09:00
3ff38bb73a 파파고 번역 시 내용을 분리할 DELIMITER 변경 2025-12-11 18:57:46 +09:00
4498af4509 Fix AI character translation unique constraint column 2025-12-11 18:19:10 +09:00
8636a8cac0 캐릭터 번역 캐시 및 응답 필드 추가 2025-12-11 17:19:00 +09:00
304c001a27 파파고 번역 API 연동 2025-12-11 16:34:22 +09:00
fdac55ebdf AGENTS.md 파일 삭제 2025-12-11 15:50:52 +09:00
668d4f28cd AGENTS.md 파일에 AI Coding Agent가 반드시 따라야 할 개발 헌법(운영 규칙) 상세하게 추가 2025-12-10 00:22:53 +09:00
7b0644cb66 AGENTS.md 파일 추가 2025-12-09 14:56:14 +09:00
503802bcce feat(chat-character): 캐릭터 상세 조회 시 언어 코드 추가 2025-11-26 11:53:40 +09:00
899f2865b3 feat(chat-character): 캐릭터 등록시 파파고 언어 감지 API를 호출하여 languageCode를 기록하는 기능 추가 2025-11-26 11:40:58 +09:00
e0dcbd16fc feat(character-comment): 캐릭터 댓글의 답글 조회시 결과에 언어 코드 추가 2025-11-25 19:35:59 +09:00
62ec994069 feat(character-comment): 캐릭터 댓글 조회시 결과에 언어 코드 추가 2025-11-25 18:12:50 +09:00
8ec6d50dd8 feat(content-comment): 콘텐츠 댓글 조회시 결과에 언어 코드 추가 2025-11-25 18:10:36 +09:00
ddd46d585e feat(creator-cheers): 팬 Talk 응원글 조회시 결과에 언어 코드 추가 2025-11-25 18:05:08 +09:00
c5fa260a0d feat(creator-cheers): 팬 Talk 응원글 등록 시 언어 코드가 null인 경우 파파고 언어 감지 API를 호출하는 기능 추가 2025-11-25 16:42:26 +09:00
412c52e754 feat(creator-cheers): 팬 Talk 응원글 등록 시 언어 코드를 입력 받을 수 있는 기능 추가 2025-11-25 16:36:39 +09:00
8f4544ad71 refactor(lang-detect): LanguageDetectEvent ID 필드를 단일 id로 통합
- LanguageDetectEvent의 contentId/commentId를 제거하고 공통 id(Long) 필드로 단순화
- LanguageDetectListener에서 targetType에 따라 id를 AudioContent/AudioContentComment/CharacterComment 조회에 사용하도록 수정
- AudioContentService, AudioContentCommentService, AudioContentDonationService, CharacterCommentService 등 이벤트 발행부를 새 시그니처(id + targetType)로 정리
2025-11-25 16:32:29 +09:00
619ceeea24 feat(character-comment): 캐릭터 댓글 등록 시 언어 코드가 null인 경우 파파고 언어 감지 API를 호출하는 기능 추가 2025-11-25 16:19:08 +09:00
a2998002e5 feat(character-comment): 캐릭터 댓글 등록 시 언어 코드를 입력 받을 수 있는 기능 추가 2025-11-25 16:10:20 +09:00
da9b89a6cf feat(content-comment): 콘텐츠 댓글/후원 시 언어 코드가 null인 경우 파파고 언어 감지 API를 호출하는 기능 추가 2025-11-25 16:03:52 +09:00
5ee5107364 feat(content-comment): 콘텐츠 댓글/후원 시 언어 코드를 입력 받을 수 있는 기능 추가 2025-11-25 15:54:01 +09:00
ae2c699748 refactor(LanguageDetectEvent): 언어 감지 요청 이벤트 클래스명 수정
- AudioContentLanguageDetectEvent -> LanguageDetectEvent
2025-11-25 15:42:32 +09:00
93ccb666c4 feat(content): 콘텐츠 업로드 후 languageCode가 null이면 naver papago 언어 감지 API 호출 기능 추가 2025-11-25 15:11:27 +09:00
edaea84a5b feat(content): 콘텐츠 업로드 request, 상세 조회 response에 languageCode 추가
- CreateAudioContentRequest, GetAudioContentDetailResponse
2025-11-24 12:31:49 +09:00
76806e2e90 feat(content-theme): 무료 콘텐츠의 테마를 조회할 때 '자기소개'가 가장 먼저 표시되도록 수정 2025-11-21 00:49:17 +09:00
39c51825da feat(content-theme): 무료 콘텐츠의 테마를 조회할 때 '자기소개'가 가장 먼저 표시되도록 수정 2025-11-21 00:37:29 +09:00
9a58b7b95f feat(latest-content-by-creator): 최신 콘텐츠 1개 조회시 오픈 되어 있는 콘텐츠만 조회하도록 수정 2025-11-20 21:19:29 +09:00
26eae4b06e feat(latest-content-by-creator): 최신 콘텐츠 1개 조회시 오픈 되어 있는 콘텐츠만 조회하도록 수정 2025-11-20 20:59:09 +09:00
65 changed files with 3401 additions and 111 deletions

View File

@@ -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.common.ApiResponse
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 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.HttpMethod
@@ -40,6 +45,7 @@ class AdminChatCharacterController(
private val adminService: AdminChatCharacterService,
private val s3Uploader: S3Uploader,
private val originalWorkService: AdminOriginalWorkService,
private val applicationEventPublisher: ApplicationEventPublisher,
@Value("\${weraser.api-key}")
private val apiKey: String,
@@ -165,6 +171,18 @@ class AdminChatCharacterController(
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)
}
@@ -315,6 +333,13 @@ class AdminChatCharacterController(
request = request
)
applicationEventPublisher.publishEvent(
LanguageTranslationEvent(
id = request.id,
targetType = LanguageTranslationTargetType.CHARACTER
)
)
// 원작 연결: originalWorkId가 있으면 서비스 계층을 통해 배정
if (request.originalWorkId != null) {
// 서비스에서 유효성 검증 및 저장까지 처리

View File

@@ -10,6 +10,11 @@ import kr.co.vividnext.sodalive.chat.original.OriginalWorkTag
import kr.co.vividnext.sodalive.chat.original.OriginalWorkTagMapping
import kr.co.vividnext.sodalive.chat.original.repository.OriginalWorkTagRepository
import kr.co.vividnext.sodalive.common.SodaException
import kr.co.vividnext.sodalive.content.LanguageDetectEvent
import kr.co.vividnext.sodalive.content.LanguageDetectTargetType
import kr.co.vividnext.sodalive.i18n.translation.LanguageTranslationEvent
import kr.co.vividnext.sodalive.i18n.translation.LanguageTranslationTargetType
import org.springframework.context.ApplicationEventPublisher
import org.springframework.data.domain.Page
import org.springframework.data.domain.PageRequest
import org.springframework.data.domain.Sort
@@ -24,7 +29,9 @@ import org.springframework.transaction.annotation.Transactional
class AdminOriginalWorkService(
private val originalWorkRepository: OriginalWorkRepository,
private val chatCharacterRepository: ChatCharacterRepository,
private val originalWorkTagRepository: OriginalWorkTagRepository
private val originalWorkTagRepository: OriginalWorkTagRepository,
private val applicationEventPublisher: ApplicationEventPublisher
) {
/** 원작 등록 (중복 제목 방지 포함) */
@@ -56,7 +63,44 @@ class AdminOriginalWorkService(
entity.tagMappings.add(OriginalWorkTagMapping(originalWork = entity, tag = tagEntity))
}
}
return originalWorkRepository.save(entity)
val originalWork = originalWorkRepository.save(entity)
/**
* 저장이 완료된 후
* originalWork의
*
* languageCode == null이면 언어 감지 이벤트 호출
* languageCode != null이면 번역 이벤트 호출
*
*/
if (originalWork.languageCode == null) {
val papagoQuery = listOf(
originalWork.title,
originalWork.contentType,
originalWork.category,
originalWork.description
)
.filter { it.isNotBlank() }
.joinToString(" ")
applicationEventPublisher.publishEvent(
LanguageDetectEvent(
id = originalWork.id!!,
query = papagoQuery,
targetType = LanguageDetectTargetType.ORIGINAL_WORK
)
)
} else {
applicationEventPublisher.publishEvent(
LanguageTranslationEvent(
id = originalWork.id!!,
targetType = LanguageTranslationTargetType.ORIGINAL_WORK
)
)
}
return originalWork
}
/** 원작 수정 (이미지 경로 포함 선택적 변경) */
@@ -107,6 +151,25 @@ class AdminOriginalWorkService(
if (imagePath != null) {
ow.imagePath = imagePath
}
/**
* 번역 이벤트 호출
*/
if (
request.title != null ||
request.contentType != null ||
request.category != null ||
request.description != null ||
request.tags != null
) {
applicationEventPublisher.publishEvent(
LanguageTranslationEvent(
id = ow.id!!,
targetType = LanguageTranslationTargetType.ORIGINAL_WORK
)
)
}
return originalWorkRepository.save(ow)
}

View File

@@ -5,8 +5,11 @@ import kr.co.vividnext.sodalive.aws.s3.S3Uploader
import kr.co.vividnext.sodalive.common.SodaException
import kr.co.vividnext.sodalive.content.theme.AudioContentTheme
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 org.springframework.beans.factory.annotation.Value
import org.springframework.context.ApplicationEventPublisher
import org.springframework.data.repository.findByIdOrNull
import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional
@@ -18,6 +21,8 @@ class AdminContentThemeService(
private val objectMapper: ObjectMapper,
private val repository: AdminContentThemeRepository,
private val applicationEventPublisher: ApplicationEventPublisher,
@Value("\${cloud.aws.s3.bucket}")
private val bucket: String
) {
@@ -37,7 +42,14 @@ class AdminContentThemeService(
}
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) {

View File

@@ -1,7 +1,9 @@
package kr.co.vividnext.sodalive.api.home
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.translate.AiCharacterTranslationRepository
import kr.co.vividnext.sodalive.content.AudioContentMainItem
import kr.co.vividnext.sodalive.content.AudioContentService
import kr.co.vividnext.sodalive.content.ContentType
@@ -10,10 +12,13 @@ import kr.co.vividnext.sodalive.content.main.banner.AudioContentBannerService
import kr.co.vividnext.sodalive.content.main.curation.AudioContentCurationService
import kr.co.vividnext.sodalive.content.series.ContentSeriesService
import kr.co.vividnext.sodalive.content.series.GetSeriesListResponse
import kr.co.vividnext.sodalive.content.series.translation.SeriesTranslationRepository
import kr.co.vividnext.sodalive.content.theme.AudioContentThemeService
import kr.co.vividnext.sodalive.content.translation.ContentTranslationRepository
import kr.co.vividnext.sodalive.creator.admin.content.series.SeriesPublishedDaysOfWeek
import kr.co.vividnext.sodalive.event.GetEventResponse
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.LiveRoomStatus
import kr.co.vividnext.sodalive.member.Member
@@ -47,6 +52,12 @@ class HomeService(
private val rankingRepository: RankingRepository,
private val explorerQueryRepository: ExplorerQueryRepository,
private val contentTranslationRepository: ContentTranslationRepository,
private val aiCharacterTranslationRepository: AiCharacterTranslationRepository,
private val seriesTranslationRepository: SeriesTranslationRepository,
private val langContext: LangContext,
@Value("\${cloud.aws.cloud-front.host}")
private val imageHost: String
) {
@@ -111,6 +122,8 @@ class HomeService(
}
}
val translatedLatestContentList = getTranslatedContentList(contentList = latestContentList)
val eventBannerList = GetEventResponse(
totalCount = 0,
eventList = emptyList()
@@ -122,23 +135,28 @@ class HomeService(
isAdult = isAdult
)
// 오직 보이스온에서만
val originalAudioDramaList = seriesService.getOriginalAudioDramaList(
isAdult = isAdult,
contentType = contentType,
orderByRandom = true
)
val translatedOriginalAudioDramaList = getTranslatedSeriesList(seriesList = originalAudioDramaList)
val auditionList = auditionService.getInProgressAuditionList(isAdult = isAdult)
// 요일별 시리즈
val dayOfWeekSeriesList = seriesService.getDayOfWeekSeriesList(
memberId = memberId,
isAdult = isAdult,
contentType = contentType,
dayOfWeek = getDayOfWeekByTimezone(timezone)
)
val translatedDayOfWeekSeriesList = getTranslatedSeriesList(seriesList = dayOfWeekSeriesList)
// 인기 캐릭터 조회
val popularCharacters = characterService.getPopularCharacters()
val translatedPopularCharacters = getTranslatedAiCharacterList(aiCharacterList = characterService.getPopularCharacters())
val currentDateTime = LocalDateTime.now()
val startDate = currentDateTime
@@ -159,12 +177,64 @@ class HomeService(
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(
memberId = memberId,
isAdult = isAdult,
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(
theme = contentThemeService.getActiveThemeOfContent(
isAdult = isAdult,
@@ -183,6 +253,8 @@ class HomeService(
}
}
val translatedFreeContentList = getTranslatedContentList(contentList = freeContentList)
// 포인트 사용가능 콘텐츠 리스트 - 랜덤으로 가져오기 (DB에서 isPointAvailable 조건 적용)
val pointAvailableContentList = contentService.getLatestContentByTheme(
theme = emptyList(),
@@ -199,6 +271,8 @@ class HomeService(
}
}
val translatedPointAvailableContentList = getTranslatedContentList(contentList = pointAvailableContentList)
val curationList = curationService.getContentCurationList(
tabId = 3L, // 기존에 사용하던 단편 탭의 큐레이션을 사용
isAdult = isAdult,
@@ -210,17 +284,17 @@ class HomeService(
liveList = liveList,
creatorRanking = creatorRanking,
latestContentThemeList = latestContentThemeList,
latestContentList = latestContentList,
latestContentList = translatedLatestContentList,
bannerList = bannerList,
eventBannerList = eventBannerList,
originalAudioDramaList = originalAudioDramaList,
originalAudioDramaList = translatedOriginalAudioDramaList,
auditionList = auditionList,
dayOfWeekSeriesList = dayOfWeekSeriesList,
popularCharacters = popularCharacters,
contentRanking = contentRanking,
recommendChannelList = recommendChannelList,
freeContentList = freeContentList,
pointAvailableContentList = pointAvailableContentList,
dayOfWeekSeriesList = translatedDayOfWeekSeriesList,
popularCharacters = translatedPopularCharacters,
contentRanking = translatedContentRanking,
recommendChannelList = translatedRecommendChannelList,
freeContentList = translatedFreeContentList,
pointAvailableContentList = translatedPointAvailableContentList,
recommendContentList = getRecommendContentList(
isAdultContentVisible = isAdultContentVisible,
contentType = contentType,
@@ -249,7 +323,7 @@ class HomeService(
listOf(theme)
}
return contentService.getLatestContentByTheme(
val contentList = contentService.getLatestContentByTheme(
theme = themeList,
contentType = contentType,
isFree = false,
@@ -261,6 +335,8 @@ class HomeService(
true
}
}
return getTranslatedContentList(contentList = contentList)
}
fun getDayOfWeekSeriesList(
@@ -272,12 +348,14 @@ class HomeService(
val memberId = member?.id
val isAdult = member?.auth != null && isAdultContentVisible
return seriesService.getDayOfWeekSeriesList(
val dayOfWeekSeriesList = seriesService.getDayOfWeekSeriesList(
memberId = memberId,
isAdult = isAdult,
contentType = contentType,
dayOfWeek = dayOfWeek
)
return getTranslatedSeriesList(seriesList = dayOfWeekSeriesList)
}
fun getContentRankingBySort(
@@ -371,6 +449,114 @@ 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
}
}
/**
* 시리즈 리스트의 제목을 현재 언어(locale)에 맞춰 일괄 번역한다.
*
* 처리 절차:
* - 입력된 시리즈들의 seriesId 집합을 만들고, 요청 언어 코드(langContext.lang.code)로
* seriesTranslationRepository에서 번역 데이터를 한 번에 조회한다.
* - 각 항목에 대해 번역된 제목이 존재하고 비어있지 않으면 title만 번역 값으로 교체한다.
* - 번역이 없거나 공백이면 원본 항목을 그대로 반환한다.
*
* 성능:
* - N건의 항목을 1회의 조회로 해결하기 위해 IN 쿼리를 사용한다.
*
* @param seriesList 번역 대상 SeriesListItem 목록
* @return 제목이 가능한 항목은 번역된 목록(불변 사본), 그 외는 원본 항목 유지
*/
private fun getTranslatedSeriesList(
seriesList: List<GetSeriesListResponse.SeriesListItem>
): List<GetSeriesListResponse.SeriesListItem> {
val seriesIds = seriesList.map { it.seriesId }
return if (seriesIds.isNotEmpty()) {
val translations = seriesTranslationRepository
.findBySeriesIdInAndLocale(seriesIds = seriesIds, locale = langContext.lang.code)
.associateBy { it.seriesId }
seriesList.map { item ->
val translatedTitle = translations[item.seriesId]?.renderedPayload?.title
if (translatedTitle.isNullOrBlank()) {
item
} else {
item.copy(title = translatedTitle)
}
}
} else {
seriesList
}
}
/**
* AI 캐릭터 리스트의 이름/설명을 현재 언어(locale)에 맞춰 일괄 번역한다.
*
* 처리 절차:
* - 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
}
}
}

View File

@@ -22,6 +22,8 @@ class ChatCharacter(
// 캐릭터 한 줄 소개
var description: String,
var languageCode: String? = null,
// AI 시스템 프롬프트
@Column(columnDefinition = "TEXT", nullable = false)
var systemPrompt: String,

View File

@@ -16,6 +16,7 @@ import javax.persistence.Table
data class CharacterComment(
@Column(columnDefinition = "TEXT", nullable = false)
var comment: String,
var languageCode: String?,
var isActive: Boolean = true
) : BaseEntity() {
@ManyToOne(fetch = FetchType.LAZY)

View File

@@ -47,7 +47,7 @@ class CharacterCommentController(
if (member.auth == null) 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)
}

View File

@@ -2,7 +2,8 @@ package kr.co.vividnext.sodalive.chat.character.comment
// Request DTOs
data class CreateCharacterCommentRequest(
val comment: String
val comment: String,
val languageCode: String? = null
)
// Response DTOs
@@ -20,7 +21,8 @@ data class CharacterCommentResponse(
val memberNickname: String,
val createdAt: Long,
val replyCount: Int,
val comment: String
val comment: String,
val languageCode: String?
)
// 답글 Response 단건(목록 원소)
@@ -35,7 +37,8 @@ data class CharacterReplyResponse(
val memberProfileImage: String,
val memberNickname: String,
val createdAt: Long,
val comment: String
val comment: String,
val languageCode: String?
)
// 댓글의 답글 조회 Response 컨테이너

View File

@@ -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.common.SodaException
import kr.co.vividnext.sodalive.content.LanguageDetectEvent
import kr.co.vividnext.sodalive.content.LanguageDetectTargetType
import kr.co.vividnext.sodalive.member.Member
import org.springframework.context.ApplicationEventPublisher
import org.springframework.data.domain.PageRequest
import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional
@@ -12,7 +15,8 @@ import java.time.ZoneId
class CharacterCommentService(
private val chatCharacterRepository: ChatCharacterRepository,
private val commentRepository: CharacterCommentRepository,
private val reportRepository: CharacterCommentReportRepository
private val reportRepository: CharacterCommentReportRepository,
private val applicationEventPublisher: ApplicationEventPublisher
) {
private fun profileUrl(imageHost: String, profileImage: String?): String {
@@ -40,7 +44,8 @@ class CharacterCommentService(
memberNickname = member.nickname,
createdAt = toEpochMilli(entity.createdAt),
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),
memberNickname = member.nickname,
createdAt = toEpochMilli(entity.createdAt),
comment = entity.comment
comment = entity.comment,
languageCode = entity.languageCode
)
}
@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("캐릭터를 찾을 수 없습니다.") }
if (!character.isActive) throw SodaException("비활성화된 캐릭터입니다.")
if (text.isBlank()) throw SodaException("댓글 내용을 입력해주세요.")
val entity = CharacterComment(comment = text)
val entity = CharacterComment(comment = text, languageCode = languageCode)
entity.chatCharacter = character
entity.member = member
commentRepository.save(entity)
// 언어 코드가 지정되지 않은 경우, 파파고 언어 감지 API를 통해 비동기로 언어를 식별한다.
if (languageCode.isNullOrBlank()) {
applicationEventPublisher.publishEvent(
LanguageDetectEvent(
id = entity.id!!,
query = text,
targetType = LanguageDetectTargetType.CHARACTER_COMMENT
)
)
}
return entity.id!!
}
@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("캐릭터를 찾을 수 없습니다.") }
if (!character.isActive) throw SodaException("비활성화된 캐릭터입니다.")
val parent = commentRepository.findById(parentCommentId).orElseThrow { SodaException("댓글을 찾을 수 없습니다.") }
@@ -78,11 +102,23 @@ class CharacterCommentService(
if (!parent.isActive) throw SodaException("비활성화된 댓글입니다.")
if (text.isBlank()) throw SodaException("댓글 내용을 입력해주세요.")
val entity = CharacterComment(comment = text)
val entity = CharacterComment(comment = text, languageCode = languageCode)
entity.chatCharacter = character
entity.member = member
entity.parent = parent
commentRepository.save(entity)
// 언어 코드가 지정되지 않은 경우, 파파고 언어 감지 API를 통해 비동기로 언어를 식별한다.
if (languageCode.isNullOrBlank()) {
applicationEventPublisher.publishEvent(
LanguageDetectEvent(
id = entity.id!!,
query = text,
targetType = LanguageDetectTargetType.CHARACTER_COMMENT
)
)
}
return entity.id!!
}

View File

@@ -1,6 +1,7 @@
package kr.co.vividnext.sodalive.chat.character.controller
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.CharacterBackgroundResponse
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.OtherCharacter
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.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.common.ApiResponse
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 org.springframework.beans.factory.annotation.Value
import org.springframework.data.domain.PageRequest
@@ -32,7 +43,12 @@ class ChatCharacterController(
private val bannerService: ChatCharacterBannerService,
private val chatRoomService: ChatRoomService,
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}")
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()
@@ -103,10 +137,10 @@ class ChatCharacterController(
ApiResponse.ok(
CharacterMainResponse(
banners = banners,
recentCharacters = recentCharacters,
popularCharacters = popularCharacters,
newCharacters = newCharacters,
recommendCharacters = recommendCharacters,
recentCharacters = translatedRecentCharacters,
popularCharacters = getTranslatedAiCharacterList(popularCharacters),
newCharacters = getTranslatedAiCharacterList(newCharacters),
recommendCharacters = getTranslatedAiCharacterList(recommendCharacters),
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개, 현재 캐릭터 제외)
val others = service.getOtherCharactersBySharedTags(characterId, 10)
.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개 조회
val latestComment = characterCommentService.getLatestComment(imageHost, character.id!!)
@@ -171,6 +346,7 @@ class ChatCharacterController(
characterId = character.id!!,
name = character.name,
description = character.description,
languageCode = character.languageCode,
mbti = character.mbti,
gender = character.gender,
age = character.age,
@@ -181,9 +357,10 @@ class ChatCharacterController(
originalTitle = character.originalTitle,
originalLink = character.originalLink,
characterType = character.characterType,
others = others,
others = translatedOthers,
latestComment = latestComment,
totalComments = characterCommentService.getTotalCommentCount(character.id!!)
totalComments = characterCommentService.getTotalCommentCount(character.id!!),
translated = translated
)
)
}
@@ -194,13 +371,20 @@ class ChatCharacterController(
* - 예외: 2주 이내 캐릭터가 0개인 경우, 최근 등록한 캐릭터 20개만 제공
*/
@GetMapping("/recent")
fun getRecentCharacters(@RequestParam("page", required = false) page: Int?) = run {
ApiResponse.ok(
service.getRecentCharactersPage(
page = page ?: 0,
size = 20
)
fun getRecentCharacters(
@RequestParam("page", required = false) page: Int?
): ApiResponse<RecentCharactersResponse> = run {
val characterPage = service.getRecentCharactersPage(
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개까지만 제외 대상으로 고려
.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
}
}
}

View File

@@ -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.comment.CharacterCommentResponse
import kr.co.vividnext.sodalive.chat.character.translate.TranslatedAiCharacterDetail
data class CharacterDetailResponse(
val characterId: Long,
val name: String,
val description: String,
val languageCode: String?,
val mbti: String?,
val gender: String?,
val age: Int?,
@@ -19,7 +21,8 @@ data class CharacterDetailResponse(
val characterType: CharacterType,
val others: List<OtherCharacter>,
val latestComment: CharacterCommentResponse?,
val totalComments: Int
val totalComments: Int,
val translated: TranslatedAiCharacterDetail?
)
data class OtherCharacter(

View File

@@ -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?
)

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,124 @@
package kr.co.vividnext.sodalive.chat.original.service
import kr.co.vividnext.sodalive.chat.original.OriginalWork
import kr.co.vividnext.sodalive.chat.original.translation.OriginalWorkTranslation
import kr.co.vividnext.sodalive.chat.original.translation.OriginalWorkTranslationPayload
import kr.co.vividnext.sodalive.chat.original.translation.OriginalWorkTranslationRepository
import kr.co.vividnext.sodalive.chat.original.translation.TranslatedOriginalWork
import kr.co.vividnext.sodalive.i18n.translation.PapagoTranslationService
import kr.co.vividnext.sodalive.i18n.translation.TranslateRequest
import org.slf4j.LoggerFactory
import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional
@Service
class OriginalWorkTranslationService(
private val translationRepository: OriginalWorkTranslationRepository,
private val papagoTranslationService: PapagoTranslationService
) {
private val log = LoggerFactory.getLogger(javaClass)
/**
* 원작의 언어와 요청 언어가 다를 때 번역 데이터를 확보하고 반환한다.
* - 기존 번역이 있으면 그대로 사용
* - 없으면 파파고 번역 수행 후 저장
* - 실패/불필요 시 null 반환
*/
@Transactional
fun ensureTranslated(originalWork: OriginalWork, targetLocale: String): TranslatedOriginalWork? {
val source = originalWork.languageCode?.lowercase()
val target = targetLocale.lowercase()
if (source.isNullOrBlank() || source == target) {
return null
}
// 기존 번역 조회
val existed = translationRepository.findByOriginalWorkIdAndLocale(originalWork.id!!, target)
val existedPayload = existed?.renderedPayload
if (existedPayload != null) {
val t = existedPayload.title.trim()
val ct = existedPayload.contentType.trim()
val cat = existedPayload.category.trim()
val desc = existedPayload.description.trim()
val tags = existedPayload.tags
val hasAny = t.isNotEmpty() || ct.isNotEmpty() || cat.isNotEmpty() || desc.isNotEmpty() || tags.isNotEmpty()
if (hasAny) {
return TranslatedOriginalWork(
title = t,
contentType = ct,
category = cat,
description = desc,
tags = tags
)
}
}
// 파파고 번역 수행
return try {
val tags = originalWork.tagMappings.map { it.tag.tag }.filter { it.isNotBlank() }
val texts = buildList {
add(originalWork.title)
add(originalWork.contentType)
add(originalWork.category)
add(originalWork.description)
addAll(tags)
}
val response = papagoTranslationService.translate(
TranslateRequest(
texts = texts,
sourceLanguage = source,
targetLanguage = target
)
)
val out = response.translatedText
if (out.isEmpty()) return null
// 앞 4개는 필드, 나머지는 태그
val title = out.getOrNull(0)?.trim().orEmpty()
val contentType = out.getOrNull(1)?.trim().orEmpty()
val category = out.getOrNull(2)?.trim().orEmpty()
val description = out.getOrNull(3)?.trim().orEmpty()
val translatedTags = if (out.size > 4) {
out.drop(4).map { it.trim() }.filter { it.isNotEmpty() }
} else {
emptyList()
}
val hasAny = title.isNotEmpty() || contentType.isNotEmpty() ||
category.isNotEmpty() || description.isNotEmpty() || translatedTags.isNotEmpty()
if (!hasAny) return null
val payload = OriginalWorkTranslationPayload(
title = title,
contentType = contentType,
category = category,
description = description,
tags = translatedTags
)
val entity = existed?.apply { this.renderedPayload = payload }
?: OriginalWorkTranslation(
originalWorkId = originalWork.id!!,
locale = target,
renderedPayload = payload
)
translationRepository.save(entity)
TranslatedOriginalWork(
title = title,
contentType = contentType,
category = category,
description = description,
tags = translatedTags
)
} catch (e: Exception) {
log.warn("Failed to translate OriginalWork(id={}) from {} to {}: {}", originalWork.id, source, target, e.message)
null
}
}
}

View File

@@ -0,0 +1,102 @@
package kr.co.vividnext.sodalive.chat.original.translation
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
import kr.co.vividnext.sodalive.common.BaseEntity
import javax.persistence.AttributeConverter
import javax.persistence.Column
import javax.persistence.Convert
import javax.persistence.Converter
import javax.persistence.Entity
import javax.persistence.Table
import javax.persistence.UniqueConstraint
@Entity
@Table(
uniqueConstraints = [
UniqueConstraint(columnNames = ["original_work_id", "locale"])
]
)
class OriginalWorkTranslation(
@Column(name = "original_work_id")
val originalWorkId: Long,
@Column(name = "locale")
val locale: String,
@Column(columnDefinition = "json")
@Convert(converter = OriginalWorkTranslationPayloadConverter::class)
var renderedPayload: OriginalWorkTranslationPayload
) : BaseEntity()
data class OriginalWorkTranslationPayload(
val title: String,
val contentType: String,
val category: String,
val description: String,
val tags: List<String>
)
data class TranslatedOriginalWork(
val title: String,
val contentType: String,
val category: String,
val description: String,
val tags: List<String>
)
@Converter(autoApply = false)
class OriginalWorkTranslationPayloadConverter : AttributeConverter<OriginalWorkTranslationPayload, String> {
override fun convertToDatabaseColumn(attribute: OriginalWorkTranslationPayload?): String {
if (attribute == null) return "{}"
return objectMapper.writeValueAsString(attribute)
}
override fun convertToEntityAttribute(dbData: String?): OriginalWorkTranslationPayload {
if (dbData.isNullOrBlank()) {
return OriginalWorkTranslationPayload(
title = "",
contentType = "",
category = "",
description = "",
tags = emptyList()
)
}
return try {
val node = objectMapper.readTree(dbData)
val title = node.get("title")?.asText() ?: ""
val contentType = node.get("contentType")?.asText() ?: ""
val category = node.get("category")?.asText() ?: ""
val description = node.get("description")?.asText() ?: ""
val tagsNode = node.get("tags")
val tags: List<String> = when {
tagsNode == null || tagsNode.isNull -> emptyList()
tagsNode.isArray -> tagsNode.mapNotNull { it.asText(null) }.filter { it.isNotBlank() }
tagsNode.isTextual -> tagsNode.asText()
.split(',')
.map { it.trim() }
.filter { it.isNotEmpty() }
else -> emptyList()
}
OriginalWorkTranslationPayload(
title = title,
contentType = contentType,
category = category,
description = description,
tags = tags
)
} catch (_: Exception) {
OriginalWorkTranslationPayload(
title = "",
contentType = "",
category = "",
description = "",
tags = emptyList()
)
}
}
companion object {
private val objectMapper = jacksonObjectMapper()
}
}

View File

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

View File

@@ -1,11 +1,19 @@
package kr.co.vividnext.sodalive.configs
import kr.co.vividnext.sodalive.i18n.LangInterceptor
import org.springframework.context.annotation.Configuration
import org.springframework.web.servlet.config.annotation.CorsRegistry
import org.springframework.web.servlet.config.annotation.InterceptorRegistry
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer
@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) {
registry.addMapping("/**")
.allowedOrigins(

View File

@@ -32,6 +32,7 @@ data class AudioContent(
var title: String,
@Column(columnDefinition = "TEXT", nullable = false)
var detail: String,
var languageCode: String?,
var playCount: Long = 0,
var price: Int = 0,
var releaseDate: LocalDateTime? = null,

View File

@@ -1453,11 +1453,10 @@ class AudioContentQueryRepositoryImpl(
isAdult: Boolean
): AudioContent? {
var where = audioContent.member.id.eq(creatorId)
.and(
audioContent.isActive.isTrue
.and(audioContent.duration.isNotNull)
.or(audioContent.releaseDate.isNotNull.and(audioContent.duration.isNotNull))
)
.and(audioContent.isActive.isTrue)
.and(audioContent.duration.isNotNull)
.and(audioContent.releaseDate.isNotNull)
.and(audioContent.releaseDate.loe(LocalDateTime.now()))
if (!isAdult) {
where = where.and(audioContent.isAdult.isFalse)

View File

@@ -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.PinContentRepository
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.extensions.convertLocalDateTime
import kr.co.vividnext.sodalive.fcm.FcmEvent
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.block.BlockMemberRepository
import kr.co.vividnext.sodalive.utils.generateFileName
@@ -56,11 +66,18 @@ class AudioContentService(
private val audioContentLikeRepository: AudioContentLikeRepository,
private val pinContentRepository: PinContentRepository,
private val translationService: PapagoTranslationService,
private val contentTranslationRepository: ContentTranslationRepository,
private val s3Uploader: S3Uploader,
private val objectMapper: ObjectMapper,
private val audioContentCloudFront: AudioContentCloudFront,
private val applicationEventPublisher: ApplicationEventPublisher,
private val langContext: LangContext,
private val contentThemeTranslationRepository: ContentThemeTranslationRepository,
@Value("\${cloud.aws.s3.content-bucket}")
private val audioContentBucket: String,
@@ -160,6 +177,13 @@ class AudioContentService(
audioContent.audioContentHashTags.addAll(newAudioContentHashTagList)
}
applicationEventPublisher.publishEvent(
LanguageTranslationEvent(
id = request.contentId,
targetType = LanguageTranslationTargetType.CONTENT
)
)
}
@Transactional
@@ -238,6 +262,7 @@ class AudioContentService(
val audioContent = AudioContent(
title = request.title.trim(),
detail = request.detail.trim(),
languageCode = request.languageCode,
price = if (request.price > 0) {
request.price
} else {
@@ -331,6 +356,31 @@ class AudioContentService(
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
)
)
} else {
applicationEventPublisher.publishEvent(
LanguageTranslationEvent(
id = audioContent.id!!,
targetType = LanguageTranslationTargetType.CONTENT
)
)
}
return CreateAudioContentResponse(contentId = audioContent.id!!)
}
@@ -477,6 +527,7 @@ class AudioContentService(
}
}
@Transactional
fun getDetail(
id: Long,
member: Member,
@@ -699,13 +750,108 @@ class AudioContentService(
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
)
}
}
}
/**
* themeStr 번역 처리
*/
val themeStrTranslated = run {
val theme = audioContent.theme
if (theme?.id != null) {
val locale = langContext.lang.code
val translated = contentThemeTranslationRepository
.findByContentThemeIdAndLocale(theme.id!!, locale)
val text = translated?.theme
if (!text.isNullOrBlank()) text else theme.theme
} else {
audioContent.theme!!.theme
}
}
return GetAudioContentDetailResponse(
contentId = audioContent.id!!,
title = audioContent.title,
detail = contentDetail,
languageCode = audioContent.languageCode,
coverImageUrl = "$coverImageHost/${audioContent.coverImage!!}",
contentUrl = audioContentUrl,
themeStr = audioContent.theme!!.theme,
themeStr = themeStrTranslated,
tag = tag,
price = audioContent.price,
duration = audioContent.duration ?: "",
@@ -745,7 +891,8 @@ class AudioContentService(
previousContent = previousContent,
nextContent = nextContent,
buyerList = buyerList,
isAvailableUsePoint = audioContent.isPointAvailable
isAvailableUsePoint = audioContent.isPointAvailable,
translated = translated
)
}
@@ -852,9 +999,27 @@ class AudioContentService(
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(
totalCount = totalCount,
items = items
items = translatedContentList
)
}
@@ -994,8 +1159,20 @@ class AudioContentService(
orderByRandom: Boolean = false,
isPointAvailableOnly: Boolean = false
): 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,
offset = offset,
limit = limit,
@@ -1005,5 +1182,79 @@ class AudioContentService(
orderByRandom = orderByRandom,
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()
}
}

View File

@@ -17,5 +17,6 @@ data class CreateAudioContentRequest(
val isCommentAvailable: Boolean = false,
val isFullDetailVisible: Boolean = true,
val previewStartTime: String? = null,
val previewEndTime: String? = null
val previewEndTime: String? = null,
val languageCode: String? = null
)

View File

@@ -3,11 +3,13 @@ package kr.co.vividnext.sodalive.content
import com.querydsl.core.annotations.QueryProjection
import kr.co.vividnext.sodalive.content.comment.GetAudioContentCommentListItem
import kr.co.vividnext.sodalive.content.order.OrderType
import kr.co.vividnext.sodalive.content.translation.TranslatedContent
data class GetAudioContentDetailResponse(
val contentId: Long,
val title: String,
val detail: String,
val languageCode: String?,
val coverImageUrl: String,
val contentUrl: String,
val themeStr: String,
@@ -39,7 +41,8 @@ data class GetAudioContentDetailResponse(
val previousContent: OtherContentResponse?,
val nextContent: OtherContentResponse?,
val buyerList: List<ContentBuyer>,
val isAvailableUsePoint: Boolean
val isAvailableUsePoint: Boolean,
val translated: TranslatedContent?
)
data class OtherContentResponse @QueryProjection constructor(

View File

@@ -0,0 +1,394 @@
package kr.co.vividnext.sodalive.content
import kr.co.vividnext.sodalive.chat.character.comment.CharacterCommentRepository
import kr.co.vividnext.sodalive.chat.character.repository.ChatCharacterRepository
import kr.co.vividnext.sodalive.chat.original.OriginalWorkRepository
import kr.co.vividnext.sodalive.content.comment.AudioContentCommentRepository
import kr.co.vividnext.sodalive.content.series.ContentSeriesRepository
import kr.co.vividnext.sodalive.explorer.profile.CreatorCheersRepository
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.data.repository.findByIdOrNull
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,
SERIES,
ORIGINAL_WORK
}
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 seriesRepository: ContentSeriesRepository,
private val originalWorkRepository: OriginalWorkRepository,
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)
LanguageDetectTargetType.SERIES -> handleSeriesLanguageDetect(event)
LanguageDetectTargetType.ORIGINAL_WORK -> handleOriginalWorkLanguageDetect(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 handleSeriesLanguageDetect(event: LanguageDetectEvent) {
val seriesId = event.id
val series = seriesRepository.findByIdOrNull(seriesId)
if (series == null) {
log.warn("[PapagoLanguageDetect] Series not found. seriesId={}", seriesId)
return
}
// 이미 언어 코드가 설정된 경우 호출하지 않음
if (!series.languageCode.isNullOrBlank()) {
log.debug(
"[PapagoLanguageDetect] languageCode already set. Skip language detection. seriesId={}, languageCode={}",
seriesId,
series.languageCode
)
return
}
val langCode = requestPapagoLanguageCode(event.query, seriesId) ?: return
series.languageCode = langCode
seriesRepository.save(series)
applicationEventPublisher.publishEvent(
LanguageTranslationEvent(
id = seriesId,
targetType = LanguageTranslationTargetType.SERIES
)
)
log.info(
"[PapagoLanguageDetect] languageCode updated from Papago. seriesId={}, langCode={}",
seriesId,
langCode
)
}
private fun handleOriginalWorkLanguageDetect(event: LanguageDetectEvent) {
val originalWorkId = event.id
val originalWork = originalWorkRepository.findByIdOrNull(originalWorkId)
if (originalWork == null) {
log.warn("[PapagoLanguageDetect] OriginalWork not found. originalWorkId={}", originalWorkId)
return
}
// 이미 언어 코드가 설정된 경우 호출하지 않음
if (!originalWork.languageCode.isNullOrBlank()) {
log.debug(
"[PapagoLanguageDetect] languageCode already set. Skip language detection. originalWorkId={}, languageCode={}",
originalWorkId,
originalWork.languageCode
)
return
}
val langCode = requestPapagoLanguageCode(event.query, originalWorkId) ?: return
originalWork.languageCode = langCode
originalWorkRepository.save(originalWork)
// 언어 감지가 완료된 후 언어 번역 이벤트 호출
applicationEventPublisher.publishEvent(
LanguageTranslationEvent(
id = originalWorkId,
targetType = LanguageTranslationTargetType.ORIGINAL_WORK
)
)
log.info(
"[PapagoLanguageDetect] languageCode updated from Papago. originalWorkId={}, langCode={}",
originalWorkId,
langCode
)
}
private fun requestPapagoLanguageCode(query: String, targetIdForLog: Long): String? {
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
}
}
}

View File

@@ -16,6 +16,7 @@ import javax.persistence.Table
data class AudioContentComment(
@Column(columnDefinition = "TEXT", nullable = false)
var comment: String,
var languageCode: String?,
@Column(nullable = true)
var donationCan: Int? = null,
val isSecret: Boolean = false,

View File

@@ -32,7 +32,8 @@ class AudioContentCommentController(
audioContentId = request.contentId,
parentId = request.parentId,
isSecret = request.isSecret,
member = member
member = member,
languageCode = request.languageCode
)
try {

View File

@@ -85,6 +85,7 @@ class AudioContentCommentQueryRepositoryImpl(
audioContentComment.member.nickname,
audioContentComment.member.profileImage.prepend("/").prepend(cloudFrontHost),
audioContentComment.comment,
audioContentComment.languageCode,
audioContentComment.isSecret,
audioContentComment.donationCan.coalesce(0),
formattedDate,
@@ -166,6 +167,7 @@ class AudioContentCommentQueryRepositoryImpl(
audioContentComment.member.nickname,
audioContentComment.member.profileImage.prepend("/").prepend(cloudFrontHost),
audioContentComment.comment,
audioContentComment.languageCode,
audioContentComment.isSecret,
audioContentComment.donationCan.coalesce(0),
formattedDate,

View File

@@ -2,6 +2,8 @@ package kr.co.vividnext.sodalive.content.comment
import kr.co.vividnext.sodalive.common.SodaException
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.fcm.FcmEvent
import kr.co.vividnext.sodalive.fcm.FcmEventType
@@ -32,7 +34,8 @@ class AudioContentCommentService(
comment: String,
audioContentId: Long,
parentId: Long? = null,
isSecret: Boolean = false
isSecret: Boolean = false,
languageCode: String?
): Long {
val audioContent = audioContentRepository.findByIdOrNull(id = audioContentId)
?: throw SodaException("잘못된 콘텐츠 입니다.\n다시 시도해 주세요.")
@@ -50,7 +53,7 @@ class AudioContentCommentService(
throw SodaException("콘텐츠 구매 후 비밀댓글을 등록할 수 있습니다.")
}
val audioContentComment = AudioContentComment(comment = comment, isSecret = isSecret)
val audioContentComment = AudioContentComment(comment = comment, languageCode = languageCode, isSecret = isSecret)
audioContentComment.audioContent = audioContent
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!!
}

View File

@@ -13,6 +13,7 @@ data class GetAudioContentCommentListItem @QueryProjection constructor(
val nickname: String,
val profileUrl: String,
val comment: String,
val languageCode: String?,
val isSecret: Boolean,
val donationCan: Int,
val date: String,

View File

@@ -4,5 +4,6 @@ data class RegisterCommentRequest(
val comment: String,
val contentId: Long,
val parentId: Long?,
val isSecret: Boolean = false
val isSecret: Boolean = false,
val languageCode: String? = null
)

View File

@@ -4,5 +4,6 @@ data class AudioContentDonationRequest(
val contentId: Long,
val donationCan: Int,
val comment: String,
val container: String
val container: String,
val languageCode: String? = null
)

View File

@@ -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.common.SodaException
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.AudioContentCommentRepository
import kr.co.vividnext.sodalive.member.Member
import org.springframework.context.ApplicationEventPublisher
import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional
@@ -14,7 +17,8 @@ import org.springframework.transaction.annotation.Transactional
class AudioContentDonationService(
private val canPaymentService: CanPaymentService,
private val queryRepository: AudioContentRepository,
private val commentRepository: AudioContentCommentRepository
private val commentRepository: AudioContentCommentRepository,
private val applicationEventPublisher: ApplicationEventPublisher
) {
@Transactional
fun donation(request: AudioContentDonationRequest, member: Member) {
@@ -34,10 +38,23 @@ class AudioContentDonationService(
val audioContentComment = AudioContentComment(
comment = request.comment,
languageCode = request.languageCode,
donationCan = request.donationCan
)
audioContentComment.audioContent = audioContent
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
)
)
}
}
}

View File

@@ -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.curation.GetAudioContentCurationResponse
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.i18n.LangContext
import kr.co.vividnext.sodalive.member.Member
import kr.co.vividnext.sodalive.member.block.BlockMemberRepository
import org.springframework.beans.factory.annotation.Value
@@ -20,14 +24,33 @@ class AudioContentMainService(
private val repository: AudioContentRepository,
private val blockMemberRepository: BlockMemberRepository,
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}")
private val imageHost: String
) {
@Transactional(readOnly = true)
@Cacheable(cacheNames = ["default"], key = "'themeList:' + ':' + #isAdult")
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)
@@ -60,8 +83,12 @@ class AudioContentMainService(
member: Member,
pageable: Pageable
): GetNewContentAllResponse {
/**
* - AS-IS theme은 한글만 처리하도록 되어 있음
* - TO-BE 번역된 theme이 들어와도 동일한 동작을 하도록 처리
*/
val isAdult = member.auth != null && isAdultContentVisible
val themeList = if (theme.isBlank()) {
val themeListRaw = if (theme.isBlank()) {
audioContentThemeRepository.getActiveThemeOfContent(
isAdult = isAdult,
contentType = contentType
@@ -70,13 +97,19 @@ class AudioContentMainService(
listOf(theme)
}
val themeList = normalizeThemeForQuery(
themes = themeListRaw,
contentType = contentType,
isAdult = isAdult
)
val totalCount = repository.totalCountNewContentFor2Weeks(
themeList,
memberId = member.id!!,
isAdult = isAdult,
contentType = contentType
)
val items = repository.findByThemeFor2Weeks(
val contentList = repository.findByThemeFor2Weeks(
cloudfrontHost = imageHost,
memberId = member.id!!,
theme = themeList,
@@ -87,7 +120,75 @@ class AudioContentMainService(
)
.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)

View File

@@ -6,15 +6,26 @@ import kr.co.vividnext.sodalive.content.order.OrderRepository
import kr.co.vividnext.sodalive.content.order.OrderType
import kr.co.vividnext.sodalive.content.series.content.ContentSeriesContentRepository
import kr.co.vividnext.sodalive.content.series.content.GetSeriesContentListResponse
import kr.co.vividnext.sodalive.content.series.translation.SeriesGenreTranslationRepository
import kr.co.vividnext.sodalive.content.series.translation.SeriesTranslation
import kr.co.vividnext.sodalive.content.series.translation.SeriesTranslationPayload
import kr.co.vividnext.sodalive.content.series.translation.SeriesTranslationRepository
import kr.co.vividnext.sodalive.content.series.translation.TranslatedSeries
import kr.co.vividnext.sodalive.content.translation.ContentTranslationRepository
import kr.co.vividnext.sodalive.creator.admin.content.series.Series
import kr.co.vividnext.sodalive.creator.admin.content.series.SeriesPublishedDaysOfWeek
import kr.co.vividnext.sodalive.creator.admin.content.series.SeriesSortType
import kr.co.vividnext.sodalive.creator.admin.content.series.SeriesState
import kr.co.vividnext.sodalive.explorer.ExplorerQueryRepository
import kr.co.vividnext.sodalive.i18n.Lang
import kr.co.vividnext.sodalive.i18n.LangContext
import kr.co.vividnext.sodalive.i18n.translation.PapagoTranslationService
import kr.co.vividnext.sodalive.i18n.translation.TranslateRequest
import kr.co.vividnext.sodalive.member.Member
import kr.co.vividnext.sodalive.member.block.BlockMemberRepository
import org.springframework.beans.factory.annotation.Value
import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional
import java.time.LocalDateTime
import java.time.ZoneId
import java.time.format.DateTimeFormatter
@@ -27,6 +38,13 @@ class ContentSeriesService(
private val explorerQueryRepository: ExplorerQueryRepository,
private val seriesContentRepository: ContentSeriesContentRepository,
private val langContext: LangContext,
private val seriesTranslationRepository: SeriesTranslationRepository,
private val seriesGenreTranslationRepository: SeriesGenreTranslationRepository,
private val contentTranslationRepository: ContentTranslationRepository,
private val translationService: PapagoTranslationService,
@Value("\${cloud.aws.cloud-front.host}")
private val coverImageHost: String
) {
@@ -42,11 +60,77 @@ class ContentSeriesService(
limit: Long = 20
): List<GetSeriesListResponse.SeriesListItem> {
val originalAudioDramaList = repository.getOriginalAudioDramaList(isAdult, contentType, orderByRandom, offset, limit)
return seriesToSeriesListItem(originalAudioDramaList, isAdult, contentType)
return getTranslatedSeriesList(seriesToSeriesListItem(originalAudioDramaList, isAdult, contentType))
}
fun getGenreList(memberId: Long, isAdult: Boolean, contentType: ContentType): List<GetSeriesGenreListResponse> {
return repository.getGenreList(memberId = memberId, isAdult = isAdult, contentType = contentType)
/**
* langContext.lang == Lang.EN || Lang.JA 일 때 번역된 콘텐츠 테마 반환
* 번역이 없으면 번역 API 호출 후 저장하고 반환
*/
val genres = repository.getGenreList(memberId = memberId, isAdult = isAdult, contentType = contentType)
val currentLang = langContext.lang
if (currentLang == Lang.EN || currentLang == Lang.JA) {
val targetLocale = currentLang.code
val ids = genres.map { it.id }
// 기존 번역 일괄 조회
val existing = if (ids.isNotEmpty()) {
// 메서드가 없을 경우 개별 조회로 대체되지만, 성능을 위해 우선 시도
try {
seriesGenreTranslationRepository
.findBySeriesGenreIdInAndLocale(ids, targetLocale)
} catch (_: Exception) {
// Spring Data 메서드 미존재 시 안전하게 개별 조회로 폴백
ids.mapNotNull { id ->
seriesGenreTranslationRepository.findBySeriesGenreIdAndLocale(id, targetLocale)
}
}
} else {
emptyList()
}
val existingMap = existing.associateBy { it.seriesGenreId }.toMutableMap()
// 미번역 항목 수집
val untranslated = genres.filter { existingMap[it.id] == null }
if (untranslated.isNotEmpty()) {
val texts = untranslated.map { it.genre }
val response = translationService.translate(
request = TranslateRequest(
texts = texts,
sourceLanguage = "ko",
targetLanguage = targetLocale
)
)
val translatedTexts = response.translatedText
val toSave = mutableListOf<kr.co.vividnext.sodalive.content.series.translation.SeriesGenreTranslation>()
untranslated.forEachIndexed { index, item ->
val translated = translatedTexts.getOrNull(index) ?: item.genre
toSave.add(
kr.co.vividnext.sodalive.content.series.translation.SeriesGenreTranslation(
seriesGenreId = item.id,
locale = targetLocale,
genre = translated
)
)
}
if (toSave.isNotEmpty()) {
seriesGenreTranslationRepository.saveAll(toSave)
toSave.forEach { saved -> existingMap[saved.seriesGenreId] = saved }
}
}
// 원래 순서 보존하여 결과 조립
return genres.map { g ->
val translated = existingMap[g.id]?.genre ?: g.genre
GetSeriesGenreListResponse(id = g.id, genre = translated)
}
}
return genres
}
fun getSeriesList(
@@ -83,7 +167,7 @@ class ContentSeriesService(
).filter { !blockMemberRepository.isBlocked(blockedMemberId = member.id!!, memberId = it.member!!.id!!) }
val items = seriesToSeriesListItem(seriesList = rawItems, isAdult = isAuth, contentType = contentType)
return GetSeriesListResponse(totalCount, items)
return GetSeriesListResponse(totalCount, getTranslatedSeriesList(items))
}
fun getSeriesListByGenre(
@@ -112,9 +196,10 @@ class ContentSeriesService(
).filter { !blockMemberRepository.isBlocked(blockedMemberId = member.id!!, memberId = it.member!!.id!!) }
val items = seriesToSeriesListItem(seriesList = rawItems, isAdult = isAuth, contentType = contentType)
return GetSeriesListResponse(totalCount, items)
return GetSeriesListResponse(totalCount, getTranslatedSeriesList(items))
}
@Transactional
fun getSeriesDetail(
seriesId: Long,
isAdultContentVisible: Boolean,
@@ -156,7 +241,115 @@ class ContentSeriesService(
limit = 5
)
/**
* series.languageCode != null && series.languageCode != languageCode
*
* 번역 시리즈를 조회한다. - series, locale
* 번역 콘텐츠가 있으면
* TranslatedSeries로 가공한다
*
* 번역 콘텐츠가 없으면
* 파파고 API를 통해 번역한 후 저장한다.
*
* 번역 대상: title, introduction, keywordList
*
* 파파고로 번역한 데이터를 TranslatedSeries 가공한다
*/
val dateTimeFormatter = DateTimeFormatter.ofPattern("yyyy.MM.dd")
// 요청된 언어(locale)에 대한 시리즈 번역을 조회하거나, 없으면 동기 번역 후 저장한다.
var translated: TranslatedSeries? = null
run {
val locale = langContext.lang.code
val languageCode = series.languageCode
// 원본 언어가 존재하고, 요청 언어와 다를 때만 번역 처리
if (!languageCode.isNullOrBlank() && languageCode != locale) {
val existing = seriesTranslationRepository.findBySeriesIdAndLocale(seriesId = seriesId, locale = locale)
if (existing != null) {
val payload = existing.renderedPayload
val kws = payload.keywords.ifEmpty { keywordList }
translated = TranslatedSeries(
title = payload.title,
introduction = payload.introduction,
keywords = kws
)
} else {
val texts = mutableListOf<String>()
texts.add(series.title)
texts.add(series.introduction)
// 키워드는 개별 항목으로 번역 요청하여 N회 호출을 방지한다.
val keywordListForTranslate = keywordList
texts.addAll(keywordListForTranslate)
val response = translationService.translate(
request = TranslateRequest(
texts = texts,
sourceLanguage = languageCode,
targetLanguage = locale
)
)
val translatedTexts = response.translatedText
if (translatedTexts.size == texts.size) {
var index = 0
val translatedTitle = translatedTexts[index++]
val translatedIntroduction = translatedTexts[index++]
val translatedKeywords = if (keywordListForTranslate.isNotEmpty()) {
translatedTexts.subList(index, translatedTexts.size)
} else {
// 번역할 키워드가 없으면 원본 키워드 반환 정책 적용
keywordList
}
val payload = SeriesTranslationPayload(
title = translatedTitle,
introduction = translatedIntroduction,
keywords = translatedKeywords
)
seriesTranslationRepository.save(
SeriesTranslation(
seriesId = seriesId,
locale = locale,
renderedPayload = payload
)
)
val kws = translatedKeywords.ifEmpty { keywordList }
translated = TranslatedSeries(
title = translatedTitle,
introduction = translatedIntroduction,
keywords = kws
)
}
}
}
}
// 장르 번역 조회 (있으면 반환)
val translatedGenre: String? = run {
val genreId = series.genre?.id
if (genreId != null) {
val locale = langContext.lang.code
val found = seriesGenreTranslationRepository.findBySeriesGenreIdAndLocale(genreId, locale)
val text = found?.genre
if (!text.isNullOrBlank()) {
text
} else {
null
}
} else {
null
}
}
// publishedDateUtc는 ISO8601(Z 포함)로 반환
val publishedDateUtc = series.createdAt!!
.atZone(ZoneId.of("UTC"))
.toInstant()
.toString()
return GetSeriesDetailResponse(
seriesId = seriesId,
title = series.title,
@@ -171,6 +364,7 @@ class ContentSeriesService(
.withZoneSameInstant(ZoneId.of("Asia/Seoul"))
.toLocalDateTime()
.format(dateTimeFormatter),
publishedDateUtc = publishedDateUtc,
creator = GetSeriesDetailResponse.GetSeriesDetailCreator(
creatorId = series.member!!.id!!,
nickname = series.member!!.nickname,
@@ -186,7 +380,9 @@ class ContentSeriesService(
keywordList = keywordList,
publishedDaysOfWeek = publishedDaysOfWeekText(series.publishedDaysOfWeek),
contentList = seriesContentList.items,
contentCount = seriesContentList.totalCount
contentCount = seriesContentList.totalCount,
translated = translated,
translatedGenre = translatedGenre
)
}
@@ -228,7 +424,33 @@ class ContentSeriesService(
it
}
return GetSeriesContentListResponse(totalCount, contentList)
/**
* 콘텐츠 리스트의 제목을 현재 언어(locale)에 맞춰 일괄 번역한다.
*
* 처리 절차:
* - 입력된 콘텐츠들의 contentId 집합을 만들고, 요청 언어 코드(langContext.lang.code)로
* contentTranslationRepository에서 번역 데이터를 한 번에 조회한다.
* - 각 항목에 대해 번역된 제목이 존재하고 비어있지 않으면 title만 번역 값으로 교체한다.
* - 번역이 없거나 공백이면 원본 항목을 그대로 반환한다.
*
* 성능:
* - N건의 항목을 1회의 조회로 해결하기 위해 IN 쿼리를 사용한다.
*/
val contentIds = contentList.map { it.contentId }
val translatedItems = if (contentIds.isNotEmpty()) {
val translations = contentTranslationRepository
.findByContentIdInAndLocale(contentIds = contentIds, locale = langContext.lang.code)
.associateBy { it.contentId }
contentList.map { item ->
val translatedTitle = translations[item.contentId]?.renderedPayload?.title
if (translatedTitle.isNullOrBlank()) item else item.copy(title = translatedTitle)
}
} else {
contentList
}
return GetSeriesContentListResponse(totalCount, translatedItems)
}
fun getRecommendSeriesList(
@@ -243,7 +465,13 @@ class ContentSeriesService(
limit = 20
).filter { !blockMemberRepository.isBlocked(blockedMemberId = member.id!!, memberId = it.member!!.id!!) }
return seriesToSeriesListItem(seriesList = seriesList, isAdult = isAuth, contentType = contentType)
return getTranslatedSeriesList(
seriesToSeriesListItem(
seriesList = seriesList,
isAdult = isAuth,
contentType = contentType
)
)
}
fun fetchSeriesByCurationId(
@@ -258,7 +486,7 @@ class ContentSeriesService(
isAdult = isAdult,
contentType = contentType
)
return seriesToSeriesListItem(seriesList, isAdult, contentType)
return getTranslatedSeriesList(seriesToSeriesListItem(seriesList, isAdult, contentType))
}
fun getDayOfWeekSeriesList(
@@ -288,7 +516,7 @@ class ContentSeriesService(
seriesList
}
return seriesToSeriesListItem(seriesList, isAdult, contentType)
return getTranslatedSeriesList(seriesToSeriesListItem(seriesList, isAdult, contentType))
}
private fun seriesToSeriesListItem(
@@ -338,27 +566,105 @@ class ContentSeriesService(
}
private fun publishedDaysOfWeekText(publishedDaysOfWeek: Set<SeriesPublishedDaysOfWeek>): String {
/**
* i18n을 적용하여 언어별로 요일 표시를 변경한다.
*/
val lang = langContext.lang
val labelRandom = when (lang) {
Lang.EN -> "Random"
Lang.JA -> "ランダム"
else -> "랜덤"
}
val labels = when (lang) {
Lang.EN -> mapOf(
SeriesPublishedDaysOfWeek.SUN to "Sun",
SeriesPublishedDaysOfWeek.MON to "Mon",
SeriesPublishedDaysOfWeek.TUE to "Tue",
SeriesPublishedDaysOfWeek.WED to "Wed",
SeriesPublishedDaysOfWeek.THU to "Thu",
SeriesPublishedDaysOfWeek.FRI to "Fri",
SeriesPublishedDaysOfWeek.SAT to "Sat",
SeriesPublishedDaysOfWeek.RANDOM to labelRandom
)
Lang.JA -> mapOf(
SeriesPublishedDaysOfWeek.SUN to "",
SeriesPublishedDaysOfWeek.MON to "",
SeriesPublishedDaysOfWeek.TUE to "",
SeriesPublishedDaysOfWeek.WED to "",
SeriesPublishedDaysOfWeek.THU to "",
SeriesPublishedDaysOfWeek.FRI to "",
SeriesPublishedDaysOfWeek.SAT to "",
SeriesPublishedDaysOfWeek.RANDOM to labelRandom
)
else -> mapOf(
SeriesPublishedDaysOfWeek.SUN to "",
SeriesPublishedDaysOfWeek.MON to "",
SeriesPublishedDaysOfWeek.TUE to "",
SeriesPublishedDaysOfWeek.WED to "",
SeriesPublishedDaysOfWeek.THU to "",
SeriesPublishedDaysOfWeek.FRI to "",
SeriesPublishedDaysOfWeek.SAT to "",
SeriesPublishedDaysOfWeek.RANDOM to labelRandom
)
}
val dayOfWeekText = publishedDaysOfWeek.toList().sortedBy { it.ordinal }
.map {
when (it) {
SeriesPublishedDaysOfWeek.SUN -> ""
SeriesPublishedDaysOfWeek.MON -> ""
SeriesPublishedDaysOfWeek.TUE -> ""
SeriesPublishedDaysOfWeek.WED -> ""
SeriesPublishedDaysOfWeek.THU -> ""
SeriesPublishedDaysOfWeek.FRI -> ""
SeriesPublishedDaysOfWeek.SAT -> ""
SeriesPublishedDaysOfWeek.RANDOM -> "랜덤"
}
}
.map { labels[it] ?: it.name }
.joinToString(", ") { it }
return if (publishedDaysOfWeek.contains(SeriesPublishedDaysOfWeek.RANDOM)) {
val containsRandom = publishedDaysOfWeek.contains(SeriesPublishedDaysOfWeek.RANDOM)
return if (containsRandom) {
dayOfWeekText
} else if (publishedDaysOfWeek.size < 7) {
"매주 $dayOfWeekText"
when (lang) {
Lang.EN -> "Every $dayOfWeekText"
Lang.JA -> "毎週 $dayOfWeekText"
else -> "매주 $dayOfWeekText"
}
} else {
"매일"
when (lang) {
Lang.EN -> "Daily"
Lang.JA -> "毎日"
else -> "매일"
}
}
}
/**
* 시리즈 리스트의 제목을 현재 언어(locale)에 맞춰 일괄 번역한다.
*
* 처리 절차:
* - 입력된 시리즈들의 seriesId 집합을 만들고, 요청 언어 코드(langContext.lang.code)로
* seriesTranslationRepository에서 번역 데이터를 한 번에 조회한다.
* - 각 항목에 대해 번역된 제목이 존재하고 비어있지 않으면 title만 번역 값으로 교체한다.
* - 번역이 없거나 공백이면 원본 항목을 그대로 반환한다.
*
* 성능:
* - N건의 항목을 1회의 조회로 해결하기 위해 IN 쿼리를 사용한다.
*
* @param seriesList 번역 대상 SeriesListItem 목록
* @return 제목이 가능한 항목은 번역된 목록(불변 사본), 그 외는 원본 항목 유지
*/
private fun getTranslatedSeriesList(
seriesList: List<GetSeriesListResponse.SeriesListItem>
): List<GetSeriesListResponse.SeriesListItem> {
val seriesIds = seriesList.map { it.seriesId }
if (seriesIds.isEmpty()) return seriesList
val translations = seriesTranslationRepository
.findBySeriesIdInAndLocale(seriesIds = seriesIds, locale = langContext.lang.code)
.associateBy { it.seriesId }
return seriesList.map { item ->
val translatedTitle = translations[item.seriesId]?.renderedPayload?.title
if (translatedTitle.isNullOrBlank()) {
item
} else {
item.copy(title = translatedTitle)
}
}
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -7,7 +7,7 @@ import javax.persistence.Table
@Entity
@Table(name = "content_theme")
data class AudioContentTheme(
class AudioContentTheme(
@Column(nullable = false)
var theme: String,
@Column(nullable = false)

View File

@@ -1,5 +1,6 @@
package kr.co.vividnext.sodalive.content.theme
import com.querydsl.core.types.dsl.CaseBuilder
import com.querydsl.jpa.impl.JPAQueryFactory
import kr.co.vividnext.sodalive.content.ContentType
import kr.co.vividnext.sodalive.content.QAudioContent.audioContent
@@ -14,6 +15,10 @@ class AudioContentThemeQueryRepository(
@Value("\${cloud.aws.cloud-front.host}")
private val cloudFrontHost: String
) {
data class ThemeIdAndName(
val id: Long,
val theme: String
)
fun getActiveThemes(): List<GetAudioContentThemeResponse> {
return queryFactory
.select(
@@ -64,15 +69,90 @@ class AudioContentThemeQueryRepository(
where = where.and(audioContent.isPointAvailable.isTrue)
}
return queryFactory
val query = queryFactory
.select(audioContentTheme.theme)
.from(audioContent)
.innerJoin(audioContent.member, member)
.innerJoin(audioContent.theme, audioContentTheme)
.where(where)
.groupBy(audioContentTheme.id)
.orderBy(audioContentTheme.orders.asc())
.fetch()
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()
}
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? {

View File

@@ -5,6 +5,12 @@ import kr.co.vividnext.sodalive.content.AudioContentRepository
import kr.co.vividnext.sodalive.content.ContentType
import kr.co.vividnext.sodalive.content.SortType
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 org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional
@@ -12,26 +18,94 @@ import org.springframework.transaction.annotation.Transactional
@Service
class AudioContentThemeService(
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)
fun getThemes(): List<GetAudioContentThemeResponse> {
return queryRepository.getActiveThemes()
}
@Transactional(readOnly = true)
@Transactional
fun getActiveThemeOfContent(
isAdult: Boolean = false,
isFree: Boolean = false,
isPointAvailableOnly: Boolean = false,
contentType: ContentType
): List<String> {
return queryRepository.getActiveThemeOfContent(
val themesWithIds = queryRepository.getActiveThemeWithIdsOfContent(
isAdult = isAdult,
isFree = isFree,
isPointAvailableOnly = isPointAvailableOnly,
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)

View File

@@ -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()

View File

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

View File

@@ -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?
)

View File

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

View File

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

View File

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

View File

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

View File

@@ -488,6 +488,7 @@ class ExplorerQueryRepository(
"$cloudFrontHost/profile/default-profile.png"
},
content = it.cheers,
languageCode = it.languageCode,
date = date.format(DateTimeFormatter.ofPattern("yyyy.MM.dd E hh:mm a")),
replyList = it.children.asSequence()
.map { cheers ->
@@ -505,6 +506,7 @@ class ExplorerQueryRepository(
"$cloudFrontHost/profile/default-profile.png"
},
content = cheers.cheers,
languageCode = cheers.languageCode,
date = replyDate.format(DateTimeFormatter.ofPattern("yyyy.MM.dd E hh:mm a")),
replyList = listOf()
)

View File

@@ -3,8 +3,11 @@ package kr.co.vividnext.sodalive.explorer
import kr.co.vividnext.sodalive.common.SodaException
import kr.co.vividnext.sodalive.content.AudioContentService
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.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.GetFollowerListResponseItem
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.fcm.FcmEvent
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.member.Member
import kr.co.vividnext.sodalive.member.MemberRole
@@ -44,6 +48,9 @@ class ExplorerService(
private val seriesService: ContentSeriesService,
private val applicationEventPublisher: ApplicationEventPublisher,
private val contentTranslationRepository: ContentTranslationRepository,
private val langContext: LangContext,
@Value("\${cloud.aws.cloud-front.host}")
private val cloudFrontHost: String
@@ -230,6 +237,24 @@ class ExplorerService(
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개
val latestContent = if (isCreator) {
audioContentService.getLatestCreatorAudioContent(creatorId, member, isAdultContentVisible)
@@ -330,7 +355,7 @@ class ExplorerService(
userDonationRanking = memberDonationRanking,
similarCreatorList = similarCreatorList,
liveRoomList = liveRoomList,
contentList = contentList,
contentList = translatedContentList,
latestContent = latestContent,
totalContentCount = totalContentCount,
ownedContentCount = ownedContentCount,
@@ -441,7 +466,7 @@ class ExplorerService(
val isBlocked = memberService.isBlocked(blockedMemberId = member.id!!, memberId = request.creatorId)
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.creator = creator
@@ -456,6 +481,17 @@ class ExplorerService(
}
cheersRepository.save(cheers)
// 언어 코드가 지정되지 않은 경우, 파파고 언어 감지 API를 통해 비동기로 언어를 식별한다.
if (request.languageCode.isNullOrBlank()) {
applicationEventPublisher.publishEvent(
LanguageDetectEvent(
id = cheers.id!!,
query = request.content,
targetType = LanguageDetectTargetType.CREATOR_CHEERS
)
)
}
}
fun getCreatorProfileCheers(

View File

@@ -11,6 +11,7 @@ data class GetCheersResponseItem(
val nickname: String,
val profileUrl: String,
val content: String,
val languageCode: String?,
val date: String,
val replyList: List<GetCheersResponseItem>
)

View File

@@ -13,6 +13,7 @@ import javax.persistence.OneToMany
data class CreatorCheers(
@Column(columnDefinition = "TEXT", nullable = false)
var cheers: String,
var languageCode: String?,
var isActive: Boolean = true
) : BaseEntity() {
@ManyToOne(fetch = FetchType.LAZY)

View File

@@ -3,5 +3,6 @@ package kr.co.vividnext.sodalive.explorer.profile
data class PostWriteCheersRequest(
val parentId: Long? = null,
val creatorId: Long,
val content: String
val content: String,
val languageCode: String? = null
)

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

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

View File

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

View File

@@ -0,0 +1,456 @@
package kr.co.vividnext.sodalive.i18n.translation
import kr.co.vividnext.sodalive.admin.content.series.AdminContentSeriesRepository
import kr.co.vividnext.sodalive.admin.content.series.genre.AdminContentSeriesGenreRepository
import kr.co.vividnext.sodalive.chat.character.repository.ChatCharacterRepository
import kr.co.vividnext.sodalive.chat.character.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.chat.original.OriginalWorkRepository
import kr.co.vividnext.sodalive.chat.original.translation.OriginalWorkTranslation
import kr.co.vividnext.sodalive.chat.original.translation.OriginalWorkTranslationPayload
import kr.co.vividnext.sodalive.chat.original.translation.OriginalWorkTranslationRepository
import kr.co.vividnext.sodalive.content.AudioContentRepository
import kr.co.vividnext.sodalive.content.series.translation.SeriesGenreTranslation
import kr.co.vividnext.sodalive.content.series.translation.SeriesGenreTranslationRepository
import kr.co.vividnext.sodalive.content.series.translation.SeriesTranslation
import kr.co.vividnext.sodalive.content.series.translation.SeriesTranslationPayload
import kr.co.vividnext.sodalive.content.series.translation.SeriesTranslationRepository
import kr.co.vividnext.sodalive.content.theme.AudioContentThemeQueryRepository
import kr.co.vividnext.sodalive.content.theme.translation.ContentThemeTranslation
import kr.co.vividnext.sodalive.content.theme.translation.ContentThemeTranslationRepository
import kr.co.vividnext.sodalive.content.translation.ContentTranslation
import kr.co.vividnext.sodalive.content.translation.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,
SERIES,
SERIES_GENRE,
ORIGINAL_WORK
}
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 seriesRepository: AdminContentSeriesRepository,
private val seriesGenreRepository: AdminContentSeriesGenreRepository,
private val originalWorkRepository: OriginalWorkRepository,
private val contentTranslationRepository: ContentTranslationRepository,
private val aiCharacterTranslationRepository: AiCharacterTranslationRepository,
private val contentThemeTranslationRepository: ContentThemeTranslationRepository,
private val seriesTranslationRepository: SeriesTranslationRepository,
private val seriesGenreTranslationRepository: SeriesGenreTranslationRepository,
private val originalWorkTranslationRepository: OriginalWorkTranslationRepository,
private val translationService: PapagoTranslationService
) {
@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)
LanguageTranslationTargetType.SERIES -> handleSeriesLanguageTranslation(event)
LanguageTranslationTargetType.SERIES_GENRE -> handleSeriesGenreLanguageTranslation(event)
LanguageTranslationTargetType.ORIGINAL_WORK -> handleOriginalWorkLanguageTranslation(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)
}
}
}
}
private fun handleSeriesLanguageTranslation(event: LanguageTranslationEvent) {
val series = seriesRepository.findByIdOrNull(event.id) ?: return
val languageCode = series.languageCode
if (languageCode != null) return
getTranslatableLanguageCodes(languageCode).forEach { locale ->
val keywords = series.keywordList
.mapNotNull { it.keyword?.tag }
.joinToString(", ")
val texts = mutableListOf<String>()
texts.add(series.title)
texts.add(series.introduction)
texts.add(keywords)
val sourceLanguage = series.languageCode ?: "ko"
val response = translationService.translate(
request = TranslateRequest(
texts = texts,
sourceLanguage = sourceLanguage,
targetLanguage = locale
)
)
val translatedTexts = response.translatedText
if (translatedTexts.size == texts.size) {
var index = 0
val translatedTitle = translatedTexts[index++]
val translatedIntroduction = translatedTexts[index++]
val translatedKeywordsJoined = translatedTexts[index]
val translatedKeywords = translatedKeywordsJoined
.split(",")
.map { it.trim() }
.filter { it.isNotBlank() }
val payload = SeriesTranslationPayload(
title = translatedTitle,
introduction = translatedIntroduction,
keywords = translatedKeywords
)
val existing = seriesTranslationRepository
.findBySeriesIdAndLocale(series.id!!, locale)
if (existing == null) {
seriesTranslationRepository.save(
SeriesTranslation(
seriesId = series.id!!,
locale = locale,
renderedPayload = payload
)
)
} else {
existing.renderedPayload = payload
seriesTranslationRepository.save(existing)
}
}
}
}
private fun handleSeriesGenreLanguageTranslation(event: LanguageTranslationEvent) {
val seriesGenre = seriesGenreRepository.findActiveSeriesGenreById(event.id) ?: return
val sourceLanguage = "ko"
getTranslatableLanguageCodes(sourceLanguage).forEach { locale ->
val texts = mutableListOf<String>()
texts.add(seriesGenre.genre)
val response = translationService.translate(
request = TranslateRequest(
texts = texts,
sourceLanguage = sourceLanguage,
targetLanguage = locale
)
)
val translatedTexts = response.translatedText
if (translatedTexts.size == texts.size) {
val translatedGenre = translatedTexts[0]
val existing = seriesGenreTranslationRepository
.findBySeriesGenreIdAndLocale(seriesGenre.id!!, locale)
if (existing == null) {
seriesGenreTranslationRepository.save(
SeriesGenreTranslation(
seriesGenreId = seriesGenre.id!!,
locale = locale,
genre = translatedGenre
)
)
} else {
existing.genre = translatedGenre
seriesGenreTranslationRepository.save(existing)
}
}
}
}
private fun handleOriginalWorkLanguageTranslation(event: LanguageTranslationEvent) {
val originalWork = originalWorkRepository.findByIdOrNull(event.id) ?: return
val languageCode = originalWork.languageCode
if (languageCode != null) return
/**
* handleSeriesLanguageTranslation 참조하여 원작 번역 구현
*
* originalWorkTranslationRepository
*
* 번역대상
* - title
* - contentType
* - category
* - description
* - tags
*/
getTranslatableLanguageCodes(languageCode).forEach { locale ->
val tagsJoined = originalWork.tagMappings
.mapNotNull { it.tag.tag }
.joinToString(", ")
val texts = mutableListOf<String>()
texts.add(originalWork.title)
texts.add(originalWork.contentType)
texts.add(originalWork.category)
texts.add(originalWork.description)
texts.add(tagsJoined)
val sourceLanguage = originalWork.languageCode ?: "ko"
val response = translationService.translate(
request = TranslateRequest(
texts = texts,
sourceLanguage = sourceLanguage,
targetLanguage = locale
)
)
val translatedTexts = response.translatedText
if (translatedTexts.size == texts.size) {
var index = 0
val translatedTitle = translatedTexts[index++]
val translatedContentType = translatedTexts[index++]
val translatedCategory = translatedTexts[index++]
val translatedDescription = translatedTexts[index++]
val translatedTagsJoined = translatedTexts[index]
val translatedTags = translatedTagsJoined
.split(",")
.map { it.trim() }
.filter { it.isNotBlank() }
val payload = OriginalWorkTranslationPayload(
title = translatedTitle,
contentType = translatedContentType,
category = translatedCategory,
description = translatedDescription,
tags = translatedTags
)
val existing = originalWorkTranslationRepository
.findByOriginalWorkIdAndLocale(originalWork.id!!, locale)
if (existing == null) {
originalWorkTranslationRepository.save(
OriginalWorkTranslation(
originalWorkId = originalWork.id!!,
locale = locale,
renderedPayload = payload
)
)
} else {
existing.renderedPayload = payload
originalWorkTranslationRepository.save(existing)
}
}
}
}
}

View File

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

View File

@@ -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
import org.springframework.web.client.postForEntity
@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<PapagoTranslationResponse>(
papagoTranslateUrl,
requestEntity
)
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()
}
}
}

View File

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

View File

@@ -45,6 +45,9 @@ google:
webClientId: ${GOOGLE_WEB_CLIENT_ID}
cloud:
naver:
papagoClientId: ${NCLOUD_PAPAGO_CLIENT_ID}
papagoClientSecret: ${NCLOUD_PAPAGO_CLIENT_SECRET}
aws:
credentials:
accessKey: ${APP_AWS_ACCESS_KEY}

View 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