Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 16 additions & 8 deletions src/main/java/com/semosan/api/common/fcm/FcmService.java
Original file line number Diff line number Diff line change
Expand Up @@ -13,15 +13,23 @@
@Service
public class FcmService {

public String sendMessage(String token, String title, String body, Map<String, String> data) throws FirebaseMessagingException {
Notification notification = Notification.builder()
.setTitle(title)
.setBody(body)
.build();

public String sendMessage(
String token,
String title,
String body,
Map<String, String> 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);
Expand Down
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -21,7 +19,6 @@ public class AsyncNotificationDispatcher implements NotificationDispatcher {

private final FcmService fcmService;
private final FcmTokenService fcmTokenService;
private final ObjectMapper objectMapper;

/**
* @Async 가 같은 클래스에 있는 함수를 호출 할 때는 비동기로 작동하지 않으니 주의해야함
Expand All @@ -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) {
Expand All @@ -45,14 +42,17 @@ public void dispatch(NotificationDispatchCommand cmd) {

private Map<String, String> buildDataPayload(NotificationDispatchCommand cmd) {
Map<String, String> 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;
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<String> requiredKeys;
private final boolean dataOnly;

NotificationType(String titleTemplate, String bodyTemplate, Set<String> requiredKeys) {
NotificationType(String titleTemplate, String bodyTemplate, Set<String> requiredKeys, boolean dataOnly) {
this.titleTemplate = titleTemplate;
this.bodyTemplate = bodyTemplate;
this.requiredKeys = requiredKeys;
this.dataOnly = dataOnly;
}

/**
Expand All @@ -69,6 +74,10 @@ public String formatBody(Map<String, Object> params) {
return format(bodyTemplate, params);
}

public boolean isDataOnly() {
return dataOnly;
}

private static String format(String template, Map<String, Object> params) {
if (params == null || params.isEmpty()) return template;
String result = template;
Expand Down
Original file line number Diff line number Diff line change
@@ -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<Map<String, String>> 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<Map<String, String>> 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
);
}
}