feat(fcm): 푸시 알림함 저장 및 카테고리 조회를 지원한다

This commit is contained in:
2026-03-11 19:33:07 +09:00
parent f5c3c62e68
commit f69ace570a
23 changed files with 1309 additions and 12 deletions

View File

@@ -1,5 +1,6 @@
package kr.co.vividnext.sodalive.fcm
import kr.co.vividnext.sodalive.fcm.notification.PushNotificationCategory
import org.springframework.context.ApplicationEventPublisher
import org.springframework.security.access.prepost.PreAuthorize
import org.springframework.transaction.annotation.Transactional
@@ -21,6 +22,7 @@ class FcmController(private val applicationEventPublisher: ApplicationEventPubli
applicationEventPublisher.publishEvent(
FcmEvent(
type = FcmEventType.INDIVIDUAL,
category = PushNotificationCategory.SYSTEM,
title = request.title,
isAuth = request.isAuth,
message = request.message,
@@ -31,6 +33,7 @@ class FcmController(private val applicationEventPublisher: ApplicationEventPubli
applicationEventPublisher.publishEvent(
FcmEvent(
type = FcmEventType.ALL,
category = PushNotificationCategory.SYSTEM,
title = request.title,
message = request.message,
isAuth = request.isAuth

View File

@@ -1,6 +1,8 @@
package kr.co.vividnext.sodalive.fcm
import kr.co.vividnext.sodalive.content.comment.AudioContentCommentRepository
import kr.co.vividnext.sodalive.fcm.notification.PushNotificationCategory
import kr.co.vividnext.sodalive.fcm.notification.PushNotificationService
import kr.co.vividnext.sodalive.i18n.Lang
import kr.co.vividnext.sodalive.i18n.SodaMessageSource
import kr.co.vividnext.sodalive.live.room.GenderRestriction
@@ -27,10 +29,14 @@ enum class FcmDeepLinkValue(val value: String) {
class FcmEvent(
val type: FcmEventType,
val category: PushNotificationCategory? = null,
val title: String = "",
val message: String = "",
val titleKey: String? = null,
val messageKey: String? = null,
val senderMemberId: Long? = null,
val senderNicknameSnapshot: String? = null,
val senderProfileImageSnapshot: String? = null,
val args: List<Any> = listOf(),
val container: String = "",
val recipients: List<Long> = listOf(),
@@ -54,7 +60,8 @@ class FcmSendListener(
private val pushService: FcmService,
private val memberRepository: MemberRepository,
private val contentCommentRepository: AudioContentCommentRepository,
private val messageSource: SodaMessageSource
private val messageSource: SodaMessageSource,
private val pushNotificationService: PushNotificationService
) {
@Async
@TransactionalEventListener
@@ -165,6 +172,13 @@ class FcmSendListener(
val title = translate(fcmEvent.titleKey, fcmEvent.title, lang, fcmEvent.args)
val message = translate(fcmEvent.messageKey, fcmEvent.message, lang, fcmEvent.args)
pushNotificationService.saveNotification(
fcmEvent = fcmEvent,
languageCode = lang.code,
translatedMessage = message,
recipientPushTokens = tokens
)
val tokensByOS = tokens.groupBy { it.deviceType }
for ((os, osTokens) in tokensByOS) {
osTokens.map { it.token }.distinct().chunked(500).forEach { batch ->

View File

@@ -128,17 +128,7 @@ class FcmService(
}
private fun createDeepLink(deepLinkValue: FcmDeepLinkValue?, deepLinkId: Long?): String? {
if (deepLinkValue == null || deepLinkId == null) {
return null
}
val uriScheme = if (serverEnv.equals("voiceon", ignoreCase = true)) {
"voiceon"
} else {
"voiceon-test"
}
return "$uriScheme://${deepLinkValue.value}/$deepLinkId"
return buildDeepLink(serverEnv, deepLinkValue, deepLinkId)
}
fun sendPointGranted(tokens: List<String>, point: Int) {
@@ -206,4 +196,24 @@ class FcmService(
logger.error("[FCM] ❌ 최종 실패 대상 ${targets.size}명 → $targets")
}
}
companion object {
fun buildDeepLink(
serverEnv: String,
deepLinkValue: FcmDeepLinkValue?,
deepLinkId: Long?
): String? {
if (deepLinkValue == null || deepLinkId == null) {
return null
}
val uriScheme = if (serverEnv.equals("voiceon", ignoreCase = true)) {
"voiceon"
} else {
"voiceon-test"
}
return "$uriScheme://${deepLinkValue.value}/$deepLinkId"
}
}
}

View File

@@ -10,6 +10,7 @@ interface PushTokenQueryRepository {
fun findByToken(token: String): PushToken?
fun findByMemberId(memberId: Long): List<PushToken>
fun findByMemberIds(memberIds: List<Long>): List<PushToken>
fun findMemberIdsByTokenIn(tokens: List<String>): List<Long>
}
class PushTokenQueryRepositoryImpl(
@@ -36,4 +37,19 @@ class PushTokenQueryRepositoryImpl(
.where(pushToken.member.id.`in`(memberIds))
.fetch()
}
override fun findMemberIdsByTokenIn(tokens: List<String>): List<Long> {
if (tokens.isEmpty()) return emptyList()
return queryFactory
.select(pushToken.member.id)
.from(pushToken)
.where(
pushToken.token.`in`(tokens)
.and(pushToken.member.id.isNotNull)
)
.fetch()
.filterNotNull()
.distinct()
}
}

View File

@@ -0,0 +1,16 @@
package kr.co.vividnext.sodalive.fcm.notification
enum class PushNotificationCategory(val code: String) {
LIVE("live"),
CONTENT("content"),
COMMUNITY("community"),
MESSAGE("message"),
AUDITION("audition"),
SYSTEM("system");
companion object {
fun fromCode(code: String): PushNotificationCategory? {
return values().find { it.code == code.lowercase() }
}
}
}

View File

@@ -0,0 +1,43 @@
package kr.co.vividnext.sodalive.fcm.notification
import kr.co.vividnext.sodalive.common.ApiResponse
import kr.co.vividnext.sodalive.common.SodaException
import kr.co.vividnext.sodalive.member.Member
import org.springframework.data.domain.Pageable
import org.springframework.security.core.annotation.AuthenticationPrincipal
import org.springframework.web.bind.annotation.GetMapping
import org.springframework.web.bind.annotation.RequestMapping
import org.springframework.web.bind.annotation.RequestParam
import org.springframework.web.bind.annotation.RestController
@RestController
@RequestMapping("/push/notification")
class PushNotificationController(
private val pushNotificationService: PushNotificationService
) {
@GetMapping("/list")
fun getNotificationList(
@AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?,
pageable: Pageable,
@RequestParam("category", required = false) category: String?
) = run {
if (member == null) throw SodaException(messageKey = "common.error.bad_credentials")
ApiResponse.ok(
pushNotificationService.getNotificationList(
member = member,
pageable = pageable,
category = category
)
)
}
@GetMapping("/categories")
fun getAvailableCategories(
@AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?
) = run {
if (member == null) throw SodaException(messageKey = "common.error.bad_credentials")
ApiResponse.ok(pushNotificationService.getAvailableCategories(member))
}
}

View File

@@ -0,0 +1,78 @@
package kr.co.vividnext.sodalive.fcm.notification
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
import com.fasterxml.jackson.module.kotlin.readValue
import kr.co.vividnext.sodalive.common.BaseEntity
import javax.persistence.AttributeConverter
import javax.persistence.CascadeType
import javax.persistence.Column
import javax.persistence.Convert
import javax.persistence.Converter
import javax.persistence.Entity
import javax.persistence.EnumType
import javax.persistence.Enumerated
import javax.persistence.FetchType
import javax.persistence.JoinColumn
import javax.persistence.ManyToOne
import javax.persistence.OneToMany
import javax.persistence.Table
@Entity
@Table(name = "push_notification_list")
class PushNotificationList(
@Column(nullable = false)
var senderNicknameSnapshot: String,
@Column(nullable = true)
var senderProfileImageSnapshot: String? = null,
@Column(columnDefinition = "TEXT", nullable = false)
var message: String,
@Enumerated(EnumType.STRING)
@Column(nullable = false, length = 20)
var category: PushNotificationCategory,
@Column(nullable = true)
var deepLink: String? = null,
@Column(nullable = false, length = 8)
var languageCode: String
) : BaseEntity() {
@OneToMany(mappedBy = "notification", cascade = [CascadeType.ALL], orphanRemoval = true)
val recipientChunks: MutableList<PushNotificationRecipientChunk> = mutableListOf()
fun addRecipientChunk(chunk: PushNotificationRecipientChunk) {
chunk.notification = this
recipientChunks.add(chunk)
}
}
@Entity
@Table(name = "push_notification_recipient_chunk")
class PushNotificationRecipientChunk(
@Column(columnDefinition = "json", nullable = false)
@Convert(converter = PushNotificationRecipientChunkConverter::class)
var recipientMemberIds: List<Long>
) : BaseEntity() {
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "notification_id", nullable = false)
var notification: PushNotificationList? = null
}
@Converter(autoApply = false)
class PushNotificationRecipientChunkConverter : AttributeConverter<List<Long>, String> {
override fun convertToDatabaseColumn(attribute: List<Long>?): String {
if (attribute == null) return "[]"
return objectMapper.writeValueAsString(attribute)
}
override fun convertToEntityAttribute(dbData: String?): List<Long> {
if (dbData.isNullOrBlank()) return emptyList()
return objectMapper.readValue(dbData)
}
companion object {
private val objectMapper = jacksonObjectMapper()
}
}

View File

@@ -0,0 +1,135 @@
package kr.co.vividnext.sodalive.fcm.notification
import com.querydsl.core.types.dsl.BooleanExpression
import com.querydsl.core.types.dsl.Expressions
import com.querydsl.jpa.JPAExpressions
import com.querydsl.jpa.impl.JPAQueryFactory
import kr.co.vividnext.sodalive.fcm.notification.QPushNotificationList.pushNotificationList
import org.springframework.data.domain.Pageable
import org.springframework.data.jpa.repository.JpaRepository
import org.springframework.stereotype.Repository
import java.time.LocalDateTime
@Repository
interface PushNotificationListRepository : JpaRepository<PushNotificationList, Long>, PushNotificationListQueryRepository
interface PushNotificationListQueryRepository {
fun getNotificationList(
memberId: Long,
languageCode: String,
category: PushNotificationCategory?,
fromDateTime: LocalDateTime,
pageable: Pageable
): List<PushNotificationListItem>
fun getNotificationCount(
memberId: Long,
languageCode: String,
category: PushNotificationCategory?,
fromDateTime: LocalDateTime
): Long
fun getAvailableCategories(
memberId: Long,
languageCode: String,
fromDateTime: LocalDateTime
): List<PushNotificationCategory>
}
@Repository
class PushNotificationListQueryRepositoryImpl(
private val queryFactory: JPAQueryFactory
) : PushNotificationListQueryRepository {
override fun getNotificationList(
memberId: Long,
languageCode: String,
category: PushNotificationCategory?,
fromDateTime: LocalDateTime,
pageable: Pageable
): List<PushNotificationListItem> {
return queryFactory
.select(
QPushNotificationListItem(
pushNotificationList.id,
pushNotificationList.senderNicknameSnapshot,
pushNotificationList.senderProfileImageSnapshot,
pushNotificationList.message,
pushNotificationList.category,
pushNotificationList.deepLink,
pushNotificationList.createdAt
)
)
.from(pushNotificationList)
.where(
pushNotificationList.languageCode.eq(languageCode),
pushNotificationList.createdAt.goe(fromDateTime),
recipientContainsMember(memberId),
categoryEq(category)
)
.orderBy(pushNotificationList.createdAt.desc(), pushNotificationList.id.desc())
.offset(pageable.offset)
.limit(pageable.pageSize.toLong())
.fetch()
}
override fun getNotificationCount(
memberId: Long,
languageCode: String,
category: PushNotificationCategory?,
fromDateTime: LocalDateTime
): Long {
return queryFactory
.select(pushNotificationList.id.count())
.from(pushNotificationList)
.where(
pushNotificationList.languageCode.eq(languageCode),
pushNotificationList.createdAt.goe(fromDateTime),
recipientContainsMember(memberId),
categoryEq(category)
)
.fetchOne() ?: 0L
}
override fun getAvailableCategories(
memberId: Long,
languageCode: String,
fromDateTime: LocalDateTime
): List<PushNotificationCategory> {
return queryFactory
.select(pushNotificationList.category)
.distinct()
.from(pushNotificationList)
.where(
pushNotificationList.languageCode.eq(languageCode),
pushNotificationList.createdAt.goe(fromDateTime),
recipientContainsMember(memberId)
)
.fetch()
}
private fun categoryEq(category: PushNotificationCategory?): BooleanExpression? {
return if (category == null) {
null
} else {
pushNotificationList.category.eq(category)
}
}
private fun recipientContainsMember(memberId: Long): BooleanExpression {
val recipientChunk = QPushNotificationRecipientChunk("recipientChunk")
return JPAExpressions
.selectOne()
.from(recipientChunk)
.where(
recipientChunk.notification.id.eq(pushNotificationList.id)
.and(
Expressions.booleanTemplate(
"JSON_CONTAINS({0}, JSON_ARRAY({1}), '$')",
recipientChunk.recipientMemberIds,
memberId
)
)
)
.exists()
}
}

View File

@@ -0,0 +1,46 @@
package kr.co.vividnext.sodalive.fcm.notification
import com.querydsl.core.annotations.QueryProjection
import java.time.LocalDateTime
import java.time.ZoneId
data class GetPushNotificationListResponse(
val totalCount: Long,
val items: List<PushNotificationListItem>
)
data class PushNotificationListItem(
val id: Long,
val senderNickname: String,
val senderProfileImage: String?,
val message: String,
val category: String,
val deepLink: String?,
val sentAt: String
) {
@QueryProjection
constructor(
id: Long,
senderNickname: String,
senderProfileImage: String?,
message: String,
category: PushNotificationCategory,
deepLink: String?,
sentAt: LocalDateTime
) : this(
id = id,
senderNickname = senderNickname,
senderProfileImage = senderProfileImage,
message = message,
category = category.code,
deepLink = deepLink,
sentAt = sentAt
.atZone(ZoneId.of("UTC"))
.toInstant()
.toString()
)
}
data class GetPushNotificationCategoryResponse(
val categories: List<String>
)

View File

@@ -0,0 +1,218 @@
package kr.co.vividnext.sodalive.fcm.notification
import kr.co.vividnext.sodalive.common.SodaException
import kr.co.vividnext.sodalive.fcm.FcmEvent
import kr.co.vividnext.sodalive.fcm.FcmEventType
import kr.co.vividnext.sodalive.fcm.FcmService
import kr.co.vividnext.sodalive.fcm.PushTokenInfo
import kr.co.vividnext.sodalive.fcm.PushTokenRepository
import kr.co.vividnext.sodalive.i18n.Lang
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.MemberRepository
import org.springframework.beans.factory.annotation.Value
import org.springframework.data.domain.Pageable
import org.springframework.data.repository.findByIdOrNull
import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional
import java.time.LocalDateTime
import java.time.ZoneOffset
@Service
@Transactional(readOnly = true)
class PushNotificationService(
private val pushNotificationListRepository: PushNotificationListRepository,
private val pushTokenRepository: PushTokenRepository,
private val memberRepository: MemberRepository,
private val messageSource: SodaMessageSource,
private val langContext: LangContext,
@Value("\${cloud.aws.cloud-front.host}")
private val cloudFrontHost: String,
@Value("\${server.env}")
private val serverEnv: String
) {
@Transactional
fun saveNotification(
fcmEvent: FcmEvent,
languageCode: String,
translatedMessage: String,
recipientPushTokens: List<PushTokenInfo>
) {
if (recipientPushTokens.isEmpty()) return
val recipientMemberIds = pushTokenRepository
.findMemberIdsByTokenIn(recipientPushTokens.map { it.token })
.distinct()
// 최종 수신자 ID가 없으면 알림 리스트를 저장하지 않는다.
if (recipientMemberIds.isEmpty()) return
val category = resolveCategory(fcmEvent) ?: return
val senderSnapshot = resolveSenderSnapshot(fcmEvent)
val deepLink = FcmService.buildDeepLink(serverEnv, fcmEvent.deepLinkValue, fcmEvent.deepLinkId)
val notification = PushNotificationList(
senderNicknameSnapshot = senderSnapshot.nickname,
senderProfileImageSnapshot = senderSnapshot.profileImage,
message = translatedMessage,
category = category,
deepLink = deepLink,
languageCode = languageCode
)
recipientMemberIds
.chunked(RECIPIENT_CHUNK_SIZE)
.forEach { chunk ->
// 수신자는 JSON 배열 청크 단위로 분할 저장한다.
notification.addRecipientChunk(
PushNotificationRecipientChunk(
recipientMemberIds = chunk
)
)
}
pushNotificationListRepository.save(notification)
}
fun getNotificationList(
member: Member,
pageable: Pageable,
category: String?
): GetPushNotificationListResponse {
val parsedCategory = parseCategory(category)
val languageCode = langContext.lang.code
val fromDateTime = oneMonthAgoUtc()
val totalCount = pushNotificationListRepository.getNotificationCount(
memberId = member.id!!,
languageCode = languageCode,
category = parsedCategory,
fromDateTime = fromDateTime
)
val items = pushNotificationListRepository.getNotificationList(
memberId = member.id!!,
languageCode = languageCode,
category = parsedCategory,
fromDateTime = fromDateTime,
pageable = pageable
)
return GetPushNotificationListResponse(
totalCount = totalCount,
items = items
)
}
fun getAvailableCategories(member: Member): GetPushNotificationCategoryResponse {
val lang = langContext.lang
val localizedCategories = pushNotificationListRepository.getAvailableCategories(
memberId = member.id!!,
languageCode = lang.code,
fromDateTime = oneMonthAgoUtc()
).map { category ->
messageSource.getMessage("push.notification.category.${category.code}", lang) ?: category.code
}
val allCategoryLabel = messageSource.getMessage("push.notification.category.all", lang) ?: "전체"
val categories = listOf(allCategoryLabel) + localizedCategories
return GetPushNotificationCategoryResponse(categories = categories)
}
private fun parseCategory(category: String?): PushNotificationCategory? {
if (category.isNullOrBlank()) return null
val normalizedCategory = category.trim()
if (isAllCategory(normalizedCategory)) {
return null
}
PushNotificationCategory.fromCode(normalizedCategory)?.let { return it }
val parsedCategory = PushNotificationCategory.values().firstOrNull { pushCategory ->
supportedCategoryInputLangs.any { lang ->
val localizedLabel = messageSource.getMessage("push.notification.category.${pushCategory.code}", lang)
localizedLabel.equals(normalizedCategory, ignoreCase = true)
}
}
return parsedCategory ?: throw SodaException(messageKey = "common.error.invalid_request")
}
private fun isAllCategory(category: String): Boolean {
if (category.equals("all", ignoreCase = true)) return true
return supportedCategoryInputLangs.any { lang ->
val localizedLabel = messageSource.getMessage("push.notification.category.all", lang)
localizedLabel.equals(category, ignoreCase = true)
}
}
private fun oneMonthAgoUtc(): LocalDateTime {
return LocalDateTime.now(ZoneOffset.UTC).minusMonths(1)
}
private fun resolveCategory(fcmEvent: FcmEvent): PushNotificationCategory? {
// 이벤트에서 명시한 카테고리가 있으면 우선 사용하고, 없으면 타입 기반 기본값으로 보정한다.
return fcmEvent.category ?: when (fcmEvent.type) {
FcmEventType.CREATE_LIVE,
FcmEventType.START_LIVE,
FcmEventType.CANCEL_LIVE -> PushNotificationCategory.LIVE
FcmEventType.UPLOAD_CONTENT,
FcmEventType.CREATE_CONTENT_COMMENT -> PushNotificationCategory.CONTENT
FcmEventType.CHANGE_NOTICE -> PushNotificationCategory.COMMUNITY
FcmEventType.SEND_MESSAGE -> PushNotificationCategory.MESSAGE
FcmEventType.IN_PROGRESS_AUDITION -> PushNotificationCategory.AUDITION
FcmEventType.ALL,
FcmEventType.INDIVIDUAL -> PushNotificationCategory.SYSTEM
}
}
private fun resolveSenderSnapshot(fcmEvent: FcmEvent): SenderSnapshot {
if (!fcmEvent.senderNicknameSnapshot.isNullOrBlank()) {
return SenderSnapshot(
nickname = fcmEvent.senderNicknameSnapshot,
profileImage = fcmEvent.senderProfileImageSnapshot ?: defaultProfileImageUrl()
)
}
val sender = fcmEvent.senderMemberId?.let { memberRepository.findByIdOrNull(it) }
if (sender != null) {
return SenderSnapshot(
nickname = sender.nickname,
profileImage = toProfileImageUrl(sender.profileImage)
)
}
val fallbackNickname = fcmEvent.title.ifBlank { "" }
return SenderSnapshot(
nickname = fallbackNickname,
profileImage = defaultProfileImageUrl()
)
}
private fun toProfileImageUrl(profileImage: String?): String {
if (profileImage.isNullOrBlank()) return defaultProfileImageUrl()
return "$cloudFrontHost/$profileImage"
}
private fun defaultProfileImageUrl(): String {
return "$cloudFrontHost/profile/default-profile.png"
}
data class SenderSnapshot(
val nickname: String,
val profileImage: String
)
companion object {
private const val RECIPIENT_CHUNK_SIZE = 500
private val supportedCategoryInputLangs = listOf(Lang.KO, Lang.EN, Lang.JA)
}
}