feat(home): AI 캐릭터 부족분을 랜덤 보충한다

This commit is contained in:
2026-07-22 22:14:26 +09:00
parent 075ca88f01
commit a0bf71be47
10 changed files with 230 additions and 4 deletions

View File

@@ -295,6 +295,30 @@
- 2026-07-09: GREEN에서 `HomeRecommendationFacade.HOME_AI_CHARACTER_LIMIT``HomeRecommendationQueryService.DEFAULT_AI_CHARACTER_LIMIT`를 20으로 변경하고, 홈 통합 응답이 AI 캐릭터를 최대 20개 반환하는 controller 테스트를 추가했다. `./gradlew test --tests kr.co.vividnext.sodalive.v2.recommendation.application.HomeRecommendationQueryServiceTest --tests kr.co.vividnext.sodalive.v2.api.home.HomeRecommendationControllerTest` 실행 결과 `BUILD SUCCESSFUL`로 통과했다. - 2026-07-09: GREEN에서 `HomeRecommendationFacade.HOME_AI_CHARACTER_LIMIT``HomeRecommendationQueryService.DEFAULT_AI_CHARACTER_LIMIT`를 20으로 변경하고, 홈 통합 응답이 AI 캐릭터를 최대 20개 반환하는 controller 테스트를 추가했다. `./gradlew test --tests kr.co.vividnext.sodalive.v2.recommendation.application.HomeRecommendationQueryServiceTest --tests kr.co.vividnext.sodalive.v2.api.home.HomeRecommendationControllerTest` 실행 결과 `BUILD SUCCESSFUL`로 통과했다.
- 2026-07-09: 회귀 검증으로 `./gradlew ktlintCheck``./gradlew tasks --all`을 실행해 모두 `BUILD SUCCESSFUL`로 통과했다. 두 명령은 최초 sandbox 실행에서 Gradle wrapper의 `~/.gradle` lock 파일 접근 권한 문제로 실패했고, 권한 상승 재실행으로 통과했다. `./gradlew :app:ktlintCheck`는 단일 루트 프로젝트에 `app` 프로젝트가 없어 실패했으며, 저장소 기준 명령인 `./gradlew ktlintCheck`로 대체 검증했다. - 2026-07-09: 회귀 검증으로 `./gradlew ktlintCheck``./gradlew tasks --all`을 실행해 모두 `BUILD SUCCESSFUL`로 통과했다. 두 명령은 최초 sandbox 실행에서 Gradle wrapper의 `~/.gradle` lock 파일 접근 권한 문제로 실패했고, 권한 상승 재실행으로 통과했다. `./gradlew :app:ktlintCheck`는 단일 루트 프로젝트에 `app` 프로젝트가 없어 실패했으며, 저장소 기준 명령인 `./gradlew ktlintCheck`로 대체 검증했다.
- [x] **Task 9.2: 홈 첫 화면 AI 캐릭터 부족분 랜덤 보충**
- Files:
- Modify: `docs/20260529_메인_홈_추천_API/prd.md`
- Modify: `docs/20260529_메인_홈_추천_API/plan-task.md`
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/recommendation/port/out/HomeRecommendationQueryPort.kt`
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/recommendation/application/HomeRecommendationQueryService.kt`
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/recommendation/adapter/out/persistence/DefaultHomeRecommendationQueryRepository.kt`
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/recommendation/application/HomeRecommendationQueryServiceTest.kt`
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/recommendation/adapter/out/persistence/DefaultHomeRecommendationQueryRepositoryTest.kt`
- RED: 스냅샷 상세 조립 결과가 요청 limit 미만이면 스냅샷 target id를 제외하고 랜덤 AI 캐릭터 id를 부족분만큼 조회한 뒤 기존 상세 조회를 재사용하는 service 테스트와, 랜덤 id 조회가 활성 `ChatCharacter` 및 활성 CREATOR/AI_CHARACTER `creatorMember`만 반환하고 제외 id를 빼는 repository 테스트를 작성한다.
- 실패 확인:
```bash
./gradlew test \
--tests kr.co.vividnext.sodalive.v2.recommendation.application.HomeRecommendationQueryServiceTest \
--tests kr.co.vividnext.sodalive.v2.recommendation.adapter.out.persistence.DefaultHomeRecommendationQueryRepositoryTest
```
- GREEN: 홈 통합 조회에서 `backfillRandom = true`로 호출하고, `HomeRecommendationQueryService.findAiCharacterRecommendations`에서 보충이 명시적으로 켜져 있으며 상세 결과가 `limit`보다 적을 때만 `HomeRecommendationQueryPort.findRandomAiCharacterRecommendationIds(excludeCharacterIds, limit)`로 부족분 id를 먼저 뽑은 뒤 기존 `findAiCharacterRecommendationDetails(...)`로 상세와 채팅 수를 조회한다. `excludeCharacterIds`는 최신 스냅샷 target id 전체를 사용해 stale 스냅샷 후보를 다시 뽑지 않는다.
- REFACTOR: 전체보기 paging 조회는 랜덤 보충을 적용하지 않고, AI 캐릭터 추천 점수 산식/스냅샷 생성/공개 API schema는 변경하지 않는다.
- 기대 결과: `GET /api/v2/home/recommendations`의 `aiCharacters`는 가능한 경우 20개까지 채워지고, 랜덤 후보가 부족하면 중복 없이 가능한 AI 캐릭터만 반환한다.
- 검증 기록:
- 2026-07-22: RED에서 `HomeRecommendationQueryPort.findRandomAiCharacterRecommendationIds(...)` 계약을 추가하고 service/repository 테스트를 작성했으며, 기존 구현은 `DefaultHomeRecommendationQueryRepository`가 신규 port 메서드를 구현하지 않아 `compileKotlin`에서 실패했다.
- 2026-07-22: GREEN에서 홈 통합 조회에만 `backfillRandom = true`를 전달하고, AI 캐릭터 스냅샷 상세 결과가 limit보다 적을 때 랜덤 AI 캐릭터 id를 먼저 조회한 뒤 기존 상세 조회로 상세와 채팅 수를 재사용하도록 구현했다. 빈 스냅샷 refresh 완료 marker와 전체보기 paging 조회는 랜덤 보충하지 않도록 기존 계약을 유지했다. `./gradlew test --tests kr.co.vividnext.sodalive.v2.recommendation.application.HomeRecommendationQueryServiceTest --tests kr.co.vividnext.sodalive.v2.recommendation.adapter.out.persistence.DefaultHomeRecommendationQueryRepositoryTest` 실행 결과 `BUILD SUCCESSFUL`로 통과했다.
- 2026-07-22: 리뷰 지적에 따라 전체보기 첫 페이지가 `offset == 0`으로 랜덤 보충되는 문제를 `backfillRandom` 명시 플래그로 보정했다. 재검증으로 `./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.HomeRecommendationControllerTest`, `./gradlew ktlintCheck`, `git diff --check`, `./gradlew test`를 실행해 모두 `BUILD SUCCESSFUL` 또는 무출력 통과를 확인했다.
### Phase 4: 콘텐츠 조회 이력 기록 ### Phase 4: 콘텐츠 조회 이력 기록
- [x] **Task 4.1: 콘텐츠 조회 이력 엔티티/서비스 작성** - [x] **Task 4.1: 콘텐츠 조회 이력 엔티티/서비스 작성**

View File

@@ -164,6 +164,7 @@
#### Requirements #### Requirements
- AI 캐릭터 리스트를 조회한다. - AI 캐릭터 리스트를 조회한다.
- 홈 첫 화면은 20개를 조회한다. - 홈 첫 화면은 20개를 조회한다.
- 홈 첫 화면은 최신 스냅샷 상세 조회 결과가 20개 미만이면 이미 조회한 스냅샷 캐릭터를 제외한 활성 AI 캐릭터 랜덤 조회로 부족분을 채운다.
- 전체 리스트 API는 페이징으로 조회할 수 있어야 한다. - 전체 리스트 API는 페이징으로 조회할 수 있어야 한다.
- 노출 정보는 캐릭터 id, AI 캐릭터에 대응하는 creator id, 캐릭터 이름, 캐릭터 소개, 프로필 이미지, 작품명, 사용자들이 친 전체 채팅 수를 포함한다. - 노출 정보는 캐릭터 id, AI 캐릭터에 대응하는 creator id, 캐릭터 이름, 캐릭터 소개, 프로필 이미지, 작품명, 사용자들이 친 전체 채팅 수를 포함한다.
- AI 캐릭터에 대응하는 creator id는 `ChatCharacter.creatorMember.id`이며, 해당 Member는 `role = CREATOR`, `memberKind = AI_CHARACTER`인 내부 크리에이터 Member다. - AI 캐릭터에 대응하는 creator id는 `ChatCharacter.creatorMember.id`이며, 해당 Member는 `role = CREATOR`, `memberKind = AI_CHARACTER`인 내부 크리에이터 Member다.
@@ -182,6 +183,8 @@
#### Edge Cases #### Edge Cases
- 비활성 또는 노출 제한 캐릭터는 제외한다. - 비활성 또는 노출 제한 캐릭터는 제외한다.
- 활성 `ChatCharacter``creatorMember`가 없거나 연결된 Member가 비활성/비 CREATOR/비 AI_CHARACTER이면 해당 AI 캐릭터는 홈 추천 응답에서 제외한다. - 활성 `ChatCharacter``creatorMember`가 없거나 연결된 Member가 비활성/비 CREATOR/비 AI_CHARACTER이면 해당 AI 캐릭터는 홈 추천 응답에서 제외한다.
- 랜덤 보충 후보가 부족하면 중복 없이 조회 가능한 AI 캐릭터만 내려준다.
- 전체 리스트 API의 paging 조회는 페이지별 랜덤 보충을 적용하지 않는다.
### Feature H. 장르의 크리에이터 ### Feature H. 장르의 크리에이터

View File

@@ -20,6 +20,7 @@
- 유지: `DefaultHomeRecommendationQueryRepository.findAiCharacterRecommendationDetails(...)`의 상세 조립, `characterId`, `creatorId`, 원작명, 전체 채팅 수, 활성 `creatorMember` 필터는 변경하지 않는다. - 유지: `DefaultHomeRecommendationQueryRepository.findAiCharacterRecommendationDetails(...)`의 상세 조립, `characterId`, `creatorId`, 원작명, 전체 채팅 수, 활성 `creatorMember` 필터는 변경하지 않는다.
- 유지: `HomeRecommendationFacade`, `HomeRecommendationController`, `HomeRecommendationResponse.HomeAiCharacterItem`의 공개 API URL과 응답 필드는 변경하지 않는다. - 유지: `HomeRecommendationFacade`, `HomeRecommendationController`, `HomeRecommendationResponse.HomeAiCharacterItem`의 공개 API URL과 응답 필드는 변경하지 않는다.
- 유지: 최근 응원/인기 커뮤니티 스냅샷 산식, window, 저장 limit, random tie-breaker 정렬은 변경하지 않는다. - 유지: 최근 응원/인기 커뮤니티 스냅샷 산식, window, 저장 limit, random tie-breaker 정렬은 변경하지 않는다.
- 추가: 홈 통합 조회에서 최신 스냅샷 상세 결과가 20개 미만이면 스냅샷 캐릭터 id를 제외한 활성 AI 캐릭터 랜덤 조회로 부족분을 채운다.
- 변경: `findAiCharacterSnapshots(...)`의 입력 window, AI 캐릭터 점수 산식, 신규 부스트 값, 팔로우 증가 수 포함, 후보 제외 조건, top 20 동점 정렬 기준만 변경한다. - 변경: `findAiCharacterSnapshots(...)`의 입력 window, AI 캐릭터 점수 산식, 신규 부스트 값, 팔로우 증가 수 포함, 후보 제외 조건, top 20 동점 정렬 기준만 변경한다.
- 변경: `RecommendationSnapshotRefreshService`에는 AI 캐릭터 단일 refresh 경로를 추가하되, 기존 전체 일 refresh가 최근 응원/인기 커뮤니티까지 함께 갱신하는 동작은 유지한다. - 변경: `RecommendationSnapshotRefreshService`에는 AI 캐릭터 단일 refresh 경로를 추가하되, 기존 전체 일 refresh가 최근 응원/인기 커뮤니티까지 함께 갱신하는 동작은 유지한다.
- 추가: 최신 AI 캐릭터 스냅샷이 없을 때만 fallback refresh orchestration을 추가한다. - 추가: 최신 AI 캐릭터 스냅샷이 없을 때만 fallback refresh orchestration을 추가한다.
@@ -217,6 +218,21 @@
### Phase 7: 최종 검증과 문서 갱신 ### Phase 7: 최종 검증과 문서 갱신
- [x] **Task 6.3: 홈 통합 AI 캐릭터 부족분 랜덤 보충**
- 파일 경로:
- Modify: `docs/20260709_메인_홈_추천_AI캐릭터_스냅샷/prd.md`
- Modify: `docs/20260709_메인_홈_추천_AI캐릭터_스냅샷/plan-task.md`
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/recommendation/port/out/HomeRecommendationQueryPort.kt`
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/recommendation/application/HomeRecommendationQueryService.kt`
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/recommendation/adapter/out/persistence/DefaultHomeRecommendationQueryRepository.kt`
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/recommendation/application/HomeRecommendationQueryServiceTest.kt`
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/recommendation/adapter/out/persistence/DefaultHomeRecommendationQueryRepositoryTest.kt`
- RED: 새 `findRandomAiCharacterRecommendationIds(...)` port 계약을 추가하고, 스냅샷 상세 결과가 limit 미만이면 부족분만큼 랜덤 id를 조회한 뒤 기존 상세 조회를 재사용하는 service 테스트와 제외 id/활성 AI creator member 조건을 검증하는 repository 테스트를 작성한다.
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.recommendation.application.HomeRecommendationQueryServiceTest --tests kr.co.vividnext.sodalive.v2.recommendation.adapter.out.persistence.DefaultHomeRecommendationQueryRepositoryTest`
- GREEN: 홈 통합 조회에서 `backfillRandom = true`로 호출하고, 보충이 명시적으로 켜져 있으며 스냅샷이 존재하고 상세 결과가 limit 미만인 경우에만 랜덤 id를 먼저 조회한 뒤 기존 상세 조회로 상세와 채팅 수를 재사용한다.
- REFACTOR: 빈 스냅샷 refresh 완료 marker와 전체보기 paging 조회는 기존 빈 결과/paging 계약을 유지한다.
- 기대 결과: 홈 통합 AI 캐릭터 섹션은 가능한 경우 20개까지 채워지고, 공개 API 응답 필드는 변경되지 않는다.
- [x] **Task 7.1: focused regression 실행** - [x] **Task 7.1: focused regression 실행**
- 파일 경로: - 파일 경로:
- Modify: `docs/20260709_메인_홈_추천_AI캐릭터_스냅샷/plan-task.md` - Modify: `docs/20260709_메인_홈_추천_AI캐릭터_스냅샷/plan-task.md`
@@ -246,7 +262,7 @@
- Feature A: Task 2.1, Task 3.1에서 AI 채팅 수 45%, 활성 사용자 수 35%, 팔로우 증가 수 20% 산식을 검증한다. - Feature A: Task 2.1, Task 3.1에서 AI 채팅 수 45%, 활성 사용자 수 35%, 팔로우 증가 수 20% 산식을 검증한다.
- Feature B: Task 2.3, Task 4.1에서 KST 전날 하루를 UTC 조회 범위로 변환하는 정책을 검증한다. - Feature B: Task 2.3, Task 4.1에서 KST 전날 하루를 UTC 조회 범위로 변환하는 정책을 검증한다.
- Feature C: Task 2.2, Task 3.2에서 `ChatCharacter.createdAt` 기준 AI 전용 신규 부스트와 null/future 제외를 검증한다. - 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 D: Task 3.3, Task 5.3, Task 6.1, Task 6.3에서 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 방지를 검증한다. - 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 제외를 확인한다. - Non-Goals: Task 6.1과 Task 7.2에서 공개 API URL/응답 필드 변경 없음, AI 캐릭터 팔로우 생성/취소 동작 변경 없음, 관리자/ML/A-B 제외를 확인한다.
@@ -258,6 +274,9 @@
- 2026-07-10: 구현 검증으로 산식/윈도우 focused 테스트 `RecommendationScorePolicyTest`, `RecommendationSnapshotWindowPolicyTest`를 실행해 `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: 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: 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-22: 사용자 피드백에 따라 홈 통합 AI 캐릭터 섹션의 스냅샷 상세 결과가 20개 미만이면 랜덤 활성 AI 캐릭터로 부족분을 보충하도록 PRD와 plan-task를 보강했다. RED에서 신규 port 계약 미구현으로 `DefaultHomeRecommendationQueryRepository` 컴파일이 실패했고, GREEN에서 홈 통합 조회에만 `backfillRandom = true`를 전달해 랜덤 id 조회와 기존 상세 조회 재사용 조건을 추가했다. focused 검증으로 `./gradlew test --tests kr.co.vividnext.sodalive.v2.recommendation.application.HomeRecommendationQueryServiceTest --tests kr.co.vividnext.sodalive.v2.recommendation.adapter.out.persistence.DefaultHomeRecommendationQueryRepositoryTest`를 실행해 `BUILD SUCCESSFUL`을 확인했다.
- 2026-07-22: 리뷰 지적에 따라 랜덤 보충 쿼리가 전체 AI 캐릭터의 채팅 메시지를 집계한 뒤 `RAND()`/`LIMIT`하지 않도록 2단계 조회로 보정했다. 랜덤 쿼리는 활성 AI 캐릭터 id만 limit만큼 선택하고, 상세/전체 채팅 수는 기존 `findAiCharacterRecommendationDetails(...)` 경로를 재사용한다. RED에서 기존 production 구현의 `findRandomAiCharacterRecommendationDetails(...)` 참조로 `compileKotlin`이 실패했고, GREEN 후 `./gradlew test --tests kr.co.vividnext.sodalive.v2.recommendation.application.HomeRecommendationQueryServiceTest --tests kr.co.vividnext.sodalive.v2.recommendation.adapter.out.persistence.DefaultHomeRecommendationQueryRepositoryTest``./gradlew ktlintCheck``BUILD SUCCESSFUL`로 통과했다.
- 2026-07-22: 리뷰 게이트에서 전체보기 첫 페이지가 `offset == 0` 조건으로 랜덤 보충되는 blocker를 확인해 `backfillRandom` 명시 플래그로 홈 통합 조회와 전체보기 조회를 분리했다. 재검증으로 `./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.HomeRecommendationControllerTest`, `./gradlew ktlintCheck`, `git diff --check`, `./gradlew test`를 실행해 모두 `BUILD SUCCESSFUL` 또는 무출력 통과를 확인했고, 재리뷰에서 blocker 없이 승인받았다.
- 2026-07-10: API 회귀 focused 테스트 `HomeRecommendationControllerTest`, `HomeRecommendationResponseTest`를 실행해 공개 응답 스키마 유지가 `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: 전체 검증으로 `./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: 리뷰 게이트에서 발견된 fallback 조건/단일 실행/section lock 해제 시점 이슈를 수정했다. 최신 스냅샷 존재 여부는 요청 page가 아니라 `offset=0, limit=1` 존재 확인으로 분리했고, fallback refresh는 단일 in-flight future로 제한했으며, scheduler AI section lock은 트랜잭션 완료 후 해제되도록 보강했다.

View File

@@ -21,6 +21,7 @@
- 팔로우 증가 수는 전날 발생한 AI 캐릭터 신규 팔로우 수로 계산한다. - 팔로우 증가 수는 전날 발생한 AI 캐릭터 신규 팔로우 수로 계산한다.
- 신규 부스트는 `ChatCharacter.createdAt` 기준으로 10일 이내 1.15, 20일 이내 1.10, 30일 이내 1.05, 그 외 1.0을 적용한다. - 신규 부스트는 `ChatCharacter.createdAt` 기준으로 10일 이내 1.15, 20일 이내 1.10, 30일 이내 1.05, 그 외 1.0을 적용한다.
- 홈 통합 조회와 AI 캐릭터 전체보기 조회는 최신 AI 캐릭터 스냅샷의 점수순 결과를 사용한다. - 홈 통합 조회와 AI 캐릭터 전체보기 조회는 최신 AI 캐릭터 스냅샷의 점수순 결과를 사용한다.
- 홈 통합 조회는 최신 스냅샷 상세 조회 결과가 20개 미만이면 스냅샷 캐릭터를 제외한 활성 AI 캐릭터 랜덤 조회로 부족분을 채운다.
--- ---
@@ -117,11 +118,14 @@
- 사용자들이 친 전체 채팅 수 - 사용자들이 친 전체 채팅 수
- 스냅샷 정렬은 점수 내림차순, 동일 점수면 더 늦게 생성된 캐릭터를 우선한다. - 스냅샷 정렬은 점수 내림차순, 동일 점수면 더 늦게 생성된 캐릭터를 우선한다.
- 조회 시점에도 비활성 또는 노출 제한 캐릭터는 제외한다. - 조회 시점에도 비활성 또는 노출 제한 캐릭터는 제외한다.
- 홈 통합 조회에서 스냅샷 상세 조회 결과가 20개 미만이면 이미 조회한 스냅샷 캐릭터 id를 제외하고 활성 AI 캐릭터를 랜덤으로 조회해 부족분을 뒤에 붙인다.
#### Edge Cases #### Edge Cases
- 스냅샷에는 존재하지만 조회 시점에 캐릭터 또는 `creatorMember`가 비활성화된 경우 응답에서 제외한다. - 스냅샷에는 존재하지만 조회 시점에 캐릭터 또는 `creatorMember`가 비활성화된 경우 응답에서 제외한다.
- fallback refresh 후에도 최신 스냅샷이 없으면 기존 홈 추천 API 동작과 동일하게 빈 배열을 반환한다. - fallback refresh 후에도 최신 스냅샷이 없으면 기존 홈 추천 API 동작과 동일하게 빈 배열을 반환한다.
- 상세 조회 결과가 스냅샷 저장 개수보다 적어도 홈 조회 전체는 성공 처리한다. - 상세 조회 결과가 스냅샷 저장 개수보다 적어도 홈 조회 전체는 성공 처리한다.
- 랜덤 보충 후보가 부족하면 중복 없이 조회 가능한 AI 캐릭터만 반환한다.
- 빈 스냅샷 refresh 완료 marker가 있거나 전체보기 paging 조회이면 랜덤 보충을 적용하지 않는다.
### Feature E. 스냅샷 없음 fallback refresh ### Feature E. 스냅샷 없음 fallback refresh

View File

@@ -75,7 +75,10 @@ class HomeRecommendationFacade(
includeAdultContents = includeAdult includeAdultContents = includeAdult
) )
.map { it.toItem() }, .map { it.toItem() },
aiCharacters = queryService.findAiCharacterRecommendations(limit = HOME_AI_CHARACTER_LIMIT).map { it.toItem() }, aiCharacters = queryService.findAiCharacterRecommendations(
limit = HOME_AI_CHARACTER_LIMIT,
backfillRandom = true
).map { it.toItem() },
genreCreators = queryService.findGenreCreatorRecommendations( genreCreators = queryService.findGenreCreatorRecommendations(
memberId = member?.id, memberId = member?.id,
includeAdultGenres = includeAdult, includeAdultGenres = includeAdult,

View File

@@ -775,6 +775,29 @@ class DefaultHomeRecommendationQueryRepository(
.fetch() .fetch()
} }
override fun findRandomAiCharacterRecommendationIds(
excludeCharacterIds: List<Long>,
limit: Int
): List<Long> {
val creatorMember = QMember("creatorMember")
val randomTieBreaker = Expressions.numberTemplate(Double::class.java, "function('rand')")
return queryFactory
.select(chatCharacter.id)
.from(chatCharacter)
.join(chatCharacter.creatorMember, creatorMember)
.where(
chatCharacter.isActive.isTrue,
excludeAiCharacterCondition(excludeCharacterIds),
creatorMember.isActive.isTrue,
creatorMember.role.eq(MemberRole.CREATOR),
creatorMember.memberKind.eq(MemberKind.AI_CHARACTER)
)
.orderBy(randomTieBreaker.asc())
.limit(limit.toLong())
.fetch()
}
override fun findCheerCreatorRecommendationDetails( override fun findCheerCreatorRecommendationDetails(
creatorIds: List<Long>, creatorIds: List<Long>,
memberId: Long? memberId: Long?
@@ -1235,6 +1258,10 @@ class DefaultHomeRecommendationQueryRepository(
return if (includeAdultLives) null else liveRoom.isAdult.isFalse return if (includeAdultLives) null else liveRoom.isAdult.isFalse
} }
private fun excludeAiCharacterCondition(excludeCharacterIds: List<Long>): BooleanExpression? {
return if (excludeCharacterIds.isEmpty()) null else chatCharacter.id.notIn(excludeCharacterIds)
}
private fun notBlockedCreatorCondition(memberId: Long?, creatorIdPath: Expression<Long>): BooleanExpression? { private fun notBlockedCreatorCondition(memberId: Long?, creatorIdPath: Expression<Long>): BooleanExpression? {
if (memberId == null) return null if (memberId == null) return null
val blockMember = QBlockMember("recommendationBlockMember") val blockMember = QBlockMember("recommendationBlockMember")

View File

@@ -78,13 +78,23 @@ class HomeRecommendationQueryService(
fun findAiCharacterRecommendations( fun findAiCharacterRecommendations(
offset: Long = 0, offset: Long = 0,
limit: Int = DEFAULT_AI_CHARACTER_LIMIT limit: Int = DEFAULT_AI_CHARACTER_LIMIT,
backfillRandom: Boolean = false
): List<HomeAiCharacterRecommendationRecord> { ): List<HomeAiCharacterRecommendationRecord> {
val snapshots = findAiCharacterSnapshotsWithFallback(offset, limit) val snapshots = findAiCharacterSnapshotsWithFallback(offset, limit)
val detailsById = queryPort.findAiCharacterRecommendationDetails(snapshots.map { it.targetId }) val detailsById = queryPort.findAiCharacterRecommendationDetails(snapshots.map { it.targetId })
.associateBy { it.characterId } .associateBy { it.characterId }
val snapshotDetails = snapshots.mapNotNull { detailsById[it.targetId] }
return snapshots.mapNotNull { detailsById[it.targetId] } if (!backfillRandom || snapshots.isEmpty() || snapshotDetails.size >= limit) return snapshotDetails
val randomIds = queryPort.findRandomAiCharacterRecommendationIds(
excludeCharacterIds = snapshots.map { it.targetId },
limit = limit - snapshotDetails.size
)
val randomDetailsById = queryPort.findAiCharacterRecommendationDetails(randomIds).associateBy { it.characterId }
val randomDetails = randomIds.mapNotNull { randomDetailsById[it] }
return (snapshotDetails + randomDetails).distinctBy { it.characterId }.take(limit)
} }
private fun findAiCharacterSnapshotsWithFallback( private fun findAiCharacterSnapshotsWithFallback(

View File

@@ -58,6 +58,11 @@ interface HomeRecommendationQueryPort {
fun findAiCharacterRecommendationDetails(characterIds: List<Long>): List<HomeAiCharacterRecommendationRecord> fun findAiCharacterRecommendationDetails(characterIds: List<Long>): List<HomeAiCharacterRecommendationRecord>
fun findRandomAiCharacterRecommendationIds(
excludeCharacterIds: List<Long>,
limit: Int
): List<Long>
fun findCheerCreatorRecommendationDetails( fun findCheerCreatorRecommendationDetails(
creatorIds: List<Long>, creatorIds: List<Long>,
memberId: Long? = null memberId: Long? = null

View File

@@ -1619,6 +1619,35 @@ class DefaultHomeRecommendationQueryRepositoryTest @Autowired constructor(
) )
} }
@Test
@DisplayName("랜덤 AI 캐릭터 id는 제외 id를 빼고 활성 AI 캐릭터 크리에이터 회원만 조회한다")
fun shouldFindRandomAiCharacterRecommendationIdsExcludingExistingCharacters() {
val excludedCharacter = saveCharacter("ai-random-excluded", isActive = true)
val visibleCharacter = saveCharacter("ai-random-visible", isActive = true)
val inactiveCharacter = saveCharacter("ai-random-inactive", isActive = false)
val inactiveCreatorCharacter = saveCharacter("ai-random-inactive-creator", isActive = true).apply {
creatorMember!!.isActive = false
}
val userCreatorCharacter = saveCharacter("ai-random-user-creator", isActive = true).apply {
creatorMember!!.role = MemberRole.USER
}
val humanCreatorCharacter = saveCharacter("ai-random-human-creator", isActive = true).apply {
creatorMember!!.memberKind = MemberKind.HUMAN
}
flushAndClear()
val characterIds = repository.findRandomAiCharacterRecommendationIds(
excludeCharacterIds = listOf(excludedCharacter.id!!),
limit = 10
)
assertEquals(listOf(visibleCharacter.id), characterIds)
assertEquals(false, characterIds.contains(inactiveCharacter.id))
assertEquals(false, characterIds.contains(inactiveCreatorCharacter.id))
assertEquals(false, characterIds.contains(userCreatorCharacter.id))
assertEquals(false, characterIds.contains(humanCreatorCharacter.id))
}
@Test @Test
@DisplayName("최근 응원 크리에이터 상세는 활성 크리에이터의 닉네임과 프로필만 조회한다") @DisplayName("최근 응원 크리에이터 상세는 활성 크리에이터의 닉네임과 프로필만 조회한다")
fun shouldFindCheerCreatorRecommendationDetailsForActiveCreatorsOnly() { fun shouldFindCheerCreatorRecommendationDetailsForActiveCreatorsOnly() {

View File

@@ -196,6 +196,53 @@ class HomeRecommendationQueryServiceTest {
assertEquals(null, characters.last().originalWorkTitle) assertEquals(null, characters.last().originalWorkTitle)
} }
@Test
@DisplayName("홈 AI 캐릭터 추천은 스냅샷 상세가 부족하면 랜덤 AI 캐릭터로 limit까지 채운다")
fun shouldBackfillAiCharactersWithRandomDetailsWhenSnapshotDetailsAreUnderLimit() {
val snapshotAt = LocalDateTime.of(2026, 7, 9, 14, 59, 59)
snapshotPort.replaceSnapshots(
RecommendedSectionType.AI_CHARACTER,
snapshotAt,
(1L..3L).map { targetId -> snapshot(RecommendedSectionType.AI_CHARACTER, targetId, 100.0 - targetId, snapshotAt) }
)
port.aiCharacterDetailsByRequest = mapOf(
(1L..3L).toList() to listOf(aiCharacterRecord(1L, 101L)),
listOf(4L, 5L) to listOf(aiCharacterRecord(4L, 104L), aiCharacterRecord(5L, 105L))
)
port.randomAiCharacterIds = listOf(4L, 5L)
val characters = service.findAiCharacterRecommendations(limit = 3, backfillRandom = true)
assertEquals(listOf(4L, 5L), port.aiCharacterDetailIds)
assertEquals((1L..3L).toList(), port.randomAiCharacterExcludeIds)
assertEquals(2, port.randomAiCharacterLimit)
assertEquals(listOf((1L..3L).toList(), listOf(4L, 5L)), port.aiCharacterDetailIdRequests)
assertEquals(listOf(1L, 4L, 5L), characters.map { it.characterId })
}
@Test
@DisplayName("홈 AI 캐릭터 추천은 랜덤 후보가 부족하면 가능한 캐릭터만 중복 없이 반환한다")
fun shouldReturnAvailableAiCharactersWhenRandomBackfillIsUnderLimit() {
val snapshotAt = LocalDateTime.of(2026, 7, 9, 14, 59, 59)
snapshotPort.replaceSnapshots(
RecommendedSectionType.AI_CHARACTER,
snapshotAt,
listOf(snapshot(RecommendedSectionType.AI_CHARACTER, 1L, 100.0, snapshotAt))
)
port.aiCharacterDetailsByRequest = mapOf(
listOf(1L) to listOf(aiCharacterRecord(1L, 101L)),
listOf(2L) to listOf(aiCharacterRecord(2L, 102L))
)
port.randomAiCharacterIds = listOf(2L)
val characters = service.findAiCharacterRecommendations(limit = 3, backfillRandom = true)
assertEquals(listOf(1L), port.randomAiCharacterExcludeIds)
assertEquals(2, port.randomAiCharacterLimit)
assertEquals(listOf(listOf(1L), listOf(2L)), port.aiCharacterDetailIdRequests)
assertEquals(listOf(1L, 2L), characters.map { it.characterId })
}
@Test @Test
@DisplayName("AI 캐릭터 추천은 최신 스냅샷이 없으면 fallback refresh 후 스냅샷 상세를 조립한다") @DisplayName("AI 캐릭터 추천은 최신 스냅샷이 없으면 fallback refresh 후 스냅샷 상세를 조립한다")
fun shouldFindAiCharactersAfterFallbackWhenLatestSnapshotsDoNotExist() { fun shouldFindAiCharactersAfterFallbackWhenLatestSnapshotsDoNotExist() {
@@ -254,10 +301,39 @@ class HomeRecommendationQueryServiceTest {
assertEquals(null, fallback.offset) assertEquals(null, fallback.offset)
assertEquals(null, fallback.limit) assertEquals(null, fallback.limit)
assertEquals(null, port.randomAiCharacterExcludeIds)
assertEquals(null, port.randomAiCharacterLimit)
assertEquals(emptyList<Long>(), port.aiCharacterDetailIds) assertEquals(emptyList<Long>(), port.aiCharacterDetailIds)
assertEquals(emptyList<HomeAiCharacterRecommendationRecord>(), characters) assertEquals(emptyList<HomeAiCharacterRecommendationRecord>(), characters)
} }
@Test
@DisplayName("AI 캐릭터 전체보기는 첫 페이지여도 랜덤 보충을 적용하지 않는다")
fun shouldNotBackfillAiCharacterPageWithRandomDetails() {
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) }
)
port.aiCharacterDetails = listOf(aiCharacterRecord(1L, 101L))
port.randomAiCharacterIds = listOf(21L)
val characters = service.findAiCharacterRecommendations(offset = 0, limit = 20)
assertEquals(null, fallback.offset)
assertEquals(null, fallback.limit)
assertEquals(null, port.randomAiCharacterExcludeIds)
assertEquals(null, port.randomAiCharacterLimit)
assertEquals((1L..20L).toList(), port.aiCharacterDetailIds)
assertEquals(listOf(1L), characters.map { it.characterId })
}
@Test @Test
@DisplayName("AI 캐릭터 추천은 최신 스냅샷이 있으면 요청 페이지가 비어도 fallback refresh를 호출하지 않는다") @DisplayName("AI 캐릭터 추천은 최신 스냅샷이 있으면 요청 페이지가 비어도 fallback refresh를 호출하지 않는다")
fun shouldNotFallbackWhenLatestAiSnapshotsExistButRequestedPageIsEmpty() { fun shouldNotFallbackWhenLatestAiSnapshotsExistButRequestedPageIsEmpty() {
@@ -902,6 +978,9 @@ class HomeRecommendationQueryServiceTest {
var firstAudioMemberId: Long? = null var firstAudioMemberId: Long? = null
var firstAudioIncludeAdultContents: Boolean? = null var firstAudioIncludeAdultContents: Boolean? = null
var aiCharacterDetailIds: List<Long> = emptyList() var aiCharacterDetailIds: List<Long> = emptyList()
val aiCharacterDetailIdRequests = mutableListOf<List<Long>>()
var randomAiCharacterExcludeIds: List<Long>? = null
var randomAiCharacterLimit: Int? = null
var cheerCreatorDetailIds: List<Long> = emptyList() var cheerCreatorDetailIds: List<Long> = emptyList()
var popularCommunityDetailIds: List<Long> = emptyList() var popularCommunityDetailIds: List<Long> = emptyList()
var popularCommunityIncludeAdultCommunities: Boolean? = null var popularCommunityIncludeAdultCommunities: Boolean? = null
@@ -962,6 +1041,8 @@ class HomeRecommendationQueryServiceTest {
) )
) )
var aiCharacterDetails: List<HomeAiCharacterRecommendationRecord> = emptyList() var aiCharacterDetails: List<HomeAiCharacterRecommendationRecord> = emptyList()
var aiCharacterDetailsByRequest: Map<List<Long>, List<HomeAiCharacterRecommendationRecord>> = emptyMap()
var randomAiCharacterIds: List<Long> = emptyList()
var cheerCreatorDetails: List<HomeCheerCreatorRecommendationRecord> = emptyList() var cheerCreatorDetails: List<HomeCheerCreatorRecommendationRecord> = emptyList()
var cheerCreatorMemberId: Long? = null var cheerCreatorMemberId: Long? = null
var popularCommunityDetails: List<HomePopularCommunityRecommendationRecord> = emptyList() var popularCommunityDetails: List<HomePopularCommunityRecommendationRecord> = emptyList()
@@ -1048,9 +1129,20 @@ class HomeRecommendationQueryServiceTest {
override fun findAiCharacterRecommendationDetails(characterIds: List<Long>): List<HomeAiCharacterRecommendationRecord> { override fun findAiCharacterRecommendationDetails(characterIds: List<Long>): List<HomeAiCharacterRecommendationRecord> {
aiCharacterDetailIds = characterIds aiCharacterDetailIds = characterIds
aiCharacterDetailIdRequests.add(characterIds)
aiCharacterDetailsByRequest[characterIds]?.let { return it }
return aiCharacterDetails return aiCharacterDetails
} }
override fun findRandomAiCharacterRecommendationIds(
excludeCharacterIds: List<Long>,
limit: Int
): List<Long> {
randomAiCharacterExcludeIds = excludeCharacterIds
randomAiCharacterLimit = limit
return randomAiCharacterIds.take(limit)
}
override fun findCheerCreatorRecommendationDetails( override fun findCheerCreatorRecommendationDetails(
creatorIds: List<Long>, creatorIds: List<Long>,
memberId: Long? memberId: Long?
@@ -1154,6 +1246,16 @@ private fun fixedClock(): Clock {
return Clock.fixed(Instant.parse("2026-07-09T21:00:00Z"), ZoneOffset.UTC) return Clock.fixed(Instant.parse("2026-07-09T21:00:00Z"), ZoneOffset.UTC)
} }
private fun aiCharacterRecord(characterId: Long, creatorId: Long) = HomeAiCharacterRecommendationRecord(
characterId = characterId,
creatorId = creatorId,
name = "character-$characterId",
description = "description-$characterId",
profileImage = null,
totalChatCount = 0L,
originalWorkTitle = null
)
private class FakeCheerCreatorSnapshotFallbackPort( private class FakeCheerCreatorSnapshotFallbackPort(
private val snapshots: List<RecommendationSnapshotRecord> private val snapshots: List<RecommendationSnapshotRecord>
) : CheerCreatorSnapshotFallbackPort { ) : CheerCreatorSnapshotFallbackPort {