diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/content/comment/AudioContentCommentController.kt b/src/main/kotlin/kr/co/vividnext/sodalive/content/comment/AudioContentCommentController.kt index a68faa2..c7b5834 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/content/comment/AudioContentCommentController.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/content/comment/AudioContentCommentController.kt @@ -3,6 +3,9 @@ package kr.co.vividnext.sodalive.content.comment import kr.co.vividnext.sodalive.common.ApiResponse import kr.co.vividnext.sodalive.common.SodaException import kr.co.vividnext.sodalive.member.Member +import kr.co.vividnext.sodalive.member.MemberService +import kr.co.vividnext.sodalive.useraction.ActionType +import kr.co.vividnext.sodalive.useraction.UserActionService import org.springframework.data.domain.Pageable import org.springframework.security.core.annotation.AuthenticationPrincipal import org.springframework.web.bind.annotation.GetMapping @@ -14,7 +17,11 @@ import org.springframework.web.bind.annotation.RequestParam import org.springframework.web.bind.annotation.RestController @RestController -class AudioContentCommentController(private val service: AudioContentCommentService) { +class AudioContentCommentController( + private val service: AudioContentCommentService, + private val memberService: MemberService, + private val userActionService: UserActionService +) { @PostMapping("/audio-content/comment") fun registerComment( @RequestBody request: RegisterCommentRequest, @@ -22,15 +29,27 @@ class AudioContentCommentController(private val service: AudioContentCommentServ ) = run { if (member == null) throw SodaException("로그인 정보를 확인해주세요.") - ApiResponse.ok( - service.registerComment( - comment = request.comment, - audioContentId = request.contentId, - parentId = request.parentId, - isSecret = request.isSecret, - member = member - ) + service.registerComment( + comment = request.comment, + audioContentId = request.contentId, + parentId = request.parentId, + isSecret = request.isSecret, + member = member ) + + try { + val memberId = member.id!! + val pushTokenList = memberService.getPushTokenList(recipient = memberId) + + userActionService.recordAction( + memberId = member.id!!, + actionType = ActionType.CONTENT_COMMENT, + pushTokenList = pushTokenList + ) + } catch (_: Exception) { + } + + ApiResponse.ok(Unit, "") } @PutMapping("/audio-content/comment") diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/fcm/FcmService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/fcm/FcmService.kt index 997b2f5..68e0570 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/fcm/FcmService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/fcm/FcmService.kt @@ -4,8 +4,6 @@ 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.FirebaseMessagingException -import com.google.firebase.messaging.Message import com.google.firebase.messaging.MessagingErrorCode import com.google.firebase.messaging.MulticastMessage import com.google.firebase.messaging.Notification @@ -38,7 +36,7 @@ class FcmService(private val pushTokenService: PushTokenService) { while (attempt <= maxAttempts && targets.isNotEmpty()) { val multicastMessage = MulticastMessage.builder() - .addAllTokens(tokens) + .addAllTokens(targets) multicastMessage.setAndroidConfig( AndroidConfig.builder() @@ -117,51 +115,69 @@ class FcmService(private val pushTokenService: PushTokenService) { } } - fun sendPointGranted(token: String, point: Int) { + 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) { - try { - val message = Message.builder() - .setToken(token) - .putAllData(data) + while (attempts <= maxAttempts && targets.isNotEmpty()) { + val multicastMessage = MulticastMessage.builder() + .addAllTokens(targets) + .putAllData(data) + + multicastMessage.setAndroidConfig( + AndroidConfig.builder() + .setPriority(AndroidConfig.Priority.HIGH) .build() + ) - val response = FirebaseMessaging.getInstance().send(message) - logger.info("[FCM] ✅ 성공 (attempt ${attempts + 1}): messageId=$response") - return // 성공 시 즉시 종료 - } catch (e: FirebaseMessagingException) { - attempts++ + multicastMessage.setApnsConfig( + ApnsConfig.builder() + .setAps( + Aps.builder() + .setSound("default") + .build() + ) + .build() + ) - // "registration-token-not-registered" 예외 코드 확인 - if (e.messagingErrorCode == MessagingErrorCode.UNREGISTERED) { - logger.error("[FCM] ❌ 실패: 토큰이 등록되지 않음 (등록 해제됨) → 재시도 안함") - // DB에서 삭제 - pushTokenService.unregisterInvalidToken(token) - return - } + val response = FirebaseMessaging.getInstance().sendEachForMulticast(multicastMessage.build()) + val failedTokens = mutableListOf() - logger.error("[FCM] ❌ 실패 (attempt $attempts): ${e.errorCode} - ${e.message}") + response.responses.forEachIndexed { index, res -> + if (!res.isSuccessful) { + val exception = res.exception + val token = targets[index] - if (attempts >= maxAttempts) { - logger.error("[FCM] ❌ 최종 실패: 전송 불가") - } - } catch (e: Exception) { - // Firebase 이외의 예외도 잡기 - attempts++ - logger.error("[FCM] ❌ 실패 (attempt $attempts): ${e.message}") - - if (attempts >= maxAttempts) { - logger.error("[FCM] ❌ 최종 실패: 알 수 없는 오류") + 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") } } } diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/member/MemberController.kt b/src/main/kotlin/kr/co/vividnext/sodalive/member/MemberController.kt index 1a1b2e4..11c893c 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/member/MemberController.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/member/MemberController.kt @@ -66,7 +66,11 @@ class MemberController( userActionService.recordAction( memberId = response.memberId, actionType = ActionType.SIGN_UP, - pushToken = request.pushToken + pushTokenList = if (request.pushToken != null) { + listOf(request.pushToken) + } else { + emptyList() + } ) return ApiResponse.ok(message = "회원가입을 축하드립니다.", data = response.loginResponse) @@ -355,7 +359,11 @@ class MemberController( userActionService.recordAction( memberId = response.memberId, actionType = ActionType.SIGN_UP, - pushToken = request.pushToken + pushTokenList = if (request.pushToken != null) { + listOf(request.pushToken) + } else { + emptyList() + } ) } @@ -386,7 +394,11 @@ class MemberController( userActionService.recordAction( memberId = response.memberId, actionType = ActionType.SIGN_UP, - pushToken = request.pushToken + pushTokenList = if (request.pushToken != null) { + listOf(request.pushToken) + } else { + emptyList() + } ) } diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/member/MemberRepository.kt b/src/main/kotlin/kr/co/vividnext/sodalive/member/MemberRepository.kt index 2f14fea..2eb20ef 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/member/MemberRepository.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/member/MemberRepository.kt @@ -64,6 +64,8 @@ interface MemberQueryRepository { fun existsByNickname(nickname: String): Boolean fun findNicknamesWithPrefix(prefix: String): List + + fun getPushTokenList(memberId: Long): List } @Repository @@ -507,4 +509,17 @@ class MemberQueryRepositoryImpl( ) .fetch() } + + override fun getPushTokenList(memberId: Long): List { + val where = member.isActive.isTrue + .and(member.email.notIn("admin@sodalive.net")) + .and(member.id.eq(memberId)) + + return queryFactory + .select(pushToken.token) + .from(member) + .innerJoin(pushToken).on(member.id.eq(pushToken.member.id)) + .where(where) + .fetch() + } } diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/member/MemberService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/member/MemberService.kt index 49f3362..ad71bec 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/member/MemberService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/member/MemberService.kt @@ -907,6 +907,12 @@ class MemberService( return MemberResolveResult(member = member, isNew = true) } + fun getPushTokenList(recipient: Long): List { + return repository.getPushTokenList(recipient) + .toSet() + .toList() + } + private fun checkEmail(email: String) { val member = repository.findByEmail(email) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/member/auth/AuthController.kt b/src/main/kotlin/kr/co/vividnext/sodalive/member/auth/AuthController.kt index f645797..17366f5 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/member/auth/AuthController.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/member/auth/AuthController.kt @@ -3,6 +3,7 @@ package kr.co.vividnext.sodalive.member.auth import kr.co.vividnext.sodalive.common.ApiResponse import kr.co.vividnext.sodalive.common.SodaException import kr.co.vividnext.sodalive.member.Member +import kr.co.vividnext.sodalive.member.MemberService import kr.co.vividnext.sodalive.useraction.ActionType import kr.co.vividnext.sodalive.useraction.UserActionService import org.springframework.security.core.annotation.AuthenticationPrincipal @@ -15,6 +16,7 @@ import org.springframework.web.bind.annotation.RestController @RequestMapping("/auth") class AuthController( private val service: AuthService, + private val memberService: MemberService, private val userActionService: UserActionService ) { @PostMapping @@ -33,11 +35,16 @@ class AuthController( val authResponse = service.authenticate(authenticateData, member.id!!) - userActionService.recordAction( - memberId = member.id!!, - actionType = ActionType.USER_AUTHENTICATION, - pushToken = member.pushToken - ) + try { + val memberId = member.id!! + val pushTokenList = memberService.getPushTokenList(recipient = memberId) + userActionService.recordAction( + memberId = member.id!!, + actionType = ActionType.USER_AUTHENTICATION, + pushTokenList = pushTokenList + ) + } catch (_: Exception) { + } ApiResponse.ok(authResponse) } diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/useraction/UserActionService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/useraction/UserActionService.kt index 6881d59..f6919ac 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/useraction/UserActionService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/useraction/UserActionService.kt @@ -24,7 +24,12 @@ class UserActionService( private val coroutineScope = CoroutineScope(Dispatchers.IO) - fun recordAction(memberId: Long, actionType: ActionType, orderId: Long? = null, pushToken: String? = null) { + fun recordAction( + memberId: Long, + actionType: ActionType, + orderId: Long? = null, + pushTokenList: List = emptyList() + ) { coroutineScope.launch { val now = LocalDateTime.now() repository.save(UserActionLog(memberId, actionType)) @@ -73,8 +78,8 @@ class UserActionService( ) ) - if (pushToken != null) { - fcmService.sendPointGranted(pushToken, policy.pointAmount) + if (pushTokenList.isNotEmpty()) { + fcmService.sendPointGranted(pushTokenList, policy.pointAmount) } } }