-
Notifications
You must be signed in to change notification settings - Fork 1
[NO-ISSUE] FCM 푸시 알림 기능 develop 이식 및 회원 탈퇴 시 토큰 정리 #266
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,22 @@ | ||
| [[admin-push-api]] | ||
| == 푸시 브로드캐스트 관리 | ||
|
|
||
| [[admin-push-broadcast]] | ||
| === 전체 기기 푸시 발송 | ||
|
|
||
| 관리자가 입력한 제목·본문으로 활성 FCM 토큰 전체에 푸시를 발송합니다. | ||
|
|
||
| * ADMIN 권한이 필요합니다. | ||
| * 발송 결과(대상/성공/실패 수)는 이력으로 저장됩니다. | ||
| * Firebase가 설정되지 않은 경우 전송하지 않고 대상 기기 수만 반환합니다. | ||
|
|
||
| operation::admin/push/broadcast[snippets='http-request,request-fields,http-response,response-fields'] | ||
|
|
||
| [[admin-push-broadcast-history]] | ||
| === 푸시 발송 이력 조회 | ||
|
|
||
| 푸시 브로드캐스트 발송 이력을 최신순으로 페이지 조회합니다. | ||
|
|
||
| * VIEWER 이상 권한이 필요합니다. | ||
|
|
||
| operation::admin/push/broadcast-history[snippets='http-request,query-parameters,http-response,response-fields'] |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,21 @@ | ||
| [[notification-api]] | ||
| == FCM 토큰 API | ||
|
|
||
| [[fcm-token-register]] | ||
| === FCM 토큰 등록 | ||
|
|
||
| 로그인 사용자의 FCM 디바이스 토큰을 등록하거나 갱신합니다. | ||
|
|
||
| * 인증이 필요합니다. | ||
| * 동일 토큰 또는 동일 디바이스가 있으면 갱신하고, 없으면 신규 등록합니다. | ||
|
|
||
| operation::fcm-token/register[snippets='http-request,request-fields,http-response,response-fields'] | ||
|
|
||
| [[fcm-token-deactivate]] | ||
| === FCM 토큰 비활성화 | ||
|
|
||
| 로그아웃 등으로 해당 디바이스의 FCM 토큰을 비활성화합니다. | ||
|
|
||
| * 인증이 필요합니다. | ||
|
|
||
| operation::fcm-token/deactivate[snippets='http-request,query-parameters,http-response,response-fields'] |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,75 @@ | ||
| package com.souzip.adapter.webapi.admin; | ||
|
|
||
| import com.souzip.adapter.webapi.admin.dto.PushBroadcastRequest; | ||
| import com.souzip.application.notification.FcmNotificationService; | ||
| import com.souzip.application.notification.PushBroadcastHistoryCommandService; | ||
| import com.souzip.application.notification.PushBroadcastHistoryQueryService; | ||
| import com.souzip.application.notification.dto.PushBroadcastHistoryResponse; | ||
| import com.souzip.application.notification.dto.PushBroadcastResult; | ||
| import com.souzip.domain.admin.infrastructure.security.annotation.AdminAccess; | ||
| import com.souzip.domain.admin.infrastructure.security.annotation.CurrentAdminId; | ||
| import com.souzip.domain.admin.infrastructure.security.annotation.ViewerAccess; | ||
| import com.souzip.shared.common.dto.SuccessResponse; | ||
| import com.souzip.shared.common.dto.pagination.PaginationRequest; | ||
| import com.souzip.shared.common.dto.pagination.PaginationResponse; | ||
| import jakarta.validation.Valid; | ||
| import java.util.UUID; | ||
| import lombok.RequiredArgsConstructor; | ||
| import org.springframework.web.bind.annotation.GetMapping; | ||
| import org.springframework.web.bind.annotation.ModelAttribute; | ||
| import org.springframework.web.bind.annotation.PostMapping; | ||
| import org.springframework.web.bind.annotation.RequestBody; | ||
| import org.springframework.web.bind.annotation.RequestMapping; | ||
| import org.springframework.web.bind.annotation.RestController; | ||
|
|
||
| @RequestMapping("/api/admin/push") | ||
| @RequiredArgsConstructor | ||
| @RestController | ||
| public class AdminPushApi { | ||
|
|
||
| private final FcmNotificationService fcmNotificationService; | ||
| private final PushBroadcastHistoryCommandService pushBroadcastHistoryCommandService; | ||
| private final PushBroadcastHistoryQueryService pushBroadcastHistoryQueryService; | ||
|
|
||
| // 관리자가 입력한 제목·본문으로 활성 FCM 토큰 전체에 푸시를 발송합니다. | ||
| @AdminAccess | ||
| @PostMapping("/broadcast") | ||
| public SuccessResponse<PushBroadcastResult> broadcast( | ||
| @CurrentAdminId UUID adminId, | ||
| @Valid @RequestBody PushBroadcastRequest request | ||
| ) { | ||
| PushBroadcastResult result = fcmNotificationService.broadcastToAllActiveTokens( | ||
| request.title(), | ||
| request.body() | ||
| ); | ||
| pushBroadcastHistoryCommandService.record(adminId, request.title(), request.body(), result); | ||
| String message = buildMessage(result); | ||
| return SuccessResponse.of(result, message); | ||
| } | ||
|
|
||
| // 푸시 브로드캐스트 발송 이력을 최신순으로 페이지 조회합니다. | ||
| @ViewerAccess | ||
| @GetMapping("/broadcast/history") | ||
| public SuccessResponse<PaginationResponse<PushBroadcastHistoryResponse>> broadcastHistory( | ||
| @ModelAttribute PaginationRequest paginationRequest | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. medium: 신규 history API의 RestDocs는 Useful? React with 👍 / 👎. |
||
| ) { | ||
| PaginationResponse<PushBroadcastHistoryResponse> page = | ||
| pushBroadcastHistoryQueryService.findPage(paginationRequest); | ||
| return SuccessResponse.of(page); | ||
| } | ||
|
|
||
| private static String buildMessage(PushBroadcastResult result) { | ||
| if (result.totalTargets() == 0) { | ||
| return "등록된 활성 기기가 없어 전송하지 않았습니다."; | ||
| } | ||
| if (!result.firebaseConfigured()) { | ||
| return "Firebase가 설정되지 않아 전송하지 않았습니다. (대상 기기 " + result.totalTargets() + "건)"; | ||
| } | ||
| return String.format( | ||
| "푸시 발송 완료. 대상 %d건, 성공 %d건, 실패 %d건", | ||
| result.totalTargets(), | ||
| result.successCount(), | ||
| result.failCount() | ||
| ); | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,19 @@ | ||
| package com.souzip.adapter.webapi.admin.dto; | ||
|
|
||
| import jakarta.validation.constraints.NotBlank; | ||
| import jakarta.validation.constraints.Size; | ||
|
|
||
| public record PushBroadcastRequest( | ||
| @NotBlank(message = "제목은 필수입니다.") | ||
| @Size(max = 200, message = "제목은 200자를 초과할 수 없습니다.") | ||
| String title, | ||
|
|
||
| @NotBlank(message = "내용은 필수입니다.") | ||
| @Size(max = 1000, message = "내용은 1000자를 초과할 수 없습니다.") | ||
| String body | ||
| ) { | ||
| public PushBroadcastRequest { | ||
| title = title != null ? title.trim() : null; | ||
| body = body != null ? body.trim() : null; | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -0,0 +1,145 @@ | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| package com.souzip.application.notification; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| import com.google.firebase.messaging.FirebaseMessaging; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| import com.google.firebase.messaging.FirebaseMessagingException; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| import com.google.firebase.messaging.Message; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| import com.google.firebase.messaging.Notification; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| import com.souzip.application.notification.dto.PushBroadcastResult; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| import com.souzip.application.notification.provided.FcmTokenFinder; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| import com.souzip.domain.notification.FcmToken; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| import com.souzip.shared.exception.BusinessException; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| import com.souzip.shared.exception.ErrorCode; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| import java.util.ArrayList; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| import java.util.HashMap; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| import java.util.List; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| import java.util.Map; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| import lombok.RequiredArgsConstructor; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| import lombok.extern.slf4j.Slf4j; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| import org.springframework.beans.factory.ObjectProvider; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| import org.springframework.stereotype.Service; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| @Slf4j | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| @Service | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| @RequiredArgsConstructor | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| public class FcmNotificationService { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| private final FcmTokenFinder fcmTokenFinder; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| private final FcmTokenCommandService fcmTokenCommandService; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| private final ObjectProvider<FirebaseMessaging> firebaseMessaging; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| // 단일 기기 토큰으로 알림(제목·본문)을 전송합니다. | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| public void sendToToken(String registrationToken, String title, String body) { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| sendToToken(registrationToken, title, body, Map.of()); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| // 데이터 페이로드를 포함해 전송합니다. | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| public void sendToToken(String registrationToken, String title, String body, Map<String, String> data) { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| FirebaseMessaging messaging = firebaseMessaging.getIfAvailable(); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if (messaging == null) { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| log.warn("FirebaseMessaging 빈이 없습니다. firebase.enabled 와 credentials 를 확인하세요. 전송을 건너뜁니다."); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| return; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| try { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| Notification notification = Notification.builder() | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| .setTitle(title) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| .setBody(body) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| .build(); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| Message.Builder messageBuilder = Message.builder() | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| .setToken(registrationToken) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| .setNotification(notification); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if (data != null && !data.isEmpty()) { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| messageBuilder.putAllData(new HashMap<>(data)); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| messaging.send(messageBuilder.build()); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } catch (FirebaseMessagingException e) { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| log.error("FCM 전송 실패 tokenPrefix={} error={}", maskToken(registrationToken), e.getMessagingErrorCode(), e); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. medium: 아래 주석처럼 Useful? React with 👍 / 👎. |
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| // 예외 메시지에 원본 토큰 등 민감 정보가 섞일 수 있어 그대로 전파하지 않고, 오류 코드만 보존합니다. | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| throw new FcmSendException(e.getMessagingErrorCode()); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
coderabbitai[bot] marked this conversation as resolved.
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| // DB 조회는 FcmTokenFinder(짧은 readOnly 트랜잭션)에서만 하고, FCM 전송은 트랜잭션 밖에서 수행합니다. | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| public void sendToUser(Long userId, String title, String body) { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| List<FcmToken> tokens = fcmTokenFinder.getActiveTokensByUserId(userId); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if (tokens.isEmpty()) { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| return; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| int successCount = 0; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| int failCount = 0; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| List<Long> staleTokenIds = new ArrayList<>(); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| for (FcmToken token : tokens) { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| try { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| sendToToken(token.getToken(), title, body); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| successCount++; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } catch (FcmSendException e) { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| failCount++; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if (e.isPermanentFailure()) { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| staleTokenIds.add(token.getId()); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| log.warn( | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| "FCM 전송 실패(다음 토큰으로 계속) userId={}, fcmTokenId={}, errorCode={}", | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| userId, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| token.getId(), | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| e.getMessagingErrorCode() | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| ); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| deactivateStaleTokens(staleTokenIds); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if (failCount > 0 && successCount == 0) { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| throw new BusinessException(ErrorCode.FCM_SEND_FAILED, "활성 토큰 전송이 모두 실패했습니다."); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if (failCount > 0) { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| log.warn("FCM 일부 토큰 실패 userId={}, 성공={}, 실패={}", userId, successCount, failCount); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
Comment on lines
+62
to
+94
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. userId 로깅은 PII 유출에 해당할 수 있음. Lines 72-77, 84에서 디버깅 목적이라면 fcmTokenId만 로깅하거나, 필요시 별도의 디버그 레벨 로그로 분리하는 방안을 고려하세요. 🔒 PII 로깅 제거 제안 failCount++;
log.warn(
- "FCM 전송 실패(다음 토큰으로 계속) userId={}, fcmTokenId={}, errorCode={}",
- userId,
+ "FCM 전송 실패(다음 토큰으로 계속) fcmTokenId={}, errorCode={}",
token.getId(),
e.getErrorCode()
); if (failCount > 0) {
- log.warn("FCM 일부 토큰 실패 userId={}, 성공={}, 실패={}", userId, successCount, failCount);
+ log.warn("FCM 일부 토큰 실패 성공={}, 실패={}", successCount, failCount);
}📝 Committable suggestion
Suggested change
🤖 Prompt for AI AgentsSource: Coding guidelines |
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| // DB 조회는 FcmTokenFinder(짧은 readOnly 트랜잭션)에서만 하고, FCM 전송 루프는 트랜잭션 밖에서 수행합니다. | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| public PushBroadcastResult broadcastToAllActiveTokens(String title, String body) { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| List<FcmToken> tokens = fcmTokenFinder.getAllActiveTokens(); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if (tokens.isEmpty()) { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| return new PushBroadcastResult(0, 0, 0, true); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| FirebaseMessaging messaging = firebaseMessaging.getIfAvailable(); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if (messaging == null) { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| log.warn("FirebaseMessaging 빈이 없습니다. 브로드캐스트를 건너뜁니다. 대상 기기 수={}", tokens.size()); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| return new PushBroadcastResult(tokens.size(), 0, 0, false); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| int successCount = 0; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| int failCount = 0; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| List<Long> staleTokenIds = new ArrayList<>(); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| for (FcmToken token : tokens) { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| try { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| sendToToken(token.getToken(), title, body); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| successCount++; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } catch (FcmSendException e) { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| failCount++; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if (e.isPermanentFailure()) { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| staleTokenIds.add(token.getId()); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| log.warn( | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| "FCM 브로드캐스트 실패(다음 토큰으로 계속) fcmTokenId={}, errorCode={}", | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| token.getId(), | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| e.getMessagingErrorCode() | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| ); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| deactivateStaleTokens(staleTokenIds); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| return new PushBroadcastResult(tokens.size(), successCount, failCount, true); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| // 영구 실패(UNREGISTERED 등) 토큰은 이후 전송 대상에서 제외되도록 비활성화합니다. | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| private void deactivateStaleTokens(List<Long> staleTokenIds) { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if (staleTokenIds.isEmpty()) { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| return; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| fcmTokenCommandService.deactivateByIds(staleTokenIds); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| log.info("영구 실패로 비활성화한 FCM 토큰 수={}", staleTokenIds.size()); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| private static String maskToken(String token) { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if (token == null || token.length() < 12) { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| return "***"; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| return token.substring(0, 6) + "..." + token.substring(token.length() - 4); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,28 @@ | ||
| package com.souzip.application.notification; | ||
|
|
||
| import com.google.firebase.messaging.MessagingErrorCode; | ||
| import com.souzip.shared.exception.BusinessException; | ||
| import com.souzip.shared.exception.ErrorCode; | ||
|
|
||
| // FCM 전송 실패를 나타내며, 영구 실패 여부 판별을 위해 MessagingErrorCode 를 보존합니다. | ||
| // (예외 메시지에는 토큰 등 민감 정보를 담지 않습니다.) | ||
| public class FcmSendException extends BusinessException { | ||
|
|
||
| private final transient MessagingErrorCode messagingErrorCode; | ||
|
|
||
| public FcmSendException(MessagingErrorCode messagingErrorCode) { | ||
| super(ErrorCode.FCM_SEND_FAILED); | ||
| this.messagingErrorCode = messagingErrorCode; | ||
| } | ||
|
|
||
| public MessagingErrorCode getMessagingErrorCode() { | ||
| return messagingErrorCode; | ||
| } | ||
|
|
||
| // 토큰이 더 이상 유효하지 않아 재전송해도 계속 실패하는 영구 오류인지 여부. | ||
| public boolean isPermanentFailure() { | ||
| return messagingErrorCode == MessagingErrorCode.UNREGISTERED | ||
| || messagingErrorCode == MessagingErrorCode.INVALID_ARGUMENT | ||
| || messagingErrorCode == MessagingErrorCode.SENDER_ID_MISMATCH; | ||
|
Comment on lines
+23
to
+26
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
medium: Useful? React with 👍 / 👎. |
||
| } | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🧩 Analysis chain
🏁 Script executed:
Repository: souzip/souzip-api
Length of output: 244
firebase-admin 9.4.3 버전(보안 권고/취약점) 확인 및 필요 시 업그레이드 권장 (build.gradle 63행)
com.google.firebase:firebase-admin:9.4.3을 사용 중이며, Maven 메타데이터 기준 최신은9.9.0입니다. 취약점 스캔/호환성 검토 후9.9.0(또는 그 이후)로 업그레이드를 검토하세요.🤖 Prompt for AI Agents