test #388

Merged
klaus merged 15 commits from test into main 2026-02-13 09:14:20 +00:00
15 changed files with 1278 additions and 598 deletions

View File

@@ -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.AudioContentMainItem
import kr.co.vividnext.sodalive.content.main.GetAudioContentRankingItem import kr.co.vividnext.sodalive.content.main.GetAudioContentRankingItem
import kr.co.vividnext.sodalive.content.main.banner.GetAudioContentBannerResponse 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.content.series.GetSeriesListResponse
import kr.co.vividnext.sodalive.event.GetEventResponse import kr.co.vividnext.sodalive.event.GetEventResponse
import kr.co.vividnext.sodalive.explorer.GetExplorerSectionCreatorResponse import kr.co.vividnext.sodalive.explorer.GetExplorerSectionCreatorResponse
@@ -27,6 +26,5 @@ data class GetHomeResponse(
val recommendChannelList: List<RecommendChannelResponse>, val recommendChannelList: List<RecommendChannelResponse>,
val freeContentList: List<AudioContentMainItem>, val freeContentList: List<AudioContentMainItem>,
val pointAvailableContentList: List<AudioContentMainItem>, val pointAvailableContentList: List<AudioContentMainItem>,
val recommendContentList: List<AudioContentMainItem>, val recommendContentList: List<AudioContentMainItem>
val curationList: List<GetContentCurationResponse>
) )

View File

@@ -1,20 +1,16 @@
package kr.co.vividnext.sodalive.api.home package kr.co.vividnext.sodalive.api.home
import kr.co.vividnext.sodalive.audition.AuditionService 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.service.ChatCharacterService
import kr.co.vividnext.sodalive.chat.character.translate.AiCharacterTranslationRepository
import kr.co.vividnext.sodalive.content.AudioContentMainItem import kr.co.vividnext.sodalive.content.AudioContentMainItem
import kr.co.vividnext.sodalive.content.AudioContentService import kr.co.vividnext.sodalive.content.AudioContentService
import kr.co.vividnext.sodalive.content.ContentType 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.GetAudioContentRankingItem
import kr.co.vividnext.sodalive.content.main.banner.AudioContentBannerService 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.ContentSeriesService
import kr.co.vividnext.sodalive.content.series.GetSeriesListResponse import kr.co.vividnext.sodalive.content.series.GetSeriesListResponse
import kr.co.vividnext.sodalive.content.series.translation.SeriesTranslationRepository
import kr.co.vividnext.sodalive.content.theme.AudioContentThemeService import kr.co.vividnext.sodalive.content.theme.AudioContentThemeService
import kr.co.vividnext.sodalive.content.translation.ContentTranslationRepository
import kr.co.vividnext.sodalive.creator.admin.content.series.SeriesPublishedDaysOfWeek import kr.co.vividnext.sodalive.creator.admin.content.series.SeriesPublishedDaysOfWeek
import kr.co.vividnext.sodalive.event.GetEventResponse import kr.co.vividnext.sodalive.event.GetEventResponse
import kr.co.vividnext.sodalive.explorer.ExplorerQueryRepository import kr.co.vividnext.sodalive.explorer.ExplorerQueryRepository
@@ -22,7 +18,6 @@ import kr.co.vividnext.sodalive.i18n.LangContext
import kr.co.vividnext.sodalive.live.room.LiveRoomService import kr.co.vividnext.sodalive.live.room.LiveRoomService
import kr.co.vividnext.sodalive.live.room.LiveRoomStatus import kr.co.vividnext.sodalive.live.room.LiveRoomStatus
import kr.co.vividnext.sodalive.member.Member 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.query.recommend.RecommendChannelQueryService
import kr.co.vividnext.sodalive.rank.ContentRankingSortType import kr.co.vividnext.sodalive.rank.ContentRankingSortType
import kr.co.vividnext.sodalive.rank.RankingRepository import kr.co.vividnext.sodalive.rank.RankingRepository
@@ -34,16 +29,15 @@ import java.time.DayOfWeek
import java.time.LocalDateTime import java.time.LocalDateTime
import java.time.ZoneId import java.time.ZoneId
import java.time.temporal.TemporalAdjusters import java.time.temporal.TemporalAdjusters
import kotlin.math.pow
@Service @Service
class HomeService( class HomeService(
private val memberService: MemberService,
private val liveRoomService: LiveRoomService, private val liveRoomService: LiveRoomService,
private val auditionService: AuditionService, private val auditionService: AuditionService,
private val seriesService: ContentSeriesService, private val seriesService: ContentSeriesService,
private val contentService: AudioContentService, private val contentService: AudioContentService,
private val bannerService: AudioContentBannerService, private val bannerService: AudioContentBannerService,
private val curationService: AudioContentCurationService,
private val contentThemeService: AudioContentThemeService, private val contentThemeService: AudioContentThemeService,
private val recommendChannelService: RecommendChannelQueryService, private val recommendChannelService: RecommendChannelQueryService,
@@ -52,10 +46,6 @@ class HomeService(
private val rankingRepository: RankingRepository, private val rankingRepository: RankingRepository,
private val explorerQueryRepository: ExplorerQueryRepository, private val explorerQueryRepository: ExplorerQueryRepository,
private val contentTranslationRepository: ContentTranslationRepository,
private val aiCharacterTranslationRepository: AiCharacterTranslationRepository,
private val seriesTranslationRepository: SeriesTranslationRepository,
private val langContext: LangContext, private val langContext: LangContext,
@Value("\${cloud.aws.cloud-front.host}") @Value("\${cloud.aws.cloud-front.host}")
@@ -64,8 +54,19 @@ class HomeService(
companion object { companion object {
private const val RECOMMEND_TARGET_SIZE = 30 private const val RECOMMEND_TARGET_SIZE = 30
private const val RECOMMEND_MAX_ATTEMPTS = 3 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( fun fetchData(
timezone: String, timezone: String,
isAdultContentVisible: Boolean, isAdultContentVisible: Boolean,
@@ -84,24 +85,19 @@ class HomeService(
timezone = timezone timezone = timezone
) )
val creatorRanking = rankingRepository val creatorRankingMembers = rankingRepository.getCreatorRankings(memberId = memberId)
.getCreatorRankings() val creatorRankingIds = creatorRankingMembers.mapNotNull { it.id }
.filter { val followedCreatorIds = if (memberId != null) {
if (memberId != null) { explorerQueryRepository.getFollowedCreatorIds(creatorRankingIds, memberId)
!memberService.isBlocked(blockedMemberId = memberId, memberId = it.id!!)
} else { } else {
true emptySet()
}
}
.map {
val followerCount = explorerQueryRepository.getNotificationUserIds(it.id!!).size
val follow = if (memberId != null) {
explorerQueryRepository.isFollow(it.id!!, memberId = memberId)
} else {
false
} }
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( val latestContentThemeList = contentThemeService.getActiveThemeOfContent(
@@ -111,19 +107,12 @@ class HomeService(
) )
val latestContentList = contentService.getLatestContentByTheme( val latestContentList = contentService.getLatestContentByTheme(
memberId = memberId,
theme = latestContentThemeList, theme = latestContentThemeList,
contentType = contentType, contentType = contentType,
isFree = false, isFree = false,
isAdult = isAdult isAdult = isAdult
).filter { )
if (memberId != null) {
!memberService.isBlocked(blockedMemberId = memberId, memberId = it.creatorId)
} else {
true
}
}
val translatedLatestContentList = getTranslatedContentList(contentList = latestContentList)
val eventBannerList = GetEventResponse( val eventBannerList = GetEventResponse(
totalCount = 0, totalCount = 0,
@@ -136,28 +125,23 @@ class HomeService(
isAdult = isAdult isAdult = isAdult
) )
// 오직 보이스온에서만
val originalAudioDramaList = seriesService.getOriginalAudioDramaList( val originalAudioDramaList = seriesService.getOriginalAudioDramaList(
isAdult = isAdult, isAdult = isAdult,
contentType = contentType, contentType = contentType
orderByRandom = true
) )
val translatedOriginalAudioDramaList = getTranslatedSeriesList(seriesList = originalAudioDramaList)
val auditionList = auditionService.getInProgressAuditionList(isAdult = isAdult) val auditionList = auditionService.getInProgressAuditionList(isAdult = isAdult)
// 요일별 시리즈 // 요일별 시리즈
val dayOfWeekSeriesList = seriesService.getDayOfWeekSeriesList( val translatedDayOfWeekSeriesList = seriesService.getDayOfWeekSeriesList(
memberId = memberId, memberId = memberId,
isAdult = isAdult, isAdult = isAdult,
contentType = contentType, contentType = contentType,
dayOfWeek = getDayOfWeekByTimezone(timezone) dayOfWeek = getDayOfWeekByTimezone(timezone)
) )
val translatedDayOfWeekSeriesList = getTranslatedSeriesList(seriesList = dayOfWeekSeriesList)
// 인기 캐릭터 조회 // 인기 캐릭터 조회
val translatedPopularCharacters = getTranslatedAiCharacterList(aiCharacterList = characterService.getPopularCharacters()) val translatedPopularCharacters = characterService.getPopularCharacters(locale = langContext.lang.code)
val currentDateTime = LocalDateTime.now() val currentDateTime = LocalDateTime.now()
val startDate = currentDateTime val startDate = currentDateTime
@@ -178,130 +162,61 @@ class HomeService(
sort = ContentRankingSortType.REVENUE sort = ContentRankingSortType.REVENUE
) )
val contentRankingContentIds = contentRanking.map { it.contentId }
val translatedContentRanking = if (contentRankingContentIds.isNotEmpty()) {
val translations = contentTranslationRepository
.findByContentIdInAndLocale(contentIds = contentRankingContentIds, locale = langContext.lang.code)
.associateBy { it.contentId }
contentRanking.map { item ->
val translatedTitle = translations[item.contentId]?.renderedPayload?.title
if (translatedTitle.isNullOrBlank()) {
item
} else {
item.copy(title = translatedTitle)
}
}
} else {
contentRanking
}
val recommendChannelList = recommendChannelService.getRecommendChannel( val recommendChannelList = recommendChannelService.getRecommendChannel(
memberId = memberId, memberId = memberId,
isAdult = isAdult, isAdult = isAdult,
contentType = contentType contentType = contentType
) )
/** val freeContentList = getRandomizedContentList(
* recommendChannelList의 콘텐츠 번역 데이터 조회 memberId = memberId,
* isAdult = isAdult,
* languageCode != null contentType = contentType,
* contentTranslationRepository를 이용해 번역 콘텐츠를 조회한다. - contentId, locale
*
* 한 번에 조회하고 contentId를 매핑하여 recommendChannelList의 콘텐츠 title을 번역 데이터로 변경한다
*/
val channelContentIds = recommendChannelList
.flatMap { it.contentList }
.map { it.contentId }
.distinct()
val translatedRecommendChannelList = if (channelContentIds.isNotEmpty()) {
val translations = contentTranslationRepository
.findByContentIdInAndLocale(contentIds = channelContentIds, locale = langContext.lang.code)
.associateBy { it.contentId }
recommendChannelList.map { channel ->
val translatedContentList = channel.contentList.map { item ->
val translatedTitle = translations[item.contentId]?.renderedPayload?.title
if (translatedTitle.isNullOrBlank()) {
item
} else {
item.copy(title = translatedTitle)
}
}
channel.copy(contentList = translatedContentList)
}
} else {
recommendChannelList
}
val freeContentList = contentService.getLatestContentByTheme(
theme = contentThemeService.getActiveThemeOfContent( theme = contentThemeService.getActiveThemeOfContent(
isAdult = isAdult, isAdult = isAdult,
isFree = true, isFree = true,
contentType = contentType contentType = contentType
), ),
contentType = contentType,
isFree = true, isFree = true,
isAdult = isAdult, isPointAvailableOnly = false
orderByRandom = true )
).filter {
if (memberId != null) {
!memberService.isBlocked(blockedMemberId = memberId, memberId = it.creatorId)
} else {
true
}
}
val translatedFreeContentList = getTranslatedContentList(contentList = freeContentList)
// 포인트 사용가능 콘텐츠 리스트 - 랜덤으로 가져오기 (DB에서 isPointAvailable 조건 적용) // 포인트 사용가능 콘텐츠 리스트 - 랜덤으로 가져오기 (DB에서 isPointAvailable 조건 적용)
val pointAvailableContentList = contentService.getLatestContentByTheme( val pointAvailableContentList = getRandomizedContentList(
memberId = memberId,
isAdult = isAdult,
contentType = contentType,
theme = emptyList(), theme = emptyList(),
contentType = contentType,
isFree = false, isFree = false,
isAdult = isAdult,
orderByRandom = true,
isPointAvailableOnly = true isPointAvailableOnly = true
).filter {
if (memberId != null) {
!memberService.isBlocked(blockedMemberId = memberId, memberId = it.creatorId)
} else {
true
}
}
val translatedPointAvailableContentList = getTranslatedContentList(contentList = pointAvailableContentList)
val curationList = curationService.getContentCurationList(
tabId = 3L, // 기존에 사용하던 단편 탭의 큐레이션을 사용
isAdult = isAdult,
contentType = contentType,
memberId = memberId
) )
val excludeContentIds = (
latestContentList.map { it.contentId } +
contentRanking.map { it.contentId }
).distinct()
return GetHomeResponse( return GetHomeResponse(
liveList = liveList, liveList = liveList,
creatorRanking = creatorRanking, creatorRanking = creatorRanking,
latestContentThemeList = latestContentThemeList, latestContentThemeList = latestContentThemeList,
latestContentList = translatedLatestContentList, latestContentList = latestContentList,
bannerList = bannerList, bannerList = bannerList,
eventBannerList = eventBannerList, eventBannerList = eventBannerList,
originalAudioDramaList = translatedOriginalAudioDramaList, originalAudioDramaList = originalAudioDramaList,
auditionList = auditionList, auditionList = auditionList,
dayOfWeekSeriesList = translatedDayOfWeekSeriesList, dayOfWeekSeriesList = translatedDayOfWeekSeriesList,
popularCharacters = translatedPopularCharacters, popularCharacters = translatedPopularCharacters,
contentRanking = translatedContentRanking, contentRanking = contentRanking,
recommendChannelList = translatedRecommendChannelList, recommendChannelList = recommendChannelList,
freeContentList = translatedFreeContentList, freeContentList = freeContentList,
pointAvailableContentList = translatedPointAvailableContentList, pointAvailableContentList = pointAvailableContentList,
recommendContentList = getRecommendContentList( recommendContentList = getRecommendContentList(
isAdultContentVisible = isAdultContentVisible, isAdultContentVisible = isAdultContentVisible,
contentType = contentType, contentType = contentType,
member = member member = member,
), excludeContentIds = excludeContentIds
curationList = curationList )
) )
} }
@@ -325,20 +240,13 @@ class HomeService(
listOf(theme) listOf(theme)
} }
val contentList = contentService.getLatestContentByTheme( return contentService.getLatestContentByTheme(
memberId = memberId,
theme = themeList, theme = themeList,
contentType = contentType, contentType = contentType,
isFree = false, isFree = false,
isAdult = isAdult isAdult = isAdult
).filter { )
if (memberId != null) {
!memberService.isBlocked(blockedMemberId = memberId, memberId = it.creatorId)
} else {
true
}
}
return getTranslatedContentList(contentList = contentList)
} }
fun getDayOfWeekSeriesList( fun getDayOfWeekSeriesList(
@@ -350,14 +258,12 @@ class HomeService(
val memberId = member?.id val memberId = member?.id
val isAdult = member?.auth != null && isAdultContentVisible val isAdult = member?.auth != null && isAdultContentVisible
val dayOfWeekSeriesList = seriesService.getDayOfWeekSeriesList( return seriesService.getDayOfWeekSeriesList(
memberId = memberId, memberId = memberId,
isAdult = isAdult, isAdult = isAdult,
contentType = contentType, contentType = contentType,
dayOfWeek = dayOfWeek dayOfWeek = dayOfWeek
) )
return getTranslatedSeriesList(seriesList = dayOfWeekSeriesList)
} }
fun getContentRankingBySort( fun getContentRankingBySort(
@@ -412,153 +318,155 @@ class HomeService(
return dayToSeriesPublishedDaysOfWeek[zonedDateTime.dayOfWeek] ?: SeriesPublishedDaysOfWeek.RANDOM return dayToSeriesPublishedDaysOfWeek[zonedDateTime.dayOfWeek] ?: SeriesPublishedDaysOfWeek.RANDOM
} }
// 추천 콘텐츠 조회 로직은 변경 가능성을 고려하여 별도 메서드로 추출한다.
fun getRecommendContentList( fun getRecommendContentList(
isAdultContentVisible: Boolean, isAdultContentVisible: Boolean,
contentType: ContentType, contentType: ContentType,
member: Member? member: Member?,
excludeContentIds: List<Long> = emptyList()
): List<AudioContentMainItem> { ): List<AudioContentMainItem> {
val memberId = member?.id val memberId = member?.id
val isAdult = member?.auth != null && isAdultContentVisible val isAdult = member?.auth != null && isAdultContentVisible
// Set + List 조합으로 중복 제거 및 순서 보존, 각 시도마다 limit=60으로 조회 // 3개의 버킷(최근/중간/과거)에서 후보군을 조회한 뒤, 시간감쇠 점수 기반으로 샘플링한다.
val seen = HashSet<Long>(RECOMMEND_TARGET_SIZE * 2) val buckets = listOf(
val result = ArrayList<AudioContentMainItem>(RECOMMEND_TARGET_SIZE) RecommendBucket(offset = 0L, limit = 50L),
var attempt = 0 RecommendBucket(offset = 50L, limit = 100L),
while (attempt < RECOMMEND_MAX_ATTEMPTS && result.size < RECOMMEND_TARGET_SIZE) { RecommendBucket(offset = 150L, limit = 150L)
attempt += 1 )
val result = mutableListOf<AudioContentMainItem>()
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
val batch = contentService.getLatestContentByTheme( val batch = contentService.getLatestContentByTheme(
theme = emptyList(), // 특정 테마에 종속되지 않도록 전체에서 랜덤 조회 memberId = memberId,
theme = emptyList(),
contentType = contentType, contentType = contentType,
offset = 0, offset = bucket.offset,
limit = (RECOMMEND_TARGET_SIZE * RECOMMEND_MAX_ATTEMPTS).toLong(), // 60개 조회 limit = bucket.limit,
sortType = SortType.NEWEST,
isFree = false, isFree = false,
isAdult = isAdult, isAdult = isAdult,
orderByRandom = true orderByRandom = false,
).filter { excludeContentIds = seenIds.toList()
if (memberId != null) { )
!memberService.isBlocked(blockedMemberId = memberId, memberId = it.creatorId)
} else {
true
}
}
for (item in batch) { val selected = pickByTimeDecay(
if (result.size >= RECOMMEND_TARGET_SIZE) break batch = batch,
if (seen.add(item.contentId)) { targetSize = minOf(targetPerBucket, RECOMMEND_TARGET_SIZE - result.size),
result.add(item) seenIds = seenIds
)
if (selected.isNotEmpty()) {
result.addAll(selected)
} }
} }
} }
return getTranslatedContentList(contentList = result) return result.take(RECOMMEND_TARGET_SIZE).shuffled()
} }
/** private fun pickByTimeDecay(
* 콘텐츠 리스트의 제목을 현재 언어(locale)에 맞춰 일괄 번역한다. batch: List<AudioContentMainItem>,
* targetSize: Int,
* 처리 절차: seenIds: MutableSet<Long>
* - 입력된 콘텐츠들의 contentId 집합을 만들고, 요청 언어 코드(langContext.lang.code)로 ): List<AudioContentMainItem> {
* contentTranslationRepository에서 번역 데이터를 한 번에 조회한다. if (targetSize <= 0 || batch.isEmpty()) return emptyList()
* - 각 항목에 대해 번역된 제목이 존재하고 비어있지 않으면 title만 번역 값으로 교체한다.
* - 번역이 없거나 공백이면 원본 항목을 그대로 반환한다.
*
* 성능:
* - N건의 항목을 1회의 조회로 해결하기 위해 IN 쿼리를 사용한다.
*
* @param contentList 번역 대상 AudioContentMainItem 목록
* @return 제목이 가능한 항목은 번역된 목록(불변 사본), 그 외는 원본 항목 유지
*/
private fun getTranslatedContentList(contentList: List<AudioContentMainItem>): List<AudioContentMainItem> {
val contentIds = contentList.map { it.contentId }
return if (contentIds.isNotEmpty()) { val candidates = batch
val translations = contentTranslationRepository .asSequence()
.findByContentIdInAndLocale(contentIds = contentIds, locale = langContext.lang.code) .filterNot { seenIds.contains(it.contentId) }
.associateBy { it.contentId } .mapIndexed { index, item -> DecayCandidate(item = item, rank = index) }
.toMutableList()
contentList.map { item -> if (candidates.isEmpty()) return emptyList()
val translatedTitle = translations[item.contentId]?.renderedPayload?.title
if (translatedTitle.isNullOrBlank()) { val selected = mutableListOf<AudioContentMainItem>()
item while (selected.size < targetSize && candidates.isNotEmpty()) {
} else { val selectedIndex = selectByWeight(candidates)
item.copy(title = translatedTitle) val chosen = candidates.removeAt(selectedIndex).item
} if (seenIds.add(chosen.contentId)) {
} selected.add(chosen)
} else {
contentList
} }
} }
/** return selected
* 시리즈 리스트의 제목을 현재 언어(locale)에 맞춰 일괄 번역한다.
*
* 처리 절차:
* - 입력된 시리즈들의 seriesId 집합을 만들고, 요청 언어 코드(langContext.lang.code)로
* seriesTranslationRepository에서 번역 데이터를 한 번에 조회한다.
* - 각 항목에 대해 번역된 제목이 존재하고 비어있지 않으면 title만 번역 값으로 교체한다.
* - 번역이 없거나 공백이면 원본 항목을 그대로 반환한다.
*
* 성능:
* - N건의 항목을 1회의 조회로 해결하기 위해 IN 쿼리를 사용한다.
*
* @param seriesList 번역 대상 SeriesListItem 목록
* @return 제목이 가능한 항목은 번역된 목록(불변 사본), 그 외는 원본 항목 유지
*/
private fun getTranslatedSeriesList(
seriesList: List<GetSeriesListResponse.SeriesListItem>
): List<GetSeriesListResponse.SeriesListItem> {
val seriesIds = seriesList.map { it.seriesId }
return if (seriesIds.isNotEmpty()) {
val translations = seriesTranslationRepository
.findBySeriesIdInAndLocale(seriesIds = seriesIds, locale = langContext.lang.code)
.associateBy { it.seriesId }
seriesList.map { item ->
val translatedTitle = translations[item.seriesId]?.renderedPayload?.title
if (translatedTitle.isNullOrBlank()) {
item
} else {
item.copy(title = translatedTitle)
} }
}
} else { private fun selectByWeight(candidates: List<DecayCandidate>): Int {
seriesList 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
* AI 캐릭터 리스트의 이름/설명을 현재 언어(locale)에 맞춰 일괄 번역한다. }
*
* 처리 절차:
* - characterId 목록을 추출하고, 요청 언어 코드로 aiCharacterTranslationRepository에서
* 번역 데이터를 한 번에 조회한다.
* - 각 캐릭터에 대해 name과 description 모두 번역 값이 존재하고 비어있지 않을 때에만
* 해당 필드를 교체한다. 둘 중 하나라도 없으면 원본 캐릭터를 그대로 유지한다.
*
* @param aiCharacterList 번역 대상 캐릭터 목록
* @return 가능한 경우 name/description이 번역된 캐릭터 목록, 그 외는 원본 유지
*/
private fun getTranslatedAiCharacterList(aiCharacterList: List<Character>): List<Character> {
val characterIds = aiCharacterList.map { it.characterId }
return if (characterIds.isNotEmpty()) { private fun getRandomizedContentList(
val translations = aiCharacterTranslationRepository memberId: Long?,
.findByCharacterIdInAndLocale(characterIds = characterIds, locale = langContext.lang.code) isAdult: Boolean,
.associateBy { it.characterId } contentType: ContentType,
theme: List<String>,
isFree: Boolean,
isPointAvailableOnly: Boolean,
targetSize: Int = 20
): List<AudioContentMainItem> {
val buckets = listOf(
RecommendBucket(offset = 0L, limit = 50L),
RecommendBucket(offset = 50L, limit = 100L),
RecommendBucket(offset = 150L, limit = 150L)
)
aiCharacterList.map { character -> val result = mutableListOf<AudioContentMainItem>()
val translatedName = translations[character.characterId]?.renderedPayload?.name val seenIds = mutableSetOf<Long>()
val translatedDesc = translations[character.characterId]?.renderedPayload?.description
if (translatedName.isNullOrBlank() || translatedDesc.isNullOrBlank()) { repeat(RECOMMEND_MAX_ATTEMPTS) {
character if (result.size >= targetSize) return@repeat
} else {
character.copy(name = translatedName, description = translatedDesc) 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)
} }
} }
} else {
aiCharacterList
} }
return result.take(targetSize).shuffled()
} }
} }

View File

@@ -100,7 +100,7 @@ class ChatCharacterController(
} }
// 인기 캐릭터 조회 // 인기 캐릭터 조회
val popularCharacters = service.getPopularCharacters() val popularCharacters = service.getPopularCharacters(locale = langContext.lang.code)
// 최근 등록된 캐릭터 리스트 조회 // 최근 등록된 캐릭터 리스트 조회
val newCharacters = service.getRecentCharactersPage( val newCharacters = service.getRecentCharactersPage(
@@ -138,7 +138,7 @@ class ChatCharacterController(
CharacterMainResponse( CharacterMainResponse(
banners = banners, banners = banners,
recentCharacters = translatedRecentCharacters, recentCharacters = translatedRecentCharacters,
popularCharacters = getTranslatedAiCharacterList(popularCharacters), popularCharacters = popularCharacters,
newCharacters = getTranslatedAiCharacterList(newCharacters), newCharacters = getTranslatedAiCharacterList(newCharacters),
recommendCharacters = getTranslatedAiCharacterList(recommendCharacters), recommendCharacters = getTranslatedAiCharacterList(recommendCharacters),
curationSections = curationSections curationSections = curationSections

View File

@@ -77,18 +77,23 @@ class ChatCharacterService(
*/ */
@Transactional(readOnly = true) @Transactional(readOnly = true)
@Cacheable( @Cacheable(
cacheNames = ["popularCharacters_24h"], cacheNames = ["popularCharacters_24h_locale"],
key = "T(kr.co.vividnext.sodalive.chat.character.service.RankingWindowCalculator).now('popular-character').cacheKey" key = "T(kr.co.vividnext.sodalive.chat.character.service.RankingWindowCalculator)" +
".now('popular-character').cacheKey + '-' + #locale"
) )
fun getPopularCharacters(limit: Long = 20): List<Character> { fun getPopularCharacters(locale: String, limit: Long = 20): List<Character> {
val window = RankingWindowCalculator.now("popular-character") val window = RankingWindowCalculator.now("popular-character")
val topIds = popularCharacterQuery.findPopularCharacterIds(window.windowStart, window.nextBoundary, limit) val results = popularCharacterQuery.findPopularCharactersWithTranslation(
val list = loadCharactersInOrder(topIds) window.windowStart,
window.nextBoundary,
limit,
locale
)
val recentSet = if (list.isNotEmpty()) { val recentSet = if (results.isNotEmpty()) {
imageRepository imageRepository
.findCharacterIdsWithRecentImages( .findCharacterIdsWithRecentImages(
list.map { it.id!! }, results.map { it.id },
LocalDateTime.now().minusDays(3) LocalDateTime.now().minusDays(3)
) )
.toSet() .toSet()
@@ -96,11 +101,11 @@ class ChatCharacterService(
emptySet() emptySet()
} }
return list.map { return results.map {
Character( Character(
characterId = it.id!!, characterId = it.id,
name = it.name, name = it.translatedPayload?.name.takeIf { name -> !name.isNullOrBlank() } ?: it.name,
description = it.description, description = it.translatedPayload?.description.takeIf { desc -> !desc.isNullOrBlank() } ?: it.description,
imageUrl = "$imageHost/${it.imagePath ?: "profile/default-profile.png"}", imageUrl = "$imageHost/${it.imagePath ?: "profile/default-profile.png"}",
new = recentSet.contains(it.id) new = recentSet.contains(it.id)
) )

View File

@@ -1,7 +1,10 @@
package kr.co.vividnext.sodalive.chat.character.service package kr.co.vividnext.sodalive.chat.character.service
import com.querydsl.core.types.Projections
import com.querydsl.jpa.impl.JPAQueryFactory import com.querydsl.jpa.impl.JPAQueryFactory
import kr.co.vividnext.sodalive.chat.character.QChatCharacter 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.ParticipantType
import kr.co.vividnext.sodalive.chat.room.QChatMessage import kr.co.vividnext.sodalive.chat.room.QChatMessage
import kr.co.vividnext.sodalive.chat.room.QChatParticipant import kr.co.vividnext.sodalive.chat.room.QChatParticipant
@@ -51,4 +54,56 @@ class PopularCharacterQuery(
.limit(limit) .limit(limit)
.fetch() .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<PopularCharacterQueryResult> {
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()
}
} }

View File

@@ -25,6 +25,7 @@ import kr.co.vividnext.sodalive.content.pin.QPinContent.pinContent
import kr.co.vividnext.sodalive.content.playlist.AudioContentPlaylistContent import kr.co.vividnext.sodalive.content.playlist.AudioContentPlaylistContent
import kr.co.vividnext.sodalive.content.playlist.QAudioContentPlaylistContent import kr.co.vividnext.sodalive.content.playlist.QAudioContentPlaylistContent
import kr.co.vividnext.sodalive.content.theme.QAudioContentTheme.audioContentTheme import kr.co.vividnext.sodalive.content.theme.QAudioContentTheme.audioContentTheme
import kr.co.vividnext.sodalive.content.translation.QContentTranslation.contentTranslation
import kr.co.vividnext.sodalive.creator.admin.content.series.QSeries.series import kr.co.vividnext.sodalive.creator.admin.content.series.QSeries.series
import kr.co.vividnext.sodalive.creator.admin.content.series.QSeriesContent.seriesContent import kr.co.vividnext.sodalive.creator.admin.content.series.QSeriesContent.seriesContent
import kr.co.vividnext.sodalive.event.QEvent.event import kr.co.vividnext.sodalive.event.QEvent.event
@@ -178,6 +179,7 @@ interface AudioContentQueryRepository {
fun findContentIdAndHashTagId(contentId: Long, hashTagId: Int): AudioContentHashTag? fun findContentIdAndHashTagId(contentId: Long, hashTagId: Int): AudioContentHashTag?
fun getLatestContentByTheme( fun getLatestContentByTheme(
memberId: Long? = null,
theme: List<String>, theme: List<String>,
contentType: ContentType, contentType: ContentType,
offset: Long, offset: Long,
@@ -186,7 +188,9 @@ interface AudioContentQueryRepository {
isFree: Boolean, isFree: Boolean,
isAdult: Boolean, isAdult: Boolean,
orderByRandom: Boolean = false, orderByRandom: Boolean = false,
isPointAvailableOnly: Boolean = false isPointAvailableOnly: Boolean = false,
excludeContentIds: List<Long> = emptyList(),
locale: String? = null
): List<AudioContentMainItem> ): List<AudioContentMainItem>
fun findContentByCurationId( fun findContentByCurationId(
@@ -1319,6 +1323,7 @@ class AudioContentQueryRepositoryImpl(
} }
override fun getLatestContentByTheme( override fun getLatestContentByTheme(
memberId: Long?,
theme: List<String>, theme: List<String>,
contentType: ContentType, contentType: ContentType,
offset: Long, offset: Long,
@@ -1327,8 +1332,18 @@ class AudioContentQueryRepositoryImpl(
isFree: Boolean, isFree: Boolean,
isAdult: Boolean, isAdult: Boolean,
orderByRandom: Boolean, orderByRandom: Boolean,
isPointAvailableOnly: Boolean isPointAvailableOnly: Boolean,
excludeContentIds: List<Long>,
locale: String?
): List<AudioContentMainItem> { ): List<AudioContentMainItem> {
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 var where = audioContent.isActive.isTrue
.and(audioContent.duration.isNotNull) .and(audioContent.duration.isNotNull)
.and( .and(
@@ -1366,6 +1381,31 @@ class AudioContentQueryRepositoryImpl(
where = where.and(audioContent.isPointAvailable.isTrue) where = where.and(audioContent.isPointAvailable.isTrue)
} }
if (excludeContentIds.isNotEmpty()) {
where = where.and(audioContent.id.notIn(excludeContentIds))
}
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) { val orderBy = if (orderByRandom) {
Expressions.numberTemplate(Double::class.java, "function('rand')").asc() Expressions.numberTemplate(Double::class.java, "function('rand')").asc()
} else { } else {
@@ -1387,20 +1427,7 @@ class AudioContentQueryRepositoryImpl(
} }
} }
return queryFactory return select
.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)
.where(where) .where(where)
.offset(offset) .offset(offset)
.limit(limit) .limit(limit)
@@ -1408,6 +1435,78 @@ class AudioContentQueryRepositoryImpl(
.fetch() .fetch()
} }
val coverImageUrl = audioContent.coverImage.prepend("/").prepend(imageHost)
var select = queryFactory
.select(
audioContent.id,
member.id,
audioContent.title,
contentTranslation.renderedPayload,
coverImageUrl,
member.nickname,
audioContent.isPointAvailable
)
.from(audioContent)
.innerJoin(audioContent.member, member)
.innerJoin(audioContent.theme, audioContentTheme)
.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)
}
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()
}
}
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( override fun findContentByCurationId(
curationId: Long, curationId: Long,
isAdult: Boolean, isAdult: Boolean,

View File

@@ -1198,6 +1198,7 @@ class AudioContentService(
@Transactional(readOnly = true) @Transactional(readOnly = true)
fun getLatestContentByTheme( fun getLatestContentByTheme(
memberId: Long? = null,
theme: List<String>, theme: List<String>,
contentType: ContentType, contentType: ContentType,
offset: Long = 0, offset: Long = 0,
@@ -1206,7 +1207,8 @@ class AudioContentService(
isFree: Boolean = false, isFree: Boolean = false,
isAdult: Boolean = false, isAdult: Boolean = false,
orderByRandom: Boolean = false, orderByRandom: Boolean = false,
isPointAvailableOnly: Boolean = false isPointAvailableOnly: Boolean = false,
excludeContentIds: List<Long> = emptyList()
): List<AudioContentMainItem> { ): List<AudioContentMainItem> {
/** /**
* - AS-IS theme은 한글만 처리하도록 되어 있음 * - AS-IS theme은 한글만 처리하도록 되어 있음
@@ -1220,7 +1222,8 @@ class AudioContentService(
isPointAvailableOnly = isPointAvailableOnly isPointAvailableOnly = isPointAvailableOnly
) )
val contentList = repository.getLatestContentByTheme( return repository.getLatestContentByTheme(
memberId = memberId,
theme = normalizedTheme, theme = normalizedTheme,
contentType = contentType, contentType = contentType,
offset = offset, offset = offset,
@@ -1229,26 +1232,10 @@ class AudioContentService(
isFree = isFree, isFree = isFree,
isAdult = isAdult, isAdult = isAdult,
orderByRandom = orderByRandom, orderByRandom = orderByRandom,
isPointAvailableOnly = isPointAvailableOnly isPointAvailableOnly = isPointAvailableOnly,
excludeContentIds = excludeContentIds,
locale = langContext.lang.code
) )
val contentIds = contentList.map { it.contentId }
return if (contentIds.isNotEmpty()) {
val translations = contentTranslationRepository
.findByContentIdInAndLocale(contentIds = contentIds, locale = langContext.lang.code)
.associateBy { it.contentId }
contentList.map { item ->
val translatedTitle = translations[item.contentId]?.renderedPayload?.title
if (translatedTitle.isNullOrBlank()) {
item
} else {
item.copy(title = translatedTitle)
}
}
} else {
contentList
}
} }
/** /**

View File

@@ -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.SeriesTranslationRepository
import kr.co.vividnext.sodalive.content.series.translation.TranslatedSeries import kr.co.vividnext.sodalive.content.series.translation.TranslatedSeries
import kr.co.vividnext.sodalive.content.translation.ContentTranslationRepository 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.SeriesPublishedDaysOfWeek
import kr.co.vividnext.sodalive.creator.admin.content.series.SeriesSortType 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.explorer.ExplorerQueryRepository
import kr.co.vividnext.sodalive.i18n.Lang import kr.co.vividnext.sodalive.i18n.Lang
import kr.co.vividnext.sodalive.i18n.LangContext 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.beans.factory.annotation.Value
import org.springframework.stereotype.Service import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional import org.springframework.transaction.annotation.Transactional
import java.time.LocalDateTime
import java.time.ZoneId import java.time.ZoneId
import java.time.format.DateTimeFormatter import java.time.format.DateTimeFormatter
@@ -55,12 +52,21 @@ class ContentSeriesService(
fun getOriginalAudioDramaList( fun getOriginalAudioDramaList(
isAdult: Boolean, isAdult: Boolean,
contentType: ContentType, contentType: ContentType,
orderByRandom: Boolean = false,
offset: Long = 0, offset: Long = 0,
limit: Long = 20 limit: Long = 20
): List<GetSeriesListResponse.SeriesListItem> { ): List<GetSeriesListResponse.SeriesListItem> {
val originalAudioDramaList = repository.getOriginalAudioDramaList(isAdult, contentType, orderByRandom, offset, limit) val originalAudioDramaList = repository.getOriginalAudioDramaList(
return getTranslatedSeriesList(seriesToSeriesListItem(originalAudioDramaList, isAdult, contentType)) 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<GetSeriesGenreListResponse> { fun getGenreList(memberId: Long, isAdult: Boolean, contentType: ContentType): List<GetSeriesGenreListResponse> {
@@ -151,10 +157,11 @@ class ContentSeriesService(
isAuth = isAuth, isAuth = isAuth,
contentType = contentType, contentType = contentType,
isOriginal = isOriginal, isOriginal = isOriginal,
isCompleted = isCompleted isCompleted = isCompleted,
memberId = member.id
) )
val rawItems = repository.getSeriesList( val items = repository.getSeriesListV2(
imageHost = coverImageHost, imageHost = coverImageHost,
creatorId = creatorId, creatorId = creatorId,
isAuth = isAuth, isAuth = isAuth,
@@ -163,11 +170,14 @@ class ContentSeriesService(
isCompleted = isCompleted, isCompleted = isCompleted,
orderByRandom = orderByRandom, orderByRandom = orderByRandom,
offset = offset, offset = offset,
limit = limit limit = limit,
).filter { !blockMemberRepository.isBlocked(blockedMemberId = member.id!!, memberId = it.member!!.id!!) } 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, items)
return GetSeriesListResponse(totalCount, getTranslatedSeriesList(items))
} }
fun getSeriesListByGenre( fun getSeriesListByGenre(
@@ -183,20 +193,24 @@ class ContentSeriesService(
val totalCount = repository.getSeriesByGenreTotalCount( val totalCount = repository.getSeriesByGenreTotalCount(
genreId = genreId, genreId = genreId,
isAuth = isAuth, isAuth = isAuth,
contentType = contentType contentType = contentType,
memberId = member.id
) )
val rawItems = repository.getSeriesByGenreList( val items = repository.getSeriesByGenreListV2(
imageHost = coverImageHost, imageHost = coverImageHost,
genreId = genreId, genreId = genreId,
isAuth = isAuth, isAuth = isAuth,
contentType = contentType, contentType = contentType,
offset = offset, offset = offset,
limit = limit limit = limit,
).filter { !blockMemberRepository.isBlocked(blockedMemberId = member.id!!, memberId = it.member!!.id!!) } 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, items)
return GetSeriesListResponse(totalCount, getTranslatedSeriesList(items))
} }
@Transactional @Transactional
@@ -459,19 +473,16 @@ class ContentSeriesService(
member: Member member: Member
): List<GetSeriesListResponse.SeriesListItem> { ): List<GetSeriesListResponse.SeriesListItem> {
val isAuth = member.auth != null && isAdultContentVisible val isAuth = member.auth != null && isAdultContentVisible
val seriesList = repository.getRecommendSeriesList( return repository.getRecommendSeriesListV2(
imageHost = coverImageHost,
isAuth = isAuth, isAuth = isAuth,
contentType = contentType, contentType = contentType,
limit = 20 limit = 20,
).filter { !blockMemberRepository.isBlocked(blockedMemberId = member.id!!, memberId = it.member!!.id!!) } locale = langContext.lang.code,
memberId = member.id
return getTranslatedSeriesList( ).map { item ->
seriesToSeriesListItem( item.copy(publishedDaysOfWeek = publishedDaysOfWeekText(item.rawPublishedDaysOfWeek))
seriesList = seriesList, }
isAdult = isAuth,
contentType = contentType
)
)
} }
fun fetchSeriesByCurationId( fun fetchSeriesByCurationId(
@@ -480,13 +491,16 @@ class ContentSeriesService(
isAdult: Boolean, isAdult: Boolean,
contentType: ContentType contentType: ContentType
): List<GetSeriesListResponse.SeriesListItem> { ): List<GetSeriesListResponse.SeriesListItem> {
val seriesList = repository.findByCurationId( return repository.findByCurationIdV2(
imageHost = coverImageHost,
curationId = curationId, curationId = curationId,
memberId = memberId, memberId = memberId,
isAdult = isAdult, isAdult = isAdult,
contentType = contentType contentType = contentType,
) locale = langContext.lang.code
return getTranslatedSeriesList(seriesToSeriesListItem(seriesList, isAdult, contentType)) ).map { item ->
item.copy(publishedDaysOfWeek = publishedDaysOfWeekText(item.rawPublishedDaysOfWeek))
}
} }
fun getDayOfWeekSeriesList( fun getDayOfWeekSeriesList(
@@ -497,71 +511,17 @@ class ContentSeriesService(
offset: Long = 0, offset: Long = 0,
limit: Long = 10 limit: Long = 10
): List<GetSeriesListResponse.SeriesListItem> { ): List<GetSeriesListResponse.SeriesListItem> {
var seriesList = repository.getDayOfWeekSeriesList( return repository.getDayOfWeekSeriesListV2(
imageHost = coverImageHost,
dayOfWeek = dayOfWeek, dayOfWeek = dayOfWeek,
contentType = contentType, contentType = contentType,
isAdult = isAdult, isAdult = isAdult,
offset = offset, offset = offset,
limit = limit limit = limit,
) locale = langContext.lang.code,
memberId = memberId
seriesList = if (memberId != null) { ).map { item ->
seriesList.filter { item.copy(publishedDaysOfWeek = publishedDaysOfWeekText(item.rawPublishedDaysOfWeek))
!blockMemberRepository.isBlocked(
blockedMemberId = memberId,
memberId = it.member!!.id!!
)
}
} else {
seriesList
}
return getTranslatedSeriesList(seriesToSeriesListItem(seriesList, isAdult, contentType))
}
private fun seriesToSeriesListItem(
seriesList: List<Series>,
isAdult: Boolean,
contentType: ContentType
): List<GetSeriesListResponse.SeriesListItem> {
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
} }
} }
@@ -632,39 +592,4 @@ class ContentSeriesService(
} }
} }
} }
/**
* 시리즈 리스트의 제목을 현재 언어(locale)에 맞춰 일괄 번역한다.
*
* 처리 절차:
* - 입력된 시리즈들의 seriesId 집합을 만들고, 요청 언어 코드(langContext.lang.code)로
* seriesTranslationRepository에서 번역 데이터를 한 번에 조회한다.
* - 각 항목에 대해 번역된 제목이 존재하고 비어있지 않으면 title만 번역 값으로 교체한다.
* - 번역이 없거나 공백이면 원본 항목을 그대로 반환한다.
*
* 성능:
* - N건의 항목을 1회의 조회로 해결하기 위해 IN 쿼리를 사용한다.
*
* @param seriesList 번역 대상 SeriesListItem 목록
* @return 제목이 가능한 항목은 번역된 목록(불변 사본), 그 외는 원본 항목 유지
*/
private fun getTranslatedSeriesList(
seriesList: List<GetSeriesListResponse.SeriesListItem>
): List<GetSeriesListResponse.SeriesListItem> {
val seriesIds = seriesList.map { it.seriesId }
if (seriesIds.isEmpty()) return seriesList
val translations = seriesTranslationRepository
.findBySeriesIdInAndLocale(seriesIds = seriesIds, locale = langContext.lang.code)
.associateBy { it.seriesId }
return seriesList.map { item ->
val translatedTitle = translations[item.seriesId]?.renderedPayload?.title
if (translatedTitle.isNullOrBlank()) {
item
} else {
item.copy(title = translatedTitle)
}
}
}
} }

View File

@@ -1,10 +1,13 @@
package kr.co.vividnext.sodalive.content.series 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( data class GetSeriesListResponse(
val totalCount: Int, val totalCount: Int,
val items: List<SeriesListItem> val items: List<SeriesListItem>
) { ) {
data class SeriesListItem( data class SeriesListItem @QueryProjection constructor(
val seriesId: Long, val seriesId: Long,
val title: String, val title: String,
val coverImage: String, val coverImage: String,
@@ -13,10 +16,11 @@ data class GetSeriesListResponse(
val creator: SeriesListItemCreator, val creator: SeriesListItemCreator,
var numberOfContent: Int = 0, var numberOfContent: Int = 0,
var isNew: Boolean = false, var isNew: Boolean = false,
var isPopular: Boolean = false var isPopular: Boolean = false,
val rawPublishedDaysOfWeek: Set<SeriesPublishedDaysOfWeek> = emptySet()
) )
data class SeriesListItemCreator( data class SeriesListItemCreator @QueryProjection constructor(
val creatorId: Long, val creatorId: Long,
val nickname: String, val nickname: String,
val profileImage: String val profileImage: String

View File

@@ -637,6 +637,23 @@ class ExplorerQueryRepository(
.fetchOne() ?: false .fetchOne() ?: false
} }
fun getFollowedCreatorIds(creatorIds: List<Long>, memberId: Long): Set<Long> {
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? { fun getCreatorCheers(cheersId: Long): CreatorCheers? {
return queryFactory return queryFactory
.selectFrom(creatorCheers) .selectFrom(creatorCheers)

View File

@@ -6,9 +6,12 @@ import kr.co.vividnext.sodalive.content.ContentType
import kr.co.vividnext.sodalive.content.QAudioContent.audioContent import kr.co.vividnext.sodalive.content.QAudioContent.audioContent
import kr.co.vividnext.sodalive.content.comment.QAudioContentComment.audioContentComment import kr.co.vividnext.sodalive.content.comment.QAudioContentComment.audioContentComment
import kr.co.vividnext.sodalive.content.like.QAudioContentLike.audioContentLike import kr.co.vividnext.sodalive.content.like.QAudioContentLike.audioContentLike
import kr.co.vividnext.sodalive.content.translation.ContentTranslationPayload
import kr.co.vividnext.sodalive.content.translation.QContentTranslation.contentTranslation
import kr.co.vividnext.sodalive.member.MemberRole import kr.co.vividnext.sodalive.member.MemberRole
import kr.co.vividnext.sodalive.member.QMember.member import kr.co.vividnext.sodalive.member.QMember.member
import kr.co.vividnext.sodalive.member.auth.QAuth.auth import kr.co.vividnext.sodalive.member.auth.QAuth.auth
import kr.co.vividnext.sodalive.member.block.QBlockMember.blockMember
import org.springframework.beans.factory.annotation.Value import org.springframework.beans.factory.annotation.Value
import org.springframework.stereotype.Repository import org.springframework.stereotype.Repository
@@ -19,7 +22,19 @@ class RecommendChannelQueryRepository(
@Value("\${cloud.aws.cloud-front.host}") @Value("\${cloud.aws.cloud-front.host}")
private val imageHost: String private val imageHost: String
) { ) {
fun getRecommendChannelList(isAdult: Boolean, contentType: ContentType): List<RecommendChannelResponse> { fun getRecommendChannelList(
memberId: Long?,
isAdult: Boolean,
contentType: ContentType
): List<RecommendChannelResponse> {
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) var where = member.role.eq(MemberRole.CREATOR)
.and(audioContent.isActive.isTrue) .and(audioContent.isActive.isTrue)
@@ -39,7 +54,7 @@ class RecommendChannelQueryRepository(
} }
} }
return queryFactory var select = queryFactory
.select( .select(
QRecommendChannelResponse( QRecommendChannelResponse(
member.id, member.id,
@@ -52,6 +67,13 @@ class RecommendChannelQueryRepository(
.from(member) .from(member)
.innerJoin(auth).on(auth.member.id.eq(member.id)) .innerJoin(auth).on(auth.member.id.eq(member.id))
.innerJoin(audioContent).on(audioContent.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) .where(where)
.groupBy(member.id) .groupBy(member.id)
.having(audioContent.id.count().goe(3)) .having(audioContent.id.count().goe(3))
@@ -60,22 +82,43 @@ class RecommendChannelQueryRepository(
.fetch() .fetch()
} }
fun getContentsByCreatorIdLikeDesc(creatorId: Long, isAdult: Boolean): List<RecommendChannelContentItem> { fun getContentsByCreatorIdLikeDesc(
creatorId: Long,
memberId: Long?,
isAdult: Boolean,
locale: String? = null
): List<RecommendChannelContentItem> {
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) var where = audioContent.member.id.eq(creatorId)
if (!isAdult) { if (!isAdult) {
where = where.and(audioContent.isAdult.isFalse) where = where.and(audioContent.isAdult.isFalse)
} }
return queryFactory val coverImageUrl = audioContent.coverImage.prepend("/").prepend(imageHost)
val payloadExpression = if (locale != null) {
contentTranslation.renderedPayload
} else {
Expressions.nullExpression(ContentTranslationPayload::class.java)
}
val likeCountExpression = audioContentLike.id.countDistinct()
val commentCountExpression = audioContentComment.id.countDistinct()
var select = queryFactory
.select( .select(
QRecommendChannelContentItem(
audioContent.id, audioContent.id,
audioContent.title, audioContent.title,
audioContent.coverImage.prepend("/").prepend(imageHost), payloadExpression,
audioContentLike.id.countDistinct(), coverImageUrl,
audioContentComment.id.countDistinct() likeCountExpression,
) commentCountExpression
) )
.from(audioContent) .from(audioContent)
.leftJoin(audioContentLike) .leftJoin(audioContentLike)
@@ -88,10 +131,43 @@ class RecommendChannelQueryRepository(
audioContentComment.audioContent.id.eq(audioContent.id) audioContentComment.audioContent.id.eq(audioContent.id)
.and(audioContentComment.isActive.isTrue) .and(audioContentComment.isActive.isTrue)
) )
if (locale != null) {
select = select.leftJoin(contentTranslation)
.on(
contentTranslation.contentId.eq(audioContent.id)
.and(contentTranslation.locale.eq(locale))
)
}
if (memberId != null) {
where = where.and(blockMember.id.isNull)
select = select.leftJoin(blockMember).on(blockMemberCondition)
}
val results = select
.where(where) .where(where)
.groupBy(audioContent.id) .groupBy(audioContent.id)
.orderBy(audioContentLike.id.countDistinct().desc()) .orderBy(likeCountExpression.desc())
.limit(3) .limit(3)
.fetch() .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
)
}
} }
} }

View File

@@ -1,13 +1,17 @@
package kr.co.vividnext.sodalive.query.recommend package kr.co.vividnext.sodalive.query.recommend
import kr.co.vividnext.sodalive.content.ContentType import kr.co.vividnext.sodalive.content.ContentType
import kr.co.vividnext.sodalive.i18n.LangContext
import org.springframework.cache.annotation.Cacheable import org.springframework.cache.annotation.Cacheable
import org.springframework.stereotype.Service import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional import org.springframework.transaction.annotation.Transactional
@Service @Service
@Transactional(readOnly = true) @Transactional(readOnly = true)
class RecommendChannelQueryService(private val repository: RecommendChannelQueryRepository) { class RecommendChannelQueryService(
private val repository: RecommendChannelQueryRepository,
private val langContext: LangContext
) {
@Cacheable( @Cacheable(
cacheNames = ["default"], cacheNames = ["default"],
key = "'recommendChannel:' + (#memberId ?: 'guest') + ':' + #isAdult + ':' + #contentType" key = "'recommendChannel:' + (#memberId ?: 'guest') + ':' + #isAdult + ':' + #contentType"
@@ -18,6 +22,7 @@ class RecommendChannelQueryService(private val repository: RecommendChannelQuery
contentType: ContentType contentType: ContentType
): List<RecommendChannelResponse> { ): List<RecommendChannelResponse> {
val recommendChannelList = repository.getRecommendChannelList( val recommendChannelList = repository.getRecommendChannelList(
memberId = memberId,
isAdult = isAdult, isAdult = isAdult,
contentType = contentType contentType = contentType
) )
@@ -25,7 +30,9 @@ class RecommendChannelQueryService(private val repository: RecommendChannelQuery
return recommendChannelList.map { return recommendChannelList.map {
it.contentList = repository.getContentsByCreatorIdLikeDesc( it.contentList = repository.getContentsByCreatorIdLikeDesc(
creatorId = it.channelId, creatorId = it.channelId,
isAdult = isAdult memberId = memberId,
isAdult = isAdult,
locale = langContext.lang.code
) )
it it

View File

@@ -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.main.QGetAudioContentRankingItem
import kr.co.vividnext.sodalive.content.order.QOrder.order import kr.co.vividnext.sodalive.content.order.QOrder.order
import kr.co.vividnext.sodalive.content.theme.QAudioContentTheme.audioContentTheme import kr.co.vividnext.sodalive.content.theme.QAudioContentTheme.audioContentTheme
import kr.co.vividnext.sodalive.content.translation.ContentTranslationPayload
import kr.co.vividnext.sodalive.creator.admin.content.series.QSeries.series import kr.co.vividnext.sodalive.creator.admin.content.series.QSeries.series
import kr.co.vividnext.sodalive.creator.admin.content.series.QSeriesContent.seriesContent import kr.co.vividnext.sodalive.creator.admin.content.series.QSeriesContent.seriesContent
import kr.co.vividnext.sodalive.creator.admin.content.series.Series import kr.co.vividnext.sodalive.creator.admin.content.series.Series
@@ -33,11 +34,29 @@ class RankingRepository(
@Value("\${cloud.aws.cloud-front.host}") @Value("\${cloud.aws.cloud-front.host}")
private val imageHost: String private val imageHost: String
) { ) {
fun getCreatorRankings(): List<Member> { fun getCreatorRankings(memberId: Long? = null): List<Member> {
return queryFactory 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) .select(member)
.from(creatorRanking) .from(creatorRanking)
.innerJoin(creatorRanking.member, member) .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()) .orderBy(creatorRanking.ranking.asc())
.fetch() .fetch()
} }
@@ -51,7 +70,8 @@ class RankingRepository(
offset: Long, offset: Long,
limit: Long, limit: Long,
sortType: String, sortType: String,
theme: String = "" theme: String = "",
locale: String? = null
): List<GetAudioContentRankingItem> { ): List<GetAudioContentRankingItem> {
val blockMemberCondition = if (memberId != null) { val blockMemberCondition = if (memberId != null) {
blockMember.member.id.eq(member.id) blockMember.member.id.eq(member.id)
@@ -61,6 +81,8 @@ class RankingRepository(
null null
} }
val contentTranslation = kr.co.vividnext.sodalive.content.translation.QContentTranslation.contentTranslation
var where = audioContent.isActive.isTrue var where = audioContent.isActive.isTrue
.and(audioContent.member.isActive.isTrue) .and(audioContent.member.isActive.isTrue)
.and(audioContent.member.isNotNull) .and(audioContent.member.isNotNull)
@@ -91,20 +113,27 @@ class RankingRepository(
where = where.and(audioContentTheme.theme.eq(theme)) where = where.and(audioContentTheme.theme.eq(theme))
} }
val coverImageUrl = audioContent.coverImage.prepend("/").prepend(imageHost)
val creatorProfileImageUrl = member.profileImage.prepend("/").prepend(imageHost)
val payloadExpression = if (locale != null) {
contentTranslation.renderedPayload
} else {
Expressions.nullExpression(ContentTranslationPayload::class.java)
}
var select = queryFactory var select = queryFactory
.select( .select(
QGetAudioContentRankingItem(
audioContent.id, audioContent.id,
audioContent.title, audioContent.title,
audioContent.coverImage.prepend("/").prepend(imageHost), payloadExpression,
coverImageUrl,
audioContentTheme.theme, audioContentTheme.theme,
audioContent.price, audioContent.price,
audioContent.duration, audioContent.duration,
member.id, member.id,
member.nickname, member.nickname,
audioContent.isPointAvailable, audioContent.isPointAvailable,
member.profileImage.prepend("/").prepend(imageHost) creatorProfileImageUrl
)
) )
select = when (sortType) { select = when (sortType) {
@@ -149,6 +178,11 @@ class RankingRepository(
} }
} }
if (locale != null) {
select = select.leftJoin(contentTranslation)
.on(contentTranslation.contentId.eq(audioContent.id).and(contentTranslation.locale.eq(locale)))
}
if (memberId != null) { if (memberId != null) {
where = where.and(blockMember.id.isNull) where = where.and(blockMember.id.isNull)
select = select.leftJoin(blockMember).on(blockMemberCondition) select = select.leftJoin(blockMember).on(blockMemberCondition)
@@ -217,10 +251,38 @@ class RankingRepository(
} }
} }
return select val results = select
.offset(offset) .offset(offset)
.limit(limit) .limit(limit)
.fetch() .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( fun getSeriesRanking(

View File

@@ -9,7 +9,7 @@ import kr.co.vividnext.sodalive.creator.admin.content.series.Series
import kr.co.vividnext.sodalive.creator.admin.content.series.SeriesPublishedDaysOfWeek import kr.co.vividnext.sodalive.creator.admin.content.series.SeriesPublishedDaysOfWeek
import kr.co.vividnext.sodalive.creator.admin.content.series.SeriesState import kr.co.vividnext.sodalive.creator.admin.content.series.SeriesState
import kr.co.vividnext.sodalive.explorer.GetExplorerSectionResponse import kr.co.vividnext.sodalive.explorer.GetExplorerSectionResponse
import kr.co.vividnext.sodalive.member.MemberService import kr.co.vividnext.sodalive.i18n.LangContext
import org.springframework.beans.factory.annotation.Value import org.springframework.beans.factory.annotation.Value
import org.springframework.stereotype.Service import org.springframework.stereotype.Service
import java.time.LocalDateTime import java.time.LocalDateTime
@@ -17,22 +17,15 @@ import java.time.LocalDateTime
@Service @Service
class RankingService( class RankingService(
private val repository: RankingRepository, private val repository: RankingRepository,
private val memberService: MemberService,
private val seriesContentRepository: ContentSeriesContentRepository, private val seriesContentRepository: ContentSeriesContentRepository,
private val langContext: LangContext,
@Value("\${cloud.aws.cloud-front.host}") @Value("\${cloud.aws.cloud-front.host}")
private val imageHost: String private val imageHost: String
) { ) {
fun getCreatorRanking(memberId: Long?, rankingDate: String): GetExplorerSectionResponse { fun getCreatorRanking(memberId: Long?, rankingDate: String): GetExplorerSectionResponse {
val creatorRankings = repository val creatorRankings = repository
.getCreatorRankings() .getCreatorRankings(memberId = memberId)
.filter {
if (memberId != null) {
!memberService.isBlocked(blockedMemberId = memberId, memberId = it.id!!)
} else {
true
}
}
.map { it.toExplorerSectionCreator(imageHost) } .map { it.toExplorerSectionCreator(imageHost) }
return GetExplorerSectionResponse( return GetExplorerSectionResponse(
@@ -68,7 +61,8 @@ class RankingService(
offset = offset, offset = offset,
limit = limit, limit = limit,
sortType = sortType, sortType = sortType,
theme = theme theme = theme,
locale = langContext.lang.code
) )
loopCount++ loopCount++
} while (contentList.size < 5 && loopCount < 5) } while (contentList.size < 5 && loopCount < 5)