Skip to content

Commit

Permalink
[ARV-196] 알림 도메인 프로젝트 초기 설정 (#190)
Browse files Browse the repository at this point in the history
* [ARV-196] build: firebase admin SDK 9.3.0 버전 의존성 추가

* [ARV-196] style: gradle 주석 수정

* [ARV-196] chore: fcm init 추가

- 민감 정보 ignore 설정

* [ARV-196] feat: notification api 기초 설계

* [ARV-196] feat: notification service 및 infra 기초 설계

* [ARV-196] test: OAuth2 Register Integration Test 제거

- 따로 테스트 코드 전부 리뉴얼 예정

* [ARV-196] feat: 알링 기능 구현

- 알림 전송 이벤트 비동기화
- 알림 읽기, 알림 조회
- 디바이스 토큰 등록 및 삭제

* [ARV-196] fix: 알림 조회 페이징 적용

- no offset 사용

* [ARV-196] fix: CICD script에 firebase 설정 추가

- build 시 firebase-service-account.json 등록하도록 추가
- 해당 파일은 github secret 관리

* [ARV-196] style: NotificationService 주석 추가

- build 시 firebase-service-account.json 등록하도록 추가
- 해당 파일은 github secret 관리
  • Loading branch information
sangcci authored Feb 5, 2025
1 parent d7e5683 commit 23155e2
Show file tree
Hide file tree
Showing 26 changed files with 680 additions and 83 deletions.
7 changes: 7 additions & 0 deletions .github/workflows/CICD.yml
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,11 @@ jobs:
echo "${{ secrets.CI_YML }}" | base64 --decode > src/main/resources/application.yml
echo "${{ secrets.CI_YML }}" | base64 --decode > src/test/resources/application.yml
- name: Create firebase-service-account.json
run: |
mkdir -p src/main/resources/firebase
echo "${{ secrets.FIREBASE_SERVICE_ACCOUNT }}" | base64 --decode > src/main/resources/firebase/firebase-service-account.json
- name: Grant execute permission for gradlew
run: chmod +x gradlew

Expand All @@ -77,7 +82,9 @@ jobs:
- name: Create application.yml
run: |
mkdir -p src/main/resources
mkdir -o src/main/resources/firebase
echo "${{ secrets.CD_YML }}" | base64 --decode > src/main/resources/application.yml
echo "${{ secrets.FIREBASE_SERVICE_ACCOUNT }}" | base64 --decode > src/main/resources/firebase/firebase-service-account.json
- name: Set up JDK 17
uses: actions/setup-java@v3
Expand Down
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,9 @@ build/
!**/src/main/**/build/
!**/src/test/**/build/

### Firebase ###
/src/main/resources/firebase/firebase-service-account.json

### STS ###
.apt_generated
.classpath
Expand Down
3 changes: 3 additions & 0 deletions build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,9 @@ dependencies {
implementation 'org.springframework.boot:spring-boot-starter-data-redis'
implementation 'it.ozimov:embedded-redis:0.7.2'

// firebase
implementation 'com.google.firebase:firebase-admin:9.3.0'

// aws s3
implementation 'io.awspring.cloud:spring-cloud-aws-starter-s3:3.0.0'

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
package com.backend.allreva.common.config;

import com.google.auth.oauth2.GoogleCredentials;
import com.google.firebase.FirebaseApp;
import com.google.firebase.FirebaseOptions;
import java.io.FileInputStream;
import java.io.IOException;
import javax.annotation.PostConstruct;
import org.springframework.stereotype.Component;

@Component
public class FcmInitializer {

@PostConstruct
public void initizlize() {
try (FileInputStream serviceAccount = new FileInputStream("./src/main/resources/firebase/firebase-service-account.json")) {
FirebaseOptions options = FirebaseOptions.builder()
.setCredentials(GoogleCredentials.fromStream(serviceAccount))
.build();
FirebaseApp.initializeApp(options);
} catch (IOException e) {
e.printStackTrace();
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
package com.backend.allreva.common.event;

import java.util.List;
import lombok.Builder;

@Builder
public record NotificationEvent(
List<Long> recipientIds,
String title,
String message
) {

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
package com.backend.allreva.common.event;

import java.util.List;
import lombok.Getter;

@Getter
public enum NotificationMessage {

NEW_NOTIFICATION("새로운 알림 등록", "새로운 알림이 도착했습니다"),
NEW_CHAT_MESSAGE("%s", "%s");

private final String title;
private final String message;

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

public NotificationEvent toEvent(List<Long> recipientIds) {
return NotificationEvent.builder()
.title(title)
.message(message)
.recipientIds(recipientIds)
.build();
}
}
44 changes: 44 additions & 0 deletions src/main/java/com/backend/allreva/firebase/dto/FcmMessage.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
package com.backend.allreva.firebase.dto;

import com.fasterxml.jackson.annotation.JsonProperty;
import lombok.Builder;

@Builder
public record FcmMessage(
@JsonProperty("validate_only") boolean validateOnly,
Message message
) {
@Builder
public record Message(
Notification notification,
String token
) {

}

@Builder
public record Notification(
String title,
String body
) {

}

public static FcmMessage from(
final String token,
final boolean validateOnly,
final String title,
final String message
) {
return FcmMessage.builder()
.validateOnly(validateOnly)
.message(Message.builder()
.notification(Notification.builder()
.title(title)
.body(message)
.build())
.token(token)
.build())
.build();
}
}
19 changes: 19 additions & 0 deletions src/main/java/com/backend/allreva/firebase/infra/FcmClient.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
package com.backend.allreva.firebase.infra;

import com.backend.allreva.firebase.dto.FcmMessage;
import org.springframework.cloud.openfeign.FeignClient;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestHeader;

@FeignClient(name = "fcmClient", url = "https://fcm.googleapis.com/v1")
public interface FcmClient {

@PostMapping("/projects/{projectId}/messages:send")
String sendMessage(
@RequestHeader("Authorization") final String authorization,
@RequestBody final FcmMessage message,
@PathVariable("projectId") final String projectId
);
}
37 changes: 37 additions & 0 deletions src/main/java/com/backend/allreva/firebase/service/FcmSender.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
package com.backend.allreva.firebase.service;

import com.backend.allreva.firebase.dto.FcmMessage;
import com.backend.allreva.firebase.infra.FcmClient;
import com.backend.allreva.firebase.util.FcmTokenUtils;
import com.backend.allreva.notification.command.NotificationSender;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;

@Slf4j
@Component
@RequiredArgsConstructor
public class FcmSender implements NotificationSender {

private final FcmClient fcmClient;

@Value("${fcm.project-id}")
private String projectId;

public void sendMessage(
final String deviceToken,
final String title,
final String message
) {
String accessToken = FcmTokenUtils.getAccessToken();
String authorizationHeader = "Bearer " + accessToken;
FcmMessage fcmMessage = FcmMessage.from(deviceToken, false, title, message);
fcmClient.sendMessage(
authorizationHeader,
fcmMessage,
projectId
);
log.info("FCM 메시지 전송 성공");
}
}
52 changes: 52 additions & 0 deletions src/main/java/com/backend/allreva/firebase/util/FcmTokenUtils.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
package com.backend.allreva.firebase.util;

import com.google.auth.oauth2.AccessToken;
import com.google.auth.oauth2.GoogleCredentials;
import java.io.FileInputStream;
import java.io.IOException;
import java.time.Instant;
import java.util.List;
import lombok.NoArgsConstructor;
import lombok.extern.slf4j.Slf4j;

@Slf4j
@NoArgsConstructor(access = lombok.AccessLevel.PRIVATE)
public final class FcmTokenUtils {

private static final String KEY_PATH = "src/main/resources/firebase/firebase-service-account.json";
private static final GoogleCredentials googleCredentials;
private static AccessToken accessToken;

static {
try (FileInputStream serviceAccount = new FileInputStream(KEY_PATH)) {
googleCredentials = GoogleCredentials
.fromStream(serviceAccount)
.createScoped(List.of("https://www.googleapis.com/auth/firebase.messaging"));
} catch (IOException e) {
log.error("GoogleCredentials 초기화 실패");
throw new RuntimeException(e);
}
}

public static String getAccessToken() {
try {
// 기존 캐시된 토큰이 존재하고, 만료되지 않았다면 재사용
if (accessToken != null && !isTokenExpired(accessToken)) {
log.info("기존 엑세스 토큰 재사용. 만료 시간: {}", accessToken.getExpirationTime());
return accessToken.getTokenValue();
}
// 토큰 갱신
googleCredentials.refreshIfExpired();
accessToken = googleCredentials.getAccessToken();
log.info("새 엑세스 토큰 발급. 만료 시간: {}", accessToken.getExpirationTime());
return accessToken.getTokenValue();
} catch (IOException e) {
log.error("엑세스 토큰 발급 실패");
throw new RuntimeException(e);
}
}

private static boolean isTokenExpired(AccessToken token) {
return token.getExpirationTime().toInstant().isBefore(Instant.now());
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
package com.backend.allreva.notification.command;

import com.backend.allreva.notification.command.domain.Notification;
import java.util.List;
import java.util.Optional;

public interface NotificationRepository {
Optional<Notification> findById(Long id);
Optional<Notification> findByIdAndRecipientId(Long id, Long recipientId);
List<Notification> findNotificationsByRecipientId(Long recipientId, Long lastId, int pageSize);
List<Notification> findAll();

void saveAll(List<Notification> notifications);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
package com.backend.allreva.notification.command;

public interface NotificationSender {

void sendMessage(String deviceToken, String title, String message);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
package com.backend.allreva.notification.command;

import com.backend.allreva.common.event.NotificationEvent;
import com.backend.allreva.member.command.domain.Member;
import com.backend.allreva.notification.command.domain.Notification;
import com.backend.allreva.notification.command.dto.DeviceTokenRequest;
import com.backend.allreva.notification.command.dto.NotificationIdRequest;
import com.backend.allreva.notification.exception.NotificationNotFoundException;
import com.backend.allreva.notification.infra.DeviceTokenRepository;
import java.util.List;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.context.event.EventListener;
import org.springframework.scheduling.annotation.Async;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

@Slf4j
@Service
@RequiredArgsConstructor
public class NotificationService {

private final NotificationSender notificationSender;
private final NotificationRepository notificationRepository;
private final DeviceTokenRepository deviceTokenRepository;

@Transactional(readOnly = true)
public List<Notification> getNotificationsByRecipientId(final Member member, final Long lastId, final int pageSize) {
return notificationRepository.findNotificationsByRecipientId(member.getId(), lastId, pageSize);
}

@Transactional
public void markAsRead(final Member member, final NotificationIdRequest notificationIdRequest) {
Notification notification = notificationRepository.findByIdAndRecipientId(member.getId(), notificationIdRequest.id())
.orElseThrow(NotificationNotFoundException::new);
notification.read();
}

public void registerDeviceToken(final Member member, final DeviceTokenRequest deviceTokenRequest) {
deviceTokenRepository.save(member.getId(), deviceTokenRequest.deviceToken());
}

public void deleteDeviceToken(final Member member) {
deviceTokenRepository.delete(member.getId());
}

@Async
@EventListener
public void sendMessage(final NotificationEvent event) {
// device token 가져오기 (지금은 fcm 고정)
List<String> deviceTokens = deviceTokenRepository.findTokensByMemberIds(event.recipientIds());
if (deviceTokens == null || deviceTokens.isEmpty()) {
log.debug("알림 전송할 대상이 없습니다.");
return;
}
// 알림 메세지 보내기
// TODO: 알림 전송 실패 시 대책 마련
deviceTokens.forEach(fcmToken ->
notificationSender.sendMessage(fcmToken, event.title(), event.message())
);
log.debug("알림 메세지 전송 완료");
// 알림 메세지 저장
List<Notification> notificationEntities = event.recipientIds().stream()
.map(recipientId -> Notification.from(event.title(), event.message(), recipientId))
.toList();
notificationRepository.saveAll(notificationEntities);
log.debug("알림 메세지 저장 완료");
}
}
Loading

0 comments on commit 23155e2

Please sign in to comment.