From 408a342f172cd8fcb4fb2f552fa7bf0a495d4969 Mon Sep 17 00:00:00 2001 From: Klaus Date: Mon, 22 Jun 2026 15:52:03 +0900 Subject: [PATCH] =?UTF-8?q?feat(creator-channel):=20FanTalk=20=ED=83=AD=20?= =?UTF-8?q?repository=EB=A5=BC=20=EC=B6=94=EA=B0=80=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../CreatorChannelFanTalkQueryRepository.kt | 5 + ...ultCreatorChannelFanTalkQueryRepository.kt | 138 +++++++++++ ...reatorChannelFanTalkQueryRepositoryTest.kt | 227 ++++++++++++++++++ 3 files changed, 370 insertions(+) create mode 100644 src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/fantalk/adapter/out/persistence/CreatorChannelFanTalkQueryRepository.kt create mode 100644 src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/fantalk/adapter/out/persistence/DefaultCreatorChannelFanTalkQueryRepository.kt create mode 100644 src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/fantalk/adapter/out/persistence/DefaultCreatorChannelFanTalkQueryRepositoryTest.kt diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/fantalk/adapter/out/persistence/CreatorChannelFanTalkQueryRepository.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/fantalk/adapter/out/persistence/CreatorChannelFanTalkQueryRepository.kt new file mode 100644 index 00000000..cc1721b4 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/fantalk/adapter/out/persistence/CreatorChannelFanTalkQueryRepository.kt @@ -0,0 +1,5 @@ +package kr.co.vividnext.sodalive.v2.creator.channel.fantalk.adapter.out.persistence + +import kr.co.vividnext.sodalive.v2.creator.channel.fantalk.port.out.CreatorChannelFanTalkQueryPort + +interface CreatorChannelFanTalkQueryRepository : CreatorChannelFanTalkQueryPort diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/fantalk/adapter/out/persistence/DefaultCreatorChannelFanTalkQueryRepository.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/fantalk/adapter/out/persistence/DefaultCreatorChannelFanTalkQueryRepository.kt new file mode 100644 index 00000000..5e808c63 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/fantalk/adapter/out/persistence/DefaultCreatorChannelFanTalkQueryRepository.kt @@ -0,0 +1,138 @@ +package kr.co.vividnext.sodalive.v2.creator.channel.fantalk.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.QCreatorCheers.creatorCheers +import kr.co.vividnext.sodalive.member.QMember.member +import kr.co.vividnext.sodalive.member.block.QBlockMember +import kr.co.vividnext.sodalive.v2.creator.channel.fantalk.port.out.CreatorChannelFanTalkCreatorRecord +import kr.co.vividnext.sodalive.v2.creator.channel.fantalk.port.out.CreatorChannelFanTalkRecord +import kr.co.vividnext.sodalive.v2.creator.channel.fantalk.port.out.CreatorChannelFanTalkReplyRecord +import org.springframework.stereotype.Repository + +@Repository +class DefaultCreatorChannelFanTalkQueryRepository( + private val queryFactory: JPAQueryFactory +) : CreatorChannelFanTalkQueryRepository { + override fun findCreator(creatorId: Long, viewerId: Long?): CreatorChannelFanTalkCreatorRecord? { + val creator = queryFactory + .select(member.id, member.role, member.nickname) + .from(member) + .where( + member.id.eq(creatorId), + member.isActive.isTrue + ) + .fetchFirst() ?: return null + + return CreatorChannelFanTalkCreatorRecord( + creatorId = creator.get(member.id)!!, + role = creator.get(member.role)!!, + nickname = creator.get(member.nickname)!! + ) + } + + override fun existsBlockedBetween(viewerId: Long, creatorId: Long): Boolean { + val blockMember = QBlockMember("creatorChannelFanTalkBlockMember") + 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 countFanTalks(creatorId: Long, viewerId: Long): Int { + return queryFactory + .select(creatorCheers.id.count()) + .from(creatorCheers) + .where(fanTalkCondition(creatorId, viewerId)) + .fetchOne() + ?.toInt() + ?: 0 + } + + override fun findFanTalks( + creatorId: Long, + viewerId: Long, + offset: Long, + limit: Int + ): List { + return queryFactory + .select( + Projections.constructor( + CreatorChannelFanTalkRecord::class.java, + creatorCheers.id, + creatorCheers.member.id, + creatorCheers.member.nickname, + creatorCheers.member.profileImage, + creatorCheers.cheers, + creatorCheers.createdAt + ) + ) + .from(creatorCheers) + .where(fanTalkCondition(creatorId, viewerId)) + .orderBy(creatorCheers.createdAt.desc(), creatorCheers.id.desc()) + .offset(offset) + .limit(limit.toLong()) + .fetch() + } + + override fun findCreatorReplies( + creatorId: Long, + parentFanTalkIds: List + ): List { + if (parentFanTalkIds.isEmpty()) return emptyList() + + return queryFactory + .select( + Projections.constructor( + CreatorChannelFanTalkReplyRecord::class.java, + creatorCheers.id, + creatorCheers.parent.id, + creatorCheers.member.id, + creatorCheers.member.nickname, + creatorCheers.member.profileImage, + creatorCheers.cheers, + creatorCheers.createdAt + ) + ) + .from(creatorCheers) + .where( + creatorCheers.creator.id.eq(creatorId), + creatorCheers.member.id.eq(creatorId), + creatorCheers.isActive.isTrue, + creatorCheers.parent.id.`in`(parentFanTalkIds) + ) + .orderBy(creatorCheers.createdAt.asc(), creatorCheers.id.asc()) + .fetch() + } + + private fun fanTalkCondition(creatorId: Long, viewerId: Long): BooleanExpression { + return creatorCheers.creator.id.eq(creatorId) + .and(creatorCheers.isActive.isTrue) + .and(creatorCheers.parent.isNull) + .and(notBlockedFanTalkWriterCondition(viewerId)) + } + + private fun notBlockedFanTalkWriterCondition(viewerId: Long): BooleanExpression { + val viewerBlock = QBlockMember("viewerBlockFanTalkTabWriter") + val writerBlock = QBlockMember("writerBlockFanTalkTabViewer") + return creatorCheers.member.id.notIn( + queryFactory + .select(viewerBlock.blockedMember.id) + .from(viewerBlock) + .where(viewerBlock.member.id.eq(viewerId), viewerBlock.isActive.isTrue) + ).and( + creatorCheers.member.id.notIn( + queryFactory + .select(writerBlock.member.id) + .from(writerBlock) + .where(writerBlock.blockedMember.id.eq(viewerId), writerBlock.isActive.isTrue) + ) + ) + } +} diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/fantalk/adapter/out/persistence/DefaultCreatorChannelFanTalkQueryRepositoryTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/fantalk/adapter/out/persistence/DefaultCreatorChannelFanTalkQueryRepositoryTest.kt new file mode 100644 index 00000000..53dcda76 --- /dev/null +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/fantalk/adapter/out/persistence/DefaultCreatorChannelFanTalkQueryRepositoryTest.kt @@ -0,0 +1,227 @@ +package kr.co.vividnext.sodalive.v2.creator.channel.fantalk.adapter.out.persistence + +import com.querydsl.jpa.impl.JPAQueryFactory +import kr.co.vividnext.sodalive.configs.QueryDslConfig +import kr.co.vividnext.sodalive.explorer.profile.CreatorCheers +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.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 DefaultCreatorChannelFanTalkQueryRepositoryTest @Autowired constructor( + private val entityManager: EntityManager, + queryFactory: JPAQueryFactory +) { + private val repository = DefaultCreatorChannelFanTalkQueryRepository(queryFactory) + + @Test + @DisplayName("활성 회원은 role과 닉네임을 조회하고 비활성 회원은 조회하지 않는다") + fun shouldFindOnlyActiveCreator() { + val viewer = saveMember("creator-lookup-viewer", MemberRole.USER) + val activeCreator = saveMember("active-fantalk-creator", MemberRole.CREATOR) + val inactiveCreator = saveMember("inactive-fantalk-creator", MemberRole.CREATOR, isActive = false) + val nonCreator = saveMember("fantalk-non-creator", MemberRole.USER) + flushAndClear() + + val activeRecord = repository.findCreator(activeCreator.id!!, viewer.id!!) + val inactiveRecord = repository.findCreator(inactiveCreator.id!!, viewer.id!!) + val nonCreatorRecord = repository.findCreator(nonCreator.id!!, viewer.id!!) + + assertNotNull(activeRecord) + assertEquals(activeCreator.id, activeRecord!!.creatorId) + assertEquals(MemberRole.CREATOR, activeRecord.role) + assertEquals(activeCreator.nickname, activeRecord.nickname) + assertNull(inactiveRecord) + assertEquals(MemberRole.USER, nonCreatorRecord!!.role) + } + + @Test + @DisplayName("조회자와 크리에이터 사이 양방향 활성 차단만 차단 상태로 조회한다") + fun shouldFindActiveBlockInBothDirections() { + val viewer = saveMember("fantalk-block-viewer", MemberRole.USER) + val creator = saveMember("fantalk-block-creator", MemberRole.CREATOR) + val otherCreator = saveMember("fantalk-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("최상위 FanTalk 수와 목록은 활성 루트 글만 세고 작성자 차단을 제외한다") + fun shouldCountAndFindOnlyVisibleTopLevelFanTalks() { + val viewer = saveMember("fantalk-list-viewer", MemberRole.USER) + val creator = saveMember("fantalk-list-creator", MemberRole.CREATOR) + val otherCreator = saveMember("fantalk-list-other-creator", MemberRole.CREATOR) + val visibleWriter = saveMember("visible-writer", MemberRole.USER, profileImage = "visible.png") + val blockedWriter = saveMember("blocked-writer", MemberRole.USER) + val writerBlockingViewer = saveMember("writer-blocking-viewer", MemberRole.USER) + val now = LocalDateTime.of(2026, 6, 22, 12, 0) + val older = saveCheers(visibleWriter, creator, "older", isActive = true, createdAt = now.minusHours(2)) + val newer = saveCheers(visibleWriter, creator, "newer", isActive = true, createdAt = now.minusHours(1)) + saveCheers(visibleWriter, creator, "inactive", isActive = false, createdAt = now) + saveCheers(visibleWriter, otherCreator, "other creator", isActive = true, createdAt = now) + saveCheers(visibleWriter, creator, "reply", isActive = true, createdAt = now, parent = older) + saveCheers(blockedWriter, creator, "viewer blocked", isActive = true, createdAt = now.plusHours(1)) + saveCheers(writerBlockingViewer, creator, "writer blocked", isActive = true, createdAt = now.plusHours(2)) + saveBlock(viewer, blockedWriter, isActive = true) + saveBlock(writerBlockingViewer, viewer, isActive = true) + flushAndClear() + + val count = repository.countFanTalks(creator.id!!, viewer.id!!) + val firstPage = repository.findFanTalks(creator.id!!, viewer.id!!, offset = 0, limit = 1) + val secondPage = repository.findFanTalks(creator.id!!, viewer.id!!, offset = 1, limit = 2) + + assertEquals(2, count) + assertEquals(listOf(newer.id), firstPage.map { it.fanTalkId }) + assertEquals(listOf(older.id), secondPage.map { it.fanTalkId }) + assertEquals(visibleWriter.id, firstPage.first().writerId) + assertEquals(visibleWriter.nickname, firstPage.first().writerNickname) + assertEquals(visibleWriter.profileImage, firstPage.first().writerProfileImagePath) + assertEquals("newer", firstPage.first().content) + } + + @Test + @DisplayName("최상위 FanTalk 목록은 createdAt desc, id desc 순서로 정렬한다") + fun shouldOrderFanTalksByCreatedAtDescAndIdDesc() { + val viewer = saveMember("fantalk-order-viewer", MemberRole.USER) + val creator = saveMember("fantalk-order-creator", MemberRole.CREATOR) + val writer = saveMember("fantalk-order-writer", MemberRole.USER) + val sameCreatedAt = LocalDateTime.of(2026, 6, 22, 12, 0) + val first = saveCheers(writer, creator, "first", isActive = true, createdAt = sameCreatedAt) + val second = saveCheers(writer, creator, "second", isActive = true, createdAt = sameCreatedAt) + val newest = saveCheers(writer, creator, "newest", isActive = true, createdAt = sameCreatedAt.plusMinutes(1)) + flushAndClear() + + val records = repository.findFanTalks(creator.id!!, viewer.id!!, offset = 0, limit = 10) + + assertEquals(listOf(newest.id, second.id, first.id), records.map { it.fanTalkId }) + } + + @Test + @DisplayName("크리에이터 답글은 지정한 부모의 활성 크리에이터 작성 답글만 오래된 순으로 조회한다") + fun shouldFindOnlyActiveCreatorRepliesForRequestedParents() { + val creator = saveMember("reply-creator", MemberRole.CREATOR) + val writer = saveMember("reply-writer", MemberRole.USER) + val otherCreator = saveMember("reply-other-creator", MemberRole.CREATOR) + val now = LocalDateTime.of(2026, 6, 22, 12, 0) + val parent = saveCheers(writer, creator, "parent", isActive = true, createdAt = now.minusHours(3)) + val otherParent = saveCheers(writer, creator, "other parent", isActive = true, createdAt = now.minusHours(2)) + val newerReply = saveCheers(creator, creator, "newer reply", isActive = true, createdAt = now, parent = parent) + val olderReply = saveCheers( + creator, + creator, + "older reply", + isActive = true, + createdAt = now.minusMinutes(1), + parent = parent + ) + saveCheers(writer, creator, "fan reply", isActive = true, createdAt = now.plusMinutes(1), parent = parent) + saveCheers(creator, creator, "inactive reply", isActive = false, createdAt = now.plusMinutes(2), parent = parent) + saveCheers( + creator, + otherCreator, + "other creator reply", + isActive = true, + createdAt = now.plusMinutes(3), + parent = parent + ) + saveCheers( + creator, + creator, + "other parent reply", + isActive = true, + createdAt = now.plusMinutes(4), + parent = otherParent + ) + flushAndClear() + + val replies = repository.findCreatorReplies(creator.id!!, listOf(parent.id!!)) + val emptyReplies = repository.findCreatorReplies(creator.id!!, emptyList()) + + assertEquals(listOf(olderReply.id, newerReply.id), replies.map { it.fanTalkId }) + assertEquals(parent.id, replies.first().parentFanTalkId) + assertEquals(creator.id, replies.first().writerId) + assertEquals(creator.nickname, replies.first().writerNickname) + assertEquals(creator.profileImage, replies.first().writerProfileImagePath) + assertEquals("older reply", replies.first().content) + assertTrue(emptyReplies.isEmpty()) + } + + private fun saveMember( + nickname: String, + role: MemberRole, + isActive: Boolean = true, + profileImage: String? = "$nickname.png" + ): Member { + val member = Member( + email = "$nickname@test.com", + password = "password", + nickname = nickname, + profileImage = profileImage, + role = role, + 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 saveCheers( + member: Member, + creator: Member, + cheers: String, + isActive: Boolean, + createdAt: LocalDateTime, + parent: CreatorCheers? = null + ): CreatorCheers { + val creatorCheers = CreatorCheers(cheers = cheers, languageCode = "ko", isActive = isActive) + creatorCheers.member = member + creatorCheers.creator = creator + creatorCheers.parent = parent + entityManager.persist(creatorCheers) + entityManager.flush() + updateCreatedAt("CreatorCheers", creatorCheers.id!!, createdAt) + return creatorCheers + } + + 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() + } +}