From 0712cfc12809e845a04370a560d1ed7f8ccdc5ab Mon Sep 17 00:00:00 2001 From: 1000hyehyang Date: Mon, 9 Feb 2026 15:13:15 +0900 Subject: [PATCH 1/5] =?UTF-8?q?feat(#218):=20FCM=20=ED=91=B8=EC=8B=9C=20?= =?UTF-8?q?=EC=95=8C=EB=A6=BC=20=EB=B0=8F=20=EC=9D=B4=EB=A9=94=EC=9D=BC=20?= =?UTF-8?q?=EC=95=8C=EB=A6=BC=20=EA=B8=B0=EB=B3=B8=20=EC=84=A4=EA=B3=84=20?= =?UTF-8?q?=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 5 + build.gradle | 6 + docker-compose.yaml | 2 + .../application/event/AutoConfirmedEvent.java | 12 ++ .../event/CampaignCompletedEvent.java | 12 ++ .../event/SettlementReadyEvent.java | 13 ++ .../event/NotificationEventListener.java | 126 +++++++++++++++++ .../application/service/FcmTokenService.java | 64 +++++++++ .../NotificationDeliveryProcessor.java | 106 ++++++++++++++ .../notification/domain/entity/FcmToken.java | 53 +++++++ .../domain/entity/NotificationDelivery.java | 55 ++++++-- .../domain/repository/FcmTokenRepository.java | 19 +++ .../NotificationDeliveryRepository.java | 21 ++- .../exception/NotificationErrorCode.java | 7 +- .../infrastructure/config/FirebaseConfig.java | 74 ++++++++++ .../sender/EmailNotificationSender.java | 97 +++++++++++++ .../sender/FcmNotificationSender.java | 131 ++++++++++++++++++ .../sender/NotificationChannelSender.java | 18 +++ .../sender/PermanentSendFailureException.java | 16 +++ .../worker/NotificationDeliveryWorker.java | 83 +++++++++++ .../controller/FcmTokenController.java | 48 +++++++ .../dto/request/FcmTokenRegisterRequest.java | 15 ++ .../dto/request/FcmTokenRemoveRequest.java | 10 ++ .../presentation/swagger/FcmTokenSwagger.java | 51 +++++++ src/main/resources/application-dev.yml | 24 ++++ src/main/resources/application-prod.yml | 24 ++++ 26 files changed, 1081 insertions(+), 11 deletions(-) create mode 100644 src/main/java/com/example/RealMatch/business/application/event/AutoConfirmedEvent.java create mode 100644 src/main/java/com/example/RealMatch/business/application/event/CampaignCompletedEvent.java create mode 100644 src/main/java/com/example/RealMatch/business/application/event/SettlementReadyEvent.java create mode 100644 src/main/java/com/example/RealMatch/notification/application/service/FcmTokenService.java create mode 100644 src/main/java/com/example/RealMatch/notification/application/service/NotificationDeliveryProcessor.java create mode 100644 src/main/java/com/example/RealMatch/notification/domain/entity/FcmToken.java create mode 100644 src/main/java/com/example/RealMatch/notification/domain/repository/FcmTokenRepository.java create mode 100644 src/main/java/com/example/RealMatch/notification/infrastructure/config/FirebaseConfig.java create mode 100644 src/main/java/com/example/RealMatch/notification/infrastructure/sender/EmailNotificationSender.java create mode 100644 src/main/java/com/example/RealMatch/notification/infrastructure/sender/FcmNotificationSender.java create mode 100644 src/main/java/com/example/RealMatch/notification/infrastructure/sender/NotificationChannelSender.java create mode 100644 src/main/java/com/example/RealMatch/notification/infrastructure/sender/PermanentSendFailureException.java create mode 100644 src/main/java/com/example/RealMatch/notification/infrastructure/worker/NotificationDeliveryWorker.java create mode 100644 src/main/java/com/example/RealMatch/notification/presentation/controller/FcmTokenController.java create mode 100644 src/main/java/com/example/RealMatch/notification/presentation/dto/request/FcmTokenRegisterRequest.java create mode 100644 src/main/java/com/example/RealMatch/notification/presentation/dto/request/FcmTokenRemoveRequest.java create mode 100644 src/main/java/com/example/RealMatch/notification/presentation/swagger/FcmTokenSwagger.java diff --git a/.gitignore b/.gitignore index 0b7303a9..c7437fdb 100644 --- a/.gitignore +++ b/.gitignore @@ -8,6 +8,11 @@ src/test/resources/application.yml .env .env.* +# Firebase 서비스 계정 키 +config/firebase-service-account.json +**/firebase-service-account.json +**/*firebase-adminsdk*.json + ### Gradle ### .gradle/ build/ diff --git a/build.gradle b/build.gradle index da8dc333..2dfae9a6 100644 --- a/build.gradle +++ b/build.gradle @@ -83,6 +83,12 @@ dependencies { // Spring Retry implementation 'org.springframework.retry:spring-retry' implementation 'org.springframework:spring-aspects' + + // Spring Mail + implementation 'org.springframework.boot:spring-boot-starter-mail' + + // Firebase Admin SDK (FCM 푸시 알림) + implementation 'com.google.firebase:firebase-admin:9.2.0' } tasks.named('test') { diff --git a/docker-compose.yaml b/docker-compose.yaml index 182060bb..a3f07fc2 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -58,6 +58,8 @@ services: - rabbitmq env_file: - .env + volumes: + - ./config/firebase-service-account.json:/app/config/firebase-service-account.json:ro networks: - realmatch-network diff --git a/src/main/java/com/example/RealMatch/business/application/event/AutoConfirmedEvent.java b/src/main/java/com/example/RealMatch/business/application/event/AutoConfirmedEvent.java new file mode 100644 index 00000000..207914a0 --- /dev/null +++ b/src/main/java/com/example/RealMatch/business/application/event/AutoConfirmedEvent.java @@ -0,0 +1,12 @@ +package com.example.RealMatch.business.application.event; + +/** + * 캠페인이 자동으로 확정되었을 때 발행되는 이벤트 + */ +public record AutoConfirmedEvent( + Long campaignId, + Long brandUserId, + Long creatorUserId, + String campaignName +) { +} diff --git a/src/main/java/com/example/RealMatch/business/application/event/CampaignCompletedEvent.java b/src/main/java/com/example/RealMatch/business/application/event/CampaignCompletedEvent.java new file mode 100644 index 00000000..d3123707 --- /dev/null +++ b/src/main/java/com/example/RealMatch/business/application/event/CampaignCompletedEvent.java @@ -0,0 +1,12 @@ +package com.example.RealMatch.business.application.event; + +/** + * 캠페인이 완료되었을 때 발행되는 이벤트 + */ +public record CampaignCompletedEvent( + Long campaignId, + Long brandUserId, + Long creatorUserId, + String campaignName +) { +} diff --git a/src/main/java/com/example/RealMatch/business/application/event/SettlementReadyEvent.java b/src/main/java/com/example/RealMatch/business/application/event/SettlementReadyEvent.java new file mode 100644 index 00000000..a02eeea6 --- /dev/null +++ b/src/main/java/com/example/RealMatch/business/application/event/SettlementReadyEvent.java @@ -0,0 +1,13 @@ +package com.example.RealMatch.business.application.event; + +/** + * 정산이 가능한 상태가 되었을 때 발행되는 이벤트 + */ +public record SettlementReadyEvent( + Long campaignId, + Long creatorUserId, + Long brandUserId, + String campaignName, + Long settlementAmount +) { +} diff --git a/src/main/java/com/example/RealMatch/notification/application/event/NotificationEventListener.java b/src/main/java/com/example/RealMatch/notification/application/event/NotificationEventListener.java index a7d4aa16..50157407 100644 --- a/src/main/java/com/example/RealMatch/notification/application/event/NotificationEventListener.java +++ b/src/main/java/com/example/RealMatch/notification/application/event/NotificationEventListener.java @@ -8,9 +8,12 @@ import com.example.RealMatch.brand.domain.entity.Brand; import com.example.RealMatch.brand.domain.repository.BrandRepository; +import com.example.RealMatch.business.application.event.AutoConfirmedEvent; import com.example.RealMatch.business.application.event.CampaignApplySentEvent; +import com.example.RealMatch.business.application.event.CampaignCompletedEvent; import com.example.RealMatch.business.application.event.CampaignProposalSentEvent; import com.example.RealMatch.business.application.event.CampaignProposalStatusChangedEvent; +import com.example.RealMatch.business.application.event.SettlementReadyEvent; import com.example.RealMatch.business.domain.enums.ProposalDirection; import com.example.RealMatch.business.domain.enums.ProposalStatus; import com.example.RealMatch.notification.application.dto.CreateNotificationCommand; @@ -222,6 +225,129 @@ public void handleCampaignApplySent(CampaignApplySentEvent event) { } } + // ==================== 데모데이 이후용: CampaignCompletedEvent ==================== + + /** + * CampaignCompletedEvent 구독. + * 크리에이터에게 CAMPAIGN_COMPLETED 알림 생성. + * (데모데이 이후 해당 플로우에서 이벤트 발행만 추가하면 동작) + */ + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) + public void handleCampaignCompleted(CampaignCompletedEvent event) { + if (event == null) { + LOG.warn("[Notification] Invalid CampaignCompletedEvent: event is null"); + return; + } + + try { + String eventId = String.format("CAMPAIGN_COMPLETED:%d", event.campaignId()); + String brandName = findBrandNameByUserId(event.brandUserId()); + + MessageTemplate template = messageTemplateService.createCampaignCompletedMessage(brandName); + + CreateNotificationCommand command = CreateNotificationCommand.builder() + .eventId(eventId) + .userId(event.creatorUserId()) + .kind(NotificationKind.CAMPAIGN_COMPLETED) + .title(template.title()) + .body(template.body()) + .referenceType(ReferenceType.CAMPAIGN) + .referenceId(String.valueOf(event.campaignId())) + .campaignId(event.campaignId()) + .build(); + + notificationService.create(command); + + LOG.info("[Notification] Created CAMPAIGN_COMPLETED. eventId={}, campaignId={}, userId={}", + eventId, event.campaignId(), event.creatorUserId()); + } catch (Exception e) { + LOG.error("[Notification] Failed to create CAMPAIGN_COMPLETED. campaignId={}, userId={}", + event.campaignId(), event.creatorUserId(), e); + } + } + + // ==================== 데모데이 이후용: SettlementReadyEvent ==================== + + /** + * SettlementReadyEvent 구독. + * 크리에이터에게 SETTLEMENT_READY 알림 생성. + * (데모데이 이후 해당 플로우에서 이벤트 발행만 추가하면 동작) + */ + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) + public void handleSettlementReady(SettlementReadyEvent event) { + if (event == null) { + LOG.warn("[Notification] Invalid SettlementReadyEvent: event is null"); + return; + } + + try { + String eventId = String.format("SETTLEMENT_READY:%d", event.campaignId()); + String brandName = findBrandNameByUserId(event.brandUserId()); + + MessageTemplate template = messageTemplateService.createSettlementReadyMessage(brandName); + + CreateNotificationCommand command = CreateNotificationCommand.builder() + .eventId(eventId) + .userId(event.creatorUserId()) + .kind(NotificationKind.SETTLEMENT_READY) + .title(template.title()) + .body(template.body()) + .referenceType(ReferenceType.CAMPAIGN) + .referenceId(String.valueOf(event.campaignId())) + .campaignId(event.campaignId()) + .build(); + + notificationService.create(command); + + LOG.info("[Notification] Created SETTLEMENT_READY. eventId={}, campaignId={}, userId={}", + eventId, event.campaignId(), event.creatorUserId()); + } catch (Exception e) { + LOG.error("[Notification] Failed to create SETTLEMENT_READY. campaignId={}, userId={}", + event.campaignId(), event.creatorUserId(), e); + } + } + + // ==================== 데모데이 이후용: AutoConfirmedEvent ==================== + + /** + * AutoConfirmedEvent 구독. + * 브랜드에게 AUTO_CONFIRMED 알림 생성. + * (데모데이 이후 해당 플로우에서 이벤트 발행만 추가하면 동작) + */ + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) + public void handleAutoConfirmed(AutoConfirmedEvent event) { + if (event == null) { + LOG.warn("[Notification] Invalid AutoConfirmedEvent: event is null"); + return; + } + + try { + String eventId = String.format("AUTO_CONFIRMED:%d", event.campaignId()); + String brandName = findBrandNameByUserId(event.brandUserId()); + + MessageTemplate template = messageTemplateService.createAutoConfirmedMessage(brandName); + + CreateNotificationCommand command = CreateNotificationCommand.builder() + .eventId(eventId) + .userId(event.brandUserId()) + .kind(NotificationKind.AUTO_CONFIRMED) + .title(template.title()) + .body(template.body()) + .referenceType(ReferenceType.CAMPAIGN) + .referenceId(String.valueOf(event.campaignId())) + .campaignId(event.campaignId()) + .build(); + + notificationService.create(command); + + LOG.info("[Notification] Created AUTO_CONFIRMED. eventId={}, campaignId={}, userId={}", + eventId, event.campaignId(), event.brandUserId()); + } catch (Exception e) { + LOG.error("[Notification] Failed to create AUTO_CONFIRMED. campaignId={}, userId={}", + event.campaignId(), event.brandUserId(), e); + } + } + // ==================== 공통 헬퍼 ==================== /** diff --git a/src/main/java/com/example/RealMatch/notification/application/service/FcmTokenService.java b/src/main/java/com/example/RealMatch/notification/application/service/FcmTokenService.java new file mode 100644 index 00000000..85322be6 --- /dev/null +++ b/src/main/java/com/example/RealMatch/notification/application/service/FcmTokenService.java @@ -0,0 +1,64 @@ +package com.example.RealMatch.notification.application.service; + +import java.util.Optional; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import com.example.RealMatch.notification.domain.entity.FcmToken; +import com.example.RealMatch.notification.domain.repository.FcmTokenRepository; + +import lombok.RequiredArgsConstructor; + +@Service +@RequiredArgsConstructor +@Transactional +public class FcmTokenService { + + private static final Logger LOG = LoggerFactory.getLogger(FcmTokenService.class); + + private final FcmTokenRepository fcmTokenRepository; + + /** + * FCM 토큰을 등록한다. + * 동일한 토큰이 이미 존재하면 소유자를 현재 유저로 재할당한다 (디바이스 로그아웃→재로그인 대응). + */ + public void registerToken(Long userId, String token, String deviceInfo) { + Optional existing = fcmTokenRepository.findByToken(token); + + if (existing.isPresent()) { + FcmToken fcmToken = existing.get(); + if (!fcmToken.getUserId().equals(userId)) { + fcmToken.reassignTo(userId); + LOG.info("[FCM] Token reassigned. token={}..., newUserId={}", token.substring(0, 10), userId); + } + return; + } + + FcmToken fcmToken = FcmToken.builder() + .userId(userId) + .token(token) + .deviceInfo(deviceInfo) + .build(); + fcmTokenRepository.save(fcmToken); + LOG.info("[FCM] Token registered. userId={}, deviceInfo={}", userId, deviceInfo); + } + + /** + * FCM 토큰을 삭제한다 (로그아웃 시 호출). + */ + public void removeToken(String token) { + fcmTokenRepository.deleteByToken(token); + LOG.info("[FCM] Token removed. token={}...", token.substring(0, Math.min(10, token.length()))); + } + + /** + * 유저의 모든 FCM 토큰을 삭제한다 (회원 탈퇴 시 호출). + */ + public void removeAllTokensByUserId(Long userId) { + fcmTokenRepository.deleteByUserId(userId); + LOG.info("[FCM] All tokens removed. userId={}", userId); + } +} diff --git a/src/main/java/com/example/RealMatch/notification/application/service/NotificationDeliveryProcessor.java b/src/main/java/com/example/RealMatch/notification/application/service/NotificationDeliveryProcessor.java new file mode 100644 index 00000000..8c3e3d87 --- /dev/null +++ b/src/main/java/com/example/RealMatch/notification/application/service/NotificationDeliveryProcessor.java @@ -0,0 +1,106 @@ +package com.example.RealMatch.notification.application.service; + +import java.util.EnumMap; +import java.util.List; +import java.util.Map; +import java.util.UUID; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Propagation; +import org.springframework.transaction.annotation.Transactional; + +import com.example.RealMatch.notification.domain.entity.Notification; +import com.example.RealMatch.notification.domain.entity.NotificationDelivery; +import com.example.RealMatch.notification.domain.entity.enums.DeliveryStatus; +import com.example.RealMatch.notification.domain.repository.NotificationDeliveryRepository; +import com.example.RealMatch.notification.domain.repository.NotificationRepository; +import com.example.RealMatch.notification.infrastructure.sender.NotificationChannelSender; +import com.example.RealMatch.notification.infrastructure.sender.PermanentSendFailureException; +import com.example.RealMatch.user.domain.entity.enums.NotificationChannel; + +/** + * 개별 NotificationDelivery 건의 발송을 처리하는 프로세서. + * 각 delivery를 독립 트랜잭션(REQUIRES_NEW)으로 처리하여 장애를 격리한다. + */ +@Service +public class NotificationDeliveryProcessor { + + private static final Logger LOG = LoggerFactory.getLogger(NotificationDeliveryProcessor.class); + + private final NotificationDeliveryRepository deliveryRepository; + private final NotificationRepository notificationRepository; + private final Map senderMap; + + public NotificationDeliveryProcessor( + NotificationDeliveryRepository deliveryRepository, + NotificationRepository notificationRepository, + List senders) { + this.deliveryRepository = deliveryRepository; + this.notificationRepository = notificationRepository; + + this.senderMap = new EnumMap<>(NotificationChannel.class); + for (NotificationChannelSender sender : senders) { + this.senderMap.put(sender.getChannel(), sender); + } + LOG.info("[DeliveryProcessor] Initialized with senders: {}", this.senderMap.keySet()); + } + + /** + * 단건 배달을 처리한다. + * REQUIRES_NEW 트랜잭션으로 실행되어 실패가 다른 건에 영향을 주지 않는다. + */ + @Transactional(propagation = Propagation.REQUIRES_NEW) + public void processDelivery(UUID deliveryId) { + // 1) 최신 상태로 다시 조회 (stale read 방지) + NotificationDelivery delivery = deliveryRepository.findById(deliveryId).orElse(null); + if (delivery == null) { + LOG.warn("[DeliveryProcessor] Delivery not found. deliveryId={}", deliveryId); + return; + } + if (delivery.getStatus() != DeliveryStatus.PENDING) { + LOG.debug("[DeliveryProcessor] Delivery already processed. deliveryId={}, status={}", + deliveryId, delivery.getStatus()); + return; + } + + // 2) IN_PROGRESS로 전환 (워커 점유) + delivery.markAsInProgress(); + deliveryRepository.saveAndFlush(delivery); + + // 3) 알림 원장 조회 + Notification notification = notificationRepository.findById(delivery.getNotificationId()).orElse(null); + if (notification == null) { + delivery.markAsPermanentlyFailed("Notification not found: " + delivery.getNotificationId()); + return; + } + + // 4) 채널별 발송기 조회 + NotificationChannelSender sender = senderMap.get(delivery.getChannel()); + if (sender == null || !sender.isAvailable()) { + delivery.recordFailure("No available sender for channel: " + delivery.getChannel()); + LOG.warn("[DeliveryProcessor] No sender for channel={}. deliveryId={}", + delivery.getChannel(), deliveryId); + return; + } + + // 5) 발송 시도 + try { + String providerMessageId = sender.send(notification); + delivery.markAsSent(providerMessageId); + LOG.debug("[DeliveryProcessor] Delivery sent. deliveryId={}, channel={}, providerId={}", + deliveryId, delivery.getChannel(), providerMessageId); + } catch (PermanentSendFailureException e) { + // 재시도 불가능 → 영구 실패 + delivery.markAsPermanentlyFailed(e.getMessage()); + LOG.warn("[DeliveryProcessor] Permanent failure. deliveryId={}, channel={}, reason={}", + deliveryId, delivery.getChannel(), e.getMessage()); + } catch (Exception e) { + // 일시적 실패 → 재시도 스케줄링 + delivery.recordFailure(e.getMessage()); + LOG.warn("[DeliveryProcessor] Transient failure. deliveryId={}, channel={}, attempt={}, reason={}", + deliveryId, delivery.getChannel(), delivery.getAttemptCount(), e.getMessage()); + } + } +} diff --git a/src/main/java/com/example/RealMatch/notification/domain/entity/FcmToken.java b/src/main/java/com/example/RealMatch/notification/domain/entity/FcmToken.java new file mode 100644 index 00000000..054177c3 --- /dev/null +++ b/src/main/java/com/example/RealMatch/notification/domain/entity/FcmToken.java @@ -0,0 +1,53 @@ +package com.example.RealMatch.notification.domain.entity; + +import com.example.RealMatch.global.common.BaseEntity; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Index; +import jakarta.persistence.Table; +import jakarta.persistence.UniqueConstraint; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Table(name = "fcm_token", + indexes = { + @Index(name = "idx_fcm_token_user", columnList = "user_id") + }, + uniqueConstraints = { + @UniqueConstraint(name = "uk_fcm_token_value", columnNames = "token") + }) +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class FcmToken extends BaseEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "user_id", nullable = false) + private Long userId; + + @Column(name = "token", nullable = false, length = 500) + private String token; + + @Column(name = "device_info", length = 255) + private String deviceInfo; + + @Builder + protected FcmToken(Long userId, String token, String deviceInfo) { + this.userId = userId; + this.token = token; + this.deviceInfo = deviceInfo; + } + + public void reassignTo(Long newUserId) { + this.userId = newUserId; + } +} diff --git a/src/main/java/com/example/RealMatch/notification/domain/entity/NotificationDelivery.java b/src/main/java/com/example/RealMatch/notification/domain/entity/NotificationDelivery.java index d1008985..3f68605c 100644 --- a/src/main/java/com/example/RealMatch/notification/domain/entity/NotificationDelivery.java +++ b/src/main/java/com/example/RealMatch/notification/domain/entity/NotificationDelivery.java @@ -24,12 +24,15 @@ @Entity @Table(name = "notification_delivery", indexes = { @Index(name = "idx_delivery_notification", columnList = "notification_id, channel"), - @Index(name = "idx_delivery_status", columnList = "status, attempted_at") + @Index(name = "idx_delivery_status_retry", columnList = "status, next_retry_at, created_at") }) @Getter @NoArgsConstructor(access = AccessLevel.PROTECTED) public class NotificationDelivery extends BaseEntity { + public static final int MAX_RETRY_COUNT = 5; + private static final long[] BACKOFF_MINUTES = {1, 5, 30, 120, 720}; + @Id @GeneratedValue(strategy = GenerationType.UUID) @Column(columnDefinition = "BINARY(16)") @@ -55,6 +58,9 @@ public class NotificationDelivery extends BaseEntity { @Column(name = "sent_at") private LocalDateTime sentAt; + @Column(name = "next_retry_at") + private LocalDateTime nextRetryAt; + @Column(name = "provider_message_id", length = 255) private String providerMessageId; @@ -75,21 +81,54 @@ protected NotificationDelivery(UUID notificationId, NotificationChannel channel, this.attemptCount = 0; } + /** 발송 성공 */ public void markAsSent(String providerMessageId) { this.status = DeliveryStatus.SENT; this.sentAt = LocalDateTime.now(); this.providerMessageId = providerMessageId; + this.nextRetryAt = null; } - public void markAsFailed(String failReason) { - this.status = DeliveryStatus.FAILED; - this.failReason = failReason; - this.attemptCount++; - } - + /** 발송 진행 중 (워커가 점유) */ public void markAsInProgress() { this.status = DeliveryStatus.IN_PROGRESS; - this.attemptCount++; this.attemptedAt = LocalDateTime.now(); } + + /** + * 발송 실패 기록 + 재시도 스케줄링. + * attemptCount < MAX_RETRY_COUNT → PENDING + nextRetryAt 설정 + * attemptCount ≥ MAX_RETRY_COUNT → FAILED (영구 보관) + */ + public void recordFailure(String failReason) { + this.failReason = truncate(failReason, 500); + this.attemptCount++; + + if (this.attemptCount >= MAX_RETRY_COUNT) { + this.status = DeliveryStatus.FAILED; + this.nextRetryAt = null; + } else { + this.status = DeliveryStatus.PENDING; + int backoffIndex = Math.min(this.attemptCount - 1, BACKOFF_MINUTES.length - 1); + this.nextRetryAt = LocalDateTime.now().plusMinutes(BACKOFF_MINUTES[backoffIndex]); + } + } + + /** 영구 실패 처리 */ + public void markAsPermanentlyFailed(String failReason) { + this.status = DeliveryStatus.FAILED; + this.failReason = truncate(failReason, 500); + this.nextRetryAt = null; + } + + public boolean isRetryable() { + return this.attemptCount < MAX_RETRY_COUNT; + } + + private static String truncate(String value, int maxLength) { + if (value == null) { + return null; + } + return value.length() <= maxLength ? value : value.substring(0, maxLength); + } } diff --git a/src/main/java/com/example/RealMatch/notification/domain/repository/FcmTokenRepository.java b/src/main/java/com/example/RealMatch/notification/domain/repository/FcmTokenRepository.java new file mode 100644 index 00000000..5b173459 --- /dev/null +++ b/src/main/java/com/example/RealMatch/notification/domain/repository/FcmTokenRepository.java @@ -0,0 +1,19 @@ +package com.example.RealMatch.notification.domain.repository; + +import java.util.List; +import java.util.Optional; + +import org.springframework.data.jpa.repository.JpaRepository; + +import com.example.RealMatch.notification.domain.entity.FcmToken; + +public interface FcmTokenRepository extends JpaRepository { + + List findByUserId(Long userId); + + Optional findByToken(String token); + + void deleteByToken(String token); + + void deleteByUserId(Long userId); +} diff --git a/src/main/java/com/example/RealMatch/notification/domain/repository/NotificationDeliveryRepository.java b/src/main/java/com/example/RealMatch/notification/domain/repository/NotificationDeliveryRepository.java index 97dcb63d..f9745817 100644 --- a/src/main/java/com/example/RealMatch/notification/domain/repository/NotificationDeliveryRepository.java +++ b/src/main/java/com/example/RealMatch/notification/domain/repository/NotificationDeliveryRepository.java @@ -1,10 +1,13 @@ package com.example.RealMatch.notification.domain.repository; +import java.time.LocalDateTime; import java.util.List; import java.util.Optional; import java.util.UUID; +import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; @@ -16,6 +19,20 @@ public interface NotificationDeliveryRepository extends JpaRepository findByNotificationIdAndChannel(UUID notificationId, NotificationChannel channel); - @Query("SELECT nd FROM NotificationDelivery nd WHERE nd.status = :status ORDER BY nd.attemptedAt ASC") - List findByStatusOrderByAttemptedAtAsc(@Param("status") DeliveryStatus status); + @Query("SELECT nd FROM NotificationDelivery nd " + + "WHERE nd.status = :status " + + "AND (nd.nextRetryAt IS NULL OR nd.nextRetryAt <= :now) " + + "ORDER BY nd.createdAt ASC") + List findRetryableDeliveries( + @Param("status") DeliveryStatus status, + @Param("now") LocalDateTime now, + Pageable pageable); + + @Modifying(clearAutomatically = true) + @Query("UPDATE NotificationDelivery nd SET nd.status = :newStatus, nd.nextRetryAt = null " + + "WHERE nd.status = :stuckStatus AND nd.attemptedAt <= :stuckBefore") + int recoverStuckDeliveries( + @Param("stuckStatus") DeliveryStatus stuckStatus, + @Param("newStatus") DeliveryStatus newStatus, + @Param("stuckBefore") LocalDateTime stuckBefore); } diff --git a/src/main/java/com/example/RealMatch/notification/exception/NotificationErrorCode.java b/src/main/java/com/example/RealMatch/notification/exception/NotificationErrorCode.java index 95830cf5..deca083a 100644 --- a/src/main/java/com/example/RealMatch/notification/exception/NotificationErrorCode.java +++ b/src/main/java/com/example/RealMatch/notification/exception/NotificationErrorCode.java @@ -13,7 +13,12 @@ public enum NotificationErrorCode implements BaseErrorCode { NOTIFICATION_INVALID_FILTER(HttpStatus.BAD_REQUEST, "NOTIFICATION_400_1", "유효하지 않은 필터 값입니다."), NOTIFICATION_NOT_FOUND(HttpStatus.NOT_FOUND, "NOTIFICATION_404_1", "알림을 찾을 수 없습니다."), - NOTIFICATION_FORBIDDEN(HttpStatus.FORBIDDEN, "NOTIFICATION_403_1", "해당 알림에 대한 권한이 없습니다."); + NOTIFICATION_FORBIDDEN(HttpStatus.FORBIDDEN, "NOTIFICATION_403_1", "해당 알림에 대한 권한이 없습니다."), + + FCM_TOKEN_INVALID(HttpStatus.BAD_REQUEST, "FCM_400_1", "유효하지 않은 FCM 토큰입니다."), + FCM_SEND_FAILED(HttpStatus.INTERNAL_SERVER_ERROR, "FCM_500_1", "FCM 푸시 발송에 실패했습니다."), + EMAIL_SEND_FAILED(HttpStatus.INTERNAL_SERVER_ERROR, "EMAIL_500_1", "이메일 발송에 실패했습니다."), + DELIVERY_PROCESSING_FAILED(HttpStatus.INTERNAL_SERVER_ERROR, "DELIVERY_500_1", "알림 배달 처리에 실패했습니다."); private final HttpStatus status; private final String code; diff --git a/src/main/java/com/example/RealMatch/notification/infrastructure/config/FirebaseConfig.java b/src/main/java/com/example/RealMatch/notification/infrastructure/config/FirebaseConfig.java new file mode 100644 index 00000000..6d25185b --- /dev/null +++ b/src/main/java/com/example/RealMatch/notification/infrastructure/config/FirebaseConfig.java @@ -0,0 +1,74 @@ +package com.example.RealMatch.notification.infrastructure.config; + +import java.io.FileInputStream; +import java.io.IOException; +import java.io.InputStream; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +import com.google.auth.oauth2.GoogleCredentials; +import com.google.firebase.FirebaseApp; +import com.google.firebase.FirebaseOptions; +import com.google.firebase.messaging.FirebaseMessaging; + +import jakarta.annotation.PostConstruct; + +/** + * Firebase Admin SDK 초기화 설정 + * FCM 서비스 계정 키 파일이 없으면 FCM 기능은 비활성화된다. + */ +@Configuration +public class FirebaseConfig { + + private static final Logger LOG = LoggerFactory.getLogger(FirebaseConfig.class); + + @Value("${fcm.credentials-path:}") + private String credentialsPath; + + @Value("${fcm.project-id:}") + private String projectId; + + private boolean firebaseInitialized = false; + + @PostConstruct + public void init() { + if (credentialsPath == null || credentialsPath.isBlank()) { + LOG.warn("[FCM] Firebase credentials path is not configured. FCM push will be disabled."); + return; + } + + if (FirebaseApp.getApps().isEmpty()) { + try (InputStream serviceAccount = new FileInputStream(credentialsPath)) { + FirebaseOptions options = FirebaseOptions.builder() + .setCredentials(GoogleCredentials.fromStream(serviceAccount)) + .setProjectId(projectId) + .build(); + FirebaseApp.initializeApp(options); + firebaseInitialized = true; + LOG.info("[FCM] Firebase Admin SDK initialized. projectId={}", projectId); + } catch (IOException e) { + LOG.error("[FCM] Failed to initialize Firebase Admin SDK. FCM push will be disabled.", e); + } + } else { + firebaseInitialized = true; + LOG.info("[FCM] Firebase Admin SDK already initialized."); + } + } + + @Bean + public FirebaseMessaging firebaseMessaging() { + if (!firebaseInitialized || FirebaseApp.getApps().isEmpty()) { + LOG.warn("[FCM] FirebaseMessaging bean is null (Firebase not initialized)."); + return null; + } + return FirebaseMessaging.getInstance(); + } + + public boolean isFirebaseInitialized() { + return firebaseInitialized; + } +} diff --git a/src/main/java/com/example/RealMatch/notification/infrastructure/sender/EmailNotificationSender.java b/src/main/java/com/example/RealMatch/notification/infrastructure/sender/EmailNotificationSender.java new file mode 100644 index 00000000..a37a5405 --- /dev/null +++ b/src/main/java/com/example/RealMatch/notification/infrastructure/sender/EmailNotificationSender.java @@ -0,0 +1,97 @@ +package com.example.RealMatch.notification.infrastructure.sender; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.mail.javamail.JavaMailSender; +import org.springframework.mail.javamail.MimeMessageHelper; +import org.springframework.stereotype.Component; + +import com.example.RealMatch.notification.domain.entity.Notification; +import com.example.RealMatch.user.domain.entity.User; +import com.example.RealMatch.user.domain.entity.enums.NotificationChannel; +import com.example.RealMatch.user.domain.repository.UserRepository; + +import jakarta.mail.internet.MimeMessage; +import lombok.RequiredArgsConstructor; + +@Component +@RequiredArgsConstructor +public class EmailNotificationSender implements NotificationChannelSender { + + private static final Logger LOG = LoggerFactory.getLogger(EmailNotificationSender.class); + + private final JavaMailSender mailSender; + private final UserRepository userRepository; + + @Value("${spring.mail.from:realmatch.lab@gmail.com}") + private String fromAddress; + + @Value("${app.frontend.url}") + private String frontendUrl; + + @Override + public NotificationChannel getChannel() { + return NotificationChannel.EMAIL; + } + + @Override + public boolean isAvailable() { + return mailSender != null; + } + + @Override + public String send(Notification notification) throws Exception { + User user = userRepository.findById(notification.getUserId()) + .orElseThrow(() -> new PermanentSendFailureException( + "User not found for email. userId=" + notification.getUserId())); + + String email = user.getEmail(); + if (email == null || email.isBlank()) { + throw new PermanentSendFailureException( + "User has no email address. userId=" + notification.getUserId()); + } + + MimeMessage mimeMessage = mailSender.createMimeMessage(); + MimeMessageHelper helper = new MimeMessageHelper(mimeMessage, true, "UTF-8"); + + helper.setFrom(fromAddress); + helper.setTo(email); + helper.setSubject("[RealMatch] " + notification.getTitle()); + helper.setText(buildHtmlContent(notification), true); + + mailSender.send(mimeMessage); + + LOG.info("[Email] Sent. userId={}, to={}, notificationId={}", + notification.getUserId(), email, notification.getId()); + return "email-sent-to:" + email; + } + + private String buildHtmlContent(Notification notification) { + String notificationUrl = frontendUrl + "/notifications"; + + return """ + + + + +
+

%s

+

%s

+ +
+

+ 이 메일은 RealMatch에서 발송되었습니다. +

+ + + """.formatted(notification.getTitle(), notification.getBody(), notificationUrl); + } +} diff --git a/src/main/java/com/example/RealMatch/notification/infrastructure/sender/FcmNotificationSender.java b/src/main/java/com/example/RealMatch/notification/infrastructure/sender/FcmNotificationSender.java new file mode 100644 index 00000000..fea861f0 --- /dev/null +++ b/src/main/java/com/example/RealMatch/notification/infrastructure/sender/FcmNotificationSender.java @@ -0,0 +1,131 @@ +package com.example.RealMatch.notification.infrastructure.sender; + +import java.util.List; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.lang.Nullable; +import org.springframework.stereotype.Component; + +import com.example.RealMatch.notification.domain.entity.FcmToken; +import com.example.RealMatch.notification.domain.entity.Notification; +import com.example.RealMatch.notification.domain.repository.FcmTokenRepository; +import com.example.RealMatch.user.domain.entity.enums.NotificationChannel; +import com.google.firebase.messaging.FirebaseMessaging; +import com.google.firebase.messaging.FirebaseMessagingException; +import com.google.firebase.messaging.Message; +import com.google.firebase.messaging.MessagingErrorCode; + +/** + * FCM(Firebase Cloud Messaging) 기반 웹 푸시 발송기. + * 사용자의 모든 디바이스 토큰에 푸시를 발송한다. + */ +@Component +public class FcmNotificationSender implements NotificationChannelSender { + + private static final Logger LOG = LoggerFactory.getLogger(FcmNotificationSender.class); + + @Nullable + private final FirebaseMessaging firebaseMessaging; + private final FcmTokenRepository fcmTokenRepository; + + public FcmNotificationSender( + @Autowired(required = false) @Nullable FirebaseMessaging firebaseMessaging, + FcmTokenRepository fcmTokenRepository) { + this.firebaseMessaging = firebaseMessaging; + this.fcmTokenRepository = fcmTokenRepository; + } + + @Override + public NotificationChannel getChannel() { + return NotificationChannel.PUSH; + } + + @Override + public boolean isAvailable() { + return firebaseMessaging != null; + } + + @Override + public String send(Notification notification) throws Exception { + if (firebaseMessaging == null) { + throw new PermanentSendFailureException("Firebase is not initialized. FCM push disabled."); + } + + List tokens = fcmTokenRepository.findByUserId(notification.getUserId()); + if (tokens.isEmpty()) { + throw new PermanentSendFailureException( + "No FCM tokens found for userId=" + notification.getUserId()); + } + + StringBuilder messageIds = new StringBuilder(); + int successCount = 0; + int failCount = 0; + + for (FcmToken fcmToken : tokens) { + try { + String messageId = sendToToken(notification, fcmToken.getToken()); + if (messageId != null) { + if (!messageIds.isEmpty()) { + messageIds.append(","); + } + messageIds.append(messageId); + successCount++; + } + } catch (FirebaseMessagingException e) { + handleFcmError(fcmToken, e); + failCount++; + } + } + + if (successCount == 0 && failCount > 0) { + throw new PermanentSendFailureException( + "All FCM sends failed. tokens=" + tokens.size()); + } + + LOG.info("[FCM] Push sent. userId={}, success={}, fail={}, notificationId={}", + notification.getUserId(), successCount, failCount, notification.getId()); + return messageIds.toString(); + } + + private String sendToToken(Notification notification, String token) throws FirebaseMessagingException { + com.google.firebase.messaging.Notification fcmNotification = + com.google.firebase.messaging.Notification.builder() + .setTitle(notification.getTitle()) + .setBody(notification.getBody()) + .build(); + + Message message = Message.builder() + .setNotification(fcmNotification) + .setToken(token) + .putData("notificationId", notification.getId().toString()) + .putData("kind", notification.getKind().name()) + .putData("referenceType", + notification.getReferenceType() != null ? notification.getReferenceType().name() : "") + .putData("referenceId", + notification.getReferenceId() != null ? notification.getReferenceId() : "") + .build(); + + return firebaseMessaging.send(message); + } + + /** + * FCM 에러 처리. UNREGISTERED/INVALID_ARGUMENT 토큰은 자동 삭제한다. + */ + private void handleFcmError(FcmToken fcmToken, FirebaseMessagingException e) { + MessagingErrorCode errorCode = e.getMessagingErrorCode(); + + if (errorCode == MessagingErrorCode.UNREGISTERED + || errorCode == MessagingErrorCode.INVALID_ARGUMENT) { + LOG.warn("[FCM] Invalid token removed. userId={}, token={}..., error={}", + fcmToken.getUserId(), + fcmToken.getToken().substring(0, Math.min(10, fcmToken.getToken().length())), + errorCode); + fcmTokenRepository.delete(fcmToken); + } else { + LOG.error("[FCM] Send failed. userId={}, error={}, message={}", + fcmToken.getUserId(), errorCode, e.getMessage()); + } + } +} diff --git a/src/main/java/com/example/RealMatch/notification/infrastructure/sender/NotificationChannelSender.java b/src/main/java/com/example/RealMatch/notification/infrastructure/sender/NotificationChannelSender.java new file mode 100644 index 00000000..201ef80c --- /dev/null +++ b/src/main/java/com/example/RealMatch/notification/infrastructure/sender/NotificationChannelSender.java @@ -0,0 +1,18 @@ +package com.example.RealMatch.notification.infrastructure.sender; + +import com.example.RealMatch.notification.domain.entity.Notification; +import com.example.RealMatch.user.domain.entity.enums.NotificationChannel; + +/** + * 채널별 알림 발송 전략 인터페이스. + * 각 구현체(FCM, Email)는 이 인터페이스를 구현하고, + * NotificationDeliveryProcessor에서 채널에 맞는 구현체를 선택하여 발송한다. + */ +public interface NotificationChannelSender { + + NotificationChannel getChannel(); + + String send(Notification notification) throws Exception; + + boolean isAvailable(); +} diff --git a/src/main/java/com/example/RealMatch/notification/infrastructure/sender/PermanentSendFailureException.java b/src/main/java/com/example/RealMatch/notification/infrastructure/sender/PermanentSendFailureException.java new file mode 100644 index 00000000..0b1d05e0 --- /dev/null +++ b/src/main/java/com/example/RealMatch/notification/infrastructure/sender/PermanentSendFailureException.java @@ -0,0 +1,16 @@ +package com.example.RealMatch.notification.infrastructure.sender; + +/** + * 재시도가 불가능한 영구 발송 실패를 나타내는 예외. + * 예: 유효하지 않은 FCM 토큰, 존재하지 않는 이메일 주소 등. + */ +public class PermanentSendFailureException extends RuntimeException { + + public PermanentSendFailureException(String message) { + super(message); + } + + public PermanentSendFailureException(String message, Throwable cause) { + super(message, cause); + } +} diff --git a/src/main/java/com/example/RealMatch/notification/infrastructure/worker/NotificationDeliveryWorker.java b/src/main/java/com/example/RealMatch/notification/infrastructure/worker/NotificationDeliveryWorker.java new file mode 100644 index 00000000..4e86c2f0 --- /dev/null +++ b/src/main/java/com/example/RealMatch/notification/infrastructure/worker/NotificationDeliveryWorker.java @@ -0,0 +1,83 @@ +package com.example.RealMatch.notification.infrastructure.worker; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.UUID; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.data.domain.PageRequest; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +import com.example.RealMatch.notification.application.service.NotificationDeliveryProcessor; +import com.example.RealMatch.notification.domain.entity.NotificationDelivery; +import com.example.RealMatch.notification.domain.entity.enums.DeliveryStatus; +import com.example.RealMatch.notification.domain.repository.NotificationDeliveryRepository; + +import lombok.RequiredArgsConstructor; + +/** + * PENDING 상태의 NotificationDelivery를 주기적으로 가져와 발송하는 스케줄러 워커. + * + *

설계 원칙: + *

    + *
  • 외부 I/O(FCM, Email)는 이 워커에서만 수행한다 (리스너에서 직접 호출 금지)
  • + *
  • 각 delivery는 독립 트랜잭션으로 처리하여 장애를 격리한다
  • + *
  • 재시도는 exponential backoff(1m, 5m, 30m, 2h, 12h) 기반
  • + *
  • 워커 비정상 종료 시 IN_PROGRESS 상태 건은 별도 복구 로직으로 처리한다
  • + *
+ */ +@Component +@RequiredArgsConstructor +public class NotificationDeliveryWorker { + + private static final Logger LOG = LoggerFactory.getLogger(NotificationDeliveryWorker.class); + + private static final int BATCH_SIZE = 50; + + private static final int STUCK_THRESHOLD_MINUTES = 10; + + private final NotificationDeliveryRepository deliveryRepository; + private final NotificationDeliveryProcessor deliveryProcessor; + + @Scheduled(fixedDelay = 30_000, initialDelay = 10_000) + public void processPendingDeliveries() { + List pendingBatch = deliveryRepository.findRetryableDeliveries( + DeliveryStatus.PENDING, + LocalDateTime.now(), + PageRequest.of(0, BATCH_SIZE)); + + if (pendingBatch.isEmpty()) { + return; + } + + LOG.info("[DeliveryWorker] Processing {} pending deliveries.", pendingBatch.size()); + + for (NotificationDelivery delivery : pendingBatch) { + processWithFaultIsolation(delivery.getId()); + } + } + + @Transactional + @Scheduled(fixedDelay = 300_000, initialDelay = 60_000) + public void recoverStuckDeliveries() { + LocalDateTime stuckBefore = LocalDateTime.now().minusMinutes(STUCK_THRESHOLD_MINUTES); + int recovered = deliveryRepository.recoverStuckDeliveries( + DeliveryStatus.IN_PROGRESS, DeliveryStatus.PENDING, stuckBefore); + if (recovered > 0) { + LOG.warn("[DeliveryWorker] Recovered {} stuck deliveries.", recovered); + } + } + + private void processWithFaultIsolation(UUID deliveryId) { + try { + deliveryProcessor.processDelivery(deliveryId); + } catch (Exception e) { + // 절대 상위로 전파하지 않는다 + LOG.error("[DeliveryWorker] Unexpected error processing delivery. deliveryId={}", + deliveryId, e); + } + } +} diff --git a/src/main/java/com/example/RealMatch/notification/presentation/controller/FcmTokenController.java b/src/main/java/com/example/RealMatch/notification/presentation/controller/FcmTokenController.java new file mode 100644 index 00000000..44c2c054 --- /dev/null +++ b/src/main/java/com/example/RealMatch/notification/presentation/controller/FcmTokenController.java @@ -0,0 +1,48 @@ +package com.example.RealMatch.notification.presentation.controller; + +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import com.example.RealMatch.global.config.jwt.CustomUserDetails; +import com.example.RealMatch.global.presentation.CustomResponse; +import com.example.RealMatch.notification.application.service.FcmTokenService; +import com.example.RealMatch.notification.presentation.dto.request.FcmTokenRegisterRequest; +import com.example.RealMatch.notification.presentation.dto.request.FcmTokenRemoveRequest; +import com.example.RealMatch.notification.presentation.swagger.FcmTokenSwagger; + +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; + +@Tag(name = "FCM Token", description = "FCM 디바이스 토큰 관리 API") +@RestController +@RequestMapping("/api/v1/fcm/tokens") +@RequiredArgsConstructor +public class FcmTokenController implements FcmTokenSwagger { + + private final FcmTokenService fcmTokenService; + + @Override + @PostMapping + public CustomResponse registerToken( + @AuthenticationPrincipal CustomUserDetails userDetails, + @Valid @RequestBody FcmTokenRegisterRequest request + ) { + fcmTokenService.registerToken(userDetails.getUserId(), request.token(), request.deviceInfo()); + return CustomResponse.ok(null); + } + + @Override + @DeleteMapping + public CustomResponse removeToken( + @AuthenticationPrincipal CustomUserDetails userDetails, + @Valid @RequestBody FcmTokenRemoveRequest request + ) { + fcmTokenService.removeToken(request.token()); + return CustomResponse.ok(null); + } +} diff --git a/src/main/java/com/example/RealMatch/notification/presentation/dto/request/FcmTokenRegisterRequest.java b/src/main/java/com/example/RealMatch/notification/presentation/dto/request/FcmTokenRegisterRequest.java new file mode 100644 index 00000000..d5d42d8b --- /dev/null +++ b/src/main/java/com/example/RealMatch/notification/presentation/dto/request/FcmTokenRegisterRequest.java @@ -0,0 +1,15 @@ +package com.example.RealMatch.notification.presentation.dto.request; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; + +public record FcmTokenRegisterRequest( + + @NotBlank(message = "FCM 토큰은 필수입니다.") + @Size(max = 500, message = "FCM 토큰은 500자 이내여야 합니다.") + String token, + + @Size(max = 255, message = "디바이스 정보는 255자 이내여야 합니다.") + String deviceInfo +) { +} diff --git a/src/main/java/com/example/RealMatch/notification/presentation/dto/request/FcmTokenRemoveRequest.java b/src/main/java/com/example/RealMatch/notification/presentation/dto/request/FcmTokenRemoveRequest.java new file mode 100644 index 00000000..2b8f6868 --- /dev/null +++ b/src/main/java/com/example/RealMatch/notification/presentation/dto/request/FcmTokenRemoveRequest.java @@ -0,0 +1,10 @@ +package com.example.RealMatch.notification.presentation.dto.request; + +import jakarta.validation.constraints.NotBlank; + +public record FcmTokenRemoveRequest( + + @NotBlank(message = "FCM 토큰은 필수입니다.") + String token +) { +} diff --git a/src/main/java/com/example/RealMatch/notification/presentation/swagger/FcmTokenSwagger.java b/src/main/java/com/example/RealMatch/notification/presentation/swagger/FcmTokenSwagger.java new file mode 100644 index 00000000..aba7e12f --- /dev/null +++ b/src/main/java/com/example/RealMatch/notification/presentation/swagger/FcmTokenSwagger.java @@ -0,0 +1,51 @@ +package com.example.RealMatch.notification.presentation.swagger; + +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.RequestBody; + +import com.example.RealMatch.global.config.jwt.CustomUserDetails; +import com.example.RealMatch.global.presentation.CustomResponse; +import com.example.RealMatch.notification.presentation.dto.request.FcmTokenRegisterRequest; +import com.example.RealMatch.notification.presentation.dto.request.FcmTokenRemoveRequest; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; + +public interface FcmTokenSwagger { + + @Operation( + summary = "FCM 토큰 등록 API by 여채현", + description = """ + 사용자의 FCM 디바이스 토큰을 등록합니다. + + - 동일 토큰이 이미 존재하면 소유자를 현재 유저로 재할당합니다. + - 사용자당 여러 디바이스 토큰이 허용됩니다. + - 웹 브라우저 푸시 알림을 위해 로그인 시 호출합니다. + """ + ) + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "토큰 등록 성공") + }) + CustomResponse registerToken( + @Parameter(hidden = true) @AuthenticationPrincipal CustomUserDetails userDetails, + @RequestBody FcmTokenRegisterRequest request + ); + + @Operation( + summary = "FCM 토큰 삭제 API by 여채현", + description = """ + 사용자의 FCM 디바이스 토큰을 삭제합니다. + + - 로그아웃 시 호출하여 해당 디바이스에 푸시가 가지 않도록 합니다. + """ + ) + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "토큰 삭제 성공") + }) + CustomResponse removeToken( + @Parameter(hidden = true) @AuthenticationPrincipal CustomUserDetails userDetails, + @RequestBody FcmTokenRemoveRequest request + ); +} diff --git a/src/main/resources/application-dev.yml b/src/main/resources/application-dev.yml index 18db9da9..074e4bd0 100644 --- a/src/main/resources/application-dev.yml +++ b/src/main/resources/application-dev.yml @@ -101,6 +101,24 @@ spring: port: ${REDIS_PORT} password: ${REDIS_PASSWORD} + mail: + host: ${MAIL_HOST:smtp.gmail.com} + port: ${MAIL_PORT:587} + username: ${MAIL_USERNAME} + password: ${MAIL_PASSWORD} + properties: + mail: + smtp: + auth: true + starttls: + enable: true + required: true + connectiontimeout: 5000 + timeout: 5000 + writetimeout: 5000 + default-encoding: UTF-8 + from: ${MAIL_FROM:realmatch.lab@gmail.com} + rabbitmq: host: ${RABBITMQ_URL} port: ${RABBITMQ_MQ_PORT} @@ -123,6 +141,8 @@ management: show-details: always app: + frontend: + url: ${FRONT_DOMAIN_URL} s3: bucket-name: ${S3_BUCKET_NAME:realmatch-s3} region: ${S3_REGION:us-east-2} @@ -132,3 +152,7 @@ app: key-prefix: ${S3_KEY_PREFIX:attachment} access-key-id: ${AWS_ACCESS_KEY_ID:} secret-access-key: ${AWS_SECRET_ACCESS_KEY:} + +fcm: + credentials-path: ${FCM_CREDENTIALS_PATH:} + project-id: ${FCM_PROJECT_ID:} diff --git a/src/main/resources/application-prod.yml b/src/main/resources/application-prod.yml index 71928935..9f59c3b0 100644 --- a/src/main/resources/application-prod.yml +++ b/src/main/resources/application-prod.yml @@ -104,6 +104,24 @@ spring: port: ${REDIS_PORT} password: ${REDIS_PASSWORD} + mail: + host: ${MAIL_HOST:smtp.gmail.com} + port: ${MAIL_PORT:587} + username: ${MAIL_USERNAME} + password: ${MAIL_PASSWORD} + properties: + mail: + smtp: + auth: true + starttls: + enable: true + required: true + connectiontimeout: 5000 + timeout: 5000 + writetimeout: 5000 + default-encoding: UTF-8 + from: ${MAIL_FROM:realmatch.lab@gmail.com} + rabbitmq: host: ${RABBITMQ_URL} port: ${RABBITMQ_MQ_PORT} @@ -133,6 +151,8 @@ management: enabled: false app: + frontend: + url: ${FRONT_DOMAIN_URL:https://realmatch.co.kr} s3: bucket-name: ${S3_BUCKET_NAME:realmatch-s3} region: ${S3_REGION:us-east-2} @@ -142,3 +162,7 @@ app: key-prefix: ${S3_KEY_PREFIX:attachment} access-key-id: ${AWS_ACCESS_KEY_ID:} secret-access-key: ${AWS_SECRET_ACCESS_KEY:} + +fcm: + credentials-path: ${FCM_CREDENTIALS_PATH:} + project-id: ${FCM_PROJECT_ID:} From e93073abd0184a8785a0137a4255ab5366f0acc7 Mon Sep 17 00:00:00 2001 From: 1000hyehyang Date: Mon, 9 Feb 2026 15:44:25 +0900 Subject: [PATCH 2/5] =?UTF-8?q?chore(#218):=20pr-check=20=EB=8D=94?= =?UTF-8?q?=EB=AF=B8=20=ED=99=98=EA=B2=BD=EB=B3=80=EC=88=98=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/pr-check.yml | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/.github/workflows/pr-check.yml b/.github/workflows/pr-check.yml index 9e23f081..69dd5920 100644 --- a/.github/workflows/pr-check.yml +++ b/.github/workflows/pr-check.yml @@ -93,8 +93,18 @@ jobs: AWS_ACCESS_KEY_ID: AWSACCESSKEYIDISSECRET AWS_SECRET_ACCESS_KEY: AWSSECRETACCESSKEYISSECRET # FRONT + FRONT_DOMAIN_URL: http://localhost:3000 FRONT_DOMAIN_URL_V2: http://localhost:3000 FRONT_DOMAIN_URL_LOCAL: http://localhost:3000 + # FCM + FCM_CREDENTIALS_PATH: "" + FCM_PROJECT_ID: "" + # SMTP + MAIL_HOST: smtp.gmail.com + MAIL_PORT: 587 + MAIL_USERNAME: test@example.com + MAIL_PASSWORD: test_password + MAIL_FROM: test@example.com steps: - name: Checkout code From 139748d31f73e65d67f814814b757a32dd308fb1 Mon Sep 17 00:00:00 2001 From: 1000hyehyang Date: Mon, 9 Feb 2026 15:46:20 +0900 Subject: [PATCH 3/5] =?UTF-8?q?fix(#218):=20=EC=BD=94=EB=93=9C=EB=A6=AC?= =?UTF-8?q?=EB=B7=B0=20=EB=B0=98=EC=98=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../notification/application/service/FcmTokenService.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/com/example/RealMatch/notification/application/service/FcmTokenService.java b/src/main/java/com/example/RealMatch/notification/application/service/FcmTokenService.java index 85322be6..c483e662 100644 --- a/src/main/java/com/example/RealMatch/notification/application/service/FcmTokenService.java +++ b/src/main/java/com/example/RealMatch/notification/application/service/FcmTokenService.java @@ -32,7 +32,7 @@ public void registerToken(Long userId, String token, String deviceInfo) { FcmToken fcmToken = existing.get(); if (!fcmToken.getUserId().equals(userId)) { fcmToken.reassignTo(userId); - LOG.info("[FCM] Token reassigned. token={}..., newUserId={}", token.substring(0, 10), userId); + LOG.info("[FCM] Token reassigned. token={}..., newUserId={}", token.substring(0, Math.min(token.length(), 10)), userId); } return; } From f61e3a3bc501edac95d4ad3ca4728da700a15e54 Mon Sep 17 00:00:00 2001 From: 1000hyehyang Date: Mon, 9 Feb 2026 15:59:40 +0900 Subject: [PATCH 4/5] =?UTF-8?q?fix(#218):=20HTML=20Injection=20=EB=B0=A9?= =?UTF-8?q?=EC=A7=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../sender/EmailNotificationSender.java | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/src/main/java/com/example/RealMatch/notification/infrastructure/sender/EmailNotificationSender.java b/src/main/java/com/example/RealMatch/notification/infrastructure/sender/EmailNotificationSender.java index a37a5405..b3d9dd96 100644 --- a/src/main/java/com/example/RealMatch/notification/infrastructure/sender/EmailNotificationSender.java +++ b/src/main/java/com/example/RealMatch/notification/infrastructure/sender/EmailNotificationSender.java @@ -6,6 +6,7 @@ import org.springframework.mail.javamail.JavaMailSender; import org.springframework.mail.javamail.MimeMessageHelper; import org.springframework.stereotype.Component; +import org.springframework.web.util.HtmlUtils; import com.example.RealMatch.notification.domain.entity.Notification; import com.example.RealMatch.user.domain.entity.User; @@ -69,20 +70,20 @@ public String send(Notification notification) throws Exception { private String buildHtmlContent(Notification notification) { String notificationUrl = frontendUrl + "/notifications"; + // HTML Injection 방지를 위해 사용자 입력값 이스케이프 처리 + String escapedTitle = HtmlUtils.htmlEscape(notification.getTitle()); + String escapedBody = HtmlUtils.htmlEscape(notification.getBody()); return """ - +

%s

%s

@@ -92,6 +93,6 @@ private String buildHtmlContent(Notification notification) {

- """.formatted(notification.getTitle(), notification.getBody(), notificationUrl); + """.formatted(escapedTitle, escapedBody, notificationUrl); } } From c4fdd0a78b709a362bf3f91dc16cba16c980469e Mon Sep 17 00:00:00 2001 From: 1000hyehyang Date: Mon, 9 Feb 2026 16:06:55 +0900 Subject: [PATCH 5/5] =?UTF-8?q?chore(#218):=20=EC=9D=B4=EB=A9=94=EC=9D=BC?= =?UTF-8?q?=20=EC=95=8C=EB=A6=BC=20=EB=B2=84=ED=8A=BC=20=EC=83=89=EC=83=81?= =?UTF-8?q?=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../infrastructure/sender/EmailNotificationSender.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/com/example/RealMatch/notification/infrastructure/sender/EmailNotificationSender.java b/src/main/java/com/example/RealMatch/notification/infrastructure/sender/EmailNotificationSender.java index b3d9dd96..2e77e1ac 100644 --- a/src/main/java/com/example/RealMatch/notification/infrastructure/sender/EmailNotificationSender.java +++ b/src/main/java/com/example/RealMatch/notification/infrastructure/sender/EmailNotificationSender.java @@ -83,7 +83,7 @@ private String buildHtmlContent(Notification notification) {

%s

%s