Compare commits
11 Commits
abc3e8e9aa
...
a66f857373
| Author | SHA1 | Date | |
|---|---|---|---|
| a66f857373 | |||
| 54d0489ca2 | |||
| 2cdd3ed0af | |||
| 5d7d8fa3d0 | |||
| d14406bae7 | |||
| 804a60756b | |||
| ec68d827a6 | |||
| 951bd1b2d1 | |||
| 8b2957c249 | |||
| d1ce1221c9 | |||
| 3fd957a0d1 |
@@ -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 테스트를 단독 재실행해 통과를 확인했다.
|
||||
|
||||
@@ -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`의 의미를 기준으로 한다.
|
||||
- 업로드한 오디오 콘텐츠 개수는 공개된 오디오 콘텐츠만 포함하고 예약 업로드는 반영하지 않는다.
|
||||
- 시리즈 개수는 크리에이터의 활성 시리즈 개수를 기준으로 한다.
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
|
||||
|
||||
@@ -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>,
|
||||
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
)
|
||||
@@ -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"
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user