diff --git a/src/main/java/com/semosan/api/common/fcm/FcmService.java b/src/main/java/com/semosan/api/common/fcm/FcmService.java index b6f3791d..682455fe 100644 --- a/src/main/java/com/semosan/api/common/fcm/FcmService.java +++ b/src/main/java/com/semosan/api/common/fcm/FcmService.java @@ -13,15 +13,23 @@ @Service public class FcmService { - public String sendMessage(String token, String title, String body, Map data) throws FirebaseMessagingException { - Notification notification = Notification.builder() - .setTitle(title) - .setBody(body) - .build(); - + public String sendMessage( + String token, + String title, + String body, + Map data, + boolean dataOnly + ) throws FirebaseMessagingException { Message.Builder builder = Message.builder() - .setToken(token) - .setNotification(notification); + .setToken(token); + + if (!dataOnly) { + Notification notification = Notification.builder() + .setTitle(title) + .setBody(body) + .build(); + builder.setNotification(notification); + } if (data != null && !data.isEmpty()) { builder.putAllData(data); diff --git a/src/main/java/com/semosan/api/domain/notification/dispatcher/AsyncNotificationDispatcher.java b/src/main/java/com/semosan/api/domain/notification/dispatcher/AsyncNotificationDispatcher.java index 961a09ea..a8cf1c2b 100644 --- a/src/main/java/com/semosan/api/domain/notification/dispatcher/AsyncNotificationDispatcher.java +++ b/src/main/java/com/semosan/api/domain/notification/dispatcher/AsyncNotificationDispatcher.java @@ -1,7 +1,5 @@ package com.semosan.api.domain.notification.dispatcher; -import com.fasterxml.jackson.core.JsonProcessingException; -import com.fasterxml.jackson.databind.ObjectMapper; import com.google.firebase.messaging.FirebaseMessagingException; import com.google.firebase.messaging.MessagingErrorCode; import com.semosan.api.common.fcm.FcmService; @@ -21,7 +19,6 @@ public class AsyncNotificationDispatcher implements NotificationDispatcher { private final FcmService fcmService; private final FcmTokenService fcmTokenService; - private final ObjectMapper objectMapper; /** * @Async 가 같은 클래스에 있는 함수를 호출 할 때는 비동기로 작동하지 않으니 주의해야함 @@ -34,7 +31,7 @@ public void dispatch(NotificationDispatchCommand cmd) { for (String token : cmd.tokens()) { try { - fcmService.sendMessage(token, cmd.title(), cmd.body(), dataPayload); + fcmService.sendMessage(token, cmd.title(), cmd.body(), dataPayload, cmd.type().isDataOnly()); } catch (FirebaseMessagingException e) { handleSendError(token, e); } catch (Exception e) { @@ -45,14 +42,17 @@ public void dispatch(NotificationDispatchCommand cmd) { private Map buildDataPayload(NotificationDispatchCommand cmd) { Map data = new HashMap<>(); + if (cmd.extras() != null) { + cmd.extras().forEach((key, value) -> { + if (key != null && value != null) { + data.put(key, String.valueOf(value)); + } + }); + } data.put("type", cmd.type().name()); + data.put("title", cmd.title()); + data.put("body", cmd.body()); data.put("notificationId", String.valueOf(cmd.notificationId())); - try { - data.put("extras", objectMapper.writeValueAsString(cmd.extras())); - } catch (JsonProcessingException e) { - log.error("extras 직렬화 실패", e); - data.put("extras", "{}"); - } return data; } diff --git a/src/main/java/com/semosan/api/domain/notification/enums/NotificationType.java b/src/main/java/com/semosan/api/domain/notification/enums/NotificationType.java index 3eeea59b..205f5c94 100644 --- a/src/main/java/com/semosan/api/domain/notification/enums/NotificationType.java +++ b/src/main/java/com/semosan/api/domain/notification/enums/NotificationType.java @@ -12,38 +12,43 @@ public enum NotificationType { COMMUNITY_COMMENT( "새 댓글이 달렸어요", "{actorName}: {commentPreview}", - Set.of("actorName", "commentPreview") + Set.of("actorName", "commentPreview"), + false ), /** * 트래킹 중 거리 마일스톤 도달 시 사진 촬영 유도. - * iOS 배너 노출을 위해 notification.title 에 앱 이름을 명시한다. + * 포그라운드 즉시 표시를 위해 FCM data-only 로 발송하고, 앱에서 로컬 알림을 생성한다. */ TRACKING_PHOTO_MILESTONE( "SEMOSAN", "{distance}m 돌파! 인증 사진을 남겨보세요!", - Set.of("distance") + Set.of("distance"), + true ), /** * 코스 거리 50% 도달 시 정상 인증 유도. * 진짜 정상 좌표가 식별 불가해 코스 절반 지점을 "정상" 근처로 간주하는 임시 정책. - * iOS 배너 노출을 위해 notification.title 에 앱 이름을 명시한다. + * 포그라운드 즉시 표시를 위해 FCM data-only 로 발송하고, 앱에서 로컬 알림을 생성한다. */ TRACKING_SUMMIT_REACHED( "SEMOSAN", "정상에 도착했나요? 정상 인증하기!", - Set.of() + Set.of(), + true ); private final String titleTemplate; private final String bodyTemplate; private final Set requiredKeys; + private final boolean dataOnly; - NotificationType(String titleTemplate, String bodyTemplate, Set requiredKeys) { + NotificationType(String titleTemplate, String bodyTemplate, Set requiredKeys, boolean dataOnly) { this.titleTemplate = titleTemplate; this.bodyTemplate = bodyTemplate; this.requiredKeys = requiredKeys; + this.dataOnly = dataOnly; } /** @@ -69,6 +74,10 @@ public String formatBody(Map params) { return format(bodyTemplate, params); } + public boolean isDataOnly() { + return dataOnly; + } + private static String format(String template, Map params) { if (params == null || params.isEmpty()) return template; String result = template; diff --git a/src/test/java/com/semosan/api/domain/notification/dispatcher/AsyncNotificationDispatcherTest.java b/src/test/java/com/semosan/api/domain/notification/dispatcher/AsyncNotificationDispatcherTest.java new file mode 100644 index 00000000..eb70de24 --- /dev/null +++ b/src/test/java/com/semosan/api/domain/notification/dispatcher/AsyncNotificationDispatcherTest.java @@ -0,0 +1,98 @@ +package com.semosan.api.domain.notification.dispatcher; + +import com.semosan.api.common.fcm.FcmService; +import com.semosan.api.domain.notification.enums.NotificationType; +import com.semosan.api.domain.notification.service.FcmTokenService; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.util.List; +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.verify; + +@ExtendWith(MockitoExtension.class) +class AsyncNotificationDispatcherTest { + + @Mock + private FcmService fcmService; + + @Mock + private FcmTokenService fcmTokenService; + + @Test + @SuppressWarnings("unchecked") + void dispatchSendsTrackingNotificationAsDataOnlyWithTitleBodyAndDistance() throws Exception { + AsyncNotificationDispatcher dispatcher = dispatcher(); + NotificationDispatchCommand command = new NotificationDispatchCommand( + 1L, + 10L, + NotificationType.TRACKING_PHOTO_MILESTONE, + "SEMOSAN", + "500m 돌파! 인증 사진을 남겨보세요!", + Map.of("distance", 500), + List.of("token-1") + ); + + dispatcher.dispatch(command); + + ArgumentCaptor> dataCaptor = ArgumentCaptor.forClass(Map.class); + verify(fcmService).sendMessage( + eq("token-1"), + eq("SEMOSAN"), + eq("500m 돌파! 인증 사진을 남겨보세요!"), + dataCaptor.capture(), + eq(true) + ); + assertThat(dataCaptor.getValue()) + .containsEntry("type", "TRACKING_PHOTO_MILESTONE") + .containsEntry("title", "SEMOSAN") + .containsEntry("body", "500m 돌파! 인증 사진을 남겨보세요!") + .containsEntry("distance", "500") + .containsEntry("notificationId", "1") + .doesNotContainKey("extras"); + } + + @Test + @SuppressWarnings("unchecked") + void dispatchKeepsGeneralNotificationPayload() throws Exception { + AsyncNotificationDispatcher dispatcher = dispatcher(); + NotificationDispatchCommand command = new NotificationDispatchCommand( + 2L, + 10L, + NotificationType.COMMUNITY_COMMENT, + "새 댓글이 달렸어요", + "푸름: 확인했어요", + Map.of("actorName", "푸름", "commentPreview", "확인했어요"), + List.of("token-1") + ); + + dispatcher.dispatch(command); + + ArgumentCaptor> dataCaptor = ArgumentCaptor.forClass(Map.class); + verify(fcmService).sendMessage( + eq("token-1"), + eq("새 댓글이 달렸어요"), + eq("푸름: 확인했어요"), + dataCaptor.capture(), + eq(false) + ); + assertThat(dataCaptor.getValue()) + .containsEntry("type", "COMMUNITY_COMMENT") + .containsEntry("title", "새 댓글이 달렸어요") + .containsEntry("body", "푸름: 확인했어요") + .doesNotContainKey("extras"); + } + + private AsyncNotificationDispatcher dispatcher() { + return new AsyncNotificationDispatcher( + fcmService, + fcmTokenService + ); + } +}