test #388

Merged
klaus merged 15 commits from test into main 2026-02-13 09:14:20 +00:00
7 changed files with 102 additions and 138 deletions
Showing only changes of commit 341f24c643 - Show all commits

View File

@@ -14,7 +14,6 @@ import kr.co.vividnext.sodalive.content.series.ContentSeriesService
import kr.co.vividnext.sodalive.content.series.GetSeriesListResponse import kr.co.vividnext.sodalive.content.series.GetSeriesListResponse
import kr.co.vividnext.sodalive.content.series.translation.SeriesTranslationRepository import kr.co.vividnext.sodalive.content.series.translation.SeriesTranslationRepository
import kr.co.vividnext.sodalive.content.theme.AudioContentThemeService import kr.co.vividnext.sodalive.content.theme.AudioContentThemeService
import kr.co.vividnext.sodalive.content.translation.ContentTranslationRepository
import kr.co.vividnext.sodalive.creator.admin.content.series.SeriesPublishedDaysOfWeek import kr.co.vividnext.sodalive.creator.admin.content.series.SeriesPublishedDaysOfWeek
import kr.co.vividnext.sodalive.event.GetEventResponse import kr.co.vividnext.sodalive.event.GetEventResponse
import kr.co.vividnext.sodalive.explorer.ExplorerQueryRepository import kr.co.vividnext.sodalive.explorer.ExplorerQueryRepository
@@ -50,7 +49,6 @@ class HomeService(
private val rankingRepository: RankingRepository, private val rankingRepository: RankingRepository,
private val explorerQueryRepository: ExplorerQueryRepository, private val explorerQueryRepository: ExplorerQueryRepository,
private val contentTranslationRepository: ContentTranslationRepository,
private val aiCharacterTranslationRepository: AiCharacterTranslationRepository, private val aiCharacterTranslationRepository: AiCharacterTranslationRepository,
private val seriesTranslationRepository: SeriesTranslationRepository, private val seriesTranslationRepository: SeriesTranslationRepository,
@@ -122,8 +120,6 @@ class HomeService(
isAdult = isAdult isAdult = isAdult
) )
val translatedLatestContentList = getTranslatedContentList(contentList = latestContentList)
val eventBannerList = GetEventResponse( val eventBannerList = GetEventResponse(
totalCount = 0, totalCount = 0,
eventList = emptyList() eventList = emptyList()
@@ -177,64 +173,12 @@ class HomeService(
sort = ContentRankingSortType.REVENUE sort = ContentRankingSortType.REVENUE
) )
val contentRankingContentIds = contentRanking.map { it.contentId }
val translatedContentRanking = if (contentRankingContentIds.isNotEmpty()) {
val translations = contentTranslationRepository
.findByContentIdInAndLocale(contentIds = contentRankingContentIds, locale = langContext.lang.code)
.associateBy { it.contentId }
contentRanking.map { item ->
val translatedTitle = translations[item.contentId]?.renderedPayload?.title
if (translatedTitle.isNullOrBlank()) {
item
} else {
item.copy(title = translatedTitle)
}
}
} else {
contentRanking
}
val recommendChannelList = recommendChannelService.getRecommendChannel( val recommendChannelList = recommendChannelService.getRecommendChannel(
memberId = memberId, memberId = memberId,
isAdult = isAdult, isAdult = isAdult,
contentType = contentType contentType = contentType
) )
/**
* recommendChannelList의 콘텐츠 번역 데이터 조회
*
* languageCode != null
* contentTranslationRepository를 이용해 번역 콘텐츠를 조회한다. - contentId, locale
*
* 한 번에 조회하고 contentId를 매핑하여 recommendChannelList의 콘텐츠 title을 번역 데이터로 변경한다
*/
val channelContentIds = recommendChannelList
.flatMap { it.contentList }
.map { it.contentId }
.distinct()
val translatedRecommendChannelList = if (channelContentIds.isNotEmpty()) {
val translations = contentTranslationRepository
.findByContentIdInAndLocale(contentIds = channelContentIds, locale = langContext.lang.code)
.associateBy { it.contentId }
recommendChannelList.map { channel ->
val translatedContentList = channel.contentList.map { item ->
val translatedTitle = translations[item.contentId]?.renderedPayload?.title
if (translatedTitle.isNullOrBlank()) {
item
} else {
item.copy(title = translatedTitle)
}
}
channel.copy(contentList = translatedContentList)
}
} else {
recommendChannelList
}
val freeContentList = getRandomizedContentList( val freeContentList = getRandomizedContentList(
memberId = memberId, memberId = memberId,
isAdult = isAdult, isAdult = isAdult,
@@ -248,8 +192,6 @@ class HomeService(
isPointAvailableOnly = false isPointAvailableOnly = false
) )
val translatedFreeContentList = getTranslatedContentList(contentList = freeContentList)
// 포인트 사용가능 콘텐츠 리스트 - 랜덤으로 가져오기 (DB에서 isPointAvailable 조건 적용) // 포인트 사용가능 콘텐츠 리스트 - 랜덤으로 가져오기 (DB에서 isPointAvailable 조건 적용)
val pointAvailableContentList = getRandomizedContentList( val pointAvailableContentList = getRandomizedContentList(
memberId = memberId, memberId = memberId,
@@ -260,28 +202,26 @@ class HomeService(
isPointAvailableOnly = true isPointAvailableOnly = true
) )
val translatedPointAvailableContentList = getTranslatedContentList(contentList = pointAvailableContentList)
val excludeContentIds = ( val excludeContentIds = (
translatedLatestContentList.map { it.contentId } + latestContentList.map { it.contentId } +
translatedContentRanking.map { it.contentId } contentRanking.map { it.contentId }
).distinct() ).distinct()
return GetHomeResponse( return GetHomeResponse(
liveList = liveList, liveList = liveList,
creatorRanking = creatorRanking, creatorRanking = creatorRanking,
latestContentThemeList = latestContentThemeList, latestContentThemeList = latestContentThemeList,
latestContentList = translatedLatestContentList, latestContentList = latestContentList,
bannerList = bannerList, bannerList = bannerList,
eventBannerList = eventBannerList, eventBannerList = eventBannerList,
originalAudioDramaList = translatedOriginalAudioDramaList, originalAudioDramaList = translatedOriginalAudioDramaList,
auditionList = auditionList, auditionList = auditionList,
dayOfWeekSeriesList = translatedDayOfWeekSeriesList, dayOfWeekSeriesList = translatedDayOfWeekSeriesList,
popularCharacters = translatedPopularCharacters, popularCharacters = translatedPopularCharacters,
contentRanking = translatedContentRanking, contentRanking = contentRanking,
recommendChannelList = translatedRecommendChannelList, recommendChannelList = recommendChannelList,
freeContentList = translatedFreeContentList, freeContentList = freeContentList,
pointAvailableContentList = translatedPointAvailableContentList, pointAvailableContentList = pointAvailableContentList,
recommendContentList = getRecommendContentList( recommendContentList = getRecommendContentList(
isAdultContentVisible = isAdultContentVisible, isAdultContentVisible = isAdultContentVisible,
contentType = contentType, contentType = contentType,
@@ -311,15 +251,13 @@ class HomeService(
listOf(theme) listOf(theme)
} }
val contentList = contentService.getLatestContentByTheme( return contentService.getLatestContentByTheme(
memberId = memberId, memberId = memberId,
theme = themeList, theme = themeList,
contentType = contentType, contentType = contentType,
isFree = false, isFree = false,
isAdult = isAdult isAdult = isAdult
) )
return getTranslatedContentList(contentList = contentList)
} }
fun getDayOfWeekSeriesList( fun getDayOfWeekSeriesList(
@@ -445,7 +383,7 @@ class HomeService(
} }
} }
return getTranslatedContentList(contentList = result.take(RECOMMEND_TARGET_SIZE).shuffled()) return result.take(RECOMMEND_TARGET_SIZE).shuffled()
} }
private fun pickByTimeDecay( private fun pickByTimeDecay(
@@ -542,43 +480,7 @@ class HomeService(
} }
} }
return getTranslatedContentList(contentList = result.take(targetSize).shuffled()) return result.take(targetSize).shuffled()
}
/**
* 콘텐츠 리스트의 제목을 현재 언어(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
}
} }
/** /**

View File

@@ -25,6 +25,7 @@ import kr.co.vividnext.sodalive.content.pin.QPinContent.pinContent
import kr.co.vividnext.sodalive.content.playlist.AudioContentPlaylistContent import kr.co.vividnext.sodalive.content.playlist.AudioContentPlaylistContent
import kr.co.vividnext.sodalive.content.playlist.QAudioContentPlaylistContent import kr.co.vividnext.sodalive.content.playlist.QAudioContentPlaylistContent
import kr.co.vividnext.sodalive.content.theme.QAudioContentTheme.audioContentTheme import kr.co.vividnext.sodalive.content.theme.QAudioContentTheme.audioContentTheme
import kr.co.vividnext.sodalive.content.translation.QContentTranslation.contentTranslation
import kr.co.vividnext.sodalive.creator.admin.content.series.QSeries.series import kr.co.vividnext.sodalive.creator.admin.content.series.QSeries.series
import kr.co.vividnext.sodalive.creator.admin.content.series.QSeriesContent.seriesContent import kr.co.vividnext.sodalive.creator.admin.content.series.QSeriesContent.seriesContent
import kr.co.vividnext.sodalive.event.QEvent.event import kr.co.vividnext.sodalive.event.QEvent.event
@@ -188,7 +189,8 @@ interface AudioContentQueryRepository {
isAdult: Boolean, isAdult: Boolean,
orderByRandom: Boolean = false, orderByRandom: Boolean = false,
isPointAvailableOnly: Boolean = false, isPointAvailableOnly: Boolean = false,
excludeContentIds: List<Long> = emptyList() excludeContentIds: List<Long> = emptyList(),
locale: String? = null
): List<AudioContentMainItem> ): List<AudioContentMainItem>
fun findContentByCurationId( fun findContentByCurationId(
@@ -1331,7 +1333,8 @@ class AudioContentQueryRepositoryImpl(
isAdult: Boolean, isAdult: Boolean,
orderByRandom: Boolean, orderByRandom: Boolean,
isPointAvailableOnly: Boolean, isPointAvailableOnly: Boolean,
excludeContentIds: List<Long> excludeContentIds: List<Long>,
locale: String?
): List<AudioContentMainItem> { ): List<AudioContentMainItem> {
val blockMemberCondition = if (memberId != null) { val blockMemberCondition = if (memberId != null) {
blockMember.member.id.eq(member.id) blockMember.member.id.eq(member.id)
@@ -1382,12 +1385,27 @@ class AudioContentQueryRepositoryImpl(
where = where.and(audioContent.id.notIn(excludeContentIds)) where = where.and(audioContent.id.notIn(excludeContentIds))
} }
val titleExpression = if (locale != null) {
val translatedTitle = Expressions.stringTemplate(
"JSON_EXTRACT({0}, '$.title')",
contentTranslation.renderedPayload
)
val coalesceTitle = Expressions.stringTemplate(
"COALESCE(NULLIF({0}, ''), {1})",
translatedTitle,
audioContent.title
)
coalesceTitle
} else {
audioContent.title
}
var select = queryFactory var select = queryFactory
.select( .select(
QAudioContentMainItem( QAudioContentMainItem(
audioContent.id, audioContent.id,
member.id, member.id,
audioContent.title, titleExpression,
audioContent.coverImage.prepend("/").prepend(imageHost), audioContent.coverImage.prepend("/").prepend(imageHost),
member.nickname, member.nickname,
audioContent.isPointAvailable audioContent.isPointAvailable
@@ -1397,6 +1415,11 @@ class AudioContentQueryRepositoryImpl(
.innerJoin(audioContent.member, member) .innerJoin(audioContent.member, member)
.innerJoin(audioContent.theme, audioContentTheme) .innerJoin(audioContent.theme, audioContentTheme)
if (locale != null) {
select = select.leftJoin(contentTranslation)
.on(contentTranslation.contentId.eq(audioContent.id).and(contentTranslation.locale.eq(locale)))
}
if (memberId != null) { if (memberId != null) {
where = where.and(blockMember.id.isNull) where = where.and(blockMember.id.isNull)
select = select.leftJoin(blockMember).on(blockMemberCondition) select = select.leftJoin(blockMember).on(blockMemberCondition)

View File

@@ -1222,7 +1222,7 @@ class AudioContentService(
isPointAvailableOnly = isPointAvailableOnly isPointAvailableOnly = isPointAvailableOnly
) )
val contentList = repository.getLatestContentByTheme( return repository.getLatestContentByTheme(
memberId = memberId, memberId = memberId,
theme = normalizedTheme, theme = normalizedTheme,
contentType = contentType, contentType = contentType,
@@ -1233,26 +1233,9 @@ class AudioContentService(
isAdult = isAdult, isAdult = isAdult,
orderByRandom = orderByRandom, orderByRandom = orderByRandom,
isPointAvailableOnly = isPointAvailableOnly, isPointAvailableOnly = isPointAvailableOnly,
excludeContentIds = excludeContentIds excludeContentIds = excludeContentIds,
locale = langContext.lang.code
) )
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
}
} }
/** /**

View File

@@ -6,6 +6,7 @@ import kr.co.vividnext.sodalive.content.ContentType
import kr.co.vividnext.sodalive.content.QAudioContent.audioContent import kr.co.vividnext.sodalive.content.QAudioContent.audioContent
import kr.co.vividnext.sodalive.content.comment.QAudioContentComment.audioContentComment import kr.co.vividnext.sodalive.content.comment.QAudioContentComment.audioContentComment
import kr.co.vividnext.sodalive.content.like.QAudioContentLike.audioContentLike import kr.co.vividnext.sodalive.content.like.QAudioContentLike.audioContentLike
import kr.co.vividnext.sodalive.content.translation.QContentTranslation.contentTranslation
import kr.co.vividnext.sodalive.member.MemberRole import kr.co.vividnext.sodalive.member.MemberRole
import kr.co.vividnext.sodalive.member.QMember.member import kr.co.vividnext.sodalive.member.QMember.member
import kr.co.vividnext.sodalive.member.auth.QAuth.auth import kr.co.vividnext.sodalive.member.auth.QAuth.auth
@@ -83,7 +84,8 @@ class RecommendChannelQueryRepository(
fun getContentsByCreatorIdLikeDesc( fun getContentsByCreatorIdLikeDesc(
creatorId: Long, creatorId: Long,
memberId: Long?, memberId: Long?,
isAdult: Boolean isAdult: Boolean,
locale: String? = null
): List<RecommendChannelContentItem> { ): List<RecommendChannelContentItem> {
val blockMemberCondition = if (memberId != null) { val blockMemberCondition = if (memberId != null) {
blockMember.member.id.eq(audioContent.member.id) blockMember.member.id.eq(audioContent.member.id)
@@ -99,11 +101,26 @@ class RecommendChannelQueryRepository(
where = where.and(audioContent.isAdult.isFalse) where = where.and(audioContent.isAdult.isFalse)
} }
val titleExpression = if (locale != null) {
val translatedTitle = Expressions.stringTemplate(
"JSON_EXTRACT({0}, '$.title')",
contentTranslation.renderedPayload
)
val coalesceTitle = Expressions.stringTemplate(
"COALESCE(NULLIF({0}, ''), {1})",
translatedTitle,
audioContent.title
)
coalesceTitle
} else {
audioContent.title
}
var select = queryFactory var select = queryFactory
.select( .select(
QRecommendChannelContentItem( QRecommendChannelContentItem(
audioContent.id, audioContent.id,
audioContent.title, titleExpression,
audioContent.coverImage.prepend("/").prepend(imageHost), audioContent.coverImage.prepend("/").prepend(imageHost),
audioContentLike.id.countDistinct(), audioContentLike.id.countDistinct(),
audioContentComment.id.countDistinct() audioContentComment.id.countDistinct()
@@ -121,6 +138,14 @@ class RecommendChannelQueryRepository(
.and(audioContentComment.isActive.isTrue) .and(audioContentComment.isActive.isTrue)
) )
if (locale != null) {
select = select.leftJoin(contentTranslation)
.on(
contentTranslation.contentId.eq(audioContent.id)
.and(contentTranslation.locale.eq(locale))
)
}
if (memberId != null) { if (memberId != null) {
where = where.and(blockMember.id.isNull) where = where.and(blockMember.id.isNull)
select = select.leftJoin(blockMember).on(blockMemberCondition) select = select.leftJoin(blockMember).on(blockMemberCondition)

View File

@@ -1,13 +1,17 @@
package kr.co.vividnext.sodalive.query.recommend package kr.co.vividnext.sodalive.query.recommend
import kr.co.vividnext.sodalive.content.ContentType import kr.co.vividnext.sodalive.content.ContentType
import kr.co.vividnext.sodalive.i18n.LangContext
import org.springframework.cache.annotation.Cacheable import org.springframework.cache.annotation.Cacheable
import org.springframework.stereotype.Service import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional import org.springframework.transaction.annotation.Transactional
@Service @Service
@Transactional(readOnly = true) @Transactional(readOnly = true)
class RecommendChannelQueryService(private val repository: RecommendChannelQueryRepository) { class RecommendChannelQueryService(
private val repository: RecommendChannelQueryRepository,
private val langContext: LangContext
) {
@Cacheable( @Cacheable(
cacheNames = ["default"], cacheNames = ["default"],
key = "'recommendChannel:' + (#memberId ?: 'guest') + ':' + #isAdult + ':' + #contentType" key = "'recommendChannel:' + (#memberId ?: 'guest') + ':' + #isAdult + ':' + #contentType"
@@ -27,7 +31,8 @@ class RecommendChannelQueryService(private val repository: RecommendChannelQuery
it.contentList = repository.getContentsByCreatorIdLikeDesc( it.contentList = repository.getContentsByCreatorIdLikeDesc(
creatorId = it.channelId, creatorId = it.channelId,
memberId = memberId, memberId = memberId,
isAdult = isAdult isAdult = isAdult,
locale = langContext.lang.code
) )
it it

View File

@@ -69,7 +69,8 @@ class RankingRepository(
offset: Long, offset: Long,
limit: Long, limit: Long,
sortType: String, sortType: String,
theme: String = "" theme: String = "",
locale: String? = null
): List<GetAudioContentRankingItem> { ): List<GetAudioContentRankingItem> {
val blockMemberCondition = if (memberId != null) { val blockMemberCondition = if (memberId != null) {
blockMember.member.id.eq(member.id) blockMember.member.id.eq(member.id)
@@ -79,6 +80,8 @@ class RankingRepository(
null null
} }
val contentTranslation = kr.co.vividnext.sodalive.content.translation.QContentTranslation.contentTranslation
var where = audioContent.isActive.isTrue var where = audioContent.isActive.isTrue
.and(audioContent.member.isActive.isTrue) .and(audioContent.member.isActive.isTrue)
.and(audioContent.member.isNotNull) .and(audioContent.member.isNotNull)
@@ -109,11 +112,26 @@ class RankingRepository(
where = where.and(audioContentTheme.theme.eq(theme)) where = where.and(audioContentTheme.theme.eq(theme))
} }
val titleExpression = if (locale != null) {
val translatedTitle = Expressions.stringTemplate(
"JSON_EXTRACT({0}, '$.title')",
contentTranslation.renderedPayload
)
val coalesceTitle = Expressions.stringTemplate(
"COALESCE(NULLIF({0}, ''), {1})",
translatedTitle,
audioContent.title
)
coalesceTitle
} else {
audioContent.title
}
var select = queryFactory var select = queryFactory
.select( .select(
QGetAudioContentRankingItem( QGetAudioContentRankingItem(
audioContent.id, audioContent.id,
audioContent.title, titleExpression,
audioContent.coverImage.prepend("/").prepend(imageHost), audioContent.coverImage.prepend("/").prepend(imageHost),
audioContentTheme.theme, audioContentTheme.theme,
audioContent.price, audioContent.price,
@@ -167,6 +185,11 @@ class RankingRepository(
} }
} }
if (locale != null) {
select = select.leftJoin(contentTranslation)
.on(contentTranslation.contentId.eq(audioContent.id).and(contentTranslation.locale.eq(locale)))
}
if (memberId != null) { if (memberId != null) {
where = where.and(blockMember.id.isNull) where = where.and(blockMember.id.isNull)
select = select.leftJoin(blockMember).on(blockMemberCondition) select = select.leftJoin(blockMember).on(blockMemberCondition)

View File

@@ -9,6 +9,7 @@ import kr.co.vividnext.sodalive.creator.admin.content.series.Series
import kr.co.vividnext.sodalive.creator.admin.content.series.SeriesPublishedDaysOfWeek import kr.co.vividnext.sodalive.creator.admin.content.series.SeriesPublishedDaysOfWeek
import kr.co.vividnext.sodalive.creator.admin.content.series.SeriesState import kr.co.vividnext.sodalive.creator.admin.content.series.SeriesState
import kr.co.vividnext.sodalive.explorer.GetExplorerSectionResponse import kr.co.vividnext.sodalive.explorer.GetExplorerSectionResponse
import kr.co.vividnext.sodalive.i18n.LangContext
import org.springframework.beans.factory.annotation.Value import org.springframework.beans.factory.annotation.Value
import org.springframework.stereotype.Service import org.springframework.stereotype.Service
import java.time.LocalDateTime import java.time.LocalDateTime
@@ -17,6 +18,7 @@ import java.time.LocalDateTime
class RankingService( class RankingService(
private val repository: RankingRepository, private val repository: RankingRepository,
private val seriesContentRepository: ContentSeriesContentRepository, private val seriesContentRepository: ContentSeriesContentRepository,
private val langContext: LangContext,
@Value("\${cloud.aws.cloud-front.host}") @Value("\${cloud.aws.cloud-front.host}")
private val imageHost: String private val imageHost: String
@@ -59,7 +61,8 @@ class RankingService(
offset = offset, offset = offset,
limit = limit, limit = limit,
sortType = sortType, sortType = sortType,
theme = theme theme = theme,
locale = langContext.lang.code
) )
loopCount++ loopCount++
} while (contentList.size < 5 && loopCount < 5) } while (contentList.size < 5 && loopCount < 5)