Compare commits
9 Commits
97c4b1ed1f
...
61d5178eba
| Author | SHA1 | Date | |
|---|---|---|---|
| 61d5178eba | |||
| 263202f0f5 | |||
| d5ffa56f17 | |||
| 10020b03d6 | |||
| 1f944329f4 | |||
| 247569ee16 | |||
| e596cb0826 | |||
| c7b6d0d7c5 | |||
| 9c67e870f6 |
@@ -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);
|
||||
382
docs/20260710_메인_홈_크리에이터_랭킹_개편/plan-task.md
Normal file
382
docs/20260710_메인_홈_크리에이터_랭킹_개편/plan-task.md
Normal 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 파일 접근 제한으로 실패해 승인된 외부 실행으로 재시도했다.
|
||||
275
docs/20260710_메인_홈_크리에이터_랭킹_개편/prd.md
Normal file
275
docs/20260710_메인_홈_크리에이터_랭킹_개편/prd.md
Normal 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`
|
||||
@@ -34,45 +34,15 @@ class CreatorRankingSnapshot(
|
||||
@Column(name = "profile_image_url", updatable = false, length = 500)
|
||||
val profileImageUrl: String?,
|
||||
|
||||
@Column(name = "rank_no", nullable = false, updatable = false)
|
||||
val rankNo: Int,
|
||||
|
||||
@Column(name = "final_score", nullable = false, updatable = false)
|
||||
val finalScore: Double,
|
||||
|
||||
@Column(name = "content_live_score", nullable = false, updatable = false)
|
||||
val contentLiveScore: Double,
|
||||
@Column(name = "score_policy_version", nullable = false, updatable = false, length = 80)
|
||||
val scorePolicyVersion: String,
|
||||
|
||||
@Column(name = "engagement_score", nullable = false, updatable = false)
|
||||
val engagementScore: Double,
|
||||
|
||||
@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
|
||||
@Column(name = "score_detail_json", nullable = false, updatable = false, columnDefinition = "json")
|
||||
val scoreDetailJson: String
|
||||
) : BaseEntity()
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
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.CreatorRankingSnapshotJobTrigger
|
||||
import org.springframework.data.jpa.repository.JpaRepository
|
||||
import org.springframework.data.jpa.repository.Lock
|
||||
import org.springframework.data.jpa.repository.Query
|
||||
@@ -18,4 +20,11 @@ interface CreatorRankingSnapshotJobRepository : JpaRepository<CreatorRankingSnap
|
||||
aggregationEndAtUtc: LocalDateTime,
|
||||
statuses: List<CreatorRankingSnapshotJobStatus>
|
||||
): List<CreatorRankingSnapshotJob>
|
||||
|
||||
fun countByRankingTypeAndAggregationStartAtUtcAndAggregationEndAtUtcAndTrigger(
|
||||
rankingType: CreatorRankingType,
|
||||
aggregationStartAtUtc: LocalDateTime,
|
||||
aggregationEndAtUtc: LocalDateTime,
|
||||
trigger: CreatorRankingSnapshotJobTrigger
|
||||
): Long
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@ import org.springframework.data.repository.query.Param
|
||||
import java.time.LocalDateTime
|
||||
|
||||
interface CreatorRankingSnapshotRepository : JpaRepository<CreatorRankingSnapshot, Long> {
|
||||
fun findAllByAggregationStartAtUtcAndAggregationEndAtUtcOrderByFinalScoreDesc(
|
||||
fun findAllByAggregationStartAtUtcAndAggregationEndAtUtcOrderByRankNoAsc(
|
||||
aggregationStartAtUtc: LocalDateTime,
|
||||
aggregationEndAtUtc: LocalDateTime
|
||||
): List<CreatorRankingSnapshot>
|
||||
@@ -20,7 +20,7 @@ interface CreatorRankingSnapshotRepository : JpaRepository<CreatorRankingSnapsho
|
||||
select max(latest.aggregation_end_at_utc)
|
||||
from creator_ranking_snapshot latest
|
||||
)
|
||||
order by crs.final_score desc
|
||||
order by crs.rank_no asc
|
||||
""",
|
||||
nativeQuery = true
|
||||
)
|
||||
@@ -38,7 +38,7 @@ interface CreatorRankingSnapshotRepository : JpaRepository<CreatorRankingSnapsho
|
||||
from creator_ranking_snapshot latest
|
||||
)
|
||||
)
|
||||
order by crs.final_score desc
|
||||
order by crs.rank_no asc
|
||||
""",
|
||||
nativeQuery = true
|
||||
)
|
||||
@@ -55,7 +55,7 @@ interface CreatorRankingSnapshotRepository : JpaRepository<CreatorRankingSnapsho
|
||||
where latest.ranking_type = :rankingType
|
||||
and latest.visible_from_at <= :nowUtc
|
||||
)
|
||||
order by crs.final_score desc
|
||||
order by crs.rank_no asc
|
||||
""",
|
||||
nativeQuery = true
|
||||
)
|
||||
@@ -69,6 +69,7 @@ interface CreatorRankingSnapshotRepository : JpaRepository<CreatorRankingSnapsho
|
||||
select *
|
||||
from creator_ranking_snapshot crs
|
||||
where crs.ranking_type = :rankingType
|
||||
and crs.visible_from_at <= :nowUtc
|
||||
and crs.aggregation_start_at_utc = (
|
||||
select max(previous.aggregation_start_at_utc)
|
||||
from creator_ranking_snapshot previous
|
||||
@@ -76,7 +77,7 @@ interface CreatorRankingSnapshotRepository : JpaRepository<CreatorRankingSnapsho
|
||||
and previous.aggregation_start_at_utc < :currentAggregationStartAtUtc
|
||||
and previous.visible_from_at <= :nowUtc
|
||||
)
|
||||
order by crs.final_score desc
|
||||
order by crs.rank_no asc
|
||||
""",
|
||||
nativeQuery = true
|
||||
)
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
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.port.out.CreatorRankingAggregationPort
|
||||
import kr.co.vividnext.sodalive.v2.ranking.port.out.CreatorRankingAggregationResult
|
||||
import org.springframework.stereotype.Repository
|
||||
import java.sql.Timestamp
|
||||
import java.time.LocalDateTime
|
||||
import javax.persistence.EntityManager
|
||||
|
||||
@@ -12,8 +12,6 @@ import javax.persistence.EntityManager
|
||||
class DefaultCreatorRankingAggregationRepository(
|
||||
private val entityManager: EntityManager
|
||||
) : CreatorRankingAggregationPort {
|
||||
private val scorePolicy = CreatorRankingScorePolicy()
|
||||
|
||||
override fun aggregateCandidates(
|
||||
startInclusiveUtc: LocalDateTime,
|
||||
endExclusiveUtc: LocalDateTime
|
||||
@@ -26,13 +24,9 @@ class DefaultCreatorRankingAggregationRepository(
|
||||
endExclusiveUtc: LocalDateTime
|
||||
): CreatorRankingAggregationResult {
|
||||
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(
|
||||
candidates = includedCandidates,
|
||||
lowScoreExcludedCount = candidates.size - includedCandidates.size
|
||||
candidates = candidates,
|
||||
lowScoreExcludedCount = 0
|
||||
)
|
||||
}
|
||||
|
||||
@@ -49,42 +43,15 @@ class DefaultCreatorRankingAggregationRepository(
|
||||
}
|
||||
|
||||
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(
|
||||
creatorId = creatorId,
|
||||
nickname = nickname,
|
||||
profileImageUrl = profileImageUrl,
|
||||
finalScore = finalScore,
|
||||
contentLiveScore = contentLiveScore,
|
||||
engagementScore = engagementScore,
|
||||
supportScore = supportScore,
|
||||
fanLoyaltyScore = fanLoyaltyScore,
|
||||
liveCanAmount = liveCanAmount,
|
||||
contentPurchaseCanAmount = contentPurchaseCanAmount,
|
||||
contentLikeCount = contentLikeCount,
|
||||
contentCommentCount = contentCommentCount,
|
||||
channelDonationCanAmount = channelDonationCanAmount,
|
||||
channelDonationCount = channelDonationCount,
|
||||
fanTalkCount = fanTalkCount,
|
||||
finalFollowerCount = finalFollowerCount,
|
||||
followIncrease = followIncrease
|
||||
creatorId = this[0].toLong(),
|
||||
nickname = this[1] as String,
|
||||
profileImageUrl = this[2] as String?,
|
||||
creatorDebutAt = this[3].toLocalDateTimeOrNull(),
|
||||
liveCanAmount = this[4].toLong(),
|
||||
contentPurchaseCanAmount = this[5].toLong(),
|
||||
followIncrease = this[6].toLong().coerceAtLeast(0L),
|
||||
aiChatCount = this[7].toLong()
|
||||
)
|
||||
}
|
||||
|
||||
@@ -92,9 +59,16 @@ class DefaultCreatorRankingAggregationRepository(
|
||||
return (this as Number?)?.toLong() ?: 0L
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val MINIMUM_FINAL_SCORE = 1.0
|
||||
private fun Any?.toLocalDateTimeOrNull(): LocalDateTime? {
|
||||
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 = """
|
||||
with active_creators as (
|
||||
select id, nickname, profile_image
|
||||
@@ -104,9 +78,7 @@ class DefaultCreatorRankingAggregationRepository(
|
||||
), can_metrics as (
|
||||
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 = '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
|
||||
sum(case when uc.can_usage = 'ORDER_CONTENT' then ucc.can else 0 end) as content_purchase_can_amount
|
||||
from use_can_calculate ucc
|
||||
join use_can uc on uc.id = ucc.use_can_id
|
||||
where ucc.recipient_creator_id is not null
|
||||
@@ -115,43 +87,6 @@ class DefaultCreatorRankingAggregationRepository(
|
||||
and ucc.created_at >= :startInclusiveUtc
|
||||
and ucc.created_at < :endExclusiveUtc
|
||||
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 (
|
||||
select creator_id,
|
||||
count(id) as new_follow_count
|
||||
@@ -167,37 +102,68 @@ class DefaultCreatorRankingAggregationRepository(
|
||||
and updated_at >= :startInclusiveUtc
|
||||
and updated_at < :endExclusiveUtc
|
||||
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,
|
||||
ac.nickname as nickname,
|
||||
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.content_purchase_can_amount, 0) as content_purchase_can_amount,
|
||||
coalesce(lm.content_like_count, 0) as content_like_count,
|
||||
coalesce(com.content_comment_count, 0) as content_comment_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
|
||||
greatest(coalesce(nfm.new_follow_count, 0) - coalesce(um.unfollow_count, 0), 0) as follow_increase,
|
||||
coalesce(acm.ai_chat_count, 0) as ai_chat_count
|
||||
from active_creators ac
|
||||
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 unfollow_metrics um on um.creator_id = ac.id
|
||||
where coalesce(cm.live_can_amount, 0) <> 0
|
||||
or coalesce(cm.content_purchase_can_amount, 0) <> 0
|
||||
or coalesce(lm.content_like_count, 0) <> 0
|
||||
or coalesce(com.content_comment_count, 0) <> 0
|
||||
or coalesce(cm.channel_donation_can_amount, 0) <> 0
|
||||
or coalesce(cm.channel_donation_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
|
||||
left join ai_chat_metrics acm on acm.creator_id = ac.id
|
||||
left join debut_metrics dm on dm.creator_id = ac.id
|
||||
where coalesce(cm.live_can_amount, 0) > 0
|
||||
or coalesce(cm.content_purchase_can_amount, 0) > 0
|
||||
or greatest(coalesce(nfm.new_follow_count, 0) - coalesce(um.unfollow_count, 0), 0) > 0
|
||||
or coalesce(acm.ai_chat_count, 0) > 0
|
||||
""".trimIndent()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
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.CreatorRankingSnapshotJobRecord
|
||||
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.transaction.annotation.Transactional
|
||||
import java.time.LocalDateTime
|
||||
@@ -32,6 +34,20 @@ class DefaultCreatorRankingSnapshotJobRepository(
|
||||
).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
|
||||
override fun markProcessing(jobId: Long, processingStartedAt: LocalDateTime): CreatorRankingSnapshotJobRecord? {
|
||||
val job = repository.findByIdForUpdate(jobId) ?: return null
|
||||
|
||||
@@ -15,7 +15,7 @@ class DefaultCreatorRankingSnapshotRepository(
|
||||
aggregationStartAtUtc: LocalDateTime,
|
||||
aggregationEndAtUtc: LocalDateTime
|
||||
): List<CreatorRankingSnapshotRecord> {
|
||||
return repository.findAllByAggregationStartAtUtcAndAggregationEndAtUtcOrderByFinalScoreDesc(
|
||||
return repository.findAllByAggregationStartAtUtcAndAggregationEndAtUtcOrderByRankNoAsc(
|
||||
aggregationStartAtUtc = aggregationStartAtUtc,
|
||||
aggregationEndAtUtc = aggregationEndAtUtc
|
||||
).map { it.toRecord() }
|
||||
@@ -42,16 +42,12 @@ class DefaultCreatorRankingSnapshotRepository(
|
||||
nowUtc: LocalDateTime
|
||||
): List<CreatorRankingSnapshotRecord> {
|
||||
return repository.findPreviousVisibleSnapshots(
|
||||
rankingType = rankingType.name,
|
||||
currentAggregationStartAtUtc = currentAggregationStartAtUtc,
|
||||
nowUtc = nowUtc
|
||||
rankingType.name,
|
||||
currentAggregationStartAtUtc,
|
||||
nowUtc
|
||||
).map { it.toRecord() }
|
||||
}
|
||||
|
||||
override fun isSnapshotTableEmpty(): Boolean {
|
||||
return repository.count() == 0L
|
||||
}
|
||||
|
||||
@Transactional
|
||||
override fun replaceSnapshots(
|
||||
rankingType: CreatorRankingType,
|
||||
@@ -61,9 +57,9 @@ class DefaultCreatorRankingSnapshotRepository(
|
||||
newSnapshots: List<CreatorRankingSnapshotRecord>
|
||||
) {
|
||||
repository.deleteByRankingTypeAndAggregationStartAtUtcAndAggregationEndAtUtc(
|
||||
rankingType = rankingType,
|
||||
aggregationStartAtUtc = aggregationStartAtUtc,
|
||||
aggregationEndAtUtc = aggregationEndAtUtc
|
||||
rankingType,
|
||||
aggregationStartAtUtc,
|
||||
aggregationEndAtUtc
|
||||
)
|
||||
repository.saveAll(newSnapshots.map { it.toEntity(rankingType, visibleFromAtUtc) })
|
||||
}
|
||||
@@ -77,20 +73,10 @@ class DefaultCreatorRankingSnapshotRepository(
|
||||
creatorId = creatorId,
|
||||
nickname = nickname,
|
||||
profileImageUrl = profileImageUrl,
|
||||
rankNo = rankNo,
|
||||
finalScore = finalScore,
|
||||
contentLiveScore = contentLiveScore,
|
||||
engagementScore = engagementScore,
|
||||
supportScore = supportScore,
|
||||
fanLoyaltyScore = fanLoyaltyScore,
|
||||
liveCanAmount = liveCanAmount,
|
||||
contentPurchaseCanAmount = contentPurchaseCanAmount,
|
||||
contentLikeCount = contentLikeCount,
|
||||
contentCommentCount = contentCommentCount,
|
||||
channelDonationCanAmount = channelDonationCanAmount,
|
||||
channelDonationCount = channelDonationCount,
|
||||
fanTalkCount = fanTalkCount,
|
||||
finalFollowerCount = finalFollowerCount,
|
||||
followIncrease = followIncrease
|
||||
scorePolicyVersion = scorePolicyVersion,
|
||||
scoreDetailJson = scoreDetailJson
|
||||
)
|
||||
}
|
||||
|
||||
@@ -106,20 +92,10 @@ class DefaultCreatorRankingSnapshotRepository(
|
||||
creatorId = creatorId,
|
||||
nickname = nickname,
|
||||
profileImageUrl = profileImageUrl,
|
||||
rankNo = rankNo,
|
||||
finalScore = finalScore,
|
||||
contentLiveScore = contentLiveScore,
|
||||
engagementScore = engagementScore,
|
||||
supportScore = supportScore,
|
||||
fanLoyaltyScore = fanLoyaltyScore,
|
||||
liveCanAmount = liveCanAmount,
|
||||
contentPurchaseCanAmount = contentPurchaseCanAmount,
|
||||
contentLikeCount = contentLikeCount,
|
||||
contentCommentCount = contentCommentCount,
|
||||
channelDonationCanAmount = channelDonationCanAmount,
|
||||
channelDonationCount = channelDonationCount,
|
||||
fanTalkCount = fanTalkCount,
|
||||
finalFollowerCount = finalFollowerCount,
|
||||
followIncrease = followIncrease
|
||||
scorePolicyVersion = scorePolicyVersion,
|
||||
scoreDetailJson = scoreDetailJson
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.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.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.CreatorRankingSnapshotPort
|
||||
import kr.co.vividnext.sodalive.v2.ranking.port.out.CreatorRankingSnapshotRecord
|
||||
import org.slf4j.LoggerFactory
|
||||
import org.springframework.beans.factory.annotation.Value
|
||||
import org.springframework.stereotype.Service
|
||||
import org.springframework.transaction.annotation.Transactional
|
||||
import java.time.LocalDateTime
|
||||
import java.time.ZoneId
|
||||
import java.time.ZonedDateTime
|
||||
@@ -23,42 +17,21 @@ import java.time.ZonedDateTime
|
||||
class CreatorRankingQueryService(
|
||||
private val snapshotPort: CreatorRankingSnapshotPort,
|
||||
private val blockPort: CreatorRankingBlockPort,
|
||||
private val aggregationPort: CreatorRankingAggregationPort,
|
||||
private val snapshotJobService: CreatorRankingSnapshotJobService,
|
||||
private val nowProvider: () -> ZonedDateTime = { ZonedDateTime.now() },
|
||||
@Value("\${cloud.aws.cloud-front.host}")
|
||||
private val cloudFrontHost: String
|
||||
) {
|
||||
private val log = LoggerFactory.getLogger(javaClass)
|
||||
private val periodPolicy = CreatorRankingPeriodPolicy()
|
||||
private val scorePolicy = CreatorRankingScorePolicy()
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
fun getCreatorRankings(viewerMemberId: Long?): CreatorRankingResult {
|
||||
val startedAt = System.currentTimeMillis()
|
||||
return runCatching {
|
||||
val nowUtc = nowUtc()
|
||||
val latestSnapshots = snapshotPort.findLatestVisibleSnapshots(CreatorRankingType.WEEKLY, nowUtc)
|
||||
val latestSnapshots = findLatestVisibleSnapshots(nowUtc)
|
||||
val latestItems = latestSnapshots.toRankedItems()
|
||||
if (latestItems.isEmpty()) {
|
||||
if (snapshotPort.isSnapshotTableEmpty()) {
|
||||
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
|
||||
)
|
||||
return@runCatching QueryLogResult(CreatorRankingResult(showRankChange = false, items = emptyList()), 0)
|
||||
}
|
||||
|
||||
val previousItems = snapshotPort.findPreviousVisibleSnapshots(
|
||||
@@ -77,10 +50,7 @@ class CreatorRankingQueryService(
|
||||
).maskIfBlocked(blockedCreatorIds)
|
||||
}
|
||||
|
||||
QueryLogResult(
|
||||
result = CreatorRankingResult(showRankChange = showRankChange, items = items),
|
||||
blockedCreatorCount = blockedCreatorIds.size
|
||||
)
|
||||
QueryLogResult(CreatorRankingResult(showRankChange = showRankChange, items = items), blockedCreatorIds.size)
|
||||
}.onSuccess { logResult ->
|
||||
log.info(
|
||||
"event=creator_ranking_query_success showRankChange={} itemCount={} blockedCreatorCount={} elapsedMs={}",
|
||||
@@ -99,75 +69,30 @@ class CreatorRankingQueryService(
|
||||
}.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(
|
||||
val result: CreatorRankingResult,
|
||||
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 {
|
||||
return nowProvider().withZoneSameInstant(UTC_ZONE).toLocalDateTime()
|
||||
}
|
||||
|
||||
private fun List<CreatorRankingSnapshotRecord>.toRankedItems(): List<CreatorRankingItem> {
|
||||
return groupBy { it.finalScore }
|
||||
.toSortedMap(compareByDescending { it })
|
||||
.values
|
||||
.flatMap { it.shuffled() }
|
||||
return sortedBy { it.rankNo }
|
||||
.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 {
|
||||
@@ -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> {
|
||||
if (viewerMemberId == null) {
|
||||
return emptySet()
|
||||
}
|
||||
return blockPort.findBlockedCreatorIds(
|
||||
memberId = viewerMemberId,
|
||||
creatorIds = items.map { it.creatorId }
|
||||
)
|
||||
if (viewerMemberId == null) return emptySet()
|
||||
return blockPort.findBlockedCreatorIds(memberId = viewerMemberId, creatorIds = items.map { it.creatorId })
|
||||
}
|
||||
|
||||
private fun CreatorRankingItem.maskIfBlocked(blockedCreatorIds: Set<Long>): CreatorRankingItem {
|
||||
if (!blockedCreatorIds.contains(creatorId)) {
|
||||
return this
|
||||
}
|
||||
if (!blockedCreatorIds.contains(creatorId)) return this
|
||||
return copy(
|
||||
creatorId = MASKED_CREATOR_ID,
|
||||
nickname = MASKED_NICKNAME,
|
||||
|
||||
@@ -35,15 +35,26 @@ class CreatorRankingSnapshotJobService(
|
||||
|
||||
fun refreshLastCompletedWeekByScheduledJob() {
|
||||
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,
|
||||
utcRange: CreatorRankingUtcRange
|
||||
utcRange: CreatorRankingUtcRange,
|
||||
trigger: CreatorRankingSnapshotJobTrigger
|
||||
) {
|
||||
val job = savePendingJob(utcRange, CreatorRankingSnapshotJobTrigger.SCHEDULED)
|
||||
val job = savePendingJob(utcRange, trigger)
|
||||
val jobId = job.id ?: return
|
||||
markProcessing(jobId)
|
||||
logJobStatusChanged(job, CreatorRankingSnapshotJobStatus.PROCESSING)
|
||||
@@ -85,22 +96,25 @@ class CreatorRankingSnapshotJobService(
|
||||
}!!
|
||||
}
|
||||
|
||||
private fun markProcessing(jobId: Long) {
|
||||
transactionTemplate.executeWithoutResult {
|
||||
jobPort.markProcessing(jobId, LocalDateTime.now())
|
||||
private fun fallbackCountReachedLimit(utcRange: CreatorRankingUtcRange): Boolean {
|
||||
return jobPort.countByRankingTypeAndPeriodAndTrigger(
|
||||
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) {
|
||||
transactionTemplate.executeWithoutResult {
|
||||
jobPort.markDone(jobId, LocalDateTime.now())
|
||||
}
|
||||
transactionTemplate.executeWithoutResult { jobPort.markDone(jobId, LocalDateTime.now()) }
|
||||
}
|
||||
|
||||
private fun markFailed(jobId: Long, message: String?) {
|
||||
transactionTemplate.executeWithoutResult {
|
||||
jobPort.markFailed(jobId, LocalDateTime.now(), message)
|
||||
}
|
||||
transactionTemplate.executeWithoutResult { jobPort.markFailed(jobId, LocalDateTime.now(), message) }
|
||||
}
|
||||
|
||||
@Transactional
|
||||
@@ -128,34 +142,22 @@ class CreatorRankingSnapshotJobService(
|
||||
aggregationEndAtUtc: LocalDateTime,
|
||||
statuses: List<CreatorRankingSnapshotJobStatus> = CreatorRankingSnapshotJobStatus.values().toList()
|
||||
): List<CreatorRankingSnapshotJobRecord> {
|
||||
return jobPort.findByPeriodAndStatuses(
|
||||
aggregationStartAtUtc = aggregationStartAtUtc,
|
||||
aggregationEndAtUtc = aggregationEndAtUtc,
|
||||
statuses = statuses
|
||||
)
|
||||
return jobPort.findByPeriodAndStatuses(aggregationStartAtUtc, aggregationEndAtUtc, statuses)
|
||||
}
|
||||
|
||||
@Transactional
|
||||
fun retryFailedJob(jobId: Long) {
|
||||
val job = jobPort.findById(jobId) ?: return
|
||||
if (job.status != CreatorRankingSnapshotJobStatus.FAILED) return
|
||||
|
||||
jobPort.markPending(jobId)
|
||||
}
|
||||
|
||||
fun ensureLastCompletedWeekSnapshotForColdStart() {
|
||||
withLastCompletedWeekPeriodLock { now, _ ->
|
||||
transactionTemplate.executeWithoutResult {
|
||||
refreshService.refreshLastCompletedWeek(now)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun withLastCompletedWeekPeriodLock(action: (ZonedDateTime, CreatorRankingUtcRange) -> Unit) {
|
||||
val now = nowProvider()
|
||||
val period = periodPolicy.resolveLastCompletedWeek(now)
|
||||
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)
|
||||
|
||||
try {
|
||||
@@ -185,4 +187,8 @@ class CreatorRankingSnapshotJobService(
|
||||
error
|
||||
)
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val FALLBACK_LIMIT = 3L
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.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.CreatorRankingScoreSpec
|
||||
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.CreatorRankingUtcRange
|
||||
@@ -38,9 +40,7 @@ class CreatorRankingSnapshotRefreshService(
|
||||
startInclusiveUtc = utcRange.startInclusiveUtc,
|
||||
endExclusiveUtc = utcRange.endExclusiveUtc
|
||||
)
|
||||
val snapshots = aggregationResult.candidates.map { it.toSnapshotRecord(utcRange) }
|
||||
.sortedByDescending { it.finalScore }
|
||||
.takeRankedBoundary(limit = SNAPSHOT_LIMIT)
|
||||
val snapshots = assembleSnapshots(aggregationResult.candidates, utcRange, visibleFromAtUtc)
|
||||
|
||||
snapshotPort.replaceSnapshots(
|
||||
rankingType = CreatorRankingType.WEEKLY,
|
||||
@@ -50,7 +50,7 @@ class CreatorRankingSnapshotRefreshService(
|
||||
newSnapshots = snapshots
|
||||
)
|
||||
afterCommit {
|
||||
snapshots.forEachIndexed { index, snapshot ->
|
||||
snapshots.forEach { snapshot ->
|
||||
runCatching {
|
||||
homeFollowingNewsPublishService.publishCreatorRankingVisible(
|
||||
creatorId = snapshot.creatorId,
|
||||
@@ -58,13 +58,13 @@ class CreatorRankingSnapshotRefreshService(
|
||||
creatorProfileImagePath = snapshot.profileImageUrl,
|
||||
aggregationStartAtUtc = utcRange.startInclusiveUtc,
|
||||
visibleFromAtUtc = visibleFromAtUtc,
|
||||
rank = index + 1
|
||||
rank = snapshot.rankNo
|
||||
)
|
||||
}.onFailure { ex ->
|
||||
log.warn(
|
||||
"event=home_following_creator_ranking_news_publish_failure creatorId={} rank={} error={}",
|
||||
snapshot.creatorId,
|
||||
index + 1,
|
||||
snapshot.rankNo,
|
||||
ex.message,
|
||||
ex
|
||||
)
|
||||
@@ -100,12 +100,115 @@ class CreatorRankingSnapshotRefreshService(
|
||||
}
|
||||
}
|
||||
|
||||
private fun CreatorRankingAggregationResult.toLogCounts(storedCount: Int): RefreshLogCounts {
|
||||
return RefreshLogCounts(
|
||||
candidateCount = candidates.size,
|
||||
storedCount = storedCount,
|
||||
lowScoreExcludedCount = lowScoreExcludedCount
|
||||
private fun assembleSnapshots(
|
||||
candidates: List<CreatorRankingSnapshotCandidate>,
|
||||
utcRange: CreatorRankingUtcRange,
|
||||
visibleFromAtUtc: java.time.LocalDateTime
|
||||
): 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(
|
||||
@@ -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 {
|
||||
private const val SNAPSHOT_LIMIT = 20
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -1,49 +1,24 @@
|
||||
package kr.co.vividnext.sodalive.v2.ranking.domain
|
||||
|
||||
class CreatorRankingScorePolicy {
|
||||
fun calculateContentLiveScore(
|
||||
liveCanAmount: Long,
|
||||
contentPurchaseCanAmount: Long
|
||||
): Double {
|
||||
return (liveCanAmount * CreatorRankingScoreSpec.CONTENT_LIVE_CAN_WEIGHT) +
|
||||
(contentPurchaseCanAmount * CreatorRankingScoreSpec.CONTENT_PURCHASE_CAN_WEIGHT)
|
||||
fun calculateRankScore(rawValue: Long, rank: Int): Double {
|
||||
if (rawValue <= 0L) return 0.0
|
||||
return (100 - (5 * (rank - 1))).coerceAtLeast(0).toDouble()
|
||||
}
|
||||
|
||||
fun calculateEngagementScore(
|
||||
contentLikeCount: Long,
|
||||
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 normalizeFollowIncrease(value: Long): Long {
|
||||
return value.coerceAtLeast(0L)
|
||||
}
|
||||
|
||||
fun calculateFinalScore(
|
||||
contentLiveScore: Double,
|
||||
engagementScore: Double,
|
||||
supportScore: Double,
|
||||
fanLoyaltyScore: Double
|
||||
liveRevenueRankScore: Double,
|
||||
contentRevenueRankScore: Double,
|
||||
followIncreaseRankScore: Double,
|
||||
aiChatRankScore: Double
|
||||
): Double {
|
||||
return (contentLiveScore * CreatorRankingScoreSpec.CONTENT_LIVE_SCORE_WEIGHT) +
|
||||
(engagementScore * CreatorRankingScoreSpec.ENGAGEMENT_SCORE_WEIGHT) +
|
||||
(supportScore * CreatorRankingScoreSpec.SUPPORT_SCORE_WEIGHT) +
|
||||
(fanLoyaltyScore * CreatorRankingScoreSpec.FAN_LOYALTY_SCORE_WEIGHT)
|
||||
return (liveRevenueRankScore * CreatorRankingScoreSpec.LIVE_REVENUE_WEIGHT) +
|
||||
(contentRevenueRankScore * CreatorRankingScoreSpec.CONTENT_REVENUE_WEIGHT) +
|
||||
(followIncreaseRankScore * CreatorRankingScoreSpec.FOLLOW_INCREASE_WEIGHT) +
|
||||
(aiChatRankScore * CreatorRankingScoreSpec.AI_CHAT_WEIGHT)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,21 +1,12 @@
|
||||
package kr.co.vividnext.sodalive.v2.ranking.domain
|
||||
|
||||
object CreatorRankingScoreSpec {
|
||||
const val CONTENT_LIVE_CAN_WEIGHT = 0.7
|
||||
const val CONTENT_PURCHASE_CAN_WEIGHT = 0.3
|
||||
const val SCORE_POLICY_VERSION = "CREATOR_WEEKLY_POPULARITY_V2"
|
||||
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 CONTENT_COMMENT_COUNT_WEIGHT = 0.5
|
||||
|
||||
const val CHANNEL_DONATION_CAN_WEIGHT = 0.6
|
||||
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
|
||||
const val LIVE_REVENUE_WEIGHT = 0.6
|
||||
const val CONTENT_REVENUE_WEIGHT = 0.2
|
||||
const val FOLLOW_INCREASE_WEIGHT = 0.1
|
||||
const val AI_CHAT_WEIGHT = 0.1
|
||||
}
|
||||
|
||||
@@ -1,21 +1,14 @@
|
||||
package kr.co.vividnext.sodalive.v2.ranking.domain
|
||||
|
||||
import java.time.LocalDateTime
|
||||
|
||||
data class CreatorRankingSnapshotCandidate(
|
||||
val creatorId: Long,
|
||||
val nickname: String,
|
||||
val profileImageUrl: String?,
|
||||
val finalScore: Double,
|
||||
val contentLiveScore: Double,
|
||||
val engagementScore: Double,
|
||||
val supportScore: Double,
|
||||
val fanLoyaltyScore: Double,
|
||||
val creatorDebutAt: LocalDateTime?,
|
||||
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
|
||||
val followIncrease: Long,
|
||||
val aiChatCount: Long
|
||||
)
|
||||
|
||||
@@ -14,6 +14,13 @@ interface CreatorRankingSnapshotJobPort {
|
||||
statuses: List<CreatorRankingSnapshotJobStatus>
|
||||
): List<CreatorRankingSnapshotJobRecord>
|
||||
|
||||
fun countByRankingTypeAndPeriodAndTrigger(
|
||||
rankingType: CreatorRankingType,
|
||||
aggregationStartAtUtc: LocalDateTime,
|
||||
aggregationEndAtUtc: LocalDateTime,
|
||||
trigger: CreatorRankingSnapshotJobTrigger
|
||||
): Long
|
||||
|
||||
fun markProcessing(jobId: Long, processingStartedAt: LocalDateTime): CreatorRankingSnapshotJobRecord?
|
||||
|
||||
fun markDone(jobId: Long, processedAt: LocalDateTime): CreatorRankingSnapshotJobRecord?
|
||||
@@ -32,7 +39,8 @@ enum class CreatorRankingSnapshotJobStatus {
|
||||
|
||||
enum class CreatorRankingSnapshotJobTrigger {
|
||||
SCHEDULED,
|
||||
MANUAL
|
||||
MANUAL,
|
||||
FALLBACK
|
||||
}
|
||||
|
||||
data class CreatorRankingSnapshotJobRecord(
|
||||
|
||||
@@ -24,8 +24,6 @@ interface CreatorRankingSnapshotPort {
|
||||
nowUtc: LocalDateTime
|
||||
): List<CreatorRankingSnapshotRecord>
|
||||
|
||||
fun isSnapshotTableEmpty(): Boolean
|
||||
|
||||
fun replaceSnapshots(
|
||||
rankingType: CreatorRankingType,
|
||||
aggregationStartAtUtc: LocalDateTime,
|
||||
@@ -43,18 +41,8 @@ data class CreatorRankingSnapshotRecord(
|
||||
val creatorId: Long,
|
||||
val nickname: String,
|
||||
val profileImageUrl: String?,
|
||||
val rankNo: Int,
|
||||
val finalScore: Double,
|
||||
val contentLiveScore: Double,
|
||||
val engagementScore: Double,
|
||||
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
|
||||
val scorePolicyVersion: String,
|
||||
val scoreDetailJson: String
|
||||
)
|
||||
|
||||
@@ -7,6 +7,7 @@ import kr.co.vividnext.sodalive.member.MemberRole
|
||||
import kr.co.vividnext.sodalive.member.block.BlockMember
|
||||
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.domain.CreatorRankingScoreSpec
|
||||
import kr.co.vividnext.sodalive.v2.ranking.domain.CreatorRankingType
|
||||
import org.junit.jupiter.api.DisplayName
|
||||
import org.junit.jupiter.api.Test
|
||||
@@ -23,7 +24,13 @@ import org.springframework.transaction.annotation.Transactional
|
||||
import java.time.LocalDateTime
|
||||
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
|
||||
@Transactional
|
||||
@ContextConfiguration(initializers = [EmbeddedRedisInitializer::class])
|
||||
@@ -33,13 +40,20 @@ class CreatorRankingControllerTest @Autowired constructor(
|
||||
private val entityManager: EntityManager
|
||||
) {
|
||||
@Test
|
||||
@DisplayName("크리에이터 랭킹 조회는 허용된 응답 필드만 반환하고 점수와 기간은 노출하지 않는다")
|
||||
fun shouldReturnCreatorRankingSchemaWithoutScoreAndPeriodFields() {
|
||||
@DisplayName("크리에이터 랭킹 API는 공개 응답 스키마만 노출하고 내부 스냅샷 필드를 숨긴다")
|
||||
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(
|
||||
creatorId = 1L,
|
||||
creatorId = creatorOne.id!!,
|
||||
nickname = "creator-one",
|
||||
profileImageUrl = "profile-one.png",
|
||||
finalScore = 100.0
|
||||
rankNo = 1,
|
||||
aggregationStartAtUtc = PREVIOUS_START,
|
||||
aggregationEndAtUtc = PREVIOUS_END,
|
||||
visibleFromAtUtc = PREVIOUS_VISIBLE
|
||||
)
|
||||
entityManager.flush()
|
||||
entityManager.clear()
|
||||
@@ -47,23 +61,33 @@ class CreatorRankingControllerTest @Autowired constructor(
|
||||
mockMvc.perform(get("/api/v2/home/rankings/creators"))
|
||||
.andExpect(status().isOk)
|
||||
.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].rankChange").doesNotExist())
|
||||
.andExpect(jsonPath("$.data.items[0].isNew").value(false))
|
||||
.andExpect(jsonPath("$.data.items[0].creatorId").value(1L))
|
||||
.andExpect(jsonPath("$.data.items[0].nickname").value("creator-one"))
|
||||
.andExpect(jsonPath("$.data.items[0].profileImageUrl").value("https://cdn.test/profile-one.png"))
|
||||
.andExpect(jsonPath("$.data.items[0].isNew").value(true))
|
||||
.andExpect(jsonPath("$.data.items[0].creatorId").value(creatorTwo.id))
|
||||
.andExpect(jsonPath("$.data.items[0].nickname").value("creator-two"))
|
||||
.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].aggregationStartAtUtc").doesNotExist())
|
||||
.andExpect(jsonPath("$.data.items[0].aggregationEndAtUtc").doesNotExist())
|
||||
.andExpect(jsonPath("$.data.aggregationStartAtUtc").doesNotExist())
|
||||
.andExpect(jsonPath("$.data.aggregationEndAtUtc").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())
|
||||
.andExpect(jsonPath("$.data.finalScore").doesNotExist())
|
||||
.andExpect(jsonPath("$.data.rankNo").doesNotExist())
|
||||
.andExpect(jsonPath("$.data.scorePolicyVersion").doesNotExist())
|
||||
.andExpect(jsonPath("$.data.scoreDetailJson").doesNotExist())
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("크리에이터 랭킹 조회는 비회원도 호출 가능하고 빈 랭킹을 성공 응답으로 반환한다")
|
||||
fun shouldReturnEmptyCreatorRankingsForAnonymous() {
|
||||
@DisplayName("크리에이터 랭킹 API는 비회원도 빈 랭킹을 성공 응답으로 조회할 수 있다")
|
||||
fun shouldReturnEmptySuccessForAnonymous() {
|
||||
mockMvc.perform(get("/api/v2/home/rankings/creators"))
|
||||
.andExpect(status().isOk)
|
||||
.andExpect(jsonPath("$.success").value(true))
|
||||
@@ -73,7 +97,7 @@ class CreatorRankingControllerTest @Autowired constructor(
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("크리에이터 랭킹 조회는 인증 회원 id를 전달해 차단 크리에이터 정보를 마스킹한다")
|
||||
@DisplayName("크리에이터 랭킹 API는 인증 회원과 차단 관계인 크리에이터 정보를 마스킹한다")
|
||||
fun shouldMaskBlockedCreatorForAuthenticatedMember() {
|
||||
val viewer = saveMember("ranking-viewer", MemberRole.USER)
|
||||
val blockedCreator = saveMember("blocked-creator", MemberRole.CREATOR)
|
||||
@@ -81,7 +105,7 @@ class CreatorRankingControllerTest @Autowired constructor(
|
||||
creatorId = blockedCreator.id!!,
|
||||
nickname = blockedCreator.nickname,
|
||||
profileImageUrl = "blocked-profile.png",
|
||||
finalScore = 100.0
|
||||
rankNo = 1
|
||||
)
|
||||
saveBlock(member = viewer, blockedMember = blockedCreator)
|
||||
entityManager.flush()
|
||||
@@ -97,6 +121,11 @@ class CreatorRankingControllerTest @Autowired constructor(
|
||||
.andExpect(jsonPath("$.data.items[0].creatorId").value(0L))
|
||||
.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].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 {
|
||||
@@ -123,32 +152,34 @@ class CreatorRankingControllerTest @Autowired constructor(
|
||||
creatorId: Long,
|
||||
nickname: String,
|
||||
profileImageUrl: String?,
|
||||
finalScore: Double
|
||||
rankNo: Int,
|
||||
aggregationStartAtUtc: LocalDateTime = START,
|
||||
aggregationEndAtUtc: LocalDateTime = END,
|
||||
visibleFromAtUtc: LocalDateTime = VISIBLE
|
||||
) {
|
||||
entityManager.persist(
|
||||
CreatorRankingSnapshot(
|
||||
rankingType = CreatorRankingType.WEEKLY,
|
||||
aggregationStartAtUtc = LocalDateTime.of(2026, 5, 31, 15, 0, 0),
|
||||
aggregationEndAtUtc = LocalDateTime.of(2026, 6, 7, 15, 0, 0),
|
||||
visibleFromAtUtc = LocalDateTime.of(2026, 6, 8, 0, 0, 0),
|
||||
aggregationStartAtUtc = aggregationStartAtUtc,
|
||||
aggregationEndAtUtc = aggregationEndAtUtc,
|
||||
visibleFromAtUtc = visibleFromAtUtc,
|
||||
creatorId = creatorId,
|
||||
nickname = nickname,
|
||||
profileImageUrl = profileImageUrl,
|
||||
finalScore = finalScore,
|
||||
contentLiveScore = 0.0,
|
||||
engagementScore = 0.0,
|
||||
supportScore = 0.0,
|
||||
fanLoyaltyScore = 0.0,
|
||||
liveCanAmount = 0,
|
||||
contentPurchaseCanAmount = 0,
|
||||
contentLikeCount = 0,
|
||||
contentCommentCount = 0,
|
||||
channelDonationCanAmount = 0,
|
||||
channelDonationCount = 0,
|
||||
fanTalkCount = 0,
|
||||
finalFollowerCount = 0,
|
||||
followIncrease = 0
|
||||
rankNo = rankNo,
|
||||
finalScore = (100 - rankNo).toDouble(),
|
||||
scorePolicyVersion = CreatorRankingScoreSpec.SCORE_POLICY_VERSION,
|
||||
scoreDetailJson = "{}"
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.UseCanCalculate
|
||||
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.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.explorer.profile.CreatorCheers
|
||||
import kr.co.vividnext.sodalive.live.room.LiveRoom
|
||||
import kr.co.vividnext.sodalive.member.Member
|
||||
import kr.co.vividnext.sodalive.member.MemberRole
|
||||
import kr.co.vividnext.sodalive.member.following.CreatorFollowing
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.Assertions.assertFalse
|
||||
import org.junit.jupiter.api.Assertions.assertTrue
|
||||
import org.junit.jupiter.api.DisplayName
|
||||
import org.junit.jupiter.api.Test
|
||||
import org.springframework.beans.factory.annotation.Autowired
|
||||
@@ -28,25 +30,41 @@ import javax.persistence.EntityManager
|
||||
@DataJpaTest(
|
||||
properties = [
|
||||
"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(
|
||||
private val adapter: DefaultCreatorRankingAggregationRepository,
|
||||
private val entityManager: EntityManager
|
||||
) {
|
||||
private val adapter = DefaultCreatorRankingAggregationRepository(entityManager)
|
||||
private val startAt = LocalDateTime.of(2026, 5, 31, 15, 0)
|
||||
private val endAt = LocalDateTime.of(2026, 6, 7, 15, 0)
|
||||
private val inPeriod = LocalDateTime.of(2026, 6, 1, 0, 0)
|
||||
@Test
|
||||
@DisplayName("활성 크리에이터만 V2 raw metric 후보로 집계한다")
|
||||
fun shouldAggregateOnlyActiveCreators() {
|
||||
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
|
||||
@DisplayName("콘텐츠/라이브 캔은 사용 구분, 정산 상태, 환불 여부, UTC 기간으로 집계한다")
|
||||
fun shouldAggregateLiveAndContentCanAmountsByUsageStatusRefundAndUtcPeriod() {
|
||||
@DisplayName("캔 지표는 사용 구분, 정산 상태, 환불 여부, half-open UTC 기간으로 집계한다")
|
||||
fun shouldAggregateCanMetricsByUsageStatusRefundAndHalfOpenPeriod() {
|
||||
val creator = saveCreator("can-creator")
|
||||
val user = saveUser("can-user")
|
||||
saveUseCanCalculate(user, creator, CanUsage.DONATION, 10, UseCanCalculateStatus.RECEIVED, false, startAt)
|
||||
saveUseCanCalculate(user, creator, CanUsage.LIVE, 20, UseCanCalculateStatus.RECEIVED, false, inPeriod)
|
||||
saveUseCanCalculate(user, creator, CanUsage.DONATION, 10, UseCanCalculateStatus.RECEIVED, false, START_AT)
|
||||
saveUseCanCalculate(user, creator, CanUsage.LIVE, 20, UseCanCalculateStatus.RECEIVED, false, IN_PERIOD)
|
||||
saveUseCanCalculate(
|
||||
user,
|
||||
creator,
|
||||
@@ -54,11 +72,11 @@ class DefaultCreatorRankingAggregationRepositoryTest @Autowired constructor(
|
||||
30,
|
||||
UseCanCalculateStatus.RECEIVED,
|
||||
false,
|
||||
endAt.minusSeconds(1)
|
||||
END_AT.minusSeconds(1)
|
||||
)
|
||||
saveUseCanCalculate(user, creator, CanUsage.ORDER_CONTENT, 40, UseCanCalculateStatus.RECEIVED, false, inPeriod)
|
||||
saveUseCanCalculate(user, creator, CanUsage.DONATION, 100, UseCanCalculateStatus.RECEIVED, true, inPeriod)
|
||||
saveUseCanCalculate(user, creator, CanUsage.LIVE, 200, UseCanCalculateStatus.CALCULATE_COMPLETE, false, inPeriod)
|
||||
saveUseCanCalculate(user, creator, CanUsage.ORDER_CONTENT, 40, UseCanCalculateStatus.RECEIVED, false, IN_PERIOD)
|
||||
saveUseCanCalculate(user, creator, CanUsage.DONATION, 100, UseCanCalculateStatus.RECEIVED, true, IN_PERIOD)
|
||||
saveUseCanCalculate(user, creator, CanUsage.LIVE, 200, UseCanCalculateStatus.CALCULATE_COMPLETE, false, IN_PERIOD)
|
||||
saveUseCanCalculate(
|
||||
user,
|
||||
creator,
|
||||
@@ -66,9 +84,9 @@ class DefaultCreatorRankingAggregationRepositoryTest @Autowired constructor(
|
||||
300,
|
||||
UseCanCalculateStatus.RECEIVED,
|
||||
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()
|
||||
|
||||
val candidate = aggregate().single()
|
||||
@@ -78,139 +96,136 @@ class DefaultCreatorRankingAggregationRepositoryTest @Autowired constructor(
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("활성 콘텐츠의 활성 좋아요와 작성자 본인이 아닌 활성 댓글/대댓글만 집계한다")
|
||||
fun shouldAggregateActiveContentLikesAndCommentsExcludingCreatorSelfResponses() {
|
||||
val creator = saveCreator("engagement-creator")
|
||||
val otherCreator = saveCreator("inactive-content-creator")
|
||||
val user = saveUser("engagement-user")
|
||||
val content = saveAudioContent(creator, isActive = true)
|
||||
val inactiveContent = saveAudioContent(otherCreator, isActive = false)
|
||||
saveUseCanCalculate(user, creator, CanUsage.DONATION, 10, UseCanCalculateStatus.RECEIVED, false, inPeriod)
|
||||
saveContentLike(content, user, isActive = true, createdAt = inPeriod)
|
||||
saveContentLike(content, user, isActive = false, createdAt = inPeriod)
|
||||
saveContentLike(content, user, isActive = true, createdAt = startAt.minusSeconds(1))
|
||||
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))
|
||||
@DisplayName("팔로우 증가는 음수를 0으로 보정하고 양수 신규 팔로우는 후보를 만든다")
|
||||
fun shouldClampNegativeFollowIncreaseAndIncludePositiveFollowCandidate() {
|
||||
val negativeCreator = saveCreator("negative-follow-creator")
|
||||
val positiveCreator = saveCreator("positive-follow-creator")
|
||||
val user = saveUser("follow-user")
|
||||
val firstUnfollower = saveUser("first-unfollower")
|
||||
val secondUnfollower = saveUser("second-unfollower")
|
||||
saveUseCanCalculate(user, negativeCreator, CanUsage.DONATION, 1, UseCanCalculateStatus.RECEIVED, false, IN_PERIOD)
|
||||
saveFollowing(negativeCreator, firstUnfollower, isActive = false, START_AT.minusDays(3), IN_PERIOD)
|
||||
saveFollowing(negativeCreator, secondUnfollower, isActive = false, START_AT.minusDays(2), IN_PERIOD)
|
||||
saveFollowing(positiveCreator, user, isActive = true, IN_PERIOD, IN_PERIOD)
|
||||
flushAndClear()
|
||||
|
||||
val candidates = aggregate()
|
||||
|
||||
assertEquals(listOf(creator.id), candidates.map { it.creatorId })
|
||||
val candidate = candidates.single()
|
||||
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 })
|
||||
assertEquals(0, candidates.single { it.creatorId == negativeCreator.id }.followIncrease)
|
||||
assertEquals(1, candidates.single { it.creatorId == positiveCreator.id }.followIncrease)
|
||||
}
|
||||
|
||||
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 {
|
||||
return saveMember(nickname, MemberRole.CREATOR, profileImage, isActive)
|
||||
val candidate = aggregate().single()
|
||||
|
||||
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 {
|
||||
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(
|
||||
email = "$nickname@test.com",
|
||||
password = "password",
|
||||
nickname = nickname,
|
||||
profileImage = profileImage,
|
||||
profileImage = "$nickname.png",
|
||||
role = role,
|
||||
isActive = isActive
|
||||
)
|
||||
@@ -219,22 +234,6 @@ class DefaultCreatorRankingAggregationRepositoryTest @Autowired constructor(
|
||||
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(
|
||||
member: Member,
|
||||
creator: Member,
|
||||
@@ -255,49 +254,6 @@ class DefaultCreatorRankingAggregationRepositoryTest @Autowired constructor(
|
||||
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(
|
||||
creator: Member,
|
||||
member: Member,
|
||||
@@ -313,6 +269,87 @@ class DefaultCreatorRankingAggregationRepositoryTest @Autowired constructor(
|
||||
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) {
|
||||
entityManager.createNativeQuery(
|
||||
"update $tableName set created_at = :createdAt, updated_at = :updatedAt where id = :id"
|
||||
@@ -328,4 +365,10 @@ class DefaultCreatorRankingAggregationRepositoryTest @Autowired constructor(
|
||||
entityManager.flush()
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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, 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
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package kr.co.vividnext.sodalive.v2.ranking.adapter.out.persistence
|
||||
|
||||
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.port.out.CreatorRankingSnapshotRecord
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
@@ -14,345 +15,315 @@ import java.time.LocalDateTime
|
||||
@DataJpaTest(
|
||||
properties = [
|
||||
"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(
|
||||
private val adapter: DefaultCreatorRankingSnapshotRepository,
|
||||
private val repository: CreatorRankingSnapshotRepository
|
||||
) {
|
||||
private val adapter = DefaultCreatorRankingSnapshotRepository(repository)
|
||||
|
||||
@Test
|
||||
@DisplayName("같은 집계 기간의 스냅샷은 삭제 후 새 후보로 교체한다")
|
||||
fun shouldReplaceSnapshotsByAggregationPeriod() {
|
||||
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)
|
||||
)
|
||||
)
|
||||
|
||||
@DisplayName("replaceSnapshots는 rankNo, finalScore, scorePolicyVersion, scoreDetailJson을 저장하고 매핑한다")
|
||||
fun shouldReplaceSnapshotsAndMapV2SnapshotContractFields() {
|
||||
adapter.replaceSnapshots(
|
||||
rankingType = CreatorRankingType.WEEKLY,
|
||||
aggregationStartAtUtc = startAt,
|
||||
aggregationEndAtUtc = endAt,
|
||||
visibleFromAtUtc = endAt.plusHours(9),
|
||||
newSnapshots = listOf(snapshotRecord(creatorId = 3L, aggregationStartAtUtc = startAt, aggregationEndAtUtc = endAt))
|
||||
aggregationStartAtUtc = START,
|
||||
aggregationEndAtUtc = END,
|
||||
visibleFromAtUtc = VISIBLE,
|
||||
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()
|
||||
assertEquals(CreatorRankingType.WEEKLY, saved.rankingType)
|
||||
assertEquals(visibleFromAt, saved.visibleFromAtUtc)
|
||||
assertEquals(2L, saved.creatorId)
|
||||
assertEquals(2, saved.rankNo)
|
||||
assertEquals(88.5, saved.finalScore, 0.0001)
|
||||
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
|
||||
@DisplayName("최신 완료 주차 스냅샷은 최신 종료 시각 기준으로 최종 점수 내림차순 조회한다")
|
||||
fun shouldFindLatestSnapshotsByLatestAggregationEndAndFinalScoreDescending() {
|
||||
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)
|
||||
@DisplayName("findLatestSnapshots는 최신 종료 시각 스냅샷을 rank_no 오름차순으로 조회한다")
|
||||
fun shouldFindLatestSnapshotsOrderedByRankNoAsc() {
|
||||
repository.saveAll(
|
||||
listOf(
|
||||
snapshot(
|
||||
entity(
|
||||
creatorId = 1L,
|
||||
rankNo = 1,
|
||||
finalScore = 999.0,
|
||||
aggregationStartAtUtc = oldStartAt,
|
||||
aggregationEndAtUtc = oldEndAt
|
||||
aggregationStartAtUtc = PREVIOUS_START,
|
||||
aggregationEndAtUtc = PREVIOUS_END
|
||||
),
|
||||
snapshot(
|
||||
creatorId = 2L,
|
||||
finalScore = 100.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
|
||||
)
|
||||
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 latestSnapshots = adapter.findLatestSnapshots()
|
||||
|
||||
assertEquals(listOf(3L, 4L, 2L), latestSnapshots.map { it.creatorId })
|
||||
assertEquals(listOf(latestStartAt, latestStartAt, latestStartAt), latestSnapshots.map { it.aggregationStartAtUtc })
|
||||
assertEquals(listOf(latestEndAt, latestEndAt, latestEndAt), latestSnapshots.map { it.aggregationEndAtUtc })
|
||||
assertEquals(listOf(1, 2, 3), latestSnapshots.map { it.rankNo })
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("최신 공개 스냅샷은 visibleFromAt이 현재 시각 이하인 최신 노출 시각 기준으로 조회한다")
|
||||
fun shouldFindLatestVisibleSnapshotsByVisibleFromAt() {
|
||||
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)
|
||||
@DisplayName("findLatestVisibleSnapshots는 현재 공개 가능한 최신 visible_from_at 스냅샷을 rank_no 오름차순으로 조회한다")
|
||||
fun shouldFindLatestVisibleSnapshotsOrderedByRankNoAsc() {
|
||||
repository.saveAll(
|
||||
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,
|
||||
aggregationStartAtUtc = previousStartAt,
|
||||
aggregationEndAtUtc = previousEndAt,
|
||||
visibleFromAtUtc = LocalDateTime.of(2026, 6, 1, 0, 0),
|
||||
finalScore = 100.0
|
||||
rankNo = 1,
|
||||
aggregationStartAtUtc = OLDEST_START,
|
||||
aggregationEndAtUtc = OLDEST_END,
|
||||
visibleFromAtUtc = VISIBLE.minusWeeks(1)
|
||||
),
|
||||
snapshot(
|
||||
entity(
|
||||
creatorId = 2L,
|
||||
aggregationStartAtUtc = latestStartAt,
|
||||
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,
|
||||
rankNo = 3,
|
||||
finalScore = 300.0,
|
||||
aggregationStartAtUtc = previousStartAt,
|
||||
aggregationEndAtUtc = previousEndAt
|
||||
aggregationStartAtUtc = PREVIOUS_START,
|
||||
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(previousEndAt, previousEndAt), previousSnapshots.map { it.aggregationEndAtUtc })
|
||||
assertEquals(listOf(3L, 4L, 2L), previousVisible.map { it.creatorId })
|
||||
assertEquals(listOf(1, 2, 3), previousVisible.map { it.rankNo })
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("요청한 집계 기간에 스냅샷이 없으면 이전 주차를 대신 반환하지 않는다")
|
||||
fun shouldReturnEmptyWhenRequestedAggregationPeriodHasNoSnapshots() {
|
||||
val previousStartAt = LocalDateTime.of(2026, 5, 24, 15, 0)
|
||||
val previousEndAt = LocalDateTime.of(2026, 5, 31, 15, 0)
|
||||
val requestedStartAt = LocalDateTime.of(2026, 5, 31, 15, 0)
|
||||
val requestedEndAt = LocalDateTime.of(2026, 6, 7, 15, 0)
|
||||
repository.save(
|
||||
snapshot(
|
||||
@DisplayName("findPreviousCompletedSnapshots는 최신 완료 전 집계 종료 시각 스냅샷을 rank_no 오름차순으로 조회한다")
|
||||
fun shouldFindPreviousCompletedSnapshotsOrderedByRankNoAsc() {
|
||||
repository.saveAll(
|
||||
listOf(
|
||||
entity(creatorId = 1L, rankNo = 1, aggregationStartAtUtc = OLDEST_START, aggregationEndAtUtc = OLDEST_END),
|
||||
entity(
|
||||
creatorId = 2L,
|
||||
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,
|
||||
aggregationStartAtUtc = previousStartAt,
|
||||
aggregationEndAtUtc = previousEndAt
|
||||
rankNo = 1,
|
||||
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(
|
||||
aggregationStartAtUtc = requestedStartAt,
|
||||
aggregationEndAtUtc = requestedEndAt
|
||||
)
|
||||
val periodSnapshots = adapter.findSnapshotsByAggregationPeriod(START, END)
|
||||
|
||||
assertEquals(emptyList<CreatorRankingSnapshotRecord>(), snapshots)
|
||||
assertEquals(listOf(3L, 4L, 2L), periodSnapshots.map { it.creatorId })
|
||||
assertEquals(listOf(1, 2, 3), periodSnapshots.map { it.rankNo })
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("스냅샷 row가 하나도 없을 때만 테이블 완전 공백으로 판단한다")
|
||||
fun shouldReturnTrueOnlyWhenSnapshotTableHasNoRows() {
|
||||
assertEquals(true, adapter.isSnapshotTableEmpty())
|
||||
|
||||
repository.save(
|
||||
snapshot(
|
||||
@DisplayName("replaceSnapshots는 같은 rankingType과 집계 기간만 삭제하고 다른 기간은 유지한다")
|
||||
fun shouldReplaceOnlySameTypeAndAggregationPeriod() {
|
||||
repository.saveAll(
|
||||
listOf(
|
||||
entity(
|
||||
creatorId = 1L,
|
||||
aggregationStartAtUtc = LocalDateTime.of(2026, 5, 24, 15, 0),
|
||||
aggregationEndAtUtc = LocalDateTime.of(2026, 5, 31, 15, 0)
|
||||
rankNo = 1,
|
||||
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(
|
||||
rankingType = CreatorRankingType.WEEKLY,
|
||||
aggregationStartAtUtc = startAt,
|
||||
aggregationEndAtUtc = endAt,
|
||||
visibleFromAtUtc = endAt.plusHours(9),
|
||||
newSnapshots = candidates
|
||||
aggregationStartAtUtc = START,
|
||||
aggregationEndAtUtc = END,
|
||||
visibleFromAtUtc = VISIBLE,
|
||||
newSnapshots = listOf(record(creatorId = 4L, rankNo = 1))
|
||||
)
|
||||
|
||||
val latestSnapshots = adapter.findLatestSnapshots()
|
||||
assertEquals(22, latestSnapshots.size)
|
||||
assertEquals(setOf(20L, 21L, 22L), latestSnapshots.takeLast(3).map { it.creatorId }.toSet())
|
||||
assertEquals(listOf(2L, 3L, 4L), repository.findAll().map { it.creatorId }.sorted())
|
||||
}
|
||||
|
||||
private fun snapshot(
|
||||
private fun record(
|
||||
creatorId: Long,
|
||||
finalScore: Double = 100.0,
|
||||
aggregationStartAtUtc: LocalDateTime,
|
||||
aggregationEndAtUtc: LocalDateTime,
|
||||
visibleFromAtUtc: LocalDateTime = aggregationEndAtUtc.plusHours(9)
|
||||
): CreatorRankingSnapshot {
|
||||
return CreatorRankingSnapshot(
|
||||
rankingType = CreatorRankingType.WEEKLY,
|
||||
aggregationStartAtUtc = aggregationStartAtUtc,
|
||||
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)
|
||||
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 = "{}"
|
||||
): CreatorRankingSnapshotRecord {
|
||||
return CreatorRankingSnapshotRecord(
|
||||
rankingType = CreatorRankingType.WEEKLY,
|
||||
rankingType = rankingType,
|
||||
aggregationStartAtUtc = aggregationStartAtUtc,
|
||||
aggregationEndAtUtc = aggregationEndAtUtc,
|
||||
visibleFromAtUtc = visibleFromAtUtc,
|
||||
creatorId = creatorId,
|
||||
nickname = "creator-$creatorId",
|
||||
profileImageUrl = "profile-$creatorId.png",
|
||||
rankNo = rankNo,
|
||||
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
|
||||
scorePolicyVersion = scorePolicyVersion,
|
||||
scoreDetailJson = scoreDetailJson
|
||||
)
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,568 +1,152 @@
|
||||
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.port.out.CreatorRankingAggregationPort
|
||||
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.CreatorRankingSnapshotRecord
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
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.DisplayName
|
||||
import org.junit.jupiter.api.Test
|
||||
import org.junit.jupiter.api.extension.ExtendWith
|
||||
import org.mockito.Mockito
|
||||
import org.springframework.boot.test.system.CapturedOutput
|
||||
import org.springframework.boot.test.system.OutputCaptureExtension
|
||||
import org.springframework.transaction.annotation.Transactional
|
||||
import java.time.LocalDateTime
|
||||
import java.time.ZoneId
|
||||
import java.time.ZonedDateTime
|
||||
|
||||
@ExtendWith(OutputCaptureExtension::class)
|
||||
class CreatorRankingQueryServiceTest {
|
||||
@Test
|
||||
@DisplayName("스냅샷 후보와 조회 item 내부 모델은 순위 변화와 신규 진입 값을 담을 수 있다")
|
||||
fun shouldCreateRankingDomainModelsForLaterQueryService() {
|
||||
val candidate = CreatorRankingSnapshotCandidate(
|
||||
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)
|
||||
@DisplayName("fallback refresh 후 재조회가 같은 read-only transaction에 묶이지 않도록 조회 메서드에는 Transactional이 없다")
|
||||
fun shouldNotWrapQueryMethodWithTransactional() {
|
||||
val method = CreatorRankingQueryService::class.java.getDeclaredMethod("getCreatorRankings", java.lang.Long::class.java)
|
||||
|
||||
assertEquals(1L, candidate.creatorId)
|
||||
assertEquals(100.0, candidate.finalScore, 0.0001)
|
||||
assertNull(item.rankChange)
|
||||
assertTrue(item.isNew)
|
||||
assertEquals(-1, fallenItem.rankChange)
|
||||
assertFalse(fallenItem.isNew)
|
||||
assertFalse(method.isAnnotationPresent(Transactional::class.java))
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("최신 완료 주차 스냅샷이 없으면 순위 변화 비노출과 빈 목록을 반환한다")
|
||||
fun shouldReturnEmptyResultWhenLatestSnapshotsDoNotExist() {
|
||||
@DisplayName("최신 공개 스냅샷이 없으면 fallback refresh 후 재조회한 스냅샷을 응답한다")
|
||||
fun shouldRefreshByFallbackAndRequeryWhenLatestSnapshotsEmpty() {
|
||||
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)
|
||||
|
||||
Mockito.verify(jobService).refreshLastCompletedWeekByFallback()
|
||||
assertEquals(2, snapshotPort.latestCallCount)
|
||||
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(0, aggregationPort.aggregateCallCount)
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("최신 스냅샷이 없고 스냅샷 테이블이 완전히 비어 있으면 cold-start fallback을 반환한다")
|
||||
fun shouldUseColdStartFallbackOnlyWhenSnapshotTableIsEmpty() {
|
||||
@DisplayName("fallback 실패 또는 재조회 결과 없음은 빈 목록 성공으로 반환한다")
|
||||
fun shouldReturnEmptyWhenFallbackFailsAndRequeryStillEmpty() {
|
||||
val snapshotPort = FakeCreatorRankingQuerySnapshotPort()
|
||||
val aggregationPort = FakeCreatorRankingQueryAggregationPort()
|
||||
val snapshotJobService = Mockito.mock(CreatorRankingSnapshotJobService::class.java)
|
||||
snapshotPort.snapshotTableEmpty = true
|
||||
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 jobService = Mockito.mock(CreatorRankingSnapshotJobService::class.java)
|
||||
Mockito.doThrow(IllegalStateException("fallback failed")).`when`(jobService).refreshLastCompletedWeekByFallback()
|
||||
val service = service(snapshotPort = snapshotPort, snapshotJobService = jobService)
|
||||
|
||||
val result = service.getCreatorRankings(viewerMemberId = null)
|
||||
|
||||
assertFalse(result.showRankChange)
|
||||
assertTrue(result.items.isEmpty())
|
||||
Mockito.verify(snapshotJobService, Mockito.never()).ensureLastCompletedWeekSnapshotForColdStart()
|
||||
assertEquals(2, snapshotPort.latestCallCount)
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("최신 스냅샷이 없어도 과거 스냅샷 row가 있으면 cold-start fallback을 호출하지 않는다")
|
||||
fun shouldNotUseColdStartFallbackWhenAnyHistoricalSnapshotExists() {
|
||||
@DisplayName("최신 공개 스냅샷이 있으면 fallback 없이 rankNo 기준 rank/rankChange/isNew를 계산한다")
|
||||
fun shouldUseRankNoForRankChangeWithoutFallback() {
|
||||
val snapshotPort = FakeCreatorRankingQuerySnapshotPort()
|
||||
val aggregationPort = FakeCreatorRankingQueryAggregationPort()
|
||||
val snapshotJobService = Mockito.mock(CreatorRankingSnapshotJobService::class.java)
|
||||
snapshotPort.snapshotTableEmpty = false
|
||||
aggregationPort.candidates = listOf(candidate(creatorId = 1L))
|
||||
val service = service(
|
||||
snapshotPort = snapshotPort,
|
||||
aggregationPort = aggregationPort,
|
||||
snapshotJobService = snapshotJobService
|
||||
val jobService = Mockito.mock(CreatorRankingSnapshotJobService::class.java)
|
||||
snapshotPort.latestResponses = ArrayDeque(
|
||||
listOf(listOf(snapshot(2, rankNo = 1), snapshot(1, rankNo = 2), snapshot(3, rankNo = 3)))
|
||||
)
|
||||
snapshotPort.previousSnapshots = listOf(snapshot(1, rankNo = 1), snapshot(2, rankNo = 2))
|
||||
val service = service(snapshotPort = snapshotPort, snapshotJobService = jobService)
|
||||
|
||||
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("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)
|
||||
|
||||
Mockito.verify(jobService, Mockito.never()).refreshLastCompletedWeekByFallback()
|
||||
assertTrue(result.showRankChange)
|
||||
assertEquals(listOf(2L, 1L, 3L, 4L), result.items.map { it.creatorId })
|
||||
assertEquals(listOf(1, 2, 3, 4), result.items.map { it.rank })
|
||||
assertEquals(listOf(1, -1, 0, null), result.items.map { it.rankChange })
|
||||
assertEquals(listOf(false, false, false, true), result.items.map { it.isNew })
|
||||
assertEquals(listOf(2L, 1L, 3L), result.items.map { it.creatorId })
|
||||
assertEquals(listOf(1, 2, 3), result.items.map { it.rank })
|
||||
assertEquals(listOf(1, -1, null), result.items.map { it.rankChange })
|
||||
assertEquals(listOf(false, false, true), result.items.map { it.isNew })
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("조회 서비스는 현재 UTC 시각 기준 최신 공개 스냅샷과 직전 공개 스냅샷으로 순위 변화를 계산한다")
|
||||
fun shouldUseLatestVisibleSnapshotsAndPreviousVisibleSnapshots() {
|
||||
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() {
|
||||
@DisplayName("차단 관계가 있으면 row를 제거하지 않고 식별 정보만 마스킹한다")
|
||||
fun shouldMaskBlockedCreatorWithoutRemovingRow() {
|
||||
val snapshotPort = FakeCreatorRankingQuerySnapshotPort()
|
||||
val blockPort = FakeCreatorRankingBlockPort()
|
||||
snapshotPort.latestSnapshots = listOf(
|
||||
snapshot(creatorId = 1L, finalScore = 300.0),
|
||||
snapshot(creatorId = 2L, finalScore = 200.0)
|
||||
)
|
||||
snapshotPort.previousSnapshots = listOf(
|
||||
snapshot(creatorId = 1L, finalScore = 300.0),
|
||||
snapshot(creatorId = 2L, finalScore = 200.0)
|
||||
)
|
||||
snapshotPort.latestResponses = ArrayDeque(listOf(listOf(snapshot(1, rankNo = 1), snapshot(2, rankNo = 2))))
|
||||
snapshotPort.previousSnapshots = listOf(snapshot(1, rankNo = 1), snapshot(2, rankNo = 2))
|
||||
blockPort.blockedCreatorIds = setOf(2L)
|
||||
val service = service(snapshotPort = snapshotPort, blockPort = blockPort)
|
||||
|
||||
val result = service.getCreatorRankings(viewerMemberId = 99L)
|
||||
|
||||
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("", result.items[1].nickname)
|
||||
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(
|
||||
snapshotPort: CreatorRankingSnapshotPort = FakeCreatorRankingQuerySnapshotPort(),
|
||||
blockPort: CreatorRankingBlockPort = FakeCreatorRankingBlockPort(),
|
||||
aggregationPort: CreatorRankingAggregationPort = FakeCreatorRankingQueryAggregationPort(),
|
||||
snapshotJobService: CreatorRankingSnapshotJobService = Mockito.mock(CreatorRankingSnapshotJobService::class.java),
|
||||
now: ZonedDateTime = ZonedDateTime.of(2026, 6, 8, 9, 0, 0, 0, ZoneId.of("Asia/Seoul"))
|
||||
): CreatorRankingQueryService {
|
||||
return CreatorRankingQueryService(
|
||||
snapshotPort = snapshotPort,
|
||||
blockPort = blockPort,
|
||||
aggregationPort = aggregationPort,
|
||||
snapshotJobService = snapshotJobService,
|
||||
nowProvider = { now },
|
||||
cloudFrontHost = "https://cdn.test"
|
||||
)
|
||||
}
|
||||
) = CreatorRankingQueryService(snapshotPort, blockPort, snapshotJobService, { now }, "https://cdn.test")
|
||||
|
||||
private fun candidate(
|
||||
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 {
|
||||
private fun snapshot(creatorId: Long, rankNo: Int): CreatorRankingSnapshotRecord {
|
||||
return CreatorRankingSnapshotRecord(
|
||||
rankingType = CreatorRankingType.WEEKLY,
|
||||
aggregationStartAtUtc = LocalDateTime.of(2026, 5, 31, 15, 0, 0),
|
||||
aggregationEndAtUtc = LocalDateTime.of(2026, 6, 7, 15, 0, 0),
|
||||
visibleFromAtUtc = LocalDateTime.of(2026, 6, 8, 0, 0, 0),
|
||||
aggregationStartAtUtc = LocalDateTime.of(2026, 5, 31, 15, 0),
|
||||
aggregationEndAtUtc = LocalDateTime.of(2026, 6, 7, 15, 0),
|
||||
visibleFromAtUtc = LocalDateTime.of(2026, 6, 8, 0, 0),
|
||||
creatorId = creatorId,
|
||||
nickname = "creator-$creatorId",
|
||||
profileImageUrl = "profile-$creatorId.png",
|
||||
finalScore = finalScore,
|
||||
contentLiveScore = 0.0,
|
||||
engagementScore = 0.0,
|
||||
supportScore = 0.0,
|
||||
fanLoyaltyScore = 0.0,
|
||||
liveCanAmount = 0,
|
||||
contentPurchaseCanAmount = 0,
|
||||
contentLikeCount = 0,
|
||||
contentCommentCount = 0,
|
||||
channelDonationCanAmount = 0,
|
||||
channelDonationCount = 0,
|
||||
fanTalkCount = 0,
|
||||
finalFollowerCount = 0,
|
||||
followIncrease = 0
|
||||
rankNo = rankNo,
|
||||
finalScore = (100 - rankNo).toDouble(),
|
||||
scorePolicyVersion = "CREATOR_WEEKLY_POPULARITY_V2",
|
||||
scoreDetailJson = "{}"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private class FakeCreatorRankingQuerySnapshotPort : CreatorRankingSnapshotPort {
|
||||
var latestSnapshots: List<CreatorRankingSnapshotRecord> = emptyList()
|
||||
var latestResponses: ArrayDeque<List<CreatorRankingSnapshotRecord>> = ArrayDeque(listOf(emptyList()))
|
||||
var previousSnapshots: List<CreatorRankingSnapshotRecord> = emptyList()
|
||||
var latestFailure: RuntimeException? = null
|
||||
var snapshotTableEmpty: Boolean = true
|
||||
var latestRankingType: CreatorRankingType? = null
|
||||
var latestNowUtc: LocalDateTime? = null
|
||||
var previousRankingType: CreatorRankingType? = null
|
||||
var previousCurrentAggregationStartAtUtc: LocalDateTime? = null
|
||||
var previousNowUtc: LocalDateTime? = null
|
||||
|
||||
var latestCallCount = 0
|
||||
override fun findSnapshotsByAggregationPeriod(
|
||||
aggregationStartAtUtc: LocalDateTime,
|
||||
aggregationEndAtUtc: LocalDateTime
|
||||
): List<CreatorRankingSnapshotRecord> = emptyList()
|
||||
) = emptyList<CreatorRankingSnapshotRecord>()
|
||||
|
||||
override fun findLatestSnapshots(): List<CreatorRankingSnapshotRecord> {
|
||||
latestFailure?.let { throw it }
|
||||
return latestSnapshots
|
||||
}
|
||||
override fun findLatestSnapshots() = latestResponses.firstOrNull() ?: emptyList()
|
||||
|
||||
override fun findPreviousCompletedSnapshots(): List<CreatorRankingSnapshotRecord> = previousSnapshots
|
||||
override fun findPreviousCompletedSnapshots() = previousSnapshots
|
||||
|
||||
override fun findLatestVisibleSnapshots(
|
||||
rankingType: CreatorRankingType,
|
||||
nowUtc: LocalDateTime
|
||||
): List<CreatorRankingSnapshotRecord> {
|
||||
latestFailure?.let { throw it }
|
||||
latestRankingType = rankingType
|
||||
latestNowUtc = nowUtc
|
||||
return latestSnapshots
|
||||
latestCallCount++
|
||||
return if (latestResponses.size > 1) {
|
||||
latestResponses.removeFirst()
|
||||
} else {
|
||||
latestResponses.firstOrNull() ?: emptyList()
|
||||
}
|
||||
}
|
||||
|
||||
override fun findPreviousVisibleSnapshots(
|
||||
rankingType: CreatorRankingType,
|
||||
currentAggregationStartAtUtc: LocalDateTime,
|
||||
nowUtc: LocalDateTime
|
||||
): List<CreatorRankingSnapshotRecord> {
|
||||
previousRankingType = rankingType
|
||||
previousCurrentAggregationStartAtUtc = currentAggregationStartAtUtc
|
||||
previousNowUtc = nowUtc
|
||||
return previousSnapshots
|
||||
}
|
||||
|
||||
override fun isSnapshotTableEmpty(): Boolean = snapshotTableEmpty
|
||||
|
||||
) = previousSnapshots
|
||||
override fun replaceSnapshots(
|
||||
rankingType: CreatorRankingType,
|
||||
aggregationStartAtUtc: LocalDateTime,
|
||||
@@ -572,33 +156,7 @@ private class FakeCreatorRankingQuerySnapshotPort : CreatorRankingSnapshotPort {
|
||||
) = 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 {
|
||||
var blockedCreatorIds: Set<Long> = emptySet()
|
||||
var memberId: Long? = null
|
||||
var creatorIds: Set<Long> = emptySet()
|
||||
|
||||
override fun findBlockedCreatorIds(memberId: Long, creatorIds: Collection<Long>): Set<Long> {
|
||||
this.memberId = memberId
|
||||
this.creatorIds = creatorIds.toSet()
|
||||
return blockedCreatorIds
|
||||
}
|
||||
override fun findBlockedCreatorIds(memberId: Long, creatorIds: Collection<Long>) = blockedCreatorIds
|
||||
}
|
||||
|
||||
@@ -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.CreatorRankingSnapshotJobTrigger
|
||||
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.assertTrue
|
||||
import org.junit.jupiter.api.DisplayName
|
||||
import org.junit.jupiter.api.Test
|
||||
import org.junit.jupiter.api.extension.ExtendWith
|
||||
import org.mockito.Mockito
|
||||
import org.redisson.api.RLock
|
||||
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.TransactionDefinition
|
||||
import org.springframework.transaction.TransactionStatus
|
||||
import org.springframework.transaction.support.SimpleTransactionStatus
|
||||
import java.time.LocalDateTime
|
||||
import java.time.ZoneId
|
||||
import java.time.ZonedDateTime
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
@ExtendWith(OutputCaptureExtension::class)
|
||||
class CreatorRankingSnapshotJobServiceTest {
|
||||
@Test
|
||||
@DisplayName("스케줄 실행은 집계 기간을 포함한 SCHEDULED job을 생성하고 성공 시 DONE으로 기록한다")
|
||||
fun shouldCreateScheduledJobAndMarkDoneWhenRefreshSucceeds() {
|
||||
@DisplayName("fallback은 FALLBACK job을 PENDING -> PROCESSING -> DONE으로 전이하고 refresh를 실행한다")
|
||||
fun shouldRefreshByFallbackWithJobStatusTransitions() {
|
||||
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 }
|
||||
val service = service(refreshService, jobPort)
|
||||
|
||||
service.refreshLastCompletedWeekByScheduledJob()
|
||||
val refreshed = service.refreshLastCompletedWeekByFallback()
|
||||
|
||||
val job = jobPort.jobs.single()
|
||||
assertEquals(CreatorRankingType.WEEKLY, job.rankingType)
|
||||
assertEquals(LocalDateTime.of(2026, 5, 31, 15, 0), job.aggregationStartAtUtc)
|
||||
assertEquals(LocalDateTime.of(2026, 6, 7, 15, 0), job.aggregationEndAtUtc)
|
||||
assertEquals(LocalDateTime.of(2026, 6, 8, 0, 0), job.visibleFromAtUtc)
|
||||
assertEquals(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)
|
||||
assertTrue(refreshed)
|
||||
assertEquals(CreatorRankingSnapshotJobTrigger.FALLBACK, jobPort.jobs.single().trigger)
|
||||
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
|
||||
@DisplayName("스케줄 job refresh는 기간 기반 lock 획득 실패 시 job 생성과 refresh를 건너뛴다")
|
||||
fun shouldSkipScheduledJobRefreshWhenPeriodLockNotAcquired() {
|
||||
@DisplayName("fallback job이 같은 기간에 3회 이상이면 refresh와 job 생성을 건너뛴다")
|
||||
fun shouldSkipFallbackWhenFallbackLimitReached() {
|
||||
val refreshService = Mockito.mock(CreatorRankingSnapshotRefreshService::class.java)
|
||||
val jobPort = FakeCreatorRankingSnapshotJobPort()
|
||||
val redissonClient = periodLockRedissonClient(lockAcquired = false)
|
||||
val now = ZonedDateTime.of(2026, 6, 8, 7, 30, 0, 0, ZoneId.of("Asia/Seoul"))
|
||||
val service = CreatorRankingSnapshotJobService(refreshService, jobPort, redissonClient, transactionManager()) { now }
|
||||
jobPort.fallbackCount = 3
|
||||
val service = service(refreshService, jobPort)
|
||||
|
||||
service.refreshLastCompletedWeekByScheduledJob()
|
||||
val refreshed = service.refreshLastCompletedWeekByFallback()
|
||||
|
||||
assertFalse(refreshed)
|
||||
assertTrue(jobPort.jobs.isEmpty())
|
||||
Mockito.verify(refreshService, Mockito.never()).refreshLastCompletedWeek(now)
|
||||
Mockito.verifyNoInteractions(refreshService)
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("기간 기반 lock은 스냅샷 refresh transaction commit 이후 해제한다")
|
||||
fun shouldUnlockPeriodLockAfterRefreshTransactionCommit() {
|
||||
@DisplayName("fallback refresh 실패는 job을 FAILED로 남기고 예외를 전파한다")
|
||||
fun shouldMarkFailedWhenFallbackRefreshFails() {
|
||||
val refreshService = Mockito.mock(CreatorRankingSnapshotRefreshService::class.java)
|
||||
val jobPort = FakeCreatorRankingSnapshotJobPort()
|
||||
val redissonClient = Mockito.mock(RedissonClient::class.java)
|
||||
val lock = Mockito.mock(RLock::class.java)
|
||||
val transactionManager = Mockito.mock(PlatformTransactionManager::class.java)
|
||||
val transactionStatus = SimpleTransactionStatus()
|
||||
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 }
|
||||
Mockito.doThrow(IllegalStateException("refresh failed")).`when`(refreshService).refreshLastCompletedWeek(
|
||||
anyZonedDateTime()
|
||||
)
|
||||
val service = service(refreshService, jobPort)
|
||||
|
||||
service.refreshLastCompletedWeekByScheduledJob()
|
||||
val exception = assertThrows(IllegalStateException::class.java) { service.refreshLastCompletedWeekByFallback() }
|
||||
|
||||
val inOrder = Mockito.inOrder(transactionManager, lock)
|
||||
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("refresh failed", exception.message)
|
||||
assertEquals(CreatorRankingSnapshotJobStatus.FAILED, jobPort.jobs.single().status)
|
||||
assertEquals("aggregate 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)
|
||||
assertEquals("refresh failed", jobPort.jobs.single().lastError)
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("cold-start 스냅샷 생성은 기간 기반 lock 획득 시에만 refresh를 실행한다")
|
||||
fun shouldRefreshColdStartSnapshotOnlyWhenPeriodLockAcquired() {
|
||||
@DisplayName("period lock 획득 실패는 fallback을 정상 skip한다")
|
||||
fun shouldSkipFallbackWhenLockNotAcquired() {
|
||||
val refreshService = Mockito.mock(CreatorRankingSnapshotRefreshService::class.java)
|
||||
val jobPort = FakeCreatorRankingSnapshotJobPort()
|
||||
val redissonClient = Mockito.mock(RedissonClient::class.java)
|
||||
val lock = Mockito.mock(RLock::class.java)
|
||||
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)
|
||||
val redissonClient = Mockito.mock(RedissonClient::class.java)
|
||||
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.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 lock = Mockito.mock(RLock::class.java)
|
||||
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)
|
||||
Mockito.`when`(redissonClient.getLock(Mockito.anyString())).thenReturn(lock)
|
||||
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 {
|
||||
val jobs = mutableListOf<CreatorRankingSnapshotJobRecord>()
|
||||
var fallbackCount = 0L
|
||||
private var nextId = 1L
|
||||
|
||||
override fun save(job: CreatorRankingSnapshotJobRecord): CreatorRankingSnapshotJobRecord {
|
||||
val saved = job.copy(id = job.id ?: nextId++)
|
||||
val saved = job.copy(id = nextId++)
|
||||
jobs.add(saved)
|
||||
return saved
|
||||
}
|
||||
|
||||
override fun findById(jobId: Long): CreatorRankingSnapshotJobRecord? {
|
||||
return jobs.firstOrNull { it.id == jobId }
|
||||
}
|
||||
override fun findById(jobId: Long) = jobs.firstOrNull { it.id == jobId }
|
||||
|
||||
override fun findByPeriodAndStatuses(
|
||||
aggregationStartAtUtc: LocalDateTime,
|
||||
aggregationEndAtUtc: LocalDateTime,
|
||||
statuses: List<CreatorRankingSnapshotJobStatus>
|
||||
): List<CreatorRankingSnapshotJobRecord> {
|
||||
return jobs.filter {
|
||||
it.aggregationStartAtUtc == aggregationStartAtUtc &&
|
||||
it.aggregationEndAtUtc == aggregationEndAtUtc &&
|
||||
it.status in statuses
|
||||
}
|
||||
}
|
||||
) = jobs.filter { it.status in statuses }
|
||||
|
||||
override fun markProcessing(jobId: Long, processingStartedAt: LocalDateTime): CreatorRankingSnapshotJobRecord? {
|
||||
return update(jobId) {
|
||||
override fun countByRankingTypeAndPeriodAndTrigger(
|
||||
rankingType: CreatorRankingType,
|
||||
aggregationStartAtUtc: LocalDateTime,
|
||||
aggregationEndAtUtc: LocalDateTime,
|
||||
trigger: CreatorRankingSnapshotJobTrigger
|
||||
) = fallbackCount
|
||||
|
||||
override fun markProcessing(jobId: Long, processingStartedAt: LocalDateTime) = update(jobId) {
|
||||
it.copy(
|
||||
status = CreatorRankingSnapshotJobStatus.PROCESSING,
|
||||
processingStartedAt = processingStartedAt
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
override fun markDone(jobId: Long, processedAt: LocalDateTime): CreatorRankingSnapshotJobRecord? {
|
||||
return update(jobId) {
|
||||
override fun markDone(jobId: Long, processedAt: LocalDateTime) = update(jobId) {
|
||||
it.copy(
|
||||
status = CreatorRankingSnapshotJobStatus.DONE,
|
||||
processedAt = processedAt,
|
||||
lastError = null
|
||||
processedAt = processedAt
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
override fun markFailed(jobId: Long, processedAt: LocalDateTime, lastError: String?): CreatorRankingSnapshotJobRecord? {
|
||||
return update(jobId) {
|
||||
override fun markFailed(
|
||||
jobId: Long,
|
||||
processedAt: LocalDateTime,
|
||||
lastError: String?
|
||||
) = update(jobId) {
|
||||
it.copy(
|
||||
status = CreatorRankingSnapshotJobStatus.FAILED,
|
||||
processedAt = processedAt,
|
||||
lastError = lastError
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
override fun markPending(jobId: Long): CreatorRankingSnapshotJobRecord? {
|
||||
return update(jobId) {
|
||||
override fun markPending(jobId: Long) = update(jobId) {
|
||||
it.copy(
|
||||
status = CreatorRankingSnapshotJobStatus.PENDING,
|
||||
lastError = null,
|
||||
processingStartedAt = null,
|
||||
processedAt = null
|
||||
processedAt = null,
|
||||
processingStartedAt = null
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun update(
|
||||
jobId: Long,
|
||||
updater: (CreatorRankingSnapshotJobRecord) -> CreatorRankingSnapshotJobRecord
|
||||
mapper: (CreatorRankingSnapshotJobRecord) -> CreatorRankingSnapshotJobRecord
|
||||
): CreatorRankingSnapshotJobRecord? {
|
||||
val index = jobs.indexOfFirst { it.id == jobId }
|
||||
if (index < 0) return null
|
||||
val updated = updater(jobs[index])
|
||||
jobs[index] = updated
|
||||
return updated
|
||||
jobs[index] = mapper(jobs[index])
|
||||
return jobs[index]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package kr.co.vividnext.sodalive.v2.ranking.application
|
||||
|
||||
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.CreatorRankingType
|
||||
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.CreatorRankingSnapshotRecord
|
||||
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.Test
|
||||
import org.junit.jupiter.api.extension.ExtendWith
|
||||
import org.mockito.Mockito
|
||||
import org.springframework.boot.test.system.CapturedOutput
|
||||
import org.springframework.boot.test.system.OutputCaptureExtension
|
||||
import org.springframework.transaction.support.TransactionSynchronizationManager
|
||||
import java.time.LocalDateTime
|
||||
import java.time.ZoneId
|
||||
import java.time.ZonedDateTime
|
||||
|
||||
@ExtendWith(OutputCaptureExtension::class)
|
||||
class CreatorRankingSnapshotRefreshServiceTest {
|
||||
@Test
|
||||
@DisplayName("주간 스냅샷 생성은 KST 지난 주를 UTC 조회 기간으로 변환하고 raw 지표 점수를 다시 계산해 저장한다")
|
||||
fun shouldRefreshLastCompletedWeekWithUtcRangeAndCalculatedScores() {
|
||||
@DisplayName("주간 스냅샷 생성은 SQL rank 의미의 V2 점수로 정렬하고 rankNo와 score detail을 저장한다")
|
||||
fun shouldRefreshWithRankBasedV2Score() {
|
||||
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"))
|
||||
val service = service(aggregationPort, snapshotPort)
|
||||
aggregationPort.candidates = listOf(
|
||||
candidate(
|
||||
creatorId = 1L,
|
||||
finalScore = 1.0,
|
||||
liveCanAmount = 100,
|
||||
contentPurchaseCanAmount = 50,
|
||||
contentLikeCount = 10,
|
||||
contentCommentCount = 4,
|
||||
channelDonationCanAmount = 30,
|
||||
channelDonationCount = 6,
|
||||
fanTalkCount = 3,
|
||||
finalFollowerCount = 20,
|
||||
followIncrease = -2
|
||||
)
|
||||
candidate(1, debut = LocalDateTime.of(2026, 6, 1, 0, 0), live = 100, content = 10, follow = -3, ai = 0),
|
||||
candidate(2, debut = LocalDateTime.of(2026, 6, 2, 0, 0), live = 100, content = 0, follow = 5, ai = 10),
|
||||
candidate(3, debut = null, live = 0, content = 50, follow = 0, ai = 0)
|
||||
)
|
||||
|
||||
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, 0), aggregationPort.startInclusiveUtc)
|
||||
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, 5, 31, 15, 0), aggregationPort.startInclusiveUtc)
|
||||
assertEquals(LocalDateTime.of(2026, 6, 7, 15, 0), aggregationPort.endExclusiveUtc)
|
||||
assertEquals(LocalDateTime.of(2026, 6, 8, 0, 0), snapshotPort.visibleFromAtUtc)
|
||||
assertEquals(85.0, stored.contentLiveScore, 0.0001)
|
||||
assertEquals(7.0, stored.engagementScore, 0.0001)
|
||||
assertEquals(19.8, stored.supportScore, 0.0001)
|
||||
assertEquals(13.4, stored.fanLoyaltyScore, 0.0001)
|
||||
assertEquals(38.14, stored.finalScore, 0.0001)
|
||||
assertEquals(listOf(2L, 1L, 3L), snapshotPort.snapshots.map { it.creatorId })
|
||||
assertEquals(listOf(1, 2, 3), snapshotPort.snapshots.map { it.rankNo })
|
||||
assertEquals(CreatorRankingScoreSpec.SCORE_POLICY_VERSION, snapshotPort.snapshots.first().scorePolicyVersion)
|
||||
assertTrue(snapshotPort.snapshots.first().scoreDetailJson.contains("\"aiChat\""))
|
||||
assertTrue(snapshotPort.snapshots[1].scoreDetailJson.contains("\"followIncrease\":{\"rawValue\":0"))
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("주간 스냅샷 생성은 20위 점수 경계와 동점인 후보를 모두 저장하고 더 낮은 점수는 제외한다")
|
||||
fun shouldStoreAllCandidatesTiedAtTwentiethScoreBoundary() {
|
||||
@DisplayName("주간 스냅샷 생성은 최종 점수 0 후보를 제외하고 정확히 상위 20명만 저장한다")
|
||||
fun shouldStoreExactlyTopTwentyPositiveScores() {
|
||||
val aggregationPort = FakeCreatorRankingAggregationPort()
|
||||
val snapshotPort = FakeCreatorRankingSnapshotPort()
|
||||
val service = service(aggregationPort = aggregationPort, snapshotPort = snapshotPort)
|
||||
aggregationPort.candidates = (1L..19L).map { candidate(creatorId = it, liveCanAmount = 1_000 - it) } +
|
||||
candidate(creatorId = 20L, liveCanAmount = 500) +
|
||||
candidate(creatorId = 21L, liveCanAmount = 500) +
|
||||
candidate(creatorId = 22L, liveCanAmount = 500) +
|
||||
candidate(creatorId = 23L, liveCanAmount = 499)
|
||||
val service = service(aggregationPort, snapshotPort)
|
||||
aggregationPort.candidates = (1L..25L).map { candidate(it, live = 1_000 - it) } + candidate(100L)
|
||||
|
||||
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
|
||||
@DisplayName("주간 스냅샷 생성은 같은 집계 기간을 다시 생성할 때 기존 row를 교체한다")
|
||||
fun shouldReplaceSnapshotsForSameAggregationPeriod() {
|
||||
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() {
|
||||
@DisplayName("주간 스냅샷 저장 후 홈 팔로잉 최근 소식 발행 순위는 rankNo를 사용한다")
|
||||
fun shouldPublishFollowingNewsWithRankNo() {
|
||||
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)
|
||||
)
|
||||
val service = service(aggregationPort, snapshotPort, publishService)
|
||||
aggregationPort.candidates = listOf(candidate(1, live = 10), candidate(2, live = 20))
|
||||
|
||||
service.refreshLastCompletedWeek(ZonedDateTime.of(2026, 6, 8, 6, 0, 0, 0, ZoneId.of("Asia/Seoul")))
|
||||
|
||||
Mockito.verify(publishService).publishCreatorRankingVisible(
|
||||
creatorId = 1L,
|
||||
creatorNickname = "creator-1",
|
||||
creatorProfileImagePath = "profile-1.png",
|
||||
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 = 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(
|
||||
aggregationPort: CreatorRankingAggregationPort = FakeCreatorRankingAggregationPort(),
|
||||
snapshotPort: CreatorRankingSnapshotPort = FakeCreatorRankingSnapshotPort(),
|
||||
aggregationPort: CreatorRankingAggregationPort,
|
||||
snapshotPort: CreatorRankingSnapshotPort,
|
||||
publishService: HomeFollowingNewsPublishService = Mockito.mock(HomeFollowingNewsPublishService::class.java)
|
||||
): CreatorRankingSnapshotRefreshService {
|
||||
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
|
||||
}
|
||||
) = CreatorRankingSnapshotRefreshService(aggregationPort, snapshotPort, publishService)
|
||||
|
||||
private fun candidate(
|
||||
creatorId: Long,
|
||||
finalScore: Double = 0.0,
|
||||
liveCanAmount: Long = 0,
|
||||
contentPurchaseCanAmount: Long = 0,
|
||||
contentLikeCount: Long = 0,
|
||||
contentCommentCount: Long = 0,
|
||||
channelDonationCanAmount: Long = 0,
|
||||
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
|
||||
)
|
||||
}
|
||||
id: Long,
|
||||
debut: LocalDateTime? = null,
|
||||
live: Long = 0,
|
||||
content: Long = 0,
|
||||
follow: Long = 0,
|
||||
ai: Long = 0
|
||||
) = CreatorRankingSnapshotCandidate(id, "creator-$id", "profile-$id.png", debut, live, content, follow, ai)
|
||||
}
|
||||
|
||||
private class FakeCreatorRankingAggregationPort : CreatorRankingAggregationPort {
|
||||
var candidates: List<CreatorRankingSnapshotCandidate> = emptyList()
|
||||
var lowScoreExcludedCount: Int = 0
|
||||
var failure: RuntimeException? = null
|
||||
var startInclusiveUtc: LocalDateTime? = null
|
||||
var endExclusiveUtc: LocalDateTime? = null
|
||||
|
||||
override fun aggregateCandidates(
|
||||
startInclusiveUtc: LocalDateTime,
|
||||
endExclusiveUtc: LocalDateTime
|
||||
): List<CreatorRankingSnapshotCandidate> {
|
||||
this.startInclusiveUtc = startInclusiveUtc
|
||||
this.endExclusiveUtc = endExclusiveUtc
|
||||
failure?.let { throw it }
|
||||
return candidates
|
||||
}
|
||||
override fun aggregateCandidates(startInclusiveUtc: LocalDateTime, endExclusiveUtc: LocalDateTime) = candidates
|
||||
|
||||
override fun aggregateCandidateResult(
|
||||
startInclusiveUtc: LocalDateTime,
|
||||
@@ -344,48 +107,25 @@ private class FakeCreatorRankingAggregationPort : CreatorRankingAggregationPort
|
||||
): CreatorRankingAggregationResult {
|
||||
this.startInclusiveUtc = startInclusiveUtc
|
||||
this.endExclusiveUtc = endExclusiveUtc
|
||||
failure?.let { throw it }
|
||||
return CreatorRankingAggregationResult(
|
||||
candidates = candidates,
|
||||
lowScoreExcludedCount = lowScoreExcludedCount
|
||||
)
|
||||
return CreatorRankingAggregationResult(candidates, 0)
|
||||
}
|
||||
}
|
||||
|
||||
private class FakeCreatorRankingSnapshotPort : CreatorRankingSnapshotPort {
|
||||
val snapshots = mutableListOf<CreatorRankingSnapshotRecord>()
|
||||
var rankingType: CreatorRankingType? = null
|
||||
var aggregationStartAtUtc: LocalDateTime? = null
|
||||
var aggregationEndAtUtc: LocalDateTime? = null
|
||||
var visibleFromAtUtc: LocalDateTime? = null
|
||||
var failure: RuntimeException? = null
|
||||
|
||||
override fun findSnapshotsByAggregationPeriod(
|
||||
aggregationStartAtUtc: LocalDateTime,
|
||||
aggregationEndAtUtc: LocalDateTime
|
||||
): List<CreatorRankingSnapshotRecord> {
|
||||
return snapshots.filter {
|
||||
it.aggregationStartAtUtc == aggregationStartAtUtc && it.aggregationEndAtUtc == aggregationEndAtUtc
|
||||
}
|
||||
}
|
||||
|
||||
override fun findLatestSnapshots(): List<CreatorRankingSnapshotRecord> = snapshots
|
||||
|
||||
override fun findPreviousCompletedSnapshots(): List<CreatorRankingSnapshotRecord> = snapshots
|
||||
|
||||
override fun findLatestVisibleSnapshots(
|
||||
rankingType: CreatorRankingType,
|
||||
nowUtc: LocalDateTime
|
||||
): List<CreatorRankingSnapshotRecord> = snapshots
|
||||
|
||||
) = snapshots
|
||||
override fun findLatestSnapshots() = snapshots
|
||||
override fun findPreviousCompletedSnapshots() = snapshots
|
||||
override fun findLatestVisibleSnapshots(rankingType: CreatorRankingType, nowUtc: LocalDateTime) = snapshots
|
||||
override fun findPreviousVisibleSnapshots(
|
||||
rankingType: CreatorRankingType,
|
||||
currentAggregationStartAtUtc: LocalDateTime,
|
||||
nowUtc: LocalDateTime
|
||||
): List<CreatorRankingSnapshotRecord> = snapshots
|
||||
|
||||
override fun isSnapshotTableEmpty(): Boolean = snapshots.isEmpty()
|
||||
|
||||
) = snapshots
|
||||
override fun replaceSnapshots(
|
||||
rankingType: CreatorRankingType,
|
||||
aggregationStartAtUtc: LocalDateTime,
|
||||
@@ -393,14 +133,8 @@ private class FakeCreatorRankingSnapshotPort : CreatorRankingSnapshotPort {
|
||||
visibleFromAtUtc: LocalDateTime,
|
||||
newSnapshots: List<CreatorRankingSnapshotRecord>
|
||||
) {
|
||||
failure?.let { throw it }
|
||||
this.rankingType = rankingType
|
||||
this.aggregationStartAtUtc = aggregationStartAtUtc
|
||||
this.aggregationEndAtUtc = aggregationEndAtUtc
|
||||
this.visibleFromAtUtc = visibleFromAtUtc
|
||||
snapshots.removeIf {
|
||||
it.aggregationStartAtUtc == aggregationStartAtUtc && it.aggregationEndAtUtc == aggregationEndAtUtc
|
||||
}
|
||||
snapshots.clear()
|
||||
snapshots.addAll(newSnapshots)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,76 +1,81 @@
|
||||
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.assertTrue
|
||||
import org.junit.jupiter.api.DisplayName
|
||||
import org.junit.jupiter.api.Test
|
||||
import java.time.LocalDateTime
|
||||
|
||||
class CreatorRankingScorePolicyTest {
|
||||
private val policy = CreatorRankingScorePolicy()
|
||||
|
||||
@Test
|
||||
@DisplayName("콘텐츠/라이브 점수는 라이브 계열 캔 70%와 콘텐츠 구매 캔 30%를 raw value로 계산한다")
|
||||
fun shouldCalculateContentLiveScore() {
|
||||
assertEquals(0.7, CreatorRankingScoreSpec.CONTENT_LIVE_CAN_WEIGHT, 0.0001)
|
||||
assertEquals(0.3, CreatorRankingScoreSpec.CONTENT_PURCHASE_CAN_WEIGHT, 0.0001)
|
||||
|
||||
val score = policy.calculateContentLiveScore(liveCanAmount = 1000, contentPurchaseCanAmount = 200)
|
||||
|
||||
assertEquals(760.0, score, 0.0001)
|
||||
@DisplayName("순위 점수는 rank 1/2/20/21 경계와 raw value 0 강제 0점을 따른다")
|
||||
fun shouldCalculateRankScoreBoundaries() {
|
||||
assertEquals(100.0, policy.calculateRankScore(rawValue = 10, rank = 1), 0.0001)
|
||||
assertEquals(95.0, policy.calculateRankScore(rawValue = 10, rank = 2), 0.0001)
|
||||
assertEquals(5.0, policy.calculateRankScore(rawValue = 10, rank = 20), 0.0001)
|
||||
assertEquals(0.0, policy.calculateRankScore(rawValue = 10, rank = 21), 0.0001)
|
||||
assertEquals(0.0, policy.calculateRankScore(rawValue = 0, rank = 1), 0.0001)
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("참여 반응 점수는 좋아요 수와 댓글 수를 각각 50% raw value로 계산한다")
|
||||
fun shouldCalculateEngagementScore() {
|
||||
assertEquals(0.5, CreatorRankingScoreSpec.CONTENT_LIKE_COUNT_WEIGHT, 0.0001)
|
||||
assertEquals(0.5, CreatorRankingScoreSpec.CONTENT_COMMENT_COUNT_WEIGHT, 0.0001)
|
||||
|
||||
val score = policy.calculateEngagementScore(contentLikeCount = 40, contentCommentCount = 20)
|
||||
|
||||
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)
|
||||
@DisplayName("최종 점수는 라이브 60%, 콘텐츠 20%, 팔로우 10%, AI 채팅 10% 가중합이다")
|
||||
fun shouldCalculateWeightedFinalScore() {
|
||||
assertEquals(0.6, CreatorRankingScoreSpec.LIVE_REVENUE_WEIGHT, 0.0001)
|
||||
assertEquals(0.2, CreatorRankingScoreSpec.CONTENT_REVENUE_WEIGHT, 0.0001)
|
||||
assertEquals(0.1, CreatorRankingScoreSpec.FOLLOW_INCREASE_WEIGHT, 0.0001)
|
||||
assertEquals(0.1, CreatorRankingScoreSpec.AI_CHAT_WEIGHT, 0.0001)
|
||||
|
||||
val score = policy.calculateFinalScore(
|
||||
contentLiveScore = 760.0,
|
||||
engagementScore = 30.0,
|
||||
supportScore = 606.0,
|
||||
fanLoyaltyScore = 67.0
|
||||
liveRevenueRankScore = 100.0,
|
||||
contentRevenueRankScore = 95.0,
|
||||
followIncreaseRankScore = 90.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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user