diff --git a/docs/20260529_메인_홈_추천_API/plan-task.md b/docs/20260529_메인_홈_추천_API/plan-task.md index b3a0d57e..8f6ce095 100644 --- a/docs/20260529_메인_홈_추천_API/plan-task.md +++ b/docs/20260529_메인_홈_추천_API/plan-task.md @@ -29,6 +29,9 @@ - 저장소에는 DB migration 디렉터리가 없으므로 신규 스냅샷/조회 이력 엔티티 추가 시 운영 DB DDL 반영은 배포 절차에서 별도 수행한다. 코드 구현 task에는 JPA 엔티티/리포지토리와 통합 테스트를 포함하고, Phase 7 완료 후 신규 엔티티 테이블 생성 SQL을 문서 산출물로 작성한다. - 조회 구현은 JPA/QueryDSL 우선, native SQL 제한 사용의 하이브리드 전략으로 진행한다. 단순 조회/상세 조립/대상 활성 조건은 JPA 또는 QueryDSL로 표현하고, CTE/window function/`union all`/DB-side exact scoring처럼 SQL 고급 기능이 필요한 추천 산정에만 native SQL을 사용한다. native SQL 사용 시에는 H2 MySQL mode와 Kotlin 정책 산식 parity를 포함한 repository 통합 테스트를 반드시 둔다. - 이번 범위에서는 기존 홈/콘텐츠 홈/라이브/AI 캐릭터 API의 공개 스키마를 변경하지 않고, 앱 다국어 문구 번역, ML 개인화, A/B 테스트 플랫폼, 관리자 화면, 추천 결과 수동 편집 기능은 구현하지 않는다. 응답 enum은 앱 다국어 처리를 위해 안정적인 영문 code로 유지한다. +- 방금 활동한 크리에이터 item은 활동을 등록한 `Member.id`를 non-null `creatorId`로 항상 제공한다. +- `LIVE` 활동의 `targetId`는 `live_room.is_active = true`이면 `live_room.id`, `false`이면 `null`로 제공한다. +- 별도 `isOnAir`, `targetType`, 종료 전용 활동 타입은 추가하지 않고 기존 활동 시간·정렬과 비 LIVE `targetId` 의미를 유지한다. --- @@ -691,12 +694,185 @@ --- +### Phase 11: 최근 활동 라이브 이동 대상 분기 + +**Phase 결과:** `recentlyActiveCreators[]`가 모든 활동의 `creatorId`를 제공하고, 진행 중 라이브는 라이브 방으로, 종료된 라이브는 크리에이터 채널로 이동할 수 있는 식별자 계약을 제공한다. + +**선행조건:** Phase 10 완료와 `docs/20260529_메인_홈_추천_API/prd.md` Feature D의 2026-07-30 확정 계약. + +**Phase 완료 조건:** `P11-T1`, `P11-T2`, `P11-GATE`의 체크박스와 완료 증거가 모두 충족되고 검증 결과가 이 문서의 Verification Log에 누적된다. + +**리뷰 후속 조건(2026-07-30):** 완료된 `P11-T1`, `P11-T2`, `P11-GATE`는 되돌리지 않고, `REV-P11-001`의 신규 회귀 Goal `P11-R1`을 완료한 뒤 Phase 11 리뷰를 종료한다. + +#### Task 11.1: 최근 활동 조회 record와 LIVE target id 분기 + +**Goal 실행 `P11-T1`:** 최근 활동 조회 결과가 크리에이터 id를 항상 포함하고 라이브 활성 상태에 따라 라이브 방 id 또는 `null`을 반환한다. + +- **시작 조건:** Phase 10 완료와 PRD Feature D Response Contract 확정. +- **완료 증거:** 아래 체크박스 완료, repository focused test 통과, 내부 record와 native query row 매핑 일치. +- **범위 밖:** 공개 API DTO와 facade 매핑, 신규 활동 타입·이동 타입·상태 필드 추가. + +**Files:** + +- 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` +- Modify: `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` + +**Interfaces:** + +- Consumes: `LiveRoom.isActive`, `LiveRoom.id`, `LiveRoom.member.id`, 기존 `findRecentlyActiveCreators(limit, memberId, includeAdultActivities)`. +- Produces: + +```kotlin +data class RecentlyActiveCreatorRecord( + val creatorId: Long, + val creatorNickname: String, + val creatorProfileImage: String?, + val activityType: CreatorActivityType, + val activityAt: LocalDateTime, + val targetId: Long? +) +``` + +- [x] **RED:** `shouldFindOneLatestActivityPerCreatorWithActivityType`에서 모든 활동의 `creatorId`를 검증하고 진행 중 LIVE의 `targetId`가 `live_room.id`인지 검증한다. `shouldIncludeInactiveLiveWithChannelNameInRecentlyActiveCreators`에서는 종료된 LIVE의 `creatorId`와 `targetId = null`을 검증한다. +- [x] **RED 확인:** 아래 focused test를 실행해 `creatorId` 미구현 컴파일 실패 또는 진행 중 LIVE `targetId`의 `null` assertion 실패를 확인한다. + +```bash +./gradlew test \ + --tests kr.co.vividnext.sodalive.v2.recommendation.adapter.out.persistence.DefaultHomeRecommendationQueryRepositoryTest.shouldFindOneLatestActivityPerCreatorWithActivityType \ + --tests kr.co.vividnext.sodalive.v2.recommendation.adapter.out.persistence.DefaultHomeRecommendationQueryRepositoryTest.shouldIncludeInactiveLiveWithChannelNameInRecentlyActiveCreators +``` + +- [x] **GREEN:** `RecentlyActiveCreatorRecord`에 non-null `creatorId`를 추가한다. native SQL outer select에 `ranked.creator_id`를 포함하고 LIVE branch의 `target_id`를 아래 식으로 변경한 뒤 row index를 새 select 순서에 맞춘다. `HomeRecommendationQueryServiceTest`의 기존 record fixture에는 해당 크리에이터 id만 추가한다. + +```sql +case when lr.is_active = true then lr.id else null end as target_id +``` + +- [x] **GREEN 확인:** RED focused test를 다시 실행해 진행 중 LIVE는 `targetId = live_room.id`, 종료된 LIVE는 `targetId = null`, 모든 활동은 올바른 `creatorId`를 반환하는지 확인한다. +- [x] **REFACTOR:** 비 LIVE 활동의 `targetId`, `activityAt`, 크리에이터별 최신 활동 선정, 차단·성인·비활성 회원 제외 조건을 변경하지 않았는지 repository 테스트 클래스 전체로 회귀 확인하고 결과를 Progress에 기록한다. + +```bash +./gradlew test \ + --tests kr.co.vividnext.sodalive.v2.recommendation.adapter.out.persistence.DefaultHomeRecommendationQueryRepositoryTest \ + --tests kr.co.vividnext.sodalive.v2.recommendation.application.HomeRecommendationQueryServiceTest +``` + +#### Task 11.2: 최근 활동 공개 응답에 creatorId 노출 + +**Goal 실행 `P11-T2`:** 홈 통합 API가 내부 최근 활동 record의 `creatorId`와 상태별 `targetId`를 최종 JSON에 그대로 노출한다. + +- **시작 조건:** `P11-T1` 완료. +- **완료 증거:** 아래 체크박스 완료, 홈 통합 API 통합 테스트 통과, additive schema와 상태별 JSON 계약 확인. +- **범위 밖:** 앱 네비게이션 코드 구현, 라이브 입장 실패 처리, 기존 endpoint URL과 다른 추천 item 변경. + +**Files:** + +- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/home/dto/recommendation/HomeRecommendationResponse.kt` +- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/home/application/HomeRecommendationFacade.kt` +- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/home/HomeRecommendationControllerTest.kt` + +**Interfaces:** + +- Consumes: `P11-T1`의 `RecentlyActiveCreatorRecord.creatorId`와 상태별 nullable `targetId`. +- Produces: + +```kotlin +data class HomeActiveCreatorItem( + val creatorId: Long, + val creatorNickname: String, + val creatorProfileImage: String, + val activityType: String, + val activityAt: String, + val targetId: Long? +) +``` + +- [x] **RED:** `HomeRecommendationControllerTest.shouldExposeNavigationIdsForRecentlyActiveLiveCreators`를 추가한다. 서로 다른 크리에이터의 진행 중 LIVE와 종료된 LIVE를 저장하고 홈 통합 API 응답에서 두 item의 `creatorId`, 진행 중 LIVE의 `targetId = live_room.id`, 종료된 LIVE의 명시적 `targetId = null`을 검증한다. +- [x] **RED 확인:** 아래 focused test를 실행해 `creatorId` JSON path 미존재로 실패하는지 확인한다. + +```bash +./gradlew test \ + --tests kr.co.vividnext.sodalive.v2.api.home.HomeRecommendationControllerTest.shouldExposeNavigationIdsForRecentlyActiveLiveCreators +``` + +- [x] **GREEN:** `HomeActiveCreatorItem`에 non-null `creatorId`를 추가하고 `HomeRecommendationFacade.RecentlyActiveCreatorRecord.toItem()`에서 `creatorId = creatorId`를 매핑한다. controller 테스트의 `saveLiveRoom` fixture는 종료된 라이브를 만들 수 있도록 `isActive: Boolean = true`만 추가한다. +- [x] **GREEN 확인:** 같은 focused test를 다시 실행해 최종 JSON의 진행 중/종료 LIVE 이동 식별자 계약을 확인한다. +- [x] **REFACTOR:** `isOnAir`, `targetType`, 종료 전용 활동 타입을 추가하지 않고 기존 프로필 이미지·활동 타입·UTC 시간 변환을 유지한다. 홈 API 테스트 클래스 전체를 실행해 공개 응답 회귀를 확인하고 결과를 Progress에 기록한다. + +```bash +./gradlew test --tests kr.co.vividnext.sodalive.v2.api.home.HomeRecommendationControllerTest +``` + +#### Phase 11 Gate + +**Goal 실행 `P11-GATE`:** Phase 11의 내부 조회·공개 응답 계약과 변경 범위 품질을 최종 판정한다. + +- **시작 조건:** `P11-T1`, `P11-T2` 완료. +- **완료 증거:** 아래 focused/영향 범위 회귀, lint, 문서 검증이 모두 통과하고 실제 결과가 Verification Log에 기록됨. +- **범위 밖:** Gate 통과를 위한 테스트 삭제·완화, 전체 라이브/추천 구조 리팩터링, 신규 dependency 추가. + +```bash +./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.api.home.HomeRecommendationControllerTest +./gradlew ktlintCheck +./gradlew tasks --all +git diff --check +``` + +**Expected:** 모든 명령이 0 exit code로 통과하고, 진행 중 LIVE는 라이브 방 id, 종료된 LIVE는 `null`, 모든 최근 활동 item은 non-null 크리에이터 id를 반환하며 비 LIVE 활동 계약은 유지된다. + +- 전체 `./gradlew test`는 공통 인증·설정·다중 도메인을 변경하지 않는 국소 조회/DTO 변경이므로 기본 생략한다. focused/영향 범위 회귀에서 범위를 판단할 수 없는 실패가 발생하면 전체 테스트로 확장하고 결과를 기록한다. + +**기존 구현 실행 순서:** `P11-T1 → P11-T2 → P11-GATE` + +#### Task 11.3: 종료 LIVE targetId의 명시적 null JSON 계약 검증 보강 + +**Goal 실행 `P11-R1`:** `REV-P11-001`에 따라 종료 LIVE 응답이 `targetId` 필드를 생략하지 않고 명시적 `null`로 제공하는 계약을 회귀 테스트로 고정한다. + +- **시작 조건:** `docs/20260529_메인_홈_추천_API/reviews/phase-11-review.md`의 `REV-P11-001` 확정. +- **완료 증거:** JSON path 존재와 null 값을 각각 검증하는 focused test, 홈 API 테스트 회귀, lint·문서 검증, review/Verification Log 기록. +- **범위 밖:** production DTO·facade·query 변경, nullable 정책 변경, 신규 Jackson 전역 설정. +- **TDD 예외 사유:** 현재 production 구현은 nullable DTO와 기본 Jackson 설정으로 명시적 null을 직렬화하며, 확정 항목은 누락과 null을 구분하지 못하는 기존 assertion의 판별력 공백이다. production 동작을 변경하지 않는 테스트 보강이므로 별도 실패 구현을 만들지 않는다. + +**Files:** + +- Modify: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/home/HomeRecommendationControllerTest.kt` +- Modify: `docs/20260529_메인_홈_추천_API/plan-task.md` +- Modify: `docs/20260529_메인_홈_추천_API/reviews/phase-11-review.md` + +- [x] 종료 LIVE의 `$.data.recentlyActiveCreators[1].targetId`에 `hasJsonPath()`를 추가하고 기존 `doesNotExist()`를 함께 사용해 필드 존재와 null 값을 모두 검증한다. +- [x] 아래 focused test를 실행해 명시적 null JSON 계약이 통과하는지 확인한다. + +```bash +./gradlew test \ + --tests kr.co.vividnext.sodalive.v2.api.home.HomeRecommendationControllerTest.shouldExposeNavigationIdsForRecentlyActiveLiveCreators +``` + +- [x] 홈 API 테스트 클래스와 Phase 11 문서 검증을 실행한다. + +```bash +./gradlew test --tests kr.co.vividnext.sodalive.v2.api.home.HomeRecommendationControllerTest +./gradlew ktlintCheck +./gradlew tasks --all +git diff --check +``` + +- [x] 실제 실행 결과를 이 문서 Verification Log와 `phase-11-review.md`의 수정 후 검증 기록에 누적하고 `REV-P11-001`을 `수정 완료`로 갱신한다. + +**후속 실행 순서:** `P11-R1` + +--- + ## 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, Task 10.1에서 활동 타입 영문 enum, 최신 활동 1개, 크리에이터 프로필 이미지/닉네임, UTC 시간, 이동 대상 id nullable, `COMMUNITY` 활동의 `creator_community.id` 이동 대상 id를 검증한다. +- Feature D: Task 1.3, Task 3.1, Task 10.1, Task 11.1, Task 11.2, Task 11.3에서 활동 타입 영문 enum, 최신 활동 1개, 크리에이터 id/프로필 이미지/닉네임, UTC 시간, `COMMUNITY` 활동의 `creator_community.id`, 진행 중 LIVE의 `live_room.id`, 종료된 LIVE의 명시적 nullable 이동 대상과 크리에이터 채널 fallback 식별자를 검증한다. - 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` 노출을 검증한다. @@ -705,12 +881,17 @@ - Feature J: Task 1.1, Task 2.2, Task 2.3.1, Task 2.4, Task 2.5, Task 2.8, Task 2.9, Task 3.3, Task 5.1, Task 5.2에서 최근 응원 점수/스냅샷 조회, 스냅샷 일 배치 클러스터 단일 실행, 8명 limit, 크리에이터 프로필 이미지/닉네임 노출, `CHANNEL_DONATION` 기준 후원 금액/후원 수, 팬 Talk 수, 최근 7일 집계, 데뷔일 기준 신규 부스트, DB-side exact scoring, 해당 섹션의 동시 팔로우를 검증한다. - Feature K: Task 1.1, Task 2.2, Task 2.5, Task 2.8, Task 2.9, Task 3.3, Task 7.1에서 인기 커뮤니티 점수/조건/홈 통합 응답 노출 필드(크리에이터 프로필 이미지, 닉네임, UTC 시간, 좋아요 수, 댓글 수, 내용)/댓글 불가 게시글 댓글 수 0점 계산, 데뷔일 기준 신규 부스트, 최근 7일 집계, DB-side exact scoring을 검증한다. - Metrics: Task 7.2에서 메인 홈 API 성공률/응답 시간, 섹션별 빈 응답 비율, 전체보기 API 조회 수, 추천 섹션별 클릭률, 동시 팔로우 요청/성공 수, 콘텐츠 조회 이력 기록 성공률, 일 배치 집계 성공/실패 수와 스냅샷 생성 소요 시간의 로그 또는 metric 기록 지점을 검증한다. -- Technical Constraints/Non-Goals: Phase 1~7과 Phase 9에서 `v2.api.home`/`v2.recommendation` 패키지 경계, `port.out` 의존 방향, 신규 v2 endpoint 분리, 기존 공개 스키마 유지, 서버 다국어 번역/ML 개인화/A-B 테스트/관리자 화면/수동 편집 제외 조건을 검증한다. 응답 enum 영문 code 안정성은 Task 1.3과 Task 3.1에서, `RecommendationSnapshotPort`의 persistence entity 노출 정리는 Task 2.4에서, 점수 기반 스냅샷의 `RecommendationScoreSpec` 공유 산식과 candidate pre-limit 금지는 Task 2.9에서, JPA/QueryDSL 우선 및 native SQL 제한 사용 전략은 Task 2.9와 Task 3.1에서, native query Boolean 계산 컬럼 매핑은 Task 9.1에서, 신규 엔티티 테이블 생성 SQL 문서화는 Task 7.4에서 검증한다. +- Technical Constraints/Non-Goals: Phase 1~7, Phase 9, Phase 11에서 `v2.api.home`/`v2.recommendation` 패키지 경계, `port.out` 의존 방향, 신규 v2 endpoint 분리, additive `creatorId`, 기존 필드 유지, 서버 다국어 번역/ML 개인화/A-B 테스트/관리자 화면/수동 편집 제외 조건을 검증한다. 응답 enum 영문 code 안정성은 Task 1.3과 Task 3.1에서, `RecommendationSnapshotPort`의 persistence entity 노출 정리는 Task 2.4에서, 점수 기반 스냅샷의 `RecommendationScoreSpec` 공유 산식과 candidate pre-limit 금지는 Task 2.9에서, JPA/QueryDSL 우선 및 native SQL 제한 사용 전략은 Task 2.9와 Task 3.1에서, native query Boolean 계산 컬럼 매핑은 Task 9.1에서, 신규 엔티티 테이블 생성 SQL 문서화는 Task 7.4에서, 최근 활동의 신규 상태·이동 타입 미추가는 Task 11.2에서 검증한다. --- ## Verification Log +- 2026-07-30: Phase 11의 `P11-R1` 반영 상태를 Gradle 재실행 없이 2차 정적 리뷰했다. PRD Feature D와 native query/row mapping, `RecentlyActiveCreatorRecord`, facade, `HomeActiveCreatorItem`, repository/API 테스트를 다시 대조했고, 모든 활동의 non-null `creatorId`, 진행 중 LIVE의 `live_room.id`, 종료 LIVE의 명시적 null, 비 LIVE `targetId` 유지가 일치함을 확인했다. record·DTO 생성자와 조회 호출 지점, null 제외 설정, 변경 Kotlin 라인 길이, `git diff --check`를 점검했으며 신규 확정 발견 사항은 없었다. 기존 `REV-P11-001`은 수정 완료 상태를 유지하고 추가 회귀 Task/Goal은 만들지 않았다. 사용자 지시에 따라 컴파일과 테스트는 실행하지 않았으며 상세 결과는 `reviews/phase-11-review.md`의 2차 정적 재점검 절에 기록했다. +- 2026-07-30: P11-R1을 완료했다. `HomeRecommendationControllerTest.shouldExposeNavigationIdsForRecentlyActiveLiveCreators`의 종료 LIVE `targetId` assertion에 `hasJsonPath()`를 추가하고 기존 `doesNotExist()`를 유지해 JSON path 존재와 명시적 null 값을 함께 검증하도록 보강했다. focused test는 최초 120000ms timeout에 도달해 240000ms로 재실행했고 `BUILD SUCCESSFUL`로 통과했다. 추가 검증으로 `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.home.HomeRecommendationControllerTest`, `./gradlew ktlintCheck`, `./gradlew tasks --all`, `git diff --check`가 모두 `BUILD SUCCESSFUL` 또는 무출력 통과했다. production DTO·facade·query는 변경하지 않았고, `REV-P11-001`은 수정 완료로 갱신했다. +- 2026-07-30: Phase 11 구현을 테스트 재실행 없이 정적 리뷰했다. record/native query의 `creatorId` select와 row index, LIVE `targetId` 분기, DTO/facade 매핑은 PRD Feature D와 일치했다. `HomeRecommendationControllerTest.shouldExposeNavigationIdsForRecentlyActiveLiveCreators`의 종료 LIVE assertion이 `doesNotExist()`만 사용해 JSON path 누락과 null을 구분하지 못하는 테스트 공백 `REV-P11-001`을 Low로 확정하고, 완료된 Task와 Gate를 되돌리지 않은 채 신규 회귀 Task 11.3/Goal `P11-R1`을 추가했다. 사용자 지시에 따라 Gradle 컴파일과 테스트는 실행하지 않았으며, 상세 근거는 `docs/20260529_메인_홈_추천_API/reviews/phase-11-review.md`에 기록했다. +- 2026-07-30: Phase 11 구현을 완료했다. P11-T1 RED에서 `DefaultHomeRecommendationQueryRepositoryTest.shouldFindOneLatestActivityPerCreatorWithActivityType`, `shouldIncludeInactiveLiveWithChannelNameInRecentlyActiveCreators` focused 실행이 `RecentlyActiveCreatorRecord.creatorId` 미구현으로 `compileTestKotlin` 실패하는 것을 확인했다. GREEN에서 내부 record에 non-null `creatorId`를 추가하고 최근 활동 native SQL outer select/row mapping에 `ranked.creator_id`를 포함했으며, LIVE `target_id`를 `case when lr.is_active = true then lr.id else null end`로 변경했다. focused 재실행과 `./gradlew test --tests kr.co.vividnext.sodalive.v2.recommendation.adapter.out.persistence.DefaultHomeRecommendationQueryRepositoryTest --tests kr.co.vividnext.sodalive.v2.recommendation.application.HomeRecommendationQueryServiceTest`가 `BUILD SUCCESSFUL`로 통과했다. P11-T2 RED에서는 `HomeRecommendationControllerTest.shouldExposeNavigationIdsForRecentlyActiveLiveCreators`가 `$.data.recentlyActiveCreators[0].creatorId` `PathNotFoundException`으로 실패했고, GREEN에서 `HomeActiveCreatorItem.creatorId`와 facade 매핑을 추가해 focused test와 `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.home.HomeRecommendationControllerTest`가 `BUILD SUCCESSFUL`로 통과했다. P11-GATE로 `./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.api.home.HomeRecommendationControllerTest`, `./gradlew ktlintCheck`, `./gradlew tasks --all`, `git diff --check`가 모두 통과했다. 추가 광역 회귀로 `./gradlew test`를 실행했으나 300000ms와 600000ms timeout에 각각 도달해 완료 결과를 얻지 못했고, 동일 timeout 2회 후 추가 재시도하지 않았다. +- 2026-07-30: 사용자 승인에 따라 방금 활동한 크리에이터의 모든 item에 non-null `creatorId`를 추가하고, 진행 중 LIVE는 `targetId = live_room.id`, 종료된 LIVE는 `targetId = null`로 분기하는 요구사항을 PRD Feature D와 plan-task Phase 11에 반영했다. `P11-T1`은 내부 record/native query, `P11-T2`는 공개 DTO/facade/JSON, `P11-GATE`는 focused 회귀와 lint를 각각 소유하도록 분리했다. 별도 `isOnAir`, `targetType`, 종료 전용 활동 타입과 기존 정렬 변경은 범위에서 제외했다. 문서 자체 검토로 Phase 11의 PRD coverage, 타입·테스트명·파일 경로 일치, placeholder와 상충 문구 부재를 확인했고, `git diff --check`와 `./gradlew tasks --all`이 통과했다. 이 단계에서는 제품 코드를 변경하거나 테스트를 실행하지 않았다. - 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 파일 접근 제한으로 최초 실패해 권한 상승으로 재실행했다. diff --git a/docs/20260529_메인_홈_추천_API/prd.md b/docs/20260529_메인_홈_추천_API/prd.md index b084cb83..51532800 100644 --- a/docs/20260529_메인_홈_추천_API/prd.md +++ b/docs/20260529_메인_홈_추천_API/prd.md @@ -23,6 +23,7 @@ - 시간 응답은 UTC 기준으로 내려주고 앱에서 표시 포맷과 다국어를 처리한다. - 장르 기반 크리에이터 추천을 위해 콘텐츠 조회 이력 기록 방식을 도입한다. - 여러 크리에이터를 동시에 팔로우하는 API를 제공한다. +- 방금 활동한 크리에이터의 라이브가 진행 중이면 라이브로, 종료됐으면 해당 크리에이터 채널로 이동할 수 있는 식별자를 제공한다. --- @@ -33,6 +34,8 @@ - 추천 산식의 머신러닝 모델화, 개인화 가중치 학습, A/B 테스트 플랫폼은 이번 범위에 포함하지 않는다. - 관리자 화면 신규 개발은 포함하지 않는다. - 추천 결과 수동 편집 기능은 포함하지 않는다. +- 방금 활동한 크리에이터 응답에 별도 `isOnAir`, `targetType`, 종료 전용 활동 타입을 추가하지 않는다. +- 라이브 종료 시각을 새로 저장하거나 방금 활동한 크리에이터의 기존 활동 시간·정렬 기준을 변경하지 않는다. --- @@ -46,7 +49,7 @@ ## 6. User Stories - 사용자는 메인 홈 진입 시 라이브 중인 방송 20개를 최신순으로 보고 싶다. - 사용자는 홈 배너를 최대 20개까지 정해진 노출 순서대로 보고 싶다. -- 사용자는 방금 활동한 크리에이터와 활동 영역을 확인하고 해당 콘텐츠/커뮤니티로 이동하고 싶다. +- 사용자는 방금 활동한 크리에이터와 활동 영역을 확인하고 해당 콘텐츠/커뮤니티로 이동하며, 라이브 활동은 진행 중이면 라이브로, 종료됐으면 해당 크리에이터 채널로 이동하고 싶다. - 사용자는 최근 데뷔한 크리에이터를 추천 점수순으로 보고 전체 리스트도 확인하고 싶다. - 사용자는 신규 크리에이터가 올린 첫 번째 오디오 콘텐츠를 발견하고 전체보기로 더 탐색하고 싶다. - 사용자는 AI 캐릭터를 추천 점수순으로 보고 채팅 화면으로 이동하고 싶다. @@ -115,15 +118,36 @@ - 활동 타입 후보는 `LIVE`, `AUDIO`, `COMMUNITY`, `LIVE_REPLAY`로 한다. - 오디오는 콘텐츠를 업로드한 경우를 의미한다. - 커뮤니티는 커뮤니티 게시글을 등록한 경우를 의미한다. -- 라이브는 라이브 진행 후 종료한 경우를 의미한다. +- 라이브는 `live_room.channel_name`이 존재하고 빈 값이 아닌 진행 중 또는 종료된 라이브를 의미한다. - 라이브 다시듣기는 콘텐츠 업로드 시 `다시듣기` 테마로 올린 경우를 의미한다. -- 노출 정보는 크리에이터 프로필 이미지, 닉네임, 활동 타입, UTC 기반 활동 시간, 이동 대상 id를 포함한다. -- 라이브 활동은 별도 이동 대상 id가 필요하지 않다. +- 노출 정보는 크리에이터 id, 프로필 이미지, 닉네임, 활동 타입, UTC 기반 활동 시간, 이동 대상 id를 포함한다. +- `creatorId`는 모든 활동 item에 non-null로 제공하며, 활동을 등록한 `Member.id`를 사용한다. +- 라이브 활동은 `live_room.is_active = true`이면 `targetId`로 `live_room.id`를 내려주고, `live_room.is_active = false`이면 `targetId`를 `null`로 내려준다. - 라이브 외 활동은 오디오/라이브 다시듣기 콘텐츠 id를 내려주며, 커뮤니티 활동은 `creator_community.id`를 내려준다. +- 앱 클라이언트는 `activityType = LIVE`이면서 `targetId != null`이면 라이브로 이동하고, `targetId = null`이면 `creatorId`를 사용해 크리에이터 채널로 이동한다. - 크리에이터당 최신 활동 1개만 노출한다. +- 라이브의 활동 시간은 기존과 같이 `live_room.begin_date_time`을 사용하며, 기존 최신 활동 선정과 정렬 기준을 변경하지 않는다. #### Edge Cases - `다시듣기` 콘텐츠는 `AUDIO`가 아니라 `LIVE_REPLAY`로 분류한다. +- 응답 조회 후 라이브 입장 전에 방송이 종료되어 라이브 진입에 실패하면 앱 클라이언트는 같은 item의 `creatorId`를 사용해 크리에이터 채널로 이동할 수 있다. + +#### Response Contract + +| 활동 상태 | `activityType` | `creatorId` | `targetId` | 이동 대상 | +|---|---|---|---|---| +| 진행 중 라이브 | `LIVE` | 크리에이터 `Member.id` | `live_room.id` | 라이브 | +| 종료된 라이브 | `LIVE` | 크리에이터 `Member.id` | `null` | 크리에이터 채널 | +| 오디오 | `AUDIO` | 크리에이터 `Member.id` | `content.id` | 오디오 콘텐츠 | +| 라이브 다시듣기 | `LIVE_REPLAY` | 크리에이터 `Member.id` | `content.id` | 라이브 다시듣기 콘텐츠 | +| 커뮤니티 | `COMMUNITY` | 크리에이터 `Member.id` | `creator_community.id` | 커뮤니티 게시글 | + +#### Acceptance Criteria +- 모든 `recentlyActiveCreators[]` item은 non-null `creatorId`를 반환한다. +- 진행 중 라이브 item은 `activityType = LIVE`, `targetId = live_room.id`를 반환한다. +- 종료된 라이브 item은 `activityType = LIVE`, `targetId = null`을 반환한다. +- 오디오, 라이브 다시듣기, 커뮤니티의 `activityType`과 `targetId` 의미는 변경하지 않는다. +- `creatorId` 추가는 additive schema 변경으로 처리하고, `isOnAir`, `targetType`, 신규 활동 타입은 추가하지 않는다. ### Feature E. 최근 데뷔한 크리에이터 @@ -267,6 +291,8 @@ - Controller는 `adapter.in.web`, application service/use case는 `application`, repository/cache/scheduler 구현은 `adapter.out.*`, application이 외부 조회/저장 구현에 의존하는 계약은 `port.out`에 둔다. - `port.in`은 여러 adapter에서 같은 use case를 재사용하거나 진입 계약을 명확히 해야 할 때만 둔다. - 홈 추천 AI 캐릭터 응답의 `creatorId` 추가는 기존 `characterId` 의미를 변경하지 않는 additive schema 변경으로만 처리한다. +- 방금 활동한 크리에이터 응답의 `creatorId` 추가는 기존 필드를 제거하거나 이름을 변경하지 않는 additive schema 변경으로 처리한다. +- 방금 활동한 크리에이터의 `LIVE` `targetId`는 `live_room.is_active`에 따라 `live_room.id` 또는 `null`로 결정하고, 다른 활동 타입의 `targetId` 의미는 유지한다. - 정책, 점수 계산, 노출 조건, 스냅샷 모델처럼 인프라 의존이 없는 코드는 `domain`에 둔다. - `kr.co.vividnext.sodalive.v2` 외부 코드는 엔티티만 재활용하고, Controller/Service/Repository/DTO는 신규 작성한다. - 기존 엔티티 후보는 `Member`, `LiveRoom`, `AudioContent`, `AudioContentBanner`, `CreatorFollowing`, `CreatorCommunity`, `CreatorCommunityLike`, `CreatorCommunityComment`, `CreatorCheers`, `ChannelDonationMessage`, `AudioContentComment`, `AudioContentLike`, `ChatCharacter` 등이다. @@ -306,6 +332,7 @@ - 실제 데뷔일을 계산할 첫 공개 콘텐츠와 첫 라이브가 모두 없는 크리에이터는 Phase 2 스냅샷 후보에서 제외한다. - Phase 2 점수 기반 스냅샷은 DB-side exact scoring으로 계산한다. service는 기준 시각 계산과 snapshot replace만 담당하고, 최종 점수 산식/정렬/limit은 repository query에서 처리한다. - 조회 구현은 JPA/QueryDSL 우선, native SQL 제한 사용의 하이브리드 전략으로 진행한다. native SQL은 SQL 고급 기능이 필요한 추천/랭킹/스냅샷 산정에 한정하고, 단순 상세 조회와 대상 활성 조건은 가능하면 QueryDSL/JPA 조건으로 표현한다. +- 2026-07-30: 방금 활동한 크리에이터 item은 `creatorId`를 항상 제공한다. `LIVE`의 `targetId`는 진행 중이면 `live_room.id`, 종료됐으면 `null`로 제공하며, 종료된 라이브는 `creatorId`로 크리에이터 채널에 이동한다. 별도 `isOnAir`, `targetType`, 종료 전용 활동 타입은 추가하지 않는다. --- diff --git a/docs/20260529_메인_홈_추천_API/reviews/phase-11-review.md b/docs/20260529_메인_홈_추천_API/reviews/phase-11-review.md new file mode 100644 index 00000000..575e4d3f --- /dev/null +++ b/docs/20260529_메인_홈_추천_API/reviews/phase-11-review.md @@ -0,0 +1,193 @@ +# Phase 11 코드 리뷰 보고서 + +## 1. 리뷰 정보 + +| 항목 | 내용 | +|---|---| +| 리뷰 대상 | Phase 11 / Task 11.1~11.2 / `P11-GATE` | +| 기준 commit 또는 working tree | `b30447f0` 기준 working tree 변경 | +| 리뷰 일자 | 2026-07-30 | +| 리뷰어 | Codex | +| 기준 문서 | `docs/20260529_메인_홈_추천_API/prd.md`, `docs/20260529_메인_홈_추천_API/plan-task.md` | +| 리뷰 상태 | 수정 검증 완료 / 2차 정적 재점검 완료 | + +## 2. 리뷰 목적과 범위 + +### 목적 + +- Phase 11 구현이 PRD Feature D의 `creatorId`와 상태별 LIVE `targetId` 계약을 충족하는지 정적으로 확인한다. +- 완료된 Task·Gate 기록과 실제 코드·테스트 변경이 일치하는지 확인한다. + +### 포함 범위 + +- 코드: `RecentlyActiveCreatorRecord`, 최근 활동 native query/row mapping, `HomeActiveCreatorItem`, facade 변환 +- 테스트: 최근 활동 repository 테스트, 홈 통합 API JSON 테스트, service fixture +- 문서: PRD Feature D, plan-task Phase 11과 Verification Log +- 수동 검증: working tree diff, 호출 흐름, Spring JSON path matcher 의미 대조 + +### 제외 범위 + +- Phase 1~10과 Phase 11 외 기능 +- 앱 클라이언트 네비게이션 및 라이브 입장 실패 fallback 구현 +- 사용자 지시에 따른 Gradle 컴파일·테스트 재실행 + +## 3. 판정 기준 + +| 심각도 | 기준 | +|---|---| +| Blocker | 핵심 흐름 불능, 보안·데이터 손실 또는 완료 판정을 무효화하는 문제 | +| High | 확정 요구사항·공개 API 계약 위반 또는 주요 회귀 | +| Medium | 제한된 조건의 기능·복구 문제 | +| Low | 테스트 판별력, 유지보수성 또는 문서 정합성 문제 | + +| 상태 | 의미 | +|---|---| +| 후보 | 근거 확인 전 | +| 확정 | 코드·테스트·문서 근거로 확인됨 | +| 오탐 | 요구사항 또는 코드 근거상 문제 아님 | +| 보류 | 외부 결정·환경 필요 | +| 수정 완료 | 수정과 검증 완료 | + +## 4. 검토한 근거 + +### 문서와 코드 + +- 요구사항: PRD Feature D Requirements, Response Contract, Acceptance Criteria +- 계획: `P11-T1`, `P11-T2`, `P11-GATE` +- 내부 조회: `DefaultHomeRecommendationQueryRepository.kt:130-208` +- 내부 record: `HomeRecommendationQueryPort.kt:105-112` +- 공개 응답: `HomeRecommendationResponse.kt:38-45` +- facade: `HomeRecommendationFacade.kt:247-254` +- repository 테스트: `DefaultHomeRecommendationQueryRepositoryTest.kt:405-467` +- API 테스트: `HomeRecommendationControllerTest.kt:527-545` + +### 실행 환경 + +```text +검토 방식: working tree 정적 리뷰 +기준 commit: b30447f0 +컴파일·테스트: 사용자 지시에 따라 실행하지 않음 +민감정보: 조회·기록하지 않음 +``` + +### 실행한 검증 + +| 명령 또는 수동 검증 | 결과 | 핵심 증거 | +|---|---|---| +| `git status --short`, `git diff --name-status` | 성공 | Phase 11 계획의 코드·테스트 7개 파일과 문서 변경 확인 | +| Phase 11 관련 `git diff` 및 호출 흐름 대조 | 성공 | query select/row index, record/DTO/facade 필드가 계약과 일치 | +| Spring 5.3.29 `JsonPathResultMatchers` 로컬 source jar 확인 | 성공 | `doesNotExist()`가 누락 path와 null 값 모두 허용함을 확인 | +| Jackson null 제외 설정 검색 | 성공 | 대상 DTO와 전역 설정에 `NON_NULL` 적용이 없음을 확인 | +| Gradle 컴파일·테스트 | 미실행 | 사용자가 현재 통과 상태를 제공하고 재실행을 금지함 | + +## 5. 발견 사항 요약 + +| ID | 심각도 | 상태 | 제목 | 소유 Task | 후속 goal | +|---|---|---|---|---|---| +| `REV-P11-001` | Low | 수정 완료 | 종료 LIVE 테스트가 targetId 누락과 null을 구분하지 못함 | Task 11.3 | `P11-R1` | + +구현 코드에서 확정된 기능 결함은 발견하지 않았다. + +## 6. 발견 사항 상세 + +### REV-P11-001 — 종료 LIVE 테스트가 targetId 누락과 null을 구분하지 못함 + +- **심각도:** Low +- **상태:** 수정 완료 +- **관련 요구사항:** PRD Feature D Acceptance Criteria +- **관련 계약:** 종료된 LIVE는 `targetId = null` +- **소유 Task:** Task 11.3 / `P11-R1` + +**관찰 내용** + +`HomeRecommendationControllerTest.shouldExposeNavigationIdsForRecentlyActiveLiveCreators`는 종료 LIVE의 `targetId`를 `doesNotExist()`로 검증한다. Spring 5.3.29에서 이 matcher는 JSON path가 없을 때와 값이 null일 때 모두 통과하므로, 필드를 생략하는 회귀를 탐지하지 못한다. + +**근거** + +- 코드: `HomeRecommendationControllerTest.kt:545` +- 문서: PRD Feature D의 Response Contract와 Acceptance Criteria +- 라이브러리: Spring Test 5.3.29 `JsonPathResultMatchers.doesNotExist()` 및 `JsonPathExpectationsHelper.doesNotExist(...)` + +**재현 또는 검증 절차** + +1. 종료 LIVE JSON assertion이 `doesNotExist()`만 사용하는지 확인했다. +2. 로컬 Spring Test 5.3.29 source jar에서 `doesNotExist()` 구현을 확인했다. +3. 해당 구현은 path 평가 실패를 정상 반환하고, path가 있으면 값이 null일 때 성공한다. +4. 따라서 현재 assertion은 `targetId` 누락과 명시적 null을 구분하지 않는다. + +**영향** + +현재 production 구현은 nullable DTO를 사용하고 null 제외 설정이 없어 계약과 일치하는 구조다. 그러나 이후 Jackson null 제외 설정이나 DTO annotation이 추가되어 `targetId`가 생략돼도 이 테스트는 통과하므로 공개 응답 계약 회귀를 차단하지 못한다. + +**권장 조치** + +같은 JSON path에 `hasJsonPath()`를 추가하고 기존 `doesNotExist()`와 함께 검증한다. production 코드는 변경하지 않는다. + +**판정 기록** + +- 2026-07-30 — Spring 5.3.29 matcher 구현과 PRD 계약을 대조해 Low 확정. +- 2026-07-30 — `hasJsonPath()`와 `doesNotExist()` 조합으로 JSON path 존재와 null 값을 모두 검증하도록 수정하고 focused/API 회귀/lint/문서 검증을 완료했다. + +## 7. 확정 항목의 plan·goal 전환 + +### 신규 회귀 수정 Task + +- Task 11.3: 종료 LIVE `targetId`의 명시적 null JSON 계약 검증 보강 +- Goal: `P11-R1` +- 변경 범위: `HomeRecommendationControllerTest`, plan-task, 이 review 문서 +- production 코드 변경: 없음 + +### create_goal objective 초안 + +```text +[P11-R1]의 확정 review 항목 REV-P11-001을 수정하고 회귀를 방지한다. +plan-task.md의 Task 11.3만 수행한다. +종료 LIVE targetId의 JSON path 존재와 null 값을 함께 검증하고 focused test, 홈 API 회귀, lint, 문서 검증과 기록이 모두 끝나기 전에는 complete로 표시하지 않는다. +production DTO·facade·query 변경과 관련 없는 리팩터링은 범위 밖이다. +``` + +## 8. 리뷰 종료 판정 + +| 판정 항목 | 결과 | 근거 | +|---|---|---| +| 리뷰 범위 전체 확인 | 충족 | Phase 11 문서·코드·테스트 diff 대조 | +| 후보 항목 판정 완료 | 충족 | `REV-P11-001` Low 확정 | +| 확정 항목 plan 반영 | 충족 | Task 11.3 / `P11-R1` 추가 | +| 보류 항목의 담당·재개 조건 기록 | 해당 없음 | 보류 항목 없음 | +| 검증 명령과 결과 기록 | 충족 | 정적 검증 기록, Gradle 미실행 사유 명시 | + +**최종 결론:** 수정 완료 + +**남은 항목:** 없음 + +## 9. 수정 후 검증 기록 + +- 2026-07-30: `HomeRecommendationControllerTest.shouldExposeNavigationIdsForRecentlyActiveLiveCreators`에 종료 LIVE `targetId` `hasJsonPath()` assertion을 추가해 명시적 null JSON 계약을 고정했다. focused test는 120000ms timeout 후 240000ms로 재실행해 `BUILD SUCCESSFUL`로 통과했다. 이어서 `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.home.HomeRecommendationControllerTest`, `./gradlew ktlintCheck`, `./gradlew tasks --all`, `git diff --check`를 실행했고 모두 `BUILD SUCCESSFUL` 또는 무출력 통과했다. production 코드는 변경하지 않았다. + +## 10. 2차 정적 재점검 — 2026-07-30 + +### 범위와 방법 + +- 기준: `b30447f0` 기준 현재 working tree와 `P11-R1` 반영 상태 +- 문서: PRD Feature D, plan-task Phase 11·PRD Coverage Check·Verification Log +- 코드 흐름: native query/row mapping → `RecentlyActiveCreatorRecord` → facade → `HomeActiveCreatorItem` +- 테스트: repository의 전체 활동 타입·LIVE 상태 분기와 홈 API JSON 계약 assertion +- 제외: 사용자 지시에 따라 Gradle 컴파일·테스트 재실행 + +### 정적 검증 결과 + +| 검증 | 결과 | 근거 | +|---|---|---| +| `git diff`로 Phase 11 변경 전체 대조 | 충족 | `creatorId` select/row index/DTO/facade 매핑과 LIVE `targetId` 분기가 PRD 계약과 일치 | +| `rg`로 record·DTO 생성자와 조회 호출 지점 확인 | 충족 | 변경 타입의 모든 생성·매핑 지점이 신규 non-null `creatorId`를 반영 | +| nullable JSON 설정과 assertion 대조 | 충족 | 대상 DTO·전역 설정에 null 제외가 없고 `hasJsonPath()` + `doesNotExist()`가 명시적 null 계약을 고정 | +| 비 LIVE 회귀와 LIVE 상태 분기 테스트 대조 | 충족 | AUDIO·LIVE_REPLAY·COMMUNITY의 기존 `targetId`, 진행 중/종료 LIVE, 모든 활동의 `creatorId`를 검증 | +| 변경 Kotlin 라인 길이와 `git diff --check` | 충족 | 130자 초과 신규 Kotlin 라인 없음, whitespace 오류 없음 | +| 과설계·범위 확장 점검 | 충족 | 신규 dependency·상태 타입·추상화 없이 기존 record/DTO/query만 최소 변경 | + +### 발견 사항과 종료 판정 + +- 신규 후보·확정·보류 항목 없음. +- 기존 `REV-P11-001`은 Task 11.3 / `P11-R1`에서 수정 완료 상태를 유지한다. +- 추가로 `plan-task.md`에 전환할 회귀 수정 Task/Goal 없음. +- **최종 결론:** Phase 11 2차 정적 리뷰 완료, 확정 발견 사항 없음. 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 a462f09b..dbe708c4 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 @@ -245,6 +245,7 @@ class HomeRecommendationFacade( } private fun RecentlyActiveCreatorRecord.toItem() = HomeActiveCreatorItem( + creatorId = creatorId, creatorNickname = creatorNickname, creatorProfileImage = profileImageUrl(cloudFrontHost, creatorProfileImage), activityType = activityType.name, diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/home/dto/recommendation/HomeRecommendationResponse.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/home/dto/recommendation/HomeRecommendationResponse.kt index 3f90003d..cc19db1a 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/home/dto/recommendation/HomeRecommendationResponse.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/home/dto/recommendation/HomeRecommendationResponse.kt @@ -36,6 +36,7 @@ data class HomeLiveItem( ) data class HomeActiveCreatorItem( + val creatorId: Long, val creatorNickname: String, val creatorProfileImage: String, val activityType: String, 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 829560ef..31b00b67 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 @@ -127,7 +127,8 @@ class DefaultHomeRecommendationQueryRepository( includeAdultActivities: Boolean ): List { val sql = """ - select ranked.creator_nickname, + select ranked.creator_id, + ranked.creator_nickname, ranked.creator_profile_image, ranked.activity_type, ranked.activity_at, @@ -144,7 +145,7 @@ class DefaultHomeRecommendationQueryRepository( m.profile_image as creator_profile_image, 'LIVE' as activity_type, lr.begin_date_time as activity_at, - null as target_id, + case when lr.is_active = true then lr.id else null end as target_id, lr.id as target_sort_id from live_room lr join member m on m.id = lr.member_id @@ -199,11 +200,12 @@ class DefaultHomeRecommendationQueryRepository( return rows.map { row -> RecentlyActiveCreatorRecord( - creatorNickname = row[0] as String, - creatorProfileImage = row[1] as String?, - activityType = CreatorActivityType.valueOf(row[2] as String), - activityAt = toLocalDateTime(row[3]), - targetId = (row[4] as Number?)?.toLong() + creatorId = (row[0] as Number).toLong(), + creatorNickname = row[1] as String, + creatorProfileImage = row[2] as String?, + activityType = CreatorActivityType.valueOf(row[3] as String), + activityAt = toLocalDateTime(row[4]), + targetId = (row[5] as Number?)?.toLong() ) } } 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 30e0300e..5a45bc0b 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 @@ -103,6 +103,7 @@ data class HomeBannerRecommendationRecord( ) data class RecentlyActiveCreatorRecord( + val creatorId: Long, val creatorNickname: String, val creatorProfileImage: String?, val activityType: CreatorActivityType, diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/home/HomeRecommendationControllerTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/home/HomeRecommendationControllerTest.kt index e13d1ed3..7dfc1864 100644 --- a/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/home/HomeRecommendationControllerTest.kt +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/home/HomeRecommendationControllerTest.kt @@ -524,6 +524,28 @@ class HomeRecommendationControllerTest @Autowired constructor( .andExpect(jsonPath("$.data.items[0].creatorId").value(character.creatorMember!!.id)) } + @Test + @DisplayName("최근 활동 라이브 크리에이터는 creatorId와 라이브 상태별 targetId를 노출한다") + fun shouldExposeNavigationIdsForRecentlyActiveLiveCreators() { + val activeCreator = saveMember("active-live-activity", MemberRole.CREATOR) + val inactiveCreator = saveMember("inactive-live-activity", MemberRole.CREATOR) + val baseAt = LocalDateTime.of(2026, 5, 31, 10, 0) + val activeLive = saveLiveRoom(activeCreator, baseAt.plusMinutes(2), "active-live-channel", isAdult = false) + saveLiveRoom(inactiveCreator, baseAt.plusMinutes(1), "inactive-live-channel", isAdult = false, isActive = false) + entityManager.flush() + entityManager.clear() + + mockMvc.perform(get("/api/v2/home/recommendations")) + .andExpect(status().isOk) + .andExpect(jsonPath("$.data.recentlyActiveCreators[0].creatorId").value(activeCreator.id)) + .andExpect(jsonPath("$.data.recentlyActiveCreators[0].activityType").value("LIVE")) + .andExpect(jsonPath("$.data.recentlyActiveCreators[0].targetId").value(activeLive.id)) + .andExpect(jsonPath("$.data.recentlyActiveCreators[1].creatorId").value(inactiveCreator.id)) + .andExpect(jsonPath("$.data.recentlyActiveCreators[1].activityType").value("LIVE")) + .andExpect(jsonPath("$.data.recentlyActiveCreators[1].targetId").hasJsonPath()) + .andExpect(jsonPath("$.data.recentlyActiveCreators[1].targetId").doesNotExist()) + } + private fun saveMember(seed: String, role: MemberRole): Member { return memberRepository.saveAndFlush( Member( @@ -548,7 +570,8 @@ class HomeRecommendationControllerTest @Autowired constructor( creator: Member, beginDateTime: LocalDateTime, channelName: String, - isAdult: Boolean + isAdult: Boolean, + isActive: Boolean = true ): LiveRoom { val room = LiveRoom( title = "live-${creator.nickname}-$channelName", @@ -559,6 +582,7 @@ class HomeRecommendationControllerTest @Autowired constructor( ) room.member = creator room.channelName = channelName + room.isActive = isActive entityManager.persist(room) return room } 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 ef606ecd..175dacd2 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 @@ -411,7 +411,7 @@ class DefaultHomeRecommendationQueryRepositoryTest @Autowired constructor( val replayCreator = saveMember("activity-replay", MemberRole.CREATOR) val communityCreator = saveMember("activity-community", MemberRole.CREATOR) saveAudioContent(liveCreator, baseAt.minusDays(2), isActive = true) - saveLiveRoom(liveCreator, baseAt, channelName = "activity-live-channel") + val live = saveLiveRoom(liveCreator, baseAt, channelName = "activity-live-channel") val audio = saveAudioContent(audioCreator, baseAt.minusHours(1), isActive = true) val replay = saveAudioContent(replayCreator, baseAt.minusHours(2), isActive = true, themeName = "다시듣기") val community = saveCommunity(communityCreator, isCommentAvailable = true) @@ -426,8 +426,12 @@ class DefaultHomeRecommendationQueryRepositoryTest @Autowired constructor( listOf(liveCreator.nickname, audioCreator.nickname, replayCreator.nickname, communityCreator.nickname), creators.map { it.creatorNickname } ) + assertEquals(liveCreator.id, byCreatorNickname[liveCreator.nickname]!!.creatorId) + assertEquals(audioCreator.id, byCreatorNickname[audioCreator.nickname]!!.creatorId) + assertEquals(replayCreator.id, byCreatorNickname[replayCreator.nickname]!!.creatorId) + assertEquals(communityCreator.id, byCreatorNickname[communityCreator.nickname]!!.creatorId) assertEquals(CreatorActivityType.LIVE, byCreatorNickname[liveCreator.nickname]!!.activityType) - assertEquals(null, byCreatorNickname[liveCreator.nickname]!!.targetId) + assertEquals(live.id, byCreatorNickname[liveCreator.nickname]!!.targetId) assertEquals(baseAt, byCreatorNickname[liveCreator.nickname]!!.activityAt) assertEquals(CreatorActivityType.AUDIO, byCreatorNickname[audioCreator.nickname]!!.activityType) assertEquals(audio.id, byCreatorNickname[audioCreator.nickname]!!.targetId) @@ -457,6 +461,7 @@ class DefaultHomeRecommendationQueryRepositoryTest @Autowired constructor( val creators = repository.findRecentlyActiveCreators(limit = 10) assertEquals(listOf(inactiveLiveCreator.nickname), creators.map { it.creatorNickname }) + assertEquals(inactiveLiveCreator.id, creators.single().creatorId) assertEquals(CreatorActivityType.LIVE, creators.single().activityType) assertEquals(baseAt.plusMinutes(2), creators.single().activityAt) assertEquals(null, creators.single().targetId) @@ -470,8 +475,8 @@ class DefaultHomeRecommendationQueryRepositoryTest @Autowired constructor( val adultLiveCreator = saveMember("activity-adult-live", MemberRole.CREATOR) val adultAudioCreator = saveMember("activity-adult-audio", MemberRole.CREATOR) val adultCommunityCreator = saveMember("activity-adult-community", MemberRole.CREATOR) - saveLiveRoom(normalLiveCreator, baseAt.plusMinutes(3), channelName = "normal-live", isAdult = false) - saveLiveRoom(adultLiveCreator, baseAt.plusMinutes(2), channelName = "adult-live", isAdult = true) + val normalLive = saveLiveRoom(normalLiveCreator, baseAt.plusMinutes(3), channelName = "normal-live", isAdult = false) + val adultLive = saveLiveRoom(adultLiveCreator, baseAt.plusMinutes(2), channelName = "adult-live", isAdult = true) val adultAudio = saveAudioContent(adultAudioCreator, baseAt.plusMinutes(1), isActive = true, isAdult = true) val adultCommunity = saveCommunity(adultCommunityCreator, isCommentAvailable = true, isAdult = true) updateCreatedAt("CreatorCommunity", adultCommunity.id!!, baseAt) @@ -490,8 +495,8 @@ class DefaultHomeRecommendationQueryRepositoryTest @Autowired constructor( ), visibleCreators.map { it.creatorNickname } ) - assertEquals(null, visibleCreators[0].targetId) - assertEquals(null, visibleCreators[1].targetId) + assertEquals(normalLive.id, visibleCreators[0].targetId) + assertEquals(adultLive.id, visibleCreators[1].targetId) assertEquals(adultAudio.id, visibleCreators[2].targetId) assertEquals(adultCommunity.id, visibleCreators[3].targetId) assertEquals(CreatorActivityType.LIVE, visibleCreators[0].activityType) 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 a6efc425..2da88b6e 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 @@ -1012,6 +1012,7 @@ class HomeRecommendationQueryServiceTest { ) val activeCreators = listOf( RecentlyActiveCreatorRecord( + creatorId = 301L, creatorNickname = "creator", creatorProfileImage = "profile.png", activityType = CreatorActivityType.LIVE,