Skip to content

Commit

Permalink
Merge pull request #100 from prgrms-web-devcourse-final-project/Feat/#24
Browse files Browse the repository at this point in the history


Feat/#61 : 채팅 푸시알림 기능 초기 개발
  • Loading branch information
Cheol-Jin authored Jan 10, 2025
2 parents 29f63b5 + 5b595fe commit b1e4a95
Show file tree
Hide file tree
Showing 18 changed files with 560 additions and 4 deletions.
4 changes: 4 additions & 0 deletions build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -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') {
Expand Down
58 changes: 54 additions & 4 deletions src/main/java/com/petmatz/api/chatting/ChatController.java
Original file line number Diff line number Diff line change
Expand Up @@ -2,26 +2,28 @@

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;
import jakarta.annotation.Nullable;
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;

Expand All @@ -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 메세지 전송 ( 구독한 쪽으로 )
Expand All @@ -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 하나 파기
Expand Down Expand Up @@ -92,4 +113,33 @@ public Response<ChatMessageResponse> selectChatMessage(
chatMessageInfos.getTotalPages(),
chatMessageInfos.getTotalElements()));
}

//푸시알림 테스트용 api
@PostMapping("/chat")
public ResponseEntity<String> 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("메시지가 성공적으로 전송되었습니다.");
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -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/**",
Expand Down
Original file line number Diff line number Diff line change
@@ -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(); // 닉네임 반환
}
}
Original file line number Diff line number Diff line change
@@ -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);
}
}


Original file line number Diff line number Diff line change
@@ -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 초기화 완료");
}
}

Original file line number Diff line number Diff line change
@@ -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<String> registerFcmToken(@RequestBody Map<String, String> 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 토큰이 누락되었습니다.");
}
}
}

Original file line number Diff line number Diff line change
@@ -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);
}
}
}
32 changes: 32 additions & 0 deletions src/main/java/com/petmatz/infra/firebase/dto/FcmMessage.java
Original file line number Diff line number Diff line change
@@ -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);
}
}
Original file line number Diff line number Diff line change
@@ -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);
}
}


Loading

0 comments on commit b1e4a95

Please sign in to comment.