From 3df66d98ef5389c8030ca3740a2c387ed63e1f94 Mon Sep 17 00:00:00 2001 From: Klaus Date: Tue, 23 Jun 2026 16:12:11 +0900 Subject: [PATCH] =?UTF-8?q?feat(audio-recommendation):=20=EC=98=A4?= =?UTF-8?q?=EB=94=94=EC=98=A4=20=EC=B6=94=EC=B2=9C=20=EC=A0=90=EC=88=98=20?= =?UTF-8?q?=EC=A0=95=EC=B1=85=EC=9D=84=20=EC=B6=94=EA=B0=80=ED=95=9C?= =?UTF-8?q?=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/AudioRecommendationScorePolicy.kt | 83 +++++++++++++++++++ .../AudioRecommendationScorePolicyTest.kt | 44 ++++++++++ 2 files changed, 127 insertions(+) create mode 100644 src/main/kotlin/kr/co/vividnext/sodalive/v2/audio/recommendation/domain/AudioRecommendationScorePolicy.kt create mode 100644 src/test/kotlin/kr/co/vividnext/sodalive/v2/audio/recommendation/domain/AudioRecommendationScorePolicyTest.kt diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/audio/recommendation/domain/AudioRecommendationScorePolicy.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/audio/recommendation/domain/AudioRecommendationScorePolicy.kt new file mode 100644 index 00000000..1481e99d --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/audio/recommendation/domain/AudioRecommendationScorePolicy.kt @@ -0,0 +1,83 @@ +package kr.co.vividnext.sodalive.v2.audio.recommendation.domain + +import java.time.LocalDateTime +import java.time.temporal.ChronoUnit + +class AudioRecommendationScorePolicy { + fun calculateNewAndHotScore( + viewCount: Long, + likeCount: Long, + commentCount: Long, + releaseDate: LocalDateTime, + now: LocalDateTime + ): Double { + return viewCount * NEW_AND_HOT_VIEW_WEIGHT + + likeCount * NEW_AND_HOT_LIKE_WEIGHT + + commentCount * NEW_AND_HOT_COMMENT_WEIGHT + + newAndHotRecencyMultiplier(releaseDate, now) * NEW_AND_HOT_RECENCY_WEIGHT + } + + fun calculateRecommendedAudioScore( + viewCount: Long, + likeCount: Long, + commentCount: Long, + releaseDate: LocalDateTime, + now: LocalDateTime + ): Double { + return viewCount * RECOMMENDED_VIEW_WEIGHT + + likeCount * RECOMMENDED_LIKE_WEIGHT + + commentCount * RECOMMENDED_COMMENT_WEIGHT + + recommendedAudioRecencyMultiplier(releaseDate, now) * RECOMMENDED_RECENCY_WEIGHT + } + + fun calculateCommentScore(commentCount: Long, latestCommentAt: LocalDateTime, now: LocalDateTime): Double { + return commentCount * COMMENT_COUNT_WEIGHT + commentRecencyMultiplier(latestCommentAt, now) * COMMENT_RECENCY_WEIGHT + } + + fun newAndHotRecencyMultiplier(releaseDate: LocalDateTime, now: LocalDateTime): Double { + val days = daysBetween(releaseDate, now) + return when { + days <= 3 -> 1.3 + days <= 7 -> 1.15 + days <= 14 -> 1.0 + else -> 0.8 + } + } + + fun recommendedAudioRecencyMultiplier(releaseDate: LocalDateTime, now: LocalDateTime): Double { + val days = daysBetween(releaseDate, now) + return when { + days <= 3 -> 1.3 + days <= 7 -> 1.15 + days <= 30 -> 1.1 + else -> 1.0 + } + } + + fun commentRecencyMultiplier(latestCommentAt: LocalDateTime, now: LocalDateTime): Double { + val days = daysBetween(latestCommentAt, now) + return when { + days <= 3 -> 1.3 + days <= 7 -> 1.15 + days <= 14 -> 1.0 + else -> 0.0 + } + } + + private fun daysBetween(from: LocalDateTime, now: LocalDateTime): Long { + return ChronoUnit.DAYS.between(from.toLocalDate(), now.toLocalDate()).coerceAtLeast(0) + } + + companion object { + const val NEW_AND_HOT_RECENCY_WEIGHT = 35.0 + const val NEW_AND_HOT_VIEW_WEIGHT = 35.0 + const val NEW_AND_HOT_LIKE_WEIGHT = 15.0 + const val NEW_AND_HOT_COMMENT_WEIGHT = 15.0 + const val RECOMMENDED_VIEW_WEIGHT = 45.0 + const val RECOMMENDED_LIKE_WEIGHT = 25.0 + const val RECOMMENDED_COMMENT_WEIGHT = 20.0 + const val RECOMMENDED_RECENCY_WEIGHT = 10.0 + const val COMMENT_COUNT_WEIGHT = 80.0 + const val COMMENT_RECENCY_WEIGHT = 20.0 + } +} diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/v2/audio/recommendation/domain/AudioRecommendationScorePolicyTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/audio/recommendation/domain/AudioRecommendationScorePolicyTest.kt new file mode 100644 index 00000000..ea296ea6 --- /dev/null +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/audio/recommendation/domain/AudioRecommendationScorePolicyTest.kt @@ -0,0 +1,44 @@ +package kr.co.vividnext.sodalive.v2.audio.recommendation.domain + +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Test +import java.time.LocalDateTime + +class AudioRecommendationScorePolicyTest { + private val policy = AudioRecommendationScorePolicy() + private val now = LocalDateTime.of(2026, 6, 23, 12, 0) + + @Test + @DisplayName("New & Hot 점수는 원본 count를 정규화하지 않고 가중합으로 계산한다") + fun shouldCalculateNewAndHotScoreWithoutNormalization() { + val score = policy.calculateNewAndHotScore(10, 4, 2, now.minusDays(2), now) + + assertEquals(485.5, score) + } + + @Test + @DisplayName("추천 오디오 점수는 원본 count와 최신성 배수를 가중합으로 계산한다") + fun shouldCalculateRecommendedAudioScoreWithoutNormalization() { + val score = policy.calculateRecommendedAudioScore(10, 4, 2, now.minusDays(10), now) + + assertEquals(601.0, score) + } + + @Test + @DisplayName("최신성 배수는 정책별 일수 경계를 적용한다") + fun shouldReturnRecencyMultipliersByPolicyBoundaries() { + assertEquals(1.3, policy.newAndHotRecencyMultiplier(now.minusDays(3), now)) + assertEquals(1.15, policy.newAndHotRecencyMultiplier(now.minusDays(7), now)) + assertEquals(1.0, policy.newAndHotRecencyMultiplier(now.minusDays(14), now)) + assertEquals(0.8, policy.newAndHotRecencyMultiplier(now.minusDays(15), now)) + assertEquals(1.3, policy.recommendedAudioRecencyMultiplier(now.minusDays(3), now)) + assertEquals(1.15, policy.recommendedAudioRecencyMultiplier(now.minusDays(7), now)) + assertEquals(1.1, policy.recommendedAudioRecencyMultiplier(now.minusDays(30), now)) + assertEquals(1.0, policy.recommendedAudioRecencyMultiplier(now.minusDays(31), now)) + assertEquals(1.3, policy.commentRecencyMultiplier(now.minusDays(3), now)) + assertEquals(1.15, policy.commentRecencyMultiplier(now.minusDays(7), now)) + assertEquals(1.0, policy.commentRecencyMultiplier(now.minusDays(14), now)) + assertEquals(0.0, policy.commentRecencyMultiplier(now.minusDays(15), now)) + } +}