test #443

Merged
klaus merged 25 commits from test into main 2026-08-03 06:20:48 +00:00
20 changed files with 1811 additions and 51 deletions
Showing only changes of commit 13e0b42375 - Show all commits

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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