-
Notifications
You must be signed in to change notification settings - Fork 0
feat: Expo 푸시 알림 발송 연동 #73
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
2 commits
Select commit
Hold shift + click to select a range
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
5 changes: 4 additions & 1 deletion
5
...n/java/com/gachi/be/domain/notification/repository/NotificationDeliveryLogRepository.java
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,7 +1,10 @@ | ||
| package com.gachi.be.domain.notification.repository; | ||
|
|
||
| import com.gachi.be.domain.notification.entity.NotificationDeliveryLog; | ||
| import java.util.List; | ||
| import org.springframework.data.jpa.repository.JpaRepository; | ||
|
|
||
| public interface NotificationDeliveryLogRepository | ||
| extends JpaRepository<NotificationDeliveryLog, Long> {} | ||
| extends JpaRepository<NotificationDeliveryLog, Long> { | ||
| List<NotificationDeliveryLog> findAllByNotificationIdOrderByAttemptedAtAsc(Long notificationId); | ||
| } |
107 changes: 107 additions & 0 deletions
107
src/main/java/com/gachi/be/domain/notification/service/ExpoPushNotificationClient.java
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,107 @@ | ||
| package com.gachi.be.domain.notification.service; | ||
|
|
||
| import com.fasterxml.jackson.databind.JsonNode; | ||
| import com.fasterxml.jackson.databind.ObjectMapper; | ||
| import com.gachi.be.domain.notification.entity.Notification; | ||
| import com.gachi.be.domain.notification.entity.PushDeviceToken; | ||
| import com.gachi.be.global.config.external.NotificationPushProperties; | ||
| import java.io.IOException; | ||
| import java.net.URI; | ||
| import java.net.http.HttpClient; | ||
| import java.net.http.HttpRequest; | ||
| import java.net.http.HttpResponse; | ||
| import java.time.Duration; | ||
| import java.util.Map; | ||
| import lombok.extern.slf4j.Slf4j; | ||
| import org.springframework.stereotype.Component; | ||
| import org.springframework.util.StringUtils; | ||
|
|
||
| /** Expo Push API로 React Native 앱 푸시 알림을 발송한다. */ | ||
| @Slf4j | ||
| @Component | ||
| public class ExpoPushNotificationClient implements PushNotificationClient { | ||
| private static final String PROVIDER_NAME = "EXPO"; | ||
| private static final String DEVICE_NOT_REGISTERED = "DeviceNotRegistered"; | ||
|
|
||
| private final NotificationPushProperties properties; | ||
| private final ObjectMapper objectMapper; | ||
| private final HttpClient httpClient; | ||
|
|
||
| public ExpoPushNotificationClient( | ||
| NotificationPushProperties properties, ObjectMapper objectMapper) { | ||
| this.properties = properties; | ||
| this.objectMapper = objectMapper; | ||
| this.httpClient = | ||
| HttpClient.newBuilder() | ||
| .connectTimeout(Duration.ofSeconds(properties.getConnectTimeoutSeconds())) | ||
| .version(HttpClient.Version.HTTP_1_1) | ||
| .build(); | ||
| } | ||
|
|
||
| @Override | ||
| public String providerName() { | ||
| return PROVIDER_NAME; | ||
| } | ||
|
|
||
| @Override | ||
| public PushSendResult send( | ||
| Notification notification, PushDeviceToken pushDeviceToken, Map<String, Object> payload) { | ||
| try { | ||
| String body = | ||
| objectMapper.writeValueAsString( | ||
| new ExpoPushRequest( | ||
| pushDeviceToken.getToken(), | ||
| notification.getTitle(), | ||
| notification.getBody(), | ||
| "default", | ||
| payload != null ? payload : Map.of())); | ||
|
|
||
| HttpRequest.Builder requestBuilder = | ||
| HttpRequest.newBuilder() | ||
| .uri(URI.create(properties.getExpo().getApiUrl())) | ||
| .header("Accept", "application/json") | ||
| .header("Accept-Encoding", "gzip, deflate") | ||
| .header("Content-Type", "application/json") | ||
| .timeout(Duration.ofSeconds(properties.getReadTimeoutSeconds())) | ||
| .POST(HttpRequest.BodyPublishers.ofString(body)); | ||
| if (StringUtils.hasText(properties.getExpo().getAccessToken())) { | ||
| requestBuilder.header("Authorization", "Bearer " + properties.getExpo().getAccessToken()); | ||
| } | ||
|
|
||
| HttpResponse<String> response = | ||
| httpClient.send(requestBuilder.build(), HttpResponse.BodyHandlers.ofString()); | ||
| if (response.statusCode() < 200 || response.statusCode() >= 300) { | ||
| return PushSendResult.failed( | ||
| "Expo Push API HTTP " + response.statusCode() + ": " + response.body(), false); | ||
| } | ||
| return parseTicket(response.body()); | ||
| } catch (InterruptedException e) { | ||
| Thread.currentThread().interrupt(); | ||
| return PushSendResult.failed("Expo Push API 호출이 인터럽트되었습니다.", false); | ||
| } catch (IOException | IllegalArgumentException e) { | ||
| log.warn("[ExpoPush] 푸시 발송 실패. notificationId={}", notification.getId(), e); | ||
| return PushSendResult.failed("Expo Push API 호출 실패: " + e.getMessage(), false); | ||
| } | ||
| } | ||
|
|
||
| private PushSendResult parseTicket(String responseBody) throws IOException { | ||
| JsonNode root = objectMapper.readTree(responseBody); | ||
| JsonNode ticket = root.path("data"); | ||
| if (ticket.isArray()) { | ||
| ticket = ticket.isEmpty() ? objectMapper.nullNode() : ticket.get(0); | ||
| } | ||
| String status = ticket.path("status").asText(""); | ||
| if ("ok".equals(status)) { | ||
| return PushSendResult.sent(ticket.path("id").asText(null)); | ||
| } | ||
|
|
||
| String message = ticket.path("message").asText("Expo Push API 발송 실패"); | ||
| String error = ticket.path("details").path("error").asText(""); | ||
| boolean invalidToken = DEVICE_NOT_REGISTERED.equals(error); | ||
| String failureReason = StringUtils.hasText(error) ? message + " (" + error + ")" : message; | ||
| return PushSendResult.failed(failureReason, invalidToken); | ||
| } | ||
|
|
||
| private record ExpoPushRequest( | ||
| String to, String title, String body, String sound, Map<String, Object> data) {} | ||
| } |
4 changes: 4 additions & 0 deletions
4
src/main/java/com/gachi/be/domain/notification/service/NotificationCreatedEvent.java
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,4 @@ | ||
| package com.gachi.be.domain.notification.service; | ||
|
|
||
| /** 알림 보관함 저장 커밋 이후 외부 푸시 발송을 시작하기 위한 도메인 이벤트. */ | ||
| public record NotificationCreatedEvent(Long notificationId, Long userId) {} |
171 changes: 171 additions & 0 deletions
171
src/main/java/com/gachi/be/domain/notification/service/NotificationPushDispatcher.java
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,171 @@ | ||
| package com.gachi.be.domain.notification.service; | ||
|
|
||
| import com.fasterxml.jackson.core.type.TypeReference; | ||
| import com.fasterxml.jackson.databind.ObjectMapper; | ||
| import com.gachi.be.domain.notification.entity.Notification; | ||
| import com.gachi.be.domain.notification.entity.NotificationDeliveryLog; | ||
| import com.gachi.be.domain.notification.entity.PushDeviceToken; | ||
| import com.gachi.be.domain.notification.entity.enums.NotificationDeliveryStatus; | ||
| import com.gachi.be.domain.notification.entity.enums.PushPlatform; | ||
| import com.gachi.be.domain.notification.repository.NotificationDeliveryLogRepository; | ||
| import com.gachi.be.domain.notification.repository.NotificationRepository; | ||
| import com.gachi.be.domain.notification.repository.PushDeviceTokenRepository; | ||
| import com.gachi.be.domain.user.entity.User; | ||
| import com.gachi.be.domain.user.repository.UserRepository; | ||
| import com.gachi.be.global.config.external.NotificationPushProperties; | ||
| import java.util.Collections; | ||
| import java.util.List; | ||
| import java.util.Map; | ||
| import java.util.Objects; | ||
| import lombok.RequiredArgsConstructor; | ||
| import lombok.extern.slf4j.Slf4j; | ||
| import org.springframework.stereotype.Component; | ||
| import org.springframework.transaction.annotation.Propagation; | ||
| import org.springframework.transaction.annotation.Transactional; | ||
| import org.springframework.transaction.event.TransactionPhase; | ||
| import org.springframework.transaction.event.TransactionalEventListener; | ||
| import org.springframework.util.StringUtils; | ||
|
|
||
| /** 알림 생성 이후 사용자 설정과 토큰 상태를 반영해 외부 푸시 발송을 수행한다. */ | ||
| @Slf4j | ||
| @Component | ||
| @RequiredArgsConstructor | ||
| public class NotificationPushDispatcher { | ||
| private static final String PROVIDER_EXPO = "expo"; | ||
| private static final TypeReference<Map<String, Object>> PAYLOAD_TYPE = new TypeReference<>() {}; | ||
|
|
||
| private final NotificationPushProperties properties; | ||
| private final NotificationRepository notificationRepository; | ||
| private final PushDeviceTokenRepository pushDeviceTokenRepository; | ||
| private final NotificationDeliveryLogRepository deliveryLogRepository; | ||
| private final UserRepository userRepository; | ||
| private final PushNotificationClient pushNotificationClient; | ||
| private final ObjectMapper objectMapper; | ||
|
|
||
| @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT, fallbackExecution = true) | ||
| @Transactional(propagation = Propagation.REQUIRES_NEW) | ||
| public void dispatch(NotificationCreatedEvent event) { | ||
| Notification notification = | ||
| notificationRepository.findById(event.notificationId()).orElse(null); | ||
| if (notification == null) { | ||
| log.info( | ||
| "[NotificationPush] 알림을 찾을 수 없어 발송을 건너뜁니다. notificationId={}", event.notificationId()); | ||
| return; | ||
| } | ||
|
|
||
| Long targetUserId = notification.getUserId(); | ||
| if (!Objects.equals(event.userId(), targetUserId)) { | ||
| log.warn( | ||
| "[NotificationPush] 이벤트 userId와 알림 소유자 userId가 다릅니다. notificationId={}, eventUserId={}, notificationUserId={}", | ||
| event.notificationId(), | ||
| event.userId(), | ||
| targetUserId); | ||
| } | ||
|
|
||
| User user = userRepository.findById(targetUserId).orElse(null); | ||
| if (user == null) { | ||
| saveSkipped(notification, null, "사용자를 찾을 수 없어 푸시 발송을 건너뜁니다."); | ||
| return; | ||
| } | ||
| if (!user.isNotificationEnabled()) { | ||
| saveSkipped(notification, null, "사용자 알림 수신 설정이 꺼져 있습니다."); | ||
| return; | ||
| } | ||
| if (!properties.isEnabled()) { | ||
| saveSkipped(notification, null, "푸시 발송 설정이 비활성화되어 있습니다."); | ||
| return; | ||
| } | ||
| if (!PROVIDER_EXPO.equalsIgnoreCase(properties.getProvider())) { | ||
| saveSkipped(notification, null, "지원하지 않는 푸시 provider입니다: " + properties.getProvider()); | ||
| return; | ||
| } | ||
|
|
||
| List<PushDeviceToken> tokens = | ||
| pushDeviceTokenRepository.findAllByUserIdAndEnabledTrueAndDeletedAtIsNull(targetUserId); | ||
| if (tokens.isEmpty()) { | ||
| saveSkipped(notification, null, "활성 푸시 토큰이 없습니다."); | ||
| return; | ||
| } | ||
|
|
||
| Map<String, Object> payload = deserializePayload(notification.getPayloadJson()); | ||
| for (PushDeviceToken token : tokens) { | ||
| dispatchToToken(notification, token, payload); | ||
| } | ||
| } | ||
|
|
||
| private void dispatchToToken( | ||
| Notification notification, PushDeviceToken token, Map<String, Object> payload) { | ||
| if (token.getPlatform() != PushPlatform.EXPO) { | ||
| saveSkipped(notification, token, "현재 provider에서 지원하지 않는 토큰 플랫폼입니다: " + token.getPlatform()); | ||
| return; | ||
| } | ||
|
|
||
| PushSendResult result = pushNotificationClient.send(notification, token, payload); | ||
| if (result.success()) { | ||
| saveDeliveryLog( | ||
| notification, | ||
| token, | ||
| NotificationDeliveryStatus.SENT, | ||
| pushNotificationClient.providerName(), | ||
| result.providerMessageId(), | ||
| null); | ||
| return; | ||
| } | ||
|
|
||
| if (result.invalidToken()) { | ||
| token.softDelete(); | ||
| } | ||
| saveDeliveryLog( | ||
| notification, | ||
| token, | ||
| NotificationDeliveryStatus.FAILED, | ||
| pushNotificationClient.providerName(), | ||
| null, | ||
| result.failureReason()); | ||
| } | ||
|
|
||
| private void saveSkipped(Notification notification, PushDeviceToken token, String failureReason) { | ||
| saveDeliveryLog( | ||
| notification, | ||
| token, | ||
| NotificationDeliveryStatus.SKIPPED, | ||
| resolveConfiguredProvider(), | ||
| null, | ||
| failureReason); | ||
| } | ||
|
|
||
| private void saveDeliveryLog( | ||
| Notification notification, | ||
| PushDeviceToken token, | ||
| NotificationDeliveryStatus status, | ||
| String provider, | ||
| String providerMessageId, | ||
| String failureReason) { | ||
| deliveryLogRepository.save( | ||
| NotificationDeliveryLog.builder() | ||
| .notification(notification) | ||
| .pushDeviceToken(token) | ||
| .status(status) | ||
| .provider(provider) | ||
| .providerMessageId(providerMessageId) | ||
| .failureReason(failureReason) | ||
| .build()); | ||
| } | ||
|
|
||
| private String resolveConfiguredProvider() { | ||
| return StringUtils.hasText(properties.getProvider()) | ||
| ? properties.getProvider().trim().toUpperCase() | ||
| : "UNKNOWN"; | ||
| } | ||
|
|
||
| private Map<String, Object> deserializePayload(String payloadJson) { | ||
| if (!StringUtils.hasText(payloadJson)) { | ||
| return Collections.emptyMap(); | ||
| } | ||
| try { | ||
| return objectMapper.readValue(payloadJson, PAYLOAD_TYPE); | ||
| } catch (Exception e) { | ||
| return Map.of("raw", payloadJson); | ||
| } | ||
| } | ||
| } | ||
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.