docs(home): 크리에이터 랭킹 개편 계획을 문서화한다
This commit is contained in:
@@ -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`
|
||||
Reference in New Issue
Block a user