diff --git a/fitapet-app-external-api/src/main/java/kr/co/fitapet/api/common/event/notification/NoticeEvent.java b/fitapet-app-external-api/src/main/java/kr/co/fitapet/api/common/event/notification/NoticeEvent.java index 9433dfca..77713496 100644 --- a/fitapet-app-external-api/src/main/java/kr/co/fitapet/api/common/event/notification/NoticeEvent.java +++ b/fitapet-app-external-api/src/main/java/kr/co/fitapet/api/common/event/notification/NoticeEvent.java @@ -6,10 +6,22 @@ import lombok.Builder; import java.util.Map; -import java.util.Objects; +import java.util.stream.Collectors; +/** + * FCM Push Notification Event를 위한 Data Type Class + * + * @param memberId Long : 푸시 알림을 받을 회원 ID + * @param noticeType {@link NoticeType} : push 알림을 위한 알림 타입 + * @param notificationType {@link NotificationType} : DB에 저장될 알림 타입 + * @param title String : 푸시 알림 제목 + * @param content String : 푸시 알림 내용. {@link NoticeType}에 따라 동적으로 생성 + * @param imageUrl String : 푸시 알림 이미지 URL (optional) + * @param data Map<{@link NotificationDataKey}, String> : 푸시 알림에 포함될 데이터 + */ @Builder public record NoticeEvent( + Long memberId, NoticeType noticeType, NotificationType notificationType, String title, @@ -18,24 +30,47 @@ public record NoticeEvent( Map data ) { public Notification toEntity() { - String fromId = data.getOrDefault(NotificationDataKey.FROM_ID, null); - String toId = data.getOrDefault(NotificationDataKey.TO_ID, null); - String domainId = data.getOrDefault(NotificationDataKey.DOMAIN_ID, null); - String subjectId = data.getOrDefault(NotificationDataKey.SUBJECT_ID, null); + Map idFields = getIdFields(); return Notification.builder() .title(title) - .content(content) + .content(getFormattedContent()) .imageUrl(imageUrl) .ctype(notificationType) - .fromId(Objects.isNull(fromId) ? null : Long.parseLong(fromId)) - .toId(Objects.isNull(toId) ? null : Long.parseLong(toId)) - .domainId(Objects.isNull(domainId) ? null : Long.parseLong(domainId)) - .subjectId(Objects.isNull(subjectId) ? null : Long.parseLong(subjectId)) + .fromId(idFields.getOrDefault(NotificationDataKey.FROM_ID.getField(), null)) + .toId(idFields.getOrDefault(NotificationDataKey.TO_ID.getField(), null)) + .domainId(idFields.getOrDefault(NotificationDataKey.DOMAIN_ID.getField(), null)) + .subjectId(idFields.getOrDefault(NotificationDataKey.SUBJECT_ID.getField(), null)) .build(); } - public String getValueFromNotificationDataKey(NotificationDataKey key) { - return data.getOrDefault(key, null); + /** + * data의 정보를 {@link NoticeType}에 따라 동적으로 생성된 content를 반환합니다. + * @return String : 동적으로 생성된 content + */ + public String getFormattedContent() { + return noticeType.createFormattedContent(getNameFields()); + } + + private Map getIdFields() { + return data.entrySet().stream() + .filter(e -> e.getKey().isIdField()) + .collect( + Collectors.toMap( + e -> e.getKey().getField(), + e -> Long.parseLong(e.getValue()) + ) + ); + } + + private Map getNameFields() { + return data.entrySet().stream() + .filter(e -> e.getKey().isNameField()) + .collect( + Collectors.toMap( + e -> e.getKey().getField(), + Map.Entry::getValue + ) + ); } } diff --git a/fitapet-app-external-api/src/main/java/kr/co/fitapet/api/common/event/notification/NoticeEventHandler.java b/fitapet-app-external-api/src/main/java/kr/co/fitapet/api/common/event/notification/NoticeEventHandler.java index 8da31aab..f3d42e4e 100644 --- a/fitapet-app-external-api/src/main/java/kr/co/fitapet/api/common/event/notification/NoticeEventHandler.java +++ b/fitapet-app-external-api/src/main/java/kr/co/fitapet/api/common/event/notification/NoticeEventHandler.java @@ -1,9 +1,14 @@ package kr.co.fitapet.api.common.event.notification; import kr.co.fitapet.domain.domains.device.domain.DeviceToken; +import kr.co.fitapet.domain.domains.device.exception.DeviceTokenErrorCode; +import kr.co.fitapet.domain.domains.device.exception.DeviceTokenErrorException; import kr.co.fitapet.domain.domains.device.service.DeviceTokenSearchService; import kr.co.fitapet.domain.domains.notification.domain.Notification; import kr.co.fitapet.domain.domains.notification.service.NotificationSaveService; +import kr.co.fitapet.domain.domains.notification.type.NotificationType; +import kr.co.fitapet.infra.client.fcm.NotificationDataKey; +import kr.co.fitapet.infra.common.event.NotificationSingleEvent; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.context.ApplicationEventPublisher; @@ -23,13 +28,41 @@ public class NoticeEventHandler { private final DeviceTokenSearchService deviceTokenSearchService; private final NotificationSaveService notificationSaveService; + /** + * 일반적인 push notification 이벤트{@link NoticeEvent} 핸들러입니다. + *
+ * 알림 이벤트를 받아서 알림을 저장하고, 해당 알림을 전송할 대상을 조회하여 {@link Notification}를 등록합니다. + *
+ * {@link TransactionalEventListener}를 통해 이벤트를 발행하는 트랜잭션 커밋 이후에 핸들러가 실행됩니다. + *
+ * 이벤트를 발행한 트랜잭션과 별개의 트랜잭션으로 실행됩니다. + * + * @param event {@link NoticeEvent} + */ @Transactional(propagation = Propagation.REQUIRES_NEW) @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT, fallbackExecution = true) public void handleNoticeEvent(NoticeEvent event) { log.info("handleNoticeEvent: {}", event); - // notice 저장 - // notice 전송할 대상 조회 (deviceToken 리스트) - // deviceToken 리스트 전체 NotificationEvent 등록 + Notification notification = event.toEntity(); + notificationSaveService.save(notification); + + List deviceTokens = deviceTokenSearchService.findDeviceTokensByMemberId(event.memberId()); + if (deviceTokens.isEmpty()) { + log.warn("No device tokens found for member: {}", event.memberId()); + throw new DeviceTokenErrorException(DeviceTokenErrorCode.NOT_FOUND_DEVICE_TOKEN_ERROR); + } + + deviceTokens.forEach(deviceToken -> { + eventPublisher.publishEvent( + NotificationSingleEvent.builder() + .deviceToken(deviceToken.getDeviceToken()) + .title(event.title()) + .content(event.getFormattedContent()) + .imageUrl(event.imageUrl()) + .data(event.data()) + .build() + ); + }); } /** @@ -55,6 +88,21 @@ public void handleAnnouncementEvent(AnnouncementEvent event) { Long toId = deviceToken.getMember().getId(); notifications.add(event.toEntity(toId)); + Map data = Map.of( + NotificationDataKey.NOTICE_TYPE, NotificationType.ANNOUNCEMENT.name(), + NotificationDataKey.TO_ID, deviceToken.getMember().getId().toString(), + NotificationDataKey.TO_NAME, deviceToken.getMember().getName() + ); + + eventPublisher.publishEvent( + NotificationSingleEvent.builder() + .deviceToken(token) + .title(event.title()) + .content(event.content()) + .imageUrl(event.imageUrl()) + .data(data) + .build() + ); }); notificationSaveService.saveAll(notifications); } diff --git a/fitapet-domain/src/main/java/kr/co/fitapet/domain/domains/device/exception/DeviceTokenErrorCode.java b/fitapet-domain/src/main/java/kr/co/fitapet/domain/domains/device/exception/DeviceTokenErrorCode.java new file mode 100644 index 00000000..f157e732 --- /dev/null +++ b/fitapet-domain/src/main/java/kr/co/fitapet/domain/domains/device/exception/DeviceTokenErrorCode.java @@ -0,0 +1,29 @@ +package kr.co.fitapet.domain.domains.device.exception; + +import kr.co.fitapet.common.execption.BaseErrorCode; +import kr.co.fitapet.common.execption.CausedBy; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +import static kr.co.fitapet.common.execption.StatusCode.NOT_FOUND; + +@Getter +@RequiredArgsConstructor(access = AccessLevel.PRIVATE) +public enum DeviceTokenErrorCode implements BaseErrorCode { + /* 404 NOT FOUND */ + NOT_FOUND_DEVICE_TOKEN_ERROR(NOT_FOUND.getCode(), "존재하지 않는 디바이스 토큰입니다."); + + private final int code; + private final String message; + + @Override + public CausedBy causedBy() { + return CausedBy.of(code, name(), message); + } + + @Override + public String getExplainError() throws NoSuchFieldError { + return message; + } +} diff --git a/fitapet-domain/src/main/java/kr/co/fitapet/domain/domains/device/exception/DeviceTokenErrorException.java b/fitapet-domain/src/main/java/kr/co/fitapet/domain/domains/device/exception/DeviceTokenErrorException.java new file mode 100644 index 00000000..5c23f3b1 --- /dev/null +++ b/fitapet-domain/src/main/java/kr/co/fitapet/domain/domains/device/exception/DeviceTokenErrorException.java @@ -0,0 +1,22 @@ +package kr.co.fitapet.domain.domains.device.exception; + +import kr.co.fitapet.common.execption.BaseErrorCode; +import kr.co.fitapet.common.execption.CausedBy; +import kr.co.fitapet.common.execption.GlobalErrorException; + +public class DeviceTokenErrorException extends GlobalErrorException { + private final DeviceTokenErrorCode errorCode; + + public DeviceTokenErrorException(DeviceTokenErrorCode errorCode) { + super(errorCode); + this.errorCode = errorCode; + } + + public CausedBy causedBy() { + return errorCode.causedBy(); + } + + public BaseErrorCode getErrorCode() { + return errorCode; + } +} diff --git a/fitapet-infra/src/main/java/kr/co/fitapet/infra/client/fcm/NotificationDataKey.java b/fitapet-infra/src/main/java/kr/co/fitapet/infra/client/fcm/NotificationDataKey.java index b7298b6e..6b6ed616 100644 --- a/fitapet-infra/src/main/java/kr/co/fitapet/infra/client/fcm/NotificationDataKey.java +++ b/fitapet-infra/src/main/java/kr/co/fitapet/infra/client/fcm/NotificationDataKey.java @@ -19,17 +19,19 @@ public enum NotificationDataKey { SUBJECT_NAME("subjectName"), ; private final String field; + private static final List idFields = List.of(FROM_ID, TO_ID, DOMAIN_ID, SUBJECT_ID); + private static final List nameFields = List.of(FROM_NAME, TO_NAME, DOMAIN_NAME, SUBJECT_NAME); @JsonValue public String getField() { return field; } - public static List getIdFields() { - return List.of("fromId", "toId", "domainId", "subjectId"); + public boolean isIdField() { + return idFields.contains(this); } - public static List getNameFields() { - return List.of("fromName", "toName", "domainName", "subjectName"); + public boolean isNameField() { + return nameFields.contains(this); } }