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
@@ -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.configs.QueryDslConfig
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.AudioContentBannerType
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.MemberRole
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.DisplayName
import org.junit.jupiter.api.Test
@@ -32,7 +38,7 @@ class DefaultAudioRecommendationQueryRepositoryTest @Autowired constructor(
private val entityManager: EntityManager,
queryFactory: JPAQueryFactory
) {
private val repository = DefaultAudioRecommendationQueryRepository(queryFactory, "https://cdn.test")
private val repository = DefaultAudioRecommendationQueryRepository(queryFactory, entityManager, "https://cdn.test")
@Test
@DisplayName("배너는 홈 추천 배너와 같은 활성/탭/차단 정책과 CDN URL을 적용한다")
@@ -152,6 +158,155 @@ class DefaultAudioRecommendationQueryRepositoryTest @Autowired constructor(
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 {
val member = Member(
email = "$nickname@test.com",
@@ -237,6 +392,47 @@ class DefaultAudioRecommendationQueryRepositoryTest @Autowired constructor(
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) {
val seriesContent = SeriesContent()
seriesContent.series = series