feat(community): 커뮤니티 게시글 최근 소식을 발행한다

This commit is contained in:
2026-06-26 02:50:24 +09:00
parent 9fc6643c18
commit e89b5e1dad
2 changed files with 219 additions and 2 deletions

View File

@@ -27,11 +27,15 @@ import kr.co.vividnext.sodalive.member.Member
import kr.co.vividnext.sodalive.member.block.BlockMemberRepository import kr.co.vividnext.sodalive.member.block.BlockMemberRepository
import kr.co.vividnext.sodalive.utils.generateFileName import kr.co.vividnext.sodalive.utils.generateFileName
import kr.co.vividnext.sodalive.utils.validateImage 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.beans.factory.annotation.Value
import org.springframework.context.ApplicationEventPublisher import org.springframework.context.ApplicationEventPublisher
import org.springframework.data.repository.findByIdOrNull import org.springframework.data.repository.findByIdOrNull
import org.springframework.stereotype.Service import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional 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 org.springframework.web.multipart.MultipartFile
import java.time.LocalDateTime import java.time.LocalDateTime
import java.time.ZoneId import java.time.ZoneId
@@ -52,6 +56,7 @@ class CreatorCommunityService(
private val applicationEventPublisher: ApplicationEventPublisher, private val applicationEventPublisher: ApplicationEventPublisher,
private val messageSource: SodaMessageSource, private val messageSource: SodaMessageSource,
private val langContext: LangContext, private val langContext: LangContext,
private val homeFollowingNewsPublishService: HomeFollowingNewsPublishService,
@Value("\${cloud.aws.s3.bucket}") @Value("\${cloud.aws.s3.bucket}")
private val imageBucket: String, private val imageBucket: String,
@@ -62,6 +67,8 @@ class CreatorCommunityService(
@Value("\${cloud.aws.cloud-front.host}") @Value("\${cloud.aws.cloud-front.host}")
private val imageHost: String private val imageHost: String
) { ) {
private val log = LoggerFactory.getLogger(javaClass)
@Transactional @Transactional
fun createCommunityPost( fun createCommunityPost(
audioFile: MultipartFile?, audioFile: MultipartFile?,
@@ -134,6 +141,54 @@ class CreatorCommunityService(
deepLinkId = member.id!! 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 @Transactional

View File

@@ -1,6 +1,8 @@
package kr.co.vividnext.sodalive.explorer.profile.creatorCommunity 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.databind.ObjectMapper
import com.fasterxml.jackson.module.kotlin.KotlinModule
import kr.co.vividnext.sodalive.aws.cloudfront.AudioContentCloudFront import kr.co.vividnext.sodalive.aws.cloudfront.AudioContentCloudFront
import kr.co.vividnext.sodalive.aws.s3.S3Uploader import kr.co.vividnext.sodalive.aws.s3.S3Uploader
import kr.co.vividnext.sodalive.can.payment.CanPaymentService 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.Member
import kr.co.vividnext.sodalive.member.MemberRole import kr.co.vividnext.sodalive.member.MemberRole
import kr.co.vividnext.sodalive.member.block.BlockMemberRepository 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.assertEquals
import org.junit.jupiter.api.Assertions.assertFalse import org.junit.jupiter.api.Assertions.assertFalse
import org.junit.jupiter.api.Assertions.assertNotNull import org.junit.jupiter.api.Assertions.assertNotNull
@@ -32,6 +35,8 @@ import org.junit.jupiter.api.Test
import org.mockito.ArgumentCaptor import org.mockito.ArgumentCaptor
import org.mockito.Mockito import org.mockito.Mockito
import org.springframework.context.ApplicationEventPublisher import org.springframework.context.ApplicationEventPublisher
import org.springframework.web.multipart.MultipartFile
import java.io.InputStream
import java.time.LocalDateTime import java.time.LocalDateTime
import java.util.Optional import java.util.Optional
@@ -41,7 +46,9 @@ class CreatorCommunityServiceTest {
private lateinit var likeRepository: CreatorCommunityLikeRepository private lateinit var likeRepository: CreatorCommunityLikeRepository
private lateinit var commentRepository: CreatorCommunityCommentRepository private lateinit var commentRepository: CreatorCommunityCommentRepository
private lateinit var useCanRepository: UseCanRepository private lateinit var useCanRepository: UseCanRepository
private lateinit var s3Uploader: S3Uploader
private lateinit var applicationEventPublisher: ApplicationEventPublisher private lateinit var applicationEventPublisher: ApplicationEventPublisher
private lateinit var homeFollowingNewsPublishService: HomeFollowingNewsPublishService
private lateinit var service: CreatorCommunityService private lateinit var service: CreatorCommunityService
@BeforeEach @BeforeEach
@@ -51,7 +58,9 @@ class CreatorCommunityServiceTest {
likeRepository = Mockito.mock(CreatorCommunityLikeRepository::class.java) likeRepository = Mockito.mock(CreatorCommunityLikeRepository::class.java)
commentRepository = Mockito.mock(CreatorCommunityCommentRepository::class.java) commentRepository = Mockito.mock(CreatorCommunityCommentRepository::class.java)
useCanRepository = Mockito.mock(UseCanRepository::class.java) useCanRepository = Mockito.mock(UseCanRepository::class.java)
s3Uploader = Mockito.mock(S3Uploader::class.java)
applicationEventPublisher = Mockito.mock(ApplicationEventPublisher::class.java) applicationEventPublisher = Mockito.mock(ApplicationEventPublisher::class.java)
homeFollowingNewsPublishService = Mockito.mock(HomeFollowingNewsPublishService::class.java)
service = CreatorCommunityService( service = CreatorCommunityService(
canPaymentService = Mockito.mock(CanPaymentService::class.java), canPaymentService = Mockito.mock(CanPaymentService::class.java),
@@ -60,12 +69,13 @@ class CreatorCommunityServiceTest {
likeRepository = likeRepository, likeRepository = likeRepository,
commentRepository = commentRepository, commentRepository = commentRepository,
useCanRepository = useCanRepository, useCanRepository = useCanRepository,
s3Uploader = Mockito.mock(S3Uploader::class.java), s3Uploader = s3Uploader,
objectMapper = ObjectMapper(), objectMapper = ObjectMapper().registerModule(KotlinModule.Builder().build()),
audioContentCloudFront = Mockito.mock(AudioContentCloudFront::class.java), audioContentCloudFront = Mockito.mock(AudioContentCloudFront::class.java),
applicationEventPublisher = applicationEventPublisher, applicationEventPublisher = applicationEventPublisher,
messageSource = SodaMessageSource(), messageSource = SodaMessageSource(),
langContext = LangContext(), langContext = LangContext(),
homeFollowingNewsPublishService = homeFollowingNewsPublishService,
imageBucket = "image-bucket", imageBucket = "image-bucket",
contentBucket = "content-bucket", contentBucket = "content-bucket",
imageHost = "https://cdn.test" imageHost = "https://cdn.test"
@@ -286,6 +296,158 @@ class CreatorCommunityServiceTest {
assertNull(post.fixedAt) 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<CreatorCommunity>(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<CreatorCommunity>(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<CreatorCommunity>(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 <T> eqValue(value: T): T {
return Mockito.eq(value) ?: value
}
private fun createMember(id: Long, role: MemberRole, nickname: String): Member { private fun createMember(id: Long, role: MemberRole, nickname: String): Member {
val member = Member( val member = Member(
email = "$nickname@test.com", email = "$nickname@test.com",