diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 7cc6b4e..872f13a 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -33,6 +33,12 @@ jobs: echo "${{ secrets.APPLICATION_DEV_YML }}" >> ./application-dev.yml shell: bash + - name: Create Firebase Config Directory + run: | + mkdir -p src/main/resources/firebase + echo "${{ secrets.FIREBASE_SERVICE_KEY }}" | base64 --decode > src/main/resources/firebase/catchmate-9653a-firebase-adminsdk-kh06c-0315680471.json + shell: bash + # gradlew에 실행 권한을 부여합니다. - name: Grant execute permission for gradlew run: chmod +x gradlew diff --git a/.gitignore b/.gitignore index 4457eb4..33c732e 100644 --- a/.gitignore +++ b/.gitignore @@ -37,3 +37,4 @@ out/ .vscode/ application-dev.yml +/src/main/resources/firebase/catchmate-9653a-firebase-adminsdk-kh06c-0315680471.json diff --git a/build.gradle b/build.gradle index 16f1df7..0cfffd3 100644 --- a/build.gradle +++ b/build.gradle @@ -58,6 +58,10 @@ dependencies { // AWS S3 implementation 'org.springframework.cloud:spring-cloud-starter-aws:2.2.6.RELEASE' + + // Firebase + implementation 'com.google.firebase:firebase-admin:9.2.0' + implementation group: 'com.squareup.okhttp3', name: 'okhttp', version: '4.2.2' } //tasks.named('test') { diff --git a/src/main/java/com/back/catchmate/domain/enroll/controller/EnrollController.java b/src/main/java/com/back/catchmate/domain/enroll/controller/EnrollController.java index 9eebb22..bf9b6ff 100644 --- a/src/main/java/com/back/catchmate/domain/enroll/controller/EnrollController.java +++ b/src/main/java/com/back/catchmate/domain/enroll/controller/EnrollController.java @@ -27,6 +27,8 @@ import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; +import java.io.IOException; + @Tag(name = "직관 신청 관련 API") @RestController @RequestMapping("/enroll") @@ -81,14 +83,14 @@ public NewEnrollCountInfo getNewEnrollmentListCount(@JwtValidation Long userId) @PatchMapping("/{enrollId}/accept") @Operation(summary = "받은 직관 신청 수락 API", description = "내가 받은 직관 신청을 수락하는 API 입니다.") public UpdateEnrollInfo acceptEnroll(@PathVariable Long enrollId, - @JwtValidation Long userId) { + @JwtValidation Long userId) throws IOException { return enrollService.acceptEnroll(enrollId, userId); } @PatchMapping("/{enrollId}/reject") @Operation(summary = "받은 직관 신청 거절 API", description = "내가 받은 직관 신청을 거절하는 API 입니다.") public UpdateEnrollInfo rejectEnroll(@PathVariable Long enrollId, - @JwtValidation Long userId) { + @JwtValidation Long userId) throws IOException { return enrollService.rejectEnroll(enrollId, userId); } } diff --git a/src/main/java/com/back/catchmate/domain/enroll/converter/EnrollConverter.java b/src/main/java/com/back/catchmate/domain/enroll/converter/EnrollConverter.java index 360f7bd..b651f9d 100644 --- a/src/main/java/com/back/catchmate/domain/enroll/converter/EnrollConverter.java +++ b/src/main/java/com/back/catchmate/domain/enroll/converter/EnrollConverter.java @@ -4,7 +4,6 @@ import com.back.catchmate.domain.board.dto.BoardResponse.BoardInfo; import com.back.catchmate.domain.board.entity.Board; import com.back.catchmate.domain.enroll.dto.EnrollRequest.CreateEnrollRequest; -import com.back.catchmate.domain.enroll.dto.EnrollResponse; import com.back.catchmate.domain.enroll.dto.EnrollResponse.CancelEnrollInfo; import com.back.catchmate.domain.enroll.dto.EnrollResponse.CreateEnrollInfo; import com.back.catchmate.domain.enroll.dto.EnrollResponse.EnrollReceiveInfo; diff --git a/src/main/java/com/back/catchmate/domain/enroll/service/EnrollService.java b/src/main/java/com/back/catchmate/domain/enroll/service/EnrollService.java index 160a376..5ed07aa 100644 --- a/src/main/java/com/back/catchmate/domain/enroll/service/EnrollService.java +++ b/src/main/java/com/back/catchmate/domain/enroll/service/EnrollService.java @@ -25,7 +25,7 @@ public interface EnrollService { NewEnrollCountInfo getNewEnrollListCount(Long userId); - UpdateEnrollInfo acceptEnroll(Long enrollId, Long userId); + UpdateEnrollInfo acceptEnroll(Long enrollId, Long userId) throws IOException; - UpdateEnrollInfo rejectEnroll(Long enrollId, Long userId); + UpdateEnrollInfo rejectEnroll(Long enrollId, Long userId) throws IOException; } diff --git a/src/main/java/com/back/catchmate/domain/enroll/service/EnrollServiceImpl.java b/src/main/java/com/back/catchmate/domain/enroll/service/EnrollServiceImpl.java index 3478b39..684ed79 100644 --- a/src/main/java/com/back/catchmate/domain/enroll/service/EnrollServiceImpl.java +++ b/src/main/java/com/back/catchmate/domain/enroll/service/EnrollServiceImpl.java @@ -13,6 +13,7 @@ import com.back.catchmate.domain.enroll.entity.AcceptStatus; import com.back.catchmate.domain.enroll.entity.Enroll; import com.back.catchmate.domain.enroll.repository.EnrollRepository; +import com.back.catchmate.domain.notification.service.FCMService; import com.back.catchmate.domain.user.entity.User; import com.back.catchmate.domain.user.repository.UserRepository; import com.back.catchmate.global.error.ErrorCode; @@ -23,12 +24,17 @@ import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import java.io.IOException; + +import static com.back.catchmate.domain.notification.message.NotificationMessages.*; + @Service @RequiredArgsConstructor public class EnrollServiceImpl implements EnrollService { private final EnrollRepository enrollRepository; private final UserRepository userRepository; private final BoardRepository boardRepository; + private final FCMService fcmService; private final EnrollConverter enrollConverter; @Override @@ -120,7 +126,7 @@ public EnrollResponse.NewEnrollCountInfo getNewEnrollListCount(Long userId) { @Override @Transactional - public UpdateEnrollInfo acceptEnroll(Long enrollId, Long userId) { + public UpdateEnrollInfo acceptEnroll(Long enrollId, Long userId) throws IOException { User loginUser = userRepository.findById(userId) .orElseThrow(() -> new BaseException(ErrorCode.USER_NOT_FOUND)); @@ -136,13 +142,16 @@ public UpdateEnrollInfo acceptEnroll(Long enrollId, Long userId) { throw new BaseException(ErrorCode.ENROLL_ACCEPT_INVALID); } + // 직관 신청자에게 수락 푸시 알림 메세지 전송 + fcmService.sendMessage(enrollApplicant.getFcmToken(), ENROLLMENT_ACCEPT_TITLE, ENROLLMENT_ACCEPT_BODY, enroll.getBoard().getId()); + enroll.setAcceptStatus(AcceptStatus.ACCEPTED); return enrollConverter.toUpdateEnrollInfo(enroll, AcceptStatus.ACCEPTED); } @Override @Transactional - public UpdateEnrollInfo rejectEnroll(Long enrollId, Long userId) { + public UpdateEnrollInfo rejectEnroll(Long enrollId, Long userId) throws IOException { User loginUser = userRepository.findById(userId) .orElseThrow(() -> new BaseException(ErrorCode.USER_NOT_FOUND)); @@ -158,6 +167,9 @@ public UpdateEnrollInfo rejectEnroll(Long enrollId, Long userId) { throw new BaseException(ErrorCode.ENROLL_REJECT_INVALID); } + // 직관 신청자에게 거절 푸시 알림 메세지 전송 + fcmService.sendMessage(enrollApplicant.getFcmToken(), ENROLLMENT_REJECT_TITLE, ENROLLMENT_REJECT_BODY, enroll.getBoard().getId()); + enroll.setAcceptStatus(AcceptStatus.REJECTED); return enrollConverter.toUpdateEnrollInfo(enroll, AcceptStatus.REJECTED); } diff --git a/src/main/java/com/back/catchmate/domain/notification/converter/NotificationConverter.java b/src/main/java/com/back/catchmate/domain/notification/converter/NotificationConverter.java new file mode 100644 index 0000000..46a9cc9 --- /dev/null +++ b/src/main/java/com/back/catchmate/domain/notification/converter/NotificationConverter.java @@ -0,0 +1,17 @@ +package com.back.catchmate.domain.notification.converter; + +import com.back.catchmate.domain.notification.dto.NotificationResponse.CreateNotificationInfo; +import com.back.catchmate.domain.notification.entity.Notification; +import org.springframework.stereotype.Component; + +import java.time.LocalDateTime; + +@Component +public class NotificationConverter { + public CreateNotificationInfo toCreateNotificationInfo(Notification notification) { + return CreateNotificationInfo.builder() + .notificationId(notification.getId()) + .createdAt(LocalDateTime.now()) + .build(); + } +} diff --git a/src/main/java/com/back/catchmate/domain/notification/dto/FCMMessageRequest.java b/src/main/java/com/back/catchmate/domain/notification/dto/FCMMessageRequest.java new file mode 100644 index 0000000..2c40a8b --- /dev/null +++ b/src/main/java/com/back/catchmate/domain/notification/dto/FCMMessageRequest.java @@ -0,0 +1,37 @@ +package com.back.catchmate.domain.notification.dto; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; + +@Getter +@Builder +@AllArgsConstructor +public class FCMMessageRequest { + private boolean validateOnly; + private Message message; + + @Getter + @Builder + @AllArgsConstructor + public static class Message { + private Notification notification; + private String token; + private Data data; + } + + @Getter + @Builder + @AllArgsConstructor + public static class Notification { + private String title; + private String body; + } + + @Getter + @Builder + @AllArgsConstructor + public static class Data { + private String boardId; + } +} diff --git a/src/main/java/com/back/catchmate/domain/notification/dto/NotificationResponse.java b/src/main/java/com/back/catchmate/domain/notification/dto/NotificationResponse.java new file mode 100644 index 0000000..fe87201 --- /dev/null +++ b/src/main/java/com/back/catchmate/domain/notification/dto/NotificationResponse.java @@ -0,0 +1,34 @@ +package com.back.catchmate.domain.notification.dto; + +import com.back.catchmate.domain.board.dto.BoardResponse.BoardInfo; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; + +public abstract class NotificationResponse { + + @Getter + @Builder + @NoArgsConstructor + @AllArgsConstructor + public static class NotificationInfo { + private Long notificationId; + private BoardInfo boardInfo; + private String title; + private String body; + private LocalDateTime createdAt; + private boolean isRead; + } + + @Getter + @Builder + @NoArgsConstructor + @AllArgsConstructor + public static class CreateNotificationInfo { + private Long notificationId; + private LocalDateTime createdAt; + } +} diff --git a/src/main/java/com/back/catchmate/domain/notification/entity/Notification.java b/src/main/java/com/back/catchmate/domain/notification/entity/Notification.java index 82c8a89..2726f58 100644 --- a/src/main/java/com/back/catchmate/domain/notification/entity/Notification.java +++ b/src/main/java/com/back/catchmate/domain/notification/entity/Notification.java @@ -46,4 +46,13 @@ public class Notification extends BaseTimeEntity { @Column(nullable = false) private boolean isRead; + + // 알림 수신 여부 설정 메서드 + public void markAsRead() { + this.isRead = true; + } + + public boolean isNotRead() { + return !this.isRead; + } } diff --git a/src/main/java/com/back/catchmate/domain/notification/message/NotificationMessages.java b/src/main/java/com/back/catchmate/domain/notification/message/NotificationMessages.java new file mode 100644 index 0000000..1970943 --- /dev/null +++ b/src/main/java/com/back/catchmate/domain/notification/message/NotificationMessages.java @@ -0,0 +1,8 @@ +package com.back.catchmate.domain.notification.message; + +public class NotificationMessages { + public static final String ENROLLMENT_ACCEPT_TITLE = "직관 신청 수락 안내 문자"; + public static final String ENROLLMENT_REJECT_TITLE = "직관 신청 수락 안내 문자"; + public static final String ENROLLMENT_ACCEPT_BODY = "귀하의 직관 신청이 승인되었습니다. 참여를 환영합니다!"; + public static final String ENROLLMENT_REJECT_BODY = "귀하의 직관 신청이 거절되었습니다. 다음 기회에 참여를 부탁드립니다."; +} diff --git a/src/main/java/com/back/catchmate/domain/notification/repository/NotificationRepository.java b/src/main/java/com/back/catchmate/domain/notification/repository/NotificationRepository.java new file mode 100644 index 0000000..4ef2ef7 --- /dev/null +++ b/src/main/java/com/back/catchmate/domain/notification/repository/NotificationRepository.java @@ -0,0 +1,7 @@ +package com.back.catchmate.domain.notification.repository; + +import com.back.catchmate.domain.notification.entity.Notification; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface NotificationRepository extends JpaRepository { +} diff --git a/src/main/java/com/back/catchmate/domain/notification/service/FCMService.java b/src/main/java/com/back/catchmate/domain/notification/service/FCMService.java new file mode 100644 index 0000000..cd1e196 --- /dev/null +++ b/src/main/java/com/back/catchmate/domain/notification/service/FCMService.java @@ -0,0 +1,88 @@ +package com.back.catchmate.domain.notification.service; + +import com.back.catchmate.domain.notification.dto.FCMMessageRequest; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.google.auth.oauth2.GoogleCredentials; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import okhttp3.MediaType; +import okhttp3.OkHttpClient; +import okhttp3.Request; +import okhttp3.RequestBody; +import okhttp3.Response; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.core.io.ClassPathResource; +import org.springframework.http.HttpHeaders; +import org.springframework.scheduling.annotation.Async; +import org.springframework.stereotype.Service; + +import java.io.IOException; +import java.util.List; + +@Slf4j +@Service +@RequiredArgsConstructor +public class FCMService { + @Value("${fcm.firebase_config_path}") + private String FIREBASE_CONFIG_PATH; + @Value("${fcm.firebase_api_uri}") + private String FIREBASE_ALARM_SEND_API_URI; + + private final ObjectMapper objectMapper; + + // Firebase로 부터 Access Token을 가져오는 메서드 + private String getAccessToken() throws IOException { + GoogleCredentials googleCredentials = GoogleCredentials + .fromStream(new ClassPathResource(FIREBASE_CONFIG_PATH).getInputStream()) + .createScoped(List.of("https://www.googleapis.com/auth/cloud-platform")); + + googleCredentials.refreshIfExpired(); + + return googleCredentials.getAccessToken().getTokenValue(); + } + + // 알림 파라미터들을 요구하는 body 형태로 가공 + public String makeMessage(String targetToken, String title, String body, Long boardId) throws JsonProcessingException { + FCMMessageRequest fcmMessage = FCMMessageRequest.builder() + .message( + FCMMessageRequest.Message.builder() + .token(targetToken) + .notification( + FCMMessageRequest.Notification.builder() + .title(title) + .body(body) + .build() + ) + .data( + FCMMessageRequest.Data.builder() + .boardId(String.valueOf(boardId)) + .build() + ) + .build() + ) + .validateOnly(false) + .build(); + + return objectMapper.writeValueAsString(fcmMessage); + } + + // 알림 푸쉬를 보내는 역할을 하는 메서드 + @Async("asyncTask") + public void sendMessage(String targetToken, String title, String body, Long boardId) throws IOException { + String message = makeMessage(targetToken, title, body, boardId); + + OkHttpClient client = new OkHttpClient(); + RequestBody requestBody = RequestBody.create(message, MediaType.get("application/json; charset=utf-8")); + + Request request = new Request.Builder() + .url(FIREBASE_ALARM_SEND_API_URI) + .post(requestBody) + .addHeader(HttpHeaders.AUTHORIZATION, "Bearer " + getAccessToken()) + .addHeader(HttpHeaders.CONTENT_TYPE, "application/json; UTF-8") + .build(); + + Response response = client.newCall(request).execute(); + log.info(response.body().string()); + } +}