From 3d843ac5d666e2a0d0a45b2b885ab0578793a020 Mon Sep 17 00:00:00 2001 From: Klaus Date: Wed, 17 Jun 2026 19:16:50 +0900 Subject: [PATCH] =?UTF-8?q?feat(creator):=20=EC=B1=84=EB=84=90=20=EB=9D=BC?= =?UTF-8?q?=EC=9D=B4=EB=B8=8C=20=EB=8B=A4=EC=8B=9C=EB=93=A3=EA=B8=B0=20?= =?UTF-8?q?=EC=A0=80=EC=9E=A5=EC=86=8C=EB=A5=BC=20=EC=B6=94=EA=B0=80?= =?UTF-8?q?=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../plan-task.md | 16 +- .../CreatorChannelLiveQueryRepository.kt | 5 + ...efaultCreatorChannelLiveQueryRepository.kt | 365 ++++++++++++++++++ ...ltCreatorChannelLiveQueryRepositoryTest.kt | 355 +++++++++++++++++ 4 files changed, 736 insertions(+), 5 deletions(-) create mode 100644 src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/live/adapter/out/persistence/CreatorChannelLiveQueryRepository.kt create mode 100644 src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/live/adapter/out/persistence/DefaultCreatorChannelLiveQueryRepository.kt create mode 100644 src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/live/adapter/out/persistence/DefaultCreatorChannelLiveQueryRepositoryTest.kt diff --git a/docs/20260617_크리에이터_채널_라이브_API/plan-task.md b/docs/20260617_크리에이터_채널_라이브_API/plan-task.md index 88ba7e3b..1b1fb5ca 100644 --- a/docs/20260617_크리에이터_채널_라이브_API/plan-task.md +++ b/docs/20260617_크리에이터_채널_라이브_API/plan-task.md @@ -291,7 +291,7 @@ private fun LocalDateTime.toUtcIso(): String { ### Phase 3: 라이브 다시듣기 persistence adapter -- [ ] **Task 3.1: 라이브 탭 repository 골격과 count 조회 추가** +- [x] **Task 3.1: 라이브 탭 repository 골격과 count 조회 추가** - 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/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다. - 통과 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.live.adapter.out.persistence.DefaultCreatorChannelLiveQueryRepositoryTest` - 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: - 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` @@ -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로 처리한다. - 통과 확인: `./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 함수로 분리한다. + - 검증 기록(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: - 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` @@ -324,8 +326,9 @@ private fun LocalDateTime.toUtcIso(): String { - GREEN: orders left join 또는 집계 subquery로 콘텐츠별 활성 주문의 `can` 합계를 계산하고 `POPULAR` 정렬에 반영한다. `point`는 더하지 않는다. 매출이 없으면 0으로 처리한다. - 통과 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.live.adapter.out.persistence.DefaultCreatorChannelLiveQueryRepositoryTest` - 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: - 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` @@ -337,8 +340,9 @@ private fun LocalDateTime.toUtcIso(): String { - 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` - REFACTOR: 응답 상태 판정과 `OWNED` 정렬 판정의 의미가 어긋나지 않도록 동일한 유효 주문 조건을 사용한다. + - 검증 기록(2026-06-17): `OWNED` 정렬은 조회자의 활성 `KEEP` 주문 존재 여부를 QueryDSL group by 정렬 기준으로 삼고, 응답의 `isOwned`/`isRented`는 조회된 content id 목록 기준 bulk 조회로 채우도록 구현했다. 유효 대여, 만료 대여, 소장+대여 동시 존재를 `shouldSortLiveReplayAudioContentsByOwnedAndReturnOrderStates`로 확인했다. -- [ ] **Task 3.5: 현재 라이브 조회 위임 구현** +- [x] **Task 3.5: 현재 라이브 조회 위임 구현** - Files: - 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` @@ -347,6 +351,7 @@ private fun LocalDateTime.toUtcIso(): String { - GREEN: `DefaultCreatorChannelLiveQueryRepository.findCurrentLive`는 기존 `DefaultCreatorChannelHomeQueryRepository.findCurrentLive`와 같은 조건을 사용한다. 코드 중복이 과하면 private helper를 복사하되, 동작 보존을 우선한다. - 통과 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.live.adapter.out.persistence.DefaultCreatorChannelLiveQueryRepositoryTest` - 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 아래에 무엇을, 왜, 어떻게 검증했는지 실행 명령과 결과를 누적 기록한다. - 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`로 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을 받았다. diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/live/adapter/out/persistence/CreatorChannelLiveQueryRepository.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/live/adapter/out/persistence/CreatorChannelLiveQueryRepository.kt new file mode 100644 index 00000000..8756477b --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/live/adapter/out/persistence/CreatorChannelLiveQueryRepository.kt @@ -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 diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/live/adapter/out/persistence/DefaultCreatorChannelLiveQueryRepository.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/live/adapter/out/persistence/DefaultCreatorChannelLiveQueryRepository.kt new file mode 100644 index 00000000..f258a5d6 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/live/adapter/out/persistence/DefaultCreatorChannelLiveQueryRepository.kt @@ -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 { + 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 { + 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, + orderStatesByContentId: Map + ): 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): Map { + 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, + now: LocalDateTime + ): Map { + 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 = "다시듣기" + } +} diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/live/adapter/out/persistence/DefaultCreatorChannelLiveQueryRepositoryTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/live/adapter/out/persistence/DefaultCreatorChannelLiveQueryRepositoryTest.kt new file mode 100644 index 00000000..7457b0f4 --- /dev/null +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/live/adapter/out/persistence/DefaultCreatorChannelLiveQueryRepositoryTest.kt @@ -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() + } +}