Compare commits
24 Commits
2a7d74b018
...
d5f4dc529a
| Author | SHA1 | Date | |
|---|---|---|---|
| d5f4dc529a | |||
| 94cfa3ba50 | |||
| 9f24851835 | |||
| cf29600ad3 | |||
| 7ec19e3c8c | |||
| abeffb0a4f | |||
| 90c5149df8 | |||
| 6fabcca03f | |||
| cd43b40e44 | |||
| 4d76958409 | |||
| f34962b285 | |||
| 4e97364a14 | |||
| ee32696c6c | |||
| 453d914f44 | |||
| f1e03706c7 | |||
| 25c48a7606 | |||
| e4706d6699 | |||
| dc93f9845b | |||
| d62ce35912 | |||
| af5f250abe | |||
| 2c2607b6d0 | |||
| c9d7399f0e | |||
| 87c51d6087 | |||
| d44f890391 |
@@ -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);
|
||||
513
docs/20260623_메인_콘텐츠_랭킹_탭_API/plan-task.md
Normal file
513
docs/20260623_메인_콘텐츠_랭킹_탭_API/plan-task.md
Normal 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 완료 상태와 검증 기록만 갱신했다.
|
||||
356
docs/20260623_메인_콘텐츠_랭킹_탭_API/prd.md
Normal file
356
docs/20260623_메인_콘텐츠_랭킹_탭_API/prd.md
Normal 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으로 계산하고 좋아요/댓글 증가율은 점수에 반영한다. 콘텐츠 전체 후보 제외가 의도라면 구현 전에 수정해야 한다.
|
||||
@@ -103,6 +103,7 @@ class SecurityConfig(
|
||||
.antMatchers(HttpMethod.POST, "/charge/payverse/webhook").permitAll()
|
||||
.antMatchers(HttpMethod.GET, "/api/v2/home/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()
|
||||
// 페이지네이션 하위 경로(/lives, /debut-creators 등)는 인증 필수
|
||||
.antMatchers(HttpMethod.GET, "/api/v2/home/recommendations/**").authenticated()
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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?
|
||||
)
|
||||
@@ -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
|
||||
)
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
@@ -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
|
||||
}
|
||||
@@ -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>
|
||||
}
|
||||
@@ -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>
|
||||
}
|
||||
@@ -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?
|
||||
)
|
||||
@@ -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
|
||||
)
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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"))
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)))
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user