feat(banner): v2 콘텐츠 추천에 예약 기간 필터를 적용한다

This commit is contained in:
2026-10-27 20:49:17 +09:00
parent 0a46d8a123
commit 69e6b7d24c
2 changed files with 64 additions and 2 deletions
@@ -10,6 +10,7 @@ import com.querydsl.jpa.impl.JPAQueryFactory
import kr.co.vividnext.sodalive.content.QAudioContent.audioContent import kr.co.vividnext.sodalive.content.QAudioContent.audioContent
import kr.co.vividnext.sodalive.content.main.banner.AudioContentBannerType import kr.co.vividnext.sodalive.content.main.banner.AudioContentBannerType
import kr.co.vividnext.sodalive.content.main.banner.QAudioContentBanner.audioContentBanner import kr.co.vividnext.sodalive.content.main.banner.QAudioContentBanner.audioContentBanner
import kr.co.vividnext.sodalive.content.main.banner.isVisibleAt
import kr.co.vividnext.sodalive.content.theme.QAudioContentTheme.audioContentTheme import kr.co.vividnext.sodalive.content.theme.QAudioContentTheme.audioContentTheme
import kr.co.vividnext.sodalive.content.translation.ContentTranslationRepository import kr.co.vividnext.sodalive.content.translation.ContentTranslationRepository
import kr.co.vividnext.sodalive.creator.admin.content.series.QSeries.series import kr.co.vividnext.sodalive.creator.admin.content.series.QSeries.series
@@ -34,6 +35,7 @@ import org.springframework.stereotype.Repository
import java.math.BigDecimal import java.math.BigDecimal
import java.math.BigInteger import java.math.BigInteger
import java.time.LocalDateTime import java.time.LocalDateTime
import java.time.ZoneOffset
import javax.persistence.EntityManager import javax.persistence.EntityManager
@Repository @Repository
@@ -42,7 +44,8 @@ class DefaultAudioRecommendationQueryRepository(
private val entityManager: EntityManager, private val entityManager: EntityManager,
private val contentTranslationRepository: ContentTranslationRepository, private val contentTranslationRepository: ContentTranslationRepository,
@Value("\${cloud.aws.cloud-front.host}") @Value("\${cloud.aws.cloud-front.host}")
private val cloudFrontHost: String private val cloudFrontHost: String,
private val nowProvider: () -> LocalDateTime = { LocalDateTime.now(ZoneOffset.UTC) }
) : AudioRecommendationQueryRepository { ) : AudioRecommendationQueryRepository {
override fun findBanners( override fun findBanners(
limit: Int, limit: Int,
@@ -74,6 +77,7 @@ class DefaultAudioRecommendationQueryRepository(
audioContentBanner.isActive.isTrue, audioContentBanner.isActive.isTrue,
audioContentBanner.tab.id.eq(2L), audioContentBanner.tab.id.eq(2L),
audioContentBanner.lang.eq(lang), audioContentBanner.lang.eq(lang),
audioContentBanner.isVisibleAt(nowProvider()),
adultBannerCondition(canViewAdultContent), adultBannerCondition(canViewAdultContent),
activeBannerTargetCondition(memberId, bannerCreator, seriesOwner) activeBannerTargetCondition(memberId, bannerCreator, seriesOwner)
) )
@@ -7,7 +7,9 @@ import kr.co.vividnext.sodalive.content.AudioContent
import kr.co.vividnext.sodalive.content.comment.AudioContentComment import kr.co.vividnext.sodalive.content.comment.AudioContentComment
import kr.co.vividnext.sodalive.content.like.AudioContentLike 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.AudioContentBanner
import kr.co.vividnext.sodalive.content.main.banner.AudioContentBannerReservationFixtures
import kr.co.vividnext.sodalive.content.main.banner.AudioContentBannerType import kr.co.vividnext.sodalive.content.main.banner.AudioContentBannerType
import kr.co.vividnext.sodalive.content.main.banner.BannerReservationCase
import kr.co.vividnext.sodalive.content.main.tab.AudioContentMainTab import kr.co.vividnext.sodalive.content.main.tab.AudioContentMainTab
import kr.co.vividnext.sodalive.content.theme.AudioContentTheme import kr.co.vividnext.sodalive.content.theme.AudioContentTheme
import kr.co.vividnext.sodalive.content.translation.ContentTranslation import kr.co.vividnext.sodalive.content.translation.ContentTranslation
@@ -26,6 +28,8 @@ import kr.co.vividnext.sodalive.v2.recommendation.domain.RecommendedSectionType
import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.DisplayName import org.junit.jupiter.api.DisplayName
import org.junit.jupiter.api.Test import org.junit.jupiter.api.Test
import org.junit.jupiter.params.ParameterizedTest
import org.junit.jupiter.params.provider.MethodSource
import org.springframework.beans.factory.annotation.Autowired import org.springframework.beans.factory.annotation.Autowired
import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest
import org.springframework.context.annotation.Import import org.springframework.context.annotation.Import
@@ -44,12 +48,55 @@ class DefaultAudioRecommendationQueryRepositoryTest @Autowired constructor(
queryFactory: JPAQueryFactory, queryFactory: JPAQueryFactory,
contentTranslationRepository: ContentTranslationRepository contentTranslationRepository: ContentTranslationRepository
) { ) {
private var bannerNow = AudioContentBannerReservationFixtures.now
private val repository = DefaultAudioRecommendationQueryRepository( private val repository = DefaultAudioRecommendationQueryRepository(
queryFactory, queryFactory,
entityManager, entityManager,
contentTranslationRepository, contentTranslationRepository,
"https://cdn.test" "https://cdn.test"
) ) { bannerNow }
@Test
@DisplayName("콘텐츠 추천 배너는 limit 전에 미래 및 만료 예약을 제외한다")
fun shouldExcludeFutureAndExpiredReservationsBeforeLimit() {
val now = bannerNow
val tab = saveContentRecommendationTab()
listOf("future", "expired", "visible-1", "visible-2").forEachIndexed { index, name ->
saveBanner(name, AudioContentBannerType.LINK, index, tab = tab).apply {
if (name == "future") startDate = now.plusYears(1).withNano(0)
if (name == "expired") endDate = now.minusYears(1).withNano(0)
}
}
flushAndClear()
assertEquals(
listOf("https://cdn.test/visible-1", "https://cdn.test/visible-2"),
repository.findBanners(2, null, false).map { it.imageUrl }
)
}
@ParameterizedTest(name = "{0}")
@MethodSource("kr.co.vividnext.sodalive.content.main.banner.AudioContentBannerReservationFixtures#cases")
@DisplayName("콘텐츠 추천 배너는 UTC 초 정밀도의 예약 경계를 포함한다")
fun shouldApplyReservationBoundaries(case: BannerReservationCase) {
bannerNow = case.now
entityManager.createNativeQuery("alter table content_banner alter column start_date timestamp(0)").executeUpdate()
entityManager.createNativeQuery("alter table content_banner alter column end_date timestamp(0)").executeUpdate()
val tab = saveContentRecommendationTab()
val banner = saveBanner("candidate", AudioContentBannerType.LINK, 1, tab = tab).apply {
startDate = case.startDate
endDate = case.endDate
}
flushAndClear()
val stored = entityManager.find(AudioContentBanner::class.java, banner.id)
assertEquals(case.startDate, stored.startDate)
assertEquals(case.endDate, stored.endDate)
assertEquals(
if (case.visible) listOf("https://cdn.test/candidate") else emptyList(),
repository.findBanners(2, null, false).map { it.imageUrl }
)
}
@Test @Test
@DisplayName("배너는 콘텐츠 추천 탭과 성인 조회 가능 여부를 적용한다") @DisplayName("배너는 콘텐츠 추천 탭과 성인 조회 가능 여부를 적용한다")
@@ -57,6 +104,8 @@ class DefaultAudioRecommendationQueryRepositoryTest @Autowired constructor(
val viewer = saveMember("viewer", MemberRole.USER) val viewer = saveMember("viewer", MemberRole.USER)
val visibleCreator = saveMember("visible-creator", MemberRole.CREATOR) val visibleCreator = saveMember("visible-creator", MemberRole.CREATOR)
val blockedCreator = saveMember("blocked-creator", MemberRole.CREATOR) val blockedCreator = saveMember("blocked-creator", MemberRole.CREATOR)
val reverseBlockedCreator = saveMember("reverse-blocked-creator", MemberRole.CREATOR)
val inactiveCreator = saveMember("inactive-banner-creator", MemberRole.CREATOR, isActive = false)
val recommendationTab = saveContentRecommendationTab() val recommendationTab = saveContentRecommendationTab()
val visibleBanner = saveBanner( val visibleBanner = saveBanner(
"visible.png", "visible.png",
@@ -89,7 +138,16 @@ class DefaultAudioRecommendationQueryRepositoryTest @Autowired constructor(
tab = recommendationTab tab = recommendationTab
) )
saveBanner("home.png", AudioContentBannerType.LINK, 1, link = "https://home.test") saveBanner("home.png", AudioContentBannerType.LINK, 1, link = "https://home.test")
saveBanner("inactive-target.png", AudioContentBannerType.CREATOR, 0, creator = inactiveCreator, tab = recommendationTab)
saveBanner(
"reverse-blocked.png",
AudioContentBannerType.CREATOR,
0,
creator = reverseBlockedCreator,
tab = recommendationTab
)
saveBlock(viewer, blockedCreator) saveBlock(viewer, blockedCreator)
saveBlock(reverseBlockedCreator, viewer)
flushAndClear() flushAndClear()
val safeBanners = repository.findBanners(limit = 20, memberId = viewer.id, canViewAdultContent = false) val safeBanners = repository.findBanners(limit = 20, memberId = viewer.id, canViewAdultContent = false)