Skip to content

Commit

Permalink
feat: #39 noticeType && noticeEvent
Browse files Browse the repository at this point in the history
  • Loading branch information
psychology50 committed Feb 27, 2024
1 parent 5e8c9b7 commit 242d651
Show file tree
Hide file tree
Showing 12 changed files with 313 additions and 14 deletions.
Original file line number Diff line number Diff line change
@@ -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<NotificationDataKey, ?> data
) {
}
Original file line number Diff line number Diff line change
@@ -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 등록
}
}
Original file line number Diff line number Diff line change
@@ -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;

/**
* 알림 타입을 정의하는 클래스입니다.
*
* <p>
* 알림 타입은 [DOMAIN]_[ACTION]_[FROM]_[TO] 혹은 [DOMAIN]_[ACTION]_[FROM]_[SUBJECT] 형식으로 정의합니다. (FROM, TO, SUBJECT는 선택사항입니다.)
* NoticeContentType은 각 알림 타입에 대한 이름, 제목 및 내용을 정의합니다.
* </p>
*/
@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<String> order;

NoticeType(String title, String contentFormat, String ...args) {
this.title = title;
this.contentFormat = contentFormat;
this.order = List.of(args);
}

/**
* 알림 타입에 맞는 내용을 생성합니다.
* @param args Map<String, String> : 알림 타입에 맞는 인자
* @return String : content
*/
public String createFormattedContent(Map<String, String> 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);
}
}
Original file line number Diff line number Diff line change
@@ -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);
}
}
Original file line number Diff line number Diff line change
@@ -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);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Original file line number Diff line number Diff line change
@@ -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<String> getNameFields() {
return List.of("fromName", "toName", "domainName", "subjectName");
}
}
Original file line number Diff line number Diff line change
@@ -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
*
* <p>
* 하나 이상의 디바이스에 푸시 알림을 보내기 위한 객체입니다.
* 푸시 알림은 단일 디바이스, 여러 디바이스 또는 토픽으로 보낼 수 있습니다.
* 푸시 알림에는 제목, 내용 및 이미지 URL이 포함될 수 있습니다.
* 푸시 알림이 토픽으로 보내지는 경우 deviceTokens 필드는 null이어야 합니다.
* </p>
* @param deviceTokens List<String> : 푸시 알림을 받을 디바이스 토큰 리스트
* @param title String : 푸시 알림 제목
* @param topic String : 푸시 알림을 받을 토픽
* @param content String : 푸시 알림 내용
* @param imageUrl String : 푸시 알림 이미지 URL
*/
@Builder
public record NotificationEvent(
List<String> deviceTokens,
String title,
String topic,
String content,
String imageUrl,
Map<String, String> data
) {
public boolean isTopic() {
return this.deviceTokens == null;
}

public boolean isMulticast() {
return this.deviceTokens() != null && this.deviceTokens().size() > 1;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
import lombok.Builder;

import java.util.List;
import java.util.Map;

/**
* FCM 메시지 전송 요청
Expand All @@ -20,7 +21,8 @@ public record NotificationRequest(
String topic,
String title,
String body,
String imageUrl
String imageUrl,
Map<String, String> data
) {
public NotificationRequest {
// tokens가 있으면 topic은 없어야 한다. 역도 성립
Expand All @@ -41,29 +43,38 @@ public record NotificationRequest(
}
}

public static NotificationRequest valueOf(List<String> tokens, String title, String body, String imageUrl) {
return new NotificationRequest(tokens, null, title, body, imageUrl);
public static NotificationRequest valueOf(List<String> tokens, String title, String body, String imageUrl, Map<String, String> 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<String, String> 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);
}

Expand Down
Original file line number Diff line number Diff line change
@@ -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");
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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());
}
}
Loading

0 comments on commit 242d651

Please sign in to comment.