feat(creator-community): 커뮤니티 댓글 알림 딥링크에 게시글 식별자를 포함한다
This commit is contained in:
36
docs/20260313_크리에이터커뮤니티댓글알림딥링크적용.md
Normal file
36
docs/20260313_크리에이터커뮤니티댓글알림딥링크적용.md
Normal file
@@ -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` 실행(성공)
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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으로 설정하고 목록 조회 결과를 준비한다.
|
||||
|
||||
Reference in New Issue
Block a user