diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/adapter/out/persistence/DefaultCreatorChannelHomeQueryRepository.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/adapter/out/persistence/DefaultCreatorChannelHomeQueryRepository.kt index c42dde50..da7f9c6a 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/adapter/out/persistence/DefaultCreatorChannelHomeQueryRepository.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/adapter/out/persistence/DefaultCreatorChannelHomeQueryRepository.kt @@ -9,6 +9,8 @@ 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.content.order.OrderType +import kr.co.vividnext.sodalive.content.order.QOrder.order 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 @@ -159,9 +161,11 @@ class DefaultCreatorChannelHomeQueryRepository( viewerId: Long? ): CreatorChannelAudioContentRecord? { val row = findAudioContentRows(creatorId, now, null, canViewAdultContent, 1).firstOrNull() ?: return null + val contentId = itAudioId(row) return row.toAudioRecord( firstContentId = firstAudioContentId(creatorId, now, canViewAdultContent), - seriesByContentId = audioSeriesByContentIds(listOf(itAudioId(row))) + seriesByContentId = audioSeriesByContentIds(listOf(contentId)), + orderStatesByContentId = orderStatesByContentIds(viewerId, listOf(contentId), now) ) } @@ -356,9 +360,11 @@ class DefaultCreatorChannelHomeQueryRepository( limit: Int ): List { val rows = findAudioContentRows(creatorId, now, latestAudioContentId, canViewAdultContent, limit) + val contentIds = rows.map { itAudioId(it) } val firstContentId = firstAudioContentId(creatorId, now, canViewAdultContent) - val seriesByContentId = audioSeriesByContentIds(rows.map { itAudioId(it) }) - return rows.map { it.toAudioRecord(firstContentId, seriesByContentId) } + val seriesByContentId = audioSeriesByContentIds(contentIds) + val orderStatesByContentId = orderStatesByContentIds(viewerId, contentIds, now) + return rows.map { it.toAudioRecord(firstContentId, seriesByContentId, orderStatesByContentId) } } override fun findSeries( @@ -551,10 +557,12 @@ class DefaultCreatorChannelHomeQueryRepository( private fun com.querydsl.core.Tuple.toAudioRecord( firstContentId: Long?, - seriesByContentId: Map + 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)!!, @@ -567,11 +575,38 @@ class DefaultCreatorChannelHomeQueryRepository( publishedAt = get(audioContent.releaseDate)!!, seriesName = seriesSummary?.title, isOriginalSeries = seriesSummary?.isOriginal, - isOwned = false, - isRented = false + isOwned = orderState?.isOwned ?: false, + isRented = orderState?.isRented ?: false ) } + 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 audioSeriesByContentIds(contentIds: List): Map { if (contentIds.isEmpty()) return emptyMap() return queryFactory @@ -912,6 +947,11 @@ class DefaultCreatorChannelHomeQueryRepository( val isOriginal: Boolean ) + private data class AudioOrderState( + val isOwned: Boolean, + val isRented: Boolean + ) + private data class SeriesContentStats( val contentCount: Int, val latestPublishedAt: LocalDateTime diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/adapter/out/persistence/DefaultCreatorChannelHomeQueryRepositoryTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/adapter/out/persistence/DefaultCreatorChannelHomeQueryRepositoryTest.kt index 824fd9d9..a935a1aa 100644 --- a/src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/adapter/out/persistence/DefaultCreatorChannelHomeQueryRepositoryTest.kt +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/adapter/out/persistence/DefaultCreatorChannelHomeQueryRepositoryTest.kt @@ -8,6 +8,8 @@ import kr.co.vividnext.sodalive.chat.character.ChatCharacter import kr.co.vividnext.sodalive.configs.QueryDslConfig import kr.co.vividnext.sodalive.content.AudioContent import kr.co.vividnext.sodalive.content.ContentType +import kr.co.vividnext.sodalive.content.order.Order +import kr.co.vividnext.sodalive.content.order.OrderType import kr.co.vividnext.sodalive.content.theme.AudioContentTheme import kr.co.vividnext.sodalive.creator.admin.content.series.Series import kr.co.vividnext.sodalive.creator.admin.content.series.SeriesContent @@ -526,6 +528,44 @@ class DefaultCreatorChannelHomeQueryRepositoryTest @Autowired constructor( assertTrue(records.last().isPointAvailable) } + @Test + @DisplayName("최신 오디오와 오디오 목록은 조회자의 유효한 소장/대여 주문 상태를 함께 반환한다") + fun shouldFindAudioContentOwnershipFlagsByViewerOrders() { + val now = LocalDateTime.of(2026, 6, 12, 12, 0) + val viewer = saveMember("audio-order-viewer", MemberRole.USER) + val creator = saveMember("audio-order-creator", MemberRole.CREATOR) + val keepAndRental = saveAudioContent(creator, now.minusDays(3), isAdult = false) + val rentalOnly = saveAudioContent(creator, now.minusDays(2), isAdult = false) + val keepOnly = saveAudioContent(creator, now.minusDays(1), isAdult = false) + saveOrder(viewer, creator, keepOnly, OrderType.KEEP) + saveOrder(viewer, creator, rentalOnly, OrderType.RENTAL, endDate = now.plusDays(1)) + saveOrder(viewer, creator, keepAndRental, OrderType.KEEP) + saveOrder(viewer, creator, keepAndRental, OrderType.RENTAL, endDate = now.plusDays(1)) + flushAndClear() + + val latestRecord = repository.findLatestAudioContent( + creator.id!!, + now, + canViewAdultContent = false, + viewerId = viewer.id!! + ) + val records = repository.findAudioContents( + creator.id!!, + now, + latestAudioContentId = latestRecord!!.audioContentId, + canViewAdultContent = false, + viewerId = viewer.id!!, + limit = 9 + ) + + assertEquals(keepOnly.id, latestRecord.audioContentId) + assertTrue(latestRecord.isOwned) + assertFalse(latestRecord.isRented) + assertEquals(listOf(rentalOnly.id, keepAndRental.id), records.map { it.audioContentId }) + assertEquals(listOf(false, true), records.map { it.isOwned }) + assertEquals(listOf(true, true), records.map { it.isRented }) + } + @Test @DisplayName("오디오 목록은 releaseDate가 null인 콘텐츠를 제외한다") fun shouldExcludeNullReleaseDateAudioContent() { @@ -1441,6 +1481,23 @@ class DefaultCreatorChannelHomeQueryRepositoryTest @Autowired constructor( return useCan } + private fun saveOrder( + member: Member, + creator: Member, + content: AudioContent, + type: OrderType, + isActive: Boolean = true, + endDate: LocalDateTime? = null + ): Order { + val order = Order(type = type, isActive = isActive) + order.member = member + order.creator = creator + order.audioContent = content + endDate?.let { order.endDate = it } + entityManager.persist(order) + return order + } + private fun saveCheers( member: Member, creator: Member,