feat(audio-recommendation): 추천 snapshot 저장소를 추가한다

This commit is contained in:
2026-06-23 21:05:15 +09:00
parent b7052f03f6
commit 70346b911f
3 changed files with 498 additions and 2 deletions

View File

@@ -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")
}
}

View File

@@ -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>
}