diff --git a/docs/20260313_크리에이터커뮤니티댓글알림딥링크적용.md b/docs/20260313_크리에이터커뮤니티댓글알림딥링크적용.md new file mode 100644 index 00000000..f2ecbd0e --- /dev/null +++ b/docs/20260313_크리에이터커뮤니티댓글알림딥링크적용.md @@ -0,0 +1,36 @@ +- [x] 요구사항/기존 패턴 확정: 크리에이터 커뮤니티 댓글 등록 시점에 푸시 발송 + 알림 리스트 저장 경로를 기존 FCM 이벤트 파이프라인으로 연결한다. + - QA: `CreatorCommunityService#createCommunityPostComment`, `FcmEvent`, `FcmSendListener`, `PushNotificationService` 흐름을 코드로 확인한다. +- [x] 딥링크 규칙 확정: 댓글 알림의 딥링크를 `voiceon://community/{creatorId}?postId={postId}`(테스트 환경은 `voiceon-test://community/{creatorId}?postId={postId}`)로 생성되도록 이벤트 메타를 설정한다. + - QA: `FcmService.buildDeepLink(serverEnv, deepLinkValue, deepLinkId, deepLinkCommentPostId)` 규칙과 `creatorId/postId` 매핑을 확인한다. +- [x] 댓글 등록 시 알림 이벤트 구현: 댓글 작성자가 크리에이터 본인이 아닌 경우에만 크리에이터 대상 `INDIVIDUAL` 이벤트를 발행한다. + - QA: 이벤트에 `category=COMMUNITY`, `deepLinkValue=COMMUNITY`, `deepLinkId=creatorId`, `deepLinkCommentPostId=postId`, `recipients=[creatorId]`가 포함되는지 확인한다. +- [x] 알림 문구 메시지 키 추가: 크리에이터 커뮤니티 댓글 알림용 다국어 키를 `SodaMessageSource`에 추가한다. + - QA: KO/EN/JA 값이 모두 존재하고 `messageKey`로 조회 가능해야 한다. +- [x] 검증 실행: 수정 파일 LSP 진단, 관련 테스트, 전체 빌드 실행 후 결과를 기록한다. + - QA: `./gradlew test`, `./gradlew build` 성공. + +## 완료 기준 (Acceptance Criteria) +- [x] 댓글 등록 API 호출 후(작성자 != 크리에이터) `FcmEvent`가 발행되어 크리에이터에게 푸시 전송 대상이 생성된다. +- [x] 동일 이벤트로 저장되는 알림 리스트의 `deepLink` 값이 푸시 payload `deep_link`와 동일 규칙으로 생성된다. +- [x] 댓글 알림 딥링크는 커뮤니티 전체보기 진입 경로(`community/{creatorId}`)를 유지하면서 대상 게시글 식별자(`postId`)를 포함한다. +- [x] 기존 커뮤니티 새 글 알림 및 다른 도메인 푸시 딥링크 동작에 회귀 영향이 없다. + +## 검증 기록 + +### 1차 구현 +- 무엇을: `CreatorCommunityService#createCommunityPostComment`에 댓글 등록 직후 크리에이터 대상 `FcmEventType.INDIVIDUAL` 이벤트 발행 로직을 추가했다. 이벤트에는 `category=COMMUNITY`, `messageKey=creator.community.fcm.new_comment`, `deepLinkValue=COMMUNITY`, `deepLinkId=creatorId`, `recipients=[creatorId]`를 설정했고, 크리에이터 본인 댓글은 알림을 발행하지 않도록 제외했다. 또한 `SodaMessageSource`에 `creator.community.fcm.new_comment` 다국어 메시지를 추가했다. +- 왜: 댓글 알림 수신자가 푸시 터치/알림 리스트 터치 시 동일 딥링크(`community/{creatorId}`)로 이동하도록, 기존 FCM 이벤트-알림 저장 공통 경로를 그대로 재사용하기 위해서다. +- 어떻게: + - `lsp_diagnostics` 실행 시도: Kotlin LSP 미구성으로 실행 불가(환경 한계) + - `./gradlew test --tests "*CreatorCommunityServiceTest"` 실행(성공) + - `./gradlew test` 실행(성공) + - `./gradlew build` 실행(성공) + +### 2차 수정 +- 무엇을: 커뮤니티 댓글 알림 딥링크에 `postId`를 함께 전달하도록 `FcmEvent`에 `deepLinkCommentPostId`를 추가하고, `FcmService.buildDeepLink`에서 커뮤니티 딥링크일 때 `?postId={postId}`를 붙이도록 수정했다. 이에 맞춰 `CreatorCommunityService`에서 댓글 등록 이벤트 발행 시 `deepLinkCommentPostId = postId`를 설정했고, `PushNotificationService`도 동일 딥링크 문자열을 알림 리스트에 저장하도록 반영했다. 테스트는 `CreatorCommunityServiceTest`, `PushNotificationServiceTest`를 보강했다. +- 왜: 기존 `community/{creatorId}`만으로는 어떤 게시글의 댓글 리스트를 열어야 하는지 식별할 수 없어, 커뮤니티 전체보기 진입은 유지하면서 대상 게시글 식별자를 함께 전달하기 위해서다. +- 어떻게: + - `lsp_diagnostics` 실행 시도: Kotlin LSP 미구성으로 실행 불가(환경 한계) + - `./gradlew test --tests "*CreatorCommunityServiceTest" --tests "*PushNotificationServiceTest"` 실행(성공) + - `./gradlew test` 실행(성공) + - `./gradlew build` 실행(성공) 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 8b2c5214..5a07bf67 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 @@ -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 diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/fcm/FcmEvent.kt b/src/main/kotlin/kr/co/vividnext/sodalive/fcm/FcmEvent.kt index 2cbda86e..c747b300 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/fcm/FcmEvent.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/fcm/FcmEvent.kt @@ -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 ) } } diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/fcm/FcmService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/fcm/FcmService.kt index 2e7e0a3d..f4824489 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/fcm/FcmService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/fcm/FcmService.kt @@ -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, 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 } } } diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/fcm/notification/PushNotificationService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/fcm/notification/PushNotificationService.kt index 4895c5b9..ba4ce54c 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/fcm/notification/PushNotificationService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/fcm/notification/PushNotificationService.kt @@ -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, diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/i18n/SodaMessageSource.kt b/src/main/kotlin/kr/co/vividnext/sodalive/i18n/SodaMessageSource.kt index 114138c0..dcfdb908 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/i18n/SodaMessageSource.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/i18n/SodaMessageSource.kt @@ -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.", 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 new file mode 100644 index 00000000..f3f53dae --- /dev/null +++ b/src/test/kotlin/kr/co/vividnext/sodalive/explorer/profile/creatorCommunity/CreatorCommunityServiceTest.kt @@ -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 + } +} diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/fcm/notification/PushNotificationServiceTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/fcm/notification/PushNotificationServiceTest.kt index bdab6ea6..8848711d 100644 --- a/src/test/kotlin/kr/co/vividnext/sodalive/fcm/notification/PushNotificationServiceTest.kt +++ b/src/test/kotlin/kr/co/vividnext/sodalive/fcm/notification/PushNotificationServiceTest.kt @@ -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으로 설정하고 목록 조회 결과를 준비한다.