From e598d2058dd77116bafcf6af96803a6c3fb2ffd8 Mon Sep 17 00:00:00 2001 From: Klaus Date: Fri, 26 Jun 2026 02:48:29 +0900 Subject: [PATCH] =?UTF-8?q?feat(home-following):=20=EC=B5=9C=EA=B7=BC=20?= =?UTF-8?q?=EC=86=8C=EC=8B=9D=20=EB=B0=9C=ED=96=89=20service=EB=A5=BC=20?= =?UTF-8?q?=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 --- .../HomeFollowingNewsPublishService.kt | 146 ++++++++++++++++++ .../HomeFollowingNewsPublishServiceTest.kt | 139 +++++++++++++++++ 2 files changed, 285 insertions(+) create mode 100644 src/main/kotlin/kr/co/vividnext/sodalive/v2/home/following/application/HomeFollowingNewsPublishService.kt create mode 100644 src/test/kotlin/kr/co/vividnext/sodalive/v2/home/following/application/HomeFollowingNewsPublishServiceTest.kt diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/home/following/application/HomeFollowingNewsPublishService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/home/following/application/HomeFollowingNewsPublishService.kt new file mode 100644 index 00000000..a66fe443 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/home/following/application/HomeFollowingNewsPublishService.kt @@ -0,0 +1,146 @@ +package kr.co.vividnext.sodalive.v2.home.following.application + +import kr.co.vividnext.sodalive.v2.home.following.domain.FollowingNewsType +import kr.co.vividnext.sodalive.v2.home.following.domain.HomeFollowingNewsSourceKey +import kr.co.vividnext.sodalive.v2.home.following.port.out.HomeFollowingNewsInboxPort +import kr.co.vividnext.sodalive.v2.home.following.port.out.HomeFollowingNewsInboxRecord +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Propagation +import org.springframework.transaction.annotation.Transactional +import java.time.LocalDateTime + +@Service +class HomeFollowingNewsPublishService( + private val inboxPort: HomeFollowingNewsInboxPort +) { + @Transactional(propagation = Propagation.REQUIRES_NEW) + fun publishCommunityPostCreated( + postId: Long, + creatorId: Long, + creatorNickname: String, + creatorProfileImagePath: String?, + title: String, + body: String, + thumbnailImagePath: String?, + occurredAtUtc: LocalDateTime, + isAdult: Boolean + ): Int { + return publishToFollowers( + creatorId = creatorId, + newsType = FollowingNewsType.COMMUNITY_POST, + sourceKey = HomeFollowingNewsSourceKey.communityPost(postId), + targetId = postId, + occurredAtUtc = occurredAtUtc, + visibleFromAtUtc = occurredAtUtc, + creatorNickname = creatorNickname, + creatorProfileImagePath = creatorProfileImagePath, + title = title, + body = body, + thumbnailImagePath = thumbnailImagePath, + rank = null, + isAdult = isAdult + ) + } + + @Transactional(propagation = Propagation.REQUIRES_NEW) + fun publishContentUploaded( + contentId: Long, + creatorId: Long, + creatorNickname: String, + creatorProfileImagePath: String?, + title: String, + body: String, + thumbnailImagePath: String?, + occurredAtUtc: LocalDateTime, + visibleFromAtUtc: LocalDateTime, + isAdult: Boolean + ): Int { + return publishToFollowers( + creatorId = creatorId, + newsType = FollowingNewsType.AUDIO_CONTENT, + sourceKey = HomeFollowingNewsSourceKey.audioContent(contentId), + targetId = contentId, + occurredAtUtc = occurredAtUtc, + visibleFromAtUtc = visibleFromAtUtc, + creatorNickname = creatorNickname, + creatorProfileImagePath = creatorProfileImagePath, + title = title, + body = body, + thumbnailImagePath = thumbnailImagePath, + rank = null, + isAdult = isAdult + ) + } + + @Transactional(propagation = Propagation.REQUIRES_NEW) + fun publishCreatorRankingVisible( + creatorId: Long, + creatorNickname: String, + creatorProfileImagePath: String?, + aggregationStartAtUtc: LocalDateTime, + visibleFromAtUtc: LocalDateTime, + rank: Int + ): Int { + return publishToFollowers( + creatorId = creatorId, + newsType = FollowingNewsType.CREATOR_RANKING, + sourceKey = HomeFollowingNewsSourceKey.creatorRanking(creatorId, aggregationStartAtUtc), + targetId = creatorId, + occurredAtUtc = visibleFromAtUtc, + visibleFromAtUtc = visibleFromAtUtc, + creatorNickname = creatorNickname, + creatorProfileImagePath = creatorProfileImagePath, + title = creatorNickname, + body = "$rank", + thumbnailImagePath = creatorProfileImagePath, + rank = rank, + isAdult = false + ) + } + + private fun publishToFollowers( + creatorId: Long, + newsType: FollowingNewsType, + sourceKey: String, + targetId: Long, + occurredAtUtc: LocalDateTime, + visibleFromAtUtc: LocalDateTime, + creatorNickname: String, + creatorProfileImagePath: String?, + title: String, + body: String, + thumbnailImagePath: String?, + rank: Int?, + isAdult: Boolean + ): Int { + val followerIds = inboxPort.findActiveFollowerIds(creatorId) + if (followerIds.isEmpty()) { + return 0 + } + + val records = followerIds.map { memberId -> + HomeFollowingNewsInboxRecord( + memberId = memberId, + creatorId = creatorId, + newsType = newsType.name, + sourceKey = sourceKey, + targetId = targetId, + occurredAtUtc = occurredAtUtc, + visibleFromAtUtc = visibleFromAtUtc, + creatorNickname = creatorNickname, + creatorProfileImagePath = creatorProfileImagePath, + title = title.take(TITLE_MAX_LENGTH), + body = body.take(BODY_MAX_LENGTH), + thumbnailImagePath = thumbnailImagePath, + rank = rank, + isAdult = isAdult + ) + } + return inboxPort.insertIgnoreAll(records) + } + + companion object { + private const val TITLE_MAX_LENGTH = 255 + private const val BODY_MAX_LENGTH = 1_000 + } +} diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/v2/home/following/application/HomeFollowingNewsPublishServiceTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/home/following/application/HomeFollowingNewsPublishServiceTest.kt new file mode 100644 index 00000000..882bf4cb --- /dev/null +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/home/following/application/HomeFollowingNewsPublishServiceTest.kt @@ -0,0 +1,139 @@ +package kr.co.vividnext.sodalive.v2.home.following.application + +import kr.co.vividnext.sodalive.v2.home.following.domain.FollowingNewsType +import kr.co.vividnext.sodalive.v2.home.following.port.out.HomeFollowingNewsInboxPort +import kr.co.vividnext.sodalive.v2.home.following.port.out.HomeFollowingNewsInboxRecord +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Test +import java.time.LocalDateTime + +class HomeFollowingNewsPublishServiceTest { + @Test + @DisplayName("커뮤니티 게시글 발행은 현재 활성 팔로워에게만 inbox record를 생성한다") + fun shouldPublishCommunityPostCreatedToActiveFollowers() { + val inboxPort = FakeHomeFollowingNewsInboxPort(activeFollowerIds = listOf(1L, 2L)) + val service = HomeFollowingNewsPublishService(inboxPort) + val occurredAtUtc = LocalDateTime.of(2026, 6, 25, 1, 2, 3) + + service.publishCommunityPostCreated( + postId = 100L, + creatorId = 9L, + creatorNickname = "creator", + creatorProfileImagePath = "profile.png", + title = "새 커뮤니티 글", + body = "본문", + thumbnailImagePath = "post.png", + occurredAtUtc = occurredAtUtc, + isAdult = true + ) + + assertEquals(9L, inboxPort.findActiveFollowerIdsCreatorId) + assertEquals(listOf(1L, 2L), inboxPort.records.map { it.memberId }) + val record = inboxPort.records.first() + assertEquals(FollowingNewsType.COMMUNITY_POST.name, record.newsType) + assertEquals("COMMUNITY_POST:100", record.sourceKey) + assertEquals(100L, record.targetId) + assertEquals(occurredAtUtc, record.visibleFromAtUtc) + assertEquals("post.png", record.thumbnailImagePath) + assertEquals(true, record.isAdult) + } + + @Test + @DisplayName("오디오 콘텐츠 발행은 공개 시각을 visibleFromAtUtc로 저장한다") + fun shouldPublishContentUploadedWithVisibleFromAtUtc() { + val inboxPort = FakeHomeFollowingNewsInboxPort(activeFollowerIds = listOf(3L)) + val service = HomeFollowingNewsPublishService(inboxPort) + val occurredAtUtc = LocalDateTime.of(2026, 6, 25, 2, 0) + val visibleFromAtUtc = LocalDateTime.of(2026, 6, 25, 9, 0) + + service.publishContentUploaded( + contentId = 200L, + creatorId = 8L, + creatorNickname = "audio-creator", + creatorProfileImagePath = null, + title = "오디오 제목", + body = "오디오 설명", + thumbnailImagePath = "cover.jpg", + occurredAtUtc = occurredAtUtc, + visibleFromAtUtc = visibleFromAtUtc, + isAdult = false + ) + + val record = inboxPort.records.single() + assertEquals(FollowingNewsType.AUDIO_CONTENT.name, record.newsType) + assertEquals("AUDIO_CONTENT:200", record.sourceKey) + assertEquals(occurredAtUtc, record.occurredAtUtc) + assertEquals(visibleFromAtUtc, record.visibleFromAtUtc) + assertEquals("cover.jpg", record.thumbnailImagePath) + } + + @Test + @DisplayName("발행 record의 title과 body는 inbox 컬럼 길이에 맞게 잘린다") + fun shouldTruncateTitleAndBodyToInboxColumnLimits() { + val inboxPort = FakeHomeFollowingNewsInboxPort(activeFollowerIds = listOf(5L)) + val service = HomeFollowingNewsPublishService(inboxPort) + + service.publishContentUploaded( + contentId = 201L, + creatorId = 8L, + creatorNickname = "audio-creator", + creatorProfileImagePath = null, + title = "가".repeat(300), + body = "나".repeat(1_200), + thumbnailImagePath = null, + occurredAtUtc = LocalDateTime.of(2026, 6, 25, 2, 0), + visibleFromAtUtc = LocalDateTime.of(2026, 6, 25, 9, 0), + isAdult = false + ) + + val record = inboxPort.records.single() + assertEquals(255, record.title.length) + assertEquals(1_000, record.body.length) + } + + @Test + @DisplayName("크리에이터 랭킹 발행은 rank와 스냅샷 노출 시각을 저장한다") + fun shouldPublishCreatorRankingVisibleWithRankAndVisibleFromAtUtc() { + val inboxPort = FakeHomeFollowingNewsInboxPort(activeFollowerIds = listOf(4L)) + val service = HomeFollowingNewsPublishService(inboxPort) + val aggregationStartAtUtc = LocalDateTime.of(2026, 5, 31, 15, 0) + val visibleFromAtUtc = LocalDateTime.of(2026, 6, 8, 0, 0) + + service.publishCreatorRankingVisible( + creatorId = 7L, + creatorNickname = "ranker", + creatorProfileImagePath = "ranker.png", + aggregationStartAtUtc = aggregationStartAtUtc, + visibleFromAtUtc = visibleFromAtUtc, + rank = 2 + ) + + val record = inboxPort.records.single() + assertEquals(FollowingNewsType.CREATOR_RANKING.name, record.newsType) + assertEquals("CREATOR_RANKING:7:2026-05-31T15:00", record.sourceKey) + assertEquals(7L, record.targetId) + assertEquals(visibleFromAtUtc, record.occurredAtUtc) + assertEquals(visibleFromAtUtc, record.visibleFromAtUtc) + assertEquals(2, record.rank) + } +} + +private class FakeHomeFollowingNewsInboxPort( + private val activeFollowerIds: List +) : HomeFollowingNewsInboxPort { + val records = mutableListOf() + var findActiveFollowerIdsCreatorId: Long? = null + + override fun insertIgnoreAll(records: List): Int { + this.records.addAll(records) + return records.size + } + + override fun deactivateByMemberIdAndCreatorId(memberId: Long, creatorId: Long): Long = 0 + + override fun findActiveFollowerIds(creatorId: Long): List { + findActiveFollowerIdsCreatorId = creatorId + return activeFollowerIds + } +}