From 232d97e37e1b97df22dab30fdf126905cf4b7fde Mon Sep 17 00:00:00 2001 From: Klaus Date: Thu, 12 Feb 2026 16:01:53 +0900 Subject: [PATCH 01/15] =?UTF-8?q?=EC=B0=A8=EB=8B=A8=20=EC=82=AC=EC=9A=A9?= =?UTF-8?q?=EC=9E=90=20=EC=A0=9C=EC=99=B8=EB=A5=BC=20=EC=A1=B0=ED=9A=8C=20?= =?UTF-8?q?=EC=BF=BC=EB=A6=AC=EB=A1=9C=20=ED=86=B5=ED=95=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 홈, 추천 채널, 랭킹 조회에서 차단 사용자 제외를 애플리케이션 필터링 대신 DB 쿼리로 처리한다. 콘텐츠/랭킹/추천 조회 API에 memberId 인자를 전달한다. --- .../sodalive/api/home/HomeService.kt | 56 ++++--------------- .../content/AudioContentRepository.kt | 45 ++++++++++----- .../sodalive/content/AudioContentService.kt | 2 + .../RecommendChannelQueryRepository.kt | 47 ++++++++++++++-- .../recommend/RecommendChannelQueryService.kt | 2 + .../sodalive/rank/RankingRepository.kt | 22 +++++++- .../vividnext/sodalive/rank/RankingService.kt | 11 +--- 7 files changed, 110 insertions(+), 75 deletions(-) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/api/home/HomeService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/api/home/HomeService.kt index f0b9714e..76273eb6 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/api/home/HomeService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/api/home/HomeService.kt @@ -22,7 +22,6 @@ 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 -import kr.co.vividnext.sodalive.member.MemberService import kr.co.vividnext.sodalive.query.recommend.RecommendChannelQueryService import kr.co.vividnext.sodalive.rank.ContentRankingSortType import kr.co.vividnext.sodalive.rank.RankingRepository @@ -37,7 +36,6 @@ import java.time.temporal.TemporalAdjusters @Service class HomeService( - private val memberService: MemberService, private val liveRoomService: LiveRoomService, private val auditionService: AuditionService, private val seriesService: ContentSeriesService, @@ -85,14 +83,7 @@ class HomeService( ) val creatorRanking = rankingRepository - .getCreatorRankings() - .filter { - if (memberId != null) { - !memberService.isBlocked(blockedMemberId = memberId, memberId = it.id!!) - } else { - true - } - } + .getCreatorRankings(memberId = memberId) .map { val followerCount = explorerQueryRepository.getNotificationUserIds(it.id!!).size val follow = if (memberId != null) { @@ -111,17 +102,12 @@ class HomeService( ) val latestContentList = contentService.getLatestContentByTheme( + memberId = memberId, theme = latestContentThemeList, contentType = contentType, isFree = false, isAdult = isAdult - ).filter { - if (memberId != null) { - !memberService.isBlocked(blockedMemberId = memberId, memberId = it.creatorId) - } else { - true - } - } + ) val translatedLatestContentList = getTranslatedContentList(contentList = latestContentList) @@ -237,6 +223,7 @@ class HomeService( } val freeContentList = contentService.getLatestContentByTheme( + memberId = memberId, theme = contentThemeService.getActiveThemeOfContent( isAdult = isAdult, isFree = true, @@ -246,31 +233,20 @@ class HomeService( isFree = true, isAdult = isAdult, orderByRandom = true - ).filter { - if (memberId != null) { - !memberService.isBlocked(blockedMemberId = memberId, memberId = it.creatorId) - } else { - true - } - } + ) val translatedFreeContentList = getTranslatedContentList(contentList = freeContentList) // 포인트 사용가능 콘텐츠 리스트 - 랜덤으로 가져오기 (DB에서 isPointAvailable 조건 적용) val pointAvailableContentList = contentService.getLatestContentByTheme( + memberId = memberId, theme = emptyList(), contentType = contentType, isFree = false, isAdult = isAdult, orderByRandom = true, isPointAvailableOnly = true - ).filter { - if (memberId != null) { - !memberService.isBlocked(blockedMemberId = memberId, memberId = it.creatorId) - } else { - true - } - } + ) val translatedPointAvailableContentList = getTranslatedContentList(contentList = pointAvailableContentList) @@ -326,17 +302,12 @@ class HomeService( } val contentList = contentService.getLatestContentByTheme( + memberId = memberId, theme = themeList, contentType = contentType, isFree = false, isAdult = isAdult - ).filter { - if (memberId != null) { - !memberService.isBlocked(blockedMemberId = memberId, memberId = it.creatorId) - } else { - true - } - } + ) return getTranslatedContentList(contentList = contentList) } @@ -428,6 +399,7 @@ class HomeService( while (attempt < RECOMMEND_MAX_ATTEMPTS && result.size < RECOMMEND_TARGET_SIZE) { attempt += 1 val batch = contentService.getLatestContentByTheme( + memberId = memberId, theme = emptyList(), // 특정 테마에 종속되지 않도록 전체에서 랜덤 조회 contentType = contentType, offset = 0, @@ -435,13 +407,7 @@ class HomeService( isFree = false, isAdult = isAdult, orderByRandom = true - ).filter { - if (memberId != null) { - !memberService.isBlocked(blockedMemberId = memberId, memberId = it.creatorId) - } else { - true - } - } + ) for (item in batch) { if (result.size >= RECOMMEND_TARGET_SIZE) break diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/content/AudioContentRepository.kt b/src/main/kotlin/kr/co/vividnext/sodalive/content/AudioContentRepository.kt index d30632e9..23a010b1 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/content/AudioContentRepository.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/content/AudioContentRepository.kt @@ -178,6 +178,7 @@ interface AudioContentQueryRepository { fun findContentIdAndHashTagId(contentId: Long, hashTagId: Int): AudioContentHashTag? fun getLatestContentByTheme( + memberId: Long? = null, theme: List, contentType: ContentType, offset: Long, @@ -1319,6 +1320,7 @@ class AudioContentQueryRepositoryImpl( } override fun getLatestContentByTheme( + memberId: Long?, theme: List, contentType: ContentType, offset: Long, @@ -1329,6 +1331,14 @@ class AudioContentQueryRepositoryImpl( orderByRandom: Boolean, isPointAvailableOnly: Boolean ): List { + val blockMemberCondition = if (memberId != null) { + blockMember.member.id.eq(member.id) + .and(blockMember.isActive.isTrue) + .and(blockMember.blockedMember.id.eq(memberId)) + } else { + null + } + var where = audioContent.isActive.isTrue .and(audioContent.duration.isNotNull) .and( @@ -1366,6 +1376,26 @@ class AudioContentQueryRepositoryImpl( where = where.and(audioContent.isPointAvailable.isTrue) } + var select = queryFactory + .select( + QAudioContentMainItem( + audioContent.id, + member.id, + audioContent.title, + audioContent.coverImage.prepend("/").prepend(imageHost), + member.nickname, + audioContent.isPointAvailable + ) + ) + .from(audioContent) + .innerJoin(audioContent.member, member) + .innerJoin(audioContent.theme, audioContentTheme) + + if (memberId != null) { + where = where.and(blockMember.id.isNull) + select = select.leftJoin(blockMember).on(blockMemberCondition) + } + val orderBy = if (orderByRandom) { Expressions.numberTemplate(Double::class.java, "function('rand')").asc() } else { @@ -1387,20 +1417,7 @@ class AudioContentQueryRepositoryImpl( } } - return queryFactory - .select( - QAudioContentMainItem( - audioContent.id, - member.id, - audioContent.title, - audioContent.coverImage.prepend("/").prepend(imageHost), - member.nickname, - audioContent.isPointAvailable - ) - ) - .from(audioContent) - .innerJoin(audioContent.member, member) - .innerJoin(audioContent.theme, audioContentTheme) + return select .where(where) .offset(offset) .limit(limit) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/content/AudioContentService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/content/AudioContentService.kt index c3777ed5..0a3703cd 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/content/AudioContentService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/content/AudioContentService.kt @@ -1198,6 +1198,7 @@ class AudioContentService( @Transactional(readOnly = true) fun getLatestContentByTheme( + memberId: Long? = null, theme: List, contentType: ContentType, offset: Long = 0, @@ -1221,6 +1222,7 @@ class AudioContentService( ) val contentList = repository.getLatestContentByTheme( + memberId = memberId, theme = normalizedTheme, contentType = contentType, offset = offset, diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/query/recommend/RecommendChannelQueryRepository.kt b/src/main/kotlin/kr/co/vividnext/sodalive/query/recommend/RecommendChannelQueryRepository.kt index 295ed6aa..95208d96 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/query/recommend/RecommendChannelQueryRepository.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/query/recommend/RecommendChannelQueryRepository.kt @@ -9,6 +9,7 @@ import kr.co.vividnext.sodalive.content.like.QAudioContentLike.audioContentLike import kr.co.vividnext.sodalive.member.MemberRole import kr.co.vividnext.sodalive.member.QMember.member import kr.co.vividnext.sodalive.member.auth.QAuth.auth +import kr.co.vividnext.sodalive.member.block.QBlockMember.blockMember import org.springframework.beans.factory.annotation.Value import org.springframework.stereotype.Repository @@ -19,7 +20,19 @@ class RecommendChannelQueryRepository( @Value("\${cloud.aws.cloud-front.host}") private val imageHost: String ) { - fun getRecommendChannelList(isAdult: Boolean, contentType: ContentType): List { + fun getRecommendChannelList( + memberId: Long?, + isAdult: Boolean, + contentType: ContentType + ): List { + val blockMemberCondition = if (memberId != null) { + blockMember.member.id.eq(member.id) + .and(blockMember.isActive.isTrue) + .and(blockMember.blockedMember.id.eq(memberId)) + } else { + null + } + var where = member.role.eq(MemberRole.CREATOR) .and(audioContent.isActive.isTrue) @@ -39,7 +52,7 @@ class RecommendChannelQueryRepository( } } - return queryFactory + var select = queryFactory .select( QRecommendChannelResponse( member.id, @@ -52,6 +65,13 @@ class RecommendChannelQueryRepository( .from(member) .innerJoin(auth).on(auth.member.id.eq(member.id)) .innerJoin(audioContent).on(audioContent.member.id.eq(member.id)) + + if (memberId != null) { + where = where.and(blockMember.id.isNull) + select = select.leftJoin(blockMember).on(blockMemberCondition) + } + + return select .where(where) .groupBy(member.id) .having(audioContent.id.count().goe(3)) @@ -60,14 +80,26 @@ class RecommendChannelQueryRepository( .fetch() } - fun getContentsByCreatorIdLikeDesc(creatorId: Long, isAdult: Boolean): List { + fun getContentsByCreatorIdLikeDesc( + creatorId: Long, + memberId: Long?, + isAdult: Boolean + ): List { + val blockMemberCondition = if (memberId != null) { + blockMember.member.id.eq(audioContent.member.id) + .and(blockMember.isActive.isTrue) + .and(blockMember.blockedMember.id.eq(memberId)) + } else { + null + } + var where = audioContent.member.id.eq(creatorId) if (!isAdult) { where = where.and(audioContent.isAdult.isFalse) } - return queryFactory + var select = queryFactory .select( QRecommendChannelContentItem( audioContent.id, @@ -88,6 +120,13 @@ class RecommendChannelQueryRepository( audioContentComment.audioContent.id.eq(audioContent.id) .and(audioContentComment.isActive.isTrue) ) + + if (memberId != null) { + where = where.and(blockMember.id.isNull) + select = select.leftJoin(blockMember).on(blockMemberCondition) + } + + return select .where(where) .groupBy(audioContent.id) .orderBy(audioContentLike.id.countDistinct().desc()) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/query/recommend/RecommendChannelQueryService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/query/recommend/RecommendChannelQueryService.kt index 2ad1bc86..4888b1ab 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/query/recommend/RecommendChannelQueryService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/query/recommend/RecommendChannelQueryService.kt @@ -18,6 +18,7 @@ class RecommendChannelQueryService(private val repository: RecommendChannelQuery contentType: ContentType ): List { val recommendChannelList = repository.getRecommendChannelList( + memberId = memberId, isAdult = isAdult, contentType = contentType ) @@ -25,6 +26,7 @@ class RecommendChannelQueryService(private val repository: RecommendChannelQuery return recommendChannelList.map { it.contentList = repository.getContentsByCreatorIdLikeDesc( creatorId = it.channelId, + memberId = memberId, isAdult = isAdult ) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/rank/RankingRepository.kt b/src/main/kotlin/kr/co/vividnext/sodalive/rank/RankingRepository.kt index 7e81aa5c..cea4fab0 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/rank/RankingRepository.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/rank/RankingRepository.kt @@ -33,11 +33,29 @@ class RankingRepository( @Value("\${cloud.aws.cloud-front.host}") private val imageHost: String ) { - fun getCreatorRankings(): List { - return queryFactory + fun getCreatorRankings(memberId: Long? = null): List { + val blockMemberCondition = if (memberId != null) { + blockMember.member.id.eq(member.id) + .and(blockMember.isActive.isTrue) + .and(blockMember.blockedMember.id.eq(memberId)) + } else { + null + } + + var select = queryFactory .select(member) .from(creatorRanking) .innerJoin(creatorRanking.member, member) + + if (memberId != null) { + select = select.leftJoin(blockMember).on(blockMemberCondition) + } + + if (memberId != null) { + select = select.where(blockMember.id.isNull) + } + + return select .orderBy(creatorRanking.ranking.asc()) .fetch() } diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/rank/RankingService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/rank/RankingService.kt index 3ae4d09d..ea36e7bc 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/rank/RankingService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/rank/RankingService.kt @@ -9,7 +9,6 @@ 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.SeriesState import kr.co.vividnext.sodalive.explorer.GetExplorerSectionResponse -import kr.co.vividnext.sodalive.member.MemberService import org.springframework.beans.factory.annotation.Value import org.springframework.stereotype.Service import java.time.LocalDateTime @@ -17,7 +16,6 @@ import java.time.LocalDateTime @Service class RankingService( private val repository: RankingRepository, - private val memberService: MemberService, private val seriesContentRepository: ContentSeriesContentRepository, @Value("\${cloud.aws.cloud-front.host}") @@ -25,14 +23,7 @@ class RankingService( ) { fun getCreatorRanking(memberId: Long?, rankingDate: String): GetExplorerSectionResponse { val creatorRankings = repository - .getCreatorRankings() - .filter { - if (memberId != null) { - !memberService.isBlocked(blockedMemberId = memberId, memberId = it.id!!) - } else { - true - } - } + .getCreatorRankings(memberId = memberId) .map { it.toExplorerSectionCreator(imageHost) } return GetExplorerSectionResponse( From 1ca7e1744dc6d4fdef5b3541021c5618ca3886ed Mon Sep 17 00:00:00 2001 From: Klaus Date: Thu, 12 Feb 2026 16:15:58 +0900 Subject: [PATCH 02/15] =?UTF-8?q?=ED=99=88=20=ED=81=AC=EB=A6=AC=EC=97=90?= =?UTF-8?q?=EC=9D=B4=ED=84=B0=20=EB=9E=AD=ED=82=B9=20=ED=8C=94=EB=A1=9C?= =?UTF-8?q?=EC=9A=B0=20=EC=A1=B0=ED=9A=8C=20=EC=B5=9C=EC=A0=81=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 홈 API의 크리에이터 랭킹 응답에서 팔로우 여부를 일괄 조회로 계산한다. --- .../sodalive/api/home/HomeService.kt | 24 ++++++++++--------- .../explorer/ExplorerQueryRepository.kt | 17 +++++++++++++ 2 files changed, 30 insertions(+), 11 deletions(-) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/api/home/HomeService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/api/home/HomeService.kt index 76273eb6..7fad82d6 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/api/home/HomeService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/api/home/HomeService.kt @@ -82,18 +82,20 @@ class HomeService( timezone = timezone ) - val creatorRanking = rankingRepository - .getCreatorRankings(memberId = memberId) - .map { - val followerCount = explorerQueryRepository.getNotificationUserIds(it.id!!).size - val follow = if (memberId != null) { - explorerQueryRepository.isFollow(it.id!!, memberId = memberId) - } else { - false - } + val creatorRankingMembers = rankingRepository.getCreatorRankings(memberId = memberId) + val creatorRankingIds = creatorRankingMembers.mapNotNull { it.id } + val followedCreatorIds = if (memberId != null) { + explorerQueryRepository.getFollowedCreatorIds(creatorRankingIds, memberId) + } else { + emptySet() + } - it.toExplorerSectionCreator(imageHost, follow, followerCount = followerCount) - } + val creatorRanking = creatorRankingMembers.map { creator -> + val creatorId = creator.id!! + val follow = memberId != null && followedCreatorIds.contains(creatorId) + + creator.toExplorerSectionCreator(imageHost, follow) + } val latestContentThemeList = contentThemeService.getActiveThemeOfContent( isAdult = isAdult, diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/explorer/ExplorerQueryRepository.kt b/src/main/kotlin/kr/co/vividnext/sodalive/explorer/ExplorerQueryRepository.kt index cce60c55..c0c1fae6 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/explorer/ExplorerQueryRepository.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/explorer/ExplorerQueryRepository.kt @@ -637,6 +637,23 @@ class ExplorerQueryRepository( .fetchOne() ?: false } + fun getFollowedCreatorIds(creatorIds: List, memberId: Long): Set { + if (creatorIds.isEmpty()) { + return emptySet() + } + + return queryFactory + .select(creatorFollowing.creator.id) + .from(creatorFollowing) + .where( + creatorFollowing.isActive.isTrue + .and(creatorFollowing.creator.id.`in`(creatorIds)) + .and(creatorFollowing.member.id.eq(memberId)) + ) + .fetch() + .toSet() + } + fun getCreatorCheers(cheersId: Long): CreatorCheers? { return queryFactory .selectFrom(creatorCheers) From e690bf8aecd94f1d8f6d26e5c7cdb38e14900f77 Mon Sep 17 00:00:00 2001 From: Klaus Date: Thu, 12 Feb 2026 18:14:08 +0900 Subject: [PATCH 03/15] =?UTF-8?q?=EC=B6=94=EC=B2=9C=20=EC=BD=98=ED=85=90?= =?UTF-8?q?=EC=B8=A0=20=EC=8B=9C=EA=B0=84=20=EA=B0=90=EC=87=A0=20=EC=A0=81?= =?UTF-8?q?=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../sodalive/api/home/HomeService.kt | 125 ++++++++++++++---- .../content/AudioContentRepository.kt | 10 +- .../sodalive/content/AudioContentService.kt | 6 +- 3 files changed, 114 insertions(+), 27 deletions(-) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/api/home/HomeService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/api/home/HomeService.kt index 7fad82d6..a844b37b 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/api/home/HomeService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/api/home/HomeService.kt @@ -7,6 +7,7 @@ import kr.co.vividnext.sodalive.chat.character.translate.AiCharacterTranslationR import kr.co.vividnext.sodalive.content.AudioContentMainItem import kr.co.vividnext.sodalive.content.AudioContentService import kr.co.vividnext.sodalive.content.ContentType +import kr.co.vividnext.sodalive.content.SortType import kr.co.vividnext.sodalive.content.main.GetAudioContentRankingItem import kr.co.vividnext.sodalive.content.main.banner.AudioContentBannerService import kr.co.vividnext.sodalive.content.main.curation.AudioContentCurationService @@ -33,6 +34,7 @@ import java.time.DayOfWeek import java.time.LocalDateTime import java.time.ZoneId import java.time.temporal.TemporalAdjusters +import kotlin.math.pow @Service class HomeService( @@ -62,8 +64,19 @@ class HomeService( companion object { private const val RECOMMEND_TARGET_SIZE = 30 private const val RECOMMEND_MAX_ATTEMPTS = 3 + private const val TIME_DECAY_HALF_LIFE_RANK = 30.0 } + private data class RecommendBucket( + val offset: Long, + val limit: Long + ) + + private data class DecayCandidate( + val item: AudioContentMainItem, + val rank: Int + ) + fun fetchData( timezone: String, isAdultContentVisible: Boolean, @@ -252,6 +265,11 @@ class HomeService( val translatedPointAvailableContentList = getTranslatedContentList(contentList = pointAvailableContentList) + val excludeContentIds = ( + translatedLatestContentList.map { it.contentId } + + translatedContentRanking.map { it.contentId } + ).distinct() + val curationList = curationService.getContentCurationList( tabId = 3L, // 기존에 사용하던 단편 탭의 큐레이션을 사용 isAdult = isAdult, @@ -277,7 +295,8 @@ class HomeService( recommendContentList = getRecommendContentList( isAdultContentVisible = isAdultContentVisible, contentType = contentType, - member = member + member = member, + excludeContentIds = excludeContentIds ), curationList = curationList ) @@ -385,41 +404,101 @@ class HomeService( return dayToSeriesPublishedDaysOfWeek[zonedDateTime.dayOfWeek] ?: SeriesPublishedDaysOfWeek.RANDOM } - // 추천 콘텐츠 조회 로직은 변경 가능성을 고려하여 별도 메서드로 추출한다. fun getRecommendContentList( isAdultContentVisible: Boolean, contentType: ContentType, - member: Member? + member: Member?, + excludeContentIds: List = emptyList() ): List { val memberId = member?.id val isAdult = member?.auth != null && isAdultContentVisible - // Set + List 조합으로 중복 제거 및 순서 보존, 각 시도마다 limit=60으로 조회 - val seen = HashSet(RECOMMEND_TARGET_SIZE * 2) - val result = ArrayList(RECOMMEND_TARGET_SIZE) - var attempt = 0 - while (attempt < RECOMMEND_MAX_ATTEMPTS && result.size < RECOMMEND_TARGET_SIZE) { - attempt += 1 - val batch = contentService.getLatestContentByTheme( - memberId = memberId, - theme = emptyList(), // 특정 테마에 종속되지 않도록 전체에서 랜덤 조회 - contentType = contentType, - offset = 0, - limit = (RECOMMEND_TARGET_SIZE * RECOMMEND_MAX_ATTEMPTS).toLong(), // 60개 조회 - isFree = false, - isAdult = isAdult, - orderByRandom = true - ) + // 3개의 버킷(최근/중간/과거)에서 후보군을 조회한 뒤, 시간감쇠 점수 기반으로 샘플링한다. + val buckets = listOf( + RecommendBucket(offset = 0L, limit = 50L), + RecommendBucket(offset = 50L, limit = 100L), + RecommendBucket(offset = 150L, limit = 150L) + ) - for (item in batch) { + val result = mutableListOf() + val seenIds = excludeContentIds.toMutableSet() + + repeat(RECOMMEND_MAX_ATTEMPTS) { + if (result.size >= RECOMMEND_TARGET_SIZE) return@repeat + + val remaining = RECOMMEND_TARGET_SIZE - result.size + val targetPerBucket = maxOf(1, (remaining + buckets.size - 1) / buckets.size) + + for (bucket in buckets) { if (result.size >= RECOMMEND_TARGET_SIZE) break - if (seen.add(item.contentId)) { - result.add(item) + + val batch = contentService.getLatestContentByTheme( + memberId = memberId, + theme = emptyList(), + contentType = contentType, + offset = bucket.offset, + limit = bucket.limit, + sortType = SortType.NEWEST, + isFree = false, + isAdult = isAdult, + orderByRandom = false, + excludeContentIds = seenIds.toList() + ) + + val selected = pickByTimeDecay( + batch = batch, + targetSize = minOf(targetPerBucket, RECOMMEND_TARGET_SIZE - result.size), + seenIds = seenIds + ) + if (selected.isNotEmpty()) { + result.addAll(selected) } } } - return getTranslatedContentList(contentList = result) + return getTranslatedContentList(contentList = result.take(RECOMMEND_TARGET_SIZE).shuffled()) + } + + private fun pickByTimeDecay( + batch: List, + targetSize: Int, + seenIds: MutableSet + ): List { + if (targetSize <= 0 || batch.isEmpty()) return emptyList() + + val candidates = batch + .asSequence() + .filterNot { seenIds.contains(it.contentId) } + .mapIndexed { index, item -> DecayCandidate(item = item, rank = index) } + .toMutableList() + + if (candidates.isEmpty()) return emptyList() + + val selected = mutableListOf() + while (selected.size < targetSize && candidates.isNotEmpty()) { + val selectedIndex = selectByWeight(candidates) + val chosen = candidates.removeAt(selectedIndex).item + if (seenIds.add(chosen.contentId)) { + selected.add(chosen) + } + } + + return selected + } + + private fun selectByWeight(candidates: List): Int { + val weights = candidates.map { 0.5.pow(it.rank / TIME_DECAY_HALF_LIFE_RANK) } + val totalWeight = weights.sum() + + var randomPoint = Math.random() * totalWeight + for (index in weights.indices) { + randomPoint -= weights[index] + if (randomPoint <= 0) { + return index + } + } + + return candidates.lastIndex } /** diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/content/AudioContentRepository.kt b/src/main/kotlin/kr/co/vividnext/sodalive/content/AudioContentRepository.kt index 23a010b1..47732e3d 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/content/AudioContentRepository.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/content/AudioContentRepository.kt @@ -187,7 +187,8 @@ interface AudioContentQueryRepository { isFree: Boolean, isAdult: Boolean, orderByRandom: Boolean = false, - isPointAvailableOnly: Boolean = false + isPointAvailableOnly: Boolean = false, + excludeContentIds: List = emptyList() ): List fun findContentByCurationId( @@ -1329,7 +1330,8 @@ class AudioContentQueryRepositoryImpl( isFree: Boolean, isAdult: Boolean, orderByRandom: Boolean, - isPointAvailableOnly: Boolean + isPointAvailableOnly: Boolean, + excludeContentIds: List ): List { val blockMemberCondition = if (memberId != null) { blockMember.member.id.eq(member.id) @@ -1376,6 +1378,10 @@ class AudioContentQueryRepositoryImpl( where = where.and(audioContent.isPointAvailable.isTrue) } + if (excludeContentIds.isNotEmpty()) { + where = where.and(audioContent.id.notIn(excludeContentIds)) + } + var select = queryFactory .select( QAudioContentMainItem( diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/content/AudioContentService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/content/AudioContentService.kt index 0a3703cd..00ca2d60 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/content/AudioContentService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/content/AudioContentService.kt @@ -1207,7 +1207,8 @@ class AudioContentService( isFree: Boolean = false, isAdult: Boolean = false, orderByRandom: Boolean = false, - isPointAvailableOnly: Boolean = false + isPointAvailableOnly: Boolean = false, + excludeContentIds: List = emptyList() ): List { /** * - AS-IS theme은 한글만 처리하도록 되어 있음 @@ -1231,7 +1232,8 @@ class AudioContentService( isFree = isFree, isAdult = isAdult, orderByRandom = orderByRandom, - isPointAvailableOnly = isPointAvailableOnly + isPointAvailableOnly = isPointAvailableOnly, + excludeContentIds = excludeContentIds ) val contentIds = contentList.map { it.contentId } From 9d0c8d063ea24dfb67e7093a11b6447ee3121a21 Mon Sep 17 00:00:00 2001 From: Klaus Date: Thu, 12 Feb 2026 19:16:35 +0900 Subject: [PATCH 04/15] =?UTF-8?q?=ED=99=88=20-=20=EB=AC=B4=EB=A3=8C=20?= =?UTF-8?q?=EC=BD=98=ED=85=90=EC=B8=A0,=20=ED=8F=AC=EC=9D=B8=ED=8A=B8=20?= =?UTF-8?q?=EC=82=AC=EC=9A=A9=EA=B0=80=EB=8A=A5=20=EC=BD=98=ED=85=90?= =?UTF-8?q?=EC=B8=A0=20=EB=9E=9C=EB=8D=A4=20=EC=B6=94=EC=B2=9C=20=EB=A1=9C?= =?UTF-8?q?=EC=A7=81=EC=9D=84=20=EC=B6=94=EC=B2=9C=20=EC=BD=98=ED=85=90?= =?UTF-8?q?=EC=B8=A0=EC=99=80=20=EB=8F=99=EC=9D=BC=ED=95=98=EA=B2=8C=20?= =?UTF-8?q?=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../sodalive/api/home/HomeService.kt | 72 ++++++++++++++++--- 1 file changed, 63 insertions(+), 9 deletions(-) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/api/home/HomeService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/api/home/HomeService.kt index a844b37b..929ef50d 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/api/home/HomeService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/api/home/HomeService.kt @@ -237,29 +237,28 @@ class HomeService( recommendChannelList } - val freeContentList = contentService.getLatestContentByTheme( + val freeContentList = getRandomizedContentList( memberId = memberId, + isAdult = isAdult, + contentType = contentType, theme = contentThemeService.getActiveThemeOfContent( isAdult = isAdult, isFree = true, contentType = contentType ), - contentType = contentType, isFree = true, - isAdult = isAdult, - orderByRandom = true + isPointAvailableOnly = false ) val translatedFreeContentList = getTranslatedContentList(contentList = freeContentList) // 포인트 사용가능 콘텐츠 리스트 - 랜덤으로 가져오기 (DB에서 isPointAvailable 조건 적용) - val pointAvailableContentList = contentService.getLatestContentByTheme( + val pointAvailableContentList = getRandomizedContentList( memberId = memberId, - theme = emptyList(), - contentType = contentType, - isFree = false, isAdult = isAdult, - orderByRandom = true, + contentType = contentType, + theme = emptyList(), + isFree = false, isPointAvailableOnly = true ) @@ -501,6 +500,61 @@ class HomeService( return candidates.lastIndex } + private fun getRandomizedContentList( + memberId: Long?, + isAdult: Boolean, + contentType: ContentType, + theme: List, + isFree: Boolean, + isPointAvailableOnly: Boolean, + targetSize: Int = 20 + ): List { + val buckets = listOf( + RecommendBucket(offset = 0L, limit = 50L), + RecommendBucket(offset = 50L, limit = 100L), + RecommendBucket(offset = 150L, limit = 150L) + ) + + val result = mutableListOf() + val seenIds = mutableSetOf() + + repeat(RECOMMEND_MAX_ATTEMPTS) { + if (result.size >= targetSize) return@repeat + + val remaining = targetSize - result.size + val targetPerBucket = maxOf(1, (remaining + buckets.size - 1) / buckets.size) + + for (bucket in buckets) { + if (result.size >= targetSize) break + + val batch = contentService.getLatestContentByTheme( + memberId = memberId, + theme = theme, + contentType = contentType, + offset = bucket.offset, + limit = bucket.limit, + sortType = SortType.NEWEST, + isFree = isFree, + isAdult = isAdult, + orderByRandom = false, + isPointAvailableOnly = isPointAvailableOnly, + excludeContentIds = seenIds.toList() + ) + + val selected = pickByTimeDecay( + batch = batch, + targetSize = minOf(targetPerBucket, targetSize - result.size), + seenIds = seenIds + ) + if (selected.isNotEmpty()) { + result.addAll(selected) + } + } + } + + return getTranslatedContentList(contentList = result.take(targetSize).shuffled()) + } + /** * 콘텐츠 리스트의 제목을 현재 언어(locale)에 맞춰 일괄 번역한다. * From 46b0989795bf3705313eb1ba8c6e7d0e1749884a Mon Sep 17 00:00:00 2001 From: Klaus Date: Thu, 12 Feb 2026 19:28:24 +0900 Subject: [PATCH 05/15] =?UTF-8?q?=ED=99=88=20API=20=EC=9D=91=EB=8B=B5?= =?UTF-8?q?=EC=97=90=EC=84=9C=20=EC=82=AC=EC=9A=A9=ED=95=98=EC=A7=80=20?= =?UTF-8?q?=EC=95=8A=EB=8A=94=20=ED=81=90=EB=A0=88=EC=9D=B4=EC=85=98=20?= =?UTF-8?q?=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../vividnext/sodalive/api/home/GetHomeResponse.kt | 4 +--- .../kr/co/vividnext/sodalive/api/home/HomeService.kt | 12 +----------- 2 files changed, 2 insertions(+), 14 deletions(-) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/api/home/GetHomeResponse.kt b/src/main/kotlin/kr/co/vividnext/sodalive/api/home/GetHomeResponse.kt index 131034e5..aef8611e 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/api/home/GetHomeResponse.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/api/home/GetHomeResponse.kt @@ -5,7 +5,6 @@ import kr.co.vividnext.sodalive.chat.character.dto.Character import kr.co.vividnext.sodalive.content.AudioContentMainItem import kr.co.vividnext.sodalive.content.main.GetAudioContentRankingItem import kr.co.vividnext.sodalive.content.main.banner.GetAudioContentBannerResponse -import kr.co.vividnext.sodalive.content.main.tab.GetContentCurationResponse import kr.co.vividnext.sodalive.content.series.GetSeriesListResponse import kr.co.vividnext.sodalive.event.GetEventResponse import kr.co.vividnext.sodalive.explorer.GetExplorerSectionCreatorResponse @@ -27,6 +26,5 @@ data class GetHomeResponse( val recommendChannelList: List, val freeContentList: List, val pointAvailableContentList: List, - val recommendContentList: List, - val curationList: List + val recommendContentList: List ) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/api/home/HomeService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/api/home/HomeService.kt index 929ef50d..152f6f6f 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/api/home/HomeService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/api/home/HomeService.kt @@ -10,7 +10,6 @@ import kr.co.vividnext.sodalive.content.ContentType import kr.co.vividnext.sodalive.content.SortType import kr.co.vividnext.sodalive.content.main.GetAudioContentRankingItem 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 @@ -43,7 +42,6 @@ class HomeService( private val seriesService: ContentSeriesService, private val contentService: AudioContentService, private val bannerService: AudioContentBannerService, - private val curationService: AudioContentCurationService, private val contentThemeService: AudioContentThemeService, private val recommendChannelService: RecommendChannelQueryService, @@ -269,13 +267,6 @@ class HomeService( translatedContentRanking.map { it.contentId } ).distinct() - val curationList = curationService.getContentCurationList( - tabId = 3L, // 기존에 사용하던 단편 탭의 큐레이션을 사용 - isAdult = isAdult, - contentType = contentType, - memberId = memberId - ) - return GetHomeResponse( liveList = liveList, creatorRanking = creatorRanking, @@ -296,8 +287,7 @@ class HomeService( contentType = contentType, member = member, excludeContentIds = excludeContentIds - ), - curationList = curationList + ) ) } From 341f24c643782b9f2f4b8f7edb0720ccb05e1070 Mon Sep 17 00:00:00 2001 From: Klaus Date: Fri, 13 Feb 2026 10:37:06 +0900 Subject: [PATCH 06/15] =?UTF-8?q?HomeService=20fetchData=20=EB=A6=AC?= =?UTF-8?q?=ED=8C=A9=ED=86=A0=EB=A7=81=20=EB=B0=8F=20DB=20JOIN=20=EA=B8=B0?= =?UTF-8?q?=EB=B0=98=20=EB=B2=88=EC=97=AD=20=EC=A0=81=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit fetchData 함수에서 별도로 수행하던 번역 데이터 조회를 DB JOIN 및 COALESCE를 사용하도록 개선하여 성능을 최적화함. - AudioContentRepository, RankingRepository 등에 locale 파라미터 추가 - DB 레벨에서 번역된 제목을 조회하도록 쿼리 수정 - HomeService에서 불필요한 getTranslatedContentList 호출 제거 --- .../sodalive/api/home/HomeService.kt | 118 ++---------------- .../content/AudioContentRepository.kt | 29 ++++- .../sodalive/content/AudioContentService.kt | 23 +--- .../RecommendChannelQueryRepository.kt | 29 ++++- .../recommend/RecommendChannelQueryService.kt | 9 +- .../sodalive/rank/RankingRepository.kt | 27 +++- .../vividnext/sodalive/rank/RankingService.kt | 5 +- 7 files changed, 102 insertions(+), 138 deletions(-) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/api/home/HomeService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/api/home/HomeService.kt index 152f6f6f..6d44d725 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/api/home/HomeService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/api/home/HomeService.kt @@ -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.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 @@ -50,7 +49,6 @@ class HomeService( private val rankingRepository: RankingRepository, private val explorerQueryRepository: ExplorerQueryRepository, - private val contentTranslationRepository: ContentTranslationRepository, private val aiCharacterTranslationRepository: AiCharacterTranslationRepository, private val seriesTranslationRepository: SeriesTranslationRepository, @@ -122,8 +120,6 @@ class HomeService( isAdult = isAdult ) - val translatedLatestContentList = getTranslatedContentList(contentList = latestContentList) - val eventBannerList = GetEventResponse( totalCount = 0, eventList = emptyList() @@ -177,64 +173,12 @@ 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 = getRandomizedContentList( memberId = memberId, isAdult = isAdult, @@ -248,8 +192,6 @@ class HomeService( isPointAvailableOnly = false ) - val translatedFreeContentList = getTranslatedContentList(contentList = freeContentList) - // 포인트 사용가능 콘텐츠 리스트 - 랜덤으로 가져오기 (DB에서 isPointAvailable 조건 적용) val pointAvailableContentList = getRandomizedContentList( memberId = memberId, @@ -260,28 +202,26 @@ class HomeService( isPointAvailableOnly = true ) - val translatedPointAvailableContentList = getTranslatedContentList(contentList = pointAvailableContentList) - val excludeContentIds = ( - translatedLatestContentList.map { it.contentId } + - translatedContentRanking.map { it.contentId } + latestContentList.map { it.contentId } + + contentRanking.map { it.contentId } ).distinct() return GetHomeResponse( liveList = liveList, creatorRanking = creatorRanking, latestContentThemeList = latestContentThemeList, - latestContentList = translatedLatestContentList, + latestContentList = latestContentList, bannerList = bannerList, eventBannerList = eventBannerList, originalAudioDramaList = translatedOriginalAudioDramaList, auditionList = auditionList, dayOfWeekSeriesList = translatedDayOfWeekSeriesList, popularCharacters = translatedPopularCharacters, - contentRanking = translatedContentRanking, - recommendChannelList = translatedRecommendChannelList, - freeContentList = translatedFreeContentList, - pointAvailableContentList = translatedPointAvailableContentList, + contentRanking = contentRanking, + recommendChannelList = recommendChannelList, + freeContentList = freeContentList, + pointAvailableContentList = pointAvailableContentList, recommendContentList = getRecommendContentList( isAdultContentVisible = isAdultContentVisible, contentType = contentType, @@ -311,15 +251,13 @@ class HomeService( listOf(theme) } - val contentList = contentService.getLatestContentByTheme( + return contentService.getLatestContentByTheme( memberId = memberId, theme = themeList, contentType = contentType, isFree = false, isAdult = isAdult ) - - return getTranslatedContentList(contentList = contentList) } 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( @@ -542,43 +480,7 @@ class HomeService( } } - return getTranslatedContentList(contentList = result.take(targetSize).shuffled()) - } - - /** - * 콘텐츠 리스트의 제목을 현재 언어(locale)에 맞춰 일괄 번역한다. - * - * 처리 절차: - * - 입력된 콘텐츠들의 contentId 집합을 만들고, 요청 언어 코드(langContext.lang.code)로 - * contentTranslationRepository에서 번역 데이터를 한 번에 조회한다. - * - 각 항목에 대해 번역된 제목이 존재하고 비어있지 않으면 title만 번역 값으로 교체한다. - * - 번역이 없거나 공백이면 원본 항목을 그대로 반환한다. - * - * 성능: - * - N건의 항목을 1회의 조회로 해결하기 위해 IN 쿼리를 사용한다. - * - * @param contentList 번역 대상 AudioContentMainItem 목록 - * @return 제목이 가능한 항목은 번역된 목록(불변 사본), 그 외는 원본 항목 유지 - */ - private fun getTranslatedContentList(contentList: List): List { - 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 - } + return result.take(targetSize).shuffled() } /** diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/content/AudioContentRepository.kt b/src/main/kotlin/kr/co/vividnext/sodalive/content/AudioContentRepository.kt index 47732e3d..05bd96ec 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/content/AudioContentRepository.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/content/AudioContentRepository.kt @@ -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.QAudioContentPlaylistContent 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.QSeriesContent.seriesContent import kr.co.vividnext.sodalive.event.QEvent.event @@ -188,7 +189,8 @@ interface AudioContentQueryRepository { isAdult: Boolean, orderByRandom: Boolean = false, isPointAvailableOnly: Boolean = false, - excludeContentIds: List = emptyList() + excludeContentIds: List = emptyList(), + locale: String? = null ): List fun findContentByCurationId( @@ -1331,7 +1333,8 @@ class AudioContentQueryRepositoryImpl( isAdult: Boolean, orderByRandom: Boolean, isPointAvailableOnly: Boolean, - excludeContentIds: List + excludeContentIds: List, + locale: String? ): List { val blockMemberCondition = if (memberId != null) { blockMember.member.id.eq(member.id) @@ -1382,12 +1385,27 @@ class AudioContentQueryRepositoryImpl( 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 .select( QAudioContentMainItem( audioContent.id, member.id, - audioContent.title, + titleExpression, audioContent.coverImage.prepend("/").prepend(imageHost), member.nickname, audioContent.isPointAvailable @@ -1397,6 +1415,11 @@ class AudioContentQueryRepositoryImpl( .innerJoin(audioContent.member, member) .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) { where = where.and(blockMember.id.isNull) select = select.leftJoin(blockMember).on(blockMemberCondition) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/content/AudioContentService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/content/AudioContentService.kt index 00ca2d60..89922a4a 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/content/AudioContentService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/content/AudioContentService.kt @@ -1222,7 +1222,7 @@ class AudioContentService( isPointAvailableOnly = isPointAvailableOnly ) - val contentList = repository.getLatestContentByTheme( + return repository.getLatestContentByTheme( memberId = memberId, theme = normalizedTheme, contentType = contentType, @@ -1233,26 +1233,9 @@ class AudioContentService( isAdult = isAdult, orderByRandom = orderByRandom, 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 - } } /** diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/query/recommend/RecommendChannelQueryRepository.kt b/src/main/kotlin/kr/co/vividnext/sodalive/query/recommend/RecommendChannelQueryRepository.kt index 95208d96..df513800 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/query/recommend/RecommendChannelQueryRepository.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/query/recommend/RecommendChannelQueryRepository.kt @@ -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.comment.QAudioContentComment.audioContentComment 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.QMember.member import kr.co.vividnext.sodalive.member.auth.QAuth.auth @@ -83,7 +84,8 @@ class RecommendChannelQueryRepository( fun getContentsByCreatorIdLikeDesc( creatorId: Long, memberId: Long?, - isAdult: Boolean + isAdult: Boolean, + locale: String? = null ): List { val blockMemberCondition = if (memberId != null) { blockMember.member.id.eq(audioContent.member.id) @@ -99,11 +101,26 @@ class RecommendChannelQueryRepository( 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 .select( QRecommendChannelContentItem( audioContent.id, - audioContent.title, + titleExpression, audioContent.coverImage.prepend("/").prepend(imageHost), audioContentLike.id.countDistinct(), audioContentComment.id.countDistinct() @@ -121,6 +138,14 @@ class RecommendChannelQueryRepository( .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) { where = where.and(blockMember.id.isNull) select = select.leftJoin(blockMember).on(blockMemberCondition) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/query/recommend/RecommendChannelQueryService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/query/recommend/RecommendChannelQueryService.kt index 4888b1ab..727509ad 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/query/recommend/RecommendChannelQueryService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/query/recommend/RecommendChannelQueryService.kt @@ -1,13 +1,17 @@ package kr.co.vividnext.sodalive.query.recommend import kr.co.vividnext.sodalive.content.ContentType +import kr.co.vividnext.sodalive.i18n.LangContext import org.springframework.cache.annotation.Cacheable import org.springframework.stereotype.Service import org.springframework.transaction.annotation.Transactional @Service @Transactional(readOnly = true) -class RecommendChannelQueryService(private val repository: RecommendChannelQueryRepository) { +class RecommendChannelQueryService( + private val repository: RecommendChannelQueryRepository, + private val langContext: LangContext +) { @Cacheable( cacheNames = ["default"], key = "'recommendChannel:' + (#memberId ?: 'guest') + ':' + #isAdult + ':' + #contentType" @@ -27,7 +31,8 @@ class RecommendChannelQueryService(private val repository: RecommendChannelQuery it.contentList = repository.getContentsByCreatorIdLikeDesc( creatorId = it.channelId, memberId = memberId, - isAdult = isAdult + isAdult = isAdult, + locale = langContext.lang.code ) it diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/rank/RankingRepository.kt b/src/main/kotlin/kr/co/vividnext/sodalive/rank/RankingRepository.kt index cea4fab0..d16c0f39 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/rank/RankingRepository.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/rank/RankingRepository.kt @@ -69,7 +69,8 @@ class RankingRepository( offset: Long, limit: Long, sortType: String, - theme: String = "" + theme: String = "", + locale: String? = null ): List { val blockMemberCondition = if (memberId != null) { blockMember.member.id.eq(member.id) @@ -79,6 +80,8 @@ class RankingRepository( null } + val contentTranslation = kr.co.vividnext.sodalive.content.translation.QContentTranslation.contentTranslation + var where = audioContent.isActive.isTrue .and(audioContent.member.isActive.isTrue) .and(audioContent.member.isNotNull) @@ -109,11 +112,26 @@ class RankingRepository( 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 .select( QGetAudioContentRankingItem( audioContent.id, - audioContent.title, + titleExpression, audioContent.coverImage.prepend("/").prepend(imageHost), audioContentTheme.theme, 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) { where = where.and(blockMember.id.isNull) select = select.leftJoin(blockMember).on(blockMemberCondition) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/rank/RankingService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/rank/RankingService.kt index ea36e7bc..49d59bfb 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/rank/RankingService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/rank/RankingService.kt @@ -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.SeriesState import kr.co.vividnext.sodalive.explorer.GetExplorerSectionResponse +import kr.co.vividnext.sodalive.i18n.LangContext import org.springframework.beans.factory.annotation.Value import org.springframework.stereotype.Service import java.time.LocalDateTime @@ -17,6 +18,7 @@ import java.time.LocalDateTime class RankingService( private val repository: RankingRepository, private val seriesContentRepository: ContentSeriesContentRepository, + private val langContext: LangContext, @Value("\${cloud.aws.cloud-front.host}") private val imageHost: String @@ -59,7 +61,8 @@ class RankingService( offset = offset, limit = limit, sortType = sortType, - theme = theme + theme = theme, + locale = langContext.lang.code ) loopCount++ } while (contentList.size < 5 && loopCount < 5) From ac0def6187a3914cbe24128ee50aa1833baece94 Mon Sep 17 00:00:00 2001 From: Klaus Date: Fri, 13 Feb 2026 12:10:13 +0900 Subject: [PATCH 07/15] =?UTF-8?q?OriginalAudioDrama=20=EB=A6=AC=EC=8A=A4?= =?UTF-8?q?=ED=8A=B8=20=EC=A1=B0=ED=9A=8C=20=EC=BF=BC=EB=A6=AC=20=EC=B5=9C?= =?UTF-8?q?=EC=A0=81=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit OriginalAudioDrama 리스트 조회 시 엔티티 대신 DTO를 직접 조회하도록 개선 콘텐츠 개수, 신규 콘텐츠 여부, 번역 제목을 서브쿼리와 조인을 통해 한 번에 가져오도록 하여 기존의 N+1 문제와 다수의 추가 쿼리 발생을 해결 --- .../sodalive/api/home/HomeService.kt | 8 +- .../content/series/ContentSeriesRepository.kt | 97 ++++++++++++++++--- .../content/series/ContentSeriesService.kt | 15 ++- .../content/series/GetSeriesListResponse.kt | 10 +- 4 files changed, 104 insertions(+), 26 deletions(-) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/api/home/HomeService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/api/home/HomeService.kt index 6d44d725..6b038a0c 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/api/home/HomeService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/api/home/HomeService.kt @@ -131,15 +131,11 @@ class HomeService( isAdult = isAdult ) - // 오직 보이스온에서만 val originalAudioDramaList = seriesService.getOriginalAudioDramaList( isAdult = isAdult, - contentType = contentType, - orderByRandom = true + contentType = contentType ) - val translatedOriginalAudioDramaList = getTranslatedSeriesList(seriesList = originalAudioDramaList) - val auditionList = auditionService.getInProgressAuditionList(isAdult = isAdult) // 요일별 시리즈 @@ -214,7 +210,7 @@ class HomeService( latestContentList = latestContentList, bannerList = bannerList, eventBannerList = eventBannerList, - originalAudioDramaList = translatedOriginalAudioDramaList, + originalAudioDramaList = originalAudioDramaList, auditionList = auditionList, dayOfWeekSeriesList = translatedDayOfWeekSeriesList, popularCharacters = translatedPopularCharacters, diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/content/series/ContentSeriesRepository.kt b/src/main/kotlin/kr/co/vividnext/sodalive/content/series/ContentSeriesRepository.kt index a1e682ac..8448f20f 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/content/series/ContentSeriesRepository.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/content/series/ContentSeriesRepository.kt @@ -10,6 +10,7 @@ import kr.co.vividnext.sodalive.content.main.curation.QAudioContentCuration.audi import kr.co.vividnext.sodalive.content.main.curation.QAudioContentCurationItem.audioContentCurationItem import kr.co.vividnext.sodalive.content.series.content.GetSeriesContentMinMaxPriceResponse import kr.co.vividnext.sodalive.content.series.content.QGetSeriesContentMinMaxPriceResponse +import kr.co.vividnext.sodalive.content.series.translation.QSeriesTranslation.seriesTranslation 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.Series @@ -20,6 +21,7 @@ import kr.co.vividnext.sodalive.member.MemberRole import kr.co.vividnext.sodalive.member.QMember.member import kr.co.vividnext.sodalive.member.block.QBlockMember.blockMember import org.springframework.data.jpa.repository.JpaRepository +import java.time.LocalDateTime interface ContentSeriesRepository : JpaRepository, ContentSeriesQueryRepository @@ -64,12 +66,13 @@ interface ContentSeriesQueryRepository { fun getSeriesContentMinMaxPrice(seriesId: Long): GetSeriesContentMinMaxPriceResponse fun getRecommendSeriesList(isAuth: Boolean, contentType: ContentType, limit: Long): List fun getOriginalAudioDramaList( + imageHost: String, isAdult: Boolean, contentType: ContentType, - orderByRandom: Boolean = false, + locale: String, offset: Long = 0, limit: Long = 20 - ): List + ): List fun getOriginalAudioDramaTotalCount(isAdult: Boolean, contentType: ContentType): Int fun getGenreList(isAdult: Boolean, memberId: Long, contentType: ContentType): List @@ -365,12 +368,13 @@ class ContentSeriesQueryRepositoryImpl( } override fun getOriginalAudioDramaList( + imageHost: String, isAdult: Boolean, contentType: ContentType, - orderByRandom: Boolean, + locale: String, offset: Long, limit: Long - ): List { + ): List { var where = series.isOriginal.isTrue .and(series.isActive.isTrue) @@ -392,20 +396,85 @@ class ContentSeriesQueryRepositoryImpl( } } - return queryFactory - .selectFrom(series) - .innerJoin(series.member, member) - .where(where) - .orderBy( - if (orderByRandom) { - Expressions.numberTemplate(Double::class.java, "function('rand')").asc() - } else { - series.id.desc() - } + val now = LocalDateTime.now() + val sevenDaysAgo = now.minusDays(7) + + val contentCountSubquery = queryFactory + .select(seriesContent.id.count()) + .from(seriesContent) + .innerJoin(seriesContent.content, audioContent) + .where( + seriesContent.series.id.eq(series.id), + audioContent.isActive.isTrue, + if (!isAdult) audioContent.isAdult.isFalse else null ) + + val isNewSubquery = queryFactory + .select(seriesContent.id) + .from(seriesContent) + .innerJoin(seriesContent.content, audioContent) + .where( + seriesContent.series.id.eq(series.id), + audioContent.isActive.isTrue, + if (!isAdult) audioContent.isAdult.isFalse else null, + audioContent.releaseDate.between(sevenDaysAgo, now) + ) + .limit(1) + + val results = queryFactory + .select( + series.id, + series.title, + seriesTranslation.renderedPayload, + series.coverImage, + series.state, + member.id, + member.nickname, + member.profileImage, + contentCountSubquery, + isNewSubquery.exists(), + series + ) + .from(series) + .innerJoin(series.member, member) + .leftJoin(seriesTranslation).on(series.id.eq(seriesTranslation.seriesId), seriesTranslation.locale.eq(locale)) + .where(where) + .having(contentCountSubquery.gt(0)) + .orderBy(series.id.desc()) .offset(offset) .limit(limit) .fetch() + + return results.map { row -> + val seriesId = row.get(series.id)!! + val originTitle = row.get(series.title)!! + val payload = row.get(seriesTranslation.renderedPayload) + val translatedTitle = payload?.title + val coverImage = row.get(series.coverImage) + val state = row.get(series.state) + val creatorId = row.get(member.id)!! + val nickname = row.get(member.nickname)!! + val profileImage = row.get(member.profileImage) + val numberOfContent = row.get(8, Long::class.java) ?: 0L + val isNew = row.get(9, Boolean::class.java) ?: false + val seriesEntity = row.get(series)!! + + GetSeriesListResponse.SeriesListItem( + seriesId = seriesId, + title = if (translatedTitle.isNullOrBlank()) originTitle else translatedTitle, + coverImage = "$imageHost/$coverImage", + publishedDaysOfWeek = "", // Service layer will fill this + isComplete = state == SeriesState.COMPLETE, + creator = GetSeriesListResponse.SeriesListItemCreator( + creatorId = creatorId, + nickname = nickname, + profileImage = "$imageHost/$profileImage" + ), + numberOfContent = numberOfContent.toInt(), + isNew = isNew, + rawPublishedDaysOfWeek = seriesEntity.publishedDaysOfWeek + ) + } } override fun getOriginalAudioDramaTotalCount(isAdult: Boolean, contentType: ContentType): Int { diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/content/series/ContentSeriesService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/content/series/ContentSeriesService.kt index bd7ced51..839eeffb 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/content/series/ContentSeriesService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/content/series/ContentSeriesService.kt @@ -55,12 +55,21 @@ class ContentSeriesService( fun getOriginalAudioDramaList( isAdult: Boolean, contentType: ContentType, - orderByRandom: Boolean = false, offset: Long = 0, limit: Long = 20 ): List { - val originalAudioDramaList = repository.getOriginalAudioDramaList(isAdult, contentType, orderByRandom, offset, limit) - return getTranslatedSeriesList(seriesToSeriesListItem(originalAudioDramaList, isAdult, contentType)) + val originalAudioDramaList = repository.getOriginalAudioDramaList( + imageHost = coverImageHost, + isAdult = isAdult, + contentType = contentType, + locale = langContext.lang.code, + offset = offset, + limit = limit + ) + + return originalAudioDramaList.map { item -> + item.copy(publishedDaysOfWeek = publishedDaysOfWeekText(item.rawPublishedDaysOfWeek)) + } } fun getGenreList(memberId: Long, isAdult: Boolean, contentType: ContentType): List { diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/content/series/GetSeriesListResponse.kt b/src/main/kotlin/kr/co/vividnext/sodalive/content/series/GetSeriesListResponse.kt index f83b40c4..4df1264e 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/content/series/GetSeriesListResponse.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/content/series/GetSeriesListResponse.kt @@ -1,10 +1,13 @@ package kr.co.vividnext.sodalive.content.series +import com.querydsl.core.annotations.QueryProjection +import kr.co.vividnext.sodalive.creator.admin.content.series.SeriesPublishedDaysOfWeek + data class GetSeriesListResponse( val totalCount: Int, val items: List ) { - data class SeriesListItem( + data class SeriesListItem @QueryProjection constructor( val seriesId: Long, val title: String, val coverImage: String, @@ -13,10 +16,11 @@ data class GetSeriesListResponse( val creator: SeriesListItemCreator, var numberOfContent: Int = 0, var isNew: Boolean = false, - var isPopular: Boolean = false + var isPopular: Boolean = false, + val rawPublishedDaysOfWeek: Set = emptySet() ) - data class SeriesListItemCreator( + data class SeriesListItemCreator @QueryProjection constructor( val creatorId: Long, val nickname: String, val profileImage: String From 01a1a05d770a126d9e3b508c4954cf7e74188044 Mon Sep 17 00:00:00 2001 From: Klaus Date: Fri, 13 Feb 2026 15:13:13 +0900 Subject: [PATCH 08/15] =?UTF-8?q?=EC=8B=9C=EB=A6=AC=EC=A6=88=20=EB=AA=A9?= =?UTF-8?q?=EB=A1=9D=20=EC=A1=B0=ED=9A=8C=20=EC=BF=BC=EB=A6=AC=20=EC=B5=9C?= =?UTF-8?q?=EC=A0=81=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../sodalive/api/home/HomeService.kt | 47 +- .../content/series/ContentSeriesRepository.kt | 707 +++++++++++++++--- .../content/series/ContentSeriesService.kt | 168 ++--- 3 files changed, 634 insertions(+), 288 deletions(-) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/api/home/HomeService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/api/home/HomeService.kt index 6b038a0c..10ae9663 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/api/home/HomeService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/api/home/HomeService.kt @@ -12,7 +12,6 @@ import kr.co.vividnext.sodalive.content.main.GetAudioContentRankingItem import kr.co.vividnext.sodalive.content.main.banner.AudioContentBannerService 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.creator.admin.content.series.SeriesPublishedDaysOfWeek import kr.co.vividnext.sodalive.event.GetEventResponse @@ -50,7 +49,6 @@ class HomeService( private val explorerQueryRepository: ExplorerQueryRepository, private val aiCharacterTranslationRepository: AiCharacterTranslationRepository, - private val seriesTranslationRepository: SeriesTranslationRepository, private val langContext: LangContext, @@ -139,13 +137,12 @@ class HomeService( val auditionList = auditionService.getInProgressAuditionList(isAdult = isAdult) // 요일별 시리즈 - val dayOfWeekSeriesList = seriesService.getDayOfWeekSeriesList( + val translatedDayOfWeekSeriesList = seriesService.getDayOfWeekSeriesList( memberId = memberId, isAdult = isAdult, contentType = contentType, dayOfWeek = getDayOfWeekByTimezone(timezone) ) - val translatedDayOfWeekSeriesList = getTranslatedSeriesList(seriesList = dayOfWeekSeriesList) // 인기 캐릭터 조회 val translatedPopularCharacters = getTranslatedAiCharacterList(aiCharacterList = characterService.getPopularCharacters()) @@ -265,14 +262,12 @@ class HomeService( val memberId = member?.id val isAdult = member?.auth != null && isAdultContentVisible - val dayOfWeekSeriesList = seriesService.getDayOfWeekSeriesList( + return seriesService.getDayOfWeekSeriesList( memberId = memberId, isAdult = isAdult, contentType = contentType, dayOfWeek = dayOfWeek ) - - return getTranslatedSeriesList(seriesList = dayOfWeekSeriesList) } fun getContentRankingBySort( @@ -479,44 +474,6 @@ class HomeService( return result.take(targetSize).shuffled() } - /** - * 시리즈 리스트의 제목을 현재 언어(locale)에 맞춰 일괄 번역한다. - * - * 처리 절차: - * - 입력된 시리즈들의 seriesId 집합을 만들고, 요청 언어 코드(langContext.lang.code)로 - * seriesTranslationRepository에서 번역 데이터를 한 번에 조회한다. - * - 각 항목에 대해 번역된 제목이 존재하고 비어있지 않으면 title만 번역 값으로 교체한다. - * - 번역이 없거나 공백이면 원본 항목을 그대로 반환한다. - * - * 성능: - * - N건의 항목을 1회의 조회로 해결하기 위해 IN 쿼리를 사용한다. - * - * @param seriesList 번역 대상 SeriesListItem 목록 - * @return 제목이 가능한 항목은 번역된 목록(불변 사본), 그 외는 원본 항목 유지 - */ - private fun getTranslatedSeriesList( - seriesList: List - ): List { - 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)에 맞춰 일괄 번역한다. * diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/content/series/ContentSeriesRepository.kt b/src/main/kotlin/kr/co/vividnext/sodalive/content/series/ContentSeriesRepository.kt index 8448f20f..cb5c6c77 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/content/series/ContentSeriesRepository.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/content/series/ContentSeriesRepository.kt @@ -31,10 +31,11 @@ interface ContentSeriesQueryRepository { isAuth: Boolean, contentType: ContentType, isOriginal: Boolean, - isCompleted: Boolean + isCompleted: Boolean, + memberId: Long? = null ): Int - fun getSeriesList( + fun getSeriesListV2( imageHost: String, creatorId: Long?, isAuth: Boolean, @@ -43,28 +44,41 @@ interface ContentSeriesQueryRepository { isCompleted: Boolean, orderByRandom: Boolean, offset: Long, - limit: Long - ): List + limit: Long, + locale: String, + memberId: Long? = null + ): List fun getSeriesByGenreTotalCount( genreId: Long, isAuth: Boolean, - contentType: ContentType + contentType: ContentType, + memberId: Long? = null ): Int - fun getSeriesByGenreList( + fun getSeriesByGenreListV2( imageHost: String, genreId: Long, isAuth: Boolean, contentType: ContentType, offset: Long, - limit: Long - ): List + limit: Long, + locale: String, + memberId: Long? = null + ): List fun getSeriesDetail(seriesId: Long, isAuth: Boolean, contentType: ContentType): Series? fun getKeywordList(seriesId: Long): List fun getSeriesContentMinMaxPrice(seriesId: Long): GetSeriesContentMinMaxPriceResponse - fun getRecommendSeriesList(isAuth: Boolean, contentType: ContentType, limit: Long): List + fun getRecommendSeriesListV2( + imageHost: String, + isAuth: Boolean, + contentType: ContentType, + limit: Long, + locale: String, + memberId: Long? = null + ): List + fun getOriginalAudioDramaList( imageHost: String, isAdult: Boolean, @@ -76,27 +90,57 @@ interface ContentSeriesQueryRepository { fun getOriginalAudioDramaTotalCount(isAdult: Boolean, contentType: ContentType): Int fun getGenreList(isAdult: Boolean, memberId: Long, contentType: ContentType): List - fun findByCurationId(curationId: Long, memberId: Long, isAdult: Boolean, contentType: ContentType): List - fun getDayOfWeekSeriesList( + fun findByCurationIdV2( + imageHost: String, + curationId: Long, + memberId: Long, + isAdult: Boolean, + contentType: ContentType, + locale: String + ): List + + fun getDayOfWeekSeriesListV2( + imageHost: String, dayOfWeek: SeriesPublishedDaysOfWeek, contentType: ContentType, isAdult: Boolean, offset: Long, - limit: Long - ): List + limit: Long, + locale: String, + memberId: Long? = null + ): List } class ContentSeriesQueryRepositoryImpl( private val queryFactory: JPAQueryFactory ) : ContentSeriesQueryRepository { + private fun parsePublishedDaysOfWeek(raw: String?): Set { + if (raw.isNullOrBlank()) { + return emptySet() + } + + return raw.split(",") + .mapNotNull { value -> + val trimmed = value.trim() + if (trimmed.isEmpty()) { + null + } else { + runCatching { SeriesPublishedDaysOfWeek.valueOf(trimmed) }.getOrNull() + } + } + .toSet() + } + override fun getSeriesTotalCount( creatorId: Long?, isAuth: Boolean, contentType: ContentType, isOriginal: Boolean, - isCompleted: Boolean + isCompleted: Boolean, + memberId: Long? ): Int { var where = series.isActive.isTrue + .and(audioContent.isActive.isTrue) if (creatorId != null) { where = where.and(series.member.id.eq(creatorId)) @@ -111,7 +155,9 @@ class ContentSeriesQueryRepositoryImpl( } if (!isAuth) { - where = where.and(series.isAdult.isFalse) + where = where + .and(series.isAdult.isFalse) + .and(audioContent.isAdult.isFalse) } else { if (contentType != ContentType.ALL) { where = where.and( @@ -128,16 +174,31 @@ class ContentSeriesQueryRepositoryImpl( } } - return queryFactory - .select(series.id) - .from(series) - .innerJoin(series.member, member) - .where(where) - .fetch() - .size + if (memberId != null) { + val blockedSubquery = queryFactory + .select(blockMember.id) + .from(blockMember) + .where( + blockMember.member.id.eq(series.member.id), + blockMember.blockedMember.id.eq(memberId), + blockMember.isActive.isTrue + ) + where = where.and(blockedSubquery.exists().not()) + } + + return ( + queryFactory + .select(series.id.countDistinct()) + .from(series) + .innerJoin(series.member, member) + .innerJoin(seriesContent).on(series.id.eq(seriesContent.series.id)) + .innerJoin(seriesContent.content, audioContent) + .where(where) + .fetchOne() ?: 0L + ).toInt() } - override fun getSeriesList( + override fun getSeriesListV2( imageHost: String, creatorId: Long?, isAuth: Boolean, @@ -146,9 +207,12 @@ class ContentSeriesQueryRepositoryImpl( isCompleted: Boolean, orderByRandom: Boolean, offset: Long, - limit: Long - ): List { + limit: Long, + locale: String, + memberId: Long? + ): List { var where = series.isActive.isTrue + .and(audioContent.isActive.isTrue) if (creatorId != null) { where = where.and(series.member.id.eq(creatorId)) @@ -162,54 +226,134 @@ class ContentSeriesQueryRepositoryImpl( } if (!isAuth) { - where = where.and(series.isAdult.isFalse) + where = where + .and(series.isAdult.isFalse) + .and(audioContent.isAdult.isFalse) } else { if (contentType != ContentType.ALL) { where = where.and( series.member.isNull.or( series.member.auth.gender.eq( - if (contentType == ContentType.MALE) { - 0 - } else { - 1 - } + if (contentType == ContentType.MALE) 0 else 1 ) ) ) } } + // 차단 필터 + if (memberId != null) { + val blockedSubquery = queryFactory + .select(blockMember.id) + .from(blockMember) + .where( + blockMember.member.id.eq(series.member.id), + blockMember.blockedMember.id.eq(memberId), + blockMember.isActive.isTrue + ) + where = where.and(blockedSubquery.exists().not()) + } + + val latestReleaseDate = audioContent.releaseDate.max() + val orderBy = if (orderByRandom) { listOf(Expressions.numberTemplate(Double::class.java, "function('rand')").asc()) } else if (creatorId != null) { listOf(series.orders.asc(), series.createdAt.asc()) } else { - listOf(audioContent.releaseDate.max().desc(), series.createdAt.asc()) + listOf(latestReleaseDate.desc(), series.createdAt.asc()) } - return queryFactory - .selectFrom(series) + val now = LocalDateTime.now() + val sevenDaysAgo = now.minusDays(7) + + val contentCount = seriesContent.id.countDistinct() + val isNewCase = Expressions.numberTemplate( + Int::class.java, + "case when {0} then 1 else 0 end", + audioContent.releaseDate.between(sevenDaysAgo, now) + ) + val isNewFlag = isNewCase.max() + val seriesPublishedDay = Expressions.enumPath(SeriesPublishedDaysOfWeek::class.java, "seriesPublishedDay") + val publishedDaysConcat = Expressions.stringTemplate( + "group_concat(distinct {0} order by {0} separator ',')", + seriesPublishedDay + ) + + val results = queryFactory + .select( + series.id, + series.title, + seriesTranslation.renderedPayload, + series.coverImage, + series.state, + member.id, + member.nickname, + member.profileImage, + contentCount, + isNewFlag, + publishedDaysConcat + ) + .from(series) .innerJoin(series.member, member) .innerJoin(seriesContent).on(series.id.eq(seriesContent.series.id)) .innerJoin(seriesContent.content, audioContent) + .leftJoin(series.publishedDaysOfWeek, seriesPublishedDay) + .leftJoin(seriesTranslation).on(series.id.eq(seriesTranslation.seriesId), seriesTranslation.locale.eq(locale)) .where(where) .groupBy(series.id) + .having(contentCount.gt(0)) .orderBy(*orderBy.toTypedArray()) .offset(offset) .limit(limit) .fetch() + + return results.map { row -> + val sId = row.get(series.id)!! + val originTitle = row.get(series.title)!! + val payload = row.get(seriesTranslation.renderedPayload) + val translatedTitle = payload?.title + val coverImg = row.get(series.coverImage) + val state = row.get(series.state) + val cId = row.get(member.id)!! + val nick = row.get(member.nickname)!! + val profImg = row.get(member.profileImage) + val nContent = row.get(contentCount) ?: 0L + val isN = (row.get(isNewFlag) ?: 0) > 0 + val rawDays = parsePublishedDaysOfWeek(row.get(publishedDaysConcat)) + + GetSeriesListResponse.SeriesListItem( + seriesId = sId, + title = if (translatedTitle.isNullOrBlank()) originTitle else translatedTitle, + coverImage = "$imageHost/$coverImg", + publishedDaysOfWeek = "", // Service layer will fill this + isComplete = state == SeriesState.COMPLETE, + creator = GetSeriesListResponse.SeriesListItemCreator( + creatorId = cId, + nickname = nick, + profileImage = "$imageHost/$profImg" + ), + numberOfContent = nContent.toInt(), + isNew = isN, + rawPublishedDaysOfWeek = rawDays + ) + } } override fun getSeriesByGenreTotalCount( genreId: Long, isAuth: Boolean, - contentType: ContentType + contentType: ContentType, + memberId: Long? ): Int { var where = series.isActive.isTrue .and(series.genre.id.eq(genreId)) + .and(audioContent.isActive.isTrue) if (!isAuth) { - where = where.and(series.isAdult.isFalse) + where = where + .and(series.isAdult.isFalse) + .and(audioContent.isAdult.isFalse) } else { if (contentType != ContentType.ALL) { where = where.and( @@ -226,29 +370,44 @@ class ContentSeriesQueryRepositoryImpl( } } - return queryFactory - .select(series.id) - .from(seriesContent) - .innerJoin(seriesContent.series, series) - .innerJoin(seriesContent.content, audioContent) - .innerJoin(series.member, member) - .innerJoin(series.genre, seriesGenre) - .where(where) - .groupBy(series.id) - .fetch() - .size + if (memberId != null) { + val blockedSubquery = queryFactory + .select(blockMember.id) + .from(blockMember) + .where( + blockMember.member.id.eq(series.member.id), + blockMember.blockedMember.id.eq(memberId), + blockMember.isActive.isTrue + ) + where = where.and(blockedSubquery.exists().not()) + } + + return ( + queryFactory + .select(series.id.countDistinct()) + .from(seriesContent) + .innerJoin(seriesContent.series, series) + .innerJoin(seriesContent.content, audioContent) + .innerJoin(series.member, member) + .innerJoin(series.genre, seriesGenre) + .where(where) + .fetchOne() ?: 0L + ).toInt() } - override fun getSeriesByGenreList( + override fun getSeriesByGenreListV2( imageHost: String, genreId: Long, isAuth: Boolean, contentType: ContentType, offset: Long, - limit: Long - ): List { + limit: Long, + locale: String, + memberId: Long? + ): List { var where = series.isActive.isTrue .and(series.genre.id.eq(genreId)) + .and(audioContent.isActive.isTrue) if (!isAuth) { where = where.and(series.isAdult.isFalse) @@ -257,30 +416,106 @@ class ContentSeriesQueryRepositoryImpl( where = where.and( series.member.isNull.or( series.member.auth.gender.eq( - if (contentType == ContentType.MALE) { - 0 - } else { - 1 - } + if (contentType == ContentType.MALE) 0 else 1 ) ) ) } } - return queryFactory - .select(series) + if (!isAuth) { + where = where.and(audioContent.isAdult.isFalse) + } + + // 차단 필터 + if (memberId != null) { + val blockedSubquery = queryFactory + .select(blockMember.id) + .from(blockMember) + .where( + blockMember.member.id.eq(series.member.id), + blockMember.blockedMember.id.eq(memberId), + blockMember.isActive.isTrue + ) + where = where.and(blockedSubquery.exists().not()) + } + + val now = LocalDateTime.now() + val sevenDaysAgo = now.minusDays(7) + + val contentCount = seriesContent.id.countDistinct() + val isNewCase = Expressions.numberTemplate( + Int::class.java, + "case when {0} then 1 else 0 end", + audioContent.releaseDate.between(sevenDaysAgo, now) + ) + val isNewFlag = isNewCase.max() + val seriesPublishedDay = Expressions.enumPath(SeriesPublishedDaysOfWeek::class.java, "seriesPublishedDay") + val publishedDaysConcat = Expressions.stringTemplate( + "group_concat(distinct {0} order by {0} separator ',')", + seriesPublishedDay + ) + val latestReleaseDate = audioContent.releaseDate.max() + + val results = queryFactory + .select( + series.id, + series.title, + seriesTranslation.renderedPayload, + series.coverImage, + series.state, + member.id, + member.nickname, + member.profileImage, + contentCount, + isNewFlag, + publishedDaysConcat + ) .from(seriesContent) .innerJoin(seriesContent.series, series) .innerJoin(seriesContent.content, audioContent) .innerJoin(series.member, member) .innerJoin(series.genre, seriesGenre) + .leftJoin(series.publishedDaysOfWeek, seriesPublishedDay) + .leftJoin(seriesTranslation).on(series.id.eq(seriesTranslation.seriesId), seriesTranslation.locale.eq(locale)) .where(where) .groupBy(series.id) - .orderBy(audioContent.releaseDate.max().desc(), series.createdAt.asc()) + .having(contentCount.gt(0)) + .orderBy(latestReleaseDate.desc(), series.createdAt.asc()) .offset(offset) .limit(limit) .fetch() + + return results.map { row -> + val sId = row.get(series.id)!! + val originTitle = row.get(series.title)!! + val payload = row.get(seriesTranslation.renderedPayload) + val translatedTitle = payload?.title + val coverImg = row.get(series.coverImage) + val state = row.get(series.state) + val cId = row.get(member.id)!! + val nick = row.get(member.nickname)!! + val profImg = row.get(member.profileImage) + val nContent = row.get(contentCount) ?: 0L + val isN = (row.get(isNewFlag) ?: 0) > 0 + val rawDays = parsePublishedDaysOfWeek(row.get(publishedDaysConcat)) + + GetSeriesListResponse.SeriesListItem( + seriesId = sId, + title = if (translatedTitle.isNullOrBlank()) originTitle else translatedTitle, + coverImage = "$imageHost/$coverImg", + publishedDaysOfWeek = "", // Service layer will fill this + isComplete = state == SeriesState.COMPLETE, + creator = GetSeriesListResponse.SeriesListItemCreator( + creatorId = cId, + nickname = nick, + profileImage = "$imageHost/$profImg" + ), + numberOfContent = nContent.toInt(), + isNew = isN, + rawPublishedDaysOfWeek = rawDays + ) + } } override fun getSeriesDetail(seriesId: Long, isAuth: Boolean, contentType: ContentType): Series? { @@ -337,34 +572,120 @@ class ContentSeriesQueryRepositoryImpl( .fetchFirst() } - override fun getRecommendSeriesList(isAuth: Boolean, contentType: ContentType, limit: Long): List { + override fun getRecommendSeriesListV2( + imageHost: String, + isAuth: Boolean, + contentType: ContentType, + limit: Long, + locale: String, + memberId: Long? + ): List { var where = series.isActive.isTrue + .and(audioContent.isActive.isTrue) if (!isAuth) { - where = where.and(series.isAdult.isFalse) + where = where + .and(series.isAdult.isFalse) + .and(audioContent.isAdult.isFalse) } else { if (contentType != ContentType.ALL) { where = where.and( series.member.isNull.or( series.member.auth.gender.eq( - if (contentType == ContentType.MALE) { - 0 - } else { - 1 - } + if (contentType == ContentType.MALE) 0 else 1 ) ) ) } } - return queryFactory - .selectFrom(series) + // 차단 필터 + if (memberId != null) { + val blockedSubquery = queryFactory + .select(blockMember.id) + .from(blockMember) + .where( + blockMember.member.id.eq(series.member.id), + blockMember.blockedMember.id.eq(memberId), + blockMember.isActive.isTrue + ) + where = where.and(blockedSubquery.exists().not()) + } + + val now = LocalDateTime.now() + val sevenDaysAgo = now.minusDays(7) + + val contentCount = seriesContent.id.countDistinct() + val isNewCase = Expressions.numberTemplate( + Int::class.java, + "case when {0} then 1 else 0 end", + audioContent.releaseDate.between(sevenDaysAgo, now) + ) + val isNewFlag = isNewCase.max() + val seriesPublishedDay = Expressions.enumPath(SeriesPublishedDaysOfWeek::class.java, "seriesPublishedDay") + val publishedDaysConcat = Expressions.stringTemplate( + "group_concat(distinct {0} order by {0} separator ',')", + seriesPublishedDay + ) + + val results = queryFactory + .select( + series.id, + series.title, + seriesTranslation.renderedPayload, + series.coverImage, + series.state, + member.id, + member.nickname, + member.profileImage, + contentCount, + isNewFlag, + publishedDaysConcat + ) + .from(series) + .innerJoin(series.member, member) + .innerJoin(seriesContent).on(series.id.eq(seriesContent.series.id)) + .innerJoin(seriesContent.content, audioContent) + .leftJoin(series.publishedDaysOfWeek, seriesPublishedDay) + .leftJoin(seriesTranslation).on(series.id.eq(seriesTranslation.seriesId), seriesTranslation.locale.eq(locale)) .where(where) + .groupBy(series.id) + .having(contentCount.gt(0)) .orderBy(Expressions.numberTemplate(Double::class.java, "function('rand')").asc()) .offset(0) .limit(limit) .fetch() + + return results.map { row -> + val sId = row.get(series.id)!! + val originTitle = row.get(series.title)!! + val payload = row.get(seriesTranslation.renderedPayload) + val translatedTitle = payload?.title + val coverImg = row.get(series.coverImage) + val state = row.get(series.state) + val cId = row.get(member.id)!! + val nick = row.get(member.nickname)!! + val profImg = row.get(member.profileImage) + val nContent = row.get(contentCount) ?: 0L + val isN = (row.get(isNewFlag) ?: 0) > 0 + val rawDays = parsePublishedDaysOfWeek(row.get(publishedDaysConcat)) + + GetSeriesListResponse.SeriesListItem( + seriesId = sId, + title = if (translatedTitle.isNullOrBlank()) originTitle else translatedTitle, + coverImage = "$imageHost/$coverImg", + publishedDaysOfWeek = "", // Service layer will fill this + isComplete = state == SeriesState.COMPLETE, + creator = GetSeriesListResponse.SeriesListItemCreator( + creatorId = cId, + nickname = nick, + profileImage = "$imageHost/$profImg" + ), + numberOfContent = nContent.toInt(), + isNew = isN, + rawPublishedDaysOfWeek = rawDays + ) + } } override fun getOriginalAudioDramaList( @@ -377,6 +698,7 @@ class ContentSeriesQueryRepositoryImpl( ): List { var where = series.isOriginal.isTrue .and(series.isActive.isTrue) + .and(audioContent.isActive.isTrue) if (!isAdult) { where = where.and(series.isAdult.isFalse) @@ -396,30 +718,25 @@ class ContentSeriesQueryRepositoryImpl( } } + if (!isAdult) { + where = where.and(audioContent.isAdult.isFalse) + } + val now = LocalDateTime.now() val sevenDaysAgo = now.minusDays(7) - val contentCountSubquery = queryFactory - .select(seriesContent.id.count()) - .from(seriesContent) - .innerJoin(seriesContent.content, audioContent) - .where( - seriesContent.series.id.eq(series.id), - audioContent.isActive.isTrue, - if (!isAdult) audioContent.isAdult.isFalse else null - ) - - val isNewSubquery = queryFactory - .select(seriesContent.id) - .from(seriesContent) - .innerJoin(seriesContent.content, audioContent) - .where( - seriesContent.series.id.eq(series.id), - audioContent.isActive.isTrue, - if (!isAdult) audioContent.isAdult.isFalse else null, - audioContent.releaseDate.between(sevenDaysAgo, now) - ) - .limit(1) + val contentCount = seriesContent.id.countDistinct() + val isNewCase = Expressions.numberTemplate( + Int::class.java, + "case when {0} then 1 else 0 end", + audioContent.releaseDate.between(sevenDaysAgo, now) + ) + val isNewFlag = isNewCase.max() + val seriesPublishedDay = Expressions.enumPath(SeriesPublishedDaysOfWeek::class.java, "seriesPublishedDay") + val publishedDaysConcat = Expressions.stringTemplate( + "group_concat(distinct {0} order by {0} separator ',')", + seriesPublishedDay + ) val results = queryFactory .select( @@ -431,15 +748,19 @@ class ContentSeriesQueryRepositoryImpl( member.id, member.nickname, member.profileImage, - contentCountSubquery, - isNewSubquery.exists(), - series + contentCount, + isNewFlag, + publishedDaysConcat ) .from(series) .innerJoin(series.member, member) + .innerJoin(seriesContent).on(series.id.eq(seriesContent.series.id)) + .innerJoin(seriesContent.content, audioContent) + .leftJoin(series.publishedDaysOfWeek, seriesPublishedDay) .leftJoin(seriesTranslation).on(series.id.eq(seriesTranslation.seriesId), seriesTranslation.locale.eq(locale)) .where(where) - .having(contentCountSubquery.gt(0)) + .groupBy(series.id) + .having(contentCount.gt(0)) .orderBy(series.id.desc()) .offset(offset) .limit(limit) @@ -455,9 +776,9 @@ class ContentSeriesQueryRepositoryImpl( val creatorId = row.get(member.id)!! val nickname = row.get(member.nickname)!! val profileImage = row.get(member.profileImage) - val numberOfContent = row.get(8, Long::class.java) ?: 0L - val isNew = row.get(9, Boolean::class.java) ?: false - val seriesEntity = row.get(series)!! + val numberOfContent = row.get(contentCount) ?: 0L + val isNew = (row.get(isNewFlag) ?: 0) > 0 + val rawDays = parsePublishedDaysOfWeek(row.get(publishedDaysConcat)) GetSeriesListResponse.SeriesListItem( seriesId = seriesId, @@ -472,7 +793,7 @@ class ContentSeriesQueryRepositoryImpl( ), numberOfContent = numberOfContent.toInt(), isNew = isNew, - rawPublishedDaysOfWeek = seriesEntity.publishedDaysOfWeek + rawPublishedDaysOfWeek = rawDays ) } } @@ -560,12 +881,14 @@ class ContentSeriesQueryRepositoryImpl( .fetch() } - override fun findByCurationId( + override fun findByCurationIdV2( + imageHost: String, curationId: Long, memberId: Long, isAdult: Boolean, - contentType: ContentType - ): List { + contentType: ContentType, + locale: String + ): List { val blockMemberCondition = blockMember.member.id.eq(member.id) .and(blockMember.isActive.isTrue) .and(blockMember.blockedMember.id.eq(memberId)) @@ -576,6 +899,7 @@ class ContentSeriesQueryRepositoryImpl( .and(audioContentCuration.id.eq(curationId)) .and(audioContentCurationItem.isActive.isTrue) .and(blockMember.id.isNull) + .and(audioContent.isActive.isTrue) if (!isAdult) { where = where.and(series.isAdult.isFalse) @@ -584,38 +908,108 @@ class ContentSeriesQueryRepositoryImpl( where = where.and( series.member.isNull.or( series.member.auth.gender.eq( - if (contentType == ContentType.MALE) { - 0 - } else { - 1 - } + if (contentType == ContentType.MALE) 0 else 1 ) ) ) } } - return queryFactory - .select(series) + if (!isAdult) { + where = where.and(audioContent.isAdult.isFalse) + } + + val now = LocalDateTime.now() + val sevenDaysAgo = now.minusDays(7) + + val contentCount = seriesContent.id.countDistinct() + val isNewCase = Expressions.numberTemplate( + Int::class.java, + "case when {0} then 1 else 0 end", + audioContent.releaseDate.between(sevenDaysAgo, now) + ) + val isNewFlag = isNewCase.max() + val minCurationOrder = audioContentCurationItem.orders.min() + val seriesPublishedDay = Expressions.enumPath(SeriesPublishedDaysOfWeek::class.java, "seriesPublishedDay") + val publishedDaysConcat = Expressions.stringTemplate( + "group_concat(distinct {0} order by {0} separator ',')", + seriesPublishedDay + ) + + val results = queryFactory + .select( + series.id, + series.title, + seriesTranslation.renderedPayload, + series.coverImage, + series.state, + member.id, + member.nickname, + member.profileImage, + contentCount, + isNewFlag, + publishedDaysConcat, + minCurationOrder + ) .from(audioContentCurationItem) .innerJoin(audioContentCurationItem.curation, audioContentCuration) .innerJoin(audioContentCurationItem.series, series) .innerJoin(series.member, member) + .innerJoin(seriesContent).on(series.id.eq(seriesContent.series.id)) + .innerJoin(seriesContent.content, audioContent) + .leftJoin(series.publishedDaysOfWeek, seriesPublishedDay) .leftJoin(blockMember).on(blockMemberCondition) + .leftJoin(seriesTranslation).on(series.id.eq(seriesTranslation.seriesId), seriesTranslation.locale.eq(locale)) .where(where) - .orderBy(audioContentCurationItem.orders.asc()) + .groupBy(series.id) + .orderBy(minCurationOrder.asc(), series.id.asc()) .fetch() + + return results.map { row -> + val sId = row.get(series.id)!! + val originTitle = row.get(series.title)!! + val payload = row.get(seriesTranslation.renderedPayload) + val translatedTitle = payload?.title + val coverImg = row.get(series.coverImage) + val state = row.get(series.state) + val cId = row.get(member.id)!! + val nick = row.get(member.nickname)!! + val profImg = row.get(member.profileImage) + val nContent = row.get(contentCount) ?: 0L + val isN = (row.get(isNewFlag) ?: 0) > 0 + val rawDays = parsePublishedDaysOfWeek(row.get(publishedDaysConcat)) + + GetSeriesListResponse.SeriesListItem( + seriesId = sId, + title = if (translatedTitle.isNullOrBlank()) originTitle else translatedTitle, + coverImage = "$imageHost/$coverImg", + publishedDaysOfWeek = "", // Service layer will fill this + isComplete = state == SeriesState.COMPLETE, + creator = GetSeriesListResponse.SeriesListItemCreator( + creatorId = cId, + nickname = nick, + profileImage = "$imageHost/$profImg" + ), + numberOfContent = nContent.toInt(), + isNew = isN, + rawPublishedDaysOfWeek = rawDays + ) + } } - override fun getDayOfWeekSeriesList( + override fun getDayOfWeekSeriesListV2( + imageHost: String, dayOfWeek: SeriesPublishedDaysOfWeek, contentType: ContentType, isAdult: Boolean, offset: Long, - limit: Long - ): List { + limit: Long, + locale: String, + memberId: Long? + ): List { var where = series.isActive.isTrue .and(series.publishedDaysOfWeek.contains(dayOfWeek)) + .and(audioContent.isActive.isTrue) if (!isAdult) { where = where.and(series.isAdult.isFalse) @@ -624,24 +1018,103 @@ class ContentSeriesQueryRepositoryImpl( where = where.and( series.member.isNull.or( series.member.auth.gender.eq( - if (contentType == ContentType.MALE) { - 0 - } else { - 1 - } + if (contentType == ContentType.MALE) 0 else 1 ) ) ) } } - return queryFactory - .selectFrom(series) + if (!isAdult) { + where = where.and(audioContent.isAdult.isFalse) + } + + // 차단 필터 + if (memberId != null) { + val blockedSubquery = queryFactory + .select(blockMember.id) + .from(blockMember) + .where( + blockMember.member.id.eq(series.member.id), + blockMember.blockedMember.id.eq(memberId), + blockMember.isActive.isTrue + ) + where = where.and(blockedSubquery.exists().not()) + } + + val now = LocalDateTime.now() + val sevenDaysAgo = now.minusDays(7) + + val contentCount = seriesContent.id.countDistinct() + val isNewCase = Expressions.numberTemplate( + Int::class.java, + "case when {0} then 1 else 0 end", + audioContent.releaseDate.between(sevenDaysAgo, now) + ) + val isNewFlag = isNewCase.max() + val seriesPublishedDay = Expressions.enumPath(SeriesPublishedDaysOfWeek::class.java, "seriesPublishedDay") + val publishedDaysConcat = Expressions.stringTemplate( + "group_concat(distinct {0} order by {0} separator ',')", + seriesPublishedDay + ) + + val results = queryFactory + .select( + series.id, + series.title, + seriesTranslation.renderedPayload, + series.coverImage, + series.state, + member.id, + member.nickname, + member.profileImage, + contentCount, + isNewFlag, + publishedDaysConcat + ) + .from(series) + .innerJoin(series.member, member) + .innerJoin(seriesContent).on(series.id.eq(seriesContent.series.id)) + .innerJoin(seriesContent.content, audioContent) + .leftJoin(series.publishedDaysOfWeek, seriesPublishedDay) + .leftJoin(seriesTranslation).on(series.id.eq(seriesTranslation.seriesId), seriesTranslation.locale.eq(locale)) .where(where) .groupBy(series.id) + .having(contentCount.gt(0)) .offset(offset) .limit(limit) .orderBy(series.createdAt.desc()) .fetch() + + return results.map { row -> + val sId = row.get(series.id)!! + val originTitle = row.get(series.title)!! + val payload = row.get(seriesTranslation.renderedPayload) + val translatedTitle = payload?.title + val coverImg = row.get(series.coverImage) + val state = row.get(series.state) + val cId = row.get(member.id)!! + val nick = row.get(member.nickname)!! + val profImg = row.get(member.profileImage) + val nContent = row.get(contentCount) ?: 0L + val isN = (row.get(isNewFlag) ?: 0) > 0 + val rawDays = parsePublishedDaysOfWeek(row.get(publishedDaysConcat)) + + GetSeriesListResponse.SeriesListItem( + seriesId = sId, + title = if (translatedTitle.isNullOrBlank()) originTitle else translatedTitle, + coverImage = "$imageHost/$coverImg", + publishedDaysOfWeek = "", // Service layer will fill this + isComplete = state == SeriesState.COMPLETE, + creator = GetSeriesListResponse.SeriesListItemCreator( + creatorId = cId, + nickname = nick, + profileImage = "$imageHost/$profImg" + ), + numberOfContent = nContent.toInt(), + isNew = isN, + rawPublishedDaysOfWeek = rawDays + ) + } } } diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/content/series/ContentSeriesService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/content/series/ContentSeriesService.kt index 839eeffb..6cf0342c 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/content/series/ContentSeriesService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/content/series/ContentSeriesService.kt @@ -12,10 +12,8 @@ import kr.co.vividnext.sodalive.content.series.translation.SeriesTranslationPayl 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 @@ -26,7 +24,6 @@ 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 @@ -160,10 +157,11 @@ class ContentSeriesService( isAuth = isAuth, contentType = contentType, isOriginal = isOriginal, - isCompleted = isCompleted + isCompleted = isCompleted, + memberId = member.id ) - val rawItems = repository.getSeriesList( + val items = repository.getSeriesListV2( imageHost = coverImageHost, creatorId = creatorId, isAuth = isAuth, @@ -172,11 +170,14 @@ class ContentSeriesService( isCompleted = isCompleted, orderByRandom = orderByRandom, offset = offset, - limit = limit - ).filter { !blockMemberRepository.isBlocked(blockedMemberId = member.id!!, memberId = it.member!!.id!!) } + limit = limit, + locale = langContext.lang.code, + memberId = member.id + ).map { item -> + item.copy(publishedDaysOfWeek = publishedDaysOfWeekText(item.rawPublishedDaysOfWeek)) + } - val items = seriesToSeriesListItem(seriesList = rawItems, isAdult = isAuth, contentType = contentType) - return GetSeriesListResponse(totalCount, getTranslatedSeriesList(items)) + return GetSeriesListResponse(totalCount, items) } fun getSeriesListByGenre( @@ -192,20 +193,24 @@ class ContentSeriesService( val totalCount = repository.getSeriesByGenreTotalCount( genreId = genreId, isAuth = isAuth, - contentType = contentType + contentType = contentType, + memberId = member.id ) - val rawItems = repository.getSeriesByGenreList( + val items = repository.getSeriesByGenreListV2( imageHost = coverImageHost, genreId = genreId, isAuth = isAuth, contentType = contentType, offset = offset, - limit = limit - ).filter { !blockMemberRepository.isBlocked(blockedMemberId = member.id!!, memberId = it.member!!.id!!) } + limit = limit, + locale = langContext.lang.code, + memberId = member.id + ).map { item -> + item.copy(publishedDaysOfWeek = publishedDaysOfWeekText(item.rawPublishedDaysOfWeek)) + } - val items = seriesToSeriesListItem(seriesList = rawItems, isAdult = isAuth, contentType = contentType) - return GetSeriesListResponse(totalCount, getTranslatedSeriesList(items)) + return GetSeriesListResponse(totalCount, items) } @Transactional @@ -468,19 +473,16 @@ class ContentSeriesService( member: Member ): List { val isAuth = member.auth != null && isAdultContentVisible - val seriesList = repository.getRecommendSeriesList( + return repository.getRecommendSeriesListV2( + imageHost = coverImageHost, isAuth = isAuth, contentType = contentType, - limit = 20 - ).filter { !blockMemberRepository.isBlocked(blockedMemberId = member.id!!, memberId = it.member!!.id!!) } - - return getTranslatedSeriesList( - seriesToSeriesListItem( - seriesList = seriesList, - isAdult = isAuth, - contentType = contentType - ) - ) + limit = 20, + locale = langContext.lang.code, + memberId = member.id + ).map { item -> + item.copy(publishedDaysOfWeek = publishedDaysOfWeekText(item.rawPublishedDaysOfWeek)) + } } fun fetchSeriesByCurationId( @@ -489,13 +491,16 @@ class ContentSeriesService( isAdult: Boolean, contentType: ContentType ): List { - val seriesList = repository.findByCurationId( + return repository.findByCurationIdV2( + imageHost = coverImageHost, curationId = curationId, memberId = memberId, isAdult = isAdult, - contentType = contentType - ) - return getTranslatedSeriesList(seriesToSeriesListItem(seriesList, isAdult, contentType)) + contentType = contentType, + locale = langContext.lang.code + ).map { item -> + item.copy(publishedDaysOfWeek = publishedDaysOfWeekText(item.rawPublishedDaysOfWeek)) + } } fun getDayOfWeekSeriesList( @@ -506,72 +511,18 @@ class ContentSeriesService( offset: Long = 0, limit: Long = 10 ): List { - var seriesList = repository.getDayOfWeekSeriesList( + return repository.getDayOfWeekSeriesListV2( + imageHost = coverImageHost, dayOfWeek = dayOfWeek, contentType = contentType, isAdult = isAdult, offset = offset, - limit = limit - ) - - seriesList = if (memberId != null) { - seriesList.filter { - !blockMemberRepository.isBlocked( - blockedMemberId = memberId, - memberId = it.member!!.id!! - ) - } - } else { - seriesList + limit = limit, + locale = langContext.lang.code, + memberId = memberId + ).map { item -> + item.copy(publishedDaysOfWeek = publishedDaysOfWeekText(item.rawPublishedDaysOfWeek)) } - - return getTranslatedSeriesList(seriesToSeriesListItem(seriesList, isAdult, contentType)) - } - - private fun seriesToSeriesListItem( - seriesList: List, - isAdult: Boolean, - contentType: ContentType - ): List { - return seriesList - .map { - GetSeriesListResponse.SeriesListItem( - seriesId = it.id!!, - title = it.title, - coverImage = "$coverImageHost/${it.coverImage!!}", - publishedDaysOfWeek = publishedDaysOfWeekText(it.publishedDaysOfWeek), - isComplete = it.state == SeriesState.COMPLETE, - creator = GetSeriesListResponse.SeriesListItemCreator( - creatorId = it.member!!.id!!, - nickname = it.member!!.nickname, - profileImage = "$coverImageHost/${it.member!!.profileImage!!}" - ) - ) - } - .map { - it.numberOfContent = seriesContentRepository.getContentCount( - seriesId = it.seriesId, - isAdult = isAdult, - contentType = contentType - ) - - it - } - .filter { - it.numberOfContent > 0 - } - .map { - val nowDateTime = LocalDateTime.now() - - it.isNew = seriesContentRepository.isNewContent( - seriesId = it.seriesId, - isAdult = isAdult, - fromDate = nowDateTime.minusDays(7), - nowDate = nowDateTime - ) - - it - } } private fun publishedDaysOfWeekText(publishedDaysOfWeek: Set): String { @@ -641,39 +592,4 @@ class ContentSeriesService( } } } - - /** - * 시리즈 리스트의 제목을 현재 언어(locale)에 맞춰 일괄 번역한다. - * - * 처리 절차: - * - 입력된 시리즈들의 seriesId 집합을 만들고, 요청 언어 코드(langContext.lang.code)로 - * seriesTranslationRepository에서 번역 데이터를 한 번에 조회한다. - * - 각 항목에 대해 번역된 제목이 존재하고 비어있지 않으면 title만 번역 값으로 교체한다. - * - 번역이 없거나 공백이면 원본 항목을 그대로 반환한다. - * - * 성능: - * - N건의 항목을 1회의 조회로 해결하기 위해 IN 쿼리를 사용한다. - * - * @param seriesList 번역 대상 SeriesListItem 목록 - * @return 제목이 가능한 항목은 번역된 목록(불변 사본), 그 외는 원본 항목 유지 - */ - private fun getTranslatedSeriesList( - seriesList: List - ): List { - 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) - } - } - } } From ec077d23f01e77974b37f3edd87950b6498eb60c Mon Sep 17 00:00:00 2001 From: Klaus Date: Fri, 13 Feb 2026 15:46:54 +0900 Subject: [PATCH 09/15] =?UTF-8?q?=EC=9D=B8=EA=B8=B0=20=EC=BA=90=EB=A6=AD?= =?UTF-8?q?=ED=84=B0=20=EB=B2=88=EC=97=AD=20=EC=A1=B0=ED=9A=8C=20=EA=B0=9C?= =?UTF-8?q?=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../sodalive/api/home/HomeService.kt | 37 +------------ .../controller/ChatCharacterController.kt | 4 +- .../character/service/ChatCharacterService.kt | 27 +++++---- .../service/PopularCharacterQuery.kt | 55 +++++++++++++++++++ 4 files changed, 74 insertions(+), 49 deletions(-) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/api/home/HomeService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/api/home/HomeService.kt index 10ae9663..448cd110 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/api/home/HomeService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/api/home/HomeService.kt @@ -1,7 +1,6 @@ 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 @@ -145,7 +144,7 @@ class HomeService( ) // 인기 캐릭터 조회 - val translatedPopularCharacters = getTranslatedAiCharacterList(aiCharacterList = characterService.getPopularCharacters()) + val translatedPopularCharacters = characterService.getPopularCharacters(locale = langContext.lang.code) val currentDateTime = LocalDateTime.now() val startDate = currentDateTime @@ -473,38 +472,4 @@ class HomeService( return result.take(targetSize).shuffled() } - - /** - * AI 캐릭터 리스트의 이름/설명을 현재 언어(locale)에 맞춰 일괄 번역한다. - * - * 처리 절차: - * - characterId 목록을 추출하고, 요청 언어 코드로 aiCharacterTranslationRepository에서 - * 번역 데이터를 한 번에 조회한다. - * - 각 캐릭터에 대해 name과 description 모두 번역 값이 존재하고 비어있지 않을 때에만 - * 해당 필드를 교체한다. 둘 중 하나라도 없으면 원본 캐릭터를 그대로 유지한다. - * - * @param aiCharacterList 번역 대상 캐릭터 목록 - * @return 가능한 경우 name/description이 번역된 캐릭터 목록, 그 외는 원본 유지 - */ - private fun getTranslatedAiCharacterList(aiCharacterList: List): List { - 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 - } - } } diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/controller/ChatCharacterController.kt b/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/controller/ChatCharacterController.kt index afa2bb37..9c81a93a 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/controller/ChatCharacterController.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/controller/ChatCharacterController.kt @@ -100,7 +100,7 @@ class ChatCharacterController( } // 인기 캐릭터 조회 - val popularCharacters = service.getPopularCharacters() + val popularCharacters = service.getPopularCharacters(locale = langContext.lang.code) // 최근 등록된 캐릭터 리스트 조회 val newCharacters = service.getRecentCharactersPage( @@ -138,7 +138,7 @@ class ChatCharacterController( CharacterMainResponse( banners = banners, recentCharacters = translatedRecentCharacters, - popularCharacters = getTranslatedAiCharacterList(popularCharacters), + popularCharacters = popularCharacters, newCharacters = getTranslatedAiCharacterList(newCharacters), recommendCharacters = getTranslatedAiCharacterList(recommendCharacters), curationSections = curationSections diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/service/ChatCharacterService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/service/ChatCharacterService.kt index 47ac7565..23eb26ef 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/service/ChatCharacterService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/service/ChatCharacterService.kt @@ -77,18 +77,23 @@ class ChatCharacterService( */ @Transactional(readOnly = true) @Cacheable( - cacheNames = ["popularCharacters_24h"], - key = "T(kr.co.vividnext.sodalive.chat.character.service.RankingWindowCalculator).now('popular-character').cacheKey" + cacheNames = ["popularCharacters_24h_locale"], + key = "T(kr.co.vividnext.sodalive.chat.character.service.RankingWindowCalculator)" + + ".now('popular-character').cacheKey + '-' + #locale" ) - fun getPopularCharacters(limit: Long = 20): List { + fun getPopularCharacters(locale: String, limit: Long = 20): List { val window = RankingWindowCalculator.now("popular-character") - val topIds = popularCharacterQuery.findPopularCharacterIds(window.windowStart, window.nextBoundary, limit) - val list = loadCharactersInOrder(topIds) + val results = popularCharacterQuery.findPopularCharactersWithTranslation( + window.windowStart, + window.nextBoundary, + limit, + locale + ) - val recentSet = if (list.isNotEmpty()) { + val recentSet = if (results.isNotEmpty()) { imageRepository .findCharacterIdsWithRecentImages( - list.map { it.id!! }, + results.map { it.id }, LocalDateTime.now().minusDays(3) ) .toSet() @@ -96,11 +101,11 @@ class ChatCharacterService( emptySet() } - return list.map { + return results.map { Character( - characterId = it.id!!, - name = it.name, - description = it.description, + characterId = it.id, + name = it.translatedPayload?.name.takeIf { name -> !name.isNullOrBlank() } ?: it.name, + description = it.translatedPayload?.description.takeIf { desc -> !desc.isNullOrBlank() } ?: it.description, imageUrl = "$imageHost/${it.imagePath ?: "profile/default-profile.png"}", new = recentSet.contains(it.id) ) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/service/PopularCharacterQuery.kt b/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/service/PopularCharacterQuery.kt index ea542f78..94253a5b 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/service/PopularCharacterQuery.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/service/PopularCharacterQuery.kt @@ -1,7 +1,10 @@ package kr.co.vividnext.sodalive.chat.character.service +import com.querydsl.core.types.Projections import com.querydsl.jpa.impl.JPAQueryFactory import kr.co.vividnext.sodalive.chat.character.QChatCharacter +import kr.co.vividnext.sodalive.chat.character.translate.AiCharacterTranslationRenderedPayload +import kr.co.vividnext.sodalive.chat.character.translate.QAiCharacterTranslation import kr.co.vividnext.sodalive.chat.room.ParticipantType import kr.co.vividnext.sodalive.chat.room.QChatMessage import kr.co.vividnext.sodalive.chat.room.QChatParticipant @@ -51,4 +54,56 @@ class PopularCharacterQuery( .limit(limit) .fetch() } + + data class PopularCharacterQueryResult( + val id: Long, + val name: String, + val description: String, + val imagePath: String?, + val translatedPayload: AiCharacterTranslationRenderedPayload? + ) + + fun findPopularCharactersWithTranslation( + windowStart: Instant, + endExclusive: Instant, + limit: Long, + locale: String + ): List { + val m = QChatMessage.chatMessage + val p = QChatParticipant.chatParticipant + val c = QChatCharacter.chatCharacter + val t = QAiCharacterTranslation.aiCharacterTranslation + + val start = LocalDateTime.ofInstant(windowStart, ZoneOffset.UTC) + val end = LocalDateTime.ofInstant(endExclusive, ZoneOffset.UTC) + + return queryFactory + .select( + Projections.constructor( + PopularCharacterQueryResult::class.java, + c.id, + c.name, + c.description, + c.imagePath, + t.renderedPayload + ) + ) + .from(m) + .join(p).on( + p.chatRoom.id.eq(m.chatRoom.id) + .and(p.participantType.eq(ParticipantType.CHARACTER)) + ) + .join(c).on(c.id.eq(p.character.id)) + .leftJoin(t).on(t.characterId.eq(c.id).and(t.locale.eq(locale))) + .where( + m.createdAt.goe(start) + .and(m.createdAt.lt(end)) + .and(m.isActive.isTrue) + .and(c.isActive.isTrue) + ) + .groupBy(c.id, c.name, c.description, c.imagePath, t.id, t.renderedPayload) + .orderBy(m.id.count().desc()) + .limit(limit) + .fetch() + } } From 88612b34794d140172cd0b275a4081949dd10afb Mon Sep 17 00:00:00 2001 From: Klaus Date: Fri, 13 Feb 2026 16:37:13 +0900 Subject: [PATCH 10/15] =?UTF-8?q?=EB=B2=88=EC=97=AD=20=EC=A0=9C=EB=AA=A9?= =?UTF-8?q?=20=EC=A1=B0=ED=9A=8C=20=EB=B0=A9=EC=8B=9D=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../content/AudioContentRepository.kt | 107 +++++++++++++----- 1 file changed, 80 insertions(+), 27 deletions(-) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/content/AudioContentRepository.kt b/src/main/kotlin/kr/co/vividnext/sodalive/content/AudioContentRepository.kt index 05bd96ec..aa1ae861 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/content/AudioContentRepository.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/content/AudioContentRepository.kt @@ -1385,40 +1385,73 @@ class AudioContentQueryRepositoryImpl( 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 + if (locale == null) { + var select = queryFactory + .select( + QAudioContentMainItem( + audioContent.id, + member.id, + audioContent.title, + audioContent.coverImage.prepend("/").prepend(imageHost), + member.nickname, + audioContent.isPointAvailable + ) + ) + .from(audioContent) + .innerJoin(audioContent.member, member) + .innerJoin(audioContent.theme, audioContentTheme) + + if (memberId != null) { + where = where.and(blockMember.id.isNull) + select = select.leftJoin(blockMember).on(blockMemberCondition) + } + + val orderBy = if (orderByRandom) { + Expressions.numberTemplate(Double::class.java, "function('rand')").asc() + } else { + when (sortType) { + SortType.NEWEST -> audioContent.releaseDate.desc() + SortType.PRICE_HIGH -> if (isFree) { + audioContent.releaseDate.desc() + } else { + audioContent.price.desc() + } + + SortType.PRICE_LOW -> if (isFree) { + audioContent.releaseDate.asc() + } else { + audioContent.price.desc() + } + + SortType.POPULARITY -> audioContent.playCount.desc() + } + } + + return select + .where(where) + .offset(offset) + .limit(limit) + .orderBy(orderBy) + .fetch() } + val coverImageUrl = audioContent.coverImage.prepend("/").prepend(imageHost) + var select = queryFactory .select( - QAudioContentMainItem( - audioContent.id, - member.id, - titleExpression, - audioContent.coverImage.prepend("/").prepend(imageHost), - member.nickname, - audioContent.isPointAvailable - ) + audioContent.id, + member.id, + audioContent.title, + contentTranslation.renderedPayload, + coverImageUrl, + member.nickname, + audioContent.isPointAvailable ) .from(audioContent) .innerJoin(audioContent.member, member) .innerJoin(audioContent.theme, audioContentTheme) - - if (locale != null) { - select = select.leftJoin(contentTranslation) - .on(contentTranslation.contentId.eq(audioContent.id).and(contentTranslation.locale.eq(locale))) - } + .leftJoin(contentTranslation) + .on(contentTranslation.contentId.eq(audioContent.id).and(contentTranslation.locale.eq(locale))) if (memberId != null) { where = where.and(blockMember.id.isNull) @@ -1446,12 +1479,32 @@ class AudioContentQueryRepositoryImpl( } } - return select + val results = select .where(where) .offset(offset) .limit(limit) .orderBy(orderBy) .fetch() + + return results.map { row -> + val contentId = row.get(audioContent.id)!! + val creatorId = row.get(member.id)!! + val originTitle = row.get(audioContent.title)!! + val payload = row.get(contentTranslation.renderedPayload) + val translatedTitle = payload?.title + val imageUrl = row.get(coverImageUrl)!! + val creatorNickname = row.get(member.nickname)!! + val isPointAvailableValue = row.get(audioContent.isPointAvailable) ?: false + + AudioContentMainItem( + contentId = contentId, + creatorId = creatorId, + title = if (translatedTitle.isNullOrBlank()) originTitle else translatedTitle, + coverImageUrl = imageUrl, + creatorNickname = creatorNickname, + isPointAvailable = isPointAvailableValue + ) + } } override fun findContentByCurationId( From 999507ee150e6805412c27232206941c4965f552 Mon Sep 17 00:00:00 2001 From: Klaus Date: Fri, 13 Feb 2026 16:47:22 +0900 Subject: [PATCH 11/15] =?UTF-8?q?=EB=B2=88=EC=97=AD=20=EC=A0=9C=EB=AA=A9?= =?UTF-8?q?=20=EC=A1=B0=ED=9A=8C=20=EB=B0=A9=EC=8B=9D=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../RecommendChannelQueryRepository.kt | 54 ++++++++------ .../sodalive/rank/RankingRepository.kt | 71 ++++++++++++------- 2 files changed, 79 insertions(+), 46 deletions(-) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/query/recommend/RecommendChannelQueryRepository.kt b/src/main/kotlin/kr/co/vividnext/sodalive/query/recommend/RecommendChannelQueryRepository.kt index df513800..fc99f58a 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/query/recommend/RecommendChannelQueryRepository.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/query/recommend/RecommendChannelQueryRepository.kt @@ -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.comment.QAudioContentComment.audioContentComment import kr.co.vividnext.sodalive.content.like.QAudioContentLike.audioContentLike +import kr.co.vividnext.sodalive.content.translation.ContentTranslationPayload import kr.co.vividnext.sodalive.content.translation.QContentTranslation.contentTranslation import kr.co.vividnext.sodalive.member.MemberRole import kr.co.vividnext.sodalive.member.QMember.member @@ -101,30 +102,23 @@ class RecommendChannelQueryRepository( 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 + val coverImageUrl = audioContent.coverImage.prepend("/").prepend(imageHost) + val payloadExpression = if (locale != null) { + contentTranslation.renderedPayload } else { - audioContent.title + Expressions.nullExpression(ContentTranslationPayload::class.java) } + val likeCountExpression = audioContentLike.id.countDistinct() + val commentCountExpression = audioContentComment.id.countDistinct() var select = queryFactory .select( - QRecommendChannelContentItem( - audioContent.id, - titleExpression, - audioContent.coverImage.prepend("/").prepend(imageHost), - audioContentLike.id.countDistinct(), - audioContentComment.id.countDistinct() - ) + audioContent.id, + audioContent.title, + payloadExpression, + coverImageUrl, + likeCountExpression, + commentCountExpression ) .from(audioContent) .leftJoin(audioContentLike) @@ -151,11 +145,29 @@ class RecommendChannelQueryRepository( select = select.leftJoin(blockMember).on(blockMemberCondition) } - return select + val results = select .where(where) .groupBy(audioContent.id) - .orderBy(audioContentLike.id.countDistinct().desc()) + .orderBy(likeCountExpression.desc()) .limit(3) .fetch() + + return results.map { row -> + val contentId = row.get(audioContent.id)!! + val originTitle = row.get(audioContent.title)!! + val payload = row.get(payloadExpression) + val translatedTitle = payload?.title + val thumbnailImageUrl = row.get(coverImageUrl)!! + val likeCount = row.get(likeCountExpression) ?: 0L + val commentCount = row.get(commentCountExpression) ?: 0L + + RecommendChannelContentItem( + contentId = contentId, + title = if (translatedTitle.isNullOrBlank()) originTitle else translatedTitle, + thumbnailImageUrl = thumbnailImageUrl, + likeCount = likeCount, + commentCount = commentCount + ) + } } } diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/rank/RankingRepository.kt b/src/main/kotlin/kr/co/vividnext/sodalive/rank/RankingRepository.kt index d16c0f39..b813d9ae 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/rank/RankingRepository.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/rank/RankingRepository.kt @@ -13,6 +13,7 @@ import kr.co.vividnext.sodalive.content.main.QContentCreatorResponse import kr.co.vividnext.sodalive.content.main.QGetAudioContentRankingItem import kr.co.vividnext.sodalive.content.order.QOrder.order import kr.co.vividnext.sodalive.content.theme.QAudioContentTheme.audioContentTheme +import kr.co.vividnext.sodalive.content.translation.ContentTranslationPayload 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.Series @@ -112,35 +113,27 @@ class RankingRepository( 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 + val coverImageUrl = audioContent.coverImage.prepend("/").prepend(imageHost) + val creatorProfileImageUrl = member.profileImage.prepend("/").prepend(imageHost) + val payloadExpression = if (locale != null) { + contentTranslation.renderedPayload } else { - audioContent.title + Expressions.nullExpression(ContentTranslationPayload::class.java) } var select = queryFactory .select( - QGetAudioContentRankingItem( - audioContent.id, - titleExpression, - audioContent.coverImage.prepend("/").prepend(imageHost), - audioContentTheme.theme, - audioContent.price, - audioContent.duration, - member.id, - member.nickname, - audioContent.isPointAvailable, - member.profileImage.prepend("/").prepend(imageHost) - ) + audioContent.id, + audioContent.title, + payloadExpression, + coverImageUrl, + audioContentTheme.theme, + audioContent.price, + audioContent.duration, + member.id, + member.nickname, + audioContent.isPointAvailable, + creatorProfileImageUrl ) select = when (sortType) { @@ -258,10 +251,38 @@ class RankingRepository( } } - return select + val results = select .offset(offset) .limit(limit) .fetch() + + return results.map { row -> + val contentId = row.get(audioContent.id)!! + val originTitle = row.get(audioContent.title)!! + val payload = row.get(payloadExpression) + val translatedTitle = payload?.title + val imageUrl = row.get(coverImageUrl)!! + val themeStr = row.get(audioContentTheme.theme) ?: "" + val price = row.get(audioContent.price) ?: 0 + val duration = row.get(audioContent.duration) ?: "" + val creatorId = row.get(member.id)!! + val creatorNickname = row.get(member.nickname)!! + val isPointAvailable = row.get(audioContent.isPointAvailable) ?: false + val creatorProfileImageUrlValue = row.get(creatorProfileImageUrl)!! + + GetAudioContentRankingItem( + contentId = contentId, + title = if (translatedTitle.isNullOrBlank()) originTitle else translatedTitle, + coverImageUrl = imageUrl, + themeStr = themeStr, + price = price, + duration = duration, + creatorId = creatorId, + creatorNickname = creatorNickname, + isPointAvailable = isPointAvailable, + creatorProfileImageUrl = creatorProfileImageUrlValue + ) + } } fun getSeriesRanking( From 43c5a8e8cbd06f9aaaf92f3fd892d432afc2f487 Mon Sep 17 00:00:00 2001 From: Klaus Date: Fri, 13 Feb 2026 17:09:44 +0900 Subject: [PATCH 12/15] =?UTF-8?q?=EC=8B=9C=EB=A6=AC=EC=A6=88=20=EB=B0=9C?= =?UTF-8?q?=ED=96=89=20=EC=9A=94=EC=9D=BC=20=EC=A0=95=EB=A0=AC=20=EB=B3=B4?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../content/series/ContentSeriesRepository.kt | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/content/series/ContentSeriesRepository.kt b/src/main/kotlin/kr/co/vividnext/sodalive/content/series/ContentSeriesRepository.kt index cb5c6c77..d02be1ec 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/content/series/ContentSeriesRepository.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/content/series/ContentSeriesRepository.kt @@ -128,7 +128,8 @@ class ContentSeriesQueryRepositoryImpl( runCatching { SeriesPublishedDaysOfWeek.valueOf(trimmed) }.getOrNull() } } - .toSet() + .sortedBy { it.ordinal } + .toCollection(LinkedHashSet()) } override fun getSeriesTotalCount( @@ -276,7 +277,7 @@ class ContentSeriesQueryRepositoryImpl( val isNewFlag = isNewCase.max() val seriesPublishedDay = Expressions.enumPath(SeriesPublishedDaysOfWeek::class.java, "seriesPublishedDay") val publishedDaysConcat = Expressions.stringTemplate( - "group_concat(distinct {0} order by {0} separator ',')", + "function('group_concat', {0})", seriesPublishedDay ) @@ -452,7 +453,7 @@ class ContentSeriesQueryRepositoryImpl( val isNewFlag = isNewCase.max() val seriesPublishedDay = Expressions.enumPath(SeriesPublishedDaysOfWeek::class.java, "seriesPublishedDay") val publishedDaysConcat = Expressions.stringTemplate( - "group_concat(distinct {0} order by {0} separator ',')", + "function('group_concat', {0})", seriesPublishedDay ) val latestReleaseDate = audioContent.releaseDate.max() @@ -624,7 +625,7 @@ class ContentSeriesQueryRepositoryImpl( val isNewFlag = isNewCase.max() val seriesPublishedDay = Expressions.enumPath(SeriesPublishedDaysOfWeek::class.java, "seriesPublishedDay") val publishedDaysConcat = Expressions.stringTemplate( - "group_concat(distinct {0} order by {0} separator ',')", + "function('group_concat', {0})", seriesPublishedDay ) @@ -734,7 +735,7 @@ class ContentSeriesQueryRepositoryImpl( val isNewFlag = isNewCase.max() val seriesPublishedDay = Expressions.enumPath(SeriesPublishedDaysOfWeek::class.java, "seriesPublishedDay") val publishedDaysConcat = Expressions.stringTemplate( - "group_concat(distinct {0} order by {0} separator ',')", + "function('group_concat', {0})", seriesPublishedDay ) @@ -932,7 +933,7 @@ class ContentSeriesQueryRepositoryImpl( val minCurationOrder = audioContentCurationItem.orders.min() val seriesPublishedDay = Expressions.enumPath(SeriesPublishedDaysOfWeek::class.java, "seriesPublishedDay") val publishedDaysConcat = Expressions.stringTemplate( - "group_concat(distinct {0} order by {0} separator ',')", + "function('group_concat', {0})", seriesPublishedDay ) @@ -1054,7 +1055,7 @@ class ContentSeriesQueryRepositoryImpl( val isNewFlag = isNewCase.max() val seriesPublishedDay = Expressions.enumPath(SeriesPublishedDaysOfWeek::class.java, "seriesPublishedDay") val publishedDaysConcat = Expressions.stringTemplate( - "group_concat(distinct {0} order by {0} separator ',')", + "function('group_concat', {0})", seriesPublishedDay ) From a76c3ba34abef63c5066f2134e9d46b76f9bddc8 Mon Sep 17 00:00:00 2001 From: Klaus Date: Fri, 13 Feb 2026 17:26:53 +0900 Subject: [PATCH 13/15] =?UTF-8?q?EnumPath=EC=97=90=20stringValue()?= =?UTF-8?q?=EB=A5=BC=20=EC=A0=81=EC=9A=A9=ED=95=98=EC=97=AC=20group=5Fconc?= =?UTF-8?q?at=20=EC=98=A4=EB=A5=98=20=ED=95=B4=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Querydsl에서 Enum 타입을 group_concat 함수의 인자로 사용할 때 발생하는 Hibernate QueryException을 해결하기 위해 EnumPath에 stringValue() 를 적용하여 문자열로 변환한 후 함수를 호출하도록 수정함. --- .../content/series/ContentSeriesRepository.kt | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/content/series/ContentSeriesRepository.kt b/src/main/kotlin/kr/co/vividnext/sodalive/content/series/ContentSeriesRepository.kt index d02be1ec..e9123360 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/content/series/ContentSeriesRepository.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/content/series/ContentSeriesRepository.kt @@ -277,7 +277,7 @@ class ContentSeriesQueryRepositoryImpl( val isNewFlag = isNewCase.max() val seriesPublishedDay = Expressions.enumPath(SeriesPublishedDaysOfWeek::class.java, "seriesPublishedDay") val publishedDaysConcat = Expressions.stringTemplate( - "function('group_concat', {0})", + "function('group_concat', {0}.stringValue())", seriesPublishedDay ) @@ -453,7 +453,7 @@ class ContentSeriesQueryRepositoryImpl( val isNewFlag = isNewCase.max() val seriesPublishedDay = Expressions.enumPath(SeriesPublishedDaysOfWeek::class.java, "seriesPublishedDay") val publishedDaysConcat = Expressions.stringTemplate( - "function('group_concat', {0})", + "function('group_concat', {0}.stringValue())", seriesPublishedDay ) val latestReleaseDate = audioContent.releaseDate.max() @@ -625,7 +625,7 @@ class ContentSeriesQueryRepositoryImpl( val isNewFlag = isNewCase.max() val seriesPublishedDay = Expressions.enumPath(SeriesPublishedDaysOfWeek::class.java, "seriesPublishedDay") val publishedDaysConcat = Expressions.stringTemplate( - "function('group_concat', {0})", + "function('group_concat', {0}.stringValue())", seriesPublishedDay ) @@ -735,7 +735,7 @@ class ContentSeriesQueryRepositoryImpl( val isNewFlag = isNewCase.max() val seriesPublishedDay = Expressions.enumPath(SeriesPublishedDaysOfWeek::class.java, "seriesPublishedDay") val publishedDaysConcat = Expressions.stringTemplate( - "function('group_concat', {0})", + "function('group_concat', {0}.stringValue())", seriesPublishedDay ) @@ -933,7 +933,7 @@ class ContentSeriesQueryRepositoryImpl( val minCurationOrder = audioContentCurationItem.orders.min() val seriesPublishedDay = Expressions.enumPath(SeriesPublishedDaysOfWeek::class.java, "seriesPublishedDay") val publishedDaysConcat = Expressions.stringTemplate( - "function('group_concat', {0})", + "function('group_concat', {0}.stringValue())", seriesPublishedDay ) @@ -1055,7 +1055,7 @@ class ContentSeriesQueryRepositoryImpl( val isNewFlag = isNewCase.max() val seriesPublishedDay = Expressions.enumPath(SeriesPublishedDaysOfWeek::class.java, "seriesPublishedDay") val publishedDaysConcat = Expressions.stringTemplate( - "function('group_concat', {0})", + "function('group_concat', {0}.stringValue())", seriesPublishedDay ) From 1b039bccea1d90d12460b61b5b804816d4939936 Mon Sep 17 00:00:00 2001 From: Klaus Date: Fri, 13 Feb 2026 17:49:31 +0900 Subject: [PATCH 14/15] =?UTF-8?q?Group=5Fconcat=20=EC=A0=9C=EA=B1=B0=20?= =?UTF-8?q?=EB=B0=8F=20=EC=95=A0=ED=94=8C=EB=A6=AC=EC=BC=80=EC=9D=B4?= =?UTF-8?q?=EC=85=98=20=EB=A0=88=EB=B2=A8=20=EB=8D=B0=EC=9D=B4=ED=84=B0=20?= =?UTF-8?q?=EB=B3=91=ED=95=A9=20=EC=A0=81=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit EnumPath 사용 시 발생하는 Hibernate QueryException을 해결하기 위해 group_concat 사용을 전면 제거함. 연재 요일 데이터를 개별 쿼리로 조회한 후 메모리에서 시리즈 ID를 기준으로 그룹화하여 결과를 생성하도록 수정함. --- .../content/series/ContentSeriesRepository.kt | 154 +++++++++--------- 1 file changed, 77 insertions(+), 77 deletions(-) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/content/series/ContentSeriesRepository.kt b/src/main/kotlin/kr/co/vividnext/sodalive/content/series/ContentSeriesRepository.kt index e9123360..f9995040 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/content/series/ContentSeriesRepository.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/content/series/ContentSeriesRepository.kt @@ -114,24 +114,6 @@ interface ContentSeriesQueryRepository { class ContentSeriesQueryRepositoryImpl( private val queryFactory: JPAQueryFactory ) : ContentSeriesQueryRepository { - private fun parsePublishedDaysOfWeek(raw: String?): Set { - if (raw.isNullOrBlank()) { - return emptySet() - } - - return raw.split(",") - .mapNotNull { value -> - val trimmed = value.trim() - if (trimmed.isEmpty()) { - null - } else { - runCatching { SeriesPublishedDaysOfWeek.valueOf(trimmed) }.getOrNull() - } - } - .sortedBy { it.ordinal } - .toCollection(LinkedHashSet()) - } - override fun getSeriesTotalCount( creatorId: Long?, isAuth: Boolean, @@ -275,11 +257,6 @@ class ContentSeriesQueryRepositoryImpl( audioContent.releaseDate.between(sevenDaysAgo, now) ) val isNewFlag = isNewCase.max() - val seriesPublishedDay = Expressions.enumPath(SeriesPublishedDaysOfWeek::class.java, "seriesPublishedDay") - val publishedDaysConcat = Expressions.stringTemplate( - "function('group_concat', {0}.stringValue())", - seriesPublishedDay - ) val results = queryFactory .select( @@ -292,14 +269,12 @@ class ContentSeriesQueryRepositoryImpl( member.nickname, member.profileImage, contentCount, - isNewFlag, - publishedDaysConcat + isNewFlag ) .from(series) .innerJoin(series.member, member) .innerJoin(seriesContent).on(series.id.eq(seriesContent.series.id)) .innerJoin(seriesContent.content, audioContent) - .leftJoin(series.publishedDaysOfWeek, seriesPublishedDay) .leftJoin(seriesTranslation).on(series.id.eq(seriesTranslation.seriesId), seriesTranslation.locale.eq(locale)) .where(where) .groupBy(series.id) @@ -309,6 +284,16 @@ class ContentSeriesQueryRepositoryImpl( .limit(limit) .fetch() + val seriesPublishedDay = Expressions.enumPath(SeriesPublishedDaysOfWeek::class.java, "seriesPublishedDay") + val seriesIds = results.map { it.get(series.id)!! } + val daysMap = queryFactory + .select(series.id, seriesPublishedDay) + .from(series) + .leftJoin(series.publishedDaysOfWeek, seriesPublishedDay) + .where(series.id.`in`(seriesIds)) + .fetch() + .groupBy({ it.get(series.id)!! }, { it.get(seriesPublishedDay) }) + return results.map { row -> val sId = row.get(series.id)!! val originTitle = row.get(series.title)!! @@ -321,7 +306,7 @@ class ContentSeriesQueryRepositoryImpl( val profImg = row.get(member.profileImage) val nContent = row.get(contentCount) ?: 0L val isN = (row.get(isNewFlag) ?: 0) > 0 - val rawDays = parsePublishedDaysOfWeek(row.get(publishedDaysConcat)) + val rawDays = daysMap[sId]?.filterNotNull()?.toSet() ?: emptySet() GetSeriesListResponse.SeriesListItem( seriesId = sId, @@ -336,7 +321,7 @@ class ContentSeriesQueryRepositoryImpl( ), numberOfContent = nContent.toInt(), isNew = isN, - rawPublishedDaysOfWeek = rawDays + rawPublishedDaysOfWeek = rawDays.sortedBy { it.ordinal }.toSet() ) } } @@ -451,11 +436,6 @@ class ContentSeriesQueryRepositoryImpl( audioContent.releaseDate.between(sevenDaysAgo, now) ) val isNewFlag = isNewCase.max() - val seriesPublishedDay = Expressions.enumPath(SeriesPublishedDaysOfWeek::class.java, "seriesPublishedDay") - val publishedDaysConcat = Expressions.stringTemplate( - "function('group_concat', {0}.stringValue())", - seriesPublishedDay - ) val latestReleaseDate = audioContent.releaseDate.max() val results = queryFactory @@ -469,15 +449,13 @@ class ContentSeriesQueryRepositoryImpl( member.nickname, member.profileImage, contentCount, - isNewFlag, - publishedDaysConcat + isNewFlag ) .from(seriesContent) .innerJoin(seriesContent.series, series) .innerJoin(seriesContent.content, audioContent) .innerJoin(series.member, member) .innerJoin(series.genre, seriesGenre) - .leftJoin(series.publishedDaysOfWeek, seriesPublishedDay) .leftJoin(seriesTranslation).on(series.id.eq(seriesTranslation.seriesId), seriesTranslation.locale.eq(locale)) .where(where) .groupBy(series.id) @@ -487,6 +465,16 @@ class ContentSeriesQueryRepositoryImpl( .limit(limit) .fetch() + val seriesPublishedDay = Expressions.enumPath(SeriesPublishedDaysOfWeek::class.java, "seriesPublishedDay") + val seriesIds = results.map { it.get(series.id)!! } + val daysMap = queryFactory + .select(series.id, seriesPublishedDay) + .from(series) + .leftJoin(series.publishedDaysOfWeek, seriesPublishedDay) + .where(series.id.`in`(seriesIds)) + .fetch() + .groupBy({ it.get(series.id)!! }, { it.get(seriesPublishedDay) }) + return results.map { row -> val sId = row.get(series.id)!! val originTitle = row.get(series.title)!! @@ -499,7 +487,7 @@ class ContentSeriesQueryRepositoryImpl( val profImg = row.get(member.profileImage) val nContent = row.get(contentCount) ?: 0L val isN = (row.get(isNewFlag) ?: 0) > 0 - val rawDays = parsePublishedDaysOfWeek(row.get(publishedDaysConcat)) + val rawDays = daysMap[sId]?.filterNotNull()?.toSet() ?: emptySet() GetSeriesListResponse.SeriesListItem( seriesId = sId, @@ -514,7 +502,7 @@ class ContentSeriesQueryRepositoryImpl( ), numberOfContent = nContent.toInt(), isNew = isN, - rawPublishedDaysOfWeek = rawDays + rawPublishedDaysOfWeek = rawDays.sortedBy { it.ordinal }.toSet() ) } } @@ -623,11 +611,6 @@ class ContentSeriesQueryRepositoryImpl( audioContent.releaseDate.between(sevenDaysAgo, now) ) val isNewFlag = isNewCase.max() - val seriesPublishedDay = Expressions.enumPath(SeriesPublishedDaysOfWeek::class.java, "seriesPublishedDay") - val publishedDaysConcat = Expressions.stringTemplate( - "function('group_concat', {0}.stringValue())", - seriesPublishedDay - ) val results = queryFactory .select( @@ -640,14 +623,12 @@ class ContentSeriesQueryRepositoryImpl( member.nickname, member.profileImage, contentCount, - isNewFlag, - publishedDaysConcat + isNewFlag ) .from(series) .innerJoin(series.member, member) .innerJoin(seriesContent).on(series.id.eq(seriesContent.series.id)) .innerJoin(seriesContent.content, audioContent) - .leftJoin(series.publishedDaysOfWeek, seriesPublishedDay) .leftJoin(seriesTranslation).on(series.id.eq(seriesTranslation.seriesId), seriesTranslation.locale.eq(locale)) .where(where) .groupBy(series.id) @@ -657,6 +638,16 @@ class ContentSeriesQueryRepositoryImpl( .limit(limit) .fetch() + val seriesPublishedDay = Expressions.enumPath(SeriesPublishedDaysOfWeek::class.java, "seriesPublishedDay") + val seriesIds = results.map { it.get(series.id)!! } + val daysMap = queryFactory + .select(series.id, seriesPublishedDay) + .from(series) + .leftJoin(series.publishedDaysOfWeek, seriesPublishedDay) + .where(series.id.`in`(seriesIds)) + .fetch() + .groupBy({ it.get(series.id)!! }, { it.get(seriesPublishedDay) }) + return results.map { row -> val sId = row.get(series.id)!! val originTitle = row.get(series.title)!! @@ -669,7 +660,7 @@ class ContentSeriesQueryRepositoryImpl( val profImg = row.get(member.profileImage) val nContent = row.get(contentCount) ?: 0L val isN = (row.get(isNewFlag) ?: 0) > 0 - val rawDays = parsePublishedDaysOfWeek(row.get(publishedDaysConcat)) + val rawDays = daysMap[sId]?.filterNotNull()?.toSet() ?: emptySet() GetSeriesListResponse.SeriesListItem( seriesId = sId, @@ -684,7 +675,7 @@ class ContentSeriesQueryRepositoryImpl( ), numberOfContent = nContent.toInt(), isNew = isN, - rawPublishedDaysOfWeek = rawDays + rawPublishedDaysOfWeek = rawDays.sortedBy { it.ordinal }.toSet() ) } } @@ -733,11 +724,6 @@ class ContentSeriesQueryRepositoryImpl( audioContent.releaseDate.between(sevenDaysAgo, now) ) val isNewFlag = isNewCase.max() - val seriesPublishedDay = Expressions.enumPath(SeriesPublishedDaysOfWeek::class.java, "seriesPublishedDay") - val publishedDaysConcat = Expressions.stringTemplate( - "function('group_concat', {0}.stringValue())", - seriesPublishedDay - ) val results = queryFactory .select( @@ -750,14 +736,12 @@ class ContentSeriesQueryRepositoryImpl( member.nickname, member.profileImage, contentCount, - isNewFlag, - publishedDaysConcat + isNewFlag ) .from(series) .innerJoin(series.member, member) .innerJoin(seriesContent).on(series.id.eq(seriesContent.series.id)) .innerJoin(seriesContent.content, audioContent) - .leftJoin(series.publishedDaysOfWeek, seriesPublishedDay) .leftJoin(seriesTranslation).on(series.id.eq(seriesTranslation.seriesId), seriesTranslation.locale.eq(locale)) .where(where) .groupBy(series.id) @@ -767,6 +751,16 @@ class ContentSeriesQueryRepositoryImpl( .limit(limit) .fetch() + val seriesPublishedDay = Expressions.enumPath(SeriesPublishedDaysOfWeek::class.java, "seriesPublishedDay") + val seriesIds = results.map { it.get(series.id)!! } + val daysMap = queryFactory + .select(series.id, seriesPublishedDay) + .from(series) + .leftJoin(series.publishedDaysOfWeek, seriesPublishedDay) + .where(series.id.`in`(seriesIds)) + .fetch() + .groupBy({ it.get(series.id)!! }, { it.get(seriesPublishedDay) }) + return results.map { row -> val seriesId = row.get(series.id)!! val originTitle = row.get(series.title)!! @@ -779,7 +773,7 @@ class ContentSeriesQueryRepositoryImpl( val profileImage = row.get(member.profileImage) val numberOfContent = row.get(contentCount) ?: 0L val isNew = (row.get(isNewFlag) ?: 0) > 0 - val rawDays = parsePublishedDaysOfWeek(row.get(publishedDaysConcat)) + val rawDays = daysMap[seriesId]?.filterNotNull()?.toSet() ?: emptySet() GetSeriesListResponse.SeriesListItem( seriesId = seriesId, @@ -794,7 +788,7 @@ class ContentSeriesQueryRepositoryImpl( ), numberOfContent = numberOfContent.toInt(), isNew = isNew, - rawPublishedDaysOfWeek = rawDays + rawPublishedDaysOfWeek = rawDays.sortedBy { it.ordinal }.toSet() ) } } @@ -931,11 +925,6 @@ class ContentSeriesQueryRepositoryImpl( ) val isNewFlag = isNewCase.max() val minCurationOrder = audioContentCurationItem.orders.min() - val seriesPublishedDay = Expressions.enumPath(SeriesPublishedDaysOfWeek::class.java, "seriesPublishedDay") - val publishedDaysConcat = Expressions.stringTemplate( - "function('group_concat', {0}.stringValue())", - seriesPublishedDay - ) val results = queryFactory .select( @@ -949,7 +938,6 @@ class ContentSeriesQueryRepositoryImpl( member.profileImage, contentCount, isNewFlag, - publishedDaysConcat, minCurationOrder ) .from(audioContentCurationItem) @@ -958,7 +946,6 @@ class ContentSeriesQueryRepositoryImpl( .innerJoin(series.member, member) .innerJoin(seriesContent).on(series.id.eq(seriesContent.series.id)) .innerJoin(seriesContent.content, audioContent) - .leftJoin(series.publishedDaysOfWeek, seriesPublishedDay) .leftJoin(blockMember).on(blockMemberCondition) .leftJoin(seriesTranslation).on(series.id.eq(seriesTranslation.seriesId), seriesTranslation.locale.eq(locale)) .where(where) @@ -966,6 +953,16 @@ class ContentSeriesQueryRepositoryImpl( .orderBy(minCurationOrder.asc(), series.id.asc()) .fetch() + val seriesPublishedDay = Expressions.enumPath(SeriesPublishedDaysOfWeek::class.java, "seriesPublishedDay") + val seriesIds = results.map { it.get(series.id)!! } + val daysMap = queryFactory + .select(series.id, seriesPublishedDay) + .from(series) + .leftJoin(series.publishedDaysOfWeek, seriesPublishedDay) + .where(series.id.`in`(seriesIds)) + .fetch() + .groupBy({ it.get(series.id)!! }, { it.get(seriesPublishedDay) }) + return results.map { row -> val sId = row.get(series.id)!! val originTitle = row.get(series.title)!! @@ -978,7 +975,7 @@ class ContentSeriesQueryRepositoryImpl( val profImg = row.get(member.profileImage) val nContent = row.get(contentCount) ?: 0L val isN = (row.get(isNewFlag) ?: 0) > 0 - val rawDays = parsePublishedDaysOfWeek(row.get(publishedDaysConcat)) + val rawDays = daysMap[sId]?.filterNotNull()?.toSet() ?: emptySet() GetSeriesListResponse.SeriesListItem( seriesId = sId, @@ -993,7 +990,7 @@ class ContentSeriesQueryRepositoryImpl( ), numberOfContent = nContent.toInt(), isNew = isN, - rawPublishedDaysOfWeek = rawDays + rawPublishedDaysOfWeek = rawDays.sortedBy { it.ordinal }.toSet() ) } } @@ -1053,11 +1050,6 @@ class ContentSeriesQueryRepositoryImpl( audioContent.releaseDate.between(sevenDaysAgo, now) ) val isNewFlag = isNewCase.max() - val seriesPublishedDay = Expressions.enumPath(SeriesPublishedDaysOfWeek::class.java, "seriesPublishedDay") - val publishedDaysConcat = Expressions.stringTemplate( - "function('group_concat', {0}.stringValue())", - seriesPublishedDay - ) val results = queryFactory .select( @@ -1070,14 +1062,12 @@ class ContentSeriesQueryRepositoryImpl( member.nickname, member.profileImage, contentCount, - isNewFlag, - publishedDaysConcat + isNewFlag ) .from(series) .innerJoin(series.member, member) .innerJoin(seriesContent).on(series.id.eq(seriesContent.series.id)) .innerJoin(seriesContent.content, audioContent) - .leftJoin(series.publishedDaysOfWeek, seriesPublishedDay) .leftJoin(seriesTranslation).on(series.id.eq(seriesTranslation.seriesId), seriesTranslation.locale.eq(locale)) .where(where) .groupBy(series.id) @@ -1087,6 +1077,16 @@ class ContentSeriesQueryRepositoryImpl( .orderBy(series.createdAt.desc()) .fetch() + val seriesPublishedDay = Expressions.enumPath(SeriesPublishedDaysOfWeek::class.java, "seriesPublishedDay") + val seriesIds = results.map { it.get(series.id)!! } + val daysMap = queryFactory + .select(series.id, seriesPublishedDay) + .from(series) + .leftJoin(series.publishedDaysOfWeek, seriesPublishedDay) + .where(series.id.`in`(seriesIds)) + .fetch() + .groupBy({ it.get(series.id)!! }, { it.get(seriesPublishedDay) }) + return results.map { row -> val sId = row.get(series.id)!! val originTitle = row.get(series.title)!! @@ -1099,7 +1099,7 @@ class ContentSeriesQueryRepositoryImpl( val profImg = row.get(member.profileImage) val nContent = row.get(contentCount) ?: 0L val isN = (row.get(isNewFlag) ?: 0) > 0 - val rawDays = parsePublishedDaysOfWeek(row.get(publishedDaysConcat)) + val rawDays = daysMap[sId]?.filterNotNull()?.toSet() ?: emptySet() GetSeriesListResponse.SeriesListItem( seriesId = sId, @@ -1114,7 +1114,7 @@ class ContentSeriesQueryRepositoryImpl( ), numberOfContent = nContent.toInt(), isNew = isN, - rawPublishedDaysOfWeek = rawDays + rawPublishedDaysOfWeek = rawDays.sortedBy { it.ordinal }.toSet() ) } } From a3affbaa85f8e45f2c4f9ea274f743531bdf8b0c Mon Sep 17 00:00:00 2001 From: Klaus Date: Fri, 13 Feb 2026 18:00:18 +0900 Subject: [PATCH 15/15] =?UTF-8?q?=EC=82=AC=EC=9A=A9=ED=95=98=EC=A7=80=20?= =?UTF-8?q?=EC=95=8A=EB=8A=94=20=EC=BD=94=EB=93=9C=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../kotlin/kr/co/vividnext/sodalive/api/home/HomeService.kt | 3 --- 1 file changed, 3 deletions(-) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/api/home/HomeService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/api/home/HomeService.kt index 448cd110..6e5ceaa0 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/api/home/HomeService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/api/home/HomeService.kt @@ -2,7 +2,6 @@ package kr.co.vividnext.sodalive.api.home import kr.co.vividnext.sodalive.audition.AuditionService 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 @@ -47,8 +46,6 @@ class HomeService( private val rankingRepository: RankingRepository, private val explorerQueryRepository: ExplorerQueryRepository, - private val aiCharacterTranslationRepository: AiCharacterTranslationRepository, - private val langContext: LangContext, @Value("\${cloud.aws.cloud-front.host}")