diff --git a/.github/workflows/backend-dev-deploy.yml b/.github/workflows/backend-dev-deploy.yml index b34bd9fd2..126dd8388 100644 --- a/.github/workflows/backend-dev-deploy.yml +++ b/.github/workflows/backend-dev-deploy.yml @@ -28,6 +28,14 @@ jobs: - name: 백엔드 메인으로 checkout uses: actions/checkout@v3 + - name: firebase key 생성 + run: | + echo "$FIREBASE_KEY" > firebase-kerdy.json + + - name: firebase key 이동 + run: | + cp firebase-kerdy.json src/main/resources + - name: JDK 11로 설정 uses: actions/setup-java@v3 with: diff --git a/backend/emm-sale/build.gradle b/backend/emm-sale/build.gradle index b5feacfc2..8b91c8f99 100644 --- a/backend/emm-sale/build.gradle +++ b/backend/emm-sale/build.gradle @@ -65,6 +65,9 @@ dependencies { // jwt implementation 'io.jsonwebtoken:jjwt:0.9.1' + + // firebase + implementation 'com.google.firebase:firebase-admin:9.2.0' } tasks.named('test') { diff --git a/backend/emm-sale/src/main/java/com/emmsale/config/RestTemplateConfig.java b/backend/emm-sale/src/main/java/com/emmsale/config/RestTemplateConfig.java new file mode 100644 index 000000000..7215c3200 --- /dev/null +++ b/backend/emm-sale/src/main/java/com/emmsale/config/RestTemplateConfig.java @@ -0,0 +1,14 @@ +package com.emmsale.config; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.web.client.RestTemplate; + +@Configuration +public class RestTemplateConfig { + + @Bean + public RestTemplate restTemplate() { + return new RestTemplate(); + } +} diff --git a/backend/emm-sale/src/main/java/com/emmsale/login/utils/GithubClient.java b/backend/emm-sale/src/main/java/com/emmsale/login/utils/GithubClient.java index cc0d21f44..62e194124 100644 --- a/backend/emm-sale/src/main/java/com/emmsale/login/utils/GithubClient.java +++ b/backend/emm-sale/src/main/java/com/emmsale/login/utils/GithubClient.java @@ -5,6 +5,7 @@ import com.emmsale.login.application.dto.GithubProfileResponse; import com.emmsale.login.exception.LoginException; import com.emmsale.login.exception.LoginExceptionType; +import lombok.RequiredArgsConstructor; import org.springframework.beans.factory.annotation.Value; import org.springframework.http.HttpEntity; import org.springframework.http.HttpHeaders; @@ -15,10 +16,9 @@ import org.springframework.web.client.RestTemplate; @Component +@RequiredArgsConstructor public class GithubClient { - private static final RestTemplate REST_TEMPLATE = new RestTemplate(); - @Value("${github.client.id}") private String clientId; @Value("${github.client.secret}") @@ -28,6 +28,8 @@ public class GithubClient { @Value("${github.url.profile}") private String profileUrl; + private final RestTemplate restTemplate; + public String getAccessTokenFromGithub(final String code) { final GithubAccessTokenRequest githubAccessTokenRequest = buildGithubAccessTokenRequest(code); @@ -54,7 +56,7 @@ private GithubProfileResponse getGithubProfileResponse(final String accessToken) final HttpEntity httpEntity = new HttpEntity<>(headers); - return REST_TEMPLATE + return restTemplate .exchange(profileUrl, HttpMethod.GET, httpEntity, GithubProfileResponse.class) .getBody(); } @@ -76,7 +78,7 @@ private String getAccessTokenResponse(final GithubAccessTokenRequest githubAcces headers ); - return REST_TEMPLATE + return restTemplate .exchange(accessTokenUrl, HttpMethod.POST, httpEntity, GithubAccessTokenResponse.class) .getBody() .getAccessToken(); diff --git a/backend/emm-sale/src/main/java/com/emmsale/notification/application/FirebaseCloudMessageClient.java b/backend/emm-sale/src/main/java/com/emmsale/notification/application/FirebaseCloudMessageClient.java new file mode 100644 index 000000000..8f2ee1588 --- /dev/null +++ b/backend/emm-sale/src/main/java/com/emmsale/notification/application/FirebaseCloudMessageClient.java @@ -0,0 +1,121 @@ +package com.emmsale.notification.application; + +import static com.emmsale.notification.exception.NotificationExceptionType.*; +import static com.emmsale.notification.exception.NotificationExceptionType.CONVERTING_JSON_ERROR; +import static com.emmsale.notification.exception.NotificationExceptionType.NOT_FOUND_FCM_TOKEN; + +import com.emmsale.member.domain.Member; +import com.emmsale.member.domain.MemberRepository; +import com.emmsale.member.exception.MemberException; +import com.emmsale.member.exception.MemberExceptionType; +import com.emmsale.notification.application.dto.FcmMessage; +import com.emmsale.notification.application.dto.FcmMessage.Data; +import com.emmsale.notification.application.dto.FcmMessage.Message; +import com.emmsale.notification.domain.FcmToken; +import com.emmsale.notification.domain.FcmTokenRepository; +import com.emmsale.notification.domain.Notification; +import com.emmsale.notification.exception.NotificationException; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.google.auth.oauth2.GoogleCredentials; +import java.io.IOException; +import java.util.List; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.core.io.ClassPathResource; +import org.springframework.http.HttpEntity; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpMethod; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.stereotype.Service; +import org.springframework.web.client.RestTemplate; + +@Service +@RequiredArgsConstructor +@Slf4j +public class FirebaseCloudMessageClient { + + private static final String PREFIX_ACCESS_TOKEN = "Bearer "; + private static final String PREFIX_FCM_REQUEST_URL = "https://fcm.googleapis.com/v1/projects/"; + private static final String POSTFIX_FCM_REQUEST_URL = "/messages:send"; + private static final String FIREBASE_KEY_PATH = "firebase-kerdy.json"; + private static final boolean DEFAULT_VALIDATE_ONLY = false; + + private final ObjectMapper objectMapper; + private final MemberRepository memberRepository; + private final RestTemplate restTemplate; + private final FcmTokenRepository fcmTokenRepository; + + @Value("${firebase.project.id}") + private String projectId; + + public void sendMessageTo(final Long receiverId, final Notification notification) { + + final FcmToken fcmToken = fcmTokenRepository.findByMemberId(receiverId) + .orElseThrow(() -> new NotificationException(NOT_FOUND_FCM_TOKEN)); + + final String message = makeMessage(fcmToken.getToken(), notification); + + final HttpHeaders httpHeaders = new HttpHeaders(); + httpHeaders.add(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE); + httpHeaders.add(HttpHeaders.AUTHORIZATION, PREFIX_ACCESS_TOKEN + getAccessToken()); + + final HttpEntity httpEntity = new HttpEntity<>(message, httpHeaders); + + final String fcmRequestUrl = PREFIX_FCM_REQUEST_URL + projectId + POSTFIX_FCM_REQUEST_URL; + + final ResponseEntity exchange = restTemplate.exchange( + fcmRequestUrl, + HttpMethod.POST, + httpEntity, + String.class + ); + + if (exchange.getStatusCode().isError()) { + log.error("firebase 접속 에러 = {}", exchange.getBody()); + } + } + + private String makeMessage(final String targetToken, final Notification notification) { + + final Long senderId = notification.getSenderId(); + final Member sender = memberRepository.findById(senderId) + .orElseThrow(() -> new MemberException(MemberExceptionType.NOT_FOUND_MEMBER)); + + final Data messageData = new Data( + sender.getName(), senderId.toString(), + notification.getReceiverId().toString(), notification.getMessage(), + sender.getOpenProfileUrl() + ); + + final Message message = new Message(messageData, targetToken); + + final FcmMessage fcmMessage = new FcmMessage(DEFAULT_VALIDATE_ONLY, message); + + try { + return objectMapper.writeValueAsString(fcmMessage); + } catch (JsonProcessingException e) { + log.error("메세지 보낼 때 JSON 변환 에러", e); + throw new NotificationException(CONVERTING_JSON_ERROR); + } + } + + private String getAccessToken() { + final String firebaseConfigPath = FIREBASE_KEY_PATH; + + try { + final GoogleCredentials googleCredentials = GoogleCredentials + .fromStream(new ClassPathResource(firebaseConfigPath).getInputStream()) + .createScoped(List.of("https://www.googleapis.com/auth/cloud-platform")); + + googleCredentials.refreshIfExpired(); + + return googleCredentials.getAccessToken().getTokenValue(); + } catch (IOException e) { + log.error("구글 토큰 요청 에러", e); + throw new NotificationException(GOOGLE_REQUEST_TOKEN_ERROR); + } + } +} diff --git a/backend/emm-sale/src/main/java/com/emmsale/notification/application/NotificationCommandService.java b/backend/emm-sale/src/main/java/com/emmsale/notification/application/NotificationCommandService.java index 861002b10..3d5a5f4ad 100644 --- a/backend/emm-sale/src/main/java/com/emmsale/notification/application/NotificationCommandService.java +++ b/backend/emm-sale/src/main/java/com/emmsale/notification/application/NotificationCommandService.java @@ -28,6 +28,7 @@ public class NotificationCommandService { private final NotificationRepository notificationRepository; private final FcmTokenRepository fcmTokenRepository; private final MemberRepository memberRepository; + private final FirebaseCloudMessageClient firebaseCloudMessageClient; public NotificationResponse create(final NotificationRequest notificationRequest) { final Long senderId = notificationRequest.getSenderId(); @@ -49,7 +50,8 @@ public NotificationResponse create(final NotificationRequest notificationRequest notificationRequest.getMessage() )); - //FCM에 notification 보내기 + firebaseCloudMessageClient.sendMessageTo(receiverId, savedNotification); + return NotificationResponse.from(savedNotification); } diff --git a/backend/emm-sale/src/main/java/com/emmsale/notification/application/NotificationQueryService.java b/backend/emm-sale/src/main/java/com/emmsale/notification/application/NotificationQueryService.java index aca34ff61..6c086b71b 100644 --- a/backend/emm-sale/src/main/java/com/emmsale/notification/application/NotificationQueryService.java +++ b/backend/emm-sale/src/main/java/com/emmsale/notification/application/NotificationQueryService.java @@ -1,5 +1,7 @@ package com.emmsale.notification.application; +import static com.emmsale.notification.exception.NotificationExceptionType.*; + import com.emmsale.notification.application.dto.NotificationResponse; import com.emmsale.notification.domain.Notification; import com.emmsale.notification.domain.NotificationRepository; @@ -18,9 +20,7 @@ public class NotificationQueryService { public NotificationResponse findNotificationBy(final Long notificationId) { final Notification savedNotification = notificationRepository.findById(notificationId) - .orElseThrow( - () -> new NotificationException(NotificationExceptionType.NOT_FOUND_NOTIFICATION) - ); + .orElseThrow(() -> new NotificationException(NOT_FOUND_NOTIFICATION)); return NotificationResponse.from(savedNotification); } diff --git a/backend/emm-sale/src/main/java/com/emmsale/notification/application/dto/FcmMessage.java b/backend/emm-sale/src/main/java/com/emmsale/notification/application/dto/FcmMessage.java new file mode 100644 index 000000000..2228afb3c --- /dev/null +++ b/backend/emm-sale/src/main/java/com/emmsale/notification/application/dto/FcmMessage.java @@ -0,0 +1,33 @@ +package com.emmsale.notification.application.dto; + +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@RequiredArgsConstructor +@Getter +public class FcmMessage { + + @JsonProperty("validate_only") + private final boolean validateOnly; + private final Message message; + + @RequiredArgsConstructor + @Getter + public static class Message { + + private final Data data; + private final String token; + } + + @RequiredArgsConstructor + @Getter + public static class Data { + + private final String senderName; + private final String senderId; + private final String receiverId; + private final String message; + private final String openProfileUrl; + } +} diff --git a/backend/emm-sale/src/main/java/com/emmsale/notification/exception/NotificationExceptionType.java b/backend/emm-sale/src/main/java/com/emmsale/notification/exception/NotificationExceptionType.java index 825c27997..e842426f5 100644 --- a/backend/emm-sale/src/main/java/com/emmsale/notification/exception/NotificationExceptionType.java +++ b/backend/emm-sale/src/main/java/com/emmsale/notification/exception/NotificationExceptionType.java @@ -13,7 +13,23 @@ public enum NotificationExceptionType implements BaseExceptionType { NOT_FOUND_NOTIFICATION( HttpStatus.NOT_FOUND, "알림이 존재하지 않습니다." + ), + + NOT_FOUND_FCM_TOKEN( + HttpStatus.NOT_FOUND, + "해당 사용자의 기기를 구별할 수 있는 FCM 토큰이 존재하지 않습니다." + ), + + CONVERTING_JSON_ERROR( + HttpStatus.INTERNAL_SERVER_ERROR, + "알림 메시지를 보낼 때 JSON으로 변환하는 과정에서 발생한 에러입니다." + ), + + GOOGLE_REQUEST_TOKEN_ERROR( + HttpStatus.INTERNAL_SERVER_ERROR, + "구글에 토큰 요청할 때 발생한 에러" ) + ; private final HttpStatus httpStatus; diff --git a/backend/emm-sale/src/main/resources/application.yml b/backend/emm-sale/src/main/resources/application.yml index 879137516..e11c6020b 100644 --- a/backend/emm-sale/src/main/resources/application.yml +++ b/backend/emm-sale/src/main/resources/application.yml @@ -38,3 +38,7 @@ security: token: secret-key: secret_key expire-length: 3_600_000_000 + +firebase: + project: + id: kerdy diff --git a/backend/emm-sale/src/main/resources/http/notification.http b/backend/emm-sale/src/main/resources/http/notification.http index 815c03220..ccdce57d2 100644 --- a/backend/emm-sale/src/main/resources/http/notification.http +++ b/backend/emm-sale/src/main/resources/http/notification.http @@ -1,18 +1,18 @@ ### 알림 저장 -POST http://localhost:8080/notification +POST http://localhost:8080/notifications Content-Type: application/json { "senderId" : 1, - "receiverId" : 2, - "message" : "알람이다", + "receiverId" : 3, + "message" : "테스트 알람이다", "eventId" : 1 } ### FCM 토큰 저장 API (새롭게 저장) -POST http://localhost:8080/notification/token +POST http://localhost:8080/notifications/token Content-Type: application/json { @@ -22,7 +22,7 @@ Content-Type: application/json ### FCM 토큰 저장 API (기존에 있던 것 수정) -POST http://localhost:8080/notification/token +POST http://localhost:8080/notifications/token Content-Type: application/json { diff --git a/backend/emm-sale/src/test/java/com/emmsale/notification/application/NotificationCommandServiceTest.java b/backend/emm-sale/src/test/java/com/emmsale/notification/application/NotificationCommandServiceTest.java index 09ccdb30e..d06b2506e 100644 --- a/backend/emm-sale/src/test/java/com/emmsale/notification/application/NotificationCommandServiceTest.java +++ b/backend/emm-sale/src/test/java/com/emmsale/notification/application/NotificationCommandServiceTest.java @@ -5,8 +5,13 @@ import static org.junit.jupiter.api.Assertions.assertAll; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.Mockito.doNothing; +import static org.mockito.Mockito.mock; import com.emmsale.helper.ServiceIntegrationTestHelper; +import com.emmsale.member.domain.MemberRepository; import com.emmsale.notification.application.dto.FcmTokenRequest; import com.emmsale.notification.application.dto.NotificationModifyRequest; import com.emmsale.notification.application.dto.NotificationRequest; @@ -18,6 +23,7 @@ import com.emmsale.notification.domain.NotificationStatus; import com.emmsale.notification.exception.NotificationException; import com.emmsale.notification.exception.NotificationExceptionType; +import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.junit.jupiter.params.ParameterizedTest; @@ -32,6 +38,22 @@ class NotificationCommandServiceTest extends ServiceIntegrationTestHelper { private FcmTokenRepository fcmTokenRepository; @Autowired private NotificationRepository notificationRepository; + @Autowired + private MemberRepository memberRepository; + private NotificationCommandService mockingNotificationCommandService; + private FirebaseCloudMessageClient firebaseCloudMessageClient; + + @BeforeEach + void setUp() { + firebaseCloudMessageClient = mock(FirebaseCloudMessageClient.class); + + mockingNotificationCommandService = new NotificationCommandService( + notificationRepository, + fcmTokenRepository, + memberRepository, + firebaseCloudMessageClient + ); + } @Test @DisplayName("create() : 알림을 새로 생성할 수 있다.") @@ -50,12 +72,14 @@ void test_create() throws Exception { eventId ); - final NotificationResponse actual = new NotificationResponse( + final NotificationResponse expected = new NotificationResponse( notificationId, senderId, receiverId, message, eventId ); + doNothing().when(firebaseCloudMessageClient).sendMessageTo(anyLong(), any()); + //when - final NotificationResponse expected = notificationCommandService.create(request); + final NotificationResponse actual = mockingNotificationCommandService.create(request); //then assertThat(actual)