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

[feat]: FCM을 활용한 웹 푸시 기능 #88

Merged
merged 11 commits into from
Nov 1, 2024
13 changes: 13 additions & 0 deletions .github/workflows/cd.yml
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,10 @@ jobs:
- name: Decode env.properties from GitHub Secrets
run: |
echo "${{ secrets.ENV_FILE }}" | base64 --decode > ./env.properties

- name: Decode Firebase config from GitHub Secrets
run: |
echo "${{ secrets.FIREBASE_CONFIG }}" | base64 --decode > ./splanet-firebase.json

- name: Transfer env.properties to EC2
uses: appleboy/[email protected]
Expand All @@ -41,6 +45,15 @@ jobs:
source: "./env.properties"
target: "/home/ubuntu/"

- name: Transfer splanet-firebase.json to EC2
uses: appleboy/[email protected]
with:
host: ${{ secrets.EC2_HOST }}
username: ubuntu
key: ${{ secrets.EC2_SSH_KEY }}
source: "./splanet-firebase.json"
target: "/home/ubuntu/"

- name: Build and Push Docker image
run: docker buildx build --push --platform linux/amd64 -t kimsongmok/splanet:${{ env.IMAGE_TAG }} .

Expand Down
4 changes: 4 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,10 @@ jobs:
run: |
echo "${{ secrets.ENV_FILE }}" | base64 --decode > ./src/main/resources/env.properties

- name: Decode Firebase config from GitHub Secrets
run: |
echo "${{ secrets.FIREBASE_CONFIG }}" | base64 --decode > ./src/main/resources/splanet-firebase.json

- name: Set environment variables from env.properties
run: |
set -o allexport
Expand Down
3 changes: 2 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -54,4 +54,5 @@ logs/
splanet-db
### env ###
.env
/src/main/resources/env.properties
/src/main/resources/env.properties
splanet-firebase.json
2 changes: 1 addition & 1 deletion Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ COPY gradle gradle
COPY build.gradle .
COPY settings.gradle .
COPY src src
COPY splanet-firebase.json src/main/resources/splanet-firebase.json
RUN chmod +x ./gradlew

# Gradle 빌드에서 프로필을 지정하여 실행
Expand All @@ -17,6 +18,5 @@ COPY --from=builder build/libs/*.jar app.jar
# 런타임에서도 동일하게 환경 변수 사용
ENV SPRING_PROFILES_ACTIVE=prod


ENTRYPOINT ["java", "-jar", "/app.jar"]
VOLUME /tmp
4 changes: 4 additions & 0 deletions build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,10 @@ dependencies {
implementation 'org.springframework.boot:spring-boot-starter'

implementation 'mysql:mysql-connector-java:8.0.33' // 버전 추가'

// FCM
implementation 'com.google.firebase:firebase-admin:6.8.1'

}

tasks.named('test') {
Expand Down
2 changes: 2 additions & 0 deletions src/main/java/com/splanet/splanet/SplanetApplication.java
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,11 @@
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.data.jpa.repository.config.EnableJpaAuditing;
import org.springframework.scheduling.annotation.EnableScheduling;

@SpringBootApplication
@EnableJpaAuditing
@EnableScheduling
public class SplanetApplication {

public static void main(String[] args) {
Expand Down
31 changes: 31 additions & 0 deletions src/main/java/com/splanet/splanet/config/FirebaseConfig.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
package com.splanet.splanet.config;

import com.google.auth.oauth2.GoogleCredentials;
import com.google.firebase.FirebaseApp;
import com.google.firebase.FirebaseOptions;
import com.google.firebase.messaging.FirebaseMessaging;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.core.io.ClassPathResource;

import java.io.IOException;

@Configuration
public class FirebaseConfig {

@Bean
public FirebaseMessaging firebaseMessaging() throws IOException {
GoogleCredentials googleCredentials = GoogleCredentials
.fromStream(new ClassPathResource("splanet-firebase.json").getInputStream());

FirebaseOptions options = new FirebaseOptions.Builder()
.setCredentials(googleCredentials)
.build();

if (FirebaseApp.getApps().isEmpty()) {
FirebaseApp.initializeApp(options);
}

return FirebaseMessaging.getInstance();
}
}
37 changes: 37 additions & 0 deletions src/main/java/com/splanet/splanet/core/fcm/FCMInitializer.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
package com.splanet.splanet.core.fcm;

import com.google.auth.oauth2.GoogleCredentials;
import com.google.firebase.FirebaseApp;
import com.google.firebase.FirebaseOptions;
import jakarta.annotation.PostConstruct;
import lombok.extern.slf4j.Slf4j;
import org.springframework.core.io.ClassPathResource;
import org.springframework.stereotype.Service;

import java.io.IOException;

@Service
@Slf4j
public class FCMInitializer {

private static final String FIREBASE_CONFIG_PATH = "splanet-firebase.json";

@PostConstruct
public void initialize() {
try {
if (FirebaseApp.getApps().isEmpty()) {
GoogleCredentials googleCredentials = GoogleCredentials
.fromStream(new ClassPathResource(FIREBASE_CONFIG_PATH).getInputStream());
FirebaseOptions options = new FirebaseOptions.Builder()
.setCredentials(googleCredentials)
.build();
FirebaseApp.initializeApp(options);
log.info("FirebaseApp 초기화 완료");
} else {
log.info("FirebaseApp이 이미 초기화되었습니다.");
}
} catch (IOException e) {
log.error("FCM 초기화 오류 발생: " + e.getMessage());
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
package com.splanet.splanet.core.util;

import jakarta.persistence.EntityManagerFactory;
import lombok.RequiredArgsConstructor;
import org.hibernate.SessionFactory;
import org.hibernate.stat.Statistics;
import org.springframework.stereotype.Service;

@Service
@RequiredArgsConstructor
public class QueryPerformanceService {

private final EntityManagerFactory entityManagerFactory;

public void measureQueryCountAndTime(Runnable methodToTest) {
// SessionFactory에서 Statistics 객체 가져오기
SessionFactory sessionFactory = entityManagerFactory.unwrap(SessionFactory.class);
Statistics statistics = sessionFactory.getStatistics();
statistics.clear(); // 이전 통계 초기화

// 쿼리 실행 시간 측정 시작
long startTime = System.nanoTime();

// 테스트할 메서드 실행
methodToTest.run();

// 쿼리 실행 시간 측정 종료
long endTime = System.nanoTime();
long executionTime = (endTime - startTime) / 1_000_000; // 밀리초로 변환

// 쿼리 실행 횟수 확인
long queryCount = statistics.getQueryExecutionCount();

// 실행 시간과 쿼리 횟수 로그 출력
System.out.println("Query Execution Time: " + executionTime + " ms");
System.out.println("Query Execution Count: " + queryCount);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,7 @@ private boolean isApiPath(String requestURI) {
}

private boolean isExemptedPath(String requestURI) {
return requestURI.equals("/api/users/create") || requestURI.startsWith("/api/token") || requestURI.startsWith("/api/stt") || requestURI.equals("/api/gpt/trial") || requestURI.equals("/api/gpt/generate-device-id") || requestURI.equals("/api/gpt/plan/save");
return requestURI.equals("/api/users/create") || requestURI.startsWith("/api/token") || requestURI.startsWith("/api/stt") || requestURI.equals("/api/gpt/trial") || requestURI.equals("/api/gpt/generate-device-id") || requestURI.equals("/api/gpt/plan/save") || requestURI.startsWith("/api/notification");
}

private void sendErrorResponse(HttpServletResponse response, int status, String message) throws IOException {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
package com.splanet.splanet.notification.controller;

import com.splanet.splanet.notification.dto.FcmTokenRequest;
import com.splanet.splanet.notification.dto.FcmTokenUpdateRequest;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.media.Content;
import io.swagger.v3.oas.annotations.responses.ApiResponse;
import io.swagger.v3.oas.annotations.responses.ApiResponses;
import io.swagger.v3.oas.annotations.tags.Tag;
import org.springframework.http.ResponseEntity;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.*;

@RequestMapping("/api/fcm")
@Tag(name = "FCM", description = "FCM 토큰 관리 API")
public interface FcmTokenApi {

@PostMapping("/register")
@Operation(summary = "FCM 토큰 등록", description = "유저가 FCM 토큰을 등록합니다.")
@ApiResponses(value = {
@ApiResponse(responseCode = "200", description = "FCM 토큰이 성공적으로 등록되었습니다."),
@ApiResponse(responseCode = "404", description = "유저를 찾을 수 없습니다.", content = @Content)
})
ResponseEntity<String> registerFcmToken(
@AuthenticationPrincipal Long userId,
@RequestBody FcmTokenRequest fcmTokenRequest
);

@PutMapping("/update")
@Operation(summary = "FCM 토큰 설정 수정", description = "알림 설정 및 알림 오프셋을 수정합니다.")
@ApiResponses(value = {
@ApiResponse(responseCode = "200", description = "FCM 토큰 설정이 성공적으로 수정되었습니다."),
@ApiResponse(responseCode = "404", description = "유저를 찾을 수 없습니다.", content = @Content)
})
ResponseEntity<String> updateFcmTokenSettings(
@AuthenticationPrincipal Long userId,
@RequestBody FcmTokenUpdateRequest fcmTokenUpdateRequest
);

@DeleteMapping("/delete")
@Operation(summary = "FCM 토큰 삭제", description = "유저의 FCM 토큰을 삭제합니다.")
@ApiResponses(value = {
@ApiResponse(responseCode = "200", description = "FCM 토큰이 성공적으로 삭제되었습니다."),
@ApiResponse(responseCode = "404", description = "해당 토큰을 찾을 수 없습니다.", content = @Content)
})
ResponseEntity<String> deleteFcmToken(
@AuthenticationPrincipal Long userId,
@RequestParam String token
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
package com.splanet.splanet.notification.controller;

import com.splanet.splanet.notification.dto.FcmTokenRequest;
import com.splanet.splanet.notification.dto.FcmTokenUpdateRequest;
import com.splanet.splanet.notification.service.FcmTokenService;
import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.RestController;

@RestController
@RequiredArgsConstructor
public class FcmTokenController implements FcmTokenApi {

private final FcmTokenService fcmTokenService;

@Override
public ResponseEntity<String> registerFcmToken(Long userId, FcmTokenRequest fcmTokenRequest) {
fcmTokenService.registerFcmToken(userId, fcmTokenRequest.token());
return ResponseEntity.ok("FCM token 생성 완료");
}

@Override
public ResponseEntity<String> updateFcmTokenSettings(Long userId, FcmTokenUpdateRequest fcmTokenUpdateRequest) {
fcmTokenService.updateFcmTokenSettings(userId, fcmTokenUpdateRequest);
return ResponseEntity.ok("FCM token 수정 완료");
}

@Override
public ResponseEntity<String> deleteFcmToken(Long userId, String token) {
fcmTokenService.deleteFcmToken(userId, token);
return ResponseEntity.ok("FCM token 삭제 완료");
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
package com.splanet.splanet.notification.controller;

import com.splanet.splanet.notification.service.NotificationService;
import io.swagger.v3.oas.annotations.Operation;
import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;

@RestController
@RequestMapping("/api/notifications")
@RequiredArgsConstructor
public class NotificationController {

private final NotificationService notificationService;

@PostMapping("/send/{userId}")
@Operation(summary = "푸시 알림 테스트", description = "해당 유저에게 테스트 알림을 전송합니다. (사전에 FCM 토큰 발급 필요)")
public ResponseEntity<String> sendTestNotification(@PathVariable Long userId) {
notificationService.sendTestNotification(userId);
return ResponseEntity.ok("테스트 알림 전송 완료: " + userId);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
package com.splanet.splanet.notification.dto;

import jakarta.validation.constraints.NotBlank;

public record FcmTokenRequest(
@NotBlank(message = "FCM 토큰은 필수입니다.")
String token
) {}
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
package com.splanet.splanet.notification.dto;

public record FcmTokenUpdateRequest(String token, Boolean isNotificationEnabled, Integer notificationOffset) {}
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
package com.splanet.splanet.notification.entity;

import com.splanet.splanet.core.BaseEntity;
import com.splanet.splanet.user.entity.User;
import jakarta.persistence.*;
import lombok.*;
import lombok.experimental.SuperBuilder;

import java.time.LocalDateTime;

@Getter
@Entity
@NoArgsConstructor
@AllArgsConstructor
@SuperBuilder(toBuilder = true)
public class FcmToken extends BaseEntity {

@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "user_id", nullable = false)
private User user;

@Column(nullable = false, unique = true)
private String token;

private String deviceType;

@Builder.Default
@Column(nullable = false)
private Boolean isNotificationEnabled = true;

@Builder.Default
@Column(nullable = false)
private Integer notificationOffset = 10;

public LocalDateTime calculateNotificationTime(LocalDateTime planStartDate) {
return planStartDate.minusMinutes(notificationOffset);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
package com.splanet.splanet.notification.entity;

import com.splanet.splanet.core.BaseEntity;
import com.splanet.splanet.plan.entity.Plan;
import jakarta.persistence.*;
import lombok.*;
import lombok.experimental.SuperBuilder;

import java.time.LocalDateTime;

@Getter
@Entity
@Table(
indexes = {
@Index(name = "idx_notification_log_fcm_token_id", columnList = "fcm_token_id")
}
)
@NoArgsConstructor
@AllArgsConstructor
@SuperBuilder(toBuilder = true)
public class NotificationLog extends BaseEntity {

@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "fcm_token_id", nullable = false)
private FcmToken fcmToken;

@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "plan_id", nullable = false)
private Plan plan;

@Column(nullable = false)
private LocalDateTime sentAt;
}
Loading
Loading