Skip to content

Commit d9e8c95

Browse files
authored
feat: FCM 404 에러 핸들링 추가 (#2283)
* feat: FCM 전송 결과 저장 로직 추가 * feat: FCM 무효 토큰 정리 스케줄러 추가
1 parent c191515 commit d9e8c95

11 files changed

Lines changed: 224 additions & 58 deletions

File tree

‎src/main/java/in/koreatech/koin/admin/notification/service/AdminNotificationService.java‎

Lines changed: 10 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -7,65 +7,50 @@
77
import org.springframework.stereotype.Service;
88
import org.springframework.transaction.annotation.Transactional;
99

10-
import in.koreatech.koin.admin.manager.model.Admin;
1110
import in.koreatech.koin.admin.manager.repository.AdminRepository;
1211
import in.koreatech.koin.admin.notification.dto.AdminNotificationRequest;
13-
import in.koreatech.koin.admin.notification.repository.AdminNotificationRepository;
1412
import in.koreatech.koin.admin.notification.repository.AdminNotificationSubscribeRepository;
1513
import in.koreatech.koin.domain.notification.model.Notification;
1614
import in.koreatech.koin.domain.notification.model.NotificationDetailSubscribeType;
1715
import in.koreatech.koin.domain.notification.model.NotificationSubscribe;
1816
import in.koreatech.koin.domain.notification.model.NotificationSubscribeType;
17+
import in.koreatech.koin.domain.notification.service.NotificationService;
1918
import in.koreatech.koin.domain.user.model.User;
2019
import in.koreatech.koin.global.exception.CustomException;
21-
import in.koreatech.koin.infrastructure.fcm.FcmClient;
2220
import lombok.RequiredArgsConstructor;
23-
import lombok.extern.slf4j.Slf4j;
2421

25-
@Slf4j
2622
@Service
2723
@RequiredArgsConstructor
2824
@Transactional(readOnly = true)
2925
public class AdminNotificationService {
3026

3127
private final AdminNotificationSubscribeRepository adminNotificationSubscribeRepository;
32-
private final AdminNotificationRepository adminNotificationRepository;
3328
private final AdminRepository adminRepository;
34-
private final FcmClient fcmClient;
29+
private final NotificationService notificationService;
3530

3631
@Transactional
3732
public void sendNotification(AdminNotificationRequest request, Integer adminId) {
38-
Admin admin = adminRepository.getById(adminId);
33+
adminRepository.getById(adminId);
3934
validateDetailTypeMatchesSubscribeType(request.subscribeType(), request.detailSubscribeType());
4035
List<NotificationSubscribe> notificationSubscribes = getNotificationSubscribes(
4136
request.subscribeType(), request.detailSubscribeType()
4237
);
4338

44-
for (NotificationSubscribe notificationSubscribe : notificationSubscribes) {
45-
try {
39+
List<Notification> notifications = notificationSubscribes.stream()
40+
.map(notificationSubscribe -> {
4641
User user = notificationSubscribe.getUser();
47-
Notification notification = Notification.of(
42+
return Notification.of(
4843
request.mobileAppPath(),
4944
request.schemaUrl(),
5045
request.title(),
5146
request.message(),
5247
request.imageUrl(),
5348
user
5449
);
55-
adminNotificationRepository.save(notification);
56-
fcmClient.sendMessage(
57-
user.getDeviceToken(),
58-
notification.getTitle(),
59-
notification.getMessage(),
60-
notification.getImageUrl(),
61-
notification.getMobileAppPath(),
62-
notification.getSchemeUri(),
63-
notification.getType().toLowerCase()
64-
);
65-
} catch (Exception e) {
66-
log.warn("FCM 알림 전송 과정에서 에러 발생", e);
67-
}
68-
}
50+
})
51+
.toList();
52+
53+
notificationService.pushNotifications(notifications);
6954
}
7055

7156
private void validateDetailTypeMatchesSubscribeType(

‎src/main/java/in/koreatech/koin/domain/notification/model/Notification.java‎

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,15 @@ public class Notification extends BaseEntity {
5757
@Column(name = "is_read", nullable = false)
5858
private boolean isRead = false;
5959

60+
@Column(name = "is_push_success")
61+
private Boolean pushSuccess;
62+
63+
@Column(name = "fcm_error_code")
64+
private String fcmErrorCode;
65+
66+
@Column(name = "fcm_messaging_error_code")
67+
private String fcmMessagingErrorCode;
68+
6069
public Notification(
6170
MobileAppPath appPath,
6271
String schemeUri,
@@ -98,6 +107,18 @@ public void read() {
98107
this.isRead = true;
99108
}
100109

110+
public void markPushSuccess() {
111+
this.pushSuccess = true;
112+
this.fcmErrorCode = null;
113+
this.fcmMessagingErrorCode = null;
114+
}
115+
116+
public void markPushFailure(String fcmErrorCode, String fcmMessagingErrorCode) {
117+
this.pushSuccess = false;
118+
this.fcmErrorCode = fcmErrorCode;
119+
this.fcmMessagingErrorCode = fcmMessagingErrorCode;
120+
}
121+
101122
public String getType() {
102123
return type.name().toLowerCase();
103124
}

‎src/main/java/in/koreatech/koin/domain/notification/repository/NotificationJdbcRepository.java‎

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -32,9 +32,12 @@ INSERT INTO notification (
3232
type,
3333
users_id,
3434
is_read,
35+
is_push_success,
36+
fcm_error_code,
37+
fcm_messaging_error_code,
3538
created_at,
3639
updated_at
37-
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
40+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
3841
""";
3942

4043
LocalDateTime now = LocalDateTime.now();
@@ -52,9 +55,13 @@ INSERT INTO notification (
5255
preparedStatement.setString(6, notification.getType().toUpperCase());
5356
preparedStatement.setInt(7, notification.getUser().getId());
5457
preparedStatement.setBoolean(8, notification.isRead());
55-
preparedStatement.setObject(9, now);
56-
preparedStatement.setObject(10, now);
58+
preparedStatement.setObject(9, notification.getPushSuccess());
59+
preparedStatement.setString(10, notification.getFcmErrorCode());
60+
preparedStatement.setString(11, notification.getFcmMessagingErrorCode());
61+
preparedStatement.setObject(12, now);
62+
preparedStatement.setObject(13, now);
5763
}
5864
);
5965
}
66+
6067
}

‎src/main/java/in/koreatech/koin/domain/notification/repository/NotificationRepository.java‎

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
package in.koreatech.koin.domain.notification.repository;
22

3+
import java.time.LocalDateTime;
34
import java.util.List;
45

56
import org.springframework.data.jpa.repository.Query;
@@ -24,4 +25,19 @@ List<Integer> findUserIdsBySchemeUriLikeAndUserIdIn(
2425
@Param("schemeUriPattern") String schemeUriPattern,
2526
@Param("userIds") List<Integer> userIds
2627
);
28+
29+
@Query("""
30+
SELECT DISTINCT n.user.id
31+
FROM Notification n
32+
WHERE n.pushSuccess = false
33+
AND n.fcmMessagingErrorCode = 'UNREGISTERED'
34+
AND n.createdAt >= :start
35+
AND n.createdAt < :end
36+
AND n.user.deviceToken IS NOT NULL
37+
AND n.user.updatedAt <= n.createdAt
38+
""")
39+
List<Integer> findUnregisteredPushFailureUserIds(
40+
@Param("start") LocalDateTime start,
41+
@Param("end") LocalDateTime end
42+
);
2743
}
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
package in.koreatech.koin.domain.notification.scheduler;
2+
3+
import org.springframework.scheduling.annotation.Scheduled;
4+
import org.springframework.stereotype.Component;
5+
6+
import in.koreatech.koin.domain.notification.service.NotificationDeviceTokenCleanupService;
7+
import lombok.RequiredArgsConstructor;
8+
import lombok.extern.slf4j.Slf4j;
9+
10+
@Slf4j
11+
@Component
12+
@RequiredArgsConstructor
13+
public class NotificationDeviceTokenCleanupScheduler {
14+
15+
private final NotificationDeviceTokenCleanupService notificationDeviceTokenCleanupService;
16+
17+
@Scheduled(cron = "0 0 0 * * *")
18+
public void cleanupUnregisteredDeviceTokens() {
19+
try {
20+
notificationDeviceTokenCleanupService.cleanupYesterdayUnregisteredDeviceTokens();
21+
} catch (Exception e) {
22+
log.warn("FCM 무효 토큰 정리 과정에서 오류가 발생했습니다.", e);
23+
}
24+
}
25+
}
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
package in.koreatech.koin.domain.notification.service;
2+
3+
import java.time.Clock;
4+
import java.time.LocalDate;
5+
import java.time.LocalDateTime;
6+
import java.util.List;
7+
8+
import org.springframework.stereotype.Service;
9+
import org.springframework.transaction.annotation.Transactional;
10+
11+
import in.koreatech.koin.domain.notification.repository.NotificationRepository;
12+
import in.koreatech.koin.domain.user.repository.UserRepository;
13+
import lombok.RequiredArgsConstructor;
14+
import lombok.extern.slf4j.Slf4j;
15+
16+
@Slf4j
17+
@Service
18+
@RequiredArgsConstructor
19+
@Transactional(readOnly = true)
20+
public class NotificationDeviceTokenCleanupService {
21+
22+
private final Clock clock;
23+
private final NotificationRepository notificationRepository;
24+
private final UserRepository userRepository;
25+
26+
@Transactional
27+
public void cleanupYesterdayUnregisteredDeviceTokens() {
28+
LocalDate today = LocalDate.now(clock);
29+
LocalDateTime start = today.minusDays(1).atStartOfDay();
30+
LocalDateTime end = today.atStartOfDay();
31+
32+
List<Integer> userIds = notificationRepository.findUnregisteredPushFailureUserIds(start, end);
33+
log.info("전날 UNREGISTERED 실패 알림 기준 deviceToken 삭제 대상 조회. start={}, end={}, count={}",
34+
start,
35+
end,
36+
userIds.size()
37+
);
38+
39+
if (!userIds.isEmpty()) {
40+
userRepository.clearDeviceTokensByIdIn(userIds);
41+
}
42+
log.info("deviceToken 삭제 요청 완료. count={}", userIds.size());
43+
}
44+
}

‎src/main/java/in/koreatech/koin/domain/notification/service/NotificationService.java‎

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
import in.koreatech.koin.domain.user.repository.UserRepository;
2020
import in.koreatech.koin.infrastructure.fcm.FcmClient;
2121
import in.koreatech.koin.infrastructure.fcm.FcmSendRequest;
22+
import in.koreatech.koin.infrastructure.fcm.FcmSendResponse;
2223
import io.micrometer.common.util.StringUtils;
2324
import lombok.RequiredArgsConstructor;
2425
import lombok.extern.slf4j.Slf4j;
@@ -56,7 +57,8 @@ public void pushNotifications(List<Notification> notifications) {
5657
notification.getType().toLowerCase()
5758
))
5859
.toList();
59-
fcmClient.sendMessages(fcmSendRequests);
60+
List<FcmSendResponse> fcmSendResponses = fcmClient.sendMessages(fcmSendRequests);
61+
markPushResults(notifications, fcmSendResponses);
6062

6163
try {
6264
notificationJdbcRepository.batchInsert(notifications);
@@ -65,6 +67,18 @@ public void pushNotifications(List<Notification> notifications) {
6567
}
6668
}
6769

70+
private void markPushResults(List<Notification> notifications, List<FcmSendResponse> responses) {
71+
for (int index = 0; index < notifications.size(); index++) {
72+
Notification notification = notifications.get(index);
73+
FcmSendResponse response = responses.get(index);
74+
if (response.success()) {
75+
notification.markPushSuccess();
76+
continue;
77+
}
78+
notification.markPushFailure(response.errorCode(), response.messagingErrorCode());
79+
}
80+
}
81+
6882
public NotificationStatusResponse getNotificationInfo(Integer userId) {
6983
User user = userRepository.getById(userId);
7084
boolean isPermit = StringUtils.isNotBlank(user.getDeviceToken());

‎src/main/java/in/koreatech/koin/domain/user/repository/UserRepository.java‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,10 @@
55
import java.util.Optional;
66
import java.util.stream.Collectors;
77

8+
import org.springframework.data.jpa.repository.Modifying;
9+
import org.springframework.data.jpa.repository.Query;
810
import org.springframework.data.repository.Repository;
11+
import org.springframework.data.repository.query.Param;
912

1013
import in.koreatech.koin.global.exception.CustomException;
1114
import in.koreatech.koin.global.code.ApiResponseCode;
@@ -48,6 +51,15 @@ public interface UserRepository extends Repository<User, Integer> {
4851

4952
void delete(User user);
5053

54+
@Modifying
55+
@Query("""
56+
UPDATE User u
57+
SET u.deviceToken = null
58+
WHERE u.id IN :userIds
59+
AND u.deviceToken IS NOT NULL
60+
""")
61+
void clearDeviceTokensByIdIn(@Param("userIds") List<Integer> userIds);
62+
5163
default User getById(Integer userId) {
5264
return findById(userId)
5365
.orElseThrow(() -> CustomException.of(ApiResponseCode.NOT_FOUND_USER, "userId: " + userId));

0 commit comments

Comments
 (0)