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) - } - } - } }