diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/content/AudioContentService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/content/AudioContentService.kt index 4ea1c05a..6f9d33b0 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/content/AudioContentService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/content/AudioContentService.kt @@ -39,6 +39,7 @@ import kr.co.vividnext.sodalive.member.Member import kr.co.vividnext.sodalive.member.block.BlockMemberRepository import kr.co.vividnext.sodalive.member.contentpreference.isAdultVisibleByPolicy import kr.co.vividnext.sodalive.utils.generateFileName +import kr.co.vividnext.sodalive.v2.home.following.application.HomeFollowingNewsPublishService import kr.co.vividnext.sodalive.v2.recommendation.application.CreatorContentViewHistoryService import org.slf4j.LoggerFactory import org.springframework.beans.factory.annotation.Value @@ -47,6 +48,8 @@ import org.springframework.context.ApplicationEventPublisher import org.springframework.data.repository.findByIdOrNull import org.springframework.stereotype.Service import org.springframework.transaction.annotation.Transactional +import org.springframework.transaction.support.TransactionSynchronization +import org.springframework.transaction.support.TransactionSynchronizationManager import org.springframework.web.multipart.MultipartFile import java.text.SimpleDateFormat import java.time.LocalDateTime @@ -82,6 +85,7 @@ class AudioContentService( private val langContext: LangContext, private val contentThemeTranslationRepository: ContentThemeTranslationRepository, + private val homeFollowingNewsPublishService: HomeFollowingNewsPublishService, @Value("\${cloud.aws.s3.content-bucket}") private val audioContentBucket: String, @@ -476,7 +480,8 @@ class AudioContentService( ) ) - if (audioContent.releaseDate == null || audioContent.releaseDate!! <= audioContent.createdAt) { + val now = LocalDateTime.now() + if (audioContent.releaseDate == null || audioContent.releaseDate!! <= now) { audioContent.isActive = true applicationEventPublisher.publishEvent( @@ -494,6 +499,10 @@ class AudioContentService( deepLinkId = contentId ) ) + publishContentUploadedAfterCommit( + audioContent = audioContent, + visibleFromAtUtc = audioContent.releaseDate ?: audioContent.createdAt ?: now + ) } } @@ -520,9 +529,64 @@ class AudioContentService( deepLinkId = audioContent.id!! ) ) + publishContentUploadedAfterCommit( + audioContent = audioContent, + visibleFromAtUtc = audioContent.releaseDate ?: LocalDateTime.now() + ) } } + private fun publishContentUploadedAfterCommit(audioContent: AudioContent, visibleFromAtUtc: LocalDateTime) { + val creator = audioContent.member!! + val occurredAtUtc = audioContent.createdAt ?: visibleFromAtUtc + val newsBody = audioContent.newsDetailPreview() + afterCommit { + homeFollowingNewsPublishService.publishContentUploaded( + contentId = audioContent.id!!, + creatorId = creator.id!!, + creatorNickname = creator.nickname, + creatorProfileImagePath = creator.profileImage, + title = audioContent.title, + body = newsBody, + thumbnailImagePath = audioContent.coverImage, + occurredAtUtc = occurredAtUtc, + visibleFromAtUtc = visibleFromAtUtc, + isAdult = audioContent.isAdult + ) + } + } + + private fun AudioContent.newsDetailPreview(): String { + if (price < 50 || isFullDetailVisible) { + return detail + } + + val length = detail.length + return if (length < 60) { + "${detail.take(length / 2)}..." + } else { + "${detail.take(30)}..." + } + } + + private fun afterCommit(action: () -> Unit) { + if (!TransactionSynchronizationManager.isSynchronizationActive()) { + runCatching(action).onFailure { ex -> + log.warn("event=home_following_news_publish_failure error={}", ex.message, ex) + } + return + } + TransactionSynchronizationManager.registerSynchronization( + object : TransactionSynchronization { + override fun afterCommit() { + runCatching(action).onFailure { ex -> + log.warn("event=home_following_news_publish_failure error={}", ex.message, ex) + } + } + } + ) + } + @Transactional fun getDetail( id: Long, diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/content/AudioContentServiceTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/content/AudioContentServiceTest.kt index a50027bc..81ff8130 100644 --- a/src/test/kotlin/kr/co/vividnext/sodalive/content/AudioContentServiceTest.kt +++ b/src/test/kotlin/kr/co/vividnext/sodalive/content/AudioContentServiceTest.kt @@ -21,6 +21,7 @@ import kr.co.vividnext.sodalive.i18n.SodaMessageSource import kr.co.vividnext.sodalive.i18n.translation.ResourceTranslationJobScheduler import kr.co.vividnext.sodalive.member.Member import kr.co.vividnext.sodalive.member.block.BlockMemberRepository +import kr.co.vividnext.sodalive.v2.home.following.application.HomeFollowingNewsPublishService import kr.co.vividnext.sodalive.v2.recommendation.application.CreatorContentViewHistoryService import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Assertions.assertNull @@ -57,6 +58,7 @@ class AudioContentServiceTest { private lateinit var audioContentCloudFront: AudioContentCloudFront private lateinit var applicationEventPublisher: ApplicationEventPublisher private lateinit var contentThemeTranslationRepository: ContentThemeTranslationRepository + private lateinit var homeFollowingNewsPublishService: HomeFollowingNewsPublishService private lateinit var service: AudioContentService @@ -80,6 +82,7 @@ class AudioContentServiceTest { audioContentCloudFront = Mockito.mock(AudioContentCloudFront::class.java) applicationEventPublisher = Mockito.mock(ApplicationEventPublisher::class.java) contentThemeTranslationRepository = Mockito.mock(ContentThemeTranslationRepository::class.java) + homeFollowingNewsPublishService = Mockito.mock(HomeFollowingNewsPublishService::class.java) service = AudioContentService( repository = repository, @@ -103,6 +106,7 @@ class AudioContentServiceTest { messageSource = SodaMessageSource(), langContext = LangContext(), contentThemeTranslationRepository = contentThemeTranslationRepository, + homeFollowingNewsPublishService = homeFollowingNewsPublishService, audioContentBucket = "audio-bucket", coverImageBucket = "cover-bucket", coverImageHost = "https://cdn.test" @@ -273,6 +277,178 @@ class AudioContentServiceTest { assertTrue(output.out.contains("contentId=${audioContent.id}")) } + @Test + @DisplayName("업로드 완료 시 즉시 공개 콘텐츠는 최근 소식을 발행한다") + fun shouldPublishNewsWhenUploadCompleteMakesContentPublicImmediately() { + val creator = createMember(id = 2100L, nickname = "audio-creator") + creator.profileImage = "profile/audio-creator.png" + val audioContent = createAudioContent(creator = creator, isAdult = true) + audioContent.isActive = false + audioContent.releaseDate = LocalDateTime.of(2026, 6, 25, 9, 0) + audioContent.createdAt = LocalDateTime.of(2026, 6, 25, 9, 0) + Mockito.`when`(repository.findById(audioContent.id!!)).thenReturn(Optional.of(audioContent)) + + service.uploadComplete( + contentId = audioContent.id!!, + content = "output/${audioContent.id}/${audioContent.id}-content.mp3", + duration = "00:03:00" + ) + + Mockito.verify(homeFollowingNewsPublishService).publishContentUploaded( + contentId = audioContent.id!!, + creatorId = creator.id!!, + creatorNickname = creator.nickname!!, + creatorProfileImagePath = creator.profileImage, + title = audioContent.title, + body = audioContent.detail, + thumbnailImagePath = audioContent.coverImage, + occurredAtUtc = audioContent.createdAt!!, + visibleFromAtUtc = audioContent.createdAt!!, + isAdult = true + ) + } + + @Test + @DisplayName("유료 오디오가 상세 비공개이면 최근 소식은 전체 상세를 노출하지 않는다") + fun shouldMaskPaidAudioDetailWhenPublishingNews() { + val creator = createMember(id = 2120L, nickname = "paid-audio-creator") + val audioContent = createAudioContent(creator = creator) + audioContent.isActive = false + audioContent.isFullDetailVisible = false + audioContent.detail = "유료 오디오 상세 설명 전체 본문은 최근 소식에서 모두 보이면 안 됩니다" + audioContent.releaseDate = LocalDateTime.of(2026, 6, 25, 9, 0) + audioContent.createdAt = LocalDateTime.of(2026, 6, 25, 9, 0) + Mockito.`when`(repository.findById(audioContent.id!!)).thenReturn(Optional.of(audioContent)) + + service.uploadComplete( + contentId = audioContent.id!!, + content = "output/${audioContent.id}/${audioContent.id}-content.mp3", + duration = "00:03:00" + ) + + Mockito.verify(homeFollowingNewsPublishService).publishContentUploaded( + contentId = audioContent.id!!, + creatorId = creator.id!!, + creatorNickname = creator.nickname!!, + creatorProfileImagePath = creator.profileImage, + title = audioContent.title, + body = "유료 오디오 상세 설명 전체 본문은 ...", + thumbnailImagePath = audioContent.coverImage, + occurredAtUtc = audioContent.createdAt!!, + visibleFromAtUtc = audioContent.createdAt!!, + isAdult = false + ) + } + + @Test + @DisplayName("최근 소식 발행 실패는 업로드 완료 처리를 실패시키지 않는다") + fun shouldNotFailUploadCompleteWhenNewsPublishFails() { + val creator = createMember(id = 2130L, nickname = "publish-failure-creator") + val audioContent = createAudioContent(creator = creator) + audioContent.isActive = false + audioContent.releaseDate = LocalDateTime.of(2026, 6, 25, 9, 0) + audioContent.createdAt = LocalDateTime.of(2026, 6, 25, 9, 0) + Mockito.`when`(repository.findById(audioContent.id!!)).thenReturn(Optional.of(audioContent)) + Mockito.doAnswer { throw IllegalStateException("publish failed") } + .`when`(homeFollowingNewsPublishService) + .publishContentUploaded( + contentId = anyLongValue(), + creatorId = anyLongValue(), + creatorNickname = anyStringValue(), + creatorProfileImagePath = Mockito.anyString(), + title = anyStringValue(), + body = anyStringValue(), + thumbnailImagePath = Mockito.anyString(), + occurredAtUtc = anyLocalDateTime(), + visibleFromAtUtc = anyLocalDateTime(), + isAdult = Mockito.anyBoolean() + ) + + service.uploadComplete( + contentId = audioContent.id!!, + content = "output/${audioContent.id}/${audioContent.id}-content.mp3", + duration = "00:03:00" + ) + + assertTrue(audioContent.isActive) + } + + @Test + @DisplayName("업로드 완료 시 공개 시각이 생성 이후 업로드 전이면 최근 소식을 발행한다") + fun shouldPublishNewsWhenReleaseDatePassedBeforeUploadComplete() { + val creator = createMember(id = 2150L, nickname = "audio-late-upload-creator") + val audioContent = createAudioContent(creator = creator) + audioContent.isActive = false + audioContent.createdAt = LocalDateTime.now().minusHours(2) + audioContent.releaseDate = LocalDateTime.now().minusHours(1) + Mockito.`when`(repository.findById(audioContent.id!!)).thenReturn(Optional.of(audioContent)) + + service.uploadComplete( + contentId = audioContent.id!!, + content = "output/${audioContent.id}/${audioContent.id}-content.mp3", + duration = "00:03:00" + ) + + Mockito.verify(homeFollowingNewsPublishService).publishContentUploaded( + contentId = audioContent.id!!, + creatorId = creator.id!!, + creatorNickname = creator.nickname!!, + creatorProfileImagePath = creator.profileImage, + title = audioContent.title, + body = audioContent.detail, + thumbnailImagePath = audioContent.coverImage, + occurredAtUtc = audioContent.createdAt!!, + visibleFromAtUtc = audioContent.releaseDate!!, + isAdult = false + ) + } + + @Test + @DisplayName("업로드 완료 시 예약 공개 콘텐츠는 생성 시점에 최근 소식을 발행하지 않는다") + fun shouldNotPublishNewsWhenUploadCompleteKeepsScheduledContentInactive() { + val creator = createMember(id = 2200L, nickname = "scheduled-creator") + val audioContent = createAudioContent(creator = creator) + audioContent.isActive = false + audioContent.createdAt = LocalDateTime.of(2026, 6, 25, 9, 0) + audioContent.releaseDate = LocalDateTime.of(2026, 6, 26, 9, 0) + Mockito.`when`(repository.findById(audioContent.id!!)).thenReturn(Optional.of(audioContent)) + + service.uploadComplete( + contentId = audioContent.id!!, + content = "output/${audioContent.id}/${audioContent.id}-content.mp3", + duration = "00:03:00" + ) + + Mockito.verifyNoInteractions(homeFollowingNewsPublishService) + } + + @Test + @DisplayName("예약 콘텐츠 공개 작업은 활성화 시점에 최근 소식을 발행한다") + fun shouldPublishNewsWhenReleaseContentActivatesScheduledContent() { + val creator = createMember(id = 2300L, nickname = "release-creator") + creator.profileImage = "profile/release-creator.png" + val audioContent = createAudioContent(creator = creator) + audioContent.isActive = false + audioContent.createdAt = LocalDateTime.of(2026, 6, 24, 9, 0) + audioContent.releaseDate = LocalDateTime.of(2026, 6, 25, 9, 0) + Mockito.`when`(repository.getNotReleaseContent()).thenReturn(listOf(audioContent)) + + service.releaseContent() + + Mockito.verify(homeFollowingNewsPublishService).publishContentUploaded( + contentId = audioContent.id!!, + creatorId = creator.id!!, + creatorNickname = creator.nickname!!, + creatorProfileImagePath = creator.profileImage, + title = audioContent.title, + body = audioContent.detail, + thumbnailImagePath = audioContent.coverImage, + occurredAtUtc = audioContent.createdAt!!, + visibleFromAtUtc = audioContent.releaseDate!!, + isAdult = false + ) + } + private fun createMember(id: Long, nickname: String): Member { val member = Member( email = "$nickname@test.com", @@ -283,6 +459,14 @@ class AudioContentServiceTest { return member } + private fun anyLongValue(): Long { + return Mockito.anyLong() + } + + private fun anyStringValue(): String { + return Mockito.anyString() ?: "" + } + private fun createAudioContent(creator: Member, isAdult: Boolean = false): AudioContent { val theme = AudioContentTheme(theme = "수면", image = "sleep.png") theme.id = 300L