From f1c2e6c50bd4c974b5dd3ed974acbbfd63983623 Mon Sep 17 00:00:00 2001 From: Klaus Date: Thu, 30 Jul 2026 19:51:30 +0900 Subject: [PATCH] =?UTF-8?q?feat(home):=20=ED=8C=94=EB=A1=9C=EC=9E=89=20?= =?UTF-8?q?=ED=83=AD=20=EC=B5=9C=EA=B7=BC=20=EC=86=8C=EC=8B=9D=20=EC=A0=95?= =?UTF-8?q?=EC=B1=85=EC=9D=84=20=EB=B3=B4=EA=B0=95=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../plan-task.md | 429 ++++++++++++++++++ docs/20260625_메인_홈_팔로잉_탭_API/prd.md | 33 +- .../reviews/phase-1-review.md | 59 +++ .../reviews/phase-2-review.md | 59 +++ .../reviews/phase-3-review.md | 120 +++++ .../reviews/phase-4-review.md | 203 +++++++++ .../reviews/phase-5-review.md | 58 +++ .../reviews/phase-5.5-review.md | 60 +++ .../reviews/phase-6-review.md | 185 ++++++++ .../reviews/phase-7-review.md | 162 +++++++ .../sodalive/member/MemberService.kt | 9 +- .../following/CreatorFollowingRepository.kt | 13 + .../DefaultHomeFollowingQueryRepository.kt | 139 +++++- .../HomeFollowingNewsInboxJpaRepository.kt | 1 + .../application/HomeFollowingQueryService.kt | 25 +- .../port/out/HomeFollowingQueryPort.kt | 11 +- .../sodalive/member/MemberServiceTest.kt | 87 ++++ .../in/web/HomeFollowingEndToEndTest.kt | 28 +- ...DefaultHomeFollowingQueryRepositoryTest.kt | 360 ++++++++++++++- ...ollowingNewsInboxPersistenceAdapterTest.kt | 84 +++- .../HomeFollowingQueryServiceTest.kt | 39 +- 21 files changed, 2145 insertions(+), 19 deletions(-) create mode 100644 docs/20260625_메인_홈_팔로잉_탭_API/reviews/phase-1-review.md create mode 100644 docs/20260625_메인_홈_팔로잉_탭_API/reviews/phase-2-review.md create mode 100644 docs/20260625_메인_홈_팔로잉_탭_API/reviews/phase-3-review.md create mode 100644 docs/20260625_메인_홈_팔로잉_탭_API/reviews/phase-4-review.md create mode 100644 docs/20260625_메인_홈_팔로잉_탭_API/reviews/phase-5-review.md create mode 100644 docs/20260625_메인_홈_팔로잉_탭_API/reviews/phase-5.5-review.md create mode 100644 docs/20260625_메인_홈_팔로잉_탭_API/reviews/phase-6-review.md create mode 100644 docs/20260625_메인_홈_팔로잉_탭_API/reviews/phase-7-review.md diff --git a/docs/20260625_메인_홈_팔로잉_탭_API/plan-task.md b/docs/20260625_메인_홈_팔로잉_탭_API/plan-task.md index 3f055e85..144200fd 100644 --- a/docs/20260625_메인_홈_팔로잉_탭_API/plan-task.md +++ b/docs/20260625_메인_홈_팔로잉_탭_API/plan-task.md @@ -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`로만 점검한다. diff --git a/docs/20260625_메인_홈_팔로잉_탭_API/prd.md b/docs/20260625_메인_홈_팔로잉_탭_API/prd.md index 6270d69e..a28fbdfb 100644 --- a/docs/20260625_메인_홈_팔로잉_탭_API/prd.md +++ b/docs/20260625_메인_홈_팔로잉_탭_API/prd.md @@ -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` 기준 최신 항목 하나만 응답한다. diff --git a/docs/20260625_메인_홈_팔로잉_탭_API/reviews/phase-1-review.md b/docs/20260625_메인_홈_팔로잉_탭_API/reviews/phase-1-review.md new file mode 100644 index 00000000..fbc454a1 --- /dev/null +++ b/docs/20260625_메인_홈_팔로잉_탭_API/reviews/phase-1-review.md @@ -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 완료 판정을 유지한다. diff --git a/docs/20260625_메인_홈_팔로잉_탭_API/reviews/phase-2-review.md b/docs/20260625_메인_홈_팔로잉_탭_API/reviews/phase-2-review.md new file mode 100644 index 00000000..80066e32 --- /dev/null +++ b/docs/20260625_메인_홈_팔로잉_탭_API/reviews/phase-2-review.md @@ -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 완료 판정을 유지한다. diff --git a/docs/20260625_메인_홈_팔로잉_탭_API/reviews/phase-3-review.md b/docs/20260625_메인_홈_팔로잉_탭_API/reviews/phase-3-review.md new file mode 100644 index 00000000..b07acc64 --- /dev/null +++ b/docs/20260625_메인_홈_팔로잉_탭_API/reviews/phase-3-review.md @@ -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 완료 판정을 유지한다. diff --git a/docs/20260625_메인_홈_팔로잉_탭_API/reviews/phase-4-review.md b/docs/20260625_메인_홈_팔로잉_탭_API/reviews/phase-4-review.md new file mode 100644 index 00000000..e1ea0378 --- /dev/null +++ b/docs/20260625_메인_홈_팔로잉_탭_API/reviews/phase-4-review.md @@ -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 완료 판정을 유지한다. diff --git a/docs/20260625_메인_홈_팔로잉_탭_API/reviews/phase-5-review.md b/docs/20260625_메인_홈_팔로잉_탭_API/reviews/phase-5-review.md new file mode 100644 index 00000000..12849a4d --- /dev/null +++ b/docs/20260625_메인_홈_팔로잉_탭_API/reviews/phase-5-review.md @@ -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 완료 판정을 유지한다. diff --git a/docs/20260625_메인_홈_팔로잉_탭_API/reviews/phase-5.5-review.md b/docs/20260625_메인_홈_팔로잉_탭_API/reviews/phase-5.5-review.md new file mode 100644 index 00000000..1b972005 --- /dev/null +++ b/docs/20260625_메인_홈_팔로잉_탭_API/reviews/phase-5.5-review.md @@ -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 완료 판정을 유지한다. diff --git a/docs/20260625_메인_홈_팔로잉_탭_API/reviews/phase-6-review.md b/docs/20260625_메인_홈_팔로잉_탭_API/reviews/phase-6-review.md new file mode 100644 index 00000000..595e3c44 --- /dev/null +++ b/docs/20260625_메인_홈_팔로잉_탭_API/reviews/phase-6-review.md @@ -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 완료 판정을 유지한다. diff --git a/docs/20260625_메인_홈_팔로잉_탭_API/reviews/phase-7-review.md b/docs/20260625_메인_홈_팔로잉_탭_API/reviews/phase-7-review.md new file mode 100644 index 00000000..49fb1b3b --- /dev/null +++ b/docs/20260625_메인_홈_팔로잉_탭_API/reviews/phase-7-review.md @@ -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 완료 판정을 유지한다. diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/member/MemberService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/member/MemberService.kt index 559b9445..71eb01ca 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/member/MemberService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/member/MemberService.kt @@ -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 ) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/member/following/CreatorFollowingRepository.kt b/src/main/kotlin/kr/co/vividnext/sodalive/member/following/CreatorFollowingRepository.kt index d74b551d..3dc9ac73 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/member/following/CreatorFollowingRepository.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/member/following/CreatorFollowingRepository.kt @@ -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, 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() + } } diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/home/following/adapter/out/persistence/DefaultHomeFollowingQueryRepository.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/home/following/adapter/out/persistence/DefaultHomeFollowingQueryRepository.kt index d5d33206..9be531ca 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/v2/home/following/adapter/out/persistence/DefaultHomeFollowingQueryRepository.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/home/following/adapter/out/persistence/DefaultHomeFollowingQueryRepository.kt @@ -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 { + override fun findOnAirLives( + memberId: Long, + canViewAdultContent: Boolean, + effectiveViewerGender: Gender, + isViewerCreator: Boolean, + limit: Int + ): List { 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 { 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 { 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 diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/home/following/adapter/out/persistence/HomeFollowingNewsInboxJpaRepository.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/home/following/adapter/out/persistence/HomeFollowingNewsInboxJpaRepository.kt index 479eedb2..9b035941 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/v2/home/following/adapter/out/persistence/HomeFollowingNewsInboxJpaRepository.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/home/following/adapter/out/persistence/HomeFollowingNewsInboxJpaRepository.kt @@ -52,6 +52,7 @@ interface HomeFollowingNewsInboxJpaRepository : JpaRepository - fun findOnAirLives(memberId: Long, canViewAdultContent: Boolean, limit: Int): List + fun findOnAirLives( + memberId: Long, + canViewAdultContent: Boolean, + effectiveViewerGender: Gender, + isViewerCreator: Boolean, + limit: Int + ): List fun findMonthlySchedules( memberId: Long, canViewAdultContent: Boolean, + effectiveViewerGender: Gender, + isViewerCreator: Boolean, now: LocalDateTime, limit: Int ): List diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/member/MemberServiceTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/member/MemberServiceTest.kt index c8625b6e..ca3c1bb8 100644 --- a/src/test/kotlin/kr/co/vividnext/sodalive/member/MemberServiceTest.kt +++ b/src/test/kotlin/kr/co/vividnext/sodalive/member/MemberServiceTest.kt @@ -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) + } } diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/home/following/adapter/in/web/HomeFollowingEndToEndTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/home/following/adapter/in/web/HomeFollowingEndToEndTest.kt index 4324eb60..54090af8 100644 --- a/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/home/following/adapter/in/web/HomeFollowingEndToEndTest.kt +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/home/following/adapter/in/web/HomeFollowingEndToEndTest.kt @@ -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, diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/v2/home/following/adapter/out/persistence/DefaultHomeFollowingQueryRepositoryTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/home/following/adapter/out/persistence/DefaultHomeFollowingQueryRepositoryTest.kt index e624688b..461212b7 100644 --- a/src/test/kotlin/kr/co/vividnext/sodalive/v2/home/following/adapter/out/persistence/DefaultHomeFollowingQueryRepositoryTest.kt +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/home/following/adapter/out/persistence/DefaultHomeFollowingQueryRepositoryTest.kt @@ -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() diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/v2/home/following/adapter/out/persistence/HomeFollowingNewsInboxPersistenceAdapterTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/home/following/adapter/out/persistence/HomeFollowingNewsInboxPersistenceAdapterTest.kt index d4f79f2e..380bd0f1 100644 --- a/src/test/kotlin/kr/co/vividnext/sodalive/v2/home/following/adapter/out/persistence/HomeFollowingNewsInboxPersistenceAdapterTest.kt +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/home/following/adapter/out/persistence/HomeFollowingNewsInboxPersistenceAdapterTest.kt @@ -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 { + 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(), activeRows) + } finally { + executor.shutdownNow() + } + } + private fun saveMember(seed: String, role: MemberRole): Member { val member = Member( email = "$seed@test.com", diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/v2/home/following/application/HomeFollowingQueryServiceTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/home/following/application/HomeFollowingQueryServiceTest.kt index ecaf237e..db834f1d 100644 --- a/src/test/kotlin/kr/co/vividnext/sodalive/v2/home/following/application/HomeFollowingQueryServiceTest.kt +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/home/following/application/HomeFollowingQueryServiceTest.kt @@ -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 { + override fun findOnAirLives( + memberId: Long, + canViewAdultContent: Boolean, + effectiveViewerGender: Gender, + isViewerCreator: Boolean, + limit: Int + ): List { 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 { monthlySchedulesCanViewAdultContent = canViewAdultContent + monthlySchedulesEffectiveViewerGender = effectiveViewerGender + monthlySchedulesIsViewerCreator = isViewerCreator monthlySchedulesNow = now monthlySchedulesLimit = limit return emptyList()