From 13029ab8d2dd256ad2c3d60805a5ca3498e3bccb Mon Sep 17 00:00:00 2001 From: Klaus Date: Sat, 13 Dec 2025 00:51:07 +0900 Subject: [PATCH] =?UTF-8?q?=EC=BD=98=ED=85=90=EC=B8=A0=20=ED=85=8C?= =?UTF-8?q?=EB=A7=88=20=EB=B2=88=EC=97=AD=20N+1=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 온라인 경로에서 콘텐츠 테마 번역을 배치 조회/번역/저장으로 처리. - 기존 번역은 IN 조회, 미번역만 한 번의 번역 요청 후 저장. - 결과 순서 보전, 번역 누락/실패 시 원문으로 폴백. - 공개 API 변경 없음. --- .../theme/AudioContentThemeQueryRepository.kt | 67 ++++++++++++++++ .../content/theme/AudioContentThemeService.kt | 80 ++++++++++++++++++- .../ContentThemeTranslationRepository.kt | 2 + 3 files changed, 146 insertions(+), 3 deletions(-) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/content/theme/AudioContentThemeQueryRepository.kt b/src/main/kotlin/kr/co/vividnext/sodalive/content/theme/AudioContentThemeQueryRepository.kt index f4c2bbd..fec9474 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/content/theme/AudioContentThemeQueryRepository.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/content/theme/AudioContentThemeQueryRepository.kt @@ -15,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 { return queryFactory .select( @@ -88,6 +92,69 @@ class AudioContentThemeQueryRepository( return query.fetch() } + fun getActiveThemeWithIdsOfContent( + isAdult: Boolean = false, + isFree: Boolean = false, + isPointAvailableOnly: Boolean = false, + contentType: ContentType + ): List { + 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? { return queryFactory .selectFrom(audioContentTheme) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/content/theme/AudioContentThemeService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/content/theme/AudioContentThemeService.kt index 385b270..2adff9d 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/content/theme/AudioContentThemeService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/content/theme/AudioContentThemeService.kt @@ -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 { return queryRepository.getActiveThemes() } - @Transactional(readOnly = true) + @Transactional fun getActiveThemeOfContent( isAdult: Boolean = false, isFree: Boolean = false, isPointAvailableOnly: Boolean = false, contentType: ContentType ): List { - 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() + + // 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) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/content/theme/translation/ContentThemeTranslationRepository.kt b/src/main/kotlin/kr/co/vividnext/sodalive/content/theme/translation/ContentThemeTranslationRepository.kt index 7bee3c0..546f005 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/content/theme/translation/ContentThemeTranslationRepository.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/content/theme/translation/ContentThemeTranslationRepository.kt @@ -4,4 +4,6 @@ import org.springframework.data.jpa.repository.JpaRepository interface ContentThemeTranslationRepository : JpaRepository { fun findByContentThemeIdAndLocale(contentThemeId: Long, locale: String): ContentThemeTranslation? + + fun findByContentThemeIdInAndLocale(contentThemeIds: Collection, locale: String): List }