diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/audio/recommendation/adapter/out/persistence/DefaultAudioRecommendationQueryRepository.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/audio/recommendation/adapter/out/persistence/DefaultAudioRecommendationQueryRepository.kt index 3651cb32..2c1de1c9 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/v2/audio/recommendation/adapter/out/persistence/DefaultAudioRecommendationQueryRepository.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/audio/recommendation/adapter/out/persistence/DefaultAudioRecommendationQueryRepository.kt @@ -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.block.QBlockMember 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.OriginalSeries import kr.co.vividnext.sodalive.v2.common.domain.RecommendationBanner 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.stereotype.Repository +import java.math.BigDecimal +import java.math.BigInteger import java.time.LocalDateTime +import javax.persistence.EntityManager @Repository class DefaultAudioRecommendationQueryRepository( private val queryFactory: JPAQueryFactory, + private val entityManager: EntityManager, @Value("\${cloud.aws.cloud-front.host}") private val cloudFrontHost: String ) : AudioRecommendationQueryRepository { @@ -151,7 +158,239 @@ class DefaultAudioRecommendationQueryRepository( memberId: Long?, canViewAdultContent: Boolean ): List { - 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 { + 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 { + 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 { + 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 { + 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( @@ -300,3 +539,44 @@ class DefaultAudioRecommendationQueryRepository( 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") + } +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/audio/recommendation/port/out/AudioRecommendationQueryPort.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/audio/recommendation/port/out/AudioRecommendationQueryPort.kt index 7e5a9f2c..aab20039 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/v2/audio/recommendation/port/out/AudioRecommendationQueryPort.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/audio/recommendation/port/out/AudioRecommendationQueryPort.kt @@ -1,9 +1,11 @@ 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.AudioRecommendationVisibility 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.common.domain.RecommendationBanner +import kr.co.vividnext.sodalive.v2.recommendation.port.out.RecommendationSnapshotRecord import java.time.LocalDateTime interface AudioRecommendationQueryPort { @@ -19,4 +21,22 @@ interface AudioRecommendationQueryPort { now: LocalDateTime ): List fun findCommentedAudiosByIds(contentIds: List, memberId: Long?, canViewAdultContent: Boolean): List + fun findNewAndHotSnapshots( + windowStart: LocalDateTime, + snapshotAt: LocalDateTime, + visibility: AudioRecommendationVisibility, + limit: Int + ): List + fun findMostCommentedSnapshots( + windowStart: LocalDateTime, + snapshotAt: LocalDateTime, + visibility: AudioRecommendationVisibility, + limit: Int + ): List + fun findRecommendedAudioSnapshots( + windowStart: LocalDateTime, + snapshotAt: LocalDateTime, + visibility: AudioRecommendationVisibility, + limit: Int + ): List } diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/v2/audio/recommendation/adapter/out/persistence/DefaultAudioRecommendationQueryRepositoryTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/audio/recommendation/adapter/out/persistence/DefaultAudioRecommendationQueryRepositoryTest.kt index d1876ffd..1a3116b1 100644 --- a/src/test/kotlin/kr/co/vividnext/sodalive/v2/audio/recommendation/adapter/out/persistence/DefaultAudioRecommendationQueryRepositoryTest.kt +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/audio/recommendation/adapter/out/persistence/DefaultAudioRecommendationQueryRepositoryTest.kt @@ -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