From a0bf71be47cfeddabb35599d734c008ee85bbff2 Mon Sep 17 00:00:00 2001 From: Klaus Date: Wed, 22 Jul 2026 22:14:26 +0900 Subject: [PATCH] =?UTF-8?q?feat(home):=20AI=20=EC=BA=90=EB=A6=AD=ED=84=B0?= =?UTF-8?q?=20=EB=B6=80=EC=A1=B1=EB=B6=84=EC=9D=84=20=EB=9E=9C=EB=8D=A4=20?= =?UTF-8?q?=EB=B3=B4=EC=B6=A9=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/20260529_메인_홈_추천_API/plan-task.md | 24 +++++ docs/20260529_메인_홈_추천_API/prd.md | 3 + .../plan-task.md | 21 +++- .../prd.md | 4 + .../application/HomeRecommendationFacade.kt | 5 +- ...efaultHomeRecommendationQueryRepository.kt | 27 +++++ .../HomeRecommendationQueryService.kt | 14 ++- .../port/out/HomeRecommendationQueryPort.kt | 5 + ...ltHomeRecommendationQueryRepositoryTest.kt | 29 +++++ .../HomeRecommendationQueryServiceTest.kt | 102 ++++++++++++++++++ 10 files changed, 230 insertions(+), 4 deletions(-) diff --git a/docs/20260529_메인_홈_추천_API/plan-task.md b/docs/20260529_메인_홈_추천_API/plan-task.md index 07412812..b3a0d57e 100644 --- a/docs/20260529_메인_홈_추천_API/plan-task.md +++ b/docs/20260529_메인_홈_추천_API/plan-task.md @@ -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: 회귀 검증으로 `./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: 콘텐츠 조회 이력 기록 - [x] **Task 4.1: 콘텐츠 조회 이력 엔티티/서비스 작성** diff --git a/docs/20260529_메인_홈_추천_API/prd.md b/docs/20260529_메인_홈_추천_API/prd.md index 6ea62a31..b084cb83 100644 --- a/docs/20260529_메인_홈_추천_API/prd.md +++ b/docs/20260529_메인_홈_추천_API/prd.md @@ -164,6 +164,7 @@ #### Requirements - AI 캐릭터 리스트를 조회한다. - 홈 첫 화면은 20개를 조회한다. +- 홈 첫 화면은 최신 스냅샷 상세 조회 결과가 20개 미만이면 이미 조회한 스냅샷 캐릭터를 제외한 활성 AI 캐릭터 랜덤 조회로 부족분을 채운다. - 전체 리스트 API는 페이징으로 조회할 수 있어야 한다. - 노출 정보는 캐릭터 id, AI 캐릭터에 대응하는 creator id, 캐릭터 이름, 캐릭터 소개, 프로필 이미지, 작품명, 사용자들이 친 전체 채팅 수를 포함한다. - AI 캐릭터에 대응하는 creator id는 `ChatCharacter.creatorMember.id`이며, 해당 Member는 `role = CREATOR`, `memberKind = AI_CHARACTER`인 내부 크리에이터 Member다. @@ -182,6 +183,8 @@ #### Edge Cases - 비활성 또는 노출 제한 캐릭터는 제외한다. - 활성 `ChatCharacter`에 `creatorMember`가 없거나 연결된 Member가 비활성/비 CREATOR/비 AI_CHARACTER이면 해당 AI 캐릭터는 홈 추천 응답에서 제외한다. +- 랜덤 보충 후보가 부족하면 중복 없이 조회 가능한 AI 캐릭터만 내려준다. +- 전체 리스트 API의 paging 조회는 페이지별 랜덤 보충을 적용하지 않는다. ### Feature H. 장르의 크리에이터 diff --git a/docs/20260709_메인_홈_추천_AI캐릭터_스냅샷/plan-task.md b/docs/20260709_메인_홈_추천_AI캐릭터_스냅샷/plan-task.md index e060c54d..5a50f77f 100644 --- a/docs/20260709_메인_홈_추천_AI캐릭터_스냅샷/plan-task.md +++ b/docs/20260709_메인_홈_추천_AI캐릭터_스냅샷/plan-task.md @@ -20,6 +20,7 @@ - 유지: `DefaultHomeRecommendationQueryRepository.findAiCharacterRecommendationDetails(...)`의 상세 조립, `characterId`, `creatorId`, 원작명, 전체 채팅 수, 활성 `creatorMember` 필터는 변경하지 않는다. - 유지: `HomeRecommendationFacade`, `HomeRecommendationController`, `HomeRecommendationResponse.HomeAiCharacterItem`의 공개 API URL과 응답 필드는 변경하지 않는다. - 유지: 최근 응원/인기 커뮤니티 스냅샷 산식, window, 저장 limit, random tie-breaker 정렬은 변경하지 않는다. +- 추가: 홈 통합 조회에서 최신 스냅샷 상세 결과가 20개 미만이면 스냅샷 캐릭터 id를 제외한 활성 AI 캐릭터 랜덤 조회로 부족분을 채운다. - 변경: `findAiCharacterSnapshots(...)`의 입력 window, AI 캐릭터 점수 산식, 신규 부스트 값, 팔로우 증가 수 포함, 후보 제외 조건, top 20 동점 정렬 기준만 변경한다. - 변경: `RecommendationSnapshotRefreshService`에는 AI 캐릭터 단일 refresh 경로를 추가하되, 기존 전체 일 refresh가 최근 응원/인기 커뮤니티까지 함께 갱신하는 동작은 유지한다. - 추가: 최신 AI 캐릭터 스냅샷이 없을 때만 fallback refresh orchestration을 추가한다. @@ -217,6 +218,21 @@ ### 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 실행** - 파일 경로: - Modify: `docs/20260709_메인_홈_추천_AI캐릭터_스냅샷/plan-task.md` @@ -246,7 +262,7 @@ - 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 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 방지를 검증한다. - 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: 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-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: 전체 검증으로 `./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은 트랜잭션 완료 후 해제되도록 보강했다. diff --git a/docs/20260709_메인_홈_추천_AI캐릭터_스냅샷/prd.md b/docs/20260709_메인_홈_추천_AI캐릭터_스냅샷/prd.md index c2907123..213d136b 100644 --- a/docs/20260709_메인_홈_추천_AI캐릭터_스냅샷/prd.md +++ b/docs/20260709_메인_홈_추천_AI캐릭터_스냅샷/prd.md @@ -21,6 +21,7 @@ - 팔로우 증가 수는 전날 발생한 AI 캐릭터 신규 팔로우 수로 계산한다. - 신규 부스트는 `ChatCharacter.createdAt` 기준으로 10일 이내 1.15, 20일 이내 1.10, 30일 이내 1.05, 그 외 1.0을 적용한다. - 홈 통합 조회와 AI 캐릭터 전체보기 조회는 최신 AI 캐릭터 스냅샷의 점수순 결과를 사용한다. +- 홈 통합 조회는 최신 스냅샷 상세 조회 결과가 20개 미만이면 스냅샷 캐릭터를 제외한 활성 AI 캐릭터 랜덤 조회로 부족분을 채운다. --- @@ -117,11 +118,14 @@ - 사용자들이 친 전체 채팅 수 - 스냅샷 정렬은 점수 내림차순, 동일 점수면 더 늦게 생성된 캐릭터를 우선한다. - 조회 시점에도 비활성 또는 노출 제한 캐릭터는 제외한다. +- 홈 통합 조회에서 스냅샷 상세 조회 결과가 20개 미만이면 이미 조회한 스냅샷 캐릭터 id를 제외하고 활성 AI 캐릭터를 랜덤으로 조회해 부족분을 뒤에 붙인다. #### Edge Cases - 스냅샷에는 존재하지만 조회 시점에 캐릭터 또는 `creatorMember`가 비활성화된 경우 응답에서 제외한다. - fallback refresh 후에도 최신 스냅샷이 없으면 기존 홈 추천 API 동작과 동일하게 빈 배열을 반환한다. - 상세 조회 결과가 스냅샷 저장 개수보다 적어도 홈 조회 전체는 성공 처리한다. +- 랜덤 보충 후보가 부족하면 중복 없이 조회 가능한 AI 캐릭터만 반환한다. +- 빈 스냅샷 refresh 완료 marker가 있거나 전체보기 paging 조회이면 랜덤 보충을 적용하지 않는다. ### Feature E. 스냅샷 없음 fallback refresh diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/home/application/HomeRecommendationFacade.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/home/application/HomeRecommendationFacade.kt index 3b7617c5..a462f09b 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/home/application/HomeRecommendationFacade.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/home/application/HomeRecommendationFacade.kt @@ -75,7 +75,10 @@ class HomeRecommendationFacade( includeAdultContents = includeAdult ) .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( memberId = member?.id, includeAdultGenres = includeAdult, diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/recommendation/adapter/out/persistence/DefaultHomeRecommendationQueryRepository.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/recommendation/adapter/out/persistence/DefaultHomeRecommendationQueryRepository.kt index 0b5bdf43..829560ef 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/v2/recommendation/adapter/out/persistence/DefaultHomeRecommendationQueryRepository.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/recommendation/adapter/out/persistence/DefaultHomeRecommendationQueryRepository.kt @@ -775,6 +775,29 @@ class DefaultHomeRecommendationQueryRepository( .fetch() } + override fun findRandomAiCharacterRecommendationIds( + excludeCharacterIds: List, + limit: Int + ): List { + 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( creatorIds: List, memberId: Long? @@ -1235,6 +1258,10 @@ class DefaultHomeRecommendationQueryRepository( return if (includeAdultLives) null else liveRoom.isAdult.isFalse } + private fun excludeAiCharacterCondition(excludeCharacterIds: List): BooleanExpression? { + return if (excludeCharacterIds.isEmpty()) null else chatCharacter.id.notIn(excludeCharacterIds) + } + private fun notBlockedCreatorCondition(memberId: Long?, creatorIdPath: Expression): BooleanExpression? { if (memberId == null) return null val blockMember = QBlockMember("recommendationBlockMember") diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/recommendation/application/HomeRecommendationQueryService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/recommendation/application/HomeRecommendationQueryService.kt index d57eb3fc..9c877a3d 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/v2/recommendation/application/HomeRecommendationQueryService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/recommendation/application/HomeRecommendationQueryService.kt @@ -78,13 +78,23 @@ class HomeRecommendationQueryService( fun findAiCharacterRecommendations( offset: Long = 0, - limit: Int = DEFAULT_AI_CHARACTER_LIMIT + limit: Int = DEFAULT_AI_CHARACTER_LIMIT, + backfillRandom: Boolean = false ): List { val snapshots = findAiCharacterSnapshotsWithFallback(offset, limit) val detailsById = queryPort.findAiCharacterRecommendationDetails(snapshots.map { it.targetId }) .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( diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/recommendation/port/out/HomeRecommendationQueryPort.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/recommendation/port/out/HomeRecommendationQueryPort.kt index eaedcb4e..30e0300e 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/v2/recommendation/port/out/HomeRecommendationQueryPort.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/recommendation/port/out/HomeRecommendationQueryPort.kt @@ -58,6 +58,11 @@ interface HomeRecommendationQueryPort { fun findAiCharacterRecommendationDetails(characterIds: List): List + fun findRandomAiCharacterRecommendationIds( + excludeCharacterIds: List, + limit: Int + ): List + fun findCheerCreatorRecommendationDetails( creatorIds: List, memberId: Long? = null diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/v2/recommendation/adapter/out/persistence/DefaultHomeRecommendationQueryRepositoryTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/recommendation/adapter/out/persistence/DefaultHomeRecommendationQueryRepositoryTest.kt index 5ea19c98..ef606ecd 100644 --- a/src/test/kotlin/kr/co/vividnext/sodalive/v2/recommendation/adapter/out/persistence/DefaultHomeRecommendationQueryRepositoryTest.kt +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/recommendation/adapter/out/persistence/DefaultHomeRecommendationQueryRepositoryTest.kt @@ -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 @DisplayName("최근 응원 크리에이터 상세는 활성 크리에이터의 닉네임과 프로필만 조회한다") fun shouldFindCheerCreatorRecommendationDetailsForActiveCreatorsOnly() { diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/v2/recommendation/application/HomeRecommendationQueryServiceTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/recommendation/application/HomeRecommendationQueryServiceTest.kt index e2d4d5a8..a6efc425 100644 --- a/src/test/kotlin/kr/co/vividnext/sodalive/v2/recommendation/application/HomeRecommendationQueryServiceTest.kt +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/recommendation/application/HomeRecommendationQueryServiceTest.kt @@ -196,6 +196,53 @@ class HomeRecommendationQueryServiceTest { 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 @DisplayName("AI 캐릭터 추천은 최신 스냅샷이 없으면 fallback refresh 후 스냅샷 상세를 조립한다") fun shouldFindAiCharactersAfterFallbackWhenLatestSnapshotsDoNotExist() { @@ -254,10 +301,39 @@ class HomeRecommendationQueryServiceTest { assertEquals(null, fallback.offset) assertEquals(null, fallback.limit) + assertEquals(null, port.randomAiCharacterExcludeIds) + assertEquals(null, port.randomAiCharacterLimit) assertEquals(emptyList(), port.aiCharacterDetailIds) assertEquals(emptyList(), 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 @DisplayName("AI 캐릭터 추천은 최신 스냅샷이 있으면 요청 페이지가 비어도 fallback refresh를 호출하지 않는다") fun shouldNotFallbackWhenLatestAiSnapshotsExistButRequestedPageIsEmpty() { @@ -902,6 +978,9 @@ class HomeRecommendationQueryServiceTest { var firstAudioMemberId: Long? = null var firstAudioIncludeAdultContents: Boolean? = null var aiCharacterDetailIds: List = emptyList() + val aiCharacterDetailIdRequests = mutableListOf>() + var randomAiCharacterExcludeIds: List? = null + var randomAiCharacterLimit: Int? = null var cheerCreatorDetailIds: List = emptyList() var popularCommunityDetailIds: List = emptyList() var popularCommunityIncludeAdultCommunities: Boolean? = null @@ -962,6 +1041,8 @@ class HomeRecommendationQueryServiceTest { ) ) var aiCharacterDetails: List = emptyList() + var aiCharacterDetailsByRequest: Map, List> = emptyMap() + var randomAiCharacterIds: List = emptyList() var cheerCreatorDetails: List = emptyList() var cheerCreatorMemberId: Long? = null var popularCommunityDetails: List = emptyList() @@ -1048,9 +1129,20 @@ class HomeRecommendationQueryServiceTest { override fun findAiCharacterRecommendationDetails(characterIds: List): List { aiCharacterDetailIds = characterIds + aiCharacterDetailIdRequests.add(characterIds) + aiCharacterDetailsByRequest[characterIds]?.let { return it } return aiCharacterDetails } + override fun findRandomAiCharacterRecommendationIds( + excludeCharacterIds: List, + limit: Int + ): List { + randomAiCharacterExcludeIds = excludeCharacterIds + randomAiCharacterLimit = limit + return randomAiCharacterIds.take(limit) + } + override fun findCheerCreatorRecommendationDetails( creatorIds: List, memberId: Long? @@ -1154,6 +1246,16 @@ private fun fixedClock(): Clock { 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 val snapshots: List ) : CheerCreatorSnapshotFallbackPort {