From 48a01d600b1c9da07027814d4c22d76d4670149d Mon Sep 17 00:00:00 2001 From: msk226 Date: Wed, 7 Jan 2026 21:27:35 +0900 Subject: [PATCH] =?UTF-8?q?[Feature]=20=EC=95=8C=EB=A6=BC=20=EA=B8=B0?= =?UTF-8?q?=EB=8A=A5=20=EC=84=A4=EA=B3=84=20-=20=ED=8A=B8=EB=9E=9C?= =?UTF-8?q?=EC=9E=AD=EC=85=98=20=EC=95=84=EC=9B=83=EB=B0=95=EC=8A=A4=20?= =?UTF-8?q?=ED=8C=A8=ED=84=B4=20=EB=8F=84=EC=9E=85=EC=9C=BC=EB=A1=9C=20?= =?UTF-8?q?=EB=B9=84=EB=8F=99=EA=B8=B0=20=EC=95=8C=EB=A6=BC=20=ED=8C=8C?= =?UTF-8?q?=EC=9D=B4=ED=94=84=EB=9D=BC=EC=9D=B8=20=EA=B5=AC=EC=B6=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../main/java/kr/spot/config/AsyncConfig.java | 11 ++ .../java/kr/spot/code/status/ErrorStatus.java | 8 + modules/notification-api/build.gradle | 6 + .../event/NotificationRequestedEvent.java | 71 ++++++++ modules/notification/build.gradle | 1 + .../dispatch/NoOpPushNotificationClient.java | 22 +++ .../NotificationDispatchScheduler.java | 64 +++++++ .../dispatch/NotificationSender.java | 96 +++++++++++ .../dispatch/PushNotificationClient.java | 18 ++ .../CleanupNotificationOnMemberWithdrawn.java | 2 +- .../event/NotificationEventListener.java | 120 +++++++++++++ .../StudyApplicationNotificationListener.java | 84 ++++----- .../recipient/RecipientResolver.java | 63 +++++++ .../template/NotificationContent.java | 8 + .../template/NotificationTemplate.java | 8 + .../NotificationTemplateRenderer.java | 98 +++++++++++ .../java/kr/spot/domain/Notification.java | 162 ++++++++++++++++-- .../spot/domain/enums/NotificationStatus.java | 9 + .../spot/domain/enums/NotificationType.java | 30 +++- .../main/java/kr/spot/domain/vo/Content.java | 26 --- .../kr/spot/domain/vo/NotificationTarget.java | 33 ---- .../jpa/NotificationRepository.java | 40 ++++- ...anupNotificationOnMemberWithdrawnTest.java | 36 ++-- ...dyApplicationNotificationListenerTest.java | 136 +++++++-------- .../kr/spot/ports/GetStudyMemberIdsPort.java | 18 ++ .../ports/GetStudyMemberIdsService.java | 32 ++++ settings.gradle | 1 + 27 files changed, 996 insertions(+), 207 deletions(-) create mode 100644 modules/notification-api/build.gradle create mode 100644 modules/notification-api/src/main/java/kr/spot/event/NotificationRequestedEvent.java create mode 100644 modules/notification/src/main/java/kr/spot/application/dispatch/NoOpPushNotificationClient.java create mode 100644 modules/notification/src/main/java/kr/spot/application/dispatch/NotificationDispatchScheduler.java create mode 100644 modules/notification/src/main/java/kr/spot/application/dispatch/NotificationSender.java create mode 100644 modules/notification/src/main/java/kr/spot/application/dispatch/PushNotificationClient.java create mode 100644 modules/notification/src/main/java/kr/spot/application/event/NotificationEventListener.java create mode 100644 modules/notification/src/main/java/kr/spot/application/recipient/RecipientResolver.java create mode 100644 modules/notification/src/main/java/kr/spot/application/template/NotificationContent.java create mode 100644 modules/notification/src/main/java/kr/spot/application/template/NotificationTemplate.java create mode 100644 modules/notification/src/main/java/kr/spot/application/template/NotificationTemplateRenderer.java create mode 100644 modules/notification/src/main/java/kr/spot/domain/enums/NotificationStatus.java delete mode 100644 modules/notification/src/main/java/kr/spot/domain/vo/Content.java delete mode 100644 modules/notification/src/main/java/kr/spot/domain/vo/NotificationTarget.java create mode 100644 modules/study-api/src/main/java/kr/spot/ports/GetStudyMemberIdsPort.java create mode 100644 modules/study/src/main/java/kr/spot/study/application/ports/GetStudyMemberIdsService.java diff --git a/app/config/src/main/java/kr/spot/config/AsyncConfig.java b/app/config/src/main/java/kr/spot/config/AsyncConfig.java index 61f8e78f..67139e42 100644 --- a/app/config/src/main/java/kr/spot/config/AsyncConfig.java +++ b/app/config/src/main/java/kr/spot/config/AsyncConfig.java @@ -20,4 +20,15 @@ public Executor imageUploadExecutor() { executor.initialize(); return executor; } + + @Bean(name = "notificationExecutor") + public Executor notificationExecutor() { + ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor(); + executor.setCorePoolSize(5); + executor.setMaxPoolSize(20); + executor.setQueueCapacity(100); + executor.setThreadNamePrefix("notification-"); + executor.initialize(); + return executor; + } } diff --git a/common/api/src/main/java/kr/spot/code/status/ErrorStatus.java b/common/api/src/main/java/kr/spot/code/status/ErrorStatus.java index a7c83e4c..2a1a320c 100644 --- a/common/api/src/main/java/kr/spot/code/status/ErrorStatus.java +++ b/common/api/src/main/java/kr/spot/code/status/ErrorStatus.java @@ -86,6 +86,14 @@ public enum ErrorStatus implements BaseErrorCode { _REVIEW_ACCESS_DENIED(403, "REVIEW403", "해당 스터디에 속하는 회고가 아닙니다."), _ALREADY_REACTED(400, "REVIEW4000", "이미 반응을 누른 회고입니다."), _REACTION_NOT_FOUND(404, "REVIEW4040", "반응을 찾을 수 없습니다."), + + // 알림 관련 + _NOTIFICATION_NOT_FOUND(404, "NOTIFICATION404", "알림을 찾을 수 없습니다."), + _INVALID_NOTIFICATION_TYPE(400, "NOTIFICATION4000", "유효하지 않은 알림 유형입니다."), + _NOTIFICATION_PAYLOAD_MISSING_MEMBER_ID(400, "NOTIFICATION4001", "알림 페이로드에 memberId가 필요합니다."), + _NOTIFICATION_PAYLOAD_MISSING_STUDY_ID(400, "NOTIFICATION4002", "알림 페이로드에 studyId가 필요합니다."), + _NOTIFICATION_TEMPLATE_NOT_FOUND(500, "NOTIFICATION5000", "알림 템플릿을 찾을 수 없습니다."), + _PUSH_NOTIFICATION_FAILED(500, "NOTIFICATION5001", "푸시 알림 발송에 실패했습니다."), ; private final int httpStatus; diff --git a/modules/notification-api/build.gradle b/modules/notification-api/build.gradle new file mode 100644 index 00000000..0c4770e1 --- /dev/null +++ b/modules/notification-api/build.gradle @@ -0,0 +1,6 @@ +plugins { + id "java" +} + +dependencies { +} diff --git a/modules/notification-api/src/main/java/kr/spot/event/NotificationRequestedEvent.java b/modules/notification-api/src/main/java/kr/spot/event/NotificationRequestedEvent.java new file mode 100644 index 00000000..4f7b48e8 --- /dev/null +++ b/modules/notification-api/src/main/java/kr/spot/event/NotificationRequestedEvent.java @@ -0,0 +1,71 @@ +package kr.spot.event; + +import java.time.LocalDateTime; +import java.util.Map; + +/** + * 알림 요청 이벤트. + * 각 도메인 서비스에서 이 이벤트를 발행하면, Notification 도메인에서 처리합니다. + * + * @param type 알림 유형 (문자열로 전달, Notification 도메인에서 NotificationType으로 변환) + * @param payload 알림 생성에 필요한 데이터 (템플릿 렌더링 및 수신자 결정에 사용) + * @param scheduledAt 발송 예정 시각 (null이면 즉시 발송) + * @param referenceId 연관 엔티티 ID (딥링크용) + * @param imageUrl 알림 이미지 URL (optional) + */ +public record NotificationRequestedEvent( + String type, + Map payload, + LocalDateTime scheduledAt, + Long referenceId, + String imageUrl +) { + + /** + * 즉시 발송 알림 생성 + */ + public static NotificationRequestedEvent immediate( + String type, + Map payload, + Long referenceId, + String imageUrl + ) { + return new NotificationRequestedEvent(type, payload, LocalDateTime.now(), referenceId, imageUrl); + } + + /** + * 즉시 발송 알림 생성 (이미지 없음) + */ + public static NotificationRequestedEvent immediate( + String type, + Map payload, + Long referenceId + ) { + return immediate(type, payload, referenceId, null); + } + + /** + * 예약 발송 알림 생성 + */ + public static NotificationRequestedEvent scheduled( + String type, + LocalDateTime scheduledAt, + Map payload, + Long referenceId, + String imageUrl + ) { + return new NotificationRequestedEvent(type, payload, scheduledAt, referenceId, imageUrl); + } + + /** + * 예약 발송 알림 생성 (이미지 없음) + */ + public static NotificationRequestedEvent scheduled( + String type, + LocalDateTime scheduledAt, + Map payload, + Long referenceId + ) { + return scheduled(type, scheduledAt, payload, referenceId, null); + } +} diff --git a/modules/notification/build.gradle b/modules/notification/build.gradle index 279ee047..87e6daef 100644 --- a/modules/notification/build.gradle +++ b/modules/notification/build.gradle @@ -9,6 +9,7 @@ dependencies { implementation project(":modules:shared") implementation project(":modules:study-api") + implementation project(":modules:notification-api") implementation "org.springframework.boot:spring-boot-starter-data-jpa" annotationProcessor "jakarta.annotation:jakarta.annotation-api" diff --git a/modules/notification/src/main/java/kr/spot/application/dispatch/NoOpPushNotificationClient.java b/modules/notification/src/main/java/kr/spot/application/dispatch/NoOpPushNotificationClient.java new file mode 100644 index 00000000..3331faad --- /dev/null +++ b/modules/notification/src/main/java/kr/spot/application/dispatch/NoOpPushNotificationClient.java @@ -0,0 +1,22 @@ +package kr.spot.application.dispatch; + +import kr.spot.domain.Notification; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; + +/** + * 푸시 알림을 실제로 발송하지 않는 더미 구현체. 개발/테스트 환경 또는 FCM 연동 전에 사용합니다. + */ +@Slf4j +@Component +public class NoOpPushNotificationClient implements PushNotificationClient { + + @Override + public void send(Notification notification) { + log.info("[NoOp] Push notification would be sent: memberId={}, type={}, title={}", + notification.getMemberId(), + notification.getType(), + notification.getTitle() + ); + } +} diff --git a/modules/notification/src/main/java/kr/spot/application/dispatch/NotificationDispatchScheduler.java b/modules/notification/src/main/java/kr/spot/application/dispatch/NotificationDispatchScheduler.java new file mode 100644 index 00000000..4e8e8dea --- /dev/null +++ b/modules/notification/src/main/java/kr/spot/application/dispatch/NotificationDispatchScheduler.java @@ -0,0 +1,64 @@ +package kr.spot.application.dispatch; + +import java.net.InetAddress; +import java.net.UnknownHostException; +import java.time.LocalDateTime; +import java.util.List; +import kr.spot.domain.Notification; +import kr.spot.domain.enums.NotificationStatus; +import kr.spot.infrastructure.jpa.NotificationRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +@Slf4j +@Component +@RequiredArgsConstructor +public class NotificationDispatchScheduler { + + private static final int BATCH_SIZE = 100; + + private final NotificationRepository notificationRepository; + private final NotificationSender notificationSender; + private final String serverId = resolveServerId(); + + private static String resolveServerId() { + try { + String hostname = InetAddress.getLocalHost().getHostName(); + return hostname.length() > 50 ? hostname.substring(0, 50) : hostname; + } catch (UnknownHostException e) { + return "server-" + System.currentTimeMillis(); + } + } + + /** + * 발송 대상 알림을 선점하고 비동기로 발송합니다. 10초마다 실행됩니다. + */ + @Scheduled(fixedDelay = 10_000) + @Transactional + public void dispatch() { + LocalDateTime now = LocalDateTime.now(); + + // 1. 발송 대상 선점 (FOR UPDATE SKIP LOCKED) + int pickedCount = notificationRepository.pickPendingNotifications( + serverId, now, BATCH_SIZE + ); + + if (pickedCount == 0) { + return; + } + + log.info("Picked {} notifications for dispatch", pickedCount); + + // 2. 선점한 알림 조회 + List notifications = notificationRepository + .findByPickedByAndDispatchStatus(serverId, NotificationStatus.PROCESSING); + + // 3. 비동기로 발송 처리 + for (Notification notification : notifications) { + notificationSender.sendAsync(notification.getId()); + } + } +} diff --git a/modules/notification/src/main/java/kr/spot/application/dispatch/NotificationSender.java b/modules/notification/src/main/java/kr/spot/application/dispatch/NotificationSender.java new file mode 100644 index 00000000..d7876ccf --- /dev/null +++ b/modules/notification/src/main/java/kr/spot/application/dispatch/NotificationSender.java @@ -0,0 +1,96 @@ +package kr.spot.application.dispatch; + +import java.time.Duration; +import java.time.LocalDateTime; +import kr.spot.domain.Notification; +import kr.spot.domain.enums.NotificationStatus; +import kr.spot.infrastructure.jpa.NotificationRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.scheduling.annotation.Async; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Propagation; +import org.springframework.transaction.annotation.Transactional; + +@Slf4j +@Component +@RequiredArgsConstructor +public class NotificationSender { + + private final PushNotificationClient pushClient; + private final NotificationRepository notificationRepository; + + @Async("notificationExecutor") + @Transactional(propagation = Propagation.REQUIRES_NEW) + public void sendAsync(long notificationId) { + Notification notification = notificationRepository.findById(notificationId) + .orElse(null); + + if (notification == null) { + log.warn("Notification not found: id={}", notificationId); + return; + } + + // 발송 전 상태 재확인 (다른 서버가 이미 처리했을 수 있음) + if (notification.getDispatchStatus() != NotificationStatus.PROCESSING) { + log.info("Notification {} already processed, status={}", + notificationId, notification.getDispatchStatus()); + return; + } + + try { + pushClient.send(notification); + + // 성공 + notification.markAsSent(LocalDateTime.now()); + notificationRepository.save(notification); + + log.debug("Notification sent successfully: id={}, memberId={}", + notificationId, notification.getMemberId()); + + } catch (Exception e) { + handleFailure(notification, e); + } + } + + private void handleFailure(Notification notification, Exception e) { + log.warn("Failed to send notification: id={}, error={}", + notification.getId(), e.getMessage()); + + if (notification.canRetry()) { + // 재시도 예약 (Exponential Backoff) + Duration backoff = calculateBackoff(notification.getRetryCount() + 1); + notification.scheduleRetry(LocalDateTime.now().plus(backoff)); + + log.info("Notification {} scheduled for retry #{} at {}", + notification.getId(), + notification.getRetryCount(), + notification.getNextRetryAt()); + } else { + // 최종 실패 + notification.markAsFailed(truncateError(e.getMessage())); + + log.error("Notification {} permanently failed after {} retries", + notification.getId(), + notification.getRetryCount()); + } + + notificationRepository.save(notification); + } + + private Duration calculateBackoff(int retryCount) { + // 1분, 5분, 30분 + return switch (retryCount) { + case 1 -> Duration.ofMinutes(1); + case 2 -> Duration.ofMinutes(5); + default -> Duration.ofMinutes(30); + }; + } + + private String truncateError(String message) { + if (message == null) { + return "Unknown error"; + } + return message.length() > 500 ? message.substring(0, 500) : message; + } +} diff --git a/modules/notification/src/main/java/kr/spot/application/dispatch/PushNotificationClient.java b/modules/notification/src/main/java/kr/spot/application/dispatch/PushNotificationClient.java new file mode 100644 index 00000000..912d11ff --- /dev/null +++ b/modules/notification/src/main/java/kr/spot/application/dispatch/PushNotificationClient.java @@ -0,0 +1,18 @@ +package kr.spot.application.dispatch; + +import kr.spot.domain.Notification; + +/** + * 푸시 알림 발송 클라이언트 인터페이스. + * FCM, APNs 등의 구현체가 이 인터페이스를 구현합니다. + */ +public interface PushNotificationClient { + + /** + * 푸시 알림을 발송합니다. + * + * @param notification 발송할 알림 + * @throws Exception 발송 실패 시 + */ + void send(Notification notification) throws Exception; +} diff --git a/modules/notification/src/main/java/kr/spot/application/event/CleanupNotificationOnMemberWithdrawn.java b/modules/notification/src/main/java/kr/spot/application/event/CleanupNotificationOnMemberWithdrawn.java index de746d6e..f3ffcd79 100644 --- a/modules/notification/src/main/java/kr/spot/application/event/CleanupNotificationOnMemberWithdrawn.java +++ b/modules/notification/src/main/java/kr/spot/application/event/CleanupNotificationOnMemberWithdrawn.java @@ -15,6 +15,6 @@ public class CleanupNotificationOnMemberWithdrawn { @TransactionalEventListener(phase = TransactionPhase.BEFORE_COMMIT) public void handle(MemberWithdrawnEvent event) { - notificationRepository.deleteByTargetTargetMemberId(event.memberId()); + notificationRepository.deleteByMemberId(event.memberId()); } } diff --git a/modules/notification/src/main/java/kr/spot/application/event/NotificationEventListener.java b/modules/notification/src/main/java/kr/spot/application/event/NotificationEventListener.java new file mode 100644 index 00000000..8edf6719 --- /dev/null +++ b/modules/notification/src/main/java/kr/spot/application/event/NotificationEventListener.java @@ -0,0 +1,120 @@ +package kr.spot.application.event; + +import java.util.List; +import java.util.Map; +import kr.spot.IdGenerator; +import kr.spot.application.recipient.RecipientResolver; +import kr.spot.application.template.NotificationContent; +import kr.spot.application.template.NotificationTemplateRenderer; +import kr.spot.code.status.ErrorStatus; +import kr.spot.domain.Notification; +import kr.spot.domain.enums.NotificationType; +import kr.spot.event.NotificationRequestedEvent; +import kr.spot.exception.GeneralException; +import kr.spot.infrastructure.jpa.NotificationRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.dao.DataIntegrityViolationException; +import org.springframework.stereotype.Component; +import org.springframework.transaction.event.TransactionPhase; +import org.springframework.transaction.event.TransactionalEventListener; + +@Slf4j +@Component +@RequiredArgsConstructor +public class NotificationEventListener { + + private final IdGenerator idGenerator; + private final NotificationRepository notificationRepository; + private final NotificationTemplateRenderer templateRenderer; + private final RecipientResolver recipientResolver; + + @TransactionalEventListener(phase = TransactionPhase.BEFORE_COMMIT) + public void handle(NotificationRequestedEvent event) { + try { + NotificationType type = parseNotificationType(event.type()); + Map payload = event.payload(); + + // 1. 수신자 목록 조회 + List recipientIds = recipientResolver.resolve(type, payload); + if (recipientIds.isEmpty()) { + log.warn("No recipients found for notification type: {}", type); + return; + } + + // 2. 템플릿 렌더링 + NotificationContent content = templateRenderer.render(type, payload); + + // 3. 알림 레코드 생성 및 저장 + List notifications = recipientIds.stream() + .map(memberId -> createNotification( + memberId, type, content, event + )) + .toList(); + + saveNotifications(notifications); + + log.info("Created {} notifications for type: {}", notifications.size(), type); + } catch (GeneralException e) { + log.error("Failed to create notifications: errorCode={}, message={}", + e.getStatus().getCode(), e.getStatus().getMessage()); + throw e; + } catch (Exception e) { + log.error("Failed to create notifications for event: {}", event, e); + } + } + + private NotificationType parseNotificationType(String type) { + try { + return NotificationType.valueOf(type); + } catch (IllegalArgumentException e) { + throw new GeneralException(ErrorStatus._INVALID_NOTIFICATION_TYPE); + } + } + + private Notification createNotification( + long memberId, + NotificationType type, + NotificationContent content, + NotificationRequestedEvent event + ) { + String dedupeKey = generateDedupeKey(type, event.referenceId(), memberId); + + return Notification.create( + idGenerator.nextId(), + memberId, + type, + content.title(), + content.body(), + event.imageUrl(), + type.getReferenceType(), + event.referenceId(), + event.scheduledAt(), + dedupeKey + ); + } + + private String generateDedupeKey(NotificationType type, Long referenceId, long memberId) { + if (referenceId == null) { + return null; // 중복 방지 키가 없는 경우 (일반 알림) + } + return String.format("%s:%s:%d:%d", + type.name(), + type.getReferenceType(), + referenceId, + memberId + ); + } + + private void saveNotifications(List notifications) { + for (Notification notification : notifications) { + try { + notificationRepository.save(notification); + } catch (DataIntegrityViolationException e) { + // dedupe_key 중복 - 이미 동일한 알림이 존재함 (멱등성 보장) + log.debug("Duplicate notification ignored: dedupeKey={}", + notification.getDedupeKey()); + } + } + } +} diff --git a/modules/notification/src/main/java/kr/spot/application/event/StudyApplicationNotificationListener.java b/modules/notification/src/main/java/kr/spot/application/event/StudyApplicationNotificationListener.java index 4f24de92..1bfa7dc6 100644 --- a/modules/notification/src/main/java/kr/spot/application/event/StudyApplicationNotificationListener.java +++ b/modules/notification/src/main/java/kr/spot/application/event/StudyApplicationNotificationListener.java @@ -1,66 +1,70 @@ package kr.spot.application.event; +import java.time.LocalDateTime; import kr.spot.IdGenerator; import kr.spot.domain.Notification; import kr.spot.domain.enums.NotificationType; -import kr.spot.domain.vo.Content; -import kr.spot.domain.vo.NotificationTarget; import kr.spot.event.StudyApplicationProcessedEvent; import kr.spot.infrastructure.jpa.NotificationRepository; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import org.springframework.dao.DataIntegrityViolationException; 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; +/** + * 스터디 신청 처리 결과 알림을 생성합니다. + * study 모듈에서 발행하는 StudyApplicationProcessedEvent를 처리합니다. + */ @Slf4j @Component @RequiredArgsConstructor public class StudyApplicationNotificationListener { - private final IdGenerator idGenerator; - private final NotificationRepository notificationRepository; + private final IdGenerator idGenerator; + private final NotificationRepository notificationRepository; - @Transactional(propagation = Propagation.REQUIRES_NEW) - @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) - public void handle(StudyApplicationProcessedEvent event) { - try { - Notification notification = createNotification(event); - notificationRepository.save(notification); - log.info("Notification saved: studyId={}, applicantId={}, decision={}", - event.studyId(), event.applicantId(), event.decision()); - } catch (Exception e) { - log.error("Failed to save notification: {}", event, e); + @TransactionalEventListener(phase = TransactionPhase.BEFORE_COMMIT) + public void handle(StudyApplicationProcessedEvent event) { + try { + Notification notification = createNotification(event); + notificationRepository.save(notification); + log.info("Notification saved: studyId={}, applicantId={}, decision={}", + event.studyId(), event.applicantId(), event.decision()); + } catch (DataIntegrityViolationException e) { + // dedupe_key 중복 - 이미 동일한 알림이 존재함 + log.debug("Duplicate notification ignored for studyId={}, applicantId={}", + event.studyId(), event.applicantId()); + } catch (Exception e) { + log.error("Failed to save notification: {}", event, e); + } } - } - private Notification createNotification(StudyApplicationProcessedEvent event) { - Content content = Content.of( - buildTitle(event), - buildMessage(event), - event.studyThumbnailUrl() - ); + private Notification createNotification(StudyApplicationProcessedEvent event) { + NotificationType type = event.isApproved() + ? NotificationType.STUDY_APPLICATION_APPROVED + : NotificationType.STUDY_APPLICATION_REJECTED; - NotificationTarget target = NotificationTarget.of( - event.applicantId(), - event.studyId(), - NotificationType.STUDY_APPLICATION_RESULT, - null - ); + String title = event.studyName(); + String body = event.isApproved() + ? "스터디 가입이 승인되었습니다! 지금 바로 참여해보세요." + : "스터디 가입이 거절되었습니다."; - return Notification.of(idGenerator.nextId(), content, target); - } + String dedupeKey = String.format("%s:STUDY:%d:%d", + type.name(), event.studyId(), event.applicantId()); - private String buildTitle(StudyApplicationProcessedEvent event) { - return event.studyName() + (event.isApproved() ? " 신청이 수락되었어요!" : " 신청이 거절되었어요."); - } - - private String buildMessage(StudyApplicationProcessedEvent event) { - if (event.isApproved()) { - return String.format("'%s' 스터디 가입이 승인되었습니다.", event.studyName()); + return Notification.create( + idGenerator.nextId(), + event.applicantId(), + type, + title, + body, + event.studyThumbnailUrl(), + "STUDY", + event.studyId(), + LocalDateTime.now(), + dedupeKey + ); } - return String.format("'%s' 스터디 가입이 거절되었습니다.", event.studyName()); - } } diff --git a/modules/notification/src/main/java/kr/spot/application/recipient/RecipientResolver.java b/modules/notification/src/main/java/kr/spot/application/recipient/RecipientResolver.java new file mode 100644 index 00000000..194e9207 --- /dev/null +++ b/modules/notification/src/main/java/kr/spot/application/recipient/RecipientResolver.java @@ -0,0 +1,63 @@ +package kr.spot.application.recipient; + +import java.util.List; +import java.util.Map; +import kr.spot.code.status.ErrorStatus; +import kr.spot.domain.enums.NotificationType; +import kr.spot.exception.GeneralException; +import kr.spot.ports.GetStudyMemberIdsPort; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +public class RecipientResolver { + + private final GetStudyMemberIdsPort getStudyMemberIdsPort; + + public List resolve(NotificationType type, Map payload) { + return switch (type) { + // 특정 회원 1명에게 발송 + case STUDY_APPLICATION_APPROVED, + STUDY_APPLICATION_REJECTED, + TODO_COMPLETED -> resolveSingleMember(payload); + + // 스터디 전체 멤버에게 발송 + case ATTENDANCE_STARTED, + ATTENDANCE_ENDED, + NOTICE_CREATED, + SCHEDULE_CREATED, + SCHEDULE_UPDATED, + SCHEDULE_REMINDER -> resolveStudyMembers(payload); + + // 특정 조건의 회원들에게 발송 (추후 확장) + case HOT_POST_DAILY -> List.of(); + }; + } + + private List resolveSingleMember(Map payload) { + Object memberId = payload.get("memberId"); + if (memberId == null) { + throw new GeneralException(ErrorStatus._NOTIFICATION_PAYLOAD_MISSING_MEMBER_ID); + } + return List.of(toLong(memberId)); + } + + private List resolveStudyMembers(Map payload) { + Object studyId = payload.get("studyId"); + if (studyId == null) { + throw new GeneralException(ErrorStatus._NOTIFICATION_PAYLOAD_MISSING_STUDY_ID); + } + return getStudyMemberIdsPort.getMemberIdsByStudyId(toLong(studyId)); + } + + private long toLong(Object value) { + if (value instanceof Long l) { + return l; + } + if (value instanceof Number n) { + return n.longValue(); + } + return Long.parseLong(String.valueOf(value)); + } +} diff --git a/modules/notification/src/main/java/kr/spot/application/template/NotificationContent.java b/modules/notification/src/main/java/kr/spot/application/template/NotificationContent.java new file mode 100644 index 00000000..3f317487 --- /dev/null +++ b/modules/notification/src/main/java/kr/spot/application/template/NotificationContent.java @@ -0,0 +1,8 @@ +package kr.spot.application.template; + +public record NotificationContent( + String title, + String body +) { + +} diff --git a/modules/notification/src/main/java/kr/spot/application/template/NotificationTemplate.java b/modules/notification/src/main/java/kr/spot/application/template/NotificationTemplate.java new file mode 100644 index 00000000..ca0c7bb7 --- /dev/null +++ b/modules/notification/src/main/java/kr/spot/application/template/NotificationTemplate.java @@ -0,0 +1,8 @@ +package kr.spot.application.template; + +record NotificationTemplate( + String titleTemplate, + String bodyTemplate +) { + +} diff --git a/modules/notification/src/main/java/kr/spot/application/template/NotificationTemplateRenderer.java b/modules/notification/src/main/java/kr/spot/application/template/NotificationTemplateRenderer.java new file mode 100644 index 00000000..3f52b6ab --- /dev/null +++ b/modules/notification/src/main/java/kr/spot/application/template/NotificationTemplateRenderer.java @@ -0,0 +1,98 @@ +package kr.spot.application.template; + +import java.util.EnumMap; +import java.util.Map; +import kr.spot.code.status.ErrorStatus; +import kr.spot.domain.enums.NotificationType; +import kr.spot.exception.GeneralException; +import org.springframework.stereotype.Component; + +@Component +public class NotificationTemplateRenderer { + + private static final Map TEMPLATES; + + static { + TEMPLATES = new EnumMap<>(NotificationType.class); + + // 스터디 신청 결과 + TEMPLATES.put(NotificationType.STUDY_APPLICATION_APPROVED, + new NotificationTemplate( + "${studyName}", + "스터디 가입이 승인되었습니다! 지금 바로 참여해보세요." + )); + TEMPLATES.put(NotificationType.STUDY_APPLICATION_REJECTED, + new NotificationTemplate( + "${studyName}", + "스터디 가입이 거절되었습니다." + )); + + // 출석 체크 + TEMPLATES.put(NotificationType.ATTENDANCE_STARTED, + new NotificationTemplate( + "[${studyName}] 출석 체크", + "${scheduleName} 출석 체크가 시작되었습니다. 지금 출석해주세요!" + )); + TEMPLATES.put(NotificationType.ATTENDANCE_ENDED, + new NotificationTemplate( + "[${studyName}] 출석 체크 종료", + "${scheduleName} 출석 체크가 종료되었습니다." + )); + + // 스터디 업데이트 + TEMPLATES.put(NotificationType.NOTICE_CREATED, + new NotificationTemplate( + "[${studyName}] 새 공지사항", + "${noticeTitle}" + )); + TEMPLATES.put(NotificationType.SCHEDULE_CREATED, + new NotificationTemplate( + "[${studyName}] 새 일정", + "${scheduleName} 일정이 등록되었습니다." + )); + TEMPLATES.put(NotificationType.SCHEDULE_UPDATED, + new NotificationTemplate( + "[${studyName}] 일정 변경", + "${scheduleName} 일정이 변경되었습니다." + )); + TEMPLATES.put(NotificationType.TODO_COMPLETED, + new NotificationTemplate( + "[${studyName}] 할 일 완료", + "할 일이 완료되었습니다: ${todoTitle}" + )); + + // 예약 알림 + TEMPLATES.put(NotificationType.SCHEDULE_REMINDER, + new NotificationTemplate( + "[${studyName}] 일정 알림", + "${scheduleName}이(가) 곧 시작됩니다." + )); + TEMPLATES.put(NotificationType.HOT_POST_DAILY, + new NotificationTemplate( + "오늘의 인기글", + "${postTitle}" + )); + } + + public NotificationContent render(NotificationType type, Map payload) { + NotificationTemplate template = TEMPLATES.get(type); + if (template == null) { + throw new GeneralException(ErrorStatus._NOTIFICATION_TEMPLATE_NOT_FOUND); + } + + String title = interpolate(template.titleTemplate(), payload); + String body = interpolate(template.bodyTemplate(), payload); + + return new NotificationContent(title, body); + } + + private String interpolate(String template, Map payload) { + String result = template; + for (Map.Entry entry : payload.entrySet()) { + String placeholder = "${" + entry.getKey() + "}"; + String value = entry.getValue() != null ? String.valueOf(entry.getValue()) : ""; + result = result.replace(placeholder, value); + } + return result; + } +} diff --git a/modules/notification/src/main/java/kr/spot/domain/Notification.java b/modules/notification/src/main/java/kr/spot/domain/Notification.java index 431f38bd..c8fc014e 100644 --- a/modules/notification/src/main/java/kr/spot/domain/Notification.java +++ b/modules/notification/src/main/java/kr/spot/domain/Notification.java @@ -1,37 +1,167 @@ package kr.spot.domain; -import jakarta.persistence.Embedded; +import jakarta.persistence.Column; import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; import jakarta.persistence.Id; -import kr.spot.domain.vo.Content; -import kr.spot.domain.vo.NotificationTarget; +import jakarta.persistence.Index; +import jakarta.persistence.Table; +import java.time.LocalDateTime; +import kr.spot.domain.enums.NotificationStatus; +import kr.spot.domain.enums.NotificationType; import lombok.AccessLevel; -import lombok.AllArgsConstructor; import lombok.Getter; import lombok.NoArgsConstructor; -import org.hibernate.annotations.SQLDelete; -import org.hibernate.annotations.SQLRestriction; @Getter @Entity -@SQLDelete(sql = "UPDATE notification SET status = 'INACTIVE' WHERE id = ?") -@SQLRestriction("status = 'ACTIVE'") +@Table(name = "notification", indexes = { + @Index(name = "idx_dispatch", columnList = "dispatchStatus, scheduledAt, pickedBy"), + @Index(name = "idx_member_created", columnList = "memberId, createdAt"), + @Index(name = "idx_retry", columnList = "dispatchStatus, nextRetryAt") +}) @NoArgsConstructor(access = AccessLevel.PROTECTED) -@AllArgsConstructor(access = AccessLevel.PRIVATE) public class Notification extends BaseEntity { + private static final int DEFAULT_MAX_RETRY = 3; + @Id private Long id; - @Embedded - private Content content; + // 수신자 + @Column(nullable = false) + private Long memberId; + + // 알림 타입 + @Enumerated(EnumType.STRING) + @Column(nullable = false, length = 50) + private NotificationType type; + + // 알림 내용 + @Column(nullable = false, length = 100) + private String title; + + @Column(nullable = false, length = 500) + private String body; + + @Column(length = 500) + private String imageUrl; + + // 연관 데이터 (딥링크용) + @Column(length = 50) + private String referenceType; + + private Long referenceId; + + // 발송 상태 관리 + @Enumerated(EnumType.STRING) + @Column(nullable = false, length = 20) + private NotificationStatus dispatchStatus; + + @Column(nullable = false) + private LocalDateTime scheduledAt; + + private LocalDateTime sentAt; + + // 동시성 제어 & 재시도 + @Column(length = 50) + private String pickedBy; + + private LocalDateTime pickedAt; + + @Column(nullable = false) + private int retryCount; + + @Column(nullable = false) + private int maxRetry; - @Embedded - private NotificationTarget target; + private LocalDateTime nextRetryAt; - private Boolean isChecked; + // 중복 방지 + @Column(unique = true, length = 200) + private String dedupeKey; + + // 실패 정보 + @Column(length = 500) + private String lastError; + + // 읽음 여부 + @Column(nullable = false) + private boolean isRead; + + private Notification( + Long id, + Long memberId, + NotificationType type, + String title, + String body, + String imageUrl, + String referenceType, + Long referenceId, + LocalDateTime scheduledAt, + String dedupeKey + ) { + this.id = id; + this.memberId = memberId; + this.type = type; + this.title = title; + this.body = body; + this.imageUrl = imageUrl; + this.referenceType = referenceType; + this.referenceId = referenceId; + this.dispatchStatus = NotificationStatus.PENDING; + this.scheduledAt = scheduledAt; + this.retryCount = 0; + this.maxRetry = DEFAULT_MAX_RETRY; + this.isRead = false; + } + + public static Notification create( + long id, + long memberId, + NotificationType type, + String title, + String body, + String imageUrl, + String referenceType, + Long referenceId, + LocalDateTime scheduledAt, + String dedupeKey + ) { + return new Notification( + id, memberId, type, title, body, imageUrl, + referenceType, referenceId, scheduledAt, dedupeKey + ); + } + + public void markAsSent(LocalDateTime now) { + this.dispatchStatus = NotificationStatus.SENT; + this.sentAt = now; + this.pickedBy = null; + this.pickedAt = null; + } + + public void markAsFailed(String errorMessage) { + this.dispatchStatus = NotificationStatus.FAILED; + this.lastError = errorMessage; + this.pickedBy = null; + this.pickedAt = null; + } + + public void scheduleRetry(LocalDateTime nextRetryAt) { + this.dispatchStatus = NotificationStatus.PENDING; + this.retryCount++; + this.nextRetryAt = nextRetryAt; + this.pickedBy = null; + this.pickedAt = null; + } + + public boolean canRetry() { + return retryCount < maxRetry; + } - public static Notification of(Long id, Content content, NotificationTarget target) { - return new Notification(id, content, target, false); + public void markAsRead() { + this.isRead = true; } } diff --git a/modules/notification/src/main/java/kr/spot/domain/enums/NotificationStatus.java b/modules/notification/src/main/java/kr/spot/domain/enums/NotificationStatus.java new file mode 100644 index 00000000..e48e5f3d --- /dev/null +++ b/modules/notification/src/main/java/kr/spot/domain/enums/NotificationStatus.java @@ -0,0 +1,9 @@ +package kr.spot.domain.enums; + +public enum NotificationStatus { + PENDING, // 발송 대기 + PROCESSING, // 발송 처리 중 (서버가 선점) + SENT, // 발송 완료 + FAILED // 발송 실패 (최대 재시도 초과) +} + diff --git a/modules/notification/src/main/java/kr/spot/domain/enums/NotificationType.java b/modules/notification/src/main/java/kr/spot/domain/enums/NotificationType.java index e6fa68f8..8c1f3efc 100644 --- a/modules/notification/src/main/java/kr/spot/domain/enums/NotificationType.java +++ b/modules/notification/src/main/java/kr/spot/domain/enums/NotificationType.java @@ -1,9 +1,29 @@ package kr.spot.domain.enums; +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Getter +@RequiredArgsConstructor public enum NotificationType { - HOT_POST, - STUDY_ANNOUNCEMENT_UPDATE, - STUDY_TODO_UPDATE, - STUDY_SCHEDULE_UPDATE, - STUDY_APPLICATION_RESULT, + // 즉시 알림 - 스터디 신청 + STUDY_APPLICATION_APPROVED("스터디 승인", "STUDY"), + STUDY_APPLICATION_REJECTED("스터디 거절", "STUDY"), + + // 즉시 알림 - 출석 + ATTENDANCE_STARTED("출석 체크 시작", "SCHEDULE"), + ATTENDANCE_ENDED("출석 체크 종료", "SCHEDULE"), + + // 즉시 알림 - 스터디 업데이트 + NOTICE_CREATED("공지사항 등록", "NOTICE"), + SCHEDULE_CREATED("일정 등록", "SCHEDULE"), + SCHEDULE_UPDATED("일정 변경", "SCHEDULE"), + TODO_COMPLETED("할 일 완료", "TODO"), + + // 예약 알림 + SCHEDULE_REMINDER("일정 리마인드", "SCHEDULE"), + HOT_POST_DAILY("오늘의 인기글", "POST"); + + private final String description; + private final String referenceType; } diff --git a/modules/notification/src/main/java/kr/spot/domain/vo/Content.java b/modules/notification/src/main/java/kr/spot/domain/vo/Content.java deleted file mode 100644 index 6db73050..00000000 --- a/modules/notification/src/main/java/kr/spot/domain/vo/Content.java +++ /dev/null @@ -1,26 +0,0 @@ -package kr.spot.domain.vo; - -import jakarta.persistence.Embeddable; -import lombok.AccessLevel; -import lombok.AllArgsConstructor; -import lombok.EqualsAndHashCode; -import lombok.Getter; -import lombok.NoArgsConstructor; - -@Getter -@Embeddable -@EqualsAndHashCode -@NoArgsConstructor(access = AccessLevel.PROTECTED) -@AllArgsConstructor(access = AccessLevel.PRIVATE) -public class Content { - - private String title; - - private String content; - - private String imageUrl; - - public static Content of(String title, String content, String imageUrl) { - return new Content(title, content, imageUrl); - } -} diff --git a/modules/notification/src/main/java/kr/spot/domain/vo/NotificationTarget.java b/modules/notification/src/main/java/kr/spot/domain/vo/NotificationTarget.java deleted file mode 100644 index 9cfd95b8..00000000 --- a/modules/notification/src/main/java/kr/spot/domain/vo/NotificationTarget.java +++ /dev/null @@ -1,33 +0,0 @@ -package kr.spot.domain.vo; - -import jakarta.persistence.Embeddable; -import jakarta.persistence.EnumType; -import jakarta.persistence.Enumerated; -import kr.spot.domain.enums.NotificationType; -import lombok.AccessLevel; -import lombok.AllArgsConstructor; -import lombok.EqualsAndHashCode; -import lombok.Getter; -import lombok.NoArgsConstructor; - -@Getter -@Embeddable -@EqualsAndHashCode -@NoArgsConstructor(access = AccessLevel.PROTECTED) -@AllArgsConstructor(access = AccessLevel.PRIVATE) -public class NotificationTarget { - - private Long targetMemberId; - - private Long linkStudyId; - - @Enumerated(EnumType.STRING) - private NotificationType notificationType; - - private String token; - - public static NotificationTarget of(Long targetMemberId, Long linkStudyId, - NotificationType notificationType, String token) { - return new NotificationTarget(targetMemberId, linkStudyId, notificationType, token); - } -} diff --git a/modules/notification/src/main/java/kr/spot/infrastructure/jpa/NotificationRepository.java b/modules/notification/src/main/java/kr/spot/infrastructure/jpa/NotificationRepository.java index eadf8a00..1ac80156 100644 --- a/modules/notification/src/main/java/kr/spot/infrastructure/jpa/NotificationRepository.java +++ b/modules/notification/src/main/java/kr/spot/infrastructure/jpa/NotificationRepository.java @@ -1,9 +1,47 @@ package kr.spot.infrastructure.jpa; +import java.time.LocalDateTime; +import java.util.List; import kr.spot.domain.Notification; +import kr.spot.domain.enums.NotificationStatus; 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; public interface NotificationRepository extends JpaRepository { - void deleteByTargetTargetMemberId(long targetMemberId); + void deleteByMemberId(long memberId); + + /** + * 발송 대상 알림 선점 (FOR UPDATE SKIP LOCKED 사용) MySQL 8.0+에서 지원 + */ + @Modifying + @Query(value = """ + UPDATE notification n + SET n.dispatch_status = 'PROCESSING', + n.picked_by = :serverId, + n.picked_at = :now + WHERE n.id IN ( + SELECT id FROM ( + SELECT id FROM notification + WHERE dispatch_status = 'PENDING' + AND scheduled_at <= :now + AND picked_by IS NULL + ORDER BY scheduled_at ASC + LIMIT :limit + FOR UPDATE SKIP LOCKED + ) AS subquery + ) + """, nativeQuery = true) + int pickPendingNotifications( + @Param("serverId") String serverId, + @Param("now") LocalDateTime now, + @Param("limit") int limit + ); + + /** + * 특정 서버가 선점한 알림 목록 조회 + */ + List findByPickedByAndDispatchStatus(String serverId, NotificationStatus status); } diff --git a/modules/notification/src/test/java/kr/spot/application/event/CleanupNotificationOnMemberWithdrawnTest.java b/modules/notification/src/test/java/kr/spot/application/event/CleanupNotificationOnMemberWithdrawnTest.java index 5a9b7bc4..8ae5e70d 100644 --- a/modules/notification/src/test/java/kr/spot/application/event/CleanupNotificationOnMemberWithdrawnTest.java +++ b/modules/notification/src/test/java/kr/spot/application/event/CleanupNotificationOnMemberWithdrawnTest.java @@ -14,28 +14,28 @@ @ExtendWith(MockitoExtension.class) class CleanupNotificationOnMemberWithdrawnTest { - private static final long MEMBER_ID = 1L; + private static final long MEMBER_ID = 1L; - @Mock - NotificationRepository notificationRepository; + @Mock + NotificationRepository notificationRepository; - CleanupNotificationOnMemberWithdrawn handler; + CleanupNotificationOnMemberWithdrawn handler; - @BeforeEach - void setUp() { - handler = new CleanupNotificationOnMemberWithdrawn(notificationRepository); - } + @BeforeEach + void setUp() { + handler = new CleanupNotificationOnMemberWithdrawn(notificationRepository); + } - @Test - @DisplayName("회원 탈퇴 시 알림을 삭제한다") - void should_deleteNotifications_when_memberWithdrawn() { - // given - MemberWithdrawnEvent event = new MemberWithdrawnEvent(MEMBER_ID); + @Test + @DisplayName("회원 탈퇴 시 알림을 삭제한다") + void should_deleteNotifications_when_memberWithdrawn() { + // given + MemberWithdrawnEvent event = new MemberWithdrawnEvent(MEMBER_ID); - // when - handler.handle(event); + // when + handler.handle(event); - // then - verify(notificationRepository).deleteByTargetTargetMemberId(MEMBER_ID); - } + // then + verify(notificationRepository).deleteByMemberId(MEMBER_ID); + } } diff --git a/modules/notification/src/test/java/kr/spot/application/event/StudyApplicationNotificationListenerTest.java b/modules/notification/src/test/java/kr/spot/application/event/StudyApplicationNotificationListenerTest.java index a53aad1c..e326272f 100644 --- a/modules/notification/src/test/java/kr/spot/application/event/StudyApplicationNotificationListenerTest.java +++ b/modules/notification/src/test/java/kr/spot/application/event/StudyApplicationNotificationListenerTest.java @@ -7,6 +7,7 @@ import kr.spot.IdGenerator; import kr.spot.domain.Notification; +import kr.spot.domain.enums.NotificationStatus; import kr.spot.domain.enums.NotificationType; import kr.spot.event.StudyApplicationProcessedEvent; import kr.spot.infrastructure.jpa.NotificationRepository; @@ -22,71 +23,72 @@ @ExtendWith(MockitoExtension.class) class StudyApplicationNotificationListenerTest { - @Mock - IdGenerator idGenerator; - - @Mock - NotificationRepository notificationRepository; - - @Captor - ArgumentCaptor notificationCaptor; - - StudyApplicationNotificationListener listener; - - @BeforeEach - void setUp() { - listener = new StudyApplicationNotificationListener(idGenerator, notificationRepository); - } - - @Test - @DisplayName("승인 이벤트 수신 시 알림이 저장된다") - void should_save_notification_when_approved() { - // given - Long notificationId = 100L; - StudyApplicationProcessedEvent event = StudyApplicationProcessedEvent.of( - 1L, 2L, 3L, "APPROVE", "자바 스터디", "http://image.url/java.png"); - - when(idGenerator.nextId()).thenReturn(notificationId); - when(notificationRepository.save(any(Notification.class))) - .thenAnswer(invocation -> invocation.getArgument(0)); - - // when - listener.handle(event); - - // then - verify(notificationRepository).save(notificationCaptor.capture()); - - Notification notification = notificationCaptor.getValue(); - assertThat(notification.getId()).isEqualTo(notificationId); - assertThat(notification.getContent().getTitle()).isEqualTo("자바 스터디 신청이 수락되었어요!"); - assertThat(notification.getContent().getContent()).contains("자바 스터디"); - assertThat(notification.getContent().getContent()).contains("승인"); - assertThat(notification.getTarget().getTargetMemberId()).isEqualTo(2L); - assertThat(notification.getTarget().getLinkStudyId()).isEqualTo(1L); - assertThat(notification.getTarget().getNotificationType()) - .isEqualTo(NotificationType.STUDY_APPLICATION_RESULT); - } - - @Test - @DisplayName("거절 이벤트 수신 시 알림이 저장된다") - void should_save_notification_when_rejected() { - // given - Long notificationId = 100L; - StudyApplicationProcessedEvent event = StudyApplicationProcessedEvent.of( - 1L, 2L, 3L, "REJECT", "자바 스터디", "http://image.url"); - - when(idGenerator.nextId()).thenReturn(notificationId); - when(notificationRepository.save(any(Notification.class))) - .thenAnswer(invocation -> invocation.getArgument(0)); - - // when - listener.handle(event); - - // then - verify(notificationRepository).save(notificationCaptor.capture()); - - Notification notification = notificationCaptor.getValue(); - assertThat(notification.getContent().getTitle()).isEqualTo("자바 스터디 신청이 거절되었어요."); - assertThat(notification.getContent().getContent()).contains("거절"); - } + @Mock + IdGenerator idGenerator; + + @Mock + NotificationRepository notificationRepository; + + @Captor + ArgumentCaptor notificationCaptor; + + StudyApplicationNotificationListener listener; + + @BeforeEach + void setUp() { + listener = new StudyApplicationNotificationListener(idGenerator, notificationRepository); + } + + @Test + @DisplayName("승인 이벤트 수신 시 알림이 저장된다") + void should_save_notification_when_approved() { + // given + Long notificationId = 100L; + StudyApplicationProcessedEvent event = StudyApplicationProcessedEvent.of( + 1L, 2L, 3L, "APPROVE", "자바 스터디", "http://image.url/java.png"); + + when(idGenerator.nextId()).thenReturn(notificationId); + when(notificationRepository.save(any(Notification.class))) + .thenAnswer(invocation -> invocation.getArgument(0)); + + // when + listener.handle(event); + + // then + verify(notificationRepository).save(notificationCaptor.capture()); + + Notification notification = notificationCaptor.getValue(); + assertThat(notification.getId()).isEqualTo(notificationId); + assertThat(notification.getTitle()).isEqualTo("자바 스터디"); + assertThat(notification.getBody()).contains("승인"); + assertThat(notification.getMemberId()).isEqualTo(2L); + assertThat(notification.getReferenceId()).isEqualTo(1L); + assertThat(notification.getReferenceType()).isEqualTo("STUDY"); + assertThat(notification.getType()).isEqualTo(NotificationType.STUDY_APPLICATION_APPROVED); + assertThat(notification.getDispatchStatus()).isEqualTo(NotificationStatus.PENDING); + } + + @Test + @DisplayName("거절 이벤트 수신 시 알림이 저장된다") + void should_save_notification_when_rejected() { + // given + Long notificationId = 100L; + StudyApplicationProcessedEvent event = StudyApplicationProcessedEvent.of( + 1L, 2L, 3L, "REJECT", "자바 스터디", "http://image.url"); + + when(idGenerator.nextId()).thenReturn(notificationId); + when(notificationRepository.save(any(Notification.class))) + .thenAnswer(invocation -> invocation.getArgument(0)); + + // when + listener.handle(event); + + // then + verify(notificationRepository).save(notificationCaptor.capture()); + + Notification notification = notificationCaptor.getValue(); + assertThat(notification.getTitle()).isEqualTo("자바 스터디"); + assertThat(notification.getBody()).contains("거절"); + assertThat(notification.getType()).isEqualTo(NotificationType.STUDY_APPLICATION_REJECTED); + } } diff --git a/modules/study-api/src/main/java/kr/spot/ports/GetStudyMemberIdsPort.java b/modules/study-api/src/main/java/kr/spot/ports/GetStudyMemberIdsPort.java new file mode 100644 index 00000000..2e523de5 --- /dev/null +++ b/modules/study-api/src/main/java/kr/spot/ports/GetStudyMemberIdsPort.java @@ -0,0 +1,18 @@ +package kr.spot.ports; + +import java.util.List; + +/** + * 스터디 멤버 ID 조회 포트. + * 알림 발송 등에서 스터디 멤버 목록을 조회할 때 사용합니다. + */ +public interface GetStudyMemberIdsPort { + + /** + * 해당 스터디의 모든 멤버 ID를 조회합니다. + * + * @param studyId 스터디 ID + * @return 멤버 ID 목록 + */ + List getMemberIdsByStudyId(long studyId); +} diff --git a/modules/study/src/main/java/kr/spot/study/application/ports/GetStudyMemberIdsService.java b/modules/study/src/main/java/kr/spot/study/application/ports/GetStudyMemberIdsService.java new file mode 100644 index 00000000..ca8a5efc --- /dev/null +++ b/modules/study/src/main/java/kr/spot/study/application/ports/GetStudyMemberIdsService.java @@ -0,0 +1,32 @@ +package kr.spot.study.application.ports; + +import java.util.List; +import kr.spot.ports.GetStudyMemberIdsPort; +import kr.spot.study.domain.associations.StudyMember; +import kr.spot.study.domain.enums.StudyMemberStatus; +import kr.spot.study.infrastructure.jpa.associations.StudyMemberRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class GetStudyMemberIdsService implements GetStudyMemberIdsPort { + + private static final List ACTIVE_STATUSES = List.of( + StudyMemberStatus.OWNER, + StudyMemberStatus.APPROVED + ); + + private final StudyMemberRepository studyMemberRepository; + + @Override + public List getMemberIdsByStudyId(long studyId) { + return studyMemberRepository + .findAllByStudyIdAndStudyMemberStatusIn(studyId, ACTIVE_STATUSES) + .stream() + .map(StudyMember::getMemberId) + .toList(); + } +} diff --git a/settings.gradle b/settings.gradle index 4b247681..7f52192a 100644 --- a/settings.gradle +++ b/settings.gradle @@ -20,6 +20,7 @@ include 'modules:member-api' include 'modules:auth-api' include 'modules:study-api' include 'modules:region-api' +include 'modules:notification-api' include 'infra:bucket-s3' include 'infra:view-redis'