feat(fcm): 수신 언어별 크리에이터 이름을 알림에 반영한다

This commit is contained in:
2026-10-16 14:03:33 +09:00
parent 698cdc1fe3
commit 995b41e89d
4 changed files with 320 additions and 15 deletions
@@ -7,6 +7,7 @@ import kr.co.vividnext.sodalive.i18n.Lang
import kr.co.vividnext.sodalive.i18n.SodaMessageSource
import kr.co.vividnext.sodalive.live.room.GenderRestriction
import kr.co.vividnext.sodalive.member.MemberRepository
import kr.co.vividnext.sodalive.v2.creatorname.application.CreatorLocalizedNameService
import org.springframework.scheduling.annotation.Async
import org.springframework.stereotype.Component
import org.springframework.transaction.annotation.Propagation
@@ -56,7 +57,9 @@ class FcmEvent(
val commentParentId: Long? = null,
val myMemberId: Long? = null,
val isAvailableJoinCreator: Boolean? = null,
val genderRestriction: GenderRestriction? = null
val genderRestriction: GenderRestriction? = null,
val titleMemberId: Long? = null,
val nameArgumentMemberIds: Map<Int, Long> = emptyMap()
)
@Component
@@ -65,7 +68,8 @@ class FcmSendListener(
private val memberRepository: MemberRepository,
private val contentCommentRepository: AudioContentCommentRepository,
private val messageSource: SodaMessageSource,
private val pushNotificationService: PushNotificationService
private val pushNotificationService: PushNotificationService,
private val localizedNameService: CreatorLocalizedNameService
) {
@Async
@TransactionalEventListener
@@ -169,18 +173,26 @@ class FcmSendListener(
creatorId: Long? = null,
auditionId: Long? = null
) {
val tokensByLang = pushTokens.groupBy { it.languageCode }
val tokensByLang = pushTokens.groupBy { Lang.fromAcceptLanguage(it.languageCode) }
val nameMemberIds = listOfNotNull(fcmEvent.senderMemberId, fcmEvent.titleMemberId)
.plus(fcmEvent.nameArgumentMemberIds.values)
.distinct()
for ((langCode, tokens) in tokensByLang) {
val lang = Lang.fromAcceptLanguage(langCode)
val title = translate(fcmEvent.titleKey, fcmEvent.title, lang, fcmEvent.args)
val message = translate(fcmEvent.messageKey, fcmEvent.message, lang, fcmEvent.args)
for ((lang, tokens) in tokensByLang) {
val names = localizedNameService.findLocalizedNames(nameMemberIds, lang)
val args = fcmEvent.args.mapIndexed { index, argument ->
fcmEvent.nameArgumentMemberIds[index]?.let { names[it] } ?: argument
}
val defaultTitle = fcmEvent.titleMemberId?.let { names[it] } ?: fcmEvent.title
val title = translate(fcmEvent.titleKey, defaultTitle, lang, args)
val message = translate(fcmEvent.messageKey, fcmEvent.message, lang, args)
pushNotificationService.saveNotification(
fcmEvent = fcmEvent,
languageCode = lang.code,
translatedMessage = message,
recipientPushTokens = tokens
recipientPushTokens = tokens,
localizedSenderNickname = fcmEvent.senderMemberId?.let { names[it] }
)
val tokensByOS = tokens.groupBy { it.deviceType }
@@ -36,7 +36,8 @@ class PushNotificationService(
fcmEvent: FcmEvent,
languageCode: String,
translatedMessage: String,
recipientPushTokens: List<PushTokenInfo>
recipientPushTokens: List<PushTokenInfo>,
localizedSenderNickname: String?
) {
if (recipientPushTokens.isEmpty()) return
@@ -58,7 +59,7 @@ class PushNotificationService(
)
val notification = PushNotificationList(
senderNicknameSnapshot = senderSnapshot.nickname,
senderNicknameSnapshot = localizedSenderNickname ?: senderSnapshot.nickname,
senderProfileImageSnapshot = senderSnapshot.profileImage,
message = translatedMessage,
category = category,
@@ -0,0 +1,287 @@
package kr.co.vividnext.sodalive.fcm
import kr.co.vividnext.sodalive.aws.s3.S3Uploader
import kr.co.vividnext.sodalive.content.AudioContent
import kr.co.vividnext.sodalive.content.comment.AudioContentComment
import kr.co.vividnext.sodalive.content.comment.AudioContentCommentService
import kr.co.vividnext.sodalive.content.theme.AudioContentTheme
import kr.co.vividnext.sodalive.fcm.notification.PushNotificationCategory
import kr.co.vividnext.sodalive.fcm.notification.PushNotificationList
import kr.co.vividnext.sodalive.fcm.notification.PushNotificationListRepository
import kr.co.vividnext.sodalive.fcm.notification.PushNotificationRecipientChunk
import kr.co.vividnext.sodalive.i18n.Lang
import kr.co.vividnext.sodalive.i18n.LangContext
import kr.co.vividnext.sodalive.live.reservation.LiveReservation
import kr.co.vividnext.sodalive.live.reservation.LiveReservationRepository
import kr.co.vividnext.sodalive.live.room.LiveRoom
import kr.co.vividnext.sodalive.live.room.LiveRoomRepository
import kr.co.vividnext.sodalive.live.room.LiveRoomService
import kr.co.vividnext.sodalive.live.room.cancel.CancelLiveRequest
import kr.co.vividnext.sodalive.member.Member
import kr.co.vividnext.sodalive.member.MemberRepository
import kr.co.vividnext.sodalive.member.MemberRole
import kr.co.vividnext.sodalive.support.EmbeddedRedisInitializer
import kr.co.vividnext.sodalive.v2.gift.application.GiftPushService
import kr.co.vividnext.sodalive.v2.usercreatorchat.service.UserCreatorChatService
import org.junit.jupiter.api.AfterEach
import org.junit.jupiter.api.Assertions.assertAll
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.junit.jupiter.params.ParameterizedTest
import org.junit.jupiter.params.provider.EnumSource
import org.mockito.Mockito
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.boot.test.context.SpringBootTest
import org.springframework.boot.test.mock.mockito.MockBean
import org.springframework.jdbc.core.JdbcTemplate
import org.springframework.mock.web.MockHttpServletRequest
import org.springframework.test.context.ContextConfiguration
import org.springframework.test.context.event.ApplicationEvents
import org.springframework.test.context.event.RecordApplicationEvents
import org.springframework.test.util.AopTestUtils
import org.springframework.transaction.annotation.Transactional
import org.springframework.web.context.request.RequestContextHolder
import org.springframework.web.context.request.ServletRequestAttributes
import java.time.LocalDateTime
import javax.persistence.EntityManager
@SpringBootTest(
properties = [
"spring.cache.type=none",
"jwt.secret=MDEyMzQ1Njc4OWFiY2RlZjAxMjM0NTY3ODlhYmNkZWYwMTIzNDU2Nzg5YWJjZGVmMDEyMzQ1Njc4OWFiY2RlZg=="
]
)
@ContextConfiguration(initializers = [EmbeddedRedisInitializer::class])
@RecordApplicationEvents
@Transactional
class FcmSendListenerLocalizedNameTest @Autowired constructor(
private val listener: FcmSendListener,
private val chatService: UserCreatorChatService,
private val liveRoomService: LiveRoomService,
private val commentService: AudioContentCommentService,
private val giftPushService: GiftPushService,
private val memberRepository: MemberRepository,
private val pushTokenRepository: PushTokenRepository,
private val notificationRepository: PushNotificationListRepository,
private val roomRepository: LiveRoomRepository,
private val reservationRepository: LiveReservationRepository,
private val jdbcTemplate: JdbcTemplate,
private val langContext: LangContext,
private val entityManager: EntityManager
) {
@Autowired
private lateinit var applicationEvents: ApplicationEvents
@MockBean
private lateinit var fcmService: FcmService
@MockBean
private lateinit var s3Uploader: S3Uploader
@BeforeEach
fun setUpRequest() {
RequestContextHolder.setRequestAttributes(ServletRequestAttributes(MockHttpServletRequest()))
langContext.setLang(Lang.EN)
}
@AfterEach
fun resetRequest() {
RequestContextHolder.resetRequestAttributes()
}
@Test
@DisplayName("실제 DM 이벤트의 이름 인자와 255자 발신자 스냅샷을 토큰 언어별로 독립 렌더링한다")
fun shouldRenderDmNameArgumentAndSenderSnapshotForEachTokenLanguage() {
val creator = savedMember("dm-creator", MemberRole.CREATOR)
val recipient = savedMember("dm-recipient")
val japaneseName = "名".repeat(255)
saveAlias(creator, japaneseName)
saveTokens(recipient)
val roomId = chatService.createOrGetRoom(creator, recipient.id!!).roomId
chatService.sendTextMessageByWebSocket(creator.id!!, roomId, "dm-creator 본문은 그대로", Lang.EN)
val event = publishedEvent()
AopTestUtils.getUltimateTargetObject<FcmSendListener>(listener).send(event)
entityManager.flush()
entityManager.clear()
val pushes = outgoingPushes()
val snapshots = notificationRepository.findAll().associateBy { it.languageCode }
assertAll(
{ assertEquals(2, pushes.size) },
{ assertEquals("DM", pushes.getValue("token-ja").first) },
{ assertEquals("${japaneseName}からDMが届きました。", pushes.getValue("token-ja").second) },
{ assertEquals("DM", pushes.getValue("token-ko").first) },
{ assertEquals("dm-creator님으로 부터 DM이 도착했습니다.", pushes.getValue("token-ko").second) },
{ assertEquals(japaneseName, snapshots.getValue("ja").senderNicknameSnapshot) },
{ assertEquals("dm-creator", snapshots.getValue("ko").senderNicknameSnapshot) },
{ assertEquals(pushes.getValue("token-ja").second, snapshots.getValue("ja").message) },
{ assertEquals(pushes.getValue("token-ko").second, snapshots.getValue("ko").message) },
{ assertEquals(listOf("dm-creator"), event.args) },
{ assertEquals("dm-creator", memberRepository.findById(creator.id!!).orElseThrow().nickname) }
)
}
@Test
@DisplayName("실제 라이브 취소 이벤트의 제목만 현지화하고 같은 문자열인 방 제목 인자는 보존한다")
fun shouldLocalizeLiveSenderTitleWithoutReplacingRoomTitleArgument() {
val creator = savedMember("creator.*$", MemberRole.CREATOR)
val recipient = savedMember("live-recipient")
saveAlias(creator, "日本の配信者")
saveTokens(recipient)
val room = roomRepository.saveAndFlush(
LiveRoom(
title = "creator.*$",
notice = "notice",
beginDateTime = LocalDateTime.now().plusDays(1),
numberOfPeople = 10,
isAdult = false
).apply { member = creator }
)
reservationRepository.saveAndFlush(
LiveReservation().apply {
member = recipient
this.room = room
}
)
liveRoomService.cancelLive(CancelLiveRequest(room.id!!, "취소 사유"), creator)
val event = publishedEvent()
AopTestUtils.getUltimateTargetObject<FcmSendListener>(listener).send(event)
val pushes = outgoingPushes()
val snapshots = notificationRepository.findAll().associateBy { it.languageCode }
assertAll(
{ assertEquals("日本の配信者", pushes.getValue("token-ja").first) },
{ assertEquals("ライブ取消: creator.*$", pushes.getValue("token-ja").second) },
{ assertEquals("creator.*$", pushes.getValue("token-ko").first) },
{ assertEquals("라이브 취소 : creator.*$", pushes.getValue("token-ko").second) },
{ assertEquals("日本の配信者", snapshots.getValue("ja").senderNicknameSnapshot) },
{ assertEquals("creator.*$", snapshots.getValue("ko").senderNicknameSnapshot) },
{ assertEquals("creator.*$", event.title) },
{ assertEquals(listOf("creator.*$"), event.args) }
)
}
@Test
@DisplayName("발신자 ID가 없는 과거 알림은 현재 이름 변경 뒤 신규 알림을 발송해도 이름과 본문이 불변이다")
fun shouldPreserveHistoricalNotificationWithoutSenderId() {
val creator = savedMember("historical-creator", MemberRole.CREATOR)
val recipient = savedMember("historical-recipient")
saveTokens(recipient)
val historical = notificationRepository.saveAndFlush(
PushNotificationList(
senderNicknameSnapshot = "過去の名前",
message = "過去の配信通知",
category = PushNotificationCategory.LIVE,
languageCode = "ja"
).apply { addRecipientChunk(PushNotificationRecipientChunk(listOf(recipient.id!!))) }
)
saveAlias(creator, "現在の名前")
creator.nickname = "changed-original"
entityManager.flush()
entityManager.clear()
val roomId = chatService.createOrGetRoom(creator, recipient.id!!).roomId
chatService.sendTextMessageByWebSocket(creator.id!!, roomId, "새 알림 본문", Lang.EN)
AopTestUtils.getUltimateTargetObject<FcmSendListener>(listener).send(publishedEvent())
entityManager.flush()
entityManager.clear()
val stored = notificationRepository.findById(historical.id!!).orElseThrow()
assertEquals(3L, notificationRepository.count())
assertEquals("過去の名前", stored.senderNicknameSnapshot)
assertEquals("過去の配信通知", stored.message)
}
@ParameterizedTest
@EnumSource(value = MemberRole::class, names = ["CREATOR", "USER"])
@DisplayName("실제 오디오 답글은 부모 이름 제목과 행위자 snapshot을 분리하고 콘텐츠 제목 인자를 보존한다")
fun shouldKeepReplyTitleOwnerSeparateFromActualSender(senderRole: MemberRole) {
val parentAuthor = savedMember("parent-author", MemberRole.CREATOR)
val sender = savedMember("reply-sender", senderRole)
saveAlias(parentAuthor, "親コメントの作者")
saveAlias(sender, "返信した作者")
saveTokens(parentAuthor)
val theme = AudioContentTheme("theme", "theme.png")
entityManager.persist(theme)
val content = AudioContent(title = "parent-author", detail = "detail", languageCode = "en").apply {
member = parentAuthor
this.theme = theme
}
entityManager.persist(content)
val parent = AudioContentComment("parent", "en").apply {
member = parentAuthor
audioContent = content
}
entityManager.persist(parent)
entityManager.flush()
commentService.registerComment(sender, "reply", content.id!!, parent.id, languageCode = "en")
val event = publishedEvent()
AopTestUtils.getUltimateTargetObject<FcmSendListener>(listener).send(event)
val pushes = outgoingPushes()
val snapshots = notificationRepository.findAll().associateBy { it.languageCode }
assertEquals(parentAuthor.id, event.titleMemberId)
assertEquals(sender.id, event.senderMemberId)
assertEquals("親コメントの作者", pushes.getValue("token-ja").first)
assertEquals("コメントに返信しました: parent-author", pushes.getValue("token-ja").second)
assertEquals("parent-author", pushes.getValue("token-ko").first)
assertEquals("reply-sender", snapshots.getValue("ko").senderNicknameSnapshot)
assertEquals(
if (senderRole == MemberRole.CREATOR) "返信した作者" else "reply-sender",
snapshots.getValue("ja").senderNicknameSnapshot
)
assertEquals(listOf("parent-author"), event.args)
}
@Test
@DisplayName("실제 기프트 알림의 자유 제목과 본문은 수신자 별칭이나 토큰 언어로 이름 치환하지 않는다")
fun shouldPreserveGiftTextAndDefaultSenderSnapshotWithoutNameMetadata() {
val recipient = savedMember("운송장 번호를 입력해 주세요", MemberRole.CREATOR)
saveAlias(recipient, "名前ではない通知")
saveTokens(recipient)
giftPushService.sendTrackingDeadlineReminder("gift-001", recipient.id!!)
val event = publishedEvent()
AopTestUtils.getUltimateTargetObject<FcmSendListener>(listener).send(event)
val pushes = outgoingPushes()
assertEquals(null, event.senderMemberId)
assertEquals(null, event.titleMemberId)
assertEquals(emptyMap<Int, Long>(), event.nameArgumentMemberIds)
assertEquals("운송장 번호를 입력해 주세요", pushes.getValue("token-ja").first)
assertEquals(pushes.getValue("token-ko"), pushes.getValue("token-ja"))
notificationRepository.findAll().forEach { assertEquals(event.title, it.senderNicknameSnapshot) }
}
private fun publishedEvent(): FcmEvent = applicationEvents.stream(FcmEvent::class.java).toList().single()
private fun savedMember(nickname: String, role: MemberRole = MemberRole.USER): Member = memberRepository.saveAndFlush(
Member(email = "$nickname@localized-fcm.test", password = "pw", nickname = nickname, role = role)
)
private fun saveAlias(member: Member, displayName: String) {
jdbcTemplate.update(
"INSERT INTO creator_localized_name (member_id, lang, display_name, is_active, created_at, updated_at) " +
"VALUES (?, 'JA', ?, true, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)",
member.id!!,
displayName
)
}
private fun saveTokens(member: Member) {
pushTokenRepository.saveAndFlush(PushToken("token-ja", "aos", "ja-JP").apply { this.member = member })
pushTokenRepository.saveAndFlush(PushToken("token-ko", "ios", "ko-KR").apply { this.member = member })
}
private fun outgoingPushes(): Map<String, Pair<String, String>> = Mockito.mockingDetails(fcmService).invocations
.filter { it.method.name == "send" }
.associate { invocation ->
val tokens = invocation.getArgument<List<String>>(0)
tokens.single() to Pair(invocation.getArgument<String>(1), invocation.getArgument<String>(2))
}
}
@@ -71,7 +71,8 @@ class PushNotificationServiceTest {
fcmEvent = event,
languageCode = "ko",
translatedMessage = "테스트 메시지",
recipientPushTokens = pushTokens
recipientPushTokens = pushTokens,
localizedSenderNickname = null
)
// then: 수신자 없음 규칙에 따라 저장이 발생하지 않아야 한다.
@@ -95,7 +96,8 @@ class PushNotificationServiceTest {
fcmEvent = event,
languageCode = "ko",
translatedMessage = "시스템 알림",
recipientPushTokens = pushTokens
recipientPushTokens = pushTokens,
localizedSenderNickname = null
)
// then: SYSTEM 카테고리 보정 결과에 따라 저장이 발생하지 않아야 한다.
@@ -124,7 +126,8 @@ class PushNotificationServiceTest {
fcmEvent = event,
languageCode = "ko",
translatedMessage = "라이브가 시작되었습니다.",
recipientPushTokens = pushTokens
recipientPushTokens = pushTokens,
localizedSenderNickname = null
)
// then: 보정된 LIVE 카테고리로 저장되어야 한다.
@@ -163,7 +166,8 @@ class PushNotificationServiceTest {
fcmEvent = event,
languageCode = "ko",
translatedMessage = "라이브가 시작되었습니다.",
recipientPushTokens = pushTokens
recipientPushTokens = pushTokens,
localizedSenderNickname = null
)
// then: 발송자 스냅샷/딥링크/카테고리/언어와 수신자 청크가 정확히 저장되어야 한다.
@@ -206,7 +210,8 @@ class PushNotificationServiceTest {
fcmEvent = event,
languageCode = "ko",
translatedMessage = "새 댓글이 등록되었습니다.",
recipientPushTokens = pushTokens
recipientPushTokens = pushTokens,
localizedSenderNickname = null
)
val captor = ArgumentCaptor.forClass(PushNotificationList::class.java)