feat(audio-recommendation): 추천 snapshot 갱신 서비스를 추가한다
This commit is contained in:
@@ -0,0 +1,136 @@
|
|||||||
|
package kr.co.vividnext.sodalive.v2.audio.recommendation.application
|
||||||
|
|
||||||
|
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 org.slf4j.LoggerFactory
|
||||||
|
import org.springframework.stereotype.Service
|
||||||
|
import org.springframework.transaction.annotation.Propagation
|
||||||
|
import org.springframework.transaction.annotation.Transactional
|
||||||
|
import java.time.LocalDateTime
|
||||||
|
import java.time.ZoneId
|
||||||
|
import java.time.ZonedDateTime
|
||||||
|
|
||||||
|
@Service
|
||||||
|
class AudioRecommendationSnapshotRefreshService(
|
||||||
|
private val snapshotPort: RecommendationSnapshotPort,
|
||||||
|
private val queryPort: AudioRecommendationQueryPort
|
||||||
|
) {
|
||||||
|
private val log = LoggerFactory.getLogger(javaClass)
|
||||||
|
|
||||||
|
@Transactional(propagation = Propagation.REQUIRES_NEW)
|
||||||
|
fun refreshDailySnapshots() {
|
||||||
|
refreshDailySnapshots(ZonedDateTime.now(KST_ZONE))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional(propagation = Propagation.REQUIRES_NEW)
|
||||||
|
fun refreshDailySnapshots(now: LocalDateTime) {
|
||||||
|
refreshDailySnapshots(now.atZone(KST_ZONE))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional(propagation = Propagation.REQUIRES_NEW)
|
||||||
|
fun refreshDailySnapshots(now: ZonedDateTime) {
|
||||||
|
val startedAt = System.currentTimeMillis()
|
||||||
|
val snapshotAt = snapshotAt(now)
|
||||||
|
val newAndHotWindowStart = windowStart(snapshotAt, days = 3)
|
||||||
|
val mostCommentedWindowStart = windowStart(snapshotAt, days = 7)
|
||||||
|
val recommendedWindowStart = mostCommentedWindowStart
|
||||||
|
|
||||||
|
runCatching {
|
||||||
|
replaceNewAndHotSnapshots(newAndHotWindowStart, snapshotAt, AudioRecommendationVisibility.SAFE)
|
||||||
|
replaceNewAndHotSnapshots(newAndHotWindowStart, snapshotAt, AudioRecommendationVisibility.ALL)
|
||||||
|
replaceMostCommentedSnapshots(mostCommentedWindowStart, snapshotAt, AudioRecommendationVisibility.SAFE)
|
||||||
|
replaceMostCommentedSnapshots(mostCommentedWindowStart, snapshotAt, AudioRecommendationVisibility.ALL)
|
||||||
|
replaceRecommendedAudioSnapshots(recommendedWindowStart, snapshotAt, AudioRecommendationVisibility.SAFE)
|
||||||
|
replaceRecommendedAudioSnapshots(recommendedWindowStart, snapshotAt, AudioRecommendationVisibility.ALL)
|
||||||
|
}.onSuccess {
|
||||||
|
log.info(
|
||||||
|
"event=audio_recommendation_snapshot_refresh_success snapshotAt={} elapsedMs={}",
|
||||||
|
snapshotAt,
|
||||||
|
System.currentTimeMillis() - startedAt
|
||||||
|
)
|
||||||
|
}.onFailure { ex ->
|
||||||
|
log.warn(
|
||||||
|
"event=audio_recommendation_snapshot_refresh_failure snapshotAt={} elapsedMs={} error={}",
|
||||||
|
snapshotAt,
|
||||||
|
System.currentTimeMillis() - startedAt,
|
||||||
|
ex.message,
|
||||||
|
ex
|
||||||
|
)
|
||||||
|
throw ex
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun replaceNewAndHotSnapshots(
|
||||||
|
windowStart: LocalDateTime,
|
||||||
|
snapshotAt: LocalDateTime,
|
||||||
|
visibility: AudioRecommendationVisibility
|
||||||
|
) {
|
||||||
|
val sectionType = visibility.newAndHotSectionType()
|
||||||
|
val snapshots = queryPort.findNewAndHotSnapshots(windowStart, snapshotAt, visibility, NEW_AND_HOT_LIMIT)
|
||||||
|
snapshotPort.replaceSnapshots(sectionType, snapshotAt, snapshots)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun replaceMostCommentedSnapshots(
|
||||||
|
windowStart: LocalDateTime,
|
||||||
|
snapshotAt: LocalDateTime,
|
||||||
|
visibility: AudioRecommendationVisibility
|
||||||
|
) {
|
||||||
|
val sectionType = visibility.mostCommentedSectionType()
|
||||||
|
val snapshots = queryPort.findMostCommentedSnapshots(windowStart, snapshotAt, visibility, MOST_COMMENTED_LIMIT)
|
||||||
|
snapshotPort.replaceSnapshots(sectionType, snapshotAt, snapshots)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun replaceRecommendedAudioSnapshots(
|
||||||
|
windowStart: LocalDateTime,
|
||||||
|
snapshotAt: LocalDateTime,
|
||||||
|
visibility: AudioRecommendationVisibility
|
||||||
|
) {
|
||||||
|
val sectionType = visibility.recommendedAudioSectionType()
|
||||||
|
val snapshots = queryPort.findRecommendedAudioSnapshots(windowStart, snapshotAt, visibility, RECOMMENDED_AUDIO_LIMIT)
|
||||||
|
snapshotPort.replaceSnapshots(sectionType, snapshotAt, snapshots)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun snapshotAt(now: ZonedDateTime): LocalDateTime {
|
||||||
|
val nowKst = now
|
||||||
|
.withZoneSameInstant(KST_ZONE)
|
||||||
|
return nowKst.toLocalDate()
|
||||||
|
.minusDays(1)
|
||||||
|
.atTime(23, 59, 59)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun windowStart(snapshotAt: LocalDateTime, days: Long): LocalDateTime {
|
||||||
|
return snapshotAt.toLocalDate()
|
||||||
|
.minusDays(days - 1)
|
||||||
|
.atStartOfDay()
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
const val NEW_AND_HOT_LIMIT = 12
|
||||||
|
const val MOST_COMMENTED_LIMIT = 5
|
||||||
|
const val RECOMMENDED_AUDIO_LIMIT = 10
|
||||||
|
private val KST_ZONE: ZoneId = ZoneId.of("Asia/Seoul")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
package kr.co.vividnext.sodalive.v2.audio.recommendation.application
|
||||||
|
|
||||||
|
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 org.junit.jupiter.api.DisplayName
|
||||||
|
import org.junit.jupiter.api.Test
|
||||||
|
import org.mockito.Mockito
|
||||||
|
import java.time.LocalDateTime
|
||||||
|
import java.time.ZoneId
|
||||||
|
import java.time.ZonedDateTime
|
||||||
|
|
||||||
|
class AudioRecommendationSnapshotRefreshServiceTest {
|
||||||
|
private val snapshotPort = Mockito.mock(RecommendationSnapshotPort::class.java)
|
||||||
|
private val queryPort = Mockito.mock(AudioRecommendationQueryPort::class.java)
|
||||||
|
private val service = AudioRecommendationSnapshotRefreshService(snapshotPort, queryPort)
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("일 배치는 KST 전날 23:59:59 기준으로 여섯 오디오 스냅샷을 교체한다")
|
||||||
|
fun shouldRefreshAllAudioSnapshotsWithKstPreviousDaySnapshotAt() {
|
||||||
|
val now = LocalDateTime.of(2026, 6, 24, 0, 0)
|
||||||
|
val snapshotAt = LocalDateTime.of(2026, 6, 23, 23, 59, 59)
|
||||||
|
val newAndHotWindowStart = LocalDateTime.of(2026, 6, 21, 0, 0)
|
||||||
|
val mostCommentedWindowStart = LocalDateTime.of(2026, 6, 17, 0, 0)
|
||||||
|
|
||||||
|
service.refreshDailySnapshots(now)
|
||||||
|
|
||||||
|
Mockito.verify(queryPort).findNewAndHotSnapshots(
|
||||||
|
newAndHotWindowStart,
|
||||||
|
snapshotAt,
|
||||||
|
AudioRecommendationVisibility.SAFE,
|
||||||
|
AudioRecommendationSnapshotRefreshService.NEW_AND_HOT_LIMIT
|
||||||
|
)
|
||||||
|
Mockito.verify(queryPort).findMostCommentedSnapshots(
|
||||||
|
mostCommentedWindowStart,
|
||||||
|
snapshotAt,
|
||||||
|
AudioRecommendationVisibility.ALL,
|
||||||
|
AudioRecommendationSnapshotRefreshService.MOST_COMMENTED_LIMIT
|
||||||
|
)
|
||||||
|
Mockito.verify(queryPort).findRecommendedAudioSnapshots(
|
||||||
|
mostCommentedWindowStart,
|
||||||
|
snapshotAt,
|
||||||
|
AudioRecommendationVisibility.SAFE,
|
||||||
|
AudioRecommendationSnapshotRefreshService.RECOMMENDED_AUDIO_LIMIT
|
||||||
|
)
|
||||||
|
Mockito.verify(snapshotPort).replaceSnapshots(RecommendedSectionType.NEW_AND_HOT_AUDIO_SAFE, snapshotAt, emptyList())
|
||||||
|
Mockito.verify(snapshotPort).replaceSnapshots(RecommendedSectionType.NEW_AND_HOT_AUDIO_ALL, snapshotAt, emptyList())
|
||||||
|
Mockito.verify(snapshotPort).replaceSnapshots(RecommendedSectionType.MOST_COMMENTED_AUDIO_SAFE, snapshotAt, emptyList())
|
||||||
|
Mockito.verify(snapshotPort).replaceSnapshots(RecommendedSectionType.MOST_COMMENTED_AUDIO_ALL, snapshotAt, emptyList())
|
||||||
|
Mockito.verify(snapshotPort).replaceSnapshots(RecommendedSectionType.RECOMMENDED_AUDIO_SAFE, snapshotAt, emptyList())
|
||||||
|
Mockito.verify(snapshotPort).replaceSnapshots(RecommendedSectionType.RECOMMENDED_AUDIO_ALL, snapshotAt, emptyList())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("일 배치는 ZonedDateTime 입력의 zone과 무관하게 KST 날짜 경계 기준으로 스냅샷 시각을 계산한다")
|
||||||
|
fun shouldRefreshSnapshotsByKstBoundaryFromZonedDateTime() {
|
||||||
|
val now = ZonedDateTime.of(2026, 6, 24, 0, 0, 0, 0, ZoneId.of("Asia/Seoul"))
|
||||||
|
val snapshotAt = LocalDateTime.of(2026, 6, 23, 23, 59, 59)
|
||||||
|
val newAndHotWindowStart = LocalDateTime.of(2026, 6, 21, 0, 0)
|
||||||
|
val mostCommentedWindowStart = LocalDateTime.of(2026, 6, 17, 0, 0)
|
||||||
|
|
||||||
|
service.refreshDailySnapshots(now)
|
||||||
|
|
||||||
|
Mockito.verify(queryPort).findNewAndHotSnapshots(
|
||||||
|
newAndHotWindowStart,
|
||||||
|
snapshotAt,
|
||||||
|
AudioRecommendationVisibility.SAFE,
|
||||||
|
AudioRecommendationSnapshotRefreshService.NEW_AND_HOT_LIMIT
|
||||||
|
)
|
||||||
|
Mockito.verify(queryPort).findMostCommentedSnapshots(
|
||||||
|
mostCommentedWindowStart,
|
||||||
|
snapshotAt,
|
||||||
|
AudioRecommendationVisibility.ALL,
|
||||||
|
AudioRecommendationSnapshotRefreshService.MOST_COMMENTED_LIMIT
|
||||||
|
)
|
||||||
|
Mockito.verify(queryPort).findRecommendedAudioSnapshots(
|
||||||
|
mostCommentedWindowStart,
|
||||||
|
snapshotAt,
|
||||||
|
AudioRecommendationVisibility.SAFE,
|
||||||
|
AudioRecommendationSnapshotRefreshService.RECOMMENDED_AUDIO_LIMIT
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user