From e2a10397b0a9af99568142aad7ba32a365b8bc37 Mon Sep 17 00:00:00 2001 From: hoon Date: Wed, 27 Nov 2024 16:45:54 +0900 Subject: [PATCH 1/8] =?UTF-8?q?feat=20:=20FCM=20=ED=91=B8=EC=89=AC?= =?UTF-8?q?=EC=95=8C=EB=9E=8C=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/cicd.yml | 5 + .github/workflows/dev_cicd.yml | 5 + .gitignore | 1 + build.gradle | 3 + .../comment/service/CommentService.java | 2 + .../ootdzip/fcm/controller/FcmController.java | 51 ++++++ .../ootd/ootdzip/fcm/data/FcmMessageRes.java | 21 +++ .../zip/ootd/ootdzip/fcm/data/FcmPostReq.java | 11 ++ .../zip/ootd/ootdzip/fcm/domain/FcmInfo.java | 92 ++++++++++ .../fcm/domain/FcmNotificationType.java | 33 ++++ .../ootdzip/fcm/repository/FcmRepository.java | 17 ++ .../ootd/ootdzip/fcm/service/FcmService.java | 169 ++++++++++++++++++ .../controller/NotificationController.java | 7 - .../notification/data/NotificationRes.java | 25 --- .../repository/EmitterRepository.java | 59 ------ .../service/NotificationService.java | 77 +------- .../ootdzip/ootd/service/OotdService.java | 2 + .../zip/ootd/ootdzip/user/domain/User.java | 4 + .../user/repository/UserRepository.java | 7 + 19 files changed, 430 insertions(+), 161 deletions(-) create mode 100644 src/main/java/zip/ootd/ootdzip/fcm/controller/FcmController.java create mode 100644 src/main/java/zip/ootd/ootdzip/fcm/data/FcmMessageRes.java create mode 100644 src/main/java/zip/ootd/ootdzip/fcm/data/FcmPostReq.java create mode 100644 src/main/java/zip/ootd/ootdzip/fcm/domain/FcmInfo.java create mode 100644 src/main/java/zip/ootd/ootdzip/fcm/domain/FcmNotificationType.java create mode 100644 src/main/java/zip/ootd/ootdzip/fcm/repository/FcmRepository.java create mode 100644 src/main/java/zip/ootd/ootdzip/fcm/service/FcmService.java delete mode 100644 src/main/java/zip/ootd/ootdzip/notification/data/NotificationRes.java delete mode 100644 src/main/java/zip/ootd/ootdzip/notification/repository/EmitterRepository.java diff --git a/.github/workflows/cicd.yml b/.github/workflows/cicd.yml index 8fb84967..20a722fa 100644 --- a/.github/workflows/cicd.yml +++ b/.github/workflows/cicd.yml @@ -14,6 +14,9 @@ env: OCCUPY_SECRET_DIR: src/main/resources OCCUPY_SECRET_TEST_DIR: src/test/resources OCCUPY_SECRET_DIR_FILE_NAME: application-secret.yml + FCM_DIR: src/main/resources/firebase + FCM_FILE_NAME: ootdzip-cf27f-firebase-adminsdk-iuig1-8969152a6a.json + FCM_KEY: ${{ secrets.FCM_KEY }} jobs: build-with-gradle: @@ -30,6 +33,8 @@ jobs: distribution: 'corretto' - name: Secret 파일 복사 run: echo $OCCUPY_SECRET | base64 --decode > $OCCUPY_SECRET_DIR/$OCCUPY_SECRET_DIR_FILE_NAME && echo $OCCUPY_SECRET | base64 --decode > $OCCUPY_SECRET_TEST_DIR/$OCCUPY_SECRET_DIR_FILE_NAME + - name: FCM 키 파일 복사 + run: echo $OCCUPY_SECRET | base64 --decode > FCM_DIR/FCM_FILE_NAME && echo FCM_KEY - name: gradlew에 실행 권한 부여 run: chmod +x ./gradlew - name: 프로젝트 빌드 diff --git a/.github/workflows/dev_cicd.yml b/.github/workflows/dev_cicd.yml index 81699706..eb6378fe 100644 --- a/.github/workflows/dev_cicd.yml +++ b/.github/workflows/dev_cicd.yml @@ -14,6 +14,9 @@ env: OCCUPY_SECRET_DIR: src/main/resources OCCUPY_SECRET_TEST_DIR: src/test/resources OCCUPY_SECRET_DIR_FILE_NAME: application-secret.yml + FCM_DIR: src/main/resources/firebase + FCM_FILE_NAME: ootdzip-cf27f-firebase-adminsdk-iuig1-8969152a6a.json + FCM_KEY: ${{ secrets.FCM_KEY }} jobs: build-with-gradle: @@ -30,6 +33,8 @@ jobs: distribution: 'corretto' - name: Secret 파일 복사 run: echo $OCCUPY_SECRET | base64 --decode > $OCCUPY_SECRET_DIR/$OCCUPY_SECRET_DIR_FILE_NAME && echo $OCCUPY_SECRET | base64 --decode > $OCCUPY_SECRET_TEST_DIR/$OCCUPY_SECRET_DIR_FILE_NAME + - name: FCM 키 파일 복사 + run: echo $OCCUPY_SECRET | base64 --decode > FCM_DIR/FCM_FILE_NAME && echo FCM_KEY - name: gradlew에 실행 권한 부여 run: chmod +x ./gradlew - name: 프로젝트 빌드 diff --git a/.gitignore b/.gitignore index d99d68f7..6ce684ef 100644 --- a/.gitignore +++ b/.gitignore @@ -39,6 +39,7 @@ out/ ### key file ### **/application-secret.yaml **/resources/**/*.p8 +**/resources/firebase/ ### Querydsl ### /src/main/generated/ diff --git a/build.gradle b/build.gradle index 9327effe..83710c69 100644 --- a/build.gradle +++ b/build.gradle @@ -76,6 +76,9 @@ dependencies { annotationProcessor "jakarta.annotation:jakarta.annotation-api" annotationProcessor "jakarta.persistence:jakarta.persistence-api" + // firebase for 푸쉬알람 + implementation 'com.google.firebase:firebase-admin:9.2.0' + // test testImplementation 'org.springframework.boot:spring-boot-starter-test' testImplementation 'org.springframework.security:spring-security-test' diff --git a/src/main/java/zip/ootd/ootdzip/comment/service/CommentService.java b/src/main/java/zip/ootd/ootdzip/comment/service/CommentService.java index c0eadb05..018bb923 100644 --- a/src/main/java/zip/ootd/ootdzip/comment/service/CommentService.java +++ b/src/main/java/zip/ootd/ootdzip/comment/service/CommentService.java @@ -19,6 +19,7 @@ import zip.ootd.ootdzip.common.exception.CustomException; import zip.ootd.ootdzip.common.exception.code.ErrorCode; import zip.ootd.ootdzip.common.response.CommonSliceResponse; +import zip.ootd.ootdzip.fcm.service.FcmService; import zip.ootd.ootdzip.notification.domain.NotificationType; import zip.ootd.ootdzip.notification.event.NotificationEvent; import zip.ootd.ootdzip.ootd.domain.Ootd; @@ -99,6 +100,7 @@ private void notifyOotdComment(User receiver, User sender, String content, Strin return; } + // 앱 내 알람 eventPublisher.publishEvent(NotificationEvent.builder() .receiver(receiver) .sender(sender) diff --git a/src/main/java/zip/ootd/ootdzip/fcm/controller/FcmController.java b/src/main/java/zip/ootd/ootdzip/fcm/controller/FcmController.java new file mode 100644 index 00000000..c5583f07 --- /dev/null +++ b/src/main/java/zip/ootd/ootdzip/fcm/controller/FcmController.java @@ -0,0 +1,51 @@ +package zip.ootd.ootdzip.fcm.controller; + +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; + +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import zip.ootd.ootdzip.common.response.ApiResponse; +import zip.ootd.ootdzip.fcm.data.FcmPostReq; +import zip.ootd.ootdzip.fcm.service.FcmService; +import zip.ootd.ootdzip.user.service.UserService; + +@RestController +@RequiredArgsConstructor +@Tag(name = "FCM 컨트롤러", description = "푸쉬 알람 설정할 때 사용 합니다.") +@RequestMapping("/api/v1/fcm") +public class FcmController { + + private final FcmService fcmService; + + private final UserService userService; + + /** + * 푸쉬 알람을 허용한 유저로부터 + * FCM 토큰값을 얻어와서 DB 에 저장해둡니다. + * 해당 토큰값은 디바이스 고유값으로 해당 값으로 FCM 이 디바이스에게 푸쉬알람을 보낼 수 있습니다. + * 유저가 앱을실행하고 로그인할 때마다 프론트는 해당 API 를통해 토큰값을 서버로 보냅니다. + */ + @PostMapping("") + public ApiResponse onFcmToken(@RequestBody @Valid FcmPostReq fcmPostReq) { + + fcmService.onFcmToken(fcmPostReq, userService.getAuthenticatiedUser()); + + return new ApiResponse<>(true); + } + + /** + * 사용자 토큰 상태를 off 하여 해당 기기는 알림을 받을 수 없습니다. + */ + @DeleteMapping("") + public ApiResponse offFcmToken(@RequestBody @Valid FcmPostReq fcmPostReq) { + + fcmService.offFcmToken(fcmPostReq); + + return new ApiResponse<>(true); + } +} diff --git a/src/main/java/zip/ootd/ootdzip/fcm/data/FcmMessageRes.java b/src/main/java/zip/ootd/ootdzip/fcm/data/FcmMessageRes.java new file mode 100644 index 00000000..310004f9 --- /dev/null +++ b/src/main/java/zip/ootd/ootdzip/fcm/data/FcmMessageRes.java @@ -0,0 +1,21 @@ +package zip.ootd.ootdzip.fcm.data; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import zip.ootd.ootdzip.notification.domain.Notification; + +@Getter +@Builder +public class FcmMessageRes { + private boolean validateOnly; + private FcmMessageRes.Message message; + + @Builder + @AllArgsConstructor + @Getter + public static class Message { + private Notification notification; + private String token; + } +} diff --git a/src/main/java/zip/ootd/ootdzip/fcm/data/FcmPostReq.java b/src/main/java/zip/ootd/ootdzip/fcm/data/FcmPostReq.java new file mode 100644 index 00000000..c842dc5c --- /dev/null +++ b/src/main/java/zip/ootd/ootdzip/fcm/data/FcmPostReq.java @@ -0,0 +1,11 @@ +package zip.ootd.ootdzip.fcm.data; + +import jakarta.validation.constraints.NotNull; +import lombok.Data; + +@Data +public class FcmPostReq { + + @NotNull(message = "토큰값은 필수입니다.") + private String fcmToken; +} diff --git a/src/main/java/zip/ootd/ootdzip/fcm/domain/FcmInfo.java b/src/main/java/zip/ootd/ootdzip/fcm/domain/FcmInfo.java new file mode 100644 index 00000000..22994d5c --- /dev/null +++ b/src/main/java/zip/ootd/ootdzip/fcm/domain/FcmInfo.java @@ -0,0 +1,92 @@ +package zip.ootd.ootdzip.fcm.domain; + +import java.util.ArrayList; +import java.util.List; +import java.util.stream.Stream; + +import jakarta.persistence.CascadeType; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.OneToMany; +import jakarta.persistence.Table; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import zip.ootd.ootdzip.common.entity.BaseEntity; +import zip.ootd.ootdzip.notification.domain.Notification; +import zip.ootd.ootdzip.notification.domain.NotificationType; +import zip.ootd.ootdzip.user.domain.User; + +@Entity +@Table(name = "fcm_infos") +@Getter +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class FcmInfo extends BaseEntity { + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "user_id", nullable = false) + private User user; + + @Builder.Default + @Column(nullable = false) + private Boolean isPermission = true; + + @Builder.Default + @Column(nullable = false) + private Boolean isLogin = true; + + @Column(nullable = false) + private String fcmToken; + + @Builder.Default + @OneToMany(mappedBy = "fcmInfo", cascade = CascadeType.ALL, orphanRemoval = true) + List fcmNotificationTypes = new ArrayList<>(); + + // fcm 기본생성자 + // 모든 알람에 대해 허용으로 기본으로 생성합니다. + public static FcmInfo createDefaultFcmInfo(User user, String fcmToken) { + + List fcmDefaultNotificationTypes = Stream.of(NotificationType.values()) + .map(notificationType -> FcmNotificationType.builder() + .notificationType(notificationType).build()).toList(); + + FcmInfo fcmInfo = FcmInfo.builder() + .user(user) + .fcmToken(fcmToken) + .build(); + + fcmInfo.addFcmNotificationTypes(fcmDefaultNotificationTypes); + return fcmInfo; + } + + // 해당 기기 사용자가 로그인을 했을 경우 + public void login() { + this.isLogin = true; + } + + // 해당 기기 사용자가 로그아웃을 했을 경우 + public void logout() { + this.isLogin = false; + } + + public boolean isExistAllowNotificationType(Notification notification) { + return fcmNotificationTypes.stream() + .anyMatch(fnt -> fnt.getNotificationType() == notification.getNotificationType() && fnt.getIsAllow()); + } + + // == 연관관계 메서드 == // + public void addFcmNotificationType(FcmNotificationType fcmNotificationType) { + fcmNotificationTypes.add(fcmNotificationType); + fcmNotificationType.setFcmInfo(this); + } + + public void addFcmNotificationTypes(List fcmNotificationTypes) { + fcmNotificationTypes.forEach(this::addFcmNotificationType); + } +} diff --git a/src/main/java/zip/ootd/ootdzip/fcm/domain/FcmNotificationType.java b/src/main/java/zip/ootd/ootdzip/fcm/domain/FcmNotificationType.java new file mode 100644 index 00000000..5264f04d --- /dev/null +++ b/src/main/java/zip/ootd/ootdzip/fcm/domain/FcmNotificationType.java @@ -0,0 +1,33 @@ +package zip.ootd.ootdzip.fcm.domain; + +import jakarta.persistence.Entity; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; +import zip.ootd.ootdzip.common.entity.BaseEntity; +import zip.ootd.ootdzip.notification.domain.NotificationType; + +@Entity +@Table(name = "fcm_notification_types") +@Getter +@Setter +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class FcmNotificationType extends BaseEntity { + + @ManyToOne + @JoinColumn(name = "fcm_info_id", nullable = false) + private FcmInfo fcmInfo; + + private NotificationType notificationType; + + @Builder.Default + private Boolean isAllow = true; + +} diff --git a/src/main/java/zip/ootd/ootdzip/fcm/repository/FcmRepository.java b/src/main/java/zip/ootd/ootdzip/fcm/repository/FcmRepository.java new file mode 100644 index 00000000..ed8d16b6 --- /dev/null +++ b/src/main/java/zip/ootd/ootdzip/fcm/repository/FcmRepository.java @@ -0,0 +1,17 @@ +package zip.ootd.ootdzip.fcm.repository; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import zip.ootd.ootdzip.fcm.domain.FcmInfo; +import zip.ootd.ootdzip.user.domain.User; + +import java.util.Optional; + +@Repository +public interface FcmRepository extends JpaRepository { + + boolean existsByFcmToken(String fcmToken); + + Optional findByFcmToken(String fcmToken); +} diff --git a/src/main/java/zip/ootd/ootdzip/fcm/service/FcmService.java b/src/main/java/zip/ootd/ootdzip/fcm/service/FcmService.java new file mode 100644 index 00000000..c967d070 --- /dev/null +++ b/src/main/java/zip/ootd/ootdzip/fcm/service/FcmService.java @@ -0,0 +1,169 @@ +package zip.ootd.ootdzip.fcm.service; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.util.List; +import java.util.Optional; +import java.util.stream.Collectors; + +import org.springframework.core.io.ClassPathResource; +import org.springframework.http.HttpEntity; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpMethod; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.http.converter.StringHttpMessageConverter; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.client.RestTemplate; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.google.auth.oauth2.GoogleCredentials; + +import lombok.RequiredArgsConstructor; +import zip.ootd.ootdzip.fcm.data.FcmMessageRes; +import zip.ootd.ootdzip.fcm.data.FcmPostReq; +import zip.ootd.ootdzip.fcm.domain.FcmInfo; +import zip.ootd.ootdzip.fcm.repository.FcmRepository; +import zip.ootd.ootdzip.notification.domain.Notification; +import zip.ootd.ootdzip.user.domain.User; +import zip.ootd.ootdzip.user.repository.UserRepository; + +@Service +@Transactional +@RequiredArgsConstructor +public class FcmService { + + private final FcmRepository fcmRepository; + private final UserRepository userRepository; + + /** + * 사용자가 토큰이없다면 최초생성이므로 + * 모든 알람을 on 으로 하여 생성합니다. + * + * 토큰이 이미 존재한다면 + * 사용자 토큰을 로그인상태 설정하여 알람을 받을 수 있도록 합니다. + */ + @Transactional + public void onFcmToken(FcmPostReq request, User loginUser) { + + Optional fcmInfo = fcmRepository.findByFcmToken(request.getFcmToken()); + + // fcm 토큰이 없다면 + // 최초로 생성하고, 모든 알람허용으로 기본 생성 + if (fcmInfo.isEmpty()) { + FcmInfo createdFcmInfo = FcmInfo.createDefaultFcmInfo(loginUser, request.getFcmToken()); + fcmRepository.save(createdFcmInfo); + } + + // fcm 토큰이 있다면 + // 해당 토큰 기기 사용자를 로그인상태로 변경 + fcmInfo.ifPresent(FcmInfo::login); + } + + @Transactional + public void offFcmToken(FcmPostReq request) { + + Optional fcmInfo = fcmRepository.findByFcmToken(request.getFcmToken()); + + // fcm 토큰이 있다면 + // 해당 토큰 기기 사용자를 로그아웃 상태로 변경 + fcmInfo.ifPresent(FcmInfo::logout); + } + + /** + * 푸시 메시지 처리를 수행하는 비즈니스 로직 + */ + public boolean sendMessage(Notification notification) { + + List receiverTokens = getReceiverTokens(notification); + + // 토큰이 없다면 없는 유저거나 알람을 받지 않는 유저 + if (receiverTokens == null || receiverTokens.isEmpty()) { + return false; + } + + List> responses = receiverTokens.stream() + .map(token -> requestFCM(makeMessage(notification, token))).toList(); + + return true; + } + + public ResponseEntity requestFCM(String message) { + + RestTemplate restTemplate = new RestTemplate(); + + /** + * 추가된 사항 : RestTemplate 이용중 클라이언트의 한글 깨짐 증상에 대한 수정 + * @refernece : https://stackoverflow.com/questions/29392422/how-can-i-tell-resttemplate-to-post-with-utf-8-encoding + */ + restTemplate.getMessageConverters() + .add(0, new StringHttpMessageConverter(StandardCharsets.UTF_8)); + + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_JSON); + headers.set("Authorization", "Bearer " + getAccessToken()); + + HttpEntity entity = new HttpEntity<>(message, headers); + + String API_URL = ""; + ResponseEntity response = restTemplate.exchange(API_URL, HttpMethod.POST, entity, String.class); + + return response; + } + + /** + * 사용자가 허락한 푸쉬알람일시 해당하는 기기 토큰값 반환 + * 동일한 사용자여도 알람을 허용한 기기가 여러개 일 수 있음으로 리스트로 반환합니다. + */ + @Transactional + public List getReceiverTokens(Notification notification) { + Optional receiver = userRepository.findWithFcmInfosByUser(notification.getReceiver()); + // 알람을 수신할 유저가 존재하고 + // 해당 유저가 로그인상태고 + // 해당 유저가 알람 권한을 허용해놨을 경우 + return receiver.map(user -> user.getFcmInfos().stream() + .filter(FcmInfo::getIsLogin) + .filter(FcmInfo::getIsPermission) + .filter(fcmInfo -> fcmInfo.isExistAllowNotificationType(notification)) + .map(FcmInfo::getFcmToken) + .collect(Collectors.toList())).orElse(null); + } + + /** + * Firebase Admin SDK의 비공개 키를 참조하여 Bearer 토큰을 발급 받습니다. + */ + private String getAccessToken() { + String firebaseConfigPath = "firebase/ootdzip-cf27f-firebase-adminsdk-iuig1-8969152a6a.json"; + + try { + GoogleCredentials googleCredentials = GoogleCredentials + .fromStream(new ClassPathResource(firebaseConfigPath).getInputStream()) + .createScoped(List.of("")); + + googleCredentials.refreshIfExpired(); + return googleCredentials.getAccessToken().getTokenValue(); + } catch (IOException e) { + throw new RuntimeException(e); + } + } + + /** + * 메시지를 생성합니다. (Object -> String) + */ + private String makeMessage(Notification notification, String receiverToken) { + + ObjectMapper om = new ObjectMapper(); + try { + FcmMessageRes fcmMessageRes = FcmMessageRes.builder() + .message(FcmMessageRes.Message.builder() + .token(receiverToken) + .notification(notification) + .build()).validateOnly(false).build(); + return om.writeValueAsString(fcmMessageRes); + } catch (JsonProcessingException e) { + throw new RuntimeException(e); + } + } +} diff --git a/src/main/java/zip/ootd/ootdzip/notification/controller/NotificationController.java b/src/main/java/zip/ootd/ootdzip/notification/controller/NotificationController.java index bf6ba239..164d8a90 100644 --- a/src/main/java/zip/ootd/ootdzip/notification/controller/NotificationController.java +++ b/src/main/java/zip/ootd/ootdzip/notification/controller/NotificationController.java @@ -29,13 +29,6 @@ public class NotificationController { private final NotificationService notificationService; private final UserService userService; - @Hidden - @GetMapping(value = "/subscribe", produces = "text/event-stream") - public SseEmitter subscribe( - @RequestHeader(value = "Last-Event-ID", required = false, defaultValue = "") String lastEventId) { - return notificationService.subscribe(userService.getAuthenticatiedUser(), lastEventId); - } - @Operation(summary = "알람 조회", description = "사용자가 받은 알람을 조회합니다.") @GetMapping(value = "") public ApiResponse> getNotification( diff --git a/src/main/java/zip/ootd/ootdzip/notification/data/NotificationRes.java b/src/main/java/zip/ootd/ootdzip/notification/data/NotificationRes.java deleted file mode 100644 index 0412b062..00000000 --- a/src/main/java/zip/ootd/ootdzip/notification/data/NotificationRes.java +++ /dev/null @@ -1,25 +0,0 @@ -package zip.ootd.ootdzip.notification.data; - -import lombok.AllArgsConstructor; -import lombok.Builder; -import lombok.Data; - -@Builder -@AllArgsConstructor -@Data -public class NotificationRes { - - Long notificationId; - - String userProfileImage; - - String timeStamp; - - Boolean isRead; - - String content; - - String ootdImage; - - //TODO : 푸쉬 알림 필요시 해당 DTO 사용 -} diff --git a/src/main/java/zip/ootd/ootdzip/notification/repository/EmitterRepository.java b/src/main/java/zip/ootd/ootdzip/notification/repository/EmitterRepository.java deleted file mode 100644 index 74d78c47..00000000 --- a/src/main/java/zip/ootd/ootdzip/notification/repository/EmitterRepository.java +++ /dev/null @@ -1,59 +0,0 @@ -package zip.ootd.ootdzip.notification.repository; - -import java.util.Map; -import java.util.concurrent.ConcurrentHashMap; -import java.util.stream.Collectors; - -import org.springframework.stereotype.Repository; -import org.springframework.web.servlet.mvc.method.annotation.SseEmitter; - -@Repository -public class EmitterRepository { - private final Map emitters = new ConcurrentHashMap<>(); - private final Map eventCache = new ConcurrentHashMap<>(); - - public SseEmitter save(String emitterId, SseEmitter sseEmitter) { - emitters.put(emitterId, sseEmitter); - return sseEmitter; - } - - public void saveEventCache(String eventCacheId, Object event) { - eventCache.put(eventCacheId, event); - } - - public Map findAllEmitterStartWithByUserId(String userId) { - return emitters.entrySet().stream() - .filter(entry -> entry.getKey().startsWith(userId)) - .collect(Collectors.toMap(Map.Entry::getKey, Map.Entry::getValue)); - } - - public Map findAllEventCacheStartWithByUserId(String userId) { - return eventCache.entrySet().stream() - .filter(entry -> entry.getKey().startsWith(userId)) - .collect(Collectors.toMap(Map.Entry::getKey, Map.Entry::getValue)); - } - - public void deleteById(String id) { - emitters.remove(id); - } - - public void deleteAllEmitterStartWithId(String id) { - emitters.forEach( - (key, emitter) -> { - if (key.startsWith(id)) { - emitters.remove(key); - } - } - ); - } - - public void deleteAllEventCacheStartWithId(String id) { - eventCache.forEach( - (key, emitter) -> { - if (key.startsWith(id)) { - eventCache.remove(key); - } - } - ); - } -} diff --git a/src/main/java/zip/ootd/ootdzip/notification/service/NotificationService.java b/src/main/java/zip/ootd/ootdzip/notification/service/NotificationService.java index b851c9b3..14411209 100644 --- a/src/main/java/zip/ootd/ootdzip/notification/service/NotificationService.java +++ b/src/main/java/zip/ootd/ootdzip/notification/service/NotificationService.java @@ -1,8 +1,6 @@ package zip.ootd.ootdzip.notification.service; -import java.io.IOException; import java.util.List; -import java.util.Map; import java.util.Set; import java.util.stream.Collectors; @@ -10,15 +8,14 @@ import org.springframework.data.domain.Slice; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; -import org.springframework.web.servlet.mvc.method.annotation.SseEmitter; import lombok.RequiredArgsConstructor; import zip.ootd.ootdzip.common.response.CommonSliceResponse; +import zip.ootd.ootdzip.fcm.service.FcmService; import zip.ootd.ootdzip.notification.data.NotificationGetAllReq; import zip.ootd.ootdzip.notification.data.NotificationGetAllRes; import zip.ootd.ootdzip.notification.domain.Notification; import zip.ootd.ootdzip.notification.domain.NotificationType; -import zip.ootd.ootdzip.notification.repository.EmitterRepository; import zip.ootd.ootdzip.notification.repository.NotificationRepository; import zip.ootd.ootdzip.user.domain.User; import zip.ootd.ootdzip.user.service.UserService; @@ -28,57 +25,11 @@ @Transactional @RequiredArgsConstructor public class NotificationService { - private static final Long DEFAULT_TIMEOUT = 60L * 1000 * 60; // 1시간 - private final EmitterRepository emitterRepository; private final NotificationRepository notificationRepository; private final UserService userService; private final UserBlockRepository userBlockRepository; - - public SseEmitter subscribe(User loginUser, String lastEventId) { - Long userId = loginUser.getId(); - String emitterId = makeEmitterIdByUserId(userId); - SseEmitter emitter = emitterRepository.save(emitterId, new SseEmitter(DEFAULT_TIMEOUT)); - - emitter.onCompletion(() -> emitterRepository.deleteById(emitterId)); - emitter.onTimeout(() -> emitterRepository.deleteById(emitterId)); - - // 503 에러를 방지하기 위한 더미 이벤트 전송 - // SSE 연결후 데이터를 하나도 안보내면 503 응답이 발생함 - String eventId = makeEmitterIdByUserId(userId); - sendNotification(emitter, eventId, emitterId, "EventStream Created. [UserId =" + userId + "]"); - - // 클라이언트가 미수신한 Event 목록이 존재할 경우 전송하여 Event 유실을 예방 - if (!lastEventId.isEmpty()) { - sendLostData(lastEventId, userId, emitterId, emitter); - } - - return emitter; - } - - private String makeEmitterIdByUserId(Long userId) { - return userId + "_" + System.currentTimeMillis(); - } - - private void sendNotification(SseEmitter emitter, String eventId, String emitterId, Object data) { - try { - emitter.send(SseEmitter.event() - .id(eventId) - .name("sse") - .data(data) - ); - } catch (IOException exception) { - emitterRepository.deleteById(emitterId); - throw new RuntimeException("알람을 위한 클라이언트와 연결중에 오류가 발생했습니다."); - } - } - - private void sendLostData(String lastEventId, Long userId, String emitterId, SseEmitter emitter) { - Map eventCaches = emitterRepository.findAllEventCacheStartWithByUserId(String.valueOf(userId)); - eventCaches.entrySet().stream() - .filter(entry -> lastEventId.compareTo(entry.getKey()) < 0) - .forEach(entry -> sendNotification(emitter, entry.getKey(), emitterId, entry.getValue())); - } + private final FcmService fcmService; public Notification saveNotification(User receiver, User sender, @@ -87,7 +38,7 @@ public Notification saveNotification(User receiver, String imageUrl, String goUrl) { - return notificationRepository.save(Notification.builder() + Notification notification = notificationRepository.save(Notification.builder() .receiver(receiver) .sender(sender) .notificationType(notificationType) @@ -95,25 +46,11 @@ public Notification saveNotification(User receiver, .imageUrl(imageUrl) .goUrl(goUrl) .build()); - } - //TODO: 푸쉬 알림 필요시 스펙에 맞게 개발, - // 알람종류별로 보낼지말지 필터링하는 것도 필요 - // 해당 기능 사용시 AOP 에서 중복 알람저장안되도록 saveNotification 은 지워주기 - public void send(User receiver, User sender, - NotificationType notificationType, String content, String imageUrl, String goUrl) { - - Notification notification = saveNotification(receiver, sender, notificationType, content, imageUrl, goUrl); - - Long userId = receiver.getId(); - String eventId = userId + "_" + System.currentTimeMillis(); - Map emitters = emitterRepository.findAllEmitterStartWithByUserId(String.valueOf(userId)); - emitters.forEach( - (key, emitter) -> { - emitterRepository.saveEventCache(key, notification); - sendNotification(emitter, eventId, key, new Notification()); - } - ); + // 푸쉬 알람 보내기 + fcmService.sendMessage(notification); + + return notification; } public CommonSliceResponse getNotifications(User loginUesr, NotificationGetAllReq request) { diff --git a/src/main/java/zip/ootd/ootdzip/ootd/service/OotdService.java b/src/main/java/zip/ootd/ootdzip/ootd/service/OotdService.java index 116a102d..b5ed08e2 100644 --- a/src/main/java/zip/ootd/ootdzip/ootd/service/OotdService.java +++ b/src/main/java/zip/ootd/ootdzip/ootd/service/OotdService.java @@ -28,6 +28,7 @@ import zip.ootd.ootdzip.common.exception.code.ErrorCode; import zip.ootd.ootdzip.common.response.CommonPageResponse; import zip.ootd.ootdzip.common.response.CommonSliceResponse; +import zip.ootd.ootdzip.fcm.service.FcmService; import zip.ootd.ootdzip.images.domain.Images; import zip.ootd.ootdzip.images.service.ImagesService; import zip.ootd.ootdzip.lock.annotation.RLockCustom; @@ -265,6 +266,7 @@ private void notifyOotdLike(User receiver, User sender, String imageUrl, Long id return; } + // 앱 내 알람 eventPublisher.publishEvent(NotificationEvent.builder() .receiver(receiver) .sender(sender) diff --git a/src/main/java/zip/ootd/ootdzip/user/domain/User.java b/src/main/java/zip/ootd/ootdzip/user/domain/User.java index ad989299..71cfefb1 100644 --- a/src/main/java/zip/ootd/ootdzip/user/domain/User.java +++ b/src/main/java/zip/ootd/ootdzip/user/domain/User.java @@ -27,6 +27,7 @@ import zip.ootd.ootdzip.common.entity.BaseEntity; import zip.ootd.ootdzip.common.exception.CustomException; import zip.ootd.ootdzip.common.exception.code.ErrorCode; +import zip.ootd.ootdzip.fcm.domain.FcmInfo; import zip.ootd.ootdzip.images.domain.Images; import zip.ootd.ootdzip.ootd.domain.Ootd; import zip.ootd.ootdzip.user.data.UserRole; @@ -87,6 +88,9 @@ public class User extends BaseEntity { @OneToMany(mappedBy = "user", cascade = CascadeType.ALL) private List userStyles; + @OneToMany(mappedBy = "user", cascade = CascadeType.ALL) + private List fcmInfos; + public static User getDefault() { return User.builder() .name(null) diff --git a/src/main/java/zip/ootd/ootdzip/user/repository/UserRepository.java b/src/main/java/zip/ootd/ootdzip/user/repository/UserRepository.java index 681ef138..3557f1dd 100644 --- a/src/main/java/zip/ootd/ootdzip/user/repository/UserRepository.java +++ b/src/main/java/zip/ootd/ootdzip/user/repository/UserRepository.java @@ -4,9 +4,16 @@ import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; + +import org.springframework.data.repository.query.Param; + import zip.ootd.ootdzip.user.domain.User; public interface UserRepository extends JpaRepository, UserRepositoryCustom { Optional findByName(String name); + + @Query("SELECT u FROM User u JOIN FETCH u.fcmInfos WHERE u = :user") + Optional findWithFcmInfosByUser(@Param("user") User user); } From 56913512835a26333f650cdd599fbdee2ebb9ba0 Mon Sep 17 00:00:00 2001 From: hoon Date: Wed, 27 Nov 2024 16:48:13 +0900 Subject: [PATCH 2/8] =?UTF-8?q?checkstyle=20:=20=EB=B6=88=ED=95=84?= =?UTF-8?q?=EC=9A=94=ED=95=9C=20=EC=9E=84=ED=8F=AC=ED=8A=B8=20=EC=A0=9C?= =?UTF-8?q?=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../zip/ootd/ootdzip/comment/service/CommentService.java | 1 - .../zip/ootd/ootdzip/fcm/repository/FcmRepository.java | 7 ++----- .../notification/controller/NotificationController.java | 3 --- .../java/zip/ootd/ootdzip/ootd/service/OotdService.java | 1 - 4 files changed, 2 insertions(+), 10 deletions(-) diff --git a/src/main/java/zip/ootd/ootdzip/comment/service/CommentService.java b/src/main/java/zip/ootd/ootdzip/comment/service/CommentService.java index 018bb923..76f72ec9 100644 --- a/src/main/java/zip/ootd/ootdzip/comment/service/CommentService.java +++ b/src/main/java/zip/ootd/ootdzip/comment/service/CommentService.java @@ -19,7 +19,6 @@ import zip.ootd.ootdzip.common.exception.CustomException; import zip.ootd.ootdzip.common.exception.code.ErrorCode; import zip.ootd.ootdzip.common.response.CommonSliceResponse; -import zip.ootd.ootdzip.fcm.service.FcmService; import zip.ootd.ootdzip.notification.domain.NotificationType; import zip.ootd.ootdzip.notification.event.NotificationEvent; import zip.ootd.ootdzip.ootd.domain.Ootd; diff --git a/src/main/java/zip/ootd/ootdzip/fcm/repository/FcmRepository.java b/src/main/java/zip/ootd/ootdzip/fcm/repository/FcmRepository.java index ed8d16b6..f829023e 100644 --- a/src/main/java/zip/ootd/ootdzip/fcm/repository/FcmRepository.java +++ b/src/main/java/zip/ootd/ootdzip/fcm/repository/FcmRepository.java @@ -1,17 +1,14 @@ package zip.ootd.ootdzip.fcm.repository; +import java.util.Optional; + import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.stereotype.Repository; import zip.ootd.ootdzip.fcm.domain.FcmInfo; -import zip.ootd.ootdzip.user.domain.User; - -import java.util.Optional; @Repository public interface FcmRepository extends JpaRepository { - boolean existsByFcmToken(String fcmToken); - Optional findByFcmToken(String fcmToken); } diff --git a/src/main/java/zip/ootd/ootdzip/notification/controller/NotificationController.java b/src/main/java/zip/ootd/ootdzip/notification/controller/NotificationController.java index 164d8a90..5eabcab2 100644 --- a/src/main/java/zip/ootd/ootdzip/notification/controller/NotificationController.java +++ b/src/main/java/zip/ootd/ootdzip/notification/controller/NotificationController.java @@ -3,12 +3,9 @@ import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PatchMapping; import org.springframework.web.bind.annotation.PathVariable; -import org.springframework.web.bind.annotation.RequestHeader; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; -import org.springframework.web.servlet.mvc.method.annotation.SseEmitter; -import io.swagger.v3.oas.annotations.Hidden; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.validation.Valid; diff --git a/src/main/java/zip/ootd/ootdzip/ootd/service/OotdService.java b/src/main/java/zip/ootd/ootdzip/ootd/service/OotdService.java index b5ed08e2..e0886239 100644 --- a/src/main/java/zip/ootd/ootdzip/ootd/service/OotdService.java +++ b/src/main/java/zip/ootd/ootdzip/ootd/service/OotdService.java @@ -28,7 +28,6 @@ import zip.ootd.ootdzip.common.exception.code.ErrorCode; import zip.ootd.ootdzip.common.response.CommonPageResponse; import zip.ootd.ootdzip.common.response.CommonSliceResponse; -import zip.ootd.ootdzip.fcm.service.FcmService; import zip.ootd.ootdzip.images.domain.Images; import zip.ootd.ootdzip.images.service.ImagesService; import zip.ootd.ootdzip.lock.annotation.RLockCustom; From 2605119127119787c65eee2d61789bc793f82854 Mon Sep 17 00:00:00 2001 From: hoon Date: Wed, 27 Nov 2024 16:53:37 +0900 Subject: [PATCH 3/8] merge : merge fcm-push-alaram(#240) --- .github/workflows/cicd.yml | 5 + .github/workflows/dev_cicd.yml | 5 + .gitignore | 1 + build.gradle | 13 ++ .../comment/service/CommentService.java | 2 + .../ootdzip/fcm/controller/FcmController.java | 51 ++++++ .../ootd/ootdzip/fcm/data/FcmMessageRes.java | 21 +++ .../zip/ootd/ootdzip/fcm/data/FcmPostReq.java | 11 ++ .../zip/ootd/ootdzip/fcm/domain/FcmInfo.java | 92 ++++++++++ .../fcm/domain/FcmNotificationType.java | 33 ++++ .../ootdzip/fcm/repository/FcmRepository.java | 17 ++ .../ootd/ootdzip/fcm/service/FcmService.java | 169 ++++++++++++++++++ .../controller/NotificationController.java | 7 - .../notification/data/NotificationRes.java | 25 --- .../repository/EmitterRepository.java | 59 ------ .../service/NotificationService.java | 77 +------- .../ootdzip/ootd/service/OotdService.java | 2 + .../zip/ootd/ootdzip/user/domain/User.java | 4 + .../user/repository/UserRepository.java | 7 + 19 files changed, 440 insertions(+), 161 deletions(-) create mode 100644 src/main/java/zip/ootd/ootdzip/fcm/controller/FcmController.java create mode 100644 src/main/java/zip/ootd/ootdzip/fcm/data/FcmMessageRes.java create mode 100644 src/main/java/zip/ootd/ootdzip/fcm/data/FcmPostReq.java create mode 100644 src/main/java/zip/ootd/ootdzip/fcm/domain/FcmInfo.java create mode 100644 src/main/java/zip/ootd/ootdzip/fcm/domain/FcmNotificationType.java create mode 100644 src/main/java/zip/ootd/ootdzip/fcm/repository/FcmRepository.java create mode 100644 src/main/java/zip/ootd/ootdzip/fcm/service/FcmService.java delete mode 100644 src/main/java/zip/ootd/ootdzip/notification/data/NotificationRes.java delete mode 100644 src/main/java/zip/ootd/ootdzip/notification/repository/EmitterRepository.java diff --git a/.github/workflows/cicd.yml b/.github/workflows/cicd.yml index 49eda1ae..e1f504b1 100644 --- a/.github/workflows/cicd.yml +++ b/.github/workflows/cicd.yml @@ -14,6 +14,9 @@ env: OCCUPY_SECRET_DIR: src/main/resources OCCUPY_SECRET_TEST_DIR: src/test/resources OCCUPY_SECRET_DIR_FILE_NAME: application-secret.yml + FCM_DIR: src/main/resources/firebase + FCM_FILE_NAME: ootdzip-cf27f-firebase-adminsdk-iuig1-8969152a6a.json + FCM_KEY: ${{ secrets.FCM_KEY }} jobs: build-with-gradle: @@ -30,6 +33,8 @@ jobs: distribution: 'corretto' - name: Secret 파일 복사 run: echo $OCCUPY_SECRET | base64 --decode > $OCCUPY_SECRET_DIR/$OCCUPY_SECRET_DIR_FILE_NAME && echo $OCCUPY_SECRET | base64 --decode > $OCCUPY_SECRET_TEST_DIR/$OCCUPY_SECRET_DIR_FILE_NAME + - name: FCM 키 파일 복사 + run: echo $OCCUPY_SECRET | base64 --decode > FCM_DIR/FCM_FILE_NAME && echo FCM_KEY - name: gradlew에 실행 권한 부여 run: chmod +x ./gradlew - name: 프로젝트 빌드 diff --git a/.github/workflows/dev_cicd.yml b/.github/workflows/dev_cicd.yml index 642499b7..38db3480 100644 --- a/.github/workflows/dev_cicd.yml +++ b/.github/workflows/dev_cicd.yml @@ -14,6 +14,9 @@ env: OCCUPY_SECRET_DIR: src/main/resources OCCUPY_SECRET_TEST_DIR: src/test/resources OCCUPY_SECRET_DIR_FILE_NAME: application-secret.yml + FCM_DIR: src/main/resources/firebase + FCM_FILE_NAME: ootdzip-cf27f-firebase-adminsdk-iuig1-8969152a6a.json + FCM_KEY: ${{ secrets.FCM_KEY }} jobs: build-with-gradle: @@ -30,6 +33,8 @@ jobs: distribution: 'corretto' - name: Secret 파일 복사 run: echo $OCCUPY_SECRET | base64 --decode > $OCCUPY_SECRET_DIR/$OCCUPY_SECRET_DIR_FILE_NAME && echo $OCCUPY_SECRET | base64 --decode > $OCCUPY_SECRET_TEST_DIR/$OCCUPY_SECRET_DIR_FILE_NAME + - name: FCM 키 파일 복사 + run: echo $OCCUPY_SECRET | base64 --decode > FCM_DIR/FCM_FILE_NAME && echo FCM_KEY - name: gradlew에 실행 권한 부여 run: chmod +x ./gradlew - name: 프로젝트 빌드 diff --git a/.gitignore b/.gitignore index d99d68f7..6ce684ef 100644 --- a/.gitignore +++ b/.gitignore @@ -39,6 +39,7 @@ out/ ### key file ### **/application-secret.yaml **/resources/**/*.p8 +**/resources/firebase/ ### Querydsl ### /src/main/generated/ diff --git a/build.gradle b/build.gradle index cb37dbfe..e619294d 100644 --- a/build.gradle +++ b/build.gradle @@ -30,20 +30,25 @@ dependencies { implementation 'org.springframework.boot:spring-boot-starter-web' implementation 'org.springframework.boot:spring-boot-starter-data-redis' implementation 'org.springframework.boot:spring-boot-starter-validation' + // Swagger implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.0.2' // Lombok compileOnly 'org.projectlombok:lombok' annotationProcessor 'org.projectlombok:lombok' + // Jackson Java 8 Date/Time 지원 implementation 'com.fasterxml.jackson.datatype:jackson-datatype-jsr310' + // JWT implementation 'io.jsonwebtoken:jjwt-api:0.12.5' runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.12.5' runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.12.5' + // 이미지 리사이즈 implementation 'net.coobird:thumbnailator:0.4.20' + // 파일 업로드 implementation 'org.springframework.cloud:spring-cloud-starter-aws:2.2.6.RELEASE' implementation 'commons-io:commons-io:2.6' @@ -54,12 +59,20 @@ dependencies { annotationProcessor "com.querydsl:querydsl-apt:${dependencyManagement.importedProperties['querydsl.version']}:jakarta" annotationProcessor "jakarta.annotation:jakarta.annotation-api" annotationProcessor "jakarta.persistence:jakarta.persistence-api" + // MariaDB 연결 runtimeOnly 'org.mariadb.jdbc:mariadb-java-client' + // Redis 연결: Redisson implementation 'org.redisson:redisson-spring-boot-starter:3.31.0' + // firebase for 푸쉬알람 + implementation 'com.google.firebase:firebase-admin:9.2.0' + + // Test + + // test testImplementation 'org.springframework.boot:spring-boot-starter-test' testImplementation 'org.springframework.security:spring-security-test' testImplementation 'org.mockito:mockito-core:3.11.2' diff --git a/src/main/java/zip/ootd/ootdzip/comment/service/CommentService.java b/src/main/java/zip/ootd/ootdzip/comment/service/CommentService.java index c0eadb05..018bb923 100644 --- a/src/main/java/zip/ootd/ootdzip/comment/service/CommentService.java +++ b/src/main/java/zip/ootd/ootdzip/comment/service/CommentService.java @@ -19,6 +19,7 @@ import zip.ootd.ootdzip.common.exception.CustomException; import zip.ootd.ootdzip.common.exception.code.ErrorCode; import zip.ootd.ootdzip.common.response.CommonSliceResponse; +import zip.ootd.ootdzip.fcm.service.FcmService; import zip.ootd.ootdzip.notification.domain.NotificationType; import zip.ootd.ootdzip.notification.event.NotificationEvent; import zip.ootd.ootdzip.ootd.domain.Ootd; @@ -99,6 +100,7 @@ private void notifyOotdComment(User receiver, User sender, String content, Strin return; } + // 앱 내 알람 eventPublisher.publishEvent(NotificationEvent.builder() .receiver(receiver) .sender(sender) diff --git a/src/main/java/zip/ootd/ootdzip/fcm/controller/FcmController.java b/src/main/java/zip/ootd/ootdzip/fcm/controller/FcmController.java new file mode 100644 index 00000000..c5583f07 --- /dev/null +++ b/src/main/java/zip/ootd/ootdzip/fcm/controller/FcmController.java @@ -0,0 +1,51 @@ +package zip.ootd.ootdzip.fcm.controller; + +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; + +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import zip.ootd.ootdzip.common.response.ApiResponse; +import zip.ootd.ootdzip.fcm.data.FcmPostReq; +import zip.ootd.ootdzip.fcm.service.FcmService; +import zip.ootd.ootdzip.user.service.UserService; + +@RestController +@RequiredArgsConstructor +@Tag(name = "FCM 컨트롤러", description = "푸쉬 알람 설정할 때 사용 합니다.") +@RequestMapping("/api/v1/fcm") +public class FcmController { + + private final FcmService fcmService; + + private final UserService userService; + + /** + * 푸쉬 알람을 허용한 유저로부터 + * FCM 토큰값을 얻어와서 DB 에 저장해둡니다. + * 해당 토큰값은 디바이스 고유값으로 해당 값으로 FCM 이 디바이스에게 푸쉬알람을 보낼 수 있습니다. + * 유저가 앱을실행하고 로그인할 때마다 프론트는 해당 API 를통해 토큰값을 서버로 보냅니다. + */ + @PostMapping("") + public ApiResponse onFcmToken(@RequestBody @Valid FcmPostReq fcmPostReq) { + + fcmService.onFcmToken(fcmPostReq, userService.getAuthenticatiedUser()); + + return new ApiResponse<>(true); + } + + /** + * 사용자 토큰 상태를 off 하여 해당 기기는 알림을 받을 수 없습니다. + */ + @DeleteMapping("") + public ApiResponse offFcmToken(@RequestBody @Valid FcmPostReq fcmPostReq) { + + fcmService.offFcmToken(fcmPostReq); + + return new ApiResponse<>(true); + } +} diff --git a/src/main/java/zip/ootd/ootdzip/fcm/data/FcmMessageRes.java b/src/main/java/zip/ootd/ootdzip/fcm/data/FcmMessageRes.java new file mode 100644 index 00000000..310004f9 --- /dev/null +++ b/src/main/java/zip/ootd/ootdzip/fcm/data/FcmMessageRes.java @@ -0,0 +1,21 @@ +package zip.ootd.ootdzip.fcm.data; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import zip.ootd.ootdzip.notification.domain.Notification; + +@Getter +@Builder +public class FcmMessageRes { + private boolean validateOnly; + private FcmMessageRes.Message message; + + @Builder + @AllArgsConstructor + @Getter + public static class Message { + private Notification notification; + private String token; + } +} diff --git a/src/main/java/zip/ootd/ootdzip/fcm/data/FcmPostReq.java b/src/main/java/zip/ootd/ootdzip/fcm/data/FcmPostReq.java new file mode 100644 index 00000000..c842dc5c --- /dev/null +++ b/src/main/java/zip/ootd/ootdzip/fcm/data/FcmPostReq.java @@ -0,0 +1,11 @@ +package zip.ootd.ootdzip.fcm.data; + +import jakarta.validation.constraints.NotNull; +import lombok.Data; + +@Data +public class FcmPostReq { + + @NotNull(message = "토큰값은 필수입니다.") + private String fcmToken; +} diff --git a/src/main/java/zip/ootd/ootdzip/fcm/domain/FcmInfo.java b/src/main/java/zip/ootd/ootdzip/fcm/domain/FcmInfo.java new file mode 100644 index 00000000..22994d5c --- /dev/null +++ b/src/main/java/zip/ootd/ootdzip/fcm/domain/FcmInfo.java @@ -0,0 +1,92 @@ +package zip.ootd.ootdzip.fcm.domain; + +import java.util.ArrayList; +import java.util.List; +import java.util.stream.Stream; + +import jakarta.persistence.CascadeType; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.OneToMany; +import jakarta.persistence.Table; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import zip.ootd.ootdzip.common.entity.BaseEntity; +import zip.ootd.ootdzip.notification.domain.Notification; +import zip.ootd.ootdzip.notification.domain.NotificationType; +import zip.ootd.ootdzip.user.domain.User; + +@Entity +@Table(name = "fcm_infos") +@Getter +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class FcmInfo extends BaseEntity { + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "user_id", nullable = false) + private User user; + + @Builder.Default + @Column(nullable = false) + private Boolean isPermission = true; + + @Builder.Default + @Column(nullable = false) + private Boolean isLogin = true; + + @Column(nullable = false) + private String fcmToken; + + @Builder.Default + @OneToMany(mappedBy = "fcmInfo", cascade = CascadeType.ALL, orphanRemoval = true) + List fcmNotificationTypes = new ArrayList<>(); + + // fcm 기본생성자 + // 모든 알람에 대해 허용으로 기본으로 생성합니다. + public static FcmInfo createDefaultFcmInfo(User user, String fcmToken) { + + List fcmDefaultNotificationTypes = Stream.of(NotificationType.values()) + .map(notificationType -> FcmNotificationType.builder() + .notificationType(notificationType).build()).toList(); + + FcmInfo fcmInfo = FcmInfo.builder() + .user(user) + .fcmToken(fcmToken) + .build(); + + fcmInfo.addFcmNotificationTypes(fcmDefaultNotificationTypes); + return fcmInfo; + } + + // 해당 기기 사용자가 로그인을 했을 경우 + public void login() { + this.isLogin = true; + } + + // 해당 기기 사용자가 로그아웃을 했을 경우 + public void logout() { + this.isLogin = false; + } + + public boolean isExistAllowNotificationType(Notification notification) { + return fcmNotificationTypes.stream() + .anyMatch(fnt -> fnt.getNotificationType() == notification.getNotificationType() && fnt.getIsAllow()); + } + + // == 연관관계 메서드 == // + public void addFcmNotificationType(FcmNotificationType fcmNotificationType) { + fcmNotificationTypes.add(fcmNotificationType); + fcmNotificationType.setFcmInfo(this); + } + + public void addFcmNotificationTypes(List fcmNotificationTypes) { + fcmNotificationTypes.forEach(this::addFcmNotificationType); + } +} diff --git a/src/main/java/zip/ootd/ootdzip/fcm/domain/FcmNotificationType.java b/src/main/java/zip/ootd/ootdzip/fcm/domain/FcmNotificationType.java new file mode 100644 index 00000000..5264f04d --- /dev/null +++ b/src/main/java/zip/ootd/ootdzip/fcm/domain/FcmNotificationType.java @@ -0,0 +1,33 @@ +package zip.ootd.ootdzip.fcm.domain; + +import jakarta.persistence.Entity; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; +import zip.ootd.ootdzip.common.entity.BaseEntity; +import zip.ootd.ootdzip.notification.domain.NotificationType; + +@Entity +@Table(name = "fcm_notification_types") +@Getter +@Setter +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class FcmNotificationType extends BaseEntity { + + @ManyToOne + @JoinColumn(name = "fcm_info_id", nullable = false) + private FcmInfo fcmInfo; + + private NotificationType notificationType; + + @Builder.Default + private Boolean isAllow = true; + +} diff --git a/src/main/java/zip/ootd/ootdzip/fcm/repository/FcmRepository.java b/src/main/java/zip/ootd/ootdzip/fcm/repository/FcmRepository.java new file mode 100644 index 00000000..ed8d16b6 --- /dev/null +++ b/src/main/java/zip/ootd/ootdzip/fcm/repository/FcmRepository.java @@ -0,0 +1,17 @@ +package zip.ootd.ootdzip.fcm.repository; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import zip.ootd.ootdzip.fcm.domain.FcmInfo; +import zip.ootd.ootdzip.user.domain.User; + +import java.util.Optional; + +@Repository +public interface FcmRepository extends JpaRepository { + + boolean existsByFcmToken(String fcmToken); + + Optional findByFcmToken(String fcmToken); +} diff --git a/src/main/java/zip/ootd/ootdzip/fcm/service/FcmService.java b/src/main/java/zip/ootd/ootdzip/fcm/service/FcmService.java new file mode 100644 index 00000000..c967d070 --- /dev/null +++ b/src/main/java/zip/ootd/ootdzip/fcm/service/FcmService.java @@ -0,0 +1,169 @@ +package zip.ootd.ootdzip.fcm.service; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.util.List; +import java.util.Optional; +import java.util.stream.Collectors; + +import org.springframework.core.io.ClassPathResource; +import org.springframework.http.HttpEntity; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpMethod; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.http.converter.StringHttpMessageConverter; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.client.RestTemplate; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.google.auth.oauth2.GoogleCredentials; + +import lombok.RequiredArgsConstructor; +import zip.ootd.ootdzip.fcm.data.FcmMessageRes; +import zip.ootd.ootdzip.fcm.data.FcmPostReq; +import zip.ootd.ootdzip.fcm.domain.FcmInfo; +import zip.ootd.ootdzip.fcm.repository.FcmRepository; +import zip.ootd.ootdzip.notification.domain.Notification; +import zip.ootd.ootdzip.user.domain.User; +import zip.ootd.ootdzip.user.repository.UserRepository; + +@Service +@Transactional +@RequiredArgsConstructor +public class FcmService { + + private final FcmRepository fcmRepository; + private final UserRepository userRepository; + + /** + * 사용자가 토큰이없다면 최초생성이므로 + * 모든 알람을 on 으로 하여 생성합니다. + * + * 토큰이 이미 존재한다면 + * 사용자 토큰을 로그인상태 설정하여 알람을 받을 수 있도록 합니다. + */ + @Transactional + public void onFcmToken(FcmPostReq request, User loginUser) { + + Optional fcmInfo = fcmRepository.findByFcmToken(request.getFcmToken()); + + // fcm 토큰이 없다면 + // 최초로 생성하고, 모든 알람허용으로 기본 생성 + if (fcmInfo.isEmpty()) { + FcmInfo createdFcmInfo = FcmInfo.createDefaultFcmInfo(loginUser, request.getFcmToken()); + fcmRepository.save(createdFcmInfo); + } + + // fcm 토큰이 있다면 + // 해당 토큰 기기 사용자를 로그인상태로 변경 + fcmInfo.ifPresent(FcmInfo::login); + } + + @Transactional + public void offFcmToken(FcmPostReq request) { + + Optional fcmInfo = fcmRepository.findByFcmToken(request.getFcmToken()); + + // fcm 토큰이 있다면 + // 해당 토큰 기기 사용자를 로그아웃 상태로 변경 + fcmInfo.ifPresent(FcmInfo::logout); + } + + /** + * 푸시 메시지 처리를 수행하는 비즈니스 로직 + */ + public boolean sendMessage(Notification notification) { + + List receiverTokens = getReceiverTokens(notification); + + // 토큰이 없다면 없는 유저거나 알람을 받지 않는 유저 + if (receiverTokens == null || receiverTokens.isEmpty()) { + return false; + } + + List> responses = receiverTokens.stream() + .map(token -> requestFCM(makeMessage(notification, token))).toList(); + + return true; + } + + public ResponseEntity requestFCM(String message) { + + RestTemplate restTemplate = new RestTemplate(); + + /** + * 추가된 사항 : RestTemplate 이용중 클라이언트의 한글 깨짐 증상에 대한 수정 + * @refernece : https://stackoverflow.com/questions/29392422/how-can-i-tell-resttemplate-to-post-with-utf-8-encoding + */ + restTemplate.getMessageConverters() + .add(0, new StringHttpMessageConverter(StandardCharsets.UTF_8)); + + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_JSON); + headers.set("Authorization", "Bearer " + getAccessToken()); + + HttpEntity entity = new HttpEntity<>(message, headers); + + String API_URL = ""; + ResponseEntity response = restTemplate.exchange(API_URL, HttpMethod.POST, entity, String.class); + + return response; + } + + /** + * 사용자가 허락한 푸쉬알람일시 해당하는 기기 토큰값 반환 + * 동일한 사용자여도 알람을 허용한 기기가 여러개 일 수 있음으로 리스트로 반환합니다. + */ + @Transactional + public List getReceiverTokens(Notification notification) { + Optional receiver = userRepository.findWithFcmInfosByUser(notification.getReceiver()); + // 알람을 수신할 유저가 존재하고 + // 해당 유저가 로그인상태고 + // 해당 유저가 알람 권한을 허용해놨을 경우 + return receiver.map(user -> user.getFcmInfos().stream() + .filter(FcmInfo::getIsLogin) + .filter(FcmInfo::getIsPermission) + .filter(fcmInfo -> fcmInfo.isExistAllowNotificationType(notification)) + .map(FcmInfo::getFcmToken) + .collect(Collectors.toList())).orElse(null); + } + + /** + * Firebase Admin SDK의 비공개 키를 참조하여 Bearer 토큰을 발급 받습니다. + */ + private String getAccessToken() { + String firebaseConfigPath = "firebase/ootdzip-cf27f-firebase-adminsdk-iuig1-8969152a6a.json"; + + try { + GoogleCredentials googleCredentials = GoogleCredentials + .fromStream(new ClassPathResource(firebaseConfigPath).getInputStream()) + .createScoped(List.of("")); + + googleCredentials.refreshIfExpired(); + return googleCredentials.getAccessToken().getTokenValue(); + } catch (IOException e) { + throw new RuntimeException(e); + } + } + + /** + * 메시지를 생성합니다. (Object -> String) + */ + private String makeMessage(Notification notification, String receiverToken) { + + ObjectMapper om = new ObjectMapper(); + try { + FcmMessageRes fcmMessageRes = FcmMessageRes.builder() + .message(FcmMessageRes.Message.builder() + .token(receiverToken) + .notification(notification) + .build()).validateOnly(false).build(); + return om.writeValueAsString(fcmMessageRes); + } catch (JsonProcessingException e) { + throw new RuntimeException(e); + } + } +} diff --git a/src/main/java/zip/ootd/ootdzip/notification/controller/NotificationController.java b/src/main/java/zip/ootd/ootdzip/notification/controller/NotificationController.java index bf6ba239..164d8a90 100644 --- a/src/main/java/zip/ootd/ootdzip/notification/controller/NotificationController.java +++ b/src/main/java/zip/ootd/ootdzip/notification/controller/NotificationController.java @@ -29,13 +29,6 @@ public class NotificationController { private final NotificationService notificationService; private final UserService userService; - @Hidden - @GetMapping(value = "/subscribe", produces = "text/event-stream") - public SseEmitter subscribe( - @RequestHeader(value = "Last-Event-ID", required = false, defaultValue = "") String lastEventId) { - return notificationService.subscribe(userService.getAuthenticatiedUser(), lastEventId); - } - @Operation(summary = "알람 조회", description = "사용자가 받은 알람을 조회합니다.") @GetMapping(value = "") public ApiResponse> getNotification( diff --git a/src/main/java/zip/ootd/ootdzip/notification/data/NotificationRes.java b/src/main/java/zip/ootd/ootdzip/notification/data/NotificationRes.java deleted file mode 100644 index 0412b062..00000000 --- a/src/main/java/zip/ootd/ootdzip/notification/data/NotificationRes.java +++ /dev/null @@ -1,25 +0,0 @@ -package zip.ootd.ootdzip.notification.data; - -import lombok.AllArgsConstructor; -import lombok.Builder; -import lombok.Data; - -@Builder -@AllArgsConstructor -@Data -public class NotificationRes { - - Long notificationId; - - String userProfileImage; - - String timeStamp; - - Boolean isRead; - - String content; - - String ootdImage; - - //TODO : 푸쉬 알림 필요시 해당 DTO 사용 -} diff --git a/src/main/java/zip/ootd/ootdzip/notification/repository/EmitterRepository.java b/src/main/java/zip/ootd/ootdzip/notification/repository/EmitterRepository.java deleted file mode 100644 index 74d78c47..00000000 --- a/src/main/java/zip/ootd/ootdzip/notification/repository/EmitterRepository.java +++ /dev/null @@ -1,59 +0,0 @@ -package zip.ootd.ootdzip.notification.repository; - -import java.util.Map; -import java.util.concurrent.ConcurrentHashMap; -import java.util.stream.Collectors; - -import org.springframework.stereotype.Repository; -import org.springframework.web.servlet.mvc.method.annotation.SseEmitter; - -@Repository -public class EmitterRepository { - private final Map emitters = new ConcurrentHashMap<>(); - private final Map eventCache = new ConcurrentHashMap<>(); - - public SseEmitter save(String emitterId, SseEmitter sseEmitter) { - emitters.put(emitterId, sseEmitter); - return sseEmitter; - } - - public void saveEventCache(String eventCacheId, Object event) { - eventCache.put(eventCacheId, event); - } - - public Map findAllEmitterStartWithByUserId(String userId) { - return emitters.entrySet().stream() - .filter(entry -> entry.getKey().startsWith(userId)) - .collect(Collectors.toMap(Map.Entry::getKey, Map.Entry::getValue)); - } - - public Map findAllEventCacheStartWithByUserId(String userId) { - return eventCache.entrySet().stream() - .filter(entry -> entry.getKey().startsWith(userId)) - .collect(Collectors.toMap(Map.Entry::getKey, Map.Entry::getValue)); - } - - public void deleteById(String id) { - emitters.remove(id); - } - - public void deleteAllEmitterStartWithId(String id) { - emitters.forEach( - (key, emitter) -> { - if (key.startsWith(id)) { - emitters.remove(key); - } - } - ); - } - - public void deleteAllEventCacheStartWithId(String id) { - eventCache.forEach( - (key, emitter) -> { - if (key.startsWith(id)) { - eventCache.remove(key); - } - } - ); - } -} diff --git a/src/main/java/zip/ootd/ootdzip/notification/service/NotificationService.java b/src/main/java/zip/ootd/ootdzip/notification/service/NotificationService.java index b851c9b3..14411209 100644 --- a/src/main/java/zip/ootd/ootdzip/notification/service/NotificationService.java +++ b/src/main/java/zip/ootd/ootdzip/notification/service/NotificationService.java @@ -1,8 +1,6 @@ package zip.ootd.ootdzip.notification.service; -import java.io.IOException; import java.util.List; -import java.util.Map; import java.util.Set; import java.util.stream.Collectors; @@ -10,15 +8,14 @@ import org.springframework.data.domain.Slice; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; -import org.springframework.web.servlet.mvc.method.annotation.SseEmitter; import lombok.RequiredArgsConstructor; import zip.ootd.ootdzip.common.response.CommonSliceResponse; +import zip.ootd.ootdzip.fcm.service.FcmService; import zip.ootd.ootdzip.notification.data.NotificationGetAllReq; import zip.ootd.ootdzip.notification.data.NotificationGetAllRes; import zip.ootd.ootdzip.notification.domain.Notification; import zip.ootd.ootdzip.notification.domain.NotificationType; -import zip.ootd.ootdzip.notification.repository.EmitterRepository; import zip.ootd.ootdzip.notification.repository.NotificationRepository; import zip.ootd.ootdzip.user.domain.User; import zip.ootd.ootdzip.user.service.UserService; @@ -28,57 +25,11 @@ @Transactional @RequiredArgsConstructor public class NotificationService { - private static final Long DEFAULT_TIMEOUT = 60L * 1000 * 60; // 1시간 - private final EmitterRepository emitterRepository; private final NotificationRepository notificationRepository; private final UserService userService; private final UserBlockRepository userBlockRepository; - - public SseEmitter subscribe(User loginUser, String lastEventId) { - Long userId = loginUser.getId(); - String emitterId = makeEmitterIdByUserId(userId); - SseEmitter emitter = emitterRepository.save(emitterId, new SseEmitter(DEFAULT_TIMEOUT)); - - emitter.onCompletion(() -> emitterRepository.deleteById(emitterId)); - emitter.onTimeout(() -> emitterRepository.deleteById(emitterId)); - - // 503 에러를 방지하기 위한 더미 이벤트 전송 - // SSE 연결후 데이터를 하나도 안보내면 503 응답이 발생함 - String eventId = makeEmitterIdByUserId(userId); - sendNotification(emitter, eventId, emitterId, "EventStream Created. [UserId =" + userId + "]"); - - // 클라이언트가 미수신한 Event 목록이 존재할 경우 전송하여 Event 유실을 예방 - if (!lastEventId.isEmpty()) { - sendLostData(lastEventId, userId, emitterId, emitter); - } - - return emitter; - } - - private String makeEmitterIdByUserId(Long userId) { - return userId + "_" + System.currentTimeMillis(); - } - - private void sendNotification(SseEmitter emitter, String eventId, String emitterId, Object data) { - try { - emitter.send(SseEmitter.event() - .id(eventId) - .name("sse") - .data(data) - ); - } catch (IOException exception) { - emitterRepository.deleteById(emitterId); - throw new RuntimeException("알람을 위한 클라이언트와 연결중에 오류가 발생했습니다."); - } - } - - private void sendLostData(String lastEventId, Long userId, String emitterId, SseEmitter emitter) { - Map eventCaches = emitterRepository.findAllEventCacheStartWithByUserId(String.valueOf(userId)); - eventCaches.entrySet().stream() - .filter(entry -> lastEventId.compareTo(entry.getKey()) < 0) - .forEach(entry -> sendNotification(emitter, entry.getKey(), emitterId, entry.getValue())); - } + private final FcmService fcmService; public Notification saveNotification(User receiver, User sender, @@ -87,7 +38,7 @@ public Notification saveNotification(User receiver, String imageUrl, String goUrl) { - return notificationRepository.save(Notification.builder() + Notification notification = notificationRepository.save(Notification.builder() .receiver(receiver) .sender(sender) .notificationType(notificationType) @@ -95,25 +46,11 @@ public Notification saveNotification(User receiver, .imageUrl(imageUrl) .goUrl(goUrl) .build()); - } - //TODO: 푸쉬 알림 필요시 스펙에 맞게 개발, - // 알람종류별로 보낼지말지 필터링하는 것도 필요 - // 해당 기능 사용시 AOP 에서 중복 알람저장안되도록 saveNotification 은 지워주기 - public void send(User receiver, User sender, - NotificationType notificationType, String content, String imageUrl, String goUrl) { - - Notification notification = saveNotification(receiver, sender, notificationType, content, imageUrl, goUrl); - - Long userId = receiver.getId(); - String eventId = userId + "_" + System.currentTimeMillis(); - Map emitters = emitterRepository.findAllEmitterStartWithByUserId(String.valueOf(userId)); - emitters.forEach( - (key, emitter) -> { - emitterRepository.saveEventCache(key, notification); - sendNotification(emitter, eventId, key, new Notification()); - } - ); + // 푸쉬 알람 보내기 + fcmService.sendMessage(notification); + + return notification; } public CommonSliceResponse getNotifications(User loginUesr, NotificationGetAllReq request) { diff --git a/src/main/java/zip/ootd/ootdzip/ootd/service/OotdService.java b/src/main/java/zip/ootd/ootdzip/ootd/service/OotdService.java index 116a102d..b5ed08e2 100644 --- a/src/main/java/zip/ootd/ootdzip/ootd/service/OotdService.java +++ b/src/main/java/zip/ootd/ootdzip/ootd/service/OotdService.java @@ -28,6 +28,7 @@ import zip.ootd.ootdzip.common.exception.code.ErrorCode; import zip.ootd.ootdzip.common.response.CommonPageResponse; import zip.ootd.ootdzip.common.response.CommonSliceResponse; +import zip.ootd.ootdzip.fcm.service.FcmService; import zip.ootd.ootdzip.images.domain.Images; import zip.ootd.ootdzip.images.service.ImagesService; import zip.ootd.ootdzip.lock.annotation.RLockCustom; @@ -265,6 +266,7 @@ private void notifyOotdLike(User receiver, User sender, String imageUrl, Long id return; } + // 앱 내 알람 eventPublisher.publishEvent(NotificationEvent.builder() .receiver(receiver) .sender(sender) diff --git a/src/main/java/zip/ootd/ootdzip/user/domain/User.java b/src/main/java/zip/ootd/ootdzip/user/domain/User.java index ad989299..71cfefb1 100644 --- a/src/main/java/zip/ootd/ootdzip/user/domain/User.java +++ b/src/main/java/zip/ootd/ootdzip/user/domain/User.java @@ -27,6 +27,7 @@ import zip.ootd.ootdzip.common.entity.BaseEntity; import zip.ootd.ootdzip.common.exception.CustomException; import zip.ootd.ootdzip.common.exception.code.ErrorCode; +import zip.ootd.ootdzip.fcm.domain.FcmInfo; import zip.ootd.ootdzip.images.domain.Images; import zip.ootd.ootdzip.ootd.domain.Ootd; import zip.ootd.ootdzip.user.data.UserRole; @@ -87,6 +88,9 @@ public class User extends BaseEntity { @OneToMany(mappedBy = "user", cascade = CascadeType.ALL) private List userStyles; + @OneToMany(mappedBy = "user", cascade = CascadeType.ALL) + private List fcmInfos; + public static User getDefault() { return User.builder() .name(null) diff --git a/src/main/java/zip/ootd/ootdzip/user/repository/UserRepository.java b/src/main/java/zip/ootd/ootdzip/user/repository/UserRepository.java index 681ef138..3557f1dd 100644 --- a/src/main/java/zip/ootd/ootdzip/user/repository/UserRepository.java +++ b/src/main/java/zip/ootd/ootdzip/user/repository/UserRepository.java @@ -4,9 +4,16 @@ import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; + +import org.springframework.data.repository.query.Param; + import zip.ootd.ootdzip.user.domain.User; public interface UserRepository extends JpaRepository, UserRepositoryCustom { Optional findByName(String name); + + @Query("SELECT u FROM User u JOIN FETCH u.fcmInfos WHERE u = :user") + Optional findWithFcmInfosByUser(@Param("user") User user); } From 1274c684ba132b9f6efcc14912a8e0b274847bee Mon Sep 17 00:00:00 2001 From: hoon Date: Wed, 27 Nov 2024 20:58:54 +0900 Subject: [PATCH 4/8] =?UTF-8?q?checkstyle=20:=20=EC=B2=B4=ED=81=AC?= =?UTF-8?q?=EC=8A=A4=ED=83=80=EC=9D=BC=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/zip/ootd/ootdzip/fcm/service/FcmService.java | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/src/main/java/zip/ootd/ootdzip/fcm/service/FcmService.java b/src/main/java/zip/ootd/ootdzip/fcm/service/FcmService.java index c967d070..bc0a304c 100644 --- a/src/main/java/zip/ootd/ootdzip/fcm/service/FcmService.java +++ b/src/main/java/zip/ootd/ootdzip/fcm/service/FcmService.java @@ -94,10 +94,8 @@ public ResponseEntity requestFCM(String message) { RestTemplate restTemplate = new RestTemplate(); - /** - * 추가된 사항 : RestTemplate 이용중 클라이언트의 한글 깨짐 증상에 대한 수정 - * @refernece : https://stackoverflow.com/questions/29392422/how-can-i-tell-resttemplate-to-post-with-utf-8-encoding - */ + // 추가된 사항 : RestTemplate 이용중 클라이언트의 한글 깨짐 증상에 대한 수정 + //@refernece : https://stackoverflow.com/questions/29392422/how-can-i-tell-resttemplate-to-post-with-utf-8-encoding restTemplate.getMessageConverters() .add(0, new StringHttpMessageConverter(StandardCharsets.UTF_8)); @@ -107,7 +105,7 @@ public ResponseEntity requestFCM(String message) { HttpEntity entity = new HttpEntity<>(message, headers); - String API_URL = ""; + final String API_URL = ""; ResponseEntity response = restTemplate.exchange(API_URL, HttpMethod.POST, entity, String.class); return response; From 5ed76b2505eefddec1cc5caf95b91b7e770407b3 Mon Sep 17 00:00:00 2001 From: hoon Date: Wed, 27 Nov 2024 21:00:59 +0900 Subject: [PATCH 5/8] =?UTF-8?q?checkstyle=20:=20=EC=B2=B4=ED=81=AC?= =?UTF-8?q?=EC=8A=A4=ED=83=80=EC=9D=BC=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/java/zip/ootd/ootdzip/fcm/service/FcmService.java | 4 ++-- .../java/zip/ootd/ootdzip/user/repository/UserRepository.java | 2 -- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/src/main/java/zip/ootd/ootdzip/fcm/service/FcmService.java b/src/main/java/zip/ootd/ootdzip/fcm/service/FcmService.java index bc0a304c..42578658 100644 --- a/src/main/java/zip/ootd/ootdzip/fcm/service/FcmService.java +++ b/src/main/java/zip/ootd/ootdzip/fcm/service/FcmService.java @@ -105,8 +105,8 @@ public ResponseEntity requestFCM(String message) { HttpEntity entity = new HttpEntity<>(message, headers); - final String API_URL = ""; - ResponseEntity response = restTemplate.exchange(API_URL, HttpMethod.POST, entity, String.class); + String url = ""; + ResponseEntity response = restTemplate.exchange(url, HttpMethod.POST, entity, String.class); return response; } diff --git a/src/main/java/zip/ootd/ootdzip/user/repository/UserRepository.java b/src/main/java/zip/ootd/ootdzip/user/repository/UserRepository.java index 3557f1dd..ce8a8907 100644 --- a/src/main/java/zip/ootd/ootdzip/user/repository/UserRepository.java +++ b/src/main/java/zip/ootd/ootdzip/user/repository/UserRepository.java @@ -3,9 +3,7 @@ import java.util.Optional; import org.springframework.data.jpa.repository.JpaRepository; - import org.springframework.data.jpa.repository.Query; - import org.springframework.data.repository.query.Param; import zip.ootd.ootdzip.user.domain.User; From 3d059c6b23fb2d642bc65529c9dc2109457d9e3b Mon Sep 17 00:00:00 2001 From: hoon Date: Wed, 27 Nov 2024 16:48:13 +0900 Subject: [PATCH 6/8] =?UTF-8?q?checkstyle=20:=20=EB=B6=88=ED=95=84?= =?UTF-8?q?=EC=9A=94=ED=95=9C=20=EC=9E=84=ED=8F=AC=ED=8A=B8=20=EC=A0=9C?= =?UTF-8?q?=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../zip/ootd/ootdzip/comment/service/CommentService.java | 1 - .../zip/ootd/ootdzip/fcm/repository/FcmRepository.java | 7 ++----- .../notification/controller/NotificationController.java | 3 --- .../java/zip/ootd/ootdzip/ootd/service/OotdService.java | 1 - 4 files changed, 2 insertions(+), 10 deletions(-) diff --git a/src/main/java/zip/ootd/ootdzip/comment/service/CommentService.java b/src/main/java/zip/ootd/ootdzip/comment/service/CommentService.java index 018bb923..76f72ec9 100644 --- a/src/main/java/zip/ootd/ootdzip/comment/service/CommentService.java +++ b/src/main/java/zip/ootd/ootdzip/comment/service/CommentService.java @@ -19,7 +19,6 @@ import zip.ootd.ootdzip.common.exception.CustomException; import zip.ootd.ootdzip.common.exception.code.ErrorCode; import zip.ootd.ootdzip.common.response.CommonSliceResponse; -import zip.ootd.ootdzip.fcm.service.FcmService; import zip.ootd.ootdzip.notification.domain.NotificationType; import zip.ootd.ootdzip.notification.event.NotificationEvent; import zip.ootd.ootdzip.ootd.domain.Ootd; diff --git a/src/main/java/zip/ootd/ootdzip/fcm/repository/FcmRepository.java b/src/main/java/zip/ootd/ootdzip/fcm/repository/FcmRepository.java index ed8d16b6..f829023e 100644 --- a/src/main/java/zip/ootd/ootdzip/fcm/repository/FcmRepository.java +++ b/src/main/java/zip/ootd/ootdzip/fcm/repository/FcmRepository.java @@ -1,17 +1,14 @@ package zip.ootd.ootdzip.fcm.repository; +import java.util.Optional; + import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.stereotype.Repository; import zip.ootd.ootdzip.fcm.domain.FcmInfo; -import zip.ootd.ootdzip.user.domain.User; - -import java.util.Optional; @Repository public interface FcmRepository extends JpaRepository { - boolean existsByFcmToken(String fcmToken); - Optional findByFcmToken(String fcmToken); } diff --git a/src/main/java/zip/ootd/ootdzip/notification/controller/NotificationController.java b/src/main/java/zip/ootd/ootdzip/notification/controller/NotificationController.java index 164d8a90..5eabcab2 100644 --- a/src/main/java/zip/ootd/ootdzip/notification/controller/NotificationController.java +++ b/src/main/java/zip/ootd/ootdzip/notification/controller/NotificationController.java @@ -3,12 +3,9 @@ import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PatchMapping; import org.springframework.web.bind.annotation.PathVariable; -import org.springframework.web.bind.annotation.RequestHeader; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; -import org.springframework.web.servlet.mvc.method.annotation.SseEmitter; -import io.swagger.v3.oas.annotations.Hidden; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.validation.Valid; diff --git a/src/main/java/zip/ootd/ootdzip/ootd/service/OotdService.java b/src/main/java/zip/ootd/ootdzip/ootd/service/OotdService.java index b5ed08e2..e0886239 100644 --- a/src/main/java/zip/ootd/ootdzip/ootd/service/OotdService.java +++ b/src/main/java/zip/ootd/ootdzip/ootd/service/OotdService.java @@ -28,7 +28,6 @@ import zip.ootd.ootdzip.common.exception.code.ErrorCode; import zip.ootd.ootdzip.common.response.CommonPageResponse; import zip.ootd.ootdzip.common.response.CommonSliceResponse; -import zip.ootd.ootdzip.fcm.service.FcmService; import zip.ootd.ootdzip.images.domain.Images; import zip.ootd.ootdzip.images.service.ImagesService; import zip.ootd.ootdzip.lock.annotation.RLockCustom; From cd09ec17573539ca2a9a57c1e16705cb0ec6161b Mon Sep 17 00:00:00 2001 From: hoon Date: Wed, 27 Nov 2024 21:10:24 +0900 Subject: [PATCH 7/8] =?UTF-8?q?checkstyle=20:=20=EC=B2=B4=ED=81=AC?= =?UTF-8?q?=EC=8A=A4=ED=83=80=EC=9D=BC=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/zip/ootd/ootdzip/user/repository/UserRepository.java | 4 ---- 1 file changed, 4 deletions(-) diff --git a/src/main/java/zip/ootd/ootdzip/user/repository/UserRepository.java b/src/main/java/zip/ootd/ootdzip/user/repository/UserRepository.java index 78a4d95c..ce8a8907 100644 --- a/src/main/java/zip/ootd/ootdzip/user/repository/UserRepository.java +++ b/src/main/java/zip/ootd/ootdzip/user/repository/UserRepository.java @@ -6,10 +6,6 @@ import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; -import org.springframework.data.jpa.repository.Query; - -import org.springframework.data.repository.query.Param; - import zip.ootd.ootdzip.user.domain.User; public interface UserRepository extends JpaRepository, UserRepositoryCustom { From 711866a8c7f24aa8044c9229f26d4d42d9ecff89 Mon Sep 17 00:00:00 2001 From: hoon Date: Wed, 27 Nov 2024 23:40:10 +0900 Subject: [PATCH 8/8] =?UTF-8?q?refactor=20:=20=EC=84=A4=EC=A0=95=20?= =?UTF-8?q?=EB=B6=84=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../zip/ootd/ootdzip/fcm/service/FcmService.java | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/src/main/java/zip/ootd/ootdzip/fcm/service/FcmService.java b/src/main/java/zip/ootd/ootdzip/fcm/service/FcmService.java index 42578658..40469a1a 100644 --- a/src/main/java/zip/ootd/ootdzip/fcm/service/FcmService.java +++ b/src/main/java/zip/ootd/ootdzip/fcm/service/FcmService.java @@ -6,6 +6,7 @@ import java.util.Optional; import java.util.stream.Collectors; +import org.springframework.beans.factory.annotation.Value; import org.springframework.core.io.ClassPathResource; import org.springframework.http.HttpEntity; import org.springframework.http.HttpHeaders; @@ -38,6 +39,12 @@ public class FcmService { private final FcmRepository fcmRepository; private final UserRepository userRepository; + @Value(("${fcm.service-key}")) + private String accessKey; + + @Value(("${fcm.project-id}")) + private String projectId; + /** * 사용자가 토큰이없다면 최초생성이므로 * 모든 알람을 on 으로 하여 생성합니다. @@ -105,7 +112,7 @@ public ResponseEntity requestFCM(String message) { HttpEntity entity = new HttpEntity<>(message, headers); - String url = ""; + String url = ""; ResponseEntity response = restTemplate.exchange(url, HttpMethod.POST, entity, String.class); return response; @@ -133,11 +140,10 @@ public List getReceiverTokens(Notification notification) { * Firebase Admin SDK의 비공개 키를 참조하여 Bearer 토큰을 발급 받습니다. */ private String getAccessToken() { - String firebaseConfigPath = "firebase/ootdzip-cf27f-firebase-adminsdk-iuig1-8969152a6a.json"; try { GoogleCredentials googleCredentials = GoogleCredentials - .fromStream(new ClassPathResource(firebaseConfigPath).getInputStream()) + .fromStream(new ClassPathResource(accessKey).getInputStream()) .createScoped(List.of("")); googleCredentials.refreshIfExpired();