feat(channel-home): 숨긴 후원을 제외하고 소유자에게 후원 ID를 제공한다

This commit is contained in:
2026-11-05 02:19:45 +09:00
parent d0b5fff40d
commit f073de374d
10 changed files with 78 additions and 4 deletions
@@ -1,5 +1,6 @@
package kr.co.vividnext.sodalive.v2.api.creator.channel.home.dto package kr.co.vividnext.sodalive.v2.api.creator.channel.home.dto
import com.fasterxml.jackson.annotation.JsonInclude
import com.fasterxml.jackson.annotation.JsonProperty import com.fasterxml.jackson.annotation.JsonProperty
import kr.co.vividnext.sodalive.v2.api.creator.channel.common.dto.CreatorChannelAudioContentResponse import kr.co.vividnext.sodalive.v2.api.creator.channel.common.dto.CreatorChannelAudioContentResponse
import kr.co.vividnext.sodalive.v2.common.domain.CreatorActivityType import kr.co.vividnext.sodalive.v2.common.domain.CreatorActivityType
@@ -109,6 +110,8 @@ data class CreatorChannelLiveResponse(
} }
data class CreatorChannelDonationResponse( data class CreatorChannelDonationResponse(
@JsonInclude(JsonInclude.Include.NON_NULL)
val donationId: Long?,
val nickname: String, val nickname: String,
val profileImageUrl: String, val profileImageUrl: String,
val can: Int, val can: Int,
@@ -120,6 +123,7 @@ data class CreatorChannelDonationResponse(
companion object { companion object {
fun from(donation: CreatorChannelDonation): CreatorChannelDonationResponse { fun from(donation: CreatorChannelDonation): CreatorChannelDonationResponse {
return CreatorChannelDonationResponse( return CreatorChannelDonationResponse(
donationId = donation.donationId,
nickname = donation.nickname, nickname = donation.nickname,
profileImageUrl = donation.profileImageUrl, profileImageUrl = donation.profileImageUrl,
can = donation.can, can = donation.can,
@@ -195,6 +195,7 @@ class DefaultCreatorChannelHomeQueryRepository(
.select( .select(
Projections.constructor( Projections.constructor(
CreatorChannelDonationRecord::class.java, CreatorChannelDonationRecord::class.java,
channelDonationMessage.id,
channelDonationMessage.member.id, channelDonationMessage.member.id,
donorName.displayName, donorName.displayName,
channelDonationMessage.member.profileImage, channelDonationMessage.member.profileImage,
@@ -210,6 +211,7 @@ class DefaultCreatorChannelHomeQueryRepository(
.leftJoin(donorName.name).on(donorName.condition) .leftJoin(donorName.name).on(donorName.condition)
.where( .where(
channelDonationMessage.creator.id.eq(creatorId), channelDonationMessage.creator.id.eq(creatorId),
channelDonationMessage.isHidden.isFalse,
channelDonationMessage.createdAt.goe(start), channelDonationMessage.createdAt.goe(start),
channelDonationMessage.createdAt.lt(end) channelDonationMessage.createdAt.lt(end)
) )
@@ -103,7 +103,7 @@ class CreatorChannelHomeQueryService(
)?.toDomain(), )?.toDomain(),
latestAudioContent = latestAudioContent, latestAudioContent = latestAudioContent,
channelDonations = queryPort.findChannelDonations(creatorId, viewerId, now, lang = lang) channelDonations = queryPort.findChannelDonations(creatorId, viewerId, now, lang = lang)
.map { it.toDomain(creatorId, viewerId, lang) }, .map { it.toDomain(creatorId, viewer, lang) },
notices = communityQueryService.findHomeCommunityPosts( notices = communityQueryService.findHomeCommunityPosts(
creatorId = creatorId, creatorId = creatorId,
viewerId = viewerId, viewerId = viewerId,
@@ -197,9 +197,11 @@ class CreatorChannelHomeQueryService(
isRented = isRented 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 val isMasked = isSecret && viewerId != creatorId && viewerId != memberId
return CreatorChannelDonation( return CreatorChannelDonation(
donationId = donationId.takeIf { viewer.role == MemberRole.CREATOR && viewerId == creatorId },
memberId = memberId, memberId = memberId,
nickname = when { nickname = when {
isMasked -> messageSource.getMessage("explorer.channel_donation.anonymous.nickname", lang).orEmpty() isMasked -> messageSource.getMessage("explorer.channel_donation.anonymous.nickname", lang).orEmpty()
@@ -43,6 +43,7 @@ data class CreatorChannelLive(
) )
data class CreatorChannelDonation( data class CreatorChannelDonation(
val donationId: Long?,
val memberId: Long, val memberId: Long,
val nickname: String, val nickname: String,
val profileImageUrl: String, val profileImageUrl: String,
@@ -114,6 +114,7 @@ data class CreatorChannelAudioContentRecord(
) )
data class CreatorChannelDonationRecord( data class CreatorChannelDonationRecord(
val donationId: Long,
val memberId: Long, val memberId: Long,
val nickname: String, val nickname: String,
val profileImagePath: String?, val profileImagePath: String?,
@@ -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.context.SpringBootTest
import org.springframework.boot.test.mock.mockito.MockBean import org.springframework.boot.test.mock.mockito.MockBean
import org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.user 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.context.ContextConfiguration
import org.springframework.test.web.servlet.MockMvc import org.springframework.test.web.servlet.MockMvc
import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get
@@ -54,6 +55,7 @@ import javax.persistence.EntityManager
) )
@AutoConfigureMockMvc @AutoConfigureMockMvc
@ContextConfiguration(initializers = [EmbeddedRedisInitializer::class]) @ContextConfiguration(initializers = [EmbeddedRedisInitializer::class])
@DirtiesContext(classMode = DirtiesContext.ClassMode.AFTER_CLASS)
class CreatorChannelHomeEndToEndTest @Autowired constructor( class CreatorChannelHomeEndToEndTest @Autowired constructor(
private val mockMvc: MockMvc, private val mockMvc: MockMvc,
private val entityManager: EntityManager, private val entityManager: EntityManager,
@@ -178,9 +180,12 @@ class CreatorChannelHomeEndToEndTest @Autowired constructor(
item["createdAtUtc"].asText() item["createdAtUtc"].asText()
) )
assertEquals( 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() 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 val masked = stored.isSecret && fixture.viewer.id != fixture.creatorId && fixture.viewer.id != stored.member?.id
if (masked) { if (masked) {
assertEquals(anonymousNickname, item["nickname"].asText()) assertEquals(anonymousNickname, item["nickname"].asText())
@@ -254,6 +254,7 @@ class CreatorChannelHomeControllerTest @Autowired constructor(
), ),
channelDonations = listOf( channelDonations = listOf(
CreatorChannelDonation( CreatorChannelDonation(
donationId = null,
memberId = 20L, memberId = 20L,
nickname = "fan", nickname = "fan",
profileImageUrl = "fan.png", profileImageUrl = "fan.png",
@@ -186,6 +186,7 @@ class CreatorChannelHomeFacadeTest {
), ),
channelDonations = listOf( channelDonations = listOf(
CreatorChannelDonation( CreatorChannelDonation(
donationId = null,
memberId = 20L, memberId = 20L,
nickname = "fan", nickname = "fan",
profileImageUrl = "fan.png", profileImageUrl = "fan.png",
@@ -821,6 +821,55 @@ class DefaultCreatorChannelHomeQueryRepositoryTest @Autowired constructor(
assertEquals(listOf(true, true, false, true, false, true, false, true), records.map { it.isSecret }) 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 @Test
@DisplayName("채널 후원 메시지는 추가 메시지만 반환하고 없으면 빈 문자열을 반환한다") @DisplayName("채널 후원 메시지는 추가 메시지만 반환하고 없으면 빈 문자열을 반환한다")
fun shouldReturnOnlyAdditionalChannelDonationMessage() { fun shouldReturnOnlyAdditionalChannelDonationMessage() {
@@ -1376,9 +1425,11 @@ class DefaultCreatorChannelHomeQueryRepositoryTest @Autowired constructor(
can: Int, can: Int,
createdAt: LocalDateTime, createdAt: LocalDateTime,
isSecret: Boolean = false, isSecret: Boolean = false,
additionalMessage: String? = "thanks" additionalMessage: String? = "thanks",
isHidden: Boolean = false
): ChannelDonationMessage { ): ChannelDonationMessage {
val donation = ChannelDonationMessage(can = can, isSecret = isSecret, additionalMessage = additionalMessage) val donation = ChannelDonationMessage(can = can, isSecret = isSecret, additionalMessage = additionalMessage)
donation.isHidden = isHidden
donation.creator = creator donation.creator = creator
donation.member = donor donation.member = donor
entityManager.persist(donation) entityManager.persist(donation)
@@ -204,6 +204,9 @@ class CreatorChannelHomeQueryServiceTest {
"20, USER, true", "20, USER, true",
"10, USER, true", "10, USER, true",
"30, CREATOR, true", "30, CREATOR, true",
"1, USER, true",
"1, ADMIN, true",
"40, ADMIN, true",
"1, CREATOR, false", "1, CREATOR, false",
"20, USER, false", "20, USER, false",
"10, USER, false", "10, USER, false",
@@ -237,6 +240,7 @@ class CreatorChannelHomeQueryServiceTest {
assertEquals(isSecret, donation.isSecret) assertEquals(isSecret, donation.isSecret)
assertEquals(30, donation.can) assertEquals(30, donation.can)
assertEquals(LocalDateTime.of(2026, 6, 13, 8, 0), donation.createdAt) 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( channelDonations = listOf(
CreatorChannelDonation( CreatorChannelDonation(
donationId = null,
memberId = 20L, memberId = 20L,
nickname = "fan", nickname = "fan",
profileImageUrl = "fan.png", profileImageUrl = "fan.png",
@@ -690,6 +695,7 @@ private class FakeCreatorChannelHomeQueryPort(private val donorId: Long = 20L) :
lang: Lang lang: Lang
): List<CreatorChannelDonationRecord> = listOf( ): List<CreatorChannelDonationRecord> = listOf(
CreatorChannelDonationRecord( CreatorChannelDonationRecord(
donationId = 101L,
memberId = donorId, memberId = donorId,
nickname = channelDonationNickname, nickname = channelDonationNickname,
profileImagePath = channelDonationProfilePath, profileImagePath = channelDonationProfilePath,