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.QMember.member
|
||||||
import kr.co.vividnext.sodalive.member.block.QBlockMember
|
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.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.CommentedAudio
|
||||||
import kr.co.vividnext.sodalive.v2.audio.recommendation.domain.OriginalSeries
|
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.RecommendationBanner
|
||||||
import kr.co.vividnext.sodalive.v2.common.domain.toCdnUrl
|
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.beans.factory.annotation.Value
|
||||||
import org.springframework.stereotype.Repository
|
import org.springframework.stereotype.Repository
|
||||||
|
import java.math.BigDecimal
|
||||||
|
import java.math.BigInteger
|
||||||
import java.time.LocalDateTime
|
import java.time.LocalDateTime
|
||||||
|
import javax.persistence.EntityManager
|
||||||
|
|
||||||
@Repository
|
@Repository
|
||||||
class DefaultAudioRecommendationQueryRepository(
|
class DefaultAudioRecommendationQueryRepository(
|
||||||
private val queryFactory: JPAQueryFactory,
|
private val queryFactory: JPAQueryFactory,
|
||||||
|
private val entityManager: EntityManager,
|
||||||
@Value("\${cloud.aws.cloud-front.host}")
|
@Value("\${cloud.aws.cloud-front.host}")
|
||||||
private val cloudFrontHost: String
|
private val cloudFrontHost: String
|
||||||
) : AudioRecommendationQueryRepository {
|
) : AudioRecommendationQueryRepository {
|
||||||
@@ -151,7 +158,239 @@ class DefaultAudioRecommendationQueryRepository(
|
|||||||
memberId: Long?,
|
memberId: Long?,
|
||||||
canViewAdultContent: Boolean
|
canViewAdultContent: Boolean
|
||||||
): List<CommentedAudio> {
|
): 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(
|
private fun audioRows(
|
||||||
@@ -300,3 +539,44 @@ class DefaultAudioRecommendationQueryRepository(
|
|||||||
return if (condition == null) this else and(condition)
|
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
|
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.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.CommentedAudio
|
||||||
import kr.co.vividnext.sodalive.v2.audio.recommendation.domain.OriginalSeries
|
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.RecommendationBanner
|
||||||
|
import kr.co.vividnext.sodalive.v2.recommendation.port.out.RecommendationSnapshotRecord
|
||||||
import java.time.LocalDateTime
|
import java.time.LocalDateTime
|
||||||
|
|
||||||
interface AudioRecommendationQueryPort {
|
interface AudioRecommendationQueryPort {
|
||||||
@@ -19,4 +21,22 @@ interface AudioRecommendationQueryPort {
|
|||||||
now: LocalDateTime
|
now: LocalDateTime
|
||||||
): List<AudioCard>
|
): List<AudioCard>
|
||||||
fun findCommentedAudiosByIds(contentIds: List<Long>, memberId: Long?, canViewAdultContent: Boolean): List<CommentedAudio>
|
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>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ import com.querydsl.jpa.impl.JPAQueryFactory
|
|||||||
import kr.co.vividnext.sodalive.admin.content.series.genre.SeriesGenre
|
import kr.co.vividnext.sodalive.admin.content.series.genre.SeriesGenre
|
||||||
import kr.co.vividnext.sodalive.configs.QueryDslConfig
|
import kr.co.vividnext.sodalive.configs.QueryDslConfig
|
||||||
import kr.co.vividnext.sodalive.content.AudioContent
|
import kr.co.vividnext.sodalive.content.AudioContent
|
||||||
|
import kr.co.vividnext.sodalive.content.comment.AudioContentComment
|
||||||
|
import kr.co.vividnext.sodalive.content.like.AudioContentLike
|
||||||
import kr.co.vividnext.sodalive.content.main.banner.AudioContentBanner
|
import kr.co.vividnext.sodalive.content.main.banner.AudioContentBanner
|
||||||
import kr.co.vividnext.sodalive.content.main.banner.AudioContentBannerType
|
import kr.co.vividnext.sodalive.content.main.banner.AudioContentBannerType
|
||||||
import kr.co.vividnext.sodalive.content.theme.AudioContentTheme
|
import kr.co.vividnext.sodalive.content.theme.AudioContentTheme
|
||||||
@@ -12,6 +14,10 @@ import kr.co.vividnext.sodalive.creator.admin.content.series.SeriesContent
|
|||||||
import kr.co.vividnext.sodalive.member.Member
|
import kr.co.vividnext.sodalive.member.Member
|
||||||
import kr.co.vividnext.sodalive.member.MemberRole
|
import kr.co.vividnext.sodalive.member.MemberRole
|
||||||
import kr.co.vividnext.sodalive.member.block.BlockMember
|
import kr.co.vividnext.sodalive.member.block.BlockMember
|
||||||
|
import kr.co.vividnext.sodalive.v2.audio.recommendation.domain.AudioRecommendationScorePolicy
|
||||||
|
import kr.co.vividnext.sodalive.v2.audio.recommendation.domain.AudioRecommendationVisibility
|
||||||
|
import kr.co.vividnext.sodalive.v2.recommendation.adapter.out.persistence.CreatorContentViewHistory
|
||||||
|
import kr.co.vividnext.sodalive.v2.recommendation.domain.RecommendedSectionType
|
||||||
import org.junit.jupiter.api.Assertions.assertEquals
|
import org.junit.jupiter.api.Assertions.assertEquals
|
||||||
import org.junit.jupiter.api.DisplayName
|
import org.junit.jupiter.api.DisplayName
|
||||||
import org.junit.jupiter.api.Test
|
import org.junit.jupiter.api.Test
|
||||||
@@ -32,7 +38,7 @@ class DefaultAudioRecommendationQueryRepositoryTest @Autowired constructor(
|
|||||||
private val entityManager: EntityManager,
|
private val entityManager: EntityManager,
|
||||||
queryFactory: JPAQueryFactory
|
queryFactory: JPAQueryFactory
|
||||||
) {
|
) {
|
||||||
private val repository = DefaultAudioRecommendationQueryRepository(queryFactory, "https://cdn.test")
|
private val repository = DefaultAudioRecommendationQueryRepository(queryFactory, entityManager, "https://cdn.test")
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@DisplayName("배너는 홈 추천 배너와 같은 활성/탭/차단 정책과 CDN URL을 적용한다")
|
@DisplayName("배너는 홈 추천 배너와 같은 활성/탭/차단 정책과 CDN URL을 적용한다")
|
||||||
@@ -152,6 +158,155 @@ class DefaultAudioRecommendationQueryRepositoryTest @Autowired constructor(
|
|||||||
assertEquals(false, latestAudios[1].isOriginalSeries)
|
assertEquals(false, latestAudios[1].isOriginalSeries)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("New & Hot 후보는 조회/좋아요/댓글/최신성 점수순으로 산정하고 SAFE는 성인을 제외한다")
|
||||||
|
fun shouldFindNewAndHotSnapshotsWithVisibility() {
|
||||||
|
val snapshotAt = LocalDateTime.now().plusDays(1)
|
||||||
|
val windowStart = snapshotAt.minusDays(2).toLocalDate().atStartOfDay()
|
||||||
|
val creator = saveMember("snapshot-creator", MemberRole.CREATOR)
|
||||||
|
val theme = saveTheme()
|
||||||
|
val visible = saveAudio(creator, theme, "visible-hot", snapshotAt.minusDays(1))
|
||||||
|
val adult = saveAudio(creator, theme, "adult-hot", snapshotAt.minusDays(1), isAdult = true)
|
||||||
|
repeat(2) { saveView(visible, snapshotAt.minusHours(it.toLong())) }
|
||||||
|
saveLike(visible, snapshotAt.minusHours(1))
|
||||||
|
saveComment(visible, creator, "visible-comment", snapshotAt.minusHours(1))
|
||||||
|
repeat(5) { saveView(adult, snapshotAt.minusHours(it.toLong())) }
|
||||||
|
flushAndClear()
|
||||||
|
|
||||||
|
val safe = repository.findNewAndHotSnapshots(windowStart, snapshotAt, AudioRecommendationVisibility.SAFE, 12)
|
||||||
|
val all = repository.findNewAndHotSnapshots(windowStart, snapshotAt, AudioRecommendationVisibility.ALL, 12)
|
||||||
|
|
||||||
|
assertEquals(listOf(visible.id), safe.map { it.targetId })
|
||||||
|
assertEquals(RecommendedSectionType.NEW_AND_HOT_AUDIO_SAFE, safe.first().sectionType)
|
||||||
|
assertEquals(listOf(adult.id, visible.id), all.map { it.targetId })
|
||||||
|
val expectedScore = AudioRecommendationScorePolicy().calculateNewAndHotScore(
|
||||||
|
viewCount = 2,
|
||||||
|
likeCount = 1,
|
||||||
|
commentCount = 1,
|
||||||
|
releaseDate = visible.releaseDate!!,
|
||||||
|
now = snapshotAt
|
||||||
|
)
|
||||||
|
assertEquals(expectedScore, safe.first().score)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("최근 댓글 많은 오디오는 댓글 점수 후보와 최신 댓글 상세를 반환한다")
|
||||||
|
fun shouldFindMostCommentedSnapshotsAndCommentedAudios() {
|
||||||
|
val snapshotAt = LocalDateTime.now().plusDays(1)
|
||||||
|
val windowStart = snapshotAt.minusDays(6).toLocalDate().atStartOfDay()
|
||||||
|
val viewer = saveMember("comment-viewer", MemberRole.USER)
|
||||||
|
val creator = saveMember("comment-creator", MemberRole.CREATOR)
|
||||||
|
val writer = saveMember("comment-writer", MemberRole.USER).apply { profileImage = "writer.png" }
|
||||||
|
val blockedWriter = saveMember("blocked-writer", MemberRole.USER)
|
||||||
|
val inactiveWriter = saveMember("inactive-comment-writer", MemberRole.USER, isActive = false)
|
||||||
|
val theme = saveTheme()
|
||||||
|
val first = saveAudio(creator, theme, "first-commented", snapshotAt.minusDays(2), coverImage = "commented.png")
|
||||||
|
val second = saveAudio(creator, theme, "second-commented", snapshotAt.minusDays(2))
|
||||||
|
val hiddenOnly = saveAudio(creator, theme, "hidden-only", snapshotAt.minusDays(2))
|
||||||
|
val invisibleOnly = saveAudio(creator, theme, "invisible-only", snapshotAt.minusDays(2))
|
||||||
|
saveComment(first, writer, "old", snapshotAt.minusDays(2))
|
||||||
|
saveComment(first, writer, "latest", snapshotAt.minusHours(1))
|
||||||
|
saveComment(first, blockedWriter, "blocked-latest", snapshotAt.minusMinutes(30))
|
||||||
|
saveComment(first, writer, "inactive", snapshotAt.minusMinutes(1), isActive = false)
|
||||||
|
saveComment(second, blockedWriter, "blocked", snapshotAt.minusHours(2))
|
||||||
|
val parent = saveComment(hiddenOnly, writer, "parent", snapshotAt.minusDays(1), isSecret = true)
|
||||||
|
saveComment(hiddenOnly, writer, "reply", snapshotAt.minusHours(1), parent = parent)
|
||||||
|
saveComment(invisibleOnly, inactiveWriter, "inactive-writer", snapshotAt.minusHours(2))
|
||||||
|
saveComment(invisibleOnly, blockedWriter, "blocked-writer", snapshotAt.minusHours(1))
|
||||||
|
saveBlock(creator, blockedWriter)
|
||||||
|
saveBlock(viewer, blockedWriter)
|
||||||
|
flushAndClear()
|
||||||
|
|
||||||
|
val snapshots = repository.findMostCommentedSnapshots(windowStart, snapshotAt, AudioRecommendationVisibility.SAFE, 5)
|
||||||
|
val commented = repository.findCommentedAudiosByIds(
|
||||||
|
contentIds = listOf(first.id!!, second.id!!),
|
||||||
|
memberId = viewer.id,
|
||||||
|
canViewAdultContent = false
|
||||||
|
)
|
||||||
|
|
||||||
|
assertEquals(listOf(first.id), snapshots.map { it.targetId })
|
||||||
|
assertEquals(RecommendedSectionType.MOST_COMMENTED_AUDIO_SAFE, snapshots.first().sectionType)
|
||||||
|
assertEquals(listOf(first.id), commented.map { it.audioContentId })
|
||||||
|
assertEquals("latest", commented.first().latestComment)
|
||||||
|
assertEquals("https://cdn.test/writer.png", commented.first().latestCommentWriterProfileImageUrl)
|
||||||
|
assertEquals("https://cdn.test/commented.png", commented.first().imageUrl)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("댓글 상세는 공개 최상위 댓글만 노출하고 동일 시각이면 id가 큰 댓글 하나를 선택한다")
|
||||||
|
fun shouldFindLatestVisibleTopLevelCommentWithIdTieBreaker() {
|
||||||
|
val now = LocalDateTime.now().plusDays(1)
|
||||||
|
val viewer = saveMember("tie-viewer", MemberRole.USER)
|
||||||
|
val creator = saveMember("tie-creator", MemberRole.CREATOR)
|
||||||
|
val writer = saveMember("tie-writer", MemberRole.USER)
|
||||||
|
val theme = saveTheme()
|
||||||
|
val audio = saveAudio(creator, theme, "tie-commented", now.minusDays(1))
|
||||||
|
val sameCreatedAt = now.minusHours(1)
|
||||||
|
saveComment(audio, writer, "same-time-first", sameCreatedAt)
|
||||||
|
saveComment(audio, writer, "same-time-second", sameCreatedAt)
|
||||||
|
saveComment(audio, writer, "secret-latest", now.minusMinutes(20), isSecret = true)
|
||||||
|
val parent = saveComment(audio, writer, "public-parent", now.minusHours(2))
|
||||||
|
saveComment(audio, writer, "reply-latest", now.minusMinutes(10), parent = parent)
|
||||||
|
flushAndClear()
|
||||||
|
|
||||||
|
val commented = repository.findCommentedAudiosByIds(
|
||||||
|
contentIds = listOf(audio.id!!),
|
||||||
|
memberId = viewer.id,
|
||||||
|
canViewAdultContent = false
|
||||||
|
)
|
||||||
|
|
||||||
|
assertEquals(1, commented.size)
|
||||||
|
assertEquals("same-time-second", commented.single().latestComment)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("댓글 상세는 크리에이터와 댓글 작성자 간 차단 댓글을 최신 댓글에서 제외한다")
|
||||||
|
fun shouldExcludeCreatorBlockedWriterFromLatestCommentDetail() {
|
||||||
|
val now = LocalDateTime.now().plusDays(1)
|
||||||
|
val viewer = saveMember("creator-block-comment-viewer", MemberRole.USER)
|
||||||
|
val creator = saveMember("creator-block-comment-creator", MemberRole.CREATOR)
|
||||||
|
val writer = saveMember("creator-block-comment-writer", MemberRole.USER)
|
||||||
|
val blockedWriter = saveMember("creator-block-comment-blocked-writer", MemberRole.USER)
|
||||||
|
val theme = saveTheme()
|
||||||
|
val audio = saveAudio(creator, theme, "creator-block-commented", now.minusDays(1))
|
||||||
|
saveComment(audio, writer, "visible-comment", now.minusHours(2))
|
||||||
|
saveComment(audio, blockedWriter, "creator-blocked-latest", now.minusHours(1))
|
||||||
|
saveBlock(creator, blockedWriter)
|
||||||
|
flushAndClear()
|
||||||
|
|
||||||
|
val commented = repository.findCommentedAudiosByIds(
|
||||||
|
contentIds = listOf(audio.id!!),
|
||||||
|
memberId = viewer.id,
|
||||||
|
canViewAdultContent = false
|
||||||
|
)
|
||||||
|
|
||||||
|
assertEquals(1, commented.size)
|
||||||
|
assertEquals("visible-comment", commented.single().latestComment)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("추천 오디오는 playCount가 아니라 조회 이력 기반 점수로 산정한다")
|
||||||
|
fun shouldFindRecommendedAudioSnapshotsWithoutPlayCount() {
|
||||||
|
val snapshotAt = LocalDateTime.now().plusDays(1)
|
||||||
|
val windowStart = snapshotAt.minusDays(6).toLocalDate().atStartOfDay()
|
||||||
|
val creator = saveMember("recommended-creator", MemberRole.CREATOR)
|
||||||
|
val theme = saveTheme()
|
||||||
|
val viewed = saveAudio(creator, theme, "viewed", snapshotAt.minusDays(1))
|
||||||
|
val playCountOnly = saveAudio(creator, theme, "play-count-only", snapshotAt.minusDays(1)).apply { playCount = 999 }
|
||||||
|
repeat(3) { saveView(viewed, snapshotAt.minusHours(it.toLong())) }
|
||||||
|
flushAndClear()
|
||||||
|
|
||||||
|
val snapshots = repository.findRecommendedAudioSnapshots(windowStart, snapshotAt, AudioRecommendationVisibility.SAFE, 10)
|
||||||
|
|
||||||
|
assertEquals(viewed.id, snapshots.first().targetId)
|
||||||
|
assertEquals(RecommendedSectionType.RECOMMENDED_AUDIO_SAFE, snapshots.first().sectionType)
|
||||||
|
assertEquals(
|
||||||
|
true,
|
||||||
|
snapshots.indexOfFirst { it.targetId == viewed.id } <
|
||||||
|
snapshots.indexOfFirst { it.targetId == playCountOnly.id }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
private fun saveMember(nickname: String, role: MemberRole, isActive: Boolean = true): Member {
|
private fun saveMember(nickname: String, role: MemberRole, isActive: Boolean = true): Member {
|
||||||
val member = Member(
|
val member = Member(
|
||||||
email = "$nickname@test.com",
|
email = "$nickname@test.com",
|
||||||
@@ -237,6 +392,47 @@ class DefaultAudioRecommendationQueryRepositoryTest @Autowired constructor(
|
|||||||
return audio
|
return audio
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun saveView(audio: AudioContent, viewedAt: LocalDateTime) {
|
||||||
|
entityManager.persist(
|
||||||
|
CreatorContentViewHistory(memberId = 1L, contentId = audio.id!!, genreId = 1L, viewedAt = viewedAt)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun saveLike(audio: AudioContent, createdAt: LocalDateTime) {
|
||||||
|
val like = AudioContentLike(memberId = 1L)
|
||||||
|
like.audioContent = audio
|
||||||
|
like.createdAt = createdAt
|
||||||
|
like.updatedAt = createdAt
|
||||||
|
entityManager.persist(like)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun saveComment(
|
||||||
|
audio: AudioContent,
|
||||||
|
writer: Member,
|
||||||
|
commentBody: String,
|
||||||
|
createdAt: LocalDateTime,
|
||||||
|
isActive: Boolean = true,
|
||||||
|
isSecret: Boolean = false,
|
||||||
|
parent: AudioContentComment? = null
|
||||||
|
): AudioContentComment {
|
||||||
|
val comment = AudioContentComment(
|
||||||
|
comment = commentBody,
|
||||||
|
languageCode = "ko",
|
||||||
|
isSecret = isSecret,
|
||||||
|
isActive = isActive
|
||||||
|
)
|
||||||
|
comment.audioContent = audio
|
||||||
|
comment.member = writer
|
||||||
|
comment.parent = parent
|
||||||
|
comment.createdAt = createdAt
|
||||||
|
comment.updatedAt = createdAt
|
||||||
|
entityManager.persist(comment)
|
||||||
|
entityManager.flush()
|
||||||
|
comment.createdAt = createdAt
|
||||||
|
comment.updatedAt = createdAt
|
||||||
|
return comment
|
||||||
|
}
|
||||||
|
|
||||||
private fun saveSeriesContent(series: Series, audio: AudioContent) {
|
private fun saveSeriesContent(series: Series, audio: AudioContent) {
|
||||||
val seriesContent = SeriesContent()
|
val seriesContent = SeriesContent()
|
||||||
seriesContent.series = series
|
seriesContent.series = series
|
||||||
|
|||||||
Reference in New Issue
Block a user