From a5193e5a11d479ec7b649158a0d767ff1b8beab0 Mon Sep 17 00:00:00 2001 From: Guga Date: Wed, 11 Oct 2023 15:41:45 +0900 Subject: [PATCH] =?UTF-8?q?[BE]=20feat:=20=EC=9C=A0=EC=A0=80=20FCM=20?= =?UTF-8?q?=EB=A1=9C=EC=A7=81=20=EB=B3=80=EA=B2=BD(#471)=20(#472)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: 유저의 fcm 요청이 모두 성공했을 때 예외를 발생시키지 않는다 * feat: 회원 탈퇴 시 memberFCM 삭제 및 로그인 시 memberFCM 저장 로직 구현 * chore: hard wrap 120 적용 * feat: 회원가입과 로그인 여부에 따라 FCM 로직을 분기한다 * feat: 회원가입 FCM 저장 로직 비동기 처리 * chore: 외래키 제약 조건 추가 분리 * feat: memberFCM 토큰이 없거나 메세징이 실패했을 경우에 log 만 작성하도록 변경 * refactor: 로직 개선 * test: 로직 변경에 따른 검증 내용 변경 * refactor: 신규 가입 여부에 따른 fcm 발행 관리를 Service 에게 위임 * chore: flyway 버전 변경 --- .../FCMNotificationEventListener.java | 17 ++--- .../fcm/application/MemberFCMService.java | 38 +++++++--- .../fcm/repository/MemberFCMRepository.java | 5 ++ .../festago/presentation/AuthController.java | 9 ++- .../db/migration/V8__unique_member_fcm.sql | 2 + .../auth/presentation/AuthControllerTest.java | 56 +++++++++++++++ .../FCMNotificationEventListenerTest.java | 71 ++++++++++++------- .../fcm/application/MemberFCMServiceTest.java | 63 +++++++++++++++- 8 files changed, 214 insertions(+), 47 deletions(-) create mode 100644 backend/src/main/resources/db/migration/V8__unique_member_fcm.sql diff --git a/backend/src/main/java/com/festago/fcm/application/FCMNotificationEventListener.java b/backend/src/main/java/com/festago/fcm/application/FCMNotificationEventListener.java index 1de6c5418..379b02c5f 100644 --- a/backend/src/main/java/com/festago/fcm/application/FCMNotificationEventListener.java +++ b/backend/src/main/java/com/festago/fcm/application/FCMNotificationEventListener.java @@ -1,7 +1,5 @@ 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; @@ -43,8 +41,7 @@ public void sendFcmNotification(EntryProcessEvent event) { 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); + log.warn("fail send FCM message", e); } } @@ -80,11 +77,11 @@ private AndroidNotification createAndroidNotification(String channelId) { } private void checkAllSuccess(BatchResponse batchResponse, Long memberId) { - List failSend = batchResponse.getResponses().stream() - .filter(sendResponse -> !sendResponse.isSuccessful()) - .toList(); - - log.warn("member {} 에 대한 다음 요청들이 실패했습니다. {}", memberId, failSend); - throw new InternalServerException(ErrorCode.FAIL_SEND_FCM_MESSAGE); + if (batchResponse.getFailureCount() > 0) { + List failSend = batchResponse.getResponses().stream() + .filter(sendResponse -> !sendResponse.isSuccessful()) + .toList(); + log.warn("member {} 에 대한 다음 요청들이 실패했습니다. {}", memberId, failSend); + } } } diff --git a/backend/src/main/java/com/festago/fcm/application/MemberFCMService.java b/backend/src/main/java/com/festago/fcm/application/MemberFCMService.java index e044feb7a..ad346b2d6 100644 --- a/backend/src/main/java/com/festago/fcm/application/MemberFCMService.java +++ b/backend/src/main/java/com/festago/fcm/application/MemberFCMService.java @@ -2,12 +2,11 @@ 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 java.util.Optional; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.scheduling.annotation.Async; @@ -19,7 +18,6 @@ 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; @@ -32,17 +30,41 @@ public MemberFCMService(MemberFCMRepository memberFCMRepository, AuthExtractor a @Transactional(readOnly = true) public MemberFCMsResponse findMemberFCM(Long memberId) { List memberFCM = memberFCMRepository.findByMemberId(memberId); - if (memberFCM.size() < LEAST_MEMBER_FCM) { - log.error("member {} 의 FCM 토큰이 발급되지 않았습니다.", memberId); - throw new InternalServerException(ErrorCode.FCM_NOT_FOUND); + if (memberFCM.isEmpty()) { + log.warn("member {} 의 FCM 토큰이 발급되지 않았습니다.", memberId); } return MemberFCMsResponse.from(memberFCM); } @Async - public void saveMemberFCM(String accessToken, String fcmToken) { + public void saveMemberFCM(boolean isNewMember, String accessToken, String fcmToken) { + if (isNewMember) { + saveNewMemberFCM(accessToken, fcmToken); + return; + } + saveOriginMemberFCM(accessToken, fcmToken); + } + + private void saveOriginMemberFCM(String accessToken, String fcmToken) { + Long memberId = extractMemberId(accessToken); + Optional memberFCM = memberFCMRepository.findMemberFCMByMemberIdAndFcmToken(memberId, fcmToken); + if (memberFCM.isEmpty()) { + memberFCMRepository.save(new MemberFCM(memberId, fcmToken)); + } + } + + private Long extractMemberId(String accessToken) { AuthPayload authPayload = authExtractor.extract(accessToken); - Long memberId = authPayload.getMemberId(); + return authPayload.getMemberId(); + } + + private void saveNewMemberFCM(String accessToken, String fcmToken) { + Long memberId = extractMemberId(accessToken); memberFCMRepository.save(new MemberFCM(memberId, fcmToken)); } + + @Async + public void deleteMemberFCM(Long memberId) { + memberFCMRepository.deleteAllByMemberId(memberId); + } } diff --git a/backend/src/main/java/com/festago/fcm/repository/MemberFCMRepository.java b/backend/src/main/java/com/festago/fcm/repository/MemberFCMRepository.java index e5bee3719..fb6ed5709 100644 --- a/backend/src/main/java/com/festago/fcm/repository/MemberFCMRepository.java +++ b/backend/src/main/java/com/festago/fcm/repository/MemberFCMRepository.java @@ -2,9 +2,14 @@ import com.festago.fcm.domain.MemberFCM; import java.util.List; +import java.util.Optional; import org.springframework.data.jpa.repository.JpaRepository; public interface MemberFCMRepository extends JpaRepository { List findByMemberId(Long memberId); + + Optional findMemberFCMByMemberIdAndFcmToken(Long memberId, String fcmToken); + + void deleteAllByMemberId(Long memberId); } diff --git a/backend/src/main/java/com/festago/presentation/AuthController.java b/backend/src/main/java/com/festago/presentation/AuthController.java index a287b33ec..c08773431 100644 --- a/backend/src/main/java/com/festago/presentation/AuthController.java +++ b/backend/src/main/java/com/festago/presentation/AuthController.java @@ -30,16 +30,23 @@ public class AuthController { @Operation(description = "소셜 엑세스 토큰을 기반으로 로그인 요청을 보낸다.", summary = "OAuth2 로그인") public ResponseEntity login(@RequestBody @Valid LoginRequest request) { LoginResponse response = authFacadeService.login(request.socialType(), request.accessToken()); - memberFCMService.saveMemberFCM(response.accessToken(), request.fcmToken()); + registerFCM(response, request); return ResponseEntity.ok() .body(response); } + private void registerFCM(LoginResponse response, LoginRequest request) { + String accessToken = response.accessToken(); + String fcmToken = request.fcmToken(); + memberFCMService.saveMemberFCM(response.isNew(), accessToken, fcmToken); + } + @DeleteMapping @SecurityRequirement(name = "bearerAuth") @Operation(description = "회원 탈퇴 요청을 보낸다.", summary = "유저 회원 탈퇴") public ResponseEntity deleteMember(@Member Long memberId) { authFacadeService.deleteMember(memberId); + memberFCMService.deleteMemberFCM(memberId); return ResponseEntity.ok() .build(); } diff --git a/backend/src/main/resources/db/migration/V8__unique_member_fcm.sql b/backend/src/main/resources/db/migration/V8__unique_member_fcm.sql new file mode 100644 index 000000000..52f3684cd --- /dev/null +++ b/backend/src/main/resources/db/migration/V8__unique_member_fcm.sql @@ -0,0 +1,2 @@ +alter table member_fcm + add constraint unique_member_fcm unique (member_id, fcm_token); diff --git a/backend/src/test/java/com/festago/auth/presentation/AuthControllerTest.java b/backend/src/test/java/com/festago/auth/presentation/AuthControllerTest.java index 19c22eb68..6b9cf1786 100644 --- a/backend/src/test/java/com/festago/auth/presentation/AuthControllerTest.java +++ b/backend/src/test/java/com/festago/auth/presentation/AuthControllerTest.java @@ -3,6 +3,8 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.ArgumentMatchers.any; import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print; @@ -89,4 +91,58 @@ class AuthControllerTest { .contentType(MediaType.APPLICATION_JSON)) .andExpect(status().isOk()); } + + @Test + void 회원가입_시_유저의_FCM_를_저장한다() throws Exception { + // given + String fcmToken = "fcmToken"; + String accessToken = "accessToken"; + boolean isNewMember = true; + LoginResponse expected = new LoginResponse(accessToken, "nickname", isNewMember); + given(authFacadeService.login(any(), any())) + .willReturn(expected); + LoginRequest request = new LoginRequest(SocialType.FESTAGO, "code", fcmToken); + + // when & then + String response = mockMvc.perform(post("/auth/oauth2") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isOk()) + .andDo(print()) + .andReturn() + .getResponse() + .getContentAsString(); + LoginResponse actual = objectMapper.readValue(response, LoginResponse.class); + + assertThat(actual).isEqualTo(expected); + verify(memberFCMService, times(1)) + .saveMemberFCM(isNewMember, accessToken, fcmToken); + } + + @Test + void 로그인_시_유저의_FCM_를_저장한다() throws Exception { + // given + String fcmToken = "fcmToken"; + String accessToken = "accessToken"; + boolean isNewMember = false; + LoginResponse expected = new LoginResponse(accessToken, "nickname", isNewMember); + given(authFacadeService.login(any(), any())) + .willReturn(expected); + LoginRequest request = new LoginRequest(SocialType.FESTAGO, "code", fcmToken); + + // when & then + String response = mockMvc.perform(post("/auth/oauth2") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isOk()) + .andDo(print()) + .andReturn() + .getResponse() + .getContentAsString(); + LoginResponse actual = objectMapper.readValue(response, LoginResponse.class); + + assertThat(actual).isEqualTo(expected); + verify(memberFCMService, times(1)) + .saveMemberFCM(isNewMember, accessToken, fcmToken); + } } diff --git a/backend/src/test/java/com/festago/fcm/application/FCMNotificationEventListenerTest.java b/backend/src/test/java/com/festago/fcm/application/FCMNotificationEventListenerTest.java index 795aa245c..03b422833 100644 --- a/backend/src/test/java/com/festago/fcm/application/FCMNotificationEventListenerTest.java +++ b/backend/src/test/java/com/festago/fcm/application/FCMNotificationEventListenerTest.java @@ -4,9 +4,10 @@ import static org.mockito.ArgumentMatchers.anyLong; import static org.mockito.BDDMockito.given; import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.when; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; -import com.festago.common.exception.InternalServerException; import com.festago.entry.dto.event.EntryProcessEvent; import com.festago.fcm.dto.MemberFCMResponse; import com.festago.fcm.dto.MemberFCMsResponse; @@ -15,7 +16,6 @@ import com.google.firebase.messaging.FirebaseMessagingException; import com.google.firebase.messaging.SendResponse; import java.util.List; -import org.assertj.core.api.Assertions; import org.junit.jupiter.api.DisplayNameGeneration; import org.junit.jupiter.api.DisplayNameGenerator.ReplaceUnderscores; import org.junit.jupiter.api.Test; @@ -39,37 +39,58 @@ class FCMNotificationEventListenerTest { FCMNotificationEventListener FCMNotificationEventListener; @Test - void 유저의_FCM_요청_중_하나라도_실패하면_예외() throws FirebaseMessagingException { + void 유저의_모든_FCM_요청이_성공() throws FirebaseMessagingException { // given given(memberFCMService.findMemberFCM(anyLong())).willReturn( - new MemberFCMsResponse(List.of(new MemberFCMResponse(1L, 1L, "token1"), new MemberFCMResponse(2L, 1L, "token2")))); - - given(firebaseMessaging.sendAll(any())).willReturn(new MockBatchResponse()); + new MemberFCMsResponse(List.of(new MemberFCMResponse(1L, 1L, "token1"), + new MemberFCMResponse(2L, 1L, "token2")))); + BatchResponse mockBatchResponse = mock(BatchResponse.class); + given(mockBatchResponse.getFailureCount()) + .willReturn(0); + given(firebaseMessaging.sendAll(any())).willReturn(mockBatchResponse); EntryProcessEvent event = new EntryProcessEvent(1L); - // when & then - Assertions.assertThatThrownBy(() -> FCMNotificationEventListener.sendFcmNotification(event)) - .isInstanceOf(InternalServerException.class); + // when + FCMNotificationEventListener.sendFcmNotification(event); + + // then + verify(mockBatchResponse, times(1)) + .getFailureCount(); + verify(mockBatchResponse, never()) + .getResponses(); } - private static class MockBatchResponse implements BatchResponse { + @Test + void 유저의_FCM_요청_중_하나라도_실패하면_예외() throws FirebaseMessagingException { + // given + given(memberFCMService.findMemberFCM(anyLong())).willReturn( + new MemberFCMsResponse(List.of(new MemberFCMResponse(1L, 1L, "token1"), + new MemberFCMResponse(2L, 1L, "token2")))); + + BatchResponse mockBatchResponse = mock(BatchResponse.class); + SendResponse mockSendResponse = mock(SendResponse.class); + + given(mockSendResponse.isSuccessful()) + .willReturn(false); + given(mockBatchResponse.getFailureCount()) + .willReturn(1); + given(mockBatchResponse.getResponses()) + .willReturn(List.of(mockSendResponse)); - @Override - public List getResponses() { - SendResponse mockResponse = mock(SendResponse.class); - when(mockResponse.isSuccessful()).thenReturn(false); - return List.of(mockResponse, mockResponse); - } + given(firebaseMessaging.sendAll(any())).willReturn(mockBatchResponse); + + EntryProcessEvent event = new EntryProcessEvent(1L); - @Override - public int getSuccessCount() { - return 0; - } + // when + FCMNotificationEventListener.sendFcmNotification(event); - @Override - public int getFailureCount() { - return 0; - } + // then + verify(mockBatchResponse, times(1)) + .getFailureCount(); + verify(mockBatchResponse, times(1)) + .getResponses(); + verify(mockSendResponse, times(1)) + .isSuccessful(); } } diff --git a/backend/src/test/java/com/festago/fcm/application/MemberFCMServiceTest.java b/backend/src/test/java/com/festago/fcm/application/MemberFCMServiceTest.java index dd8eb7d46..f4a65a20d 100644 --- a/backend/src/test/java/com/festago/fcm/application/MemberFCMServiceTest.java +++ b/backend/src/test/java/com/festago/fcm/application/MemberFCMServiceTest.java @@ -4,6 +4,7 @@ import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyLong; import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.never; import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; @@ -14,6 +15,7 @@ import com.festago.fcm.dto.MemberFCMResponse; import com.festago.fcm.repository.MemberFCMRepository; import java.util.List; +import java.util.Optional; import java.util.stream.Collectors; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; @@ -55,18 +57,73 @@ class MemberFCMServiceTest { } @Test - void 유저의_FCM_저장() { + void 기존_유저의_새로운_FCM_토큰이라면_저장() { // given String accessToken = "accessToken"; String fcmToken = "fcmToken"; + boolean isNewMember = false; + Long memberId = 1L; given(authExtractor.extract(any())) - .willReturn(new AuthPayload(1L, Role.MEMBER)); + .willReturn(new AuthPayload(memberId, Role.MEMBER)); + given(memberFCMRepository.findMemberFCMByMemberIdAndFcmToken(memberId, fcmToken)) + .willReturn(Optional.empty()); // when - memberFCMService.saveMemberFCM(accessToken, fcmToken); + memberFCMService.saveMemberFCM(isNewMember, accessToken, fcmToken); // then verify(memberFCMRepository, times(1)) .save(any(MemberFCM.class)); } + + @Test + void 기존_유저의_이미_존재하는_유저의_FCM_토큰이라면_저장하지_않는다() { + // given + String accessToken = "accessToken"; + String originToken = "fcmToken"; + boolean isNewMember = false; + Long memberId = 1L; + given(authExtractor.extract(any())) + .willReturn(new AuthPayload(memberId, Role.MEMBER)); + given(memberFCMRepository.findMemberFCMByMemberIdAndFcmToken(memberId, originToken)) + .willReturn(Optional.of(new MemberFCM(memberId, originToken))); + + // when + memberFCMService.saveMemberFCM(isNewMember, accessToken, originToken); + + // then + verify(memberFCMRepository, never()) + .save(any(MemberFCM.class)); + } + + @Test + void 새로운_유저의_FCM_토큰을_저장한다() { + // given + String accessToken = "accessToken"; + String fcmToken = "fcmToken"; + boolean isNewMember = false; + Long memberId = 1L; + given(authExtractor.extract(any())) + .willReturn(new AuthPayload(memberId, Role.MEMBER)); + + // when + memberFCMService.saveMemberFCM(isNewMember, accessToken, fcmToken); + + // then + verify(memberFCMRepository, times(1)) + .save(any(MemberFCM.class)); + } + + @Test + void 유저의_FCM_삭제() { + // given + Long memberId = 1L; + + // when + memberFCMService.deleteMemberFCM(memberId); + + // then + verify(memberFCMRepository, times(1)) + .deleteAllByMemberId(memberId); + } }