Compare commits

...

3 Commits

51 changed files with 4412 additions and 90 deletions

View File

@@ -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 파일 접근 제한으로 최초 실패해 권한 상승으로 재실행했다.

View File

@@ -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`, 종료 전용 활동 타입은 추가하지 않는다.
---

View File

@@ -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차 정적 리뷰 완료, 확정 발견 사항 없음.

View File

@@ -21,10 +21,30 @@
- 스케줄 성인 노출 정책: repository query에서 조회자의 성인 노출 정책을 먼저 반영하고, service 최종 조합에서도 내부 스케줄 후보의 `isAdult`로 한 번 더 보정한다. 공개 스케줄 응답에는 `isAdult`를 노출하지 않는다.
- 현재 라이브와 예약 라이브 스케줄은 기존 라이브 목록과 동일하게 성별 제한(`LiveRoom.genderRestriction`)과 크리에이터 입장 제한(`LiveRoom.isAvailableJoinCreator`)을 반영한다. application service는 조회자의 `Auth.gender`가 있으면 이를 우선하고, 없으면 `Member.gender`를 사용하는 `effectiveViewerGender`를 산출해 query port에 넘긴다.
- 신규 오디오 콘텐츠와 오디오 목록은 중복 노출하지 않는다. `latestAudioContent`로 내려간 가장 최신 콘텐츠를 오디오 목록에서 제외한다.
- `latestAudioContent`는 상단 고정 여부와 관계없이 공개 시각 최신순을 유지한다.
- `audioContents`는 활성 `PinContent``updatedAt desc`로 먼저 배치하고, 나머지는 `AudioContent.releaseDate desc`, `AudioContent.id desc`로 배치한 뒤 최대 9개를 내려준다.
- 크리에이터별 활성 오디오 상단 고정 한도는 9개다. 10번째 고정은 기존처럼 `PinContent.updatedAt`이 가장 오래된 활성 고정을 교체한다.
- 채널 후원 홈 섹션은 기존 채널 후원 목록과 동일하게 이번 달 기준 최신순 8개를 내려준다. 응답 메시지는 기본 문구를 조합하지 않고 후원자가 입력한 추가 메시지만 내려준다.
- 오리지널 시리즈 여부는 `Series.isOriginal == true`로 판단한다.
- 화보와 상단 탭별 전체보기 API는 이번 범위에서 제외한다.
### 0.1 2026-07-30 후속 변경 상태
| Phase | 상태 | 완료 Task | 활성/다음 Goal | 차단 또는 남은 조건 |
|---:|---|---:|---|---|
| 1~2 | 완료 | `3/3` | 없음 | 기존 검증 기록 유지 |
| 3 | 완료 | `17/17` | 없음 | 없음 |
| 4 | 완료 | `5/5` | 없음 | 없음 |
| 5 | 완료 | `3/3` | 없음 | 없음 |
| 6 | 완료 | `4/4` | 없음 | 없음 |
| 7 | 완료 | `5/5` | 없음 | 없음 |
- 동시에 하나의 미완료 Goal만 진행한다.
- 완료된 Task 체크박스와 기존 검증 기록은 되돌리지 않는다.
- 2026-07-30 1차 정적 리뷰 후속 실행 순서 `P7-R1``P4-R1``P5-R1``P6-R1` 완료.
- 2026-07-30 2차 정적 리뷰 후속 실행 순서 `P3-R1``P4-R2``P4-R3``P7-R2` 완료.
- 2026-07-31 3차 정적 리뷰 후속 실행 순서 `P7-R3` 완료.
---
## 1. 파일 구조 계획
@@ -54,6 +74,7 @@
- Create: `src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/adapter/in/web/CreatorChannelHomeControllerTest.kt`
### 문서 산출물
- Modify: `docs/20260612_크리에이터_채널_홈_API/prd.md`
- Modify: `docs/20260612_크리에이터_채널_홈_API/plan-task.md`
---
@@ -515,6 +536,20 @@ data class CreatorChannelSnsResponse(
- REFACTOR: 좋아요/댓글/구매 여부 조회를 `leftJoin` 하나로 합치지 않고, 현재의 id 목록 기반 bulk 조회 구조를 유지한다.
- 기대 결과: 구매자는 삭제된 유료 게시글도 기존 전체보기 의미와 동일하게 접근할 수 있고, 비구매자는 삭제된 게시글을 조회하지 못한다.
- [x] **Task 3.17: 홈 채널 후원자의 삭제 닉네임 prefix 제거**
**Goal 실행 `P3-R1`:** 홈 채널 후원도 기존 채널 후원 목록과 동일하게 삭제 회원 닉네임의 `deleted_` prefix를 공개 응답에서 제거한다.
- **시작 조건:** `REV-P3-001` 확정, Task 3.1~3.16 완료.
- **완료 증거:** 삭제 회원 후원자 RED/GREEN, service focused test, 기존 후원 조회 회귀, 검증 기록 누적.
- **범위 밖:** 비밀 후원 노출 정책, 후원 메시지, projection 컬럼, 공개 응답 스키마 변경.
- Files:
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/home/application/CreatorChannelHomeQueryService.kt`
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/home/application/CreatorChannelHomeQueryServiceTest.kt`
- [x] **RED:** `nickname = "deleted_donor"`인 후원 record가 홈 domain에서 `nickname = "donor"`로 조립되는 테스트를 추가한다.
- [x] **GREEN:** 기존 채널 후원 목록과 전용 v2 후원 탭이 사용하는 `removeDeletedNicknamePrefix()`를 홈 후원 domain 변환 경계에도 적용한다.
- [x] **REFACTOR/GATE:** 닉네임 변환 외 repository 조회·후원 메시지·JSON 계약은 변경하지 않고 후원 관련 focused test 결과를 누적한다.
---
### Phase 4: application service 조립
@@ -546,6 +581,50 @@ data class CreatorChannelSnsResponse(
- REFACTOR: 차단 예외 메시지 조합에 `SodaMessageSource`가 필요하면 기존 `ExplorerService.getCreatorDetail` 패턴을 따른다.
- 기대 결과: 신규 API 접근 정책이 구버전 채널 정책과 맞는다.
- [x] **Task 4.3: 조회자 콘텐츠 선호 조회를 1회로 통합**
**Goal 실행 `P4-R1`:** 홈 조회에서 한 번 가져온 `ViewerContentPreference`로 성인 노출 여부와 콘텐츠 타입을 모두 결정한다.
- **시작 조건:** `REV-P4-001` 확정, Task 4.1~4.2 완료.
- **완료 증거:** service RED/GREEN, focused test, Phase 4 직접 영향 회귀, 검증 기록 누적.
- **범위 밖:** 선호 기본값·국가별 성인 판정·쿼리 필터 정책 변경.
- Files:
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/home/application/CreatorChannelHomeQueryService.kt`
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/home/application/CreatorChannelHomeQueryServiceTest.kt`
- [x] **RED:** `getStoredPreference(viewer)`가 반환한 `isAdult`가 모든 성인 필터 인자에 전달되고, service가 `canViewAdultContent(viewer)`를 별도 호출하지 않는 것을 검증한다.
- [x] **GREEN:** 이미 조회한 `preference.isAdult``canViewAdultContent`로 사용해 중복 `REQUIRES_NEW` 조회를 제거한다.
- [x] **REFACTOR/GATE:** 서비스 조립·성인 필터·`contentType` 회귀를 확인하고 `plan-task.md` 검증 기록에 결과를 누적한다.
- [x] **Task 4.4: 라이브 크리에이터 입장 제한에 조회자 role 전달**
**Goal 실행 `P4-R2`:** 조회 대상과의 동일인 여부가 아니라 조회자의 `MemberRole.CREATOR` 여부로 현재/예약 라이브의 크리에이터 입장 제한을 적용한다.
- **시작 조건:** `REV-P4-002` 확정, `P3-R1` 완료.
- **완료 증거:** 다른 크리에이터 조회 RED/GREEN, 현재 라이브·예약 스케줄 service 회귀, 검증 기록 누적.
- **범위 밖:** 성별·성인 필터, 자기 라이브 예외, query port의 공개 API 스키마 변경.
- Files:
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/home/application/CreatorChannelHomeQueryService.kt`
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/home/application/CreatorChannelHomeQueryServiceTest.kt`
- [x] **RED:** `viewer.role == CREATOR`이고 `viewer.id != creatorId`인 경우 `findCurrentLive``findSchedules``isViewerCreator = true`가 전달되는 테스트를 추가한다. 일반 회원은 `false`, 대상 본인인 크리에이터는 `true`인 기존 의미도 함께 고정한다.
- [x] **GREEN:** `isViewerCreator``viewer.role == MemberRole.CREATOR`로 계산해 기존 라이브 목록의 `isAvailableJoinCreator` 정책과 정렬한다.
- [x] **REFACTOR/GATE:** repository의 자기 라이브 예외 조건은 유지하고 service focused test 결과를 누적한다.
- [x] **Task 4.5: 홈 조회 기본 시각을 UTC로 고정**
**Goal 실행 `P4-R3`:** JVM 기본 timezone과 무관하게 홈 조회의 공개/예약 경계와 KST 월 범위 계산에 UTC `LocalDateTime`을 전달한다.
- **시작 조건:** `REV-P4-003` 확정, `P4-R2` 완료.
- **완료 증거:** 비 UTC JVM timezone RED/GREEN, facade→service `now` 전달 회귀, 검증 기록 누적.
- **범위 밖:** DB 컬럼 타입, API 시간 문자열 형식, 클라이언트 timezone 파라미터 추가.
- Files:
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/home/application/CreatorChannelHomeFacade.kt`
- Modify if needed: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/home/application/CreatorChannelHomeQueryService.kt`
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/home/application/CreatorChannelHomeQueryServiceTest.kt`
- Test if needed: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/home/CreatorChannelHomeEndToEndTest.kt`
- [x] **RED:** JVM 기본 timezone을 `Asia/Seoul`로 바꿔도 인자 없는 홈 조회가 UTC 기준 `now`를 service/query port에 전달하는 테스트를 추가하고, 테스트 종료 시 원 timezone을 복구한다.
- [x] **GREEN:** 기본 `now` 생성 지점을 `LocalDateTime.now(ZoneOffset.UTC)` 또는 같은 의미의 UTC clock으로 고정한다. 명시적으로 전달된 `now`는 그대로 사용한다.
- [x] **REFACTOR/GATE:** 별도 시간 추상화가 필요하지 않으면 추가하지 않고, 공개/예약 비교와 KST 월 경계의 직접 영향 회귀만 확인한다.
---
### Phase 5: web API와 응답 계약
@@ -588,6 +667,21 @@ data class CreatorChannelSnsResponse(
- REFACTOR: nullable 섹션은 단건이면 `null`, 목록이면 빈 배열로 일관되게 내려준다.
- 기대 결과: 클라이언트가 사용할 JSON 스키마가 테스트로 고정된다.
- [x] **Task 5.3: 빈 홈 응답의 null/빈 배열 JSON 계약 고정**
**Goal 실행 `P5-R1`:** 데이터가 없는 홈 응답에서 단건 섹션은 `null`, 목록 섹션은 빈 배열로 직렬화되는 공개 계약을 회귀 테스트로 고정한다.
- **시작 조건:** `REV-P5-001` 확정, `P4-R1` 완료.
- **완료 증거:** MockMvc RED/GREEN, controller focused test, 검증 기록 누적.
- **범위 밖:** 공개 필드 이름·endpoint·`ApiResponse` 구조 변경.
- Files:
- Modify if needed: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/home/dto/CreatorChannelHomeResponse.kt`
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/home/adapter/in/web/CreatorChannelHomeControllerTest.kt`
- **TDD 예외 사유:** 확정 항목은 실행 결함이 아니라 빈 응답 회귀 증거 부재이므로, 신규 계약 테스트가 첫 실행에서 바로 통과할 수 있다.
- [x] **계약 검증:** `currentLive`, `latestAudioContent`, `fanTalk.latestFanTalk`이 명시적 `null`이고, `channelDonations`, `notices`, `schedules`, `audioContents`, `series`, `communities`가 빈 배열인 fixture를 응답해 각 JSON 경로의 존재와 값을 검증한다.
- [x] **GREEN:** 최초 계약 검증이 실제 직렬화 문제를 드러낼 때만 DTO mapping/annotation을 최소 수정한다.
- [x] **REFACTOR/GATE:** 채워진 응답 계약 테스트와 빈 응답 계약 테스트를 같이 실행하고 결과를 누적한다.
---
### Phase 6: 통합 회귀와 문서 갱신
@@ -636,6 +730,141 @@ data class CreatorChannelSnsResponse(
- REFACTOR: 실패한 검증이 있으면 해당 phase/task로 돌아가 plan-task 체크박스를 완료 처리하지 않는다.
- 기대 결과: 구현 완료 시 어떤 검증으로 완료 판단했는지 문서에 남는다.
- [x] **Task 6.4: 단일 HTTP 요청 기준 홈 전체 조립 통합 회귀 보강**
**Goal 실행 `P6-R1`:** 실제 controller→facade→service→repository/공용 서비스 경로가 한 번의 홈 API 요청에서 전체 섹션을 조립하는지 검증한다.
- **시작 조건:** `REV-P6-001` 확정, `P5-R1` 완료.
- **완료 증거:** 통합 시나리오 검증, 실제 bean 경로의 전체 섹션 JSON assertion, Phase 6 직접 영향 회귀, 검증 기록 누적.
- **범위 밖:** 홈 응답 스키마·조회 정책 변경, 테스트 편의용 운영 API 추가.
- Files:
- Create: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/home/CreatorChannelHomeEndToEndTest.kt`
- **TDD 예외 사유:** 확정 항목은 계층 연결의 실행 결함이 아니라 단일 요청 통합 증거 부재이므로, 신규 E2E가 첫 실행에서 바로 통과할 수 있다.
- [x] **통합 검증:** 현재 리포지토리 테스트의 각 조회 호출과 mock facade controller 테스트로는 증명되지 않는 단일 요청 시나리오를 작성한다. 최소한 creator/current live/latest audio/donation/notice/schedule/audio list/series/community/fan Talk/activity/SNS를 한 fixture에 구성하고 실제 bean과 JSON을 검증한다.
- [x] **GREEN:** 통합 테스트가 드러낸 mapping·bean wiring·쿼리 누락만 최소 수정한다. 최초 통합 검증이 바로 통과하면 생산 코드는 변경하지 않는다.
- [x] **REFACTOR/GATE:** fixture helper는 해당 테스트 범위에만 두고, 새 E2E와 기존 service/repository/controller focused test를 함께 실행해 결과를 누적한다.
---
### Phase 7: 오디오 상단 고정 한도와 홈 목록 정렬 보정
**Phase 결과:** 크리에이터가 오디오 콘텐츠를 최대 9개까지 상단 고정할 수 있고, 홈 `audioContents`에서 고정 콘텐츠가 최근 고정순으로 먼저 노출된다.
**선행조건:** PRD Feature D/H와 `DEC-001` 확정.
**Phase 완료 조건:** 기존 `P7-T1`, `P7-T2`, `P7-GATE`, `P7-R1`, `P7-R2` 완료 이력을 유지하고, 3차 리뷰 후속 `P7-R3`의 실제 검증 결과까지 누적.
- [x] **Task 7.1: 오디오 상단 고정 최대 개수를 9개로 보정**
**Goal 실행 `P7-T1`:** 상세 응답의 고정 가능 여부와 상단 고정 등록이 같은 9개 상한을 사용하도록 고정한다.
- **시작 조건:** PRD Feature H의 고정 상한과 초과 교체 정책 확정.
- **완료 증거:** RED/GREEN/REFACTOR 체크박스 완료, `AudioContentServiceTest` focused test 통과, 검증 기록 누적.
- **범위 밖:** 고정/해제 endpoint, 오류 키, `PinContent` 테이블 스키마 변경.
- Files:
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/content/AudioContentService.kt`
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/content/AudioContentServiceTest.kt`
- Interfaces:
- Consumes: `PinContentRepository.getPinContentList(memberId, active)`의 기존 오래된 고정 우선 반환 순서.
- Produces: `MAX_PIN_CONTENT_COUNT = 9`, `isAvailablePin == (activePinCount < 9)`, 10번째 고정 시 가장 오래된 활성 고정 교체 동작.
- [x] **RED:** 활성 고정이 8개일 때 상세 `isAvailablePin == true`, 9개일 때 `false`임을 검증하고, 9개 상태의 새 고정이 리포지토리 목록의 가장 오래된 항목을 재사용하는 테스트를 추가한다.
- [x] **RED 확인:** `./gradlew test --tests kr.co.vividnext.sodalive.content.AudioContentServiceTest --no-daemon`을 실행해 기존 3개 상한 때문에 8개 상태의 `isAvailablePin` 또는 교체 대상 assertion이 실패함을 확인한다.
- [x] **GREEN:** `AudioContentService`의 상세 고정 가능 판정과 `pinToTheTop`이 공통 `MAX_PIN_CONTENT_COUNT = 9`를 사용하도록 최소 수정한다.
- [x] **GREEN 확인:** 동일 `AudioContentServiceTest` 명령을 다시 실행해 통과를 확인한다.
- [x] **REFACTOR:** 상한 숫자 중복만 제거하고 고정 초과 시 최근 고정을 거부하는 새 예외·설정·DB 제약은 추가하지 않는다. focused test와 `./gradlew ktlintCheck --no-daemon`를 재실행한다.
- [x] **Task 7.2: 홈 오디오 목록을 활성 고정 우선으로 정렬**
**Goal 실행 `P7-T2`:** `latestAudioContent`의 최신 공개 정책을 유지하면서 `audioContents`만 활성 고정 우선순으로 반환한다.
- **시작 조건:** `P7-T1` 완료.
- **완료 증거:** RED/GREEN/REFACTOR 체크박스 완료, `DefaultCreatorChannelHomeQueryRepositoryTest` focused test 통과, 검증 기록 누적.
- **범위 밖:** `latestAudioContent` 선정 정책, `audioContents` 최대 9개, 공개 응답 DTO/스키마 변경.
- Files:
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/home/adapter/out/persistence/DefaultCreatorChannelHomeQueryRepository.kt`
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/home/adapter/out/persistence/DefaultCreatorChannelHomeQueryRepositoryTest.kt`
- Interfaces:
- Consumes: `PinContent.member`, `PinContent.content`, `PinContent.isActive`, `PinContent.updatedAt` 및 기존 `findLatestAudioContent`/`findAudioContents` port 계약.
- Produces: `findAudioContents`의 활성 고정 우선 정렬과 `findLatestAudioContent`의 기존 `releaseDate desc`, `id desc` 정렬 유지.
- [x] **RED:** 최신 공개 콘텐츠, 최근에 고정한 이전 공개 콘텐츠, 먼저 고정한 이전 공개 콘텐츠, 비활성 고정, 일반 콘텐츠 fixture를 구성해 다음을 한 테스트에서 검증한다.
- `latestAudioContent`는 상단 고정 여부와 관계없이 실제 최신 공개 콘텐츠다.
- `audioContents``PinContent.member.id == creatorId && isActive == true`인 콘텐츠를 `PinContent.updatedAt desc`로 먼저 반환한다.
- 비활성 고정은 일반 콘텐츠로 취급하고, 고정 이후 나머지는 `releaseDate desc`, `id desc`다.
- `latestAudioContent``audioContents`에 중복되지 않고, 고정 우선 정렬 후 최대 9개만 반환한다.
- [x] **RED 확인:** `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.home.adapter.out.persistence.DefaultCreatorChannelHomeQueryRepositoryTest --no-daemon`을 실행해 기존 최신순 전용 정렬 때문에 고정 우선 기대 순서 assertion이 실패함을 확인한다.
- [x] **GREEN:** `findAudioContentRows`의 최신 단건/목록 용도를 구분해 목록 조회에만 크리에이터의 활성 `PinContent`를 left join하고 `PinContent` 존재 여부 desc, `PinContent.updatedAt desc`, `AudioContent.releaseDate desc`, `AudioContent.id desc`를 적용한다.
- [x] **GREEN 확인:** 동일 repository focused test를 다시 실행해 통과를 확인한다.
- [x] **REFACTOR:** 공개 조건·projection·bulk 조립은 기존 구조를 유지하고, 정렬 구분을 위한 최소 변경만 남긴다. repository focused test, `CreatorChannelHomeQueryServiceTest`, `./gradlew ktlintCheck --no-daemon`를 실행한다.
#### Phase 7 Gate
**Goal 실행 `P7-GATE`:** 고정 한도와 홈 오디오 정렬의 확정 요구사항, 직접 영향 회귀, 문서 정합성을 최종 판정한다.
- **시작 조건:** `P7-T1`, `P7-T2` 완료.
- **완료 증거:** 아래 명령 전체 성공과 검증 기록 누적.
- **범위 밖:** 전체 회귀 실패와 무관한 기존 문제 수정.
```bash
./gradlew test --tests kr.co.vividnext.sodalive.content.AudioContentServiceTest --no-daemon
./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.home.adapter.out.persistence.DefaultCreatorChannelHomeQueryRepositoryTest --tests kr.co.vividnext.sodalive.v2.creator.channel.home.application.CreatorChannelHomeQueryServiceTest --no-daemon
./gradlew ktlintCheck --no-daemon
./gradlew tasks --all --no-daemon
git diff --check
```
**Expected:** 활성 고정 상한 9개, 10번째 고정의 최고령 고정 교체, `latestAudioContent` 최신순 유지, `audioContents` 활성 고정 우선/일반 최신순/최대 9개가 검증되고 Kotlin 포맷과 문서 명령이 성공한다.
전체 `./gradlew test`는 공개 API 스키마·공통 인증·예외·설정을 변경하지 않고 두 focused test 범위로 직접 영향을 판정할 수 있으므로 기본 생략한다. Gate 실행 중 targeted test로 영향 범위를 판단할 수 없는 실패가 발생하면 전체 회귀로 확장한다.
**실행 순서:** `P7-T1``P7-T2``P7-GATE`(기존 완료) → `P7-R1`(기존 완료) → `P7-R2`(기존 완료) → `P7-R3`
- [x] **Task 7.3: 동시 상단 고정에서도 크리에이터별 활성 9개 상한 보장**
**Goal 실행 `P7-R1`:** 같은 크리에이터의 상단 고정 변경을 직렬화해 동시 요청에서도 활성 고정과 콘텐츠 중복이 생기지 않고 9개 상한을 지킨다.
- **시작 조건:** `REV-P7-001` 확정, 기존 `P7-GATE` 완료.
- **완료 증거:** 동시성 RED/GREEN, 홈 목록 9개 상한 assertion, Phase 7 Gate 재실행, 검증 기록 누적.
- **범위 밖:** endpoint·오류 키·노출 정렬 계약·DB DDL 변경.
- Files:
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/content/AudioContentService.kt`
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/member/MemberRepository.kt` if needed
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/content/AudioContentServiceTest.kt`
- Create: `src/test/kotlin/kr/co/vividnext/sodalive/content/AudioContentPinConcurrencyTest.kt`
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/home/adapter/out/persistence/DefaultCreatorChannelHomeQueryRepositoryTest.kt`
- [x] **RED:** `AiCharacterAdminCommunityPostConcurrencyTest` 패턴을 참고해 실제 transaction 2개를 사용한다. 활성 고정 8개 상태에서 같은 크리에이터의 서로 다른 콘텐츠 고정 요청을 동시 실행해 최종 활성 고정 수가 9를 넘지 않고 콘텐츠가 중복되지 않아야 함을 재현한다. 홈 오디오 fixture도 9개를 초과해 반환 개수 9를 명시적으로 검증한다.
- [x] **GREEN:** 기존 `MemberRepository.findByIdForUpdate` 패턴 등 최소의 크리에이터 단위 pessimistic lock을 고정 상태 조회 전에 적용한다. 같은 콘텐츠의 재요청·9개 초과 교체 의미는 유지한다.
- [x] **REFACTOR/GATE:** lock 범위를 고정 변경 transaction에만 두고, `AudioContentServiceTest`, home repository/service focused test, `ktlintCheck`, `git diff --check`의 결과를 누적한다.
- [x] **Task 7.4: 상단 고정 transaction의 첫 DB 조회에서 크리에이터 lock 획득**
**Goal 실행 `P7-R2`:** MySQL InnoDB 기본 `REPEATABLE READ`에서도 고정 상태를 읽기 전에 크리에이터 lock을 획득해 대기 transaction이 최신 고정 상태를 기준으로 판단하게 한다.
- **시작 조건:** `REV-P7-002` 확정, `P4-R3` 완료.
- **완료 증거:** DB 호출 순서 RED/GREEN, 기존 동시성·9개 상한 회귀, Phase 7 직접 영향 Gate, 검증 기록 누적.
- **범위 밖:** DB 격리수준 설정, `PinContent` DDL/unique constraint, 새 테스트 의존성, endpoint·오류 키 변경.
- Files:
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/content/AudioContentService.kt`
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/content/AudioContentServiceTest.kt`
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/content/AudioContentPinConcurrencyTest.kt`
- [x] **RED:** `memberRepository.findByIdForUpdate``repository.findByIdAndCreatorId`, `findByContentIdAndMemberId`, `getPinContentList`보다 먼저 호출되는지를 하나의 `inOrder` 검증으로 고정한다. 현재 구현은 콘텐츠 일반 조회가 먼저라 실패해야 한다.
- [x] **GREEN:** `pinToTheTop`의 첫 DB 접근에서 크리에이터 member row를 잠그고, 이후 콘텐츠와 활성 고정 상태를 조회한다. 필요하면 반환된 locked member를 후속 저장에 사용한다.
- [x] **REFACTOR/GATE:** 기존 동시 요청 결과 검증과 10번째 교체·비활성 재활성화 회귀를 유지하고, 격리수준 변경이나 추가 lock/DDL 없이 최소 호출 순서 변경만 남긴다.
- [x] **Task 7.5: 상단 고정 해제도 크리에이터 lock으로 직렬화**
**Goal 실행 `P7-R3`:** 같은 크리에이터의 고정과 해제가 동일한 member row lock을 공유하게 해, 최고령 `PinContent` 행 재사용과 이전 콘텐츠 해제가 겹쳐 새 고정이 소실되지 않도록 한다.
- **시작 조건:** `REV-P7-003` 확정, `P7-R2` 완료.
- **완료 증거:** 해제 lock 순서 RED/GREEN, 기존 동시 고정·10번째 교체·비활성 재활성화 회귀, Phase 7 직접 영향 Gate, 검증 기록 누적.
- **범위 밖:** `PinContent` DDL/unique constraint, endpoint·오류 키·홈 정렬 계약, 고정 행 재사용 정책 변경.
- Files:
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/content/AudioContentService.kt`
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/content/AudioContentServiceTest.kt`
- Test if needed: `src/test/kotlin/kr/co/vividnext/sodalive/content/AudioContentPinConcurrencyTest.kt`
- [x] **RED:** `unpinAtTheTop``memberRepository.findByIdForUpdate(member.id)`로 크리에이터 lock을 획득한 뒤 `findByContentIdAndMemberId`를 호출하는 순서를 `inOrder`로 먼저 고정한다. 현재 구현은 lock 호출이 없어 실패해야 한다.
- [x] **GREEN:** `unpinAtTheTop`의 첫 DB 접근에서 `pinToTheTop`과 같은 member row lock을 획득하고, 잠긴 creator id로 해제할 `PinContent`를 조회해 비활성화한다.
- [x] **REFACTOR/GATE:** 별도 lock·격리수준·DDL을 추가하지 않고 공용 member lock만 재사용한다. 해제 focused test와 기존 `AudioContentServiceTest`, `AudioContentPinConcurrencyTest`, 홈 repository/service 회귀, `ktlintCheck`, `git diff --check` 결과를 누적한다.
---
## 구현 중 주의사항
@@ -645,6 +874,7 @@ data class CreatorChannelSnsResponse(
- 공개 시간은 UTC ISO-8601 문자열로 내려주고, 앱 표시 포맷은 서버에서 조합하지 않는다.
- 목록 섹션은 데이터가 없으면 빈 배열, 단건 섹션은 없으면 `null`로 내려준다.
- 신규 API 공개 스키마 변경은 이 문서의 task 범위 안에서만 수행한다.
- Phase 7은 공개 응답 DTO를 변경하지 않고, `PinContent` 고정 상한과 홈 `audioContents` 조회 순서만 보정한다.
---
@@ -703,3 +933,27 @@ data class CreatorChannelSnsResponse(
- 2026-06-13: Phase 6 Task 6.1 통합 시나리오 검증 - `DefaultCreatorChannelHomeQueryRepositoryTest`에 현실적인 단일 크리에이터 fixture로 creator/currentLive/latestAudioContent/channelDonations/notices/schedules/audioContents/series/communities/fanTalk/activity/sns 후보 조회를 모두 검증하는 `shouldFindCreatorChannelHomeIntegratedSections`를 추가했다. 기존 구현에서 `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.adapter.out.persistence.DefaultCreatorChannelHomeQueryRepositoryTest --tests '*shouldFindCreatorChannelHomeIntegratedSections' --no-daemon` 통과. MockMvc 응답 표면은 `CreatorChannelHomeControllerTest`에 schedule 내부 `isAdult`와 channelDonation 내부 `donationId`/`memberId`/`isSecret` 비노출 assertion을 보강했고, `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.adapter.in.web.CreatorChannelHomeControllerTest --no-daemon` 통과.
- 2026-06-13: Phase 6 Task 6.2 추천 페이지 enum rename 회귀 확인 - `./gradlew test --tests kr.co.vividnext.sodalive.v2.recommendation.application.HomeRecommendationQueryServiceTest --tests kr.co.vividnext.sodalive.v2.recommendation.adapter.out.persistence.DefaultHomeRecommendationQueryRepositoryTest --no-daemon` 통과. `rg -n "RecommendedActivityType" src/main/kotlin src/test/kotlin` 결과 없음.
- 2026-06-13: Phase 6 Task 6.3 전체 검증 - `./gradlew test --tests kr.co.vividnext.sodalive.v2.common.domain.CreatorActivityTypeTest --tests kr.co.vividnext.sodalive.v2.creator.channel.domain.CreatorChannelHomeQueryPolicyTest --tests kr.co.vividnext.sodalive.v2.creator.channel.application.CreatorChannelHomeQueryServiceTest --tests kr.co.vividnext.sodalive.v2.creator.channel.adapter.out.persistence.DefaultCreatorChannelHomeQueryRepositoryTest --tests kr.co.vividnext.sodalive.v2.creator.channel.adapter.in.web.CreatorChannelHomeControllerTest --tests kr.co.vividnext.sodalive.v2.recommendation.application.HomeRecommendationQueryServiceTest --tests kr.co.vividnext.sodalive.v2.recommendation.adapter.out.persistence.DefaultHomeRecommendationQueryRepositoryTest --no-daemon`, `./gradlew ktlintCheck --no-daemon`, `git diff --check`, `./gradlew test --no-daemon` 통과. 병렬 Gradle 실행 중 `build/snapshot/kotlin/kaptGenerateStubsTestKotlin` 삭제 경합이 한 번 발생했으나 동일 repository 테스트를 단독 재실행해 통과를 확인했다.
- 2026-07-30: Phase 7 후속 변경 문서화 - 활성 오디오 상단 고정 한도 9개, 10번째 고정의 최고령 고정 교체, `latestAudioContent` 최신순 유지, `audioContents` 활성 고정 `PinContent.updatedAt desc` 우선·일반 콘텐츠 `releaseDate desc`, `id desc`·최대 9개 정책을 PRD Feature D/H와 `DEC-001`에 확정했다. 기존 완료 Task는 유지하고 `P7-T1``P7-T2``P7-GATE` TDD 계획을 추가했다. `git diff --check` 통과. `./gradlew tasks --all --no-daemon`은 최초 샌드박스의 `~/.gradle` lock 파일 접근 제한으로 실패했고, 승인된 Gradle 캐시 접근으로 동일 명령을 재실행해 `BUILD SUCCESSFUL`을 확인했다. 사용자 요청이 관련 문서 반영이므로 생산 코드와 테스트는 아직 변경·실행하지 않았다.
- 2026-07-30: Phase 7 Task 7.1 RED 확인 - `AudioContentServiceTest`에 활성 고정 8개/9개 상세 `isAvailablePin` 계약과 10번째 고정의 최고령 활성 고정 재사용 테스트를 추가했다. `./gradlew test --tests kr.co.vividnext.sodalive.content.AudioContentServiceTest --no-daemon` 실행 시 기존 3개 상한 때문에 `shouldExposeAvailablePinByNineActivePinLimit`가 실패하는 것을 확인했다.
- 2026-07-30: Phase 7 Task 7.1 GREEN/REFACTOR 확인 - `AudioContentService`의 상세 고정 가능 판정과 `pinToTheTop` 교체 기준을 `MAX_PIN_CONTENT_COUNT = 9`로 통일했다. `./gradlew test --tests kr.co.vividnext.sodalive.content.AudioContentServiceTest --no-daemon`, `./gradlew ktlintCheck --no-daemon` 통과.
- 2026-07-30: Phase 7 Task 7.2 RED 확인 - `DefaultCreatorChannelHomeQueryRepositoryTest`에 최신 오디오, 최근/이전 활성 고정, 비활성 고정, 일반 오디오 fixture를 추가했다. `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.home.adapter.out.persistence.DefaultCreatorChannelHomeQueryRepositoryTest --no-daemon` 실행 시 기존 최신순 전용 정렬 때문에 `shouldSortHomeAudioContentsByActivePinBeforeReleaseDate`가 실패하는 것을 확인했다.
- 2026-07-30: Phase 7 Task 7.2 GREEN/REFACTOR 확인 - `findAudioContentRows`를 최신 단건과 목록 용도로 구분하고, 목록 조회에만 활성 `PinContent` left join 및 `pinContent.isActive desc`, `pinContent.updatedAt desc`, `AudioContent.releaseDate desc`, `AudioContent.id desc` 정렬을 적용했다. `pinContent.id.isNotNull.desc()`는 HQL syntax 오류를 내 기존 repository 패턴인 `pinContent.isActive.desc()`로 보정했다. `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.home.adapter.out.persistence.DefaultCreatorChannelHomeQueryRepositoryTest --no-daemon`, `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.home.application.CreatorChannelHomeQueryServiceTest --no-daemon`, `./gradlew ktlintCheck --no-daemon` 통과.
- 2026-07-30: Phase 7 Gate 확인 - `./gradlew test --tests kr.co.vividnext.sodalive.content.AudioContentServiceTest --no-daemon`, `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.home.adapter.out.persistence.DefaultCreatorChannelHomeQueryRepositoryTest --tests kr.co.vividnext.sodalive.v2.creator.channel.home.application.CreatorChannelHomeQueryServiceTest --no-daemon`, `./gradlew ktlintCheck --no-daemon`, `./gradlew tasks --all --no-daemon`, `git diff --check` 모두 통과.
- 2026-07-30: Phase 7 reviewer gate 보정 RED/GREEN 확인 - 리뷰어가 `pinToTheTop`이 비활성 `PinContent`까지 포함한 전체 목록 기준으로 동작하면 `9 active + inactive` 상태에서 활성 고정이 10개가 될 수 있음을 차단 이슈로 지적했다. `shouldKeepNineActivePinsWhenReactivatingInactivePin` 테스트 추가 직후 `./gradlew test --tests kr.co.vividnext.sodalive.content.AudioContentServiceTest --no-daemon`에서 해당 테스트 실패를 확인했고, `pinToTheTop``getPinContentList(memberId, active = true)` 기준으로 최고령 활성 고정을 내린 뒤 비활성 기존 고정을 재활성화하도록 보정했다. 보정 후 같은 `AudioContentServiceTest` 통과.
- 2026-07-30: Phase 7 reviewer gate 보정 후 Gate 재확인 - `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.home.adapter.out.persistence.DefaultCreatorChannelHomeQueryRepositoryTest --tests kr.co.vividnext.sodalive.v2.creator.channel.home.application.CreatorChannelHomeQueryServiceTest --no-daemon`, `./gradlew ktlintCheck --no-daemon`, `./gradlew tasks --all --no-daemon`, `git diff --check` 통과.
- 2026-07-30: Phase 7 reviewer gate 최종 확인 - 보정 delta 재검토 결과 차단 findings 없음.
- 2026-07-30: Phase 1~7 정적 리뷰 - `docs/20260612_크리에이터_채널_홈_API/reviews/phase-1-review.md`~`phase-7-review.md`를 작성했다. Phase 1~3은 확정 발견 사항 없음으로 판정했고, `REV-P4-001`(선호 중복 조회), `REV-P5-001`(빈 응답 계약 테스트 누락), `REV-P6-001`(단일 요청 통합 증거 누락), `REV-P7-001`(동시 고정 상한/중복 경쟁)을 확정해 Task 4.3·5.3·6.4·7.3과 `P4-R1`·`P5-R1`·`P6-R1`·`P7-R1`로 전환했다. 사용자 지시에 따라 컴파일·테스트·ktlint은 실행하지 않았고, `rg`·`sed`·`git diff`·`git log`·`git status`를 사용한 정적 검토와 `git diff --check` 성공만 기록한다.
- 2026-07-30: Phase 7 Task 7.3 RED/GREEN 확인 - `AudioContentServiceTest``MemberRepository.findByIdForUpdate``PinContent` 조회 전에 호출되는 순서를 먼저 고정했고, `AudioContentPinConcurrencyTest`에 활성 고정 8개 상태의 동시 고정 요청 최종 활성 9개/콘텐츠 중복 없음 통합 검증을 추가했다. RED는 production service 생성자에 lock 의존성이 없어 컴파일 실패하는 것으로 확인했고, `AudioContentService.pinToTheTop`이 고정 상태 조회 전 `MemberRepository.findByIdForUpdate(member.id!!)`를 호출하도록 보정했다. `DefaultCreatorChannelHomeQueryRepositoryTest`에는 활성 고정 후보가 9개를 넘어도 `findAudioContents(..., limit = 9)`가 9개만 반환하는 assertion을 추가했다. `./gradlew test --tests kr.co.vividnext.sodalive.content.AudioContentServiceTest --tests kr.co.vividnext.sodalive.content.AudioContentPinConcurrencyTest --no-daemon`, `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.home.adapter.out.persistence.DefaultCreatorChannelHomeQueryRepositoryTest --no-daemon` 통과.
- 2026-07-30: Phase 4 Task 4.3 RED/GREEN 확인 - `CreatorChannelHomeQueryServiceTest``getStoredPreference(viewer)` 결과의 `isAdult`가 service/커뮤니티 성인 필터에 전달되고 `canViewAdultContent(viewer)`가 별도 호출되지 않는 테스트를 추가했다. RED는 `NeverWantedButInvoked`로 확인했고, service가 `preference.isAdult`를 재사용하도록 보정했다. `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.home.application.CreatorChannelHomeQueryServiceTest --no-daemon` 통과.
- 2026-07-30: Phase 5 Task 5.3 계약 검증 - `CreatorChannelHomeControllerTest`에 빈 홈 응답 fixture를 추가해 `currentLive`, `latestAudioContent`, `fanTalk.latestFanTalk`은 명시적 `null`, `channelDonations`, `notices`, `schedules`, `audioContents`, `series`, `communities`는 빈 배열로 직렬화됨을 검증했다. 문서상 TDD 예외 항목이며 생산 DTO 변경 없이 `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.creator.channel.home.adapter.in.web.CreatorChannelHomeControllerTest --no-daemon` 통과.
- 2026-07-30: Phase 6 Task 6.4 통합 검증 - `CreatorChannelHomeEndToEndTest`를 추가해 실제 Spring bean과 DB fixture로 인증된 `GET /api/v2/creator-channels/{creatorId}/home` 단일 요청이 creator/current live/latest audio/donation/notice/schedule/audio list/series/community/fan Talk/activity/SNS 대표 필드를 조립하는지 검증했다. fixture 필수 series genre와 `audioContentCount` 기대값을 실제 정책에 맞게 보정한 뒤 `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.creator.channel.home.CreatorChannelHomeEndToEndTest --no-daemon` 통과.
- 2026-07-30: 리뷰 후속 통합 Gate 확인 - `./gradlew test --tests kr.co.vividnext.sodalive.content.AudioContentServiceTest --tests kr.co.vividnext.sodalive.content.AudioContentPinConcurrencyTest --tests kr.co.vividnext.sodalive.v2.creator.channel.home.application.CreatorChannelHomeQueryServiceTest --tests kr.co.vividnext.sodalive.v2.api.creator.channel.home.adapter.in.web.CreatorChannelHomeControllerTest --tests kr.co.vividnext.sodalive.v2.api.creator.channel.home.CreatorChannelHomeEndToEndTest --tests kr.co.vividnext.sodalive.v2.creator.channel.home.adapter.out.persistence.DefaultCreatorChannelHomeQueryRepositoryTest --no-daemon`, `./gradlew ktlintCheck --no-daemon`, `git diff --check` 통과.
- 2026-07-30: Phase 1~7 2차 정적 리뷰 - 기존 Phase별 리뷰 문서에 2차 리뷰 결과를 추가했다. Phase 1·2·5·6은 신규 확정 발견 사항 없음으로 판정했다. `REV-P3-001`(삭제 회원 후원자 닉네임 prefix), `REV-P4-002`(다른 크리에이터 조회 시 라이브 입장 제한 누락), `REV-P4-003`(기본 `now`의 JVM timezone 의존), `REV-P7-002`(member lock 전 일반 조회로 인한 MySQL snapshot 위험)를 확정해 Task 3.17·4.4·4.5·7.4와 `P3-R1`·`P4-R2`·`P4-R3`·`P7-R2`로 전환했다. 사용자 지시에 따라 컴파일·테스트·ktlint은 실행하지 않았으며 코드·테스트·문서와 MySQL 공식 격리수준 문서를 정적으로 대조했다. `git diff --check`, 리뷰 문서별 2차 섹션 단일 존재, finding ID와 미완료 Task 연결, trailing whitespace 부재를 정적 확인했다.
- 2026-07-30: Phase 3 Task 3.17 RED/GREEN 확인 - `CreatorChannelHomeQueryServiceTest`에 삭제 회원 후원자 `deleted_donor`가 홈 domain에서 `donor`로 조립되는 테스트를 추가했다. RED는 `AssertionFailedError`로 확인했고, `CreatorChannelDonationRecord.toDomain()`에서 `removeDeletedNicknamePrefix()`를 적용한 뒤 `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.home.application.CreatorChannelHomeQueryServiceTest --tests '*shouldRemoveDeletedNicknamePrefixFromChannelDonation' --no-daemon` 통과.
- 2026-07-30: Phase 4 Task 4.4 RED/GREEN 확인 - `viewer.role == CREATOR`이고 `viewer.id != creatorId`인 조회자의 `isViewerCreator` 전달 테스트를 추가해 RED를 확인했다. 이후 `CreatorChannelHomeQueryService``isViewerCreator` 계산을 `viewer.role == MemberRole.CREATOR`로 보정하고 일반 회원 false, 대상 본인 크리에이터 true 회귀와 함께 `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.home.application.CreatorChannelHomeQueryServiceTest --tests '*shouldPassViewerCreatorFlagWhenViewerIsDifferentCreator' --tests '*shouldPassNonCreatorFlagWhenViewerIsUser' --tests '*shouldPassViewerCreatorFlagToLivePolicyQueries' --no-daemon` 통과.
- 2026-07-30: Phase 4 Task 4.5 RED/GREEN 확인 - `CreatorChannelHomeFacadeTest`에 JVM 기본 timezone을 `Asia/Seoul`로 바꾼 상태에서 인자 없는 홈 조회가 UTC 기준 `now`를 service에 전달하는 테스트를 추가했다. RED는 UTC 범위 assertion 실패로 확인했고, facade와 service의 기본 `now``LocalDateTime.now(ZoneOffset.UTC)`로 고정한 뒤 `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.creator.channel.home.application.CreatorChannelHomeFacadeTest --no-daemon` 통과.
- 2026-07-30: Phase 7 Task 7.4 RED/GREEN 확인 - `AudioContentServiceTest`의 10번째 고정 테스트에 `memberRepository.findByIdForUpdate``repository.findByIdAndCreatorId``PinContent` 조회보다 먼저 호출되는 `inOrder` 검증을 추가했다. RED는 `VerificationInOrderFailure`로 확인했고, `AudioContentService.pinToTheTop`이 첫 DB 접근에서 locked creator를 가져와 이후 콘텐츠/고정 조회와 저장에 사용하도록 보정했다. `./gradlew test --tests kr.co.vividnext.sodalive.content.AudioContentServiceTest --tests '*shouldReuseOldestActivePinWhenPinningTenthContent' --tests '*shouldKeepNineActivePinsWhenReactivatingInactivePin' --no-daemon` 통과.
- 2026-07-31: 2차 리뷰 후속 최종 Gate 확인 - Reviewer gate에서 `P7-R2` 순서 테스트가 `getPinContentList` 호출까지 고정하지 않은 blocker를 확인해 같은 `inOrder`에 추가했고, 재리뷰 PASS를 받았다. 전체 `./gradlew test --no-daemon`은 첫 실행에서 UTC 기본 `now` 변경 영향으로 `CreatorChannelHomeEndToEndTest`의 local-time fixture current live가 `null`이 되어 실패했고, fixture 기준 시각을 `LocalDateTime.now(ZoneOffset.UTC)`로 보정했다. 보정 후 `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.creator.channel.home.CreatorChannelHomeEndToEndTest --no-daemon`, `./gradlew test --no-daemon`, `./gradlew ktlintCheck --no-daemon`, `git diff --check` 통과.
- 2026-07-31: Phase 1~7 3차 정적 리뷰 - 기존 Phase별 리뷰 문서에 3차 결과를 누적했다. Phase 1~6은 신규 확정 발견 사항 없음으로 판정했고, `REV-P7-003`(고정 해제가 creator lock을 공유하지 않아 최고령 `PinContent` 행 재사용과 겹치면 새 고정이 소실될 수 있음)을 확정해 Task 7.5 / `P7-R3`으로 전환했다. 사용자 지시에 따라 컴파일·테스트·ktlint은 실행하지 않았다. `rg -n "RecommendedActivityType" src/main/kotlin src/test/kotlin`과 문서 trailing whitespace 검색은 결과 없음, finding ID와 미완료 Task/Goal 연결 확인, `git diff --check -- docs/20260612_크리에이터_채널_홈_API/plan-task.md` 통과.
- 2026-07-31: Phase 7 Task 7.5 RED/GREEN 확인 - `AudioContentServiceTest``unpinAtTheTop``memberRepository.findByIdForUpdate`로 크리에이터 lock을 획득한 뒤 `findByContentIdAndMemberId`를 호출하는 `inOrder` 테스트를 추가했다. RED는 `WantedButNotInvoked`로 확인했고, `AudioContentService.unpinAtTheTop`이 첫 DB 접근에서 creator member row lock을 획득한 뒤 잠긴 creator id로 `PinContent`를 조회하도록 최소 보정했다. `./gradlew test --tests kr.co.vividnext.sodalive.content.AudioContentServiceTest --tests '*shouldLockCreatorBeforeFindingPinWhenUnpinningContent' --no-daemon`, `./gradlew test --tests kr.co.vividnext.sodalive.content.AudioContentServiceTest --tests kr.co.vividnext.sodalive.content.AudioContentPinConcurrencyTest --no-daemon`, `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.home.adapter.out.persistence.DefaultCreatorChannelHomeQueryRepositoryTest --tests kr.co.vividnext.sodalive.v2.creator.channel.home.application.CreatorChannelHomeQueryServiceTest --no-daemon`, `./gradlew ktlintCheck --no-daemon`, `git diff --check` 통과. 사용자 지시에 따라 전체 테스트는 실행하지 않았다.
- 2026-07-31: Phase 1~7 4차 정적 리뷰 - 기존 Phase별 리뷰 문서에 4차 결과를 각각 누적했다. PRD·계획·구조 정렬 후속 계약과 현재 domain/port/repository/service/facade/controller, 기존 테스트를 정적 대조한 결과 모든 Phase에서 신규 확정 발견 사항이 없어 후속 Task/Goal은 추가하지 않았다. 사용자 지시에 따라 컴파일·테스트·ktlint은 실행하지 않았다. `rg`로 이전 enum 잔존 여부와 Phase별 4차 절의 단일 존재를 확인했고, trailing whitespace 검색과 `git diff --check`가 통과했다. 문서 가이드에 따른 `./gradlew tasks --all --no-daemon`은 최초 샌드박스의 Gradle lock 접근 제한으로 실패했으나 승인된 캐시 접근으로 재실행해 `BUILD SUCCESSFUL`을 확인했다.

View File

@@ -22,6 +22,7 @@
- 공지, 커뮤니티 게시글은 홈 노출에 필요한 게시글 요약 필드를 제공한다.
- 채널 후원은 최신순 8개를 내려준다.
- 오디오 콘텐츠는 최근 업로드 기준 최대 9개를 내려주고, 예약 업로드 전 콘텐츠는 일반 오디오 목록에는 포함하지 않는다.
- 크리에이터가 상단 고정한 오디오 콘텐츠는 최대 9개까지 관리하고, 홈 오디오 목록에서 일반 콘텐츠보다 먼저 노출한다.
- 시리즈는 최대 8개를 내려주고, 해당 시리즈에 속한 콘텐츠의 최신 공개일 기준으로 정렬한다.
- 팬 Talk는 가장 최근에 남긴 팬 Talk 1개와 전체 팬 Talk 개수를 함께 내려준다.
- 활동 지수와 SNS는 `ExplorerService.getCreatorDetail`의 계산/필드 의미를 기준으로 확장한다.
@@ -181,6 +182,13 @@
#### Requirements
- 최근 업로드된 오디오 콘텐츠를 최대 9개 내려준다.
- 신규 오디오 콘텐츠 영역과 오디오 목록 영역의 첫 번째 항목이 겹치지 않도록, 오디오 목록에서는 Feature D의 `latestAudioContent`로 내려간 가장 최신 콘텐츠를 제외한다.
- `latestAudioContent`는 상단 고정 여부와 관계없이 기존처럼 공개 시각 기준 최신 콘텐츠를 내려준다.
- `audioContents``PinContent.member.id == creatorId && PinContent.isActive == true`인 콘텐츠를 일반 콘텐츠보다 먼저 내려준다.
- 상단 고정 콘텐츠 사이의 정렬은 `PinContent.updatedAt desc`다.
- 고정되지 않은 콘텐츠 사이의 정렬은 기존 `AudioContent.releaseDate desc`, `AudioContent.id desc`를 유지한다.
- 상단 고정 우선순위를 적용한 뒤 전체 `audioContents`를 최대 9개로 제한한다.
- 크리에이터별 활성 상단 고정 콘텐츠는 최대 9개다. 9개가 활성인 상태에서 새 콘텐츠를 고정하면 기존 동작처럼 `PinContent.updatedAt`이 가장 오래된 활성 고정을 교체한다.
- 오디오 상세의 `isAvailablePin`은 요청자가 해당 콘텐츠의 크리에이터이고 활성 고정 개수가 9개 미만일 때만 `true`다.
- 예약 업로드 전 콘텐츠는 포함하지 않는다.
- `releaseDate == null`인 오디오 콘텐츠는 목록, 최신 콘텐츠, 첫 콘텐츠 판정에서 제외한다.
- 응답에는 다음 값을 포함한다.
@@ -201,6 +209,8 @@
#### Edge Cases
- 시리즈에 속하지 않은 콘텐츠는 시리즈 관련 필드를 `null`로 내려준다.
- 오디오 콘텐츠가 없으면 빈 배열을 내려준다.
- `latestAudioContent`가 상단 고정 콘텐츠여도 `audioContents`에 중복 노출하지 않는다.
- 비활성 `PinContent`는 고정 우선 정렬에 사용하지 않는다.
### Feature I. 시리즈
@@ -332,3 +342,11 @@
## 11. Open Questions
- 없음.
---
## 12. Decision Log
| 날짜 | ID | 상태 | 결정 | 근거 | 영향 범위 |
|---|---|---|---|---|---|
| 2026-07-30 | `DEC-001` | 확정 | 오디오 상단 고정 한도를 3개에서 9개로 늘리고, `audioContents`에서 활성 고정을 `PinContent.updatedAt desc`로 먼저 노출한 뒤 일반 콘텐츠를 기존 최신순으로 노출한다. `latestAudioContent`의 최신 공개 정책과 공개 API 스키마는 유지한다. | 2026-07-30 `deep-interview` 확정 결과와 기존 `PinContent` 교체 동작 | Feature D, Feature H, `P7-T1`, `P7-T2`, `P7-GATE` |

View File

@@ -0,0 +1,66 @@
# Phase 1 코드 리뷰 보고서
## 1. 리뷰 정보
| 항목 | 내용 |
|---|---|
| 리뷰 대상 | Phase 1 / Task 1.1 |
| 기준 commit 또는 working tree | `f1c2e6c5` + 2026-07-30 working tree |
| 리뷰 일자 | 2026-07-30 |
| 리뷰어 | Codex |
| 기준 문서 | `prd.md`, `plan-task.md`, `docs/agent-guides/*.md` |
| 리뷰 상태 | 판정 완료 |
## 2. 목적과 범위
- `RecommendedActivityType`의 공용 `CreatorActivityType` 이동과 추천 기능 회귀 범위를 코드·테스트·검증 기록과 정적 대조했다.
- 후속 패키지 정렬 문서로 승인된 경로 변경은 결함에서 제외했다.
- 사용자 지시에 따라 컴파일·테스트는 실행하지 않았고, 기존 성공 기록은 참고 증거로만 사용했다.
## 3. 검토 근거
- `CreatorActivityType.kt``LIVE`, `AUDIO`, `COMMUNITY`, `LIVE_REPLAY`와 각 name 기반 `code`를 제공한다.
- 추천 service/port/repository와 대응 테스트의 import와 type은 공용 enum으로 정렬되어 있다.
- `rg -n "RecommendedActivityType" src/main/kotlin src/test/kotlin`으로 이전 타입 잔존 여부를 정적 확인했다.
## 4. 발견 사항
확정 발견 사항 없음.
## 5. plan·goal 전환
전환 항목 없음.
## 6. 리뷰 종료 판정
| 판정 항목 | 결과 | 근거 |
|---|---|---|
| 리뷰 범위 전체 확인 | 충족 | Phase 1 코드·테스트·문서 정적 대조 |
| 후보 항목 판정 완료 | 충족 | 후보 없음 |
| 확정 항목 plan 반영 | 해당 없음 | 확정 발견 사항 없음 |
| 검증 명령과 결과 기록 | 충족 | 정적 검토만 실행, 테스트 미실행 사유 기록 |
**최종 결론:** 확정 발견 사항 없음.
**남은 항목:** 없음.
## 7. 2차 리뷰 — 2026-07-30
- **대상:** 1차 리뷰 후속 변경이 공용 `CreatorActivityType`과 추천 기능에 만든 영향.
- **방법:** 현재 working tree의 import/type 사용처와 추천 회귀 테스트 코드를 정적 대조했다. 사용자 지시에 따라 테스트·컴파일·ktlint은 실행하지 않았다.
- **결과:** 신규 확정 발견 사항 없음. Phase 1 후속 Task를 추가하지 않는다.
- **남은 항목:** 없음.
## 8. 3차 리뷰 — 2026-07-31
- **대상:** 현재 working tree의 `CreatorActivityType` 정의와 추천·크리에이터 채널 홈 사용처.
- **방법:** enum 값·`code`, import/type 사용처, 대응 테스트와 `plan-task.md` Task 1.1을 정적 대조했다. 사용자 지시에 따라 테스트·컴파일·ktlint은 실행하지 않았다.
- **결과:** 신규 확정 발견 사항 없음. Phase 1 후속 Task를 추가하지 않는다.
- **남은 항목:** 없음.
## 9. 4차 리뷰 — 2026-07-31
- **대상:** 공용 `CreatorActivityType` 정의와 추천·홈 스케줄의 현재 사용 경계.
- **방법:** enum 값·`code`, 추천 service/port/repository 및 홈 domain/DTO import, 이전 `RecommendedActivityType` 잔존 여부를 Task 1.1과 정적 대조했다. 사용자 지시에 따라 테스트·컴파일·ktlint은 실행하지 않았다.
- **결과:** 신규 확정 발견 사항 없음. Phase 1 후속 Task를 추가하지 않는다.
- **남은 항목:** 없음.

View File

@@ -0,0 +1,66 @@
# Phase 2 코드 리뷰 보고서
## 1. 리뷰 정보
| 항목 | 내용 |
|---|---|
| 리뷰 대상 | Phase 2 / Task 2.1~2.2 |
| 기준 commit 또는 working tree | `f1c2e6c5` + 2026-07-30 working tree |
| 리뷰 일자 | 2026-07-30 |
| 리뷰어 | Codex |
| 기준 문서 | `prd.md`, `plan-task.md`, 후속 홈 API 구조 정렬 문서, `docs/agent-guides/*.md` |
| 리뷰 상태 | 판정 완료 |
## 2. 목적과 범위
- 홈 domain/response가 PRD의 13개 상위 섹션을 유지하는지, 순수 정책이 스케줄 제한·정렬·성인 보정·최신 오디오 중복 제거를 보장하는지 정적 검토했다.
- 최초 오디오 판정을 repository 계층으로 이동한 후속 정렬은 해당 문서와 현재 repository 테스트를 함께 대조했다.
- 사용자 지시에 따라 컴파일·테스트는 실행하지 않았다.
## 3. 검토 근거
- `CreatorChannelHome.kt`, `CreatorChannelHomeResponse.kt`, `CreatorChannelHomeQueryPolicy.kt`
- `CreatorChannelHomeQueryPolicyTest.kt`, `CreatorChannelHomeQueryServiceTest.kt`, `DefaultCreatorChannelHomeQueryRepositoryTest.kt`
- 최신 오디오 제외와 스케줄 경계값·동시각 LIVE 우선·성인 노출 정책의 테스트 존재를 `rg`로 확인했다.
## 4. 발견 사항
확정 발견 사항 없음.
## 5. plan·goal 전환
전환 항목 없음.
## 6. 리뷰 종료 판정
| 판정 항목 | 결과 | 근거 |
|---|---|---|
| 리뷰 범위 전체 확인 | 충족 | Phase 2 모델·정책·테스트 정적 대조 |
| 후보 항목 판정 완료 | 충족 | 후보 없음 |
| 확정 항목 plan 반영 | 해당 없음 | 확정 발견 사항 없음 |
| 검증 명령과 결과 기록 | 충족 | 정적 검토만 실행, 테스트 미실행 사유 기록 |
**최종 결론:** 확정 발견 사항 없음.
**남은 항목:** 없음.
## 7. 2차 리뷰 — 2026-07-30
- **대상:** 1차 리뷰 후속 변경 이후 domain/response/policy의 정렬·제한·null/빈 목록 계약.
- **방법:** 모델 변환과 policy 호출부, 대응 단위 테스트를 정적 대조했다. 사용자 지시에 따라 테스트·컴파일·ktlint은 실행하지 않았다.
- **결과:** 신규 확정 발견 사항 없음. Phase 2 후속 Task를 추가하지 않는다.
- **남은 항목:** 없음.
## 8. 3차 리뷰 — 2026-07-31
- **대상:** 홈 13개 상위 섹션 domain/response와 스케줄·최신 오디오 제외 순수 정책.
- **방법:** `CreatorChannelHome`, `CreatorChannelHomeResponse`, `CreatorChannelHomeQueryPolicy`와 service/controller 테스트를 PRD Feature A~N 및 Task 2.1~2.2와 정적 대조했다. 사용자 지시에 따라 테스트·컴파일·ktlint은 실행하지 않았다.
- **결과:** 신규 확정 발견 사항 없음. Phase 2 후속 Task를 추가하지 않는다.
- **남은 항목:** 없음.
## 9. 4차 리뷰 — 2026-07-31
- **대상:** 홈 domain/response 13개 섹션과 스케줄 제한·동시각 정렬·성인 보정·최신 오디오 제외 정책.
- **방법:** domain/response factory, `CreatorChannelHomeQueryPolicy`, service 호출부와 대응 단위·응답 계약 테스트를 Task 2.1~2.2와 정적 대조했다. 사용자 지시에 따라 테스트·컴파일·ktlint은 실행하지 않았다.
- **결과:** 신규 확정 발견 사항 없음. Phase 2 후속 Task를 추가하지 않는다.
- **남은 항목:** 없음.

View File

@@ -0,0 +1,88 @@
# Phase 3 코드 리뷰 보고서
## 1. 리뷰 정보
| 항목 | 내용 |
|---|---|
| 리뷰 대상 | Phase 3 / Task 3.1~3.16 |
| 기준 commit 또는 working tree | `f1c2e6c5` + 2026-07-30 working tree |
| 리뷰 일자 | 2026-07-30 |
| 리뷰어 | Codex |
| 기준 문서 | `prd.md`, `plan-task.md`, 커뮤니티 좋아요·홈 API 구조 정렬 후속 문서, `docs/agent-guides/*.md` |
| 리뷰 상태 | 판정 완료 |
## 2. 목적과 범위
- 현재 query port/persistence adapter와 공용 커뮤니티 조회 경계가 creator, 차단, 라이브, 예약, 오디오, 후원, 시리즈, 팬 Talk, 활동, SNS 정책을 PRD와 같은 의미로 구현하는지 정적 검토했다.
- 후속 문서로 승인된 `isOwned`/`isRented`, `isLiked`, 패키지 분리는 원 계획과의 단순 차이로 결함 판정하지 않았다.
- 사용자 지시에 따라 컴파일·테스트는 실행하지 않았다.
## 3. 검토 근거
- `CreatorChannelHomeQueryPort.kt`, `DefaultCreatorChannelHomeQueryRepository.kt`
- `DefaultCreatorChannelHomeQueryRepositoryTest.kt`의 공개 시각/null, 성인, 성별·크리에이터 입장, 구매·비밀 후원, 삭제된 유료 게시물, 시리즈 경계, 데뷔일, KST 월 경계 테스트
- 주요 쿼리의 대량 조립이 id 목록 기반 bulk 조회를 유지하고, 추가 N+1을 만들지 않는지 확인했다.
## 4. 발견 사항
확정 발견 사항 없음.
## 5. plan·goal 전환
전환 항목 없음.
## 6. 리뷰 종료 판정
| 판정 항목 | 결과 | 근거 |
|---|---|---|
| 리뷰 범위 전체 확인 | 충족 | Phase 3 코드·테스트·후속 계약 정적 대조 |
| 후보 항목 판정 완료 | 충족 | 후보 없음 |
| 확정 항목 plan 반영 | 해당 없음 | 확정 발견 사항 없음 |
| 검증 명령과 결과 기록 | 충족 | 정적 검토만 실행, 테스트 미실행 사유 기록 |
**최종 결론:** 확정 발견 사항 없음.
**남은 항목:** 없음.
## 7. 2차 리뷰 — 2026-07-30
### 7.1 리뷰 범위와 방법
- 1차 리뷰 후속 변경이 반영된 홈 후원 record/domain 변환을 PRD의 “기존 채널 후원 목록과 동일” 계약과 대조했다.
- 기존 `ChannelDonationService`와 전용 v2 후원 탭의 삭제 회원 닉네임 처리도 함께 확인했다.
- 사용자 지시에 따라 테스트·컴파일·ktlint은 실행하지 않았다.
### 7.2 발견 사항
| ID | 심각도 | 상태 | 제목 | 소유 Task | 후속 goal |
|---|---|---|---|---|---|
| `REV-P3-001` | Low | 완료 | 홈 후원에 삭제 회원 닉네임 prefix가 노출된다 | Task 3.17 | `P3-R1` |
#### REV-P3-001 — 홈 후원에 삭제 회원 닉네임 prefix가 노출된다
- **관련 요구사항:** PRD Feature E, Task 3.5·3.15
- **관찰:** 홈 repository는 후원자의 저장 닉네임을 그대로 projection하고, `CreatorChannelHomeQueryService.toDomain()`도 그대로 복사한다. 기존 `ChannelDonationService``CreatorChannelDonationQueryService``removeDeletedNicknamePrefix()`를 적용한다.
- **영향:** 탈퇴한 후원자의 내부 저장 형식인 `deleted_...`가 홈 API에서만 공개되어 기존 채널 후원 목록과 표시 의미가 달라진다.
- **권장 조치:** 홈 후원 domain 변환 경계에 기존 extension을 적용하고 삭제 회원 후원자 회귀 테스트를 추가한다.
- **판정 기록:** 코드 경로 3개를 정적 대조해 확정했다.
- **완료 기록:** 2026-07-30 — `CreatorChannelHomeQueryServiceTest` RED/GREEN으로 확인하고 service 변환 경계에 `removeDeletedNicknamePrefix()`를 적용했다.
### 7.3 plan·goal 전환과 종료 판정
- `plan-task.md` Phase 3에 Task 3.17 / `P3-R1`을 추가했다.
- **최종 결론:** Low 1건 수정 완료.
- **남은 항목:** 없음.
## 8. 3차 리뷰 — 2026-07-31
- **대상:** 현재 query port/persistence adapter와 공용 커뮤니티 조회 경계의 creator, 라이브·스케줄, 오디오, 후원, 공지·커뮤니티, 시리즈, 팬 Talk, 활동, SNS 정책.
- **방법:** projection·bulk 조립, 공개/예약·성인·차단 조건, KST 월 경계, 삭제 회원 닉네임 보정과 repository/service 테스트를 PRD 및 Task 3.1~3.17과 정적 대조했다. 사용자 지시에 따라 테스트·컴파일·ktlint은 실행하지 않았다.
- **결과:** `REV-P3-001` 수정 반영을 포함해 신규 확정 발견 사항 없음. Phase 3 후속 Task를 추가하지 않는다.
- **남은 항목:** 없음.
## 9. 4차 리뷰 — 2026-07-31
- **대상:** query port/persistence adapter와 공용 커뮤니티 service의 creator·라이브·스케줄·오디오·후원·게시글·시리즈·팬 Talk·활동·SNS 조회 정책.
- **방법:** projection/bulk 조회, 공개·예약·성인·성별·차단·구매·KST 월 경계 조건과 repository/service 테스트를 PRD Feature A~N 및 Task 3.1~3.17과 정적 대조했다. 사용자 지시에 따라 테스트·컴파일·ktlint은 실행하지 않았다.
- **결과:** 기존 `REV-P3-001` 보정이 유지되며 신규 확정 발견 사항 없음. Phase 3 후속 Task를 추가하지 않는다.
- **남은 항목:** 없음.

View File

@@ -0,0 +1,122 @@
# Phase 4 코드 리뷰 보고서
## 1. 리뷰 정보
| 항목 | 내용 |
|---|---|
| 리뷰 대상 | Phase 4 / Task 4.1~4.2 |
| 기준 commit 또는 working tree | `f1c2e6c5` + 2026-07-30 working tree |
| 리뷰 일자 | 2026-07-30 |
| 리뷰어 | Codex |
| 기준 문서 | `prd.md`, `plan-task.md`, 홈 API 구조 정렬 후속 문서, `docs/agent-guides/*.md` |
| 리뷰 상태 | 판정 완료 |
## 2. 목적과 범위
- service의 접근 검증, 조회자 context, 섹션 조립, 최종 정책 보정을 PRD와 대조했다.
- `CreatorChannelHomeQueryService.kt`, `MemberContentPreferenceService.kt`, 대응 service 테스트를 포함했다.
- 사용자 지시에 따라 컴파일·테스트는 실행하지 않았다.
## 3. 검토 근거
- `CreatorChannelHomeQueryService.kt:70-71``getStoredPreference(viewer)``canViewAdultContent(viewer)`를 이어서 호출한다.
- `MemberContentPreferenceService.kt:144-157`에서 `canViewAdultContent`ub294 `getStoredPreference(member).isAdult`를 다시 호출한다.
- 두 호출은 같은 요청에서 같은 `ViewerContentPreference`를 사용할 수 있으며, `getStoredPreference``REQUIRES_NEW` transaction이다.
## 4. 발견 사항
| ID | 심각도 | 상태 | 제목 | 소유 Task | 후속 goal |
|---|---|---|---|---|---|
| `REV-P4-001` | Low | 완료 | 홈 조회가 콘텐츠 선호를 같은 요청에서 두 번 조회한다 | Task 4.3 | `P4-R1` |
### REV-P4-001 — 홈 조회가 콘텐츠 선호를 같은 요청에서 두 번 조회한다
- **심각도:** Low
- **상태:** 확정
- **관련 요구사항:** Phase 4 조회자 성인 노출·콘텐츠 타입 context 조립
- **소유 Task:** Task 4.3 / `P4-R1`
**관찰 내용**
service가 이미 받은 `preference.isAdult`를 사용할 수 있지만 `canViewAdultContent(viewer)`를 다시 호출한다. 실제 bean에서는 이 메서드가 `getStoredPreference` 전체 경로를 반복한다.
**영향**
공개 응답은 바뀌지 않지만, 홈 조회마다 독립 transaction·선호·국가 context 조회가 중복된다.
**권장 조치**
`preference.isAdult`를 성인 필터에 재사용하고, service 테스트에서 별도 `canViewAdultContent` 호출이 없음을 고정한다.
**판정 기록**
- 2026-07-30 — 두 method의 정적 호출 관계와 transaction annotation으로 확정.
- 2026-07-30 — `CreatorChannelHomeQueryServiceTest` RED/GREEN으로 `preference.isAdult` 재사용과 `canViewAdultContent(viewer)` 미호출을 검증하고 완료.
## 5. plan·goal 전환
- `plan-task.md` Phase 4에 Task 4.3 / `P4-R1`을 추가했다.
- 실행 objective: `REV-P4-001`을 수정하고 service 조립 회귀를 방지한다.
## 6. 리뷰 종료 판정
| 판정 항목 | 결과 | 근거 |
|---|---|---|
| 리뷰 범위 전체 확인 | 충족 | service·선호 service·테스트 정적 대조 |
| 후보 항목 판정 완료 | 충족 | `REV-P4-001` 확정 |
| 확정 항목 plan 반영 | 충족 | Task 4.3 / `P4-R1` |
| 검증 명령과 결과 기록 | 충족 | 정적 검토만 실행, 테스트 미실행 사유 기록 |
**최종 결론:** 수정 goal 완료.
**남은 항목:** 없음.
## 7. 2차 리뷰 — 2026-07-30
### 7.1 리뷰 범위와 방법
- service/facade의 조회자 context 조립과 기본 `now` 생성 지점을 기존 라이브 목록, 홈 Following, UTC 응답·KST 월 경계 계약과 대조했다.
- 사용자 지시에 따라 테스트·컴파일·ktlint은 실행하지 않았다.
### 7.2 발견 사항
| ID | 심각도 | 상태 | 제목 | 소유 Task | 후속 goal |
|---|---|---|---|---|---|
| `REV-P4-002` | Medium | 완료 | 다른 크리에이터가 조회하면 입장 제한이 적용되지 않는다 | Task 4.4 | `P4-R2` |
| `REV-P4-003` | Medium | 완료 | 홈 기본 시각이 JVM timezone에 따라 달라진다 | Task 4.5 | `P4-R3` |
#### REV-P4-002 — 다른 크리에이터가 조회하면 입장 제한이 적용되지 않는다
- **관련 요구사항:** PRD Feature B/F의 기존 라이브 목록 동일 정책, Task 4.1
- **관찰:** service는 `isViewerCreator = viewerId == creatorId`로 계산한다. 반면 기존 라이브 목록과 `HomeFollowingQueryService`는 조회자의 `MemberRole.CREATOR` 여부를 사용하며, repository는 이 값이 true일 때 `isAvailableJoinCreator` 또는 자기 라이브 예외를 적용한다.
- **영향:** 크리에이터가 다른 크리에이터 채널을 볼 때 `isAvailableJoinCreator == false`인 현재/예약 라이브가 홈에 노출될 수 있다.
- **권장 조치:** 조회자의 role로 flag를 산출하고 다른 크리에이터 조회 회귀 테스트를 추가한다.
- **완료 기록:** 2026-07-30 — 다른 크리에이터/일반 회원/대상 본인 focused test로 `viewer.role == MemberRole.CREATOR` 계산을 검증했다.
#### REV-P4-003 — 홈 기본 시각이 JVM timezone에 따라 달라진다
- **관련 요구사항:** UTC 시간 계약, KST 기준 이번 달 후원, Task 4.1·6.4
- **관찰:** facade와 service의 기본값은 `LocalDateTime.now()`인 반면 repository는 입력 `now`를 UTC로 간주해 KST 월 경계로 변환하고 DTO도 UTC offset을 붙인다. JVM timezone을 고정하는 설정은 확인되지 않았고, 인접 `HomeFollowingQueryService`는 UTC clock을 명시한다.
- **영향:** JVM이 UTC가 아니면 공개/예약 콘텐츠 경계와 이번 달 후원 범위가 timezone offset만큼 이동하고, 응답 문자열도 실제 instant와 다를 수 있다. 현재 E2E fixture도 같은 `LocalDateTime.now()`를 사용해 이 조건을 드러내지 못한다.
- **권장 조치:** 인자 없는 조회의 `now`를 UTC로 생성하고 비 UTC JVM timezone 회귀 테스트를 추가한다.
- **완료 기록:** 2026-07-30 — facade 기본 호출을 `Asia/Seoul` JVM timezone에서 검증하고 facade/service 기본 `now`를 UTC로 고정했다.
### 7.3 plan·goal 전환과 종료 판정
- `plan-task.md` Phase 4에 Task 4.4 / `P4-R2`, Task 4.5 / `P4-R3`을 추가했다.
- **최종 결론:** Medium 2건 수정 완료.
- **남은 항목:** 없음.
## 8. 3차 리뷰 — 2026-07-31
- **대상:** 접근 검증 순서, 콘텐츠 선호 단일 조회, 조회자 role/effective gender 전달, facade·service 기본 UTC 시각과 전체 섹션 조립.
- **방법:** `CreatorChannelHomeQueryService`, `CreatorChannelHomeFacade`와 대응 service/facade 테스트를 Task 4.1~4.5 및 기존 라이브 목록 정책과 정적 대조했다. 사용자 지시에 따라 테스트·컴파일·ktlint은 실행하지 않았다.
- **결과:** `REV-P4-001`~`REV-P4-003` 수정 반영을 포함해 신규 확정 발견 사항 없음. Phase 4 후속 Task를 추가하지 않는다.
- **남은 항목:** 없음.
## 9. 4차 리뷰 — 2026-07-31
- **대상:** 접근 예외 순서, 콘텐츠 선호 단일 조회, 조회자 role/effective gender, UTC `now` 전달과 전체 섹션 조립.
- **방법:** query service/facade의 실제 호출 흐름과 service/facade 테스트를 기존 라이브 목록 정책 및 Task 4.1~4.5와 정적 대조했다. 사용자 지시에 따라 테스트·컴파일·ktlint은 실행하지 않았다.
- **결과:** 기존 `REV-P4-001`~`REV-P4-003` 보정이 유지되며 신규 확정 발견 사항 없음. Phase 4 후속 Task를 추가하지 않는다.
- **남은 항목:** 없음.

View File

@@ -0,0 +1,92 @@
# Phase 5 코드 리뷰 보고서
## 1. 리뷰 정보
| 항목 | 내용 |
|---|---|
| 리뷰 대상 | Phase 5 / Task 5.1~5.2 |
| 기준 commit 또는 working tree | `f1c2e6c5` + 2026-07-30 working tree |
| 리뷰 일자 | 2026-07-30 |
| 리뷰어 | Codex |
| 기준 문서 | `prd.md`, `plan-task.md`, 홈 API 구조 정렬 후속 문서, `docs/agent-guides/*.md` |
| 리뷰 상태 | 판정 완료 |
## 2. 목적과 범위
- endpoint·인증·`ApiResponse`·DTO mapping·JSON 필드 계약을 controller/DTO 테스트와 정적 대조했다.
- 사용자 지시에 따라 컴파일·테스트는 실행하지 않았다.
## 3. 검토 근거
- `CreatorChannelHomeControllerTest.kt:90-148`은 facade가 반환한 모든 섹션이 채워진 응답의 최상위·boolean·비노출 필드를 검증한다.
- `plan-task.md` Task 5.2 REFACTOR와 주의사항은 단건이 없으면 `null`, 목록이 없으면 빈 배열을 내려주도록 명시한다.
- 현재 controller 테스트에는 모든 nullable/목록 섹션이 빈 fixture의 JSON 직렬화 assertion이 없다.
## 4. 발견 사항
| ID | 심각도 | 상태 | 제목 | 소유 Task | 후속 goal |
|---|---|---|---|---|---|
| `REV-P5-001` | Low | 완료 | 빈 홈 응답의 null/빈 배열 계약이 테스트로 고정되지 않았다 | Task 5.3 | `P5-R1` |
### REV-P5-001 — 빈 홈 응답의 null/빈 배열 계약이 테스트로 고정되지 않았다
- **심각도:** Low
- **상태:** 확정
- **관련 요구사항:** Task 5.2 REFACTOR, 구현 중 주의사항의 빈 섹션 계약
- **소유 Task:** Task 5.3 / `P5-R1`
**관찰 내용**
DTO 타입은 nullability와 `List` 구조를 갖추고 있지만, 실제 Jackson 응답에서 null 필드가 존재하고 목록이 `[]`로 나가는지 검증하는 빈 응답 시나리오가 없다.
**영향**
현재 실행 결함을 확인한 것은 아니지만, Jackson 설정·DTO annotation·mapping 변경 시 클라이언트의 빈 화면 계약이 회귀해도 감지하지 못한다.
**권장 조치**
MockMvc에 최소 빈 fixture 한 개를 추가해 `currentLive`, `latestAudioContent`, `fanTalk.latestFanTalk`과 목록 6개의 JSON 값을 명시적으로 고정한다.
**판정 기록**
- 2026-07-30 — 계획의 명시적 계약과 현재 controller 테스트 범위를 대조해 확정.
- 2026-07-30 — `CreatorChannelHomeControllerTest`에 빈 홈 응답 null/빈 배열 JSON 계약 테스트를 추가하고 focused test 통과로 완료.
## 5. plan·goal 전환
- `plan-task.md` Phase 5에 Task 5.3 / `P5-R1`을 추가했다.
- RED가 바로 통과하면 생산 DTO는 변경하지 않고 회귀 테스트만 남기도록 범위를 제한했다.
## 6. 리뷰 종료 판정
| 판정 항목 | 결과 | 근거 |
|---|---|---|
| 리뷰 범위 전체 확인 | 충족 | controller·DTO·테스트 정적 대조 |
| 후보 항목 판정 완료 | 충족 | `REV-P5-001` 확정 |
| 확정 항목 plan 반영 | 충족 | Task 5.3 / `P5-R1` |
| 검증 명령과 결과 기록 | 충족 | 정적 검토만 실행, 테스트 미실행 사유 기록 |
**최종 결론:** 수정 goal 완료.
**남은 항목:** 없음.
## 7. 2차 리뷰 — 2026-07-30
- **대상:** 빈 홈 응답 보강 이후 controller 인증·endpoint·JSON 표면 계약.
- **방법:** controller, response factory, 채워진/빈 응답 MockMvc assertion을 정적 대조했다. 사용자 지시에 따라 테스트·컴파일·ktlint은 실행하지 않았다.
- **결과:** 신규 확정 발견 사항 없음. Phase 5 후속 Task를 추가하지 않는다.
- **남은 항목:** 없음.
## 8. 3차 리뷰 — 2026-07-31
- **대상:** 인증 controller, facade 응답 변환, Boolean 필드명, 내부 필드 비노출과 null/빈 배열 JSON 계약.
- **방법:** controller/response DTO와 채워진·빈 응답 MockMvc assertion을 Task 5.1~5.3과 정적 대조했다. 사용자 지시에 따라 테스트·컴파일·ktlint은 실행하지 않았다.
- **결과:** 신규 확정 발견 사항 없음. Phase 5 후속 Task를 추가하지 않는다.
- **남은 항목:** 없음.
## 9. 4차 리뷰 — 2026-07-31
- **대상:** 인증 endpoint, `ApiResponse.ok(...)`, facade 응답 변환, Boolean 이름과 null/빈 배열 JSON 계약.
- **방법:** controller/response DTO, 채워진·빈 응답 MockMvc assertion과 구조 정렬 후 공용 오디오 응답 필드를 Task 5.1~5.3과 정적 대조했다. 사용자 지시에 따라 테스트·컴파일·ktlint은 실행하지 않았다.
- **결과:** 기존 `REV-P5-001` 보정이 유지되며 신규 확정 발견 사항 없음. Phase 5 후속 Task를 추가하지 않는다.
- **남은 항목:** 없음.

View File

@@ -0,0 +1,94 @@
# Phase 6 코드 리뷰 보고서
## 1. 리뷰 정보
| 항목 | 내용 |
|---|---|
| 리뷰 대상 | Phase 6 / Task 6.1~6.3 |
| 기준 commit 또는 working tree | `f1c2e6c5` + 2026-07-30 working tree |
| 리뷰 일자 | 2026-07-30 |
| 리뷰어 | Codex |
| 기준 문서 | `prd.md`, `plan-task.md`, 홈 API 구조 정렬 후속 문서, `docs/agent-guides/*.md` |
| 리뷰 상태 | 판정 완료 |
## 2. 목적과 범위
- Task 6.1이 요구한 “PRD의 홈 전체 섹션이 한 요청에서 조립” 증거와 실제 테스트 계층/빈 경로를 대조했다.
- 추천 enum 회귀와 기존 검증 기록의 존재도 정적 확인했다.
- 사용자 지시에 따라 컴파일·테스트는 실행하지 않았다.
## 3. 검토 근거
- `CreatorChannelHomeControllerTest.kt:46-52``@WebMvcTest`와 mock `CreatorChannelHomeFacade`를 사용한다.
- `DefaultCreatorChannelHomeQueryRepositoryTest.kt:178-275``shouldFindCreatorChannelHomeIntegratedSections`는 동일 fixture로 repository method를 각각 직접 호출한다.
- 해당 repository 통합 시나리오는 후속 구조 정렬에서 공용 커뮤니티 service로 이동한 notices/communities를 포함하지 않는다.
- `CreatorChannelHomeEndToEndTest` 또는 같은 역할의 실제 홈 endpoint 통합 테스트는 존재하지 않는다.
## 4. 발견 사항
| ID | 심각도 | 상태 | 제목 | 소유 Task | 후속 goal |
|---|---|---|---|---|---|
| `REV-P6-001` | Low | 완료 | 한 HTTP 요청의 홈 전체 조립을 증명하는 통합 테스트가 없다 | Task 6.4 | `P6-R1` |
### REV-P6-001 — 한 HTTP 요청의 홈 전체 조립을 증명하는 통합 테스트가 없다
- **심각도:** Low
- **상태:** 확정
- **관련 요구사항:** Task 6.1 기대 결과
- **소유 Task:** Task 6.4 / `P6-R1`
**관찰 내용**
현재 증거는 “mock facade가 만든 domain을 controller가 JSON으로 변환”하는 테스트와 “한 fixture에서 repository method를 각각 호출”하는 테스트로 분리되어 있다. 따라서 controller→facade→service→repository/공용 서비스→JSON이 한 요청에서 연결되는지는 직접 증명되지 않는다.
**영향**
개별 계층 테스트가 통과해도 bean wiring, facade의 커뮤니티 조회 호출, 시간/context 전달, 전체 mapping 중 누락을 한 번에 검출하지 못한다. 이는 실행 결함 확정이 아니라 Task 6.1 완료 증거의 공백이다.
**권장 조치**
실제 Spring bean과 DB fixture를 사용해 인증된 단일 홈 요청을 보내고, 13개 상위 섹션의 대표 필드를 검증하는 통합 테스트를 추가한다.
**판정 기록**
- 2026-07-30 — 테스트 annotation, mock 경계, repository 호출 범위, E2E 파일 부재를 정적 대조해 확정.
- 2026-07-30 — `CreatorChannelHomeEndToEndTest`를 추가해 실제 bean 단일 HTTP 요청으로 전체 대표 섹션 JSON을 검증하고 완료.
## 5. plan·goal 전환
- `plan-task.md` Phase 6에 Task 6.4 / `P6-R1`을 추가했다.
- 통합 테스트가 바로 통과하면 생산 코드는 변경하지 않도록 범위를 제한했다.
## 6. 리뷰 종료 판정
| 판정 항목 | 결과 | 근거 |
|---|---|---|
| 리뷰 범위 전체 확인 | 충족 | controller·repository 통합 증거 대조 |
| 후보 항목 판정 완료 | 충족 | `REV-P6-001` 확정 |
| 확정 항목 plan 반영 | 충족 | Task 6.4 / `P6-R1` |
| 검증 명령과 결과 기록 | 충족 | 정적 검토만 실행, 테스트 미실행 사유 기록 |
**최종 결론:** 수정 goal 완료.
**남은 항목:** 없음.
## 7. 2차 리뷰 — 2026-07-30
- **대상:** 신규 `CreatorChannelHomeEndToEndTest`가 실제 bean 경로와 13개 섹션의 대표 응답을 연결하는지 확인했다.
- **방법:** fixture 생성 시각, HTTP 호출, JSON assertion과 기존 focused test 경계를 정적 대조했다. 사용자 지시에 따라 테스트·컴파일·ktlint은 실행하지 않았다.
- **결과:** Phase 6 자체의 신규 확정 발견 사항은 없다. E2E가 JVM 기본 timezone과 같은 `LocalDateTime.now()`를 사용해 UTC 오류를 가릴 수 있는 점은 원인 소유 Phase 4의 `REV-P4-003` / Task 4.5로 전환했으며 중복 Task를 만들지 않았다.
- **남은 항목:** Phase 6 없음. Task 4.5 완료 후 E2E 직접 영향만 재확인한다.
## 8. 3차 리뷰 — 2026-07-31
- **대상:** 실제 controller→facade→service→repository/공용 community 경로의 단일 HTTP 요청 통합 증거와 추천 enum 회귀 범위.
- **방법:** `CreatorChannelHomeEndToEndTest`의 UTC fixture·대표 13개 섹션 assertion, repository/service/controller 테스트 경계와 Task 6.1~6.4를 정적 대조했다. 사용자 지시에 따라 테스트·컴파일·ktlint은 실행하지 않았다.
- **결과:** `REV-P6-001`과 Phase 4 UTC 후속 수정 반영을 포함해 신규 확정 발견 사항 없음. Phase 6 후속 Task를 추가하지 않는다.
- **남은 항목:** 없음.
## 9. 4차 리뷰 — 2026-07-31
- **대상:** 단일 인증 HTTP 요청의 실제 bean 경로, UTC fixture, 홈 13개 섹션 대표 assertion과 추천 enum 회귀 증거.
- **방법:** E2E·repository·service·controller 테스트의 계층 경계와 현재 생산 코드 wiring을 Task 6.1~6.4와 정적 대조했다. 사용자 지시에 따라 테스트·컴파일·ktlint은 실행하지 않았다.
- **결과:** 기존 `REV-P6-001` 및 UTC 보정이 유지되며 신규 확정 발견 사항 없음. Phase 6 후속 Task를 추가하지 않는다.
- **남은 항목:** 없음.

View File

@@ -0,0 +1,172 @@
# Phase 7 코드 리뷰 보고서
## 1. 리뷰 정보
| 항목 | 내용 |
|---|---|
| 리뷰 대상 | Phase 7 / Task 7.1~7.2 / P7-GATE |
| 기준 commit 또는 working tree | `f1c2e6c5` + 2026-07-30 working tree |
| 리뷰 일자 | 2026-07-30 |
| 리뷰어 | Codex |
| 기준 문서 | `prd.md`, `plan-task.md`, `docs/agent-guides/*.md` |
| 리뷰 상태 | 판정 완료 |
## 2. 목적과 범위
- 활성 고정 9개 상한, 10번째 고정의 최고령 교체, 비활성 재활성화, 홈 오디오 고정 우선 정렬, 최신 오디오 제외을 코드·테스트·문서와 정적 대조했다.
- 사용자 지시에 따라 컴파일·테스트는 실행하지 않았고, Phase 7의 기존 Gate 성공 기록은 참고 증거로만 사용했다.
## 3. 검토 근거
- `AudioContentService.kt:1230-1261`은 transaction 안에서 현재 고정→활성 목록 순으로 읽고 추가·재활성화하지만 크리에이터 단위 lock을 취하지 않는다.
- `PinContent.kt:11-19`에는 `(member_id, content_id)` unique constraint가 없고, `PinContentRepository.kt:15-43`의 조회에도 pessimistic lock이 없다.
- `MemberRepository.kt:35-37`에는 이미 회원 행을 잠그는 `findByIdForUpdate` 패턴이 있다.
- `AudioContentServiceTest.kt:454-530`은 8/9개, 10번째 교체, 비활성 재활성화를 순차 mock 요청으로 검증하며 동시 요청은 다루지 않는다.
## 4. 발견 사항
| ID | 심각도 | 상태 | 제목 | 소유 Task | 후속 goal |
|---|---|---|---|---|---|
| `REV-P7-001` | Medium | 완료 | 동시 고정 요청이 활성 9개 상한을 넘거나 중복 행을 만들 수 있다 | Task 7.3 | `P7-R1` |
### REV-P7-001 — 동시 고정 요청이 활성 9개 상한을 넘거나 중복 행을 만들 수 있다
- **심각도:** Medium
- **상태:** 확정
- **관련 요구사항:** PRD Feature D/H, `DEC-001`, Task 7.1
- **소유 Task:** Task 7.3 / `P7-R1`
**관찰 내용**
활성 고정이 8개일 때 같은 크리에이터의 두 transaction이 동시에 목록을 읽으면 둘 다 `size < 9`로 판단해 서로 다른 행을 추가할 수 있다. 같은 콘텐츠에 대한 두 요청도 둘 다 `findByContentIdAndMemberId == null`을 관찰한 후 중복 행을 추가할 수 있다.
**정적 재현 절차**
1. 활성 고정 8개를 준비한다.
2. transaction A/B가 각각 서로 다른 콘텐츠에 대해 기존 고정이 없음과 활성 목록 8개를 읽는다.
3. A/B가 각각 새 `PinContent`를 저장하면 최종 활성 수는 10개가 된다.
4. 현재 코드·lock·constraint 중 이 interleaving을 차단하는 장치가 없다.
**영향**
동시 요청이라는 제한 조건에서 크리에이터별 활성 고정 9개 계약이 깨지고, 홈 정렬 join에 중복 행이 생기면 응답 개수·순서도 오염될 수 있다.
**권장 조치**
고정 상태를 읽기 전 크리에이터 행을 pessimistic write lock으로 직렬화하고, 새 DDL 없이 동시성 통합 테스트로 9개 상한과 콘텐츠 유일성을 고정한다. 홈 repository 테스트에는 9개 초과 fixture에서 반환 개수가 9임을 명시적으로 추가한다.
**판정 기록**
- 2026-07-30 — transaction 내 읽기-판단-쓰기 순서, lock 부재, unique constraint 부재를 정적 대조해 확정.
- 2026-07-30 — `AudioContentService.pinToTheTop`에 creator member row lock을 추가하고 `AudioContentPinConcurrencyTest`/`AudioContentServiceTest` 통과로 완료. Reviewer gate에서 요구한 홈 목록 9개 상한 assertion은 `DefaultCreatorChannelHomeQueryRepositoryTest`에 추가해 통과 확인.
## 5. plan·goal 전환
- `plan-task.md` Phase 7에 Task 7.3 / `P7-R1`을 추가했다.
- 기존 Task 7.1~7.2과 P7-GATE의 완료 이력은 유지하고 리뷰 후속 goal만 추가했다.
## 6. 리뷰 종료 판정
| 판정 항목 | 결과 | 근거 |
|---|---|---|
| 리뷰 범위 전체 확인 | 충족 | 고정 service·entity·repository·테스트 정적 대조 |
| 후보 항목 판정 완료 | 충족 | `REV-P7-001` 확정 |
| 확정 항목 plan 반영 | 충족 | Task 7.3 / `P7-R1` |
| 검증 명령과 결과 기록 | 충족 | 정적 검토만 실행, 테스트 미실행 사유 기록 |
**최종 결론:** 수정 goal 완료.
**남은 항목:** 없음.
## 7. 2차 리뷰 — 2026-07-30
### 7.1 리뷰 범위와 방법
- Task 7.3의 member row lock 호출 순서와 동시성 테스트가 MySQL 운영 격리수준에서도 직렬화를 보장하는지 정적 검토했다.
- MySQL 공식 InnoDB `REPEATABLE READ`의 consistent read/locking read 의미를 근거로 대조했다.
- 사용자 지시에 따라 테스트·컴파일·ktlint은 실행하지 않았다.
### 7.2 발견 사항
| ID | 심각도 | 상태 | 제목 | 소유 Task | 후속 goal |
|---|---|---|---|---|---|
| `REV-P7-002` | Medium | 완료 | 크리에이터 lock 전에 일반 조회가 snapshot을 만들 수 있다 | Task 7.4 | `P7-R2` |
#### REV-P7-002 — 크리에이터 lock 전에 일반 조회가 snapshot을 만들 수 있다
- **관련 요구사항:** PRD Feature D/H, Task 7.3
- **관찰:** `pinToTheTop``repository.findByIdAndCreatorId` 일반 조회 후 `memberRepository.findByIdForUpdate`를 호출하고, 그 뒤 `PinContent`를 일반 조회한다. MySQL InnoDB 기본 `REPEATABLE READ`에서는 첫 consistent read가 snapshot을 정하고 locking read는 최신 행을 읽으므로 두 방식을 섞으면 후속 일반 조회가 lock 대기 전 snapshot을 계속 사용할 수 있다. [MySQL 8.0 Reference Manual](https://dev.mysql.com/doc/refman/8.0/en/innodb-transaction-isolation-levels.html)
- **영향:** 두 번째 transaction이 member lock을 기다린 뒤에도 첫 transaction의 최신 고정 변경을 보지 못해 9개 상한이나 콘텐츠 중복 방지 판단이 stale 상태를 기준으로 수행될 수 있다.
- **검증 공백:** 현재 단위 테스트는 member lock이 `PinContent` 조회보다 빠른지만 확인하고 콘텐츠 일반 조회는 순서 검증에 포함하지 않는다. 동시성 테스트의 시작 latch도 두 transaction의 고정 목록 읽기 시점을 강제하지 않아 이 interleaving을 보장하지 않는다.
- **권장 조치:** transaction의 첫 DB 접근에서 member row lock을 잡고 모든 일반 조회를 그 뒤로 옮기며, 호출 순서를 단위 테스트로 고정한다.
- **완료 기록:** 2026-07-30 — `AudioContentServiceTest``inOrder` RED/GREEN으로 lock이 콘텐츠/고정 조회보다 먼저 호출됨을 고정했다.
### 7.3 plan·goal 전환과 종료 판정
- `plan-task.md` Phase 7에 Task 7.4 / `P7-R2`를 추가했다.
- **최종 결론:** Medium 1건 수정 완료.
- **남은 항목:** 없음.
## 8. 3차 리뷰 — 2026-07-31
### 8.1 리뷰 범위와 방법
- Task 7.3~7.4 후속 구현이 같은 크리에이터의 모든 상단 고정 변경을 실제로 직렬화하는지 `pinToTheTop`, `unpinAtTheTop`, member lock, `PinContent` 행 재사용 흐름을 정적 검토했다.
- 기존 단위·동시성 테스트가 고정과 해제의 경쟁을 포함하는지도 함께 확인했다.
- 사용자 지시에 따라 테스트·컴파일·ktlint은 실행하지 않았다.
### 8.2 발견 사항
| ID | 심각도 | 상태 | 제목 | 소유 Task | 후속 goal |
|---|---|---|---|---|---|
| `REV-P7-003` | Medium | 수정 완료 | 고정 해제가 creator lock을 공유하지 않아 새 고정을 소실할 수 있다 | Task 7.5 | `P7-R3` |
#### REV-P7-003 — 고정 해제가 creator lock을 공유하지 않아 새 고정을 소실할 수 있다
- **심각도:** Medium
- **상태:** 수정 완료
- **관련 요구사항:** PRD Feature H, Task 7.1·7.3·7.4
- **소유 Task:** Task 7.5 / `P7-R3`
**관찰 내용**
`pinToTheTop`은 첫 DB 접근에서 `MemberRepository.findByIdForUpdate`로 크리에이터를 잠근 뒤, 활성 고정이 9개이면 가장 오래된 `PinContent` 행의 `content`를 새 콘텐츠로 바꿔 재사용한다. 반면 `unpinAtTheTop`은 같은 member lock 없이 이전 콘텐츠로 `PinContent`를 조회하고 `isActive = false`로 변경한다.
**정적 재현 절차**
1. 활성 고정 9개에서 가장 오래된 고정 콘텐츠를 A, 새 고정 콘텐츠를 B로 둔다.
2. 해제 transaction이 A의 `PinContent`를 먼저 읽은 뒤 commit 전 대기한다.
3. 고정 transaction이 member lock을 얻고 같은 행을 B의 활성 고정으로 재사용해 commit한다.
4. 해제 transaction이 늦게 commit하면 이전 A를 가리키던 stale entity update가 재사용된 행을 다시 비활성화하거나 이전 상태로 덮을 수 있다.
**근거**
- 코드: `AudioContentService.pinToTheTop`은 member lock과 최고령 `PinContent` 행 재사용을 수행한다.
- 코드: `AudioContentService.unpinAtTheTop`은 member lock 없이 `PinContent`를 조회·비활성화한다.
- 테스트: `AudioContentPinConcurrencyTest`는 고정 요청 2개의 경쟁만 검증하고 고정/해제 경쟁은 다루지 않는다.
**영향**
겹친 두 요청의 순서에 따라 성공한 새 고정 B가 홈 `audioContents`에서 사라지거나 재사용 행 상태가 요청 완료 순서와 다르게 남을 수 있다. 활성 9개 상한 자체를 초과하지는 않지만 상단 고정 상태의 일관성이 깨진다.
**권장 조치**
`unpinAtTheTop`도 첫 DB 접근에서 `pinToTheTop`과 같은 member row lock을 획득한 뒤 해제 대상을 조회한다. 최소 회귀 테스트로 member lock이 `PinContent` 조회보다 먼저 호출되는 순서를 고정하고 기존 동시 고정·10번째 교체 테스트를 함께 유지한다.
**판정 기록**
- 2026-07-31 — 고정 행 재사용, 해제의 lock 부재, 현재 동시성 테스트 범위를 정적 대조해 확정했다.
- 2026-07-31 — `unpinAtTheTop`의 creator lock 선행 획득을 RED/GREEN으로 보정하고, Phase 7 직접 영향 단위 테스트와 `ktlintCheck`, `git diff --check` 통과를 확인했다.
### 8.3 plan·goal 전환과 종료 판정
- `plan-task.md` Phase 7에 Task 7.5 / `P7-R3`을 추가했다.
- **최종 결론:** Medium 1건 수정 완료.
- **남은 항목:** 없음.
## 9. 4차 리뷰 — 2026-07-31
- **대상:** 활성 고정 9개 상한, 10번째·비활성 재고정, 홈 고정 우선 정렬, 최신 오디오 제외와 고정/해제 creator lock 직렬화.
- **방법:** `AudioContentService`, member pessimistic lock, `PinContent` 조회 순서, 홈 repository 정렬과 단위·동시성·repository 테스트를 Task 7.1~7.5 및 기존 `REV-P7-001`~`REV-P7-003`과 정적 대조했다. 사용자 지시에 따라 테스트·컴파일·ktlint은 실행하지 않았다.
- **결과:** 기존 동시성 보정이 유지되며 신규 확정 발견 사항 없음. Phase 7 후속 Task를 추가하지 않는다.
- **남은 항목:** 없음.

View File

@@ -10,6 +10,23 @@
---
## 현재 후속 작업 상태
| Phase | 상태 | 완료 Task | 활성/다음 Goal | 차단 또는 남은 조건 |
|---:|---|---:|---|---|
| 1~2 | 완료 | 기존 Task 전체 | 없음 | 없음 |
| 3 | 완료 | 기존 `6/6`, 회귀 `1/1` | 없음 | 없음 |
| 4 | 완료 | 기존 `5/5`, 회귀 `2/2` | 없음 | 없음 |
| 5~5.5 | 완료 | 기존 Task 전체 | 없음 | 없음 |
| 6 | 완료 | 기존 `2/2`, 회귀 `2/2` | 없음 | 없음 |
| 7 | 완료 | 기존 `1/1`, 회귀 `2/2` | 없음 | 없음 |
- 2026-07-30 1차 Phase별 리뷰의 확정 항목은 `P3-R1``P3-R-GATE``P7-R1``P7-R2``P7-R-GATE` 순서로 실행 완료했다.
- 2026-07-30 2차 정적 리뷰의 후속 순서 `P4-R1``P4-R-GATE``P6-R1``P6-R-GATE`는 실행 완료했다.
- 2026-07-30 3차 정적 리뷰의 후속 순서 `P4-R2``P4-R2-GATE``P6-R2``P6-R2-GATE`는 실행 완료했다.
---
## 0. 확정 사항
- API endpoint: `GET /api/v2/home/following`
@@ -30,6 +47,11 @@
- 최근 소식 상세 값은 타입별 nullable nested DTO로 내려준다. `type`과 일치하는 nested DTO만 non-null이고 나머지는 `null`이다.
- `CREATOR_RANKING``creatorRanking.rank`, `creatorRanking.creatorId`, `creatorRanking.nickname`, `creatorRanking.profileImageUrl`을 사용한다. `rankChange`, `isNew`는 사용하지 않는다.
- `CONTENT_RANKING``contentRanking.rank`, `contentRanking.contentId`, `contentRanking.contentImageUrl`, `contentRanking.title`을 사용한다.
- `CREATOR_RANKING`은 현재 시점에 공개된 최신 `WEEKLY`, `DONE` 크리에이터 랭킹 job 기준 배치만 최근 소식에 표시한다. 신규 배치 공개 전에는 직전 공개 배치를 유지하고, 공개 후에는 이전 배치를 표시하지 않는다.
- 최신 공개 배치에 포함되지 않은 팔로잉 크리에이터의 과거 `CREATOR_RANKING`은 보충하지 않는다.
- `CONTENT_RANKING`은 같은 `contentId`의 노출 가능한 row 중 `visibleFromAtUtc desc`, `newsId desc` 기준 최신 항목 하나만 표시한다.
- 랭킹 배치 필터와 콘텐츠 중복 제거를 먼저 적용한 뒤 전체 최근 소식 최대 30개를 조회하며, 제외 후 30개 미만이어도 과거·중복 랭킹으로 보충하지 않는다.
- `CONTENT_RANKING` inbox 발행과 콘텐츠 랭킹 스냅샷 연동은 이번 보완 범위에 포함하지 않는다.
- `AUDIO_CONTENT`, `PHOTO_CONTENT`는 각각 `audioContent`/`photoContent``contentId`, `contentImageUrl`, `title`, `creatorProfileImageUrl`, `creatorNickname`을 담고, 공개 시각은 최상위 `visibleFromAtUtc`를 사용한다.
- `COMMUNITY_POST``communityPost``postId`, `creatorProfileImage`, `creatorNickname`, nullable `imageUrl`, `content`, UTC `createdAt`, `likeCount`, `commentCount`를 담는다.
- `COMMUNITY_POST` 최근 소식은 무료 커뮤니티 게시글만 발행한다. 유료 커뮤니티 게시글은 inbox row를 생성하지 않는다.
@@ -90,6 +112,11 @@
- Keep: `docs/20260625_메인_홈_팔로잉_탭_API/create-home-following-news-inbox-table.sql`
- Modify: `docs/20260625_메인_홈_팔로잉_탭_API/plan-task.md`
### Phase 7 후속 보완
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/home/following/adapter/out/persistence/DefaultHomeFollowingQueryRepository.kt`
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/home/following/adapter/out/persistence/DefaultHomeFollowingQueryRepositoryTest.kt`
- Verify: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/home/following/adapter/in/web/HomeFollowingEndToEndTest.kt`
---
## 2. Response data class 초안
@@ -565,6 +592,48 @@ data class HomeFollowingNewsInboxRecord(
- 통과 확인: 같은 단일 테스트 명령 실행, PASS 확인.
- REFACTOR: mock 기반 race 테스트와 통합 테스트의 책임을 분리해, mock은 분기 검증만 하고 통합 테스트는 실제 Hibernate 세션/트랜잭션 유효성을 검증하도록 정리한다.
- [x] **Task 3.7: 라이브 성별·크리에이터 입장 제한 회귀 수정**
**Goal 실행 `P3-R1`:** `REV-P3-001`에 따라 On Air와 라이브 스케줄에서 기존 라이브 입장 제한을 동일하게 적용한다.
- **시작 조건:** `reviews/phase-3-review.md``REV-P3-001` 확정, 기존 Task 3.1~3.6 완료.
- **완료 증거:** 제한 불일치 재현 테스트의 RED 확인, 최소 구현 후 focused test와 E2E 통과, 검증 기록 누적.
- **범위 밖:** 공개 응답 스키마 변경, 오디오 스케줄 정책 변경, 라이브 입장 정책 자체의 재정의.
- **Files:**
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/home/following/port/out/HomeFollowingQueryPort.kt`
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/home/following/application/HomeFollowingQueryService.kt`
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/home/following/adapter/out/persistence/DefaultHomeFollowingQueryRepository.kt`
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/home/following/application/HomeFollowingQueryServiceTest.kt`
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/home/following/adapter/out/persistence/DefaultHomeFollowingQueryRepositoryTest.kt`
- Verify: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/home/following/adapter/in/web/HomeFollowingEndToEndTest.kt`
- [x] **RED:** 회원 본인인증 성별을 우선한 effective gender와 크리에이터 회원 여부가 query port에 전달되는 테스트를 추가하고 실패를 확인한다.
- [x] **RED:** 성별 제한이 맞지 않는 라이브와 `isAvailableJoinCreator=false`인 타 크리에이터 라이브가 On Air 및 라이브 스케줄에서 제외되는 repository 테스트를 추가하고 실패를 확인한다.
- [x] **GREEN:** 기존 `LiveRoomQueryRepositoryImpl`/`DefaultCreatorChannelHomeQueryRepository`
`genderRestriction` 및 크리에이터 입장 제한 조건을 재사용 가능한 최소 QueryDSL 조건으로 적용한다.
- [x] **GREEN 확인:** 아래 focused test를 실행해 신규 회귀와 기존 섹션 조회가 통과하는지 확인한다.
```bash
./gradlew --no-daemon test --tests "kr.co.vividnext.sodalive.v2.home.following.application.HomeFollowingQueryServiceTest"
./gradlew --no-daemon test --tests "kr.co.vividnext.sodalive.v2.home.following.adapter.out.persistence.DefaultHomeFollowingQueryRepositoryTest"
```
- [x] **REFACTOR:** live 조건만 정리하고 공개 DTO와 최근 소식 로직은 변경하지 않는다.
#### Phase 3 리뷰 회귀 Gate
**Goal 실행 `P3-R-GATE`:** Phase 3의 라이브 입장 정책 수정과 팔로잉 탭 조립 회귀를 최종 판정한다.
- **시작 조건:** `P3-R1` 완료.
- **완료 증거:** 아래 명령이 모두 `BUILD SUCCESSFUL`이고 결과가 `## 6. 검증 기록`과 `reviews/phase-3-review.md`에 누적됨.
- **범위 밖:** 전체 회귀 실패와 무관한 코드 수정, 테스트 삭제·완화.
```bash
./gradlew --no-daemon test --tests "kr.co.vividnext.sodalive.v2.home.following.application.HomeFollowingQueryServiceTest"
./gradlew --no-daemon test --tests "kr.co.vividnext.sodalive.v2.home.following.adapter.out.persistence.DefaultHomeFollowingQueryRepositoryTest"
./gradlew --no-daemon test --tests "kr.co.vividnext.sodalive.v2.api.home.following.adapter.in.web.HomeFollowingEndToEndTest"
./gradlew --no-daemon ktlintCheck
```
### Phase 4: 최근 소식 Publish Service와 기존 이벤트 연결
- [x] **Task 4.1: sourceKey 생성 정책 구현**
@@ -631,6 +700,96 @@ data class HomeFollowingNewsInboxRecord(
- 통과 확인: 위 두 단일 테스트 명령 재실행, PASS 확인.
- REFACTOR: 결제/수정/관리자 저장 중 실제 공개 이벤트가 아닌 경로에서 중복 발행하지 않도록 sourceKey unique와 호출 지점을 함께 점검한다.
- [x] **Task 4.6: 언팔로우·재팔로우와 최근 소식 발행 동시성 보장**
**Goal 실행 `P4-R1`:** `REV-P4-001`에 따라 follower 판정부터 inbox insert까지의 경계를 팔로우 상태 변경과 직렬화해, 언팔로우 이전 이벤트가 재팔로우 후 새 소식으로 노출되지 않게 한다.
- **시작 조건:** `reviews/phase-4-review.md`의 `REV-P4-001` 확정, 기존 Task 4.1~4.5 완료.
- **완료 증거:** 두 트랜잭션을 제어한 회귀 테스트의 RED 확인, 최소 구현 후 focused test 통과, 실제 결과를 `## 6. 검증 기록`과 `reviews/phase-4-review.md`에 누적.
- **범위 밖:** 외부 MQ/outbox/worker 도입, inbox 공개 API·DDL 변경, 팔로우 알림 정책 변경, 관련 없는 publish 경로 리팩터링.
- **Files:**
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/home/following/application/HomeFollowingNewsPublishService.kt`
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/home/following/port/out/HomeFollowingNewsInboxPort.kt`
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/home/following/adapter/out/persistence/HomeFollowingNewsInboxJpaRepository.kt`
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/home/following/adapter/out/persistence/HomeFollowingNewsInboxPersistenceAdapter.kt`
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/member/following/CreatorFollowingRepository.kt`
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/member/MemberService.kt`
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/home/following/adapter/out/persistence/HomeFollowingNewsInboxPersistenceAdapterTest.kt`
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/home/following/application/HomeFollowingNewsPublishServiceTest.kt`
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/member/MemberServiceTest.kt`
- Verify: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/home/following/adapter/in/web/HomeFollowingEndToEndTest.kt`
- [x] **RED:** publish가 active follower를 읽은 뒤 insert하기 전에 같은 회원이 언팔로우하는 순서를 두 트랜잭션과 latch로 고정한다. 언팔로우 완료 후 publish insert가 늦게 완료되고 다시 팔로우하면 언팔로우 이전 이벤트가 조회되는 현재 실패를 재현한다.
- [x] **GREEN:** follower 판정과 inbox insert를 하나의 트랜잭션 경계로 묶고 해당 `creator_following` row를 팔로우/언팔로우 상태 변경과 동일한 잠금 순서로 직렬화한다. publish가 먼저 완료되면 뒤이은 언팔로우가 새 row까지 비활성화하고, 언팔로우가 먼저 완료되면 publish가 해당 회원 row를 생성하지 않아야 한다.
- [x] **GREEN:** 재팔로우는 기존 비활성 inbox를 복구하지 않으며, 재팔로우 이후 발생한 새 이벤트만 active row로 생성되는 기존 정책을 유지한다.
- [x] **GREEN 확인:** 아래 focused test를 실행해 동시성 회귀와 기존 중복 방지·발행·언팔로우 테스트가 모두 통과하는지 확인한다.
```bash
./gradlew --no-daemon test --tests "kr.co.vividnext.sodalive.v2.home.following.adapter.out.persistence.HomeFollowingNewsInboxPersistenceAdapterTest" --tests "kr.co.vividnext.sodalive.v2.home.following.application.HomeFollowingNewsPublishServiceTest" --tests "kr.co.vividnext.sodalive.member.MemberServiceTest"
```
- [x] **REFACTOR:** H2/MySQL에서 검증 가능한 기존 JPA 경로와 공개 port 범위를 유지하고, 동시성 보장에 필요하지 않은 계층·설정·DDL을 추가하지 않는다.
#### Phase 4 Review Gate
**Goal 실행 `P4-R-GATE`:** `P4-R1`의 동시성 보장과 기존 최근 소식 발행·조회 계약을 최종 판정한다.
- **시작 조건:** `P4-R1` 완료.
- **완료 증거:** 아래 명령이 모두 `BUILD SUCCESSFUL`이고 결과가 `## 6. 검증 기록`과 `reviews/phase-4-review.md`에 누적됨.
- **범위 밖:** 테스트 삭제·완화, 공개 API·DDL 확장, 이번 회귀와 무관한 코드 수정.
```bash
./gradlew --no-daemon test --tests "kr.co.vividnext.sodalive.v2.home.following.adapter.out.persistence.HomeFollowingNewsInboxPersistenceAdapterTest" --tests "kr.co.vividnext.sodalive.v2.home.following.application.HomeFollowingNewsPublishServiceTest" --tests "kr.co.vividnext.sodalive.member.MemberServiceTest"
./gradlew --no-daemon test --tests "kr.co.vividnext.sodalive.v2.api.home.following.adapter.in.web.HomeFollowingEndToEndTest"
./gradlew --no-daemon ktlintCheck
```
**Expected:** publish와 언팔로우의 완료 순서와 무관하게 최종 언팔로우 상태에서는 해당 creator의 active inbox가 없고, 재팔로우 후에는 재팔로우 이후 이벤트만 노출된다.
- 전체 `./gradlew test`는 follower fan-out과 팔로우 상태 변경 경계만 보완하는 국소 수정이므로 기본 Gate에서 생략한다. focused test 또는 E2E가 공유 트랜잭션 경계의 회귀를 충분히 판정하지 못하면 전체 회귀로 확장하고 근거와 결과를 기록한다.
- [x] **Task 4.7: 통합 팔로우 API의 `isActive=false` 경로에서 inbox 비활성화**
**Goal 실행 `P4-R2`:** `REV-P4-002`에 따라 `creatorFollow(..., isActive=false)`가 새 active 팔로우를 만들지 않고, 기존 관계의 active inbox를 비활성화해 재팔로우 후 과거 소식이 다시 노출되지 않게 한다.
- **시작 조건:** `reviews/phase-4-review.md`의 `REV-P4-002` 확정, `P4-R1`과 `P4-R-GATE` 완료.
- **완료 증거:** 통합 팔로우 API 경로의 회귀 테스트 RED 확인, 최소 구현 후 `MemberServiceTest` 통과, Phase 4 회귀 Gate와 검증 기록 누적.
- **범위 밖:** `CreatorFollowRequest`·controller 공개 스키마 변경, 알림 설정 정책 변경, 외부 MQ/outbox 도입, 전용 `creatorUnFollow(...)` 경로 리팩터링.
- **Files:**
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/member/MemberService.kt`
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/member/MemberServiceTest.kt`
- Verify: `src/main/kotlin/kr/co/vividnext/sodalive/member/MemberController.kt`
- Verify: `src/main/kotlin/kr/co/vividnext/sodalive/member/following/CreatorFollowRequest.kt`
- **Interfaces:**
- Consumes: `MemberService.creatorFollow(creatorId: Long, isNotify: Boolean, isActive: Boolean, memberId: Long)`, `HomeFollowingNewsInboxPort.deactivateByMemberIdAndCreatorId(memberId: Long, creatorId: Long)`.
- Produces: 기존 public method·request 계약을 바꾸지 않고 `isActive=false`일 때 전용 언팔로우 경로와 같은 inbox 최종 상태.
- [x] **RED:** 관계가 없는 회원이 `creatorFollow(..., isActive=false)`를 호출해도 새 active 팔로우가 생성되지 않는지 검증한다.
- [x] **RED:** active 팔로우와 active inbox를 준비한 뒤 `creatorFollow(..., isActive=false)`를 호출하고 관계와 inbox가 모두 inactive인지 검증한다. 이어 `creatorFollow(..., isActive=true)`를 호출해 기존 inbox가 inactive로 유지되는지 검증한다.
- [x] **RED 확인:** 아래 단일 테스트를 실행해 `creatorFollow(..., isActive=false)` 직후 inbox가 여전히 active인 assertion 실패를 확인한다.
```bash
./gradlew --no-daemon test --tests "kr.co.vividnext.sodalive.member.MemberServiceTest"
```
- [x] **GREEN:** 관계가 없고 `isActive=false`면 전용 `creatorUnFollow(...)`와 동일하게 새 active 관계를 만들지 않는다. 기존 `creator_following` row에 `isActive=false`를 반영하는 같은 트랜잭션에서는 `homeFollowingNewsInboxPort.deactivateByMemberIdAndCreatorId(...)`를 호출한다. `isActive=true`인 알림 변경·재팔로우 경로에서는 기존 비활성 inbox를 복구하지 않는다.
- [x] **GREEN 확인:** 같은 `MemberServiceTest` 명령을 재실행해 전용 언팔로우와 통합 팔로우 API 경로의 회귀가 모두 통과하는지 확인한다.
- [x] **REFACTOR:** 두 공개 method의 계약과 비관적 잠금 순서를 유지하고, 공통화가 한 번만 쓰이는 추상화나 신규 계층은 추가하지 않는다.
#### Phase 4 3차 리뷰 회귀 Gate
**Goal 실행 `P4-R2-GATE`:** `P4-R2`의 통합 언팔로우 경로와 기존 publish·언팔로우 동시성 계약을 최종 판정한다.
- **시작 조건:** `P4-R2` 완료.
- **완료 증거:** 아래 명령이 모두 `BUILD SUCCESSFUL`이고 결과가 `## 6. 검증 기록`과 `reviews/phase-4-review.md`에 누적됨.
- **범위 밖:** 테스트 삭제·완화, 공개 API·DDL 변경, 이번 회귀와 무관한 코드 수정.
```bash
./gradlew --no-daemon test --tests "kr.co.vividnext.sodalive.member.MemberServiceTest"
./gradlew --no-daemon test --tests "kr.co.vividnext.sodalive.v2.home.following.adapter.out.persistence.HomeFollowingNewsInboxPersistenceAdapterTest" --tests "kr.co.vividnext.sodalive.v2.home.following.application.HomeFollowingNewsPublishServiceTest"
./gradlew --no-daemon ktlintCheck
```
**Expected:** 전용 언팔로우와 `creatorFollow(..., isActive=false)` 어느 경로를 사용해도 새 active 팔로우가 생기지 않고 기존 active inbox가 비활성화되며, 재팔로우는 과거 inbox를 복구하지 않는다.
### Phase 5: Facade 통합, 최근 대화 재사용, API End-to-End
- [x] **Task 5.1: HomeFollowingFacade 통합**
@@ -772,6 +931,178 @@ data class HomeFollowingNewsInboxRecord(
- 기대 결과: 두 명령 모두 `BUILD SUCCESSFUL`
- 검증 결과 기록: 각 task 완료 시 실행 명령, 결과, 실패 시 원인과 후속 조치를 이 문서의 해당 task 아래에 한국어로 누적 기록한다.
- [x] **Task 6.3: `newsId` 공개 계약을 현재 구현과 동기화**
**Goal 실행 `P6-R1`:** `REV-P6-001`에 따라 `newsId`가 `home_following_news_inbox.id`의 10진 문자열임을 PRD에 명시하고, `scheduleId`의 `{TYPE}:{targetId}` 계약과 분리한다.
- **시작 조건:** `reviews/phase-6-review.md`의 `REV-P6-001` 확정, 현재 repository/test/E2E의 `newsId` 동작 확인.
- **완료 증거:** PRD 식별자 정책과 최근 소식 정렬·동률 해소 설명이 현재 구현과 일치하고, 대체 검증 결과를 `## 6. 검증 기록`과 `reviews/phase-6-review.md`에 누적.
- **범위 밖:** 공개 응답 필드 추가·삭제, runtime 코드·테스트·DDL 변경, 기존 `newsId` 값 형식 변경.
- **Files:**
- Modify: `docs/20260625_메인_홈_팔로잉_탭_API/prd.md`
- Modify: `docs/20260625_메인_홈_팔로잉_탭_API/plan-task.md`
- Verify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/home/following/adapter/out/persistence/DefaultHomeFollowingQueryRepository.kt`
- Verify: `src/test/kotlin/kr/co/vividnext/sodalive/v2/home/following/adapter/out/persistence/DefaultHomeFollowingQueryRepositoryTest.kt`
- Verify: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/home/following/adapter/in/web/HomeFollowingEndToEndTest.kt`
- **TDD 예외 사유:** 현재 공개 동작을 변경하지 않는 문서 계약 정합성 보완이며 신규 production behavior가 없다.
- [x] `prd.md`의 공통 식별자 설명에서 `scheduleId`만 `{TYPE}:{targetId}` 형식으로 유지하고, `newsId`는 inbox PK의 10진 문자열이며 같은 노출 시각의 정렬·`CONTENT_RANKING` 동률 해소에 사용한다고 명시한다.
- [x] 아래 검색으로 PRD, repository, repository test, E2E의 `newsId` 형식을 정적으로 대조한다.
```bash
rg -n "newsId|scheduleId|home_following_news_inbox\\.id" docs/20260625_메인_홈_팔로잉_탭_API/prd.md src/main/kotlin/kr/co/vividnext/sodalive/v2/home/following/adapter/out/persistence/DefaultHomeFollowingQueryRepository.kt src/test/kotlin/kr/co/vividnext/sodalive/v2/home/following/adapter/out/persistence/DefaultHomeFollowingQueryRepositoryTest.kt src/test/kotlin/kr/co/vividnext/sodalive/v2/api/home/following/adapter/in/web/HomeFollowingEndToEndTest.kt
git diff --check
```
- [x] 문서 명령 유효성만 확인하는 `./gradlew tasks --all`을 실행해 `BUILD SUCCESSFUL`을 확인한다. 컴파일과 테스트는 실행하지 않는다.
#### Phase 6 Review Gate
**Goal 실행 `P6-R-GATE`:** `P6-R1`의 식별자 계약 정합성과 공개 동작 무변경을 최종 판정한다.
- **시작 조건:** `P6-R1` 완료.
- **완료 증거:** 정적 검색과 `git diff --check`, `./gradlew tasks --all` 결과가 `## 6. 검증 기록`과 `reviews/phase-6-review.md`에 누적됨.
- **범위 밖:** 컴파일·테스트 재실행, runtime 코드·테스트·DDL 변경.
**Expected:** `scheduleId`는 `{TYPE}:{targetId}`, `newsId`는 inbox PK 10진 문자열로 문서와 현재 구현이 일치하며 API 응답 동작은 변경되지 않는다.
- [x] **Task 6.4: 크리에이터 랭킹 최신 공개 배치 기준 문서 동기화**
**Goal 실행 `P6-R2`:** `REV-P6-002`에 따라 PRD의 최신 `CREATOR_RANKING` 배치 판정 기준을 현재 구현의 `WEEKLY`, `DONE` job 우선·legacy snapshot 제한 fallback 정책과 일치시킨다.
- **시작 조건:** `reviews/phase-6-review.md`의 `REV-P6-002` 확정, `P7-R1`과 `P7-R-GATE` 완료.
- **완료 증거:** PRD Feature F와 기술 제약의 최신 배치 설명이 Task 7.2 및 repository 조건과 일치하고, 정적 검색 결과를 `## 6. 검증 기록`과 `reviews/phase-6-review.md`에 누적.
- **범위 밖:** runtime 조회 조건·테스트·DDL·공개 응답 변경, 랭킹 집계·점수·공개 시각 정책 변경.
- **Files:**
- Modify: `docs/20260625_메인_홈_팔로잉_탭_API/prd.md`
- Modify: `docs/20260625_메인_홈_팔로잉_탭_API/plan-task.md`
- Verify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/home/following/adapter/out/persistence/DefaultHomeFollowingQueryRepository.kt`
- Verify: `src/test/kotlin/kr/co/vividnext/sodalive/v2/home/following/adapter/out/persistence/DefaultHomeFollowingQueryRepositoryTest.kt`
- **TDD 예외 사유:** 현재 공개 동작을 바꾸지 않는 문서 정합성 보완이며 신규 production behavior가 없다.
- [x] `prd.md`의 두 최신 공개 배치 설명을 적용 가능한 `creator_ranking_snapshot_job`의 최신 `WEEKLY`, `DONE`, `visibleFromAtUtc <= nowUtc` 시각 우선으로 수정한다.
- [x] 적용 가능한 `DONE` job이 전혀 없는 legacy/backfill 데이터에서만 최신 공개 snapshot 시각을 fallback으로 사용하고, 최신 완료 job의 결과가 0건이면 과거 snapshot으로 보충하지 않는다고 명시한다.
- [x] 아래 검색으로 PRD, Task 7.2, repository, 빈 최신 배치 회귀 테스트를 정적으로 대조한다.
```bash
rg -n "creator_ranking_snapshot_job|creator_ranking_snapshot|최신 공개 배치|WEEKLY|DONE|legacy|fallback" docs/20260625_메인_홈_팔로잉_탭_API/prd.md docs/20260625_메인_홈_팔로잉_탭_API/plan-task.md src/main/kotlin/kr/co/vividnext/sodalive/v2/home/following/adapter/out/persistence/DefaultHomeFollowingQueryRepository.kt src/test/kotlin/kr/co/vividnext/sodalive/v2/home/following/adapter/out/persistence/DefaultHomeFollowingQueryRepositoryTest.kt
git diff --check
```
- [x] 문서 명령 유효성만 확인하는 `./gradlew tasks --all`을 실행해 `BUILD SUCCESSFUL`을 확인한다. 컴파일과 테스트는 실행하지 않는다.
#### Phase 6 3차 리뷰 회귀 Gate
**Goal 실행 `P6-R2-GATE`:** `P6-R2`의 최신 공개 배치 문서 정합성과 runtime 무변경을 최종 판정한다.
- **시작 조건:** `P6-R2` 완료.
- **완료 증거:** 정적 검색과 `git diff --check`, `./gradlew tasks --all` 결과가 `## 6. 검증 기록`과 `reviews/phase-6-review.md`에 누적됨.
- **범위 밖:** 컴파일·테스트 실행, runtime 코드·테스트·DDL 변경.
**Expected:** PRD가 최신 완료 job의 빈 배치를 포함한 현재 판정과 일치하고, 적용 가능한 `DONE` job이 없는 경우에만 snapshot fallback을 허용한다.
### Phase 7: 최근 소식 랭킹 조회 정책 보완
**Phase 결과:** 최근 소식에서 크리에이터 랭킹은 최신 공개 배치만, 콘텐츠 랭킹은 동일 콘텐츠의 최신 소식 하나만 표시된다.
**선행조건:** Phase 1~6 완료와 2026-07-30 확정 요구사항 반영.
**Phase 완료 조건:** `P7-T1`과 `P7-GATE` 완료, focused test·직접 영향 회귀·문서 검증 기록 누적.
- [x] **Task 7.1: 최신 크리에이터 랭킹 배치 필터와 콘텐츠 랭킹 중복 제거**
**Goal 실행 `P7-T1`:** `findRecentNews(...)`가 랭킹 정책을 최대 30개 제한 전에 적용해 최신 크리에이터 배치와 콘텐츠별 최신 랭킹 소식만 반환한다.
- **시작 조건:** PRD Feature F와 Decision Log의 2026-07-30 결정 확인.
- **완료 증거:** 아래 TDD 체크박스 전체 완료, focused test 통과, 실제 결과를 `## 6. 검증 기록`에 누적.
- **범위 밖:** `CONTENT_RANKING` inbox 발행, 콘텐츠 랭킹 스냅샷 연동, 공개 API 스키마·DDL 변경, 다른 최근 소식 타입 리팩터링.
- **Files:**
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/home/following/adapter/out/persistence/DefaultHomeFollowingQueryRepository.kt`
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/home/following/adapter/out/persistence/DefaultHomeFollowingQueryRepositoryTest.kt`
- **Interfaces:**
- Consumes: `creator_ranking_snapshot.ranking_type`, `visible_from_at`, `home_following_news_inbox.news_type`, `target_id`, `visible_from_at_utc`, `id`.
- Produces: 기존 `HomeFollowingQueryPort.findRecentNews(memberId, canViewAdultContent, nowUtc, limit)` 계약을 변경하지 않은 필터링 결과.
- [x] **RED:** `shouldFindOnlyLatestVisibleCreatorRankingBatchInRecentNews` 테스트에 직전·최신 공개 크리에이터 랭킹 스냅샷과 inbox를 저장한다. 신규 배치 공개 전에는 직전 배치가 조회되고, 공개 후에는 최신 배치만 조회되며, 최신 배치에 없는 크리에이터의 과거 순위는 제외되는지 검증한다.
- [x] **RED:** `shouldFindLatestContentRankingNewsPerContentBeforeLimit` 테스트에 동일 `contentId`의 서로 다른 `visibleFromAtUtc` row, 같은 시각의 서로 다른 `newsId` row, 다른 콘텐츠 row를 저장한다. `visibleFromAtUtc desc`, `newsId desc` 기준 최신 row 하나만 남고 중복 제거 후 `limit`까지 다른 고유 소식이 채워지는지 검증한다.
- [x] **RED 확인:** 아래 focused test를 실행해 과거 크리에이터 배치 노출, `CONTENT_RANKING` 미조립 또는 동일 콘텐츠 중복 노출 때문에 assertion이 실패하는지 확인한다.
```bash
./gradlew --no-daemon test --tests "kr.co.vividnext.sodalive.v2.home.following.adapter.out.persistence.DefaultHomeFollowingQueryRepositoryTest"
```
- [x] **GREEN:** `DefaultHomeFollowingQueryRepository.findRecentNews(...)`에 `creator_ranking_snapshot`의 `rankingType = WEEKLY`, `visibleFromAtUtc <= nowUtc` 중 최신 공개 시각과 일치하는 `CREATOR_RANKING`만 허용하는 조건을 추가한다. 공개 스냅샷이 없으면 `CREATOR_RANKING`을 반환하지 않는다.
- [x] **GREEN:** `CONTENT_RANKING`을 활성 오디오 콘텐츠 target과 조인·조립하고, 동일 회원·동일 `targetId`의 노출 가능한 더 최신 row가 존재하지 않는 항목만 남기는 조건을 추가한다. 최신 비교는 `visibleFromAtUtc`, 동률이면 inbox `id`를 사용하며 이 조건을 전체 `limit`보다 먼저 적용한다.
- [x] **GREEN 확인:** 같은 focused test 명령을 다시 실행해 두 회귀 테스트와 기존 repository 테스트가 모두 통과하는지 확인한다.
- [x] **REFACTOR:** 이번 Task가 추가한 QueryDSL alias와 조건 함수만 정리하고, 공개 port/DTO·DDL은 변경하지 않는다. 아래 직접 영향 회귀와 lint를 실행해 결과를 기록한다.
```bash
./gradlew --no-daemon test --tests "kr.co.vividnext.sodalive.v2.api.home.following.adapter.in.web.HomeFollowingEndToEndTest"
./gradlew --no-daemon ktlintCheck
```
#### Phase 7 Gate
**Goal 실행 `P7-GATE`:** Phase 7의 최신 배치·콘텐츠 중복 제거 정책과 기존 팔로잉 탭 API 회귀를 최종 판정한다.
- **시작 조건:** `P7-T1` 완료.
- **완료 증거:** 아래 명령이 모두 `BUILD SUCCESSFUL`이고 결과가 `## 6. 검증 기록`에 누적됨.
- **범위 밖:** 전체 회귀 실패와 무관한 코드 수정, 테스트 삭제·완화, `CONTENT_RANKING` 발행 기능 추가.
```bash
./gradlew --no-daemon test --tests "kr.co.vividnext.sodalive.v2.home.following.adapter.out.persistence.DefaultHomeFollowingQueryRepositoryTest"
./gradlew --no-daemon test --tests "kr.co.vividnext.sodalive.v2.api.home.following.adapter.in.web.HomeFollowingEndToEndTest"
./gradlew --no-daemon ktlintCheck
```
**Expected:** 과거 크리에이터 랭킹 배치와 동일 콘텐츠의 중복 랭킹이 최근 소식에 포함되지 않고, 기존 팔로잉 탭 응답 계약과 다른 소식 타입 회귀가 없다.
- 전체 `./gradlew test`는 조회 repository 한 파일과 해당 테스트만 변경하는 국소 보완이므로 기본 Gate에서 생략한다. focused test 또는 E2E에서 공유 경계 회귀를 판단할 수 없는 실패가 발생하면 전체 회귀로 확장하고 근거와 결과를 기록한다.
- [x] **Task 7.2: 빈 최신 크리에이터 랭킹 배치에서 과거 소식 제외**
**Goal 실행 `P7-R1`:** `REV-P7-001`에 따라 최신 완료 배치의 결과가 0건이어도 이전 배치의 크리에이터 랭킹 소식을 노출하지 않는다.
- **시작 조건:** `reviews/phase-7-review.md`의 `REV-P7-001` 확정, 기존 `P7-GATE` 완료.
- **완료 증거:** 빈 최신 배치 재현 테스트의 RED 확인, 최소 구현 후 repository/E2E 회귀 통과, 검증 기록 누적.
- **범위 밖:** 랭킹 집계·점수 정책 변경, `CONTENT_RANKING` 발행, 신규 테이블/공개 API 변경.
- **Files:**
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/home/following/adapter/out/persistence/DefaultHomeFollowingQueryRepository.kt`
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/home/following/adapter/out/persistence/DefaultHomeFollowingQueryRepositoryTest.kt`
- Verify: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/home/following/adapter/in/web/HomeFollowingEndToEndTest.kt`
- [x] **RED:** 직전 공개 스냅샷/inbox와 더 최신 `WEEKLY`, `DONE` snapshot job을 저장하되 최신 배치 스냅샷은 0건으로 두고, 과거 `CREATOR_RANKING`이 제외되어야 하는 테스트를 추가해 실패를 확인한다.
- [x] **GREEN:** 최신 공개 배치 식별은 결과 row가 없어도 남는 `creator_ranking_snapshot_job`의 `WEEKLY`, `DONE`,
`visibleFromAtUtc <= nowUtc` 최신 시각을 기준으로 한다. 기존 snapshot만 있고 적용 가능한 job 이력이 전혀 없는 데이터의 호환 fallback이 필요하면 그 경우로만 제한하며, 빈 `DONE` 배치에서는 과거 snapshot으로 fallback하지 않는다.
- [x] **GREEN 확인:** repository focused test를 재실행해 최신 빈 배치와 기존 신규 공개 전/후 정책이 모두 통과하는지 확인한다.
- [x] **REFACTOR:** 최신 배치 조건 helper와 필요한 QueryDSL alias만 정리하고 port/DTO/DDL은 변경하지 않는다.
- [x] **Task 7.3: 노출 가능한 콘텐츠 랭킹 row만 최신 중복 제거 기준으로 사용**
**Goal 실행 `P7-R2`:** `REV-P7-002`에 따라 더 최신이지만 노출 불가능한 `CONTENT_RANKING` row가 이전의 노출 가능한 row를 가리지 않도록 한다.
- **시작 조건:** `P7-R1` 완료와 `reviews/phase-7-review.md`의 `REV-P7-002` 확정.
- **완료 증거:** `rank=null` 또는 회원에게 노출 불가한 최신 row 재현 테스트의 RED 확인, 최소 구현 후 focused test 통과, 검증 기록 누적.
- **범위 밖:** 콘텐츠 랭킹 발행/스냅샷 연동, 콘텐츠 동일성 기준 변경, 공개 응답 스키마 변경.
- **Files:**
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/home/following/adapter/out/persistence/DefaultHomeFollowingQueryRepository.kt`
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/home/following/adapter/out/persistence/DefaultHomeFollowingQueryRepositoryTest.kt`
- [x] **RED:** 같은 `memberId/targetId`에서 이전 row는 노출 가능하고 더 최신 row는 `rank=null`인 fixture를 추가해 이전 row가 유지되어야 하는 테스트를 작성하고 실패를 확인한다.
- [x] **RED:** 비성인 회원에게 더 최신 inbox row만 `isAdult=true`인 경우에도 이전의 노출 가능한 row가 유지되는 테스트를 작성하고 실패를 확인한다.
- [x] **GREEN:** `latestContentRankingNewsCondition(...)`의 newer-row 판정에 `rank is not null`, 회원별 inbox 성인 조건 등 row마다 달라질 수 있는 외부 조회와 동일한 노출 조건을 적용한다.
- [x] **GREEN 확인:** repository focused test를 재실행해 노출 불가 newer row와 기존 최신 시각/id tie-break 회귀가 모두 통과하는지 확인한다.
- [x] **REFACTOR:** 중복 제거 조건만 최소 수정하고 다른 최근 소식 타입의 조인·조립은 변경하지 않는다.
#### Phase 7 리뷰 회귀 Gate
**Goal 실행 `P7-R-GATE`:** Phase 7 리뷰에서 확정된 빈 배치와 노출 가능 row 기준을 최종 판정한다.
- **시작 조건:** `P7-R1`, `P7-R2` 완료.
- **완료 증거:** 아래 명령이 모두 `BUILD SUCCESSFUL`이고 결과가 `## 6. 검증 기록`과 `reviews/phase-7-review.md`에 누적됨.
- **범위 밖:** 전체 회귀 실패와 무관한 코드 수정, 테스트 삭제·완화, 신규 발행 기능.
```bash
./gradlew --no-daemon test --tests "kr.co.vividnext.sodalive.v2.home.following.adapter.out.persistence.DefaultHomeFollowingQueryRepositoryTest"
./gradlew --no-daemon test --tests "kr.co.vividnext.sodalive.v2.api.home.following.adapter.in.web.HomeFollowingEndToEndTest"
./gradlew --no-daemon ktlintCheck
```
---
## 5. 구현 순서 요약
@@ -783,6 +1114,11 @@ data class HomeFollowingNewsInboxRecord(
5. publish service를 만들고 언팔로우/랭킹/콘텐츠/커뮤니티 이벤트에 연결한다.
6. `FollowingNewsResponse`를 타입별 nested DTO 계약으로 전환하고 무료 커뮤니티 게시글만 `COMMUNITY_POST` 최근 소식을 발행하도록 보강한다.
7. End-to-End 테스트와 전체 회귀 검증을 수행한다.
8. 후속 회귀 수정으로 `CREATOR_RANKING` 최신 공개 배치 필터와 `CONTENT_RANKING` 콘텐츠별 최신 소식 중복 제거를 적용한다.
9. Phase 3 리뷰 회귀 수정으로 On Air와 라이브 스케줄에 기존 라이브 입장 제한을 적용한다.
10. Phase 7 리뷰 회귀 수정으로 빈 최신 랭킹 배치와 노출 불가 콘텐츠 랭킹 newer row를 처리한다.
11. Phase 4 리뷰 회귀 수정으로 publish와 팔로우 상태 변경의 동시성 경계를 보장한다.
12. Phase 6 리뷰 후속 문서 수정으로 `newsId` 식별자 계약을 현재 구현과 동기화한다.
---
@@ -865,3 +1201,96 @@ data class HomeFollowingNewsInboxRecord(
- 직접 검색 결과 정렬 계약을 명시한 위치는 `docs/20260625_메인_홈_팔로잉_탭_API/prd.md`, `docs/20260625_메인_홈_팔로잉_탭_API/plan-task.md`, `DefaultHomeFollowingQueryRepositoryTest.shouldFindActiveFollowingCreatorsByOldestFollowOrder`였고, 호출부는 `HomeFollowingQueryService`가 반환 순서를 그대로 조립하는 구조로 확인했다.
- `./gradlew --no-daemon test --tests "kr.co.vividnext.sodalive.v2.home.following.adapter.out.persistence.DefaultHomeFollowingQueryRepositoryTest"` 실행 결과 `BUILD SUCCESSFUL`.
- `./gradlew --no-daemon tasks --all` 실행 결과 `BUILD SUCCESSFUL`.
- 2026-07-30 Phase 7 요구사항·계획 문서 반영:
- PRD에 `CREATOR_RANKING` 최신 공개 `WEEKLY` 배치 한정, 신규 배치 공개 전 직전 배치 유지, 최신 배치에 없는 크리에이터의 과거 순위 미보충 정책을 추가했다.
- PRD에 `CONTENT_RANKING`의 동일 `contentId`별 최신 항목 한 건 조회와 발행 기능 제외 정책을 추가했다.
- `plan-task.md`에 미완료 회귀 수정 Goal `P7-T1`과 Phase Gate `P7-GATE`를 추가하고 RED → GREEN → REFACTOR, focused test, 직접 영향 E2E, lint 검증 명령을 연결했다.
- `rg -n -S "최신 공개|과거.*랭킹|동일.*contentId|CONTENT_RANKING.*발행|P7-T1|P7-GATE|Task 7\\.1|Phase 7" docs/20260625_메인_홈_팔로잉_탭_API/prd.md docs/20260625_메인_홈_팔로잉_탭_API/plan-task.md`로 두 정책과 Goal 연결을 확인했다.
- `git diff --check` 실행 결과 오류가 없었다.
- `./gradlew tasks --all` 최초 실행은 sandbox의 사용자 Gradle 캐시 접근 제한으로 실패했으며, 동일 명령을 승인된 권한으로 재실행해 `BUILD SUCCESSFUL`을 확인했다.
- 이번 요청은 문서 반영만 수행했으므로 `P7-T1` 구현 체크박스와 Phase 7 상태는 대기로 유지했다.
- 2026-07-30 Phase 7 구현 검증:
- RED 확인: `./gradlew --no-daemon test --tests "kr.co.vividnext.sodalive.v2.home.following.adapter.out.persistence.DefaultHomeFollowingQueryRepositoryTest"` 실행 결과 `shouldFindOnlyLatestVisibleCreatorRankingBatchInRecentNews`, `shouldFindLatestContentRankingNewsPerContentBeforeLimit` assertion 실패로 `BUILD FAILED`.
- `DefaultHomeFollowingQueryRepository.findRecentNews(...)`에 최신 공개 `WEEKLY` 크리에이터 랭킹 배치 필터, `CONTENT_RANKING` 활성 오디오 target 조립, 동일 contentId 최신 row 조건을 추가했다. 공개 port/DTO/DDL은 변경하지 않았다.
- GREEN 확인: `./gradlew --no-daemon test --tests "kr.co.vividnext.sodalive.v2.home.following.adapter.out.persistence.DefaultHomeFollowingQueryRepositoryTest"` 실행 결과 `BUILD SUCCESSFUL`.
- 직접 영향 E2E 확인: `./gradlew --no-daemon test --tests "kr.co.vividnext.sodalive.v2.api.home.following.adapter.in.web.HomeFollowingEndToEndTest"` 최초 실행은 E2E fixture에 최신 공개 `creator_ranking_snapshot`이 없어 `BUILD FAILED`; fixture 보강 후 재실행 결과 `BUILD SUCCESSFUL`.
- Gate lint 확인: `./gradlew --no-daemon ktlintCheck` 실행 결과 `BUILD SUCCESSFUL`.
- 2026-07-30 Phase 1~7 정적 코드 리뷰:
- 사용자 요청에 따라 컴파일과 테스트는 재실행하지 않고 PRD, 구현 계획, production/test 코드, 기존 검증 기록을 정적으로 대조했다.
- 문서 명령 유효성 확인을 위한 `./gradlew tasks --all` 최초 실행은 sandbox의 사용자 Gradle cache 접근 제한으로 실패했고,
승인된 권한으로 같은 명령을 재실행해 `BUILD SUCCESSFUL`을 확인했다. 이 명령은 컴파일과 테스트를 실행하지 않는다.
- Phase별 판정은 `docs/20260625_메인_홈_팔로잉_탭_API/reviews/phase-1-review.md`부터
`phase-7-review.md`까지 기록했으며, Phase 5.5는 `phase-5.5-review.md`로 분리했다.
- Phase 3의 라이브 입장 제한 누락 `REV-P3-001`을 확정하고 `P3-R1`, `P3-R-GATE`를 추가했다.
- Phase 7의 빈 최신 크리에이터 랭킹 배치 판정 `REV-P7-001`과 노출 불가 콘텐츠 랭킹 newer row 판정
`REV-P7-002`를 확정하고 `P7-R1`, `P7-R2`, `P7-R-GATE`를 추가했다.
- Phase 1, 2, 4, 5, 5.5, 6은 이번 정적 리뷰 범위에서 확정 발견 사항이 없다.
- 2026-07-30 Phase 3·7 리뷰 보완 구현 검증:
- RED 확인: `./gradlew --no-daemon test --tests "kr.co.vividnext.sodalive.v2.home.following.application.HomeFollowingQueryServiceTest" --tests "kr.co.vividnext.sodalive.v2.home.following.adapter.out.persistence.DefaultHomeFollowingQueryRepositoryTest"` 실행 결과 `findOnAirLives`/`findMonthlySchedules`의 effective gender·creator 여부 인자 미반영 컴파일 오류로 `BUILD FAILED`.
- `HomeFollowingQueryService`에서 본인인증 성별 우선 effective gender와 크리에이터 여부를 port로 전달하고, `DefaultHomeFollowingQueryRepository`의 On Air·라이브 스케줄에 `genderRestriction`/`isAvailableJoinCreator` 조건을 적용했다.
- `CREATOR_RANKING` 최신 배치 식별을 `creator_ranking_snapshot_job`의 최신 `WEEKLY`, `DONE` 공개 시각 기준으로 보강해 최신 빈 배치에서 과거 랭킹을 보충하지 않도록 했다.
- `CONTENT_RANKING` 중복 제거의 newer row 판정에 `rank is not null`과 회원별 inbox 성인 조건을 추가해 노출 불가 row가 이전 노출 가능 row를 가리지 않도록 했다.
- GREEN 확인: 위 focused test 명령 재실행 결과 `BUILD SUCCESSFUL`.
- E2E 확인: `./gradlew --no-daemon test --tests "kr.co.vividnext.sodalive.v2.api.home.following.adapter.in.web.HomeFollowingEndToEndTest"` 실행 결과 `BUILD SUCCESSFUL`.
- Gate lint 확인: `./gradlew --no-daemon ktlintCheck` 최초 실행은 import 정렬 위반으로 `BUILD FAILED`; import 정렬 수정 후 재실행 결과 `BUILD SUCCESSFUL`.
- 리뷰 게이트: Oracle reviewer가 P3-R1, P7-R1, P7-R2, 공개 API/DDL 비확장, 테스트/문서 기록을 검토했고 Critical/Important blocker 없음으로 승인했다.
- 2026-07-30 Phase 1~7 2차 정적 코드 리뷰:
- 사용자 요청에 따라 컴파일과 테스트를 실행하지 않고 PRD, 구현 계획, production/test 코드, 기존 검증 기록을 다시 대조했다.
- Phase 3의 `P3-R1`과 Phase 7의 `P7-R1`·`P7-R2` 반영 코드 및 기존 Gate 성공 기록을 정적으로 재확인했다.
- Phase 4에서 active follower 조회와 inbox insert 사이에 언팔로우가 완료되면 뒤늦게 active inbox가 생성되고, 이후 재팔로우 시 언팔로우 이전 이벤트가 노출될 수 있는 `REV-P4-001`을 확정했다.
- Phase 6에서 PRD는 `newsId`를 `{TYPE}:{targetId}`로 설명하지만 repository와 테스트는 inbox PK의 10진 문자열을 사용하는 `REV-P6-001`을 확정했다.
- 후속 작업으로 Task 4.6 / `P4-R1` / `P4-R-GATE`와 Task 6.3 / `P6-R1` / `P6-R-GATE`를 추가했다.
- Phase 1, 2, 3, 5, 5.5, 7에서는 이번 2차 정적 리뷰의 신규 확정 발견 사항이 없다.
- 이번 리뷰에서는 Gradle 명령을 실행하지 않았으며, 문서 변경은 정적 검색과 `git diff --check`로만 점검한다.
- 2026-07-30 Phase 4·6 리뷰 보완 구현 검증:
- RED 확인: `./gradlew --no-daemon test --tests "kr.co.vividnext.sodalive.v2.home.following.adapter.out.persistence.HomeFollowingNewsInboxPersistenceAdapterTest.shouldNotExposeNewsCreatedBeforeUnfollowAfterRefollowWhenPublishIsDelayed"` 실행 결과 stale active inbox assertion 실패로 `BUILD FAILED`.
- `creator_following` active follower 조회에 `for update`를 적용하고, `MemberService.creatorFollow(...)`/`creatorUnFollow(...)`가 같은 row를 `PESSIMISTIC_WRITE`로 조회하도록 보강했다. 공개 API·port·DDL은 변경하지 않았다.
- GREEN 확인: 위 동시성 단일 테스트 재실행 결과 `BUILD SUCCESSFUL`.
- P4-R1 focused test `./gradlew --no-daemon test --tests "kr.co.vividnext.sodalive.v2.home.following.adapter.out.persistence.HomeFollowingNewsInboxPersistenceAdapterTest" --tests "kr.co.vividnext.sodalive.v2.home.following.application.HomeFollowingNewsPublishServiceTest" --tests "kr.co.vividnext.sodalive.member.MemberServiceTest"` 실행 결과 `BUILD SUCCESSFUL`.
- P4-R-GATE E2E `./gradlew --no-daemon test --tests "kr.co.vividnext.sodalive.v2.api.home.following.adapter.in.web.HomeFollowingEndToEndTest"` 실행 결과 `BUILD SUCCESSFUL`.
- P4-R-GATE lint `./gradlew --no-daemon ktlintCheck` 최초 실행은 테스트 import 정렬 위반으로 `BUILD FAILED`; import 정렬 수정 후 재실행한다.
- P4-R-GATE lint 재실행 결과 `BUILD SUCCESSFUL`.
- P6-R1 문서 보완으로 PRD에서 `scheduleId`는 `{TYPE}:{targetId}`, `newsId`는 `home_following_news_inbox.id`의 10진 문자열이며 정렬·동률 해소에 사용하는 계약으로 분리했다.
- P6-R-GATE 정적 검색 `rg -n "newsId|scheduleId|home_following_news_inbox\.id" ...` 실행 결과 PRD, repository, repository test, E2E가 `scheduleId`는 `{TYPE}:{targetId}`, `newsId`는 inbox PK 문자열 계약으로 일치함을 확인했다.
- P6-R-GATE `git diff --check` 실행 결과 오류 없음.
- P6-R-GATE `./gradlew tasks --all` 실행 결과 `BUILD SUCCESSFUL`.
- 리뷰 게이트: Oracle reviewer가 P4 동시성 보완과 P6 문서 계약 동기화를 검토했고 Critical/Important/Minor blocker 없음으로 승인했다.
- 2026-07-30 Phase 1~7 3차 정적 코드 리뷰:
- 사용자 요청에 따라 컴파일과 테스트를 실행하지 않고 PRD, 구현 계획, production/test 코드, 기존 리뷰·검증 기록을 Phase별로 다시 대조했다.
- Phase 4에서 공개 `POST /member/creator/follow`가 `isActive=false`를 전달해도 관계가 없으면 새 active 팔로우를 만들고, 기존 관계에서는 inbox 비활성화를 호출하지 않아 재팔로우 시 과거 소식이 다시 노출될 수 있는 `REV-P4-002`를 확정했다.
- Phase 6에서 PRD의 최신 `CREATOR_RANKING` 배치 설명이 snapshot만 기준으로 적혀 있어, `P7-R1`로 반영된 최신 `WEEKLY`, `DONE` job 우선·legacy snapshot 제한 fallback 동작과 불일치하는 `REV-P6-002`를 확정했다.
- 후속 작업으로 Task 4.7 / `P4-R2` / `P4-R2-GATE`와 Task 6.4 / `P6-R2` / `P6-R2-GATE`를 추가했다.
- Phase 1, 2, 3, 5, 5.5, 7에서는 이번 3차 정적 리뷰의 신규 확정 발견 사항이 없다.
- 이번 리뷰에서는 Gradle 명령을 실행하지 않았으며, 문서 변경은 정적 검색과 `git diff --check`로만 점검한다.
- 2026-07-30 Phase 4 3차 리뷰 보완 구현 검증:
- RED 확인: `./gradlew --no-daemon test --tests "kr.co.vividnext.sodalive.member.MemberServiceTest"` 실행 결과 `creatorFollow(..., isActive=false)` 신규 회귀 2건이 assertion 실패로 `BUILD FAILED`.
- 관계가 없고 `isActive=false`인 통합 팔로우 요청은 새 active 관계를 만들지 않고, 기존 관계를 inactive로 바꾸는 경로에서는 `homeFollowingNewsInboxPort.deactivateByMemberIdAndCreatorId(...)`를 호출하도록 `MemberService.creatorFollow(...)`만 최소 수정했다.
- GREEN 확인: 같은 `MemberServiceTest` 명령 재실행 결과 `BUILD SUCCESSFUL`.
- P4-R2-GATE 확인: `./gradlew --no-daemon test --tests "kr.co.vividnext.sodalive.v2.home.following.adapter.out.persistence.HomeFollowingNewsInboxPersistenceAdapterTest" --tests "kr.co.vividnext.sodalive.v2.home.following.application.HomeFollowingNewsPublishServiceTest"` 실행 결과 `BUILD SUCCESSFUL`.
- P4-R2-GATE lint `./gradlew --no-daemon ktlintCheck` 최초 실행은 신규 테스트 줄 길이 위반으로 `BUILD FAILED`; 포맷 정리 후 `MemberServiceTest`와 `ktlintCheck` 재실행 결과 모두 `BUILD SUCCESSFUL`.
- 2026-07-30 Phase 6 3차 리뷰 보완 문서 검증:
- PRD Feature F와 최근 소식 Inbox 기술 제약의 `CREATOR_RANKING` 최신 공개 배치 설명을 현재 구현의 `creator_ranking_snapshot_job` 최신 `WEEKLY`, `DONE`, `visibleFromAtUtc <= nowUtc` 우선 기준과 일치하도록 갱신했다.
- 적용 가능한 완료 job이 전혀 없는 legacy/backfill 데이터에서만 `creator_ranking_snapshot` fallback을 허용하고, 최신 완료 job의 결과가 0건이면 과거 snapshot으로 보충하지 않는다고 명시했다.
- P6-R2-GATE 정적 검색 `rg -n "creator_ranking_snapshot_job|creator_ranking_snapshot|최신 공개 배치|WEEKLY|DONE|legacy|fallback" ...` 실행 결과 PRD, Task 7.2, repository, 빈 최신 배치 회귀 테스트의 기준을 대조했다. 검색 결과의 과거 Task 7.1 snapshot 설명은 Task 7.2에서 superseded된 완료 기록으로 확인했다.
- P6-R2-GATE `git diff --check` 실행 결과 오류 없음.
- P6-R2-GATE `./gradlew tasks --all` 실행 결과 `BUILD SUCCESSFUL`.
- 추가 전체 회귀 확인: `./gradlew --no-daemon test` 실행 결과 `BUILD SUCCESSFUL`.
- 리뷰 게이트: Oracle reviewer가 P4-R2 통합 언팔로우 보완과 P6-R2 문서 동기화를 검토했고 Critical/Important/Minor blocker 없음으로 승인했다.
- 2026-07-30 Phase 1~7 4차 정적 코드 리뷰:
- 사용자 요청에 따라 컴파일과 테스트를 실행하지 않고 PRD, 구현 계획, DDL, production/test 코드와 기존 리뷰 후속 구현을 Phase별로 다시 대조했다.
- Phase 1은 공개 endpoint·비회원 응답·DTO, Phase 2는 inbox 저장·중복·잠금, Phase 3은 섹션별 조회·라이브 제한을 확인했다.
- Phase 4는 after-commit 발행·팔로우 상태 동시성·통합 inactive 경로, Phase 5와 5.5는 facade/E2E·nested payload·무료 커뮤니티 정책을 확인했다.
- Phase 6은 문서·식별자·latest batch 계약, Phase 7은 최신 빈 배치와 노출 가능한 콘텐츠 랭킹 중복 제거를 확인했다.
- 각 Phase의 4차 판정은 `reviews/phase-1-review.md`부터 `phase-7-review.md`까지 누적했으며 Phase 5.5는 별도 보고서에 기록했다.
- 이번 차수의 신규 확정 발견 사항이 없어 신규 회귀 수정 Task/Goal은 추가하지 않았고 기존 Phase 완료 판정을 유지한다.
- 이번 리뷰에서는 Gradle 명령을 실행하지 않았으며, 문서 변경은 정적 검색과 `git diff --check`로만 점검한다.

View File

@@ -12,6 +12,8 @@
- 최근 소식은 랭킹, 커뮤니티 게시글 업로드, 콘텐츠 업로드가 섞인 피드라 매 요청마다 팔로잉한 모든 크리에이터의 모든 원천 데이터를 크게 조인하면 응답 지연과 DB 부하가 커질 수 있다.
- 최근 소식은 전체 후보를 매번 조회하는 모델보다, 팔로우 중인 크리에이터의 이벤트가 발생할 때 각 follower의 우체통에 소식 row를 넣는 사용자별 Inbox Feed 모델이 요구사항에 더 맞다.
- 따라서 공개 API 조립 계층과 도메인 조회 계층을 분리하고, 최근 소식은 사용자별 inbox row를 최신순으로 읽는 구조가 필요하다.
- 현재 최근 소식 조회는 노출 가능한 과거 `CREATOR_RANKING` inbox도 함께 조회하므로, 전체 소식이 30개 미만이면 직전 공개 배치보다 오래된 크리에이터 순위가 표시될 수 있다.
- 향후 `CONTENT_RANKING` inbox가 적재되면 같은 콘텐츠가 여러 랭킹 소식에 포함될 수 있으므로, 조회 시 동일 콘텐츠의 중복 노출을 방지하는 정책이 필요하다.
---
@@ -24,6 +26,8 @@
- DM/AI 채팅방 중 최신 대화순 10개를 응답한다.
- 사용자가 팔로우한 크리에이터들의 이번 달 오늘 이후 스케줄을 오늘과 가까운 순으로 최대 3개 응답한다.
- 사용자가 팔로우한 크리에이터들의 최근 소식을 최신 노출 가능 시각순 최대 30개 응답한다.
- `CREATOR_RANKING` 최근 소식은 현재 시점에 공개된 최신 크리에이터 랭킹 배치만 응답한다.
- `CONTENT_RANKING` 최근 소식은 같은 콘텐츠의 노출 가능한 inbox가 여러 개여도 가장 최신 항목 하나만 응답한다.
- 최근 소식은 팔로우 중인 크리에이터의 이벤트 발생 시점에 사용자별 inbox row를 생성하고, 조회 시 열람 가능 시각/활성 여부/차단/성인 노출 조건을 적용한다.
- 새로 팔로우한 사용자는 과거 소식을 받지 않는다.
- 언팔로우하면 해당 크리에이터가 보낸 기존 inbox row를 비활성화한다.
@@ -42,6 +46,7 @@
- 최근 소식의 운영자 수동 고정/숨김 기능은 포함하지 않는다.
- 최근 소식 발송용 외부 MQ, outbox table, 별도 worker, cursor/retry dashboard는 이번 범위에 포함하지 않는다.
- 화보 업로드 기능 자체 구현은 포함하지 않는다. 단, 향후 콘텐츠 타입 확장을 고려한 응답 타입은 정의한다.
- `CONTENT_RANKING` inbox 발행과 콘텐츠 랭킹 스냅샷 연동은 이번 보완 범위에 포함하지 않는다.
- 전체보기/페이징 API는 이번 요구사항에 포함하지 않는다.
---
@@ -59,7 +64,8 @@
- 사용자는 팔로우한 크리에이터가 지금 진행 중인 라이브를 바로 확인하고 싶다.
- 사용자는 최근 DM/AI 채팅방으로 빠르게 이동하고 싶다.
- 사용자는 팔로우한 크리에이터의 이번 달 예정 라이브/콘텐츠 일정을 가까운 일정부터 보고 싶다.
- 사용자는 팔로우한 크리에이터의 이번 주 랭킹 순위, 커뮤니티 게시글, 콘텐츠 업로드 소식을 최신순으로 보고 싶다.
- 사용자는 팔로우한 크리에이터의 최신 공개 랭킹 순위, 커뮤니티 게시글, 콘텐츠 업로드 소식을 최신순으로 보고 싶다.
- 사용자는 과거 크리에이터 랭킹 배치나 같은 콘텐츠의 중복 랭킹 소식 없이 최근 소식을 보고 싶다.
- 앱 클라이언트는 소식 item의 타입별 터치 액션을 명확한 target id로 처리하고 싶다.
---
@@ -174,6 +180,13 @@
- inbox row에는 소식 타입, 발생 시각, 열람 가능 시각, 수신 회원 id, 크리에이터 id, target id, 표시용 제목/본문/이미지 path, 랭킹 순위 값 등 응답 생성에 필요한 최소 정보를 저장한다.
- API 조회는 `memberId = 요청 회원 id`, `isActive = true`, `visibleFromAtUtc <= nowUtc`인 inbox row를 최신순으로 조회한다.
- 조회 정렬은 `visibleFromAtUtc desc`, `newsId desc`를 기본으로 한다.
- `CREATOR_RANKING`은 적용 가능한 `creator_ranking_snapshot_job``rankingType = WEEKLY`, `status = DONE`, `visibleFromAtUtc <= nowUtc`를 만족하는 최신 완료 job의 `visibleFromAtUtc`와 같은 inbox row만 조회한다.
- 적용 가능한 완료 job이 전혀 없는 legacy/backfill 데이터에서만 `creator_ranking_snapshot``WEEKLY`, `visibleFromAtUtc <= nowUtc` 최신 공개 시각을 fallback으로 사용한다.
- 최신 완료 job의 결과가 0건이면 과거 snapshot으로 보충하지 않는다.
- 다음 랭킹 배치가 공개되기 전에는 직전 공개 배치를 최신 배치로 유지하고, 새 배치가 공개된 뒤에는 이전 배치의 `CREATOR_RANKING` inbox를 조회하지 않는다.
- 사용자가 팔로우한 크리에이터가 최신 공개 배치에 없으면 해당 크리에이터의 과거 `CREATOR_RANKING` inbox로 대체하지 않는다.
- `CONTENT_RANKING`은 같은 회원과 같은 `targetId`를 콘텐츠 동일성 기준으로 사용하고, 노출 가능한 row 중 `visibleFromAtUtc desc`, `newsId desc` 기준 첫 항목 하나만 조회한다.
- 랭킹 배치 필터와 콘텐츠 중복 제거는 전체 최대 30개 제한보다 먼저 적용한다.
- 조회 시 원천 target의 비활성/삭제 여부, 차단 관계, 성인 노출 가능 여부를 최종 확인한다.
- `FollowingNewsResponse` 최상위 응답 필드는 `newsId`, `type`, `visibleFromAtUtc`만 공통으로 포함한다.
- 타입별 세부 값은 nullable nested DTO로 내려주며, `type`과 일치하는 nested DTO만 non-null이고 나머지는 `null`이다.
@@ -190,12 +203,16 @@
- 즉시 공개 콘텐츠는 `visibleFromAtUtc = releaseDate`로 저장할 수 있다.
- 크리에이터 랭킹 소식은 크리에이터 랭킹 스냅샷 생성 시 inbox row를 생성할 수 있으나, `visibleFromAtUtc`는 랭킹 스냅샷의 `visibleFromAtUtc`를 그대로 사용한다.
- 크리에이터 랭킹 스냅샷이 월요일 01:00 KST에 생성되고 월요일 09:00 KST에 화면 반영되는 경우, `CREATOR_RANKING` inbox row도 월요일 09:00 KST 전에는 API에 노출되지 않아야 한다.
- 월요일 신규 랭킹 공개 전에는 직전 공개 배치의 `CREATOR_RANKING` 소식을 표시하고, 신규 배치 공개 시점부터는 신규 배치 소식만 표시한다.
- 최근 소식에서 순위 변화와 신규 진입 여부는 사용하지 않는다. 랭킹 타입은 nested DTO의 `rank`만 내려준다.
#### Edge Cases
- inbox row가 없거나 필터링 후 결과가 없으면 빈 배열을 내려준다.
- inbox 적재 실패 시 API 조회에서 실시간 fallback 집계를 무조건 수행하지 않는다.
- 랭킹 소식의 순위 값이 없거나 오래된 경우 해당 item은 생성하지 않는다.
- 최신 공개 크리에이터 랭킹 배치에 해당하는 inbox가 없으면 과거 배치로 보충하지 않고 `CREATOR_RANKING` 소식을 가능한 개수만 응답한다.
- 같은 콘텐츠의 `CONTENT_RANKING` row가 여러 개이고 `visibleFromAtUtc`가 같으면 `newsId`가 큰 row 하나만 응답한다.
- 과거 크리에이터 랭킹과 중복 콘텐츠 랭킹을 제외한 뒤 최근 소식이 30개 미만이어도 과거·중복 랭킹으로 보충하지 않는다.
- 같은 회원, 같은 소식 타입, 같은 `sourceKey`에 대해 중복 inbox row를 생성하지 않는다.
- 언팔로우와 inbox 적재가 동시에 발생하면, 최종적으로 언팔로우 상태인 크리에이터의 새 소식은 노출하지 않는다.
- 타입별 이미지가 없으면 해당 nested DTO의 이미지 URL 필드는 `null`로 내려준다.
@@ -328,7 +345,8 @@ enum class FollowingNewsType {
```
- `ChatRoomListItemResponse`는 기존 `v2.chat.dto` 응답 DTO를 직접 재사용한다.
- `scheduleId``newsId`는 서로 다른 원천 타입의 id 충돌을 피하기 위해 `{TYPE}:{targetId}` 형식의 문자열을 기본안으로 한다. 최근 소식의 이동 대상 id는 타입별 nested DTO 안의 id 필드를 사용한다.
- `scheduleId`는 서로 다른 원천 타입의 id 충돌을 피하기 위해 `{TYPE}:{targetId}` 형식의 문자열을 사용한다.
- `newsId``home_following_news_inbox.id`의 10진 문자열이며, 최근 소식의 `visibleFromAtUtc desc`, `newsId desc` 정렬과 `CONTENT_RANKING` 동률 해소에 사용한다. 최근 소식의 이동 대상 id는 타입별 nested DTO 안의 id 필드를 사용한다.
---
@@ -370,7 +388,9 @@ enum class FollowingNewsType {
- publish service는 `publishContentUploaded(...)`, `publishFreeCommunityPostCreated(...)`, `publishCreatorRankingVisible(...)`처럼 이벤트별 명시적 메서드를 제공한다. 유료 커뮤니티 게시글은 publish service 호출 대상이 아니다.
- 운영 규모가 커지면 publish service 내부에서 outbox row 저장 또는 비동기 worker 위임으로 전환할 수 있도록 호출부 계약을 작게 유지한다.
- `CREATOR_RANKING` 타입은 크리에이터 랭킹 소식만 포함한다.
- `CONTENT_RANKING` 타입은 향후 콘텐츠 랭킹 소식용으로 enum과 table 값만 예약하고, 이번 범위에서는 생성하지 않는다.
- `CREATOR_RANKING` 조회의 최신 공개 배치는 적용 가능한 `creator_ranking_snapshot_job``WEEKLY`, `DONE`, `visibleFromAtUtc <= nowUtc` 조건으로 우선 판정한다. 적용 가능한 완료 job이 전혀 없는 legacy/backfill 데이터에서만 `creator_ranking_snapshot`의 최신 공개 시각을 fallback으로 사용하며, 최신 완료 job의 결과가 0건이면 과거 snapshot으로 보충하지 않는다.
- `CONTENT_RANKING` 타입은 향후 콘텐츠 랭킹 소식용으로 enum과 table 값을 유지하되, 이번 보완에서는 발행 기능을 추가하지 않고 기존 또는 향후 적재된 inbox의 조회 정책만 정의한다.
- `CONTENT_RANKING``targetId`별 최신 노출 가능 row 하나만 남기고, `visibleFromAtUtc`가 같으면 `newsId` 내림차순으로 하나를 선택한다.
- 언팔로우 시 해당 회원과 크리에이터의 활성 inbox row를 비활성화한다.
- 재팔로우 시 비활성화된 기존 inbox row는 복구하지 않는다.
- 현재 `creator_following`에는 재팔로우 시점이 명확히 남지 않으므로, 조회 조건으로 재팔로우 시점을 추론하지 않는다.
@@ -395,3 +415,10 @@ enum class FollowingNewsType {
## 12. Open Questions
- 현재 PRD 기준의 미결정 요구사항은 없다.
- 구현 계획 단계에서는 기존 라이브 조회 코드의 진행 중 판단 조건과 스케줄 `isOnAir` 판단 조건을 같은 조건으로 추출할지 검토한다.
---
## 13. Decision Log
- 2026-07-30: `CREATOR_RANKING` 최근 소식은 KST 달력 주간이 아니라 현재 시점의 최신 공개 `WEEKLY`, `DONE` 크리에이터 랭킹 job을 기준으로 한다. 신규 배치 공개 전에는 직전 공개 배치를 유지하고, 공개 후에는 이전 배치를 노출하지 않으며, 최신 완료 job의 결과가 0건이면 과거 snapshot으로 보충하지 않는다.
- 2026-07-30: `CONTENT_RANKING` 발행 기능은 이번 보완 범위에서 제외한다. 조회 시 동일 `contentId`의 노출 가능한 row 중 `visibleFromAtUtc desc`, `newsId desc` 기준 최신 항목 하나만 응답한다.

View File

@@ -0,0 +1,59 @@
# Phase 1 코드 리뷰 보고서
## 1. 리뷰 정보
| 항목 | 내용 |
|---|---|
| 리뷰 대상 | Phase 1 / Task 1.1~1.2 |
| 기준 commit 또는 working tree | `e6f56f24` + 2026-07-30 working tree |
| 리뷰 일자 | 2026-07-30 |
| 리뷰어 | Codex |
| 기준 문서 | `prd.md`, `plan-task.md`, `docs/agent-guides/*.md` |
| 리뷰 상태 | 판정 완료 |
## 2. 목적과 범위
- DTO/domain 모델, 비로그인 응답, controller, Security `permitAll`이 공개 계약과 일치하는지 정적으로 대조했다.
- `HomeFollowingTabResponse.kt`, `HomeFollowingController.kt`, `HomeFollowingFacade.kt`, `SecurityConfig.kt`와 대응 테스트를 포함했다.
- 사용자의 지시에 따라 컴파일과 테스트는 실행하지 않았다. `plan-task.md`의 기존 성공 기록은 참고 증거로만 사용했다.
## 3. 검토 근거
- `HomeFollowingTabResponse.loginRequired()`는 로그인 필요 상태와 다섯 개 빈 배열을 생성한다.
- controller는 nullable 인증 회원을 facade에 전달하고 `ApiResponse.ok(...)`로 감싼다.
- `SecurityConfig``GET /api/v2/home/following``permitAll`로 허용한다.
- DTO 테스트와 controller 테스트는 비회원/인증 회원 계약 및 nested 최근 소식 변환을 다룬다.
## 4. 발견 사항
확정 발견 사항 없음.
## 5. plan·goal 전환
전환 항목 없음.
## 6. 리뷰 종료 판정
| 판정 항목 | 결과 | 근거 |
|---|---|---|
| 리뷰 범위 전체 확인 | 충족 | Phase 1 코드·테스트·문서 정적 대조 |
| 후보 항목 판정 완료 | 충족 | 후보 없음 |
| 확정 항목 plan 반영 | 해당 없음 | 확정 발견 사항 없음 |
| 검증 명령과 결과 기록 | 충족 | 테스트 미실행 사유와 기존 기록 분리 |
**최종 결론:** 확정 발견 사항 없음.
**남은 항목:** 없음.
## 7. 3차 정적 리뷰 — 2026-07-30
- controller, facade, DTO, Security permitAll과 비로그인 빈 응답 계약을 현재 working tree 기준으로 다시 대조했다.
- 컴파일과 테스트는 사용자 지시에 따라 실행하지 않았다.
- 신규 확정 발견 사항 없음. 기존 Phase 1 완료 판정을 유지한다.
## 8. 4차 정적 리뷰 — 2026-07-30
- 공개 endpoint, nullable 인증 principal, `ApiResponse.ok(...)`, 로그인 필요 빈 응답과 nested DTO 변환을 현재 working tree에서 다시 대조했다.
- controller·facade·DTO 테스트가 비회원/인증 회원 분기와 공개 응답 계약을 고정하는지 정적으로 확인했다.
- 컴파일과 테스트는 사용자 지시에 따라 실행하지 않았다.
- 신규 확정 발견 사항 없음. 기존 Phase 1 완료 판정을 유지한다.

View File

@@ -0,0 +1,59 @@
# Phase 2 코드 리뷰 보고서
## 1. 리뷰 정보
| 항목 | 내용 |
|---|---|
| 리뷰 대상 | Phase 2 / Task 2.1~2.2 |
| 기준 commit 또는 working tree | `e6f56f24` + 2026-07-30 working tree |
| 리뷰 일자 | 2026-07-30 |
| 리뷰어 | Codex |
| 기준 문서 | `prd.md`, `plan-task.md`, `create-home-following-news-inbox-table.sql` |
| 리뷰 상태 | 판정 완료 |
## 2. 목적과 범위
- inbox entity/JPA repository/adapter와 MySQL DDL의 컬럼·유니크 키·인덱스·비활성화 정책을 정적으로 대조했다.
- 중복 충돌 retry, 활성 follower 조회, 테스트 격리와 기존 통합 테스트 범위를 확인했다.
- 사용자의 지시에 따라 컴파일과 테스트는 실행하지 않았다.
## 3. 검토 근거
- entity와 DDL은 `member_id/news_type/source_key` 유니크 정책, timestamp, 길이, 활성 상태 컬럼이 일치한다.
- adapter는 입력 중복 제거 후 기존 수신 회원을 일괄 조회하고 `saveAll`/`flush`하며, unique 충돌 시 새 트랜잭션으로 한 번 재시도한다.
- 언팔로우 비활성화 쿼리와 활성 follower 조회 쿼리는 계획의 키 조건을 사용한다.
- 통합 테스트는 실제 unique 충돌 후 트랜잭션 사용 가능 여부, 비활성화, 활성 follower 조회를 포함한다.
## 4. 발견 사항
확정 발견 사항 없음.
## 5. plan·goal 전환
전환 항목 없음.
## 6. 리뷰 종료 판정
| 판정 항목 | 결과 | 근거 |
|---|---|---|
| 리뷰 범위 전체 확인 | 충족 | entity/repository/adapter/DDL/test 정적 대조 |
| 후보 항목 판정 완료 | 충족 | 후보 없음 |
| 확정 항목 plan 반영 | 해당 없음 | 확정 발견 사항 없음 |
| 검증 명령과 결과 기록 | 충족 | 테스트 미실행 사유 기록 |
**최종 결론:** 확정 발견 사항 없음.
**남은 항목:** 없음.
## 7. 3차 정적 리뷰 — 2026-07-30
- inbox DDL, Entity, JPA adapter의 중복 방지·retry·비활성화 계약을 현재 working tree 기준으로 다시 대조했다.
- 컴파일과 테스트는 사용자 지시에 따라 실행하지 않았다.
- 신규 확정 발견 사항 없음. 기존 Phase 2 완료 판정을 유지한다.
## 8. 4차 정적 리뷰 — 2026-07-30
- DDL·Entity의 컬럼 길이, timestamp, unique/index 정책과 adapter의 중복 제거·충돌 재시도·비활성화 동작을 다시 대조했다.
- 활성 follower 조회의 잠금이 Phase 4 팔로우 상태 변경 경계와 연결되고 관련 persistence 테스트가 이를 고정하는지 정적으로 확인했다.
- 컴파일과 테스트는 사용자 지시에 따라 실행하지 않았다.
- 신규 확정 발견 사항 없음. 기존 Phase 2 완료 판정을 유지한다.

View File

@@ -0,0 +1,120 @@
# Phase 3 코드 리뷰 보고서
## 1. 리뷰 정보
| 항목 | 내용 |
|---|---|
| 리뷰 대상 | Phase 3 / Task 3.1~3.6 |
| 기준 commit 또는 working tree | `e6f56f24` + 2026-07-30 working tree |
| 리뷰 일자 | 2026-07-30 |
| 리뷰어 | Codex |
| 기준 문서 | `prd.md` Feature B~F, `plan-task.md` Phase 3 |
| 리뷰 상태 | 판정 완료 |
## 2. 목적과 범위
- 팔로잉 크리에이터, On Air, 월간 스케줄, 최근 소식 repository와 query service를 기존 접근 정책까지 포함해 정적으로 대조했다.
- `DefaultHomeFollowingQueryRepository.kt`, `HomeFollowingQueryPort.kt`, `HomeFollowingQueryService.kt`와 대응 테스트를 검토했다.
- 사용자의 지시에 따라 컴파일과 테스트는 실행하지 않았다.
## 3. 발견 사항 요약
| ID | 심각도 | 상태 | 제목 | 소유 Task | 후속 goal |
|---|---|---|---|---|---|
| `REV-P3-001` | High | 확정 | On Air와 라이브 스케줄이 기존 라이브 입장 제한을 적용하지 않는다 | Task 3.7 | `P3-R1` |
## 4. 발견 사항 상세
### REV-P3-001 — On Air와 라이브 스케줄이 기존 라이브 입장 제한을 적용하지 않는다
- **심각도:** High
- **상태:** 확정
- **관련 요구사항:** PRD Feature C의 성별·크리에이터 입장 제한, Feature E의 기존 채널 스케줄 정책 재사용
- **소유 Task:** 신규 Task 3.7 / `P3-R1`
**관찰 내용**
`HomeFollowingQueryService`는 회원 id와 성인 콘텐츠 허용 여부만 port에 전달한다.
`DefaultHomeFollowingQueryRepository.findOnAirLives(...)`와 live schedule 조회는 활성/채널/성인/차단 조건만 적용하며
`live_room.gender_restriction`과 크리에이터 회원의 `is_available_join_creator` 조건을 적용하지 않는다.
반면 기존 `LiveRoomQueryRepositoryImpl.getLiveRoomListNow(...)`
`DefaultCreatorChannelHomeQueryRepository.findCurrentLive/findSchedules(...)`는 effective gender와 크리에이터 입장 제한을 적용한다.
**근거**
- 코드: `HomeFollowingQueryService.kt:19-30`
- 코드: `DefaultHomeFollowingQueryRepository.kt:70-99`, `242-273`
- 비교 코드: `LiveRoomRepository.kt:97-115`
- 비교 코드: `DefaultCreatorChannelHomeQueryRepository.kt:136-145`, `225-233`
- 테스트 공백: `DefaultHomeFollowingQueryRepositoryTest`의 On Air 테스트는 활성/성인/정렬만 검증한다.
**정적 재현 절차**
1. 남성 회원이 여성 전용 라이브를 연 팔로잉 크리에이터를 조회한다고 가정한다.
2. 라이브를 `isActive=true`, non-empty `channelName`, 비성인으로 두면 현재 팔로잉 조회 조건을 모두 통과한다.
3. 실제 라이브 입장 정책은 성별 불일치로 입장을 거부하지만 팔로잉 탭 On Air에는 노출된다.
4. 크리에이터 회원이 `isAvailableJoinCreator=false`인 타 크리에이터 라이브를 조회하는 경우도 동일하게 노출된다.
**영향**
팔로잉 탭에 터치해도 입장할 수 없는 라이브 또는 스케줄이 노출되어 기존 라이브 접근 정책과 API 결과가 불일치한다.
**권장 조치**
회원의 본인인증 성별을 우선한 effective gender와 크리에이터 회원 여부를 query service에서 전달하고, 기존 QueryDSL 조건을
On Air와 live schedule에 최소 적용한다. 성별 불일치와 크리에이터 입장 불가 회귀 테스트를 먼저 추가한다.
**판정 기록**
- 2026-07-30 — PRD와 기존 라이브 조회 구현을 대조해 확정했다. 런타임 테스트는 사용자 지시에 따라 실행하지 않았다.
## 5. plan·goal 전환
- `plan-task.md` Phase 3에 Task 3.7, `P3-R1`, `P3-R-GATE`를 추가했다.
- 기존 Task 3.1~3.6의 완료 체크와 검증 기록은 유지했다.
## 6. 리뷰 종료 판정
| 판정 항목 | 결과 | 근거 |
|---|---|---|
| 리뷰 범위 전체 확인 | 충족 | Phase 3 repository/service/test 정적 대조 |
| 후보 항목 판정 완료 | 충족 | `REV-P3-001` 확정 |
| 확정 항목 plan 반영 | 충족 | Task 3.7 / `P3-R1` |
| 검증 명령과 결과 기록 | 충족 | 테스트 미실행 사유 기록 |
**최종 결론:** 수정 goal 필요.
**남은 항목:** `P3-R1` 구현 후 `P3-R-GATE`와 이 문서의 수정 후 검증 기록을 수행한다.
## 7. 수정 후 검증 기록
- 2026-07-30 — RED 확인: `./gradlew --no-daemon test --tests "kr.co.vividnext.sodalive.v2.home.following.application.HomeFollowingQueryServiceTest" --tests "kr.co.vividnext.sodalive.v2.home.following.adapter.out.persistence.DefaultHomeFollowingQueryRepositoryTest"` 실행 결과 effective gender·creator 여부 port 인자 미반영 컴파일 오류로 `BUILD FAILED`.
- 2026-07-30 — `HomeFollowingQueryService`에서 본인인증 성별 우선 effective gender와 크리에이터 회원 여부를 `HomeFollowingQueryPort`에 전달하고, On Air와 라이브 스케줄 QueryDSL에 `genderRestriction``isAvailableJoinCreator` 조건을 적용했다.
- 2026-07-30 — GREEN 확인: 같은 focused test 명령 재실행 결과 `BUILD SUCCESSFUL`.
- 2026-07-30 — Gate 확인: `./gradlew --no-daemon test --tests "kr.co.vividnext.sodalive.v2.api.home.following.adapter.in.web.HomeFollowingEndToEndTest"``./gradlew --no-daemon ktlintCheck` 재실행 결과 모두 `BUILD SUCCESSFUL`.
- 2026-07-30 — 리뷰 게이트: Oracle reviewer가 Critical/Important blocker 없음으로 승인했다.
## 8. 후속 종료 판정 — 2026-07-30
- `HomeFollowingQueryService`가 본인인증 성별 우선 effective gender와 크리에이터 여부를 On Air·스케줄 조회에 전달하는 현재 코드를 정적으로 재확인했다.
- `DefaultHomeFollowingQueryRepository`가 On Air와 라이브 스케줄에 `genderRestriction``isAvailableJoinCreator` 조건을 적용하는 현재 코드를 정적으로 재확인했다.
- 이번 후속 판정에서는 컴파일과 테스트를 재실행하지 않았고, 위 `## 7. 수정 후 검증 기록`의 기존 성공 결과를 근거로 삼았다.
**최종 결론:** `P3-R1``P3-R-GATE` 수정 검증 완료.
**남은 항목:** 없음.
## 9. 3차 정적 리뷰 — 2026-07-30
- 팔로잉 크리에이터, On Air, 월간 스케줄, 최근 소식 조회 조건과 `P3-R1`의 성별·크리에이터 입장 제한 반영을 현재 working tree에서 다시 추적했다.
- 컴파일과 테스트는 사용자 지시에 따라 실행하지 않았다.
- 신규 확정 발견 사항 없음. 기존 Phase 3 완료 판정을 유지한다.
## 10. 4차 정적 리뷰 — 2026-07-30
- 팔로잉 크리에이터·On Air·월간 스케줄·최근 소식의 활성/role/차단/성인/시간 범위/정렬/limit 조건을 PRD와 다시 대조했다.
- `P3-R1`의 effective gender와 크리에이터 입장 제한이 service→port→On Air·라이브 스케줄 query에 동일하게 전달되는지 확인했다.
- repository/service 테스트가 KST 월간 경계, 동률 정렬, 원천 target 활성 상태와 라이브 접근 제한을 고정하는지 정적으로 확인했다.
- 컴파일과 테스트는 사용자 지시에 따라 실행하지 않았다.
- 신규 확정 발견 사항 없음. 기존 Phase 3 완료 판정을 유지한다.

View File

@@ -0,0 +1,203 @@
# Phase 4 코드 리뷰 보고서
## 1. 리뷰 정보
| 항목 | 내용 |
|---|---|
| 리뷰 대상 | Phase 4 / Task 4.1~4.5 |
| 기준 commit 또는 working tree | `e6f56f24` + 2026-07-30 working tree |
| 리뷰 일자 | 2026-07-30 |
| 리뷰어 | Codex |
| 기준 문서 | `prd.md` Feature F, `plan-task.md` Phase 4 |
| 리뷰 상태 | 판정 완료 |
## 2. 목적과 범위
- source key, follower fan-out, 언팔로우 비활성화, 랭킹/오디오/무료 커뮤니티 발행 연결을 정적으로 추적했다.
- 원 트랜잭션 commit 이후 발행, 발행 실패 격리, 공개 시각과 중복 방지 키를 확인했다.
- 사용자의 지시에 따라 컴파일과 테스트는 실행하지 않았다.
## 3. 검토 근거
- source key는 타입과 원천 id/기간으로 안정적으로 생성된다.
- publish service는 활성 follower에게만 record를 만들고 DB 컬럼 길이에 맞춰 title/body를 제한한다.
- 언팔로우는 기존 inbox를 비활성화하며 재팔로우가 이를 복구하지 않는다.
- 랭킹, 즉시/예약 오디오, 무료 커뮤니티 생성 경로는 commit 이후 publish service를 호출하고 실패를 원 처리와 격리한다.
- 관련 단위·서비스 테스트는 발행 성공, 예약 공개, 유료 미발행, 발행 실패 격리를 포함한다.
## 4. 발견 사항
확정 발견 사항 없음.
## 5. plan·goal 전환
전환 항목 없음.
## 6. 리뷰 종료 판정
| 판정 항목 | 결과 | 근거 |
|---|---|---|
| 리뷰 범위 전체 확인 | 충족 | 발행 경로와 호출부 정적 추적 |
| 후보 항목 판정 완료 | 충족 | 후보 없음 |
| 확정 항목 plan 반영 | 해당 없음 | 확정 발견 사항 없음 |
| 검증 명령과 결과 기록 | 충족 | 테스트 미실행 사유 기록 |
**최종 결론:** 확정 발견 사항 없음.
**남은 항목:** 없음.
## 7. 2차 리뷰 — 2026-07-30
### 리뷰 범위
- active follower 조회, inbox insert의 트랜잭션 경계와 언팔로우·재팔로우 상태 변경을 함께 정적으로 추적했다.
- 컴파일과 테스트는 사용자 지시에 따라 실행하지 않았다.
### REV-P4-001 — follower 판정 후 언팔로우가 완료되면 stale active inbox가 생성될 수 있음
**심각도:** Medium
**상태:** 확정
**근거**
- `HomeFollowingNewsPublishService.publishToFollowers(...)`는 active follower id 목록을 먼저 읽고 이후 별도 호출로 inbox row를 insert한다.
- `HomeFollowingNewsInboxJpaRepository.findActiveFollowerIds(...)``creator_following`을 잠그지 않는 조회다.
- `HomeFollowingNewsInboxPersistenceAdapter.insertIgnoreAll(...)`의 실제 insert는 `REQUIRES_NEW` 경로에서 실행될 수 있어 follower 조회와 하나의 직렬화 경계를 공유하지 않는다.
- `MemberService.creatorUnFollow(...)`는 언팔로우 시점에 존재하는 active inbox만 비활성화하고, `creatorFollow(...)`의 재팔로우는 기존 관계 row를 다시 active로 바꾼다.
**정적 재현 절차**
1. publish 트랜잭션 A가 active follower인 회원을 읽고 insert 전에 멈춘다.
2. 트랜잭션 B가 같은 회원의 언팔로우와 현재 inbox 비활성화를 완료한다.
3. A가 기존 follower snapshot으로 active inbox를 뒤늦게 insert한다.
4. 언팔로우 중에는 조회의 active-following 조건으로 숨겨지지만, 회원이 다시 팔로우하면 해당 언팔로우 이전 이벤트가 노출 가능해진다.
**영향**
재팔로우 시 기존 비활성 row를 복구하지 않고 재팔로우 이후의 새 이벤트만 제공한다는 정책을 우회해, 언팔로우 이전 이벤트가 최근 소식으로 나타날 수 있다.
**권장 조치**
follower 판정부터 inbox insert까지를 하나의 트랜잭션 경계로 묶고, 해당 `creator_following` row를 팔로우·언팔로우 상태 변경과 같은 순서로 잠근다. publish가 먼저 끝나면 뒤이은 언팔로우가 새 row까지 비활성화하고, 언팔로우가 먼저 끝나면 publish가 row를 생성하지 않는 두 순서를 동시성 테스트로 고정한다.
**판정 기록**
- 2026-07-30 — publish/follow/unfollow 호출 흐름과 트랜잭션 경계를 대조해 확정했다. 런타임 테스트는 실행하지 않았다.
### plan·goal 전환
- `plan-task.md` Phase 4에 Task 4.6, `P4-R1`, `P4-R-GATE`를 추가했다.
- 기존 Task 4.1~4.5의 완료 체크와 검증 기록은 유지했다.
### 2차 리뷰 종료 판정
| 판정 항목 | 결과 | 근거 |
|---|---|---|
| 리뷰 범위 전체 확인 | 충족 | publish/follow/unfollow 트랜잭션 경계 정적 추적 |
| 후보 항목 판정 완료 | 충족 | `REV-P4-001` 확정 |
| 확정 항목 plan 반영 | 충족 | Task 4.6 / `P4-R1` |
| 검증 명령과 결과 기록 | 충족 | 테스트 미실행 사유 기록 |
**최종 결론:** 수정 goal 필요.
**남은 항목:** 없음. `P4-R1``P4-R-GATE` 수정 후 검증은 아래 기록에 누적했다.
## 8. 수정 후 검증 — 2026-07-30
- RED 확인: `HomeFollowingNewsInboxPersistenceAdapterTest.shouldNotExposeNewsCreatedBeforeUnfollowAfterRefollowWhenPublishIsDelayed` 실행 결과 stale active inbox assertion 실패로 `BUILD FAILED`.
- follower 조회와 inbox insert 경계에서 `creator_following` row를 잠그도록 active follower 조회에 `for update`를 적용했고, `creatorFollow(...)`/`creatorUnFollow(...)`도 같은 row를 `PESSIMISTIC_WRITE`로 조회하도록 변경했다.
- GREEN 확인: 동시성 단일 테스트 재실행 결과 `BUILD SUCCESSFUL`.
- P4-R1 focused test `HomeFollowingNewsInboxPersistenceAdapterTest`, `HomeFollowingNewsPublishServiceTest`, `MemberServiceTest` 실행 결과 `BUILD SUCCESSFUL`.
- P4-R-GATE E2E `HomeFollowingEndToEndTest` 실행 결과 `BUILD SUCCESSFUL`.
- P4-R-GATE lint `ktlintCheck` 최초 실행은 테스트 import 정렬 위반으로 `BUILD FAILED`; import 정렬 수정 후 재실행한다.
- P4-R-GATE lint `ktlintCheck` 재실행 결과 `BUILD SUCCESSFUL`.
- 리뷰 게이트에서 Critical/Important/Minor blocker 없음으로 승인됐다.
**수정 후 결론:** `REV-P4-001` 보완 완료.
## 9. 3차 정적 리뷰 — 2026-07-30
### 리뷰 범위
- 공개 팔로우·언팔로우 controller 경로, `MemberService` 상태 전이, inbox 비활성화와 재팔로우 조회 결과를 함께 정적으로 추적했다.
- 컴파일과 테스트는 사용자 지시에 따라 실행하지 않았다.
### REV-P4-002 — 통합 팔로우 API의 `isActive=false` 경로가 기존 inbox를 비활성화하지 않음
**심각도:** High
**상태:** 확정
**관련 요구사항:** PRD Feature F의 언팔로우 시 기존 inbox 비활성화, 재팔로우 시 기존 inbox 미복구
**소유 Task:** Task 4.7 / `P4-R2`
**관찰 내용**
`POST /member/creator/follow`는 request의 `isActive=false``MemberService.creatorFollow(...)`에 전달해 팔로우·언팔로우를 함께 처리한다. 관계 row가 없으면 전달된 `isActive`를 반영하지 않은 기본 active 관계를 만들고, 기존 관계 row가 있으면 `creatorFollowing.isActive=false`만 반영한 채 inbox 비활성화를 호출하지 않는다. 이후 같은 API로 다시 활성화하면 기존 inbox row가 계속 active이므로 active-following 조회 조건을 다시 만족해 언팔로우 이전 소식이 노출될 수 있다.
**근거**
- `MemberController.creatorFollow(...)`는 nullable `request.isActive`를 기본값과 함께 service에 전달한다.
- `CreatorFollowRequest``isActive`를 공개 request 필드로 정의한다.
- `MemberService.creatorFollow(...)`의 신규 관계 분기는 기본값이 `isActive=true``CreatorFollowing()`을 저장하고, 기존 관계 분기는 `isNotify``isActive`만 변경한다.
- `MemberService.creatorUnFollow(...)``homeFollowingNewsInboxPort.deactivateByMemberIdAndCreatorId(...)`를 호출한다.
- `MemberServiceTest`는 전용 `creatorUnFollow(...)` 경로만 검증하며 `creatorFollow(..., isActive=false)` 경로는 검증하지 않는다.
**정적 재현 절차**
1. 관계가 없는 회원이 `POST /member/creator/follow``isActive=false`를 보내면 기본 active 관계가 새로 생성된다.
2. 별도로 active 팔로우와 active inbox row가 있는 회원이 같은 요청을 보내면 관계 row만 inactive가 되고 inbox는 active로 남는다.
3. 같은 API에 `isActive=true`를 보내 재팔로우한다.
4. 기존 active inbox가 다시 active-following 조건을 만족해 최근 소식 조회 후보가 된다.
**영향**
관계가 없는 회원은 언팔로우 요청으로 오히려 active follower가 될 수 있다. 기존 follower는 언팔로우 경로에 따라 과거 소식 보존 상태가 달라지고, 통합 언팔로우 경로에서 재팔로우 이후 과거 소식 미복구 계약을 위반한다.
**권장 조치**
관계가 없고 `isActive=false`면 전용 언팔로우 경로와 동일하게 새 active 관계를 만들지 않는다. 기존 관계를 `isActive=false`로 반영하는 같은 트랜잭션에서는 inbox를 비활성화한다. `isActive=true`인 알림 변경·재팔로우는 기존 비활성 inbox를 복구하지 않도록 유지하고, 두 경우를 `MemberServiceTest`로 고정한다.
**판정 기록**
- 2026-07-30 — request/controller/service의 공개 `isActive=false` 흐름과 PRD 재팔로우 정책을 정적으로 대조해 확정했다. 런타임 테스트는 실행하지 않았다.
### plan·goal 전환
- `plan-task.md` Phase 4에 Task 4.7, `P4-R2`, `P4-R2-GATE`를 추가했다.
- 기존 Task 4.1~4.6의 완료 체크와 검증 기록은 유지했다.
### 3차 리뷰 종료 판정
| 판정 항목 | 결과 | 근거 |
|---|---|---|
| 리뷰 범위 전체 확인 | 충족 | 두 공개 상태 변경 경로와 조회 조건 정적 추적 |
| 후보 항목 판정 완료 | 충족 | `REV-P4-002` 확정 |
| 확정 항목 plan 반영 | 충족 | Task 4.7 / `P4-R2` |
| 검증 명령과 결과 기록 | 충족 | 테스트 미실행 사유와 정적 근거 기록 |
**최종 결론:** 수정 goal 필요.
**남은 항목:** 없음. `P4-R2``P4-R2-GATE` 수정 후 검증은 아래 기록에 누적했다.
## 10. 3차 수정 후 검증 — 2026-07-30
- RED 확인: `./gradlew --no-daemon test --tests "kr.co.vividnext.sodalive.member.MemberServiceTest"` 실행 결과 `creatorFollow(..., isActive=false)` 신규 회귀 2건이 assertion 실패로 `BUILD FAILED`.
- 관계가 없고 `isActive=false`인 통합 팔로우 요청은 새 active 관계를 만들지 않고, 기존 관계를 inactive로 바꾸는 경로에서는 `homeFollowingNewsInboxPort.deactivateByMemberIdAndCreatorId(...)`를 호출하도록 `MemberService.creatorFollow(...)`만 최소 수정했다.
- GREEN 확인: 같은 `MemberServiceTest` 명령 재실행 결과 `BUILD SUCCESSFUL`.
- P4-R2-GATE 확인: `./gradlew --no-daemon test --tests "kr.co.vividnext.sodalive.v2.home.following.adapter.out.persistence.HomeFollowingNewsInboxPersistenceAdapterTest" --tests "kr.co.vividnext.sodalive.v2.home.following.application.HomeFollowingNewsPublishServiceTest"` 실행 결과 `BUILD SUCCESSFUL`.
- P4-R2-GATE lint `./gradlew --no-daemon ktlintCheck` 최초 실행은 신규 테스트 줄 길이 위반으로 `BUILD FAILED`; 포맷 정리 후 `MemberServiceTest``ktlintCheck` 재실행 결과 모두 `BUILD SUCCESSFUL`.
- 추가 전체 회귀 확인: `./gradlew --no-daemon test` 실행 결과 `BUILD SUCCESSFUL`.
- 리뷰 게이트에서 Critical/Important/Minor blocker 없음으로 승인됐다.
**수정 후 결론:** `REV-P4-002` 보완 완료.
## 11. 4차 정적 리뷰 — 2026-07-30
- source key, after-commit 발행, follower fan-out, 랭킹·오디오·무료 커뮤니티 호출부와 발행 실패 격리를 다시 추적했다.
- `P4-R1``creator_following` 잠금 순서와 `P4-R2`의 통합 `isActive=false` 경로가 전용 언팔로우와 같은 inbox 최종 상태를 만드는지 확인했다.
- persistence·publish·`MemberService` 테스트가 중복 방지, 동시 publish/unfollow, 신규 inactive 요청과 재팔로우 미복구를 고정하는지 정적으로 확인했다.
- 컴파일과 테스트는 사용자 지시에 따라 실행하지 않았다.
- 신규 확정 발견 사항 없음. `REV-P4-001`, `REV-P4-002` 수정 완료와 기존 Phase 4 완료 판정을 유지한다.

View File

@@ -0,0 +1,58 @@
# Phase 5 코드 리뷰 보고서
## 1. 리뷰 정보
| 항목 | 내용 |
|---|---|
| 리뷰 대상 | Phase 5 / Task 5.1~5.2 |
| 기준 commit 또는 working tree | `e6f56f24` + 2026-07-30 working tree |
| 리뷰 일자 | 2026-07-30 |
| 리뷰어 | Codex |
| 기준 문서 | `prd.md` Feature A·D, `plan-task.md` Phase 5 |
| 리뷰 상태 | 판정 완료 |
## 2. 목적과 범위
- facade의 비회원 단락, 도메인 조회와 기존 최근 대화 10개 조립, E2E API 표면을 정적으로 대조했다.
- `HomeFollowingFacade.kt`, facade/controller/E2E 테스트를 검토했다.
- 사용자의 지시에 따라 컴파일과 테스트는 실행하지 않았다.
## 3. 검토 근거
- 비회원은 query/chat service 호출 없이 로그인 필요 응답을 받는다.
- 로그인 회원은 `ChatRoomListService.getRooms(member, "ALL", null, 10)` 결과를 도메인 조회 결과에 조립한다.
- E2E 테스트는 비회원 빈 섹션과 로그인 회원의 다섯 섹션, 최근 소식 JSON surface를 검증한다.
## 4. 발견 사항
확정 발견 사항 없음.
## 5. plan·goal 전환
전환 항목 없음.
## 6. 리뷰 종료 판정
| 판정 항목 | 결과 | 근거 |
|---|---|---|
| 리뷰 범위 전체 확인 | 충족 | facade/controller/E2E 정적 대조 |
| 후보 항목 판정 완료 | 충족 | 후보 없음 |
| 확정 항목 plan 반영 | 해당 없음 | 확정 발견 사항 없음 |
| 검증 명령과 결과 기록 | 충족 | 테스트 미실행 사유 기록 |
**최종 결론:** 확정 발견 사항 없음.
**남은 항목:** 없음.
## 7. 3차 정적 리뷰 — 2026-07-30
- facade의 로그인 분기, 최근 대화 재사용, E2E 조립 범위를 현재 working tree 기준으로 다시 대조했다.
- 컴파일과 테스트는 사용자 지시에 따라 실행하지 않았다.
- 신규 확정 발견 사항 없음. 기존 Phase 5 완료 판정을 유지한다.
## 8. 4차 정적 리뷰 — 2026-07-30
- 비회원 조기 반환, 로그인 회원의 도메인 조회와 `ChatRoomListService.getRooms(..., limit = 10)` 조립을 다시 대조했다.
- controller/facade/E2E 테스트가 비회원의 조회 생략과 로그인 회원의 다섯 섹션 공개 응답을 고정하는지 정적으로 확인했다.
- 컴파일과 테스트는 사용자 지시에 따라 실행하지 않았다.
- 신규 확정 발견 사항 없음. 기존 Phase 5 완료 판정을 유지한다.

View File

@@ -0,0 +1,60 @@
# Phase 5.5 코드 리뷰 보고서
## 1. 리뷰 정보
| 항목 | 내용 |
|---|---|
| 리뷰 대상 | Phase 5.5 / Task 5.5.1~5.5.5 |
| 기준 commit 또는 working tree | `e6f56f24` + 2026-07-30 working tree |
| 리뷰 일자 | 2026-07-30 |
| 리뷰어 | Codex |
| 기준 문서 | `prd.md` Feature F·G, `plan-task.md` Phase 5.5 |
| 리뷰 상태 | 판정 완료 |
## 2. 목적과 범위
- 타입별 nullable nested DTO, 원천 target enrichment, 무료 커뮤니티 전용 발행과 E2E JSON 계약을 정적으로 대조했다.
- DTO/domain/repository/커뮤니티 호출부와 대응 테스트를 포함했다.
- 사용자의 지시에 따라 컴파일과 테스트는 실행하지 않았다.
## 3. 검토 근거
- 최근 소식 최상위는 공통 필드와 다섯 nested 필드만 노출하고 flat 이동/표시 필드를 제거했다.
- repository는 랭킹, 활성 오디오, 활성 무료 커뮤니티 원천을 타입별 payload로 조립한다.
- 커뮤니티 좋아요와 최상위 댓글은 active row만 집계한다.
- 유료 커뮤니티는 발행과 조회 양쪽에서 제외된다.
- DTO 및 E2E 테스트는 nested payload의 상호 배타적 null 계약과 제거된 flat 필드를 검증한다.
## 4. 발견 사항
확정 발견 사항 없음.
## 5. plan·goal 전환
전환 항목 없음.
## 6. 리뷰 종료 판정
| 판정 항목 | 결과 | 근거 |
|---|---|---|
| 리뷰 범위 전체 확인 | 충족 | DTO/repository/call site/test 정적 대조 |
| 후보 항목 판정 완료 | 충족 | 후보 없음 |
| 확정 항목 plan 반영 | 해당 없음 | 확정 발견 사항 없음 |
| 검증 명령과 결과 기록 | 충족 | 테스트 미실행 사유 기록 |
**최종 결론:** 확정 발견 사항 없음.
**남은 항목:** 없음.
## 7. 3차 정적 리뷰 — 2026-07-30
- 타입별 nullable nested DTO, 원천 target enrichment, 무료 커뮤니티 발행 제한과 E2E JSON 계약을 현재 working tree 기준으로 다시 대조했다.
- 컴파일과 테스트는 사용자 지시에 따라 실행하지 않았다.
- 신규 확정 발견 사항 없음. 기존 Phase 5.5 완료 판정을 유지한다.
## 8. 4차 정적 리뷰 — 2026-07-30
- 최근 소식 공통 필드와 타입별 상호 배타적 nested DTO, CDN/UTC 변환, 원천 오디오·무료 커뮤니티 enrichment를 다시 대조했다.
- 유료 커뮤니티와 비활성·성인 원천 target 제외, active like·top-level comment 집계가 repository와 E2E 테스트에 연결되는지 확인했다.
- 컴파일과 테스트는 사용자 지시에 따라 실행하지 않았다.
- 신규 확정 발견 사항 없음. 기존 Phase 5.5 완료 판정을 유지한다.

View File

@@ -0,0 +1,185 @@
# Phase 6 코드 리뷰 보고서
## 1. 리뷰 정보
| 항목 | 내용 |
|---|---|
| 리뷰 대상 | Phase 6 / Task 6.1~6.2 |
| 기준 commit 또는 working tree | `e6f56f24` + 2026-07-30 working tree |
| 리뷰 일자 | 2026-07-30 |
| 리뷰어 | Codex |
| 기준 문서 | `prd.md`, `plan-task.md`, DDL, 기존 검증 기록 |
| 리뷰 상태 | 판정 완료 |
## 2. 목적과 범위
- PRD, 구현 계획, DDL, 실제 공개 DTO의 현재 계약과 Phase별 기존 검증 기록을 정적으로 대조했다.
- 현재 compile/test 통과 상태는 사용자 설명과 `plan-task.md` 기존 기록을 근거로 삼았으며 직접 재실행하지 않았다.
## 3. 검토 근거
- PRD와 계획의 endpoint, 섹션 limit, nested 최근 소식 필드, 무료 커뮤니티 정책, DDL 유니크 키가 현재 코드와 일치한다.
- `plan-task.md`에는 Phase 1~7의 focused/회귀/lint 성공 기록과 실패 후 보완 이력이 누적되어 있다.
- 이번 리뷰에서 확정된 후속 결함은 기존 완료 상태를 되돌리지 않고 소유 Phase의 신규 Task로 추가했다.
- `./gradlew tasks --all` 최초 실행은 sandbox의 사용자 Gradle cache 접근 제한으로 실패했고, 승인된 권한으로 재실행한 결과
`BUILD SUCCESSFUL`이었다. task 목록만 확인했으며 컴파일과 테스트는 실행하지 않았다.
## 4. 발견 사항
확정 발견 사항 없음.
## 5. plan·goal 전환
전환 항목 없음. Phase 3과 Phase 7의 코드 발견 사항은 각 Phase 보고서와 Task에 귀속했다.
## 6. 리뷰 종료 판정
| 판정 항목 | 결과 | 근거 |
|---|---|---|
| 리뷰 범위 전체 확인 | 충족 | PRD/plan/DDL/code/기존 기록 정적 대조 |
| 후보 항목 판정 완료 | 충족 | Phase 6 자체 후보 없음 |
| 확정 항목 plan 반영 | 해당 없음 | 다른 Phase 소유 항목은 해당 Phase에 반영 |
| 검증 명령과 결과 기록 | 충족 | 테스트 미실행 사유와 기존 기록 분리 |
**최종 결론:** 확정 발견 사항 없음.
**남은 항목:** 없음. Phase 3·7 회귀 Goal과 Phase 6 후속 문서 검증 기록은 이후 섹션에 누적했다.
## 7. 2차 리뷰 — 2026-07-30
### 기존 후속 항목 확인
- Phase 3의 `P3-R1`과 Phase 7의 `P7-R1`·`P7-R2` 구현 및 기존 Gate 성공 기록이 누적된 것을 확인했다.
- 이번 확인에서는 컴파일과 테스트를 재실행하지 않았다.
### REV-P6-001 — PRD의 `newsId` 형식이 현재 공개 동작과 불일치
**심각도:** Low
**상태:** 확정
**근거**
- PRD 공통 식별자 설명은 `scheduleId``newsId` 모두 `{TYPE}:{targetId}` 형식을 기본안으로 적고 있다.
- `DefaultHomeFollowingQueryRepository``home_following_news_inbox.id`를 문자열로 변환해 `newsId`로 반환한다.
- repository test와 E2E도 inbox PK의 10진 문자열을 계약으로 검증한다.
- 최근 소식 정렬과 `CONTENT_RANKING` 동률 해소도 inbox `id`를 사용하므로, 현재 동작을 유지한 문서 수정이 가장 작은 정합성 보완이다.
**영향**
클라이언트 또는 후속 구현자가 PRD만 보면 `newsId`를 타입·target 기반 식별자로 해석할 수 있어 실제 응답 파싱, 정렬 의미, 동일 target의 여러 소식 식별을 잘못 구현할 수 있다.
**권장 조치**
runtime 코드와 공개 응답 값은 변경하지 않는다. PRD에서 `scheduleId``{TYPE}:{targetId}`, `newsId``home_following_news_inbox.id`의 10진 문자열이며 같은 노출 시각의 정렬·동률 해소에 쓰인다고 분리해 명시한다.
**판정 기록**
- 2026-07-30 — PRD, repository, repository test, E2E를 정적으로 대조해 확정했다. Gradle 명령은 실행하지 않았다.
### plan·goal 전환
- `plan-task.md` Phase 6에 문서 전용 Task 6.3, `P6-R1`, `P6-R-GATE`를 추가했다.
- 기존 Task 6.1~6.2의 완료 체크와 검증 기록은 유지했다.
### 2차 리뷰 종료 판정
| 판정 항목 | 결과 | 근거 |
|---|---|---|
| 리뷰 범위 전체 확인 | 충족 | PRD/repository/test/E2E 식별자 계약 정적 대조 |
| 후보 항목 판정 완료 | 충족 | `REV-P6-001` 확정 |
| 확정 항목 plan 반영 | 충족 | Task 6.3 / `P6-R1` |
| 검증 명령과 결과 기록 | 충족 | Gradle 미실행 사유 기록 |
**최종 결론:** 문서 수정 goal 필요.
**남은 항목:** 없음. `P6-R1``P6-R-GATE` 수정 후 검증은 아래 기록에 누적했다.
## 8. 수정 후 검증 — 2026-07-30
- PRD에서 `scheduleId``newsId` 식별자 설명을 분리했다.
- `scheduleId``{TYPE}:{targetId}` 형식, `newsId``home_following_news_inbox.id`의 10진 문자열이며 `visibleFromAtUtc desc`, `newsId desc` 정렬과 `CONTENT_RANKING` 동률 해소에 사용한다고 명시했다.
- runtime 코드, 테스트, DDL, 공개 응답 필드는 변경하지 않았다.
- 정적 검색 `rg -n "newsId|scheduleId|home_following_news_inbox\.id" ...` 실행 결과 PRD와 repository/test/E2E의 식별자 계약이 일치함을 확인했다.
- `git diff --check` 실행 결과 오류 없음.
- `./gradlew tasks --all` 실행 결과 `BUILD SUCCESSFUL`.
- 리뷰 게이트에서 Critical/Important/Minor blocker 없음으로 승인됐다.
**수정 후 결론:** `REV-P6-001` 문서 보완 완료.
## 9. 3차 정적 리뷰 — 2026-07-30
### 리뷰 범위
- PRD의 최근 소식 최신 배치 정책, Phase 7 회귀 Task, repository 조건과 회귀 테스트를 정적으로 대조했다.
- 컴파일과 테스트는 사용자 지시에 따라 실행하지 않았다.
### REV-P6-002 — PRD의 최신 크리에이터 랭킹 배치 판정 기준이 현재 구현과 불일치
**심각도:** Low
**상태:** 확정
**관련 요구사항:** 최신 공개 `WEEKLY` 배치만 조회하고 빈 최신 배치에서 과거 순위를 보충하지 않는 정책
**소유 Task:** Task 6.4 / `P6-R2`
**관찰 내용**
PRD Feature F와 기술 제약은 최신 `CREATOR_RANKING` 배치를 `creator_ranking_snapshot`의 최대 공개 시각으로 판정한다고 기술한다. 그러나 `REV-P7-001` 보완 이후 현재 repository는 적용 가능한 `creator_ranking_snapshot_job`의 최신 `WEEKLY`, `DONE`, `visibleFromAtUtc <= nowUtc` 시각을 우선 사용하고, 해당 job이 전혀 없을 때만 snapshot으로 fallback한다.
**근거**
- PRD의 최신 배치 설명 두 곳은 `creator_ranking_snapshot`만 판정 원천으로 명시한다.
- `plan-task.md` Task 7.2는 빈 최신 완료 배치를 식별하기 위해 job 우선·legacy snapshot 제한 fallback을 확정했다.
- `DefaultHomeFollowingQueryRepository.latestVisibleCreatorRankingBatchCondition(...)`은 최신 DONE job 시각을 우선하고 `hasRankingJob.not()`일 때만 snapshot 시각을 사용한다.
- `DefaultHomeFollowingQueryRepositoryTest.shouldExcludeCreatorRankingNewsWhenLatestDoneBatchIsEmpty`는 최신 DONE job의 snapshot row가 0건이어도 과거 소식을 반환하지 않는 현재 계약을 검증한다.
**영향**
후속 구현자가 PRD만 따르면 최신 빈 완료 배치에서 과거 snapshot을 다시 최신으로 판정해 이미 수정한 회귀를 재도입할 수 있다.
**권장 조치**
runtime은 변경하지 않는다. PRD 두 곳을 최신 공개 DONE job 우선으로 동기화하고, 적용 가능한 DONE job이 전혀 없는 legacy/backfill 데이터에서만 snapshot fallback을 허용하며 빈 DONE 배치는 과거 snapshot으로 보충하지 않는다고 명시한다.
**판정 기록**
- 2026-07-30 — PRD, Task 7.2, repository, 빈 최신 배치 회귀 테스트를 정적으로 대조해 확정했다. Gradle 명령은 실행하지 않았다.
### plan·goal 전환
- `plan-task.md` Phase 6에 문서 전용 Task 6.4, `P6-R2`, `P6-R2-GATE`를 추가했다.
- 기존 Task 6.1~6.3의 완료 체크와 검증 기록은 유지했다.
### 3차 리뷰 종료 판정
| 판정 항목 | 결과 | 근거 |
|---|---|---|
| 리뷰 범위 전체 확인 | 충족 | PRD/Task/repository/test 최신 배치 기준 정적 대조 |
| 후보 항목 판정 완료 | 충족 | `REV-P6-002` 확정 |
| 확정 항목 plan 반영 | 충족 | Task 6.4 / `P6-R2` |
| 검증 명령과 결과 기록 | 충족 | Gradle 미실행 사유와 정적 근거 기록 |
**최종 결론:** 문서 수정 goal 필요.
**남은 항목:** 없음. `P6-R2``P6-R2-GATE` 수정 후 검증은 아래 기록에 누적했다.
## 10. 3차 수정 후 검증 — 2026-07-30
- PRD Feature F와 최근 소식 Inbox 기술 제약의 `CREATOR_RANKING` 최신 공개 배치 설명을 현재 구현의 `creator_ranking_snapshot_job` 최신 `WEEKLY`, `DONE`, `visibleFromAtUtc <= nowUtc` 우선 기준과 일치하도록 갱신했다.
- 적용 가능한 완료 job이 전혀 없는 legacy/backfill 데이터에서만 `creator_ranking_snapshot` fallback을 허용하고, 최신 완료 job의 결과가 0건이면 과거 snapshot으로 보충하지 않는다고 명시했다.
- P6-R2-GATE 정적 검색 `rg -n "creator_ranking_snapshot_job|creator_ranking_snapshot|최신 공개 배치|WEEKLY|DONE|legacy|fallback" ...` 실행 결과 PRD, Task 7.2, repository, 빈 최신 배치 회귀 테스트의 기준을 대조했다. 검색 결과의 과거 Task 7.1 snapshot 설명은 Task 7.2에서 superseded된 완료 기록으로 확인했다.
- P6-R2-GATE `git diff --check` 실행 결과 오류 없음.
- P6-R2-GATE `./gradlew tasks --all` 실행 결과 `BUILD SUCCESSFUL`.
- 추가 전체 회귀 확인: `./gradlew --no-daemon test` 실행 결과 `BUILD SUCCESSFUL`.
- 리뷰 게이트에서 Critical/Important/Minor blocker 없음으로 승인됐다.
**수정 후 결론:** `REV-P6-002` 문서 보완 완료.
## 11. 4차 정적 리뷰 — 2026-07-30
- PRD·plan·DDL·공개 DTO와 현재 repository의 endpoint, limit, 식별자, latest ranking batch, nested 최근 소식 계약을 다시 대조했다.
- `newsId`의 inbox PK 문자열 계약과 최신 `WEEKLY`, `DONE` job 우선·legacy snapshot 제한 fallback 설명이 현재 코드·테스트와 일치함을 확인했다.
- 컴파일과 테스트는 사용자 지시에 따라 실행하지 않았다.
- 신규 확정 발견 사항 없음. `REV-P6-001`, `REV-P6-002` 문서 보완 완료와 기존 Phase 6 완료 판정을 유지한다.

View File

@@ -0,0 +1,162 @@
# Phase 7 코드 리뷰 보고서
## 1. 리뷰 정보
| 항목 | 내용 |
|---|---|
| 리뷰 대상 | Phase 7 / Task 7.1 |
| 기준 commit 또는 working tree | `e6f56f24` + 2026-07-30 working tree |
| 리뷰 일자 | 2026-07-30 |
| 리뷰어 | Codex |
| 기준 문서 | `prd.md` Feature F·Decision Log, `plan-task.md` Phase 7 |
| 리뷰 상태 | 판정 완료 |
## 2. 목적과 범위
- 최신 공개 크리에이터 랭킹 배치 필터와 콘텐츠별 최신 랭킹 중복 제거가 모든 확정 edge case에서 limit 전에 적용되는지 검토했다.
- 현재 working tree의 repository, repository test, E2E fixture 변경을 포함했다.
- 사용자의 지시에 따라 컴파일과 테스트는 실행하지 않았다. 기존 Phase 7 성공 기록은 참고 증거로만 사용했다.
## 3. 발견 사항 요약
| ID | 심각도 | 상태 | 제목 | 소유 Task | 후속 goal |
|---|---|---|---|---|---|
| `REV-P7-001` | High | 확정 | 최신 완료 배치가 0건이면 과거 크리에이터 랭킹이 다시 최신으로 판정된다 | Task 7.2 | `P7-R1` |
| `REV-P7-002` | High | 확정 | 노출 불가능한 최신 콘텐츠 랭킹 row가 이전의 노출 가능한 row를 가린다 | Task 7.3 | `P7-R2` |
## 4. 발견 사항 상세
### REV-P7-001 — 최신 완료 배치가 0건이면 과거 크리에이터 랭킹이 다시 최신으로 판정된다
- **심각도:** High
- **상태:** 확정
- **관련 요구사항:** 최신 공개 배치만 노출, 최신 배치에 없는 크리에이터의 과거 순위 미보충
- **소유 Task:** 신규 Task 7.2 / `P7-R1`
**관찰 내용**
최신 배치 조건은 `creator_ranking_snapshot.visibleFromAtUtc.max()`만 조회한다. 최신 집계가 성공했지만 점수 조건을 통과한
snapshot이 0건이면 해당 배치를 나타내는 snapshot row가 없으므로 max 값은 직전 배치 시각으로 남는다.
랭킹 job은 결과가 0건이어도 `creator_ranking_snapshot_job``WEEKLY`, `DONE`, `visibleFromAtUtc`를 남기므로 완료 배치를
식별할 근거가 이미 있지만 현재 팔로잉 조회는 이를 사용하지 않는다.
**근거**
- 코드: `DefaultHomeFollowingQueryRepository.kt:428-451`
- 코드: `DefaultCreatorRankingSnapshotRepository.kt:51-65`
- 코드: `CreatorRankingSnapshotJobService.kt:52-69`
- 테스트 공백: `shouldFindOnlyLatestVisibleCreatorRankingBatchInRecentNews`는 최신 배치에 snapshot이 1건 이상인 경우만 검증한다.
**정적 재현 절차**
1. 직전 주간 snapshot과 같은 시각의 `CREATOR_RANKING` inbox를 둔다.
2. 더 최신 주간 job을 `DONE`으로 완료하지만 집계 결과 snapshot은 0건으로 둔다.
3. 최신 job 공개 시각 이후 API를 조회한다.
4. 요구 결과는 크리에이터 랭킹 0건이지만, 현재 subquery max는 직전 snapshot 시각이므로 과거 inbox가 노출된다.
**영향**
활동 부족 등으로 최신 배치 결과가 비어 있는 주에 명시적으로 금지된 과거 랭킹 보충이 발생한다.
**권장 조치**
결과 row와 독립적으로 남는 최신 공개 `DONE` job을 배치 식별 기준으로 사용하고, 최신 빈 배치에서는 과거 snapshot으로
fallback하지 않는 회귀 테스트를 추가한다.
**판정 기록**
- 2026-07-30 — snapshot 교체와 job 완료 흐름을 정적으로 추적해 확정했다.
### REV-P7-002 — 노출 불가능한 최신 콘텐츠 랭킹 row가 이전의 노출 가능한 row를 가린다
- **심각도:** High
- **상태:** 확정
- **관련 요구사항:** 같은 콘텐츠의 노출 가능한 row 중 최신 1건 조회
- **소유 Task:** 신규 Task 7.3 / `P7-R2`
**관찰 내용**
외부 row는 `CONTENT_RANKING``rank is not null`, inbox 성인 조건 등으로 노출 가능 여부를 확인한다. 그러나
`latestContentRankingNewsCondition(...)`의 newer-row subquery는 member/type/target/active/visible/order만 확인하고
`rank is not null`과 회원별 성인 조건을 확인하지 않는다.
따라서 더 최신 row의 `rank`가 null이면 최신 row는 외부 조건에서 제외되면서도 이전 정상 row를 subquery에서 가려 결과가 0건이 된다.
**근거**
- 코드: `DefaultHomeFollowingQueryRepository.kt:154-166`
- 코드: `DefaultHomeFollowingQueryRepository.kt:428-438`
- 코드: `DefaultHomeFollowingQueryRepository.kt:454-471`
- 테스트 공백: 현재 콘텐츠 중복 테스트는 모든 newer row가 노출 가능한 경우만 검증한다.
**정적 재현 절차**
1. 같은 회원·콘텐츠에 `rank=2`, 노출 가능 시각 09:00인 정상 row를 둔다.
2. 같은 키에 `rank=null`, 노출 가능 시각 10:00인 active row를 둔다.
3. 10:00 이후 조회하면 이전 row는 newer row 존재로 제외되고, newer row는 `rank is not null` 조건에서 제외된다.
4. 요구 결과는 노출 가능한 row 중 최신인 09:00 row 1건이지만 실제 결과는 0건이다.
**영향**
부분 적재나 정책 변경으로 최신 inbox row가 노출 불가능해지면 유효한 이전 콘텐츠 랭킹까지 사라져 “노출 가능한 row 중 최신” 계약을 위반한다.
**권장 조치**
newer-row 판정에 외부 조회와 동일한 row별 노출 조건을 적용한다. 최소한 `rank is not null`과 회원별 inbox 성인 조건의 회귀
테스트를 추가하고 기존 시각/id tie-break 테스트를 유지한다.
**판정 기록**
- 2026-07-30 — 외부 where와 correlated subquery 조건을 대조해 확정했다.
## 5. plan·goal 전환
- `plan-task.md` Phase 7에 Task 7.2/7.3, `P7-R1`, `P7-R2`, `P7-R-GATE`를 추가했다.
- 기존 Task 7.1과 `P7-GATE`의 완료 기록은 유지했다.
## 6. 리뷰 종료 판정
| 판정 항목 | 결과 | 근거 |
|---|---|---|
| 리뷰 범위 전체 확인 | 충족 | working tree repository/test/E2E 정적 대조 |
| 후보 항목 판정 완료 | 충족 | `REV-P7-001`, `REV-P7-002` 확정 |
| 확정 항목 plan 반영 | 충족 | Task 7.2/7.3 |
| 검증 명령과 결과 기록 | 충족 | 테스트 미실행 사유 기록 |
**최종 결론:** 수정 goal 필요.
**남은 항목:** `P7-R1``P7-R2``P7-R-GATE` 실행 후 이 문서에 수정 검증을 누적한다.
## 7. 수정 후 검증 기록
- 2026-07-30 — RED 확인: `./gradlew --no-daemon test --tests "kr.co.vividnext.sodalive.v2.home.following.application.HomeFollowingQueryServiceTest" --tests "kr.co.vividnext.sodalive.v2.home.following.adapter.out.persistence.DefaultHomeFollowingQueryRepositoryTest"` 실행 결과 최신 빈 배치와 노출 불가 newer row 보완 전 컴파일/회귀 실패를 확인했다.
- 2026-07-30 — `CREATOR_RANKING` 최신 배치 기준을 `creator_ranking_snapshot_job`의 최신 `WEEKLY`, `DONE`, `visibleFromAtUtc <= nowUtc`로 보강해 최신 완료 배치가 비어 있어도 과거 배치를 보충하지 않도록 했다.
- 2026-07-30 — `CONTENT_RANKING` newer row 판정에 `rank is not null`과 회원별 inbox 성인 조건을 적용해 노출 불가 최신 row가 이전 노출 가능 row를 가리지 않도록 했다.
- 2026-07-30 — GREEN 확인: 같은 focused test 명령 재실행 결과 `BUILD SUCCESSFUL`.
- 2026-07-30 — Gate 확인: `./gradlew --no-daemon test --tests "kr.co.vividnext.sodalive.v2.api.home.following.adapter.in.web.HomeFollowingEndToEndTest"``./gradlew --no-daemon ktlintCheck` 재실행 결과 모두 `BUILD SUCCESSFUL`.
- 2026-07-30 — 리뷰 게이트: Oracle reviewer가 Critical/Important blocker 없음으로 승인했다.
## 8. 후속 종료 판정 — 2026-07-30
- 최신 공개 크리에이터 랭킹 배치가 빈 결과여도 과거 배치로 fallback하지 않는 현재 job 기준 조건을 정적으로 재확인했다.
- `CONTENT_RANKING` newer row 판정에 `rank is not null`과 회원별 inbox 성인 조건이 적용된 현재 코드를 정적으로 재확인했다.
- 이번 후속 판정에서는 컴파일과 테스트를 재실행하지 않았고, 위 `## 7. 수정 후 검증 기록`의 기존 성공 결과를 근거로 삼았다.
**최종 결론:** `P7-R1`, `P7-R2``P7-R-GATE` 수정 검증 완료.
**남은 항목:** 없음.
## 9. 3차 정적 리뷰 — 2026-07-30
- 최신 DONE job 우선의 크리에이터 랭킹 배치 판정, legacy snapshot fallback, 노출 가능한 콘텐츠 랭킹 중복 제거를 현재 working tree에서 다시 추적했다.
- 컴파일과 테스트는 사용자 지시에 따라 실행하지 않았다.
- runtime 신규 확정 발견 사항 없음. PRD의 stale 최신 배치 설명은 문서 소유 Phase의 `REV-P6-002`와 Task 6.4로 전환했으며, 기존 Phase 7 완료 판정을 유지한다.
## 10. 4차 정적 리뷰 — 2026-07-30
- 최신 공개 `WEEKLY`, `DONE` job 기준과 job 부재 시 snapshot fallback, 최신 빈 배치의 과거 랭킹 미보충을 다시 추적했다.
- `CONTENT_RANKING`의 노출 가능한 newer row 판정에 active/rank/성인/visible/id 조건이 적용되고 전체 limit 전에 콘텐츠별 한 건만 남는지 확인했다.
- repository 테스트가 신규 배치 공개 전후, 빈 최신 배치, rank null·성인 newer row와 동률 id 해소를 고정하는지 정적으로 확인했다.
- 컴파일과 테스트는 사용자 지시에 따라 실행하지 않았다.
- 신규 확정 발견 사항 없음. `REV-P7-001`, `REV-P7-002` 수정 완료와 기존 Phase 7 완료 판정을 유지한다.

View File

@@ -36,6 +36,7 @@ import kr.co.vividnext.sodalive.i18n.translation.LanguageTranslationEvent
import kr.co.vividnext.sodalive.i18n.translation.LanguageTranslationTargetType
import kr.co.vividnext.sodalive.i18n.translation.ResourceTranslationJobScheduler
import kr.co.vividnext.sodalive.member.Member
import kr.co.vividnext.sodalive.member.MemberRepository
import kr.co.vividnext.sodalive.member.block.BlockMemberRepository
import kr.co.vividnext.sodalive.member.contentpreference.isAdultVisibleByPolicy
import kr.co.vividnext.sodalive.utils.generateFileName
@@ -57,6 +58,8 @@ import java.time.ZoneId
import java.time.format.DateTimeFormatter
import java.util.Locale
private const val MAX_PIN_CONTENT_COUNT = 9
@Service
@Transactional(readOnly = true)
class AudioContentService(
@@ -72,6 +75,7 @@ class AudioContentService(
private val commentRepository: AudioContentCommentRepository,
private val audioContentLikeRepository: AudioContentLikeRepository,
private val pinContentRepository: PinContentRepository,
private val memberRepository: MemberRepository,
private val resourceTranslationJobScheduler: ResourceTranslationJobScheduler,
private val contentTranslationRepository: ContentTranslationRepository,
@@ -795,7 +799,7 @@ class AudioContentService(
val pinContentListCount = pinContentRepository.getPinContentList(memberId = member.id!!, active = true).size
val isAvailablePin = if (member.id!! == audioContent.member!!.id!!) {
pinContentListCount < 3
pinContentListCount < MAX_PIN_CONTENT_COUNT
} else {
false
}
@@ -1227,7 +1231,9 @@ class AudioContentService(
@Transactional
fun pinToTheTop(contentId: Long, member: Member) {
val audioContent = repository.findByIdAndCreatorId(contentId = contentId, creatorId = member.id!!)
val creator = memberRepository.findByIdForUpdate(member.id!!)
?: throw SodaException(messageKey = "content.error.invalid_content_retry")
val audioContent = repository.findByIdAndCreatorId(contentId = contentId, creatorId = creator.id!!)
?: throw SodaException(messageKey = "content.error.invalid_content_retry")
if (audioContent.releaseDate != null && audioContent.releaseDate!! >= LocalDateTime.now()) {
@@ -1236,21 +1242,24 @@ class AudioContentService(
var pinContent = pinContentRepository.findByContentIdAndMemberId(
contentId = contentId,
memberId = member.id!!
memberId = creator.id!!
)
val activePinContentList = pinContentRepository.getPinContentList(memberId = creator.id!!, active = true)
if (pinContent != null) {
if (!pinContent.isActive && activePinContentList.size >= MAX_PIN_CONTENT_COUNT) {
activePinContentList[0].isActive = false
}
pinContent.isActive = true
} else {
val pinContentList = pinContentRepository.getPinContentList(memberId = member.id!!)
pinContent = if (pinContentList.size >= 3) {
pinContentList[0]
pinContent = if (activePinContentList.size >= MAX_PIN_CONTENT_COUNT) {
activePinContentList[0]
} else {
PinContent()
}
pinContent.isActive = true
pinContent.member = member
pinContent.member = creator
pinContent.content = audioContent
pinContentRepository.save(pinContent)
}
@@ -1258,9 +1267,11 @@ class AudioContentService(
@Transactional
fun unpinAtTheTop(contentId: Long, member: Member) {
val creator = memberRepository.findByIdForUpdate(member.id!!)
?: throw SodaException(messageKey = "content.error.invalid_content_retry")
val pinContent = pinContentRepository.findByContentIdAndMemberId(
contentId = contentId,
memberId = member.id!!
memberId = creator.id!!
) ?: throw SodaException(messageKey = "content.error.invalid_content_retry")
pinContent.isActive = false

View File

@@ -497,12 +497,14 @@ class MemberService(
fun creatorFollow(creatorId: Long, isNotify: Boolean, isActive: Boolean, memberId: Long) {
if (creatorId == memberId) return
val creatorFollowing = creatorFollowingRepository.findByCreatorIdAndMemberId(
val creatorFollowing = creatorFollowingRepository.findByCreatorIdAndMemberIdForUpdate(
creatorId = creatorId,
memberId = memberId
)
if (creatorFollowing == null) {
if (!isActive) return
val creator = repository.findByIdOrNull(creatorId)
?: throw SodaException(messageKey = "member.validation.creator_not_found")
val member = repository.findByIdOrNull(memberId)
@@ -515,12 +517,15 @@ class MemberService(
} else {
creatorFollowing.isNotify = isNotify && isActive
creatorFollowing.isActive = isActive
if (!isActive) {
homeFollowingNewsInboxPort.deactivateByMemberIdAndCreatorId(memberId = memberId, creatorId = creatorId)
}
}
}
@Transactional
fun creatorUnFollow(creatorId: Long, memberId: Long) {
val creatorFollowing = creatorFollowingRepository.findByCreatorIdAndMemberId(
val creatorFollowing = creatorFollowingRepository.findByCreatorIdAndMemberIdForUpdate(
creatorId = creatorId,
memberId = memberId
)

View File

@@ -4,12 +4,14 @@ import com.querydsl.jpa.impl.JPAQueryFactory
import kr.co.vividnext.sodalive.member.following.QCreatorFollowing.creatorFollowing
import org.springframework.data.jpa.repository.JpaRepository
import org.springframework.stereotype.Repository
import javax.persistence.LockModeType
@Repository
interface CreatorFollowingRepository : JpaRepository<CreatorFollowing, Long>, CreatorFollowingQueryRepository
interface CreatorFollowingQueryRepository {
fun findByCreatorIdAndMemberId(creatorId: Long, memberId: Long): CreatorFollowing?
fun findByCreatorIdAndMemberIdForUpdate(creatorId: Long, memberId: Long): CreatorFollowing?
}
@Repository
@@ -25,4 +27,15 @@ class CreatorFollowingQueryRepositoryImpl(
)
.fetchFirst()
}
override fun findByCreatorIdAndMemberIdForUpdate(creatorId: Long, memberId: Long): CreatorFollowing? {
return queryFactory
.selectFrom(creatorFollowing)
.where(
creatorFollowing.creator.id.eq(creatorId)
.and(creatorFollowing.member.id.eq(memberId))
)
.setLockMode(LockModeType.PESSIMISTIC_WRITE)
.fetchFirst()
}
}

View File

@@ -6,6 +6,7 @@ import kr.co.vividnext.sodalive.v2.creator.channel.home.application.CreatorChann
import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional
import java.time.LocalDateTime
import java.time.ZoneOffset
@Service
@Transactional(readOnly = true)
@@ -15,7 +16,7 @@ class CreatorChannelHomeFacade(
fun getHome(
creatorId: Long,
viewer: Member,
now: LocalDateTime = LocalDateTime.now()
now: LocalDateTime = LocalDateTime.now(ZoneOffset.UTC)
): CreatorChannelHomeResponse {
return CreatorChannelHomeResponse.from(
creatorChannelHomeQueryService.getHome(

View File

@@ -245,6 +245,7 @@ class HomeRecommendationFacade(
}
private fun RecentlyActiveCreatorRecord.toItem() = HomeActiveCreatorItem(
creatorId = creatorId,
creatorNickname = creatorNickname,
creatorProfileImage = profileImageUrl(cloudFrontHost, creatorProfileImage),
activityType = activityType.name,

View File

@@ -36,6 +36,7 @@ data class HomeLiveItem(
)
data class HomeActiveCreatorItem(
val creatorId: Long,
val creatorNickname: String,
val creatorProfileImage: String,
val activityType: String,

View File

@@ -9,6 +9,7 @@ import kr.co.vividnext.sodalive.content.ContentType
import kr.co.vividnext.sodalive.content.QAudioContent.audioContent
import kr.co.vividnext.sodalive.content.order.OrderType
import kr.co.vividnext.sodalive.content.order.QOrder.order
import kr.co.vividnext.sodalive.content.pin.QPinContent.pinContent
import kr.co.vividnext.sodalive.creator.admin.content.series.QSeries.series
import kr.co.vividnext.sodalive.creator.admin.content.series.QSeriesContent.seriesContent
import kr.co.vividnext.sodalive.explorer.profile.QCreatorCheers.creatorCheers
@@ -269,7 +270,7 @@ class DefaultCreatorChannelHomeQueryRepository(
viewerId: Long?,
limit: Int
): List<CreatorChannelAudioContentRecord> {
val rows = findAudioContentRows(creatorId, now, latestAudioContentId, canViewAdultContent, limit)
val rows = findAudioContentRows(creatorId, now, latestAudioContentId, canViewAdultContent, limit, pinFirst = true)
val contentIds = rows.map { itAudioId(it) }
val firstContentId = firstAudioContentId(creatorId, now, canViewAdultContent)
val seriesByContentId = audioSeriesByContentIds(contentIds)
@@ -435,21 +436,33 @@ class DefaultCreatorChannelHomeQueryRepository(
now: LocalDateTime,
excludedContentId: Long?,
canViewAdultContent: Boolean,
limit: Int
) = queryFactory
.select(
audioContent.id,
audioContent.title,
audioContent.duration,
audioContent.coverImage,
audioContent.price,
audioContent.isAdult,
audioContent.isPointAvailable,
audioContent.releaseDate,
audioContent.createdAt
)
.from(audioContent)
.where(
limit: Int,
pinFirst: Boolean = false
): List<com.querydsl.core.Tuple> {
val query = queryFactory
.select(
audioContent.id,
audioContent.title,
audioContent.duration,
audioContent.coverImage,
audioContent.price,
audioContent.isAdult,
audioContent.isPointAvailable,
audioContent.releaseDate,
audioContent.createdAt
)
.from(audioContent)
if (pinFirst) {
query.leftJoin(pinContent)
.on(
pinContent.member.id.eq(creatorId),
pinContent.content.id.eq(audioContent.id),
pinContent.isActive.isTrue
)
}
query.where(
audioContent.member.id.eq(creatorId),
audioContent.member.isActive.isTrue,
audioContent.isActive.isTrue,
@@ -459,9 +472,22 @@ class DefaultCreatorChannelHomeQueryRepository(
excludedContentId?.let { audioContent.id.ne(it) },
adultAudioCondition(canViewAdultContent)
)
.orderBy(audioContent.releaseDate.desc(), audioContent.id.desc())
.limit(limit.toLong())
.fetch()
if (pinFirst) {
query.orderBy(
pinContent.isActive.desc(),
pinContent.updatedAt.desc(),
audioContent.releaseDate.desc(),
audioContent.id.desc()
)
} else {
query.orderBy(audioContent.releaseDate.desc(), audioContent.id.desc())
}
return query
.limit(limit.toLong())
.fetch()
}
private fun itAudioId(row: com.querydsl.core.Tuple): Long = row.get(audioContent.id)!!

View File

@@ -1,6 +1,7 @@
package kr.co.vividnext.sodalive.v2.creator.channel.home.application
import kr.co.vividnext.sodalive.common.SodaException
import kr.co.vividnext.sodalive.extensions.removeDeletedNicknamePrefix
import kr.co.vividnext.sodalive.i18n.LangContext
import kr.co.vividnext.sodalive.i18n.SodaMessageSource
import kr.co.vividnext.sodalive.member.Gender
@@ -36,6 +37,7 @@ import org.springframework.beans.factory.annotation.Value
import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional
import java.time.LocalDateTime
import java.time.ZoneOffset
@Service
@Transactional(readOnly = true)
@@ -52,7 +54,7 @@ class CreatorChannelHomeQueryService(
fun getHome(
creatorId: Long,
viewer: Member,
now: LocalDateTime = LocalDateTime.now()
now: LocalDateTime = LocalDateTime.now(ZoneOffset.UTC)
): CreatorChannelHome {
val viewerId = viewer.id!!
val creator = queryPort.findCreator(creatorId, viewerId)
@@ -68,8 +70,8 @@ class CreatorChannelHomeQueryService(
validateCreatorRole(creator)
val preference = memberContentPreferenceService.getStoredPreference(viewer)
val canViewAdultContent = memberContentPreferenceService.canViewAdultContent(viewer)
val isViewerCreator = viewerId == creatorId
val canViewAdultContent = preference.isAdult
val isViewerCreator = viewer.role == MemberRole.CREATOR
val effectiveViewerGender = viewer.effectiveGender()
val latestAudioContent = queryPort
.findLatestAudioContent(creatorId, now, canViewAdultContent, viewerId)
@@ -187,7 +189,7 @@ class CreatorChannelHomeQueryService(
)
private fun CreatorChannelDonationRecord.toDomain() = CreatorChannelDonation(
nickname = nickname,
nickname = nickname.removeDeletedNicknamePrefix(),
profileImageUrl = profileImagePath.toCdnUrl(cloudFrontHost) ?: defaultProfileImageUrl(),
can = can,
message = message,

View File

@@ -11,7 +11,9 @@ import kr.co.vividnext.sodalive.explorer.profile.creatorCommunity.QCreatorCommun
import kr.co.vividnext.sodalive.explorer.profile.creatorCommunity.comment.QCreatorCommunityComment.creatorCommunityComment
import kr.co.vividnext.sodalive.explorer.profile.creatorCommunity.like.QCreatorCommunityLike.creatorCommunityLike
import kr.co.vividnext.sodalive.extensions.toUtcIso
import kr.co.vividnext.sodalive.live.room.GenderRestriction
import kr.co.vividnext.sodalive.live.room.QLiveRoom.liveRoom
import kr.co.vividnext.sodalive.member.Gender
import kr.co.vividnext.sodalive.member.MemberRole
import kr.co.vividnext.sodalive.member.QMember
import kr.co.vividnext.sodalive.member.block.QBlockMember
@@ -22,11 +24,16 @@ import kr.co.vividnext.sodalive.v2.home.following.adapter.out.persistence.QHomeF
import kr.co.vividnext.sodalive.v2.home.following.domain.FollowingNewsType
import kr.co.vividnext.sodalive.v2.home.following.domain.HomeFollowingCommunityPostNews
import kr.co.vividnext.sodalive.v2.home.following.domain.HomeFollowingContentNews
import kr.co.vividnext.sodalive.v2.home.following.domain.HomeFollowingContentRankingNews
import kr.co.vividnext.sodalive.v2.home.following.domain.HomeFollowingCreator
import kr.co.vividnext.sodalive.v2.home.following.domain.HomeFollowingCreatorRankingNews
import kr.co.vividnext.sodalive.v2.home.following.domain.HomeFollowingLive
import kr.co.vividnext.sodalive.v2.home.following.domain.HomeFollowingNews
import kr.co.vividnext.sodalive.v2.home.following.domain.HomeFollowingSchedule
import kr.co.vividnext.sodalive.v2.ranking.adapter.out.persistence.QCreatorRankingSnapshot
import kr.co.vividnext.sodalive.v2.ranking.adapter.out.persistence.QCreatorRankingSnapshotJob
import kr.co.vividnext.sodalive.v2.ranking.domain.CreatorRankingType
import kr.co.vividnext.sodalive.v2.ranking.port.out.CreatorRankingSnapshotJobStatus
import org.springframework.beans.factory.annotation.Value
import org.springframework.stereotype.Repository
import java.time.LocalDateTime
@@ -64,7 +71,13 @@ class DefaultHomeFollowingQueryRepository(
}
}
override fun findOnAirLives(memberId: Long, canViewAdultContent: Boolean, limit: Int): List<HomeFollowingLive> {
override fun findOnAirLives(
memberId: Long,
canViewAdultContent: Boolean,
effectiveViewerGender: Gender,
isViewerCreator: Boolean,
limit: Int
): List<HomeFollowingLive> {
val creator = QMember("onAirCreator")
return queryFactory
.select(liveRoom.id, creator.profileImage, creator.nickname, liveRoom.title, liveRoom.beginDateTime)
@@ -80,6 +93,8 @@ class DefaultHomeFollowingQueryRepository(
creator.isActive.isTrue,
creator.role.eq(MemberRole.CREATOR),
adultLiveCondition(canViewAdultContent),
genderLiveCondition(memberId, effectiveViewerGender),
creatorJoinLiveCondition(memberId, isViewerCreator),
notBlockedCreatorCondition(memberId, creator.id)
)
.orderBy(liveRoom.beginDateTime.desc(), liveRoom.id.desc())
@@ -99,11 +114,19 @@ class DefaultHomeFollowingQueryRepository(
override fun findMonthlySchedules(
memberId: Long,
canViewAdultContent: Boolean,
effectiveViewerGender: Gender,
isViewerCreator: Boolean,
now: LocalDateTime,
limit: Int
): List<HomeFollowingSchedule> {
val window = monthlyScheduleWindow(now)
val liveSchedules = findLiveSchedules(memberId, canViewAdultContent, window)
val liveSchedules = findLiveSchedules(
memberId,
canViewAdultContent,
effectiveViewerGender,
isViewerCreator,
window
)
val audioSchedules = findAudioSchedules(memberId, canViewAdultContent, window)
return (liveSchedules + audioSchedules)
.sortedWith(
@@ -141,7 +164,7 @@ class DefaultHomeFollowingQueryRepository(
.from(homeFollowingNewsInbox)
.join(creator).on(creator.id.eq(homeFollowingNewsInbox.creatorId))
.leftJoin(newsAudioContent).on(
homeFollowingNewsInbox.newsType.eq(FollowingNewsType.AUDIO_CONTENT),
homeFollowingNewsInbox.newsType.`in`(FollowingNewsType.AUDIO_CONTENT, FollowingNewsType.CONTENT_RANKING),
newsAudioContent.id.eq(homeFollowingNewsInbox.targetId)
)
.leftJoin(newsCommunity).on(
@@ -157,7 +180,7 @@ class DefaultHomeFollowingQueryRepository(
activeFollowingCondition(memberId, homeFollowingNewsInbox.creatorId),
adultNewsCondition(canViewAdultContent),
notBlockedCreatorCondition(memberId, homeFollowingNewsInbox.creatorId),
activeNewsTargetCondition(canViewAdultContent)
activeNewsTargetCondition(memberId, canViewAdultContent, nowUtc)
)
.orderBy(homeFollowingNewsInbox.visibleFromAtUtc.desc(), homeFollowingNewsInbox.id.desc())
.limit(limit.toLong())
@@ -209,6 +232,16 @@ class DefaultHomeFollowingQueryRepository(
} else {
null
},
contentRanking = if (type == FollowingNewsType.CONTENT_RANKING && rank != null) {
HomeFollowingContentRankingNews(
rank = rank,
contentId = targetId,
contentImageUrl = get(newsAudioContent.coverImage).toCdnUrl(cloudFrontHost),
title = get(newsAudioContent.title)!!
)
} else {
null
},
communityPost = if (type == FollowingNewsType.COMMUNITY_POST) {
HomeFollowingCommunityPostNews(
postId = targetId,
@@ -229,6 +262,8 @@ class DefaultHomeFollowingQueryRepository(
private fun findLiveSchedules(
memberId: Long,
canViewAdultContent: Boolean,
effectiveViewerGender: Gender,
isViewerCreator: Boolean,
window: ScheduleWindow
): List<HomeFollowingSchedule> {
val creator = QMember("scheduleLiveCreator")
@@ -254,6 +289,8 @@ class DefaultHomeFollowingQueryRepository(
creator.isActive.isTrue,
creator.role.eq(MemberRole.CREATOR),
adultLiveCondition(canViewAdultContent),
genderLiveCondition(memberId, effectiveViewerGender),
creatorJoinLiveCondition(memberId, isViewerCreator),
notBlockedCreatorCondition(memberId, creator.id)
)
.fetch()
@@ -340,6 +377,20 @@ class DefaultHomeFollowingQueryRepository(
return if (canViewAdultContent) null else liveRoom.isAdult.isFalse
}
private fun genderLiveCondition(viewerId: Long, effectiveViewerGender: Gender): BooleanExpression? {
val genderCondition = when (effectiveViewerGender) {
Gender.MALE -> liveRoom.genderRestriction.`in`(GenderRestriction.ALL, GenderRestriction.MALE_ONLY)
Gender.FEMALE -> liveRoom.genderRestriction.`in`(GenderRestriction.ALL, GenderRestriction.FEMALE_ONLY)
Gender.NONE -> return null
}
return genderCondition.or(liveRoom.member.id.eq(viewerId))
}
private fun creatorJoinLiveCondition(viewerId: Long, isViewerCreator: Boolean): BooleanExpression? {
if (!isViewerCreator) return null
return liveRoom.isAvailableJoinCreator.isTrue.or(liveRoom.member.id.eq(viewerId))
}
private fun adultAudioCondition(canViewAdultContent: Boolean): BooleanExpression? {
return if (canViewAdultContent) null else audioContent.isAdult.isFalse
}
@@ -385,7 +436,11 @@ class DefaultHomeFollowingQueryRepository(
}
}
private fun activeNewsTargetCondition(canViewAdultContent: Boolean): BooleanExpression {
private fun activeNewsTargetCondition(
memberId: Long,
canViewAdultContent: Boolean,
nowUtc: LocalDateTime
): BooleanExpression {
val newsAudioContent = QAudioContent("newsAudioContent")
val newsCommunity = QCreatorCommunity("newsCommunity")
val activeAudioExists = JPAExpressions
@@ -410,10 +465,84 @@ class DefaultHomeFollowingQueryRepository(
return homeFollowingNewsInbox.newsType.eq(FollowingNewsType.CREATOR_RANKING)
.and(homeFollowingNewsInbox.rank.isNotNull)
.and(latestVisibleCreatorRankingBatchCondition(nowUtc))
.or(homeFollowingNewsInbox.newsType.eq(FollowingNewsType.AUDIO_CONTENT).and(activeAudioExists))
.or(
homeFollowingNewsInbox.newsType.eq(FollowingNewsType.CONTENT_RANKING)
.and(homeFollowingNewsInbox.rank.isNotNull)
.and(activeAudioExists)
.and(latestContentRankingNewsCondition(memberId, canViewAdultContent, nowUtc))
)
.or(homeFollowingNewsInbox.newsType.eq(FollowingNewsType.COMMUNITY_POST).and(activeCommunityExists))
}
private fun latestVisibleCreatorRankingBatchCondition(nowUtc: LocalDateTime): BooleanExpression {
val latestCreatorRankingSnapshot = QCreatorRankingSnapshot("latestCreatorRankingSnapshot")
val latestCreatorRankingJob = QCreatorRankingSnapshotJob("latestCreatorRankingJob")
val anyCreatorRankingJob = QCreatorRankingSnapshotJob("anyCreatorRankingJob")
val latestJobVisibleFromAt = JPAExpressions
.select(latestCreatorRankingJob.visibleFromAtUtc.max())
.from(latestCreatorRankingJob)
.where(
latestCreatorRankingJob.rankingType.eq(CreatorRankingType.WEEKLY),
latestCreatorRankingJob.status.eq(CreatorRankingSnapshotJobStatus.DONE),
latestCreatorRankingJob.visibleFromAtUtc.loe(nowUtc)
)
val hasRankingJob = JPAExpressions
.selectOne()
.from(anyCreatorRankingJob)
.where(
anyCreatorRankingJob.rankingType.eq(CreatorRankingType.WEEKLY),
anyCreatorRankingJob.status.eq(CreatorRankingSnapshotJobStatus.DONE),
anyCreatorRankingJob.visibleFromAtUtc.loe(nowUtc)
)
.exists()
val latestSnapshotVisibleFromAt = JPAExpressions
.select(latestCreatorRankingSnapshot.visibleFromAtUtc.max())
.from(latestCreatorRankingSnapshot)
.where(
latestCreatorRankingSnapshot.rankingType.eq(CreatorRankingType.WEEKLY),
latestCreatorRankingSnapshot.visibleFromAtUtc.loe(nowUtc)
)
return homeFollowingNewsInbox.visibleFromAtUtc.eq(latestJobVisibleFromAt).or(
hasRankingJob.not().and(homeFollowingNewsInbox.visibleFromAtUtc.eq(latestSnapshotVisibleFromAt))
)
}
private fun latestContentRankingNewsCondition(
memberId: Long,
canViewAdultContent: Boolean,
nowUtc: LocalDateTime
): BooleanExpression {
val newerContentRanking = QHomeFollowingNewsInbox("newerContentRanking")
return JPAExpressions
.selectOne()
.from(newerContentRanking)
.where(
newerContentRanking.memberId.eq(memberId),
newerContentRanking.newsType.eq(FollowingNewsType.CONTENT_RANKING),
newerContentRanking.targetId.eq(homeFollowingNewsInbox.targetId),
newerContentRanking.isActive.isTrue,
newerContentRanking.rank.isNotNull,
adultNewerContentRankingCondition(canViewAdultContent, newerContentRanking),
newerContentRanking.visibleFromAtUtc.loe(nowUtc),
newerContentRanking.visibleFromAtUtc.gt(homeFollowingNewsInbox.visibleFromAtUtc)
.or(
newerContentRanking.visibleFromAtUtc.eq(homeFollowingNewsInbox.visibleFromAtUtc)
.and(newerContentRanking.id.gt(homeFollowingNewsInbox.id))
)
)
.notExists()
}
private fun adultNewerContentRankingCondition(
canViewAdultContent: Boolean,
newerContentRanking: QHomeFollowingNewsInbox
): BooleanExpression? {
return if (canViewAdultContent) null else newerContentRanking.isAdult.isFalse
}
private fun adultAudioNewsTargetCondition(
canViewAdultContent: Boolean,
newsAudioContent: QAudioContent

View File

@@ -52,6 +52,7 @@ interface HomeFollowingNewsInboxJpaRepository : JpaRepository<HomeFollowingNewsI
where cf.creator_id = :creatorId
and cf.is_active = true
order by cf.member_id asc
for update
""",
nativeQuery = true
)

View File

@@ -1,6 +1,8 @@
package kr.co.vividnext.sodalive.v2.home.following.application
import kr.co.vividnext.sodalive.member.Gender
import kr.co.vividnext.sodalive.member.Member
import kr.co.vividnext.sodalive.member.MemberRole
import kr.co.vividnext.sodalive.member.contentpreference.MemberContentPreferenceService
import kr.co.vividnext.sodalive.v2.home.following.domain.HomeFollowing
import kr.co.vividnext.sodalive.v2.home.following.port.out.HomeFollowingQueryPort
@@ -19,17 +21,36 @@ class HomeFollowingQueryService(
fun findHomeFollowing(member: Member): HomeFollowing {
val memberId = requireNotNull(member.id)
val canViewAdultContent = memberContentPreferenceService.canViewAdultContent(member)
val effectiveViewerGender = member.effectiveGender()
val isViewerCreator = member.role == MemberRole.CREATOR
val now = nowProvider()
return HomeFollowing(
followingCreators = queryPort.findFollowingCreators(memberId, FOLLOWING_CREATORS_LIMIT),
onAirLives = queryPort.findOnAirLives(memberId, canViewAdultContent, ON_AIR_LIVES_LIMIT),
onAirLives = queryPort.findOnAirLives(
memberId,
canViewAdultContent,
effectiveViewerGender,
isViewerCreator,
ON_AIR_LIVES_LIMIT
),
recentChats = emptyList(),
monthlySchedules = queryPort.findMonthlySchedules(memberId, canViewAdultContent, now, MONTHLY_SCHEDULES_LIMIT),
monthlySchedules = queryPort.findMonthlySchedules(
memberId,
canViewAdultContent,
effectiveViewerGender,
isViewerCreator,
now,
MONTHLY_SCHEDULES_LIMIT
),
recentNews = queryPort.findRecentNews(memberId, canViewAdultContent, now, RECENT_NEWS_LIMIT)
)
}
private fun Member.effectiveGender(): Gender {
return auth?.let { if (it.gender == 1) Gender.MALE else Gender.FEMALE } ?: gender
}
companion object {
private const val FOLLOWING_CREATORS_LIMIT = 20
private const val ON_AIR_LIVES_LIMIT = 10

View File

@@ -1,5 +1,6 @@
package kr.co.vividnext.sodalive.v2.home.following.port.out
import kr.co.vividnext.sodalive.member.Gender
import kr.co.vividnext.sodalive.v2.home.following.domain.HomeFollowingCreator
import kr.co.vividnext.sodalive.v2.home.following.domain.HomeFollowingLive
import kr.co.vividnext.sodalive.v2.home.following.domain.HomeFollowingNews
@@ -9,11 +10,19 @@ import java.time.LocalDateTime
interface HomeFollowingQueryPort {
fun findFollowingCreators(memberId: Long, limit: Int): List<HomeFollowingCreator>
fun findOnAirLives(memberId: Long, canViewAdultContent: Boolean, limit: Int): List<HomeFollowingLive>
fun findOnAirLives(
memberId: Long,
canViewAdultContent: Boolean,
effectiveViewerGender: Gender,
isViewerCreator: Boolean,
limit: Int
): List<HomeFollowingLive>
fun findMonthlySchedules(
memberId: Long,
canViewAdultContent: Boolean,
effectiveViewerGender: Gender,
isViewerCreator: Boolean,
now: LocalDateTime,
limit: Int
): List<HomeFollowingSchedule>

View File

@@ -127,7 +127,8 @@ class DefaultHomeRecommendationQueryRepository(
includeAdultActivities: Boolean
): List<RecentlyActiveCreatorRecord> {
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()
)
}
}

View File

@@ -103,6 +103,7 @@ data class HomeBannerRecommendationRecord(
)
data class RecentlyActiveCreatorRecord(
val creatorId: Long,
val creatorNickname: String,
val creatorProfileImage: String?,
val activityType: CreatorActivityType,

View File

@@ -0,0 +1,127 @@
package kr.co.vividnext.sodalive.content
import kr.co.vividnext.sodalive.content.pin.PinContent
import kr.co.vividnext.sodalive.content.theme.AudioContentTheme
import kr.co.vividnext.sodalive.member.Member
import kr.co.vividnext.sodalive.member.MemberRole
import kr.co.vividnext.sodalive.support.EmbeddedRedisInitializer
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.DisplayName
import org.junit.jupiter.api.Test
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.boot.test.context.SpringBootTest
import org.springframework.test.context.ContextConfiguration
import org.springframework.transaction.PlatformTransactionManager
import org.springframework.transaction.annotation.Propagation
import org.springframework.transaction.annotation.Transactional
import org.springframework.transaction.support.TransactionTemplate
import java.time.LocalDateTime
import java.util.concurrent.CountDownLatch
import java.util.concurrent.Executors
import java.util.concurrent.TimeUnit
import javax.persistence.EntityManager
@SpringBootTest(properties = ["cloud.aws.cloud-front.host=https://test.cloudfront.net"])
@ContextConfiguration(initializers = [EmbeddedRedisInitializer::class])
@Transactional
class AudioContentPinConcurrencyTest @Autowired constructor(
private val service: AudioContentService,
private val entityManager: EntityManager,
private val transactionManager: PlatformTransactionManager
) {
@Test
@Transactional(propagation = Propagation.NOT_SUPPORTED)
@DisplayName("동시 상단 고정 요청에서도 활성 고정은 9개를 넘지 않는다")
fun shouldKeepNineActivePinsWhenPinRequestsRace() {
val transactionTemplate = TransactionTemplate(transactionManager)
val fixture = transactionTemplate.execute {
val creator = saveMember("pin-race-creator", MemberRole.CREATOR)
val existingContents = (1..8).map { saveAudioContent(creator, "existing-$it") }
existingContents.forEach { savePinContent(creator, it) }
val first = saveAudioContent(creator, "first-race")
val second = saveAudioContent(creator, "second-race")
entityManager.flush()
PinRaceFixture(creator.id!!, first.id!!, second.id!!)
} ?: throw IllegalStateException("fixture creation failed")
val start = CountDownLatch(1)
val executor = Executors.newFixedThreadPool(2)
try {
val first = executor.submit {
start.await()
service.pinToTheTop(fixture.firstContentId, memberRef(fixture.creatorId))
}
val second = executor.submit {
start.await()
service.pinToTheTop(fixture.secondContentId, memberRef(fixture.creatorId))
}
start.countDown()
first.get(5, TimeUnit.SECONDS)
second.get(5, TimeUnit.SECONDS)
} finally {
executor.shutdownNow()
}
val activeContentIds = transactionTemplate.execute { activePinnedContentIds(fixture.creatorId) }.orEmpty()
assertEquals(9, activeContentIds.size)
assertEquals(activeContentIds.size, activeContentIds.distinct().size)
}
private fun saveMember(nickname: String, role: MemberRole): Member {
val member = Member(
email = "$nickname@test.com",
password = "password",
nickname = nickname,
role = role
)
entityManager.persist(member)
return member
}
private fun saveAudioContent(creator: Member, title: String): AudioContent {
val theme = AudioContentTheme(theme = "theme-$title", image = "$title.png", isActive = true)
entityManager.persist(theme)
val content = AudioContent(
title = title,
detail = "detail",
languageCode = "ko",
releaseDate = LocalDateTime.now().minusDays(1),
isAdult = false
)
content.member = creator
content.theme = theme
content.isActive = true
content.coverImage = "$title.png"
content.duration = "00:10:00"
entityManager.persist(content)
return content
}
private fun savePinContent(creator: Member, content: AudioContent): PinContent {
val pinContent = PinContent(isActive = true)
pinContent.member = creator
pinContent.content = content
entityManager.persist(pinContent)
return pinContent
}
private fun memberRef(memberId: Long): Member {
val member = Member(email = "creator-$memberId@test.com", password = "password", nickname = "creator-$memberId")
member.id = memberId
return member
}
private fun activePinnedContentIds(creatorId: Long): List<Long> {
return entityManager.createQuery(
"select p.content.id from PinContent p where p.member.id = :creatorId and p.isActive = true",
java.lang.Long::class.java
).setParameter("creatorId", creatorId).resultList.map { it.toLong() }
}
private data class PinRaceFixture(
val creatorId: Long,
val firstContentId: Long,
val secondContentId: Long
)
}

View File

@@ -10,6 +10,7 @@ import kr.co.vividnext.sodalive.content.like.AudioContentLikeRepository
import kr.co.vividnext.sodalive.content.order.LimitedEditionOrderRepository
import kr.co.vividnext.sodalive.content.order.OrderRepository
import kr.co.vividnext.sodalive.content.order.OrderType
import kr.co.vividnext.sodalive.content.pin.PinContent
import kr.co.vividnext.sodalive.content.pin.PinContentRepository
import kr.co.vividnext.sodalive.content.theme.AudioContentTheme
import kr.co.vividnext.sodalive.content.theme.AudioContentThemeQueryRepository
@@ -20,6 +21,7 @@ import kr.co.vividnext.sodalive.i18n.LangContext
import kr.co.vividnext.sodalive.i18n.SodaMessageSource
import kr.co.vividnext.sodalive.i18n.translation.ResourceTranslationJobScheduler
import kr.co.vividnext.sodalive.member.Member
import kr.co.vividnext.sodalive.member.MemberRepository
import kr.co.vividnext.sodalive.member.block.BlockMemberRepository
import kr.co.vividnext.sodalive.v2.home.following.application.HomeFollowingNewsPublishService
import kr.co.vividnext.sodalive.v2.recommendation.application.CreatorContentViewHistoryService
@@ -52,6 +54,7 @@ class AudioContentServiceTest {
private lateinit var commentRepository: AudioContentCommentRepository
private lateinit var audioContentLikeRepository: AudioContentLikeRepository
private lateinit var pinContentRepository: PinContentRepository
private lateinit var memberRepository: MemberRepository
private lateinit var resourceTranslationJobScheduler: ResourceTranslationJobScheduler
private lateinit var contentTranslationRepository: ContentTranslationRepository
private lateinit var s3Uploader: S3Uploader
@@ -76,6 +79,7 @@ class AudioContentServiceTest {
commentRepository = Mockito.mock(AudioContentCommentRepository::class.java)
audioContentLikeRepository = Mockito.mock(AudioContentLikeRepository::class.java)
pinContentRepository = Mockito.mock(PinContentRepository::class.java)
memberRepository = Mockito.mock(MemberRepository::class.java)
resourceTranslationJobScheduler = Mockito.mock(ResourceTranslationJobScheduler::class.java)
contentTranslationRepository = Mockito.mock(ContentTranslationRepository::class.java)
s3Uploader = Mockito.mock(S3Uploader::class.java)
@@ -97,6 +101,7 @@ class AudioContentServiceTest {
commentRepository = commentRepository,
audioContentLikeRepository = audioContentLikeRepository,
pinContentRepository = pinContentRepository,
memberRepository = memberRepository,
resourceTranslationJobScheduler = resourceTranslationJobScheduler,
contentTranslationRepository = contentTranslationRepository,
s3Uploader = s3Uploader,
@@ -450,6 +455,123 @@ class AudioContentServiceTest {
)
}
@Test
@DisplayName("오디오 상세의 상단 고정 가능 여부는 활성 고정 9개 상한을 따른다")
fun shouldExposeAvailablePinByNineActivePinLimit() {
val creator = createMember(id = 2400L, nickname = "pin-limit-creator")
val audioContent = createAudioContent(creator = creator)
stubSuccessfulDetailDependencies(creator, creator, audioContent)
Mockito.`when`(pinContentRepository.getPinContentList(memberId = creator.id!!, active = true))
.thenReturn(createPinContents(8))
.thenReturn(createPinContents(9))
val availableResponse = service.getDetail(
id = audioContent.id!!,
member = creator,
isAdultContentVisible = false,
timezone = "Asia/Seoul"
)
val unavailableResponse = service.getDetail(
id = audioContent.id!!,
member = creator,
isAdultContentVisible = false,
timezone = "Asia/Seoul"
)
assertTrue(availableResponse.isAvailablePin)
assertEquals(false, unavailableResponse.isAvailablePin)
}
@Test
@DisplayName("10번째 상단 고정은 가장 오래된 활성 고정을 새 콘텐츠로 교체한다")
fun shouldReuseOldestActivePinWhenPinningTenthContent() {
val creator = createMember(id = 2410L, nickname = "pin-reuse-creator")
val audioContent = createAudioContent(creator = creator)
val oldestPinContent = createPinContents(1).single()
Mockito.`when`(repository.findByIdAndCreatorId(contentId = audioContent.id!!, creatorId = creator.id!!))
.thenReturn(audioContent)
Mockito.`when`(memberRepository.findByIdForUpdate(creator.id!!)).thenReturn(creator)
Mockito.`when`(
pinContentRepository.findByContentIdAndMemberId(
contentId = audioContent.id!!,
memberId = creator.id!!
)
).thenReturn(null)
Mockito.`when`(pinContentRepository.getPinContentList(memberId = creator.id!!, active = true))
.thenReturn(listOf(oldestPinContent) + createPinContents(8))
service.pinToTheTop(contentId = audioContent.id!!, member = creator)
Mockito.inOrder(memberRepository, repository, pinContentRepository).apply {
verify(memberRepository).findByIdForUpdate(creator.id!!)
verify(repository).findByIdAndCreatorId(contentId = audioContent.id!!, creatorId = creator.id!!)
verify(pinContentRepository).findByContentIdAndMemberId(
contentId = audioContent.id!!,
memberId = creator.id!!
)
verify(pinContentRepository).getPinContentList(memberId = creator.id!!, active = true)
}
assertTrue(oldestPinContent.isActive)
assertEquals(creator, oldestPinContent.member)
assertEquals(audioContent, oldestPinContent.content)
Mockito.verify(pinContentRepository).save(oldestPinContent)
}
@Test
@DisplayName("상단 고정 해제는 고정 행 조회 전에 크리에이터를 잠근다")
fun shouldLockCreatorBeforeFindingPinWhenUnpinningContent() {
val creator = createMember(id = 2415L, nickname = "pin-unpin-lock-creator")
val audioContent = createAudioContent(creator = creator)
val pinContent = PinContent(isActive = true)
pinContent.member = creator
pinContent.content = audioContent
Mockito.`when`(memberRepository.findByIdForUpdate(creator.id!!)).thenReturn(creator)
Mockito.`when`(
pinContentRepository.findByContentIdAndMemberId(
contentId = audioContent.id!!,
memberId = creator.id!!
)
).thenReturn(pinContent)
service.unpinAtTheTop(contentId = audioContent.id!!, member = creator)
Mockito.inOrder(memberRepository, pinContentRepository).apply {
verify(memberRepository).findByIdForUpdate(creator.id!!)
verify(pinContentRepository).findByContentIdAndMemberId(
contentId = audioContent.id!!,
memberId = creator.id!!
)
}
assertEquals(false, pinContent.isActive)
}
@Test
@DisplayName("비활성 고정을 다시 활성화할 때도 활성 고정은 9개를 넘지 않는다")
fun shouldKeepNineActivePinsWhenReactivatingInactivePin() {
val creator = createMember(id = 2420L, nickname = "pin-reactivate-creator")
val audioContent = createAudioContent(creator = creator)
val inactivePinContent = PinContent(isActive = false)
inactivePinContent.member = creator
inactivePinContent.content = audioContent
val oldestActivePinContent = createPinContents(1).single()
Mockito.`when`(repository.findByIdAndCreatorId(contentId = audioContent.id!!, creatorId = creator.id!!))
.thenReturn(audioContent)
Mockito.`when`(memberRepository.findByIdForUpdate(creator.id!!)).thenReturn(creator)
Mockito.`when`(
pinContentRepository.findByContentIdAndMemberId(
contentId = audioContent.id!!,
memberId = creator.id!!
)
).thenReturn(inactivePinContent)
Mockito.`when`(pinContentRepository.getPinContentList(memberId = creator.id!!, active = true))
.thenReturn(listOf(oldestActivePinContent) + createPinContents(8))
service.pinToTheTop(contentId = audioContent.id!!, member = creator)
assertEquals(false, oldestActivePinContent.isActive)
assertTrue(inactivePinContent.isActive)
}
private fun createMember(id: Long, nickname: String): Member {
val member = Member(
email = "$nickname@test.com",
@@ -496,6 +618,10 @@ class AudioContentServiceTest {
return audioContent
}
private fun createPinContents(count: Int): List<PinContent> {
return (1..count).map { PinContent() }
}
private fun stubSuccessfulDetailDependencies(viewer: Member, creator: Member, audioContent: AudioContent) {
Mockito.`when`(repository.findById(audioContent.id!!)).thenReturn(Optional.of(audioContent))
Mockito.`when`(explorerQueryRepository.getMember(creator.id!!)).thenReturn(creator)

View File

@@ -1,12 +1,14 @@
package kr.co.vividnext.sodalive.member
import kr.co.vividnext.sodalive.common.SodaException
import kr.co.vividnext.sodalive.member.following.CreatorFollowingRepository
import kr.co.vividnext.sodalive.member.login.LoginRequest
import kr.co.vividnext.sodalive.support.EmbeddedRedisInitializer
import kr.co.vividnext.sodalive.v2.home.following.adapter.out.persistence.HomeFollowingNewsInbox
import kr.co.vividnext.sodalive.v2.home.following.adapter.out.persistence.HomeFollowingNewsInboxJpaRepository
import kr.co.vividnext.sodalive.v2.home.following.domain.FollowingNewsType
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertNull
import org.junit.jupiter.api.Assertions.assertThrows
import org.junit.jupiter.api.DisplayName
import org.junit.jupiter.api.Test
@@ -23,6 +25,7 @@ import javax.persistence.EntityManager
class MemberServiceTest @Autowired constructor(
private val service: MemberService,
private val memberRepository: MemberRepository,
private val creatorFollowingRepository: CreatorFollowingRepository,
private val homeFollowingNewsInboxJpaRepository: HomeFollowingNewsInboxJpaRepository,
private val entityManager: EntityManager
) {
@@ -94,4 +97,88 @@ class MemberServiceTest @Autowired constructor(
assertEquals(false, homeFollowingNewsInboxJpaRepository.findById(inbox.id!!).get().isActive)
}
@Test
@DisplayName("통합 팔로우 API에서 isActive=false이고 기존 관계가 없으면 active 팔로우를 생성하지 않는다")
fun shouldNotCreateActiveFollowingWhenCreatorFollowIsInactiveWithoutExistingRelation() {
val member = memberRepository.save(
Member(
email = "inactive-follower@test.com",
password = "password",
nickname = "inactive-follower"
)
)
val creator = memberRepository.save(
Member(
email = "inactive-creator@test.com",
password = "password",
nickname = "inactive-creator",
role = MemberRole.CREATOR
)
)
service.creatorFollow(creatorId = creator.id!!, isNotify = true, isActive = false, memberId = member.id!!)
entityManager.flush()
entityManager.clear()
assertNull(creatorFollowingRepository.findByCreatorIdAndMemberId(creatorId = creator.id!!, memberId = member.id!!))
}
@Test
@DisplayName("통합 팔로우 API에서 isActive=false이면 기존 inbox를 비활성화하고 재팔로우해도 복구하지 않는다")
fun shouldDeactivateFollowingNewsInboxWhenCreatorFollowIsInactiveAndKeepInactiveAfterRefollow() {
val member = memberRepository.save(
Member(
email = "integrated-follower@test.com",
password = "password",
nickname = "integrated-follower"
)
)
val creator = memberRepository.save(
Member(
email = "integrated-creator@test.com",
password = "password",
nickname = "integrated-creator",
role = MemberRole.CREATOR
)
)
service.creatorFollow(creatorId = creator.id!!, isNotify = true, isActive = true, memberId = member.id!!)
val inbox = homeFollowingNewsInboxJpaRepository.save(
HomeFollowingNewsInbox(
memberId = member.id!!,
creatorId = creator.id!!,
newsType = FollowingNewsType.COMMUNITY_POST,
sourceKey = "COMMUNITY_POST:2",
targetId = 2L,
occurredAtUtc = LocalDateTime.of(2026, 6, 25, 2, 0),
visibleFromAtUtc = LocalDateTime.of(2026, 6, 25, 2, 0),
creatorNickname = "integrated-creator",
creatorProfileImagePath = null,
title = "title",
body = "body",
thumbnailImagePath = null,
rank = null,
isAdult = false
)
)
entityManager.flush()
entityManager.clear()
service.creatorFollow(creatorId = creator.id!!, isNotify = true, isActive = false, memberId = member.id!!)
entityManager.flush()
entityManager.clear()
val inactiveFollowing = creatorFollowingRepository.findByCreatorIdAndMemberId(
creatorId = creator.id!!,
memberId = member.id!!
)
assertEquals(false, inactiveFollowing?.isActive)
assertEquals(false, homeFollowingNewsInboxJpaRepository.findById(inbox.id!!).get().isActive)
service.creatorFollow(creatorId = creator.id!!, isNotify = true, isActive = true, memberId = member.id!!)
entityManager.flush()
entityManager.clear()
assertEquals(false, homeFollowingNewsInboxJpaRepository.findById(inbox.id!!).get().isActive)
}
}

View File

@@ -0,0 +1,275 @@
package kr.co.vividnext.sodalive.v2.api.creator.channel.home
import kr.co.vividnext.sodalive.admin.content.series.genre.SeriesGenre
import kr.co.vividnext.sodalive.common.CountryContext
import kr.co.vividnext.sodalive.content.AudioContent
import kr.co.vividnext.sodalive.content.order.Order
import kr.co.vividnext.sodalive.content.order.OrderType
import kr.co.vividnext.sodalive.content.theme.AudioContentTheme
import kr.co.vividnext.sodalive.creator.admin.content.series.Series
import kr.co.vividnext.sodalive.creator.admin.content.series.SeriesContent
import kr.co.vividnext.sodalive.explorer.profile.CreatorCheers
import kr.co.vividnext.sodalive.explorer.profile.channelDonation.ChannelDonationMessage
import kr.co.vividnext.sodalive.explorer.profile.creatorCommunity.CreatorCommunity
import kr.co.vividnext.sodalive.live.room.LiveRoom
import kr.co.vividnext.sodalive.live.room.visit.LiveRoomVisit
import kr.co.vividnext.sodalive.member.Member
import kr.co.vividnext.sodalive.member.MemberAdapter
import kr.co.vividnext.sodalive.member.MemberRole
import kr.co.vividnext.sodalive.support.EmbeddedRedisInitializer
import org.junit.jupiter.api.DisplayName
import org.junit.jupiter.api.Test
import org.mockito.Mockito
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc
import org.springframework.boot.test.context.SpringBootTest
import org.springframework.boot.test.mock.mockito.MockBean
import org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.user
import org.springframework.test.context.ContextConfiguration
import org.springframework.test.web.servlet.MockMvc
import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get
import org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath
import org.springframework.test.web.servlet.result.MockMvcResultMatchers.status
import org.springframework.transaction.support.TransactionTemplate
import java.time.LocalDateTime
import java.time.ZoneOffset
import javax.persistence.EntityManager
@SpringBootTest(
properties = [
"cloud.aws.cloud-front.host=https://cdn.test",
"spring.cache.type=none",
"spring.datasource.url=jdbc:h2:mem:creator-channel-home-e2e;MODE=MySQL;NON_KEYWORDS=VALUE;DB_CLOSE_ON_EXIT=FALSE"
]
)
@AutoConfigureMockMvc
@ContextConfiguration(initializers = [EmbeddedRedisInitializer::class])
class CreatorChannelHomeEndToEndTest @Autowired constructor(
private val mockMvc: MockMvc,
private val entityManager: EntityManager,
private val transactionTemplate: TransactionTemplate
) {
@MockBean
private lateinit var countryContext: CountryContext
@Test
@DisplayName("크리에이터 채널 홈 API는 단일 인증 요청으로 전체 홈 섹션을 조립한다")
fun shouldAssembleCreatorChannelHomeSectionsThroughSingleHttpRequest() {
Mockito.doReturn("US").`when`(countryContext).countryCode
val fixture = createFixture()
mockMvc.perform(get("/api/v2/creator-channels/${fixture.creatorId}/home").with(user(MemberAdapter(fixture.viewer))))
.andExpect(status().isOk)
.andExpect(jsonPath("$.success").value(true))
.andExpect(jsonPath("$.data.creator.creatorId").value(fixture.creatorId))
.andExpect(jsonPath("$.data.currentLive.liveId").value(fixture.currentLiveId))
.andExpect(jsonPath("$.data.latestAudioContent.audioContentId").value(fixture.latestAudioId))
.andExpect(jsonPath("$.data.channelDonations[0].can").value(500))
.andExpect(jsonPath("$.data.notices[0].postId").value(fixture.noticeId))
.andExpect(jsonPath("$.data.schedules[0].targetId").value(fixture.liveScheduleId))
.andExpect(jsonPath("$.data.schedules[1].targetId").value(fixture.audioScheduleId))
.andExpect(jsonPath("$.data.audioContents[0].audioContentId").value(fixture.listAudioId))
.andExpect(jsonPath("$.data.audioContents[1].audioContentId").value(fixture.firstAudioId))
.andExpect(jsonPath("$.data.series[0].seriesId").value(fixture.seriesId))
.andExpect(jsonPath("$.data.communities[0].postId").value(fixture.communityId))
.andExpect(jsonPath("$.data.fanTalk.latestFanTalk.fanTalkId").value(fixture.fanTalkId))
.andExpect(jsonPath("$.data.introduce").value("e2e introduce"))
.andExpect(jsonPath("$.data.activity.audioContentCount").value(3))
.andExpect(jsonPath("$.data.sns.instagramUrl").value("e2e-instagram"))
}
private fun createFixture(): Fixture {
return transactionTemplate.execute {
val now = LocalDateTime.now(ZoneOffset.UTC)
val viewer = saveMember("home-e2e-viewer", MemberRole.USER)
val creator = saveMember("home-e2e-creator", MemberRole.CREATOR)
creator.introduce = "e2e introduce"
creator.instagramUrl = "e2e-instagram"
creator.fancimmUrl = "e2e-fancimm"
creator.xUrl = "e2e-x"
creator.youtubeUrl = "e2e-youtube"
creator.websiteUrl = "e2e-kakao"
val donor = saveMember("home-e2e-donor", MemberRole.USER)
val fan = saveMember("home-e2e-fan", MemberRole.USER)
val currentLive = saveLiveRoom(creator, now.minusHours(2), channelName = "e2e-live")
val liveSchedule = saveLiveRoom(creator, now.plusHours(1), channelName = null)
val audioSchedule = saveAudioContent(creator, now.plusHours(2), title = "schedule-audio")
val firstAudio = saveAudioContent(creator, now.minusDays(3), title = "first-audio")
val listAudio = saveAudioContent(creator, now.minusDays(2), title = "list-audio")
val latestAudio = saveAudioContent(creator, now.minusDays(1), title = "latest-audio")
val series = saveSeries("home-e2e-series", creator)
saveSeriesContent(series, listAudio)
saveOrder(viewer, creator, latestAudio, OrderType.KEEP)
saveDonation(creator, donor, now.minusHours(3))
val notice = saveCommunityPost(creator, "e2e notice", isFixed = true, fixedAt = now.minusHours(4))
val community = saveCommunityPost(creator, "e2e community", isFixed = false, fixedAt = null)
val fanTalk = saveCheers(fan, creator, "e2e fan talk", now.minusMinutes(30))
saveVisit(currentLive, viewer)
entityManager.flush()
Fixture(
viewer = viewer,
creatorId = creator.id!!,
currentLiveId = currentLive.id!!,
latestAudioId = latestAudio.id!!,
liveScheduleId = liveSchedule.id!!,
audioScheduleId = audioSchedule.id!!,
firstAudioId = firstAudio.id!!,
listAudioId = listAudio.id!!,
seriesId = series.id!!,
noticeId = notice.id!!,
communityId = community.id!!,
fanTalkId = fanTalk.id!!
)
} ?: throw IllegalStateException("fixture creation failed")
}
private fun saveMember(nickname: String, role: MemberRole): Member {
val member = Member(
email = "$nickname@test.com",
password = "password",
nickname = nickname,
profileImage = "$nickname.png",
role = role
)
entityManager.persist(member)
return member
}
private fun saveLiveRoom(creator: Member, beginDateTime: LocalDateTime, channelName: String?): LiveRoom {
val liveRoom = LiveRoom(
title = "live-$channelName",
notice = "notice",
beginDateTime = beginDateTime,
numberOfPeople = 0,
coverImage = "live.png",
isAdult = false,
price = 50
)
liveRoom.member = creator
liveRoom.channelName = channelName
liveRoom.isActive = true
entityManager.persist(liveRoom)
return liveRoom
}
private fun saveAudioContent(creator: Member, releaseDate: LocalDateTime, title: String): AudioContent {
val theme = AudioContentTheme(theme = "theme-$title", image = "$title.png", isActive = true)
entityManager.persist(theme)
val content = AudioContent(
title = title,
detail = "detail",
languageCode = "ko",
releaseDate = releaseDate,
price = 100,
isAdult = false
)
content.member = creator
content.theme = theme
content.isActive = true
content.coverImage = "$title.png"
content.duration = "00:10:00"
entityManager.persist(content)
return content
}
private fun saveSeries(title: String, creator: Member): Series {
val series = Series(title = title, introduction = "intro", languageCode = "ko", isOriginal = true, isActive = true)
series.member = creator
series.genre = saveSeriesGenre(title)
entityManager.persist(series)
return series
}
private fun saveSeriesGenre(name: String): SeriesGenre {
val genre = SeriesGenre(genre = "genre-$name", isAdult = false, isActive = true)
entityManager.persist(genre)
return genre
}
private fun saveSeriesContent(series: Series, content: AudioContent): SeriesContent {
val seriesContent = SeriesContent()
seriesContent.series = series
seriesContent.content = content
entityManager.persist(seriesContent)
return seriesContent
}
private fun saveOrder(member: Member, creator: Member, content: AudioContent, type: OrderType): Order {
val order = Order(type = type, isActive = true)
order.member = member
order.creator = creator
order.audioContent = content
entityManager.persist(order)
return order
}
private fun saveDonation(creator: Member, donor: Member, createdAt: LocalDateTime): ChannelDonationMessage {
val donation = ChannelDonationMessage(can = 500, isSecret = false, additionalMessage = "e2e thanks")
donation.creator = creator
donation.member = donor
entityManager.persist(donation)
entityManager.flush()
updateCreatedAt("ChannelDonationMessage", donation.id!!, createdAt)
return donation
}
private fun saveCommunityPost(
creator: Member,
content: String,
isFixed: Boolean,
fixedAt: LocalDateTime?
): CreatorCommunity {
val post = CreatorCommunity(
content = content,
price = 0,
isCommentAvailable = true,
isAdult = false,
imagePath = "$content.png",
isFixed = isFixed,
fixedAt = fixedAt
)
post.member = creator
entityManager.persist(post)
return post
}
private fun saveCheers(member: Member, creator: Member, cheers: String, createdAt: LocalDateTime): CreatorCheers {
val creatorCheers = CreatorCheers(cheers = cheers, languageCode = "ko", isActive = true)
creatorCheers.member = member
creatorCheers.creator = creator
entityManager.persist(creatorCheers)
entityManager.flush()
updateCreatedAt("CreatorCheers", creatorCheers.id!!, createdAt)
return creatorCheers
}
private fun saveVisit(room: LiveRoom, member: Member): LiveRoomVisit {
val visit = LiveRoomVisit()
visit.room = room
visit.member = member
entityManager.persist(visit)
return visit
}
private fun updateCreatedAt(entityName: String, id: Long, createdAt: LocalDateTime) {
entityManager.createQuery("update $entityName e set e.createdAt = :createdAt where e.id = :id")
.setParameter("createdAt", createdAt)
.setParameter("id", id)
.executeUpdate()
}
private data class Fixture(
val viewer: Member,
val creatorId: Long,
val currentLiveId: Long,
val latestAudioId: Long,
val liveScheduleId: Long,
val audioScheduleId: Long,
val firstAudioId: Long,
val listAudioId: Long,
val seriesId: Long,
val noticeId: Long,
val communityId: Long,
val fanTalkId: Long
)
}

View File

@@ -147,6 +147,33 @@ class CreatorChannelHomeControllerTest @Autowired constructor(
)
}
@Test
@DisplayName("크리에이터 채널 홈 빈 섹션은 단건 null과 목록 빈 배열로 응답한다")
fun shouldReturnNullSinglesAndEmptyArraysForEmptyCreatorChannelHomeSections() {
val viewer = createMember(id = 10L)
Mockito.doReturn(CreatorChannelHomeResponse.from(createEmptyHome())).`when`(facade).getHome(
Mockito.eq(1L),
Mockito.any(Member::class.java) ?: viewer,
Mockito.any(LocalDateTime::class.java) ?: LocalDateTime.now()
)
mockMvc.perform(
get("/api/v2/creator-channels/1/home")
.with(user(MemberAdapter(viewer)))
)
.andExpect(status().isOk)
.andExpect(jsonPath("$.success").value(true))
.andExpect(jsonPath("$.data.currentLive").value(null as String?))
.andExpect(jsonPath("$.data.latestAudioContent").value(null as String?))
.andExpect(jsonPath("$.data.fanTalk.latestFanTalk").value(null as String?))
.andExpect(jsonPath("$.data.channelDonations").isEmpty)
.andExpect(jsonPath("$.data.notices").isEmpty)
.andExpect(jsonPath("$.data.schedules").isEmpty)
.andExpect(jsonPath("$.data.audioContents").isEmpty)
.andExpect(jsonPath("$.data.series").isEmpty)
.andExpect(jsonPath("$.data.communities").isEmpty)
}
private fun createMember(id: Long): Member {
return Member(
email = "viewer$id@test.com",
@@ -288,4 +315,18 @@ class CreatorChannelHomeControllerTest @Autowired constructor(
)
)
}
private fun createEmptyHome(): CreatorChannelHome {
return createHome().copy(
currentLive = null,
latestAudioContent = null,
channelDonations = emptyList(),
notices = emptyList(),
schedules = emptyList(),
audioContents = emptyList(),
series = emptyList(),
communities = emptyList(),
fanTalk = CreatorChannelFanTalkSummary(totalCount = 0, latestFanTalk = null)
)
}
}

View File

@@ -22,8 +22,11 @@ import org.junit.jupiter.api.Assertions.assertNull
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.DisplayName
import org.junit.jupiter.api.Test
import org.mockito.ArgumentCaptor
import org.mockito.Mockito
import java.time.LocalDateTime
import java.time.ZoneOffset
import java.util.TimeZone
class CreatorChannelHomeFacadeTest {
@Test
@@ -77,6 +80,40 @@ class CreatorChannelHomeFacadeTest {
assertEquals("instagram", response.sns.instagramUrl)
}
@Test
@DisplayName("크리에이터 채널 홈 facade 기본 now는 JVM timezone과 무관하게 UTC 기준으로 전달한다")
fun shouldUseUtcNowWhenNowIsNotProvided() {
val service = Mockito.mock(CreatorChannelHomeQueryService::class.java)
val facade = CreatorChannelHomeFacade(service)
val viewer = createMember(id = 10L)
val previousTimeZone = TimeZone.getDefault()
Mockito.doReturn(createHome()).`when`(service).getHome(
Mockito.eq(1L),
Mockito.eq(viewer) ?: viewer,
Mockito.any(LocalDateTime::class.java) ?: LocalDateTime.now()
)
try {
TimeZone.setDefault(TimeZone.getTimeZone("Asia/Seoul"))
val beforeUtc = LocalDateTime.now(ZoneOffset.UTC).minusSeconds(1)
facade.getHome(creatorId = 1L, viewer = viewer)
val afterUtc = LocalDateTime.now(ZoneOffset.UTC).plusSeconds(1)
val nowCaptor = ArgumentCaptor.forClass(LocalDateTime::class.java)
Mockito.verify(service).getHome(
Mockito.eq(1L),
Mockito.eq(viewer) ?: viewer,
nowCaptor.capture() ?: LocalDateTime.MIN
)
val capturedNow = nowCaptor.value
assertTrue(!capturedNow.isBefore(beforeUtc), "captured now should be after UTC lower bound")
assertTrue(!capturedNow.isAfter(afterUtc), "captured now should be before UTC upper bound")
} finally {
TimeZone.setDefault(previousTimeZone)
}
}
private fun createMember(id: Long): Member {
return Member(
email = "viewer$id@test.com",

View File

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

View File

@@ -14,6 +14,8 @@ import kr.co.vividnext.sodalive.member.following.CreatorFollowing
import kr.co.vividnext.sodalive.support.EmbeddedRedisInitializer
import kr.co.vividnext.sodalive.v2.home.following.adapter.out.persistence.HomeFollowingNewsInbox
import kr.co.vividnext.sodalive.v2.home.following.domain.FollowingNewsType
import kr.co.vividnext.sodalive.v2.ranking.adapter.out.persistence.CreatorRankingSnapshot
import kr.co.vividnext.sodalive.v2.ranking.domain.CreatorRankingType
import kr.co.vividnext.sodalive.v2.usercreatorchat.UserCreatorChatMessage
import kr.co.vividnext.sodalive.v2.usercreatorchat.UserCreatorChatMessageType
import kr.co.vividnext.sodalive.v2.usercreatorchat.UserCreatorChatParticipant
@@ -167,7 +169,9 @@ class HomeFollowingEndToEndTest @Autowired constructor(
newsType = FollowingNewsType.COMMUNITY_POST,
targetId = paidPost.id!!
)
val rankedNews = saveNews(viewer.id!!, creator.id!!, "ranked-news", now.minusHours(1), rank = 7)
val rankedVisibleFromAtUtc = now.minusHours(1)
saveCreatorRankingSnapshot(creator, rankNo = 7, visibleFromAtUtc = rankedVisibleFromAtUtc)
val rankedNews = saveNews(viewer.id!!, creator.id!!, "ranked-news", rankedVisibleFromAtUtc, rank = 7)
val chatRoom = saveDmChatRoom(viewer, creator, now.minusMinutes(10))
entityManager.flush()
entityManager.clear()
@@ -275,6 +279,28 @@ class HomeFollowingEndToEndTest @Autowired constructor(
return news
}
private fun saveCreatorRankingSnapshot(
creator: Member,
rankNo: Int,
visibleFromAtUtc: LocalDateTime
): CreatorRankingSnapshot {
val snapshot = CreatorRankingSnapshot(
rankingType = CreatorRankingType.WEEKLY,
aggregationStartAtUtc = visibleFromAtUtc.minusWeeks(1),
aggregationEndAtUtc = visibleFromAtUtc,
visibleFromAtUtc = visibleFromAtUtc,
creatorId = creator.id!!,
nickname = creator.nickname,
profileImageUrl = creator.profileImage,
rankNo = rankNo,
finalScore = 100.0 - rankNo,
scorePolicyVersion = "v1",
scoreDetailJson = "{}"
)
entityManager.persist(snapshot)
return snapshot
}
private fun saveCommunityPost(creator: Member, content: String, price: Int, imagePath: String): CreatorCommunity {
val post = CreatorCommunity(
content = content,

View File

@@ -8,6 +8,7 @@ import kr.co.vividnext.sodalive.content.AudioContent
import kr.co.vividnext.sodalive.content.ContentType
import kr.co.vividnext.sodalive.content.order.Order
import kr.co.vividnext.sodalive.content.order.OrderType
import kr.co.vividnext.sodalive.content.pin.PinContent
import kr.co.vividnext.sodalive.content.theme.AudioContentTheme
import kr.co.vividnext.sodalive.creator.admin.content.series.Series
import kr.co.vividnext.sodalive.creator.admin.content.series.SeriesContent
@@ -513,6 +514,66 @@ class DefaultCreatorChannelHomeQueryRepositoryTest @Autowired constructor(
assertTrue(records.last().isPointAvailable)
}
@Test
@DisplayName("홈 오디오 목록은 최신 오디오를 제외하고 활성 고정 콘텐츠를 최근 고정순으로 먼저 조회한다")
fun shouldSortHomeAudioContentsByActivePinBeforeReleaseDate() {
val now = LocalDateTime.of(2026, 6, 12, 12, 0)
val creator = saveMember("home-audio-pin-creator", MemberRole.CREATOR)
val pinnedRecent = saveAudioContent(creator, now.minusDays(5), isAdult = false)
val pinnedOlder = saveAudioContent(creator, now.minusDays(4), isAdult = false)
val normal = saveAudioContent(creator, now.minusDays(3), isAdult = false)
val inactivePinned = saveAudioContent(creator, now.minusDays(2), isAdult = false)
val latest = saveAudioContent(creator, now.minusDays(1), isAdult = false)
savePinContent(creator, latest, isActive = true, updatedAt = now.minusMinutes(5))
savePinContent(creator, pinnedRecent, isActive = true, updatedAt = now.minusMinutes(10))
savePinContent(creator, pinnedOlder, isActive = true, updatedAt = now.minusMinutes(20))
savePinContent(creator, inactivePinned, isActive = false, updatedAt = now.minusMinutes(1))
flushAndClear()
val latestRecord = repository.findLatestAudioContent(creator.id!!, now, canViewAdultContent = false)
val records = repository.findAudioContents(
creator.id!!,
now,
latestAudioContentId = latestRecord!!.audioContentId,
canViewAdultContent = false,
limit = 9
)
assertEquals(latest.id, latestRecord.audioContentId)
assertEquals(
listOf(pinnedRecent.id, pinnedOlder.id, inactivePinned.id, normal.id),
records.map { it.audioContentId }
)
}
@Test
@DisplayName("홈 오디오 목록은 활성 고정 후보가 9개를 넘어도 최대 9개만 조회한다")
fun shouldLimitHomeAudioContentsToNineWhenActivePinnedCandidatesExceedLimit() {
val now = LocalDateTime.of(2026, 6, 12, 12, 0)
val creator = saveMember("home-audio-pin-limit-creator", MemberRole.CREATOR)
val pinnedContents = (1..10).map { index ->
saveAudioContent(creator, now.minusDays(index.toLong() + 1), isAdult = false)
}
val latest = saveAudioContent(creator, now.minusDays(1), isAdult = false)
pinnedContents.forEachIndexed { index, content ->
savePinContent(creator, content, isActive = true, updatedAt = now.minusMinutes(index.toLong()))
}
flushAndClear()
val latestRecord = repository.findLatestAudioContent(creator.id!!, now, canViewAdultContent = false)
val records = repository.findAudioContents(
creator.id!!,
now,
latestAudioContentId = latestRecord!!.audioContentId,
canViewAdultContent = false,
limit = 9
)
assertEquals(latest.id, latestRecord.audioContentId)
assertEquals(9, records.size)
assertEquals(pinnedContents.take(9).map { it.id }, records.map { it.audioContentId })
}
@Test
@DisplayName("최신 오디오와 오디오 목록은 조회자의 유효한 소장/대여 주문 상태를 함께 반환한다")
fun shouldFindAudioContentOwnershipFlagsByViewerOrders() {
@@ -1154,6 +1215,21 @@ class DefaultCreatorChannelHomeQueryRepositoryTest @Autowired constructor(
return seriesContent
}
private fun savePinContent(
creator: Member,
content: AudioContent,
isActive: Boolean,
updatedAt: LocalDateTime
): PinContent {
val pinContent = PinContent(isActive = isActive)
pinContent.member = creator
pinContent.content = content
entityManager.persist(pinContent)
entityManager.flush()
updateUpdatedAt("PinContent", pinContent.id!!, updatedAt)
return pinContent
}
private fun saveDonation(
creator: Member,
donor: Member,

View File

@@ -108,7 +108,7 @@ class CreatorChannelHomeQueryServiceTest {
fun shouldPassViewerCreatorFlagToLivePolicyQueries() {
val port = FakeCreatorChannelHomeQueryPort()
val service = createService(port)
val viewer = createMember(id = 1L, gender = Gender.FEMALE, authGender = null)
val viewer = createMember(id = 1L, gender = Gender.FEMALE, authGender = null, role = MemberRole.CREATOR)
service.getHome(creatorId = 1L, viewer = viewer, now = LocalDateTime.of(2026, 6, 13, 10, 0))
@@ -118,6 +118,64 @@ class CreatorChannelHomeQueryServiceTest {
assertEquals(Gender.FEMALE, port.schedulesEffectiveViewerGender)
}
@Test
@DisplayName("조회자가 다른 크리에이터여도 라이브 조회 정책 컨텍스트에 크리에이터 여부를 전달한다")
fun shouldPassViewerCreatorFlagWhenViewerIsDifferentCreator() {
val port = FakeCreatorChannelHomeQueryPort()
val service = createService(port)
val viewer = createMember(id = 10L, role = MemberRole.CREATOR)
service.getHome(creatorId = 1L, viewer = viewer, now = LocalDateTime.of(2026, 6, 13, 10, 0))
assertTrue(port.currentLiveIsViewerCreator == true)
assertTrue(port.schedulesIsViewerCreator == true)
}
@Test
@DisplayName("일반 회원 조회자는 라이브 조회 정책 컨텍스트에 크리에이터가 아닌 것으로 전달한다")
fun shouldPassNonCreatorFlagWhenViewerIsUser() {
val port = FakeCreatorChannelHomeQueryPort()
val service = createService(port)
val viewer = createMember(id = 10L, role = MemberRole.USER)
service.getHome(creatorId = 1L, viewer = viewer, now = LocalDateTime.of(2026, 6, 13, 10, 0))
assertFalse(port.currentLiveIsViewerCreator == true)
assertFalse(port.schedulesIsViewerCreator == true)
}
@Test
@DisplayName("홈 조회는 저장된 콘텐츠 선호를 한 번만 읽어 성인 필터에 재사용한다")
fun shouldReuseStoredPreferenceAdultFlagForAdultFilters() {
val port = FakeCreatorChannelHomeQueryPort()
val communityPort = FakeCreatorChannelCommunityQueryPort()
val preferenceService = createPreferenceService(canViewAdultContent = false)
val service = createService(port, communityPort, preferenceService = preferenceService)
val viewer = createMember(id = 10L)
service.getHome(creatorId = 1L, viewer = viewer, now = LocalDateTime.of(2026, 6, 13, 10, 0))
assertEquals(false, port.currentLiveCanViewAdultContent)
assertEquals(false, port.schedulesCanViewAdultContent)
assertEquals(listOf(false, false), communityPort.homeCanViewAdultContentValues)
Mockito.verify(preferenceService).getStoredPreference(viewer)
Mockito.verify(preferenceService, Mockito.never()).canViewAdultContent(viewer)
}
@Test
@DisplayName("홈 채널 후원자는 삭제 회원 닉네임 prefix를 제거해 조립한다")
fun shouldRemoveDeletedNicknamePrefixFromChannelDonation() {
val port = FakeCreatorChannelHomeQueryPort().apply {
channelDonationNickname = "deleted_donor"
}
val service = createService(port)
val viewer = createMember(id = 10L)
val home = service.getHome(creatorId = 1L, viewer = viewer, now = LocalDateTime.of(2026, 6, 13, 10, 0))
assertEquals("donor", home.channelDonations.first().nickname)
}
@Test
@DisplayName("creatorId에 해당하는 회원이 없으면 user_not_found 예외를 던진다")
fun shouldThrowUserNotFoundWhenCreatorMemberDoesNotExist() {
@@ -409,22 +467,9 @@ class CreatorChannelHomeQueryServiceTest {
private fun createService(
port: FakeCreatorChannelHomeQueryPort,
communityPort: FakeCreatorChannelCommunityQueryPort = FakeCreatorChannelCommunityQueryPort(),
canViewAdultContent: Boolean = true
canViewAdultContent: Boolean = true,
preferenceService: MemberContentPreferenceService = createPreferenceService(canViewAdultContent)
): CreatorChannelHomeQueryService {
val preferenceService = Mockito.mock(MemberContentPreferenceService::class.java)
Mockito.`when`(
preferenceService.getStoredPreference(Mockito.any(Member::class.java) ?: createMember(id = 0L))
).thenReturn(
ViewerContentPreference(
countryCode = "US",
isAdultContentVisible = canViewAdultContent,
contentType = ContentType.ALL,
isAdult = canViewAdultContent
)
)
Mockito.`when`(
preferenceService.canViewAdultContent(Mockito.any(Member::class.java) ?: createMember(id = 0L))
).thenReturn(canViewAdultContent)
val messageSource = SodaMessageSource()
val langContext = LangContext()
langContext.setLang(Lang.KO)
@@ -450,17 +495,37 @@ class CreatorChannelHomeQueryServiceTest {
)
}
private fun createPreferenceService(canViewAdultContent: Boolean): MemberContentPreferenceService {
val preferenceService = Mockito.mock(MemberContentPreferenceService::class.java)
Mockito.`when`(
preferenceService.getStoredPreference(Mockito.any(Member::class.java) ?: createMember(id = 0L))
).thenReturn(
ViewerContentPreference(
countryCode = "US",
isAdultContentVisible = canViewAdultContent,
contentType = ContentType.ALL,
isAdult = canViewAdultContent
)
)
Mockito.`when`(
preferenceService.canViewAdultContent(Mockito.any(Member::class.java) ?: createMember(id = 0L))
).thenReturn(canViewAdultContent)
return preferenceService
}
private fun createMember(
id: Long,
gender: Gender = Gender.NONE,
authGender: Int? = null
authGender: Int? = null,
role: MemberRole = MemberRole.USER
): Member {
val member = Member(
email = "member$id@test.com",
password = "password",
nickname = "member$id",
provider = MemberProvider.EMAIL,
gender = gender
gender = gender,
role = role
)
member.id = id
authGender?.let {
@@ -500,6 +565,7 @@ private class FakeCreatorChannelHomeQueryPort : CreatorChannelHomeQueryPort {
var schedulesEffectiveViewerGender: Gender? = null
var schedulesCanViewAdultContent: Boolean? = null
var audioContentsLatestAudioContentId: Long? = null
var channelDonationNickname: String = "fan"
override fun findCreator(creatorId: Long, viewerId: Long?): CreatorChannelCreatorRecord? = creator
@@ -541,7 +607,7 @@ private class FakeCreatorChannelHomeQueryPort : CreatorChannelHomeQueryPort {
limit: Int
): List<CreatorChannelDonationRecord> = listOf(
CreatorChannelDonationRecord(
nickname = "fan",
nickname = channelDonationNickname,
profileImagePath = "profile/fan.png",
can = 30,
message = "thanks",

View File

@@ -7,13 +7,20 @@ import kr.co.vividnext.sodalive.content.theme.AudioContentTheme
import kr.co.vividnext.sodalive.explorer.profile.creatorCommunity.CreatorCommunity
import kr.co.vividnext.sodalive.explorer.profile.creatorCommunity.comment.CreatorCommunityComment
import kr.co.vividnext.sodalive.explorer.profile.creatorCommunity.like.CreatorCommunityLike
import kr.co.vividnext.sodalive.live.room.GenderRestriction
import kr.co.vividnext.sodalive.live.room.LiveRoom
import kr.co.vividnext.sodalive.member.Gender
import kr.co.vividnext.sodalive.member.Member
import kr.co.vividnext.sodalive.member.MemberRole
import kr.co.vividnext.sodalive.member.block.BlockMember
import kr.co.vividnext.sodalive.member.following.CreatorFollowing
import kr.co.vividnext.sodalive.v2.common.domain.CreatorActivityType
import kr.co.vividnext.sodalive.v2.home.following.domain.FollowingNewsType
import kr.co.vividnext.sodalive.v2.ranking.adapter.out.persistence.CreatorRankingSnapshot
import kr.co.vividnext.sodalive.v2.ranking.adapter.out.persistence.CreatorRankingSnapshotJob
import kr.co.vividnext.sodalive.v2.ranking.domain.CreatorRankingType
import kr.co.vividnext.sodalive.v2.ranking.port.out.CreatorRankingSnapshotJobStatus
import kr.co.vividnext.sodalive.v2.ranking.port.out.CreatorRankingSnapshotJobTrigger
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertFalse
import org.junit.jupiter.api.Assertions.assertTrue
@@ -98,12 +105,50 @@ class DefaultHomeFollowingQueryRepositoryTest @Autowired constructor(
saveLiveRoom(nonCreator, LocalDateTime.of(2026, 6, 25, 15, 0), channelName = "non-creator")
flushAndClear()
val lives = repository.findOnAirLives(memberId = viewer.id!!, canViewAdultContent = false, limit = 10)
val lives = repository.findOnAirLives(
memberId = viewer.id!!,
canViewAdultContent = false,
effectiveViewerGender = Gender.NONE,
isViewerCreator = false,
limit = 10
)
assertEquals(listOf(latest.id!!, older.id!!), lives.map { it.liveId })
assertEquals("https://cdn.test/live-profile.png", lives.first().creatorProfileImageUrl)
}
@Test
@DisplayName("On Air는 성별 제한과 크리에이터 입장 제한 정책을 반영한다")
fun shouldFindFollowingOnAirLivesWithViewerGenderAndCreatorJoinPolicy() {
val viewer = saveMember("live-policy-viewer", MemberRole.CREATOR)
val creator = saveMember("live-policy-creator", MemberRole.CREATOR)
saveFollowing(viewer, creator)
saveLiveRoom(
creator,
LocalDateTime.of(2026, 6, 25, 10, 0),
channelName = "male-only",
genderRestriction = GenderRestriction.MALE_ONLY
)
saveLiveRoom(
creator,
LocalDateTime.of(2026, 6, 25, 11, 0),
channelName = "creator-hidden",
isAvailableJoinCreator = false
)
val visible = saveLiveRoom(creator, LocalDateTime.of(2026, 6, 25, 12, 0), channelName = "visible")
flushAndClear()
val lives = repository.findOnAirLives(
memberId = viewer.id!!,
canViewAdultContent = true,
effectiveViewerGender = Gender.FEMALE,
isViewerCreator = true,
limit = 10
)
assertEquals(listOf(visible.id!!), lives.map { it.liveId })
}
@Test
@DisplayName("이달의 스케줄은 KST 오늘 00시부터 다음 달 00시 전까지 라이브와 오디오를 가까운 순으로 조회한다")
fun shouldFindMonthlySchedulesInKstWindow() {
@@ -127,6 +172,8 @@ class DefaultHomeFollowingQueryRepositoryTest @Autowired constructor(
val schedules = repository.findMonthlySchedules(
memberId = viewer.id!!,
canViewAdultContent = false,
effectiveViewerGender = Gender.NONE,
isViewerCreator = false,
now = LocalDateTime.of(2026, 6, 25, 12, 0),
limit = 3
)
@@ -160,6 +207,8 @@ class DefaultHomeFollowingQueryRepositoryTest @Autowired constructor(
val schedules = repository.findMonthlySchedules(
memberId = viewer.id!!,
canViewAdultContent = false,
effectiveViewerGender = Gender.NONE,
isViewerCreator = false,
now = LocalDateTime.of(2026, 6, 25, 0, 0),
limit = 3
)
@@ -181,6 +230,8 @@ class DefaultHomeFollowingQueryRepositoryTest @Autowired constructor(
val schedules = repository.findMonthlySchedules(
memberId = viewer.id!!,
canViewAdultContent = false,
effectiveViewerGender = Gender.NONE,
isViewerCreator = false,
now = LocalDateTime.of(2026, 6, 25, 14, 30),
limit = 3
)
@@ -205,6 +256,8 @@ class DefaultHomeFollowingQueryRepositoryTest @Autowired constructor(
val schedules = repository.findMonthlySchedules(
memberId = viewer.id!!,
canViewAdultContent = false,
effectiveViewerGender = Gender.NONE,
isViewerCreator = false,
now = LocalDateTime.of(2026, 6, 25, 0, 0),
limit = 10
)
@@ -220,6 +273,41 @@ class DefaultHomeFollowingQueryRepositoryTest @Autowired constructor(
)
}
@Test
@DisplayName("라이브 스케줄은 성별 제한과 크리에이터 입장 제한 정책을 반영한다")
fun shouldFindLiveMonthlySchedulesWithViewerGenderAndCreatorJoinPolicy() {
val viewer = saveMember("schedule-policy-viewer", MemberRole.CREATOR)
val creator = saveMember("schedule-policy-creator", MemberRole.CREATOR)
val theme = saveTheme("schedule-policy-theme")
saveFollowing(viewer, creator)
saveLiveRoom(
creator,
LocalDateTime.of(2026, 6, 25, 1, 0),
channelName = null,
genderRestriction = GenderRestriction.MALE_ONLY
)
saveLiveRoom(
creator,
LocalDateTime.of(2026, 6, 25, 2, 0),
channelName = null,
isAvailableJoinCreator = false
)
val visibleLive = saveLiveRoom(creator, LocalDateTime.of(2026, 6, 25, 3, 0), channelName = null)
val audio = saveAudioContent(creator, theme, LocalDateTime.of(2026, 6, 25, 4, 0))
flushAndClear()
val schedules = repository.findMonthlySchedules(
memberId = viewer.id!!,
canViewAdultContent = true,
effectiveViewerGender = Gender.FEMALE,
isViewerCreator = true,
now = LocalDateTime.of(2026, 6, 25, 0, 0),
limit = 10
)
assertEquals(listOf("LIVE:${visibleLive.id!!}", "AUDIO:${audio.id!!}"), schedules.map { it.scheduleId })
}
@Test
@DisplayName("최근 소식은 노출 가능한 랭킹 inbox 중 rank가 있는 row만 최신순으로 조회한다")
fun shouldFindRecentNewsWithRankedCreatorRankingPayloadOnly() {
@@ -230,6 +318,7 @@ class DefaultHomeFollowingQueryRepositoryTest @Autowired constructor(
saveFollowing(viewer, creator)
saveFollowing(viewer, blockedCreator)
saveFollowing(viewer, nonCreator)
saveCreatorRankingSnapshot(creator, rankNo = 3, visibleFromAtUtc = LocalDateTime.of(2026, 6, 25, 9, 0))
saveNews(viewer.id!!, creator.id!!, "old-without-rank", LocalDateTime.of(2026, 6, 25, 8, 0), rank = null)
val latestVisible = saveNews(viewer.id!!, creator.id!!, "latest", LocalDateTime.of(2026, 6, 25, 9, 0), rank = 3)
saveNews(viewer.id!!, creator.id!!, "future", LocalDateTime.of(2026, 6, 25, 10, 0), rank = 1)
@@ -250,12 +339,236 @@ class DefaultHomeFollowingQueryRepositoryTest @Autowired constructor(
assertEquals(listOf(3), news.map { it.creatorRanking?.rank })
}
@Test
@DisplayName("최근 소식의 크리에이터 랭킹은 최신 공개 배치만 조회한다")
fun shouldFindOnlyLatestVisibleCreatorRankingBatchInRecentNews() {
val viewer = saveMember("news-ranking-viewer", MemberRole.USER)
val firstCreator = saveMember("news-ranking-first", MemberRole.CREATOR)
val secondCreator = saveMember("news-ranking-second", MemberRole.CREATOR)
saveFollowing(viewer, firstCreator)
saveFollowing(viewer, secondCreator)
saveCreatorRankingSnapshot(firstCreator, rankNo = 1, visibleFromAtUtc = LocalDateTime.of(2026, 6, 8, 0, 0))
saveCreatorRankingSnapshot(secondCreator, rankNo = 2, visibleFromAtUtc = LocalDateTime.of(2026, 6, 8, 0, 0))
saveCreatorRankingSnapshot(firstCreator, rankNo = 1, visibleFromAtUtc = LocalDateTime.of(2026, 6, 15, 0, 0))
val oldFirstNews = saveNews(
viewer.id!!,
firstCreator.id!!,
"old-ranking-first",
LocalDateTime.of(2026, 6, 8, 0, 0),
rank = 1
)
val oldSecondNews = saveNews(
viewer.id!!,
secondCreator.id!!,
"old-ranking-second",
LocalDateTime.of(2026, 6, 8, 0, 0),
rank = 2
)
val latestFirstNews = saveNews(
viewer.id!!,
firstCreator.id!!,
"latest-ranking-first",
LocalDateTime.of(2026, 6, 15, 0, 0),
rank = 1
)
flushAndClear()
val beforeLatestVisible = repository.findRecentNews(
memberId = viewer.id!!,
canViewAdultContent = true,
nowUtc = LocalDateTime.of(2026, 6, 14, 23, 59),
limit = 30
)
val afterLatestVisible = repository.findRecentNews(
memberId = viewer.id!!,
canViewAdultContent = true,
nowUtc = LocalDateTime.of(2026, 6, 15, 0, 0),
limit = 30
)
assertEquals(listOf(oldSecondNews.id!!.toString(), oldFirstNews.id!!.toString()), beforeLatestVisible.map { it.newsId })
assertEquals(listOf(latestFirstNews.id!!.toString()), afterLatestVisible.map { it.newsId })
}
@Test
@DisplayName("최근 소식의 크리에이터 랭킹은 최신 완료 배치가 비어 있어도 과거 배치로 보충하지 않는다")
fun shouldExcludeCreatorRankingNewsWhenLatestDoneBatchIsEmpty() {
val viewer = saveMember("news-empty-ranking-viewer", MemberRole.USER)
val creator = saveMember("news-empty-ranking-creator", MemberRole.CREATOR)
saveFollowing(viewer, creator)
saveCreatorRankingSnapshot(creator, rankNo = 1, visibleFromAtUtc = LocalDateTime.of(2026, 6, 8, 0, 0))
saveCreatorRankingSnapshotJob(LocalDateTime.of(2026, 6, 8, 0, 0))
saveCreatorRankingSnapshotJob(LocalDateTime.of(2026, 6, 15, 0, 0))
saveNews(
viewer.id!!,
creator.id!!,
"old-ranking-empty-latest",
LocalDateTime.of(2026, 6, 8, 0, 0),
rank = 1
)
flushAndClear()
val news = repository.findRecentNews(
memberId = viewer.id!!,
canViewAdultContent = true,
nowUtc = LocalDateTime.of(2026, 6, 15, 0, 0),
limit = 30
)
assertTrue(news.none { it.type == FollowingNewsType.CREATOR_RANKING })
}
@Test
@DisplayName("최근 소식의 콘텐츠 랭킹은 콘텐츠별 최신 row만 limit 전에 남긴다")
fun shouldFindLatestContentRankingNewsPerContentBeforeLimit() {
val viewer = saveMember("news-content-ranking-viewer", MemberRole.USER)
val creator = saveMember("news-content-ranking-creator", MemberRole.CREATOR)
val theme = saveTheme("news-content-ranking-theme")
saveFollowing(viewer, creator)
val firstContent = saveAudioContent(creator, theme, LocalDateTime.of(2026, 6, 25, 8, 0)).apply {
title = "first ranked content"
coverImage = "content-ranking/first.png"
}
val secondContent = saveAudioContent(creator, theme, LocalDateTime.of(2026, 6, 25, 8, 10)).apply {
title = "second ranked content"
coverImage = "content-ranking/second.png"
}
saveNews(
memberId = viewer.id!!,
creatorId = creator.id!!,
sourceKey = "content-ranking-first-older",
visibleFromAtUtc = LocalDateTime.of(2026, 6, 25, 9, 0),
rank = 3,
newsType = FollowingNewsType.CONTENT_RANKING,
targetId = firstContent.id!!
)
saveNews(
memberId = viewer.id!!,
creatorId = creator.id!!,
sourceKey = "content-ranking-first-same-time-older-id",
visibleFromAtUtc = LocalDateTime.of(2026, 6, 25, 9, 1),
rank = 2,
newsType = FollowingNewsType.CONTENT_RANKING,
targetId = firstContent.id!!
)
val latestFirst = saveNews(
memberId = viewer.id!!,
creatorId = creator.id!!,
sourceKey = "content-ranking-first-same-time-latest-id",
visibleFromAtUtc = LocalDateTime.of(2026, 6, 25, 9, 1),
rank = 1,
newsType = FollowingNewsType.CONTENT_RANKING,
targetId = firstContent.id!!
)
val latestSecond = saveNews(
memberId = viewer.id!!,
creatorId = creator.id!!,
sourceKey = "content-ranking-second",
visibleFromAtUtc = LocalDateTime.of(2026, 6, 25, 8, 59),
rank = 4,
newsType = FollowingNewsType.CONTENT_RANKING,
targetId = secondContent.id!!
)
flushAndClear()
val news = repository.findRecentNews(
memberId = viewer.id!!,
canViewAdultContent = true,
nowUtc = LocalDateTime.of(2026, 6, 25, 10, 0),
limit = 2
)
assertEquals(listOf(latestFirst.id!!.toString(), latestSecond.id!!.toString()), news.map { it.newsId })
assertEquals(listOf(firstContent.id!!, secondContent.id!!), news.map { it.contentRanking?.contentId })
assertEquals(listOf(1, 4), news.map { it.contentRanking?.rank })
assertTrue(news.all { it.creatorRanking == null && it.audioContent == null && it.communityPost == null })
}
@Test
@DisplayName("최근 소식의 콘텐츠 랭킹은 rank가 없는 최신 row가 이전 노출 가능 row를 가리지 않는다")
fun shouldKeepOlderContentRankingNewsWhenNewerRowHasNoRank() {
val viewer = saveMember("news-content-null-rank-viewer", MemberRole.USER)
val creator = saveMember("news-content-null-rank-creator", MemberRole.CREATOR)
val theme = saveTheme("news-content-null-rank-theme")
saveFollowing(viewer, creator)
val content = saveAudioContent(creator, theme, LocalDateTime.of(2026, 6, 25, 8, 0))
val visible = saveNews(
memberId = viewer.id!!,
creatorId = creator.id!!,
sourceKey = "content-ranking-visible-before-null-rank",
visibleFromAtUtc = LocalDateTime.of(2026, 6, 25, 9, 0),
rank = 2,
newsType = FollowingNewsType.CONTENT_RANKING,
targetId = content.id!!
)
saveNews(
memberId = viewer.id!!,
creatorId = creator.id!!,
sourceKey = "content-ranking-null-rank-newer",
visibleFromAtUtc = LocalDateTime.of(2026, 6, 25, 10, 0),
rank = null,
newsType = FollowingNewsType.CONTENT_RANKING,
targetId = content.id!!
)
flushAndClear()
val news = repository.findRecentNews(
memberId = viewer.id!!,
canViewAdultContent = true,
nowUtc = LocalDateTime.of(2026, 6, 25, 11, 0),
limit = 30
)
assertEquals(listOf(visible.id!!.toString()), news.map { it.newsId })
}
@Test
@DisplayName("최근 소식의 콘텐츠 랭킹은 성인 최신 row가 비성인 회원의 이전 노출 가능 row를 가리지 않는다")
fun shouldKeepOlderContentRankingNewsWhenNewerRowIsAdultForNonAdultViewer() {
val viewer = saveMember("news-content-adult-row-viewer", MemberRole.USER)
val creator = saveMember("news-content-adult-row-creator", MemberRole.CREATOR)
val theme = saveTheme("news-content-adult-row-theme")
saveFollowing(viewer, creator)
val content = saveAudioContent(creator, theme, LocalDateTime.of(2026, 6, 25, 8, 0), isAdult = false)
val visible = saveNews(
memberId = viewer.id!!,
creatorId = creator.id!!,
sourceKey = "content-ranking-visible-before-adult-row",
visibleFromAtUtc = LocalDateTime.of(2026, 6, 25, 9, 0),
rank = 2,
isAdult = false,
newsType = FollowingNewsType.CONTENT_RANKING,
targetId = content.id!!
)
saveNews(
memberId = viewer.id!!,
creatorId = creator.id!!,
sourceKey = "content-ranking-adult-newer",
visibleFromAtUtc = LocalDateTime.of(2026, 6, 25, 10, 0),
rank = 1,
isAdult = true,
newsType = FollowingNewsType.CONTENT_RANKING,
targetId = content.id!!
)
flushAndClear()
val news = repository.findRecentNews(
memberId = viewer.id!!,
canViewAdultContent = false,
nowUtc = LocalDateTime.of(2026, 6, 25, 11, 0),
limit = 30
)
assertEquals(listOf(visible.id!!.toString()), news.map { it.newsId })
}
@Test
@DisplayName("최근 소식은 UTC now 이후 visibleFromAtUtc row를 조기 노출하지 않는다")
fun shouldNotExposeNewsVisibleAfterUtcNow() {
val viewer = saveMember("news-utc-viewer", MemberRole.USER)
val creator = saveMember("news-utc-creator", MemberRole.CREATOR)
saveFollowing(viewer, creator)
saveCreatorRankingSnapshot(creator, rankNo = 1, visibleFromAtUtc = LocalDateTime.of(2026, 6, 25, 14, 30))
val visibleNow = saveNews(viewer.id!!, creator.id!!, "visible-now", LocalDateTime.of(2026, 6, 25, 14, 30), rank = 1)
saveNews(viewer.id!!, creator.id!!, "future-utc", LocalDateTime.of(2026, 6, 25, 14, 31), rank = 2)
flushAndClear()
@@ -555,14 +868,18 @@ class DefaultHomeFollowingQueryRepositoryTest @Autowired constructor(
creator: Member,
beginDateTime: LocalDateTime,
channelName: String?,
isAdult: Boolean = false
isAdult: Boolean = false,
genderRestriction: GenderRestriction = GenderRestriction.ALL,
isAvailableJoinCreator: Boolean = true
): LiveRoom {
val liveRoom = LiveRoom(
title = "live-${creator.nickname}-$beginDateTime",
notice = "notice",
beginDateTime = beginDateTime,
numberOfPeople = 0,
isAdult = isAdult
isAdult = isAdult,
isAvailableJoinCreator = isAvailableJoinCreator,
genderRestriction = genderRestriction
).apply {
member = creator
this.channelName = channelName
@@ -667,6 +984,43 @@ class DefaultHomeFollowingQueryRepositoryTest @Autowired constructor(
return news
}
private fun saveCreatorRankingSnapshot(
creator: Member,
rankNo: Int,
visibleFromAtUtc: LocalDateTime,
rankingType: CreatorRankingType = CreatorRankingType.WEEKLY
): CreatorRankingSnapshot {
val snapshot = CreatorRankingSnapshot(
rankingType = rankingType,
aggregationStartAtUtc = visibleFromAtUtc.minusWeeks(1),
aggregationEndAtUtc = visibleFromAtUtc,
visibleFromAtUtc = visibleFromAtUtc,
creatorId = creator.id!!,
nickname = creator.nickname,
profileImageUrl = creator.profileImage,
rankNo = rankNo,
finalScore = 100.0 - rankNo,
scorePolicyVersion = "v1",
scoreDetailJson = "{}"
)
entityManager.persist(snapshot)
return snapshot
}
private fun saveCreatorRankingSnapshotJob(visibleFromAtUtc: LocalDateTime): CreatorRankingSnapshotJob {
val job = CreatorRankingSnapshotJob(
rankingType = CreatorRankingType.WEEKLY,
aggregationStartAtUtc = visibleFromAtUtc.minusWeeks(1),
aggregationEndAtUtc = visibleFromAtUtc,
visibleFromAtUtc = visibleFromAtUtc,
trigger = CreatorRankingSnapshotJobTrigger.SCHEDULED,
status = CreatorRankingSnapshotJobStatus.DONE,
processedAt = visibleFromAtUtc.minusHours(1)
)
entityManager.persist(job)
return job
}
private fun flushAndClear() {
entityManager.flush()
entityManager.clear()

View File

@@ -4,6 +4,7 @@ import kr.co.vividnext.sodalive.configs.QueryDslConfig
import kr.co.vividnext.sodalive.member.Member
import kr.co.vividnext.sodalive.member.MemberRole
import kr.co.vividnext.sodalive.member.following.CreatorFollowing
import kr.co.vividnext.sodalive.member.following.CreatorFollowingRepository
import kr.co.vividnext.sodalive.v2.home.following.domain.FollowingNewsType
import kr.co.vividnext.sodalive.v2.home.following.port.out.HomeFollowingNewsInboxRecord
import org.junit.jupiter.api.Assertions.assertDoesNotThrow
@@ -17,7 +18,15 @@ import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest
import org.springframework.context.annotation.Import
import org.springframework.test.annotation.DirtiesContext
import org.springframework.test.context.transaction.TestTransaction
import org.springframework.transaction.PlatformTransactionManager
import org.springframework.transaction.annotation.Propagation
import org.springframework.transaction.annotation.Transactional
import org.springframework.transaction.support.TransactionTemplate
import java.time.LocalDateTime
import java.util.concurrent.CountDownLatch
import java.util.concurrent.Executors
import java.util.concurrent.TimeUnit
import java.util.concurrent.TimeoutException
import javax.persistence.EntityManager
@DataJpaTest(
@@ -29,9 +38,13 @@ import javax.persistence.EntityManager
@Import(QueryDslConfig::class)
class HomeFollowingNewsInboxPersistenceAdapterTest @Autowired constructor(
private val repository: HomeFollowingNewsInboxJpaRepository,
private val entityManager: EntityManager
private val creatorFollowingRepository: CreatorFollowingRepository,
private val entityManager: EntityManager,
transactionManager: PlatformTransactionManager
) {
private val adapter = HomeFollowingNewsInboxPersistenceAdapter(repository, entityManager)
private val transactionalAdapter = HomeFollowingNewsInboxPersistenceAdapter(repository, entityManager, transactionManager)
private val transactionTemplate = TransactionTemplate(transactionManager)
@Test
@DisplayName("insertIgnoreAll은 memberId newsType sourceKey 중복을 예외 없이 무시하고 신규 row만 저장한다")
@@ -115,6 +128,75 @@ class HomeFollowingNewsInboxPersistenceAdapterTest @Autowired constructor(
assertEquals(listOf(activeFollowerWithoutInbox.id!!), followerIds)
}
@Test
@Transactional(propagation = Propagation.NOT_SUPPORTED)
@DirtiesContext(methodMode = DirtiesContext.MethodMode.AFTER_METHOD)
@DisplayName("publish가 follower 조회 후 insert 전 멈춘 사이 언팔로우가 완료돼도 재팔로우 후 과거 소식이 활성화되지 않는다")
fun shouldNotExposeNewsCreatedBeforeUnfollowAfterRefollowWhenPublishIsDelayed() {
val (memberId, creatorId) = transactionTemplate.execute {
val creator = saveMember("concurrent-creator", MemberRole.CREATOR)
val follower = saveMember("concurrent-follower", MemberRole.USER)
saveFollowing(follower, creator, isActive = true)
entityManager.flush()
follower.id!! to creator.id!!
}!!
val followerRead = CountDownLatch(1)
val allowPublishInsert = CountDownLatch(1)
val executor = Executors.newFixedThreadPool(2)
try {
val publish = executor.submit<Int> {
transactionTemplate.execute {
val followerIds = transactionalAdapter.findActiveFollowerIds(creatorId)
followerRead.countDown()
assertTrue(allowPublishInsert.await(5, TimeUnit.SECONDS))
transactionalAdapter.insertIgnoreAll(
followerIds.map { followerId ->
record(memberId = followerId, creatorId = creatorId, sourceKey = "COMMUNITY_POST:delayed")
}
)
}!!
}
assertTrue(followerRead.await(5, TimeUnit.SECONDS))
val unfollow = executor.submit {
transactionTemplate.executeWithoutResult {
val following = creatorFollowingRepository.findByCreatorIdAndMemberId(
creatorId = creatorId,
memberId = memberId
)!!
following.isActive = false
transactionalAdapter.deactivateByMemberIdAndCreatorId(memberId = memberId, creatorId = creatorId)
}
}
Thread.sleep(200)
val unfollowCompletedBeforeInsert = try {
unfollow.get(1, TimeUnit.SECONDS)
true
} catch (_: TimeoutException) {
false
}
allowPublishInsert.countDown()
assertEquals(1, publish.get(5, TimeUnit.SECONDS))
if (!unfollowCompletedBeforeInsert) {
unfollow.get(5, TimeUnit.SECONDS)
}
transactionTemplate.executeWithoutResult {
creatorFollowingRepository.findByCreatorIdAndMemberId(
creatorId = creatorId,
memberId = memberId
)!!.isActive = true
}
val activeRows = transactionTemplate.execute {
repository.findAll().filter { it.memberId == memberId && it.creatorId == creatorId && it.isActive }
}!!
assertEquals(emptyList<HomeFollowingNewsInbox>(), activeRows)
} finally {
executor.shutdownNow()
}
}
private fun saveMember(seed: String, role: MemberRole): Member {
val member = Member(
email = "$seed@test.com",

View File

@@ -1,6 +1,9 @@
package kr.co.vividnext.sodalive.v2.home.following.application
import kr.co.vividnext.sodalive.member.Gender
import kr.co.vividnext.sodalive.member.Member
import kr.co.vividnext.sodalive.member.MemberRole
import kr.co.vividnext.sodalive.member.auth.Auth
import kr.co.vividnext.sodalive.member.contentpreference.MemberContentPreferenceService
import kr.co.vividnext.sodalive.v2.home.following.domain.HomeFollowingCreator
import kr.co.vividnext.sodalive.v2.home.following.domain.HomeFollowingLive
@@ -58,6 +61,24 @@ class HomeFollowingQueryServiceTest {
assertEquals(11L, queryPort.memberId)
}
@Test
@DisplayName("본인인증 성별 우선 effective gender와 크리에이터 여부는 라이브 조회 port에 전달된다")
fun shouldPassEffectiveGenderAndCreatorRoleToLiveQueryPort() {
val member = member(13L).apply {
role = MemberRole.CREATOR
gender = Gender.MALE
auth = Auth(name = "auth", birth = "20000101", uniqueCi = "ci", di = "di", gender = 0)
}
Mockito.`when`(memberContentPreferenceService.canViewAdultContent(member)).thenReturn(true)
service.findHomeFollowing(member)
assertEquals(Gender.FEMALE, queryPort.onAirEffectiveViewerGender)
assertEquals(Gender.FEMALE, queryPort.monthlySchedulesEffectiveViewerGender)
assertTrue(queryPort.onAirIsViewerCreator!!)
assertTrue(queryPort.monthlySchedulesIsViewerCreator!!)
}
@Test
@DisplayName("기본 now는 JVM 기본 timezone과 무관하게 UTC 기준으로 port에 전달된다")
fun shouldUseUtcNowRegardlessOfJvmDefaultTimezone() {
@@ -91,9 +112,13 @@ class HomeFollowingQueryServiceTest {
var followingCreatorsLimit: Int? = null
var onAirLivesLimit: Int? = null
var onAirCanViewAdultContent: Boolean? = null
var onAirEffectiveViewerGender: Gender? = null
var onAirIsViewerCreator: Boolean? = null
var monthlySchedulesLimit: Int? = null
var monthlySchedulesNow: LocalDateTime? = null
var monthlySchedulesCanViewAdultContent: Boolean? = null
var monthlySchedulesEffectiveViewerGender: Gender? = null
var monthlySchedulesIsViewerCreator: Boolean? = null
var recentNewsLimit: Int? = null
var recentNewsNow: LocalDateTime? = null
var recentNewsCanViewAdultContent: Boolean? = null
@@ -104,8 +129,16 @@ class HomeFollowingQueryServiceTest {
return listOf(HomeFollowingCreator(1L, "creator", "profile"))
}
override fun findOnAirLives(memberId: Long, canViewAdultContent: Boolean, limit: Int): List<HomeFollowingLive> {
override fun findOnAirLives(
memberId: Long,
canViewAdultContent: Boolean,
effectiveViewerGender: Gender,
isViewerCreator: Boolean,
limit: Int
): List<HomeFollowingLive> {
onAirCanViewAdultContent = canViewAdultContent
onAirEffectiveViewerGender = effectiveViewerGender
onAirIsViewerCreator = isViewerCreator
onAirLivesLimit = limit
return emptyList()
}
@@ -113,10 +146,14 @@ class HomeFollowingQueryServiceTest {
override fun findMonthlySchedules(
memberId: Long,
canViewAdultContent: Boolean,
effectiveViewerGender: Gender,
isViewerCreator: Boolean,
now: LocalDateTime,
limit: Int
): List<HomeFollowingSchedule> {
monthlySchedulesCanViewAdultContent = canViewAdultContent
monthlySchedulesEffectiveViewerGender = effectiveViewerGender
monthlySchedulesIsViewerCreator = isViewerCreator
monthlySchedulesNow = now
monthlySchedulesLimit = limit
return emptyList()

View File

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

View File

@@ -1012,6 +1012,7 @@ class HomeRecommendationQueryServiceTest {
)
val activeCreators = listOf(
RecentlyActiveCreatorRecord(
creatorId = 301L,
creatorNickname = "creator",
creatorProfileImage = "profile.png",
activityType = CreatorActivityType.LIVE,