feat(home-following): 팔로잉 탭 조회 repository를 추가한다

This commit is contained in:
2026-06-26 02:47:06 +09:00
parent 91c648ca44
commit 45fc8bd21f
2 changed files with 798 additions and 0 deletions

View File

@@ -0,0 +1,343 @@
package kr.co.vividnext.sodalive.v2.home.following.adapter.out.persistence
import com.querydsl.core.Tuple
import com.querydsl.core.types.Expression
import com.querydsl.core.types.dsl.BooleanExpression
import com.querydsl.jpa.JPAExpressions
import com.querydsl.jpa.impl.JPAQueryFactory
import kr.co.vividnext.sodalive.content.QAudioContent
import kr.co.vividnext.sodalive.content.QAudioContent.audioContent
import kr.co.vividnext.sodalive.explorer.profile.creatorCommunity.QCreatorCommunity
import kr.co.vividnext.sodalive.extensions.toUtcIso
import kr.co.vividnext.sodalive.live.room.QLiveRoom.liveRoom
import kr.co.vividnext.sodalive.member.MemberRole
import kr.co.vividnext.sodalive.member.QMember
import kr.co.vividnext.sodalive.member.block.QBlockMember
import kr.co.vividnext.sodalive.member.following.QCreatorFollowing.creatorFollowing
import kr.co.vividnext.sodalive.v2.common.domain.CreatorActivityType
import kr.co.vividnext.sodalive.v2.common.domain.toCdnUrl
import kr.co.vividnext.sodalive.v2.home.following.adapter.out.persistence.QHomeFollowingNewsInbox.homeFollowingNewsInbox
import kr.co.vividnext.sodalive.v2.home.following.domain.FollowingNewsType
import kr.co.vividnext.sodalive.v2.home.following.domain.HomeFollowingCreator
import kr.co.vividnext.sodalive.v2.home.following.domain.HomeFollowingLive
import kr.co.vividnext.sodalive.v2.home.following.domain.HomeFollowingNews
import kr.co.vividnext.sodalive.v2.home.following.domain.HomeFollowingSchedule
import org.springframework.beans.factory.annotation.Value
import org.springframework.stereotype.Repository
import java.time.LocalDateTime
import java.time.ZoneId
import java.time.ZoneOffset
@Repository
class DefaultHomeFollowingQueryRepository(
private val queryFactory: JPAQueryFactory,
@Value("\${cloud.aws.cloud-front.host}")
private val cloudFrontHost: String
) : HomeFollowingQueryRepository {
override fun findFollowingCreators(memberId: Long, limit: Int): List<HomeFollowingCreator> {
val creator = QMember("followingCreator")
return queryFactory
.select(creator.id, creator.nickname, creator.profileImage)
.from(creatorFollowing)
.join(creatorFollowing.creator, creator)
.where(
creatorFollowing.member.id.eq(memberId),
creatorFollowing.isActive.isTrue,
creator.isActive.isTrue,
creator.role.eq(MemberRole.CREATOR),
notBlockedCreatorCondition(memberId, creator.id)
)
.orderBy(creatorFollowing.createdAt.desc(), creatorFollowing.id.desc())
.limit(limit.toLong())
.fetch()
.map { row ->
HomeFollowingCreator(
creatorId = row.get(creator.id)!!,
creatorNickname = row.get(creator.nickname)!!,
creatorProfileImageUrl = profileImageUrl(row.get(creator.profileImage))
)
}
}
override fun findOnAirLives(memberId: Long, canViewAdultContent: Boolean, limit: Int): List<HomeFollowingLive> {
val creator = QMember("onAirCreator")
return queryFactory
.select(liveRoom.id, creator.profileImage, creator.nickname, liveRoom.title, liveRoom.beginDateTime)
.from(liveRoom)
.join(liveRoom.member, creator)
.join(creatorFollowing).on(creatorFollowing.creator.id.eq(creator.id))
.where(
creatorFollowing.member.id.eq(memberId),
creatorFollowing.isActive.isTrue,
liveRoom.isActive.isTrue,
liveRoom.channelName.isNotNull,
liveRoom.channelName.isNotEmpty,
creator.isActive.isTrue,
creator.role.eq(MemberRole.CREATOR),
adultLiveCondition(canViewAdultContent),
notBlockedCreatorCondition(memberId, creator.id)
)
.orderBy(liveRoom.beginDateTime.desc(), liveRoom.id.desc())
.limit(limit.toLong())
.fetch()
.map { row ->
HomeFollowingLive(
liveId = row.get(liveRoom.id)!!,
creatorProfileImageUrl = profileImageUrl(row.get(creator.profileImage)),
creatorNickname = row.get(creator.nickname)!!,
title = row.get(liveRoom.title)!!,
startedAtUtc = row.get(liveRoom.beginDateTime)!!.toUtcIso()
)
}
}
override fun findMonthlySchedules(
memberId: Long,
canViewAdultContent: Boolean,
now: LocalDateTime,
limit: Int
): List<HomeFollowingSchedule> {
val window = monthlyScheduleWindow(now)
val liveSchedules = findLiveSchedules(memberId, canViewAdultContent, window)
val audioSchedules = findAudioSchedules(memberId, canViewAdultContent, window)
return (liveSchedules + audioSchedules)
.sortedWith(
compareBy<HomeFollowingSchedule> { it.scheduledAtUtc }
.thenBy { it.type.sortOrder }
.thenBy { it.targetId }
)
.take(limit)
}
override fun findRecentNews(
memberId: Long,
canViewAdultContent: Boolean,
nowUtc: LocalDateTime,
limit: Int
): List<HomeFollowingNews> {
val creator = QMember("newsCreator")
return queryFactory
.select(
homeFollowingNewsInbox.id,
homeFollowingNewsInbox.newsType,
homeFollowingNewsInbox.creatorProfileImagePath,
homeFollowingNewsInbox.creatorNickname,
homeFollowingNewsInbox.title,
homeFollowingNewsInbox.body,
homeFollowingNewsInbox.thumbnailImagePath,
homeFollowingNewsInbox.targetId,
homeFollowingNewsInbox.occurredAtUtc,
homeFollowingNewsInbox.visibleFromAtUtc,
homeFollowingNewsInbox.rank
)
.from(homeFollowingNewsInbox)
.join(creator).on(creator.id.eq(homeFollowingNewsInbox.creatorId))
.where(
homeFollowingNewsInbox.memberId.eq(memberId),
homeFollowingNewsInbox.isActive.isTrue,
homeFollowingNewsInbox.visibleFromAtUtc.loe(nowUtc),
creator.isActive.isTrue,
creator.role.eq(MemberRole.CREATOR),
adultNewsCondition(canViewAdultContent),
notBlockedCreatorCondition(memberId, homeFollowingNewsInbox.creatorId),
activeNewsTargetCondition()
)
.orderBy(homeFollowingNewsInbox.visibleFromAtUtc.desc(), homeFollowingNewsInbox.id.desc())
.limit(limit.toLong())
.fetch()
.map { row ->
HomeFollowingNews(
newsId = row.get(homeFollowingNewsInbox.id)!!.toString(),
type = row.get(homeFollowingNewsInbox.newsType)!!,
creatorProfileImageUrl = profileImageUrl(row.get(homeFollowingNewsInbox.creatorProfileImagePath)),
creatorNickname = row.get(homeFollowingNewsInbox.creatorNickname)!!,
title = row.get(homeFollowingNewsInbox.title)!!,
body = row.get(homeFollowingNewsInbox.body)!!,
thumbnailImageUrl = row.get(homeFollowingNewsInbox.thumbnailImagePath).toCdnUrl(cloudFrontHost),
targetId = row.get(homeFollowingNewsInbox.targetId)!!,
occurredAtUtc = row.get(homeFollowingNewsInbox.occurredAtUtc)!!.toUtcIso(),
visibleFromAtUtc = row.get(homeFollowingNewsInbox.visibleFromAtUtc)!!.toUtcIso(),
rank = row.get(homeFollowingNewsInbox.rank)
)
}
}
private fun findLiveSchedules(
memberId: Long,
canViewAdultContent: Boolean,
window: ScheduleWindow
): List<HomeFollowingSchedule> {
val creator = QMember("scheduleLiveCreator")
return queryFactory
.select(
liveRoom.id,
creator.id,
creator.profileImage,
creator.nickname,
liveRoom.title,
liveRoom.beginDateTime,
liveRoom.channelName
)
.from(liveRoom)
.join(liveRoom.member, creator)
.join(creatorFollowing).on(creatorFollowing.creator.id.eq(creator.id))
.where(
creatorFollowing.member.id.eq(memberId),
creatorFollowing.isActive.isTrue,
liveRoom.isActive.isTrue,
liveRoom.beginDateTime.goe(window.startUtc),
liveRoom.beginDateTime.lt(window.endUtc),
creator.isActive.isTrue,
creator.role.eq(MemberRole.CREATOR),
adultLiveCondition(canViewAdultContent),
notBlockedCreatorCondition(memberId, creator.id)
)
.fetch()
.map { row -> row.toLiveSchedule(creator) }
}
private fun findAudioSchedules(
memberId: Long,
canViewAdultContent: Boolean,
window: ScheduleWindow
): List<HomeFollowingSchedule> {
val creator = QMember("scheduleAudioCreator")
return queryFactory
.select(
audioContent.id,
creator.id,
creator.profileImage,
creator.nickname,
audioContent.title,
audioContent.releaseDate
)
.from(audioContent)
.join(audioContent.member, creator)
.join(creatorFollowing).on(creatorFollowing.creator.id.eq(creator.id))
.where(
creatorFollowing.member.id.eq(memberId),
creatorFollowing.isActive.isTrue,
audioContent.duration.isNotNull,
audioContent.releaseDate.isNotNull,
audioContent.releaseDate.goe(window.startUtc),
audioContent.releaseDate.lt(window.endUtc),
creator.isActive.isTrue,
creator.role.eq(MemberRole.CREATOR),
adultAudioCondition(canViewAdultContent),
notBlockedCreatorCondition(memberId, creator.id)
)
.fetch()
.map { row -> row.toAudioSchedule(creator) }
}
private fun Tuple.toLiveSchedule(creator: QMember): HomeFollowingSchedule {
val liveId = get(liveRoom.id)!!
val channelName = get(liveRoom.channelName)
return HomeFollowingSchedule(
scheduleId = "${CreatorActivityType.LIVE}:$liveId",
creatorId = get(creator.id)!!,
creatorProfileImageUrl = profileImageUrl(get(creator.profileImage)),
creatorNickname = get(creator.nickname)!!,
title = get(liveRoom.title)!!,
type = CreatorActivityType.LIVE,
targetId = liveId,
scheduledAtUtc = get(liveRoom.beginDateTime)!!.toUtcIso(),
isOnAir = !channelName.isNullOrBlank()
)
}
private fun Tuple.toAudioSchedule(creator: QMember): HomeFollowingSchedule {
val contentId = get(audioContent.id)!!
return HomeFollowingSchedule(
scheduleId = "${CreatorActivityType.AUDIO}:$contentId",
creatorId = get(creator.id)!!,
creatorProfileImageUrl = profileImageUrl(get(creator.profileImage)),
creatorNickname = get(creator.nickname)!!,
title = get(audioContent.title)!!,
type = CreatorActivityType.AUDIO,
targetId = contentId,
scheduledAtUtc = get(audioContent.releaseDate)!!.toUtcIso(),
isOnAir = false
)
}
private fun monthlyScheduleWindow(now: LocalDateTime): ScheduleWindow {
val kstNow = now.atOffset(ZoneOffset.UTC).atZoneSameInstant(KST_ZONE_ID).toLocalDateTime()
val startKst = kstNow.toLocalDate().atStartOfDay()
val endKst = startKst.toLocalDate().plusMonths(1).withDayOfMonth(1).atStartOfDay()
return ScheduleWindow(startUtc = startKst.toUtcFromKst(), endUtc = endKst.toUtcFromKst())
}
private fun LocalDateTime.toUtcFromKst(): LocalDateTime {
return atZone(KST_ZONE_ID).withZoneSameInstant(ZoneOffset.UTC).toLocalDateTime()
}
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 adultNewsCondition(canViewAdultContent: Boolean): BooleanExpression? {
return if (canViewAdultContent) null else homeFollowingNewsInbox.isAdult.isFalse
}
private fun activeNewsTargetCondition(): BooleanExpression {
val newsAudioContent = QAudioContent("newsAudioContent")
val newsCommunity = QCreatorCommunity("newsCommunity")
val activeAudioExists = JPAExpressions
.selectOne()
.from(newsAudioContent)
.where(
newsAudioContent.id.eq(homeFollowingNewsInbox.targetId),
newsAudioContent.isActive.isTrue
)
.exists()
val activeCommunityExists = JPAExpressions
.selectOne()
.from(newsCommunity)
.where(
newsCommunity.id.eq(homeFollowingNewsInbox.targetId),
newsCommunity.isActive.isTrue
)
.exists()
return homeFollowingNewsInbox.newsType.eq(FollowingNewsType.CREATOR_RANKING)
.or(homeFollowingNewsInbox.newsType.eq(FollowingNewsType.AUDIO_CONTENT).and(activeAudioExists))
.or(homeFollowingNewsInbox.newsType.eq(FollowingNewsType.COMMUNITY_POST).and(activeCommunityExists))
}
private fun notBlockedCreatorCondition(memberId: Long, creatorIdPath: Expression<Long>): BooleanExpression {
val blockMember = QBlockMember("homeFollowingBlockMember")
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 profileImageUrl(path: String?): String {
return path.toCdnUrl(cloudFrontHost) ?: "$cloudFrontHost/profile/default-profile.png"
}
private val CreatorActivityType.sortOrder: Int
get() = when (this) {
CreatorActivityType.LIVE -> 0
else -> 1
}
private data class ScheduleWindow(
val startUtc: LocalDateTime,
val endUtc: LocalDateTime
)
companion object {
private val KST_ZONE_ID: ZoneId = ZoneId.of("Asia/Seoul")
}
}