Skip to content

Commit

Permalink
[BE] feat: 스태프 인증 처리시 FCM 을 통해 유저 핸드폰에 입장 처리 완료 메시지를 보낸다(#444) (#449)
Browse files Browse the repository at this point in the history
* feat: FCMConfig 생성

* chore: FCMConfig 패키지 이동

* feat: MemberFCM 생성

* feat: MemberFCMService 생성

* feat: NotificationFacade 생성

* feat: 로그인 시 fcm 을 등록한다.

* feat: 입장 처리 시 EntryProcessEvent 를 발행한다.

* feat: 유저 탈퇴시 유저의 FCM 을 모두 삭제한다.

* feat: 테스트 환경에서 FirebaseMessaging 을 MockBean 으로 등록한다

* chore: 테스트 컨벤션 적용

* chore: Notification -> FCMNotificationFacade 네이밍 변견

* feat: flyway 추가

* chore: 마지막 줄 개행 추가

* feat: submodule 업데이트

* refactor: 메서드 네이밍, 메서드 순서, 파라미터 순서 변경

* refactor: fcm bean 을 테스트에서 제외

* feat: EventListener phase 명시

* chore: FCMEventListener 네이밍 변경

* feat: MemberFCM 의 Member 의존성 제거

* chore: EntryProcessEvent 패키지 분리

* refactor: AuthService 가 MemberFCM 을 의존하지 않도록 변경

* feat: MemberFCM 빈 생성자 추가

* chore: flyway version 변경

* feat: FCMChannel 을 Enum 으로 관리

* chore: 메서드 접근자 및 네이밍 변경, log 메세지 변경

* feat: local prod, dev 환경에서만 FCM Bean 들이 생성되도록 변경

* refactor: eventListen 로직을 비동기적으로 처리한다

* refactor: LoginService 와 MemberFCMService 를 분리한다

* chore: 파라미터 네이밍 변경

* chore: logger -> log 네이밍 변경

* chore: log 메시지 변경

* chore: flyway 버전 변경
  • Loading branch information
BGuga authored Sep 26, 2023
1 parent 8f0ca1b commit 0a1f237
Show file tree
Hide file tree
Showing 24 changed files with 564 additions and 27 deletions.
3 changes: 3 additions & 0 deletions backend/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,9 @@ dependencies {
// Flyway
implementation 'org.flywaydb:flyway-core'
implementation 'org.flywaydb:flyway-mysql'

// Firebase
implementation 'com.google.firebase:firebase-admin:8.1.0'
}

tasks.named('test') {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,9 @@

import com.festago.auth.domain.AuthPayload;
import com.festago.auth.domain.Role;
import com.festago.auth.domain.SocialType;
import com.festago.auth.domain.UserInfo;
import com.festago.auth.dto.LoginMemberDto;
import com.festago.auth.dto.LoginRequest;
import com.festago.auth.dto.LoginResponse;
import org.springframework.stereotype.Service;

Expand All @@ -22,8 +22,9 @@ public AuthFacadeService(AuthService authService, OAuth2Clients oAuth2Clients,
this.authProvider = authProvider;
}

public LoginResponse login(LoginRequest request) {
LoginMemberDto loginMember = authService.login(getUserInfo(request));
public LoginResponse login(SocialType socialType, String oAuthToken) {
UserInfo userInfo = getUserInfo(socialType, oAuthToken);
LoginMemberDto loginMember = authService.login(userInfo);
String accessToken = getAccessToken(loginMember.memberId());
return LoginResponse.of(accessToken, loginMember);
}
Expand All @@ -32,9 +33,9 @@ private String getAccessToken(Long memberId) {
return authProvider.provide(new AuthPayload(memberId, Role.MEMBER));
}

private UserInfo getUserInfo(LoginRequest request) {
OAuth2Client oAuth2Client = oAuth2Clients.getClient(request.socialType());
return oAuth2Client.getUserInfo(request.accessToken());
private UserInfo getUserInfo(SocialType socialType, String oAuthToken) {
OAuth2Client oAuth2Client = oAuth2Clients.getClient(socialType);
return oAuth2Client.getUserInfo(oAuthToken);
}

public void deleteMember(Long memberId) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
import com.festago.common.exception.NotFoundException;
import com.festago.member.domain.Member;
import com.festago.member.repository.MemberRepository;
import java.util.Optional;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Service;
Expand All @@ -24,12 +25,18 @@ public AuthService(MemberRepository memberRepository) {
}

public LoginMemberDto login(UserInfo userInfo) {
return memberRepository.findBySocialIdAndSocialType(userInfo.socialId(), userInfo.socialType())
.map(LoginMemberDto::isExists)
.orElseGet(() -> {
Member member = signUp(userInfo);
return LoginMemberDto.isNew(member);
});
LoginMemberDto loginMemberDto = handleLoginRequest(userInfo);
return loginMemberDto;
}

public LoginMemberDto handleLoginRequest(UserInfo userInfo) {
Optional<Member> originMember = memberRepository.findBySocialIdAndSocialType(userInfo.socialId(), userInfo.socialType());
if (originMember.isPresent()) {
Member member = originMember.get();
return LoginMemberDto.isExists(member);
}
Member newMember = signUp(userInfo);
return LoginMemberDto.isNew(newMember);
}

private Member signUp(UserInfo userInfo) {
Expand Down
3 changes: 2 additions & 1 deletion backend/src/main/java/com/festago/auth/dto/LoginRequest.java
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@

public record LoginRequest(
@NotNull(message = "socialType 은 null 일 수 없습니다.") SocialType socialType,
@NotNull(message = "acessToken 은 null 일 수 없습니다.") String accessToken) {
@NotNull(message = "acessToken 은 null 일 수 없습니다.") String accessToken,
@NotNull(message = "fcmToken 은 null 일 수 없습니다.") String fcmToken) {

}
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,8 @@ public enum ErrorCode {
INVALID_ENTRY_CODE_OFFSET("올바르지 않은 입장코드 오프셋입니다."),
INVALID_ROLE_NAME("해당하는 Role이 없습니다."),
FOR_TEST_ERROR("테스트용 에러입니다."),
;
FAIL_SEND_FCM_MESSAGE("FCM Message 전송에 실패했습니다."),
FCM_NOT_FOUND("유효하지 않은 MemberFCM 이 감지 되었습니다.");

private final String message;

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,12 @@
import com.festago.entry.dto.EntryCodeResponse;
import com.festago.entry.dto.TicketValidationRequest;
import com.festago.entry.dto.TicketValidationResponse;
import com.festago.entry.dto.event.EntryProcessEvent;
import com.festago.ticketing.domain.MemberTicket;
import com.festago.ticketing.repository.MemberTicketRepository;
import java.time.Clock;
import java.time.LocalDateTime;
import org.springframework.context.ApplicationEventPublisher;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

Expand All @@ -21,11 +23,14 @@ public class EntryService {

private final EntryCodeManager entryCodeManager;
private final MemberTicketRepository memberTicketRepository;
private final ApplicationEventPublisher publisher;
private final Clock clock;

public EntryService(EntryCodeManager entryCodeManager, MemberTicketRepository memberTicketRepository, Clock clock) {
public EntryService(EntryCodeManager entryCodeManager, MemberTicketRepository memberTicketRepository,
ApplicationEventPublisher publisher, Clock clock) {
this.entryCodeManager = entryCodeManager;
this.memberTicketRepository = memberTicketRepository;
this.publisher = publisher;
this.clock = clock;
}

Expand All @@ -50,6 +55,7 @@ public TicketValidationResponse validate(TicketValidationRequest request) {
EntryCodePayload entryCodePayload = entryCodeManager.extract(request.code());
MemberTicket memberTicket = findMemberTicket(entryCodePayload.getMemberTicketId());
memberTicket.changeState(entryCodePayload.getEntryState());
publisher.publishEvent(new EntryProcessEvent(memberTicket.getOwner().getId()));
return TicketValidationResponse.from(memberTicket);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
package com.festago.entry.dto.event;

public record EntryProcessEvent(
Long memberId) {

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
package com.festago.fcm.application;

import com.festago.common.exception.ErrorCode;
import com.festago.common.exception.InternalServerException;
import com.festago.entry.dto.event.EntryProcessEvent;
import com.festago.fcm.domain.FCMChannel;
import com.festago.fcm.dto.MemberFCMResponse;
import com.google.firebase.messaging.AndroidConfig;
import com.google.firebase.messaging.AndroidNotification;
import com.google.firebase.messaging.BatchResponse;
import com.google.firebase.messaging.FirebaseMessaging;
import com.google.firebase.messaging.FirebaseMessagingException;
import com.google.firebase.messaging.Message;
import com.google.firebase.messaging.SendResponse;
import java.util.List;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.context.annotation.Profile;
import org.springframework.scheduling.annotation.Async;
import org.springframework.stereotype.Component;
import org.springframework.transaction.event.TransactionPhase;
import org.springframework.transaction.event.TransactionalEventListener;

@Component
@Profile({"dev", "prod"})
public class FCMNotificationEventListener {

private static final Logger log = LoggerFactory.getLogger(FCMNotificationEventListener.class);

private final FirebaseMessaging firebaseMessaging;
private final MemberFCMService memberFCMService;

public FCMNotificationEventListener(FirebaseMessaging firebaseMessaging, MemberFCMService memberFCMService) {
this.firebaseMessaging = firebaseMessaging;
this.memberFCMService = memberFCMService;
}

@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
@Async
public void sendFcmNotification(EntryProcessEvent event) {
List<Message> messages = createMessages(getMemberFCMToken(event.memberId()), FCMChannel.NOT_DEFINED.name());
try {
BatchResponse batchResponse = firebaseMessaging.sendAll(messages);
checkAllSuccess(batchResponse, event.memberId());
} catch (FirebaseMessagingException e) {
log.error("fail send FCM message", e);
throw new InternalServerException(ErrorCode.FAIL_SEND_FCM_MESSAGE);
}
}

private List<String> getMemberFCMToken(Long memberId) {
return memberFCMService.findMemberFCM(memberId).memberFCMs().stream()
.map(MemberFCMResponse::fcmToken)
.toList();
}

private List<Message> createMessages(List<String> tokens, String channelId) {
return tokens.stream()
.map(token -> createMessage(token, channelId))
.toList();
}

private Message createMessage(String token, String channelId) {
return Message.builder()
.setAndroidConfig(createAndroidConfig(channelId))
.setToken(token)
.build();
}

private AndroidConfig createAndroidConfig(String channelId) {
return AndroidConfig.builder()
.setNotification(createAndroidNotification(channelId))
.build();
}

private AndroidNotification createAndroidNotification(String channelId) {
return AndroidNotification.builder()
.setChannelId(channelId)
.build();
}

private void checkAllSuccess(BatchResponse batchResponse, Long memberId) {
List<SendResponse> failSend = batchResponse.getResponses().stream()
.filter(sendResponse -> !sendResponse.isSuccessful())
.toList();

log.warn("member {} 에 대한 다음 요청들이 실패했습니다. {}", memberId, failSend);
throw new InternalServerException(ErrorCode.FAIL_SEND_FCM_MESSAGE);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
package com.festago.fcm.application;

import com.festago.auth.application.AuthExtractor;
import com.festago.auth.domain.AuthPayload;
import com.festago.common.exception.ErrorCode;
import com.festago.common.exception.InternalServerException;
import com.festago.fcm.domain.MemberFCM;
import com.festago.fcm.dto.MemberFCMsResponse;
import com.festago.fcm.repository.MemberFCMRepository;
import java.util.List;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.scheduling.annotation.Async;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

@Service
@Transactional
public class MemberFCMService {

private static final Logger log = LoggerFactory.getLogger(MemberFCMService.class);
private static final int LEAST_MEMBER_FCM = 1;

private final MemberFCMRepository memberFCMRepository;
private final AuthExtractor authExtractor;

public MemberFCMService(MemberFCMRepository memberFCMRepository, AuthExtractor authExtractor) {
this.memberFCMRepository = memberFCMRepository;
this.authExtractor = authExtractor;
}

@Transactional(readOnly = true)
public MemberFCMsResponse findMemberFCM(Long memberId) {
List<MemberFCM> memberFCM = memberFCMRepository.findByMemberId(memberId);
if (memberFCM.size() < LEAST_MEMBER_FCM) {
log.error("member {} 의 FCM 토큰이 발급되지 않았습니다.", memberId);
throw new InternalServerException(ErrorCode.FCM_NOT_FOUND);
}
return MemberFCMsResponse.from(memberFCM);
}

@Async
public void saveMemberFCM(String accessToken, String fcmToken) {
AuthPayload authPayload = authExtractor.extract(accessToken);
Long memberId = authPayload.getMemberId();
memberFCMRepository.save(new MemberFCM(memberId, fcmToken));
}
}
58 changes: 58 additions & 0 deletions backend/src/main/java/com/festago/fcm/config/FCMConfig.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
package com.festago.fcm.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.util.List;
import java.util.Optional;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Profile;
import org.springframework.core.io.ClassPathResource;

@Configuration
@Profile({"prod", "dev"})
public class FCMConfig {

@Value("${fcm.key.path}")
private String fcmPrivateKeyPath;

@Value("${fcm.key.scope}")
private String fireBaseScope;

@Bean
public FirebaseMessaging firebaseMessaging() throws IOException {
Optional<FirebaseApp> defaultFirebaseApp = defaultFirebaseApp();
if (defaultFirebaseApp.isPresent()) {
return FirebaseMessaging.getInstance(defaultFirebaseApp.get());
}
return FirebaseMessaging.getInstance(
FirebaseApp.initializeApp(createFirebaseOption())
);
}

private Optional<FirebaseApp> defaultFirebaseApp() {
List<FirebaseApp> firebaseAppList = FirebaseApp.getApps();
if (firebaseAppList == null || firebaseAppList.isEmpty()) {
return Optional.empty();
}
return firebaseAppList.stream()
.filter(firebaseApp -> firebaseApp.getName().equals(FirebaseApp.DEFAULT_APP_NAME))
.findAny();
}

private FirebaseOptions createFirebaseOption() throws IOException {
return FirebaseOptions.builder()
.setCredentials(createGoogleCredentials())
.build();
}

private GoogleCredentials createGoogleCredentials() throws IOException {
return GoogleCredentials
.fromStream(new ClassPathResource(fcmPrivateKeyPath).getInputStream())
.createScoped(fireBaseScope);
}
}
5 changes: 5 additions & 0 deletions backend/src/main/java/com/festago/fcm/domain/FCMChannel.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
package com.festago.fcm.domain;

public enum FCMChannel {
NOT_DEFINED;
}
Loading

0 comments on commit 0a1f237

Please sign in to comment.