From ee32696c6c572b5ed98b2602466a60071111914e Mon Sep 17 00:00:00 2001 From: Klaus Date: Wed, 24 Jun 2026 16:21:43 +0900 Subject: [PATCH] =?UTF-8?q?feat(content-ranking):=20=EB=9E=AD=ED=82=B9=20?= =?UTF-8?q?=ED=9B=84=EB=B3=B4=20=EC=A7=91=EA=B3=84=EB=A5=BC=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...efaultAudioRankingAggregationRepository.kt | 243 +++++++++++++++++ .../domain/AudioRankingSnapshotCandidate.kt | 24 ++ .../port/out/AudioRankingAggregationPort.kt | 36 +++ ...ltAudioRankingAggregationRepositoryTest.kt | 252 ++++++++++++++++++ 4 files changed, 555 insertions(+) create mode 100644 src/main/kotlin/kr/co/vividnext/sodalive/v2/content/ranking/adapter/out/persistence/DefaultAudioRankingAggregationRepository.kt create mode 100644 src/main/kotlin/kr/co/vividnext/sodalive/v2/content/ranking/domain/AudioRankingSnapshotCandidate.kt create mode 100644 src/main/kotlin/kr/co/vividnext/sodalive/v2/content/ranking/port/out/AudioRankingAggregationPort.kt create mode 100644 src/test/kotlin/kr/co/vividnext/sodalive/v2/content/ranking/adapter/out/persistence/DefaultAudioRankingAggregationRepositoryTest.kt diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/content/ranking/adapter/out/persistence/DefaultAudioRankingAggregationRepository.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/content/ranking/adapter/out/persistence/DefaultAudioRankingAggregationRepository.kt new file mode 100644 index 00000000..f7b02103 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/content/ranking/adapter/out/persistence/DefaultAudioRankingAggregationRepository.kt @@ -0,0 +1,243 @@ +package kr.co.vividnext.sodalive.v2.content.ranking.adapter.out.persistence + +import kr.co.vividnext.sodalive.v2.content.ranking.domain.AudioRankingSnapshotCandidate +import kr.co.vividnext.sodalive.v2.content.ranking.port.out.AudioRankingAggregationPort +import org.springframework.stereotype.Repository +import java.sql.Timestamp +import java.time.LocalDateTime +import javax.persistence.EntityManager + +@Repository +class DefaultAudioRankingAggregationRepository( + private val entityManager: EntityManager +) : AudioRankingAggregationPort { + override fun aggregateWeeklyPopularCandidates( + startInclusiveUtc: LocalDateTime, + endExclusiveUtc: LocalDateTime + ): List { + return aggregateCandidates(startInclusiveUtc, endExclusiveUtc, null, null) + } + + override fun aggregateRisingCandidates( + startInclusiveUtc: LocalDateTime, + endExclusiveUtc: LocalDateTime + ): List { + val previousStartInclusiveUtc = startInclusiveUtc.minusWeeks(1) + val previousEndExclusiveUtc = startInclusiveUtc + return aggregateCandidates(startInclusiveUtc, endExclusiveUtc, previousStartInclusiveUtc, previousEndExclusiveUtc) + } + + override fun aggregateRevenueCandidates( + startInclusiveUtc: LocalDateTime, + endExclusiveUtc: LocalDateTime + ): List { + return aggregateCandidates(startInclusiveUtc, endExclusiveUtc, null, null) + .filter { it.revenueCanAmount > 0 } + .map { it.copy(finalScore = it.revenueCanAmount.toDouble()) } + } + + override fun aggregateSalesCountCandidates( + startInclusiveUtc: LocalDateTime, + endExclusiveUtc: LocalDateTime + ): List { + return aggregateCandidates(startInclusiveUtc, endExclusiveUtc, null, null) + .filter { it.salesCount > 0 } + .map { it.copy(finalScore = it.salesCount.toDouble()) } + } + + override fun aggregateCommentCountCandidates( + startInclusiveUtc: LocalDateTime, + endExclusiveUtc: LocalDateTime + ): List { + return aggregateCandidates(startInclusiveUtc, endExclusiveUtc, null, null) + .filter { it.commentCount > 0 } + .map { it.copy(finalScore = it.commentCount.toDouble()) } + } + + override fun aggregateLikeCountCandidates( + startInclusiveUtc: LocalDateTime, + endExclusiveUtc: LocalDateTime + ): List { + return aggregateCandidates(startInclusiveUtc, endExclusiveUtc, null, null) + .filter { it.likeCount > 0 } + .map { it.copy(finalScore = it.likeCount.toDouble()) } + } + + private fun aggregateCandidates( + startInclusiveUtc: LocalDateTime, + endExclusiveUtc: LocalDateTime, + previousStartInclusiveUtc: LocalDateTime?, + previousEndExclusiveUtc: LocalDateTime? + ): List { + val rows = entityManager.createNativeQuery(AGGREGATION_SQL) + .setParameter("startInclusiveUtc", startInclusiveUtc) + .setParameter("endExclusiveUtc", endExclusiveUtc) + .setParameter("previousStartInclusiveUtc", previousStartInclusiveUtc ?: startInclusiveUtc) + .setParameter("previousEndExclusiveUtc", previousEndExclusiveUtc ?: startInclusiveUtc) + .resultList + + return rows.map { row -> (row as Array<*>).toCandidate() } + } + + private fun Array<*>.toCandidate(): AudioRankingSnapshotCandidate { + return AudioRankingSnapshotCandidate( + contentId = this[0].toLong(), + title = this[1] as String, + creatorMemberId = this[2].toLong(), + creatorNickname = this[3] as String, + coverImageUrl = this[4] as String?, + releaseDate = this[5].toLocalDateTime(), + isAdult = this[6].toBoolean(), + isPaid = this[7].toLong() > 0, + revenueCanAmount = this[8].toLong(), + salesCount = this[9].toLong(), + viewCount = this[10].toLong(), + likeCount = this[11].toLong(), + commentCount = this[12].toLong(), + previousSalesCount = this[13].toLong(), + previousViewCount = this[14].toLong(), + previousLikeCount = this[15].toLong(), + previousCommentCount = this[16].toLong() + ) + } + + private fun Any?.toLong(): Long { + return (this as Number?)?.toLong() ?: 0L + } + + private fun Any?.toBoolean(): Boolean { + return when (this) { + is Boolean -> this + is Number -> toInt() != 0 + else -> false + } + } + + private fun Any?.toLocalDateTime(): LocalDateTime { + return when (this) { + is LocalDateTime -> this + is Timestamp -> toLocalDateTime() + else -> error("Unsupported datetime value: $this") + } + } + + companion object { + private val AGGREGATION_SQL = """ + with eligible_content as ( + select c.id as content_id, + c.title as title, + c.member_id as creator_member_id, + m.nickname as creator_nickname, + c.cover_image as cover_image_url, + c.release_date as release_date, + c.is_adult as is_adult, + c.price as price + from content c + join member m on m.id = c.member_id + join content_theme ct on ct.id = c.theme_id + where c.is_active = true + and c.release_date is not null + and c.release_date < :endExclusiveUtc + and c.duration is not null + and c.limited is null + and ct.is_active = true + and m.role = 'CREATOR' + and m.is_active = true + ), order_metrics as ( + select o.content_id, + sum(o.can) as revenue_can_amount, + count(o.id) as sales_count + from orders o + where o.is_active = true + and o.created_at >= :startInclusiveUtc + and o.created_at < :endExclusiveUtc + group by o.content_id + ), view_metrics as ( + select ccvh.content_id, + count(ccvh.id) as view_count + from creator_content_view_history ccvh + where ccvh.viewed_at >= :startInclusiveUtc + and ccvh.viewed_at < :endExclusiveUtc + group by ccvh.content_id + ), like_metrics as ( + select cl.content_id, + count(cl.id) as like_count + from content_like cl + where cl.is_active = true + and cl.created_at >= :startInclusiveUtc + and cl.created_at < :endExclusiveUtc + group by cl.content_id + ), comment_metrics as ( + select cc.content_id, + count(cc.id) as comment_count + from content_comment cc + where cc.is_active = true + and cc.created_at >= :startInclusiveUtc + and cc.created_at < :endExclusiveUtc + group by cc.content_id + ), previous_order_metrics as ( + select o.content_id, + count(o.id) as previous_sales_count + from orders o + where o.is_active = true + and o.created_at >= :previousStartInclusiveUtc + and o.created_at < :previousEndExclusiveUtc + group by o.content_id + ), previous_view_metrics as ( + select ccvh.content_id, + count(ccvh.id) as previous_view_count + from creator_content_view_history ccvh + where ccvh.viewed_at >= :previousStartInclusiveUtc + and ccvh.viewed_at < :previousEndExclusiveUtc + group by ccvh.content_id + ), previous_like_metrics as ( + select cl.content_id, + count(cl.id) as previous_like_count + from content_like cl + where cl.is_active = true + and cl.created_at >= :previousStartInclusiveUtc + and cl.created_at < :previousEndExclusiveUtc + group by cl.content_id + ), previous_comment_metrics as ( + select cc.content_id, + count(cc.id) as previous_comment_count + from content_comment cc + where cc.is_active = true + and cc.created_at >= :previousStartInclusiveUtc + and cc.created_at < :previousEndExclusiveUtc + group by cc.content_id + ) + select ec.content_id, + ec.title, + ec.creator_member_id, + ec.creator_nickname, + ec.cover_image_url, + ec.release_date, + ec.is_adult, + ec.price, + coalesce(om.revenue_can_amount, 0) as revenue_can_amount, + coalesce(om.sales_count, 0) as sales_count, + coalesce(vm.view_count, 0) as view_count, + coalesce(lm.like_count, 0) as like_count, + coalesce(cm.comment_count, 0) as comment_count, + coalesce(pom.previous_sales_count, 0) as previous_sales_count, + coalesce(pvm.previous_view_count, 0) as previous_view_count, + coalesce(plm.previous_like_count, 0) as previous_like_count, + coalesce(pcm.previous_comment_count, 0) as previous_comment_count + from eligible_content ec + left join order_metrics om on om.content_id = ec.content_id + left join view_metrics vm on vm.content_id = ec.content_id + left join like_metrics lm on lm.content_id = ec.content_id + left join comment_metrics cm on cm.content_id = ec.content_id + left join previous_order_metrics pom on pom.content_id = ec.content_id + left join previous_view_metrics pvm on pvm.content_id = ec.content_id + left join previous_like_metrics plm on plm.content_id = ec.content_id + left join previous_comment_metrics pcm on pcm.content_id = ec.content_id + where coalesce(om.revenue_can_amount, 0) <> 0 + or coalesce(om.sales_count, 0) <> 0 + or coalesce(vm.view_count, 0) <> 0 + or coalesce(lm.like_count, 0) <> 0 + or coalesce(cm.comment_count, 0) <> 0 + """.trimIndent() + } +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/content/ranking/domain/AudioRankingSnapshotCandidate.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/content/ranking/domain/AudioRankingSnapshotCandidate.kt new file mode 100644 index 00000000..adf5d873 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/content/ranking/domain/AudioRankingSnapshotCandidate.kt @@ -0,0 +1,24 @@ +package kr.co.vividnext.sodalive.v2.content.ranking.domain + +import java.time.LocalDateTime + +data class AudioRankingSnapshotCandidate( + val contentId: Long, + val title: String, + val creatorMemberId: Long, + val creatorNickname: String, + val coverImageUrl: String?, + val releaseDate: LocalDateTime, + val isAdult: Boolean, + val isPaid: Boolean, + val finalScore: Double = 0.0, + val revenueCanAmount: Long = 0, + val salesCount: Long = 0, + val viewCount: Long = 0, + val likeCount: Long = 0, + val commentCount: Long = 0, + val previousSalesCount: Long = 0, + val previousViewCount: Long = 0, + val previousLikeCount: Long = 0, + val previousCommentCount: Long = 0 +) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/content/ranking/port/out/AudioRankingAggregationPort.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/content/ranking/port/out/AudioRankingAggregationPort.kt new file mode 100644 index 00000000..e8f472c9 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/content/ranking/port/out/AudioRankingAggregationPort.kt @@ -0,0 +1,36 @@ +package kr.co.vividnext.sodalive.v2.content.ranking.port.out + +import kr.co.vividnext.sodalive.v2.content.ranking.domain.AudioRankingSnapshotCandidate +import java.time.LocalDateTime + +interface AudioRankingAggregationPort { + fun aggregateWeeklyPopularCandidates( + startInclusiveUtc: LocalDateTime, + endExclusiveUtc: LocalDateTime + ): List + + fun aggregateRisingCandidates( + startInclusiveUtc: LocalDateTime, + endExclusiveUtc: LocalDateTime + ): List + + fun aggregateRevenueCandidates( + startInclusiveUtc: LocalDateTime, + endExclusiveUtc: LocalDateTime + ): List + + fun aggregateSalesCountCandidates( + startInclusiveUtc: LocalDateTime, + endExclusiveUtc: LocalDateTime + ): List + + fun aggregateCommentCountCandidates( + startInclusiveUtc: LocalDateTime, + endExclusiveUtc: LocalDateTime + ): List + + fun aggregateLikeCountCandidates( + startInclusiveUtc: LocalDateTime, + endExclusiveUtc: LocalDateTime + ): List +} diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/v2/content/ranking/adapter/out/persistence/DefaultAudioRankingAggregationRepositoryTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/content/ranking/adapter/out/persistence/DefaultAudioRankingAggregationRepositoryTest.kt new file mode 100644 index 00000000..1b91201c --- /dev/null +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/content/ranking/adapter/out/persistence/DefaultAudioRankingAggregationRepositoryTest.kt @@ -0,0 +1,252 @@ +package kr.co.vividnext.sodalive.v2.content.ranking.adapter.out.persistence + +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.order.Order +import kr.co.vividnext.sodalive.content.order.OrderType +import kr.co.vividnext.sodalive.content.theme.AudioContentTheme +import kr.co.vividnext.sodalive.member.Member +import kr.co.vividnext.sodalive.member.MemberRole +import kr.co.vividnext.sodalive.v2.recommendation.adapter.out.persistence.CreatorContentViewHistory +import org.junit.jupiter.api.Assertions.assertEquals +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 DefaultAudioRankingAggregationRepositoryTest @Autowired constructor( + private val entityManager: EntityManager +) { + private val adapter = DefaultAudioRankingAggregationRepository(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("주간 인기 후보는 매출, 판매량, 조회수, 좋아요, 댓글 수를 기간 기준으로 집계한다") + fun shouldAggregateWeeklyPopularMetricsByPeriod() { + val creator = saveCreator("creator") + val buyer = saveUser("buyer") + val content = saveAudioContent(creator, price = 100, isActive = true, releaseDate = inPeriod) + saveOrder(content, buyer, creator, inPeriod) + saveOrder(content, buyer, creator, endAt) + saveView(content, buyer, inPeriod) + saveView(content, buyer, startAt.minusSeconds(1)) + saveLike(content, buyer, isActive = true, createdAt = inPeriod) + saveLike(content, buyer, isActive = false, createdAt = inPeriod) + saveComment(content, buyer, isActive = true, createdAt = inPeriod) + saveComment(content, buyer, isActive = false, createdAt = inPeriod) + flushAndClear() + + val candidate = adapter.aggregateWeeklyPopularCandidates(startAt, endAt).single() + + assertEquals(content.id, candidate.contentId) + assertEquals(100, candidate.revenueCanAmount) + assertEquals(1, candidate.salesCount) + assertEquals(1, candidate.viewCount) + assertEquals(1, candidate.likeCount) + assertEquals(1, candidate.commentCount) + } + + @Test + @DisplayName("비활성 콘텐츠, 공개 전 콘텐츠, 비활성 크리에이터 콘텐츠는 후보에서 제외한다") + fun shouldExcludeInactiveUnreleasedAndInactiveCreatorContent() { + val activeCreator = saveCreator("active") + val inactiveCreator = saveCreator("inactive", isActive = false) + val buyer = saveUser("buyer") + val validContent = saveAudioContent(activeCreator, price = 100, isActive = true, releaseDate = inPeriod) + val inactiveContent = saveAudioContent(activeCreator, price = 100, isActive = false, releaseDate = inPeriod) + val unreleasedContent = saveAudioContent(activeCreator, price = 100, isActive = true, releaseDate = endAt.plusDays(1)) + val inactiveCreatorContent = saveAudioContent(inactiveCreator, price = 100, isActive = true, releaseDate = inPeriod) + listOf(validContent, inactiveContent, unreleasedContent, inactiveCreatorContent).forEach { content -> + saveOrder(content, buyer, content.member!!, inPeriod) + } + flushAndClear() + + val candidates = adapter.aggregateWeeklyPopularCandidates(startAt, endAt) + + assertEquals(listOf(validContent.id), candidates.map { it.contentId }) + } + + @Test + @DisplayName("한정판 콘텐츠는 랭킹 후보에서 제외한다") + fun shouldExcludeLimitedContent() { + val creator = saveCreator("limited") + val buyer = saveUser("buyer-limited") + val validContent = saveAudioContent(creator, price = 100, isActive = true, releaseDate = inPeriod) + val limitedContent = saveAudioContent(creator, price = 100, isActive = true, releaseDate = inPeriod, limited = 10) + listOf(validContent, limitedContent).forEach { content -> + saveOrder(content, buyer, creator, inPeriod) + } + flushAndClear() + + val candidates = adapter.aggregateWeeklyPopularCandidates(startAt, endAt) + + assertEquals(listOf(validContent.id), candidates.map { it.contentId }) + } + + @Test + @DisplayName("지금 뜨는 중 후보는 직전 비교 기간 지표를 함께 반환한다") + fun shouldAggregatePreviousMetricsForRisingCandidates() { + val creator = saveCreator("creator") + val viewer = saveUser("viewer") + val content = saveAudioContent(creator, price = 0, isActive = true, releaseDate = inPeriod) + saveView(content, viewer, startAt.minusDays(1)) + saveView(content, viewer, inPeriod) + saveLike(content, viewer, isActive = true, createdAt = startAt.minusDays(1)) + saveLike(content, viewer, isActive = true, createdAt = inPeriod) + saveComment(content, viewer, isActive = true, createdAt = startAt.minusDays(1)) + saveComment(content, viewer, isActive = true, createdAt = inPeriod) + flushAndClear() + + val candidate = adapter.aggregateRisingCandidates(startAt, endAt).single() + + assertEquals(1, candidate.viewCount) + assertEquals(1, candidate.previousViewCount) + assertEquals(1, candidate.likeCount) + assertEquals(1, candidate.previousLikeCount) + assertEquals(1, candidate.commentCount) + assertEquals(1, candidate.previousCommentCount) + } + + @Test + @DisplayName("매출, 판매량, 댓글 수, 좋아요 후보는 v2 집계 지표를 최종 점수로 사용한다") + fun shouldAggregateMetricRankingCandidatesWithRawScores() { + val creator = saveCreator("metric") + val buyer = saveUser("buyer-metric") + val content = saveAudioContent(creator, price = 100, isActive = true, releaseDate = inPeriod) + saveOrder(content, buyer, creator, inPeriod) + saveOrder(content, buyer, creator, inPeriod.plusHours(1)) + saveLike(content, buyer, isActive = true, createdAt = inPeriod) + saveComment(content, buyer, isActive = true, createdAt = inPeriod) + flushAndClear() + + assertEquals(200.0, adapter.aggregateRevenueCandidates(startAt, endAt).single().finalScore) + assertEquals(2.0, adapter.aggregateSalesCountCandidates(startAt, endAt).single().finalScore) + assertEquals(1.0, adapter.aggregateLikeCountCandidates(startAt, endAt).single().finalScore) + assertEquals(1.0, adapter.aggregateCommentCountCandidates(startAt, endAt).single().finalScore) + } + + @Test + @DisplayName("후보는 성인 콘텐츠 여부를 함께 반환한다") + fun shouldMapAdultFlagToCandidate() { + val creator = saveCreator("adult") + val buyer = saveUser("buyer-adult") + val content = saveAudioContent(creator, price = 100, isActive = true, releaseDate = inPeriod, isAdult = true) + saveOrder(content, buyer, creator, inPeriod) + flushAndClear() + + val candidate = adapter.aggregateRevenueCandidates(startAt, endAt).single() + + assertEquals(true, candidate.isAdult) + } + + private fun saveCreator(nickname: String, isActive: Boolean = true): Member { + return saveMember(nickname, MemberRole.CREATOR, isActive) + } + + private fun saveUser(nickname: String): Member { + return saveMember(nickname, MemberRole.USER, true) + } + + private fun saveMember(nickname: String, role: MemberRole, isActive: Boolean): Member { + val member = Member( + email = "$nickname@test.com", + password = "password", + nickname = nickname, + role = role, + isActive = isActive + ) + entityManager.persist(member) + entityManager.flush() + return member + } + + private fun saveAudioContent( + creator: Member, + price: Int, + isActive: Boolean, + releaseDate: LocalDateTime, + limited: Int? = null, + isAdult: Boolean = false + ): AudioContent { + val theme = AudioContentTheme(theme = "theme-${creator.nickname}", image = "theme.png") + entityManager.persist(theme) + val content = AudioContent( + title = "content-${creator.nickname}-${releaseDate.nano}", + detail = "detail", + languageCode = "ko", + price = price, + releaseDate = releaseDate, + limited = limited + ) + content.member = creator + content.theme = theme + content.isActive = isActive + content.isAdult = isAdult + content.duration = "00:01:00" + entityManager.persist(content) + entityManager.flush() + return content + } + + private fun saveOrder(content: AudioContent, buyer: Member, creator: Member, createdAt: LocalDateTime) { + val order = Order(type = OrderType.KEEP, isActive = true) + order.member = buyer + order.creator = creator + order.audioContent = content + entityManager.persist(order) + entityManager.flush() + updateTimestamps("orders", order.id!!, createdAt, createdAt) + } + + private fun saveView(content: AudioContent, viewer: Member, viewedAt: LocalDateTime) { + entityManager.persist(CreatorContentViewHistory(viewer.id!!, content.id!!, content.theme!!.id!!, viewedAt)) + } + + private fun saveLike(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 saveComment(content: AudioContent, member: Member, isActive: Boolean, createdAt: LocalDateTime) { + val comment = AudioContentComment(comment = "comment", languageCode = "ko", isActive = isActive) + comment.audioContent = content + comment.member = member + entityManager.persist(comment) + entityManager.flush() + updateTimestamps("content_comment", comment.id!!, createdAt, createdAt) + } + + 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() + } +}