From e82afb553070f599a66fa74b3812201cc464de5e Mon Sep 17 00:00:00 2001 From: Klaus Date: Sun, 12 Jul 2026 16:31:04 +0900 Subject: [PATCH] =?UTF-8?q?fix(content):=20=EC=B5=9C=EA=B7=BC=20=EB=8C=93?= =?UTF-8?q?=EA=B8=80=20=EC=98=A4=EB=94=94=EC=98=A4=20=ED=9B=84=EB=B3=B4?= =?UTF-8?q?=EB=A5=BC=20=ED=81=AC=EB=A6=AC=EC=97=90=EC=9D=B4=ED=84=B0?= =?UTF-8?q?=EB=B3=84=EB=A1=9C=20=EC=A0=9C=ED=95=9C=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...faultAudioRecommendationQueryRepository.kt | 34 +++++++++++++++---- ...tAudioRecommendationQueryRepositoryTest.kt | 28 +++++++++++++++ 2 files changed, 56 insertions(+), 6 deletions(-) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/content/recommendation/adapter/out/persistence/DefaultAudioRecommendationQueryRepository.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/content/recommendation/adapter/out/persistence/DefaultAudioRecommendationQueryRepository.kt index 9aaff91b..556d0d88 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/v2/content/recommendation/adapter/out/persistence/DefaultAudioRecommendationQueryRepository.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/content/recommendation/adapter/out/persistence/DefaultAudioRecommendationQueryRepository.kt @@ -288,7 +288,8 @@ class DefaultAudioRecommendationQueryRepository( else 0.0 end * 20.0 """.trimIndent(), - requireComments = true + requireComments = true, + dedupeByCreator = true ) } @@ -325,12 +326,13 @@ class DefaultAudioRecommendationQueryRepository( limit: Int, sectionType: RecommendedSectionType, scoreExpression: String, - requireComments: Boolean = false + requireComments: Boolean = false, + dedupeByCreator: Boolean = false ): List { 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 + val scoredSql = """ + select c.id, c.member_id creator_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 @@ -372,9 +374,29 @@ class DefaultAudioRecommendationQueryRepository( 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() + val sql = if (dedupeByCreator) { + """ + select id, score, random_tie_breaker + from ( + select scored.*, row_number() over ( + partition by creator_id + order by score desc, random_tie_breaker asc + ) creator_rank + from ($scoredSql) scored + ) ranked + where creator_rank = 1 + order by score desc, random_tie_breaker asc + limit :limit + """.trimIndent() + } else { + """ + select id, score, random_tie_breaker + from ($scoredSql) scored + order by score desc, random_tie_breaker asc + limit :limit + """.trimIndent() + } return entityManager.createNativeQuery(sql) .setParameter("windowStart", windowStart) .setParameter("snapshotAt", snapshotAt) diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/v2/content/recommendation/adapter/out/persistence/DefaultAudioRecommendationQueryRepositoryTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/content/recommendation/adapter/out/persistence/DefaultAudioRecommendationQueryRepositoryTest.kt index 7cf054bd..504971f6 100644 --- a/src/test/kotlin/kr/co/vividnext/sodalive/v2/content/recommendation/adapter/out/persistence/DefaultAudioRecommendationQueryRepositoryTest.kt +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/content/recommendation/adapter/out/persistence/DefaultAudioRecommendationQueryRepositoryTest.kt @@ -232,6 +232,34 @@ class DefaultAudioRecommendationQueryRepositoryTest @Autowired constructor( assertEquals("https://cdn.test/commented.png", commented.first().imageUrl) } + @Test + @DisplayName("최근 댓글 많은 오디오 스냅샷은 같은 크리에이터 후보 중 최고 점수 1개만 반환한다") + fun shouldKeepOnlyHighestScoredMostCommentedSnapshotPerCreator() { + val snapshotAt = LocalDateTime.of(2026, 7, 12, 23, 59, 59) + val windowStart = snapshotAt.minusDays(6).toLocalDate().atStartOfDay() + val sameCreator = saveMember("dedupe-comment-creator", MemberRole.CREATOR) + val otherCreator = saveMember("dedupe-other-creator", MemberRole.CREATOR) + val writer = saveMember("dedupe-comment-writer", MemberRole.USER) + val theme = saveTheme() + val lowerScore = saveAudio(sameCreator, theme, "same-creator-lower", snapshotAt.minusDays(2)) + val higherScore = saveAudio(sameCreator, theme, "same-creator-higher", snapshotAt.minusDays(2)) + val other = saveAudio(otherCreator, theme, "other-creator", snapshotAt.minusDays(2)) + saveComment(lowerScore, writer, "lower", snapshotAt.minusHours(1)) + saveComment(higherScore, writer, "higher-1", snapshotAt.minusHours(2)) + saveComment(higherScore, writer, "higher-2", snapshotAt.minusHours(1)) + saveComment(other, writer, "other", snapshotAt.minusHours(1)) + flushAndClear() + + val snapshots = repository.findMostCommentedSnapshots( + windowStart, + snapshotAt, + AudioRecommendationVisibility.SAFE, + 5 + ) + + assertEquals(listOf(higherScore.id, other.id), snapshots.map { it.targetId }) + } + @Test @DisplayName("댓글 상세는 공개 최상위 댓글만 노출하고 동일 시각이면 id가 큰 댓글 하나를 선택한다") fun shouldFindLatestVisibleTopLevelCommentWithIdTieBreaker() {