From 242d6511f01955202c108af30561b042330cdd07 Mon Sep 17 00:00:00 2001 From: JaeSeo Yang <96044622+psychology50@users.noreply.github.com> Date: Tue, 27 Feb 2024 17:22:00 +0900 Subject: [PATCH] feat: #39 noticeType && noticeEvent --- .../event/notification/NoticeEvent.java | 13 +++++ .../notification/NoticeEventHandler.java | 26 +++++++++ .../common/event/notification/NoticeType.java | 56 +++++++++++++++++++ .../co/fitapet/common/StringFormatTest.java | 23 ++++++++ .../domains/device/domain/DeviceToken.java | 42 ++++++++++++++ .../notification/type/NotificationType.java | 9 ++- .../infra/client/fcm/NotificationDataKey.java | 31 ++++++++++ .../infra/client/fcm/NotificationEvent.java | 39 +++++++++++++ .../infra/client/fcm/NotificationRequest.java | 21 +++++-- .../event/FcmNotificationEventHandler.java | 37 ++++++++++++ .../kr/co/fitapet/infra/config/FcmConfig.java | 24 ++++++-- .../src/main/resources/application-infra.yml | 6 +- 12 files changed, 313 insertions(+), 14 deletions(-) create mode 100644 fitapet-app-external-api/src/main/java/kr/co/fitapet/api/common/event/notification/NoticeEvent.java create mode 100644 fitapet-app-external-api/src/main/java/kr/co/fitapet/api/common/event/notification/NoticeEventHandler.java create mode 100644 fitapet-app-external-api/src/main/java/kr/co/fitapet/api/common/event/notification/NoticeType.java create mode 100644 fitapet-app-external-api/src/test/java/kr/co/fitapet/common/StringFormatTest.java create mode 100644 fitapet-domain/src/main/java/kr/co/fitapet/domain/domains/device/domain/DeviceToken.java create mode 100644 fitapet-infra/src/main/java/kr/co/fitapet/infra/client/fcm/NotificationDataKey.java create mode 100644 fitapet-infra/src/main/java/kr/co/fitapet/infra/client/fcm/NotificationEvent.java create mode 100644 fitapet-infra/src/main/java/kr/co/fitapet/infra/common/event/FcmNotificationEventHandler.java 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 new file mode 100644 index 00000000..fc884a59 --- /dev/null +++ b/fitapet-app-external-api/src/main/java/kr/co/fitapet/api/common/event/notification/NoticeEvent.java @@ -0,0 +1,13 @@ +package kr.co.fitapet.api.common.event.notification; + +import kr.co.fitapet.infra.client.fcm.NotificationDataKey; +import lombok.Builder; + +import java.util.Map; + +@Builder +public record NoticeEvent( + NoticeType noticeType, + Map data +) { +} 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 new file mode 100644 index 00000000..e6286b91 --- /dev/null +++ b/fitapet-app-external-api/src/main/java/kr/co/fitapet/api/common/event/notification/NoticeEventHandler.java @@ -0,0 +1,26 @@ +package kr.co.fitapet.api.common.event.notification; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.context.ApplicationEventPublisher; +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; + +@Slf4j +@Component +@RequiredArgsConstructor +public class NoticeEventHandler { + private final ApplicationEventPublisher eventPublisher; + + @Transactional(propagation = Propagation.REQUIRES_NEW) + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT, fallbackExecution = true) + public void handleNoticeEventToManager(NoticeEvent event) { + log.info("handleNoticeEvent: {}", event); + // notice 저장 + // notice 전송할 대상 조회 (deviceToken 리스트) + // deviceToken 리스트 전체 NotificationEvent 등록 + } +} diff --git a/fitapet-app-external-api/src/main/java/kr/co/fitapet/api/common/event/notification/NoticeType.java b/fitapet-app-external-api/src/main/java/kr/co/fitapet/api/common/event/notification/NoticeType.java new file mode 100644 index 00000000..91f6325d --- /dev/null +++ b/fitapet-app-external-api/src/main/java/kr/co/fitapet/api/common/event/notification/NoticeType.java @@ -0,0 +1,56 @@ +package kr.co.fitapet.api.common.event.notification; + +import lombok.Getter; + +import java.text.MessageFormat; +import java.util.List; +import java.util.Map; + +/** + * 알림 타입을 정의하는 클래스입니다. + * + *

+ * 알림 타입은 [DOMAIN]_[ACTION]_[FROM]_[TO] 혹은 [DOMAIN]_[ACTION]_[FROM]_[SUBJECT] 형식으로 정의합니다. (FROM, TO, SUBJECT는 선택사항입니다.) + * NoticeContentType은 각 알림 타입에 대한 이름, 제목 및 내용을 정의합니다. + *

+ */ +@Getter +public enum NoticeType { + /* 공지사항 */ + ANNOUNCEMENT("%s", "%s"), + + /* 케어 */ + CARE_DO_FROM_SUBJECT("케어 완료", "%s님이 %s의 %s 케어를 완료했어요.", "fromName", "subjectName", "domainName"), // from, 반려동물 이름(SUBJECT), 케어이름(DOMAIN) + CARE_CANCEL_FROM_SUBJECT("케어 취소", "%s님이 %s의 %s 케어를 취소했어요.", "fromName", "subjectName", "domainName"), // from, 반려동물 이름(SUBJECT), 케어이름(DOMAIN) + CARE_ALARM_SUBJECT("케어 알림", "%s의 %s 케어가 아직 완료되지 않았어요.", "subjectName", "domainName"), // 반려동물 이름(SUBJECT), 케어이름(DOMAIN) + + /* 멤버 */ + MANAGER_INVITED_FROM_SUBJECT("관리자 초대", "%s님이 %s 관리에 초대했어요.", "fromName", "subjectName"), // from, 반려동물 이름(SUBJECT) + MANAGER_ACCEPT_FROM_SUBJECT("관리자 초대 승인", "%s님이 %s의 케어 멤버 초대를 수락했어요.", "fromName", "subjectName"), // from, 반려동물 이름(SUBJECT) + MANAGER_JOIN_FROM_SUBJECT("관리자 가입", "%s님이 %s의 케어 멤버로 참여했어요.", "fromName", "subjectName"), // from, 반려동물 이름(SUBJECT) + MANAGER_DELEGATE_TO_SUBJECT("관리자 위임", "%s의 관리자가 %s님으로 변경되었어요.", "subjectName", "toName"), // 반려동물 이름(SUBJECT), to + ; + + private final String title; + private final String contentFormat; + private final List order; + + NoticeType(String title, String contentFormat, String ...args) { + this.title = title; + this.contentFormat = contentFormat; + this.order = List.of(args); + } + + /** + * 알림 타입에 맞는 내용을 생성합니다. + * @param args Map : 알림 타입에 맞는 인자 + * @return String : content + */ + public String createFormattedContent(Map args) { + if (this.name().equals(ANNOUNCEMENT.name())) throw new IllegalStateException("공지사항은 포맷팅할 수 없습니다."); + if (args.size() != order.size()) throw new IllegalArgumentException("포맷팅할 수 없는 인자입니다."); + + Object[] arguments = order.stream().map(args::get).toArray(); + return MessageFormat.format(contentFormat, arguments); + } +} diff --git a/fitapet-app-external-api/src/test/java/kr/co/fitapet/common/StringFormatTest.java b/fitapet-app-external-api/src/test/java/kr/co/fitapet/common/StringFormatTest.java new file mode 100644 index 00000000..fd6bf450 --- /dev/null +++ b/fitapet-app-external-api/src/test/java/kr/co/fitapet/common/StringFormatTest.java @@ -0,0 +1,23 @@ +package kr.co.fitapet.common; + +import org.junit.jupiter.api.Test; + +public class StringFormatTest { + @Test + public void plainFormatter() { + String format = "안녕하세요, %s님. %s에 오신 것을 환영합니다."; + String name = "홍길동"; + String time = "오늘"; + String result = String.format(format, name, time); + System.out.println(result); + } + + @Test + public void noneMatchingArgument() { + String format = "안녕하세요, %s님. 오신 것을 환영합니다."; + String name = "홍길동"; + String time = "오늘"; + String result = String.format(format, name, time); + System.out.println(result); + } +} diff --git a/fitapet-domain/src/main/java/kr/co/fitapet/domain/domains/device/domain/DeviceToken.java b/fitapet-domain/src/main/java/kr/co/fitapet/domain/domains/device/domain/DeviceToken.java new file mode 100644 index 00000000..eafa0b29 --- /dev/null +++ b/fitapet-domain/src/main/java/kr/co/fitapet/domain/domains/device/domain/DeviceToken.java @@ -0,0 +1,42 @@ +package kr.co.fitapet.domain.domains.device.domain; + +import jakarta.persistence.*; +import kr.co.fitapet.domain.domains.member.domain.Member; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; +import org.springframework.data.annotation.CreatedDate; +import org.springframework.data.jpa.domain.support.AuditingEntityListener; + +import java.time.LocalDateTime; + +@Entity +@Getter +@Table(name = "device_token") +@EntityListeners(AuditingEntityListener.class) +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class DeviceToken { + @Id @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + private String deviceToken; + private String os; + private String deviceName; + + @CreatedDate + private LocalDateTime createdAt; + + @JoinColumn(name = "member_id") + @ManyToOne(fetch = FetchType.LAZY) + private Member member; + + private DeviceToken(String deviceToken, String os, String deviceName, Member member) { + this.deviceToken = deviceToken; + this.os = os; + this.deviceName = deviceName; + this.member = member; + } + + public static DeviceToken of(String deviceToken, String os, String deviceName, Member member) { + return new DeviceToken(deviceToken, os, deviceName, member); + } +} diff --git a/fitapet-domain/src/main/java/kr/co/fitapet/domain/domains/notification/type/NotificationType.java b/fitapet-domain/src/main/java/kr/co/fitapet/domain/domains/notification/type/NotificationType.java index bda9e0ed..d010e2af 100644 --- a/fitapet-domain/src/main/java/kr/co/fitapet/domain/domains/notification/type/NotificationType.java +++ b/fitapet-domain/src/main/java/kr/co/fitapet/domain/domains/notification/type/NotificationType.java @@ -13,9 +13,12 @@ @RequiredArgsConstructor public enum NotificationType implements LegacyCommonType { NOTICE("1", "공지사항"), - CARE("2", "케어활동"), - MEMO("3", "일기"), - SCHEDULE("4", "스케줄"); + MEMBER("2", "멤버"), + MANAGER("3", "매니저 활동"), + PET("4", "반려동물"), + CARE("5", "케어활동"), + MEMO("6", "일기"), + SCHEDULE("7", "스케줄"); private final String code; private final String type; 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 new file mode 100644 index 00000000..04d5a2ad --- /dev/null +++ b/fitapet-infra/src/main/java/kr/co/fitapet/infra/client/fcm/NotificationDataKey.java @@ -0,0 +1,31 @@ +package kr.co.fitapet.infra.client.fcm; + +import com.fasterxml.jackson.annotation.JsonValue; +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +import java.util.List; + +@RequiredArgsConstructor +public enum NotificationDataKey { + NOTICE_TYPE("noticeType"), + FROM_ID("fromId"), + FROM_NAME("fromName"), + TO_ID("toId"), + TO_NAME("toName"), + DOMAIN_ID("domainId"), + DOMAIN_NAME("domainName"), + SUBJECT_ID("subjectId"), + SUBJECT_NAME("subjectName"), + ; + private final String field; + + @JsonValue + public String getField() { + return field; + } + + public List getNameFields() { + return List.of("fromName", "toName", "domainName", "subjectName"); + } +} diff --git a/fitapet-infra/src/main/java/kr/co/fitapet/infra/client/fcm/NotificationEvent.java b/fitapet-infra/src/main/java/kr/co/fitapet/infra/client/fcm/NotificationEvent.java new file mode 100644 index 00000000..7144c590 --- /dev/null +++ b/fitapet-infra/src/main/java/kr/co/fitapet/infra/client/fcm/NotificationEvent.java @@ -0,0 +1,39 @@ +package kr.co.fitapet.infra.client.fcm; + +import lombok.Builder; + +import java.util.List; +import java.util.Map; + +/** + * FCM Push Notification Event를 위한 Object + * + *

+ * 하나 이상의 디바이스에 푸시 알림을 보내기 위한 객체입니다. + * 푸시 알림은 단일 디바이스, 여러 디바이스 또는 토픽으로 보낼 수 있습니다. + * 푸시 알림에는 제목, 내용 및 이미지 URL이 포함될 수 있습니다. + * 푸시 알림이 토픽으로 보내지는 경우 deviceTokens 필드는 null이어야 합니다. + *

+ * @param deviceTokens List : 푸시 알림을 받을 디바이스 토큰 리스트 + * @param title String : 푸시 알림 제목 + * @param topic String : 푸시 알림을 받을 토픽 + * @param content String : 푸시 알림 내용 + * @param imageUrl String : 푸시 알림 이미지 URL + */ +@Builder +public record NotificationEvent( + List deviceTokens, + String title, + String topic, + String content, + String imageUrl, + Map data +) { + public boolean isTopic() { + return this.deviceTokens == null; + } + + public boolean isMulticast() { + return this.deviceTokens() != null && this.deviceTokens().size() > 1; + } +} diff --git a/fitapet-infra/src/main/java/kr/co/fitapet/infra/client/fcm/NotificationRequest.java b/fitapet-infra/src/main/java/kr/co/fitapet/infra/client/fcm/NotificationRequest.java index c0558715..bd2e95eb 100644 --- a/fitapet-infra/src/main/java/kr/co/fitapet/infra/client/fcm/NotificationRequest.java +++ b/fitapet-infra/src/main/java/kr/co/fitapet/infra/client/fcm/NotificationRequest.java @@ -6,6 +6,7 @@ import lombok.Builder; import java.util.List; +import java.util.Map; /** * FCM 메시지 전송 요청 @@ -20,7 +21,8 @@ public record NotificationRequest( String topic, String title, String body, - String imageUrl + String imageUrl, + Map data ) { public NotificationRequest { // tokens가 있으면 topic은 없어야 한다. 역도 성립 @@ -41,29 +43,38 @@ public record NotificationRequest( } } - public static NotificationRequest valueOf(List tokens, String title, String body, String imageUrl) { - return new NotificationRequest(tokens, null, title, body, imageUrl); + public static NotificationRequest valueOf(List tokens, String title, String body, String imageUrl, Map data) { + return new NotificationRequest(tokens, null, title, body, imageUrl, data); } - public static NotificationRequest valueOf(String topic, String title, String body, String imageUrl) { - return new NotificationRequest(null, topic, title, body, imageUrl); + public static NotificationRequest valueOf(String topic, String title, String body, String imageUrl, Map data) { + return new NotificationRequest(null, topic, title, body, imageUrl, data); + } + + public static NotificationRequest fromEvent(NotificationEvent event) { + return (event.isTopic()) ? + valueOf(event.topic(), event.title(), event.content(), event.imageUrl(), event.data()) : + valueOf(event.deviceTokens(), event.title(), event.content(), event.imageUrl(), event.data()); } public Message.Builder buildSendMessageToToken() { return Message.builder() .setToken(tokens.get(0)) + .putAllData(data) .setNotification(toNotification()); } public MulticastMessage.Builder buildSendMessagesToTokens() { return MulticastMessage.builder() .setNotification(toNotification()) + .putAllData(data) .addAllTokens(tokens); } public Message.Builder buildSendMessageToTopic() { return Message.builder() .setNotification(toNotification()) + .putAllData(data) .setTopic(topic); } diff --git a/fitapet-infra/src/main/java/kr/co/fitapet/infra/common/event/FcmNotificationEventHandler.java b/fitapet-infra/src/main/java/kr/co/fitapet/infra/common/event/FcmNotificationEventHandler.java new file mode 100644 index 00000000..38e12c5d --- /dev/null +++ b/fitapet-infra/src/main/java/kr/co/fitapet/infra/common/event/FcmNotificationEventHandler.java @@ -0,0 +1,37 @@ +package kr.co.fitapet.infra.common.event; + +import com.google.firebase.FirebaseException; +import kr.co.fitapet.infra.client.fcm.NotificationEvent; +import kr.co.fitapet.infra.client.fcm.NotificationRequest; +import kr.co.fitapet.infra.client.fcm.NotificationService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; +import org.springframework.transaction.event.TransactionalEventListener; + +@Component +@RequiredArgsConstructor +@Slf4j +public class FcmNotificationEventHandler { + private final NotificationService notificationService; + + @TransactionalEventListener + public void handleTokensEvent(NotificationEvent event) { + log.info("handleTokensEvent: {}", event); + NotificationRequest request = NotificationRequest.fromEvent(event); + + try { + if (event.isTopic()) { + notificationService.sendMessagesToTopic(request); + } else if (event.isMulticast()) { + notificationService.sendMessages(request); + } else { + notificationService.sendMessage(request); + } + } catch (FirebaseException e) { + e.printStackTrace(); + } + + log.info("Successfully sent FCM message"); + } +} diff --git a/fitapet-infra/src/main/java/kr/co/fitapet/infra/config/FcmConfig.java b/fitapet-infra/src/main/java/kr/co/fitapet/infra/config/FcmConfig.java index 119b2b02..16664b88 100644 --- a/fitapet-infra/src/main/java/kr/co/fitapet/infra/config/FcmConfig.java +++ b/fitapet-infra/src/main/java/kr/co/fitapet/infra/config/FcmConfig.java @@ -4,6 +4,7 @@ import com.google.firebase.FirebaseApp; import com.google.firebase.FirebaseOptions; import com.google.firebase.messaging.FirebaseMessaging; +import jakarta.annotation.PostConstruct; import org.springframework.beans.factory.annotation.Value; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; @@ -14,21 +15,34 @@ @Configuration public class FcmConfig { private final ClassPathResource firebaseResource; + private final String projectId; - public FcmConfig(@Value("${app.firebase.config.file}") String firebaseConfigPath) { + public FcmConfig( + @Value("${app.firebase.config.file}") String firebaseConfigPath, + @Value("${app.firebase.project.id}") String projectId + ) { this.firebaseResource = new ClassPathResource(firebaseConfigPath); + this.projectId = projectId; } - @Bean - FirebaseApp firebaseApp() throws IOException { + @PostConstruct + public void init() throws IOException { FirebaseOptions option = FirebaseOptions.builder() .setCredentials(GoogleCredentials.fromStream(firebaseResource.getInputStream())) + .setProjectId(projectId) .build(); - return FirebaseApp.initializeApp(option); + if (FirebaseApp.getApps().isEmpty()) { + FirebaseApp.initializeApp(option); + } + } + + @Bean + FirebaseApp firebaseApp() { + return FirebaseApp.getInstance(); } @Bean - FirebaseMessaging firebaseMessaging() throws IOException { + FirebaseMessaging firebaseMessaging() { return FirebaseMessaging.getInstance(firebaseApp()); } } diff --git a/fitapet-infra/src/main/resources/application-infra.yml b/fitapet-infra/src/main/resources/application-infra.yml index 44a0b413..39e61329 100644 --- a/fitapet-infra/src/main/resources/application-infra.yml +++ b/fitapet-infra/src/main/resources/application-infra.yml @@ -70,6 +70,8 @@ app: firebase: config: file: ${FIREBASE_CONFIG_FILE} + project: + id: ${FIREBASE_PROJECT_ID} --- spring: @@ -137,4 +139,6 @@ cloud: app: firebase: config: - file: ${FIREBASE_CONFIG_FILE} \ No newline at end of file + file: ${FIREBASE_CONFIG_FILE} + project: + id: ${FIREBASE_PROJECT_ID} \ No newline at end of file