feat(creator-community): 커뮤니티 댓글 알림 딥링크에 게시글 식별자를 포함한다

This commit is contained in:
2026-03-13 18:54:14 +09:00
parent 5b547cb73c
commit b13a9888d4
8 changed files with 252 additions and 9 deletions

View File

@@ -382,7 +382,9 @@ class CreatorCommunityService(
val post = repository.findByIdOrNull(id = postId)
?: throw SodaException(messageKey = "creator.community.invalid_post_retry")
if (isBlockedBetweenMembers(memberId = member.id!!, creatorId = post.member!!.id!!)) {
val creatorId = post.member!!.id!!
if (isBlockedBetweenMembers(memberId = member.id!!, creatorId = creatorId)) {
throw SodaException(messageKey = "creator.community.invalid_access_retry")
}
@@ -407,6 +409,22 @@ class CreatorCommunityService(
}
commentRepository.save(postComment)
if (member.id != creatorId) {
applicationEventPublisher.publishEvent(
FcmEvent(
type = FcmEventType.INDIVIDUAL,
category = PushNotificationCategory.COMMUNITY,
title = member.nickname,
messageKey = "creator.community.fcm.new_comment",
senderMemberId = member.id,
recipients = listOf(creatorId),
deepLinkValue = FcmDeepLinkValue.COMMUNITY,
deepLinkId = creatorId,
deepLinkCommentPostId = postId
)
)
}
}
@Transactional

View File

@@ -49,6 +49,7 @@ class FcmEvent(
val auditionId: Long? = null,
val deepLinkValue: FcmDeepLinkValue? = null,
val deepLinkId: Long? = null,
val deepLinkCommentPostId: Long? = null,
val commentParentId: Long? = null,
val myMemberId: Long? = null,
val isAvailableJoinCreator: Boolean? = null,
@@ -193,7 +194,8 @@ class FcmSendListener(
creatorId = creatorId ?: fcmEvent.creatorId,
auditionId = auditionId ?: fcmEvent.auditionId,
deepLinkValue = fcmEvent.deepLinkValue,
deepLinkId = fcmEvent.deepLinkId
deepLinkId = fcmEvent.deepLinkId,
deepLinkCommentPostId = fcmEvent.deepLinkCommentPostId
)
}
}

View File

@@ -32,7 +32,8 @@ class FcmService(
creatorId: Long? = null,
auditionId: Long? = null,
deepLinkValue: FcmDeepLinkValue? = null,
deepLinkId: Long? = null
deepLinkId: Long? = null,
deepLinkCommentPostId: Long? = null
) {
if (tokens.isEmpty()) return
logger.info("os: $container")
@@ -89,7 +90,7 @@ class FcmService(
multicastMessage.putData("audition_id", auditionId.toString())
}
val deepLink = createDeepLink(deepLinkValue, deepLinkId)
val deepLink = createDeepLink(deepLinkValue, deepLinkId, deepLinkCommentPostId)
if (deepLink != null) {
multicastMessage.putData("deep_link", deepLink)
}
@@ -127,8 +128,12 @@ class FcmService(
}
}
private fun createDeepLink(deepLinkValue: FcmDeepLinkValue?, deepLinkId: Long?): String? {
return buildDeepLink(serverEnv, deepLinkValue, deepLinkId)
private fun createDeepLink(
deepLinkValue: FcmDeepLinkValue?,
deepLinkId: Long?,
deepLinkCommentPostId: Long?
): String? {
return buildDeepLink(serverEnv, deepLinkValue, deepLinkId, deepLinkCommentPostId)
}
fun sendPointGranted(tokens: List<String>, point: Int) {
@@ -201,7 +206,8 @@ class FcmService(
fun buildDeepLink(
serverEnv: String,
deepLinkValue: FcmDeepLinkValue?,
deepLinkId: Long?
deepLinkId: Long?,
deepLinkCommentPostId: Long? = null
): String? {
if (deepLinkValue == null || deepLinkId == null) {
return null
@@ -213,7 +219,12 @@ class FcmService(
"voiceon-test"
}
return "$uriScheme://${deepLinkValue.value}/$deepLinkId"
val baseDeepLink = "$uriScheme://${deepLinkValue.value}/$deepLinkId"
if (deepLinkValue == FcmDeepLinkValue.COMMUNITY && deepLinkCommentPostId != null) {
return "$baseDeepLink?postId=$deepLinkCommentPostId"
}
return baseDeepLink
}
}
}

View File

@@ -49,7 +49,12 @@ class PushNotificationService(
val category = resolveCategory(fcmEvent) ?: return
val senderSnapshot = resolveSenderSnapshot(fcmEvent)
val deepLink = FcmService.buildDeepLink(serverEnv, fcmEvent.deepLinkValue, fcmEvent.deepLinkId)
val deepLink = FcmService.buildDeepLink(
serverEnv = serverEnv,
deepLinkValue = fcmEvent.deepLinkValue,
deepLinkId = fcmEvent.deepLinkId,
deepLinkCommentPostId = fcmEvent.deepLinkCommentPostId
)
val notification = PushNotificationList(
senderNicknameSnapshot = senderSnapshot.nickname,

View File

@@ -2272,6 +2272,11 @@ class SodaMessageSource {
Lang.EN to "A new post has been added.",
Lang.JA to "新しい投稿が登録されました。"
),
"creator.community.fcm.new_comment" to mapOf(
Lang.KO to "커뮤니티 게시글에 새 댓글이 등록되었습니다.",
Lang.EN to "A new comment has been added to your community post.",
Lang.JA to "コミュニティ投稿に新しいコメントが登録されました。"
),
"creator.community.invalid_request_retry" to mapOf(
Lang.KO to "잘못된 요청입니다.\n다시 시도해 주세요.",
Lang.EN to "Invalid request.\ntry again.",

View File

@@ -0,0 +1,134 @@
package kr.co.vividnext.sodalive.explorer.profile.creatorCommunity
import com.fasterxml.jackson.databind.ObjectMapper
import kr.co.vividnext.sodalive.aws.cloudfront.AudioContentCloudFront
import kr.co.vividnext.sodalive.aws.s3.S3Uploader
import kr.co.vividnext.sodalive.can.payment.CanPaymentService
import kr.co.vividnext.sodalive.can.use.UseCanRepository
import kr.co.vividnext.sodalive.explorer.profile.creatorCommunity.comment.CreatorCommunityComment
import kr.co.vividnext.sodalive.explorer.profile.creatorCommunity.comment.CreatorCommunityCommentRepository
import kr.co.vividnext.sodalive.explorer.profile.creatorCommunity.like.CreatorCommunityLikeRepository
import kr.co.vividnext.sodalive.fcm.FcmDeepLinkValue
import kr.co.vividnext.sodalive.fcm.FcmEvent
import kr.co.vividnext.sodalive.fcm.FcmEventType
import kr.co.vividnext.sodalive.fcm.notification.PushNotificationCategory
import kr.co.vividnext.sodalive.i18n.LangContext
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 org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.DisplayName
import org.junit.jupiter.api.Test
import org.mockito.ArgumentCaptor
import org.mockito.Mockito
import org.springframework.context.ApplicationEventPublisher
import java.util.Optional
class CreatorCommunityServiceTest {
private lateinit var repository: CreatorCommunityRepository
private lateinit var blockMemberRepository: BlockMemberRepository
private lateinit var commentRepository: CreatorCommunityCommentRepository
private lateinit var useCanRepository: UseCanRepository
private lateinit var applicationEventPublisher: ApplicationEventPublisher
private lateinit var service: CreatorCommunityService
@BeforeEach
fun setup() {
repository = Mockito.mock(CreatorCommunityRepository::class.java)
blockMemberRepository = Mockito.mock(BlockMemberRepository::class.java)
commentRepository = Mockito.mock(CreatorCommunityCommentRepository::class.java)
useCanRepository = Mockito.mock(UseCanRepository::class.java)
applicationEventPublisher = Mockito.mock(ApplicationEventPublisher::class.java)
service = CreatorCommunityService(
canPaymentService = Mockito.mock(CanPaymentService::class.java),
repository = repository,
blockMemberRepository = blockMemberRepository,
likeRepository = Mockito.mock(CreatorCommunityLikeRepository::class.java),
commentRepository = commentRepository,
useCanRepository = useCanRepository,
s3Uploader = Mockito.mock(S3Uploader::class.java),
objectMapper = ObjectMapper(),
audioContentCloudFront = Mockito.mock(AudioContentCloudFront::class.java),
applicationEventPublisher = applicationEventPublisher,
messageSource = SodaMessageSource(),
langContext = LangContext(),
imageBucket = "image-bucket",
contentBucket = "content-bucket",
imageHost = "https://cdn.test"
)
}
@Test
@DisplayName("크리에이터가 아닌 사용자가 댓글 작성 시 크리에이터 대상 커뮤니티 딥링크 알림 이벤트를 발행한다")
fun shouldPublishCreatorCommunityCommentNotificationEventWhenCommenterIsNotCreator() {
val creator = createMember(id = 11L, role = MemberRole.CREATOR, nickname = "creator")
val commenter = createMember(id = 22L, role = MemberRole.USER, nickname = "viewer")
val post = CreatorCommunity(content = "post", price = 0, isCommentAvailable = true, isAdult = false)
post.id = 301L
post.member = creator
Mockito.`when`(repository.findById(post.id!!)).thenReturn(Optional.of(post))
Mockito.`when`(useCanRepository.isExistCommunityPostOrdered(post.id!!, commenter.id!!)).thenReturn(false)
Mockito.`when`(commentRepository.save(Mockito.any(CreatorCommunityComment::class.java)))
.thenAnswer { invocation -> invocation.getArgument(0) }
service.createCommunityPostComment(
member = commenter,
comment = "새 댓글",
postId = post.id!!,
parentId = null,
isSecret = false
)
val captor = ArgumentCaptor.forClass(FcmEvent::class.java)
Mockito.verify(applicationEventPublisher).publishEvent(captor.capture())
val event = captor.value
assertEquals(FcmEventType.INDIVIDUAL, event.type)
assertEquals(PushNotificationCategory.COMMUNITY, event.category)
assertEquals("creator.community.fcm.new_comment", event.messageKey)
assertEquals(commenter.id, event.senderMemberId)
assertEquals(listOf(creator.id!!), event.recipients)
assertEquals(FcmDeepLinkValue.COMMUNITY, event.deepLinkValue)
assertEquals(creator.id, event.deepLinkId)
assertEquals(post.id, event.deepLinkCommentPostId)
}
@Test
@DisplayName("크리에이터 본인이 댓글 작성 시 자기 자신 대상 알림 이벤트를 발행하지 않는다")
fun shouldNotPublishNotificationEventWhenCreatorCommentsOwnPost() {
val creator = createMember(id = 44L, role = MemberRole.CREATOR, nickname = "creator")
val post = CreatorCommunity(content = "post", price = 0, isCommentAvailable = true, isAdult = false)
post.id = 401L
post.member = creator
Mockito.`when`(repository.findById(post.id!!)).thenReturn(Optional.of(post))
Mockito.`when`(useCanRepository.isExistCommunityPostOrdered(post.id!!, creator.id!!)).thenReturn(false)
Mockito.`when`(commentRepository.save(Mockito.any(CreatorCommunityComment::class.java)))
.thenAnswer { invocation -> invocation.getArgument(0) }
service.createCommunityPostComment(
member = creator,
comment = "내가 단 댓글",
postId = post.id!!,
parentId = null,
isSecret = false
)
Mockito.verify(applicationEventPublisher, Mockito.never()).publishEvent(Mockito.any())
}
private fun createMember(id: Long, role: MemberRole, nickname: String): Member {
val member = Member(
email = "$nickname@test.com",
password = "password",
nickname = nickname,
role = role
)
member.id = id
return member
}
}

View File

@@ -127,6 +127,38 @@ class PushNotificationServiceTest {
assertEquals(1, saved.recipientChunks[2].recipientMemberIds.size)
}
@Test
fun shouldAppendPostIdQueryForCommunityCommentDeepLinkWhenSavingNotification() {
val event = FcmEvent(
type = FcmEventType.INDIVIDUAL,
category = PushNotificationCategory.COMMUNITY,
senderMemberId = 500L,
recipients = listOf(10L),
deepLinkValue = FcmDeepLinkValue.COMMUNITY,
deepLinkId = 77L,
deepLinkCommentPostId = 999L
)
val pushTokens = listOf(PushTokenInfo(token = "token-1", deviceType = "aos", languageCode = "ko"))
val sender = createMember(id = 500L, role = MemberRole.CREATOR, nickname = "creator")
Mockito.`when`(pushTokenRepository.findMemberIdsByTokenIn(listOf("token-1"))).thenReturn(listOf(10L))
Mockito.`when`(memberRepository.findById(500L)).thenReturn(Optional.of(sender))
Mockito.`when`(pushNotificationListRepository.save(Mockito.any(PushNotificationList::class.java)))
.thenAnswer { invocation -> invocation.getArgument(0) }
service.saveNotification(
fcmEvent = event,
languageCode = "ko",
translatedMessage = "새 댓글이 등록되었습니다.",
recipientPushTokens = pushTokens
)
val captor = ArgumentCaptor.forClass(PushNotificationList::class.java)
Mockito.verify(pushNotificationListRepository).save(captor.capture())
val saved = captor.value
assertEquals("voiceon://community/77?postId=999", saved.deepLink)
}
@Test
fun shouldApplyLanguageAndOptionalCategoryWhenGettingNotificationList() {
// given: 현재 기기 언어를 EN으로 설정하고 목록 조회 결과를 준비한다.