From e5d2d3c8157a5d4fc16e72b609efbb096fe3e5ab Mon Sep 17 00:00:00 2001 From: Klaus Date: Mon, 8 Jun 2026 17:45:04 +0900 Subject: [PATCH] =?UTF-8?q?feat(ranking):=20=ED=81=AC=EB=A6=AC=EC=97=90?= =?UTF-8?q?=EC=9D=B4=ED=84=B0=20=EB=9E=AD=ED=82=B9=20=EC=A7=91=EA=B3=84=20?= =?UTF-8?q?=EC=A0=80=EC=9E=A5=EC=86=8C=EB=A5=BC=20=EC=B6=94=EA=B0=80?= =?UTF-8?q?=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...aultCreatorRankingAggregationRepository.kt | 183 ++++++++++ .../port/out/CreatorRankingAggregationPort.kt | 11 + ...CreatorRankingAggregationRepositoryTest.kt | 331 ++++++++++++++++++ 3 files changed, 525 insertions(+) create mode 100644 src/main/kotlin/kr/co/vividnext/sodalive/v2/ranking/adapter/out/persistence/DefaultCreatorRankingAggregationRepository.kt create mode 100644 src/main/kotlin/kr/co/vividnext/sodalive/v2/ranking/port/out/CreatorRankingAggregationPort.kt create mode 100644 src/test/kotlin/kr/co/vividnext/sodalive/v2/ranking/adapter/out/persistence/DefaultCreatorRankingAggregationRepositoryTest.kt diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/ranking/adapter/out/persistence/DefaultCreatorRankingAggregationRepository.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/ranking/adapter/out/persistence/DefaultCreatorRankingAggregationRepository.kt new file mode 100644 index 00000000..694b2858 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/ranking/adapter/out/persistence/DefaultCreatorRankingAggregationRepository.kt @@ -0,0 +1,183 @@ +package kr.co.vividnext.sodalive.v2.ranking.adapter.out.persistence + +import kr.co.vividnext.sodalive.v2.ranking.domain.CreatorRankingScorePolicy +import kr.co.vividnext.sodalive.v2.ranking.domain.CreatorRankingSnapshotCandidate +import kr.co.vividnext.sodalive.v2.ranking.port.out.CreatorRankingAggregationPort +import org.springframework.stereotype.Repository +import java.time.LocalDateTime +import javax.persistence.EntityManager + +@Repository +class DefaultCreatorRankingAggregationRepository( + private val entityManager: EntityManager +) : CreatorRankingAggregationPort { + private val scorePolicy = CreatorRankingScorePolicy() + + override fun aggregateCandidates( + startInclusiveUtc: LocalDateTime, + endExclusiveUtc: LocalDateTime + ): List { + val rows = entityManager.createNativeQuery(AGGREGATION_SQL) + .setParameter("startInclusiveUtc", startInclusiveUtc) + .setParameter("endExclusiveUtc", endExclusiveUtc) + .resultList + + return rows + .map { row -> (row as Array<*>).toCandidate() } + .filter { candidate -> candidate.finalScore >= MINIMUM_FINAL_SCORE } + .sortedWith(compareByDescending { it.finalScore }.thenBy { it.creatorId }) + } + + private fun Array<*>.toCandidate(): CreatorRankingSnapshotCandidate { + val creatorId = this[0].toLong() + val nickname = this[1] as String + val profileImageUrl = this[2] as String? + val liveCanAmount = this[3].toLong() + val contentPurchaseCanAmount = this[4].toLong() + val contentLikeCount = this[5].toLong() + val contentCommentCount = this[6].toLong() + val channelDonationCanAmount = this[7].toLong() + val channelDonationCount = this[8].toLong() + val fanTalkCount = this[9].toLong() + val finalFollowerCount = this[10].toLong() + val followIncrease = this[11].toLong() + val contentLiveScore = scorePolicy.calculateContentLiveScore(liveCanAmount, contentPurchaseCanAmount) + val engagementScore = scorePolicy.calculateEngagementScore(contentLikeCount, contentCommentCount) + val supportScore = scorePolicy.calculateSupportScore(channelDonationCanAmount, channelDonationCount, fanTalkCount) + val fanLoyaltyScore = scorePolicy.calculateFanLoyaltyScore(finalFollowerCount, followIncrease) + val finalScore = scorePolicy.calculateFinalScore(contentLiveScore, engagementScore, supportScore, fanLoyaltyScore) + + return CreatorRankingSnapshotCandidate( + creatorId = creatorId, + nickname = nickname, + profileImageUrl = profileImageUrl, + finalScore = finalScore, + contentLiveScore = contentLiveScore, + engagementScore = engagementScore, + supportScore = supportScore, + fanLoyaltyScore = fanLoyaltyScore, + liveCanAmount = liveCanAmount, + contentPurchaseCanAmount = contentPurchaseCanAmount, + contentLikeCount = contentLikeCount, + contentCommentCount = contentCommentCount, + channelDonationCanAmount = channelDonationCanAmount, + channelDonationCount = channelDonationCount, + fanTalkCount = fanTalkCount, + finalFollowerCount = finalFollowerCount, + followIncrease = followIncrease + ) + } + + private fun Any?.toLong(): Long { + return (this as Number?)?.toLong() ?: 0L + } + + companion object { + private const val MINIMUM_FINAL_SCORE = 1.0 + + private val AGGREGATION_SQL = """ + with active_creators as ( + select id, nickname, profile_image + from member + where role = 'CREATOR' + and is_active = true + ), can_metrics as ( + select ucc.recipient_creator_id as creator_id, + sum(case when uc.can_usage in ('DONATION', 'LIVE', 'SPIN_ROULETTE') then ucc.can else 0 end) as live_can_amount, + sum(case when uc.can_usage = 'ORDER_CONTENT' then ucc.can else 0 end) as content_purchase_can_amount, + sum(case when uc.can_usage = 'CHANNEL_DONATION' then ucc.can else 0 end) as channel_donation_can_amount, + sum(case when uc.can_usage = 'CHANNEL_DONATION' then 1 else 0 end) as channel_donation_count + from use_can_calculate ucc + join use_can uc on uc.id = ucc.use_can_id + where ucc.recipient_creator_id is not null + and ucc.status = 'RECEIVED' + and uc.is_refund = false + and ucc.created_at >= :startInclusiveUtc + and ucc.created_at < :endExclusiveUtc + group by ucc.recipient_creator_id + ), like_metrics as ( + select c.member_id as creator_id, + count(cl.id) as content_like_count + from content_like cl + join content c on c.id = cl.content_id + where c.is_active = true + and cl.is_active = true + and cl.created_at >= :startInclusiveUtc + and cl.created_at < :endExclusiveUtc + group by c.member_id + ), comment_metrics as ( + select c.member_id as creator_id, + count(cc.id) as content_comment_count + from content_comment cc + join content c on c.id = cc.content_id + where c.is_active = true + and cc.is_active = true + and cc.member_id <> c.member_id + and cc.created_at >= :startInclusiveUtc + and cc.created_at < :endExclusiveUtc + group by c.member_id + ), fan_talk_metrics as ( + select creator_id, + count(id) as fan_talk_count + from creator_cheers + where is_active = true + and parent_id is null + and created_at >= :startInclusiveUtc + and created_at < :endExclusiveUtc + group by creator_id + ), final_follower_metrics as ( + select creator_id, + count(id) as final_follower_count + from creator_following + where is_active = true + and created_at < :endExclusiveUtc + group by creator_id + ), new_follow_metrics as ( + select creator_id, + count(id) as new_follow_count + from creator_following + where created_at >= :startInclusiveUtc + and created_at < :endExclusiveUtc + group by creator_id + ), unfollow_metrics as ( + select creator_id, + count(id) as unfollow_count + from creator_following + where is_active = false + and updated_at >= :startInclusiveUtc + and updated_at < :endExclusiveUtc + group by creator_id + ) + select ac.id as creator_id, + ac.nickname as nickname, + ac.profile_image as profile_image_url, + coalesce(cm.live_can_amount, 0) as live_can_amount, + coalesce(cm.content_purchase_can_amount, 0) as content_purchase_can_amount, + coalesce(lm.content_like_count, 0) as content_like_count, + coalesce(com.content_comment_count, 0) as content_comment_count, + coalesce(cm.channel_donation_can_amount, 0) as channel_donation_can_amount, + coalesce(cm.channel_donation_count, 0) as channel_donation_count, + coalesce(ftm.fan_talk_count, 0) as fan_talk_count, + coalesce(ffm.final_follower_count, 0) as final_follower_count, + coalesce(nfm.new_follow_count, 0) - coalesce(um.unfollow_count, 0) as follow_increase + from active_creators ac + left join can_metrics cm on cm.creator_id = ac.id + left join like_metrics lm on lm.creator_id = ac.id + left join comment_metrics com on com.creator_id = ac.id + left join fan_talk_metrics ftm on ftm.creator_id = ac.id + left join final_follower_metrics ffm on ffm.creator_id = ac.id + left join new_follow_metrics nfm on nfm.creator_id = ac.id + left join unfollow_metrics um on um.creator_id = ac.id + where coalesce(cm.live_can_amount, 0) <> 0 + or coalesce(cm.content_purchase_can_amount, 0) <> 0 + or coalesce(lm.content_like_count, 0) <> 0 + or coalesce(com.content_comment_count, 0) <> 0 + or coalesce(cm.channel_donation_can_amount, 0) <> 0 + or coalesce(cm.channel_donation_count, 0) <> 0 + or coalesce(ftm.fan_talk_count, 0) <> 0 + or coalesce(ffm.final_follower_count, 0) <> 0 + or coalesce(nfm.new_follow_count, 0) <> 0 + or coalesce(um.unfollow_count, 0) <> 0 + """.trimIndent() + } +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/ranking/port/out/CreatorRankingAggregationPort.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/ranking/port/out/CreatorRankingAggregationPort.kt new file mode 100644 index 00000000..c02aaf9d --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/ranking/port/out/CreatorRankingAggregationPort.kt @@ -0,0 +1,11 @@ +package kr.co.vividnext.sodalive.v2.ranking.port.out + +import kr.co.vividnext.sodalive.v2.ranking.domain.CreatorRankingSnapshotCandidate +import java.time.LocalDateTime + +interface CreatorRankingAggregationPort { + fun aggregateCandidates( + startInclusiveUtc: LocalDateTime, + endExclusiveUtc: LocalDateTime + ): List +} diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/v2/ranking/adapter/out/persistence/DefaultCreatorRankingAggregationRepositoryTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/ranking/adapter/out/persistence/DefaultCreatorRankingAggregationRepositoryTest.kt new file mode 100644 index 00000000..59235dec --- /dev/null +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/ranking/adapter/out/persistence/DefaultCreatorRankingAggregationRepositoryTest.kt @@ -0,0 +1,331 @@ +package kr.co.vividnext.sodalive.v2.ranking.adapter.out.persistence + +import kr.co.vividnext.sodalive.can.payment.PaymentGateway +import kr.co.vividnext.sodalive.can.use.CanUsage +import kr.co.vividnext.sodalive.can.use.UseCan +import kr.co.vividnext.sodalive.can.use.UseCanCalculate +import kr.co.vividnext.sodalive.can.use.UseCanCalculateStatus +import kr.co.vividnext.sodalive.configs.QueryDslConfig +import kr.co.vividnext.sodalive.content.AudioContent +import kr.co.vividnext.sodalive.content.comment.AudioContentComment +import kr.co.vividnext.sodalive.content.like.AudioContentLike +import kr.co.vividnext.sodalive.content.theme.AudioContentTheme +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.following.CreatorFollowing +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.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 DefaultCreatorRankingAggregationRepositoryTest @Autowired constructor( + private val entityManager: EntityManager +) { + private val adapter = DefaultCreatorRankingAggregationRepository(entityManager) + private val startAt = LocalDateTime.of(2026, 5, 31, 15, 0) + private val endAt = LocalDateTime.of(2026, 6, 7, 15, 0) + private val inPeriod = LocalDateTime.of(2026, 6, 1, 0, 0) + + @Test + @DisplayName("콘텐츠/라이브 캔은 사용 구분, 정산 상태, 환불 여부, UTC 기간으로 집계한다") + fun shouldAggregateLiveAndContentCanAmountsByUsageStatusRefundAndUtcPeriod() { + val creator = saveCreator("can-creator") + val user = saveUser("can-user") + saveUseCanCalculate(user, creator, CanUsage.DONATION, 10, UseCanCalculateStatus.RECEIVED, false, startAt) + saveUseCanCalculate(user, creator, CanUsage.LIVE, 20, UseCanCalculateStatus.RECEIVED, false, inPeriod) + saveUseCanCalculate( + user, + creator, + CanUsage.SPIN_ROULETTE, + 30, + UseCanCalculateStatus.RECEIVED, + false, + endAt.minusSeconds(1) + ) + saveUseCanCalculate(user, creator, CanUsage.ORDER_CONTENT, 40, UseCanCalculateStatus.RECEIVED, false, inPeriod) + saveUseCanCalculate(user, creator, CanUsage.DONATION, 100, UseCanCalculateStatus.RECEIVED, true, inPeriod) + saveUseCanCalculate(user, creator, CanUsage.LIVE, 200, UseCanCalculateStatus.CALCULATE_COMPLETE, false, inPeriod) + saveUseCanCalculate( + user, + creator, + CanUsage.ORDER_CONTENT, + 300, + UseCanCalculateStatus.RECEIVED, + false, + startAt.minusSeconds(1) + ) + saveUseCanCalculate(user, creator, CanUsage.ORDER_CONTENT, 400, UseCanCalculateStatus.RECEIVED, false, endAt) + flushAndClear() + + val candidate = aggregate().single() + + assertEquals(60, candidate.liveCanAmount) + assertEquals(40, candidate.contentPurchaseCanAmount) + } + + @Test + @DisplayName("활성 콘텐츠의 활성 좋아요와 작성자 본인이 아닌 활성 댓글/대댓글만 집계한다") + fun shouldAggregateActiveContentLikesAndCommentsExcludingCreatorSelfResponses() { + val creator = saveCreator("engagement-creator") + val otherCreator = saveCreator("inactive-content-creator") + val user = saveUser("engagement-user") + val content = saveAudioContent(creator, isActive = true) + val inactiveContent = saveAudioContent(otherCreator, isActive = false) + saveUseCanCalculate(user, creator, CanUsage.DONATION, 10, UseCanCalculateStatus.RECEIVED, false, inPeriod) + saveContentLike(content, user, isActive = true, createdAt = inPeriod) + saveContentLike(content, user, isActive = false, createdAt = inPeriod) + saveContentLike(content, user, isActive = true, createdAt = startAt.minusSeconds(1)) + saveContentLike(inactiveContent, user, isActive = true, createdAt = inPeriod) + val parent = saveContentComment(content, user, isActive = true, createdAt = inPeriod) + saveContentComment(content, user, parent = parent, isActive = true, createdAt = inPeriod) + saveContentComment(content, creator, isActive = true, createdAt = inPeriod) + saveContentComment(content, user, isActive = false, createdAt = inPeriod) + saveContentComment(content, user, isActive = true, createdAt = endAt) + saveContentComment(inactiveContent, user, isActive = true, createdAt = inPeriod) + flushAndClear() + + val candidate = aggregate().single { it.creatorId == creator.id } + + assertEquals(1, candidate.contentLikeCount) + assertEquals(2, candidate.contentCommentCount) + } + + @Test + @DisplayName("채널 후원 캔/건수와 최상위 활성 팬 Talk만 집계한다") + fun shouldAggregateChannelDonationAndTopLevelActiveFanTalks() { + val creator = saveCreator("support-creator") + val user = saveUser("support-user") + saveUseCanCalculate(user, creator, CanUsage.CHANNEL_DONATION, 100, UseCanCalculateStatus.RECEIVED, false, inPeriod) + saveUseCanCalculate(user, creator, CanUsage.CHANNEL_DONATION, 200, UseCanCalculateStatus.RECEIVED, false, inPeriod) + saveUseCanCalculate(user, creator, CanUsage.CHANNEL_DONATION, 300, UseCanCalculateStatus.RECEIVED, true, inPeriod) + saveUseCanCalculate(user, creator, CanUsage.CHANNEL_DONATION, 400, UseCanCalculateStatus.REFUND, false, inPeriod) + val topLevel = saveCreatorCheers(creator, user, isActive = true, createdAt = inPeriod) + saveCreatorCheers(creator, user, parent = topLevel, isActive = true, createdAt = inPeriod) + saveCreatorCheers(creator, user, isActive = false, createdAt = inPeriod) + saveCreatorCheers(creator, user, isActive = true, createdAt = endAt) + flushAndClear() + + val candidate = aggregate().single() + + assertEquals(300, candidate.channelDonationCanAmount) + assertEquals(2, candidate.channelDonationCount) + assertEquals(1, candidate.fanTalkCount) + } + + @Test + @DisplayName("팔로우 최종 활성 수와 현재 row 기준 생성/비활성 변경 증가 수를 집계한다") + fun shouldAggregateFinalFollowerCountAndFollowIncreaseFromCurrentRows() { + val creator = saveCreator("follow-creator") + val activeFollower = saveUser("active-follower") + val newFollower = saveUser("new-follower") + val unfollower = saveUser("unfollower") + val oldInactiveFollower = saveUser("old-inactive-follower") + saveUseCanCalculate(activeFollower, creator, CanUsage.DONATION, 10, UseCanCalculateStatus.RECEIVED, false, inPeriod) + saveFollowing( + creator, + activeFollower, + isActive = true, + createdAt = startAt.minusDays(5), + updatedAt = startAt.minusDays(5) + ) + saveFollowing(creator, newFollower, isActive = true, createdAt = inPeriod, updatedAt = inPeriod) + saveFollowing(creator, unfollower, isActive = false, createdAt = startAt.minusDays(10), updatedAt = inPeriod) + saveFollowing( + creator, + oldInactiveFollower, + isActive = false, + createdAt = startAt.minusDays(10), + updatedAt = startAt.minusSeconds(1) + ) + flushAndClear() + + val candidate = aggregate().single() + + assertEquals(2, candidate.finalFollowerCount) + // 현재 CreatorFollowing row만으로 집계하므로 기간 내 재팔로우 이력은 별도 이벤트로 복원하지 않는다. + assertEquals(0, candidate.followIncrease) + } + + @Test + @DisplayName("활성 크리에이터별 원천 지표를 합쳐 1점 이상 후보만 점수와 함께 반환한다") + fun shouldMergeMetricsForActiveCreatorsAndExcludeInactiveNonCreatorAndLowScoreCandidates() { + val creator = saveCreator("merged-creator", profileImage = "merged.png") + val lowScoreCreator = saveCreator("low-score-creator") + val inactiveCreator = saveCreator("inactive-creator", isActive = false) + val nonCreator = saveUser("non-creator") + val user = saveUser("merged-user") + saveUseCanCalculate(user, creator, CanUsage.DONATION, 10, UseCanCalculateStatus.RECEIVED, false, inPeriod) + saveContentLike(saveAudioContent(creator, isActive = true), user, isActive = true, createdAt = inPeriod) + saveUseCanCalculate(user, inactiveCreator, CanUsage.DONATION, 1000, UseCanCalculateStatus.RECEIVED, false, inPeriod) + saveUseCanCalculate(user, nonCreator, CanUsage.DONATION, 1000, UseCanCalculateStatus.RECEIVED, false, inPeriod) + saveFollowing(lowScoreCreator, user, isActive = true, createdAt = startAt.minusDays(1), updatedAt = startAt.minusDays(1)) + flushAndClear() + + val candidates = aggregate() + + assertEquals(listOf(creator.id), candidates.map { it.creatorId }) + val candidate = candidates.single() + assertEquals("merged-creator", candidate.nickname) + assertEquals("merged.png", candidate.profileImageUrl) + assertEquals(10, candidate.liveCanAmount) + assertEquals(0, candidate.contentPurchaseCanAmount) + assertEquals(1, candidate.contentLikeCount) + assertEquals(7.0, candidate.contentLiveScore, 0.0001) + assertEquals(0.5, candidate.engagementScore, 0.0001) + assertEquals(0.0, candidate.supportScore, 0.0001) + assertEquals(0.0, candidate.fanLoyaltyScore, 0.0001) + assertEquals(2.6, candidate.finalScore, 0.0001) + assertTrue(candidate.finalScore >= 1.0) + assertFalse(candidates.any { it.creatorId == lowScoreCreator.id }) + assertFalse(candidates.any { it.creatorId == inactiveCreator.id }) + assertFalse(candidates.any { it.creatorId == nonCreator.id }) + } + + private fun aggregate() = adapter.aggregateCandidates(startAt, endAt) + + private fun saveCreator(nickname: String, profileImage: String? = null, isActive: Boolean = true): Member { + return saveMember(nickname, MemberRole.CREATOR, profileImage, isActive) + } + + private fun saveUser(nickname: String): Member { + return saveMember(nickname, MemberRole.USER, null, true) + } + + private fun saveMember(nickname: String, role: MemberRole, profileImage: String?, isActive: Boolean): Member { + val member = Member( + email = "$nickname@test.com", + password = "password", + nickname = nickname, + profileImage = profileImage, + role = role, + isActive = isActive + ) + entityManager.persist(member) + entityManager.flush() + return member + } + + private fun saveAudioContent(creator: Member, isActive: Boolean): AudioContent { + val theme = AudioContentTheme(theme = "theme-${creator.nickname}", image = "theme.png") + entityManager.persist(theme) + val content = AudioContent( + title = "content-${creator.nickname}", + detail = "detail", + languageCode = "ko", + releaseDate = inPeriod + ) + content.member = creator + content.theme = theme + content.isActive = isActive + entityManager.persist(content) + return content + } + + private fun saveUseCanCalculate( + member: Member, + creator: Member, + usage: CanUsage, + can: Int, + status: UseCanCalculateStatus, + isRefund: Boolean, + createdAt: LocalDateTime + ) { + val useCan = UseCan(canUsage = usage, can = can, rewardCan = 0, isRefund = isRefund) + useCan.member = member + entityManager.persist(useCan) + val calculate = UseCanCalculate(can = can, paymentGateway = PaymentGateway.PG, status = status) + calculate.useCan = useCan + calculate.recipientCreatorId = creator.id + entityManager.persist(calculate) + entityManager.flush() + updateTimestamps("use_can_calculate", calculate.id!!, createdAt, createdAt) + } + + private fun saveContentLike(content: AudioContent, member: Member, isActive: Boolean, createdAt: LocalDateTime) { + val like = AudioContentLike(memberId = member.id!!) + like.audioContent = content + like.isActive = isActive + entityManager.persist(like) + entityManager.flush() + updateTimestamps("content_like", like.id!!, createdAt, createdAt) + } + + private fun saveContentComment( + content: AudioContent, + member: Member, + parent: AudioContentComment? = null, + isActive: Boolean, + createdAt: LocalDateTime + ): AudioContentComment { + val comment = AudioContentComment(comment = "comment", languageCode = "ko", isActive = isActive) + comment.audioContent = content + comment.member = member + comment.parent = parent + entityManager.persist(comment) + entityManager.flush() + updateTimestamps("content_comment", comment.id!!, createdAt, createdAt) + return comment + } + + private fun saveCreatorCheers( + creator: Member, + member: Member, + parent: CreatorCheers? = null, + isActive: Boolean, + createdAt: LocalDateTime + ): CreatorCheers { + val cheers = CreatorCheers(cheers = "cheers", languageCode = "ko", isActive = isActive) + cheers.creator = creator + cheers.member = member + cheers.parent = parent + entityManager.persist(cheers) + entityManager.flush() + updateTimestamps("creator_cheers", cheers.id!!, createdAt, createdAt) + return cheers + } + + private fun saveFollowing( + creator: Member, + member: Member, + isActive: Boolean, + createdAt: LocalDateTime, + updatedAt: LocalDateTime + ) { + val following = CreatorFollowing(isActive = isActive) + following.creator = creator + following.member = member + entityManager.persist(following) + entityManager.flush() + updateTimestamps("creator_following", following.id!!, createdAt, updatedAt) + } + + private fun updateTimestamps(tableName: String, id: Long, createdAt: LocalDateTime, updatedAt: LocalDateTime) { + entityManager.createNativeQuery( + "update $tableName set created_at = :createdAt, updated_at = :updatedAt where id = :id" + ) + .setParameter("createdAt", createdAt) + .setParameter("updatedAt", updatedAt) + .setParameter("id", id) + .executeUpdate() + entityManager.clear() + } + + private fun flushAndClear() { + entityManager.flush() + entityManager.clear() + } +}