Skip to content

Commit

Permalink
feat: 모임시작 5분전 알림 기능 추가 (#159)
Browse files Browse the repository at this point in the history
* feat: Meeting 도메인에서 1분마다 5분뒤에 시작하는 미팅들을 이벤트로 발송하게 한다

* feat: alert 도메인 구현 및 fcmAlertPublisher 를 정의한다

* refactor: Meeting 도메인 참조를 app 패키지로 이동시킨다

* feat: AlertController를 구현한다

* feat: Alert 엔티티를 UserAlert 로 변경하고, 알림의 종류에 대해서 추상화 한다

* feat: FCM 설정을 구현하고 통신에 retry 정책을 추가한다

* refactor: flyway V9가 유저 리뷰랑 겹쳐서 10으로 업데이트

* fix: AlertRepository의 쿼리를 수정하고, FCM관련 기능들이 prod 프로파일에서만 동작하도록 수정한다
  • Loading branch information
devxb authored Jan 28, 2024
1 parent d24c6a7 commit feebed6
Show file tree
Hide file tree
Showing 25 changed files with 450 additions and 8 deletions.
1 change: 1 addition & 0 deletions build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ apply from: "gradle/db.gradle"
apply from: "gradle/aws.gradle"
apply from: "gradle/sentry.gradle"
apply from: "gradle/gatling.gradle"
apply from: "gradle/messaging.gradle"

allprojects {

Expand Down
2 changes: 2 additions & 0 deletions gradle.properties
Original file line number Diff line number Diff line change
Expand Up @@ -27,3 +27,5 @@ jwtVersion=0.11.5
gatlingVersion=3.9.5.6
### Data faker ###
datafakerVersion=2.0.2
### FCM ###
fcmVersion=9.2.0
4 changes: 4 additions & 0 deletions gradle/messaging.gradle
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
dependencies {
implementation "com.google.firebase:firebase-admin:${fcmVersion}"

}
6 changes: 5 additions & 1 deletion src/main/java/net/teumteum/Application.java
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,14 @@

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.scheduling.annotation.EnableAsync;
import org.springframework.scheduling.annotation.EnableScheduling;


@EnableAsync
@EnableScheduling
@SpringBootApplication
public class Application {

public static void main(String[] args) {
SpringApplication.run(Application.class, args);
}
Expand Down
18 changes: 18 additions & 0 deletions src/main/java/net/teumteum/alert/app/AlertExecutorConfigurer.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
package net.teumteum.alert.app;

import java.util.concurrent.Executor;
import java.util.concurrent.Executors;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

@Configuration
public class AlertExecutorConfigurer {

public static final String ALERT_EXECUTOR = "alertExecutor";

@Bean
public Executor alertExecutor() {
return Executors.newSingleThreadScheduledExecutor();
}

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
package net.teumteum.alert.app;

import static net.teumteum.alert.app.AlertExecutorConfigurer.ALERT_EXECUTOR;

import java.time.Instant;
import lombok.RequiredArgsConstructor;
import net.teumteum.alert.domain.AlertPublisher;
import net.teumteum.alert.domain.AlertService;
import net.teumteum.alert.domain.Alertable;
import net.teumteum.alert.domain.BeforeMeetingAlert;
import net.teumteum.meeting.domain.MeetingAlerted;
import org.springframework.context.annotation.Profile;
import org.springframework.context.event.EventListener;
import org.springframework.scheduling.annotation.Async;
import org.springframework.stereotype.Service;

@Service
@Profile("prod")
@RequiredArgsConstructor
public class BeforeMeetingAlertHandler {

private final AlertService alertService;
private final AlertPublisher<BeforeMeetingAlert> alertPublisher;

@Async(ALERT_EXECUTOR)
@EventListener({MeetingAlerted.class})
public void alert(MeetingAlerted alerted) {
alertService.findAllByUserId(alerted.userIds())
.stream()
.map(userAlert -> new BeforeMeetingAlert(userAlert.getUserId(), userAlert.getToken(), Instant.now()))
.forEach(alertPublisher::publish);
}

}
27 changes: 27 additions & 0 deletions src/main/java/net/teumteum/alert/controller/AlertController.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
package net.teumteum.alert.controller;

import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import net.teumteum.alert.domain.AlertService;
import net.teumteum.alert.domain.request.RegisterAlertRequest;
import net.teumteum.core.security.service.SecurityService;
import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.ResponseStatus;
import org.springframework.web.bind.annotation.RestController;

@RestController
@RequiredArgsConstructor
public class AlertController {

private final AlertService alertService;
private final SecurityService securityService;

@PostMapping("/alerts")
@ResponseStatus(HttpStatus.OK)
public void registerAlert(@Valid @RequestBody RegisterAlertRequest registerAlertRequest) {
var loginUserId = securityService.getCurrentUserId();
alertService.registerAlert(loginUserId, registerAlertRequest);
}
}
8 changes: 8 additions & 0 deletions src/main/java/net/teumteum/alert/domain/AlertPublisher.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
package net.teumteum.alert.domain;

@FunctionalInterface
public interface AlertPublisher<T extends Alertable> {

void publish(T alertable);

}
12 changes: 12 additions & 0 deletions src/main/java/net/teumteum/alert/domain/AlertRepository.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
package net.teumteum.alert.domain;

import java.util.List;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;

public interface AlertRepository extends JpaRepository<UserAlert, Long> {

@Query("select u from user_alert as u where u.userId in :userIds")
List<UserAlert> findAllByUserId(@Param("userIds") Iterable<Long> userIds);
}
26 changes: 26 additions & 0 deletions src/main/java/net/teumteum/alert/domain/AlertService.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
package net.teumteum.alert.domain;

import java.util.List;
import java.util.Set;
import lombok.RequiredArgsConstructor;
import net.teumteum.alert.domain.request.RegisterAlertRequest;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

@Service
@RequiredArgsConstructor
@Transactional(readOnly = true)
public class AlertService {

private final AlertRepository alertRepository;

@Transactional
public void registerAlert(Long userId, RegisterAlertRequest registerAlertRequest) {
var alert = new UserAlert(null, userId, registerAlertRequest.token());
alertRepository.save(alert);
}

public List<UserAlert> findAllByUserId(Set<Long> userIds) {
return alertRepository.findAllByUserId(userIds);
}
}
10 changes: 10 additions & 0 deletions src/main/java/net/teumteum/alert/domain/Alertable.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
package net.teumteum.alert.domain;

public interface Alertable {

String token();

String title();

String body();
}
20 changes: 20 additions & 0 deletions src/main/java/net/teumteum/alert/domain/BeforeMeetingAlert.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
package net.teumteum.alert.domain;

import java.time.Instant;

public record BeforeMeetingAlert(
Long userId,
String token,
Instant publishedAt
) implements Alertable {

@Override
public String title() {
return "5분 뒤에 모임이 시작돼요!";
}

@Override
public String body() {
return "모임 장소로 가서 틈틈 모임을 준비해주세요.";
}
}
34 changes: 34 additions & 0 deletions src/main/java/net/teumteum/alert/domain/UserAlert.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
package net.teumteum.alert.domain;

import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
import lombok.AllArgsConstructor;
import lombok.Getter;
import lombok.NoArgsConstructor;

@Getter
@NoArgsConstructor
@AllArgsConstructor
@Table(name = "user_alert")
@Entity(name = "user_alert")
public class UserAlert {

@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "id")
private Long id;

@Column(name = "user_id", nullable = false, unique = true)
private Long userId;

@Column(name = "token", nullable = false)
private String token;

public String getToken() {
return token;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
package net.teumteum.alert.domain.request;

import jakarta.validation.constraints.NotNull;

public record RegisterAlertRequest(
@NotNull
String token
) {

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
package net.teumteum.alert.infra;

import java.util.concurrent.Executor;
import java.util.concurrent.Executors;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

@Configuration
public class FcmAlertExecutorConfigurer {

public static final String FCM_ALERT_EXECUTOR = "fcmAlertExecutor";

@Bean
public Executor fcmAlertExecutor() {
return Executors.newSingleThreadScheduledExecutor();
}

}
97 changes: 97 additions & 0 deletions src/main/java/net/teumteum/alert/infra/FcmAlertPublisher.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
package net.teumteum.alert.infra;

import static net.teumteum.alert.infra.FcmAlertExecutorConfigurer.FCM_ALERT_EXECUTOR;

import com.google.auth.oauth2.GoogleCredentials;
import com.google.firebase.ErrorCode;
import com.google.firebase.FirebaseApp;
import com.google.firebase.FirebaseOptions;
import com.google.firebase.messaging.AndroidConfig;
import com.google.firebase.messaging.AndroidNotification;
import com.google.firebase.messaging.FirebaseMessaging;
import com.google.firebase.messaging.FirebaseMessagingException;
import com.google.firebase.messaging.Message;
import com.google.firebase.messaging.Notification;
import jakarta.annotation.PostConstruct;
import java.io.IOException;
import net.teumteum.alert.domain.AlertPublisher;
import net.teumteum.alert.domain.BeforeMeetingAlert;
import org.springframework.context.annotation.Profile;
import org.springframework.core.io.ClassPathResource;
import org.springframework.lang.Nullable;
import org.springframework.scheduling.annotation.Async;
import org.springframework.stereotype.Service;

@Service
@Profile("prod")
public class FcmAlertPublisher implements AlertPublisher<BeforeMeetingAlert> {

private static final int MAX_RETRY_COUNT = 5;
private static final String FCM_TOKEN_PATH = "teum-teum-12611-firebase-adminsdk-cjyx3-ea066f25ef.json";

@Override
@Async(FCM_ALERT_EXECUTOR)
public void publish(BeforeMeetingAlert beforeMeetingAlert) {
var message = buildMessage(beforeMeetingAlert);
publishWithRetry(0, message, null);
}

private void publishWithRetry(int currentRetryCount, Message message, @Nullable ErrorCode errorCode) {
if (MAX_RETRY_COUNT == currentRetryCount) {
return;
}
if (errorCode == ErrorCode.INTERNAL
|| errorCode == ErrorCode.CONFLICT
|| errorCode == ErrorCode.UNKNOWN
|| errorCode == ErrorCode.DATA_LOSS) {
try {
FirebaseMessaging.getInstance().send(message);
} catch (FirebaseMessagingException firebaseMessagingException) {
publishWithRetry(currentRetryCount + 1, message, firebaseMessagingException.getErrorCode());
}
}
}

private Message buildMessage(BeforeMeetingAlert beforeMeetingAlert) {
return Message.builder()
.setToken(beforeMeetingAlert.token())
.setNotification(buildNotification(beforeMeetingAlert))
.setAndroidConfig(buildAndroidConfig(beforeMeetingAlert))
.putData("publishedAt", beforeMeetingAlert.publishedAt().toString())
.putData("userId", beforeMeetingAlert.userId().toString())
.build();
}

private Notification buildNotification(BeforeMeetingAlert beforeMeetingAlert) {
return Notification.builder()
.setTitle(beforeMeetingAlert.title())
.setBody(beforeMeetingAlert.body())
.build();
}

private AndroidConfig buildAndroidConfig(BeforeMeetingAlert beforeMeetingAlert) {
return AndroidConfig.builder()
.setNotification(AndroidNotification.builder()
.setTitle(beforeMeetingAlert.title())
.setBody(beforeMeetingAlert.body())
.setClickAction("push_click")
.build())
.build();
}

@PostConstruct
private void fcmCredential() {
try {
var resource = new ClassPathResource(FCM_TOKEN_PATH);
resource.getInputStream();

var firebaseOptions = FirebaseOptions.builder()
.setCredentials(GoogleCredentials.fromStream(resource.getInputStream()))
.build();

FirebaseApp.initializeApp(firebaseOptions);
} catch (IOException ioException) {
throw new IllegalStateException("애플리케이션을 시작할 수 없습니다.", ioException);
}
}
}
4 changes: 4 additions & 0 deletions src/main/java/net/teumteum/meeting/domain/Meeting.java
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,10 @@ public boolean isHost(Long userId) {
return hostUserId.equals(userId);
}

public Set<Long> getParticipantUserIds() {
return new HashSet<>(participantUserIds);
}

@PrePersist
private void assertField() {
assertTitle();
Expand Down
7 changes: 7 additions & 0 deletions src/main/java/net/teumteum/meeting/domain/MeetingAlerted.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
package net.teumteum.meeting.domain;

import java.util.Set;

public record MeetingAlerted(Set<Long> userIds) {

}
Loading

0 comments on commit feebed6

Please sign in to comment.