From 7e976d8b3ed867e162a88e5f3f6c376af5aeaa9c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E1=84=8E=E1=85=AC=E1=84=8E=E1=85=A5=E1=86=AF=E1=84=8C?= =?UTF-8?q?=E1=85=B5=E1=86=AB?= Date: Thu, 9 Jan 2025 21:44:37 +0900 Subject: [PATCH 1/3] =?UTF-8?q?feat=20:=20=EC=B1=84=ED=8C=85=20=ED=91=B8?= =?UTF-8?q?=EC=8B=9C=EC=95=8C=EB=A6=BC=20=EA=B8=B0=EB=8A=A5=20=EA=B0=9C?= =?UTF-8?q?=EB=B0=9C=20-=20Firebase=20FCM=20=EC=97=B0=EB=8F=99=20=EC=99=84?= =?UTF-8?q?=EB=A3=8C=20=EB=B0=8F=20=EC=95=8C=EB=A6=BC=20=EC=A0=84=EC=86=A1?= =?UTF-8?q?=20=EB=A1=9C=EC=A7=81=20=EC=B6=94=EA=B0=80=20=20=20-=20FCM=20?= =?UTF-8?q?=ED=86=A0=ED=81=B0=20=EB=93=B1=EB=A1=9D=20=EB=B0=8F=20=EA=B4=80?= =?UTF-8?q?=EB=A6=AC=20=EB=A1=9C=EC=A7=81=20=EA=B5=AC=ED=98=84=20=20=20-?= =?UTF-8?q?=20Pub/Sub=20=EA=B8=B0=EB=B0=98=20=EB=A9=94=EC=8B=9C=EC=A7=80?= =?UTF-8?q?=20=EC=A0=84=EC=86=A1=20=EB=B0=8F=20=ED=91=B8=EC=8B=9C=20?= =?UTF-8?q?=EC=95=8C=EB=A6=BC=20=EB=A1=9C=EC=A7=81=20=ED=85=8C=EC=8A=A4?= =?UTF-8?q?=ED=8A=B8=20=EC=99=84=EB=A3=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Redis 활용 오프라인 알림 저장 및 TTL 설정 - Redis 키값 구조 기반 설계 - FCM 토큰 저장: `fcmToken:{userId}` - Redis TTL 설정으로 데이터 자동 삭제 구현 (30일) - 수신자의 온라인 상태 여부에 따라 알림 전송 방식 다르게 처리하는 로직 개발중 --- build.gradle | 4 + .../petmatz/api/chatting/ChatController.java | 58 +++++++++++- .../common/config/WebSecurityConfig.java | 2 + .../user/service/UserNicknameService.java | 23 +++++ .../user/service/UserStatusService.java | 38 ++++++++ .../infra/firebase/config/FirebaseConfig.java | 28 ++++++ .../firebase/controller/FcmController.java | 35 ++++++++ .../controller/NotificationController.java | 34 +++++++ .../infra/firebase/dto/FcmMessage.java | 32 +++++++ .../firebase/service/FcmTokenService.java | 29 ++++++ .../firebase/service/NotificationService.java | 89 +++++++++++++++++++ .../service/PushNotificationService.java | 49 ++++++++++ .../redis/component/RedisSubscriber.java | 42 +++++++++ .../infra/redis/config/JacksonConfig.java | 20 +++++ .../infra/redis/config/RedisConfig.java | 11 +++ .../redis/config/RedisListenerConfig.java | 25 ++++++ .../infra/redis/service/RedisPublisher.java | 18 ++++ .../resources/static/firebase-messaging-sw.js | 30 +++++++ 18 files changed, 563 insertions(+), 4 deletions(-) create mode 100644 src/main/java/com/petmatz/domain/user/service/UserNicknameService.java create mode 100644 src/main/java/com/petmatz/domain/user/service/UserStatusService.java create mode 100644 src/main/java/com/petmatz/infra/firebase/config/FirebaseConfig.java create mode 100644 src/main/java/com/petmatz/infra/firebase/controller/FcmController.java create mode 100644 src/main/java/com/petmatz/infra/firebase/controller/NotificationController.java create mode 100644 src/main/java/com/petmatz/infra/firebase/dto/FcmMessage.java create mode 100644 src/main/java/com/petmatz/infra/firebase/service/FcmTokenService.java create mode 100644 src/main/java/com/petmatz/infra/firebase/service/NotificationService.java create mode 100644 src/main/java/com/petmatz/infra/firebase/service/PushNotificationService.java create mode 100644 src/main/java/com/petmatz/infra/redis/component/RedisSubscriber.java create mode 100644 src/main/java/com/petmatz/infra/redis/config/JacksonConfig.java create mode 100644 src/main/java/com/petmatz/infra/redis/config/RedisListenerConfig.java create mode 100644 src/main/java/com/petmatz/infra/redis/service/RedisPublisher.java create mode 100644 src/main/resources/static/firebase-messaging-sw.js diff --git a/build.gradle b/build.gradle index 2815703..b53c450 100644 --- a/build.gradle +++ b/build.gradle @@ -84,6 +84,10 @@ dependencies { testRuntimeOnly 'org.junit.platform:junit-platform-launcher' runtimeOnly 'com.mysql:mysql-connector-j' + + implementation 'com.google.firebase:firebase-admin:9.1.1' + implementation 'com.fasterxml.jackson.module:jackson-module-parameter-names:2.13.0' + } tasks.named('test') { diff --git a/src/main/java/com/petmatz/api/chatting/ChatController.java b/src/main/java/com/petmatz/api/chatting/ChatController.java index c4d6cb5..6334504 100644 --- a/src/main/java/com/petmatz/api/chatting/ChatController.java +++ b/src/main/java/com/petmatz/api/chatting/ChatController.java @@ -2,11 +2,15 @@ import com.petmatz.api.chatting.dto.*; import com.petmatz.api.global.dto.Response; +import com.petmatz.common.security.utils.JwtExtractProvider; import com.petmatz.domain.chatting.ChatMessageService; import com.petmatz.domain.chatting.ChatRoomService; import com.petmatz.domain.chatting.dto.ChatMessageInfo; import com.petmatz.domain.user.info.UserInfo; +import com.petmatz.domain.user.service.UserNicknameService; import com.petmatz.domain.user.service.UserServiceImpl; +import com.petmatz.domain.user.service.UserStatusService; +import com.petmatz.infra.firebase.service.NotificationService; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Parameter; import io.swagger.v3.oas.annotations.Parameters; @@ -14,14 +18,12 @@ import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.data.domain.Page; +import org.springframework.http.ResponseEntity; import org.springframework.messaging.handler.annotation.DestinationVariable; import org.springframework.messaging.handler.annotation.MessageMapping; import org.springframework.messaging.handler.annotation.Payload; import org.springframework.messaging.simp.SimpMessagingTemplate; -import org.springframework.web.bind.annotation.GetMapping; -import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RequestParam; -import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.bind.annotation.*; import java.time.LocalDateTime; @@ -35,6 +37,10 @@ public class ChatController { private final SimpMessagingTemplate simpMessagingTemplate; private final UserServiceImpl userService; private final ChatRoomService chatRoomService; + private final NotificationService notificationService; + private final UserStatusService userStatusService; + private final JwtExtractProvider jwtExtractProvider; + private final UserNicknameService userNicknameService; //TODO 메세지 전송 ( 구독한 쪽으로 ) @@ -44,6 +50,21 @@ public void sendPrivateMessage(ChatMessageRequest chatMessageRequest) { chatService.updateMessage(chatMessageInfo, chatMessageRequest.chatRoomId()); String destination = "/topic/chat/" + chatMessageRequest.chatRoomId(); simpMessagingTemplate.convertAndSend(destination, chatMessageInfo); + + // 알림 처리 + String senderId = jwtExtractProvider.findAccountIdFromJwt(); + String receiverId = chatMessageRequest.receiverEmail(); + String messageContent = chatMessageRequest.msg(); + + boolean isReceiverOnline = userStatusService.isUserOnline(receiverId); + + if (isReceiverOnline) { + // 수신자가 온라인 상태일 때 FCM 푸시 알림 전송 + notificationService.sendChatNotification(receiverId, "새 메시지", messageContent); + } else { + // 수신자가 오프라인 상태일 때 Redis에 메시지 저장 + notificationService.saveOfflineNotification(receiverId, messageContent); + } } // TODO 멍멍이 부탁등록 실시간 API 하나 파기 @@ -92,4 +113,33 @@ public Response selectChatMessage( chatMessageInfos.getTotalPages(), chatMessageInfos.getTotalElements())); } + + //푸시알림 테스트용 api + @PostMapping("/chat") + public ResponseEntity sendChatMessage(@RequestBody ChatMessageRequest chatMessageRequest) { + ChatMessageInfo chatMessageInfo = chatMessageRequest.of(); + chatService.updateMessage(chatMessageInfo, chatMessageRequest.chatRoomId()); + + // 메시지 브로드캐스트 + String destination = "/topic/chat/" + chatMessageRequest.chatRoomId(); + simpMessagingTemplate.convertAndSend(destination, chatMessageInfo); + + // 알림 처리 + String senderId = jwtExtractProvider.findAccountIdFromJwt(); + String receiverId = chatMessageRequest.receiverEmail(); + String messageContent = chatMessageRequest.msg(); + + boolean isReceiverOnline = userStatusService.isUserOnline(receiverId); + + if (isReceiverOnline) { + // 수신자가 온라인 상태일 때 FCM 푸시 알림 전송 + notificationService.sendChatNotification(receiverId, "새 메시지", messageContent); + } else { + // 수신자가 오프라인 상태일 때 Redis에 메시지 저장 + notificationService.saveOfflineNotification(receiverId, messageContent); + } + + return ResponseEntity.ok("메시지가 성공적으로 전송되었습니다."); + } + } diff --git a/src/main/java/com/petmatz/common/config/WebSecurityConfig.java b/src/main/java/com/petmatz/common/config/WebSecurityConfig.java index e82e58a..6ede2ce 100644 --- a/src/main/java/com/petmatz/common/config/WebSecurityConfig.java +++ b/src/main/java/com/petmatz/common/config/WebSecurityConfig.java @@ -45,10 +45,12 @@ protected SecurityFilterChain configure(HttpSecurity httpSecurity) throws Except .sessionCreationPolicy(SessionCreationPolicy.STATELESS)) // 세션 비활성화 (JWT 사용) .authorizeHttpRequests(authRequests -> authRequests .requestMatchers("/", "/api/auth/**", "/oauth2/**").permitAll() + .requestMatchers("/api/v1/chat").permitAll() // 테스트용 .requestMatchers(HttpMethod.GET, "/api/sosboard").permitAll() .requestMatchers(HttpMethod.GET, "/api/sosboard/user/{nickname}").permitAll() .requestMatchers("/api/user/**").hasAnyRole("USER", "ADMIN") .requestMatchers("/api/admin/**").hasRole("ADMIN") + .requestMatchers("/login.html").permitAll() //스웨거 모든 접근 허용 .requestMatchers( "/swagger-ui/**", diff --git a/src/main/java/com/petmatz/domain/user/service/UserNicknameService.java b/src/main/java/com/petmatz/domain/user/service/UserNicknameService.java new file mode 100644 index 0000000..ad0ad6f --- /dev/null +++ b/src/main/java/com/petmatz/domain/user/service/UserNicknameService.java @@ -0,0 +1,23 @@ +package com.petmatz.domain.user.service; + +import com.petmatz.common.security.utils.JwtExtractProvider; +import com.petmatz.domain.user.repository.UserRepository; +import com.petmatz.domain.user.entity.User; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +public class UserNicknameService { + private final UserRepository userRepository; + private final JwtExtractProvider jwtExtractProvider; + + public String getNicknameByEmail() { + String accountId = jwtExtractProvider.findAccountIdFromJwt(); + User user = userRepository.findByAccountId(accountId); + if (user == null) { + return "Unknown User"; // 예외 처리 + } + return user.getNickname(); // 닉네임 반환 + } +} diff --git a/src/main/java/com/petmatz/domain/user/service/UserStatusService.java b/src/main/java/com/petmatz/domain/user/service/UserStatusService.java new file mode 100644 index 0000000..ead6a94 --- /dev/null +++ b/src/main/java/com/petmatz/domain/user/service/UserStatusService.java @@ -0,0 +1,38 @@ +package com.petmatz.domain.user.service; + +import com.petmatz.infra.redis.service.RedisPublisher; +import lombok.RequiredArgsConstructor; +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.stereotype.Service; + +import java.util.concurrent.TimeUnit; + +@Service +@RequiredArgsConstructor +public class UserStatusService { + + private final StringRedisTemplate redisTemplate; + private final RedisPublisher redisPublisher; + private static final String STATUS_PREFIX = "userStatus:"; + private static final String ONLINE_TOPIC = "user-online"; + + // 사용자 상태 업데이트 및 Pub/Sub 발행 + public void updateUserStatus(String userId, boolean isOnline) { + String key = STATUS_PREFIX + userId; + if (isOnline) { + redisTemplate.opsForValue().set(key, "online", 30, TimeUnit.MINUTES); // TTL 설정 + redisPublisher.publish(ONLINE_TOPIC, userId); // Pub/Sub 발행 + } else { + redisTemplate.delete(key); + } + } + + // 사용자 온라인 상태 확인 + public boolean isUserOnline(String userId) { + String key = STATUS_PREFIX + userId; + String status = redisTemplate.opsForValue().get(key); + return "online".equals(status); + } +} + + diff --git a/src/main/java/com/petmatz/infra/firebase/config/FirebaseConfig.java b/src/main/java/com/petmatz/infra/firebase/config/FirebaseConfig.java new file mode 100644 index 0000000..1455af8 --- /dev/null +++ b/src/main/java/com/petmatz/infra/firebase/config/FirebaseConfig.java @@ -0,0 +1,28 @@ +package com.petmatz.infra.firebase.config; +import com.google.auth.oauth2.GoogleCredentials; +import com.google.firebase.FirebaseApp; +import com.google.firebase.FirebaseOptions; +import org.springframework.context.annotation.Configuration; +import org.springframework.core.io.ClassPathResource; + +import javax.annotation.PostConstruct; +import java.io.IOException; +import java.io.InputStream; + +@Configuration +public class FirebaseConfig { + + @PostConstruct + public void initializeFirebase() throws IOException { + InputStream serviceAccount = + new ClassPathResource("config/petmatz-f5d00-firebase-adminsdk-4vsnm-68589101cb.json").getInputStream(); + + FirebaseOptions options = FirebaseOptions.builder() + .setCredentials(GoogleCredentials.fromStream(serviceAccount)) + .build(); + + FirebaseApp.initializeApp(options); + System.out.println("Firebase Admin SDK 초기화 완료"); + } +} + diff --git a/src/main/java/com/petmatz/infra/firebase/controller/FcmController.java b/src/main/java/com/petmatz/infra/firebase/controller/FcmController.java new file mode 100644 index 0000000..c61421f --- /dev/null +++ b/src/main/java/com/petmatz/infra/firebase/controller/FcmController.java @@ -0,0 +1,35 @@ +package com.petmatz.infra.firebase.controller; + +import com.petmatz.common.security.utils.JwtExtractProvider; +import com.petmatz.infra.firebase.service.FcmTokenService; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +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 java.util.Map; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/api/v1/fcm") +public class FcmController { + + private final FcmTokenService fcmTokenService; + private final JwtExtractProvider jwtExtractProvider; + + @PostMapping("/register") + public ResponseEntity registerFcmToken(@RequestBody Map request) { + String userId = jwtExtractProvider.findAccountIdFromJwt(); + String fcmToken = request.get("fcmToken"); + + if (fcmToken != null) { + fcmTokenService.saveToken(userId, fcmToken); + return ResponseEntity.ok("FCM 토큰이 등록되었습니다."); + } else { + return ResponseEntity.badRequest().body("FCM 토큰이 누락되었습니다."); + } + } +} + diff --git a/src/main/java/com/petmatz/infra/firebase/controller/NotificationController.java b/src/main/java/com/petmatz/infra/firebase/controller/NotificationController.java new file mode 100644 index 0000000..fa904a6 --- /dev/null +++ b/src/main/java/com/petmatz/infra/firebase/controller/NotificationController.java @@ -0,0 +1,34 @@ +package com.petmatz.infra.firebase.controller; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.petmatz.infra.firebase.dto.FcmMessage; +import com.petmatz.infra.redis.service.RedisPublisher; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.*; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/api/v1/notifications") +public class NotificationController { + + private final RedisPublisher redisPublisher; + private final ObjectMapper objectMapper; + + @PostMapping + public String sendNotification(@RequestBody FcmMessage fcmMessage){ + try { + + // 객체를 JSON 문자열로 변환 + ObjectMapper objectMapper = new ObjectMapper(); + String messageJson = objectMapper.writeValueAsString(fcmMessage); + + // Redis Pub/Sub 채널에 발행 + redisPublisher.publish("notifications", messageJson); + + return "메시지가 발행되었습니다."; + } catch (JsonProcessingException e) { + throw new RuntimeException("메시지 직렬화 실패: " + e.getMessage(), e); + } + } +} diff --git a/src/main/java/com/petmatz/infra/firebase/dto/FcmMessage.java b/src/main/java/com/petmatz/infra/firebase/dto/FcmMessage.java new file mode 100644 index 0000000..5213897 --- /dev/null +++ b/src/main/java/com/petmatz/infra/firebase/dto/FcmMessage.java @@ -0,0 +1,32 @@ +package com.petmatz.infra.firebase.dto; + +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.*; + +@Getter +@Setter +@NoArgsConstructor +public class FcmMessage { + + private String token; + private String title; + private String body; + + // 모든 필드를 초기화하는 생성자 + @JsonCreator + public FcmMessage(@JsonProperty("token") String token, + @JsonProperty("title") String title, + @JsonProperty("body") String body) { + this.token = token; + this.title = title; + this.body = body; + } + + // 정적 팩토리 메서드 + public static FcmMessage of(@JsonProperty("token") String token, + @JsonProperty("title") String title, + @JsonProperty("body") String body) { + return new FcmMessage(token, title, body); + } +} diff --git a/src/main/java/com/petmatz/infra/firebase/service/FcmTokenService.java b/src/main/java/com/petmatz/infra/firebase/service/FcmTokenService.java new file mode 100644 index 0000000..bdc33f2 --- /dev/null +++ b/src/main/java/com/petmatz/infra/firebase/service/FcmTokenService.java @@ -0,0 +1,29 @@ +package com.petmatz.infra.firebase.service; + +import lombok.RequiredArgsConstructor; +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.stereotype.Service; + +import java.util.concurrent.TimeUnit; + +@Service +@RequiredArgsConstructor +public class FcmTokenService { + + private final StringRedisTemplate redisTemplate; + private static final String TOKEN_PREFIX = "fcmToken:"; + + // FCM 토큰 저장 + public void saveToken(String userId, String fcmToken) { + String key = TOKEN_PREFIX + userId; + redisTemplate.opsForValue().set(key, fcmToken, 30, TimeUnit.DAYS); // 30일 TTL + } + + // FCM 토큰 조회 + public String getToken(String userId) { + String key = TOKEN_PREFIX + userId; + return redisTemplate.opsForValue().get(key); + } +} + + diff --git a/src/main/java/com/petmatz/infra/firebase/service/NotificationService.java b/src/main/java/com/petmatz/infra/firebase/service/NotificationService.java new file mode 100644 index 0000000..db2bde7 --- /dev/null +++ b/src/main/java/com/petmatz/infra/firebase/service/NotificationService.java @@ -0,0 +1,89 @@ +package com.petmatz.infra.firebase.service; + +import com.petmatz.infra.firebase.dto.FcmMessage; +import lombok.RequiredArgsConstructor; +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.stereotype.Service; + +import java.util.List; + +@Service +@RequiredArgsConstructor +public class NotificationService { + + private final StringRedisTemplate redisTemplate; + private final PushNotificationService pushNotificationService; + private final FcmTokenService fcmTokenService; + + private static final String NOTIFICATION_PREFIX = "offlineNotifications:"; + private static final String STATUS_PREFIX = "userStatus:"; + private static final long NOTIFICATION_TTL = 30; // 오프라인 알림 TTL (30일) + + // 오프라인 알림 저장 + public void saveOfflineNotification(String userId, String messageContent) { + String key = NOTIFICATION_PREFIX + userId; + redisTemplate.opsForList().rightPush(key, messageContent); // 메시지 저장 + } + + // 오프라인 알림 전송 + public void sendOfflineNotifications(String userId) { + String key = NOTIFICATION_PREFIX + userId; + List notifications = redisTemplate.opsForList().range(key, 0, -1); + redisTemplate.delete(key); // 전송 후 삭제 + + if (notifications != null) { + for (String messageContent : notifications) { + // FCM 토큰 조회 + String fcmToken = fcmTokenService.getToken(userId); + + if (fcmToken != null) { + // FcmMessage 객체 생성 + FcmMessage fcmMessage = FcmMessage.of(fcmToken, "오프라인 메시지", messageContent); + + // FCM 알림 전송 + pushNotificationService.sendChatNotification(fcmMessage); + } else { + System.err.println("FCM 토큰을 찾을 수 없습니다: " + userId); + } + } + } + } + + + + // 4. FCM을 통한 채팅 알림 전송 + public void sendChatNotification(String userId, String title, String body) { + String fcmToken = fcmTokenService.getToken(userId); // FcmTokenService를 통해 FCM 토큰 조회 + + if (fcmToken != null) { + pushNotificationService.sendChatNotification(FcmMessage.of(fcmToken, title, body)); // FCM 메시지 전송 + } else { + System.err.println("FCM 토큰이 없습니다: " + userId); // 로그로 알림 + } + } + + // 알림 처리 + public void handleNotification(String receiverId, String messageContent) { + String key = STATUS_PREFIX + receiverId; + boolean isOnline = "online".equals(redisTemplate.opsForValue().get(key)); + + if (isOnline) { + // FCM 메시지 객체 생성 + String fcmToken = fcmTokenService.getToken(receiverId); // FCM 토큰 조회 + if (fcmToken != null) { + FcmMessage fcmMessage = FcmMessage.of(fcmToken, "새 메시지", messageContent); + pushNotificationService.sendChatNotification(fcmMessage); // FCM 메시지 전송 + } else { + System.err.println("FCM 토큰을 찾을 수 없습니다: " + receiverId); + } + } else { + // 사용자 오프라인 상태일 경우 알림 저장 + saveOfflineNotification(receiverId, messageContent); + } + } + +} + + + + diff --git a/src/main/java/com/petmatz/infra/firebase/service/PushNotificationService.java b/src/main/java/com/petmatz/infra/firebase/service/PushNotificationService.java new file mode 100644 index 0000000..4230b24 --- /dev/null +++ b/src/main/java/com/petmatz/infra/firebase/service/PushNotificationService.java @@ -0,0 +1,49 @@ +package com.petmatz.infra.firebase.service; + +import com.google.firebase.messaging.FirebaseMessaging; +import com.google.firebase.messaging.FirebaseMessagingException; +import com.google.firebase.messaging.Message; +import com.google.firebase.messaging.Notification; +import com.petmatz.domain.user.service.UserNicknameService; +import com.petmatz.infra.firebase.dto.FcmMessage; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +@Slf4j +public class PushNotificationService { + + private final FcmTokenService fcmTokenService; + private final UserNicknameService userNicknameService; + + public void sendChatNotification(FcmMessage fcmMessage) { + String senderNickname = userNicknameService.getNicknameByEmail(); + + if (fcmMessage.getToken() != null) { + try { + Message message = Message.builder() + .setToken(fcmMessage.getToken()) + .setNotification(Notification.builder() + .setTitle(senderNickname) + .setBody(fcmMessage.getBody()) + .build()) + .build(); + + String response = FirebaseMessaging.getInstance().send(message); + log.info("푸시 알림 전송 성공: {}", response); + } catch (FirebaseMessagingException e) { + log.error("푸시 알림 전송 실패: {}", e.getMessage()); + log.error("오류 코드: {}", e.getMessagingErrorCode()); + } catch (Exception e) { + log.error("알 수 없는 오류 발생: {}", e.getMessage()); + } + } else { + log.warn("FCM 토큰이 없습니다. 알림 전송 실패."); + } + } +} + + + diff --git a/src/main/java/com/petmatz/infra/redis/component/RedisSubscriber.java b/src/main/java/com/petmatz/infra/redis/component/RedisSubscriber.java new file mode 100644 index 0000000..79cc0b7 --- /dev/null +++ b/src/main/java/com/petmatz/infra/redis/component/RedisSubscriber.java @@ -0,0 +1,42 @@ +package com.petmatz.infra.redis.component; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.petmatz.infra.firebase.dto.FcmMessage; +import com.petmatz.infra.firebase.service.PushNotificationService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.redis.connection.MessageListener; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +@Slf4j +public class RedisSubscriber implements MessageListener { + + private final PushNotificationService pushNotificationService; + private final ObjectMapper objectMapper; + + @Override + public void onMessage(org.springframework.data.redis.connection.Message message, byte[] pattern) { + String topic = new String(pattern); + String payload = new String(message.getBody()); + + log.info("Received event from topic '{}'", topic); + + try { + // 1차 역직렬화: 이스케이프된 JSON 문자열 처리 + String unescapedPayload = objectMapper.readValue(payload, String.class); + + // 2차 역직렬화: FcmMessage 객체로 변환 + FcmMessage fcmMessage = objectMapper.readValue(unescapedPayload, FcmMessage.class); + + // FCM 푸시 알림 전송 + pushNotificationService.sendChatNotification(fcmMessage); + } catch (Exception e) { + log.error("푸시 알림 전송 중 오류 발생: {}", e.getMessage(), e); + } + } +} + + + diff --git a/src/main/java/com/petmatz/infra/redis/config/JacksonConfig.java b/src/main/java/com/petmatz/infra/redis/config/JacksonConfig.java new file mode 100644 index 0000000..d5c659d --- /dev/null +++ b/src/main/java/com/petmatz/infra/redis/config/JacksonConfig.java @@ -0,0 +1,20 @@ +package com.petmatz.infra.redis.config; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.SerializationFeature; +import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.http.converter.json.Jackson2ObjectMapperBuilder; + +@Configuration +public class JacksonConfig { + @Bean + public ObjectMapper objectMapper(Jackson2ObjectMapperBuilder builder) { + ObjectMapper objectMapper = builder.createXmlMapper(false).build(); + objectMapper.registerModule(new JavaTimeModule()); + objectMapper.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS); + return objectMapper; + } +} + diff --git a/src/main/java/com/petmatz/infra/redis/config/RedisConfig.java b/src/main/java/com/petmatz/infra/redis/config/RedisConfig.java index a56952f..af1e8ed 100644 --- a/src/main/java/com/petmatz/infra/redis/config/RedisConfig.java +++ b/src/main/java/com/petmatz/infra/redis/config/RedisConfig.java @@ -5,7 +5,9 @@ import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.data.redis.connection.RedisConnectionFactory; +import org.springframework.data.redis.connection.lettuce.LettuceConnectionFactory; import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.data.redis.core.StringRedisTemplate; import org.springframework.data.redis.serializer.GenericJackson2JsonRedisSerializer; import org.springframework.data.redis.serializer.StringRedisSerializer; @@ -32,4 +34,13 @@ public RedisTemplate redisTemplate(RedisConnectionFactory connec return redisTemplate; } + @Bean + public RedisConnectionFactory redisConnectionFactory() { + return new LettuceConnectionFactory(); + } + @Bean + public StringRedisTemplate stringRedisTemplate(RedisConnectionFactory connectionFactory) { + return new StringRedisTemplate(connectionFactory); + } + } diff --git a/src/main/java/com/petmatz/infra/redis/config/RedisListenerConfig.java b/src/main/java/com/petmatz/infra/redis/config/RedisListenerConfig.java new file mode 100644 index 0000000..b593000 --- /dev/null +++ b/src/main/java/com/petmatz/infra/redis/config/RedisListenerConfig.java @@ -0,0 +1,25 @@ +package com.petmatz.infra.redis.config; + +import com.petmatz.infra.redis.component.RedisSubscriber; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.data.redis.connection.RedisConnectionFactory; +import org.springframework.data.redis.listener.PatternTopic; +import org.springframework.data.redis.listener.RedisMessageListenerContainer; + +@Configuration +public class RedisListenerConfig { + + @Bean + public RedisMessageListenerContainer redisMessageListenerContainer( + RedisConnectionFactory connectionFactory, RedisSubscriber redisSubscriber) { + RedisMessageListenerContainer container = new RedisMessageListenerContainer(); + container.setConnectionFactory(connectionFactory); + + // RedisSubscriber를 Pub/Sub 채널에 등록 + container.addMessageListener(redisSubscriber, new PatternTopic("notifications")); + return container; + } +} + + diff --git a/src/main/java/com/petmatz/infra/redis/service/RedisPublisher.java b/src/main/java/com/petmatz/infra/redis/service/RedisPublisher.java new file mode 100644 index 0000000..45f6359 --- /dev/null +++ b/src/main/java/com/petmatz/infra/redis/service/RedisPublisher.java @@ -0,0 +1,18 @@ +package com.petmatz.infra.redis.service; + +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.stereotype.Service; +import lombok.RequiredArgsConstructor; + +@Service +@RequiredArgsConstructor +public class RedisPublisher { + + private final RedisTemplate redisTemplate; + + public void publish(String topic, String message) { + + redisTemplate.convertAndSend(topic, message); + redisTemplate.opsForList().rightPush("notifications", message); // Redis에 데이터 저장 + } +} diff --git a/src/main/resources/static/firebase-messaging-sw.js b/src/main/resources/static/firebase-messaging-sw.js new file mode 100644 index 0000000..a1a7c25 --- /dev/null +++ b/src/main/resources/static/firebase-messaging-sw.js @@ -0,0 +1,30 @@ +importScripts('https://www.gstatic.com/firebasejs/9.17.1/firebase-app-compat.js'); +importScripts('https://www.gstatic.com/firebasejs/9.17.1/firebase-messaging-compat.js'); + +// Firebase 초기화 +const firebaseConfig = { + apiKey: "AIzaSyDS-CXRD6LJGIRenI45yuv0gnwZy2ikndE", + authDomain: "petmatz-f5d00.firebaseapp.com", + projectId: "petmatz-f5d00", + storageBucket: "petmatz-f5d00.appspot.com", + messagingSenderId: "1026162815915", + appId: "1:1026162815915:web:7475a143a4a7c8c1f098ee", + measurementId: "G-QXPY2YWX7C" +}; + +firebase.initializeApp(firebaseConfig); + +const messaging = firebase.messaging(); + +// 백그라운드 메시지 처리 +messaging.onBackgroundMessage((payload) => { + console.log('[firebase-messaging-sw.js] 백그라운드 메시지 수신:', payload); + + const notificationTitle = payload.notification.title; + const notificationOptions = { + body: payload.notification.body, + icon: '/Users/cheoljin/Downloads/images.jpeg' // 알림 아이콘 경로 설정 + }; + + self.registration.showNotification(notificationTitle, notificationOptions); +}); From 3d5d67906978774c61a2303b4e34156256744a55 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E1=84=8E=E1=85=AC=E1=84=8E=E1=85=A5=E1=86=AF=E1=84=8C?= =?UTF-8?q?=E1=85=B5=E1=86=AB?= Date: Fri, 10 Jan 2025 01:18:15 +0900 Subject: [PATCH 2/3] =?UTF-8?q?fix=20:=20=20firebase-messaging-sw.js=20?= =?UTF-8?q?=ED=8C=8C=EC=9D=BC=EC=9D=98=20=EC=A4=91=EC=9A=94=EC=A0=95?= =?UTF-8?q?=EB=B3=B4=EB=A5=BC=20=EB=8B=A4=EB=A5=B8=20=ED=8C=8C=EC=9D=BC?= =?UTF-8?q?=EC=97=90=EC=84=9C=20=EB=B6=88=EB=9F=AC=EC=98=A4=EB=8A=94?= =?UTF-8?q?=EC=8B=9D=EC=9C=BC=EB=A1=9C=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../resources/static/firebase-messaging-sw.js | 30 ------------------- 1 file changed, 30 deletions(-) delete mode 100644 src/main/resources/static/firebase-messaging-sw.js diff --git a/src/main/resources/static/firebase-messaging-sw.js b/src/main/resources/static/firebase-messaging-sw.js deleted file mode 100644 index a1a7c25..0000000 --- a/src/main/resources/static/firebase-messaging-sw.js +++ /dev/null @@ -1,30 +0,0 @@ -importScripts('https://www.gstatic.com/firebasejs/9.17.1/firebase-app-compat.js'); -importScripts('https://www.gstatic.com/firebasejs/9.17.1/firebase-messaging-compat.js'); - -// Firebase 초기화 -const firebaseConfig = { - apiKey: "AIzaSyDS-CXRD6LJGIRenI45yuv0gnwZy2ikndE", - authDomain: "petmatz-f5d00.firebaseapp.com", - projectId: "petmatz-f5d00", - storageBucket: "petmatz-f5d00.appspot.com", - messagingSenderId: "1026162815915", - appId: "1:1026162815915:web:7475a143a4a7c8c1f098ee", - measurementId: "G-QXPY2YWX7C" -}; - -firebase.initializeApp(firebaseConfig); - -const messaging = firebase.messaging(); - -// 백그라운드 메시지 처리 -messaging.onBackgroundMessage((payload) => { - console.log('[firebase-messaging-sw.js] 백그라운드 메시지 수신:', payload); - - const notificationTitle = payload.notification.title; - const notificationOptions = { - body: payload.notification.body, - icon: '/Users/cheoljin/Downloads/images.jpeg' // 알림 아이콘 경로 설정 - }; - - self.registration.showNotification(notificationTitle, notificationOptions); -}); From 5b595feeabc59ff725bbe250821ff06fe89be05f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E1=84=8E=E1=85=AC=E1=84=8E=E1=85=A5=E1=86=AF=E1=84=8C?= =?UTF-8?q?=E1=85=B5=E1=86=AB?= Date: Fri, 10 Jan 2025 01:18:47 +0900 Subject: [PATCH 3/3] =?UTF-8?q?fix=20:=20=20firebase-messaging-sw.js=20?= =?UTF-8?q?=ED=8C=8C=EC=9D=BC=EC=9D=98=20=EC=A4=91=EC=9A=94=EC=A0=95?= =?UTF-8?q?=EB=B3=B4=EB=A5=BC=20=EB=8B=A4=EB=A5=B8=20=ED=8C=8C=EC=9D=BC?= =?UTF-8?q?=EC=97=90=EC=84=9C=20=EB=B6=88=EB=9F=AC=EC=98=A4=EB=8A=94?= =?UTF-8?q?=EC=8B=9D=EC=9C=BC=EB=A1=9C=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../resources/static/firebase-messaging-sw.js | 27 +++++++++++++++++++ 1 file changed, 27 insertions(+) create mode 100644 src/main/resources/static/firebase-messaging-sw.js diff --git a/src/main/resources/static/firebase-messaging-sw.js b/src/main/resources/static/firebase-messaging-sw.js new file mode 100644 index 0000000..9de8856 --- /dev/null +++ b/src/main/resources/static/firebase-messaging-sw.js @@ -0,0 +1,27 @@ +importScripts('https://www.gstatic.com/firebasejs/9.17.1/firebase-app-compat.js'); +importScripts('https://www.gstatic.com/firebasejs/9.17.1/firebase-messaging-compat.js'); + +fetch('/firebase-config.json') + .then(response => response.json()) + .then(config => { + firebase.initializeApp(config); + + const messaging = firebase.messaging(); + + // 백그라운드 메시지 처리 + messaging.onBackgroundMessage((payload) => { + console.log('[firebase-messaging-sw.js] 백그라운드 메시지 수신:', payload); + + const notificationTitle = payload.notification.title; + const notificationOptions = { + body: payload.notification.body, + icon: payload.notification.icon || '/default-icon.png' // 아이콘 경로 설정 + }; + + self.registration.showNotification(notificationTitle, notificationOptions); + }); + }) + .catch(error => { + console.error('Firebase 설정 로드 실패:', error); + }); +