diff --git a/.env.example b/.env.example index da9cfce..aa8cd1d 100644 --- a/.env.example +++ b/.env.example @@ -45,3 +45,11 @@ AUTH_RATE_LIMIT_KEY_PREFIX=auth:rate-limit: AUTH_RATE_LIMIT_LOGIN_LIMIT=5 AUTH_RATE_LIMIT_LOGIN_WINDOW_SECONDS=60 AUTH_RATE_LIMIT_TRUSTED_PROXIES=127.0.0.1,::1 + +# Push notification +NOTIFICATION_PUSH_ENABLED=false +NOTIFICATION_PUSH_PROVIDER=expo +NOTIFICATION_PUSH_CONNECT_TIMEOUT_SECONDS=5 +NOTIFICATION_PUSH_READ_TIMEOUT_SECONDS=10 +EXPO_PUSH_API_URL=https://exp.host/--/api/v2/push/send +EXPO_PUSH_ACCESS_TOKEN= diff --git a/deploy/.env.example b/deploy/.env.example index 8d0f866..6261d2a 100644 --- a/deploy/.env.example +++ b/deploy/.env.example @@ -49,5 +49,12 @@ AWS_S3_BUCKET= AWS_S3_PUBLIC_BASE_URL= AWS_S3_IMAGE_PREFIX=images +NOTIFICATION_PUSH_ENABLED=false +NOTIFICATION_PUSH_PROVIDER=expo +NOTIFICATION_PUSH_CONNECT_TIMEOUT_SECONDS=5 +NOTIFICATION_PUSH_READ_TIMEOUT_SECONDS=10 +EXPO_PUSH_API_URL=https://exp.host/--/api/v2/push/send +EXPO_PUSH_ACCESS_TOKEN= + # Let's Encrypt account email (required for production operations) CERTBOT_EMAIL=admin@example.com diff --git a/deploy/docker-compose.yml b/deploy/docker-compose.yml index 95ee5c4..0ddb060 100644 --- a/deploy/docker-compose.yml +++ b/deploy/docker-compose.yml @@ -94,6 +94,12 @@ services: AUTH_RATE_LIMIT_EMAIL_HMAC_SECRET: ${AUTH_RATE_LIMIT_EMAIL_HMAC_SECRET} AUTH_RATE_LIMIT_TRUSTED_PROXIES: ${AUTH_RATE_LIMIT_TRUSTED_PROXIES:-127.0.0.1,::1} MANAGEMENT_HEALTH_MAIL_ENABLED: ${MANAGEMENT_HEALTH_MAIL_ENABLED:-false} + NOTIFICATION_PUSH_ENABLED: ${NOTIFICATION_PUSH_ENABLED:-false} + NOTIFICATION_PUSH_PROVIDER: ${NOTIFICATION_PUSH_PROVIDER:-expo} + NOTIFICATION_PUSH_CONNECT_TIMEOUT_SECONDS: ${NOTIFICATION_PUSH_CONNECT_TIMEOUT_SECONDS:-5} + NOTIFICATION_PUSH_READ_TIMEOUT_SECONDS: ${NOTIFICATION_PUSH_READ_TIMEOUT_SECONDS:-10} + EXPO_PUSH_API_URL: ${EXPO_PUSH_API_URL:-https://exp.host/--/api/v2/push/send} + EXPO_PUSH_ACCESS_TOKEN: ${EXPO_PUSH_ACCESS_TOKEN:-} CLOVA_OCR_API_URL: ${CLOVA_OCR_API_URL} CLOVA_OCR_SECRET_KEY: ${CLOVA_OCR_SECRET_KEY} PAPAGO_CLIENT_ID: ${PAPAGO_CLIENT_ID} diff --git a/docs/env.md b/docs/env.md index f329167..c4f8ef6 100644 --- a/docs/env.md +++ b/docs/env.md @@ -57,6 +57,15 @@ - `AUTH_RATE_LIMIT_LOGIN_LIMIT`: `/api/v1/auth/login` 윈도우 내 허용 횟수 (기본값: `5`) - `AUTH_RATE_LIMIT_LOGIN_WINDOW_SECONDS`: `/api/v1/auth/login` 윈도우 길이(초) (기본값: `60`) +## Push Notification + +- `NOTIFICATION_PUSH_ENABLED`: 실제 외부 푸시 발송 여부. 기본값은 `false`이며, 서버 보관함 알림 생성은 이 값과 무관하게 유지됩니다. +- `NOTIFICATION_PUSH_PROVIDER`: 푸시 발송 provider. 현재 지원값은 `expo`입니다. +- `NOTIFICATION_PUSH_CONNECT_TIMEOUT_SECONDS`: Expo Push API 연결 타임아웃(초) +- `NOTIFICATION_PUSH_READ_TIMEOUT_SECONDS`: Expo Push API 응답 타임아웃(초) +- `EXPO_PUSH_API_URL`: Expo Push API 발송 엔드포인트. 기본값은 `https://exp.host/--/api/v2/push/send` +- `EXPO_PUSH_ACCESS_TOKEN`: Expo push security가 활성화된 프로젝트에서 사용하는 access token. 비어 있으면 Authorization 헤더를 보내지 않습니다. + ## AWS Credential - Local/dev: AWS CLI profile 또는 환경변수 자격증명 사용 diff --git a/src/main/java/com/gachi/be/domain/notification/entity/NotificationDeliveryLog.java b/src/main/java/com/gachi/be/domain/notification/entity/NotificationDeliveryLog.java index a49e919..6e7801b 100644 --- a/src/main/java/com/gachi/be/domain/notification/entity/NotificationDeliveryLog.java +++ b/src/main/java/com/gachi/be/domain/notification/entity/NotificationDeliveryLog.java @@ -43,6 +43,9 @@ public class NotificationDeliveryLog { @Column(nullable = false, length = 20) private NotificationDeliveryStatus status; + @Column(nullable = false, length = 20) + private String provider; + @Column(name = "provider_message_id", length = 255) private String providerMessageId; @@ -63,11 +66,13 @@ public NotificationDeliveryLog( Notification notification, PushDeviceToken pushDeviceToken, NotificationDeliveryStatus status, + String provider, String providerMessageId, String failureReason) { this.notification = notification; this.pushDeviceToken = pushDeviceToken; this.status = status; + this.provider = provider != null ? provider : "UNKNOWN"; this.providerMessageId = providerMessageId; this.failureReason = failureReason; } diff --git a/src/main/java/com/gachi/be/domain/notification/repository/NotificationDeliveryLogRepository.java b/src/main/java/com/gachi/be/domain/notification/repository/NotificationDeliveryLogRepository.java index f47f985..7025913 100644 --- a/src/main/java/com/gachi/be/domain/notification/repository/NotificationDeliveryLogRepository.java +++ b/src/main/java/com/gachi/be/domain/notification/repository/NotificationDeliveryLogRepository.java @@ -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 {} + extends JpaRepository { + List findAllByNotificationIdOrderByAttemptedAtAsc(Long notificationId); +} diff --git a/src/main/java/com/gachi/be/domain/notification/service/ExpoPushNotificationClient.java b/src/main/java/com/gachi/be/domain/notification/service/ExpoPushNotificationClient.java new file mode 100644 index 0000000..f62789b --- /dev/null +++ b/src/main/java/com/gachi/be/domain/notification/service/ExpoPushNotificationClient.java @@ -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 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 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 data) {} +} diff --git a/src/main/java/com/gachi/be/domain/notification/service/NotificationCreatedEvent.java b/src/main/java/com/gachi/be/domain/notification/service/NotificationCreatedEvent.java new file mode 100644 index 0000000..34d3c42 --- /dev/null +++ b/src/main/java/com/gachi/be/domain/notification/service/NotificationCreatedEvent.java @@ -0,0 +1,4 @@ +package com.gachi.be.domain.notification.service; + +/** 알림 보관함 저장 커밋 이후 외부 푸시 발송을 시작하기 위한 도메인 이벤트. */ +public record NotificationCreatedEvent(Long notificationId, Long userId) {} diff --git a/src/main/java/com/gachi/be/domain/notification/service/NotificationPushDispatcher.java b/src/main/java/com/gachi/be/domain/notification/service/NotificationPushDispatcher.java new file mode 100644 index 0000000..163401e --- /dev/null +++ b/src/main/java/com/gachi/be/domain/notification/service/NotificationPushDispatcher.java @@ -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> 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 tokens = + pushDeviceTokenRepository.findAllByUserIdAndEnabledTrueAndDeletedAtIsNull(targetUserId); + if (tokens.isEmpty()) { + saveSkipped(notification, null, "활성 푸시 토큰이 없습니다."); + return; + } + + Map payload = deserializePayload(notification.getPayloadJson()); + for (PushDeviceToken token : tokens) { + dispatchToToken(notification, token, payload); + } + } + + private void dispatchToToken( + Notification notification, PushDeviceToken token, Map 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 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); + } + } +} diff --git a/src/main/java/com/gachi/be/domain/notification/service/NotificationService.java b/src/main/java/com/gachi/be/domain/notification/service/NotificationService.java index 3ae6e16..77f70c3 100644 --- a/src/main/java/com/gachi/be/domain/notification/service/NotificationService.java +++ b/src/main/java/com/gachi/be/domain/notification/service/NotificationService.java @@ -28,6 +28,7 @@ import java.util.List; import java.util.Map; import lombok.RequiredArgsConstructor; +import org.springframework.context.ApplicationEventPublisher; import org.springframework.dao.DataIntegrityViolationException; import org.springframework.data.domain.PageRequest; import org.springframework.stereotype.Service; @@ -46,6 +47,7 @@ public class NotificationService { private final PushDeviceTokenRepository pushDeviceTokenRepository; private final NotificationDeliveryLogRepository notificationDeliveryLogRepository; private final ObjectMapper objectMapper; + private final ApplicationEventPublisher eventPublisher; @Transactional(readOnly = true) public NotificationListResponse getNotifications( @@ -171,7 +173,9 @@ public Notification createNotification(Long userId, NotificationCreateCommand co .build(); try { - return notificationRepository.save(notification); + Notification saved = notificationRepository.save(notification); + eventPublisher.publishEvent(new NotificationCreatedEvent(saved.getId(), saved.getUserId())); + return saved; } catch (DataIntegrityViolationException e) { // 동시에 같은 알림을 생성해도 사용자에게 중복 노출되지 않도록 DB unique 제약을 한 번 더 신뢰한다. if (StringUtils.hasText(dedupeKey)) { @@ -199,6 +203,7 @@ public void recordDeliveryResult(NotificationDeliveryResultCommand command) { NotificationDeliveryLog.builder() .notification(notification) .pushDeviceToken(pushDeviceToken) + .provider("MANUAL") .status( command.status() != null ? command.status() : NotificationDeliveryStatus.PENDING) .providerMessageId(normalizeOptional(command.providerMessageId())) diff --git a/src/main/java/com/gachi/be/domain/notification/service/PushNotificationClient.java b/src/main/java/com/gachi/be/domain/notification/service/PushNotificationClient.java new file mode 100644 index 0000000..3f70757 --- /dev/null +++ b/src/main/java/com/gachi/be/domain/notification/service/PushNotificationClient.java @@ -0,0 +1,30 @@ +package com.gachi.be.domain.notification.service; + +import com.gachi.be.domain.notification.entity.Notification; +import com.gachi.be.domain.notification.entity.PushDeviceToken; +import java.util.Map; + +/** 외부 푸시 provider별 발송 구현체가 맞춰야 하는 공통 계약. */ +public interface PushNotificationClient { + + /** + * 푸시 제공자 식별자를 반환한다. + * + * @return delivery log provider 컬럼에 저장할 식별자. 예: {@code EXPO}, {@code FCM} + */ + String providerName(); + + /** + * 단일 디바이스 토큰으로 푸시 알림을 발송한다. + * + *

구현체는 provider 응답을 {@link PushSendResult}로 변환해야 하며, 호출자가 delivery log를 남길 수 있도록 복구 가능한 발송 실패는 + * 예외 대신 실패 결과로 반환하는 것을 기본 계약으로 한다. + * + * @param notification 발송할 알림 엔티티 + * @param pushDeviceToken 대상 디바이스 토큰 + * @param payload 앱에서 알림 클릭 후 라우팅 등에 사용할 추가 데이터 + * @return 성공/실패, provider 메시지 ID, 토큰 무효화 여부를 포함한 발송 결과 + */ + PushSendResult send( + Notification notification, PushDeviceToken pushDeviceToken, Map payload); +} diff --git a/src/main/java/com/gachi/be/domain/notification/service/PushSendResult.java b/src/main/java/com/gachi/be/domain/notification/service/PushSendResult.java new file mode 100644 index 0000000..26bb3f9 --- /dev/null +++ b/src/main/java/com/gachi/be/domain/notification/service/PushSendResult.java @@ -0,0 +1,14 @@ +package com.gachi.be.domain.notification.service; + +/** 외부 푸시 provider 발송 결과를 delivery log 정책에 맞게 표현한다. */ +public record PushSendResult( + boolean success, String providerMessageId, String failureReason, boolean invalidToken) { + + public static PushSendResult sent(String providerMessageId) { + return new PushSendResult(true, providerMessageId, null, false); + } + + public static PushSendResult failed(String failureReason, boolean invalidToken) { + return new PushSendResult(false, null, failureReason, invalidToken); + } +} diff --git a/src/main/java/com/gachi/be/global/config/external/ExternalApiConfig.java b/src/main/java/com/gachi/be/global/config/external/ExternalApiConfig.java index 640808a..3bd0d53 100644 --- a/src/main/java/com/gachi/be/global/config/external/ExternalApiConfig.java +++ b/src/main/java/com/gachi/be/global/config/external/ExternalApiConfig.java @@ -11,6 +11,7 @@ @EnableConfigurationProperties({ ClovaOcrProperties.class, PapagoProperties.class, - AiServerProperties.class + AiServerProperties.class, + NotificationPushProperties.class }) public class ExternalApiConfig {} diff --git a/src/main/java/com/gachi/be/global/config/external/NotificationPushProperties.java b/src/main/java/com/gachi/be/global/config/external/NotificationPushProperties.java new file mode 100644 index 0000000..b989aef --- /dev/null +++ b/src/main/java/com/gachi/be/global/config/external/NotificationPushProperties.java @@ -0,0 +1,27 @@ +package com.gachi.be.global.config.external; + +import jakarta.validation.constraints.Positive; +import lombok.Getter; +import lombok.Setter; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.validation.annotation.Validated; + +@Getter +@Setter +@Validated +@ConfigurationProperties(prefix = "app.notification.push") +public class NotificationPushProperties { + + private boolean enabled = false; + private String provider = "expo"; + @Positive private int connectTimeoutSeconds = 5; + @Positive private int readTimeoutSeconds = 10; + private Expo expo = new Expo(); + + @Getter + @Setter + public static class Expo { + private String apiUrl = "https://exp.host/--/api/v2/push/send"; + private String accessToken; + } +} diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 9641aef..4cab34e 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -69,3 +69,12 @@ app: base-url: ${AI_SERVER_BASE_URL:http://localhost:8000} connect-timeout-seconds: ${AI_SERVER_CONNECT_TIMEOUT_SECONDS:10} read-timeout-seconds: ${AI_SERVER_READ_TIMEOUT_SECONDS:120} + notification: + push: + enabled: ${NOTIFICATION_PUSH_ENABLED:false} + provider: ${NOTIFICATION_PUSH_PROVIDER:expo} + connect-timeout-seconds: ${NOTIFICATION_PUSH_CONNECT_TIMEOUT_SECONDS:5} + read-timeout-seconds: ${NOTIFICATION_PUSH_READ_TIMEOUT_SECONDS:10} + expo: + api-url: ${EXPO_PUSH_API_URL:https://exp.host/--/api/v2/push/send} + access-token: ${EXPO_PUSH_ACCESS_TOKEN:} diff --git a/src/main/resources/db/migration/V15__notification_delivery_provider.sql b/src/main/resources/db/migration/V15__notification_delivery_provider.sql new file mode 100644 index 0000000..e8c6fc3 --- /dev/null +++ b/src/main/resources/db/migration/V15__notification_delivery_provider.sql @@ -0,0 +1,5 @@ +ALTER TABLE notification_delivery_logs + ADD COLUMN IF NOT EXISTS provider VARCHAR(20) NOT NULL DEFAULT 'UNKNOWN'; + +CREATE INDEX IF NOT EXISTS idx_notification_delivery_logs_provider + ON notification_delivery_logs (provider, attempted_at DESC); diff --git a/src/test/java/com/gachi/be/domain/notification/service/NotificationPushDispatcherIntegrationTest.java b/src/test/java/com/gachi/be/domain/notification/service/NotificationPushDispatcherIntegrationTest.java new file mode 100644 index 0000000..8f32365 --- /dev/null +++ b/src/test/java/com/gachi/be/domain/notification/service/NotificationPushDispatcherIntegrationTest.java @@ -0,0 +1,158 @@ +package com.gachi.be.domain.notification.service; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.gachi.be.domain.notification.entity.Notification; +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.NotificationType; +import com.gachi.be.domain.notification.entity.enums.PushPlatform; +import com.gachi.be.domain.notification.repository.NotificationDeliveryLogRepository; +import com.gachi.be.domain.notification.repository.PushDeviceTokenRepository; +import com.gachi.be.domain.user.entity.User; +import com.gachi.be.domain.user.entity.enums.UserStatus; +import com.gachi.be.domain.user.repository.UserRepository; +import java.time.OffsetDateTime; +import java.util.Map; +import java.util.concurrent.atomic.AtomicInteger; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.context.TestConfiguration; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Primary; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.TestPropertySource; + +@SpringBootTest +@ActiveProfiles("test") +@TestPropertySource(properties = "app.notification.push.enabled=true") +class NotificationPushDispatcherIntegrationTest { + private static final AtomicInteger PHONE_SEQUENCE = new AtomicInteger(9000); + + @Autowired private UserRepository userRepository; + @Autowired private NotificationService notificationService; + @Autowired private PushDeviceTokenRepository pushDeviceTokenRepository; + @Autowired private NotificationDeliveryLogRepository deliveryLogRepository; + @Autowired private CapturingPushNotificationClient pushNotificationClient; + + @BeforeEach + void setUp() { + pushNotificationClient.reset(); + } + + @Test + void dispatchSendsPushAndRecordsDeliveryLog() { + User user = createActiveUser("push_success"); + registerToken(user, "ExpoPushToken[success]"); + Notification notification = createNotification(user, "push:success"); + + var logs = + deliveryLogRepository.findAllByNotificationIdOrderByAttemptedAtAsc(notification.getId()); + assertThat(logs).hasSize(1); + assertThat(logs.get(0).getStatus()).isEqualTo(NotificationDeliveryStatus.SENT); + assertThat(logs.get(0).getProvider()).isEqualTo("TEST"); + assertThat(logs.get(0).getProviderMessageId()).isEqualTo("ticket-1"); + assertThat(pushNotificationClient.sendCount).isEqualTo(1); + } + + @Test + void dispatchSkipsWhenUserDisabledNotification() { + User user = createActiveUser("push_disabled"); + user.updateNotificationEnabled(false); + userRepository.saveAndFlush(user); + registerToken(user, "ExpoPushToken[disabled]"); + Notification notification = createNotification(user, "push:disabled"); + + var logs = + deliveryLogRepository.findAllByNotificationIdOrderByAttemptedAtAsc(notification.getId()); + assertThat(logs).hasSize(1); + assertThat(logs.get(0).getStatus()).isEqualTo(NotificationDeliveryStatus.SKIPPED); + assertThat(logs.get(0).getProvider()).isEqualTo("EXPO"); + assertThat(pushNotificationClient.sendCount).isZero(); + } + + @Test + void dispatchDisablesInvalidToken() { + User user = createActiveUser("push_invalid"); + PushDeviceToken token = registerToken(user, "ExpoPushToken[invalid]"); + pushNotificationClient.nextResult = PushSendResult.failed("Expo token is not registered", true); + Notification notification = createNotification(user, "push:invalid"); + + var logs = + deliveryLogRepository.findAllByNotificationIdOrderByAttemptedAtAsc(notification.getId()); + assertThat(logs).hasSize(1); + assertThat(logs.get(0).getStatus()).isEqualTo(NotificationDeliveryStatus.FAILED); + assertThat(pushDeviceTokenRepository.findById(token.getId())) + .get() + .extracting("enabled") + .isEqualTo(false); + } + + private Notification createNotification(User user, String dedupeKey) { + return notificationService.createNotification( + user.getId(), + new NotificationCreateCommand( + NotificationType.SYSTEM, "title", "body", Map.of("targetId", 1), dedupeKey)); + } + + private PushDeviceToken registerToken(User user, String token) { + notificationService.registerPushToken( + user.getId(), + new com.gachi.be.domain.notification.dto.request.PushTokenRegisterRequest( + PushPlatform.EXPO, token, "device-" + user.getId(), "1.0.0")); + var tokens = + pushDeviceTokenRepository.findAllByUserIdAndEnabledTrueAndDeletedAtIsNull(user.getId()); + assertThat(tokens).hasSize(1); + return tokens.get(0); + } + + private User createActiveUser(String postfix) { + OffsetDateTime now = OffsetDateTime.now(); + return userRepository.saveAndFlush( + User.builder() + .name("parent-" + postfix) + .email(postfix + "@gachi.com") + .loginId("login_" + postfix) + .passwordHash("encoded-password") + .phoneNumber("0109999" + String.format("%04d", PHONE_SEQUENCE.getAndIncrement())) + .status(UserStatus.ACTIVE) + .emailVerifiedAt(now) + .consentAgreedAt(now) + .consentVersion("2026-04-v1") + .passwordUpdatedAt(now) + .build()); + } + + @TestConfiguration + static class PushClientConfig { + @Bean + @Primary + CapturingPushNotificationClient capturingPushNotificationClient() { + return new CapturingPushNotificationClient(); + } + } + + static class CapturingPushNotificationClient implements PushNotificationClient { + private PushSendResult nextResult = PushSendResult.sent("ticket-1"); + private int sendCount; + + @Override + public String providerName() { + return "TEST"; + } + + @Override + public PushSendResult send( + Notification notification, PushDeviceToken pushDeviceToken, Map payload) { + sendCount++; + return nextResult; + } + + void reset() { + nextResult = PushSendResult.sent("ticket-1"); + sendCount = 0; + } + } +}