feat(creator-channel): 오디오 탭 repository를 추가한다

This commit is contained in:
2026-06-19 18:07:11 +09:00
parent cffd50c33f
commit 76cc6e6557
3 changed files with 803 additions and 0 deletions

View File

@@ -0,0 +1,5 @@
package kr.co.vividnext.sodalive.v2.creator.channel.audio.adapter.out.persistence
import kr.co.vividnext.sodalive.v2.creator.channel.audio.port.out.CreatorChannelAudioQueryPort
interface CreatorChannelAudioQueryRepository : CreatorChannelAudioQueryPort

View File

@@ -0,0 +1,407 @@
package kr.co.vividnext.sodalive.v2.creator.channel.audio.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.order.QOrder.order
import kr.co.vividnext.sodalive.content.series.translation.QSeriesTranslation
import kr.co.vividnext.sodalive.content.theme.QAudioContentTheme.audioContentTheme
import kr.co.vividnext.sodalive.content.theme.translation.QContentThemeTranslation
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.audio.port.out.CreatorChannelAudioContentRecord
import kr.co.vividnext.sodalive.v2.creator.channel.audio.port.out.CreatorChannelAudioCreatorRecord
import kr.co.vividnext.sodalive.v2.creator.channel.audio.port.out.CreatorChannelAudioThemeRecord
import org.springframework.stereotype.Repository
import java.time.LocalDateTime
@Repository
class DefaultCreatorChannelAudioQueryRepository(
private val queryFactory: JPAQueryFactory
) : CreatorChannelAudioQueryRepository {
override fun findCreator(creatorId: Long, viewerId: Long?): CreatorChannelAudioCreatorRecord? {
return queryFactory
.select(
Projections.constructor(
CreatorChannelAudioCreatorRecord::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("creatorChannelAudioBlockMember")
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 findActiveThemeId(themeId: Long): Long? {
return queryFactory
.select(audioContentTheme.id)
.from(audioContentTheme)
.where(
audioContentTheme.id.eq(themeId),
audioContentTheme.isActive.isTrue
)
.fetchFirst()
}
override fun findAudioThemes(locale: String): List<CreatorChannelAudioThemeRecord> {
val themeTranslation = QContentThemeTranslation("audioThemeTranslation")
return queryFactory
.select(audioContentTheme.id, audioContentTheme.theme, themeTranslation.theme)
.from(audioContentTheme)
.leftJoin(themeTranslation)
.on(
themeTranslation.contentThemeId.eq(audioContentTheme.id),
themeTranslation.locale.eq(locale)
)
.where(audioContentTheme.isActive.isTrue)
.orderBy(audioContentTheme.orders.asc(), audioContentTheme.id.asc())
.fetch()
.map {
CreatorChannelAudioThemeRecord(
themeId = it.get(audioContentTheme.id)!!,
themeName = it.get(themeTranslation.theme).takeUnless(String?::isNullOrBlank)
?: it.get(audioContentTheme.theme)!!
)
}
}
override fun countAudioContents(
creatorId: Long,
themeId: Long?,
now: LocalDateTime,
canViewAdultContent: Boolean
): Int {
return queryFactory
.select(audioContent.id.count())
.from(audioContent)
.innerJoin(audioContent.theme, audioContentTheme)
.where(audioContentCondition(creatorId, themeId, now, canViewAdultContent))
.fetchOne()
?.toInt()
?: 0
}
override fun countPaidAudioContents(
creatorId: Long,
themeId: Long?,
now: LocalDateTime,
canViewAdultContent: Boolean
): Int {
return queryFactory
.select(audioContent.id.count())
.from(audioContent)
.innerJoin(audioContent.theme, audioContentTheme)
.where(
audioContentCondition(creatorId, themeId, now, canViewAdultContent),
audioContent.price.gt(0)
)
.fetchOne()
?.toInt()
?: 0
}
override fun countPurchasedAudioContents(
creatorId: Long,
viewerId: Long,
themeId: Long?,
now: LocalDateTime,
canViewAdultContent: Boolean
): Int {
val purchasedOrder = QOrder("audioPurchasedOrder")
return queryFactory
.select(audioContent.id.countDistinct())
.from(audioContent)
.innerJoin(audioContent.theme, audioContentTheme)
.innerJoin(purchasedOrder)
.on(purchasedOrder.audioContent.id.eq(audioContent.id))
.where(
audioContentCondition(creatorId, themeId, now, canViewAdultContent),
audioContent.price.gt(0),
purchasedOrder.member.id.eq(viewerId),
purchasedOrder.isActive.isTrue,
validPurchasedOrderCondition(purchasedOrder, now)
)
.fetchOne()
?.toInt()
?: 0
}
override fun findAudioContents(
creatorId: Long,
viewerId: Long,
themeId: Long?,
now: LocalDateTime,
canViewAdultContent: Boolean,
sort: ContentSort,
locale: String,
offset: Long,
limit: Int
): List<CreatorChannelAudioContentRecord> {
val rows = findAudioContentRows(creatorId, viewerId, themeId, now, canViewAdultContent, sort, offset, limit)
val contentIds = rows.map { itAudioId(it) }
val firstContentId = firstAudioContentId(creatorId, now, canViewAdultContent)
val seriesByContentId = audioSeriesByContentIds(contentIds, locale)
val orderStatesByContentId = orderStatesByContentIds(viewerId, contentIds, now)
return rows.map { it.toAudioRecord(firstContentId, seriesByContentId, orderStatesByContentId) }
}
private fun findAudioContentRows(
creatorId: Long,
viewerId: Long,
themeId: Long?,
now: LocalDateTime,
canViewAdultContent: Boolean,
sort: ContentSort,
offset: Long,
limit: Int
): List<Tuple> {
val query = queryFactory
.select(
audioContent.id,
audioContent.title,
audioContent.duration,
audioContent.coverImage,
audioContent.price,
audioContent.isAdult,
audioContent.isPointAvailable,
audioContent.releaseDate
)
.from(audioContent)
.innerJoin(audioContent.theme, audioContentTheme)
.where(audioContentCondition(creatorId, themeId, now, canViewAdultContent))
when (sort) {
ContentSort.POPULAR -> {
val revenueOrder = QOrder("audioRevenueOrder")
query
.leftJoin(revenueOrder)
.on(
revenueOrder.audioContent.id.eq(audioContent.id),
revenueOrder.isActive.isTrue
)
.groupByAudioContentRow()
.orderBy(
revenueOrder.can.sum().coalesce(0).desc(),
audioContent.releaseDate.desc(),
audioContent.id.desc()
)
}
ContentSort.OWNED -> {
val ownedOrder = QOrder("audioOwnedOrder")
query
.leftJoin(ownedOrder)
.on(
ownedOrder.audioContent.id.eq(audioContent.id),
ownedOrder.member.id.eq(viewerId),
ownedOrder.isActive.isTrue,
validPurchasedOrderCondition(ownedOrder, now)
)
.groupByAudioContentRow()
.orderBy(
CaseBuilder()
.`when`(ownedOrder.id.countDistinct().gt(0))
.then(1)
.otherwise(0)
.desc(),
audioContent.releaseDate.desc(),
audioContent.id.desc()
)
}
ContentSort.LATEST -> query.orderBy(
audioContent.releaseDate.desc(),
audioContent.price.desc(),
audioContent.id.desc()
)
ContentSort.PRICE_HIGH -> query.orderBy(
audioContent.price.desc(),
audioContent.releaseDate.desc(),
audioContent.id.desc()
)
ContentSort.PRICE_LOW -> query.orderBy(
audioContent.price.asc(),
audioContent.releaseDate.desc(),
audioContent.id.desc()
)
}
return query
.offset(offset)
.limit(limit.toLong())
.fetch()
}
private fun com.querydsl.jpa.impl.JPAQuery<Tuple>.groupByAudioContentRow(): com.querydsl.jpa.impl.JPAQuery<Tuple> {
return groupBy(
audioContent.id,
audioContent.title,
audioContent.duration,
audioContent.coverImage,
audioContent.price,
audioContent.isAdult,
audioContent.isPointAvailable,
audioContent.releaseDate
)
}
private fun audioContentCondition(
creatorId: Long,
themeId: Long?,
now: LocalDateTime,
canViewAdultContent: Boolean
): BooleanExpression {
return audioContent.member.id.eq(creatorId)
.and(audioContent.member.isActive.isTrue)
.and(audioContent.isActive.isTrue)
.and(audioContentTheme.isActive.isTrue)
.and(themeCondition(themeId))
.and(audioContent.duration.isNotNull)
.and(audioContent.releaseDate.isNotNull)
.and(audioContent.releaseDate.loe(now))
.and(adultAudioCondition(canViewAdultContent))
}
private fun themeCondition(themeId: Long?): BooleanExpression? {
return themeId?.let { audioContentTheme.id.eq(it) }
}
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 fun itAudioId(row: Tuple): Long = row.get(audioContent.id)!!
private fun Tuple.toAudioRecord(
firstContentId: Long?,
seriesByContentId: Map<Long, AudioSeriesSummary>,
orderStatesByContentId: Map<Long, AudioOrderState>
): CreatorChannelAudioContentRecord {
val audioContentId = get(audioContent.id)!!
val seriesSummary = seriesByContentId[audioContentId]
val orderState = orderStatesByContentId[audioContentId]
return CreatorChannelAudioContentRecord(
audioContentId = audioContentId,
title = get(audioContent.title)!!,
duration = get(audioContent.duration),
imagePath = get(audioContent.coverImage),
price = get(audioContent.price)!!,
isAdult = get(audioContent.isAdult)!!,
isPointAvailable = get(audioContent.isPointAvailable)!!,
isFirstContent = firstContentId == audioContentId,
seriesName = seriesSummary?.title,
isOriginalSeries = seriesSummary?.isOriginal,
isOwned = orderState?.isOwned ?: false,
isRented = orderState?.isRented ?: false
)
}
private fun firstAudioContentId(
creatorId: Long,
now: LocalDateTime,
canViewAdultContent: Boolean
): Long? {
return queryFactory
.select(audioContent.id)
.from(audioContent)
.innerJoin(audioContent.theme, audioContentTheme)
.where(audioContentCondition(creatorId, themeId = null, now, canViewAdultContent))
.orderBy(audioContent.releaseDate.asc(), audioContent.id.asc())
.fetchFirst()
}
private fun audioSeriesByContentIds(contentIds: List<Long>, locale: String): Map<Long, AudioSeriesSummary> {
if (contentIds.isEmpty()) return emptyMap()
val seriesTranslation = QSeriesTranslation("audioSeriesTranslation")
return queryFactory
.select(
seriesContent.content.id,
series.title,
series.isOriginal,
seriesTranslation
)
.from(seriesContent)
.innerJoin(seriesContent.series, series)
.leftJoin(seriesTranslation)
.on(
seriesTranslation.seriesId.eq(series.id),
seriesTranslation.locale.eq(locale)
)
.where(seriesContent.content.id.`in`(contentIds))
.fetch()
.associate {
val originalTitle = it.get(series.title)!!
val translatedTitle = it.get(seriesTranslation)?.renderedPayload?.title
it.get(seriesContent.content.id)!! to AudioSeriesSummary(
title = translatedTitle.takeUnless(String?::isNullOrBlank) ?: originalTitle,
isOriginal = it.get(series.isOriginal)!!
)
}
}
private fun orderStatesByContentIds(
viewerId: Long,
contentIds: List<Long>,
now: LocalDateTime
): Map<Long, AudioOrderState> {
if (contentIds.isEmpty()) return emptyMap()
return queryFactory
.select(order.audioContent.id, order.type)
.from(order)
.where(
order.member.id.eq(viewerId),
order.audioContent.id.`in`(contentIds),
order.isActive.isTrue,
validPurchasedOrderCondition(order, now)
)
.fetch()
.groupBy { it.get(order.audioContent.id)!! }
.mapValues { (_, rows) ->
val types = rows.map { it.get(order.type)!! }.toSet()
AudioOrderState(
isOwned = OrderType.KEEP in types,
isRented = OrderType.RENTAL in types
)
}
}
private fun adultAudioCondition(canViewAdultContent: Boolean): BooleanExpression? {
return if (canViewAdultContent) null else audioContent.isAdult.isFalse
}
private data class AudioSeriesSummary(
val title: String,
val isOriginal: Boolean
)
private data class AudioOrderState(
val isOwned: Boolean,
val isRented: Boolean
)
}