feat(creator): 채널 라이브 다시듣기 저장소를 추가한다
This commit is contained in:
@@ -291,7 +291,7 @@ private fun LocalDateTime.toUtcIso(): String {
|
|||||||
|
|
||||||
### Phase 3: 라이브 다시듣기 persistence adapter
|
### Phase 3: 라이브 다시듣기 persistence adapter
|
||||||
|
|
||||||
- [ ] **Task 3.1: 라이브 탭 repository 골격과 count 조회 추가**
|
- [x] **Task 3.1: 라이브 탭 repository 골격과 count 조회 추가**
|
||||||
- Files:
|
- Files:
|
||||||
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/live/adapter/out/persistence/CreatorChannelLiveQueryRepository.kt`
|
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/live/adapter/out/persistence/CreatorChannelLiveQueryRepository.kt`
|
||||||
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/live/adapter/out/persistence/DefaultCreatorChannelLiveQueryRepository.kt`
|
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/live/adapter/out/persistence/DefaultCreatorChannelLiveQueryRepository.kt`
|
||||||
@@ -302,8 +302,9 @@ private fun LocalDateTime.toUtcIso(): String {
|
|||||||
- GREEN: `countLiveReplayAudioContents(creatorId, now, canViewAdultContent)`를 구현한다. 조건은 creator, active member, active content, active theme, `theme == "다시듣기"`, `duration != null`, `releaseDate != null`, `releaseDate <= now`, adult filter다.
|
- GREEN: `countLiveReplayAudioContents(creatorId, now, canViewAdultContent)`를 구현한다. 조건은 creator, active member, active content, active theme, `theme == "다시듣기"`, `duration != null`, `releaseDate != null`, `releaseDate <= now`, adult filter다.
|
||||||
- 통과 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.live.adapter.out.persistence.DefaultCreatorChannelLiveQueryRepositoryTest`
|
- 통과 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.live.adapter.out.persistence.DefaultCreatorChannelLiveQueryRepositoryTest`
|
||||||
- REFACTOR: `"다시듣기"` 문자열은 repository companion object의 `LIVE_REPLAY_THEME` 상수로 둔다.
|
- REFACTOR: `"다시듣기"` 문자열은 repository companion object의 `LIVE_REPLAY_THEME` 상수로 둔다.
|
||||||
|
- 검증 기록(2026-06-17): RED 단계에서 `DefaultCreatorChannelLiveQueryRepository` 미존재 컴파일 실패를 확인했다. GREEN 단계에서 live query repository interface/default 구현체와 `countLiveReplayAudioContents`를 추가하고, 공개 `다시듣기` 콘텐츠/성인 노출 정책 count를 `DefaultCreatorChannelLiveQueryRepositoryTest.shouldCountPublicLiveReplayAudioContentsOnly`로 검증했다.
|
||||||
|
|
||||||
- [ ] **Task 3.2: 라이브 다시듣기 기본 목록과 페이징 조회 추가**
|
- [x] **Task 3.2: 라이브 다시듣기 기본 목록과 페이징 조회 추가**
|
||||||
- Files:
|
- Files:
|
||||||
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/live/adapter/out/persistence/DefaultCreatorChannelLiveQueryRepository.kt`
|
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/live/adapter/out/persistence/DefaultCreatorChannelLiveQueryRepository.kt`
|
||||||
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/live/adapter/out/persistence/DefaultCreatorChannelLiveQueryRepositoryTest.kt`
|
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/live/adapter/out/persistence/DefaultCreatorChannelLiveQueryRepositoryTest.kt`
|
||||||
@@ -313,8 +314,9 @@ private fun LocalDateTime.toUtcIso(): String {
|
|||||||
- GREEN: `findLiveReplayAudioContents(creatorId, viewerId, now, canViewAdultContent, sort, offset, limit)`를 구현한다. 우선 `LATEST`, `PRICE_HIGH`, `PRICE_LOW` 정렬을 QueryDSL order specifier로 처리한다.
|
- GREEN: `findLiveReplayAudioContents(creatorId, viewerId, now, canViewAdultContent, sort, offset, limit)`를 구현한다. 우선 `LATEST`, `PRICE_HIGH`, `PRICE_LOW` 정렬을 QueryDSL order specifier로 처리한다.
|
||||||
- 통과 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.live.adapter.out.persistence.DefaultCreatorChannelLiveQueryRepositoryTest`
|
- 통과 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.live.adapter.out.persistence.DefaultCreatorChannelLiveQueryRepositoryTest`
|
||||||
- REFACTOR: content row 조회, first content id 조회, series summary 조회, order status 조회를 작은 private 함수로 분리한다.
|
- REFACTOR: content row 조회, first content id 조회, series summary 조회, order status 조회를 작은 private 함수로 분리한다.
|
||||||
|
- 검증 기록(2026-06-17): `findLiveReplayAudioContents`를 QueryDSL `orderBy`/`offset`/`limit` 기반으로 구현하고, `LATEST`의 공개일/가격 정렬, page offset/limit 적용, first content 및 series summary mapping을 `shouldFindLiveReplayAudioContentsWithPaginationAndLatestSort`로 확인했다. `PRICE_HIGH`, `PRICE_LOW` 정렬은 `shouldSortLiveReplayAudioContentsByPrice`로 확인했다.
|
||||||
|
|
||||||
- [ ] **Task 3.3: `POPULAR` 정렬 구현**
|
- [x] **Task 3.3: `POPULAR` 정렬 구현**
|
||||||
- Files:
|
- Files:
|
||||||
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/live/adapter/out/persistence/DefaultCreatorChannelLiveQueryRepository.kt`
|
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/live/adapter/out/persistence/DefaultCreatorChannelLiveQueryRepository.kt`
|
||||||
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/live/adapter/out/persistence/DefaultCreatorChannelLiveQueryRepositoryTest.kt`
|
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/live/adapter/out/persistence/DefaultCreatorChannelLiveQueryRepositoryTest.kt`
|
||||||
@@ -324,8 +326,9 @@ private fun LocalDateTime.toUtcIso(): String {
|
|||||||
- GREEN: orders left join 또는 집계 subquery로 콘텐츠별 활성 주문의 `can` 합계를 계산하고 `POPULAR` 정렬에 반영한다. `point`는 더하지 않는다. 매출이 없으면 0으로 처리한다.
|
- GREEN: orders left join 또는 집계 subquery로 콘텐츠별 활성 주문의 `can` 합계를 계산하고 `POPULAR` 정렬에 반영한다. `point`는 더하지 않는다. 매출이 없으면 0으로 처리한다.
|
||||||
- 통과 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.live.adapter.out.persistence.DefaultCreatorChannelLiveQueryRepositoryTest`
|
- 통과 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.live.adapter.out.persistence.DefaultCreatorChannelLiveQueryRepositoryTest`
|
||||||
- REFACTOR: 인기순 집계가 기본 목록 count를 중복시키지 않도록 count query와 list query를 분리 유지한다.
|
- REFACTOR: 인기순 집계가 기본 목록 count를 중복시키지 않도록 count query와 list query를 분리 유지한다.
|
||||||
|
- 검증 기록(2026-06-17): `POPULAR` 정렬은 활성 주문의 `orders.can` 합계를 left join/group by로 계산하도록 구현했다. `orders.point`와 비활성 주문이 정렬에 반영되지 않는지 `shouldSortLiveReplayAudioContentsByPopularCanRevenue`로 확인했다.
|
||||||
|
|
||||||
- [ ] **Task 3.4: `OWNED` 정렬과 소장/대여 응답 상태 구현**
|
- [x] **Task 3.4: `OWNED` 정렬과 소장/대여 응답 상태 구현**
|
||||||
- Files:
|
- Files:
|
||||||
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/live/adapter/out/persistence/DefaultCreatorChannelLiveQueryRepository.kt`
|
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/live/adapter/out/persistence/DefaultCreatorChannelLiveQueryRepository.kt`
|
||||||
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/live/adapter/out/persistence/DefaultCreatorChannelLiveQueryRepositoryTest.kt`
|
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/live/adapter/out/persistence/DefaultCreatorChannelLiveQueryRepositoryTest.kt`
|
||||||
@@ -337,8 +340,9 @@ private fun LocalDateTime.toUtcIso(): String {
|
|||||||
- GREEN: 조회된 content id 목록 기준으로 주문 상태를 bulk 조회해 record에 채운다. `OWNED` 정렬은 `KEEP` 존재 여부를 1차 기준으로 삼는다. 응답의 `isOwned`, `isRented`는 각각의 주문 존재 여부를 그대로 반영하고 서로 배타적으로 보정하지 않는다.
|
- GREEN: 조회된 content id 목록 기준으로 주문 상태를 bulk 조회해 record에 채운다. `OWNED` 정렬은 `KEEP` 존재 여부를 1차 기준으로 삼는다. 응답의 `isOwned`, `isRented`는 각각의 주문 존재 여부를 그대로 반영하고 서로 배타적으로 보정하지 않는다.
|
||||||
- 통과 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.live.adapter.out.persistence.DefaultCreatorChannelLiveQueryRepositoryTest`
|
- 통과 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.live.adapter.out.persistence.DefaultCreatorChannelLiveQueryRepositoryTest`
|
||||||
- REFACTOR: 응답 상태 판정과 `OWNED` 정렬 판정의 의미가 어긋나지 않도록 동일한 유효 주문 조건을 사용한다.
|
- REFACTOR: 응답 상태 판정과 `OWNED` 정렬 판정의 의미가 어긋나지 않도록 동일한 유효 주문 조건을 사용한다.
|
||||||
|
- 검증 기록(2026-06-17): `OWNED` 정렬은 조회자의 활성 `KEEP` 주문 존재 여부를 QueryDSL group by 정렬 기준으로 삼고, 응답의 `isOwned`/`isRented`는 조회된 content id 목록 기준 bulk 조회로 채우도록 구현했다. 유효 대여, 만료 대여, 소장+대여 동시 존재를 `shouldSortLiveReplayAudioContentsByOwnedAndReturnOrderStates`로 확인했다.
|
||||||
|
|
||||||
- [ ] **Task 3.5: 현재 라이브 조회 위임 구현**
|
- [x] **Task 3.5: 현재 라이브 조회 위임 구현**
|
||||||
- Files:
|
- Files:
|
||||||
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/live/adapter/out/persistence/DefaultCreatorChannelLiveQueryRepository.kt`
|
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/live/adapter/out/persistence/DefaultCreatorChannelLiveQueryRepository.kt`
|
||||||
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/live/adapter/out/persistence/DefaultCreatorChannelLiveQueryRepositoryTest.kt`
|
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/live/adapter/out/persistence/DefaultCreatorChannelLiveQueryRepositoryTest.kt`
|
||||||
@@ -347,6 +351,7 @@ private fun LocalDateTime.toUtcIso(): String {
|
|||||||
- GREEN: `DefaultCreatorChannelLiveQueryRepository.findCurrentLive`는 기존 `DefaultCreatorChannelHomeQueryRepository.findCurrentLive`와 같은 조건을 사용한다. 코드 중복이 과하면 private helper를 복사하되, 동작 보존을 우선한다.
|
- GREEN: `DefaultCreatorChannelLiveQueryRepository.findCurrentLive`는 기존 `DefaultCreatorChannelHomeQueryRepository.findCurrentLive`와 같은 조건을 사용한다. 코드 중복이 과하면 private helper를 복사하되, 동작 보존을 우선한다.
|
||||||
- 통과 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.live.adapter.out.persistence.DefaultCreatorChannelLiveQueryRepositoryTest`
|
- 통과 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.live.adapter.out.persistence.DefaultCreatorChannelLiveQueryRepositoryTest`
|
||||||
- REFACTOR: 이후 공용 live query helper 추출은 이번 구현 범위에서 제외한다.
|
- REFACTOR: 이후 공용 live query helper 추출은 이번 구현 범위에서 제외한다.
|
||||||
|
- 검증 기록(2026-06-17): 기존 홈 API의 현재 라이브 조건을 live tab repository에 복사해 성인 노출, 성별 제한, 크리에이터 입장 제한, 진행 중 라이브 정렬 정책을 맞췄다. `shouldFindCurrentLiveWithHomePolicy`와 `shouldFindCreatorAndBlockedRelationship`으로 current live/creator/block port 계약을 확인했다.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -485,3 +490,4 @@ private fun LocalDateTime.toUtcIso(): String {
|
|||||||
- 아직 구현 전 계획 문서 작성 단계다. 구현 task 완료 후 각 task 아래에 무엇을, 왜, 어떻게 검증했는지 실행 명령과 결과를 누적 기록한다.
|
- 아직 구현 전 계획 문서 작성 단계다. 구현 task 완료 후 각 task 아래에 무엇을, 왜, 어떻게 검증했는지 실행 명령과 결과를 누적 기록한다.
|
||||||
- 2026-06-17 문서 갱신 검증: 라이브 탭 신규 구현을 `v2.api.creator.channel.live` 조립 계층과 `v2.creator.channel.live` 도메인 조회 계층으로 진행하도록 PRD/plan-task를 갱신했다. 기존 홈 API 구조 정렬은 Phase 6 후속 프롬프트로 분리했다. `git diff --check`로 whitespace 오류가 없음을 확인했고, `./gradlew tasks --all`로 Gradle 명령 유효성 확인에 성공했다.
|
- 2026-06-17 문서 갱신 검증: 라이브 탭 신규 구현을 `v2.api.creator.channel.live` 조립 계층과 `v2.creator.channel.live` 도메인 조회 계층으로 진행하도록 PRD/plan-task를 갱신했다. 기존 홈 API 구조 정렬은 Phase 6 후속 프롬프트로 분리했다. `git diff --check`로 whitespace 오류가 없음을 확인했고, `./gradlew tasks --all`로 Gradle 명령 유효성 확인에 성공했다.
|
||||||
- 2026-06-17 Phase 2 검증: `CreatorChannelLiveReplayQueryPolicyTest`와 `CreatorChannelLiveQueryServiceTest`를 먼저 추가해 RED 단계에서 Phase 2 production 클래스 미존재 컴파일 실패를 확인했다. GREEN 단계에서 라이브 탭 domain/page 정책, port, service 조립을 추가하고 `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.live.domain.CreatorChannelLiveReplayQueryPolicyTest --tests kr.co.vividnext.sodalive.v2.creator.channel.live.application.CreatorChannelLiveQueryServiceTest`, `./gradlew ktlintCheck`, `git diff --check` 성공을 확인했다. Phase 3 port 구현 전 Spring context가 깨지지 않도록 service는 `ObjectProvider<CreatorChannelLiveQueryPort>`로 port를 지연 조회하게 했고, `./gradlew test --tests kr.co.vividnext.sodalive.support.SpringBootIntegrationSampleTest` 성공으로 대표 context 기동을 확인했다. 추가 리뷰 반영으로 `page >= 0`, `20 <= size <= 50` 검증과 invalid 요청 시 port 조회 전 중단을 보강했고, 동일 targeted 테스트와 `ktlintCheck`, `git diff --check` 성공을 확인했다. 전체 `./gradlew test`는 실행 중 기존 SpringBoot 통합 테스트들의 bean/OOM 계열 실패와 timeout이 발생해 완료하지 못했으며, Phase 2 직접 변경 범위는 위 targeted/context 검증으로 확인했다.
|
- 2026-06-17 Phase 2 검증: `CreatorChannelLiveReplayQueryPolicyTest`와 `CreatorChannelLiveQueryServiceTest`를 먼저 추가해 RED 단계에서 Phase 2 production 클래스 미존재 컴파일 실패를 확인했다. GREEN 단계에서 라이브 탭 domain/page 정책, port, service 조립을 추가하고 `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.live.domain.CreatorChannelLiveReplayQueryPolicyTest --tests kr.co.vividnext.sodalive.v2.creator.channel.live.application.CreatorChannelLiveQueryServiceTest`, `./gradlew ktlintCheck`, `git diff --check` 성공을 확인했다. Phase 3 port 구현 전 Spring context가 깨지지 않도록 service는 `ObjectProvider<CreatorChannelLiveQueryPort>`로 port를 지연 조회하게 했고, `./gradlew test --tests kr.co.vividnext.sodalive.support.SpringBootIntegrationSampleTest` 성공으로 대표 context 기동을 확인했다. 추가 리뷰 반영으로 `page >= 0`, `20 <= size <= 50` 검증과 invalid 요청 시 port 조회 전 중단을 보강했고, 동일 targeted 테스트와 `ktlintCheck`, `git diff --check` 성공을 확인했다. 전체 `./gradlew test`는 실행 중 기존 SpringBoot 통합 테스트들의 bean/OOM 계열 실패와 timeout이 발생해 완료하지 못했으며, Phase 2 직접 변경 범위는 위 targeted/context 검증으로 확인했다.
|
||||||
|
- 2026-06-17 Phase 3 검증: `DefaultCreatorChannelLiveQueryRepositoryTest`를 먼저 추가해 RED 단계에서 `DefaultCreatorChannelLiveQueryRepository` 미존재 컴파일 실패를 확인했다. GREEN 단계에서 live tab repository interface/default 구현체를 추가하고 count/list/pagination/sort/order state/current live policy를 구현했다. `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.live.adapter.out.persistence.DefaultCreatorChannelLiveQueryRepositoryTest`, `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.live.application.CreatorChannelLiveQueryServiceTest --tests kr.co.vividnext.sodalive.v2.creator.channel.live.adapter.out.persistence.DefaultCreatorChannelLiveQueryRepositoryTest --tests kr.co.vividnext.sodalive.support.SpringBootIntegrationSampleTest`, `./gradlew ktlintCheck`, `git diff --check` 성공을 확인했다. 리뷰 게이트에서 Phase 3 persistence adapter 범위와 시나리오 검증에 대한 unconditional approval을 받았다.
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
package kr.co.vividnext.sodalive.v2.creator.channel.live.adapter.out.persistence
|
||||||
|
|
||||||
|
import kr.co.vividnext.sodalive.v2.creator.channel.live.port.out.CreatorChannelLiveQueryPort
|
||||||
|
|
||||||
|
interface CreatorChannelLiveQueryRepository : CreatorChannelLiveQueryPort
|
||||||
@@ -0,0 +1,365 @@
|
|||||||
|
package kr.co.vividnext.sodalive.v2.creator.channel.live.adapter.out.persistence
|
||||||
|
|
||||||
|
import com.querydsl.core.Tuple
|
||||||
|
import com.querydsl.core.types.Projections
|
||||||
|
import com.querydsl.core.types.dsl.BooleanExpression
|
||||||
|
import com.querydsl.jpa.impl.JPAQueryFactory
|
||||||
|
import kr.co.vividnext.sodalive.content.QAudioContent.audioContent
|
||||||
|
import kr.co.vividnext.sodalive.content.order.OrderType
|
||||||
|
import kr.co.vividnext.sodalive.content.order.QOrder
|
||||||
|
import kr.co.vividnext.sodalive.content.order.QOrder.order
|
||||||
|
import kr.co.vividnext.sodalive.content.theme.QAudioContentTheme.audioContentTheme
|
||||||
|
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.live.room.GenderRestriction
|
||||||
|
import kr.co.vividnext.sodalive.live.room.QLiveRoom.liveRoom
|
||||||
|
import kr.co.vividnext.sodalive.member.Gender
|
||||||
|
import kr.co.vividnext.sodalive.member.QMember.member
|
||||||
|
import kr.co.vividnext.sodalive.member.block.QBlockMember
|
||||||
|
import kr.co.vividnext.sodalive.v2.common.domain.ContentSort
|
||||||
|
import kr.co.vividnext.sodalive.v2.creator.channel.live.port.out.CreatorChannelAudioContentRecord
|
||||||
|
import kr.co.vividnext.sodalive.v2.creator.channel.live.port.out.CreatorChannelCreatorRecord
|
||||||
|
import kr.co.vividnext.sodalive.v2.creator.channel.live.port.out.CreatorChannelLiveRecord
|
||||||
|
import org.springframework.stereotype.Repository
|
||||||
|
import java.time.LocalDateTime
|
||||||
|
|
||||||
|
@Repository
|
||||||
|
class DefaultCreatorChannelLiveQueryRepository(
|
||||||
|
private val queryFactory: JPAQueryFactory
|
||||||
|
) : CreatorChannelLiveQueryRepository {
|
||||||
|
override fun findCreator(creatorId: Long, viewerId: Long?): CreatorChannelCreatorRecord? {
|
||||||
|
return queryFactory
|
||||||
|
.select(
|
||||||
|
Projections.constructor(
|
||||||
|
CreatorChannelCreatorRecord::class.java,
|
||||||
|
member.id,
|
||||||
|
member.role,
|
||||||
|
member.nickname
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.from(member)
|
||||||
|
.where(
|
||||||
|
member.id.eq(creatorId),
|
||||||
|
member.isActive.isTrue
|
||||||
|
)
|
||||||
|
.fetchFirst()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun existsBlockedBetween(viewerId: Long, creatorId: Long): Boolean {
|
||||||
|
val blockMember = QBlockMember("creatorChannelLiveBlockMember")
|
||||||
|
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 countLiveReplayAudioContents(
|
||||||
|
creatorId: Long,
|
||||||
|
now: LocalDateTime,
|
||||||
|
canViewAdultContent: Boolean
|
||||||
|
): Int {
|
||||||
|
return queryFactory
|
||||||
|
.select(audioContent.id.count())
|
||||||
|
.from(audioContent)
|
||||||
|
.innerJoin(audioContent.theme, audioContentTheme)
|
||||||
|
.where(liveReplayAudioCondition(creatorId, now, canViewAdultContent))
|
||||||
|
.fetchOne()
|
||||||
|
?.toInt()
|
||||||
|
?: 0
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun findLiveReplayAudioContents(
|
||||||
|
creatorId: Long,
|
||||||
|
viewerId: Long?,
|
||||||
|
now: LocalDateTime,
|
||||||
|
canViewAdultContent: Boolean,
|
||||||
|
sort: ContentSort,
|
||||||
|
offset: Long,
|
||||||
|
limit: Int
|
||||||
|
): List<CreatorChannelAudioContentRecord> {
|
||||||
|
val rows = findLiveReplayAudioRows(creatorId, viewerId, now, canViewAdultContent, sort, offset, limit)
|
||||||
|
val contentIds = rows.map { itAudioId(it) }
|
||||||
|
val firstContentId = firstLiveReplayAudioContentId(creatorId, now, canViewAdultContent)
|
||||||
|
val seriesByContentId = audioSeriesByContentIds(contentIds)
|
||||||
|
val orderStatesByContentId = orderStatesByContentIds(viewerId, contentIds, now)
|
||||||
|
|
||||||
|
return rows
|
||||||
|
.map { it.toAudioRecord(firstContentId, seriesByContentId, orderStatesByContentId) }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun findLiveReplayAudioRows(
|
||||||
|
creatorId: Long,
|
||||||
|
viewerId: Long?,
|
||||||
|
now: LocalDateTime,
|
||||||
|
canViewAdultContent: Boolean,
|
||||||
|
sort: ContentSort,
|
||||||
|
offset: Long,
|
||||||
|
limit: Int
|
||||||
|
): List<Tuple> {
|
||||||
|
val query = queryFactory
|
||||||
|
.select(
|
||||||
|
audioContent.id,
|
||||||
|
audioContent.title,
|
||||||
|
audioContent.duration,
|
||||||
|
audioContent.coverImage,
|
||||||
|
audioContent.price,
|
||||||
|
audioContent.isAdult,
|
||||||
|
audioContent.isPointAvailable,
|
||||||
|
audioContent.releaseDate
|
||||||
|
)
|
||||||
|
.from(audioContent)
|
||||||
|
.innerJoin(audioContent.theme, audioContentTheme)
|
||||||
|
.where(liveReplayAudioCondition(creatorId, now, canViewAdultContent))
|
||||||
|
|
||||||
|
when (sort) {
|
||||||
|
ContentSort.POPULAR -> {
|
||||||
|
val revenueOrder = QOrder("liveReplayRevenueOrder")
|
||||||
|
query
|
||||||
|
.leftJoin(revenueOrder)
|
||||||
|
.on(
|
||||||
|
revenueOrder.audioContent.id.eq(audioContent.id),
|
||||||
|
revenueOrder.isActive.isTrue
|
||||||
|
)
|
||||||
|
.groupBy(
|
||||||
|
audioContent.id,
|
||||||
|
audioContent.title,
|
||||||
|
audioContent.duration,
|
||||||
|
audioContent.coverImage,
|
||||||
|
audioContent.price,
|
||||||
|
audioContent.isAdult,
|
||||||
|
audioContent.isPointAvailable,
|
||||||
|
audioContent.releaseDate
|
||||||
|
)
|
||||||
|
.orderBy(
|
||||||
|
revenueOrder.can.sum().coalesce(0).desc(),
|
||||||
|
audioContent.releaseDate.desc(),
|
||||||
|
audioContent.id.desc()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
ContentSort.OWNED -> {
|
||||||
|
val ownedOrder = QOrder("liveReplayOwnedOrder")
|
||||||
|
query
|
||||||
|
.leftJoin(ownedOrder)
|
||||||
|
.on(
|
||||||
|
ownedOrder.audioContent.id.eq(audioContent.id),
|
||||||
|
ownedOrder.member.id.eq(viewerId ?: -1L),
|
||||||
|
ownedOrder.isActive.isTrue,
|
||||||
|
ownedOrder.type.eq(OrderType.KEEP)
|
||||||
|
)
|
||||||
|
.groupBy(
|
||||||
|
audioContent.id,
|
||||||
|
audioContent.title,
|
||||||
|
audioContent.duration,
|
||||||
|
audioContent.coverImage,
|
||||||
|
audioContent.price,
|
||||||
|
audioContent.isAdult,
|
||||||
|
audioContent.isPointAvailable,
|
||||||
|
audioContent.releaseDate
|
||||||
|
)
|
||||||
|
.orderBy(
|
||||||
|
ownedOrder.id.count().desc(),
|
||||||
|
audioContent.releaseDate.desc(),
|
||||||
|
audioContent.id.desc()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
ContentSort.LATEST -> query.orderBy(
|
||||||
|
audioContent.releaseDate.desc(),
|
||||||
|
audioContent.price.desc(),
|
||||||
|
audioContent.id.desc()
|
||||||
|
)
|
||||||
|
ContentSort.PRICE_HIGH -> query.orderBy(
|
||||||
|
audioContent.price.desc(),
|
||||||
|
audioContent.releaseDate.desc(),
|
||||||
|
audioContent.id.desc()
|
||||||
|
)
|
||||||
|
ContentSort.PRICE_LOW -> query.orderBy(
|
||||||
|
audioContent.price.asc(),
|
||||||
|
audioContent.releaseDate.desc(),
|
||||||
|
audioContent.id.desc()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return query
|
||||||
|
.offset(offset)
|
||||||
|
.limit(limit.toLong())
|
||||||
|
.fetch()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun liveReplayAudioCondition(
|
||||||
|
creatorId: Long,
|
||||||
|
now: LocalDateTime,
|
||||||
|
canViewAdultContent: Boolean
|
||||||
|
): BooleanExpression {
|
||||||
|
return audioContent.member.id.eq(creatorId)
|
||||||
|
.and(audioContent.member.isActive.isTrue)
|
||||||
|
.and(audioContent.isActive.isTrue)
|
||||||
|
.and(audioContentTheme.isActive.isTrue)
|
||||||
|
.and(audioContentTheme.theme.eq(LIVE_REPLAY_THEME))
|
||||||
|
.and(audioContent.duration.isNotNull)
|
||||||
|
.and(audioContent.releaseDate.isNotNull)
|
||||||
|
.and(audioContent.releaseDate.loe(now))
|
||||||
|
.and(adultAudioCondition(canViewAdultContent))
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun itAudioId(row: Tuple): Long = row.get(audioContent.id)!!
|
||||||
|
|
||||||
|
private fun Tuple.toAudioRecord(
|
||||||
|
firstContentId: Long?,
|
||||||
|
seriesByContentId: Map<Long, AudioSeriesSummary>,
|
||||||
|
orderStatesByContentId: Map<Long, AudioOrderState>
|
||||||
|
): CreatorChannelAudioContentRecord {
|
||||||
|
val audioContentId = get(audioContent.id)!!
|
||||||
|
val seriesSummary = seriesByContentId[audioContentId]
|
||||||
|
val orderState = orderStatesByContentId[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,
|
||||||
|
isOwned = orderState?.isOwned ?: false,
|
||||||
|
isRented = orderState?.isRented ?: false
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun firstLiveReplayAudioContentId(
|
||||||
|
creatorId: Long,
|
||||||
|
now: LocalDateTime,
|
||||||
|
canViewAdultContent: Boolean
|
||||||
|
): Long? {
|
||||||
|
return queryFactory
|
||||||
|
.select(audioContent.id)
|
||||||
|
.from(audioContent)
|
||||||
|
.innerJoin(audioContent.theme, audioContentTheme)
|
||||||
|
.where(liveReplayAudioCondition(creatorId, now, canViewAdultContent))
|
||||||
|
.orderBy(audioContent.releaseDate.asc(), audioContent.id.asc())
|
||||||
|
.fetchFirst()
|
||||||
|
}
|
||||||
|
|
||||||
|
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 orderStatesByContentIds(
|
||||||
|
viewerId: Long?,
|
||||||
|
contentIds: List<Long>,
|
||||||
|
now: LocalDateTime
|
||||||
|
): Map<Long, AudioOrderState> {
|
||||||
|
if (viewerId == null || contentIds.isEmpty()) return emptyMap()
|
||||||
|
return queryFactory
|
||||||
|
.select(order.audioContent.id, order.type)
|
||||||
|
.from(order)
|
||||||
|
.where(
|
||||||
|
order.member.id.eq(viewerId),
|
||||||
|
order.audioContent.id.`in`(contentIds),
|
||||||
|
order.isActive.isTrue,
|
||||||
|
order.type.eq(OrderType.KEEP)
|
||||||
|
.or(order.type.eq(OrderType.RENTAL).and(order.endDate.after(now)))
|
||||||
|
)
|
||||||
|
.fetch()
|
||||||
|
.groupBy { it.get(order.audioContent.id)!! }
|
||||||
|
.mapValues { (_, rows) ->
|
||||||
|
val types = rows.map { it.get(order.type)!! }.toSet()
|
||||||
|
AudioOrderState(
|
||||||
|
isOwned = OrderType.KEEP in types,
|
||||||
|
isRented = OrderType.RENTAL in types
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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 data class AudioSeriesSummary(
|
||||||
|
val title: String,
|
||||||
|
val isOriginal: Boolean
|
||||||
|
)
|
||||||
|
|
||||||
|
private data class AudioOrderState(
|
||||||
|
val isOwned: Boolean,
|
||||||
|
val isRented: Boolean
|
||||||
|
)
|
||||||
|
|
||||||
|
private companion object {
|
||||||
|
const val LIVE_REPLAY_THEME = "다시듣기"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,355 @@
|
|||||||
|
package kr.co.vividnext.sodalive.v2.creator.channel.live.adapter.out.persistence
|
||||||
|
|
||||||
|
import com.querydsl.jpa.impl.JPAQueryFactory
|
||||||
|
import kr.co.vividnext.sodalive.admin.content.series.genre.SeriesGenre
|
||||||
|
import kr.co.vividnext.sodalive.configs.QueryDslConfig
|
||||||
|
import kr.co.vividnext.sodalive.content.AudioContent
|
||||||
|
import kr.co.vividnext.sodalive.content.order.Order
|
||||||
|
import kr.co.vividnext.sodalive.content.order.OrderType
|
||||||
|
import kr.co.vividnext.sodalive.content.theme.AudioContentTheme
|
||||||
|
import kr.co.vividnext.sodalive.creator.admin.content.series.Series
|
||||||
|
import kr.co.vividnext.sodalive.creator.admin.content.series.SeriesContent
|
||||||
|
import kr.co.vividnext.sodalive.live.room.GenderRestriction
|
||||||
|
import kr.co.vividnext.sodalive.live.room.LiveRoom
|
||||||
|
import kr.co.vividnext.sodalive.member.Gender
|
||||||
|
import kr.co.vividnext.sodalive.member.Member
|
||||||
|
import kr.co.vividnext.sodalive.member.MemberRole
|
||||||
|
import kr.co.vividnext.sodalive.member.block.BlockMember
|
||||||
|
import kr.co.vividnext.sodalive.v2.common.domain.ContentSort
|
||||||
|
import org.junit.jupiter.api.Assertions.assertEquals
|
||||||
|
import org.junit.jupiter.api.Assertions.assertTrue
|
||||||
|
import org.junit.jupiter.api.DisplayName
|
||||||
|
import org.junit.jupiter.api.Test
|
||||||
|
import org.springframework.beans.factory.annotation.Autowired
|
||||||
|
import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest
|
||||||
|
import org.springframework.context.annotation.Import
|
||||||
|
import java.time.LocalDateTime
|
||||||
|
import javax.persistence.EntityManager
|
||||||
|
|
||||||
|
@DataJpaTest(
|
||||||
|
properties = [
|
||||||
|
"spring.cache.type=none",
|
||||||
|
"spring.datasource.url=jdbc:h2:mem:testdb;MODE=MySQL;NON_KEYWORDS=VALUE"
|
||||||
|
]
|
||||||
|
)
|
||||||
|
@Import(QueryDslConfig::class)
|
||||||
|
class DefaultCreatorChannelLiveQueryRepositoryTest @Autowired constructor(
|
||||||
|
private val entityManager: EntityManager,
|
||||||
|
queryFactory: JPAQueryFactory
|
||||||
|
) {
|
||||||
|
private val repository = DefaultCreatorChannelLiveQueryRepository(queryFactory)
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("라이브 다시듣기 count는 공개 다시듣기 콘텐츠와 성인 노출 정책만 반영한다")
|
||||||
|
fun shouldCountPublicLiveReplayAudioContentsOnly() {
|
||||||
|
val now = LocalDateTime.of(2026, 6, 17, 12, 0)
|
||||||
|
val creator = saveMember("count-creator", MemberRole.CREATOR)
|
||||||
|
val liveReplayTheme = saveTheme("다시듣기")
|
||||||
|
saveAudioContent(creator, now.minusDays(2), isAdult = false, theme = liveReplayTheme)
|
||||||
|
saveAudioContent(creator, now.minusDays(1), isAdult = false, theme = liveReplayTheme)
|
||||||
|
saveAudioContent(creator, now.minusHours(1), isAdult = true, theme = liveReplayTheme)
|
||||||
|
saveAudioContent(creator, now.minusHours(2), isAdult = false, theme = saveTheme("수면"))
|
||||||
|
saveAudioContent(creator, now.plusHours(1), isAdult = false, theme = liveReplayTheme)
|
||||||
|
saveAudioContent(creator, now.minusHours(3), isAdult = false, theme = liveReplayTheme).isActive = false
|
||||||
|
saveAudioContent(creator, now.minusHours(4), isAdult = false, theme = saveTheme("inactive", isActive = false))
|
||||||
|
saveAudioContent(creator, now.minusHours(5), isAdult = false, theme = liveReplayTheme).duration = null
|
||||||
|
saveAudioContent(creator, now.minusHours(6), isAdult = false, theme = liveReplayTheme).releaseDate = null
|
||||||
|
flushAndClear()
|
||||||
|
|
||||||
|
val hiddenAdultCount = repository.countLiveReplayAudioContents(creator.id!!, now, canViewAdultContent = false)
|
||||||
|
val visibleAdultCount = repository.countLiveReplayAudioContents(creator.id!!, now, canViewAdultContent = true)
|
||||||
|
|
||||||
|
assertEquals(2, hiddenAdultCount)
|
||||||
|
assertEquals(3, visibleAdultCount)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("라이브 다시듣기 목록은 page 인자와 기본 정렬을 DB에서 적용하고 series/firstContent를 채운다")
|
||||||
|
fun shouldFindLiveReplayAudioContentsWithPaginationAndLatestSort() {
|
||||||
|
val now = LocalDateTime.of(2026, 6, 17, 12, 0)
|
||||||
|
val creator = saveMember("list-creator", MemberRole.CREATOR)
|
||||||
|
val theme = saveTheme("다시듣기")
|
||||||
|
val oldFirst = saveAudioContent(creator, now.minusDays(30), isAdult = false, theme = theme, price = 100)
|
||||||
|
repeat(20) { index ->
|
||||||
|
saveAudioContent(creator, now.minusDays(29L - index), isAdult = false, theme = theme, price = 100 + index)
|
||||||
|
}
|
||||||
|
val sameDateLowPrice = saveAudioContent(creator, now.minusHours(1), isAdult = false, theme = theme, price = 100)
|
||||||
|
val sameDateHighPrice = saveAudioContent(creator, now.minusHours(1), isAdult = false, theme = theme, price = 300)
|
||||||
|
val series = saveSeries("live-replay-series", creator, isOriginal = true)
|
||||||
|
saveSeriesContent(series, sameDateHighPrice)
|
||||||
|
flushAndClear()
|
||||||
|
|
||||||
|
val firstPage = repository.findLiveReplayAudioContents(
|
||||||
|
creatorId = creator.id!!,
|
||||||
|
viewerId = null,
|
||||||
|
now = now,
|
||||||
|
canViewAdultContent = false,
|
||||||
|
sort = ContentSort.LATEST,
|
||||||
|
offset = 0,
|
||||||
|
limit = 21
|
||||||
|
)
|
||||||
|
val secondPage = repository.findLiveReplayAudioContents(
|
||||||
|
creatorId = creator.id!!,
|
||||||
|
viewerId = null,
|
||||||
|
now = now,
|
||||||
|
canViewAdultContent = false,
|
||||||
|
sort = ContentSort.LATEST,
|
||||||
|
offset = 20,
|
||||||
|
limit = 21
|
||||||
|
)
|
||||||
|
|
||||||
|
assertEquals(21, firstPage.size)
|
||||||
|
assertEquals(listOf(sameDateHighPrice.id, sameDateLowPrice.id), firstPage.take(2).map { it.audioContentId })
|
||||||
|
assertEquals(3, secondPage.size)
|
||||||
|
assertEquals(firstPage[20].audioContentId, secondPage.first().audioContentId)
|
||||||
|
assertEquals(oldFirst.id, secondPage.last().audioContentId)
|
||||||
|
assertEquals("live-replay-series", firstPage.first().seriesName)
|
||||||
|
assertEquals(true, firstPage.first().isOriginalSeries)
|
||||||
|
assertTrue(secondPage.last().isFirstContent)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("라이브 다시듣기 목록은 가격 정렬을 적용한다")
|
||||||
|
fun shouldSortLiveReplayAudioContentsByPrice() {
|
||||||
|
val now = LocalDateTime.of(2026, 6, 17, 12, 0)
|
||||||
|
val creator = saveMember("price-creator", MemberRole.CREATOR)
|
||||||
|
val theme = saveTheme("다시듣기")
|
||||||
|
val low = saveAudioContent(creator, now.minusDays(1), isAdult = false, theme = theme, price = 100)
|
||||||
|
val high = saveAudioContent(creator, now.minusDays(2), isAdult = false, theme = theme, price = 300)
|
||||||
|
flushAndClear()
|
||||||
|
|
||||||
|
val highRecords = repository.findLiveReplayAudioContents(creator.id!!, null, now, false, ContentSort.PRICE_HIGH, 0, 20)
|
||||||
|
val lowRecords = repository.findLiveReplayAudioContents(creator.id!!, null, now, false, ContentSort.PRICE_LOW, 0, 20)
|
||||||
|
|
||||||
|
assertEquals(listOf(high.id, low.id), highRecords.map { it.audioContentId })
|
||||||
|
assertEquals(listOf(low.id, high.id), lowRecords.map { it.audioContentId })
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("인기순은 활성 주문 can 합계를 기준으로 정렬하고 point와 비활성 주문을 제외한다")
|
||||||
|
fun shouldSortLiveReplayAudioContentsByPopularCanRevenue() {
|
||||||
|
val now = LocalDateTime.of(2026, 6, 17, 12, 0)
|
||||||
|
val viewer = saveMember("popular-viewer", MemberRole.USER)
|
||||||
|
val creator = saveMember("popular-creator", MemberRole.CREATOR)
|
||||||
|
val theme = saveTheme("다시듣기")
|
||||||
|
val olderHighRevenue = saveAudioContent(creator, now.minusDays(3), isAdult = false, theme = theme, price = 100)
|
||||||
|
val newerLowRevenue = saveAudioContent(creator, now.minusDays(1), isAdult = false, theme = theme, price = 100)
|
||||||
|
val inactiveRevenue = saveAudioContent(creator, now.minusDays(2), isAdult = false, theme = theme, price = 100)
|
||||||
|
saveOrder(viewer, creator, olderHighRevenue, OrderType.KEEP, can = 500, point = 900)
|
||||||
|
saveOrder(viewer, creator, newerLowRevenue, OrderType.KEEP, can = 100, point = 9000)
|
||||||
|
saveOrder(viewer, creator, inactiveRevenue, OrderType.KEEP, isActive = false, can = 1000)
|
||||||
|
flushAndClear()
|
||||||
|
|
||||||
|
val records = repository.findLiveReplayAudioContents(creator.id!!, viewer.id!!, now, false, ContentSort.POPULAR, 0, 20)
|
||||||
|
|
||||||
|
assertEquals(listOf(olderHighRevenue.id, newerLowRevenue.id, inactiveRevenue.id), records.map { it.audioContentId })
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("소장순은 조회자 KEEP 콘텐츠를 먼저 정렬하고 소장/대여 상태를 함께 반환한다")
|
||||||
|
fun shouldSortLiveReplayAudioContentsByOwnedAndReturnOrderStates() {
|
||||||
|
val now = LocalDateTime.of(2026, 6, 17, 12, 0)
|
||||||
|
val viewer = saveMember("owned-viewer", MemberRole.USER)
|
||||||
|
val creator = saveMember("owned-creator", MemberRole.CREATOR)
|
||||||
|
val theme = saveTheme("다시듣기")
|
||||||
|
val keepAndRental = saveAudioContent(creator, now.minusDays(4), isAdult = false, theme = theme)
|
||||||
|
val expiredRental = saveAudioContent(creator, now.minusDays(3), isAdult = false, theme = theme)
|
||||||
|
val rentalOnly = saveAudioContent(creator, now.minusDays(2), isAdult = false, theme = theme)
|
||||||
|
val keepOnly = saveAudioContent(creator, now.minusDays(1), isAdult = false, theme = theme)
|
||||||
|
saveOrder(viewer, creator, keepOnly, OrderType.KEEP)
|
||||||
|
saveOrder(viewer, creator, rentalOnly, OrderType.RENTAL, endDate = now.plusDays(1))
|
||||||
|
saveOrder(viewer, creator, expiredRental, OrderType.RENTAL, endDate = now.minusDays(1))
|
||||||
|
saveOrder(viewer, creator, keepAndRental, OrderType.KEEP)
|
||||||
|
saveOrder(viewer, creator, keepAndRental, OrderType.RENTAL, endDate = now.plusDays(1))
|
||||||
|
flushAndClear()
|
||||||
|
|
||||||
|
val records = repository.findLiveReplayAudioContents(creator.id!!, viewer.id!!, now, false, ContentSort.OWNED, 0, 20)
|
||||||
|
|
||||||
|
assertEquals(listOf(keepOnly.id, keepAndRental.id, rentalOnly.id, expiredRental.id), records.map { it.audioContentId })
|
||||||
|
assertEquals(listOf(true, true, false, false), records.map { it.isOwned })
|
||||||
|
assertEquals(listOf(false, true, true, false), records.map { it.isRented })
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("현재 라이브 조회는 홈 API와 같은 성인/성별/크리에이터 입장 정책을 적용한다")
|
||||||
|
fun shouldFindCurrentLiveWithHomePolicy() {
|
||||||
|
val now = LocalDateTime.of(2026, 6, 17, 12, 0)
|
||||||
|
val creator = saveMember("current-live-creator", MemberRole.CREATOR)
|
||||||
|
val viewerCreator = saveMember("current-live-viewer", MemberRole.CREATOR)
|
||||||
|
saveLiveRoom(creator, now.minusMinutes(3), channelName = "adult", isAdult = true)
|
||||||
|
saveLiveRoom(
|
||||||
|
creator,
|
||||||
|
now.minusMinutes(4),
|
||||||
|
channelName = "male-only",
|
||||||
|
isAdult = false,
|
||||||
|
genderRestriction = GenderRestriction.MALE_ONLY
|
||||||
|
)
|
||||||
|
saveLiveRoom(
|
||||||
|
creator,
|
||||||
|
now.minusMinutes(5),
|
||||||
|
channelName = "creator-hidden",
|
||||||
|
isAdult = false,
|
||||||
|
isAvailableJoinCreator = false
|
||||||
|
)
|
||||||
|
val visible = saveLiveRoom(creator, now.minusMinutes(6), channelName = "visible", isAdult = false)
|
||||||
|
flushAndClear()
|
||||||
|
|
||||||
|
val live = repository.findCurrentLive(
|
||||||
|
creatorId = creator.id!!,
|
||||||
|
now = now,
|
||||||
|
canViewAdultContent = false,
|
||||||
|
viewerId = viewerCreator.id!!,
|
||||||
|
isViewerCreator = true,
|
||||||
|
effectiveViewerGender = Gender.FEMALE
|
||||||
|
)
|
||||||
|
|
||||||
|
assertEquals(visible.id, live!!.liveId)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("크리에이터 조회와 차단 관계 조회는 live service port 계약을 만족한다")
|
||||||
|
fun shouldFindCreatorAndBlockedRelationship() {
|
||||||
|
val viewer = saveMember("blocked-viewer", MemberRole.USER)
|
||||||
|
val creator = saveMember("blocked-creator", MemberRole.CREATOR)
|
||||||
|
saveBlock(creator, viewer)
|
||||||
|
flushAndClear()
|
||||||
|
|
||||||
|
val record = repository.findCreator(creator.id!!, viewer.id!!)
|
||||||
|
|
||||||
|
assertEquals(creator.id, record!!.creatorId)
|
||||||
|
assertEquals(MemberRole.CREATOR, record.role)
|
||||||
|
assertTrue(repository.existsBlockedBetween(viewer.id!!, creator.id!!))
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun saveMember(nickname: String, role: MemberRole, isActive: Boolean = true): Member {
|
||||||
|
val member = Member(
|
||||||
|
email = "$nickname@test.com",
|
||||||
|
password = "password",
|
||||||
|
nickname = nickname,
|
||||||
|
profileImage = "$nickname.png",
|
||||||
|
role = role,
|
||||||
|
isActive = isActive
|
||||||
|
)
|
||||||
|
entityManager.persist(member)
|
||||||
|
return member
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun saveBlock(member: Member, blockedMember: Member): BlockMember {
|
||||||
|
val block = BlockMember(isActive = true)
|
||||||
|
block.member = member
|
||||||
|
block.blockedMember = blockedMember
|
||||||
|
entityManager.persist(block)
|
||||||
|
return block
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun saveLiveRoom(
|
||||||
|
creator: Member,
|
||||||
|
beginDateTime: LocalDateTime,
|
||||||
|
channelName: String?,
|
||||||
|
isAdult: Boolean,
|
||||||
|
isActive: Boolean = true,
|
||||||
|
genderRestriction: GenderRestriction = GenderRestriction.ALL,
|
||||||
|
isAvailableJoinCreator: Boolean = true
|
||||||
|
): LiveRoom {
|
||||||
|
val liveRoom = LiveRoom(
|
||||||
|
title = "live-${creator.nickname}-$beginDateTime",
|
||||||
|
notice = "notice",
|
||||||
|
beginDateTime = beginDateTime,
|
||||||
|
numberOfPeople = 0,
|
||||||
|
coverImage = "live.png",
|
||||||
|
isAdult = isAdult,
|
||||||
|
price = 50,
|
||||||
|
isAvailableJoinCreator = isAvailableJoinCreator,
|
||||||
|
genderRestriction = genderRestriction
|
||||||
|
)
|
||||||
|
liveRoom.member = creator
|
||||||
|
liveRoom.channelName = channelName
|
||||||
|
liveRoom.isActive = isActive
|
||||||
|
entityManager.persist(liveRoom)
|
||||||
|
return liveRoom
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun saveAudioContent(
|
||||||
|
creator: Member,
|
||||||
|
releaseDate: LocalDateTime,
|
||||||
|
isAdult: Boolean,
|
||||||
|
theme: AudioContentTheme,
|
||||||
|
price: Int = 0,
|
||||||
|
isPointAvailable: Boolean = false
|
||||||
|
): AudioContent {
|
||||||
|
val content = AudioContent(
|
||||||
|
title = "audio-${creator.nickname}-$releaseDate",
|
||||||
|
detail = "detail",
|
||||||
|
languageCode = "ko",
|
||||||
|
releaseDate = releaseDate,
|
||||||
|
isAdult = isAdult,
|
||||||
|
price = price,
|
||||||
|
isPointAvailable = isPointAvailable
|
||||||
|
)
|
||||||
|
content.member = creator
|
||||||
|
content.theme = theme
|
||||||
|
content.isActive = true
|
||||||
|
content.coverImage = "audio.png"
|
||||||
|
content.duration = "00:10:00"
|
||||||
|
entityManager.persist(content)
|
||||||
|
return content
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun saveTheme(name: String, isActive: Boolean = true): AudioContentTheme {
|
||||||
|
val theme = AudioContentTheme(theme = name, image = "$name.png", isActive = isActive)
|
||||||
|
entityManager.persist(theme)
|
||||||
|
return theme
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun saveSeries(title: String, creator: Member, isOriginal: Boolean = false): Series {
|
||||||
|
val series = Series(title = title, introduction = "introduction", languageCode = "ko", isOriginal = isOriginal)
|
||||||
|
series.member = creator
|
||||||
|
series.genre = saveSeriesGenre(title)
|
||||||
|
series.coverImage = "$title.png"
|
||||||
|
entityManager.persist(series)
|
||||||
|
return series
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun saveSeriesGenre(name: String): SeriesGenre {
|
||||||
|
val genre = SeriesGenre(genre = "genre-$name", isAdult = false, isActive = true)
|
||||||
|
entityManager.persist(genre)
|
||||||
|
return genre
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun saveSeriesContent(series: Series, content: AudioContent): SeriesContent {
|
||||||
|
val seriesContent = SeriesContent()
|
||||||
|
seriesContent.series = series
|
||||||
|
seriesContent.content = content
|
||||||
|
entityManager.persist(seriesContent)
|
||||||
|
return seriesContent
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun saveOrder(
|
||||||
|
member: Member,
|
||||||
|
creator: Member,
|
||||||
|
content: AudioContent,
|
||||||
|
type: OrderType,
|
||||||
|
isActive: Boolean = true,
|
||||||
|
endDate: LocalDateTime? = null,
|
||||||
|
can: Int? = null,
|
||||||
|
point: Int = 0
|
||||||
|
): Order {
|
||||||
|
val order = Order(type = type, isActive = isActive)
|
||||||
|
order.member = member
|
||||||
|
order.creator = creator
|
||||||
|
order.audioContent = content
|
||||||
|
can?.let { order.can = it }
|
||||||
|
order.point = point
|
||||||
|
entityManager.persist(order)
|
||||||
|
if (endDate != null) {
|
||||||
|
entityManager.flush()
|
||||||
|
order.endDate = endDate
|
||||||
|
}
|
||||||
|
return order
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun flushAndClear() {
|
||||||
|
entityManager.flush()
|
||||||
|
entityManager.clear()
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user