feat(channel-home): 비밀 후원을 권한에 따라 익명 표시한다

This commit is contained in:
2026-11-04 22:02:00 +09:00
parent 8b3aab232f
commit 6947675322
11 changed files with 324 additions and 41 deletions
@@ -1,5 +1,6 @@
package kr.co.vividnext.sodalive.v2.api.creator.channel.home
import com.fasterxml.jackson.databind.ObjectMapper
import kr.co.vividnext.sodalive.admin.content.series.genre.SeriesGenre
import kr.co.vividnext.sodalive.common.CountryContext
import kr.co.vividnext.sodalive.content.AudioContent
@@ -11,6 +12,7 @@ import kr.co.vividnext.sodalive.creator.admin.content.series.SeriesContent
import kr.co.vividnext.sodalive.explorer.profile.CreatorCheers
import kr.co.vividnext.sodalive.explorer.profile.channelDonation.ChannelDonationMessage
import kr.co.vividnext.sodalive.explorer.profile.creatorCommunity.CreatorCommunity
import kr.co.vividnext.sodalive.i18n.Lang
import kr.co.vividnext.sodalive.i18n.translation.SourceTextNormalizer
import kr.co.vividnext.sodalive.live.room.LiveRoom
import kr.co.vividnext.sodalive.live.room.visit.LiveRoomVisit
@@ -19,8 +21,14 @@ import kr.co.vividnext.sodalive.member.MemberAdapter
import kr.co.vividnext.sodalive.member.MemberRole
import kr.co.vividnext.sodalive.support.EmbeddedRedisInitializer
import kr.co.vividnext.sodalive.v2.creator.channel.community.translation.adapter.out.persistence.CreatorCommunityTranslation
import kr.co.vividnext.sodalive.v2.creatorname.adapter.out.persistence.CreatorLocalizedNameEntity
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertFalse
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.DisplayName
import org.junit.jupiter.api.Test
import org.junit.jupiter.params.ParameterizedTest
import org.junit.jupiter.params.provider.CsvSource
import org.mockito.Mockito
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc
@@ -49,7 +57,8 @@ import javax.persistence.EntityManager
class CreatorChannelHomeEndToEndTest @Autowired constructor(
private val mockMvc: MockMvc,
private val entityManager: EntityManager,
private val transactionTemplate: TransactionTemplate
private val transactionTemplate: TransactionTemplate,
private val objectMapper: ObjectMapper
) {
@MockBean
private lateinit var countryContext: CountryContext
@@ -67,6 +76,7 @@ class CreatorChannelHomeEndToEndTest @Autowired constructor(
.andExpect(jsonPath("$.data.currentLive.liveId").value(fixture.currentLiveId))
.andExpect(jsonPath("$.data.latestAudioContent.audioContentId").value(fixture.latestAudioId))
.andExpect(jsonPath("$.data.channelDonations[0].can").value(500))
.andExpect(jsonPath("$.data.channelDonations[0].isSecret").value(false))
.andExpect(jsonPath("$.data.notices[0].postId").value(fixture.noticeId))
.andExpect(jsonPath("$.data.notices[0].isPinned").value(true))
.andExpect(jsonPath("$.data.notices[0].isCommentAvailable").value(true))
@@ -106,6 +116,164 @@ class CreatorChannelHomeEndToEndTest @Autowired constructor(
.andExpect(jsonPath("$.data.communities[0].content").value("translated community"))
}
@ParameterizedTest(name = "{0}, {1}")
@CsvSource(
"creator, ko", "donor, ko", "defaultDonor, ko", "outsider, ko", "otherCreator, ko",
"creator, en", "donor, en", "defaultDonor, en", "outsider, en", "otherCreator, en",
"creator, ja", "donor, ja", "defaultDonor, ja", "outsider, ja", "otherCreator, ja"
)
@DisplayName("홈 비밀 후원은 본인과 채널 주인만 원본을 보고 타인은 요청 언어로 익명 표시한다")
fun shouldMaskOnlyOtherMembersSecretDonationsThroughHttp(viewerRole: String, language: String) {
val fixture = transactionTemplate.execute {
val creator = saveMember("secret-home-creator", MemberRole.CREATOR)
val donor = saveMember("deleted_secret-home-donor", MemberRole.CREATOR)
val defaultDonor = saveMember("secret-home-default-donor", MemberRole.USER).apply { profileImage = null }
val viewers = mapOf(
"creator" to creator,
"donor" to donor,
"defaultDonor" to defaultDonor,
"outsider" to saveMember("secret-home-outsider", MemberRole.USER),
"otherCreator" to saveMember("secret-home-other-creator", MemberRole.CREATOR)
)
listOf(Lang.EN, Lang.JA).forEach { lang ->
entityManager.persist(CreatorLocalizedNameEntity(donor.id!!, lang, "deleted_localized-${lang.code}"))
}
val start = currentKstMonthStartUtc()
val rows = listOf(
saveDonation(creator, donor, start.plusMinutes(1), 100, false, "public thanks"),
saveDonation(creator, defaultDonor, start.plusMinutes(2), 200, false, null),
saveDonation(creator, donor, start.plusMinutes(3), 300, true, "private thanks"),
saveDonation(creator, donor, start.plusMinutes(4), 400, true, null),
saveDonation(creator, donor, start.plusMinutes(5), 500, true, ""),
saveDonation(creator, defaultDonor, start.plusMinutes(6), 600, true, "other private thanks")
)
entityManager.flush()
entityManager.clear()
val persistedRows = rows.map { entityManager.find(ChannelDonationMessage::class.java, it.id!!) }
persistedRows.forEach { row -> assertEquals(start.plusMinutes((row.can / 100).toLong()), row.createdAt) }
DonationFixture(creator.id!!, donor.id!!, viewers.getValue(viewerRole), persistedRows)
} ?: throw IllegalStateException("donation fixture creation failed")
val result = mockMvc.perform(
get("/api/v2/creator-channels/${fixture.creatorId}/home")
.header("Accept-Language", language)
.with(user(MemberAdapter(fixture.viewer)))
)
.andExpect(status().isOk)
.andExpect(jsonPath("$.data.donationCount").doesNotExist())
.andReturn()
val donations = objectMapper.readTree(result.response.contentAsByteArray)["data"]["channelDonations"]
assertEquals(listOf(600, 500, 400, 300, 200, 100), donations.map { it["can"].asInt() })
val anonymousNickname = mapOf("ko" to "익명", "en" to "Anonymous", "ja" to "匿名").getValue(language)
val anonymousMessage = mapOf(
"ko" to "크리에이터에게만 보여요",
"en" to "Only visible to the creator",
"ja" to "クリエイターにのみ表示されます"
).getValue(language)
fixture.donations.sortedByDescending { it.can }.zip(donations).forEach { (stored, item) ->
assertTrue(item.path("isSecret").isBoolean)
assertEquals(stored.isSecret, item["isSecret"].asBoolean())
assertEquals(stored.can, item["can"].asInt())
assertEquals(
requireNotNull(stored.createdAt).atOffset(ZoneOffset.UTC).toInstant().toString(),
item["createdAtUtc"].asText()
)
assertEquals(
setOf("nickname", "profileImageUrl", "can", "message", "createdAtUtc", "isSecret"),
item.fieldNames().asSequence().toSet()
)
val masked = stored.isSecret && fixture.viewer.id != fixture.creatorId && fixture.viewer.id != stored.member?.id
if (masked) {
assertEquals(anonymousNickname, item["nickname"].asText())
assertEquals(anonymousMessage, item["message"].asText())
assertEquals("https://cdn.test/profile/anonymous-profile.png", item["profileImageUrl"].asText())
assertFalse(item.toString().contains("secret-home-donor"))
assertFalse(item.toString().contains("localized-"))
assertFalse(item.toString().contains("private thanks"))
} else {
val isDonor = stored.member?.id == fixture.donorId
val nickname = if (isDonor) {
if (language == "ko") "secret-home-donor" else "deleted_localized-$language"
} else {
"secret-home-default-donor"
}
val profile = if (isDonor) "deleted_secret-home-donor.png" else "profile/default-profile.png"
assertEquals(nickname, item["nickname"].asText())
assertEquals(stored.additionalMessage.orEmpty(), item["message"].asText())
assertEquals("https://cdn.test/$profile", item["profileImageUrl"].asText())
}
}
}
@Test
@DisplayName("홈은 KST 월 시작을 포함하고 다음 월을 제외하며 공개·비밀 전체에서 최신 8건을 반환한다")
fun shouldKeepKstMonthBoundsAndLatestEightMixedDonationsThroughHttp() {
fun nextMonthStartUtc(monthStartUtc: LocalDateTime): LocalDateTime =
monthStartUtc.plusHours(9).plusMonths(1).minusHours(9)
assertEquals(
LocalDateTime.of(2026, 12, 31, 15, 0),
nextMonthStartUtc(LocalDateTime.of(2026, 11, 30, 15, 0))
)
val start = currentKstMonthStartUtc()
val end = nextMonthStartUtc(start)
val members = transactionTemplate.execute {
val creator = saveMember("month-home-creator", MemberRole.CREATOR)
val donor = saveMember("month-home-donor", MemberRole.USER)
val viewer = saveMember("month-home-viewer", MemberRole.USER)
saveDonation(creator, donor, start.minusSeconds(1), 900, true)
saveDonation(creator, donor, start, 901, true)
saveDonation(creator, donor, end, 902, true)
saveDonation(creator, donor, end.minusSeconds(1), 903, false)
Triple(creator, donor, viewer)
} ?: throw IllegalStateException("month fixture creation failed")
val boundaryResult = mockMvc.perform(
get("/api/v2/creator-channels/${members.first.id}/home").with(user(MemberAdapter(members.third)))
)
.andExpect(status().isOk)
.andExpect(jsonPath("$.data.channelDonations[0].createdAtUtc").value("${end.minusSeconds(1)}Z"))
.andExpect(jsonPath("$.data.channelDonations[1].createdAtUtc").value("$start:00Z"))
.andExpect(jsonPath("$.data.channelDonations[1].isSecret").value(true))
.andReturn()
val boundaryItems = objectMapper.readTree(boundaryResult.response.contentAsByteArray)["data"]["channelDonations"]
assertEquals(listOf(903, 901), boundaryItems.map { it["can"].asInt() })
transactionTemplate.execute {
val creator = entityManager.find(Member::class.java, members.first.id)
val donor = entityManager.find(Member::class.java, members.second.id)
(1..10).forEach { index ->
saveDonation(creator, donor, start.plusHours((index / 2).toLong()), index, index % 2 == 1)
}
entityManager.flush()
entityManager.clear()
}
val result = mockMvc.perform(
get("/api/v2/creator-channels/${members.first.id}/home").with(user(MemberAdapter(members.third)))
)
.andExpect(status().isOk)
.andReturn()
val items = objectMapper.readTree(result.response.contentAsByteArray)["data"]["channelDonations"]
assertEquals(listOf(903, 10, 9, 8, 7, 6, 5, 4), items.map { it["can"].asInt() })
assertEquals(listOf(false, false, true, false, true, false, true, false), items.map { it["isSecret"].asBoolean() })
val persisted = transactionTemplate.execute {
entityManager.createQuery(
"select d from ChannelDonationMessage d where d.creator.id = :creatorId order by d.createdAt desc, d.id desc",
ChannelDonationMessage::class.java
).setParameter("creatorId", members.first.id).resultList.associateBy { it.can }
}!!
items.forEach { item ->
val stored = persisted.getValue(item["can"].asInt())
assertEquals(
requireNotNull(stored.createdAt).atOffset(ZoneOffset.UTC).toInstant().toString(),
item["createdAtUtc"].asText()
)
}
}
private fun currentKstMonthStartUtc(): LocalDateTime =
LocalDateTime.now(ZoneOffset.UTC).plusHours(9).toLocalDate().withDayOfMonth(1).atStartOfDay().minusHours(9)
private fun createFixture(): Fixture {
return transactionTemplate.execute {
val now = LocalDateTime.now(ZoneOffset.UTC)
@@ -231,8 +399,15 @@ class CreatorChannelHomeEndToEndTest @Autowired constructor(
return order
}
private fun saveDonation(creator: Member, donor: Member, createdAt: LocalDateTime): ChannelDonationMessage {
val donation = ChannelDonationMessage(can = 500, isSecret = false, additionalMessage = "e2e thanks")
private fun saveDonation(
creator: Member,
donor: Member,
createdAt: LocalDateTime,
can: Int = 500,
isSecret: Boolean = false,
message: String? = "e2e thanks"
): ChannelDonationMessage {
val donation = ChannelDonationMessage(can = can, isSecret = isSecret, additionalMessage = message)
donation.creator = creator
donation.member = donor
entityManager.persist(donation)
@@ -301,6 +476,13 @@ class CreatorChannelHomeEndToEndTest @Autowired constructor(
.executeUpdate()
}
private data class DonationFixture(
val creatorId: Long,
val donorId: Long,
val viewer: Member,
val donations: List<ChannelDonationMessage>
)
private data class Fixture(
val viewer: Member,
val creatorId: Long,
@@ -139,7 +139,7 @@ class CreatorChannelHomeControllerTest @Autowired constructor(
.andExpect(jsonPath("$.data.schedules[0].isAdult").doesNotExist())
.andExpect(jsonPath("$.data.channelDonations[0].donationId").doesNotExist())
.andExpect(jsonPath("$.data.channelDonations[0].memberId").doesNotExist())
.andExpect(jsonPath("$.data.channelDonations[0].isSecret").doesNotExist())
.andExpect(jsonPath("$.data.channelDonations[0].isSecret").value(true))
.andExpect(jsonPath("$.data.notices[0].dateUtc").value("2026-06-12T04:00:00Z"))
.andExpect(jsonPath("$.data.notices[0].imageUrl").doesNotExist())
.andExpect(jsonPath("$.data.notices[0].audioUrl").doesNotExist())
@@ -259,7 +259,8 @@ class CreatorChannelHomeControllerTest @Autowired constructor(
profileImageUrl = "fan.png",
can = 50,
message = "thanks",
createdAt = LocalDateTime.of(2026, 6, 12, 2, 0)
createdAt = LocalDateTime.of(2026, 6, 12, 2, 0),
isSecret = true
)
),
notices = listOf(post),
@@ -64,6 +64,7 @@ class CreatorChannelHomeFacadeTest {
assertTrue(response.latestAudioContent?.isOwned == true)
assertFalse(response.latestAudioContent?.isRented == true)
assertEquals("thanks", response.channelDonations.first().message)
assertTrue(response.channelDonations.first().isSecret)
assertEquals(301L, response.notices.first().postId)
assertEquals("2026-06-12T04:00:00Z", response.notices.first().dateUtc)
assertNull(response.notices.first().imageUrl)
@@ -190,7 +191,8 @@ class CreatorChannelHomeFacadeTest {
profileImageUrl = "fan.png",
can = 50,
message = "thanks",
createdAt = LocalDateTime.of(2026, 6, 12, 2, 0)
createdAt = LocalDateTime.of(2026, 6, 12, 2, 0),
isSecret = true
)
),
notices = listOf(post),
@@ -779,13 +779,16 @@ class DefaultCreatorChannelHomeQueryRepositoryTest @Autowired constructor(
}
@Test
@DisplayName("채널 후원은 KST 기준 이번 달과 크리에이터의 비밀 후원 열람을 반영한다")
fun shouldFindKstMonthDonationsAndExposeSecretDonationToCreator() {
@DisplayName("채널 후원은 KST 기준 이번 달 공개·비밀 후원을 조회자와 무관하게 포함한다")
fun shouldFindAllKstMonthDonationsForEveryViewer() {
val now = LocalDateTime.of(2026, 5, 31, 15, 30)
val creator = saveMember("kst-donation-creator", MemberRole.CREATOR)
val donor = saveMember("kst-donation-donor", MemberRole.USER)
saveDonation(creator, donor, 300, LocalDateTime.of(2026, 5, 31, 15, 10), isSecret = true)
saveDonation(creator, donor, 100, LocalDateTime.of(2026, 5, 31, 14, 50), isSecret = false)
saveDonation(creator, donor, 200, LocalDateTime.of(2026, 5, 31, 15, 0), isSecret = true)
saveDonation(creator, donor, 400, LocalDateTime.of(2026, 6, 30, 14, 59, 59), isSecret = true)
saveDonation(creator, donor, 500, LocalDateTime.of(2026, 6, 30, 15, 0), isSecret = true)
val thirdParty = saveMember("kst-donation-third-party", MemberRole.USER)
flushAndClear()
@@ -793,9 +796,29 @@ class DefaultCreatorChannelHomeQueryRepositoryTest @Autowired constructor(
val donorView = repository.findChannelDonations(creator.id!!, donor.id!!, now, limit = 8, lang = Lang.KO)
val thirdPartyView = repository.findChannelDonations(creator.id!!, thirdParty.id!!, now, limit = 8, lang = Lang.KO)
assertEquals(listOf(300), creatorView.map { it.can })
assertEquals(listOf(300), donorView.map { it.can })
assertEquals(emptyList<Int>(), thirdPartyView.map { it.can })
assertEquals(listOf(400, 300, 200), creatorView.map { it.can })
assertEquals(listOf(400, 300, 200), donorView.map { it.can })
assertEquals(listOf(400, 300, 200), thirdPartyView.map { it.can })
assertEquals(listOf(true, true, true), thirdPartyView.map { it.isSecret })
assertEquals(LocalDateTime.of(2026, 5, 31, 15, 0), thirdPartyView.last().createdAt)
}
@Test
@DisplayName("홈 후원은 공개·비밀 후보 전체 중 생성 시각과 id 내림차순으로 최신 8건을 조회한다")
fun shouldFindLatestEightAcrossPublicAndSecretDonations() {
val now = LocalDateTime.of(2026, 6, 12, 12, 0)
val creator = saveMember("mixed-home-creator", MemberRole.CREATOR)
val donor = saveMember("mixed-home-donor", MemberRole.USER)
val viewer = saveMember("mixed-home-viewer", MemberRole.USER)
(1..10).forEach { index ->
saveDonation(creator, donor, index, now.minusMinutes((index / 2).toLong()), isSecret = index % 2 == 1)
}
flushAndClear()
val records = repository.findChannelDonations(creator.id!!, viewer.id!!, now, limit = 8, lang = Lang.KO)
assertEquals(listOf(1, 3, 2, 5, 4, 7, 6, 9), records.map { it.can })
assertEquals(listOf(true, true, false, true, false, true, false, true), records.map { it.isSecret })
}
@Test
@@ -54,6 +54,8 @@ import org.junit.jupiter.api.Assertions.assertThrows
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.DisplayName
import org.junit.jupiter.api.Test
import org.junit.jupiter.params.ParameterizedTest
import org.junit.jupiter.params.provider.CsvSource
import org.mockito.Mockito
import org.springframework.beans.factory.ObjectProvider
import java.time.LocalDateTime
@@ -196,6 +198,49 @@ class CreatorChannelHomeQueryServiceTest {
assertEquals("donor", home.channelDonations.first().nickname)
}
@ParameterizedTest
@CsvSource(
"1, CREATOR, true",
"20, USER, true",
"10, USER, true",
"30, CREATOR, true",
"1, CREATOR, false",
"20, USER, false",
"10, USER, false",
"30, CREATOR, false"
)
@DisplayName("홈 mapper는 비밀 여부와 조회자 ID로만 익명화하며 원본 이름과 프로필 기본값을 보존한다")
fun shouldMaskSecretDonationOnlyForOtherViewers(viewerId: Long, role: MemberRole, isSecret: Boolean) {
listOf(false, true).forEach { isLocalized ->
listOf(null, "profile/fan.png").forEach { profilePath ->
val port = FakeCreatorChannelHomeQueryPort(donorId = 20L).apply {
channelDonationNickname = "deleted_donor"
channelDonationIsSecret = isSecret
channelDonationIsLocalizedName = isLocalized
channelDonationProfilePath = profilePath
}
val service = createService(port)
val home = service.getHome(
1L,
createMember(viewerId, role = role),
LocalDateTime.of(2026, 6, 13, 10, 0),
Lang.KO
)
val donation = home.channelDonations.single()
val masked = isSecret && viewerId != 1L && viewerId != 20L
val expectedNickname = if (isLocalized) "deleted_donor" else "donor"
assertEquals(if (masked) "익명" else expectedNickname, donation.nickname)
assertEquals(if (masked) "크리에이터에게만 보여요" else "thanks", donation.message)
val originalProfile = profilePath ?: "profile/default-profile.png"
val expectedProfile = if (masked) "profile/anonymous-profile.png" else originalProfile
assertEquals("https://cdn.test/$expectedProfile", donation.profileImageUrl)
assertEquals(isSecret, donation.isSecret)
assertEquals(30, donation.can)
assertEquals(LocalDateTime.of(2026, 6, 13, 8, 0), donation.createdAt)
}
}
}
@Test
@DisplayName("creatorId에 해당하는 회원이 없으면 user_not_found 예외를 던진다")
fun shouldThrowUserNotFoundWhenCreatorMemberDoesNotExist() {
@@ -350,7 +395,9 @@ class CreatorChannelHomeQueryServiceTest {
assertEquals("thanks", json["channelDonations"][0]["message"].asText())
assertFalse(json["channelDonations"][0].has("donation" + "Id"))
assertFalse(json["channelDonations"][0].has("member" + "Id"))
assertFalse(json["channelDonations"][0].has("is" + "Secret"))
assertTrue(json["channelDonations"][0]["isSecret"].isBoolean)
assertFalse(json["channelDonations"][0]["isSecret"].asBoolean())
assertFalse(json["channelDonations"][0].has("secret"))
}
private fun createHome(): CreatorChannelHome {
@@ -413,7 +460,8 @@ class CreatorChannelHomeQueryServiceTest {
profileImageUrl = "fan.png",
can = 50,
message = "thanks",
createdAt = LocalDateTime.of(2026, 6, 12, 2, 0)
createdAt = LocalDateTime.of(2026, 6, 12, 2, 0),
isSecret = false
)
),
notices = listOf(post),
@@ -564,7 +612,7 @@ class CreatorChannelHomeQueryServiceTest {
}
}
private class FakeCreatorChannelHomeQueryPort : CreatorChannelHomeQueryPort {
private class FakeCreatorChannelHomeQueryPort(private val donorId: Long = 20L) : CreatorChannelHomeQueryPort {
var creator: CreatorChannelCreatorRecord? = CreatorChannelCreatorRecord(
creatorId = 1L,
role = MemberRole.CREATOR,
@@ -593,6 +641,9 @@ private class FakeCreatorChannelHomeQueryPort : CreatorChannelHomeQueryPort {
var audioContentsLocale: String? = null
var seriesLocale: String? = null
var channelDonationNickname: String = "fan"
var channelDonationIsSecret: Boolean = false
var channelDonationIsLocalizedName: Boolean = false
var channelDonationProfilePath: String? = "profile/fan.png"
override fun findCreator(creatorId: Long, viewerId: Long?, lang: Lang): CreatorChannelCreatorRecord? = creator
@@ -639,12 +690,14 @@ private class FakeCreatorChannelHomeQueryPort : CreatorChannelHomeQueryPort {
lang: Lang
): List<CreatorChannelDonationRecord> = listOf(
CreatorChannelDonationRecord(
memberId = 20L,
memberId = donorId,
nickname = channelDonationNickname,
profileImagePath = "profile/fan.png",
profileImagePath = channelDonationProfilePath,
can = 30,
message = "thanks",
createdAt = LocalDateTime.of(2026, 6, 13, 8, 0)
createdAt = LocalDateTime.of(2026, 6, 13, 8, 0),
isSecret = channelDonationIsSecret,
isLocalizedName = channelDonationIsLocalizedName
)
)