From 951f6789f0a8827b040db6b19997e42c5f1f4d88 Mon Sep 17 00:00:00 2001 From: Klaus Date: Mon, 22 Jun 2026 19:17:56 +0900 Subject: [PATCH] =?UTF-8?q?feat(creator-channel):=20=ED=9B=84=EC=9B=90=20?= =?UTF-8?q?=ED=83=AD=20repository=EB=A5=BC=20=EC=B6=94=EA=B0=80=ED=95=9C?= =?UTF-8?q?=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../CreatorChannelDonationQueryRepository.kt | 5 + ...ltCreatorChannelDonationQueryRepository.kt | 119 +++++++++ ...eatorChannelDonationQueryRepositoryTest.kt | 227 ++++++++++++++++++ 3 files changed, 351 insertions(+) create mode 100644 src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/donation/adapter/out/persistence/CreatorChannelDonationQueryRepository.kt create mode 100644 src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/donation/adapter/out/persistence/DefaultCreatorChannelDonationQueryRepository.kt create mode 100644 src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/donation/adapter/out/persistence/DefaultCreatorChannelDonationQueryRepositoryTest.kt diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/donation/adapter/out/persistence/CreatorChannelDonationQueryRepository.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/donation/adapter/out/persistence/CreatorChannelDonationQueryRepository.kt new file mode 100644 index 00000000..67ea037b --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/donation/adapter/out/persistence/CreatorChannelDonationQueryRepository.kt @@ -0,0 +1,5 @@ +package kr.co.vividnext.sodalive.v2.creator.channel.donation.adapter.out.persistence + +import kr.co.vividnext.sodalive.v2.creator.channel.donation.port.out.CreatorChannelDonationQueryPort + +interface CreatorChannelDonationQueryRepository : CreatorChannelDonationQueryPort diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/donation/adapter/out/persistence/DefaultCreatorChannelDonationQueryRepository.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/donation/adapter/out/persistence/DefaultCreatorChannelDonationQueryRepository.kt new file mode 100644 index 00000000..cc59ab1c --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/donation/adapter/out/persistence/DefaultCreatorChannelDonationQueryRepository.kt @@ -0,0 +1,119 @@ +package kr.co.vividnext.sodalive.v2.creator.channel.donation.adapter.out.persistence + +import com.querydsl.core.types.Projections +import com.querydsl.core.types.dsl.BooleanExpression +import com.querydsl.jpa.impl.JPAQueryFactory +import kr.co.vividnext.sodalive.explorer.profile.channelDonation.QChannelDonationMessage.channelDonationMessage +import kr.co.vividnext.sodalive.member.QMember.member +import kr.co.vividnext.sodalive.member.block.QBlockMember +import kr.co.vividnext.sodalive.v2.creator.channel.donation.domain.CreatorChannelDonationQueryPolicy +import kr.co.vividnext.sodalive.v2.creator.channel.donation.port.out.CreatorChannelDonationCreatorRecord +import kr.co.vividnext.sodalive.v2.creator.channel.donation.port.out.CreatorChannelDonationRecord +import org.springframework.stereotype.Repository +import java.time.LocalDateTime + +@Repository +class DefaultCreatorChannelDonationQueryRepository( + private val queryFactory: JPAQueryFactory +) : CreatorChannelDonationQueryRepository { + private val queryPolicy = CreatorChannelDonationQueryPolicy() + + override fun findCreator(creatorId: Long, viewerId: Long?): CreatorChannelDonationCreatorRecord? { + val creator = queryFactory + .select( + member.id, + member.role, + member.nickname, + member.isVisibleDonationRank, + member.donationRankingPeriod + ) + .from(member) + .where( + member.id.eq(creatorId), + member.isActive.isTrue + ) + .fetchFirst() ?: return null + + return CreatorChannelDonationCreatorRecord( + creatorId = creator.get(member.id)!!, + role = creator.get(member.role)!!, + nickname = creator.get(member.nickname)!!, + isVisibleDonationRank = creator.get(member.isVisibleDonationRank)!!, + donationRankingPeriod = creator.get(member.donationRankingPeriod) + ) + } + + override fun existsBlockedBetween(viewerId: Long, creatorId: Long): Boolean { + val blockMember = QBlockMember("creatorChannelDonationBlockMember") + return queryFactory + .select(blockMember.id) + .from(blockMember) + .where( + blockMember.isActive.isTrue, + blockMember.member.id.eq(viewerId).and(blockMember.blockedMember.id.eq(creatorId)) + .or(blockMember.member.id.eq(creatorId).and(blockMember.blockedMember.id.eq(viewerId))) + ) + .fetchFirst() != null + } + + override fun countChannelDonations( + creatorId: Long, + viewerId: Long, + now: LocalDateTime + ): Int { + return queryFactory + .select(channelDonationMessage.id.count()) + .from(channelDonationMessage) + .where(channelDonationCondition(creatorId, viewerId, now)) + .fetchOne() + ?.toInt() + ?: 0 + } + + override fun findChannelDonations( + creatorId: Long, + viewerId: Long, + now: LocalDateTime, + offset: Long, + limit: Int + ): List { + return queryFactory + .select( + Projections.constructor( + CreatorChannelDonationRecord::class.java, + channelDonationMessage.member.nickname, + channelDonationMessage.member.profileImage, + channelDonationMessage.can, + channelDonationMessage.additionalMessage, + channelDonationMessage.createdAt + ) + ) + .from(channelDonationMessage) + .where(channelDonationCondition(creatorId, viewerId, now)) + .orderBy(channelDonationMessage.createdAt.desc(), channelDonationMessage.id.desc()) + .offset(offset) + .limit(limit.toLong()) + .fetch() + } + + private fun channelDonationCondition( + creatorId: Long, + viewerId: Long, + now: LocalDateTime + ): BooleanExpression { + val monthRange = queryPolicy.currentKstMonthRange(now) + return channelDonationMessage.creator.id.eq(creatorId) + .and(channelDonationMessage.createdAt.goe(monthRange.startInclusiveUtc)) + .and(channelDonationMessage.createdAt.lt(monthRange.endExclusiveUtc)) + .and(donationVisibilityCondition(creatorId, viewerId)) + } + + private fun donationVisibilityCondition(creatorId: Long, viewerId: Long): BooleanExpression { + return if (creatorId == viewerId) { + channelDonationMessage.id.isNotNull + } else { + channelDonationMessage.isSecret.isFalse + .or(channelDonationMessage.member.id.eq(viewerId)) + } + } +} diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/donation/adapter/out/persistence/DefaultCreatorChannelDonationQueryRepositoryTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/donation/adapter/out/persistence/DefaultCreatorChannelDonationQueryRepositoryTest.kt new file mode 100644 index 00000000..eaa5b531 --- /dev/null +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/donation/adapter/out/persistence/DefaultCreatorChannelDonationQueryRepositoryTest.kt @@ -0,0 +1,227 @@ +package kr.co.vividnext.sodalive.v2.creator.channel.donation.adapter.out.persistence + +import com.querydsl.jpa.impl.JPAQueryFactory +import kr.co.vividnext.sodalive.configs.QueryDslConfig +import kr.co.vividnext.sodalive.explorer.profile.channelDonation.ChannelDonationMessage +import kr.co.vividnext.sodalive.member.DonationRankingPeriod +import kr.co.vividnext.sodalive.member.Member +import kr.co.vividnext.sodalive.member.MemberRole +import kr.co.vividnext.sodalive.member.block.BlockMember +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertNotNull +import org.junit.jupiter.api.Assertions.assertNull +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Test +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest +import org.springframework.context.annotation.Import +import java.nio.file.Paths +import java.time.LocalDateTime +import javax.persistence.EntityManager + +@DataJpaTest( + properties = [ + "spring.cache.type=none", + "spring.datasource.url=jdbc:h2:mem:testdb;MODE=MySQL;NON_KEYWORDS=VALUE" + ] +) +@Import(QueryDslConfig::class) +class DefaultCreatorChannelDonationQueryRepositoryTest @Autowired constructor( + private val entityManager: EntityManager, + queryFactory: JPAQueryFactory +) { + private val repository = DefaultCreatorChannelDonationQueryRepository(queryFactory) + + @Test + @DisplayName("활성 회원은 후원 랭킹 설정과 role을 조회하고 비활성 회원은 조회하지 않는다") + fun shouldFindOnlyActiveCreatorWithDonationRankingSettings() { + val viewer = saveMember("donation-lookup-viewer", MemberRole.USER) + val creator = saveMember( + "donation-active-creator", + MemberRole.CREATOR, + isVisibleDonationRank = false, + donationRankingPeriod = DonationRankingPeriod.WEEKLY + ) + val inactiveCreator = saveMember("donation-inactive-creator", MemberRole.CREATOR, isActive = false) + val nonCreator = saveMember("donation-non-creator", MemberRole.USER) + flushAndClear() + + val creatorRecord = repository.findCreator(creator.id!!, viewer.id!!) + val inactiveRecord = repository.findCreator(inactiveCreator.id!!, viewer.id!!) + val nonCreatorRecord = repository.findCreator(nonCreator.id!!, viewer.id!!) + + assertNotNull(creatorRecord) + assertEquals(creator.id, creatorRecord!!.creatorId) + assertEquals(MemberRole.CREATOR, creatorRecord.role) + assertEquals(creator.nickname, creatorRecord.nickname) + assertFalse(creatorRecord.isVisibleDonationRank) + assertEquals(DonationRankingPeriod.WEEKLY, creatorRecord.donationRankingPeriod) + assertNull(inactiveRecord) + assertEquals(MemberRole.USER, nonCreatorRecord!!.role) + } + + @Test + @DisplayName("조회자와 크리에이터 사이 양방향 활성 차단만 차단 상태로 조회한다") + fun shouldFindActiveBlockInBothDirections() { + val viewer = saveMember("donation-block-viewer", MemberRole.USER) + val creator = saveMember("donation-block-creator", MemberRole.CREATOR) + val otherCreator = saveMember("donation-other-creator", MemberRole.CREATOR) + saveBlock(viewer, creator, isActive = true) + saveBlock(otherCreator, viewer, isActive = false) + flushAndClear() + + assertTrue(repository.existsBlockedBetween(viewer.id!!, creator.id!!)) + assertTrue(repository.existsBlockedBetween(creator.id!!, viewer.id!!)) + assertFalse(repository.existsBlockedBetween(viewer.id!!, otherCreator.id!!)) + } + + @Test + @DisplayName("크리에이터 본인은 현재 KST 월 범위의 공개/비공개 채널 후원을 모두 조회한다") + fun shouldCountAndFindAllCurrentMonthDonationsForCreatorSelf() { + val now = LocalDateTime.of(2026, 6, 22, 3, 0) + val creator = saveMember("donation-self-creator", MemberRole.CREATOR) + val donor = saveMember("donation-self-donor", MemberRole.USER, profileImage = "self-donor.png") + val monthStartCreatedAt = LocalDateTime.of(2026, 5, 31, 15, 0) + val monthStart = saveDonation(creator, donor, 100, monthStartCreatedAt, additionalMessage = null) + val secret = saveDonation(creator, donor, 200, LocalDateTime.of(2026, 6, 22, 2, 0), isSecret = true) + saveDonation(creator, donor, 300, LocalDateTime.of(2026, 5, 31, 14, 59, 59)) + saveDonation(creator, donor, 400, LocalDateTime.of(2026, 6, 30, 15, 0)) + flushAndClear() + + val count = repository.countChannelDonations(creator.id!!, creator.id!!, now) + val records = repository.findChannelDonations(creator.id!!, creator.id!!, now, offset = 0, limit = 10) + + assertEquals(2, count) + assertEquals(listOf(secret.can, monthStart.can), records.map { it.can }) + assertEquals(donor.nickname, records.last().nickname) + assertEquals(donor.profileImage, records.last().profileImagePath) + assertNull(records.last().message) + assertEquals(monthStartCreatedAt, records.last().createdAt) + } + + @Test + @DisplayName("일반 조회자는 현재 KST 월 범위의 공개 후원과 본인 비공개 후원만 조회한다") + fun shouldCountAndFindOnlyVisibleCurrentMonthDonationsForViewer() { + val now = LocalDateTime.of(2026, 6, 22, 12, 0) + val creator = saveMember("donation-visible-creator", MemberRole.CREATOR) + val viewer = saveMember("donation-visible-viewer", MemberRole.USER) + val otherDonor = saveMember("donation-visible-other", MemberRole.USER) + val publicDonation = saveDonation(creator, otherDonor, 100, now.minusHours(3), additionalMessage = "public") + val ownSecretDonation = saveDonation( + creator, + viewer, + 200, + now.minusHours(2), + isSecret = true, + additionalMessage = "own secret" + ) + saveDonation(creator, otherDonor, 300, now.minusHours(1), isSecret = true, additionalMessage = "hidden") + flushAndClear() + + val count = repository.countChannelDonations(creator.id!!, viewer.id!!, now) + val records = repository.findChannelDonations(creator.id!!, viewer.id!!, now, offset = 0, limit = 10) + + assertEquals(2, count) + assertEquals(listOf(ownSecretDonation.can, publicDonation.can), records.map { it.can }) + assertEquals(listOf("own secret", "public"), records.map { it.message }) + } + + @Test + @DisplayName("채널 후원 목록은 createdAt desc, id desc로 정렬하고 offset/limit을 적용한다") + fun shouldOrderByCreatedAtAndIdDescWithOffsetAndLimit() { + val now = LocalDateTime.of(2026, 6, 22, 12, 0) + val creator = saveMember("donation-order-creator", MemberRole.CREATOR) + val viewer = saveMember("donation-order-viewer", MemberRole.USER) + val donor = saveMember("donation-order-donor", MemberRole.USER) + val sameCreatedAt = now.minusHours(1) + val first = saveDonation(creator, donor, 100, sameCreatedAt, additionalMessage = "first") + val second = saveDonation(creator, donor, 200, sameCreatedAt, additionalMessage = "second") + saveDonation(creator, donor, 300, sameCreatedAt.plusMinutes(1), additionalMessage = "newest") + flushAndClear() + + val records = repository.findChannelDonations(creator.id!!, viewer.id!!, now, offset = 1, limit = 2) + + assertEquals(listOf(second.can, first.can), records.map { it.can }) + } + + @Test + @DisplayName("후원 탭 repository 목록 조회는 entity 전체 fetch 없이 필요한 컬럼 projection만 사용한다") + fun shouldUseProjectionForDonationList() { + val source = Paths.get( + "src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/donation/adapter/out/persistence/" + + "DefaultCreatorChannelDonationQueryRepository.kt" + ) + .toFile() + .readText() + + assertFalse(source.contains(".selectFrom(channelDonationMessage)"), "donation list must not fetch entity rows") + assertTrue( + source.contains( + """Projections.constructor( + CreatorChannelDonationRecord::class.java""" + ), + "donation list must use constructor projection for direct record mapping" + ) + } + + private fun saveMember( + nickname: String, + role: MemberRole, + isActive: Boolean = true, + profileImage: String? = "$nickname.png", + isVisibleDonationRank: Boolean = true, + donationRankingPeriod: DonationRankingPeriod? = DonationRankingPeriod.CUMULATIVE + ): Member { + val member = Member( + email = "$nickname@test.com", + password = "password", + nickname = nickname, + profileImage = profileImage, + role = role, + isVisibleDonationRank = isVisibleDonationRank, + donationRankingPeriod = donationRankingPeriod, + isActive = isActive + ) + entityManager.persist(member) + return member + } + + private fun saveBlock(member: Member, blockedMember: Member, isActive: Boolean): BlockMember { + val block = BlockMember(isActive = isActive) + block.member = member + block.blockedMember = blockedMember + entityManager.persist(block) + return block + } + + private fun saveDonation( + creator: Member, + donor: Member, + can: Int, + createdAt: LocalDateTime, + isSecret: Boolean = false, + additionalMessage: String? = "thanks" + ): ChannelDonationMessage { + val donation = ChannelDonationMessage(can = can, isSecret = isSecret, additionalMessage = additionalMessage) + donation.creator = creator + donation.member = donor + entityManager.persist(donation) + entityManager.flush() + updateCreatedAt("ChannelDonationMessage", donation.id!!, createdAt) + return donation + } + + private fun updateCreatedAt(entityName: String, id: Long, createdAt: LocalDateTime) { + entityManager.createQuery("update $entityName e set e.createdAt = :createdAt where e.id = :id") + .setParameter("createdAt", createdAt) + .setParameter("id", id) + .executeUpdate() + } + + private fun flushAndClear() { + entityManager.flush() + entityManager.clear() + } +}