Compare commits

...

9 Commits

26 changed files with 1920 additions and 2247 deletions

View File

@@ -0,0 +1,53 @@
-- 메인 홈 크리에이터 랭킹 스냅샷 구조 개편 운영 DB 반영 SQL
-- 목적:
-- 1. 기존 raw value 기반 크리에이터 랭킹 스냅샷 데이터를 폐기한다.
-- 2. 산식별 metric 컬럼을 제거하고, 향후 산식 변경에 강한 고정 결과 컬럼 + JSON 상세 구조로 변경한다.
-- 3. 공개 조회는 저장된 rank_no 기준으로 안정적으로 정렬한다.
--
-- 전제:
-- 1. 기존 creator_ranking_snapshot 데이터는 보존하지 않는다.
-- 2. 기존 creator_ranking_snapshot_job 이력도 fallback 3회 제한과 충돌하지 않도록 보존하지 않는다.
-- 3. 운영 반영 전 백업과 반영 후 신규 스냅샷 refresh를 별도로 수행한다.
-- 4. 현재 운영 DB에 docs/20260608_크리에이터_랭킹/create-ranking-tables.sql의
-- ranking_type, visible_from_at 보강 DDL이 반영되어 있다고 가정한다.
-- 1. 기존 데이터 삭제
truncate table creator_ranking_snapshot_job;
truncate table creator_ranking_snapshot;
alter table creator_ranking_snapshot_job
modify column trigger_type varchar(20) not null comment '실행 트리거(SCHEDULED, MANUAL, FALLBACK)';
drop index idx_creator_ranking_snapshot_period_score on creator_ranking_snapshot;
drop index idx_creator_ranking_snapshot_visible_score on creator_ranking_snapshot;
alter table creator_ranking_snapshot
add column rank_no int not null comment '최종 저장 순위(동점 tie-breaker 반영)' after profile_image_url,
add column score_policy_version varchar(80) not null comment '스냅샷 생성에 사용한 점수 정책 버전' after final_score;
alter table creator_ranking_snapshot
add column score_detail_json json not null comment '산식별 원천 지표, 순위, 점수, 가중치, tie-breaker 상세 JSON' after score_policy_version;
alter table creator_ranking_snapshot
drop column content_live_score,
drop column engagement_score,
drop column support_score,
drop column fan_loyalty_score,
drop column live_can_amount,
drop column content_purchase_can_amount,
drop column content_like_count,
drop column content_comment_count,
drop column channel_donation_can_amount,
drop column channel_donation_count,
drop column fan_talk_count,
drop column final_follower_count,
drop column follow_increase;
create unique index uk_creator_ranking_snapshot_period_rank
on creator_ranking_snapshot (ranking_type, aggregation_start_at_utc, aggregation_end_at_utc, rank_no);
create index idx_creator_ranking_snapshot_period_rank
on creator_ranking_snapshot (ranking_type, aggregation_end_at_utc desc, rank_no asc);
create index idx_creator_ranking_snapshot_visible_rank
on creator_ranking_snapshot (ranking_type, visible_from_at desc, rank_no asc);

View File

@@ -0,0 +1,382 @@
# 메인 홈 크리에이터 랭킹 개편 Implementation Plan
**Goal:** `GET /api/v2/home/rankings/creators` 공개 API 스키마는 유지하면서, 크리에이터 랭킹 fallback을 콘텐츠 랭킹과 같은 refresh 후 재조회 방식으로 변경하고, 스냅샷 산식을 순위 기반 인기 점수로 교체한다.
**Architecture:** DB는 후보 크리에이터의 raw metric만 집계한다. Kotlin application/domain 계층이 metric별 `rank()`, rank score, weighted score, 최종 점수, tie-breaker 정렬, 상위 20명 제한, `score_detail_json` 생성을 담당한다. `creator_ranking_snapshot`은 고정 결과 컬럼(`rank_no`, `final_score`, `score_policy_version`)과 산식 상세 JSON만 저장하며, 공개 조회는 `rank_no asc`를 기준으로 한다.
**Tech Stack:** Kotlin, Spring Boot 2.7.14, Java 17, Spring Data JPA/native SQL, Redisson, Jackson JSON serialization, JUnit 5, MockMvc, Gradle Wrapper
---
## 0. 구현 전 확정 사항
- 공개 API endpoint: `GET /api/v2/home/rankings/creators`
- 공개 API 응답 DTO 변경 없음: `showRankChange`, `items[].rank`, `rankChange`, `isNew`, `creatorId`, `nickname`, `profileImageUrl`
- 집계 기준: KST 기준 지난 완료 주차
- 집계 구간: 직전 월요일 `00:00:00 KST` 이상, 이번 주 월요일 `00:00:00 KST` 미만
- DB 조회 구간: 위 집계 구간을 UTC로 변환한 `created_at >= startInclusiveUtc` and `created_at < endExclusiveUtc`
- 공개 시각: 집계 종료 KST + 9시간
- 후보: `member.role = 'CREATOR'`, `member.is_active = true`
- 후보 포함 metric: `liveCanAmount`, `contentPurchaseCanAmount`, `followIncrease`, `aiChatCount >= 1`
- `followIncrease`는 음수이면 0으로 보정한다.
- metric 값이 0이면 해당 metric rank score는 0점이다.
- 최종 산식: `liveRevenueRankScore * 0.6 + contentRevenueRankScore * 0.2 + followIncreaseRankScore * 0.1 + aiChatRankScore * 0.1`
- metric 순위 점수: `max(100 - (5 * (rank - 1)), 0)`
- 동률 metric은 SQL `rank()` 의미를 따른다.
- 최종 tie-breaker: `creatorPopularityScore desc`, `creatorDebutAt desc`, `creatorId desc`
- 크리에이터 데뷔일은 기존 `ExplorerService` 기준과 맞춰 `firstLiveBeginDateTime`, `firstContentReleaseDate` 중 빠른 값으로 본다. 둘 다 없으면 tie-breaker에서 가장 뒤로 둔다.
- 스냅샷 저장은 최종 20명으로 제한하고 20위 동점자를 추가 저장하지 않는다.
- 기존 `creator_ranking_snapshot`, `creator_ranking_snapshot_job` 데이터는 폐기하고 신규 구조로 다시 쌓는다.
- 기존 old semantic score/raw metric 컬럼은 삭제한다.
- 미래 산식 변경은 고정 컬럼 추가가 꼭 필요한 경우가 아니라면 `score_policy_version``score_detail_json` schema 변경으로 처리한다.
---
## 1. 파일 구조 계획
### 문서/DDL
- Modify: `docs/20260710_메인_홈_크리에이터_랭킹_개편/prd.md`
- Modify: `docs/20260710_메인_홈_크리에이터_랭킹_개편/plan-task.md`
- Modify: `docs/20260710_메인_홈_크리에이터_랭킹_개편/alter-creator-ranking-snapshot-score-detail.sql`
### API 조립 계층
- Verify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/home/adapter/in/web/CreatorRankingController.kt`
- Verify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/home/application/HomeCreatorRankingFacade.kt`
- Verify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/home/dto/ranking/CreatorRankingResponse.kt`
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/home/CreatorRankingControllerTest.kt`
### 랭킹 domain/application
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/ranking/domain/CreatorRankingScoreSpec.kt`
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/ranking/domain/CreatorRankingScorePolicy.kt`
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/ranking/domain/CreatorRankingSnapshotCandidate.kt`
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/ranking/domain/CreatorRankingScoreDetail.kt`
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/ranking/application/CreatorRankingSnapshotRefreshService.kt`
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/ranking/application/CreatorRankingQueryService.kt`
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/ranking/application/CreatorRankingSnapshotJobService.kt`
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/ranking/domain/CreatorRankingScorePolicyTest.kt`
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/ranking/application/CreatorRankingSnapshotRefreshServiceTest.kt`
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/ranking/application/CreatorRankingQueryServiceTest.kt`
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/ranking/application/CreatorRankingSnapshotJobServiceTest.kt`
### 랭킹 port/persistence/scheduler
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/ranking/port/out/CreatorRankingAggregationPort.kt`
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/ranking/port/out/CreatorRankingSnapshotPort.kt`
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/ranking/port/out/CreatorRankingSnapshotJobPort.kt`
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/ranking/adapter/out/persistence/CreatorRankingSnapshot.kt`
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/ranking/adapter/out/persistence/CreatorRankingSnapshotRepository.kt`
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/ranking/adapter/out/persistence/DefaultCreatorRankingSnapshotRepository.kt`
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/ranking/adapter/out/persistence/DefaultCreatorRankingAggregationRepository.kt`
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/ranking/adapter/out/persistence/CreatorRankingSnapshotJob.kt`
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/ranking/adapter/out/persistence/CreatorRankingSnapshotJobRepository.kt`
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/ranking/adapter/out/persistence/DefaultCreatorRankingSnapshotJobRepository.kt`
- Verify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/ranking/adapter/out/scheduler/CreatorRankingSnapshotScheduler.kt`
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/ranking/adapter/out/persistence/DefaultCreatorRankingSnapshotRepositoryTest.kt`
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/ranking/adapter/out/persistence/DefaultCreatorRankingAggregationRepositoryTest.kt`
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/ranking/adapter/out/persistence/DefaultCreatorRankingSnapshotJobRepositoryTest.kt`
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/ranking/adapter/out/scheduler/CreatorRankingSnapshotSchedulerTest.kt`
---
## 2. `score_detail_json` schema 초안
구현 시 `CreatorRankingScoreDetail`에서 schema를 고정한다. JSON 문자열을 직접 이어 붙이지 않고 Jackson/ObjectMapper 또는 JPA converter를 사용한다.
```json
{
"scorePolicyVersion": "CREATOR_WEEKLY_POPULARITY_V2",
"formula": "liveRevenueRankScore * 0.6 + contentRevenueRankScore * 0.2 + followIncreaseRankScore * 0.1 + aiChatRankScore * 0.1",
"metrics": {
"liveRevenue": {
"rawValue": 12000,
"rank": 1,
"rankScore": 100.0,
"weight": 0.6,
"weightedScore": 60.0
},
"contentRevenue": {
"rawValue": 3000,
"rank": 4,
"rankScore": 85.0,
"weight": 0.2,
"weightedScore": 17.0
},
"followIncrease": {
"rawValue": 5,
"rank": 8,
"rankScore": 65.0,
"weight": 0.1,
"weightedScore": 6.5
},
"aiChat": {
"rawValue": 10,
"rank": 2,
"rankScore": 95.0,
"weight": 0.1,
"weightedScore": 9.5
}
},
"tieBreakers": {
"creatorDebutAt": "2026-06-01T00:00:00",
"creatorId": 123
}
}
```
---
### Phase 1: 점수 정책과 score detail 모델
- [x] **Task 1.1: 순위 기반 점수 정책으로 교체**
- Files:
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/ranking/domain/CreatorRankingScoreSpec.kt`
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/ranking/domain/CreatorRankingScorePolicy.kt`
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/ranking/domain/CreatorRankingScorePolicyTest.kt`
- RED: rank 1/2/20/21, 동률 rank, metric 값 0일 때 0점, 최종 가중합 `60/20/10/10`, `followIncrease` 음수 보정 테스트를 먼저 작성한다.
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.ranking.domain.CreatorRankingScorePolicyTest`
- GREEN: raw value 기반 기존 score 함수를 순위 기반 함수로 교체하거나 신규 함수로 대체한다.
- REFACTOR: weight 상수명은 신규 산식 의미(`LIVE_REVENUE_WEIGHT`, `CONTENT_REVENUE_WEIGHT`, `FOLLOW_INCREASE_WEIGHT`, `AI_CHAT_WEIGHT`)와 일치시킨다.
- 기대 결과: 산식 경계값과 가중치가 domain test로 고정된다.
- [x] **Task 1.2: 후보/스냅샷 domain model을 신규 metric 중심으로 변경**
- Files:
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/ranking/domain/CreatorRankingSnapshotCandidate.kt`
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/ranking/domain/CreatorRankingScoreDetail.kt`
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/ranking/port/out/CreatorRankingSnapshotPort.kt`
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/ranking/domain/CreatorRankingScorePolicyTest.kt`
- RED: `CreatorRankingScoreDetail`이 PRD의 JSON schema에 맞는 metric key, raw value, rank, rank score, weight, weighted score, tie-breaker 값을 보존하는 테스트를 작성한다.
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.ranking.domain.CreatorRankingScorePolicyTest`
- GREEN: 후보에는 `creatorDebutAt`, `liveCanAmount`, `contentPurchaseCanAmount`, `followIncrease`, `aiChatCount`만 산식 raw metric으로 남긴다.
- REFACTOR: old semantic 필드(`contentLiveScore`, `engagementScore`, `supportScore`, `fanLoyaltyScore`, old raw counts)는 domain/port record에서 제거한다.
- 기대 결과: 신규 산식에 필요한 데이터 계약만 domain/port에 남는다.
- [x] **Task 1.3: 후보 목록에 rank, score, tie-breaker, score detail을 적용하는 assembler 작성**
- Files:
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/ranking/application/CreatorRankingSnapshotRefreshService.kt`
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/ranking/application/CreatorRankingQueryService.kt`
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/ranking/application/CreatorRankingSnapshotRefreshServiceTest.kt`
- RED: 여러 후보의 metric 동률, 최종 점수 동률, 데뷔일 동률, `creatorId` 동률 해소, 최종 점수 0 제외, 상위 20명 제한을 검증하는 service 테스트를 작성한다.
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.ranking.application.CreatorRankingSnapshotRefreshServiceTest`
- GREEN: raw 후보 목록을 받아 metric별 rank score와 `score_detail_json`을 만든 뒤 `finalScore desc`, `creatorDebutAt desc nulls last`, `creatorId desc`로 정렬하고 `rankNo`를 부여한다.
- REFACTOR: cold-start용 중복 `toSnapshotRecord` 로직이 남지 않도록 refresh/query 공통 변환 책임을 정리한다.
- 기대 결과: DB 계산 여부와 무관하게 Kotlin 정책 테스트가 최종 저장 순서를 고정한다.
### Phase 2: 스냅샷 테이블 구조와 persistence 변경
- [x] **Task 2.1: 스냅샷 entity/record를 고정 결과 컬럼 + JSON 상세 구조로 변경**
- Files:
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/ranking/adapter/out/persistence/CreatorRankingSnapshot.kt`
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/ranking/port/out/CreatorRankingSnapshotPort.kt`
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/ranking/adapter/out/persistence/DefaultCreatorRankingSnapshotRepository.kt`
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/ranking/adapter/out/persistence/DefaultCreatorRankingSnapshotRepositoryTest.kt`
- RED: `rankNo`, `scorePolicyVersion`, `scoreDetailJson` 저장/조회와 old semantic 컬럼 미사용을 검증하는 repository 테스트를 작성한다.
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.ranking.adapter.out.persistence.DefaultCreatorRankingSnapshotRepositoryTest`
- GREEN: entity와 mapper에서 삭제 컬럼을 제거하고 신규 컬럼을 추가한다.
- REFACTOR: JSON은 string passthrough로 둘지 converter로 둘지 기존 코드 스타일에 맞춰 한 곳에서만 직렬화한다.
- 기대 결과: JPA entity와 DDL 방향이 일치한다.
- [x] **Task 2.2: 공개/직전 스냅샷 조회를 `rank_no asc` 기준으로 변경**
- Files:
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/ranking/adapter/out/persistence/CreatorRankingSnapshotRepository.kt`
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/ranking/adapter/out/persistence/DefaultCreatorRankingSnapshotRepository.kt`
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/ranking/adapter/out/persistence/DefaultCreatorRankingSnapshotRepositoryTest.kt`
- RED: `finalScore`가 같거나 저장 순서가 섞여 있어도 `findLatestVisibleSnapshots`, `findPreviousVisibleSnapshots`, 기간 조회가 `rankNo asc`로 반환되는 테스트를 작성한다.
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.ranking.adapter.out.persistence.DefaultCreatorRankingSnapshotRepositoryTest`
- GREEN: native query의 `order by crs.final_score desc``order by crs.rank_no asc`로 변경한다.
- REFACTOR: query service에서 동점 그룹 `shuffled()`를 제거하고 저장된 `rankNo`를 신뢰한다.
- 기대 결과: 공개 조회 순서가 저장 시점 tie-breaker와 동일하다.
- [x] **Task 2.3: DDL 문서와 코드 schema 정합성 검증**
- Files:
- Modify: `docs/20260710_메인_홈_크리에이터_랭킹_개편/alter-creator-ranking-snapshot-score-detail.sql`
- Verify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/ranking/adapter/out/persistence/CreatorRankingSnapshot.kt`
- RED: TDD 예외 사유: 운영 DB DDL은 로컬 단위 테스트로 직접 실패 재현하기 어렵다.
- 대체 검증 방법: entity 컬럼명과 DDL 컬럼명을 수동 대조하고, H2 기반 repository 테스트가 신규 컬럼 저장/조회에 성공하는지 확인한다.
- GREEN: DDL에 기존 데이터 삭제, old semantic 컬럼 drop, `rank_no`, `score_policy_version`, `score_detail_json` 추가, rank 기준 인덱스가 포함되어 있는지 확인한다.
- REFACTOR: DDL 주석에 destructive migration 전제와 신규 refresh 필요성을 명확히 유지한다.
- 기대 결과: 운영 반영 SQL과 애플리케이션 entity가 같은 테이블 계약을 가진다.
### Phase 3: 후보 metric 집계 변경
- [x] **Task 3.1: 캔 매출과 팔로우 증가 후보 집계 변경**
- Files:
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/ranking/adapter/out/persistence/DefaultCreatorRankingAggregationRepository.kt`
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/ranking/port/out/CreatorRankingAggregationPort.kt`
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/ranking/adapter/out/persistence/DefaultCreatorRankingAggregationRepositoryTest.kt`
- RED: 활성 크리에이터만 포함, `DONATION/LIVE/SPIN_ROULETTE` live can 합계, `ORDER_CONTENT` content purchase 합계, 공통 캔 조건, `followIncrease` 음수 0 보정, metric이 모두 0인 후보 제외 테스트를 작성한다.
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.ranking.adapter.out.persistence.DefaultCreatorRankingAggregationRepositoryTest`
- GREEN: 기존 좋아요/댓글/채널후원/팬Talk/최종팔로워 raw metric 집계를 제거하고 신규 metric만 반환한다.
- REFACTOR: SQL CTE 이름은 신규 metric 의미와 맞춘다.
- 기대 결과: PRD 후보 조건과 raw metric이 repository 테스트로 고정된다.
- [x] **Task 3.2: AI 채팅 개수 집계 추가**
- Files:
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/ranking/adapter/out/persistence/DefaultCreatorRankingAggregationRepository.kt`
- Verify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/recommendation/adapter/out/persistence/DefaultHomeRecommendationQueryRepository.kt`
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/ranking/adapter/out/persistence/DefaultCreatorRankingAggregationRepositoryTest.kt`
- RED: `chat_message``chat_participant.participant_type = 'CHARACTER'` 메시지만 집계하고, 비활성 message/participant/character/creator와 기간 밖 메시지를 제외하는 테스트를 작성한다.
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.ranking.adapter.out.persistence.DefaultCreatorRankingAggregationRepositoryTest`
- GREEN: 기존 AI 캐릭터 스냅샷 query의 `chat_message`, `chat_participant`, `chat_character`, creator member 관계를 재사용해 `aiChatCount`를 집계한다.
- REFACTOR: AI 채팅 집계 조건이 추천 스냅샷과 갈라지지 않도록 SQL 조건을 나란히 비교한다.
- 기대 결과: `aiChatCount >= 1`만으로도 후보가 될 수 있다.
- [x] **Task 3.3: 크리에이터 데뷔일 집계 추가**
- Files:
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/ranking/adapter/out/persistence/DefaultCreatorRankingAggregationRepository.kt`
- Verify: `src/main/kotlin/kr/co/vividnext/sodalive/explorer/ExplorerService.kt`
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/ranking/adapter/out/persistence/DefaultCreatorRankingAggregationRepositoryTest.kt`
- RED: 첫 라이브 시작 시각과 첫 콘텐츠 공개 시각 중 빠른 값을 `creatorDebutAt`으로 반환하고, 둘 다 없으면 null로 반환하는 테스트를 작성한다.
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.ranking.adapter.out.persistence.DefaultCreatorRankingAggregationRepositoryTest`
- GREEN: 기존 탐색 상세 화면의 데뷔일 계산 기준과 동일한 source를 aggregation SQL에 반영한다.
- REFACTOR: null debut tie-breaker는 service 정렬에서 nulls last로만 처리하고 SQL에 정렬 책임을 두지 않는다.
- 기대 결과: 최종 tie-breaker 입력값이 후보 집계에서 안정적으로 제공된다.
### Phase 4: 스냅샷 refresh와 scheduler/job 흐름
- [x] **Task 4.1: refresh 저장 로직을 신규 산식과 20명 제한으로 변경**
- Files:
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/ranking/application/CreatorRankingSnapshotRefreshService.kt`
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/ranking/application/CreatorRankingSnapshotRefreshServiceTest.kt`
- RED: 후보 25명 중 최종 tie-breaker 기준 상위 20명만 저장하고, 저장 record에 `rankNo` 1~20, `scorePolicyVersion`, `scoreDetailJson`, `finalScore`가 들어가는 테스트를 작성한다.
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.ranking.application.CreatorRankingSnapshotRefreshServiceTest`
- GREEN: 기존 `takeRankedBoundary` 동점자 추가 저장 로직을 제거하고 정확히 20명만 저장한다.
- REFACTOR: 홈 팔로잉 뉴스 발행 rank도 `rankNo`를 사용하게 정리한다.
- 기대 결과: 스케줄러와 fallback이 같은 refresh 결과를 저장한다.
- [x] **Task 4.2: 크리에이터 랭킹 job에 `FALLBACK` trigger와 3회 제한 추가**
- Files:
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/ranking/port/out/CreatorRankingSnapshotJobPort.kt`
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/ranking/adapter/out/persistence/CreatorRankingSnapshotJob.kt`
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/ranking/adapter/out/persistence/CreatorRankingSnapshotJobRepository.kt`
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/ranking/adapter/out/persistence/DefaultCreatorRankingSnapshotJobRepository.kt`
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/ranking/application/CreatorRankingSnapshotJobService.kt`
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/ranking/application/CreatorRankingSnapshotJobServiceTest.kt`
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/ranking/adapter/out/persistence/DefaultCreatorRankingSnapshotJobRepositoryTest.kt`
- RED: 동일 집계 기간 `FALLBACK` job이 3개 이상이면 refresh를 실행하지 않고, 3개 미만이면 `FALLBACK` job을 저장해 `PROCESSING -> DONE/FAILED`로 전이하는 테스트를 작성한다.
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.ranking.application.CreatorRankingSnapshotJobServiceTest`
- GREEN: 콘텐츠 랭킹 `AudioRankingSnapshotJobService.refreshLastCompletedWeekByFallback` 흐름을 크리에이터 랭킹에 맞게 적용한다.
- REFACTOR: lock name은 기간과 ranking type이 드러나도록 유지하고 lock 획득 실패는 정상 skip으로 처리한다.
- 기대 결과: fallback 반복 실행 제한과 job 이력이 콘텐츠 랭킹과 같은 패턴을 따른다.
- [x] **Task 4.3: scheduler/manual job 흐름 회귀 확인**
- Files:
- Verify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/ranking/adapter/out/scheduler/CreatorRankingSnapshotScheduler.kt`
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/ranking/application/CreatorRankingSnapshotJobService.kt`
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/ranking/adapter/out/scheduler/CreatorRankingSnapshotSchedulerTest.kt`
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/ranking/application/CreatorRankingSnapshotJobServiceTest.kt`
- RED: scheduled job은 `SCHEDULED` trigger를 유지하고 fallback 제한 카운트에 포함되지 않는 테스트를 작성한다.
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.ranking.adapter.out.scheduler.CreatorRankingSnapshotSchedulerTest`
- GREEN: scheduler가 기존 실행 시각과 동일하게 job service를 호출하되 내부 refresh 결과는 신규 산식을 사용하게 한다.
- REFACTOR: manual retry/create API가 있다면 `FALLBACK` trigger와 충돌하지 않도록 enum 처리 범위를 점검한다.
- 기대 결과: 정기 생성, 수동 job, fallback job이 같은 refresh service를 공유한다.
### Phase 5: 조회 fallback과 공개 응답 유지
- [x] **Task 5.1: cold-start 직접 집계 fallback을 refresh 후 재조회 fallback으로 변경**
- Files:
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/ranking/application/CreatorRankingQueryService.kt`
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/ranking/application/CreatorRankingSnapshotJobService.kt`
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/ranking/application/CreatorRankingQueryServiceTest.kt`
- RED: 최신 공개 스냅샷이 없으면 `snapshotJobService.refreshLastCompletedWeekByFallback()`을 호출하고, 이후 `findLatestVisibleSnapshots`를 재조회해 응답하는 테스트를 작성한다. 최신 공개 스냅샷이 있으면 fallback을 실행하지 않는 테스트도 함께 작성한다.
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.ranking.application.CreatorRankingQueryServiceTest`
- GREEN: `isSnapshotTableEmpty()` 기반 cold-start 직접 집계 응답과 `aggregateColdStartFallback` 경로를 제거한다.
- REFACTOR: fallback 실패는 로그만 남기고 공개 API는 `showRankChange=false`, `items=[]`로 성공 응답하게 유지한다.
- 기대 결과: 과거 스냅샷 row 존재 여부와 무관하게 최신 공개 스냅샷 없음 시 콘텐츠 랭킹과 같은 fallback을 시도한다.
- [x] **Task 5.2: 조회 순위/순위 변화/차단 마스킹 회귀 고정**
- Files:
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/ranking/application/CreatorRankingQueryService.kt`
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/ranking/application/CreatorRankingQueryServiceTest.kt`
- RED: latest/previous snapshot의 `rankNo` 기준으로 `rank`, `rankChange`, `isNew`, `showRankChange`가 계산되고, 차단 크리에이터는 기존처럼 마스킹되는 테스트를 작성한다.
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.ranking.application.CreatorRankingQueryServiceTest`
- GREEN: `groupBy(finalScore).shuffled()`를 제거하고 저장된 `rankNo` 또는 repository 정렬 순서를 사용한다.
- REFACTOR: API 응답에는 `finalScore`, `scorePolicyVersion`, `scoreDetailJson`을 노출하지 않는다.
- 기대 결과: 공개 응답 계약은 유지하고 순위 결정은 저장 스냅샷과 일치한다.
- [x] **Task 5.3: controller/facade 공개 스키마 회귀 테스트**
- Files:
- Verify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/home/adapter/in/web/CreatorRankingController.kt`
- Verify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/home/application/HomeCreatorRankingFacade.kt`
- Verify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/home/dto/ranking/CreatorRankingResponse.kt`
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/home/CreatorRankingControllerTest.kt`
- RED: MockMvc 응답 JSON에 기존 공개 필드만 있고 `finalScore`, `rankNo`, `scorePolicyVersion`, `scoreDetailJson`, `fallback`이 없는 테스트를 작성한다.
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.home.CreatorRankingControllerTest`
- GREEN: 필요 시 테스트 fixture만 신규 내부 record 구조에 맞춰 수정하고 controller/facade 공개 DTO는 변경하지 않는다.
- REFACTOR: API 계층이 ranking persistence model을 import하지 않도록 확인한다.
- 기대 결과: 앱 클라이언트 호환성이 controller 테스트로 고정된다.
### Phase 6: 통합 검증과 문서 갱신
- [x] **Task 6.1: 핵심 단위/통합 테스트 회귀 실행**
- Files:
- Verify: `src/test/kotlin/kr/co/vividnext/sodalive/v2/ranking/**`
- Verify: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/home/CreatorRankingControllerTest.kt`
- RED: TDD 예외 사유: 각 phase에서 실패 테스트를 먼저 작성하므로 이 task는 전체 회귀 실행 task다.
- 대체 검증 방법: 아래 단일/전체 명령을 실행하고 실패가 있으면 해당 phase task로 돌아가 수정한다.
- GREEN:
- `./gradlew test --tests kr.co.vividnext.sodalive.v2.ranking.domain.CreatorRankingScorePolicyTest`
- `./gradlew test --tests kr.co.vividnext.sodalive.v2.ranking.adapter.out.persistence.DefaultCreatorRankingAggregationRepositoryTest`
- `./gradlew test --tests kr.co.vividnext.sodalive.v2.ranking.adapter.out.persistence.DefaultCreatorRankingSnapshotRepositoryTest`
- `./gradlew test --tests kr.co.vividnext.sodalive.v2.ranking.application.CreatorRankingSnapshotRefreshServiceTest`
- `./gradlew test --tests kr.co.vividnext.sodalive.v2.ranking.application.CreatorRankingSnapshotJobServiceTest`
- `./gradlew test --tests kr.co.vividnext.sodalive.v2.ranking.application.CreatorRankingQueryServiceTest`
- `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.home.CreatorRankingControllerTest`
- REFACTOR: 중복 fixture/helper가 과해지면 기존 테스트 스타일 안에서만 정리한다.
- 기대 결과: 신규 산식, persistence, fallback, 공개 응답이 모두 회귀 테스트를 통과한다.
- [x] **Task 6.2: 포맷/컴파일/전체 테스트 검증**
- Files:
- Verify: `build.gradle.kts`
- Verify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/ranking/**`
- Verify: `src/test/kotlin/kr/co/vividnext/sodalive/v2/ranking/**`
- RED: TDD 예외 사유: 빌드/포맷 검증은 구현 완료 후 회귀 확인이다.
- 대체 검증 방법: Gradle 명령 결과를 plan-task 검증 기록에 누적한다.
- GREEN:
- `./gradlew test`
- `./gradlew ktlintCheck`
- 필요 시 `./gradlew compileKotlin`
- REFACTOR: ktlint 실패는 formatting만 최소 수정하고, unrelated style 변경은 하지 않는다.
- 기대 결과: 전체 테스트와 포맷 검증이 통과한다.
- [x] **Task 6.3: 문서와 운영 반영 절차 최종 정리**
- Files:
- Modify: `docs/20260710_메인_홈_크리에이터_랭킹_개편/prd.md`
- Modify: `docs/20260710_메인_홈_크리에이터_랭킹_개편/plan-task.md`
- Modify: `docs/20260710_메인_홈_크리에이터_랭킹_개편/alter-creator-ranking-snapshot-score-detail.sql`
- RED: TDD 예외 사유: 문서 정합성 작업이다.
- 대체 검증 방법: PRD 결정 사항, DDL, 구현 결과, 테스트 결과가 서로 모순되지 않는지 수동 대조한다.
- GREEN: 각 완료 task를 `- [x]`로 갱신하고, 실행 명령/결과/검증 이유를 해당 task 아래에 누적한다.
- REFACTOR: 구현 중 PRD 범위가 바뀌었으면 먼저 PRD를 갱신한 뒤 계획 문서를 다시 맞춘다.
- 기대 결과: 구현 결과와 운영 DDL, 테스트 근거가 문서에 남는다.
---
## 전체 검증 명령
```bash
./gradlew test --tests kr.co.vividnext.sodalive.v2.ranking.domain.CreatorRankingScorePolicyTest
./gradlew test --tests kr.co.vividnext.sodalive.v2.ranking.adapter.out.persistence.DefaultCreatorRankingAggregationRepositoryTest
./gradlew test --tests kr.co.vividnext.sodalive.v2.ranking.adapter.out.persistence.DefaultCreatorRankingSnapshotRepositoryTest
./gradlew test --tests kr.co.vividnext.sodalive.v2.ranking.application.CreatorRankingSnapshotRefreshServiceTest
./gradlew test --tests kr.co.vividnext.sodalive.v2.ranking.application.CreatorRankingSnapshotJobServiceTest
./gradlew test --tests kr.co.vividnext.sodalive.v2.ranking.application.CreatorRankingQueryServiceTest
./gradlew test --tests kr.co.vividnext.sodalive.v2.api.home.CreatorRankingControllerTest
./gradlew test
./gradlew ktlintCheck
```
---
## 검증 기록
- 2026-07-10: PRD 기반 구현 계획 문서를 생성했다. 문서 변경 후 명령 유효성 확인을 위해 `./gradlew tasks --all`을 실행했고 `BUILD SUCCESSFUL`을 확인했다. sandbox에서는 Gradle wrapper lock 파일 접근 제한으로 실패해 승인된 외부 실행으로 재시도했다.
- 2026-07-10: Task 5.1에 최신 공개 스냅샷이 있을 때 fallback을 실행하지 않는 회귀 테스트 요구를 추가했다. 문서 변경 후 `./gradlew tasks --all`을 실행했고 `BUILD SUCCESSFUL`을 확인했다. sandbox에서는 Gradle wrapper lock 파일 접근 제한으로 실패해 승인된 외부 실행으로 재시도했다.
- 2026-07-10: creator ranking V2 refactor 구현 후 `./gradlew compileKotlin`을 실행해 main source 컴파일 `BUILD SUCCESSFUL`을 확인했다.
- 2026-07-10: TDD RED 확인으로 `./gradlew compileTestKotlin`을 실행했고 old semantic snapshot/candidate 필드 제거로 기존 테스트가 컴파일 실패하는 것을 확인했다. 이후 V2 rank policy, `rankNo/scorePolicyVersion/scoreDetailJson`, fallback refresh-after-requery, FALLBACK job/count 테스트로 갱신했다.
- 2026-07-10: targeted GREEN 검증으로 `./gradlew test --tests kr.co.vividnext.sodalive.v2.ranking.domain.CreatorRankingScorePolicyTest --tests kr.co.vividnext.sodalive.v2.ranking.adapter.out.persistence.DefaultCreatorRankingAggregationRepositoryTest --tests kr.co.vividnext.sodalive.v2.ranking.adapter.out.persistence.DefaultCreatorRankingSnapshotRepositoryTest --tests kr.co.vividnext.sodalive.v2.ranking.adapter.out.persistence.DefaultCreatorRankingSnapshotJobRepositoryTest --tests kr.co.vividnext.sodalive.v2.ranking.application.CreatorRankingSnapshotRefreshServiceTest --tests kr.co.vividnext.sodalive.v2.ranking.application.CreatorRankingSnapshotJobServiceTest --tests kr.co.vividnext.sodalive.v2.ranking.application.CreatorRankingQueryServiceTest --tests kr.co.vividnext.sodalive.v2.api.home.CreatorRankingControllerTest ktlintCheck`를 실행했고 `BUILD SUCCESSFUL`을 확인했다.
- 2026-07-10: reviewer gate에서 aggregation/snapshot/controller 테스트가 약화되었다는 rejection을 받아 `DefaultCreatorRankingAggregationRepositoryTest``DefaultCreatorRankingSnapshotRepositoryTest``@DataJpaTest` 기반으로, `CreatorRankingControllerTest``@SpringBootTest` + `MockMvc` 기반으로 복구했다. 복구 과정에서 H2 native query의 `Timestamp` 매핑과 직전 공개 스냅샷 조회의 `visible_from_at <= nowUtc` 누락을 테스트로 확인하고 수정했다.
- 2026-07-10: surface 재검증으로 `./gradlew test --rerun-tasks --tests kr.co.vividnext.sodalive.v2.ranking.adapter.out.persistence.DefaultCreatorRankingAggregationRepositoryTest --tests kr.co.vividnext.sodalive.v2.ranking.adapter.out.persistence.DefaultCreatorRankingSnapshotRepositoryTest --tests kr.co.vividnext.sodalive.v2.api.home.CreatorRankingControllerTest`를 실행했고 `BUILD SUCCESSFUL`을 확인했다.
- 2026-07-10: 최종 회귀 검증으로 `./gradlew test`, `./gradlew ktlintCheck`, `GIT_MASTER=1 git diff --check`를 실행했고 모두 성공했다. old cold-start fallback 및 old score 필드 잔존 검색도 신규 JSON 직렬화 코드 외 유의미한 잔존이 없음을 확인했다.
- 2026-07-10: reviewer gate 재심사에서 `UNCONDITIONAL APPROVAL`을 받았다.
- 2026-07-10: 코드 리뷰 반영으로 `CreatorRankingScoreDetail``LocalDateTime` JSON 직렬화를 ISO-8601 문자열로 고정하고, `CreatorRankingScorePolicyTest`에서 `creatorDebutAt = "2026-06-01T00:00:00"` 값을 직접 검증하도록 보강했다. 운영 DDL에는 `creator_ranking_snapshot_job.trigger_type` comment를 `SCHEDULED, MANUAL, FALLBACK`으로 갱신하는 alter를 추가했다.
- 2026-07-10: 리뷰 반영 후 재검증으로 `./gradlew test`, `./gradlew ktlintCheck`, `git diff --check`를 실행했고 모두 `BUILD SUCCESSFUL` 또는 성공 종료를 확인했다. `./gradlew ktlintCheck`는 sandbox에서 Gradle wrapper lock 파일 접근 제한으로 실패해 승인된 외부 실행으로 재시도했다.

View File

@@ -0,0 +1,275 @@
# PRD: 메인 홈 크리에이터 랭킹 개편
## 1. Overview
메인 홈 크리에이터 랭킹의 스냅샷 조회 fallback을 콘텐츠 랭킹 fallback과 같은 refresh 후 재조회 방식으로 변경하고, 크리에이터 랭킹 스냅샷 점수 산식을 순위 기반 인기 점수로 교체한다.
---
## 2. Problem
- 현재 크리에이터 랭킹 fallback은 스냅샷 테이블이 완전히 비어 있는 cold-start 상황에서만 원천 데이터를 직접 집계해 응답한다.
- 이 방식은 콘텐츠 랭킹 fallback처럼 스케줄러와 동일한 refresh 경로로 스냅샷을 생성한 뒤 재조회하지 않으므로, 운영 중 특정 주차 스냅샷이 없을 때 복구 동작이 다르다.
- 기존 크리에이터 랭킹 산식은 캔/좋아요/댓글/후원/팬Talk/팔로우 raw value에 가중치를 곱한다.
- 신규 요구사항은 라이브 매출, 콘텐츠 매출, 팔로워 증가, AI 채팅 개수를 각각 랭킹 점수로 변환한 뒤 가중합해야 하므로 기존 raw value 기반 산식과 다르다.
- 점수 계산을 DB에서 하든 Kotlin에서 하든 산식과 순위 점수 경계값이 흔들리지 않도록 테스트 근거가 필요하다.
---
## 3. Goals
- `GET /api/v2/home/rankings/creators` 공개 API 응답 스키마는 유지한다.
- 최신 공개 크리에이터 랭킹 스냅샷이 없으면 콘텐츠 랭킹과 같은 방식으로 fallback refresh를 실행한다.
- fallback refresh는 스케줄러와 동일한 크리에이터 랭킹 refresh 로직을 실행하고, 저장된 스냅샷을 다시 조회해 응답한다.
- fallback 실행은 작업 이력에 `FALLBACK` trigger로 남기고, 동일 집계 기간 기준 최대 3회까지만 시도한다.
- 집계 기간은 기존과 동일하게 KST 기준 지난 완료 주차를 UTC half-open 범위로 변환해 조회한다.
- 후보 크리에이터는 활성 크리에이터 중 라이브 매출, 콘텐츠 매출, 팔로워 증가, AI 채팅 개수 중 하나라도 있는 크리에이터로 제한한다.
- 최종 크리에이터 인기 점수는 `라이브 매출 랭킹 점수 60% + 콘텐츠 매출 랭킹 점수 20% + 팔로워 증가 수 랭킹 점수 10% + 채팅 개수 랭킹 점수 10%`로 계산한다.
- 최종 응답 인원 수는 20명이다.
- 이번 변경에서는 기존 크리에이터 랭킹 스냅샷 데이터를 폐기하고 신규 구조로 다시 쌓는다.
- 이후 산식 변경 시 DB 테이블 변경을 반복하지 않도록 `creator_ranking_snapshot`을 고정 결과 컬럼과 JSON 상세 컬럼 중심으로 재정리한다.
- 산식, 순위 점수 변환, 후보 제외 조건, fallback 실행 제한은 단위/통합 테스트로 촘촘히 검증한다.
---
## 4. Non-Goals
- 메인 홈 크리에이터 랭킹 API endpoint, 응답 필드, JSON 스키마는 변경하지 않는다.
- 콘텐츠 랭킹 산식과 콘텐츠 랭킹 fallback 동작은 변경하지 않는다.
- 메인 홈 추천 섹션의 `CHEER_CREATOR`, `AI_CHARACTER`, `POPULAR_COMMUNITY` 스냅샷은 변경하지 않는다.
- 관리자 화면 신규 개발, 수동 순위 보정, 랭킹 제외/고정 기능은 포함하지 않는다.
- 개인화 랭킹, 실시간 랭킹, A/B 테스트, 머신러닝 기반 점수 산정은 포함하지 않는다.
- 신규 공개 API를 만들지 않는다.
---
## 5. Target Users
- 회원/비회원: 메인 홈에서 인기 크리에이터 랭킹을 확인하는 사용자
- 앱 클라이언트: 기존 크리에이터 랭킹 응답 스키마로 화면을 구성하는 클라이언트
- 운영자: 스냅샷 생성 실패 또는 누락 시 fallback 복구 여부와 신규 산식 결과를 확인해야 하는 내부 사용자
- 개발자: 순위 기반 점수 산식과 fallback job 흐름을 테스트로 검증해야 하는 개발자
---
## 6. User Stories
- 사용자는 지난 완료 주차 기준으로 라이브 매출, 콘텐츠 매출, 팔로워 증가, AI 채팅 반응이 좋은 크리에이터 20명을 보고 싶다.
- 앱 클라이언트는 기존 `showRankChange`, `rank`, `rankChange`, `isNew`, 크리에이터 표시 정보를 그대로 받아 화면 변경 없이 순위 기준만 바뀐 결과를 노출하고 싶다.
- 운영자는 스케줄러가 크리에이터 랭킹 스냅샷 생성을 놓친 경우 첫 조회 또는 후속 조회에서 동일 refresh 로직으로 스냅샷이 생성되기를 원한다.
- 개발자는 DB-side 계산과 Kotlin-side 계산 중 어떤 방식을 선택하더라도 순위 점수 변환 결과를 테스트로 고정하고 싶다.
---
## 7. Core Features
### Feature A. 집계 기간과 공개 시각 유지
#### Requirements
- 기준 시각을 KST로 변환한 뒤 이번 주 월요일 00:00:00 KST를 구한다.
- 집계 구간은 직전 월요일 00:00:00 KST 이상, 이번 주 월요일 00:00:00 KST 미만이다.
- DB 조회에는 이 구간을 UTC로 변환한 `created_at >= startInclusiveUtc` and `created_at < endExclusiveUtc` 조건을 사용한다.
- 공개 시각은 집계 종료 KST + 9시간이다.
- 공개 조회는 기존처럼 `visibleFromAt <= nowUtc` 조건을 만족하는 최신 공개 스냅샷만 사용한다.
#### Edge Cases
- 월요일 09:00:00 KST 전에는 새 주차 스냅샷이 생성되어 있어도 공개 조회 대상이 아니다.
- 연도/월 경계를 넘는 주차도 동일한 KST 주간 정책을 따른다.
- fallback으로 생성된 스냅샷도 `visibleFromAt <= nowUtc` 조건을 만족하지 않으면 공개 조회에 노출하지 않는다.
### Feature B. 후보 크리에이터 집계
#### Requirements
- 후보 대상은 `member.role = 'CREATOR'`이고 `member.is_active = true`인 회원이다.
- 아래 지표 중 하나라도 있는 크리에이터만 후보에 포함한다.
- `liveCanAmount`
- `contentPurchaseCanAmount`
- `followIncrease`
- `aiChatCount >= 1`
- `liveCanAmount``use_can_calculate``DONATION`, `LIVE`, `SPIN_ROULETTE` 사용 캔 합계로 계산한다.
- `contentPurchaseCanAmount``ORDER_CONTENT` 사용 캔 합계로 계산한다.
- 공통 캔 조건은 `recipient_creator_id is not null`, `status = RECEIVED`, `use_can.is_refund = false`, 집계 기간 내 생성이다.
- `followIncrease`는 집계 기간 내 팔로우 증가 수로 계산한다.
- `followIncrease`의 최소값은 0이다. 기간 내 언팔로우가 팔로우보다 많아 계산값이 음수가 되면 0으로 보정한다.
- `aiChatCount``chat_message`에서 `ParticipantType.CHARACTER` 메시지를 세는 방식으로 계산한다.
- `aiChatCount` 집계는 기존 AI 캐릭터 스냅샷 query와 동일하게 `chat_message`, `chat_participant`, `chat_character`, `creatorMember` 관계를 기준으로 한다.
- 원천 지표가 없으면 0으로 계산한다.
#### Edge Cases
- 캔 매출이 없어도 팔로워 증가 또는 AI 채팅 개수가 있으면 후보가 될 수 있다.
- `followIncrease` 원 계산값이 음수이면 0으로 보정하므로 팔로워 증가 지표만으로는 후보가 될 수 없다.
- `aiChatCount`는 비활성 메시지, 비활성 participant, 비활성 캐릭터/크리에이터를 제외해야 한다.
- 사용자 프롬프트의 `SPIN_ROULETE`는 기존 코드 enum `SPIN_ROULETTE`의 오타로 보고, 구현 전 enum명을 확인한다.
### Feature C. 순위 기반 점수 산식
#### Requirements
- 각 원천 지표별로 크리에이터 순위를 계산한 뒤 순위 점수로 변환한다.
- 순위 점수 기본 산식은 기존 SQL 샘플과 동일한 형태를 따른다.
```sql
GREATEST((100 - cast((5 * (rank() over (order by metric desc) - 1)) as signed)) * weight, 0)
```
- 순수 랭킹 점수는 `max(100 - (5 * (rank - 1)), 0)`으로 해석한다.
- 동률 순위는 SQL `rank()` 의미를 따른다. 같은 metric 값은 같은 rank를 받고, 다음 순위는 건너뛴다.
- 최종 점수는 아래 산식으로 계산한다.
```text
creatorPopularityScore =
liveRevenueRankScore * 0.6
+ contentRevenueRankScore * 0.2
+ followIncreaseRankScore * 0.1
+ aiChatRankScore * 0.1
```
- 위 가중치 합은 `1.00`이다.
- `liveRevenueRankScore``liveCanAmount` 기준 내림차순 rank를 순위 점수로 변환한 값이다.
- `contentRevenueRankScore``contentPurchaseCanAmount` 기준 내림차순 rank를 순위 점수로 변환한 값이다.
- `followIncreaseRankScore``followIncrease` 기준 내림차순 rank를 순위 점수로 변환한 값이다.
- `aiChatRankScore``aiChatCount` 기준 내림차순 rank를 순위 점수로 변환한 값이다.
- 특정 지표 값이 0인 후보는 해당 지표 점수를 0점으로 강제한다.
- 최종 점수 정렬은 `creatorPopularityScore desc`를 1차 기준으로 한다.
- 최종 점수가 같으면 크리에이터 데뷔일 내림차순, `creatorId` 내림차순으로 정렬한다.
#### Edge Cases
- rank 1의 순수 랭킹 점수는 100점이다.
- rank 2는 95점, rank 20은 5점, rank 21부터는 0점이다.
- 동일 metric 값의 동률 후보는 같은 지표 점수를 받아야 한다.
- 특정 지표가 없는 후보는 해당 지표 점수 0점이어야 한다.
- 최종 점수가 같은 후보는 데뷔일이 더 늦은 크리에이터가 먼저 노출된다.
- 최종 점수와 데뷔일이 같으면 `creatorId`가 더 큰 크리에이터가 먼저 노출된다.
### Feature D. 스냅샷 저장과 최종 인원 수
#### Requirements
- 최종 공개 랭킹 인원 수는 20명이다.
- 스냅샷 저장 대상도 최종 점수 기준 상위 20명으로 제한한다.
- 최종 동점 tie-breaker가 명확하므로 기존처럼 20위 점수 동점자를 추가 저장하지 않고 정확히 20명만 저장한다.
- 스냅샷 테이블은 산식별 metric 컬럼을 늘리지 않고 최종 랭킹 결과를 저장하는 구조로 재정리한다.
- 기존 스냅샷 데이터는 보존하지 않고 삭제한 뒤 신규 스냅샷을 다시 생성한다.
- `creator_ranking_snapshot` 테이블 자체는 유지한다.
- 유지하는 고정 컬럼은 `id`, `ranking_type`, `aggregation_start_at_utc`, `aggregation_end_at_utc`, `visible_from_at`, `creator_id`, `nickname`, `profile_image_url`, `final_score`, `created_at`, `updated_at`이다.
- 신규 고정 컬럼은 `rank_no`, `score_policy_version`, `score_detail_json`이다.
- `rank_no`는 최종 tie-breaker까지 반영된 저장 순위이며 공개 조회는 `rank_no asc`를 기준으로 한다.
- `score_policy_version`은 스냅샷 생성에 사용한 산식 버전이다. 예: `CREATOR_WEEKLY_POPULARITY_V2`.
- `score_detail_json`은 산식별 원천 지표, rank, rank score, weight, weighted score, tie-breaker 값을 저장한다.
- `score_detail_json`에는 최소한 이번 산식의 `liveRevenue`, `contentRevenue`, `followIncrease`, `aiChat` 상세가 포함되어야 한다.
- 기존 산식 전용 컬럼은 삭제한다. 삭제 대상은 `content_live_score`, `engagement_score`, `support_score`, `fan_loyalty_score`, `live_can_amount`, `content_purchase_can_amount`, `content_like_count`, `content_comment_count`, `channel_donation_can_amount`, `channel_donation_count`, `fan_talk_count`, `final_follower_count`, `follow_increase`다.
- 이후 산식 변경 시에는 `score_policy_version``score_detail_json`의 애플리케이션 schema만 변경하고, 공개 조회에 필요한 고정 컬럼이 늘어나지 않는 한 테이블 DDL은 변경하지 않는다.
- 운영 추적은 DB의 산식별 개별 컬럼이 아니라 `score_detail_json`, 로그, 테스트 fixture를 기준으로 한다.
#### Edge Cases
- 후보가 20명 미만이면 가능한 후보만 저장/응답한다.
- 최종 점수가 0인 후보는 스냅샷 저장 대상에서 제외한다.
- 스냅샷 생성 결과가 0건이면 콘텐츠 랭킹 fallback과 동일하게 fallback job 3회 제한으로 반복 실행을 제한한다. 별도 empty marker는 이번 범위에 추가하지 않는다.
- 기존 스냅샷 row를 삭제하므로 legacy row 조회 호환, nullable backfill, old/new score version 혼재 처리는 이번 범위에서 고려하지 않는다.
- 기존 job 이력이 fallback 3회 제한에 영향을 주지 않도록 `creator_ranking_snapshot_job` 기존 데이터도 함께 삭제한다.
### Feature E. 콘텐츠 랭킹과 동일한 fallback refresh
#### Requirements
- 크리에이터 랭킹 조회 시 최신 공개 스냅샷이 없으면 fallback refresh를 시도한다.
- fallback은 조회 서비스가 원천 집계 결과를 직접 응답하지 않는다.
- fallback은 스케줄러와 동일한 크리에이터 랭킹 refresh service를 실행해 `creator_ranking_snapshot`에 저장한다.
- fallback 실행 후 저장된 최신 공개 스냅샷을 다시 조회해 응답한다.
- fallback 후에도 스냅샷이 없으면 `showRankChange=false`, `items=[]`로 성공 응답한다.
- fallback 여부는 공개 API response schema에 포함하지 않는다.
- fallback 실행은 `creator_ranking_snapshot_job``FALLBACK` trigger로 기록한다.
- 동일 집계 기간 기준 fallback job 수가 3회 이상이면 추가 fallback을 실행하지 않는다.
- fallback refresh는 기간 기반 Redisson lock을 사용해 같은 기간 중복 refresh를 막는다.
- lock 획득 실패는 다른 요청 또는 스케줄러가 처리 중인 정상 skip으로 보고, 현재 요청은 재조회 후 없으면 빈 목록을 반환한다.
- fallback 실패는 로그와 job 상태로 남기되 공개 API 전체 실패로 전파하지 않는다.
#### Edge Cases
- 스냅샷 테이블에 과거 row가 있더라도 최신 공개 스냅샷이 없으면 fallback을 시도한다. 이는 기존 cold-start 전용 fallback과 달라지는 핵심 동작이다.
- fallback으로 생성된 스냅샷이 아직 공개 시각 전이면 재조회 결과가 없을 수 있으며, 이 경우 빈 목록을 반환한다.
- fallback job 저장은 성공했지만 refresh가 실패하면 job은 `FAILED`가 되어야 한다.
- fallback 3회 제한에는 실패한 fallback job도 포함한다.
### Feature F. 순위 변화 계산 유지
#### Requirements
- 최신 공개 스냅샷과 직전 공개 스냅샷을 비교해 기존과 같은 방식으로 `rankChange`, `isNew`, `showRankChange`를 계산한다.
- 공개 API 응답 DTO는 변경하지 않는다.
- 차단 관계 마스킹은 기존 크리에이터 랭킹 조회 정책을 유지한다.
- 프로필 이미지 CDN URL 변환 정책도 기존 정책을 유지한다.
#### Edge Cases
- 직전 공개 스냅샷이 없으면 `showRankChange=false`, `rankChange=null`, `isNew=false`를 유지한다.
- 차단된 크리에이터는 row를 제거하지 않고 기존처럼 id `0`, 빈 닉네임, 기본 프로필 이미지로 마스킹한다.
### Feature G. 테스트 기준
#### Requirements
- 기간 정책 테스트는 KST 주간 경계와 UTC 변환을 검증한다.
- 순위 점수 정책 테스트는 rank 1, 2, 20, 21과 동률 rank를 검증한다.
- 최종 점수 정책 테스트는 네 개 지표의 가중합을 검증한다.
- 지표별 0값 처리와 `followIncrease` 음수값 0 보정은 테스트로 명시한다.
- 집계 repository 테스트는 캔 조건, 팔로우 증가, AI 채팅 개수, 활성 크리에이터 조건, 후보 제외 조건을 검증한다.
- refresh service 테스트는 신규 산식으로 상위 20명만 저장하는지 검증한다.
- query service 테스트는 최신 공개 스냅샷 없음 시 fallback 실행 후 재조회하는지 검증한다.
- job service 테스트는 `FALLBACK` trigger 생성, 최대 3회 제한, lock 획득 실패 skip, 실패 상태 기록을 검증한다.
- controller/facade 테스트는 공개 응답 스키마가 바뀌지 않았음을 검증한다.
#### Edge Cases
- fallback refresh 실패 시 빈 목록 성공 응답을 검증한다.
- fallback refresh 성공 후 재조회 결과가 있으면 해당 스냅샷으로 응답함을 검증한다.
- 최신 공개 스냅샷이 있으면 fallback을 실행하지 않음을 검증한다.
---
## 8. Technical Constraints
- Kotlin, Spring Boot 2.7.14, Java 17, Gradle Wrapper 구조를 유지한다.
- 기존 `kr.co.vividnext.sodalive.v2.ranking` 패키지 경계와 `v2.api.home` facade/controller 경계를 유지한다.
- 기존 API endpoint `GET /api/v2/home/rankings/creators`를 유지한다.
- 콘텐츠 랭킹 fallback 구현인 `AudioRankingQueryService``AudioRankingSnapshotJobService`의 흐름을 우선 참고한다.
- 크리에이터 랭킹 job trigger enum에 `FALLBACK`을 추가해야 한다.
- 신규 산식의 원천 지표와 하위 점수는 산식 전용 컬럼으로 추가하지 않고 `score_detail_json`에 저장한다.
- 기존 `creator_ranking_snapshot` 테이블은 유지하되 formula-specific 컬럼은 삭제한다.
- 운영 DB 반영용 DDL은 MySQL 기준으로 작성하며, 기존 스냅샷/job 데이터 삭제를 전제로 한다.
- 공개 조회와 직전 스냅샷 비교는 `rank_no`와 고정 컬럼만 사용한다.
- `score_detail_json`은 MySQL `JSON` 타입을 기본으로 사용한다.
- `score_detail_json`의 key schema는 코드 상수와 테스트 fixture로 고정해 산식 변경 시에도 의도치 않은 운영 추적 포맷 변경을 막는다.
- DB-side scoring을 선택하면 Kotlin 정책 테스트와 DB expression이 같은 상수를 참조하거나, 최소한 동일 기대값 테스트로 parity를 보장해야 한다.
- Kotlin-side scoring을 선택하면 DB는 원천 metric만 집계하고 Kotlin이 rank/score/sort/limit을 적용한다. 후보 수가 많을 때 메모리 사용량을 구현 계획에서 검토한다.
- 공개 API 스키마는 변경하지 않는다.
---
## 9. Metrics
- 크리에이터 랭킹 스냅샷 생성 성공/실패 횟수
- fallback 실행/성공/실패 횟수
- fallback 최대 3회 제한으로 skip된 횟수
- fallback lock 획득 성공/실패 횟수
- 크리에이터 랭킹 조회 API latency
- 신규 산식 기준 후보 크리에이터 수
- 최종 저장/응답 item 수
- metric별 rank score 분포
- 스냅샷 생성 소요 시간
---
## 10. Decisions
- 라이브 매출 랭킹 점수 가중치는 60%로 한다.
- 최종 산식 가중치 합은 `60% + 20% + 10% + 10% = 100%`다.
- `AI 채팅 개수``chat_message`에서 `ParticipantType.CHARACTER` 메시지를 세는 방식으로 본다.
- `followIncrease`의 최소값은 0이다.
- 특정 지표 값이 0인 후보는 해당 지표 점수를 0점으로 강제한다.
- 최종 동점 tie-breaker는 크리에이터 데뷔일 내림차순, `creatorId` 내림차순이다.
- 최종 동점 tie-breaker가 명확하므로 스냅샷 저장은 20명으로 제한한다.
- 기존 크리에이터 랭킹 스냅샷 데이터와 job 이력은 삭제하고 신규 구조 기준으로 다시 생성한다.
- `creator_ranking_snapshot` 테이블은 유지하되 산식별 컬럼을 삭제하고 `rank_no`, `score_policy_version`, `score_detail_json` 중심으로 재정리한다.
- 기존 old semantic 컬럼을 재사용해 의미를 바꾸는 방식은 운영 추적과 관리자 조회에서 혼동을 만들 수 있으므로 사용하지 않는다.
- 미래 산식 변경은 고정 컬럼 추가가 꼭 필요한 경우가 아니라면 `score_policy_version``score_detail_json` schema 변경만으로 처리한다.
- 최종 점수가 0인 후보는 저장하지 않는다.
- 스냅샷 생성 결과가 0건이어도 empty marker는 추가하지 않고, 콘텐츠 랭킹 fallback과 같은 job 3회 제한으로 반복 fallback을 제한한다.
---
## 11. Open Questions
- 현재 PRD 기준 미결정 항목은 없다. DDL은 기존 데이터 삭제와 산식별 컬럼 삭제를 전제로 작성한다.
---
## 12. Related Documents
- `docs/prd/sample-prd.md`
- `docs/agent-guides/작업절차.md`
- `docs/agent-guides/문서유지보수.md`
- `docs/20260608_크리에이터_랭킹/prd.md`
- `docs/20260623_메인_콘텐츠_랭킹_탭_API/prd.md`
- `docs/20260710_메인_홈_크리에이터_랭킹_개편/alter-creator-ranking-snapshot-score-detail.sql`

View File

@@ -34,45 +34,15 @@ class CreatorRankingSnapshot(
@Column(name = "profile_image_url", updatable = false, length = 500) @Column(name = "profile_image_url", updatable = false, length = 500)
val profileImageUrl: String?, val profileImageUrl: String?,
@Column(name = "rank_no", nullable = false, updatable = false)
val rankNo: Int,
@Column(name = "final_score", nullable = false, updatable = false) @Column(name = "final_score", nullable = false, updatable = false)
val finalScore: Double, val finalScore: Double,
@Column(name = "content_live_score", nullable = false, updatable = false) @Column(name = "score_policy_version", nullable = false, updatable = false, length = 80)
val contentLiveScore: Double, val scorePolicyVersion: String,
@Column(name = "engagement_score", nullable = false, updatable = false) @Column(name = "score_detail_json", nullable = false, updatable = false, columnDefinition = "json")
val engagementScore: Double, val scoreDetailJson: String
@Column(name = "support_score", nullable = false, updatable = false)
val supportScore: Double,
@Column(name = "fan_loyalty_score", nullable = false, updatable = false)
val fanLoyaltyScore: Double,
@Column(name = "live_can_amount", nullable = false, updatable = false)
val liveCanAmount: Long,
@Column(name = "content_purchase_can_amount", nullable = false, updatable = false)
val contentPurchaseCanAmount: Long,
@Column(name = "content_like_count", nullable = false, updatable = false)
val contentLikeCount: Long,
@Column(name = "content_comment_count", nullable = false, updatable = false)
val contentCommentCount: Long,
@Column(name = "channel_donation_can_amount", nullable = false, updatable = false)
val channelDonationCanAmount: Long,
@Column(name = "channel_donation_count", nullable = false, updatable = false)
val channelDonationCount: Long,
@Column(name = "fan_talk_count", nullable = false, updatable = false)
val fanTalkCount: Long,
@Column(name = "final_follower_count", nullable = false, updatable = false)
val finalFollowerCount: Long,
@Column(name = "follow_increase", nullable = false, updatable = false)
val followIncrease: Long
) : BaseEntity() ) : BaseEntity()

View File

@@ -1,6 +1,8 @@
package kr.co.vividnext.sodalive.v2.ranking.adapter.out.persistence package kr.co.vividnext.sodalive.v2.ranking.adapter.out.persistence
import kr.co.vividnext.sodalive.v2.ranking.domain.CreatorRankingType
import kr.co.vividnext.sodalive.v2.ranking.port.out.CreatorRankingSnapshotJobStatus import kr.co.vividnext.sodalive.v2.ranking.port.out.CreatorRankingSnapshotJobStatus
import kr.co.vividnext.sodalive.v2.ranking.port.out.CreatorRankingSnapshotJobTrigger
import org.springframework.data.jpa.repository.JpaRepository import org.springframework.data.jpa.repository.JpaRepository
import org.springframework.data.jpa.repository.Lock import org.springframework.data.jpa.repository.Lock
import org.springframework.data.jpa.repository.Query import org.springframework.data.jpa.repository.Query
@@ -18,4 +20,11 @@ interface CreatorRankingSnapshotJobRepository : JpaRepository<CreatorRankingSnap
aggregationEndAtUtc: LocalDateTime, aggregationEndAtUtc: LocalDateTime,
statuses: List<CreatorRankingSnapshotJobStatus> statuses: List<CreatorRankingSnapshotJobStatus>
): List<CreatorRankingSnapshotJob> ): List<CreatorRankingSnapshotJob>
fun countByRankingTypeAndAggregationStartAtUtcAndAggregationEndAtUtcAndTrigger(
rankingType: CreatorRankingType,
aggregationStartAtUtc: LocalDateTime,
aggregationEndAtUtc: LocalDateTime,
trigger: CreatorRankingSnapshotJobTrigger
): Long
} }

View File

@@ -7,7 +7,7 @@ import org.springframework.data.repository.query.Param
import java.time.LocalDateTime import java.time.LocalDateTime
interface CreatorRankingSnapshotRepository : JpaRepository<CreatorRankingSnapshot, Long> { interface CreatorRankingSnapshotRepository : JpaRepository<CreatorRankingSnapshot, Long> {
fun findAllByAggregationStartAtUtcAndAggregationEndAtUtcOrderByFinalScoreDesc( fun findAllByAggregationStartAtUtcAndAggregationEndAtUtcOrderByRankNoAsc(
aggregationStartAtUtc: LocalDateTime, aggregationStartAtUtc: LocalDateTime,
aggregationEndAtUtc: LocalDateTime aggregationEndAtUtc: LocalDateTime
): List<CreatorRankingSnapshot> ): List<CreatorRankingSnapshot>
@@ -20,7 +20,7 @@ interface CreatorRankingSnapshotRepository : JpaRepository<CreatorRankingSnapsho
select max(latest.aggregation_end_at_utc) select max(latest.aggregation_end_at_utc)
from creator_ranking_snapshot latest from creator_ranking_snapshot latest
) )
order by crs.final_score desc order by crs.rank_no asc
""", """,
nativeQuery = true nativeQuery = true
) )
@@ -38,7 +38,7 @@ interface CreatorRankingSnapshotRepository : JpaRepository<CreatorRankingSnapsho
from creator_ranking_snapshot latest from creator_ranking_snapshot latest
) )
) )
order by crs.final_score desc order by crs.rank_no asc
""", """,
nativeQuery = true nativeQuery = true
) )
@@ -55,7 +55,7 @@ interface CreatorRankingSnapshotRepository : JpaRepository<CreatorRankingSnapsho
where latest.ranking_type = :rankingType where latest.ranking_type = :rankingType
and latest.visible_from_at <= :nowUtc and latest.visible_from_at <= :nowUtc
) )
order by crs.final_score desc order by crs.rank_no asc
""", """,
nativeQuery = true nativeQuery = true
) )
@@ -69,6 +69,7 @@ interface CreatorRankingSnapshotRepository : JpaRepository<CreatorRankingSnapsho
select * select *
from creator_ranking_snapshot crs from creator_ranking_snapshot crs
where crs.ranking_type = :rankingType where crs.ranking_type = :rankingType
and crs.visible_from_at <= :nowUtc
and crs.aggregation_start_at_utc = ( and crs.aggregation_start_at_utc = (
select max(previous.aggregation_start_at_utc) select max(previous.aggregation_start_at_utc)
from creator_ranking_snapshot previous from creator_ranking_snapshot previous
@@ -76,7 +77,7 @@ interface CreatorRankingSnapshotRepository : JpaRepository<CreatorRankingSnapsho
and previous.aggregation_start_at_utc < :currentAggregationStartAtUtc and previous.aggregation_start_at_utc < :currentAggregationStartAtUtc
and previous.visible_from_at <= :nowUtc and previous.visible_from_at <= :nowUtc
) )
order by crs.final_score desc order by crs.rank_no asc
""", """,
nativeQuery = true nativeQuery = true
) )

View File

@@ -1,10 +1,10 @@
package kr.co.vividnext.sodalive.v2.ranking.adapter.out.persistence package kr.co.vividnext.sodalive.v2.ranking.adapter.out.persistence
import kr.co.vividnext.sodalive.v2.ranking.domain.CreatorRankingScorePolicy
import kr.co.vividnext.sodalive.v2.ranking.domain.CreatorRankingSnapshotCandidate import kr.co.vividnext.sodalive.v2.ranking.domain.CreatorRankingSnapshotCandidate
import kr.co.vividnext.sodalive.v2.ranking.port.out.CreatorRankingAggregationPort import kr.co.vividnext.sodalive.v2.ranking.port.out.CreatorRankingAggregationPort
import kr.co.vividnext.sodalive.v2.ranking.port.out.CreatorRankingAggregationResult import kr.co.vividnext.sodalive.v2.ranking.port.out.CreatorRankingAggregationResult
import org.springframework.stereotype.Repository import org.springframework.stereotype.Repository
import java.sql.Timestamp
import java.time.LocalDateTime import java.time.LocalDateTime
import javax.persistence.EntityManager import javax.persistence.EntityManager
@@ -12,8 +12,6 @@ import javax.persistence.EntityManager
class DefaultCreatorRankingAggregationRepository( class DefaultCreatorRankingAggregationRepository(
private val entityManager: EntityManager private val entityManager: EntityManager
) : CreatorRankingAggregationPort { ) : CreatorRankingAggregationPort {
private val scorePolicy = CreatorRankingScorePolicy()
override fun aggregateCandidates( override fun aggregateCandidates(
startInclusiveUtc: LocalDateTime, startInclusiveUtc: LocalDateTime,
endExclusiveUtc: LocalDateTime endExclusiveUtc: LocalDateTime
@@ -26,13 +24,9 @@ class DefaultCreatorRankingAggregationRepository(
endExclusiveUtc: LocalDateTime endExclusiveUtc: LocalDateTime
): CreatorRankingAggregationResult { ): CreatorRankingAggregationResult {
val candidates = aggregateAllCandidates(startInclusiveUtc, endExclusiveUtc) val candidates = aggregateAllCandidates(startInclusiveUtc, endExclusiveUtc)
val includedCandidates = candidates
.filter { candidate -> candidate.finalScore >= MINIMUM_FINAL_SCORE }
.sortedWith(compareByDescending<CreatorRankingSnapshotCandidate> { it.finalScore }.thenBy { it.creatorId })
return CreatorRankingAggregationResult( return CreatorRankingAggregationResult(
candidates = includedCandidates, candidates = candidates,
lowScoreExcludedCount = candidates.size - includedCandidates.size lowScoreExcludedCount = 0
) )
} }
@@ -49,42 +43,15 @@ class DefaultCreatorRankingAggregationRepository(
} }
private fun Array<*>.toCandidate(): CreatorRankingSnapshotCandidate { private fun Array<*>.toCandidate(): CreatorRankingSnapshotCandidate {
val creatorId = this[0].toLong()
val nickname = this[1] as String
val profileImageUrl = this[2] as String?
val liveCanAmount = this[3].toLong()
val contentPurchaseCanAmount = this[4].toLong()
val contentLikeCount = this[5].toLong()
val contentCommentCount = this[6].toLong()
val channelDonationCanAmount = this[7].toLong()
val channelDonationCount = this[8].toLong()
val fanTalkCount = this[9].toLong()
val finalFollowerCount = this[10].toLong()
val followIncrease = this[11].toLong()
val contentLiveScore = scorePolicy.calculateContentLiveScore(liveCanAmount, contentPurchaseCanAmount)
val engagementScore = scorePolicy.calculateEngagementScore(contentLikeCount, contentCommentCount)
val supportScore = scorePolicy.calculateSupportScore(channelDonationCanAmount, channelDonationCount, fanTalkCount)
val fanLoyaltyScore = scorePolicy.calculateFanLoyaltyScore(finalFollowerCount, followIncrease)
val finalScore = scorePolicy.calculateFinalScore(contentLiveScore, engagementScore, supportScore, fanLoyaltyScore)
return CreatorRankingSnapshotCandidate( return CreatorRankingSnapshotCandidate(
creatorId = creatorId, creatorId = this[0].toLong(),
nickname = nickname, nickname = this[1] as String,
profileImageUrl = profileImageUrl, profileImageUrl = this[2] as String?,
finalScore = finalScore, creatorDebutAt = this[3].toLocalDateTimeOrNull(),
contentLiveScore = contentLiveScore, liveCanAmount = this[4].toLong(),
engagementScore = engagementScore, contentPurchaseCanAmount = this[5].toLong(),
supportScore = supportScore, followIncrease = this[6].toLong().coerceAtLeast(0L),
fanLoyaltyScore = fanLoyaltyScore, aiChatCount = this[7].toLong()
liveCanAmount = liveCanAmount,
contentPurchaseCanAmount = contentPurchaseCanAmount,
contentLikeCount = contentLikeCount,
contentCommentCount = contentCommentCount,
channelDonationCanAmount = channelDonationCanAmount,
channelDonationCount = channelDonationCount,
fanTalkCount = fanTalkCount,
finalFollowerCount = finalFollowerCount,
followIncrease = followIncrease
) )
} }
@@ -92,9 +59,16 @@ class DefaultCreatorRankingAggregationRepository(
return (this as Number?)?.toLong() ?: 0L return (this as Number?)?.toLong() ?: 0L
} }
companion object { private fun Any?.toLocalDateTimeOrNull(): LocalDateTime? {
private const val MINIMUM_FINAL_SCORE = 1.0 return when (this) {
null -> null
is LocalDateTime -> this
is Timestamp -> toLocalDateTime()
else -> throw IllegalStateException("Unsupported creator debut timestamp type: ${this::class.java.name}")
}
}
companion object {
private val AGGREGATION_SQL = """ private val AGGREGATION_SQL = """
with active_creators as ( with active_creators as (
select id, nickname, profile_image select id, nickname, profile_image
@@ -104,9 +78,7 @@ class DefaultCreatorRankingAggregationRepository(
), can_metrics as ( ), can_metrics as (
select ucc.recipient_creator_id as creator_id, select ucc.recipient_creator_id as creator_id,
sum(case when uc.can_usage in ('DONATION', 'LIVE', 'SPIN_ROULETTE') then ucc.can else 0 end) as live_can_amount, sum(case when uc.can_usage in ('DONATION', 'LIVE', 'SPIN_ROULETTE') then ucc.can else 0 end) as live_can_amount,
sum(case when uc.can_usage = 'ORDER_CONTENT' then ucc.can else 0 end) as content_purchase_can_amount, sum(case when uc.can_usage = 'ORDER_CONTENT' then ucc.can else 0 end) as content_purchase_can_amount
sum(case when uc.can_usage = 'CHANNEL_DONATION' then ucc.can else 0 end) as channel_donation_can_amount,
sum(case when uc.can_usage = 'CHANNEL_DONATION' then 1 else 0 end) as channel_donation_count
from use_can_calculate ucc from use_can_calculate ucc
join use_can uc on uc.id = ucc.use_can_id join use_can uc on uc.id = ucc.use_can_id
where ucc.recipient_creator_id is not null where ucc.recipient_creator_id is not null
@@ -115,43 +87,6 @@ class DefaultCreatorRankingAggregationRepository(
and ucc.created_at >= :startInclusiveUtc and ucc.created_at >= :startInclusiveUtc
and ucc.created_at < :endExclusiveUtc and ucc.created_at < :endExclusiveUtc
group by ucc.recipient_creator_id group by ucc.recipient_creator_id
), like_metrics as (
select c.member_id as creator_id,
count(cl.id) as content_like_count
from content_like cl
join content c on c.id = cl.content_id
where c.is_active = true
and cl.is_active = true
and cl.created_at >= :startInclusiveUtc
and cl.created_at < :endExclusiveUtc
group by c.member_id
), comment_metrics as (
select c.member_id as creator_id,
count(cc.id) as content_comment_count
from content_comment cc
join content c on c.id = cc.content_id
where c.is_active = true
and cc.is_active = true
and cc.member_id <> c.member_id
and cc.created_at >= :startInclusiveUtc
and cc.created_at < :endExclusiveUtc
group by c.member_id
), fan_talk_metrics as (
select creator_id,
count(id) as fan_talk_count
from creator_cheers
where is_active = true
and parent_id is null
and created_at >= :startInclusiveUtc
and created_at < :endExclusiveUtc
group by creator_id
), final_follower_metrics as (
select creator_id,
count(id) as final_follower_count
from creator_following
where is_active = true
and created_at < :endExclusiveUtc
group by creator_id
), new_follow_metrics as ( ), new_follow_metrics as (
select creator_id, select creator_id,
count(id) as new_follow_count count(id) as new_follow_count
@@ -167,37 +102,68 @@ class DefaultCreatorRankingAggregationRepository(
and updated_at >= :startInclusiveUtc and updated_at >= :startInclusiveUtc
and updated_at < :endExclusiveUtc and updated_at < :endExclusiveUtc
group by creator_id group by creator_id
), ai_chat_metrics as (
select creator_member.id as creator_id,
count(cm.id) as ai_chat_count
from chat_message cm
join chat_participant cp on cp.id = cm.participant_id
join chat_character cc on cc.id = cp.character_id
join member creator_member on creator_member.id = cc.creator_member_id
where cm.is_active = true
and cp.is_active = true
and cp.participant_type = 'CHARACTER'
and cc.is_active = true
and creator_member.is_active = true
and creator_member.role = 'CREATOR'
and cm.created_at >= :startInclusiveUtc
and cm.created_at < :endExclusiveUtc
group by creator_member.id
), first_live as (
select member_id as creator_id,
min(begin_date_time) as first_live_begin_at
from live_room
where channel_name is not null
and begin_date_time is not null
and begin_date_time <= current_timestamp
group by member_id
), first_content as (
select member_id as creator_id,
min(release_date) as first_content_release_at
from content
where is_active = true
and release_date is not null
and release_date <= current_timestamp
group by member_id
), debut_metrics as (
select ac.id as creator_id,
case
when fl.first_live_begin_at is null then fc.first_content_release_at
when fc.first_content_release_at is null then fl.first_live_begin_at
when fl.first_live_begin_at <= fc.first_content_release_at then fl.first_live_begin_at
else fc.first_content_release_at
end as creator_debut_at
from active_creators ac
left join first_live fl on fl.creator_id = ac.id
left join first_content fc on fc.creator_id = ac.id
) )
select ac.id as creator_id, select ac.id as creator_id,
ac.nickname as nickname, ac.nickname as nickname,
ac.profile_image as profile_image_url, ac.profile_image as profile_image_url,
dm.creator_debut_at as creator_debut_at,
coalesce(cm.live_can_amount, 0) as live_can_amount, coalesce(cm.live_can_amount, 0) as live_can_amount,
coalesce(cm.content_purchase_can_amount, 0) as content_purchase_can_amount, coalesce(cm.content_purchase_can_amount, 0) as content_purchase_can_amount,
coalesce(lm.content_like_count, 0) as content_like_count, greatest(coalesce(nfm.new_follow_count, 0) - coalesce(um.unfollow_count, 0), 0) as follow_increase,
coalesce(com.content_comment_count, 0) as content_comment_count, coalesce(acm.ai_chat_count, 0) as ai_chat_count
coalesce(cm.channel_donation_can_amount, 0) as channel_donation_can_amount,
coalesce(cm.channel_donation_count, 0) as channel_donation_count,
coalesce(ftm.fan_talk_count, 0) as fan_talk_count,
coalesce(ffm.final_follower_count, 0) as final_follower_count,
coalesce(nfm.new_follow_count, 0) - coalesce(um.unfollow_count, 0) as follow_increase
from active_creators ac from active_creators ac
left join can_metrics cm on cm.creator_id = ac.id left join can_metrics cm on cm.creator_id = ac.id
left join like_metrics lm on lm.creator_id = ac.id
left join comment_metrics com on com.creator_id = ac.id
left join fan_talk_metrics ftm on ftm.creator_id = ac.id
left join final_follower_metrics ffm on ffm.creator_id = ac.id
left join new_follow_metrics nfm on nfm.creator_id = ac.id left join new_follow_metrics nfm on nfm.creator_id = ac.id
left join unfollow_metrics um on um.creator_id = ac.id left join unfollow_metrics um on um.creator_id = ac.id
where coalesce(cm.live_can_amount, 0) <> 0 left join ai_chat_metrics acm on acm.creator_id = ac.id
or coalesce(cm.content_purchase_can_amount, 0) <> 0 left join debut_metrics dm on dm.creator_id = ac.id
or coalesce(lm.content_like_count, 0) <> 0 where coalesce(cm.live_can_amount, 0) > 0
or coalesce(com.content_comment_count, 0) <> 0 or coalesce(cm.content_purchase_can_amount, 0) > 0
or coalesce(cm.channel_donation_can_amount, 0) <> 0 or greatest(coalesce(nfm.new_follow_count, 0) - coalesce(um.unfollow_count, 0), 0) > 0
or coalesce(cm.channel_donation_count, 0) <> 0 or coalesce(acm.ai_chat_count, 0) > 0
or coalesce(ftm.fan_talk_count, 0) <> 0
or coalesce(ffm.final_follower_count, 0) <> 0
or coalesce(nfm.new_follow_count, 0) <> 0
or coalesce(um.unfollow_count, 0) <> 0
""".trimIndent() """.trimIndent()
} }
} }

View File

@@ -1,8 +1,10 @@
package kr.co.vividnext.sodalive.v2.ranking.adapter.out.persistence package kr.co.vividnext.sodalive.v2.ranking.adapter.out.persistence
import kr.co.vividnext.sodalive.v2.ranking.domain.CreatorRankingType
import kr.co.vividnext.sodalive.v2.ranking.port.out.CreatorRankingSnapshotJobPort import kr.co.vividnext.sodalive.v2.ranking.port.out.CreatorRankingSnapshotJobPort
import kr.co.vividnext.sodalive.v2.ranking.port.out.CreatorRankingSnapshotJobRecord import kr.co.vividnext.sodalive.v2.ranking.port.out.CreatorRankingSnapshotJobRecord
import kr.co.vividnext.sodalive.v2.ranking.port.out.CreatorRankingSnapshotJobStatus import kr.co.vividnext.sodalive.v2.ranking.port.out.CreatorRankingSnapshotJobStatus
import kr.co.vividnext.sodalive.v2.ranking.port.out.CreatorRankingSnapshotJobTrigger
import org.springframework.stereotype.Repository import org.springframework.stereotype.Repository
import org.springframework.transaction.annotation.Transactional import org.springframework.transaction.annotation.Transactional
import java.time.LocalDateTime import java.time.LocalDateTime
@@ -32,6 +34,20 @@ class DefaultCreatorRankingSnapshotJobRepository(
).map { it.toRecord() } ).map { it.toRecord() }
} }
override fun countByRankingTypeAndPeriodAndTrigger(
rankingType: CreatorRankingType,
aggregationStartAtUtc: LocalDateTime,
aggregationEndAtUtc: LocalDateTime,
trigger: CreatorRankingSnapshotJobTrigger
): Long {
return repository.countByRankingTypeAndAggregationStartAtUtcAndAggregationEndAtUtcAndTrigger(
rankingType = rankingType,
aggregationStartAtUtc = aggregationStartAtUtc,
aggregationEndAtUtc = aggregationEndAtUtc,
trigger = trigger
)
}
@Transactional @Transactional
override fun markProcessing(jobId: Long, processingStartedAt: LocalDateTime): CreatorRankingSnapshotJobRecord? { override fun markProcessing(jobId: Long, processingStartedAt: LocalDateTime): CreatorRankingSnapshotJobRecord? {
val job = repository.findByIdForUpdate(jobId) ?: return null val job = repository.findByIdForUpdate(jobId) ?: return null

View File

@@ -15,7 +15,7 @@ class DefaultCreatorRankingSnapshotRepository(
aggregationStartAtUtc: LocalDateTime, aggregationStartAtUtc: LocalDateTime,
aggregationEndAtUtc: LocalDateTime aggregationEndAtUtc: LocalDateTime
): List<CreatorRankingSnapshotRecord> { ): List<CreatorRankingSnapshotRecord> {
return repository.findAllByAggregationStartAtUtcAndAggregationEndAtUtcOrderByFinalScoreDesc( return repository.findAllByAggregationStartAtUtcAndAggregationEndAtUtcOrderByRankNoAsc(
aggregationStartAtUtc = aggregationStartAtUtc, aggregationStartAtUtc = aggregationStartAtUtc,
aggregationEndAtUtc = aggregationEndAtUtc aggregationEndAtUtc = aggregationEndAtUtc
).map { it.toRecord() } ).map { it.toRecord() }
@@ -42,16 +42,12 @@ class DefaultCreatorRankingSnapshotRepository(
nowUtc: LocalDateTime nowUtc: LocalDateTime
): List<CreatorRankingSnapshotRecord> { ): List<CreatorRankingSnapshotRecord> {
return repository.findPreviousVisibleSnapshots( return repository.findPreviousVisibleSnapshots(
rankingType = rankingType.name, rankingType.name,
currentAggregationStartAtUtc = currentAggregationStartAtUtc, currentAggregationStartAtUtc,
nowUtc = nowUtc nowUtc
).map { it.toRecord() } ).map { it.toRecord() }
} }
override fun isSnapshotTableEmpty(): Boolean {
return repository.count() == 0L
}
@Transactional @Transactional
override fun replaceSnapshots( override fun replaceSnapshots(
rankingType: CreatorRankingType, rankingType: CreatorRankingType,
@@ -61,9 +57,9 @@ class DefaultCreatorRankingSnapshotRepository(
newSnapshots: List<CreatorRankingSnapshotRecord> newSnapshots: List<CreatorRankingSnapshotRecord>
) { ) {
repository.deleteByRankingTypeAndAggregationStartAtUtcAndAggregationEndAtUtc( repository.deleteByRankingTypeAndAggregationStartAtUtcAndAggregationEndAtUtc(
rankingType = rankingType, rankingType,
aggregationStartAtUtc = aggregationStartAtUtc, aggregationStartAtUtc,
aggregationEndAtUtc = aggregationEndAtUtc aggregationEndAtUtc
) )
repository.saveAll(newSnapshots.map { it.toEntity(rankingType, visibleFromAtUtc) }) repository.saveAll(newSnapshots.map { it.toEntity(rankingType, visibleFromAtUtc) })
} }
@@ -77,20 +73,10 @@ class DefaultCreatorRankingSnapshotRepository(
creatorId = creatorId, creatorId = creatorId,
nickname = nickname, nickname = nickname,
profileImageUrl = profileImageUrl, profileImageUrl = profileImageUrl,
rankNo = rankNo,
finalScore = finalScore, finalScore = finalScore,
contentLiveScore = contentLiveScore, scorePolicyVersion = scorePolicyVersion,
engagementScore = engagementScore, scoreDetailJson = scoreDetailJson
supportScore = supportScore,
fanLoyaltyScore = fanLoyaltyScore,
liveCanAmount = liveCanAmount,
contentPurchaseCanAmount = contentPurchaseCanAmount,
contentLikeCount = contentLikeCount,
contentCommentCount = contentCommentCount,
channelDonationCanAmount = channelDonationCanAmount,
channelDonationCount = channelDonationCount,
fanTalkCount = fanTalkCount,
finalFollowerCount = finalFollowerCount,
followIncrease = followIncrease
) )
} }
@@ -106,20 +92,10 @@ class DefaultCreatorRankingSnapshotRepository(
creatorId = creatorId, creatorId = creatorId,
nickname = nickname, nickname = nickname,
profileImageUrl = profileImageUrl, profileImageUrl = profileImageUrl,
rankNo = rankNo,
finalScore = finalScore, finalScore = finalScore,
contentLiveScore = contentLiveScore, scorePolicyVersion = scorePolicyVersion,
engagementScore = engagementScore, scoreDetailJson = scoreDetailJson
supportScore = supportScore,
fanLoyaltyScore = fanLoyaltyScore,
liveCanAmount = liveCanAmount,
contentPurchaseCanAmount = contentPurchaseCanAmount,
contentLikeCount = contentLikeCount,
contentCommentCount = contentCommentCount,
channelDonationCanAmount = channelDonationCanAmount,
channelDonationCount = channelDonationCount,
fanTalkCount = fanTalkCount,
finalFollowerCount = finalFollowerCount,
followIncrease = followIncrease
) )
} }
} }

View File

@@ -2,19 +2,13 @@ package kr.co.vividnext.sodalive.v2.ranking.application
import kr.co.vividnext.sodalive.v2.common.domain.toCdnUrl import kr.co.vividnext.sodalive.v2.common.domain.toCdnUrl
import kr.co.vividnext.sodalive.v2.ranking.domain.CreatorRankingItem import kr.co.vividnext.sodalive.v2.ranking.domain.CreatorRankingItem
import kr.co.vividnext.sodalive.v2.ranking.domain.CreatorRankingPeriodPolicy
import kr.co.vividnext.sodalive.v2.ranking.domain.CreatorRankingScorePolicy
import kr.co.vividnext.sodalive.v2.ranking.domain.CreatorRankingSnapshotCandidate
import kr.co.vividnext.sodalive.v2.ranking.domain.CreatorRankingType import kr.co.vividnext.sodalive.v2.ranking.domain.CreatorRankingType
import kr.co.vividnext.sodalive.v2.ranking.domain.CreatorRankingUtcRange
import kr.co.vividnext.sodalive.v2.ranking.port.out.CreatorRankingAggregationPort
import kr.co.vividnext.sodalive.v2.ranking.port.out.CreatorRankingBlockPort import kr.co.vividnext.sodalive.v2.ranking.port.out.CreatorRankingBlockPort
import kr.co.vividnext.sodalive.v2.ranking.port.out.CreatorRankingSnapshotPort import kr.co.vividnext.sodalive.v2.ranking.port.out.CreatorRankingSnapshotPort
import kr.co.vividnext.sodalive.v2.ranking.port.out.CreatorRankingSnapshotRecord import kr.co.vividnext.sodalive.v2.ranking.port.out.CreatorRankingSnapshotRecord
import org.slf4j.LoggerFactory import org.slf4j.LoggerFactory
import org.springframework.beans.factory.annotation.Value import org.springframework.beans.factory.annotation.Value
import org.springframework.stereotype.Service import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional
import java.time.LocalDateTime import java.time.LocalDateTime
import java.time.ZoneId import java.time.ZoneId
import java.time.ZonedDateTime import java.time.ZonedDateTime
@@ -23,42 +17,21 @@ import java.time.ZonedDateTime
class CreatorRankingQueryService( class CreatorRankingQueryService(
private val snapshotPort: CreatorRankingSnapshotPort, private val snapshotPort: CreatorRankingSnapshotPort,
private val blockPort: CreatorRankingBlockPort, private val blockPort: CreatorRankingBlockPort,
private val aggregationPort: CreatorRankingAggregationPort,
private val snapshotJobService: CreatorRankingSnapshotJobService, private val snapshotJobService: CreatorRankingSnapshotJobService,
private val nowProvider: () -> ZonedDateTime = { ZonedDateTime.now() }, private val nowProvider: () -> ZonedDateTime = { ZonedDateTime.now() },
@Value("\${cloud.aws.cloud-front.host}") @Value("\${cloud.aws.cloud-front.host}")
private val cloudFrontHost: String private val cloudFrontHost: String
) { ) {
private val log = LoggerFactory.getLogger(javaClass) private val log = LoggerFactory.getLogger(javaClass)
private val periodPolicy = CreatorRankingPeriodPolicy()
private val scorePolicy = CreatorRankingScorePolicy()
@Transactional(readOnly = true)
fun getCreatorRankings(viewerMemberId: Long?): CreatorRankingResult { fun getCreatorRankings(viewerMemberId: Long?): CreatorRankingResult {
val startedAt = System.currentTimeMillis() val startedAt = System.currentTimeMillis()
return runCatching { return runCatching {
val nowUtc = nowUtc() val nowUtc = nowUtc()
val latestSnapshots = snapshotPort.findLatestVisibleSnapshots(CreatorRankingType.WEEKLY, nowUtc) val latestSnapshots = findLatestVisibleSnapshots(nowUtc)
val latestItems = latestSnapshots.toRankedItems() val latestItems = latestSnapshots.toRankedItems()
if (latestItems.isEmpty()) { if (latestItems.isEmpty()) {
if (snapshotPort.isSnapshotTableEmpty()) { return@runCatching QueryLogResult(CreatorRankingResult(showRankChange = false, items = emptyList()), 0)
val fallbackItems = aggregateColdStartFallback(nowUtc).toRankedItems()
if (fallbackItems.isNotEmpty()) {
delegateColdStartSnapshotRefresh()
}
val blockedCreatorIds = findBlockedCreatorIds(viewerMemberId = viewerMemberId, items = fallbackItems)
return@runCatching QueryLogResult(
result = CreatorRankingResult(
showRankChange = false,
items = fallbackItems.map { it.maskIfBlocked(blockedCreatorIds) }
),
blockedCreatorCount = blockedCreatorIds.size
)
}
return@runCatching QueryLogResult(
result = CreatorRankingResult(showRankChange = false, items = emptyList()),
blockedCreatorCount = 0
)
} }
val previousItems = snapshotPort.findPreviousVisibleSnapshots( val previousItems = snapshotPort.findPreviousVisibleSnapshots(
@@ -77,10 +50,7 @@ class CreatorRankingQueryService(
).maskIfBlocked(blockedCreatorIds) ).maskIfBlocked(blockedCreatorIds)
} }
QueryLogResult( QueryLogResult(CreatorRankingResult(showRankChange = showRankChange, items = items), blockedCreatorIds.size)
result = CreatorRankingResult(showRankChange = showRankChange, items = items),
blockedCreatorCount = blockedCreatorIds.size
)
}.onSuccess { logResult -> }.onSuccess { logResult ->
log.info( log.info(
"event=creator_ranking_query_success showRankChange={} itemCount={} blockedCreatorCount={} elapsedMs={}", "event=creator_ranking_query_success showRankChange={} itemCount={} blockedCreatorCount={} elapsedMs={}",
@@ -99,75 +69,30 @@ class CreatorRankingQueryService(
}.getOrThrow().result }.getOrThrow().result
} }
private fun findLatestVisibleSnapshots(nowUtc: LocalDateTime): List<CreatorRankingSnapshotRecord> {
val latestSnapshots = snapshotPort.findLatestVisibleSnapshots(CreatorRankingType.WEEKLY, nowUtc)
if (latestSnapshots.isNotEmpty()) return latestSnapshots
runCatching { snapshotJobService.refreshLastCompletedWeekByFallback() }
.onFailure { ex ->
log.warn("event=creator_ranking_query_fallback_failure error={}", ex.message, ex)
}
return snapshotPort.findLatestVisibleSnapshots(CreatorRankingType.WEEKLY, nowUtc)
}
private data class QueryLogResult( private data class QueryLogResult(
val result: CreatorRankingResult, val result: CreatorRankingResult,
val blockedCreatorCount: Int val blockedCreatorCount: Int
) )
private fun aggregateColdStartFallback(nowUtc: LocalDateTime): List<CreatorRankingSnapshotRecord> {
val startedAt = System.currentTimeMillis()
val period = periodPolicy.resolveLastCompletedWeek(nowProvider())
val utcRange = periodPolicy.toUtcRange(period)
val visibleFromAtUtc = periodPolicy.resolveVisibleFromAtUtc(period.endExclusiveKst)
if (visibleFromAtUtc > nowUtc) {
return emptyList()
}
log.info(
"event=creator_ranking_query_cold_start_fallback_attempt " +
"aggregationStartAtUtc={} aggregationEndAtUtc={}",
utcRange.startInclusiveUtc,
utcRange.endExclusiveUtc
)
return runCatching {
aggregationPort.aggregateCandidates(
startInclusiveUtc = utcRange.startInclusiveUtc,
endExclusiveUtc = utcRange.endExclusiveUtc
).map { it.toSnapshotRecord(utcRange) }
}.onSuccess { snapshots ->
log.info(
"event=creator_ranking_query_cold_start_fallback_success " +
"aggregationStartAtUtc={} aggregationEndAtUtc={} itemCount={} elapsedMs={}",
utcRange.startInclusiveUtc,
utcRange.endExclusiveUtc,
snapshots.size.coerceAtMost(RANKING_LIMIT),
System.currentTimeMillis() - startedAt
)
}.onFailure { ex ->
log.warn(
"event=creator_ranking_query_cold_start_fallback_failure " +
"aggregationStartAtUtc={} aggregationEndAtUtc={} elapsedMs={} error={}",
utcRange.startInclusiveUtc,
utcRange.endExclusiveUtc,
System.currentTimeMillis() - startedAt,
ex.message,
ex
)
}.getOrThrow()
}
private fun delegateColdStartSnapshotRefresh() {
runCatching {
snapshotJobService.ensureLastCompletedWeekSnapshotForColdStart()
}.onFailure { ex ->
log.warn(
"event=creator_ranking_query_cold_start_snapshot_refresh_failure error={}",
ex.message,
ex
)
}
}
private fun nowUtc(): LocalDateTime { private fun nowUtc(): LocalDateTime {
return nowProvider().withZoneSameInstant(UTC_ZONE).toLocalDateTime() return nowProvider().withZoneSameInstant(UTC_ZONE).toLocalDateTime()
} }
private fun List<CreatorRankingSnapshotRecord>.toRankedItems(): List<CreatorRankingItem> { private fun List<CreatorRankingSnapshotRecord>.toRankedItems(): List<CreatorRankingItem> {
return groupBy { it.finalScore } return sortedBy { it.rankNo }
.toSortedMap(compareByDescending { it })
.values
.flatMap { it.shuffled() }
.take(RANKING_LIMIT) .take(RANKING_LIMIT)
.mapIndexed { index, snapshot -> snapshot.toItem(rank = index + 1) } .map { snapshot -> snapshot.toItem(rank = snapshot.rankNo) }
} }
private fun CreatorRankingSnapshotRecord.toItem(rank: Int): CreatorRankingItem { private fun CreatorRankingSnapshotRecord.toItem(rank: Int): CreatorRankingItem {
@@ -181,70 +106,13 @@ class CreatorRankingQueryService(
) )
} }
private fun CreatorRankingSnapshotCandidate.toSnapshotRecord(utcRange: CreatorRankingUtcRange): CreatorRankingSnapshotRecord {
val calculatedContentLiveScore = scorePolicy.calculateContentLiveScore(
liveCanAmount = liveCanAmount,
contentPurchaseCanAmount = contentPurchaseCanAmount
)
val calculatedEngagementScore = scorePolicy.calculateEngagementScore(
contentLikeCount = contentLikeCount,
contentCommentCount = contentCommentCount
)
val calculatedSupportScore = scorePolicy.calculateSupportScore(
channelDonationCanAmount = channelDonationCanAmount,
channelDonationCount = channelDonationCount,
fanTalkCount = fanTalkCount
)
val calculatedFanLoyaltyScore = scorePolicy.calculateFanLoyaltyScore(
finalFollowerCount = finalFollowerCount,
followIncrease = followIncrease
)
val calculatedFinalScore = scorePolicy.calculateFinalScore(
contentLiveScore = calculatedContentLiveScore,
engagementScore = calculatedEngagementScore,
supportScore = calculatedSupportScore,
fanLoyaltyScore = calculatedFanLoyaltyScore
)
return CreatorRankingSnapshotRecord(
rankingType = CreatorRankingType.WEEKLY,
aggregationStartAtUtc = utcRange.startInclusiveUtc,
aggregationEndAtUtc = utcRange.endExclusiveUtc,
visibleFromAtUtc = utcRange.endExclusiveUtc.plusHours(9),
creatorId = creatorId,
nickname = nickname,
profileImageUrl = profileImageUrl,
finalScore = calculatedFinalScore,
contentLiveScore = calculatedContentLiveScore,
engagementScore = calculatedEngagementScore,
supportScore = calculatedSupportScore,
fanLoyaltyScore = calculatedFanLoyaltyScore,
liveCanAmount = liveCanAmount,
contentPurchaseCanAmount = contentPurchaseCanAmount,
contentLikeCount = contentLikeCount,
contentCommentCount = contentCommentCount,
channelDonationCanAmount = channelDonationCanAmount,
channelDonationCount = channelDonationCount,
fanTalkCount = fanTalkCount,
finalFollowerCount = finalFollowerCount,
followIncrease = followIncrease
)
}
private fun findBlockedCreatorIds(viewerMemberId: Long?, items: List<CreatorRankingItem>): Set<Long> { private fun findBlockedCreatorIds(viewerMemberId: Long?, items: List<CreatorRankingItem>): Set<Long> {
if (viewerMemberId == null) { if (viewerMemberId == null) return emptySet()
return emptySet() return blockPort.findBlockedCreatorIds(memberId = viewerMemberId, creatorIds = items.map { it.creatorId })
}
return blockPort.findBlockedCreatorIds(
memberId = viewerMemberId,
creatorIds = items.map { it.creatorId }
)
} }
private fun CreatorRankingItem.maskIfBlocked(blockedCreatorIds: Set<Long>): CreatorRankingItem { private fun CreatorRankingItem.maskIfBlocked(blockedCreatorIds: Set<Long>): CreatorRankingItem {
if (!blockedCreatorIds.contains(creatorId)) { if (!blockedCreatorIds.contains(creatorId)) return this
return this
}
return copy( return copy(
creatorId = MASKED_CREATOR_ID, creatorId = MASKED_CREATOR_ID,
nickname = MASKED_NICKNAME, nickname = MASKED_NICKNAME,

View File

@@ -35,15 +35,26 @@ class CreatorRankingSnapshotJobService(
fun refreshLastCompletedWeekByScheduledJob() { fun refreshLastCompletedWeekByScheduledJob() {
withLastCompletedWeekPeriodLock { now, utcRange -> withLastCompletedWeekPeriodLock { now, utcRange ->
refreshLastCompletedWeekByScheduledJob(now, utcRange) refreshLastCompletedWeek(now, utcRange, CreatorRankingSnapshotJobTrigger.SCHEDULED)
} }
} }
private fun refreshLastCompletedWeekByScheduledJob( fun refreshLastCompletedWeekByFallback(): Boolean {
var refreshed = false
withLastCompletedWeekPeriodLock { now, utcRange ->
if (fallbackCountReachedLimit(utcRange)) return@withLastCompletedWeekPeriodLock
refreshLastCompletedWeek(now, utcRange, CreatorRankingSnapshotJobTrigger.FALLBACK)
refreshed = true
}
return refreshed
}
private fun refreshLastCompletedWeek(
now: ZonedDateTime, now: ZonedDateTime,
utcRange: CreatorRankingUtcRange utcRange: CreatorRankingUtcRange,
trigger: CreatorRankingSnapshotJobTrigger
) { ) {
val job = savePendingJob(utcRange, CreatorRankingSnapshotJobTrigger.SCHEDULED) val job = savePendingJob(utcRange, trigger)
val jobId = job.id ?: return val jobId = job.id ?: return
markProcessing(jobId) markProcessing(jobId)
logJobStatusChanged(job, CreatorRankingSnapshotJobStatus.PROCESSING) logJobStatusChanged(job, CreatorRankingSnapshotJobStatus.PROCESSING)
@@ -85,22 +96,25 @@ class CreatorRankingSnapshotJobService(
}!! }!!
} }
private fun markProcessing(jobId: Long) { private fun fallbackCountReachedLimit(utcRange: CreatorRankingUtcRange): Boolean {
transactionTemplate.executeWithoutResult { return jobPort.countByRankingTypeAndPeriodAndTrigger(
jobPort.markProcessing(jobId, LocalDateTime.now()) rankingType = CreatorRankingType.WEEKLY,
aggregationStartAtUtc = utcRange.startInclusiveUtc,
aggregationEndAtUtc = utcRange.endExclusiveUtc,
trigger = CreatorRankingSnapshotJobTrigger.FALLBACK
) >= FALLBACK_LIMIT
} }
private fun markProcessing(jobId: Long) {
transactionTemplate.executeWithoutResult { jobPort.markProcessing(jobId, LocalDateTime.now()) }
} }
private fun markDone(jobId: Long) { private fun markDone(jobId: Long) {
transactionTemplate.executeWithoutResult { transactionTemplate.executeWithoutResult { jobPort.markDone(jobId, LocalDateTime.now()) }
jobPort.markDone(jobId, LocalDateTime.now())
}
} }
private fun markFailed(jobId: Long, message: String?) { private fun markFailed(jobId: Long, message: String?) {
transactionTemplate.executeWithoutResult { transactionTemplate.executeWithoutResult { jobPort.markFailed(jobId, LocalDateTime.now(), message) }
jobPort.markFailed(jobId, LocalDateTime.now(), message)
}
} }
@Transactional @Transactional
@@ -128,34 +142,22 @@ class CreatorRankingSnapshotJobService(
aggregationEndAtUtc: LocalDateTime, aggregationEndAtUtc: LocalDateTime,
statuses: List<CreatorRankingSnapshotJobStatus> = CreatorRankingSnapshotJobStatus.values().toList() statuses: List<CreatorRankingSnapshotJobStatus> = CreatorRankingSnapshotJobStatus.values().toList()
): List<CreatorRankingSnapshotJobRecord> { ): List<CreatorRankingSnapshotJobRecord> {
return jobPort.findByPeriodAndStatuses( return jobPort.findByPeriodAndStatuses(aggregationStartAtUtc, aggregationEndAtUtc, statuses)
aggregationStartAtUtc = aggregationStartAtUtc,
aggregationEndAtUtc = aggregationEndAtUtc,
statuses = statuses
)
} }
@Transactional @Transactional
fun retryFailedJob(jobId: Long) { fun retryFailedJob(jobId: Long) {
val job = jobPort.findById(jobId) ?: return val job = jobPort.findById(jobId) ?: return
if (job.status != CreatorRankingSnapshotJobStatus.FAILED) return if (job.status != CreatorRankingSnapshotJobStatus.FAILED) return
jobPort.markPending(jobId) jobPort.markPending(jobId)
} }
fun ensureLastCompletedWeekSnapshotForColdStart() {
withLastCompletedWeekPeriodLock { now, _ ->
transactionTemplate.executeWithoutResult {
refreshService.refreshLastCompletedWeek(now)
}
}
}
private fun withLastCompletedWeekPeriodLock(action: (ZonedDateTime, CreatorRankingUtcRange) -> Unit) { private fun withLastCompletedWeekPeriodLock(action: (ZonedDateTime, CreatorRankingUtcRange) -> Unit) {
val now = nowProvider() val now = nowProvider()
val period = periodPolicy.resolveLastCompletedWeek(now) val period = periodPolicy.resolveLastCompletedWeek(now)
val utcRange = periodPolicy.toUtcRange(period) val utcRange = periodPolicy.toUtcRange(period)
val lockName = "lock:creator-ranking-snapshot-refresh:${utcRange.startInclusiveUtc}:${utcRange.endExclusiveUtc}" val lockName = "lock:creator-ranking-snapshot-refresh:" +
"${CreatorRankingType.WEEKLY}:${utcRange.startInclusiveUtc}:${utcRange.endExclusiveUtc}"
val lock = redissonClient.getLock(lockName) val lock = redissonClient.getLock(lockName)
try { try {
@@ -185,4 +187,8 @@ class CreatorRankingSnapshotJobService(
error error
) )
} }
companion object {
private const val FALLBACK_LIMIT = 3L
}
} }

View File

@@ -2,7 +2,9 @@ package kr.co.vividnext.sodalive.v2.ranking.application
import kr.co.vividnext.sodalive.v2.home.following.application.HomeFollowingNewsPublishService import kr.co.vividnext.sodalive.v2.home.following.application.HomeFollowingNewsPublishService
import kr.co.vividnext.sodalive.v2.ranking.domain.CreatorRankingPeriodPolicy import kr.co.vividnext.sodalive.v2.ranking.domain.CreatorRankingPeriodPolicy
import kr.co.vividnext.sodalive.v2.ranking.domain.CreatorRankingScoreDetail
import kr.co.vividnext.sodalive.v2.ranking.domain.CreatorRankingScorePolicy import kr.co.vividnext.sodalive.v2.ranking.domain.CreatorRankingScorePolicy
import kr.co.vividnext.sodalive.v2.ranking.domain.CreatorRankingScoreSpec
import kr.co.vividnext.sodalive.v2.ranking.domain.CreatorRankingSnapshotCandidate import kr.co.vividnext.sodalive.v2.ranking.domain.CreatorRankingSnapshotCandidate
import kr.co.vividnext.sodalive.v2.ranking.domain.CreatorRankingType import kr.co.vividnext.sodalive.v2.ranking.domain.CreatorRankingType
import kr.co.vividnext.sodalive.v2.ranking.domain.CreatorRankingUtcRange import kr.co.vividnext.sodalive.v2.ranking.domain.CreatorRankingUtcRange
@@ -38,9 +40,7 @@ class CreatorRankingSnapshotRefreshService(
startInclusiveUtc = utcRange.startInclusiveUtc, startInclusiveUtc = utcRange.startInclusiveUtc,
endExclusiveUtc = utcRange.endExclusiveUtc endExclusiveUtc = utcRange.endExclusiveUtc
) )
val snapshots = aggregationResult.candidates.map { it.toSnapshotRecord(utcRange) } val snapshots = assembleSnapshots(aggregationResult.candidates, utcRange, visibleFromAtUtc)
.sortedByDescending { it.finalScore }
.takeRankedBoundary(limit = SNAPSHOT_LIMIT)
snapshotPort.replaceSnapshots( snapshotPort.replaceSnapshots(
rankingType = CreatorRankingType.WEEKLY, rankingType = CreatorRankingType.WEEKLY,
@@ -50,7 +50,7 @@ class CreatorRankingSnapshotRefreshService(
newSnapshots = snapshots newSnapshots = snapshots
) )
afterCommit { afterCommit {
snapshots.forEachIndexed { index, snapshot -> snapshots.forEach { snapshot ->
runCatching { runCatching {
homeFollowingNewsPublishService.publishCreatorRankingVisible( homeFollowingNewsPublishService.publishCreatorRankingVisible(
creatorId = snapshot.creatorId, creatorId = snapshot.creatorId,
@@ -58,13 +58,13 @@ class CreatorRankingSnapshotRefreshService(
creatorProfileImagePath = snapshot.profileImageUrl, creatorProfileImagePath = snapshot.profileImageUrl,
aggregationStartAtUtc = utcRange.startInclusiveUtc, aggregationStartAtUtc = utcRange.startInclusiveUtc,
visibleFromAtUtc = visibleFromAtUtc, visibleFromAtUtc = visibleFromAtUtc,
rank = index + 1 rank = snapshot.rankNo
) )
}.onFailure { ex -> }.onFailure { ex ->
log.warn( log.warn(
"event=home_following_creator_ranking_news_publish_failure creatorId={} rank={} error={}", "event=home_following_creator_ranking_news_publish_failure creatorId={} rank={} error={}",
snapshot.creatorId, snapshot.creatorId,
index + 1, snapshot.rankNo,
ex.message, ex.message,
ex ex
) )
@@ -100,12 +100,115 @@ class CreatorRankingSnapshotRefreshService(
} }
} }
private fun CreatorRankingAggregationResult.toLogCounts(storedCount: Int): RefreshLogCounts { private fun assembleSnapshots(
return RefreshLogCounts( candidates: List<CreatorRankingSnapshotCandidate>,
candidateCount = candidates.size, utcRange: CreatorRankingUtcRange,
storedCount = storedCount, visibleFromAtUtc: java.time.LocalDateTime
lowScoreExcludedCount = lowScoreExcludedCount ): List<CreatorRankingSnapshotRecord> {
val liveRanks = candidates.toSqlRanks { it.liveCanAmount }
val contentRanks = candidates.toSqlRanks { it.contentPurchaseCanAmount }
val followRanks = candidates.toSqlRanks { it.followIncrease.coerceAtLeast(0L) }
val aiChatRanks = candidates.toSqlRanks { it.aiChatCount }
return candidates.map { candidate ->
val live = metric(
candidate.liveCanAmount,
liveRanks.getValue(candidate.creatorId),
CreatorRankingScoreSpec.LIVE_REVENUE_WEIGHT
) )
val content = metric(
candidate.contentPurchaseCanAmount,
contentRanks.getValue(candidate.creatorId),
CreatorRankingScoreSpec.CONTENT_REVENUE_WEIGHT
)
val followRaw = scorePolicy.normalizeFollowIncrease(candidate.followIncrease)
val follow = metric(
followRaw,
followRanks.getValue(candidate.creatorId),
CreatorRankingScoreSpec.FOLLOW_INCREASE_WEIGHT
)
val aiChat = metric(
candidate.aiChatCount,
aiChatRanks.getValue(candidate.creatorId),
CreatorRankingScoreSpec.AI_CHAT_WEIGHT
)
val finalScore = scorePolicy.calculateFinalScore(
live.rankScore,
content.rankScore,
follow.rankScore,
aiChat.rankScore
)
val detail = CreatorRankingScoreDetail(
metrics = CreatorRankingScoreDetail.Metrics(
liveRevenue = live,
contentRevenue = content,
followIncrease = follow,
aiChat = aiChat
),
tieBreakers = CreatorRankingScoreDetail.TieBreakers(
creatorDebutAt = candidate.creatorDebutAt,
creatorId = candidate.creatorId
)
)
ScoredCandidate(candidate, finalScore, detail.toJson())
}.filter { it.finalScore > 0.0 }
.sortedWith(
compareByDescending<ScoredCandidate> { it.finalScore }
.thenByDescending { it.candidate.creatorDebutAt ?: java.time.LocalDateTime.MIN }
.thenByDescending { it.candidate.creatorId }
)
.take(SNAPSHOT_LIMIT)
.mapIndexed { index, scored ->
CreatorRankingSnapshotRecord(
rankingType = CreatorRankingType.WEEKLY,
aggregationStartAtUtc = utcRange.startInclusiveUtc,
aggregationEndAtUtc = utcRange.endExclusiveUtc,
visibleFromAtUtc = visibleFromAtUtc,
creatorId = scored.candidate.creatorId,
nickname = scored.candidate.nickname,
profileImageUrl = scored.candidate.profileImageUrl,
rankNo = index + 1,
finalScore = scored.finalScore,
scorePolicyVersion = CreatorRankingScoreSpec.SCORE_POLICY_VERSION,
scoreDetailJson = scored.scoreDetailJson
)
}
}
private fun List<CreatorRankingSnapshotCandidate>.toSqlRanks(
metric: (CreatorRankingSnapshotCandidate) -> Long
): Map<Long, Int> {
var previousValue: Long? = null
var currentRank = 0
return sortedByDescending(metric).mapIndexed { index, candidate ->
val value = metric(candidate)
if (previousValue == null || previousValue != value) {
currentRank = index + 1
previousValue = value
}
candidate.creatorId to currentRank
}.toMap()
}
private fun metric(rawValue: Long, rank: Int, weight: Double): CreatorRankingScoreDetail.Metric {
val rankScore = scorePolicy.calculateRankScore(rawValue, rank)
return CreatorRankingScoreDetail.Metric(
rawValue = rawValue,
rank = rank,
rankScore = rankScore,
weight = weight,
weightedScore = rankScore * weight
)
}
private data class ScoredCandidate(
val candidate: CreatorRankingSnapshotCandidate,
val finalScore: Double,
val scoreDetailJson: String
)
private fun CreatorRankingAggregationResult.toLogCounts(storedCount: Int): RefreshLogCounts {
return RefreshLogCounts(candidates.size, storedCount, lowScoreExcludedCount)
} }
private data class RefreshLogCounts( private data class RefreshLogCounts(
@@ -132,62 +235,6 @@ class CreatorRankingSnapshotRefreshService(
) )
} }
private fun CreatorRankingSnapshotCandidate.toSnapshotRecord(utcRange: CreatorRankingUtcRange): CreatorRankingSnapshotRecord {
val calculatedContentLiveScore = scorePolicy.calculateContentLiveScore(
liveCanAmount = liveCanAmount,
contentPurchaseCanAmount = contentPurchaseCanAmount
)
val calculatedEngagementScore = scorePolicy.calculateEngagementScore(
contentLikeCount = contentLikeCount,
contentCommentCount = contentCommentCount
)
val calculatedSupportScore = scorePolicy.calculateSupportScore(
channelDonationCanAmount = channelDonationCanAmount,
channelDonationCount = channelDonationCount,
fanTalkCount = fanTalkCount
)
val calculatedFanLoyaltyScore = scorePolicy.calculateFanLoyaltyScore(
finalFollowerCount = finalFollowerCount,
followIncrease = followIncrease
)
val calculatedFinalScore = scorePolicy.calculateFinalScore(
contentLiveScore = calculatedContentLiveScore,
engagementScore = calculatedEngagementScore,
supportScore = calculatedSupportScore,
fanLoyaltyScore = calculatedFanLoyaltyScore
)
return CreatorRankingSnapshotRecord(
rankingType = CreatorRankingType.WEEKLY,
aggregationStartAtUtc = utcRange.startInclusiveUtc,
aggregationEndAtUtc = utcRange.endExclusiveUtc,
visibleFromAtUtc = utcRange.endExclusiveUtc.plusHours(9),
creatorId = creatorId,
nickname = nickname,
profileImageUrl = profileImageUrl,
finalScore = calculatedFinalScore,
contentLiveScore = calculatedContentLiveScore,
engagementScore = calculatedEngagementScore,
supportScore = calculatedSupportScore,
fanLoyaltyScore = calculatedFanLoyaltyScore,
liveCanAmount = liveCanAmount,
contentPurchaseCanAmount = contentPurchaseCanAmount,
contentLikeCount = contentLikeCount,
contentCommentCount = contentCommentCount,
channelDonationCanAmount = channelDonationCanAmount,
channelDonationCount = channelDonationCount,
fanTalkCount = fanTalkCount,
finalFollowerCount = finalFollowerCount,
followIncrease = followIncrease
)
}
private fun List<CreatorRankingSnapshotRecord>.takeRankedBoundary(limit: Int): List<CreatorRankingSnapshotRecord> {
if (size <= limit) return this
val boundaryScore = this[limit - 1].finalScore
return filter { it.finalScore >= boundaryScore }
}
companion object { companion object {
private const val SNAPSHOT_LIMIT = 20 private const val SNAPSHOT_LIMIT = 20
} }

View File

@@ -0,0 +1,42 @@
package kr.co.vividnext.sodalive.v2.ranking.domain
import com.fasterxml.jackson.databind.ObjectMapper
import com.fasterxml.jackson.databind.SerializationFeature
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
import java.time.LocalDateTime
data class CreatorRankingScoreDetail(
val scorePolicyVersion: String = CreatorRankingScoreSpec.SCORE_POLICY_VERSION,
val formula: String = CreatorRankingScoreSpec.FORMULA,
val metrics: Metrics,
val tieBreakers: TieBreakers
) {
fun toJson(): String = objectMapper.writeValueAsString(this)
data class Metrics(
val liveRevenue: Metric,
val contentRevenue: Metric,
val followIncrease: Metric,
val aiChat: Metric
)
data class Metric(
val rawValue: Long,
val rank: Int,
val rankScore: Double,
val weight: Double,
val weightedScore: Double
)
data class TieBreakers(
val creatorDebutAt: LocalDateTime?,
val creatorId: Long
)
companion object {
private val objectMapper: ObjectMapper = jacksonObjectMapper()
.registerModule(JavaTimeModule())
.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS)
}
}

View File

@@ -1,49 +1,24 @@
package kr.co.vividnext.sodalive.v2.ranking.domain package kr.co.vividnext.sodalive.v2.ranking.domain
class CreatorRankingScorePolicy { class CreatorRankingScorePolicy {
fun calculateContentLiveScore( fun calculateRankScore(rawValue: Long, rank: Int): Double {
liveCanAmount: Long, if (rawValue <= 0L) return 0.0
contentPurchaseCanAmount: Long return (100 - (5 * (rank - 1))).coerceAtLeast(0).toDouble()
): Double {
return (liveCanAmount * CreatorRankingScoreSpec.CONTENT_LIVE_CAN_WEIGHT) +
(contentPurchaseCanAmount * CreatorRankingScoreSpec.CONTENT_PURCHASE_CAN_WEIGHT)
} }
fun calculateEngagementScore( fun normalizeFollowIncrease(value: Long): Long {
contentLikeCount: Long, return value.coerceAtLeast(0L)
contentCommentCount: Long
): Double {
return (contentLikeCount * CreatorRankingScoreSpec.CONTENT_LIKE_COUNT_WEIGHT) +
(contentCommentCount * CreatorRankingScoreSpec.CONTENT_COMMENT_COUNT_WEIGHT)
}
fun calculateSupportScore(
channelDonationCanAmount: Long,
channelDonationCount: Long,
fanTalkCount: Long
): Double {
return (channelDonationCanAmount * CreatorRankingScoreSpec.CHANNEL_DONATION_CAN_WEIGHT) +
(channelDonationCount * CreatorRankingScoreSpec.CHANNEL_DONATION_COUNT_WEIGHT) +
(fanTalkCount * CreatorRankingScoreSpec.FAN_TALK_COUNT_WEIGHT)
}
fun calculateFanLoyaltyScore(
finalFollowerCount: Long,
followIncrease: Long
): Double {
return (finalFollowerCount * CreatorRankingScoreSpec.FINAL_FOLLOWER_COUNT_WEIGHT) +
(followIncrease * CreatorRankingScoreSpec.FOLLOW_INCREASE_WEIGHT)
} }
fun calculateFinalScore( fun calculateFinalScore(
contentLiveScore: Double, liveRevenueRankScore: Double,
engagementScore: Double, contentRevenueRankScore: Double,
supportScore: Double, followIncreaseRankScore: Double,
fanLoyaltyScore: Double aiChatRankScore: Double
): Double { ): Double {
return (contentLiveScore * CreatorRankingScoreSpec.CONTENT_LIVE_SCORE_WEIGHT) + return (liveRevenueRankScore * CreatorRankingScoreSpec.LIVE_REVENUE_WEIGHT) +
(engagementScore * CreatorRankingScoreSpec.ENGAGEMENT_SCORE_WEIGHT) + (contentRevenueRankScore * CreatorRankingScoreSpec.CONTENT_REVENUE_WEIGHT) +
(supportScore * CreatorRankingScoreSpec.SUPPORT_SCORE_WEIGHT) + (followIncreaseRankScore * CreatorRankingScoreSpec.FOLLOW_INCREASE_WEIGHT) +
(fanLoyaltyScore * CreatorRankingScoreSpec.FAN_LOYALTY_SCORE_WEIGHT) (aiChatRankScore * CreatorRankingScoreSpec.AI_CHAT_WEIGHT)
} }
} }

View File

@@ -1,21 +1,12 @@
package kr.co.vividnext.sodalive.v2.ranking.domain package kr.co.vividnext.sodalive.v2.ranking.domain
object CreatorRankingScoreSpec { object CreatorRankingScoreSpec {
const val CONTENT_LIVE_CAN_WEIGHT = 0.7 const val SCORE_POLICY_VERSION = "CREATOR_WEEKLY_POPULARITY_V2"
const val CONTENT_PURCHASE_CAN_WEIGHT = 0.3 const val FORMULA = "liveRevenueRankScore * 0.6 + contentRevenueRankScore * 0.2 + " +
"followIncreaseRankScore * 0.1 + aiChatRankScore * 0.1"
const val CONTENT_LIKE_COUNT_WEIGHT = 0.5 const val LIVE_REVENUE_WEIGHT = 0.6
const val CONTENT_COMMENT_COUNT_WEIGHT = 0.5 const val CONTENT_REVENUE_WEIGHT = 0.2
const val FOLLOW_INCREASE_WEIGHT = 0.1
const val CHANNEL_DONATION_CAN_WEIGHT = 0.6 const val AI_CHAT_WEIGHT = 0.1
const val CHANNEL_DONATION_COUNT_WEIGHT = 0.2
const val FAN_TALK_COUNT_WEIGHT = 0.2
const val FINAL_FOLLOWER_COUNT_WEIGHT = 0.7
const val FOLLOW_INCREASE_WEIGHT = 0.3
const val CONTENT_LIVE_SCORE_WEIGHT = 0.35
const val ENGAGEMENT_SCORE_WEIGHT = 0.3
const val SUPPORT_SCORE_WEIGHT = 0.25
const val FAN_LOYALTY_SCORE_WEIGHT = 0.1
} }

View File

@@ -1,21 +1,14 @@
package kr.co.vividnext.sodalive.v2.ranking.domain package kr.co.vividnext.sodalive.v2.ranking.domain
import java.time.LocalDateTime
data class CreatorRankingSnapshotCandidate( data class CreatorRankingSnapshotCandidate(
val creatorId: Long, val creatorId: Long,
val nickname: String, val nickname: String,
val profileImageUrl: String?, val profileImageUrl: String?,
val finalScore: Double, val creatorDebutAt: LocalDateTime?,
val contentLiveScore: Double,
val engagementScore: Double,
val supportScore: Double,
val fanLoyaltyScore: Double,
val liveCanAmount: Long, val liveCanAmount: Long,
val contentPurchaseCanAmount: Long, val contentPurchaseCanAmount: Long,
val contentLikeCount: Long, val followIncrease: Long,
val contentCommentCount: Long, val aiChatCount: Long
val channelDonationCanAmount: Long,
val channelDonationCount: Long,
val fanTalkCount: Long,
val finalFollowerCount: Long,
val followIncrease: Long
) )

View File

@@ -14,6 +14,13 @@ interface CreatorRankingSnapshotJobPort {
statuses: List<CreatorRankingSnapshotJobStatus> statuses: List<CreatorRankingSnapshotJobStatus>
): List<CreatorRankingSnapshotJobRecord> ): List<CreatorRankingSnapshotJobRecord>
fun countByRankingTypeAndPeriodAndTrigger(
rankingType: CreatorRankingType,
aggregationStartAtUtc: LocalDateTime,
aggregationEndAtUtc: LocalDateTime,
trigger: CreatorRankingSnapshotJobTrigger
): Long
fun markProcessing(jobId: Long, processingStartedAt: LocalDateTime): CreatorRankingSnapshotJobRecord? fun markProcessing(jobId: Long, processingStartedAt: LocalDateTime): CreatorRankingSnapshotJobRecord?
fun markDone(jobId: Long, processedAt: LocalDateTime): CreatorRankingSnapshotJobRecord? fun markDone(jobId: Long, processedAt: LocalDateTime): CreatorRankingSnapshotJobRecord?
@@ -32,7 +39,8 @@ enum class CreatorRankingSnapshotJobStatus {
enum class CreatorRankingSnapshotJobTrigger { enum class CreatorRankingSnapshotJobTrigger {
SCHEDULED, SCHEDULED,
MANUAL MANUAL,
FALLBACK
} }
data class CreatorRankingSnapshotJobRecord( data class CreatorRankingSnapshotJobRecord(

View File

@@ -24,8 +24,6 @@ interface CreatorRankingSnapshotPort {
nowUtc: LocalDateTime nowUtc: LocalDateTime
): List<CreatorRankingSnapshotRecord> ): List<CreatorRankingSnapshotRecord>
fun isSnapshotTableEmpty(): Boolean
fun replaceSnapshots( fun replaceSnapshots(
rankingType: CreatorRankingType, rankingType: CreatorRankingType,
aggregationStartAtUtc: LocalDateTime, aggregationStartAtUtc: LocalDateTime,
@@ -43,18 +41,8 @@ data class CreatorRankingSnapshotRecord(
val creatorId: Long, val creatorId: Long,
val nickname: String, val nickname: String,
val profileImageUrl: String?, val profileImageUrl: String?,
val rankNo: Int,
val finalScore: Double, val finalScore: Double,
val contentLiveScore: Double, val scorePolicyVersion: String,
val engagementScore: Double, val scoreDetailJson: String
val supportScore: Double,
val fanLoyaltyScore: Double,
val liveCanAmount: Long,
val contentPurchaseCanAmount: Long,
val contentLikeCount: Long,
val contentCommentCount: Long,
val channelDonationCanAmount: Long,
val channelDonationCount: Long,
val fanTalkCount: Long,
val finalFollowerCount: Long,
val followIncrease: Long
) )

View File

@@ -7,6 +7,7 @@ import kr.co.vividnext.sodalive.member.MemberRole
import kr.co.vividnext.sodalive.member.block.BlockMember import kr.co.vividnext.sodalive.member.block.BlockMember
import kr.co.vividnext.sodalive.support.EmbeddedRedisInitializer import kr.co.vividnext.sodalive.support.EmbeddedRedisInitializer
import kr.co.vividnext.sodalive.v2.ranking.adapter.out.persistence.CreatorRankingSnapshot import kr.co.vividnext.sodalive.v2.ranking.adapter.out.persistence.CreatorRankingSnapshot
import kr.co.vividnext.sodalive.v2.ranking.domain.CreatorRankingScoreSpec
import kr.co.vividnext.sodalive.v2.ranking.domain.CreatorRankingType import kr.co.vividnext.sodalive.v2.ranking.domain.CreatorRankingType
import org.junit.jupiter.api.DisplayName import org.junit.jupiter.api.DisplayName
import org.junit.jupiter.api.Test import org.junit.jupiter.api.Test
@@ -23,7 +24,13 @@ import org.springframework.transaction.annotation.Transactional
import java.time.LocalDateTime import java.time.LocalDateTime
import javax.persistence.EntityManager import javax.persistence.EntityManager
@SpringBootTest(properties = ["cloud.aws.cloud-front.host=https://cdn.test"]) @SpringBootTest(
properties = [
"cloud.aws.cloud-front.host=https://cdn.test",
"spring.datasource.url=jdbc:h2:mem:creator-ranking-controller;" +
"MODE=MySQL;DATABASE_TO_UPPER=false;NON_KEYWORDS=VALUE;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE"
]
)
@AutoConfigureMockMvc @AutoConfigureMockMvc
@Transactional @Transactional
@ContextConfiguration(initializers = [EmbeddedRedisInitializer::class]) @ContextConfiguration(initializers = [EmbeddedRedisInitializer::class])
@@ -33,13 +40,20 @@ class CreatorRankingControllerTest @Autowired constructor(
private val entityManager: EntityManager private val entityManager: EntityManager
) { ) {
@Test @Test
@DisplayName("크리에이터 랭킹 조회는 허용된 응답 필드만 반환하고 점수와 기간은 노출하지 않는") @DisplayName("크리에이터 랭킹 API는 공개 응답 스키마만 노출하고 내부 스냅샷 필드를 숨긴")
fun shouldReturnCreatorRankingSchemaWithoutScoreAndPeriodFields() { fun shouldReturnOnlyPublicCreatorRankingSchemaThroughEndpoint() {
val creatorOne = saveMember("creator-one", MemberRole.CREATOR)
val creatorTwo = saveMember("creator-two", MemberRole.CREATOR)
saveSnapshot(creatorId = creatorOne.id!!, nickname = "creator-one", profileImageUrl = "profile-one.png", rankNo = 2)
saveSnapshot(creatorId = creatorTwo.id!!, nickname = "creator-two", profileImageUrl = "profile-two.png", rankNo = 1)
saveSnapshot( saveSnapshot(
creatorId = 1L, creatorId = creatorOne.id!!,
nickname = "creator-one", nickname = "creator-one",
profileImageUrl = "profile-one.png", profileImageUrl = "profile-one.png",
finalScore = 100.0 rankNo = 1,
aggregationStartAtUtc = PREVIOUS_START,
aggregationEndAtUtc = PREVIOUS_END,
visibleFromAtUtc = PREVIOUS_VISIBLE
) )
entityManager.flush() entityManager.flush()
entityManager.clear() entityManager.clear()
@@ -47,23 +61,33 @@ class CreatorRankingControllerTest @Autowired constructor(
mockMvc.perform(get("/api/v2/home/rankings/creators")) mockMvc.perform(get("/api/v2/home/rankings/creators"))
.andExpect(status().isOk) .andExpect(status().isOk)
.andExpect(jsonPath("$.success").value(true)) .andExpect(jsonPath("$.success").value(true))
.andExpect(jsonPath("$.data.showRankChange").value(false)) .andExpect(jsonPath("$.data.showRankChange").value(true))
.andExpect(jsonPath("$.data.items[0].rank").value(1)) .andExpect(jsonPath("$.data.items[0].rank").value(1))
.andExpect(jsonPath("$.data.items[0].rankChange").doesNotExist()) .andExpect(jsonPath("$.data.items[0].rankChange").doesNotExist())
.andExpect(jsonPath("$.data.items[0].isNew").value(false)) .andExpect(jsonPath("$.data.items[0].isNew").value(true))
.andExpect(jsonPath("$.data.items[0].creatorId").value(1L)) .andExpect(jsonPath("$.data.items[0].creatorId").value(creatorTwo.id))
.andExpect(jsonPath("$.data.items[0].nickname").value("creator-one")) .andExpect(jsonPath("$.data.items[0].nickname").value("creator-two"))
.andExpect(jsonPath("$.data.items[0].profileImageUrl").value("https://cdn.test/profile-one.png")) .andExpect(jsonPath("$.data.items[0].profileImageUrl").value("https://cdn.test/profile-two.png"))
.andExpect(jsonPath("$.data.items[1].rank").value(2))
.andExpect(jsonPath("$.data.items[1].rankChange").value(-1))
.andExpect(jsonPath("$.data.items[1].isNew").value(false))
.andExpect(jsonPath("$.data.items[1].creatorId").value(creatorOne.id))
.andExpect(jsonPath("$.data.items[1].nickname").value("creator-one"))
.andExpect(jsonPath("$.data.items[1].profileImageUrl").value("https://cdn.test/profile-one.png"))
.andExpect(jsonPath("$.data.items[0].finalScore").doesNotExist()) .andExpect(jsonPath("$.data.items[0].finalScore").doesNotExist())
.andExpect(jsonPath("$.data.items[0].aggregationStartAtUtc").doesNotExist()) .andExpect(jsonPath("$.data.items[0].rankNo").doesNotExist())
.andExpect(jsonPath("$.data.items[0].aggregationEndAtUtc").doesNotExist()) .andExpect(jsonPath("$.data.items[0].scorePolicyVersion").doesNotExist())
.andExpect(jsonPath("$.data.aggregationStartAtUtc").doesNotExist()) .andExpect(jsonPath("$.data.items[0].scoreDetailJson").doesNotExist())
.andExpect(jsonPath("$.data.aggregationEndAtUtc").doesNotExist()) .andExpect(jsonPath("$.data.fallback").doesNotExist())
.andExpect(jsonPath("$.data.finalScore").doesNotExist())
.andExpect(jsonPath("$.data.rankNo").doesNotExist())
.andExpect(jsonPath("$.data.scorePolicyVersion").doesNotExist())
.andExpect(jsonPath("$.data.scoreDetailJson").doesNotExist())
} }
@Test @Test
@DisplayName("크리에이터 랭킹 조회는 비회원도 호출 가능하고 빈 랭킹을 성공 응답으로 반환한") @DisplayName("크리에이터 랭킹 API는 비회원도 빈 랭킹을 성공 응답으로 조회할 수 있")
fun shouldReturnEmptyCreatorRankingsForAnonymous() { fun shouldReturnEmptySuccessForAnonymous() {
mockMvc.perform(get("/api/v2/home/rankings/creators")) mockMvc.perform(get("/api/v2/home/rankings/creators"))
.andExpect(status().isOk) .andExpect(status().isOk)
.andExpect(jsonPath("$.success").value(true)) .andExpect(jsonPath("$.success").value(true))
@@ -73,7 +97,7 @@ class CreatorRankingControllerTest @Autowired constructor(
} }
@Test @Test
@DisplayName("크리에이터 랭킹 조회는 인증 회원 id를 전달해 차단 크리에이터 정보를 마스킹한다") @DisplayName("크리에이터 랭킹 API는 인증 회원과 차단 관계인 크리에이터 정보를 마스킹한다")
fun shouldMaskBlockedCreatorForAuthenticatedMember() { fun shouldMaskBlockedCreatorForAuthenticatedMember() {
val viewer = saveMember("ranking-viewer", MemberRole.USER) val viewer = saveMember("ranking-viewer", MemberRole.USER)
val blockedCreator = saveMember("blocked-creator", MemberRole.CREATOR) val blockedCreator = saveMember("blocked-creator", MemberRole.CREATOR)
@@ -81,7 +105,7 @@ class CreatorRankingControllerTest @Autowired constructor(
creatorId = blockedCreator.id!!, creatorId = blockedCreator.id!!,
nickname = blockedCreator.nickname, nickname = blockedCreator.nickname,
profileImageUrl = "blocked-profile.png", profileImageUrl = "blocked-profile.png",
finalScore = 100.0 rankNo = 1
) )
saveBlock(member = viewer, blockedMember = blockedCreator) saveBlock(member = viewer, blockedMember = blockedCreator)
entityManager.flush() entityManager.flush()
@@ -97,6 +121,11 @@ class CreatorRankingControllerTest @Autowired constructor(
.andExpect(jsonPath("$.data.items[0].creatorId").value(0L)) .andExpect(jsonPath("$.data.items[0].creatorId").value(0L))
.andExpect(jsonPath("$.data.items[0].nickname").value("")) .andExpect(jsonPath("$.data.items[0].nickname").value(""))
.andExpect(jsonPath("$.data.items[0].profileImageUrl").value("https://cdn.test/profile/default-profile.png")) .andExpect(jsonPath("$.data.items[0].profileImageUrl").value("https://cdn.test/profile/default-profile.png"))
.andExpect(jsonPath("$.data.items[0].finalScore").doesNotExist())
.andExpect(jsonPath("$.data.items[0].rankNo").doesNotExist())
.andExpect(jsonPath("$.data.items[0].scorePolicyVersion").doesNotExist())
.andExpect(jsonPath("$.data.items[0].scoreDetailJson").doesNotExist())
.andExpect(jsonPath("$.data.fallback").doesNotExist())
} }
private fun saveMember(seed: String, role: MemberRole): Member { private fun saveMember(seed: String, role: MemberRole): Member {
@@ -123,32 +152,34 @@ class CreatorRankingControllerTest @Autowired constructor(
creatorId: Long, creatorId: Long,
nickname: String, nickname: String,
profileImageUrl: String?, profileImageUrl: String?,
finalScore: Double rankNo: Int,
aggregationStartAtUtc: LocalDateTime = START,
aggregationEndAtUtc: LocalDateTime = END,
visibleFromAtUtc: LocalDateTime = VISIBLE
) { ) {
entityManager.persist( entityManager.persist(
CreatorRankingSnapshot( CreatorRankingSnapshot(
rankingType = CreatorRankingType.WEEKLY, rankingType = CreatorRankingType.WEEKLY,
aggregationStartAtUtc = LocalDateTime.of(2026, 5, 31, 15, 0, 0), aggregationStartAtUtc = aggregationStartAtUtc,
aggregationEndAtUtc = LocalDateTime.of(2026, 6, 7, 15, 0, 0), aggregationEndAtUtc = aggregationEndAtUtc,
visibleFromAtUtc = LocalDateTime.of(2026, 6, 8, 0, 0, 0), visibleFromAtUtc = visibleFromAtUtc,
creatorId = creatorId, creatorId = creatorId,
nickname = nickname, nickname = nickname,
profileImageUrl = profileImageUrl, profileImageUrl = profileImageUrl,
finalScore = finalScore, rankNo = rankNo,
contentLiveScore = 0.0, finalScore = (100 - rankNo).toDouble(),
engagementScore = 0.0, scorePolicyVersion = CreatorRankingScoreSpec.SCORE_POLICY_VERSION,
supportScore = 0.0, scoreDetailJson = "{}"
fanLoyaltyScore = 0.0,
liveCanAmount = 0,
contentPurchaseCanAmount = 0,
contentLikeCount = 0,
contentCommentCount = 0,
channelDonationCanAmount = 0,
channelDonationCount = 0,
fanTalkCount = 0,
finalFollowerCount = 0,
followIncrease = 0
) )
) )
} }
companion object {
private val PREVIOUS_START = LocalDateTime.of(2026, 5, 24, 15, 0)
private val PREVIOUS_END = LocalDateTime.of(2026, 5, 31, 15, 0)
private val PREVIOUS_VISIBLE = LocalDateTime.of(2026, 6, 1, 0, 0)
private val START = LocalDateTime.of(2026, 5, 31, 15, 0)
private val END = LocalDateTime.of(2026, 6, 7, 15, 0)
private val VISIBLE = LocalDateTime.of(2026, 6, 8, 0, 0)
}
} }

View File

@@ -5,18 +5,20 @@ import kr.co.vividnext.sodalive.can.use.CanUsage
import kr.co.vividnext.sodalive.can.use.UseCan import kr.co.vividnext.sodalive.can.use.UseCan
import kr.co.vividnext.sodalive.can.use.UseCanCalculate import kr.co.vividnext.sodalive.can.use.UseCanCalculate
import kr.co.vividnext.sodalive.can.use.UseCanCalculateStatus import kr.co.vividnext.sodalive.can.use.UseCanCalculateStatus
import kr.co.vividnext.sodalive.chat.character.ChatCharacter
import kr.co.vividnext.sodalive.chat.room.ChatMessage
import kr.co.vividnext.sodalive.chat.room.ChatParticipant
import kr.co.vividnext.sodalive.chat.room.ChatRoom
import kr.co.vividnext.sodalive.chat.room.ParticipantType
import kr.co.vividnext.sodalive.configs.QueryDslConfig import kr.co.vividnext.sodalive.configs.QueryDslConfig
import kr.co.vividnext.sodalive.content.AudioContent 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.theme.AudioContentTheme import kr.co.vividnext.sodalive.content.theme.AudioContentTheme
import kr.co.vividnext.sodalive.explorer.profile.CreatorCheers import kr.co.vividnext.sodalive.live.room.LiveRoom
import kr.co.vividnext.sodalive.member.Member import kr.co.vividnext.sodalive.member.Member
import kr.co.vividnext.sodalive.member.MemberRole import kr.co.vividnext.sodalive.member.MemberRole
import kr.co.vividnext.sodalive.member.following.CreatorFollowing import kr.co.vividnext.sodalive.member.following.CreatorFollowing
import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertFalse 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.DisplayName
import org.junit.jupiter.api.Test import org.junit.jupiter.api.Test
import org.springframework.beans.factory.annotation.Autowired import org.springframework.beans.factory.annotation.Autowired
@@ -28,25 +30,41 @@ import javax.persistence.EntityManager
@DataJpaTest( @DataJpaTest(
properties = [ properties = [
"spring.cache.type=none", "spring.cache.type=none",
"spring.datasource.url=jdbc:h2:mem:testdb;MODE=MySQL;NON_KEYWORDS=VALUE" "spring.datasource.url=jdbc:h2:mem:creator-ranking-aggregation;" +
"MODE=MySQL;DATABASE_TO_UPPER=false;NON_KEYWORDS=VALUE;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE"
] ]
) )
@Import(QueryDslConfig::class) @Import(QueryDslConfig::class, DefaultCreatorRankingAggregationRepository::class)
class DefaultCreatorRankingAggregationRepositoryTest @Autowired constructor( class DefaultCreatorRankingAggregationRepositoryTest @Autowired constructor(
private val adapter: DefaultCreatorRankingAggregationRepository,
private val entityManager: EntityManager private val entityManager: EntityManager
) { ) {
private val adapter = DefaultCreatorRankingAggregationRepository(entityManager) @Test
private val startAt = LocalDateTime.of(2026, 5, 31, 15, 0) @DisplayName("활성 크리에이터만 V2 raw metric 후보로 집계한다")
private val endAt = LocalDateTime.of(2026, 6, 7, 15, 0) fun shouldAggregateOnlyActiveCreators() {
private val inPeriod = LocalDateTime.of(2026, 6, 1, 0, 0) val activeCreator = saveCreator("active-creator")
val inactiveCreator = saveCreator("inactive-creator", isActive = false)
val nonCreator = saveUser("non-creator")
val user = saveUser("active-user")
saveUseCanCalculate(user, activeCreator, CanUsage.DONATION, 10, UseCanCalculateStatus.RECEIVED, false, IN_PERIOD)
saveUseCanCalculate(user, inactiveCreator, CanUsage.DONATION, 100, UseCanCalculateStatus.RECEIVED, false, IN_PERIOD)
saveUseCanCalculate(user, nonCreator, CanUsage.DONATION, 200, UseCanCalculateStatus.RECEIVED, false, IN_PERIOD)
flushAndClear()
val candidates = aggregate()
assertEquals(listOf(activeCreator.id), candidates.map { it.creatorId })
assertFalse(candidates.any { it.creatorId == inactiveCreator.id })
assertFalse(candidates.any { it.creatorId == nonCreator.id })
}
@Test @Test
@DisplayName("콘텐츠/라이브 캔은 사용 구분, 정산 상태, 환불 여부, UTC 기간으로 집계한다") @DisplayName("캔 지표는 사용 구분, 정산 상태, 환불 여부, half-open UTC 기간으로 집계한다")
fun shouldAggregateLiveAndContentCanAmountsByUsageStatusRefundAndUtcPeriod() { fun shouldAggregateCanMetricsByUsageStatusRefundAndHalfOpenPeriod() {
val creator = saveCreator("can-creator") val creator = saveCreator("can-creator")
val user = saveUser("can-user") val user = saveUser("can-user")
saveUseCanCalculate(user, creator, CanUsage.DONATION, 10, UseCanCalculateStatus.RECEIVED, false, startAt) saveUseCanCalculate(user, creator, CanUsage.DONATION, 10, UseCanCalculateStatus.RECEIVED, false, START_AT)
saveUseCanCalculate(user, creator, CanUsage.LIVE, 20, UseCanCalculateStatus.RECEIVED, false, inPeriod) saveUseCanCalculate(user, creator, CanUsage.LIVE, 20, UseCanCalculateStatus.RECEIVED, false, IN_PERIOD)
saveUseCanCalculate( saveUseCanCalculate(
user, user,
creator, creator,
@@ -54,11 +72,11 @@ class DefaultCreatorRankingAggregationRepositoryTest @Autowired constructor(
30, 30,
UseCanCalculateStatus.RECEIVED, UseCanCalculateStatus.RECEIVED,
false, false,
endAt.minusSeconds(1) END_AT.minusSeconds(1)
) )
saveUseCanCalculate(user, creator, CanUsage.ORDER_CONTENT, 40, UseCanCalculateStatus.RECEIVED, false, inPeriod) saveUseCanCalculate(user, creator, CanUsage.ORDER_CONTENT, 40, UseCanCalculateStatus.RECEIVED, false, IN_PERIOD)
saveUseCanCalculate(user, creator, CanUsage.DONATION, 100, UseCanCalculateStatus.RECEIVED, true, inPeriod) saveUseCanCalculate(user, creator, CanUsage.DONATION, 100, UseCanCalculateStatus.RECEIVED, true, IN_PERIOD)
saveUseCanCalculate(user, creator, CanUsage.LIVE, 200, UseCanCalculateStatus.CALCULATE_COMPLETE, false, inPeriod) saveUseCanCalculate(user, creator, CanUsage.LIVE, 200, UseCanCalculateStatus.CALCULATE_COMPLETE, false, IN_PERIOD)
saveUseCanCalculate( saveUseCanCalculate(
user, user,
creator, creator,
@@ -66,9 +84,9 @@ class DefaultCreatorRankingAggregationRepositoryTest @Autowired constructor(
300, 300,
UseCanCalculateStatus.RECEIVED, UseCanCalculateStatus.RECEIVED,
false, false,
startAt.minusSeconds(1) START_AT.minusSeconds(1)
) )
saveUseCanCalculate(user, creator, CanUsage.ORDER_CONTENT, 400, UseCanCalculateStatus.RECEIVED, false, endAt) saveUseCanCalculate(user, creator, CanUsage.ORDER_CONTENT, 400, UseCanCalculateStatus.RECEIVED, false, END_AT)
flushAndClear() flushAndClear()
val candidate = aggregate().single() val candidate = aggregate().single()
@@ -78,139 +96,136 @@ class DefaultCreatorRankingAggregationRepositoryTest @Autowired constructor(
} }
@Test @Test
@DisplayName("활성 콘텐츠의 활성 좋아요와 작성자 본인이 아닌 활성 댓글/대댓글만 집계한") @DisplayName("팔로우 증가는 음수를 0으로 보정하고 양수 신규 팔로우는 후보를 만든")
fun shouldAggregateActiveContentLikesAndCommentsExcludingCreatorSelfResponses() { fun shouldClampNegativeFollowIncreaseAndIncludePositiveFollowCandidate() {
val creator = saveCreator("engagement-creator") val negativeCreator = saveCreator("negative-follow-creator")
val otherCreator = saveCreator("inactive-content-creator") val positiveCreator = saveCreator("positive-follow-creator")
val user = saveUser("engagement-user") val user = saveUser("follow-user")
val content = saveAudioContent(creator, isActive = true) val firstUnfollower = saveUser("first-unfollower")
val inactiveContent = saveAudioContent(otherCreator, isActive = false) val secondUnfollower = saveUser("second-unfollower")
saveUseCanCalculate(user, creator, CanUsage.DONATION, 10, UseCanCalculateStatus.RECEIVED, false, inPeriod) saveUseCanCalculate(user, negativeCreator, CanUsage.DONATION, 1, UseCanCalculateStatus.RECEIVED, false, IN_PERIOD)
saveContentLike(content, user, isActive = true, createdAt = inPeriod) saveFollowing(negativeCreator, firstUnfollower, isActive = false, START_AT.minusDays(3), IN_PERIOD)
saveContentLike(content, user, isActive = false, createdAt = inPeriod) saveFollowing(negativeCreator, secondUnfollower, isActive = false, START_AT.minusDays(2), IN_PERIOD)
saveContentLike(content, user, isActive = true, createdAt = startAt.minusSeconds(1)) saveFollowing(positiveCreator, user, isActive = true, IN_PERIOD, IN_PERIOD)
saveContentLike(inactiveContent, user, isActive = true, createdAt = inPeriod)
val parent = saveContentComment(content, user, isActive = true, createdAt = inPeriod)
saveContentComment(content, user, parent = parent, isActive = true, createdAt = inPeriod)
saveContentComment(content, creator, isActive = true, createdAt = inPeriod)
saveContentComment(content, user, isActive = false, createdAt = inPeriod)
saveContentComment(content, user, isActive = true, createdAt = endAt)
saveContentComment(inactiveContent, user, isActive = true, createdAt = inPeriod)
flushAndClear()
val candidate = aggregate().single { it.creatorId == creator.id }
assertEquals(1, candidate.contentLikeCount)
assertEquals(2, candidate.contentCommentCount)
}
@Test
@DisplayName("채널 후원 캔/건수와 최상위 활성 팬 Talk만 집계한다")
fun shouldAggregateChannelDonationAndTopLevelActiveFanTalks() {
val creator = saveCreator("support-creator")
val user = saveUser("support-user")
saveUseCanCalculate(user, creator, CanUsage.CHANNEL_DONATION, 100, UseCanCalculateStatus.RECEIVED, false, inPeriod)
saveUseCanCalculate(user, creator, CanUsage.CHANNEL_DONATION, 200, UseCanCalculateStatus.RECEIVED, false, inPeriod)
saveUseCanCalculate(user, creator, CanUsage.CHANNEL_DONATION, 300, UseCanCalculateStatus.RECEIVED, true, inPeriod)
saveUseCanCalculate(user, creator, CanUsage.CHANNEL_DONATION, 400, UseCanCalculateStatus.REFUND, false, inPeriod)
val topLevel = saveCreatorCheers(creator, user, isActive = true, createdAt = inPeriod)
saveCreatorCheers(creator, user, parent = topLevel, isActive = true, createdAt = inPeriod)
saveCreatorCheers(creator, user, isActive = false, createdAt = inPeriod)
saveCreatorCheers(creator, user, isActive = true, createdAt = endAt)
flushAndClear()
val candidate = aggregate().single()
assertEquals(300, candidate.channelDonationCanAmount)
assertEquals(2, candidate.channelDonationCount)
assertEquals(1, candidate.fanTalkCount)
}
@Test
@DisplayName("팔로우 최종 활성 수와 현재 row 기준 생성/비활성 변경 증가 수를 집계한다")
fun shouldAggregateFinalFollowerCountAndFollowIncreaseFromCurrentRows() {
val creator = saveCreator("follow-creator")
val activeFollower = saveUser("active-follower")
val newFollower = saveUser("new-follower")
val unfollower = saveUser("unfollower")
val oldInactiveFollower = saveUser("old-inactive-follower")
saveUseCanCalculate(activeFollower, creator, CanUsage.DONATION, 10, UseCanCalculateStatus.RECEIVED, false, inPeriod)
saveFollowing(
creator,
activeFollower,
isActive = true,
createdAt = startAt.minusDays(5),
updatedAt = startAt.minusDays(5)
)
saveFollowing(creator, newFollower, isActive = true, createdAt = inPeriod, updatedAt = inPeriod)
saveFollowing(creator, unfollower, isActive = false, createdAt = startAt.minusDays(10), updatedAt = inPeriod)
saveFollowing(
creator,
oldInactiveFollower,
isActive = false,
createdAt = startAt.minusDays(10),
updatedAt = startAt.minusSeconds(1)
)
flushAndClear()
val candidate = aggregate().single()
assertEquals(2, candidate.finalFollowerCount)
// 현재 CreatorFollowing row만으로 집계하므로 기간 내 재팔로우 이력은 별도 이벤트로 복원하지 않는다.
assertEquals(0, candidate.followIncrease)
}
@Test
@DisplayName("활성 크리에이터별 원천 지표를 합쳐 1점 이상 후보만 점수와 함께 반환한다")
fun shouldMergeMetricsForActiveCreatorsAndExcludeInactiveNonCreatorAndLowScoreCandidates() {
val creator = saveCreator("merged-creator", profileImage = "merged.png")
val lowScoreCreator = saveCreator("low-score-creator")
val inactiveCreator = saveCreator("inactive-creator", isActive = false)
val nonCreator = saveUser("non-creator")
val user = saveUser("merged-user")
saveUseCanCalculate(user, creator, CanUsage.DONATION, 10, UseCanCalculateStatus.RECEIVED, false, inPeriod)
saveContentLike(saveAudioContent(creator, isActive = true), user, isActive = true, createdAt = inPeriod)
saveUseCanCalculate(user, inactiveCreator, CanUsage.DONATION, 1000, UseCanCalculateStatus.RECEIVED, false, inPeriod)
saveUseCanCalculate(user, nonCreator, CanUsage.DONATION, 1000, UseCanCalculateStatus.RECEIVED, false, inPeriod)
saveFollowing(lowScoreCreator, user, isActive = true, createdAt = startAt.minusDays(1), updatedAt = startAt.minusDays(1))
flushAndClear() flushAndClear()
val candidates = aggregate() val candidates = aggregate()
assertEquals(listOf(creator.id), candidates.map { it.creatorId }) assertEquals(0, candidates.single { it.creatorId == negativeCreator.id }.followIncrease)
val candidate = candidates.single() assertEquals(1, candidates.single { it.creatorId == positiveCreator.id }.followIncrease)
assertEquals("merged-creator", candidate.nickname)
assertEquals("merged.png", candidate.profileImageUrl)
assertEquals(10, candidate.liveCanAmount)
assertEquals(0, candidate.contentPurchaseCanAmount)
assertEquals(1, candidate.contentLikeCount)
assertEquals(7.0, candidate.contentLiveScore, 0.0001)
assertEquals(0.5, candidate.engagementScore, 0.0001)
assertEquals(0.0, candidate.supportScore, 0.0001)
assertEquals(0.0, candidate.fanLoyaltyScore, 0.0001)
assertEquals(2.6, candidate.finalScore, 0.0001)
assertTrue(candidate.finalScore >= 1.0)
assertFalse(candidates.any { it.creatorId == lowScoreCreator.id })
assertFalse(candidates.any { it.creatorId == inactiveCreator.id })
assertFalse(candidates.any { it.creatorId == nonCreator.id })
} }
private fun aggregate() = adapter.aggregateCandidates(startAt, endAt) @Test
@DisplayName("AI 채팅 수는 활성 메시지/캐릭터 참여자/캐릭터/크리에이터와 half-open 기간만 집계한다")
fun shouldAggregateAiChatCountOnlyForActiveCharacterConversationInHalfOpenPeriod() {
val creator = saveCreator("chat-creator")
val inactiveCreator = saveCreator("inactive-chat-creator", isActive = false)
val activeCharacter = saveCharacter(creator, "active-character", isActive = true)
val inactiveCharacter = saveCharacter(saveCreator("inactive-character-creator"), "inactive-character", isActive = false)
val inactiveCreatorCharacter = saveCharacter(inactiveCreator, "inactive-creator-character", isActive = true)
val room = saveChatRoom("chat-room")
val activeParticipant = saveParticipant(room, ParticipantType.CHARACTER, character = activeCharacter, isActive = true)
val inactiveParticipant = saveParticipant(room, ParticipantType.CHARACTER, character = activeCharacter, isActive = false)
val inactiveCharacterParticipant = saveParticipant(
room,
ParticipantType.CHARACTER,
character = inactiveCharacter,
isActive = true
)
val inactiveCreatorParticipant = saveParticipant(
room,
ParticipantType.CHARACTER,
character = inactiveCreatorCharacter,
isActive = true
)
saveChatMessage(room, activeParticipant, isActive = true, START_AT)
saveChatMessage(room, activeParticipant, isActive = true, END_AT.minusSeconds(1))
saveChatMessage(room, activeParticipant, isActive = false, IN_PERIOD)
saveChatMessage(room, inactiveParticipant, isActive = true, IN_PERIOD)
saveChatMessage(room, inactiveCharacterParticipant, isActive = true, IN_PERIOD)
saveChatMessage(room, inactiveCreatorParticipant, isActive = true, IN_PERIOD)
saveChatMessage(room, activeParticipant, isActive = true, START_AT.minusSeconds(1))
saveChatMessage(room, activeParticipant, isActive = true, END_AT)
flushAndClear()
private fun saveCreator(nickname: String, profileImage: String? = null, isActive: Boolean = true): Member { val candidate = aggregate().single()
return saveMember(nickname, MemberRole.CREATOR, profileImage, isActive)
assertEquals(creator.id, candidate.creatorId)
assertEquals(2, candidate.aiChatCount)
}
@Test
@DisplayName("크리에이터 데뷔일은 첫 라이브와 첫 콘텐츠 공개일 중 빠른 값이고 없으면 null이다")
fun shouldUseEarliestLiveOrContentAsCreatorDebutAtAndNullWhenMissing() {
val liveFirstCreator = saveCreator("live-first-creator")
val contentFirstCreator = saveCreator("content-first-creator")
val noDebutCreator = saveCreator("no-debut-creator")
val user = saveUser("debut-user")
val futureDate = LocalDateTime.now().plusYears(1)
saveLiveRoom(liveFirstCreator, LocalDateTime.of(2025, 12, 1, 0, 0), channelName = null)
saveLiveRoom(liveFirstCreator, LocalDateTime.of(2026, 1, 10, 0, 0))
saveAudioContent(liveFirstCreator, LocalDateTime.of(2026, 2, 10, 0, 0))
saveLiveRoom(contentFirstCreator, LocalDateTime.of(2026, 3, 10, 0, 0))
saveAudioContent(contentFirstCreator, LocalDateTime.of(2026, 1, 20, 0, 0))
saveLiveRoom(contentFirstCreator, futureDate)
saveAudioContent(contentFirstCreator, futureDate)
saveUseCanCalculate(user, liveFirstCreator, CanUsage.DONATION, 1, UseCanCalculateStatus.RECEIVED, false, IN_PERIOD)
saveUseCanCalculate(user, contentFirstCreator, CanUsage.DONATION, 1, UseCanCalculateStatus.RECEIVED, false, IN_PERIOD)
saveUseCanCalculate(user, noDebutCreator, CanUsage.DONATION, 1, UseCanCalculateStatus.RECEIVED, false, IN_PERIOD)
flushAndClear()
val candidates = aggregate().associateBy { it.creatorId }
assertEquals(LocalDateTime.of(2026, 1, 10, 0, 0), candidates.getValue(liveFirstCreator.id!!).creatorDebutAt)
assertEquals(LocalDateTime.of(2026, 1, 20, 0, 0), candidates.getValue(contentFirstCreator.id!!).creatorDebutAt)
assertEquals(null, candidates.getValue(noDebutCreator.id!!).creatorDebutAt)
}
@Test
@DisplayName("모든 지표가 0인 후보는 제외하고 하나라도 양수인 후보는 포함한다")
fun shouldExcludeAllZeroCandidatesAndIncludeAnyPositiveMetricCandidate() {
val zeroCreator = saveCreator("zero-creator")
val liveCanCreator = saveCreator("live-can-creator")
val contentCanCreator = saveCreator("content-can-creator")
val followCreator = saveCreator("follow-creator")
val chatCreator = saveCreator("chat-positive-creator")
val user = saveUser("positive-user")
saveLiveRoom(zeroCreator, IN_PERIOD)
saveUseCanCalculate(user, liveCanCreator, CanUsage.DONATION, 1, UseCanCalculateStatus.RECEIVED, false, IN_PERIOD)
saveUseCanCalculate(user, contentCanCreator, CanUsage.ORDER_CONTENT, 1, UseCanCalculateStatus.RECEIVED, false, IN_PERIOD)
saveFollowing(followCreator, user, isActive = true, IN_PERIOD, IN_PERIOD)
val room = saveChatRoom("positive-chat-room")
val participant = saveParticipant(
room,
ParticipantType.CHARACTER,
character = saveCharacter(chatCreator, "chat-positive-character"),
isActive = true
)
saveChatMessage(room, participant, isActive = true, IN_PERIOD)
flushAndClear()
val creatorIds = aggregate().map { it.creatorId }.toSet()
assertFalse(creatorIds.contains(zeroCreator.id))
assertEquals(setOf(liveCanCreator.id, contentCanCreator.id, followCreator.id, chatCreator.id), creatorIds)
}
private fun aggregate() = adapter.aggregateCandidates(START_AT, END_AT)
private fun saveCreator(nickname: String, isActive: Boolean = true): Member {
return saveMember(nickname = nickname, role = MemberRole.CREATOR, isActive = isActive)
} }
private fun saveUser(nickname: String): Member { private fun saveUser(nickname: String): Member {
return saveMember(nickname, MemberRole.USER, null, true) return saveMember(nickname = nickname, role = MemberRole.USER, isActive = true)
} }
private fun saveMember(nickname: String, role: MemberRole, profileImage: String?, isActive: Boolean): Member { private fun saveMember(nickname: String, role: MemberRole, isActive: Boolean): Member {
val member = Member( val member = Member(
email = "$nickname@test.com", email = "$nickname@test.com",
password = "password", password = "password",
nickname = nickname, nickname = nickname,
profileImage = profileImage, profileImage = "$nickname.png",
role = role, role = role,
isActive = isActive isActive = isActive
) )
@@ -219,22 +234,6 @@ class DefaultCreatorRankingAggregationRepositoryTest @Autowired constructor(
return member return member
} }
private fun saveAudioContent(creator: Member, isActive: Boolean): AudioContent {
val theme = AudioContentTheme(theme = "theme-${creator.nickname}", image = "theme.png")
entityManager.persist(theme)
val content = AudioContent(
title = "content-${creator.nickname}",
detail = "detail",
languageCode = "ko",
releaseDate = inPeriod
)
content.member = creator
content.theme = theme
content.isActive = isActive
entityManager.persist(content)
return content
}
private fun saveUseCanCalculate( private fun saveUseCanCalculate(
member: Member, member: Member,
creator: Member, creator: Member,
@@ -255,49 +254,6 @@ class DefaultCreatorRankingAggregationRepositoryTest @Autowired constructor(
updateTimestamps("use_can_calculate", calculate.id!!, createdAt, createdAt) updateTimestamps("use_can_calculate", calculate.id!!, createdAt, createdAt)
} }
private fun saveContentLike(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 saveContentComment(
content: AudioContent,
member: Member,
parent: AudioContentComment? = null,
isActive: Boolean,
createdAt: LocalDateTime
): AudioContentComment {
val comment = AudioContentComment(comment = "comment", languageCode = "ko", isActive = isActive)
comment.audioContent = content
comment.member = member
comment.parent = parent
entityManager.persist(comment)
entityManager.flush()
updateTimestamps("content_comment", comment.id!!, createdAt, createdAt)
return comment
}
private fun saveCreatorCheers(
creator: Member,
member: Member,
parent: CreatorCheers? = null,
isActive: Boolean,
createdAt: LocalDateTime
): CreatorCheers {
val cheers = CreatorCheers(cheers = "cheers", languageCode = "ko", isActive = isActive)
cheers.creator = creator
cheers.member = member
cheers.parent = parent
entityManager.persist(cheers)
entityManager.flush()
updateTimestamps("creator_cheers", cheers.id!!, createdAt, createdAt)
return cheers
}
private fun saveFollowing( private fun saveFollowing(
creator: Member, creator: Member,
member: Member, member: Member,
@@ -313,6 +269,87 @@ class DefaultCreatorRankingAggregationRepositoryTest @Autowired constructor(
updateTimestamps("creator_following", following.id!!, createdAt, updatedAt) updateTimestamps("creator_following", following.id!!, createdAt, updatedAt)
} }
private fun saveCharacter(creator: Member, name: String, isActive: Boolean = true): ChatCharacter {
val character = ChatCharacter(
characterUUID = "uuid-$name",
name = name,
description = "description",
systemPrompt = "prompt",
isActive = isActive
)
character.creatorMember = creator
entityManager.persist(character)
return character
}
private fun saveChatRoom(sessionId: String): ChatRoom {
val room = ChatRoom(sessionId = sessionId, title = sessionId)
entityManager.persist(room)
return room
}
private fun saveParticipant(
room: ChatRoom,
participantType: ParticipantType,
character: ChatCharacter?,
isActive: Boolean
): ChatParticipant {
val participant = ChatParticipant(
chatRoom = room,
participantType = participantType,
character = character,
isActive = isActive
)
entityManager.persist(participant)
return participant
}
private fun saveChatMessage(room: ChatRoom, participant: ChatParticipant, isActive: Boolean, createdAt: LocalDateTime) {
val message = ChatMessage(
message = "message",
chatRoom = room,
participant = participant,
isActive = isActive
)
entityManager.persist(message)
entityManager.flush()
updateTimestamps("chat_message", message.id!!, createdAt, createdAt)
}
private fun saveLiveRoom(
creator: Member,
beginDateTime: LocalDateTime,
channelName: String? = "channel-${creator.nickname}"
): LiveRoom {
val room = LiveRoom(
title = "live-${creator.nickname}",
notice = "notice",
beginDateTime = beginDateTime,
numberOfPeople = 10,
isAdult = false
)
room.member = creator
room.channelName = channelName
entityManager.persist(room)
return room
}
private fun saveAudioContent(creator: Member, releaseDate: LocalDateTime): AudioContent {
val theme = AudioContentTheme(theme = "theme-${creator.nickname}", image = "theme.png")
entityManager.persist(theme)
val content = AudioContent(
title = "content-${creator.nickname}",
detail = "detail",
languageCode = "ko",
releaseDate = releaseDate
)
content.member = creator
content.theme = theme
content.isActive = true
entityManager.persist(content)
return content
}
private fun updateTimestamps(tableName: String, id: Long, createdAt: LocalDateTime, updatedAt: LocalDateTime) { private fun updateTimestamps(tableName: String, id: Long, createdAt: LocalDateTime, updatedAt: LocalDateTime) {
entityManager.createNativeQuery( entityManager.createNativeQuery(
"update $tableName set created_at = :createdAt, updated_at = :updatedAt where id = :id" "update $tableName set created_at = :createdAt, updated_at = :updatedAt where id = :id"
@@ -328,4 +365,10 @@ class DefaultCreatorRankingAggregationRepositoryTest @Autowired constructor(
entityManager.flush() entityManager.flush()
entityManager.clear() entityManager.clear()
} }
companion object {
private val START_AT = LocalDateTime.of(2026, 5, 31, 15, 0)
private val END_AT = LocalDateTime.of(2026, 6, 7, 15, 0)
private val IN_PERIOD = LocalDateTime.of(2026, 6, 1, 0, 0)
}
} }

View File

@@ -138,4 +138,43 @@ class DefaultCreatorRankingSnapshotJobRepositoryTest @Autowired constructor(
assertEquals(LocalDateTime.of(2026, 6, 8, 7, 30), unchanged?.processingStartedAt) assertEquals(LocalDateTime.of(2026, 6, 8, 7, 30), unchanged?.processingStartedAt)
assertEquals(LocalDateTime.of(2026, 6, 8, 7, 31), unchanged?.processedAt) assertEquals(LocalDateTime.of(2026, 6, 8, 7, 31), unchanged?.processedAt)
} }
@Test
@DisplayName("fallback 제한 count는 같은 기간의 FALLBACK trigger만 계산한다")
fun shouldCountFallbackJobsByPeriodAndTriggerOnly() {
val startAt = LocalDateTime.of(2026, 5, 31, 15, 0)
val endAt = LocalDateTime.of(2026, 6, 7, 15, 0)
adapter.save(job(startAt, endAt, CreatorRankingSnapshotJobTrigger.FALLBACK))
adapter.save(job(startAt, endAt, CreatorRankingSnapshotJobTrigger.FALLBACK))
adapter.save(job(startAt, endAt, CreatorRankingSnapshotJobTrigger.SCHEDULED))
adapter.save(job(startAt, endAt, CreatorRankingSnapshotJobTrigger.MANUAL))
adapter.save(job(startAt.minusWeeks(1), endAt.minusWeeks(1), CreatorRankingSnapshotJobTrigger.FALLBACK))
val count = adapter.countByRankingTypeAndPeriodAndTrigger(
rankingType = CreatorRankingType.WEEKLY,
aggregationStartAtUtc = startAt,
aggregationEndAtUtc = endAt,
trigger = CreatorRankingSnapshotJobTrigger.FALLBACK
)
assertEquals(2L, count)
}
private fun job(
startAt: LocalDateTime,
endAt: LocalDateTime,
trigger: CreatorRankingSnapshotJobTrigger
): CreatorRankingSnapshotJobRecord {
return CreatorRankingSnapshotJobRecord(
rankingType = CreatorRankingType.WEEKLY,
aggregationStartAtUtc = startAt,
aggregationEndAtUtc = endAt,
visibleFromAtUtc = endAt.plusHours(9),
trigger = trigger,
status = CreatorRankingSnapshotJobStatus.FAILED,
lastError = null,
processingStartedAt = null,
processedAt = null
)
}
} }

View File

@@ -1,6 +1,7 @@
package kr.co.vividnext.sodalive.v2.ranking.adapter.out.persistence package kr.co.vividnext.sodalive.v2.ranking.adapter.out.persistence
import kr.co.vividnext.sodalive.configs.QueryDslConfig import kr.co.vividnext.sodalive.configs.QueryDslConfig
import kr.co.vividnext.sodalive.v2.ranking.domain.CreatorRankingScoreSpec
import kr.co.vividnext.sodalive.v2.ranking.domain.CreatorRankingType import kr.co.vividnext.sodalive.v2.ranking.domain.CreatorRankingType
import kr.co.vividnext.sodalive.v2.ranking.port.out.CreatorRankingSnapshotRecord import kr.co.vividnext.sodalive.v2.ranking.port.out.CreatorRankingSnapshotRecord
import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Assertions.assertEquals
@@ -14,345 +15,315 @@ import java.time.LocalDateTime
@DataJpaTest( @DataJpaTest(
properties = [ properties = [
"spring.cache.type=none", "spring.cache.type=none",
"spring.datasource.url=jdbc:h2:mem:testdb;MODE=MySQL;NON_KEYWORDS=VALUE" "spring.datasource.url=jdbc:h2:mem:creator-ranking-snapshot;" +
"MODE=MySQL;DATABASE_TO_UPPER=false;NON_KEYWORDS=VALUE;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE"
] ]
) )
@Import(QueryDslConfig::class) @Import(QueryDslConfig::class, DefaultCreatorRankingSnapshotRepository::class)
class DefaultCreatorRankingSnapshotRepositoryTest @Autowired constructor( class DefaultCreatorRankingSnapshotRepositoryTest @Autowired constructor(
private val adapter: DefaultCreatorRankingSnapshotRepository,
private val repository: CreatorRankingSnapshotRepository private val repository: CreatorRankingSnapshotRepository
) { ) {
private val adapter = DefaultCreatorRankingSnapshotRepository(repository)
@Test @Test
@DisplayName("같은 집계 기간의 스냅샷은 삭제 후 새 후보로 교체한다") @DisplayName("replaceSnapshots는 rankNo, finalScore, scorePolicyVersion, scoreDetailJson을 저장하고 매핑한다")
fun shouldReplaceSnapshotsByAggregationPeriod() { fun shouldReplaceSnapshotsAndMapV2SnapshotContractFields() {
val startAt = LocalDateTime.of(2026, 5, 31, 15, 0)
val endAt = LocalDateTime.of(2026, 6, 7, 15, 0)
val oldStartAt = startAt.minusWeeks(1)
val oldEndAt = endAt.minusWeeks(1)
repository.saveAll(
listOf(
snapshot(creatorId = 1L, aggregationStartAtUtc = oldStartAt, aggregationEndAtUtc = oldEndAt),
snapshot(creatorId = 2L, aggregationStartAtUtc = startAt, aggregationEndAtUtc = endAt)
)
)
adapter.replaceSnapshots( adapter.replaceSnapshots(
rankingType = CreatorRankingType.WEEKLY, rankingType = CreatorRankingType.WEEKLY,
aggregationStartAtUtc = startAt, aggregationStartAtUtc = START,
aggregationEndAtUtc = endAt, aggregationEndAtUtc = END,
visibleFromAtUtc = endAt.plusHours(9), visibleFromAtUtc = VISIBLE,
newSnapshots = listOf(snapshotRecord(creatorId = 3L, aggregationStartAtUtc = startAt, aggregationEndAtUtc = endAt)) newSnapshots = listOf(
record(
creatorId = 1L,
rankNo = 2,
finalScore = 88.5,
scorePolicyVersion = CreatorRankingScoreSpec.SCORE_POLICY_VERSION,
scoreDetailJson = "{\"liveCanAmount\":10}"
)
) )
val allSnapshots = repository.findAll()
assertEquals(listOf(1L, 3L), allSnapshots.map { it.creatorId }.sorted())
assertEquals(listOf(3L), adapter.findLatestSnapshots().map { it.creatorId })
}
@Test
@DisplayName("스냅샷은 랭킹 타입과 공개 노출 시각을 저장하고 같은 타입/기간만 교체한다")
fun shouldPersistRankingTypeAndVisibleFromAtAndReplaceByTypeAndPeriod() {
val startAt = LocalDateTime.of(2026, 5, 31, 15, 0)
val endAt = LocalDateTime.of(2026, 6, 7, 15, 0)
val visibleFromAt = LocalDateTime.of(2026, 6, 8, 0, 0)
repository.save(snapshot(creatorId = 1L, aggregationStartAtUtc = startAt, aggregationEndAtUtc = endAt))
adapter.replaceSnapshots(
rankingType = CreatorRankingType.WEEKLY,
aggregationStartAtUtc = startAt,
aggregationEndAtUtc = endAt,
visibleFromAtUtc = visibleFromAt,
newSnapshots = listOf(snapshotRecord(creatorId = 2L, aggregationStartAtUtc = startAt, aggregationEndAtUtc = endAt))
) )
val saved = repository.findAll().single() val saved = repository.findAll().single()
assertEquals(CreatorRankingType.WEEKLY, saved.rankingType) assertEquals(2, saved.rankNo)
assertEquals(visibleFromAt, saved.visibleFromAtUtc) assertEquals(88.5, saved.finalScore, 0.0001)
assertEquals(2L, saved.creatorId) assertEquals(CreatorRankingScoreSpec.SCORE_POLICY_VERSION, saved.scorePolicyVersion)
assertEquals("{\"liveCanAmount\":10}", saved.scoreDetailJson)
val mapped = adapter.findSnapshotsByAggregationPeriod(START, END).single()
assertEquals(2, mapped.rankNo)
assertEquals(88.5, mapped.finalScore, 0.0001)
assertEquals(CreatorRankingScoreSpec.SCORE_POLICY_VERSION, mapped.scorePolicyVersion)
assertEquals("{\"liveCanAmount\":10}", mapped.scoreDetailJson)
} }
@Test @Test
@DisplayName("최신 완료 주차 스냅샷은 최신 종료 시각 기준으로 최종 점수 내림차순 조회한다") @DisplayName("findLatestSnapshots는 최신 종료 시각 스냅샷을 rank_no 오름차순으로 조회한다")
fun shouldFindLatestSnapshotsByLatestAggregationEndAndFinalScoreDescending() { fun shouldFindLatestSnapshotsOrderedByRankNoAsc() {
val oldStartAt = LocalDateTime.of(2026, 5, 24, 15, 0)
val oldEndAt = LocalDateTime.of(2026, 5, 31, 15, 0)
val latestStartAt = LocalDateTime.of(2026, 5, 31, 15, 0)
val latestEndAt = LocalDateTime.of(2026, 6, 7, 15, 0)
repository.saveAll( repository.saveAll(
listOf( listOf(
snapshot( entity(
creatorId = 1L, creatorId = 1L,
rankNo = 1,
finalScore = 999.0, finalScore = 999.0,
aggregationStartAtUtc = oldStartAt, aggregationStartAtUtc = PREVIOUS_START,
aggregationEndAtUtc = oldEndAt aggregationEndAtUtc = PREVIOUS_END
), ),
snapshot( entity(creatorId = 2L, rankNo = 3, finalScore = 300.0),
creatorId = 2L, entity(creatorId = 3L, rankNo = 1, finalScore = 100.0),
finalScore = 100.0, entity(creatorId = 4L, rankNo = 2, finalScore = 500.0)
aggregationStartAtUtc = latestStartAt,
aggregationEndAtUtc = latestEndAt
),
snapshot(
creatorId = 3L,
finalScore = 300.0,
aggregationStartAtUtc = latestStartAt,
aggregationEndAtUtc = latestEndAt
),
snapshot(
creatorId = 4L,
finalScore = 200.0,
aggregationStartAtUtc = latestStartAt,
aggregationEndAtUtc = latestEndAt
)
) )
) )
val latestSnapshots = adapter.findLatestSnapshots() val latestSnapshots = adapter.findLatestSnapshots()
assertEquals(listOf(3L, 4L, 2L), latestSnapshots.map { it.creatorId }) assertEquals(listOf(3L, 4L, 2L), latestSnapshots.map { it.creatorId })
assertEquals(listOf(latestStartAt, latestStartAt, latestStartAt), latestSnapshots.map { it.aggregationStartAtUtc }) assertEquals(listOf(1, 2, 3), latestSnapshots.map { it.rankNo })
assertEquals(listOf(latestEndAt, latestEndAt, latestEndAt), latestSnapshots.map { it.aggregationEndAtUtc })
} }
@Test @Test
@DisplayName("최신 공개 스냅샷은 visibleFromAt이 현재 시각 이하인 최신 노출 시각 기준으로 조회한다") @DisplayName("findLatestVisibleSnapshots는 현재 공개 가능한 최신 visible_from_at 스냅샷을 rank_no 오름차순으로 조회한다")
fun shouldFindLatestVisibleSnapshotsByVisibleFromAt() { fun shouldFindLatestVisibleSnapshotsOrderedByRankNoAsc() {
val previousStartAt = LocalDateTime.of(2026, 5, 24, 15, 0)
val previousEndAt = LocalDateTime.of(2026, 5, 31, 15, 0)
val latestStartAt = LocalDateTime.of(2026, 5, 31, 15, 0)
val latestEndAt = LocalDateTime.of(2026, 6, 7, 15, 0)
repository.saveAll( repository.saveAll(
listOf( listOf(
snapshot( entity(creatorId = 1L, rankNo = 1, finalScore = 999.0, visibleFromAtUtc = VISIBLE.plusDays(1)),
entity(creatorId = 2L, rankNo = 3, finalScore = 300.0, visibleFromAtUtc = VISIBLE),
entity(creatorId = 3L, rankNo = 1, finalScore = 100.0, visibleFromAtUtc = VISIBLE),
entity(creatorId = 4L, rankNo = 2, finalScore = 500.0, visibleFromAtUtc = VISIBLE),
entity(
creatorId = 5L,
rankNo = 1,
finalScore = 700.0,
visibleFromAtUtc = VISIBLE.minusDays(1)
)
)
)
val latestVisible = adapter.findLatestVisibleSnapshots(CreatorRankingType.WEEKLY, NOW)
assertEquals(listOf(3L, 4L, 2L), latestVisible.map { it.creatorId })
assertEquals(listOf(1, 2, 3), latestVisible.map { it.rankNo })
}
@Test
@DisplayName("findPreviousVisibleSnapshots는 현재 집계 시작 전 공개 스냅샷을 rank_no 오름차순으로 조회한다")
fun shouldFindPreviousVisibleSnapshotsOrderedByRankNoAsc() {
repository.saveAll(
listOf(
entity(
creatorId = 1L, creatorId = 1L,
aggregationStartAtUtc = previousStartAt, rankNo = 1,
aggregationEndAtUtc = previousEndAt, aggregationStartAtUtc = OLDEST_START,
visibleFromAtUtc = LocalDateTime.of(2026, 6, 1, 0, 0), aggregationEndAtUtc = OLDEST_END,
finalScore = 100.0 visibleFromAtUtc = VISIBLE.minusWeeks(1)
), ),
snapshot( entity(
creatorId = 2L, creatorId = 2L,
aggregationStartAtUtc = latestStartAt, rankNo = 3,
aggregationEndAtUtc = latestEndAt,
visibleFromAtUtc = LocalDateTime.of(2026, 6, 8, 0, 0),
finalScore = 200.0
)
)
)
val beforeVisible = adapter.findLatestVisibleSnapshots(
rankingType = CreatorRankingType.WEEKLY,
nowUtc = LocalDateTime.of(2026, 6, 7, 23, 59, 59)
)
val afterVisible = adapter.findLatestVisibleSnapshots(
rankingType = CreatorRankingType.WEEKLY,
nowUtc = LocalDateTime.of(2026, 6, 8, 0, 0)
)
assertEquals(listOf(1L), beforeVisible.map { it.creatorId })
assertEquals(listOf(2L), afterVisible.map { it.creatorId })
}
@Test
@DisplayName("직전 공개 스냅샷은 현재 공개 스냅샷보다 이전 집계 시작 시각 중 최신 공개 기준으로 조회한다")
fun shouldFindPreviousVisibleSnapshotsBeforeCurrentVisibleSnapshot() {
val oldestStartAt = LocalDateTime.of(2026, 5, 17, 15, 0)
val previousStartAt = LocalDateTime.of(2026, 5, 24, 15, 0)
val latestStartAt = LocalDateTime.of(2026, 5, 31, 15, 0)
repository.saveAll(
listOf(
snapshot(creatorId = 1L, aggregationStartAtUtc = oldestStartAt, aggregationEndAtUtc = oldestStartAt.plusWeeks(1)),
snapshot(
creatorId = 2L,
aggregationStartAtUtc = previousStartAt,
aggregationEndAtUtc = previousStartAt.plusWeeks(1),
finalScore = 300.0
),
snapshot(
creatorId = 3L,
aggregationStartAtUtc = previousStartAt,
aggregationEndAtUtc = previousStartAt.plusWeeks(1),
finalScore = 200.0
),
snapshot(creatorId = 4L, aggregationStartAtUtc = latestStartAt, aggregationEndAtUtc = latestStartAt.plusWeeks(1))
)
)
val previous = adapter.findPreviousVisibleSnapshots(
rankingType = CreatorRankingType.WEEKLY,
currentAggregationStartAtUtc = latestStartAt,
nowUtc = LocalDateTime.of(2026, 6, 8, 0, 0)
)
assertEquals(listOf(2L, 3L), previous.map { it.creatorId })
}
@Test
@DisplayName("직전 완료 주차 스냅샷은 최신 종료 시각보다 이전인 가장 큰 종료 시각 기준으로 조회한다")
fun shouldFindPreviousCompletedSnapshotsBeforeLatestPeriod() {
val oldestStartAt = LocalDateTime.of(2026, 5, 17, 15, 0)
val oldestEndAt = LocalDateTime.of(2026, 5, 24, 15, 0)
val previousStartAt = LocalDateTime.of(2026, 5, 24, 15, 0)
val previousEndAt = LocalDateTime.of(2026, 5, 31, 15, 0)
val latestStartAt = LocalDateTime.of(2026, 5, 31, 15, 0)
val latestEndAt = LocalDateTime.of(2026, 6, 7, 15, 0)
repository.saveAll(
listOf(
snapshot(creatorId = 1L, aggregationStartAtUtc = oldestStartAt, aggregationEndAtUtc = oldestEndAt),
snapshot(
creatorId = 2L,
finalScore = 200.0,
aggregationStartAtUtc = previousStartAt,
aggregationEndAtUtc = previousEndAt
),
snapshot(
creatorId = 3L,
finalScore = 300.0, finalScore = 300.0,
aggregationStartAtUtc = previousStartAt, aggregationStartAtUtc = PREVIOUS_START,
aggregationEndAtUtc = previousEndAt aggregationEndAtUtc = PREVIOUS_END,
visibleFromAtUtc = VISIBLE.minusDays(1)
), ),
snapshot(creatorId = 4L, aggregationStartAtUtc = latestStartAt, aggregationEndAtUtc = latestEndAt) entity(
creatorId = 3L,
rankNo = 1,
finalScore = 100.0,
aggregationStartAtUtc = PREVIOUS_START,
aggregationEndAtUtc = PREVIOUS_END,
visibleFromAtUtc = VISIBLE.minusDays(1)
),
entity(
creatorId = 4L,
rankNo = 2,
finalScore = 500.0,
aggregationStartAtUtc = PREVIOUS_START,
aggregationEndAtUtc = PREVIOUS_END,
visibleFromAtUtc = VISIBLE.minusDays(1)
),
entity(
creatorId = 5L,
rankNo = 1,
aggregationStartAtUtc = PREVIOUS_START,
aggregationEndAtUtc = PREVIOUS_END,
visibleFromAtUtc = VISIBLE.plusDays(1)
),
entity(
creatorId = 6L,
rankNo = 1,
aggregationStartAtUtc = START,
aggregationEndAtUtc = END,
visibleFromAtUtc = VISIBLE
)
) )
) )
val previousSnapshots = adapter.findPreviousCompletedSnapshots() val previousVisible = adapter.findPreviousVisibleSnapshots(CreatorRankingType.WEEKLY, START, NOW)
assertEquals(listOf(3L, 2L), previousSnapshots.map { it.creatorId }) assertEquals(listOf(3L, 4L, 2L), previousVisible.map { it.creatorId })
assertEquals(listOf(previousEndAt, previousEndAt), previousSnapshots.map { it.aggregationEndAtUtc }) assertEquals(listOf(1, 2, 3), previousVisible.map { it.rankNo })
} }
@Test @Test
@DisplayName("요청한 집계 기간에 스냅샷이 없으면 이전 주차를 대신 반환하지 않는") @DisplayName("findPreviousCompletedSnapshots는 최신 완료 전 집계 종료 시각 스냅샷을 rank_no 오름차순으로 조회한")
fun shouldReturnEmptyWhenRequestedAggregationPeriodHasNoSnapshots() { fun shouldFindPreviousCompletedSnapshotsOrderedByRankNoAsc() {
val previousStartAt = LocalDateTime.of(2026, 5, 24, 15, 0) repository.saveAll(
val previousEndAt = LocalDateTime.of(2026, 5, 31, 15, 0) listOf(
val requestedStartAt = LocalDateTime.of(2026, 5, 31, 15, 0) entity(creatorId = 1L, rankNo = 1, aggregationStartAtUtc = OLDEST_START, aggregationEndAtUtc = OLDEST_END),
val requestedEndAt = LocalDateTime.of(2026, 6, 7, 15, 0) entity(
repository.save( creatorId = 2L,
snapshot( rankNo = 3,
finalScore = 300.0,
aggregationStartAtUtc = PREVIOUS_START,
aggregationEndAtUtc = PREVIOUS_END
),
entity(
creatorId = 3L,
rankNo = 1,
finalScore = 100.0,
aggregationStartAtUtc = PREVIOUS_START,
aggregationEndAtUtc = PREVIOUS_END
),
entity(
creatorId = 4L,
rankNo = 2,
finalScore = 500.0,
aggregationStartAtUtc = PREVIOUS_START,
aggregationEndAtUtc = PREVIOUS_END
),
entity(creatorId = 5L, rankNo = 1, aggregationStartAtUtc = START, aggregationEndAtUtc = END)
)
)
val previousCompleted = adapter.findPreviousCompletedSnapshots()
assertEquals(listOf(3L, 4L, 2L), previousCompleted.map { it.creatorId })
assertEquals(listOf(1, 2, 3), previousCompleted.map { it.rankNo })
}
@Test
@DisplayName("findSnapshotsByAggregationPeriod는 요청 기간 스냅샷을 rank_no 오름차순으로 조회한다")
fun shouldFindSnapshotsByAggregationPeriodOrderedByRankNoAsc() {
repository.saveAll(
listOf(
entity(
creatorId = 1L, creatorId = 1L,
aggregationStartAtUtc = previousStartAt, rankNo = 1,
aggregationEndAtUtc = previousEndAt finalScore = 999.0,
aggregationStartAtUtc = PREVIOUS_START,
aggregationEndAtUtc = PREVIOUS_END
),
entity(creatorId = 2L, rankNo = 3, finalScore = 300.0),
entity(creatorId = 3L, rankNo = 1, finalScore = 100.0),
entity(creatorId = 4L, rankNo = 2, finalScore = 500.0)
) )
) )
val snapshots = adapter.findSnapshotsByAggregationPeriod( val periodSnapshots = adapter.findSnapshotsByAggregationPeriod(START, END)
aggregationStartAtUtc = requestedStartAt,
aggregationEndAtUtc = requestedEndAt
)
assertEquals(emptyList<CreatorRankingSnapshotRecord>(), snapshots) assertEquals(listOf(3L, 4L, 2L), periodSnapshots.map { it.creatorId })
assertEquals(listOf(1, 2, 3), periodSnapshots.map { it.rankNo })
} }
@Test @Test
@DisplayName("스냅샷 row가 하나도 없을 때만 테이블 완전 공백으로 판단한다") @DisplayName("replaceSnapshots는 같은 rankingType과 집계 기간만 삭제하고 다른 기간은 유지한다")
fun shouldReturnTrueOnlyWhenSnapshotTableHasNoRows() { fun shouldReplaceOnlySameTypeAndAggregationPeriod() {
assertEquals(true, adapter.isSnapshotTableEmpty()) repository.saveAll(
listOf(
repository.save( entity(
snapshot(
creatorId = 1L, creatorId = 1L,
aggregationStartAtUtc = LocalDateTime.of(2026, 5, 24, 15, 0), rankNo = 1,
aggregationEndAtUtc = LocalDateTime.of(2026, 5, 31, 15, 0) rankingType = CreatorRankingType.WEEKLY,
aggregationStartAtUtc = START,
aggregationEndAtUtc = END
),
entity(
creatorId = 2L,
rankNo = 1,
rankingType = CreatorRankingType.WEEKLY,
aggregationStartAtUtc = PREVIOUS_START,
aggregationEndAtUtc = PREVIOUS_END
),
entity(
creatorId = 3L,
rankNo = 1,
rankingType = CreatorRankingType.WEEKLY,
aggregationStartAtUtc = OLDEST_START,
aggregationEndAtUtc = OLDEST_END
) )
) )
assertEquals(false, adapter.isSnapshotTableEmpty())
}
@Test
@DisplayName("20위 점수 경계 동점 후보는 저장소에서 누락 없이 저장하고 조회할 수 있다")
fun shouldPersistAllCandidatesTiedAtTwentiethScoreBoundary() {
val startAt = LocalDateTime.of(2026, 5, 31, 15, 0)
val endAt = LocalDateTime.of(2026, 6, 7, 15, 0)
val candidates = (1L..19L).map { creatorId ->
snapshotRecord(
creatorId = creatorId,
finalScore = 1000.0 - creatorId,
aggregationStartAtUtc = startAt,
aggregationEndAtUtc = endAt
)
} + listOf(
snapshotRecord(creatorId = 20L, finalScore = 500.0, aggregationStartAtUtc = startAt, aggregationEndAtUtc = endAt),
snapshotRecord(creatorId = 21L, finalScore = 500.0, aggregationStartAtUtc = startAt, aggregationEndAtUtc = endAt),
snapshotRecord(creatorId = 22L, finalScore = 500.0, aggregationStartAtUtc = startAt, aggregationEndAtUtc = endAt)
) )
adapter.replaceSnapshots( adapter.replaceSnapshots(
rankingType = CreatorRankingType.WEEKLY, rankingType = CreatorRankingType.WEEKLY,
aggregationStartAtUtc = startAt, aggregationStartAtUtc = START,
aggregationEndAtUtc = endAt, aggregationEndAtUtc = END,
visibleFromAtUtc = endAt.plusHours(9), visibleFromAtUtc = VISIBLE,
newSnapshots = candidates newSnapshots = listOf(record(creatorId = 4L, rankNo = 1))
) )
val latestSnapshots = adapter.findLatestSnapshots() assertEquals(listOf(2L, 3L, 4L), repository.findAll().map { it.creatorId }.sorted())
assertEquals(22, latestSnapshots.size)
assertEquals(setOf(20L, 21L, 22L), latestSnapshots.takeLast(3).map { it.creatorId }.toSet())
} }
private fun snapshot( private fun record(
creatorId: Long, creatorId: Long,
finalScore: Double = 100.0, rankNo: Int,
aggregationStartAtUtc: LocalDateTime, finalScore: Double = (100 - rankNo).toDouble(),
aggregationEndAtUtc: LocalDateTime, rankingType: CreatorRankingType = CreatorRankingType.WEEKLY,
visibleFromAtUtc: LocalDateTime = aggregationEndAtUtc.plusHours(9) aggregationStartAtUtc: LocalDateTime = START,
): CreatorRankingSnapshot { aggregationEndAtUtc: LocalDateTime = END,
return CreatorRankingSnapshot( visibleFromAtUtc: LocalDateTime = VISIBLE,
rankingType = CreatorRankingType.WEEKLY, scorePolicyVersion: String = CreatorRankingScoreSpec.SCORE_POLICY_VERSION,
aggregationStartAtUtc = aggregationStartAtUtc, scoreDetailJson: String = "{}"
aggregationEndAtUtc = aggregationEndAtUtc,
visibleFromAtUtc = visibleFromAtUtc,
creatorId = creatorId,
nickname = "creator-$creatorId",
profileImageUrl = "profile-$creatorId.png",
finalScore = finalScore,
contentLiveScore = 10.0,
engagementScore = 20.0,
supportScore = 30.0,
fanLoyaltyScore = 40.0,
liveCanAmount = 100,
contentPurchaseCanAmount = 200,
contentLikeCount = 3,
contentCommentCount = 4,
channelDonationCanAmount = 500,
channelDonationCount = 6,
fanTalkCount = 7,
finalFollowerCount = 8,
followIncrease = -1
)
}
private fun snapshotRecord(
creatorId: Long,
finalScore: Double = 100.0,
aggregationStartAtUtc: LocalDateTime,
aggregationEndAtUtc: LocalDateTime,
visibleFromAtUtc: LocalDateTime = aggregationEndAtUtc.plusHours(9)
): CreatorRankingSnapshotRecord { ): CreatorRankingSnapshotRecord {
return CreatorRankingSnapshotRecord( return CreatorRankingSnapshotRecord(
rankingType = CreatorRankingType.WEEKLY, rankingType = rankingType,
aggregationStartAtUtc = aggregationStartAtUtc, aggregationStartAtUtc = aggregationStartAtUtc,
aggregationEndAtUtc = aggregationEndAtUtc, aggregationEndAtUtc = aggregationEndAtUtc,
visibleFromAtUtc = visibleFromAtUtc, visibleFromAtUtc = visibleFromAtUtc,
creatorId = creatorId, creatorId = creatorId,
nickname = "creator-$creatorId", nickname = "creator-$creatorId",
profileImageUrl = "profile-$creatorId.png", profileImageUrl = "profile-$creatorId.png",
rankNo = rankNo,
finalScore = finalScore, finalScore = finalScore,
contentLiveScore = 10.0, scorePolicyVersion = scorePolicyVersion,
engagementScore = 20.0, scoreDetailJson = scoreDetailJson
supportScore = 30.0,
fanLoyaltyScore = 40.0,
liveCanAmount = 100,
contentPurchaseCanAmount = 200,
contentLikeCount = 3,
contentCommentCount = 4,
channelDonationCanAmount = 500,
channelDonationCount = 6,
fanTalkCount = 7,
finalFollowerCount = 8,
followIncrease = -1
) )
} }
private fun entity(
creatorId: Long,
rankNo: Int,
finalScore: Double = (100 - rankNo).toDouble(),
rankingType: CreatorRankingType = CreatorRankingType.WEEKLY,
aggregationStartAtUtc: LocalDateTime = START,
aggregationEndAtUtc: LocalDateTime = END,
visibleFromAtUtc: LocalDateTime = VISIBLE,
scorePolicyVersion: String = CreatorRankingScoreSpec.SCORE_POLICY_VERSION,
scoreDetailJson: String = "{}"
): CreatorRankingSnapshot {
return CreatorRankingSnapshot(
rankingType = rankingType,
aggregationStartAtUtc = aggregationStartAtUtc,
aggregationEndAtUtc = aggregationEndAtUtc,
visibleFromAtUtc = visibleFromAtUtc,
creatorId = creatorId,
nickname = "creator-$creatorId",
profileImageUrl = "profile-$creatorId.png",
rankNo = rankNo,
finalScore = finalScore,
scorePolicyVersion = scorePolicyVersion,
scoreDetailJson = scoreDetailJson
)
}
companion object {
private val OLDEST_START = LocalDateTime.of(2026, 5, 17, 15, 0)
private val OLDEST_END = LocalDateTime.of(2026, 5, 24, 15, 0)
private val PREVIOUS_START = LocalDateTime.of(2026, 5, 24, 15, 0)
private val PREVIOUS_END = LocalDateTime.of(2026, 5, 31, 15, 0)
private val START = LocalDateTime.of(2026, 5, 31, 15, 0)
private val END = LocalDateTime.of(2026, 6, 7, 15, 0)
private val VISIBLE = LocalDateTime.of(2026, 6, 8, 0, 0)
private val NOW = LocalDateTime.of(2026, 6, 8, 1, 0)
}
} }

View File

@@ -1,568 +1,152 @@
package kr.co.vividnext.sodalive.v2.ranking.application package kr.co.vividnext.sodalive.v2.ranking.application
import kr.co.vividnext.sodalive.v2.ranking.domain.CreatorRankingItem
import kr.co.vividnext.sodalive.v2.ranking.domain.CreatorRankingSnapshotCandidate
import kr.co.vividnext.sodalive.v2.ranking.domain.CreatorRankingType import kr.co.vividnext.sodalive.v2.ranking.domain.CreatorRankingType
import kr.co.vividnext.sodalive.v2.ranking.port.out.CreatorRankingAggregationPort
import kr.co.vividnext.sodalive.v2.ranking.port.out.CreatorRankingBlockPort import kr.co.vividnext.sodalive.v2.ranking.port.out.CreatorRankingBlockPort
import kr.co.vividnext.sodalive.v2.ranking.port.out.CreatorRankingSnapshotPort import kr.co.vividnext.sodalive.v2.ranking.port.out.CreatorRankingSnapshotPort
import kr.co.vividnext.sodalive.v2.ranking.port.out.CreatorRankingSnapshotRecord import kr.co.vividnext.sodalive.v2.ranking.port.out.CreatorRankingSnapshotRecord
import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertFalse import org.junit.jupiter.api.Assertions.assertFalse
import org.junit.jupiter.api.Assertions.assertNull
import org.junit.jupiter.api.Assertions.assertThrows
import org.junit.jupiter.api.Assertions.assertTrue import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.DisplayName import org.junit.jupiter.api.DisplayName
import org.junit.jupiter.api.Test import org.junit.jupiter.api.Test
import org.junit.jupiter.api.extension.ExtendWith
import org.mockito.Mockito import org.mockito.Mockito
import org.springframework.boot.test.system.CapturedOutput import org.springframework.transaction.annotation.Transactional
import org.springframework.boot.test.system.OutputCaptureExtension
import java.time.LocalDateTime import java.time.LocalDateTime
import java.time.ZoneId import java.time.ZoneId
import java.time.ZonedDateTime import java.time.ZonedDateTime
@ExtendWith(OutputCaptureExtension::class)
class CreatorRankingQueryServiceTest { class CreatorRankingQueryServiceTest {
@Test @Test
@DisplayName("스냅샷 후보와 조회 item 내부 모델은 순위 변화와 신규 진입 값을 담을 수 있") @DisplayName("fallback refresh 후 재조회가 같은 read-only transaction에 묶이지 않도록 조회 메서드에는 Transactional이 없")
fun shouldCreateRankingDomainModelsForLaterQueryService() { fun shouldNotWrapQueryMethodWithTransactional() {
val candidate = CreatorRankingSnapshotCandidate( val method = CreatorRankingQueryService::class.java.getDeclaredMethod("getCreatorRankings", java.lang.Long::class.java)
creatorId = 1L,
nickname = "creator",
profileImageUrl = "profile.png",
finalScore = 100.0,
contentLiveScore = 10.0,
engagementScore = 20.0,
supportScore = 30.0,
fanLoyaltyScore = 40.0,
liveCanAmount = 100,
contentPurchaseCanAmount = 200,
contentLikeCount = 3,
contentCommentCount = 4,
channelDonationCanAmount = 500,
channelDonationCount = 6,
fanTalkCount = 7,
finalFollowerCount = 8,
followIncrease = -1
)
val item = CreatorRankingItem(
rank = 1,
rankChange = null,
isNew = true,
creatorId = candidate.creatorId,
nickname = candidate.nickname,
profileImageUrl = candidate.profileImageUrl
)
val fallenItem = item.copy(rank = 2, rankChange = -1, isNew = false)
assertEquals(1L, candidate.creatorId) assertFalse(method.isAnnotationPresent(Transactional::class.java))
assertEquals(100.0, candidate.finalScore, 0.0001)
assertNull(item.rankChange)
assertTrue(item.isNew)
assertEquals(-1, fallenItem.rankChange)
assertFalse(fallenItem.isNew)
} }
@Test @Test
@DisplayName("최신 완료 주차 스냅샷이 없으면 순위 변화 비노출과 빈 목록을 반환한다") @DisplayName("최신 공개 스냅샷이 없으면 fallback refresh 후 재조회한 스냅샷을 응답한다")
fun shouldReturnEmptyResultWhenLatestSnapshotsDoNotExist() { fun shouldRefreshByFallbackAndRequeryWhenLatestSnapshotsEmpty() {
val snapshotPort = FakeCreatorRankingQuerySnapshotPort() val snapshotPort = FakeCreatorRankingQuerySnapshotPort()
val service = service(snapshotPort = snapshotPort) val jobService = Mockito.mock(CreatorRankingSnapshotJobService::class.java)
snapshotPort.latestResponses = ArrayDeque(listOf(emptyList(), listOf(snapshot(1, rankNo = 1))))
val service = service(snapshotPort = snapshotPort, snapshotJobService = jobService)
val result = service.getCreatorRankings(viewerMemberId = null) val result = service.getCreatorRankings(viewerMemberId = null)
Mockito.verify(jobService).refreshLastCompletedWeekByFallback()
assertEquals(2, snapshotPort.latestCallCount)
assertFalse(result.showRankChange) assertFalse(result.showRankChange)
assertTrue(result.items.isEmpty())
}
@Test
@DisplayName("최신 스냅샷이 있으면 cold-start fallback 집계를 호출하지 않는다")
fun shouldNotUseColdStartFallbackWhenLatestSnapshotsExist() {
val snapshotPort = FakeCreatorRankingQuerySnapshotPort()
val aggregationPort = FakeCreatorRankingQueryAggregationPort()
snapshotPort.latestSnapshots = listOf(snapshot(creatorId = 1L, finalScore = 100.0))
snapshotPort.snapshotTableEmpty = true
aggregationPort.candidates = listOf(candidate(creatorId = 2L))
val service = service(snapshotPort = snapshotPort, aggregationPort = aggregationPort)
val result = service.getCreatorRankings(viewerMemberId = null)
assertEquals(listOf(1L), result.items.map { it.creatorId }) assertEquals(listOf(1L), result.items.map { it.creatorId })
assertEquals(0, aggregationPort.aggregateCallCount)
} }
@Test @Test
@DisplayName("최신 스냅샷이 없고 스냅샷 테이블이 완전히 비어 있으면 cold-start fallback을 반환한다") @DisplayName("fallback 실패 또는 재조회 결과 없음은 빈 목록 성공으로 반환한다")
fun shouldUseColdStartFallbackOnlyWhenSnapshotTableIsEmpty() { fun shouldReturnEmptyWhenFallbackFailsAndRequeryStillEmpty() {
val snapshotPort = FakeCreatorRankingQuerySnapshotPort() val snapshotPort = FakeCreatorRankingQuerySnapshotPort()
val aggregationPort = FakeCreatorRankingQueryAggregationPort() val jobService = Mockito.mock(CreatorRankingSnapshotJobService::class.java)
val snapshotJobService = Mockito.mock(CreatorRankingSnapshotJobService::class.java) Mockito.doThrow(IllegalStateException("fallback failed")).`when`(jobService).refreshLastCompletedWeekByFallback()
snapshotPort.snapshotTableEmpty = true val service = service(snapshotPort = snapshotPort, snapshotJobService = jobService)
aggregationPort.candidates = listOf(
candidate(creatorId = 1L, liveCanAmount = 100),
candidate(creatorId = 2L, liveCanAmount = 200)
)
val service = service(
snapshotPort = snapshotPort,
aggregationPort = aggregationPort,
snapshotJobService = snapshotJobService
)
val result = service.getCreatorRankings(viewerMemberId = null)
assertFalse(result.showRankChange)
assertEquals(listOf(2L, 1L), result.items.map { it.creatorId })
assertEquals(listOf(1, 2), result.items.map { it.rank })
assertTrue(result.items.all { it.rankChange == null })
assertTrue(result.items.none { it.isNew })
assertEquals(1, aggregationPort.aggregateCallCount)
assertEquals(LocalDateTime.of(2026, 5, 31, 15, 0), aggregationPort.startInclusiveUtc)
assertEquals(LocalDateTime.of(2026, 6, 7, 15, 0), aggregationPort.endExclusiveUtc)
Mockito.verify(snapshotJobService).ensureLastCompletedWeekSnapshotForColdStart()
}
@Test
@DisplayName("cold-start fallback 후보가 없으면 스냅샷 생성 위임을 호출하지 않는다")
fun shouldNotDelegateColdStartSnapshotRefreshWhenFallbackIsEmpty() {
val snapshotPort = FakeCreatorRankingQuerySnapshotPort()
val aggregationPort = FakeCreatorRankingQueryAggregationPort()
val snapshotJobService = Mockito.mock(CreatorRankingSnapshotJobService::class.java)
snapshotPort.snapshotTableEmpty = true
val service = service(
snapshotPort = snapshotPort,
aggregationPort = aggregationPort,
snapshotJobService = snapshotJobService
)
val result = service.getCreatorRankings(viewerMemberId = null) val result = service.getCreatorRankings(viewerMemberId = null)
assertFalse(result.showRankChange) assertFalse(result.showRankChange)
assertTrue(result.items.isEmpty()) assertTrue(result.items.isEmpty())
Mockito.verify(snapshotJobService, Mockito.never()).ensureLastCompletedWeekSnapshotForColdStart() assertEquals(2, snapshotPort.latestCallCount)
} }
@Test @Test
@DisplayName("최신 스냅샷이 없어도 과거 스냅샷 row가 있으면 cold-start fallback을 호출하지 않는") @DisplayName("최신 공개 스냅샷이 있으면 fallback 없이 rankNo 기준 rank/rankChange/isNew를 계산한")
fun shouldNotUseColdStartFallbackWhenAnyHistoricalSnapshotExists() { fun shouldUseRankNoForRankChangeWithoutFallback() {
val snapshotPort = FakeCreatorRankingQuerySnapshotPort() val snapshotPort = FakeCreatorRankingQuerySnapshotPort()
val aggregationPort = FakeCreatorRankingQueryAggregationPort() val jobService = Mockito.mock(CreatorRankingSnapshotJobService::class.java)
val snapshotJobService = Mockito.mock(CreatorRankingSnapshotJobService::class.java) snapshotPort.latestResponses = ArrayDeque(
snapshotPort.snapshotTableEmpty = false listOf(listOf(snapshot(2, rankNo = 1), snapshot(1, rankNo = 2), snapshot(3, rankNo = 3)))
aggregationPort.candidates = listOf(candidate(creatorId = 1L))
val service = service(
snapshotPort = snapshotPort,
aggregationPort = aggregationPort,
snapshotJobService = snapshotJobService
) )
snapshotPort.previousSnapshots = listOf(snapshot(1, rankNo = 1), snapshot(2, rankNo = 2))
val service = service(snapshotPort = snapshotPort, snapshotJobService = jobService)
val result = service.getCreatorRankings(viewerMemberId = null) val result = service.getCreatorRankings(viewerMemberId = null)
assertFalse(result.showRankChange) Mockito.verify(jobService, Mockito.never()).refreshLastCompletedWeekByFallback()
assertTrue(result.items.isEmpty())
assertEquals(0, aggregationPort.aggregateCallCount)
Mockito.verify(snapshotJobService, Mockito.never()).ensureLastCompletedWeekSnapshotForColdStart()
}
@Test
@DisplayName("cold-start fallback도 차단 관계가 있으면 크리에이터 식별 정보만 마스킹한다")
fun shouldMaskBlockedCreatorIdentityInColdStartFallback() {
val snapshotPort = FakeCreatorRankingQuerySnapshotPort()
val aggregationPort = FakeCreatorRankingQueryAggregationPort()
val blockPort = FakeCreatorRankingBlockPort()
snapshotPort.snapshotTableEmpty = true
aggregationPort.candidates = listOf(
candidate(creatorId = 1L, liveCanAmount = 200),
candidate(creatorId = 2L, liveCanAmount = 100)
)
blockPort.blockedCreatorIds = setOf(1L)
val service = service(
snapshotPort = snapshotPort,
blockPort = blockPort,
aggregationPort = aggregationPort
)
val result = service.getCreatorRankings(viewerMemberId = 99L)
assertEquals(99L, blockPort.memberId)
assertEquals(setOf(1L, 2L), blockPort.creatorIds)
assertEquals(0L, result.items.first().creatorId)
assertEquals("", result.items.first().nickname)
assertEquals("https://cdn.test/profile/default-profile.png", result.items.first().profileImageUrl)
assertEquals(2L, result.items[1].creatorId)
}
@Test
@DisplayName("직전 완료 주차 스냅샷이 없으면 순위 변화 없이 최신 스냅샷 상위 20명을 반환한다")
fun shouldReturnLatestTopTwentyWithoutRankChangeWhenPreviousSnapshotsDoNotExist() {
val snapshotPort = FakeCreatorRankingQuerySnapshotPort()
snapshotPort.latestSnapshots = (1L..21L).map { creatorId ->
snapshot(creatorId = creatorId, finalScore = (100 - creatorId).toDouble())
}
val service = service(snapshotPort = snapshotPort)
val result = service.getCreatorRankings(viewerMemberId = null)
assertFalse(result.showRankChange)
assertEquals(20, result.items.size)
assertEquals((1..20).toList(), result.items.map { it.rank })
assertEquals((1L..20L).toList(), result.items.map { it.creatorId })
assertTrue(result.items.all { it.rankChange == null })
assertTrue(result.items.none { it.isNew })
}
@Test
@DisplayName("직전 완료 주차 스냅샷이 있으면 현재 순위와 비교해 순위 변화와 신규 진입을 계산한다")
fun shouldCalculateRankChangeAndNewEntryFromPreviousSnapshots() {
val snapshotPort = FakeCreatorRankingQuerySnapshotPort()
snapshotPort.latestSnapshots = listOf(
snapshot(creatorId = 2L, finalScore = 300.0),
snapshot(creatorId = 1L, finalScore = 200.0),
snapshot(creatorId = 3L, finalScore = 100.0),
snapshot(creatorId = 4L, finalScore = 50.0)
)
snapshotPort.previousSnapshots = listOf(
snapshot(creatorId = 1L, finalScore = 400.0),
snapshot(creatorId = 2L, finalScore = 300.0),
snapshot(creatorId = 3L, finalScore = 100.0)
)
val service = service(snapshotPort = snapshotPort)
val result = service.getCreatorRankings(viewerMemberId = null)
assertTrue(result.showRankChange) assertTrue(result.showRankChange)
assertEquals(listOf(2L, 1L, 3L, 4L), result.items.map { it.creatorId }) assertEquals(listOf(2L, 1L, 3L), result.items.map { it.creatorId })
assertEquals(listOf(1, 2, 3, 4), result.items.map { it.rank }) assertEquals(listOf(1, 2, 3), result.items.map { it.rank })
assertEquals(listOf(1, -1, 0, null), result.items.map { it.rankChange }) assertEquals(listOf(1, -1, null), result.items.map { it.rankChange })
assertEquals(listOf(false, false, false, true), result.items.map { it.isNew }) assertEquals(listOf(false, false, true), result.items.map { it.isNew })
} }
@Test @Test
@DisplayName("조회 서비스는 현재 UTC 시각 기준 최신 공개 스냅샷과 직전 공개 스냅샷으로 순위 변화를 계산한다") @DisplayName("차단 관계가 있으면 row를 제거하지 않고 식별 정보만 마스킹한다")
fun shouldUseLatestVisibleSnapshotsAndPreviousVisibleSnapshots() { fun shouldMaskBlockedCreatorWithoutRemovingRow() {
val snapshotPort = FakeCreatorRankingQuerySnapshotPort()
val now = ZonedDateTime.of(2026, 6, 8, 9, 0, 0, 0, ZoneId.of("Asia/Seoul"))
snapshotPort.latestSnapshots = listOf(
snapshot(creatorId = 2L, finalScore = 300.0),
snapshot(creatorId = 1L, finalScore = 200.0)
)
snapshotPort.previousSnapshots = listOf(
snapshot(creatorId = 1L, finalScore = 400.0),
snapshot(creatorId = 2L, finalScore = 100.0)
)
val service = service(snapshotPort = snapshotPort, now = now)
val result = service.getCreatorRankings(viewerMemberId = null)
assertEquals(CreatorRankingType.WEEKLY, snapshotPort.latestRankingType)
assertEquals(LocalDateTime.of(2026, 6, 8, 0, 0), snapshotPort.latestNowUtc)
assertEquals(CreatorRankingType.WEEKLY, snapshotPort.previousRankingType)
assertEquals(LocalDateTime.of(2026, 5, 31, 15, 0), snapshotPort.previousCurrentAggregationStartAtUtc)
assertEquals(LocalDateTime.of(2026, 6, 8, 0, 0), snapshotPort.previousNowUtc)
assertEquals(listOf(1, -1), result.items.map { it.rankChange })
}
@Test
@DisplayName("cold-start fallback은 공개 노출 시각 전이면 원천 집계와 스냅샷 생성 위임을 실행하지 않는다")
fun shouldNotUseColdStartFallbackBeforeVisibleFromAt() {
val snapshotPort = FakeCreatorRankingQuerySnapshotPort()
val aggregationPort = FakeCreatorRankingQueryAggregationPort()
val snapshotJobService = Mockito.mock(CreatorRankingSnapshotJobService::class.java)
snapshotPort.snapshotTableEmpty = true
aggregationPort.candidates = listOf(candidate(creatorId = 1L))
val service = service(
snapshotPort = snapshotPort,
aggregationPort = aggregationPort,
snapshotJobService = snapshotJobService,
now = ZonedDateTime.of(2026, 6, 8, 8, 59, 59, 0, ZoneId.of("Asia/Seoul"))
)
val result = service.getCreatorRankings(viewerMemberId = null)
assertFalse(result.showRankChange)
assertTrue(result.items.isEmpty())
assertEquals(0, aggregationPort.aggregateCallCount)
Mockito.verify(snapshotJobService, Mockito.never()).ensureLastCompletedWeekSnapshotForColdStart()
}
@Test
@DisplayName("동점 스냅샷은 같은 점수 구간 안에서만 섞이고 상위 20명만 반환한다")
fun shouldRandomizeOnlyWithinTieGroupsAndLimitToTwentyItems() {
val snapshotPort = FakeCreatorRankingQuerySnapshotPort()
snapshotPort.latestSnapshots = listOf(
snapshot(creatorId = 1L, finalScore = 300.0),
snapshot(creatorId = 2L, finalScore = 200.0),
snapshot(creatorId = 3L, finalScore = 200.0)
) + (4L..22L).map { creatorId ->
snapshot(creatorId = creatorId, finalScore = (100 - creatorId).toDouble())
}
val service = service(snapshotPort = snapshotPort)
val result = service.getCreatorRankings(viewerMemberId = null)
assertEquals(20, result.items.size)
assertEquals(1L, result.items.first().creatorId)
assertEquals(setOf(2L, 3L), result.items.drop(1).take(2).map { it.creatorId }.toSet())
assertEquals((1..20).toList(), result.items.map { it.rank })
}
@Test
@DisplayName("차단 관계가 있으면 순위 row는 유지하고 크리에이터 식별 정보만 마스킹한다")
fun shouldMaskBlockedCreatorIdentityWithoutRemovingRankingRow() {
val snapshotPort = FakeCreatorRankingQuerySnapshotPort() val snapshotPort = FakeCreatorRankingQuerySnapshotPort()
val blockPort = FakeCreatorRankingBlockPort() val blockPort = FakeCreatorRankingBlockPort()
snapshotPort.latestSnapshots = listOf( snapshotPort.latestResponses = ArrayDeque(listOf(listOf(snapshot(1, rankNo = 1), snapshot(2, rankNo = 2))))
snapshot(creatorId = 1L, finalScore = 300.0), snapshotPort.previousSnapshots = listOf(snapshot(1, rankNo = 1), snapshot(2, rankNo = 2))
snapshot(creatorId = 2L, finalScore = 200.0)
)
snapshotPort.previousSnapshots = listOf(
snapshot(creatorId = 1L, finalScore = 300.0),
snapshot(creatorId = 2L, finalScore = 200.0)
)
blockPort.blockedCreatorIds = setOf(2L) blockPort.blockedCreatorIds = setOf(2L)
val service = service(snapshotPort = snapshotPort, blockPort = blockPort) val service = service(snapshotPort = snapshotPort, blockPort = blockPort)
val result = service.getCreatorRankings(viewerMemberId = 99L) val result = service.getCreatorRankings(viewerMemberId = 99L)
assertEquals(listOf(1, 2), result.items.map { it.rank }) assertEquals(listOf(1, 2), result.items.map { it.rank })
assertEquals(99L, blockPort.memberId)
assertEquals(setOf(1L, 2L), blockPort.creatorIds)
assertEquals(2, result.items.size)
assertEquals(0L, result.items[1].creatorId) assertEquals(0L, result.items[1].creatorId)
assertEquals("", result.items[1].nickname) assertEquals("", result.items[1].nickname)
assertEquals("https://cdn.test/profile/default-profile.png", result.items[1].profileImageUrl) assertEquals("https://cdn.test/profile/default-profile.png", result.items[1].profileImageUrl)
assertEquals(0, result.items[1].rankChange)
assertFalse(result.items[1].isNew)
}
@Test
@DisplayName("비회원 조회는 차단 관계를 조회하지 않고 원본 랭킹을 반환한다")
fun shouldNotLookupBlocksForAnonymousViewer() {
val snapshotPort = FakeCreatorRankingQuerySnapshotPort()
val blockPort = FakeCreatorRankingBlockPort()
snapshotPort.latestSnapshots = listOf(snapshot(creatorId = 1L, finalScore = 100.0))
val service = service(snapshotPort = snapshotPort, blockPort = blockPort)
val result = service.getCreatorRankings(viewerMemberId = null)
assertNull(blockPort.memberId)
assertEquals(1L, result.items.single().creatorId)
assertEquals("creator-1", result.items.single().nickname)
assertEquals("https://cdn.test/profile-1.png", result.items.single().profileImageUrl)
}
@Test
@DisplayName("크리에이터 랭킹 조회 성공은 순위 변화 노출 여부와 반환 수를 로그로 남긴다")
fun shouldLogCreatorRankingQuerySuccessWithResultCounts(output: CapturedOutput) {
val snapshotPort = FakeCreatorRankingQuerySnapshotPort()
snapshotPort.latestSnapshots = listOf(snapshot(creatorId = 1L, finalScore = 100.0))
val service = service(snapshotPort = snapshotPort)
service.getCreatorRankings(viewerMemberId = null)
assertTrue(output.out.contains("event=creator_ranking_query_success"))
assertTrue(output.out.contains("showRankChange=false"))
assertTrue(output.out.contains("itemCount=1"))
assertTrue(output.out.contains("blockedCreatorCount=0"))
}
@Test
@DisplayName("크리에이터 랭킹 조회 실패는 에러를 로그로 남기고 예외를 전파한다")
fun shouldLogCreatorRankingQueryFailureWithError(output: CapturedOutput) {
val snapshotPort = FakeCreatorRankingQuerySnapshotPort()
snapshotPort.latestFailure = IllegalStateException("latest snapshots failed")
val service = service(snapshotPort = snapshotPort)
val exception = assertThrows(IllegalStateException::class.java) {
service.getCreatorRankings(viewerMemberId = 99L)
}
assertEquals("latest snapshots failed", exception.message)
assertTrue(output.out.contains("event=creator_ranking_query_failure"))
assertTrue(output.out.contains("error=latest snapshots failed"))
}
@Test
@DisplayName("cold-start fallback 성공은 기간과 반환 수를 로그로 남긴다")
fun shouldLogColdStartFallbackSuccessWithPeriodAndCount(output: CapturedOutput) {
val snapshotPort = FakeCreatorRankingQuerySnapshotPort()
val aggregationPort = FakeCreatorRankingQueryAggregationPort()
snapshotPort.snapshotTableEmpty = true
aggregationPort.candidates = listOf(candidate(creatorId = 1L))
val service = service(snapshotPort = snapshotPort, aggregationPort = aggregationPort)
service.getCreatorRankings(viewerMemberId = null)
assertTrue(output.out.contains("event=creator_ranking_query_cold_start_fallback_attempt"))
assertTrue(output.out.contains("event=creator_ranking_query_cold_start_fallback_success"))
assertTrue(output.out.contains("aggregationStartAtUtc=2026-05-31T15:00"))
assertTrue(output.out.contains("aggregationEndAtUtc=2026-06-07T15:00"))
assertTrue(output.out.contains("itemCount=1"))
}
@Test
@DisplayName("cold-start fallback 실패는 기간과 에러를 로그로 남기고 예외를 전파한다")
fun shouldLogColdStartFallbackFailureWithError(output: CapturedOutput) {
val snapshotPort = FakeCreatorRankingQuerySnapshotPort()
val aggregationPort = FakeCreatorRankingQueryAggregationPort()
snapshotPort.snapshotTableEmpty = true
aggregationPort.failure = IllegalStateException("fallback failed")
val service = service(snapshotPort = snapshotPort, aggregationPort = aggregationPort)
val exception = assertThrows(IllegalStateException::class.java) {
service.getCreatorRankings(viewerMemberId = null)
}
assertEquals("fallback failed", exception.message)
assertTrue(output.out.contains("event=creator_ranking_query_cold_start_fallback_attempt"))
assertTrue(output.out.contains("event=creator_ranking_query_cold_start_fallback_failure"))
assertTrue(output.out.contains("aggregationStartAtUtc=2026-05-31T15:00"))
assertTrue(output.out.contains("aggregationEndAtUtc=2026-06-07T15:00"))
assertTrue(output.out.contains("error=fallback failed"))
}
@Test
@DisplayName("cold-start 스냅샷 생성 위임 실패는 fallback 응답을 깨지 않고 로그로 남긴다")
fun shouldKeepFallbackResponseWhenColdStartSnapshotRefreshDelegationFails(output: CapturedOutput) {
val snapshotPort = FakeCreatorRankingQuerySnapshotPort()
val aggregationPort = FakeCreatorRankingQueryAggregationPort()
val snapshotJobService = Mockito.mock(CreatorRankingSnapshotJobService::class.java)
snapshotPort.snapshotTableEmpty = true
aggregationPort.candidates = listOf(candidate(creatorId = 1L))
Mockito.doThrow(IllegalStateException("cold-start refresh failed"))
.`when`(snapshotJobService).ensureLastCompletedWeekSnapshotForColdStart()
val service = service(
snapshotPort = snapshotPort,
aggregationPort = aggregationPort,
snapshotJobService = snapshotJobService
)
val result = service.getCreatorRankings(viewerMemberId = null)
assertFalse(result.showRankChange)
assertEquals(listOf(1L), result.items.map { it.creatorId })
assertTrue(output.out.contains("event=creator_ranking_query_cold_start_snapshot_refresh_failure"))
assertTrue(output.out.contains("error=cold-start refresh failed"))
} }
private fun service( private fun service(
snapshotPort: CreatorRankingSnapshotPort = FakeCreatorRankingQuerySnapshotPort(), snapshotPort: CreatorRankingSnapshotPort = FakeCreatorRankingQuerySnapshotPort(),
blockPort: CreatorRankingBlockPort = FakeCreatorRankingBlockPort(), blockPort: CreatorRankingBlockPort = FakeCreatorRankingBlockPort(),
aggregationPort: CreatorRankingAggregationPort = FakeCreatorRankingQueryAggregationPort(),
snapshotJobService: CreatorRankingSnapshotJobService = Mockito.mock(CreatorRankingSnapshotJobService::class.java), snapshotJobService: CreatorRankingSnapshotJobService = Mockito.mock(CreatorRankingSnapshotJobService::class.java),
now: ZonedDateTime = ZonedDateTime.of(2026, 6, 8, 9, 0, 0, 0, ZoneId.of("Asia/Seoul")) now: ZonedDateTime = ZonedDateTime.of(2026, 6, 8, 9, 0, 0, 0, ZoneId.of("Asia/Seoul"))
): CreatorRankingQueryService { ) = CreatorRankingQueryService(snapshotPort, blockPort, snapshotJobService, { now }, "https://cdn.test")
return CreatorRankingQueryService(
snapshotPort = snapshotPort,
blockPort = blockPort,
aggregationPort = aggregationPort,
snapshotJobService = snapshotJobService,
nowProvider = { now },
cloudFrontHost = "https://cdn.test"
)
}
private fun candidate( private fun snapshot(creatorId: Long, rankNo: Int): CreatorRankingSnapshotRecord {
creatorId: Long,
liveCanAmount: Long = 100
): CreatorRankingSnapshotCandidate {
return CreatorRankingSnapshotCandidate(
creatorId = creatorId,
nickname = "creator-$creatorId",
profileImageUrl = "profile-$creatorId.png",
finalScore = 0.0,
contentLiveScore = 0.0,
engagementScore = 0.0,
supportScore = 0.0,
fanLoyaltyScore = 0.0,
liveCanAmount = liveCanAmount,
contentPurchaseCanAmount = 0,
contentLikeCount = 0,
contentCommentCount = 0,
channelDonationCanAmount = 0,
channelDonationCount = 0,
fanTalkCount = 0,
finalFollowerCount = 0,
followIncrease = 0
)
}
private fun snapshot(
creatorId: Long,
finalScore: Double
): CreatorRankingSnapshotRecord {
return CreatorRankingSnapshotRecord( return CreatorRankingSnapshotRecord(
rankingType = CreatorRankingType.WEEKLY, rankingType = CreatorRankingType.WEEKLY,
aggregationStartAtUtc = LocalDateTime.of(2026, 5, 31, 15, 0, 0), aggregationStartAtUtc = LocalDateTime.of(2026, 5, 31, 15, 0),
aggregationEndAtUtc = LocalDateTime.of(2026, 6, 7, 15, 0, 0), aggregationEndAtUtc = LocalDateTime.of(2026, 6, 7, 15, 0),
visibleFromAtUtc = LocalDateTime.of(2026, 6, 8, 0, 0, 0), visibleFromAtUtc = LocalDateTime.of(2026, 6, 8, 0, 0),
creatorId = creatorId, creatorId = creatorId,
nickname = "creator-$creatorId", nickname = "creator-$creatorId",
profileImageUrl = "profile-$creatorId.png", profileImageUrl = "profile-$creatorId.png",
finalScore = finalScore, rankNo = rankNo,
contentLiveScore = 0.0, finalScore = (100 - rankNo).toDouble(),
engagementScore = 0.0, scorePolicyVersion = "CREATOR_WEEKLY_POPULARITY_V2",
supportScore = 0.0, scoreDetailJson = "{}"
fanLoyaltyScore = 0.0,
liveCanAmount = 0,
contentPurchaseCanAmount = 0,
contentLikeCount = 0,
contentCommentCount = 0,
channelDonationCanAmount = 0,
channelDonationCount = 0,
fanTalkCount = 0,
finalFollowerCount = 0,
followIncrease = 0
) )
} }
} }
private class FakeCreatorRankingQuerySnapshotPort : CreatorRankingSnapshotPort { private class FakeCreatorRankingQuerySnapshotPort : CreatorRankingSnapshotPort {
var latestSnapshots: List<CreatorRankingSnapshotRecord> = emptyList() var latestResponses: ArrayDeque<List<CreatorRankingSnapshotRecord>> = ArrayDeque(listOf(emptyList()))
var previousSnapshots: List<CreatorRankingSnapshotRecord> = emptyList() var previousSnapshots: List<CreatorRankingSnapshotRecord> = emptyList()
var latestFailure: RuntimeException? = null var latestCallCount = 0
var snapshotTableEmpty: Boolean = true
var latestRankingType: CreatorRankingType? = null
var latestNowUtc: LocalDateTime? = null
var previousRankingType: CreatorRankingType? = null
var previousCurrentAggregationStartAtUtc: LocalDateTime? = null
var previousNowUtc: LocalDateTime? = null
override fun findSnapshotsByAggregationPeriod( override fun findSnapshotsByAggregationPeriod(
aggregationStartAtUtc: LocalDateTime, aggregationStartAtUtc: LocalDateTime,
aggregationEndAtUtc: LocalDateTime aggregationEndAtUtc: LocalDateTime
): List<CreatorRankingSnapshotRecord> = emptyList() ) = emptyList<CreatorRankingSnapshotRecord>()
override fun findLatestSnapshots(): List<CreatorRankingSnapshotRecord> { override fun findLatestSnapshots() = latestResponses.firstOrNull() ?: emptyList()
latestFailure?.let { throw it }
return latestSnapshots
}
override fun findPreviousCompletedSnapshots(): List<CreatorRankingSnapshotRecord> = previousSnapshots override fun findPreviousCompletedSnapshots() = previousSnapshots
override fun findLatestVisibleSnapshots( override fun findLatestVisibleSnapshots(
rankingType: CreatorRankingType, rankingType: CreatorRankingType,
nowUtc: LocalDateTime nowUtc: LocalDateTime
): List<CreatorRankingSnapshotRecord> { ): List<CreatorRankingSnapshotRecord> {
latestFailure?.let { throw it } latestCallCount++
latestRankingType = rankingType return if (latestResponses.size > 1) {
latestNowUtc = nowUtc latestResponses.removeFirst()
return latestSnapshots } else {
latestResponses.firstOrNull() ?: emptyList()
}
} }
override fun findPreviousVisibleSnapshots( override fun findPreviousVisibleSnapshots(
rankingType: CreatorRankingType, rankingType: CreatorRankingType,
currentAggregationStartAtUtc: LocalDateTime, currentAggregationStartAtUtc: LocalDateTime,
nowUtc: LocalDateTime nowUtc: LocalDateTime
): List<CreatorRankingSnapshotRecord> { ) = previousSnapshots
previousRankingType = rankingType
previousCurrentAggregationStartAtUtc = currentAggregationStartAtUtc
previousNowUtc = nowUtc
return previousSnapshots
}
override fun isSnapshotTableEmpty(): Boolean = snapshotTableEmpty
override fun replaceSnapshots( override fun replaceSnapshots(
rankingType: CreatorRankingType, rankingType: CreatorRankingType,
aggregationStartAtUtc: LocalDateTime, aggregationStartAtUtc: LocalDateTime,
@@ -572,33 +156,7 @@ private class FakeCreatorRankingQuerySnapshotPort : CreatorRankingSnapshotPort {
) = Unit ) = Unit
} }
private class FakeCreatorRankingQueryAggregationPort : CreatorRankingAggregationPort {
var candidates: List<CreatorRankingSnapshotCandidate> = emptyList()
var failure: RuntimeException? = null
var aggregateCallCount = 0
var startInclusiveUtc: LocalDateTime? = null
var endExclusiveUtc: LocalDateTime? = null
override fun aggregateCandidates(
startInclusiveUtc: LocalDateTime,
endExclusiveUtc: LocalDateTime
): List<CreatorRankingSnapshotCandidate> {
aggregateCallCount++
this.startInclusiveUtc = startInclusiveUtc
this.endExclusiveUtc = endExclusiveUtc
failure?.let { throw it }
return candidates
}
}
private class FakeCreatorRankingBlockPort : CreatorRankingBlockPort { private class FakeCreatorRankingBlockPort : CreatorRankingBlockPort {
var blockedCreatorIds: Set<Long> = emptySet() var blockedCreatorIds: Set<Long> = emptySet()
var memberId: Long? = null override fun findBlockedCreatorIds(memberId: Long, creatorIds: Collection<Long>) = blockedCreatorIds
var creatorIds: Set<Long> = emptySet()
override fun findBlockedCreatorIds(memberId: Long, creatorIds: Collection<Long>): Set<Long> {
this.memberId = memberId
this.creatorIds = creatorIds.toSet()
return blockedCreatorIds
}
} }

View File

@@ -6,458 +6,184 @@ import kr.co.vividnext.sodalive.v2.ranking.port.out.CreatorRankingSnapshotJobRec
import kr.co.vividnext.sodalive.v2.ranking.port.out.CreatorRankingSnapshotJobStatus import kr.co.vividnext.sodalive.v2.ranking.port.out.CreatorRankingSnapshotJobStatus
import kr.co.vividnext.sodalive.v2.ranking.port.out.CreatorRankingSnapshotJobTrigger import kr.co.vividnext.sodalive.v2.ranking.port.out.CreatorRankingSnapshotJobTrigger
import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertFalse
import org.junit.jupiter.api.Assertions.assertThrows import org.junit.jupiter.api.Assertions.assertThrows
import org.junit.jupiter.api.Assertions.assertTrue import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.DisplayName import org.junit.jupiter.api.DisplayName
import org.junit.jupiter.api.Test import org.junit.jupiter.api.Test
import org.junit.jupiter.api.extension.ExtendWith
import org.mockito.Mockito import org.mockito.Mockito
import org.redisson.api.RLock import org.redisson.api.RLock
import org.redisson.api.RedissonClient import org.redisson.api.RedissonClient
import org.springframework.boot.test.system.CapturedOutput
import org.springframework.boot.test.system.OutputCaptureExtension
import org.springframework.transaction.PlatformTransactionManager import org.springframework.transaction.PlatformTransactionManager
import org.springframework.transaction.TransactionDefinition import org.springframework.transaction.TransactionDefinition
import org.springframework.transaction.TransactionStatus
import org.springframework.transaction.support.SimpleTransactionStatus import org.springframework.transaction.support.SimpleTransactionStatus
import java.time.LocalDateTime import java.time.LocalDateTime
import java.time.ZoneId import java.time.ZoneId
import java.time.ZonedDateTime import java.time.ZonedDateTime
import java.util.concurrent.TimeUnit import java.util.concurrent.TimeUnit
@ExtendWith(OutputCaptureExtension::class)
class CreatorRankingSnapshotJobServiceTest { class CreatorRankingSnapshotJobServiceTest {
@Test @Test
@DisplayName("스케줄 실행은 집계 기간을 포함한 SCHEDULED job을 생성하고 성공 시 DONE으로 기록한다") @DisplayName("fallback은 FALLBACK job을 PENDING -> PROCESSING -> DONE으로 전이하고 refresh를 실행한다")
fun shouldCreateScheduledJobAndMarkDoneWhenRefreshSucceeds() { fun shouldRefreshByFallbackWithJobStatusTransitions() {
val refreshService = Mockito.mock(CreatorRankingSnapshotRefreshService::class.java) val refreshService = Mockito.mock(CreatorRankingSnapshotRefreshService::class.java)
val jobPort = FakeCreatorRankingSnapshotJobPort() val jobPort = FakeCreatorRankingSnapshotJobPort()
val now = ZonedDateTime.of(2026, 6, 8, 7, 30, 0, 0, ZoneId.of("Asia/Seoul")) val service = service(refreshService, jobPort)
val redissonClient = periodLockRedissonClient(lockAcquired = true)
val service = CreatorRankingSnapshotJobService(refreshService, jobPort, redissonClient, transactionManager()) { now }
service.refreshLastCompletedWeekByScheduledJob() val refreshed = service.refreshLastCompletedWeekByFallback()
val job = jobPort.jobs.single() assertTrue(refreshed)
assertEquals(CreatorRankingType.WEEKLY, job.rankingType) assertEquals(CreatorRankingSnapshotJobTrigger.FALLBACK, jobPort.jobs.single().trigger)
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(CreatorRankingSnapshotJobTrigger.SCHEDULED, job.trigger)
assertEquals(CreatorRankingSnapshotJobStatus.DONE, job.status)
assertEquals(null, job.lastError)
Mockito.verify(refreshService).refreshLastCompletedWeek(now)
}
@Test
@DisplayName("스케줄 실행 실패는 FAILED 상태와 실패 사유를 기록하고 예외를 전파한다")
fun shouldMarkScheduledJobFailedWhenRefreshFails() {
val refreshService = Mockito.mock(CreatorRankingSnapshotRefreshService::class.java)
val jobPort = FakeCreatorRankingSnapshotJobPort()
val now = ZonedDateTime.of(2026, 6, 8, 7, 30, 0, 0, ZoneId.of("Asia/Seoul"))
val redissonClient = periodLockRedissonClient(lockAcquired = true)
val service = CreatorRankingSnapshotJobService(refreshService, jobPort, redissonClient, transactionManager()) { now }
Mockito.doThrow(IllegalStateException("aggregate failed"))
.`when`(refreshService).refreshLastCompletedWeek(now)
val exception = assertThrows(IllegalStateException::class.java) {
service.refreshLastCompletedWeekByScheduledJob()
}
assertEquals("aggregate failed", exception.message)
assertEquals(CreatorRankingSnapshotJobStatus.FAILED, jobPort.jobs.single().status)
assertEquals("aggregate failed", jobPort.jobs.single().lastError)
}
@Test
@DisplayName("관리자 수동 생성은 지정 UTC 기간의 MANUAL PENDING job을 만든다")
fun shouldCreateManualPendingJobForRequestedPeriod() {
val refreshService = Mockito.mock(CreatorRankingSnapshotRefreshService::class.java)
val jobPort = FakeCreatorRankingSnapshotJobPort()
val service = CreatorRankingSnapshotJobService(refreshService, jobPort, unusedRedissonClient(), transactionManager())
val startAt = LocalDateTime.of(2026, 5, 31, 15, 0)
val endAt = LocalDateTime.of(2026, 6, 7, 15, 0)
val job = service.createManualJob(startAt, endAt)
assertEquals(startAt, job.aggregationStartAtUtc)
assertEquals(endAt, job.aggregationEndAtUtc)
assertEquals(CreatorRankingType.WEEKLY, job.rankingType)
assertEquals(LocalDateTime.of(2026, 6, 8, 0, 0), job.visibleFromAtUtc)
assertEquals(CreatorRankingSnapshotJobTrigger.MANUAL, job.trigger)
assertEquals(CreatorRankingSnapshotJobStatus.PENDING, job.status)
assertEquals(null, job.lastError)
assertEquals(null, job.processingStartedAt)
assertEquals(null, job.processedAt)
}
@Test
@DisplayName("관리자 목록 조회는 기간과 상태 조건으로 snapshot job을 조회한다")
fun shouldFindJobsByRequestedPeriodAndStatuses() {
val refreshService = Mockito.mock(CreatorRankingSnapshotRefreshService::class.java)
val jobPort = FakeCreatorRankingSnapshotJobPort()
val service = CreatorRankingSnapshotJobService(refreshService, jobPort, unusedRedissonClient(), transactionManager())
val startAt = LocalDateTime.of(2026, 5, 31, 15, 0)
val endAt = LocalDateTime.of(2026, 6, 7, 15, 0)
val failed = jobPort.save(
CreatorRankingSnapshotJobRecord(
rankingType = CreatorRankingType.WEEKLY,
aggregationStartAtUtc = startAt,
aggregationEndAtUtc = endAt,
visibleFromAtUtc = endAt.plusHours(9),
trigger = CreatorRankingSnapshotJobTrigger.MANUAL,
status = CreatorRankingSnapshotJobStatus.FAILED,
lastError = "aggregate failed",
processingStartedAt = null,
processedAt = null
)
)
jobPort.save(
CreatorRankingSnapshotJobRecord(
rankingType = CreatorRankingType.WEEKLY,
aggregationStartAtUtc = startAt,
aggregationEndAtUtc = endAt,
visibleFromAtUtc = endAt.plusHours(9),
trigger = CreatorRankingSnapshotJobTrigger.MANUAL,
status = CreatorRankingSnapshotJobStatus.DONE,
lastError = null,
processingStartedAt = null,
processedAt = null
)
)
val jobs = service.findJobs(
aggregationStartAtUtc = startAt,
aggregationEndAtUtc = endAt,
statuses = listOf(CreatorRankingSnapshotJobStatus.FAILED)
)
assertEquals(listOf(failed.id), jobs.map { it.id })
}
@Test
@DisplayName("관리자 실패 job 재시도는 FAILED job만 PENDING으로 되돌린다")
fun shouldRetryOnlyFailedSnapshotJob() {
val refreshService = Mockito.mock(CreatorRankingSnapshotRefreshService::class.java)
val jobPort = FakeCreatorRankingSnapshotJobPort()
val service = CreatorRankingSnapshotJobService(refreshService, jobPort, unusedRedissonClient(), transactionManager())
val failed = jobPort.save(
CreatorRankingSnapshotJobRecord(
rankingType = CreatorRankingType.WEEKLY,
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 = CreatorRankingSnapshotJobTrigger.MANUAL,
status = CreatorRankingSnapshotJobStatus.FAILED,
lastError = "aggregate failed",
processingStartedAt = LocalDateTime.of(2026, 6, 8, 7, 30),
processedAt = LocalDateTime.of(2026, 6, 8, 7, 31)
)
)
val pending = jobPort.save(
CreatorRankingSnapshotJobRecord(
rankingType = CreatorRankingType.WEEKLY,
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 = CreatorRankingSnapshotJobTrigger.MANUAL,
status = CreatorRankingSnapshotJobStatus.PENDING,
lastError = "keep",
processingStartedAt = null,
processedAt = null
)
)
service.retryFailedJob(failed.id!!)
service.retryFailedJob(pending.id!!)
service.retryFailedJob(999L)
val retried = jobPort.findById(failed.id!!)!!
val unchanged = jobPort.findById(pending.id!!)!!
assertEquals(CreatorRankingSnapshotJobStatus.PENDING, retried.status)
assertEquals(null, retried.lastError)
assertEquals(null, retried.processingStartedAt)
assertEquals(null, retried.processedAt)
assertEquals(CreatorRankingSnapshotJobStatus.PENDING, unchanged.status)
assertEquals("keep", unchanged.lastError)
}
@Test
@DisplayName("스케줄 job 상태 변경은 job id와 상태를 로그로 남긴다")
fun shouldLogScheduledJobStatusTransitions(output: CapturedOutput) {
val refreshService = Mockito.mock(CreatorRankingSnapshotRefreshService::class.java)
val jobPort = FakeCreatorRankingSnapshotJobPort()
val now = ZonedDateTime.of(2026, 6, 8, 7, 30, 0, 0, ZoneId.of("Asia/Seoul"))
val redissonClient = periodLockRedissonClient(lockAcquired = true)
val service = CreatorRankingSnapshotJobService(refreshService, jobPort, redissonClient, transactionManager()) { now }
service.refreshLastCompletedWeekByScheduledJob()
assertTrue(output.out.contains("event=creator_ranking_snapshot_job_status_changed"))
assertTrue(output.out.contains("jobId=1"))
assertTrue(output.out.contains("trigger=SCHEDULED"))
assertTrue(output.out.contains("status=PROCESSING"))
assertTrue(output.out.contains("status=DONE"))
}
@Test
@DisplayName("실패 job 상태 변경은 실패 상태와 사유를 로그로 남긴다")
fun shouldLogFailedScheduledJobStatusTransition(output: CapturedOutput) {
val refreshService = Mockito.mock(CreatorRankingSnapshotRefreshService::class.java)
val jobPort = FakeCreatorRankingSnapshotJobPort()
val now = ZonedDateTime.of(2026, 6, 8, 7, 30, 0, 0, ZoneId.of("Asia/Seoul"))
val redissonClient = periodLockRedissonClient(lockAcquired = true)
val service = CreatorRankingSnapshotJobService(refreshService, jobPort, redissonClient, transactionManager()) { now }
Mockito.doThrow(IllegalStateException("aggregate failed"))
.`when`(refreshService).refreshLastCompletedWeek(now)
assertThrows(IllegalStateException::class.java) {
service.refreshLastCompletedWeekByScheduledJob()
}
assertTrue(output.out.contains("event=creator_ranking_snapshot_job_status_changed"))
assertTrue(output.out.contains("jobId=1"))
assertTrue(output.out.contains("trigger=SCHEDULED"))
assertTrue(output.out.contains("status=FAILED"))
assertTrue(output.out.contains("error=aggregate failed"))
}
@Test
@DisplayName("스케줄 job refresh는 cold-start와 같은 기간 기반 lock 경계를 사용한다")
fun shouldUseSamePeriodLockForScheduledJobRefresh() {
val refreshService = Mockito.mock(CreatorRankingSnapshotRefreshService::class.java)
val jobPort = FakeCreatorRankingSnapshotJobPort()
val redissonClient = periodLockRedissonClient(lockAcquired = true)
val now = ZonedDateTime.of(2026, 6, 8, 7, 30, 0, 0, ZoneId.of("Asia/Seoul"))
val lockName = "lock:creator-ranking-snapshot-refresh:2026-05-31T15:00:2026-06-07T15:00"
val service = CreatorRankingSnapshotJobService(refreshService, jobPort, redissonClient, transactionManager()) { now }
service.refreshLastCompletedWeekByScheduledJob()
Mockito.verify(redissonClient).getLock(lockName)
Mockito.verify(refreshService).refreshLastCompletedWeek(now)
assertEquals(CreatorRankingSnapshotJobStatus.DONE, jobPort.jobs.single().status) assertEquals(CreatorRankingSnapshotJobStatus.DONE, jobPort.jobs.single().status)
Mockito.verify(refreshService).refreshLastCompletedWeek(ZonedDateTime.of(2026, 6, 8, 9, 0, 0, 0, ZoneId.of("Asia/Seoul")))
} }
@Test @Test
@DisplayName("스케줄 job refresh는 기간 기반 lock 획득 실패 시 job 생성과 refresh를 건너뛴다") @DisplayName("fallback job이 같은 기간에 3회 이상이면 refresh와 job 생성을 건너뛴다")
fun shouldSkipScheduledJobRefreshWhenPeriodLockNotAcquired() { fun shouldSkipFallbackWhenFallbackLimitReached() {
val refreshService = Mockito.mock(CreatorRankingSnapshotRefreshService::class.java) val refreshService = Mockito.mock(CreatorRankingSnapshotRefreshService::class.java)
val jobPort = FakeCreatorRankingSnapshotJobPort() val jobPort = FakeCreatorRankingSnapshotJobPort()
val redissonClient = periodLockRedissonClient(lockAcquired = false) jobPort.fallbackCount = 3
val now = ZonedDateTime.of(2026, 6, 8, 7, 30, 0, 0, ZoneId.of("Asia/Seoul")) val service = service(refreshService, jobPort)
val service = CreatorRankingSnapshotJobService(refreshService, jobPort, redissonClient, transactionManager()) { now }
service.refreshLastCompletedWeekByScheduledJob() val refreshed = service.refreshLastCompletedWeekByFallback()
assertFalse(refreshed)
assertTrue(jobPort.jobs.isEmpty()) assertTrue(jobPort.jobs.isEmpty())
Mockito.verify(refreshService, Mockito.never()).refreshLastCompletedWeek(now) Mockito.verifyNoInteractions(refreshService)
} }
@Test @Test
@DisplayName("기간 기반 lock은 스냅샷 refresh transaction commit 이후 해제한다") @DisplayName("fallback refresh 실패는 job을 FAILED로 남기고 예외를 전파한다")
fun shouldUnlockPeriodLockAfterRefreshTransactionCommit() { fun shouldMarkFailedWhenFallbackRefreshFails() {
val refreshService = Mockito.mock(CreatorRankingSnapshotRefreshService::class.java) val refreshService = Mockito.mock(CreatorRankingSnapshotRefreshService::class.java)
val jobPort = FakeCreatorRankingSnapshotJobPort() val jobPort = FakeCreatorRankingSnapshotJobPort()
val redissonClient = Mockito.mock(RedissonClient::class.java) Mockito.doThrow(IllegalStateException("refresh failed")).`when`(refreshService).refreshLastCompletedWeek(
val lock = Mockito.mock(RLock::class.java) anyZonedDateTime()
val transactionManager = Mockito.mock(PlatformTransactionManager::class.java) )
val transactionStatus = SimpleTransactionStatus() val service = service(refreshService, jobPort)
val now = ZonedDateTime.of(2026, 6, 8, 7, 30, 0, 0, ZoneId.of("Asia/Seoul"))
val lockName = "lock:creator-ranking-snapshot-refresh:2026-05-31T15:00:2026-06-07T15:00"
Mockito.`when`(redissonClient.getLock(lockName)).thenReturn(lock)
Mockito.`when`(lock.tryLock(0, -1, TimeUnit.SECONDS)).thenReturn(true)
Mockito.`when`(lock.isHeldByCurrentThread).thenReturn(true)
Mockito.`when`(transactionManager.getTransaction(Mockito.any(TransactionDefinition::class.java)))
.thenReturn(transactionStatus)
val service = CreatorRankingSnapshotJobService(
refreshService,
jobPort,
redissonClient,
transactionManager
) { now }
service.refreshLastCompletedWeekByScheduledJob() val exception = assertThrows(IllegalStateException::class.java) { service.refreshLastCompletedWeekByFallback() }
val inOrder = Mockito.inOrder(transactionManager, lock) assertEquals("refresh failed", exception.message)
inOrder.verify(transactionManager).commit(transactionStatus)
inOrder.verify(lock).unlock()
}
@Test
@DisplayName("스케줄 refresh 실패 시 rollback 이후 별도 transaction으로 FAILED 상태를 커밋한다")
fun shouldCommitFailedStatusAfterRefreshTransactionRollback() {
val refreshService = Mockito.mock(CreatorRankingSnapshotRefreshService::class.java)
val jobPort = FakeCreatorRankingSnapshotJobPort()
val redissonClient = periodLockRedissonClient(lockAcquired = true)
val transactionManager = Mockito.mock(PlatformTransactionManager::class.java)
val saveStatus = SimpleTransactionStatus()
val processingStatus = SimpleTransactionStatus()
val refreshStatus = SimpleTransactionStatus()
val failedStatus = SimpleTransactionStatus()
val now = ZonedDateTime.of(2026, 6, 8, 1, 0, 0, 0, ZoneId.of("Asia/Seoul"))
Mockito.`when`(transactionManager.getTransaction(Mockito.any(TransactionDefinition::class.java)))
.thenReturn(saveStatus, processingStatus, refreshStatus, failedStatus)
Mockito.doThrow(IllegalStateException("aggregate failed"))
.`when`(refreshService).refreshLastCompletedWeek(now)
val service = CreatorRankingSnapshotJobService(
refreshService,
jobPort,
redissonClient,
transactionManager
) { now }
val exception = assertThrows(IllegalStateException::class.java) {
service.refreshLastCompletedWeekByScheduledJob()
}
assertEquals("aggregate failed", exception.message)
assertEquals(CreatorRankingSnapshotJobStatus.FAILED, jobPort.jobs.single().status) assertEquals(CreatorRankingSnapshotJobStatus.FAILED, jobPort.jobs.single().status)
assertEquals("aggregate failed", jobPort.jobs.single().lastError) assertEquals("refresh failed", jobPort.jobs.single().lastError)
val inOrder = Mockito.inOrder(transactionManager)
inOrder.verify(transactionManager).commit(saveStatus)
inOrder.verify(transactionManager).commit(processingStatus)
inOrder.verify(transactionManager).rollback(refreshStatus)
inOrder.verify(transactionManager).commit(failedStatus)
} }
@Test @Test
@DisplayName("cold-start 스냅샷 생성은 기간 기반 lock 획득 시에만 refresh를 실행한다") @DisplayName("period lock 획득 실패는 fallback을 정상 skip한다")
fun shouldRefreshColdStartSnapshotOnlyWhenPeriodLockAcquired() { fun shouldSkipFallbackWhenLockNotAcquired() {
val refreshService = Mockito.mock(CreatorRankingSnapshotRefreshService::class.java) val refreshService = Mockito.mock(CreatorRankingSnapshotRefreshService::class.java)
val jobPort = FakeCreatorRankingSnapshotJobPort() val jobPort = FakeCreatorRankingSnapshotJobPort()
val redissonClient = Mockito.mock(RedissonClient::class.java)
val lock = Mockito.mock(RLock::class.java) val lock = Mockito.mock(RLock::class.java)
val now = ZonedDateTime.of(2026, 6, 8, 7, 30, 0, 0, ZoneId.of("Asia/Seoul")) Mockito.`when`(lock.tryLock(0, -1, TimeUnit.SECONDS)).thenReturn(false)
val lockName = "lock:creator-ranking-snapshot-refresh:2026-05-31T15:00:2026-06-07T15:00" val redissonClient = Mockito.mock(RedissonClient::class.java)
Mockito.`when`(redissonClient.getLock(lockName)).thenReturn(lock) Mockito.`when`(redissonClient.getLock(Mockito.anyString())).thenReturn(lock)
val service = service(refreshService, jobPort, redissonClient)
val refreshed = service.refreshLastCompletedWeekByFallback()
assertFalse(refreshed)
assertTrue(jobPort.jobs.isEmpty())
Mockito.verifyNoInteractions(refreshService)
}
private fun service(
refreshService: CreatorRankingSnapshotRefreshService,
jobPort: CreatorRankingSnapshotJobPort,
redissonClient: RedissonClient = lockedRedisson(),
now: ZonedDateTime = ZonedDateTime.of(2026, 6, 8, 9, 0, 0, 0, ZoneId.of("Asia/Seoul"))
) = CreatorRankingSnapshotJobService(refreshService, jobPort, redissonClient, ImmediateTransactionManager(), { now })
private fun anyZonedDateTime(): ZonedDateTime {
return Mockito.any(ZonedDateTime::class.java) ?: ZonedDateTime.now()
}
private fun lockedRedisson(): RedissonClient {
val lock = Mockito.mock(RLock::class.java)
Mockito.`when`(lock.tryLock(0, -1, TimeUnit.SECONDS)).thenReturn(true) Mockito.`when`(lock.tryLock(0, -1, TimeUnit.SECONDS)).thenReturn(true)
Mockito.`when`(lock.isHeldByCurrentThread).thenReturn(true) Mockito.`when`(lock.isHeldByCurrentThread).thenReturn(true)
val service = CreatorRankingSnapshotJobService(refreshService, jobPort, redissonClient, transactionManager()) { now }
service.ensureLastCompletedWeekSnapshotForColdStart()
Mockito.verify(redissonClient).getLock(lockName)
Mockito.verify(lock).tryLock(0, -1, TimeUnit.SECONDS)
Mockito.verify(refreshService).refreshLastCompletedWeek(now)
Mockito.verify(lock).unlock()
}
@Test
@DisplayName("cold-start 스냅샷 생성은 기간 기반 lock 획득 실패 시 refresh를 실행하지 않는다")
fun shouldSkipColdStartSnapshotRefreshWhenPeriodLockNotAcquired() {
val refreshService = Mockito.mock(CreatorRankingSnapshotRefreshService::class.java)
val jobPort = FakeCreatorRankingSnapshotJobPort()
val redissonClient = Mockito.mock(RedissonClient::class.java) val redissonClient = Mockito.mock(RedissonClient::class.java)
val lock = Mockito.mock(RLock::class.java) Mockito.`when`(redissonClient.getLock(Mockito.anyString())).thenReturn(lock)
val now = ZonedDateTime.of(2026, 6, 8, 7, 30, 0, 0, ZoneId.of("Asia/Seoul"))
val lockName = "lock:creator-ranking-snapshot-refresh:2026-05-31T15:00:2026-06-07T15:00"
Mockito.`when`(redissonClient.getLock(lockName)).thenReturn(lock)
Mockito.`when`(lock.tryLock(0, -1, TimeUnit.SECONDS)).thenReturn(false)
Mockito.`when`(lock.isHeldByCurrentThread).thenReturn(false)
val service = CreatorRankingSnapshotJobService(refreshService, jobPort, redissonClient, transactionManager()) { now }
service.ensureLastCompletedWeekSnapshotForColdStart()
Mockito.verify(redissonClient).getLock(lockName)
Mockito.verify(lock).tryLock(0, -1, TimeUnit.SECONDS)
Mockito.verify(refreshService, Mockito.never()).refreshLastCompletedWeek(now)
Mockito.verify(lock, Mockito.never()).unlock()
}
}
private fun unusedRedissonClient(): RedissonClient = Mockito.mock(RedissonClient::class.java)
private fun transactionManager(): PlatformTransactionManager {
val transactionManager = Mockito.mock(PlatformTransactionManager::class.java)
Mockito.`when`(transactionManager.getTransaction(Mockito.any(TransactionDefinition::class.java)))
.thenReturn(SimpleTransactionStatus())
return transactionManager
}
private fun periodLockRedissonClient(lockAcquired: Boolean): RedissonClient {
val redissonClient = Mockito.mock(RedissonClient::class.java)
val lock = Mockito.mock(RLock::class.java)
val lockName = "lock:creator-ranking-snapshot-refresh:2026-05-31T15:00:2026-06-07T15:00"
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 return redissonClient
} }
}
private class ImmediateTransactionManager : PlatformTransactionManager {
override fun getTransaction(definition: TransactionDefinition?): TransactionStatus = SimpleTransactionStatus()
override fun commit(status: TransactionStatus) = Unit
override fun rollback(status: TransactionStatus) = Unit
}
private class FakeCreatorRankingSnapshotJobPort : CreatorRankingSnapshotJobPort { private class FakeCreatorRankingSnapshotJobPort : CreatorRankingSnapshotJobPort {
val jobs = mutableListOf<CreatorRankingSnapshotJobRecord>() val jobs = mutableListOf<CreatorRankingSnapshotJobRecord>()
var fallbackCount = 0L
private var nextId = 1L private var nextId = 1L
override fun save(job: CreatorRankingSnapshotJobRecord): CreatorRankingSnapshotJobRecord { override fun save(job: CreatorRankingSnapshotJobRecord): CreatorRankingSnapshotJobRecord {
val saved = job.copy(id = job.id ?: nextId++) val saved = job.copy(id = nextId++)
jobs.add(saved) jobs.add(saved)
return saved return saved
} }
override fun findById(jobId: Long): CreatorRankingSnapshotJobRecord? { override fun findById(jobId: Long) = jobs.firstOrNull { it.id == jobId }
return jobs.firstOrNull { it.id == jobId }
}
override fun findByPeriodAndStatuses( override fun findByPeriodAndStatuses(
aggregationStartAtUtc: LocalDateTime, aggregationStartAtUtc: LocalDateTime,
aggregationEndAtUtc: LocalDateTime, aggregationEndAtUtc: LocalDateTime,
statuses: List<CreatorRankingSnapshotJobStatus> statuses: List<CreatorRankingSnapshotJobStatus>
): List<CreatorRankingSnapshotJobRecord> { ) = jobs.filter { it.status in statuses }
return jobs.filter {
it.aggregationStartAtUtc == aggregationStartAtUtc &&
it.aggregationEndAtUtc == aggregationEndAtUtc &&
it.status in statuses
}
}
override fun markProcessing(jobId: Long, processingStartedAt: LocalDateTime): CreatorRankingSnapshotJobRecord? { override fun countByRankingTypeAndPeriodAndTrigger(
return update(jobId) { rankingType: CreatorRankingType,
aggregationStartAtUtc: LocalDateTime,
aggregationEndAtUtc: LocalDateTime,
trigger: CreatorRankingSnapshotJobTrigger
) = fallbackCount
override fun markProcessing(jobId: Long, processingStartedAt: LocalDateTime) = update(jobId) {
it.copy( it.copy(
status = CreatorRankingSnapshotJobStatus.PROCESSING, status = CreatorRankingSnapshotJobStatus.PROCESSING,
processingStartedAt = processingStartedAt processingStartedAt = processingStartedAt
) )
} }
}
override fun markDone(jobId: Long, processedAt: LocalDateTime): CreatorRankingSnapshotJobRecord? { override fun markDone(jobId: Long, processedAt: LocalDateTime) = update(jobId) {
return update(jobId) {
it.copy( it.copy(
status = CreatorRankingSnapshotJobStatus.DONE, status = CreatorRankingSnapshotJobStatus.DONE,
processedAt = processedAt, processedAt = processedAt
lastError = null
) )
} }
}
override fun markFailed(jobId: Long, processedAt: LocalDateTime, lastError: String?): CreatorRankingSnapshotJobRecord? { override fun markFailed(
return update(jobId) { jobId: Long,
processedAt: LocalDateTime,
lastError: String?
) = update(jobId) {
it.copy( it.copy(
status = CreatorRankingSnapshotJobStatus.FAILED, status = CreatorRankingSnapshotJobStatus.FAILED,
processedAt = processedAt, processedAt = processedAt,
lastError = lastError lastError = lastError
) )
} }
}
override fun markPending(jobId: Long): CreatorRankingSnapshotJobRecord? { override fun markPending(jobId: Long) = update(jobId) {
return update(jobId) {
it.copy( it.copy(
status = CreatorRankingSnapshotJobStatus.PENDING, status = CreatorRankingSnapshotJobStatus.PENDING,
lastError = null, lastError = null,
processingStartedAt = null, processedAt = null,
processedAt = null processingStartedAt = null
) )
} }
}
private fun update( private fun update(
jobId: Long, jobId: Long,
updater: (CreatorRankingSnapshotJobRecord) -> CreatorRankingSnapshotJobRecord mapper: (CreatorRankingSnapshotJobRecord) -> CreatorRankingSnapshotJobRecord
): CreatorRankingSnapshotJobRecord? { ): CreatorRankingSnapshotJobRecord? {
val index = jobs.indexOfFirst { it.id == jobId } val index = jobs.indexOfFirst { it.id == jobId }
if (index < 0) return null if (index < 0) return null
val updated = updater(jobs[index]) jobs[index] = mapper(jobs[index])
jobs[index] = updated return jobs[index]
return updated
} }
} }

View File

@@ -1,6 +1,7 @@
package kr.co.vividnext.sodalive.v2.ranking.application package kr.co.vividnext.sodalive.v2.ranking.application
import kr.co.vividnext.sodalive.v2.home.following.application.HomeFollowingNewsPublishService import kr.co.vividnext.sodalive.v2.home.following.application.HomeFollowingNewsPublishService
import kr.co.vividnext.sodalive.v2.ranking.domain.CreatorRankingScoreSpec
import kr.co.vividnext.sodalive.v2.ranking.domain.CreatorRankingSnapshotCandidate import kr.co.vividnext.sodalive.v2.ranking.domain.CreatorRankingSnapshotCandidate
import kr.co.vividnext.sodalive.v2.ranking.domain.CreatorRankingType import kr.co.vividnext.sodalive.v2.ranking.domain.CreatorRankingType
import kr.co.vividnext.sodalive.v2.ranking.port.out.CreatorRankingAggregationPort import kr.co.vividnext.sodalive.v2.ranking.port.out.CreatorRankingAggregationPort
@@ -8,335 +9,97 @@ import kr.co.vividnext.sodalive.v2.ranking.port.out.CreatorRankingAggregationRes
import kr.co.vividnext.sodalive.v2.ranking.port.out.CreatorRankingSnapshotPort import kr.co.vividnext.sodalive.v2.ranking.port.out.CreatorRankingSnapshotPort
import kr.co.vividnext.sodalive.v2.ranking.port.out.CreatorRankingSnapshotRecord import kr.co.vividnext.sodalive.v2.ranking.port.out.CreatorRankingSnapshotRecord
import org.junit.jupiter.api.Assertions.assertEquals 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.DisplayName import org.junit.jupiter.api.DisplayName
import org.junit.jupiter.api.Test import org.junit.jupiter.api.Test
import org.junit.jupiter.api.extension.ExtendWith
import org.mockito.Mockito import org.mockito.Mockito
import org.springframework.boot.test.system.CapturedOutput
import org.springframework.boot.test.system.OutputCaptureExtension
import org.springframework.transaction.support.TransactionSynchronizationManager
import java.time.LocalDateTime import java.time.LocalDateTime
import java.time.ZoneId import java.time.ZoneId
import java.time.ZonedDateTime import java.time.ZonedDateTime
@ExtendWith(OutputCaptureExtension::class)
class CreatorRankingSnapshotRefreshServiceTest { class CreatorRankingSnapshotRefreshServiceTest {
@Test @Test
@DisplayName("주간 스냅샷 생성은 KST 지난 주를 UTC 조회 기간으로 변환하고 raw 지표 점수를 다시 계산해 저장한다") @DisplayName("주간 스냅샷 생성은 SQL rank 의미의 V2 점수로 정렬하고 rankNo와 score detail을 저장한다")
fun shouldRefreshLastCompletedWeekWithUtcRangeAndCalculatedScores() { fun shouldRefreshWithRankBasedV2Score() {
val aggregationPort = FakeCreatorRankingAggregationPort() val aggregationPort = FakeCreatorRankingAggregationPort()
val snapshotPort = FakeCreatorRankingSnapshotPort() val snapshotPort = FakeCreatorRankingSnapshotPort()
val service = service(aggregationPort = aggregationPort, snapshotPort = snapshotPort) val service = service(aggregationPort, snapshotPort)
val now = ZonedDateTime.of(2026, 6, 8, 6, 0, 0, 0, ZoneId.of("Asia/Seoul"))
aggregationPort.candidates = listOf( aggregationPort.candidates = listOf(
candidate( candidate(1, debut = LocalDateTime.of(2026, 6, 1, 0, 0), live = 100, content = 10, follow = -3, ai = 0),
creatorId = 1L, candidate(2, debut = LocalDateTime.of(2026, 6, 2, 0, 0), live = 100, content = 0, follow = 5, ai = 10),
finalScore = 1.0, candidate(3, debut = null, live = 0, content = 50, follow = 0, ai = 0)
liveCanAmount = 100,
contentPurchaseCanAmount = 50,
contentLikeCount = 10,
contentCommentCount = 4,
channelDonationCanAmount = 30,
channelDonationCount = 6,
fanTalkCount = 3,
finalFollowerCount = 20,
followIncrease = -2
)
) )
service.refreshLastCompletedWeek(now) service.refreshLastCompletedWeek(ZonedDateTime.of(2026, 6, 8, 6, 0, 0, 0, ZoneId.of("Asia/Seoul")))
val stored = snapshotPort.snapshots.single() assertEquals(LocalDateTime.of(2026, 5, 31, 15, 0), aggregationPort.startInclusiveUtc)
assertEquals(LocalDateTime.of(2026, 5, 31, 15, 0, 0), aggregationPort.startInclusiveUtc) assertEquals(LocalDateTime.of(2026, 6, 7, 15, 0), aggregationPort.endExclusiveUtc)
assertEquals(LocalDateTime.of(2026, 6, 7, 15, 0, 0), aggregationPort.endExclusiveUtc)
assertEquals(aggregationPort.startInclusiveUtc, snapshotPort.aggregationStartAtUtc)
assertEquals(aggregationPort.endExclusiveUtc, snapshotPort.aggregationEndAtUtc)
assertEquals(CreatorRankingType.WEEKLY, snapshotPort.rankingType)
assertEquals(LocalDateTime.of(2026, 6, 8, 0, 0), snapshotPort.visibleFromAtUtc) assertEquals(LocalDateTime.of(2026, 6, 8, 0, 0), snapshotPort.visibleFromAtUtc)
assertEquals(85.0, stored.contentLiveScore, 0.0001) assertEquals(listOf(2L, 1L, 3L), snapshotPort.snapshots.map { it.creatorId })
assertEquals(7.0, stored.engagementScore, 0.0001) assertEquals(listOf(1, 2, 3), snapshotPort.snapshots.map { it.rankNo })
assertEquals(19.8, stored.supportScore, 0.0001) assertEquals(CreatorRankingScoreSpec.SCORE_POLICY_VERSION, snapshotPort.snapshots.first().scorePolicyVersion)
assertEquals(13.4, stored.fanLoyaltyScore, 0.0001) assertTrue(snapshotPort.snapshots.first().scoreDetailJson.contains("\"aiChat\""))
assertEquals(38.14, stored.finalScore, 0.0001) assertTrue(snapshotPort.snapshots[1].scoreDetailJson.contains("\"followIncrease\":{\"rawValue\":0"))
} }
@Test @Test
@DisplayName("주간 스냅샷 생성은 20위 점수 경계와 동점인 후보를 모두 저장하고 더 낮은 점수는 제외한다") @DisplayName("주간 스냅샷 생성은 최종 점수 0 후보를 제외하고 정확히 상위 20명만 저장한다")
fun shouldStoreAllCandidatesTiedAtTwentiethScoreBoundary() { fun shouldStoreExactlyTopTwentyPositiveScores() {
val aggregationPort = FakeCreatorRankingAggregationPort() val aggregationPort = FakeCreatorRankingAggregationPort()
val snapshotPort = FakeCreatorRankingSnapshotPort() val snapshotPort = FakeCreatorRankingSnapshotPort()
val service = service(aggregationPort = aggregationPort, snapshotPort = snapshotPort) val service = service(aggregationPort, snapshotPort)
aggregationPort.candidates = (1L..19L).map { candidate(creatorId = it, liveCanAmount = 1_000 - it) } + aggregationPort.candidates = (1L..25L).map { candidate(it, live = 1_000 - it) } + candidate(100L)
candidate(creatorId = 20L, liveCanAmount = 500) +
candidate(creatorId = 21L, liveCanAmount = 500) +
candidate(creatorId = 22L, liveCanAmount = 500) +
candidate(creatorId = 23L, liveCanAmount = 499)
service.refreshLastCompletedWeek(ZonedDateTime.of(2026, 6, 8, 6, 0, 0, 0, ZoneId.of("Asia/Seoul"))) service.refreshLastCompletedWeek(ZonedDateTime.of(2026, 6, 8, 6, 0, 0, 0, ZoneId.of("Asia/Seoul")))
assertEquals((1L..22L).toList(), snapshotPort.snapshots.map { it.creatorId }) assertEquals(20, snapshotPort.snapshots.size)
assertEquals((1..20).toList(), snapshotPort.snapshots.map { it.rankNo })
assertEquals((1L..20L).toList(), snapshotPort.snapshots.map { it.creatorId })
} }
@Test @Test
@DisplayName("주간 스냅샷 생성은 같은 집계 기간을 다시 생성할 때 기존 row를 교체한다") @DisplayName("주간 스냅샷 저장 후 홈 팔로잉 최근 소식 발행 순위는 rankNo를 사용한다")
fun shouldReplaceSnapshotsForSameAggregationPeriod() { fun shouldPublishFollowingNewsWithRankNo() {
val aggregationPort = FakeCreatorRankingAggregationPort()
val snapshotPort = FakeCreatorRankingSnapshotPort()
val service = service(aggregationPort = aggregationPort, snapshotPort = snapshotPort)
val now = ZonedDateTime.of(2026, 6, 8, 6, 0, 0, 0, ZoneId.of("Asia/Seoul"))
aggregationPort.candidates = listOf(candidate(creatorId = 1L, liveCanAmount = 100))
service.refreshLastCompletedWeek(now)
aggregationPort.candidates = listOf(candidate(creatorId = 2L, liveCanAmount = 200))
service.refreshLastCompletedWeek(now)
assertEquals(listOf(2L), snapshotPort.snapshots.map { it.creatorId })
}
@Test
@DisplayName("주간 스냅샷 생성 성공은 집계 기간과 후보/저장 수를 로그로 남긴다")
fun shouldLogSnapshotRefreshSuccessWithPeriodAndCounts(output: CapturedOutput) {
val aggregationPort = FakeCreatorRankingAggregationPort()
val snapshotPort = FakeCreatorRankingSnapshotPort()
val service = service(aggregationPort = aggregationPort, snapshotPort = snapshotPort)
aggregationPort.candidates = listOf(
candidate(creatorId = 1L, liveCanAmount = 100),
candidate(creatorId = 2L, liveCanAmount = 50)
)
service.refreshLastCompletedWeek(ZonedDateTime.of(2026, 6, 8, 6, 0, 0, 0, ZoneId.of("Asia/Seoul")))
assertEquals(true, output.out.contains("event=creator_ranking_snapshot_refresh_success"))
assertEquals(true, output.out.contains("aggregationStartAtUtc=2026-05-31T15:00"))
assertEquals(true, output.out.contains("aggregationEndAtUtc=2026-06-07T15:00"))
assertEquals(true, output.out.contains("candidateCount=2"))
assertEquals(true, output.out.contains("storedCount=2"))
assertEquals(true, output.out.contains("lowScoreExcludedCount=0"))
}
@Test
@DisplayName("주간 스냅샷 생성 성공 로그는 트랜잭션 커밋 후 기록한다")
fun shouldLogSnapshotRefreshSuccessAfterTransactionCommit(output: CapturedOutput) {
val aggregationPort = FakeCreatorRankingAggregationPort()
val service = service(aggregationPort = aggregationPort)
aggregationPort.candidates = listOf(candidate(creatorId = 1L, liveCanAmount = 100))
TransactionSynchronizationManager.initSynchronization()
try {
service.refreshLastCompletedWeek(ZonedDateTime.of(2026, 6, 8, 6, 0, 0, 0, ZoneId.of("Asia/Seoul")))
assertEquals(false, output.out.contains("event=creator_ranking_snapshot_refresh_success"))
TransactionSynchronizationManager.getSynchronizations().forEach { it.afterCommit() }
} finally {
TransactionSynchronizationManager.clearSynchronization()
}
assertEquals(true, output.out.contains("event=creator_ranking_snapshot_refresh_success"))
}
@Test
@DisplayName("주간 스냅샷 생성 성공은 최종 점수 1점 미만 제외 수를 로그로 남긴다")
fun shouldLogLowScoreExcludedCount(output: CapturedOutput) {
val aggregationPort = FakeCreatorRankingAggregationPort()
val service = service(aggregationPort = aggregationPort)
aggregationPort.candidates = listOf(candidate(creatorId = 1L, liveCanAmount = 100))
aggregationPort.lowScoreExcludedCount = 2
service.refreshLastCompletedWeek(ZonedDateTime.of(2026, 6, 8, 6, 0, 0, 0, ZoneId.of("Asia/Seoul")))
assertEquals(true, output.out.contains("candidateCount=1"))
assertEquals(true, output.out.contains("lowScoreExcludedCount=2"))
}
@Test
@DisplayName("주간 스냅샷 생성 실패는 집계 기간과 에러를 로그로 남기고 예외를 전파한다")
fun shouldLogSnapshotRefreshFailureWithPeriodAndError(output: CapturedOutput) {
val aggregationPort = FakeCreatorRankingAggregationPort()
val service = service(aggregationPort = aggregationPort)
aggregationPort.failure = IllegalStateException("aggregate failed")
val exception = assertThrows(IllegalStateException::class.java) {
service.refreshLastCompletedWeek(ZonedDateTime.of(2026, 6, 8, 6, 0, 0, 0, ZoneId.of("Asia/Seoul")))
}
assertEquals("aggregate failed", exception.message)
assertEquals(true, output.out.contains("event=creator_ranking_snapshot_refresh_failure"))
assertEquals(true, output.out.contains("aggregationStartAtUtc=2026-05-31T15:00"))
assertEquals(true, output.out.contains("aggregationEndAtUtc=2026-06-07T15:00"))
assertEquals(true, output.out.contains("error=aggregate failed"))
}
@Test
@DisplayName("주간 스냅샷 저장 성공 후 크리에이터 랭킹 최근 소식을 순위와 함께 발행한다")
fun shouldPublishCreatorRankingNewsAfterSnapshotsAreReplaced() {
val aggregationPort = FakeCreatorRankingAggregationPort() val aggregationPort = FakeCreatorRankingAggregationPort()
val snapshotPort = FakeCreatorRankingSnapshotPort() val snapshotPort = FakeCreatorRankingSnapshotPort()
val publishService = Mockito.mock(HomeFollowingNewsPublishService::class.java) val publishService = Mockito.mock(HomeFollowingNewsPublishService::class.java)
val service = service( val service = service(aggregationPort, snapshotPort, publishService)
aggregationPort = aggregationPort, aggregationPort.candidates = listOf(candidate(1, live = 10), candidate(2, live = 20))
snapshotPort = snapshotPort,
publishService = publishService
)
aggregationPort.candidates = listOf(
candidate(creatorId = 1L, liveCanAmount = 200),
candidate(creatorId = 2L, liveCanAmount = 100)
)
service.refreshLastCompletedWeek(ZonedDateTime.of(2026, 6, 8, 6, 0, 0, 0, ZoneId.of("Asia/Seoul"))) service.refreshLastCompletedWeek(ZonedDateTime.of(2026, 6, 8, 6, 0, 0, 0, ZoneId.of("Asia/Seoul")))
Mockito.verify(publishService).publishCreatorRankingVisible( Mockito.verify(publishService).publishCreatorRankingVisible(
creatorId = 1L, creatorId = 2L,
creatorNickname = "creator-1", creatorNickname = "creator-2",
creatorProfileImagePath = "profile-1.png", creatorProfileImagePath = "profile-2.png",
aggregationStartAtUtc = LocalDateTime.of(2026, 5, 31, 15, 0), aggregationStartAtUtc = LocalDateTime.of(2026, 5, 31, 15, 0),
visibleFromAtUtc = LocalDateTime.of(2026, 6, 8, 0, 0), visibleFromAtUtc = LocalDateTime.of(2026, 6, 8, 0, 0),
rank = 1 rank = 1
) )
Mockito.verify(publishService).publishCreatorRankingVisible(
creatorId = 2L,
creatorNickname = "creator-2",
creatorProfileImagePath = "profile-2.png",
aggregationStartAtUtc = LocalDateTime.of(2026, 5, 31, 15, 0),
visibleFromAtUtc = LocalDateTime.of(2026, 6, 8, 0, 0),
rank = 2
)
}
@Test
@DisplayName("주간 스냅샷 저장 실패 시 크리에이터 랭킹 최근 소식을 발행하지 않는다")
fun shouldNotPublishCreatorRankingNewsWhenReplaceSnapshotsFails() {
val aggregationPort = FakeCreatorRankingAggregationPort()
val snapshotPort = FakeCreatorRankingSnapshotPort()
val publishService = Mockito.mock(HomeFollowingNewsPublishService::class.java)
val service = service(
aggregationPort = aggregationPort,
snapshotPort = snapshotPort,
publishService = publishService
)
aggregationPort.candidates = listOf(candidate(creatorId = 1L, liveCanAmount = 100))
snapshotPort.failure = IllegalStateException("replace failed")
assertThrows(IllegalStateException::class.java) {
service.refreshLastCompletedWeek(ZonedDateTime.of(2026, 6, 8, 6, 0, 0, 0, ZoneId.of("Asia/Seoul")))
}
Mockito.verifyNoInteractions(publishService)
}
@Test
@DisplayName("일부 크리에이터 랭킹 최근 소식 발행 실패는 스냅샷 갱신을 실패시키지 않는다")
fun shouldNotFailSnapshotRefreshWhenCreatorRankingNewsPublishFails() {
val aggregationPort = FakeCreatorRankingAggregationPort()
val snapshotPort = FakeCreatorRankingSnapshotPort()
val publishService = Mockito.mock(HomeFollowingNewsPublishService::class.java)
val service = service(
aggregationPort = aggregationPort,
snapshotPort = snapshotPort,
publishService = publishService
)
aggregationPort.candidates = listOf(
candidate(creatorId = 1L, liveCanAmount = 200),
candidate(creatorId = 2L, liveCanAmount = 100)
)
Mockito.doAnswer { invocation ->
if (invocation.getArgument<Long>(0) == 1L) {
throw IllegalStateException("publish failed")
}
0
}.`when`(publishService)
.publishCreatorRankingVisible(
creatorId = Mockito.anyLong(),
creatorNickname = anyStringValue(),
creatorProfileImagePath = Mockito.anyString(),
aggregationStartAtUtc = anyLocalDateTime(),
visibleFromAtUtc = anyLocalDateTime(),
rank = Mockito.anyInt()
)
service.refreshLastCompletedWeek(ZonedDateTime.of(2026, 6, 8, 6, 0, 0, 0, ZoneId.of("Asia/Seoul")))
Mockito.verify(publishService).publishCreatorRankingVisible(
creatorId = 2L,
creatorNickname = "creator-2",
creatorProfileImagePath = "profile-2.png",
aggregationStartAtUtc = LocalDateTime.of(2026, 5, 31, 15, 0),
visibleFromAtUtc = LocalDateTime.of(2026, 6, 8, 0, 0),
rank = 2
)
} }
private fun service( private fun service(
aggregationPort: CreatorRankingAggregationPort = FakeCreatorRankingAggregationPort(), aggregationPort: CreatorRankingAggregationPort,
snapshotPort: CreatorRankingSnapshotPort = FakeCreatorRankingSnapshotPort(), snapshotPort: CreatorRankingSnapshotPort,
publishService: HomeFollowingNewsPublishService = Mockito.mock(HomeFollowingNewsPublishService::class.java) publishService: HomeFollowingNewsPublishService = Mockito.mock(HomeFollowingNewsPublishService::class.java)
): CreatorRankingSnapshotRefreshService { ) = CreatorRankingSnapshotRefreshService(aggregationPort, snapshotPort, publishService)
return CreatorRankingSnapshotRefreshService(
aggregationPort = aggregationPort,
snapshotPort = snapshotPort,
homeFollowingNewsPublishService = publishService
)
}
private fun anyStringValue(): String {
return Mockito.anyString() ?: ""
}
private fun anyLocalDateTime(): LocalDateTime {
return Mockito.any(LocalDateTime::class.java) ?: LocalDateTime.MIN
}
private fun candidate( private fun candidate(
creatorId: Long, id: Long,
finalScore: Double = 0.0, debut: LocalDateTime? = null,
liveCanAmount: Long = 0, live: Long = 0,
contentPurchaseCanAmount: Long = 0, content: Long = 0,
contentLikeCount: Long = 0, follow: Long = 0,
contentCommentCount: Long = 0, ai: Long = 0
channelDonationCanAmount: Long = 0, ) = CreatorRankingSnapshotCandidate(id, "creator-$id", "profile-$id.png", debut, live, content, follow, ai)
channelDonationCount: Long = 0,
fanTalkCount: Long = 0,
finalFollowerCount: Long = 0,
followIncrease: Long = 0
): CreatorRankingSnapshotCandidate {
return CreatorRankingSnapshotCandidate(
creatorId = creatorId,
nickname = "creator-$creatorId",
profileImageUrl = "profile-$creatorId.png",
finalScore = finalScore,
contentLiveScore = 0.0,
engagementScore = 0.0,
supportScore = 0.0,
fanLoyaltyScore = 0.0,
liveCanAmount = liveCanAmount,
contentPurchaseCanAmount = contentPurchaseCanAmount,
contentLikeCount = contentLikeCount,
contentCommentCount = contentCommentCount,
channelDonationCanAmount = channelDonationCanAmount,
channelDonationCount = channelDonationCount,
fanTalkCount = fanTalkCount,
finalFollowerCount = finalFollowerCount,
followIncrease = followIncrease
)
}
} }
private class FakeCreatorRankingAggregationPort : CreatorRankingAggregationPort { private class FakeCreatorRankingAggregationPort : CreatorRankingAggregationPort {
var candidates: List<CreatorRankingSnapshotCandidate> = emptyList() var candidates: List<CreatorRankingSnapshotCandidate> = emptyList()
var lowScoreExcludedCount: Int = 0
var failure: RuntimeException? = null
var startInclusiveUtc: LocalDateTime? = null var startInclusiveUtc: LocalDateTime? = null
var endExclusiveUtc: LocalDateTime? = null var endExclusiveUtc: LocalDateTime? = null
override fun aggregateCandidates( override fun aggregateCandidates(startInclusiveUtc: LocalDateTime, endExclusiveUtc: LocalDateTime) = candidates
startInclusiveUtc: LocalDateTime,
endExclusiveUtc: LocalDateTime
): List<CreatorRankingSnapshotCandidate> {
this.startInclusiveUtc = startInclusiveUtc
this.endExclusiveUtc = endExclusiveUtc
failure?.let { throw it }
return candidates
}
override fun aggregateCandidateResult( override fun aggregateCandidateResult(
startInclusiveUtc: LocalDateTime, startInclusiveUtc: LocalDateTime,
@@ -344,48 +107,25 @@ private class FakeCreatorRankingAggregationPort : CreatorRankingAggregationPort
): CreatorRankingAggregationResult { ): CreatorRankingAggregationResult {
this.startInclusiveUtc = startInclusiveUtc this.startInclusiveUtc = startInclusiveUtc
this.endExclusiveUtc = endExclusiveUtc this.endExclusiveUtc = endExclusiveUtc
failure?.let { throw it } return CreatorRankingAggregationResult(candidates, 0)
return CreatorRankingAggregationResult(
candidates = candidates,
lowScoreExcludedCount = lowScoreExcludedCount
)
} }
} }
private class FakeCreatorRankingSnapshotPort : CreatorRankingSnapshotPort { private class FakeCreatorRankingSnapshotPort : CreatorRankingSnapshotPort {
val snapshots = mutableListOf<CreatorRankingSnapshotRecord>() val snapshots = mutableListOf<CreatorRankingSnapshotRecord>()
var rankingType: CreatorRankingType? = null
var aggregationStartAtUtc: LocalDateTime? = null
var aggregationEndAtUtc: LocalDateTime? = null
var visibleFromAtUtc: LocalDateTime? = null var visibleFromAtUtc: LocalDateTime? = null
var failure: RuntimeException? = null
override fun findSnapshotsByAggregationPeriod( override fun findSnapshotsByAggregationPeriod(
aggregationStartAtUtc: LocalDateTime, aggregationStartAtUtc: LocalDateTime,
aggregationEndAtUtc: LocalDateTime aggregationEndAtUtc: LocalDateTime
): List<CreatorRankingSnapshotRecord> { ) = snapshots
return snapshots.filter { override fun findLatestSnapshots() = snapshots
it.aggregationStartAtUtc == aggregationStartAtUtc && it.aggregationEndAtUtc == aggregationEndAtUtc override fun findPreviousCompletedSnapshots() = snapshots
} override fun findLatestVisibleSnapshots(rankingType: CreatorRankingType, nowUtc: LocalDateTime) = snapshots
}
override fun findLatestSnapshots(): List<CreatorRankingSnapshotRecord> = snapshots
override fun findPreviousCompletedSnapshots(): List<CreatorRankingSnapshotRecord> = snapshots
override fun findLatestVisibleSnapshots(
rankingType: CreatorRankingType,
nowUtc: LocalDateTime
): List<CreatorRankingSnapshotRecord> = snapshots
override fun findPreviousVisibleSnapshots( override fun findPreviousVisibleSnapshots(
rankingType: CreatorRankingType, rankingType: CreatorRankingType,
currentAggregationStartAtUtc: LocalDateTime, currentAggregationStartAtUtc: LocalDateTime,
nowUtc: LocalDateTime nowUtc: LocalDateTime
): List<CreatorRankingSnapshotRecord> = snapshots ) = snapshots
override fun isSnapshotTableEmpty(): Boolean = snapshots.isEmpty()
override fun replaceSnapshots( override fun replaceSnapshots(
rankingType: CreatorRankingType, rankingType: CreatorRankingType,
aggregationStartAtUtc: LocalDateTime, aggregationStartAtUtc: LocalDateTime,
@@ -393,14 +133,8 @@ private class FakeCreatorRankingSnapshotPort : CreatorRankingSnapshotPort {
visibleFromAtUtc: LocalDateTime, visibleFromAtUtc: LocalDateTime,
newSnapshots: List<CreatorRankingSnapshotRecord> newSnapshots: List<CreatorRankingSnapshotRecord>
) { ) {
failure?.let { throw it }
this.rankingType = rankingType
this.aggregationStartAtUtc = aggregationStartAtUtc
this.aggregationEndAtUtc = aggregationEndAtUtc
this.visibleFromAtUtc = visibleFromAtUtc this.visibleFromAtUtc = visibleFromAtUtc
snapshots.removeIf { snapshots.clear()
it.aggregationStartAtUtc == aggregationStartAtUtc && it.aggregationEndAtUtc == aggregationEndAtUtc
}
snapshots.addAll(newSnapshots) snapshots.addAll(newSnapshots)
} }
} }

View File

@@ -1,76 +1,81 @@
package kr.co.vividnext.sodalive.v2.ranking.domain package kr.co.vividnext.sodalive.v2.ranking.domain
import com.fasterxml.jackson.databind.JsonNode
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.DisplayName import org.junit.jupiter.api.DisplayName
import org.junit.jupiter.api.Test import org.junit.jupiter.api.Test
import java.time.LocalDateTime
class CreatorRankingScorePolicyTest { class CreatorRankingScorePolicyTest {
private val policy = CreatorRankingScorePolicy() private val policy = CreatorRankingScorePolicy()
@Test @Test
@DisplayName("콘텐츠/라이브 점수는 라이브 계열 캔 70%와 콘텐츠 구매 캔 30%를 raw value로 계산한") @DisplayName("순위 점수는 rank 1/2/20/21 경계와 raw value 0 강제 0점을 따른")
fun shouldCalculateContentLiveScore() { fun shouldCalculateRankScoreBoundaries() {
assertEquals(0.7, CreatorRankingScoreSpec.CONTENT_LIVE_CAN_WEIGHT, 0.0001) assertEquals(100.0, policy.calculateRankScore(rawValue = 10, rank = 1), 0.0001)
assertEquals(0.3, CreatorRankingScoreSpec.CONTENT_PURCHASE_CAN_WEIGHT, 0.0001) assertEquals(95.0, policy.calculateRankScore(rawValue = 10, rank = 2), 0.0001)
assertEquals(5.0, policy.calculateRankScore(rawValue = 10, rank = 20), 0.0001)
val score = policy.calculateContentLiveScore(liveCanAmount = 1000, contentPurchaseCanAmount = 200) assertEquals(0.0, policy.calculateRankScore(rawValue = 10, rank = 21), 0.0001)
assertEquals(0.0, policy.calculateRankScore(rawValue = 0, rank = 1), 0.0001)
assertEquals(760.0, score, 0.0001)
} }
@Test @Test
@DisplayName("참여 반응 점수는 좋아요 수와 댓글 수를 각각 50% raw value로 계산한") @DisplayName("최종 점수는 라이브 60%, 콘텐츠 20%, 팔로우 10%, AI 채팅 10% 가중합이")
fun shouldCalculateEngagementScore() { fun shouldCalculateWeightedFinalScore() {
assertEquals(0.5, CreatorRankingScoreSpec.CONTENT_LIKE_COUNT_WEIGHT, 0.0001) assertEquals(0.6, CreatorRankingScoreSpec.LIVE_REVENUE_WEIGHT, 0.0001)
assertEquals(0.5, CreatorRankingScoreSpec.CONTENT_COMMENT_COUNT_WEIGHT, 0.0001) assertEquals(0.2, CreatorRankingScoreSpec.CONTENT_REVENUE_WEIGHT, 0.0001)
assertEquals(0.1, CreatorRankingScoreSpec.FOLLOW_INCREASE_WEIGHT, 0.0001)
val score = policy.calculateEngagementScore(contentLikeCount = 40, contentCommentCount = 20) assertEquals(0.1, CreatorRankingScoreSpec.AI_CHAT_WEIGHT, 0.0001)
assertEquals(30.0, score, 0.0001)
}
@Test
@DisplayName("응원 점수는 채널 후원 캔/건수와 팬 Talk 수를 raw value로 계산한다")
fun shouldCalculateSupportScore() {
assertEquals(0.6, CreatorRankingScoreSpec.CHANNEL_DONATION_CAN_WEIGHT, 0.0001)
assertEquals(0.2, CreatorRankingScoreSpec.CHANNEL_DONATION_COUNT_WEIGHT, 0.0001)
assertEquals(0.2, CreatorRankingScoreSpec.FAN_TALK_COUNT_WEIGHT, 0.0001)
val score = policy.calculateSupportScore(
channelDonationCanAmount = 1000,
channelDonationCount = 10,
fanTalkCount = 20
)
assertEquals(606.0, score, 0.0001)
}
@Test
@DisplayName("팬 충성도 점수는 음수 팔로우 증가 수를 최종 점수에 그대로 반영한다")
fun shouldCalculateFanLoyaltyScoreWithNegativeFollowIncrease() {
assertEquals(0.7, CreatorRankingScoreSpec.FINAL_FOLLOWER_COUNT_WEIGHT, 0.0001)
assertEquals(0.3, CreatorRankingScoreSpec.FOLLOW_INCREASE_WEIGHT, 0.0001)
val score = policy.calculateFanLoyaltyScore(finalFollowerCount = 100, followIncrease = -10)
assertEquals(67.0, score, 0.0001)
}
@Test
@DisplayName("최종 점수는 카테고리별 점수에 PRD 가중치를 적용하고 0~100 정규화하지 않는다")
fun shouldCalculateFinalScoreWithoutNormalization() {
assertEquals(0.35, CreatorRankingScoreSpec.CONTENT_LIVE_SCORE_WEIGHT, 0.0001)
assertEquals(0.3, CreatorRankingScoreSpec.ENGAGEMENT_SCORE_WEIGHT, 0.0001)
assertEquals(0.25, CreatorRankingScoreSpec.SUPPORT_SCORE_WEIGHT, 0.0001)
assertEquals(0.1, CreatorRankingScoreSpec.FAN_LOYALTY_SCORE_WEIGHT, 0.0001)
val score = policy.calculateFinalScore( val score = policy.calculateFinalScore(
contentLiveScore = 760.0, liveRevenueRankScore = 100.0,
engagementScore = 30.0, contentRevenueRankScore = 95.0,
supportScore = 606.0, followIncreaseRankScore = 90.0,
fanLoyaltyScore = 67.0 aiChatRankScore = 85.0
) )
assertEquals(433.2, score, 0.0001) assertEquals(96.5, score, 0.0001)
}
@Test
@DisplayName("팔로우 증가는 음수이면 0으로 정규화한다")
fun shouldNormalizeNegativeFollowIncrease() {
assertEquals(0L, policy.normalizeFollowIncrease(-5))
assertEquals(3L, policy.normalizeFollowIncrease(3))
}
@Test
@DisplayName("score detail JSON은 정책 버전, 산식, metric 상세, tie-breaker key를 고정한다")
fun shouldSerializeScoreDetailContract() {
val detail = CreatorRankingScoreDetail(
metrics = CreatorRankingScoreDetail.Metrics(
liveRevenue = metric(100, 1, 100.0, 0.6),
contentRevenue = metric(80, 2, 95.0, 0.2),
followIncrease = metric(3, 3, 90.0, 0.1),
aiChat = metric(5, 4, 85.0, 0.1)
),
tieBreakers = CreatorRankingScoreDetail.TieBreakers(
creatorDebutAt = LocalDateTime.of(2026, 6, 1, 0, 0),
creatorId = 123L
)
)
val json: JsonNode = jacksonObjectMapper().readTree(detail.toJson())
assertEquals("CREATOR_WEEKLY_POPULARITY_V2", json["scorePolicyVersion"].asText())
assertTrue(json.has("formula"))
assertEquals(100, json["metrics"]["liveRevenue"]["rawValue"].asLong())
assertEquals(1, json["metrics"]["liveRevenue"]["rank"].asInt())
assertEquals(100.0, json["metrics"]["liveRevenue"]["rankScore"].asDouble(), 0.0001)
assertEquals(0.6, json["metrics"]["liveRevenue"]["weight"].asDouble(), 0.0001)
assertEquals(60.0, json["metrics"]["liveRevenue"]["weightedScore"].asDouble(), 0.0001)
assertEquals(123L, json["tieBreakers"]["creatorId"].asLong())
assertEquals("2026-06-01T00:00:00", json["tieBreakers"]["creatorDebutAt"].asText())
}
private fun metric(rawValue: Long, rank: Int, rankScore: Double, weight: Double): CreatorRankingScoreDetail.Metric {
return CreatorRankingScoreDetail.Metric(rawValue, rank, rankScore, weight, rankScore * weight)
} }
} }