From e690bf8aecd94f1d8f6d26e5c7cdb38e14900f77 Mon Sep 17 00:00:00 2001 From: Klaus Date: Thu, 12 Feb 2026 18:14:08 +0900 Subject: [PATCH] =?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 }