Compare commits

...

42 Commits

Author SHA1 Message Date
4442fa6d91 Merge pull request 'test' (#433) from test into main
Reviewed-on: #433
2026-07-10 07:20:06 +00:00
924d4abbe2 chore(build): 테스트 JVM 메모리 설정을 고정한다 2026-07-10 16:03:54 +09:00
61d5178eba test(home): 크리에이터 랭킹 공개 응답 스키마를 검증한다 2026-07-10 14:46:20 +09:00
263202f0f5 feat(home): 크리에이터 랭킹 조회 fallback을 재조회로 변경한다 2026-07-10 14:45:42 +09:00
d5ffa56f17 feat(home): 크리에이터 랭킹 fallback refresh job을 실행한다 2026-07-10 14:45:10 +09:00
10020b03d6 feat(home): 크리에이터 랭킹 fallback job 집계를 추가한다 2026-07-10 14:44:29 +09:00
1f944329f4 feat(home): 크리에이터 랭킹 갱신 산식을 적용한다 2026-07-10 14:43:50 +09:00
247569ee16 feat(home): 크리에이터 랭킹 스냅샷 저장 구조를 변경한다 2026-07-10 14:43:11 +09:00
e596cb0826 feat(home): 크리에이터 랭킹 후보 집계를 변경한다 2026-07-10 14:42:34 +09:00
c7b6d0d7c5 feat(home): 크리에이터 랭킹 점수 정책을 변경한다 2026-07-10 14:42:25 +09:00
9c67e870f6 docs(home): 크리에이터 랭킹 개편 계획을 문서화한다 2026-07-10 14:42:15 +09:00
97c4b1ed1f test(home): 메인 전체 탭 유료 오디오 필터를 검증한다 2026-07-10 10:16:52 +09:00
d3d9855a52 feat(home): 메인 전체 탭 유료 오디오 필터를 적용한다 2026-07-10 10:16:37 +09:00
561e181859 docs(home): 메인 전체 탭 유료 오디오 요구사항을 문서화한다 2026-07-10 10:16:27 +09:00
2403e8703c fix(home): 커뮤니티 활동 이동 대상 id를 변경한다 2026-07-10 09:20:32 +09:00
13ac9d43d6 docs(home): 커뮤니티 활동 이동 대상을 문서화한다 2026-07-10 09:20:26 +09:00
b4555a4460 feat(home): 인기 커뮤니티 fallback을 홈 조회에 연결한다 2026-07-10 08:58:16 +09:00
94b03a8c11 feat(home): 인기 커뮤니티 스냅샷 갱신 경로를 분리한다 2026-07-10 08:57:12 +09:00
6cda73270f feat(home): 인기 커뮤니티 빈 스냅샷 marker를 저장한다 2026-07-10 08:56:32 +09:00
ea331ce66f feat(home): 인기 커뮤니티 집계 쿼리를 변경한다 2026-07-10 08:56:02 +09:00
699b1621b7 feat(home): 인기 커뮤니티 점수 정책을 변경한다 2026-07-10 08:55:54 +09:00
8a7396b677 docs(home): 인기 커뮤니티 스냅샷 계획을 추가한다 2026-07-10 08:55:45 +09:00
7d0cf0a82a feat(home): 응원 크리에이터 fallback을 홈 조회에 연결한다 2026-07-10 06:07:20 +09:00
391acf9ee5 feat(home): 추천 스냅샷 fallback을 공통화한다 2026-07-10 06:06:56 +09:00
ce43cf2c67 feat(home): 응원 스냅샷 갱신 lock을 적용한다 2026-07-10 06:06:30 +09:00
64b05deee7 feat(home): 응원 스냅샷 marker 저장을 지원한다 2026-07-10 06:06:02 +09:00
4f348c3643 feat(home): 응원 크리에이터 집계 쿼리를 변경한다 2026-07-10 06:05:37 +09:00
c9e35f2ed4 feat(home): 응원 크리에이터 점수 정책을 변경한다 2026-07-10 06:05:27 +09:00
3aa61c94b8 docs(home): AI 스냅샷 fallback 문서를 정리한다 2026-07-10 06:05:21 +09:00
5d612ffcdb docs(home): 응원 크리에이터 스냅샷 계획을 추가한다 2026-07-10 06:05:15 +09:00
cc7d28108a feat(home): AI 캐릭터 스냅샷 fallback을 연결한다 2026-07-10 03:22:07 +09:00
09c7ea7f34 feat(home): AI 캐릭터 스냅샷 갱신 경로를 분리한다 2026-07-10 03:21:41 +09:00
ca26577279 feat(home): AI 캐릭터 빈 스냅샷 마커를 저장한다 2026-07-10 03:21:16 +09:00
2a0d2a385a feat(home): AI 캐릭터 스냅샷 집계 쿼리를 변경한다 2026-07-10 03:20:47 +09:00
eb4df66ece feat(home): AI 캐릭터 스냅샷 집계 기간을 정의한다 2026-07-10 03:20:20 +09:00
9288b44d5e feat(home): AI 캐릭터 추천 점수 정책을 변경한다 2026-07-10 03:20:11 +09:00
a11ef3c4d1 docs(home): AI 캐릭터 스냅샷 계획을 추가한다 2026-07-10 03:20:05 +09:00
15b55704e0 fix(home): AI 캐릭터 홈 노출 개수를 늘린다 2026-07-09 18:27:49 +09:00
c706c73b05 docs(home): AI 캐릭터 홈 노출 개수 변경을 기록한다 2026-07-09 18:27:38 +09:00
78f24d774b fix(home): 최근 활동 라이브 활성 필터를 제거한다 2026-07-09 15:47:04 +09:00
b5b682e340 feat(fantalk): 팬톡 작성 수정 응답을 반환한다 2026-07-09 06:00:35 +09:00
ae380e8922 docs(fantalk): 팬톡 응답 보강 계획을 추가한다 2026-07-09 06:00:25 +09:00
71 changed files with 6523 additions and 2526 deletions

View File

@@ -102,11 +102,13 @@ tasks.withType<KotlinCompile> {
tasks.withType<Test> { tasks.withType<Test> {
useJUnitPlatform() useJUnitPlatform()
val springContextCacheMaxSize = (project.findProperty("test.springContextCacheMaxSize") as String?) ?: "1" val springContextCacheMaxSize = "1"
maxHeapSize = (project.findProperty("testMaxHeap") as String?) ?: "1536m" maxHeapSize = "1024m"
maxParallelForks = 1
jvmArgs( jvmArgs(
"-Dfile.encoding=UTF-8", "-Dfile.encoding=UTF-8",
"-XX:+HeapDumpOnOutOfMemoryError",
"-Dspring.test.context.cache.maxSize=$springContextCacheMaxSize" "-Dspring.test.context.cache.maxSize=$springContextCacheMaxSize"
) )
} }

View File

@@ -247,7 +247,7 @@
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/recommendation/adapter/out/persistence/DefaultHomeRecommendationQueryRepositoryTest.kt` - Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/recommendation/adapter/out/persistence/DefaultHomeRecommendationQueryRepositoryTest.kt`
- RED: 라이브 최신순 20개, 활성 배너 orders 정렬 최대 20개, 동일 `orders` 배너의 랜덤 tie-breaker 정렬, 크리에이터당 최신 활동 1개만 반환하는 테스트를 작성한다. 라이브 노출 정보는 크리에이터 닉네임/프로필 이미지/라이브 번호를 포함하고, 활동 크리에이터 노출 정보는 크리에이터 프로필 이미지/닉네임/활동 타입/UTC 활동 시간/이동 대상 id를 포함하며 라이브 활동의 이동 대상 id는 nullable임을 검증한다. 배너는 비활성 이벤트 대상 `EVENT`, 비활성 크리에이터 대상 `CREATOR`, 비활성 시리즈 대상 `SERIES`, 비활성 시리즈 소유 회원 대상 `SERIES`가 제외되고, `LINK` 배너는 별도 대상 엔티티 검증 없이 배너 자체 활성 상태만으로 노출되는 repository 테스트를 함께 작성한다. - RED: 라이브 최신순 20개, 활성 배너 orders 정렬 최대 20개, 동일 `orders` 배너의 랜덤 tie-breaker 정렬, 크리에이터당 최신 활동 1개만 반환하는 테스트를 작성한다. 라이브 노출 정보는 크리에이터 닉네임/프로필 이미지/라이브 번호를 포함하고, 활동 크리에이터 노출 정보는 크리에이터 프로필 이미지/닉네임/활동 타입/UTC 활동 시간/이동 대상 id를 포함하며 라이브 활동의 이동 대상 id는 nullable임을 검증한다. 배너는 비활성 이벤트 대상 `EVENT`, 비활성 크리에이터 대상 `CREATOR`, 비활성 시리즈 대상 `SERIES`, 비활성 시리즈 소유 회원 대상 `SERIES`가 제외되고, `LINK` 배너는 별도 대상 엔티티 검증 없이 배너 자체 활성 상태만으로 노출되는 repository 테스트를 함께 작성한다.
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.recommendation.application.HomeRecommendationQueryServiceTest --tests kr.co.vividnext.sodalive.v2.recommendation.adapter.out.persistence.DefaultHomeRecommendationQueryRepositoryTest` - 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.recommendation.application.HomeRecommendationQueryServiceTest --tests kr.co.vividnext.sodalive.v2.recommendation.adapter.out.persistence.DefaultHomeRecommendationQueryRepositoryTest`
- GREEN: `LiveRoom`, `AudioContentBanner`, `AudioContent`, `CreatorCommunity` 기반 QueryDSL 조회를 구현한다. application service는 `HomeRecommendationQueryPort`에만 의존하고 persistence 구현체가 port를 구현한다. 배너는 기존 콘텐츠 홈 배너의 앱 이동 필드를 유지하고, 동일 `orders` 값은 후보군 축소 후 랜덤화하거나 랜덤 tie-breaker를 적용한다. 배너 대상 활성 조건은 service 후처리가 아니라 repository 조회 조건으로 고정한다. 활동 타입 enum 값은 `LIVE`, `AUDIO`, `COMMUNITY`, `LIVE_REPLAY` 영문 code 그대로 유지한다. 최근 활동 `COMMUNITY`의 이동 대상 id는 커뮤니티 게시글 id가 아니라 해당 게시글 작성자 크리에이터 id를 사용한다. - GREEN: `LiveRoom`, `AudioContentBanner`, `AudioContent`, `CreatorCommunity` 기반 QueryDSL 조회를 구현한다. application service는 `HomeRecommendationQueryPort`에만 의존하고 persistence 구현체가 port를 구현한다. 배너는 기존 콘텐츠 홈 배너의 앱 이동 필드를 유지하고, 동일 `orders` 값은 후보군 축소 후 랜덤화하거나 랜덤 tie-breaker를 적용한다. 배너 대상 활성 조건은 service 후처리가 아니라 repository 조회 조건으로 고정한다. 활동 타입 enum 값은 `LIVE`, `AUDIO`, `COMMUNITY`, `LIVE_REPLAY` 영문 code 그대로 유지한다. 최근 활동 `COMMUNITY`의 이동 대상 id는 후속 Phase 10 기준으로 `creator_community.id`를 사용한다.
- REFACTOR: 차단 관계, 비활성 회원, 비활성 콘텐츠/배너 제외 조건을 공통 private 조건 함수로 정리한다. 단순 조회와 대상 활성 조건은 QueryDSL/JPA 우선으로 표현하고, native SQL은 SQL 고급 기능이 필요한 쿼리에만 남긴다. - REFACTOR: 차단 관계, 비활성 회원, 비활성 콘텐츠/배너 제외 조건을 공통 private 조건 함수로 정리한다. 단순 조회와 대상 활성 조건은 QueryDSL/JPA 우선으로 표현하고, native SQL은 SQL 고급 기능이 필요한 쿼리에만 남긴다.
- 기대 결과: 특정 섹션 데이터가 부족해도 service가 가능한 개수만 반환한다. - 기대 결과: 특정 섹션 데이터가 부족해도 service가 가능한 개수만 반환한다.
@@ -268,12 +268,31 @@
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/recommendation/application/HomeRecommendationQueryService.kt` - Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/recommendation/application/HomeRecommendationQueryService.kt`
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/recommendation/adapter/out/persistence/DefaultHomeRecommendationQueryRepository.kt` - Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/recommendation/adapter/out/persistence/DefaultHomeRecommendationQueryRepository.kt`
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/recommendation/application/HomeRecommendationQueryServiceTest.kt` - Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/recommendation/application/HomeRecommendationQueryServiceTest.kt`
- RED: 스냅샷 기준 AI 캐릭터 10개, AI 캐릭터 응답의 캐릭터 이름/소개/전체 채팅 수/오리지널 작품명 조건, 최근 응원 8명과 크리에이터 프로필 이미지/닉네임, 인기 커뮤니티 10개와 크리에이터 프로필 이미지/닉네임/UTC 시간/좋아요 수/댓글 수/커뮤니티 내용, 스냅샷 없음 빈 배열 테스트를 작성한다. - RED: 스냅샷 기준 AI 캐릭터 20개, AI 캐릭터 응답의 캐릭터 이름/소개/전체 채팅 수/오리지널 작품명 조건, 최근 응원 8명과 크리에이터 프로필 이미지/닉네임, 인기 커뮤니티 10개와 크리에이터 프로필 이미지/닉네임/UTC 시간/좋아요 수/댓글 수/커뮤니티 내용, 스냅샷 없음 빈 배열 테스트를 작성한다.
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.recommendation.application.HomeRecommendationQueryServiceTest` - 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.recommendation.application.HomeRecommendationQueryServiceTest`
- GREEN: `RecommendationSnapshotRepository`에서 최신 스냅샷을 읽고 대상 엔티티 상세 정보를 조립한다. AI 캐릭터 작품명은 오리지널 작품 캐릭터인 경우에만 채우고, 인기 커뮤니티는 스냅샷에 저장된 점수/랜덤 tie-breaker 순서를 유지한다. - GREEN: `RecommendationSnapshotRepository`에서 최신 스냅샷을 읽고 대상 엔티티 상세 정보를 조립한다. AI 캐릭터 작품명은 오리지널 작품 캐릭터인 경우에만 채우고, 인기 커뮤니티는 스냅샷에 저장된 점수/랜덤 tie-breaker 순서를 유지한다.
- REFACTOR: 비활성/노출 제한 캐릭터, 커뮤니티 비공개/유료/핀/성인 조건을 repository 조건으로 고정한다. - REFACTOR: 비활성/노출 제한 캐릭터, 커뮤니티 비공개/유료/핀/성인 조건을 repository 조건으로 고정한다.
- 기대 결과: AI 캐릭터 노출 필드가 PRD와 일치하고, 인기 커뮤니티는 크리에이터당 1개만 반환하며 동일 점수는 스냅샷 생성 시 저장한 랜덤 tie-breaker 기준으로 노출된다. - 기대 결과: AI 캐릭터 노출 필드가 PRD와 일치하고, 인기 커뮤니티는 크리에이터당 1개만 반환하며 동일 점수는 스냅샷 생성 시 저장한 랜덤 tie-breaker 기준으로 노출된다.
### Phase 9: AI 캐릭터 홈 노출 개수 20개 조정
- [x] **Task 9.1: 홈 추천 AI 캐릭터 기본 조회 개수 20개 적용**
- Files:
- Modify: `docs/20260529_메인_홈_추천_API/prd.md`
- Modify: `docs/20260529_메인_홈_추천_API/plan-task.md`
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/home/application/HomeRecommendationFacade.kt`
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/recommendation/application/HomeRecommendationQueryService.kt`
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/recommendation/application/HomeRecommendationQueryServiceTest.kt`
- RED: AI 캐릭터 추천 기본 조회가 최신 스냅샷 20개를 기준으로 상세 조회 id를 전달하는 테스트로 기존 10개 계약이 실패하도록 한다.
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.recommendation.application.HomeRecommendationQueryServiceTest`
- GREEN: 홈 통합 응답의 AI 캐릭터 limit과 service 기본 AI 캐릭터 limit을 20개로 변경한다. 스냅샷 저장 상한은 이미 20개이므로 변경하지 않는다.
- REFACTOR: PRD와 기존 Task 3.3의 AI 캐릭터 개수 문구를 20개로 맞추고, 전체보기 paging 기본 size 20 계약은 유지한다.
- 기대 결과: `GET /api/v2/home/recommendations``aiCharacters`가 최신 AI 캐릭터 스냅샷 기준 최대 20개까지 내려간다.
- 검증 기록:
- 2026-07-09: RED에서 `HomeRecommendationQueryServiceTest`의 AI 캐릭터 최신 스냅샷 기준 기대값을 20개로 변경했고, 기존 구현은 `DEFAULT_AI_CHARACTER_LIMIT = 10` 때문에 `HomeRecommendationQueryServiceTest > AI 캐릭터 추천은 최신 스냅샷 20개를 기준으로 순서를 유지해 상세를 조립한다`가 실패했다.
- 2026-07-09: GREEN에서 `HomeRecommendationFacade.HOME_AI_CHARACTER_LIMIT``HomeRecommendationQueryService.DEFAULT_AI_CHARACTER_LIMIT`를 20으로 변경하고, 홈 통합 응답이 AI 캐릭터를 최대 20개 반환하는 controller 테스트를 추가했다. `./gradlew test --tests kr.co.vividnext.sodalive.v2.recommendation.application.HomeRecommendationQueryServiceTest --tests kr.co.vividnext.sodalive.v2.api.home.HomeRecommendationControllerTest` 실행 결과 `BUILD SUCCESSFUL`로 통과했다.
- 2026-07-09: 회귀 검증으로 `./gradlew ktlintCheck``./gradlew tasks --all`을 실행해 모두 `BUILD SUCCESSFUL`로 통과했다. 두 명령은 최초 sandbox 실행에서 Gradle wrapper의 `~/.gradle` lock 파일 접근 권한 문제로 실패했고, 권한 상승 재실행으로 통과했다. `./gradlew :app:ktlintCheck`는 단일 루트 프로젝트에 `app` 프로젝트가 없어 실패했으며, 저장소 기준 명령인 `./gradlew ktlintCheck`로 대체 검증했다.
### Phase 4: 콘텐츠 조회 이력 기록 ### Phase 4: 콘텐츠 조회 이력 기록
- [x] **Task 4.1: 콘텐츠 조회 이력 엔티티/서비스 작성** - [x] **Task 4.1: 콘텐츠 조회 이력 엔티티/서비스 작성**
@@ -620,12 +639,38 @@
--- ---
### Phase 10: 최근 활동 커뮤니티 이동 대상 id 변경
- [x] **Task 10.1: `COMMUNITY` 활동 `targetId`를 `creator_community.id`로 변경**
- Files:
- Modify: `docs/20260529_메인_홈_추천_API/prd.md`
- Modify: `docs/20260529_메인_홈_추천_API/plan-task.md`
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/recommendation/adapter/out/persistence/DefaultHomeRecommendationQueryRepository.kt`
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/recommendation/adapter/out/persistence/DefaultHomeRecommendationQueryRepositoryTest.kt`
- RED: `findRecentlyActiveCreators` repository 테스트에서 `COMMUNITY` 활동의 `targetId`가 기존 크리에이터 id가 아니라 `creator_community.id`여야 함을 먼저 검증한다.
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.recommendation.adapter.out.persistence.DefaultHomeRecommendationQueryRepositoryTest.shouldFindOneLatestActivityPerCreatorWithActivityType`
- GREEN: native SQL의 `COMMUNITY` 활동 `target_id` select 값을 `m.id`에서 `cc.id`로 변경한다.
- REFACTOR: 응답 DTO 스키마, 활동 타입 enum, 오디오/라이브 다시듣기 target id 의미는 변경하지 않는다.
- 검증 기준:
- Run: `./gradlew test --tests kr.co.vividnext.sodalive.v2.recommendation.adapter.out.persistence.DefaultHomeRecommendationQueryRepositoryTest.shouldFindOneLatestActivityPerCreatorWithActivityType`
- Run: `./gradlew test --tests kr.co.vividnext.sodalive.v2.recommendation.adapter.out.persistence.DefaultHomeRecommendationQueryRepositoryTest`
- Run: `./gradlew ktlintCheck`
- Run: `./gradlew tasks --all`
- 기대 결과: 홈 추천 `recentlyActiveCreators[]` 중 `activityType = COMMUNITY`인 item의 `targetId`가 클라이언트가 이동에 사용할 수 있는 `creator_community.id`로 내려간다.
- 검증 기록(2026-07-10):
- 무엇을: 홈 추천 `recentlyActiveCreators` 조회에서 `COMMUNITY` 활동의 `targetId`가 `creator_community.id`로 내려가는지 확인했다.
- 왜: 클라이언트 이동 방식이 크리에이터 id가 아니라 커뮤니티 게시글 id를 사용하도록 변경되었기 때문이다.
- 어떻게: RED에서 `DefaultHomeRecommendationQueryRepositoryTest.shouldFindOneLatestActivityPerCreatorWithActivityType`의 `COMMUNITY` 기대값을 `community.id`로 변경해 기존 구현이 실패하는 것을 확인했다. GREEN에서 native SQL의 `COMMUNITY` `target_id` select 값을 `m.id`에서 `cc.id`로 변경한 뒤 단일 테스트와 repository 테스트 클래스 전체를 재실행했다.
- 결과: RED 단일 테스트는 `DefaultHomeRecommendationQueryRepositoryTest.kt:437` assertion 실패로 재현됐고, GREEN 후 `./gradlew test --tests kr.co.vividnext.sodalive.v2.recommendation.adapter.out.persistence.DefaultHomeRecommendationQueryRepositoryTest.shouldFindOneLatestActivityPerCreatorWithActivityType`, `./gradlew test --tests kr.co.vividnext.sodalive.v2.recommendation.adapter.out.persistence.DefaultHomeRecommendationQueryRepositoryTest`, `./gradlew ktlintCheck`, `./gradlew tasks --all`이 모두 `BUILD SUCCESSFUL`로 통과했다. `ktlintCheck`와 `tasks --all`은 sandbox의 `~/.gradle` lock 파일 접근 제한으로 최초 실패했고 권한 상승 재실행으로 통과했다.
---
## PRD Coverage Check ## PRD Coverage Check
- Feature A: Phase 3, Phase 6, Phase 7에서 통합 조회, limit, 인증/비회원, 팔로우 제외, 콘텐츠 조회 이력, 본인인증 여부, 차단 필터, 스냅샷 빈 배열 처리를 검증한다. - Feature A: Phase 3, Phase 6, Phase 7에서 통합 조회, limit, 인증/비회원, 팔로우 제외, 콘텐츠 조회 이력, 본인인증 여부, 차단 필터, 스냅샷 빈 배열 처리를 검증한다.
- Feature B: Task 3.1, Task 6.3에서 라이브 최신순/전체보기/비활성 회원 제외와 크리에이터 닉네임/프로필 이미지/라이브 번호 노출 필드를 검증한다. - Feature B: Task 3.1, Task 6.3에서 라이브 최신순/전체보기/비활성 회원 제외와 크리에이터 닉네임/프로필 이미지/라이브 번호 노출 필드를 검증한다.
- Feature C: Task 3.1과 Task 7.7에서 기존 콘텐츠 홈 배너 재활용, orders 정렬, 동일 orders 랜덤 정렬, 활성 배너/콘텐츠 조건, `EVENT`/`CREATOR`/`SERIES` 대상 비활성 제외, `CREATOR`/`SERIES` 대상 양방향 차단 제외, `LINK` 배너의 자체 활성 상태 기준 노출, 앱 이동 필드 유지를 검증한다. - Feature C: Task 3.1과 Task 7.7에서 기존 콘텐츠 홈 배너 재활용, orders 정렬, 동일 orders 랜덤 정렬, 활성 배너/콘텐츠 조건, `EVENT`/`CREATOR`/`SERIES` 대상 비활성 제외, `CREATOR`/`SERIES` 대상 양방향 차단 제외, `LINK` 배너의 자체 활성 상태 기준 노출, 앱 이동 필드 유지를 검증한다.
- Feature D: Task 1.3, Task 3.1에서 활동 타입 영문 enum, 최신 활동 1개, 크리에이터 프로필 이미지/닉네임, UTC 시간, 이동 대상 id nullable 검증한다. - Feature D: Task 1.3, Task 3.1, Task 10.1에서 활동 타입 영문 enum, 최신 활동 1개, 크리에이터 프로필 이미지/닉네임, UTC 시간, 이동 대상 id nullable, `COMMUNITY` 활동의 `creator_community.id` 이동 대상 id를 검증한다.
- Feature E: Task 1.1, Task 1.2, Task 3.2, Task 6.3에서 데뷔일/점수/동점 랜덤 정렬/프로필 이미지와 닉네임 노출/전체보기를 검증한다. - Feature E: Task 1.1, Task 1.2, Task 3.2, Task 6.3에서 데뷔일/점수/동점 랜덤 정렬/프로필 이미지와 닉네임 노출/전체보기를 검증한다.
- Feature F: Task 1.1, Task 3.2, Task 6.3, Task 9.1에서 첫 오디오 콘텐츠 판정, 최신성 점수 구간, 예약 공개 제외, native query Boolean 계산 컬럼 매핑을 검증한다. - Feature F: Task 1.1, Task 3.2, Task 6.3, Task 9.1에서 첫 오디오 콘텐츠 판정, 최신성 점수 구간, 예약 공개 제외, native query Boolean 계산 컬럼 매핑을 검증한다.
- Feature G: Task 1.1, Task 2.2, Task 2.6, Task 2.7, Task 2.8, Task 2.9, Task 3.3, Task 6.3, Task 8.1, Task 8.2에서 AI 캐릭터 점수, 캐릭터 생성일 기준 신규 부스트, 스냅샷, AI 채팅 집계 범위, DB-side exact scoring, 응답 필드, 오리지널 작품명 조건, 전체보기, AI 캐릭터에 대응하는 `creatorId` 노출을 검증한다. - Feature G: Task 1.1, Task 2.2, Task 2.6, Task 2.7, Task 2.8, Task 2.9, Task 3.3, Task 6.3, Task 8.1, Task 8.2에서 AI 캐릭터 점수, 캐릭터 생성일 기준 신규 부스트, 스냅샷, AI 채팅 집계 범위, DB-side exact scoring, 응답 필드, 오리지널 작품명 조건, 전체보기, AI 캐릭터에 대응하는 `creatorId` 노출을 검증한다.
@@ -640,6 +685,7 @@
## Verification Log ## Verification Log
- 2026-07-10: 사용자 피드백에 따라 홈 추천 최근 활동 크리에이터의 `COMMUNITY` 활동 `targetId`를 기존 크리에이터 id에서 `creator_community.id`로 변경했다. PRD Feature D와 plan-task Phase 10을 보강했고, RED/GREEN으로 repository 테스트를 갱신했다. 검증으로 `./gradlew test --tests kr.co.vividnext.sodalive.v2.recommendation.adapter.out.persistence.DefaultHomeRecommendationQueryRepositoryTest.shouldFindOneLatestActivityPerCreatorWithActivityType`, `./gradlew test --tests kr.co.vividnext.sodalive.v2.recommendation.adapter.out.persistence.DefaultHomeRecommendationQueryRepositoryTest`, `./gradlew ktlintCheck`, `./gradlew tasks --all`을 실행해 모두 `BUILD SUCCESSFUL`을 확인했다. `ktlintCheck`와 `tasks --all`은 sandbox의 `~/.gradle` lock 파일 접근 제한으로 최초 실패해 권한 상승으로 재실행했다.
- 2026-06-27: Phase 9 코드 리뷰 및 검증을 진행했다. 변경 범위가 첫 오디오 콘텐츠 native query row 매핑의 Boolean 변환 보정과 운영 회귀 테스트/문서 보강에 한정되어 있는지 확인했고, `isPointAvailable`, `isAdult`, `isOriginalSeries`가 `Boolean` 또는 `Number(0/1)` 모두에서 명시적으로 Boolean으로 변환되는지 점검했다. 리뷰 결과 수정이 필요한 결함은 발견하지 못했다. 검증으로 `./gradlew test --tests kr.co.vividnext.sodalive.v2.recommendation.adapter.out.persistence.DefaultHomeRecommendationQueryRepositoryTest.shouldMapNumericNativeBooleanFromFirstAudioContentRows`, `./gradlew test --tests kr.co.vividnext.sodalive.v2.recommendation.adapter.out.persistence.DefaultHomeRecommendationQueryRepositoryTest`, `./gradlew ktlintCheck`, `./gradlew tasks --all`, `git diff --check`, `git diff --check --cached`, `./gradlew test`를 실행했고 모두 `BUILD SUCCESSFUL` 또는 통과를 확인했다. `ktlintCheck`와 `tasks --all`은 sandbox의 `~/.gradle` lock 파일 접근 제한으로 최초 실패해 권한 상승으로 재실행했다. - 2026-06-27: Phase 9 코드 리뷰 및 검증을 진행했다. 변경 범위가 첫 오디오 콘텐츠 native query row 매핑의 Boolean 변환 보정과 운영 회귀 테스트/문서 보강에 한정되어 있는지 확인했고, `isPointAvailable`, `isAdult`, `isOriginalSeries`가 `Boolean` 또는 `Number(0/1)` 모두에서 명시적으로 Boolean으로 변환되는지 점검했다. 리뷰 결과 수정이 필요한 결함은 발견하지 못했다. 검증으로 `./gradlew test --tests kr.co.vividnext.sodalive.v2.recommendation.adapter.out.persistence.DefaultHomeRecommendationQueryRepositoryTest.shouldMapNumericNativeBooleanFromFirstAudioContentRows`, `./gradlew test --tests kr.co.vividnext.sodalive.v2.recommendation.adapter.out.persistence.DefaultHomeRecommendationQueryRepositoryTest`, `./gradlew ktlintCheck`, `./gradlew tasks --all`, `git diff --check`, `git diff --check --cached`, `./gradlew test`를 실행했고 모두 `BUILD SUCCESSFUL` 또는 통과를 확인했다. `ktlintCheck`와 `tasks --all`은 sandbox의 `~/.gradle` lock 파일 접근 제한으로 최초 실패해 권한 상승으로 재실행했다.
- 2026-06-23: Phase 8 코드 리뷰 및 검증을 진행했다. 변경 범위가 `creatorId` additive schema 추가에 한정되어 있는지 확인했고, `HomeAiCharacterRecommendationRecord.creatorId` → `HomeAiCharacterItem.creatorId` 매핑, `ChatCharacter.creatorMember` inner join과 활성/CREATOR/AI_CHARACTER 필터, 홈 통합/AI 캐릭터 전체보기 JSON 응답 검증 테스트를 점검했다. 리뷰 결과 수정이 필요한 결함은 발견하지 못했다. 검증으로 `./gradlew test --rerun-tasks --tests kr.co.vividnext.sodalive.v2.recommendation.application.HomeRecommendationQueryServiceTest --tests kr.co.vividnext.sodalive.v2.recommendation.adapter.out.persistence.DefaultHomeRecommendationQueryRepositoryTest --tests kr.co.vividnext.sodalive.v2.api.home.dto.recommendation.HomeRecommendationResponseTest --tests kr.co.vividnext.sodalive.v2.api.home.HomeRecommendationControllerTest`, `./gradlew ktlintCheck`, `./gradlew tasks --all`, `git diff --check`, `./gradlew test`를 실행했고 모두 `BUILD SUCCESSFUL` 또는 통과를 확인했다. `ktlintCheck`와 `tasks --all`은 sandbox의 `~/.gradle` lock 파일 접근 제한으로 최초 실패해 권한 상승으로 재실행했다. - 2026-06-23: Phase 8 코드 리뷰 및 검증을 진행했다. 변경 범위가 `creatorId` additive schema 추가에 한정되어 있는지 확인했고, `HomeAiCharacterRecommendationRecord.creatorId` → `HomeAiCharacterItem.creatorId` 매핑, `ChatCharacter.creatorMember` inner join과 활성/CREATOR/AI_CHARACTER 필터, 홈 통합/AI 캐릭터 전체보기 JSON 응답 검증 테스트를 점검했다. 리뷰 결과 수정이 필요한 결함은 발견하지 못했다. 검증으로 `./gradlew test --rerun-tasks --tests kr.co.vividnext.sodalive.v2.recommendation.application.HomeRecommendationQueryServiceTest --tests kr.co.vividnext.sodalive.v2.recommendation.adapter.out.persistence.DefaultHomeRecommendationQueryRepositoryTest --tests kr.co.vividnext.sodalive.v2.api.home.dto.recommendation.HomeRecommendationResponseTest --tests kr.co.vividnext.sodalive.v2.api.home.HomeRecommendationControllerTest`, `./gradlew ktlintCheck`, `./gradlew tasks --all`, `git diff --check`, `./gradlew test`를 실행했고 모두 `BUILD SUCCESSFUL` 또는 통과를 확인했다. `ktlintCheck`와 `tasks --all`은 sandbox의 `~/.gradle` lock 파일 접근 제한으로 최초 실패해 권한 상승으로 재실행했다.
- 2026-06-23: Phase 8 구현을 진행했다. RED에서 `HomeAiCharacterRecommendationRecord`와 `HomeAiCharacterItem`의 `creatorId` 미구현으로 `compileTestKotlin`이 실패하는 것을 확인했고, GREEN에서 `HomeAiCharacterRecommendationRecord.creatorId`, AI 캐릭터 상세 조회의 `ChatCharacter.creatorMember` inner join 및 활성/CREATOR/AI_CHARACTER 조건, `HomeAiCharacterItem.creatorId`, facade 매핑을 추가했다. 회귀 검증으로 `./gradlew test --tests kr.co.vividnext.sodalive.v2.recommendation.application.HomeRecommendationQueryServiceTest --tests kr.co.vividnext.sodalive.v2.recommendation.adapter.out.persistence.DefaultHomeRecommendationQueryRepositoryTest --tests kr.co.vividnext.sodalive.v2.api.home.dto.recommendation.HomeRecommendationResponseTest --tests kr.co.vividnext.sodalive.v2.api.home.HomeRecommendationControllerTest`, `./gradlew ktlintCheck`, `./gradlew tasks --all`, `git diff --check`, `./gradlew test`를 실행해 모두 성공을 확인했다. `ktlintCheck`는 최초 실행에서 import 정렬 오류로 실패했고 import 순서 보정 후 `BUILD SUCCESSFUL`로 통과했다. 리뷰어 지적에 따라 `creatorMember` 누락 row 제외 테스트를 추가했고, 해당 단일 테스트와 Phase 8 대상 테스트 묶음, `ktlintCheck`를 재실행해 모두 성공한 뒤 리뷰어 재검토에서 승인받았다. - 2026-06-23: Phase 8 구현을 진행했다. RED에서 `HomeAiCharacterRecommendationRecord`와 `HomeAiCharacterItem`의 `creatorId` 미구현으로 `compileTestKotlin`이 실패하는 것을 확인했고, GREEN에서 `HomeAiCharacterRecommendationRecord.creatorId`, AI 캐릭터 상세 조회의 `ChatCharacter.creatorMember` inner join 및 활성/CREATOR/AI_CHARACTER 조건, `HomeAiCharacterItem.creatorId`, facade 매핑을 추가했다. 회귀 검증으로 `./gradlew test --tests kr.co.vividnext.sodalive.v2.recommendation.application.HomeRecommendationQueryServiceTest --tests kr.co.vividnext.sodalive.v2.recommendation.adapter.out.persistence.DefaultHomeRecommendationQueryRepositoryTest --tests kr.co.vividnext.sodalive.v2.api.home.dto.recommendation.HomeRecommendationResponseTest --tests kr.co.vividnext.sodalive.v2.api.home.HomeRecommendationControllerTest`, `./gradlew ktlintCheck`, `./gradlew tasks --all`, `git diff --check`, `./gradlew test`를 실행해 모두 성공을 확인했다. `ktlintCheck`는 최초 실행에서 import 정렬 오류로 실패했고 import 순서 보정 후 `BUILD SUCCESSFUL`로 통과했다. 리뷰어 지적에 따라 `creatorMember` 누락 row 제외 테스트를 추가했고, 해당 단일 테스트와 Phase 8 대상 테스트 묶음, `ktlintCheck`를 재실행해 모두 성공한 뒤 리뷰어 재검토에서 승인받았다.

View File

@@ -72,7 +72,7 @@
- 방금 활동한 크리에이터: 10개 - 방금 활동한 크리에이터: 10개
- 최근 데뷔한 크리에이터: 10개 - 최근 데뷔한 크리에이터: 10개
- 처음부터 함께 성장: 10개 - 처음부터 함께 성장: 10개
- 크리에이터와 이야기를 나눠요: 10개 - 크리에이터와 이야기를 나눠요: 20개
- 장르의 크리에이터: 장르 최대 5개, 장르별 크리에이터 8명 - 장르의 크리에이터: 장르 최대 5개, 장르별 크리에이터 8명
- 최근 응원이 많은 크리에이터: 8명 - 최근 응원이 많은 크리에이터: 8명
- 인기 커뮤니티: 10개 - 인기 커뮤니티: 10개
@@ -119,7 +119,7 @@
- 라이브 다시듣기는 콘텐츠 업로드 시 `다시듣기` 테마로 올린 경우를 의미한다. - 라이브 다시듣기는 콘텐츠 업로드 시 `다시듣기` 테마로 올린 경우를 의미한다.
- 노출 정보는 크리에이터 프로필 이미지, 닉네임, 활동 타입, UTC 기반 활동 시간, 이동 대상 id를 포함한다. - 노출 정보는 크리에이터 프로필 이미지, 닉네임, 활동 타입, UTC 기반 활동 시간, 이동 대상 id를 포함한다.
- 라이브 활동은 별도 이동 대상 id가 필요하지 않다. - 라이브 활동은 별도 이동 대상 id가 필요하지 않다.
- 라이브 외 활동은 오디오/라이브 다시듣기 콘텐츠 id를 내려주며, 커뮤니티 활동은 커뮤니티 게시글 작성자 크리에이터 id를 내려준다. - 라이브 외 활동은 오디오/라이브 다시듣기 콘텐츠 id를 내려주며, 커뮤니티 활동은 `creator_community.id`를 내려준다.
- 크리에이터당 최신 활동 1개만 노출한다. - 크리에이터당 최신 활동 1개만 노출한다.
#### Edge Cases #### Edge Cases
@@ -162,7 +162,7 @@
#### Requirements #### Requirements
- AI 캐릭터 리스트를 조회한다. - AI 캐릭터 리스트를 조회한다.
- 홈 첫 화면은 10개를 조회한다. - 홈 첫 화면은 20개를 조회한다.
- 전체 리스트 API는 페이징으로 조회할 수 있어야 한다. - 전체 리스트 API는 페이징으로 조회할 수 있어야 한다.
- 노출 정보는 캐릭터 id, AI 캐릭터에 대응하는 creator id, 캐릭터 이름, 캐릭터 소개, 프로필 이미지, 작품명, 사용자들이 친 전체 채팅 수를 포함한다. - 노출 정보는 캐릭터 id, AI 캐릭터에 대응하는 creator id, 캐릭터 이름, 캐릭터 소개, 프로필 이미지, 작품명, 사용자들이 친 전체 채팅 수를 포함한다.
- AI 캐릭터에 대응하는 creator id는 `ChatCharacter.creatorMember.id`이며, 해당 Member는 `role = CREATOR`, `memberKind = AI_CHARACTER`인 내부 크리에이터 Member다. - AI 캐릭터에 대응하는 creator id는 `ChatCharacter.creatorMember.id`이며, 해당 Member는 `role = CREATOR`, `memberKind = AI_CHARACTER`인 내부 크리에이터 Member다.
@@ -279,7 +279,7 @@
- 일 1회 갱신 스냅샷은 후보를 application/service 메모리로 모두 불러와 점수를 계산하지 않는다. DB 조회에서 모든 적격 후보의 최종 점수와 랜덤 tie-breaker를 계산한 뒤 `score desc, randomTieBreaker asc` 기준으로 정렬하고, 그 이후에만 최종 저장 개수 limit을 적용한다. - 일 1회 갱신 스냅샷은 후보를 application/service 메모리로 모두 불러와 점수를 계산하지 않는다. DB 조회에서 모든 적격 후보의 최종 점수와 랜덤 tie-breaker를 계산한 뒤 `score desc, randomTieBreaker asc` 기준으로 정렬하고, 그 이후에만 최종 저장 개수 limit을 적용한다.
- 최종 점수 계산 전 candidate pre-limit, 랜덤 후보 컷오프, 임의 2배수 선제 제한은 정확한 top 후보를 누락할 수 있으므로 금지한다. - 최종 점수 계산 전 candidate pre-limit, 랜덤 후보 컷오프, 임의 2배수 선제 제한은 정확한 top 후보를 누락할 수 있으므로 금지한다.
- DB score expression과 Kotlin `RecommendationScorePolicy`는 동일한 `RecommendationScoreSpec`의 가중치/부스트 구간 상수를 공유해 산식 drift를 방지한다. - DB score expression과 Kotlin `RecommendationScorePolicy`는 동일한 `RecommendationScoreSpec`의 가중치/부스트 구간 상수를 공유해 산식 drift를 방지한다.
- 스냅샷 최종 저장 개수는 동점자 랜덤 노출 여지를 확보하기 위해 홈 첫 화면 노출 수의 2배인 AI 캐릭터 최대 20개, 최근 응원이 많은 크리에이터 최대 16개, 인기 커뮤니티 최대 20개로 한다. 단, 이 숫자는 최종 점수 계산과 동점 랜덤 정렬 이후 적용하는 저장 limit이며 candidate pre-limit가 아니다. - 스냅샷 최종 저장 개수는 AI 캐릭터 홈 첫 화면 20개 안정 노출을 위해 AI 캐릭터 최대 20개, 동점자 랜덤 노출 여지를 확보하기 위해 최근 응원이 많은 크리에이터 최대 16개, 인기 커뮤니티 최대 20개로 한다. 단, 이 숫자는 최종 점수 계산과 동점 랜덤 정렬 이후 적용하는 저장 limit이며 candidate pre-limit가 아니다.
- 공개 시간은 UTC 기준 응답을 원칙으로 한다. - 공개 시간은 UTC 기준 응답을 원칙으로 한다.
- 응답 DTO의 enum 값은 앱 다국어 처리를 위해 안정적인 영문 code로 내려준다. - 응답 DTO의 enum 값은 앱 다국어 처리를 위해 안정적인 영문 code로 내려준다.
- 기존 API 스키마는 변경하지 않고 신규 v2 endpoint로 분리한다. - 기존 API 스키마는 변경하지 않고 신규 v2 endpoint로 분리한다.

View File

@@ -2,7 +2,7 @@
> **For agentic workers:** REQUIRED SUB-SKILL: Use `superpowers:subagent-driven-development` 또는 `superpowers:executing-plans`로 task 단위 구현을 진행한다. 각 단계는 체크박스(`- [ ]`)로 진행 상태를 갱신한다. > **For agentic workers:** REQUIRED SUB-SKILL: Use `superpowers:subagent-driven-development` 또는 `superpowers:executing-plans`로 task 단위 구현을 진행한다. 각 단계는 체크박스(`- [ ]`)로 진행 상태를 갱신한다.
**Goal:** `GET /api/v2/audio/contents`로 메인 콘텐츠 전체 탭의 오디오, 시리즈, 오리지널, 무료, 포인트 목록을 정렬/요일/페이징 조건에 맞춰 조회한다. **Goal:** `GET /api/v2/audio/contents`로 메인 콘텐츠 전체 탭의 유료 오디오, 시리즈, 오리지널, 무료, 포인트 목록을 정렬/요일/페이징 조건에 맞춰 조회한다.
**Architecture:** 공개 API controller/facade/response DTO는 `kr.co.vividnext.sodalive.v2.api.content.all` 조립 계층에 둔다. 전체 탭 조회 service, 요청 보정 정책, domain model, port, QueryDSL repository는 `kr.co.vividnext.sodalive.v2.content.all` 하위에 두고 `v2.api.*`에 의존하지 않는다. 기존 `ContentSort`, `SeriesPublishedDaysOfWeek`, 콘텐츠 추천/채널 오디오/채널 시리즈 조회 패턴을 재사용하되 공개 응답 DTO는 전체 탭 전용으로 최소 필드만 둔다. **Architecture:** 공개 API controller/facade/response DTO는 `kr.co.vividnext.sodalive.v2.api.content.all` 조립 계층에 둔다. 전체 탭 조회 service, 요청 보정 정책, domain model, port, QueryDSL repository는 `kr.co.vividnext.sodalive.v2.content.all` 하위에 두고 `v2.api.*`에 의존하지 않는다. 기존 `ContentSort`, `SeriesPublishedDaysOfWeek`, 콘텐츠 추천/채널 오디오/채널 시리즈 조회 패턴을 재사용하되 공개 응답 DTO는 전체 탭 전용으로 최소 필드만 둔다.
@@ -25,6 +25,9 @@
- `dayOfWeek`가 invalid이면 요일 조건을 적용하지 않고 `dayOfWeek = null`로 fallback한다. - `dayOfWeek`가 invalid이면 요일 조건을 적용하지 않고 `dayOfWeek = null`로 fallback한다.
- `type != SERIES`이면 `dayOfWeek`는 조회 조건에 적용하지 않고 응답에서 `null`로 내려준다. - `type != SERIES`이면 `dayOfWeek`는 조회 조건에 적용하지 않고 응답에서 `null`로 내려준다.
- `type=ORIGINAL`에는 `dayOfWeek`를 적용하지 않는다. - `type=ORIGINAL`에는 `dayOfWeek`를 적용하지 않는다.
- `type=AUDIO``price > 0`인 유료 공개 오디오만 조회한다.
- `type=FREE``price == 0`인 무료 공개 오디오만 조회하며 `type=AUDIO` 결과와 겹치지 않는다.
- `type=POINT``isPointAvailable == true` 조건을 유지하고 `type=AUDIO`의 유료 조건을 상속하지 않는다.
- 전체 응답은 `totalCount`, `audios`, `series`, `sort`, `dayOfWeek`, `page`, `size`, `hasNext`를 포함한다. - 전체 응답은 `totalCount`, `audios`, `series`, `sort`, `dayOfWeek`, `page`, `size`, `hasNext`를 포함한다.
- `AUDIO`, `FREE`, `POINT``audios`만 채우고 `series`는 빈 배열로 내려준다. - `AUDIO`, `FREE`, `POINT``audios`만 채우고 `series`는 빈 배열로 내려준다.
- `SERIES`, `ORIGINAL``series`만 채우고 `audios`는 빈 배열로 내려준다. - `SERIES`, `ORIGINAL``series`만 채우고 `audios`는 빈 배열로 내려준다.
@@ -243,6 +246,7 @@ interface MainContentAllQueryPort {
memberId: Long?, memberId: Long?,
canViewAdultContent: Boolean, canViewAdultContent: Boolean,
now: LocalDateTime, now: LocalDateTime,
onlyPaid: Boolean = false,
onlyFree: Boolean = false, onlyFree: Boolean = false,
onlyPointAvailable: Boolean = false onlyPointAvailable: Boolean = false
): Int ): Int
@@ -254,6 +258,7 @@ interface MainContentAllQueryPort {
sort: ContentSort, sort: ContentSort,
offset: Long, offset: Long,
limit: Int, limit: Int,
onlyPaid: Boolean = false,
onlyFree: Boolean = false, onlyFree: Boolean = false,
onlyPointAvailable: Boolean = false onlyPointAvailable: Boolean = false
): List<MainContentAllAudio> ): List<MainContentAllAudio>
@@ -559,6 +564,41 @@ interface MainContentAllQueryPort {
- GREEN: `git diff --check` 성공. - GREEN: `git diff --check` 성공.
- 확인: `rg -n "duration|publishedDaysOfWeek|isProceeding|contentCount|paidContentCount" src/main/kotlin/kr/co/vividnext/sodalive/v2/api/content/all src/test/kotlin/kr/co/vividnext/sodalive/v2/api/content/all` 실행 시 공개 DTO 소스에는 제거 대상 필드가 없고, E2E fixture의 공개 조건 설정과 DTO 테스트의 부재 검증만 검색되었다. - 확인: `rg -n "duration|publishedDaysOfWeek|isProceeding|contentCount|paidContentCount" src/main/kotlin/kr/co/vividnext/sodalive/v2/api/content/all src/test/kotlin/kr/co/vividnext/sodalive/v2/api/content/all` 실행 시 공개 DTO 소스에는 제거 대상 필드가 없고, E2E fixture의 공개 조건 설정과 DTO 테스트의 부재 검증만 검색되었다.
### Phase 6: AUDIO 유료 오디오 필터 보강
- [x] **Task 6.1: service와 port 계약에 AUDIO 전용 유료 필터 추가**
- Files:
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/content/all/port/out/MainContentAllQueryPort.kt`
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/content/all/application/MainContentAllQueryService.kt`
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/content/all/application/MainContentAllQueryServiceTest.kt`
- RED: `type=AUDIO`가 audio count/list port를 `onlyPaid=true`, `onlyFree=false`, `onlyPointAvailable=false`로 호출하는 실패 테스트를 작성한다.
- RED: `type=FREE`는 `onlyFree=true`, `onlyPaid=false`를 유지하고, `type=POINT`는 `onlyPointAvailable=true`, `onlyPaid=false`를 유지하는 실패 테스트를 작성한다.
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.content.all.application.MainContentAllQueryServiceTest`
- GREEN: `MainContentAllQueryPort.countAudios(...)`, `findAudios(...)`에 `onlyPaid` 파라미터를 추가하고 `MainContentAllQueryService`의 `MainContentAllType.AUDIO` 분기에서만 `onlyPaid = true`를 전달한다.
- REFACTOR: `FREE`, `POINT` 분기에서 `AUDIO` 전용 유료 필터가 전파되지 않는지 fake port 검증 필드를 정리한다.
- 기대 결과: `resolvedType == MainContentAllType.AUDIO`일 때만 유료 필터가 port 계약에 명시된다.
- 검증 기록:
- RED: `./gradlew test --tests kr.co.vividnext.sodalive.v2.content.all.application.MainContentAllQueryServiceTest` 실행 시 `MainContentAllQueryServiceTest.kt:37`의 `assertTrue(port.lastOnlyPaid)` 실패로 `AUDIO` 분기가 `onlyPaid=true`를 전달하지 않음을 확인했다.
- GREEN: 동일 명령 성공으로 `AUDIO`는 `onlyPaid=true`, `FREE`/`POINT`는 `onlyPaid=false`로 port에 전달됨을 확인했다.
- [x] **Task 6.2: repository와 E2E에서 AUDIO 무료 제외 검증**
- Files:
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/content/all/adapter/out/persistence/DefaultMainContentAllQueryRepository.kt`
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/content/all/adapter/out/persistence/DefaultMainContentAllQueryRepositoryTest.kt`
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/content/all/adapter/in/web/MainContentAllEndToEndTest.kt`
- RED: repository 테스트에 유료 오디오와 무료 오디오 fixture를 함께 만들고, `onlyPaid=true`인 `countAudios(...)`와 `findAudios(...)`가 `price == 0` 오디오를 제외하는 실패 테스트를 작성한다.
- RED: `onlyFree=true`는 기존처럼 `price == 0`만 반환하고, `onlyPointAvailable=true`는 `isPointAvailable == true` 조건을 유지하는 회귀 테스트를 함께 확인한다.
- RED: E2E 테스트에서 `GET /api/v2/audio/contents?type=AUDIO`와 type 미지정 기본 조회가 무료 오디오를 반환하지 않는 실패 테스트를 작성한다.
- 실패 확인:
- `./gradlew test --tests kr.co.vividnext.sodalive.v2.content.all.adapter.out.persistence.DefaultMainContentAllQueryRepositoryTest`
- `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.content.all.adapter.in.web.MainContentAllEndToEndTest`
- GREEN: repository의 오디오 조회 조건에 `onlyPaid=true`일 때만 `price > 0` 조건을 추가한다.
- REFACTOR: `onlyPaid`, `onlyFree`, `onlyPointAvailable` 조건이 각 type의 명시적 필터로만 적용되는지 테스트명과 fixture를 정리한다.
- 기대 결과: `AUDIO` 목록과 전체 개수에서 무료 콘텐츠가 제외되고, `FREE`/`POINT` 조회 계약은 기존 의미를 유지한다.
- 검증 기록:
- RED: `./gradlew test --tests kr.co.vividnext.sodalive.v2.content.all.adapter.out.persistence.DefaultMainContentAllQueryRepositoryTest --tests kr.co.vividnext.sodalive.v2.api.content.all.adapter.in.web.MainContentAllEndToEndTest` 실행 시 `DefaultMainContentAllQueryRepositoryTest.kt:70`, `MainContentAllEndToEndTest.kt:83` 실패로 `onlyPaid` 조건이 무료 오디오를 제외하지 못함을 확인했다.
- GREEN: `./gradlew test --tests kr.co.vividnext.sodalive.v2.content.all.application.MainContentAllQueryServiceTest --tests kr.co.vividnext.sodalive.v2.content.all.adapter.out.persistence.DefaultMainContentAllQueryRepositoryTest --tests kr.co.vividnext.sodalive.v2.api.content.all.adapter.in.web.MainContentAllEndToEndTest` 성공으로 service, repository, E2E의 AUDIO 유료 필터를 확인했다.
--- ---
## 4. 실행 명령 ## 4. 실행 명령
@@ -570,6 +610,7 @@ interface MainContentAllQueryPort {
- Service 테스트: `./gradlew test --tests kr.co.vividnext.sodalive.v2.content.all.application.MainContentAllQueryServiceTest` - Service 테스트: `./gradlew test --tests kr.co.vividnext.sodalive.v2.content.all.application.MainContentAllQueryServiceTest`
- Repository 테스트: `./gradlew test --tests kr.co.vividnext.sodalive.v2.content.all.adapter.out.persistence.DefaultMainContentAllQueryRepositoryTest` - Repository 테스트: `./gradlew test --tests kr.co.vividnext.sodalive.v2.content.all.adapter.out.persistence.DefaultMainContentAllQueryRepositoryTest`
- End-to-end 테스트: `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.content.all.adapter.in.web.MainContentAllEndToEndTest` - End-to-end 테스트: `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.content.all.adapter.in.web.MainContentAllEndToEndTest`
- AUDIO 유료 필터 회귀 테스트: `./gradlew test --tests kr.co.vividnext.sodalive.v2.content.all.application.MainContentAllQueryServiceTest --tests kr.co.vividnext.sodalive.v2.content.all.adapter.out.persistence.DefaultMainContentAllQueryRepositoryTest --tests kr.co.vividnext.sodalive.v2.api.content.all.adapter.in.web.MainContentAllEndToEndTest`
- 전체 신규 패키지 테스트: `./gradlew test --tests 'kr.co.vividnext.sodalive.v2.content.all.*' --tests 'kr.co.vividnext.sodalive.v2.api.content.all.*'` - 전체 신규 패키지 테스트: `./gradlew test --tests 'kr.co.vividnext.sodalive.v2.content.all.*' --tests 'kr.co.vividnext.sodalive.v2.api.content.all.*'`
- 포맷 검증: `./gradlew ktlintCheck` - 포맷 검증: `./gradlew ktlintCheck`
- 문서 변경 후 명령 유효성 확인: `./gradlew tasks --all` - 문서 변경 후 명령 유효성 확인: `./gradlew tasks --all`
@@ -612,3 +653,17 @@ interface MainContentAllQueryPort {
- GREEN: `./gradlew ktlintCheck` 성공. - GREEN: `./gradlew ktlintCheck` 성공.
- GREEN: `git diff --check` 성공. - GREEN: `git diff --check` 성공.
- 확인: `rg -n "duration|publishedDaysOfWeek|isProceeding|contentCount|paidContentCount" src/main/kotlin/kr/co/vividnext/sodalive/v2/api/content/all src/test/kotlin/kr/co/vividnext/sodalive/v2/api/content/all` 실행 시 공개 DTO 소스에는 제거 대상 필드가 없고, E2E fixture의 공개 조건 설정과 DTO 테스트의 부재 검증만 검색되었다. - 확인: `rg -n "duration|publishedDaysOfWeek|isProceeding|contentCount|paidContentCount" src/main/kotlin/kr/co/vividnext/sodalive/v2/api/content/all src/test/kotlin/kr/co/vividnext/sodalive/v2/api/content/all` 실행 시 공개 DTO 소스에는 제거 대상 필드가 없고, E2E fixture의 공개 조건 설정과 DTO 테스트의 부재 검증만 검색되었다.
- 2026-07-10 AUDIO 유료 콘텐츠 문서 선반영
- 문서: `docs/20260624_메인_콘텐츠_전체_탭_API/prd.md`에 `type=AUDIO`는 `price > 0` 유료 공개 오디오만 조회하도록 요구사항을 보강했다.
- 문서: `docs/20260624_메인_콘텐츠_전체_탭_API/plan-task.md`에 `onlyPaid` port 계약 초안과 Phase 6 미완료 task를 추가했다.
- GREEN: `git diff --check` 성공.
- GREEN: `./gradlew tasks --all`은 최초 샌드박스 실행에서 `~/.gradle` lock 파일 접근 권한으로 실패했고, 권한 승인 후 재실행해 성공했다.
- 참고: 이번 단계는 사용자 요청에 따라 연관 문서만 수정했으며 production/test 코드는 변경하지 않았다.
- 2026-07-10 Phase 6 AUDIO 유료 필터 구현 검증
- RED: service 테스트에서 `MainContentAllQueryServiceTest.kt:37`의 `assertTrue(port.lastOnlyPaid)` 실패로 `AUDIO` 분기의 `onlyPaid` 미전달을 확인했다.
- GREEN: `./gradlew test --tests kr.co.vividnext.sodalive.v2.content.all.application.MainContentAllQueryServiceTest` 성공으로 `AUDIO`만 `onlyPaid=true`를 전달하고 `FREE`/`POINT`는 기존 필터 의미를 유지함을 확인했다.
- RED: repository/E2E 테스트에서 `DefaultMainContentAllQueryRepositoryTest.kt:70`, `MainContentAllEndToEndTest.kt:83` 실패로 `onlyPaid` 조건이 `price == 0` 무료 오디오를 제외하지 못함을 확인했다.
- GREEN: `./gradlew test --tests kr.co.vividnext.sodalive.v2.content.all.application.MainContentAllQueryServiceTest --tests kr.co.vividnext.sodalive.v2.content.all.adapter.out.persistence.DefaultMainContentAllQueryRepositoryTest --tests kr.co.vividnext.sodalive.v2.api.content.all.adapter.in.web.MainContentAllEndToEndTest` 성공으로 명시적 `type=AUDIO`와 기본 type 조회에서 무료 오디오가 제외됨을 확인했다.
- GREEN: `./gradlew test` 성공.
- GREEN: `./gradlew ktlintCheck` 성공.
- GREEN: `git diff --check` 성공.

View File

@@ -1,7 +1,7 @@
# PRD: 메인 콘텐츠 전체 탭 API # PRD: 메인 콘텐츠 전체 탭 API
## 1. Overview ## 1. Overview
메인 콘텐츠 탭의 내부 전체 탭에서 오디오, 시리즈, 오리지널, 무료, 포인트 구분별 공개 콘텐츠를 정렬과 페이징으로 조회하는 v2 API를 제공한다. 메인 콘텐츠 탭의 내부 전체 탭에서 유료 오디오, 시리즈, 오리지널, 무료, 포인트 구분별 공개 콘텐츠를 정렬과 페이징으로 조회하는 v2 API를 제공한다.
--- ---
@@ -17,6 +17,8 @@
- 메인 콘텐츠 전체 탭 조회 API를 `kr.co.vividnext.sodalive.v2` 하위 신규 코드로 제공한다. - 메인 콘텐츠 전체 탭 조회 API를 `kr.co.vividnext.sodalive.v2` 하위 신규 코드로 제공한다.
- 기존 패턴과 동일하게 API 조립 계층과 도메인 조회 계층을 분리한다. - 기존 패턴과 동일하게 API 조립 계층과 도메인 조회 계층을 분리한다.
- 구분은 `AUDIO`, `SERIES`, `ORIGINAL`, `FREE`, `POINT`를 지원한다. - 구분은 `AUDIO`, `SERIES`, `ORIGINAL`, `FREE`, `POINT`를 지원한다.
- `AUDIO` 구분은 유료 오디오 콘텐츠만 조회한다.
- `FREE` 구분은 무료 오디오 콘텐츠만 조회하며, `AUDIO` 구분 결과와 가격 조건이 겹치지 않는다.
- 공개된 콘텐츠만 조회한다. - 공개된 콘텐츠만 조회한다.
- 회원이 차단했거나 회원을 차단한 크리에이터의 콘텐츠는 노출하지 않는다. - 회원이 차단했거나 회원을 차단한 크리에이터의 콘텐츠는 노출하지 않는다.
- 비회원은 19금 콘텐츠를 노출하지 않는다. - 비회원은 19금 콘텐츠를 노출하지 않는다.
@@ -48,7 +50,7 @@
--- ---
## 6. User Stories ## 6. User Stories
- 사용자는 오디오 콘텐츠 전체 목록을 최신순, 인기순, 가격순으로 보고 싶다. - 사용자는 유료 오디오 콘텐츠 전체 목록을 최신순, 인기순, 가격순으로 보고 싶다.
- 사용자는 선택한 요일의 시리즈 목록을 보고 싶다. - 사용자는 선택한 요일의 시리즈 목록을 보고 싶다.
- 사용자는 오리지널 시리즈만 따로 보고 싶다. - 사용자는 오리지널 시리즈만 따로 보고 싶다.
- 사용자는 무료 오디오만 따로 보고 싶다. - 사용자는 무료 오디오만 따로 보고 싶다.
@@ -68,7 +70,7 @@
- 회원 조회 시 `@AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?` 패턴을 사용한다. - 회원 조회 시 `@AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?` 패턴을 사용한다.
- 요청 query parameter는 `type`, `sort`, `dayOfWeek`, `page`, `size`를 사용한다. - 요청 query parameter는 `type`, `sort`, `dayOfWeek`, `page`, `size`를 사용한다.
- `type` 값은 아래 enum으로 정의한다. - `type` 값은 아래 enum으로 정의한다.
- `AUDIO`: 오디오 - `AUDIO`: 유료 오디오
- `SERIES`: 시리즈 - `SERIES`: 시리즈
- `ORIGINAL`: 오리지널 - `ORIGINAL`: 오리지널
- `FREE`: 무료 - `FREE`: 무료
@@ -117,13 +119,15 @@
### Feature C. 오디오 구분 ### Feature C. 오디오 구분
#### Requirements #### Requirements
- `type=AUDIO`는 차단 관계가 아닌 모든 크리에이터의 공개 오디오 콘텐츠를 조회한다. - `type=AUDIO`는 차단 관계가 아닌 모든 크리에이터의 유료 공개 오디오 콘텐츠를 조회한다.
- 전체 개수는 같은 공개/차단/성인 콘텐츠 조건을 적용한 오디오 콘텐츠 개수다. - 유료 오디오는 `price > 0`인 공개 오디오로 정의한다.
- 전체 개수는 같은 공개/차단/성인 콘텐츠 조건과 유료 조건을 적용한 오디오 콘텐츠 개수다.
- 응답 목록은 `audios`에 내려주고 `series`는 빈 배열로 내려준다. - 응답 목록은 `audios`에 내려주고 `series`는 빈 배열로 내려준다.
- 응답 item은 기존 추천 탭의 `AudioCardResponse` 필드 의미를 우선 재사용한다. - 응답 item은 기존 추천 탭의 `AudioCardResponse` 필드 의미를 우선 재사용한다.
#### Edge Cases #### Edge Cases
- 시리즈에 속하지 않은 오디오도 목록에 포함한다. - 시리즈에 속하지 않은 유료 오디오도 목록에 포함한다.
- 무료 오디오는 `type=AUDIO` 목록과 개수에서 제외하고 `type=FREE`에서만 조회한다.
- 오디오의 오리지널 여부는 기존 추천 탭과 동일하게 해당 오디오가 속한 시리즈의 `isOriginal` 기준으로 판단한다. - 오디오의 오리지널 여부는 기존 추천 탭과 동일하게 해당 오디오가 속한 시리즈의 `isOriginal` 기준으로 판단한다.
### Feature D. 시리즈 구분 ### Feature D. 시리즈 구분
@@ -161,7 +165,8 @@
#### Requirements #### Requirements
- `type=FREE`는 차단 관계가 아닌 모든 크리에이터의 무료 오디오 콘텐츠를 조회한다. - `type=FREE`는 차단 관계가 아닌 모든 크리에이터의 무료 오디오 콘텐츠를 조회한다.
- 무료 오디오는 `price == 0`인 공개 오디오로 정의한다. - 무료 오디오는 `price == 0`인 공개 오디오로 정의한다.
- 정렬, 페이징, 전체 개수, 성인 콘텐츠 정책은 `AUDIO`와 동일하다. - 공개/차단/성인 콘텐츠 정책과 정렬, 페이징, 전체 개수 산정 방식은 오디오 공통 정책을 따른다.
- `type=FREE``type=AUDIO`의 유료 조건을 상속하지 않는다.
- 응답 목록은 `audios`에 내려주고 `series`는 빈 배열로 내려준다. - 응답 목록은 `audios`에 내려주고 `series`는 빈 배열로 내려준다.
#### Edge Cases #### Edge Cases
@@ -172,7 +177,8 @@
#### Requirements #### Requirements
- `type=POINT`는 차단 관계가 아닌 모든 크리에이터의 포인트 사용 가능 오디오 콘텐츠를 조회한다. - `type=POINT`는 차단 관계가 아닌 모든 크리에이터의 포인트 사용 가능 오디오 콘텐츠를 조회한다.
- 포인트 오디오는 `isPointAvailable == true`인 공개 오디오로 정의한다. - 포인트 오디오는 `isPointAvailable == true`인 공개 오디오로 정의한다.
- 정렬, 페이징, 전체 개수, 성인 콘텐츠 정책은 `AUDIO`와 동일하다. - 공개/차단/성인 콘텐츠 정책과 정렬, 페이징, 전체 개수 산정 방식은 오디오 공통 정책을 따른다.
- `type=POINT``type=AUDIO`의 유료 조건을 상속하지 않고 `isPointAvailable == true` 조건만 추가한다.
- 응답 목록은 `audios`에 내려주고 `series`는 빈 배열로 내려준다. - 응답 목록은 `audios`에 내려주고 `series`는 빈 배열로 내려준다.
#### Edge Cases #### Edge Cases
@@ -215,7 +221,7 @@ Authorization: Bearer {accessToken} (optional)
- 비회원 조회를 허용한다. - 비회원 조회를 허용한다.
- `SecurityConfig``GET /api/v2/audio/contents` permitAll 설정을 추가한다. - `SecurityConfig``GET /api/v2/audio/contents` permitAll 설정을 추가한다.
- `type` 미지정 시 `AUDIO`를 기본값으로 사용한다. - `type` 미지정 시 `AUDIO`를 기본값으로 사용하며, 유료 오디오만 조회한다.
- `sort` 미지정 또는 invalid 값은 `LATEST`로 fallback한다. - `sort` 미지정 또는 invalid 값은 `LATEST`로 fallback한다.
- `type=SERIES`에서 요일 선택이 필요하면 `dayOfWeek`를 함께 보낸다. - `type=SERIES`에서 요일 선택이 필요하면 `dayOfWeek`를 함께 보낸다.
- 예: `GET /api/v2/audio/contents?type=SERIES&dayOfWeek=MON&sort=LATEST&page=0&size=20` - 예: `GET /api/v2/audio/contents?type=SERIES&dayOfWeek=MON&sort=LATEST&page=0&size=20`
@@ -310,6 +316,8 @@ data class MainContentSeriesResponse(
- `LangContext`: 시리즈 제목 다국어 처리 - `LangContext`: 시리즈 제목 다국어 처리
### 구현 주의사항 ### 구현 주의사항
- `type=AUDIO``price > 0` 조건을 적용하고, `type=FREE``price == 0` 조건을 적용해 두 구분의 결과가 겹치지 않게 한다.
- `type=POINT``isPointAvailable == true` 조건을 유지하며, `AUDIO` 전용 유료 필터를 암묵적으로 재사용하지 않는다.
- 기존 추천 탭의 무료/포인트 오디오는 랜덤 조회지만, 전체 탭은 사용자가 선택한 `sort` 기준으로 조회한다. - 기존 추천 탭의 무료/포인트 오디오는 랜덤 조회지만, 전체 탭은 사용자가 선택한 `sort` 기준으로 조회한다.
- 기존 legacy 요일별 시리즈 API는 `dayOfWeek` query parameter로 `SeriesPublishedDaysOfWeek` enum을 받으므로 v2 전체 탭도 같은 parameter 이름과 enum 값을 사용한다. - 기존 legacy 요일별 시리즈 API는 `dayOfWeek` query parameter로 `SeriesPublishedDaysOfWeek` enum을 받으므로 v2 전체 탭도 같은 parameter 이름과 enum 값을 사용한다.
- 기존 v2 채널 오디오/시리즈 탭처럼 invalid parameter fallback을 유지하려면 controller에서는 `dayOfWeek: String?`으로 받고 policy/service 경계에서 `SeriesPublishedDaysOfWeek`로 보정한다. - 기존 v2 채널 오디오/시리즈 탭처럼 invalid parameter fallback을 유지하려면 controller에서는 `dayOfWeek: String?`으로 받고 policy/service 경계에서 `SeriesPublishedDaysOfWeek`로 보정한다.

View File

@@ -0,0 +1,274 @@
# 메인 홈 추천 AI 캐릭터 스냅샷 산식 변경 Plan/Task
## 시나리오 계약
- Happy path: KST 전날 하루 데이터를 UTC 범위로 변환해 AI 캐릭터 점수를 계산하고, 점수순 상위 20개 스냅샷을 저장한다. Real surface: `DefaultHomeRecommendationQueryRepositoryTest`, `RecommendationSnapshotRefreshServiceTest`.
- Edge: `ChatCharacter.createdAt`이 null이거나 미래인 캐릭터, 비활성 캐릭터, 비활성 메시지/participant/user, 비활성 재팔로우 row는 AI 캐릭터 스냅샷 후보에서 제외된다. Real surface: `DefaultHomeRecommendationQueryRepositoryTest`.
- Fallback: 최신 `AI_CHARACTER` 스냅샷이 없으면 fallback refresh를 같은 application service 경로로 실행하고, 홈 API는 최대 1,500ms만 기다린다. timeout 이후에도 refresh는 가능한 경우 백그라운드에서 완료되어 다음 요청부터 저장된 스냅샷을 사용한다. Real surface: `HomeRecommendationQueryServiceTest`, fallback service unit test.
- Adjacent regression: 메인 홈 추천 API URL과 AI 캐릭터 응답 필드는 변경하지 않는다. 최근 응원/인기 커뮤니티 스냅샷 산식과 조회 동작은 이번 변경으로 바꾸지 않는다. Real surface: 기존 `HomeRecommendationControllerTest`, `RecommendationSnapshotRefreshServiceTest`.
## 범위와 전제
- 기존 `docs/20260529_메인_홈_추천_API/prd.md`는 과거 구현 기준으로 유지한다.
- 이번 문서는 `docs/20260709_메인_홈_추천_AI캐릭터_스냅샷/prd.md`의 후속 구현 계획이다.
- 신규 공개 API, 신규 응답 필드, 운영 DDL 추가는 기본 범위에 포함하지 않는다.
- fallback refresh는 AI 캐릭터 섹션만 대상으로 한다. 스케줄러의 전체 일 스냅샷 갱신은 유지하되, AI 캐릭터 갱신 로직은 fallback과 같은 application service 경로를 사용하도록 분리한다.
- Redisson `RLock`은 thread-bound이므로, 백그라운드 refresh를 사용할 때 lock 획득과 해제는 refresh를 실제 수행하는 같은 worker thread 안에서 처리한다.
## 기존 AI_CHARACTER 로직 유지/변경 경계
- 유지: `RecommendedSectionType.AI_CHARACTER` enum 값과 code는 변경하지 않는다.
- 유지: `RecommendationSnapshot` 저장 구조와 `RecommendationSnapshotPort.findLatestSnapshots(...)` 조회 계약은 변경하지 않는다.
- 유지: `HomeRecommendationQueryService.findAiCharacterRecommendations(...)`의 정상 경로는 최신 스냅샷 조회 후 상세 조립이다. fallback은 스냅샷이 없을 때만 추가된다.
- 유지: `DefaultHomeRecommendationQueryRepository.findAiCharacterRecommendationDetails(...)`의 상세 조립, `characterId`, `creatorId`, 원작명, 전체 채팅 수, 활성 `creatorMember` 필터는 변경하지 않는다.
- 유지: `HomeRecommendationFacade`, `HomeRecommendationController`, `HomeRecommendationResponse.HomeAiCharacterItem`의 공개 API URL과 응답 필드는 변경하지 않는다.
- 유지: 최근 응원/인기 커뮤니티 스냅샷 산식, window, 저장 limit, random tie-breaker 정렬은 변경하지 않는다.
- 변경: `findAiCharacterSnapshots(...)`의 입력 window, AI 캐릭터 점수 산식, 신규 부스트 값, 팔로우 증가 수 포함, 후보 제외 조건, top 20 동점 정렬 기준만 변경한다.
- 변경: `RecommendationSnapshotRefreshService`에는 AI 캐릭터 단일 refresh 경로를 추가하되, 기존 전체 일 refresh가 최근 응원/인기 커뮤니티까지 함께 갱신하는 동작은 유지한다.
- 추가: 최신 AI 캐릭터 스냅샷이 없을 때만 fallback refresh orchestration을 추가한다.
## 실행 명령
- 문서 명령 확인: `./gradlew tasks --all`
- 산식/기간 단위 테스트: `./gradlew test --tests kr.co.vividnext.sodalive.v2.recommendation.domain.RecommendationScorePolicyTest --tests kr.co.vividnext.sodalive.v2.recommendation.domain.RecommendationSnapshotWindowPolicyTest`
- 스냅샷 refresh 서비스 테스트: `./gradlew test --tests kr.co.vividnext.sodalive.v2.recommendation.application.RecommendationSnapshotRefreshServiceTest`
- AI 캐릭터 query 통합 테스트: `./gradlew test --tests kr.co.vividnext.sodalive.v2.recommendation.adapter.out.persistence.DefaultHomeRecommendationQueryRepositoryTest`
- 홈 추천 조회/fallback 테스트: `./gradlew test --tests kr.co.vividnext.sodalive.v2.recommendation.application.HomeRecommendationQueryServiceTest`
- 홈 API 회귀 테스트: `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.home.HomeRecommendationControllerTest`
- 포맷 검증: `./gradlew ktlintCheck`
- 전체 회귀: `./gradlew test`
---
### Phase 1: 문서와 기준 고정
- [x] **Task 1.1: PRD 기반 구현 계획 문서 작성**
- 파일 경로:
- Modify: `docs/20260709_메인_홈_추천_AI캐릭터_스냅샷/prd.md`
- Create: `docs/20260709_메인_홈_추천_AI캐릭터_스냅샷/plan-task.md`
- RED: 문서 작업은 TDD 예외. TDD 예외 사유: 코드 동작 변경 전 요구사항과 구현 순서를 고정하는 작업이다.
- GREEN: PRD의 산식, KST→UTC 전날 범위, top 20, 동점 정렬, fallback timeout/lock 정책을 task로 분해한다.
- REFACTOR: 기존 홈 추천 구현 파일과 테스트 파일 기준으로 task별 수정/검증 경로를 맞춘다.
- 기대 결과: 구현 시작 전에 PRD와 plan-task가 같은 디렉터리에 준비된다.
---
### Phase 2: 산식과 시간 범위 정책
- [x] **Task 2.1: AI 캐릭터 점수 계산식 계약 테스트 보강**
- 파일 경로:
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/recommendation/domain/RecommendationScoreSpec.kt`
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/recommendation/domain/RecommendationScorePolicy.kt`
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/recommendation/domain/RecommendationScorePolicyTest.kt`
- RED: AI 캐릭터 점수가 `aiChatCount * 0.45 + activeUserCount * 0.35 + followIncreaseCount * 0.20`에 신규 부스트를 곱하도록 실패 테스트를 추가한다. 계산을 DB에서 하든 Kotlin에서 하든 동일한 기대값을 검증할 수 있도록 계산식 자체를 독립 단위 테스트로 고정한다.
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.recommendation.domain.RecommendationScorePolicyTest`
- GREEN: 기존 크리에이터 부스트 `1.5/1.3/1.2`는 유지하고, AI 캐릭터 전용 부스트 상수와 `followIncreaseCount`를 받는 AI 점수 함수를 추가/수정한다.
- REFACTOR: 산식 상수명은 PRD 용어가 드러나도록 `AI_CHAT_WEIGHT`, `AI_ACTIVE_USER_WEIGHT`, `AI_FOLLOW_INCREASE_WEIGHT`, `AI_NEW_BOOST_*` 형태로 정리한다.
- 계산식 테스트 케이스:
- `aiChatCount=0`, `activeUserCount=0`, `followIncreaseCount=0`, `newBoost=1.0`이면 `0.0`
- `aiChatCount=10`, `activeUserCount=0`, `followIncreaseCount=0`, `newBoost=1.0`이면 `4.5`
- `aiChatCount=0`, `activeUserCount=10`, `followIncreaseCount=0`, `newBoost=1.0`이면 `3.5`
- `aiChatCount=0`, `activeUserCount=0`, `followIncreaseCount=10`, `newBoost=1.0`이면 `2.0`
- `aiChatCount=10`, `activeUserCount=10`, `followIncreaseCount=10`, `newBoost=1.0`이면 `10.0`
- `aiChatCount=10`, `activeUserCount=10`, `followIncreaseCount=10`, `newBoost=1.15`이면 `11.5`
- 소수 오차는 `0.0001` 이내로 검증한다.
- 기대 결과: AI 캐릭터 산식만 변경되고 최근 응원/인기 커뮤니티/최근 데뷔 산식은 변경되지 않는다.
- [x] **Task 2.2: AI 캐릭터 신규 부스트 경계값 계약 테스트 보강**
- 파일 경로:
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/recommendation/domain/RecommendationScoreSpec.kt`
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/recommendation/domain/RecommendationScorePolicy.kt`
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/recommendation/domain/RecommendationScorePolicyTest.kt`
- RED: AI 캐릭터 신규 부스트가 생성일 기준 0일/10일 `1.15`, 11일/20일 `1.10`, 21일/30일 `1.05`, 31일 `1.0`으로 계산되는 실패 테스트를 작성한다. 같은 테스트에서 기존 크리에이터 신규 부스트 값이 변경되지 않았는지도 확인한다.
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.recommendation.domain.RecommendationScorePolicyTest`
- GREEN: AI 캐릭터 전용 부스트 계산 함수를 PRD 값으로 변경한다.
- REFACTOR: 날짜 차이 계산 방식은 기존 `ChronoUnit.DAYS.between(baseAt.toLocalDate(), 기준일.toLocalDate())`와 일치하도록 유지한다.
- 기대 결과: 생성일 경계값에 대한 산식 근거가 Kotlin 단위 테스트로 고정된다.
- [x] **Task 2.3: KST 전날 하루를 UTC 조회 범위로 변환하는 정책 추가**
- 파일 경로:
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/recommendation/domain/RecommendationSnapshotWindowPolicy.kt`
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/recommendation/domain/RecommendationSnapshotWindowPolicyTest.kt`
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/recommendation/application/RecommendationSnapshotRefreshService.kt`
- RED: `nowUtc = 2026-07-09T21:00:00`이면 KST 기준 전날인 `2026-07-09 00:00:00 <= t < 2026-07-10 00:00:00`이 UTC `2026-07-08T15:00:00 <= t < 2026-07-09T15:00:00` half-open 범위로 변환되고, 저장 `snapshotAt``2026-07-09T14:59:59`가 되는 실패 테스트를 작성한다.
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.recommendation.domain.RecommendationSnapshotWindowPolicyTest`
- GREEN: `previousKstDayUtcWindow(nowUtc)` 정책을 추가하고, AI 캐릭터 스냅샷 계산에는 `windowStartUtc`, `windowEndExclusiveUtc`를 전달한다.
- REFACTOR: 기존 최근 응원/인기 커뮤니티 7일 window 계산과 AI 캐릭터 전날 window 계산이 섞이지 않도록 함수명을 분리한다.
- 기대 결과: 운영 DB 시간이 UTC여도 AI 캐릭터 집계 기준은 KST 전날 하루로 고정된다.
---
### Phase 3: AI 캐릭터 스냅샷 Query 변경
- [x] **Task 3.1: 전날 AI 답변 수/활성 사용자 수/팔로우 증가 수 집계**
- 파일 경로:
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/recommendation/adapter/out/persistence/DefaultHomeRecommendationQueryRepository.kt`
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/recommendation/port/out/HomeRecommendationQueryPort.kt`
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/recommendation/adapter/out/persistence/DefaultHomeRecommendationQueryRepositoryTest.kt`
- RED: `findAiCharacterSnapshots(windowStartUtc, windowEndExclusiveUtc, 20)`가 전날 범위 안의 AI 답변 수, 중복 없는 활성 사용자 수, `ChatCharacter.creatorMember.id` 대상 신규 활성 `creator_following` row 수를 반영하도록 실패 테스트를 작성한다. 범위 밖 메시지/팔로우, 비활성 메시지/participant/user/following은 제외되는 fixture를 포함한다.
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.recommendation.adapter.out.persistence.DefaultHomeRecommendationQueryRepositoryTest`
- GREEN: AI 캐릭터 snapshot native query 또는 QueryDSL 집계에 `creator_following` 전날 신규 row 집계를 추가하고, `score = ((chat * 0.45) + (activeUser * 0.35) + (followIncrease * 0.20)) * aiNewBoost`로 계산한다.
- REFACTOR: 기존 AI 발화 수/활성 사용자 수 테스트는 전날 window 기준으로 이름과 fixture를 갱신한다. 상세 조회 로직 `findAiCharacterRecommendationDetails(...)`는 변경하지 않는다.
- 기대 결과: 팔로우 증가 수가 AI 캐릭터 점수에 포함되고, 재활성화 row는 `created_at`이 전날 신규 row가 아니면 집계되지 않는다.
- [x] **Task 3.2: AI 신규 부스트와 후보 제외 조건 적용**
- 파일 경로:
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/recommendation/adapter/out/persistence/DefaultHomeRecommendationQueryRepository.kt`
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/recommendation/adapter/out/persistence/DefaultHomeRecommendationQueryRepositoryTest.kt`
- RED: `ChatCharacter.createdAt` 기준 AI 전용 부스트가 적용되는 테스트, `createdAt` null 또는 기준 시각보다 미래인 캐릭터가 후보에서 제외되는 테스트, `creatorMember`가 없거나 비활성/비 `CREATOR`/비 `AI_CHARACTER`인 캐릭터가 제외되는 테스트를 작성한다.
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.recommendation.adapter.out.persistence.DefaultHomeRecommendationQueryRepositoryTest`
- GREEN: AI 스냅샷 후보 query에 `ChatCharacter.createdAt is not null`, `createdAt < windowEndExclusiveUtc`, 활성 `creatorMember` 조건을 추가하고 AI 전용 신규 부스트 값을 적용한다.
- REFACTOR: 신규 부스트 계산 기준과 query의 날짜 비교 기준이 `RecommendationSnapshotWindowPolicy`의 UTC end-exclusive와 일치하는지 정리한다.
- 기대 결과: PRD의 null/future 생성일 제외와 AI 전용 신규 부스트 경계가 DB 집계 테스트로 고정된다.
- [x] **Task 3.3: top 20 저장 후보와 동점 생성일 정렬 적용**
- 파일 경로:
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/recommendation/adapter/out/persistence/DefaultHomeRecommendationQueryRepository.kt`
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/recommendation/adapter/out/persistence/DefaultHomeRecommendationQueryRepositoryTest.kt`
- RED: AI 캐릭터 후보가 21개 이상일 때 최종 점수 기준 20개만 반환되고, 점수가 동일하면 `ChatCharacter.createdAt`이 더 늦은 캐릭터가 먼저 반환되는 실패 테스트를 작성한다.
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.recommendation.adapter.out.persistence.DefaultHomeRecommendationQueryRepositoryTest`
- GREEN: AI 스냅샷 query의 정렬을 `score desc`, `chat_character.created_at desc` 기준으로 바꾸고 최종 limit 20을 적용한다. 기존 `RecommendationSnapshotRecord.randomTieBreaker` 필드는 schema 유지를 위해 값을 채우되 AI 정렬 기준으로 사용하지 않는다.
- REFACTOR: 최근 응원/인기 커뮤니티의 random tie-breaker 정렬은 변경하지 않는다.
- 기대 결과: AI 캐릭터 스냅샷만 동점 생성일 최신순 정책을 사용한다.
---
### Phase 4: Refresh Service와 Scheduler 정리
- [x] **Task 4.1: AI 캐릭터 단일 refresh 경로 분리**
- 파일 경로:
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/recommendation/application/RecommendationSnapshotRefreshService.kt`
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/recommendation/application/RecommendationSnapshotRefreshServiceTest.kt`
- RED: `refreshAiCharacterSnapshots(nowUtc)`가 KST 전날 하루 UTC window로 `queryPort.findAiCharacterSnapshots(windowStartUtc, windowEndExclusiveUtc, 20)`을 호출하고 `AI_CHARACTER` 스냅샷만 replace하는 실패 테스트를 작성한다.
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.recommendation.application.RecommendationSnapshotRefreshServiceTest`
- GREEN: 전체 일 refresh 내부에서 AI 캐릭터 부분을 public 또는 internal method로 분리하고, fallback도 같은 method를 호출할 수 있게 한다.
- REFACTOR: 최근 응원/인기 커뮤니티 refresh는 기존 7일 window와 저장 limit을 유지한다.
- 기대 결과: scheduler와 fallback이 같은 AI 스냅샷 생성 로직을 재사용할 수 있다.
- [x] **Task 4.2: Scheduler가 AI section lock 경로를 재사용하도록 보강**
- 파일 경로:
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/recommendation/adapter/out/scheduler/RecommendationSnapshotScheduler.kt`
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/recommendation/application/RecommendationSnapshotRefreshService.kt`
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/recommendation/application/RecommendationSnapshotRefreshServiceTest.kt`
- RED: 스케줄러 또는 refresh service 테스트에서 AI 캐릭터 refresh가 section lock key를 사용하고, lock 획득 실패 시 AI 중복 refresh를 실행하지 않는 실패 테스트를 작성한다.
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.recommendation.application.RecommendationSnapshotRefreshServiceTest`
- GREEN: 기존 전체 scheduler lock과 최근 응원/인기 커뮤니티 갱신 흐름은 유지하되, AI 캐릭터 refresh만 fallback과 충돌하지 않도록 공통 section lock key를 사용한다. 권장 key는 `lock:recommendation-snapshot-refresh:AI_CHARACTER`다.
- REFACTOR: Redisson `RLock` 획득/해제는 같은 thread에서 수행되도록 lock 처리 위치를 정리한다.
- 기대 결과: 스케줄러 실행 중 fallback이 동시에 AI 캐릭터 스냅샷을 중복 생성하지 않는다.
---
### Phase 5: 스냅샷 없음 fallback refresh
- [x] **Task 5.1: AI 캐릭터 fallback refresh service 추가**
- 파일 경로:
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/recommendation/application/AiCharacterSnapshotFallbackPort.kt`
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/recommendation/application/RecommendationSnapshotRefreshService.kt`
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/recommendation/application/RecommendationSnapshotFallbackServiceTest.kt`
- RED: 최신 `AI_CHARACTER` 스냅샷이 없을 때 fallback service가 AI refresh를 요청하고, refresh 완료 후 저장된 스냅샷을 다시 조회하도록 실패 테스트를 작성한다. 이미 스냅샷이 있으면 refresh를 요청하지 않는 테스트도 추가한다.
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.recommendation.application.RecommendationSnapshotFallbackServiceTest`
- GREEN: fallback service를 추가해 double-check 조회, AI 단일 refresh 호출, refresh 후 재조회 흐름을 구현한다.
- REFACTOR: fallback service는 점수 계산/상세 조립을 직접 하지 않고 snapshot 조회와 refresh orchestration만 담당한다.
- 기대 결과: 스냅샷 없음 fallback이 scheduler와 같은 저장 로직을 재사용한다.
- [x] **Task 5.2: fallback lock, timeout, 백그라운드 완료 정책 구현**
- 파일 경로:
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/recommendation/application/RecommendationSnapshotFallbackService.kt`
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/recommendation/application/RecommendationSnapshotFallbackServiceTest.kt`
- RED: lock 대기 최대 300ms, 홈 API refresh 완료 대기 최대 1,500ms, timeout 시 현재 요청 빈 결과 반환, timeout 후 background refresh 계속 진행, lock이 잡혀 있는 동안 후속 요청은 중복 refresh를 시작하지 않는 테스트를 작성한다.
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.recommendation.application.RecommendationSnapshotFallbackServiceTest`
- GREEN: refresh 작업을 worker thread에서 실행하고 해당 thread 안에서 Redisson lock을 획득/해제한다. 홈 요청 thread는 future 완료를 최대 1,500ms만 기다리고, timeout 시 future를 cancel하지 않는다.
- REFACTOR: 실제 구현에서 `TaskExecutor`, `CompletableFuture`, 또는 기존 async 인프라 중 하나만 사용한다. 테스트에서는 deterministic executor/fake clock을 사용해 sleep 기반 테스트를 피한다.
- 기대 결과: 첫 요청 timeout 후에도 refresh가 완료되면 다음 요청은 저장된 스냅샷을 사용하고, 동시 요청은 중복 refresh를 만들지 않는다.
- [x] **Task 5.3: 홈 AI 캐릭터 조회에 fallback 연결**
- 파일 경로:
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/recommendation/application/HomeRecommendationQueryService.kt`
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/recommendation/application/HomeRecommendationQueryServiceTest.kt`
- RED: `findAiCharacterRecommendations`가 최신 스냅샷 없음이면 fallback service를 호출한 뒤 다시 스냅샷을 조회하고 상세를 조립하는 실패 테스트를 작성한다. fallback 후에도 스냅샷이 없으면 빈 리스트를 반환하는 테스트를 추가한다.
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.recommendation.application.HomeRecommendationQueryServiceTest`
- GREEN: AI 캐릭터 조회에서 snapshotPort 1차 조회가 비어 있을 때만 fallback service를 호출하고, 재조회 결과를 기존 상세 조립 로직에 전달한다. 스냅샷이 있는 기존 정상 경로의 상세 조회 순서와 응답 매핑은 유지한다.
- REFACTOR: 스냅샷이 이미 있는 정상 경로에서는 fallback service가 호출되지 않도록 분기와 테스트를 명확히 유지한다.
- 기대 결과: 홈 통합 조회와 AI 캐릭터 전체보기 모두 기존 응답 스키마를 유지한 채 fallback을 사용할 수 있다.
---
### Phase 6: API 회귀와 관측 로그
- [x] **Task 6.1: 홈 API 응답 스키마 회귀 검증**
- 파일 경로:
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/home/HomeRecommendationControllerTest.kt`
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/home/dto/recommendation/HomeRecommendationResponseTest.kt`
- RED: 홈 통합 조회와 AI 캐릭터 전체보기 응답이 기존 `characterId`, `creatorId`, 이름, 소개, 이미지, 작품명, 전체 채팅 수 필드를 그대로 내려주는 회귀 테스트를 확인/보강한다.
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.home.HomeRecommendationControllerTest --tests kr.co.vividnext.sodalive.v2.api.home.dto.recommendation.HomeRecommendationResponseTest`
- GREEN: DTO 변경 없이 fallback이 연결된 application service 결과를 기존 facade/controller가 그대로 매핑하게 한다.
- REFACTOR: 공개 API URL과 JSON field name 변경이 없음을 테스트 assertion으로 유지한다.
- 기대 결과: 클라이언트 공개 스키마는 변경되지 않는다.
- [x] **Task 6.2: fallback/refresh 관측 로그 추가**
- 파일 경로:
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/recommendation/application/RecommendationSnapshotFallbackService.kt`
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/recommendation/application/RecommendationSnapshotRefreshService.kt`
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/recommendation/application/RecommendationSnapshotFallbackServiceTest.kt`
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/recommendation/application/RecommendationSnapshotRefreshServiceTest.kt`
- RED: fallback refresh 실행/성공/실패/timeout, lock 획득 성공/실패, AI 스냅샷 저장 수 로그 이벤트가 남는지 실패 테스트를 작성한다.
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.recommendation.application.RecommendationSnapshotFallbackServiceTest --tests kr.co.vividnext.sodalive.v2.recommendation.application.RecommendationSnapshotRefreshServiceTest`
- GREEN: 기존 프로젝트 관례대로 신규 metric dependency 없이 구조화 로그를 남긴다.
- REFACTOR: 로그 event key는 검색 가능한 snake_case로 고정하고, 예외 로그는 홈 전체 실패를 유발하지 않는 fallback 실패와 실제 refresh 실패를 구분한다.
- 기대 결과: 운영에서 fallback 빈 배열 원인을 timeout/lock/실패/데이터 없음으로 구분할 수 있다.
---
### Phase 7: 최종 검증과 문서 갱신
- [x] **Task 7.1: focused regression 실행**
- 파일 경로:
- Modify: `docs/20260709_메인_홈_추천_AI캐릭터_스냅샷/plan-task.md`
- RED: 구현 task 완료 후 계획 문서에 기록할 focused command 목록을 확정한다.
- 실패 확인: 해당 없음. TDD 예외 사유: 검증 기록 문서화 task다.
- GREEN: 아래 명령을 실행하고 결과를 이 문서 하단 검증 기록에 누적한다.
- REFACTOR: 실패한 명령이 있으면 원인과 재실행 결과를 같은 task 아래에 기록한다.
- 기대 결과: 산식/집계/fallback/API 회귀가 최소 명령으로 검증된다.
- 실행 명령:
- `./gradlew test --tests kr.co.vividnext.sodalive.v2.recommendation.domain.RecommendationScorePolicyTest --tests kr.co.vividnext.sodalive.v2.recommendation.domain.RecommendationSnapshotWindowPolicyTest`
- `./gradlew test --tests kr.co.vividnext.sodalive.v2.recommendation.adapter.out.persistence.DefaultHomeRecommendationQueryRepositoryTest`
- `./gradlew test --tests kr.co.vividnext.sodalive.v2.recommendation.application.RecommendationSnapshotRefreshServiceTest --tests kr.co.vividnext.sodalive.v2.recommendation.application.RecommendationSnapshotFallbackServiceTest --tests kr.co.vividnext.sodalive.v2.recommendation.application.HomeRecommendationQueryServiceTest`
- `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.home.HomeRecommendationControllerTest`
- [x] **Task 7.2: 전체 회귀와 문서 검증**
- 파일 경로:
- Modify: `docs/20260709_메인_홈_추천_AI캐릭터_스냅샷/plan-task.md`
- RED: 구현 완료 후 전체 회귀 명령 실행 전에는 검증 기록이 비어 있어야 한다.
- 실패 확인: 해당 없음. TDD 예외 사유: 검증 기록 문서화 task다.
- GREEN: `./gradlew ktlintCheck`, `./gradlew test`, `./gradlew tasks --all`, `git diff --check`를 실행하고 결과를 검증 기록에 남긴다.
- REFACTOR: 문서와 코드의 산식/timeout 값이 다르면 구현 또는 문서를 수정한 뒤 재검증한다.
- 기대 결과: 전체 테스트, 포맷, 문서 명령 유효성, diff 공백 검사가 모두 통과한다.
---
## Coverage Check
- Feature A: Task 2.1, Task 3.1에서 AI 채팅 수 45%, 활성 사용자 수 35%, 팔로우 증가 수 20% 산식을 검증한다.
- Feature B: Task 2.3, Task 4.1에서 KST 전날 하루를 UTC 조회 범위로 변환하는 정책을 검증한다.
- Feature C: Task 2.2, Task 3.2에서 `ChatCharacter.createdAt` 기준 AI 전용 신규 부스트와 null/future 제외를 검증한다.
- Feature D: Task 3.3, Task 5.3, Task 6.1에서 top 20, 동점 생성일 최신순, 기존 응답 스키마 유지를 검증한다.
- Feature E: Task 5.1, Task 5.2, Task 5.3에서 fallback refresh 재사용, double-check, 300ms lock 대기, 1,500ms 홈 API 대기, timeout 후 background 완료, 중복 refresh 방지를 검증한다.
- Non-Goals: Task 6.1과 Task 7.2에서 공개 API URL/응답 필드 변경 없음, AI 캐릭터 팔로우 생성/취소 동작 변경 없음, 관리자/ML/A-B 제외를 확인한다.
## 전체 검증 기록
- 2026-07-09: PRD 기반으로 `plan-task.md`를 생성했다. 구현 전 계획 문서 작성 작업이므로 코드 테스트는 아직 실행하지 않았고, 문서 형식/명령 유효성 검증을 진행한다.
- 2026-07-09: 사용자 피드백에 따라 계산을 DB에서 하든 Kotlin 단에서 하든 산식 근거를 촘촘히 남기도록 Task 2.1을 AI 캐릭터 점수 계산식 계약 테스트로 보강했다. `0`, 단일 항목 가중치, 세 항목 합산, 신규 부스트 적용, 소수 오차 허용 범위를 구체 테스트 케이스로 문서화했다.
- 2026-07-09: 문서 검증으로 `git diff --check`를 실행해 통과했다. `./gradlew tasks --all`은 일반 샌드박스에서 `~/.gradle` wrapper lock 파일 접근 제한으로 실패했고, 권한 상승 재실행 결과 `BUILD SUCCESSFUL`로 통과했다.
- 2026-07-10: 구현 검증으로 산식/윈도우 focused 테스트 `RecommendationScorePolicyTest`, `RecommendationSnapshotWindowPolicyTest`를 실행해 `BUILD SUCCESSFUL`을 확인했다.
- 2026-07-10: AI 캐릭터 스냅샷 집계 focused 테스트 `DefaultHomeRecommendationQueryRepositoryTest`를 실행해 전날 UTC window, 팔로우 증가량, 후보 제외, top 20/동점 정렬이 `BUILD SUCCESSFUL`임을 확인했다.
- 2026-07-10: refresh/fallback/query focused 테스트 `RecommendationSnapshotRefreshServiceTest`, `RecommendationSnapshotFallbackServiceTest`, `HomeRecommendationQueryServiceTest`를 실행해 section lock, 300ms lock wait, 1,500ms home wait, timeout 후 background 완료, double-check, fallback 연결이 `BUILD SUCCESSFUL`임을 확인했다.
- 2026-07-10: API 회귀 focused 테스트 `HomeRecommendationControllerTest`, `HomeRecommendationResponseTest`를 실행해 공개 응답 스키마 유지가 `BUILD SUCCESSFUL`임을 확인했다.
- 2026-07-10: 전체 검증으로 `./gradlew ktlintCheck`, `./gradlew test`, `./gradlew tasks --all`, `git diff --check`를 실행했다. `./gradlew test`는 120초 제한에서 1회 timeout되어 600초 제한으로 재실행했고, 모든 명령이 최종 `BUILD SUCCESSFUL` 또는 무출력 통과했다.
- 2026-07-10: 리뷰 게이트에서 발견된 fallback 조건/단일 실행/section lock 해제 시점 이슈를 수정했다. 최신 스냅샷 존재 여부는 요청 page가 아니라 `offset=0, limit=1` 존재 확인으로 분리했고, fallback refresh는 단일 in-flight future로 제한했으며, scheduler AI section lock은 트랜잭션 완료 후 해제되도록 보강했다.
- 2026-07-10: 수정 후 `HomeRecommendationQueryServiceTest`, `RecommendationSnapshotFallbackServiceTest`, `RecommendationSnapshotRefreshServiceTest` focused 테스트를 실행해 `BUILD SUCCESSFUL`을 확인했다. 이어서 domain/repository/API focused 테스트, `./gradlew ktlintCheck`, `git diff --check`, `./gradlew test`, `./gradlew tasks --all`을 재실행했고 모두 최종 통과했다.
- 2026-07-10: 후속 리뷰 응답으로 AI 캐릭터 팔로우 증가량 집계에서 비활성 follower 계정을 제외하도록 `creator_following.member_id``member.is_active = true` 조건을 추가했다. `DefaultHomeRecommendationQueryRepositoryTest`에 활성 follow row이지만 follower 계정이 비활성인 fixture를 추가했고, `./gradlew test --tests kr.co.vividnext.sodalive.v2.recommendation.adapter.out.persistence.DefaultHomeRecommendationQueryRepositoryTest`, `./gradlew ktlintCheck`, `git diff --check`가 모두 통과했다.
- 2026-07-10: 후속 리뷰 응답으로 `HomeRecommendationQueryService.findAiCharacterRecommendations``Propagation.NOT_SUPPORTED`를 제거해 홈 API 트랜잭션 경계를 유지하도록 되돌렸다. 또한 AI 캐릭터 스냅샷 window를 `endExclusiveUtc` 기반 half-open 범위로 정리해 `cm.created_at < :windowEndExclusive`, `cf.created_at < :windowEndExclusive`, `cc.created_at < :windowEndExclusive`로 집계되도록 수정했다.
- 2026-07-10: half-open 경계 회귀 테스트 `shouldFindAiCharacterSnapshotsWithExclusiveWindowEnd`를 추가했다. 첫 focused 재검증은 `executeSnapshotQuery`가 AI SQL에 없는 `:snapshotAt` 파라미터를 항상 바인딩해 실패했고, SQL에 존재하는 경우에만 `snapshotAt`을 바인딩하도록 수정했다. 이후 `./gradlew test --tests kr.co.vividnext.sodalive.v2.recommendation.adapter.out.persistence.DefaultHomeRecommendationQueryRepositoryTest --tests kr.co.vividnext.sodalive.v2.recommendation.application.HomeRecommendationQueryServiceTest --tests kr.co.vividnext.sodalive.v2.recommendation.application.RecommendationSnapshotRefreshServiceTest --tests 'kr.co.vividnext.sodalive.v2.api.home.HomeRecommendationControllerTest.shouldReturnTwentyAiCharactersOnHomeRecommendations'`를 재실행해 `BUILD SUCCESSFUL`을 확인했다.
- 2026-07-10: 전체 회귀 재검증 중 `HomeRecommendationControllerTest.shouldExposeCreatorIdOnAiCharacterRecommendations`가 전체 실행에서 최신 AI 스냅샷 충돌로 1회 실패했다. 테스트 fixture의 AI 스냅샷 시각을 fallback window보다 최신인 `2026-12-31T23:59:59`로 고정해 테스트 독립성을 보강했고, `./gradlew test --tests 'kr.co.vividnext.sodalive.v2.api.home.HomeRecommendationControllerTest.shouldExposeCreatorIdOnAiCharacterRecommendations' --tests 'kr.co.vividnext.sodalive.v2.api.home.HomeRecommendationControllerTest.shouldReturnTwentyAiCharactersOnHomeRecommendations'``BUILD SUCCESSFUL`임을 확인했다.
- 2026-07-10: 최종 검증으로 `./gradlew test`, `./gradlew ktlintCheck`, `git diff --check`를 재실행했다. 전체 테스트 1125건 포함 모든 명령이 최종 통과했다.
- 2026-07-10: 리뷰 게이트에서 PRD inclusive window 문구와 fallback post-refresh 재조회 트랜잭션 가시성 이슈가 차단으로 지적됐다. PRD/plan-task의 기간 표현을 `windowStartUtc <= t < windowEndExclusiveUtc` half-open 기준으로 정렬했고, active 공통 `RecommendationSnapshotFallbackService`의 snapshot read를 `PROPAGATION_REQUIRES_NEW` read-only `TransactionTemplate`으로 분리해 caller read transaction과 독립된 DB read가 되도록 보강했다.
- 2026-07-10: 후속 정리로 삭제된 `AiCharacterSnapshotFallbackServiceTest` 참조를 active `RecommendationSnapshotFallbackServiceTest`로 갱신했고, 비활성 legacy `AiCharacterSnapshotFallbackService` 구현을 제거한 뒤 `AiCharacterSnapshotFallbackPort`만 별도 파일로 유지했다.

View File

@@ -0,0 +1,172 @@
# PRD: 메인 홈 추천 AI 캐릭터 스냅샷 산식 변경
## 1. Overview
메인 홈 추천 탭의 AI 캐릭터 스냅샷 생성과 조회가 전날 데이터 기반 추천 점수를 사용하도록 수정한다.
---
## 2. Problem
- 기존 메인 홈 추천 AI 캐릭터 스냅샷은 최근 7일 채팅 데이터와 기존 신규 부스트를 기준으로 계산되어, 전날 성과를 빠르게 반영하기 어렵다.
- 기존 PRD에서는 AI 캐릭터 팔로우 증가량을 산식에서 제외했으나, 이번 요구사항에서는 전날 신규 팔로우 수를 점수에 포함해야 한다.
- AI 캐릭터에는 별도 데뷔 날짜가 없으므로 신규 부스트 기준일을 캐릭터 생성 날짜/시간으로 명확히 고정해야 한다.
- 산식 변경이 추천 노출 순서에 직접 영향을 주므로 계산 기간, 집계 대상, 부스트 구간, 조회 정렬 기준을 문서로 고정해야 한다.
---
## 3. Goals
- AI 캐릭터 추천 점수를 전날 데이터 기반으로 산출한다.
- AI 캐릭터 점수 산식을 `(AI 채팅 수 * 45% + 최근 활성 사용자 수 * 35% + 팔로우 증가 수 * 20%) * 신규 부스트`로 변경한다.
- AI 채팅 수는 전날 AI 캐릭터가 답변한 메시지 수로 계산한다.
- 최근 활성 사용자 수는 전날 해당 캐릭터와 1회 이상 채팅한 중복 없는 사용자 수로 계산한다.
- 팔로우 증가 수는 전날 발생한 AI 캐릭터 신규 팔로우 수로 계산한다.
- 신규 부스트는 `ChatCharacter.createdAt` 기준으로 10일 이내 1.15, 20일 이내 1.10, 30일 이내 1.05, 그 외 1.0을 적용한다.
- 홈 통합 조회와 AI 캐릭터 전체보기 조회는 최신 AI 캐릭터 스냅샷의 점수순 결과를 사용한다.
---
## 4. Non-Goals
- 메인 홈 추천 API URL, 응답 필드, 응답 JSON 스키마는 변경하지 않는다.
- AI 캐릭터 상세, AI 채팅방, 크리에이터 채널 API의 공개 스키마는 변경하지 않는다.
- 추천 결과 수동 편집, 관리자 화면, A/B 테스트, 머신러닝 개인화는 이번 범위에 포함하지 않는다.
- AI 캐릭터 팔로우 생성/취소 동작 자체를 변경하지 않는다.
- 기존 AI 캐릭터 `characterId``creatorId`의 의미를 변경하지 않는다.
---
## 5. Target Users
- 회원: 메인 홈 추천 탭에서 전날 반응이 좋은 AI 캐릭터를 발견하고 채팅 또는 크리에이터 채널로 이동하는 사용자
- 앱 클라이언트: 기존 메인 홈 추천 응답 계약을 유지한 채 AI 캐릭터 추천 순서만 최신 스냅샷 기준으로 사용하는 클라이언트
- 운영자: 전날 채팅/사용자/팔로우 반응이 추천 순서에 반영되는지 확인해야 하는 운영 담당자
---
## 6. User Stories
- 사용자는 메인 홈 추천 탭에서 전날 채팅 반응이 많았던 AI 캐릭터를 우선 보고 싶다.
- 사용자는 여러 사용자가 실제로 대화한 AI 캐릭터를 추천받고 싶다.
- 사용자는 최근 팔로우가 늘어난 AI 캐릭터를 추천 목록에서 발견하고 싶다.
- 사용자는 신규 AI 캐릭터가 일정 기간 동안 적절한 노출 기회를 받기를 기대한다.
- 앱 클라이언트는 기존 API 응답 구조를 바꾸지 않고 추천 순서만 변경된 결과를 받고 싶다.
---
## 7. Core Features
### Feature A. AI 캐릭터 스냅샷 점수 산식 변경
#### Requirements
- AI 캐릭터 점수는 아래 산식으로 계산한다.
- `score = ((aiChatCount * 0.45) + (activeUserCount * 0.35) + (followIncreaseCount * 0.20)) * newBoost`
- `aiChatCount`는 집계 기간 안에 AI 캐릭터가 답변한 활성 메시지 수다.
- AI가 답변한 메시지는 기존 AI 캐릭터 추천 집계와 동일하게 `chat_participant.participant_type = 'CHARACTER'`인 활성 participant가 작성한 활성 `chat_message`로 판정한다.
- `activeUserCount`는 집계 기간 안에 해당 캐릭터와 1회 이상 채팅한 중복 없는 활성 사용자 수다.
- `followIncreaseCount`는 집계 기간 안에 해당 AI 캐릭터에 대응하는 creator member를 신규 팔로우한 수다.
- AI 캐릭터에 대응하는 creator member는 기존 홈 추천 응답의 `creatorId`와 동일한 `ChatCharacter.creatorMember.id`다.
- 팔로우 증가는 `creator_following.creator_id = ChatCharacter.creatorMember.id`인 활성 팔로우 이력 중 집계 기간에 생성된 row 기준으로 계산한다.
- 재팔로우가 기존 비활성 row를 다시 활성화하는 방식이면 신규 팔로우 수에 포함하지 않는다.
- 점수 산식 상수는 코드와 테스트에서 재사용 가능한 형태로 관리한다.
#### Edge Cases
- `aiChatCount`, `activeUserCount`, `followIncreaseCount`가 모두 0인 캐릭터는 스냅샷 후보에서 제외한다.
- AI 답변은 있지만 사용자 메시지가 없는 경우 `activeUserCount = 0`으로 계산한다.
- 사용자 메시지는 있지만 AI 답변이 없는 경우에도 `activeUserCount``followIncreaseCount`가 있으면 산식에 따라 점수를 계산할 수 있다.
- 비활성 메시지, 비활성 participant, 비활성 사용자, 비활성 캐릭터는 집계에서 제외한다.
- `creatorMember`가 없거나 연결된 member가 비활성/비 `CREATOR`/비 `AI_CHARACTER`이면 스냅샷 후보와 조회 결과에서 제외한다.
### Feature B. 전날 데이터 기반 집계 기간
#### Requirements
- 스냅샷 점수 입력값은 전날 데이터만 사용한다.
- 전날 기준은 KST 기준 전일 00:00:00 이상, 다음날 00:00:00 미만의 half-open 범위로 정의한다.
- 운영 DB 시간은 UTC이므로, 실제 조회와 계산에서는 KST 기준 전일 00:00:00 이상, 다음날 00:00:00 미만 범위를 UTC 기준 `windowStartUtc <= createdAt < windowEndExclusiveUtc`로 변환해 사용한다.
- 스케줄러 실행 시각이 KST 06:00이면, 실행일 전날 KST 일자를 집계 대상으로 삼는다.
- 스냅샷 생성 시 `snapshotAt`은 기존 `RecommendationSnapshot` 저장/조회 계약을 유지하되, 점수 입력 집계 기간은 전날 하루로 제한한다.
#### Edge Cases
- 스케줄러 재실행으로 같은 `sectionType`, `snapshotAt` 스냅샷을 다시 만들 때 기존 최신 스냅샷 대체 정책을 유지한다.
- 집계 기간에 해당하는 데이터가 없으면 AI 캐릭터 스냅샷은 빈 결과가 될 수 있으며, 홈 조회 API는 기존처럼 빈 배열을 반환한다.
### Feature C. 신규 부스트 변경
#### Requirements
- AI 캐릭터 신규 부스트는 데뷔 날짜가 아니라 `ChatCharacter.createdAt` 기준으로 계산한다.
- 신규 부스트 구간은 다음과 같다.
- 캐릭터 생성 후 10일 이내: `1.15`
- 캐릭터 생성 후 20일 이내: `1.10`
- 캐릭터 생성 후 30일 이내: `1.05`
- 그 외: `1.0`
- 경계일 계산은 기존 추천 점수 정책의 `ChronoUnit.DAYS.between(baseAt.toLocalDate(), 기준일.toLocalDate())` 방식과 일관되게 한다.
- 부스트 기준일은 스냅샷 생성 기준 시각인 `snapshotAt`으로 한다.
#### Edge Cases
- `ChatCharacter.createdAt`이 null인 캐릭터는 스냅샷 산식에 포함하지 않는다.
- 생성일이 기준 시각보다 미래인 데이터는 데이터 오류로 보고 스냅샷 후보에서 제외한다.
### Feature D. AI 캐릭터 스냅샷 조회
#### Requirements
- 홈 통합 조회 `GET /api/v2/home/recommendations`의 AI 캐릭터 섹션은 최신 `AI_CHARACTER` 스냅샷 순서를 사용한다.
- AI 캐릭터 전체보기 조회도 최신 `AI_CHARACTER` 스냅샷 순서를 사용한다.
- AI 캐릭터 스냅샷은 최종 점수 기준 20위까지 저장한다.
- 기존 노출 정보는 유지한다.
- `characterId`
- `creatorId`
- 캐릭터 이름
- 캐릭터 소개
- 프로필 이미지
- 작품명
- 사용자들이 친 전체 채팅 수
- 스냅샷 정렬은 점수 내림차순, 동일 점수면 더 늦게 생성된 캐릭터를 우선한다.
- 조회 시점에도 비활성 또는 노출 제한 캐릭터는 제외한다.
#### Edge Cases
- 스냅샷에는 존재하지만 조회 시점에 캐릭터 또는 `creatorMember`가 비활성화된 경우 응답에서 제외한다.
- fallback refresh 후에도 최신 스냅샷이 없으면 기존 홈 추천 API 동작과 동일하게 빈 배열을 반환한다.
- 상세 조회 결과가 스냅샷 저장 개수보다 적어도 홈 조회 전체는 성공 처리한다.
### Feature E. 스냅샷 없음 fallback refresh
#### Requirements
- 최신 `AI_CHARACTER` 스냅샷이 없으면 fallback으로 스케줄러와 동일한 AI 캐릭터 스냅샷 refresh 로직을 재사용한다.
- fallback refresh는 홈 조회 API가 직접 점수를 계산해 응답하지 않고, 스케줄러와 동일한 저장 로직으로 스냅샷을 만든 뒤 저장된 값을 다시 조회해 반환한다.
- fallback refresh는 AI 캐릭터 섹션 단위 lock 또는 single-flight 처리를 적용해 동시에 여러 요청이 같은 refresh를 중복 실행하지 않도록 한다.
- lock 획득 후에는 다른 요청이 이미 스냅샷을 생성했을 수 있으므로 최신 스냅샷을 다시 조회하고, 여전히 없을 때만 refresh를 실행한다.
- fallback refresh가 성공하면 최신 스냅샷을 다시 조회해 Feature D의 조회 로직과 동일한 방식으로 응답한다.
- fallback refresh timeout은 refresh 작업의 강제 중단 시간이 아니라 홈 API 요청이 refresh 완료를 기다리는 최대 대기 시간이다.
- fallback refresh의 lock 대기 시간은 최대 300ms로 제한한다.
- fallback refresh를 시작한 홈 API 요청은 refresh 완료를 최대 1,500ms까지 기다린다.
- fallback refresh가 홈 API 대기 시간을 초과하면 현재 홈 요청은 AI 캐릭터 섹션을 빈 배열로 반환한다.
- 홈 API 대기 시간이 초과되어도 이미 시작된 refresh 작업은 가능한 경우 취소하지 않고 백그라운드에서 완료되도록 한다.
- 백그라운드 refresh가 완료되면 이후 홈 조회는 저장된 최신 스냅샷을 조회해 반환한다.
- fallback refresh가 진행 중인 동안 lock 또는 single-flight 상태를 유지해 후속 요청이 중복 refresh를 시작하지 않도록 한다.
#### Edge Cases
- lock을 획득하지 못했지만 다른 요청이 refresh 중이면 짧게 대기한 뒤 최신 스냅샷을 다시 조회한다.
- 대기 후에도 refresh가 완료되지 않아 최신 스냅샷이 없으면 해당 요청도 AI 캐릭터 섹션을 빈 배열로 반환하고 중복 refresh는 시작하지 않는다.
- fallback refresh가 실패하면 실패 로그를 남기고 홈 조회 전체를 실패시키지 않으며 AI 캐릭터 섹션은 빈 배열로 반환한다.
- fallback refresh가 성공했지만 집계 대상 데이터가 없어 스냅샷이 생성되지 않으면 AI 캐릭터 섹션은 빈 배열로 반환한다.
- fallback refresh 중 예외가 발생해도 다른 추천 섹션 조회 결과는 가능한 범위에서 유지한다.
---
## 8. Technical Constraints
- Kotlin, Spring Boot 2.7.14, Java 17, Gradle Wrapper 구조를 유지한다.
- 기존 `kr.co.vividnext.sodalive.v2.recommendation` 패키지 경계와 `v2.api.home`에서 `v2.recommendation`을 호출하는 의존 방향을 유지한다.
- 기존 `RecommendationSnapshot` 기반 저장/조회 구조를 재사용한다.
- 기존 공개 API 응답 DTO는 필드 추가 없이 유지한다.
- AI 캐릭터 스냅샷 생성 쿼리는 candidate pre-limit 없이 산식 계산과 정렬을 수행한 뒤 최종 20개 저장 limit을 적용한다.
- 스케줄러 refresh와 fallback refresh는 산식, 기간, 저장 limit, 정렬 기준이 갈라지지 않도록 같은 application service 경로를 사용한다.
- 홈 API 대기 timeout과 refresh 작업/DB query timeout은 분리한다. 홈 API 대기 timeout이 발생해도 refresh 작업이 반드시 같은 timeout으로 중단되면 안 된다.
- fallback refresh의 lock은 단일 서버 환경에서는 JVM local lock을 사용할 수 있지만, 다중 인스턴스 운영 환경에서는 DB lock 또는 분산 lock 등 인스턴스 간 중복 실행을 막을 수 있는 방식을 구현 계획에서 확정한다.
- 신규 DDL이 필요한 경우 운영 DB 반영용 SQL 문서는 MySQL 기준으로 별도 작성한다.
- 점수 산식과 신규 부스트 값은 테스트에서 경계값을 검증할 수 있도록 상수화한다.
---
## 9. Metrics
- AI 캐릭터 스냅샷 생성 성공/실패 로그
- 스냅샷 최종 저장 수
- fallback refresh 실행/성공/실패/timeout 로그
- fallback refresh lock 획득 성공/실패 로그
- `aiChatCount`, `activeUserCount`, `followIncreaseCount` 입력값 분포
- AI 캐릭터 홈 섹션 응답 성공/실패 로그
- AI 캐릭터 홈 섹션 노출 수

View File

@@ -0,0 +1,50 @@
# 팬톡 작성/수정 응답 보강 Plan/Task
## 시나리오 계약
- Happy path: 인증 사용자가 `POST /explorer/profile/cheers`를 호출하면 HTTP 200과 함께 `$.data.fanTalkId`, `$.data.writerId`, `$.data.content`, `$.data.creatorReplies=[]`가 반환된다. Real surface: MockMvc controller test.
- Edge: 인증 사용자가 `PUT /explorer/profile/cheers`로 내용을 수정하면 HTTP 200과 함께 수정 후 content가 `$.data.content`에 반환된다. Real surface: MockMvc controller test.
- Adjacent regression: 기존 V2 FanTalk 탭 `CreatorChannelFanTalkResponse.from(domain)` 매핑과 조회 controller 응답은 유지된다. Real surface: 기존 `CreatorChannelFanTalkControllerTest`, DTO/facade unit test.
### Phase 1: 문서와 RED 테스트
- [x] **Task 1.1: PRD/plan-task 작성**
- 파일 경로: `docs/20260709_팬톡_작성수정_응답보강/prd.md`, `docs/20260709_팬톡_작성수정_응답보강/plan-task.md`
- RED: 문서 작업은 TDD 예외. TDD 예외 사유: 코드 동작 변경 전 요구사항 고정 문서다.
- GREEN: 요구사항, non-goal, 시나리오, 검증 명령을 문서화한다.
- REFACTOR: 문서 경로와 명령 유효성을 확인한다.
- 검증 기록: `docs/20260709_팬톡_작성수정_응답보강/prd.md`, `docs/20260709_팬톡_작성수정_응답보강/plan-task.md`를 생성해 endpoint 유지, legacy 직접 수정, `CreatorChannelFanTalkResponse` 반환 범위를 문서화했다.
- [x] **Task 1.2: 작성/수정 응답 RED 테스트 추가**
- 파일 경로: `src/test/kotlin/kr/co/vividnext/sodalive/explorer/ExplorerControllerTest.kt`, `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/fantalk/application/CreatorChannelFanTalkFacadeTest.kt`
- RED: `./gradlew test --tests kr.co.vividnext.sodalive.explorer.ExplorerControllerTest --tests kr.co.vividnext.sodalive.v2.api.creator.channel.fantalk.application.CreatorChannelFanTalkFacadeTest --no-daemon` 실행 시 신규 기대 동작이 미구현이라 실패해야 한다.
- GREEN: 구현 후 같은 명령이 통과해야 한다.
- REFACTOR: 기존 FanTalk 탭 controller 테스트를 함께 실행한다.
- RED 검증 기록: 신규 controller 테스트 추가 직후 같은 명령을 실행했고, `ExplorerService.writeCheers/modifyCheers``Unit` 반환이라 `CannotStubVoidMethodWithReturnValue`로 2개 테스트 실패를 확인했다.
### Phase 2: 최소 구현
- [x] **Task 2.1: legacy cheers 응답 매핑 구현**
- 파일 경로: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/fantalk/dto/CreatorChannelFanTalkTabResponse.kt`
- RED: legacy `CreatorCheers``CreatorChannelFanTalkResponse`로 만들 수 없어 테스트가 실패한다.
- GREEN: `CreatorChannelFanTalkResponse.from(CreatorCheers, cloudFrontHost)`를 추가한다.
- REFACTOR: 기존 domain 기반 `from` 매핑을 변경하지 않는다.
- GREEN 검증 기록: `CreatorChannelFanTalkResponse.from(CreatorCheers, cloudFrontHost)`를 추가해 작성자 ID/닉네임/프로필 URL/content/UTC 생성 시각/빈 creatorReplies를 매핑했다.
- [x] **Task 2.2: 작성/수정 서비스 반환값 변경**
- 파일 경로: `src/main/kotlin/kr/co/vividnext/sodalive/explorer/ExplorerService.kt`, `src/main/kotlin/kr/co/vividnext/sodalive/explorer/ExplorerController.kt`
- RED: controller/service가 `Unit`을 반환해 `data`가 비어 있다.
- GREEN: 저장/수정 후 `CreatorChannelFanTalkResponse`를 반환한다.
- REFACTOR: 메시지와 기존 검증 흐름을 유지한다.
- GREEN 검증 기록: `ExplorerService.writeCheers`, `ExplorerService.modifyCheers`가 저장/수정된 `CreatorCheers``CreatorChannelFanTalkResponse`로 반환하도록 변경했고, `ExplorerController`의 기존 `ApiResponse.ok(service..., message)` 흐름은 유지했다.
## 전체 검증 기록
- 작업 시작 전 확인: `rg -n "explorer/profile/cheers|CreatorChannelFanTalkResponse|writeCheers|modifyCheers"`로 대상 controller/service/DTO 위치를 확인했다.
- RED: `./gradlew test --tests kr.co.vividnext.sodalive.explorer.ExplorerControllerTest --tests kr.co.vividnext.sodalive.v2.api.creator.channel.fantalk.application.CreatorChannelFanTalkFacadeTest --no-daemon` 실행 결과 신규 테스트 2개가 `CannotStubVoidMethodWithReturnValue`로 실패해 기존 서비스 반환 타입이 `Unit`임을 확인했다.
- GREEN/인접 회귀: `./gradlew test --tests kr.co.vividnext.sodalive.explorer.ExplorerControllerTest --tests kr.co.vividnext.sodalive.v2.api.creator.channel.fantalk.application.CreatorChannelFanTalkFacadeTest --tests kr.co.vividnext.sodalive.v2.api.creator.channel.fantalk.adapter.in.web.CreatorChannelFanTalkControllerTest --no-daemon` 실행 결과 `BUILD SUCCESSFUL in 1m`을 확인했다.
- Diff 검증: `git diff --check` 실행 결과 출력 없이 통과했다.
- 포맷 검증: `./gradlew ktlintCheck --no-daemon` 실행 결과 `BUILD SUCCESSFUL in 1m 21s`를 확인했다.
- 문서 명령 검증: `./gradlew tasks --all --no-daemon` 실행 결과 `BUILD SUCCESSFUL in 20s`를 확인했다.
- 전체 회귀 검증: `./gradlew test --no-daemon` 실행 결과 `BUILD SUCCESSFUL in 8m 56s`를 확인했다.
- 리뷰 게이트 1차 결과: 목표 검증/QA/컨텍스트 마이닝은 PASS였지만, 보안 리뷰와 코드 품질 리뷰에서 `PUT /explorer/profile/cheers`가 작성자/크리에이터가 아닌 인증 사용자에게도 `cheersId` 기반 응답 데이터를 반환할 수 있다는 IDOR성 정보 노출 지적이 나왔다.
- 보안 RED: `ExplorerServiceTest.shouldRejectModifyCheersForNonWriterAndNonCreator` 추가 직후 `./gradlew test --tests kr.co.vividnext.sodalive.explorer.ExplorerServiceTest --no-daemon` 실행 결과 `AssertionFailedError`로 실패해 비작성자/비크리에이터 요청이 예외 없이 데이터를 받을 수 있음을 확인했다.
- 보안 GREEN: `ExplorerService.modifyCheers`에서 `isWriter`, `isCreator`를 계산하고 둘 다 아니면 `SodaException(messageKey = "common.error.invalid_request")`를 던지도록 수정했다. 이후 `./gradlew test --tests kr.co.vividnext.sodalive.explorer.ExplorerServiceTest --tests kr.co.vividnext.sodalive.explorer.ExplorerControllerTest --tests kr.co.vividnext.sodalive.v2.api.creator.channel.fantalk.application.CreatorChannelFanTalkFacadeTest --tests kr.co.vividnext.sodalive.v2.api.creator.channel.fantalk.adapter.in.web.CreatorChannelFanTalkControllerTest --no-daemon` 실행 결과 `BUILD SUCCESSFUL in 1m 12s`를 확인했다.
- 최종 포맷 검증: 보안 수정 후 `./gradlew ktlintCheck --no-daemon` 실행 결과 `BUILD SUCCESSFUL in 1m 47s`를 확인했다.
- 최종 문서 명령 검증: 보안 수정 후 `./gradlew tasks --all --no-daemon` 실행 결과 `BUILD SUCCESSFUL in 30s`를 확인했다.
- 최종 diff 검증: 보안 수정 후 `git diff --check` 실행 결과 출력 없이 통과했다.
- 최종 전체 회귀 검증: 보안 수정 후 `./gradlew test --no-daemon` 실행 결과 `BUILD SUCCESSFUL in 14m 53s`를 확인했다.

View File

@@ -0,0 +1,39 @@
# PRD: 팬톡 작성/수정 응답 보강
## 1. Overview
`POST /explorer/profile/cheers`, `PUT /explorer/profile/cheers` 성공 응답의 `data`에 실제 저장/수정된 팬톡 데이터를 `CreatorChannelFanTalkResponse` 스키마로 내려준다.
## 2. Problem
- 현재 두 API는 성공 여부와 메시지만 확인할 수 있고, 클라이언트가 방금 작성/수정된 팬톡 데이터를 응답에서 바로 사용할 수 없다.
- 작성/수정 후 화면 반영을 위해 별도 조회가 필요해질 수 있다.
## 3. Goals
- 작성 API 성공 시 저장된 `CreatorCheers``CreatorChannelFanTalkResponse`로 변환해 `ApiResponse.data`에 포함한다.
- 수정 API 성공 시 수정 후 `CreatorCheers` 상태를 `CreatorChannelFanTalkResponse`로 변환해 `ApiResponse.data`에 포함한다.
- 기존 성공 메시지와 인증/차단/검증 흐름은 유지한다.
## 4. Non-Goals
- API endpoint 경로 변경 또는 신규 endpoint 추가는 하지 않는다.
- legacy `CreatorCheers` 저장 구조는 변경하지 않는다.
- FanTalk 탭 조회 API 응답 스키마는 변경하지 않는다.
## 5. Core Features
### Feature A: 작성/수정 응답 data 보강
#### Requirements
- `POST /explorer/profile/cheers` 반환 타입은 `ApiResponse<CreatorChannelFanTalkResponse>`가 되어야 한다.
- `PUT /explorer/profile/cheers` 반환 타입은 `ApiResponse<CreatorChannelFanTalkResponse>`가 되어야 한다.
- 응답 필드는 기존 V2 FanTalk 탭의 `CreatorChannelFanTalkResponse` 필드와 동일해야 한다.
- legacy `CreatorCheers`에서 응답을 만들 때 작성자 ID, 닉네임, 프로필 이미지 URL, 내용, UTC 생성 시각을 매핑한다.
- 작성/수정 직후 단일 팬톡 응답의 `creatorReplies`는 빈 목록으로 내려준다.
#### Edge Cases
- 작성자가 기본 프로필인 경우 기존 기본 프로필 URL 정책을 유지한다.
- 탈퇴 회원 prefix가 붙은 닉네임은 기존 제거 정책을 적용한다.
- 수정 요청에서 작성자 또는 크리에이터가 비활성 처리한 경우에도 수정 후 현재 row 상태를 응답한다.
## 6. Technical Constraints
- 기존 legacy controller/service를 직접 수정한다.
- 공개 응답 DTO는 기존 `CreatorChannelFanTalkResponse`를 재사용한다.
- 변경은 문서, 테스트, 최소 구현 범위로 제한한다.

View File

@@ -0,0 +1,285 @@
# 메인 홈 추천 응원 크리에이터 스냅샷 수정 Plan/Task
## 시나리오 계약
- Happy path: KST 전날 하루 데이터를 UTC half-open 범위로 변환해 `CHEER_CREATOR` 점수를 계산하고, 점수순 상위 16개 스냅샷을 저장한다. Real surface: `DefaultHomeRecommendationQueryRepositoryTest`, `RecommendationSnapshotRefreshServiceTest`.
- Score: 응원 점수는 `((channelDonationAmount * 0.45) + (fanTalkCount * 0.30) + (channelDonationCount * 0.10)) * newBoost`다. 채널 후원 금액은 `use_can_calculate.can` 그대로 사용하고, 채널 후원 수는 `UseCanCalculate.useCan` 기준으로 중복 제거한다. Real surface: `RecommendationScorePolicyTest`, `DefaultHomeRecommendationQueryRepositoryTest`.
- Boost: 신규 부스트는 크리에이터 데뷔일 기준 0~10일 `1.15`, 11~20일 `1.10`, 21~30일 `1.05`, 31일 이상 `1.0`이다. Real surface: `RecommendationScorePolicyTest`, `DefaultHomeRecommendationQueryRepositoryTest`.
- Fallback: 최신 `CHEER_CREATOR` 스냅샷이 없으면 lock, double-check, 동일 refresh 로직 재사용, refresh 후 재조회 순서로 fallback을 실행한다. lock 대기는 최대 300ms, 홈 API refresh 완료 대기는 최대 1,500ms다. Real surface: fallback service test, `HomeRecommendationQueryServiceTest`.
- Empty marker: `CHEER_CREATOR` refresh 결과가 0건이면 `targetId = 0` marker를 저장해 정상 refresh 완료 상태를 남기고, 조회 응답에서는 marker를 제외한다. Real surface: `RecommendationSnapshotPersistenceAdapterTest`, `HomeRecommendationQueryServiceTest`.
- Adjacent regression: 메인 홈 추천 API URL과 `CHEER_CREATOR` 응답 필드는 변경하지 않는다. AI 캐릭터, 인기 커뮤니티, 최근 데뷔 등 다른 섹션 산식과 공개 스키마는 이번 변경으로 바꾸지 않는다. Real surface: 기존 focused tests, `HomeRecommendationControllerTest`.
## 범위와 전제
- 이번 문서는 `docs/20260710_메인_홈_추천_응원크리에이터_스냅샷/prd.md`의 구현 계획이다.
- 신규 공개 API, 신규 응답 필드, 운영 DDL 추가는 범위에 포함하지 않는다.
- 기존 `recommendation_snapshot` 테이블과 `RecommendedSectionType.CHEER_CREATOR`를 재사용한다.
- `CHEER_CREATOR` 집계는 현재 구조와 성능 특성을 유지해 DB-side exact scoring을 기본으로 한다. Kotlin 단에는 산식/부스트 근거 테스트용 정책 함수를 둔다.
- 전날 기준은 KST 전날 00:00:00 이상, 다음날 00:00:00 미만이고, DB 조회에는 UTC half-open window를 사용한다.
- fallback orchestration은 AI 캐릭터 전용 구현을 그대로 복사하지 않고, 섹션별 lock key와 refresh action을 받을 수 있는 최소 공통 runner를 우선 적용한다.
- 다른 스냅샷 섹션으로 empty marker를 확장하는 작업은 이번 구현 범위에서 제외한다. 단, `CHEER_CREATOR`에 적용할 때 이후 공통화가 가능하도록 조건문/상수명을 명확히 둔다.
## 기존 CHEER_CREATOR 로직 유지/변경 경계
- 유지: `RecommendedSectionType.CHEER_CREATOR` enum 값과 code는 변경하지 않는다.
- 유지: 홈 응원 크리에이터 응답 필드인 `creatorId`, `creatorNickname`, `creatorProfileImage`는 변경하지 않는다.
- 유지: 홈 첫 화면 응답은 최대 8명, 스냅샷 후보 조회는 최대 16개를 사용한다.
- 유지: 상세 조회 시점의 활성 크리에이터 필터와 차단 필터는 유지한다.
- 유지: 채널 후원 금액은 `use_can_calculate.can` 값을 그대로 합산한다.
- 변경: 점수 가중치는 후원 금액 45%, 팬Talk 수 30%, 채널 후원 수 10%로 바꾼다.
- 변경: 채널 후원 수는 `UseCanCalculate.useCan` 기준 distinct count로 계산한다.
- 변경: 팬Talk 수는 `CreatorCheers.isActive == true` row 수로 계산한다.
- 변경: 집계 기간은 최근 7일에서 KST 전날 하루로 바꾼다.
- 변경: 신규 부스트는 기존 크리에이터 공통 부스트 `1.5/1.3/1.2`가 아니라 `CHEER_CREATOR` 전용 `1.15/1.10/1.05`를 사용한다.
- 추가: `CHEER_CREATOR` 최신 스냅샷이 없을 때 fallback refresh를 실행한다.
- 추가: `CHEER_CREATOR` refresh 결과 0건이면 empty snapshot marker를 저장한다.
## 실행 명령
- 문서 명령 확인: `./gradlew tasks --all`
- 산식/부스트 단위 테스트: `./gradlew test --tests kr.co.vividnext.sodalive.v2.recommendation.domain.RecommendationScorePolicyTest`
- 스냅샷 저장 marker 테스트: `./gradlew test --tests kr.co.vividnext.sodalive.v2.recommendation.adapter.out.persistence.RecommendationSnapshotPersistenceAdapterTest`
- 응원 크리에이터 query 통합 테스트: `./gradlew test --tests kr.co.vividnext.sodalive.v2.recommendation.adapter.out.persistence.DefaultHomeRecommendationQueryRepositoryTest`
- refresh/fallback 조회 테스트: `./gradlew test --tests kr.co.vividnext.sodalive.v2.recommendation.application.RecommendationSnapshotRefreshServiceTest --tests kr.co.vividnext.sodalive.v2.recommendation.application.HomeRecommendationQueryServiceTest`
- fallback service 테스트: `./gradlew test --tests kr.co.vividnext.sodalive.v2.recommendation.application.RecommendationSnapshotFallbackServiceTest`
- 홈 API 회귀 테스트: `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.home.HomeRecommendationControllerTest --tests kr.co.vividnext.sodalive.v2.api.home.dto.recommendation.HomeRecommendationResponseTest`
- 포맷 검증: `./gradlew ktlintCheck`
- 전체 회귀: `./gradlew test`
---
### Phase 1: 문서와 기준 고정
- [x] **Task 1.1: PRD 기반 구현 계획 문서 작성**
- 파일 경로:
- Verify: `docs/20260710_메인_홈_추천_응원크리에이터_스냅샷/prd.md`
- Create: `docs/20260710_메인_홈_추천_응원크리에이터_스냅샷/plan-task.md`
- RED: 문서 작업은 TDD 예외. TDD 예외 사유: 코드 동작 변경 전 요구사항과 구현 순서를 고정하는 작업이다.
- GREEN: PRD의 산식, KST 전날 UTC half-open 범위, 후원 수 distinct 기준, empty marker, fallback timeout/lock 정책을 task로 분해한다.
- REFACTOR: 기존 홈 추천 구현 파일과 테스트 파일 기준으로 task별 수정/검증 경로를 맞춘다.
- 기대 결과: 구현 시작 전에 PRD와 plan-task가 같은 디렉터리에 준비된다.
---
### Phase 2: 산식과 부스트 정책
- [x] **Task 2.1: 응원 점수 계산식 계약 테스트 보강**
- 파일 경로:
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/recommendation/domain/RecommendationScoreSpec.kt`
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/recommendation/domain/RecommendationScorePolicy.kt`
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/recommendation/domain/RecommendationScorePolicyTest.kt`
- RED: `CHEER_DONATION_AMOUNT_WEIGHT = 0.45`, `CHEER_FAN_TALK_WEIGHT = 0.30`, `CHEER_DONATION_COUNT_WEIGHT = 0.10`을 기대하는 실패 테스트를 작성한다. `calculateCheerScore(...)`가 세 입력값과 부스트를 PRD 산식대로 계산하는지도 검증한다.
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.recommendation.domain.RecommendationScorePolicyTest`
- GREEN: 응원 점수 상수를 PRD 값으로 변경하고 기존 `calculateCheerScore(...)`가 같은 상수를 사용하게 한다.
- REFACTOR: 기존 AI/최근 데뷔/인기 커뮤니티 상수와 함수 값은 변경하지 않았는지 같은 테스트 안에서 회귀 assertion을 유지한다.
- 계산식 테스트 케이스:
- `donationAmount=0`, `fanTalkCount=0`, `donationCount=0`, `newBoost=1.0`이면 `0.0`
- `donationAmount=100`, `fanTalkCount=0`, `donationCount=0`, `newBoost=1.0`이면 `45.0`
- `donationAmount=0`, `fanTalkCount=10`, `donationCount=0`, `newBoost=1.0`이면 `3.0`
- `donationAmount=0`, `fanTalkCount=0`, `donationCount=10`, `newBoost=1.0`이면 `1.0`
- `donationAmount=100`, `fanTalkCount=10`, `donationCount=10`, `newBoost=1.0`이면 `49.0`
- `donationAmount=100`, `fanTalkCount=10`, `donationCount=10`, `newBoost=1.15`이면 `56.35`
- 기대 결과: 응원 점수 산식 근거가 Kotlin 단위 테스트로 고정된다.
- [x] **Task 2.2: 응원 크리에이터 신규 부스트 경계값 테스트 추가**
- 파일 경로:
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/recommendation/domain/RecommendationScoreSpec.kt`
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/recommendation/domain/RecommendationScorePolicy.kt`
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/recommendation/domain/RecommendationScorePolicyTest.kt`
- RED: 응원 크리에이터 전용 신규 부스트가 데뷔일 기준 0일/10일 `1.15`, 11일/20일 `1.10`, 21일/30일 `1.05`, 31일 `1.0`으로 계산되는 실패 테스트를 작성한다.
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.recommendation.domain.RecommendationScorePolicyTest`
- GREEN: `calculateCheerCreatorNewBoost(debutAt, now)` 또는 동등한 전용 함수를 추가하고 `CHEER_NEW_BOOST_*` 상수를 사용한다.
- REFACTOR: 기존 `calculateCreatorNewBoost(...)`는 최근 데뷔/인기 커뮤니티용 기존 값 `1.5/1.3/1.2`를 유지한다.
- 기대 결과: `CHEER_CREATOR`만 낮아진 신규 부스트 값을 사용하고 다른 크리에이터 기반 섹션은 기존 부스트를 유지한다.
---
### Phase 3: 전날 집계 window와 DB 스냅샷 query
- [x] **Task 3.1: `CHEER_CREATOR` refresh window를 KST 전날 하루로 변경**
- 파일 경로:
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/recommendation/application/RecommendationSnapshotRefreshService.kt`
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/recommendation/port/out/HomeRecommendationQueryPort.kt`
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/recommendation/adapter/out/persistence/DefaultHomeRecommendationQueryRepository.kt`
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/recommendation/application/RecommendationSnapshotRefreshServiceTest.kt`
- RED: `refreshCheerCreatorSnapshots(nowUtc)``RecommendationSnapshotWindowPolicy.previousKstDayUtcWindow(nowUtc)`로 얻은 `windowStartUtc`, `windowEndExclusiveUtc`, `snapshotAt`을 사용하도록 실패 테스트를 작성한다.
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.recommendation.application.RecommendationSnapshotRefreshServiceTest`
- GREEN: `CHEER_CREATOR` 단일 refresh 경로를 분리하고, 기존 일괄 refresh에서 최근 7일 window 대신 전날 UTC half-open window를 넘긴다.
- REFACTOR: `POPULAR_COMMUNITY`의 기존 7일 window는 변경하지 않는다. `HomeRecommendationQueryPort.findCheerCreatorSnapshots(...)` 시그니처는 `windowEndExclusiveUtc` 의미가 드러나도록 정리한다.
- 기대 결과: 스케줄러와 fallback이 같은 `CHEER_CREATOR` 전날 refresh 경로를 호출할 수 있다.
- [x] **Task 3.2: 채널 후원 금액/후원 수 집계 기준 변경**
- 파일 경로:
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/recommendation/adapter/out/persistence/DefaultHomeRecommendationQueryRepository.kt`
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/recommendation/adapter/out/persistence/DefaultHomeRecommendationQueryRepositoryTest.kt`
- RED: 같은 `UseCanCalculate.useCan`을 참조하는 여러 row가 있을 때 `channelDonationAmount``use_can_calculate.can` 값을 그대로 합산하고, `channelDonationCount`는 distinct `use_can` 기준 1건으로 계산되는 실패 테스트를 작성한다.
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.recommendation.adapter.out.persistence.DefaultHomeRecommendationQueryRepositoryTest`
- GREEN: donation stats query에서 금액은 기존 `sum(ucc.can)`을 유지하고, 후원 수는 `count(distinct ucc.use_can_id)` 또는 엔티티 매핑에 맞는 동일 의미 컬럼으로 변경한다.
- REFACTOR: `CanUsage.CHANNEL_DONATION`, `status = RECEIVED`, `is_refund = false` 등 기존 제외 조건은 유지한다.
- 기대 결과: 후원 이벤트 단위 중복 제거가 점수의 후원 수 항목에만 적용된다.
- [x] **Task 3.3: 팬Talk 수와 half-open 시간 조건 적용**
- 파일 경로:
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/recommendation/adapter/out/persistence/DefaultHomeRecommendationQueryRepository.kt`
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/recommendation/adapter/out/persistence/DefaultHomeRecommendationQueryRepositoryTest.kt`
- RED: `CreatorCheers.isActive == true` row만 집계하고, `created_at >= windowStartUtc and created_at < windowEndExclusiveUtc` 조건으로 전날 경계를 검증하는 실패 테스트를 작성한다. `windowEndExclusiveUtc`와 같은 시각의 row는 제외되어야 한다.
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.recommendation.adapter.out.persistence.DefaultHomeRecommendationQueryRepositoryTest`
- GREEN: `creator_cheers` 집계 조건을 active row와 half-open time range로 맞춘다.
- REFACTOR: 후원 집계 조건도 같은 half-open range를 사용해 `<= :snapshotAt` 방식이 남지 않게 정리한다.
- 기대 결과: 전날 KST 하루 경계가 후원과 팬Talk 집계에 동일하게 적용된다.
- [x] **Task 3.4: 응원 크리에이터 DB-side 점수와 부스트 적용**
- 파일 경로:
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/recommendation/adapter/out/persistence/DefaultHomeRecommendationQueryRepository.kt`
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/recommendation/adapter/out/persistence/DefaultHomeRecommendationQueryRepositoryTest.kt`
- RED: DB 스냅샷 query 결과 점수가 `RecommendationScorePolicy.calculateCheerScore(...)`와 동일하고, 데뷔일 기준 신규 부스트 `1.15/1.10/1.05/1.0` 경계값을 반영하는 실패 테스트를 작성한다.
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.recommendation.adapter.out.persistence.DefaultHomeRecommendationQueryRepositoryTest`
- GREEN: native SQL score expression과 boost case expression을 `RecommendationScoreSpec`의 응원 전용 상수로 변경한다.
- REFACTOR: `creator_debut` 계산은 콘텐츠 첫 공개일과 라이브 첫 진행일 중 빠른 날짜라는 기존 정의를 유지한다.
- 기대 결과: DB-side exact scoring과 Kotlin 정책 테스트의 산식 값이 일치한다.
- [x] **Task 3.5: 후보 제외, 정렬, limit 회귀 테스트 보강**
- 파일 경로:
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/recommendation/adapter/out/persistence/DefaultHomeRecommendationQueryRepository.kt`
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/recommendation/adapter/out/persistence/DefaultHomeRecommendationQueryRepositoryTest.kt`
- RED: 후원/팬Talk가 모두 0인 크리에이터 제외, 데뷔일이 없는 크리에이터 제외, 비활성 크리에이터 제외, 점수 내림차순/`randomTieBreaker` 오름차순/limit 16 동작을 검증하는 실패 테스트를 작성한다.
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.recommendation.adapter.out.persistence.DefaultHomeRecommendationQueryRepositoryTest`
- GREEN: 기존 후보 제외 조건과 정렬/limit을 PRD 기준에 맞게 유지 또는 보정한다.
- REFACTOR: AI 캐릭터와 인기 커뮤니티 스냅샷 query가 영향받지 않았는지 focused test로 확인한다.
- 기대 결과: `CHEER_CREATOR` 스냅샷 저장 후보만 정확히 변경된다.
---
### Phase 4: refresh 저장과 empty marker
- [x] **Task 4.1: `CHEER_CREATOR` 빈 결과 marker 저장 정책 추가**
- 파일 경로:
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/recommendation/adapter/out/persistence/RecommendationSnapshotPersistenceAdapter.kt`
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/recommendation/adapter/out/persistence/RecommendationSnapshotPersistenceAdapterTest.kt`
- RED: `replaceSnapshots(RecommendedSectionType.CHEER_CREATOR, snapshotAt, emptyList())` 호출 시 `targetId = 0` marker가 저장되고, `findLatestSnapshots(CHEER_CREATOR)`는 빈 배열이며, `existsLatestSnapshot(CHEER_CREATOR)`는 true인 실패 테스트를 작성한다.
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.recommendation.adapter.out.persistence.RecommendationSnapshotPersistenceAdapterTest`
- GREEN: empty marker 적용 대상을 `AI_CHARACTER``CHEER_CREATOR`로 제한하는 명시적 상수/함수를 추가한다.
- REFACTOR: `POPULAR_COMMUNITY` 등 다른 섹션으로 marker 정책을 확장하지 않는다.
- 기대 결과: 데이터가 없는 날에도 `CHEER_CREATOR` refresh 완료 상태가 저장된다.
- [x] **Task 4.2: 실제 row 재실행 시 marker 대체 보장**
- 파일 경로:
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/recommendation/adapter/out/persistence/RecommendationSnapshotPersistenceAdapter.kt`
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/recommendation/adapter/out/persistence/RecommendationSnapshotPersistenceAdapterTest.kt`
- RED: 같은 `sectionType`, `snapshotAt`에 marker가 있는 상태에서 실제 스냅샷 row로 `replaceSnapshots(...)`를 호출하면 marker가 제거되고 실제 row만 남는 실패 테스트를 작성한다.
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.recommendation.adapter.out.persistence.RecommendationSnapshotPersistenceAdapterTest`
- GREEN: 기존 `deleteBySectionTypeAndSnapshotAt` 후 저장 흐름이 marker 대체를 보장하는지 확인하고, 부족하면 해당 경로만 보정한다.
- REFACTOR: 조회 쿼리의 `target_id <> 0` 조건은 유지한다.
- 기대 결과: 빈 결과 refresh 후 재실행으로 실제 추천 row가 생겨도 marker가 응답/존재 상태를 오염시키지 않는다.
- [x] **Task 4.3: refresh service가 `CHEER_CREATOR` 저장 수와 marker 상태를 로그로 남김**
- 파일 경로:
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/recommendation/application/RecommendationSnapshotRefreshService.kt`
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/recommendation/application/RecommendationSnapshotRefreshServiceTest.kt`
- RED: `refreshCheerCreatorSnapshots(nowUtc)` 성공 시 `event=cheer_creator_recommendation_snapshot_refresh_success`, `savedCount`, `windowStartUtc`, `windowEndExclusiveUtc`, `snapshotAt`이 로그에 남는 실패 테스트를 작성한다.
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.recommendation.application.RecommendationSnapshotRefreshServiceTest`
- GREEN: 기존 구조화 로그 관례에 맞춰 section refresh 성공/실패 로그를 추가한다.
- REFACTOR: 전체 일괄 refresh 성공 로그는 유지하되, 섹션별 로그와 중복되어도 검색 가능한 event key를 사용한다.
- 기대 결과: 운영에서 `CHEER_CREATOR` refresh 결과 0건과 실패를 구분할 수 있다.
---
### Phase 5: fallback refresh
- [x] **Task 5.1: 섹션 스냅샷 fallback 공통 runner 도입**
- 파일 경로:
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/recommendation/application/RecommendationSnapshotFallbackService.kt`
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/recommendation/application/AiCharacterSnapshotFallbackPort.kt`
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/recommendation/application/RecommendationSnapshotFallbackServiceTest.kt`
- RED: 공통 runner가 section type, lock key, refresh action, offset/limit을 받아 double-check 조회 후 refresh를 실행하는 실패 테스트를 작성한다. 기존 AI 캐릭터 fallback 테스트도 통과해야 한다.
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.recommendation.application.RecommendationSnapshotFallbackServiceTest`
- GREEN: AI 캐릭터 전용 lock/timeout/single-flight 흐름을 공통 runner로 이동하거나, AI 서비스가 공통 runner를 위임 호출하도록 최소 변경한다.
- REFACTOR: Redisson `RLock` 획득/해제는 refresh worker thread 안에서 수행한다. 테스트에서는 deterministic executor를 사용해 sleep 기반 테스트를 피한다.
- 기대 결과: `CHEER_CREATOR` fallback을 추가할 때 lock/double-check/timeout 구현을 중복 작성하지 않는다.
- [x] **Task 5.2: `CHEER_CREATOR` fallback target 추가**
- 파일 경로:
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/recommendation/application/RecommendationSnapshotFallbackService.kt`
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/recommendation/application/RecommendationSnapshotRefreshService.kt`
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/recommendation/application/RecommendationSnapshotFallbackServiceTest.kt`
- RED: 최신 `CHEER_CREATOR` 스냅샷이 없을 때 lock key `lock:recommendation-snapshot-refresh:CHEER_CREATOR`로 lock을 얻고, `refreshCheerCreatorSnapshots(nowUtc)`를 호출한 뒤 저장된 스냅샷을 재조회하는 실패 테스트를 작성한다.
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.recommendation.application.RecommendationSnapshotFallbackServiceTest`
- GREEN: `CHEER_CREATOR` fallback target을 등록하고 lock 대기 300ms, 홈 대기 1,500ms 상수를 PRD 값으로 유지한다.
- REFACTOR: `AI_CHARACTER` fallback lock key와 상호 간섭하지 않도록 section별 key를 분리한다.
- 기대 결과: 스냅샷 없음 상황에서 홈 API가 스케줄러와 동일한 `CHEER_CREATOR` refresh 로직을 재사용한다.
- [x] **Task 5.3: lock miss, timeout, 실패, empty marker fallback 케이스 검증**
- 파일 경로:
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/recommendation/application/RecommendationSnapshotFallbackService.kt`
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/recommendation/application/RecommendationSnapshotFallbackServiceTest.kt`
- RED: lock 획득 실패 시 중복 refresh를 시작하지 않는 테스트, 홈 대기 1,500ms timeout 시 빈 배열을 반환하되 background refresh를 cancel하지 않는 테스트, refresh 실패 시 빈 배열과 warn log를 남기는 테스트, marker가 있으면 fallback을 반복하지 않는 테스트를 작성한다.
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.recommendation.application.RecommendationSnapshotFallbackServiceTest`
- GREEN: 공통 runner의 future/single-flight 상태, 예외 처리, timeout 처리, existsLatestSnapshot double-check 흐름을 구현한다.
- REFACTOR: fallback service는 점수 계산이나 상세 DTO 조립을 직접 하지 않고 snapshot 조회와 refresh orchestration만 담당한다.
- 기대 결과: 홈 조회 경로에서 refresh 중복, 장시간 대기, 전체 API 실패가 발생하지 않는다.
- [x] **Task 5.4: 홈 응원 크리에이터 조회에 fallback 연결**
- 파일 경로:
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/recommendation/application/HomeRecommendationQueryService.kt`
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/recommendation/application/HomeRecommendationQueryServiceTest.kt`
- RED: `findCheerCreatorRecommendations(...)`가 최신 스냅샷 없음이면 `CHEER_CREATOR` fallback을 호출하고, fallback 결과 스냅샷 순서대로 상세를 조립하는 실패 테스트를 작성한다. marker가 있어 `existsLatestSnapshot(CHEER_CREATOR) == true`이면 fallback을 호출하지 않는 테스트도 추가한다.
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.recommendation.application.HomeRecommendationQueryServiceTest`
- GREEN: 기존 `findAiCharacterSnapshotsWithFallback(...)`와 같은 패턴으로 `CHEER_CREATOR` 스냅샷 조회에 fallback을 연결한다.
- REFACTOR: 스냅샷 후보 16개 조회, 상세 조회 후 최대 8개 반환, 차단 필터 전달은 기존 동작을 유지한다.
- 기대 결과: 홈 통합 조회의 최근 응원이 많은 크리에이터 섹션이 스냅샷 없음 상황을 자체 복구할 수 있다.
---
### Phase 6: API 회귀와 최종 검증
- [x] **Task 6.1: 홈 API 응답 스키마 회귀 검증**
- 파일 경로:
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/home/HomeRecommendationControllerTest.kt`
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/home/dto/recommendation/HomeRecommendationResponseTest.kt`
- RED: 홈 통합 조회의 `CHEER_CREATOR` 응답이 기존 `creatorId`, `creatorNickname`, `creatorProfileImage` 필드만 유지하고 신규 필드를 추가하지 않는 회귀 테스트를 확인/보강한다.
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.home.HomeRecommendationControllerTest --tests kr.co.vividnext.sodalive.v2.api.home.dto.recommendation.HomeRecommendationResponseTest`
- GREEN: DTO/Controller 변경 없이 application service 결과가 기존 response로 매핑되게 한다.
- REFACTOR: 공개 API URL과 JSON field name 변경이 없음을 assertion으로 유지한다.
- 기대 결과: 클라이언트 공개 스키마는 변경되지 않는다.
- [x] **Task 6.2: focused regression 실행**
- 파일 경로:
- Modify: `docs/20260710_메인_홈_추천_응원크리에이터_스냅샷/plan-task.md`
- RED: 구현 task 완료 후 계획 문서에 기록할 focused command 목록을 확정한다.
- 실패 확인: 해당 없음. TDD 예외 사유: 검증 기록 문서화 task다.
- GREEN: 아래 명령을 실행하고 결과를 이 문서 하단 검증 기록에 누적한다.
- REFACTOR: 실패한 명령이 있으면 원인과 재실행 결과를 같은 task 아래에 기록한다.
- 실행 명령:
- `./gradlew test --tests kr.co.vividnext.sodalive.v2.recommendation.domain.RecommendationScorePolicyTest`
- `./gradlew test --tests kr.co.vividnext.sodalive.v2.recommendation.adapter.out.persistence.RecommendationSnapshotPersistenceAdapterTest`
- `./gradlew test --tests kr.co.vividnext.sodalive.v2.recommendation.adapter.out.persistence.DefaultHomeRecommendationQueryRepositoryTest`
- `./gradlew test --tests kr.co.vividnext.sodalive.v2.recommendation.application.RecommendationSnapshotRefreshServiceTest --tests kr.co.vividnext.sodalive.v2.recommendation.application.RecommendationSnapshotFallbackServiceTest --tests kr.co.vividnext.sodalive.v2.recommendation.application.HomeRecommendationQueryServiceTest`
- `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.home.HomeRecommendationControllerTest --tests kr.co.vividnext.sodalive.v2.api.home.dto.recommendation.HomeRecommendationResponseTest`
- 기대 결과: 산식/집계/marker/fallback/API 회귀가 최소 명령으로 검증된다.
- [x] **Task 6.3: 전체 회귀와 문서 검증**
- 파일 경로:
- Modify: `docs/20260710_메인_홈_추천_응원크리에이터_스냅샷/plan-task.md`
- RED: 구현 완료 후 전체 회귀 명령 실행 전에는 검증 기록이 구현 전 상태여야 한다.
- 실패 확인: 해당 없음. TDD 예외 사유: 검증 기록 문서화 task다.
- GREEN: `./gradlew ktlintCheck`, `./gradlew test`, `./gradlew tasks --all`, `git diff --check`를 실행하고 결과를 검증 기록에 남긴다.
- REFACTOR: 문서와 코드의 산식/timeout/window 값이 다르면 구현 또는 문서를 수정한 뒤 재검증한다.
- 기대 결과: 전체 테스트, 포맷, 문서 명령 유효성, diff 공백 검사가 모두 통과한다.
---
## Coverage Check
- Feature A: Task 2.1, Task 3.2, Task 3.4에서 후원 금액 45%, 팬Talk 30%, 후원 수 10%, 후원 수 distinct 기준을 검증한다.
- Feature B: Task 3.1, Task 3.3에서 KST 전날 하루를 UTC half-open 조회 범위로 변환하고 `windowEndExclusiveUtc` 경계를 검증한다.
- Feature C: Task 2.2, Task 3.4에서 데뷔일 기준 응원 전용 신규 부스트와 경계값을 검증한다.
- Feature D: Task 3.5, Task 5.4, Task 6.1에서 최신 `CHEER_CREATOR` 스냅샷 순서, 후보 16개/응답 8개, 기존 응답 스키마 유지를 검증한다.
- Feature E: Task 5.1, Task 5.2, Task 5.3, Task 5.4에서 fallback refresh 재사용, double-check, 300ms lock 대기, 1,500ms 홈 API 대기, timeout 후 background 완료, 중복 refresh 방지를 검증한다.
- Feature F: Task 4.1, Task 4.2, Task 5.3에서 `CHEER_CREATOR` empty marker 저장, 조회 제외, 존재 여부 true, marker 기반 fallback 반복 방지를 검증한다.
- Non-Goals: Task 4.1, Task 6.1, Task 6.3에서 다른 스냅샷 섹션 marker 확장 없음, 공개 API URL/응답 필드 변경 없음, 신규 DDL 없음, 관리자/ML/A-B 제외를 확인한다.
## 전체 검증 기록
- 2026-07-10: PRD 기반으로 `plan-task.md`를 생성했다. 구현 전 계획 문서 작성 작업이므로 코드 테스트는 아직 실행하지 않았고, 문서 형식/명령 유효성 검증을 진행한다.
- 2026-07-10: 문서 검증으로 `git diff --check -- docs/20260710_메인_홈_추천_응원크리에이터_스냅샷/plan-task.md`를 실행해 통과했다. `./gradlew tasks --all`은 일반 sandbox에서 `~/.gradle` wrapper lock 파일 접근 제한으로 실패했고, 권한 상승 재실행 결과 `BUILD SUCCESSFUL`로 통과했다.
- 2026-07-10: 구현 RED 확인으로 `RecommendationScorePolicyTest``CHEER_NEW_BOOST_*``calculateCheerCreatorNewBoost(...)` 미구현 컴파일 실패를 확인했고, `DefaultHomeRecommendationQueryRepositoryTest`는 half-open/distinct 집계 기대값 불일치 실패를 확인했다. `RecommendationSnapshotPersistenceAdapterTest``CHEER_CREATOR` empty marker 미지원 실패를 확인했다.
- 2026-07-10: focused regression으로 `./gradlew test --tests kr.co.vividnext.sodalive.v2.recommendation.domain.RecommendationScorePolicyTest --tests kr.co.vividnext.sodalive.v2.recommendation.adapter.out.persistence.RecommendationSnapshotPersistenceAdapterTest --tests kr.co.vividnext.sodalive.v2.recommendation.adapter.out.persistence.DefaultHomeRecommendationQueryRepositoryTest --tests kr.co.vividnext.sodalive.v2.recommendation.application.RecommendationSnapshotRefreshServiceTest --tests kr.co.vividnext.sodalive.v2.recommendation.application.RecommendationSnapshotFallbackServiceTest --tests kr.co.vividnext.sodalive.v2.recommendation.application.HomeRecommendationQueryServiceTest --tests kr.co.vividnext.sodalive.v2.api.home.HomeRecommendationControllerTest --tests kr.co.vividnext.sodalive.v2.api.home.dto.recommendation.HomeRecommendationResponseTest`를 실행해 `BUILD SUCCESSFUL`로 통과했다.
- 2026-07-10: 최종 검증으로 `./gradlew ktlintCheck`, `./gradlew test`, `./gradlew tasks --all`, `git diff --check`를 실행했다. `./gradlew test`는 최초 300초 timeout 후 600초 timeout으로 재실행해 `BUILD SUCCESSFUL`로 통과했고, 나머지 명령도 `BUILD SUCCESSFUL` 또는 출력 없음으로 통과했다.
- 2026-07-10: 리뷰 보강으로 `CHEER_CREATOR` 스케줄러 refresh도 section lock을 사용하도록 수정하고, 공통 fallback runner의 기본 worker를 2개 thread로 변경해 AI/응원 섹션 간 fallback 대기 간섭을 줄였다. `RecommendationSnapshotFallbackServiceTest`에 lock miss, refresh 실패, timeout 후 worker 지속, AI block 중 CHEER fallback 독립 실행 테스트를 추가해 Task 5.3 커버리지와 완료 표시를 맞췄다. 보강 RED 확인 후 `./gradlew test --tests kr.co.vividnext.sodalive.v2.recommendation.application.RecommendationSnapshotRefreshServiceTest --tests kr.co.vividnext.sodalive.v2.recommendation.application.RecommendationSnapshotFallbackServiceTest` 재실행 결과 `BUILD SUCCESSFUL`로 통과했다.
- 2026-07-10: 리뷰 보강 후 순차 검증으로 `./gradlew ktlintCheck`, `./gradlew test --tests kr.co.vividnext.sodalive.v2.recommendation.application.RecommendationSnapshotRefreshServiceTest --tests kr.co.vividnext.sodalive.v2.recommendation.application.RecommendationSnapshotFallbackServiceTest`, focused recommendation/API regression 묶음, `git diff --check`를 실행했다. 모두 `BUILD SUCCESSFUL` 또는 출력 없음으로 통과했다.
- 2026-07-10: 추가 리뷰 보강으로 `CHEER_CREATOR` 홈/fallback 조회를 expected `snapshotAt` 기준으로 변경해 stale latest snapshot이 fallback을 막지 않게 했다. `RecommendationSnapshotPort.findSnapshots(...)`, `existsSnapshot(...)`와 adapter/repository exact snapshot 조회 테스트를 추가했고, `DefaultHomeRecommendationQueryRepositoryTest``findCheerCreatorSnapshots(...)` 호출부는 두 번째 인자를 `windowEndExclusive`로 정리했다. 비활성 legacy `AiCharacterSnapshotFallbackServiceTest`는 삭제하고 AI 핵심 fallback coverage를 `RecommendationSnapshotFallbackServiceTest`로 이관했다. focused regression, `./gradlew test`, `./gradlew ktlintCheck`, `git diff --check`를 재실행해 `BUILD SUCCESSFUL` 또는 출력 없음으로 통과했다.
- 2026-07-10: `review-work`에서 발견한 blocking 이슈를 수정했다. `HomeRecommendationQueryService`가 expected `snapshotAt` 계산에 사용한 같은 `nowUtc``refreshCheerCreatorIfMissing(...)`에 전달하도록 변경했고, `RecommendationSnapshotFallbackServiceTest`의 기본 현재 시각 의존 호출을 고정 `nowUtc`로 바꿨다. `HomeRecommendationQueryServiceTest`는 fallback에 전달된 `nowUtc`를 검증한다. 재검증으로 focused recommendation/API regression, `./gradlew ktlintCheck`, `./gradlew test`, `git diff --check`를 실행했고 모두 `BUILD SUCCESSFUL` 또는 출력 없음으로 통과했다.

View File

@@ -0,0 +1,250 @@
# PRD: 메인 홈 추천 응원 크리에이터 스냅샷 수정
## 1. Overview
메인 홈 추천 탭의 `CHEER_CREATOR` 스냅샷 생성과 조회를 전날 데이터 기반의 응원 점수로 수정하고, 스냅샷이 없을 때 홈 API가 동일 refresh 로직을 안전하게 재사용하도록 fallback 흐름을 보강한다.
---
## 2. Problem
- 기존 `CHEER_CREATOR` 산식은 최근 7일 데이터를 기반으로 하며, 후원 금액 가중치와 신규 부스트 값이 이번 요구사항과 다르다.
- 현재 일괄 refresh와 홈 API fallback refresh가 섹션별로 동일한 생성 로직을 공유하지 않으면 산식 drift가 발생할 수 있다.
- 스냅샷이 없는 초기 배포, 운영 데이터 삭제, 배치 실패 상황에서 홈 조회가 매 요청마다 무거운 집계를 중복 실행하면 API 지연과 DB 부하가 커질 수 있다.
- 집계 산식이 추천 노출 순서를 직접 바꾸므로 DB-side 계산과 Kotlin-side 계산 중 어떤 방식을 선택하더라도 산식/부스트 경계값 테스트가 필요하다.
---
## 3. Goals
- `CHEER_CREATOR` 스냅샷은 전날 KST 하루 데이터를 기반으로 생성한다.
- 응원 점수 산식을 `((채널 후원 금액 * 0.45) + (팬Talk 수 * 0.30) + (채널 후원 수 * 0.10)) * 신규 부스트`로 변경한다.
- 신규 부스트는 크리에이터 데뷔일 기준 10일 이내 1.15, 20일 이내 1.10, 30일 이내 1.05, 그 외 1.0을 적용한다.
- 스케줄러 refresh와 fallback refresh는 동일한 `CHEER_CREATOR` refresh 로직을 재사용한다.
- 스냅샷이 없을 때 fallback refresh는 lock, double-check, single-flight 대기로 중복 refresh를 방지한다.
- 홈 API는 fallback refresh 완료를 최대 1,500ms까지만 기다리고, lock 대기는 최대 300ms로 제한한다.
- fallback refresh 실패, timeout, refresh 결과 없음은 홈 API 전체 실패로 전파하지 않고 `CHEER_CREATOR` 섹션 빈 배열로 처리한다.
- 산식과 신규 부스트는 단위 테스트에서 경계값과 가중치 계산을 촘촘히 검증한다.
---
## 4. Non-Goals
- 메인 홈 추천 API URL, 응답 필드, 응답 JSON 스키마는 변경하지 않는다.
- `CHEER_CREATOR` 이외 추천 섹션의 산식과 조회 정책은 변경하지 않는다.
- 관리자 화면, 수동 추천 편집, A/B 테스트, 개인화 추천은 이번 범위에 포함하지 않는다.
- 후원, 팬Talk 생성/수정/삭제 자체의 도메인 동작은 변경하지 않는다.
- 신규 추천 스냅샷 테이블을 만들지 않고, 기존 `recommendation_snapshot` 구조를 우선 재사용한다.
---
## 5. Target Users
- 회원/비회원: 메인 홈 추천 탭에서 전날 응원 반응이 많았던 크리에이터를 발견하는 사용자
- 앱 클라이언트: 기존 응답 계약을 유지한 채 `CHEER_CREATOR` 추천 순서만 변경된 결과를 받는 클라이언트
- 운영자: 전날 후원/팬Talk 반응이 추천 노출에 반영되는지 확인해야 하는 운영 담당자
---
## 6. User Stories
- 사용자는 메인 홈 추천 탭에서 전날 응원이 많았던 크리에이터를 우선 보고 싶다.
- 사용자는 후원 금액뿐 아니라 팬Talk와 후원 참여 횟수도 함께 반영된 추천을 보고 싶다.
- 사용자는 신규 크리에이터가 일정 기간 동안 적절한 노출 기회를 받기를 기대한다.
- 앱 클라이언트는 스냅샷이 없는 상황에서도 홈 API가 실패하지 않고 안정적으로 빈 배열 또는 생성된 스냅샷을 받기를 원한다.
- 운영자는 배치 실패 후 첫 홈 조회가 스케줄러와 동일한 로직으로 스냅샷을 복구하기를 원한다.
---
## 7. Core Features
### Feature A. 응원 크리에이터 스냅샷 산식 변경
#### Requirements
- `CHEER_CREATOR` 점수는 아래 산식으로 계산한다.
- `score = ((channelDonationAmount * 0.45) + (fanTalkCount * 0.30) + (channelDonationCount * 0.10)) * newBoost`
- `channelDonationAmount`는 집계 기간 안에 발생한 채널 후원 금액 합계다.
- `channelDonationCount`는 집계 기간 안에 발생한 채널 후원 건수다.
- 채널 후원 수는 `UseCanCalculate.useCan`이 같은 row를 1개 후원 이벤트로 보고 중복 제거해 계산한다.
- 채널 후원은 기존 요구사항과 동일하게 `CanUsage.CHANNEL_DONATION`이며, 환불/미수령/비정상 상태는 기존 채널 후원 집계 제외 조건을 유지한다.
- `fanTalkCount`는 집계 기간 안에 생성된 활성 팬Talk 수다.
- 팬Talk 수는 `CreatorCheers.isActive == true`인 row 수로 계산한다.
- 팬Talk는 기존 `CreatorCheers` 또는 현재 구현의 `creator_cheers` 기반 데이터를 의미한다.
- 점수 산식 상수는 `RecommendationScoreSpec` 등 기존 점수 정책 위치에 모아 DB expression과 Kotlin 정책 테스트가 같은 값을 참조하도록 한다.
- 스냅샷 정렬은 점수 내림차순, 동점이면 스냅샷 생성 시 저장한 `randomTieBreaker` 오름차순을 유지한다.
- 최종 저장 수는 기존 홈 노출 안정성을 위해 `CHEER_CREATOR` 최대 16개를 유지한다.
#### Edge Cases
- `channelDonationAmount`, `fanTalkCount`, `channelDonationCount`가 모두 0인 크리에이터는 스냅샷 후보에서 제외한다.
- 후원 금액은 없지만 팬Talk가 있으면 산식에 따라 점수를 계산한다.
- 팬Talk는 없지만 채널 후원이 있으면 산식에 따라 점수를 계산한다.
- 비활성 크리에이터, 차단 필터에 의해 조회 시 제외되는 크리에이터는 최종 홈 응답에서 제외한다.
- 스냅샷에는 존재하지만 조회 시점에 크리에이터가 비활성화된 경우 응답에서 제외한다.
### Feature B. 전날 데이터 기반 집계 기간
#### Requirements
- 점수 입력값은 전날 KST 하루 데이터만 사용한다.
- 전날 기준은 KST 기준 전일 00:00:00 이상, 다음날 00:00:00 미만의 half-open 범위로 정의한다.
- 운영 DB 시간이 UTC 기준이면, 실제 조회는 `windowStartUtc <= createdAt < windowEndExclusiveUtc`로 변환해 사용한다.
- `snapshotAt`은 해당 KST 전날의 종료 시각을 UTC로 변환한 `windowEndExclusiveUtc.minusSeconds(1)`을 사용한다.
- 스케줄러가 KST 06:00에 실행되면 실행일 전날 KST 일자를 집계 대상으로 삼는다.
- 기존 코드에 남아 있는 `created_at <= :snapshotAt` 방식은 이번 범위에서 half-open end-exclusive 조건으로 맞춘다.
#### Edge Cases
- 집계 기간에 대상 데이터가 없으면 실제 추천 row는 0개일 수 있다.
- 데이터가 0개인 날도 refresh가 정상 수행되었음을 구분할 수 있어야 한다.
- 같은 `sectionType`, `snapshotAt`에 대해 재실행하면 기존 스냅샷을 대체하는 정책을 유지한다.
### Feature C. 신규 부스트 변경
#### Requirements
- 신규 부스트 기준일은 크리에이터 데뷔일이다.
- 크리에이터 데뷔일은 기존 홈 추천 PRD와 동일하게 콘텐츠를 처음 공개한 날과 라이브를 한 날 중 빠른 날짜로 계산한다.
- 신규 부스트 구간은 다음과 같다.
- 데뷔 후 10일 이내: `1.15`
- 데뷔 후 20일 이내: `1.10`
- 데뷔 후 30일 이내: `1.05`
- 그 외: `1.0`
- 경계일 계산은 기존 추천 점수 정책의 날짜 단위 계산과 일관되게 한다.
- 부스트 기준 시각은 스냅샷의 `snapshotAt`이다.
#### Edge Cases
- 공개 콘텐츠와 라이브 이력이 모두 없어 데뷔일을 계산할 수 없는 크리에이터는 스냅샷 후보에서 제외한다.
- 데뷔일이 `snapshotAt`보다 미래인 데이터는 데이터 오류로 보고 스냅샷 후보에서 제외한다.
- 10일/20일/30일 경계값은 테스트에서 명확히 검증한다.
### Feature D. `CHEER_CREATOR` 스냅샷 조회
#### Requirements
- 홈 통합 조회 `GET /api/v2/home/recommendations`의 최근 응원이 많은 크리에이터 섹션은 최신 `CHEER_CREATOR` 스냅샷 순서를 사용한다.
- 기존 노출 정보는 유지한다.
- `creatorId`
- 크리에이터 닉네임
- 크리에이터 프로필 이미지
- 조회 시점에도 기존 차단 필터와 활성 크리에이터 필터를 적용한다.
- 스냅샷 후보는 최대 16개까지 조회하고, 상세 조회/필터링 후 홈 첫 화면에는 최대 8명을 반환한다.
#### Edge Cases
- 최신 스냅샷 row가 존재하지만 조회 필터로 모두 제외되면 빈 배열을 반환한다.
- 상세 조회 결과가 스냅샷 저장 개수보다 적어도 홈 조회 전체는 성공 처리한다.
- 스냅샷 정렬 순서와 응답 순서는 일치해야 한다.
### Feature E. 스냅샷 없음 fallback refresh
#### Requirements
- 최신 `CHEER_CREATOR` 스냅샷이 없으면 fallback refresh를 시도한다.
- fallback refresh는 홈 API에서 직접 계산 결과를 응답하지 않고, 스케줄러와 동일한 refresh 로직으로 `recommendation_snapshot`에 저장한 뒤 저장된 스냅샷을 다시 조회한다.
- fallback refresh 흐름은 다음 순서를 따른다.
- 최신 `CHEER_CREATOR` 스냅샷 조회
- 최신 스냅샷이 없으면 fallback refresh 진입
- refresh 전 섹션 전용 lock 획득 시도
- lock 안에서 최신 스냅샷을 한 번 더 조회
- 여전히 없으면 스케줄러와 동일한 `CHEER_CREATOR` refresh 로직 실행
- 저장된 스냅샷을 다시 조회해 반환
- refresh 후에도 노출 가능한 스냅샷이 없으면 빈 배열 반환
- lock key는 섹션 단위로 분리한다.
- 권장: `lock:recommendation-snapshot-refresh:CHEER_CREATOR`
- lock 대기 시간은 최대 300ms로 제한한다.
- 홈 API가 fallback refresh 완료를 기다리는 시간은 최대 1,500ms로 제한한다.
- timeout은 홈 API 대기 timeout이며, 이미 시작된 refresh 작업을 반드시 중단한다는 의미가 아니다.
- 동일 JVM에서는 single-flight 상태를 유지해 동시에 들어온 홈 요청이 중복 refresh를 시작하지 않도록 한다.
- 다중 인스턴스에서는 Redisson 기반 분산 lock으로 인스턴스 간 중복 refresh를 방지한다.
- lock 획득 실패 또는 refresh 진행 중인 요청은 짧게 대기한 뒤 최신 스냅샷을 다시 조회하고, 없으면 빈 배열을 반환한다.
- fallback refresh 실패는 로그를 남기고 홈 API 전체 실패로 전파하지 않는다.
#### Edge Cases
- lock 획득 직전에 다른 요청 또는 스케줄러가 스냅샷을 만들 수 있으므로 double-check가 필요하다.
- fallback refresh가 성공했지만 집계 대상 데이터가 없으면 빈 배열을 반환한다.
- 집계 대상 데이터가 없는 정상 refresh와 아직 한 번도 refresh되지 않은 상태를 구분해야 반복 fallback을 막을 수 있다.
- 홈 API 대기 시간이 초과되어 빈 배열을 반환한 뒤에도 백그라운드 refresh가 완료되면 이후 요청은 저장된 최신 스냅샷을 사용한다.
### Feature F. 빈 결과 refresh 마커
#### Requirements
- `CHEER_CREATOR`도 AI 캐릭터와 같은 방식으로 빈 결과 refresh 마커를 저장하는 방안을 우선 검토한다.
- 권장 방식은 `targetId = 0` empty snapshot marker를 `CHEER_CREATOR`에도 적용하는 것이다.
- 조회 쿼리는 기존처럼 `target_id <> 0`을 유지해 marker가 사용자 응답에 노출되지 않게 한다.
- 존재 여부 확인은 marker를 포함해 판단하여, 집계 결과가 없는 날 매 홈 요청마다 fallback refresh가 반복되지 않도록 한다.
- 이 방식은 별도 DDL 없이 기존 `recommendation_snapshot` 구조를 재사용할 수 있어 이번 요구사항에 가장 단순하다.
#### Edge Cases
- marker가 있더라도 실제 추천 row가 없으면 홈 응답은 빈 배열이다.
- 같은 `sectionType`, `snapshotAt`에 실제 row가 생기는 재실행이 있으면 marker는 대체되어야 한다.
- marker 저장 정책을 `AI_CHARACTER``CHEER_CREATOR` 외 섹션으로 확장할지는 이번 구현 계획에서 필요한 범위만 결정한다.
---
## 8. Technical Constraints
- Kotlin, Spring Boot 2.7.14, Java 17, Gradle Wrapper 구조를 유지한다.
- 기존 `kr.co.vividnext.sodalive.v2.recommendation` 패키지 경계와 `v2.api.home`에서 `v2.recommendation`을 호출하는 의존 방향을 유지한다.
- 기존 `RecommendationSnapshot`, `RecommendationSnapshotPort`, `HomeRecommendationQueryPort` 기반 저장/조회 구조를 재사용한다.
- 공개 API 응답 DTO는 필드 추가 없이 유지한다.
- 스케줄러 refresh와 fallback refresh는 산식, 기간, 저장 limit, 정렬 기준이 갈라지지 않도록 같은 application service 경로를 사용한다.
- fallback refresh 기능은 AI 캐릭터 전용 구현을 복사하기보다 섹션별로 재사용 가능한 형태를 우선 검토한다. 단, 과도한 일반화가 필요하면 `CHEER_CREATOR`에 필요한 최소 추상화만 적용한다.
- `CHEER_CREATOR` 집계는 정확한 top 후보를 위해 최종 점수 계산 전 candidate pre-limit를 두지 않는다.
- DB-side scoring을 유지하는 경우 Kotlin 단에는 산식 parity 검증용 정책 함수를 두고, DB expression과 같은 상수를 공유한다.
- Kotlin-side scoring으로 변경하는 경우 DB에서 필요한 원천 metric을 정확히 집계하고, service에서 최종 점수/정렬/limit을 적용한다. 이 경우 후보 전체를 메모리에 올리는 비용과 데이터량을 구현 계획에서 검토한다.
- 기본 권장안은 현재 구조와 성능 특성을 유지하는 DB-side exact scoring이다. 다만 산식/부스트 계산은 Kotlin 정책 테스트로 검증 가능한 형태를 둔다.
- 시간 범위는 KST 전날을 UTC half-open window로 변환하는 `RecommendationSnapshotWindowPolicy`를 재사용한다.
- 신규 DDL은 만들지 않는 것을 기본으로 한다. 불가피하게 필요하면 운영 DB 반영용 SQL 문서는 MySQL 기준으로 별도 작성한다.
---
## 9. Metrics
- `CHEER_CREATOR` 스냅샷 생성 성공/실패 로그
- `CHEER_CREATOR` 스냅샷 최종 저장 수
- fallback refresh 실행/성공/실패/timeout 로그
- fallback refresh lock 획득 성공/실패 로그
- `channelDonationAmount`, `fanTalkCount`, `channelDonationCount` 입력값 분포
- empty snapshot marker 저장 횟수
- 홈 API `CHEER_CREATOR` 섹션 빈 응답 비율
- 홈 API fallback refresh 대기 시간
---
## 10. Open Questions
- 없음.
---
## 11. Decisions
- 채널 후원 금액은 `use_can_calculate.can` 값을 그대로 사용한다.
- 채널 후원 수는 `UseCanCalculate.useCan`이 같은 row를 1개 후원 이벤트로 보고 중복 제거한다.
- 팬Talk 수는 `CreatorCheers.isActive == true`인 row 수로 계산한다.
- 빈 결과 marker 정책은 다른 스냅샷 섹션에도 확장하는 것이 맞지만, 이번 구현 범위에서는 `CHEER_CREATOR`에만 적용한다.
---
## 12. Future Prompt: 빈 결과 marker 정책 공통화
아래 프롬프트는 `CHEER_CREATOR` 구현 이후 다른 추천 스냅샷 섹션에 empty snapshot marker 정책을 확장할 때 사용한다.
```text
추천 스냅샷의 빈 결과 marker 정책을 공통화한다.
목표:
- `recommendation_snapshot` 기반 추천 섹션에서 refresh 결과가 0건인 경우에도 "정상 refresh 완료" 상태를 저장한다.
- 조회 응답에는 marker가 절대 노출되지 않아야 한다.
- 스냅샷이 실제로 없는 상태와 refresh 결과가 빈 상태를 구분해 홈 API fallback refresh가 매 요청마다 반복 실행되지 않게 한다.
요구사항:
1. 기존 `targetId = 0` empty snapshot marker 정책을 `RecommendationSnapshot` 공통 저장 정책으로 정리한다.
2. marker 적용 대상 섹션은 구현 전에 명시한다. 기본 후보는 일 단위 refresh와 fallback refresh를 사용하는 섹션이다.
3. `findLatestSnapshots` 조회는 기존처럼 `target_id <> 0` 조건을 유지해 marker를 응답 후보에서 제외한다.
4. `existsLatestSnapshot` 또는 동등한 존재 여부 확인은 marker를 포함해 판단한다.
5. 같은 `sectionType`, `snapshotAt`에 실제 스냅샷 row가 생기는 재실행에서는 marker가 대체되어야 한다.
6. 섹션별 refresh 로직은 결과가 0건이어도 marker 저장을 통해 완료 상태를 남긴다.
7. marker 때문에 기존 API 응답 스키마, 정렬, limit, 상세 조회 로직이 바뀌면 안 된다.
8. 신규 DDL 없이 기존 `recommendation_snapshot` 테이블을 재사용한다.
검증 기준:
- marker만 있는 섹션의 최신 스냅샷 조회 결과는 빈 배열이다.
- marker만 있는 섹션의 존재 여부 확인은 true다.
- refresh 결과가 0건이면 marker가 저장된다.
- 이후 refresh 결과가 실제 row를 만들면 같은 `sectionType`, `snapshotAt`의 marker는 제거되고 실제 row만 남는다.
- fallback refresh는 marker가 있는 섹션에서 중복 refresh를 시작하지 않는다.
- 기존 실제 스냅샷 조회 정렬은 `score desc`, `randomTieBreaker asc`를 유지한다.
```
---
## 13. Related Documents
- `docs/prd/sample-prd.md`
- `docs/agent-guides/작업절차.md`
- `docs/agent-guides/문서유지보수.md`
- `docs/20260529_메인_홈_추천_API/prd.md`
- `docs/20260709_메인_홈_추천_AI캐릭터_스냅샷/prd.md`

View File

@@ -0,0 +1,322 @@
# 메인 홈 추천 인기 커뮤니티 스냅샷 수정 Plan/Task
## 시나리오 계약
- Happy path: 홈 API 호출 시각 기준 KST 전날 대상일 `snapshotAt`을 계산하고, 대상일 포함 최근 7일 데이터를 UTC half-open 범위로 변환해 `POPULAR_COMMUNITY` 점수를 계산한 뒤 점수순 상위 20개 스냅샷을 저장한다. Real surface: `RecommendationSnapshotWindowPolicyTest`, `DefaultHomeRecommendationQueryRepositoryTest`, `RecommendationSnapshotRefreshServiceTest`.
- Score: 인기 커뮤니티 점수는 `((likeCount * 0.50) + (commentCount * 0.40) + (creatorFollowerCount * 0.10)) * newBoost`다. `creatorFollowerCount`는 스냅샷 생성 시점의 활성 팔로워 총수다. Real surface: `RecommendationScorePolicyTest`, `DefaultHomeRecommendationQueryRepositoryTest`.
- Exclusion: 공지/고정 게시글(`CreatorCommunity.isFixed`/`is_fixed = true`)과 유료 게시글(`price > 0`)은 스냅샷 후보와 상세 조회 결과에서 제외한다. Real surface: `DefaultHomeRecommendationQueryRepositoryTest`, `HomeRecommendationQueryServiceTest`.
- Boost: 신규 부스트는 크리에이터 데뷔일 기준 0~10일 `1.15`, 11~20일 `1.10`, 21~30일 `1.05`, 31일 이상 `1.0`이다. Real surface: `RecommendationScorePolicyTest`, `DefaultHomeRecommendationQueryRepositoryTest`.
- Daily freshness: 과거 최신 `POPULAR_COMMUNITY` 스냅샷이 있어도 홈 API 호출 기준 대상일 `snapshotAt` 스냅샷이 없으면 fallback refresh를 시도한다. Real surface: `HomeRecommendationQueryServiceTest`, `RecommendationSnapshotFallbackServiceTest`.
- Fallback: 대상일 `POPULAR_COMMUNITY` 스냅샷이 없으면 lock, double-check, 동일 refresh 로직 재사용, refresh 후 재조회 순서로 fallback을 실행한다. lock 대기는 최대 300ms, 홈 API refresh 완료 대기는 최대 1,500ms다. Real surface: fallback service test, `HomeRecommendationQueryServiceTest`.
- Empty marker: `POPULAR_COMMUNITY` refresh 결과가 0건이면 `targetId = 0` marker를 저장해 정상 refresh 완료 상태를 남기고, 조회 응답에서는 marker를 제외한다. Real surface: `RecommendationSnapshotPersistenceAdapterTest`, `HomeRecommendationQueryServiceTest`.
- Adjacent regression: 메인 홈 추천 API URL과 `popularCommunityPosts` 응답 필드는 변경하지 않는다. AI 캐릭터, 응원 크리에이터, 최근 데뷔 등 다른 섹션 산식과 공개 스키마는 이번 변경으로 바꾸지 않는다. Real surface: 기존 focused tests, `HomeRecommendationControllerTest`.
## 범위와 전제
- 이번 문서는 `docs/20260710_메인_홈_추천_인기커뮤니티_스냅샷/prd.md`의 구현 계획이다.
- 신규 공개 API, 신규 응답 필드, 운영 DDL 추가는 범위에 포함하지 않는다.
- 기존 `recommendation_snapshot` 테이블과 `RecommendedSectionType.POPULAR_COMMUNITY`를 재사용한다.
- `POPULAR_COMMUNITY` 집계는 현재 구조와 성능 특성을 유지해 DB-side exact scoring을 기본으로 한다. Kotlin 단에는 산식/부스트 근거 테스트용 정책 함수를 둔다.
- 최근 7일 기준은 KST 대상일 포함 7일 00:00:00 이상, 대상일 다음날 00:00:00 미만이며, DB 조회에는 UTC half-open window를 사용한다.
- 홈 조회는 대상일 `snapshotAt` 스냅샷을 우선 조회한다. 최신 스냅샷이 대상일보다 과거이면 fallback refresh 대상으로 본다.
- fallback orchestration은 기존 `RecommendationSnapshotFallbackService``POPULAR_COMMUNITY` target을 추가하는 최소 변경을 우선한다.
- empty marker는 이번 범위에서 `POPULAR_COMMUNITY`에만 추가한다. 다른 섹션 공통화는 별도 후속 작업으로 둔다.
## 기존 POPULAR_COMMUNITY 로직 유지/변경 경계
- 유지: `RecommendedSectionType.POPULAR_COMMUNITY` enum 값과 code는 변경하지 않는다.
- 유지: 홈 인기 커뮤니티 응답 필드는 변경하지 않는다.
- 유지: 스냅샷 후보 저장 수 최대 20개, 홈 첫 화면 반환 수 최대 10개를 유지한다.
- 유지: 상세 조회 시점의 활성 게시글/크리에이터 필터, 성인 필터, 차단 필터, 크리에이터 중복 제거 정책을 유지한다.
- 유지: 댓글 불가 게시글은 댓글 수를 0으로 계산한다.
- 변경: 댓글 가중치를 `0.50`에서 `0.40`으로 바꾼다.
- 변경: 신규 부스트는 기존 크리에이터 공통 부스트 `1.5/1.3/1.2`가 아니라 `POPULAR_COMMUNITY` 전용 `1.15/1.10/1.05`를 사용한다.
- 변경: 좋아요/댓글 집계 기간은 UTC half-open 최근 7일 window를 사용한다.
- 변경: 게시글 자체 생성 시각 조건도 대상일 `windowEndExclusiveUtc` 이전으로 맞춘다.
- 추가: `POPULAR_COMMUNITY` 대상일 스냅샷이 없을 때 fallback refresh를 실행한다.
- 추가: `POPULAR_COMMUNITY` refresh 결과 0건이면 empty snapshot marker를 저장한다.
## 실행 명령
- 문서 명령 확인: `./gradlew tasks --all`
- 산식/부스트/window 단위 테스트: `./gradlew test --tests kr.co.vividnext.sodalive.v2.recommendation.domain.RecommendationScorePolicyTest --tests kr.co.vividnext.sodalive.v2.recommendation.domain.RecommendationSnapshotWindowPolicyTest`
- 스냅샷 저장 marker 테스트: `./gradlew test --tests kr.co.vividnext.sodalive.v2.recommendation.adapter.out.persistence.RecommendationSnapshotPersistenceAdapterTest`
- 인기 커뮤니티 query 통합 테스트: `./gradlew test --tests kr.co.vividnext.sodalive.v2.recommendation.adapter.out.persistence.DefaultHomeRecommendationQueryRepositoryTest`
- refresh/fallback 조회 테스트: `./gradlew test --tests kr.co.vividnext.sodalive.v2.recommendation.application.RecommendationSnapshotRefreshServiceTest --tests kr.co.vividnext.sodalive.v2.recommendation.application.RecommendationSnapshotFallbackServiceTest --tests kr.co.vividnext.sodalive.v2.recommendation.application.HomeRecommendationQueryServiceTest`
- 홈 API 회귀 테스트: `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.home.HomeRecommendationControllerTest --tests kr.co.vividnext.sodalive.v2.api.home.dto.recommendation.HomeRecommendationResponseTest`
- 포맷 검증: `./gradlew ktlintCheck`
- 전체 회귀: `./gradlew test`
---
### Phase 1: 문서와 기준 고정
- [x] **Task 1.1: PRD 기반 구현 계획 문서 작성**
- 파일 경로:
- Verify: `docs/20260710_메인_홈_추천_인기커뮤니티_스냅샷/prd.md`
- Create: `docs/20260710_메인_홈_추천_인기커뮤니티_스냅샷/plan-task.md`
- RED: 문서 작업은 TDD 예외. TDD 예외 사유: 코드 동작 변경 전 요구사항과 구현 순서를 고정하는 작업이다.
- GREEN: PRD의 산식, 최근 7일 KST/UTC half-open 범위, 공지/유료 제외, 대상일 `snapshotAt` 조회, empty marker, fallback timeout/lock 정책을 task로 분해한다.
- REFACTOR: 기존 홈 추천 구현 파일과 테스트 파일 기준으로 task별 수정/검증 경로를 맞춘다.
- 기대 결과: 구현 시작 전에 PRD와 plan-task가 같은 디렉터리에 준비된다.
---
### Phase 2: 산식, 부스트, window 정책
- [x] **Task 2.1: 인기 커뮤니티 점수 계산식 계약 테스트 보강**
- 파일 경로:
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/recommendation/domain/RecommendationScoreSpec.kt`
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/recommendation/domain/RecommendationScorePolicy.kt`
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/recommendation/domain/RecommendationScorePolicyTest.kt`
- RED: `COMMUNITY_LIKE_WEIGHT = 0.50`, `COMMUNITY_COMMENT_WEIGHT = 0.40`, `COMMUNITY_FOLLOWER_WEIGHT = 0.10`을 기대하는 실패 테스트를 작성한다. `calculateCommunityScore(...)`가 세 입력값과 부스트를 PRD 산식대로 계산하는지도 검증한다.
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.recommendation.domain.RecommendationScorePolicyTest`
- GREEN: 인기 커뮤니티 점수 상수를 PRD 값으로 변경하고 기존 `calculateCommunityScore(...)`가 같은 상수를 사용하게 한다.
- REFACTOR: 기존 AI/최근 데뷔/응원 크리에이터 상수와 함수 값은 변경하지 않았는지 같은 테스트 안에서 회귀 assertion을 유지한다.
- 계산식 테스트 케이스:
- `likeCount=0`, `commentCount=0`, `followerCount=0`, `newBoost=1.0`이면 `0.0`
- `likeCount=10`, `commentCount=0`, `followerCount=0`, `newBoost=1.0`이면 `5.0`
- `likeCount=0`, `commentCount=10`, `followerCount=0`, `newBoost=1.0`이면 `4.0`
- `likeCount=0`, `commentCount=0`, `followerCount=10`, `newBoost=1.0`이면 `1.0`
- `likeCount=40`, `commentCount=20`, `followerCount=100`, `newBoost=1.0`이면 `38.0`
- `likeCount=40`, `commentCount=20`, `followerCount=100`, `newBoost=1.15`이면 `43.7`
- 기대 결과: 인기 커뮤니티 점수 산식 근거가 Kotlin 단위 테스트로 고정된다.
- [x] **Task 2.2: 인기 커뮤니티 신규 부스트 경계값 테스트 추가**
- 파일 경로:
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/recommendation/domain/RecommendationScoreSpec.kt`
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/recommendation/domain/RecommendationScorePolicy.kt`
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/recommendation/domain/RecommendationScorePolicyTest.kt`
- RED: 인기 커뮤니티 전용 신규 부스트가 데뷔일 기준 0일/10일 `1.15`, 11일/20일 `1.10`, 21일/30일 `1.05`, 31일 `1.0`으로 계산되는 실패 테스트를 작성한다.
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.recommendation.domain.RecommendationScorePolicyTest`
- GREEN: `calculateCommunityCreatorNewBoost(debutAt, now)` 또는 동등한 전용 함수를 추가하고 `COMMUNITY_NEW_BOOST_*` 상수를 사용한다.
- REFACTOR: 기존 `calculateCreatorNewBoost(...)`는 최근 데뷔용 기존 값 `1.5/1.3/1.2`를 유지한다. `CHEER_CREATOR` 전용 부스트와 같은 값이더라도 이름은 섹션별 의도를 드러낸다.
- 기대 결과: `POPULAR_COMMUNITY`만 낮아진 신규 부스트 값을 사용하고 다른 크리에이터 기반 섹션은 기존 부스트를 유지한다.
- [x] **Task 2.3: 최근 7일 KST window 정책 추가**
- 파일 경로:
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/recommendation/domain/RecommendationSnapshotWindowPolicy.kt`
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/recommendation/domain/RecommendationSnapshotWindowPolicyTest.kt`
- RED: `nowUtc = 2026-07-10T03:00:00`이면 KST 기준 대상일이 `2026-07-09`이고, 최근 7일 window가 UTC `2026-07-02T15:00:00 <= t < 2026-07-09T15:00:00`, `snapshotAt = 2026-07-09T14:59:59`로 계산되는 실패 테스트를 작성한다.
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.recommendation.domain.RecommendationSnapshotWindowPolicyTest`
- GREEN: `previousKstSevenDayUtcWindow(nowUtc)` 또는 의미가 명확한 함수를 추가한다.
- REFACTOR: 기존 `previousKstDayUtcWindow(...)`는 AI 캐릭터/응원 크리에이터 전날 하루 집계에서 계속 사용하므로 변경하지 않는다.
- 기대 결과: 인기 커뮤니티만 최근 7일 window를 사용하고, 대상일 `snapshotAt` 계산은 다른 섹션과 일관된다.
---
### Phase 3: DB 스냅샷 query와 상세 조회
- [x] **Task 3.1: `POPULAR_COMMUNITY` refresh 입력을 최근 7일 UTC half-open window로 변경**
- 파일 경로:
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/recommendation/application/RecommendationSnapshotRefreshService.kt`
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/recommendation/port/out/HomeRecommendationQueryPort.kt`
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/recommendation/adapter/out/persistence/DefaultHomeRecommendationQueryRepository.kt`
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/recommendation/application/RecommendationSnapshotRefreshServiceTest.kt`
- RED: `refreshPopularCommunitySnapshots(nowUtc)`가 최근 7일 window의 `windowStartUtc`, `windowEndExclusiveUtc`, `snapshotAt`을 사용해 `queryPort.findPopularCommunitySnapshots(...)``snapshotPort.replaceSnapshots(...)`를 호출하는 실패 테스트를 작성한다.
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.recommendation.application.RecommendationSnapshotRefreshServiceTest`
- GREEN: `POPULAR_COMMUNITY` 단일 refresh 경로를 분리하고, 기존 일괄 refresh에서 같은 경로를 호출하게 한다.
- REFACTOR: `HomeRecommendationQueryPort.findPopularCommunitySnapshots(...)` 시그니처는 `windowEndExclusiveUtc` 의미가 드러나도록 정리한다.
- 기대 결과: 스케줄러와 fallback이 같은 `POPULAR_COMMUNITY` 최근 7일 refresh 경로를 호출할 수 있다.
- [x] **Task 3.2: 좋아요/댓글/팔로워 집계 기준 변경**
- 파일 경로:
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/recommendation/adapter/out/persistence/DefaultHomeRecommendationQueryRepository.kt`
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/recommendation/adapter/out/persistence/DefaultHomeRecommendationQueryRepositoryTest.kt`
- RED: 활성 좋아요/댓글만 최근 7일 half-open window 안에서 distinct id로 집계하고, `creatorFollowerCount`는 기간 조건 없이 스냅샷 생성 시점의 활성 팔로워 총수로 집계하는 실패 테스트를 작성한다.
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.recommendation.adapter.out.persistence.DefaultHomeRecommendationQueryRepositoryTest`
- GREEN: `like_stats`, `comment_stats`, `follower_stats` native query를 PRD 기준으로 수정한다. 좋아요/댓글은 `created_at >= :windowStartUtc and created_at < :windowEndExclusiveUtc`, 팔로워는 `is_active = true` 총수로 계산한다.
- REFACTOR: 댓글 불가 게시글은 기존처럼 `commentCount = 0`으로 계산하는 조건을 유지한다.
- 기대 결과: 세 metric의 기간/활성 조건이 서로 다른 의도대로 고정된다.
- [x] **Task 3.3: 공지/고정 및 유료 게시글 제외 조건 고정**
- 파일 경로:
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/recommendation/adapter/out/persistence/DefaultHomeRecommendationQueryRepository.kt`
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/recommendation/adapter/out/persistence/DefaultHomeRecommendationQueryRepositoryTest.kt`
- RED: `is_fixed = true` 게시글, `price > 0` 게시글, 비활성 게시글, 비활성 크리에이터가 스냅샷 후보에서 제외되는 실패 테스트를 작성한다.
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.recommendation.adapter.out.persistence.DefaultHomeRecommendationQueryRepositoryTest`
- GREEN: 스냅샷 query의 후보 조건을 `cc.is_active = true`, `m.is_active = true`, `cc.is_fixed = false`, `cc.price <= 0` 기준으로 맞춘다.
- REFACTOR: 기존 `cc.price = 0` 조건이 도메인상 무료 판정과 다르면 `price <= 0`로 통일한다.
- 기대 결과: 공지/유료 게시글이 추천 스냅샷에 저장되지 않는다.
- [x] **Task 3.4: 인기 커뮤니티 DB-side 점수와 부스트 적용**
- 파일 경로:
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/recommendation/adapter/out/persistence/DefaultHomeRecommendationQueryRepository.kt`
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/recommendation/adapter/out/persistence/DefaultHomeRecommendationQueryRepositoryTest.kt`
- RED: DB 스냅샷 query 결과 점수가 `RecommendationScorePolicy.calculateCommunityScore(...)`와 동일하고, 데뷔일 기준 신규 부스트 `1.15/1.10/1.05/1.0` 경계값을 반영하는 실패 테스트를 작성한다.
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.recommendation.adapter.out.persistence.DefaultHomeRecommendationQueryRepositoryTest`
- GREEN: native SQL score expression과 boost case expression을 `RecommendationScoreSpec`의 인기 커뮤니티 전용 상수로 변경한다.
- REFACTOR: `creator_debut` 계산은 콘텐츠 첫 공개일과 라이브 첫 진행일 중 빠른 날짜라는 기존 정의를 유지한다.
- 기대 결과: DB-side exact scoring과 Kotlin 정책 테스트의 산식 값이 일치한다.
- [x] **Task 3.5: 후보 제외, 게시글 생성 시각, 정렬, limit 회귀 테스트 보강**
- 파일 경로:
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/recommendation/adapter/out/persistence/DefaultHomeRecommendationQueryRepository.kt`
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/recommendation/adapter/out/persistence/DefaultHomeRecommendationQueryRepositoryTest.kt`
- RED: 좋아요/댓글/팔로워 수가 모두 0인 게시글 제외, 데뷔일이 없는 크리에이터 제외, `created_at >= windowEndExclusiveUtc` 게시글 제외, 점수 내림차순/`randomTieBreaker` 오름차순/limit 20 동작을 검증하는 실패 테스트를 작성한다.
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.recommendation.adapter.out.persistence.DefaultHomeRecommendationQueryRepositoryTest`
- GREEN: 기존 후보 제외 조건과 정렬/limit을 PRD 기준에 맞게 유지 또는 보정한다.
- REFACTOR: AI 캐릭터와 응원 크리에이터 스냅샷 query가 영향받지 않았는지 focused test로 확인한다.
- 기대 결과: `POPULAR_COMMUNITY` 스냅샷 저장 후보만 정확히 변경된다.
- [x] **Task 3.6: 인기 커뮤니티 상세 조회에도 공지/유료 제외 적용**
- 파일 경로:
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/recommendation/adapter/out/persistence/DefaultHomeRecommendationQueryRepository.kt`
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/recommendation/adapter/out/persistence/DefaultHomeRecommendationQueryRepositoryTest.kt`
- RED: 스냅샷 저장 이후 게시글이 `isFixed = true` 또는 `price > 0`로 변경되면 `findPopularCommunityRecommendationDetails(...)` 결과에서 제외되는 실패 테스트를 작성한다.
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.recommendation.adapter.out.persistence.DefaultHomeRecommendationQueryRepositoryTest`
- GREEN: 상세 조회 where 조건에 `creatorCommunity.isFixed.isFalse`, 무료 조건을 명시한다.
- REFACTOR: 성인 필터, 차단 필터, 좋아요 여부, 구매 여부 계산은 기존 동작을 유지한다.
- 기대 결과: 스냅샷 생성 이후 상태가 바뀐 공지/유료 게시글도 홈 응답에 노출되지 않는다.
---
### Phase 4: refresh 저장과 empty marker
- [x] **Task 4.1: `POPULAR_COMMUNITY` 빈 결과 marker 저장 정책 추가**
- 파일 경로:
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/recommendation/adapter/out/persistence/RecommendationSnapshotPersistenceAdapter.kt`
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/recommendation/adapter/out/persistence/RecommendationSnapshotPersistenceAdapterTest.kt`
- RED: `replaceSnapshots(RecommendedSectionType.POPULAR_COMMUNITY, snapshotAt, emptyList())` 호출 시 `targetId = 0` marker가 저장되고, `findSnapshots(POPULAR_COMMUNITY, snapshotAt)`는 빈 배열이며, `existsSnapshot(POPULAR_COMMUNITY, snapshotAt)`는 true인 실패 테스트를 작성한다.
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.recommendation.adapter.out.persistence.RecommendationSnapshotPersistenceAdapterTest`
- GREEN: empty marker 적용 대상에 `POPULAR_COMMUNITY`를 추가한다.
- REFACTOR: marker 대상 조건은 `supportsEmptySnapshotMarker(...)` 같은 명시적 함수에 유지하고, 조회 쿼리의 `target_id <> 0` 조건은 변경하지 않는다.
- 기대 결과: 데이터가 없는 날에도 `POPULAR_COMMUNITY` refresh 완료 상태가 저장된다.
- [x] **Task 4.2: 실제 row 재실행 시 marker 대체 보장**
- 파일 경로:
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/recommendation/adapter/out/persistence/RecommendationSnapshotPersistenceAdapter.kt`
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/recommendation/adapter/out/persistence/RecommendationSnapshotPersistenceAdapterTest.kt`
- RED: 같은 `sectionType`, `snapshotAt`에 marker가 있는 상태에서 실제 스냅샷 row로 `replaceSnapshots(...)`를 호출하면 marker가 제거되고 실제 row만 남는 실패 테스트를 작성한다.
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.recommendation.adapter.out.persistence.RecommendationSnapshotPersistenceAdapterTest`
- GREEN: 기존 `deleteBySectionTypeAndSnapshotAt` 후 저장 흐름이 marker 대체를 보장하는지 확인하고, 부족하면 해당 경로만 보정한다.
- REFACTOR: `AI_CHARACTER`, `CHEER_CREATOR` marker 동작 회귀 테스트도 함께 유지한다.
- 기대 결과: 빈 결과 refresh 후 재실행으로 실제 추천 row가 생겨도 marker가 응답/존재 상태를 오염시키지 않는다.
- [x] **Task 4.3: refresh service가 `POPULAR_COMMUNITY` 저장 수와 marker 상태를 로그로 남김**
- 파일 경로:
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/recommendation/application/RecommendationSnapshotRefreshService.kt`
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/recommendation/application/RecommendationSnapshotRefreshServiceTest.kt`
- RED: `refreshPopularCommunitySnapshots(nowUtc)` 성공 시 `event=popular_community_recommendation_snapshot_refresh_success`, `savedCount`, `windowStartUtc`, `windowEndExclusiveUtc`, `snapshotAt`이 로그에 남는 실패 테스트를 작성한다.
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.recommendation.application.RecommendationSnapshotRefreshServiceTest`
- GREEN: 기존 구조화 로그 관례에 맞춰 section refresh 성공/실패 로그를 추가한다.
- REFACTOR: 전체 일괄 refresh 성공 로그는 유지하되, 섹션별 로그와 중복되어도 검색 가능한 event key를 사용한다.
- 기대 결과: 운영에서 `POPULAR_COMMUNITY` refresh 결과 0건과 실패를 구분할 수 있다.
---
### Phase 5: 대상일 조회와 fallback refresh
- [x] **Task 5.1: 홈 인기 커뮤니티 조회를 대상일 `snapshotAt` 기준으로 변경**
- 파일 경로:
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/recommendation/application/HomeRecommendationQueryService.kt`
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/recommendation/application/HomeRecommendationQueryServiceTest.kt`
- RED: 과거 최신 `POPULAR_COMMUNITY` 스냅샷이 있어도 홈 API 호출 기준 대상일 `snapshotAt` 스냅샷이 없으면 과거 스냅샷을 반환하지 않고 fallback을 호출하는 실패 테스트를 작성한다.
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.recommendation.application.HomeRecommendationQueryServiceTest`
- GREEN: `RecommendationSnapshotWindowPolicy`로 대상일 `snapshotAt`을 계산하고 `snapshotPort.findSnapshots(POPULAR_COMMUNITY, snapshotAt, ...)`를 사용한다.
- REFACTOR: 스냅샷 후보 20개 조회, 상세 조회 후 최대 10개 반환, 크리에이터 중복 제거, 차단/성인 필터 전달은 기존 동작을 유지한다.
- 기대 결과: 인기 커뮤니티 홈 조회가 일 단위 최신성을 강제한다.
- [x] **Task 5.2: `POPULAR_COMMUNITY` fallback target 추가**
- 파일 경로:
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/recommendation/application/RecommendationSnapshotFallbackService.kt`
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/recommendation/application/RecommendationSnapshotRefreshService.kt`
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/recommendation/application/RecommendationSnapshotFallbackServiceTest.kt`
- RED: 대상일 `POPULAR_COMMUNITY` 스냅샷이 없을 때 lock key `lock:recommendation-snapshot-refresh:POPULAR_COMMUNITY`로 lock을 얻고, `refreshPopularCommunitySnapshots(nowUtc)`를 호출한 뒤 대상일 스냅샷을 재조회하는 실패 테스트를 작성한다.
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.recommendation.application.RecommendationSnapshotFallbackServiceTest`
- GREEN: `POPULAR_COMMUNITY` fallback target을 등록하고 lock 대기 300ms, 홈 대기 1,500ms 상수를 PRD 값으로 유지한다.
- REFACTOR: `AI_CHARACTER`, `CHEER_CREATOR` fallback lock key와 상호 간섭하지 않도록 section별 key를 분리한다.
- 기대 결과: 스냅샷 없음 상황에서 홈 API가 스케줄러와 동일한 `POPULAR_COMMUNITY` refresh 로직을 재사용한다.
- [x] **Task 5.3: fallback double-check를 대상일 `snapshotAt` 기준으로 검증**
- 파일 경로:
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/recommendation/application/RecommendationSnapshotFallbackService.kt`
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/recommendation/application/RecommendationSnapshotFallbackServiceTest.kt`
- RED: lock 획득 후 대상일 `snapshotAt` 스냅샷이 이미 존재하면 refresh를 실행하지 않고 해당 스냅샷을 반환하는 실패 테스트를 작성한다. 과거 최신 스냅샷만 있는 경우에는 refresh를 실행해야 한다.
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.recommendation.application.RecommendationSnapshotFallbackServiceTest`
- GREEN: fallback service의 조회/존재 확인을 `findSnapshots(sectionType, snapshotAt, ...)`, `existsSnapshot(sectionType, snapshotAt)` 기준으로 수행한다.
- REFACTOR: AI 캐릭터/응원 크리에이터는 기존 요구사항과 충돌하지 않도록 필요한 경우 대상일 snapshot 조회 방식으로 함께 정리하되, 산식/window는 변경하지 않는다.
- 기대 결과: fallback double-check가 일 단위 최신성 요구와 일치한다.
- [x] **Task 5.4: lock miss, timeout, 실패, empty marker fallback 케이스 검증**
- 파일 경로:
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/recommendation/application/RecommendationSnapshotFallbackService.kt`
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/recommendation/application/RecommendationSnapshotFallbackServiceTest.kt`
- RED: lock 획득 실패 시 중복 refresh를 시작하지 않는 테스트, 홈 대기 1,500ms timeout 시 빈 배열을 반환하되 background refresh를 cancel하지 않는 테스트, refresh 실패 시 빈 배열과 warn log를 남기는 테스트, marker가 있으면 fallback을 반복하지 않는 테스트를 작성한다.
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.recommendation.application.RecommendationSnapshotFallbackServiceTest`
- GREEN: 공통 runner의 future/single-flight 상태, 예외 처리, timeout 처리, existsSnapshot double-check 흐름을 구현한다.
- REFACTOR: fallback service는 점수 계산이나 상세 DTO 조립을 직접 하지 않고 snapshot 조회와 refresh orchestration만 담당한다.
- 기대 결과: 홈 조회 경로에서 refresh 중복, 장시간 대기, 전체 API 실패가 발생하지 않는다.
- [x] **Task 5.5: 스케줄러와 fallback refresh lock key 공유**
- 파일 경로:
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/recommendation/application/RecommendationSnapshotRefreshService.kt`
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/recommendation/application/RecommendationSnapshotRefreshServiceTest.kt`
- RED: 일괄 scheduler refresh가 `POPULAR_COMMUNITY` 갱신 시 `lock:recommendation-snapshot-refresh:POPULAR_COMMUNITY`를 사용하고, lock miss 시 중복 refresh를 실행하지 않는 실패 테스트를 작성한다.
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.recommendation.application.RecommendationSnapshotRefreshServiceTest`
- GREEN: 기존 AI 캐릭터/응원 크리에이터 section lock 패턴에 `POPULAR_COMMUNITY`를 추가한다.
- REFACTOR: 세 섹션 lock 처리 중복이 커지면 section type과 lock key를 받는 최소 helper로 정리한다.
- 기대 결과: 스케줄러와 홈 fallback이 같은 섹션 lock으로 중복 refresh를 막는다.
---
### Phase 6: API 회귀와 최종 검증
- [x] **Task 6.1: 홈 API 응답 스키마 회귀 검증**
- 파일 경로:
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/home/HomeRecommendationControllerTest.kt`
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/home/dto/recommendation/HomeRecommendationResponseTest.kt`
- RED: 홈 통합 조회의 `popularCommunityPosts` 응답이 기존 필드만 유지하고 신규 필드를 추가하지 않는 회귀 테스트를 확인/보강한다.
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.home.HomeRecommendationControllerTest --tests kr.co.vividnext.sodalive.v2.api.home.dto.recommendation.HomeRecommendationResponseTest`
- GREEN: DTO/Controller 변경 없이 application service 결과가 기존 response로 매핑되게 한다.
- REFACTOR: 공개 API URL과 JSON field name 변경이 없음을 assertion으로 유지한다.
- 기대 결과: 클라이언트 공개 스키마는 변경되지 않는다.
- [x] **Task 6.2: focused regression 실행**
- 파일 경로:
- Modify: `docs/20260710_메인_홈_추천_인기커뮤니티_스냅샷/plan-task.md`
- RED: 구현 task 완료 후 계획 문서에 기록할 focused command 목록을 확정한다.
- 실패 확인: 해당 없음. TDD 예외 사유: 검증 기록 문서화 task다.
- GREEN: 아래 명령을 실행하고 결과를 이 문서 하단 검증 기록에 누적한다.
- REFACTOR: 실패한 명령이 있으면 원인과 재실행 결과를 같은 task 아래에 기록한다.
- 실행 명령:
- `./gradlew test --tests kr.co.vividnext.sodalive.v2.recommendation.domain.RecommendationScorePolicyTest --tests kr.co.vividnext.sodalive.v2.recommendation.domain.RecommendationSnapshotWindowPolicyTest`
- `./gradlew test --tests kr.co.vividnext.sodalive.v2.recommendation.adapter.out.persistence.RecommendationSnapshotPersistenceAdapterTest`
- `./gradlew test --tests kr.co.vividnext.sodalive.v2.recommendation.adapter.out.persistence.DefaultHomeRecommendationQueryRepositoryTest`
- `./gradlew test --tests kr.co.vividnext.sodalive.v2.recommendation.application.RecommendationSnapshotRefreshServiceTest --tests kr.co.vividnext.sodalive.v2.recommendation.application.RecommendationSnapshotFallbackServiceTest --tests kr.co.vividnext.sodalive.v2.recommendation.application.HomeRecommendationQueryServiceTest`
- `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.home.HomeRecommendationControllerTest --tests kr.co.vividnext.sodalive.v2.api.home.dto.recommendation.HomeRecommendationResponseTest`
- 기대 결과: 산식/window/집계/marker/fallback/API 회귀가 최소 명령으로 검증된다.
- [x] **Task 6.3: 전체 회귀와 문서 검증**
- 파일 경로:
- Modify: `docs/20260710_메인_홈_추천_인기커뮤니티_스냅샷/plan-task.md`
- RED: 구현 완료 후 전체 회귀 명령 실행 전에는 검증 기록이 구현 전 상태여야 한다.
- 실패 확인: 해당 없음. TDD 예외 사유: 검증 기록 문서화 task다.
- GREEN: `./gradlew ktlintCheck`, `./gradlew test`, `./gradlew tasks --all`을 실행하고 결과를 문서 하단 검증 기록에 누적한다.
- REFACTOR: PRD와 plan-task가 구현 결과와 어긋나면 먼저 문서를 갱신하고 필요한 focused test를 재실행한다.
- 기대 결과: 포맷, 전체 테스트, 문서 명령 유효성을 모두 확인한다.
---
## 검증 기록
- 2026-07-10: 구현 계획 문서 작성. 코드 변경은 아직 수행하지 않았다.
- 2026-07-10: focused regression 통과.
- `./gradlew test --tests kr.co.vividnext.sodalive.v2.recommendation.domain.RecommendationScorePolicyTest --tests kr.co.vividnext.sodalive.v2.recommendation.domain.RecommendationSnapshotWindowPolicyTest` 성공.
- `./gradlew test --tests kr.co.vividnext.sodalive.v2.recommendation.adapter.out.persistence.RecommendationSnapshotPersistenceAdapterTest` 성공.
- `./gradlew test --tests kr.co.vividnext.sodalive.v2.recommendation.adapter.out.persistence.DefaultHomeRecommendationQueryRepositoryTest` 성공.
- `./gradlew test --tests kr.co.vividnext.sodalive.v2.recommendation.application.RecommendationSnapshotRefreshServiceTest --tests kr.co.vividnext.sodalive.v2.recommendation.application.RecommendationSnapshotFallbackServiceTest --tests kr.co.vividnext.sodalive.v2.recommendation.application.HomeRecommendationQueryServiceTest` 성공.
- `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.home.HomeRecommendationControllerTest --tests kr.co.vividnext.sodalive.v2.api.home.dto.recommendation.HomeRecommendationResponseTest` 성공.
- 2026-07-10: 전체 회귀와 문서 명령 검증 통과.
- `./gradlew ktlintCheck` 성공.
- `./gradlew test` 성공.
- `./gradlew tasks --all` 성공.
- 2026-07-10: 리뷰 게이트 후 차단 이슈 수정 및 재검증 통과.
- 리뷰 지적: `POPULAR_COMMUNITY` 0점 후보 제외 누락, 최근 데뷔 크리에이터 query에 커뮤니티 전용 부스트 상수 침투.
- 수정: 인기 커뮤니티 후보에 좋아요/댓글/팔로워 중 하나 이상 존재 조건 추가, 최근 데뷔 크리에이터는 기존 `NEW_BOOST_*` 상수로 복원.
- 회귀 테스트 추가: 0점 인기 커뮤니티 후보 제외, 최근 데뷔 크리에이터 기존 신규 부스트 유지.
- `./gradlew ktlintCheck` 성공.
- `./gradlew test --tests kr.co.vividnext.sodalive.v2.recommendation.adapter.out.persistence.DefaultHomeRecommendationQueryRepositoryTest` 성공.
- `./gradlew test --tests kr.co.vividnext.sodalive.v2.recommendation.domain.RecommendationScorePolicyTest --tests kr.co.vividnext.sodalive.v2.recommendation.domain.RecommendationSnapshotWindowPolicyTest --tests kr.co.vividnext.sodalive.v2.recommendation.adapter.out.persistence.RecommendationSnapshotPersistenceAdapterTest --tests kr.co.vividnext.sodalive.v2.recommendation.adapter.out.persistence.DefaultHomeRecommendationQueryRepositoryTest --tests kr.co.vividnext.sodalive.v2.recommendation.application.RecommendationSnapshotRefreshServiceTest --tests kr.co.vividnext.sodalive.v2.recommendation.application.RecommendationSnapshotFallbackServiceTest --tests kr.co.vividnext.sodalive.v2.recommendation.application.HomeRecommendationQueryServiceTest` 성공.
- `./gradlew cleanTest test` 성공.
- 2026-07-10: `POPULAR_COMMUNITY` snapshot 후보 paid/fixed 제외 직접 통합 테스트 보강.
- 추가: `shouldExcludePaidAndFixedPostsFromPopularCommunitySnapshots`에서 점수가 있는 유료/고정 게시글도 스냅샷 후보에서 제외됨을 검증.
- `./gradlew test --tests kr.co.vividnext.sodalive.v2.recommendation.adapter.out.persistence.DefaultHomeRecommendationQueryRepositoryTest` 성공.
- `./gradlew ktlintCheck` 성공.

View File

@@ -0,0 +1,254 @@
# PRD: 메인 홈 추천 인기 커뮤니티 스냅샷 수정
## 1. Overview
메인 홈 추천 탭의 `POPULAR_COMMUNITY` 스냅샷 생성과 조회를 최근 7일 데이터 기반 인기 점수로 수정하고, 스냅샷이 없을 때 홈 API가 동일 refresh 로직을 안전하게 재사용하도록 fallback 흐름을 보강한다.
---
## 2. Problem
- 기존 `POPULAR_COMMUNITY` 산식은 댓글 가중치와 신규 부스트 값이 이번 요구사항과 다르다.
- 기존 구현은 공지/고정 게시글과 유료 게시글 제외 조건을 일부 갖고 있으나, 요구사항의 제외 조건을 테스트로 명확히 고정해야 한다.
- 현재 `POPULAR_COMMUNITY` 조회는 대상일 `snapshotAt` 스냅샷이 없으면 별도 fallback 없이 빈 결과가 될 수 있다.
- 스케줄러 refresh와 홈 API fallback refresh가 같은 생성 로직을 공유하지 않으면 산식 drift가 발생할 수 있다.
- 집계 산식이 추천 노출 순서를 직접 바꾸므로 DB-side 계산과 Kotlin-side 계산 중 어떤 방식을 선택하더라도 산식/부스트 경계값 테스트가 필요하다.
---
## 3. Goals
- `POPULAR_COMMUNITY` 스냅샷은 최근 7일 데이터를 기반으로 생성한다.
- 인기 커뮤니티 점수 산식을 `((좋아요 수 * 0.50) + (댓글 수 * 0.40) + (크리에이터 팔로우 수 * 0.10)) * 신규 부스트`로 변경한다.
- 신규 부스트는 크리에이터 데뷔일 기준 10일 이내 1.15, 20일 이내 1.10, 30일 이내 1.05, 그 외 1.0을 적용한다.
- 공지/고정 게시글과 유료 게시글은 스냅샷 후보와 조회 결과에서 제외한다.
- 스케줄러 refresh와 fallback refresh는 동일한 `POPULAR_COMMUNITY` refresh 로직을 재사용한다.
- 대상일 `snapshotAt` 스냅샷이 없을 때 fallback refresh는 lock, double-check, single-flight 대기로 중복 refresh를 방지한다.
- 홈 API는 fallback refresh 완료를 최대 1,500ms까지만 기다리고, lock 대기는 최대 300ms로 제한한다.
- fallback refresh 실패, timeout, refresh 결과 없음은 홈 API 전체 실패로 전파하지 않고 `popularCommunityPosts` 빈 배열로 처리한다.
- 산식과 신규 부스트는 단위 테스트에서 경계값과 가중치 계산을 촘촘히 검증한다.
---
## 4. Non-Goals
- 메인 홈 추천 API URL, 응답 필드, 응답 JSON 스키마는 변경하지 않는다.
- `POPULAR_COMMUNITY` 이외 추천 섹션의 산식과 조회 정책은 변경하지 않는다.
- 커뮤니티 게시글 작성/수정/삭제, 좋아요, 댓글, 구매 동작 자체는 변경하지 않는다.
- 관리자 화면, 수동 추천 편집, A/B 테스트, 개인화 추천은 이번 범위에 포함하지 않는다.
- 신규 추천 스냅샷 테이블을 만들지 않고, 기존 `recommendation_snapshot` 구조를 우선 재사용한다.
---
## 5. Target Users
- 회원/비회원: 메인 홈 추천 탭에서 최근 반응이 좋은 무료 커뮤니티 게시글을 발견하는 사용자
- 앱 클라이언트: 기존 응답 계약을 유지한 채 `POPULAR_COMMUNITY` 추천 순서만 변경된 결과를 받는 클라이언트
- 운영자: 최근 7일 커뮤니티 반응이 추천 노출에 반영되는지 확인해야 하는 운영 담당자
---
## 6. User Stories
- 사용자는 메인 홈 추천 탭에서 최근 좋아요와 댓글 반응이 많은 커뮤니티 게시글을 우선 보고 싶다.
- 사용자는 유료 게시글이나 공지 게시글이 인기 추천 영역에 섞이지 않기를 기대한다.
- 사용자는 신규 크리에이터의 커뮤니티 게시글도 일정 기간 동안 적절한 노출 기회를 받기를 기대한다.
- 앱 클라이언트는 스냅샷이 없는 상황에서도 홈 API가 실패하지 않고 안정적으로 빈 배열 또는 생성된 스냅샷을 받기를 원한다.
- 운영자는 배치 실패 후 첫 홈 조회가 스케줄러와 동일한 로직으로 스냅샷을 복구하기를 원한다.
---
## 7. Core Features
### Feature A. 인기 커뮤니티 스냅샷 산식 변경
#### Requirements
- `POPULAR_COMMUNITY` 점수는 아래 산식으로 계산한다.
- `score = ((likeCount * 0.50) + (commentCount * 0.40) + (creatorFollowerCount * 0.10)) * newBoost`
- `likeCount`는 집계 기간 안에 생성된 활성 좋아요 수다.
- 좋아요 수는 `creator_community_like.is_active = true`인 row를 기준으로 `distinct id` 집계한다.
- `commentCount`는 집계 기간 안에 생성된 활성 댓글 수다.
- 댓글 수는 `creator_community_comment.is_active = true`인 row를 기준으로 `distinct id` 집계한다.
- 댓글 불가 게시글은 댓글 row가 있어도 산식의 `commentCount`를 0으로 계산한다.
- `creatorFollowerCount`는 스냅샷 생성 시점 기준 크리에이터의 활성 팔로워 총수다.
- `creatorFollowerCount`는 최근 7일 신규 팔로우 수가 아니며, 스냅샷 생성 시점까지 활성 상태인 전체 팔로워 수를 의미한다.
- 팔로워 수는 `creator_following.creator_id = creator_community.member_id`이고 `is_active = true`인 row를 기준으로 `distinct id` 집계한다.
- 점수 산식 상수는 `RecommendationScoreSpec` 등 기존 점수 정책 위치에 모아 DB expression과 Kotlin 정책 테스트가 같은 값을 참조하도록 한다.
- 스냅샷 정렬은 점수 내림차순, 동점이면 스냅샷 생성 시 저장한 `randomTieBreaker` 오름차순을 유지한다.
- 최종 저장 수는 기존 홈 노출 안정성을 위해 `POPULAR_COMMUNITY` 최대 20개를 유지한다.
#### Edge Cases
- `likeCount`, `commentCount`, `creatorFollowerCount`가 모두 0인 게시글은 스냅샷 후보에서 제외한다.
- 좋아요는 없지만 댓글 또는 팔로워 수가 있으면 산식에 따라 점수를 계산한다.
- 댓글은 없지만 좋아요 또는 팔로워 수가 있으면 산식에 따라 점수를 계산한다.
- 비활성 좋아요, 비활성 댓글, 비활성 게시글, 비활성 크리에이터는 집계에서 제외한다.
- 스냅샷에는 존재하지만 조회 시점에 게시글 또는 크리에이터가 비활성화된 경우 응답에서 제외한다.
### Feature B. 최근 7일 데이터 기반 집계 기간
#### Requirements
- 좋아요와 댓글 입력값은 최근 7일 데이터만 사용한다.
- 최근 7일 기준은 KST 기준 스냅샷 대상일을 포함한 7일의 00:00:00 이상, 대상일 다음날 00:00:00 미만의 half-open 범위로 정의한다.
- 예를 들어 스케줄러가 2026-07-10 KST에 2026-07-09 대상 스냅샷을 만들면, 집계 기간은 2026-07-03 00:00:00 KST 이상 2026-07-10 00:00:00 KST 미만이다.
- 운영 DB 시간이 UTC 기준이면, 실제 조회는 `windowStartUtc <= createdAt < windowEndExclusiveUtc`로 변환해 사용한다.
- `snapshotAt`은 해당 KST 대상일의 종료 시각을 UTC로 변환한 `windowEndExclusiveUtc.minusSeconds(1)`을 사용한다.
- 기존 코드에 남아 있는 `created_at <= :snapshotAt` 방식은 이번 범위에서 half-open end-exclusive 조건으로 맞춘다.
#### Edge Cases
- 집계 기간에 좋아요/댓글 데이터가 없어도 팔로워 수만으로 추천 후보가 될 수 있다.
- 집계 기간과 무관하게 게시글 자체는 스냅샷 대상 시점 이전에 생성된 활성 무료/비공지 게시글이어야 한다.
- 같은 `sectionType`, `snapshotAt`에 대해 재실행하면 기존 스냅샷을 대체하는 정책을 유지한다.
### Feature C. 공지/유료 게시글 제외
#### Requirements
- 공지 게시글은 스냅샷 후보에서 제외한다.
- 요구사항의 `is_pin == true`는 현재 코드의 `CreatorCommunity.isFixed` 및 DB 컬럼 `is_fixed = true`와 동일한 의미로 해석한다.
- 유료 게시글은 스냅샷 후보에서 제외한다.
- 유료 게시글 제외 조건은 `creator_community.price > 0`이며, 추천 후보는 `price <= 0` 또는 현재 도메인의 무료 판정과 일치해야 한다.
- 상세 조회 시점에도 공지/고정 게시글과 유료 게시글을 다시 제외해 스냅샷 생성 이후 상태 변경이 응답에 노출되지 않도록 한다.
#### Edge Cases
- 스냅샷 생성 당시 무료였지만 조회 시점에 유료로 변경된 게시글은 응답에서 제외한다.
- 스냅샷 생성 당시 비공지였지만 조회 시점에 공지/고정으로 변경된 게시글은 응답에서 제외한다.
- 스냅샷 생성 당시 활성 상태였지만 조회 시점에 비활성화된 게시글은 응답에서 제외한다.
### Feature D. 신규 부스트 변경
#### Requirements
- 신규 부스트 기준일은 크리에이터 데뷔일이다.
- 크리에이터 데뷔일은 기존 홈 추천 PRD와 동일하게 콘텐츠를 처음 공개한 날과 라이브를 한 날 중 빠른 날짜로 계산한다.
- 신규 부스트 구간은 다음과 같다.
- 데뷔 후 10일 이내: `1.15`
- 데뷔 후 20일 이내: `1.10`
- 데뷔 후 30일 이내: `1.05`
- 그 외: `1.0`
- 경계일 계산은 기존 추천 점수 정책의 날짜 단위 계산과 일관되게 한다.
- 부스트 기준 시각은 스냅샷의 `snapshotAt`이다.
#### Edge Cases
- 공개 콘텐츠와 라이브 이력이 모두 없어 데뷔일을 계산할 수 없는 크리에이터는 스냅샷 후보에서 제외한다.
- 데뷔일이 `snapshotAt`보다 미래인 데이터는 데이터 오류로 보고 스냅샷 후보에서 제외한다.
- 10일/20일/30일 경계값은 테스트에서 명확히 검증한다.
### Feature E. `POPULAR_COMMUNITY` 스냅샷 조회
#### Requirements
- 홈 통합 조회 `GET /api/v2/home/recommendations`의 인기 커뮤니티 섹션은 대상일 `snapshotAt``POPULAR_COMMUNITY` 스냅샷 순서를 사용한다.
- 대상일 `snapshotAt`은 홈 API 호출 시각 기준 KST 전날의 종료 시각을 UTC로 변환한 값이다.
- 일 단위 최신성을 강제하므로, 과거 최신 스냅샷이 존재하더라도 대상일 `snapshotAt` 스냅샷이 없으면 fallback refresh를 시도한다.
- 기존 노출 정보는 유지한다.
- `communityId`
- `creatorId`
- 크리에이터 닉네임
- 크리에이터 프로필 이미지
- 이미지 경로
- 오디오 경로
- 본문
- 가격
- 생성 시각
- 전체 좋아요 수
- 전체 댓글 수
- 구매 여부
- 좋아요 여부
- 조회 시점에도 기존 차단 필터, 성인 필터, 활성 게시글/크리에이터 필터를 적용한다.
- 스냅샷 후보는 최대 20개까지 조회하고, 상세 조회/필터링/크리에이터 중복 제거 후 홈 첫 화면에는 최대 10개를 반환한다.
- 동일 크리에이터의 게시글이 여러 개 스냅샷에 포함된 경우 기존 정책처럼 점수가 가장 높은 1개만 홈 응답에 노출한다.
#### Edge Cases
- 대상일 `snapshotAt` 스냅샷 row가 존재하지만 조회 필터로 모두 제외되면 빈 배열을 반환한다.
- 상세 조회 결과가 스냅샷 저장 개수보다 적어도 홈 조회 전체는 성공 처리한다.
- 스냅샷 정렬 순서와 응답 순서는 일치해야 한다.
### Feature F. 스냅샷 없음 fallback refresh
#### Requirements
- 대상일 `snapshotAt``POPULAR_COMMUNITY` 스냅샷이 없으면 fallback refresh를 시도한다.
- fallback refresh는 홈 API에서 직접 계산 결과를 응답하지 않고, 스케줄러와 동일한 refresh 로직으로 `recommendation_snapshot`에 저장한 뒤 저장된 스냅샷을 다시 조회한다.
- fallback refresh 흐름은 다음 순서를 따른다.
- 홈 API 호출 시각 기준 대상일 `snapshotAt` 계산
- 대상일 `snapshotAt``POPULAR_COMMUNITY` 스냅샷 조회
- 대상일 스냅샷이 없으면 fallback refresh 진입
- refresh 전 섹션 전용 lock 획득 시도
- lock 안에서 대상일 `snapshotAt` 스냅샷을 한 번 더 조회
- 여전히 없으면 스케줄러와 동일한 `POPULAR_COMMUNITY` refresh 로직 실행
- 대상일 `snapshotAt`에 저장된 스냅샷을 다시 조회해 반환
- refresh 후에도 노출 가능한 스냅샷이 없으면 빈 배열 반환
- lock key는 섹션 단위로 분리한다.
- 권장: `lock:recommendation-snapshot-refresh:POPULAR_COMMUNITY`
- lock 대기 시간은 최대 300ms로 제한한다.
- 홈 API가 fallback refresh 완료를 기다리는 시간은 최대 1,500ms로 제한한다.
- timeout은 홈 API 대기 timeout이며, 이미 시작된 refresh 작업을 반드시 중단한다는 의미가 아니다.
- 동일 JVM에서는 single-flight 상태를 유지해 동시에 들어온 홈 요청이 중복 refresh를 시작하지 않도록 한다.
- 다중 인스턴스에서는 Redisson 기반 분산 lock으로 인스턴스 간 중복 refresh를 방지한다.
- lock 획득 실패 또는 refresh 진행 중인 요청은 짧게 대기한 뒤 대상일 `snapshotAt` 스냅샷을 다시 조회하고, 없으면 빈 배열을 반환한다.
- fallback refresh 실패는 로그를 남기고 홈 API 전체 실패로 전파하지 않는다.
- 구현은 기존 `RecommendationSnapshotFallbackService`를 섹션 단위로 확장하는 방식을 우선 검토한다. 이는 AI 캐릭터/응원 크리에이터 fallback과 lock, timeout, double-check 동작을 공유할 수 있어 사용자 제안 흐름보다 중복이 적다.
#### Edge Cases
- lock 획득 직전에 다른 요청 또는 스케줄러가 스냅샷을 만들 수 있으므로 double-check가 필요하다.
- fallback refresh가 성공했지만 집계 대상 데이터가 없으면 빈 배열을 반환한다.
- 집계 대상 데이터가 없는 정상 refresh와 아직 한 번도 refresh되지 않은 상태를 구분해야 반복 fallback을 막을 수 있다.
- 홈 API 대기 시간이 초과되어 빈 배열을 반환한 뒤에도 백그라운드 refresh가 완료되면 이후 요청은 저장된 대상일 `snapshotAt` 스냅샷을 사용한다.
### Feature G. 빈 결과 refresh 마커
#### Requirements
- `POPULAR_COMMUNITY`도 AI 캐릭터/응원 크리에이터와 같은 방식으로 빈 결과 refresh 마커를 저장하는 방안을 우선 적용한다.
- 권장 방식은 `targetId = 0` empty snapshot marker를 `POPULAR_COMMUNITY`에도 적용하는 것이다.
- 조회 쿼리는 기존처럼 `target_id <> 0`을 유지해 marker가 사용자 응답에 노출되지 않게 한다.
- 대상일 `snapshotAt` 존재 여부 확인은 marker를 포함해 판단하여, 집계 결과가 없는 날 매 홈 요청마다 fallback refresh가 반복되지 않도록 한다.
- 이 방식은 별도 DDL 없이 기존 `recommendation_snapshot` 구조를 재사용할 수 있어 이번 요구사항에 가장 단순하다.
#### Edge Cases
- marker가 있더라도 실제 추천 row가 없으면 홈 응답은 빈 배열이다.
- 같은 `sectionType`, `snapshotAt`에 실제 row가 생기는 재실행이 있으면 marker는 대체되어야 한다.
- marker 저장 정책을 모든 스냅샷 섹션으로 공통화하는 작업은 이번 범위에서는 `POPULAR_COMMUNITY`에 필요한 최소 변경만 수행한다.
---
## 8. Technical Constraints
- Kotlin, Spring Boot 2.7.14, Java 17, Gradle Wrapper 구조를 유지한다.
- 기존 `kr.co.vividnext.sodalive.v2.recommendation` 패키지 경계와 `v2.api.home`에서 `v2.recommendation`을 호출하는 의존 방향을 유지한다.
- 기존 `RecommendationSnapshot`, `RecommendationSnapshotPort`, `HomeRecommendationQueryPort` 기반 저장/조회 구조를 재사용한다.
- 공개 API 응답 DTO는 필드 추가 없이 유지한다.
- 스케줄러 refresh와 fallback refresh는 산식, 기간, 저장 limit, 정렬 기준이 갈라지지 않도록 같은 application service 경로를 사용한다.
- fallback refresh 기능은 기존 AI 캐릭터/응원 크리에이터 구현을 복사하기보다 섹션별로 재사용 가능한 형태를 우선 검토한다. 단, 과도한 일반화가 필요하면 `POPULAR_COMMUNITY`에 필요한 최소 추상화만 적용한다.
- `POPULAR_COMMUNITY` 집계는 정확한 top 후보를 위해 최종 점수 계산 전 candidate pre-limit를 두지 않는다.
- DB-side scoring을 유지하는 경우 Kotlin 단에는 산식 parity 검증용 정책 함수를 두고, DB expression과 같은 상수를 공유한다.
- Kotlin-side scoring으로 변경하는 경우 DB에서 필요한 원천 metric을 정확히 집계하고, service에서 최종 점수/정렬/limit을 적용한다. 이 경우 후보 전체를 메모리에 올리는 비용과 데이터량을 구현 계획에서 검토한다.
- 기본 권장안은 현재 구조와 성능 특성을 유지하는 DB-side exact scoring이다. 다만 산식/부스트 계산은 Kotlin 정책 테스트로 검증 가능한 형태를 둔다.
- 시간 범위는 KST 기준 최근 7일을 UTC half-open window로 변환하는 정책 함수를 사용한다. 기존 `RecommendationSnapshotWindowPolicy`에 7일 window 함수를 추가하는 방식을 우선 검토한다.
- 신규 DDL은 만들지 않는 것을 기본으로 한다. 불가피하게 필요하면 운영 DB 반영용 SQL 문서는 MySQL 기준으로 별도 작성한다.
---
## 9. Metrics
- `POPULAR_COMMUNITY` 스냅샷 생성 성공/실패 로그
- `POPULAR_COMMUNITY` 스냅샷 최종 저장 수
- fallback refresh 실행/성공/실패/timeout 로그
- fallback refresh lock 획득 성공/실패 로그
- `likeCount`, `commentCount`, `creatorFollowerCount` 입력값 분포
- empty snapshot marker 저장 횟수
- 홈 API `popularCommunityPosts` 빈 응답 비율
- 홈 API fallback refresh 대기 시간
---
## 10. Open Questions
- 없음.
---
## 11. Decisions
- 요구사항의 `is_pin`은 현재 코드의 `CreatorCommunity.isFixed` 및 DB 컬럼 `is_fixed`로 매핑한다.
- 유료 게시글 제외는 `price > 0` 제외로 정의한다.
- `creatorFollowerCount`는 스냅샷 생성 시점의 활성 팔로워 총수로 확정한다.
- 일 단위 최신성을 강제하며, fallback 조건은 최신 스냅샷 부재가 아니라 대상일 `snapshotAt` 스냅샷 부재로 확정한다.
- fallback 방식은 사용자 제안의 동일 refresh 로직 재사용, lock, double-check 원칙을 채택한다.
- 더 나은 구현 방향은 기존 `RecommendationSnapshotFallbackService``POPULAR_COMMUNITY`를 추가해 AI 캐릭터/응원 크리에이터와 같은 lock, timeout, single-flight 흐름을 공유하는 것이다.
- 점수 계산은 기본적으로 DB-side exact scoring을 유지하고, Kotlin 정책 함수와 테스트로 가중치/부스트의 근거를 고정한다.
---
## 12. Related Documents
- `docs/prd/sample-prd.md`
- `docs/agent-guides/작업절차.md`
- `docs/agent-guides/문서유지보수.md`
- `docs/20260529_메인_홈_추천_API/prd.md`
- `docs/20260709_메인_홈_추천_AI캐릭터_스냅샷/prd.md`
- `docs/20260710_메인_홈_추천_응원크리에이터_스냅샷/prd.md`

View File

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

View File

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

View File

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

View File

@@ -12,7 +12,7 @@ import javax.persistence.JoinColumn
import javax.persistence.ManyToOne import javax.persistence.ManyToOne
@Entity @Entity
data class UseCanCalculate( class UseCanCalculate(
val can: Int, val can: Int,
@Column(nullable = false) @Column(nullable = false)

View File

@@ -31,6 +31,7 @@ import kr.co.vividnext.sodalive.member.Member
import kr.co.vividnext.sodalive.member.MemberRole import kr.co.vividnext.sodalive.member.MemberRole
import kr.co.vividnext.sodalive.member.MemberService import kr.co.vividnext.sodalive.member.MemberService
import kr.co.vividnext.sodalive.member.contentpreference.MemberContentPreferenceService import kr.co.vividnext.sodalive.member.contentpreference.MemberContentPreferenceService
import kr.co.vividnext.sodalive.v2.api.creator.channel.fantalk.dto.CreatorChannelFanTalkResponse
import org.springframework.beans.factory.annotation.Value import org.springframework.beans.factory.annotation.Value
import org.springframework.context.ApplicationEventPublisher import org.springframework.context.ApplicationEventPublisher
import org.springframework.data.domain.Pageable import org.springframework.data.domain.Pageable
@@ -579,7 +580,7 @@ class ExplorerService(
} }
@Transactional @Transactional
fun writeCheers(request: PostWriteCheersRequest, member: Member) { fun writeCheers(request: PostWriteCheersRequest, member: Member): CreatorChannelFanTalkResponse {
val creator = queryRepository.getMember(request.creatorId) val creator = queryRepository.getMember(request.creatorId)
?: throw SodaException(messageKey = "member.validation.user_not_found") ?: throw SodaException(messageKey = "member.validation.user_not_found")
@@ -605,18 +606,20 @@ class ExplorerService(
cheers.parent = parent cheers.parent = parent
} }
cheersRepository.save(cheers) val savedCheers = cheersRepository.save(cheers)
// 언어 코드가 지정되지 않은 경우, 파파고 언어 감지 API를 통해 비동기로 언어를 식별한다. // 언어 코드가 지정되지 않은 경우, 파파고 언어 감지 API를 통해 비동기로 언어를 식별한다.
if (request.languageCode.isNullOrBlank()) { if (request.languageCode.isNullOrBlank()) {
applicationEventPublisher.publishEvent( applicationEventPublisher.publishEvent(
LanguageDetectEvent( LanguageDetectEvent(
id = cheers.id!!, id = savedCheers.id!!,
query = request.content, query = request.content,
targetType = LanguageDetectTargetType.CREATOR_CHEERS targetType = LanguageDetectTargetType.CREATOR_CHEERS
) )
) )
} }
return CreatorChannelFanTalkResponse.from(savedCheers, cloudFrontHost)
} }
fun getCreatorProfileCheers( fun getCreatorProfileCheers(
@@ -639,24 +642,29 @@ class ExplorerService(
} }
@Transactional @Transactional
fun modifyCheers(request: PutWriteCheersRequest, member: Member) { fun modifyCheers(request: PutWriteCheersRequest, member: Member): CreatorChannelFanTalkResponse {
val cheers = queryRepository.getCheers(request.cheersId) val cheers = queryRepository.getCheers(request.cheersId)
?: throw SodaException(messageKey = "common.error.invalid_request") ?: throw SodaException(messageKey = "common.error.invalid_request")
val isWriter = cheers.member!!.id!! == member.id!!
val isCreator = cheers.creator!!.id!! == member.id!!
if (cheers.member!!.id!! == member.id!!) { if (!isWriter && !isCreator) {
throw SodaException(messageKey = "common.error.invalid_request")
}
if (isWriter) {
if (request.content != null) { if (request.content != null) {
cheers.cheers = request.content cheers.cheers = request.content
} }
} }
if ( if (isCreator || isWriter) {
cheers.creator!!.id!! == member.id!! ||
cheers.member!!.id!! == member.id!!
) {
if (request.isActive != null) { if (request.isActive != null) {
cheers.isActive = request.isActive cheers.isActive = request.isActive
} }
} }
return CreatorChannelFanTalkResponse.from(cheers, cloudFrontHost)
} }
@Transactional @Transactional

View File

@@ -1,7 +1,10 @@
package kr.co.vividnext.sodalive.v2.api.creator.channel.fantalk.dto package kr.co.vividnext.sodalive.v2.api.creator.channel.fantalk.dto
import com.fasterxml.jackson.annotation.JsonProperty import com.fasterxml.jackson.annotation.JsonProperty
import kr.co.vividnext.sodalive.explorer.profile.CreatorCheers
import kr.co.vividnext.sodalive.extensions.removeDeletedNicknamePrefix
import kr.co.vividnext.sodalive.extensions.toUtcIso import kr.co.vividnext.sodalive.extensions.toUtcIso
import kr.co.vividnext.sodalive.v2.common.domain.toCdnUrl
import kr.co.vividnext.sodalive.v2.creator.channel.fantalk.domain.CreatorChannelFanTalk import kr.co.vividnext.sodalive.v2.creator.channel.fantalk.domain.CreatorChannelFanTalk
import kr.co.vividnext.sodalive.v2.creator.channel.fantalk.domain.CreatorChannelFanTalkReply import kr.co.vividnext.sodalive.v2.creator.channel.fantalk.domain.CreatorChannelFanTalkReply
import kr.co.vividnext.sodalive.v2.creator.channel.fantalk.domain.CreatorChannelFanTalkTab import kr.co.vividnext.sodalive.v2.creator.channel.fantalk.domain.CreatorChannelFanTalkTab
@@ -48,6 +51,20 @@ data class CreatorChannelFanTalkResponse(
creatorReplies = fanTalk.creatorReplies.map(CreatorChannelFanTalkReplyResponse::from) creatorReplies = fanTalk.creatorReplies.map(CreatorChannelFanTalkReplyResponse::from)
) )
} }
fun from(fanTalk: CreatorCheers, cloudFrontHost: String): CreatorChannelFanTalkResponse {
val writer = fanTalk.member!!
return CreatorChannelFanTalkResponse(
fanTalkId = fanTalk.id!!,
writerId = writer.id!!,
writerNickname = writer.nickname.removeDeletedNicknamePrefix(),
writerProfileImageUrl = writer.profileImage.toCdnUrl(cloudFrontHost)
?: "$cloudFrontHost/profile/default-profile.png",
content = fanTalk.cheers,
createdAtUtc = fanTalk.createdAt!!.toUtcIso(),
creatorReplies = emptyList()
)
}
} }
} }

View File

@@ -315,7 +315,7 @@ class HomeRecommendationFacade(
private const val HOME_ACTIVE_CREATOR_LIMIT = 10 private const val HOME_ACTIVE_CREATOR_LIMIT = 10
private const val HOME_RECENT_DEBUT_CREATOR_LIMIT = 10 private const val HOME_RECENT_DEBUT_CREATOR_LIMIT = 10
private const val HOME_FIRST_AUDIO_CONTENT_LIMIT = 10 private const val HOME_FIRST_AUDIO_CONTENT_LIMIT = 10
private const val HOME_AI_CHARACTER_LIMIT = 10 private const val HOME_AI_CHARACTER_LIMIT = 20
private const val HOME_GENRE_CREATOR_GENRE_LIMIT = 5 private const val HOME_GENRE_CREATOR_GENRE_LIMIT = 5
private const val HOME_GENRE_CREATOR_CREATOR_LIMIT = 8 private const val HOME_GENRE_CREATOR_CREATOR_LIMIT = 8
private const val HOME_CHEER_CREATOR_LIMIT = 8 private const val HOME_CHEER_CREATOR_LIMIT = 8

View File

@@ -36,6 +36,7 @@ class DefaultMainContentAllQueryRepository(
memberId: Long?, memberId: Long?,
canViewAdultContent: Boolean, canViewAdultContent: Boolean,
now: LocalDateTime, now: LocalDateTime,
onlyPaid: Boolean,
onlyFree: Boolean, onlyFree: Boolean,
onlyPointAvailable: Boolean onlyPointAvailable: Boolean
): Int { ): Int {
@@ -44,7 +45,7 @@ class DefaultMainContentAllQueryRepository(
.from(audioContent) .from(audioContent)
.join(audioContent.member, member) .join(audioContent.member, member)
.join(audioContent.theme, audioContentTheme) .join(audioContent.theme, audioContentTheme)
.where(audioCondition(memberId, canViewAdultContent, now, onlyFree, onlyPointAvailable)) .where(audioCondition(memberId, canViewAdultContent, now, onlyPaid, onlyFree, onlyPointAvailable))
.fetchOne() .fetchOne()
?.toInt() ?.toInt()
?: 0 ?: 0
@@ -57,10 +58,11 @@ class DefaultMainContentAllQueryRepository(
sort: ContentSort, sort: ContentSort,
offset: Long, offset: Long,
limit: Int, limit: Int,
onlyPaid: Boolean,
onlyFree: Boolean, onlyFree: Boolean,
onlyPointAvailable: Boolean onlyPointAvailable: Boolean
): List<MainContentAllAudio> { ): List<MainContentAllAudio> {
val rows = findAudioRows(memberId, canViewAdultContent, now, sort, offset, limit, onlyFree, onlyPointAvailable) val rows = findAudioRows(memberId, canViewAdultContent, now, sort, offset, limit, onlyPaid, onlyFree, onlyPointAvailable)
if (rows.isEmpty()) return emptyList() if (rows.isEmpty()) return emptyList()
val contentIds = rows.map { it.get(audioContent.id)!! } val contentIds = rows.map { it.get(audioContent.id)!! }
@@ -157,6 +159,7 @@ class DefaultMainContentAllQueryRepository(
sort: ContentSort, sort: ContentSort,
offset: Long, offset: Long,
limit: Int, limit: Int,
onlyPaid: Boolean,
onlyFree: Boolean, onlyFree: Boolean,
onlyPointAvailable: Boolean onlyPointAvailable: Boolean
): List<Tuple> { ): List<Tuple> {
@@ -175,7 +178,7 @@ class DefaultMainContentAllQueryRepository(
.from(audioContent) .from(audioContent)
.join(audioContent.member, member) .join(audioContent.member, member)
.join(audioContent.theme, audioContentTheme) .join(audioContent.theme, audioContentTheme)
.where(audioCondition(memberId, canViewAdultContent, now, onlyFree, onlyPointAvailable)) .where(audioCondition(memberId, canViewAdultContent, now, onlyPaid, onlyFree, onlyPointAvailable))
when (sort) { when (sort) {
ContentSort.POPULAR -> { ContentSort.POPULAR -> {
@@ -344,10 +347,12 @@ class DefaultMainContentAllQueryRepository(
memberId: Long?, memberId: Long?,
canViewAdultContent: Boolean, canViewAdultContent: Boolean,
now: LocalDateTime, now: LocalDateTime,
onlyPaid: Boolean,
onlyFree: Boolean, onlyFree: Boolean,
onlyPointAvailable: Boolean onlyPointAvailable: Boolean
): BooleanExpression { ): BooleanExpression {
return publicAudioCondition(canViewAdultContent, now) return publicAudioCondition(canViewAdultContent, now)
.and(optionalAudioPaidCondition(onlyPaid))
.and(optionalAudioFreeCondition(onlyFree)) .and(optionalAudioFreeCondition(onlyFree))
.and(optionalAudioPointCondition(onlyPointAvailable)) .and(optionalAudioPointCondition(onlyPointAvailable))
.withOptionalAnd(notBlockedCreatorCondition(memberId, audioContent.member.id)) .withOptionalAnd(notBlockedCreatorCondition(memberId, audioContent.member.id))
@@ -392,6 +397,10 @@ class DefaultMainContentAllQueryRepository(
.withOptionalAnd(notBlockedCreatorCondition(memberId, series.member.id)) .withOptionalAnd(notBlockedCreatorCondition(memberId, series.member.id))
} }
private fun optionalAudioPaidCondition(onlyPaid: Boolean): BooleanExpression? {
return if (onlyPaid) audioContent.price.gt(0) else null
}
private fun optionalAudioFreeCondition(onlyFree: Boolean): BooleanExpression? { private fun optionalAudioFreeCondition(onlyFree: Boolean): BooleanExpression? {
return if (onlyFree) audioContent.price.eq(0) else null return if (onlyFree) audioContent.price.eq(0) else null
} }

View File

@@ -46,7 +46,8 @@ class MainContentAllQueryService(
page = resolvedPage, page = resolvedPage,
memberId = memberId, memberId = memberId,
canViewAdultContent = canViewAdultContent, canViewAdultContent = canViewAdultContent,
now = now now = now,
onlyPaid = true
) )
MainContentAllType.FREE -> getAudioContents( MainContentAllType.FREE -> getAudioContents(
@@ -102,10 +103,18 @@ class MainContentAllQueryService(
memberId: Long?, memberId: Long?,
canViewAdultContent: Boolean, canViewAdultContent: Boolean,
now: LocalDateTime, now: LocalDateTime,
onlyPaid: Boolean = false,
onlyFree: Boolean = false, onlyFree: Boolean = false,
onlyPointAvailable: Boolean = false onlyPointAvailable: Boolean = false
): MainContentAll { ): MainContentAll {
val totalCount = queryPort.countAudios(memberId, canViewAdultContent, now, onlyFree, onlyPointAvailable) val totalCount = queryPort.countAudios(
memberId = memberId,
canViewAdultContent = canViewAdultContent,
now = now,
onlyPaid = onlyPaid,
onlyFree = onlyFree,
onlyPointAvailable = onlyPointAvailable
)
val audios = queryPort.findAudios( val audios = queryPort.findAudios(
memberId = memberId, memberId = memberId,
canViewAdultContent = canViewAdultContent, canViewAdultContent = canViewAdultContent,
@@ -113,6 +122,7 @@ class MainContentAllQueryService(
sort = sort, sort = sort,
offset = page.offset, offset = page.offset,
limit = page.size + 1, limit = page.size + 1,
onlyPaid = onlyPaid,
onlyFree = onlyFree, onlyFree = onlyFree,
onlyPointAvailable = onlyPointAvailable onlyPointAvailable = onlyPointAvailable
) )

View File

@@ -11,6 +11,7 @@ interface MainContentAllQueryPort {
memberId: Long?, memberId: Long?,
canViewAdultContent: Boolean, canViewAdultContent: Boolean,
now: LocalDateTime, now: LocalDateTime,
onlyPaid: Boolean = false,
onlyFree: Boolean = false, onlyFree: Boolean = false,
onlyPointAvailable: Boolean = false onlyPointAvailable: Boolean = false
): Int ): Int
@@ -22,6 +23,7 @@ interface MainContentAllQueryPort {
sort: ContentSort, sort: ContentSort,
offset: Long, offset: Long,
limit: Int, limit: Int,
onlyPaid: Boolean = false,
onlyFree: Boolean = false, onlyFree: Boolean = false,
onlyPointAvailable: Boolean = false onlyPointAvailable: Boolean = false
): List<MainContentAllAudio> ): List<MainContentAllAudio>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -148,8 +148,7 @@ class DefaultHomeRecommendationQueryRepository(
lr.id as target_sort_id lr.id as target_sort_id
from live_room lr from live_room lr
join member m on m.id = lr.member_id join member m on m.id = lr.member_id
where lr.is_active = true where lr.channel_name is not null
and lr.channel_name is not null
and lr.channel_name <> '' and lr.channel_name <> ''
and (:includeAdultActivities = true or lr.is_adult = false) and (:includeAdultActivities = true or lr.is_adult = false)
and m.is_active = true and m.is_active = true
@@ -174,7 +173,7 @@ class DefaultHomeRecommendationQueryRepository(
m.profile_image as creator_profile_image, m.profile_image as creator_profile_image,
'COMMUNITY' as activity_type, 'COMMUNITY' as activity_type,
cc.created_at as activity_at, cc.created_at as activity_at,
m.id as target_id, cc.id as target_id,
cc.id as target_sort_id cc.id as target_sort_id
from creator_community cc from creator_community cc
join member m on m.id = cc.member_id join member m on m.id = cc.member_id
@@ -484,81 +483,97 @@ class DefaultHomeRecommendationQueryRepository(
override fun findAiCharacterSnapshots( override fun findAiCharacterSnapshots(
windowStart: LocalDateTime, windowStart: LocalDateTime,
snapshotAt: LocalDateTime, windowEndExclusive: LocalDateTime,
limit: Int limit: Int
): List<RecommendationSnapshotRecord> { ): List<RecommendationSnapshotRecord> {
val sql = """ val sql = """
select ranked.target_id,
ranked.score,
ranked.snapshot_rank as random_tie_breaker
from (
select scored.target_id,
scored.score,
row_number() over (order by scored.score desc, scored.character_created_at desc, scored.target_id desc) as snapshot_rank
from (
select cc.id as target_id, select cc.id as target_id,
(((select count(cm.id) cc.created_at as character_created_at,
from chat_message cm ((coalesce(acs.ai_chat_count, 0) * ${RecommendationScoreSpec.AI_CHAT_WEIGHT} +
join chat_participant cp on cp.id = cm.participant_id coalesce(aus.active_user_count, 0) * ${RecommendationScoreSpec.AI_ACTIVE_USER_WEIGHT} +
where cp.character_id = cc.id coalesce(fs.follow_increase_count, 0) * ${RecommendationScoreSpec.AI_FOLLOW_INCREASE_WEIGHT}) *
and cm.created_at >= :windowStart
and cm.created_at <= :snapshotAt
and cm.is_active = true
and cp.is_active = true
and cp.participant_type = 'CHARACTER') * ${RecommendationScoreSpec.AI_RECENT_CHAT_WEIGHT} +
(select count(distinct up.member_id)
from chat_message cm
join chat_participant up on up.id = cm.participant_id
join chat_participant cp on cp.chat_room_id = cm.chat_room_id
join member m on m.id = up.member_id
where cp.character_id = cc.id
and cm.created_at >= :windowStart
and cm.created_at <= :snapshotAt
and cm.is_active = true
and up.is_active = true
and cp.is_active = true
and up.participant_type = 'USER'
and cp.participant_type = 'CHARACTER'
and m.is_active = true) * ${RecommendationScoreSpec.AI_RECENT_ACTIVE_USER_WEIGHT}) *
case case
when cc.created_at >= :boost10Start then ${RecommendationScoreSpec.NEW_BOOST_10_DAYS} when cc.created_at >= :boost10Start then ${RecommendationScoreSpec.AI_NEW_BOOST_10_DAYS}
when cc.created_at >= :boost20Start then ${RecommendationScoreSpec.NEW_BOOST_20_DAYS} when cc.created_at >= :boost20Start then ${RecommendationScoreSpec.AI_NEW_BOOST_20_DAYS}
when cc.created_at >= :boost30Start then ${RecommendationScoreSpec.NEW_BOOST_30_DAYS} when cc.created_at >= :boost30Start then ${RecommendationScoreSpec.AI_NEW_BOOST_30_DAYS}
else ${RecommendationScoreSpec.DEFAULT_NEW_BOOST} else ${RecommendationScoreSpec.DEFAULT_NEW_BOOST}
end) as score, end) as score
rand() as random_tie_breaker
from chat_character cc from chat_character cc
where cc.is_active = true join member creator_member on creator_member.id = cc.creator_member_id
and (exists ( left join (
select 1 select cp.character_id as character_id, count(cm.id) as ai_chat_count
from chat_message cm from chat_message cm
join chat_participant cp on cp.id = cm.participant_id join chat_participant cp on cp.id = cm.participant_id
where cp.character_id = cc.id where cm.created_at >= :windowStart
and cm.created_at >= :windowStart and cm.created_at < :windowEndExclusive
and cm.created_at <= :snapshotAt
and cm.is_active = true and cm.is_active = true
and cp.is_active = true and cp.is_active = true
and cp.participant_type = 'CHARACTER' and cp.participant_type = 'CHARACTER'
) or exists ( group by cp.character_id
select 1 ) acs on acs.character_id = cc.id
left join (
select cp.character_id as character_id, count(distinct up.member_id) as active_user_count
from chat_message cm from chat_message cm
join chat_participant up on up.id = cm.participant_id join chat_participant up on up.id = cm.participant_id
join chat_participant cp on cp.chat_room_id = cm.chat_room_id join chat_participant cp on cp.chat_room_id = cm.chat_room_id
join member m on m.id = up.member_id join member m on m.id = up.member_id
where cp.character_id = cc.id where cm.created_at >= :windowStart
and cm.created_at >= :windowStart and cm.created_at < :windowEndExclusive
and cm.created_at <= :snapshotAt
and cm.is_active = true and cm.is_active = true
and up.is_active = true and up.is_active = true
and cp.is_active = true and cp.is_active = true
and up.participant_type = 'USER' and up.participant_type = 'USER'
and cp.participant_type = 'CHARACTER' and cp.participant_type = 'CHARACTER'
and m.is_active = true and m.is_active = true
)) group by cp.character_id
order by score desc, random_tie_breaker asc ) aus on aus.character_id = cc.id
left join (
select cf.creator_id as creator_id, count(cf.id) as follow_increase_count
from creator_following cf
join member follower_member on follower_member.id = cf.member_id
where cf.created_at >= :windowStart
and cf.created_at < :windowEndExclusive
and cf.is_active = true
and follower_member.is_active = true
group by cf.creator_id
) fs on fs.creator_id = cc.creator_member_id
where cc.is_active = true
and cc.created_at is not null
and cc.created_at < :windowEndExclusive
and creator_member.is_active = true
and creator_member.role = 'CREATOR'
and creator_member.member_kind = 'AI_CHARACTER'
) scored
where scored.score > 0
) ranked
order by ranked.score desc, ranked.snapshot_rank asc
limit :limit limit :limit
""".trimIndent() """.trimIndent()
return executeSnapshotQuery(sql, RecommendedSectionType.AI_CHARACTER, windowStart, snapshotAt, limit) return executeSnapshotQuery(
sql,
RecommendedSectionType.AI_CHARACTER,
windowStart,
windowEndExclusive.minusSeconds(1),
limit,
windowEndExclusive
)
} }
override fun findCheerCreatorSnapshots( override fun findCheerCreatorSnapshots(
windowStart: LocalDateTime, windowStart: LocalDateTime,
snapshotAt: LocalDateTime, windowEndExclusive: LocalDateTime,
limit: Int limit: Int
): List<RecommendationSnapshotRecord> { ): List<RecommendationSnapshotRecord> {
val snapshotAt = windowEndExclusive.minusSeconds(1)
val sql = """ val sql = """
with creator_debut as ( with creator_debut as (
select debut_events.creator_id as creator_id, min(debut_events.debut_at) as debut_at select debut_events.creator_id as creator_id, min(debut_events.debut_at) as debut_at
@@ -581,14 +596,14 @@ class DefaultHomeRecommendationQueryRepository(
donation_stats as ( donation_stats as (
select ucc.recipient_creator_id as creator_id, select ucc.recipient_creator_id as creator_id,
coalesce(sum(ucc.can), 0) as donation_amount, coalesce(sum(ucc.can), 0) as donation_amount,
count(ucc.id) as donation_count count(distinct ucc.use_can_id) as donation_count
from use_can_calculate ucc from use_can_calculate ucc
join use_can uc on uc.id = ucc.use_can_id join use_can uc on uc.id = ucc.use_can_id
where ucc.status = 'RECEIVED' where ucc.status = 'RECEIVED'
and uc.is_refund = false and uc.is_refund = false
and uc.can_usage = 'CHANNEL_DONATION' and uc.can_usage = 'CHANNEL_DONATION'
and ucc.created_at >= :windowStart and ucc.created_at >= :windowStart
and ucc.created_at <= :snapshotAt and ucc.created_at < :windowEndExclusive
group by ucc.recipient_creator_id group by ucc.recipient_creator_id
), ),
fan_talk_stats as ( fan_talk_stats as (
@@ -596,7 +611,7 @@ class DefaultHomeRecommendationQueryRepository(
from creator_cheers ch from creator_cheers ch
where ch.is_active = true where ch.is_active = true
and ch.created_at >= :windowStart and ch.created_at >= :windowStart
and ch.created_at <= :snapshotAt and ch.created_at < :windowEndExclusive
group by ch.creator_id group by ch.creator_id
) )
select m.id as target_id, select m.id as target_id,
@@ -604,9 +619,9 @@ class DefaultHomeRecommendationQueryRepository(
coalesce(fts.fan_talk_count, 0) * ${RecommendationScoreSpec.CHEER_FAN_TALK_WEIGHT} + coalesce(fts.fan_talk_count, 0) * ${RecommendationScoreSpec.CHEER_FAN_TALK_WEIGHT} +
coalesce(ds.donation_count, 0) * ${RecommendationScoreSpec.CHEER_DONATION_COUNT_WEIGHT}) * coalesce(ds.donation_count, 0) * ${RecommendationScoreSpec.CHEER_DONATION_COUNT_WEIGHT}) *
case case
when cd.debut_at >= :boost10Start then ${RecommendationScoreSpec.NEW_BOOST_10_DAYS} when cd.debut_at >= :boost10Start then ${RecommendationScoreSpec.CHEER_NEW_BOOST_10_DAYS}
when cd.debut_at >= :boost20Start then ${RecommendationScoreSpec.NEW_BOOST_20_DAYS} when cd.debut_at >= :boost20Start then ${RecommendationScoreSpec.CHEER_NEW_BOOST_20_DAYS}
when cd.debut_at >= :boost30Start then ${RecommendationScoreSpec.NEW_BOOST_30_DAYS} when cd.debut_at >= :boost30Start then ${RecommendationScoreSpec.CHEER_NEW_BOOST_30_DAYS}
else ${RecommendationScoreSpec.DEFAULT_NEW_BOOST} else ${RecommendationScoreSpec.DEFAULT_NEW_BOOST}
end) as score, end) as score,
rand() as random_tie_breaker rand() as random_tie_breaker
@@ -621,14 +636,22 @@ class DefaultHomeRecommendationQueryRepository(
limit :limit limit :limit
""".trimIndent() """.trimIndent()
return executeSnapshotQuery(sql, RecommendedSectionType.CHEER_CREATOR, windowStart, snapshotAt, limit) return executeSnapshotQuery(
sql,
RecommendedSectionType.CHEER_CREATOR,
windowStart,
snapshotAt,
limit,
windowEndExclusive
)
} }
override fun findPopularCommunitySnapshots( override fun findPopularCommunitySnapshots(
windowStart: LocalDateTime, windowStart: LocalDateTime,
snapshotAt: LocalDateTime, windowEndExclusive: LocalDateTime,
limit: Int limit: Int
): List<RecommendationSnapshotRecord> { ): List<RecommendationSnapshotRecord> {
val snapshotAt = windowEndExclusive.minusSeconds(1)
val sql = """ val sql = """
with creator_debut as ( with creator_debut as (
select debut_events.creator_id as creator_id, min(debut_events.debut_at) as debut_at select debut_events.creator_id as creator_id, min(debut_events.debut_at) as debut_at
@@ -653,7 +676,7 @@ class DefaultHomeRecommendationQueryRepository(
from creator_community_like ccl from creator_community_like ccl
where ccl.is_active = true where ccl.is_active = true
and ccl.created_at >= :windowStart and ccl.created_at >= :windowStart
and ccl.created_at <= :snapshotAt and ccl.created_at < :windowEndExclusive
group by ccl.creator_community_id group by ccl.creator_community_id
), ),
comment_stats as ( comment_stats as (
@@ -661,7 +684,7 @@ class DefaultHomeRecommendationQueryRepository(
from creator_community_comment ccc from creator_community_comment ccc
where ccc.is_active = true where ccc.is_active = true
and ccc.created_at >= :windowStart and ccc.created_at >= :windowStart
and ccc.created_at <= :snapshotAt and ccc.created_at < :windowEndExclusive
group by ccc.creator_community_id group by ccc.creator_community_id
), ),
follower_stats as ( follower_stats as (
@@ -675,9 +698,9 @@ class DefaultHomeRecommendationQueryRepository(
(case when cc.is_comment_available = true then coalesce(cs.comment_count, 0) else 0 end) * ${RecommendationScoreSpec.COMMUNITY_COMMENT_WEIGHT} + (case when cc.is_comment_available = true then coalesce(cs.comment_count, 0) else 0 end) * ${RecommendationScoreSpec.COMMUNITY_COMMENT_WEIGHT} +
coalesce(fs.follower_count, 0) * ${RecommendationScoreSpec.COMMUNITY_FOLLOWER_WEIGHT}) * coalesce(fs.follower_count, 0) * ${RecommendationScoreSpec.COMMUNITY_FOLLOWER_WEIGHT}) *
case case
when cd.debut_at >= :boost10Start then ${RecommendationScoreSpec.NEW_BOOST_10_DAYS} when cd.debut_at >= :boost10Start then ${RecommendationScoreSpec.COMMUNITY_NEW_BOOST_10_DAYS}
when cd.debut_at >= :boost20Start then ${RecommendationScoreSpec.NEW_BOOST_20_DAYS} when cd.debut_at >= :boost20Start then ${RecommendationScoreSpec.COMMUNITY_NEW_BOOST_20_DAYS}
when cd.debut_at >= :boost30Start then ${RecommendationScoreSpec.NEW_BOOST_30_DAYS} when cd.debut_at >= :boost30Start then ${RecommendationScoreSpec.COMMUNITY_NEW_BOOST_30_DAYS}
else ${RecommendationScoreSpec.DEFAULT_NEW_BOOST} else ${RecommendationScoreSpec.DEFAULT_NEW_BOOST}
end) as score, end) as score,
rand() as random_tie_breaker rand() as random_tie_breaker
@@ -689,15 +712,27 @@ class DefaultHomeRecommendationQueryRepository(
left join follower_stats fs on fs.creator_id = cc.member_id left join follower_stats fs on fs.creator_id = cc.member_id
where cc.is_active = true where cc.is_active = true
and m.is_active = true and m.is_active = true
and cc.price = 0 and cc.price <= 0
and cc.is_fixed = false and cc.is_fixed = false
and cc.created_at <= :snapshotAt and cc.created_at < :windowEndExclusive
and cd.debut_at is not null and cd.debut_at is not null
and (
coalesce(ls.like_count, 0) > 0
or (cc.is_comment_available = true and coalesce(cs.comment_count, 0) > 0)
or coalesce(fs.follower_count, 0) > 0
)
order by score desc, random_tie_breaker asc order by score desc, random_tie_breaker asc
limit :limit limit :limit
""".trimIndent() """.trimIndent()
return executeSnapshotQuery(sql, RecommendedSectionType.POPULAR_COMMUNITY, windowStart, snapshotAt, limit) return executeSnapshotQuery(
sql,
RecommendedSectionType.POPULAR_COMMUNITY,
windowStart,
snapshotAt,
limit,
windowEndExclusive
)
} }
override fun findAiCharacterRecommendationDetails( override fun findAiCharacterRecommendationDetails(
@@ -810,6 +845,7 @@ class DefaultHomeRecommendationQueryRepository(
creatorCommunity.isActive.isTrue, creatorCommunity.isActive.isTrue,
member.isActive.isTrue, member.isActive.isTrue,
creatorCommunity.isFixed.isFalse, creatorCommunity.isFixed.isFalse,
creatorCommunity.price.loe(0),
includeAdultCommunityCondition(includeAdultCommunities), includeAdultCommunityCondition(includeAdultCommunities),
notBlockedCreatorCondition(memberId, member.id), notBlockedCreatorCondition(memberId, member.id),
creatorCommunity.id.`in`(communityIds) creatorCommunity.id.`in`(communityIds)
@@ -1130,12 +1166,19 @@ class DefaultHomeRecommendationQueryRepository(
sectionType: RecommendedSectionType, sectionType: RecommendedSectionType,
windowStart: LocalDateTime, windowStart: LocalDateTime,
snapshotAt: LocalDateTime, snapshotAt: LocalDateTime,
limit: Int limit: Int,
windowEndExclusive: LocalDateTime? = null
): List<RecommendationSnapshotRecord> { ): List<RecommendationSnapshotRecord> {
val query = entityManager.createNativeQuery(sql) val query = entityManager.createNativeQuery(sql)
.setParameter("windowStart", windowStart) .setParameter("windowStart", windowStart)
.setParameter("snapshotAt", snapshotAt)
.setParameter("limit", limit) .setParameter("limit", limit)
if (sql.contains(":snapshotAt")) {
query.setParameter("snapshotAt", snapshotAt)
}
if (windowEndExclusive != null) {
query.setParameter("windowEndExclusive", windowEndExclusive)
}
query
.setParameter( .setParameter(
"boost10Start", "boost10Start",
snapshotAt.toLocalDate().minusDays(RecommendationScoreSpec.NEW_BOOST_10_DAY_LIMIT).atStartOfDay() snapshotAt.toLocalDate().minusDays(RecommendationScoreSpec.NEW_BOOST_10_DAY_LIMIT).atStartOfDay()

View File

@@ -18,11 +18,34 @@ class RecommendationSnapshotPersistenceAdapter(
return repository.findLatestSnapshots(sectionType.name, offset, limit).map { it.toRecord() } return repository.findLatestSnapshots(sectionType.name, offset, limit).map { it.toRecord() }
} }
override fun findSnapshots(
sectionType: RecommendedSectionType,
snapshotAt: LocalDateTime,
offset: Long,
limit: Int
): List<RecommendationSnapshotRecord> {
return repository.findSnapshots(sectionType.name, snapshotAt, offset, limit).map { it.toRecord() }
}
override fun existsLatestSnapshot(sectionType: RecommendedSectionType): Boolean {
return repository.existsBySectionType(sectionType)
}
override fun existsSnapshot(sectionType: RecommendedSectionType, snapshotAt: LocalDateTime): Boolean {
return repository.existsBySectionTypeAndSnapshotAt(sectionType, snapshotAt)
}
override fun replaceSnapshots( override fun replaceSnapshots(
sectionType: RecommendedSectionType, sectionType: RecommendedSectionType,
snapshotAt: LocalDateTime, snapshotAt: LocalDateTime,
newSnapshots: List<RecommendationSnapshotRecord> newSnapshots: List<RecommendationSnapshotRecord>
) { ) {
if (newSnapshots.isEmpty() && supportsEmptySnapshotMarker(sectionType)) {
repository.deleteBySectionTypeAndSnapshotAt(sectionType, snapshotAt)
repository.save(emptySnapshotMarker(sectionType, snapshotAt).toEntity())
return
}
repository.deleteBySectionTypeAndSnapshotAt(sectionType, snapshotAt) repository.deleteBySectionTypeAndSnapshotAt(sectionType, snapshotAt)
repository.saveAll(newSnapshots.map { it.toEntity() }) repository.saveAll(newSnapshots.map { it.toEntity() })
} }
@@ -46,4 +69,27 @@ class RecommendationSnapshotPersistenceAdapter(
randomTieBreaker = randomTieBreaker randomTieBreaker = randomTieBreaker
) )
} }
private fun emptySnapshotMarker(
sectionType: RecommendedSectionType,
snapshotAt: LocalDateTime
): RecommendationSnapshotRecord {
return RecommendationSnapshotRecord(
sectionType = sectionType,
targetId = EMPTY_SNAPSHOT_MARKER_TARGET_ID,
score = 0.0,
snapshotAt = snapshotAt,
randomTieBreaker = 0.0
)
}
private fun supportsEmptySnapshotMarker(sectionType: RecommendedSectionType): Boolean {
return sectionType == RecommendedSectionType.AI_CHARACTER ||
sectionType == RecommendedSectionType.CHEER_CREATOR ||
sectionType == RecommendedSectionType.POPULAR_COMMUNITY
}
companion object {
private const val EMPTY_SNAPSHOT_MARKER_TARGET_ID = 0L
}
} }

View File

@@ -17,6 +17,7 @@ interface RecommendationSnapshotRepository : JpaRepository<RecommendationSnapsho
from recommendation_snapshot latest from recommendation_snapshot latest
where latest.section_type = :sectionType where latest.section_type = :sectionType
) )
and rs.target_id <> 0
order by rs.score desc, rs.random_tie_breaker asc order by rs.score desc, rs.random_tie_breaker asc
limit :limit offset :offset limit :limit offset :offset
""", """,
@@ -28,5 +29,28 @@ interface RecommendationSnapshotRepository : JpaRepository<RecommendationSnapsho
@Param("limit") limit: Int @Param("limit") limit: Int
): List<RecommendationSnapshot> ): List<RecommendationSnapshot>
@Query(
value = """
select *
from recommendation_snapshot rs
where rs.section_type = :sectionType
and rs.snapshot_at = :snapshotAt
and rs.target_id <> 0
order by rs.score desc, rs.random_tie_breaker asc
limit :limit offset :offset
""",
nativeQuery = true
)
fun findSnapshots(
@Param("sectionType") sectionType: String,
@Param("snapshotAt") snapshotAt: LocalDateTime,
@Param("offset") offset: Long,
@Param("limit") limit: Int
): List<RecommendationSnapshot>
fun deleteBySectionTypeAndSnapshotAt(sectionType: RecommendedSectionType, snapshotAt: LocalDateTime) fun deleteBySectionTypeAndSnapshotAt(sectionType: RecommendedSectionType, snapshotAt: LocalDateTime)
fun existsBySectionType(sectionType: RecommendedSectionType): Boolean
fun existsBySectionTypeAndSnapshotAt(sectionType: RecommendedSectionType, snapshotAt: LocalDateTime): Boolean
} }

View File

@@ -0,0 +1,13 @@
package kr.co.vividnext.sodalive.v2.recommendation.application
import kr.co.vividnext.sodalive.v2.recommendation.port.out.RecommendationSnapshotRecord
import java.time.LocalDateTime
import java.time.ZoneOffset
interface AiCharacterSnapshotFallbackPort {
fun refreshIfMissing(
offset: Long,
limit: Int,
nowUtc: LocalDateTime = LocalDateTime.now(ZoneOffset.UTC)
): List<RecommendationSnapshotRecord>
}

View File

@@ -1,6 +1,7 @@
package kr.co.vividnext.sodalive.v2.recommendation.application package kr.co.vividnext.sodalive.v2.recommendation.application
import kr.co.vividnext.sodalive.v2.common.domain.CreatorActivityType import kr.co.vividnext.sodalive.v2.common.domain.CreatorActivityType
import kr.co.vividnext.sodalive.v2.recommendation.domain.RecommendationSnapshotWindowPolicy
import kr.co.vividnext.sodalive.v2.recommendation.domain.RecommendedSectionType import kr.co.vividnext.sodalive.v2.recommendation.domain.RecommendedSectionType
import kr.co.vividnext.sodalive.v2.recommendation.port.out.HomeAiCharacterRecommendationRecord import kr.co.vividnext.sodalive.v2.recommendation.port.out.HomeAiCharacterRecommendationRecord
import kr.co.vividnext.sodalive.v2.recommendation.port.out.HomeBannerRecommendationRecord import kr.co.vividnext.sodalive.v2.recommendation.port.out.HomeBannerRecommendationRecord
@@ -13,16 +14,24 @@ import kr.co.vividnext.sodalive.v2.recommendation.port.out.HomeRecommendationQue
import kr.co.vividnext.sodalive.v2.recommendation.port.out.RecentDebutCreatorRecord import kr.co.vividnext.sodalive.v2.recommendation.port.out.RecentDebutCreatorRecord
import kr.co.vividnext.sodalive.v2.recommendation.port.out.RecentlyActiveCreatorRecord import kr.co.vividnext.sodalive.v2.recommendation.port.out.RecentlyActiveCreatorRecord
import kr.co.vividnext.sodalive.v2.recommendation.port.out.RecommendationSnapshotPort import kr.co.vividnext.sodalive.v2.recommendation.port.out.RecommendationSnapshotPort
import kr.co.vividnext.sodalive.v2.recommendation.port.out.RecommendationSnapshotRecord
import org.springframework.stereotype.Service import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional import org.springframework.transaction.annotation.Transactional
import java.time.Clock
import java.time.LocalDateTime import java.time.LocalDateTime
@Service @Service
@Transactional(readOnly = true) @Transactional(readOnly = true)
class HomeRecommendationQueryService( class HomeRecommendationQueryService(
private val queryPort: HomeRecommendationQueryPort, private val queryPort: HomeRecommendationQueryPort,
private val snapshotPort: RecommendationSnapshotPort private val snapshotPort: RecommendationSnapshotPort,
private val aiCharacterSnapshotFallbackService: AiCharacterSnapshotFallbackPort? = null,
private val cheerCreatorSnapshotFallbackService: CheerCreatorSnapshotFallbackPort? = null,
private val popularCommunitySnapshotFallbackService: PopularCommunitySnapshotFallbackPort? = null,
private val clock: Clock = Clock.systemUTC()
) { ) {
private val snapshotWindowPolicy = RecommendationSnapshotWindowPolicy()
fun findLiveRecommendations( fun findLiveRecommendations(
offset: Long = 0, offset: Long = 0,
limit: Int = DEFAULT_LIVE_LIMIT, limit: Int = DEFAULT_LIVE_LIMIT,
@@ -71,31 +80,57 @@ class HomeRecommendationQueryService(
offset: Long = 0, offset: Long = 0,
limit: Int = DEFAULT_AI_CHARACTER_LIMIT limit: Int = DEFAULT_AI_CHARACTER_LIMIT
): List<HomeAiCharacterRecommendationRecord> { ): List<HomeAiCharacterRecommendationRecord> {
val snapshots = snapshotPort.findLatestSnapshots(RecommendedSectionType.AI_CHARACTER, offset, limit) val snapshots = findAiCharacterSnapshotsWithFallback(offset, limit)
val detailsById = queryPort.findAiCharacterRecommendationDetails(snapshots.map { it.targetId }) val detailsById = queryPort.findAiCharacterRecommendationDetails(snapshots.map { it.targetId })
.associateBy { it.characterId } .associateBy { it.characterId }
return snapshots.mapNotNull { detailsById[it.targetId] } return snapshots.mapNotNull { detailsById[it.targetId] }
} }
private fun findAiCharacterSnapshotsWithFallback(
offset: Long,
limit: Int
) = snapshotPort.findLatestSnapshots(RecommendedSectionType.AI_CHARACTER, offset, limit)
.ifEmpty {
if (snapshotPort.existsLatestSnapshot(RecommendedSectionType.AI_CHARACTER)) {
emptyList()
} else {
aiCharacterSnapshotFallbackService?.refreshIfMissing(offset, limit).orEmpty()
}
}
fun findCheerCreatorRecommendations( fun findCheerCreatorRecommendations(
limit: Int = DEFAULT_CHEER_CREATOR_LIMIT, limit: Int = DEFAULT_CHEER_CREATOR_LIMIT,
memberId: Long? = null memberId: Long? = null
): List<HomeCheerCreatorRecommendationRecord> { ): List<HomeCheerCreatorRecommendationRecord> {
val snapshots = snapshotPort.findLatestSnapshots(RecommendedSectionType.CHEER_CREATOR).take(CHEER_CREATOR_CANDIDATE_LIMIT) val snapshots = findCheerCreatorSnapshotsWithFallback()
val detailsById = queryPort.findCheerCreatorRecommendationDetails(snapshots.map { it.targetId }, memberId) val detailsById = queryPort.findCheerCreatorRecommendationDetails(snapshots.map { it.targetId }, memberId)
.associateBy { it.creatorId } .associateBy { it.creatorId }
return snapshots.mapNotNull { detailsById[it.targetId] }.take(limit) return snapshots.mapNotNull { detailsById[it.targetId] }.take(limit)
} }
private fun findCheerCreatorSnapshotsWithFallback(): List<RecommendationSnapshotRecord> {
val nowUtc = LocalDateTime.now(clock)
val snapshotAt = snapshotWindowPolicy.previousKstDayUtcWindow(nowUtc).snapshotAt
val snapshots = snapshotPort.findSnapshots(
RecommendedSectionType.CHEER_CREATOR,
snapshotAt,
limit = CHEER_CREATOR_CANDIDATE_LIMIT
)
if (snapshots.isNotEmpty()) return snapshots
if (snapshotPort.existsSnapshot(RecommendedSectionType.CHEER_CREATOR, snapshotAt)) return emptyList()
return cheerCreatorSnapshotFallbackService
?.refreshCheerCreatorIfMissing(offset = 0, limit = CHEER_CREATOR_CANDIDATE_LIMIT, nowUtc = nowUtc)
.orEmpty()
}
fun findPopularCommunityRecommendations( fun findPopularCommunityRecommendations(
limit: Int = DEFAULT_POPULAR_COMMUNITY_LIMIT, limit: Int = DEFAULT_POPULAR_COMMUNITY_LIMIT,
memberId: Long? = null, memberId: Long? = null,
includeAdultCommunities: Boolean = false includeAdultCommunities: Boolean = false
): List<HomePopularCommunityRecommendationRecord> { ): List<HomePopularCommunityRecommendationRecord> {
val snapshots = snapshotPort.findLatestSnapshots(RecommendedSectionType.POPULAR_COMMUNITY) val snapshots = findPopularCommunitySnapshotsWithFallback()
.take(POPULAR_COMMUNITY_CANDIDATE_LIMIT)
val detailsById = queryPort.findPopularCommunityRecommendationDetails( val detailsById = queryPort.findPopularCommunityRecommendationDetails(
snapshots.map { it.targetId }, snapshots.map { it.targetId },
memberId, memberId,
@@ -109,6 +144,21 @@ class HomeRecommendationQueryService(
}.take(limit) }.take(limit)
} }
private fun findPopularCommunitySnapshotsWithFallback(): List<RecommendationSnapshotRecord> {
val nowUtc = LocalDateTime.now(clock)
val snapshotAt = snapshotWindowPolicy.previousKstDayUtcWindow(nowUtc).snapshotAt
val snapshots = snapshotPort.findSnapshots(
RecommendedSectionType.POPULAR_COMMUNITY,
snapshotAt,
limit = POPULAR_COMMUNITY_CANDIDATE_LIMIT
)
if (snapshots.isNotEmpty()) return snapshots
if (snapshotPort.existsSnapshot(RecommendedSectionType.POPULAR_COMMUNITY, snapshotAt)) return emptyList()
return popularCommunitySnapshotFallbackService
?.refreshPopularCommunityIfMissing(offset = 0, limit = POPULAR_COMMUNITY_CANDIDATE_LIMIT, nowUtc = nowUtc)
.orEmpty()
}
fun findGenreCreatorRecommendations( fun findGenreCreatorRecommendations(
memberId: Long?, memberId: Long?,
includeAdultGenres: Boolean, includeAdultGenres: Boolean,
@@ -164,7 +214,7 @@ class HomeRecommendationQueryService(
private const val DEFAULT_ACTIVE_CREATOR_LIMIT = 10 private const val DEFAULT_ACTIVE_CREATOR_LIMIT = 10
private const val DEFAULT_RECENT_DEBUT_CREATOR_LIMIT = 10 private const val DEFAULT_RECENT_DEBUT_CREATOR_LIMIT = 10
private const val DEFAULT_FIRST_AUDIO_CONTENT_LIMIT = 10 private const val DEFAULT_FIRST_AUDIO_CONTENT_LIMIT = 10
private const val DEFAULT_AI_CHARACTER_LIMIT = 10 private const val DEFAULT_AI_CHARACTER_LIMIT = 20
private const val DEFAULT_CHEER_CREATOR_LIMIT = 8 private const val DEFAULT_CHEER_CREATOR_LIMIT = 8
private const val CHEER_CREATOR_CANDIDATE_LIMIT = 16 private const val CHEER_CREATOR_CANDIDATE_LIMIT = 16
private const val DEFAULT_POPULAR_COMMUNITY_LIMIT = 10 private const val DEFAULT_POPULAR_COMMUNITY_LIMIT = 10

View File

@@ -0,0 +1,245 @@
package kr.co.vividnext.sodalive.v2.recommendation.application
import kr.co.vividnext.sodalive.v2.recommendation.domain.RecommendationSnapshotWindowPolicy
import kr.co.vividnext.sodalive.v2.recommendation.domain.RecommendedSectionType
import kr.co.vividnext.sodalive.v2.recommendation.port.out.RecommendationSnapshotPort
import kr.co.vividnext.sodalive.v2.recommendation.port.out.RecommendationSnapshotRecord
import org.redisson.api.RedissonClient
import org.slf4j.LoggerFactory
import org.springframework.context.annotation.Primary
import org.springframework.stereotype.Service
import org.springframework.transaction.PlatformTransactionManager
import org.springframework.transaction.TransactionDefinition
import org.springframework.transaction.support.TransactionTemplate
import java.time.LocalDateTime
import java.time.ZoneOffset
import java.util.concurrent.CompletableFuture
import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.Executor
import java.util.concurrent.ExecutorService
import java.util.concurrent.Executors
import java.util.concurrent.TimeUnit
import java.util.concurrent.TimeoutException
import java.util.concurrent.atomic.AtomicReference
import javax.annotation.PreDestroy
@Primary
@Service
class RecommendationSnapshotFallbackService(
private val snapshotPort: RecommendationSnapshotPort,
private val refreshService: RecommendationSnapshotRefreshService,
private val redissonClient: RedissonClient,
executor: Executor? = null,
private val homeWaitMillis: Long = HOME_WAIT_MILLIS,
transactionManager: PlatformTransactionManager? = null
) : AiCharacterSnapshotFallbackPort, CheerCreatorSnapshotFallbackPort, PopularCommunitySnapshotFallbackPort {
private val log = LoggerFactory.getLogger(javaClass)
private val ownedExecutor: ExecutorService? = if (executor == null) {
Executors.newFixedThreadPool(DEFAULT_WORKER_THREADS)
} else {
null
}
private val workerExecutor: Executor = executor ?: ownedExecutor!!
private val snapshotWindowPolicy = RecommendationSnapshotWindowPolicy()
private val refreshFutures = ConcurrentHashMap<RecommendedSectionType, AtomicReference<CompletableFuture<Void>?>>()
private val readTransactionTemplate = transactionManager?.let {
TransactionTemplate(it).also { template ->
template.propagationBehavior = TransactionDefinition.PROPAGATION_REQUIRES_NEW
template.isReadOnly = true
}
}
override fun refreshIfMissing(
offset: Long,
limit: Int,
nowUtc: LocalDateTime
): List<RecommendationSnapshotRecord> {
return refreshIfMissing(target(RecommendedSectionType.AI_CHARACTER, AI_CHARACTER_LOCK_KEY), offset, limit, nowUtc)
}
override fun refreshCheerCreatorIfMissing(
offset: Long,
limit: Int,
nowUtc: LocalDateTime
): List<RecommendationSnapshotRecord> {
return refreshIfMissing(target(RecommendedSectionType.CHEER_CREATOR, CHEER_CREATOR_LOCK_KEY), offset, limit, nowUtc)
}
override fun refreshPopularCommunityIfMissing(
offset: Long,
limit: Int,
nowUtc: LocalDateTime
): List<RecommendationSnapshotRecord> {
return refreshIfMissing(
target(RecommendedSectionType.POPULAR_COMMUNITY, POPULAR_COMMUNITY_LOCK_KEY),
offset,
limit,
nowUtc
)
}
private fun refreshIfMissing(
target: FallbackTarget,
offset: Long,
limit: Int,
nowUtc: LocalDateTime
): List<RecommendationSnapshotRecord> {
val snapshotAt = snapshotWindowPolicy.previousKstDayUtcWindow(nowUtc).snapshotAt
val existing = findSnapshots(target.sectionType, snapshotAt, offset, limit)
if (existing.isNotEmpty()) return existing
if (hasSnapshot(target.sectionType, snapshotAt)) return findSnapshots(target.sectionType, snapshotAt, offset, limit)
val future = getOrStartRefresh(target, nowUtc)
return try {
future.get(homeWaitMillis, TimeUnit.MILLISECONDS)
findSnapshots(target.sectionType, snapshotAt, offset, limit)
} catch (ex: TimeoutException) {
log.warn(
"event=recommendation_snapshot_fallback_timeout sectionType={} homeWaitMs={}",
target.sectionType,
homeWaitMillis
)
emptyList()
} catch (ex: InterruptedException) {
Thread.currentThread().interrupt()
log.warn(
"event=recommendation_snapshot_fallback_failure sectionType={} error={}",
target.sectionType,
ex.message,
ex
)
emptyList()
} catch (ex: Exception) {
log.warn(
"event=recommendation_snapshot_fallback_failure sectionType={} error={}",
target.sectionType,
ex.message,
ex
)
emptyList()
}
}
private fun findSnapshots(
sectionType: RecommendedSectionType,
snapshotAt: LocalDateTime,
offset: Long,
limit: Int
): List<RecommendationSnapshotRecord> {
return readSnapshotPort { snapshotPort.findSnapshots(sectionType, snapshotAt, offset, limit) }
}
private fun hasSnapshot(sectionType: RecommendedSectionType, snapshotAt: LocalDateTime): Boolean {
return readSnapshotPort { snapshotPort.existsSnapshot(sectionType, snapshotAt) }
}
private fun <T> readSnapshotPort(action: () -> T): T {
val template = readTransactionTemplate ?: return action()
return template.execute { action() } ?: action()
}
@Synchronized
private fun getOrStartRefresh(target: FallbackTarget, nowUtc: LocalDateTime): CompletableFuture<Void> {
val reference = refreshFutures.computeIfAbsent(target.sectionType) { AtomicReference() }
reference.get()?.let { return it }
val newFuture = CompletableFuture.runAsync({ refreshInWorker(target, nowUtc) }, workerExecutor)
reference.set(newFuture)
newFuture.whenComplete { _, _ -> reference.compareAndSet(newFuture, null) }
return newFuture
}
private fun refreshInWorker(target: FallbackTarget, nowUtc: LocalDateTime) {
val lock = redissonClient.getLock(target.lockKey)
try {
if (!lock.tryLock(LOCK_WAIT_MILLIS, -1, TimeUnit.MILLISECONDS)) {
log.info(
"event=recommendation_snapshot_fallback_lock_missed sectionType={} lockKey={} lockWaitMs={}",
target.sectionType,
target.lockKey,
LOCK_WAIT_MILLIS
)
return
}
log.info(
"event=recommendation_snapshot_fallback_lock_acquired sectionType={} lockKey={}",
target.sectionType,
target.lockKey
)
val snapshotAt = snapshotWindowPolicy.previousKstDayUtcWindow(nowUtc).snapshotAt
if (hasSnapshot(target.sectionType, snapshotAt)) return
log.info(
"event=recommendation_snapshot_fallback_refresh_start sectionType={} lockKey={}",
target.sectionType,
target.lockKey
)
val refreshedCount = refresh(target.sectionType, nowUtc)
log.info(
"event=recommendation_snapshot_fallback_refresh_success sectionType={} refreshedCount={}",
target.sectionType,
refreshedCount
)
} catch (ex: Exception) {
log.warn(
"event=recommendation_snapshot_fallback_refresh_failure sectionType={} error={}",
target.sectionType,
ex.message,
ex
)
} finally {
if (lock.isHeldByCurrentThread) {
lock.unlock()
}
}
}
private fun refresh(sectionType: RecommendedSectionType, nowUtc: LocalDateTime): Int {
return when (sectionType) {
RecommendedSectionType.AI_CHARACTER -> refreshService.refreshAiCharacterSnapshots(nowUtc)
RecommendedSectionType.CHEER_CREATOR -> refreshService.refreshCheerCreatorSnapshots(nowUtc)
RecommendedSectionType.POPULAR_COMMUNITY -> refreshService.refreshPopularCommunitySnapshots(nowUtc)
else -> error("Unsupported fallback sectionType: $sectionType")
}
}
@PreDestroy
fun shutdown() {
ownedExecutor?.shutdown()
}
private fun target(sectionType: RecommendedSectionType, lockKey: String): FallbackTarget {
return FallbackTarget(sectionType, lockKey)
}
private data class FallbackTarget(
val sectionType: RecommendedSectionType,
val lockKey: String
)
companion object {
const val AI_CHARACTER_LOCK_KEY = "lock:recommendation-snapshot-refresh:AI_CHARACTER"
const val CHEER_CREATOR_LOCK_KEY = "lock:recommendation-snapshot-refresh:CHEER_CREATOR"
const val POPULAR_COMMUNITY_LOCK_KEY = "lock:recommendation-snapshot-refresh:POPULAR_COMMUNITY"
private const val LOCK_WAIT_MILLIS = 300L
private const val HOME_WAIT_MILLIS = 1_500L
private const val DEFAULT_WORKER_THREADS = 2
}
}
interface CheerCreatorSnapshotFallbackPort {
fun refreshCheerCreatorIfMissing(
offset: Long,
limit: Int,
nowUtc: LocalDateTime = LocalDateTime.now(ZoneOffset.UTC)
): List<RecommendationSnapshotRecord>
}
interface PopularCommunitySnapshotFallbackPort {
fun refreshPopularCommunityIfMissing(
offset: Long,
limit: Int,
nowUtc: LocalDateTime = LocalDateTime.now(ZoneOffset.UTC)
): List<RecommendationSnapshotRecord>
}

View File

@@ -1,23 +1,28 @@
package kr.co.vividnext.sodalive.v2.recommendation.application package kr.co.vividnext.sodalive.v2.recommendation.application
import kr.co.vividnext.sodalive.v2.recommendation.domain.RecommendationSnapshotWindowPolicy
import kr.co.vividnext.sodalive.v2.recommendation.domain.RecommendedSectionType import kr.co.vividnext.sodalive.v2.recommendation.domain.RecommendedSectionType
import kr.co.vividnext.sodalive.v2.recommendation.port.out.HomeRecommendationQueryPort import kr.co.vividnext.sodalive.v2.recommendation.port.out.HomeRecommendationQueryPort
import kr.co.vividnext.sodalive.v2.recommendation.port.out.RecommendationSnapshotPort import kr.co.vividnext.sodalive.v2.recommendation.port.out.RecommendationSnapshotPort
import kr.co.vividnext.sodalive.v2.recommendation.port.out.RecommendationSnapshotRecord import kr.co.vividnext.sodalive.v2.recommendation.port.out.RecommendationSnapshotRecord
import org.redisson.api.RedissonClient
import org.slf4j.LoggerFactory import org.slf4j.LoggerFactory
import org.springframework.stereotype.Service import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional import org.springframework.transaction.annotation.Transactional
import org.springframework.transaction.support.TransactionSynchronization import org.springframework.transaction.support.TransactionSynchronization
import org.springframework.transaction.support.TransactionSynchronizationManager import org.springframework.transaction.support.TransactionSynchronizationManager
import java.time.LocalDateTime import java.time.LocalDateTime
import java.time.ZoneId import java.time.ZoneOffset
import java.util.concurrent.TimeUnit
@Service @Service
class RecommendationSnapshotRefreshService( open class RecommendationSnapshotRefreshService(
private val snapshotPort: RecommendationSnapshotPort, private val snapshotPort: RecommendationSnapshotPort,
private val queryPort: HomeRecommendationQueryPort private val queryPort: HomeRecommendationQueryPort,
private val redissonClient: RedissonClient? = null
) { ) {
private val log = LoggerFactory.getLogger(javaClass) private val log = LoggerFactory.getLogger(javaClass)
private val windowPolicy = RecommendationSnapshotWindowPolicy()
@Transactional(readOnly = true) @Transactional(readOnly = true)
fun getLatestSnapshots(sectionType: RecommendedSectionType): List<RecommendationSnapshotRecord> { fun getLatestSnapshots(sectionType: RecommendedSectionType): List<RecommendationSnapshotRecord> {
@@ -26,24 +31,18 @@ class RecommendationSnapshotRefreshService(
@Transactional @Transactional
fun refreshDailySnapshots() { fun refreshDailySnapshots() {
refreshDailySnapshots(LocalDateTime.now()) refreshDailySnapshots(LocalDateTime.now(ZoneOffset.UTC))
} }
@Transactional @Transactional
fun refreshDailySnapshots(now: LocalDateTime) { fun refreshDailySnapshots(now: LocalDateTime) {
val startedAt = System.currentTimeMillis() val startedAt = System.currentTimeMillis()
val snapshotAt = now val snapshotAt = windowPolicy.previousKstDayUtcWindow(now).snapshotAt
.atZone(UTC_ZONE)
.withZoneSameInstant(KST_ZONE)
.toLocalDate()
.minusDays(1)
.atTime(23, 59, 59)
val windowStart = snapshotAt.toLocalDate().minusDays(6).atStartOfDay()
runCatching { runCatching {
val aiCharacterCount = replaceAiCharacterSnapshots(windowStart, snapshotAt) val aiCharacterCount = refreshAiCharacterSnapshotsWithSectionLock(now)
val cheerCreatorCount = replaceCheerCreatorSnapshots(windowStart, snapshotAt) val cheerCreatorCount = refreshCheerCreatorSnapshotsWithSectionLock(now)
val popularCommunityCount = replacePopularCommunitySnapshots(windowStart, snapshotAt) val popularCommunityCount = refreshPopularCommunitySnapshotsWithSectionLock(now)
RefreshCounts(aiCharacterCount, cheerCreatorCount, popularCommunityCount) RefreshCounts(aiCharacterCount, cheerCreatorCount, popularCommunityCount)
}.onSuccess { counts -> }.onSuccess { counts ->
afterCommit { afterCommit {
@@ -69,24 +68,141 @@ class RecommendationSnapshotRefreshService(
} }
} }
private fun replaceAiCharacterSnapshots(windowStart: LocalDateTime, snapshotAt: LocalDateTime): Int { @Transactional
val snapshots = queryPort.findAiCharacterSnapshots(windowStart, snapshotAt, AI_CHARACTER_SNAPSHOT_LIMIT) open fun refreshAiCharacterSnapshots(nowUtc: LocalDateTime = LocalDateTime.now(ZoneOffset.UTC)): Int {
snapshotPort.replaceSnapshots(RecommendedSectionType.AI_CHARACTER, snapshotAt, snapshots) val startedAt = System.currentTimeMillis()
val window = windowPolicy.previousKstDayUtcWindow(nowUtc)
val snapshots = queryPort.findAiCharacterSnapshots(window.startUtc, window.endExclusiveUtc, AI_CHARACTER_SNAPSHOT_LIMIT)
snapshotPort.replaceSnapshots(RecommendedSectionType.AI_CHARACTER, window.snapshotAt, snapshots)
afterCommit {
log.info(
"event=ai_character_recommendation_snapshot_refresh_success " +
"snapshotAt={} windowStartUtc={} windowEndExclusiveUtc={} savedCount={} elapsedMs={}",
window.snapshotAt,
window.startUtc,
window.endExclusiveUtc,
snapshots.size,
System.currentTimeMillis() - startedAt
)
}
return snapshots.size return snapshots.size
} }
private fun replaceCheerCreatorSnapshots(windowStart: LocalDateTime, snapshotAt: LocalDateTime): Int { @Transactional
val snapshots = queryPort.findCheerCreatorSnapshots(windowStart, snapshotAt, CHEER_CREATOR_SNAPSHOT_LIMIT) open fun refreshCheerCreatorSnapshots(nowUtc: LocalDateTime = LocalDateTime.now(ZoneOffset.UTC)): Int {
snapshotPort.replaceSnapshots(RecommendedSectionType.CHEER_CREATOR, snapshotAt, snapshots) val startedAt = System.currentTimeMillis()
val window = windowPolicy.previousKstDayUtcWindow(nowUtc)
val snapshots = queryPort.findCheerCreatorSnapshots(
window.startUtc,
window.endExclusiveUtc,
CHEER_CREATOR_SNAPSHOT_LIMIT
)
snapshotPort.replaceSnapshots(RecommendedSectionType.CHEER_CREATOR, window.snapshotAt, snapshots)
afterCommit {
log.info(
"event=cheer_creator_recommendation_snapshot_refresh_success " +
"snapshotAt={} windowStartUtc={} windowEndExclusiveUtc={} savedCount={} elapsedMs={}",
window.snapshotAt,
window.startUtc,
window.endExclusiveUtc,
snapshots.size,
System.currentTimeMillis() - startedAt
)
}
return snapshots.size return snapshots.size
} }
private fun replacePopularCommunitySnapshots(windowStart: LocalDateTime, snapshotAt: LocalDateTime): Int { @Transactional
val snapshots = queryPort.findPopularCommunitySnapshots(windowStart, snapshotAt, POPULAR_COMMUNITY_SNAPSHOT_LIMIT) open fun refreshPopularCommunitySnapshots(nowUtc: LocalDateTime = LocalDateTime.now(ZoneOffset.UTC)): Int {
snapshotPort.replaceSnapshots(RecommendedSectionType.POPULAR_COMMUNITY, snapshotAt, snapshots) val startedAt = System.currentTimeMillis()
val window = windowPolicy.previousKstSevenDayUtcWindow(nowUtc)
val snapshots = queryPort.findPopularCommunitySnapshots(
window.startUtc,
window.endExclusiveUtc,
POPULAR_COMMUNITY_SNAPSHOT_LIMIT
)
snapshotPort.replaceSnapshots(RecommendedSectionType.POPULAR_COMMUNITY, window.snapshotAt, snapshots)
afterCommit {
log.info(
"event=popular_community_recommendation_snapshot_refresh_success " +
"snapshotAt={} windowStartUtc={} windowEndExclusiveUtc={} savedCount={} elapsedMs={}",
window.snapshotAt,
window.startUtc,
window.endExclusiveUtc,
snapshots.size,
System.currentTimeMillis() - startedAt
)
}
return snapshots.size return snapshots.size
} }
private fun refreshAiCharacterSnapshotsWithSectionLock(nowUtc: LocalDateTime): Int {
val client = redissonClient ?: return refreshAiCharacterSnapshots(nowUtc)
val lock = client.getLock(RecommendationSnapshotFallbackService.AI_CHARACTER_LOCK_KEY)
if (!lock.tryLock(0, -1, TimeUnit.MILLISECONDS)) {
log.info(
"event=ai_character_recommendation_snapshot_refresh_lock_missed lockKey={}",
RecommendationSnapshotFallbackService.AI_CHARACTER_LOCK_KEY
)
return 0
}
unlockAfterTransaction(lock)
return refreshAiCharacterSnapshots(nowUtc)
}
private fun refreshCheerCreatorSnapshotsWithSectionLock(nowUtc: LocalDateTime): Int {
val client = redissonClient ?: return refreshCheerCreatorSnapshots(nowUtc)
val lock = client.getLock(RecommendationSnapshotFallbackService.CHEER_CREATOR_LOCK_KEY)
if (!lock.tryLock(0, -1, TimeUnit.MILLISECONDS)) {
log.info(
"event=cheer_creator_recommendation_snapshot_refresh_lock_missed lockKey={}",
RecommendationSnapshotFallbackService.CHEER_CREATOR_LOCK_KEY
)
return 0
}
unlockAfterTransaction(lock)
return refreshCheerCreatorSnapshots(nowUtc)
}
private fun refreshPopularCommunitySnapshotsWithSectionLock(nowUtc: LocalDateTime): Int {
val client = redissonClient ?: return refreshPopularCommunitySnapshots(nowUtc)
val lock = client.getLock(RecommendationSnapshotFallbackService.POPULAR_COMMUNITY_LOCK_KEY)
if (!lock.tryLock(0, -1, TimeUnit.MILLISECONDS)) {
log.info(
"event=popular_community_recommendation_snapshot_refresh_lock_missed lockKey={}",
RecommendationSnapshotFallbackService.POPULAR_COMMUNITY_LOCK_KEY
)
return 0
}
unlockAfterTransaction(lock)
return refreshPopularCommunitySnapshots(nowUtc)
}
private fun unlockAfterTransaction(lock: org.redisson.api.RLock) {
if (!TransactionSynchronizationManager.isSynchronizationActive()) {
if (lock.isHeldByCurrentThread) {
lock.unlock()
}
return
}
TransactionSynchronizationManager.registerSynchronization(
object : TransactionSynchronization {
override fun afterCompletion(status: Int) {
if (lock.isHeldByCurrentThread) {
lock.unlock()
}
}
}
)
}
private data class RefreshCounts( private data class RefreshCounts(
val aiCharacterCount: Int, val aiCharacterCount: Int,
val cheerCreatorCount: Int, val cheerCreatorCount: Int,
@@ -109,7 +225,5 @@ class RecommendationSnapshotRefreshService(
private const val AI_CHARACTER_SNAPSHOT_LIMIT = 20 private const val AI_CHARACTER_SNAPSHOT_LIMIT = 20
private const val CHEER_CREATOR_SNAPSHOT_LIMIT = 16 private const val CHEER_CREATOR_SNAPSHOT_LIMIT = 16
private const val POPULAR_COMMUNITY_SNAPSHOT_LIMIT = 20 private const val POPULAR_COMMUNITY_SNAPSHOT_LIMIT = 20
private val UTC_ZONE: ZoneId = ZoneId.of("UTC")
private val KST_ZONE: ZoneId = ZoneId.of("Asia/Seoul")
} }
} }

View File

@@ -9,7 +9,33 @@ class RecommendationScorePolicy {
} }
fun calculateAiCharacterNewBoost(createdAt: LocalDateTime, now: LocalDateTime): Double { fun calculateAiCharacterNewBoost(createdAt: LocalDateTime, now: LocalDateTime): Double {
return calculateNewBoost(createdAt, now) val days = ChronoUnit.DAYS.between(createdAt.toLocalDate(), now.toLocalDate())
return when {
days <= RecommendationScoreSpec.NEW_BOOST_10_DAY_LIMIT -> RecommendationScoreSpec.AI_NEW_BOOST_10_DAYS
days <= RecommendationScoreSpec.NEW_BOOST_20_DAY_LIMIT -> RecommendationScoreSpec.AI_NEW_BOOST_20_DAYS
days <= RecommendationScoreSpec.NEW_BOOST_30_DAY_LIMIT -> RecommendationScoreSpec.AI_NEW_BOOST_30_DAYS
else -> RecommendationScoreSpec.DEFAULT_NEW_BOOST
}
}
fun calculateCheerCreatorNewBoost(debutAt: LocalDateTime, now: LocalDateTime): Double {
val days = ChronoUnit.DAYS.between(debutAt.toLocalDate(), now.toLocalDate())
return when {
days <= RecommendationScoreSpec.NEW_BOOST_10_DAY_LIMIT -> RecommendationScoreSpec.CHEER_NEW_BOOST_10_DAYS
days <= RecommendationScoreSpec.NEW_BOOST_20_DAY_LIMIT -> RecommendationScoreSpec.CHEER_NEW_BOOST_20_DAYS
days <= RecommendationScoreSpec.NEW_BOOST_30_DAY_LIMIT -> RecommendationScoreSpec.CHEER_NEW_BOOST_30_DAYS
else -> RecommendationScoreSpec.DEFAULT_NEW_BOOST
}
}
fun calculateCommunityCreatorNewBoost(debutAt: LocalDateTime, now: LocalDateTime): Double {
val days = ChronoUnit.DAYS.between(debutAt.toLocalDate(), now.toLocalDate())
return when {
days <= RecommendationScoreSpec.NEW_BOOST_10_DAY_LIMIT -> RecommendationScoreSpec.COMMUNITY_NEW_BOOST_10_DAYS
days <= RecommendationScoreSpec.NEW_BOOST_20_DAY_LIMIT -> RecommendationScoreSpec.COMMUNITY_NEW_BOOST_20_DAYS
days <= RecommendationScoreSpec.NEW_BOOST_30_DAY_LIMIT -> RecommendationScoreSpec.COMMUNITY_NEW_BOOST_30_DAYS
else -> RecommendationScoreSpec.DEFAULT_NEW_BOOST
}
} }
fun calculateDebutCreatorScore( fun calculateDebutCreatorScore(
@@ -28,11 +54,13 @@ class RecommendationScorePolicy {
fun calculateAiChatScore( fun calculateAiChatScore(
recentChatCount: Long, recentChatCount: Long,
recentActiveUserCount: Long, recentActiveUserCount: Long,
followIncreaseCount: Long,
newBoost: Double newBoost: Double
): Double { ): Double {
return ( return (
(RecommendationScoreSpec.AI_RECENT_CHAT_WEIGHT * recentChatCount) + (RecommendationScoreSpec.AI_CHAT_WEIGHT * recentChatCount) +
(RecommendationScoreSpec.AI_RECENT_ACTIVE_USER_WEIGHT * recentActiveUserCount) (RecommendationScoreSpec.AI_ACTIVE_USER_WEIGHT * recentActiveUserCount) +
(RecommendationScoreSpec.AI_FOLLOW_INCREASE_WEIGHT * followIncreaseCount)
) * newBoost ) * newBoost
} }

View File

@@ -9,19 +9,29 @@ object RecommendationScoreSpec {
const val DEBUT_CONTENT_ACTIVITY_WEIGHT = 0.3 const val DEBUT_CONTENT_ACTIVITY_WEIGHT = 0.3
const val DEBUT_COMMUNICATION_WEIGHT = 0.2 const val DEBUT_COMMUNICATION_WEIGHT = 0.2
const val AI_RECENT_CHAT_WEIGHT = 0.45 const val AI_CHAT_WEIGHT = 0.45
const val AI_RECENT_ACTIVE_USER_WEIGHT = 0.35 const val AI_ACTIVE_USER_WEIGHT = 0.35
const val AI_FOLLOW_INCREASE_WEIGHT = 0.20
const val CHEER_DONATION_AMOUNT_WEIGHT = 0.6 const val CHEER_DONATION_AMOUNT_WEIGHT = 0.45
const val CHEER_FAN_TALK_WEIGHT = 0.3 const val CHEER_FAN_TALK_WEIGHT = 0.3
const val CHEER_DONATION_COUNT_WEIGHT = 0.1 const val CHEER_DONATION_COUNT_WEIGHT = 0.1
const val COMMUNITY_LIKE_WEIGHT = 0.5 const val COMMUNITY_LIKE_WEIGHT = 0.5
const val COMMUNITY_COMMENT_WEIGHT = 0.5 const val COMMUNITY_COMMENT_WEIGHT = 0.4
const val COMMUNITY_FOLLOWER_WEIGHT = 0.1 const val COMMUNITY_FOLLOWER_WEIGHT = 0.1
const val NEW_BOOST_10_DAYS = 1.5 const val NEW_BOOST_10_DAYS = 1.5
const val NEW_BOOST_20_DAYS = 1.3 const val NEW_BOOST_20_DAYS = 1.3
const val NEW_BOOST_30_DAYS = 1.2 const val NEW_BOOST_30_DAYS = 1.2
const val AI_NEW_BOOST_10_DAYS = 1.15
const val AI_NEW_BOOST_20_DAYS = 1.10
const val AI_NEW_BOOST_30_DAYS = 1.05
const val CHEER_NEW_BOOST_10_DAYS = 1.15
const val CHEER_NEW_BOOST_20_DAYS = 1.10
const val CHEER_NEW_BOOST_30_DAYS = 1.05
const val COMMUNITY_NEW_BOOST_10_DAYS = 1.15
const val COMMUNITY_NEW_BOOST_20_DAYS = 1.10
const val COMMUNITY_NEW_BOOST_30_DAYS = 1.05
const val DEFAULT_NEW_BOOST = 1.0 const val DEFAULT_NEW_BOOST = 1.0
} }

View File

@@ -0,0 +1,46 @@
package kr.co.vividnext.sodalive.v2.recommendation.domain
import java.time.LocalDateTime
import java.time.ZoneId
class RecommendationSnapshotWindowPolicy {
fun previousKstDayUtcWindow(nowUtc: LocalDateTime): RecommendationSnapshotWindow {
val previousKstDate = nowUtc
.atZone(UTC_ZONE)
.withZoneSameInstant(KST_ZONE)
.toLocalDate()
.minusDays(1)
val startUtc = previousKstDate
.atStartOfDay(KST_ZONE)
.withZoneSameInstant(UTC_ZONE)
.toLocalDateTime()
val endExclusiveUtc = previousKstDate
.plusDays(1)
.atStartOfDay(KST_ZONE)
.withZoneSameInstant(UTC_ZONE)
.toLocalDateTime()
val snapshotAt = endExclusiveUtc.minusSeconds(1)
return RecommendationSnapshotWindow(
startUtc = startUtc,
endExclusiveUtc = endExclusiveUtc,
snapshotAt = snapshotAt
)
}
fun previousKstSevenDayUtcWindow(nowUtc: LocalDateTime): RecommendationSnapshotWindow {
val dayWindow = previousKstDayUtcWindow(nowUtc)
return dayWindow.copy(startUtc = dayWindow.startUtc.minusDays(6))
}
companion object {
private val UTC_ZONE: ZoneId = ZoneId.of("UTC")
private val KST_ZONE: ZoneId = ZoneId.of("Asia/Seoul")
}
}
data class RecommendationSnapshotWindow(
val startUtc: LocalDateTime,
val endExclusiveUtc: LocalDateTime,
val snapshotAt: LocalDateTime
)

View File

@@ -40,19 +40,19 @@ interface HomeRecommendationQueryPort {
fun findAiCharacterSnapshots( fun findAiCharacterSnapshots(
windowStart: LocalDateTime, windowStart: LocalDateTime,
snapshotAt: LocalDateTime, windowEndExclusive: LocalDateTime,
limit: Int limit: Int
): List<RecommendationSnapshotRecord> ): List<RecommendationSnapshotRecord>
fun findCheerCreatorSnapshots( fun findCheerCreatorSnapshots(
windowStart: LocalDateTime, windowStart: LocalDateTime,
snapshotAt: LocalDateTime, windowEndExclusive: LocalDateTime,
limit: Int limit: Int
): List<RecommendationSnapshotRecord> ): List<RecommendationSnapshotRecord>
fun findPopularCommunitySnapshots( fun findPopularCommunitySnapshots(
windowStart: LocalDateTime, windowStart: LocalDateTime,
snapshotAt: LocalDateTime, windowEndExclusive: LocalDateTime,
limit: Int limit: Int
): List<RecommendationSnapshotRecord> ): List<RecommendationSnapshotRecord>

View File

@@ -10,6 +10,17 @@ interface RecommendationSnapshotPort {
limit: Int = Int.MAX_VALUE limit: Int = Int.MAX_VALUE
): List<RecommendationSnapshotRecord> ): List<RecommendationSnapshotRecord>
fun findSnapshots(
sectionType: RecommendedSectionType,
snapshotAt: LocalDateTime,
offset: Long = 0,
limit: Int = Int.MAX_VALUE
): List<RecommendationSnapshotRecord>
fun existsLatestSnapshot(sectionType: RecommendedSectionType): Boolean
fun existsSnapshot(sectionType: RecommendedSectionType, snapshotAt: LocalDateTime): Boolean
fun replaceSnapshots( fun replaceSnapshots(
sectionType: RecommendedSectionType, sectionType: RecommendedSectionType,
snapshotAt: LocalDateTime, snapshotAt: LocalDateTime,

View File

@@ -0,0 +1,141 @@
package kr.co.vividnext.sodalive.explorer
import com.fasterxml.jackson.databind.ObjectMapper
import kr.co.vividnext.sodalive.common.CountryContext
import kr.co.vividnext.sodalive.explorer.profile.PostWriteCheersRequest
import kr.co.vividnext.sodalive.explorer.profile.PutWriteCheersRequest
import kr.co.vividnext.sodalive.i18n.LangContext
import kr.co.vividnext.sodalive.i18n.SodaMessageSource
import kr.co.vividnext.sodalive.member.Member
import kr.co.vividnext.sodalive.member.MemberAdapter
import kr.co.vividnext.sodalive.member.MemberRole
import kr.co.vividnext.sodalive.v2.api.creator.channel.fantalk.dto.CreatorChannelFanTalkResponse
import org.junit.jupiter.api.DisplayName
import org.junit.jupiter.api.Test
import org.mockito.Mockito
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest
import org.springframework.boot.test.context.TestConfiguration
import org.springframework.boot.test.mock.mockito.MockBean
import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Import
import org.springframework.http.HttpStatus
import org.springframework.http.MediaType
import org.springframework.security.config.annotation.web.builders.HttpSecurity
import org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.user
import org.springframework.security.web.SecurityFilterChain
import org.springframework.security.web.authentication.HttpStatusEntryPoint
import org.springframework.test.web.servlet.MockMvc
import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post
import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put
import org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath
import org.springframework.test.web.servlet.result.MockMvcResultMatchers.status
import javax.servlet.http.HttpServletResponse
@WebMvcTest(ExplorerController::class)
@Import(ExplorerControllerTest.TestSecurityConfig::class)
class ExplorerControllerTest @Autowired constructor(
private val mockMvc: MockMvc,
private val objectMapper: ObjectMapper
) {
@MockBean
private lateinit var service: ExplorerService
@MockBean
private lateinit var messageSource: SodaMessageSource
@MockBean
private lateinit var langContext: LangContext
@MockBean
private lateinit var countryContext: CountryContext
@TestConfiguration
class TestSecurityConfig {
@Bean
fun securityFilterChain(http: HttpSecurity): SecurityFilterChain {
return http
.csrf().disable()
.authorizeRequests()
.anyRequest().authenticated()
.and()
.exceptionHandling()
.authenticationEntryPoint(HttpStatusEntryPoint(HttpStatus.UNAUTHORIZED))
.accessDeniedHandler { _, response, _ -> response.sendError(HttpServletResponse.SC_FORBIDDEN) }
.and()
.build()
}
}
@Test
@DisplayName("팬톡 작성 API는 저장된 팬톡 데이터를 data에 반환한다")
fun shouldReturnCreatedFanTalkData() {
val member = createMember(id = 10L)
val request = PostWriteCheersRequest(creatorId = 1L, content = "created fan talk", languageCode = "ko")
Mockito.doReturn(createFanTalkResponse(content = "created fan talk"))
.`when`(service).writeCheers(eqValue(request), eqValue(member))
mockMvc.perform(
post("/explorer/profile/cheers")
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(request))
.with(user(MemberAdapter(member)))
)
.andExpect(status().isOk)
.andExpect(jsonPath("$.success").value(true))
.andExpect(jsonPath("$.data.fanTalkId").value(101))
.andExpect(jsonPath("$.data.writerId").value(10))
.andExpect(jsonPath("$.data.writerNickname").value("fan"))
.andExpect(jsonPath("$.data.writerProfileImageUrl").value("https://cdn.test/profile/fan.png"))
.andExpect(jsonPath("$.data.content").value("created fan talk"))
.andExpect(jsonPath("$.data.createdAtUtc").value("2026-07-09T01:00:00Z"))
.andExpect(jsonPath("$.data.creatorReplies").isArray)
.andExpect(jsonPath("$.data.creatorReplies.length()").value(0))
}
@Test
@DisplayName("팬톡 수정 API는 수정된 팬톡 데이터를 data에 반환한다")
fun shouldReturnModifiedFanTalkData() {
val member = createMember(id = 10L)
val request = PutWriteCheersRequest(cheersId = 101L, content = "modified fan talk")
Mockito.doReturn(createFanTalkResponse(content = "modified fan talk"))
.`when`(service).modifyCheers(eqValue(request), eqValue(member))
mockMvc.perform(
put("/explorer/profile/cheers")
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(request))
.with(user(MemberAdapter(member)))
)
.andExpect(status().isOk)
.andExpect(jsonPath("$.success").value(true))
.andExpect(jsonPath("$.data.fanTalkId").value(101))
.andExpect(jsonPath("$.data.content").value("modified fan talk"))
.andExpect(jsonPath("$.data.creatorReplies.length()").value(0))
}
private fun createMember(id: Long): Member {
return Member(
email = "user$id@test.com",
password = "password",
nickname = "user$id",
role = MemberRole.USER
).apply { this.id = id }
}
private fun createFanTalkResponse(content: String): CreatorChannelFanTalkResponse {
return CreatorChannelFanTalkResponse(
fanTalkId = 101L,
writerId = 10L,
writerNickname = "fan",
writerProfileImageUrl = "https://cdn.test/profile/fan.png",
content = content,
createdAtUtc = "2026-07-09T01:00:00Z",
creatorReplies = emptyList()
)
}
private fun <T> eqValue(value: T): T {
return Mockito.eq(value) ?: value
}
}

View File

@@ -0,0 +1,86 @@
package kr.co.vividnext.sodalive.explorer
import kr.co.vividnext.sodalive.common.SodaException
import kr.co.vividnext.sodalive.content.AudioContentService
import kr.co.vividnext.sodalive.content.series.ContentSeriesService
import kr.co.vividnext.sodalive.content.translation.ContentTranslationRepository
import kr.co.vividnext.sodalive.explorer.profile.ChannelNoticeRepository
import kr.co.vividnext.sodalive.explorer.profile.CreatorCheers
import kr.co.vividnext.sodalive.explorer.profile.CreatorCheersRepository
import kr.co.vividnext.sodalive.explorer.profile.CreatorDonationRankingService
import kr.co.vividnext.sodalive.explorer.profile.PutWriteCheersRequest
import kr.co.vividnext.sodalive.explorer.profile.channelDonation.ChannelDonationService
import kr.co.vividnext.sodalive.explorer.profile.creatorCommunity.CreatorCommunityService
import kr.co.vividnext.sodalive.i18n.LangContext
import kr.co.vividnext.sodalive.i18n.SodaMessageSource
import kr.co.vividnext.sodalive.member.Member
import kr.co.vividnext.sodalive.member.MemberRole
import kr.co.vividnext.sodalive.member.MemberService
import kr.co.vividnext.sodalive.member.contentpreference.MemberContentPreferenceService
import org.junit.jupiter.api.Assertions.assertThrows
import org.junit.jupiter.api.DisplayName
import org.junit.jupiter.api.Test
import org.mockito.Mockito
import org.springframework.context.ApplicationEventPublisher
import java.time.LocalDateTime
class ExplorerServiceTest {
@Test
@DisplayName("팬톡 수정은 작성자나 크리에이터가 아닌 요청자에게 데이터를 반환하지 않는다")
fun shouldRejectModifyCheersForNonWriterAndNonCreator() {
val queryRepository = Mockito.mock(ExplorerQueryRepository::class.java)
val service = createService(queryRepository)
val cheers = createCheers(
writer = createMember(id = 10L, nickname = "writer"),
creator = createMember(id = 20L, nickname = "creator", role = MemberRole.CREATOR)
)
val requester = createMember(id = 30L, nickname = "requester")
Mockito.doReturn(cheers).`when`(queryRepository).getCheers(101L)
assertThrows(SodaException::class.java) {
service.modifyCheers(PutWriteCheersRequest(cheersId = 101L, content = "stolen"), requester)
}
}
private fun createService(queryRepository: ExplorerQueryRepository): ExplorerService {
return ExplorerService(
memberService = Mockito.mock(MemberService::class.java),
memberContentPreferenceService = Mockito.mock(MemberContentPreferenceService::class.java),
audioContentService = Mockito.mock(AudioContentService::class.java),
donationRankingService = Mockito.mock(CreatorDonationRankingService::class.java),
queryRepository = queryRepository,
cheersRepository = Mockito.mock(CreatorCheersRepository::class.java),
noticeRepository = Mockito.mock(ChannelNoticeRepository::class.java),
channelDonationService = Mockito.mock(ChannelDonationService::class.java),
communityService = Mockito.mock(CreatorCommunityService::class.java),
seriesService = Mockito.mock(ContentSeriesService::class.java),
applicationEventPublisher = Mockito.mock(ApplicationEventPublisher::class.java),
contentTranslationRepository = Mockito.mock(ContentTranslationRepository::class.java),
messageSource = Mockito.mock(SodaMessageSource::class.java),
langContext = Mockito.mock(LangContext::class.java),
cloudFrontHost = "https://cdn.test"
)
}
private fun createCheers(writer: Member, creator: Member): CreatorCheers {
return CreatorCheers(cheers = "fan talk", languageCode = "ko").apply {
id = 101L
member = writer
this.creator = creator
createdAt = LocalDateTime.of(2026, 7, 9, 1, 0)
}
}
private fun createMember(
id: Long,
nickname: String,
role: MemberRole = MemberRole.USER
): Member {
return Member(
email = "$nickname@test.com",
password = "password",
nickname = nickname,
role = role
).apply { this.id = id }
}
}

View File

@@ -68,6 +68,32 @@ class MainContentAllEndToEndTest @Autowired constructor(
.andExpect(jsonPath("$.data.hasNext").value(false)) .andExpect(jsonPath("$.data.hasNext").value(false))
} }
@Test
@DisplayName("전체 탭 AUDIO API는 명시적 AUDIO와 기본 type 조회에서 무료 오디오를 제외한다")
fun shouldExcludeFreeAudioContentsForAudioTypeAndDefaultType() {
val fixture = createAudioFixture("main-all-audio-paid-e2e", freeControl = true)
mockMvc.perform(
get("/api/v2/audio/contents")
.param("type", "AUDIO")
.param("sort", "LATEST")
)
.andExpect(status().isOk)
.andExpect(jsonPath("$.data.type").value("AUDIO"))
.andExpect(jsonPath("$.data.totalCount").value(1))
.andExpect(jsonPath("$.data.audios[0].audioContentId").value(fixture.audioContentId))
.andExpect(jsonPath("$.data.audios[0].price").value(100))
.andExpect(jsonPath("$.data.audios.length()").value(1))
mockMvc.perform(get("/api/v2/audio/contents"))
.andExpect(status().isOk)
.andExpect(jsonPath("$.data.type").value("AUDIO"))
.andExpect(jsonPath("$.data.totalCount").value(1))
.andExpect(jsonPath("$.data.audios[0].audioContentId").value(fixture.audioContentId))
.andExpect(jsonPath("$.data.audios[0].price").value(100))
.andExpect(jsonPath("$.data.audios.length()").value(1))
}
@Test @Test
@DisplayName("전체 탭 SERIES API는 dayOfWeek 조건으로 시리즈 응답과 빈 audios를 반환한다") @DisplayName("전체 탭 SERIES API는 dayOfWeek 조건으로 시리즈 응답과 빈 audios를 반환한다")
fun shouldReturnSeriesContentsFilteredByDayOfWeekThroughControllerServiceAndRepository() { fun shouldReturnSeriesContentsFilteredByDayOfWeekThroughControllerServiceAndRepository() {
@@ -124,7 +150,7 @@ class MainContentAllEndToEndTest @Autowired constructor(
.andExpect(jsonPath("$.data.hasNext").value(false)) .andExpect(jsonPath("$.data.hasNext").value(false))
} }
private fun createAudioFixture(prefix: String): AudioFixture { private fun createAudioFixture(prefix: String, freeControl: Boolean = false): AudioFixture {
return transactionTemplate.execute { return transactionTemplate.execute {
val creator = saveMember("$prefix-creator", MemberRole.CREATOR) val creator = saveMember("$prefix-creator", MemberRole.CREATOR)
val theme = saveTheme("$prefix-theme") val theme = saveTheme("$prefix-theme")
@@ -136,6 +162,16 @@ class MainContentAllEndToEndTest @Autowired constructor(
releaseDate = LocalDateTime.now().minusHours(1), releaseDate = LocalDateTime.now().minusHours(1),
price = 100 price = 100
) )
if (freeControl) {
saveAudioContent(
creator = creator,
theme = theme,
title = "$prefix-free-audio",
coverImage = "$prefix-free-audio.png",
releaseDate = LocalDateTime.now().minusMinutes(30),
price = 0
)
}
entityManager.flush() entityManager.flush()
entityManager.clear() entityManager.clear()

View File

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

View File

@@ -243,6 +243,22 @@ class HomeRecommendationControllerTest @Autowired constructor(
.andExpect(jsonPath("$.data.lives").isArray) .andExpect(jsonPath("$.data.lives").isArray)
} }
@Test
@DisplayName("메인 홈 통합 조회는 AI 캐릭터를 최신 스냅샷 기준 최대 20개까지 반환한다")
fun shouldReturnTwentyAiCharactersOnHomeRecommendations() {
(1..22).forEach { index ->
val character = saveAiCharacter("home-ai-character-$index")
saveRecommendationSnapshot(character.id!!)
}
entityManager.flush()
entityManager.clear()
mockMvc.perform(get("/api/v2/home/recommendations"))
.andExpect(status().isOk)
.andExpect(jsonPath("$.success").value(true))
.andExpect(jsonPath("$.data.aiCharacters.length()").value(20))
}
@Test @Test
@DisplayName("메인 홈 통합 조회 실패는 응답 시간과 함께 로그로 관측된다") @DisplayName("메인 홈 통합 조회 실패는 응답 시간과 함께 로그로 관측된다")
fun shouldLogHomeRecommendationFailure(output: CapturedOutput) { fun shouldLogHomeRecommendationFailure(output: CapturedOutput) {
@@ -574,7 +590,7 @@ class HomeRecommendationControllerTest @Autowired constructor(
sectionType = RecommendedSectionType.AI_CHARACTER, sectionType = RecommendedSectionType.AI_CHARACTER,
targetId = characterId, targetId = characterId,
score = 100.0, score = 100.0,
snapshotAt = LocalDateTime.of(2026, 6, 1, 23, 59, 59), snapshotAt = LocalDateTime.of(2026, 12, 31, 23, 59, 59),
randomTieBreaker = 0.1 randomTieBreaker = 0.1
) )
) )

View File

@@ -62,11 +62,14 @@ class DefaultMainContentAllQueryRepositoryTest @Autowired constructor(
flushAndClear() flushAndClear()
val visible = repository.findAudios(viewer.id, canViewAdultContent = false, now, ContentSort.LATEST, 0, 20) val visible = repository.findAudios(viewer.id, canViewAdultContent = false, now, ContentSort.LATEST, 0, 20)
val paidAudios = repository.findAudios(viewer.id, false, now, ContentSort.LATEST, 0, 20, onlyPaid = true)
val freeAudios = repository.findAudios(null, false, now, ContentSort.LATEST, 0, 20, onlyFree = true) val freeAudios = repository.findAudios(null, false, now, ContentSort.LATEST, 0, 20, onlyFree = true)
val pointAudios = repository.findAudios(null, false, now, ContentSort.LATEST, 0, 20, onlyPointAvailable = true) val pointAudios = repository.findAudios(null, false, now, ContentSort.LATEST, 0, 20, onlyPointAvailable = true)
assertEquals(2, repository.countAudios(viewer.id, canViewAdultContent = false, now)) assertEquals(2, repository.countAudios(viewer.id, canViewAdultContent = false, now))
assertEquals(1, repository.countAudios(viewer.id, false, now, onlyPaid = true))
assertEquals(listOf(point.id, free.id), visible.map { it.audioContentId }) assertEquals(listOf(point.id, free.id), visible.map { it.audioContentId })
assertEquals(listOf(point.id), paidAudios.map { it.audioContentId })
assertEquals(listOf(free.id), freeAudios.map { it.audioContentId }) assertEquals(listOf(free.id), freeAudios.map { it.audioContentId })
assertEquals(listOf(point.id), pointAudios.map { it.audioContentId }) assertEquals(listOf(point.id), pointAudios.map { it.audioContentId })
assertEquals("https://cdn.test/audio.png", visible.first().imageUrl) assertEquals("https://cdn.test/audio.png", visible.first().imageUrl)

View File

@@ -34,6 +34,7 @@ class MainContentAllQueryServiceTest {
assertEquals(ContentSort.POPULAR, port.lastSort) assertEquals(ContentSort.POPULAR, port.lastSort)
assertEquals(20L, port.lastOffset) assertEquals(20L, port.lastOffset)
assertEquals(21, port.lastLimit) assertEquals(21, port.lastLimit)
assertTrue(port.lastOnlyPaid)
assertFalse(port.lastOnlyFree) assertFalse(port.lastOnlyFree)
assertFalse(port.lastOnlyPointAvailable) assertFalse(port.lastOnlyPointAvailable)
assertEquals(20, tab.audios.size) assertEquals(20, tab.audios.size)
@@ -52,6 +53,7 @@ class MainContentAllQueryServiceTest {
assertEquals(MainContentAllType.FREE, tab.type) assertEquals(MainContentAllType.FREE, tab.type)
assertEquals("audio", port.lastListKind) assertEquals("audio", port.lastListKind)
assertFalse(port.lastOnlyPaid)
assertTrue(port.lastOnlyFree) assertTrue(port.lastOnlyFree)
assertFalse(port.lastOnlyPointAvailable) assertFalse(port.lastOnlyPointAvailable)
assertEquals(21, port.lastLimit) assertEquals(21, port.lastLimit)
@@ -71,6 +73,7 @@ class MainContentAllQueryServiceTest {
assertEquals(MainContentAllType.POINT, tab.type) assertEquals(MainContentAllType.POINT, tab.type)
assertEquals("audio", port.lastListKind) assertEquals("audio", port.lastListKind)
assertEquals(ContentSort.PRICE_LOW, port.lastSort) assertEquals(ContentSort.PRICE_LOW, port.lastSort)
assertFalse(port.lastOnlyPaid)
assertFalse(port.lastOnlyFree) assertFalse(port.lastOnlyFree)
assertTrue(port.lastOnlyPointAvailable) assertTrue(port.lastOnlyPointAvailable)
assertEquals(21, port.lastLimit) assertEquals(21, port.lastLimit)
@@ -153,6 +156,7 @@ private class FakeMainContentAllQueryPort : MainContentAllQueryPort {
var lastSort: ContentSort? = null var lastSort: ContentSort? = null
var lastOffset: Long? = null var lastOffset: Long? = null
var lastLimit: Int? = null var lastLimit: Int? = null
var lastOnlyPaid: Boolean = false
var lastOnlyFree: Boolean = false var lastOnlyFree: Boolean = false
var lastOnlyPointAvailable: Boolean = false var lastOnlyPointAvailable: Boolean = false
var lastOnlyOriginal: Boolean = false var lastOnlyOriginal: Boolean = false
@@ -163,11 +167,13 @@ private class FakeMainContentAllQueryPort : MainContentAllQueryPort {
memberId: Long?, memberId: Long?,
canViewAdultContent: Boolean, canViewAdultContent: Boolean,
now: LocalDateTime, now: LocalDateTime,
onlyPaid: Boolean,
onlyFree: Boolean, onlyFree: Boolean,
onlyPointAvailable: Boolean onlyPointAvailable: Boolean
): Int { ): Int {
lastMemberId = memberId lastMemberId = memberId
lastCanViewAdultContent = canViewAdultContent lastCanViewAdultContent = canViewAdultContent
lastOnlyPaid = onlyPaid
lastOnlyFree = onlyFree lastOnlyFree = onlyFree
lastOnlyPointAvailable = onlyPointAvailable lastOnlyPointAvailable = onlyPointAvailable
return 30 return 30
@@ -180,6 +186,7 @@ private class FakeMainContentAllQueryPort : MainContentAllQueryPort {
sort: ContentSort, sort: ContentSort,
offset: Long, offset: Long,
limit: Int, limit: Int,
onlyPaid: Boolean,
onlyFree: Boolean, onlyFree: Boolean,
onlyPointAvailable: Boolean onlyPointAvailable: Boolean
): List<MainContentAllAudio> { ): List<MainContentAllAudio> {
@@ -189,6 +196,7 @@ private class FakeMainContentAllQueryPort : MainContentAllQueryPort {
lastSort = sort lastSort = sort
lastOffset = offset lastOffset = offset
lastLimit = limit lastLimit = limit
lastOnlyPaid = onlyPaid
lastOnlyFree = onlyFree lastOnlyFree = onlyFree
lastOnlyPointAvailable = onlyPointAvailable lastOnlyPointAvailable = onlyPointAvailable
return (1L..limit.toLong()).map { id -> return (1L..limit.toLong()).map { id ->

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -434,7 +434,32 @@ class DefaultHomeRecommendationQueryRepositoryTest @Autowired constructor(
assertEquals(CreatorActivityType.LIVE_REPLAY, byCreatorNickname[replayCreator.nickname]!!.activityType) assertEquals(CreatorActivityType.LIVE_REPLAY, byCreatorNickname[replayCreator.nickname]!!.activityType)
assertEquals(replay.id, byCreatorNickname[replayCreator.nickname]!!.targetId) assertEquals(replay.id, byCreatorNickname[replayCreator.nickname]!!.targetId)
assertEquals(CreatorActivityType.COMMUNITY, byCreatorNickname[communityCreator.nickname]!!.activityType) assertEquals(CreatorActivityType.COMMUNITY, byCreatorNickname[communityCreator.nickname]!!.activityType)
assertEquals(communityCreator.id, byCreatorNickname[communityCreator.nickname]!!.targetId) assertEquals(community.id, byCreatorNickname[communityCreator.nickname]!!.targetId)
}
@Test
@DisplayName("최근 활동 크리에이터는 라이브 활성 여부와 무관하게 채널명이 있는 라이브를 포함한다")
fun shouldIncludeInactiveLiveWithChannelNameInRecentlyActiveCreators() {
val baseAt = LocalDateTime.of(2026, 5, 31, 10, 0)
val inactiveLiveCreator = saveMember("activity-inactive-live", MemberRole.CREATOR)
val blankChannelCreator = saveMember("activity-blank-channel", MemberRole.CREATOR)
val emptyChannelCreator = saveMember("activity-empty-channel", MemberRole.CREATOR)
saveLiveRoom(
inactiveLiveCreator,
baseAt.plusMinutes(2),
channelName = "inactive-live-channel",
isActive = false
)
saveLiveRoom(blankChannelCreator, baseAt.plusMinutes(1), channelName = null, isActive = false)
saveLiveRoom(emptyChannelCreator, baseAt, channelName = "", isActive = false)
flushAndClear()
val creators = repository.findRecentlyActiveCreators(limit = 10)
assertEquals(listOf(inactiveLiveCreator.nickname), creators.map { it.creatorNickname })
assertEquals(CreatorActivityType.LIVE, creators.single().activityType)
assertEquals(baseAt.plusMinutes(2), creators.single().activityAt)
assertEquals(null, creators.single().targetId)
} }
@Test @Test
@@ -468,7 +493,7 @@ class DefaultHomeRecommendationQueryRepositoryTest @Autowired constructor(
assertEquals(null, visibleCreators[0].targetId) assertEquals(null, visibleCreators[0].targetId)
assertEquals(null, visibleCreators[1].targetId) assertEquals(null, visibleCreators[1].targetId)
assertEquals(adultAudio.id, visibleCreators[2].targetId) assertEquals(adultAudio.id, visibleCreators[2].targetId)
assertEquals(adultCommunityCreator.id, visibleCreators[3].targetId) assertEquals(adultCommunity.id, visibleCreators[3].targetId)
assertEquals(CreatorActivityType.LIVE, visibleCreators[0].activityType) assertEquals(CreatorActivityType.LIVE, visibleCreators[0].activityType)
assertEquals(CreatorActivityType.LIVE, visibleCreators[1].activityType) assertEquals(CreatorActivityType.LIVE, visibleCreators[1].activityType)
assertEquals(CreatorActivityType.AUDIO, visibleCreators[2].activityType) assertEquals(CreatorActivityType.AUDIO, visibleCreators[2].activityType)
@@ -498,12 +523,15 @@ class DefaultHomeRecommendationQueryRepositoryTest @Autowired constructor(
} }
@Test @Test
@DisplayName("AI 캐릭터 스냅샷은 AI 발화 수 중복 없는 활성 사용자 수를 집계하고 팔로우 증가량은 제외한다") @DisplayName("AI 캐릭터 스냅샷은 전날 AI 발화 수, 중복 없는 활성 사용자 수, 신규 팔로우 증가량을 집계한다")
fun shouldFindAiCharacterSnapshotsWithoutFollowIncrease() { fun shouldFindAiCharacterSnapshotsWithFollowIncrease() {
val windowStart = LocalDateTime.of(2026, 5, 23, 0, 0) val windowStart = LocalDateTime.of(2026, 7, 8, 15, 0)
val snapshotAt = LocalDateTime.of(2026, 5, 29, 23, 59, 59) val snapshotAt = LocalDateTime.of(2026, 7, 9, 14, 59, 59)
val windowEndExclusive = snapshotAt.plusSeconds(1)
val user1 = saveMember("ai-user-1", MemberRole.USER) val user1 = saveMember("ai-user-1", MemberRole.USER)
val user2 = saveMember("ai-user-2", MemberRole.USER) val user2 = saveMember("ai-user-2", MemberRole.USER)
val inactiveFollower = saveMember("ai-inactive-follower", MemberRole.USER)
val inactiveMemberFollower = saveMember("ai-inactive-member-follower", MemberRole.USER, isActive = false)
val character = saveCharacter("character-1", isActive = true) val character = saveCharacter("character-1", isActive = true)
val inactiveCharacter = saveCharacter("character-2", isActive = false) val inactiveCharacter = saveCharacter("character-2", isActive = false)
val room = saveChatRoom("room-1") val room = saveChatRoom("room-1")
@@ -522,23 +550,32 @@ class DefaultHomeRecommendationQueryRepositoryTest @Autowired constructor(
val inactiveMessage = saveMessage(room, characterParticipant, "message-4", isActive = false) val inactiveMessage = saveMessage(room, characterParticipant, "message-4", isActive = false)
val oldMessage = saveMessage(room, characterParticipant, "message-5", isActive = true) val oldMessage = saveMessage(room, characterParticipant, "message-5", isActive = true)
val inactiveCharacterMessage = saveMessage(otherRoom, inactiveCharacterUser, "message-6", isActive = true) val inactiveCharacterMessage = saveMessage(otherRoom, inactiveCharacterUser, "message-6", isActive = true)
updateCreatedAt("ChatMessage", message1.id!!, windowStart.plusDays(1)) updateCreatedAt("ChatMessage", message1.id!!, windowStart.plusHours(1))
updateCreatedAt("ChatMessage", message2.id!!, windowStart.plusDays(2)) updateCreatedAt("ChatMessage", message2.id!!, windowStart.plusHours(2))
updateCreatedAt("ChatMessage", message3.id!!, snapshotAt) updateCreatedAt("ChatMessage", message3.id!!, snapshotAt)
updateCreatedAt("ChatMessage", characterMessage1.id!!, windowStart.plusDays(1)) updateCreatedAt("ChatMessage", characterMessage1.id!!, windowStart.plusHours(1))
updateCreatedAt("ChatMessage", characterMessage2.id!!, snapshotAt) updateCreatedAt("ChatMessage", characterMessage2.id!!, snapshotAt)
updateCreatedAt("ChatMessage", inactiveMessage.id!!, windowStart.plusDays(3)) updateCreatedAt("ChatMessage", inactiveMessage.id!!, windowStart.plusHours(3))
updateCreatedAt("ChatMessage", oldMessage.id!!, windowStart.minusSeconds(1)) updateCreatedAt("ChatMessage", oldMessage.id!!, windowStart.minusSeconds(1))
updateCreatedAt("ChatMessage", inactiveCharacterMessage.id!!, windowStart.plusDays(1)) updateCreatedAt("ChatMessage", inactiveCharacterMessage.id!!, windowStart.plusHours(1))
updateCreatedAt("ChatCharacter", character.id!!, LocalDateTime.of(2026, 5, 20, 12, 0)) val follow1 = saveFollowing(user1, character.creatorMember!!, isActive = true)
val follow2 = saveFollowing(user2, character.creatorMember!!, isActive = true)
val inactiveFollow = saveFollowing(inactiveFollower, character.creatorMember!!, isActive = false)
val inactiveMemberFollow = saveFollowing(inactiveMemberFollower, character.creatorMember!!, isActive = true)
updateCreatedAt("CreatorFollowing", follow1.id!!, windowStart.plusHours(1))
updateCreatedAt("CreatorFollowing", follow2.id!!, snapshotAt)
updateCreatedAt("CreatorFollowing", inactiveFollow.id!!, windowStart.plusHours(1))
updateCreatedAt("CreatorFollowing", inactiveMemberFollow.id!!, windowStart.plusHours(1))
updateCreatedAt("ChatCharacter", character.id!!, LocalDateTime.of(2026, 7, 1, 12, 0))
flushAndClear() flushAndClear()
val snapshots = repository.findAiCharacterSnapshots(windowStart, snapshotAt, limit = 10) val snapshots = repository.findAiCharacterSnapshots(windowStart, windowEndExclusive, limit = 10)
val expectedScore = scorePolicy.calculateAiChatScore( val expectedScore = scorePolicy.calculateAiChatScore(
recentChatCount = 2, recentChatCount = 2,
recentActiveUserCount = 2, recentActiveUserCount = 2,
newBoost = scorePolicy.calculateAiCharacterNewBoost(LocalDateTime.of(2026, 5, 20, 12, 0), snapshotAt) followIncreaseCount = 2,
newBoost = scorePolicy.calculateAiCharacterNewBoost(LocalDateTime.of(2026, 7, 1, 12, 0), snapshotAt)
) )
assertEquals(1, snapshots.size) assertEquals(1, snapshots.size)
assertEquals(RecommendedSectionType.AI_CHARACTER, snapshots.single().sectionType) assertEquals(RecommendedSectionType.AI_CHARACTER, snapshots.single().sectionType)
@@ -547,11 +584,75 @@ class DefaultHomeRecommendationQueryRepositoryTest @Autowired constructor(
assertEquals(snapshotAt, snapshots.single().snapshotAt) assertEquals(snapshotAt, snapshots.single().snapshotAt)
} }
@Test
@DisplayName("AI 캐릭터 스냅샷은 종료 시각을 exclusive 경계로 집계한다")
fun shouldFindAiCharacterSnapshotsWithExclusiveWindowEnd() {
val windowStart = LocalDateTime.of(2026, 7, 8, 15, 0)
val windowEndExclusive = LocalDateTime.of(2026, 7, 9, 15, 0)
val snapshotAt = windowEndExclusive.minusSeconds(1)
val includedUser = saveMember("ai-window-included-user", MemberRole.USER)
val excludedUser = saveMember("ai-window-excluded-user", MemberRole.USER)
val character = saveCharacter("ai-window-character", isActive = true)
val excludedCharacter = saveCharacter("ai-window-excluded-character", isActive = true)
val room = saveChatRoom("ai-window-room")
val excludedRoom = saveChatRoom("ai-window-excluded-room")
val includedUserParticipant = saveParticipant(room, ParticipantType.USER, member = includedUser)
val includedCharacterParticipant = saveParticipant(room, ParticipantType.CHARACTER, character = character)
val excludedUserParticipant = saveParticipant(excludedRoom, ParticipantType.USER, member = excludedUser)
val excludedCharacterParticipant = saveParticipant(excludedRoom, ParticipantType.CHARACTER, character = excludedCharacter)
updateCreatedAt(
"ChatMessage",
saveMessage(room, includedUserParticipant, "included-user", true).id!!,
windowEndExclusive.minusSeconds(1)
)
updateCreatedAt(
"ChatMessage",
saveMessage(room, includedCharacterParticipant, "included-character", true).id!!,
windowEndExclusive.minusSeconds(1)
)
updateCreatedAt(
"ChatMessage",
saveMessage(excludedRoom, excludedUserParticipant, "excluded-user", true).id!!,
windowEndExclusive
)
updateCreatedAt(
"ChatMessage",
saveMessage(excludedRoom, excludedCharacterParticipant, "excluded-character", true).id!!,
windowEndExclusive
)
updateCreatedAt(
"CreatorFollowing",
saveFollowing(includedUser, character.creatorMember!!, isActive = true).id!!,
windowEndExclusive.minusSeconds(1)
)
updateCreatedAt(
"CreatorFollowing",
saveFollowing(excludedUser, excludedCharacter.creatorMember!!, isActive = true).id!!,
windowEndExclusive
)
updateCreatedAt("ChatCharacter", character.id!!, windowStart)
updateCreatedAt("ChatCharacter", excludedCharacter.id!!, windowEndExclusive)
flushAndClear()
val snapshots = repository.findAiCharacterSnapshots(windowStart, windowEndExclusive, limit = 10)
val expectedScore = scorePolicy.calculateAiChatScore(
recentChatCount = 1,
recentActiveUserCount = 1,
followIncreaseCount = 1,
newBoost = scorePolicy.calculateAiCharacterNewBoost(windowStart, snapshotAt)
)
assertEquals(listOf(character.id), snapshots.map { it.targetId })
assertEquals(expectedScore, snapshots.single().score, 0.0001)
assertEquals(snapshotAt, snapshots.single().snapshotAt)
}
@Test @Test
@DisplayName("AI 캐릭터 스냅샷은 DB에서 최종 점수를 계산한 뒤 정렬하고 limit을 적용한다") @DisplayName("AI 캐릭터 스냅샷은 DB에서 최종 점수를 계산한 뒤 정렬하고 limit을 적용한다")
fun shouldFindAiCharacterSnapshotsWithDbScoreOrderAndLimit() { fun shouldFindAiCharacterSnapshotsWithDbScoreOrderAndLimit() {
val windowStart = LocalDateTime.of(2026, 5, 23, 0, 0) val windowStart = LocalDateTime.of(2026, 5, 23, 0, 0)
val snapshotAt = LocalDateTime.of(2026, 5, 29, 23, 59, 59) val snapshotAt = LocalDateTime.of(2026, 5, 29, 23, 59, 59)
val windowEndExclusive = snapshotAt.plusSeconds(1)
val user = saveMember("ai-score-user", MemberRole.USER) val user = saveMember("ai-score-user", MemberRole.USER)
val oldHighActivityCharacter = saveCharacter("old-high-activity", isActive = true) val oldHighActivityCharacter = saveCharacter("old-high-activity", isActive = true)
val newLowerActivityCharacter = saveCharacter("new-lower-activity", isActive = true) val newLowerActivityCharacter = saveCharacter("new-lower-activity", isActive = true)
@@ -585,47 +686,121 @@ class DefaultHomeRecommendationQueryRepositoryTest @Autowired constructor(
updateCreatedAt("ChatCharacter", newLowerActivityCharacter.id!!, LocalDateTime.of(2026, 5, 20, 0, 0)) updateCreatedAt("ChatCharacter", newLowerActivityCharacter.id!!, LocalDateTime.of(2026, 5, 20, 0, 0))
flushAndClear() flushAndClear()
val snapshots = repository.findAiCharacterSnapshots(windowStart, snapshotAt, limit = 1) val snapshots = repository.findAiCharacterSnapshots(windowStart, windowEndExclusive, limit = 1)
val expectedScore = scorePolicy.calculateAiChatScore( val expectedScore = scorePolicy.calculateAiChatScore(
recentChatCount = 2, recentChatCount = 3,
recentActiveUserCount = 1, recentActiveUserCount = 1,
newBoost = scorePolicy.calculateAiCharacterNewBoost(LocalDateTime.of(2026, 5, 20, 0, 0), snapshotAt) followIncreaseCount = 0,
newBoost = scorePolicy.calculateAiCharacterNewBoost(LocalDateTime.of(2026, 4, 1, 0, 0), snapshotAt)
) )
assertEquals(1, snapshots.size) assertEquals(1, snapshots.size)
assertEquals(RecommendedSectionType.AI_CHARACTER, snapshots.single().sectionType) assertEquals(RecommendedSectionType.AI_CHARACTER, snapshots.single().sectionType)
assertEquals(newLowerActivityCharacter.id, snapshots.single().targetId) assertEquals(oldHighActivityCharacter.id, snapshots.single().targetId)
assertEquals(expectedScore, snapshots.single().score, 0.0001) assertEquals(expectedScore, snapshots.single().score, 0.0001)
assertEquals(snapshotAt, snapshots.single().snapshotAt) assertEquals(snapshotAt, snapshots.single().snapshotAt)
} }
@Test @Test
@DisplayName("최근 응원 스냅샷은 CHANNEL_DONATION 후원 금액과 후원 수만 집계한다") @DisplayName("AI 캐릭터 스냅샷은 생성일 누락/미래와 유효하지 않은 AI 크리에이터 회원을 제외한다")
fun shouldExcludeInvalidAiCharacterSnapshotCandidates() {
val windowStart = LocalDateTime.of(2026, 7, 8, 15, 0)
val snapshotAt = LocalDateTime.of(2026, 7, 9, 14, 59, 59)
val windowEndExclusive = snapshotAt.plusSeconds(1)
val user = saveMember("ai-exclude-user", MemberRole.USER)
val visibleCharacter = saveCharacter("ai-exclude-visible", isActive = true)
val futureCharacter = saveCharacter("ai-exclude-future", isActive = true)
val inactiveCreatorCharacter = saveCharacter("ai-exclude-inactive-creator", isActive = true).apply {
creatorMember!!.isActive = false
}
val userCreatorCharacter = saveCharacter("ai-exclude-user-creator", isActive = true).apply {
creatorMember!!.role = MemberRole.USER
}
val humanCreatorCharacter = saveCharacter("ai-exclude-human-creator", isActive = true).apply {
creatorMember!!.memberKind = MemberKind.HUMAN
}
listOf(visibleCharacter, futureCharacter, inactiveCreatorCharacter, userCreatorCharacter, humanCreatorCharacter)
.forEachIndexed { index, character ->
val room = saveChatRoom("ai-exclude-room-$index")
val userParticipant = saveParticipant(room, ParticipantType.USER, member = user)
val characterParticipant = saveParticipant(room, ParticipantType.CHARACTER, character = character)
updateCreatedAt(
"ChatMessage",
saveMessage(room, userParticipant, "user-$index", true).id!!,
windowStart.plusHours(1)
)
updateCreatedAt(
"ChatMessage",
saveMessage(room, characterParticipant, "character-$index", true).id!!,
windowStart.plusHours(1)
)
}
updateCreatedAt("ChatCharacter", visibleCharacter.id!!, LocalDateTime.of(2026, 7, 1, 0, 0))
updateCreatedAt("ChatCharacter", futureCharacter.id!!, snapshotAt.plusSeconds(1))
updateCreatedAt("ChatCharacter", inactiveCreatorCharacter.id!!, LocalDateTime.of(2026, 7, 1, 0, 0))
updateCreatedAt("ChatCharacter", userCreatorCharacter.id!!, LocalDateTime.of(2026, 7, 1, 0, 0))
updateCreatedAt("ChatCharacter", humanCreatorCharacter.id!!, LocalDateTime.of(2026, 7, 1, 0, 0))
flushAndClear()
val snapshots = repository.findAiCharacterSnapshots(windowStart, windowEndExclusive, limit = 10)
assertEquals(listOf(visibleCharacter.id), snapshots.map { it.targetId })
}
@Test
@DisplayName("AI 캐릭터 스냅샷은 점수 동점이면 더 늦게 생성된 캐릭터를 먼저 반환하고 상위 20개만 저장 후보로 반환한다")
fun shouldOrderAiCharacterSnapshotsByScoreThenCreatedAtDescWithTopTwentyLimit() {
val windowStart = LocalDateTime.of(2026, 7, 8, 15, 0)
val snapshotAt = LocalDateTime.of(2026, 7, 9, 14, 59, 59)
val windowEndExclusive = snapshotAt.plusSeconds(1)
val user = saveMember("ai-top-user", MemberRole.USER)
val characters = (0..20).map { index ->
val character = saveCharacter("ai-top-$index", isActive = true)
val room = saveChatRoom("ai-top-room-$index")
val participant = saveParticipant(room, ParticipantType.USER, member = user)
saveParticipant(room, ParticipantType.CHARACTER, character = character)
updateCreatedAt("ChatMessage", saveMessage(room, participant, "message-$index", true).id!!, windowStart.plusHours(1))
updateCreatedAt("ChatCharacter", character.id!!, LocalDateTime.of(2026, 6, 1, 0, 0).plusMinutes(index.toLong()))
character
}
flushAndClear()
val snapshots = repository.findAiCharacterSnapshots(windowStart, windowEndExclusive, limit = 20)
assertEquals(20, snapshots.size)
assertEquals(characters.drop(1).reversed().map { it.id }, snapshots.map { it.targetId })
assertEquals(false, snapshots.any { it.targetId == characters.first().id })
}
@Test
@DisplayName("최근 응원 스냅샷은 전날 half-open 범위의 CHANNEL_DONATION 금액과 distinct 후원 수만 집계한다")
fun shouldFindCheerCreatorSnapshotsWithChannelDonationOnly() { fun shouldFindCheerCreatorSnapshotsWithChannelDonationOnly() {
val windowStart = LocalDateTime.of(2026, 5, 23, 0, 0) val windowStart = LocalDateTime.of(2026, 5, 28, 15, 0)
val snapshotAt = LocalDateTime.of(2026, 5, 29, 23, 59, 59) val windowEndExclusive = LocalDateTime.of(2026, 5, 29, 15, 0)
val snapshotAt = windowEndExclusive.minusSeconds(1)
val creator = saveMember("cheer-creator", MemberRole.CREATOR) val creator = saveMember("cheer-creator", MemberRole.CREATOR)
val donor = saveMember("cheer-donor", MemberRole.USER) val donor = saveMember("cheer-donor", MemberRole.USER)
saveAudioContent(creator, LocalDateTime.of(2026, 5, 20, 12, 0), isActive = true) saveAudioContent(creator, LocalDateTime.of(2026, 5, 20, 12, 0), isActive = true)
saveLiveRoom(creator, LocalDateTime.of(2026, 5, 10, 12, 0), channelName = "cheer-channel") saveLiveRoom(creator, LocalDateTime.of(2026, 5, 10, 12, 0), channelName = "cheer-channel")
saveUseCanCalculate( val firstDonation = saveUseCanCalculate(
donor, donor,
creator, creator,
CanUsage.CHANNEL_DONATION, CanUsage.CHANNEL_DONATION,
can = 100, can = 100,
status = UseCanCalculateStatus.RECEIVED, status = UseCanCalculateStatus.RECEIVED,
isRefund = false, isRefund = false,
createdAt = windowStart.plusDays(1) createdAt = windowStart.plusHours(1)
) )
saveUseCanCalculateForUseCan(firstDonation.useCan!!, creator, can = 50, createdAt = windowStart.plusHours(2))
saveUseCanCalculate( saveUseCanCalculate(
donor, donor,
creator, creator,
CanUsage.CHANNEL_DONATION, CanUsage.CHANNEL_DONATION,
can = 50, can = 999,
status = UseCanCalculateStatus.RECEIVED, status = UseCanCalculateStatus.RECEIVED,
isRefund = false, isRefund = false,
createdAt = snapshotAt createdAt = windowEndExclusive
) )
saveUseCanCalculate( saveUseCanCalculate(
donor, donor,
@@ -634,7 +809,7 @@ class DefaultHomeRecommendationQueryRepositoryTest @Autowired constructor(
can = 1_000, can = 1_000,
status = UseCanCalculateStatus.RECEIVED, status = UseCanCalculateStatus.RECEIVED,
isRefund = false, isRefund = false,
createdAt = windowStart.plusDays(1) createdAt = windowStart.plusHours(1)
) )
saveUseCanCalculate( saveUseCanCalculate(
donor, donor,
@@ -643,7 +818,7 @@ class DefaultHomeRecommendationQueryRepositoryTest @Autowired constructor(
can = 1_000, can = 1_000,
status = UseCanCalculateStatus.RECEIVED, status = UseCanCalculateStatus.RECEIVED,
isRefund = false, isRefund = false,
createdAt = windowStart.plusDays(1) createdAt = windowStart.plusHours(1)
) )
saveUseCanCalculate( saveUseCanCalculate(
donor, donor,
@@ -652,7 +827,7 @@ class DefaultHomeRecommendationQueryRepositoryTest @Autowired constructor(
can = 1_000, can = 1_000,
status = UseCanCalculateStatus.RECEIVED, status = UseCanCalculateStatus.RECEIVED,
isRefund = false, isRefund = false,
createdAt = windowStart.plusDays(1) createdAt = windowStart.plusHours(1)
) )
saveUseCanCalculate( saveUseCanCalculate(
donor, donor,
@@ -661,7 +836,7 @@ class DefaultHomeRecommendationQueryRepositoryTest @Autowired constructor(
can = 1_000, can = 1_000,
status = UseCanCalculateStatus.REFUND, status = UseCanCalculateStatus.REFUND,
isRefund = false, isRefund = false,
createdAt = windowStart.plusDays(1) createdAt = windowStart.plusHours(1)
) )
saveUseCanCalculate( saveUseCanCalculate(
donor, donor,
@@ -670,7 +845,7 @@ class DefaultHomeRecommendationQueryRepositoryTest @Autowired constructor(
can = 1_000, can = 1_000,
status = UseCanCalculateStatus.RECEIVED, status = UseCanCalculateStatus.RECEIVED,
isRefund = true, isRefund = true,
createdAt = windowStart.plusDays(1) createdAt = windowStart.plusHours(1)
) )
saveUseCanCalculate( saveUseCanCalculate(
donor, donor,
@@ -688,28 +863,29 @@ class DefaultHomeRecommendationQueryRepositoryTest @Autowired constructor(
can = 1_000, can = 1_000,
status = UseCanCalculateStatus.RECEIVED, status = UseCanCalculateStatus.RECEIVED,
isRefund = false, isRefund = false,
createdAt = windowStart.plusDays(1) createdAt = windowStart.plusHours(1)
) )
val cheer1 = saveCreatorCheers(donor, creator, isActive = true) val cheer1 = saveCreatorCheers(donor, creator, isActive = true)
val cheer2 = saveCreatorCheers(donor, creator, isActive = true) val cheer2 = saveCreatorCheers(donor, creator, isActive = true)
val inactiveCheer = saveCreatorCheers(donor, creator, isActive = false) val inactiveCheer = saveCreatorCheers(donor, creator, isActive = false)
updateCreatedAt("CreatorCheers", cheer1.id!!, windowStart.plusDays(1)) updateCreatedAt("CreatorCheers", cheer1.id!!, windowStart.plusHours(1))
updateCreatedAt("CreatorCheers", cheer2.id!!, snapshotAt) updateCreatedAt("CreatorCheers", cheer2.id!!, windowEndExclusive)
updateCreatedAt("CreatorCheers", inactiveCheer.id!!, windowStart.plusDays(1)) updateCreatedAt("CreatorCheers", inactiveCheer.id!!, windowStart.plusHours(1))
updateCreatedAt("Member", creator.id!!, LocalDateTime.of(2026, 5, 10, 12, 0)) updateCreatedAt("Member", creator.id!!, LocalDateTime.of(2026, 5, 10, 12, 0))
flushAndClear() flushAndClear()
val snapshots = repository.findCheerCreatorSnapshots(windowStart, snapshotAt, limit = 10) val snapshots = repository.findCheerCreatorSnapshots(windowStart, windowEndExclusive, limit = 10)
val expectedScore = scorePolicy.calculateCheerScore( val expectedScore = scorePolicy.calculateCheerScore(
donationAmount = 150, donationAmount = 150,
fanTalkCount = 2, fanTalkCount = 1,
donationCount = 2, donationCount = 1,
newBoost = scorePolicy.calculateCreatorNewBoost(LocalDateTime.of(2026, 5, 10, 12, 0), snapshotAt) newBoost = scorePolicy.calculateCheerCreatorNewBoost(LocalDateTime.of(2026, 5, 10, 12, 0), snapshotAt)
) )
assertEquals(1, snapshots.size) assertEquals(1, snapshots.size)
assertEquals(RecommendedSectionType.CHEER_CREATOR, snapshots.single().sectionType) assertEquals(RecommendedSectionType.CHEER_CREATOR, snapshots.single().sectionType)
assertEquals(creator.id, snapshots.single().targetId) assertEquals(creator.id, snapshots.single().targetId)
assertEquals(snapshotAt, snapshots.single().snapshotAt)
assertEquals(expectedScore, snapshots.single().score, 0.0001) assertEquals(expectedScore, snapshots.single().score, 0.0001)
} }
@@ -718,6 +894,7 @@ class DefaultHomeRecommendationQueryRepositoryTest @Autowired constructor(
fun shouldFindCheerCreatorSnapshotsWithDbScoreOrderAndLimit() { fun shouldFindCheerCreatorSnapshotsWithDbScoreOrderAndLimit() {
val windowStart = LocalDateTime.of(2026, 5, 23, 0, 0) val windowStart = LocalDateTime.of(2026, 5, 23, 0, 0)
val snapshotAt = LocalDateTime.of(2026, 5, 29, 23, 59, 59) val snapshotAt = LocalDateTime.of(2026, 5, 29, 23, 59, 59)
val windowEndExclusive = snapshotAt.plusSeconds(1)
val donor = saveMember("cheer-score-donor", MemberRole.USER) val donor = saveMember("cheer-score-donor", MemberRole.USER)
val oldCreator = saveMember("old-cheer-score-creator", MemberRole.CREATOR) val oldCreator = saveMember("old-cheer-score-creator", MemberRole.CREATOR)
val newCreator = saveMember("new-cheer-score-creator", MemberRole.CREATOR) val newCreator = saveMember("new-cheer-score-creator", MemberRole.CREATOR)
@@ -743,17 +920,18 @@ class DefaultHomeRecommendationQueryRepositoryTest @Autowired constructor(
) )
flushAndClear() flushAndClear()
val snapshots = repository.findCheerCreatorSnapshots(windowStart, snapshotAt, limit = 1) val snapshots = repository.findCheerCreatorSnapshots(windowStart, windowEndExclusive, limit = 1)
val expectedScore = scorePolicy.calculateCheerScore( val expectedScore = scorePolicy.calculateCheerScore(
donationAmount = 2, donationAmount = 2,
fanTalkCount = 0, fanTalkCount = 0,
donationCount = 1, donationCount = 1,
newBoost = scorePolicy.calculateCreatorNewBoost(LocalDateTime.of(2026, 5, 20, 0, 0), snapshotAt) newBoost = scorePolicy.calculateCheerCreatorNewBoost(LocalDateTime.of(2026, 5, 20, 0, 0), snapshotAt)
) )
assertEquals(1, snapshots.size) assertEquals(1, snapshots.size)
assertEquals(RecommendedSectionType.CHEER_CREATOR, snapshots.single().sectionType) assertEquals(RecommendedSectionType.CHEER_CREATOR, snapshots.single().sectionType)
assertEquals(newCreator.id, snapshots.single().targetId) assertEquals(newCreator.id, snapshots.single().targetId)
assertEquals(snapshotAt, snapshots.single().snapshotAt)
assertEquals(expectedScore, snapshots.single().score, 0.0001) assertEquals(expectedScore, snapshots.single().score, 0.0001)
} }
@@ -762,6 +940,7 @@ class DefaultHomeRecommendationQueryRepositoryTest @Autowired constructor(
fun shouldFindPopularCommunitySnapshotsWithDistinctCounts() { fun shouldFindPopularCommunitySnapshotsWithDistinctCounts() {
val windowStart = LocalDateTime.of(2026, 5, 23, 0, 0) val windowStart = LocalDateTime.of(2026, 5, 23, 0, 0)
val snapshotAt = LocalDateTime.of(2026, 5, 29, 23, 59, 59) val snapshotAt = LocalDateTime.of(2026, 5, 29, 23, 59, 59)
val windowEndExclusive = snapshotAt.plusSeconds(1)
val creator = saveMember("community-creator", MemberRole.CREATOR) val creator = saveMember("community-creator", MemberRole.CREATOR)
val member1 = saveMember("community-member-1", MemberRole.USER) val member1 = saveMember("community-member-1", MemberRole.USER)
val member2 = saveMember("community-member-2", MemberRole.USER) val member2 = saveMember("community-member-2", MemberRole.USER)
@@ -792,20 +971,20 @@ class DefaultHomeRecommendationQueryRepositoryTest @Autowired constructor(
updateCreatedAt("Member", creator.id!!, LocalDateTime.of(2026, 4, 1, 12, 0)) updateCreatedAt("Member", creator.id!!, LocalDateTime.of(2026, 4, 1, 12, 0))
flushAndClear() flushAndClear()
val snapshots = repository.findPopularCommunitySnapshots(windowStart, snapshotAt, limit = 10) val snapshots = repository.findPopularCommunitySnapshots(windowStart, windowEndExclusive, limit = 10)
.associateBy { it.targetId } .associateBy { it.targetId }
val expectedPostScore = scorePolicy.calculateCommunityScore( val expectedPostScore = scorePolicy.calculateCommunityScore(
likeCount = 2, likeCount = 2,
commentCount = 2, commentCount = 2,
followerCount = 2, followerCount = 2,
newBoost = scorePolicy.calculateCreatorNewBoost(LocalDateTime.of(2026, 4, 1, 12, 0), snapshotAt) newBoost = scorePolicy.calculateCommunityCreatorNewBoost(LocalDateTime.of(2026, 4, 1, 12, 0), snapshotAt)
) )
val expectedCommentDisabledScore = scorePolicy.calculateCommunityScore( val expectedCommentDisabledScore = scorePolicy.calculateCommunityScore(
likeCount = 0, likeCount = 0,
commentCount = 0, commentCount = 0,
followerCount = 2, followerCount = 2,
newBoost = scorePolicy.calculateCreatorNewBoost(LocalDateTime.of(2026, 4, 1, 12, 0), snapshotAt) newBoost = scorePolicy.calculateCommunityCreatorNewBoost(LocalDateTime.of(2026, 4, 1, 12, 0), snapshotAt)
) )
assertEquals(expectedPostScore, snapshots[post.id]!!.score, 0.0001) assertEquals(expectedPostScore, snapshots[post.id]!!.score, 0.0001)
assertEquals(expectedCommentDisabledScore, snapshots[commentDisabledPost.id]!!.score, 0.0001) assertEquals(expectedCommentDisabledScore, snapshots[commentDisabledPost.id]!!.score, 0.0001)
@@ -816,6 +995,7 @@ class DefaultHomeRecommendationQueryRepositoryTest @Autowired constructor(
fun shouldIncludeAdultCommunitiesInPopularCommunitySnapshots() { fun shouldIncludeAdultCommunitiesInPopularCommunitySnapshots() {
val windowStart = LocalDateTime.of(2026, 5, 23, 0, 0) val windowStart = LocalDateTime.of(2026, 5, 23, 0, 0)
val snapshotAt = LocalDateTime.of(2026, 5, 29, 23, 59, 59) val snapshotAt = LocalDateTime.of(2026, 5, 29, 23, 59, 59)
val windowEndExclusive = snapshotAt.plusSeconds(1)
val creator = saveMember("adult-community-creator", MemberRole.CREATOR) val creator = saveMember("adult-community-creator", MemberRole.CREATOR)
val member = saveMember("adult-community-member", MemberRole.USER) val member = saveMember("adult-community-member", MemberRole.USER)
saveLiveRoom(creator, LocalDateTime.of(2026, 5, 10, 12, 0), channelName = "adult-community-live") saveLiveRoom(creator, LocalDateTime.of(2026, 5, 10, 12, 0), channelName = "adult-community-live")
@@ -829,7 +1009,7 @@ class DefaultHomeRecommendationQueryRepositoryTest @Autowired constructor(
updateCreatedAt("CreatorCommunityLike", adultLike.id!!, windowStart.plusDays(1)) updateCreatedAt("CreatorCommunityLike", adultLike.id!!, windowStart.plusDays(1))
flushAndClear() flushAndClear()
val snapshots = repository.findPopularCommunitySnapshots(windowStart, snapshotAt, limit = 10) val snapshots = repository.findPopularCommunitySnapshots(windowStart, windowEndExclusive, limit = 10)
assertEquals(setOf(normalPost.id, adultPost.id), snapshots.map { it.targetId }.toSet()) assertEquals(setOf(normalPost.id, adultPost.id), snapshots.map { it.targetId }.toSet())
} }
@@ -839,6 +1019,7 @@ class DefaultHomeRecommendationQueryRepositoryTest @Autowired constructor(
fun shouldFindPopularCommunitySnapshotsWithDbScoreOrderAndLimit() { fun shouldFindPopularCommunitySnapshotsWithDbScoreOrderAndLimit() {
val windowStart = LocalDateTime.of(2026, 5, 23, 0, 0) val windowStart = LocalDateTime.of(2026, 5, 23, 0, 0)
val snapshotAt = LocalDateTime.of(2026, 5, 29, 23, 59, 59) val snapshotAt = LocalDateTime.of(2026, 5, 29, 23, 59, 59)
val windowEndExclusive = snapshotAt.plusSeconds(1)
val liker = saveMember("community-score-liker", MemberRole.USER) val liker = saveMember("community-score-liker", MemberRole.USER)
val oldCreator = saveMember("old-community-score-creator", MemberRole.CREATOR) val oldCreator = saveMember("old-community-score-creator", MemberRole.CREATOR)
val newCreator = saveMember("new-community-score-creator", MemberRole.CREATOR) val newCreator = saveMember("new-community-score-creator", MemberRole.CREATOR)
@@ -854,13 +1035,13 @@ class DefaultHomeRecommendationQueryRepositoryTest @Autowired constructor(
updateCreatedAt("CreatorCommunityLike", newLike.id!!, windowStart.plusDays(1)) updateCreatedAt("CreatorCommunityLike", newLike.id!!, windowStart.plusDays(1))
flushAndClear() flushAndClear()
val snapshots = repository.findPopularCommunitySnapshots(windowStart, snapshotAt, limit = 1) val snapshots = repository.findPopularCommunitySnapshots(windowStart, windowEndExclusive, limit = 1)
val expectedScore = scorePolicy.calculateCommunityScore( val expectedScore = scorePolicy.calculateCommunityScore(
likeCount = 1, likeCount = 1,
commentCount = 0, commentCount = 0,
followerCount = 0, followerCount = 0,
newBoost = scorePolicy.calculateCreatorNewBoost(LocalDateTime.of(2026, 5, 20, 0, 0), snapshotAt) newBoost = scorePolicy.calculateCommunityCreatorNewBoost(LocalDateTime.of(2026, 5, 20, 0, 0), snapshotAt)
) )
assertEquals(1, snapshots.size) assertEquals(1, snapshots.size)
assertEquals(RecommendedSectionType.POPULAR_COMMUNITY, snapshots.single().sectionType) assertEquals(RecommendedSectionType.POPULAR_COMMUNITY, snapshots.single().sectionType)
@@ -868,11 +1049,62 @@ class DefaultHomeRecommendationQueryRepositoryTest @Autowired constructor(
assertEquals(expectedScore, snapshots.single().score, 0.0001) assertEquals(expectedScore, snapshots.single().score, 0.0001)
} }
@Test
@DisplayName("인기 커뮤니티 스냅샷은 좋아요 댓글 팔로워가 모두 0인 게시글을 제외한다")
fun shouldExcludeZeroMetricPopularCommunitySnapshots() {
val windowStart = LocalDateTime.of(2026, 5, 23, 0, 0)
val snapshotAt = LocalDateTime.of(2026, 5, 29, 23, 59, 59)
val windowEndExclusive = snapshotAt.plusSeconds(1)
val creator = saveMember("zero-community-creator", MemberRole.CREATOR)
val liker = saveMember("zero-community-liker", MemberRole.USER)
saveLiveRoom(creator, LocalDateTime.of(2026, 5, 10, 12, 0), channelName = "zero-community-live")
val zeroPost = saveCommunity(creator, isCommentAvailable = true)
val scoredPost = saveCommunity(creator, isCommentAvailable = true)
val like = saveCommunityLike(liker, scoredPost, isActive = true)
updateCreatedAt("CreatorCommunity", zeroPost.id!!, windowStart.plusDays(1))
updateCreatedAt("CreatorCommunity", scoredPost.id!!, windowStart.plusDays(1))
updateCreatedAt("CreatorCommunityLike", like.id!!, windowStart.plusDays(1))
flushAndClear()
val snapshots = repository.findPopularCommunitySnapshots(windowStart, windowEndExclusive, limit = 10)
assertEquals(listOf(scoredPost.id), snapshots.map { it.targetId })
}
@Test
@DisplayName("인기 커뮤니티 스냅샷 후보는 점수가 있어도 유료와 고정 게시글을 제외한다")
fun shouldExcludePaidAndFixedPostsFromPopularCommunitySnapshots() {
val windowStart = LocalDateTime.of(2026, 5, 23, 0, 0)
val snapshotAt = LocalDateTime.of(2026, 5, 29, 23, 59, 59)
val windowEndExclusive = snapshotAt.plusSeconds(1)
val creator = saveMember("paid-fixed-community-creator", MemberRole.CREATOR)
val liker = saveMember("paid-fixed-community-liker", MemberRole.USER)
saveLiveRoom(creator, LocalDateTime.of(2026, 5, 10, 12, 0), channelName = "paid-fixed-community-live")
val eligiblePost = saveCommunity(creator, isCommentAvailable = true)
val paidPost = saveCommunity(creator, isCommentAvailable = true, price = 10)
val fixedPost = saveCommunity(creator, isCommentAvailable = true, isFixed = true)
val eligibleLike = saveCommunityLike(liker, eligiblePost, isActive = true)
val paidLike = saveCommunityLike(liker, paidPost, isActive = true)
val fixedLike = saveCommunityLike(liker, fixedPost, isActive = true)
listOf(eligiblePost, paidPost, fixedPost).forEach { post ->
updateCreatedAt("CreatorCommunity", post.id!!, windowStart.plusDays(1))
}
listOf(eligibleLike, paidLike, fixedLike).forEach { like ->
updateCreatedAt("CreatorCommunityLike", like.id!!, windowStart.plusDays(1))
}
flushAndClear()
val snapshots = repository.findPopularCommunitySnapshots(windowStart, windowEndExclusive, limit = 10)
assertEquals(listOf(eligiblePost.id), snapshots.map { it.targetId })
}
@Test @Test
@DisplayName("실제 데뷔일이 없는 크리에이터는 최근 응원과 인기 커뮤니티 스냅샷에서 제외한다") @DisplayName("실제 데뷔일이 없는 크리에이터는 최근 응원과 인기 커뮤니티 스냅샷에서 제외한다")
fun shouldExcludeCreatorSnapshotsWithoutActualDebutAt() { fun shouldExcludeCreatorSnapshotsWithoutActualDebutAt() {
val windowStart = LocalDateTime.of(2026, 5, 23, 0, 0) val windowStart = LocalDateTime.of(2026, 5, 23, 0, 0)
val snapshotAt = LocalDateTime.of(2026, 5, 29, 23, 59, 59) val snapshotAt = LocalDateTime.of(2026, 5, 29, 23, 59, 59)
val windowEndExclusive = snapshotAt.plusSeconds(1)
val creator = saveMember("creator-without-debut", MemberRole.CREATOR) val creator = saveMember("creator-without-debut", MemberRole.CREATOR)
val donor = saveMember("donor-without-debut", MemberRole.USER) val donor = saveMember("donor-without-debut", MemberRole.USER)
val community = saveCommunity(creator, isCommentAvailable = true) val community = saveCommunity(creator, isCommentAvailable = true)
@@ -890,8 +1122,8 @@ class DefaultHomeRecommendationQueryRepositoryTest @Autowired constructor(
updateCreatedAt("CreatorCommunityLike", like.id!!, windowStart.plusDays(1)) updateCreatedAt("CreatorCommunityLike", like.id!!, windowStart.plusDays(1))
flushAndClear() flushAndClear()
val cheerSnapshots = repository.findCheerCreatorSnapshots(windowStart, snapshotAt, limit = 10) val cheerSnapshots = repository.findCheerCreatorSnapshots(windowStart, windowEndExclusive, limit = 10)
val communitySnapshots = repository.findPopularCommunitySnapshots(windowStart, snapshotAt, limit = 10) val communitySnapshots = repository.findPopularCommunitySnapshots(windowStart, windowEndExclusive, limit = 10)
assertEquals(emptyList<Any>(), cheerSnapshots) assertEquals(emptyList<Any>(), cheerSnapshots)
assertEquals(emptyList<Any>(), communitySnapshots) assertEquals(emptyList<Any>(), communitySnapshots)
@@ -902,6 +1134,7 @@ class DefaultHomeRecommendationQueryRepositoryTest @Autowired constructor(
fun shouldUseActualDebutAtInsteadOfMemberCreatedAtForCreatorSnapshots() { fun shouldUseActualDebutAtInsteadOfMemberCreatedAtForCreatorSnapshots() {
val windowStart = LocalDateTime.of(2026, 5, 23, 0, 0) val windowStart = LocalDateTime.of(2026, 5, 23, 0, 0)
val snapshotAt = LocalDateTime.of(2026, 5, 29, 23, 59, 59) val snapshotAt = LocalDateTime.of(2026, 5, 29, 23, 59, 59)
val windowEndExclusive = snapshotAt.plusSeconds(1)
val memberCreatedAt = LocalDateTime.of(2026, 1, 1, 0, 0) val memberCreatedAt = LocalDateTime.of(2026, 1, 1, 0, 0)
val firstLiveAt = LocalDateTime.of(2026, 5, 10, 12, 0) val firstLiveAt = LocalDateTime.of(2026, 5, 10, 12, 0)
val firstContentAt = LocalDateTime.of(2026, 5, 20, 12, 0) val firstContentAt = LocalDateTime.of(2026, 5, 20, 12, 0)
@@ -925,22 +1158,23 @@ class DefaultHomeRecommendationQueryRepositoryTest @Autowired constructor(
updateCreatedAt("CreatorCommunityLike", like.id!!, windowStart.plusDays(1)) updateCreatedAt("CreatorCommunityLike", like.id!!, windowStart.plusDays(1))
flushAndClear() flushAndClear()
val cheerSnapshot = repository.findCheerCreatorSnapshots(windowStart, snapshotAt, limit = 10).single() val cheerSnapshot = repository.findCheerCreatorSnapshots(windowStart, windowEndExclusive, limit = 10).single()
val communitySnapshot = repository.findPopularCommunitySnapshots(windowStart, snapshotAt, limit = 10).single() val communitySnapshot = repository.findPopularCommunitySnapshots(windowStart, windowEndExclusive, limit = 10).single()
val expectedCheerScore = scorePolicy.calculateCheerScore( val expectedCheerScore = scorePolicy.calculateCheerScore(
donationAmount = 100, donationAmount = 100,
fanTalkCount = 0, fanTalkCount = 0,
donationCount = 1, donationCount = 1,
newBoost = scorePolicy.calculateCreatorNewBoost(firstLiveAt, snapshotAt) newBoost = scorePolicy.calculateCheerCreatorNewBoost(firstLiveAt, snapshotAt)
) )
val expectedCommunityScore = scorePolicy.calculateCommunityScore( val expectedCommunityScore = scorePolicy.calculateCommunityScore(
likeCount = 1, likeCount = 1,
commentCount = 0, commentCount = 0,
followerCount = 0, followerCount = 0,
newBoost = scorePolicy.calculateCreatorNewBoost(firstLiveAt, snapshotAt) newBoost = scorePolicy.calculateCommunityCreatorNewBoost(firstLiveAt, snapshotAt)
) )
assertEquals(expectedCheerScore, cheerSnapshot.score, 0.0001) assertEquals(expectedCheerScore, cheerSnapshot.score, 0.0001)
assertEquals(snapshotAt, cheerSnapshot.snapshotAt)
assertEquals(expectedCommunityScore, communitySnapshot.score, 0.0001) assertEquals(expectedCommunityScore, communitySnapshot.score, 0.0001)
} }
@@ -949,6 +1183,7 @@ class DefaultHomeRecommendationQueryRepositoryTest @Autowired constructor(
fun shouldExcludeBlankChannelNameLiveFromSnapshotDebutAt() { fun shouldExcludeBlankChannelNameLiveFromSnapshotDebutAt() {
val windowStart = LocalDateTime.of(2026, 5, 23, 0, 0) val windowStart = LocalDateTime.of(2026, 5, 23, 0, 0)
val snapshotAt = LocalDateTime.of(2026, 5, 29, 23, 59, 59) val snapshotAt = LocalDateTime.of(2026, 5, 29, 23, 59, 59)
val windowEndExclusive = snapshotAt.plusSeconds(1)
val blankLiveAt = LocalDateTime.of(2026, 5, 1, 12, 0) val blankLiveAt = LocalDateTime.of(2026, 5, 1, 12, 0)
val contentDebutAt = LocalDateTime.of(2026, 5, 20, 12, 0) val contentDebutAt = LocalDateTime.of(2026, 5, 20, 12, 0)
val creator = saveMember("blank-live-debut-creator", MemberRole.CREATOR) val creator = saveMember("blank-live-debut-creator", MemberRole.CREATOR)
@@ -970,22 +1205,23 @@ class DefaultHomeRecommendationQueryRepositoryTest @Autowired constructor(
updateCreatedAt("CreatorCommunityLike", like.id!!, windowStart.plusDays(1)) updateCreatedAt("CreatorCommunityLike", like.id!!, windowStart.plusDays(1))
flushAndClear() flushAndClear()
val cheerSnapshot = repository.findCheerCreatorSnapshots(windowStart, snapshotAt, limit = 10).single() val cheerSnapshot = repository.findCheerCreatorSnapshots(windowStart, windowEndExclusive, limit = 10).single()
val communitySnapshot = repository.findPopularCommunitySnapshots(windowStart, snapshotAt, limit = 10).single() val communitySnapshot = repository.findPopularCommunitySnapshots(windowStart, windowEndExclusive, limit = 10).single()
val expectedCheerScore = scorePolicy.calculateCheerScore( val expectedCheerScore = scorePolicy.calculateCheerScore(
donationAmount = 100, donationAmount = 100,
fanTalkCount = 0, fanTalkCount = 0,
donationCount = 1, donationCount = 1,
newBoost = scorePolicy.calculateCreatorNewBoost(contentDebutAt, snapshotAt) newBoost = scorePolicy.calculateCheerCreatorNewBoost(contentDebutAt, snapshotAt)
) )
val expectedCommunityScore = scorePolicy.calculateCommunityScore( val expectedCommunityScore = scorePolicy.calculateCommunityScore(
likeCount = 1, likeCount = 1,
commentCount = 0, commentCount = 0,
followerCount = 0, followerCount = 0,
newBoost = scorePolicy.calculateCreatorNewBoost(contentDebutAt, snapshotAt) newBoost = scorePolicy.calculateCommunityCreatorNewBoost(contentDebutAt, snapshotAt)
) )
assertEquals(expectedCheerScore, cheerSnapshot.score, 0.0001) assertEquals(expectedCheerScore, cheerSnapshot.score, 0.0001)
assertEquals(snapshotAt, cheerSnapshot.snapshotAt)
assertEquals(expectedCommunityScore, communitySnapshot.score, 0.0001) assertEquals(expectedCommunityScore, communitySnapshot.score, 0.0001)
} }
@@ -1017,6 +1253,30 @@ class DefaultHomeRecommendationQueryRepositoryTest @Autowired constructor(
assertEquals(listOf(newHighScoreCreator.id, newLowScoreCreator.id), creators.map { it.creatorId }) assertEquals(listOf(newHighScoreCreator.id, newLowScoreCreator.id), creators.map { it.creatorId })
} }
@Test
@DisplayName("최근 데뷔 크리에이터는 인기 커뮤니티 전용 부스트가 아니라 기존 신규 부스트를 유지한다")
fun shouldKeepOriginalNewBoostForRecentDebutCreators() {
val now = LocalDateTime.of(2026, 5, 31, 10, 0)
val boostedCreator = saveMember("original-boost-debut", MemberRole.CREATOR)
val baseCreator = saveMember("base-boost-debut", MemberRole.CREATOR)
val fan = saveMember("original-boost-fan", MemberRole.USER)
val boostedContent = saveAudioContent(boostedCreator, now.minusDays(5), isActive = true)
val baseContent = saveAudioContent(baseCreator, now.minusDays(20), isActive = true)
val boostedFollowing = saveFollowing(fan, boostedCreator, isActive = true)
val baseLike1 = saveAudioContentLike(fan, baseContent, isActive = true)
val baseLike2 = saveAudioContentLike(fan, baseContent, isActive = true)
updateCreatedAt("AudioContent", boostedContent.id!!, now.minusDays(5))
updateCreatedAt("AudioContent", baseContent.id!!, now.minusDays(20))
updateCreatedAt("CreatorFollowing", boostedFollowing.id!!, now.minusDays(1))
updateCreatedAt("AudioContentLike", baseLike1.id!!, now.minusDays(1))
updateCreatedAt("AudioContentLike", baseLike2.id!!, now.minusDays(1))
flushAndClear()
val creators = repository.findRecentDebutCreators(now, limit = 2)
assertEquals(listOf(boostedCreator.id, baseCreator.id), creators.map { it.creatorId })
}
@Test @Test
@DisplayName("최근 데뷔 크리에이터 전체보기 조회는 offset/limit과 성인 콘텐츠 제외를 DB에서 적용한다") @DisplayName("최근 데뷔 크리에이터 전체보기 조회는 offset/limit과 성인 콘텐츠 제외를 DB에서 적용한다")
fun shouldFindPagedRecentDebutCreatorsWithAdultFilter() { fun shouldFindPagedRecentDebutCreatorsWithAdultFilter() {
@@ -1421,19 +1681,16 @@ class DefaultHomeRecommendationQueryRepositoryTest @Autowired constructor(
) )
val detailById = details.associateBy { it.communityId } val detailById = details.associateBy { it.communityId }
assertEquals(setOf(eligible.id, paid.id), detailById.keys) assertEquals(setOf(eligible.id), detailById.keys)
assertEquals("content", detailById[eligible.id]!!.content) assertEquals("content", detailById[eligible.id]!!.content)
assertEquals("community/detail-image.png", detailById[eligible.id]!!.imagePath) assertEquals("community/detail-image.png", detailById[eligible.id]!!.imagePath)
assertEquals("community/detail-audio.mp3", detailById[eligible.id]!!.audioPath) assertEquals("community/detail-audio.mp3", detailById[eligible.id]!!.audioPath)
assertEquals(0, detailById[eligible.id]!!.price) assertEquals(0, detailById[eligible.id]!!.price)
assertEquals(10, detailById[paid.id]!!.price)
assertEquals(LocalDateTime.of(2026, 5, 29, 1, 0), detailById[eligible.id]!!.createdAt) assertEquals(LocalDateTime.of(2026, 5, 29, 1, 0), detailById[eligible.id]!!.createdAt)
assertEquals(2L, detailById[eligible.id]!!.likeCount) assertEquals(2L, detailById[eligible.id]!!.likeCount)
assertEquals(1L, detailById[eligible.id]!!.commentCount) assertEquals(1L, detailById[eligible.id]!!.commentCount)
assertEquals(false, detailById[eligible.id]!!.existOrdered) assertEquals(false, detailById[eligible.id]!!.existOrdered)
assertEquals(true, detailById[paid.id]!!.existOrdered)
assertEquals(true, detailById[eligible.id]!!.isLiked) assertEquals(true, detailById[eligible.id]!!.isLiked)
assertEquals(false, detailById[paid.id]!!.isLiked)
assertEquals(creator.id, detailById[eligible.id]!!.creatorId) assertEquals(creator.id, detailById[eligible.id]!!.creatorId)
assertEquals("community-detail-creator", detailById[eligible.id]!!.creatorNickname) assertEquals("community-detail-creator", detailById[eligible.id]!!.creatorNickname)
} }
@@ -1442,16 +1699,16 @@ class DefaultHomeRecommendationQueryRepositoryTest @Autowired constructor(
@DisplayName("인기 커뮤니티 상세는 비회원에게 구매 여부를 false로 반환한다") @DisplayName("인기 커뮤니티 상세는 비회원에게 구매 여부를 false로 반환한다")
fun shouldReturnFalseOrderStatusForAnonymousPopularCommunityDetails() { fun shouldReturnFalseOrderStatusForAnonymousPopularCommunityDetails() {
val creator = saveMember("anonymous-community-creator", MemberRole.CREATOR) val creator = saveMember("anonymous-community-creator", MemberRole.CREATOR)
val paid = saveCommunity(creator, isCommentAvailable = true, price = 10) val free = saveCommunity(creator, isCommentAvailable = true, price = 0)
flushAndClear() flushAndClear()
val details = repository.findPopularCommunityRecommendationDetails( val details = repository.findPopularCommunityRecommendationDetails(
listOf(paid.id!!), listOf(free.id!!),
memberId = null, memberId = null,
includeAdultCommunities = false includeAdultCommunities = false
) )
assertEquals(listOf(paid.id), details.map { it.communityId }) assertEquals(listOf(free.id), details.map { it.communityId })
assertEquals(listOf(false), details.map { it.existOrdered }) assertEquals(listOf(false), details.map { it.existOrdered })
assertEquals(listOf(false), details.map { it.isLiked }) assertEquals(listOf(false), details.map { it.isLiked })
} }
@@ -2021,7 +2278,7 @@ class DefaultHomeRecommendationQueryRepositoryTest @Autowired constructor(
status: UseCanCalculateStatus, status: UseCanCalculateStatus,
isRefund: Boolean, isRefund: Boolean,
createdAt: LocalDateTime createdAt: LocalDateTime
) { ): UseCanCalculate {
val useCan = UseCan(canUsage = usage, can = can, rewardCan = 0, isRefund = isRefund) val useCan = UseCan(canUsage = usage, can = can, rewardCan = 0, isRefund = isRefund)
useCan.member = donor useCan.member = donor
entityManager.persist(useCan) entityManager.persist(useCan)
@@ -2031,6 +2288,26 @@ class DefaultHomeRecommendationQueryRepositoryTest @Autowired constructor(
entityManager.persist(calculate) entityManager.persist(calculate)
entityManager.flush() entityManager.flush()
updateCreatedAt("UseCanCalculate", calculate.id!!, createdAt) updateCreatedAt("UseCanCalculate", calculate.id!!, createdAt)
return calculate
}
private fun saveUseCanCalculateForUseCan(
useCan: UseCan,
creator: Member,
can: Int,
createdAt: LocalDateTime
): UseCanCalculate {
val calculate = UseCanCalculate(
can = can,
paymentGateway = PaymentGateway.PG,
status = UseCanCalculateStatus.RECEIVED
)
calculate.useCan = useCan
calculate.recipientCreatorId = creator.id
entityManager.persist(calculate)
entityManager.flush()
updateCreatedAt("UseCanCalculate", calculate.id!!, createdAt)
return calculate
} }
private fun saveCreatorCheers(member: Member, creator: Member, isActive: Boolean): CreatorCheers { private fun saveCreatorCheers(member: Member, creator: Member, isActive: Boolean): CreatorCheers {

View File

@@ -77,6 +77,61 @@ class RecommendationSnapshotPersistenceAdapterTest @Autowired constructor(
assertEquals(listOf(2L, 3L), snapshots.map { it.targetId }) assertEquals(listOf(2L, 3L), snapshots.map { it.targetId })
} }
@Test
fun shouldFindSnapshotsByExactSnapshotAtAndExcludeEmptyMarker() {
val oldSnapshotAt = LocalDateTime.of(2026, 5, 28, 23, 59, 59)
val snapshotAt = LocalDateTime.of(2026, 5, 29, 23, 59, 59)
repository.saveAll(
listOf(
snapshot(RecommendedSectionType.CHEER_CREATOR, targetId = 1L, score = 999.0, snapshotAt = oldSnapshotAt),
snapshot(RecommendedSectionType.CHEER_CREATOR, targetId = 0L, score = 0.0, snapshotAt = snapshotAt),
snapshot(
RecommendedSectionType.CHEER_CREATOR,
targetId = 2L,
score = 100.0,
snapshotAt = snapshotAt,
randomTieBreaker = 0.9
),
snapshot(
RecommendedSectionType.CHEER_CREATOR,
targetId = 3L,
score = 200.0,
snapshotAt = snapshotAt,
randomTieBreaker = 0.8
)
)
)
val snapshots = adapter.findSnapshots(RecommendedSectionType.CHEER_CREATOR, snapshotAt, offset = 0, limit = 10)
assertEquals(listOf(3L, 2L), snapshots.map { it.targetId })
assertEquals(listOf(snapshotAt, snapshotAt), snapshots.map { it.snapshotAt })
}
@Test
fun shouldCheckSnapshotExistenceByExactSnapshotAtIncludingEmptyMarker() {
val staleSnapshotAt = LocalDateTime.of(2026, 5, 28, 23, 59, 59)
val expectedSnapshotAt = LocalDateTime.of(2026, 5, 29, 23, 59, 59)
repository.save(
snapshot(
RecommendedSectionType.CHEER_CREATOR,
targetId = 1L,
score = 100.0,
snapshotAt = staleSnapshotAt
)
)
assertEquals(false, adapter.existsSnapshot(RecommendedSectionType.CHEER_CREATOR, expectedSnapshotAt))
adapter.replaceSnapshots(RecommendedSectionType.CHEER_CREATOR, expectedSnapshotAt, emptyList())
assertEquals(true, adapter.existsSnapshot(RecommendedSectionType.CHEER_CREATOR, expectedSnapshotAt))
assertEquals(
emptyList<RecommendationSnapshotRecord>(),
adapter.findSnapshots(RecommendedSectionType.CHEER_CREATOR, expectedSnapshotAt)
)
}
@Test @Test
fun shouldReplaceSnapshotsByDeletingSameSectionSnapshotAtOnly() { fun shouldReplaceSnapshotsByDeletingSameSectionSnapshotAtOnly() {
val oldSnapshotAt = LocalDateTime.of(2026, 5, 28, 23, 59, 59) val oldSnapshotAt = LocalDateTime.of(2026, 5, 28, 23, 59, 59)
@@ -108,6 +163,135 @@ class RecommendationSnapshotPersistenceAdapterTest @Autowired constructor(
assertEquals(3, repository.findAll().size) assertEquals(3, repository.findAll().size)
} }
@Test
fun shouldSaveAiCharacterEmptySnapshotMarkerWhenReplacingWithEmptySnapshots() {
val oldSnapshotAt = LocalDateTime.of(2026, 5, 28, 23, 59, 59)
val snapshotAt = LocalDateTime.of(2026, 5, 29, 23, 59, 59)
repository.saveAll(
listOf(
snapshot(RecommendedSectionType.AI_CHARACTER, targetId = 1L, score = 100.0, snapshotAt = oldSnapshotAt),
snapshot(RecommendedSectionType.CHEER_CREATOR, targetId = 2L, score = 200.0, snapshotAt = oldSnapshotAt)
)
)
adapter.replaceSnapshots(RecommendedSectionType.AI_CHARACTER, snapshotAt, emptyList())
assertEquals(emptyList<RecommendationSnapshotRecord>(), adapter.findLatestSnapshots(RecommendedSectionType.AI_CHARACTER))
assertEquals(listOf(2L), adapter.findLatestSnapshots(RecommendedSectionType.CHEER_CREATOR).map { it.targetId })
assertEquals(true, adapter.existsLatestSnapshot(RecommendedSectionType.AI_CHARACTER))
assertEquals(
listOf(1L, 0L),
repository.findAll()
.filter { it.sectionType == RecommendedSectionType.AI_CHARACTER }
.sortedBy { it.snapshotAt }
.map { it.targetId }
)
}
@Test
fun shouldExcludeEmptySnapshotMarkerFromLatestSnapshotResults() {
val snapshotAt = LocalDateTime.of(2026, 5, 29, 23, 59, 59)
adapter.replaceSnapshots(RecommendedSectionType.AI_CHARACTER, snapshotAt, emptyList())
assertEquals(emptyList<RecommendationSnapshotRecord>(), adapter.findLatestSnapshots(RecommendedSectionType.AI_CHARACTER))
assertEquals(true, adapter.existsLatestSnapshot(RecommendedSectionType.AI_CHARACTER))
}
@Test
fun shouldSaveCheerCreatorEmptySnapshotMarkerWhenReplacingWithEmptySnapshots() {
val oldSnapshotAt = LocalDateTime.of(2026, 5, 28, 23, 59, 59)
val snapshotAt = LocalDateTime.of(2026, 5, 29, 23, 59, 59)
repository.save(snapshot(RecommendedSectionType.CHEER_CREATOR, targetId = 1L, score = 100.0, snapshotAt = oldSnapshotAt))
adapter.replaceSnapshots(RecommendedSectionType.CHEER_CREATOR, snapshotAt, emptyList())
assertEquals(emptyList<RecommendationSnapshotRecord>(), adapter.findLatestSnapshots(RecommendedSectionType.CHEER_CREATOR))
assertEquals(true, adapter.existsLatestSnapshot(RecommendedSectionType.CHEER_CREATOR))
assertEquals(
listOf(1L, 0L),
repository.findAll()
.filter { it.sectionType == RecommendedSectionType.CHEER_CREATOR }
.sortedBy { it.snapshotAt }
.map { it.targetId }
)
}
@Test
fun shouldSavePopularCommunityEmptySnapshotMarkerWhenReplacingWithEmptySnapshots() {
val snapshotAt = LocalDateTime.of(2026, 5, 29, 23, 59, 59)
adapter.replaceSnapshots(RecommendedSectionType.POPULAR_COMMUNITY, snapshotAt, emptyList())
assertEquals(
emptyList<RecommendationSnapshotRecord>(),
adapter.findSnapshots(RecommendedSectionType.POPULAR_COMMUNITY, snapshotAt)
)
assertEquals(true, adapter.existsSnapshot(RecommendedSectionType.POPULAR_COMMUNITY, snapshotAt))
assertEquals(
listOf(0L),
repository.findAll()
.filter { it.sectionType == RecommendedSectionType.POPULAR_COMMUNITY }
.map { it.targetId }
)
}
@Test
fun shouldReplaceCheerCreatorEmptyMarkerWithRealSnapshots() {
val snapshotAt = LocalDateTime.of(2026, 5, 29, 23, 59, 59)
adapter.replaceSnapshots(RecommendedSectionType.CHEER_CREATOR, snapshotAt, emptyList())
adapter.replaceSnapshots(
RecommendedSectionType.CHEER_CREATOR,
snapshotAt,
listOf(
RecommendationSnapshotRecord(
sectionType = RecommendedSectionType.CHEER_CREATOR,
targetId = 9L,
score = 90.0,
snapshotAt = snapshotAt,
randomTieBreaker = 0.9
)
)
)
assertEquals(listOf(9L), adapter.findLatestSnapshots(RecommendedSectionType.CHEER_CREATOR).map { it.targetId })
assertEquals(
listOf(9L),
repository.findAll()
.filter { it.sectionType == RecommendedSectionType.CHEER_CREATOR }
.map { it.targetId }
)
}
@Test
fun shouldReplacePopularCommunityEmptyMarkerWithRealSnapshots() {
val snapshotAt = LocalDateTime.of(2026, 5, 29, 23, 59, 59)
adapter.replaceSnapshots(RecommendedSectionType.POPULAR_COMMUNITY, snapshotAt, emptyList())
adapter.replaceSnapshots(
RecommendedSectionType.POPULAR_COMMUNITY,
snapshotAt,
listOf(
RecommendationSnapshotRecord(
sectionType = RecommendedSectionType.POPULAR_COMMUNITY,
targetId = 19L,
score = 190.0,
snapshotAt = snapshotAt,
randomTieBreaker = 0.9
)
)
)
assertEquals(listOf(19L), adapter.findSnapshots(RecommendedSectionType.POPULAR_COMMUNITY, snapshotAt).map { it.targetId })
assertEquals(
listOf(19L),
repository.findAll()
.filter { it.sectionType == RecommendedSectionType.POPULAR_COMMUNITY }
.map { it.targetId }
)
}
private fun snapshot( private fun snapshot(
sectionType: RecommendedSectionType, sectionType: RecommendedSectionType,
targetId: Long, targetId: Long,

View File

@@ -18,7 +18,10 @@ import kr.co.vividnext.sodalive.v2.recommendation.port.out.RecommendationSnapsho
import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.DisplayName import org.junit.jupiter.api.DisplayName
import org.junit.jupiter.api.Test import org.junit.jupiter.api.Test
import java.time.Clock
import java.time.Instant
import java.time.LocalDateTime import java.time.LocalDateTime
import java.time.ZoneOffset
class HomeRecommendationQueryServiceTest { class HomeRecommendationQueryServiceTest {
private val port = FakeHomeRecommendationQueryPort() private val port = FakeHomeRecommendationQueryPort()
@@ -141,8 +144,10 @@ class HomeRecommendationQueryServiceTest {
} }
@Test @Test
@DisplayName("AI 캐릭터 추천은 최신 스냅샷 10개를 기준으로 순서를 유지해 상세를 조립한다") @DisplayName("AI 캐릭터 추천은 최신 스냅샷 20개를 기준으로 순서를 유지해 상세를 조립한다")
fun shouldFindAiCharactersFromLatestSnapshotsWithLimitAndDetails() { fun shouldFindAiCharactersFromLatestSnapshotsWithLimitAndDetails() {
val fallback = FakeAiCharacterSnapshotFallbackPort(emptyList())
val service = HomeRecommendationQueryService(port, snapshotPort, fallback)
val oldSnapshotAt = LocalDateTime.of(2026, 5, 28, 23, 59, 59) val oldSnapshotAt = LocalDateTime.of(2026, 5, 28, 23, 59, 59)
val latestSnapshotAt = LocalDateTime.of(2026, 5, 29, 23, 59, 59) val latestSnapshotAt = LocalDateTime.of(2026, 5, 29, 23, 59, 59)
snapshotPort.replaceSnapshots( snapshotPort.replaceSnapshots(
@@ -153,7 +158,7 @@ class HomeRecommendationQueryServiceTest {
snapshotPort.replaceSnapshots( snapshotPort.replaceSnapshots(
RecommendedSectionType.AI_CHARACTER, RecommendedSectionType.AI_CHARACTER,
latestSnapshotAt, latestSnapshotAt,
(1L..12L).map { targetId -> (1L..22L).map { targetId ->
snapshot(RecommendedSectionType.AI_CHARACTER, targetId, 100.0 - targetId, latestSnapshotAt) snapshot(RecommendedSectionType.AI_CHARACTER, targetId, 100.0 - targetId, latestSnapshotAt)
} }
) )
@@ -180,7 +185,9 @@ class HomeRecommendationQueryServiceTest {
val characters = service.findAiCharacterRecommendations() val characters = service.findAiCharacterRecommendations()
assertEquals((1L..10L).toList(), port.aiCharacterDetailIds) assertEquals(null, fallback.offset)
assertEquals(null, fallback.limit)
assertEquals((1L..20L).toList(), port.aiCharacterDetailIds)
assertEquals(listOf(1L, 2L), characters.map { it.characterId }) assertEquals(listOf(1L, 2L), characters.map { it.characterId })
assertEquals(listOf(101L, 102L), characters.map { it.creatorId }) assertEquals(listOf(101L, 102L), characters.map { it.creatorId })
assertEquals("profile/character-1.png", characters.first().profileImage) assertEquals("profile/character-1.png", characters.first().profileImage)
@@ -189,10 +196,101 @@ class HomeRecommendationQueryServiceTest {
assertEquals(null, characters.last().originalWorkTitle) assertEquals(null, characters.last().originalWorkTitle)
} }
@Test
@DisplayName("AI 캐릭터 추천은 최신 스냅샷이 없으면 fallback refresh 후 스냅샷 상세를 조립한다")
fun shouldFindAiCharactersAfterFallbackWhenLatestSnapshotsDoNotExist() {
val fallback = FakeAiCharacterSnapshotFallbackPort(
listOf(snapshot(RecommendedSectionType.AI_CHARACTER, 10L, 10.0, LocalDateTime.of(2026, 7, 9, 14, 59, 59)))
)
val service = HomeRecommendationQueryService(port, snapshotPort, fallback)
port.aiCharacterDetails = listOf(
HomeAiCharacterRecommendationRecord(
characterId = 10L,
creatorId = 110L,
name = "fallback-character",
description = "description",
profileImage = null,
totalChatCount = 1L,
originalWorkTitle = null
)
)
val characters = service.findAiCharacterRecommendations()
assertEquals(0L, fallback.offset)
assertEquals(20, fallback.limit)
assertEquals(listOf(10L), port.aiCharacterDetailIds)
assertEquals(listOf(10L), characters.map { it.characterId })
}
@Test
@DisplayName("AI 캐릭터 추천은 fallback 후에도 스냅샷이 없으면 빈 배열을 반환한다")
fun shouldReturnEmptyAiCharactersWhenFallbackDoesNotCreateSnapshots() {
val fallback = FakeAiCharacterSnapshotFallbackPort(emptyList())
val service = HomeRecommendationQueryService(port, snapshotPort, fallback)
val characters = service.findAiCharacterRecommendations()
assertEquals(0L, fallback.offset)
assertEquals(20, fallback.limit)
assertEquals(emptyList<Long>(), port.aiCharacterDetailIds)
assertEquals(emptyList<HomeAiCharacterRecommendationRecord>(), characters)
}
@Test
@DisplayName("AI 캐릭터 추천은 빈 스냅샷 refresh 완료 marker가 있으면 fallback refresh를 반복하지 않는다")
fun shouldNotFallbackWhenEmptyAiSnapshotCycleExists() {
val fallback = FakeAiCharacterSnapshotFallbackPort(
listOf(snapshot(RecommendedSectionType.AI_CHARACTER, 99L, 99.0, LocalDateTime.of(2026, 7, 9, 14, 59, 59)))
)
val service = HomeRecommendationQueryService(port, snapshotPort, fallback)
snapshotPort.replaceSnapshots(
RecommendedSectionType.AI_CHARACTER,
LocalDateTime.of(2026, 7, 9, 14, 59, 59),
emptyList()
)
val characters = service.findAiCharacterRecommendations()
assertEquals(null, fallback.offset)
assertEquals(null, fallback.limit)
assertEquals(emptyList<Long>(), port.aiCharacterDetailIds)
assertEquals(emptyList<HomeAiCharacterRecommendationRecord>(), characters)
}
@Test
@DisplayName("AI 캐릭터 추천은 최신 스냅샷이 있으면 요청 페이지가 비어도 fallback refresh를 호출하지 않는다")
fun shouldNotFallbackWhenLatestAiSnapshotsExistButRequestedPageIsEmpty() {
val fallback = FakeAiCharacterSnapshotFallbackPort(
listOf(snapshot(RecommendedSectionType.AI_CHARACTER, 99L, 99.0, LocalDateTime.of(2026, 7, 9, 14, 59, 59)))
)
val service = HomeRecommendationQueryService(port, snapshotPort, fallback)
val snapshotAt = LocalDateTime.of(2026, 7, 9, 14, 59, 59)
snapshotPort.replaceSnapshots(
RecommendedSectionType.AI_CHARACTER,
snapshotAt,
(1L..20L).map { targetId -> snapshot(RecommendedSectionType.AI_CHARACTER, targetId, 100.0 - targetId, snapshotAt) }
)
val characters = service.findAiCharacterRecommendations(offset = 20, limit = 20)
assertEquals(null, fallback.offset)
assertEquals(null, fallback.limit)
assertEquals(emptyList<Long>(), port.aiCharacterDetailIds)
assertEquals(emptyList<HomeAiCharacterRecommendationRecord>(), characters)
}
@Test @Test
@DisplayName("최근 응원 크리에이터 추천은 최신 스냅샷 8명을 기준으로 닉네임과 프로필을 조립한다") @DisplayName("최근 응원 크리에이터 추천은 최신 스냅샷 8명을 기준으로 닉네임과 프로필을 조립한다")
fun shouldFindCheerCreatorsFromLatestSnapshotsWithLimitAndDetails() { fun shouldFindCheerCreatorsFromLatestSnapshotsWithLimitAndDetails() {
val snapshotAt = LocalDateTime.of(2026, 5, 29, 23, 59, 59) val fallback = FakeCheerCreatorSnapshotFallbackPort(emptyList())
val service = HomeRecommendationQueryService(
port,
snapshotPort,
cheerCreatorSnapshotFallbackService = fallback,
clock = fixedClock()
)
val snapshotAt = LocalDateTime.of(2026, 7, 9, 14, 59, 59)
snapshotPort.replaceSnapshots( snapshotPort.replaceSnapshots(
RecommendedSectionType.CHEER_CREATOR, RecommendedSectionType.CHEER_CREATOR,
snapshotAt, snapshotAt,
@@ -215,6 +313,8 @@ class HomeRecommendationQueryServiceTest {
val creators = service.findCheerCreatorRecommendations(memberId = 104L) val creators = service.findCheerCreatorRecommendations(memberId = 104L)
assertEquals(null, fallback.offset)
assertEquals(null, fallback.limit)
assertEquals((1L..9L).toList(), port.cheerCreatorDetailIds) assertEquals((1L..9L).toList(), port.cheerCreatorDetailIds)
assertEquals(104L, port.cheerCreatorMemberId) assertEquals(104L, port.cheerCreatorMemberId)
assertEquals(listOf(1L, 2L), creators.map { it.creatorId }) assertEquals(listOf(1L, 2L), creators.map { it.creatorId })
@@ -222,9 +322,100 @@ class HomeRecommendationQueryServiceTest {
} }
@Test @Test
@DisplayName("인기 커뮤니티 추천은 최신 스냅샷 10개를 기준으로 크리에이터 중복을 제거하고 상세를 조립한다") @DisplayName("최근 응원 크리에이터 추천은 최신 스냅샷이 없으면 fallback refresh 후 스냅샷 상세를 조립한다")
fun shouldFindPopularCommunitiesFromLatestSnapshotsWithLimitDetailsAndCreatorUniqueness() { fun shouldFindCheerCreatorsAfterFallbackWhenLatestSnapshotsDoNotExist() {
val snapshotAt = LocalDateTime.of(2026, 5, 29, 23, 59, 59) val fallback = FakeCheerCreatorSnapshotFallbackPort(
listOf(snapshot(RecommendedSectionType.CHEER_CREATOR, 10L, 10.0, LocalDateTime.of(2026, 7, 9, 14, 59, 59)))
)
val service = HomeRecommendationQueryService(
port,
snapshotPort,
cheerCreatorSnapshotFallbackService = fallback,
clock = fixedClock()
)
port.cheerCreatorDetails = listOf(
HomeCheerCreatorRecommendationRecord(
creatorId = 10L,
creatorNickname = "fallback-creator",
creatorProfileImage = null
)
)
val creators = service.findCheerCreatorRecommendations(memberId = 104L)
assertEquals(0L, fallback.offset)
assertEquals(16, fallback.limit)
assertEquals(LocalDateTime.of(2026, 7, 9, 21, 0), fallback.nowUtc)
assertEquals(listOf(10L), port.cheerCreatorDetailIds)
assertEquals(listOf(10L), creators.map { it.creatorId })
}
@Test
@DisplayName("최근 응원 크리에이터 추천은 오래된 스냅샷만 있으면 fallback refresh를 호출한다")
fun shouldFallbackWhenOnlyStaleCheerCreatorSnapshotsExist() {
val fallback = FakeCheerCreatorSnapshotFallbackPort(
listOf(snapshot(RecommendedSectionType.CHEER_CREATOR, 10L, 10.0, LocalDateTime.of(2026, 7, 9, 14, 59, 59)))
)
val service = HomeRecommendationQueryService(
port,
snapshotPort,
cheerCreatorSnapshotFallbackService = fallback,
clock = fixedClock()
)
val staleSnapshotAt = LocalDateTime.of(2026, 7, 8, 14, 59, 59)
snapshotPort.replaceSnapshots(
RecommendedSectionType.CHEER_CREATOR,
staleSnapshotAt,
listOf(snapshot(RecommendedSectionType.CHEER_CREATOR, 99L, 99.0, staleSnapshotAt))
)
port.cheerCreatorDetails = listOf(
HomeCheerCreatorRecommendationRecord(
creatorId = 10L,
creatorNickname = "fallback-creator",
creatorProfileImage = null
)
)
val creators = service.findCheerCreatorRecommendations(memberId = 104L)
assertEquals(0L, fallback.offset)
assertEquals(16, fallback.limit)
assertEquals(LocalDateTime.of(2026, 7, 9, 21, 0), fallback.nowUtc)
assertEquals(listOf(10L), port.cheerCreatorDetailIds)
assertEquals(listOf(10L), creators.map { it.creatorId })
}
@Test
@DisplayName("최근 응원 크리에이터 추천은 빈 스냅샷 refresh 완료 marker가 있으면 fallback refresh를 반복하지 않는다")
fun shouldNotFallbackWhenEmptyCheerCreatorSnapshotCycleExists() {
val fallback = FakeCheerCreatorSnapshotFallbackPort(
listOf(snapshot(RecommendedSectionType.CHEER_CREATOR, 99L, 99.0, LocalDateTime.of(2026, 7, 9, 14, 59, 59)))
)
val service = HomeRecommendationQueryService(
port,
snapshotPort,
cheerCreatorSnapshotFallbackService = fallback,
clock = fixedClock()
)
snapshotPort.replaceSnapshots(
RecommendedSectionType.CHEER_CREATOR,
LocalDateTime.of(2026, 7, 9, 14, 59, 59),
emptyList()
)
val creators = service.findCheerCreatorRecommendations(memberId = 104L)
assertEquals(null, fallback.offset)
assertEquals(null, fallback.limit)
assertEquals(emptyList<Long>(), port.cheerCreatorDetailIds)
assertEquals(emptyList<HomeCheerCreatorRecommendationRecord>(), creators)
}
@Test
@DisplayName("인기 커뮤니티 추천은 대상일 스냅샷 10개를 기준으로 크리에이터 중복을 제거하고 상세를 조립한다")
fun shouldFindPopularCommunitiesFromTargetDaySnapshotsWithLimitDetailsAndCreatorUniqueness() {
val service = HomeRecommendationQueryService(port, snapshotPort, clock = fixedClock())
val snapshotAt = LocalDateTime.of(2026, 7, 9, 14, 59, 59)
snapshotPort.replaceSnapshots( snapshotPort.replaceSnapshots(
RecommendedSectionType.POPULAR_COMMUNITY, RecommendedSectionType.POPULAR_COMMUNITY,
snapshotAt, snapshotAt,
@@ -297,7 +488,8 @@ class HomeRecommendationQueryServiceTest {
@Test @Test
@DisplayName("인기 커뮤니티 추천은 상위 스냅샷 중복 제거 후 뒤 후보로 기본 10개를 채운다") @DisplayName("인기 커뮤니티 추천은 상위 스냅샷 중복 제거 후 뒤 후보로 기본 10개를 채운다")
fun shouldBackfillPopularCommunitiesAfterRemovingDuplicateCreators() { fun shouldBackfillPopularCommunitiesAfterRemovingDuplicateCreators() {
val snapshotAt = LocalDateTime.of(2026, 5, 29, 23, 59, 59) val service = HomeRecommendationQueryService(port, snapshotPort, clock = fixedClock())
val snapshotAt = LocalDateTime.of(2026, 7, 9, 14, 59, 59)
snapshotPort.replaceSnapshots( snapshotPort.replaceSnapshots(
RecommendedSectionType.POPULAR_COMMUNITY, RecommendedSectionType.POPULAR_COMMUNITY,
snapshotAt, snapshotAt,
@@ -331,6 +523,77 @@ class HomeRecommendationQueryServiceTest {
assertEquals(communities.size, communities.map { it.creatorId }.toSet().size) assertEquals(communities.size, communities.map { it.creatorId }.toSet().size)
} }
@Test
@DisplayName("인기 커뮤니티 추천은 대상일 스냅샷이 없고 오래된 스냅샷만 있으면 fallback refresh를 호출한다")
fun shouldFallbackWhenOnlyStalePopularCommunitySnapshotsExist() {
val fallback = FakePopularCommunitySnapshotFallbackPort(
listOf(snapshot(RecommendedSectionType.POPULAR_COMMUNITY, 10L, 10.0, LocalDateTime.of(2026, 7, 9, 14, 59, 59)))
)
val service = HomeRecommendationQueryService(
port,
snapshotPort,
popularCommunitySnapshotFallbackService = fallback,
clock = fixedClock()
)
val staleSnapshotAt = LocalDateTime.of(2026, 7, 8, 14, 59, 59)
snapshotPort.replaceSnapshots(
RecommendedSectionType.POPULAR_COMMUNITY,
staleSnapshotAt,
listOf(snapshot(RecommendedSectionType.POPULAR_COMMUNITY, 99L, 99.0, staleSnapshotAt))
)
port.popularCommunityDetails = listOf(
HomePopularCommunityRecommendationRecord(
communityId = 10L,
creatorId = 110L,
creatorNickname = "fallback-community-creator",
creatorProfileImage = null,
imagePath = null,
audioPath = null,
content = "content",
price = 0,
createdAt = LocalDateTime.of(2026, 7, 9, 1, 0),
likeCount = 0L,
commentCount = 0L,
existOrdered = false,
isLiked = false
)
)
val communities = service.findPopularCommunityRecommendations(memberId = 105L)
assertEquals(0L, fallback.offset)
assertEquals(20, fallback.limit)
assertEquals(LocalDateTime.of(2026, 7, 9, 21, 0), fallback.nowUtc)
assertEquals(listOf(10L), port.popularCommunityDetailIds)
assertEquals(listOf(10L), communities.map { it.communityId })
}
@Test
@DisplayName("인기 커뮤니티 추천은 빈 스냅샷 refresh 완료 marker가 있으면 fallback refresh를 반복하지 않는다")
fun shouldNotFallbackWhenEmptyPopularCommunitySnapshotCycleExists() {
val fallback = FakePopularCommunitySnapshotFallbackPort(
listOf(snapshot(RecommendedSectionType.POPULAR_COMMUNITY, 99L, 99.0, LocalDateTime.of(2026, 7, 9, 14, 59, 59)))
)
val service = HomeRecommendationQueryService(
port,
snapshotPort,
popularCommunitySnapshotFallbackService = fallback,
clock = fixedClock()
)
snapshotPort.replaceSnapshots(
RecommendedSectionType.POPULAR_COMMUNITY,
LocalDateTime.of(2026, 7, 9, 14, 59, 59),
emptyList()
)
val communities = service.findPopularCommunityRecommendations(memberId = 105L)
assertEquals(null, fallback.offset)
assertEquals(null, fallback.limit)
assertEquals(emptyList<Long>(), port.popularCommunityDetailIds)
assertEquals(emptyList<HomePopularCommunityRecommendationRecord>(), communities)
}
@Test @Test
@DisplayName("최신 스냅샷이 없으면 AI 캐릭터/최근 응원/인기 커뮤니티 추천은 빈 배열을 반환한다") @DisplayName("최신 스냅샷이 없으면 AI 캐릭터/최근 응원/인기 커뮤니티 추천은 빈 배열을 반환한다")
fun shouldReturnEmptyListWhenLatestSnapshotsDoNotExist() { fun shouldReturnEmptyListWhenLatestSnapshotsDoNotExist() {
@@ -767,19 +1030,19 @@ class HomeRecommendationQueryServiceTest {
override fun findAiCharacterSnapshots( override fun findAiCharacterSnapshots(
windowStart: LocalDateTime, windowStart: LocalDateTime,
snapshotAt: LocalDateTime, windowEndExclusive: LocalDateTime,
limit: Int limit: Int
): List<RecommendationSnapshotRecord> = emptyList() ): List<RecommendationSnapshotRecord> = emptyList()
override fun findCheerCreatorSnapshots( override fun findCheerCreatorSnapshots(
windowStart: LocalDateTime, windowStart: LocalDateTime,
snapshotAt: LocalDateTime, windowEndExclusive: LocalDateTime,
limit: Int limit: Int
): List<RecommendationSnapshotRecord> = emptyList() ): List<RecommendationSnapshotRecord> = emptyList()
override fun findPopularCommunitySnapshots( override fun findPopularCommunitySnapshots(
windowStart: LocalDateTime, windowStart: LocalDateTime,
snapshotAt: LocalDateTime, windowEndExclusive: LocalDateTime,
limit: Int limit: Int
): List<RecommendationSnapshotRecord> = emptyList() ): List<RecommendationSnapshotRecord> = emptyList()
@@ -836,21 +1099,114 @@ private class FakeHomeRecommendationSnapshotPort : RecommendationSnapshotPort {
.maxOfOrNull { it.snapshotAt } .maxOfOrNull { it.snapshotAt }
val all = snapshots val all = snapshots
.filter { it.sectionType == sectionType && it.snapshotAt == latestSnapshotAt } .filter { it.sectionType == sectionType && it.snapshotAt == latestSnapshotAt && it.targetId != 0L }
.sortedWith(compareByDescending<RecommendationSnapshotRecord> { it.score }.thenBy { it.randomTieBreaker }) .sortedWith(compareByDescending<RecommendationSnapshotRecord> { it.score }.thenBy { it.randomTieBreaker })
if (offset == 0L && limit == Int.MAX_VALUE) return all if (offset == 0L && limit == Int.MAX_VALUE) return all
return all.drop(offset.toInt()).take(limit) return all.drop(offset.toInt()).take(limit)
} }
override fun findSnapshots(
sectionType: RecommendedSectionType,
snapshotAt: LocalDateTime,
offset: Long,
limit: Int
): List<RecommendationSnapshotRecord> {
val all = snapshots
.filter { it.sectionType == sectionType && it.snapshotAt == snapshotAt && it.targetId != 0L }
.sortedWith(compareByDescending<RecommendationSnapshotRecord> { it.score }.thenBy { it.randomTieBreaker })
if (offset == 0L && limit == Int.MAX_VALUE) return all
return all.drop(offset.toInt()).take(limit)
}
override fun existsLatestSnapshot(sectionType: RecommendedSectionType): Boolean {
return snapshots.any { it.sectionType == sectionType }
}
override fun existsSnapshot(sectionType: RecommendedSectionType, snapshotAt: LocalDateTime): Boolean {
return snapshots.any { it.sectionType == sectionType && it.snapshotAt == snapshotAt }
}
override fun replaceSnapshots( override fun replaceSnapshots(
sectionType: RecommendedSectionType, sectionType: RecommendedSectionType,
snapshotAt: LocalDateTime, snapshotAt: LocalDateTime,
newSnapshots: List<RecommendationSnapshotRecord> newSnapshots: List<RecommendationSnapshotRecord>
) { ) {
if (newSnapshots.isEmpty() && supportsEmptySnapshotMarker(sectionType)) {
snapshots.removeIf { it.sectionType == sectionType && it.snapshotAt == snapshotAt }
snapshots.add(snapshot(sectionType, targetId = 0L, score = 0.0, snapshotAt = snapshotAt))
return
}
snapshots.removeIf { it.sectionType == sectionType && it.snapshotAt == snapshotAt } snapshots.removeIf { it.sectionType == sectionType && it.snapshotAt == snapshotAt }
snapshots.addAll(newSnapshots) snapshots.addAll(newSnapshots)
} }
private fun supportsEmptySnapshotMarker(sectionType: RecommendedSectionType): Boolean {
return sectionType == RecommendedSectionType.AI_CHARACTER ||
sectionType == RecommendedSectionType.CHEER_CREATOR ||
sectionType == RecommendedSectionType.POPULAR_COMMUNITY
}
}
private fun fixedClock(): Clock {
return Clock.fixed(Instant.parse("2026-07-09T21:00:00Z"), ZoneOffset.UTC)
}
private class FakeCheerCreatorSnapshotFallbackPort(
private val snapshots: List<RecommendationSnapshotRecord>
) : CheerCreatorSnapshotFallbackPort {
var offset: Long? = null
var limit: Int? = null
var nowUtc: LocalDateTime? = null
override fun refreshCheerCreatorIfMissing(
offset: Long,
limit: Int,
nowUtc: LocalDateTime
): List<RecommendationSnapshotRecord> {
this.offset = offset
this.limit = limit
this.nowUtc = nowUtc
return snapshots
}
}
private class FakePopularCommunitySnapshotFallbackPort(
private val snapshots: List<RecommendationSnapshotRecord>
) : PopularCommunitySnapshotFallbackPort {
var offset: Long? = null
var limit: Int? = null
var nowUtc: LocalDateTime? = null
override fun refreshPopularCommunityIfMissing(
offset: Long,
limit: Int,
nowUtc: LocalDateTime
): List<RecommendationSnapshotRecord> {
this.offset = offset
this.limit = limit
this.nowUtc = nowUtc
return snapshots
}
}
private class FakeAiCharacterSnapshotFallbackPort(
private val snapshots: List<RecommendationSnapshotRecord>
) : AiCharacterSnapshotFallbackPort {
var offset: Long? = null
var limit: Int? = null
override fun refreshIfMissing(
offset: Long,
limit: Int,
nowUtc: LocalDateTime
): List<RecommendationSnapshotRecord> {
this.offset = offset
this.limit = limit
return snapshots
}
} }
private fun snapshot( private fun snapshot(

View File

@@ -0,0 +1,443 @@
package kr.co.vividnext.sodalive.v2.recommendation.application
import kr.co.vividnext.sodalive.v2.recommendation.domain.RecommendedSectionType
import kr.co.vividnext.sodalive.v2.recommendation.port.out.RecommendationSnapshotPort
import kr.co.vividnext.sodalive.v2.recommendation.port.out.RecommendationSnapshotRecord
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.DisplayName
import org.junit.jupiter.api.Test
import org.mockito.Mockito
import org.redisson.api.RLock
import org.redisson.api.RedissonClient
import java.time.LocalDateTime
import java.util.concurrent.CountDownLatch
import java.util.concurrent.Executor
import java.util.concurrent.Executors
import java.util.concurrent.TimeUnit
class RecommendationSnapshotFallbackServiceTest {
@Test
@DisplayName("AI fallback은 기대 스냅샷이 이미 있으면 lock과 refresh를 실행하지 않고 즉시 반환한다")
fun shouldReturnExistingAiSnapshotsWithoutLockOrRefresh() {
val snapshotPort = FakeRecommendationFallbackSnapshotPort()
val snapshotAt = LocalDateTime.of(2026, 7, 9, 14, 59, 59)
snapshotPort.replaceSnapshots(
RecommendedSectionType.AI_CHARACTER,
snapshotAt,
listOf(snapshot(RecommendedSectionType.AI_CHARACTER, 10L, snapshotAt))
)
val refreshService = Mockito.mock(RecommendationSnapshotRefreshService::class.java)
val redissonClient = Mockito.mock(RedissonClient::class.java)
val service = RecommendationSnapshotFallbackService(snapshotPort, refreshService, redissonClient, directExecutor())
val snapshots = service.refreshIfMissing(offset = 0, limit = 20, nowUtc = LocalDateTime.of(2026, 7, 9, 21, 0))
assertEquals(listOf(10L), snapshots.map { it.targetId })
Mockito.verifyNoInteractions(redissonClient)
Mockito.verifyNoInteractions(refreshService)
}
@Test
@DisplayName("AI fallback은 section lock을 잡고 공통 refresh 후 기대 스냅샷을 다시 조회한다")
fun shouldRefreshMissingAiSnapshotsWithSectionLock() {
val snapshotPort = FakeRecommendationFallbackSnapshotPort()
val refreshService = Mockito.mock(RecommendationSnapshotRefreshService::class.java)
val redissonClient = Mockito.mock(RedissonClient::class.java)
val lock = Mockito.mock(RLock::class.java)
val nowUtc = LocalDateTime.of(2026, 7, 9, 21, 0)
Mockito.`when`(redissonClient.getLock(RecommendationSnapshotFallbackService.AI_CHARACTER_LOCK_KEY)).thenReturn(lock)
Mockito.`when`(lock.tryLock(300, -1, TimeUnit.MILLISECONDS)).thenReturn(true)
Mockito.`when`(lock.isHeldByCurrentThread).thenReturn(true)
Mockito.doAnswer {
snapshotPort.replaceSnapshots(
RecommendedSectionType.AI_CHARACTER,
LocalDateTime.of(2026, 7, 9, 14, 59, 59),
listOf(snapshot(RecommendedSectionType.AI_CHARACTER, 1L))
)
1
}.`when`(refreshService).refreshAiCharacterSnapshots(nowUtc)
val service = RecommendationSnapshotFallbackService(snapshotPort, refreshService, redissonClient, directExecutor())
val snapshots = service.refreshIfMissing(offset = 0, limit = 20, nowUtc = nowUtc)
assertEquals(listOf(1L), snapshots.map { it.targetId })
Mockito.verify(redissonClient).getLock(RecommendationSnapshotFallbackService.AI_CHARACTER_LOCK_KEY)
Mockito.verify(lock).tryLock(300, -1, TimeUnit.MILLISECONDS)
Mockito.verify(refreshService).refreshAiCharacterSnapshots(nowUtc)
Mockito.verify(lock).unlock()
}
@Test
@DisplayName("응원 크리에이터 fallback은 section lock을 잡고 공통 refresh 후 최신 스냅샷을 다시 조회한다")
fun shouldRefreshMissingCheerCreatorSnapshotsWithSectionLock() {
val snapshotPort = FakeRecommendationFallbackSnapshotPort()
val refreshService = Mockito.mock(RecommendationSnapshotRefreshService::class.java)
val redissonClient = Mockito.mock(RedissonClient::class.java)
val lock = Mockito.mock(RLock::class.java)
val nowUtc = LocalDateTime.of(2026, 7, 9, 21, 0)
Mockito.`when`(redissonClient.getLock(RecommendationSnapshotFallbackService.CHEER_CREATOR_LOCK_KEY)).thenReturn(lock)
Mockito.`when`(lock.tryLock(300, -1, TimeUnit.MILLISECONDS)).thenReturn(true)
Mockito.`when`(lock.isHeldByCurrentThread).thenReturn(true)
Mockito.doAnswer {
snapshotPort.replaceSnapshots(
RecommendedSectionType.CHEER_CREATOR,
LocalDateTime.of(2026, 7, 9, 14, 59, 59),
listOf(snapshot(RecommendedSectionType.CHEER_CREATOR, 1L))
)
1
}.`when`(refreshService).refreshCheerCreatorSnapshots(nowUtc)
val service = RecommendationSnapshotFallbackService(snapshotPort, refreshService, redissonClient, directExecutor())
val snapshots = service.refreshCheerCreatorIfMissing(offset = 0, limit = 16, nowUtc = nowUtc)
assertEquals(listOf(1L), snapshots.map { it.targetId })
Mockito.verify(redissonClient).getLock(RecommendationSnapshotFallbackService.CHEER_CREATOR_LOCK_KEY)
Mockito.verify(lock).tryLock(300, -1, TimeUnit.MILLISECONDS)
Mockito.verify(refreshService).refreshCheerCreatorSnapshots(nowUtc)
Mockito.verify(lock).unlock()
}
@Test
@DisplayName("인기 커뮤니티 fallback은 section lock을 잡고 공통 refresh 후 대상일 스냅샷을 다시 조회한다")
fun shouldRefreshMissingPopularCommunitySnapshotsWithSectionLock() {
val snapshotPort = FakeRecommendationFallbackSnapshotPort()
val refreshService = Mockito.mock(RecommendationSnapshotRefreshService::class.java)
val redissonClient = Mockito.mock(RedissonClient::class.java)
val lock = Mockito.mock(RLock::class.java)
val nowUtc = LocalDateTime.of(2026, 7, 9, 21, 0)
Mockito.`when`(redissonClient.getLock(RecommendationSnapshotFallbackService.POPULAR_COMMUNITY_LOCK_KEY)).thenReturn(lock)
Mockito.`when`(lock.tryLock(300, -1, TimeUnit.MILLISECONDS)).thenReturn(true)
Mockito.`when`(lock.isHeldByCurrentThread).thenReturn(true)
Mockito.doAnswer {
snapshotPort.replaceSnapshots(
RecommendedSectionType.POPULAR_COMMUNITY,
LocalDateTime.of(2026, 7, 9, 14, 59, 59),
listOf(snapshot(RecommendedSectionType.POPULAR_COMMUNITY, 7L))
)
1
}.`when`(refreshService).refreshPopularCommunitySnapshots(nowUtc)
val service = RecommendationSnapshotFallbackService(snapshotPort, refreshService, redissonClient, directExecutor())
val snapshots = service.refreshPopularCommunityIfMissing(offset = 0, limit = 20, nowUtc = nowUtc)
assertEquals(listOf(7L), snapshots.map { it.targetId })
Mockito.verify(redissonClient).getLock(RecommendationSnapshotFallbackService.POPULAR_COMMUNITY_LOCK_KEY)
Mockito.verify(lock).tryLock(300, -1, TimeUnit.MILLISECONDS)
Mockito.verify(refreshService).refreshPopularCommunitySnapshots(nowUtc)
Mockito.verify(lock).unlock()
}
@Test
@DisplayName("인기 커뮤니티 fallback은 빈 스냅샷 marker가 있으면 refresh를 반복하지 않는다")
fun shouldSkipPopularCommunityRefreshWhenEmptySnapshotMarkerExists() {
val snapshotPort = FakeRecommendationFallbackSnapshotPort()
val nowUtc = LocalDateTime.of(2026, 7, 9, 21, 0)
snapshotPort.replaceSnapshots(
RecommendedSectionType.POPULAR_COMMUNITY,
LocalDateTime.of(2026, 7, 9, 14, 59, 59),
emptyList()
)
val refreshService = Mockito.mock(RecommendationSnapshotRefreshService::class.java)
val redissonClient = Mockito.mock(RedissonClient::class.java)
val service = RecommendationSnapshotFallbackService(snapshotPort, refreshService, redissonClient, directExecutor())
val snapshots = service.refreshPopularCommunityIfMissing(offset = 0, limit = 20, nowUtc = nowUtc)
assertEquals(emptyList<RecommendationSnapshotRecord>(), snapshots)
Mockito.verifyNoInteractions(redissonClient)
Mockito.verifyNoInteractions(refreshService)
}
@Test
@DisplayName("응원 크리에이터 fallback은 오래된 스냅샷만 있으면 refresh를 실행한다")
fun shouldRefreshCheerCreatorWhenOnlyStaleSnapshotsExist() {
val snapshotPort = FakeRecommendationFallbackSnapshotPort()
val staleSnapshotAt = LocalDateTime.of(2026, 7, 8, 14, 59, 59)
snapshotPort.replaceSnapshots(
RecommendedSectionType.CHEER_CREATOR,
staleSnapshotAt,
listOf(snapshot(RecommendedSectionType.CHEER_CREATOR, 99L, staleSnapshotAt))
)
val refreshService = Mockito.mock(RecommendationSnapshotRefreshService::class.java)
val redissonClient = Mockito.mock(RedissonClient::class.java)
val lock = Mockito.mock(RLock::class.java)
val nowUtc = LocalDateTime.of(2026, 7, 9, 21, 0)
Mockito.`when`(redissonClient.getLock(RecommendationSnapshotFallbackService.CHEER_CREATOR_LOCK_KEY)).thenReturn(lock)
Mockito.`when`(lock.tryLock(300, -1, TimeUnit.MILLISECONDS)).thenReturn(true)
Mockito.`when`(lock.isHeldByCurrentThread).thenReturn(true)
Mockito.doAnswer {
snapshotPort.replaceSnapshots(
RecommendedSectionType.CHEER_CREATOR,
LocalDateTime.of(2026, 7, 9, 14, 59, 59),
listOf(snapshot(RecommendedSectionType.CHEER_CREATOR, 1L))
)
1
}.`when`(refreshService).refreshCheerCreatorSnapshots(nowUtc)
val service = RecommendationSnapshotFallbackService(snapshotPort, refreshService, redissonClient, directExecutor())
val snapshots = service.refreshCheerCreatorIfMissing(offset = 0, limit = 16, nowUtc = nowUtc)
assertEquals(listOf(1L), snapshots.map { it.targetId })
Mockito.verify(refreshService).refreshCheerCreatorSnapshots(nowUtc)
}
@Test
@DisplayName("응원 크리에이터 fallback은 빈 스냅샷 marker가 있으면 refresh를 반복하지 않는다")
fun shouldSkipCheerCreatorRefreshWhenEmptySnapshotMarkerExists() {
val snapshotPort = FakeRecommendationFallbackSnapshotPort()
val nowUtc = LocalDateTime.of(2026, 7, 9, 21, 0)
snapshotPort.replaceSnapshots(
RecommendedSectionType.CHEER_CREATOR,
LocalDateTime.of(2026, 7, 9, 14, 59, 59),
emptyList()
)
val refreshService = Mockito.mock(RecommendationSnapshotRefreshService::class.java)
val redissonClient = Mockito.mock(RedissonClient::class.java)
val service = RecommendationSnapshotFallbackService(snapshotPort, refreshService, redissonClient, directExecutor())
val snapshots = service.refreshCheerCreatorIfMissing(offset = 0, limit = 16, nowUtc = nowUtc)
assertEquals(emptyList<RecommendationSnapshotRecord>(), snapshots)
Mockito.verifyNoInteractions(redissonClient)
Mockito.verifyNoInteractions(refreshService)
}
@Test
@DisplayName("응원 크리에이터 fallback은 lock 획득 실패 시 refresh를 중복 실행하지 않는다")
fun shouldSkipCheerCreatorRefreshWhenSectionLockIsNotAcquired() {
val snapshotPort = FakeRecommendationFallbackSnapshotPort()
val refreshService = Mockito.mock(RecommendationSnapshotRefreshService::class.java)
val redissonClient = Mockito.mock(RedissonClient::class.java)
val lock = Mockito.mock(RLock::class.java)
val nowUtc = LocalDateTime.of(2026, 7, 9, 21, 0)
Mockito.`when`(redissonClient.getLock(RecommendationSnapshotFallbackService.CHEER_CREATOR_LOCK_KEY)).thenReturn(lock)
Mockito.`when`(lock.tryLock(300, -1, TimeUnit.MILLISECONDS)).thenReturn(false)
Mockito.`when`(lock.isHeldByCurrentThread).thenReturn(false)
val service = RecommendationSnapshotFallbackService(snapshotPort, refreshService, redissonClient, directExecutor())
val snapshots = service.refreshCheerCreatorIfMissing(offset = 0, limit = 16, nowUtc = nowUtc)
assertEquals(emptyList<RecommendationSnapshotRecord>(), snapshots)
Mockito.verifyNoInteractions(refreshService)
Mockito.verify(lock, Mockito.never()).unlock()
}
@Test
@DisplayName("응원 크리에이터 fallback은 refresh 실패 시 예외를 던지지 않고 빈 배열을 반환한다")
fun shouldReturnEmptyWhenCheerCreatorRefreshFails() {
val snapshotPort = FakeRecommendationFallbackSnapshotPort()
val refreshService = Mockito.mock(RecommendationSnapshotRefreshService::class.java)
val redissonClient = Mockito.mock(RedissonClient::class.java)
val lock = Mockito.mock(RLock::class.java)
val nowUtc = LocalDateTime.of(2026, 7, 9, 21, 0)
Mockito.`when`(redissonClient.getLock(RecommendationSnapshotFallbackService.CHEER_CREATOR_LOCK_KEY)).thenReturn(lock)
Mockito.`when`(lock.tryLock(300, -1, TimeUnit.MILLISECONDS)).thenReturn(true)
Mockito.`when`(lock.isHeldByCurrentThread).thenReturn(true)
Mockito.doThrow(IllegalStateException("boom")).`when`(refreshService).refreshCheerCreatorSnapshots(nowUtc)
val service = RecommendationSnapshotFallbackService(snapshotPort, refreshService, redissonClient, directExecutor())
val snapshots = service.refreshCheerCreatorIfMissing(offset = 0, limit = 16, nowUtc = nowUtc)
assertEquals(emptyList<RecommendationSnapshotRecord>(), snapshots)
Mockito.verify(lock).unlock()
}
@Test
@DisplayName("응원 크리에이터 fallback은 timeout 시 빈 배열을 반환하고 worker를 취소하지 않는다")
fun shouldReturnEmptyOnCheerCreatorTimeoutAndKeepWorkerRunning() {
val snapshotPort = FakeRecommendationFallbackSnapshotPort()
val refreshStarted = CountDownLatch(1)
val allowRefreshComplete = CountDownLatch(1)
val refreshService = BlockingCheerRefreshService(snapshotPort, refreshStarted, allowRefreshComplete)
val redissonClient = Mockito.mock(RedissonClient::class.java)
val lock = Mockito.mock(RLock::class.java)
Mockito.`when`(redissonClient.getLock(RecommendationSnapshotFallbackService.CHEER_CREATOR_LOCK_KEY)).thenReturn(lock)
Mockito.`when`(lock.tryLock(300, -1, TimeUnit.MILLISECONDS)).thenReturn(true)
Mockito.`when`(lock.isHeldByCurrentThread).thenReturn(true)
val executor = Executors.newFixedThreadPool(2)
val service = RecommendationSnapshotFallbackService(
snapshotPort,
refreshService,
redissonClient,
executor,
homeWaitMillis = 50
)
val first = service.refreshCheerCreatorIfMissing(offset = 0, limit = 16, nowUtc = LocalDateTime.of(2026, 7, 9, 21, 0))
assertEquals(true, refreshStarted.await(1, TimeUnit.SECONDS))
allowRefreshComplete.countDown()
executor.shutdown()
assertEquals(true, executor.awaitTermination(1, TimeUnit.SECONDS))
val second = service.refreshCheerCreatorIfMissing(
offset = 0,
limit = 16,
nowUtc = LocalDateTime.of(2026, 7, 9, 21, 0)
)
assertEquals(emptyList<RecommendationSnapshotRecord>(), first)
assertEquals(listOf(99L), second.map { it.targetId })
}
@Test
@DisplayName("AI fallback이 오래 걸려도 응원 크리에이터 fallback은 같은 worker queue에서 대기하지 않는다")
fun shouldRunCheerCreatorFallbackWithoutWaitingForBlockedAiFallback() {
val snapshotPort = FakeRecommendationFallbackSnapshotPort()
val aiRefreshStarted = CountDownLatch(1)
val allowAiRefreshComplete = CountDownLatch(1)
val refreshService = BlockingAiAndFastCheerRefreshService(snapshotPort, aiRefreshStarted, allowAiRefreshComplete)
val redissonClient = Mockito.mock(RedissonClient::class.java)
val aiLock = Mockito.mock(RLock::class.java)
val cheerLock = Mockito.mock(RLock::class.java)
Mockito.`when`(redissonClient.getLock(RecommendationSnapshotFallbackService.AI_CHARACTER_LOCK_KEY)).thenReturn(aiLock)
Mockito.`when`(redissonClient.getLock(RecommendationSnapshotFallbackService.CHEER_CREATOR_LOCK_KEY)).thenReturn(cheerLock)
Mockito.`when`(aiLock.tryLock(300, -1, TimeUnit.MILLISECONDS)).thenReturn(true)
Mockito.`when`(aiLock.isHeldByCurrentThread).thenReturn(true)
Mockito.`when`(cheerLock.tryLock(300, -1, TimeUnit.MILLISECONDS)).thenReturn(true)
Mockito.`when`(cheerLock.isHeldByCurrentThread).thenReturn(true)
val service = RecommendationSnapshotFallbackService(
snapshotPort,
refreshService,
redissonClient,
homeWaitMillis = 1_000
)
val aiExecutor = Executors.newSingleThreadExecutor()
val aiFuture = aiExecutor.submit<List<RecommendationSnapshotRecord>> {
service.refreshIfMissing(offset = 0, limit = 20, nowUtc = LocalDateTime.of(2026, 7, 9, 21, 0))
}
assertEquals(true, aiRefreshStarted.await(1, TimeUnit.SECONDS))
val cheerSnapshots = service.refreshCheerCreatorIfMissing(
offset = 0,
limit = 16,
nowUtc = LocalDateTime.of(2026, 7, 9, 21, 0)
)
allowAiRefreshComplete.countDown()
aiExecutor.shutdown()
assertEquals(true, aiExecutor.awaitTermination(1, TimeUnit.SECONDS))
aiFuture.get(1, TimeUnit.SECONDS)
assertEquals(listOf(77L), cheerSnapshots.map { it.targetId })
}
private fun directExecutor(): Executor = Executor { command -> command.run() }
}
private class BlockingCheerRefreshService(
private val snapshotPort: RecommendationSnapshotPort,
private val refreshStarted: CountDownLatch,
private val allowRefreshComplete: CountDownLatch
) : RecommendationSnapshotRefreshService(
snapshotPort,
Mockito.mock(kr.co.vividnext.sodalive.v2.recommendation.port.out.HomeRecommendationQueryPort::class.java)
) {
override fun refreshCheerCreatorSnapshots(nowUtc: LocalDateTime): Int {
refreshStarted.countDown()
allowRefreshComplete.await(1, TimeUnit.SECONDS)
snapshotPort.replaceSnapshots(
RecommendedSectionType.CHEER_CREATOR,
LocalDateTime.of(2026, 7, 9, 14, 59, 59),
listOf(snapshot(RecommendedSectionType.CHEER_CREATOR, 99L))
)
return 1
}
}
private class BlockingAiAndFastCheerRefreshService(
private val snapshotPort: RecommendationSnapshotPort,
private val aiRefreshStarted: CountDownLatch,
private val allowAiRefreshComplete: CountDownLatch
) : RecommendationSnapshotRefreshService(
snapshotPort,
Mockito.mock(kr.co.vividnext.sodalive.v2.recommendation.port.out.HomeRecommendationQueryPort::class.java)
) {
override fun refreshAiCharacterSnapshots(nowUtc: LocalDateTime): Int {
aiRefreshStarted.countDown()
allowAiRefreshComplete.await(1, TimeUnit.SECONDS)
snapshotPort.replaceSnapshots(
RecommendedSectionType.AI_CHARACTER,
LocalDateTime.of(2026, 7, 9, 14, 59, 59),
listOf(snapshot(RecommendedSectionType.AI_CHARACTER, 55L))
)
return 1
}
override fun refreshCheerCreatorSnapshots(nowUtc: LocalDateTime): Int {
snapshotPort.replaceSnapshots(
RecommendedSectionType.CHEER_CREATOR,
LocalDateTime.of(2026, 7, 9, 14, 59, 59),
listOf(snapshot(RecommendedSectionType.CHEER_CREATOR, 77L))
)
return 1
}
}
private class FakeRecommendationFallbackSnapshotPort : RecommendationSnapshotPort {
private val snapshots = mutableListOf<RecommendationSnapshotRecord>()
override fun findLatestSnapshots(
sectionType: RecommendedSectionType,
offset: Long,
limit: Int
): List<RecommendationSnapshotRecord> {
val latestSnapshotAt = snapshots.filter { it.sectionType == sectionType }.maxOfOrNull { it.snapshotAt }
return snapshots
.filter { it.sectionType == sectionType && it.snapshotAt == latestSnapshotAt && it.targetId != 0L }
.drop(offset.toInt())
.take(limit)
}
override fun findSnapshots(
sectionType: RecommendedSectionType,
snapshotAt: LocalDateTime,
offset: Long,
limit: Int
): List<RecommendationSnapshotRecord> {
return snapshots
.filter { it.sectionType == sectionType && it.snapshotAt == snapshotAt && it.targetId != 0L }
.drop(offset.toInt())
.take(limit)
}
override fun existsLatestSnapshot(sectionType: RecommendedSectionType): Boolean {
return snapshots.any { it.sectionType == sectionType }
}
override fun existsSnapshot(sectionType: RecommendedSectionType, snapshotAt: LocalDateTime): Boolean {
return snapshots.any { it.sectionType == sectionType && it.snapshotAt == snapshotAt }
}
override fun replaceSnapshots(
sectionType: RecommendedSectionType,
snapshotAt: LocalDateTime,
newSnapshots: List<RecommendationSnapshotRecord>
) {
if (newSnapshots.isEmpty() && supportsEmptySnapshotMarker(sectionType)) {
snapshots.removeIf { it.sectionType == sectionType && it.snapshotAt == snapshotAt }
snapshots.add(snapshot(sectionType, 0L, snapshotAt))
return
}
snapshots.removeIf { it.sectionType == sectionType && it.snapshotAt == snapshotAt }
snapshots.addAll(newSnapshots)
}
private fun supportsEmptySnapshotMarker(sectionType: RecommendedSectionType): Boolean {
return sectionType == RecommendedSectionType.AI_CHARACTER ||
sectionType == RecommendedSectionType.CHEER_CREATOR ||
sectionType == RecommendedSectionType.POPULAR_COMMUNITY
}
}
private fun snapshot(
sectionType: RecommendedSectionType,
targetId: Long,
snapshotAt: LocalDateTime = LocalDateTime.of(2026, 7, 9, 14, 59, 59)
): RecommendationSnapshotRecord {
return RecommendationSnapshotRecord(
sectionType = sectionType,
targetId = targetId,
score = 1.0,
snapshotAt = snapshotAt,
randomTieBreaker = 1.0
)
}

View File

@@ -53,32 +53,36 @@ class RecommendationSnapshotRefreshServiceTest {
val queryPort = Mockito.mock(HomeRecommendationQueryPort::class.java) val queryPort = Mockito.mock(HomeRecommendationQueryPort::class.java)
val service = service(snapshotPort = snapshotPort, queryPort = queryPort) val service = service(snapshotPort = snapshotPort, queryPort = queryPort)
val now = LocalDateTime.of(2026, 5, 29, 15, 0, 0) val now = LocalDateTime.of(2026, 5, 29, 15, 0, 0)
val snapshotAt = LocalDateTime.of(2026, 5, 29, 23, 59, 59) val snapshotAt = LocalDateTime.of(2026, 5, 29, 14, 59, 59)
val windowStart = LocalDateTime.of(2026, 5, 23, 0, 0, 0) val windowStart = LocalDateTime.of(2026, 5, 22, 15, 0, 0)
val windowEndExclusive = LocalDateTime.of(2026, 5, 29, 15, 0, 0)
val aiWindowStart = LocalDateTime.of(2026, 5, 28, 15, 0, 0)
val aiSnapshotAt = LocalDateTime.of(2026, 5, 29, 14, 59, 59)
val aiWindowEndExclusive = LocalDateTime.of(2026, 5, 29, 15, 0, 0)
Mockito.`when`(queryPort.findAiCharacterSnapshots(windowStart, snapshotAt, 20)).thenReturn( Mockito.`when`(queryPort.findAiCharacterSnapshots(aiWindowStart, aiWindowEndExclusive, 20)).thenReturn(
listOf( listOf(
RecommendationSnapshotRecord( RecommendationSnapshotRecord(
sectionType = RecommendedSectionType.AI_CHARACTER, sectionType = RecommendedSectionType.AI_CHARACTER,
targetId = 11L, targetId = 11L,
score = 78.0, score = 78.0,
snapshotAt = snapshotAt, snapshotAt = aiSnapshotAt,
randomTieBreaker = 0.1 randomTieBreaker = 0.1
) )
) )
) )
Mockito.`when`(queryPort.findCheerCreatorSnapshots(windowStart, snapshotAt, 16)).thenReturn( Mockito.`when`(queryPort.findCheerCreatorSnapshots(aiWindowStart, aiWindowEndExclusive, 16)).thenReturn(
listOf( listOf(
RecommendationSnapshotRecord( RecommendationSnapshotRecord(
sectionType = RecommendedSectionType.CHEER_CREATOR, sectionType = RecommendedSectionType.CHEER_CREATOR,
targetId = 22L, targetId = 22L,
score = 792.22, score = 792.22,
snapshotAt = snapshotAt, snapshotAt = aiSnapshotAt,
randomTieBreaker = 0.2 randomTieBreaker = 0.2
) )
) )
) )
Mockito.`when`(queryPort.findPopularCommunitySnapshots(windowStart, snapshotAt, 20)).thenReturn( Mockito.`when`(queryPort.findPopularCommunitySnapshots(windowStart, windowEndExclusive, 20)).thenReturn(
listOf( listOf(
RecommendationSnapshotRecord( RecommendationSnapshotRecord(
sectionType = RecommendedSectionType.POPULAR_COMMUNITY, sectionType = RecommendedSectionType.POPULAR_COMMUNITY,
@@ -96,7 +100,7 @@ class RecommendationSnapshotRefreshServiceTest {
val cheerSnapshots = snapshotPort.findLatestSnapshots(RecommendedSectionType.CHEER_CREATOR) val cheerSnapshots = snapshotPort.findLatestSnapshots(RecommendedSectionType.CHEER_CREATOR)
val communitySnapshots = snapshotPort.findLatestSnapshots(RecommendedSectionType.POPULAR_COMMUNITY) val communitySnapshots = snapshotPort.findLatestSnapshots(RecommendedSectionType.POPULAR_COMMUNITY)
assertEquals(snapshotAt, aiSnapshots.single().snapshotAt) assertEquals(aiSnapshotAt, aiSnapshots.single().snapshotAt)
assertEquals(11L, aiSnapshots.single().targetId) assertEquals(11L, aiSnapshots.single().targetId)
assertEquals(78.0, aiSnapshots.single().score, 0.0001) assertEquals(78.0, aiSnapshots.single().score, 0.0001)
assertEquals(0.1, aiSnapshots.single().randomTieBreaker, 0.0001) assertEquals(0.1, aiSnapshots.single().randomTieBreaker, 0.0001)
@@ -107,9 +111,9 @@ class RecommendationSnapshotRefreshServiceTest {
assertEquals(33L, communitySnapshots.single().targetId) assertEquals(33L, communitySnapshots.single().targetId)
assertEquals(40.0, communitySnapshots.single().score, 0.0001) assertEquals(40.0, communitySnapshots.single().score, 0.0001)
Mockito.verify(queryPort).findAiCharacterSnapshots(windowStart, snapshotAt, 20) Mockito.verify(queryPort).findAiCharacterSnapshots(aiWindowStart, aiWindowEndExclusive, 20)
Mockito.verify(queryPort).findCheerCreatorSnapshots(windowStart, snapshotAt, 16) Mockito.verify(queryPort).findCheerCreatorSnapshots(aiWindowStart, aiWindowEndExclusive, 16)
Mockito.verify(queryPort).findPopularCommunitySnapshots(windowStart, snapshotAt, 20) Mockito.verify(queryPort).findPopularCommunitySnapshots(windowStart, windowEndExclusive, 20)
assertEquals(true, output.out.contains("event=recommendation_snapshot_refresh_success")) assertEquals(true, output.out.contains("event=recommendation_snapshot_refresh_success"))
assertEquals(true, output.out.contains("aiCharacterCount=1")) assertEquals(true, output.out.contains("aiCharacterCount=1"))
} }
@@ -120,11 +124,23 @@ class RecommendationSnapshotRefreshServiceTest {
val queryPort = Mockito.mock(HomeRecommendationQueryPort::class.java) val queryPort = Mockito.mock(HomeRecommendationQueryPort::class.java)
val service = service(queryPort = queryPort) val service = service(queryPort = queryPort)
val now = LocalDateTime.of(2026, 5, 29, 15, 0, 0) val now = LocalDateTime.of(2026, 5, 29, 15, 0, 0)
val snapshotAt = LocalDateTime.of(2026, 5, 29, 23, 59, 59) val windowStart = LocalDateTime.of(2026, 5, 22, 15, 0, 0)
val windowStart = LocalDateTime.of(2026, 5, 23, 0, 0, 0) val windowEndExclusive = LocalDateTime.of(2026, 5, 29, 15, 0, 0)
Mockito.`when`(queryPort.findAiCharacterSnapshots(windowStart, snapshotAt, 20)).thenReturn(emptyList()) Mockito.`when`(
Mockito.`when`(queryPort.findCheerCreatorSnapshots(windowStart, snapshotAt, 16)).thenReturn(emptyList()) queryPort.findAiCharacterSnapshots(
Mockito.`when`(queryPort.findPopularCommunitySnapshots(windowStart, snapshotAt, 20)).thenReturn(emptyList()) LocalDateTime.of(2026, 5, 28, 15, 0, 0),
LocalDateTime.of(2026, 5, 29, 15, 0, 0),
20
)
).thenReturn(emptyList())
Mockito.`when`(
queryPort.findCheerCreatorSnapshots(
LocalDateTime.of(2026, 5, 28, 15, 0, 0),
LocalDateTime.of(2026, 5, 29, 15, 0, 0),
16
)
).thenReturn(emptyList())
Mockito.`when`(queryPort.findPopularCommunitySnapshots(windowStart, windowEndExclusive, 20)).thenReturn(emptyList())
TransactionSynchronizationManager.initSynchronization() TransactionSynchronizationManager.initSynchronization()
try { try {
@@ -146,16 +162,31 @@ class RecommendationSnapshotRefreshServiceTest {
val queryPort = Mockito.mock(HomeRecommendationQueryPort::class.java) val queryPort = Mockito.mock(HomeRecommendationQueryPort::class.java)
val service = service(snapshotPort = snapshotPort, queryPort = queryPort) val service = service(snapshotPort = snapshotPort, queryPort = queryPort)
val now = LocalDateTime.of(2026, 5, 29, 15, 0, 0) val now = LocalDateTime.of(2026, 5, 29, 15, 0, 0)
val snapshotAt = LocalDateTime.of(2026, 5, 29, 23, 59, 59) val snapshotAt = LocalDateTime.of(2026, 5, 29, 14, 59, 59)
val windowStart = LocalDateTime.of(2026, 5, 23, 0, 0, 0) val windowStart = LocalDateTime.of(2026, 5, 22, 15, 0, 0)
val windowEndExclusive = LocalDateTime.of(2026, 5, 29, 15, 0, 0)
val aiSnapshotAt = LocalDateTime.of(2026, 5, 29, 14, 59, 59)
val aiWindowEndExclusive = LocalDateTime.of(2026, 5, 29, 15, 0, 0)
Mockito.`when`(queryPort.findAiCharacterSnapshots(windowStart, snapshotAt, 20)).thenReturn( Mockito.`when`(
listOf(snapshot(RecommendedSectionType.AI_CHARACTER, targetId = 25L, score = 25.0, snapshotAt = snapshotAt)) queryPort.findAiCharacterSnapshots(
LocalDateTime.of(2026, 5, 28, 15, 0, 0),
aiWindowEndExclusive,
20
) )
Mockito.`when`(queryPort.findCheerCreatorSnapshots(windowStart, snapshotAt, 16)).thenReturn( ).thenReturn(
listOf(snapshot(RecommendedSectionType.AI_CHARACTER, targetId = 25L, score = 25.0, snapshotAt = aiSnapshotAt))
)
Mockito.`when`(
queryPort.findCheerCreatorSnapshots(
LocalDateTime.of(2026, 5, 28, 15, 0, 0),
aiWindowEndExclusive,
16
)
).thenReturn(
listOf(snapshot(RecommendedSectionType.CHEER_CREATOR, targetId = 120L, score = 120.0, snapshotAt = snapshotAt)) listOf(snapshot(RecommendedSectionType.CHEER_CREATOR, targetId = 120L, score = 120.0, snapshotAt = snapshotAt))
) )
Mockito.`when`(queryPort.findPopularCommunitySnapshots(windowStart, snapshotAt, 20)).thenReturn( Mockito.`when`(queryPort.findPopularCommunitySnapshots(windowStart, windowEndExclusive, 20)).thenReturn(
listOf(snapshot(RecommendedSectionType.POPULAR_COMMUNITY, targetId = 225L, score = 225.0, snapshotAt = snapshotAt)) listOf(snapshot(RecommendedSectionType.POPULAR_COMMUNITY, targetId = 225L, score = 225.0, snapshotAt = snapshotAt))
) )
@@ -166,6 +197,196 @@ class RecommendationSnapshotRefreshServiceTest {
assertEquals(listOf(225L), snapshotPort.findLatestSnapshots(RecommendedSectionType.POPULAR_COMMUNITY).map { it.targetId }) assertEquals(listOf(225L), snapshotPort.findLatestSnapshots(RecommendedSectionType.POPULAR_COMMUNITY).map { it.targetId })
} }
@Test
@DisplayName("일 스냅샷 갱신은 AI 캐릭터 섹션 lock을 획득한 경우에만 AI refresh를 실행한다")
fun shouldRefreshAiCharacterSectionOnlyWhenSectionLockAcquired() {
val snapshotPort = FakeRecommendationSnapshotPort()
val queryPort = Mockito.mock(HomeRecommendationQueryPort::class.java)
val redissonClient = Mockito.mock(RedissonClient::class.java)
val lock = Mockito.mock(RLock::class.java)
val cheerLock = Mockito.mock(RLock::class.java)
val popularLock = Mockito.mock(RLock::class.java)
val service = service(snapshotPort = snapshotPort, queryPort = queryPort, redissonClient = redissonClient)
val now = LocalDateTime.of(2026, 5, 29, 15, 0, 0)
val windowStart = LocalDateTime.of(2026, 5, 22, 15, 0, 0)
val windowEndExclusive = LocalDateTime.of(2026, 5, 29, 15, 0, 0)
Mockito.`when`(redissonClient.getLock(RecommendationSnapshotFallbackService.AI_CHARACTER_LOCK_KEY)).thenReturn(lock)
Mockito.`when`(redissonClient.getLock(RecommendationSnapshotFallbackService.CHEER_CREATOR_LOCK_KEY)).thenReturn(cheerLock)
Mockito.`when`(redissonClient.getLock(RecommendationSnapshotFallbackService.POPULAR_COMMUNITY_LOCK_KEY))
.thenReturn(popularLock)
Mockito.`when`(lock.tryLock(0, -1, TimeUnit.MILLISECONDS)).thenReturn(true)
Mockito.`when`(lock.isHeldByCurrentThread).thenReturn(true)
Mockito.`when`(cheerLock.tryLock(0, -1, TimeUnit.MILLISECONDS)).thenReturn(true)
Mockito.`when`(cheerLock.isHeldByCurrentThread).thenReturn(true)
Mockito.`when`(popularLock.tryLock(0, -1, TimeUnit.MILLISECONDS)).thenReturn(true)
Mockito.`when`(popularLock.isHeldByCurrentThread).thenReturn(true)
Mockito.`when`(
queryPort.findAiCharacterSnapshots(
LocalDateTime.of(2026, 5, 28, 15, 0, 0),
LocalDateTime.of(2026, 5, 29, 15, 0, 0),
20
)
).thenReturn(emptyList())
Mockito.`when`(
queryPort.findCheerCreatorSnapshots(
LocalDateTime.of(2026, 5, 28, 15, 0, 0),
LocalDateTime.of(2026, 5, 29, 15, 0, 0),
16
)
).thenReturn(emptyList())
Mockito.`when`(queryPort.findPopularCommunitySnapshots(windowStart, windowEndExclusive, 20)).thenReturn(emptyList())
service.refreshDailySnapshots(now)
Mockito.verify(redissonClient).getLock(RecommendationSnapshotFallbackService.AI_CHARACTER_LOCK_KEY)
Mockito.verify(lock).tryLock(0, -1, TimeUnit.MILLISECONDS)
Mockito.verify(queryPort).findAiCharacterSnapshots(
LocalDateTime.of(2026, 5, 28, 15, 0, 0),
LocalDateTime.of(2026, 5, 29, 15, 0, 0),
20
)
Mockito.verify(lock).unlock()
Mockito.verify(cheerLock).unlock()
Mockito.verify(popularLock).unlock()
}
@Test
@DisplayName("일 스냅샷 갱신은 응원 크리에이터 섹션 lock을 획득한 경우에만 응원 refresh를 실행한다")
fun shouldRefreshCheerCreatorSectionOnlyWhenSectionLockAcquired() {
val snapshotPort = FakeRecommendationSnapshotPort()
val queryPort = Mockito.mock(HomeRecommendationQueryPort::class.java)
val redissonClient = Mockito.mock(RedissonClient::class.java)
val aiLock = Mockito.mock(RLock::class.java)
val cheerLock = Mockito.mock(RLock::class.java)
val popularLock = Mockito.mock(RLock::class.java)
val service = service(snapshotPort = snapshotPort, queryPort = queryPort, redissonClient = redissonClient)
val now = LocalDateTime.of(2026, 5, 29, 15, 0, 0)
val windowStart = LocalDateTime.of(2026, 5, 22, 15, 0, 0)
val windowEndExclusive = LocalDateTime.of(2026, 5, 29, 15, 0, 0)
val snapshotAt = LocalDateTime.of(2026, 5, 29, 14, 59, 59)
val cheerWindowStart = LocalDateTime.of(2026, 5, 28, 15, 0, 0)
val cheerWindowEndExclusive = LocalDateTime.of(2026, 5, 29, 15, 0, 0)
Mockito.`when`(redissonClient.getLock(RecommendationSnapshotFallbackService.AI_CHARACTER_LOCK_KEY)).thenReturn(aiLock)
Mockito.`when`(redissonClient.getLock(RecommendationSnapshotFallbackService.CHEER_CREATOR_LOCK_KEY)).thenReturn(cheerLock)
Mockito.`when`(redissonClient.getLock(RecommendationSnapshotFallbackService.POPULAR_COMMUNITY_LOCK_KEY))
.thenReturn(popularLock)
Mockito.`when`(aiLock.tryLock(0, -1, TimeUnit.MILLISECONDS)).thenReturn(false)
Mockito.`when`(aiLock.isHeldByCurrentThread).thenReturn(false)
Mockito.`when`(cheerLock.tryLock(0, -1, TimeUnit.MILLISECONDS)).thenReturn(true)
Mockito.`when`(cheerLock.isHeldByCurrentThread).thenReturn(true)
Mockito.`when`(popularLock.tryLock(0, -1, TimeUnit.MILLISECONDS)).thenReturn(true)
Mockito.`when`(popularLock.isHeldByCurrentThread).thenReturn(true)
Mockito.`when`(queryPort.findCheerCreatorSnapshots(cheerWindowStart, cheerWindowEndExclusive, 16)).thenReturn(
listOf(snapshot(RecommendedSectionType.CHEER_CREATOR, targetId = 10L, score = 10.0, snapshotAt = snapshotAt))
)
Mockito.`when`(queryPort.findPopularCommunitySnapshots(windowStart, windowEndExclusive, 20)).thenReturn(emptyList())
service.refreshDailySnapshots(now)
Mockito.verify(redissonClient).getLock(RecommendationSnapshotFallbackService.CHEER_CREATOR_LOCK_KEY)
Mockito.verify(cheerLock).tryLock(0, -1, TimeUnit.MILLISECONDS)
Mockito.verify(queryPort).findCheerCreatorSnapshots(cheerWindowStart, cheerWindowEndExclusive, 16)
assertEquals(listOf(10L), snapshotPort.findLatestSnapshots(RecommendedSectionType.CHEER_CREATOR).map { it.targetId })
Mockito.verify(cheerLock).unlock()
Mockito.verify(popularLock).unlock()
}
@Test
@DisplayName("일 스냅샷 갱신은 응원 크리에이터 섹션 lock 획득 실패 시 응원 refresh를 건너뛰고 다른 섹션은 유지한다")
fun shouldSkipOnlyCheerCreatorRefreshWhenSectionLockNotAcquired() {
val snapshotPort = FakeRecommendationSnapshotPort()
val queryPort = Mockito.mock(HomeRecommendationQueryPort::class.java)
val redissonClient = Mockito.mock(RedissonClient::class.java)
val aiLock = Mockito.mock(RLock::class.java)
val cheerLock = Mockito.mock(RLock::class.java)
val popularLock = Mockito.mock(RLock::class.java)
val service = service(snapshotPort = snapshotPort, queryPort = queryPort, redissonClient = redissonClient)
val now = LocalDateTime.of(2026, 5, 29, 15, 0, 0)
val snapshotAt = LocalDateTime.of(2026, 5, 29, 14, 59, 59)
val windowStart = LocalDateTime.of(2026, 5, 22, 15, 0, 0)
val windowEndExclusive = LocalDateTime.of(2026, 5, 29, 15, 0, 0)
Mockito.`when`(redissonClient.getLock(RecommendationSnapshotFallbackService.AI_CHARACTER_LOCK_KEY)).thenReturn(aiLock)
Mockito.`when`(redissonClient.getLock(RecommendationSnapshotFallbackService.CHEER_CREATOR_LOCK_KEY)).thenReturn(cheerLock)
Mockito.`when`(redissonClient.getLock(RecommendationSnapshotFallbackService.POPULAR_COMMUNITY_LOCK_KEY))
.thenReturn(popularLock)
Mockito.`when`(aiLock.tryLock(0, -1, TimeUnit.MILLISECONDS)).thenReturn(false)
Mockito.`when`(aiLock.isHeldByCurrentThread).thenReturn(false)
Mockito.`when`(cheerLock.tryLock(0, -1, TimeUnit.MILLISECONDS)).thenReturn(false)
Mockito.`when`(cheerLock.isHeldByCurrentThread).thenReturn(false)
Mockito.`when`(popularLock.tryLock(0, -1, TimeUnit.MILLISECONDS)).thenReturn(true)
Mockito.`when`(popularLock.isHeldByCurrentThread).thenReturn(true)
Mockito.`when`(queryPort.findPopularCommunitySnapshots(windowStart, windowEndExclusive, 20)).thenReturn(
listOf(snapshot(RecommendedSectionType.POPULAR_COMMUNITY, targetId = 20L, score = 20.0, snapshotAt = snapshotAt))
)
service.refreshDailySnapshots(now)
Mockito.verify(queryPort, Mockito.never()).findCheerCreatorSnapshots(
LocalDateTime.of(2026, 5, 28, 15, 0, 0),
LocalDateTime.of(2026, 5, 29, 15, 0, 0),
16
)
assertEquals(
emptyList<RecommendationSnapshotRecord>(),
snapshotPort.findLatestSnapshots(RecommendedSectionType.CHEER_CREATOR)
)
assertEquals(listOf(20L), snapshotPort.findLatestSnapshots(RecommendedSectionType.POPULAR_COMMUNITY).map { it.targetId })
Mockito.verify(cheerLock, Mockito.never()).unlock()
Mockito.verify(popularLock).unlock()
}
@Test
@DisplayName("일 스냅샷 갱신은 AI 캐릭터 섹션 lock 획득 실패 시 AI refresh만 건너뛰고 다른 섹션은 유지한다")
fun shouldSkipOnlyAiCharacterRefreshWhenSectionLockNotAcquired() {
val snapshotPort = FakeRecommendationSnapshotPort()
val queryPort = Mockito.mock(HomeRecommendationQueryPort::class.java)
val redissonClient = Mockito.mock(RedissonClient::class.java)
val lock = Mockito.mock(RLock::class.java)
val cheerLock = Mockito.mock(RLock::class.java)
val popularLock = Mockito.mock(RLock::class.java)
val service = service(snapshotPort = snapshotPort, queryPort = queryPort, redissonClient = redissonClient)
val now = LocalDateTime.of(2026, 5, 29, 15, 0, 0)
val snapshotAt = LocalDateTime.of(2026, 5, 29, 14, 59, 59)
val windowStart = LocalDateTime.of(2026, 5, 22, 15, 0, 0)
val windowEndExclusive = LocalDateTime.of(2026, 5, 29, 15, 0, 0)
Mockito.`when`(redissonClient.getLock(RecommendationSnapshotFallbackService.AI_CHARACTER_LOCK_KEY)).thenReturn(lock)
Mockito.`when`(redissonClient.getLock(RecommendationSnapshotFallbackService.CHEER_CREATOR_LOCK_KEY)).thenReturn(cheerLock)
Mockito.`when`(redissonClient.getLock(RecommendationSnapshotFallbackService.POPULAR_COMMUNITY_LOCK_KEY))
.thenReturn(popularLock)
Mockito.`when`(lock.tryLock(0, -1, TimeUnit.MILLISECONDS)).thenReturn(false)
Mockito.`when`(lock.isHeldByCurrentThread).thenReturn(false)
Mockito.`when`(cheerLock.tryLock(0, -1, TimeUnit.MILLISECONDS)).thenReturn(true)
Mockito.`when`(cheerLock.isHeldByCurrentThread).thenReturn(true)
Mockito.`when`(popularLock.tryLock(0, -1, TimeUnit.MILLISECONDS)).thenReturn(true)
Mockito.`when`(popularLock.isHeldByCurrentThread).thenReturn(true)
Mockito.`when`(
queryPort.findCheerCreatorSnapshots(
LocalDateTime.of(2026, 5, 28, 15, 0, 0),
LocalDateTime.of(2026, 5, 29, 15, 0, 0),
16
)
).thenReturn(
listOf(snapshot(RecommendedSectionType.CHEER_CREATOR, targetId = 1L, score = 1.0, snapshotAt = snapshotAt))
)
Mockito.`when`(queryPort.findPopularCommunitySnapshots(windowStart, windowEndExclusive, 20)).thenReturn(emptyList())
service.refreshDailySnapshots(now)
Mockito.verify(queryPort, Mockito.never()).findAiCharacterSnapshots(
LocalDateTime.of(2026, 5, 28, 15, 0, 0),
LocalDateTime.of(2026, 5, 29, 15, 0, 0),
20
)
assertEquals(
emptyList<RecommendationSnapshotRecord>(),
snapshotPort.findLatestSnapshots(RecommendedSectionType.AI_CHARACTER)
)
assertEquals(listOf(1L), snapshotPort.findLatestSnapshots(RecommendedSectionType.CHEER_CREATOR).map { it.targetId })
Mockito.verify(lock, Mockito.never()).unlock()
Mockito.verify(cheerLock).unlock()
Mockito.verify(popularLock).unlock()
}
@Test @Test
@DisplayName("추천 스냅샷 스케줄러는 매일 06:00 KST cron으로 갱신 서비스를 호출한다") @DisplayName("추천 스냅샷 스케줄러는 매일 06:00 KST cron으로 갱신 서비스를 호출한다")
fun shouldScheduleDailySnapshotRefreshAtKstSix() { fun shouldScheduleDailySnapshotRefreshAtKstSix() {
@@ -227,11 +448,13 @@ class RecommendationSnapshotRefreshServiceTest {
private fun service( private fun service(
snapshotPort: RecommendationSnapshotPort = FakeRecommendationSnapshotPort(), snapshotPort: RecommendationSnapshotPort = FakeRecommendationSnapshotPort(),
queryPort: HomeRecommendationQueryPort = Mockito.mock(HomeRecommendationQueryPort::class.java) queryPort: HomeRecommendationQueryPort = Mockito.mock(HomeRecommendationQueryPort::class.java),
redissonClient: RedissonClient? = null
): RecommendationSnapshotRefreshService { ): RecommendationSnapshotRefreshService {
return RecommendationSnapshotRefreshService( return RecommendationSnapshotRefreshService(
snapshotPort = snapshotPort, snapshotPort = snapshotPort,
queryPort = queryPort queryPort = queryPort,
redissonClient = redissonClient
) )
} }
@@ -264,19 +487,59 @@ private class FakeRecommendationSnapshotPort : RecommendationSnapshotPort {
.maxOfOrNull { it.snapshotAt } .maxOfOrNull { it.snapshotAt }
val all = snapshots val all = snapshots
.filter { it.sectionType == sectionType && it.snapshotAt == latestSnapshotAt } .filter { it.sectionType == sectionType && it.snapshotAt == latestSnapshotAt && it.targetId != 0L }
.sortedWith(compareByDescending<RecommendationSnapshotRecord> { it.score }.thenBy { it.randomTieBreaker }) .sortedWith(compareByDescending<RecommendationSnapshotRecord> { it.score }.thenBy { it.randomTieBreaker })
if (offset == 0L && limit == Int.MAX_VALUE) return all if (offset == 0L && limit == Int.MAX_VALUE) return all
return all.drop(offset.toInt()).take(limit) return all.drop(offset.toInt()).take(limit)
} }
override fun findSnapshots(
sectionType: RecommendedSectionType,
snapshotAt: LocalDateTime,
offset: Long,
limit: Int
): List<RecommendationSnapshotRecord> {
val all = snapshots
.filter { it.sectionType == sectionType && it.snapshotAt == snapshotAt && it.targetId != 0L }
.sortedWith(compareByDescending<RecommendationSnapshotRecord> { it.score }.thenBy { it.randomTieBreaker })
if (offset == 0L && limit == Int.MAX_VALUE) return all
return all.drop(offset.toInt()).take(limit)
}
override fun existsLatestSnapshot(sectionType: RecommendedSectionType): Boolean {
return snapshots.any { it.sectionType == sectionType }
}
override fun existsSnapshot(sectionType: RecommendedSectionType, snapshotAt: LocalDateTime): Boolean {
return snapshots.any { it.sectionType == sectionType && it.snapshotAt == snapshotAt }
}
override fun replaceSnapshots( override fun replaceSnapshots(
sectionType: RecommendedSectionType, sectionType: RecommendedSectionType,
snapshotAt: LocalDateTime, snapshotAt: LocalDateTime,
newSnapshots: List<RecommendationSnapshotRecord> newSnapshots: List<RecommendationSnapshotRecord>
) { ) {
if (newSnapshots.isEmpty() && supportsEmptySnapshotMarker(sectionType)) {
snapshots.removeIf { it.sectionType == sectionType && it.snapshotAt == snapshotAt }
snapshots.add(
RecommendationSnapshotRecord(
sectionType = sectionType,
targetId = 0L,
score = 0.0,
snapshotAt = snapshotAt,
randomTieBreaker = 0.0
)
)
return
}
snapshots.removeIf { it.sectionType == sectionType && it.snapshotAt == snapshotAt } snapshots.removeIf { it.sectionType == sectionType && it.snapshotAt == snapshotAt }
snapshots.addAll(newSnapshots) snapshots.addAll(newSnapshots)
} }
private fun supportsEmptySnapshotMarker(sectionType: RecommendedSectionType): Boolean {
return sectionType == RecommendedSectionType.AI_CHARACTER || sectionType == RecommendedSectionType.CHEER_CREATOR
}
} }

View File

@@ -27,10 +27,20 @@ class RecommendationScorePolicyTest {
fun shouldApplyAiCharacterNewBoostByCreatedDays() { fun shouldApplyAiCharacterNewBoostByCreatedDays() {
val now = LocalDateTime.of(2026, 5, 30, 12, 0) val now = LocalDateTime.of(2026, 5, 30, 12, 0)
assertEquals(1.5, policy.calculateAiCharacterNewBoost(now.minusDays(10), now), 0.0001) assertEquals(1.15, RecommendationScoreSpec.AI_NEW_BOOST_10_DAYS, 0.0001)
assertEquals(1.3, policy.calculateAiCharacterNewBoost(now.minusDays(20), now), 0.0001) assertEquals(1.10, RecommendationScoreSpec.AI_NEW_BOOST_20_DAYS, 0.0001)
assertEquals(1.2, policy.calculateAiCharacterNewBoost(now.minusDays(30), now), 0.0001) assertEquals(1.05, RecommendationScoreSpec.AI_NEW_BOOST_30_DAYS, 0.0001)
assertEquals(1.15, policy.calculateAiCharacterNewBoost(now, now), 0.0001)
assertEquals(1.15, policy.calculateAiCharacterNewBoost(now.minusDays(10), now), 0.0001)
assertEquals(1.10, policy.calculateAiCharacterNewBoost(now.minusDays(11), now), 0.0001)
assertEquals(1.10, policy.calculateAiCharacterNewBoost(now.minusDays(20), now), 0.0001)
assertEquals(1.05, policy.calculateAiCharacterNewBoost(now.minusDays(21), now), 0.0001)
assertEquals(1.05, policy.calculateAiCharacterNewBoost(now.minusDays(30), now), 0.0001)
assertEquals(1.0, policy.calculateAiCharacterNewBoost(now.minusDays(31), now), 0.0001) assertEquals(1.0, policy.calculateAiCharacterNewBoost(now.minusDays(31), now), 0.0001)
assertEquals(1.5, policy.calculateCreatorNewBoost(now.minusDays(10), now), 0.0001)
assertEquals(1.3, policy.calculateCreatorNewBoost(now.minusDays(20), now), 0.0001)
assertEquals(1.2, policy.calculateCreatorNewBoost(now.minusDays(30), now), 0.0001)
} }
@Test @Test
@@ -51,52 +61,90 @@ class RecommendationScorePolicyTest {
} }
@Test @Test
@DisplayName("AI 채팅 추천 점수는 이번 스프린트에서 팔로우 증가량을 제외한다") @DisplayName("AI 채팅 추천 점수는 AI 발화 수, 활성 사용자 수, 팔로우 증가량 가중치와 신규 부스트를 적용한다")
fun shouldCalculateAiChatScore() { fun shouldCalculateAiChatScore() {
assertEquals(0.45, RecommendationScoreSpec.AI_RECENT_CHAT_WEIGHT, 0.0001) assertEquals(0.45, RecommendationScoreSpec.AI_CHAT_WEIGHT, 0.0001)
assertEquals(0.35, RecommendationScoreSpec.AI_RECENT_ACTIVE_USER_WEIGHT, 0.0001) assertEquals(0.35, RecommendationScoreSpec.AI_ACTIVE_USER_WEIGHT, 0.0001)
assertEquals(0.20, RecommendationScoreSpec.AI_FOLLOW_INCREASE_WEIGHT, 0.0001)
val score = policy.calculateAiChatScore( assertEquals(0.0, policy.calculateAiChatScore(0, 0, 0, 1.0), 0.0001)
recentChatCount = 100, assertEquals(4.5, policy.calculateAiChatScore(10, 0, 0, 1.0), 0.0001)
recentActiveUserCount = 20, assertEquals(3.5, policy.calculateAiChatScore(0, 10, 0, 1.0), 0.0001)
newBoost = 1.3 assertEquals(2.0, policy.calculateAiChatScore(0, 0, 10, 1.0), 0.0001)
) assertEquals(10.0, policy.calculateAiChatScore(10, 10, 10, 1.0), 0.0001)
assertEquals(11.5, policy.calculateAiChatScore(10, 10, 10, 1.15), 0.0001)
assertEquals(67.6, score, 0.0001)
} }
@Test @Test
@DisplayName("최근 응원 추천 점수는 후원 금액, 팬 Talk 수, 후원 수에 가중치를 적용한다") @DisplayName("최근 응원 추천 점수는 후원 금액, 팬 Talk 수, 후원 수에 가중치를 적용한다")
fun shouldCalculateCheerScore() { fun shouldCalculateCheerScore() {
assertEquals(0.6, RecommendationScoreSpec.CHEER_DONATION_AMOUNT_WEIGHT, 0.0001) assertEquals(0.45, RecommendationScoreSpec.CHEER_DONATION_AMOUNT_WEIGHT, 0.0001)
assertEquals(0.3, RecommendationScoreSpec.CHEER_FAN_TALK_WEIGHT, 0.0001) assertEquals(0.3, RecommendationScoreSpec.CHEER_FAN_TALK_WEIGHT, 0.0001)
assertEquals(0.1, RecommendationScoreSpec.CHEER_DONATION_COUNT_WEIGHT, 0.0001) assertEquals(0.1, RecommendationScoreSpec.CHEER_DONATION_COUNT_WEIGHT, 0.0001)
val score = policy.calculateCheerScore( assertEquals(0.0, policy.calculateCheerScore(0, 0, 0, 1.0), 0.0001)
donationAmount = 1000, assertEquals(45.0, policy.calculateCheerScore(100, 0, 0, 1.0), 0.0001)
fanTalkCount = 20, assertEquals(3.0, policy.calculateCheerScore(0, 10, 0, 1.0), 0.0001)
donationCount = 10, assertEquals(1.0, policy.calculateCheerScore(0, 0, 10, 1.0), 0.0001)
newBoost = 1.2 assertEquals(49.0, policy.calculateCheerScore(100, 10, 10, 1.0), 0.0001)
) assertEquals(56.35, policy.calculateCheerScore(100, 10, 10, 1.15), 0.0001)
}
assertEquals(728.4, score, 0.0001) @Test
@DisplayName("최근 응원 크리에이터 신규 부스트는 데뷔일 기준 10일/20일/30일 구간을 적용한다")
fun shouldApplyCheerCreatorNewBoostByDebutDays() {
val now = LocalDateTime.of(2026, 5, 30, 12, 0)
assertEquals(1.15, RecommendationScoreSpec.CHEER_NEW_BOOST_10_DAYS, 0.0001)
assertEquals(1.10, RecommendationScoreSpec.CHEER_NEW_BOOST_20_DAYS, 0.0001)
assertEquals(1.05, RecommendationScoreSpec.CHEER_NEW_BOOST_30_DAYS, 0.0001)
assertEquals(1.15, policy.calculateCheerCreatorNewBoost(now, now), 0.0001)
assertEquals(1.15, policy.calculateCheerCreatorNewBoost(now.minusDays(10), now), 0.0001)
assertEquals(1.10, policy.calculateCheerCreatorNewBoost(now.minusDays(11), now), 0.0001)
assertEquals(1.10, policy.calculateCheerCreatorNewBoost(now.minusDays(20), now), 0.0001)
assertEquals(1.05, policy.calculateCheerCreatorNewBoost(now.minusDays(21), now), 0.0001)
assertEquals(1.05, policy.calculateCheerCreatorNewBoost(now.minusDays(30), now), 0.0001)
assertEquals(1.0, policy.calculateCheerCreatorNewBoost(now.minusDays(31), now), 0.0001)
assertEquals(1.5, policy.calculateCreatorNewBoost(now.minusDays(10), now), 0.0001)
assertEquals(1.3, policy.calculateCreatorNewBoost(now.minusDays(20), now), 0.0001)
assertEquals(1.2, policy.calculateCreatorNewBoost(now.minusDays(30), now), 0.0001)
} }
@Test @Test
@DisplayName("인기 커뮤니티 점수는 좋아요 수, 댓글 수, 팔로우 수에 가중치를 적용한다") @DisplayName("인기 커뮤니티 점수는 좋아요 수, 댓글 수, 팔로우 수에 가중치를 적용한다")
fun shouldCalculateCommunityScore() { fun shouldCalculateCommunityScore() {
assertEquals(0.5, RecommendationScoreSpec.COMMUNITY_LIKE_WEIGHT, 0.0001) assertEquals(0.5, RecommendationScoreSpec.COMMUNITY_LIKE_WEIGHT, 0.0001)
assertEquals(0.5, RecommendationScoreSpec.COMMUNITY_COMMENT_WEIGHT, 0.0001) assertEquals(0.4, RecommendationScoreSpec.COMMUNITY_COMMENT_WEIGHT, 0.0001)
assertEquals(0.1, RecommendationScoreSpec.COMMUNITY_FOLLOWER_WEIGHT, 0.0001) assertEquals(0.1, RecommendationScoreSpec.COMMUNITY_FOLLOWER_WEIGHT, 0.0001)
val score = policy.calculateCommunityScore( assertEquals(0.0, policy.calculateCommunityScore(0, 0, 0, 1.0), 0.0001)
likeCount = 40, assertEquals(5.0, policy.calculateCommunityScore(10, 0, 0, 1.0), 0.0001)
commentCount = 20, assertEquals(4.0, policy.calculateCommunityScore(0, 10, 0, 1.0), 0.0001)
followerCount = 100, assertEquals(1.0, policy.calculateCommunityScore(0, 0, 10, 1.0), 0.0001)
newBoost = 1.2 assertEquals(38.0, policy.calculateCommunityScore(40, 20, 100, 1.0), 0.0001)
) assertEquals(43.7, policy.calculateCommunityScore(40, 20, 100, 1.15), 0.0001)
}
assertEquals(48.0, score, 0.0001) @Test
@DisplayName("인기 커뮤니티 신규 부스트는 데뷔일 기준 10일/20일/30일 구간을 적용한다")
fun shouldApplyCommunityCreatorNewBoostByDebutDays() {
val now = LocalDateTime.of(2026, 5, 30, 12, 0)
assertEquals(1.15, RecommendationScoreSpec.COMMUNITY_NEW_BOOST_10_DAYS, 0.0001)
assertEquals(1.10, RecommendationScoreSpec.COMMUNITY_NEW_BOOST_20_DAYS, 0.0001)
assertEquals(1.05, RecommendationScoreSpec.COMMUNITY_NEW_BOOST_30_DAYS, 0.0001)
assertEquals(1.15, policy.calculateCommunityCreatorNewBoost(now, now), 0.0001)
assertEquals(1.15, policy.calculateCommunityCreatorNewBoost(now.minusDays(10), now), 0.0001)
assertEquals(1.10, policy.calculateCommunityCreatorNewBoost(now.minusDays(11), now), 0.0001)
assertEquals(1.10, policy.calculateCommunityCreatorNewBoost(now.minusDays(20), now), 0.0001)
assertEquals(1.05, policy.calculateCommunityCreatorNewBoost(now.minusDays(21), now), 0.0001)
assertEquals(1.05, policy.calculateCommunityCreatorNewBoost(now.minusDays(30), now), 0.0001)
assertEquals(1.0, policy.calculateCommunityCreatorNewBoost(now.minusDays(31), now), 0.0001)
assertEquals(1.5, policy.calculateCreatorNewBoost(now.minusDays(10), now), 0.0001)
assertEquals(1.3, policy.calculateCreatorNewBoost(now.minusDays(20), now), 0.0001)
assertEquals(1.2, policy.calculateCreatorNewBoost(now.minusDays(30), now), 0.0001)
} }
@Test @Test

View File

@@ -0,0 +1,30 @@
package kr.co.vividnext.sodalive.v2.recommendation.domain
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.DisplayName
import org.junit.jupiter.api.Test
import java.time.LocalDateTime
class RecommendationSnapshotWindowPolicyTest {
private val policy = RecommendationSnapshotWindowPolicy()
@Test
@DisplayName("KST 전날 하루를 UTC 조회 범위로 변환한다")
fun shouldConvertPreviousKstDayToUtcWindow() {
val window = policy.previousKstDayUtcWindow(LocalDateTime.of(2026, 7, 9, 21, 0, 0))
assertEquals(LocalDateTime.of(2026, 7, 8, 15, 0, 0), window.startUtc)
assertEquals(LocalDateTime.of(2026, 7, 9, 15, 0, 0), window.endExclusiveUtc)
assertEquals(LocalDateTime.of(2026, 7, 9, 14, 59, 59), window.snapshotAt)
}
@Test
@DisplayName("KST 전날을 포함한 최근 7일을 UTC 조회 범위로 변환한다")
fun shouldConvertPreviousKstSevenDaysToUtcWindow() {
val window = policy.previousKstSevenDayUtcWindow(LocalDateTime.of(2026, 7, 10, 3, 0, 0))
assertEquals(LocalDateTime.of(2026, 7, 2, 15, 0, 0), window.startUtc)
assertEquals(LocalDateTime.of(2026, 7, 9, 15, 0, 0), window.endExclusiveUtc)
assertEquals(LocalDateTime.of(2026, 7, 9, 14, 59, 59), window.snapshotAt)
}
}