Compare commits
2 Commits
78f24d774b
...
15b55704e0
| Author | SHA1 | Date | |
|---|---|---|---|
| 15b55704e0 | |||
| c706c73b05 |
@@ -268,12 +268,31 @@
|
|||||||
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/recommendation/application/HomeRecommendationQueryService.kt`
|
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/recommendation/application/HomeRecommendationQueryService.kt`
|
||||||
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/recommendation/adapter/out/persistence/DefaultHomeRecommendationQueryRepository.kt`
|
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/recommendation/adapter/out/persistence/DefaultHomeRecommendationQueryRepository.kt`
|
||||||
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/recommendation/application/HomeRecommendationQueryServiceTest.kt`
|
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/recommendation/application/HomeRecommendationQueryServiceTest.kt`
|
||||||
- RED: 스냅샷 기준 AI 캐릭터 10개, AI 캐릭터 응답의 캐릭터 이름/소개/전체 채팅 수/오리지널 작품명 조건, 최근 응원 8명과 크리에이터 프로필 이미지/닉네임, 인기 커뮤니티 10개와 크리에이터 프로필 이미지/닉네임/UTC 시간/좋아요 수/댓글 수/커뮤니티 내용, 스냅샷 없음 빈 배열 테스트를 작성한다.
|
- RED: 스냅샷 기준 AI 캐릭터 20개, AI 캐릭터 응답의 캐릭터 이름/소개/전체 채팅 수/오리지널 작품명 조건, 최근 응원 8명과 크리에이터 프로필 이미지/닉네임, 인기 커뮤니티 10개와 크리에이터 프로필 이미지/닉네임/UTC 시간/좋아요 수/댓글 수/커뮤니티 내용, 스냅샷 없음 빈 배열 테스트를 작성한다.
|
||||||
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.recommendation.application.HomeRecommendationQueryServiceTest`
|
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.recommendation.application.HomeRecommendationQueryServiceTest`
|
||||||
- GREEN: `RecommendationSnapshotRepository`에서 최신 스냅샷을 읽고 대상 엔티티 상세 정보를 조립한다. AI 캐릭터 작품명은 오리지널 작품 캐릭터인 경우에만 채우고, 인기 커뮤니티는 스냅샷에 저장된 점수/랜덤 tie-breaker 순서를 유지한다.
|
- GREEN: `RecommendationSnapshotRepository`에서 최신 스냅샷을 읽고 대상 엔티티 상세 정보를 조립한다. AI 캐릭터 작품명은 오리지널 작품 캐릭터인 경우에만 채우고, 인기 커뮤니티는 스냅샷에 저장된 점수/랜덤 tie-breaker 순서를 유지한다.
|
||||||
- REFACTOR: 비활성/노출 제한 캐릭터, 커뮤니티 비공개/유료/핀/성인 조건을 repository 조건으로 고정한다.
|
- REFACTOR: 비활성/노출 제한 캐릭터, 커뮤니티 비공개/유료/핀/성인 조건을 repository 조건으로 고정한다.
|
||||||
- 기대 결과: AI 캐릭터 노출 필드가 PRD와 일치하고, 인기 커뮤니티는 크리에이터당 1개만 반환하며 동일 점수는 스냅샷 생성 시 저장한 랜덤 tie-breaker 기준으로 노출된다.
|
- 기대 결과: AI 캐릭터 노출 필드가 PRD와 일치하고, 인기 커뮤니티는 크리에이터당 1개만 반환하며 동일 점수는 스냅샷 생성 시 저장한 랜덤 tie-breaker 기준으로 노출된다.
|
||||||
|
|
||||||
|
### Phase 9: AI 캐릭터 홈 노출 개수 20개 조정
|
||||||
|
|
||||||
|
- [x] **Task 9.1: 홈 추천 AI 캐릭터 기본 조회 개수 20개 적용**
|
||||||
|
- Files:
|
||||||
|
- Modify: `docs/20260529_메인_홈_추천_API/prd.md`
|
||||||
|
- Modify: `docs/20260529_메인_홈_추천_API/plan-task.md`
|
||||||
|
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/home/application/HomeRecommendationFacade.kt`
|
||||||
|
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/recommendation/application/HomeRecommendationQueryService.kt`
|
||||||
|
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/recommendation/application/HomeRecommendationQueryServiceTest.kt`
|
||||||
|
- RED: AI 캐릭터 추천 기본 조회가 최신 스냅샷 20개를 기준으로 상세 조회 id를 전달하는 테스트로 기존 10개 계약이 실패하도록 한다.
|
||||||
|
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.recommendation.application.HomeRecommendationQueryServiceTest`
|
||||||
|
- GREEN: 홈 통합 응답의 AI 캐릭터 limit과 service 기본 AI 캐릭터 limit을 20개로 변경한다. 스냅샷 저장 상한은 이미 20개이므로 변경하지 않는다.
|
||||||
|
- REFACTOR: PRD와 기존 Task 3.3의 AI 캐릭터 개수 문구를 20개로 맞추고, 전체보기 paging 기본 size 20 계약은 유지한다.
|
||||||
|
- 기대 결과: `GET /api/v2/home/recommendations`의 `aiCharacters`가 최신 AI 캐릭터 스냅샷 기준 최대 20개까지 내려간다.
|
||||||
|
- 검증 기록:
|
||||||
|
- 2026-07-09: RED에서 `HomeRecommendationQueryServiceTest`의 AI 캐릭터 최신 스냅샷 기준 기대값을 20개로 변경했고, 기존 구현은 `DEFAULT_AI_CHARACTER_LIMIT = 10` 때문에 `HomeRecommendationQueryServiceTest > AI 캐릭터 추천은 최신 스냅샷 20개를 기준으로 순서를 유지해 상세를 조립한다`가 실패했다.
|
||||||
|
- 2026-07-09: GREEN에서 `HomeRecommendationFacade.HOME_AI_CHARACTER_LIMIT`와 `HomeRecommendationQueryService.DEFAULT_AI_CHARACTER_LIMIT`를 20으로 변경하고, 홈 통합 응답이 AI 캐릭터를 최대 20개 반환하는 controller 테스트를 추가했다. `./gradlew test --tests kr.co.vividnext.sodalive.v2.recommendation.application.HomeRecommendationQueryServiceTest --tests kr.co.vividnext.sodalive.v2.api.home.HomeRecommendationControllerTest` 실행 결과 `BUILD SUCCESSFUL`로 통과했다.
|
||||||
|
- 2026-07-09: 회귀 검증으로 `./gradlew ktlintCheck`와 `./gradlew tasks --all`을 실행해 모두 `BUILD SUCCESSFUL`로 통과했다. 두 명령은 최초 sandbox 실행에서 Gradle wrapper의 `~/.gradle` lock 파일 접근 권한 문제로 실패했고, 권한 상승 재실행으로 통과했다. `./gradlew :app:ktlintCheck`는 단일 루트 프로젝트에 `app` 프로젝트가 없어 실패했으며, 저장소 기준 명령인 `./gradlew ktlintCheck`로 대체 검증했다.
|
||||||
|
|
||||||
### Phase 4: 콘텐츠 조회 이력 기록
|
### Phase 4: 콘텐츠 조회 이력 기록
|
||||||
|
|
||||||
- [x] **Task 4.1: 콘텐츠 조회 이력 엔티티/서비스 작성**
|
- [x] **Task 4.1: 콘텐츠 조회 이력 엔티티/서비스 작성**
|
||||||
|
|||||||
@@ -72,7 +72,7 @@
|
|||||||
- 방금 활동한 크리에이터: 10개
|
- 방금 활동한 크리에이터: 10개
|
||||||
- 최근 데뷔한 크리에이터: 10개
|
- 최근 데뷔한 크리에이터: 10개
|
||||||
- 처음부터 함께 성장: 10개
|
- 처음부터 함께 성장: 10개
|
||||||
- 크리에이터와 이야기를 나눠요: 10개
|
- 크리에이터와 이야기를 나눠요: 20개
|
||||||
- 장르의 크리에이터: 장르 최대 5개, 장르별 크리에이터 8명
|
- 장르의 크리에이터: 장르 최대 5개, 장르별 크리에이터 8명
|
||||||
- 최근 응원이 많은 크리에이터: 8명
|
- 최근 응원이 많은 크리에이터: 8명
|
||||||
- 인기 커뮤니티: 10개
|
- 인기 커뮤니티: 10개
|
||||||
@@ -162,7 +162,7 @@
|
|||||||
|
|
||||||
#### Requirements
|
#### Requirements
|
||||||
- AI 캐릭터 리스트를 조회한다.
|
- AI 캐릭터 리스트를 조회한다.
|
||||||
- 홈 첫 화면은 10개를 조회한다.
|
- 홈 첫 화면은 20개를 조회한다.
|
||||||
- 전체 리스트 API는 페이징으로 조회할 수 있어야 한다.
|
- 전체 리스트 API는 페이징으로 조회할 수 있어야 한다.
|
||||||
- 노출 정보는 캐릭터 id, AI 캐릭터에 대응하는 creator id, 캐릭터 이름, 캐릭터 소개, 프로필 이미지, 작품명, 사용자들이 친 전체 채팅 수를 포함한다.
|
- 노출 정보는 캐릭터 id, AI 캐릭터에 대응하는 creator id, 캐릭터 이름, 캐릭터 소개, 프로필 이미지, 작품명, 사용자들이 친 전체 채팅 수를 포함한다.
|
||||||
- AI 캐릭터에 대응하는 creator id는 `ChatCharacter.creatorMember.id`이며, 해당 Member는 `role = CREATOR`, `memberKind = AI_CHARACTER`인 내부 크리에이터 Member다.
|
- AI 캐릭터에 대응하는 creator id는 `ChatCharacter.creatorMember.id`이며, 해당 Member는 `role = CREATOR`, `memberKind = AI_CHARACTER`인 내부 크리에이터 Member다.
|
||||||
@@ -279,7 +279,7 @@
|
|||||||
- 일 1회 갱신 스냅샷은 후보를 application/service 메모리로 모두 불러와 점수를 계산하지 않는다. DB 조회에서 모든 적격 후보의 최종 점수와 랜덤 tie-breaker를 계산한 뒤 `score desc, randomTieBreaker asc` 기준으로 정렬하고, 그 이후에만 최종 저장 개수 limit을 적용한다.
|
- 일 1회 갱신 스냅샷은 후보를 application/service 메모리로 모두 불러와 점수를 계산하지 않는다. DB 조회에서 모든 적격 후보의 최종 점수와 랜덤 tie-breaker를 계산한 뒤 `score desc, randomTieBreaker asc` 기준으로 정렬하고, 그 이후에만 최종 저장 개수 limit을 적용한다.
|
||||||
- 최종 점수 계산 전 candidate pre-limit, 랜덤 후보 컷오프, 임의 2배수 선제 제한은 정확한 top 후보를 누락할 수 있으므로 금지한다.
|
- 최종 점수 계산 전 candidate pre-limit, 랜덤 후보 컷오프, 임의 2배수 선제 제한은 정확한 top 후보를 누락할 수 있으므로 금지한다.
|
||||||
- DB score expression과 Kotlin `RecommendationScorePolicy`는 동일한 `RecommendationScoreSpec`의 가중치/부스트 구간 상수를 공유해 산식 drift를 방지한다.
|
- DB score expression과 Kotlin `RecommendationScorePolicy`는 동일한 `RecommendationScoreSpec`의 가중치/부스트 구간 상수를 공유해 산식 drift를 방지한다.
|
||||||
- 스냅샷 최종 저장 개수는 동점자 랜덤 노출 여지를 확보하기 위해 홈 첫 화면 노출 수의 2배인 AI 캐릭터 최대 20개, 최근 응원이 많은 크리에이터 최대 16개, 인기 커뮤니티 최대 20개로 한다. 단, 이 숫자는 최종 점수 계산과 동점 랜덤 정렬 이후 적용하는 저장 limit이며 candidate pre-limit가 아니다.
|
- 스냅샷 최종 저장 개수는 AI 캐릭터 홈 첫 화면 20개 안정 노출을 위해 AI 캐릭터 최대 20개, 동점자 랜덤 노출 여지를 확보하기 위해 최근 응원이 많은 크리에이터 최대 16개, 인기 커뮤니티 최대 20개로 한다. 단, 이 숫자는 최종 점수 계산과 동점 랜덤 정렬 이후 적용하는 저장 limit이며 candidate pre-limit가 아니다.
|
||||||
- 공개 시간은 UTC 기준 응답을 원칙으로 한다.
|
- 공개 시간은 UTC 기준 응답을 원칙으로 한다.
|
||||||
- 응답 DTO의 enum 값은 앱 다국어 처리를 위해 안정적인 영문 code로 내려준다.
|
- 응답 DTO의 enum 값은 앱 다국어 처리를 위해 안정적인 영문 code로 내려준다.
|
||||||
- 기존 API 스키마는 변경하지 않고 신규 v2 endpoint로 분리한다.
|
- 기존 API 스키마는 변경하지 않고 신규 v2 endpoint로 분리한다.
|
||||||
|
|||||||
@@ -315,7 +315,7 @@ class HomeRecommendationFacade(
|
|||||||
private const val HOME_ACTIVE_CREATOR_LIMIT = 10
|
private const val HOME_ACTIVE_CREATOR_LIMIT = 10
|
||||||
private const val HOME_RECENT_DEBUT_CREATOR_LIMIT = 10
|
private const val HOME_RECENT_DEBUT_CREATOR_LIMIT = 10
|
||||||
private const val HOME_FIRST_AUDIO_CONTENT_LIMIT = 10
|
private const val HOME_FIRST_AUDIO_CONTENT_LIMIT = 10
|
||||||
private const val HOME_AI_CHARACTER_LIMIT = 10
|
private const val HOME_AI_CHARACTER_LIMIT = 20
|
||||||
private const val HOME_GENRE_CREATOR_GENRE_LIMIT = 5
|
private const val HOME_GENRE_CREATOR_GENRE_LIMIT = 5
|
||||||
private const val HOME_GENRE_CREATOR_CREATOR_LIMIT = 8
|
private const val HOME_GENRE_CREATOR_CREATOR_LIMIT = 8
|
||||||
private const val HOME_CHEER_CREATOR_LIMIT = 8
|
private const val HOME_CHEER_CREATOR_LIMIT = 8
|
||||||
|
|||||||
@@ -164,7 +164,7 @@ class HomeRecommendationQueryService(
|
|||||||
private const val DEFAULT_ACTIVE_CREATOR_LIMIT = 10
|
private const val DEFAULT_ACTIVE_CREATOR_LIMIT = 10
|
||||||
private const val DEFAULT_RECENT_DEBUT_CREATOR_LIMIT = 10
|
private const val DEFAULT_RECENT_DEBUT_CREATOR_LIMIT = 10
|
||||||
private const val DEFAULT_FIRST_AUDIO_CONTENT_LIMIT = 10
|
private const val DEFAULT_FIRST_AUDIO_CONTENT_LIMIT = 10
|
||||||
private const val DEFAULT_AI_CHARACTER_LIMIT = 10
|
private const val DEFAULT_AI_CHARACTER_LIMIT = 20
|
||||||
private const val DEFAULT_CHEER_CREATOR_LIMIT = 8
|
private const val DEFAULT_CHEER_CREATOR_LIMIT = 8
|
||||||
private const val CHEER_CREATOR_CANDIDATE_LIMIT = 16
|
private const val CHEER_CREATOR_CANDIDATE_LIMIT = 16
|
||||||
private const val DEFAULT_POPULAR_COMMUNITY_LIMIT = 10
|
private const val DEFAULT_POPULAR_COMMUNITY_LIMIT = 10
|
||||||
|
|||||||
@@ -243,6 +243,22 @@ class HomeRecommendationControllerTest @Autowired constructor(
|
|||||||
.andExpect(jsonPath("$.data.lives").isArray)
|
.andExpect(jsonPath("$.data.lives").isArray)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("메인 홈 통합 조회는 AI 캐릭터를 최신 스냅샷 기준 최대 20개까지 반환한다")
|
||||||
|
fun shouldReturnTwentyAiCharactersOnHomeRecommendations() {
|
||||||
|
(1..22).forEach { index ->
|
||||||
|
val character = saveAiCharacter("home-ai-character-$index")
|
||||||
|
saveRecommendationSnapshot(character.id!!)
|
||||||
|
}
|
||||||
|
entityManager.flush()
|
||||||
|
entityManager.clear()
|
||||||
|
|
||||||
|
mockMvc.perform(get("/api/v2/home/recommendations"))
|
||||||
|
.andExpect(status().isOk)
|
||||||
|
.andExpect(jsonPath("$.success").value(true))
|
||||||
|
.andExpect(jsonPath("$.data.aiCharacters.length()").value(20))
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@DisplayName("메인 홈 통합 조회 실패는 응답 시간과 함께 로그로 관측된다")
|
@DisplayName("메인 홈 통합 조회 실패는 응답 시간과 함께 로그로 관측된다")
|
||||||
fun shouldLogHomeRecommendationFailure(output: CapturedOutput) {
|
fun shouldLogHomeRecommendationFailure(output: CapturedOutput) {
|
||||||
|
|||||||
@@ -141,7 +141,7 @@ class HomeRecommendationQueryServiceTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@DisplayName("AI 캐릭터 추천은 최신 스냅샷 10개를 기준으로 순서를 유지해 상세를 조립한다")
|
@DisplayName("AI 캐릭터 추천은 최신 스냅샷 20개를 기준으로 순서를 유지해 상세를 조립한다")
|
||||||
fun shouldFindAiCharactersFromLatestSnapshotsWithLimitAndDetails() {
|
fun shouldFindAiCharactersFromLatestSnapshotsWithLimitAndDetails() {
|
||||||
val oldSnapshotAt = LocalDateTime.of(2026, 5, 28, 23, 59, 59)
|
val oldSnapshotAt = LocalDateTime.of(2026, 5, 28, 23, 59, 59)
|
||||||
val latestSnapshotAt = LocalDateTime.of(2026, 5, 29, 23, 59, 59)
|
val latestSnapshotAt = LocalDateTime.of(2026, 5, 29, 23, 59, 59)
|
||||||
@@ -153,7 +153,7 @@ class HomeRecommendationQueryServiceTest {
|
|||||||
snapshotPort.replaceSnapshots(
|
snapshotPort.replaceSnapshots(
|
||||||
RecommendedSectionType.AI_CHARACTER,
|
RecommendedSectionType.AI_CHARACTER,
|
||||||
latestSnapshotAt,
|
latestSnapshotAt,
|
||||||
(1L..12L).map { targetId ->
|
(1L..22L).map { targetId ->
|
||||||
snapshot(RecommendedSectionType.AI_CHARACTER, targetId, 100.0 - targetId, latestSnapshotAt)
|
snapshot(RecommendedSectionType.AI_CHARACTER, targetId, 100.0 - targetId, latestSnapshotAt)
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
@@ -180,7 +180,7 @@ class HomeRecommendationQueryServiceTest {
|
|||||||
|
|
||||||
val characters = service.findAiCharacterRecommendations()
|
val characters = service.findAiCharacterRecommendations()
|
||||||
|
|
||||||
assertEquals((1L..10L).toList(), port.aiCharacterDetailIds)
|
assertEquals((1L..20L).toList(), port.aiCharacterDetailIds)
|
||||||
assertEquals(listOf(1L, 2L), characters.map { it.characterId })
|
assertEquals(listOf(1L, 2L), characters.map { it.characterId })
|
||||||
assertEquals(listOf(101L, 102L), characters.map { it.creatorId })
|
assertEquals(listOf(101L, 102L), characters.map { it.creatorId })
|
||||||
assertEquals("profile/character-1.png", characters.first().profileImage)
|
assertEquals("profile/character-1.png", characters.first().profileImage)
|
||||||
|
|||||||
Reference in New Issue
Block a user