feat(creator-channel): 시리즈 탭 repository를 추가한다

This commit is contained in:
2026-06-20 05:20:22 +09:00
parent a67322b7fd
commit 67fe0ec497
3 changed files with 647 additions and 0 deletions

View File

@@ -0,0 +1,5 @@
package kr.co.vividnext.sodalive.v2.creator.channel.series.adapter.out.persistence
import kr.co.vividnext.sodalive.v2.creator.channel.series.port.out.CreatorChannelSeriesQueryPort
interface CreatorChannelSeriesQueryRepository : CreatorChannelSeriesQueryPort

View File

@@ -0,0 +1,288 @@
package kr.co.vividnext.sodalive.v2.creator.channel.series.adapter.out.persistence
import com.querydsl.core.Tuple
import com.querydsl.core.types.Projections
import com.querydsl.core.types.dsl.BooleanExpression
import com.querydsl.core.types.dsl.CaseBuilder
import com.querydsl.jpa.impl.JPAQueryFactory
import kr.co.vividnext.sodalive.content.QAudioContent.audioContent
import kr.co.vividnext.sodalive.content.order.OrderType
import kr.co.vividnext.sodalive.content.order.QOrder
import kr.co.vividnext.sodalive.content.series.translation.QSeriesTranslation
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.member.QMember.member
import kr.co.vividnext.sodalive.member.block.QBlockMember
import kr.co.vividnext.sodalive.v2.common.domain.ContentSort
import kr.co.vividnext.sodalive.v2.creator.channel.series.port.out.CreatorChannelSeriesCreatorRecord
import kr.co.vividnext.sodalive.v2.creator.channel.series.port.out.CreatorChannelSeriesRecord
import org.springframework.stereotype.Repository
import java.time.LocalDateTime
@Repository
class DefaultCreatorChannelSeriesQueryRepository(
private val queryFactory: JPAQueryFactory
) : CreatorChannelSeriesQueryRepository {
override fun findCreator(creatorId: Long, viewerId: Long?): CreatorChannelSeriesCreatorRecord? {
return queryFactory
.select(
Projections.constructor(
CreatorChannelSeriesCreatorRecord::class.java,
member.id,
member.role,
member.nickname
)
)
.from(member)
.where(
member.id.eq(creatorId),
member.isActive.isTrue
)
.fetchFirst()
}
override fun existsBlockedBetween(viewerId: Long, creatorId: Long): Boolean {
val blockMember = QBlockMember("creatorChannelSeriesBlockMember")
return queryFactory
.select(blockMember.id)
.from(blockMember)
.where(
blockMember.isActive.isTrue,
blockMember.member.id.eq(viewerId).and(blockMember.blockedMember.id.eq(creatorId))
.or(blockMember.member.id.eq(creatorId).and(blockMember.blockedMember.id.eq(viewerId)))
)
.fetchFirst() != null
}
override fun countSeries(creatorId: Long, now: LocalDateTime, canViewAdultContent: Boolean): Int {
return queryFactory
.select(series.id.count())
.from(series)
.where(seriesCondition(creatorId, canViewAdultContent))
.fetchOne()
?.toInt()
?: 0
}
override fun findSeries(
creatorId: Long,
viewerId: Long,
now: LocalDateTime,
canViewAdultContent: Boolean,
sort: ContentSort,
locale: String,
offset: Long,
limit: Int
): List<CreatorChannelSeriesRecord> {
val seriesIds = findSeriesIds(creatorId, viewerId, now, canViewAdultContent, sort, offset, limit)
if (seriesIds.isEmpty()) return emptyList()
val seriesTranslation = QSeriesTranslation("creatorChannelSeriesTranslation")
val rows = findSeriesRows(seriesIds, locale, seriesTranslation)
val contentStats = contentStatsBySeriesIds(seriesIds, now, canViewAdultContent)
val purchaseStats = purchaseStatsBySeriesIds(seriesIds, viewerId, now, canViewAdultContent)
return rows.sortedBy { seriesIds.indexOf(it.get(series)!!.id!!) }
.map { row ->
val targetSeries = row.get(series)!!
val translatedTitle = row.get(seriesTranslation)
?.renderedPayload
?.title
val contentStat = contentStats[targetSeries.id] ?: SeriesContentStats()
CreatorChannelSeriesRecord(
seriesId = targetSeries.id!!,
title = translatedTitle.takeUnless(String?::isNullOrBlank) ?: targetSeries.title,
coverImagePath = targetSeries.coverImage,
publishedDaysOfWeek = targetSeries.publishedDaysOfWeek,
isOriginal = targetSeries.isOriginal,
isAdult = targetSeries.isAdult,
state = targetSeries.state,
contentCount = contentStat.contentCount,
purchasedContentCount = purchaseStats[targetSeries.id] ?: 0,
paidContentCount = contentStat.paidContentCount
)
}
}
private fun findSeriesIds(
creatorId: Long,
viewerId: Long,
now: LocalDateTime,
canViewAdultContent: Boolean,
sort: ContentSort,
offset: Long,
limit: Int
): List<Long> {
val revenueOrder = QOrder("seriesRevenueOrder")
val ownedOrder = QOrder("seriesOwnedOrder")
val latestReleaseDate = audioContent.releaseDate.max()
val highestPrice = audioContent.price.max()
val lowestPrice = audioContent.price.min()
val revenue = revenueOrder.can.sum().coalesce(0)
val ownedCount = ownedOrder.audioContent.id.countDistinct()
val latestReleaseDateNullLast = CaseBuilder().`when`(latestReleaseDate.isNull).then(1).otherwise(0)
val highestPriceNullLast = CaseBuilder().`when`(highestPrice.isNull).then(1).otherwise(0)
val lowestPriceNullLast = CaseBuilder().`when`(lowestPrice.isNull).then(1).otherwise(0)
val query = queryFactory
.select(series.id)
.from(series)
.leftJoin(seriesContent).on(seriesContent.series.id.eq(series.id))
.leftJoin(audioContent).on(
seriesContent.content.id.eq(audioContent.id),
publicAudioContentCondition(now, canViewAdultContent)
)
.where(seriesCondition(creatorId, canViewAdultContent))
.groupBy(series.id)
when (sort) {
ContentSort.POPULAR -> {
query
.leftJoin(revenueOrder)
.on(
revenueOrder.audioContent.id.eq(audioContent.id),
revenueOrder.isActive.isTrue
)
.orderBy(revenue.desc(), latestReleaseDate.desc(), series.id.desc())
}
ContentSort.OWNED -> {
query
.leftJoin(ownedOrder)
.on(
ownedOrder.audioContent.id.eq(audioContent.id),
ownedOrder.member.id.eq(viewerId),
ownedOrder.isActive.isTrue,
validPurchasedOrderCondition(ownedOrder, now)
)
.orderBy(ownedCount.desc(), latestReleaseDate.desc(), series.id.desc())
}
ContentSort.LATEST -> query.orderBy(
latestReleaseDateNullLast.asc(),
latestReleaseDate.desc(),
highestPrice.desc(),
series.id.desc()
)
ContentSort.PRICE_HIGH -> query.orderBy(
highestPriceNullLast.asc(),
highestPrice.desc(),
latestReleaseDate.desc(),
series.id.desc()
)
ContentSort.PRICE_LOW -> query.orderBy(
lowestPriceNullLast.asc(),
lowestPrice.asc(),
latestReleaseDate.desc(),
series.id.desc()
)
}
return query.offset(offset).limit(limit.toLong()).fetch()
}
private fun findSeriesRows(
seriesIds: List<Long>,
locale: String,
seriesTranslation: QSeriesTranslation
): List<Tuple> {
return queryFactory
.select(series, seriesTranslation)
.from(series)
.leftJoin(seriesTranslation)
.on(
seriesTranslation.seriesId.eq(series.id),
seriesTranslation.locale.eq(locale)
)
.where(series.id.`in`(seriesIds))
.fetch()
}
private fun contentStatsBySeriesIds(
seriesIds: List<Long>,
now: LocalDateTime,
canViewAdultContent: Boolean
): Map<Long, SeriesContentStats> {
val paidContentCount = CaseBuilder()
.`when`(audioContent.price.gt(0))
.then(audioContent.id)
.otherwise(null as Long?)
.countDistinct()
return queryFactory
.select(
seriesContent.series.id,
audioContent.id.countDistinct(),
paidContentCount
)
.from(seriesContent)
.innerJoin(seriesContent.content, audioContent)
.where(
seriesContent.series.id.`in`(seriesIds),
publicAudioContentCondition(now, canViewAdultContent)
)
.groupBy(seriesContent.series.id)
.fetch()
.associate {
it.get(seriesContent.series.id)!! to SeriesContentStats(
contentCount = it.get(audioContent.id.countDistinct())?.toInt() ?: 0,
paidContentCount = it.get(paidContentCount)?.toInt() ?: 0
)
}
}
private fun purchaseStatsBySeriesIds(
seriesIds: List<Long>,
viewerId: Long,
now: LocalDateTime,
canViewAdultContent: Boolean
): Map<Long, Int> {
val purchasedOrder = QOrder("seriesPurchasedOrder")
return queryFactory
.select(seriesContent.series.id, audioContent.id.countDistinct())
.from(seriesContent)
.innerJoin(seriesContent.content, audioContent)
.innerJoin(purchasedOrder)
.on(purchasedOrder.audioContent.id.eq(audioContent.id))
.where(
seriesContent.series.id.`in`(seriesIds),
publicAudioContentCondition(now, canViewAdultContent),
audioContent.price.gt(0),
purchasedOrder.member.id.eq(viewerId),
purchasedOrder.isActive.isTrue,
validPurchasedOrderCondition(purchasedOrder, now)
)
.groupBy(seriesContent.series.id)
.fetch()
.associate { it.get(seriesContent.series.id)!! to (it.get(audioContent.id.countDistinct())?.toInt() ?: 0) }
}
private fun seriesCondition(creatorId: Long, canViewAdultContent: Boolean): BooleanExpression {
return series.member.id.eq(creatorId)
.and(series.isActive.isTrue)
.and(adultSeriesCondition(canViewAdultContent))
}
private fun adultSeriesCondition(canViewAdultContent: Boolean): BooleanExpression? {
return if (canViewAdultContent) null else series.isAdult.isFalse
}
private fun publicAudioContentCondition(now: LocalDateTime, canViewAdultContent: Boolean): BooleanExpression {
return audioContent.isActive.isTrue
.and(audioContent.duration.isNotNull)
.and(audioContent.releaseDate.isNotNull)
.and(audioContent.releaseDate.loe(now))
.and(adultAudioCondition(canViewAdultContent))
}
private fun adultAudioCondition(canViewAdultContent: Boolean): BooleanExpression? {
return if (canViewAdultContent) null else audioContent.isAdult.isFalse
}
private fun validPurchasedOrderCondition(targetOrder: QOrder, now: LocalDateTime): BooleanExpression {
return targetOrder.type.eq(OrderType.KEEP)
.or(targetOrder.type.eq(OrderType.RENTAL).and(targetOrder.endDate.after(now)))
}
private data class SeriesContentStats(
val contentCount: Int = 0,
val paidContentCount: Int = 0
)
}