feat: 포인트 지급 시 FCM data-only 푸시 메시지 전송 및 실패 시 재시도 처리

This commit is contained in:
Klaus 2025-04-22 17:35:47 +09:00
parent 51dae0f02c
commit 971683a81e
8 changed files with 111 additions and 14 deletions

View File

@ -4,6 +4,9 @@ import com.google.firebase.messaging.AndroidConfig
import com.google.firebase.messaging.ApnsConfig import com.google.firebase.messaging.ApnsConfig
import com.google.firebase.messaging.Aps import com.google.firebase.messaging.Aps
import com.google.firebase.messaging.FirebaseMessaging import com.google.firebase.messaging.FirebaseMessaging
import com.google.firebase.messaging.FirebaseMessagingException
import com.google.firebase.messaging.Message
import com.google.firebase.messaging.MessagingErrorCode
import com.google.firebase.messaging.MulticastMessage import com.google.firebase.messaging.MulticastMessage
import com.google.firebase.messaging.Notification import com.google.firebase.messaging.Notification
import org.slf4j.LoggerFactory import org.slf4j.LoggerFactory
@ -82,8 +85,54 @@ class FcmService {
} }
val response = FirebaseMessaging.getInstance().sendEachForMulticast(multicastMessage.build()) val response = FirebaseMessaging.getInstance().sendEachForMulticast(multicastMessage.build())
logger.info("보내기 성공: ${response.successCount}") logger.info("[FCM] ✅ 성공: ${response.successCount}")
logger.info("보내기 실패: ${response.failureCount}") logger.info("[FCM] ❌ 실패: ${response.failureCount}")
}
}
fun sendPointGranted(token: String, point: Int) {
val data = mapOf(
"type" to "POINT_GRANTED",
"point" to point.toString(),
"message" to "${point}포인트가 지급되었습니다!"
)
var attempts = 0
val maxAttempts = 3
while (attempts < maxAttempts) {
try {
val message = Message.builder()
.setToken(token)
.putAllData(data)
.build()
val response = FirebaseMessaging.getInstance().send(message)
logger.info("[FCM] ✅ 성공 (attempt ${attempts + 1}): messageId=$response")
return // 성공 시 즉시 종료
} catch (e: FirebaseMessagingException) {
attempts++
// "registration-token-not-registered" 예외 코드 확인
if (e.messagingErrorCode == MessagingErrorCode.UNREGISTERED) {
logger.error("[FCM] ❌ 실패: 토큰이 등록되지 않음 (등록 해제됨) → 재시도 안함")
return
}
logger.error("[FCM] ❌ 실패 (attempt $attempts): ${e.errorCode} - ${e.message}")
if (attempts >= maxAttempts) {
logger.error("[FCM] ❌ 최종 실패: 전송 불가")
}
} catch (e: Exception) {
// Firebase 이외의 예외도 잡기
attempts++
logger.error("[FCM] ❌ 실패 (attempt $attempts): ${e.message}")
if (attempts >= maxAttempts) {
logger.error("[FCM] ❌ 최종 실패: 알 수 없는 오류")
}
}
} }
} }
} }

View File

@ -65,7 +65,8 @@ class MemberController(
userActionService.recordAction( userActionService.recordAction(
memberId = response.memberId, memberId = response.memberId,
actionType = ActionType.SIGN_UP actionType = ActionType.SIGN_UP,
pushToken = request.pushToken
) )
return ApiResponse.ok(message = "회원가입을 축하드립니다.", data = response.loginResponse) return ApiResponse.ok(message = "회원가입을 축하드립니다.", data = response.loginResponse)
@ -340,7 +341,7 @@ class MemberController(
} }
val token = authHeader.substring(7) val token = authHeader.substring(7)
val response = googleAuthService.authenticate(token, request.container, request.marketingPid) val response = googleAuthService.authenticate(token, request.container, request.marketingPid, request.pushToken)
if (!response.marketingPid.isNullOrBlank()) { if (!response.marketingPid.isNullOrBlank()) {
trackingService.saveTrackingHistory( trackingService.saveTrackingHistory(
@ -350,6 +351,12 @@ class MemberController(
) )
} }
userActionService.recordAction(
memberId = response.memberId,
actionType = ActionType.SIGN_UP,
pushToken = request.pushToken
)
return ApiResponse.ok(message = "회원가입을 축하드립니다.", data = response.loginResponse) return ApiResponse.ok(message = "회원가입을 축하드립니다.", data = response.loginResponse)
} }
@ -363,7 +370,7 @@ class MemberController(
} }
val token = authHeader.substring(7) val token = authHeader.substring(7)
val response = kakaoAuthService.authenticate(token, request.container, request.marketingPid) val response = kakaoAuthService.authenticate(token, request.container, request.marketingPid, request.pushToken)
if (!response.marketingPid.isNullOrBlank()) { if (!response.marketingPid.isNullOrBlank()) {
trackingService.saveTrackingHistory( trackingService.saveTrackingHistory(
@ -373,6 +380,12 @@ class MemberController(
) )
} }
userActionService.recordAction(
memberId = response.memberId,
actionType = ActionType.SIGN_UP,
pushToken = request.pushToken
)
return ApiResponse.ok(message = "회원가입을 축하드립니다.", data = response.loginResponse) return ApiResponse.ok(message = "회원가입을 축하드립니다.", data = response.loginResponse)
} }
} }

View File

@ -125,6 +125,7 @@ class MemberService(
gender = Gender.NONE, gender = Gender.NONE,
container = request.container container = request.container
) )
member.pushToken = request.pushToken
if (!request.marketingPid.isNullOrBlank()) { if (!request.marketingPid.isNullOrBlank()) {
member.activePid = request.marketingPid member.activePid = request.marketingPid
@ -780,7 +781,12 @@ class MemberService(
} }
@Transactional @Transactional
fun findOrRegister(googleUserInfo: GoogleUserInfo, container: String, marketingPid: String?): Member { fun findOrRegister(
googleUserInfo: GoogleUserInfo,
container: String,
marketingPid: String?,
pushToken: String?
): Member {
val findMember = repository.findByGoogleId(googleUserInfo.sub) val findMember = repository.findByGoogleId(googleUserInfo.sub)
if (findMember != null) { if (findMember != null) {
if (findMember.isActive) { if (findMember.isActive) {
@ -810,6 +816,7 @@ class MemberService(
provider = MemberProvider.GOOGLE, provider = MemberProvider.GOOGLE,
container = container container = container
) )
member.pushToken = pushToken
if (!marketingPid.isNullOrBlank()) { if (!marketingPid.isNullOrBlank()) {
member.activePid = marketingPid member.activePid = marketingPid
@ -823,7 +830,12 @@ class MemberService(
} }
@Transactional @Transactional
fun findOrRegister(kakaoUserInfo: KakaoUserInfo, container: String, marketingPid: String?): Member { fun findOrRegister(
kakaoUserInfo: KakaoUserInfo,
container: String,
marketingPid: String?,
pushToken: String?
): Member {
val findMember = repository.findByKakaoId(kakaoUserInfo.id) val findMember = repository.findByKakaoId(kakaoUserInfo.id)
if (findMember != null) { if (findMember != null) {
if (findMember.isActive) { if (findMember.isActive) {
@ -853,6 +865,7 @@ class MemberService(
provider = MemberProvider.KAKAO, provider = MemberProvider.KAKAO,
container = container container = container
) )
member.pushToken = pushToken
if (!marketingPid.isNullOrBlank()) { if (!marketingPid.isNullOrBlank()) {
member.activePid = marketingPid member.activePid = marketingPid

View File

@ -7,4 +7,8 @@ data class LoginRequest(
val isCreator: Boolean = false val isCreator: Boolean = false
) )
data class SocialLoginRequest(val container: String, val marketingPid: String? = null) data class SocialLoginRequest(
val container: String,
val pushToken: String? = null,
val marketingPid: String? = null
)

View File

@ -16,6 +16,7 @@ data class SignUpRequest(
data class SignUpRequestV2( data class SignUpRequestV2(
val email: String, val email: String,
val password: String, val password: String,
val pushToken: String? = null,
val marketingPid: String? = null, val marketingPid: String? = null,
val isAgreeTermsOfService: Boolean, val isAgreeTermsOfService: Boolean,
val isAgreePrivacyPolicy: Boolean, val isAgreePrivacyPolicy: Boolean,

View File

@ -19,10 +19,15 @@ class GoogleAuthService(
@Value("\${cloud.aws.cloud-front.host}") @Value("\${cloud.aws.cloud-front.host}")
private val cloudFrontHost: String private val cloudFrontHost: String
) { ) {
fun authenticate(idToken: String, container: String, marketingPid: String?): SocialLoginResponse { fun authenticate(
idToken: String,
container: String,
marketingPid: String?,
pushToken: String?
): SocialLoginResponse {
val googleUserInfo = googleService.getUserInfo(idToken) val googleUserInfo = googleService.getUserInfo(idToken)
?: throw SodaException("구글 로그인을 하지 못했습니다. 다시 시도해 주세요") ?: throw SodaException("구글 로그인을 하지 못했습니다. 다시 시도해 주세요")
val member = memberService.findOrRegister(googleUserInfo, container, marketingPid) val member = memberService.findOrRegister(googleUserInfo, container, marketingPid, pushToken)
val principal = MemberAdapter(member) val principal = MemberAdapter(member)
val authToken = GoogleAuthenticationToken(idToken, principal.authorities) val authToken = GoogleAuthenticationToken(idToken, principal.authorities)
authToken.setPrincipal(principal) authToken.setPrincipal(principal)

View File

@ -19,10 +19,15 @@ class KakaoAuthService(
@Value("\${cloud.aws.cloud-front.host}") @Value("\${cloud.aws.cloud-front.host}")
private val cloudFrontHost: String private val cloudFrontHost: String
) { ) {
fun authenticate(accessToken: String, container: String, marketingPid: String?): SocialLoginResponse { fun authenticate(
accessToken: String,
container: String,
marketingPid: String?,
pushToken: String?
): SocialLoginResponse {
val kakaoUserInfo = kakaoService.getUserInfo(accessToken) val kakaoUserInfo = kakaoService.getUserInfo(accessToken)
?: throw SodaException("카카오 로그인을 하지 못했습니다. 다시 시도해 주세요") ?: throw SodaException("카카오 로그인을 하지 못했습니다. 다시 시도해 주세요")
val member = memberService.findOrRegister(kakaoUserInfo, container, marketingPid) val member = memberService.findOrRegister(kakaoUserInfo, container, marketingPid, pushToken)
val principal = MemberAdapter(member) val principal = MemberAdapter(member)
val authToken = KakaoAuthenticationToken(accessToken, principal.authorities) val authToken = KakaoAuthenticationToken(accessToken, principal.authorities)
authToken.setPrincipal(principal) authToken.setPrincipal(principal)

View File

@ -3,6 +3,7 @@ package kr.co.vividnext.sodalive.useraction
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kr.co.vividnext.sodalive.fcm.FcmService
import kr.co.vividnext.sodalive.point.MemberPoint import kr.co.vividnext.sodalive.point.MemberPoint
import kr.co.vividnext.sodalive.point.MemberPointRepository import kr.co.vividnext.sodalive.point.MemberPointRepository
import kr.co.vividnext.sodalive.point.PointGrantLog import kr.co.vividnext.sodalive.point.PointGrantLog
@ -16,12 +17,14 @@ class UserActionService(
private val repository: UserActionLogRepository, private val repository: UserActionLogRepository,
private val policyRepository: PointRewardPolicyRepository, private val policyRepository: PointRewardPolicyRepository,
private val grantLogRepository: PointGrantLogRepository, private val grantLogRepository: PointGrantLogRepository,
private val memberPointRepository: MemberPointRepository private val memberPointRepository: MemberPointRepository,
private val fcmService: FcmService
) { ) {
private val coroutineScope = CoroutineScope(Dispatchers.IO) private val coroutineScope = CoroutineScope(Dispatchers.IO)
fun recordAction(memberId: Long, actionType: ActionType) { fun recordAction(memberId: Long, actionType: ActionType, pushToken: String?) {
coroutineScope.launch { coroutineScope.launch {
val now = LocalDateTime.now() val now = LocalDateTime.now()
repository.save(UserActionLog(memberId, actionType)) repository.save(UserActionLog(memberId, actionType))
@ -56,6 +59,10 @@ class UserActionService(
policyId = policy.id!! policyId = policy.id!!
) )
) )
if (pushToken != null) {
fcmService.sendPointGranted(pushToken, policy.pointAmount)
}
} }
} }
} }