feat(audio-recommendation): 실시간 추천 조회 repository를 추가한다
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
package kr.co.vividnext.sodalive.v2.audio.recommendation.adapter.out.persistence
|
||||
|
||||
import kr.co.vividnext.sodalive.v2.audio.recommendation.port.out.AudioRecommendationQueryPort
|
||||
|
||||
interface AudioRecommendationQueryRepository : AudioRecommendationQueryPort
|
||||
@@ -0,0 +1,302 @@
|
||||
package kr.co.vividnext.sodalive.v2.audio.recommendation.adapter.out.persistence
|
||||
|
||||
import com.querydsl.core.Tuple
|
||||
import com.querydsl.core.types.Expression
|
||||
import com.querydsl.core.types.Projections
|
||||
import com.querydsl.core.types.dsl.BooleanExpression
|
||||
import com.querydsl.core.types.dsl.Expressions
|
||||
import com.querydsl.jpa.JPAExpressions
|
||||
import com.querydsl.jpa.impl.JPAQueryFactory
|
||||
import kr.co.vividnext.sodalive.content.QAudioContent.audioContent
|
||||
import kr.co.vividnext.sodalive.content.main.banner.AudioContentBannerType
|
||||
import kr.co.vividnext.sodalive.content.main.banner.QAudioContentBanner.audioContentBanner
|
||||
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.event.EventItem
|
||||
import kr.co.vividnext.sodalive.event.QEvent.event
|
||||
import kr.co.vividnext.sodalive.member.QMember
|
||||
import kr.co.vividnext.sodalive.member.QMember.member
|
||||
import kr.co.vividnext.sodalive.member.block.QBlockMember
|
||||
import kr.co.vividnext.sodalive.v2.audio.recommendation.domain.AudioCard
|
||||
import kr.co.vividnext.sodalive.v2.audio.recommendation.domain.CommentedAudio
|
||||
import kr.co.vividnext.sodalive.v2.audio.recommendation.domain.OriginalSeries
|
||||
import kr.co.vividnext.sodalive.v2.common.domain.RecommendationBanner
|
||||
import kr.co.vividnext.sodalive.v2.common.domain.toCdnUrl
|
||||
import org.springframework.beans.factory.annotation.Value
|
||||
import org.springframework.stereotype.Repository
|
||||
import java.time.LocalDateTime
|
||||
|
||||
@Repository
|
||||
class DefaultAudioRecommendationQueryRepository(
|
||||
private val queryFactory: JPAQueryFactory,
|
||||
@Value("\${cloud.aws.cloud-front.host}")
|
||||
private val cloudFrontHost: String
|
||||
) : AudioRecommendationQueryRepository {
|
||||
override fun findBanners(limit: Int, memberId: Long?, canViewAdultContent: Boolean): List<RecommendationBanner> {
|
||||
val bannerCreator = QMember("audioRecommendationBannerCreator")
|
||||
val seriesOwner = QMember("audioRecommendationSeriesOwner")
|
||||
val randomTieBreaker = Expressions.numberTemplate(Double::class.java, "function('rand')")
|
||||
|
||||
return queryFactory
|
||||
.select(
|
||||
audioContentBanner.thumbnailImage,
|
||||
event.id,
|
||||
event.thumbnailImage,
|
||||
event.detailImage,
|
||||
event.link,
|
||||
bannerCreator.id,
|
||||
series.id,
|
||||
audioContentBanner.link
|
||||
)
|
||||
.from(audioContentBanner)
|
||||
.leftJoin(audioContentBanner.event, event)
|
||||
.leftJoin(audioContentBanner.creator, bannerCreator)
|
||||
.leftJoin(audioContentBanner.series, series)
|
||||
.leftJoin(series.member, seriesOwner)
|
||||
.where(
|
||||
audioContentBanner.isActive.isTrue,
|
||||
audioContentBanner.tab.isNull,
|
||||
activeBannerTargetCondition(memberId, bannerCreator, seriesOwner)
|
||||
)
|
||||
.orderBy(audioContentBanner.orders.asc(), randomTieBreaker.asc())
|
||||
.limit(limit.toLong())
|
||||
.fetch()
|
||||
.map { row ->
|
||||
RecommendationBanner(
|
||||
imageUrl = row.get(audioContentBanner.thumbnailImage).toCdnUrl(cloudFrontHost) ?: "",
|
||||
eventItem = row.toEventItem(),
|
||||
creatorId = row.get(bannerCreator.id),
|
||||
seriesId = row.get(series.id),
|
||||
link = row.get(audioContentBanner.link)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
override fun findOriginalSeries(
|
||||
limit: Int,
|
||||
memberId: Long?,
|
||||
canViewAdultContent: Boolean,
|
||||
now: LocalDateTime
|
||||
): List<OriginalSeries> {
|
||||
return queryFactory
|
||||
.select(Projections.constructor(OriginalSeries::class.java, series.id, series.coverImage))
|
||||
.from(series)
|
||||
.join(series.member, member)
|
||||
.where(
|
||||
series.isActive.isTrue,
|
||||
series.isOriginal.isTrue,
|
||||
member.isActive.isTrue,
|
||||
adultSeriesCondition(canViewAdultContent),
|
||||
notBlockedCreatorCondition(memberId, member.id)
|
||||
)
|
||||
.orderBy(series.createdAt.desc(), series.id.desc())
|
||||
.limit(limit.toLong())
|
||||
.fetch()
|
||||
.map { it.copy(coverImageUrl = it.coverImageUrl.toCdnUrl(cloudFrontHost)) }
|
||||
}
|
||||
|
||||
override fun findLatestAudios(
|
||||
limit: Int,
|
||||
memberId: Long?,
|
||||
canViewAdultContent: Boolean,
|
||||
now: LocalDateTime
|
||||
): List<AudioCard> {
|
||||
val rows = audioRows(memberId, canViewAdultContent, now) {
|
||||
orderBy(audioContent.releaseDate.desc(), audioContent.id.desc()).limit(limit.toLong())
|
||||
}
|
||||
return rows.toAudioCards(now, canViewAdultContent)
|
||||
}
|
||||
|
||||
override fun findFreeAudios(
|
||||
limit: Int,
|
||||
memberId: Long?,
|
||||
canViewAdultContent: Boolean,
|
||||
now: LocalDateTime
|
||||
): List<AudioCard> {
|
||||
val randomTieBreaker = Expressions.numberTemplate(Double::class.java, "function('rand')")
|
||||
val rows = audioRows(memberId, canViewAdultContent, now, audioContent.price.eq(0)) {
|
||||
orderBy(randomTieBreaker.asc()).limit(limit.toLong())
|
||||
}
|
||||
return rows.toAudioCards(now, canViewAdultContent)
|
||||
}
|
||||
|
||||
override fun findPointAudios(
|
||||
limit: Int,
|
||||
memberId: Long?,
|
||||
canViewAdultContent: Boolean,
|
||||
now: LocalDateTime
|
||||
): List<AudioCard> {
|
||||
val randomTieBreaker = Expressions.numberTemplate(Double::class.java, "function('rand')")
|
||||
val rows = audioRows(memberId, canViewAdultContent, now, audioContent.isPointAvailable.isTrue) {
|
||||
orderBy(randomTieBreaker.asc()).limit(limit.toLong())
|
||||
}
|
||||
return rows.toAudioCards(now, canViewAdultContent)
|
||||
}
|
||||
|
||||
override fun findAudioCardsByIds(
|
||||
contentIds: List<Long>,
|
||||
memberId: Long?,
|
||||
canViewAdultContent: Boolean,
|
||||
now: LocalDateTime
|
||||
): List<AudioCard> {
|
||||
if (contentIds.isEmpty()) return emptyList()
|
||||
val orderById = contentIds.withIndex().associate { it.value to it.index }
|
||||
val rows = audioRows(memberId, canViewAdultContent, now, audioContent.id.`in`(contentIds)) { this }
|
||||
return rows.toAudioCards(now, canViewAdultContent).sortedBy { orderById[it.audioContentId] ?: Int.MAX_VALUE }
|
||||
}
|
||||
|
||||
override fun findCommentedAudiosByIds(
|
||||
contentIds: List<Long>,
|
||||
memberId: Long?,
|
||||
canViewAdultContent: Boolean
|
||||
): List<CommentedAudio> {
|
||||
return emptyList()
|
||||
}
|
||||
|
||||
private fun audioRows(
|
||||
memberId: Long?,
|
||||
canViewAdultContent: Boolean,
|
||||
now: LocalDateTime,
|
||||
extraCondition: BooleanExpression? = null,
|
||||
customize: com.querydsl.jpa.impl.JPAQuery<Tuple>.() -> com.querydsl.jpa.impl.JPAQuery<Tuple>
|
||||
): List<Tuple> {
|
||||
return queryFactory
|
||||
.select(
|
||||
audioContent.id,
|
||||
audioContent.title,
|
||||
audioContent.duration,
|
||||
audioContent.coverImage,
|
||||
audioContent.price,
|
||||
audioContent.isAdult,
|
||||
audioContent.isPointAvailable,
|
||||
audioContent.member.id,
|
||||
member.nickname
|
||||
)
|
||||
.from(audioContent)
|
||||
.join(audioContent.member, member)
|
||||
.join(audioContent.theme, audioContentTheme)
|
||||
.where(publicAudioCondition(memberId, canViewAdultContent, now), extraCondition)
|
||||
.customize()
|
||||
.fetch()
|
||||
}
|
||||
|
||||
private fun List<Tuple>.toAudioCards(now: LocalDateTime, canViewAdultContent: Boolean): List<AudioCard> {
|
||||
if (isEmpty()) return emptyList()
|
||||
val contentIds = map { it.get(audioContent.id)!! }
|
||||
val creatorIds = map { it.get(audioContent.member.id)!! }.distinct()
|
||||
val firstContentIdByCreatorId = firstAudioContentIds(creatorIds, now, canViewAdultContent)
|
||||
val isOriginalSeriesByContentId = originalSeriesFlags(contentIds)
|
||||
return map { row ->
|
||||
val contentId = row.get(audioContent.id)!!
|
||||
val creatorId = row.get(audioContent.member.id)!!
|
||||
AudioCard(
|
||||
audioContentId = contentId,
|
||||
title = row.get(audioContent.title)!!,
|
||||
duration = row.get(audioContent.duration),
|
||||
imageUrl = row.get(audioContent.coverImage).toCdnUrl(cloudFrontHost),
|
||||
price = row.get(audioContent.price)!!,
|
||||
isAdult = row.get(audioContent.isAdult)!!,
|
||||
isPointAvailable = row.get(audioContent.isPointAvailable)!!,
|
||||
isFirstContent = firstContentIdByCreatorId[creatorId] == contentId,
|
||||
isOriginalSeries = isOriginalSeriesByContentId[contentId] ?: false,
|
||||
creatorNickname = row.get(member.nickname)!!
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun firstAudioContentIds(
|
||||
creatorIds: List<Long>,
|
||||
now: LocalDateTime,
|
||||
canViewAdultContent: Boolean
|
||||
): Map<Long, Long> {
|
||||
return creatorIds.associateWith { creatorId ->
|
||||
queryFactory
|
||||
.select(audioContent.id)
|
||||
.from(audioContent)
|
||||
.join(audioContent.member, member)
|
||||
.join(audioContent.theme, audioContentTheme)
|
||||
.where(
|
||||
audioContent.member.id.eq(creatorId),
|
||||
publicAudioCondition(memberId = null, canViewAdultContent, now)
|
||||
)
|
||||
.orderBy(audioContent.releaseDate.asc(), audioContent.id.asc())
|
||||
.fetchFirst()
|
||||
}.filterValues { it != null }.mapValues { it.value!! }
|
||||
}
|
||||
|
||||
private fun originalSeriesFlags(contentIds: List<Long>): Map<Long, Boolean> {
|
||||
if (contentIds.isEmpty()) return emptyMap()
|
||||
return queryFactory
|
||||
.select(seriesContent.content.id, series.isOriginal)
|
||||
.from(seriesContent)
|
||||
.join(seriesContent.series, series)
|
||||
.where(seriesContent.content.id.`in`(contentIds))
|
||||
.fetch()
|
||||
.associate { it.get(seriesContent.content.id)!! to it.get(series.isOriginal)!! }
|
||||
}
|
||||
|
||||
private fun publicAudioCondition(memberId: Long?, canViewAdultContent: Boolean, now: LocalDateTime): BooleanExpression {
|
||||
return audioContent.isActive.isTrue
|
||||
.and(audioContent.duration.isNotNull)
|
||||
.and(audioContent.releaseDate.isNotNull)
|
||||
.and(audioContent.releaseDate.loe(now))
|
||||
.and(audioContent.member.isActive.isTrue)
|
||||
.and(audioContentTheme.isActive.isTrue)
|
||||
.withOptionalAnd(adultAudioCondition(canViewAdultContent))
|
||||
.withOptionalAnd(notBlockedCreatorCondition(memberId, audioContent.member.id))
|
||||
}
|
||||
|
||||
private fun activeBannerTargetCondition(memberId: Long?, bannerCreator: QMember, seriesOwner: QMember): BooleanExpression {
|
||||
val creatorCondition = audioContentBanner.type.eq(AudioContentBannerType.CREATOR)
|
||||
.and(bannerCreator.isActive.isTrue)
|
||||
.withOptionalAnd(notBlockedCreatorCondition(memberId, bannerCreator.id))
|
||||
val seriesCondition = audioContentBanner.type.eq(AudioContentBannerType.SERIES)
|
||||
.and(series.isActive.isTrue)
|
||||
.and(seriesOwner.isActive.isTrue)
|
||||
.withOptionalAnd(notBlockedCreatorCondition(memberId, seriesOwner.id))
|
||||
|
||||
return audioContentBanner.type.eq(AudioContentBannerType.LINK)
|
||||
.or(audioContentBanner.type.eq(AudioContentBannerType.EVENT).and(event.isActive.isTrue))
|
||||
.or(creatorCondition)
|
||||
.or(seriesCondition)
|
||||
}
|
||||
|
||||
private fun Tuple.toEventItem(): EventItem? {
|
||||
val eventId = get(event.id) ?: return null
|
||||
val thumbnailImage = get(event.thumbnailImage) ?: return null
|
||||
return EventItem(
|
||||
id = eventId,
|
||||
thumbnailImageUrl = thumbnailImage.toCdnUrl(cloudFrontHost) ?: thumbnailImage,
|
||||
detailImageUrl = get(event.detailImage).toCdnUrl(cloudFrontHost),
|
||||
popupImageUrl = null,
|
||||
link = get(event.link)
|
||||
)
|
||||
}
|
||||
|
||||
private fun adultSeriesCondition(canViewAdultContent: Boolean): BooleanExpression? {
|
||||
return if (canViewAdultContent) null else series.isAdult.isFalse
|
||||
}
|
||||
|
||||
private fun adultAudioCondition(canViewAdultContent: Boolean): BooleanExpression? {
|
||||
return if (canViewAdultContent) null else audioContent.isAdult.isFalse
|
||||
}
|
||||
|
||||
private fun notBlockedCreatorCondition(memberId: Long?, creatorIdPath: Expression<Long>): BooleanExpression? {
|
||||
if (memberId == null) return null
|
||||
val blockMember = QBlockMember("audioRecommendationBlockMember")
|
||||
return JPAExpressions
|
||||
.selectOne()
|
||||
.from(blockMember)
|
||||
.where(
|
||||
blockMember.isActive.isTrue,
|
||||
blockMember.member.id.eq(memberId).and(blockMember.blockedMember.id.eq(creatorIdPath))
|
||||
.or(blockMember.member.id.eq(creatorIdPath).and(blockMember.blockedMember.id.eq(memberId)))
|
||||
)
|
||||
.notExists()
|
||||
}
|
||||
|
||||
private fun BooleanExpression.withOptionalAnd(condition: BooleanExpression?): BooleanExpression {
|
||||
return if (condition == null) this else and(condition)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user