feat(creator-channel): 후원 탭 repository를 추가한다
This commit is contained in:
@@ -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
|
||||||
@@ -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<CreatorChannelDonationRecord> {
|
||||||
|
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))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user