feat(audio-recommendation): 추천 snapshot 저장소를 추가한다
This commit is contained in:
@@ -19,17 +19,24 @@ 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.AudioRecommendationVisibility
|
||||
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 kr.co.vividnext.sodalive.v2.recommendation.domain.RecommendedSectionType
|
||||
import kr.co.vividnext.sodalive.v2.recommendation.port.out.RecommendationSnapshotRecord
|
||||
import org.springframework.beans.factory.annotation.Value
|
||||
import org.springframework.stereotype.Repository
|
||||
import java.math.BigDecimal
|
||||
import java.math.BigInteger
|
||||
import java.time.LocalDateTime
|
||||
import javax.persistence.EntityManager
|
||||
|
||||
@Repository
|
||||
class DefaultAudioRecommendationQueryRepository(
|
||||
private val queryFactory: JPAQueryFactory,
|
||||
private val entityManager: EntityManager,
|
||||
@Value("\${cloud.aws.cloud-front.host}")
|
||||
private val cloudFrontHost: String
|
||||
) : AudioRecommendationQueryRepository {
|
||||
@@ -151,7 +158,239 @@ class DefaultAudioRecommendationQueryRepository(
|
||||
memberId: Long?,
|
||||
canViewAdultContent: Boolean
|
||||
): List<CommentedAudio> {
|
||||
return emptyList()
|
||||
if (contentIds.isEmpty()) return emptyList()
|
||||
val contentOrder = contentIds.withIndex().associate { it.value to it.index }
|
||||
val sql = """
|
||||
select c.id, c.title, c.cover_image, latest.comment, writer.profile_image
|
||||
from content c
|
||||
join member creator on creator.id = c.member_id
|
||||
join content_theme theme on theme.id = c.theme_id
|
||||
join content_comment latest on latest.content_id = c.id
|
||||
and latest.is_active = true
|
||||
and latest.parent_id is null
|
||||
and latest.is_secret = false
|
||||
join member writer on writer.id = latest.member_id and writer.is_active = true
|
||||
where c.id in (:contentIds)
|
||||
and c.is_active = true
|
||||
and c.duration is not null
|
||||
and c.release_date is not null
|
||||
and c.release_date <= CURRENT_TIMESTAMP
|
||||
and creator.is_active = true
|
||||
and theme.is_active = true
|
||||
and (:canViewAdultContent = true or c.is_adult = false)
|
||||
and (:memberId is null or not exists (
|
||||
select 1 from block_member bm
|
||||
where bm.is_active = true
|
||||
and ((bm.member_id = :memberId and bm.blocked_member_id = creator.id)
|
||||
or (bm.member_id = creator.id and bm.blocked_member_id = :memberId))
|
||||
))
|
||||
and (:memberId is null or not exists (
|
||||
select 1 from block_member bm
|
||||
where bm.is_active = true
|
||||
and ((bm.member_id = :memberId and bm.blocked_member_id = writer.id)
|
||||
or (bm.member_id = writer.id and bm.blocked_member_id = :memberId))
|
||||
))
|
||||
and not exists (
|
||||
select 1 from block_member bm
|
||||
where bm.is_active = true
|
||||
and ((bm.member_id = creator.id and bm.blocked_member_id = writer.id)
|
||||
or (bm.member_id = writer.id and bm.blocked_member_id = creator.id))
|
||||
)
|
||||
and not exists (
|
||||
select 1
|
||||
from content_comment newer
|
||||
join member newer_writer on newer_writer.id = newer.member_id and newer_writer.is_active = true
|
||||
where newer.content_id = c.id
|
||||
and newer.is_active = true
|
||||
and newer.parent_id is null
|
||||
and newer.is_secret = false
|
||||
and (
|
||||
newer.created_at > latest.created_at
|
||||
or (newer.created_at = latest.created_at and newer.id > latest.id)
|
||||
)
|
||||
and (:memberId is null or not exists (
|
||||
select 1 from block_member bm
|
||||
where bm.is_active = true
|
||||
and ((bm.member_id = :memberId and bm.blocked_member_id = newer_writer.id)
|
||||
or (bm.member_id = newer_writer.id and bm.blocked_member_id = :memberId))
|
||||
))
|
||||
and not exists (
|
||||
select 1 from block_member bm
|
||||
where bm.is_active = true
|
||||
and ((bm.member_id = creator.id and bm.blocked_member_id = newer_writer.id)
|
||||
or (bm.member_id = newer_writer.id and bm.blocked_member_id = creator.id))
|
||||
)
|
||||
)
|
||||
""".trimIndent()
|
||||
return entityManager.createNativeQuery(sql)
|
||||
.setParameter("contentIds", contentIds)
|
||||
.setParameter("memberId", memberId)
|
||||
.setParameter("canViewAdultContent", canViewAdultContent)
|
||||
.resultList
|
||||
.map { row ->
|
||||
val values = row as Array<*>
|
||||
CommentedAudio(
|
||||
audioContentId = values[0].toLongValue(),
|
||||
title = values[1] as String,
|
||||
imageUrl = (values[2] as String?).toCdnUrl(cloudFrontHost),
|
||||
latestComment = values[3] as String,
|
||||
latestCommentWriterProfileImageUrl = (values[4] as String?).toCdnUrl(cloudFrontHost)
|
||||
?: "$cloudFrontHost/profile/default-profile.png"
|
||||
)
|
||||
}
|
||||
.sortedBy { contentOrder[it.audioContentId] ?: Int.MAX_VALUE }
|
||||
}
|
||||
|
||||
override fun findNewAndHotSnapshots(
|
||||
windowStart: LocalDateTime,
|
||||
snapshotAt: LocalDateTime,
|
||||
visibility: AudioRecommendationVisibility,
|
||||
limit: Int
|
||||
): List<RecommendationSnapshotRecord> {
|
||||
return findScoredSnapshots(
|
||||
windowStart = windowStart,
|
||||
snapshotAt = snapshotAt,
|
||||
visibility = visibility,
|
||||
limit = limit,
|
||||
sectionType = visibility.newAndHotSectionType(),
|
||||
scoreExpression = """
|
||||
coalesce(v.view_count, 0) * 35.0
|
||||
+ coalesce(l.like_count, 0) * 15.0
|
||||
+ coalesce(cm.comment_count, 0) * 15.0
|
||||
+ case
|
||||
when timestampdiff(day, c.release_date, :snapshotAt) <= 3 then 1.3
|
||||
when timestampdiff(day, c.release_date, :snapshotAt) <= 7 then 1.15
|
||||
when timestampdiff(day, c.release_date, :snapshotAt) <= 14 then 1.0
|
||||
else 0.8
|
||||
end * 35.0
|
||||
""".trimIndent()
|
||||
)
|
||||
}
|
||||
|
||||
override fun findMostCommentedSnapshots(
|
||||
windowStart: LocalDateTime,
|
||||
snapshotAt: LocalDateTime,
|
||||
visibility: AudioRecommendationVisibility,
|
||||
limit: Int
|
||||
): List<RecommendationSnapshotRecord> {
|
||||
return findScoredSnapshots(
|
||||
windowStart = windowStart,
|
||||
snapshotAt = snapshotAt,
|
||||
visibility = visibility,
|
||||
limit = limit,
|
||||
sectionType = visibility.mostCommentedSectionType(),
|
||||
scoreExpression = """
|
||||
coalesce(cm.comment_count, 0) * 80.0
|
||||
+ case
|
||||
when timestampdiff(day, cm.latest_comment_at, :snapshotAt) <= 3 then 1.3
|
||||
when timestampdiff(day, cm.latest_comment_at, :snapshotAt) <= 7 then 1.15
|
||||
when timestampdiff(day, cm.latest_comment_at, :snapshotAt) <= 14 then 1.0
|
||||
else 0.0
|
||||
end * 20.0
|
||||
""".trimIndent(),
|
||||
requireComments = true
|
||||
)
|
||||
}
|
||||
|
||||
override fun findRecommendedAudioSnapshots(
|
||||
windowStart: LocalDateTime,
|
||||
snapshotAt: LocalDateTime,
|
||||
visibility: AudioRecommendationVisibility,
|
||||
limit: Int
|
||||
): List<RecommendationSnapshotRecord> {
|
||||
return findScoredSnapshots(
|
||||
windowStart = windowStart,
|
||||
snapshotAt = snapshotAt,
|
||||
visibility = visibility,
|
||||
limit = limit,
|
||||
sectionType = visibility.recommendedAudioSectionType(),
|
||||
scoreExpression = """
|
||||
coalesce(v.view_count, 0) * 45.0
|
||||
+ coalesce(l.like_count, 0) * 25.0
|
||||
+ coalesce(cm.comment_count, 0) * 20.0
|
||||
+ case
|
||||
when timestampdiff(day, c.release_date, :snapshotAt) <= 3 then 1.3
|
||||
when timestampdiff(day, c.release_date, :snapshotAt) <= 7 then 1.15
|
||||
when timestampdiff(day, c.release_date, :snapshotAt) <= 30 then 1.1
|
||||
else 1.0
|
||||
end * 10.0
|
||||
""".trimIndent()
|
||||
)
|
||||
}
|
||||
|
||||
private fun findScoredSnapshots(
|
||||
windowStart: LocalDateTime,
|
||||
snapshotAt: LocalDateTime,
|
||||
visibility: AudioRecommendationVisibility,
|
||||
limit: Int,
|
||||
sectionType: RecommendedSectionType,
|
||||
scoreExpression: String,
|
||||
requireComments: Boolean = false
|
||||
): List<RecommendationSnapshotRecord> {
|
||||
val commentJoin = if (requireComments) "join" else "left join"
|
||||
val commentRequirement = if (requireComments) "and cm.comment_count is not null" else ""
|
||||
val sql = """
|
||||
select c.id, ($scoreExpression) score, rand() random_tie_breaker
|
||||
from content c
|
||||
join member creator on creator.id = c.member_id
|
||||
join content_theme theme on theme.id = c.theme_id
|
||||
left join (
|
||||
select content_id, count(*) view_count
|
||||
from creator_content_view_history
|
||||
where viewed_at >= :windowStart and viewed_at <= :snapshotAt
|
||||
group by content_id
|
||||
) v on v.content_id = c.id
|
||||
left join (
|
||||
select content_id, count(*) like_count
|
||||
from content_like
|
||||
where is_active = true and created_at >= :windowStart and created_at <= :snapshotAt
|
||||
group by content_id
|
||||
) l on l.content_id = c.id
|
||||
$commentJoin (
|
||||
select cc.content_id, count(*) comment_count, max(cc.created_at) latest_comment_at
|
||||
from content_comment cc
|
||||
join content comment_content on comment_content.id = cc.content_id
|
||||
join member comment_writer on comment_writer.id = cc.member_id
|
||||
where cc.is_active = true
|
||||
and cc.parent_id is null
|
||||
and cc.is_secret = false
|
||||
and comment_writer.is_active = true
|
||||
and not exists (
|
||||
select 1 from block_member bm
|
||||
where bm.is_active = true
|
||||
and ((bm.member_id = comment_content.member_id and bm.blocked_member_id = comment_writer.id)
|
||||
or (bm.member_id = comment_writer.id and bm.blocked_member_id = comment_content.member_id))
|
||||
)
|
||||
and cc.created_at >= :windowStart and cc.created_at <= :snapshotAt
|
||||
group by cc.content_id
|
||||
) cm on cm.content_id = c.id
|
||||
where c.is_active = true
|
||||
and c.duration is not null
|
||||
and c.release_date is not null
|
||||
and c.release_date <= :snapshotAt
|
||||
and creator.is_active = true
|
||||
and theme.is_active = true
|
||||
and (:includeAdult = true or c.is_adult = false)
|
||||
$commentRequirement
|
||||
order by score desc, random_tie_breaker asc
|
||||
limit :limit
|
||||
""".trimIndent()
|
||||
return entityManager.createNativeQuery(sql)
|
||||
.setParameter("windowStart", windowStart)
|
||||
.setParameter("snapshotAt", snapshotAt)
|
||||
.setParameter("includeAdult", visibility == AudioRecommendationVisibility.ALL)
|
||||
.setParameter("limit", limit)
|
||||
.resultList
|
||||
.map { row ->
|
||||
val values = row as Array<*>
|
||||
RecommendationSnapshotRecord(
|
||||
sectionType = sectionType,
|
||||
targetId = values[0].toLongValue(),
|
||||
score = values[1].toDoubleValue(),
|
||||
snapshotAt = snapshotAt,
|
||||
randomTieBreaker = values[2].toDoubleValue()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun audioRows(
|
||||
@@ -300,3 +539,44 @@ class DefaultAudioRecommendationQueryRepository(
|
||||
return if (condition == null) this else and(condition)
|
||||
}
|
||||
}
|
||||
|
||||
private fun AudioRecommendationVisibility.newAndHotSectionType(): RecommendedSectionType {
|
||||
return when (this) {
|
||||
AudioRecommendationVisibility.SAFE -> RecommendedSectionType.NEW_AND_HOT_AUDIO_SAFE
|
||||
AudioRecommendationVisibility.ALL -> RecommendedSectionType.NEW_AND_HOT_AUDIO_ALL
|
||||
}
|
||||
}
|
||||
|
||||
private fun AudioRecommendationVisibility.mostCommentedSectionType(): RecommendedSectionType {
|
||||
return when (this) {
|
||||
AudioRecommendationVisibility.SAFE -> RecommendedSectionType.MOST_COMMENTED_AUDIO_SAFE
|
||||
AudioRecommendationVisibility.ALL -> RecommendedSectionType.MOST_COMMENTED_AUDIO_ALL
|
||||
}
|
||||
}
|
||||
|
||||
private fun AudioRecommendationVisibility.recommendedAudioSectionType(): RecommendedSectionType {
|
||||
return when (this) {
|
||||
AudioRecommendationVisibility.SAFE -> RecommendedSectionType.RECOMMENDED_AUDIO_SAFE
|
||||
AudioRecommendationVisibility.ALL -> RecommendedSectionType.RECOMMENDED_AUDIO_ALL
|
||||
}
|
||||
}
|
||||
|
||||
private fun Any?.toLongValue(): Long {
|
||||
return when (this) {
|
||||
is Long -> this
|
||||
is Int -> toLong()
|
||||
is BigInteger -> toLong()
|
||||
is Number -> toLong()
|
||||
else -> error("Unsupported numeric value: $this")
|
||||
}
|
||||
}
|
||||
|
||||
private fun Any?.toDoubleValue(): Double {
|
||||
return when (this) {
|
||||
is Double -> this
|
||||
is Float -> toDouble()
|
||||
is BigDecimal -> toDouble()
|
||||
is Number -> toDouble()
|
||||
else -> error("Unsupported numeric value: $this")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
package kr.co.vividnext.sodalive.v2.audio.recommendation.port.out
|
||||
|
||||
import kr.co.vividnext.sodalive.v2.audio.recommendation.domain.AudioCard
|
||||
import kr.co.vividnext.sodalive.v2.audio.recommendation.domain.AudioRecommendationVisibility
|
||||
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.recommendation.port.out.RecommendationSnapshotRecord
|
||||
import java.time.LocalDateTime
|
||||
|
||||
interface AudioRecommendationQueryPort {
|
||||
@@ -19,4 +21,22 @@ interface AudioRecommendationQueryPort {
|
||||
now: LocalDateTime
|
||||
): List<AudioCard>
|
||||
fun findCommentedAudiosByIds(contentIds: List<Long>, memberId: Long?, canViewAdultContent: Boolean): List<CommentedAudio>
|
||||
fun findNewAndHotSnapshots(
|
||||
windowStart: LocalDateTime,
|
||||
snapshotAt: LocalDateTime,
|
||||
visibility: AudioRecommendationVisibility,
|
||||
limit: Int
|
||||
): List<RecommendationSnapshotRecord>
|
||||
fun findMostCommentedSnapshots(
|
||||
windowStart: LocalDateTime,
|
||||
snapshotAt: LocalDateTime,
|
||||
visibility: AudioRecommendationVisibility,
|
||||
limit: Int
|
||||
): List<RecommendationSnapshotRecord>
|
||||
fun findRecommendedAudioSnapshots(
|
||||
windowStart: LocalDateTime,
|
||||
snapshotAt: LocalDateTime,
|
||||
visibility: AudioRecommendationVisibility,
|
||||
limit: Int
|
||||
): List<RecommendationSnapshotRecord>
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user