package kr.co.vividnext.sodalive.fcm import com.google.firebase.messaging.AndroidConfig import com.google.firebase.messaging.ApnsConfig import com.google.firebase.messaging.Aps import com.google.firebase.messaging.FirebaseMessaging import com.google.firebase.messaging.MessagingErrorCode import com.google.firebase.messaging.MulticastMessage import com.google.firebase.messaging.Notification import org.slf4j.LoggerFactory import org.springframework.beans.factory.annotation.Value import org.springframework.scheduling.annotation.Async import org.springframework.stereotype.Service @Service class FcmService( private val pushTokenService: PushTokenService, @Value("\${server.env}") private val serverEnv: String ) { private val logger = LoggerFactory.getLogger(this::class.java) @Async fun send( tokens: List, title: String, message: String, container: String, roomId: Long? = null, messageId: Long? = null, contentId: Long? = null, creatorId: Long? = null, auditionId: Long? = null, deepLinkValue: FcmDeepLinkValue? = null, deepLinkId: Long? = null, deepLinkCommentPostId: Long? = null ) { if (tokens.isEmpty()) return logger.info("os: $container") var targets = tokens val maxAttempts = 3 var attempt = 1 while (attempt <= maxAttempts && targets.isNotEmpty()) { val multicastMessage = MulticastMessage.builder() .addAllTokens(targets) multicastMessage.setAndroidConfig( AndroidConfig.builder() .setPriority(AndroidConfig.Priority.HIGH) .build() ) multicastMessage.setApnsConfig( ApnsConfig.builder() .setAps( Aps.builder() .setSound("default") .build() ) .build() ) multicastMessage .setNotification( Notification.builder() .setTitle(title) .setBody(message) .build() ) if (roomId != null) { multicastMessage.putData("room_id", roomId.toString()) } if (messageId != null) { multicastMessage.putData("message_id", messageId.toString()) } if (contentId != null) { multicastMessage.putData("content_id", contentId.toString()) } if (creatorId != null) { multicastMessage.putData("channel_id", creatorId.toString()) } if (auditionId != null) { multicastMessage.putData("audition_id", auditionId.toString()) } val deepLink = createDeepLink(deepLinkValue, deepLinkId, deepLinkCommentPostId) if (deepLink != null) { multicastMessage.putData("deep_link", deepLink) } val response = FirebaseMessaging.getInstance().sendEachForMulticast(multicastMessage.build()) val failedTokens = mutableListOf() response.responses.forEachIndexed { index, res -> if (!res.isSuccessful) { val exception = res.exception val token = targets[index] if (exception?.messagingErrorCode == MessagingErrorCode.UNREGISTERED) { logger.error("[FCM] ❌ UNREGISTERED → $token") // DB에서 삭제 pushTokenService.unregisterInvalidToken(token) } else { logger.error("[FCM] ❌ 실패: $token / ${exception?.messagingErrorCode}") failedTokens.add(token) } } } if (failedTokens.isEmpty()) { logger.info("[FCM] ✅ 전체 전송 성공") return } targets = failedTokens attempt++ } if (targets.isNotEmpty()) { logger.error("[FCM] ❌ 최종 실패 대상 ${targets.size}명 → $targets") } } private fun createDeepLink( deepLinkValue: FcmDeepLinkValue?, deepLinkId: Long?, deepLinkCommentPostId: Long? ): String? { return buildDeepLink(serverEnv, deepLinkValue, deepLinkId, deepLinkCommentPostId) } fun sendPointGranted(tokens: List, point: Int) { if (tokens.isEmpty()) return val data = mapOf( "type" to "POINT_GRANTED", "point" to point.toString(), "message" to "${point}포인트가 지급되었습니다!" ) var targets = tokens var attempts = 0 val maxAttempts = 3 while (attempts <= maxAttempts && targets.isNotEmpty()) { val multicastMessage = MulticastMessage.builder() .addAllTokens(targets) .putAllData(data) multicastMessage.setAndroidConfig( AndroidConfig.builder() .setPriority(AndroidConfig.Priority.HIGH) .build() ) multicastMessage.setApnsConfig( ApnsConfig.builder() .setAps( Aps.builder() .setSound("default") .build() ) .build() ) val response = FirebaseMessaging.getInstance().sendEachForMulticast(multicastMessage.build()) val failedTokens = mutableListOf() response.responses.forEachIndexed { index, res -> if (!res.isSuccessful) { val exception = res.exception val token = targets[index] if (exception?.messagingErrorCode == MessagingErrorCode.UNREGISTERED) { logger.error("[FCM] ❌ UNREGISTERED → $token") // DB에서 삭제 pushTokenService.unregisterInvalidToken(token) } else { logger.error("[FCM] ❌ 실패: $token / ${exception?.messagingErrorCode}") failedTokens.add(token) } } } if (failedTokens.isEmpty()) { logger.info("[FCM] ✅ 전체 전송 성공") return } targets = failedTokens attempts++ } if (targets.isNotEmpty()) { logger.error("[FCM] ❌ 최종 실패 대상 ${targets.size}명 → $targets") } } companion object { fun buildDeepLink( serverEnv: String, deepLinkValue: FcmDeepLinkValue?, deepLinkId: Long?, deepLinkCommentPostId: Long? = null ): String? { if (deepLinkValue == null || deepLinkId == null) { return null } val uriScheme = if (serverEnv.equals("voiceon", ignoreCase = true)) { "voiceon" } else { "voiceon-test" } val baseDeepLink = "$uriScheme://${deepLinkValue.value}/$deepLinkId" if (deepLinkValue == FcmDeepLinkValue.COMMUNITY && deepLinkCommentPostId != null) { return "$baseDeepLink?postId=$deepLinkCommentPostId" } return baseDeepLink } } }