Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[Feat#22] FCM을 활용한 알림 시스템 구현 #23

Merged
merged 9 commits into from
Feb 16, 2024
6 changes: 5 additions & 1 deletion .github/workflows/build.yml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
name: Build

on: [pull_request, workflow_dispatch]
on: [ pull_request, workflow_dispatch ]


jobs:
Expand All @@ -17,6 +17,10 @@ jobs:
java-version: 17
- name: Run Checkstyle
run: ./gradlew checkstyleMain checkstyleTest
- name: Make firebase-adminsdk.json
run: |
touch src/main/resources/timeet-firebase-adminsdk.json
echo "${{ secrets.FIREBASE_ADMINSDK }}" | base64 --decode > src/main/resources/timeet-firebase-adminsdk.json
- name: Make application-prod.yml
run: |
touch src/main/resources/application-prod.yml
Expand Down
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
timeet-firebase-adminsdk.json
HELP.md
.gradle
build/
Expand Down
1 change: 1 addition & 0 deletions build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ dependencies {
implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.2.0'
implementation("org.springframework.boot:spring-boot-devtools")
implementation 'org.springframework.boot:spring-boot-starter-oauth2-client'
implementation 'com.google.firebase:firebase-admin:9.2.0'
implementation 'org.springframework.boot:spring-boot-starter-websocket'


Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
package org.dnd.timeet.common.security.annotation;

import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
import org.springframework.security.core.annotation.AuthenticationPrincipal;

@Target({ElementType.PARAMETER, ElementType.ANNOTATION_TYPE})
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Spring security 관련하여
access 정보로부터 member 정보 불러올 때 가독성과 유지보수를 높이기 위해 다음과 같이 custom annotation을 사용하였는데, annotation으로 인한 side-effect나 단점이 없을지 궁금합니다 ☺️

참고자료: https://sol-devlog.tistory.com/3

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

코드 중복도 줄이고 정말 좋은 방법이네요!
저도 만든 어노테이션 잘 활용해보겠습니다 :)

@Retention(RetentionPolicy.RUNTIME)
@AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member")
public @interface ReqUser {

}
41 changes: 41 additions & 0 deletions src/main/java/org/dnd/timeet/config/FCMConfig.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
package org.dnd.timeet.config;

import com.google.auth.oauth2.GoogleCredentials;
import com.google.firebase.FirebaseApp;
import com.google.firebase.FirebaseOptions;
import com.google.firebase.messaging.FirebaseMessaging;
import java.io.IOException;
import java.io.InputStream;
import java.util.List;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.core.io.ClassPathResource;

@Configuration
public class FCMConfig {

@Bean
FirebaseMessaging firebaseMessaging() throws IOException {
ClassPathResource resource = new ClassPathResource("timeet-firebase-adminsdk.json");
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

firebase 설정과 관련하여 application.yml과 통합을 고려하였는데,
FirebaseApp 구현체에서 기본적으로 json 파일을 불러오는 형태를 사용하여, 해당 구현을 따랐습니다!


InputStream refreshToken = resource.getInputStream();

FirebaseApp firebaseApp = null;
List<FirebaseApp> firebaseAppList = FirebaseApp.getApps();

if (firebaseAppList != null && !firebaseAppList.isEmpty()) {
for (FirebaseApp app : firebaseAppList) {
if (app.getName().equals(FirebaseApp.DEFAULT_APP_NAME)) {
firebaseApp = app;
}
}
} else {
FirebaseOptions options = FirebaseOptions.builder()
.setCredentials(GoogleCredentials.fromStream(refreshToken)).build();
firebaseApp = FirebaseApp.initializeApp(options);
}
return FirebaseMessaging.getInstance(firebaseApp);
}


}
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
package org.dnd.timeet.fcm.application;

import com.google.firebase.messaging.FirebaseMessaging;
import com.google.firebase.messaging.Message;
import com.google.firebase.messaging.Notification;
import java.util.Collections;
import lombok.RequiredArgsConstructor;
import org.dnd.timeet.common.exception.InternalServerError;
import org.dnd.timeet.common.exception.NotFoundError;
import org.dnd.timeet.fcm.domain.FCMNotificationRequestDto;
import org.dnd.timeet.member.domain.Member;
import org.dnd.timeet.member.domain.MemberRepository;
import org.springframework.stereotype.Service;

@RequiredArgsConstructor
@Service
public class FCMNotificationService {

private final FirebaseMessaging firebaseMessaging;
private final MemberRepository memberRepository;

public void sendNotificationByToken(FCMNotificationRequestDto requestDto) {
Member member = memberRepository.findById(requestDto.getTargetMemberId())
.orElseThrow(() -> new NotFoundError(NotFoundError.ErrorCode.RESOURCE_NOT_FOUND,
Collections.singletonMap("MemberId", "Member not found")));
if (member.getFcmToken() == null) {
throw new NotFoundError(NotFoundError.ErrorCode.RESOURCE_NOT_FOUND,
Collections.singletonMap("fcmToken", "fcmToken not exist"));
}

Notification notification = Notification.builder()
.setTitle(requestDto.getTitle())
.setBody(requestDto.getBody())
.build();

Message message = Message.builder()
.setToken(member.getFcmToken())
.setNotification(notification)
.build();

try {
firebaseMessaging.send(message);
} catch (Exception e) {
throw new InternalServerError(InternalServerError.ErrorCode.INTERNAL_SERVER_ERROR,
Collections.singletonMap("fcmSend", "Fail to send fcm"));
}


}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
package org.dnd.timeet.fcm.domain;

import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;

@Getter
@NoArgsConstructor
public class FCMNotificationRequestDto {

private Long targetMemberId;
private String title;
private String body;

@Builder
public FCMNotificationRequestDto(Long targetMemberId, String title, String body) {
this.targetMemberId = targetMemberId;
this.title = title;
this.body = body;
}

}
13 changes: 13 additions & 0 deletions src/main/java/org/dnd/timeet/member/application/MemberService.java
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
package org.dnd.timeet.member.application;


import java.util.Collections;
import lombok.RequiredArgsConstructor;
import org.dnd.timeet.common.exception.NotFoundError;
import org.dnd.timeet.member.domain.Member;
import org.dnd.timeet.member.domain.MemberRepository;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service;
Expand All @@ -14,4 +17,14 @@ public class MemberService {

private final PasswordEncoder passwordEncoder;
private final MemberRepository memberRepository;

@Transactional
public void upsertFcmToken(Long id, String fcmToken) {
Member member = memberRepository.findById(id)
.orElseThrow(() -> new NotFoundError(NotFoundError.ErrorCode.RESOURCE_NOT_FOUND,
Collections.singletonMap("MemberId", "Member not found")));
member.setFcmToken(fcmToken);


}
}
Original file line number Diff line number Diff line change
@@ -1,8 +1,14 @@
package org.dnd.timeet.member.controller;

import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;
import org.dnd.timeet.common.security.annotation.ReqUser;
import org.dnd.timeet.member.application.MemberService;
import org.dnd.timeet.member.domain.Member;
import org.dnd.timeet.member.dto.RegisterFcmRequest;
import org.springframework.web.bind.annotation.PatchMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

Expand All @@ -14,5 +20,12 @@ public class MemberController {

private final MemberService memberService;

@PatchMapping
@Operation(summary = "fcmToken 등록", description = "fcmToken을 등록한다.")
public void registerFcmToken(@RequestBody RegisterFcmRequest registerFcmRequest,
@ReqUser Member member) {
memberService.upsertFcmToken(member.getId(), registerFcmRequest.getFcmToken());

}
}

8 changes: 8 additions & 0 deletions src/main/java/org/dnd/timeet/member/domain/Member.java
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,9 @@ public class Member extends BaseEntity {
@Column(length = 50, nullable = false)
private OAuth2Provider provider;

@Column(length = 255)
private String fcmToken;

@OneToMany(mappedBy = "member", fetch = FetchType.EAGER)
private Set<Participant> participations = new HashSet<>();

Expand All @@ -56,4 +59,9 @@ public Member(MemberRole role, String name, String imageUrl, Long oauthId, OAuth
this.oauthId = oauthId;
this.provider = provider;
}


public void setFcmToken(String fcmToken) {
this.fcmToken = fcmToken;
}
}
16 changes: 16 additions & 0 deletions src/main/java/org/dnd/timeet/member/dto/RegisterFcmRequest.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
package org.dnd.timeet.member.dto;

import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;

@Schema(description = "fcmToken 등록 요청")
@Getter
@Setter
@NoArgsConstructor
public class RegisterFcmRequest {

@Schema(description = "fcmToken", nullable = false, example = "fcmToken")
private String fcmToken;
}
Loading