Skip to content

Commit

Permalink
[Feat] fcm 메시지 알람 기능 구현 (#9)
Browse files Browse the repository at this point in the history
* chore: 외부통신을 위한 webflux의존성 추가

* feat: KAKAO API를 통해 메시지를 보내는 기능 구현

* feat: 시스템 시간 정의(Clock)

* chore: firebase의존성 주입

* feat: fcm을 사용해 알람기능 구현

* feat: 이벤트 객체 정의(수업 생성)

* feat: lessonid에 따른 객체 찾는 기능구현

* feat: 이벤트 객체 정의(수업 철회)

* feat: 알람 기능 추상화 밑 스케줄러 기능 구현

* feat: Letter기능 및 알람 메시지 구현

* feat: 오후 **시 **분 형식으로 변경하는 메서드 구현

* feat: 알람기능 구현(수업신청, 수업철회)

* feat: 알람기능의 필요한 스케줄 기능 추가

* chore: DB의존성 추가

* feat:서버가 재시작했을때 알림을 초기화해주는 기능구현

* feat: 알람 확인을 위한 프론트코드 구현

* feat: firebase 설정 추가

* feat: 알람 테스트를 위한 컨트롤러 작성

* feat: 스케쥴러 빈 등록

* feat: Redis를 활용하여 분산된서버일 때 중복으로 알람이 발생하는 문제 해결로직 작성

---------

Co-authored-by: yuseonjun <[email protected]>
  • Loading branch information
ingpyo and SeonJuuuun authored Mar 8, 2024
1 parent 9ebbfc1 commit de57354
Show file tree
Hide file tree
Showing 29 changed files with 672 additions and 4 deletions.
7 changes: 7 additions & 0 deletions doochul/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,13 @@ repositories {
dependencies {
implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
implementation 'org.springframework.boot:spring-boot-starter-web'
implementation 'org.springframework.boot:spring-boot-starter-webflux'
implementation 'org.springframework.boot:spring-boot-starter-data-redis'

implementation 'com.google.firebase:firebase-admin:9.2.0'

runtimeOnly 'mysql:mysql-connector-java'
runtimeOnly 'com.h2database:h2'
compileOnly 'org.projectlombok:lombok'
annotationProcessor 'org.projectlombok:lombok'
testImplementation 'org.springframework.boot:spring-boot-starter-test'
Expand Down
25 changes: 25 additions & 0 deletions doochul/src/main/java/org/doochul/application/RedisService.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
package org.doochul.application;

import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.stereotype.Service;

import java.time.Duration;

@Service
public class RedisService {

private final RedisTemplate<String, String> redisTemplate;

public RedisService(final RedisTemplate<String, String> redisTemplate) {
this.redisTemplate = redisTemplate;
}

public void delete(final String key) {
redisTemplate.delete(key);
}


public boolean setNX(final String key, final String value, final Duration duration) {
return Boolean.TRUE.equals(redisTemplate.opsForValue().setIfAbsent(key, value, duration));
}
}
31 changes: 31 additions & 0 deletions doochul/src/main/java/org/doochul/config/AppConfig.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
package org.doochul.config;

import java.time.Clock;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.scheduling.TaskScheduler;
import org.springframework.scheduling.concurrent.ThreadPoolTaskScheduler;
import org.springframework.scheduling.concurrent.ThreadPoolTaskScheduler;

@Configuration
public class AppConfig {

@Bean
public Clock clock() {
return Clock.systemDefaultZone();
}

@Bean
public TaskScheduler taskScheduler() {
ThreadPoolTaskScheduler taskScheduler = new ThreadPoolTaskScheduler();
taskScheduler.setPoolSize(10);
return taskScheduler;
}

@Bean
public ThreadPoolTaskScheduler taskScheduler() {
ThreadPoolTaskScheduler scheduler = new ThreadPoolTaskScheduler();
scheduler.setPoolSize(2);
return scheduler;
}
}
26 changes: 26 additions & 0 deletions doochul/src/main/java/org/doochul/config/RedisConfig.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
package org.doochul.config;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.redis.connection.RedisConnectionFactory;
import org.springframework.data.redis.connection.lettuce.LettuceConnectionFactory;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.serializer.StringRedisSerializer;

@Configuration
public class RedisConfig {

@Bean
public RedisConnectionFactory redisConnectionFactory() {
return new LettuceConnectionFactory();
}

@Bean
public RedisTemplate<String, String> redisTemplate() {
RedisTemplate<String, String> redisTemplate = new RedisTemplate<>();
redisTemplate.setConnectionFactory(redisConnectionFactory());
redisTemplate.setKeySerializer(new StringRedisSerializer());
redisTemplate.setValueSerializer(new StringRedisSerializer());
return redisTemplate;
}
}
10 changes: 10 additions & 0 deletions doochul/src/main/java/org/doochul/config/SchedulingConfig.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
package org.doochul.config;

import org.springframework.context.annotation.Configuration;
import org.springframework.scheduling.annotation.EnableScheduling;

@Configuration
@EnableScheduling
public class SchedulingConfig {

}
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
import lombok.Getter;
import lombok.NoArgsConstructor;
import org.doochul.domain.BaseEntity;
import org.doochul.domain.memberShip.MemberShip;
import org.doochul.domain.membership.MemberShip;

@Entity
@Getter
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,5 +2,13 @@

import org.springframework.data.jpa.repository.JpaRepository;

import java.time.LocalDateTime;
import java.util.List;

public interface LessonRepository extends JpaRepository<Lesson, Long> {
default Lesson getById(final Long id) {
return findById(id).orElseThrow(() -> new IllegalArgumentException("해당 수업이 없습니다."));
}

List<Lesson> findByStartedAtBefore(final LocalDateTime currentServerTime);
}
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
package org.doochul.domain.memberShip;
package org.doochul.domain.membership;

import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
Expand Down Expand Up @@ -31,4 +31,19 @@ public class MemberShip extends BaseEntity {
private Product product;

private Integer remainingCount;

public void decreasedCount() {
validateMinRemainingCount();
remainingCount -= 1;
}

public boolean isCountZero() {
return remainingCount == 0;
}

private void validateMinRemainingCount() {
if (remainingCount < 1) {
throw new IllegalArgumentException("안돼");
}
}
}
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
package org.doochul.domain.memberShip;
package org.doochul.domain.membership;

import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
package org.doochul.domain.membership;

public enum MemberShipType {
LOL, TFT
}
1 change: 0 additions & 1 deletion doochul/src/main/java/org/doochul/domain/user/User.java
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,6 @@
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Table(name = "users")
@RequiredArgsConstructor
public class User extends BaseEntity {

@Id
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,4 +5,7 @@

@Repository
public interface UserRepository extends JpaRepository<User, Long> {
default User getById(final Long id) {
return findById(id).orElseThrow(() -> new IllegalArgumentException("해당 유저는 없습니다."));
}
}
67 changes: 67 additions & 0 deletions doochul/src/main/java/org/doochul/infra/FcmMessageSender.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
package org.doochul.infra;

import com.google.auth.oauth2.GoogleCredentials;
import com.google.firebase.FirebaseApp;
import com.google.firebase.FirebaseOptions;
import com.google.firebase.messaging.FirebaseMessaging;
import com.google.firebase.messaging.Message;
import com.google.firebase.messaging.Notification;
import jakarta.annotation.PostConstruct;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.doochul.service.Letter;
import org.doochul.service.MessageSendManager;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.core.io.ClassPathResource;
import org.springframework.stereotype.Component;

import java.io.FileNotFoundException;
import java.io.IOException;
import java.util.concurrent.ExecutionException;

@Slf4j
@Component
@RequiredArgsConstructor
public class FcmMessageSender implements MessageSendManager {

@Value("${fcm.certification.path}")
private String FCM_CERTIFICATION_PATH;

@PostConstruct
public void initialize() {
try {
ClassPathResource resource = new ClassPathResource(FCM_CERTIFICATION_PATH);
FirebaseOptions options = FirebaseOptions.builder()
.setCredentials(GoogleCredentials.fromStream(resource.getInputStream()))
.build();

if (FirebaseApp.getApps().isEmpty()) {
FirebaseApp.initializeApp(options);
}
} catch (FileNotFoundException e) {
log.error("파일을 찾을 수 없습니다. ", e);
} catch (IOException e) {
log.error("FCM 인증이 실패했습니다. ", e);
}
}

@Override
public void sendTo(final Letter letter) {
final Notification notification = Notification.builder()
.setTitle(letter.title())
.setBody(letter.body())
.build();
final Message message = Message.builder()
.setToken(letter.targetToken())
.setNotification(notification)
.build();
try {
final String response = FirebaseMessaging.getInstance().sendAsync(message).get();
log.info("알림 전송 성공 : " + response);
} catch (InterruptedException e) {
log.error("FCM 알림 스레드에서 문제가 발생했습니다.", e);
} catch (ExecutionException e) {
log.error("FCM 알림 전송에 실패했습니다.", e);
}
}
}
11 changes: 11 additions & 0 deletions doochul/src/main/java/org/doochul/service/LessonCreateEvent.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
package org.doochul.service;

import org.doochul.domain.lesson.Lesson;
import org.doochul.domain.user.User;

public record LessonCreateEvent(
User student,
User teacher,
Lesson lesson
) {
}
40 changes: 40 additions & 0 deletions doochul/src/main/java/org/doochul/service/LessonStatus.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
package org.doochul.service;

import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;

@FunctionalInterface
interface LessonStatusMessage {
String getMessage(String name, LocalDateTime time, String teacher);
}

public enum LessonStatus {
BEFORE_LESSON("수업 전",
(name, time, teacher) -> name + "님 " + formatToLocalTime(time) + " " + teacher + " 강사님 수업 잊지 않으셨죠?"),
AFTER_LESSON("수업 후", (name, time, teacher) -> name + "님 오늘 수업 잘 받으셨나요?"),
SCHEDULED_LESSON("수업 신청",
(name, time, teacher) -> formatToLocalTime(time) + " " + name + "님 " + teacher + " 강사님 수업을 신청했습니다."),
WITHDRAWN_LESSON("수업 철회",
(name, time, teacher) -> formatToLocalTime(time) + " " + name + "님 " + teacher + " 강사님의 수업을 철회했습니다."),
END_LESSON("수업 종료", (name, time, teacher) -> teacher + " 강사님의 수업이 모두 종료되었습니다.");

private final String title;
private final LessonStatusMessage message;

LessonStatus(String title, LessonStatusMessage message) {
this.title = title;
this.message = message;
}

private static String formatToLocalTime(LocalDateTime time) {
return time.format(DateTimeFormatter.ofPattern("a HH시 mm분"));
}

public String getTitle() {
return title;
}

public String getMessage(String name, LocalDateTime time, String teacher) {
return message.getMessage(name, time, teacher);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
package org.doochul.service;

import org.doochul.domain.lesson.Lesson;
import org.doochul.domain.user.User;

public record LessonWithdrawnEvent(
User student,
User teacher,
Lesson lesson
) {
}
21 changes: 21 additions & 0 deletions doochul/src/main/java/org/doochul/service/Letter.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
package org.doochul.service;

import org.doochul.domain.user.User;

import java.time.LocalDateTime;

public record Letter(
String targetToken,
String title,
String body
) {
public static Letter of(
final User student,
final User teacher,
final LocalDateTime startedAt,
final LessonStatus lessonStatus
) {
final String message = lessonStatus.getMessage(student.getName(), startedAt, teacher.getName());
return new Letter(student.getDeviceToken(), lessonStatus.getTitle(), message);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
package org.doochul.service;

public interface MessageSendManager {
void sendTo(final Letter letter);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
package org.doochul.service;

import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.context.event.EventListener;
import org.springframework.scheduling.annotation.Async;
import org.springframework.stereotype.Component;

@Slf4j
@Component
@RequiredArgsConstructor
public class NotificationEventListener {
private final NotificationService notificationService;

@EventListener
@Async
public void scheduleLessonNotificationEvent(final LessonCreateEvent event) {
notificationService.applyForLesson(event);
}

@EventListener
@Async
public void withdrawnLessonNotificationEvent1(final LessonWithdrawnEvent event) {
notificationService.withdrawnForLessons(event);
}
}
Loading

0 comments on commit de57354

Please sign in to comment.