Skip to content

Commit 1a1734d

Browse files
authored
Merge pull request #123 from SEMOSAN/fix/#121-fcm-data-only-notification
[Fix] 트래킹 중 푸시 알림이 늦게 나가는 현상 수정
2 parents 84c6974 + 7ee5071 commit 1a1734d

4 files changed

Lines changed: 139 additions & 24 deletions

File tree

src/main/java/com/semosan/api/common/fcm/FcmService.java

Lines changed: 16 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -13,15 +13,23 @@
1313
@Service
1414
public class FcmService {
1515

16-
public String sendMessage(String token, String title, String body, Map<String, String> data) throws FirebaseMessagingException {
17-
Notification notification = Notification.builder()
18-
.setTitle(title)
19-
.setBody(body)
20-
.build();
21-
16+
public String sendMessage(
17+
String token,
18+
String title,
19+
String body,
20+
Map<String, String> data,
21+
boolean dataOnly
22+
) throws FirebaseMessagingException {
2223
Message.Builder builder = Message.builder()
23-
.setToken(token)
24-
.setNotification(notification);
24+
.setToken(token);
25+
26+
if (!dataOnly) {
27+
Notification notification = Notification.builder()
28+
.setTitle(title)
29+
.setBody(body)
30+
.build();
31+
builder.setNotification(notification);
32+
}
2533

2634
if (data != null && !data.isEmpty()) {
2735
builder.putAllData(data);

src/main/java/com/semosan/api/domain/notification/dispatcher/AsyncNotificationDispatcher.java

Lines changed: 10 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,5 @@
11
package com.semosan.api.domain.notification.dispatcher;
22

3-
import com.fasterxml.jackson.core.JsonProcessingException;
4-
import com.fasterxml.jackson.databind.ObjectMapper;
53
import com.google.firebase.messaging.FirebaseMessagingException;
64
import com.google.firebase.messaging.MessagingErrorCode;
75
import com.semosan.api.common.fcm.FcmService;
@@ -21,7 +19,6 @@ public class AsyncNotificationDispatcher implements NotificationDispatcher {
2119

2220
private final FcmService fcmService;
2321
private final FcmTokenService fcmTokenService;
24-
private final ObjectMapper objectMapper;
2522

2623
/**
2724
* @Async 가 같은 클래스에 있는 함수를 호출 할 때는 비동기로 작동하지 않으니 주의해야함
@@ -34,7 +31,7 @@ public void dispatch(NotificationDispatchCommand cmd) {
3431

3532
for (String token : cmd.tokens()) {
3633
try {
37-
fcmService.sendMessage(token, cmd.title(), cmd.body(), dataPayload);
34+
fcmService.sendMessage(token, cmd.title(), cmd.body(), dataPayload, cmd.type().isDataOnly());
3835
} catch (FirebaseMessagingException e) {
3936
handleSendError(token, e);
4037
} catch (Exception e) {
@@ -45,14 +42,17 @@ public void dispatch(NotificationDispatchCommand cmd) {
4542

4643
private Map<String, String> buildDataPayload(NotificationDispatchCommand cmd) {
4744
Map<String, String> data = new HashMap<>();
45+
if (cmd.extras() != null) {
46+
cmd.extras().forEach((key, value) -> {
47+
if (key != null && value != null) {
48+
data.put(key, String.valueOf(value));
49+
}
50+
});
51+
}
4852
data.put("type", cmd.type().name());
53+
data.put("title", cmd.title());
54+
data.put("body", cmd.body());
4955
data.put("notificationId", String.valueOf(cmd.notificationId()));
50-
try {
51-
data.put("extras", objectMapper.writeValueAsString(cmd.extras()));
52-
} catch (JsonProcessingException e) {
53-
log.error("extras 직렬화 실패", e);
54-
data.put("extras", "{}");
55-
}
5656
return data;
5757
}
5858

src/main/java/com/semosan/api/domain/notification/enums/NotificationType.java

Lines changed: 15 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -12,38 +12,43 @@ public enum NotificationType {
1212
COMMUNITY_COMMENT(
1313
"새 댓글이 달렸어요",
1414
"{actorName}: {commentPreview}",
15-
Set.of("actorName", "commentPreview")
15+
Set.of("actorName", "commentPreview"),
16+
false
1617
),
1718

1819
/**
1920
* 트래킹 중 거리 마일스톤 도달 시 사진 촬영 유도.
20-
* iOS 배너 노출을 위해 notification.title 에 앱 이름을 명시한다.
21+
* 포그라운드 즉시 표시를 위해 FCM data-only 로 발송하고, 앱에서 로컬 알림을 생성한다.
2122
*/
2223
TRACKING_PHOTO_MILESTONE(
2324
"SEMOSAN",
2425
"{distance}m 돌파! 인증 사진을 남겨보세요!",
25-
Set.of("distance")
26+
Set.of("distance"),
27+
true
2628
),
2729

2830
/**
2931
* 코스 거리 50% 도달 시 정상 인증 유도.
3032
* 진짜 정상 좌표가 식별 불가해 코스 절반 지점을 "정상" 근처로 간주하는 임시 정책.
31-
* iOS 배너 노출을 위해 notification.title 에 앱 이름을 명시한다.
33+
* 포그라운드 즉시 표시를 위해 FCM data-only 로 발송하고, 앱에서 로컬 알림을 생성한다.
3234
*/
3335
TRACKING_SUMMIT_REACHED(
3436
"SEMOSAN",
3537
"정상에 도착했나요? 정상 인증하기!",
36-
Set.of()
38+
Set.of(),
39+
true
3740
);
3841

3942
private final String titleTemplate;
4043
private final String bodyTemplate;
4144
private final Set<String> requiredKeys;
45+
private final boolean dataOnly;
4246

43-
NotificationType(String titleTemplate, String bodyTemplate, Set<String> requiredKeys) {
47+
NotificationType(String titleTemplate, String bodyTemplate, Set<String> requiredKeys, boolean dataOnly) {
4448
this.titleTemplate = titleTemplate;
4549
this.bodyTemplate = bodyTemplate;
4650
this.requiredKeys = requiredKeys;
51+
this.dataOnly = dataOnly;
4752
}
4853

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

77+
public boolean isDataOnly() {
78+
return dataOnly;
79+
}
80+
7281
private static String format(String template, Map<String, Object> params) {
7382
if (params == null || params.isEmpty()) return template;
7483
String result = template;
Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
package com.semosan.api.domain.notification.dispatcher;
2+
3+
import com.semosan.api.common.fcm.FcmService;
4+
import com.semosan.api.domain.notification.enums.NotificationType;
5+
import com.semosan.api.domain.notification.service.FcmTokenService;
6+
import org.junit.jupiter.api.Test;
7+
import org.junit.jupiter.api.extension.ExtendWith;
8+
import org.mockito.ArgumentCaptor;
9+
import org.mockito.Mock;
10+
import org.mockito.junit.jupiter.MockitoExtension;
11+
12+
import java.util.List;
13+
import java.util.Map;
14+
15+
import static org.assertj.core.api.Assertions.assertThat;
16+
import static org.mockito.ArgumentMatchers.eq;
17+
import static org.mockito.Mockito.verify;
18+
19+
@ExtendWith(MockitoExtension.class)
20+
class AsyncNotificationDispatcherTest {
21+
22+
@Mock
23+
private FcmService fcmService;
24+
25+
@Mock
26+
private FcmTokenService fcmTokenService;
27+
28+
@Test
29+
@SuppressWarnings("unchecked")
30+
void dispatchSendsTrackingNotificationAsDataOnlyWithTitleBodyAndDistance() throws Exception {
31+
AsyncNotificationDispatcher dispatcher = dispatcher();
32+
NotificationDispatchCommand command = new NotificationDispatchCommand(
33+
1L,
34+
10L,
35+
NotificationType.TRACKING_PHOTO_MILESTONE,
36+
"SEMOSAN",
37+
"500m 돌파! 인증 사진을 남겨보세요!",
38+
Map.of("distance", 500),
39+
List.of("token-1")
40+
);
41+
42+
dispatcher.dispatch(command);
43+
44+
ArgumentCaptor<Map<String, String>> dataCaptor = ArgumentCaptor.forClass(Map.class);
45+
verify(fcmService).sendMessage(
46+
eq("token-1"),
47+
eq("SEMOSAN"),
48+
eq("500m 돌파! 인증 사진을 남겨보세요!"),
49+
dataCaptor.capture(),
50+
eq(true)
51+
);
52+
assertThat(dataCaptor.getValue())
53+
.containsEntry("type", "TRACKING_PHOTO_MILESTONE")
54+
.containsEntry("title", "SEMOSAN")
55+
.containsEntry("body", "500m 돌파! 인증 사진을 남겨보세요!")
56+
.containsEntry("distance", "500")
57+
.containsEntry("notificationId", "1")
58+
.doesNotContainKey("extras");
59+
}
60+
61+
@Test
62+
@SuppressWarnings("unchecked")
63+
void dispatchKeepsGeneralNotificationPayload() throws Exception {
64+
AsyncNotificationDispatcher dispatcher = dispatcher();
65+
NotificationDispatchCommand command = new NotificationDispatchCommand(
66+
2L,
67+
10L,
68+
NotificationType.COMMUNITY_COMMENT,
69+
"새 댓글이 달렸어요",
70+
"푸름: 확인했어요",
71+
Map.of("actorName", "푸름", "commentPreview", "확인했어요"),
72+
List.of("token-1")
73+
);
74+
75+
dispatcher.dispatch(command);
76+
77+
ArgumentCaptor<Map<String, String>> dataCaptor = ArgumentCaptor.forClass(Map.class);
78+
verify(fcmService).sendMessage(
79+
eq("token-1"),
80+
eq("새 댓글이 달렸어요"),
81+
eq("푸름: 확인했어요"),
82+
dataCaptor.capture(),
83+
eq(false)
84+
);
85+
assertThat(dataCaptor.getValue())
86+
.containsEntry("type", "COMMUNITY_COMMENT")
87+
.containsEntry("title", "새 댓글이 달렸어요")
88+
.containsEntry("body", "푸름: 확인했어요")
89+
.doesNotContainKey("extras");
90+
}
91+
92+
private AsyncNotificationDispatcher dispatcher() {
93+
return new AsyncNotificationDispatcher(
94+
fcmService,
95+
fcmTokenService
96+
);
97+
}
98+
}

0 commit comments

Comments
 (0)