시리즈 목록 조회 쿼리 최적화

This commit is contained in:
2026-02-13 15:13:13 +09:00
parent ac0def6187
commit 01a1a05d77
3 changed files with 634 additions and 288 deletions

View File

@@ -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<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 {
seriesList
}
}
/**
* AI 캐릭터 리스트의 이름/설명을 현재 언어(locale)에 맞춰 일괄 번역한다.
*

View File

@@ -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<Series>
limit: Long,
locale: String,
memberId: Long? = null
): List<GetSeriesListResponse.SeriesListItem>
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<Series>
limit: Long,
locale: String,
memberId: Long? = null
): List<GetSeriesListResponse.SeriesListItem>
fun getSeriesDetail(seriesId: Long, isAuth: Boolean, contentType: ContentType): Series?
fun getKeywordList(seriesId: Long): List<String>
fun getSeriesContentMinMaxPrice(seriesId: Long): GetSeriesContentMinMaxPriceResponse
fun getRecommendSeriesList(isAuth: Boolean, contentType: ContentType, limit: Long): List<Series>
fun getRecommendSeriesListV2(
imageHost: String,
isAuth: Boolean,
contentType: ContentType,
limit: Long,
locale: String,
memberId: Long? = null
): List<GetSeriesListResponse.SeriesListItem>
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<GetSeriesGenreListResponse>
fun findByCurationId(curationId: Long, memberId: Long, isAdult: Boolean, contentType: ContentType): List<Series>
fun getDayOfWeekSeriesList(
fun findByCurationIdV2(
imageHost: String,
curationId: Long,
memberId: Long,
isAdult: Boolean,
contentType: ContentType,
locale: String
): List<GetSeriesListResponse.SeriesListItem>
fun getDayOfWeekSeriesListV2(
imageHost: String,
dayOfWeek: SeriesPublishedDaysOfWeek,
contentType: ContentType,
isAdult: Boolean,
offset: Long,
limit: Long
): List<Series>
limit: Long,
locale: String,
memberId: Long? = null
): List<GetSeriesListResponse.SeriesListItem>
}
class ContentSeriesQueryRepositoryImpl(
private val queryFactory: JPAQueryFactory
) : ContentSeriesQueryRepository {
private fun parsePublishedDaysOfWeek(raw: String?): Set<SeriesPublishedDaysOfWeek> {
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<Series> {
limit: Long,
locale: String,
memberId: Long?
): List<GetSeriesListResponse.SeriesListItem> {
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<Series> {
limit: Long,
locale: String,
memberId: Long?
): List<GetSeriesListResponse.SeriesListItem> {
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<Series> {
override fun getRecommendSeriesListV2(
imageHost: String,
isAuth: Boolean,
contentType: ContentType,
limit: Long,
locale: String,
memberId: Long?
): List<GetSeriesListResponse.SeriesListItem> {
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<GetSeriesListResponse.SeriesListItem> {
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<Series> {
contentType: ContentType,
locale: String
): List<GetSeriesListResponse.SeriesListItem> {
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<Series> {
limit: Long,
locale: String,
memberId: Long?
): List<GetSeriesListResponse.SeriesListItem> {
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
)
}
}
}

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.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<GetSeriesListResponse.SeriesListItem> {
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<GetSeriesListResponse.SeriesListItem> {
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<GetSeriesListResponse.SeriesListItem> {
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<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
}
}
private fun publishedDaysOfWeekText(publishedDaysOfWeek: Set<SeriesPublishedDaysOfWeek>): 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<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)
}
}
}
}