diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/content/recommendation/application/AudioRecommendationQueryService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/content/recommendation/application/AudioRecommendationQueryService.kt index 6f3a2f68..62d3d950 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/v2/content/recommendation/application/AudioRecommendationQueryService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/content/recommendation/application/AudioRecommendationQueryService.kt @@ -2,6 +2,7 @@ package kr.co.vividnext.sodalive.v2.content.recommendation.application import kr.co.vividnext.sodalive.member.Member import kr.co.vividnext.sodalive.member.contentpreference.MemberContentPreferenceService +import kr.co.vividnext.sodalive.v2.content.recommendation.domain.AudioCard import kr.co.vividnext.sodalive.v2.content.recommendation.domain.AudioRecommendationVisibility import kr.co.vividnext.sodalive.v2.content.recommendation.domain.AudioRecommendations import kr.co.vividnext.sodalive.v2.content.recommendation.port.out.AudioRecommendationQueryPort @@ -29,7 +30,7 @@ class AudioRecommendationQueryService( val visibility = if (canViewAdultContent) AudioRecommendationVisibility.ALL else AudioRecommendationVisibility.SAFE val memberId = member?.id val newAndHotSectionType = newAndHotSectionType(visibility) - val newAndHotSnapshots = snapshotPort.findLatestSnapshots(newAndHotSectionType, limit = NEW_AND_HOT_AUDIO_LIMIT) + val newAndHotSnapshots = snapshotPort.findLatestSnapshots(newAndHotSectionType, limit = NEW_AND_HOT_HOME_LIMIT) val mostCommentedSnapshots = snapshotPort.findLatestSnapshots( mostCommentedSectionType(visibility), limit = MOST_COMMENTED_AUDIO_LIMIT @@ -38,7 +39,12 @@ class AudioRecommendationQueryService( recommendedAudioSectionType(visibility), limit = RECOMMENDED_AUDIO_LIMIT ) - val refreshedNewAndHotSnapshots = refreshMissingNewAndHotSnapshots(newAndHotSectionType, newAndHotSnapshots) + val refreshedNewAndHotSnapshots = refreshMissingNewAndHotSnapshots( + newAndHotSectionType, + newAndHotSnapshots, + offset = 0, + limit = NEW_AND_HOT_HOME_LIMIT + ) return AudioRecommendations( banners = queryPort.findBanners(BANNER_LIMIT, memberId, canViewAdultContent), @@ -66,6 +72,22 @@ class AudioRecommendationQueryService( ) } + fun findNewAndHotAudios(member: Member, offset: Long, limit: Int): List { + val now = LocalDateTime.now() + val canViewAdultContent = canViewAdultContent(member) + val visibility = if (canViewAdultContent) AudioRecommendationVisibility.ALL else AudioRecommendationVisibility.SAFE + val sectionType = newAndHotSectionType(visibility) + val snapshots = snapshotPort.findLatestSnapshots(sectionType, offset, limit) + val refreshedSnapshots = refreshMissingNewAndHotSnapshots(sectionType, snapshots, offset, limit) + + return queryPort.findAudioCardsByIds( + refreshedSnapshots.map { it.targetId }, + member.id, + canViewAdultContent, + now + ) + } + fun resolveVisibility(member: Member?): AudioRecommendationVisibility { return if (canViewAdultContent(member)) AudioRecommendationVisibility.ALL else AudioRecommendationVisibility.SAFE } @@ -93,7 +115,9 @@ class AudioRecommendationQueryService( private fun refreshMissingNewAndHotSnapshots( sectionType: RecommendedSectionType, - snapshots: List + snapshots: List, + offset: Long, + limit: Int ): List { if (snapshots.isNotEmpty()) return snapshots val today = LocalDate.now(KST_ZONE) @@ -107,7 +131,7 @@ class AudioRecommendationQueryService( marker.delete() throw ex } - return snapshotPort.findLatestSnapshots(sectionType, limit = NEW_AND_HOT_AUDIO_LIMIT) + return snapshotPort.findLatestSnapshots(sectionType, offset, limit) } private fun newAndHotLazyRefreshMarkerKey(date: LocalDate): String { @@ -125,7 +149,7 @@ class AudioRecommendationQueryService( const val LATEST_AUDIO_LIMIT = 12 const val FREE_AUDIO_LIMIT = 10 const val POINT_AUDIO_LIMIT = 10 - const val NEW_AND_HOT_AUDIO_LIMIT = 12 + const val NEW_AND_HOT_HOME_LIMIT = 12 const val MOST_COMMENTED_AUDIO_LIMIT = 5 const val RECOMMENDED_AUDIO_LIMIT = 10 private const val LAZY_REFRESH_MARKER_KEY_PREFIX = "audio-recommendation:new-and-hot:lazy-refresh-attempted" diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/v2/content/recommendation/application/AudioRecommendationQueryServiceTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/content/recommendation/application/AudioRecommendationQueryServiceTest.kt index 3ecf6754..755e296b 100644 --- a/src/test/kotlin/kr/co/vividnext/sodalive/v2/content/recommendation/application/AudioRecommendationQueryServiceTest.kt +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/content/recommendation/application/AudioRecommendationQueryServiceTest.kt @@ -1,6 +1,9 @@ package kr.co.vividnext.sodalive.v2.content.recommendation.application +import kr.co.vividnext.sodalive.member.Member +import kr.co.vividnext.sodalive.member.MemberRole import kr.co.vividnext.sodalive.member.contentpreference.MemberContentPreferenceService +import kr.co.vividnext.sodalive.v2.content.recommendation.domain.AudioCard import kr.co.vividnext.sodalive.v2.content.recommendation.domain.AudioRecommendationVisibility import kr.co.vividnext.sodalive.v2.content.recommendation.port.out.AudioRecommendationQueryPort import kr.co.vividnext.sodalive.v2.recommendation.domain.RecommendedSectionType @@ -51,7 +54,7 @@ class AudioRecommendationQueryServiceTest { .findLatestSnapshots( RecommendedSectionType.NEW_AND_HOT_AUDIO_SAFE, 0, - AudioRecommendationQueryService.NEW_AND_HOT_AUDIO_LIMIT + AudioRecommendationQueryService.NEW_AND_HOT_HOME_LIMIT ) Mockito.doReturn(emptyList()) .`when`(snapshotPort) @@ -100,7 +103,7 @@ class AudioRecommendationQueryServiceTest { .findLatestSnapshots( RecommendedSectionType.NEW_AND_HOT_AUDIO_SAFE, 0, - AudioRecommendationQueryService.NEW_AND_HOT_AUDIO_LIMIT + AudioRecommendationQueryService.NEW_AND_HOT_HOME_LIMIT ) Mockito.doReturn(emptyList()) .`when`(snapshotPort) @@ -127,19 +130,14 @@ class AudioRecommendationQueryServiceTest { @Test @DisplayName("인증 회원 성인 정책은 조회용 저장 preference를 사용한다") fun shouldUseStoredPreferenceForMemberAdultVisibility() { - val member = kr.co.vividnext.sodalive.member.Member( - email = "adult@test.com", - password = "password", - nickname = "adult", - role = kr.co.vividnext.sodalive.member.MemberRole.USER - ) + val member = member(id = 10L) Mockito.doReturn(true).`when`(preferenceService).canViewAdultContent(member) Mockito.doReturn(listOf(snapshot(RecommendedSectionType.NEW_AND_HOT_AUDIO_ALL, 10L))) .`when`(snapshotPort) .findLatestSnapshots( RecommendedSectionType.NEW_AND_HOT_AUDIO_ALL, 0, - AudioRecommendationQueryService.NEW_AND_HOT_AUDIO_LIMIT + AudioRecommendationQueryService.NEW_AND_HOT_HOME_LIMIT ) service.getRecommendations(member) @@ -149,10 +147,52 @@ class AudioRecommendationQueryServiceTest { Mockito.verify(snapshotPort).findLatestSnapshots( RecommendedSectionType.NEW_AND_HOT_AUDIO_ALL, 0, - AudioRecommendationQueryService.NEW_AND_HOT_AUDIO_LIMIT + AudioRecommendationQueryService.NEW_AND_HOT_HOME_LIMIT ) } + @Test + @DisplayName("추천 탭 첫 화면은 New & Hot 스냅샷을 12개만 조회한다") + fun shouldKeepNewAndHotHomeLimitAtTwelve() { + val member = member(id = 10L) + Mockito.doReturn(true).`when`(preferenceService).canViewAdultContent(member) + Mockito.doReturn(listOf(snapshot(RecommendedSectionType.NEW_AND_HOT_AUDIO_ALL, 10L))).`when`(snapshotPort) + .findLatestSnapshots( + RecommendedSectionType.NEW_AND_HOT_AUDIO_ALL, + 0, + AudioRecommendationQueryService.NEW_AND_HOT_HOME_LIMIT + ) + + service.getRecommendations(member) + + Mockito.verify(snapshotPort).findLatestSnapshots( + RecommendedSectionType.NEW_AND_HOT_AUDIO_ALL, + 0, + AudioRecommendationQueryService.NEW_AND_HOT_HOME_LIMIT + ) + } + + @Test + @DisplayName("New & Hot 전체보기는 스냅샷 offset과 limit으로 오디오 카드를 조회한다") + fun shouldFindNewAndHotAudiosWithOffsetAndLimit() { + val member = member(id = 10L) + val snapshots = listOf( + snapshot(RecommendedSectionType.NEW_AND_HOT_AUDIO_ALL, 3L), + snapshot(RecommendedSectionType.NEW_AND_HOT_AUDIO_ALL, 4L), + snapshot(RecommendedSectionType.NEW_AND_HOT_AUDIO_ALL, 5L) + ) + Mockito.doReturn(true).`when`(preferenceService).canViewAdultContent(member) + Mockito.doReturn(snapshots).`when`(snapshotPort) + .findLatestSnapshots(RecommendedSectionType.NEW_AND_HOT_AUDIO_ALL, 20L, 21) + Mockito.doReturn(listOf(audioCard(3L), audioCard(4L), audioCard(5L))).`when`(queryPort) + .findAudioCardsByIds(eqValue(listOf(3L, 4L, 5L)), eqValue(member.id), eqValue(true), anyLocalDateTime()) + + val result = service.findNewAndHotAudios(member, offset = 20L, limit = 21) + + assertEquals(listOf(3L, 4L, 5L), result.map { it.audioContentId }) + Mockito.verify(snapshotPort).findLatestSnapshots(RecommendedSectionType.NEW_AND_HOT_AUDIO_ALL, 20L, 21) + } + @Test @DisplayName("visibility와 섹션 조합은 신규 RecommendedSectionType으로 매핑된다") fun shouldMapVisibilityToAudioSectionTypes() { @@ -186,6 +226,32 @@ class AudioRecommendationQueryServiceTest { return Mockito.any(LocalDateTime::class.java) ?: LocalDateTime.now() } + private fun member(id: Long): Member { + return Member( + email = "viewer$id@test.com", + password = "password", + nickname = "viewer$id", + role = MemberRole.USER + ).apply { + this.id = id + } + } + + private fun audioCard(id: Long): AudioCard { + return AudioCard( + audioContentId = id, + title = "audio$id", + duration = "00:01", + imageUrl = "https://cdn.test/audio$id.png", + price = id.toInt(), + isAdult = false, + isPointAvailable = true, + isFirstContent = true, + isOriginalSeries = false, + creatorNickname = "creator$id" + ) + } + private fun snapshot(sectionType: RecommendedSectionType, targetId: Long): RecommendationSnapshotRecord { return RecommendationSnapshotRecord( sectionType = sectionType,