feat(community): 커뮤니티 게시글 최근 소식을 발행한다
This commit is contained in:
@@ -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
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
Reference in New Issue
Block a user