Compare commits

..

24 Commits

Author SHA1 Message Date
d5f4dc529a docs(content-ranking): 크리에이터 랭킹 후속 범위를 기록한다 2026-06-24 20:39:15 +09:00
94cfa3ba50 docs(content-ranking): 랭킹 스냅샷 계획을 갱신한다 2026-06-24 19:04:05 +09:00
9f24851835 test(content-ranking): 랭킹 API 통합 계약을 검증한다 2026-06-24 19:03:41 +09:00
cf29600ad3 feat(content-ranking): 랭킹 조회 fallback과 차단 필터를 적용한다 2026-06-24 19:03:12 +09:00
7ec19e3c8c feat(content-ranking): 랭킹 스냅샷 스케줄러를 추가한다 2026-06-24 19:02:39 +09:00
abeffb0a4f feat(content-ranking): 랭킹 스냅샷 job 서비스를 추가한다 2026-06-24 19:02:11 +09:00
90c5149df8 feat(content-ranking): 랭킹 차단 조회 포트를 추가한다 2026-06-24 19:01:58 +09:00
6fabcca03f docs(content-ranking): 랭킹 스냅샷 DDL을 갱신한다 2026-06-24 16:31:06 +09:00
cd43b40e44 docs(content-ranking): 랭킹 스냅샷 계획을 갱신한다 2026-06-24 16:24:26 +09:00
4d76958409 docs(content-ranking): 랭킹 스냅샷 요구사항을 갱신한다 2026-06-24 16:24:00 +09:00
f34962b285 feat(content-ranking): 스냅샷 기반 랭킹 조회를 추가한다 2026-06-24 16:23:18 +09:00
4e97364a14 feat(content-ranking): 랭킹 스냅샷 갱신 서비스를 추가한다 2026-06-24 16:22:28 +09:00
ee32696c6c feat(content-ranking): 랭킹 후보 집계를 추가한다 2026-06-24 16:21:43 +09:00
453d914f44 feat(content-ranking): 랭킹 스냅샷 job 저장소를 추가한다 2026-06-24 16:21:00 +09:00
f1e03706c7 feat(content-ranking): 랭킹 스냅샷 저장소를 추가한다 2026-06-24 16:19:50 +09:00
25c48a7606 docs(content-ranking): 랭킹 API 구현 기록을 갱신한다 2026-06-24 12:38:22 +09:00
e4706d6699 feat(content-ranking): 랭킹 점수 정책을 추가한다 2026-06-24 12:37:55 +09:00
dc93f9845b feat(content-ranking): 랭킹 공개 시각 정책을 추가한다 2026-06-24 12:37:26 +09:00
d62ce35912 feat(content-ranking): 랭킹 주간 기간 정책을 추가한다 2026-06-24 12:36:34 +09:00
af5f250abe feat(content-ranking): 오디오 랭킹 조회 endpoint를 추가한다 2026-06-24 12:36:05 +09:00
2c2607b6d0 feat(content-ranking): 오디오 랭킹 facade를 추가한다 2026-06-24 12:35:26 +09:00
c9d7399f0e feat(content-ranking): 오디오 랭킹 응답 계약을 추가한다 2026-06-24 12:35:12 +09:00
87c51d6087 docs(content-ranking): 랭킹 스냅샷 DDL 초안을 기록한다 2026-06-24 00:10:39 +09:00
d44f890391 docs(content-ranking): 랭킹 탭 API 요구사항과 계획을 기록한다 2026-06-24 00:10:25 +09:00
43 changed files with 4859 additions and 0 deletions

View File

@@ -0,0 +1,90 @@
-- MySQL 메인 콘텐츠 랭킹 탭 스냅샷 테이블
-- 날짜/시간 표시 컬럼은 TIMESTAMP를 사용한다.
-- 같은 랭킹 타입/기간 재생성 시 삭제 기준:
-- delete from content_ranking_snapshot
-- where ranking_type = :rankingType
-- and aggregation_start_at_utc = :aggregationStartAtUtc
-- and aggregation_end_at_utc = :aggregationEndAtUtc;
create table content_ranking_snapshot (
id bigint not null auto_increment comment '콘텐츠 랭킹 스냅샷 ID',
ranking_type varchar(30) not null comment '랭킹 타입(WEEKLY_POPULAR, RISING, REVENUE, SALES_COUNT, COMMENT_COUNT, LIKE_COUNT)',
aggregation_start_at_utc timestamp not null comment '집계 시작 시각(UTC, 포함)',
aggregation_end_at_utc timestamp not null comment '집계 종료 시각(UTC, 미포함)',
visible_from_at timestamp not null comment '공개 조회 노출 시작 시각(UTC)',
content_id bigint not null comment '오디오 콘텐츠 ID',
title varchar(255) not null comment '스냅샷 생성 시점 콘텐츠 제목',
creator_member_id bigint not null comment '크리에이터 회원 ID(member.id)',
creator_nickname varchar(100) not null comment '스냅샷 생성 시점 크리에이터 닉네임',
cover_image_url varchar(500) null comment '스냅샷 생성 시점 콘텐츠 커버 이미지 URL',
release_date timestamp not null comment '콘텐츠 공개 시각',
is_adult tinyint(1) not null default 0 comment '스냅샷 생성 시점 성인 콘텐츠 여부',
rank_no int not null comment '스냅샷 생성 시점 순위',
final_score double not null comment '최종 랭킹 점수 또는 정렬 지표',
normalized_score double null comment '유료/무료 그룹 정규화 점수',
raw_score double null comment '정규화 전 원점수',
revenue_can_amount bigint null comment '집계 기간 매출 캔 합계',
sales_count bigint null comment '집계 기간 판매량',
view_count bigint null comment '집계 기간 상세 페이지 조회수',
like_count bigint null comment '집계 기간 좋아요 수',
comment_count bigint null comment '집계 기간 댓글 수',
previous_sales_count bigint null comment '직전 비교 기간 판매량',
previous_view_count bigint null comment '직전 비교 기간 상세 페이지 조회수',
previous_like_count bigint null comment '직전 비교 기간 좋아요 수',
previous_comment_count bigint null comment '직전 비교 기간 댓글 수',
sales_growth_rate double null comment '판매 증가율',
view_growth_rate double null comment '조회수 증가율',
like_growth_rate double null comment '좋아요 증가율',
comment_growth_rate double null comment '댓글 증가율',
content_growth_score double null comment '지금 뜨는 중 콘텐츠 성장 점수',
boost_multiplier double null comment '신규 콘텐츠 부스트 배수',
created_at timestamp not null default current_timestamp comment '생성 시각',
updated_at timestamp not null default current_timestamp on update current_timestamp comment '수정 시각',
primary key (id)
) engine=InnoDB default charset=utf8mb4 comment='메인 콘텐츠 랭킹 탭 주간 스냅샷';
create unique index uk_content_ranking_snapshot_period_content
on content_ranking_snapshot (ranking_type, aggregation_start_at_utc, aggregation_end_at_utc, content_id);
create index idx_content_ranking_snapshot_period_rank
on content_ranking_snapshot (ranking_type, aggregation_end_at_utc, rank_no);
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);
create index idx_content_ranking_snapshot_content
on content_ranking_snapshot (content_id);
create table content_ranking_snapshot_job (
id bigint not null auto_increment comment '콘텐츠 랭킹 스냅샷 생성 job ID',
ranking_type varchar(30) not null comment '랭킹 타입(WEEKLY_POPULAR, RISING, REVENUE, SALES_COUNT, COMMENT_COUNT, LIKE_COUNT)',
aggregation_start_at_utc timestamp not null comment '집계 시작 시각(UTC, 포함)',
aggregation_end_at_utc timestamp not null comment '집계 종료 시각(UTC, 미포함)',
visible_from_at timestamp not null comment '공개 조회 노출 시작 시각(UTC)',
trigger_type varchar(20) not null comment '실행 트리거(SCHEDULED, MANUAL, FALLBACK)',
status varchar(20) not null comment 'job 상태(PENDING, PROCESSING, DONE, FAILED)',
last_error text null comment '마지막 실패 사유',
processing_started_at timestamp null comment '처리 시작 시각',
processed_at timestamp null comment '처리 완료 시각',
created_at timestamp not null default current_timestamp comment '생성 시각',
updated_at timestamp not null default current_timestamp on update current_timestamp comment '수정 시각',
primary key (id)
) engine=InnoDB default charset=utf8mb4 comment='메인 콘텐츠 랭킹 탭 스냅샷 생성 job 이력';
create index idx_content_ranking_snapshot_job_period_status
on content_ranking_snapshot_job (ranking_type, aggregation_start_at_utc, aggregation_end_at_utc, status);
create index idx_content_ranking_snapshot_job_visible_status
on content_ranking_snapshot_job (ranking_type, visible_from_at, status);
create index idx_content_ranking_snapshot_job_trigger_period
on content_ranking_snapshot_job (ranking_type, aggregation_start_at_utc, aggregation_end_at_utc, trigger_type, created_at);
create index idx_content_ranking_snapshot_job_status_created_at
on content_ranking_snapshot_job (status, created_at);

View File

@@ -0,0 +1,513 @@
# 메인 콘텐츠 랭킹 탭 API Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use `superpowers:subagent-driven-development` 또는 `superpowers:executing-plans`로 task 단위 구현을 진행한다. 각 단계는 체크박스(`- [ ]`)로 진행 상태를 갱신한다.
**Goal:** `GET /api/v2/audio/rankings`로 메인 콘텐츠 랭킹 탭의 6개 랭킹 타입을 스냅샷 기반으로 조회하고, 순위/순위 변화/신규 진입 여부를 안정적으로 제공한다.
**Architecture:** 공개 API controller/facade/response DTO는 `kr.co.vividnext.sodalive.v2.api.content.ranking` 조립 계층에 둔다. 콘텐츠 랭킹 계산, 스냅샷 조회/생성, fallback, scheduler는 `kr.co.vividnext.sodalive.v2.content.ranking` 하위에 두고 `v2.api.*`에 의존하지 않는다. 스냅샷은 `rankingType + aggregation period + visibleFromAt`을 기준으로 저장하고, 조회 API는 `visibleFromAt <= now`인 생성 완료 스냅샷만 공개 응답에 사용한다.
**Tech Stack:** Kotlin, Spring Boot 2.7.14, Java 17, Spring MVC, Spring Data JPA, QueryDSL/native SQL, Redisson, JUnit 5, MockMvc, Gradle Wrapper
---
## 0. 구현 전 확정 사항
- API endpoint: `GET /api/v2/audio/rankings`
- 요청 query parameter: `type`, 기본값 `WEEKLY_POPULAR`
- 랭킹 타입:
- `WEEKLY_POPULAR`: 주간 인기
- `RISING`: 지금 뜨는 중
- `REVENUE`: 매출
- `SALES_COUNT`: 판매량
- `COMMENT_COUNT`: 댓글 수
- `LIKE_COUNT`: 좋아요
- 모든 랭킹 타입은 완료된 지난 주 데이터를 기준으로 한다.
- 집계 기준 시각: 매주 월요일 `00:00:00 KST`
- 스냅샷 생성 시간대: 매주 월요일 `01:00:00 ~ 07:30:00 KST` 사이 랭킹 타입별 분산 실행
- 새 스냅샷 노출 전환 시각: 매주 월요일 `09:00:00 KST`
- 조회 API는 `visibleFromAt <= now`인 최신 완료 스냅샷만 응답한다.
- 09:00 전에는 새 스냅샷이 생성되어도 직전 공개 스냅샷을 응답한다.
- 특정 랭킹 타입의 새 스냅샷 생성이 실패하면 해당 타입은 직전 공개 스냅샷을 유지한다.
- fallback은 요청한 랭킹 타입과 동일 집계 기간 기준 최대 3회까지만 실행한다.
- 이번 범위는 콘텐츠 랭킹만 수정한다.
- 크리에이터 랭킹의 생성 시간/표시 시간 분리와 다중 랭킹 타입 대응은 다음 범위에서 별도 PRD 문서 수정부터 시작한다.
---
## 1. 파일 구조 계획
### 신규 API 조립 계층
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/content/ranking/adapter/in/web/AudioRankingController.kt`
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/content/ranking/application/AudioRankingFacade.kt`
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/content/ranking/dto/AudioRankingResponse.kt`
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/content/ranking/adapter/in/web/AudioRankingControllerTest.kt`
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/content/ranking/application/AudioRankingFacadeTest.kt`
### 신규 콘텐츠 랭킹 도메인 계층
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/content/ranking/domain/AudioRankingType.kt`
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/content/ranking/domain/AudioRanking.kt`
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/content/ranking/domain/AudioRankingPeriodPolicy.kt`
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/content/ranking/domain/AudioRankingScorePolicy.kt`
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/content/ranking/domain/AudioRankingSchedulePolicy.kt`
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/content/ranking/domain/AudioRankingPeriodPolicyTest.kt`
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/content/ranking/domain/AudioRankingScorePolicyTest.kt`
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/content/ranking/domain/AudioRankingSchedulePolicyTest.kt`
### 신규 콘텐츠 랭킹 application/port
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/content/ranking/application/AudioRankingQueryService.kt`
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/content/ranking/application/AudioRankingSnapshotRefreshService.kt`
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/content/ranking/application/AudioRankingSnapshotJobService.kt`
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/content/ranking/port/out/AudioRankingSnapshotPort.kt`
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/content/ranking/port/out/AudioRankingSnapshotJobPort.kt`
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/content/ranking/port/out/AudioRankingAggregationPort.kt`
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/content/ranking/port/out/AudioRankingBlockPort.kt`
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/content/ranking/application/AudioRankingQueryServiceTest.kt`
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/content/ranking/application/AudioRankingSnapshotRefreshServiceTest.kt`
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/content/ranking/application/AudioRankingSnapshotJobServiceTest.kt`
### 신규 persistence/scheduler
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/content/ranking/adapter/out/persistence/AudioRankingSnapshot.kt`
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/content/ranking/adapter/out/persistence/AudioRankingSnapshotJob.kt`
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/content/ranking/adapter/out/persistence/AudioRankingSnapshotRepository.kt`
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/content/ranking/adapter/out/persistence/AudioRankingSnapshotJobRepository.kt`
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/content/ranking/adapter/out/persistence/DefaultAudioRankingSnapshotPersistenceAdapter.kt`
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/content/ranking/adapter/out/persistence/DefaultAudioRankingAggregationRepository.kt`
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/content/ranking/adapter/out/persistence/DefaultAudioRankingBlockRepository.kt`
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/content/ranking/adapter/out/scheduler/AudioRankingSnapshotScheduler.kt`
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/content/ranking/adapter/out/persistence/DefaultAudioRankingSnapshotPersistenceAdapterTest.kt`
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/content/ranking/adapter/out/persistence/DefaultAudioRankingAggregationRepositoryTest.kt`
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/content/ranking/adapter/out/scheduler/AudioRankingSnapshotSchedulerTest.kt`
### 문서/DDL
- Modify: `docs/20260623_메인_콘텐츠_랭킹_탭_API/create-content-ranking-tables.sql`
- Modify: `docs/20260623_메인_콘텐츠_랭킹_탭_API/prd.md`
- Modify: `docs/20260623_메인_콘텐츠_랭킹_탭_API/plan-task.md`
- Verify: `docs/20260608_크리에이터_랭킹/prd.md`
- Verify: `docs/20260608_크리에이터_랭킹/plan-task.md`
- Verify: `docs/20260608_크리에이터_랭킹/create-ranking-tables.sql`
---
## 2. Response data class 초안
구현 시 `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/content/ranking/dto/AudioRankingResponse.kt`에 아래 DTO를 기준으로 추가한다. 필드명은 공개 API 계약이므로 변경이 필요하면 먼저 PRD와 이 문서를 갱신한다.
```kotlin
package kr.co.vividnext.sodalive.v2.api.content.ranking.dto
import com.fasterxml.jackson.annotation.JsonProperty
import kr.co.vividnext.sodalive.v2.content.ranking.domain.AudioRanking
import kr.co.vividnext.sodalive.v2.content.ranking.domain.AudioRankingItem
import kr.co.vividnext.sodalive.v2.content.ranking.domain.AudioRankingType
data class AudioRankingResponse(
val showRankChange: Boolean,
val type: AudioRankingType,
val items: List<AudioRankingItemResponse>
) {
companion object {
fun from(ranking: AudioRanking): AudioRankingResponse {
return AudioRankingResponse(
showRankChange = ranking.showRankChange,
type = ranking.type,
items = ranking.items.map(AudioRankingItemResponse::from)
)
}
}
}
data class AudioRankingItemResponse(
val contentId: Long,
val title: String,
val creatorNickname: String,
val rank: Int,
val rankChange: Int?,
@JsonProperty("isNew")
val isNew: Boolean,
val coverImageUrl: String?
) {
companion object {
fun from(item: AudioRankingItem): AudioRankingItemResponse {
return AudioRankingItemResponse(
contentId = item.contentId,
title = item.title,
creatorNickname = item.creatorNickname,
rank = item.rank,
rankChange = item.rankChange,
isNew = item.isNew,
coverImageUrl = item.coverImageUrl
)
}
}
}
```
---
## 3. Domain / Port 초안
```kotlin
package kr.co.vividnext.sodalive.v2.content.ranking.domain
enum class AudioRankingType {
WEEKLY_POPULAR,
RISING,
REVENUE,
SALES_COUNT,
COMMENT_COUNT,
LIKE_COUNT
}
data class AudioRanking(
val showRankChange: Boolean,
val type: AudioRankingType,
val items: List<AudioRankingItem>
)
data class AudioRankingItem(
val contentId: Long,
val title: String,
val creatorNickname: String,
val rank: Int,
val rankChange: Int?,
val isNew: Boolean,
val coverImageUrl: String?
)
```
```kotlin
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<AudioRankingSnapshotRecord>
fun findPreviousVisibleSnapshots(
rankingType: AudioRankingType,
currentAggregationStartAtUtc: LocalDateTime,
nowUtc: LocalDateTime
): List<AudioRankingSnapshotRecord>
fun replaceSnapshots(
rankingType: AudioRankingType,
aggregationStartAtUtc: LocalDateTime,
aggregationEndAtUtc: LocalDateTime,
visibleFromAtUtc: LocalDateTime,
newSnapshots: List<AudioRankingSnapshotRecord>
)
}
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 rank: Int,
val finalScore: Double
)
```
---
### Phase 1: API 계약과 DTO
- [x] **Task 1.1: `AudioRankingType`과 응답 DTO 작성**
- Files:
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/content/ranking/domain/AudioRankingType.kt`
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/content/ranking/domain/AudioRanking.kt`
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/content/ranking/dto/AudioRankingResponse.kt`
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/content/ranking/dto/AudioRankingResponseTest.kt`
- RED: `AudioRankingResponse.from(...)``showRankChange`, `type`, `contentId`, `title`, `creatorNickname`, `rank`, `rankChange`, `isNew`, `coverImageUrl`을 변환하는 테스트를 작성한다.
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.content.ranking.dto.AudioRankingResponseTest`
- GREEN: DTO와 domain model을 최소 구현한다.
- REFACTOR: 공개 DTO가 persistence/entity를 import하지 않도록 확인한다.
- 기대 결과: PRD의 Response data class 계약이 테스트로 고정된다.
- [x] **Task 1.2: facade 변환 계층 작성**
- Files:
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/content/ranking/application/AudioRankingFacade.kt`
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/content/ranking/application/AudioRankingFacadeTest.kt`
- RED: facade가 `AudioRankingQueryService.getRankings(type, member)` 결과를 `AudioRankingResponse`로 변환하는 테스트를 작성한다.
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.content.ranking.application.AudioRankingFacadeTest`
- GREEN: facade는 query service 호출과 DTO 변환만 담당한다.
- REFACTOR: facade에 점수 계산, 스냅샷 조회, fallback 로직을 두지 않는다.
- 기대 결과: API 조립 계층과 도메인 조회 계층 의존 방향이 고정된다.
- [x] **Task 1.3: 비회원 허용 controller 작성**
- Files:
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/content/ranking/adapter/in/web/AudioRankingController.kt`
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/content/ranking/adapter/in/web/AudioRankingControllerTest.kt`
- RED: `GET /api/v2/audio/rankings`가 비회원과 인증 회원 모두 `200 OK`를 반환하고, `type` 미지정 시 `WEEKLY_POPULAR`로 facade를 호출하는 MockMvc 테스트를 작성한다.
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.content.ranking.adapter.in.web.AudioRankingControllerTest`
- GREEN: `@AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?` 패턴과 `@RequestParam` 기본값을 적용한다.
- REFACTOR: controller에는 인증/요청/응답 경계만 남긴다.
- 기대 결과: endpoint 경로, 기본 type, wrapper 응답 계약이 controller 테스트로 고정된다.
### Phase 2: 기간/노출/점수 정책
- [x] **Task 2.1: KST 주간 집계 기간과 UTC 변환 정책 작성**
- Files:
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/content/ranking/domain/AudioRankingPeriodPolicy.kt`
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/content/ranking/domain/AudioRankingPeriodPolicyTest.kt`
- RED: 임의의 KST 수요일 기준으로 지난 주 월요일 00:00 KST 이상, 이번 주 월요일 00:00 KST 미만 기간을 산출하고 UTC `LocalDateTime`으로 변환하는 테스트를 작성한다.
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.content.ranking.domain.AudioRankingPeriodPolicyTest`
- GREEN: `resolveLastCompletedWeek(now)``toUtcRange(period)`를 구현한다.
- REFACTOR: 서버 기본 timezone에 의존하지 않고 `ZoneId.of("Asia/Seoul")`을 명시한다.
- 기대 결과: 모든 랭킹 타입의 집계 기준 기간이 동일하게 계산된다.
- [x] **Task 2.2: 09:00 노출 전환 정책 작성**
- Files:
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/content/ranking/domain/AudioRankingSchedulePolicy.kt`
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/content/ranking/domain/AudioRankingSchedulePolicyTest.kt`
- RED: 집계 종료일 월요일 기준 `visibleFromAt`이 같은 날 09:00 KST의 UTC 시각으로 계산되고, 09:00 전에는 새 스냅샷이 공개되지 않는 테스트를 작성한다.
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.content.ranking.domain.AudioRankingSchedulePolicyTest`
- GREEN: `resolveVisibleFromAt(aggregationEndAtKst)``isVisible(visibleFromAtUtc, nowUtc)`를 구현한다.
- REFACTOR: scheduler 실행 시각과 공개 노출 시각을 별도 함수로 분리한다.
- 기대 결과: 계산 완료와 공개 노출 전환이 분리된다.
- [x] **Task 2.3: 주간 인기/지금 뜨는 중 점수 정책 작성**
- Files:
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/content/ranking/domain/AudioRankingScorePolicy.kt`
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/content/ranking/domain/AudioRankingScorePolicyTest.kt`
- RED: 유료/무료 주간 인기 원점수, 0~100 정규화, 지금 뜨는 중 증가율, 최소 반영 기준, 신규 콘텐츠 부스트를 검증하는 테스트를 작성한다.
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.content.ranking.domain.AudioRankingScorePolicyTest`
- GREEN: `calculateWeeklyPopularScore`, `normalizeScore`, `calculateRisingScore`, `applyMinimumThreshold`, `releaseBoost`를 구현한다.
- REFACTOR: 가중치와 최소 기준은 `companion object` 상수로 모은다.
- 기대 결과: PRD 산식과 “기준 미달 지표만 0점 처리” 정책이 순수 단위 테스트로 고정된다.
### Phase 3: 스냅샷 Entity/Port/DDL
- [x] **Task 3.1: 스냅샷 Entity와 port 작성**
- Files:
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/content/ranking/adapter/out/persistence/AudioRankingSnapshot.kt`
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/content/ranking/port/out/AudioRankingSnapshotPort.kt`
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/content/ranking/adapter/out/persistence/DefaultAudioRankingSnapshotPersistenceAdapterTest.kt`
- RED: `visibleFromAtUtc <= nowUtc`인 최신 스냅샷만 조회하고, 09:00 전에는 이전 visible 스냅샷을 반환하는 persistence adapter 테스트를 작성한다.
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.content.ranking.adapter.out.persistence.DefaultAudioRankingSnapshotPersistenceAdapterTest`
- GREEN: `AudioRankingSnapshot`, `AudioRankingSnapshotRepository`, `DefaultAudioRankingSnapshotPersistenceAdapter`를 구현한다.
- REFACTOR: `rankingType`, `aggregationStartAtUtc`, `aggregationEndAtUtc`, `visibleFromAtUtc` 필드명을 DDL과 맞춘다.
- 기대 결과: 공개 조회 기준이 `latest generated`가 아니라 `latest visible`로 고정된다.
- [x] **Task 3.2: 스냅샷 job Entity와 port 작성**
- Files:
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/content/ranking/adapter/out/persistence/AudioRankingSnapshotJob.kt`
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/content/ranking/port/out/AudioRankingSnapshotJobPort.kt`
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/content/ranking/application/AudioRankingSnapshotJobServiceTest.kt`
- RED: `SCHEDULED`, `MANUAL`, `FALLBACK` trigger와 `PENDING`, `PROCESSING`, `DONE`, `FAILED` 상태를 저장/변경할 수 있는 테스트를 작성한다.
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.content.ranking.application.AudioRankingSnapshotJobServiceTest`
- GREEN: job entity, repository, port adapter를 구현한다.
- REFACTOR: fallback 3회 제한 조회에 필요한 `rankingType + aggregation period + triggerType` 조건을 port에 둔다.
- 기대 결과: 스케줄 실행과 fallback 실행이 모두 job 이력으로 추적된다.
- [x] **Task 3.3: DDL 문서와 Entity 필드 정합성 확인**
- Files:
- Modify: `docs/20260623_메인_콘텐츠_랭킹_탭_API/create-content-ranking-tables.sql`
- Verify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/content/ranking/adapter/out/persistence/AudioRankingSnapshot.kt`
- Verify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/content/ranking/adapter/out/persistence/AudioRankingSnapshotJob.kt`
- TDD 예외 사유: DDL 문서와 JPA Entity 필드 정합성 확인 task이므로 신규 실패 테스트 작성 대상이 아니다.
- 대체 검증 방법:
- Run: `rg -n "visible_from_at|content_ranking_snapshot|content_ranking_snapshot_job" docs/20260623_메인_콘텐츠_랭킹_탭_API src/main/kotlin/kr/co/vividnext/sodalive/v2/content/ranking`
- Run: `./gradlew tasks --all`
- 기대 결과: 신규 Entity에 대응하는 운영 DB DDL이 같은 작업 디렉터리에 기록되어 있다.
### Phase 4: 랭킹 후보 집계와 스냅샷 후보 생성
- [x] **Task 4.1: 기존 4종 지표의 v2 전용 집계 작성**
- Files:
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/content/ranking/port/out/AudioRankingAggregationPort.kt`
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/content/ranking/adapter/out/persistence/DefaultAudioRankingAggregationRepository.kt`
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/content/ranking/adapter/out/persistence/DefaultAudioRankingAggregationRepositoryTest.kt`
- RED: `REVENUE`, `SALES_COUNT`, `COMMENT_COUNT`, `LIKE_COUNT`가 v2 집계 지표를 그대로 `finalScore`로 전달하는 테스트를 작성한다.
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.content.ranking.adapter.out.persistence.DefaultAudioRankingAggregationRepositoryTest`
- GREEN: legacy `RankingService` 호출 없이 v2 집계 repository에서 매출, 판매량, 댓글 수, 좋아요 후보를 만든다.
- REFACTOR: 기존 랭킹 조회 조건과 v2 스냅샷 공개/제외 조건이 섞이지 않도록 snapshot 생성 경로에서 legacy 의존성을 제거한다.
- 기대 결과: 6개 랭킹 타입 모두 v2 집계/스냅샷 경로로 생성된다.
- [x] **Task 4.2: 주간 인기/지금 뜨는 중 후보 집계 repository 작성**
- Files:
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/content/ranking/port/out/AudioRankingAggregationPort.kt`
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/content/ranking/adapter/out/persistence/DefaultAudioRankingAggregationRepository.kt`
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/content/ranking/adapter/out/persistence/DefaultAudioRankingAggregationRepositoryTest.kt`
- RED: 상세 조회수, 매출, 판매량, 좋아요, 댓글 수를 집계하고 비활성/공개 전/비활성 크리에이터 콘텐츠를 제외하는 repository 테스트를 작성한다.
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.content.ranking.adapter.out.persistence.DefaultAudioRankingAggregationRepositoryTest`
- GREEN: QueryDSL 또는 native SQL로 주간 인기와 지금 뜨는 중 후보 원천 지표를 조회한다.
- REFACTOR: 공개 오디오 조건, 성인 콘텐츠 조건, 차단 관계 조건을 private 조건 함수로 분리한다.
- 기대 결과: 신규 산식 2종의 원천 지표가 application service에 전달된다.
- [x] **Task 4.3: 동점 정렬과 Top 20 스냅샷 후보 생성**
- Files:
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/content/ranking/application/AudioRankingSnapshotRefreshService.kt`
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/content/ranking/application/AudioRankingSnapshotRefreshServiceTest.kt`
- RED: 최종 점수 동점이면 `releaseDate desc`, `contentId desc` 순으로 최대 20개를 저장하는 테스트를 작성한다.
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.content.ranking.application.AudioRankingSnapshotRefreshServiceTest`
- GREEN: 후보별 점수 계산, 정규화, 정렬, rank 부여, snapshot record 변환을 구현한다.
- REFACTOR: 점수 계산은 `AudioRankingScorePolicy`, 기간/노출 시각 계산은 policy에 위임한다.
- 기대 결과: 스냅샷 생성 결과가 조회 시 재정렬되지 않아도 안정적인 순위를 가진다.
### Phase 5: 스냅샷 생성 job과 분산 scheduler
- [x] **Task 5.1: 랭킹 타입별 refresh service 구현**
- Files:
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/content/ranking/application/AudioRankingSnapshotRefreshService.kt`
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/content/ranking/application/AudioRankingSnapshotRefreshServiceTest.kt`
- RED: 각 `AudioRankingType`에 대해 집계 기간, `visibleFromAt`, 후보 목록을 계산해 기존 스냅샷을 replace하는 테스트를 작성한다.
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.content.ranking.application.AudioRankingSnapshotRefreshServiceTest`
- GREEN: `refreshLastCompletedWeek(type, now)`를 구현하고 `AudioRankingSnapshotPort.replaceSnapshots(...)`를 호출한다.
- REFACTOR: 특정 타입 실패가 다른 타입 refresh를 막지 않도록 job service에서 타입 단위 실행을 분리한다.
- 기대 결과: 랭킹 타입별 독립 스냅샷 생성이 가능하다.
- [x] **Task 5.2: job service와 fallback 3회 제한 구현**
- Files:
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/content/ranking/application/AudioRankingSnapshotJobService.kt`
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/content/ranking/application/AudioRankingSnapshotJobServiceTest.kt`
- RED: scheduled job이 `PENDING -> PROCESSING -> DONE/FAILED`로 상태 변경되고, 같은 타입/기간 fallback이 3회 이상이면 refresh를 호출하지 않는 테스트를 작성한다.
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.content.ranking.application.AudioRankingSnapshotJobServiceTest`
- GREEN: job 생성, 상태 변경, fallback 제한, 기간 기반 Redisson lock 경계를 구현한다.
- REFACTOR: job 이력 저장은 `REQUIRES_NEW` 트랜잭션 패턴을 검토해 크리에이터 랭킹 job service와 맞춘다.
- 기대 결과: fallback과 scheduled refresh가 같은 job 이력 구조를 사용한다.
- [x] **Task 5.3: 01:00~07:30 분산 scheduler 구현**
- Files:
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/content/ranking/adapter/out/scheduler/AudioRankingSnapshotScheduler.kt`
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/content/ranking/adapter/out/scheduler/AudioRankingSnapshotSchedulerTest.kt`
- RED: 랭킹 타입별 scheduler method가 `Asia/Seoul` zone과 서로 다른 cron을 가지고, lock 획득 성공 시에만 job service를 호출하는 reflection/Mockito 테스트를 작성한다.
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.content.ranking.adapter.out.scheduler.AudioRankingSnapshotSchedulerTest`
- GREEN: 예시 배치로 `WEEKLY_POPULAR 02:00`, `RISING 03:00`, `REVENUE 04:00`, `SALES_COUNT 05:00`, `COMMENT_COUNT 06:00`, `LIKE_COUNT 07:00` KST scheduler를 구현한다.
- REFACTOR: lock key는 `lock:content-ranking-snapshot-refresh:{rankingType}` 형태로 목적과 타입이 드러나게 한다.
- 기대 결과: 콘텐츠 랭킹 스냅샷 생성이 01:00~07:30 범위 안에서 타입별로 분산된다.
### Phase 6: 조회 서비스와 순위 변화 계산
- [x] **Task 6.1: 최신/직전 visible 스냅샷 조회와 rankChange 계산**
- Files:
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/content/ranking/application/AudioRankingQueryService.kt`
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/content/ranking/application/AudioRankingQueryServiceTest.kt`
- RED: 직전 공개 스냅샷이 있으면 `rankChange = previousRank - currentRank`, 신규 진입은 `isNew=true`, 직전 스냅샷이 없으면 `showRankChange=false`가 되는 테스트를 작성한다.
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.content.ranking.application.AudioRankingQueryServiceTest`
- GREEN: `getRankings(type, member)`에서 최신 visible 스냅샷과 직전 visible 스냅샷을 조회해 `AudioRanking`을 조립한다.
- REFACTOR: 순위 변화 계산은 별도 private 함수로 분리한다.
- 기대 결과: 크리에이터 랭킹과 같은 의미의 `rank`, `rankChange`, `isNew`가 콘텐츠 랭킹에도 적용된다.
- [x] **Task 6.2: 차단/성인 콘텐츠 정책 반영**
- Files:
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/content/ranking/port/out/AudioRankingBlockPort.kt`
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/content/ranking/adapter/out/persistence/DefaultAudioRankingBlockRepository.kt`
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/content/ranking/application/AudioRankingQueryServiceTest.kt`
- RED: 비회원은 성인 콘텐츠를 제외하고, 회원 차단 관계가 있는 콘텐츠는 기존 콘텐츠 랭킹/추천 정책에 맞게 제외 또는 마스킹되는 테스트를 작성한다. 성인 콘텐츠 제외는 스냅샷의 `isAdult``global top 20 non-adult top 20` 후보 보존으로 보충 가능해야 한다.
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.content.ranking.application.AudioRankingQueryServiceTest`
- GREEN: 조회 조건 또는 응답 조립 단계에서 성인 콘텐츠/차단 관계 정책을 적용한다.
- REFACTOR: 기존 콘텐츠 추천 탭의 성인 콘텐츠 조회 가능 여부 계산 경로를 재사용한다.
- 기대 결과: 공개 조회 정책이 기존 v2 콘텐츠 추천/랭킹 정책과 어긋나지 않는다.
- [x] **Task 6.3: 스냅샷 없음 fallback 조회 보강**
- Files:
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/content/ranking/application/AudioRankingQueryService.kt`
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/content/ranking/application/AudioRankingQueryServiceTest.kt`
- RED: 요청 타입의 최신 visible 스냅샷이 없으면 fallback job을 최대 3회까지 실행하고, 생성 후에도 `visibleFromAt > now`이면 직전 공개 스냅샷 또는 빈 배열을 응답하는 테스트를 작성한다.
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.content.ranking.application.AudioRankingQueryServiceTest`
- GREEN: query service가 snapshot job service에 fallback을 위임하고 공개 응답 스키마를 유지한다.
- REFACTOR: fallback 실패는 구조화 로그/job 이력으로 추적하고 공개 응답에 fallback 여부를 추가하지 않는다.
- 기대 결과: 테스트 환경 초기 스냅샷 공백을 보강하되, 09:00 노출 정책은 깨지 않는다.
### Phase 7: 통합 검증과 문서 정리
- [x] **Task 7.1: controller/facade/query 통합 테스트**
- Files:
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/content/ranking/adapter/in/web/AudioRankingControllerTest.kt`
- RED: `GET /api/v2/audio/rankings?type=RISING``showRankChange`, `type`, `items[].contentId`, `rank`, `rankChange`, `isNew`를 반환하는 MockMvc 테스트를 작성한다.
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.content.ranking.adapter.in.web.AudioRankingControllerTest`
- GREEN: controller, facade, query service wiring을 완성한다.
- REFACTOR: 공개 response에 점수, 집계 기간, fallback 여부가 노출되지 않는지 확인한다.
- 기대 결과: 공개 API 계약이 end-to-end로 검증된다.
- [x] **Task 7.2: 문서와 DDL 최종 정합성 확인**
- Files:
- Modify: `docs/20260623_메인_콘텐츠_랭킹_탭_API/prd.md`
- Modify: `docs/20260623_메인_콘텐츠_랭킹_탭_API/plan-task.md`
- Modify: `docs/20260623_메인_콘텐츠_랭킹_탭_API/create-content-ranking-tables.sql`
- TDD 예외 사유: 구현 완료 후 문서/DDL 정합성 확인 task이므로 신규 실패 테스트 작성 대상이 아니다.
- 대체 검증 방법:
- Run: `rg -n "visibleFromAt|visible_from_at|09:00:00|01:00:00|07:30:00|AudioRankingType" docs/20260623_메인_콘텐츠_랭킹_탭_API src/main/kotlin src/test/kotlin`
- Run: `./gradlew tasks --all`
- 기대 결과: PRD, 계획 문서, DDL, 코드가 같은 정책을 설명한다.
- [x] **Task 7.3: 전체 회귀 검증**
- Files:
- Verify: `build.gradle.kts`
- Verify: `docs/20260623_메인_콘텐츠_랭킹_탭_API/plan-task.md`
- TDD 예외 사유: 전체 회귀 검증과 검증 기록 누적 task이므로 신규 실패 테스트 작성 대상이 아니다.
- 대체 검증 방법:
- Run: `./gradlew test --tests kr.co.vividnext.sodalive.v2.content.ranking.*`
- Run: `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.content.ranking.*`
- Run: `./gradlew ktlintCheck`
- 기대 결과: 콘텐츠 랭킹 신규 테스트와 ktlint가 통과하고, 검증 결과가 이 문서 하단에 누적된다.
### Phase 8: 다음 범위 크리에이터 랭킹 시간 정책 문서 시작점
- [x] **Task 8.1: 크리에이터 랭킹 시간 정책 변경을 별도 PRD 문서 수정으로 시작하도록 기록**
- Files:
- Verify: `docs/20260608_크리에이터_랭킹/prd.md`
- Verify: `docs/20260608_크리에이터_랭킹/plan-task.md`
- Verify: `docs/20260608_크리에이터_랭킹/create-ranking-tables.sql`
- Modify: `docs/20260623_메인_콘텐츠_랭킹_탭_API/plan-task.md`
- TDD 예외 사유: 이번 구현 범위 밖의 후속 작업 진입점을 문서화하는 task이므로 신규 실패 테스트 작성 대상이 아니다.
- 대체 검증 방법:
- Run: `rg -n "07:30|visibleFromAt|visible_from_at|ranking_type|크리에이터 랭킹" docs/20260608_크리에이터_랭킹 docs/20260623_메인_콘텐츠_랭킹_탭_API/plan-task.md`
- Run: `./gradlew tasks --all`
- 후속 작업 시작 지침:
- 다음 범위는 크리에이터 랭킹 PRD 문서 수정부터 시작한다.
- 현재 크리에이터 랭킹 스냅샷 생성 시간은 `@Scheduled(cron = "0 30 7 * * MON", zone = "Asia/Seoul")` 기준 매주 월요일 KST 07:30이다.
- 다음 범위에서는 크리에이터 랭킹도 집계 기준 시각 `월요일 00:00:00 KST`, 생성 시간 `월요일 01:00:00 KST` 후보, 노출 전환 시각 `월요일 09:00:00 KST`로 분리하는 정책을 PRD에 먼저 반영한다.
- 크리에이터 랭킹도 향후 다중 랭킹 타입 3개가 추가될 예정이므로 `creator_ranking_snapshot``creator_ranking_snapshot_job``ranking_type`, `visible_from_at` 추가가 필요한지 DDL 영향부터 검토한다.
- 크리에이터 랭킹 코드 변경은 별도 PRD와 별도 plan-task 문서가 준비된 뒤 진행한다.
- 기대 결과: 이번 콘텐츠 랭킹 구현 범위를 넘지 않으면서, 다음 범위의 첫 작업이 문서 수정부터 시작되도록 명확한 기록이 남는다.
---
## 검증 기록
- 작성 시점: PRD 기반 구현 계획 문서를 신규 생성했다. 아직 구현 전이므로 task별 검증 기록은 없다.
- 2026-06-24 Phase 1, 2 구현: `AudioRankingType`, 응답 DTO, facade, 비회원 허용 controller, KST 주간 기간 정책, 09:00 KST 노출 전환 정책, 주간 인기/지금 뜨는 중 점수 정책을 추가했다.
- 2026-06-24 RED/GREEN: 각 task는 대상 테스트를 먼저 추가한 뒤 미구현 참조 또는 컨트롤러 미존재 실패를 확인하고 최소 구현으로 GREEN 전환했다.
- 2026-06-24 검증: `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.content.ranking.dto.AudioRankingResponseTest --tests kr.co.vividnext.sodalive.v2.api.content.ranking.application.AudioRankingFacadeTest --tests kr.co.vividnext.sodalive.v2.api.content.ranking.adapter.in.web.AudioRankingControllerTest --tests kr.co.vividnext.sodalive.v2.content.ranking.domain.AudioRankingPeriodPolicyTest --tests kr.co.vividnext.sodalive.v2.content.ranking.domain.AudioRankingSchedulePolicyTest --tests kr.co.vividnext.sodalive.v2.content.ranking.domain.AudioRankingScorePolicyTest` 통과.
- 2026-06-24 검증: `./gradlew ktlintCheck` 통과.
- 2026-06-24 Phase 3, 4 구현: `content_ranking_snapshot`, `content_ranking_snapshot_job` Entity/Repository/Port/Adapter, 6개 타입 v2 집계 repository, 스냅샷 refresh service를 추가했다.
- 2026-06-24 RED/GREEN: `DefaultAudioRankingAggregationRepositoryTest`에서 H2 native query의 `release_date``Timestamp`로 반환되어 `LocalDateTime` cast 실패를 확인했고, `Timestamp.toLocalDateTime()` 변환을 추가해 GREEN 전환했다.
- 2026-06-24 검증: `./gradlew test --tests kr.co.vividnext.sodalive.v2.content.ranking.adapter.out.persistence.DefaultAudioRankingSnapshotPersistenceAdapterTest --tests kr.co.vividnext.sodalive.v2.content.ranking.adapter.out.persistence.DefaultAudioRankingSnapshotJobRepositoryTest` 통과.
- 2026-06-24 검증: `./gradlew test --tests kr.co.vividnext.sodalive.v2.content.ranking.adapter.out.persistence.DefaultAudioRankingAggregationRepositoryTest --tests kr.co.vividnext.sodalive.v2.content.ranking.application.AudioRankingSnapshotRefreshServiceTest` 통과.
- 2026-06-24 검증: `rg -n "visible_from_at|ranking_type|content_ranking_snapshot|content_ranking_snapshot_job" docs/20260623_메인_콘텐츠_랭킹_탭_API src/main/kotlin/kr/co/vividnext/sodalive/v2/content/ranking`으로 DDL/Entity 핵심 컬럼 정합성을 확인했다.
- 2026-06-24 최종 검증: `./gradlew test --tests 'kr.co.vividnext.sodalive.v2.content.ranking.*'`, `./gradlew ktlintCheck`, `./gradlew tasks --all` 통과.
- 2026-06-24 리뷰 반영: `RISING` 점수도 유료/무료 그룹별 0~100 정규화를 거치도록 보강했고, `REVENUE`, `SALES_COUNT`, `COMMENT_COUNT`, `LIKE_COUNT` 스냅샷 후보 산정은 기존 랭킹 조회 재사용이 아닌 v2 전용 집계 repository 책임으로 변경했다.
- 2026-06-24 리뷰 반영 검증: `./gradlew test --tests 'kr.co.vividnext.sodalive.v2.content.ranking.*'`, `./gradlew ktlintCheck` 통과.
- 2026-06-24 리뷰 재반영: `AudioRankingQueryService`가 최신/직전 visible snapshot을 조회해 `rankChange`, `isNew`, `showRankChange`를 계산하도록 구현했다.
- 2026-06-24 리뷰 재반영: 비회원/비성인 조회자를 위해 스냅샷에 `isAdult`를 저장하고, snapshot refresh는 전체 상위 20개와 비성인 상위 20개 후보의 합집합을 보존하도록 변경했다.
- 2026-06-24 Phase 7 구현: `AudioRankingControllerTest`를 Spring context 기반 MockMvc 통합 테스트로 전환해 `Controller -> Facade -> QueryService -> SnapshotRepository` 경로로 `GET /api/v2/audio/rankings?type=RISING` 응답의 `showRankChange`, `type`, `contentId`, `rank`, `rankChange`, `isNew`를 검증했다.
- 2026-06-24 Phase 7 검증: 공개 response에 `finalScore`, `aggregationStartAtUtc`, `aggregationEndAtUtc`, `visibleFromAtUtc`, `fallback`이 노출되지 않음을 통합 테스트로 확인했다.
- 2026-06-24 Phase 7 문서/DDL 정합성 검증: `rg -n "visibleFromAt|visible_from_at|09:00:00|01:00:00|07:30:00|AudioRankingType" docs/20260623_메인_콘텐츠_랭킹_탭_API src/main/kotlin src/test/kotlin`, `./gradlew tasks --all` 통과.
- 2026-06-24 Phase 7 회귀 검증: `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.content.ranking.adapter.in.web.AudioRankingControllerTest`, `./gradlew test --tests 'kr.co.vividnext.sodalive.v2.content.ranking.*'`, `./gradlew test --tests 'kr.co.vividnext.sodalive.v2.api.content.ranking.*'`, `./gradlew ktlintCheck` 통과.
- 2026-06-24 Phase 5 구현: `AudioRankingSnapshotJobService``AudioRankingSnapshotScheduler`를 추가해 타입별 scheduled/fallback job 이력, fallback 3회 제한, 타입/기간 기반 Redisson lock, 02:00~07:00 KST 분산 스케줄을 구현했다.
- 2026-06-24 Phase 6 구현: `AudioRankingBlockPort`, `DefaultAudioRankingBlockRepository`를 추가하고 `AudioRankingQueryService`가 회원 차단 관계 콘텐츠를 제외하며 최신 visible snapshot 공백 시 fallback job 실행 후 재조회하도록 보강했다.
- 2026-06-24 RED/GREEN: `AudioRankingSnapshotJobServiceTest`는 service 미존재 컴파일 실패를 확인한 뒤 GREEN 전환했고, `AudioRankingSnapshotSchedulerTest`는 scheduler 미존재 컴파일 실패를 확인한 뒤 GREEN 전환했다. `AudioRankingQueryServiceTest``AudioRankingBlockPort`와 query service 의존성 미구현 컴파일 실패를 확인한 뒤 차단/fallback 구현으로 GREEN 전환했다.
- 2026-06-24 Phase 5/6 검증: `./gradlew test --tests kr.co.vividnext.sodalive.v2.content.ranking.application.AudioRankingSnapshotRefreshServiceTest`, `./gradlew test --tests kr.co.vividnext.sodalive.v2.content.ranking.application.AudioRankingSnapshotJobServiceTest`, `./gradlew test --tests kr.co.vividnext.sodalive.v2.content.ranking.adapter.out.scheduler.AudioRankingSnapshotSchedulerTest`, `./gradlew test --tests kr.co.vividnext.sodalive.v2.content.ranking.application.AudioRankingQueryServiceTest` 통과.
- 2026-06-24 Phase 5/6 회귀 검증: `./gradlew test --tests 'kr.co.vividnext.sodalive.v2.content.ranking.*'`, `./gradlew test --tests 'kr.co.vividnext.sodalive.v2.api.content.ranking.*'`, `./gradlew ktlintCheck` 통과. 병렬 실행 중 `kaptTestKotlin`에서 `StreamCorruptedException: unexpected EOF in middle of data block`이 1회 발생했으나, 동일 content ranking 테스트 단독 재실행은 통과했다.
- 2026-06-24 Phase 5/6 리뷰 반영: snapshot refresh 실패 시 `FAILED` job 이력이 rollback되지 않도록 job 생성/상태 변경을 각각 `REQUIRES_NEW` 트랜잭션으로 분리했다. 공개 조회 fallback 실행 중 예외가 발생해도 응답 스키마를 유지하도록 보강했고, 차단 creator가 직전 스냅샷에만 있는 경우도 `rankChange` 계산에서 제외되도록 latest/previous creator 합집합 기준으로 차단 관계를 조회한다.
- 2026-06-24 Phase 5/6 리뷰 반영 검증: `./gradlew test --tests kr.co.vividnext.sodalive.v2.content.ranking.application.AudioRankingSnapshotJobServiceTest`, `./gradlew test --tests kr.co.vividnext.sodalive.v2.content.ranking.application.AudioRankingQueryServiceTest`, `./gradlew test --tests 'kr.co.vividnext.sodalive.v2.content.ranking.*'`, `./gradlew test --tests 'kr.co.vividnext.sodalive.v2.api.content.ranking.*'`, `./gradlew ktlintCheck` 통과.
- 2026-06-24 Phase 5/6 코드 리뷰 추가 반영: class-level `@Transactional(readOnly = true)` 경계에서 snapshot replace write가 실행되지 않도록 `refreshService.refreshLastCompletedWeek(...)` 호출 자체를 `REQUIRES_NEW` 트랜잭션으로 감쌌다. fallback job 생성 이전 또는 lock/transaction 단계 예외도 추적 가능하도록 `AudioRankingQueryService``event=audio_ranking_query_fallback_failure` warn 로그를 추가했다.
- 2026-06-24 Phase 5/6 코드 리뷰 추가 검증: `./gradlew test --tests kr.co.vividnext.sodalive.v2.content.ranking.application.AudioRankingSnapshotJobServiceTest`, `./gradlew test --tests kr.co.vividnext.sodalive.v2.content.ranking.application.AudioRankingQueryServiceTest`, `./gradlew test --tests 'kr.co.vividnext.sodalive.v2.content.ranking.*'`, `./gradlew test --tests 'kr.co.vividnext.sodalive.v2.api.content.ranking.*'`, `./gradlew ktlintCheck` 통과.
- 2026-06-24 Phase 6 잔여 리스크 반영: `DefaultAudioRankingBlockRepositoryTest` DB slice 테스트를 추가해 실제 QueryDSL 양방향 활성 차단 조회, 비활성 차단 제외, 입력 목록 외 차단 제외, 빈 입력 반환을 검증하도록 보강했다.
- 2026-06-24 Phase 6 잔여 리스크 검증: `./gradlew test --tests kr.co.vividnext.sodalive.v2.content.ranking.adapter.out.persistence.DefaultAudioRankingBlockRepositoryTest`, `./gradlew test --tests 'kr.co.vividnext.sodalive.v2.content.ranking.*'` 통과.
- 2026-06-24 Phase 6 트랜잭션 가시성 리뷰 반영: MySQL `REPEATABLE READ`에서 fallback `REQUIRES_NEW` 커밋 후 같은 read-only 트랜잭션 재조회가 새 스냅샷을 보지 못할 수 있어, `AudioRankingQueryService.getRankings()`의 외부 `@Transactional(readOnly = true)` 경계를 제거했다.
- 2026-06-24 Phase 6 트랜잭션 가시성 검증: `getRankings()``@Transactional`이 다시 붙지 않도록 회귀 테스트를 추가했다. RED 확인 후 수정했고, `./gradlew test --tests kr.co.vividnext.sodalive.v2.content.ranking.application.AudioRankingQueryServiceTest`, `./gradlew test --tests 'kr.co.vividnext.sodalive.v2.content.ranking.*'`, `./gradlew ktlintCheck` 통과.
- 2026-06-24 Phase 8 문서 확인: `rg -n "07:30|visibleFromAt|visible_from_at|ranking_type|크리에이터 랭킹" docs/20260608_크리에이터_랭킹 docs/20260623_메인_콘텐츠_랭킹_탭_API/plan-task.md`로 크리에이터 랭킹 현재 07:30 스케줄과 다음 범위의 `visible_from_at`, `ranking_type` DDL 검토 시작점을 확인했다.
- 2026-06-24 Phase 8 범위 확정: 크리에이터 랭킹 코드/DDL은 수정하지 않고, 다음 범위가 별도 PRD 문서 수정부터 시작되도록 Task 8.1 완료 상태와 검증 기록만 갱신했다.

View File

@@ -0,0 +1,356 @@
# PRD: 메인 콘텐츠 랭킹 탭 API
## 1. Overview
메인 콘텐츠 탭의 내부 랭킹 탭에서 사용할 콘텐츠 랭킹을 조회하는 v2 API를 제공한다.
랭킹 구분은 `주간 인기`, `지금 뜨는 중`, `매출`, `판매량`, `댓글 수`, `좋아요`이며, 각 랭킹은 최대 20위까지 표시한다.
---
## 2. Problem
- 기존 콘텐츠 랭킹 조회는 `RankingService.getContentRanking` 기반의 정렬 조회를 제공하지만, 신규 랭킹 탭은 v2 스냅샷 기준으로 `rank`, `rankChange`, `isNew`를 크리에이터 랭킹과 같은 의미로 내려줘야 한다.
- `주간 인기``지금 뜨는 중`은 신규 점수 산식, 유료/무료 콘텐츠별 정규화, 주간 스냅샷 갱신, fallback 실행 기록이 필요하다.
- `매출`, `판매량`, `댓글 수`, `좋아요`는 기존 랭킹과 동일한 원천 지표를 사용하되, 순위 변화와 신규 진입 여부를 안정적으로 계산하려면 v2 스냅샷 생성 시점에 완료 주차 기준으로 직접 집계해야 한다.
- 조회 시마다 모든 랭킹 타입의 원천 데이터를 집계하면 응답 지연과 계산 중복이 커지고, 운영 서버와 테스트 환경에서 같은 기준의 결과를 재현하기 어렵다.
- 기존 v2 패키지에 크리에이터 랭킹 스냅샷/작업 이력/fallback 패턴과 콘텐츠 추천 탭의 API 조립 계층/도메인 조회 계층 분리 패턴이 있으므로 이를 우선 재사용해야 한다.
---
## 3. Goals
- 메인 콘텐츠 랭킹 탭 조회 API를 `kr.co.vividnext.sodalive.v2` 하위 신규 코드로 제공한다.
- 기존 패턴과 동일하게 API 조립 계층과 도메인 조회 계층을 분리한다.
- 모든 랭킹 타입은 최대 20개 콘텐츠를 응답한다.
- 모든 랭킹 타입의 동점자는 `releaseDate desc`, `contentId desc` 순으로 2차, 3차 정렬한다.
- `rank`, `rankChange`, `isNew`의 의미는 크리에이터 랭킹과 동일하게 정의한다.
- `주간 인기``지금 뜨는 중`은 매주 월요일 00:00 KST 기준으로 지난 주 데이터를 계산한다.
- `매출`, `판매량`, `댓글 수`, `좋아요`도 완료된 지난 주 데이터를 기준으로 계산한다.
- 스냅샷 생성은 부하 분산을 위해 매주 월요일 01:00:00 KST부터 07:30:00 KST 사이에 랭킹 타입별로 분산 실행한다.
- 새로 생성된 스냅샷은 매주 월요일 09:00:00 KST부터 조회 API에 노출한다.
- 스냅샷이 없어 조회할 수 없는 경우 스케줄러로 예약된 랭킹 계산 로직을 fallback으로 직접 실행한다.
- fallback 실행은 스케줄 실행 기록처럼 저장하며, 동일 랭킹 타입과 동일 집계 기간 기준 최대 3회까지만 시도한다.
- PRD에 API endpoint와 Response data class 초안을 포함한다.
- 신규 Entity가 생성되는 경우 같은 작업 디렉터리에 대응 DB table 생성/수정 DDL을 기록한다.
---
## 4. Non-Goals
- 기존 공개 API 스키마를 임의 변경하지 않는다.
- 기존 공개 API의 `RankingService.getContentRanking` 동작과 정렬 산식은 이번 작업에서 변경하지 않는다.
- 관리자 화면, 수동 보정 기능, 랭킹 결과 고정/제외 기능은 포함하지 않는다.
- 개인화 랭킹, A/B 테스트, 머신러닝 기반 점수 산정은 포함하지 않는다.
- 20위 이후 전체보기/페이징 API는 이번 요구사항에 포함하지 않는다.
- 실시간 랭킹은 포함하지 않는다. 모든 랭킹 타입은 완료된 지난 주 데이터를 기준으로 한다.
- 기존 크리에이터 랭킹의 다중 랭킹 타입 전환, 스냅샷 테이블 구조 변경, 계산 스케줄 분산 처리는 이번 PRD에 포함하지 않고 별도 PRD에서 다룬다.
---
## 5. Target Users
- 회원: 콘텐츠 메인 탭에서 인기 콘텐츠와 상승 중인 콘텐츠를 탐색하는 사용자
- 비회원: 인증 없이 조회 가능한 랭킹 콘텐츠를 탐색하는 사용자
- 앱 클라이언트: 내부 랭킹 탭의 랭킹 타입별 목록과 순위 변화 UI를 구성하는 클라이언트
- 운영자: 주간 콘텐츠 랭킹 계산 결과와 fallback 실행 이력을 확인하는 내부 사용자
---
## 6. User Stories
- 사용자는 주간 인기 콘텐츠 상위 20개를 보고 싶다.
- 사용자는 지난 주 대비 지금 뜨는 중인 콘텐츠를 보고 싶다.
- 사용자는 매출, 판매량, 댓글 수, 좋아요 기준의 콘텐츠 랭킹을 보고 싶다.
- 사용자는 각 콘텐츠의 현재 순위, 순위 변화, 신규 진입 여부를 보고 싶다.
- 앱 클라이언트는 하나의 API endpoint에서 랭킹 타입만 바꿔 동일한 응답 구조로 화면을 구성하고 싶다.
- 테스트 환경에서는 스냅샷이 비어 있어도 조회 API 호출만으로 fallback 랭킹 계산이 실행되기를 원한다.
---
## 7. Core Features
### Feature A. 메인 콘텐츠 랭킹 탭 조회 API
#### Requirements
- 신규 API endpoint는 `GET /api/v2/audio/rankings`로 정의한다.
- 요청 query parameter는 `type`을 사용한다.
- `type` 값은 아래 enum으로 정의한다.
- `WEEKLY_POPULAR`: 주간 인기
- `RISING`: 지금 뜨는 중
- `REVENUE`: 매출
- `SALES_COUNT`: 판매량
- `COMMENT_COUNT`: 댓글 수
- `LIKE_COUNT`: 좋아요
- `type`이 없으면 `WEEKLY_POPULAR`를 기본값으로 사용한다.
- 응답 wrapper는 기존 패턴과 동일하게 `ApiResponse.ok(...)`를 사용한다.
- 조회 API는 `visibleFromAt <= now`이고 생성이 완료된 최신 스냅샷만 응답한다.
- 월요일 09:00:00 KST 전에는 새 주차 스냅샷이 이미 생성되어 있어도 직전 공개 스냅샷을 응답한다.
- 인증 회원이면 기존 콘텐츠 랭킹/추천 조회와 같은 방식으로 회원의 19금 노출 가능 여부와 차단 관계를 반영한다.
- 비회원이면 19금 콘텐츠를 노출하지 않는다.
- 비활성 콘텐츠, 공개 전 콘텐츠, 비활성 크리에이터의 콘텐츠는 노출하지 않는다.
- 각 랭킹 타입은 최대 20개를 응답한다.
- 정렬은 랭킹 점수 또는 정렬 지표 내림차순, `releaseDate desc`, `contentId desc` 순으로 적용한다.
#### Edge Cases
- 랭킹 결과가 없으면 빈 배열로 성공 응답한다.
- 후보가 20개 미만이면 가능한 개수만 내려준다.
- 특정 랭킹 타입의 새 스냅샷 생성이 실패하면 해당 타입은 직전 공개 스냅샷을 유지한다.
- 콘텐츠 제목, 크리에이터 닉네임, 커버 이미지가 기존 정책상 마스킹되어야 하는 경우 기존 콘텐츠 랭킹/추천 조회 정책을 따른다.
### Feature B. rank, rankChange, isNew 의미
#### Requirements
- `rank`는 최신 완료 주차 스냅샷에서 해당 랭킹 타입의 정렬 결과 순위다.
- `rank`는 1부터 시작한다.
- `rankChange``직전 완료 주차 rank - 최신 완료 주차 rank`로 계산한다.
- 순위가 올라갔으면 양수, 순위가 내려갔으면 음수, 동일하면 `0`을 내려준다.
- 예를 들어 직전 완료 주차 10위, 최신 완료 주차 5위이면 `rankChange``5`다.
- 예를 들어 직전 완료 주차 1위, 최신 완료 주차 10위이면 `rankChange``-9`다.
- 직전 완료 주차에는 없고 최신 완료 주차에 진입한 콘텐츠는 `isNew == true`로 내려준다.
- 신규 진입 콘텐츠의 `rankChange`는 비교 가능한 이전 순위가 없으므로 `null`로 내려준다.
- 직전 완료 주차 스냅샷이 없으면 `showRankChange == false`로 내려주고, 각 item의 `rankChange``null`, `isNew``false`로 내려준다.
- 직전 완료 주차 스냅샷이 있으면 `showRankChange == true`로 내려준다.
#### Edge Cases
- fallback으로 최신 주차 스냅샷을 생성했지만 직전 완료 주차 스냅샷이 없으면 `showRankChange == false`를 유지한다.
- 동점자는 `releaseDate desc`, `contentId desc`로 결정되므로 같은 스냅샷을 조회할 때 순위가 랜덤하게 바뀌지 않는다.
### Feature C. 주간 인기 랭킹
#### Requirements
- 갱신 기준은 매주 월요일 00:00 KST다.
- 집계 대상 기간은 지난 주 월요일 00:00:00 KST 이상, 이번 주 월요일 00:00:00 KST 미만이다.
- DB 조회 조건은 KST 집계 기간을 UTC로 변환해 사용한다.
- 유료 콘텐츠와 무료 콘텐츠는 서로 다른 원천 지표와 가중치로 1차 점수를 산출한다.
- 유료 콘텐츠 점수는 `매출 45% + 판매량 35% + 좋아요 수 10% + 댓글 수 10%`로 계산한다.
- 무료 콘텐츠 점수는 `조회수 50% + 좋아요 수 25% + 댓글 수 25%`로 계산한다.
- 조회수는 상세 페이지 조회 이력인 `creator_content_view_history` 기준으로 집계한다.
- 유료 콘텐츠와 무료 콘텐츠는 각 그룹의 최고 점수를 기준으로 0~100 정규화한 뒤 비교한다.
- 유료 정규화 점수는 `(현재 유료 콘텐츠 점수 / 유료 콘텐츠 최고 점수) * 100`으로 계산한다.
- 무료 정규화 점수는 `(현재 무료 콘텐츠 점수 / 무료 콘텐츠 최고 점수) * 100`으로 계산한다.
- 각 그룹의 최고 점수가 0 이하이면 해당 그룹의 정규화 점수는 0으로 처리한다.
- 최종 정렬은 정규화 점수 내림차순, `releaseDate desc`, `contentId desc` 순으로 적용한다.
#### 정규화 판단
- `(최고 점수 + 현재 콘텐츠 점수) * 100`은 최고점 대비 상대 위치를 0~100 범위로 맞추지 못하므로 정규화 산식으로 부적절하다.
- 유료/무료 콘텐츠를 별도 산식으로 계산한 뒤 한 목록에서 비교하려면 `(현재 점수 / 그룹 최고 점수) * 100` 방식이 더 적절하다.
- 이 방식은 각 그룹의 1위 콘텐츠를 100점으로 맞추고 나머지 콘텐츠를 상대 점수로 비교한다.
#### Edge Cases
- 유료 콘텐츠 후보가 없으면 유료 정규화는 수행하지 않고 무료 콘텐츠만 비교한다.
- 무료 콘텐츠 후보가 없으면 무료 정규화는 수행하지 않고 유료 콘텐츠만 비교한다.
- 원천 지표가 없으면 0으로 계산한다.
### Feature D. 지금 뜨는 중 랭킹
#### Requirements
- 갱신 기준은 매주 월요일 00:00 KST다.
- 집계 대상 기간은 최근 7일과 직전 7일을 비교한다.
- 기준 시점은 완료된 지난 주의 종료 시점으로 한다.
- 최근 7일: 지난 주 월요일 00:00:00 KST 이상, 이번 주 월요일 00:00:00 KST 미만
- 직전 7일: 2주 전 월요일 00:00:00 KST 이상, 지난 주 월요일 00:00:00 KST 미만
- 콘텐츠 지금 뜨는 중 점수는 `((0.5 * 콘텐츠 성장 점수) + (0.25 * 좋아요 증가율) + (0.25 * 댓글 증가율)) * 신규 콘텐츠 부스트`로 계산한다.
- 유료 콘텐츠 성장 점수는 `(0.6 * 판매 증가율) + (0.4 * 조회수 증가율)`로 계산한다.
- 무료 콘텐츠 성장 점수는 `(0.5 * 조회수 증가율) + (0.25 * 좋아요 증가율) + (0.25 * 댓글 증가율)`로 계산한다.
- 판매 증가율은 `(최근 7일 판매량 - 직전 7일 판매량) / max(직전 7일 판매량, 1)`로 계산한다.
- 조회수 증가율은 `(최근 7일 조회수 - 직전 7일 조회수) / max(직전 7일 조회수, 1)`로 계산한다.
- 좋아요 증가율은 `(최근 7일 좋아요 수 - 직전 7일 좋아요 수) / max(직전 7일 좋아요 수, 1)`로 계산한다.
- 댓글 증가율은 `(최근 7일 댓글 수 - 직전 7일 댓글 수) / max(직전 7일 댓글 수, 1)`로 계산한다.
- 최근 7일 조회수 10회 미만이면 조회수 증가율 반영값은 0으로 처리한다.
- 최근 7일 좋아요 수 3개 미만이면 좋아요 증가율 반영값은 0으로 처리한다.
- 최근 7일 댓글 수 3개 미만이면 댓글 증가율 반영값은 0으로 처리한다.
- 최근 7일 판매량 3건 미만이면 판매 증가율 반영값은 0으로 처리한다.
- 유료 콘텐츠와 무료 콘텐츠는 각 그룹의 최고 점수를 기준으로 0~100 정규화한 뒤 비교한다.
- 유료 정규화 점수는 `(현재 유료 콘텐츠 점수 / 유료 콘텐츠 최고 점수) * 100`으로 계산한다.
- 무료 정규화 점수는 `(현재 무료 콘텐츠 점수 / 무료 콘텐츠 최고 점수) * 100`으로 계산한다.
- 각 그룹의 최고 점수가 0 이하이면 해당 그룹의 정규화 점수는 0으로 처리한다.
- 신규 콘텐츠 부스트는 집계 종료일 기준 `releaseDate` 경과 일수로 적용한다.
- Release 3일 이내: `1.5`
- Release 7일 이내: `1.3`
- Release 14일 이내: `1.15`
- Release 14일 초과: `1.0`
- 최종 정렬은 정규화 점수 내림차순, `releaseDate desc`, `contentId desc` 순으로 적용한다.
#### Edge Cases
- 모든 증가율 반영값이 0이면 지금 뜨는 중 원점수는 0으로 계산한다.
- 증가율은 음수가 될 수 있으며, 음수 원점수는 정규화 전 후보 점수에 그대로 반영한다.
- 그룹 최고 점수가 0 이하인 경우 해당 그룹 콘텐츠의 정규화 점수는 0으로 처리해 음수 최고점으로 인한 역전 현상을 피한다.
### Feature E. 매출, 판매량, 댓글 수, 좋아요 랭킹
#### Requirements
- `REVENUE`, `SALES_COUNT`, `COMMENT_COUNT`, `LIKE_COUNT`는 v2 스냅샷 생성 계층에서 완료 주차 기준으로 직접 집계한다.
- 각 타입의 최종 점수는 원천 지표를 그대로 사용한다. `REVENUE`는 매출 can 합계, `SALES_COUNT`는 판매 건수, `COMMENT_COUNT`는 활성 댓글 수, `LIKE_COUNT`는 활성 좋아요 수다.
- 각 랭킹 타입도 스냅샷으로 저장한다.
- 스냅샷 생성 시 v2 집계 결과를 기반으로 전체 기준 상위 20개와 비성인 콘텐츠 기준 상위 20개의 합집합 후보를 저장한다.
- 공개 응답은 조회자의 성인 콘텐츠 열람 가능 여부를 적용한 뒤 최대 20개 콘텐츠를 반환한다.
- 동점자는 `releaseDate desc`, `contentId desc` 순으로 정렬한다.
- 최종 응답의 `rank`, `rankChange`, `isNew` 계산은 `주간 인기`, `지금 뜨는 중`과 동일한 공통 로직을 사용한다.
#### 스냅샷 저장 판단
- 이번 PRD의 기준안은 모든 랭킹 타입을 스냅샷으로 저장하는 것이다.
- 이유는 `rankChange``isNew`가 모든 응답 item에 필요하고, 이 값은 최신 완료 주차와 직전 완료 주차의 같은 랭킹 타입 결과를 비교해야 안정적으로 계산할 수 있기 때문이다.
- `주간 인기``지금 뜨는 중`만 스냅샷으로 저장하면 `매출`, `판매량`, `댓글 수`, `좋아요`는 조회 때마다 최신/직전 주차를 동적으로 재계산해야 하며, 계산 비용과 late update에 따른 순위 흔들림이 생긴다.
- 기존 랭킹과 같은 원천 지표를 사용하는 4개 랭킹도 v2 스냅샷 생성 시점에 직접 집계해, legacy 조회 조건과 v2 공개/제외 조건이 섞이지 않도록 한다.
#### Edge Cases
- v2 집계 결과 또는 조회자에게 노출 가능한 후보가 20개 미만이면 해당 개수만 저장/응답한다.
- legacy 랭킹 조회의 최소 개수 확보용 기간 확장 로직은 v2 스냅샷 생성에 적용하지 않는다.
### Feature F. 랭킹 스냅샷 및 작업 이력
#### Requirements
- 콘텐츠 랭킹 스냅샷은 랭킹 타입, 집계 시작/종료 시각, 콘텐츠 id, 순위, 점수 또는 정렬 지표, 표시용 콘텐츠 정보를 저장한다.
- 신규 스냅샷 Entity와 작업 이력 Entity의 DB table DDL은 `docs/20260623_메인_콘텐츠_랭킹_탭_API/create-content-ranking-tables.sql`에 기록한다.
- 스냅샷에는 `visibleFromAt`을 저장하며, 공개 조회는 이 시각이 지난 스냅샷만 대상으로 한다.
- 스냅샷은 최신 완료 주차와 직전 완료 주차를 조회할 수 있어야 한다.
- 같은 랭킹 타입과 같은 집계 기간의 스냅샷은 중복 저장하지 않는다.
- 스냅샷 생성은 기존 크리에이터 랭킹과 동일하게 job service, refresh service, scheduler 책임으로 분리한다.
- 집계 기준 시각은 매주 월요일 00:00:00 KST다.
- 스냅샷 생성은 원천 데이터 적재 지연과 운영 부하를 고려해 매주 월요일 01:00:00 KST부터 07:30:00 KST 사이에 랭킹 타입별로 분산 실행한다.
- 새 스냅샷의 기본 노출 전환 시각은 매주 월요일 09:00:00 KST다.
- 스케줄러는 `Asia/Seoul` zone을 명시한다.
- 다중 서버 인스턴스에서 같은 스케줄이 동시에 실행되더라도 Redisson lock으로 동일 기간/랭킹 타입은 한 번만 계산한다.
- 작업 이력에는 trigger, status, 집계 시작/종료 시각, 랭킹 타입, 오류 메시지, 처리 시작/종료 시각을 저장한다.
- trigger 값은 최소 `SCHEDULED`, `MANUAL`, `FALLBACK`을 지원한다.
#### Edge Cases
- 특정 랭킹 타입 스냅샷 생성이 실패해도 다른 랭킹 타입 생성이 가능한 구조로 분리한다.
- 일부 랭킹 타입만 스냅샷이 있으면 요청한 `type` 기준으로만 fallback 여부를 판단한다.
- 월요일 09:00:00 KST 전에 새 스냅샷이 일부만 생성되어도 공개 조회에는 반영하지 않는다.
- 월요일 09:00:00 KST 이후 특정 랭킹 타입의 새 스냅샷이 없거나 생성 실패 상태이면 해당 타입은 직전 공개 스냅샷을 응답한다.
### Feature G. 크리에이터 랭킹과의 범위 경계
#### Requirements
- 현재 크리에이터 랭킹 스냅샷 생성 스케줄은 매주 월요일 KST 07:30이다.
- 크리에이터 랭킹도 향후 다중 랭킹 타입이 추가될 예정이므로, 콘텐츠 랭킹의 스냅샷/작업 이력 구조는 향후 크리에이터 랭킹에도 같은 운영 모델을 적용할 수 있도록 `rankingType`, 집계 기간, `visibleFromAt`, job trigger/status 축을 기준으로 설계한다.
- 이번 PRD는 콘텐츠 랭킹 API와 콘텐츠 랭킹 스냅샷 생성만 구현한다.
- 기존 크리에이터 랭킹의 스냅샷 테이블에 `rankingType`을 추가하거나, 크리에이터 랭킹 계산 스케줄을 01:00~07:30 분산 방식으로 변경하는 작업은 이번 PRD에 포함하지 않는다.
- 크리에이터 랭킹 다중 타입 전환과 스케줄 분산 처리는 별도 PRD에서 기존 크리에이터 랭킹 PRD/DDL/구현 계획을 갱신해 다룬다.
### Feature H. fallback 랭킹 계산
#### Requirements
- 조회 시 요청한 랭킹 타입의 최신 완료 주차 스냅샷이 없으면 fallback 실행 가능 여부를 확인한다.
- fallback은 스케줄러가 호출하는 랭킹 계산 로직과 동일한 refresh service를 직접 실행한다.
- fallback 실행 전 `FALLBACK` trigger의 작업 이력을 `PENDING` 또는 `PROCESSING` 상태로 기록한다.
- fallback 성공 시 `DONE`, 실패 시 `FAILED`로 작업 이력을 기록한다.
- 동일 랭킹 타입과 동일 집계 기간의 fallback 실행 이력이 3회 이상이면 추가 fallback을 실행하지 않는다.
- fallback으로 스냅샷이 생성되면 해당 스냅샷을 다시 조회해 응답한다.
- fallback으로 생성된 스냅샷도 `visibleFromAt <= now` 조건을 만족해야 공개 조회에 노출한다.
- fallback 실행 후에도 스냅샷이 없으면 빈 배열로 성공 응답한다.
- fallback 여부는 공개 API response schema에 포함하지 않는다.
#### Edge Cases
- 다른 요청이 같은 랭킹 타입/기간 fallback을 처리 중이면 lock 획득 실패를 정상 skip으로 간주하고, 현재 요청은 재조회 후 없으면 빈 배열로 응답한다.
- fallback 계산 중 예외가 발생해도 공개 API는 내부 오류를 그대로 노출하지 않고 기존 예외/응답 정책을 따른다.
- fallback 작업 이력 저장 실패와 랭킹 계산 실패의 트랜잭션 경계는 구현 계획 단계에서 크리에이터 랭킹 작업 이력 패턴을 따른다.
### Feature I. v2 재사용 후보
#### Requirements
- API 조립 계층은 `v2/api/content/recommendation``AudioRecommendationController`, `AudioRecommendationFacade`, DTO 변환 패턴을 참고한다.
- 도메인 조회 계층은 `v2/content/recommendation/application/AudioRecommendationQueryService`처럼 응답 조립에 필요한 도메인 모델을 반환한다.
- `rankChange`, `isNew`, `showRankChange`, fallback 로그/작업 이력 패턴은 `v2/ranking/application/CreatorRankingQueryService``CreatorRankingSnapshotJobService`를 참고한다.
- 주간 기간 계산, UTC 변환, Redisson lock은 `v2/ranking/domain/CreatorRankingPeriodPolicy`와 크리에이터 랭킹 스냅샷 job 구조를 재사용하거나 콘텐츠 랭킹용으로 동일 패턴을 만든다.
- 상세 페이지 조회수는 `v2/recommendation/adapter/out/persistence/CreatorContentViewHistory`와 관련 port/repository를 재사용 후보로 검토한다.
- CDN URL 조립은 `v2/common/domain/CdnUrlExtensions.kt``toCdnUrl` 패턴을 우선 사용한다.
- `REVENUE`, `SALES_COUNT`, `COMMENT_COUNT`, `LIKE_COUNT`는 legacy adapter를 사용하지 않고 v2 집계 repository에서 직접 후보를 만든다.
---
## 8. API Endpoint
```http
GET /api/v2/audio/rankings?type=WEEKLY_POPULAR
Authorization: Bearer {accessToken} (optional)
```
- 비회원 조회를 허용한다.
- 회원 조회 시 기존 v2 controller 패턴과 동일하게 anonymous user를 `null` member로 처리한다.
- `type`이 없으면 `WEEKLY_POPULAR`를 기본값으로 사용한다.
- 잘못된 `type` 값에 대한 오류 응답은 기존 enum request parameter 오류 처리 정책을 따른다.
---
## 9. Response Data Class
```kotlin
data class AudioRankingResponse(
val showRankChange: Boolean,
val type: AudioRankingType,
val items: List<AudioRankingItemResponse>
)
enum class AudioRankingType {
WEEKLY_POPULAR,
RISING,
REVENUE,
SALES_COUNT,
COMMENT_COUNT,
LIKE_COUNT
}
data class AudioRankingItemResponse(
val contentId: Long,
val title: String,
val creatorNickname: String,
val rank: Int,
val rankChange: Int?,
@JsonProperty("isNew")
val isNew: Boolean,
val coverImageUrl: String?
)
```
응답 예시는 다음과 같다.
```json
{
"showRankChange": true,
"type": "WEEKLY_POPULAR",
"items": [
{
"contentId": 123,
"title": "Audio title",
"creatorNickname": "creator",
"rank": 1,
"rankChange": 5,
"isNew": false,
"coverImageUrl": "https://cdn.example.com/audio-cover.png"
},
{
"contentId": 456,
"title": "New audio",
"creatorNickname": "new creator",
"rank": 2,
"rankChange": null,
"isNew": true,
"coverImageUrl": "https://cdn.example.com/audio-cover-new.png"
}
]
}
```
---
## 10. Technical Constraints
- Kotlin + Spring Boot 2.7.14 기준으로 작성한다.
- Java 17 런타임을 기준으로 한다.
- 신규 코드는 `kr.co.vividnext.sodalive.v2` 하위에 배치한다.
- 공개 API 조립 계층과 도메인 조회 계층을 분리한다.
- 스냅샷과 작업 이력 저장은 MySQL 기준 DDL을 별도 문서 또는 구현 계획에서 작성한다.
- 이번 PRD에서 예상하는 신규 Entity는 `content_ranking_snapshot`, `content_ranking_snapshot_job` 테이블에 대응하며, 초안 DDL은 `docs/20260623_메인_콘텐츠_랭킹_탭_API/create-content-ranking-tables.sql`에 둔다.
- 시간 기준은 `Asia/Seoul`을 명시하고 DB 조회는 UTC 변환 범위를 사용한다.
- 테스트는 순위 변화 계산, 정규화 산식, 동점 정렬, fallback 최대 3회 제한, 작업 이력 기록을 포함해야 한다.
---
## 11. Metrics
- 랭킹 조회 API 응답 시간
- 랭킹 타입별 스냅샷 생성 성공/실패 횟수
- fallback 실행 횟수와 성공/실패 횟수
- fallback 최대 3회 초과로 빈 응답한 횟수
- 랭킹 타입별 응답 item 수
---
## 12. Open Questions
- fallback 최대 3회 제한은 동일 랭킹 타입과 동일 집계 기간 기준으로 가정한다.
- 지금 뜨는 중의 최소 반영 기준은 콘텐츠 전체 후보 제외가 아니라 지표별 점수 반영 제외로 해석한다. 예를 들어 최근 7일 조회수는 10회 미만이지만 좋아요 3개 이상, 댓글 3개 이상이면 조회수 증가율만 0으로 계산하고 좋아요/댓글 증가율은 점수에 반영한다. 콘텐츠 전체 후보 제외가 의도라면 구현 전에 수정해야 한다.

View File

@@ -103,6 +103,7 @@ class SecurityConfig(
.antMatchers(HttpMethod.POST, "/charge/payverse/webhook").permitAll() .antMatchers(HttpMethod.POST, "/charge/payverse/webhook").permitAll()
.antMatchers(HttpMethod.GET, "/api/v2/home/recommendations").permitAll() .antMatchers(HttpMethod.GET, "/api/v2/home/recommendations").permitAll()
.antMatchers(HttpMethod.GET, "/api/v2/audio/recommendations").permitAll() .antMatchers(HttpMethod.GET, "/api/v2/audio/recommendations").permitAll()
.antMatchers(HttpMethod.GET, "/api/v2/audio/rankings").permitAll()
.antMatchers(HttpMethod.GET, "/api/v2/home/rankings/creators").permitAll() .antMatchers(HttpMethod.GET, "/api/v2/home/rankings/creators").permitAll()
// 페이지네이션 하위 경로(/lives, /debut-creators 등)는 인증 필수 // 페이지네이션 하위 경로(/lives, /debut-creators 등)는 인증 필수
.antMatchers(HttpMethod.GET, "/api/v2/home/recommendations/**").authenticated() .antMatchers(HttpMethod.GET, "/api/v2/home/recommendations/**").authenticated()

View File

@@ -0,0 +1,25 @@
package kr.co.vividnext.sodalive.v2.api.content.ranking.adapter.`in`.web
import kr.co.vividnext.sodalive.common.ApiResponse
import kr.co.vividnext.sodalive.member.Member
import kr.co.vividnext.sodalive.v2.api.content.ranking.application.AudioRankingFacade
import kr.co.vividnext.sodalive.v2.content.ranking.domain.AudioRankingType
import org.springframework.security.core.annotation.AuthenticationPrincipal
import org.springframework.web.bind.annotation.GetMapping
import org.springframework.web.bind.annotation.RequestMapping
import org.springframework.web.bind.annotation.RequestParam
import org.springframework.web.bind.annotation.RestController
@RestController
@RequestMapping("/api/v2/audio/rankings")
class AudioRankingController(
private val facade: AudioRankingFacade
) {
@GetMapping
fun getRankings(
@RequestParam(defaultValue = "WEEKLY_POPULAR") type: AudioRankingType,
@AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?
) = run {
ApiResponse.ok(facade.getRankings(type, member))
}
}

View File

@@ -0,0 +1,16 @@
package kr.co.vividnext.sodalive.v2.api.content.ranking.application
import kr.co.vividnext.sodalive.member.Member
import kr.co.vividnext.sodalive.v2.api.content.ranking.dto.AudioRankingResponse
import kr.co.vividnext.sodalive.v2.content.ranking.application.AudioRankingQueryService
import kr.co.vividnext.sodalive.v2.content.ranking.domain.AudioRankingType
import org.springframework.stereotype.Component
@Component
class AudioRankingFacade(
private val queryService: AudioRankingQueryService
) {
fun getRankings(type: AudioRankingType, member: Member?): AudioRankingResponse {
return AudioRankingResponse.from(queryService.getRankings(type, member))
}
}

View File

@@ -0,0 +1,47 @@
package kr.co.vividnext.sodalive.v2.api.content.ranking.dto
import com.fasterxml.jackson.annotation.JsonProperty
import kr.co.vividnext.sodalive.v2.content.ranking.domain.AudioRanking
import kr.co.vividnext.sodalive.v2.content.ranking.domain.AudioRankingItem
import kr.co.vividnext.sodalive.v2.content.ranking.domain.AudioRankingType
data class AudioRankingResponse(
val showRankChange: Boolean,
val type: AudioRankingType,
val items: List<AudioRankingItemResponse>
) {
companion object {
fun from(ranking: AudioRanking): AudioRankingResponse {
return AudioRankingResponse(
showRankChange = ranking.showRankChange,
type = ranking.type,
items = ranking.items.map(AudioRankingItemResponse::from)
)
}
}
}
data class AudioRankingItemResponse(
val contentId: Long,
val title: String,
val creatorNickname: String,
val rank: Int,
val rankChange: Int?,
@JsonProperty("isNew")
val isNew: Boolean,
val coverImageUrl: String?
) {
companion object {
fun from(item: AudioRankingItem): AudioRankingItemResponse {
return AudioRankingItemResponse(
contentId = item.contentId,
title = item.title,
creatorNickname = item.creatorNickname,
rank = item.rank,
rankChange = item.rankChange,
isNew = item.isNew,
coverImageUrl = item.coverImageUrl
)
}
}
}

View File

@@ -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()

View File

@@ -0,0 +1,46 @@
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 kr.co.vividnext.sodalive.v2.content.ranking.port.out.AudioRankingSnapshotJobStatus
import kr.co.vividnext.sodalive.v2.content.ranking.port.out.AudioRankingSnapshotJobTrigger
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_job")
class AudioRankingSnapshotJob(
@Enumerated(EnumType.STRING)
@Column(name = "ranking_type", nullable = false, length = 30)
val rankingType: AudioRankingType,
@Column(name = "aggregation_start_at_utc", nullable = false)
val aggregationStartAtUtc: LocalDateTime,
@Column(name = "aggregation_end_at_utc", nullable = false)
val aggregationEndAtUtc: LocalDateTime,
@Column(name = "visible_from_at", nullable = false)
val visibleFromAtUtc: LocalDateTime,
@Enumerated(EnumType.STRING)
@Column(name = "trigger_type", nullable = false, length = 20)
val trigger: AudioRankingSnapshotJobTrigger,
@Enumerated(EnumType.STRING)
@Column(name = "status", nullable = false, length = 20)
var status: AudioRankingSnapshotJobStatus = AudioRankingSnapshotJobStatus.PENDING,
@Column(name = "last_error", columnDefinition = "text")
var lastError: String? = null,
@Column(name = "processing_started_at")
var processingStartedAt: LocalDateTime? = null,
@Column(name = "processed_at")
var processedAt: LocalDateTime? = null
) : BaseEntity()

View File

@@ -0,0 +1,31 @@
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.AudioRankingSnapshotJobStatus
import kr.co.vividnext.sodalive.v2.content.ranking.port.out.AudioRankingSnapshotJobTrigger
import org.springframework.data.jpa.repository.JpaRepository
import org.springframework.data.jpa.repository.Lock
import org.springframework.data.jpa.repository.Query
import org.springframework.data.repository.query.Param
import java.time.LocalDateTime
import javax.persistence.LockModeType
interface AudioRankingSnapshotJobRepository : JpaRepository<AudioRankingSnapshotJob, Long> {
@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("select j from AudioRankingSnapshotJob j where j.id = :jobId")
fun findByIdForUpdate(@Param("jobId") jobId: Long): AudioRankingSnapshotJob?
fun findAllByRankingTypeAndAggregationStartAtUtcAndAggregationEndAtUtcAndStatusInOrderByCreatedAtDesc(
rankingType: AudioRankingType,
aggregationStartAtUtc: LocalDateTime,
aggregationEndAtUtc: LocalDateTime,
statuses: List<AudioRankingSnapshotJobStatus>
): List<AudioRankingSnapshotJob>
fun countByRankingTypeAndAggregationStartAtUtcAndAggregationEndAtUtcAndTrigger(
rankingType: AudioRankingType,
aggregationStartAtUtc: LocalDateTime,
aggregationEndAtUtc: LocalDateTime,
trigger: AudioRankingSnapshotJobTrigger
): Long
}

View File

@@ -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<AudioRankingSnapshot, Long> {
@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<AudioRankingSnapshot>
@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<AudioRankingSnapshot>
fun deleteByRankingTypeAndAggregationStartAtUtcAndAggregationEndAtUtc(
rankingType: AudioRankingType,
aggregationStartAtUtc: LocalDateTime,
aggregationEndAtUtc: LocalDateTime
)
}

View File

@@ -0,0 +1,243 @@
package kr.co.vividnext.sodalive.v2.content.ranking.adapter.out.persistence
import kr.co.vividnext.sodalive.v2.content.ranking.domain.AudioRankingSnapshotCandidate
import kr.co.vividnext.sodalive.v2.content.ranking.port.out.AudioRankingAggregationPort
import org.springframework.stereotype.Repository
import java.sql.Timestamp
import java.time.LocalDateTime
import javax.persistence.EntityManager
@Repository
class DefaultAudioRankingAggregationRepository(
private val entityManager: EntityManager
) : AudioRankingAggregationPort {
override fun aggregateWeeklyPopularCandidates(
startInclusiveUtc: LocalDateTime,
endExclusiveUtc: LocalDateTime
): List<AudioRankingSnapshotCandidate> {
return aggregateCandidates(startInclusiveUtc, endExclusiveUtc, null, null)
}
override fun aggregateRisingCandidates(
startInclusiveUtc: LocalDateTime,
endExclusiveUtc: LocalDateTime
): List<AudioRankingSnapshotCandidate> {
val previousStartInclusiveUtc = startInclusiveUtc.minusWeeks(1)
val previousEndExclusiveUtc = startInclusiveUtc
return aggregateCandidates(startInclusiveUtc, endExclusiveUtc, previousStartInclusiveUtc, previousEndExclusiveUtc)
}
override fun aggregateRevenueCandidates(
startInclusiveUtc: LocalDateTime,
endExclusiveUtc: LocalDateTime
): List<AudioRankingSnapshotCandidate> {
return aggregateCandidates(startInclusiveUtc, endExclusiveUtc, null, null)
.filter { it.revenueCanAmount > 0 }
.map { it.copy(finalScore = it.revenueCanAmount.toDouble()) }
}
override fun aggregateSalesCountCandidates(
startInclusiveUtc: LocalDateTime,
endExclusiveUtc: LocalDateTime
): List<AudioRankingSnapshotCandidate> {
return aggregateCandidates(startInclusiveUtc, endExclusiveUtc, null, null)
.filter { it.salesCount > 0 }
.map { it.copy(finalScore = it.salesCount.toDouble()) }
}
override fun aggregateCommentCountCandidates(
startInclusiveUtc: LocalDateTime,
endExclusiveUtc: LocalDateTime
): List<AudioRankingSnapshotCandidate> {
return aggregateCandidates(startInclusiveUtc, endExclusiveUtc, null, null)
.filter { it.commentCount > 0 }
.map { it.copy(finalScore = it.commentCount.toDouble()) }
}
override fun aggregateLikeCountCandidates(
startInclusiveUtc: LocalDateTime,
endExclusiveUtc: LocalDateTime
): List<AudioRankingSnapshotCandidate> {
return aggregateCandidates(startInclusiveUtc, endExclusiveUtc, null, null)
.filter { it.likeCount > 0 }
.map { it.copy(finalScore = it.likeCount.toDouble()) }
}
private fun aggregateCandidates(
startInclusiveUtc: LocalDateTime,
endExclusiveUtc: LocalDateTime,
previousStartInclusiveUtc: LocalDateTime?,
previousEndExclusiveUtc: LocalDateTime?
): List<AudioRankingSnapshotCandidate> {
val rows = entityManager.createNativeQuery(AGGREGATION_SQL)
.setParameter("startInclusiveUtc", startInclusiveUtc)
.setParameter("endExclusiveUtc", endExclusiveUtc)
.setParameter("previousStartInclusiveUtc", previousStartInclusiveUtc ?: startInclusiveUtc)
.setParameter("previousEndExclusiveUtc", previousEndExclusiveUtc ?: startInclusiveUtc)
.resultList
return rows.map { row -> (row as Array<*>).toCandidate() }
}
private fun Array<*>.toCandidate(): AudioRankingSnapshotCandidate {
return AudioRankingSnapshotCandidate(
contentId = this[0].toLong(),
title = this[1] as String,
creatorMemberId = this[2].toLong(),
creatorNickname = this[3] as String,
coverImageUrl = this[4] as String?,
releaseDate = this[5].toLocalDateTime(),
isAdult = this[6].toBoolean(),
isPaid = this[7].toLong() > 0,
revenueCanAmount = this[8].toLong(),
salesCount = this[9].toLong(),
viewCount = this[10].toLong(),
likeCount = this[11].toLong(),
commentCount = this[12].toLong(),
previousSalesCount = this[13].toLong(),
previousViewCount = this[14].toLong(),
previousLikeCount = this[15].toLong(),
previousCommentCount = this[16].toLong()
)
}
private fun Any?.toLong(): Long {
return (this as Number?)?.toLong() ?: 0L
}
private fun Any?.toBoolean(): Boolean {
return when (this) {
is Boolean -> this
is Number -> toInt() != 0
else -> false
}
}
private fun Any?.toLocalDateTime(): LocalDateTime {
return when (this) {
is LocalDateTime -> this
is Timestamp -> toLocalDateTime()
else -> error("Unsupported datetime value: $this")
}
}
companion object {
private val AGGREGATION_SQL = """
with eligible_content as (
select c.id as content_id,
c.title as title,
c.member_id as creator_member_id,
m.nickname as creator_nickname,
c.cover_image as cover_image_url,
c.release_date as release_date,
c.is_adult as is_adult,
c.price as price
from content c
join member m on m.id = c.member_id
join content_theme ct on ct.id = c.theme_id
where c.is_active = true
and c.release_date is not null
and c.release_date < :endExclusiveUtc
and c.duration is not null
and c.limited is null
and ct.is_active = true
and m.role = 'CREATOR'
and m.is_active = true
), order_metrics as (
select o.content_id,
sum(o.can) as revenue_can_amount,
count(o.id) as sales_count
from orders o
where o.is_active = true
and o.created_at >= :startInclusiveUtc
and o.created_at < :endExclusiveUtc
group by o.content_id
), view_metrics as (
select ccvh.content_id,
count(ccvh.id) as view_count
from creator_content_view_history ccvh
where ccvh.viewed_at >= :startInclusiveUtc
and ccvh.viewed_at < :endExclusiveUtc
group by ccvh.content_id
), like_metrics as (
select cl.content_id,
count(cl.id) as like_count
from content_like cl
where cl.is_active = true
and cl.created_at >= :startInclusiveUtc
and cl.created_at < :endExclusiveUtc
group by cl.content_id
), comment_metrics as (
select cc.content_id,
count(cc.id) as comment_count
from content_comment cc
where cc.is_active = true
and cc.created_at >= :startInclusiveUtc
and cc.created_at < :endExclusiveUtc
group by cc.content_id
), previous_order_metrics as (
select o.content_id,
count(o.id) as previous_sales_count
from orders o
where o.is_active = true
and o.created_at >= :previousStartInclusiveUtc
and o.created_at < :previousEndExclusiveUtc
group by o.content_id
), previous_view_metrics as (
select ccvh.content_id,
count(ccvh.id) as previous_view_count
from creator_content_view_history ccvh
where ccvh.viewed_at >= :previousStartInclusiveUtc
and ccvh.viewed_at < :previousEndExclusiveUtc
group by ccvh.content_id
), previous_like_metrics as (
select cl.content_id,
count(cl.id) as previous_like_count
from content_like cl
where cl.is_active = true
and cl.created_at >= :previousStartInclusiveUtc
and cl.created_at < :previousEndExclusiveUtc
group by cl.content_id
), previous_comment_metrics as (
select cc.content_id,
count(cc.id) as previous_comment_count
from content_comment cc
where cc.is_active = true
and cc.created_at >= :previousStartInclusiveUtc
and cc.created_at < :previousEndExclusiveUtc
group by cc.content_id
)
select ec.content_id,
ec.title,
ec.creator_member_id,
ec.creator_nickname,
ec.cover_image_url,
ec.release_date,
ec.is_adult,
ec.price,
coalesce(om.revenue_can_amount, 0) as revenue_can_amount,
coalesce(om.sales_count, 0) as sales_count,
coalesce(vm.view_count, 0) as view_count,
coalesce(lm.like_count, 0) as like_count,
coalesce(cm.comment_count, 0) as comment_count,
coalesce(pom.previous_sales_count, 0) as previous_sales_count,
coalesce(pvm.previous_view_count, 0) as previous_view_count,
coalesce(plm.previous_like_count, 0) as previous_like_count,
coalesce(pcm.previous_comment_count, 0) as previous_comment_count
from eligible_content ec
left join order_metrics om on om.content_id = ec.content_id
left join view_metrics vm on vm.content_id = ec.content_id
left join like_metrics lm on lm.content_id = ec.content_id
left join comment_metrics cm on cm.content_id = ec.content_id
left join previous_order_metrics pom on pom.content_id = ec.content_id
left join previous_view_metrics pvm on pvm.content_id = ec.content_id
left join previous_like_metrics plm on plm.content_id = ec.content_id
left join previous_comment_metrics pcm on pcm.content_id = ec.content_id
where coalesce(om.revenue_can_amount, 0) <> 0
or coalesce(om.sales_count, 0) <> 0
or coalesce(vm.view_count, 0) <> 0
or coalesce(lm.like_count, 0) <> 0
or coalesce(cm.comment_count, 0) <> 0
""".trimIndent()
}
}

View File

@@ -0,0 +1,39 @@
package kr.co.vividnext.sodalive.v2.content.ranking.adapter.out.persistence
import com.querydsl.jpa.impl.JPAQueryFactory
import kr.co.vividnext.sodalive.member.block.QBlockMember
import kr.co.vividnext.sodalive.v2.content.ranking.port.out.AudioRankingBlockPort
import org.springframework.stereotype.Repository
@Repository
class DefaultAudioRankingBlockRepository(
private val queryFactory: JPAQueryFactory
) : AudioRankingBlockPort {
override fun findBlockedCreatorMemberIds(memberId: Long, creatorMemberIds: Set<Long>): Set<Long> {
if (creatorMemberIds.isEmpty()) return emptySet()
val viewerBlock = QBlockMember("audioRankingViewerBlock")
val creatorBlock = QBlockMember("audioRankingCreatorBlock")
val viewerBlockedIds = queryFactory
.select(viewerBlock.blockedMember.id)
.from(viewerBlock)
.where(
viewerBlock.isActive.isTrue,
viewerBlock.member.id.eq(memberId),
viewerBlock.blockedMember.id.`in`(creatorMemberIds)
)
.fetch()
val creatorBlockedIds = queryFactory
.select(creatorBlock.member.id)
.from(creatorBlock)
.where(
creatorBlock.isActive.isTrue,
creatorBlock.member.id.`in`(creatorMemberIds),
creatorBlock.blockedMember.id.eq(memberId)
)
.fetch()
return (viewerBlockedIds + creatorBlockedIds).toSet()
}
}

View File

@@ -0,0 +1,112 @@
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.AudioRankingSnapshotJobPort
import kr.co.vividnext.sodalive.v2.content.ranking.port.out.AudioRankingSnapshotJobRecord
import kr.co.vividnext.sodalive.v2.content.ranking.port.out.AudioRankingSnapshotJobStatus
import kr.co.vividnext.sodalive.v2.content.ranking.port.out.AudioRankingSnapshotJobTrigger
import org.springframework.stereotype.Repository
import org.springframework.transaction.annotation.Transactional
import java.time.LocalDateTime
@Repository
class DefaultAudioRankingSnapshotJobRepository(
private val repository: AudioRankingSnapshotJobRepository
) : AudioRankingSnapshotJobPort {
@Transactional
override fun save(job: AudioRankingSnapshotJobRecord): AudioRankingSnapshotJobRecord {
return repository.save(job.toEntity()).toRecord()
}
override fun findById(jobId: Long): AudioRankingSnapshotJobRecord? {
return repository.findById(jobId).orElse(null)?.toRecord()
}
override fun findByRankingTypeAndPeriodAndStatuses(
rankingType: AudioRankingType,
aggregationStartAtUtc: LocalDateTime,
aggregationEndAtUtc: LocalDateTime,
statuses: List<AudioRankingSnapshotJobStatus>
): List<AudioRankingSnapshotJobRecord> {
return repository.findAllByRankingTypeAndAggregationStartAtUtcAndAggregationEndAtUtcAndStatusInOrderByCreatedAtDesc(
rankingType = rankingType,
aggregationStartAtUtc = aggregationStartAtUtc,
aggregationEndAtUtc = aggregationEndAtUtc,
statuses = statuses
).map { it.toRecord() }
}
override fun countByRankingTypeAndPeriodAndTrigger(
rankingType: AudioRankingType,
aggregationStartAtUtc: LocalDateTime,
aggregationEndAtUtc: LocalDateTime,
trigger: AudioRankingSnapshotJobTrigger
): Long {
return repository.countByRankingTypeAndAggregationStartAtUtcAndAggregationEndAtUtcAndTrigger(
rankingType = rankingType,
aggregationStartAtUtc = aggregationStartAtUtc,
aggregationEndAtUtc = aggregationEndAtUtc,
trigger = trigger
)
}
@Transactional
override fun markProcessing(jobId: Long, processingStartedAt: LocalDateTime): AudioRankingSnapshotJobRecord? {
val job = repository.findByIdForUpdate(jobId) ?: return null
job.status = AudioRankingSnapshotJobStatus.PROCESSING
job.processingStartedAt = processingStartedAt
job.lastError = null
return job.toRecord()
}
@Transactional
override fun markDone(jobId: Long, processedAt: LocalDateTime): AudioRankingSnapshotJobRecord? {
val job = repository.findByIdForUpdate(jobId) ?: return null
job.status = AudioRankingSnapshotJobStatus.DONE
job.processedAt = processedAt
job.lastError = null
return job.toRecord()
}
@Transactional
override fun markFailed(jobId: Long, processedAt: LocalDateTime, lastError: String?): AudioRankingSnapshotJobRecord? {
val job = repository.findByIdForUpdate(jobId) ?: return null
job.status = AudioRankingSnapshotJobStatus.FAILED
job.processedAt = processedAt
job.lastError = lastError?.take(MAX_ERROR_LENGTH)
return job.toRecord()
}
private fun AudioRankingSnapshotJobRecord.toEntity(): AudioRankingSnapshotJob {
return AudioRankingSnapshotJob(
rankingType = rankingType,
aggregationStartAtUtc = aggregationStartAtUtc,
aggregationEndAtUtc = aggregationEndAtUtc,
visibleFromAtUtc = visibleFromAtUtc,
trigger = trigger,
status = status,
lastError = lastError,
processingStartedAt = processingStartedAt,
processedAt = processedAt
)
}
private fun AudioRankingSnapshotJob.toRecord(): AudioRankingSnapshotJobRecord {
return AudioRankingSnapshotJobRecord(
id = id,
rankingType = rankingType,
aggregationStartAtUtc = aggregationStartAtUtc,
aggregationEndAtUtc = aggregationEndAtUtc,
visibleFromAtUtc = visibleFromAtUtc,
trigger = trigger,
status = status,
lastError = lastError,
processingStartedAt = processingStartedAt,
processedAt = processedAt
)
}
companion object {
private const val MAX_ERROR_LENGTH = 1000
}
}

View File

@@ -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<AudioRankingSnapshotRecord> {
return repository.findLatestVisibleSnapshots(rankingType.name, nowUtc).map { it.toRecord() }
}
override fun findPreviousVisibleSnapshots(
rankingType: AudioRankingType,
currentAggregationStartAtUtc: LocalDateTime,
nowUtc: LocalDateTime
): List<AudioRankingSnapshotRecord> {
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<AudioRankingSnapshotRecord>
) {
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
)
}
}

View File

@@ -0,0 +1,59 @@
package kr.co.vividnext.sodalive.v2.content.ranking.adapter.out.scheduler
import kr.co.vividnext.sodalive.v2.content.ranking.application.AudioRankingSnapshotJobService
import kr.co.vividnext.sodalive.v2.content.ranking.domain.AudioRankingType
import org.redisson.api.RedissonClient
import org.springframework.scheduling.annotation.Scheduled
import org.springframework.stereotype.Component
import java.util.concurrent.TimeUnit
@Component
class AudioRankingSnapshotScheduler(
private val jobService: AudioRankingSnapshotJobService,
private val redissonClient: RedissonClient
) {
@Scheduled(cron = "0 0 2 * * MON", zone = "Asia/Seoul")
fun refreshWeeklyPopular() {
refresh(AudioRankingType.WEEKLY_POPULAR)
}
@Scheduled(cron = "0 0 3 * * MON", zone = "Asia/Seoul")
fun refreshRising() {
refresh(AudioRankingType.RISING)
}
@Scheduled(cron = "0 0 4 * * MON", zone = "Asia/Seoul")
fun refreshRevenue() {
refresh(AudioRankingType.REVENUE)
}
@Scheduled(cron = "0 0 5 * * MON", zone = "Asia/Seoul")
fun refreshSalesCount() {
refresh(AudioRankingType.SALES_COUNT)
}
@Scheduled(cron = "0 0 6 * * MON", zone = "Asia/Seoul")
fun refreshCommentCount() {
refresh(AudioRankingType.COMMENT_COUNT)
}
@Scheduled(cron = "0 0 7 * * MON", zone = "Asia/Seoul")
fun refreshLikeCount() {
refresh(AudioRankingType.LIKE_COUNT)
}
private fun refresh(type: AudioRankingType) {
val lockName = "lock:content-ranking-snapshot-refresh:$type"
val lock = redissonClient.getLock(lockName)
try {
if (lock.tryLock(0, -1, TimeUnit.SECONDS)) {
jobService.refreshLastCompletedWeekByScheduledJob(type)
}
} finally {
if (lock.isHeldByCurrentThread) {
lock.unlock()
}
}
}
}

View File

@@ -0,0 +1,115 @@
package kr.co.vividnext.sodalive.v2.content.ranking.application
import kr.co.vividnext.sodalive.member.Member
import kr.co.vividnext.sodalive.member.contentpreference.MemberContentPreferenceService
import kr.co.vividnext.sodalive.v2.content.ranking.domain.AudioRanking
import kr.co.vividnext.sodalive.v2.content.ranking.domain.AudioRankingItem
import kr.co.vividnext.sodalive.v2.content.ranking.domain.AudioRankingType
import kr.co.vividnext.sodalive.v2.content.ranking.port.out.AudioRankingBlockPort
import kr.co.vividnext.sodalive.v2.content.ranking.port.out.AudioRankingSnapshotPort
import kr.co.vividnext.sodalive.v2.content.ranking.port.out.AudioRankingSnapshotRecord
import org.slf4j.LoggerFactory
import org.springframework.stereotype.Service
import java.time.ZoneOffset
import java.time.ZonedDateTime
@Service
class AudioRankingQueryService(
private val snapshotPort: AudioRankingSnapshotPort,
private val memberContentPreferenceService: MemberContentPreferenceService,
private val blockPort: AudioRankingBlockPort,
private val jobService: AudioRankingSnapshotJobService,
private val nowProvider: () -> ZonedDateTime = { ZonedDateTime.now() }
) {
private val log = LoggerFactory.getLogger(javaClass)
fun getRankings(type: AudioRankingType, member: Member?): AudioRanking {
val nowUtc = nowProvider().withZoneSameInstant(ZoneOffset.UTC).toLocalDateTime()
val latestSnapshots = findLatestVisibleSnapshots(type, nowUtc)
if (latestSnapshots.isEmpty()) {
return AudioRanking(showRankChange = false, type = type, items = emptyList())
}
val canViewAdultContent = canViewAdultContent(member)
val previousSnapshots = snapshotPort.findPreviousVisibleSnapshots(
rankingType = type,
currentAggregationStartAtUtc = latestSnapshots.first().aggregationStartAtUtc,
nowUtc = nowUtc
)
val blockedCreatorMemberIds = blockedCreatorMemberIds(member, latestSnapshots + previousSnapshots)
val latestVisibleSnapshots = latestSnapshots.visibleTo(canViewAdultContent, blockedCreatorMemberIds).take(ITEM_LIMIT)
val previousRankByContentId = previousSnapshots.visibleTo(canViewAdultContent, blockedCreatorMemberIds)
.take(ITEM_LIMIT)
.mapIndexed { index, snapshot -> snapshot.contentId to index + 1 }
.toMap()
val showRankChange = previousRankByContentId.isNotEmpty()
return AudioRanking(
showRankChange = showRankChange,
type = type,
items = latestVisibleSnapshots.mapIndexed { index, snapshot ->
snapshot.toItem(index + 1, showRankChange, previousRankByContentId)
}
)
}
private fun findLatestVisibleSnapshots(
type: AudioRankingType,
nowUtc: java.time.LocalDateTime
): List<AudioRankingSnapshotRecord> {
val latestSnapshots = snapshotPort.findLatestVisibleSnapshots(type, nowUtc)
if (latestSnapshots.isNotEmpty()) return latestSnapshots
runCatching { jobService.refreshLastCompletedWeekByFallback(type) }
.onFailure { ex ->
log.warn(
"event=audio_ranking_query_fallback_failure rankingType={} error={}",
type,
ex.message,
ex
)
}
return snapshotPort.findLatestVisibleSnapshots(type, nowUtc)
}
private fun canViewAdultContent(member: Member?): Boolean {
if (member == null) return false
return memberContentPreferenceService.canViewAdultContent(member)
}
private fun blockedCreatorMemberIds(member: Member?, snapshots: List<AudioRankingSnapshotRecord>): Set<Long> {
val memberId = member?.id ?: return emptySet()
val creatorMemberIds = snapshots.map { it.creatorMemberId }.toSet()
if (creatorMemberIds.isEmpty()) return emptySet()
return blockPort.findBlockedCreatorMemberIds(memberId, creatorMemberIds)
}
private fun List<AudioRankingSnapshotRecord>.visibleTo(
canViewAdultContent: Boolean,
blockedCreatorMemberIds: Set<Long>
): List<AudioRankingSnapshotRecord> {
return filter { snapshot ->
(canViewAdultContent || !snapshot.isAdult) && snapshot.creatorMemberId !in blockedCreatorMemberIds
}
}
private fun AudioRankingSnapshotRecord.toItem(
rank: Int,
showRankChange: Boolean,
previousRankByContentId: Map<Long, Int>
): AudioRankingItem {
val previousRank = previousRankByContentId[contentId]
return AudioRankingItem(
contentId = contentId,
title = title,
creatorNickname = creatorNickname,
rank = rank,
rankChange = if (showRankChange && previousRank != null) previousRank - rank else null,
isNew = showRankChange && previousRank == null,
coverImageUrl = coverImageUrl
)
}
companion object {
private const val ITEM_LIMIT = 20
}
}

View File

@@ -0,0 +1,175 @@
package kr.co.vividnext.sodalive.v2.content.ranking.application
import kr.co.vividnext.sodalive.v2.content.ranking.domain.AudioRankingPeriodPolicy
import kr.co.vividnext.sodalive.v2.content.ranking.domain.AudioRankingSchedulePolicy
import kr.co.vividnext.sodalive.v2.content.ranking.domain.AudioRankingType
import kr.co.vividnext.sodalive.v2.content.ranking.domain.AudioRankingUtcRange
import kr.co.vividnext.sodalive.v2.content.ranking.port.out.AudioRankingSnapshotJobPort
import kr.co.vividnext.sodalive.v2.content.ranking.port.out.AudioRankingSnapshotJobRecord
import kr.co.vividnext.sodalive.v2.content.ranking.port.out.AudioRankingSnapshotJobStatus
import kr.co.vividnext.sodalive.v2.content.ranking.port.out.AudioRankingSnapshotJobTrigger
import org.redisson.api.RedissonClient
import org.slf4j.LoggerFactory
import org.springframework.stereotype.Service
import org.springframework.transaction.PlatformTransactionManager
import org.springframework.transaction.TransactionDefinition
import org.springframework.transaction.annotation.Transactional
import org.springframework.transaction.support.TransactionTemplate
import java.time.LocalDateTime
import java.time.ZonedDateTime
import java.util.concurrent.TimeUnit
@Service
@Transactional(readOnly = true)
class AudioRankingSnapshotJobService(
private val refreshService: AudioRankingSnapshotRefreshService,
private val jobPort: AudioRankingSnapshotJobPort,
private val redissonClient: RedissonClient,
transactionManager: PlatformTransactionManager,
private val nowProvider: () -> ZonedDateTime = { ZonedDateTime.now() }
) {
private val log = LoggerFactory.getLogger(javaClass)
private val periodPolicy = AudioRankingPeriodPolicy()
private val schedulePolicy = AudioRankingSchedulePolicy()
private val transactionTemplate = TransactionTemplate(transactionManager).also { template ->
template.propagationBehavior = TransactionDefinition.PROPAGATION_REQUIRES_NEW
}
fun refreshLastCompletedWeekByScheduledJob(type: AudioRankingType) {
withLastCompletedWeekPeriodLock(type) { now, utcRange, visibleFromAtUtc ->
refreshLastCompletedWeek(type, now, utcRange, visibleFromAtUtc, AudioRankingSnapshotJobTrigger.SCHEDULED)
}
}
fun refreshLastCompletedWeekByFallback(type: AudioRankingType): Boolean {
var refreshed = false
withLastCompletedWeekPeriodLock(type) { now, utcRange, visibleFromAtUtc ->
if (fallbackCountReachedLimit(type, utcRange)) return@withLastCompletedWeekPeriodLock
refreshLastCompletedWeek(type, now, utcRange, visibleFromAtUtc, AudioRankingSnapshotJobTrigger.FALLBACK)
refreshed = true
}
return refreshed
}
private fun refreshLastCompletedWeek(
type: AudioRankingType,
now: ZonedDateTime,
utcRange: AudioRankingUtcRange,
visibleFromAtUtc: LocalDateTime,
trigger: AudioRankingSnapshotJobTrigger
) {
val job = savePendingJob(type, utcRange, visibleFromAtUtc, trigger)
val jobId = job.id ?: return
markProcessing(jobId)
logJobStatusChanged(job, AudioRankingSnapshotJobStatus.PROCESSING)
try {
refresh(type, now)
markDone(jobId)
logJobStatusChanged(job, AudioRankingSnapshotJobStatus.DONE)
} catch (ex: Exception) {
markFailed(jobId, ex.message)
logJobStatusChanged(job, AudioRankingSnapshotJobStatus.FAILED, ex.message)
throw ex
}
}
private fun refresh(type: AudioRankingType, now: ZonedDateTime) {
transactionTemplate.executeWithoutResult {
refreshService.refreshLastCompletedWeek(type, now)
}
}
private fun savePendingJob(
type: AudioRankingType,
utcRange: AudioRankingUtcRange,
visibleFromAtUtc: LocalDateTime,
trigger: AudioRankingSnapshotJobTrigger
): AudioRankingSnapshotJobRecord {
return transactionTemplate.execute {
jobPort.save(
AudioRankingSnapshotJobRecord(
rankingType = type,
aggregationStartAtUtc = utcRange.startInclusiveUtc,
aggregationEndAtUtc = utcRange.endExclusiveUtc,
visibleFromAtUtc = visibleFromAtUtc,
trigger = trigger,
status = AudioRankingSnapshotJobStatus.PENDING,
lastError = null,
processingStartedAt = null,
processedAt = null
)
)
}!!
}
private fun markProcessing(jobId: Long) {
transactionTemplate.executeWithoutResult {
jobPort.markProcessing(jobId, LocalDateTime.now())
}
}
private fun markDone(jobId: Long) {
transactionTemplate.executeWithoutResult {
jobPort.markDone(jobId, LocalDateTime.now())
}
}
private fun markFailed(jobId: Long, message: String?) {
transactionTemplate.executeWithoutResult {
jobPort.markFailed(jobId, LocalDateTime.now(), message)
}
}
private fun fallbackCountReachedLimit(type: AudioRankingType, utcRange: AudioRankingUtcRange): Boolean {
return jobPort.countByRankingTypeAndPeriodAndTrigger(
rankingType = type,
aggregationStartAtUtc = utcRange.startInclusiveUtc,
aggregationEndAtUtc = utcRange.endExclusiveUtc,
trigger = AudioRankingSnapshotJobTrigger.FALLBACK
) >= FALLBACK_LIMIT
}
private fun withLastCompletedWeekPeriodLock(
type: AudioRankingType,
action: (ZonedDateTime, AudioRankingUtcRange, LocalDateTime) -> Unit
) {
val now = nowProvider()
val period = periodPolicy.resolveLastCompletedWeek(now)
val utcRange = periodPolicy.toUtcRange(period)
val visibleFromAtUtc = schedulePolicy.resolveVisibleFromAt(period.endExclusiveKst)
val lockName = "lock:content-ranking-snapshot-refresh:$type:${utcRange.startInclusiveUtc}:${utcRange.endExclusiveUtc}"
val lock = redissonClient.getLock(lockName)
try {
if (lock.tryLock(0, -1, TimeUnit.SECONDS)) {
action(now, utcRange, visibleFromAtUtc)
}
} finally {
if (lock.isHeldByCurrentThread) {
lock.unlock()
}
}
}
private fun logJobStatusChanged(
job: AudioRankingSnapshotJobRecord,
status: AudioRankingSnapshotJobStatus,
error: String? = null
) {
log.info(
"event=content_ranking_snapshot_job_status_changed " +
"jobId={} rankingType={} trigger={} status={} aggregationStartAtUtc={} aggregationEndAtUtc={} error={}",
job.id,
job.rankingType,
job.trigger,
status,
job.aggregationStartAtUtc,
job.aggregationEndAtUtc,
error
)
}
companion object {
private const val FALLBACK_LIMIT = 3L
}
}

View File

@@ -0,0 +1,201 @@
package kr.co.vividnext.sodalive.v2.content.ranking.application
import kr.co.vividnext.sodalive.v2.content.ranking.domain.AudioRankingPeriod
import kr.co.vividnext.sodalive.v2.content.ranking.domain.AudioRankingPeriodPolicy
import kr.co.vividnext.sodalive.v2.content.ranking.domain.AudioRankingSchedulePolicy
import kr.co.vividnext.sodalive.v2.content.ranking.domain.AudioRankingScorePolicy
import kr.co.vividnext.sodalive.v2.content.ranking.domain.AudioRankingSnapshotCandidate
import kr.co.vividnext.sodalive.v2.content.ranking.domain.AudioRankingType
import kr.co.vividnext.sodalive.v2.content.ranking.domain.AudioRankingUtcRange
import kr.co.vividnext.sodalive.v2.content.ranking.port.out.AudioRankingAggregationPort
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.Service
import org.springframework.transaction.annotation.Transactional
import java.time.ZonedDateTime
import kotlin.math.max
@Service
class AudioRankingSnapshotRefreshService(
private val aggregationPort: AudioRankingAggregationPort,
private val snapshotPort: AudioRankingSnapshotPort
) {
private val periodPolicy = AudioRankingPeriodPolicy()
private val schedulePolicy = AudioRankingSchedulePolicy()
private val scorePolicy = AudioRankingScorePolicy()
@Transactional
fun refreshLastCompletedWeek(type: AudioRankingType, now: ZonedDateTime) {
val period = periodPolicy.resolveLastCompletedWeek(now)
val utcRange = periodPolicy.toUtcRange(period)
val visibleFromAtUtc = schedulePolicy.resolveVisibleFromAt(period.endExclusiveKst)
val candidates = resolveCandidates(type, utcRange)
val snapshots = candidates.toSnapshotRecords(type, period, utcRange, visibleFromAtUtc)
snapshotPort.replaceSnapshots(
rankingType = type,
aggregationStartAtUtc = utcRange.startInclusiveUtc,
aggregationEndAtUtc = utcRange.endExclusiveUtc,
visibleFromAtUtc = visibleFromAtUtc,
newSnapshots = snapshots
)
}
private fun resolveCandidates(
type: AudioRankingType,
utcRange: AudioRankingUtcRange
): List<AudioRankingSnapshotCandidate> {
return when (type) {
AudioRankingType.WEEKLY_POPULAR -> aggregationPort.aggregateWeeklyPopularCandidates(
utcRange.startInclusiveUtc,
utcRange.endExclusiveUtc
)
AudioRankingType.RISING -> aggregationPort.aggregateRisingCandidates(
utcRange.startInclusiveUtc,
utcRange.endExclusiveUtc
)
AudioRankingType.REVENUE -> aggregationPort.aggregateRevenueCandidates(
utcRange.startInclusiveUtc,
utcRange.endExclusiveUtc
)
AudioRankingType.SALES_COUNT -> aggregationPort.aggregateSalesCountCandidates(
utcRange.startInclusiveUtc,
utcRange.endExclusiveUtc
)
AudioRankingType.COMMENT_COUNT -> aggregationPort.aggregateCommentCountCandidates(
utcRange.startInclusiveUtc,
utcRange.endExclusiveUtc
)
AudioRankingType.LIKE_COUNT -> aggregationPort.aggregateLikeCountCandidates(
utcRange.startInclusiveUtc,
utcRange.endExclusiveUtc
)
}
}
private fun List<AudioRankingSnapshotCandidate>.toSnapshotRecords(
type: AudioRankingType,
period: AudioRankingPeriod,
utcRange: AudioRankingUtcRange,
visibleFromAtUtc: java.time.LocalDateTime
): List<AudioRankingSnapshotRecord> {
val scoredCandidates = when (type) {
AudioRankingType.WEEKLY_POPULAR -> withWeeklyPopularScores()
AudioRankingType.RISING -> withRisingScores(period)
AudioRankingType.REVENUE,
AudioRankingType.SALES_COUNT,
AudioRankingType.COMMENT_COUNT,
AudioRankingType.LIKE_COUNT -> this
}
val rankedRecords = scoredCandidates
.sortedWith(
compareByDescending<AudioRankingSnapshotCandidate> { it.finalScore }
.thenByDescending { it.releaseDate }
.thenByDescending { it.contentId }
)
.mapIndexed { index, candidate -> candidate.toSnapshotRecord(type, utcRange, visibleFromAtUtc, index + 1) }
val globalContentIds = rankedRecords.take(SNAPSHOT_LIMIT).map { it.contentId }.toSet()
val safeContentIds = rankedRecords.filter { !it.isAdult }.take(SNAPSHOT_LIMIT).map { it.contentId }.toSet()
val selectedContentIds = globalContentIds + safeContentIds
return rankedRecords.filter { it.contentId in selectedContentIds }
}
private fun List<AudioRankingSnapshotCandidate>.withWeeklyPopularScores(): List<AudioRankingSnapshotCandidate> {
val rawScores = associateWith { candidate ->
scorePolicy.calculateWeeklyPopularScore(
revenue = candidate.revenueCanAmount,
salesCount = candidate.salesCount,
viewCount = candidate.viewCount,
likeCount = candidate.likeCount,
commentCount = candidate.commentCount,
isPaid = candidate.isPaid
)
}
val paidMaxScore = rawScores.filterKeys { it.isPaid }.values.maxOrNull() ?: 0.0
val freeMaxScore = rawScores.filterKeys { !it.isPaid }.values.maxOrNull() ?: 0.0
return map { candidate ->
val rawScore = rawScores.getValue(candidate)
candidate.copy(
finalScore = scorePolicy.normalizeScore(
rawScore,
if (candidate.isPaid) paidMaxScore else freeMaxScore
)
)
}
}
private fun AudioRankingSnapshotCandidate.withRisingScore(period: AudioRankingPeriod): AudioRankingSnapshotCandidate {
return copy(
finalScore = scorePolicy.calculateRisingScore(
recentSalesCount = salesCount,
previousSalesCount = previousSalesCount,
recentViewCount = viewCount,
previousViewCount = previousViewCount,
recentLikeCount = likeCount,
previousLikeCount = previousLikeCount,
recentCommentCount = commentCount,
previousCommentCount = previousCommentCount,
releaseDate = releaseDate,
aggregationEndAt = period.endExclusiveKst,
isPaid = isPaid
)
)
}
private fun List<AudioRankingSnapshotCandidate>.withRisingScores(
period: AudioRankingPeriod
): List<AudioRankingSnapshotCandidate> {
val scoredCandidates = map { it.withRisingScore(period) }
val paidMaxScore = scoredCandidates.filter { it.isPaid }.maxOfOrNull { it.finalScore } ?: 0.0
val freeMaxScore = scoredCandidates.filter { !it.isPaid }.maxOfOrNull { it.finalScore } ?: 0.0
return scoredCandidates.map { candidate ->
candidate.copy(
finalScore = scorePolicy.normalizeScore(
candidate.finalScore,
if (candidate.isPaid) paidMaxScore else freeMaxScore
)
)
}
}
private fun AudioRankingSnapshotCandidate.toSnapshotRecord(
type: AudioRankingType,
utcRange: AudioRankingUtcRange,
visibleFromAtUtc: java.time.LocalDateTime,
rank: Int
): AudioRankingSnapshotRecord {
return AudioRankingSnapshotRecord(
rankingType = type,
aggregationStartAtUtc = utcRange.startInclusiveUtc,
aggregationEndAtUtc = utcRange.endExclusiveUtc,
visibleFromAtUtc = visibleFromAtUtc,
contentId = contentId,
title = title,
creatorMemberId = creatorMemberId,
creatorNickname = creatorNickname,
coverImageUrl = coverImageUrl,
releaseDate = releaseDate,
isAdult = isAdult,
rank = rank,
finalScore = max(finalScore, 0.0),
revenueCanAmount = revenueCanAmount,
salesCount = salesCount,
viewCount = viewCount,
likeCount = likeCount,
commentCount = commentCount,
previousSalesCount = previousSalesCount,
previousViewCount = previousViewCount,
previousLikeCount = previousLikeCount,
previousCommentCount = previousCommentCount
)
}
companion object {
private const val SNAPSHOT_LIMIT = 20
}
}

View File

@@ -0,0 +1,17 @@
package kr.co.vividnext.sodalive.v2.content.ranking.domain
data class AudioRanking(
val showRankChange: Boolean,
val type: AudioRankingType,
val items: List<AudioRankingItem>
)
data class AudioRankingItem(
val contentId: Long,
val title: String,
val creatorNickname: String,
val rank: Int,
val rankChange: Int?,
val isNew: Boolean,
val coverImageUrl: String?
)

View File

@@ -0,0 +1,42 @@
package kr.co.vividnext.sodalive.v2.content.ranking.domain
import java.time.DayOfWeek
import java.time.LocalDateTime
import java.time.ZoneId
import java.time.ZonedDateTime
import java.time.temporal.TemporalAdjusters
class AudioRankingPeriodPolicy {
fun resolveLastCompletedWeek(now: ZonedDateTime): AudioRankingPeriod {
val nowKst = now.withZoneSameInstant(KST_ZONE)
val thisWeekMonday = nowKst.toLocalDate()
.with(TemporalAdjusters.previousOrSame(DayOfWeek.MONDAY))
.atStartOfDay()
return AudioRankingPeriod(
startInclusiveKst = thisWeekMonday.minusWeeks(1),
endExclusiveKst = thisWeekMonday
)
}
fun toUtcRange(period: AudioRankingPeriod): AudioRankingUtcRange {
return AudioRankingUtcRange(
startInclusiveUtc = period.startInclusiveKst.atZone(KST_ZONE).withZoneSameInstant(UTC_ZONE).toLocalDateTime(),
endExclusiveUtc = period.endExclusiveKst.atZone(KST_ZONE).withZoneSameInstant(UTC_ZONE).toLocalDateTime()
)
}
companion object {
private val KST_ZONE: ZoneId = ZoneId.of("Asia/Seoul")
private val UTC_ZONE: ZoneId = ZoneId.of("UTC")
}
}
data class AudioRankingPeriod(
val startInclusiveKst: LocalDateTime,
val endExclusiveKst: LocalDateTime
)
data class AudioRankingUtcRange(
val startInclusiveUtc: LocalDateTime,
val endExclusiveUtc: LocalDateTime
)

View File

@@ -0,0 +1,25 @@
package kr.co.vividnext.sodalive.v2.content.ranking.domain
import java.time.LocalDateTime
import java.time.LocalTime
import java.time.ZoneId
class AudioRankingSchedulePolicy {
fun resolveVisibleFromAt(aggregationEndAtKst: LocalDateTime): LocalDateTime {
return aggregationEndAtKst.toLocalDate()
.atTime(VISIBLE_FROM_TIME)
.atZone(KST_ZONE)
.withZoneSameInstant(UTC_ZONE)
.toLocalDateTime()
}
fun isVisible(visibleFromAtUtc: LocalDateTime, nowUtc: LocalDateTime): Boolean {
return !nowUtc.isBefore(visibleFromAtUtc)
}
companion object {
private val KST_ZONE: ZoneId = ZoneId.of("Asia/Seoul")
private val UTC_ZONE: ZoneId = ZoneId.of("UTC")
private val VISIBLE_FROM_TIME: LocalTime = LocalTime.of(9, 0)
}
}

View File

@@ -0,0 +1,131 @@
package kr.co.vividnext.sodalive.v2.content.ranking.domain
import java.time.LocalDateTime
import java.time.temporal.ChronoUnit
import kotlin.math.max
class AudioRankingScorePolicy {
fun calculateWeeklyPopularScore(
revenue: Long,
salesCount: Long,
viewCount: Long,
likeCount: Long,
commentCount: Long,
isPaid: Boolean
): Double {
return if (isPaid) {
revenue * WEEKLY_PAID_REVENUE_WEIGHT +
salesCount * WEEKLY_PAID_SALES_COUNT_WEIGHT +
likeCount * WEEKLY_PAID_LIKE_COUNT_WEIGHT +
commentCount * WEEKLY_PAID_COMMENT_COUNT_WEIGHT
} else {
viewCount * WEEKLY_FREE_VIEW_COUNT_WEIGHT +
likeCount * WEEKLY_FREE_LIKE_COUNT_WEIGHT +
commentCount * WEEKLY_FREE_COMMENT_COUNT_WEIGHT
}
}
fun normalizeScore(currentScore: Double, maxScore: Double): Double {
if (maxScore <= 0.0) {
return 0.0
}
return currentScore / maxScore * 100.0
}
fun calculateRisingScore(
recentSalesCount: Long,
previousSalesCount: Long,
recentViewCount: Long,
previousViewCount: Long,
recentLikeCount: Long,
previousLikeCount: Long,
recentCommentCount: Long,
previousCommentCount: Long,
releaseDate: LocalDateTime,
aggregationEndAt: LocalDateTime,
isPaid: Boolean
): Double {
val salesGrowth = applyMinimumThreshold(
growthRate(recentSalesCount, previousSalesCount),
recentSalesCount,
RISING_SALES_COUNT_THRESHOLD
)
val viewGrowth = applyMinimumThreshold(
growthRate(recentViewCount, previousViewCount),
recentViewCount,
RISING_VIEW_COUNT_THRESHOLD
)
val likeGrowth = applyMinimumThreshold(
growthRate(recentLikeCount, previousLikeCount),
recentLikeCount,
RISING_LIKE_COUNT_THRESHOLD
)
val commentGrowth = applyMinimumThreshold(
growthRate(recentCommentCount, previousCommentCount),
recentCommentCount,
RISING_COMMENT_COUNT_THRESHOLD
)
val contentGrowthScore = if (isPaid) {
salesGrowth * RISING_PAID_SALES_GROWTH_WEIGHT +
viewGrowth * RISING_PAID_VIEW_GROWTH_WEIGHT
} else {
viewGrowth * RISING_FREE_VIEW_GROWTH_WEIGHT +
likeGrowth * RISING_FREE_LIKE_GROWTH_WEIGHT +
commentGrowth * RISING_FREE_COMMENT_GROWTH_WEIGHT
}
return (
contentGrowthScore * RISING_CONTENT_GROWTH_SCORE_WEIGHT +
likeGrowth * RISING_LIKE_GROWTH_WEIGHT +
commentGrowth * RISING_COMMENT_GROWTH_WEIGHT
) * releaseBoost(releaseDate, aggregationEndAt)
}
fun applyMinimumThreshold(growthRate: Double, recentCount: Long, minimumThreshold: Long): Double {
return if (recentCount < minimumThreshold) 0.0 else growthRate
}
fun releaseBoost(releaseDate: LocalDateTime, aggregationEndAt: LocalDateTime): Double {
val days = ChronoUnit.DAYS.between(releaseDate, aggregationEndAt).coerceAtLeast(0)
return when {
days <= 3 -> RELEASE_BOOST_WITHIN_THREE_DAYS
days <= 7 -> RELEASE_BOOST_WITHIN_SEVEN_DAYS
days <= 14 -> RELEASE_BOOST_WITHIN_FOURTEEN_DAYS
else -> RELEASE_BOOST_DEFAULT
}
}
private fun growthRate(recentCount: Long, previousCount: Long): Double {
return (recentCount - previousCount).toDouble() / max(previousCount, 1).toDouble()
}
companion object {
const val WEEKLY_PAID_REVENUE_WEIGHT = 0.45
const val WEEKLY_PAID_SALES_COUNT_WEIGHT = 0.35
const val WEEKLY_PAID_LIKE_COUNT_WEIGHT = 0.1
const val WEEKLY_PAID_COMMENT_COUNT_WEIGHT = 0.1
const val WEEKLY_FREE_VIEW_COUNT_WEIGHT = 0.5
const val WEEKLY_FREE_LIKE_COUNT_WEIGHT = 0.25
const val WEEKLY_FREE_COMMENT_COUNT_WEIGHT = 0.25
const val RISING_CONTENT_GROWTH_SCORE_WEIGHT = 0.5
const val RISING_LIKE_GROWTH_WEIGHT = 0.25
const val RISING_COMMENT_GROWTH_WEIGHT = 0.25
const val RISING_PAID_SALES_GROWTH_WEIGHT = 0.6
const val RISING_PAID_VIEW_GROWTH_WEIGHT = 0.4
const val RISING_FREE_VIEW_GROWTH_WEIGHT = 0.5
const val RISING_FREE_LIKE_GROWTH_WEIGHT = 0.25
const val RISING_FREE_COMMENT_GROWTH_WEIGHT = 0.25
const val RISING_VIEW_COUNT_THRESHOLD = 10L
const val RISING_LIKE_COUNT_THRESHOLD = 3L
const val RISING_COMMENT_COUNT_THRESHOLD = 3L
const val RISING_SALES_COUNT_THRESHOLD = 3L
const val RELEASE_BOOST_WITHIN_THREE_DAYS = 1.5
const val RELEASE_BOOST_WITHIN_SEVEN_DAYS = 1.3
const val RELEASE_BOOST_WITHIN_FOURTEEN_DAYS = 1.15
const val RELEASE_BOOST_DEFAULT = 1.0
}
}

View File

@@ -0,0 +1,24 @@
package kr.co.vividnext.sodalive.v2.content.ranking.domain
import java.time.LocalDateTime
data class AudioRankingSnapshotCandidate(
val contentId: Long,
val title: String,
val creatorMemberId: Long,
val creatorNickname: String,
val coverImageUrl: String?,
val releaseDate: LocalDateTime,
val isAdult: Boolean,
val isPaid: Boolean,
val finalScore: Double = 0.0,
val revenueCanAmount: Long = 0,
val salesCount: Long = 0,
val viewCount: Long = 0,
val likeCount: Long = 0,
val commentCount: Long = 0,
val previousSalesCount: Long = 0,
val previousViewCount: Long = 0,
val previousLikeCount: Long = 0,
val previousCommentCount: Long = 0
)

View File

@@ -0,0 +1,10 @@
package kr.co.vividnext.sodalive.v2.content.ranking.domain
enum class AudioRankingType {
WEEKLY_POPULAR,
RISING,
REVENUE,
SALES_COUNT,
COMMENT_COUNT,
LIKE_COUNT
}

View File

@@ -0,0 +1,36 @@
package kr.co.vividnext.sodalive.v2.content.ranking.port.out
import kr.co.vividnext.sodalive.v2.content.ranking.domain.AudioRankingSnapshotCandidate
import java.time.LocalDateTime
interface AudioRankingAggregationPort {
fun aggregateWeeklyPopularCandidates(
startInclusiveUtc: LocalDateTime,
endExclusiveUtc: LocalDateTime
): List<AudioRankingSnapshotCandidate>
fun aggregateRisingCandidates(
startInclusiveUtc: LocalDateTime,
endExclusiveUtc: LocalDateTime
): List<AudioRankingSnapshotCandidate>
fun aggregateRevenueCandidates(
startInclusiveUtc: LocalDateTime,
endExclusiveUtc: LocalDateTime
): List<AudioRankingSnapshotCandidate>
fun aggregateSalesCountCandidates(
startInclusiveUtc: LocalDateTime,
endExclusiveUtc: LocalDateTime
): List<AudioRankingSnapshotCandidate>
fun aggregateCommentCountCandidates(
startInclusiveUtc: LocalDateTime,
endExclusiveUtc: LocalDateTime
): List<AudioRankingSnapshotCandidate>
fun aggregateLikeCountCandidates(
startInclusiveUtc: LocalDateTime,
endExclusiveUtc: LocalDateTime
): List<AudioRankingSnapshotCandidate>
}

View File

@@ -0,0 +1,5 @@
package kr.co.vividnext.sodalive.v2.content.ranking.port.out
interface AudioRankingBlockPort {
fun findBlockedCreatorMemberIds(memberId: Long, creatorMemberIds: Set<Long>): Set<Long>
}

View File

@@ -0,0 +1,56 @@
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 AudioRankingSnapshotJobPort {
fun save(job: AudioRankingSnapshotJobRecord): AudioRankingSnapshotJobRecord
fun findById(jobId: Long): AudioRankingSnapshotJobRecord?
fun findByRankingTypeAndPeriodAndStatuses(
rankingType: AudioRankingType,
aggregationStartAtUtc: LocalDateTime,
aggregationEndAtUtc: LocalDateTime,
statuses: List<AudioRankingSnapshotJobStatus>
): List<AudioRankingSnapshotJobRecord>
fun countByRankingTypeAndPeriodAndTrigger(
rankingType: AudioRankingType,
aggregationStartAtUtc: LocalDateTime,
aggregationEndAtUtc: LocalDateTime,
trigger: AudioRankingSnapshotJobTrigger
): Long
fun markProcessing(jobId: Long, processingStartedAt: LocalDateTime): AudioRankingSnapshotJobRecord?
fun markDone(jobId: Long, processedAt: LocalDateTime): AudioRankingSnapshotJobRecord?
fun markFailed(jobId: Long, processedAt: LocalDateTime, lastError: String?): AudioRankingSnapshotJobRecord?
}
enum class AudioRankingSnapshotJobStatus {
PENDING,
PROCESSING,
DONE,
FAILED
}
enum class AudioRankingSnapshotJobTrigger {
SCHEDULED,
MANUAL,
FALLBACK
}
data class AudioRankingSnapshotJobRecord(
val id: Long? = null,
val rankingType: AudioRankingType,
val aggregationStartAtUtc: LocalDateTime,
val aggregationEndAtUtc: LocalDateTime,
val visibleFromAtUtc: LocalDateTime,
val trigger: AudioRankingSnapshotJobTrigger,
val status: AudioRankingSnapshotJobStatus,
val lastError: String?,
val processingStartedAt: LocalDateTime?,
val processedAt: LocalDateTime?
)

View File

@@ -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<AudioRankingSnapshotRecord>
fun findPreviousVisibleSnapshots(
rankingType: AudioRankingType,
currentAggregationStartAtUtc: LocalDateTime,
nowUtc: LocalDateTime
): List<AudioRankingSnapshotRecord>
fun replaceSnapshots(
rankingType: AudioRankingType,
aggregationStartAtUtc: LocalDateTime,
aggregationEndAtUtc: LocalDateTime,
visibleFromAtUtc: LocalDateTime,
newSnapshots: List<AudioRankingSnapshotRecord>
)
}
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
)

View File

@@ -0,0 +1,122 @@
package kr.co.vividnext.sodalive.v2.api.content.ranking.adapter.`in`.web
import kr.co.vividnext.sodalive.member.Member
import kr.co.vividnext.sodalive.member.MemberAdapter
import kr.co.vividnext.sodalive.member.MemberRole
import kr.co.vividnext.sodalive.support.EmbeddedRedisInitializer
import kr.co.vividnext.sodalive.v2.content.ranking.adapter.out.persistence.AudioRankingSnapshot
import kr.co.vividnext.sodalive.v2.content.ranking.domain.AudioRankingType
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.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.user
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.annotation.Transactional
import java.time.LocalDateTime
import javax.persistence.EntityManager
@SpringBootTest(properties = ["cloud.aws.cloud-front.host=https://cdn.test"])
@AutoConfigureMockMvc
@Transactional
@ContextConfiguration(initializers = [EmbeddedRedisInitializer::class])
class AudioRankingControllerTest @Autowired constructor(
private val mockMvc: MockMvc,
private val entityManager: EntityManager
) {
@Test
@DisplayName("오디오 랭킹 조회는 비회원에게 200 OK와 기본 WEEKLY_POPULAR 랭킹을 반환한다")
fun shouldReturnWeeklyPopularRankingsForAnonymousByDefault() {
mockMvc.perform(get("/api/v2/audio/rankings"))
.andExpect(status().isOk)
.andExpect(jsonPath("$.success").value(true))
.andExpect(jsonPath("$.data.showRankChange").value(false))
.andExpect(jsonPath("$.data.type").value("WEEKLY_POPULAR"))
.andExpect(jsonPath("$.data.items").isArray)
.andExpect(jsonPath("$.data.items.length()").value(0))
}
@Test
@DisplayName("오디오 랭킹 조회는 인증 회원과 요청 type을 허용한다")
fun shouldAcceptAuthenticatedMemberAndRequestedType() {
val member = Member(
email = "viewer@test.com",
password = "password",
nickname = "viewer",
role = MemberRole.USER
).apply { id = 10L }
mockMvc.perform(get("/api/v2/audio/rankings").param("type", "RISING").with(user(MemberAdapter(member))))
.andExpect(status().isOk)
.andExpect(jsonPath("$.success").value(true))
.andExpect(jsonPath("$.data.type").value("RISING"))
.andExpect(jsonPath("$.data.items").isArray)
}
@Test
@DisplayName("오디오 랭킹 조회는 controller, facade, query service를 거쳐 순위 변화 응답 계약을 반환한다")
fun shouldReturnRisingRankingSchemaThroughControllerFacadeAndQueryService() {
saveSnapshot(contentId = 1L, rank = 1, aggregationStartAtUtc = PREVIOUS_START_AT, aggregationEndAtUtc = PREVIOUS_END_AT)
saveSnapshot(contentId = 2L, rank = 2, aggregationStartAtUtc = PREVIOUS_START_AT, aggregationEndAtUtc = PREVIOUS_END_AT)
saveSnapshot(contentId = 2L, rank = 1, aggregationStartAtUtc = LATEST_START_AT, aggregationEndAtUtc = LATEST_END_AT)
saveSnapshot(contentId = 3L, rank = 2, aggregationStartAtUtc = LATEST_START_AT, aggregationEndAtUtc = LATEST_END_AT)
entityManager.flush()
entityManager.clear()
mockMvc.perform(get("/api/v2/audio/rankings").param("type", "RISING"))
.andExpect(status().isOk)
.andExpect(jsonPath("$.success").value(true))
.andExpect(jsonPath("$.data.showRankChange").value(true))
.andExpect(jsonPath("$.data.type").value("RISING"))
.andExpect(jsonPath("$.data.items[0].contentId").value(2L))
.andExpect(jsonPath("$.data.items[0].rank").value(1))
.andExpect(jsonPath("$.data.items[0].rankChange").value(1))
.andExpect(jsonPath("$.data.items[0].isNew").value(false))
.andExpect(jsonPath("$.data.items[1].contentId").value(3L))
.andExpect(jsonPath("$.data.items[1].rank").value(2))
.andExpect(jsonPath("$.data.items[1].rankChange").doesNotExist())
.andExpect(jsonPath("$.data.items[1].isNew").value(true))
.andExpect(jsonPath("$.data.items[0].finalScore").doesNotExist())
.andExpect(jsonPath("$.data.items[0].aggregationStartAtUtc").doesNotExist())
.andExpect(jsonPath("$.data.items[0].aggregationEndAtUtc").doesNotExist())
.andExpect(jsonPath("$.data.items[0].visibleFromAtUtc").doesNotExist())
.andExpect(jsonPath("$.data.fallback").doesNotExist())
}
private fun saveSnapshot(
contentId: Long,
rank: Int,
aggregationStartAtUtc: LocalDateTime,
aggregationEndAtUtc: LocalDateTime
) {
entityManager.persist(
AudioRankingSnapshot(
rankingType = AudioRankingType.RISING,
aggregationStartAtUtc = aggregationStartAtUtc,
aggregationEndAtUtc = aggregationEndAtUtc,
visibleFromAtUtc = aggregationEndAtUtc.plusHours(9),
contentId = contentId,
title = "audio-$contentId",
creatorMemberId = 100L + contentId,
creatorNickname = "creator-$contentId",
coverImageUrl = "cover-$contentId.png",
releaseDate = LocalDateTime.of(2026, 6, contentId.toInt(), 0, 0),
isAdult = false,
rank = rank,
finalScore = (100 - rank).toDouble()
)
)
}
companion object {
private val PREVIOUS_START_AT = LocalDateTime.of(2026, 5, 25, 15, 0)
private val PREVIOUS_END_AT = LocalDateTime.of(2026, 6, 1, 15, 0)
private val LATEST_START_AT = LocalDateTime.of(2026, 6, 1, 15, 0)
private val LATEST_END_AT = LocalDateTime.of(2026, 6, 8, 15, 0)
}
}

View File

@@ -0,0 +1,52 @@
package kr.co.vividnext.sodalive.v2.api.content.ranking.application
import kr.co.vividnext.sodalive.member.Member
import kr.co.vividnext.sodalive.v2.content.ranking.application.AudioRankingQueryService
import kr.co.vividnext.sodalive.v2.content.ranking.domain.AudioRanking
import kr.co.vividnext.sodalive.v2.content.ranking.domain.AudioRankingItem
import kr.co.vividnext.sodalive.v2.content.ranking.domain.AudioRankingType
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.DisplayName
import org.junit.jupiter.api.Test
import org.mockito.Mockito
class AudioRankingFacadeTest {
private val queryService = Mockito.mock(AudioRankingQueryService::class.java)
private val facade = AudioRankingFacade(queryService)
@Test
@DisplayName("facade는 랭킹 타입과 회원을 쿼리 서비스에 그대로 전달하고 공개 응답으로 변환한다")
fun shouldDelegateTypeAndMemberAndConvertDomainRankingToResponse() {
val member = Mockito.mock(Member::class.java)
val ranking = AudioRanking(
showRankChange = true,
type = AudioRankingType.RISING,
items = listOf(
AudioRankingItem(
contentId = 1L,
title = "audio",
creatorNickname = "creator",
rank = 1,
rankChange = 2,
isNew = false,
coverImageUrl = "https://cdn.test/audio.png"
)
)
)
Mockito.doReturn(ranking).`when`(queryService).getRankings(AudioRankingType.RISING, member)
val response = facade.getRankings(AudioRankingType.RISING, member)
Mockito.verify(queryService).getRankings(AudioRankingType.RISING, member)
assertEquals(true, response.showRankChange)
assertEquals(AudioRankingType.RISING, response.type)
assertEquals(1, response.items.size)
assertEquals(1L, response.items[0].contentId)
assertEquals("audio", response.items[0].title)
assertEquals("creator", response.items[0].creatorNickname)
assertEquals(1, response.items[0].rank)
assertEquals(2, response.items[0].rankChange)
assertEquals(false, response.items[0].isNew)
assertEquals("https://cdn.test/audio.png", response.items[0].coverImageUrl)
}
}

View File

@@ -0,0 +1,48 @@
package kr.co.vividnext.sodalive.v2.api.content.ranking.dto
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
import kr.co.vividnext.sodalive.v2.content.ranking.domain.AudioRanking
import kr.co.vividnext.sodalive.v2.content.ranking.domain.AudioRankingItem
import kr.co.vividnext.sodalive.v2.content.ranking.domain.AudioRankingType
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.DisplayName
import org.junit.jupiter.api.Test
class AudioRankingResponseTest {
private val objectMapper = jacksonObjectMapper()
@Test
@DisplayName("오디오 랭킹 도메인을 응답 DTO로 변환하고 isNew JSON 필드명을 유지한다")
fun shouldMapAudioRankingToResponseAndSerializeIsNew() {
val ranking = AudioRanking(
showRankChange = true,
type = AudioRankingType.RISING,
items = listOf(
AudioRankingItem(
contentId = 1001L,
title = "rising audio",
creatorNickname = "creator",
rank = 1,
rankChange = 3,
isNew = true,
coverImageUrl = "https://cdn.test/audio.png"
)
)
)
val response = AudioRankingResponse.from(ranking)
val json = objectMapper.readTree(objectMapper.writeValueAsString(response))
assertEquals(true, response.showRankChange)
assertEquals(AudioRankingType.RISING, response.type)
assertEquals(1001L, response.items[0].contentId)
assertEquals("rising audio", response.items[0].title)
assertEquals("creator", response.items[0].creatorNickname)
assertEquals(1, response.items[0].rank)
assertEquals(3, response.items[0].rankChange)
assertEquals(true, response.items[0].isNew)
assertEquals("https://cdn.test/audio.png", response.items[0].coverImageUrl)
assertEquals(true, json["items"][0]["isNew"].asBoolean())
assertEquals(false, json["items"][0].has("new"))
}
}

View File

@@ -0,0 +1,252 @@
package kr.co.vividnext.sodalive.v2.content.ranking.adapter.out.persistence
import kr.co.vividnext.sodalive.configs.QueryDslConfig
import kr.co.vividnext.sodalive.content.AudioContent
import kr.co.vividnext.sodalive.content.comment.AudioContentComment
import kr.co.vividnext.sodalive.content.like.AudioContentLike
import kr.co.vividnext.sodalive.content.order.Order
import kr.co.vividnext.sodalive.content.order.OrderType
import kr.co.vividnext.sodalive.content.theme.AudioContentTheme
import kr.co.vividnext.sodalive.member.Member
import kr.co.vividnext.sodalive.member.MemberRole
import kr.co.vividnext.sodalive.v2.recommendation.adapter.out.persistence.CreatorContentViewHistory
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
import javax.persistence.EntityManager
@DataJpaTest(
properties = [
"spring.cache.type=none",
"spring.datasource.url=jdbc:h2:mem:testdb;MODE=MySQL;NON_KEYWORDS=VALUE"
]
)
@Import(QueryDslConfig::class)
class DefaultAudioRankingAggregationRepositoryTest @Autowired constructor(
private val entityManager: EntityManager
) {
private val adapter = DefaultAudioRankingAggregationRepository(entityManager)
private val startAt = LocalDateTime.of(2026, 5, 31, 15, 0)
private val endAt = LocalDateTime.of(2026, 6, 7, 15, 0)
private val inPeriod = LocalDateTime.of(2026, 6, 1, 0, 0)
@Test
@DisplayName("주간 인기 후보는 매출, 판매량, 조회수, 좋아요, 댓글 수를 기간 기준으로 집계한다")
fun shouldAggregateWeeklyPopularMetricsByPeriod() {
val creator = saveCreator("creator")
val buyer = saveUser("buyer")
val content = saveAudioContent(creator, price = 100, isActive = true, releaseDate = inPeriod)
saveOrder(content, buyer, creator, inPeriod)
saveOrder(content, buyer, creator, endAt)
saveView(content, buyer, inPeriod)
saveView(content, buyer, startAt.minusSeconds(1))
saveLike(content, buyer, isActive = true, createdAt = inPeriod)
saveLike(content, buyer, isActive = false, createdAt = inPeriod)
saveComment(content, buyer, isActive = true, createdAt = inPeriod)
saveComment(content, buyer, isActive = false, createdAt = inPeriod)
flushAndClear()
val candidate = adapter.aggregateWeeklyPopularCandidates(startAt, endAt).single()
assertEquals(content.id, candidate.contentId)
assertEquals(100, candidate.revenueCanAmount)
assertEquals(1, candidate.salesCount)
assertEquals(1, candidate.viewCount)
assertEquals(1, candidate.likeCount)
assertEquals(1, candidate.commentCount)
}
@Test
@DisplayName("비활성 콘텐츠, 공개 전 콘텐츠, 비활성 크리에이터 콘텐츠는 후보에서 제외한다")
fun shouldExcludeInactiveUnreleasedAndInactiveCreatorContent() {
val activeCreator = saveCreator("active")
val inactiveCreator = saveCreator("inactive", isActive = false)
val buyer = saveUser("buyer")
val validContent = saveAudioContent(activeCreator, price = 100, isActive = true, releaseDate = inPeriod)
val inactiveContent = saveAudioContent(activeCreator, price = 100, isActive = false, releaseDate = inPeriod)
val unreleasedContent = saveAudioContent(activeCreator, price = 100, isActive = true, releaseDate = endAt.plusDays(1))
val inactiveCreatorContent = saveAudioContent(inactiveCreator, price = 100, isActive = true, releaseDate = inPeriod)
listOf(validContent, inactiveContent, unreleasedContent, inactiveCreatorContent).forEach { content ->
saveOrder(content, buyer, content.member!!, inPeriod)
}
flushAndClear()
val candidates = adapter.aggregateWeeklyPopularCandidates(startAt, endAt)
assertEquals(listOf(validContent.id), candidates.map { it.contentId })
}
@Test
@DisplayName("한정판 콘텐츠는 랭킹 후보에서 제외한다")
fun shouldExcludeLimitedContent() {
val creator = saveCreator("limited")
val buyer = saveUser("buyer-limited")
val validContent = saveAudioContent(creator, price = 100, isActive = true, releaseDate = inPeriod)
val limitedContent = saveAudioContent(creator, price = 100, isActive = true, releaseDate = inPeriod, limited = 10)
listOf(validContent, limitedContent).forEach { content ->
saveOrder(content, buyer, creator, inPeriod)
}
flushAndClear()
val candidates = adapter.aggregateWeeklyPopularCandidates(startAt, endAt)
assertEquals(listOf(validContent.id), candidates.map { it.contentId })
}
@Test
@DisplayName("지금 뜨는 중 후보는 직전 비교 기간 지표를 함께 반환한다")
fun shouldAggregatePreviousMetricsForRisingCandidates() {
val creator = saveCreator("creator")
val viewer = saveUser("viewer")
val content = saveAudioContent(creator, price = 0, isActive = true, releaseDate = inPeriod)
saveView(content, viewer, startAt.minusDays(1))
saveView(content, viewer, inPeriod)
saveLike(content, viewer, isActive = true, createdAt = startAt.minusDays(1))
saveLike(content, viewer, isActive = true, createdAt = inPeriod)
saveComment(content, viewer, isActive = true, createdAt = startAt.minusDays(1))
saveComment(content, viewer, isActive = true, createdAt = inPeriod)
flushAndClear()
val candidate = adapter.aggregateRisingCandidates(startAt, endAt).single()
assertEquals(1, candidate.viewCount)
assertEquals(1, candidate.previousViewCount)
assertEquals(1, candidate.likeCount)
assertEquals(1, candidate.previousLikeCount)
assertEquals(1, candidate.commentCount)
assertEquals(1, candidate.previousCommentCount)
}
@Test
@DisplayName("매출, 판매량, 댓글 수, 좋아요 후보는 v2 집계 지표를 최종 점수로 사용한다")
fun shouldAggregateMetricRankingCandidatesWithRawScores() {
val creator = saveCreator("metric")
val buyer = saveUser("buyer-metric")
val content = saveAudioContent(creator, price = 100, isActive = true, releaseDate = inPeriod)
saveOrder(content, buyer, creator, inPeriod)
saveOrder(content, buyer, creator, inPeriod.plusHours(1))
saveLike(content, buyer, isActive = true, createdAt = inPeriod)
saveComment(content, buyer, isActive = true, createdAt = inPeriod)
flushAndClear()
assertEquals(200.0, adapter.aggregateRevenueCandidates(startAt, endAt).single().finalScore)
assertEquals(2.0, adapter.aggregateSalesCountCandidates(startAt, endAt).single().finalScore)
assertEquals(1.0, adapter.aggregateLikeCountCandidates(startAt, endAt).single().finalScore)
assertEquals(1.0, adapter.aggregateCommentCountCandidates(startAt, endAt).single().finalScore)
}
@Test
@DisplayName("후보는 성인 콘텐츠 여부를 함께 반환한다")
fun shouldMapAdultFlagToCandidate() {
val creator = saveCreator("adult")
val buyer = saveUser("buyer-adult")
val content = saveAudioContent(creator, price = 100, isActive = true, releaseDate = inPeriod, isAdult = true)
saveOrder(content, buyer, creator, inPeriod)
flushAndClear()
val candidate = adapter.aggregateRevenueCandidates(startAt, endAt).single()
assertEquals(true, candidate.isAdult)
}
private fun saveCreator(nickname: String, isActive: Boolean = true): Member {
return saveMember(nickname, MemberRole.CREATOR, isActive)
}
private fun saveUser(nickname: String): Member {
return saveMember(nickname, MemberRole.USER, true)
}
private fun saveMember(nickname: String, role: MemberRole, isActive: Boolean): Member {
val member = Member(
email = "$nickname@test.com",
password = "password",
nickname = nickname,
role = role,
isActive = isActive
)
entityManager.persist(member)
entityManager.flush()
return member
}
private fun saveAudioContent(
creator: Member,
price: Int,
isActive: Boolean,
releaseDate: LocalDateTime,
limited: Int? = null,
isAdult: Boolean = false
): AudioContent {
val theme = AudioContentTheme(theme = "theme-${creator.nickname}", image = "theme.png")
entityManager.persist(theme)
val content = AudioContent(
title = "content-${creator.nickname}-${releaseDate.nano}",
detail = "detail",
languageCode = "ko",
price = price,
releaseDate = releaseDate,
limited = limited
)
content.member = creator
content.theme = theme
content.isActive = isActive
content.isAdult = isAdult
content.duration = "00:01:00"
entityManager.persist(content)
entityManager.flush()
return content
}
private fun saveOrder(content: AudioContent, buyer: Member, creator: Member, createdAt: LocalDateTime) {
val order = Order(type = OrderType.KEEP, isActive = true)
order.member = buyer
order.creator = creator
order.audioContent = content
entityManager.persist(order)
entityManager.flush()
updateTimestamps("orders", order.id!!, createdAt, createdAt)
}
private fun saveView(content: AudioContent, viewer: Member, viewedAt: LocalDateTime) {
entityManager.persist(CreatorContentViewHistory(viewer.id!!, content.id!!, content.theme!!.id!!, viewedAt))
}
private fun saveLike(content: AudioContent, member: Member, isActive: Boolean, createdAt: LocalDateTime) {
val like = AudioContentLike(memberId = member.id!!)
like.audioContent = content
like.isActive = isActive
entityManager.persist(like)
entityManager.flush()
updateTimestamps("content_like", like.id!!, createdAt, createdAt)
}
private fun saveComment(content: AudioContent, member: Member, isActive: Boolean, createdAt: LocalDateTime) {
val comment = AudioContentComment(comment = "comment", languageCode = "ko", isActive = isActive)
comment.audioContent = content
comment.member = member
entityManager.persist(comment)
entityManager.flush()
updateTimestamps("content_comment", comment.id!!, createdAt, createdAt)
}
private fun updateTimestamps(tableName: String, id: Long, createdAt: LocalDateTime, updatedAt: LocalDateTime) {
entityManager.createNativeQuery(
"update $tableName set created_at = :createdAt, updated_at = :updatedAt where id = :id"
)
.setParameter("createdAt", createdAt)
.setParameter("updatedAt", updatedAt)
.setParameter("id", id)
.executeUpdate()
entityManager.clear()
}
private fun flushAndClear() {
entityManager.flush()
entityManager.clear()
}
}

View File

@@ -0,0 +1,100 @@
package kr.co.vividnext.sodalive.v2.content.ranking.adapter.out.persistence
import com.querydsl.jpa.impl.JPAQueryFactory
import kr.co.vividnext.sodalive.configs.QueryDslConfig
import kr.co.vividnext.sodalive.member.Member
import kr.co.vividnext.sodalive.member.MemberRole
import kr.co.vividnext.sodalive.member.block.BlockMember
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 javax.persistence.EntityManager
@DataJpaTest(
properties = [
"spring.cache.type=none",
"spring.datasource.url=jdbc:h2:mem:testdb;MODE=MySQL;NON_KEYWORDS=VALUE"
]
)
@Import(QueryDslConfig::class)
class DefaultAudioRankingBlockRepositoryTest @Autowired constructor(
private val entityManager: EntityManager,
queryFactory: JPAQueryFactory
) {
private val repository = DefaultAudioRankingBlockRepository(queryFactory)
@Test
@DisplayName("활성 양방향 차단된 크리에이터 member id만 조회한다")
fun shouldFindActiveBlockedCreatorMemberIdsInBothDirections() {
val viewer = saveUser("viewer")
val blockedByViewer = saveCreator("blocked-by-viewer")
val blocksViewer = saveCreator("blocks-viewer")
val inactiveBlocked = saveCreator("inactive-blocked")
val allowed = saveCreator("allowed")
val outsideInput = saveCreator("outside-input")
saveBlock(viewer, blockedByViewer, isActive = true)
saveBlock(blocksViewer, viewer, isActive = true)
saveBlock(viewer, inactiveBlocked, isActive = false)
saveBlock(viewer, outsideInput, isActive = true)
flushAndClear()
val blockedCreatorMemberIds = repository.findBlockedCreatorMemberIds(
memberId = viewer.id!!,
creatorMemberIds = setOf(blockedByViewer.id!!, blocksViewer.id!!, inactiveBlocked.id!!, allowed.id!!)
)
assertEquals(setOf(blockedByViewer.id, blocksViewer.id), blockedCreatorMemberIds)
}
@Test
@DisplayName("크리에이터 member id 목록이 비어 있으면 빈 집합을 반환한다")
fun shouldReturnEmptySetWhenCreatorMemberIdsIsEmpty() {
val viewer = saveUser("empty-viewer")
val creator = saveCreator("empty-creator")
saveBlock(viewer, creator, isActive = true)
flushAndClear()
val blockedCreatorMemberIds = repository.findBlockedCreatorMemberIds(
memberId = viewer.id!!,
creatorMemberIds = emptySet()
)
assertEquals(emptySet<Long>(), blockedCreatorMemberIds)
}
private fun saveCreator(nickname: String): Member {
return saveMember(nickname, MemberRole.CREATOR)
}
private fun saveUser(nickname: String): Member {
return saveMember(nickname, MemberRole.USER)
}
private fun saveMember(nickname: String, role: MemberRole): Member {
val member = Member(
email = "$nickname@test.com",
password = "password",
nickname = nickname,
role = role,
isActive = true
)
entityManager.persist(member)
entityManager.flush()
return member
}
private fun saveBlock(member: Member, blockedMember: Member, isActive: Boolean) {
val block = BlockMember(isActive = isActive)
block.member = member
block.blockedMember = blockedMember
entityManager.persist(block)
}
private fun flushAndClear() {
entityManager.flush()
entityManager.clear()
}
}

View File

@@ -0,0 +1,114 @@
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.AudioRankingSnapshotJobRecord
import kr.co.vividnext.sodalive.v2.content.ranking.port.out.AudioRankingSnapshotJobStatus
import kr.co.vividnext.sodalive.v2.content.ranking.port.out.AudioRankingSnapshotJobTrigger
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 DefaultAudioRankingSnapshotJobRepositoryTest @Autowired constructor(
private val repository: AudioRankingSnapshotJobRepository
) {
private val adapter = DefaultAudioRankingSnapshotJobRepository(repository)
@Test
@DisplayName("스냅샷 job은 랭킹 타입, 기간, 트리거, 상태와 처리 정보를 저장하고 변경한다")
fun shouldSaveAndUpdateSnapshotJobHistory() {
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)
val saved = adapter.save(jobRecord(startAt = startAt, endAt = endAt, visibleAt = visibleAt))
val jobId = saved.id!!
adapter.markProcessing(jobId, LocalDateTime.of(2026, 6, 8, 1, 0))
adapter.markFailed(jobId, LocalDateTime.of(2026, 6, 8, 1, 1), "aggregate failed")
val failed = adapter.findById(jobId)
assertEquals(AudioRankingType.WEEKLY_POPULAR, failed?.rankingType)
assertEquals(AudioRankingSnapshotJobTrigger.SCHEDULED, failed?.trigger)
assertEquals(AudioRankingSnapshotJobStatus.FAILED, failed?.status)
assertEquals("aggregate failed", failed?.lastError)
adapter.markProcessing(jobId, LocalDateTime.of(2026, 6, 8, 1, 2))
adapter.markDone(jobId, LocalDateTime.of(2026, 6, 8, 1, 3))
val doneJobs = adapter.findByRankingTypeAndPeriodAndStatuses(
rankingType = AudioRankingType.WEEKLY_POPULAR,
aggregationStartAtUtc = startAt,
aggregationEndAtUtc = endAt,
statuses = listOf(AudioRankingSnapshotJobStatus.DONE)
)
assertEquals(1, doneJobs.size)
assertEquals(AudioRankingSnapshotJobStatus.DONE, doneJobs.single().status)
assertEquals(null, doneJobs.single().lastError)
}
@Test
@DisplayName("fallback job 수는 랭킹 타입과 집계 기간과 FALLBACK 트리거 기준으로만 계산한다")
fun shouldCountFallbackJobsByRankingTypeAndPeriodAndTrigger() {
val startAt = LocalDateTime.of(2026, 5, 31, 15, 0)
val endAt = LocalDateTime.of(2026, 6, 7, 15, 0)
adapter.save(jobRecord(startAt = startAt, endAt = endAt, trigger = AudioRankingSnapshotJobTrigger.FALLBACK))
adapter.save(jobRecord(startAt = startAt, endAt = endAt, trigger = AudioRankingSnapshotJobTrigger.FALLBACK))
adapter.save(jobRecord(startAt = startAt, endAt = endAt, trigger = AudioRankingSnapshotJobTrigger.SCHEDULED))
adapter.save(
jobRecord(
startAt = startAt,
endAt = endAt,
rankingType = AudioRankingType.RISING,
trigger = AudioRankingSnapshotJobTrigger.FALLBACK
)
)
adapter.save(
jobRecord(
startAt = startAt.minusWeeks(1),
endAt = endAt.minusWeeks(1),
trigger = AudioRankingSnapshotJobTrigger.FALLBACK
)
)
val count = adapter.countByRankingTypeAndPeriodAndTrigger(
rankingType = AudioRankingType.WEEKLY_POPULAR,
aggregationStartAtUtc = startAt,
aggregationEndAtUtc = endAt,
trigger = AudioRankingSnapshotJobTrigger.FALLBACK
)
assertEquals(2L, count)
}
private fun jobRecord(
rankingType: AudioRankingType = AudioRankingType.WEEKLY_POPULAR,
startAt: LocalDateTime,
endAt: LocalDateTime,
visibleAt: LocalDateTime = LocalDateTime.of(2026, 6, 8, 0, 0),
trigger: AudioRankingSnapshotJobTrigger = AudioRankingSnapshotJobTrigger.SCHEDULED,
status: AudioRankingSnapshotJobStatus = AudioRankingSnapshotJobStatus.PENDING
): AudioRankingSnapshotJobRecord {
return AudioRankingSnapshotJobRecord(
rankingType = rankingType,
aggregationStartAtUtc = startAt,
aggregationEndAtUtc = endAt,
visibleFromAtUtc = visibleAt,
trigger = trigger,
status = status,
lastError = null,
processingStartedAt = null,
processedAt = null
)
}
}

View File

@@ -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
)
}
}

View File

@@ -0,0 +1,68 @@
package kr.co.vividnext.sodalive.v2.content.ranking.adapter.out.scheduler
import kr.co.vividnext.sodalive.v2.content.ranking.application.AudioRankingSnapshotJobService
import kr.co.vividnext.sodalive.v2.content.ranking.domain.AudioRankingType
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Test
import org.mockito.Mockito
import org.redisson.api.RLock
import org.redisson.api.RedissonClient
import org.springframework.scheduling.annotation.Scheduled
import java.util.concurrent.TimeUnit
class AudioRankingSnapshotSchedulerTest {
@Test
fun shouldHaveDistributedMondayKstCronByRankingType() {
assertSchedule("refreshWeeklyPopular", "0 0 2 * * MON")
assertSchedule("refreshRising", "0 0 3 * * MON")
assertSchedule("refreshRevenue", "0 0 4 * * MON")
assertSchedule("refreshSalesCount", "0 0 5 * * MON")
assertSchedule("refreshCommentCount", "0 0 6 * * MON")
assertSchedule("refreshLikeCount", "0 0 7 * * MON")
}
@Test
fun shouldCallJobServiceOnlyWhenTypeLockAcquired() {
val jobService = Mockito.mock(AudioRankingSnapshotJobService::class.java)
val redissonClient = Mockito.mock(RedissonClient::class.java)
val lock = Mockito.mock(RLock::class.java)
Mockito.`when`(redissonClient.getLock("lock:content-ranking-snapshot-refresh:REVENUE")).thenReturn(lock)
Mockito.`when`(lock.tryLock(0, -1, TimeUnit.SECONDS)).thenReturn(true)
Mockito.`when`(lock.isHeldByCurrentThread).thenReturn(true)
val scheduler = AudioRankingSnapshotScheduler(jobService, redissonClient)
scheduler.refreshRevenue()
Mockito.verify(redissonClient).getLock("lock:content-ranking-snapshot-refresh:REVENUE")
Mockito.verify(lock).tryLock(0, -1, TimeUnit.SECONDS)
Mockito.verify(jobService).refreshLastCompletedWeekByScheduledJob(AudioRankingType.REVENUE)
Mockito.verify(lock).unlock()
}
@Test
fun shouldSkipJobServiceWhenTypeLockIsNotAcquired() {
val jobService = Mockito.mock(AudioRankingSnapshotJobService::class.java)
val redissonClient = Mockito.mock(RedissonClient::class.java)
val lock = Mockito.mock(RLock::class.java)
Mockito.`when`(redissonClient.getLock("lock:content-ranking-snapshot-refresh:RISING")).thenReturn(lock)
Mockito.`when`(lock.tryLock(0, -1, TimeUnit.SECONDS)).thenReturn(false)
Mockito.`when`(lock.isHeldByCurrentThread).thenReturn(false)
val scheduler = AudioRankingSnapshotScheduler(jobService, redissonClient)
scheduler.refreshRising()
Mockito.verify(redissonClient).getLock("lock:content-ranking-snapshot-refresh:RISING")
Mockito.verify(lock).tryLock(0, -1, TimeUnit.SECONDS)
Mockito.verify(jobService, Mockito.never()).refreshLastCompletedWeekByScheduledJob(AudioRankingType.RISING)
Mockito.verify(lock, Mockito.never()).unlock()
}
private fun assertSchedule(methodName: String, cron: String) {
val scheduled = AudioRankingSnapshotScheduler::class.java
.getDeclaredMethod(methodName)
.getAnnotation(Scheduled::class.java)
assertEquals(cron, scheduled.cron)
assertEquals("Asia/Seoul", scheduled.zone)
}
}

View File

@@ -0,0 +1,317 @@
package kr.co.vividnext.sodalive.v2.content.ranking.application
import kr.co.vividnext.sodalive.member.Member
import kr.co.vividnext.sodalive.member.contentpreference.MemberContentPreferenceService
import kr.co.vividnext.sodalive.v2.content.ranking.domain.AudioRankingType
import kr.co.vividnext.sodalive.v2.content.ranking.port.out.AudioRankingBlockPort
import kr.co.vividnext.sodalive.v2.content.ranking.port.out.AudioRankingSnapshotPort
import kr.co.vividnext.sodalive.v2.content.ranking.port.out.AudioRankingSnapshotRecord
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertFalse
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.extension.ExtendWith
import org.mockito.Mockito
import org.springframework.boot.test.system.CapturedOutput
import org.springframework.boot.test.system.OutputCaptureExtension
import org.springframework.transaction.annotation.Transactional
import java.time.LocalDateTime
import java.time.ZoneId
import java.time.ZonedDateTime
@ExtendWith(OutputCaptureExtension::class)
class AudioRankingQueryServiceTest {
@Test
fun shouldNotWrapGetRankingsInTransactionSoFallbackRequeryUsesFreshSnapshot() {
val method = AudioRankingQueryService::class.java.getDeclaredMethod(
"getRankings",
AudioRankingType::class.java,
Member::class.java
)
assertEquals(null, method.getAnnotation(Transactional::class.java))
}
@Test
fun shouldReturnLatestVisibleSnapshotsWithRankChangesAndNewFlags() {
val snapshotPort = FakeAudioRankingQuerySnapshotPort()
snapshotPort.latestSnapshots = listOf(
snapshot(contentId = 2L, rank = 1),
snapshot(contentId = 1L, rank = 2),
snapshot(contentId = 3L, rank = 3)
)
snapshotPort.previousSnapshots = listOf(
snapshot(contentId = 1L, rank = 1),
snapshot(contentId = 2L, rank = 2)
)
val service = service(snapshotPort)
val result = service.getRankings(AudioRankingType.REVENUE, member = null)
assertTrue(result.showRankChange)
assertEquals(AudioRankingType.REVENUE, result.type)
assertEquals(listOf(2L, 1L, 3L), result.items.map { it.contentId })
assertEquals(listOf(1, 2, 3), result.items.map { it.rank })
assertEquals(listOf(1, -1, null), result.items.map { it.rankChange })
assertEquals(listOf(false, false, true), result.items.map { it.isNew })
assertEquals(LocalDateTime.of(2026, 6, 8, 0, 0), snapshotPort.nowUtc)
assertEquals(LocalDateTime.of(2026, 5, 31, 15, 0), snapshotPort.currentAggregationStartAtUtc)
}
@Test
fun shouldHideRankChangesWhenPreviousSnapshotDoesNotExist() {
val snapshotPort = FakeAudioRankingQuerySnapshotPort()
snapshotPort.latestSnapshots = listOf(snapshot(contentId = 1L, rank = 1))
val service = service(snapshotPort)
val result = service.getRankings(AudioRankingType.WEEKLY_POPULAR, member = null)
assertFalse(result.showRankChange)
assertEquals(listOf(1L), result.items.map { it.contentId })
assertEquals(listOf(null), result.items.map { it.rankChange })
assertEquals(listOf(false), result.items.map { it.isNew })
}
@Test
fun shouldReturnEmptyRankingWhenLatestSnapshotDoesNotExist() {
val result = service(FakeAudioRankingQuerySnapshotPort()).getRankings(AudioRankingType.LIKE_COUNT, member = null)
assertFalse(result.showRankChange)
assertEquals(AudioRankingType.LIKE_COUNT, result.type)
assertEquals(emptyList<Any>(), result.items)
}
@Test
fun shouldFilterAdultSnapshotsForNonAdultViewerAndRecalculateRanks() {
val snapshotPort = FakeAudioRankingQuerySnapshotPort()
snapshotPort.latestSnapshots = listOf(
snapshot(contentId = 1L, rank = 1, isAdult = true),
snapshot(contentId = 2L, rank = 2),
snapshot(contentId = 3L, rank = 3)
)
snapshotPort.previousSnapshots = listOf(
snapshot(contentId = 1L, rank = 1, isAdult = true),
snapshot(contentId = 2L, rank = 2)
)
val result = service(snapshotPort).getRankings(AudioRankingType.REVENUE, member = null)
assertEquals(listOf(2L, 3L), result.items.map { it.contentId })
assertEquals(listOf(1, 2), result.items.map { it.rank })
assertEquals(listOf(0, null), result.items.map { it.rankChange })
assertEquals(listOf(false, true), result.items.map { it.isNew })
}
@Test
fun shouldKeepAdultSnapshotsForAdultViewer() {
val snapshotPort = FakeAudioRankingQuerySnapshotPort()
val member = Mockito.mock(Member::class.java)
snapshotPort.latestSnapshots = listOf(snapshot(contentId = 1L, rank = 1, isAdult = true))
val result = service(snapshotPort, adultMember = member).getRankings(AudioRankingType.REVENUE, member)
assertEquals(listOf(1L), result.items.map { it.contentId })
}
@Test
fun shouldFilterBlockedCreatorSnapshotsForMemberAndRecalculateRanks() {
val snapshotPort = FakeAudioRankingQuerySnapshotPort()
val blockPort = FakeAudioRankingBlockPort(blockedCreatorMemberIds = setOf(102L))
val member = member(id = 7L)
snapshotPort.latestSnapshots = listOf(
snapshot(contentId = 1L, rank = 1, creatorMemberId = 101L),
snapshot(contentId = 2L, rank = 2, creatorMemberId = 102L),
snapshot(contentId = 3L, rank = 3, creatorMemberId = 103L)
)
snapshotPort.previousSnapshots = listOf(
snapshot(contentId = 2L, rank = 1, creatorMemberId = 102L),
snapshot(contentId = 1L, rank = 2, creatorMemberId = 101L)
)
val result = service(snapshotPort, blockPort = blockPort).getRankings(AudioRankingType.REVENUE, member)
assertEquals(listOf(1L, 3L), result.items.map { it.contentId })
assertEquals(listOf(1, 2), result.items.map { it.rank })
assertEquals(listOf(0, null), result.items.map { it.rankChange })
assertEquals(7L, blockPort.memberId)
assertEquals(setOf(101L, 102L, 103L), blockPort.creatorMemberIds)
}
@Test
fun shouldNotLookupBlockedCreatorsForAnonymousViewer() {
val snapshotPort = FakeAudioRankingQuerySnapshotPort()
val blockPort = FakeAudioRankingBlockPort(blockedCreatorMemberIds = setOf(101L))
snapshotPort.latestSnapshots = listOf(snapshot(contentId = 1L, rank = 1, creatorMemberId = 101L))
val result = service(snapshotPort, blockPort = blockPort).getRankings(AudioRankingType.REVENUE, member = null)
assertEquals(listOf(1L), result.items.map { it.contentId })
assertEquals(0, blockPort.callCount)
}
@Test
fun shouldRunFallbackAndRequeryWhenLatestVisibleSnapshotDoesNotExist() {
val snapshotPort = FakeAudioRankingQuerySnapshotPort()
val jobService = Mockito.mock(AudioRankingSnapshotJobService::class.java)
snapshotPort.latestSnapshotsByCall = listOf(
emptyList(),
listOf(snapshot(contentId = 1L, rank = 1))
)
val result = service(snapshotPort, jobService = jobService).getRankings(AudioRankingType.LIKE_COUNT, member = null)
assertEquals(listOf(1L), result.items.map { it.contentId })
assertEquals(2, snapshotPort.latestCallCount)
Mockito.verify(jobService).refreshLastCompletedWeekByFallback(AudioRankingType.LIKE_COUNT)
}
@Test
fun shouldReturnEmptyRankingWhenFallbackFails(output: CapturedOutput) {
val snapshotPort = FakeAudioRankingQuerySnapshotPort()
val jobService = Mockito.mock(AudioRankingSnapshotJobService::class.java)
Mockito.doThrow(IllegalStateException("aggregate failed"))
.`when`(jobService).refreshLastCompletedWeekByFallback(AudioRankingType.LIKE_COUNT)
val result = service(snapshotPort, jobService = jobService).getRankings(AudioRankingType.LIKE_COUNT, member = null)
assertFalse(result.showRankChange)
assertEquals(AudioRankingType.LIKE_COUNT, result.type)
assertEquals(emptyList<Any>(), result.items)
assertTrue(output.out.contains("event=audio_ranking_query_fallback_failure"))
assertTrue(output.out.contains("rankingType=LIKE_COUNT"))
assertTrue(output.out.contains("error=aggregate failed"))
}
@Test
fun shouldNotRunFallbackWhenLatestVisibleSnapshotExists() {
val snapshotPort = FakeAudioRankingQuerySnapshotPort()
val jobService = Mockito.mock(AudioRankingSnapshotJobService::class.java)
snapshotPort.latestSnapshots = listOf(snapshot(contentId = 1L, rank = 1))
service(snapshotPort, jobService = jobService).getRankings(AudioRankingType.REVENUE, member = null)
Mockito.verify(jobService, Mockito.never()).refreshLastCompletedWeekByFallback(AudioRankingType.REVENUE)
}
@Test
fun shouldFilterPreviousOnlyBlockedCreatorWhenCalculatingRankChanges() {
val snapshotPort = FakeAudioRankingQuerySnapshotPort()
val blockPort = FakeAudioRankingBlockPort(blockedCreatorMemberIds = setOf(999L))
val member = member(id = 7L)
snapshotPort.latestSnapshots = listOf(
snapshot(contentId = 1L, rank = 1, creatorMemberId = 101L),
snapshot(contentId = 2L, rank = 2, creatorMemberId = 102L)
)
snapshotPort.previousSnapshots = listOf(
snapshot(contentId = 99L, rank = 1, creatorMemberId = 999L),
snapshot(contentId = 1L, rank = 2, creatorMemberId = 101L),
snapshot(contentId = 2L, rank = 3, creatorMemberId = 102L)
)
val result = service(snapshotPort, blockPort = blockPort).getRankings(AudioRankingType.REVENUE, member)
assertEquals(setOf(101L, 102L, 999L), blockPort.creatorMemberIds)
assertEquals(listOf(0, 0), result.items.map { it.rankChange })
assertEquals(listOf(false, false), result.items.map { it.isNew })
}
private fun service(
snapshotPort: FakeAudioRankingQuerySnapshotPort,
adultMember: Member? = null,
blockPort: AudioRankingBlockPort = FakeAudioRankingBlockPort(),
jobService: AudioRankingSnapshotJobService = Mockito.mock(AudioRankingSnapshotJobService::class.java)
): AudioRankingQueryService {
val memberContentPreferenceService = Mockito.mock(MemberContentPreferenceService::class.java)
if (adultMember != null) {
Mockito.doReturn(true).`when`(memberContentPreferenceService).canViewAdultContent(adultMember)
}
return AudioRankingQueryService(
snapshotPort = snapshotPort,
memberContentPreferenceService = memberContentPreferenceService,
blockPort = blockPort,
jobService = jobService,
nowProvider = { ZonedDateTime.of(2026, 6, 8, 9, 0, 0, 0, ZoneId.of("Asia/Seoul")) }
)
}
private fun member(id: Long): Member {
return Member(password = "password", nickname = "member-$id").also { it.id = id }
}
private fun snapshot(
contentId: Long,
rank: Int,
rankingType: AudioRankingType = AudioRankingType.REVENUE,
isAdult: Boolean = false,
creatorMemberId: Long = 100L + contentId
): AudioRankingSnapshotRecord {
return AudioRankingSnapshotRecord(
rankingType = rankingType,
aggregationStartAtUtc = LocalDateTime.of(2026, 5, 31, 15, 0),
aggregationEndAtUtc = LocalDateTime.of(2026, 6, 7, 15, 0),
visibleFromAtUtc = LocalDateTime.of(2026, 6, 8, 0, 0),
contentId = contentId,
title = "audio-$contentId",
creatorMemberId = creatorMemberId,
creatorNickname = "creator-$contentId",
coverImageUrl = "cover-$contentId.png",
releaseDate = LocalDateTime.of(2026, 6, 1, 0, 0),
isAdult = isAdult,
rank = rank,
finalScore = (100 - rank).toDouble()
)
}
}
private class FakeAudioRankingQuerySnapshotPort : AudioRankingSnapshotPort {
var latestSnapshots: List<AudioRankingSnapshotRecord> = emptyList()
var latestSnapshotsByCall: List<List<AudioRankingSnapshotRecord>> = emptyList()
var previousSnapshots: List<AudioRankingSnapshotRecord> = emptyList()
var nowUtc: LocalDateTime? = null
var currentAggregationStartAtUtc: LocalDateTime? = null
var latestCallCount: Int = 0
override fun findLatestVisibleSnapshots(
rankingType: AudioRankingType,
nowUtc: LocalDateTime
): List<AudioRankingSnapshotRecord> {
this.nowUtc = nowUtc
latestCallCount += 1
if (latestSnapshotsByCall.isNotEmpty()) {
return latestSnapshotsByCall.getOrElse(latestCallCount - 1) { latestSnapshotsByCall.last() }
}
return latestSnapshots
}
override fun findPreviousVisibleSnapshots(
rankingType: AudioRankingType,
currentAggregationStartAtUtc: LocalDateTime,
nowUtc: LocalDateTime
): List<AudioRankingSnapshotRecord> {
this.currentAggregationStartAtUtc = currentAggregationStartAtUtc
return previousSnapshots
}
override fun replaceSnapshots(
rankingType: AudioRankingType,
aggregationStartAtUtc: LocalDateTime,
aggregationEndAtUtc: LocalDateTime,
visibleFromAtUtc: LocalDateTime,
newSnapshots: List<AudioRankingSnapshotRecord>
) = error("Query service test does not replace snapshots")
}
private class FakeAudioRankingBlockPort(
private val blockedCreatorMemberIds: Set<Long> = emptySet()
) : AudioRankingBlockPort {
var memberId: Long? = null
var creatorMemberIds: Set<Long> = emptySet()
var callCount: Int = 0
override fun findBlockedCreatorMemberIds(memberId: Long, creatorMemberIds: Set<Long>): Set<Long> {
callCount += 1
this.memberId = memberId
this.creatorMemberIds = creatorMemberIds
return blockedCreatorMemberIds
}
}

View File

@@ -0,0 +1,307 @@
package kr.co.vividnext.sodalive.v2.content.ranking.application
import kr.co.vividnext.sodalive.v2.content.ranking.domain.AudioRankingType
import kr.co.vividnext.sodalive.v2.content.ranking.port.out.AudioRankingSnapshotJobPort
import kr.co.vividnext.sodalive.v2.content.ranking.port.out.AudioRankingSnapshotJobRecord
import kr.co.vividnext.sodalive.v2.content.ranking.port.out.AudioRankingSnapshotJobStatus
import kr.co.vividnext.sodalive.v2.content.ranking.port.out.AudioRankingSnapshotJobTrigger
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertThrows
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Test
import org.mockito.Mockito
import org.redisson.api.RLock
import org.redisson.api.RedissonClient
import org.springframework.transaction.PlatformTransactionManager
import org.springframework.transaction.TransactionDefinition
import org.springframework.transaction.support.SimpleTransactionStatus
import java.time.LocalDateTime
import java.time.ZoneId
import java.time.ZonedDateTime
import java.util.concurrent.TimeUnit
class AudioRankingSnapshotJobServiceTest {
@Test
fun shouldCreateScheduledJobAndMarkDoneWhenRefreshSucceeds() {
val refreshService = Mockito.mock(AudioRankingSnapshotRefreshService::class.java)
val jobPort = FakeAudioRankingSnapshotJobPort()
val now = now()
val service = service(
refreshService = refreshService,
jobPort = jobPort,
redissonClient = periodLockRedissonClient(true)
) { now }
service.refreshLastCompletedWeekByScheduledJob(AudioRankingType.REVENUE)
val job = jobPort.jobs.single()
assertEquals(AudioRankingType.REVENUE, job.rankingType)
assertEquals(LocalDateTime.of(2026, 5, 31, 15, 0), job.aggregationStartAtUtc)
assertEquals(LocalDateTime.of(2026, 6, 7, 15, 0), job.aggregationEndAtUtc)
assertEquals(LocalDateTime.of(2026, 6, 8, 0, 0), job.visibleFromAtUtc)
assertEquals(AudioRankingSnapshotJobTrigger.SCHEDULED, job.trigger)
assertEquals(AudioRankingSnapshotJobStatus.DONE, job.status)
Mockito.verify(refreshService).refreshLastCompletedWeek(AudioRankingType.REVENUE, now)
}
@Test
fun shouldMarkScheduledJobFailedWhenRefreshFails() {
val refreshService = Mockito.mock(AudioRankingSnapshotRefreshService::class.java)
val jobPort = FakeAudioRankingSnapshotJobPort()
val now = now()
Mockito.doThrow(IllegalStateException("aggregate failed"))
.`when`(refreshService).refreshLastCompletedWeek(AudioRankingType.RISING, now)
val service = service(
refreshService = refreshService,
jobPort = jobPort,
redissonClient = periodLockRedissonClient(true)
) { now }
val exception = assertThrows(IllegalStateException::class.java) {
service.refreshLastCompletedWeekByScheduledJob(AudioRankingType.RISING)
}
assertEquals("aggregate failed", exception.message)
assertEquals(AudioRankingSnapshotJobStatus.FAILED, jobPort.jobs.single().status)
assertEquals("aggregate failed", jobPort.jobs.single().lastError)
}
@Test
fun shouldCommitFailedJobStatusWhenRefreshFails() {
val refreshService = Mockito.mock(AudioRankingSnapshotRefreshService::class.java)
val jobPort = FakeAudioRankingSnapshotJobPort()
val now = now()
val transactionManager = transactionManager()
Mockito.doThrow(IllegalStateException("aggregate failed"))
.`when`(refreshService).refreshLastCompletedWeek(AudioRankingType.RISING, now)
val service = AudioRankingSnapshotJobService(
refreshService = refreshService,
jobPort = jobPort,
redissonClient = periodLockRedissonClient(true),
transactionManager = transactionManager,
nowProvider = { now }
)
assertThrows(IllegalStateException::class.java) {
service.refreshLastCompletedWeekByScheduledJob(AudioRankingType.RISING)
}
Mockito.verify(transactionManager, Mockito.times(4))
.getTransaction(Mockito.any(TransactionDefinition::class.java))
Mockito.verify(transactionManager, Mockito.times(3))
.commit(Mockito.any(SimpleTransactionStatus::class.java))
Mockito.verify(transactionManager)
.rollback(Mockito.any(SimpleTransactionStatus::class.java))
}
@Test
fun shouldSkipScheduledJobWhenPeriodLockIsNotAcquired() {
val refreshService = Mockito.mock(AudioRankingSnapshotRefreshService::class.java)
val jobPort = FakeAudioRankingSnapshotJobPort()
val now = now()
val service = service(
refreshService = refreshService,
jobPort = jobPort,
redissonClient = periodLockRedissonClient(false)
) { now }
service.refreshLastCompletedWeekByScheduledJob(AudioRankingType.WEEKLY_POPULAR)
assertTrue(jobPort.jobs.isEmpty())
Mockito.verify(refreshService, Mockito.never()).refreshLastCompletedWeek(
AudioRankingType.WEEKLY_POPULAR,
now
)
}
@Test
fun shouldCreateFallbackJobWhenFallbackCountIsBelowLimit() {
val refreshService = Mockito.mock(AudioRankingSnapshotRefreshService::class.java)
val jobPort = FakeAudioRankingSnapshotJobPort()
val now = now()
val service = service(
refreshService = refreshService,
jobPort = jobPort,
redissonClient = periodLockRedissonClient(true)
) { now }
val refreshed = service.refreshLastCompletedWeekByFallback(AudioRankingType.LIKE_COUNT)
assertEquals(true, refreshed)
assertEquals(AudioRankingSnapshotJobTrigger.FALLBACK, jobPort.jobs.single().trigger)
assertEquals(AudioRankingSnapshotJobStatus.DONE, jobPort.jobs.single().status)
Mockito.verify(refreshService).refreshLastCompletedWeek(AudioRankingType.LIKE_COUNT, now)
}
@Test
fun shouldSkipFallbackJobWhenFallbackCountReachedLimit() {
val refreshService = Mockito.mock(AudioRankingSnapshotRefreshService::class.java)
val jobPort = FakeAudioRankingSnapshotJobPort()
repeat(3) {
jobPort.save(jobRecord(trigger = AudioRankingSnapshotJobTrigger.FALLBACK))
}
val now = now()
val service = service(
refreshService = refreshService,
jobPort = jobPort,
redissonClient = periodLockRedissonClient(true)
) { now }
val refreshed = service.refreshLastCompletedWeekByFallback(AudioRankingType.REVENUE)
assertEquals(false, refreshed)
assertEquals(3, jobPort.jobs.size)
Mockito.verify(refreshService, Mockito.never()).refreshLastCompletedWeek(AudioRankingType.REVENUE, now)
}
@Test
fun shouldUseTypeAndPeriodScopedLock() {
val refreshService = Mockito.mock(AudioRankingSnapshotRefreshService::class.java)
val jobPort = FakeAudioRankingSnapshotJobPort()
val redissonClient = periodLockRedissonClient(true)
val now = now()
val service = service(refreshService = refreshService, jobPort = jobPort, redissonClient = redissonClient) { now }
service.refreshLastCompletedWeekByScheduledJob(AudioRankingType.REVENUE)
Mockito.verify(redissonClient).getLock(
"lock:content-ranking-snapshot-refresh:REVENUE:2026-05-31T15:00:2026-06-07T15:00"
)
}
private fun service(
refreshService: AudioRankingSnapshotRefreshService,
jobPort: AudioRankingSnapshotJobPort,
redissonClient: RedissonClient,
nowProvider: () -> ZonedDateTime
): AudioRankingSnapshotJobService {
return AudioRankingSnapshotJobService(
refreshService = refreshService,
jobPort = jobPort,
redissonClient = redissonClient,
transactionManager = transactionManager(),
nowProvider = nowProvider
)
}
private fun now(): ZonedDateTime {
return ZonedDateTime.of(2026, 6, 8, 6, 0, 0, 0, ZoneId.of("Asia/Seoul"))
}
private fun periodLockRedissonClient(lockAcquired: Boolean): RedissonClient {
val redissonClient = Mockito.mock(RedissonClient::class.java)
val lock = Mockito.mock(RLock::class.java)
val lockName = "lock:content-ranking-snapshot-refresh:REVENUE:2026-05-31T15:00:2026-06-07T15:00"
Mockito.`when`(redissonClient.getLock(Mockito.anyString())).thenReturn(lock)
Mockito.`when`(redissonClient.getLock(lockName)).thenReturn(lock)
Mockito.`when`(lock.tryLock(0, -1, TimeUnit.SECONDS)).thenReturn(lockAcquired)
Mockito.`when`(lock.isHeldByCurrentThread).thenReturn(lockAcquired)
return redissonClient
}
}
private fun transactionManager(): PlatformTransactionManager {
val transactionManager = Mockito.mock(PlatformTransactionManager::class.java)
Mockito.`when`(transactionManager.getTransaction(Mockito.any(TransactionDefinition::class.java)))
.thenAnswer { SimpleTransactionStatus() }
return transactionManager
}
private fun jobRecord(
rankingType: AudioRankingType = AudioRankingType.REVENUE,
trigger: AudioRankingSnapshotJobTrigger = AudioRankingSnapshotJobTrigger.SCHEDULED,
status: AudioRankingSnapshotJobStatus = AudioRankingSnapshotJobStatus.PENDING
): AudioRankingSnapshotJobRecord {
return AudioRankingSnapshotJobRecord(
rankingType = rankingType,
aggregationStartAtUtc = LocalDateTime.of(2026, 5, 31, 15, 0),
aggregationEndAtUtc = LocalDateTime.of(2026, 6, 7, 15, 0),
visibleFromAtUtc = LocalDateTime.of(2026, 6, 8, 0, 0),
trigger = trigger,
status = status,
lastError = null,
processingStartedAt = null,
processedAt = null
)
}
private class FakeAudioRankingSnapshotJobPort : AudioRankingSnapshotJobPort {
val jobs = mutableListOf<AudioRankingSnapshotJobRecord>()
private var nextId = 1L
override fun save(job: AudioRankingSnapshotJobRecord): AudioRankingSnapshotJobRecord {
val saved = job.copy(id = job.id ?: nextId++)
jobs.add(saved)
return saved
}
override fun findById(jobId: Long): AudioRankingSnapshotJobRecord? = jobs.firstOrNull { it.id == jobId }
override fun findByRankingTypeAndPeriodAndStatuses(
rankingType: AudioRankingType,
aggregationStartAtUtc: LocalDateTime,
aggregationEndAtUtc: LocalDateTime,
statuses: List<AudioRankingSnapshotJobStatus>
): List<AudioRankingSnapshotJobRecord> {
return jobs.filter {
it.rankingType == rankingType &&
it.aggregationStartAtUtc == aggregationStartAtUtc &&
it.aggregationEndAtUtc == aggregationEndAtUtc &&
it.status in statuses
}
}
override fun countByRankingTypeAndPeriodAndTrigger(
rankingType: AudioRankingType,
aggregationStartAtUtc: LocalDateTime,
aggregationEndAtUtc: LocalDateTime,
trigger: AudioRankingSnapshotJobTrigger
): Long {
return jobs.count {
it.rankingType == rankingType &&
it.aggregationStartAtUtc == aggregationStartAtUtc &&
it.aggregationEndAtUtc == aggregationEndAtUtc &&
it.trigger == trigger
}.toLong()
}
override fun markProcessing(jobId: Long, processingStartedAt: LocalDateTime): AudioRankingSnapshotJobRecord? {
return update(jobId) {
it.copy(
status = AudioRankingSnapshotJobStatus.PROCESSING,
processingStartedAt = processingStartedAt
)
}
}
override fun markDone(jobId: Long, processedAt: LocalDateTime): AudioRankingSnapshotJobRecord? {
return update(jobId) {
it.copy(
status = AudioRankingSnapshotJobStatus.DONE,
processedAt = processedAt,
lastError = null
)
}
}
override fun markFailed(jobId: Long, processedAt: LocalDateTime, lastError: String?): AudioRankingSnapshotJobRecord? {
return update(jobId) {
it.copy(
status = AudioRankingSnapshotJobStatus.FAILED,
processedAt = processedAt,
lastError = lastError
)
}
}
private fun update(
jobId: Long,
transform: (AudioRankingSnapshotJobRecord) -> AudioRankingSnapshotJobRecord
): AudioRankingSnapshotJobRecord? {
val index = jobs.indexOfFirst { it.id == jobId }
if (index < 0) return null
val updated = transform(jobs[index])
jobs[index] = updated
return updated
}
}

View File

@@ -0,0 +1,267 @@
package kr.co.vividnext.sodalive.v2.content.ranking.application
import kr.co.vividnext.sodalive.v2.content.ranking.domain.AudioRankingSnapshotCandidate
import kr.co.vividnext.sodalive.v2.content.ranking.domain.AudioRankingType
import kr.co.vividnext.sodalive.v2.content.ranking.port.out.AudioRankingAggregationPort
import kr.co.vividnext.sodalive.v2.content.ranking.port.out.AudioRankingSnapshotPort
import kr.co.vividnext.sodalive.v2.content.ranking.port.out.AudioRankingSnapshotRecord
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Test
import java.time.LocalDateTime
import java.time.ZoneId
import java.time.ZonedDateTime
class AudioRankingSnapshotRefreshServiceTest {
@Test
fun shouldStoreTopTwentyByScoreReleaseDateAndContentId() {
val aggregationPort = FakeAudioRankingAggregationPort()
val snapshotPort = FakeAudioRankingSnapshotPort()
val service = service(aggregationPort = aggregationPort, snapshotPort = snapshotPort)
aggregationPort.weeklyCandidates = (1L..18L).map { contentId ->
candidate(contentId = contentId, salesCount = 100 - contentId, releaseDate = LocalDateTime.of(2026, 6, 1, 0, 0))
} + listOf(
candidate(contentId = 19L, salesCount = 10, releaseDate = LocalDateTime.of(2026, 6, 2, 0, 0)),
candidate(contentId = 20L, salesCount = 10, releaseDate = LocalDateTime.of(2026, 6, 3, 0, 0)),
candidate(contentId = 21L, salesCount = 10, releaseDate = LocalDateTime.of(2026, 6, 3, 0, 0)),
candidate(contentId = 22L, salesCount = 1, releaseDate = LocalDateTime.of(2026, 6, 4, 0, 0))
)
service.refreshLastCompletedWeek(AudioRankingType.WEEKLY_POPULAR, now())
assertEquals(20, snapshotPort.snapshots.size)
assertEquals(listOf(21L, 20L), snapshotPort.snapshots.takeLast(2).map { it.contentId })
assertEquals((1..20).toList(), snapshotPort.snapshots.map { it.rank })
}
@Test
fun shouldUseLastCompletedWeekUtcRangeAndVisibleFromAt() {
val aggregationPort = FakeAudioRankingAggregationPort()
val snapshotPort = FakeAudioRankingSnapshotPort()
val service = service(aggregationPort = aggregationPort, snapshotPort = snapshotPort)
aggregationPort.risingCandidates = listOf(candidate(contentId = 1L, viewCount = 20, previousViewCount = 10))
service.refreshLastCompletedWeek(AudioRankingType.RISING, now())
assertEquals(LocalDateTime.of(2026, 5, 31, 15, 0), aggregationPort.startInclusiveUtc)
assertEquals(LocalDateTime.of(2026, 6, 7, 15, 0), aggregationPort.endExclusiveUtc)
assertEquals(AudioRankingType.RISING, snapshotPort.rankingType)
assertEquals(LocalDateTime.of(2026, 5, 31, 15, 0), snapshotPort.aggregationStartAtUtc)
assertEquals(LocalDateTime.of(2026, 6, 7, 15, 0), snapshotPort.aggregationEndAtUtc)
assertEquals(LocalDateTime.of(2026, 6, 8, 0, 0), snapshotPort.visibleFromAtUtc)
assertEquals(LocalDateTime.of(2026, 6, 8, 0, 0), snapshotPort.snapshots.single().visibleFromAtUtc)
}
@Test
fun shouldNormalizeRisingScoresByPaidAndFreeGroups() {
val aggregationPort = FakeAudioRankingAggregationPort()
val snapshotPort = FakeAudioRankingSnapshotPort()
val service = service(aggregationPort = aggregationPort, snapshotPort = snapshotPort)
aggregationPort.risingCandidates = listOf(
candidate(contentId = 1L, salesCount = 6, previousSalesCount = 3),
candidate(contentId = 2L, salesCount = 3, previousSalesCount = 3),
candidate(contentId = 3L, viewCount = 20, previousViewCount = 10),
candidate(contentId = 4L, viewCount = 10, previousViewCount = 10)
)
service.refreshLastCompletedWeek(AudioRankingType.RISING, now())
val scoresByContentId = snapshotPort.snapshots.associate { it.contentId to it.finalScore }
assertEquals(100.0, scoresByContentId.getValue(1L), 0.0001)
assertEquals(0.0, scoresByContentId.getValue(2L), 0.0001)
assertEquals(100.0, scoresByContentId.getValue(3L), 0.0001)
assertEquals(0.0, scoresByContentId.getValue(4L), 0.0001)
}
@Test
fun shouldUseAggregationPortForMetricRankingTypes() {
val aggregationPort = FakeAudioRankingAggregationPort()
val snapshotPort = FakeAudioRankingSnapshotPort()
val service = service(aggregationPort = aggregationPort, snapshotPort = snapshotPort)
aggregationPort.revenueCandidates = listOf(candidate(contentId = 1L, finalScore = 10.0))
service.refreshLastCompletedWeek(AudioRankingType.REVENUE, now())
assertEquals(AudioRankingType.REVENUE, aggregationPort.metricRankingType)
assertEquals(listOf(1L), snapshotPort.snapshots.map { it.contentId })
}
@Test
fun shouldSortMetricTieScoresByReleaseDateAndContentId() {
val aggregationPort = FakeAudioRankingAggregationPort()
val snapshotPort = FakeAudioRankingSnapshotPort()
val service = service(aggregationPort = aggregationPort, snapshotPort = snapshotPort)
aggregationPort.revenueCandidates = listOf(
candidate(contentId = 1L, finalScore = 10.0, releaseDate = LocalDateTime.of(2026, 6, 1, 0, 0)),
candidate(contentId = 2L, finalScore = 10.0, releaseDate = LocalDateTime.of(2026, 6, 2, 0, 0)),
candidate(contentId = 3L, finalScore = 10.0, releaseDate = LocalDateTime.of(2026, 6, 2, 0, 0))
)
service.refreshLastCompletedWeek(AudioRankingType.REVENUE, now())
assertEquals(listOf(3L, 2L, 1L), snapshotPort.snapshots.map { it.contentId })
}
@Test
fun shouldStoreGlobalTopTwentyAndSafeTopTwentyCandidates() {
val aggregationPort = FakeAudioRankingAggregationPort()
val snapshotPort = FakeAudioRankingSnapshotPort()
val service = service(aggregationPort = aggregationPort, snapshotPort = snapshotPort)
aggregationPort.revenueCandidates = (1L..20L).map { contentId ->
candidate(contentId = contentId, finalScore = (100 - contentId).toDouble(), isAdult = true)
} + (21L..40L).map { contentId ->
candidate(contentId = contentId, finalScore = (100 - contentId).toDouble(), isAdult = false)
}
service.refreshLastCompletedWeek(AudioRankingType.REVENUE, now())
assertEquals(40, snapshotPort.snapshots.size)
assertEquals((1L..20L).toList(), snapshotPort.snapshots.take(20).map { it.contentId })
assertEquals((21L..40L).toList(), snapshotPort.snapshots.drop(20).map { it.contentId })
assertEquals((1..40).toList(), snapshotPort.snapshots.map { it.rank })
}
private fun service(
aggregationPort: AudioRankingAggregationPort = FakeAudioRankingAggregationPort(),
snapshotPort: AudioRankingSnapshotPort = FakeAudioRankingSnapshotPort()
): AudioRankingSnapshotRefreshService {
return AudioRankingSnapshotRefreshService(
aggregationPort = aggregationPort,
snapshotPort = snapshotPort
)
}
private fun now(): ZonedDateTime {
return ZonedDateTime.of(2026, 6, 8, 6, 0, 0, 0, ZoneId.of("Asia/Seoul"))
}
private fun candidate(
contentId: Long,
finalScore: Double = 0.0,
salesCount: Long = 0,
previousSalesCount: Long = 0,
viewCount: Long = 0,
previousViewCount: Long = 0,
releaseDate: LocalDateTime = LocalDateTime.of(2026, 6, 1, 0, 0),
isAdult: Boolean = false
): AudioRankingSnapshotCandidate {
return AudioRankingSnapshotCandidate(
contentId = contentId,
title = "audio-$contentId",
creatorMemberId = 100L + contentId,
creatorNickname = "creator-$contentId",
coverImageUrl = "cover-$contentId.png",
releaseDate = releaseDate,
isAdult = isAdult,
isPaid = salesCount > 0,
finalScore = finalScore,
salesCount = salesCount,
previousSalesCount = previousSalesCount,
viewCount = viewCount,
previousViewCount = previousViewCount
)
}
}
private class FakeAudioRankingAggregationPort : AudioRankingAggregationPort {
var weeklyCandidates: List<AudioRankingSnapshotCandidate> = emptyList()
var risingCandidates: List<AudioRankingSnapshotCandidate> = emptyList()
var revenueCandidates: List<AudioRankingSnapshotCandidate> = emptyList()
var salesCountCandidates: List<AudioRankingSnapshotCandidate> = emptyList()
var commentCountCandidates: List<AudioRankingSnapshotCandidate> = emptyList()
var likeCountCandidates: List<AudioRankingSnapshotCandidate> = emptyList()
var startInclusiveUtc: LocalDateTime? = null
var endExclusiveUtc: LocalDateTime? = null
var metricRankingType: AudioRankingType? = null
override fun aggregateWeeklyPopularCandidates(
startInclusiveUtc: LocalDateTime,
endExclusiveUtc: LocalDateTime
): List<AudioRankingSnapshotCandidate> {
this.startInclusiveUtc = startInclusiveUtc
this.endExclusiveUtc = endExclusiveUtc
return weeklyCandidates
}
override fun aggregateRisingCandidates(
startInclusiveUtc: LocalDateTime,
endExclusiveUtc: LocalDateTime
): List<AudioRankingSnapshotCandidate> {
this.startInclusiveUtc = startInclusiveUtc
this.endExclusiveUtc = endExclusiveUtc
return risingCandidates
}
override fun aggregateRevenueCandidates(
startInclusiveUtc: LocalDateTime,
endExclusiveUtc: LocalDateTime
): List<AudioRankingSnapshotCandidate> {
this.startInclusiveUtc = startInclusiveUtc
this.endExclusiveUtc = endExclusiveUtc
metricRankingType = AudioRankingType.REVENUE
return revenueCandidates
}
override fun aggregateSalesCountCandidates(
startInclusiveUtc: LocalDateTime,
endExclusiveUtc: LocalDateTime
): List<AudioRankingSnapshotCandidate> {
this.startInclusiveUtc = startInclusiveUtc
this.endExclusiveUtc = endExclusiveUtc
metricRankingType = AudioRankingType.SALES_COUNT
return salesCountCandidates
}
override fun aggregateCommentCountCandidates(
startInclusiveUtc: LocalDateTime,
endExclusiveUtc: LocalDateTime
): List<AudioRankingSnapshotCandidate> {
this.startInclusiveUtc = startInclusiveUtc
this.endExclusiveUtc = endExclusiveUtc
metricRankingType = AudioRankingType.COMMENT_COUNT
return commentCountCandidates
}
override fun aggregateLikeCountCandidates(
startInclusiveUtc: LocalDateTime,
endExclusiveUtc: LocalDateTime
): List<AudioRankingSnapshotCandidate> {
this.startInclusiveUtc = startInclusiveUtc
this.endExclusiveUtc = endExclusiveUtc
metricRankingType = AudioRankingType.LIKE_COUNT
return likeCountCandidates
}
}
private class FakeAudioRankingSnapshotPort : AudioRankingSnapshotPort {
val snapshots = mutableListOf<AudioRankingSnapshotRecord>()
var rankingType: AudioRankingType? = null
var aggregationStartAtUtc: LocalDateTime? = null
var aggregationEndAtUtc: LocalDateTime? = null
var visibleFromAtUtc: LocalDateTime? = null
override fun findLatestVisibleSnapshots(
rankingType: AudioRankingType,
nowUtc: LocalDateTime
): List<AudioRankingSnapshotRecord> = snapshots
override fun findPreviousVisibleSnapshots(
rankingType: AudioRankingType,
currentAggregationStartAtUtc: LocalDateTime,
nowUtc: LocalDateTime
): List<AudioRankingSnapshotRecord> = snapshots
override fun replaceSnapshots(
rankingType: AudioRankingType,
aggregationStartAtUtc: LocalDateTime,
aggregationEndAtUtc: LocalDateTime,
visibleFromAtUtc: LocalDateTime,
newSnapshots: List<AudioRankingSnapshotRecord>
) {
this.rankingType = rankingType
this.aggregationStartAtUtc = aggregationStartAtUtc
this.aggregationEndAtUtc = aggregationEndAtUtc
this.visibleFromAtUtc = visibleFromAtUtc
snapshots.clear()
snapshots.addAll(newSnapshots)
}
}

View File

@@ -0,0 +1,48 @@
package kr.co.vividnext.sodalive.v2.content.ranking.domain
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.DisplayName
import org.junit.jupiter.api.Test
import java.time.LocalDateTime
import java.time.ZoneId
import java.time.ZonedDateTime
class AudioRankingPeriodPolicyTest {
private val policy = AudioRankingPeriodPolicy()
@Test
@DisplayName("임의의 수요일 KST 기준 지난 주 월요일 00시 이상 이번 주 월요일 00시 미만 기간을 산출한다")
fun shouldResolveLastCompletedWeekFromWednesdayByKstMonday() {
val now = ZonedDateTime.of(2026, 6, 10, 14, 30, 0, 0, ZoneId.of("Asia/Seoul"))
val period = policy.resolveLastCompletedWeek(now)
assertEquals(LocalDateTime.of(2026, 6, 1, 0, 0), period.startInclusiveKst)
assertEquals(LocalDateTime.of(2026, 6, 8, 0, 0), period.endExclusiveKst)
}
@Test
@DisplayName("기간 산출은 서버 timezone UTC와 무관하게 KST 기준으로 계산한다")
fun shouldResolveLastCompletedWeekIndependentOfServerTimezone() {
val now = ZonedDateTime.of(2026, 6, 8, 5, 30, 0, 0, ZoneId.of("UTC"))
val period = policy.resolveLastCompletedWeek(now)
assertEquals(LocalDateTime.of(2026, 6, 1, 0, 0), period.startInclusiveKst)
assertEquals(LocalDateTime.of(2026, 6, 8, 0, 0), period.endExclusiveKst)
}
@Test
@DisplayName("KST 기간은 DB 조회용 UTC LocalDateTime 이상/미만 조건으로 변환한다")
fun shouldConvertKstPeriodToUtcRange() {
val period = AudioRankingPeriod(
startInclusiveKst = LocalDateTime.of(2026, 6, 1, 0, 0),
endExclusiveKst = LocalDateTime.of(2026, 6, 8, 0, 0)
)
val utcRange = policy.toUtcRange(period)
assertEquals(LocalDateTime.of(2026, 5, 31, 15, 0), utcRange.startInclusiveUtc)
assertEquals(LocalDateTime.of(2026, 6, 7, 15, 0), utcRange.endExclusiveUtc)
}
}

View File

@@ -0,0 +1,42 @@
package kr.co.vividnext.sodalive.v2.content.ranking.domain
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertFalse
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.DisplayName
import org.junit.jupiter.api.Test
import java.time.LocalDateTime
class AudioRankingSchedulePolicyTest {
private val policy = AudioRankingSchedulePolicy()
@Test
@DisplayName("집계 종료일과 같은 KST 날짜 09시를 UTC LocalDateTime으로 변환해 공개 시각을 산출한다")
fun shouldResolveVisibleFromAtAsSameKstDateNineAmConvertedToUtc() {
val aggregationEndAtKst = LocalDateTime.of(2026, 6, 8, 0, 0)
val visibleFromAtUtc = policy.resolveVisibleFromAt(aggregationEndAtKst)
assertEquals(LocalDateTime.of(2026, 6, 8, 0, 0), visibleFromAtUtc)
}
@Test
@DisplayName("09시 KST 이전에는 새 스냅샷을 공개하지 않는다")
fun shouldNotBeVisibleBeforeNineAmKst() {
val visibleFromAtUtc = LocalDateTime.of(2026, 6, 8, 0, 0)
val nowUtc = LocalDateTime.of(2026, 6, 7, 23, 59)
val visible = policy.isVisible(visibleFromAtUtc, nowUtc)
assertFalse(visible)
}
@Test
@DisplayName("09시 KST 경계와 이후에는 새 스냅샷을 공개한다")
fun shouldBeVisibleAtAndAfterNineAmKst() {
val visibleFromAtUtc = LocalDateTime.of(2026, 6, 8, 0, 0)
assertTrue(policy.isVisible(visibleFromAtUtc, LocalDateTime.of(2026, 6, 8, 0, 0)))
assertTrue(policy.isVisible(visibleFromAtUtc, LocalDateTime.of(2026, 6, 8, 0, 1)))
}
}

View File

@@ -0,0 +1,171 @@
package kr.co.vividnext.sodalive.v2.content.ranking.domain
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.DisplayName
import org.junit.jupiter.api.Test
import java.time.LocalDateTime
class AudioRankingScorePolicyTest {
private val policy = AudioRankingScorePolicy()
private val aggregationEndAt = LocalDateTime.of(2026, 6, 22, 0, 0)
@Test
@DisplayName("유료 주간 인기 원점수는 매출 45%, 판매량 35%, 좋아요 10%, 댓글 10%로 계산한다")
fun shouldCalculatePaidWeeklyPopularRawScore() {
assertEquals(0.45, AudioRankingScorePolicy.WEEKLY_PAID_REVENUE_WEIGHT, 0.0001)
assertEquals(0.35, AudioRankingScorePolicy.WEEKLY_PAID_SALES_COUNT_WEIGHT, 0.0001)
assertEquals(0.1, AudioRankingScorePolicy.WEEKLY_PAID_LIKE_COUNT_WEIGHT, 0.0001)
assertEquals(0.1, AudioRankingScorePolicy.WEEKLY_PAID_COMMENT_COUNT_WEIGHT, 0.0001)
val score = policy.calculateWeeklyPopularScore(
revenue = 1000,
salesCount = 100,
viewCount = 0,
likeCount = 30,
commentCount = 20,
isPaid = true
)
assertEquals(490.0, score, 0.0001)
}
@Test
@DisplayName("무료 주간 인기 원점수는 조회수 50%, 좋아요 25%, 댓글 25%로 계산한다")
fun shouldCalculateFreeWeeklyPopularRawScore() {
assertEquals(0.5, AudioRankingScorePolicy.WEEKLY_FREE_VIEW_COUNT_WEIGHT, 0.0001)
assertEquals(0.25, AudioRankingScorePolicy.WEEKLY_FREE_LIKE_COUNT_WEIGHT, 0.0001)
assertEquals(0.25, AudioRankingScorePolicy.WEEKLY_FREE_COMMENT_COUNT_WEIGHT, 0.0001)
val score = policy.calculateWeeklyPopularScore(
revenue = 0,
salesCount = 0,
viewCount = 200,
likeCount = 20,
commentCount = 8,
isPaid = false
)
assertEquals(107.0, score, 0.0001)
}
@Test
@DisplayName("정규화 점수는 그룹 최고 점수 기준 0~100으로 계산하고 최고 점수가 0 이하면 0으로 처리한다")
fun shouldNormalizeScoreToZeroToOneHundred() {
assertEquals(100.0, policy.normalizeScore(currentScore = 80.0, maxScore = 80.0), 0.0001)
assertEquals(25.0, policy.normalizeScore(currentScore = 20.0, maxScore = 80.0), 0.0001)
assertEquals(0.0, policy.normalizeScore(currentScore = 20.0, maxScore = 0.0), 0.0001)
assertEquals(0.0, policy.normalizeScore(currentScore = -20.0, maxScore = -10.0), 0.0001)
}
@Test
@DisplayName("유료 지금 뜨는 중 점수는 판매/조회 콘텐츠 성장 점수와 좋아요/댓글 증가율 및 신규 부스트로 계산한다")
fun shouldCalculatePaidRisingScore() {
assertEquals(0.5, AudioRankingScorePolicy.RISING_CONTENT_GROWTH_SCORE_WEIGHT, 0.0001)
assertEquals(0.25, AudioRankingScorePolicy.RISING_LIKE_GROWTH_WEIGHT, 0.0001)
assertEquals(0.25, AudioRankingScorePolicy.RISING_COMMENT_GROWTH_WEIGHT, 0.0001)
assertEquals(0.6, AudioRankingScorePolicy.RISING_PAID_SALES_GROWTH_WEIGHT, 0.0001)
assertEquals(0.4, AudioRankingScorePolicy.RISING_PAID_VIEW_GROWTH_WEIGHT, 0.0001)
val score = policy.calculateRisingScore(
recentSalesCount = 9,
previousSalesCount = 3,
recentViewCount = 30,
previousViewCount = 10,
recentLikeCount = 8,
previousLikeCount = 4,
recentCommentCount = 6,
previousCommentCount = 3,
releaseDate = aggregationEndAt.minusDays(2),
aggregationEndAt = aggregationEndAt,
isPaid = true
)
assertEquals(2.25, score, 0.0001)
}
@Test
@DisplayName("무료 지금 뜨는 중 점수는 조회/좋아요/댓글 콘텐츠 성장 점수와 좋아요/댓글 증가율 및 신규 부스트로 계산한다")
fun shouldCalculateFreeRisingScore() {
assertEquals(0.5, AudioRankingScorePolicy.RISING_FREE_VIEW_GROWTH_WEIGHT, 0.0001)
assertEquals(0.25, AudioRankingScorePolicy.RISING_FREE_LIKE_GROWTH_WEIGHT, 0.0001)
assertEquals(0.25, AudioRankingScorePolicy.RISING_FREE_COMMENT_GROWTH_WEIGHT, 0.0001)
val score = policy.calculateRisingScore(
recentSalesCount = 0,
previousSalesCount = 0,
recentViewCount = 30,
previousViewCount = 10,
recentLikeCount = 8,
previousLikeCount = 4,
recentCommentCount = 6,
previousCommentCount = 3,
releaseDate = aggregationEndAt.minusDays(8),
aggregationEndAt = aggregationEndAt,
isPaid = false
)
assertEquals(1.4375, score, 0.0001)
}
@Test
@DisplayName("최소 기준 미만 지표만 증가율 반영값을 0으로 처리한다")
fun shouldApplyMinimumThresholdPerMetric() {
assertEquals(10, AudioRankingScorePolicy.RISING_VIEW_COUNT_THRESHOLD)
assertEquals(3, AudioRankingScorePolicy.RISING_LIKE_COUNT_THRESHOLD)
assertEquals(3, AudioRankingScorePolicy.RISING_COMMENT_COUNT_THRESHOLD)
assertEquals(3, AudioRankingScorePolicy.RISING_SALES_COUNT_THRESHOLD)
assertEquals(0.0, policy.applyMinimumThreshold(growthRate = 5.0, recentCount = 9, minimumThreshold = 10), 0.0001)
assertEquals(5.0, policy.applyMinimumThreshold(growthRate = 5.0, recentCount = 10, minimumThreshold = 10), 0.0001)
val score = policy.calculateRisingScore(
recentSalesCount = 2,
previousSalesCount = 1,
recentViewCount = 9,
previousViewCount = 1,
recentLikeCount = 2,
previousLikeCount = 1,
recentCommentCount = 3,
previousCommentCount = 1,
releaseDate = aggregationEndAt.minusDays(20),
aggregationEndAt = aggregationEndAt,
isPaid = true
)
assertEquals(0.5, score, 0.0001)
}
@Test
@DisplayName("최소 기준을 통과한 지표의 음수 증가율은 지금 뜨는 중 점수에 그대로 반영한다")
fun shouldPreserveNegativeGrowthWhenThresholdPasses() {
val score = policy.calculateRisingScore(
recentSalesCount = 3,
previousSalesCount = 6,
recentViewCount = 10,
previousViewCount = 20,
recentLikeCount = 3,
previousLikeCount = 6,
recentCommentCount = 3,
previousCommentCount = 6,
releaseDate = aggregationEndAt.minusDays(20),
aggregationEndAt = aggregationEndAt,
isPaid = true
)
assertEquals(-0.5, score, 0.0001)
}
@Test
@DisplayName("신규 콘텐츠 부스트는 집계 종료일 기준 3일/7일/14일 경계를 포함해 적용한다")
fun shouldReturnReleaseBoostByBoundaries() {
assertEquals(1.5, AudioRankingScorePolicy.RELEASE_BOOST_WITHIN_THREE_DAYS, 0.0001)
assertEquals(1.3, AudioRankingScorePolicy.RELEASE_BOOST_WITHIN_SEVEN_DAYS, 0.0001)
assertEquals(1.15, AudioRankingScorePolicy.RELEASE_BOOST_WITHIN_FOURTEEN_DAYS, 0.0001)
assertEquals(1.0, AudioRankingScorePolicy.RELEASE_BOOST_DEFAULT, 0.0001)
assertEquals(1.5, policy.releaseBoost(aggregationEndAt.minusDays(3), aggregationEndAt), 0.0001)
assertEquals(1.3, policy.releaseBoost(aggregationEndAt.minusDays(7), aggregationEndAt), 0.0001)
assertEquals(1.15, policy.releaseBoost(aggregationEndAt.minusDays(14), aggregationEndAt), 0.0001)
assertEquals(1.0, policy.releaseBoost(aggregationEndAt.minusDays(15), aggregationEndAt), 0.0001)
}
}