diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/explorer/profile/creatorCommunity/CreatorCommunityService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/explorer/profile/creatorCommunity/CreatorCommunityService.kt index 0e908f7b..77dd3649 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/explorer/profile/creatorCommunity/CreatorCommunityService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/explorer/profile/creatorCommunity/CreatorCommunityService.kt @@ -27,11 +27,15 @@ import kr.co.vividnext.sodalive.member.Member import kr.co.vividnext.sodalive.member.block.BlockMemberRepository import kr.co.vividnext.sodalive.utils.generateFileName import kr.co.vividnext.sodalive.utils.validateImage +import kr.co.vividnext.sodalive.v2.home.following.application.HomeFollowingNewsPublishService +import org.slf4j.LoggerFactory import org.springframework.beans.factory.annotation.Value 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.time.LocalDateTime import java.time.ZoneId @@ -52,6 +56,7 @@ class CreatorCommunityService( private val applicationEventPublisher: ApplicationEventPublisher, private val messageSource: SodaMessageSource, private val langContext: LangContext, + private val homeFollowingNewsPublishService: HomeFollowingNewsPublishService, @Value("\${cloud.aws.s3.bucket}") private val imageBucket: String, @@ -62,6 +67,8 @@ class CreatorCommunityService( @Value("\${cloud.aws.cloud-front.host}") private val imageHost: String ) { + private val log = LoggerFactory.getLogger(javaClass) + @Transactional fun createCommunityPost( audioFile: MultipartFile?, @@ -134,6 +141,54 @@ class CreatorCommunityService( deepLinkId = member.id!! ) ) + publishCommunityPostCreatedAfterCommit(post, member) + } + + private fun publishCommunityPostCreatedAfterCommit(post: CreatorCommunity, member: Member) { + val occurredAtUtc = post.createdAt ?: LocalDateTime.now() + val newsContent = post.newsContentPreview() + afterCommit { + homeFollowingNewsPublishService.publishCommunityPostCreated( + postId = post.id!!, + creatorId = member.id!!, + creatorNickname = member.nickname, + creatorProfileImagePath = member.profileImage, + title = newsContent.take(80), + body = newsContent, + thumbnailImagePath = post.imagePath, + occurredAtUtc = occurredAtUtc, + isAdult = post.isAdult + ) + } + } + + private fun CreatorCommunity.newsContentPreview(): String { + if (price <= 0) { + return content + } + + val length = content.codePointCount(0, content.length) + val previewLength = if (length > 15) 15 else length / 2 + val endIndex = content.offsetByCodePoints(0, previewLength) + return content.substring(0, endIndex).plus("...") + } + + 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 diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/explorer/profile/creatorCommunity/CreatorCommunityServiceTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/explorer/profile/creatorCommunity/CreatorCommunityServiceTest.kt index 9afa68df..b7d1dee5 100644 --- a/src/test/kotlin/kr/co/vividnext/sodalive/explorer/profile/creatorCommunity/CreatorCommunityServiceTest.kt +++ b/src/test/kotlin/kr/co/vividnext/sodalive/explorer/profile/creatorCommunity/CreatorCommunityServiceTest.kt @@ -1,6 +1,8 @@ package kr.co.vividnext.sodalive.explorer.profile.creatorCommunity +import com.amazonaws.services.s3.model.ObjectMetadata import com.fasterxml.jackson.databind.ObjectMapper +import com.fasterxml.jackson.module.kotlin.KotlinModule import kr.co.vividnext.sodalive.aws.cloudfront.AudioContentCloudFront import kr.co.vividnext.sodalive.aws.s3.S3Uploader import kr.co.vividnext.sodalive.can.payment.CanPaymentService @@ -20,6 +22,7 @@ import kr.co.vividnext.sodalive.i18n.SodaMessageSource import kr.co.vividnext.sodalive.member.Member import kr.co.vividnext.sodalive.member.MemberRole import kr.co.vividnext.sodalive.member.block.BlockMemberRepository +import kr.co.vividnext.sodalive.v2.home.following.application.HomeFollowingNewsPublishService import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Assertions.assertFalse import org.junit.jupiter.api.Assertions.assertNotNull @@ -32,6 +35,8 @@ import org.junit.jupiter.api.Test import org.mockito.ArgumentCaptor import org.mockito.Mockito import org.springframework.context.ApplicationEventPublisher +import org.springframework.web.multipart.MultipartFile +import java.io.InputStream import java.time.LocalDateTime import java.util.Optional @@ -41,7 +46,9 @@ class CreatorCommunityServiceTest { private lateinit var likeRepository: CreatorCommunityLikeRepository private lateinit var commentRepository: CreatorCommunityCommentRepository private lateinit var useCanRepository: UseCanRepository + private lateinit var s3Uploader: S3Uploader private lateinit var applicationEventPublisher: ApplicationEventPublisher + private lateinit var homeFollowingNewsPublishService: HomeFollowingNewsPublishService private lateinit var service: CreatorCommunityService @BeforeEach @@ -51,7 +58,9 @@ class CreatorCommunityServiceTest { likeRepository = Mockito.mock(CreatorCommunityLikeRepository::class.java) commentRepository = Mockito.mock(CreatorCommunityCommentRepository::class.java) useCanRepository = Mockito.mock(UseCanRepository::class.java) + s3Uploader = Mockito.mock(S3Uploader::class.java) applicationEventPublisher = Mockito.mock(ApplicationEventPublisher::class.java) + homeFollowingNewsPublishService = Mockito.mock(HomeFollowingNewsPublishService::class.java) service = CreatorCommunityService( canPaymentService = Mockito.mock(CanPaymentService::class.java), @@ -60,12 +69,13 @@ class CreatorCommunityServiceTest { likeRepository = likeRepository, commentRepository = commentRepository, useCanRepository = useCanRepository, - s3Uploader = Mockito.mock(S3Uploader::class.java), - objectMapper = ObjectMapper(), + s3Uploader = s3Uploader, + objectMapper = ObjectMapper().registerModule(KotlinModule.Builder().build()), audioContentCloudFront = Mockito.mock(AudioContentCloudFront::class.java), applicationEventPublisher = applicationEventPublisher, messageSource = SodaMessageSource(), langContext = LangContext(), + homeFollowingNewsPublishService = homeFollowingNewsPublishService, imageBucket = "image-bucket", contentBucket = "content-bucket", imageHost = "https://cdn.test" @@ -286,6 +296,158 @@ class CreatorCommunityServiceTest { assertNull(post.fixedAt) } + @Test + @DisplayName("커뮤니티 게시글 생성 성공 후 최근 소식을 게시글 정보로 발행한다") + fun shouldPublishNewsAfterCommunityPostCreated() { + val creator = createMember(id = 900L, role = MemberRole.CREATOR, nickname = "community-creator") + creator.profileImage = "profile/community-creator.png" + val createdAt = LocalDateTime.of(2026, 6, 25, 10, 0) + Mockito.`when`(repository.save(Mockito.any(CreatorCommunity::class.java))).thenAnswer { invocation -> + val post = invocation.getArgument(0) + post.id = 901L + post.createdAt = createdAt + post + } + + service.createCommunityPost( + audioFile = null, + postImage = null, + requestString = """{"content":"커뮤니티 새 게시글 본문입니다","price":0,"isCommentAvailable":true,"isAdult":true}""", + member = creator + ) + + Mockito.verify(homeFollowingNewsPublishService).publishCommunityPostCreated( + postId = 901L, + creatorId = creator.id!!, + creatorNickname = creator.nickname!!, + creatorProfileImagePath = creator.profileImage, + title = "커뮤니티 새 게시글 본문입니다", + body = "커뮤니티 새 게시글 본문입니다", + thumbnailImagePath = null, + occurredAtUtc = createdAt, + isAdult = true + ) + } + + @Test + @DisplayName("유료 커뮤니티 게시글 최근 소식은 전체 본문을 노출하지 않고 미리보기만 발행한다") + fun shouldPublishPaidCommunityPostNewsWithMaskedContent() { + val creator = createMember(id = 910L, role = MemberRole.CREATOR, nickname = "paid-community-creator") + val fullContent = "유료 커뮤니티 게시글 전체 본문은 최근 소식에서 노출되면 안 됩니다" + val createdAt = LocalDateTime.of(2026, 6, 25, 11, 0) + Mockito.`when`(repository.save(Mockito.any(CreatorCommunity::class.java))).thenAnswer { invocation -> + val post = invocation.getArgument(0) + post.id = 911L + post.createdAt = createdAt + post + } + Mockito.`when`( + s3Uploader.upload( + inputStream = anyInputStream(), + bucket = eqValue("image-bucket"), + filePath = anyStringValue(), + metadata = anyObjectMetadata() + ) + ).thenReturn("creator_community/911/911-image.png") + + service.createCommunityPost( + audioFile = null, + postImage = paidPostImage(), + requestString = """{"content":"$fullContent","price":10,"isCommentAvailable":true,"isAdult":false}""", + member = creator + ) + + Mockito.verify(homeFollowingNewsPublishService).publishCommunityPostCreated( + postId = 911L, + creatorId = creator.id!!, + creatorNickname = creator.nickname!!, + creatorProfileImagePath = creator.profileImage, + title = "유료 커뮤니티 게시글 전체 ...", + body = "유료 커뮤니티 게시글 전체 ...", + thumbnailImagePath = "creator_community/911/911-image.png", + occurredAtUtc = createdAt, + isAdult = false + ) + } + + @Test + @DisplayName("최근 소식 발행 실패는 커뮤니티 게시글 생성을 실패시키지 않는다") + fun shouldNotFailCommunityPostCreationWhenNewsPublishFails() { + val creator = createMember(id = 920L, role = MemberRole.CREATOR, nickname = "publish-failure-community") + Mockito.`when`(repository.save(Mockito.any(CreatorCommunity::class.java))).thenAnswer { invocation -> + val post = invocation.getArgument(0) + post.id = 921L + post.createdAt = LocalDateTime.of(2026, 6, 25, 12, 0) + post + } + Mockito.doAnswer { throw IllegalStateException("publish failed") } + .`when`(homeFollowingNewsPublishService) + .publishCommunityPostCreated( + postId = anyLongValue(), + creatorId = anyLongValue(), + creatorNickname = anyStringValue(), + creatorProfileImagePath = Mockito.anyString(), + title = anyStringValue(), + body = anyStringValue(), + thumbnailImagePath = Mockito.anyString(), + occurredAtUtc = anyLocalDateTime(), + isAdult = Mockito.anyBoolean() + ) + + service.createCommunityPost( + audioFile = null, + postImage = null, + requestString = """{"content":"커뮤니티 발행 실패 격리","price":0,"isCommentAvailable":true,"isAdult":false}""", + member = creator + ) + + Mockito.verify(repository).save(Mockito.any(CreatorCommunity::class.java)) + } + + private fun paidPostImage(): MultipartFile { + val pngBytes = byteArrayOf( + 0x89.toByte(), + 0x50, + 0x4E, + 0x47, + 0x0D, + 0x0A, + 0x1A, + 0x0A + ) + return Mockito.mock(MultipartFile::class.java).also { image -> + Mockito.`when`(image.bytes).thenReturn(pngBytes) + Mockito.`when`(image.size).thenReturn(pngBytes.size.toLong()) + Mockito.`when`(image.contentType).thenReturn("image/png") + Mockito.`when`(image.originalFilename).thenReturn("paid.png") + Mockito.`when`(image.inputStream).thenReturn(pngBytes.inputStream()) + } + } + + private fun anyInputStream(): InputStream { + return Mockito.any(InputStream::class.java) ?: byteArrayOf().inputStream() + } + + private fun anyObjectMetadata(): ObjectMetadata { + return Mockito.any(ObjectMetadata::class.java) ?: ObjectMetadata() + } + + private fun anyStringValue(): String { + return Mockito.anyString() ?: "" + } + + private fun anyLongValue(): Long { + return Mockito.anyLong() + } + + private fun anyLocalDateTime(): LocalDateTime { + return Mockito.any(LocalDateTime::class.java) ?: LocalDateTime.MIN + } + + private fun eqValue(value: T): T { + return Mockito.eq(value) ?: value + } + private fun createMember(id: Long, role: MemberRole, nickname: String): Member { val member = Member( email = "$nickname@test.com",