Skip to content

Commit

Permalink
✨ [STMT-296] 알림 토큰 갱신 API 구현 및 FCM 알림 전송 테스트 기능 구현 (#155)
Browse files Browse the repository at this point in the history
* 🗃️ [STMT-296] 알림 토큰 테이블 DDL 작성

* ✨ [STMT-296] 알림 토큰 jpa entity 구현

* ✨ [STMT-296] 알림 토큰 도메인 구현

* ✨ [STMT-296] 알림 토큰 DB 저장, 쿼리 메서드 구현

* ✨ [STMT-296] 알림 토큰 갱신 유스케이스 구현

* ✨ [STMT-296] 알림 토큰 갱신 API 구현

* 🩹 [STMT-296] 알림 토큰 갱신 API 반환 HTTP 상태코드 변경

* ✅ [STMT-296] 알림 토큰 갱신 API 테스트 작성

* 📝 [STMT-296] 알림 토큰 갱신 API 명세서 작성

* ✨ [STMT-296] fcm admin 설정

* ✨ [STMT-296] fcm 알림 테스트용 알림 전송, 주제 구독 기능 구현

* 💚 [STMT-296] github CI action에 FCM account secret 환경 변수 설정 추가

* 💚 [STMT-296] FCM account secret 환경 변수 띄어쓰기 추가

* 💚 [STMT-296] firebase 디렉터리 생성 추가

* 🔧 [STMT-296] FCM account secret 경로 설정파일 수정

* 🩹 [STMT-296] resource에서 파일을 가져오고, FirebaseApp 재초기화를 방지하도록 수정

* 🩹 [STMT-296] 디버깅용 파일 내용 출력 코드 추가

* 🩹 [STMT-296] 디버깅용 출력 코드 삭제

* 🚀 [STMT-296] cicd 워크플로우 파일에 fcm account secret 환경 변수 설정 추가
  • Loading branch information
05AM authored Oct 15, 2024
1 parent a923a59 commit 11bee3b
Show file tree
Hide file tree
Showing 31 changed files with 593 additions and 9 deletions.
7 changes: 2 additions & 5 deletions .github/workflows/ci-cd.yml
Original file line number Diff line number Diff line change
Expand Up @@ -27,10 +27,10 @@ jobs:
- name: Set environment variables
run: |
cd ./src/main/resources
touch ./application-secret.properties
echo "${{ secrets.ENV }}" > ./application-secret.properties
mkdir -p ./firebase
echo "${{ secrets.FCM_ACCOUNT_SECRET }}" > ./firebase/"${{ secrets.FCM_ACCOUNT_SECRET_FILE_NAME }}"
# gradle caching - 빌드 시간 향상
- name: Gradle Caching
uses: actions/cache@v3
with:
Expand All @@ -41,14 +41,12 @@ jobs:
restore-keys: |
${{ runner.os }}-gradle-
# docker login
- name: Login to Docker Hub
uses: docker/login-action@v1
with:
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }}

# build gradle
- name: Build gradlew
run: |
chmod +x gradlew
Expand All @@ -57,7 +55,6 @@ jobs:
-Djib.from.auth.password=${{ secrets.DOCKER_PASSWORD }} \
-Dspring.profiles.active=prod
## deploy to production
- name: Deploy to prod
uses: appleboy/ssh-action@master
with:
Expand Down
2 changes: 2 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,8 @@ jobs:
run: |
cd ./src/test/resources
echo "${{ secrets.ENV }}" > ./application-secret.properties
mkdir -p ./firebase
echo "${{ secrets.FCM_ACCOUNT_SECRET }}" > ./firebase/"${{ secrets.FCM_ACCOUNT_SECRET_FILE_NAME }}"
- name: Gradle Caching
uses: actions/cache@v3
Expand Down
4 changes: 3 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -40,4 +40,6 @@ application-secret.properties

src/main/resources/static/docs/**

src/main/generated/**
src/main/generated/**

src/main/resources/firebase
3 changes: 2 additions & 1 deletion build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,8 @@ dependencies {
testImplementation 'org.springframework.cloud:spring-cloud-contract-stub-runner'
testImplementation 'org.testcontainers:localstack'


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

dependencyManagement {
Expand Down
26 changes: 26 additions & 0 deletions src/docs/asciidoc/index.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -899,6 +899,32 @@ include::{snippets}/delete-activity/fail/forbidden/response-fields.adoc[]
include::{snippets}/delete-activity/fail/not-joined-member/response-body.adoc[]
include::{snippets}/delete-activity/fail/not-joined-member/response-fields.adoc[]

== 알림
=== 알림 토큰 갱신

알림 토큰을 갱신하는 API 입니다.

==== POST /api/v1/notification-token/renew

===== 요청
include::{snippets}/renew-notification-token/success/http-request.adoc[]

===== 헤더
include::{snippets}/renew-notification-token/success/request-headers.adoc[]

===== 요청 본문
include::{snippets}/renew-notification-token/success/request-fields.adoc[]

===== 응답 성공 (200)
include::{snippets}/renew-notification-token/success/response-body.adoc[]
include::{snippets}/renew-notification-token/success/response-fields.adoc[]

===== 응답 실패 (400)
.존재하지 않는 스터디 분야 ID를 요청한 경우
include::{snippets}/renew-notification-token/fail/invalid-format/response-body.adoc[]
include::{snippets}/renew-notification-token/fail/invalid-format/response-fields.adoc[]


== 통합 API

=== 스터디 홈 화면 조회
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
package com.stumeet.server.common.config;

import java.io.IOException;
import java.io.InputStream;

import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.core.io.Resource;

import com.google.auth.oauth2.GoogleCredentials;
import com.google.firebase.FirebaseApp;
import com.google.firebase.FirebaseOptions;

@Configuration
public class FirebaseAdminConfig {

@Value("${fcm.secret.path}")
private Resource serviceAccountResource;

@Bean
public FirebaseApp initializeFirebaseApp() throws IOException {
if (FirebaseApp.getApps().isEmpty()) {
InputStream serviceAccount = serviceAccountResource.getInputStream();

FirebaseOptions options = FirebaseOptions.builder()
.setCredentials(GoogleCredentials.fromStream(serviceAccount))
.build();

return FirebaseApp.initializeApp(options);
} else {
return FirebaseApp.getInstance();
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,7 @@ public enum ErrorCode {
ACTIVITY_NOT_FOUND(HttpStatus.NOT_FOUND, "존재하지 않는 활동 입니다."),
ACTIVITY_STATUS_NOT_FOUND(HttpStatus.NOT_FOUND, "존재하지 않는 활동 상태 입니다."),
ACTIVITY_PARTICIPANT_NOT_FOUND(HttpStatus.NOT_FOUND, "존재하지 않는 활동 참가자입니다."),
NOTIFICATION_TOKEN_NOT_FOUND(HttpStatus.NOT_FOUND, "존재하지 않는 알림 토큰입니다."),

/*
409 - CONFLICT
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
package com.stumeet.server.notification.adapter.out.mapper;

import org.springframework.stereotype.Component;

import com.stumeet.server.notification.adapter.out.persistence.NotificationTokenJpaEntity;
import com.stumeet.server.notification.domain.NotificationToken;

@Component
public class NotificationTokenPersistenceMapper {

public NotificationToken toDomain(NotificationTokenJpaEntity entity) {
return NotificationToken.builder()
.id(entity.getId())
.memberId(entity.getMemberId())
.deviceId(entity.getDeviceId())
.token(entity.getToken())
.build();
}

public NotificationTokenJpaEntity toEntity(NotificationToken domain) {
return NotificationTokenJpaEntity.builder()
.id(domain.getId())
.memberId(domain.getMemberId())
.deviceId(domain.getDeviceId())
.token(domain.getToken())
.build();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
package com.stumeet.server.notification.adapter.out.notification;

import org.springframework.stereotype.Component;

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.MulticastMessage;
import com.google.firebase.messaging.Notification;
import com.google.firebase.messaging.TopicManagementResponse;
import com.stumeet.server.notification.application.port.out.ManageSubscriptionPort;
import com.stumeet.server.notification.application.port.out.NotificationSendPort;
import com.stumeet.server.notification.application.port.out.command.SendMessageCommand;
import com.stumeet.server.notification.application.port.out.command.SubscribeCommand;

import lombok.RequiredArgsConstructor;

@Component
@RequiredArgsConstructor
public class FcmNotificationAdapter implements ManageSubscriptionPort, NotificationSendPort {

@Override
public void subscribe(SubscribeCommand command) throws FirebaseMessagingException {
TopicManagementResponse response = FirebaseMessaging.getInstance()
.subscribeToTopic(command.registrationTokens(), command.topic());

System.out.println(response.getSuccessCount() + " tokens were subscribed successfully");
}

@Override
public void sendTokenMulticastMessage(SendMessageCommand command) throws FirebaseMessagingException {
Notification notification = makeNotification(command.title(), command.body(), command.image());

MulticastMessage multicastMessage = MulticastMessage.builder()
.addAllTokens(command.registrationTokens())
.putAllData(command.data())
.setNotification(notification)
.build();

BatchResponse response = FirebaseMessaging.getInstance()
.sendEachForMulticast(multicastMessage);

System.out.println(response.getSuccessCount() + " messages were sent successfully");
System.out.println(response);
}

@Override
public void sendTopicMessage(SendMessageCommand command) throws FirebaseMessagingException {
Notification notification = makeNotification(command.title(), command.body(), command.image());

Message message = Message.builder()
.setTopic(command.topic())
.putAllData(command.data())
.setNotification(notification)
.build();

String response = FirebaseMessaging.getInstance().send(message);
System.out.println("Successfully sent message: " + response);
}

private Notification makeNotification(String title, String body, String image) {
return Notification.builder()
.setTitle(title)
.setBody(body)
.setImage(image)
.build();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
package com.stumeet.server.notification.adapter.out.persistence;

import java.util.Optional;

import org.springframework.data.jpa.repository.JpaRepository;

public interface JpaNotificationTokenRepository extends JpaRepository<NotificationTokenJpaEntity, Long> {

Optional<NotificationTokenJpaEntity> findByMemberIdAndDeviceId(Long memberId, String deviceId);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
package com.stumeet.server.notification.adapter.out.persistence;

import org.hibernate.annotations.Comment;

import com.stumeet.server.common.model.BaseTimeEntity;

import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
import lombok.AccessLevel;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;

@Entity
@Table(name = "notification_token")
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@AllArgsConstructor(access = AccessLevel.PRIVATE)
@Builder
@Getter
public class NotificationTokenJpaEntity extends BaseTimeEntity {

@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Comment("등록 토큰 ID")
private Long id;

@Column(name = "member_id")
@Comment("멤버 ID")
private Long memberId;

@Column(name = "device_id", unique = true, updatable = false)
@Comment("기기 식별자")
private String deviceId;

@Column(name = "token")
@Comment("알림 토큰")
private String token;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
package com.stumeet.server.notification.adapter.out.persistence;

import com.stumeet.server.common.annotation.PersistenceAdapter;
import com.stumeet.server.notification.adapter.out.mapper.NotificationTokenPersistenceMapper;
import com.stumeet.server.notification.domain.exception.NotExistsNotificationTokenException;
import com.stumeet.server.notification.application.port.out.NotificationTokenQueryPort;
import com.stumeet.server.notification.application.port.out.SaveNotificationTokenPort;
import com.stumeet.server.notification.domain.NotificationToken;

import lombok.RequiredArgsConstructor;

@PersistenceAdapter
@RequiredArgsConstructor
public class NotificationTokenPersistenceAdapter implements NotificationTokenQueryPort, SaveNotificationTokenPort {

private final JpaNotificationTokenRepository jpaNotificationTokenRepository;

private final NotificationTokenPersistenceMapper notificationTokenPersistenceMapper;

@Override
public NotificationToken findTokenForMember(Long memberId, String deviceId) {
NotificationTokenJpaEntity entity = jpaNotificationTokenRepository.findByMemberIdAndDeviceId(memberId, deviceId)
.orElseThrow(() -> new NotExistsNotificationTokenException(memberId));

return notificationTokenPersistenceMapper.toDomain(entity);
}

@Override
public void save(NotificationToken notificationToken) {
NotificationTokenJpaEntity entity = notificationTokenPersistenceMapper.toEntity(notificationToken);
jpaNotificationTokenRepository.save(entity);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
package com.stumeet.server.notification.adapter.out.web;

import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;

import com.stumeet.server.common.annotation.WebAdapter;
import com.stumeet.server.common.auth.model.LoginMember;
import com.stumeet.server.common.model.ApiResponse;
import com.stumeet.server.notification.adapter.out.web.dto.RenewNotificationTokenRequest;
import com.stumeet.server.notification.application.port.in.RenewNotificationTokenUseCase;
import com.stumeet.server.notification.application.port.in.command.RenewNotificationTokenCommand;

import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;

@WebAdapter
@RequestMapping("/api/v1")
@RequiredArgsConstructor
public class RenewNotificationTokenApi {

private final RenewNotificationTokenUseCase renewNotificationTokenUseCase;

@PostMapping("/notification-token/renew")
public ResponseEntity<ApiResponse<Void>> renewRegistrationToken(
@AuthenticationPrincipal LoginMember member,
@RequestBody @Valid RenewNotificationTokenRequest request
) {
RenewNotificationTokenCommand command = new RenewNotificationTokenCommand(
member.getId(),
request.deviceId(),
request.notificationToken()
);

renewNotificationTokenUseCase.renewNotificationToken(command);

return new ResponseEntity<>(
ApiResponse.success(HttpStatus.CREATED.value(), "알림 토큰이 갱신되었습니다."),
HttpStatus.CREATED
);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
package com.stumeet.server.notification.adapter.out.web.dto;

import jakarta.validation.constraints.NotBlank;

public record RenewNotificationTokenRequest(
@NotBlank(message = "디바이스 식별자는 공백일 수 없습니다.")
String deviceId,

@NotBlank(message = "알림 토큰은 공백일 수 없습니다.")
String notificationToken
) {
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
package com.stumeet.server.notification.application.port.in;

import com.stumeet.server.notification.application.port.in.command.RenewNotificationTokenCommand;

public interface RenewNotificationTokenUseCase {
void renewNotificationToken(RenewNotificationTokenCommand command);
}
Loading

0 comments on commit 11bee3b

Please sign in to comment.