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

✨ [STMT-296] 알림 토큰 갱신 API 구현 및 FCM 알림 전송 테스트 기능 구현 #155

Merged
merged 19 commits into from
Oct 15, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
a93e181
:card_file_box: [STMT-296] 알림 토큰 테이블 DDL 작성
05AM Oct 15, 2024
43ceb3c
:sparkles: [STMT-296] 알림 토큰 jpa entity 구현
05AM Oct 15, 2024
977a0cf
:sparkles: [STMT-296] 알림 토큰 도메인 구현
05AM Oct 15, 2024
4186859
:sparkles: [STMT-296] 알림 토큰 DB 저장, 쿼리 메서드 구현
05AM Oct 15, 2024
07b44e8
:sparkles: [STMT-296] 알림 토큰 갱신 유스케이스 구현
05AM Oct 15, 2024
8f9666a
:sparkles: [STMT-296] 알림 토큰 갱신 API 구현
05AM Oct 15, 2024
082a811
:adhesive_bandage: [STMT-296] 알림 토큰 갱신 API 반환 HTTP 상태코드 변경
05AM Oct 15, 2024
8c4978d
:white_check_mark: [STMT-296] 알림 토큰 갱신 API 테스트 작성
05AM Oct 15, 2024
bfd3e37
:memo: [STMT-296] 알림 토큰 갱신 API 명세서 작성
05AM Oct 15, 2024
cc1e1f4
:sparkles: [STMT-296] fcm admin 설정
05AM Oct 15, 2024
9191851
:sparkles: [STMT-296] fcm 알림 테스트용 알림 전송, 주제 구독 기능 구현
05AM Oct 15, 2024
669687f
:green_heart: [STMT-296] github CI action에 FCM account secret 환경 변수 설…
05AM Oct 15, 2024
3ef7af8
:green_heart: [STMT-296] FCM account secret 환경 변수 띄어쓰기 추가
05AM Oct 15, 2024
b0fe456
:green_heart: [STMT-296] firebase 디렉터리 생성 추가
05AM Oct 15, 2024
d326348
:wrench: [STMT-296] FCM account secret 경로 설정파일 수정
05AM Oct 15, 2024
5fa8abf
:adhesive_bandage: [STMT-296] resource에서 파일을 가져오고, FirebaseApp 재초기화를 …
05AM Oct 15, 2024
9225032
:adhesive_bandage: [STMT-296] 디버깅용 파일 내용 출력 코드 추가
05AM Oct 15, 2024
e6748bc
:adhesive_bandage: [STMT-296] 디버깅용 출력 코드 삭제
05AM Oct 15, 2024
dc24182
:rocket: [STMT-296] cicd 워크플로우 파일에 fcm account secret 환경 변수 설정 추가
05AM Oct 15, 2024
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
Loading