diff --git a/docs/20261105_채널후원메시지숨김/channel_donation_message_is_hidden_ddl.sql b/docs/20261105_채널후원메시지숨김/channel_donation_message_is_hidden_ddl.sql new file mode 100644 index 00000000..05a378a9 --- /dev/null +++ b/docs/20261105_채널후원메시지숨김/channel_donation_message_is_hidden_ddl.sql @@ -0,0 +1,2 @@ +ALTER TABLE channel_donation_message + ADD COLUMN is_hidden TINYINT(1) NOT NULL DEFAULT 0 COMMENT '채널 후원 표시 숨김 여부'; diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/explorer/profile/channelDonation/ChannelDonationMessage.kt b/src/main/kotlin/kr/co/vividnext/sodalive/explorer/profile/channelDonation/ChannelDonationMessage.kt index 3b2e1002..b0642ae3 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/explorer/profile/channelDonation/ChannelDonationMessage.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/explorer/profile/channelDonation/ChannelDonationMessage.kt @@ -15,6 +15,9 @@ class ChannelDonationMessage( @Column(columnDefinition = "TEXT") var additionalMessage: String? = null ) : BaseEntity() { + @Column(nullable = false) + var isHidden: Boolean = false + @ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name = "member_id", nullable = false) var member: Member? = null diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/donation/adapter/out/persistence/DefaultHideCreatorChannelDonationRepository.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/donation/adapter/out/persistence/DefaultHideCreatorChannelDonationRepository.kt new file mode 100644 index 00000000..d05aaad2 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/donation/adapter/out/persistence/DefaultHideCreatorChannelDonationRepository.kt @@ -0,0 +1,30 @@ +package kr.co.vividnext.sodalive.v2.creator.channel.donation.adapter.out.persistence + +import com.querydsl.jpa.impl.JPAQueryFactory +import kr.co.vividnext.sodalive.explorer.profile.channelDonation.QChannelDonationMessage.channelDonationMessage +import kr.co.vividnext.sodalive.v2.creator.channel.donation.port.out.HideCreatorChannelDonationPort +import org.springframework.stereotype.Repository +import org.springframework.transaction.annotation.Transactional + +@Repository +class DefaultHideCreatorChannelDonationRepository( + private val queryFactory: JPAQueryFactory +) : HideCreatorChannelDonationPort { + @Transactional + override fun hideOwnedDonation(creatorId: Long, donationId: Long): Boolean { + val ownedDonation = channelDonationMessage.id.eq(donationId) + .and(channelDonationMessage.creator.id.eq(creatorId)) + val updatedCount = queryFactory + .update(channelDonationMessage) + .set(channelDonationMessage.isHidden, true) + .set(channelDonationMessage.updatedAt, channelDonationMessage.updatedAt) + .where(ownedDonation, channelDonationMessage.isHidden.isFalse) + .execute() + + return updatedCount > 0 || queryFactory + .selectOne() + .from(channelDonationMessage) + .where(ownedDonation) + .fetchFirst() != null + } +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/donation/port/out/HideCreatorChannelDonationPort.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/donation/port/out/HideCreatorChannelDonationPort.kt new file mode 100644 index 00000000..252aa9de --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/donation/port/out/HideCreatorChannelDonationPort.kt @@ -0,0 +1,5 @@ +package kr.co.vividnext.sodalive.v2.creator.channel.donation.port.out + +interface HideCreatorChannelDonationPort { + fun hideOwnedDonation(creatorId: Long, donationId: Long): Boolean +} diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/donation/adapter/out/persistence/DefaultHideCreatorChannelDonationRepositoryTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/donation/adapter/out/persistence/DefaultHideCreatorChannelDonationRepositoryTest.kt new file mode 100644 index 00000000..36af5bb2 --- /dev/null +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/donation/adapter/out/persistence/DefaultHideCreatorChannelDonationRepositoryTest.kt @@ -0,0 +1,200 @@ +package kr.co.vividnext.sodalive.v2.creator.channel.donation.adapter.out.persistence + +import kr.co.vividnext.sodalive.configs.QueryDslConfig +import kr.co.vividnext.sodalive.explorer.profile.channelDonation.ChannelDonationMessage +import kr.co.vividnext.sodalive.member.Member +import kr.co.vividnext.sodalive.member.MemberRole +import kr.co.vividnext.sodalive.v2.creator.channel.donation.port.out.HideCreatorChannelDonationPort +import org.hibernate.resource.jdbc.spi.StatementInspector +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.ValueSource +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.boot.autoconfigure.orm.jpa.HibernatePropertiesCustomizer +import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest +import org.springframework.boot.test.context.TestConfiguration +import org.springframework.context.annotation.Import +import java.time.LocalDateTime +import javax.persistence.EntityManager + +@DataJpaTest(properties = ["spring.cache.type=none"]) +@Import( + QueryDslConfig::class, + DefaultHideCreatorChannelDonationRepository::class, + DefaultHideCreatorChannelDonationRepositoryTest.SqlCaptureConfiguration::class +) +class DefaultHideCreatorChannelDonationRepositoryTest @Autowired constructor( + private val entityManager: EntityManager, + private val repository: HideCreatorChannelDonationPort, + private val sqlCapture: SqlCaptureConfiguration +) { + @Test + @DisplayName("새 후원은 기본 비숨김 상태로 저장된다") + fun shouldPersistDonationAsVisibleByDefault() { + val creator = saveMember("hide-default-creator", MemberRole.CREATOR) + val donor = saveMember("hide-default-donor", MemberRole.USER) + val donationId = saveDonation(creator, donor) + + val donation = reloadDonation(donationId) + + assertFalse(donation.isHidden) + assertOriginalValues(donation, donationId, creator.id!!, donor.id!!, false) + } + + @Test + @DisplayName("숨김 UPDATE는 MySQL 자동 갱신을 막도록 수정 시각을 자기 대입한다") + fun shouldPreserveUpdatedAtWithSelfAssignmentInGeneratedHideSql() { + val creator = saveMember("hide-sql-creator", MemberRole.CREATOR) + val donor = saveMember("hide-sql-donor", MemberRole.USER) + val donationId = saveDonation(creator, donor) + val updatedAt = requireNotNull(reloadDonation(donationId).updatedAt) + sqlCapture.statements.clear() + + assertTrue(repository.hideOwnedDonation(creator.id!!, donationId)) + + val updateSql = sqlCapture.statements.single { it.startsWith("update channel_donation_message ", ignoreCase = true) } + assertTrue(Regex("""(?i)\bupdated_at\s*=\s*(?:\w+\.)?updated_at\b""").containsMatchIn(updateSql), updateSql) + val donation = reloadDonation(donationId) + assertTrue(donation.isHidden) + assertEquals(updatedAt, donation.updatedAt) + } + + @ParameterizedTest + @ValueSource(booleans = [false, true]) + @DisplayName("소유 채널의 선택한 공개·비밀 후원 한 건만 숨기고 원본 값을 보존한다") + fun shouldHideOnlyDonationOwnedByCreatorAndRetainOriginalValues(isSecret: Boolean) { + val creator = saveMember("hide-owned-creator", MemberRole.CREATOR) + val otherCreator = saveMember("hide-other-creator", MemberRole.CREATOR) + val donor = saveMember("hide-owned-donor", MemberRole.USER) + val donationId = saveDonation(creator, donor, isSecret) + val sameChannelDonationId = saveDonation(creator, donor) + val otherChannelDonationId = saveDonation(otherCreator, donor) + assertFalse(reloadDonation(donationId).isHidden) + + assertTrue(repository.hideOwnedDonation(creator.id!!, donationId)) + + val donation = reloadDonation(donationId) + assertTrue(donation.isHidden) + assertOriginalValues(donation, donationId, creator.id!!, donor.id!!, isSecret) + val sameChannelDonation = reloadDonation(sameChannelDonationId) + assertFalse(sameChannelDonation.isHidden) + assertOriginalValues(sameChannelDonation, sameChannelDonationId, creator.id!!, donor.id!!, false) + val otherChannelDonation = reloadDonation(otherChannelDonationId) + assertFalse(otherChannelDonation.isHidden) + assertOriginalValues(otherChannelDonation, otherChannelDonationId, otherCreator.id!!, donor.id!!, false) + assertEquals(3L, donationCount()) + } + + @Test + @DisplayName("다른 채널의 후원은 숨기지 않고 실패를 반환한다") + fun shouldRejectDonationFromAnotherCreatorWithoutChangingIt() { + val creator = saveMember("hide-reject-creator", MemberRole.CREATOR) + val otherCreator = saveMember("hide-reject-other", MemberRole.CREATOR) + val donor = saveMember("hide-reject-donor", MemberRole.USER) + val donationId = saveDonation(creator, donor, isSecret = true) + reloadDonation(donationId) + + assertFalse(repository.hideOwnedDonation(otherCreator.id!!, donationId)) + + val donation = reloadDonation(donationId) + assertFalse(donation.isHidden) + assertOriginalValues(donation, donationId, creator.id!!, donor.id!!, true) + assertEquals(1L, donationCount()) + } + + @Test + @DisplayName("존재하지 않는 후원 ID는 기존 후원을 바꾸지 않고 실패를 반환한다") + fun shouldRejectMissingDonationWithoutChangingExistingDonation() { + val creator = saveMember("hide-missing-creator", MemberRole.CREATOR) + val donor = saveMember("hide-missing-donor", MemberRole.USER) + val donationId = saveDonation(creator, donor) + reloadDonation(donationId) + + assertFalse(repository.hideOwnedDonation(creator.id!!, Long.MAX_VALUE)) + + val donation = reloadDonation(donationId) + assertFalse(donation.isHidden) + assertOriginalValues(donation, donationId, creator.id!!, donor.id!!, false) + assertEquals(1L, donationCount()) + } + + @Test + @DisplayName("같은 후원의 반복 숨김은 성공하며 다른 채널 요청은 거부하고 원본 값을 보존한다") + fun shouldSucceedWhenHidingAgainWithoutChangingOriginalValues() { + val creator = saveMember("hide-repeat-creator", MemberRole.CREATOR) + val otherCreator = saveMember("hide-repeat-other", MemberRole.CREATOR) + val donor = saveMember("hide-repeat-donor", MemberRole.USER) + val donationId = saveDonation(creator, donor, isSecret = true) + reloadDonation(donationId) + + assertTrue(repository.hideOwnedDonation(creator.id!!, donationId)) + assertTrue(reloadDonation(donationId).isHidden) + assertTrue(repository.hideOwnedDonation(creator.id!!, donationId)) + assertFalse(repository.hideOwnedDonation(otherCreator.id!!, donationId)) + + val donation = reloadDonation(donationId) + assertTrue(donation.isHidden) + assertOriginalValues(donation, donationId, creator.id!!, donor.id!!, true) + assertEquals(1L, donationCount()) + } + + private fun saveMember(nickname: String, role: MemberRole): Member { + val member = Member(email = "$nickname@test.com", password = "password", nickname = nickname, role = role) + entityManager.persist(member) + return member + } + + private fun saveDonation(creator: Member, donor: Member, isSecret: Boolean = false): Long { + val donation = ChannelDonationMessage(can = 120, isSecret = isSecret, additionalMessage = "original message") + donation.creator = creator + donation.member = donor + entityManager.persist(donation) + donation.createdAt = LocalDateTime.of(2026, 11, 5, 0, 0) + return requireNotNull(donation.id) + } + + private fun reloadDonation(donationId: Long): ChannelDonationMessage { + entityManager.flush() + entityManager.clear() + return requireNotNull(entityManager.find(ChannelDonationMessage::class.java, donationId)) + } + + private fun assertOriginalValues( + donation: ChannelDonationMessage, + donationId: Long, + creatorId: Long, + donorId: Long, + isSecret: Boolean + ) { + assertEquals(donationId, donation.id) + assertEquals(120, donation.can) + assertEquals("original message", donation.additionalMessage) + assertEquals(isSecret, donation.isSecret) + assertEquals(creatorId, donation.creator?.id) + assertEquals(donorId, donation.member?.id) + assertEquals(LocalDateTime.of(2026, 11, 5, 0, 0), donation.createdAt) + } + + private fun donationCount(): Long { + return entityManager.createQuery("select count(d) from ChannelDonationMessage d", java.lang.Long::class.java) + .singleResult.toLong() + } + + @TestConfiguration + class SqlCaptureConfiguration : HibernatePropertiesCustomizer, StatementInspector { + val statements = mutableListOf() + + override fun customize(hibernateProperties: MutableMap) { + hibernateProperties["hibernate.session_factory.statement_inspector"] = this + } + + override fun inspect(sql: String): String { + statements.add(sql) + return sql + } + } +}