From 07bbc75844a06db8756652a2f9cb61ac37c09bc7 Mon Sep 17 00:00:00 2001 From: Klaus Date: Sat, 30 May 2026 17:44:59 +0900 Subject: [PATCH] =?UTF-8?q?feat(recommend):=20=ED=99=88=20=EC=B6=94?= =?UTF-8?q?=EC=B2=9C=20=EC=A0=90=EC=88=98=20=EC=A0=95=EC=B1=85=EC=9D=84=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/RecommendationScorePolicy.kt | 72 ++++++++++++++ .../domain/RecommendationScorePolicyTest.kt | 97 +++++++++++++++++++ 2 files changed, 169 insertions(+) create mode 100644 src/main/kotlin/kr/co/vividnext/sodalive/v2/recommend/domain/RecommendationScorePolicy.kt create mode 100644 src/test/kotlin/kr/co/vividnext/sodalive/v2/recommend/domain/RecommendationScorePolicyTest.kt diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/recommend/domain/RecommendationScorePolicy.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/recommend/domain/RecommendationScorePolicy.kt new file mode 100644 index 00000000..d3a491dc --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/recommend/domain/RecommendationScorePolicy.kt @@ -0,0 +1,72 @@ +package kr.co.vividnext.sodalive.v2.recommend.domain + +import java.time.LocalDateTime +import java.time.temporal.ChronoUnit + +class RecommendationScorePolicy { + fun calculateCreatorNewBoost(debutAt: LocalDateTime, now: LocalDateTime): Double { + return calculateNewBoost(debutAt, now) + } + + fun calculateAiCharacterNewBoost(createdAt: LocalDateTime, now: LocalDateTime): Double { + return calculateNewBoost(createdAt, now) + } + + fun calculateDebutCreatorScore( + followIncrease: Long, + contentActivityScore: Long, + communicationScore: Long, + newBoost: Double + ): Double { + return ((followIncrease * 0.35) + (contentActivityScore * 0.3) + (communicationScore * 0.2)) * newBoost + } + + fun calculateAiChatScore( + recentChatCount: Long, + recentActiveUserCount: Long, + followIncrease: Long, + newBoost: Double + ): Double { + return ((0.45 * recentChatCount) + (0.35 * recentActiveUserCount) + (0.20 * followIncrease)) * newBoost + } + + fun calculateCheerScore( + donationAmount: Long, + fanTalkCount: Long, + donationCount: Long, + newBoost: Double + ): Double { + return ((0.6 * donationAmount) + (0.3 * fanTalkCount) + (0.1 * donationCount)) * newBoost + } + + fun calculateCommunityScore( + likeCount: Long, + commentCount: Long, + followerCount: Long, + newBoost: Double + ): Double { + return ((0.5 * likeCount) + (0.5 * commentCount) + (0.1 * followerCount)) * newBoost + } + + fun calculateFirstAudioRecencyScore(releaseDate: LocalDateTime, now: LocalDateTime): Int { + val days = ChronoUnit.DAYS.between(releaseDate.toLocalDate(), now.toLocalDate()) + return when { + days <= 3 -> 100 + days <= 7 -> 80 + days <= 14 -> 60 + days <= 21 -> 40 + days <= 30 -> 20 + else -> 0 + } + } + + private fun calculateNewBoost(baseAt: LocalDateTime, now: LocalDateTime): Double { + val days = ChronoUnit.DAYS.between(baseAt.toLocalDate(), now.toLocalDate()) + return when { + days <= 10 -> 1.5 + days <= 20 -> 1.3 + days <= 30 -> 1.2 + else -> 1.0 + } + } +} diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/v2/recommend/domain/RecommendationScorePolicyTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/recommend/domain/RecommendationScorePolicyTest.kt new file mode 100644 index 00000000..96ec92c6 --- /dev/null +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/recommend/domain/RecommendationScorePolicyTest.kt @@ -0,0 +1,97 @@ +package kr.co.vividnext.sodalive.v2.recommend.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 RecommendationScorePolicyTest { + private val policy = RecommendationScorePolicy() + + @Test + @DisplayName("데뷔일 기준 신규 부스트는 10일/20일/30일 구간을 적용한다") + fun shouldApplyCreatorNewBoostByDebutDays() { + val now = LocalDateTime.of(2026, 5, 30, 12, 0) + + assertEquals(1.5, policy.calculateCreatorNewBoost(now.minusDays(10), now), 0.0001) + assertEquals(1.3, policy.calculateCreatorNewBoost(now.minusDays(20), now), 0.0001) + assertEquals(1.2, policy.calculateCreatorNewBoost(now.minusDays(30), now), 0.0001) + assertEquals(1.0, policy.calculateCreatorNewBoost(now.minusDays(31), now), 0.0001) + } + + @Test + @DisplayName("AI 캐릭터 생성일 기준 신규 부스트는 10일/20일/30일 구간을 적용한다") + fun shouldApplyAiCharacterNewBoostByCreatedDays() { + val now = LocalDateTime.of(2026, 5, 30, 12, 0) + + assertEquals(1.5, policy.calculateAiCharacterNewBoost(now.minusDays(10), now), 0.0001) + assertEquals(1.3, policy.calculateAiCharacterNewBoost(now.minusDays(20), now), 0.0001) + assertEquals(1.2, policy.calculateAiCharacterNewBoost(now.minusDays(30), now), 0.0001) + assertEquals(1.0, policy.calculateAiCharacterNewBoost(now.minusDays(31), now), 0.0001) + } + + @Test + @DisplayName("최근 데뷔 크리에이터 추천 점수는 PRD 가중치와 신규 부스트를 적용한다") + fun shouldCalculateDebutCreatorScore() { + val score = policy.calculateDebutCreatorScore( + followIncrease = 10, + contentActivityScore = 20, + communicationScore = 30, + newBoost = 1.5 + ) + + assertEquals(23.25, score, 0.0001) + } + + @Test + @DisplayName("AI 채팅 추천 점수는 PRD 가중치와 신규 부스트를 적용한다") + fun shouldCalculateAiChatScore() { + val score = policy.calculateAiChatScore( + recentChatCount = 100, + recentActiveUserCount = 20, + followIncrease = 10, + newBoost = 1.3 + ) + + assertEquals(70.2, score, 0.0001) + } + + @Test + @DisplayName("최근 응원 추천 점수는 후원 금액, 팬 Talk 수, 후원 수에 가중치를 적용한다") + fun shouldCalculateCheerScore() { + val score = policy.calculateCheerScore( + donationAmount = 1000, + fanTalkCount = 20, + donationCount = 10, + newBoost = 1.2 + ) + + assertEquals(728.4, score, 0.0001) + } + + @Test + @DisplayName("인기 커뮤니티 점수는 좋아요 수, 댓글 수, 팔로우 수에 가중치를 적용한다") + fun shouldCalculateCommunityScore() { + val score = policy.calculateCommunityScore( + likeCount = 40, + commentCount = 20, + followerCount = 100, + newBoost = 1.2 + ) + + assertEquals(48.0, score, 0.0001) + } + + @Test + @DisplayName("첫 오디오 최신성 점수는 releaseDate 기준 경과일 구간을 적용한다") + fun shouldCalculateFirstAudioRecencyScore() { + val now = LocalDateTime.of(2026, 5, 30, 12, 0) + + assertEquals(100, policy.calculateFirstAudioRecencyScore(now.minusDays(3), now)) + assertEquals(80, policy.calculateFirstAudioRecencyScore(now.minusDays(7), now)) + assertEquals(60, policy.calculateFirstAudioRecencyScore(now.minusDays(14), now)) + assertEquals(40, policy.calculateFirstAudioRecencyScore(now.minusDays(21), now)) + assertEquals(20, policy.calculateFirstAudioRecencyScore(now.minusDays(30), now)) + assertEquals(0, policy.calculateFirstAudioRecencyScore(now.minusDays(31), now)) + } +}