diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/home/dto/CreatorChannelHomeResponse.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/home/dto/CreatorChannelHomeResponse.kt index 2bd07c61..47b64253 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/home/dto/CreatorChannelHomeResponse.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/home/dto/CreatorChannelHomeResponse.kt @@ -1,5 +1,6 @@ package kr.co.vividnext.sodalive.v2.api.creator.channel.home.dto +import com.fasterxml.jackson.annotation.JsonInclude import com.fasterxml.jackson.annotation.JsonProperty import kr.co.vividnext.sodalive.v2.api.creator.channel.common.dto.CreatorChannelAudioContentResponse import kr.co.vividnext.sodalive.v2.common.domain.CreatorActivityType @@ -109,6 +110,8 @@ data class CreatorChannelLiveResponse( } data class CreatorChannelDonationResponse( + @JsonInclude(JsonInclude.Include.NON_NULL) + val donationId: Long?, val nickname: String, val profileImageUrl: String, val can: Int, @@ -120,6 +123,7 @@ data class CreatorChannelDonationResponse( companion object { fun from(donation: CreatorChannelDonation): CreatorChannelDonationResponse { return CreatorChannelDonationResponse( + donationId = donation.donationId, nickname = donation.nickname, profileImageUrl = donation.profileImageUrl, can = donation.can, diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/home/adapter/out/persistence/DefaultCreatorChannelHomeQueryRepository.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/home/adapter/out/persistence/DefaultCreatorChannelHomeQueryRepository.kt index 6890fdd9..6e653e15 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/home/adapter/out/persistence/DefaultCreatorChannelHomeQueryRepository.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/home/adapter/out/persistence/DefaultCreatorChannelHomeQueryRepository.kt @@ -195,6 +195,7 @@ class DefaultCreatorChannelHomeQueryRepository( .select( Projections.constructor( CreatorChannelDonationRecord::class.java, + channelDonationMessage.id, channelDonationMessage.member.id, donorName.displayName, channelDonationMessage.member.profileImage, @@ -210,6 +211,7 @@ class DefaultCreatorChannelHomeQueryRepository( .leftJoin(donorName.name).on(donorName.condition) .where( channelDonationMessage.creator.id.eq(creatorId), + channelDonationMessage.isHidden.isFalse, channelDonationMessage.createdAt.goe(start), channelDonationMessage.createdAt.lt(end) ) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/home/application/CreatorChannelHomeQueryService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/home/application/CreatorChannelHomeQueryService.kt index c4f16bc0..2321a24b 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/home/application/CreatorChannelHomeQueryService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/home/application/CreatorChannelHomeQueryService.kt @@ -103,7 +103,7 @@ class CreatorChannelHomeQueryService( )?.toDomain(), latestAudioContent = latestAudioContent, channelDonations = queryPort.findChannelDonations(creatorId, viewerId, now, lang = lang) - .map { it.toDomain(creatorId, viewerId, lang) }, + .map { it.toDomain(creatorId, viewer, lang) }, notices = communityQueryService.findHomeCommunityPosts( creatorId = creatorId, viewerId = viewerId, @@ -197,9 +197,11 @@ class CreatorChannelHomeQueryService( isRented = isRented ) - private fun CreatorChannelDonationRecord.toDomain(creatorId: Long, viewerId: Long, lang: Lang): CreatorChannelDonation { + private fun CreatorChannelDonationRecord.toDomain(creatorId: Long, viewer: Member, lang: Lang): CreatorChannelDonation { + val viewerId = viewer.id!! val isMasked = isSecret && viewerId != creatorId && viewerId != memberId return CreatorChannelDonation( + donationId = donationId.takeIf { viewer.role == MemberRole.CREATOR && viewerId == creatorId }, memberId = memberId, nickname = when { isMasked -> messageSource.getMessage("explorer.channel_donation.anonymous.nickname", lang).orEmpty() diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/home/domain/CreatorChannelHome.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/home/domain/CreatorChannelHome.kt index 48732928..b95bac01 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/home/domain/CreatorChannelHome.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/home/domain/CreatorChannelHome.kt @@ -43,6 +43,7 @@ data class CreatorChannelLive( ) data class CreatorChannelDonation( + val donationId: Long?, val memberId: Long, val nickname: String, val profileImageUrl: String, diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/home/port/out/CreatorChannelHomeQueryPort.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/home/port/out/CreatorChannelHomeQueryPort.kt index 49c584d4..d26d33dc 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/home/port/out/CreatorChannelHomeQueryPort.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/home/port/out/CreatorChannelHomeQueryPort.kt @@ -114,6 +114,7 @@ data class CreatorChannelAudioContentRecord( ) data class CreatorChannelDonationRecord( + val donationId: Long, val memberId: Long, val nickname: String, val profileImagePath: String?, diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/home/CreatorChannelHomeEndToEndTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/home/CreatorChannelHomeEndToEndTest.kt index 92c96d22..cc17e8e1 100644 --- a/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/home/CreatorChannelHomeEndToEndTest.kt +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/home/CreatorChannelHomeEndToEndTest.kt @@ -35,6 +35,7 @@ import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMock import org.springframework.boot.test.context.SpringBootTest import org.springframework.boot.test.mock.mockito.MockBean import org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.user +import org.springframework.test.annotation.DirtiesContext import org.springframework.test.context.ContextConfiguration import org.springframework.test.web.servlet.MockMvc import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get @@ -54,6 +55,7 @@ import javax.persistence.EntityManager ) @AutoConfigureMockMvc @ContextConfiguration(initializers = [EmbeddedRedisInitializer::class]) +@DirtiesContext(classMode = DirtiesContext.ClassMode.AFTER_CLASS) class CreatorChannelHomeEndToEndTest @Autowired constructor( private val mockMvc: MockMvc, private val entityManager: EntityManager, @@ -178,9 +180,12 @@ class CreatorChannelHomeEndToEndTest @Autowired constructor( item["createdAtUtc"].asText() ) assertEquals( - setOf("nickname", "profileImageUrl", "can", "message", "createdAtUtc", "isSecret"), + setOf("nickname", "profileImageUrl", "can", "message", "createdAtUtc", "isSecret") + + if (viewerRole == "creator") setOf("donationId") else emptySet(), item.fieldNames().asSequence().toSet() ) + if (viewerRole == "creator") assertEquals(stored.id, item["donationId"]?.asLong()) + assertFalse(item.has("memberId")) val masked = stored.isSecret && fixture.viewer.id != fixture.creatorId && fixture.viewer.id != stored.member?.id if (masked) { assertEquals(anonymousNickname, item["nickname"].asText()) diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/home/adapter/in/web/CreatorChannelHomeControllerTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/home/adapter/in/web/CreatorChannelHomeControllerTest.kt index b5f062e5..a77fb6b9 100644 --- a/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/home/adapter/in/web/CreatorChannelHomeControllerTest.kt +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/home/adapter/in/web/CreatorChannelHomeControllerTest.kt @@ -254,6 +254,7 @@ class CreatorChannelHomeControllerTest @Autowired constructor( ), channelDonations = listOf( CreatorChannelDonation( + donationId = null, memberId = 20L, nickname = "fan", profileImageUrl = "fan.png", diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/home/application/CreatorChannelHomeFacadeTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/home/application/CreatorChannelHomeFacadeTest.kt index 6583349a..e67a104a 100644 --- a/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/home/application/CreatorChannelHomeFacadeTest.kt +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/home/application/CreatorChannelHomeFacadeTest.kt @@ -186,6 +186,7 @@ class CreatorChannelHomeFacadeTest { ), channelDonations = listOf( CreatorChannelDonation( + donationId = null, memberId = 20L, nickname = "fan", profileImageUrl = "fan.png", diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/home/adapter/out/persistence/DefaultCreatorChannelHomeQueryRepositoryTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/home/adapter/out/persistence/DefaultCreatorChannelHomeQueryRepositoryTest.kt index 297a64e4..74b4ffa0 100644 --- a/src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/home/adapter/out/persistence/DefaultCreatorChannelHomeQueryRepositoryTest.kt +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/home/adapter/out/persistence/DefaultCreatorChannelHomeQueryRepositoryTest.kt @@ -821,6 +821,55 @@ class DefaultCreatorChannelHomeQueryRepositoryTest @Autowired constructor( assertEquals(listOf(true, true, false, true, false, true, false, true), records.map { it.isSecret }) } + @Test + @DisplayName("홈은 최신 숨김 공개·비밀 후보를 제외한 뒤 동시각 ID순으로 visible 8건을 보충한다") + fun shouldRefillHomeWithLatestEightVisibleDonationsAfterHiddenRowsAreExcluded() { + val now = LocalDateTime.of(2026, 6, 12, 12, 0) + val creator = saveMember("hidden-home-creator", MemberRole.CREATOR) + val donor = saveMember("hidden-home-donor", MemberRole.USER) + val viewers = listOf( + creator, + donor, + saveMember("hidden-home-outsider", MemberRole.USER), + saveMember("hidden-home-other-creator", MemberRole.CREATOR), + saveMember("hidden-home-admin", MemberRole.ADMIN) + ) + val rows = (1..12).map { index -> + saveDonation( + creator, + donor, + index, + now.plusMinutes((index / 2).toLong()), + isSecret = index % 2 == 1, + isHidden = index >= 10 + ) + } + flushAndClear() + + viewers.forEach { viewer -> + val records = repository.findChannelDonations(creator.id!!, viewer.id!!, now, limit = 8, lang = Lang.KO) + assertEquals((9 downTo 2).toList(), records.map { it.can }) + assertEquals(rows.take(9).drop(1).reversed().map { it.id }, records.map { it.donationId }) + assertEquals(listOf(true, false, true, false, true, false, true, false), records.map { it.isSecret }) + } + } + + @Test + @DisplayName("모든 후원이 숨김이면 홈 후원은 빈 목록이다") + fun shouldReturnEmptyHomeDonationsWhenAllRowsAreHidden() { + val now = LocalDateTime.of(2026, 6, 12, 12, 0) + val creator = saveMember("all-hidden-home-creator", MemberRole.CREATOR) + val donor = saveMember("all-hidden-home-donor", MemberRole.USER) + listOf(false, true).forEach { secret -> + saveDonation(creator, donor, 100, now, isSecret = secret, isHidden = true) + } + flushAndClear() + + listOf(creator, donor).forEach { viewer -> + assertTrue(repository.findChannelDonations(creator.id!!, viewer.id!!, now, limit = 8, lang = Lang.KO).isEmpty()) + } + } + @Test @DisplayName("채널 후원 메시지는 추가 메시지만 반환하고 없으면 빈 문자열을 반환한다") fun shouldReturnOnlyAdditionalChannelDonationMessage() { @@ -1376,9 +1425,11 @@ class DefaultCreatorChannelHomeQueryRepositoryTest @Autowired constructor( can: Int, createdAt: LocalDateTime, isSecret: Boolean = false, - additionalMessage: String? = "thanks" + additionalMessage: String? = "thanks", + isHidden: Boolean = false ): ChannelDonationMessage { val donation = ChannelDonationMessage(can = can, isSecret = isSecret, additionalMessage = additionalMessage) + donation.isHidden = isHidden donation.creator = creator donation.member = donor entityManager.persist(donation) diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/home/application/CreatorChannelHomeQueryServiceTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/home/application/CreatorChannelHomeQueryServiceTest.kt index 9f3c4365..9eba480a 100644 --- a/src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/home/application/CreatorChannelHomeQueryServiceTest.kt +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/home/application/CreatorChannelHomeQueryServiceTest.kt @@ -204,6 +204,9 @@ class CreatorChannelHomeQueryServiceTest { "20, USER, true", "10, USER, true", "30, CREATOR, true", + "1, USER, true", + "1, ADMIN, true", + "40, ADMIN, true", "1, CREATOR, false", "20, USER, false", "10, USER, false", @@ -237,6 +240,7 @@ class CreatorChannelHomeQueryServiceTest { assertEquals(isSecret, donation.isSecret) assertEquals(30, donation.can) assertEquals(LocalDateTime.of(2026, 6, 13, 8, 0), donation.createdAt) + assertEquals(if (role == MemberRole.CREATOR && viewerId == 1L) 101L else null, donation.donationId) } } } @@ -455,6 +459,7 @@ class CreatorChannelHomeQueryServiceTest { ), channelDonations = listOf( CreatorChannelDonation( + donationId = null, memberId = 20L, nickname = "fan", profileImageUrl = "fan.png", @@ -690,6 +695,7 @@ private class FakeCreatorChannelHomeQueryPort(private val donorId: Long = 20L) : lang: Lang ): List = listOf( CreatorChannelDonationRecord( + donationId = 101L, memberId = donorId, nickname = channelDonationNickname, profileImagePath = channelDonationProfilePath,