Compare commits

...

26 Commits

Author SHA1 Message Date
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
37 changed files with 4179 additions and 257 deletions

View File

@@ -247,7 +247,7 @@
- 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 테스트를 함께 작성한다.
- 실패 확인: `./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 고급 기능이 필요한 쿼리에만 남긴다.
- 기대 결과: 특정 섹션 데이터가 부족해도 service가 가능한 개수만 반환한다.
@@ -639,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
- Feature A: Phase 3, Phase 6, Phase 7에서 통합 조회, limit, 인증/비회원, 팔로우 제외, 콘텐츠 조회 이력, 본인인증 여부, 차단 필터, 스냅샷 빈 배열 처리를 검증한다.
- 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 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 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` 노출을 검증한다.
@@ -659,6 +685,7 @@
## 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-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`를 재실행해 모두 성공한 뒤 리뷰어 재검토에서 승인받았다.

View File

@@ -119,7 +119,7 @@
- 라이브 다시듣기는 콘텐츠 업로드 시 `다시듣기` 테마로 올린 경우를 의미한다.
- 노출 정보는 크리에이터 프로필 이미지, 닉네임, 활동 타입, UTC 기반 활동 시간, 이동 대상 id를 포함한다.
- 라이브 활동은 별도 이동 대상 id가 필요하지 않다.
- 라이브 외 활동은 오디오/라이브 다시듣기 콘텐츠 id를 내려주며, 커뮤니티 활동은 커뮤니티 게시글 작성자 크리에이터 id를 내려준다.
- 라이브 외 활동은 오디오/라이브 다시듣기 콘텐츠 id를 내려주며, 커뮤니티 활동은 `creator_community.id`를 내려준다.
- 크리에이터당 최신 활동 1개만 노출한다.
#### Edge Cases

View File

@@ -2,7 +2,7 @@
> **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는 전체 탭 전용으로 최소 필드만 둔다.
@@ -25,6 +25,9 @@
- `dayOfWeek`가 invalid이면 요일 조건을 적용하지 않고 `dayOfWeek = null`로 fallback한다.
- `type != SERIES`이면 `dayOfWeek`는 조회 조건에 적용하지 않고 응답에서 `null`로 내려준다.
- `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`를 포함한다.
- `AUDIO`, `FREE`, `POINT``audios`만 채우고 `series`는 빈 배열로 내려준다.
- `SERIES`, `ORIGINAL``series`만 채우고 `audios`는 빈 배열로 내려준다.
@@ -243,6 +246,7 @@ interface MainContentAllQueryPort {
memberId: Long?,
canViewAdultContent: Boolean,
now: LocalDateTime,
onlyPaid: Boolean = false,
onlyFree: Boolean = false,
onlyPointAvailable: Boolean = false
): Int
@@ -254,6 +258,7 @@ interface MainContentAllQueryPort {
sort: ContentSort,
offset: Long,
limit: Int,
onlyPaid: Boolean = false,
onlyFree: Boolean = false,
onlyPointAvailable: Boolean = false
): List<MainContentAllAudio>
@@ -559,6 +564,41 @@ interface MainContentAllQueryPort {
- 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 테스트의 부재 검증만 검색되었다.
### 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. 실행 명령
@@ -570,6 +610,7 @@ interface MainContentAllQueryPort {
- 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`
- 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 ktlintCheck`
- 문서 변경 후 명령 유효성 확인: `./gradlew tasks --all`
@@ -612,3 +653,17 @@ interface MainContentAllQueryPort {
- GREEN: `./gradlew ktlintCheck` 성공.
- 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 테스트의 부재 검증만 검색되었다.
- 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
## 1. Overview
메인 콘텐츠 탭의 내부 전체 탭에서 오디오, 시리즈, 오리지널, 무료, 포인트 구분별 공개 콘텐츠를 정렬과 페이징으로 조회하는 v2 API를 제공한다.
메인 콘텐츠 탭의 내부 전체 탭에서 유료 오디오, 시리즈, 오리지널, 무료, 포인트 구분별 공개 콘텐츠를 정렬과 페이징으로 조회하는 v2 API를 제공한다.
---
@@ -17,6 +17,8 @@
- 메인 콘텐츠 전체 탭 조회 API를 `kr.co.vividnext.sodalive.v2` 하위 신규 코드로 제공한다.
- 기존 패턴과 동일하게 API 조립 계층과 도메인 조회 계층을 분리한다.
- 구분은 `AUDIO`, `SERIES`, `ORIGINAL`, `FREE`, `POINT`를 지원한다.
- `AUDIO` 구분은 유료 오디오 콘텐츠만 조회한다.
- `FREE` 구분은 무료 오디오 콘텐츠만 조회하며, `AUDIO` 구분 결과와 가격 조건이 겹치지 않는다.
- 공개된 콘텐츠만 조회한다.
- 회원이 차단했거나 회원을 차단한 크리에이터의 콘텐츠는 노출하지 않는다.
- 비회원은 19금 콘텐츠를 노출하지 않는다.
@@ -48,7 +50,7 @@
---
## 6. User Stories
- 사용자는 오디오 콘텐츠 전체 목록을 최신순, 인기순, 가격순으로 보고 싶다.
- 사용자는 유료 오디오 콘텐츠 전체 목록을 최신순, 인기순, 가격순으로 보고 싶다.
- 사용자는 선택한 요일의 시리즈 목록을 보고 싶다.
- 사용자는 오리지널 시리즈만 따로 보고 싶다.
- 사용자는 무료 오디오만 따로 보고 싶다.
@@ -68,7 +70,7 @@
- 회원 조회 시 `@AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?` 패턴을 사용한다.
- 요청 query parameter는 `type`, `sort`, `dayOfWeek`, `page`, `size`를 사용한다.
- `type` 값은 아래 enum으로 정의한다.
- `AUDIO`: 오디오
- `AUDIO`: 유료 오디오
- `SERIES`: 시리즈
- `ORIGINAL`: 오리지널
- `FREE`: 무료
@@ -117,13 +119,15 @@
### Feature C. 오디오 구분
#### Requirements
- `type=AUDIO`는 차단 관계가 아닌 모든 크리에이터의 공개 오디오 콘텐츠를 조회한다.
- 전체 개수는 같은 공개/차단/성인 콘텐츠 조건을 적용한 오디오 콘텐츠 개수다.
- `type=AUDIO`는 차단 관계가 아닌 모든 크리에이터의 유료 공개 오디오 콘텐츠를 조회한다.
- 유료 오디오는 `price > 0`인 공개 오디오로 정의한다.
- 전체 개수는 같은 공개/차단/성인 콘텐츠 조건과 유료 조건을 적용한 오디오 콘텐츠 개수다.
- 응답 목록은 `audios`에 내려주고 `series`는 빈 배열로 내려준다.
- 응답 item은 기존 추천 탭의 `AudioCardResponse` 필드 의미를 우선 재사용한다.
#### Edge Cases
- 시리즈에 속하지 않은 오디오도 목록에 포함한다.
- 시리즈에 속하지 않은 유료 오디오도 목록에 포함한다.
- 무료 오디오는 `type=AUDIO` 목록과 개수에서 제외하고 `type=FREE`에서만 조회한다.
- 오디오의 오리지널 여부는 기존 추천 탭과 동일하게 해당 오디오가 속한 시리즈의 `isOriginal` 기준으로 판단한다.
### Feature D. 시리즈 구분
@@ -161,7 +165,8 @@
#### Requirements
- `type=FREE`는 차단 관계가 아닌 모든 크리에이터의 무료 오디오 콘텐츠를 조회한다.
- 무료 오디오는 `price == 0`인 공개 오디오로 정의한다.
- 정렬, 페이징, 전체 개수, 성인 콘텐츠 정책은 `AUDIO`와 동일하다.
- 공개/차단/성인 콘텐츠 정책과 정렬, 페이징, 전체 개수 산정 방식은 오디오 공통 정책을 따른다.
- `type=FREE``type=AUDIO`의 유료 조건을 상속하지 않는다.
- 응답 목록은 `audios`에 내려주고 `series`는 빈 배열로 내려준다.
#### Edge Cases
@@ -172,7 +177,8 @@
#### Requirements
- `type=POINT`는 차단 관계가 아닌 모든 크리에이터의 포인트 사용 가능 오디오 콘텐츠를 조회한다.
- 포인트 오디오는 `isPointAvailable == true`인 공개 오디오로 정의한다.
- 정렬, 페이징, 전체 개수, 성인 콘텐츠 정책은 `AUDIO`와 동일하다.
- 공개/차단/성인 콘텐츠 정책과 정렬, 페이징, 전체 개수 산정 방식은 오디오 공통 정책을 따른다.
- `type=POINT``type=AUDIO`의 유료 조건을 상속하지 않고 `isPointAvailable == true` 조건만 추가한다.
- 응답 목록은 `audios`에 내려주고 `series`는 빈 배열로 내려준다.
#### Edge Cases
@@ -215,7 +221,7 @@ Authorization: Bearer {accessToken} (optional)
- 비회원 조회를 허용한다.
- `SecurityConfig``GET /api/v2/audio/contents` permitAll 설정을 추가한다.
- `type` 미지정 시 `AUDIO`를 기본값으로 사용한다.
- `type` 미지정 시 `AUDIO`를 기본값으로 사용하며, 유료 오디오만 조회한다.
- `sort` 미지정 또는 invalid 값은 `LATEST`로 fallback한다.
- `type=SERIES`에서 요일 선택이 필요하면 `dayOfWeek`를 함께 보낸다.
- 예: `GET /api/v2/audio/contents?type=SERIES&dayOfWeek=MON&sort=LATEST&page=0&size=20`
@@ -310,6 +316,8 @@ data class MainContentSeriesResponse(
- `LangContext`: 시리즈 제목 다국어 처리
### 구현 주의사항
- `type=AUDIO``price > 0` 조건을 적용하고, `type=FREE``price == 0` 조건을 적용해 두 구분의 결과가 겹치지 않게 한다.
- `type=POINT``isPointAvailable == true` 조건을 유지하며, `AUDIO` 전용 유료 필터를 암묵적으로 재사용하지 않는다.
- 기존 추천 탭의 무료/포인트 오디오는 랜덤 조회지만, 전체 탭은 사용자가 선택한 `sort` 기준으로 조회한다.
- 기존 legacy 요일별 시리즈 API는 `dayOfWeek` query parameter로 `SeriesPublishedDaysOfWeek` enum을 받으므로 v2 전체 탭도 같은 parameter 이름과 enum 값을 사용한다.
- 기존 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,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

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

View File

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

View File

@@ -46,7 +46,8 @@ class MainContentAllQueryService(
page = resolvedPage,
memberId = memberId,
canViewAdultContent = canViewAdultContent,
now = now
now = now,
onlyPaid = true
)
MainContentAllType.FREE -> getAudioContents(
@@ -102,10 +103,18 @@ class MainContentAllQueryService(
memberId: Long?,
canViewAdultContent: Boolean,
now: LocalDateTime,
onlyPaid: Boolean = false,
onlyFree: Boolean = false,
onlyPointAvailable: Boolean = false
): 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(
memberId = memberId,
canViewAdultContent = canViewAdultContent,
@@ -113,6 +122,7 @@ class MainContentAllQueryService(
sort = sort,
offset = page.offset,
limit = page.size + 1,
onlyPaid = onlyPaid,
onlyFree = onlyFree,
onlyPointAvailable = onlyPointAvailable
)

View File

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

View File

@@ -173,7 +173,7 @@ class DefaultHomeRecommendationQueryRepository(
m.profile_image as creator_profile_image,
'COMMUNITY' as activity_type,
cc.created_at as activity_at,
m.id as target_id,
cc.id as target_id,
cc.id as target_sort_id
from creator_community cc
join member m on m.id = cc.member_id
@@ -483,81 +483,97 @@ class DefaultHomeRecommendationQueryRepository(
override fun findAiCharacterSnapshots(
windowStart: LocalDateTime,
snapshotAt: LocalDateTime,
windowEndExclusive: LocalDateTime,
limit: Int
): List<RecommendationSnapshotRecord> {
val sql = """
select cc.id as target_id,
(((select count(cm.id)
from chat_message cm
join chat_participant cp on cp.id = cm.participant_id
where cp.character_id = cc.id
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
when cc.created_at >= :boost10Start then ${RecommendationScoreSpec.NEW_BOOST_10_DAYS}
when cc.created_at >= :boost20Start then ${RecommendationScoreSpec.NEW_BOOST_20_DAYS}
when cc.created_at >= :boost30Start then ${RecommendationScoreSpec.NEW_BOOST_30_DAYS}
else ${RecommendationScoreSpec.DEFAULT_NEW_BOOST}
end) as score,
rand() as random_tie_breaker
from chat_character cc
where cc.is_active = true
and (exists (
select 1
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,
cc.created_at as character_created_at,
((coalesce(acs.ai_chat_count, 0) * ${RecommendationScoreSpec.AI_CHAT_WEIGHT} +
coalesce(aus.active_user_count, 0) * ${RecommendationScoreSpec.AI_ACTIVE_USER_WEIGHT} +
coalesce(fs.follow_increase_count, 0) * ${RecommendationScoreSpec.AI_FOLLOW_INCREASE_WEIGHT}) *
case
when cc.created_at >= :boost10Start then ${RecommendationScoreSpec.AI_NEW_BOOST_10_DAYS}
when cc.created_at >= :boost20Start then ${RecommendationScoreSpec.AI_NEW_BOOST_20_DAYS}
when cc.created_at >= :boost30Start then ${RecommendationScoreSpec.AI_NEW_BOOST_30_DAYS}
else ${RecommendationScoreSpec.DEFAULT_NEW_BOOST}
end) as score
from chat_character cc
join member creator_member on creator_member.id = cc.creator_member_id
left join (
select cp.character_id as character_id, count(cm.id) as ai_chat_count
from chat_message cm
join chat_participant cp on cp.id = cm.participant_id
where cp.character_id = cc.id
and cm.created_at >= :windowStart
and cm.created_at <= :snapshotAt
where cm.created_at >= :windowStart
and cm.created_at < :windowEndExclusive
and cm.is_active = true
and cp.is_active = true
and cp.participant_type = 'CHARACTER'
) or exists (
select 1
group by cp.character_id
) 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
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
where cm.created_at >= :windowStart
and cm.created_at < :windowEndExclusive
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
))
order by score desc, random_tie_breaker asc
group by cp.character_id
) 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
""".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(
windowStart: LocalDateTime,
snapshotAt: LocalDateTime,
windowEndExclusive: LocalDateTime,
limit: Int
): List<RecommendationSnapshotRecord> {
val snapshotAt = windowEndExclusive.minusSeconds(1)
val sql = """
with creator_debut as (
select debut_events.creator_id as creator_id, min(debut_events.debut_at) as debut_at
@@ -580,14 +596,14 @@ class DefaultHomeRecommendationQueryRepository(
donation_stats as (
select ucc.recipient_creator_id as creator_id,
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
join use_can uc on uc.id = ucc.use_can_id
where ucc.status = 'RECEIVED'
and uc.is_refund = false
and uc.can_usage = 'CHANNEL_DONATION'
and ucc.created_at >= :windowStart
and ucc.created_at <= :snapshotAt
and ucc.created_at < :windowEndExclusive
group by ucc.recipient_creator_id
),
fan_talk_stats as (
@@ -595,17 +611,17 @@ class DefaultHomeRecommendationQueryRepository(
from creator_cheers ch
where ch.is_active = true
and ch.created_at >= :windowStart
and ch.created_at <= :snapshotAt
and ch.created_at < :windowEndExclusive
group by ch.creator_id
)
select m.id as target_id,
((coalesce(ds.donation_amount, 0) * ${RecommendationScoreSpec.CHEER_DONATION_AMOUNT_WEIGHT} +
coalesce(fts.fan_talk_count, 0) * ${RecommendationScoreSpec.CHEER_FAN_TALK_WEIGHT} +
coalesce(ds.donation_count, 0) * ${RecommendationScoreSpec.CHEER_DONATION_COUNT_WEIGHT}) *
case
when cd.debut_at >= :boost10Start then ${RecommendationScoreSpec.NEW_BOOST_10_DAYS}
when cd.debut_at >= :boost20Start then ${RecommendationScoreSpec.NEW_BOOST_20_DAYS}
when cd.debut_at >= :boost30Start then ${RecommendationScoreSpec.NEW_BOOST_30_DAYS}
coalesce(ds.donation_count, 0) * ${RecommendationScoreSpec.CHEER_DONATION_COUNT_WEIGHT}) *
case
when cd.debut_at >= :boost10Start then ${RecommendationScoreSpec.CHEER_NEW_BOOST_10_DAYS}
when cd.debut_at >= :boost20Start then ${RecommendationScoreSpec.CHEER_NEW_BOOST_20_DAYS}
when cd.debut_at >= :boost30Start then ${RecommendationScoreSpec.CHEER_NEW_BOOST_30_DAYS}
else ${RecommendationScoreSpec.DEFAULT_NEW_BOOST}
end) as score,
rand() as random_tie_breaker
@@ -620,14 +636,22 @@ class DefaultHomeRecommendationQueryRepository(
limit :limit
""".trimIndent()
return executeSnapshotQuery(sql, RecommendedSectionType.CHEER_CREATOR, windowStart, snapshotAt, limit)
return executeSnapshotQuery(
sql,
RecommendedSectionType.CHEER_CREATOR,
windowStart,
snapshotAt,
limit,
windowEndExclusive
)
}
override fun findPopularCommunitySnapshots(
windowStart: LocalDateTime,
snapshotAt: LocalDateTime,
windowEndExclusive: LocalDateTime,
limit: Int
): List<RecommendationSnapshotRecord> {
val snapshotAt = windowEndExclusive.minusSeconds(1)
val sql = """
with creator_debut as (
select debut_events.creator_id as creator_id, min(debut_events.debut_at) as debut_at
@@ -652,7 +676,7 @@ class DefaultHomeRecommendationQueryRepository(
from creator_community_like ccl
where ccl.is_active = true
and ccl.created_at >= :windowStart
and ccl.created_at <= :snapshotAt
and ccl.created_at < :windowEndExclusive
group by ccl.creator_community_id
),
comment_stats as (
@@ -660,7 +684,7 @@ class DefaultHomeRecommendationQueryRepository(
from creator_community_comment ccc
where ccc.is_active = true
and ccc.created_at >= :windowStart
and ccc.created_at <= :snapshotAt
and ccc.created_at < :windowEndExclusive
group by ccc.creator_community_id
),
follower_stats as (
@@ -674,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} +
coalesce(fs.follower_count, 0) * ${RecommendationScoreSpec.COMMUNITY_FOLLOWER_WEIGHT}) *
case
when cd.debut_at >= :boost10Start then ${RecommendationScoreSpec.NEW_BOOST_10_DAYS}
when cd.debut_at >= :boost20Start then ${RecommendationScoreSpec.NEW_BOOST_20_DAYS}
when cd.debut_at >= :boost30Start then ${RecommendationScoreSpec.NEW_BOOST_30_DAYS}
when cd.debut_at >= :boost10Start then ${RecommendationScoreSpec.COMMUNITY_NEW_BOOST_10_DAYS}
when cd.debut_at >= :boost20Start then ${RecommendationScoreSpec.COMMUNITY_NEW_BOOST_20_DAYS}
when cd.debut_at >= :boost30Start then ${RecommendationScoreSpec.COMMUNITY_NEW_BOOST_30_DAYS}
else ${RecommendationScoreSpec.DEFAULT_NEW_BOOST}
end) as score,
rand() as random_tie_breaker
@@ -688,15 +712,27 @@ class DefaultHomeRecommendationQueryRepository(
left join follower_stats fs on fs.creator_id = cc.member_id
where cc.is_active = true
and m.is_active = true
and cc.price = 0
and cc.price <= 0
and cc.is_fixed = false
and cc.created_at <= :snapshotAt
and cc.created_at < :windowEndExclusive
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
limit :limit
""".trimIndent()
return executeSnapshotQuery(sql, RecommendedSectionType.POPULAR_COMMUNITY, windowStart, snapshotAt, limit)
return executeSnapshotQuery(
sql,
RecommendedSectionType.POPULAR_COMMUNITY,
windowStart,
snapshotAt,
limit,
windowEndExclusive
)
}
override fun findAiCharacterRecommendationDetails(
@@ -809,6 +845,7 @@ class DefaultHomeRecommendationQueryRepository(
creatorCommunity.isActive.isTrue,
member.isActive.isTrue,
creatorCommunity.isFixed.isFalse,
creatorCommunity.price.loe(0),
includeAdultCommunityCondition(includeAdultCommunities),
notBlockedCreatorCondition(memberId, member.id),
creatorCommunity.id.`in`(communityIds)
@@ -1129,12 +1166,19 @@ class DefaultHomeRecommendationQueryRepository(
sectionType: RecommendedSectionType,
windowStart: LocalDateTime,
snapshotAt: LocalDateTime,
limit: Int
limit: Int,
windowEndExclusive: LocalDateTime? = null
): List<RecommendationSnapshotRecord> {
val query = entityManager.createNativeQuery(sql)
.setParameter("windowStart", windowStart)
.setParameter("snapshotAt", snapshotAt)
.setParameter("limit", limit)
if (sql.contains(":snapshotAt")) {
query.setParameter("snapshotAt", snapshotAt)
}
if (windowEndExclusive != null) {
query.setParameter("windowEndExclusive", windowEndExclusive)
}
query
.setParameter(
"boost10Start",
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() }
}
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(
sectionType: RecommendedSectionType,
snapshotAt: LocalDateTime,
newSnapshots: List<RecommendationSnapshotRecord>
) {
if (newSnapshots.isEmpty() && supportsEmptySnapshotMarker(sectionType)) {
repository.deleteBySectionTypeAndSnapshotAt(sectionType, snapshotAt)
repository.save(emptySnapshotMarker(sectionType, snapshotAt).toEntity())
return
}
repository.deleteBySectionTypeAndSnapshotAt(sectionType, snapshotAt)
repository.saveAll(newSnapshots.map { it.toEntity() })
}
@@ -46,4 +69,27 @@ class RecommendationSnapshotPersistenceAdapter(
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
where latest.section_type = :sectionType
)
and rs.target_id <> 0
order by rs.score desc, rs.random_tie_breaker asc
limit :limit offset :offset
""",
@@ -28,5 +29,28 @@ interface RecommendationSnapshotRepository : JpaRepository<RecommendationSnapsho
@Param("limit") limit: Int
): 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 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
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.port.out.HomeAiCharacterRecommendationRecord
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.RecentlyActiveCreatorRecord
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.transaction.annotation.Transactional
import java.time.Clock
import java.time.LocalDateTime
@Service
@Transactional(readOnly = true)
class HomeRecommendationQueryService(
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(
offset: Long = 0,
limit: Int = DEFAULT_LIVE_LIMIT,
@@ -71,31 +80,57 @@ class HomeRecommendationQueryService(
offset: Long = 0,
limit: Int = DEFAULT_AI_CHARACTER_LIMIT
): List<HomeAiCharacterRecommendationRecord> {
val snapshots = snapshotPort.findLatestSnapshots(RecommendedSectionType.AI_CHARACTER, offset, limit)
val snapshots = findAiCharacterSnapshotsWithFallback(offset, limit)
val detailsById = queryPort.findAiCharacterRecommendationDetails(snapshots.map { it.targetId })
.associateBy { it.characterId }
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(
limit: Int = DEFAULT_CHEER_CREATOR_LIMIT,
memberId: Long? = null
): 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)
.associateBy { it.creatorId }
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(
limit: Int = DEFAULT_POPULAR_COMMUNITY_LIMIT,
memberId: Long? = null,
includeAdultCommunities: Boolean = false
): List<HomePopularCommunityRecommendationRecord> {
val snapshots = snapshotPort.findLatestSnapshots(RecommendedSectionType.POPULAR_COMMUNITY)
.take(POPULAR_COMMUNITY_CANDIDATE_LIMIT)
val snapshots = findPopularCommunitySnapshotsWithFallback()
val detailsById = queryPort.findPopularCommunityRecommendationDetails(
snapshots.map { it.targetId },
memberId,
@@ -109,6 +144,21 @@ class HomeRecommendationQueryService(
}.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(
memberId: Long?,
includeAdultGenres: Boolean,

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
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.HomeRecommendationQueryPort
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.stereotype.Service
import org.springframework.transaction.annotation.Transactional
import org.springframework.transaction.support.TransactionSynchronization
import org.springframework.transaction.support.TransactionSynchronizationManager
import java.time.LocalDateTime
import java.time.ZoneId
import java.time.ZoneOffset
import java.util.concurrent.TimeUnit
@Service
class RecommendationSnapshotRefreshService(
open class RecommendationSnapshotRefreshService(
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 windowPolicy = RecommendationSnapshotWindowPolicy()
@Transactional(readOnly = true)
fun getLatestSnapshots(sectionType: RecommendedSectionType): List<RecommendationSnapshotRecord> {
@@ -26,24 +31,18 @@ class RecommendationSnapshotRefreshService(
@Transactional
fun refreshDailySnapshots() {
refreshDailySnapshots(LocalDateTime.now())
refreshDailySnapshots(LocalDateTime.now(ZoneOffset.UTC))
}
@Transactional
fun refreshDailySnapshots(now: LocalDateTime) {
val startedAt = System.currentTimeMillis()
val snapshotAt = now
.atZone(UTC_ZONE)
.withZoneSameInstant(KST_ZONE)
.toLocalDate()
.minusDays(1)
.atTime(23, 59, 59)
val windowStart = snapshotAt.toLocalDate().minusDays(6).atStartOfDay()
val snapshotAt = windowPolicy.previousKstDayUtcWindow(now).snapshotAt
runCatching {
val aiCharacterCount = replaceAiCharacterSnapshots(windowStart, snapshotAt)
val cheerCreatorCount = replaceCheerCreatorSnapshots(windowStart, snapshotAt)
val popularCommunityCount = replacePopularCommunitySnapshots(windowStart, snapshotAt)
val aiCharacterCount = refreshAiCharacterSnapshotsWithSectionLock(now)
val cheerCreatorCount = refreshCheerCreatorSnapshotsWithSectionLock(now)
val popularCommunityCount = refreshPopularCommunitySnapshotsWithSectionLock(now)
RefreshCounts(aiCharacterCount, cheerCreatorCount, popularCommunityCount)
}.onSuccess { counts ->
afterCommit {
@@ -69,24 +68,141 @@ class RecommendationSnapshotRefreshService(
}
}
private fun replaceAiCharacterSnapshots(windowStart: LocalDateTime, snapshotAt: LocalDateTime): Int {
val snapshots = queryPort.findAiCharacterSnapshots(windowStart, snapshotAt, AI_CHARACTER_SNAPSHOT_LIMIT)
snapshotPort.replaceSnapshots(RecommendedSectionType.AI_CHARACTER, snapshotAt, snapshots)
@Transactional
open fun refreshAiCharacterSnapshots(nowUtc: LocalDateTime = LocalDateTime.now(ZoneOffset.UTC)): Int {
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
}
private fun replaceCheerCreatorSnapshots(windowStart: LocalDateTime, snapshotAt: LocalDateTime): Int {
val snapshots = queryPort.findCheerCreatorSnapshots(windowStart, snapshotAt, CHEER_CREATOR_SNAPSHOT_LIMIT)
snapshotPort.replaceSnapshots(RecommendedSectionType.CHEER_CREATOR, snapshotAt, snapshots)
@Transactional
open fun refreshCheerCreatorSnapshots(nowUtc: LocalDateTime = LocalDateTime.now(ZoneOffset.UTC)): Int {
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
}
private fun replacePopularCommunitySnapshots(windowStart: LocalDateTime, snapshotAt: LocalDateTime): Int {
val snapshots = queryPort.findPopularCommunitySnapshots(windowStart, snapshotAt, POPULAR_COMMUNITY_SNAPSHOT_LIMIT)
snapshotPort.replaceSnapshots(RecommendedSectionType.POPULAR_COMMUNITY, snapshotAt, snapshots)
@Transactional
open fun refreshPopularCommunitySnapshots(nowUtc: LocalDateTime = LocalDateTime.now(ZoneOffset.UTC)): Int {
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
}
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(
val aiCharacterCount: Int,
val cheerCreatorCount: Int,
@@ -109,7 +225,5 @@ class RecommendationSnapshotRefreshService(
private const val AI_CHARACTER_SNAPSHOT_LIMIT = 20
private const val CHEER_CREATOR_SNAPSHOT_LIMIT = 16
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 {
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(
@@ -28,11 +54,13 @@ class RecommendationScorePolicy {
fun calculateAiChatScore(
recentChatCount: Long,
recentActiveUserCount: Long,
followIncreaseCount: Long,
newBoost: Double
): Double {
return (
(RecommendationScoreSpec.AI_RECENT_CHAT_WEIGHT * recentChatCount) +
(RecommendationScoreSpec.AI_RECENT_ACTIVE_USER_WEIGHT * recentActiveUserCount)
(RecommendationScoreSpec.AI_CHAT_WEIGHT * recentChatCount) +
(RecommendationScoreSpec.AI_ACTIVE_USER_WEIGHT * recentActiveUserCount) +
(RecommendationScoreSpec.AI_FOLLOW_INCREASE_WEIGHT * followIncreaseCount)
) * newBoost
}

View File

@@ -9,19 +9,29 @@ object RecommendationScoreSpec {
const val DEBUT_CONTENT_ACTIVITY_WEIGHT = 0.3
const val DEBUT_COMMUNICATION_WEIGHT = 0.2
const val AI_RECENT_CHAT_WEIGHT = 0.45
const val AI_RECENT_ACTIVE_USER_WEIGHT = 0.35
const val AI_CHAT_WEIGHT = 0.45
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_DONATION_COUNT_WEIGHT = 0.1
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 NEW_BOOST_10_DAYS = 1.5
const val NEW_BOOST_20_DAYS = 1.3
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
}

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(
windowStart: LocalDateTime,
snapshotAt: LocalDateTime,
windowEndExclusive: LocalDateTime,
limit: Int
): List<RecommendationSnapshotRecord>
fun findCheerCreatorSnapshots(
windowStart: LocalDateTime,
snapshotAt: LocalDateTime,
windowEndExclusive: LocalDateTime,
limit: Int
): List<RecommendationSnapshotRecord>
fun findPopularCommunitySnapshots(
windowStart: LocalDateTime,
snapshotAt: LocalDateTime,
windowEndExclusive: LocalDateTime,
limit: Int
): List<RecommendationSnapshotRecord>

View File

@@ -10,6 +10,17 @@ interface RecommendationSnapshotPort {
limit: Int = Int.MAX_VALUE
): 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(
sectionType: RecommendedSectionType,
snapshotAt: LocalDateTime,

View File

@@ -68,6 +68,32 @@ class MainContentAllEndToEndTest @Autowired constructor(
.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
@DisplayName("전체 탭 SERIES API는 dayOfWeek 조건으로 시리즈 응답과 빈 audios를 반환한다")
fun shouldReturnSeriesContentsFilteredByDayOfWeekThroughControllerServiceAndRepository() {
@@ -124,7 +150,7 @@ class MainContentAllEndToEndTest @Autowired constructor(
.andExpect(jsonPath("$.data.hasNext").value(false))
}
private fun createAudioFixture(prefix: String): AudioFixture {
private fun createAudioFixture(prefix: String, freeControl: Boolean = false): AudioFixture {
return transactionTemplate.execute {
val creator = saveMember("$prefix-creator", MemberRole.CREATOR)
val theme = saveTheme("$prefix-theme")
@@ -136,6 +162,16 @@ class MainContentAllEndToEndTest @Autowired constructor(
releaseDate = LocalDateTime.now().minusHours(1),
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.clear()

View File

@@ -590,7 +590,7 @@ class HomeRecommendationControllerTest @Autowired constructor(
sectionType = RecommendedSectionType.AI_CHARACTER,
targetId = characterId,
score = 100.0,
snapshotAt = LocalDateTime.of(2026, 6, 1, 23, 59, 59),
snapshotAt = LocalDateTime.of(2026, 12, 31, 23, 59, 59),
randomTieBreaker = 0.1
)
)

View File

@@ -62,11 +62,14 @@ class DefaultMainContentAllQueryRepositoryTest @Autowired constructor(
flushAndClear()
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 pointAudios = repository.findAudios(null, false, now, ContentSort.LATEST, 0, 20, onlyPointAvailable = true)
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), paidAudios.map { it.audioContentId })
assertEquals(listOf(free.id), freeAudios.map { it.audioContentId })
assertEquals(listOf(point.id), pointAudios.map { it.audioContentId })
assertEquals("https://cdn.test/audio.png", visible.first().imageUrl)

View File

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

View File

@@ -434,7 +434,7 @@ class DefaultHomeRecommendationQueryRepositoryTest @Autowired constructor(
assertEquals(CreatorActivityType.LIVE_REPLAY, byCreatorNickname[replayCreator.nickname]!!.activityType)
assertEquals(replay.id, byCreatorNickname[replayCreator.nickname]!!.targetId)
assertEquals(CreatorActivityType.COMMUNITY, byCreatorNickname[communityCreator.nickname]!!.activityType)
assertEquals(communityCreator.id, byCreatorNickname[communityCreator.nickname]!!.targetId)
assertEquals(community.id, byCreatorNickname[communityCreator.nickname]!!.targetId)
}
@Test
@@ -493,7 +493,7 @@ class DefaultHomeRecommendationQueryRepositoryTest @Autowired constructor(
assertEquals(null, visibleCreators[0].targetId)
assertEquals(null, visibleCreators[1].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[1].activityType)
assertEquals(CreatorActivityType.AUDIO, visibleCreators[2].activityType)
@@ -523,12 +523,15 @@ class DefaultHomeRecommendationQueryRepositoryTest @Autowired constructor(
}
@Test
@DisplayName("AI 캐릭터 스냅샷은 AI 발화 수 중복 없는 활성 사용자 수를 집계하고 팔로우 증가량은 제외한다")
fun shouldFindAiCharacterSnapshotsWithoutFollowIncrease() {
val windowStart = LocalDateTime.of(2026, 5, 23, 0, 0)
val snapshotAt = LocalDateTime.of(2026, 5, 29, 23, 59, 59)
@DisplayName("AI 캐릭터 스냅샷은 전날 AI 발화 수, 중복 없는 활성 사용자 수, 신규 팔로우 증가량을 집계한다")
fun shouldFindAiCharacterSnapshotsWithFollowIncrease() {
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 user1 = saveMember("ai-user-1", 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 inactiveCharacter = saveCharacter("character-2", isActive = false)
val room = saveChatRoom("room-1")
@@ -547,23 +550,32 @@ class DefaultHomeRecommendationQueryRepositoryTest @Autowired constructor(
val inactiveMessage = saveMessage(room, characterParticipant, "message-4", isActive = false)
val oldMessage = saveMessage(room, characterParticipant, "message-5", isActive = true)
val inactiveCharacterMessage = saveMessage(otherRoom, inactiveCharacterUser, "message-6", isActive = true)
updateCreatedAt("ChatMessage", message1.id!!, windowStart.plusDays(1))
updateCreatedAt("ChatMessage", message2.id!!, windowStart.plusDays(2))
updateCreatedAt("ChatMessage", message1.id!!, windowStart.plusHours(1))
updateCreatedAt("ChatMessage", message2.id!!, windowStart.plusHours(2))
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", inactiveMessage.id!!, windowStart.plusDays(3))
updateCreatedAt("ChatMessage", inactiveMessage.id!!, windowStart.plusHours(3))
updateCreatedAt("ChatMessage", oldMessage.id!!, windowStart.minusSeconds(1))
updateCreatedAt("ChatMessage", inactiveCharacterMessage.id!!, windowStart.plusDays(1))
updateCreatedAt("ChatCharacter", character.id!!, LocalDateTime.of(2026, 5, 20, 12, 0))
updateCreatedAt("ChatMessage", inactiveCharacterMessage.id!!, windowStart.plusHours(1))
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()
val snapshots = repository.findAiCharacterSnapshots(windowStart, snapshotAt, limit = 10)
val snapshots = repository.findAiCharacterSnapshots(windowStart, windowEndExclusive, limit = 10)
val expectedScore = scorePolicy.calculateAiChatScore(
recentChatCount = 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(RecommendedSectionType.AI_CHARACTER, snapshots.single().sectionType)
@@ -572,11 +584,75 @@ class DefaultHomeRecommendationQueryRepositoryTest @Autowired constructor(
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
@DisplayName("AI 캐릭터 스냅샷은 DB에서 최종 점수를 계산한 뒤 정렬하고 limit을 적용한다")
fun shouldFindAiCharacterSnapshotsWithDbScoreOrderAndLimit() {
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 user = saveMember("ai-score-user", MemberRole.USER)
val oldHighActivityCharacter = saveCharacter("old-high-activity", isActive = true)
val newLowerActivityCharacter = saveCharacter("new-lower-activity", isActive = true)
@@ -610,47 +686,121 @@ class DefaultHomeRecommendationQueryRepositoryTest @Autowired constructor(
updateCreatedAt("ChatCharacter", newLowerActivityCharacter.id!!, LocalDateTime.of(2026, 5, 20, 0, 0))
flushAndClear()
val snapshots = repository.findAiCharacterSnapshots(windowStart, snapshotAt, limit = 1)
val snapshots = repository.findAiCharacterSnapshots(windowStart, windowEndExclusive, limit = 1)
val expectedScore = scorePolicy.calculateAiChatScore(
recentChatCount = 2,
recentChatCount = 3,
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(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(snapshotAt, snapshots.single().snapshotAt)
}
@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() {
val windowStart = LocalDateTime.of(2026, 5, 23, 0, 0)
val snapshotAt = LocalDateTime.of(2026, 5, 29, 23, 59, 59)
val windowStart = LocalDateTime.of(2026, 5, 28, 15, 0)
val windowEndExclusive = LocalDateTime.of(2026, 5, 29, 15, 0)
val snapshotAt = windowEndExclusive.minusSeconds(1)
val creator = saveMember("cheer-creator", MemberRole.CREATOR)
val donor = saveMember("cheer-donor", MemberRole.USER)
saveAudioContent(creator, LocalDateTime.of(2026, 5, 20, 12, 0), isActive = true)
saveLiveRoom(creator, LocalDateTime.of(2026, 5, 10, 12, 0), channelName = "cheer-channel")
saveUseCanCalculate(
val firstDonation = saveUseCanCalculate(
donor,
creator,
CanUsage.CHANNEL_DONATION,
can = 100,
status = UseCanCalculateStatus.RECEIVED,
isRefund = false,
createdAt = windowStart.plusDays(1)
createdAt = windowStart.plusHours(1)
)
saveUseCanCalculateForUseCan(firstDonation.useCan!!, creator, can = 50, createdAt = windowStart.plusHours(2))
saveUseCanCalculate(
donor,
creator,
CanUsage.CHANNEL_DONATION,
can = 50,
can = 999,
status = UseCanCalculateStatus.RECEIVED,
isRefund = false,
createdAt = snapshotAt
createdAt = windowEndExclusive
)
saveUseCanCalculate(
donor,
@@ -659,7 +809,7 @@ class DefaultHomeRecommendationQueryRepositoryTest @Autowired constructor(
can = 1_000,
status = UseCanCalculateStatus.RECEIVED,
isRefund = false,
createdAt = windowStart.plusDays(1)
createdAt = windowStart.plusHours(1)
)
saveUseCanCalculate(
donor,
@@ -668,7 +818,7 @@ class DefaultHomeRecommendationQueryRepositoryTest @Autowired constructor(
can = 1_000,
status = UseCanCalculateStatus.RECEIVED,
isRefund = false,
createdAt = windowStart.plusDays(1)
createdAt = windowStart.plusHours(1)
)
saveUseCanCalculate(
donor,
@@ -677,7 +827,7 @@ class DefaultHomeRecommendationQueryRepositoryTest @Autowired constructor(
can = 1_000,
status = UseCanCalculateStatus.RECEIVED,
isRefund = false,
createdAt = windowStart.plusDays(1)
createdAt = windowStart.plusHours(1)
)
saveUseCanCalculate(
donor,
@@ -686,7 +836,7 @@ class DefaultHomeRecommendationQueryRepositoryTest @Autowired constructor(
can = 1_000,
status = UseCanCalculateStatus.REFUND,
isRefund = false,
createdAt = windowStart.plusDays(1)
createdAt = windowStart.plusHours(1)
)
saveUseCanCalculate(
donor,
@@ -695,7 +845,7 @@ class DefaultHomeRecommendationQueryRepositoryTest @Autowired constructor(
can = 1_000,
status = UseCanCalculateStatus.RECEIVED,
isRefund = true,
createdAt = windowStart.plusDays(1)
createdAt = windowStart.plusHours(1)
)
saveUseCanCalculate(
donor,
@@ -713,28 +863,29 @@ class DefaultHomeRecommendationQueryRepositoryTest @Autowired constructor(
can = 1_000,
status = UseCanCalculateStatus.RECEIVED,
isRefund = false,
createdAt = windowStart.plusDays(1)
createdAt = windowStart.plusHours(1)
)
val cheer1 = saveCreatorCheers(donor, creator, isActive = true)
val cheer2 = saveCreatorCheers(donor, creator, isActive = true)
val inactiveCheer = saveCreatorCheers(donor, creator, isActive = false)
updateCreatedAt("CreatorCheers", cheer1.id!!, windowStart.plusDays(1))
updateCreatedAt("CreatorCheers", cheer2.id!!, snapshotAt)
updateCreatedAt("CreatorCheers", inactiveCheer.id!!, windowStart.plusDays(1))
updateCreatedAt("CreatorCheers", cheer1.id!!, windowStart.plusHours(1))
updateCreatedAt("CreatorCheers", cheer2.id!!, windowEndExclusive)
updateCreatedAt("CreatorCheers", inactiveCheer.id!!, windowStart.plusHours(1))
updateCreatedAt("Member", creator.id!!, LocalDateTime.of(2026, 5, 10, 12, 0))
flushAndClear()
val snapshots = repository.findCheerCreatorSnapshots(windowStart, snapshotAt, limit = 10)
val snapshots = repository.findCheerCreatorSnapshots(windowStart, windowEndExclusive, limit = 10)
val expectedScore = scorePolicy.calculateCheerScore(
donationAmount = 150,
fanTalkCount = 2,
donationCount = 2,
newBoost = scorePolicy.calculateCreatorNewBoost(LocalDateTime.of(2026, 5, 10, 12, 0), snapshotAt)
fanTalkCount = 1,
donationCount = 1,
newBoost = scorePolicy.calculateCheerCreatorNewBoost(LocalDateTime.of(2026, 5, 10, 12, 0), snapshotAt)
)
assertEquals(1, snapshots.size)
assertEquals(RecommendedSectionType.CHEER_CREATOR, snapshots.single().sectionType)
assertEquals(creator.id, snapshots.single().targetId)
assertEquals(snapshotAt, snapshots.single().snapshotAt)
assertEquals(expectedScore, snapshots.single().score, 0.0001)
}
@@ -743,6 +894,7 @@ class DefaultHomeRecommendationQueryRepositoryTest @Autowired constructor(
fun shouldFindCheerCreatorSnapshotsWithDbScoreOrderAndLimit() {
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 donor = saveMember("cheer-score-donor", MemberRole.USER)
val oldCreator = saveMember("old-cheer-score-creator", MemberRole.CREATOR)
val newCreator = saveMember("new-cheer-score-creator", MemberRole.CREATOR)
@@ -768,17 +920,18 @@ class DefaultHomeRecommendationQueryRepositoryTest @Autowired constructor(
)
flushAndClear()
val snapshots = repository.findCheerCreatorSnapshots(windowStart, snapshotAt, limit = 1)
val snapshots = repository.findCheerCreatorSnapshots(windowStart, windowEndExclusive, limit = 1)
val expectedScore = scorePolicy.calculateCheerScore(
donationAmount = 2,
fanTalkCount = 0,
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(RecommendedSectionType.CHEER_CREATOR, snapshots.single().sectionType)
assertEquals(newCreator.id, snapshots.single().targetId)
assertEquals(snapshotAt, snapshots.single().snapshotAt)
assertEquals(expectedScore, snapshots.single().score, 0.0001)
}
@@ -787,6 +940,7 @@ class DefaultHomeRecommendationQueryRepositoryTest @Autowired constructor(
fun shouldFindPopularCommunitySnapshotsWithDistinctCounts() {
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("community-creator", MemberRole.CREATOR)
val member1 = saveMember("community-member-1", MemberRole.USER)
val member2 = saveMember("community-member-2", MemberRole.USER)
@@ -817,20 +971,20 @@ class DefaultHomeRecommendationQueryRepositoryTest @Autowired constructor(
updateCreatedAt("Member", creator.id!!, LocalDateTime.of(2026, 4, 1, 12, 0))
flushAndClear()
val snapshots = repository.findPopularCommunitySnapshots(windowStart, snapshotAt, limit = 10)
val snapshots = repository.findPopularCommunitySnapshots(windowStart, windowEndExclusive, limit = 10)
.associateBy { it.targetId }
val expectedPostScore = scorePolicy.calculateCommunityScore(
likeCount = 2,
commentCount = 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(
likeCount = 0,
commentCount = 0,
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(expectedCommentDisabledScore, snapshots[commentDisabledPost.id]!!.score, 0.0001)
@@ -841,6 +995,7 @@ class DefaultHomeRecommendationQueryRepositoryTest @Autowired constructor(
fun shouldIncludeAdultCommunitiesInPopularCommunitySnapshots() {
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("adult-community-creator", MemberRole.CREATOR)
val member = saveMember("adult-community-member", MemberRole.USER)
saveLiveRoom(creator, LocalDateTime.of(2026, 5, 10, 12, 0), channelName = "adult-community-live")
@@ -854,7 +1009,7 @@ class DefaultHomeRecommendationQueryRepositoryTest @Autowired constructor(
updateCreatedAt("CreatorCommunityLike", adultLike.id!!, windowStart.plusDays(1))
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())
}
@@ -864,6 +1019,7 @@ class DefaultHomeRecommendationQueryRepositoryTest @Autowired constructor(
fun shouldFindPopularCommunitySnapshotsWithDbScoreOrderAndLimit() {
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 liker = saveMember("community-score-liker", MemberRole.USER)
val oldCreator = saveMember("old-community-score-creator", MemberRole.CREATOR)
val newCreator = saveMember("new-community-score-creator", MemberRole.CREATOR)
@@ -879,13 +1035,13 @@ class DefaultHomeRecommendationQueryRepositoryTest @Autowired constructor(
updateCreatedAt("CreatorCommunityLike", newLike.id!!, windowStart.plusDays(1))
flushAndClear()
val snapshots = repository.findPopularCommunitySnapshots(windowStart, snapshotAt, limit = 1)
val snapshots = repository.findPopularCommunitySnapshots(windowStart, windowEndExclusive, limit = 1)
val expectedScore = scorePolicy.calculateCommunityScore(
likeCount = 1,
commentCount = 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(RecommendedSectionType.POPULAR_COMMUNITY, snapshots.single().sectionType)
@@ -893,11 +1049,62 @@ class DefaultHomeRecommendationQueryRepositoryTest @Autowired constructor(
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
@DisplayName("실제 데뷔일이 없는 크리에이터는 최근 응원과 인기 커뮤니티 스냅샷에서 제외한다")
fun shouldExcludeCreatorSnapshotsWithoutActualDebutAt() {
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("creator-without-debut", MemberRole.CREATOR)
val donor = saveMember("donor-without-debut", MemberRole.USER)
val community = saveCommunity(creator, isCommentAvailable = true)
@@ -915,8 +1122,8 @@ class DefaultHomeRecommendationQueryRepositoryTest @Autowired constructor(
updateCreatedAt("CreatorCommunityLike", like.id!!, windowStart.plusDays(1))
flushAndClear()
val cheerSnapshots = repository.findCheerCreatorSnapshots(windowStart, snapshotAt, limit = 10)
val communitySnapshots = repository.findPopularCommunitySnapshots(windowStart, snapshotAt, limit = 10)
val cheerSnapshots = repository.findCheerCreatorSnapshots(windowStart, windowEndExclusive, limit = 10)
val communitySnapshots = repository.findPopularCommunitySnapshots(windowStart, windowEndExclusive, limit = 10)
assertEquals(emptyList<Any>(), cheerSnapshots)
assertEquals(emptyList<Any>(), communitySnapshots)
@@ -927,6 +1134,7 @@ class DefaultHomeRecommendationQueryRepositoryTest @Autowired constructor(
fun shouldUseActualDebutAtInsteadOfMemberCreatedAtForCreatorSnapshots() {
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 memberCreatedAt = LocalDateTime.of(2026, 1, 1, 0, 0)
val firstLiveAt = LocalDateTime.of(2026, 5, 10, 12, 0)
val firstContentAt = LocalDateTime.of(2026, 5, 20, 12, 0)
@@ -950,22 +1158,23 @@ class DefaultHomeRecommendationQueryRepositoryTest @Autowired constructor(
updateCreatedAt("CreatorCommunityLike", like.id!!, windowStart.plusDays(1))
flushAndClear()
val cheerSnapshot = repository.findCheerCreatorSnapshots(windowStart, snapshotAt, limit = 10).single()
val communitySnapshot = repository.findPopularCommunitySnapshots(windowStart, snapshotAt, limit = 10).single()
val cheerSnapshot = repository.findCheerCreatorSnapshots(windowStart, windowEndExclusive, limit = 10).single()
val communitySnapshot = repository.findPopularCommunitySnapshots(windowStart, windowEndExclusive, limit = 10).single()
val expectedCheerScore = scorePolicy.calculateCheerScore(
donationAmount = 100,
fanTalkCount = 0,
donationCount = 1,
newBoost = scorePolicy.calculateCreatorNewBoost(firstLiveAt, snapshotAt)
newBoost = scorePolicy.calculateCheerCreatorNewBoost(firstLiveAt, snapshotAt)
)
val expectedCommunityScore = scorePolicy.calculateCommunityScore(
likeCount = 1,
commentCount = 0,
followerCount = 0,
newBoost = scorePolicy.calculateCreatorNewBoost(firstLiveAt, snapshotAt)
newBoost = scorePolicy.calculateCommunityCreatorNewBoost(firstLiveAt, snapshotAt)
)
assertEquals(expectedCheerScore, cheerSnapshot.score, 0.0001)
assertEquals(snapshotAt, cheerSnapshot.snapshotAt)
assertEquals(expectedCommunityScore, communitySnapshot.score, 0.0001)
}
@@ -974,6 +1183,7 @@ class DefaultHomeRecommendationQueryRepositoryTest @Autowired constructor(
fun shouldExcludeBlankChannelNameLiveFromSnapshotDebutAt() {
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 blankLiveAt = LocalDateTime.of(2026, 5, 1, 12, 0)
val contentDebutAt = LocalDateTime.of(2026, 5, 20, 12, 0)
val creator = saveMember("blank-live-debut-creator", MemberRole.CREATOR)
@@ -995,22 +1205,23 @@ class DefaultHomeRecommendationQueryRepositoryTest @Autowired constructor(
updateCreatedAt("CreatorCommunityLike", like.id!!, windowStart.plusDays(1))
flushAndClear()
val cheerSnapshot = repository.findCheerCreatorSnapshots(windowStart, snapshotAt, limit = 10).single()
val communitySnapshot = repository.findPopularCommunitySnapshots(windowStart, snapshotAt, limit = 10).single()
val cheerSnapshot = repository.findCheerCreatorSnapshots(windowStart, windowEndExclusive, limit = 10).single()
val communitySnapshot = repository.findPopularCommunitySnapshots(windowStart, windowEndExclusive, limit = 10).single()
val expectedCheerScore = scorePolicy.calculateCheerScore(
donationAmount = 100,
fanTalkCount = 0,
donationCount = 1,
newBoost = scorePolicy.calculateCreatorNewBoost(contentDebutAt, snapshotAt)
newBoost = scorePolicy.calculateCheerCreatorNewBoost(contentDebutAt, snapshotAt)
)
val expectedCommunityScore = scorePolicy.calculateCommunityScore(
likeCount = 1,
commentCount = 0,
followerCount = 0,
newBoost = scorePolicy.calculateCreatorNewBoost(contentDebutAt, snapshotAt)
newBoost = scorePolicy.calculateCommunityCreatorNewBoost(contentDebutAt, snapshotAt)
)
assertEquals(expectedCheerScore, cheerSnapshot.score, 0.0001)
assertEquals(snapshotAt, cheerSnapshot.snapshotAt)
assertEquals(expectedCommunityScore, communitySnapshot.score, 0.0001)
}
@@ -1042,6 +1253,30 @@ class DefaultHomeRecommendationQueryRepositoryTest @Autowired constructor(
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
@DisplayName("최근 데뷔 크리에이터 전체보기 조회는 offset/limit과 성인 콘텐츠 제외를 DB에서 적용한다")
fun shouldFindPagedRecentDebutCreatorsWithAdultFilter() {
@@ -1446,19 +1681,16 @@ class DefaultHomeRecommendationQueryRepositoryTest @Autowired constructor(
)
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("community/detail-image.png", detailById[eligible.id]!!.imagePath)
assertEquals("community/detail-audio.mp3", detailById[eligible.id]!!.audioPath)
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(2L, detailById[eligible.id]!!.likeCount)
assertEquals(1L, detailById[eligible.id]!!.commentCount)
assertEquals(false, detailById[eligible.id]!!.existOrdered)
assertEquals(true, detailById[paid.id]!!.existOrdered)
assertEquals(true, detailById[eligible.id]!!.isLiked)
assertEquals(false, detailById[paid.id]!!.isLiked)
assertEquals(creator.id, detailById[eligible.id]!!.creatorId)
assertEquals("community-detail-creator", detailById[eligible.id]!!.creatorNickname)
}
@@ -1467,16 +1699,16 @@ class DefaultHomeRecommendationQueryRepositoryTest @Autowired constructor(
@DisplayName("인기 커뮤니티 상세는 비회원에게 구매 여부를 false로 반환한다")
fun shouldReturnFalseOrderStatusForAnonymousPopularCommunityDetails() {
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()
val details = repository.findPopularCommunityRecommendationDetails(
listOf(paid.id!!),
listOf(free.id!!),
memberId = null,
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.isLiked })
}
@@ -2046,7 +2278,7 @@ class DefaultHomeRecommendationQueryRepositoryTest @Autowired constructor(
status: UseCanCalculateStatus,
isRefund: Boolean,
createdAt: LocalDateTime
) {
): UseCanCalculate {
val useCan = UseCan(canUsage = usage, can = can, rewardCan = 0, isRefund = isRefund)
useCan.member = donor
entityManager.persist(useCan)
@@ -2056,6 +2288,26 @@ class DefaultHomeRecommendationQueryRepositoryTest @Autowired constructor(
entityManager.persist(calculate)
entityManager.flush()
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 {

View File

@@ -77,6 +77,61 @@ class RecommendationSnapshotPersistenceAdapterTest @Autowired constructor(
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
fun shouldReplaceSnapshotsByDeletingSameSectionSnapshotAtOnly() {
val oldSnapshotAt = LocalDateTime.of(2026, 5, 28, 23, 59, 59)
@@ -108,6 +163,135 @@ class RecommendationSnapshotPersistenceAdapterTest @Autowired constructor(
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(
sectionType: RecommendedSectionType,
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.DisplayName
import org.junit.jupiter.api.Test
import java.time.Clock
import java.time.Instant
import java.time.LocalDateTime
import java.time.ZoneOffset
class HomeRecommendationQueryServiceTest {
private val port = FakeHomeRecommendationQueryPort()
@@ -143,6 +146,8 @@ class HomeRecommendationQueryServiceTest {
@Test
@DisplayName("AI 캐릭터 추천은 최신 스냅샷 20개를 기준으로 순서를 유지해 상세를 조립한다")
fun shouldFindAiCharactersFromLatestSnapshotsWithLimitAndDetails() {
val fallback = FakeAiCharacterSnapshotFallbackPort(emptyList())
val service = HomeRecommendationQueryService(port, snapshotPort, fallback)
val oldSnapshotAt = LocalDateTime.of(2026, 5, 28, 23, 59, 59)
val latestSnapshotAt = LocalDateTime.of(2026, 5, 29, 23, 59, 59)
snapshotPort.replaceSnapshots(
@@ -180,6 +185,8 @@ class HomeRecommendationQueryServiceTest {
val characters = service.findAiCharacterRecommendations()
assertEquals(null, fallback.offset)
assertEquals(null, fallback.limit)
assertEquals((1L..20L).toList(), port.aiCharacterDetailIds)
assertEquals(listOf(1L, 2L), characters.map { it.characterId })
assertEquals(listOf(101L, 102L), characters.map { it.creatorId })
@@ -189,10 +196,101 @@ class HomeRecommendationQueryServiceTest {
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
@DisplayName("최근 응원 크리에이터 추천은 최신 스냅샷 8명을 기준으로 닉네임과 프로필을 조립한다")
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(
RecommendedSectionType.CHEER_CREATOR,
snapshotAt,
@@ -215,6 +313,8 @@ class HomeRecommendationQueryServiceTest {
val creators = service.findCheerCreatorRecommendations(memberId = 104L)
assertEquals(null, fallback.offset)
assertEquals(null, fallback.limit)
assertEquals((1L..9L).toList(), port.cheerCreatorDetailIds)
assertEquals(104L, port.cheerCreatorMemberId)
assertEquals(listOf(1L, 2L), creators.map { it.creatorId })
@@ -222,9 +322,100 @@ class HomeRecommendationQueryServiceTest {
}
@Test
@DisplayName("인기 커뮤니티 추천은 최신 스냅샷 10개를 기준으로 크리에이터 중복을 제거하고 상세를 조립한다")
fun shouldFindPopularCommunitiesFromLatestSnapshotsWithLimitDetailsAndCreatorUniqueness() {
val snapshotAt = LocalDateTime.of(2026, 5, 29, 23, 59, 59)
@DisplayName("최근 응원 크리에이터 추천은 최신 스냅샷이 없으면 fallback refresh 후 스냅샷 상세를 조립한다")
fun shouldFindCheerCreatorsAfterFallbackWhenLatestSnapshotsDoNotExist() {
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(
RecommendedSectionType.POPULAR_COMMUNITY,
snapshotAt,
@@ -297,7 +488,8 @@ class HomeRecommendationQueryServiceTest {
@Test
@DisplayName("인기 커뮤니티 추천은 상위 스냅샷 중복 제거 후 뒤 후보로 기본 10개를 채운다")
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(
RecommendedSectionType.POPULAR_COMMUNITY,
snapshotAt,
@@ -331,6 +523,77 @@ class HomeRecommendationQueryServiceTest {
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
@DisplayName("최신 스냅샷이 없으면 AI 캐릭터/최근 응원/인기 커뮤니티 추천은 빈 배열을 반환한다")
fun shouldReturnEmptyListWhenLatestSnapshotsDoNotExist() {
@@ -767,19 +1030,19 @@ class HomeRecommendationQueryServiceTest {
override fun findAiCharacterSnapshots(
windowStart: LocalDateTime,
snapshotAt: LocalDateTime,
windowEndExclusive: LocalDateTime,
limit: Int
): List<RecommendationSnapshotRecord> = emptyList()
override fun findCheerCreatorSnapshots(
windowStart: LocalDateTime,
snapshotAt: LocalDateTime,
windowEndExclusive: LocalDateTime,
limit: Int
): List<RecommendationSnapshotRecord> = emptyList()
override fun findPopularCommunitySnapshots(
windowStart: LocalDateTime,
snapshotAt: LocalDateTime,
windowEndExclusive: LocalDateTime,
limit: Int
): List<RecommendationSnapshotRecord> = emptyList()
@@ -836,21 +1099,114 @@ private class FakeHomeRecommendationSnapshotPort : RecommendationSnapshotPort {
.maxOfOrNull { it.snapshotAt }
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 })
if (offset == 0L && limit == Int.MAX_VALUE) return all
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(
sectionType: RecommendedSectionType,
snapshotAt: LocalDateTime,
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.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(

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 service = service(snapshotPort = snapshotPort, queryPort = queryPort)
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, 23, 0, 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)
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(
RecommendationSnapshotRecord(
sectionType = RecommendedSectionType.AI_CHARACTER,
targetId = 11L,
score = 78.0,
snapshotAt = snapshotAt,
snapshotAt = aiSnapshotAt,
randomTieBreaker = 0.1
)
)
)
Mockito.`when`(queryPort.findCheerCreatorSnapshots(windowStart, snapshotAt, 16)).thenReturn(
Mockito.`when`(queryPort.findCheerCreatorSnapshots(aiWindowStart, aiWindowEndExclusive, 16)).thenReturn(
listOf(
RecommendationSnapshotRecord(
sectionType = RecommendedSectionType.CHEER_CREATOR,
targetId = 22L,
score = 792.22,
snapshotAt = snapshotAt,
snapshotAt = aiSnapshotAt,
randomTieBreaker = 0.2
)
)
)
Mockito.`when`(queryPort.findPopularCommunitySnapshots(windowStart, snapshotAt, 20)).thenReturn(
Mockito.`when`(queryPort.findPopularCommunitySnapshots(windowStart, windowEndExclusive, 20)).thenReturn(
listOf(
RecommendationSnapshotRecord(
sectionType = RecommendedSectionType.POPULAR_COMMUNITY,
@@ -96,7 +100,7 @@ class RecommendationSnapshotRefreshServiceTest {
val cheerSnapshots = snapshotPort.findLatestSnapshots(RecommendedSectionType.CHEER_CREATOR)
val communitySnapshots = snapshotPort.findLatestSnapshots(RecommendedSectionType.POPULAR_COMMUNITY)
assertEquals(snapshotAt, aiSnapshots.single().snapshotAt)
assertEquals(aiSnapshotAt, aiSnapshots.single().snapshotAt)
assertEquals(11L, aiSnapshots.single().targetId)
assertEquals(78.0, aiSnapshots.single().score, 0.0001)
assertEquals(0.1, aiSnapshots.single().randomTieBreaker, 0.0001)
@@ -107,9 +111,9 @@ class RecommendationSnapshotRefreshServiceTest {
assertEquals(33L, communitySnapshots.single().targetId)
assertEquals(40.0, communitySnapshots.single().score, 0.0001)
Mockito.verify(queryPort).findAiCharacterSnapshots(windowStart, snapshotAt, 20)
Mockito.verify(queryPort).findCheerCreatorSnapshots(windowStart, snapshotAt, 16)
Mockito.verify(queryPort).findPopularCommunitySnapshots(windowStart, snapshotAt, 20)
Mockito.verify(queryPort).findAiCharacterSnapshots(aiWindowStart, aiWindowEndExclusive, 20)
Mockito.verify(queryPort).findCheerCreatorSnapshots(aiWindowStart, aiWindowEndExclusive, 16)
Mockito.verify(queryPort).findPopularCommunitySnapshots(windowStart, windowEndExclusive, 20)
assertEquals(true, output.out.contains("event=recommendation_snapshot_refresh_success"))
assertEquals(true, output.out.contains("aiCharacterCount=1"))
}
@@ -120,11 +124,23 @@ class RecommendationSnapshotRefreshServiceTest {
val queryPort = Mockito.mock(HomeRecommendationQueryPort::class.java)
val service = service(queryPort = queryPort)
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, 23, 0, 0, 0)
Mockito.`when`(queryPort.findAiCharacterSnapshots(windowStart, snapshotAt, 20)).thenReturn(emptyList())
Mockito.`when`(queryPort.findCheerCreatorSnapshots(windowStart, snapshotAt, 16)).thenReturn(emptyList())
Mockito.`when`(queryPort.findPopularCommunitySnapshots(windowStart, snapshotAt, 20)).thenReturn(emptyList())
val windowStart = LocalDateTime.of(2026, 5, 22, 15, 0, 0)
val windowEndExclusive = LocalDateTime.of(2026, 5, 29, 15, 0, 0)
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())
TransactionSynchronizationManager.initSynchronization()
try {
@@ -146,16 +162,31 @@ class RecommendationSnapshotRefreshServiceTest {
val queryPort = Mockito.mock(HomeRecommendationQueryPort::class.java)
val service = service(snapshotPort = snapshotPort, queryPort = queryPort)
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, 23, 0, 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)
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(
listOf(snapshot(RecommendedSectionType.AI_CHARACTER, targetId = 25L, score = 25.0, snapshotAt = snapshotAt))
Mockito.`when`(
queryPort.findAiCharacterSnapshots(
LocalDateTime.of(2026, 5, 28, 15, 0, 0),
aiWindowEndExclusive,
20
)
).thenReturn(
listOf(snapshot(RecommendedSectionType.AI_CHARACTER, targetId = 25L, score = 25.0, snapshotAt = aiSnapshotAt))
)
Mockito.`when`(queryPort.findCheerCreatorSnapshots(windowStart, snapshotAt, 16)).thenReturn(
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))
)
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))
)
@@ -166,6 +197,196 @@ class RecommendationSnapshotRefreshServiceTest {
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
@DisplayName("추천 스냅샷 스케줄러는 매일 06:00 KST cron으로 갱신 서비스를 호출한다")
fun shouldScheduleDailySnapshotRefreshAtKstSix() {
@@ -227,11 +448,13 @@ class RecommendationSnapshotRefreshServiceTest {
private fun service(
snapshotPort: RecommendationSnapshotPort = FakeRecommendationSnapshotPort(),
queryPort: HomeRecommendationQueryPort = Mockito.mock(HomeRecommendationQueryPort::class.java)
queryPort: HomeRecommendationQueryPort = Mockito.mock(HomeRecommendationQueryPort::class.java),
redissonClient: RedissonClient? = null
): RecommendationSnapshotRefreshService {
return RecommendationSnapshotRefreshService(
snapshotPort = snapshotPort,
queryPort = queryPort
queryPort = queryPort,
redissonClient = redissonClient
)
}
@@ -264,19 +487,59 @@ private class FakeRecommendationSnapshotPort : RecommendationSnapshotPort {
.maxOfOrNull { it.snapshotAt }
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 })
if (offset == 0L && limit == Int.MAX_VALUE) return all
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(
sectionType: RecommendedSectionType,
snapshotAt: LocalDateTime,
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.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() {
val now = LocalDateTime.of(2026, 5, 30, 12, 0)
assertEquals(1.5, policy.calculateAiCharacterNewBoost(now.minusDays(10), now), 0.0001)
assertEquals(1.3, policy.calculateAiCharacterNewBoost(now.minusDays(20), now), 0.0001)
assertEquals(1.2, policy.calculateAiCharacterNewBoost(now.minusDays(30), now), 0.0001)
assertEquals(1.15, RecommendationScoreSpec.AI_NEW_BOOST_10_DAYS, 0.0001)
assertEquals(1.10, RecommendationScoreSpec.AI_NEW_BOOST_20_DAYS, 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.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
@@ -51,52 +61,90 @@ class RecommendationScorePolicyTest {
}
@Test
@DisplayName("AI 채팅 추천 점수는 이번 스프린트에서 팔로우 증가량을 제외한다")
@DisplayName("AI 채팅 추천 점수는 AI 발화 수, 활성 사용자 수, 팔로우 증가량 가중치와 신규 부스트를 적용한다")
fun shouldCalculateAiChatScore() {
assertEquals(0.45, RecommendationScoreSpec.AI_RECENT_CHAT_WEIGHT, 0.0001)
assertEquals(0.35, RecommendationScoreSpec.AI_RECENT_ACTIVE_USER_WEIGHT, 0.0001)
assertEquals(0.45, RecommendationScoreSpec.AI_CHAT_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(
recentChatCount = 100,
recentActiveUserCount = 20,
newBoost = 1.3
)
assertEquals(67.6, score, 0.0001)
assertEquals(0.0, policy.calculateAiChatScore(0, 0, 0, 1.0), 0.0001)
assertEquals(4.5, policy.calculateAiChatScore(10, 0, 0, 1.0), 0.0001)
assertEquals(3.5, policy.calculateAiChatScore(0, 10, 0, 1.0), 0.0001)
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)
}
@Test
@DisplayName("최근 응원 추천 점수는 후원 금액, 팬 Talk 수, 후원 수에 가중치를 적용한다")
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.1, RecommendationScoreSpec.CHEER_DONATION_COUNT_WEIGHT, 0.0001)
val score = policy.calculateCheerScore(
donationAmount = 1000,
fanTalkCount = 20,
donationCount = 10,
newBoost = 1.2
)
assertEquals(0.0, policy.calculateCheerScore(0, 0, 0, 1.0), 0.0001)
assertEquals(45.0, policy.calculateCheerScore(100, 0, 0, 1.0), 0.0001)
assertEquals(3.0, policy.calculateCheerScore(0, 10, 0, 1.0), 0.0001)
assertEquals(1.0, policy.calculateCheerScore(0, 0, 10, 1.0), 0.0001)
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
@DisplayName("인기 커뮤니티 점수는 좋아요 수, 댓글 수, 팔로우 수에 가중치를 적용한다")
fun shouldCalculateCommunityScore() {
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)
val score = policy.calculateCommunityScore(
likeCount = 40,
commentCount = 20,
followerCount = 100,
newBoost = 1.2
)
assertEquals(0.0, policy.calculateCommunityScore(0, 0, 0, 1.0), 0.0001)
assertEquals(5.0, policy.calculateCommunityScore(10, 0, 0, 1.0), 0.0001)
assertEquals(4.0, policy.calculateCommunityScore(0, 10, 0, 1.0), 0.0001)
assertEquals(1.0, policy.calculateCommunityScore(0, 0, 10, 1.0), 0.0001)
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

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)
}
}