Compare commits

..

11 Commits

13 changed files with 3849 additions and 66 deletions

View File

@@ -19,8 +19,9 @@
- 스케줄 타입: `LIVE`, `AUDIO`만 사용한다. 오디오 콘텐츠가 `다시보기` 카테고리여도 `AUDIO`로 내려준다.
- 스케줄 정렬/개수: 현재 시각 이후 예약 중 오늘 날짜와 가장 근접한 3개, 예약 시각 오름차순, 같은 예약 시각이면 라이브 먼저 표시한다.
- 스케줄 성인 노출 정책: repository query에서 조회자의 성인 노출 정책을 먼저 반영하고, service 최종 조합에서도 내부 스케줄 후보의 `isAdult`로 한 번 더 보정한다. 공개 스케줄 응답에는 `isAdult`를 노출하지 않는다.
- 현재 라이브와 예약 라이브 스케줄은 기존 라이브 목록과 동일하게 성별 제한(`LiveRoom.genderRestriction`)과 크리에이터 입장 제한(`LiveRoom.isAvailableJoinCreator`)을 반영한다. application service는 조회자의 `Auth.gender`가 있으면 이를 우선하고, 없으면 `Member.gender`를 사용하는 `effectiveViewerGender`를 산출해 query port에 넘긴다.
- 신규 오디오 콘텐츠와 오디오 목록은 중복 노출하지 않는다. `latestAudioContent`로 내려간 가장 최신 콘텐츠를 오디오 목록에서 제외한다.
- 채널 후원 홈 섹션은 기존 채널 후원 목록과 동일하게 이번 달 기준 최신순 8개를 내려준다.
- 채널 후원 홈 섹션은 기존 채널 후원 목록과 동일하게 이번 달 기준 최신순 8개를 내려준다. 응답 메시지는 기본 문구를 조합하지 않고 후원자가 입력한 추가 메시지만 내려준다.
- 오리지널 시리즈 여부는 `Series.isOriginal == true`로 판단한다.
- 화보와 상단 탭별 전체보기 API는 이번 범위에서 제외한다.
@@ -85,6 +86,7 @@ data class CreatorChannelHomeResponse(
data class CreatorChannelCreatorResponse(
val creatorId: Long,
val characterId: Long?,
val nickname: String,
val profileImageUrl: String,
val followerCount: Int,
@@ -126,13 +128,9 @@ data class CreatorChannelAudioContentResponse(
)
data class CreatorChannelDonationResponse(
val donationId: Long,
val memberId: Long,
val nickname: String,
val profileImageUrl: String,
val can: Int,
@JsonProperty("isSecret")
val isSecret: Boolean,
val message: String,
val createdAtUtc: String
)
@@ -148,14 +146,9 @@ data class CreatorChannelSeriesResponse(
val seriesId: Long,
val title: String,
val coverImageUrl: String,
val publishedDaysOfWeek: String,
@JsonProperty("isComplete")
val isComplete: Boolean,
val numberOfContent: Int,
@JsonProperty("isNew")
val isNew: Boolean,
@JsonProperty("isPopular")
val isPopular: Boolean,
@JsonProperty("isOriginal")
val isOriginal: Boolean
)
@@ -274,7 +267,7 @@ data class CreatorChannelSnsResponse(
### Phase 3: 조회 port와 persistence adapter
- [ ] **Task 3.1: 조회 port와 record 타입 정의**
- [x] **Task 3.1: 조회 port와 record 타입 정의**
- Files:
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/port/out/CreatorChannelHomeQueryPort.kt`
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/adapter/out/persistence/DefaultCreatorChannelHomeQueryRepository.kt`
@@ -286,7 +279,7 @@ data class CreatorChannelSnsResponse(
- REFACTOR: record 타입은 JPA entity를 노출하지 않는 data class로 둔다.
- 기대 결과: application service가 의존할 조회 인터페이스가 고정된다.
- [ ] **Task 3.2: 크리에이터 기본 정보/차단/팔로우/AI 채팅/DM 조회 구현**
- [x] **Task 3.2: 크리에이터 기본 정보/차단/팔로우/AI 채팅/DM 조회 구현**
- Files:
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/adapter/out/persistence/DefaultCreatorChannelHomeQueryRepository.kt`
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/adapter/out/persistence/DefaultCreatorChannelHomeQueryRepositoryTest.kt`
@@ -302,48 +295,53 @@ data class CreatorChannelSnsResponse(
- REFACTOR: 프로필 이미지 URL 조합은 application/DTO에서 cloudFrontHost로 처리할지 repository에서 처리할지 한 곳으로 고정한다. 기존 v2 홈 DTO 관례처럼 path record와 URL 변환 함수를 분리하는 방식을 우선한다.
- 기대 결과: 기본 정보와 접근 차단 판단이 기존 정책과 맞는다.
- [ ] **Task 3.3: 현재 라이브와 예약 스케줄 조회 구현**
- [x] **Task 3.3: 현재 라이브와 예약 스케줄 조회 구현**
- Files:
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/adapter/out/persistence/DefaultCreatorChannelHomeQueryRepository.kt`
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/adapter/out/persistence/DefaultCreatorChannelHomeQueryRepositoryTest.kt`
- RED: 다음 repository 통합 테스트를 작성한다.
- 현재 라이브는 `channelName`이 있고 활성 상태이며 크리에이터가 진행 중인 라이브만 반환한다.
- 예약 라이브는 `beginDateTime > now`, 활성 상태인 row만 스케줄 후보로 반환한다.
- 예약 오디오는 `releaseDate > now`인 콘텐츠만 스케줄 후보로 반환한다.
- 예약 오디오는 `duration != null`, `releaseDate != null`, `releaseDate > now`이면 `isActive` 상태와 관계없이 스케줄 후보로 반환한다.
- 다시듣기 테마 예약 오디오도 스케줄 타입은 `AUDIO`다.
- 같은 예약 시각이면 라이브가 오디오보다 먼저 온다.
- 성인 라이브/오디오는 조회자의 성인 노출 정책이 false이면 제외된다.
- 현재 라이브와 예약 라이브 스케줄은 query port의 `effectiveViewerGender`, `viewerId`, `isViewerCreator` 입력으로 기존 라이브 목록의 성별 제한과 크리에이터 입장 제한을 반영한다.
- service 최종 보정을 위해 스케줄 후보 record에는 `isAdult`가 포함된다.
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.adapter.out.persistence.DefaultCreatorChannelHomeQueryRepositoryTest`
- GREEN: `LiveRoom`, `AudioContent`, `AudioContentTheme` 조회를 구현하고 `CreatorActivityType.LIVE`/`AUDIO``isAdult`를 record에 담는다.
- GREEN: `LiveRoom`, `AudioContent`, `AudioContentTheme` 조회를 구현하고 `CreatorActivityType.LIVE`/`AUDIO``isAdult`를 record에 담는다. 예약 오디오는 `isActive`가 아니라 `duration`과 미래 `releaseDate`로 판정한다.
- 통과 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.adapter.out.persistence.DefaultCreatorChannelHomeQueryRepositoryTest`
- REFACTOR: 최종 3개 제한은 repository query와 `CreatorChannelHomeQueryPolicy.limitSchedules` 양쪽 중복 방어를 허용하되, service에서 최종 보정한다.
- 기대 결과: 스케줄 섹션이 PRD의 타입/정렬/개수 정책을 만족한다.
- [ ] **Task 3.4: 최신 오디오와 오디오 목록 조회 구현**
- [x] **Task 3.4: 최신 오디오와 오디오 목록 조회 구현**
- Files:
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/adapter/out/persistence/DefaultCreatorChannelHomeQueryRepository.kt`
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/adapter/out/persistence/DefaultCreatorChannelHomeQueryRepositoryTest.kt`
- RED: 다음 repository 통합 테스트를 작성한다.
- `latestAudioContent`는 예약 공개 전 콘텐츠를 제외하고 공개 시각 최신순 1개를 반환한다.
- 오디오 목록은 `latestAudioContent`를 제외하고 최대 9개를 최신순으로 반환한다.
- `releaseDate == null`인 오디오는 최신/목록/첫 콘텐츠 판정에서 제외한다.
- `isPointAvailable`, duration, cover image, price가 record에 포함된다.
- 공개 순서상 첫 콘텐츠만 `isFirstContent=true`다.
- 시리즈 콘텐츠이면 시리즈 이름과 `Series.isOriginal`이 포함된다.
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.adapter.out.persistence.DefaultCreatorChannelHomeQueryRepositoryTest`
- GREEN: `AudioContent`, `SeriesContent`, `Series` 기반 조회를 구현한다.
- 통과 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.adapter.out.persistence.DefaultCreatorChannelHomeQueryRepositoryTest`
- REFACTOR: 예약 공개 여부 조건은 `releaseDate == null || releaseDate <= now`처럼 기존 콘텐츠 목록 정책과 어긋나지 않도록 작성한다.
- REFACTOR: 공개 오디오 조회 조건은 `releaseDate != null && releaseDate <= now`로 작성한다. `releaseDate == null`은 삭제/미공개 데이터로 보고 제외한다.
- 기대 결과: 신규 오디오 영역과 오디오 목록이 중복 없이 구성된다.
- [ ] **Task 3.5: 채널 후원, 공지, 커뮤니티, 팬 Talk 조회 구현**
- [x] **Task 3.5: 채널 후원, 공지, 커뮤니티, 팬 Talk 조회 구현**
- Files:
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/adapter/out/persistence/DefaultCreatorChannelHomeQueryRepository.kt`
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/adapter/out/persistence/DefaultCreatorChannelHomeQueryRepositoryTest.kt`
- RED: 다음 repository 통합 테스트를 작성한다.
- 채널 후원은 KST 기준 이번 달 범위의 최신순 8개만 반환한다.
- 공지는 `CreatorCommunity.isFixed == true`, 최대 3개, 고정 시각 최신순으로 반환한다.
- 채널 후원 응답에는 후원자 닉네임, 프로필 이미지, 후원 can, 후원자가 입력한 추가 메시지, UTC 생성 시각만 포함한다.
- 채널 후원 `message`는 기본 문구(`"%s캔을 비밀후원하셨습니다."`, `"%s캔을 후원하셨습니다."`)를 조합하지 않고 `additionalMessage`만 반환한다.
- 공지는 `CreatorCommunity.isFixed == true`, `fixedAt != null`인 데이터로 보고 최대 3개, 고정 시각 최신순으로 반환한다.
- 커뮤니티는 `isFixed == false`, 최대 3개, 작성 시각 최신순으로 반환한다.
- 성인 커뮤니티 글은 구매 여부와 무관하게 조회자의 성인 콘텐츠 노출 정책이 false이면 제외한다.
- 공지와 커뮤니티의 홈 응답 게시글 요약 필드는 기존 커뮤니티 전체보기 응답과 같은 의미로 계산한다.
- 팬 Talk는 `CreatorCheers.parent == null`, `isActive == true`인 최신 1개와 전체 개수를 반환한다.
- 차단 관계가 있는 팬 Talk 작성자는 기존 팬 Talk 목록 정책과 동일하게 제외한다.
@@ -353,15 +351,16 @@ data class CreatorChannelSnsResponse(
- REFACTOR: 커뮤니티 유료 이미지/오디오 구매 여부(`existOrdered`)는 인증 회원 기준으로 기존 community query 의미와 동일하게 계산한다.
- 기대 결과: 홈 후원/공지/커뮤니티/팬 Talk 섹션이 기존 전체보기 의미와 맞게 내려간다.
- [ ] **Task 3.6: 시리즈, 소개, 활동, SNS 조회 구현**
- [x] **Task 3.6: 시리즈, 소개, 활동, SNS 조회 구현**
- Files:
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/adapter/out/persistence/DefaultCreatorChannelHomeQueryRepository.kt`
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/adapter/out/persistence/DefaultCreatorChannelHomeQueryRepositoryTest.kt`
- RED: 다음 repository 통합 테스트를 작성한다.
- 시리즈는 최대 8개, 시리즈에 속한 공개 콘텐츠 최신 공개 시각 내림차순으로 반환한다.
- 시리즈 응답 record에는 id, 제목, 커버 이미지, 연재 요일, 완결 여부, 콘텐츠 개수, 신규/인기 표시 정보가 포함된다.
- 시리즈 응답 record에는 id, 제목, 커버 이미지, 콘텐츠 개수, 신규 표시, 오리지널 시리즈 여부가 포함된다.
- 소개는 `Member.introduce`를 반환한다.
- 데뷔일은 첫 라이브 시작 시각과 첫 공개 오디오 공개 시각 중 빠른 값이다.
- 데뷔일은 첫 라이브 시작 시각과 오디오 데뷔 후보 시각 중 빠른 값이다.
- 오디오 데뷔 후보는 업로드 순서 기준 첫 3개만 보고, 1~2번째 삭제 오디오는 건너뛰며, 3번째 삭제 오디오는 4번째로 넘어가지 않고 해당 `createdAt`을 후보로 쓴다.
- 업로드 오디오 콘텐츠 개수는 예약 업로드를 제외한다.
- 라이브 진행 횟수/누적 시간/누적 참여자는 기존 `ExplorerQueryRepository` 의미와 맞는다.
- SNS는 `instagramUrl`, `fancimmUrl`, `xUrl`, `youtubeUrl`, `websiteUrl`을 기존 상세 API 의미로 반환한다.
@@ -371,23 +370,169 @@ data class CreatorChannelSnsResponse(
- REFACTOR: 기존 `ExplorerService.getCreatorDetail`과 의미가 같은 계산은 테스트명에 근거를 남기고, 구버전 service를 직접 호출하지 않는다.
- 기대 결과: 활동/SNS/시리즈가 구버전 상세 의미와 신규 홈 요구를 함께 만족한다.
- [x] **Task 3.7: `findCreator` 기본 정보 조회를 count/exists 중심으로 개선**
- Files:
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/adapter/out/persistence/DefaultCreatorChannelHomeQueryRepository.kt`
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/adapter/out/persistence/DefaultCreatorChannelHomeQueryRepositoryTest.kt`
- RED: 기존 `shouldFindCreatorProfileWithRelationshipFlags`, `shouldNotExposeNotifyForInactiveFollowing` 테스트를 유지하고, 활성 팔로워가 여러 명이어도 `followerCount` 결과가 정확한 회귀 테스트를 보강한다.
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.adapter.out.persistence.DefaultCreatorChannelHomeQueryRepositoryTest`
- GREEN: `findCreator`에서 팔로워 수는 `select(id).fetch().size` 대신 DB `count()`로 계산하고, AI 채팅 가능 여부는 필요한 id만 조회하는 `exists` 성격의 쿼리로 유지한다. creator 기본 정보처럼 record 생성자 인자로 바로 매핑할 수 있는 조회는 필요한 컬럼을 `Tuple`로 가져와 재조립하지 않고 QueryDSL `Projections.constructor`로 record를 생성한다.
- 통과 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.adapter.out.persistence.DefaultCreatorChannelHomeQueryRepositoryTest`
- REFACTOR: `CreatorChannelCreatorRecord` 조립을 위해 불필요한 `Member` entity 전체를 로딩하지 않는지 코드로 확인한다. QueryDSL `@QueryProjection`은 port record가 QueryDSL에 의존하게 되므로 사용하지 않는다.
- 기대 결과: `findCreator`가 기존 응답 의미를 유지하면서 대량 follower row를 애플리케이션 메모리로 가져오지 않는다.
- [x] **Task 3.8: 단건/단순 목록 조회를 필요한 컬럼 projection으로 개선**
- Files:
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/adapter/out/persistence/DefaultCreatorChannelHomeQueryRepository.kt`
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/adapter/out/persistence/DefaultCreatorChannelHomeQueryRepositoryTest.kt`
- RED: `findCurrentLive`, `findLatestAudioContent`, `findAudioContents`, `findChannelDonations`, `findSns`의 기존 repository 테스트를 유지해 projection 변경 전후 응답 값이 같음을 고정한다. 단, 채널 후원 공개 응답 계약 변경은 `Task 3.15`를 우선한다.
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.adapter.out.persistence.DefaultCreatorChannelHomeQueryRepositoryTest`
- GREEN: 위 메서드들이 `selectFrom(entity).fetch().map { ... }`로 모든 컬럼을 가져오지 않도록 필요한 컬럼만 조회한다. record 생성자와 조회 컬럼이 1:1로 대응되는 부분은 `Tuple` 조회 후 수동 재조립하지 않고 QueryDSL `Projections.constructor`로 바로 record를 생성한다. `findAudioContents`는 목록 row마다 series/first content 조회가 반복되지 않도록 시리즈 정보와 첫 콘텐츠 id를 bulk 또는 별도 1회 쿼리로 계산한다.
- 통과 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.adapter.out.persistence.DefaultCreatorChannelHomeQueryRepositoryTest`
- REFACTOR: port record에는 QueryDSL annotation을 붙이지 않고, QueryDSL 의존은 persistence adapter 내부에만 둔다. 계산/병합/그룹핑 때문에 constructor projection으로 표현하기 어려운 부분에만 최소 범위로 `Tuple` 또는 별도 bulk map 조립을 허용한다.
- 기대 결과: 현재 라이브, 오디오, 후원, SNS 조회가 필요한 컬럼만 읽고 projection 변경 자체로는 응답 값이 바뀌지 않는다. 채널 후원 공개 응답 계약 변경은 `Task 3.15`에서 별도로 반영한다.
- [x] **Task 3.9: `findSchedules` 스케줄 후보 조회를 projection으로 개선**
- Files:
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/adapter/out/persistence/DefaultCreatorChannelHomeQueryRepository.kt`
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/adapter/out/persistence/DefaultCreatorChannelHomeQueryRepositoryTest.kt`
- RED: 기존 스케줄 테스트에 live/audio 후보가 여러 개 있을 때 예약 시각 오름차순, 같은 시각 live 우선, `limit` 적용 결과가 유지되는 케이스를 보강한다.
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.adapter.out.persistence.DefaultCreatorChannelHomeQueryRepositoryTest`
- GREEN: 예약 라이브와 예약 오디오 조회에서 entity 전체를 가져오지 않고 `scheduledAt`, `title`, `targetId`, `isAdult` 등 record 구성에 필요한 컬럼만 조회한다. live/audio 후보 각각이 record 생성자 인자로 바로 매핑되는 경우 QueryDSL `Projections.constructor`를 사용하고, 후보 병합 이후 정렬과 `limit`는 기존 정책과 동일하게 유지한다.
- 통과 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.adapter.out.persistence.DefaultCreatorChannelHomeQueryRepositoryTest`
- REFACTOR: 스케줄 공개 응답에는 `isAdult`가 노출되지 않고 내부 record에만 남는 구조를 유지한다.
- 기대 결과: 스케줄 후보 조회가 불필요한 `LiveRoom`, `AudioContent` 전체 컬럼을 읽지 않는다.
- [x] **Task 3.10: `findCommunityPosts` 게시글 요약 조회의 반복 쿼리와 전체 컬럼 조회 개선**
- Files:
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/adapter/out/persistence/DefaultCreatorChannelHomeQueryRepository.kt`
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/adapter/out/persistence/DefaultCreatorChannelHomeQueryRepositoryTest.kt`
- RED: 공지/커뮤니티 테스트에 여러 게시글을 넣고 `existOrdered`, `likeCount`, `commentCount`, 성인 필터, `fixedAt != null` 공지 조건이 기존 의미대로 유지되는지 검증한다.
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.adapter.out.persistence.DefaultCreatorChannelHomeQueryRepositoryTest`
- GREEN: 게시글 본문 조회는 필요한 컬럼만 조회하되, 본문 record로 바로 매핑 가능한 부분은 `Tuple`로 받은 뒤 재조립하지 않고 QueryDSL `Projections.constructor`를 사용한다. 게시글별 `existsCommunityOrder`, `countCommunityLikes`, `countCommunityComments` 반복 호출은 subquery 또는 게시글 id 목록 기반 bulk 조회로 줄인다.
- 통과 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.adapter.out.persistence.DefaultCreatorChannelHomeQueryRepositoryTest`
- REFACTOR: 기존 커뮤니티 전체보기의 구매 여부, 댓글 수, 댓글 불가 게시글 `commentCount = 0` 의미가 깨지지 않도록 테스트명을 근거로 남긴다.
- 기대 결과: 홈 공지/커뮤니티 최대 3개 조회가 게시글 수에 비례해 불필요한 추가 쿼리를 만들지 않는다.
- [x] **Task 3.11: `findSeries` 시리즈 조회의 전체 entity 로딩과 시리즈별 콘텐츠 조회 개선**
- Files:
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/adapter/out/persistence/DefaultCreatorChannelHomeQueryRepository.kt`
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/adapter/out/persistence/DefaultCreatorChannelHomeQueryRepositoryTest.kt`
- RED: 시리즈 테스트에 여러 시리즈와 콘텐츠를 넣고 최신 공개 시각 정렬, 콘텐츠 개수, 신규 표시, 오리지널 시리즈 여부, 성인/콘텐츠 타입/차단 정책, `releaseDate == null` 콘텐츠 제외가 유지되는지 검증한다.
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.adapter.out.persistence.DefaultCreatorChannelHomeQueryRepositoryTest`
- GREEN: `findSeries``selectFrom(series).fetch()` 뒤 시리즈마다 `publishedSeriesContents`, `hasNewSeriesContent`를 호출하지 않도록 `SeriesContent`/`AudioContent` join, group by, aggregate 기반 조회로 record를 만든다. aggregate 결과가 record 생성자 인자로 바로 대응되는 부분은 QueryDSL `Projections.constructor`를 사용하고, 후처리 집계가 필요한 경우에만 최소 범위로 bulk map 조립을 허용한다.
- 통과 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.adapter.out.persistence.DefaultCreatorChannelHomeQueryRepositoryTest`
- REFACTOR: 홈 시리즈 공개 응답에 `publishedDaysOfWeek`, `isComplete`, `isPopular`가 다시 포함되지 않도록 DTO와 mapper를 확인한다.
- 기대 결과: 시리즈 최대 8개 조회가 시리즈 수만큼 콘텐츠 조회를 반복하지 않고 기존 시리즈 목록 의미를 유지한다.
- [x] **Task 3.12: `findFanTalkSummary` 전체 row fetch 제거**
- Files:
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/adapter/out/persistence/DefaultCreatorChannelHomeQueryRepository.kt`
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/adapter/out/persistence/DefaultCreatorChannelHomeQueryRepositoryTest.kt`
- RED: 팬 Talk 테스트에 활성 최상위 글 여러 개와 비활성/답글/차단 작성자 글을 넣고 `totalCount`와 최신 1건이 기존 정책대로 계산되는지 검증한다.
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.adapter.out.persistence.DefaultCreatorChannelHomeQueryRepositoryTest`
- GREEN: `findFanTalkSummary`에서 조건에 맞는 전체 `CreatorCheers` row를 `fetch()`하지 않고, `totalCount`는 DB `count()`로 계산하며 `latestFanTalk`는 필요한 컬럼만 `orderBy(...).limit(1)`로 조회한다. `latestFanTalk`처럼 생성자 인자로 바로 매핑 가능한 record는 QueryDSL `Projections.constructor`로 생성한다.
- 통과 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.adapter.out.persistence.DefaultCreatorChannelHomeQueryRepositoryTest`
- REFACTOR: 차단 필터는 기존 팬 Talk 목록 정책과 동일하게 조회자 기준 양방향 차단만 반영한다.
- 기대 결과: 팬 Talk가 많은 크리에이터도 홈 조회에서 전체 팬 Talk row를 애플리케이션 메모리로 가져오지 않는다.
- [x] **Task 3.13: 크리에이터 기본 응답에 `characterId` 추가**
- Files:
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/port/out/CreatorChannelHomeQueryPort.kt`
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/adapter/out/persistence/DefaultCreatorChannelHomeQueryRepository.kt`
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/domain/CreatorChannelHome.kt`
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/dto/CreatorChannelHomeResponse.kt`
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/adapter/out/persistence/DefaultCreatorChannelHomeQueryRepositoryTest.kt`
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/application/CreatorChannelHomeQueryServiceTest.kt`
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/adapter/in/web/CreatorChannelHomeControllerTest.kt`
- RED: 활성 `ChatCharacter`가 있는 크리에이터는 `creator.characterId`가 해당 캐릭터 ID로 내려가고, 활성 캐릭터가 없으면 `null`로 내려가는 repository/service/controller 응답 계약 테스트를 먼저 추가한다.
- 실패 확인:
- `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.adapter.out.persistence.DefaultCreatorChannelHomeQueryRepositoryTest`
- `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.application.CreatorChannelHomeQueryServiceTest`
- `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.adapter.in.web.CreatorChannelHomeControllerTest`
- GREEN: creator record/domain/response DTO에 nullable `characterId`를 추가하고, `findCreator`가 활성 `ChatCharacter` ID를 projection으로 함께 조회하도록 구현한다.
- 통과 확인:
- `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.adapter.out.persistence.DefaultCreatorChannelHomeQueryRepositoryTest`
- `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.application.CreatorChannelHomeQueryServiceTest`
- `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.adapter.in.web.CreatorChannelHomeControllerTest`
- REFACTOR: `isAiChatAvailable``characterId != null` 기준과 의미가 어긋나지 않도록 한 곳에서 계산한다.
- 기대 결과: 클라이언트가 AI 채팅 진입에 필요한 `characterId`를 홈 응답에서 바로 사용할 수 있다.
- [x] **Task 3.14: 시리즈 응답에서 연재 요일/완결/인기 필드 제거**
- Files:
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/port/out/CreatorChannelHomeQueryPort.kt`
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/adapter/out/persistence/DefaultCreatorChannelHomeQueryRepository.kt`
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/domain/CreatorChannelHome.kt`
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/dto/CreatorChannelHomeResponse.kt`
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/adapter/out/persistence/DefaultCreatorChannelHomeQueryRepositoryTest.kt`
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/application/CreatorChannelHomeQueryServiceTest.kt`
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/adapter/in/web/CreatorChannelHomeControllerTest.kt`
- RED: 시리즈 JSON 응답에 `publishedDaysOfWeek`, `isComplete`, `isPopular`가 없고, `seriesId`, `title`, `coverImageUrl`, `numberOfContent`, `isNew`, `isOriginal`만 필요한 계약으로 내려가는 테스트를 먼저 추가한다.
- 실패 확인:
- `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.adapter.out.persistence.DefaultCreatorChannelHomeQueryRepositoryTest`
- `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.application.CreatorChannelHomeQueryServiceTest`
- `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.adapter.in.web.CreatorChannelHomeControllerTest`
- GREEN: series record/domain/response DTO와 mapper에서 `publishedDaysOfWeek`, `isComplete`, `isPopular`를 제거하고, repository projection도 더 이상 해당 응답 필드 조립을 위해 조회하지 않도록 정리한다.
- 통과 확인:
- `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.adapter.out.persistence.DefaultCreatorChannelHomeQueryRepositoryTest`
- `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.application.CreatorChannelHomeQueryServiceTest`
- `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.adapter.in.web.CreatorChannelHomeControllerTest`
- REFACTOR: `rg -n "publishedDaysOfWeek|isComplete|isPopular" src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel`로 신규 홈 API 경계에 제거 대상 필드가 남지 않았는지 확인한다.
- 기대 결과: 홈 시리즈 응답이 클라이언트 요청 계약에 맞게 축소된다.
- [x] **Task 3.15: 채널 후원 응답에서 기본 후원 문구와 내부 식별 필드 제거**
- Files:
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/port/out/CreatorChannelHomeQueryPort.kt`
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/adapter/out/persistence/DefaultCreatorChannelHomeQueryRepository.kt`
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/domain/CreatorChannelHome.kt`
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/dto/CreatorChannelHomeResponse.kt`
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/adapter/out/persistence/DefaultCreatorChannelHomeQueryRepositoryTest.kt`
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/application/CreatorChannelHomeQueryServiceTest.kt`
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/adapter/in/web/CreatorChannelHomeControllerTest.kt`
- RED: 채널 후원 JSON 응답에 `donationId`, `memberId`, `isSecret`이 없고, `nickname`, `profileImageUrl`, `can`, `message`, `createdAtUtc`만 내려가는 테스트를 먼저 추가한다. repository 테스트에서는 `message`가 기본 문구 조합 없이 `ChannelDonationMessage.additionalMessage` 값만 반환되고, 추가 메시지가 없으면 빈 문자열이 되는지 검증한다. 비밀 후원은 기존 정책처럼 후원자 본인과 받은 크리에이터만 조회 가능하고, 제3자는 같은 달 비밀 후원을 조회할 수 없는 negative case를 추가한다.
- 실패 확인:
- `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.adapter.out.persistence.DefaultCreatorChannelHomeQueryRepositoryTest`
- `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.application.CreatorChannelHomeQueryServiceTest`
- `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.adapter.in.web.CreatorChannelHomeControllerTest`
- GREEN: donation record/domain/response DTO와 mapper에서 `donationId`, `memberId`, `isSecret` 공개 응답 필드를 제거하고, repository는 `additionalMessage.orEmpty()``message`로 매핑한다. 비밀 후원 노출 여부와 이번 달 최신순 8개 조회 정책은 유지하되, `isSecret`은 repository 조회 조건에만 사용하고 공개 응답에는 포함하지 않는다. record 생성자 인자로 바로 매핑되는 조회는 QueryDSL `Projections.constructor`를 사용한다.
- 통과 확인:
- `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.adapter.out.persistence.DefaultCreatorChannelHomeQueryRepositoryTest`
- `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.application.CreatorChannelHomeQueryServiceTest`
- `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.adapter.in.web.CreatorChannelHomeControllerTest`
- REFACTOR: `SodaMessageSource`/`LangContext`가 채널 후원 메시지 조합만을 위해 주입되어 있다면 제거한다. `rg -n "donationId|memberId|isSecret|비밀후원|후원하셨습니다|SodaMessageSource|LangContext" src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel`로 신규 홈 API 경계에 제거 대상 응답 필드나 기본 문구 조합이 남지 않았는지 확인한다.
- 기대 결과: 홈 채널 후원 섹션이 변경된 클라이언트 계약에 맞게 추가 메시지 중심의 최소 응답만 내려준다.
- [x] **Task 3.16: 구매한 삭제 유료 커뮤니티 게시글 조회 정책 보정**
- Files:
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/adapter/out/persistence/DefaultCreatorChannelHomeQueryRepository.kt`
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/adapter/out/persistence/DefaultCreatorChannelHomeQueryRepositoryTest.kt`
- RED: 유료 커뮤니티 게시글을 구매한 조회자는 크리에이터가 이후 삭제해 `CreatorCommunity.isActive == false`가 되어도 해당 게시글을 조회하고, 비구매자는 조회하지 못하는 repository 테스트를 먼저 추가한다.
- 실패 확인:
- `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.adapter.out.persistence.DefaultCreatorChannelHomeQueryRepositoryTest --no-daemon`
- GREEN: `findCommunityPosts`의 구현 방식은 게시글 후보 조회 후 구매 여부/좋아요 수/댓글 수를 bulk 조회하는 기존 구조를 유지한다. 게시글 후보 조건만 기존 커뮤니티 전체보기 의미에 맞춰 `CreatorCommunity.isActive == true` 또는 인증 조회자가 환불되지 않은 `PAID_COMMUNITY_POST` 구매 이력이 있는 경우로 보정한다.
- 통과 확인:
- `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.adapter.out.persistence.DefaultCreatorChannelHomeQueryRepositoryTest --no-daemon`
- REFACTOR: 좋아요/댓글/구매 여부 조회를 `leftJoin` 하나로 합치지 않고, 현재의 id 목록 기반 bulk 조회 구조를 유지한다.
- 기대 결과: 구매자는 삭제된 유료 게시글도 기존 전체보기 의미와 동일하게 접근할 수 있고, 비구매자는 삭제된 게시글을 조회하지 못한다.
---
### Phase 4: application service 조립
- [ ] **Task 4.1: `CreatorChannelHomeQueryService` 정상 응답 조립 구현**
- [x] **Task 4.1: `CreatorChannelHomeQueryService` 정상 응답 조립 구현**
- Files:
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/application/CreatorChannelHomeQueryService.kt`
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/domain/CreatorChannelHomeQueryPolicy.kt`
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/application/CreatorChannelHomeQueryServiceTest.kt`
- RED: fake port를 사용해 모든 섹션 record를 넣고, service가 `CreatorChannelHome`으로 전체 섹션을 조립하는 테스트를 작성한다. `latestAudioContent`와 오디오 목록 중복 제거, 스케줄 최대 3개 제한, 같은 시각 라이브 우선 정렬, 성인 스케줄 최종 제외도 service 테스트에서 검증한다.
- RED: 조회자에게 `Auth.gender`가 있으면 `Member.gender`보다 `Auth.gender`를 우선해 `effectiveViewerGender`를 산출하고, `viewerId`, `isViewerCreator`, `effectiveViewerGender``findCurrentLive``findSchedules`에 전달되는지 fake port로 검증한다.
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.application.CreatorChannelHomeQueryServiceTest`
- GREEN: service에서 creator 검증, 성인 노출 정책 입력, port 호출, policy 적용, URL 변환에 필요한 host 전달을 구현한다.
- GREEN: service에서 creator 검증, 성인 노출 정책 입력, 조회자 라이브 정책 컨텍스트(`viewerId`, `isViewerCreator`, `effectiveViewerGender`) 산출, port 호출, policy 적용, URL 변환에 필요한 host 전달을 구현한다.
- 통과 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.application.CreatorChannelHomeQueryServiceTest`
- REFACTOR: service는 트랜잭션 경계 `@Transactional(readOnly = true)`를 갖고, persistence adapter의 세부 query에 의존하지 않도록 port만 사용한다.
- 기대 결과: controller가 단일 service 호출만으로 홈 응답을 받을 수 있다.
- [ ] **Task 4.2: 예외/접근 정책 구현**
- [x] **Task 4.2: 예외/접근 정책 구현**
- Files:
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/application/CreatorChannelHomeQueryService.kt`
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/application/CreatorChannelHomeQueryServiceTest.kt`
@@ -405,7 +550,7 @@ data class CreatorChannelSnsResponse(
### Phase 5: web API와 응답 계약
- [ ] **Task 5.1: Controller 인증 정책과 endpoint 구현**
- [x] **Task 5.1: Controller 인증 정책과 endpoint 구현**
- Files:
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/adapter/in/web/CreatorChannelHomeController.kt`
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/adapter/in/web/CreatorChannelHomeControllerTest.kt`
@@ -419,7 +564,7 @@ data class CreatorChannelSnsResponse(
- REFACTOR: 인증 null 가드는 기존 v2 controller와 동일하게 `SodaException(messageKey = "common.error.bad_credentials")`를 사용한다.
- 기대 결과: 공개 API endpoint와 인증 정책이 고정된다.
- [ ] **Task 5.2: 응답 JSON 필드 계약 고정**
- [x] **Task 5.2: 응답 JSON 필드 계약 고정**
- Files:
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/dto/CreatorChannelHomeResponse.kt`
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/adapter/in/web/CreatorChannelHomeControllerTest.kt`
@@ -447,7 +592,7 @@ data class CreatorChannelSnsResponse(
### Phase 6: 통합 회귀와 문서 갱신
- [ ] **Task 6.1: 크리에이터 채널 홈 통합 시나리오 검증**
- [x] **Task 6.1: 크리에이터 채널 홈 통합 시나리오 검증**
- Files:
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/adapter/in/web/CreatorChannelHomeControllerTest.kt`
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/adapter/out/persistence/DefaultCreatorChannelHomeQueryRepositoryTest.kt`
@@ -462,7 +607,7 @@ data class CreatorChannelSnsResponse(
- REFACTOR: 테스트 fixture helper가 과도하게 길어지면 같은 테스트 파일 내부 private helper로만 분리하고 운영 코드에는 테스트 편의를 위한 API를 추가하지 않는다.
- 기대 결과: PRD의 홈 전체 섹션이 한 요청에서 조립되는지 확인된다.
- [ ] **Task 6.2: 추천 페이지 enum rename 회귀 확인**
- [x] **Task 6.2: 추천 페이지 enum rename 회귀 확인**
- Files:
- Modify: `src/test/kotlin/kr/co/vividnext/sodalive/v2/recommendation/application/HomeRecommendationQueryServiceTest.kt`
- Modify: `src/test/kotlin/kr/co/vividnext/sodalive/v2/recommendation/adapter/out/persistence/DefaultHomeRecommendationQueryRepositoryTest.kt`
@@ -474,7 +619,7 @@ data class CreatorChannelSnsResponse(
- REFACTOR: `rg -n "RecommendedActivityType" src/main/kotlin src/test/kotlin` 결과가 없어야 한다.
- 기대 결과: 추천 페이지 최근 활동 타입 분류가 기존과 동일하게 유지된다.
- [ ] **Task 6.3: 전체 검증 및 계획 문서 검증 기록 누적**
- [x] **Task 6.3: 전체 검증 및 계획 문서 검증 기록 누적**
- Files:
- Modify: `docs/20260612_크리에이터_채널_홈_API/plan-task.md`
- RED: 테스트 작성 예외. `TDD 예외 사유`: 검증 기록 문서화 task다.
@@ -515,3 +660,46 @@ data class CreatorChannelSnsResponse(
- 2026-06-12: Phase 2 리뷰 보정 RED 확인 - 오디오 콘텐츠 `isAdult`와 스케줄 현재시각 필터 테스트 추가 후 `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.application.CreatorChannelHomeQueryServiceTest --tests kr.co.vividnext.sodalive.v2.creator.channel.domain.CreatorChannelHomeQueryPolicyTest` 실행 시 `Unresolved reference: isAdult`, `Too many arguments for limitSchedules` 컴파일 오류를 확인했다.
- 2026-06-12: Phase 2 리뷰 보정 GREEN 확인 - `CreatorChannelAudioContent`/`CreatorChannelAudioContentResponse``isAdult`를 추가하고 `CreatorChannelHomeQueryPolicy.limitSchedules(schedules, now)``scheduledAt > now`만 남기도록 수정한 뒤 `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.application.CreatorChannelHomeQueryServiceTest --tests kr.co.vividnext.sodalive.v2.creator.channel.domain.CreatorChannelHomeQueryPolicyTest` 통과.
- 2026-06-12: 스케줄 성인 노출 정책 보강 - PRD와 plan-task에 repository query 1차 필터 + service 최종 보정 방식을 명시하고, 내부 `CreatorChannelSchedule.isAdult``CreatorChannelHomeQueryPolicy.limitSchedules(schedules, now, canViewAdultContent)`를 반영했다. `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.application.CreatorChannelHomeQueryServiceTest --tests kr.co.vividnext.sodalive.v2.creator.channel.domain.CreatorChannelHomeQueryPolicyTest`, `./gradlew ktlintCheck` 통과.
- 2026-06-12: Phase 3 RED/GREEN 확인 - `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.adapter.out.persistence.DefaultCreatorChannelHomeQueryRepositoryTest` 실행 시 `Unresolved reference: DefaultCreatorChannelHomeQueryRepository` 컴파일 오류를 확인한 뒤 조회 port/record와 `DefaultCreatorChannelHomeQueryRepository`를 구현해 통과. 추가로 `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.domain.CreatorChannelHomeQueryPolicyTest --tests kr.co.vividnext.sodalive.v2.creator.channel.application.CreatorChannelHomeQueryServiceTest`, `./gradlew ktlintCheck` 통과.
- 2026-06-12: Phase 3 예약 라이브 조건 보강 - 기존 `LiveRoomRepository.getLiveRoomListReservationWithoutDate` 의미에 맞춰 예약 라이브 스케줄은 `channelName`이 비어 있는 row만 조회하도록 테스트를 보강했다. 보강 직후 `DefaultCreatorChannelHomeQueryRepositoryTest`가 스케줄 assertion에서 실패하는 것을 확인했고, `findSchedules` live 조건을 `channelName is null or empty`로 수정한 뒤 `./gradlew clean test --tests kr.co.vividnext.sodalive.v2.creator.channel.adapter.out.persistence.DefaultCreatorChannelHomeQueryRepositoryTest`, `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.domain.CreatorChannelHomeQueryPolicyTest --tests kr.co.vividnext.sodalive.v2.creator.channel.application.CreatorChannelHomeQueryServiceTest`, `./gradlew ktlintCheck` 통과.
- 2026-06-12: Phase 3 리뷰 보정 RED/GREEN 확인 - 비활성 팔로우 알림, `releaseDate == null` 공개 오디오, KST 월 경계/크리에이터 비밀 후원 열람, 팬 Talk 작성자→조회자 차단, 미래 라이브 데뷔일 제외 테스트를 추가한 뒤 `DefaultCreatorChannelHomeQueryRepositoryTest`에서 5개 실패를 확인했다. 조회 조건을 기존 repository/service 의미에 맞게 보정한 뒤 `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.adapter.out.persistence.DefaultCreatorChannelHomeQueryRepositoryTest`, `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.domain.CreatorChannelHomeQueryPolicyTest --tests kr.co.vividnext.sodalive.v2.creator.channel.application.CreatorChannelHomeQueryServiceTest`, `./gradlew ktlintCheck` 통과.
- 2026-06-12: Phase 3 Task 3.7~3.12 RED 확인 - `findCreator` 다중 활성 팔로워 count 회귀 테스트와 projection/bulk 구조 회귀 테스트를 추가한 뒤 `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.adapter.out.persistence.DefaultCreatorChannelHomeQueryRepositoryTest` 실행 시 `shouldUseProjectionAndBulkQueriesForPhaseThreeOptimizedMethods`가 기존 `selectFrom(...)`, `fetch().size`, per-row helper 사용을 잡아 실패하는 것을 확인했다.
- 2026-06-12: Phase 3 Task 3.7~3.12 GREEN 확인 - `findCreator`, `findCurrentLive`, `findLatestAudioContent`, `findAudioContents`, `findChannelDonations`, `findSns`, `findSchedules`, `findCommunityPosts`, `findSeries`, `findFanTalkSummary`를 필요한 컬럼 projection, DB count, id 목록 기반 bulk 조회로 개선하고 `findActivity`의 id fetch count도 DB count로 보정했다. `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.adapter.out.persistence.DefaultCreatorChannelHomeQueryRepositoryTest` 통과.
- 2026-06-12: Phase 3 Task 3.7~3.12 회귀/정리 확인 - `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.adapter.out.persistence.DefaultCreatorChannelHomeQueryRepositoryTest --tests kr.co.vividnext.sodalive.v2.creator.channel.domain.CreatorChannelHomeQueryPolicyTest --tests kr.co.vividnext.sodalive.v2.creator.channel.application.CreatorChannelHomeQueryServiceTest` 통과, `./gradlew ktlintCheck` 통과.
- 2026-06-12: Phase 3 Task 3.9/3.11 리뷰 보정 확인 - 스케줄 후보 병합 후 `limit` 적용 테스트와 `select(series)` entity fetch 금지 테스트를 추가해 RED를 확인했고, `findSeries`를 tuple projection과 `publishedDaysOfWeek` id 목록 기반 bulk join으로 변경했다. `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.adapter.out.persistence.DefaultCreatorChannelHomeQueryRepositoryTest --tests kr.co.vividnext.sodalive.v2.creator.channel.domain.CreatorChannelHomeQueryPolicyTest --tests kr.co.vividnext.sodalive.v2.creator.channel.application.CreatorChannelHomeQueryServiceTest`, `./gradlew ktlintCheck` 통과. `rg -n "\.select\(series\)|selectFrom\(series\)|publishedSeriesContents\(|hasNewSeriesContent\(" src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/adapter/out/persistence/DefaultCreatorChannelHomeQueryRepository.kt` 결과 없음.
- 2026-06-12: Phase 3 추가 리뷰 보정 RED/GREEN 확인 - 커뮤니티 성인 필터/작성자 본인 구매 처리, 시리즈 성인·콘텐츠 타입·차단·신규 표시 정책, `releaseDate == null` 오디오의 `createdAt` 정렬 fallback, 팬 Talk 조회자 기준 차단 정책 테스트를 추가한 뒤 port/repository signature와 query 조건을 보정했다. `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.adapter.out.persistence.DefaultCreatorChannelHomeQueryRepositoryTest`, `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.domain.CreatorChannelHomeQueryPolicyTest --tests kr.co.vividnext.sodalive.v2.creator.channel.application.CreatorChannelHomeQueryServiceTest`, `./gradlew ktlintCheck` 통과.
- 2026-06-12: Phase 3 reviewer follow-up 보정 - 리뷰에서 지적된 보이는 첫 오디오 판정의 성인 정책 반영 누락과 시리즈 콘텐츠 후보의 `duration is not null` 조건 누락 가능성을 테스트로 고정했다. 보강 직후 `DefaultCreatorChannelHomeQueryRepositoryTest`에서 2개 실패를 확인했고, `firstAudioContentId``canViewAdultContent`를 반영하고 시리즈 콘텐츠/신규 판정에 `duration.isNotNull`을 추가한 뒤 `./gradlew clean test --tests kr.co.vividnext.sodalive.v2.creator.channel.adapter.out.persistence.DefaultCreatorChannelHomeQueryRepositoryTest`, `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.domain.CreatorChannelHomeQueryPolicyTest --tests kr.co.vividnext.sodalive.v2.creator.channel.application.CreatorChannelHomeQueryServiceTest`, `./gradlew ktlintCheck` 통과.
- 2026-06-12: Phase 3 리뷰 결과 보정 RED/GREEN 확인 - 커뮤니티 댓글 수의 기존 목록 의미(parent null/양방향 차단/비밀 댓글 권한), 채널 후원 메시지 기본 문구+캔 포맷+추가 메시지 조합, 단독 오디오 duration null 제외 정책을 repository 테스트로 추가했다. 보강 직후 `DefaultCreatorChannelHomeQueryRepositoryTest`에서 3개 실패(`commentCount 5 -> 1`, null duration 최신 오디오 선택, 후원 메시지 additionalMessage 단독 반환)를 확인했고, query/메시지 조합을 보정한 뒤 `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.adapter.out.persistence.DefaultCreatorChannelHomeQueryRepositoryTest`, `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.domain.CreatorChannelHomeQueryPolicyTest --tests kr.co.vividnext.sodalive.v2.creator.channel.application.CreatorChannelHomeQueryServiceTest` 통과.
- 2026-06-12: Phase 3 리뷰어 게이트 보정 - Context mining 리뷰에서 지적된 `isCommentAvailable == false` 게시글 댓글 수와 채널 후원 메시지 다국어 메시지 소스 의미를 추가 테스트로 고정했다. 보강 직후 constructor mismatch RED를 확인했고, repository에 `SodaMessageSource`/`LangContext` 기반 메시지 조합과 댓글 불가 게시글 `commentCount = 0` 처리를 반영한 뒤 `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.adapter.out.persistence.DefaultCreatorChannelHomeQueryRepositoryTest`, `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.domain.CreatorChannelHomeQueryPolicyTest --tests kr.co.vividnext.sodalive.v2.creator.channel.application.CreatorChannelHomeQueryServiceTest` 통과.
- 2026-06-12: Phase 3 문서 정합성 보정 - 성인 커뮤니티 글은 구매 여부와 무관하게 조회자의 성인 콘텐츠 노출 정책이 우선한다는 점과 `isFixed == true` 게시글은 `fixedAt != null`인 데이터로 본다는 전제를 PRD/plan-task에 명시했다.
- 2026-06-12: Phase 3 리뷰 결과 보정 - `isFixed == true`이지만 `fixedAt == null`인 공지 fixture와 팬 Talk 활성 최상위 글 전체 개수/최신 1건 테스트를 추가했다. 보강 직후 `DefaultCreatorChannelHomeQueryRepositoryTest`에서 공지 assertion 실패를 확인했고, 공지 조회에 `fixedAt.isNotNull` 조건을 추가하고 팬 Talk 요약을 `count` 쿼리와 최신 `limit(1).fetchFirst()` 쿼리로 분리한 뒤 `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.adapter.out.persistence.DefaultCreatorChannelHomeQueryRepositoryTest --tests kr.co.vividnext.sodalive.v2.creator.channel.domain.CreatorChannelHomeQueryPolicyTest --tests kr.co.vividnext.sodalive.v2.creator.channel.application.CreatorChannelHomeQueryServiceTest`, `./gradlew ktlintCheck` 통과.
- 2026-06-12: Phase 3 조회 효율 개선 Task 문서화 - `findCreator`의 count/exists 중심 개선과 entity 전체 컬럼 조회 후 record mapping을 줄이는 작업을 `Task 3.7`~`Task 3.12`로 분리해 plan-task에 추가했다. QueryDSL `@QueryProjection`은 사용하지 않고, persistence adapter 내부 projection/tuple/bulk/count 쿼리로 개선하는 방향을 명시했다.
- 2026-06-12: Phase 3 조회 효율 개선 Task 문서화 검증 - 문서 변경 후 `./gradlew tasks --all`을 실행해 Gradle 명령 유효성을 확인했다. sandbox 권한에서는 `~/.gradle` lock 파일 접근 제한으로 실패했고, 권한 승격 후 `BUILD SUCCESSFUL`을 확인했다.
- 2026-06-12: 응답 계약 변경 문서화 - PRD와 plan-task에 `creator.characterId` 추가, 시리즈 응답의 `publishedDaysOfWeek`/`isComplete`/`isPopular` 제거를 반영했다. Phase 3의 미완료 항목 `Task 3.7`~`Task 3.12` 체크를 해제하고, 변경 반영 구현 항목을 `Task 3.13`, `Task 3.14`로 추가했다.
- 2026-06-12: 응답 계약 변경 문서 검증 - `./gradlew tasks --all`을 실행해 Gradle 명령 유효성을 확인했다. sandbox 권한에서는 `~/.gradle` lock 파일 접근 제한으로 실패했고, 권한 승격 후 `BUILD SUCCESSFUL`을 확인했다.
- 2026-06-12: Phase 3 projection 구현 방향 문서화 - `Task 3.7`~`Task 3.12`에서 record 생성자 인자로 바로 매핑 가능한 조회는 `Tuple` 조회 후 수동 재조립하지 않고 QueryDSL `Projections.constructor`를 사용하도록 task 문구를 보정했다.
- 2026-06-12: Phase 3 projection 구현 방향 문서 검증 - `./gradlew tasks --all`을 실행해 Gradle 명령 유효성을 확인했다. sandbox 권한에서는 `~/.gradle` lock 파일 접근 제한으로 실패했고, 권한 승격 후 `BUILD SUCCESSFUL`을 확인했다.
- 2026-06-13: Phase 3 Task 3.13/3.14 RED 확인 - `characterId`와 시리즈 응답 축소 계약 테스트를 먼저 추가한 뒤 `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.application.CreatorChannelHomeQueryServiceTest`, `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.adapter.out.persistence.DefaultCreatorChannelHomeQueryRepositoryTest` 실행 시 `Unresolved reference: characterId`, `No value passed for parameter 'publishedDaysOfWeek'/'isComplete'/'isPopular'` 컴파일 오류를 확인했다.
- 2026-06-13: Phase 3 Task 3.13/3.14 GREEN 확인 - creator record/domain/response에 nullable `characterId`를 추가하고 `findCreator`가 활성 `ChatCharacter` id를 조회하도록 보정했다. series record/domain/response/repository projection에서는 `publishedDaysOfWeek`, `isComplete`, `isPopular`를 제거했다. `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.application.CreatorChannelHomeQueryServiceTest --no-daemon`, `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.adapter.out.persistence.DefaultCreatorChannelHomeQueryRepositoryTest --no-daemon` 통과. `rg -n "publishedDaysOfWeek|SeriesPublishedDaysOfWeek|SeriesState|isComplete|isPopular" src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel` 결과 없음.
- 2026-06-13: Phase 3 보안 리뷰 보정 RED/GREEN 확인 - 리뷰어 게이트에서 유료 커뮤니티 게시글 비구매자에게 원문 `content``audioPath`가 노출될 수 있다는 차단 이슈를 확인했다. 비구매자/구매자/작성자 유료 커뮤니티 접근 테스트를 추가한 뒤 `DefaultCreatorChannelHomeQueryRepositoryTest`에서 `shouldMaskPaidCommunityContentAndAudioForNonBuyer` 실패를 확인했고, 기존 커뮤니티 목록과 동일하게 유료/미구매/비작성자 본문 축약 및 오디오 숨김을 반영한 뒤 `./gradlew clean test --tests kr.co.vividnext.sodalive.v2.creator.channel.adapter.out.persistence.DefaultCreatorChannelHomeQueryRepositoryTest --no-daemon` 통과.
- 2026-06-13: Phase 3 Task 3.7~3.12 constructor projection 조건 RED/GREEN 확인 - 직접 record 생성자 인자로 매핑 가능한 조회가 `Projections.constructor`를 사용하도록 source guardrail을 추가한 뒤 `shouldUseProjectionAndBulkQueriesForPhaseThreeOptimizedMethods` 실패를 확인했다. `findCurrentLive`, `findSchedules` live/audio 후보, `findFanTalkSummary` 최신 글, `findSns`를 QueryDSL `Projections.constructor`로 변경하고 후처리/마스킹/집계가 필요한 creator/audio/donation/community/series/activity 조회는 수동 조립을 유지했다. `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.adapter.out.persistence.DefaultCreatorChannelHomeQueryRepositoryTest --no-daemon`, `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.application.CreatorChannelHomeQueryServiceTest --no-daemon`, `./gradlew ktlintCheck --no-daemon` 통과. `rg -n "publishedDaysOfWeek|SeriesPublishedDaysOfWeek|SeriesState|isComplete|isPopular" src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel` 결과 없음.
- 2026-06-13: Phase 3 오디오 공개 조건 문서/구현 정합성 보정 - 공개 또는 예약 공개 오디오는 `releaseDate != null`이고, `releaseDate == null`은 삭제/미공개 데이터로 조회에서 제외한다는 전제를 PRD/plan-task에 반영했다. `releaseDate == null` 오디오가 최신/목록/첫 콘텐츠/시리즈 공개 콘텐츠 집계에서 제외되는 테스트를 추가했고, 보강 직후 `DefaultCreatorChannelHomeQueryRepositoryTest`에서 3개 실패를 확인했다. `findAudioContentRows`, `firstAudioContentId`, `seriesContentStats`의 공개 조건을 `releaseDate != null && releaseDate <= now`로 보정한 뒤 `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.adapter.out.persistence.DefaultCreatorChannelHomeQueryRepositoryTest --no-daemon` 통과.
- 2026-06-13: Phase 3 데뷔일 오디오 후보 정책 보정 - 오디오 데뷔 후보는 업로드 순서 기준 첫 3개만 보며, 1~2번째 삭제 오디오는 건너뛰고 3번째 삭제 오디오는 4번째로 넘어가지 않고 해당 `createdAt`을 후보로 쓰는 정책을 PRD/plan-task에 반영했다. 3번째 삭제 시 4번째 공개 오디오로 넘어가던 RED를 `DefaultCreatorChannelHomeQueryRepositoryTest`에서 확인했고, `findActivity``firstAudioDebutAt`으로 오디오 후보를 계산하도록 보정했다. `./gradlew test --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 --no-daemon`, `./gradlew ktlintCheck --no-daemon` 통과.
- 2026-06-13: Phase 3 P1/P2 리뷰 보정 RED/GREEN 확인 - 예약 오디오는 `duration != null && releaseDate != null && releaseDate > now`이면 `isActive`와 관계없이 스케줄 후보라는 정책과, 시리즈 신규 표시가 기존 목록처럼 7일 전/현재 시각 경계를 포함한다는 정책을 repository 테스트로 추가했다. 보강 직후 `DefaultCreatorChannelHomeQueryRepositoryTest`에서 2개 실패를 확인했고, `findSchedules` 예약 오디오 조건과 `newSeriesIds``between(now.minusDays(7), now)` 조건을 보정했다. `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.adapter.out.persistence.DefaultCreatorChannelHomeQueryRepositoryTest --no-daemon`, `./gradlew test --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 --no-daemon`, `./gradlew ktlintCheck --no-daemon` 통과.
- 2026-06-13: 채널 후원 응답 계약 변경 문서화 - 채널 후원 홈 응답에서 기본 후원 문구 조합과 공개 응답의 `donationId`/`memberId`/`isSecret`을 제거하고, 후원자 닉네임/프로필 이미지/후원 can/추가 메시지/UTC 생성 시각만 내려주는 요구사항을 PRD와 plan-task에 반영했다. Phase 3에 `Task 3.15`를 추가해 repository/service/controller 테스트와 구현 범위를 문서화했다. `git diff --check`, `./gradlew tasks --all --no-daemon` 통과.
- 2026-06-13: 채널 후원 비밀 후원 정책/Projection 구현 기준 문서화 - `Task 3.15`에 비밀 후원은 후원자 본인과 받은 크리에이터만 조회 가능하고 제3자는 조회할 수 없는 negative case를 추가했다. `isSecret`은 repository 조회 조건에만 사용하고 공개 응답에는 포함하지 않는다고 명시했으며, record 생성자 인자로 바로 매핑되는 조회는 QueryDSL `Projections.constructor`를 사용하도록 구현 기준을 보강했다. `git diff --check`, `./gradlew tasks --all --no-daemon` 통과.
- 2026-06-13: Phase 3 Task 3.16 RED/GREEN 확인 - 구매한 유료 커뮤니티 게시글은 크리에이터가 이후 삭제해 `CreatorCommunity.isActive == false`가 되어도 구매자에게 조회되고, 비구매자에게는 조회되지 않는 요구사항을 PRD/plan-task에 반영했다. `shouldExposeDeletedPaidCommunityContentToBuyer` 테스트 추가 직후 `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.adapter.out.persistence.DefaultCreatorChannelHomeQueryRepositoryTest --no-daemon`에서 해당 테스트 실패를 확인했고, `findCommunityPosts` 게시글 후보 조건을 `isActive == true` 또는 환불되지 않은 `PAID_COMMUNITY_POST` 구매 이력 존재로 보정했다. 구현 방식은 게시글 후보 조회 후 구매 여부/좋아요 수/댓글 수를 id 목록 기반 bulk 조회하는 기존 구조를 유지했다. 보정 후 같은 repository 테스트 통과.
- 2026-06-13: Phase 3 Task 3.16 정리 검증 - `git diff --check`, `./gradlew ktlintCheck --no-daemon`, `./gradlew tasks --all --no-daemon` 통과.
- 2026-06-13: Phase 3 리뷰 반영 RED/GREEN 확인 - `LiveRoomStatus` 자체가 아니라 라이브 홈 조회의 노출 정책만 보정하기 위해 현재/예약 라이브에 조회자 성별 제한과 크리에이터 입장 제한 테스트를 추가하고, 팬 Talk 작성자 닉네임의 `deleted_` prefix 제거 테스트를 추가했다. 보강 직후 `DefaultCreatorChannelHomeQueryRepositoryTest``viewerId`/`isViewerCreator`/`effectiveViewerGender` 미정의 컴파일 오류로 실패하는 것을 확인했고, `findCurrentLive`/`findSchedules`에 조회자 라이브 정책 입력을 추가해 라이브 후보만 필터링하고 `findFanTalkSummary` 최신 글 닉네임에 `removeDeletedNicknamePrefix()`를 적용했다. `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.adapter.out.persistence.DefaultCreatorChannelHomeQueryRepositoryTest --no-daemon` 통과.
- 2026-06-13: Phase 3 리뷰 반영 정리 검증 - `./gradlew test --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 --no-daemon`, `git diff --check`, `./gradlew ktlintCheck --no-daemon` 통과.
- 2026-06-13: Phase 3 P2/P3 리뷰 반영 RED/GREEN 확인 - query port 계약을 기존 raw 조회자 성별 파라미터명이 아니라 기존 라이브 목록 의미와 같은 `effectiveViewerGender`로 명확히 바꾸기 위해 repository 테스트 호출부를 먼저 변경했고, `DefaultCreatorChannelHomeQueryRepositoryTest`에서 `Cannot find a parameter with this name: effectiveViewerGender` 컴파일 실패를 확인했다. 이후 `CreatorChannelHomeQueryPort``DefaultCreatorChannelHomeQueryRepository`의 현재/예약 라이브 조회 계약을 `effectiveViewerGender`로 변경했다. PRD와 plan-task에는 현재/예약 라이브의 성별 제한·크리에이터 입장 제한 정책, service의 `Auth.gender` 우선 effective gender 산출, Phase 4 service fake port 테스트 요구사항을 명시했다. `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.adapter.out.persistence.DefaultCreatorChannelHomeQueryRepositoryTest --no-daemon`, `./gradlew test --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 --no-daemon`, `git diff --check`, `./gradlew ktlintCheck --no-daemon` 통과. `rg -n "viewer""Gender" docs/20260612_크리에이터_채널_홈_API src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel` 결과 없음.
- 2026-06-13: Phase 4 Task 4.1/4.2 RED 확인 - `CreatorChannelHomeQueryServiceTest`에 fake port 기반 정상 조립/최종 정책 테스트와 user_not_found/creator_not_found/blocked_access 예외 테스트를 추가한 뒤 `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.application.CreatorChannelHomeQueryServiceTest --no-daemon`에서 `Unresolved reference: CreatorChannelHomeQueryService`, `findCreatorRole overrides nothing` 컴파일 오류를 확인했다.
- 2026-06-13: Phase 4 Task 4.1/4.2 GREEN 확인 - `CreatorChannelHomeQueryService`를 추가해 port record를 domain 모델로 조립하고, `Auth.gender` 우선 `effectiveViewerGender`, 조회자 본인 여부, 성인 노출 정책, 최신 오디오 중복 제거, 스케줄 최종 제한/정렬/성인 제외, CloudFront URL 변환을 적용했다. 비크리에이터 예외 구분을 위해 `CreatorChannelHomeQueryPort.findCreatorRole`과 repository 구현을 최소 추가했다. `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.application.CreatorChannelHomeQueryServiceTest --no-daemon` 통과.
- 2026-06-13: Phase 4 정리 검증 - `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.domain.CreatorChannelHomeQueryPolicyTest --tests kr.co.vividnext.sodalive.v2.creator.channel.adapter.out.persistence.DefaultCreatorChannelHomeQueryRepositoryTest --no-daemon`, `./gradlew ktlintCheck --no-daemon`, `git diff --check` 통과. `rg -n "CreatorChannelHomeController|/api/v2/creator-channels" src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel` 결과 없음.
- 2026-06-13: Phase 4 리뷰 보정 RED/GREEN 확인 - 기존 채널 상세 정책과 동일하게 대상 회원 존재 확인 후 차단 관계를 먼저 검사하고 role 검사는 그 다음 수행하도록 조정했다. `findCreatorRole` 별도 port를 제거하고 `CreatorChannelCreatorRecord.role`로 기본 회원 조회 record에서 role을 함께 반환하게 변경했다. 차단 관계가 있으면 대상 회원이 비크리에이터여도 접근 차단 예외가 우선되는 service RED와, 비크리에이터 기본 회원도 role과 함께 반환되는 repository 계약 테스트를 추가했다. `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.application.CreatorChannelHomeQueryServiceTest --tests kr.co.vividnext.sodalive.v2.creator.channel.adapter.out.persistence.DefaultCreatorChannelHomeQueryRepositoryTest --no-daemon` 통과.
- 2026-06-13: Phase 5 Task 5.1 RED 확인 - `CreatorChannelHomeControllerTest`를 먼저 추가한 뒤 `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.adapter.in.web.CreatorChannelHomeControllerTest --no-daemon` 실행 시 `Unresolved reference: CreatorChannelHomeController` 컴파일 오류를 확인했다.
- 2026-06-13: Phase 5 Task 5.1/5.2 GREEN 확인 - `CreatorChannelHomeController`를 추가해 `GET /api/v2/creator-channels/{creatorId}/home` 인증 회원 endpoint, `common.error.bad_credentials` null guard, `ApiResponse.ok(CreatorChannelHomeResponse.from(...))` 응답을 구현했다. MockMvc 테스트로 비회원 요청 401, 인증 회원 요청의 service creatorId/viewer 전달, 최상위 JSON 필드와 boolean `is` prefix 계약을 고정했다. `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.adapter.in.web.CreatorChannelHomeControllerTest --no-daemon` 통과.
- 2026-06-13: Phase 5 회귀/정리 검증 - `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.adapter.in.web.CreatorChannelHomeControllerTest --tests kr.co.vividnext.sodalive.v2.creator.channel.application.CreatorChannelHomeQueryServiceTest --tests kr.co.vividnext.sodalive.v2.creator.channel.adapter.out.persistence.DefaultCreatorChannelHomeQueryRepositoryTest --no-daemon`, `./gradlew ktlintCheck --no-daemon`, `git diff --check`, `./gradlew test --no-daemon` 통과.
- 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 테스트를 단독 재실행해 통과를 확인했다.

View File

@@ -80,6 +80,7 @@
#### Requirements
- 크리에이터 기본 정보에는 다음 값을 포함한다.
- `creatorId`
- `characterId`
- `nickname`
- `profileImageUrl`
- `followerCount`
@@ -88,6 +89,7 @@
- `isFollow`
- `isNotify`
- `followerCount`는 활성 팔로우 수 기준으로 계산한다.
- `characterId`는 해당 `Member`와 연결된 활성 `ChatCharacter` ID를 내려주고, 활성 캐릭터가 없으면 `null`로 내려준다.
- `isAiChatAvailable`은 해당 `Member`와 연결된 활성 `ChatCharacter`가 있는지로 판단한다. 구현 후보는 `ChatCharacterRepository.existsByCreatorMemberId(creatorId)`를 기준으로 한다.
- `isDmAvailable``member.memberKind != MemberKind.AI_CHARACTER`이면 `true`, `AI_CHARACTER`이면 `false`로 판단한다.
- `isFollow`, `isNotify`는 인증 회원의 기존 `CreatorFollowing` 상태를 기준으로 내려준다.
@@ -103,6 +105,9 @@
- 현재 진행 중인 라이브는 기존 라이브 도메인의 `LiveRoomStatus.NOW` 의미와 동일하게 판단한다.
- 응답에는 라이브 ID, 제목, 커버 이미지, 시작 시각 UTC, 유료 여부 또는 가격, 성인 여부, 예약 여부가 아닌 현재 라이브 여부를 포함한다.
- 조회자의 성인 콘텐츠 노출 정책과 차단 정책을 반영한다.
- 현재 라이브 노출은 기존 라이브 목록 정책과 동일하게 성별 제한(`LiveRoom.genderRestriction`)과 크리에이터 입장 제한(`LiveRoom.isAvailableJoinCreator`)을 반영한다.
- 성별 제한 판단에 사용하는 조회자 성별은 기존 라이브 목록과 동일하게 `Auth.gender`가 있으면 이를 우선하고, 없으면 `Member.gender`를 사용하는 effective gender다.
- `LiveRoomStatus`는 라이브가 현재 진행 중인지/예약인지 구분하는 기준일 뿐, 라이브 노출 정책 자체로 사용하지 않는다.
#### Edge Cases
- 현재 진행 중인 라이브가 없으면 `currentLive``null`로 내려준다.
@@ -112,6 +117,7 @@
#### Requirements
- Figma 홈 상단에 노출되는 신규 오디오 콘텐츠 영역에 사용할 최신 공개 오디오 콘텐츠를 내려준다.
- 예약 공개 전 콘텐츠는 신규 오디오 콘텐츠로 노출하지 않는다.
- 공개 또는 예약 공개 오디오 콘텐츠는 항상 `releaseDate != null`인 데이터로 본다. `releaseDate == null`인 오디오 콘텐츠는 삭제/미공개 데이터로 보고 홈 오디오 조회에서 제외한다.
- 응답 필드는 홈 오디오 콘텐츠 카드와 동일하게 콘텐츠 ID, 제목, duration, 커버 이미지, 가격, 포인트 사용 가능 여부, 성인 여부를 포함한다.
- 정렬은 공개 시각 최신순이다.
@@ -122,13 +128,14 @@
#### Requirements
- 채널 후원은 최신순 최대 8개를 내려준다.
- 기존 `ChannelDonationService.getChannelDonationList` 응답 필드 의미를 유지한다.
- 조회 범위는 기존 채널 후원 목록과 동일하게 이번 달 기준으로 한다.
- 응답에는 후원 ID, 회원 ID, 닉네임, 프로필 이미지, 후원 can, 비밀 후원 여부, 메시지, 생성 시각 UTC를 포함한다.
- 비밀 후원 표시 정책은 기존 채널 후원 목록 정책을 따른다.
- 응답에는 후원 닉네임, 후원자 프로필 이미지, 후원 can 수, 후원 시 추가한 메시지, 생성 시각 UTC를 포함한다.
- `message`는 기본 문구(`"%s캔을 비밀후원하셨습니다."`, `"%s캔을 후원하셨습니다."`)를 조합하지 않고, 후원자가 입력한 추가 메시지(`ChannelDonationMessage.additionalMessage`)만 내려준다.
- 비밀 후원 노출/숨김 정책은 기존 채널 후원 목록 정책을 따른다.
#### Edge Cases
- 후원이 없으면 빈 배열을 내려준다.
- 후원자가 추가 메시지를 입력하지 않았으면 `message`는 빈 문자열로 내려준다.
### Feature F. 공지
@@ -136,12 +143,14 @@
- 커뮤니티 게시글 중 `isFixed == true`인 글을 홈의 공지 섹션으로 처리한다.
- 응답에는 게시글 ID, 크리에이터 ID, 크리에이터 닉네임, 크리에이터 프로필 이미지, 이미지 URL, 오디오 URL, 본문, 가격, 작성 시각 UTC, 구매 여부, 좋아요 수, 댓글 수를 포함한다.
- 홈 응답에 포함하는 게시글 요약 필드는 기존 크리에이터 커뮤니티 전체보기 게시글 리스트와 같은 의미를 유지한다.
- 정렬은 고정 시각 최신순을 우선하고, 고정 시각이 없으면 작성 시각 최신순으로 한다.
- 유료 공지는 조회자가 이미 구매했다면 크리에이터가 이후 삭제해 `isActive == false`가 되어도 기존 커뮤니티 전체보기 의미와 동일하게 구매자에게 조회된다.
- `isFixed == true`인 게시글은 항상 `fixedAt != null`인 데이터로 본다.
- 정렬은 고정 시각 최신순으로 한다.
- 공지 최대 노출 개수는 기존 고정 글 제한 정책에 맞춰 최대 3개로 한다.
#### Edge Cases
- 고정 게시글이 없으면 빈 배열을 내려준다.
- 성인 커뮤니티 글은 조회자의 성인 콘텐츠 노출 정책을 따른다.
- 성인 커뮤니티 글은 조회자의 성인 콘텐츠 노출 정책을 따른다. 조회자가 해당 글을 구매했더라도 성인 콘텐츠 노출 정책이 false이면 노출하지 않는다.
### Feature G. 스케줄
@@ -149,7 +158,7 @@
- 예약 라이브와 예약 업로드 오디오 콘텐츠를 하나의 스케줄 배열로 내려준다.
- 스케줄은 오늘 날짜와 가장 근접한 예약 항목 최대 3개를 내려준다.
- 예약 라이브는 `LiveRoomStatus.RESERVATION` 의미와 동일하게, `LiveRoom.beginDateTime > now`이고 활성 상태인 라이브를 대상으로 한다.
- 예약 업로드 오디오 콘텐츠는 `AudioContent.releaseDate > now` 활성 또는 예약 상태 콘텐츠를 대상으로 한다.
- 예약 업로드 오디오 콘텐츠는 `AudioContent.duration != null`, `AudioContent.releaseDate != null`, `AudioContent.releaseDate > now`인 콘텐츠를 대상으로 한다. `AudioContent.isActive` 값은 예약 스케줄 후보 판정에 사용하지 않는다.
- 응답에는 예약 날짜/시간 UTC, 제목, 타입, 대상 ID를 포함한다.
- 타입 값은 기존 추천 페이지의 `RecommendedActivityType` 코드 체계를 사용한다.
- 구현 시 `RecommendedActivityType``CreatorActivityType`으로 이름을 변경하고 공용 패키지로 이동한다.
@@ -160,6 +169,8 @@
- 정렬은 예약 날짜/시간 오름차순이다. 같은 예약 시간이면 라이브를 오디오보다 먼저 표시한다.
- 성인 예약 라이브/오디오는 조회자의 성인 노출 정책이 false이면 노출하지 않는다.
- 성인 노출 정책은 DB 조회 조건에 먼저 반영하고, 라이브/오디오 스케줄 후보를 service에서 합친 뒤에도 최종 응답 전 한 번 더 보정한다.
- 예약 라이브 스케줄은 기존 예약 라이브 목록 정책과 동일하게 성별 제한(`LiveRoom.genderRestriction`)과 크리에이터 입장 제한(`LiveRoom.isAvailableJoinCreator`)을 반영한다.
- 예약 라이브 성별 제한 판단에 사용하는 조회자 성별은 기존 라이브 목록과 동일하게 `Auth.gender`가 있으면 이를 우선하고, 없으면 `Member.gender`를 사용하는 effective gender다.
- service 최종 보정에 필요한 성인 여부는 내부 스케줄 후보 record/domain model에만 포함하고, 공개 스케줄 응답 필드에는 포함하지 않는다.
#### Edge Cases
@@ -171,6 +182,7 @@
- 최근 업로드된 오디오 콘텐츠를 최대 9개 내려준다.
- 신규 오디오 콘텐츠 영역과 오디오 목록 영역의 첫 번째 항목이 겹치지 않도록, 오디오 목록에서는 Feature D의 `latestAudioContent`로 내려간 가장 최신 콘텐츠를 제외한다.
- 예약 업로드 전 콘텐츠는 포함하지 않는다.
- `releaseDate == null`인 오디오 콘텐츠는 목록, 최신 콘텐츠, 첫 콘텐츠 판정에서 제외한다.
- 응답에는 다음 값을 포함한다.
- 오디오 콘텐츠 ID
- 제목
@@ -195,7 +207,9 @@
#### Requirements
- 시리즈는 최대 8개를 내려준다.
- 정렬은 각 시리즈에 속한 공개 콘텐츠의 최신 공개 시각 내림차순이다.
- 응답에는 기존 시리즈 카드 구성에 필요한 시리즈 ID, 제목, 커버 이미지, 연재 요일, 완결 여부, 콘텐츠 개수, 신규/인기 표시 정보를 포함한다.
- 응답에는 기존 시리즈 카드 구성에 필요한 시리즈 ID, 제목, 커버 이미지, 콘텐츠 개수, 신규 표시, 오리지널 시리즈 여부를 포함한다.
- 시리즈 응답에는 `publishedDaysOfWeek`, `isComplete`, `isPopular`를 포함하지 않는다.
- 시리즈의 공개 콘텐츠 집계와 정렬에서도 `releaseDate == null`인 오디오 콘텐츠는 제외한다.
- 성인 콘텐츠 노출 정책과 조회자 콘텐츠 타입 선호 정책은 기존 `ContentSeriesService.getSeriesList` 정책을 따른다.
#### Edge Cases
@@ -209,9 +223,11 @@
- 최대 3개를 최신순으로 내려준다.
- 응답에는 게시글 ID, 크리에이터 ID, 크리에이터 닉네임, 크리에이터 프로필 이미지, 이미지 URL, 오디오 URL, 본문, 가격, 작성 시각 UTC, 구매 여부, 좋아요 수, 댓글 수를 포함한다.
- 홈 응답에 포함하는 게시글 요약 필드는 기존 크리에이터 커뮤니티 전체보기 게시글 리스트와 같은 의미를 유지한다.
- 유료 커뮤니티 게시글은 조회자가 이미 구매했다면 크리에이터가 이후 삭제해 `isActive == false`가 되어도 기존 커뮤니티 전체보기 의미와 동일하게 구매자에게 조회된다.
#### Edge Cases
- 고정 공지는 커뮤니티 섹션에 중복 노출하지 않는다.
- 성인 커뮤니티 글은 조회자의 성인 콘텐츠 노출 정책을 따른다. 조회자가 해당 글을 구매했더라도 성인 콘텐츠 노출 정책이 false이면 노출하지 않는다.
- 커뮤니티 게시글이 없으면 빈 배열을 내려준다.
### Feature K. 팬 Talk
@@ -245,7 +261,11 @@
- 라이브 누적 참여자
- 업로드한 오디오 콘텐츠 개수
- 시리즈 개수
- 데뷔일은 첫 라이브 시작 시각과 첫 공개 오디오 콘텐츠 공개 시각 중 빠른 값으로 계산한다.
- 데뷔일은 첫 라이브 시작 시각과 첫 오디오 데뷔 후보 시각 중 빠른 값으로 계산한다.
- 오디오 데뷔 후보는 오디오 업로드 순서(`createdAt`, 동일 시각이면 `id`) 기준 첫 3개만 본다.
- 첫 번째 또는 두 번째 오디오가 삭제되어 `releaseDate == null`이면 다음 업로드 오디오의 공개 시각을 후보로 본다.
- 세 번째 오디오가 삭제되어 `releaseDate == null`이면 네 번째 오디오로 넘어가지 않고 세 번째 오디오의 `createdAt`을 후보로 본다.
- 세 번째 오디오까지 공개 후보가 없고 세 번째 삭제 오디오도 없으면 오디오 데뷔 후보는 없다.
- 라이브 진행 횟수, 라이브 누적 진행 시간, 라이브 누적 참여자는 기존 `ExplorerQueryRepository.getLiveCount`, `getLiveTime`, `getLiveContributorCount`의 의미를 기준으로 한다.
- 업로드한 오디오 콘텐츠 개수는 공개된 오디오 콘텐츠만 포함하고 예약 업로드는 반영하지 않는다.
- 시리즈 개수는 크리에이터의 활성 시리즈 개수를 기준으로 한다.

View File

@@ -0,0 +1,37 @@
package kr.co.vividnext.sodalive.v2.creator.channel.adapter.`in`.web
import kr.co.vividnext.sodalive.common.ApiResponse
import kr.co.vividnext.sodalive.common.SodaException
import kr.co.vividnext.sodalive.member.Member
import kr.co.vividnext.sodalive.v2.creator.channel.application.CreatorChannelHomeQueryService
import kr.co.vividnext.sodalive.v2.creator.channel.dto.CreatorChannelHomeResponse
import org.springframework.security.core.annotation.AuthenticationPrincipal
import org.springframework.web.bind.annotation.GetMapping
import org.springframework.web.bind.annotation.PathVariable
import org.springframework.web.bind.annotation.RequestMapping
import org.springframework.web.bind.annotation.RestController
@RestController
@RequestMapping("/api/v2/creator-channels")
class CreatorChannelHomeController(
private val creatorChannelHomeQueryService: CreatorChannelHomeQueryService
) {
@GetMapping("/{creatorId}/home")
fun getHome(
@PathVariable creatorId: Long,
@AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?
) = run {
ApiResponse.ok(
CreatorChannelHomeResponse.from(
creatorChannelHomeQueryService.getHome(
creatorId = creatorId,
viewer = requireMember(member)
)
)
)
}
private fun requireMember(member: Member?): Member {
return member ?: throw SodaException(messageKey = "common.error.bad_credentials")
}
}

View File

@@ -0,0 +1,5 @@
package kr.co.vividnext.sodalive.v2.creator.channel.adapter.out.persistence
import kr.co.vividnext.sodalive.v2.creator.channel.port.out.CreatorChannelHomeQueryPort
interface CreatorChannelHomeQueryRepository : CreatorChannelHomeQueryPort

View File

@@ -0,0 +1,915 @@
package kr.co.vividnext.sodalive.v2.creator.channel.adapter.out.persistence
import com.querydsl.core.types.Projections
import com.querydsl.core.types.dsl.BooleanExpression
import com.querydsl.core.types.dsl.Expressions
import com.querydsl.jpa.impl.JPAQueryFactory
import kr.co.vividnext.sodalive.can.use.CanUsage
import kr.co.vividnext.sodalive.can.use.QUseCan.useCan
import kr.co.vividnext.sodalive.chat.character.QChatCharacter.chatCharacter
import kr.co.vividnext.sodalive.content.ContentType
import kr.co.vividnext.sodalive.content.QAudioContent.audioContent
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
import kr.co.vividnext.sodalive.explorer.profile.channelDonation.QChannelDonationMessage.channelDonationMessage
import kr.co.vividnext.sodalive.explorer.profile.creatorCommunity.QCreatorCommunity.creatorCommunity
import kr.co.vividnext.sodalive.explorer.profile.creatorCommunity.comment.QCreatorCommunityComment.creatorCommunityComment
import kr.co.vividnext.sodalive.explorer.profile.creatorCommunity.like.QCreatorCommunityLike.creatorCommunityLike
import kr.co.vividnext.sodalive.extensions.removeDeletedNicknamePrefix
import kr.co.vividnext.sodalive.live.room.GenderRestriction
import kr.co.vividnext.sodalive.live.room.QLiveRoom.liveRoom
import kr.co.vividnext.sodalive.live.room.visit.QLiveRoomVisit.liveRoomVisit
import kr.co.vividnext.sodalive.member.Gender
import kr.co.vividnext.sodalive.member.MemberKind
import kr.co.vividnext.sodalive.member.QMember.member
import kr.co.vividnext.sodalive.member.block.QBlockMember
import kr.co.vividnext.sodalive.member.following.QCreatorFollowing.creatorFollowing
import kr.co.vividnext.sodalive.v2.common.domain.CreatorActivityType
import kr.co.vividnext.sodalive.v2.creator.channel.port.out.CreatorChannelActivityRecord
import kr.co.vividnext.sodalive.v2.creator.channel.port.out.CreatorChannelAudioContentRecord
import kr.co.vividnext.sodalive.v2.creator.channel.port.out.CreatorChannelCommunityPostRecord
import kr.co.vividnext.sodalive.v2.creator.channel.port.out.CreatorChannelCreatorRecord
import kr.co.vividnext.sodalive.v2.creator.channel.port.out.CreatorChannelDonationRecord
import kr.co.vividnext.sodalive.v2.creator.channel.port.out.CreatorChannelFanTalkRecord
import kr.co.vividnext.sodalive.v2.creator.channel.port.out.CreatorChannelFanTalkSummaryRecord
import kr.co.vividnext.sodalive.v2.creator.channel.port.out.CreatorChannelLiveRecord
import kr.co.vividnext.sodalive.v2.creator.channel.port.out.CreatorChannelScheduleRecord
import kr.co.vividnext.sodalive.v2.creator.channel.port.out.CreatorChannelSeriesRecord
import kr.co.vividnext.sodalive.v2.creator.channel.port.out.CreatorChannelSnsRecord
import org.springframework.stereotype.Repository
import java.time.Duration
import java.time.LocalDateTime
import java.time.ZoneId
import java.time.temporal.ChronoUnit
@Repository
class DefaultCreatorChannelHomeQueryRepository(
private val queryFactory: JPAQueryFactory
) : CreatorChannelHomeQueryRepository {
override fun findCreator(creatorId: Long, viewerId: Long?): CreatorChannelCreatorRecord? {
val creator = queryFactory
.select(member.id, member.role, member.nickname, member.profileImage, member.introduce, member.memberKind)
.from(member)
.where(
member.id.eq(creatorId),
member.isActive.isTrue
)
.fetchFirst() ?: return null
val following = viewerId?.let {
queryFactory
.select(creatorFollowing.isActive, creatorFollowing.isNotify)
.from(creatorFollowing)
.where(
creatorFollowing.member.id.eq(it),
creatorFollowing.creator.id.eq(creatorId),
creatorFollowing.isActive.isTrue
)
.fetchFirst()
}
val characterId = queryFactory
.select(chatCharacter.id)
.from(chatCharacter)
.where(
chatCharacter.creatorMember.id.eq(creatorId),
chatCharacter.isActive.isTrue
)
.fetchFirst()
return CreatorChannelCreatorRecord(
creatorId = creator.get(member.id)!!,
role = creator.get(member.role)!!,
characterId = characterId,
nickname = creator.get(member.nickname)!!,
profileImagePath = creator.get(member.profileImage),
introduce = creator.get(member.introduce)!!,
followerCount = queryFactory
.select(creatorFollowing.id.count())
.from(creatorFollowing)
.where(
creatorFollowing.creator.id.eq(creatorId),
creatorFollowing.isActive.isTrue,
creatorFollowing.member.isActive.isTrue
)
.fetchOne()
?.toInt()
?: 0,
isAiChatAvailable = characterId != null,
isDmAvailable = creator.get(member.memberKind) != MemberKind.AI_CHARACTER,
isFollow = following?.get(creatorFollowing.isActive) ?: false,
isNotify = following?.get(creatorFollowing.isNotify) ?: false
)
}
override fun existsBlockedBetween(viewerId: Long, creatorId: Long): Boolean {
val blockMember = QBlockMember("creatorChannelBlockMember")
return queryFactory
.select(blockMember.id)
.from(blockMember)
.where(
blockMember.isActive.isTrue,
blockMember.member.id.eq(viewerId).and(blockMember.blockedMember.id.eq(creatorId))
.or(blockMember.member.id.eq(creatorId).and(blockMember.blockedMember.id.eq(viewerId)))
)
.fetchFirst() != null
}
override fun findCurrentLive(
creatorId: Long,
now: LocalDateTime,
canViewAdultContent: Boolean,
viewerId: Long?,
isViewerCreator: Boolean,
effectiveViewerGender: Gender?
): CreatorChannelLiveRecord? {
return queryFactory
.select(
Projections.constructor(
CreatorChannelLiveRecord::class.java,
liveRoom.id,
liveRoom.title,
liveRoom.coverImage,
liveRoom.beginDateTime,
liveRoom.price,
liveRoom.isAdult
)
)
.from(liveRoom)
.where(
liveRoom.member.id.eq(creatorId),
liveRoom.member.isActive.isTrue,
liveRoom.isActive.isTrue,
liveRoom.channelName.isNotNull,
liveRoom.channelName.isNotEmpty,
liveRoom.beginDateTime.loe(now),
adultLiveCondition(canViewAdultContent),
genderLiveCondition(viewerId, effectiveViewerGender),
creatorJoinLiveCondition(viewerId, isViewerCreator)
)
.orderBy(liveRoom.beginDateTime.desc(), liveRoom.id.desc())
.fetchFirst()
}
override fun findLatestAudioContent(
creatorId: Long,
now: LocalDateTime,
canViewAdultContent: Boolean
): CreatorChannelAudioContentRecord? {
val row = findAudioContentRows(creatorId, now, null, canViewAdultContent, 1).firstOrNull() ?: return null
return row.toAudioRecord(
firstContentId = firstAudioContentId(creatorId, now, canViewAdultContent),
seriesByContentId = audioSeriesByContentIds(listOf(itAudioId(row)))
)
}
override fun findChannelDonations(
creatorId: Long,
viewerId: Long?,
now: LocalDateTime,
limit: Int
): List<CreatorChannelDonationRecord> {
val kstZoneId = ZoneId.of("Asia/Seoul")
val utcZoneId = ZoneId.of("UTC")
val nowKst = now.atZone(utcZoneId).withZoneSameInstant(kstZoneId)
val start = nowKst.toLocalDate().withDayOfMonth(1).atStartOfDay(kstZoneId)
.withZoneSameInstant(utcZoneId)
.toLocalDateTime()
val end = nowKst.toLocalDate().withDayOfMonth(1).atStartOfDay(kstZoneId).plusMonths(1)
.withZoneSameInstant(utcZoneId)
.toLocalDateTime()
return queryFactory
.select(
Projections.constructor(
CreatorChannelDonationRecord::class.java,
channelDonationMessage.member.nickname,
channelDonationMessage.member.profileImage,
channelDonationMessage.can,
channelDonationMessage.additionalMessage.coalesce(""),
channelDonationMessage.createdAt
)
)
.from(channelDonationMessage)
.where(
channelDonationMessage.creator.id.eq(creatorId),
channelDonationMessage.createdAt.goe(start),
channelDonationMessage.createdAt.lt(end),
donationVisibilityCondition(creatorId, viewerId)
)
.orderBy(channelDonationMessage.createdAt.desc(), channelDonationMessage.id.desc())
.limit(limit.toLong())
.fetch()
}
override fun findCommunityPosts(
creatorId: Long,
viewerId: Long?,
isFixed: Boolean,
canViewAdultContent: Boolean,
limit: Int
): List<CreatorChannelCommunityPostRecord> {
val posts = queryFactory
.select(
creatorCommunity.id,
creatorCommunity.member.id,
creatorCommunity.member.nickname,
creatorCommunity.member.profileImage,
creatorCommunity.imagePath,
creatorCommunity.audioPath,
creatorCommunity.content,
creatorCommunity.price,
creatorCommunity.createdAt,
creatorCommunity.fixedAt,
creatorCommunity.isFixed,
creatorCommunity.isCommentAvailable
)
.from(creatorCommunity)
.where(
creatorCommunity.member.id.eq(creatorId),
creatorCommunity.member.isActive.isTrue,
visibleCommunityPostCondition(viewerId),
creatorCommunity.isFixed.eq(isFixed),
fixedNoticeCondition(isFixed),
adultCommunityCondition(canViewAdultContent)
)
.orderBy(
if (isFixed) creatorCommunity.fixedAt.desc() else creatorCommunity.createdAt.desc(),
creatorCommunity.id.desc()
)
.limit(limit.toLong())
.fetch()
val postIds = posts.map { it.get(creatorCommunity.id)!! }
val orderedPostIds = orderedCommunityPostIds(creatorId, viewerId, postIds)
val likeCounts = communityLikeCounts(postIds)
val commentCounts = communityCommentCounts(
postIds = posts.filter { it.get(creatorCommunity.isCommentAvailable)!! }.map { it.get(creatorCommunity.id)!! },
viewerId = viewerId,
isContentCreator = viewerId == creatorId
)
return posts
.map {
val postId = it.get(creatorCommunity.id)!!
val postCreatorId = it.get(creatorCommunity.member.id)!!
val isFixedPost = it.get(creatorCommunity.isFixed)!!
val price = it.get(creatorCommunity.price)!!
val existOrdered = postId in orderedPostIds
val canAccessPaidContent = canAccessPaidCommunityContent(
price = price,
viewerId = viewerId,
creatorId = postCreatorId,
existOrdered = existOrdered
)
CreatorChannelCommunityPostRecord(
postId = postId,
creatorId = postCreatorId,
creatorNickname = it.get(creatorCommunity.member.nickname)!!,
creatorProfilePath = it.get(creatorCommunity.member.profileImage),
imagePath = it.get(creatorCommunity.imagePath),
audioPath = if (canAccessPaidContent) it.get(creatorCommunity.audioPath) else null,
content = maskPaidCommunityContent(
content = it.get(creatorCommunity.content)!!,
canAccessPaidContent = canAccessPaidContent
),
price = price,
date = if (isFixedPost) {
it.get(creatorCommunity.fixedAt) ?: it.get(creatorCommunity.createdAt)!!
} else {
it.get(creatorCommunity.createdAt)!!
},
existOrdered = existOrdered,
likeCount = likeCounts[postId] ?: 0,
commentCount = commentCounts[postId] ?: 0
)
}
}
override fun findSchedules(
creatorId: Long,
now: LocalDateTime,
canViewAdultContent: Boolean,
viewerId: Long?,
isViewerCreator: Boolean,
effectiveViewerGender: Gender?,
limit: Int
): List<CreatorChannelScheduleRecord> {
val liveSchedules = queryFactory
.select(
Projections.constructor(
CreatorChannelScheduleRecord::class.java,
liveRoom.beginDateTime,
liveRoom.title,
Expressions.constant(CreatorActivityType.LIVE),
liveRoom.id,
liveRoom.isAdult
)
)
.from(liveRoom)
.where(
liveRoom.member.id.eq(creatorId),
liveRoom.member.isActive.isTrue,
liveRoom.isActive.isTrue,
liveRoom.channelName.isNull.or(liveRoom.channelName.isEmpty),
liveRoom.beginDateTime.gt(now),
adultLiveCondition(canViewAdultContent),
genderLiveCondition(viewerId, effectiveViewerGender),
creatorJoinLiveCondition(viewerId, isViewerCreator)
)
.fetch()
val audioSchedules = queryFactory
.select(
Projections.constructor(
CreatorChannelScheduleRecord::class.java,
audioContent.releaseDate,
audioContent.title,
Expressions.constant(CreatorActivityType.AUDIO),
audioContent.id,
audioContent.isAdult
)
)
.from(audioContent)
.where(
audioContent.member.id.eq(creatorId),
audioContent.member.isActive.isTrue,
audioContent.duration.isNotNull,
audioContent.releaseDate.isNotNull,
audioContent.releaseDate.gt(now),
adultAudioCondition(canViewAdultContent)
)
.fetch()
return (liveSchedules + audioSchedules)
.sortedWith(compareBy<CreatorChannelScheduleRecord> { it.scheduledAt }.thenBy { it.type.sortOrder })
.take(limit)
}
override fun findAudioContents(
creatorId: Long,
now: LocalDateTime,
latestAudioContentId: Long?,
canViewAdultContent: Boolean,
limit: Int
): List<CreatorChannelAudioContentRecord> {
val rows = findAudioContentRows(creatorId, now, latestAudioContentId, canViewAdultContent, limit)
val firstContentId = firstAudioContentId(creatorId, now, canViewAdultContent)
val seriesByContentId = audioSeriesByContentIds(rows.map { itAudioId(it) })
return rows.map { it.toAudioRecord(firstContentId, seriesByContentId) }
}
override fun findSeries(
creatorId: Long,
viewerId: Long?,
now: LocalDateTime,
canViewAdultContent: Boolean,
contentType: ContentType,
limit: Int
): List<CreatorChannelSeriesRecord> {
val seriesRows = queryFactory
.select(
series.id,
series.title,
series.coverImage,
series.isOriginal
)
.from(series)
.where(
series.member.id.eq(creatorId),
series.member.isActive.isTrue,
series.isActive.isTrue,
adultSeriesCondition(canViewAdultContent),
contentTypeSeriesCondition(canViewAdultContent, contentType),
notBlockedSeriesCreatorCondition(viewerId)
)
.fetch()
val seriesIds = seriesRows.map { it.get(series.id)!! }
val contentStats = seriesContentStats(seriesIds, now, canViewAdultContent)
val newSeriesIds = newSeriesIds(seriesIds, now, canViewAdultContent)
return seriesRows
.mapNotNull { seriesRow ->
contentStats[seriesRow.get(series.id)!!]?.let { seriesRow to it }
}
.sortedByDescending { it.second.latestPublishedAt }
.take(limit)
.map { (seriesRow, stats) ->
val seriesId = seriesRow.get(series.id)!!
CreatorChannelSeriesRecord(
seriesId = seriesId,
title = seriesRow.get(series.title)!!,
coverImagePath = seriesRow.get(series.coverImage),
numberOfContent = stats.contentCount,
isNew = seriesId in newSeriesIds,
isOriginal = seriesRow.get(series.isOriginal)!!
)
}
}
override fun findFanTalkSummary(creatorId: Long, viewerId: Long?): CreatorChannelFanTalkSummaryRecord {
val totalCount = queryFactory
.select(creatorCheers.id.count())
.from(creatorCheers)
.where(fanTalkSummaryCondition(creatorId, viewerId))
.fetchOne()
?.toInt()
?: 0
val latestTalk = queryFactory
.select(
Projections.constructor(
CreatorChannelFanTalkRecord::class.java,
creatorCheers.id,
creatorCheers.member.id,
creatorCheers.member.nickname,
creatorCheers.member.profileImage,
creatorCheers.cheers,
creatorCheers.languageCode,
creatorCheers.createdAt
)
)
.from(creatorCheers)
.where(fanTalkSummaryCondition(creatorId, viewerId))
.orderBy(creatorCheers.createdAt.desc(), creatorCheers.id.desc())
.limit(1)
.fetchFirst()
?.let { it.copy(nickname = it.nickname.removeDeletedNicknamePrefix()) }
return CreatorChannelFanTalkSummaryRecord(
totalCount = totalCount,
latestFanTalk = latestTalk
)
}
override fun findActivity(creatorId: Long, now: LocalDateTime): CreatorChannelActivityRecord {
val firstLiveAt = queryFactory
.select(liveRoom.beginDateTime.min())
.from(liveRoom)
.where(liveRoom.member.id.eq(creatorId), liveRoom.channelName.isNotNull, liveRoom.beginDateTime.loe(now))
.fetchFirst()
val firstAudioAt = firstAudioDebutAt(creatorId, now)
val debutDate = listOfNotNull(firstLiveAt, firstAudioAt).minOrNull()
return CreatorChannelActivityRecord(
debutDate = debutDate,
dDay = debutDate?.let { "D+${ChronoUnit.DAYS.between(it.toLocalDate(), now.toLocalDate())}" }.orEmpty(),
liveCount = queryFactory
.select(liveRoom.id.count())
.from(liveRoom)
.where(liveRoom.member.id.eq(creatorId), liveRoom.channelName.isNotNull)
.fetchOne()
?: 0L,
liveDurationHours = liveDurationHours(creatorId),
liveContributorCount = queryFactory
.select(liveRoomVisit.member.id.count())
.from(liveRoomVisit)
.innerJoin(liveRoomVisit.room, liveRoom)
.where(liveRoom.member.id.eq(creatorId), liveRoom.channelName.isNotNull)
.fetchOne()
?: 0L,
audioContentCount = queryFactory
.select(audioContent.id.count())
.from(audioContent)
.where(
audioContent.member.id.eq(creatorId),
audioContent.isActive.isTrue,
audioContent.releaseDate.isNotNull,
audioContent.releaseDate.loe(now)
)
.fetchOne()
?: 0L,
seriesCount = queryFactory
.select(series.id.count())
.from(series)
.where(series.member.id.eq(creatorId), series.isActive.isTrue)
.fetchOne()
?: 0L
)
}
override fun findSns(creatorId: Long): CreatorChannelSnsRecord {
return queryFactory
.select(
Projections.constructor(
CreatorChannelSnsRecord::class.java,
member.instagramUrl.coalesce(""),
member.fancimmUrl.coalesce(""),
member.xUrl.coalesce(""),
member.youtubeUrl.coalesce(""),
member.websiteUrl.coalesce("")
)
)
.from(member)
.where(member.id.eq(creatorId))
.fetchFirst()
?: CreatorChannelSnsRecord(
instagramUrl = "",
fancimmUrl = "",
xUrl = "",
youtubeUrl = "",
kakaoOpenChatUrl = ""
)
}
private fun findAudioContentRows(
creatorId: Long,
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(
audioContent.member.id.eq(creatorId),
audioContent.member.isActive.isTrue,
audioContent.isActive.isTrue,
audioContent.duration.isNotNull,
audioContent.releaseDate.isNotNull,
audioContent.releaseDate.loe(now),
excludedContentId?.let { audioContent.id.ne(it) },
adultAudioCondition(canViewAdultContent)
)
.orderBy(audioContent.releaseDate.desc(), audioContent.id.desc())
.limit(limit.toLong())
.fetch()
private fun itAudioId(row: com.querydsl.core.Tuple): Long = row.get(audioContent.id)!!
private fun com.querydsl.core.Tuple.toAudioRecord(
firstContentId: Long?,
seriesByContentId: Map<Long, AudioSeriesSummary>
): CreatorChannelAudioContentRecord {
val audioContentId = get(audioContent.id)!!
val seriesSummary = seriesByContentId[audioContentId]
return CreatorChannelAudioContentRecord(
audioContentId = audioContentId,
title = get(audioContent.title)!!,
duration = get(audioContent.duration),
imagePath = get(audioContent.coverImage),
price = get(audioContent.price)!!,
isAdult = get(audioContent.isAdult)!!,
isPointAvailable = get(audioContent.isPointAvailable)!!,
isFirstContent = firstContentId == audioContentId,
publishedAt = get(audioContent.releaseDate)!!,
seriesName = seriesSummary?.title,
isOriginalSeries = seriesSummary?.isOriginal
)
}
private fun audioSeriesByContentIds(contentIds: List<Long>): Map<Long, AudioSeriesSummary> {
if (contentIds.isEmpty()) return emptyMap()
return queryFactory
.select(seriesContent.content.id, series.title, series.isOriginal)
.from(seriesContent)
.innerJoin(seriesContent.series, series)
.where(seriesContent.content.id.`in`(contentIds))
.fetch()
.associate {
it.get(seriesContent.content.id)!! to AudioSeriesSummary(
title = it.get(series.title)!!,
isOriginal = it.get(series.isOriginal)!!
)
}
}
private fun firstAudioContentId(creatorId: Long, now: LocalDateTime, canViewAdultContent: Boolean): Long? {
return queryFactory
.select(audioContent.id)
.from(audioContent)
.where(
audioContent.member.id.eq(creatorId),
audioContent.member.isActive.isTrue,
audioContent.isActive.isTrue,
audioContent.duration.isNotNull,
audioContent.releaseDate.isNotNull,
audioContent.releaseDate.loe(now),
adultAudioCondition(canViewAdultContent)
)
.orderBy(audioContent.releaseDate.asc(), audioContent.id.asc())
.fetchFirst()
}
private fun orderedCommunityPostIds(creatorId: Long, viewerId: Long?, postIds: List<Long>): Set<Long> {
if (viewerId == null || postIds.isEmpty()) return emptySet()
if (viewerId == creatorId) return postIds.toSet()
return queryFactory
.select(useCan.communityPost.id)
.from(useCan)
.where(
useCan.member.id.eq(viewerId),
useCan.communityPost.id.`in`(postIds),
useCan.canUsage.eq(CanUsage.PAID_COMMUNITY_POST),
useCan.isRefund.isFalse
)
.fetch()
.toSet()
}
private fun communityLikeCounts(postIds: List<Long>): Map<Long, Int> {
if (postIds.isEmpty()) return emptyMap()
return queryFactory
.select(creatorCommunityLike.creatorCommunity.id, creatorCommunityLike.id.count())
.from(creatorCommunityLike)
.where(creatorCommunityLike.creatorCommunity.id.`in`(postIds), creatorCommunityLike.isActive.isTrue)
.groupBy(creatorCommunityLike.creatorCommunity.id)
.fetch()
.associate {
it.get(creatorCommunityLike.creatorCommunity.id)!! to
(it.get(creatorCommunityLike.id.count())?.toInt() ?: 0)
}
}
private fun communityCommentCounts(postIds: List<Long>, viewerId: Long?, isContentCreator: Boolean): Map<Long, Int> {
if (postIds.isEmpty()) return emptyMap()
var where = creatorCommunityComment.creatorCommunity.id.`in`(postIds)
.and(creatorCommunityComment.isActive.isTrue)
.and(creatorCommunityComment.parent.isNull)
if (viewerId != null) {
where = where
.and(creatorCommunityComment.member.id.notIn(blockedMemberIdSubQuery(viewerId)))
.and(creatorCommunityComment.member.id.notIn(blockingMemberIdSubQuery(viewerId)))
}
if (!isContentCreator) {
where = where.and(
creatorCommunityComment.isSecret.isFalse.or(
viewerId?.let { creatorCommunityComment.member.id.eq(it) }
?: creatorCommunityComment.isSecret.isFalse
)
)
}
return queryFactory
.select(creatorCommunityComment.creatorCommunity.id, creatorCommunityComment.id.count())
.from(creatorCommunityComment)
.where(where)
.groupBy(creatorCommunityComment.creatorCommunity.id)
.fetch()
.associate {
it.get(creatorCommunityComment.creatorCommunity.id)!! to
(it.get(creatorCommunityComment.id.count())?.toInt() ?: 0)
}
}
private fun blockedMemberIdSubQuery(viewerId: Long) = QBlockMember("communityCommentViewerBlock").let { viewerBlock ->
queryFactory
.select(viewerBlock.blockedMember.id)
.from(viewerBlock)
.where(viewerBlock.member.id.eq(viewerId), viewerBlock.isActive.isTrue)
}
private fun blockingMemberIdSubQuery(viewerId: Long) = QBlockMember("communityCommentWriterBlock").let { writerBlock ->
queryFactory
.select(writerBlock.member.id)
.from(writerBlock)
.where(writerBlock.blockedMember.id.eq(viewerId), writerBlock.isActive.isTrue)
}
private fun canAccessPaidCommunityContent(
price: Int,
viewerId: Long?,
creatorId: Long,
existOrdered: Boolean
): Boolean {
return price <= 0 || viewerId == creatorId || existOrdered
}
private fun maskPaidCommunityContent(content: String, canAccessPaidContent: Boolean): String {
if (canAccessPaidContent) return content
val length = content.codePointCount(0, content.length)
val endIndex = if (length > 15) {
content.offsetByCodePoints(0, 15)
} else {
content.offsetByCodePoints(0, length / 2)
}
return content.substring(0, endIndex).plus("...")
}
private fun firstAudioDebutAt(creatorId: Long, now: LocalDateTime): LocalDateTime? {
val firstThreeUploads = queryFactory
.select(audioContent.releaseDate, audioContent.createdAt)
.from(audioContent)
.where(
audioContent.member.id.eq(creatorId),
audioContent.duration.isNotNull
)
.orderBy(audioContent.createdAt.asc(), audioContent.id.asc())
.limit(3)
.fetch()
val firstPublishedAt = firstThreeUploads
.mapNotNull { it.get(audioContent.releaseDate) }
.firstOrNull { !it.isAfter(now) }
if (firstPublishedAt != null) return firstPublishedAt
val thirdUpload = firstThreeUploads.getOrNull(2) ?: return null
return if (thirdUpload.get(audioContent.releaseDate) == null) {
thirdUpload.get(audioContent.createdAt)
} else {
null
}
}
private fun liveDurationHours(creatorId: Long): Long {
return queryFactory
.select(liveRoom.beginDateTime, liveRoom.updatedAt)
.from(liveRoom)
.where(liveRoom.member.id.eq(creatorId), liveRoom.channelName.isNotNull)
.fetch()
.sumOf { Duration.between(it.get(liveRoom.beginDateTime), it.get(liveRoom.updatedAt)).toSeconds() } / 3600
}
private fun adultLiveCondition(canViewAdultContent: Boolean): BooleanExpression? {
return if (canViewAdultContent) null else liveRoom.isAdult.isFalse
}
private fun adultAudioCondition(canViewAdultContent: Boolean): BooleanExpression? {
return if (canViewAdultContent) null else audioContent.isAdult.isFalse
}
private fun genderLiveCondition(viewerId: Long?, effectiveViewerGender: Gender?): BooleanExpression? {
if (effectiveViewerGender == null || effectiveViewerGender == Gender.NONE) return null
val genderCondition = when (effectiveViewerGender) {
Gender.MALE -> liveRoom.genderRestriction.`in`(GenderRestriction.ALL, GenderRestriction.MALE_ONLY)
Gender.FEMALE -> liveRoom.genderRestriction.`in`(GenderRestriction.ALL, GenderRestriction.FEMALE_ONLY)
Gender.NONE -> return null
}
return viewerId?.let { genderCondition.or(liveRoom.member.id.eq(it)) } ?: genderCondition
}
private fun creatorJoinLiveCondition(viewerId: Long?, isViewerCreator: Boolean): BooleanExpression? {
if (!isViewerCreator || viewerId == null) return null
return liveRoom.isAvailableJoinCreator.isTrue.or(liveRoom.member.id.eq(viewerId))
}
private fun adultCommunityCondition(canViewAdultContent: Boolean): BooleanExpression? {
return if (canViewAdultContent) null else creatorCommunity.isAdult.isFalse
}
private fun fixedNoticeCondition(isFixed: Boolean): BooleanExpression? {
return if (isFixed) creatorCommunity.fixedAt.isNotNull else null
}
private fun visibleCommunityPostCondition(viewerId: Long?): BooleanExpression {
val activePost = creatorCommunity.isActive.isTrue
if (viewerId == null) return activePost
return activePost.or(
queryFactory
.select(useCan.id)
.from(useCan)
.where(
useCan.member.id.eq(viewerId),
useCan.communityPost.id.eq(creatorCommunity.id),
useCan.canUsage.eq(CanUsage.PAID_COMMUNITY_POST),
useCan.isRefund.isFalse
)
.exists()
)
}
private fun adultSeriesCondition(canViewAdultContent: Boolean): BooleanExpression? {
return if (canViewAdultContent) null else series.isAdult.isFalse
}
private fun contentTypeSeriesCondition(
canViewAdultContent: Boolean,
contentType: ContentType
): BooleanExpression? {
if (!canViewAdultContent || contentType == ContentType.ALL) return null
return series.member.isNull.or(
series.member.auth.gender.eq(
if (contentType == ContentType.MALE) 0 else 1
)
)
}
private fun notBlockedSeriesCreatorCondition(viewerId: Long?): BooleanExpression? {
if (viewerId == null) return null
val seriesCreatorBlock = QBlockMember("seriesCreatorBlockViewer")
return queryFactory
.select(seriesCreatorBlock.id)
.from(seriesCreatorBlock)
.where(
seriesCreatorBlock.isActive.isTrue,
seriesCreatorBlock.member.id.eq(series.member.id).and(seriesCreatorBlock.blockedMember.id.eq(viewerId))
.or(seriesCreatorBlock.member.id.eq(viewerId).and(seriesCreatorBlock.blockedMember.id.eq(series.member.id)))
)
.exists()
.not()
}
private fun donationVisibilityCondition(creatorId: Long, viewerId: Long?): BooleanExpression? {
return if (viewerId == null) {
channelDonationMessage.isSecret.isFalse
} else if (viewerId == creatorId) {
null
} else {
channelDonationMessage.isSecret.isFalse.or(channelDonationMessage.member.id.eq(viewerId))
}
}
private fun seriesContentStats(
seriesIds: List<Long>,
now: LocalDateTime,
canViewAdultContent: Boolean
): Map<Long, SeriesContentStats> {
if (seriesIds.isEmpty()) return emptyMap()
val publishedAt = audioContent.releaseDate.coalesce(audioContent.createdAt)
return queryFactory
.select(seriesContent.series.id, seriesContent.id.count(), publishedAt.max())
.from(seriesContent)
.innerJoin(seriesContent.content, audioContent)
.where(
seriesContent.series.id.`in`(seriesIds),
audioContent.isActive.isTrue,
audioContent.duration.isNotNull,
audioContent.releaseDate.isNotNull,
audioContent.releaseDate.loe(now),
adultAudioCondition(canViewAdultContent)
)
.groupBy(seriesContent.series.id)
.fetch()
.associate {
it.get(seriesContent.series.id)!! to SeriesContentStats(
contentCount = it.get(seriesContent.id.count())?.toInt() ?: 0,
latestPublishedAt = it.get(publishedAt.max())!!
)
}
}
private fun newSeriesIds(
seriesIds: List<Long>,
now: LocalDateTime,
canViewAdultContent: Boolean
): Set<Long> {
if (seriesIds.isEmpty()) return emptySet()
return queryFactory
.select(seriesContent.series.id)
.from(seriesContent)
.innerJoin(seriesContent.content, audioContent)
.where(
seriesContent.series.id.`in`(seriesIds),
audioContent.isActive.isTrue,
audioContent.duration.isNotNull,
audioContent.releaseDate.between(now.minusDays(7), now),
adultAudioCondition(canViewAdultContent)
)
.fetch()
.toSet()
}
private fun notBlockedFanTalkWriterCondition(viewerId: Long?): BooleanExpression? {
if (viewerId == null) return null
val viewerBlock = QBlockMember("viewerBlockFanTalkWriter")
val writerBlock = QBlockMember("writerBlockViewerFanTalk")
return creatorCheers.member.id.notIn(
queryFactory
.select(viewerBlock.blockedMember.id)
.from(viewerBlock)
.where(viewerBlock.member.id.eq(viewerId), viewerBlock.isActive.isTrue)
).and(
creatorCheers.member.id.notIn(
queryFactory
.select(writerBlock.member.id)
.from(writerBlock)
.where(writerBlock.blockedMember.id.eq(viewerId), writerBlock.isActive.isTrue)
)
)
}
private fun fanTalkSummaryCondition(creatorId: Long, viewerId: Long?): BooleanExpression {
return creatorCheers.creator.id.eq(creatorId)
.and(creatorCheers.isActive.isTrue)
.and(creatorCheers.parent.isNull)
.and(notBlockedFanTalkWriterCondition(viewerId))
}
private val CreatorActivityType.sortOrder: Int
get() = when (this) {
CreatorActivityType.LIVE -> 0
else -> 1
}
private data class AudioSeriesSummary(
val title: String,
val isOriginal: Boolean
)
private data class SeriesContentStats(
val contentCount: Int,
val latestPublishedAt: LocalDateTime
)
}

View File

@@ -0,0 +1,265 @@
package kr.co.vividnext.sodalive.v2.creator.channel.application
import kr.co.vividnext.sodalive.common.SodaException
import kr.co.vividnext.sodalive.i18n.LangContext
import kr.co.vividnext.sodalive.i18n.SodaMessageSource
import kr.co.vividnext.sodalive.member.Gender
import kr.co.vividnext.sodalive.member.Member
import kr.co.vividnext.sodalive.member.MemberRole
import kr.co.vividnext.sodalive.member.contentpreference.MemberContentPreferenceService
import kr.co.vividnext.sodalive.member.contentpreference.isAdultVisibleByPolicy
import kr.co.vividnext.sodalive.v2.creator.channel.domain.CreatorChannelActivity
import kr.co.vividnext.sodalive.v2.creator.channel.domain.CreatorChannelAudioContent
import kr.co.vividnext.sodalive.v2.creator.channel.domain.CreatorChannelCommunityPost
import kr.co.vividnext.sodalive.v2.creator.channel.domain.CreatorChannelCreator
import kr.co.vividnext.sodalive.v2.creator.channel.domain.CreatorChannelDonation
import kr.co.vividnext.sodalive.v2.creator.channel.domain.CreatorChannelFanTalk
import kr.co.vividnext.sodalive.v2.creator.channel.domain.CreatorChannelFanTalkSummary
import kr.co.vividnext.sodalive.v2.creator.channel.domain.CreatorChannelHome
import kr.co.vividnext.sodalive.v2.creator.channel.domain.CreatorChannelHomeQueryPolicy
import kr.co.vividnext.sodalive.v2.creator.channel.domain.CreatorChannelLive
import kr.co.vividnext.sodalive.v2.creator.channel.domain.CreatorChannelSchedule
import kr.co.vividnext.sodalive.v2.creator.channel.domain.CreatorChannelSeries
import kr.co.vividnext.sodalive.v2.creator.channel.domain.CreatorChannelSns
import kr.co.vividnext.sodalive.v2.creator.channel.port.out.CreatorChannelActivityRecord
import kr.co.vividnext.sodalive.v2.creator.channel.port.out.CreatorChannelAudioContentRecord
import kr.co.vividnext.sodalive.v2.creator.channel.port.out.CreatorChannelCommunityPostRecord
import kr.co.vividnext.sodalive.v2.creator.channel.port.out.CreatorChannelCreatorRecord
import kr.co.vividnext.sodalive.v2.creator.channel.port.out.CreatorChannelDonationRecord
import kr.co.vividnext.sodalive.v2.creator.channel.port.out.CreatorChannelFanTalkRecord
import kr.co.vividnext.sodalive.v2.creator.channel.port.out.CreatorChannelFanTalkSummaryRecord
import kr.co.vividnext.sodalive.v2.creator.channel.port.out.CreatorChannelHomeQueryPort
import kr.co.vividnext.sodalive.v2.creator.channel.port.out.CreatorChannelLiveRecord
import kr.co.vividnext.sodalive.v2.creator.channel.port.out.CreatorChannelScheduleRecord
import kr.co.vividnext.sodalive.v2.creator.channel.port.out.CreatorChannelSeriesRecord
import kr.co.vividnext.sodalive.v2.creator.channel.port.out.CreatorChannelSnsRecord
import org.springframework.beans.factory.annotation.Value
import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional
import java.time.LocalDateTime
@Service
@Transactional(readOnly = true)
class CreatorChannelHomeQueryService(
private val queryPort: CreatorChannelHomeQueryPort,
private val queryPolicy: CreatorChannelHomeQueryPolicy,
private val memberContentPreferenceService: MemberContentPreferenceService,
private val messageSource: SodaMessageSource,
private val langContext: LangContext,
@Value("\${cloud.aws.cloud-front.host}")
private val cloudFrontHost: String
) {
fun getHome(
creatorId: Long,
viewer: Member,
now: LocalDateTime = LocalDateTime.now()
): CreatorChannelHome {
val viewerId = viewer.id!!
val creator = queryPort.findCreator(creatorId, viewerId)
?: throw SodaException(messageKey = "member.validation.user_not_found")
if (queryPort.existsBlockedBetween(viewerId, creatorId)) {
val messageTemplate = messageSource
.getMessage("explorer.creator.blocked_access", langContext.lang)
.orEmpty()
throw SodaException(message = String.format(messageTemplate, creator.nickname))
}
validateCreatorRole(creator)
val preference = memberContentPreferenceService.getStoredPreference(viewer)
val canViewAdultContent = isAdultVisibleByPolicy(viewer, preference.isAdultContentVisible)
val isViewerCreator = viewerId == creatorId
val effectiveViewerGender = viewer.effectiveGender()
val latestAudioContent = queryPort
.findLatestAudioContent(creatorId, now, canViewAdultContent)
?.toDomain()
val audioContents = queryPolicy.excludeLatestAudioContent(
queryPort.findAudioContents(
creatorId = creatorId,
now = now,
latestAudioContentId = latestAudioContent?.audioContentId,
canViewAdultContent = canViewAdultContent
).map { it.toDomain() },
latestAudioContent?.audioContentId
)
return CreatorChannelHome(
creator = creator.toDomain(),
currentLive = queryPort.findCurrentLive(
creatorId = creatorId,
now = now,
canViewAdultContent = canViewAdultContent,
viewerId = viewerId,
isViewerCreator = isViewerCreator,
effectiveViewerGender = effectiveViewerGender
)?.toDomain(),
latestAudioContent = latestAudioContent,
channelDonations = queryPort.findChannelDonations(creatorId, viewerId, now).map { it.toDomain() },
notices = queryPort.findCommunityPosts(
creatorId = creatorId,
viewerId = viewerId,
isFixed = true,
canViewAdultContent = canViewAdultContent
).map { it.toDomain() },
schedules = queryPolicy.limitSchedules(
queryPort.findSchedules(
creatorId = creatorId,
now = now,
canViewAdultContent = canViewAdultContent,
viewerId = viewerId,
isViewerCreator = isViewerCreator,
effectiveViewerGender = effectiveViewerGender
).map { it.toDomain() },
now,
canViewAdultContent
),
audioContents = audioContents,
series = queryPort.findSeries(
creatorId = creatorId,
viewerId = viewerId,
now = now,
canViewAdultContent = canViewAdultContent,
contentType = preference.contentType
).map { it.toDomain() },
communities = queryPort.findCommunityPosts(
creatorId = creatorId,
viewerId = viewerId,
isFixed = false,
canViewAdultContent = canViewAdultContent
).map { it.toDomain() },
fanTalk = queryPort.findFanTalkSummary(creatorId, viewerId).toDomain(),
introduce = creator.introduce,
activity = queryPort.findActivity(creatorId, now).toDomain(),
sns = queryPort.findSns(creatorId).toDomain()
)
}
private fun validateCreatorRole(creator: CreatorChannelCreatorRecord) {
when (creator.role) {
MemberRole.CREATOR -> return
else -> throw SodaException(messageKey = "member.validation.creator_not_found")
}
}
private fun Member.effectiveGender(): Gender {
auth?.let { return if (it.gender == 1) Gender.MALE else Gender.FEMALE }
return gender
}
private fun CreatorChannelCreatorRecord.toDomain() = CreatorChannelCreator(
creatorId = creatorId,
characterId = characterId,
nickname = nickname,
profileImageUrl = profileImagePath.toCdnUrl() ?: defaultProfileImageUrl(),
followerCount = followerCount,
isAiChatAvailable = isAiChatAvailable,
isDmAvailable = isDmAvailable,
isFollow = isFollow,
isNotify = isNotify
)
private fun CreatorChannelLiveRecord.toDomain() = CreatorChannelLive(
liveId = liveId,
title = title,
coverImageUrl = coverImagePath.toCdnUrl(),
beginDateTime = beginDateTime,
price = price,
isAdult = isAdult
)
private fun CreatorChannelAudioContentRecord.toDomain() = CreatorChannelAudioContent(
audioContentId = audioContentId,
title = title,
duration = duration,
imageUrl = imagePath.toCdnUrl(),
price = price,
isAdult = isAdult,
isPointAvailable = isPointAvailable,
isFirstContent = isFirstContent,
publishedAt = publishedAt,
seriesName = seriesName,
isOriginalSeries = isOriginalSeries
)
private fun CreatorChannelDonationRecord.toDomain() = CreatorChannelDonation(
nickname = nickname,
profileImageUrl = profileImagePath.toCdnUrl() ?: defaultProfileImageUrl(),
can = can,
message = message,
createdAt = createdAt
)
private fun CreatorChannelScheduleRecord.toDomain() = CreatorChannelSchedule(
scheduledAt = scheduledAt,
title = title,
type = type,
targetId = targetId,
isAdult = isAdult
)
private fun CreatorChannelSeriesRecord.toDomain() = CreatorChannelSeries(
seriesId = seriesId,
title = title,
coverImageUrl = coverImagePath.toCdnUrl().orEmpty(),
numberOfContent = numberOfContent,
isNew = isNew,
isOriginal = isOriginal
)
private fun CreatorChannelCommunityPostRecord.toDomain() = CreatorChannelCommunityPost(
postId = postId,
creatorId = creatorId,
creatorNickname = creatorNickname,
creatorProfileUrl = creatorProfilePath.toCdnUrl() ?: defaultProfileImageUrl(),
imageUrl = imagePath.toCdnUrl(),
audioUrl = audioPath.toCdnUrl(),
content = content,
price = price,
date = date,
existOrdered = existOrdered,
likeCount = likeCount,
commentCount = commentCount
)
private fun CreatorChannelFanTalkSummaryRecord.toDomain() = CreatorChannelFanTalkSummary(
totalCount = totalCount,
latestFanTalk = latestFanTalk?.toDomain()
)
private fun CreatorChannelFanTalkRecord.toDomain() = CreatorChannelFanTalk(
fanTalkId = fanTalkId,
memberId = memberId,
nickname = nickname,
profileImageUrl = profileImagePath.toCdnUrl() ?: defaultProfileImageUrl(),
content = content,
languageCode = languageCode,
createdAt = createdAt
)
private fun CreatorChannelActivityRecord.toDomain() = CreatorChannelActivity(
debutDate = debutDate,
dDay = dDay,
liveCount = liveCount,
liveDurationHours = liveDurationHours,
liveContributorCount = liveContributorCount,
audioContentCount = audioContentCount,
seriesCount = seriesCount
)
private fun CreatorChannelSnsRecord.toDomain() = CreatorChannelSns(
instagramUrl = instagramUrl,
fancimmUrl = fancimmUrl,
xUrl = xUrl,
youtubeUrl = youtubeUrl,
kakaoOpenChatUrl = kakaoOpenChatUrl
)
private fun String?.toCdnUrl(): String? {
if (isNullOrBlank()) return null
if (startsWith("https://") || startsWith("http://")) return this
return "$cloudFrontHost/$this"
}
private fun defaultProfileImageUrl(): String = "$cloudFrontHost/profile/default-profile.png"
}

View File

@@ -21,6 +21,7 @@ data class CreatorChannelHome(
data class CreatorChannelCreator(
val creatorId: Long,
val characterId: Long?,
val nickname: String,
val profileImageUrl: String,
val followerCount: Int,
@@ -54,12 +55,9 @@ data class CreatorChannelAudioContent(
)
data class CreatorChannelDonation(
val donationId: Long,
val memberId: Long,
val nickname: String,
val profileImageUrl: String,
val can: Int,
val isSecret: Boolean,
val message: String,
val createdAt: LocalDateTime
)
@@ -76,11 +74,8 @@ data class CreatorChannelSeries(
val seriesId: Long,
val title: String,
val coverImageUrl: String,
val publishedDaysOfWeek: String,
val isComplete: Boolean,
val numberOfContent: Int,
val isNew: Boolean,
val isPopular: Boolean,
val isOriginal: Boolean
)

View File

@@ -1,8 +1,10 @@
package kr.co.vividnext.sodalive.v2.creator.channel.domain
import kr.co.vividnext.sodalive.v2.common.domain.CreatorActivityType
import org.springframework.stereotype.Component
import java.time.LocalDateTime
@Component
class CreatorChannelHomeQueryPolicy {
fun limitSchedules(
schedules: List<CreatorChannelSchedule>,

View File

@@ -55,6 +55,7 @@ data class CreatorChannelHomeResponse(
data class CreatorChannelCreatorResponse(
val creatorId: Long,
val characterId: Long?,
val nickname: String,
val profileImageUrl: String,
val followerCount: Int,
@@ -71,6 +72,7 @@ data class CreatorChannelCreatorResponse(
fun from(creator: CreatorChannelCreator): CreatorChannelCreatorResponse {
return CreatorChannelCreatorResponse(
creatorId = creator.creatorId,
characterId = creator.characterId,
nickname = creator.nickname,
profileImageUrl = creator.profileImageUrl,
followerCount = creator.followerCount,
@@ -141,25 +143,18 @@ data class CreatorChannelAudioContentResponse(
}
data class CreatorChannelDonationResponse(
val donationId: Long,
val memberId: Long,
val nickname: String,
val profileImageUrl: String,
val can: Int,
@JsonProperty("isSecret")
val isSecret: Boolean,
val message: String,
val createdAtUtc: String
) {
companion object {
fun from(donation: CreatorChannelDonation): CreatorChannelDonationResponse {
return CreatorChannelDonationResponse(
donationId = donation.donationId,
memberId = donation.memberId,
nickname = donation.nickname,
profileImageUrl = donation.profileImageUrl,
can = donation.can,
isSecret = donation.isSecret,
message = donation.message,
createdAtUtc = donation.createdAt.toUtcIso()
)
@@ -189,14 +184,9 @@ data class CreatorChannelSeriesResponse(
val seriesId: Long,
val title: String,
val coverImageUrl: String,
val publishedDaysOfWeek: String,
@JsonProperty("isComplete")
val isComplete: Boolean,
val numberOfContent: Int,
@JsonProperty("isNew")
val isNew: Boolean,
@JsonProperty("isPopular")
val isPopular: Boolean,
@JsonProperty("isOriginal")
val isOriginal: Boolean
) {
@@ -206,11 +196,8 @@ data class CreatorChannelSeriesResponse(
seriesId = series.seriesId,
title = series.title,
coverImageUrl = series.coverImageUrl,
publishedDaysOfWeek = series.publishedDaysOfWeek,
isComplete = series.isComplete,
numberOfContent = series.numberOfContent,
isNew = series.isNew,
isPopular = series.isPopular,
isOriginal = series.isOriginal
)
}

View File

@@ -0,0 +1,186 @@
package kr.co.vividnext.sodalive.v2.creator.channel.port.out
import kr.co.vividnext.sodalive.content.ContentType
import kr.co.vividnext.sodalive.member.Gender
import kr.co.vividnext.sodalive.member.MemberRole
import kr.co.vividnext.sodalive.v2.common.domain.CreatorActivityType
import java.time.LocalDateTime
interface CreatorChannelHomeQueryPort {
fun findCreator(creatorId: Long, viewerId: Long?): CreatorChannelCreatorRecord?
fun existsBlockedBetween(viewerId: Long, creatorId: Long): Boolean
fun findCurrentLive(
creatorId: Long,
now: LocalDateTime,
canViewAdultContent: Boolean,
viewerId: Long?,
isViewerCreator: Boolean,
effectiveViewerGender: Gender?
): CreatorChannelLiveRecord?
fun findLatestAudioContent(
creatorId: Long,
now: LocalDateTime,
canViewAdultContent: Boolean
): CreatorChannelAudioContentRecord?
fun findChannelDonations(
creatorId: Long,
viewerId: Long?,
now: LocalDateTime,
limit: Int = 8
): List<CreatorChannelDonationRecord>
fun findCommunityPosts(
creatorId: Long,
viewerId: Long?,
isFixed: Boolean,
canViewAdultContent: Boolean,
limit: Int = 3
): List<CreatorChannelCommunityPostRecord>
fun findSchedules(
creatorId: Long,
now: LocalDateTime,
canViewAdultContent: Boolean,
viewerId: Long?,
isViewerCreator: Boolean,
effectiveViewerGender: Gender?,
limit: Int = 3
): List<CreatorChannelScheduleRecord>
fun findAudioContents(
creatorId: Long,
now: LocalDateTime,
latestAudioContentId: Long?,
canViewAdultContent: Boolean,
limit: Int = 9
): List<CreatorChannelAudioContentRecord>
fun findSeries(
creatorId: Long,
viewerId: Long?,
now: LocalDateTime,
canViewAdultContent: Boolean,
contentType: ContentType,
limit: Int = 8
): List<CreatorChannelSeriesRecord>
fun findFanTalkSummary(creatorId: Long, viewerId: Long?): CreatorChannelFanTalkSummaryRecord
fun findActivity(creatorId: Long, now: LocalDateTime): CreatorChannelActivityRecord
fun findSns(creatorId: Long): CreatorChannelSnsRecord
}
data class CreatorChannelCreatorRecord(
val creatorId: Long,
val role: MemberRole,
val characterId: Long?,
val nickname: String,
val profileImagePath: String?,
val introduce: String,
val followerCount: Int,
val isAiChatAvailable: Boolean,
val isDmAvailable: Boolean,
val isFollow: Boolean,
val isNotify: Boolean
)
data class CreatorChannelLiveRecord(
val liveId: Long,
val title: String,
val coverImagePath: String?,
val beginDateTime: LocalDateTime,
val price: Int,
val isAdult: Boolean
)
data class CreatorChannelAudioContentRecord(
val audioContentId: Long,
val title: String,
val duration: String?,
val imagePath: String?,
val price: Int,
val isAdult: Boolean,
val isPointAvailable: Boolean,
val isFirstContent: Boolean,
val publishedAt: LocalDateTime,
val seriesName: String?,
val isOriginalSeries: Boolean?
)
data class CreatorChannelDonationRecord(
val nickname: String,
val profileImagePath: String?,
val can: Int,
val message: String,
val createdAt: LocalDateTime
)
data class CreatorChannelScheduleRecord(
val scheduledAt: LocalDateTime,
val title: String,
val type: CreatorActivityType,
val targetId: Long,
val isAdult: Boolean
)
data class CreatorChannelSeriesRecord(
val seriesId: Long,
val title: String,
val coverImagePath: String?,
val numberOfContent: Int,
val isNew: Boolean,
val isOriginal: Boolean
)
data class CreatorChannelCommunityPostRecord(
val postId: Long,
val creatorId: Long,
val creatorNickname: String,
val creatorProfilePath: String?,
val imagePath: String?,
val audioPath: String?,
val content: String,
val price: Int,
val date: LocalDateTime,
val existOrdered: Boolean,
val likeCount: Int,
val commentCount: Int
)
data class CreatorChannelFanTalkSummaryRecord(
val totalCount: Int,
val latestFanTalk: CreatorChannelFanTalkRecord?
)
data class CreatorChannelFanTalkRecord(
val fanTalkId: Long,
val memberId: Long,
val nickname: String,
val profileImagePath: String?,
val content: String,
val languageCode: String?,
val createdAt: LocalDateTime
)
data class CreatorChannelActivityRecord(
val debutDate: LocalDateTime?,
val dDay: String,
val liveCount: Long,
val liveDurationHours: Long,
val liveContributorCount: Long,
val audioContentCount: Long,
val seriesCount: Long
)
data class CreatorChannelSnsRecord(
val instagramUrl: String,
val fancimmUrl: String,
val xUrl: String,
val youtubeUrl: String,
val kakaoOpenChatUrl: String
)

View File

@@ -0,0 +1,276 @@
package kr.co.vividnext.sodalive.v2.creator.channel.adapter.`in`.web
import kr.co.vividnext.sodalive.common.CountryContext
import kr.co.vividnext.sodalive.i18n.LangContext
import kr.co.vividnext.sodalive.i18n.SodaMessageSource
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.v2.common.domain.CreatorActivityType
import kr.co.vividnext.sodalive.v2.creator.channel.application.CreatorChannelHomeQueryService
import kr.co.vividnext.sodalive.v2.creator.channel.domain.CreatorChannelActivity
import kr.co.vividnext.sodalive.v2.creator.channel.domain.CreatorChannelAudioContent
import kr.co.vividnext.sodalive.v2.creator.channel.domain.CreatorChannelCommunityPost
import kr.co.vividnext.sodalive.v2.creator.channel.domain.CreatorChannelCreator
import kr.co.vividnext.sodalive.v2.creator.channel.domain.CreatorChannelDonation
import kr.co.vividnext.sodalive.v2.creator.channel.domain.CreatorChannelFanTalk
import kr.co.vividnext.sodalive.v2.creator.channel.domain.CreatorChannelFanTalkSummary
import kr.co.vividnext.sodalive.v2.creator.channel.domain.CreatorChannelHome
import kr.co.vividnext.sodalive.v2.creator.channel.domain.CreatorChannelLive
import kr.co.vividnext.sodalive.v2.creator.channel.domain.CreatorChannelSchedule
import kr.co.vividnext.sodalive.v2.creator.channel.domain.CreatorChannelSeries
import kr.co.vividnext.sodalive.v2.creator.channel.domain.CreatorChannelSns
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.WebMvcTest
import org.springframework.boot.test.context.TestConfiguration
import org.springframework.boot.test.mock.mockito.MockBean
import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Import
import org.springframework.http.HttpStatus
import org.springframework.security.config.annotation.web.builders.HttpSecurity
import org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.anonymous
import org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.user
import org.springframework.security.web.SecurityFilterChain
import org.springframework.security.web.authentication.HttpStatusEntryPoint
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 java.time.LocalDateTime
import javax.servlet.http.HttpServletResponse
@WebMvcTest(CreatorChannelHomeController::class)
@Import(CreatorChannelHomeControllerTest.TestSecurityConfig::class)
class CreatorChannelHomeControllerTest @Autowired constructor(
private val mockMvc: MockMvc
) {
@MockBean
private lateinit var service: CreatorChannelHomeQueryService
@MockBean
private lateinit var countryContext: CountryContext
@MockBean
private lateinit var langContext: LangContext
@MockBean
private lateinit var sodaMessageSource: SodaMessageSource
@TestConfiguration
class TestSecurityConfig {
@Bean
fun securityFilterChain(http: HttpSecurity): SecurityFilterChain {
return http
.csrf().disable()
.authorizeRequests()
.anyRequest().authenticated()
.and()
.exceptionHandling()
.authenticationEntryPoint(HttpStatusEntryPoint(HttpStatus.UNAUTHORIZED))
.accessDeniedHandler { _, response, _ -> response.sendError(HttpServletResponse.SC_FORBIDDEN) }
.and()
.build()
}
}
@Test
@DisplayName("크리에이터 채널 홈 조회는 비회원 요청을 거부한다")
fun shouldRejectAnonymousCreatorChannelHomeRequest() {
mockMvc.perform(
get("/api/v2/creator-channels/1/home")
.with(anonymous())
)
.andExpect(status().isUnauthorized)
}
@Test
@DisplayName("크리에이터 채널 홈 조회는 인증 회원과 creatorId를 서비스에 전달하고 성공 응답을 반환한다")
fun shouldReturnCreatorChannelHomeForAuthenticatedMember() {
val viewer = createMember(id = 10L)
Mockito.doReturn(createHome()).`when`(service).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.creator").exists())
.andExpect(jsonPath("$.data.currentLive").exists())
.andExpect(jsonPath("$.data.latestAudioContent").exists())
.andExpect(jsonPath("$.data.channelDonations").isArray)
.andExpect(jsonPath("$.data.notices").isArray)
.andExpect(jsonPath("$.data.schedules").isArray)
.andExpect(jsonPath("$.data.audioContents").isArray)
.andExpect(jsonPath("$.data.series").isArray)
.andExpect(jsonPath("$.data.communities").isArray)
.andExpect(jsonPath("$.data.fanTalk").exists())
.andExpect(jsonPath("$.data.introduce").value("introduce"))
.andExpect(jsonPath("$.data.activity").exists())
.andExpect(jsonPath("$.data.sns").exists())
.andExpect(jsonPath("$.data.creator.creatorId").value(1L))
.andExpect(jsonPath("$.data.creator.characterId").value(11L))
.andExpect(jsonPath("$.data.creator.isAiChatAvailable").value(true))
.andExpect(jsonPath("$.data.creator.isDmAvailable").value(false))
.andExpect(jsonPath("$.data.latestAudioContent.isPointAvailable").value(true))
.andExpect(jsonPath("$.data.latestAudioContent.isFirstContent").value(true))
.andExpect(jsonPath("$.data.latestAudioContent.isOriginalSeries").value(true))
.andExpect(jsonPath("$.data.currentLive.isAdult").value(true))
.andExpect(jsonPath("$.data.schedules[0].isAdult").doesNotExist())
.andExpect(jsonPath("$.data.channelDonations[0].donationId").doesNotExist())
.andExpect(jsonPath("$.data.channelDonations[0].memberId").doesNotExist())
.andExpect(jsonPath("$.data.channelDonations[0].isSecret").doesNotExist())
.andExpect(jsonPath("$.data.series[0].isNew").value(true))
.andExpect(jsonPath("$.data.series[0].isOriginal").value(true))
Mockito.verify(service).getHome(
Mockito.eq(1L),
Mockito.eq(viewer) ?: viewer,
Mockito.any(LocalDateTime::class.java) ?: LocalDateTime.now()
)
}
private fun createMember(id: Long): Member {
return Member(
email = "viewer$id@test.com",
password = "password",
nickname = "viewer$id",
role = MemberRole.USER
).apply {
this.id = id
}
}
private fun createHome(): CreatorChannelHome {
val post = CreatorChannelCommunityPost(
postId = 301L,
creatorId = 1L,
creatorNickname = "creator",
creatorProfileUrl = "profile.png",
imageUrl = "image.png",
audioUrl = "audio.mp3",
content = "notice",
price = 10,
date = LocalDateTime.of(2026, 6, 12, 4, 0),
existOrdered = true,
likeCount = 2,
commentCount = 3
)
return CreatorChannelHome(
creator = CreatorChannelCreator(
creatorId = 1L,
characterId = 11L,
nickname = "creator",
profileImageUrl = "profile.png",
followerCount = 100,
isAiChatAvailable = true,
isDmAvailable = false,
isFollow = true,
isNotify = false
),
currentLive = CreatorChannelLive(
liveId = 101L,
title = "live",
coverImageUrl = "live.png",
beginDateTime = LocalDateTime.of(2026, 6, 12, 1, 0),
price = 20,
isAdult = true
),
latestAudioContent = CreatorChannelAudioContent(
audioContentId = 201L,
title = "audio",
duration = "00:10:00",
imageUrl = "audio.png",
price = 30,
isAdult = true,
isPointAvailable = true,
isFirstContent = true,
publishedAt = LocalDateTime.of(2026, 6, 11, 1, 0),
seriesName = "series",
isOriginalSeries = true
),
channelDonations = listOf(
CreatorChannelDonation(
nickname = "fan",
profileImageUrl = "fan.png",
can = 50,
message = "thanks",
createdAt = LocalDateTime.of(2026, 6, 12, 2, 0)
)
),
notices = listOf(post),
schedules = listOf(
CreatorChannelSchedule(
scheduledAt = LocalDateTime.of(2026, 6, 12, 3, 0),
title = "schedule",
type = CreatorActivityType.LIVE,
targetId = 501L,
isAdult = false
)
),
audioContents = listOf(
CreatorChannelAudioContent(
audioContentId = 202L,
title = "audio2",
duration = null,
imageUrl = null,
price = 0,
isAdult = false,
isPointAvailable = false,
isFirstContent = false,
publishedAt = LocalDateTime.of(2026, 6, 10, 1, 0),
seriesName = null,
isOriginalSeries = null
)
),
series = listOf(
CreatorChannelSeries(
seriesId = 601L,
title = "series",
coverImageUrl = "series.png",
numberOfContent = 3,
isNew = true,
isOriginal = true
)
),
communities = listOf(post.copy(postId = 302L, content = "community")),
fanTalk = CreatorChannelFanTalkSummary(
totalCount = 1,
latestFanTalk = CreatorChannelFanTalk(
fanTalkId = 701L,
memberId = 2L,
nickname = "fan",
profileImageUrl = "fan.png",
content = "hello",
languageCode = "ko",
createdAt = LocalDateTime.of(2026, 6, 12, 5, 0)
)
),
introduce = "introduce",
activity = CreatorChannelActivity(
debutDate = LocalDateTime.of(2026, 6, 12, 6, 0),
dDay = "D+1",
liveCount = 10,
liveDurationHours = 20,
liveContributorCount = 30,
audioContentCount = 40,
seriesCount = 50
),
sns = CreatorChannelSns(
instagramUrl = "instagram",
fancimmUrl = "fancimm",
xUrl = "x",
youtubeUrl = "youtube",
kakaoOpenChatUrl = "kakao"
)
)
}
}

View File

@@ -1,6 +1,18 @@
package kr.co.vividnext.sodalive.v2.creator.channel.application
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
import kr.co.vividnext.sodalive.common.SodaException
import kr.co.vividnext.sodalive.content.ContentType
import kr.co.vividnext.sodalive.i18n.Lang
import kr.co.vividnext.sodalive.i18n.LangContext
import kr.co.vividnext.sodalive.i18n.SodaMessageSource
import kr.co.vividnext.sodalive.member.Gender
import kr.co.vividnext.sodalive.member.Member
import kr.co.vividnext.sodalive.member.MemberProvider
import kr.co.vividnext.sodalive.member.MemberRole
import kr.co.vividnext.sodalive.member.auth.Auth
import kr.co.vividnext.sodalive.member.contentpreference.MemberContentPreferenceService
import kr.co.vividnext.sodalive.member.contentpreference.ViewerContentPreference
import kr.co.vividnext.sodalive.v2.common.domain.CreatorActivityType
import kr.co.vividnext.sodalive.v2.creator.channel.domain.CreatorChannelActivity
import kr.co.vividnext.sodalive.v2.creator.channel.domain.CreatorChannelAudioContent
@@ -10,22 +22,152 @@ import kr.co.vividnext.sodalive.v2.creator.channel.domain.CreatorChannelDonation
import kr.co.vividnext.sodalive.v2.creator.channel.domain.CreatorChannelFanTalk
import kr.co.vividnext.sodalive.v2.creator.channel.domain.CreatorChannelFanTalkSummary
import kr.co.vividnext.sodalive.v2.creator.channel.domain.CreatorChannelHome
import kr.co.vividnext.sodalive.v2.creator.channel.domain.CreatorChannelHomeQueryPolicy
import kr.co.vividnext.sodalive.v2.creator.channel.domain.CreatorChannelLive
import kr.co.vividnext.sodalive.v2.creator.channel.domain.CreatorChannelSchedule
import kr.co.vividnext.sodalive.v2.creator.channel.domain.CreatorChannelSeries
import kr.co.vividnext.sodalive.v2.creator.channel.domain.CreatorChannelSns
import kr.co.vividnext.sodalive.v2.creator.channel.dto.CreatorChannelHomeResponse
import kr.co.vividnext.sodalive.v2.creator.channel.port.out.CreatorChannelActivityRecord
import kr.co.vividnext.sodalive.v2.creator.channel.port.out.CreatorChannelAudioContentRecord
import kr.co.vividnext.sodalive.v2.creator.channel.port.out.CreatorChannelCommunityPostRecord
import kr.co.vividnext.sodalive.v2.creator.channel.port.out.CreatorChannelCreatorRecord
import kr.co.vividnext.sodalive.v2.creator.channel.port.out.CreatorChannelDonationRecord
import kr.co.vividnext.sodalive.v2.creator.channel.port.out.CreatorChannelFanTalkRecord
import kr.co.vividnext.sodalive.v2.creator.channel.port.out.CreatorChannelFanTalkSummaryRecord
import kr.co.vividnext.sodalive.v2.creator.channel.port.out.CreatorChannelHomeQueryPort
import kr.co.vividnext.sodalive.v2.creator.channel.port.out.CreatorChannelLiveRecord
import kr.co.vividnext.sodalive.v2.creator.channel.port.out.CreatorChannelScheduleRecord
import kr.co.vividnext.sodalive.v2.creator.channel.port.out.CreatorChannelSeriesRecord
import kr.co.vividnext.sodalive.v2.creator.channel.port.out.CreatorChannelSnsRecord
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertFalse
import org.junit.jupiter.api.Assertions.assertNotNull
import org.junit.jupiter.api.Assertions.assertNull
import org.junit.jupiter.api.Assertions.assertThrows
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.DisplayName
import org.junit.jupiter.api.Test
import org.mockito.Mockito
import java.time.LocalDateTime
class CreatorChannelHomeQueryServiceTest {
private val objectMapper = jacksonObjectMapper()
@Test
@DisplayName("크리에이터 채널 홈 서비스는 모든 섹션을 조립하고 최종 정책을 적용한다")
fun shouldAssembleCreatorChannelHomeWithFinalPolicies() {
val port = FakeCreatorChannelHomeQueryPort()
val service = createService(port, canViewAdultContent = false)
val viewer = createMember(id = 10L, gender = Gender.FEMALE, authGender = 1)
val now = LocalDateTime.of(2026, 6, 13, 10, 0)
val home = service.getHome(creatorId = 1L, viewer = viewer, now = now)
assertEquals(1L, home.creator.creatorId)
assertEquals("https://cdn.test/profile/creator.png", home.creator.profileImageUrl)
assertEquals("https://cdn.test/live.png", home.currentLive?.coverImageUrl)
assertEquals("https://cdn.test/audio/latest.png", home.latestAudioContent?.imageUrl)
assertEquals(listOf(203L, 202L), home.audioContents.map { it.audioContentId })
assertEquals(listOf(402L, 401L, 404L), home.schedules.map { it.targetId })
assertFalse(home.schedules.any { it.isAdult })
assertEquals("https://cdn.test/profile/fan.png", home.channelDonations.first().profileImageUrl)
assertEquals("https://cdn.test/community.png", home.notices.first().imageUrl)
assertEquals("https://cdn.test/series.png", home.series.first().coverImageUrl)
assertEquals("introduce", home.introduce)
assertEquals(Gender.MALE, port.currentLiveEffectiveViewerGender)
assertEquals(Gender.MALE, port.schedulesEffectiveViewerGender)
assertEquals(10L, port.currentLiveViewerId)
assertEquals(10L, port.schedulesViewerId)
assertFalse(port.currentLiveIsViewerCreator == true)
assertFalse(port.schedulesIsViewerCreator == true)
assertEquals(false, port.currentLiveCanViewAdultContent)
assertEquals(false, port.schedulesCanViewAdultContent)
assertEquals(201L, port.audioContentsLatestAudioContentId)
}
@Test
@DisplayName("조회자가 크리에이터 본인이면 라이브 조회 정책 컨텍스트에 본인 여부를 전달한다")
fun shouldPassViewerCreatorFlagToLivePolicyQueries() {
val port = FakeCreatorChannelHomeQueryPort()
val service = createService(port)
val viewer = createMember(id = 1L, gender = Gender.FEMALE, authGender = null)
service.getHome(creatorId = 1L, viewer = viewer, now = LocalDateTime.of(2026, 6, 13, 10, 0))
assertTrue(port.currentLiveIsViewerCreator == true)
assertTrue(port.schedulesIsViewerCreator == true)
assertEquals(Gender.FEMALE, port.currentLiveEffectiveViewerGender)
assertEquals(Gender.FEMALE, port.schedulesEffectiveViewerGender)
}
@Test
@DisplayName("creatorId에 해당하는 회원이 없으면 user_not_found 예외를 던진다")
fun shouldThrowUserNotFoundWhenCreatorMemberDoesNotExist() {
val port = FakeCreatorChannelHomeQueryPort().apply {
creator = null
}
val service = createService(port)
val viewer = createMember(id = 10L)
val exception = assertThrows(SodaException::class.java) {
service.getHome(creatorId = 999L, viewer = viewer, now = LocalDateTime.of(2026, 6, 13, 10, 0))
}
assertEquals("member.validation.user_not_found", exception.messageKey)
}
@Test
@DisplayName("대상 회원 role이 CREATOR가 아니면 creator_not_found 예외를 던진다")
fun shouldThrowCreatorNotFoundWhenMemberIsNotCreator() {
val port = FakeCreatorChannelHomeQueryPort().apply {
creator = creator?.copy(role = MemberRole.USER)
}
val service = createService(port)
val viewer = createMember(id = 10L)
val exception = assertThrows(SodaException::class.java) {
service.getHome(creatorId = 1L, viewer = viewer, now = LocalDateTime.of(2026, 6, 13, 10, 0))
}
assertEquals("member.validation.creator_not_found", exception.messageKey)
}
@Test
@DisplayName("차단 관계가 있으면 대상 회원이 크리에이터가 아니어도 접근 차단 예외를 먼저 던진다")
fun shouldThrowBlockedAccessBeforeCreatorNotFoundWhenViewerAndTargetAreBlocked() {
val port = FakeCreatorChannelHomeQueryPort().apply {
creator = creator?.copy(role = MemberRole.USER)
blocked = true
}
val service = createService(port)
val viewer = createMember(id = 10L)
val exception = assertThrows(SodaException::class.java) {
service.getHome(creatorId = 1L, viewer = viewer, now = LocalDateTime.of(2026, 6, 13, 10, 0))
}
assertNull(exception.messageKey)
assertEquals("creator님의 요청으로 채널 접근이 제한됩니다.", exception.message)
}
@Test
@DisplayName("조회자와 크리에이터 사이에 차단 관계가 있으면 기존 채널 접근 차단 메시지를 던진다")
fun shouldThrowBlockedAccessWhenViewerAndCreatorAreBlocked() {
val port = FakeCreatorChannelHomeQueryPort().apply {
blocked = true
}
val service = createService(port)
val viewer = createMember(id = 10L)
val exception = assertThrows(SodaException::class.java) {
service.getHome(creatorId = 1L, viewer = viewer, now = LocalDateTime.of(2026, 6, 13, 10, 0))
}
assertNull(exception.messageKey)
assertEquals("creator님의 요청으로 채널 접근이 제한됩니다.", exception.message)
}
@Test
@DisplayName("크리에이터 채널 홈 모델은 홈 탭 전체 섹션을 담고 응답 DTO로 변환된다")
fun shouldConvertCreatorChannelHomeToResponse() {
@@ -34,9 +176,10 @@ class CreatorChannelHomeQueryServiceTest {
val response = CreatorChannelHomeResponse.from(home)
assertEquals(home.creator.creatorId, response.creator.creatorId)
assertEquals(home.creator.characterId, response.creator.characterId)
assertEquals(home.currentLive?.liveId, response.currentLive?.liveId)
assertEquals(home.latestAudioContent?.audioContentId, response.latestAudioContent?.audioContentId)
assertEquals(home.channelDonations.first().donationId, response.channelDonations.first().donationId)
assertEquals(home.channelDonations.first().message, response.channelDonations.first().message)
assertEquals(home.notices.first().postId, response.notices.first().postId)
assertEquals(home.schedules.first().targetId, response.schedules.first().targetId)
assertEquals(home.audioContents.first().audioContentId, response.audioContents.first().audioContentId)
@@ -89,6 +232,7 @@ class CreatorChannelHomeQueryServiceTest {
assertFalse(json["creator"].has("aiChatAvailable"))
assertFalse(json["creator"]["isDmAvailable"].asBoolean())
assertFalse(json["creator"].has("dmAvailable"))
assertEquals(10L, json["creator"]["characterId"].asLong())
assertTrue(json["latestAudioContent"]["isPointAvailable"].asBoolean())
assertFalse(json["latestAudioContent"].has("pointAvailable"))
assertTrue(json["latestAudioContent"]["isFirstContent"].asBoolean())
@@ -97,6 +241,13 @@ class CreatorChannelHomeQueryServiceTest {
assertFalse(json["latestAudioContent"].has("adult"))
assertTrue(json["series"][0]["isOriginal"].asBoolean())
assertFalse(json["series"][0].has("original"))
assertFalse(json["series"][0].has("published" + "DaysOfWeek"))
assertFalse(json["series"][0].has("is" + "Complete"))
assertFalse(json["series"][0].has("is" + "Popular"))
assertEquals("thanks", json["channelDonations"][0]["message"].asText())
assertFalse(json["channelDonations"][0].has("donation" + "Id"))
assertFalse(json["channelDonations"][0].has("member" + "Id"))
assertFalse(json["channelDonations"][0].has("is" + "Secret"))
}
private fun createHome(): CreatorChannelHome {
@@ -118,6 +269,7 @@ class CreatorChannelHomeQueryServiceTest {
return CreatorChannelHome(
creator = CreatorChannelCreator(
creatorId = 1L,
characterId = 10L,
nickname = "creator",
profileImageUrl = "profile.png",
followerCount = 100,
@@ -149,12 +301,9 @@ class CreatorChannelHomeQueryServiceTest {
),
channelDonations = listOf(
CreatorChannelDonation(
donationId = 401L,
memberId = 2L,
nickname = "fan",
profileImageUrl = "fan.png",
can = 50,
isSecret = false,
message = "thanks",
createdAt = LocalDateTime.of(2026, 6, 12, 2, 0)
)
@@ -189,11 +338,8 @@ class CreatorChannelHomeQueryServiceTest {
seriesId = 601L,
title = "series",
coverImageUrl = "series.png",
publishedDaysOfWeek = "MON",
isComplete = false,
numberOfContent = 3,
isNew = true,
isPopular = false,
isOriginal = true
)
),
@@ -229,4 +375,277 @@ class CreatorChannelHomeQueryServiceTest {
)
)
}
private fun createService(
port: FakeCreatorChannelHomeQueryPort,
canViewAdultContent: Boolean = true
): 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
)
)
val messageSource = SodaMessageSource()
val langContext = LangContext()
langContext.setLang(Lang.KO)
return CreatorChannelHomeQueryService(
queryPort = port,
queryPolicy = CreatorChannelHomeQueryPolicy(),
memberContentPreferenceService = preferenceService,
messageSource = messageSource,
langContext = langContext,
cloudFrontHost = "https://cdn.test"
)
}
private fun createMember(
id: Long,
gender: Gender = Gender.NONE,
authGender: Int? = null
): Member {
val member = Member(
email = "member$id@test.com",
password = "password",
nickname = "member$id",
provider = MemberProvider.EMAIL,
gender = gender
)
member.id = id
authGender?.let {
Auth(
name = "name",
birth = "19900101",
uniqueCi = "ci$id",
di = "di$id",
gender = it
).member = member
}
return member
}
}
private class FakeCreatorChannelHomeQueryPort : CreatorChannelHomeQueryPort {
var creator: CreatorChannelCreatorRecord? = CreatorChannelCreatorRecord(
creatorId = 1L,
role = MemberRole.CREATOR,
characterId = 11L,
nickname = "creator",
profileImagePath = "profile/creator.png",
introduce = "introduce",
followerCount = 100,
isAiChatAvailable = true,
isDmAvailable = true,
isFollow = true,
isNotify = false
)
var blocked = false
var currentLiveViewerId: Long? = null
var currentLiveIsViewerCreator: Boolean? = null
var currentLiveEffectiveViewerGender: Gender? = null
var currentLiveCanViewAdultContent: Boolean? = null
var schedulesViewerId: Long? = null
var schedulesIsViewerCreator: Boolean? = null
var schedulesEffectiveViewerGender: Gender? = null
var schedulesCanViewAdultContent: Boolean? = null
var audioContentsLatestAudioContentId: Long? = null
override fun findCreator(creatorId: Long, viewerId: Long?): CreatorChannelCreatorRecord? = creator
override fun existsBlockedBetween(viewerId: Long, creatorId: Long): Boolean = blocked
override fun findCurrentLive(
creatorId: Long,
now: LocalDateTime,
canViewAdultContent: Boolean,
viewerId: Long?,
isViewerCreator: Boolean,
effectiveViewerGender: Gender?
): CreatorChannelLiveRecord? {
currentLiveViewerId = viewerId
currentLiveIsViewerCreator = isViewerCreator
currentLiveEffectiveViewerGender = effectiveViewerGender
currentLiveCanViewAdultContent = canViewAdultContent
return CreatorChannelLiveRecord(
liveId = 101L,
title = "live",
coverImagePath = "live.png",
beginDateTime = LocalDateTime.of(2026, 6, 13, 9, 0),
price = 10,
isAdult = false
)
}
override fun findLatestAudioContent(
creatorId: Long,
now: LocalDateTime,
canViewAdultContent: Boolean
): CreatorChannelAudioContentRecord? = audioContentRecord(201L, "audio/latest.png")
override fun findChannelDonations(
creatorId: Long,
viewerId: Long?,
now: LocalDateTime,
limit: Int
): List<CreatorChannelDonationRecord> = listOf(
CreatorChannelDonationRecord(
nickname = "fan",
profileImagePath = "profile/fan.png",
can = 30,
message = "thanks",
createdAt = LocalDateTime.of(2026, 6, 13, 8, 0)
)
)
override fun findCommunityPosts(
creatorId: Long,
viewerId: Long?,
isFixed: Boolean,
canViewAdultContent: Boolean,
limit: Int
): List<CreatorChannelCommunityPostRecord> = listOf(
CreatorChannelCommunityPostRecord(
postId = if (isFixed) 301L else 302L,
creatorId = creatorId,
creatorNickname = "creator",
creatorProfilePath = "profile/creator.png",
imagePath = "community.png",
audioPath = "community.mp3",
content = if (isFixed) "notice" else "community",
price = 0,
date = LocalDateTime.of(2026, 6, 13, 7, 0),
existOrdered = false,
likeCount = 3,
commentCount = 4
)
)
override fun findSchedules(
creatorId: Long,
now: LocalDateTime,
canViewAdultContent: Boolean,
viewerId: Long?,
isViewerCreator: Boolean,
effectiveViewerGender: Gender?,
limit: Int
): List<CreatorChannelScheduleRecord> {
schedulesViewerId = viewerId
schedulesIsViewerCreator = isViewerCreator
schedulesEffectiveViewerGender = effectiveViewerGender
schedulesCanViewAdultContent = canViewAdultContent
return listOf(
scheduleRecord(401L, LocalDateTime.of(2026, 6, 13, 12, 0), CreatorActivityType.AUDIO, false),
scheduleRecord(402L, LocalDateTime.of(2026, 6, 13, 12, 0), CreatorActivityType.LIVE, false),
scheduleRecord(403L, LocalDateTime.of(2026, 6, 13, 13, 0), CreatorActivityType.LIVE, true),
scheduleRecord(404L, LocalDateTime.of(2026, 6, 13, 14, 0), CreatorActivityType.AUDIO, false),
scheduleRecord(405L, LocalDateTime.of(2026, 6, 13, 15, 0), CreatorActivityType.LIVE, false),
scheduleRecord(406L, LocalDateTime.of(2026, 6, 13, 9, 0), CreatorActivityType.LIVE, false)
)
}
override fun findAudioContents(
creatorId: Long,
now: LocalDateTime,
latestAudioContentId: Long?,
canViewAdultContent: Boolean,
limit: Int
): List<CreatorChannelAudioContentRecord> {
audioContentsLatestAudioContentId = latestAudioContentId
return listOf(
audioContentRecord(201L, "audio/latest.png"),
audioContentRecord(203L, "audio/203.png"),
audioContentRecord(202L, "audio/202.png")
)
}
override fun findSeries(
creatorId: Long,
viewerId: Long?,
now: LocalDateTime,
canViewAdultContent: Boolean,
contentType: ContentType,
limit: Int
): List<CreatorChannelSeriesRecord> = listOf(
CreatorChannelSeriesRecord(
seriesId = 501L,
title = "series",
coverImagePath = "series.png",
numberOfContent = 2,
isNew = true,
isOriginal = false
)
)
override fun findFanTalkSummary(creatorId: Long, viewerId: Long?): CreatorChannelFanTalkSummaryRecord {
return CreatorChannelFanTalkSummaryRecord(
totalCount = 1,
latestFanTalk = CreatorChannelFanTalkRecord(
fanTalkId = 601L,
memberId = 10L,
nickname = "fan",
profileImagePath = "profile/fan.png",
content = "hello",
languageCode = "ko",
createdAt = LocalDateTime.of(2026, 6, 13, 6, 0)
)
)
}
override fun findActivity(creatorId: Long, now: LocalDateTime): CreatorChannelActivityRecord {
return CreatorChannelActivityRecord(
debutDate = LocalDateTime.of(2026, 6, 1, 0, 0),
dDay = "D+12",
liveCount = 1,
liveDurationHours = 2,
liveContributorCount = 3,
audioContentCount = 4,
seriesCount = 5
)
}
override fun findSns(creatorId: Long): CreatorChannelSnsRecord {
return CreatorChannelSnsRecord(
instagramUrl = "instagram",
fancimmUrl = "fancimm",
xUrl = "x",
youtubeUrl = "youtube",
kakaoOpenChatUrl = "kakao"
)
}
private fun audioContentRecord(audioContentId: Long, imagePath: String): CreatorChannelAudioContentRecord {
return CreatorChannelAudioContentRecord(
audioContentId = audioContentId,
title = "audio-$audioContentId",
duration = "00:10:00",
imagePath = imagePath,
price = 10,
isAdult = false,
isPointAvailable = true,
isFirstContent = false,
publishedAt = LocalDateTime.of(2026, 6, 10, 0, audioContentId.toInt() % 60),
seriesName = null,
isOriginalSeries = null
)
}
private fun scheduleRecord(
targetId: Long,
scheduledAt: LocalDateTime,
type: CreatorActivityType,
isAdult: Boolean
): CreatorChannelScheduleRecord {
return CreatorChannelScheduleRecord(
scheduledAt = scheduledAt,
title = "schedule-$targetId",
type = type,
targetId = targetId,
isAdult = isAdult
)
}
}