feat(creator): 채널 라이브 다시듣기 저장소를 추가한다

This commit is contained in:
2026-06-17 19:16:50 +09:00
parent 108778d5d3
commit 3d843ac5d6
4 changed files with 736 additions and 5 deletions

View File

@@ -0,0 +1,5 @@
package kr.co.vividnext.sodalive.v2.creator.channel.live.adapter.out.persistence
import kr.co.vividnext.sodalive.v2.creator.channel.live.port.out.CreatorChannelLiveQueryPort
interface CreatorChannelLiveQueryRepository : CreatorChannelLiveQueryPort

View File

@@ -0,0 +1,365 @@
package kr.co.vividnext.sodalive.v2.creator.channel.live.adapter.out.persistence
import com.querydsl.core.Tuple
import com.querydsl.core.types.Projections
import com.querydsl.core.types.dsl.BooleanExpression
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.theme.QAudioContentTheme.audioContentTheme
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.live.room.GenderRestriction
import kr.co.vividnext.sodalive.live.room.QLiveRoom.liveRoom
import kr.co.vividnext.sodalive.member.Gender
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.live.port.out.CreatorChannelAudioContentRecord
import kr.co.vividnext.sodalive.v2.creator.channel.live.port.out.CreatorChannelCreatorRecord
import kr.co.vividnext.sodalive.v2.creator.channel.live.port.out.CreatorChannelLiveRecord
import org.springframework.stereotype.Repository
import java.time.LocalDateTime
@Repository
class DefaultCreatorChannelLiveQueryRepository(
private val queryFactory: JPAQueryFactory
) : CreatorChannelLiveQueryRepository {
override fun findCreator(creatorId: Long, viewerId: Long?): CreatorChannelCreatorRecord? {
return queryFactory
.select(
Projections.constructor(
CreatorChannelCreatorRecord::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("creatorChannelLiveBlockMember")
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 findCurrentLive(
creatorId: Long,
now: LocalDateTime,
canViewAdultContent: Boolean,
viewerId: Long?,
isViewerCreator: Boolean,
effectiveViewerGender: Gender?
): CreatorChannelLiveRecord? {
return queryFactory
.select(
Projections.constructor(
CreatorChannelLiveRecord::class.java,
liveRoom.id,
liveRoom.title,
liveRoom.coverImage,
liveRoom.beginDateTime,
liveRoom.price,
liveRoom.isAdult
)
)
.from(liveRoom)
.where(
liveRoom.member.id.eq(creatorId),
liveRoom.member.isActive.isTrue,
liveRoom.isActive.isTrue,
liveRoom.channelName.isNotNull,
liveRoom.channelName.isNotEmpty,
liveRoom.beginDateTime.loe(now),
adultLiveCondition(canViewAdultContent),
genderLiveCondition(viewerId, effectiveViewerGender),
creatorJoinLiveCondition(viewerId, isViewerCreator)
)
.orderBy(liveRoom.beginDateTime.desc(), liveRoom.id.desc())
.fetchFirst()
}
override fun countLiveReplayAudioContents(
creatorId: Long,
now: LocalDateTime,
canViewAdultContent: Boolean
): Int {
return queryFactory
.select(audioContent.id.count())
.from(audioContent)
.innerJoin(audioContent.theme, audioContentTheme)
.where(liveReplayAudioCondition(creatorId, now, canViewAdultContent))
.fetchOne()
?.toInt()
?: 0
}
override fun findLiveReplayAudioContents(
creatorId: Long,
viewerId: Long?,
now: LocalDateTime,
canViewAdultContent: Boolean,
sort: ContentSort,
offset: Long,
limit: Int
): List<CreatorChannelAudioContentRecord> {
val rows = findLiveReplayAudioRows(creatorId, viewerId, now, canViewAdultContent, sort, offset, limit)
val contentIds = rows.map { itAudioId(it) }
val firstContentId = firstLiveReplayAudioContentId(creatorId, now, canViewAdultContent)
val seriesByContentId = audioSeriesByContentIds(contentIds)
val orderStatesByContentId = orderStatesByContentIds(viewerId, contentIds, now)
return rows
.map { it.toAudioRecord(firstContentId, seriesByContentId, orderStatesByContentId) }
}
private fun findLiveReplayAudioRows(
creatorId: Long,
viewerId: 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(liveReplayAudioCondition(creatorId, now, canViewAdultContent))
when (sort) {
ContentSort.POPULAR -> {
val revenueOrder = QOrder("liveReplayRevenueOrder")
query
.leftJoin(revenueOrder)
.on(
revenueOrder.audioContent.id.eq(audioContent.id),
revenueOrder.isActive.isTrue
)
.groupBy(
audioContent.id,
audioContent.title,
audioContent.duration,
audioContent.coverImage,
audioContent.price,
audioContent.isAdult,
audioContent.isPointAvailable,
audioContent.releaseDate
)
.orderBy(
revenueOrder.can.sum().coalesce(0).desc(),
audioContent.releaseDate.desc(),
audioContent.id.desc()
)
}
ContentSort.OWNED -> {
val ownedOrder = QOrder("liveReplayOwnedOrder")
query
.leftJoin(ownedOrder)
.on(
ownedOrder.audioContent.id.eq(audioContent.id),
ownedOrder.member.id.eq(viewerId ?: -1L),
ownedOrder.isActive.isTrue,
ownedOrder.type.eq(OrderType.KEEP)
)
.groupBy(
audioContent.id,
audioContent.title,
audioContent.duration,
audioContent.coverImage,
audioContent.price,
audioContent.isAdult,
audioContent.isPointAvailable,
audioContent.releaseDate
)
.orderBy(
ownedOrder.id.count().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 liveReplayAudioCondition(
creatorId: 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(audioContentTheme.theme.eq(LIVE_REPLAY_THEME))
.and(audioContent.duration.isNotNull)
.and(audioContent.releaseDate.isNotNull)
.and(audioContent.releaseDate.loe(now))
.and(adultAudioCondition(canViewAdultContent))
}
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,
publishedAt = get(audioContent.releaseDate)!!,
seriesName = seriesSummary?.title,
isOriginalSeries = seriesSummary?.isOriginal,
isOwned = orderState?.isOwned ?: false,
isRented = orderState?.isRented ?: false
)
}
private fun firstLiveReplayAudioContentId(
creatorId: Long,
now: LocalDateTime,
canViewAdultContent: Boolean
): Long? {
return queryFactory
.select(audioContent.id)
.from(audioContent)
.innerJoin(audioContent.theme, audioContentTheme)
.where(liveReplayAudioCondition(creatorId, now, canViewAdultContent))
.orderBy(audioContent.releaseDate.asc(), audioContent.id.asc())
.fetchFirst()
}
private fun audioSeriesByContentIds(contentIds: List<Long>): Map<Long, AudioSeriesSummary> {
if (contentIds.isEmpty()) return emptyMap()
return queryFactory
.select(seriesContent.content.id, series.title, series.isOriginal)
.from(seriesContent)
.innerJoin(seriesContent.series, series)
.where(seriesContent.content.id.`in`(contentIds))
.fetch()
.associate {
it.get(seriesContent.content.id)!! to AudioSeriesSummary(
title = it.get(series.title)!!,
isOriginal = it.get(series.isOriginal)!!
)
}
}
private fun orderStatesByContentIds(
viewerId: Long?,
contentIds: List<Long>,
now: LocalDateTime
): Map<Long, AudioOrderState> {
if (viewerId == null || 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,
order.type.eq(OrderType.KEEP)
.or(order.type.eq(OrderType.RENTAL).and(order.endDate.after(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 adultLiveCondition(canViewAdultContent: Boolean): BooleanExpression? {
return if (canViewAdultContent) null else liveRoom.isAdult.isFalse
}
private fun adultAudioCondition(canViewAdultContent: Boolean): BooleanExpression? {
return if (canViewAdultContent) null else audioContent.isAdult.isFalse
}
private fun genderLiveCondition(viewerId: Long?, effectiveViewerGender: Gender?): BooleanExpression? {
if (effectiveViewerGender == null || effectiveViewerGender == Gender.NONE) return null
val genderCondition = when (effectiveViewerGender) {
Gender.MALE -> liveRoom.genderRestriction.`in`(GenderRestriction.ALL, GenderRestriction.MALE_ONLY)
Gender.FEMALE -> liveRoom.genderRestriction.`in`(GenderRestriction.ALL, GenderRestriction.FEMALE_ONLY)
Gender.NONE -> return null
}
return viewerId?.let { genderCondition.or(liveRoom.member.id.eq(it)) } ?: genderCondition
}
private fun creatorJoinLiveCondition(viewerId: Long?, isViewerCreator: Boolean): BooleanExpression? {
if (!isViewerCreator || viewerId == null) return null
return liveRoom.isAvailableJoinCreator.isTrue.or(liveRoom.member.id.eq(viewerId))
}
private data class AudioSeriesSummary(
val title: String,
val isOriginal: Boolean
)
private data class AudioOrderState(
val isOwned: Boolean,
val isRented: Boolean
)
private companion object {
const val LIVE_REPLAY_THEME = "다시듣기"
}
}