test #443
@@ -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`로만 점검한다.
|
||||
|
||||
@@ -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` 기준 최신 항목 하나만 응답한다.
|
||||
|
||||
59
docs/20260625_메인_홈_팔로잉_탭_API/reviews/phase-1-review.md
Normal file
59
docs/20260625_메인_홈_팔로잉_탭_API/reviews/phase-1-review.md
Normal file
@@ -0,0 +1,59 @@
|
||||
# Phase 1 코드 리뷰 보고서
|
||||
|
||||
## 1. 리뷰 정보
|
||||
|
||||
| 항목 | 내용 |
|
||||
|---|---|
|
||||
| 리뷰 대상 | Phase 1 / Task 1.1~1.2 |
|
||||
| 기준 commit 또는 working tree | `e6f56f24` + 2026-07-30 working tree |
|
||||
| 리뷰 일자 | 2026-07-30 |
|
||||
| 리뷰어 | Codex |
|
||||
| 기준 문서 | `prd.md`, `plan-task.md`, `docs/agent-guides/*.md` |
|
||||
| 리뷰 상태 | 판정 완료 |
|
||||
|
||||
## 2. 목적과 범위
|
||||
|
||||
- DTO/domain 모델, 비로그인 응답, controller, Security `permitAll`이 공개 계약과 일치하는지 정적으로 대조했다.
|
||||
- `HomeFollowingTabResponse.kt`, `HomeFollowingController.kt`, `HomeFollowingFacade.kt`, `SecurityConfig.kt`와 대응 테스트를 포함했다.
|
||||
- 사용자의 지시에 따라 컴파일과 테스트는 실행하지 않았다. `plan-task.md`의 기존 성공 기록은 참고 증거로만 사용했다.
|
||||
|
||||
## 3. 검토 근거
|
||||
|
||||
- `HomeFollowingTabResponse.loginRequired()`는 로그인 필요 상태와 다섯 개 빈 배열을 생성한다.
|
||||
- controller는 nullable 인증 회원을 facade에 전달하고 `ApiResponse.ok(...)`로 감싼다.
|
||||
- `SecurityConfig`는 `GET /api/v2/home/following`을 `permitAll`로 허용한다.
|
||||
- DTO 테스트와 controller 테스트는 비회원/인증 회원 계약 및 nested 최근 소식 변환을 다룬다.
|
||||
|
||||
## 4. 발견 사항
|
||||
|
||||
확정 발견 사항 없음.
|
||||
|
||||
## 5. plan·goal 전환
|
||||
|
||||
전환 항목 없음.
|
||||
|
||||
## 6. 리뷰 종료 판정
|
||||
|
||||
| 판정 항목 | 결과 | 근거 |
|
||||
|---|---|---|
|
||||
| 리뷰 범위 전체 확인 | 충족 | Phase 1 코드·테스트·문서 정적 대조 |
|
||||
| 후보 항목 판정 완료 | 충족 | 후보 없음 |
|
||||
| 확정 항목 plan 반영 | 해당 없음 | 확정 발견 사항 없음 |
|
||||
| 검증 명령과 결과 기록 | 충족 | 테스트 미실행 사유와 기존 기록 분리 |
|
||||
|
||||
**최종 결론:** 확정 발견 사항 없음.
|
||||
|
||||
**남은 항목:** 없음.
|
||||
|
||||
## 7. 3차 정적 리뷰 — 2026-07-30
|
||||
|
||||
- controller, facade, DTO, Security permitAll과 비로그인 빈 응답 계약을 현재 working tree 기준으로 다시 대조했다.
|
||||
- 컴파일과 테스트는 사용자 지시에 따라 실행하지 않았다.
|
||||
- 신규 확정 발견 사항 없음. 기존 Phase 1 완료 판정을 유지한다.
|
||||
|
||||
## 8. 4차 정적 리뷰 — 2026-07-30
|
||||
|
||||
- 공개 endpoint, nullable 인증 principal, `ApiResponse.ok(...)`, 로그인 필요 빈 응답과 nested DTO 변환을 현재 working tree에서 다시 대조했다.
|
||||
- controller·facade·DTO 테스트가 비회원/인증 회원 분기와 공개 응답 계약을 고정하는지 정적으로 확인했다.
|
||||
- 컴파일과 테스트는 사용자 지시에 따라 실행하지 않았다.
|
||||
- 신규 확정 발견 사항 없음. 기존 Phase 1 완료 판정을 유지한다.
|
||||
59
docs/20260625_메인_홈_팔로잉_탭_API/reviews/phase-2-review.md
Normal file
59
docs/20260625_메인_홈_팔로잉_탭_API/reviews/phase-2-review.md
Normal file
@@ -0,0 +1,59 @@
|
||||
# Phase 2 코드 리뷰 보고서
|
||||
|
||||
## 1. 리뷰 정보
|
||||
|
||||
| 항목 | 내용 |
|
||||
|---|---|
|
||||
| 리뷰 대상 | Phase 2 / Task 2.1~2.2 |
|
||||
| 기준 commit 또는 working tree | `e6f56f24` + 2026-07-30 working tree |
|
||||
| 리뷰 일자 | 2026-07-30 |
|
||||
| 리뷰어 | Codex |
|
||||
| 기준 문서 | `prd.md`, `plan-task.md`, `create-home-following-news-inbox-table.sql` |
|
||||
| 리뷰 상태 | 판정 완료 |
|
||||
|
||||
## 2. 목적과 범위
|
||||
|
||||
- inbox entity/JPA repository/adapter와 MySQL DDL의 컬럼·유니크 키·인덱스·비활성화 정책을 정적으로 대조했다.
|
||||
- 중복 충돌 retry, 활성 follower 조회, 테스트 격리와 기존 통합 테스트 범위를 확인했다.
|
||||
- 사용자의 지시에 따라 컴파일과 테스트는 실행하지 않았다.
|
||||
|
||||
## 3. 검토 근거
|
||||
|
||||
- entity와 DDL은 `member_id/news_type/source_key` 유니크 정책, timestamp, 길이, 활성 상태 컬럼이 일치한다.
|
||||
- adapter는 입력 중복 제거 후 기존 수신 회원을 일괄 조회하고 `saveAll`/`flush`하며, unique 충돌 시 새 트랜잭션으로 한 번 재시도한다.
|
||||
- 언팔로우 비활성화 쿼리와 활성 follower 조회 쿼리는 계획의 키 조건을 사용한다.
|
||||
- 통합 테스트는 실제 unique 충돌 후 트랜잭션 사용 가능 여부, 비활성화, 활성 follower 조회를 포함한다.
|
||||
|
||||
## 4. 발견 사항
|
||||
|
||||
확정 발견 사항 없음.
|
||||
|
||||
## 5. plan·goal 전환
|
||||
|
||||
전환 항목 없음.
|
||||
|
||||
## 6. 리뷰 종료 판정
|
||||
|
||||
| 판정 항목 | 결과 | 근거 |
|
||||
|---|---|---|
|
||||
| 리뷰 범위 전체 확인 | 충족 | entity/repository/adapter/DDL/test 정적 대조 |
|
||||
| 후보 항목 판정 완료 | 충족 | 후보 없음 |
|
||||
| 확정 항목 plan 반영 | 해당 없음 | 확정 발견 사항 없음 |
|
||||
| 검증 명령과 결과 기록 | 충족 | 테스트 미실행 사유 기록 |
|
||||
|
||||
**최종 결론:** 확정 발견 사항 없음.
|
||||
|
||||
**남은 항목:** 없음.
|
||||
|
||||
## 7. 3차 정적 리뷰 — 2026-07-30
|
||||
|
||||
- inbox DDL, Entity, JPA adapter의 중복 방지·retry·비활성화 계약을 현재 working tree 기준으로 다시 대조했다.
|
||||
- 컴파일과 테스트는 사용자 지시에 따라 실행하지 않았다.
|
||||
- 신규 확정 발견 사항 없음. 기존 Phase 2 완료 판정을 유지한다.
|
||||
|
||||
## 8. 4차 정적 리뷰 — 2026-07-30
|
||||
|
||||
- DDL·Entity의 컬럼 길이, timestamp, unique/index 정책과 adapter의 중복 제거·충돌 재시도·비활성화 동작을 다시 대조했다.
|
||||
- 활성 follower 조회의 잠금이 Phase 4 팔로우 상태 변경 경계와 연결되고 관련 persistence 테스트가 이를 고정하는지 정적으로 확인했다.
|
||||
- 컴파일과 테스트는 사용자 지시에 따라 실행하지 않았다.
|
||||
- 신규 확정 발견 사항 없음. 기존 Phase 2 완료 판정을 유지한다.
|
||||
120
docs/20260625_메인_홈_팔로잉_탭_API/reviews/phase-3-review.md
Normal file
120
docs/20260625_메인_홈_팔로잉_탭_API/reviews/phase-3-review.md
Normal file
@@ -0,0 +1,120 @@
|
||||
# Phase 3 코드 리뷰 보고서
|
||||
|
||||
## 1. 리뷰 정보
|
||||
|
||||
| 항목 | 내용 |
|
||||
|---|---|
|
||||
| 리뷰 대상 | Phase 3 / Task 3.1~3.6 |
|
||||
| 기준 commit 또는 working tree | `e6f56f24` + 2026-07-30 working tree |
|
||||
| 리뷰 일자 | 2026-07-30 |
|
||||
| 리뷰어 | Codex |
|
||||
| 기준 문서 | `prd.md` Feature B~F, `plan-task.md` Phase 3 |
|
||||
| 리뷰 상태 | 판정 완료 |
|
||||
|
||||
## 2. 목적과 범위
|
||||
|
||||
- 팔로잉 크리에이터, On Air, 월간 스케줄, 최근 소식 repository와 query service를 기존 접근 정책까지 포함해 정적으로 대조했다.
|
||||
- `DefaultHomeFollowingQueryRepository.kt`, `HomeFollowingQueryPort.kt`, `HomeFollowingQueryService.kt`와 대응 테스트를 검토했다.
|
||||
- 사용자의 지시에 따라 컴파일과 테스트는 실행하지 않았다.
|
||||
|
||||
## 3. 발견 사항 요약
|
||||
|
||||
| ID | 심각도 | 상태 | 제목 | 소유 Task | 후속 goal |
|
||||
|---|---|---|---|---|---|
|
||||
| `REV-P3-001` | High | 확정 | On Air와 라이브 스케줄이 기존 라이브 입장 제한을 적용하지 않는다 | Task 3.7 | `P3-R1` |
|
||||
|
||||
## 4. 발견 사항 상세
|
||||
|
||||
### REV-P3-001 — On Air와 라이브 스케줄이 기존 라이브 입장 제한을 적용하지 않는다
|
||||
|
||||
- **심각도:** High
|
||||
- **상태:** 확정
|
||||
- **관련 요구사항:** PRD Feature C의 성별·크리에이터 입장 제한, Feature E의 기존 채널 스케줄 정책 재사용
|
||||
- **소유 Task:** 신규 Task 3.7 / `P3-R1`
|
||||
|
||||
**관찰 내용**
|
||||
|
||||
`HomeFollowingQueryService`는 회원 id와 성인 콘텐츠 허용 여부만 port에 전달한다.
|
||||
`DefaultHomeFollowingQueryRepository.findOnAirLives(...)`와 live schedule 조회는 활성/채널/성인/차단 조건만 적용하며
|
||||
`live_room.gender_restriction`과 크리에이터 회원의 `is_available_join_creator` 조건을 적용하지 않는다.
|
||||
|
||||
반면 기존 `LiveRoomQueryRepositoryImpl.getLiveRoomListNow(...)`와
|
||||
`DefaultCreatorChannelHomeQueryRepository.findCurrentLive/findSchedules(...)`는 effective gender와 크리에이터 입장 제한을 적용한다.
|
||||
|
||||
**근거**
|
||||
|
||||
- 코드: `HomeFollowingQueryService.kt:19-30`
|
||||
- 코드: `DefaultHomeFollowingQueryRepository.kt:70-99`, `242-273`
|
||||
- 비교 코드: `LiveRoomRepository.kt:97-115`
|
||||
- 비교 코드: `DefaultCreatorChannelHomeQueryRepository.kt:136-145`, `225-233`
|
||||
- 테스트 공백: `DefaultHomeFollowingQueryRepositoryTest`의 On Air 테스트는 활성/성인/정렬만 검증한다.
|
||||
|
||||
**정적 재현 절차**
|
||||
|
||||
1. 남성 회원이 여성 전용 라이브를 연 팔로잉 크리에이터를 조회한다고 가정한다.
|
||||
2. 라이브를 `isActive=true`, non-empty `channelName`, 비성인으로 두면 현재 팔로잉 조회 조건을 모두 통과한다.
|
||||
3. 실제 라이브 입장 정책은 성별 불일치로 입장을 거부하지만 팔로잉 탭 On Air에는 노출된다.
|
||||
4. 크리에이터 회원이 `isAvailableJoinCreator=false`인 타 크리에이터 라이브를 조회하는 경우도 동일하게 노출된다.
|
||||
|
||||
**영향**
|
||||
|
||||
팔로잉 탭에 터치해도 입장할 수 없는 라이브 또는 스케줄이 노출되어 기존 라이브 접근 정책과 API 결과가 불일치한다.
|
||||
|
||||
**권장 조치**
|
||||
|
||||
회원의 본인인증 성별을 우선한 effective gender와 크리에이터 회원 여부를 query service에서 전달하고, 기존 QueryDSL 조건을
|
||||
On Air와 live schedule에 최소 적용한다. 성별 불일치와 크리에이터 입장 불가 회귀 테스트를 먼저 추가한다.
|
||||
|
||||
**판정 기록**
|
||||
|
||||
- 2026-07-30 — PRD와 기존 라이브 조회 구현을 대조해 확정했다. 런타임 테스트는 사용자 지시에 따라 실행하지 않았다.
|
||||
|
||||
## 5. plan·goal 전환
|
||||
|
||||
- `plan-task.md` Phase 3에 Task 3.7, `P3-R1`, `P3-R-GATE`를 추가했다.
|
||||
- 기존 Task 3.1~3.6의 완료 체크와 검증 기록은 유지했다.
|
||||
|
||||
## 6. 리뷰 종료 판정
|
||||
|
||||
| 판정 항목 | 결과 | 근거 |
|
||||
|---|---|---|
|
||||
| 리뷰 범위 전체 확인 | 충족 | Phase 3 repository/service/test 정적 대조 |
|
||||
| 후보 항목 판정 완료 | 충족 | `REV-P3-001` 확정 |
|
||||
| 확정 항목 plan 반영 | 충족 | Task 3.7 / `P3-R1` |
|
||||
| 검증 명령과 결과 기록 | 충족 | 테스트 미실행 사유 기록 |
|
||||
|
||||
**최종 결론:** 수정 goal 필요.
|
||||
|
||||
**남은 항목:** `P3-R1` 구현 후 `P3-R-GATE`와 이 문서의 수정 후 검증 기록을 수행한다.
|
||||
|
||||
## 7. 수정 후 검증 기록
|
||||
|
||||
- 2026-07-30 — RED 확인: `./gradlew --no-daemon test --tests "kr.co.vividnext.sodalive.v2.home.following.application.HomeFollowingQueryServiceTest" --tests "kr.co.vividnext.sodalive.v2.home.following.adapter.out.persistence.DefaultHomeFollowingQueryRepositoryTest"` 실행 결과 effective gender·creator 여부 port 인자 미반영 컴파일 오류로 `BUILD FAILED`.
|
||||
- 2026-07-30 — `HomeFollowingQueryService`에서 본인인증 성별 우선 effective gender와 크리에이터 회원 여부를 `HomeFollowingQueryPort`에 전달하고, On Air와 라이브 스케줄 QueryDSL에 `genderRestriction` 및 `isAvailableJoinCreator` 조건을 적용했다.
|
||||
- 2026-07-30 — GREEN 확인: 같은 focused test 명령 재실행 결과 `BUILD SUCCESSFUL`.
|
||||
- 2026-07-30 — Gate 확인: `./gradlew --no-daemon test --tests "kr.co.vividnext.sodalive.v2.api.home.following.adapter.in.web.HomeFollowingEndToEndTest"`와 `./gradlew --no-daemon ktlintCheck` 재실행 결과 모두 `BUILD SUCCESSFUL`.
|
||||
- 2026-07-30 — 리뷰 게이트: Oracle reviewer가 Critical/Important blocker 없음으로 승인했다.
|
||||
|
||||
## 8. 후속 종료 판정 — 2026-07-30
|
||||
|
||||
- `HomeFollowingQueryService`가 본인인증 성별 우선 effective gender와 크리에이터 여부를 On Air·스케줄 조회에 전달하는 현재 코드를 정적으로 재확인했다.
|
||||
- `DefaultHomeFollowingQueryRepository`가 On Air와 라이브 스케줄에 `genderRestriction` 및 `isAvailableJoinCreator` 조건을 적용하는 현재 코드를 정적으로 재확인했다.
|
||||
- 이번 후속 판정에서는 컴파일과 테스트를 재실행하지 않았고, 위 `## 7. 수정 후 검증 기록`의 기존 성공 결과를 근거로 삼았다.
|
||||
|
||||
**최종 결론:** `P3-R1` 및 `P3-R-GATE` 수정 검증 완료.
|
||||
|
||||
**남은 항목:** 없음.
|
||||
|
||||
## 9. 3차 정적 리뷰 — 2026-07-30
|
||||
|
||||
- 팔로잉 크리에이터, On Air, 월간 스케줄, 최근 소식 조회 조건과 `P3-R1`의 성별·크리에이터 입장 제한 반영을 현재 working tree에서 다시 추적했다.
|
||||
- 컴파일과 테스트는 사용자 지시에 따라 실행하지 않았다.
|
||||
- 신규 확정 발견 사항 없음. 기존 Phase 3 완료 판정을 유지한다.
|
||||
|
||||
## 10. 4차 정적 리뷰 — 2026-07-30
|
||||
|
||||
- 팔로잉 크리에이터·On Air·월간 스케줄·최근 소식의 활성/role/차단/성인/시간 범위/정렬/limit 조건을 PRD와 다시 대조했다.
|
||||
- `P3-R1`의 effective gender와 크리에이터 입장 제한이 service→port→On Air·라이브 스케줄 query에 동일하게 전달되는지 확인했다.
|
||||
- repository/service 테스트가 KST 월간 경계, 동률 정렬, 원천 target 활성 상태와 라이브 접근 제한을 고정하는지 정적으로 확인했다.
|
||||
- 컴파일과 테스트는 사용자 지시에 따라 실행하지 않았다.
|
||||
- 신규 확정 발견 사항 없음. 기존 Phase 3 완료 판정을 유지한다.
|
||||
203
docs/20260625_메인_홈_팔로잉_탭_API/reviews/phase-4-review.md
Normal file
203
docs/20260625_메인_홈_팔로잉_탭_API/reviews/phase-4-review.md
Normal file
@@ -0,0 +1,203 @@
|
||||
# Phase 4 코드 리뷰 보고서
|
||||
|
||||
## 1. 리뷰 정보
|
||||
|
||||
| 항목 | 내용 |
|
||||
|---|---|
|
||||
| 리뷰 대상 | Phase 4 / Task 4.1~4.5 |
|
||||
| 기준 commit 또는 working tree | `e6f56f24` + 2026-07-30 working tree |
|
||||
| 리뷰 일자 | 2026-07-30 |
|
||||
| 리뷰어 | Codex |
|
||||
| 기준 문서 | `prd.md` Feature F, `plan-task.md` Phase 4 |
|
||||
| 리뷰 상태 | 판정 완료 |
|
||||
|
||||
## 2. 목적과 범위
|
||||
|
||||
- source key, follower fan-out, 언팔로우 비활성화, 랭킹/오디오/무료 커뮤니티 발행 연결을 정적으로 추적했다.
|
||||
- 원 트랜잭션 commit 이후 발행, 발행 실패 격리, 공개 시각과 중복 방지 키를 확인했다.
|
||||
- 사용자의 지시에 따라 컴파일과 테스트는 실행하지 않았다.
|
||||
|
||||
## 3. 검토 근거
|
||||
|
||||
- source key는 타입과 원천 id/기간으로 안정적으로 생성된다.
|
||||
- publish service는 활성 follower에게만 record를 만들고 DB 컬럼 길이에 맞춰 title/body를 제한한다.
|
||||
- 언팔로우는 기존 inbox를 비활성화하며 재팔로우가 이를 복구하지 않는다.
|
||||
- 랭킹, 즉시/예약 오디오, 무료 커뮤니티 생성 경로는 commit 이후 publish service를 호출하고 실패를 원 처리와 격리한다.
|
||||
- 관련 단위·서비스 테스트는 발행 성공, 예약 공개, 유료 미발행, 발행 실패 격리를 포함한다.
|
||||
|
||||
## 4. 발견 사항
|
||||
|
||||
확정 발견 사항 없음.
|
||||
|
||||
## 5. plan·goal 전환
|
||||
|
||||
전환 항목 없음.
|
||||
|
||||
## 6. 리뷰 종료 판정
|
||||
|
||||
| 판정 항목 | 결과 | 근거 |
|
||||
|---|---|---|
|
||||
| 리뷰 범위 전체 확인 | 충족 | 발행 경로와 호출부 정적 추적 |
|
||||
| 후보 항목 판정 완료 | 충족 | 후보 없음 |
|
||||
| 확정 항목 plan 반영 | 해당 없음 | 확정 발견 사항 없음 |
|
||||
| 검증 명령과 결과 기록 | 충족 | 테스트 미실행 사유 기록 |
|
||||
|
||||
**최종 결론:** 확정 발견 사항 없음.
|
||||
|
||||
**남은 항목:** 없음.
|
||||
|
||||
## 7. 2차 리뷰 — 2026-07-30
|
||||
|
||||
### 리뷰 범위
|
||||
|
||||
- active follower 조회, inbox insert의 트랜잭션 경계와 언팔로우·재팔로우 상태 변경을 함께 정적으로 추적했다.
|
||||
- 컴파일과 테스트는 사용자 지시에 따라 실행하지 않았다.
|
||||
|
||||
### REV-P4-001 — follower 판정 후 언팔로우가 완료되면 stale active inbox가 생성될 수 있음
|
||||
|
||||
**심각도:** Medium
|
||||
|
||||
**상태:** 확정
|
||||
|
||||
**근거**
|
||||
|
||||
- `HomeFollowingNewsPublishService.publishToFollowers(...)`는 active follower id 목록을 먼저 읽고 이후 별도 호출로 inbox row를 insert한다.
|
||||
- `HomeFollowingNewsInboxJpaRepository.findActiveFollowerIds(...)`는 `creator_following`을 잠그지 않는 조회다.
|
||||
- `HomeFollowingNewsInboxPersistenceAdapter.insertIgnoreAll(...)`의 실제 insert는 `REQUIRES_NEW` 경로에서 실행될 수 있어 follower 조회와 하나의 직렬화 경계를 공유하지 않는다.
|
||||
- `MemberService.creatorUnFollow(...)`는 언팔로우 시점에 존재하는 active inbox만 비활성화하고, `creatorFollow(...)`의 재팔로우는 기존 관계 row를 다시 active로 바꾼다.
|
||||
|
||||
**정적 재현 절차**
|
||||
|
||||
1. publish 트랜잭션 A가 active follower인 회원을 읽고 insert 전에 멈춘다.
|
||||
2. 트랜잭션 B가 같은 회원의 언팔로우와 현재 inbox 비활성화를 완료한다.
|
||||
3. A가 기존 follower snapshot으로 active inbox를 뒤늦게 insert한다.
|
||||
4. 언팔로우 중에는 조회의 active-following 조건으로 숨겨지지만, 회원이 다시 팔로우하면 해당 언팔로우 이전 이벤트가 노출 가능해진다.
|
||||
|
||||
**영향**
|
||||
|
||||
재팔로우 시 기존 비활성 row를 복구하지 않고 재팔로우 이후의 새 이벤트만 제공한다는 정책을 우회해, 언팔로우 이전 이벤트가 최근 소식으로 나타날 수 있다.
|
||||
|
||||
**권장 조치**
|
||||
|
||||
follower 판정부터 inbox insert까지를 하나의 트랜잭션 경계로 묶고, 해당 `creator_following` row를 팔로우·언팔로우 상태 변경과 같은 순서로 잠근다. publish가 먼저 끝나면 뒤이은 언팔로우가 새 row까지 비활성화하고, 언팔로우가 먼저 끝나면 publish가 row를 생성하지 않는 두 순서를 동시성 테스트로 고정한다.
|
||||
|
||||
**판정 기록**
|
||||
|
||||
- 2026-07-30 — publish/follow/unfollow 호출 흐름과 트랜잭션 경계를 대조해 확정했다. 런타임 테스트는 실행하지 않았다.
|
||||
|
||||
### plan·goal 전환
|
||||
|
||||
- `plan-task.md` Phase 4에 Task 4.6, `P4-R1`, `P4-R-GATE`를 추가했다.
|
||||
- 기존 Task 4.1~4.5의 완료 체크와 검증 기록은 유지했다.
|
||||
|
||||
### 2차 리뷰 종료 판정
|
||||
|
||||
| 판정 항목 | 결과 | 근거 |
|
||||
|---|---|---|
|
||||
| 리뷰 범위 전체 확인 | 충족 | publish/follow/unfollow 트랜잭션 경계 정적 추적 |
|
||||
| 후보 항목 판정 완료 | 충족 | `REV-P4-001` 확정 |
|
||||
| 확정 항목 plan 반영 | 충족 | Task 4.6 / `P4-R1` |
|
||||
| 검증 명령과 결과 기록 | 충족 | 테스트 미실행 사유 기록 |
|
||||
|
||||
**최종 결론:** 수정 goal 필요.
|
||||
|
||||
**남은 항목:** 없음. `P4-R1`과 `P4-R-GATE` 수정 후 검증은 아래 기록에 누적했다.
|
||||
|
||||
## 8. 수정 후 검증 — 2026-07-30
|
||||
|
||||
- RED 확인: `HomeFollowingNewsInboxPersistenceAdapterTest.shouldNotExposeNewsCreatedBeforeUnfollowAfterRefollowWhenPublishIsDelayed` 실행 결과 stale active inbox assertion 실패로 `BUILD FAILED`.
|
||||
- follower 조회와 inbox insert 경계에서 `creator_following` row를 잠그도록 active follower 조회에 `for update`를 적용했고, `creatorFollow(...)`/`creatorUnFollow(...)`도 같은 row를 `PESSIMISTIC_WRITE`로 조회하도록 변경했다.
|
||||
- GREEN 확인: 동시성 단일 테스트 재실행 결과 `BUILD SUCCESSFUL`.
|
||||
- P4-R1 focused test `HomeFollowingNewsInboxPersistenceAdapterTest`, `HomeFollowingNewsPublishServiceTest`, `MemberServiceTest` 실행 결과 `BUILD SUCCESSFUL`.
|
||||
- P4-R-GATE E2E `HomeFollowingEndToEndTest` 실행 결과 `BUILD SUCCESSFUL`.
|
||||
- P4-R-GATE lint `ktlintCheck` 최초 실행은 테스트 import 정렬 위반으로 `BUILD FAILED`; import 정렬 수정 후 재실행한다.
|
||||
- P4-R-GATE lint `ktlintCheck` 재실행 결과 `BUILD SUCCESSFUL`.
|
||||
- 리뷰 게이트에서 Critical/Important/Minor blocker 없음으로 승인됐다.
|
||||
|
||||
**수정 후 결론:** `REV-P4-001` 보완 완료.
|
||||
|
||||
## 9. 3차 정적 리뷰 — 2026-07-30
|
||||
|
||||
### 리뷰 범위
|
||||
|
||||
- 공개 팔로우·언팔로우 controller 경로, `MemberService` 상태 전이, inbox 비활성화와 재팔로우 조회 결과를 함께 정적으로 추적했다.
|
||||
- 컴파일과 테스트는 사용자 지시에 따라 실행하지 않았다.
|
||||
|
||||
### REV-P4-002 — 통합 팔로우 API의 `isActive=false` 경로가 기존 inbox를 비활성화하지 않음
|
||||
|
||||
**심각도:** High
|
||||
|
||||
**상태:** 확정
|
||||
|
||||
**관련 요구사항:** PRD Feature F의 언팔로우 시 기존 inbox 비활성화, 재팔로우 시 기존 inbox 미복구
|
||||
|
||||
**소유 Task:** Task 4.7 / `P4-R2`
|
||||
|
||||
**관찰 내용**
|
||||
|
||||
`POST /member/creator/follow`는 request의 `isActive=false`를 `MemberService.creatorFollow(...)`에 전달해 팔로우·언팔로우를 함께 처리한다. 관계 row가 없으면 전달된 `isActive`를 반영하지 않은 기본 active 관계를 만들고, 기존 관계 row가 있으면 `creatorFollowing.isActive=false`만 반영한 채 inbox 비활성화를 호출하지 않는다. 이후 같은 API로 다시 활성화하면 기존 inbox row가 계속 active이므로 active-following 조회 조건을 다시 만족해 언팔로우 이전 소식이 노출될 수 있다.
|
||||
|
||||
**근거**
|
||||
|
||||
- `MemberController.creatorFollow(...)`는 nullable `request.isActive`를 기본값과 함께 service에 전달한다.
|
||||
- `CreatorFollowRequest`는 `isActive`를 공개 request 필드로 정의한다.
|
||||
- `MemberService.creatorFollow(...)`의 신규 관계 분기는 기본값이 `isActive=true`인 `CreatorFollowing()`을 저장하고, 기존 관계 분기는 `isNotify`와 `isActive`만 변경한다.
|
||||
- `MemberService.creatorUnFollow(...)`만 `homeFollowingNewsInboxPort.deactivateByMemberIdAndCreatorId(...)`를 호출한다.
|
||||
- `MemberServiceTest`는 전용 `creatorUnFollow(...)` 경로만 검증하며 `creatorFollow(..., isActive=false)` 경로는 검증하지 않는다.
|
||||
|
||||
**정적 재현 절차**
|
||||
|
||||
1. 관계가 없는 회원이 `POST /member/creator/follow`에 `isActive=false`를 보내면 기본 active 관계가 새로 생성된다.
|
||||
2. 별도로 active 팔로우와 active inbox row가 있는 회원이 같은 요청을 보내면 관계 row만 inactive가 되고 inbox는 active로 남는다.
|
||||
3. 같은 API에 `isActive=true`를 보내 재팔로우한다.
|
||||
4. 기존 active inbox가 다시 active-following 조건을 만족해 최근 소식 조회 후보가 된다.
|
||||
|
||||
**영향**
|
||||
|
||||
관계가 없는 회원은 언팔로우 요청으로 오히려 active follower가 될 수 있다. 기존 follower는 언팔로우 경로에 따라 과거 소식 보존 상태가 달라지고, 통합 언팔로우 경로에서 재팔로우 이후 과거 소식 미복구 계약을 위반한다.
|
||||
|
||||
**권장 조치**
|
||||
|
||||
관계가 없고 `isActive=false`면 전용 언팔로우 경로와 동일하게 새 active 관계를 만들지 않는다. 기존 관계를 `isActive=false`로 반영하는 같은 트랜잭션에서는 inbox를 비활성화한다. `isActive=true`인 알림 변경·재팔로우는 기존 비활성 inbox를 복구하지 않도록 유지하고, 두 경우를 `MemberServiceTest`로 고정한다.
|
||||
|
||||
**판정 기록**
|
||||
|
||||
- 2026-07-30 — request/controller/service의 공개 `isActive=false` 흐름과 PRD 재팔로우 정책을 정적으로 대조해 확정했다. 런타임 테스트는 실행하지 않았다.
|
||||
|
||||
### plan·goal 전환
|
||||
|
||||
- `plan-task.md` Phase 4에 Task 4.7, `P4-R2`, `P4-R2-GATE`를 추가했다.
|
||||
- 기존 Task 4.1~4.6의 완료 체크와 검증 기록은 유지했다.
|
||||
|
||||
### 3차 리뷰 종료 판정
|
||||
|
||||
| 판정 항목 | 결과 | 근거 |
|
||||
|---|---|---|
|
||||
| 리뷰 범위 전체 확인 | 충족 | 두 공개 상태 변경 경로와 조회 조건 정적 추적 |
|
||||
| 후보 항목 판정 완료 | 충족 | `REV-P4-002` 확정 |
|
||||
| 확정 항목 plan 반영 | 충족 | Task 4.7 / `P4-R2` |
|
||||
| 검증 명령과 결과 기록 | 충족 | 테스트 미실행 사유와 정적 근거 기록 |
|
||||
|
||||
**최종 결론:** 수정 goal 필요.
|
||||
|
||||
**남은 항목:** 없음. `P4-R2`와 `P4-R2-GATE` 수정 후 검증은 아래 기록에 누적했다.
|
||||
|
||||
## 10. 3차 수정 후 검증 — 2026-07-30
|
||||
|
||||
- RED 확인: `./gradlew --no-daemon test --tests "kr.co.vividnext.sodalive.member.MemberServiceTest"` 실행 결과 `creatorFollow(..., isActive=false)` 신규 회귀 2건이 assertion 실패로 `BUILD FAILED`.
|
||||
- 관계가 없고 `isActive=false`인 통합 팔로우 요청은 새 active 관계를 만들지 않고, 기존 관계를 inactive로 바꾸는 경로에서는 `homeFollowingNewsInboxPort.deactivateByMemberIdAndCreatorId(...)`를 호출하도록 `MemberService.creatorFollow(...)`만 최소 수정했다.
|
||||
- GREEN 확인: 같은 `MemberServiceTest` 명령 재실행 결과 `BUILD SUCCESSFUL`.
|
||||
- P4-R2-GATE 확인: `./gradlew --no-daemon test --tests "kr.co.vividnext.sodalive.v2.home.following.adapter.out.persistence.HomeFollowingNewsInboxPersistenceAdapterTest" --tests "kr.co.vividnext.sodalive.v2.home.following.application.HomeFollowingNewsPublishServiceTest"` 실행 결과 `BUILD SUCCESSFUL`.
|
||||
- P4-R2-GATE lint `./gradlew --no-daemon ktlintCheck` 최초 실행은 신규 테스트 줄 길이 위반으로 `BUILD FAILED`; 포맷 정리 후 `MemberServiceTest`와 `ktlintCheck` 재실행 결과 모두 `BUILD SUCCESSFUL`.
|
||||
- 추가 전체 회귀 확인: `./gradlew --no-daemon test` 실행 결과 `BUILD SUCCESSFUL`.
|
||||
- 리뷰 게이트에서 Critical/Important/Minor blocker 없음으로 승인됐다.
|
||||
|
||||
**수정 후 결론:** `REV-P4-002` 보완 완료.
|
||||
|
||||
## 11. 4차 정적 리뷰 — 2026-07-30
|
||||
|
||||
- source key, after-commit 발행, follower fan-out, 랭킹·오디오·무료 커뮤니티 호출부와 발행 실패 격리를 다시 추적했다.
|
||||
- `P4-R1`의 `creator_following` 잠금 순서와 `P4-R2`의 통합 `isActive=false` 경로가 전용 언팔로우와 같은 inbox 최종 상태를 만드는지 확인했다.
|
||||
- persistence·publish·`MemberService` 테스트가 중복 방지, 동시 publish/unfollow, 신규 inactive 요청과 재팔로우 미복구를 고정하는지 정적으로 확인했다.
|
||||
- 컴파일과 테스트는 사용자 지시에 따라 실행하지 않았다.
|
||||
- 신규 확정 발견 사항 없음. `REV-P4-001`, `REV-P4-002` 수정 완료와 기존 Phase 4 완료 판정을 유지한다.
|
||||
58
docs/20260625_메인_홈_팔로잉_탭_API/reviews/phase-5-review.md
Normal file
58
docs/20260625_메인_홈_팔로잉_탭_API/reviews/phase-5-review.md
Normal file
@@ -0,0 +1,58 @@
|
||||
# Phase 5 코드 리뷰 보고서
|
||||
|
||||
## 1. 리뷰 정보
|
||||
|
||||
| 항목 | 내용 |
|
||||
|---|---|
|
||||
| 리뷰 대상 | Phase 5 / Task 5.1~5.2 |
|
||||
| 기준 commit 또는 working tree | `e6f56f24` + 2026-07-30 working tree |
|
||||
| 리뷰 일자 | 2026-07-30 |
|
||||
| 리뷰어 | Codex |
|
||||
| 기준 문서 | `prd.md` Feature A·D, `plan-task.md` Phase 5 |
|
||||
| 리뷰 상태 | 판정 완료 |
|
||||
|
||||
## 2. 목적과 범위
|
||||
|
||||
- facade의 비회원 단락, 도메인 조회와 기존 최근 대화 10개 조립, E2E API 표면을 정적으로 대조했다.
|
||||
- `HomeFollowingFacade.kt`, facade/controller/E2E 테스트를 검토했다.
|
||||
- 사용자의 지시에 따라 컴파일과 테스트는 실행하지 않았다.
|
||||
|
||||
## 3. 검토 근거
|
||||
|
||||
- 비회원은 query/chat service 호출 없이 로그인 필요 응답을 받는다.
|
||||
- 로그인 회원은 `ChatRoomListService.getRooms(member, "ALL", null, 10)` 결과를 도메인 조회 결과에 조립한다.
|
||||
- E2E 테스트는 비회원 빈 섹션과 로그인 회원의 다섯 섹션, 최근 소식 JSON surface를 검증한다.
|
||||
|
||||
## 4. 발견 사항
|
||||
|
||||
확정 발견 사항 없음.
|
||||
|
||||
## 5. plan·goal 전환
|
||||
|
||||
전환 항목 없음.
|
||||
|
||||
## 6. 리뷰 종료 판정
|
||||
|
||||
| 판정 항목 | 결과 | 근거 |
|
||||
|---|---|---|
|
||||
| 리뷰 범위 전체 확인 | 충족 | facade/controller/E2E 정적 대조 |
|
||||
| 후보 항목 판정 완료 | 충족 | 후보 없음 |
|
||||
| 확정 항목 plan 반영 | 해당 없음 | 확정 발견 사항 없음 |
|
||||
| 검증 명령과 결과 기록 | 충족 | 테스트 미실행 사유 기록 |
|
||||
|
||||
**최종 결론:** 확정 발견 사항 없음.
|
||||
|
||||
**남은 항목:** 없음.
|
||||
|
||||
## 7. 3차 정적 리뷰 — 2026-07-30
|
||||
|
||||
- facade의 로그인 분기, 최근 대화 재사용, E2E 조립 범위를 현재 working tree 기준으로 다시 대조했다.
|
||||
- 컴파일과 테스트는 사용자 지시에 따라 실행하지 않았다.
|
||||
- 신규 확정 발견 사항 없음. 기존 Phase 5 완료 판정을 유지한다.
|
||||
|
||||
## 8. 4차 정적 리뷰 — 2026-07-30
|
||||
|
||||
- 비회원 조기 반환, 로그인 회원의 도메인 조회와 `ChatRoomListService.getRooms(..., limit = 10)` 조립을 다시 대조했다.
|
||||
- controller/facade/E2E 테스트가 비회원의 조회 생략과 로그인 회원의 다섯 섹션 공개 응답을 고정하는지 정적으로 확인했다.
|
||||
- 컴파일과 테스트는 사용자 지시에 따라 실행하지 않았다.
|
||||
- 신규 확정 발견 사항 없음. 기존 Phase 5 완료 판정을 유지한다.
|
||||
60
docs/20260625_메인_홈_팔로잉_탭_API/reviews/phase-5.5-review.md
Normal file
60
docs/20260625_메인_홈_팔로잉_탭_API/reviews/phase-5.5-review.md
Normal file
@@ -0,0 +1,60 @@
|
||||
# Phase 5.5 코드 리뷰 보고서
|
||||
|
||||
## 1. 리뷰 정보
|
||||
|
||||
| 항목 | 내용 |
|
||||
|---|---|
|
||||
| 리뷰 대상 | Phase 5.5 / Task 5.5.1~5.5.5 |
|
||||
| 기준 commit 또는 working tree | `e6f56f24` + 2026-07-30 working tree |
|
||||
| 리뷰 일자 | 2026-07-30 |
|
||||
| 리뷰어 | Codex |
|
||||
| 기준 문서 | `prd.md` Feature F·G, `plan-task.md` Phase 5.5 |
|
||||
| 리뷰 상태 | 판정 완료 |
|
||||
|
||||
## 2. 목적과 범위
|
||||
|
||||
- 타입별 nullable nested DTO, 원천 target enrichment, 무료 커뮤니티 전용 발행과 E2E JSON 계약을 정적으로 대조했다.
|
||||
- DTO/domain/repository/커뮤니티 호출부와 대응 테스트를 포함했다.
|
||||
- 사용자의 지시에 따라 컴파일과 테스트는 실행하지 않았다.
|
||||
|
||||
## 3. 검토 근거
|
||||
|
||||
- 최근 소식 최상위는 공통 필드와 다섯 nested 필드만 노출하고 flat 이동/표시 필드를 제거했다.
|
||||
- repository는 랭킹, 활성 오디오, 활성 무료 커뮤니티 원천을 타입별 payload로 조립한다.
|
||||
- 커뮤니티 좋아요와 최상위 댓글은 active row만 집계한다.
|
||||
- 유료 커뮤니티는 발행과 조회 양쪽에서 제외된다.
|
||||
- DTO 및 E2E 테스트는 nested payload의 상호 배타적 null 계약과 제거된 flat 필드를 검증한다.
|
||||
|
||||
## 4. 발견 사항
|
||||
|
||||
확정 발견 사항 없음.
|
||||
|
||||
## 5. plan·goal 전환
|
||||
|
||||
전환 항목 없음.
|
||||
|
||||
## 6. 리뷰 종료 판정
|
||||
|
||||
| 판정 항목 | 결과 | 근거 |
|
||||
|---|---|---|
|
||||
| 리뷰 범위 전체 확인 | 충족 | DTO/repository/call site/test 정적 대조 |
|
||||
| 후보 항목 판정 완료 | 충족 | 후보 없음 |
|
||||
| 확정 항목 plan 반영 | 해당 없음 | 확정 발견 사항 없음 |
|
||||
| 검증 명령과 결과 기록 | 충족 | 테스트 미실행 사유 기록 |
|
||||
|
||||
**최종 결론:** 확정 발견 사항 없음.
|
||||
|
||||
**남은 항목:** 없음.
|
||||
|
||||
## 7. 3차 정적 리뷰 — 2026-07-30
|
||||
|
||||
- 타입별 nullable nested DTO, 원천 target enrichment, 무료 커뮤니티 발행 제한과 E2E JSON 계약을 현재 working tree 기준으로 다시 대조했다.
|
||||
- 컴파일과 테스트는 사용자 지시에 따라 실행하지 않았다.
|
||||
- 신규 확정 발견 사항 없음. 기존 Phase 5.5 완료 판정을 유지한다.
|
||||
|
||||
## 8. 4차 정적 리뷰 — 2026-07-30
|
||||
|
||||
- 최근 소식 공통 필드와 타입별 상호 배타적 nested DTO, CDN/UTC 변환, 원천 오디오·무료 커뮤니티 enrichment를 다시 대조했다.
|
||||
- 유료 커뮤니티와 비활성·성인 원천 target 제외, active like·top-level comment 집계가 repository와 E2E 테스트에 연결되는지 확인했다.
|
||||
- 컴파일과 테스트는 사용자 지시에 따라 실행하지 않았다.
|
||||
- 신규 확정 발견 사항 없음. 기존 Phase 5.5 완료 판정을 유지한다.
|
||||
185
docs/20260625_메인_홈_팔로잉_탭_API/reviews/phase-6-review.md
Normal file
185
docs/20260625_메인_홈_팔로잉_탭_API/reviews/phase-6-review.md
Normal file
@@ -0,0 +1,185 @@
|
||||
# Phase 6 코드 리뷰 보고서
|
||||
|
||||
## 1. 리뷰 정보
|
||||
|
||||
| 항목 | 내용 |
|
||||
|---|---|
|
||||
| 리뷰 대상 | Phase 6 / Task 6.1~6.2 |
|
||||
| 기준 commit 또는 working tree | `e6f56f24` + 2026-07-30 working tree |
|
||||
| 리뷰 일자 | 2026-07-30 |
|
||||
| 리뷰어 | Codex |
|
||||
| 기준 문서 | `prd.md`, `plan-task.md`, DDL, 기존 검증 기록 |
|
||||
| 리뷰 상태 | 판정 완료 |
|
||||
|
||||
## 2. 목적과 범위
|
||||
|
||||
- PRD, 구현 계획, DDL, 실제 공개 DTO의 현재 계약과 Phase별 기존 검증 기록을 정적으로 대조했다.
|
||||
- 현재 compile/test 통과 상태는 사용자 설명과 `plan-task.md` 기존 기록을 근거로 삼았으며 직접 재실행하지 않았다.
|
||||
|
||||
## 3. 검토 근거
|
||||
|
||||
- PRD와 계획의 endpoint, 섹션 limit, nested 최근 소식 필드, 무료 커뮤니티 정책, DDL 유니크 키가 현재 코드와 일치한다.
|
||||
- `plan-task.md`에는 Phase 1~7의 focused/회귀/lint 성공 기록과 실패 후 보완 이력이 누적되어 있다.
|
||||
- 이번 리뷰에서 확정된 후속 결함은 기존 완료 상태를 되돌리지 않고 소유 Phase의 신규 Task로 추가했다.
|
||||
- `./gradlew tasks --all` 최초 실행은 sandbox의 사용자 Gradle cache 접근 제한으로 실패했고, 승인된 권한으로 재실행한 결과
|
||||
`BUILD SUCCESSFUL`이었다. task 목록만 확인했으며 컴파일과 테스트는 실행하지 않았다.
|
||||
|
||||
## 4. 발견 사항
|
||||
|
||||
확정 발견 사항 없음.
|
||||
|
||||
## 5. plan·goal 전환
|
||||
|
||||
전환 항목 없음. Phase 3과 Phase 7의 코드 발견 사항은 각 Phase 보고서와 Task에 귀속했다.
|
||||
|
||||
## 6. 리뷰 종료 판정
|
||||
|
||||
| 판정 항목 | 결과 | 근거 |
|
||||
|---|---|---|
|
||||
| 리뷰 범위 전체 확인 | 충족 | PRD/plan/DDL/code/기존 기록 정적 대조 |
|
||||
| 후보 항목 판정 완료 | 충족 | Phase 6 자체 후보 없음 |
|
||||
| 확정 항목 plan 반영 | 해당 없음 | 다른 Phase 소유 항목은 해당 Phase에 반영 |
|
||||
| 검증 명령과 결과 기록 | 충족 | 테스트 미실행 사유와 기존 기록 분리 |
|
||||
|
||||
**최종 결론:** 확정 발견 사항 없음.
|
||||
|
||||
**남은 항목:** 없음. Phase 3·7 회귀 Goal과 Phase 6 후속 문서 검증 기록은 이후 섹션에 누적했다.
|
||||
|
||||
## 7. 2차 리뷰 — 2026-07-30
|
||||
|
||||
### 기존 후속 항목 확인
|
||||
|
||||
- Phase 3의 `P3-R1`과 Phase 7의 `P7-R1`·`P7-R2` 구현 및 기존 Gate 성공 기록이 누적된 것을 확인했다.
|
||||
- 이번 확인에서는 컴파일과 테스트를 재실행하지 않았다.
|
||||
|
||||
### REV-P6-001 — PRD의 `newsId` 형식이 현재 공개 동작과 불일치
|
||||
|
||||
**심각도:** Low
|
||||
|
||||
**상태:** 확정
|
||||
|
||||
**근거**
|
||||
|
||||
- PRD 공통 식별자 설명은 `scheduleId`와 `newsId` 모두 `{TYPE}:{targetId}` 형식을 기본안으로 적고 있다.
|
||||
- `DefaultHomeFollowingQueryRepository`는 `home_following_news_inbox.id`를 문자열로 변환해 `newsId`로 반환한다.
|
||||
- repository test와 E2E도 inbox PK의 10진 문자열을 계약으로 검증한다.
|
||||
- 최근 소식 정렬과 `CONTENT_RANKING` 동률 해소도 inbox `id`를 사용하므로, 현재 동작을 유지한 문서 수정이 가장 작은 정합성 보완이다.
|
||||
|
||||
**영향**
|
||||
|
||||
클라이언트 또는 후속 구현자가 PRD만 보면 `newsId`를 타입·target 기반 식별자로 해석할 수 있어 실제 응답 파싱, 정렬 의미, 동일 target의 여러 소식 식별을 잘못 구현할 수 있다.
|
||||
|
||||
**권장 조치**
|
||||
|
||||
runtime 코드와 공개 응답 값은 변경하지 않는다. PRD에서 `scheduleId`는 `{TYPE}:{targetId}`, `newsId`는 `home_following_news_inbox.id`의 10진 문자열이며 같은 노출 시각의 정렬·동률 해소에 쓰인다고 분리해 명시한다.
|
||||
|
||||
**판정 기록**
|
||||
|
||||
- 2026-07-30 — PRD, repository, repository test, E2E를 정적으로 대조해 확정했다. Gradle 명령은 실행하지 않았다.
|
||||
|
||||
### plan·goal 전환
|
||||
|
||||
- `plan-task.md` Phase 6에 문서 전용 Task 6.3, `P6-R1`, `P6-R-GATE`를 추가했다.
|
||||
- 기존 Task 6.1~6.2의 완료 체크와 검증 기록은 유지했다.
|
||||
|
||||
### 2차 리뷰 종료 판정
|
||||
|
||||
| 판정 항목 | 결과 | 근거 |
|
||||
|---|---|---|
|
||||
| 리뷰 범위 전체 확인 | 충족 | PRD/repository/test/E2E 식별자 계약 정적 대조 |
|
||||
| 후보 항목 판정 완료 | 충족 | `REV-P6-001` 확정 |
|
||||
| 확정 항목 plan 반영 | 충족 | Task 6.3 / `P6-R1` |
|
||||
| 검증 명령과 결과 기록 | 충족 | Gradle 미실행 사유 기록 |
|
||||
|
||||
**최종 결론:** 문서 수정 goal 필요.
|
||||
|
||||
**남은 항목:** 없음. `P6-R1`과 `P6-R-GATE` 수정 후 검증은 아래 기록에 누적했다.
|
||||
|
||||
## 8. 수정 후 검증 — 2026-07-30
|
||||
|
||||
- PRD에서 `scheduleId`와 `newsId` 식별자 설명을 분리했다.
|
||||
- `scheduleId`는 `{TYPE}:{targetId}` 형식, `newsId`는 `home_following_news_inbox.id`의 10진 문자열이며 `visibleFromAtUtc desc`, `newsId desc` 정렬과 `CONTENT_RANKING` 동률 해소에 사용한다고 명시했다.
|
||||
- runtime 코드, 테스트, DDL, 공개 응답 필드는 변경하지 않았다.
|
||||
- 정적 검색 `rg -n "newsId|scheduleId|home_following_news_inbox\.id" ...` 실행 결과 PRD와 repository/test/E2E의 식별자 계약이 일치함을 확인했다.
|
||||
- `git diff --check` 실행 결과 오류 없음.
|
||||
- `./gradlew tasks --all` 실행 결과 `BUILD SUCCESSFUL`.
|
||||
- 리뷰 게이트에서 Critical/Important/Minor blocker 없음으로 승인됐다.
|
||||
|
||||
**수정 후 결론:** `REV-P6-001` 문서 보완 완료.
|
||||
|
||||
## 9. 3차 정적 리뷰 — 2026-07-30
|
||||
|
||||
### 리뷰 범위
|
||||
|
||||
- PRD의 최근 소식 최신 배치 정책, Phase 7 회귀 Task, repository 조건과 회귀 테스트를 정적으로 대조했다.
|
||||
- 컴파일과 테스트는 사용자 지시에 따라 실행하지 않았다.
|
||||
|
||||
### REV-P6-002 — PRD의 최신 크리에이터 랭킹 배치 판정 기준이 현재 구현과 불일치
|
||||
|
||||
**심각도:** Low
|
||||
|
||||
**상태:** 확정
|
||||
|
||||
**관련 요구사항:** 최신 공개 `WEEKLY` 배치만 조회하고 빈 최신 배치에서 과거 순위를 보충하지 않는 정책
|
||||
|
||||
**소유 Task:** Task 6.4 / `P6-R2`
|
||||
|
||||
**관찰 내용**
|
||||
|
||||
PRD Feature F와 기술 제약은 최신 `CREATOR_RANKING` 배치를 `creator_ranking_snapshot`의 최대 공개 시각으로 판정한다고 기술한다. 그러나 `REV-P7-001` 보완 이후 현재 repository는 적용 가능한 `creator_ranking_snapshot_job`의 최신 `WEEKLY`, `DONE`, `visibleFromAtUtc <= nowUtc` 시각을 우선 사용하고, 해당 job이 전혀 없을 때만 snapshot으로 fallback한다.
|
||||
|
||||
**근거**
|
||||
|
||||
- PRD의 최신 배치 설명 두 곳은 `creator_ranking_snapshot`만 판정 원천으로 명시한다.
|
||||
- `plan-task.md` Task 7.2는 빈 최신 완료 배치를 식별하기 위해 job 우선·legacy snapshot 제한 fallback을 확정했다.
|
||||
- `DefaultHomeFollowingQueryRepository.latestVisibleCreatorRankingBatchCondition(...)`은 최신 DONE job 시각을 우선하고 `hasRankingJob.not()`일 때만 snapshot 시각을 사용한다.
|
||||
- `DefaultHomeFollowingQueryRepositoryTest.shouldExcludeCreatorRankingNewsWhenLatestDoneBatchIsEmpty`는 최신 DONE job의 snapshot row가 0건이어도 과거 소식을 반환하지 않는 현재 계약을 검증한다.
|
||||
|
||||
**영향**
|
||||
|
||||
후속 구현자가 PRD만 따르면 최신 빈 완료 배치에서 과거 snapshot을 다시 최신으로 판정해 이미 수정한 회귀를 재도입할 수 있다.
|
||||
|
||||
**권장 조치**
|
||||
|
||||
runtime은 변경하지 않는다. PRD 두 곳을 최신 공개 DONE job 우선으로 동기화하고, 적용 가능한 DONE job이 전혀 없는 legacy/backfill 데이터에서만 snapshot fallback을 허용하며 빈 DONE 배치는 과거 snapshot으로 보충하지 않는다고 명시한다.
|
||||
|
||||
**판정 기록**
|
||||
|
||||
- 2026-07-30 — PRD, Task 7.2, repository, 빈 최신 배치 회귀 테스트를 정적으로 대조해 확정했다. Gradle 명령은 실행하지 않았다.
|
||||
|
||||
### plan·goal 전환
|
||||
|
||||
- `plan-task.md` Phase 6에 문서 전용 Task 6.4, `P6-R2`, `P6-R2-GATE`를 추가했다.
|
||||
- 기존 Task 6.1~6.3의 완료 체크와 검증 기록은 유지했다.
|
||||
|
||||
### 3차 리뷰 종료 판정
|
||||
|
||||
| 판정 항목 | 결과 | 근거 |
|
||||
|---|---|---|
|
||||
| 리뷰 범위 전체 확인 | 충족 | PRD/Task/repository/test 최신 배치 기준 정적 대조 |
|
||||
| 후보 항목 판정 완료 | 충족 | `REV-P6-002` 확정 |
|
||||
| 확정 항목 plan 반영 | 충족 | Task 6.4 / `P6-R2` |
|
||||
| 검증 명령과 결과 기록 | 충족 | Gradle 미실행 사유와 정적 근거 기록 |
|
||||
|
||||
**최종 결론:** 문서 수정 goal 필요.
|
||||
|
||||
**남은 항목:** 없음. `P6-R2`와 `P6-R2-GATE` 수정 후 검증은 아래 기록에 누적했다.
|
||||
|
||||
## 10. 3차 수정 후 검증 — 2026-07-30
|
||||
|
||||
- PRD Feature F와 최근 소식 Inbox 기술 제약의 `CREATOR_RANKING` 최신 공개 배치 설명을 현재 구현의 `creator_ranking_snapshot_job` 최신 `WEEKLY`, `DONE`, `visibleFromAtUtc <= nowUtc` 우선 기준과 일치하도록 갱신했다.
|
||||
- 적용 가능한 완료 job이 전혀 없는 legacy/backfill 데이터에서만 `creator_ranking_snapshot` fallback을 허용하고, 최신 완료 job의 결과가 0건이면 과거 snapshot으로 보충하지 않는다고 명시했다.
|
||||
- P6-R2-GATE 정적 검색 `rg -n "creator_ranking_snapshot_job|creator_ranking_snapshot|최신 공개 배치|WEEKLY|DONE|legacy|fallback" ...` 실행 결과 PRD, Task 7.2, repository, 빈 최신 배치 회귀 테스트의 기준을 대조했다. 검색 결과의 과거 Task 7.1 snapshot 설명은 Task 7.2에서 superseded된 완료 기록으로 확인했다.
|
||||
- P6-R2-GATE `git diff --check` 실행 결과 오류 없음.
|
||||
- P6-R2-GATE `./gradlew tasks --all` 실행 결과 `BUILD SUCCESSFUL`.
|
||||
- 추가 전체 회귀 확인: `./gradlew --no-daemon test` 실행 결과 `BUILD SUCCESSFUL`.
|
||||
- 리뷰 게이트에서 Critical/Important/Minor blocker 없음으로 승인됐다.
|
||||
|
||||
**수정 후 결론:** `REV-P6-002` 문서 보완 완료.
|
||||
|
||||
## 11. 4차 정적 리뷰 — 2026-07-30
|
||||
|
||||
- PRD·plan·DDL·공개 DTO와 현재 repository의 endpoint, limit, 식별자, latest ranking batch, nested 최근 소식 계약을 다시 대조했다.
|
||||
- `newsId`의 inbox PK 문자열 계약과 최신 `WEEKLY`, `DONE` job 우선·legacy snapshot 제한 fallback 설명이 현재 코드·테스트와 일치함을 확인했다.
|
||||
- 컴파일과 테스트는 사용자 지시에 따라 실행하지 않았다.
|
||||
- 신규 확정 발견 사항 없음. `REV-P6-001`, `REV-P6-002` 문서 보완 완료와 기존 Phase 6 완료 판정을 유지한다.
|
||||
162
docs/20260625_메인_홈_팔로잉_탭_API/reviews/phase-7-review.md
Normal file
162
docs/20260625_메인_홈_팔로잉_탭_API/reviews/phase-7-review.md
Normal file
@@ -0,0 +1,162 @@
|
||||
# Phase 7 코드 리뷰 보고서
|
||||
|
||||
## 1. 리뷰 정보
|
||||
|
||||
| 항목 | 내용 |
|
||||
|---|---|
|
||||
| 리뷰 대상 | Phase 7 / Task 7.1 |
|
||||
| 기준 commit 또는 working tree | `e6f56f24` + 2026-07-30 working tree |
|
||||
| 리뷰 일자 | 2026-07-30 |
|
||||
| 리뷰어 | Codex |
|
||||
| 기준 문서 | `prd.md` Feature F·Decision Log, `plan-task.md` Phase 7 |
|
||||
| 리뷰 상태 | 판정 완료 |
|
||||
|
||||
## 2. 목적과 범위
|
||||
|
||||
- 최신 공개 크리에이터 랭킹 배치 필터와 콘텐츠별 최신 랭킹 중복 제거가 모든 확정 edge case에서 limit 전에 적용되는지 검토했다.
|
||||
- 현재 working tree의 repository, repository test, E2E fixture 변경을 포함했다.
|
||||
- 사용자의 지시에 따라 컴파일과 테스트는 실행하지 않았다. 기존 Phase 7 성공 기록은 참고 증거로만 사용했다.
|
||||
|
||||
## 3. 발견 사항 요약
|
||||
|
||||
| ID | 심각도 | 상태 | 제목 | 소유 Task | 후속 goal |
|
||||
|---|---|---|---|---|---|
|
||||
| `REV-P7-001` | High | 확정 | 최신 완료 배치가 0건이면 과거 크리에이터 랭킹이 다시 최신으로 판정된다 | Task 7.2 | `P7-R1` |
|
||||
| `REV-P7-002` | High | 확정 | 노출 불가능한 최신 콘텐츠 랭킹 row가 이전의 노출 가능한 row를 가린다 | Task 7.3 | `P7-R2` |
|
||||
|
||||
## 4. 발견 사항 상세
|
||||
|
||||
### REV-P7-001 — 최신 완료 배치가 0건이면 과거 크리에이터 랭킹이 다시 최신으로 판정된다
|
||||
|
||||
- **심각도:** High
|
||||
- **상태:** 확정
|
||||
- **관련 요구사항:** 최신 공개 배치만 노출, 최신 배치에 없는 크리에이터의 과거 순위 미보충
|
||||
- **소유 Task:** 신규 Task 7.2 / `P7-R1`
|
||||
|
||||
**관찰 내용**
|
||||
|
||||
최신 배치 조건은 `creator_ranking_snapshot.visibleFromAtUtc.max()`만 조회한다. 최신 집계가 성공했지만 점수 조건을 통과한
|
||||
snapshot이 0건이면 해당 배치를 나타내는 snapshot row가 없으므로 max 값은 직전 배치 시각으로 남는다.
|
||||
|
||||
랭킹 job은 결과가 0건이어도 `creator_ranking_snapshot_job`에 `WEEKLY`, `DONE`, `visibleFromAtUtc`를 남기므로 완료 배치를
|
||||
식별할 근거가 이미 있지만 현재 팔로잉 조회는 이를 사용하지 않는다.
|
||||
|
||||
**근거**
|
||||
|
||||
- 코드: `DefaultHomeFollowingQueryRepository.kt:428-451`
|
||||
- 코드: `DefaultCreatorRankingSnapshotRepository.kt:51-65`
|
||||
- 코드: `CreatorRankingSnapshotJobService.kt:52-69`
|
||||
- 테스트 공백: `shouldFindOnlyLatestVisibleCreatorRankingBatchInRecentNews`는 최신 배치에 snapshot이 1건 이상인 경우만 검증한다.
|
||||
|
||||
**정적 재현 절차**
|
||||
|
||||
1. 직전 주간 snapshot과 같은 시각의 `CREATOR_RANKING` inbox를 둔다.
|
||||
2. 더 최신 주간 job을 `DONE`으로 완료하지만 집계 결과 snapshot은 0건으로 둔다.
|
||||
3. 최신 job 공개 시각 이후 API를 조회한다.
|
||||
4. 요구 결과는 크리에이터 랭킹 0건이지만, 현재 subquery max는 직전 snapshot 시각이므로 과거 inbox가 노출된다.
|
||||
|
||||
**영향**
|
||||
|
||||
활동 부족 등으로 최신 배치 결과가 비어 있는 주에 명시적으로 금지된 과거 랭킹 보충이 발생한다.
|
||||
|
||||
**권장 조치**
|
||||
|
||||
결과 row와 독립적으로 남는 최신 공개 `DONE` job을 배치 식별 기준으로 사용하고, 최신 빈 배치에서는 과거 snapshot으로
|
||||
fallback하지 않는 회귀 테스트를 추가한다.
|
||||
|
||||
**판정 기록**
|
||||
|
||||
- 2026-07-30 — snapshot 교체와 job 완료 흐름을 정적으로 추적해 확정했다.
|
||||
|
||||
### REV-P7-002 — 노출 불가능한 최신 콘텐츠 랭킹 row가 이전의 노출 가능한 row를 가린다
|
||||
|
||||
- **심각도:** High
|
||||
- **상태:** 확정
|
||||
- **관련 요구사항:** 같은 콘텐츠의 노출 가능한 row 중 최신 1건 조회
|
||||
- **소유 Task:** 신규 Task 7.3 / `P7-R2`
|
||||
|
||||
**관찰 내용**
|
||||
|
||||
외부 row는 `CONTENT_RANKING`의 `rank is not null`, inbox 성인 조건 등으로 노출 가능 여부를 확인한다. 그러나
|
||||
`latestContentRankingNewsCondition(...)`의 newer-row subquery는 member/type/target/active/visible/order만 확인하고
|
||||
`rank is not null`과 회원별 성인 조건을 확인하지 않는다.
|
||||
|
||||
따라서 더 최신 row의 `rank`가 null이면 최신 row는 외부 조건에서 제외되면서도 이전 정상 row를 subquery에서 가려 결과가 0건이 된다.
|
||||
|
||||
**근거**
|
||||
|
||||
- 코드: `DefaultHomeFollowingQueryRepository.kt:154-166`
|
||||
- 코드: `DefaultHomeFollowingQueryRepository.kt:428-438`
|
||||
- 코드: `DefaultHomeFollowingQueryRepository.kt:454-471`
|
||||
- 테스트 공백: 현재 콘텐츠 중복 테스트는 모든 newer row가 노출 가능한 경우만 검증한다.
|
||||
|
||||
**정적 재현 절차**
|
||||
|
||||
1. 같은 회원·콘텐츠에 `rank=2`, 노출 가능 시각 09:00인 정상 row를 둔다.
|
||||
2. 같은 키에 `rank=null`, 노출 가능 시각 10:00인 active row를 둔다.
|
||||
3. 10:00 이후 조회하면 이전 row는 newer row 존재로 제외되고, newer row는 `rank is not null` 조건에서 제외된다.
|
||||
4. 요구 결과는 노출 가능한 row 중 최신인 09:00 row 1건이지만 실제 결과는 0건이다.
|
||||
|
||||
**영향**
|
||||
|
||||
부분 적재나 정책 변경으로 최신 inbox row가 노출 불가능해지면 유효한 이전 콘텐츠 랭킹까지 사라져 “노출 가능한 row 중 최신” 계약을 위반한다.
|
||||
|
||||
**권장 조치**
|
||||
|
||||
newer-row 판정에 외부 조회와 동일한 row별 노출 조건을 적용한다. 최소한 `rank is not null`과 회원별 inbox 성인 조건의 회귀
|
||||
테스트를 추가하고 기존 시각/id tie-break 테스트를 유지한다.
|
||||
|
||||
**판정 기록**
|
||||
|
||||
- 2026-07-30 — 외부 where와 correlated subquery 조건을 대조해 확정했다.
|
||||
|
||||
## 5. plan·goal 전환
|
||||
|
||||
- `plan-task.md` Phase 7에 Task 7.2/7.3, `P7-R1`, `P7-R2`, `P7-R-GATE`를 추가했다.
|
||||
- 기존 Task 7.1과 `P7-GATE`의 완료 기록은 유지했다.
|
||||
|
||||
## 6. 리뷰 종료 판정
|
||||
|
||||
| 판정 항목 | 결과 | 근거 |
|
||||
|---|---|---|
|
||||
| 리뷰 범위 전체 확인 | 충족 | working tree repository/test/E2E 정적 대조 |
|
||||
| 후보 항목 판정 완료 | 충족 | `REV-P7-001`, `REV-P7-002` 확정 |
|
||||
| 확정 항목 plan 반영 | 충족 | Task 7.2/7.3 |
|
||||
| 검증 명령과 결과 기록 | 충족 | 테스트 미실행 사유 기록 |
|
||||
|
||||
**최종 결론:** 수정 goal 필요.
|
||||
|
||||
**남은 항목:** `P7-R1` → `P7-R2` → `P7-R-GATE` 실행 후 이 문서에 수정 검증을 누적한다.
|
||||
|
||||
## 7. 수정 후 검증 기록
|
||||
|
||||
- 2026-07-30 — RED 확인: `./gradlew --no-daemon test --tests "kr.co.vividnext.sodalive.v2.home.following.application.HomeFollowingQueryServiceTest" --tests "kr.co.vividnext.sodalive.v2.home.following.adapter.out.persistence.DefaultHomeFollowingQueryRepositoryTest"` 실행 결과 최신 빈 배치와 노출 불가 newer row 보완 전 컴파일/회귀 실패를 확인했다.
|
||||
- 2026-07-30 — `CREATOR_RANKING` 최신 배치 기준을 `creator_ranking_snapshot_job`의 최신 `WEEKLY`, `DONE`, `visibleFromAtUtc <= nowUtc`로 보강해 최신 완료 배치가 비어 있어도 과거 배치를 보충하지 않도록 했다.
|
||||
- 2026-07-30 — `CONTENT_RANKING` newer row 판정에 `rank is not null`과 회원별 inbox 성인 조건을 적용해 노출 불가 최신 row가 이전 노출 가능 row를 가리지 않도록 했다.
|
||||
- 2026-07-30 — GREEN 확인: 같은 focused test 명령 재실행 결과 `BUILD SUCCESSFUL`.
|
||||
- 2026-07-30 — Gate 확인: `./gradlew --no-daemon test --tests "kr.co.vividnext.sodalive.v2.api.home.following.adapter.in.web.HomeFollowingEndToEndTest"`와 `./gradlew --no-daemon ktlintCheck` 재실행 결과 모두 `BUILD SUCCESSFUL`.
|
||||
- 2026-07-30 — 리뷰 게이트: Oracle reviewer가 Critical/Important blocker 없음으로 승인했다.
|
||||
|
||||
## 8. 후속 종료 판정 — 2026-07-30
|
||||
|
||||
- 최신 공개 크리에이터 랭킹 배치가 빈 결과여도 과거 배치로 fallback하지 않는 현재 job 기준 조건을 정적으로 재확인했다.
|
||||
- `CONTENT_RANKING` newer row 판정에 `rank is not null`과 회원별 inbox 성인 조건이 적용된 현재 코드를 정적으로 재확인했다.
|
||||
- 이번 후속 판정에서는 컴파일과 테스트를 재실행하지 않았고, 위 `## 7. 수정 후 검증 기록`의 기존 성공 결과를 근거로 삼았다.
|
||||
|
||||
**최종 결론:** `P7-R1`, `P7-R2` 및 `P7-R-GATE` 수정 검증 완료.
|
||||
|
||||
**남은 항목:** 없음.
|
||||
|
||||
## 9. 3차 정적 리뷰 — 2026-07-30
|
||||
|
||||
- 최신 DONE job 우선의 크리에이터 랭킹 배치 판정, legacy snapshot fallback, 노출 가능한 콘텐츠 랭킹 중복 제거를 현재 working tree에서 다시 추적했다.
|
||||
- 컴파일과 테스트는 사용자 지시에 따라 실행하지 않았다.
|
||||
- runtime 신규 확정 발견 사항 없음. PRD의 stale 최신 배치 설명은 문서 소유 Phase의 `REV-P6-002`와 Task 6.4로 전환했으며, 기존 Phase 7 완료 판정을 유지한다.
|
||||
|
||||
## 10. 4차 정적 리뷰 — 2026-07-30
|
||||
|
||||
- 최신 공개 `WEEKLY`, `DONE` job 기준과 job 부재 시 snapshot fallback, 최신 빈 배치의 과거 랭킹 미보충을 다시 추적했다.
|
||||
- `CONTENT_RANKING`의 노출 가능한 newer row 판정에 active/rank/성인/visible/id 조건이 적용되고 전체 limit 전에 콘텐츠별 한 건만 남는지 확인했다.
|
||||
- repository 테스트가 신규 배치 공개 전후, 빈 최신 배치, rank null·성인 newer row와 동률 id 해소를 고정하는지 정적으로 확인했다.
|
||||
- 컴파일과 테스트는 사용자 지시에 따라 실행하지 않았다.
|
||||
- 신규 확정 발견 사항 없음. `REV-P7-001`, `REV-P7-002` 수정 완료와 기존 Phase 7 완료 판정을 유지한다.
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -4,12 +4,14 @@ import com.querydsl.jpa.impl.JPAQueryFactory
|
||||
import kr.co.vividnext.sodalive.member.following.QCreatorFollowing.creatorFollowing
|
||||
import org.springframework.data.jpa.repository.JpaRepository
|
||||
import org.springframework.stereotype.Repository
|
||||
import javax.persistence.LockModeType
|
||||
|
||||
@Repository
|
||||
interface CreatorFollowingRepository : JpaRepository<CreatorFollowing, Long>, CreatorFollowingQueryRepository
|
||||
|
||||
interface CreatorFollowingQueryRepository {
|
||||
fun findByCreatorIdAndMemberId(creatorId: Long, memberId: Long): CreatorFollowing?
|
||||
fun findByCreatorIdAndMemberIdForUpdate(creatorId: Long, memberId: Long): CreatorFollowing?
|
||||
}
|
||||
|
||||
@Repository
|
||||
@@ -25,4 +27,15 @@ class CreatorFollowingQueryRepositoryImpl(
|
||||
)
|
||||
.fetchFirst()
|
||||
}
|
||||
|
||||
override fun findByCreatorIdAndMemberIdForUpdate(creatorId: Long, memberId: Long): CreatorFollowing? {
|
||||
return queryFactory
|
||||
.selectFrom(creatorFollowing)
|
||||
.where(
|
||||
creatorFollowing.creator.id.eq(creatorId)
|
||||
.and(creatorFollowing.member.id.eq(memberId))
|
||||
)
|
||||
.setLockMode(LockModeType.PESSIMISTIC_WRITE)
|
||||
.fetchFirst()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,7 +11,9 @@ import kr.co.vividnext.sodalive.explorer.profile.creatorCommunity.QCreatorCommun
|
||||
import kr.co.vividnext.sodalive.explorer.profile.creatorCommunity.comment.QCreatorCommunityComment.creatorCommunityComment
|
||||
import kr.co.vividnext.sodalive.explorer.profile.creatorCommunity.like.QCreatorCommunityLike.creatorCommunityLike
|
||||
import kr.co.vividnext.sodalive.extensions.toUtcIso
|
||||
import kr.co.vividnext.sodalive.live.room.GenderRestriction
|
||||
import kr.co.vividnext.sodalive.live.room.QLiveRoom.liveRoom
|
||||
import kr.co.vividnext.sodalive.member.Gender
|
||||
import kr.co.vividnext.sodalive.member.MemberRole
|
||||
import kr.co.vividnext.sodalive.member.QMember
|
||||
import kr.co.vividnext.sodalive.member.block.QBlockMember
|
||||
@@ -22,11 +24,16 @@ import kr.co.vividnext.sodalive.v2.home.following.adapter.out.persistence.QHomeF
|
||||
import kr.co.vividnext.sodalive.v2.home.following.domain.FollowingNewsType
|
||||
import kr.co.vividnext.sodalive.v2.home.following.domain.HomeFollowingCommunityPostNews
|
||||
import kr.co.vividnext.sodalive.v2.home.following.domain.HomeFollowingContentNews
|
||||
import kr.co.vividnext.sodalive.v2.home.following.domain.HomeFollowingContentRankingNews
|
||||
import kr.co.vividnext.sodalive.v2.home.following.domain.HomeFollowingCreator
|
||||
import kr.co.vividnext.sodalive.v2.home.following.domain.HomeFollowingCreatorRankingNews
|
||||
import kr.co.vividnext.sodalive.v2.home.following.domain.HomeFollowingLive
|
||||
import kr.co.vividnext.sodalive.v2.home.following.domain.HomeFollowingNews
|
||||
import kr.co.vividnext.sodalive.v2.home.following.domain.HomeFollowingSchedule
|
||||
import kr.co.vividnext.sodalive.v2.ranking.adapter.out.persistence.QCreatorRankingSnapshot
|
||||
import kr.co.vividnext.sodalive.v2.ranking.adapter.out.persistence.QCreatorRankingSnapshotJob
|
||||
import kr.co.vividnext.sodalive.v2.ranking.domain.CreatorRankingType
|
||||
import kr.co.vividnext.sodalive.v2.ranking.port.out.CreatorRankingSnapshotJobStatus
|
||||
import org.springframework.beans.factory.annotation.Value
|
||||
import org.springframework.stereotype.Repository
|
||||
import java.time.LocalDateTime
|
||||
@@ -64,7 +71,13 @@ class DefaultHomeFollowingQueryRepository(
|
||||
}
|
||||
}
|
||||
|
||||
override fun findOnAirLives(memberId: Long, canViewAdultContent: Boolean, limit: Int): List<HomeFollowingLive> {
|
||||
override fun findOnAirLives(
|
||||
memberId: Long,
|
||||
canViewAdultContent: Boolean,
|
||||
effectiveViewerGender: Gender,
|
||||
isViewerCreator: Boolean,
|
||||
limit: Int
|
||||
): List<HomeFollowingLive> {
|
||||
val creator = QMember("onAirCreator")
|
||||
return queryFactory
|
||||
.select(liveRoom.id, creator.profileImage, creator.nickname, liveRoom.title, liveRoom.beginDateTime)
|
||||
@@ -80,6 +93,8 @@ class DefaultHomeFollowingQueryRepository(
|
||||
creator.isActive.isTrue,
|
||||
creator.role.eq(MemberRole.CREATOR),
|
||||
adultLiveCondition(canViewAdultContent),
|
||||
genderLiveCondition(memberId, effectiveViewerGender),
|
||||
creatorJoinLiveCondition(memberId, isViewerCreator),
|
||||
notBlockedCreatorCondition(memberId, creator.id)
|
||||
)
|
||||
.orderBy(liveRoom.beginDateTime.desc(), liveRoom.id.desc())
|
||||
@@ -99,11 +114,19 @@ class DefaultHomeFollowingQueryRepository(
|
||||
override fun findMonthlySchedules(
|
||||
memberId: Long,
|
||||
canViewAdultContent: Boolean,
|
||||
effectiveViewerGender: Gender,
|
||||
isViewerCreator: Boolean,
|
||||
now: LocalDateTime,
|
||||
limit: Int
|
||||
): List<HomeFollowingSchedule> {
|
||||
val window = monthlyScheduleWindow(now)
|
||||
val liveSchedules = findLiveSchedules(memberId, canViewAdultContent, window)
|
||||
val liveSchedules = findLiveSchedules(
|
||||
memberId,
|
||||
canViewAdultContent,
|
||||
effectiveViewerGender,
|
||||
isViewerCreator,
|
||||
window
|
||||
)
|
||||
val audioSchedules = findAudioSchedules(memberId, canViewAdultContent, window)
|
||||
return (liveSchedules + audioSchedules)
|
||||
.sortedWith(
|
||||
@@ -141,7 +164,7 @@ class DefaultHomeFollowingQueryRepository(
|
||||
.from(homeFollowingNewsInbox)
|
||||
.join(creator).on(creator.id.eq(homeFollowingNewsInbox.creatorId))
|
||||
.leftJoin(newsAudioContent).on(
|
||||
homeFollowingNewsInbox.newsType.eq(FollowingNewsType.AUDIO_CONTENT),
|
||||
homeFollowingNewsInbox.newsType.`in`(FollowingNewsType.AUDIO_CONTENT, FollowingNewsType.CONTENT_RANKING),
|
||||
newsAudioContent.id.eq(homeFollowingNewsInbox.targetId)
|
||||
)
|
||||
.leftJoin(newsCommunity).on(
|
||||
@@ -157,7 +180,7 @@ class DefaultHomeFollowingQueryRepository(
|
||||
activeFollowingCondition(memberId, homeFollowingNewsInbox.creatorId),
|
||||
adultNewsCondition(canViewAdultContent),
|
||||
notBlockedCreatorCondition(memberId, homeFollowingNewsInbox.creatorId),
|
||||
activeNewsTargetCondition(canViewAdultContent)
|
||||
activeNewsTargetCondition(memberId, canViewAdultContent, nowUtc)
|
||||
)
|
||||
.orderBy(homeFollowingNewsInbox.visibleFromAtUtc.desc(), homeFollowingNewsInbox.id.desc())
|
||||
.limit(limit.toLong())
|
||||
@@ -209,6 +232,16 @@ class DefaultHomeFollowingQueryRepository(
|
||||
} else {
|
||||
null
|
||||
},
|
||||
contentRanking = if (type == FollowingNewsType.CONTENT_RANKING && rank != null) {
|
||||
HomeFollowingContentRankingNews(
|
||||
rank = rank,
|
||||
contentId = targetId,
|
||||
contentImageUrl = get(newsAudioContent.coverImage).toCdnUrl(cloudFrontHost),
|
||||
title = get(newsAudioContent.title)!!
|
||||
)
|
||||
} else {
|
||||
null
|
||||
},
|
||||
communityPost = if (type == FollowingNewsType.COMMUNITY_POST) {
|
||||
HomeFollowingCommunityPostNews(
|
||||
postId = targetId,
|
||||
@@ -229,6 +262,8 @@ class DefaultHomeFollowingQueryRepository(
|
||||
private fun findLiveSchedules(
|
||||
memberId: Long,
|
||||
canViewAdultContent: Boolean,
|
||||
effectiveViewerGender: Gender,
|
||||
isViewerCreator: Boolean,
|
||||
window: ScheduleWindow
|
||||
): List<HomeFollowingSchedule> {
|
||||
val creator = QMember("scheduleLiveCreator")
|
||||
@@ -254,6 +289,8 @@ class DefaultHomeFollowingQueryRepository(
|
||||
creator.isActive.isTrue,
|
||||
creator.role.eq(MemberRole.CREATOR),
|
||||
adultLiveCondition(canViewAdultContent),
|
||||
genderLiveCondition(memberId, effectiveViewerGender),
|
||||
creatorJoinLiveCondition(memberId, isViewerCreator),
|
||||
notBlockedCreatorCondition(memberId, creator.id)
|
||||
)
|
||||
.fetch()
|
||||
@@ -340,6 +377,20 @@ class DefaultHomeFollowingQueryRepository(
|
||||
return if (canViewAdultContent) null else liveRoom.isAdult.isFalse
|
||||
}
|
||||
|
||||
private fun genderLiveCondition(viewerId: Long, effectiveViewerGender: Gender): BooleanExpression? {
|
||||
val genderCondition = when (effectiveViewerGender) {
|
||||
Gender.MALE -> liveRoom.genderRestriction.`in`(GenderRestriction.ALL, GenderRestriction.MALE_ONLY)
|
||||
Gender.FEMALE -> liveRoom.genderRestriction.`in`(GenderRestriction.ALL, GenderRestriction.FEMALE_ONLY)
|
||||
Gender.NONE -> return null
|
||||
}
|
||||
return genderCondition.or(liveRoom.member.id.eq(viewerId))
|
||||
}
|
||||
|
||||
private fun creatorJoinLiveCondition(viewerId: Long, isViewerCreator: Boolean): BooleanExpression? {
|
||||
if (!isViewerCreator) return null
|
||||
return liveRoom.isAvailableJoinCreator.isTrue.or(liveRoom.member.id.eq(viewerId))
|
||||
}
|
||||
|
||||
private fun adultAudioCondition(canViewAdultContent: Boolean): BooleanExpression? {
|
||||
return if (canViewAdultContent) null else audioContent.isAdult.isFalse
|
||||
}
|
||||
@@ -385,7 +436,11 @@ class DefaultHomeFollowingQueryRepository(
|
||||
}
|
||||
}
|
||||
|
||||
private fun activeNewsTargetCondition(canViewAdultContent: Boolean): BooleanExpression {
|
||||
private fun activeNewsTargetCondition(
|
||||
memberId: Long,
|
||||
canViewAdultContent: Boolean,
|
||||
nowUtc: LocalDateTime
|
||||
): BooleanExpression {
|
||||
val newsAudioContent = QAudioContent("newsAudioContent")
|
||||
val newsCommunity = QCreatorCommunity("newsCommunity")
|
||||
val activeAudioExists = JPAExpressions
|
||||
@@ -410,10 +465,84 @@ class DefaultHomeFollowingQueryRepository(
|
||||
|
||||
return homeFollowingNewsInbox.newsType.eq(FollowingNewsType.CREATOR_RANKING)
|
||||
.and(homeFollowingNewsInbox.rank.isNotNull)
|
||||
.and(latestVisibleCreatorRankingBatchCondition(nowUtc))
|
||||
.or(homeFollowingNewsInbox.newsType.eq(FollowingNewsType.AUDIO_CONTENT).and(activeAudioExists))
|
||||
.or(
|
||||
homeFollowingNewsInbox.newsType.eq(FollowingNewsType.CONTENT_RANKING)
|
||||
.and(homeFollowingNewsInbox.rank.isNotNull)
|
||||
.and(activeAudioExists)
|
||||
.and(latestContentRankingNewsCondition(memberId, canViewAdultContent, nowUtc))
|
||||
)
|
||||
.or(homeFollowingNewsInbox.newsType.eq(FollowingNewsType.COMMUNITY_POST).and(activeCommunityExists))
|
||||
}
|
||||
|
||||
private fun latestVisibleCreatorRankingBatchCondition(nowUtc: LocalDateTime): BooleanExpression {
|
||||
val latestCreatorRankingSnapshot = QCreatorRankingSnapshot("latestCreatorRankingSnapshot")
|
||||
val latestCreatorRankingJob = QCreatorRankingSnapshotJob("latestCreatorRankingJob")
|
||||
val anyCreatorRankingJob = QCreatorRankingSnapshotJob("anyCreatorRankingJob")
|
||||
val latestJobVisibleFromAt = JPAExpressions
|
||||
.select(latestCreatorRankingJob.visibleFromAtUtc.max())
|
||||
.from(latestCreatorRankingJob)
|
||||
.where(
|
||||
latestCreatorRankingJob.rankingType.eq(CreatorRankingType.WEEKLY),
|
||||
latestCreatorRankingJob.status.eq(CreatorRankingSnapshotJobStatus.DONE),
|
||||
latestCreatorRankingJob.visibleFromAtUtc.loe(nowUtc)
|
||||
)
|
||||
val hasRankingJob = JPAExpressions
|
||||
.selectOne()
|
||||
.from(anyCreatorRankingJob)
|
||||
.where(
|
||||
anyCreatorRankingJob.rankingType.eq(CreatorRankingType.WEEKLY),
|
||||
anyCreatorRankingJob.status.eq(CreatorRankingSnapshotJobStatus.DONE),
|
||||
anyCreatorRankingJob.visibleFromAtUtc.loe(nowUtc)
|
||||
)
|
||||
.exists()
|
||||
val latestSnapshotVisibleFromAt = JPAExpressions
|
||||
.select(latestCreatorRankingSnapshot.visibleFromAtUtc.max())
|
||||
.from(latestCreatorRankingSnapshot)
|
||||
.where(
|
||||
latestCreatorRankingSnapshot.rankingType.eq(CreatorRankingType.WEEKLY),
|
||||
latestCreatorRankingSnapshot.visibleFromAtUtc.loe(nowUtc)
|
||||
)
|
||||
|
||||
return homeFollowingNewsInbox.visibleFromAtUtc.eq(latestJobVisibleFromAt).or(
|
||||
hasRankingJob.not().and(homeFollowingNewsInbox.visibleFromAtUtc.eq(latestSnapshotVisibleFromAt))
|
||||
)
|
||||
}
|
||||
|
||||
private fun latestContentRankingNewsCondition(
|
||||
memberId: Long,
|
||||
canViewAdultContent: Boolean,
|
||||
nowUtc: LocalDateTime
|
||||
): BooleanExpression {
|
||||
val newerContentRanking = QHomeFollowingNewsInbox("newerContentRanking")
|
||||
return JPAExpressions
|
||||
.selectOne()
|
||||
.from(newerContentRanking)
|
||||
.where(
|
||||
newerContentRanking.memberId.eq(memberId),
|
||||
newerContentRanking.newsType.eq(FollowingNewsType.CONTENT_RANKING),
|
||||
newerContentRanking.targetId.eq(homeFollowingNewsInbox.targetId),
|
||||
newerContentRanking.isActive.isTrue,
|
||||
newerContentRanking.rank.isNotNull,
|
||||
adultNewerContentRankingCondition(canViewAdultContent, newerContentRanking),
|
||||
newerContentRanking.visibleFromAtUtc.loe(nowUtc),
|
||||
newerContentRanking.visibleFromAtUtc.gt(homeFollowingNewsInbox.visibleFromAtUtc)
|
||||
.or(
|
||||
newerContentRanking.visibleFromAtUtc.eq(homeFollowingNewsInbox.visibleFromAtUtc)
|
||||
.and(newerContentRanking.id.gt(homeFollowingNewsInbox.id))
|
||||
)
|
||||
)
|
||||
.notExists()
|
||||
}
|
||||
|
||||
private fun adultNewerContentRankingCondition(
|
||||
canViewAdultContent: Boolean,
|
||||
newerContentRanking: QHomeFollowingNewsInbox
|
||||
): BooleanExpression? {
|
||||
return if (canViewAdultContent) null else newerContentRanking.isAdult.isFalse
|
||||
}
|
||||
|
||||
private fun adultAudioNewsTargetCondition(
|
||||
canViewAdultContent: Boolean,
|
||||
newsAudioContent: QAudioContent
|
||||
|
||||
@@ -52,6 +52,7 @@ interface HomeFollowingNewsInboxJpaRepository : JpaRepository<HomeFollowingNewsI
|
||||
where cf.creator_id = :creatorId
|
||||
and cf.is_active = true
|
||||
order by cf.member_id asc
|
||||
for update
|
||||
""",
|
||||
nativeQuery = true
|
||||
)
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
package kr.co.vividnext.sodalive.v2.home.following.application
|
||||
|
||||
import kr.co.vividnext.sodalive.member.Gender
|
||||
import kr.co.vividnext.sodalive.member.Member
|
||||
import kr.co.vividnext.sodalive.member.MemberRole
|
||||
import kr.co.vividnext.sodalive.member.contentpreference.MemberContentPreferenceService
|
||||
import kr.co.vividnext.sodalive.v2.home.following.domain.HomeFollowing
|
||||
import kr.co.vividnext.sodalive.v2.home.following.port.out.HomeFollowingQueryPort
|
||||
@@ -19,17 +21,36 @@ class HomeFollowingQueryService(
|
||||
fun findHomeFollowing(member: Member): HomeFollowing {
|
||||
val memberId = requireNotNull(member.id)
|
||||
val canViewAdultContent = memberContentPreferenceService.canViewAdultContent(member)
|
||||
val effectiveViewerGender = member.effectiveGender()
|
||||
val isViewerCreator = member.role == MemberRole.CREATOR
|
||||
val now = nowProvider()
|
||||
|
||||
return HomeFollowing(
|
||||
followingCreators = queryPort.findFollowingCreators(memberId, FOLLOWING_CREATORS_LIMIT),
|
||||
onAirLives = queryPort.findOnAirLives(memberId, canViewAdultContent, ON_AIR_LIVES_LIMIT),
|
||||
onAirLives = queryPort.findOnAirLives(
|
||||
memberId,
|
||||
canViewAdultContent,
|
||||
effectiveViewerGender,
|
||||
isViewerCreator,
|
||||
ON_AIR_LIVES_LIMIT
|
||||
),
|
||||
recentChats = emptyList(),
|
||||
monthlySchedules = queryPort.findMonthlySchedules(memberId, canViewAdultContent, now, MONTHLY_SCHEDULES_LIMIT),
|
||||
monthlySchedules = queryPort.findMonthlySchedules(
|
||||
memberId,
|
||||
canViewAdultContent,
|
||||
effectiveViewerGender,
|
||||
isViewerCreator,
|
||||
now,
|
||||
MONTHLY_SCHEDULES_LIMIT
|
||||
),
|
||||
recentNews = queryPort.findRecentNews(memberId, canViewAdultContent, now, RECENT_NEWS_LIMIT)
|
||||
)
|
||||
}
|
||||
|
||||
private fun Member.effectiveGender(): Gender {
|
||||
return auth?.let { if (it.gender == 1) Gender.MALE else Gender.FEMALE } ?: gender
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val FOLLOWING_CREATORS_LIMIT = 20
|
||||
private const val ON_AIR_LIVES_LIMIT = 10
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package kr.co.vividnext.sodalive.v2.home.following.port.out
|
||||
|
||||
import kr.co.vividnext.sodalive.member.Gender
|
||||
import kr.co.vividnext.sodalive.v2.home.following.domain.HomeFollowingCreator
|
||||
import kr.co.vividnext.sodalive.v2.home.following.domain.HomeFollowingLive
|
||||
import kr.co.vividnext.sodalive.v2.home.following.domain.HomeFollowingNews
|
||||
@@ -9,11 +10,19 @@ import java.time.LocalDateTime
|
||||
interface HomeFollowingQueryPort {
|
||||
fun findFollowingCreators(memberId: Long, limit: Int): List<HomeFollowingCreator>
|
||||
|
||||
fun findOnAirLives(memberId: Long, canViewAdultContent: Boolean, limit: Int): List<HomeFollowingLive>
|
||||
fun findOnAirLives(
|
||||
memberId: Long,
|
||||
canViewAdultContent: Boolean,
|
||||
effectiveViewerGender: Gender,
|
||||
isViewerCreator: Boolean,
|
||||
limit: Int
|
||||
): List<HomeFollowingLive>
|
||||
|
||||
fun findMonthlySchedules(
|
||||
memberId: Long,
|
||||
canViewAdultContent: Boolean,
|
||||
effectiveViewerGender: Gender,
|
||||
isViewerCreator: Boolean,
|
||||
now: LocalDateTime,
|
||||
limit: Int
|
||||
): List<HomeFollowingSchedule>
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -4,6 +4,7 @@ import kr.co.vividnext.sodalive.configs.QueryDslConfig
|
||||
import kr.co.vividnext.sodalive.member.Member
|
||||
import kr.co.vividnext.sodalive.member.MemberRole
|
||||
import kr.co.vividnext.sodalive.member.following.CreatorFollowing
|
||||
import kr.co.vividnext.sodalive.member.following.CreatorFollowingRepository
|
||||
import kr.co.vividnext.sodalive.v2.home.following.domain.FollowingNewsType
|
||||
import kr.co.vividnext.sodalive.v2.home.following.port.out.HomeFollowingNewsInboxRecord
|
||||
import org.junit.jupiter.api.Assertions.assertDoesNotThrow
|
||||
@@ -17,7 +18,15 @@ import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest
|
||||
import org.springframework.context.annotation.Import
|
||||
import org.springframework.test.annotation.DirtiesContext
|
||||
import org.springframework.test.context.transaction.TestTransaction
|
||||
import org.springframework.transaction.PlatformTransactionManager
|
||||
import org.springframework.transaction.annotation.Propagation
|
||||
import org.springframework.transaction.annotation.Transactional
|
||||
import org.springframework.transaction.support.TransactionTemplate
|
||||
import java.time.LocalDateTime
|
||||
import java.util.concurrent.CountDownLatch
|
||||
import java.util.concurrent.Executors
|
||||
import java.util.concurrent.TimeUnit
|
||||
import java.util.concurrent.TimeoutException
|
||||
import javax.persistence.EntityManager
|
||||
|
||||
@DataJpaTest(
|
||||
@@ -29,9 +38,13 @@ import javax.persistence.EntityManager
|
||||
@Import(QueryDslConfig::class)
|
||||
class HomeFollowingNewsInboxPersistenceAdapterTest @Autowired constructor(
|
||||
private val repository: HomeFollowingNewsInboxJpaRepository,
|
||||
private val entityManager: EntityManager
|
||||
private val creatorFollowingRepository: CreatorFollowingRepository,
|
||||
private val entityManager: EntityManager,
|
||||
transactionManager: PlatformTransactionManager
|
||||
) {
|
||||
private val adapter = HomeFollowingNewsInboxPersistenceAdapter(repository, entityManager)
|
||||
private val transactionalAdapter = HomeFollowingNewsInboxPersistenceAdapter(repository, entityManager, transactionManager)
|
||||
private val transactionTemplate = TransactionTemplate(transactionManager)
|
||||
|
||||
@Test
|
||||
@DisplayName("insertIgnoreAll은 memberId newsType sourceKey 중복을 예외 없이 무시하고 신규 row만 저장한다")
|
||||
@@ -115,6 +128,75 @@ class HomeFollowingNewsInboxPersistenceAdapterTest @Autowired constructor(
|
||||
assertEquals(listOf(activeFollowerWithoutInbox.id!!), followerIds)
|
||||
}
|
||||
|
||||
@Test
|
||||
@Transactional(propagation = Propagation.NOT_SUPPORTED)
|
||||
@DirtiesContext(methodMode = DirtiesContext.MethodMode.AFTER_METHOD)
|
||||
@DisplayName("publish가 follower 조회 후 insert 전 멈춘 사이 언팔로우가 완료돼도 재팔로우 후 과거 소식이 활성화되지 않는다")
|
||||
fun shouldNotExposeNewsCreatedBeforeUnfollowAfterRefollowWhenPublishIsDelayed() {
|
||||
val (memberId, creatorId) = transactionTemplate.execute {
|
||||
val creator = saveMember("concurrent-creator", MemberRole.CREATOR)
|
||||
val follower = saveMember("concurrent-follower", MemberRole.USER)
|
||||
saveFollowing(follower, creator, isActive = true)
|
||||
entityManager.flush()
|
||||
follower.id!! to creator.id!!
|
||||
}!!
|
||||
val followerRead = CountDownLatch(1)
|
||||
val allowPublishInsert = CountDownLatch(1)
|
||||
val executor = Executors.newFixedThreadPool(2)
|
||||
|
||||
try {
|
||||
val publish = executor.submit<Int> {
|
||||
transactionTemplate.execute {
|
||||
val followerIds = transactionalAdapter.findActiveFollowerIds(creatorId)
|
||||
followerRead.countDown()
|
||||
assertTrue(allowPublishInsert.await(5, TimeUnit.SECONDS))
|
||||
transactionalAdapter.insertIgnoreAll(
|
||||
followerIds.map { followerId ->
|
||||
record(memberId = followerId, creatorId = creatorId, sourceKey = "COMMUNITY_POST:delayed")
|
||||
}
|
||||
)
|
||||
}!!
|
||||
}
|
||||
assertTrue(followerRead.await(5, TimeUnit.SECONDS))
|
||||
val unfollow = executor.submit {
|
||||
transactionTemplate.executeWithoutResult {
|
||||
val following = creatorFollowingRepository.findByCreatorIdAndMemberId(
|
||||
creatorId = creatorId,
|
||||
memberId = memberId
|
||||
)!!
|
||||
following.isActive = false
|
||||
transactionalAdapter.deactivateByMemberIdAndCreatorId(memberId = memberId, creatorId = creatorId)
|
||||
}
|
||||
}
|
||||
|
||||
Thread.sleep(200)
|
||||
val unfollowCompletedBeforeInsert = try {
|
||||
unfollow.get(1, TimeUnit.SECONDS)
|
||||
true
|
||||
} catch (_: TimeoutException) {
|
||||
false
|
||||
}
|
||||
allowPublishInsert.countDown()
|
||||
assertEquals(1, publish.get(5, TimeUnit.SECONDS))
|
||||
if (!unfollowCompletedBeforeInsert) {
|
||||
unfollow.get(5, TimeUnit.SECONDS)
|
||||
}
|
||||
transactionTemplate.executeWithoutResult {
|
||||
creatorFollowingRepository.findByCreatorIdAndMemberId(
|
||||
creatorId = creatorId,
|
||||
memberId = memberId
|
||||
)!!.isActive = true
|
||||
}
|
||||
|
||||
val activeRows = transactionTemplate.execute {
|
||||
repository.findAll().filter { it.memberId == memberId && it.creatorId == creatorId && it.isActive }
|
||||
}!!
|
||||
assertEquals(emptyList<HomeFollowingNewsInbox>(), activeRows)
|
||||
} finally {
|
||||
executor.shutdownNow()
|
||||
}
|
||||
}
|
||||
|
||||
private fun saveMember(seed: String, role: MemberRole): Member {
|
||||
val member = Member(
|
||||
email = "$seed@test.com",
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
package kr.co.vividnext.sodalive.v2.home.following.application
|
||||
|
||||
import kr.co.vividnext.sodalive.member.Gender
|
||||
import kr.co.vividnext.sodalive.member.Member
|
||||
import kr.co.vividnext.sodalive.member.MemberRole
|
||||
import kr.co.vividnext.sodalive.member.auth.Auth
|
||||
import kr.co.vividnext.sodalive.member.contentpreference.MemberContentPreferenceService
|
||||
import kr.co.vividnext.sodalive.v2.home.following.domain.HomeFollowingCreator
|
||||
import kr.co.vividnext.sodalive.v2.home.following.domain.HomeFollowingLive
|
||||
@@ -58,6 +61,24 @@ class HomeFollowingQueryServiceTest {
|
||||
assertEquals(11L, queryPort.memberId)
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("본인인증 성별 우선 effective gender와 크리에이터 여부는 라이브 조회 port에 전달된다")
|
||||
fun shouldPassEffectiveGenderAndCreatorRoleToLiveQueryPort() {
|
||||
val member = member(13L).apply {
|
||||
role = MemberRole.CREATOR
|
||||
gender = Gender.MALE
|
||||
auth = Auth(name = "auth", birth = "20000101", uniqueCi = "ci", di = "di", gender = 0)
|
||||
}
|
||||
Mockito.`when`(memberContentPreferenceService.canViewAdultContent(member)).thenReturn(true)
|
||||
|
||||
service.findHomeFollowing(member)
|
||||
|
||||
assertEquals(Gender.FEMALE, queryPort.onAirEffectiveViewerGender)
|
||||
assertEquals(Gender.FEMALE, queryPort.monthlySchedulesEffectiveViewerGender)
|
||||
assertTrue(queryPort.onAirIsViewerCreator!!)
|
||||
assertTrue(queryPort.monthlySchedulesIsViewerCreator!!)
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("기본 now는 JVM 기본 timezone과 무관하게 UTC 기준으로 port에 전달된다")
|
||||
fun shouldUseUtcNowRegardlessOfJvmDefaultTimezone() {
|
||||
@@ -91,9 +112,13 @@ class HomeFollowingQueryServiceTest {
|
||||
var followingCreatorsLimit: Int? = null
|
||||
var onAirLivesLimit: Int? = null
|
||||
var onAirCanViewAdultContent: Boolean? = null
|
||||
var onAirEffectiveViewerGender: Gender? = null
|
||||
var onAirIsViewerCreator: Boolean? = null
|
||||
var monthlySchedulesLimit: Int? = null
|
||||
var monthlySchedulesNow: LocalDateTime? = null
|
||||
var monthlySchedulesCanViewAdultContent: Boolean? = null
|
||||
var monthlySchedulesEffectiveViewerGender: Gender? = null
|
||||
var monthlySchedulesIsViewerCreator: Boolean? = null
|
||||
var recentNewsLimit: Int? = null
|
||||
var recentNewsNow: LocalDateTime? = null
|
||||
var recentNewsCanViewAdultContent: Boolean? = null
|
||||
@@ -104,8 +129,16 @@ class HomeFollowingQueryServiceTest {
|
||||
return listOf(HomeFollowingCreator(1L, "creator", "profile"))
|
||||
}
|
||||
|
||||
override fun findOnAirLives(memberId: Long, canViewAdultContent: Boolean, limit: Int): List<HomeFollowingLive> {
|
||||
override fun findOnAirLives(
|
||||
memberId: Long,
|
||||
canViewAdultContent: Boolean,
|
||||
effectiveViewerGender: Gender,
|
||||
isViewerCreator: Boolean,
|
||||
limit: Int
|
||||
): List<HomeFollowingLive> {
|
||||
onAirCanViewAdultContent = canViewAdultContent
|
||||
onAirEffectiveViewerGender = effectiveViewerGender
|
||||
onAirIsViewerCreator = isViewerCreator
|
||||
onAirLivesLimit = limit
|
||||
return emptyList()
|
||||
}
|
||||
@@ -113,10 +146,14 @@ class HomeFollowingQueryServiceTest {
|
||||
override fun findMonthlySchedules(
|
||||
memberId: Long,
|
||||
canViewAdultContent: Boolean,
|
||||
effectiveViewerGender: Gender,
|
||||
isViewerCreator: Boolean,
|
||||
now: LocalDateTime,
|
||||
limit: Int
|
||||
): List<HomeFollowingSchedule> {
|
||||
monthlySchedulesCanViewAdultContent = canViewAdultContent
|
||||
monthlySchedulesEffectiveViewerGender = effectiveViewerGender
|
||||
monthlySchedulesIsViewerCreator = isViewerCreator
|
||||
monthlySchedulesNow = now
|
||||
monthlySchedulesLimit = limit
|
||||
return emptyList()
|
||||
|
||||
Reference in New Issue
Block a user