Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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=
7 changes: 7 additions & 0 deletions deploy/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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
6 changes: 6 additions & 0 deletions deploy/docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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}
Expand Down
9 changes: 9 additions & 0 deletions docs/env.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 또는 환경변수 자격증명 사용
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand All @@ -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;
}
Expand Down
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);
}
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) {}
}
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) {}
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)
Comment thread
coderabbitai[bot] marked this conversation as resolved.
.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);
}
}
}
Loading
Loading