feat(audio-recommendation): 추천 조회 snapshot fallback을 적용한다

This commit is contained in:
2026-06-23 21:06:25 +09:00
parent 6a6deb33a3
commit ab67e36d96
5 changed files with 437 additions and 16 deletions

View File

@@ -2,33 +2,67 @@ package kr.co.vividnext.sodalive.v2.audio.recommendation.application
import kr.co.vividnext.sodalive.member.Member
import kr.co.vividnext.sodalive.member.contentpreference.MemberContentPreferenceService
import kr.co.vividnext.sodalive.member.contentpreference.isAdultVisibleByPolicy
import kr.co.vividnext.sodalive.v2.audio.recommendation.domain.AudioRecommendationVisibility
import kr.co.vividnext.sodalive.v2.audio.recommendation.domain.AudioRecommendations
import kr.co.vividnext.sodalive.v2.audio.recommendation.port.out.AudioRecommendationQueryPort
import kr.co.vividnext.sodalive.v2.recommendation.domain.RecommendedSectionType
import kr.co.vividnext.sodalive.v2.recommendation.port.out.RecommendationSnapshotPort
import kr.co.vividnext.sodalive.v2.recommendation.port.out.RecommendationSnapshotRecord
import org.redisson.api.RedissonClient
import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional
import java.time.Duration
import java.time.LocalDate
import java.time.LocalDateTime
import java.time.ZoneId
@Service
class AudioRecommendationQueryService(
private val queryPort: AudioRecommendationQueryPort,
private val memberContentPreferenceService: MemberContentPreferenceService
private val memberContentPreferenceService: MemberContentPreferenceService,
private val snapshotPort: RecommendationSnapshotPort,
private val snapshotRefreshService: AudioRecommendationSnapshotRefreshService,
private val redissonClient: RedissonClient
) {
@Transactional(readOnly = true)
fun getRecommendations(member: Member?): AudioRecommendations {
val now = LocalDateTime.now()
val canViewAdultContent = canViewAdultContent(member)
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 mostCommentedSnapshots = snapshotPort.findLatestSnapshots(
mostCommentedSectionType(visibility),
limit = MOST_COMMENTED_AUDIO_LIMIT
)
val recommendedSnapshots = snapshotPort.findLatestSnapshots(
recommendedAudioSectionType(visibility),
limit = RECOMMENDED_AUDIO_LIMIT
)
val refreshedNewAndHotSnapshots = refreshMissingNewAndHotSnapshots(newAndHotSectionType, newAndHotSnapshots)
return AudioRecommendations(
banners = queryPort.findBanners(BANNER_LIMIT, member?.id, canViewAdultContent),
originalSeries = queryPort.findOriginalSeries(ORIGINAL_SERIES_LIMIT, member?.id, canViewAdultContent, now),
latestAudios = queryPort.findLatestAudios(LATEST_AUDIO_LIMIT, member?.id, canViewAdultContent, now),
newAndHotAudios = emptyList(),
freeAudios = queryPort.findFreeAudios(FREE_AUDIO_LIMIT, member?.id, canViewAdultContent, now),
pointAudios = queryPort.findPointAudios(POINT_AUDIO_LIMIT, member?.id, canViewAdultContent, now),
mostCommentedAudios = emptyList(),
recommendedAudios = emptyList()
banners = queryPort.findBanners(BANNER_LIMIT, memberId, canViewAdultContent),
originalSeries = queryPort.findOriginalSeries(ORIGINAL_SERIES_LIMIT, memberId, canViewAdultContent, now),
latestAudios = queryPort.findLatestAudios(LATEST_AUDIO_LIMIT, memberId, canViewAdultContent, now),
newAndHotAudios = queryPort.findAudioCardsByIds(
refreshedNewAndHotSnapshots.map { it.targetId },
memberId,
canViewAdultContent,
now
),
freeAudios = queryPort.findFreeAudios(FREE_AUDIO_LIMIT, memberId, canViewAdultContent, now),
pointAudios = queryPort.findPointAudios(POINT_AUDIO_LIMIT, memberId, canViewAdultContent, now),
mostCommentedAudios = queryPort.findCommentedAudiosByIds(
mostCommentedSnapshots.map { it.targetId },
memberId,
canViewAdultContent
),
recommendedAudios = queryPort.findAudioCardsByIds(
recommendedSnapshots.map { it.targetId },
memberId,
canViewAdultContent,
now
)
)
}
@@ -57,10 +91,32 @@ class AudioRecommendationQueryService(
}
}
private fun refreshMissingNewAndHotSnapshots(
sectionType: RecommendedSectionType,
snapshots: List<RecommendationSnapshotRecord>
): List<RecommendationSnapshotRecord> {
if (snapshots.isNotEmpty()) return snapshots
val today = LocalDate.now(KST_ZONE)
val marker = redissonClient.getBucket<String>(newAndHotLazyRefreshMarkerKey(today))
if (!marker.setIfAbsent(LAZY_REFRESH_ATTEMPTED_VALUE, LAZY_REFRESH_MARKER_TTL)) {
return snapshots
}
runCatching {
snapshotRefreshService.refreshDailySnapshots()
}.onFailure { ex ->
marker.delete()
throw ex
}
return snapshotPort.findLatestSnapshots(sectionType, limit = NEW_AND_HOT_AUDIO_LIMIT)
}
private fun newAndHotLazyRefreshMarkerKey(date: LocalDate): String {
return "$LAZY_REFRESH_MARKER_KEY_PREFIX:$date"
}
private fun canViewAdultContent(member: Member?): Boolean {
if (member == null) return false
val preference = memberContentPreferenceService.initializeDefaultPreference(member)
return isAdultVisibleByPolicy(member, preference.isAdultContentVisible)
return memberContentPreferenceService.getStoredPreference(member).isAdult
}
companion object {
@@ -69,5 +125,12 @@ 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 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"
private const val LAZY_REFRESH_ATTEMPTED_VALUE = "1"
private val LAZY_REFRESH_MARKER_TTL: Duration = Duration.ofDays(2)
private val KST_ZONE: ZoneId = ZoneId.of("Asia/Seoul")
}
}

View File

@@ -65,7 +65,7 @@ class AudioRecommendationScorePolicy {
}
private fun daysBetween(from: LocalDateTime, now: LocalDateTime): Long {
return ChronoUnit.DAYS.between(from.toLocalDate(), now.toLocalDate()).coerceAtLeast(0)
return ChronoUnit.DAYS.between(from, now).coerceAtLeast(0)
}
companion object {

View File

@@ -0,0 +1,179 @@
package kr.co.vividnext.sodalive.v2.api.audio.recommendation.adapter.`in`.web
import kr.co.vividnext.sodalive.admin.content.series.genre.SeriesGenre
import kr.co.vividnext.sodalive.content.AudioContent
import kr.co.vividnext.sodalive.content.comment.AudioContentComment
import kr.co.vividnext.sodalive.content.theme.AudioContentTheme
import kr.co.vividnext.sodalive.creator.admin.content.series.Series
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.support.EmbeddedRedisInitializer
import kr.co.vividnext.sodalive.v2.recommendation.adapter.out.persistence.RecommendationSnapshot
import kr.co.vividnext.sodalive.v2.recommendation.domain.RecommendedSectionType
import org.junit.jupiter.api.DisplayName
import org.junit.jupiter.api.Test
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc
import org.springframework.boot.test.context.SpringBootTest
import org.springframework.test.context.ContextConfiguration
import org.springframework.test.web.servlet.MockMvc
import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get
import org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath
import org.springframework.test.web.servlet.result.MockMvcResultMatchers.status
import org.springframework.transaction.support.TransactionTemplate
import java.time.LocalDateTime
import javax.persistence.EntityManager
@SpringBootTest(
properties = [
"cloud.aws.cloud-front.host=https://cdn.test",
"spring.cache.type=none",
"spring.datasource.url=jdbc:h2:mem:audio-recommendation-e2e;MODE=MySQL;NON_KEYWORDS=VALUE;DB_CLOSE_ON_EXIT=FALSE"
]
)
@AutoConfigureMockMvc
@ContextConfiguration(initializers = [EmbeddedRedisInitializer::class])
class AudioRecommendationEndToEndTest @Autowired constructor(
private val mockMvc: MockMvc,
private val entityManager: EntityManager,
private val transactionTemplate: TransactionTemplate
) {
@Test
@DisplayName("오디오 추천 API는 controller-service-repository를 거쳐 추천 섹션 응답을 반환한다")
fun shouldReturnRecommendationsThroughControllerServiceAndRepository() {
val fixture = createFixture()
mockMvc.perform(get("/api/v2/audio/recommendations"))
.andExpect(status().isOk)
.andExpect(jsonPath("$.success").value(true))
.andExpect(jsonPath("$.data.originalSeries").isArray)
.andExpect(jsonPath("$.data.originalSeries[0].seriesId").value(fixture.seriesId))
.andExpect(jsonPath("$.data.latestAudios").isArray)
.andExpect(jsonPath("$.data.latestAudios[0].audioContentId").value(fixture.audioContentId))
.andExpect(jsonPath("$.data.latestAudios[0].isOriginalSeries").value(true))
.andExpect(jsonPath("$.data.recommendedAudios").isArray)
.andExpect(jsonPath("$.data.recommendedAudios[0].audioContentId").value(fixture.audioContentId))
.andExpect(jsonPath("$.data.mostCommentedAudios[0].latestComment").value("latest e2e comment"))
.andExpect(
jsonPath("$.data.mostCommentedAudios[0].latestCommentWriterProfileImageUrl")
.value("https://cdn.test/comment-writer.png")
)
}
private fun createFixture(): Fixture {
return transactionTemplate.execute {
val now = LocalDateTime.now().minusHours(1)
val creator = saveMember("audio-recommendation-e2e-creator", MemberRole.CREATOR)
val writer = saveMember("audio-recommendation-e2e-writer", MemberRole.USER, profileImage = "comment-writer.png")
val theme = saveTheme()
val audio = saveAudio(creator, theme, now)
val series = saveSeries(creator)
saveSeriesContent(series, audio)
saveComment(audio, writer, "latest e2e comment", now.plusMinutes(10))
saveSnapshot(RecommendedSectionType.NEW_AND_HOT_AUDIO_SAFE, audio.id!!, now)
saveSnapshot(RecommendedSectionType.MOST_COMMENTED_AUDIO_SAFE, audio.id!!, now)
saveSnapshot(RecommendedSectionType.RECOMMENDED_AUDIO_SAFE, audio.id!!, now)
entityManager.flush()
entityManager.clear()
Fixture(
seriesId = series.id!!,
audioContentId = audio.id!!
)
}!!
}
private fun saveMember(nickname: String, role: MemberRole, profileImage: String? = "$nickname.png"): Member {
val member = Member(
email = "$nickname@test.com",
password = "password",
nickname = nickname,
profileImage = profileImage,
role = role
)
entityManager.persist(member)
return member
}
private fun saveTheme(): AudioContentTheme {
val theme = AudioContentTheme(theme = "recommendation-e2e-theme", image = "theme.png", isActive = true)
entityManager.persist(theme)
return theme
}
private fun saveAudio(creator: Member, theme: AudioContentTheme, releaseDate: LocalDateTime): AudioContent {
val audio = AudioContent(
title = "audio-recommendation-e2e",
detail = "detail",
languageCode = "ko",
releaseDate = releaseDate,
isAdult = false,
price = 0,
isPointAvailable = true
)
audio.member = creator
audio.theme = theme
audio.isActive = true
audio.coverImage = "audio-recommendation-e2e.png"
audio.duration = "00:10"
entityManager.persist(audio)
return audio
}
private fun saveSeries(creator: Member): Series {
val genre = SeriesGenre("recommendation-e2e-genre")
entityManager.persist(genre)
val series = Series(
title = "recommendation-e2e-series",
introduction = "intro",
isOriginal = true,
isAdult = false,
isActive = true
)
series.member = creator
series.genre = genre
series.coverImage = "series.png"
entityManager.persist(series)
return series
}
private fun saveSeriesContent(series: Series, audio: AudioContent) {
val seriesContent = SeriesContent()
seriesContent.series = series
seriesContent.content = audio
entityManager.persist(seriesContent)
}
private fun saveComment(
audio: AudioContent,
writer: Member,
commentBody: String,
createdAt: LocalDateTime
): AudioContentComment {
val comment = AudioContentComment(comment = commentBody, languageCode = "ko", isActive = true)
comment.audioContent = audio
comment.member = writer
comment.createdAt = createdAt
comment.updatedAt = createdAt
entityManager.persist(comment)
return comment
}
private fun saveSnapshot(sectionType: RecommendedSectionType, targetId: Long, snapshotAt: LocalDateTime) {
entityManager.persist(
RecommendationSnapshot(
sectionType = sectionType,
targetId = targetId,
score = 1.0,
snapshotAt = snapshotAt,
randomTieBreaker = 0.0
)
)
}
private data class Fixture(
val seriesId: Long,
val audioContentId: Long
)
}

View File

@@ -1,18 +1,36 @@
package kr.co.vividnext.sodalive.v2.audio.recommendation.application
import kr.co.vividnext.sodalive.content.ContentType
import kr.co.vividnext.sodalive.member.contentpreference.MemberContentPreferenceService
import kr.co.vividnext.sodalive.member.contentpreference.ViewerContentPreference
import kr.co.vividnext.sodalive.v2.audio.recommendation.domain.AudioRecommendationVisibility
import kr.co.vividnext.sodalive.v2.audio.recommendation.port.out.AudioRecommendationQueryPort
import kr.co.vividnext.sodalive.v2.recommendation.domain.RecommendedSectionType
import kr.co.vividnext.sodalive.v2.recommendation.port.out.RecommendationSnapshotPort
import kr.co.vividnext.sodalive.v2.recommendation.port.out.RecommendationSnapshotRecord
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.DisplayName
import org.junit.jupiter.api.Test
import org.mockito.Mockito
import org.redisson.api.RBucket
import org.redisson.api.RedissonClient
import java.time.Duration
import java.time.LocalDateTime
class AudioRecommendationQueryServiceTest {
private val queryPort = Mockito.mock(AudioRecommendationQueryPort::class.java)
private val preferenceService = Mockito.mock(MemberContentPreferenceService::class.java)
private val service = AudioRecommendationQueryService(queryPort, preferenceService)
private val snapshotPort = Mockito.mock(RecommendationSnapshotPort::class.java)
private val refreshService = Mockito.mock(AudioRecommendationSnapshotRefreshService::class.java)
private val redissonClient = Mockito.mock(RedissonClient::class.java)
private val lazyRefreshMarker = Mockito.mock(RBucket::class.java) as RBucket<String>
private val service = AudioRecommendationQueryService(
queryPort,
preferenceService,
snapshotPort,
refreshService,
redissonClient
)
@Test
@DisplayName("비회원은 SAFE visibility를 사용한다")
@@ -20,6 +38,130 @@ class AudioRecommendationQueryServiceTest {
assertEquals(AudioRecommendationVisibility.SAFE, service.resolveVisibility(null))
}
@Test
@DisplayName("조회 서비스는 SAFE 스냅샷을 lazy refresh 후 상세 섹션으로 조립한다")
fun shouldBuildRecommendationsFromSafeSnapshotsWithLazyRefresh() {
val snapshot = RecommendationSnapshotRecord(
sectionType = RecommendedSectionType.NEW_AND_HOT_AUDIO_SAFE,
targetId = 1L,
score = 10.0,
snapshotAt = LocalDateTime.now(),
randomTieBreaker = 1.0
)
Mockito.doReturn(emptyList<RecommendationSnapshotRecord>(), listOf(snapshot))
.`when`(snapshotPort)
.findLatestSnapshots(
RecommendedSectionType.NEW_AND_HOT_AUDIO_SAFE,
0,
AudioRecommendationQueryService.NEW_AND_HOT_AUDIO_LIMIT
)
Mockito.doReturn(emptyList<RecommendationSnapshotRecord>())
.`when`(snapshotPort)
.findLatestSnapshots(
RecommendedSectionType.MOST_COMMENTED_AUDIO_SAFE,
0,
AudioRecommendationQueryService.MOST_COMMENTED_AUDIO_LIMIT
)
Mockito.doReturn(emptyList<RecommendationSnapshotRecord>())
.`when`(snapshotPort)
.findLatestSnapshots(
RecommendedSectionType.RECOMMENDED_AUDIO_SAFE,
0,
AudioRecommendationQueryService.RECOMMENDED_AUDIO_LIMIT
)
allowLazyRefreshOnce()
val recommendations = service.getRecommendations(null)
assertEquals(0, recommendations.mostCommentedAudios.size)
Mockito.verify(refreshService).refreshDailySnapshots()
Mockito.verify(snapshotPort, Mockito.times(1)).findLatestSnapshots(
RecommendedSectionType.MOST_COMMENTED_AUDIO_SAFE,
0,
AudioRecommendationQueryService.MOST_COMMENTED_AUDIO_LIMIT
)
Mockito.verify(snapshotPort, Mockito.times(1)).findLatestSnapshots(
RecommendedSectionType.RECOMMENDED_AUDIO_SAFE,
0,
AudioRecommendationQueryService.RECOMMENDED_AUDIO_LIMIT
)
Mockito.verify(queryPort).findBanners(AudioRecommendationQueryService.BANNER_LIMIT, null, false)
Mockito.verify(queryPort).findAudioCardsByIds(
eqValue(listOf(1L)),
Mockito.isNull(),
eqValue(false),
anyLocalDateTime()
)
}
@Test
@DisplayName("New & Hot lazy refresh는 보강 후에도 비어 있으면 같은 KST 날짜에 다시 실행하지 않는다")
fun shouldAttemptEmptyNewAndHotLazyRefreshOncePerKstDate() {
Mockito.doReturn(emptyList<RecommendationSnapshotRecord>())
.`when`(snapshotPort)
.findLatestSnapshots(
RecommendedSectionType.NEW_AND_HOT_AUDIO_SAFE,
0,
AudioRecommendationQueryService.NEW_AND_HOT_AUDIO_LIMIT
)
Mockito.doReturn(emptyList<RecommendationSnapshotRecord>())
.`when`(snapshotPort)
.findLatestSnapshots(
RecommendedSectionType.MOST_COMMENTED_AUDIO_SAFE,
0,
AudioRecommendationQueryService.MOST_COMMENTED_AUDIO_LIMIT
)
Mockito.doReturn(emptyList<RecommendationSnapshotRecord>())
.`when`(snapshotPort)
.findLatestSnapshots(
RecommendedSectionType.RECOMMENDED_AUDIO_SAFE,
0,
AudioRecommendationQueryService.RECOMMENDED_AUDIO_LIMIT
)
allowLazyRefreshOnce()
service.getRecommendations(null)
service.getRecommendations(null)
Mockito.verify(refreshService, Mockito.times(1)).refreshDailySnapshots()
}
@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
)
Mockito.doReturn(
ViewerContentPreference(
countryCode = "KR",
isAdultContentVisible = true,
contentType = ContentType.ALL,
isAdult = true
)
).`when`(preferenceService).getStoredPreference(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
)
service.getRecommendations(member)
Mockito.verify(preferenceService).getStoredPreference(member)
Mockito.verify(preferenceService, Mockito.never()).initializeDefaultPreference(member)
Mockito.verify(snapshotPort).findLatestSnapshots(
RecommendedSectionType.NEW_AND_HOT_AUDIO_ALL,
0,
AudioRecommendationQueryService.NEW_AND_HOT_AUDIO_LIMIT
)
}
@Test
@DisplayName("visibility와 섹션 조합은 신규 RecommendedSectionType으로 매핑된다")
fun shouldMapVisibilityToAudioSectionTypes() {
@@ -48,4 +190,30 @@ class AudioRecommendationQueryServiceTest {
service.recommendedAudioSectionType(AudioRecommendationVisibility.ALL)
)
}
private fun anyLocalDateTime(): LocalDateTime {
return Mockito.any(LocalDateTime::class.java) ?: LocalDateTime.now()
}
private fun snapshot(sectionType: RecommendedSectionType, targetId: Long): RecommendationSnapshotRecord {
return RecommendationSnapshotRecord(
sectionType = sectionType,
targetId = targetId,
score = 10.0,
snapshotAt = LocalDateTime.now(),
randomTieBreaker = 1.0
)
}
private fun <T> eqValue(value: T): T {
return Mockito.eq(value) ?: value
}
private fun allowLazyRefreshOnce() {
Mockito.doReturn(lazyRefreshMarker).`when`(redissonClient).getBucket<String>(Mockito.anyString())
Mockito.doReturn(true, false).`when`(lazyRefreshMarker).setIfAbsent(
eqValue("1"),
eqValue(Duration.ofDays(2))
)
}
}

View File

@@ -41,4 +41,15 @@ class AudioRecommendationScorePolicyTest {
assertEquals(1.0, policy.commentRecencyMultiplier(now.minusDays(14), now))
assertEquals(0.0, policy.commentRecencyMultiplier(now.minusDays(15), now))
}
@Test
@DisplayName("최신성 일수는 날짜 경계가 아니라 24시간 경과 기준으로 계산한다")
fun shouldCalculateRecencyDaysByElapsedTwentyFourHours() {
val releaseDate = LocalDateTime.of(2026, 6, 19, 23, 59, 59)
val snapshotAt = LocalDateTime.of(2026, 6, 23, 0, 0)
assertEquals(1.3, policy.newAndHotRecencyMultiplier(releaseDate, snapshotAt))
assertEquals(1.3, policy.recommendedAudioRecencyMultiplier(releaseDate, snapshotAt))
assertEquals(1.3, policy.commentRecencyMultiplier(releaseDate, snapshotAt))
}
}