Skip to content

Commit d41769f

Browse files
authored
Merge pull request #1315 from Moadong/develop/be
[release] BE
2 parents c1cf017 + 8dc47d6 commit d41769f

19 files changed

+957
-16
lines changed
Lines changed: 142 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,40 +1,174 @@
11
package moadong.fcm.adapter;
22

3+
import com.google.firebase.messaging.BatchResponse;
34
import com.google.firebase.messaging.FirebaseMessaging;
5+
import com.google.firebase.messaging.MulticastMessage;
46
import com.google.firebase.messaging.Message;
57
import com.google.firebase.messaging.Notification;
8+
import com.google.firebase.messaging.SendResponse;
69
import lombok.RequiredArgsConstructor;
710
import lombok.extern.slf4j.Slf4j;
11+
import moadong.fcm.model.MulticastPushPayload;
12+
import moadong.fcm.model.MulticastPushResult;
813
import moadong.fcm.model.PushPayload;
14+
import moadong.fcm.model.TokenPushPayload;
15+
import moadong.fcm.model.TokenPushResult;
916
import moadong.fcm.port.PushNotificationPort;
1017
import org.springframework.stereotype.Component;
18+
import org.springframework.util.StringUtils;
19+
20+
import java.util.ArrayList;
21+
import java.util.Collections;
22+
import java.util.LinkedHashMap;
23+
import java.util.LinkedHashSet;
24+
import java.util.List;
25+
import java.util.Map;
26+
import java.util.stream.Collectors;
27+
import java.util.stream.IntStream;
1128

1229
@Slf4j
1330
@Component
1431
@RequiredArgsConstructor
1532
public class FirebasePushNotificationAdapter implements PushNotificationPort {
1633

34+
private static final int MULTICAST_LIMIT = 500;
35+
1736
private final FirebaseMessaging firebaseMessaging;
1837

1938
@Override
20-
public boolean send(PushPayload payload) {
39+
public TokenPushResult send(PushPayload payload) {
2140
log.info("PushPayload: {}", payload);
22-
Message message = Message.builder()
41+
Message.Builder builder = Message.builder()
2342
.setNotification(Notification.builder()
2443
.setTitle(payload.title())
2544
.setBody(payload.body())
2645
.build())
27-
.putAllData(payload.data())
28-
.setTopic(payload.topic())
29-
.build();
46+
.setTopic(payload.topic());
47+
48+
Map<String, String> data = sanitizeData(payload.data());
49+
if (!data.isEmpty()) {
50+
builder.putAllData(data);
51+
}
3052

3153
try {
32-
String messageId = firebaseMessaging.send(message);
54+
String messageId = firebaseMessaging.send(builder.build());
3355
log.info("FCM send success - topic: {}, messageId: {}", payload.topic(), messageId);
34-
return true;
56+
return new TokenPushResult(true, messageId);
3557
} catch (Exception e) {
3658
log.error("FCM send failed - topic: {}, error: {}", payload.topic(), e.getMessage());
37-
return false;
59+
return new TokenPushResult(false, null);
60+
}
61+
}
62+
63+
@Override
64+
public TokenPushResult sendToToken(TokenPushPayload payload) {
65+
if (!StringUtils.hasText(payload.token())) {
66+
log.warn("FCM send skipped - blank token");
67+
return new TokenPushResult(false, null);
68+
}
69+
70+
Message.Builder builder = Message.builder()
71+
.setToken(payload.token())
72+
.setNotification(Notification.builder()
73+
.setTitle(payload.title())
74+
.setBody(payload.body())
75+
.build());
76+
77+
Map<String, String> data = sanitizeData(payload.data());
78+
if (!data.isEmpty()) {
79+
builder.putAllData(data);
80+
}
81+
82+
try {
83+
String messageId = firebaseMessaging.send(builder.build());
84+
return new TokenPushResult(true, messageId);
85+
} catch (Exception e) {
86+
log.error("FCM send failed - token: {}, error: {}", mask(payload.token()), e.getMessage(), e);
87+
return new TokenPushResult(false, null);
88+
}
89+
}
90+
91+
@Override
92+
public MulticastPushResult sendToTokens(MulticastPushPayload payload) {
93+
List<String> tokens = payload.tokens() == null ? List.of() : payload.tokens().stream()
94+
.filter(StringUtils::hasText)
95+
.collect(Collectors.collectingAndThen(
96+
Collectors.toCollection(LinkedHashSet::new),
97+
List::copyOf
98+
));
99+
100+
if (tokens.isEmpty()) {
101+
return new MulticastPushResult(0, 0, 0, List.of());
102+
}
103+
104+
int batchCount = 0;
105+
int successCount = 0;
106+
int failureCount = 0;
107+
List<String> failedTokens = new ArrayList<>();
108+
Map<String, String> data = sanitizeData(payload.data());
109+
110+
for (int start = 0; start < tokens.size(); start += MULTICAST_LIMIT) {
111+
int end = Math.min(start + MULTICAST_LIMIT, tokens.size());
112+
List<String> batchTokens = tokens.subList(start, end);
113+
batchCount++;
114+
115+
MulticastMessage.Builder builder = MulticastMessage.builder()
116+
.addAllTokens(batchTokens)
117+
.setNotification(Notification.builder()
118+
.setTitle(payload.title())
119+
.setBody(payload.body())
120+
.build());
121+
122+
if (!data.isEmpty()) {
123+
builder.putAllData(data);
124+
}
125+
126+
try {
127+
BatchResponse response = firebaseMessaging.sendEachForMulticast(builder.build());
128+
successCount += response.getSuccessCount();
129+
failureCount += response.getFailureCount();
130+
collectFailedTokens(batchTokens, response.getResponses(), failedTokens);
131+
} catch (Exception e) {
132+
log.error("FCM batch send failed - batch: {}, error: {}", batchCount, e.getMessage(), e);
133+
failureCount += batchTokens.size();
134+
failedTokens.addAll(batchTokens);
135+
}
136+
}
137+
138+
return new MulticastPushResult(
139+
batchCount,
140+
successCount,
141+
failureCount,
142+
List.copyOf(failedTokens)
143+
);
144+
}
145+
146+
private void collectFailedTokens(List<String> batchTokens, List<SendResponse> responses, List<String> failedTokens) {
147+
IntStream.range(0, responses.size())
148+
.filter(index -> !responses.get(index).isSuccessful())
149+
.mapToObj(batchTokens::get)
150+
.forEach(failedTokens::add);
151+
}
152+
153+
public Map<String, String> sanitizeData(Map<String, String> data) {
154+
if (data == null || data.isEmpty()) {
155+
return Collections.emptyMap();
156+
}
157+
158+
return data.entrySet().stream()
159+
.filter(entry -> StringUtils.hasText(entry.getKey()) && entry.getValue() != null)
160+
.collect(Collectors.toMap(
161+
Map.Entry::getKey,
162+
Map.Entry::getValue,
163+
(left, right) -> right,
164+
LinkedHashMap::new
165+
));
166+
}
167+
168+
private String mask(String token) {
169+
if (!StringUtils.hasText(token) || token.length() < 10) {
170+
return "***";
38171
}
172+
return token.substring(0, 6) + "..." + token.substring(token.length() - 4);
39173
}
40174
}
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
package moadong.fcm.controller;
2+
3+
import io.swagger.v3.oas.annotations.Operation;
4+
import io.swagger.v3.oas.annotations.security.SecurityRequirement;
5+
import io.swagger.v3.oas.annotations.tags.Tag;
6+
import jakarta.validation.Valid;
7+
import lombok.AllArgsConstructor;
8+
import moadong.fcm.payload.request.FcmAdminBatchSendRequest;
9+
import moadong.fcm.payload.request.FcmAdminSingleSendRequest;
10+
import moadong.fcm.service.FcmAdminService;
11+
import moadong.global.payload.Response;
12+
import org.springframework.http.ResponseEntity;
13+
import org.springframework.web.bind.annotation.GetMapping;
14+
import org.springframework.web.bind.annotation.PostMapping;
15+
import org.springframework.web.bind.annotation.RequestBody;
16+
import org.springframework.web.bind.annotation.RequestMapping;
17+
import org.springframework.web.bind.annotation.RestController;
18+
19+
@RestController
20+
@RequestMapping("/api/admin")
21+
@AllArgsConstructor
22+
@Tag(name = "Fcm_Notification_Admin", description = "FCM 알림관리자 페이지 기능")
23+
public class FcmAdminController {
24+
25+
private final FcmAdminService fcmAdminService;
26+
27+
@GetMapping("/tokens")
28+
@Operation(summary = "전체 FCM 토큰 조회", description = "학생/비학생 토큰을 모두 합쳐 중복 제거된 토큰 목록을 조회합니다.")
29+
@SecurityRequirement(name = "BearerAuth")
30+
public ResponseEntity<?> getAllAvailableToken() {
31+
return Response.ok(fcmAdminService.getAllAvailableToken());
32+
}
33+
34+
@PostMapping("/fcm/send")
35+
@Operation(summary = "FCM 단건 발송", description = "특정 토큰으로 개별 FCM 메시지를 발송합니다.")
36+
@SecurityRequirement(name = "BearerAuth")
37+
public ResponseEntity<?> sendToToken(@RequestBody @Valid FcmAdminSingleSendRequest request) {
38+
return Response.ok("FCM 단건 발송 완료", fcmAdminService.sendToToken(request));
39+
}
40+
41+
@PostMapping("/fcm/send-all")
42+
@Operation(summary = "FCM 전체 배치 발송", description = "저장된 전체 토큰을 대상으로 FCM 메시지를 발송합니다.")
43+
@SecurityRequirement(name = "BearerAuth")
44+
public ResponseEntity<?> sendToAll(@RequestBody @Valid FcmAdminBatchSendRequest request) {
45+
return Response.ok("FCM 전체 배치 발송 완료", fcmAdminService.sendToAll(request));
46+
}
47+
}
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
package moadong.fcm.model;
2+
3+
import java.util.List;
4+
import java.util.Map;
5+
6+
public record MulticastPushPayload(
7+
List<String> tokens,
8+
String title,
9+
String body,
10+
Map<String, String> data
11+
) {
12+
}
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
package moadong.fcm.model;
2+
3+
import java.util.List;
4+
5+
public record MulticastPushResult(
6+
int batchCount,
7+
int successCount,
8+
int failureCount,
9+
List<String> failedTokens
10+
) {
11+
}
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
package moadong.fcm.model;
2+
3+
import java.util.Map;
4+
5+
public record TokenPushPayload(
6+
String token,
7+
String title,
8+
String body,
9+
Map<String, String> data
10+
) {
11+
}
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
package moadong.fcm.model;
2+
3+
public record TokenPushResult(
4+
boolean success,
5+
String messageId
6+
) {
7+
}
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
package moadong.fcm.payload.request;
2+
3+
import jakarta.validation.constraints.NotBlank;
4+
5+
import java.util.Map;
6+
7+
public record FcmAdminBatchSendRequest(
8+
@NotBlank
9+
String title,
10+
@NotBlank
11+
String body,
12+
Map<String, String> data
13+
) {
14+
}
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
package moadong.fcm.payload.request;
2+
3+
import jakarta.validation.constraints.NotBlank;
4+
5+
import java.util.Map;
6+
7+
public record FcmAdminSingleSendRequest(
8+
@NotBlank
9+
String token,
10+
@NotBlank
11+
String title,
12+
@NotBlank
13+
String body,
14+
Map<String, String> data
15+
) {
16+
}
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
package moadong.fcm.payload.response;
2+
3+
import java.util.List;
4+
5+
public record FcmAdminBatchSendResponse(
6+
int totalTokenCount,
7+
int batchCount,
8+
int successCount,
9+
int failureCount,
10+
List<String> failedTokens
11+
) {
12+
}
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
package moadong.fcm.payload.response;
2+
3+
public record FcmAdminSingleSendResponse(
4+
String token,
5+
String messageId
6+
) {
7+
}

0 commit comments

Comments
 (0)