feat: 포인트 지급 시 FCM data-only 푸시 메시지 전송 및 실패 시 재시도 처리
This commit is contained in:
parent
51dae0f02c
commit
971683a81e
|
@ -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] ❌ 최종 실패: 알 수 없는 오류")
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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
|
||||||
|
)
|
||||||
|
|
|
@ -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,
|
||||||
|
|
|
@ -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)
|
||||||
|
|
|
@ -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)
|
||||||
|
|
|
@ -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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
Loading…
Reference in New Issue