Skip to content

Commit 01ee557

Browse files
authored
Merge pull request #6 from LESSON-MATCHING-PLATFORM/feat/fcm-error-cleanup-dedup-payload
feat: FCM 실패 분류와 dedup payload 추가
2 parents 988bec3 + a817aea commit 01ee557

8 files changed

Lines changed: 356 additions & 9 deletions

File tree

src/main/java/com/kosa/noticeserver/domain/service/PaymentEventNotificationService.java

Lines changed: 36 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
import com.kosa.noticeserver.domain.model.event.PaymentEvent;
55
import com.kosa.noticeserver.infrastructure.repository.TokenRepository;
66
import com.kosa.noticeserver.infrastructure.sender.fcm.FCMSender;
7+
import com.kosa.noticeserver.infrastructure.sender.fcm.FcmFailureClassifier;
78
import lombok.RequiredArgsConstructor;
89
import lombok.extern.slf4j.Slf4j;
910
import org.jboss.logging.MDC;
@@ -22,6 +23,7 @@ public class PaymentEventNotificationService {
2223
private final FCMSender fcmsender;
2324
private final NotificationService notificationService;
2425
private final NotificationDeliveryService notificationDeliveryService;
26+
private final FcmFailureClassifier fcmFailureClassifier;
2527

2628
public void notice(PaymentEvent paymentEvent, String eventId) {
2729

@@ -43,7 +45,9 @@ public void notice(PaymentEvent paymentEvent, String eventId) {
4345
);
4446
if (delivery.isEmpty()) return;
4547

46-
List<SendNotificationCommand> commands = tokens.stream().map(token -> buildNotification(token, paymentEvent)).toList();
48+
List<SendNotificationCommand> commands = tokens.stream()
49+
.map(token -> buildNotification(token, paymentEvent, eventId))
50+
.toList();
4751

4852
SendBatchResult send;
4953
try {
@@ -54,6 +58,8 @@ public void notice(PaymentEvent paymentEvent, String eventId) {
5458
return;
5559
}
5660

61+
cleanupInvalidTokens(send.results());
62+
5763
if (send.successCount() > 0) {
5864
notificationDeliveryService.markSent(delivery.get());
5965
} else {
@@ -113,7 +119,7 @@ public void notice(List<PaymentEvent> paymentEvents, String eventId) {
113119
if (delivery.isEmpty()) return Stream.empty();
114120

115121
claimedDeliveries.put(event.getUserId(), delivery.get());
116-
return userTokens.stream().map(token -> buildNotification(token, event));
122+
return userTokens.stream().map(token -> buildNotification(token, event, eventId));
117123

118124
})
119125
.toList();
@@ -130,6 +136,7 @@ public void notice(List<PaymentEvent> paymentEvents, String eventId) {
130136
return;
131137
}
132138

139+
cleanupInvalidTokens(result.results());
133140
markBulkDeliveryResults(result, claimedDeliveries);
134141
for (SendDetails detail : result.results()) {
135142
if (detail.isSuccess()) {
@@ -167,7 +174,30 @@ private String firstErrorMessage(List<SendDetails> details) {
167174
.orElse("FCM send failed");
168175
}
169176

170-
private SendNotificationCommand buildNotification(String token, PaymentEvent event) {
177+
private void cleanupInvalidTokens(List<SendDetails> details) {
178+
List<String> invalidTokens = details.stream()
179+
.filter(detail -> !detail.isSuccess())
180+
.filter(fcmFailureClassifier::isInvalidToken)
181+
.map(SendDetails::originalCommand)
182+
.filter(Objects::nonNull)
183+
.map(SendNotificationCommand::target)
184+
.filter(Objects::nonNull)
185+
.distinct()
186+
.toList();
187+
188+
if (invalidTokens.isEmpty()) {
189+
return;
190+
}
191+
192+
try {
193+
long deletedCount = tokenRepository.deleteByTokenIn(invalidTokens);
194+
log.warn("[{}] invalid FCM tokens deleted, tokenCount: {}", MDC.get("eventId"), deletedCount);
195+
} catch (RuntimeException e) {
196+
log.error("[{}] invalid FCM token cleanup failed, tokenCount: {}", MDC.get("eventId"), invalidTokens.size(), e);
197+
}
198+
}
199+
200+
private SendNotificationCommand buildNotification(String token, PaymentEvent event, String eventId) {
171201
String title = "결제 완료 안내";
172202
String body = String.format("%s님, %s원 결제가 정상 처리되었습니다.",
173203
event.getUserName(), event.getAmount());
@@ -176,6 +206,9 @@ private SendNotificationCommand buildNotification(String token, PaymentEvent eve
176206
data.put("orderId", event.getOrderId());
177207
data.put("paymentTime", event.getTimestamp());
178208
data.put("userId", event.getUserId());
209+
data.put("eventId", eventId);
210+
data.put("notificationType", NotificationType.PAYMENT.name());
211+
data.put("dedupKey", "PAYMENT_COMPLETED:" + event.getOrderId());
179212

180213
return new SendNotificationCommand(
181214
token,

src/main/java/com/kosa/noticeserver/infrastructure/repository/TokenRepository.java

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
import org.springframework.data.jpa.repository.Query;
66
import org.springframework.stereotype.Repository;
77

8+
import java.util.Collection;
89
import java.util.List;
910

1011
@Repository
@@ -13,4 +14,6 @@ public interface TokenRepository extends JpaRepository<TokenEntity, Long> {
1314
List<String> findAllTokensByUserId(String userId);
1415

1516
List<TokenEntity> findAllByUserIdIn(List<String> userIds);
17+
18+
long deleteByTokenIn(Collection<String> tokens);
1619
}

src/main/java/com/kosa/noticeserver/infrastructure/sender/fcm/FCMSender.java

Lines changed: 23 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -36,12 +36,13 @@ public SendBatchResult send(List<SendNotificationCommand> commands) throws Throw
3636
for (int i = 0; i < batchResponse.getResponses().size(); i++) {
3737
SendResponse sendResponse = batchResponse.getResponses().get(i);
3838
SendNotificationCommand originalCommand = commands.get(i);
39+
FirebaseMessagingException exception = sendResponse.getException();
3940

4041
results.add(new SendDetails(
4142
sendResponse.isSuccessful(),
4243
sendResponse.getMessageId(),
43-
sendResponse.getException().getMessage(),
44-
!sendResponse.isSuccessful() ? sendResponse.getException().getErrorCode().toString() : null,
44+
exception == null ? null : exception.getMessage(),
45+
resolveErrorCode(exception),
4546
originalCommand
4647
));
4748
}
@@ -55,13 +56,30 @@ public boolean supports(ChannelType type) {
5556
}
5657

5758
private Message buildMessage(SendNotificationCommand command) {
58-
return Message.builder()
59+
Message.Builder builder = Message.builder()
5960
.setToken(command.target())
6061
.setNotification(
6162
Notification.builder()
6263
.setTitle(command.title())
6364
.setBody(command.body())
64-
.build())
65-
.build();
65+
.build());
66+
67+
if (command.data() != null && !command.data().isEmpty()) {
68+
builder.putAllData(command.data());
69+
}
70+
71+
return builder.build();
72+
}
73+
74+
static String resolveErrorCode(FirebaseMessagingException exception) {
75+
if (exception == null) {
76+
return null;
77+
}
78+
79+
if (exception.getMessagingErrorCode() != null) {
80+
return exception.getMessagingErrorCode().toString();
81+
}
82+
83+
return exception.getErrorCode() == null ? null : exception.getErrorCode().toString();
6684
}
6785
}
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
package com.kosa.noticeserver.infrastructure.sender.fcm;
2+
3+
public enum FcmFailureCategory {
4+
TOKEN_INVALID,
5+
RETRYABLE,
6+
CONFIGURATION,
7+
AMBIGUOUS,
8+
UNKNOWN
9+
}
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
package com.kosa.noticeserver.infrastructure.sender.fcm;
2+
3+
import com.kosa.noticeserver.domain.model.SendDetails;
4+
import org.springframework.stereotype.Component;
5+
6+
import java.util.Set;
7+
8+
@Component
9+
public class FcmFailureClassifier {
10+
11+
private static final Set<String> TOKEN_INVALID_CODES = Set.of(
12+
"UNREGISTERED",
13+
"SENDER_ID_MISMATCH"
14+
);
15+
private static final Set<String> RETRYABLE_CODES = Set.of(
16+
"UNAVAILABLE",
17+
"INTERNAL",
18+
"QUOTA_EXCEEDED"
19+
);
20+
private static final Set<String> CONFIGURATION_CODES = Set.of(
21+
"THIRD_PARTY_AUTH_ERROR"
22+
);
23+
24+
public FcmFailureCategory classify(SendDetails details) {
25+
if (details == null || details.isSuccess()) {
26+
return FcmFailureCategory.UNKNOWN;
27+
}
28+
29+
String errorCode = details.errorCode();
30+
if (errorCode == null || errorCode.isBlank()) {
31+
return FcmFailureCategory.UNKNOWN;
32+
}
33+
34+
if (TOKEN_INVALID_CODES.contains(errorCode)) {
35+
return FcmFailureCategory.TOKEN_INVALID;
36+
}
37+
38+
if (RETRYABLE_CODES.contains(errorCode)) {
39+
return FcmFailureCategory.RETRYABLE;
40+
}
41+
42+
if (CONFIGURATION_CODES.contains(errorCode)) {
43+
return FcmFailureCategory.CONFIGURATION;
44+
}
45+
46+
return FcmFailureCategory.UNKNOWN;
47+
}
48+
49+
public FcmFailureCategory classify(Throwable throwable) {
50+
return FcmFailureCategory.AMBIGUOUS;
51+
}
52+
53+
public boolean isInvalidToken(SendDetails details) {
54+
return classify(details) == FcmFailureCategory.TOKEN_INVALID;
55+
}
56+
}

0 commit comments

Comments
 (0)