From f1e03706c74658b7e036f8674fb34023f4072234 Mon Sep 17 00:00:00 2001 From: Klaus Date: Wed, 24 Jun 2026 16:19:50 +0900 Subject: [PATCH] =?UTF-8?q?feat(content-ranking):=20=EB=9E=AD=ED=82=B9=20?= =?UTF-8?q?=EC=8A=A4=EB=83=85=EC=83=B7=20=EC=A0=80=EC=9E=A5=EC=86=8C?= =?UTF-8?q?=EB=A5=BC=20=EC=B6=94=EA=B0=80=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../create-content-ranking-tables.sql | 4 + .../out/persistence/AudioRankingSnapshot.kt | 105 ++++++++++ .../AudioRankingSnapshotRepository.kt | 57 +++++ ...tAudioRankingSnapshotPersistenceAdapter.kt | 118 +++++++++++ .../port/out/AudioRankingSnapshotPort.kt | 58 +++++ ...ioRankingSnapshotPersistenceAdapterTest.kt | 198 ++++++++++++++++++ 6 files changed, 540 insertions(+) create mode 100644 src/main/kotlin/kr/co/vividnext/sodalive/v2/content/ranking/adapter/out/persistence/AudioRankingSnapshot.kt create mode 100644 src/main/kotlin/kr/co/vividnext/sodalive/v2/content/ranking/adapter/out/persistence/AudioRankingSnapshotRepository.kt create mode 100644 src/main/kotlin/kr/co/vividnext/sodalive/v2/content/ranking/adapter/out/persistence/DefaultAudioRankingSnapshotPersistenceAdapter.kt create mode 100644 src/main/kotlin/kr/co/vividnext/sodalive/v2/content/ranking/port/out/AudioRankingSnapshotPort.kt create mode 100644 src/test/kotlin/kr/co/vividnext/sodalive/v2/content/ranking/adapter/out/persistence/DefaultAudioRankingSnapshotPersistenceAdapterTest.kt diff --git a/docs/20260623_메인_콘텐츠_랭킹_탭_API/create-content-ranking-tables.sql b/docs/20260623_메인_콘텐츠_랭킹_탭_API/create-content-ranking-tables.sql index 6f0c5e3a..6c965d7b 100644 --- a/docs/20260623_메인_콘텐츠_랭킹_탭_API/create-content-ranking-tables.sql +++ b/docs/20260623_메인_콘텐츠_랭킹_탭_API/create-content-ranking-tables.sql @@ -18,6 +18,7 @@ create table content_ranking_snapshot ( creator_nickname varchar(100) not null comment '스냅샷 생성 시점 크리에이터 닉네임', cover_image_url varchar(500) null comment '스냅샷 생성 시점 콘텐츠 커버 이미지 URL', release_date timestamp not null comment '콘텐츠 공개 시각', + is_adult boolean not null comment '스냅샷 생성 시점 성인 콘텐츠 여부', rank_no int not null comment '스냅샷 생성 시점 순위', final_score double not null comment '최종 랭킹 점수 또는 정렬 지표', normalized_score double null comment '유료/무료 그룹 정규화 점수', @@ -51,6 +52,9 @@ create index idx_content_ranking_snapshot_period_rank create index idx_content_ranking_snapshot_visible_rank on content_ranking_snapshot (ranking_type, visible_from_at desc, rank_no); +create index idx_content_ranking_snapshot_visible_adult_rank + on content_ranking_snapshot (ranking_type, visible_from_at desc, is_adult, rank_no); + create index idx_content_ranking_snapshot_period_score on content_ranking_snapshot (ranking_type, aggregation_end_at_utc, final_score desc, release_date desc, content_id desc); diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/content/ranking/adapter/out/persistence/AudioRankingSnapshot.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/content/ranking/adapter/out/persistence/AudioRankingSnapshot.kt new file mode 100644 index 00000000..60f5de84 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/content/ranking/adapter/out/persistence/AudioRankingSnapshot.kt @@ -0,0 +1,105 @@ +package kr.co.vividnext.sodalive.v2.content.ranking.adapter.out.persistence + +import kr.co.vividnext.sodalive.common.BaseEntity +import kr.co.vividnext.sodalive.v2.content.ranking.domain.AudioRankingType +import java.time.LocalDateTime +import javax.persistence.Column +import javax.persistence.Entity +import javax.persistence.EnumType +import javax.persistence.Enumerated +import javax.persistence.Table + +@Entity +@Table(name = "content_ranking_snapshot") +class AudioRankingSnapshot( + @Enumerated(EnumType.STRING) + @Column(name = "ranking_type", nullable = false, updatable = false, length = 30) + val rankingType: AudioRankingType, + + @Column(name = "aggregation_start_at_utc", nullable = false, updatable = false) + val aggregationStartAtUtc: LocalDateTime, + + @Column(name = "aggregation_end_at_utc", nullable = false, updatable = false) + val aggregationEndAtUtc: LocalDateTime, + + @Column(name = "visible_from_at", nullable = false, updatable = false) + val visibleFromAtUtc: LocalDateTime, + + @Column(name = "content_id", nullable = false, updatable = false) + val contentId: Long, + + @Column(name = "title", nullable = false, updatable = false, length = 255) + val title: String, + + @Column(name = "creator_member_id", nullable = false, updatable = false) + val creatorMemberId: Long, + + @Column(name = "creator_nickname", nullable = false, updatable = false, length = 100) + val creatorNickname: String, + + @Column(name = "cover_image_url", updatable = false, length = 500) + val coverImageUrl: String?, + + @Column(name = "release_date", nullable = false, updatable = false) + val releaseDate: LocalDateTime, + + @Column(name = "is_adult", nullable = false, updatable = false) + val isAdult: Boolean, + + @Column(name = "rank_no", nullable = false, updatable = false) + val rank: Int, + + @Column(name = "final_score", nullable = false, updatable = false) + val finalScore: Double, + + @Column(name = "normalized_score", updatable = false) + val normalizedScore: Double? = null, + + @Column(name = "raw_score", updatable = false) + val rawScore: Double? = null, + + @Column(name = "revenue_can_amount", updatable = false) + val revenueCanAmount: Long? = null, + + @Column(name = "sales_count", updatable = false) + val salesCount: Long? = null, + + @Column(name = "view_count", updatable = false) + val viewCount: Long? = null, + + @Column(name = "like_count", updatable = false) + val likeCount: Long? = null, + + @Column(name = "comment_count", updatable = false) + val commentCount: Long? = null, + + @Column(name = "previous_sales_count", updatable = false) + val previousSalesCount: Long? = null, + + @Column(name = "previous_view_count", updatable = false) + val previousViewCount: Long? = null, + + @Column(name = "previous_like_count", updatable = false) + val previousLikeCount: Long? = null, + + @Column(name = "previous_comment_count", updatable = false) + val previousCommentCount: Long? = null, + + @Column(name = "sales_growth_rate", updatable = false) + val salesGrowthRate: Double? = null, + + @Column(name = "view_growth_rate", updatable = false) + val viewGrowthRate: Double? = null, + + @Column(name = "like_growth_rate", updatable = false) + val likeGrowthRate: Double? = null, + + @Column(name = "comment_growth_rate", updatable = false) + val commentGrowthRate: Double? = null, + + @Column(name = "content_growth_score", updatable = false) + val contentGrowthScore: Double? = null, + + @Column(name = "boost_multiplier", updatable = false) + val boostMultiplier: Double? = null +) : BaseEntity() diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/content/ranking/adapter/out/persistence/AudioRankingSnapshotRepository.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/content/ranking/adapter/out/persistence/AudioRankingSnapshotRepository.kt new file mode 100644 index 00000000..2f924170 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/content/ranking/adapter/out/persistence/AudioRankingSnapshotRepository.kt @@ -0,0 +1,57 @@ +package kr.co.vividnext.sodalive.v2.content.ranking.adapter.out.persistence + +import kr.co.vividnext.sodalive.v2.content.ranking.domain.AudioRankingType +import org.springframework.data.jpa.repository.JpaRepository +import org.springframework.data.jpa.repository.Query +import org.springframework.data.repository.query.Param +import java.time.LocalDateTime + +interface AudioRankingSnapshotRepository : JpaRepository { + @Query( + value = """ + select * + from content_ranking_snapshot crs + where crs.ranking_type = :rankingType + and crs.visible_from_at = ( + select max(latest.visible_from_at) + from content_ranking_snapshot latest + where latest.ranking_type = :rankingType + and latest.visible_from_at <= :nowUtc + ) + order by crs.rank_no asc + """, + nativeQuery = true + ) + fun findLatestVisibleSnapshots( + @Param("rankingType") rankingType: String, + @Param("nowUtc") nowUtc: LocalDateTime + ): List + + @Query( + value = """ + select * + from content_ranking_snapshot crs + where crs.ranking_type = :rankingType + and crs.aggregation_start_at_utc = ( + select max(previous.aggregation_start_at_utc) + from content_ranking_snapshot previous + where previous.ranking_type = :rankingType + and previous.aggregation_start_at_utc < :currentAggregationStartAtUtc + and previous.visible_from_at <= :nowUtc + ) + order by crs.rank_no asc + """, + nativeQuery = true + ) + fun findPreviousVisibleSnapshots( + @Param("rankingType") rankingType: String, + @Param("currentAggregationStartAtUtc") currentAggregationStartAtUtc: LocalDateTime, + @Param("nowUtc") nowUtc: LocalDateTime + ): List + + fun deleteByRankingTypeAndAggregationStartAtUtcAndAggregationEndAtUtc( + rankingType: AudioRankingType, + aggregationStartAtUtc: LocalDateTime, + aggregationEndAtUtc: LocalDateTime + ) +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/content/ranking/adapter/out/persistence/DefaultAudioRankingSnapshotPersistenceAdapter.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/content/ranking/adapter/out/persistence/DefaultAudioRankingSnapshotPersistenceAdapter.kt new file mode 100644 index 00000000..aa42851d --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/content/ranking/adapter/out/persistence/DefaultAudioRankingSnapshotPersistenceAdapter.kt @@ -0,0 +1,118 @@ +package kr.co.vividnext.sodalive.v2.content.ranking.adapter.out.persistence + +import kr.co.vividnext.sodalive.v2.content.ranking.domain.AudioRankingType +import kr.co.vividnext.sodalive.v2.content.ranking.port.out.AudioRankingSnapshotPort +import kr.co.vividnext.sodalive.v2.content.ranking.port.out.AudioRankingSnapshotRecord +import org.springframework.stereotype.Repository +import org.springframework.transaction.annotation.Transactional +import java.time.LocalDateTime + +@Repository +class DefaultAudioRankingSnapshotPersistenceAdapter( + private val repository: AudioRankingSnapshotRepository +) : AudioRankingSnapshotPort { + override fun findLatestVisibleSnapshots( + rankingType: AudioRankingType, + nowUtc: LocalDateTime + ): List { + return repository.findLatestVisibleSnapshots(rankingType.name, nowUtc).map { it.toRecord() } + } + + override fun findPreviousVisibleSnapshots( + rankingType: AudioRankingType, + currentAggregationStartAtUtc: LocalDateTime, + nowUtc: LocalDateTime + ): List { + return repository.findPreviousVisibleSnapshots( + rankingType = rankingType.name, + currentAggregationStartAtUtc = currentAggregationStartAtUtc, + nowUtc = nowUtc + ).map { it.toRecord() } + } + + @Transactional + override fun replaceSnapshots( + rankingType: AudioRankingType, + aggregationStartAtUtc: LocalDateTime, + aggregationEndAtUtc: LocalDateTime, + visibleFromAtUtc: LocalDateTime, + newSnapshots: List + ) { + repository.deleteByRankingTypeAndAggregationStartAtUtcAndAggregationEndAtUtc( + rankingType = rankingType, + aggregationStartAtUtc = aggregationStartAtUtc, + aggregationEndAtUtc = aggregationEndAtUtc + ) + repository.saveAll(newSnapshots.map { it.toEntity(visibleFromAtUtc) }) + } + + private fun AudioRankingSnapshot.toRecord(): AudioRankingSnapshotRecord { + return AudioRankingSnapshotRecord( + rankingType = rankingType, + aggregationStartAtUtc = aggregationStartAtUtc, + aggregationEndAtUtc = aggregationEndAtUtc, + visibleFromAtUtc = visibleFromAtUtc, + contentId = contentId, + title = title, + creatorMemberId = creatorMemberId, + creatorNickname = creatorNickname, + coverImageUrl = coverImageUrl, + releaseDate = releaseDate, + isAdult = isAdult, + rank = rank, + finalScore = finalScore, + normalizedScore = normalizedScore, + rawScore = rawScore, + revenueCanAmount = revenueCanAmount, + salesCount = salesCount, + viewCount = viewCount, + likeCount = likeCount, + commentCount = commentCount, + previousSalesCount = previousSalesCount, + previousViewCount = previousViewCount, + previousLikeCount = previousLikeCount, + previousCommentCount = previousCommentCount, + salesGrowthRate = salesGrowthRate, + viewGrowthRate = viewGrowthRate, + likeGrowthRate = likeGrowthRate, + commentGrowthRate = commentGrowthRate, + contentGrowthScore = contentGrowthScore, + boostMultiplier = boostMultiplier + ) + } + + private fun AudioRankingSnapshotRecord.toEntity(visibleFromAtUtc: LocalDateTime): AudioRankingSnapshot { + return AudioRankingSnapshot( + rankingType = rankingType, + aggregationStartAtUtc = aggregationStartAtUtc, + aggregationEndAtUtc = aggregationEndAtUtc, + visibleFromAtUtc = visibleFromAtUtc, + contentId = contentId, + title = title, + creatorMemberId = creatorMemberId, + creatorNickname = creatorNickname, + coverImageUrl = coverImageUrl, + releaseDate = releaseDate, + isAdult = isAdult, + rank = rank, + finalScore = finalScore, + normalizedScore = normalizedScore, + rawScore = rawScore, + revenueCanAmount = revenueCanAmount, + salesCount = salesCount, + viewCount = viewCount, + likeCount = likeCount, + commentCount = commentCount, + previousSalesCount = previousSalesCount, + previousViewCount = previousViewCount, + previousLikeCount = previousLikeCount, + previousCommentCount = previousCommentCount, + salesGrowthRate = salesGrowthRate, + viewGrowthRate = viewGrowthRate, + likeGrowthRate = likeGrowthRate, + commentGrowthRate = commentGrowthRate, + contentGrowthScore = contentGrowthScore, + boostMultiplier = boostMultiplier + ) + } +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/content/ranking/port/out/AudioRankingSnapshotPort.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/content/ranking/port/out/AudioRankingSnapshotPort.kt new file mode 100644 index 00000000..d1a49280 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/content/ranking/port/out/AudioRankingSnapshotPort.kt @@ -0,0 +1,58 @@ +package kr.co.vividnext.sodalive.v2.content.ranking.port.out + +import kr.co.vividnext.sodalive.v2.content.ranking.domain.AudioRankingType +import java.time.LocalDateTime + +interface AudioRankingSnapshotPort { + fun findLatestVisibleSnapshots( + rankingType: AudioRankingType, + nowUtc: LocalDateTime + ): List + + fun findPreviousVisibleSnapshots( + rankingType: AudioRankingType, + currentAggregationStartAtUtc: LocalDateTime, + nowUtc: LocalDateTime + ): List + + fun replaceSnapshots( + rankingType: AudioRankingType, + aggregationStartAtUtc: LocalDateTime, + aggregationEndAtUtc: LocalDateTime, + visibleFromAtUtc: LocalDateTime, + newSnapshots: List + ) +} + +data class AudioRankingSnapshotRecord( + val rankingType: AudioRankingType, + val aggregationStartAtUtc: LocalDateTime, + val aggregationEndAtUtc: LocalDateTime, + val visibleFromAtUtc: LocalDateTime, + val contentId: Long, + val title: String, + val creatorMemberId: Long, + val creatorNickname: String, + val coverImageUrl: String?, + val releaseDate: LocalDateTime, + val isAdult: Boolean, + val rank: Int, + val finalScore: Double, + val normalizedScore: Double? = null, + val rawScore: Double? = null, + val revenueCanAmount: Long? = null, + val salesCount: Long? = null, + val viewCount: Long? = null, + val likeCount: Long? = null, + val commentCount: Long? = null, + val previousSalesCount: Long? = null, + val previousViewCount: Long? = null, + val previousLikeCount: Long? = null, + val previousCommentCount: Long? = null, + val salesGrowthRate: Double? = null, + val viewGrowthRate: Double? = null, + val likeGrowthRate: Double? = null, + val commentGrowthRate: Double? = null, + val contentGrowthScore: Double? = null, + val boostMultiplier: Double? = null +) diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/v2/content/ranking/adapter/out/persistence/DefaultAudioRankingSnapshotPersistenceAdapterTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/content/ranking/adapter/out/persistence/DefaultAudioRankingSnapshotPersistenceAdapterTest.kt new file mode 100644 index 00000000..1e7a57af --- /dev/null +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/content/ranking/adapter/out/persistence/DefaultAudioRankingSnapshotPersistenceAdapterTest.kt @@ -0,0 +1,198 @@ +package kr.co.vividnext.sodalive.v2.content.ranking.adapter.out.persistence + +import kr.co.vividnext.sodalive.configs.QueryDslConfig +import kr.co.vividnext.sodalive.v2.content.ranking.domain.AudioRankingType +import kr.co.vividnext.sodalive.v2.content.ranking.port.out.AudioRankingSnapshotRecord +import org.junit.jupiter.api.Assertions.assertEquals +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.orm.jpa.DataJpaTest +import org.springframework.context.annotation.Import +import java.time.LocalDateTime + +@DataJpaTest( + properties = [ + "spring.cache.type=none", + "spring.datasource.url=jdbc:h2:mem:testdb;MODE=MySQL;NON_KEYWORDS=VALUE" + ] +) +@Import(QueryDslConfig::class) +class DefaultAudioRankingSnapshotPersistenceAdapterTest @Autowired constructor( + private val repository: AudioRankingSnapshotRepository +) { + private val adapter = DefaultAudioRankingSnapshotPersistenceAdapter(repository) + + @Test + @DisplayName("최신 visible 스냅샷만 랭킹 타입별 rank 순서로 조회한다") + fun shouldFindLatestVisibleSnapshotsByRankingTypeAndVisibleFromAt() { + val previousVisibleAt = LocalDateTime.of(2026, 6, 1, 0, 0) + val latestVisibleAt = LocalDateTime.of(2026, 6, 8, 0, 0) + val hiddenVisibleAt = LocalDateTime.of(2026, 6, 15, 0, 0) + repository.saveAll( + listOf( + snapshot( + contentId = 1L, + rankingType = AudioRankingType.WEEKLY_POPULAR, + visibleFromAtUtc = previousVisibleAt + ), + snapshot( + contentId = 2L, + rankingType = AudioRankingType.WEEKLY_POPULAR, + visibleFromAtUtc = latestVisibleAt, + rank = 2 + ), + snapshot( + contentId = 3L, + rankingType = AudioRankingType.WEEKLY_POPULAR, + visibleFromAtUtc = latestVisibleAt, + rank = 1 + ), + snapshot( + contentId = 4L, + rankingType = AudioRankingType.WEEKLY_POPULAR, + visibleFromAtUtc = hiddenVisibleAt + ), + snapshot(contentId = 5L, rankingType = AudioRankingType.RISING, visibleFromAtUtc = latestVisibleAt) + ) + ) + + val snapshots = adapter.findLatestVisibleSnapshots( + rankingType = AudioRankingType.WEEKLY_POPULAR, + nowUtc = LocalDateTime.of(2026, 6, 8, 23, 59) + ) + + assertEquals(listOf(3L, 2L), snapshots.map { it.contentId }) + assertEquals(listOf(latestVisibleAt, latestVisibleAt), snapshots.map { it.visibleFromAtUtc }) + } + + @Test + @DisplayName("09시 전 생성된 신규 스냅샷은 visible 전까지 이전 visible 스냅샷을 반환한다") + fun shouldReturnPreviousVisibleSnapshotsBeforeNewVisibleFromAt() { + val previousStartAt = LocalDateTime.of(2026, 5, 24, 15, 0) + val previousEndAt = LocalDateTime.of(2026, 5, 31, 15, 0) + val currentStartAt = LocalDateTime.of(2026, 5, 31, 15, 0) + val currentEndAt = LocalDateTime.of(2026, 6, 7, 15, 0) + repository.saveAll( + listOf( + snapshot( + contentId = 1L, + aggregationStartAtUtc = previousStartAt, + aggregationEndAtUtc = previousEndAt, + visibleFromAtUtc = LocalDateTime.of(2026, 6, 1, 0, 0) + ), + snapshot( + contentId = 2L, + aggregationStartAtUtc = currentStartAt, + aggregationEndAtUtc = currentEndAt, + visibleFromAtUtc = LocalDateTime.of(2026, 6, 8, 0, 0) + ) + ) + ) + + val snapshots = adapter.findLatestVisibleSnapshots( + rankingType = AudioRankingType.WEEKLY_POPULAR, + nowUtc = LocalDateTime.of(2026, 6, 7, 23, 59) + ) + + assertEquals(listOf(1L), snapshots.map { it.contentId }) + } + + @Test + @DisplayName("스냅샷 교체는 같은 랭킹 타입과 집계 기간 row만 삭제한다") + fun shouldReplaceSnapshotsOnlyForSameRankingTypeAndPeriod() { + val startAt = LocalDateTime.of(2026, 5, 31, 15, 0) + val endAt = LocalDateTime.of(2026, 6, 7, 15, 0) + val visibleAt = LocalDateTime.of(2026, 6, 8, 0, 0) + repository.saveAll( + listOf( + snapshot( + contentId = 1L, + rankingType = AudioRankingType.WEEKLY_POPULAR, + aggregationStartAtUtc = startAt, + aggregationEndAtUtc = endAt + ), + snapshot( + contentId = 2L, + rankingType = AudioRankingType.RISING, + aggregationStartAtUtc = startAt, + aggregationEndAtUtc = endAt + ), + snapshot( + contentId = 3L, + rankingType = AudioRankingType.WEEKLY_POPULAR, + aggregationStartAtUtc = startAt.minusWeeks(1), + aggregationEndAtUtc = endAt.minusWeeks(1) + ) + ) + ) + + adapter.replaceSnapshots( + rankingType = AudioRankingType.WEEKLY_POPULAR, + aggregationStartAtUtc = startAt, + aggregationEndAtUtc = endAt, + visibleFromAtUtc = visibleAt, + newSnapshots = listOf( + snapshotRecord( + contentId = 4L, + aggregationStartAtUtc = startAt, + aggregationEndAtUtc = endAt, + visibleFromAtUtc = visibleAt + ) + ) + ) + + val all = repository.findAll().map { it.contentId }.sorted() + assertEquals(listOf(2L, 3L, 4L), all) + } + + private fun snapshot( + contentId: Long, + rankingType: AudioRankingType = AudioRankingType.WEEKLY_POPULAR, + aggregationStartAtUtc: LocalDateTime = LocalDateTime.of(2026, 5, 31, 15, 0), + aggregationEndAtUtc: LocalDateTime = LocalDateTime.of(2026, 6, 7, 15, 0), + visibleFromAtUtc: LocalDateTime = LocalDateTime.of(2026, 6, 8, 0, 0), + rank: Int = 1, + isAdult: Boolean = false + ): AudioRankingSnapshot { + return AudioRankingSnapshot( + rankingType = rankingType, + aggregationStartAtUtc = aggregationStartAtUtc, + aggregationEndAtUtc = aggregationEndAtUtc, + visibleFromAtUtc = visibleFromAtUtc, + contentId = contentId, + title = "audio-$contentId", + creatorMemberId = 100L + contentId, + creatorNickname = "creator-$contentId", + coverImageUrl = "cover-$contentId.png", + releaseDate = LocalDateTime.of(2026, 6, 1, 0, 0), + isAdult = isAdult, + rank = rank, + finalScore = 100.0 + ) + } + + private fun snapshotRecord( + contentId: Long, + aggregationStartAtUtc: LocalDateTime, + aggregationEndAtUtc: LocalDateTime, + visibleFromAtUtc: LocalDateTime, + isAdult: Boolean = false + ): AudioRankingSnapshotRecord { + return AudioRankingSnapshotRecord( + rankingType = AudioRankingType.WEEKLY_POPULAR, + aggregationStartAtUtc = aggregationStartAtUtc, + aggregationEndAtUtc = aggregationEndAtUtc, + visibleFromAtUtc = visibleFromAtUtc, + contentId = contentId, + title = "audio-$contentId", + creatorMemberId = 100L + contentId, + creatorNickname = "creator-$contentId", + coverImageUrl = "cover-$contentId.png", + releaseDate = LocalDateTime.of(2026, 6, 1, 0, 0), + isAdult = isAdult, + rank = 1, + finalScore = 100.0 + ) + } +}