diff --git a/.github/workflows/github_actions.yml b/.github/workflows/github_actions.yml index 2d8620c..ddd9511 100644 --- a/.github/workflows/github_actions.yml +++ b/.github/workflows/github_actions.yml @@ -23,6 +23,7 @@ jobs: S3_SECRET_KEY: ${{ secrets.S3_SECRET_KEY }} S3_BUCKET_NAME: ${{ secrets.S3_BUCKET_NAME }} S3_REGION: ${{ secrets.S3_REGION }} + FIREBASE_PROJECT_ID: ${{ secrets.FIREBASE_PROJECT_ID }} steps: # 1. GitHub Repository 파일 불러오기 @@ -52,7 +53,13 @@ jobs: username: ${{ secrets.DOCKERHUB_USERNAME }} password: ${{ secrets.DOCKERHUB_TOKEN }} - # 6. Docker 이미지 생성 및 Push + # 6. firebase 설정 파일 생성 + - name: Create firebase.json from Secret + run: | + mkdir -p src/main/resources/firebase + echo "${{ secrets.FIREBASE_JSON }}" > src/main/resources/firebase/firebase.json + + # 7. Docker 이미지 생성 및 Push - name: Docker 이미지 생성 및 push uses: docker/build-push-action@v6 with: @@ -74,9 +81,10 @@ jobs: S3_SECRET_KEY= ${{ secrets.S3_SECRET_KEY }} S3_BUCKET_NAME= ${{ secrets.S3_BUCKET_NAME }} S3_REGION= ${{ secrets.S3_REGION }} + FIREBASE_PROJECT_ID= ${{ secrets.FIREBASE_PROJECT_ID }} ## CD (Continuous Deployment) 파트 - # 7. EC2에 SSH로 접속하여 Docker 컨테이너 실행 + # 8. EC2에 SSH로 접속하여 Docker 컨테이너 실행 - name: EC2에 배포 uses: appleboy/ssh-action@v1.1.0 with: @@ -105,4 +113,5 @@ jobs: -e S3_SECRET_KEY=${{ secrets.S3_SECRET_KEY }} \ -e S3_BUCKET_NAME=${{ secrets.S3_BUCKET_NAME }} \ -e S3_REGION=${{ secrets.S3_REGION }} \ + -e FIREBASE_PROJECT_ID=${{ secrets.FIREBASE_PROJECT_ID }} \ ${{ secrets.DOCKERHUB_USERNAME }}/challenge:${{ github.sha }} diff --git a/.gitignore b/.gitignore index 37da8a6..a74133e 100644 --- a/.gitignore +++ b/.gitignore @@ -8,6 +8,7 @@ build/ /src/main/generated /src/main/resources/application-test.yml /src/main/resources/application-local.yml +/src/main/resources/firebase/** # General diff --git a/Dockerfile b/Dockerfile index 7993990..76593c1 100644 --- a/Dockerfile +++ b/Dockerfile @@ -18,6 +18,7 @@ ARG S3_ACCESS_KEY ARG S3_SECRET_KEY ARG S3_BUCKET_NAME ARG S3_REGION +ARG FIREBASE_PROJECT_ID # 라이브러리 설치에 필요한 파일만 복사 COPY build.gradle settings.gradle ./ diff --git a/build.gradle b/build.gradle index 3497920..6c6538b 100644 --- a/build.gradle +++ b/build.gradle @@ -73,6 +73,10 @@ dependencies { // s3 implementation "software.amazon.awssdk:s3:2.13.0" + + // Google Firebase + implementation 'com.google.firebase:firebase-admin:9.1.1' + } tasks.withType(JavaCompile) { diff --git a/src/main/java/com/challenge/api/controller/fcm/FcmController.java b/src/main/java/com/challenge/api/controller/fcm/FcmController.java new file mode 100644 index 0000000..0fc1e84 --- /dev/null +++ b/src/main/java/com/challenge/api/controller/fcm/FcmController.java @@ -0,0 +1,46 @@ +package com.challenge.api.controller.fcm; + +import com.challenge.annotation.AuthMember; +import com.challenge.api.ApiResponse; +import com.challenge.api.controller.fcm.request.FcmSendByIdRequest; +import com.challenge.api.controller.fcm.request.FcmSendByTokenRequest; +import com.challenge.api.controller.fcm.request.TokenSaveRequest; +import com.challenge.api.service.fcm.FcmService; +import com.challenge.domain.member.Member; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/api/v1/fcm") +public class FcmController { + + private final FcmService fcmService; + + @PostMapping("/send/token") + public ApiResponse sendMessageByToken(@Valid @RequestBody FcmSendByTokenRequest request) { + return ApiResponse.ok(fcmService.sendMessage(FcmSendByTokenRequest.toFcmMessage(request))); + } + + @PostMapping("/send/member") + public ApiResponse sendMessageByMemberId(@Valid @RequestBody FcmSendByIdRequest request) { + return ApiResponse.ok(fcmService.sendMessageById(request)); + } + + @PostMapping + public ApiResponse saveToken(@Valid @RequestBody TokenSaveRequest request, + @AuthMember Member member) { + return ApiResponse.ok(fcmService.saveToken(request.toServiceRequest(), member)); + } + + @DeleteMapping + public ApiResponse deleteToken(@AuthMember Member member) { + return ApiResponse.ok(fcmService.deleteToken(member)); + } + +} diff --git a/src/main/java/com/challenge/api/controller/fcm/request/FcmSendByIdRequest.java b/src/main/java/com/challenge/api/controller/fcm/request/FcmSendByIdRequest.java new file mode 100644 index 0000000..5a4f7d2 --- /dev/null +++ b/src/main/java/com/challenge/api/controller/fcm/request/FcmSendByIdRequest.java @@ -0,0 +1,29 @@ +package com.challenge.api.controller.fcm.request; + +import jakarta.validation.constraints.NotEmpty; +import jakarta.validation.constraints.NotNull; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@NoArgsConstructor +public class FcmSendByIdRequest { + + @NotNull + private Long memberId; + + @NotEmpty + private String title; + + @NotEmpty + private String body; + + @Builder + private FcmSendByIdRequest(Long memberId, String title, String body) { + this.memberId = memberId; + this.title = title; + this.body = body; + } + +} diff --git a/src/main/java/com/challenge/api/controller/fcm/request/FcmSendByTokenRequest.java b/src/main/java/com/challenge/api/controller/fcm/request/FcmSendByTokenRequest.java new file mode 100644 index 0000000..04bb3a5 --- /dev/null +++ b/src/main/java/com/challenge/api/controller/fcm/request/FcmSendByTokenRequest.java @@ -0,0 +1,33 @@ +package com.challenge.api.controller.fcm.request; + +import com.challenge.api.service.fcm.request.FcmMessage; +import jakarta.validation.constraints.NotEmpty; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@NoArgsConstructor +public class FcmSendByTokenRequest { + + @NotEmpty + private String token; + + @NotEmpty + private String title; + + @NotEmpty + private String body; + + @Builder + private FcmSendByTokenRequest(String token, String title, String body) { + this.token = token; + this.title = title; + this.body = body; + } + + public static FcmMessage toFcmMessage(FcmSendByTokenRequest request) { + return FcmMessage.of(request.getToken(), request.getTitle(), request.getBody()); + } + +} diff --git a/src/main/java/com/challenge/api/controller/fcm/request/TokenSaveRequest.java b/src/main/java/com/challenge/api/controller/fcm/request/TokenSaveRequest.java new file mode 100644 index 0000000..97fbceb --- /dev/null +++ b/src/main/java/com/challenge/api/controller/fcm/request/TokenSaveRequest.java @@ -0,0 +1,27 @@ +package com.challenge.api.controller.fcm.request; + +import com.challenge.api.service.fcm.request.TokenSaveServiceRequest; +import jakarta.validation.constraints.NotBlank; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@NoArgsConstructor +public class TokenSaveRequest { + + @NotBlank(message = "token은 필수 입력값입니다.") + String token; + + @Builder + private TokenSaveRequest(String token) { + this.token = token; + } + + public TokenSaveServiceRequest toServiceRequest() { + return TokenSaveServiceRequest.builder() + .token(this.token) + .build(); + } + +} diff --git a/src/main/java/com/challenge/api/service/fcm/FcmService.java b/src/main/java/com/challenge/api/service/fcm/FcmService.java new file mode 100644 index 0000000..391154f --- /dev/null +++ b/src/main/java/com/challenge/api/service/fcm/FcmService.java @@ -0,0 +1,86 @@ +package com.challenge.api.service.fcm; + +import com.challenge.api.controller.fcm.request.FcmSendByIdRequest; +import com.challenge.api.service.fcm.request.FcmMessage; +import com.challenge.api.service.fcm.request.TokenSaveServiceRequest; +import com.challenge.domain.member.Member; +import com.challenge.domain.member.MemberRepository; +import com.challenge.domain.notification.Notification; +import com.challenge.domain.notification.NotificationRepository; +import com.challenge.exception.ErrorCode; +import com.challenge.exception.GlobalException; +import com.google.firebase.messaging.ApnsConfig; +import com.google.firebase.messaging.Aps; +import com.google.firebase.messaging.ApsAlert; +import com.google.firebase.messaging.FirebaseMessaging; +import com.google.firebase.messaging.Message; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDateTime; + +@Slf4j +@Service +@RequiredArgsConstructor +public class FcmService { + + private final FirebaseMessaging firebaseMessaging; + private final MemberRepository memberRepository; + private final NotificationRepository notificationRepository; + + public String sendMessage(FcmMessage request) { + try { + // fcm 메시지 발송 + Message message = request.buildMessage().setApnsConfig(getApnsConfig(request)).build(); + firebaseMessaging.send(message); + } catch (Exception exception) { + log.error(exception.getMessage(), exception); + throw new GlobalException(ErrorCode.FCM_SERVICE_UNAVAILABLE); + } + + return "fcm 푸시 발송 성공"; + } + + @Transactional + public String sendMessageById(FcmSendByIdRequest request) { + Member member = memberRepository.findById(request.getMemberId()).orElseThrow( + () -> new GlobalException(ErrorCode.MEMBER_NOT_FOUND)); + + if (member.getFcmToken() == null) { + throw new GlobalException(ErrorCode.FCM_TOKEN_NOT_FOUND); + } + + // fcm 메시지 전송 + FcmMessage fcmMessage = FcmMessage.of(member.getFcmToken(), request.getTitle(), request.getBody()); + String result = sendMessage(fcmMessage); + + // 알림 발송 내역 저장 + Notification notification = Notification.of(request.getTitle(), request.getBody(), LocalDateTime.now(), member); + notificationRepository.save(notification); + + return result; + } + + @Transactional + public String saveToken(TokenSaveServiceRequest request, Member member) { + member.updateFcmToken(request.getToken()); + + return "fcm 토큰 저장 성공"; + } + + @Transactional + public String deleteToken(Member member) { + member.updateFcmToken(null); + + return "fcm 토큰 삭제 성공"; + } + + private ApnsConfig getApnsConfig(FcmMessage request) { + ApsAlert alert = ApsAlert.builder().setTitle(request.getTitle()).setBody(request.getBody()).build(); + Aps aps = Aps.builder().setAlert(alert).setSound("default").build(); + return ApnsConfig.builder().setAps(aps).build(); + } + +} diff --git a/src/main/java/com/challenge/api/service/fcm/request/FcmMessage.java b/src/main/java/com/challenge/api/service/fcm/request/FcmMessage.java new file mode 100644 index 0000000..e194327 --- /dev/null +++ b/src/main/java/com/challenge/api/service/fcm/request/FcmMessage.java @@ -0,0 +1,37 @@ +package com.challenge.api.service.fcm.request; + +import com.google.firebase.messaging.Message; +import com.google.firebase.messaging.Notification; +import lombok.Builder; +import lombok.Getter; + +@Getter +@Builder +public class FcmMessage { + + String token; + String title; + String body; + + public static FcmMessage of(String token, String title, String body) { + return FcmMessage.builder() + .token(token) + .title(title) + .body(body) + .build(); + } + + public Message.Builder buildMessage() { + return Message.builder() + .setToken(token) + .setNotification(toNotification()); + } + + public Notification toNotification() { + return Notification.builder() + .setTitle(title) + .setBody(body) + .build(); + } + +} diff --git a/src/main/java/com/challenge/api/service/fcm/request/TokenSaveServiceRequest.java b/src/main/java/com/challenge/api/service/fcm/request/TokenSaveServiceRequest.java new file mode 100644 index 0000000..1f560f2 --- /dev/null +++ b/src/main/java/com/challenge/api/service/fcm/request/TokenSaveServiceRequest.java @@ -0,0 +1,18 @@ +package com.challenge.api.service.fcm.request; + +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@NoArgsConstructor +public class TokenSaveServiceRequest { + + String token; + + @Builder + private TokenSaveServiceRequest(String token) { + this.token = token; + } + +} diff --git a/src/main/java/com/challenge/config/FcmConfig.java b/src/main/java/com/challenge/config/FcmConfig.java new file mode 100644 index 0000000..939c551 --- /dev/null +++ b/src/main/java/com/challenge/config/FcmConfig.java @@ -0,0 +1,49 @@ +package com.challenge.config; + +import com.google.auth.oauth2.GoogleCredentials; +import com.google.firebase.FirebaseApp; +import com.google.firebase.FirebaseOptions; +import com.google.firebase.messaging.FirebaseMessaging; +import jakarta.annotation.PostConstruct; +import org.springframework.beans.factory.annotation.Value; +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 FcmConfig { + + private final ClassPathResource firebaseResource; + private final String projectId; + + public FcmConfig(@Value("${fcm.file_path}") String firebaseFilePath, + @Value("${fcm.project_id}") String projectId) { + this.firebaseResource = new ClassPathResource(firebaseFilePath); + this.projectId = projectId; + } + + @PostConstruct + public void init() throws IOException { + FirebaseOptions option = FirebaseOptions.builder() + .setCredentials(GoogleCredentials.fromStream(firebaseResource.getInputStream())) + .setProjectId(projectId) + .build(); + + if (FirebaseApp.getApps().isEmpty()) { + FirebaseApp.initializeApp(option); + } + } + + @Bean + FirebaseMessaging firebaseMessaging() { + return FirebaseMessaging.getInstance(firebaseApp()); + } + + @Bean + FirebaseApp firebaseApp() { + return FirebaseApp.getInstance(); + } + +} diff --git a/src/main/java/com/challenge/config/WebConfig.java b/src/main/java/com/challenge/config/WebConfig.java index f84528d..2ffdbe5 100644 --- a/src/main/java/com/challenge/config/WebConfig.java +++ b/src/main/java/com/challenge/config/WebConfig.java @@ -17,7 +17,8 @@ public class WebConfig implements WebMvcConfigurer { private final AuthMemberArgumentResolver authMemberArgumentResolver; private final AuthInterceptor authInterceptor; - private final List excludeEndpoints = Arrays.asList("/api/v1/auth/**", "/api/v1/member/nickname/valid"); + private final List excludeEndpoints = Arrays.asList("/api/v1/auth/**", "/api/v1/member/nickname/valid", + "/api/v1/fcm/send/**"); // 인터셉터 설정 @Override diff --git a/src/main/java/com/challenge/domain/member/Member.java b/src/main/java/com/challenge/domain/member/Member.java index 03deeb2..84cfc0d 100644 --- a/src/main/java/com/challenge/domain/member/Member.java +++ b/src/main/java/com/challenge/domain/member/Member.java @@ -66,6 +66,9 @@ public class Member extends BaseDateTimeEntity { @Column(columnDefinition = "BOOLEAN DEFAULT false") private boolean isNotificationReceived = false; + @Column(length = 400) + private String fcmToken; + @Column(columnDefinition = "BOOLEAN DEFAULT false") private boolean isDeleted = false; @@ -136,4 +139,8 @@ public void updateNotificationReceived() { this.isNotificationReceived = !this.isNotificationReceived; } + public void updateFcmToken(String fcmToken) { + this.fcmToken = fcmToken; + } + } diff --git a/src/main/java/com/challenge/domain/notification/Notification.java b/src/main/java/com/challenge/domain/notification/Notification.java index 6a87020..31f102e 100644 --- a/src/main/java/com/challenge/domain/notification/Notification.java +++ b/src/main/java/com/challenge/domain/notification/Notification.java @@ -11,6 +11,7 @@ import jakarta.persistence.JoinColumn; import jakarta.persistence.ManyToOne; import lombok.AccessLevel; +import lombok.Builder; import lombok.Getter; import lombok.NoArgsConstructor; @@ -32,13 +33,27 @@ public class Notification extends BaseDateTimeEntity { @Column(nullable = false, length = 500) private String content; - @Column(nullable = false, columnDefinition = "boolean default false") - private boolean isSend; - private LocalDateTime sendAt; @ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name = "member_id", nullable = false) private Member member; + public static Notification of(String title, String content, LocalDateTime sendAt, Member member) { + return Notification.builder() + .title(title) + .content(content) + .sendAt(sendAt) + .member(member) + .build(); + } + + @Builder + private Notification(String title, String content, LocalDateTime sendAt, Member member) { + this.title = title; + this.content = content; + this.sendAt = sendAt; + this.member = member; + } + } diff --git a/src/main/java/com/challenge/domain/notification/NotificationRepository.java b/src/main/java/com/challenge/domain/notification/NotificationRepository.java new file mode 100644 index 0000000..d503674 --- /dev/null +++ b/src/main/java/com/challenge/domain/notification/NotificationRepository.java @@ -0,0 +1,7 @@ +package com.challenge.domain.notification; + +import org.springframework.data.jpa.repository.JpaRepository; + +public interface NotificationRepository extends JpaRepository { + +} diff --git a/src/main/java/com/challenge/exception/ErrorCode.java b/src/main/java/com/challenge/exception/ErrorCode.java index 9169e0b..0e9aa48 100644 --- a/src/main/java/com/challenge/exception/ErrorCode.java +++ b/src/main/java/com/challenge/exception/ErrorCode.java @@ -83,8 +83,10 @@ public enum ErrorCode { * 기타 에러 */ JOB_NOT_FOUND(NOT_FOUND, "ERROR_4001", "직무 정보를 찾을 수 없습니다. 관리자에게 문의 바랍니다."), - S3_UPLOAD_ERROR(INTERNAL_SERVER_ERROR, "ERROR_4002", "이미지 업로드에 실패했습니다. 관리자에게 문의 바랍니다."); - + S3_UPLOAD_ERROR(INTERNAL_SERVER_ERROR, "ERROR_4002", "이미지 업로드에 실패했습니다. 관리자에게 문의 바랍니다."), + FCM_SERVICE_UNAVAILABLE(INTERNAL_SERVER_ERROR, "ERROR_4003", "FCM 서버 요청에 실패했습니다. 관리자에게 문의 바랍니다."), + FCM_TOKEN_NOT_FOUND(NOT_FOUND, "ERROR_4004", "해당 회원의 FCM 토큰을 찾을 수 없습니다. 관리자에게 문의 바랍니다."); + private final HttpStatus status; private final String code; private final String message; diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 1b4314c..6c3a0c1 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -92,3 +92,7 @@ cloud: static: ${S3_REGION} stack: auto: false + +fcm: + file_path: firebase/firebase.json + project_id: ${FIREBASE_PROJECT_ID}