diff --git a/src/main/java/com/back/catchmate/domain/board/service/BoardServiceImpl.java b/src/main/java/com/back/catchmate/domain/board/service/BoardServiceImpl.java index ce7b978..7facda9 100644 --- a/src/main/java/com/back/catchmate/domain/board/service/BoardServiceImpl.java +++ b/src/main/java/com/back/catchmate/domain/board/service/BoardServiceImpl.java @@ -122,17 +122,9 @@ private void createChatRoom(Board board, User loginUser) { // 채팅방 입장 UserChatRoom userChatRoom = userChatRoomConverter.toEntity(loginUser, chatRoom); - - // 채팅방 구독 - subscribeToChatRoomTopic(loginUser.getFcmToken(), chatRoom.getId()); - userChatRoomRepository.save(userChatRoom); } - private void subscribeToChatRoomTopic(String fcmToken, Long chatRoomId) { - fcmService.subscribeToTopic(fcmToken, chatRoomId); - } - private Club findOrDefaultClub(Long clubId) { return (clubId != 0) ? clubRepository.findById(clubId).orElseThrow(() -> new BaseException(ErrorCode.CLUB_NOT_FOUND)) diff --git a/src/main/java/com/back/catchmate/domain/chat/controller/ChatController.java b/src/main/java/com/back/catchmate/domain/chat/controller/ChatController.java index e7d2e21..b8f38fd 100644 --- a/src/main/java/com/back/catchmate/domain/chat/controller/ChatController.java +++ b/src/main/java/com/back/catchmate/domain/chat/controller/ChatController.java @@ -4,8 +4,10 @@ import com.back.catchmate.domain.chat.dto.ChatResponse.PagedChatMessageInfo; import com.back.catchmate.domain.chat.service.ChatService; import com.back.catchmate.global.jwt.JwtValidation; +import com.google.firebase.messaging.FirebaseMessagingException; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; import org.springframework.data.domain.PageRequest; import org.springframework.data.domain.Pageable; @@ -15,10 +17,13 @@ import org.springframework.messaging.handler.annotation.SendTo; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; +import java.io.IOException; + @Tag(name = "채팅 관련 API") @RestController @RequestMapping("/chats") @@ -28,7 +33,8 @@ public class ChatController { @MessageMapping("/chat.{chatRoomId}") @SendTo("/topic/chat.{chatRoomId}") - public void sendMessage(@DestinationVariable Long chatRoomId, ChatMessageRequest request) { + public void sendMessage(@DestinationVariable Long chatRoomId, + @RequestBody ChatMessageRequest request) throws IOException, FirebaseMessagingException { chatService.sendChatMessage(chatRoomId, request); } diff --git a/src/main/java/com/back/catchmate/domain/chat/service/ChatRoomServiceImpl.java b/src/main/java/com/back/catchmate/domain/chat/service/ChatRoomServiceImpl.java index 94228e6..6b9ac53 100644 --- a/src/main/java/com/back/catchmate/domain/chat/service/ChatRoomServiceImpl.java +++ b/src/main/java/com/back/catchmate/domain/chat/service/ChatRoomServiceImpl.java @@ -27,7 +27,6 @@ @Service @RequiredArgsConstructor public class ChatRoomServiceImpl implements ChatRoomService { - private final FCMService fcmService; private final ChatService chatService; private final S3Service s3Service; private final UserRepository userRepository; @@ -63,8 +62,6 @@ public StateResponse leaveChatRoom(Long userId, Long chatRoomId) { // 채팅방에서 참여자 수 감소 chatRoom.decrementParticipantCount(); - unsubscribeFromTopic(user.getFcmToken(), chatRoomId); - // 퇴장 메시지 보내기 String content = user.getNickName() + " 님이 채팅을 떠났어요"; // 퇴장 메시지 내용 chatService.sendEnterLeaveMessage(chatRoom.getId(), content, user.getId(), MessageType.LEAVE); @@ -110,14 +107,8 @@ public StateResponse kickUserFromChatRoom(Long loginUserId, Long chatRoomId, Lon // 채팅방에서 참여자 수 감소 chatRoom.decrementParticipantCount(); - unsubscribeFromTopic(user.getFcmToken(), chatRoomId); - String content = "방장의 결정으로 " + user.getNickName() + " 님이 채팅방에서 나갔습니다."; chatService.sendEnterLeaveMessage(chatRoomId, content, userId, MessageType.LEAVE); return new StateResponse(true); } - - private void unsubscribeFromTopic(String fcmToken, Long chatRoomId) { - fcmService.unsubscribeFromTopic(fcmToken, chatRoomId); - } } diff --git a/src/main/java/com/back/catchmate/domain/chat/service/ChatService.java b/src/main/java/com/back/catchmate/domain/chat/service/ChatService.java index a91e449..20a1e3f 100644 --- a/src/main/java/com/back/catchmate/domain/chat/service/ChatService.java +++ b/src/main/java/com/back/catchmate/domain/chat/service/ChatService.java @@ -2,10 +2,13 @@ import com.back.catchmate.domain.chat.dto.ChatRequest.ChatMessageRequest; import com.back.catchmate.domain.chat.dto.ChatResponse.PagedChatMessageInfo; +import com.google.firebase.messaging.FirebaseMessagingException; import org.springframework.data.domain.Pageable; +import java.io.IOException; + public interface ChatService { - void sendChatMessage(Long chatRoomId, ChatMessageRequest request); + void sendChatMessage(Long chatRoomId, ChatMessageRequest request) throws IOException, FirebaseMessagingException; void sendEnterLeaveMessage(Long chatRoomId, String content, Long senderId, ChatMessageRequest.MessageType messageType); diff --git a/src/main/java/com/back/catchmate/domain/chat/service/ChatServiceImpl.java b/src/main/java/com/back/catchmate/domain/chat/service/ChatServiceImpl.java index 2d9971a..48cb39a 100644 --- a/src/main/java/com/back/catchmate/domain/chat/service/ChatServiceImpl.java +++ b/src/main/java/com/back/catchmate/domain/chat/service/ChatServiceImpl.java @@ -9,8 +9,11 @@ import com.back.catchmate.domain.chat.repository.ChatRoomRepository; import com.back.catchmate.domain.chat.repository.UserChatRoomRepository; import com.back.catchmate.domain.notification.service.FCMService; +import com.back.catchmate.domain.user.entity.User; +import com.back.catchmate.domain.user.repository.UserRepository; import com.back.catchmate.global.error.ErrorCode; import com.back.catchmate.global.error.exception.BaseException; +import com.google.firebase.messaging.FirebaseMessagingException; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.data.domain.Page; @@ -19,6 +22,7 @@ import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import java.io.IOException; import java.time.LocalDate; import java.time.LocalDateTime; @@ -34,11 +38,15 @@ public class ChatServiceImpl implements ChatService { private final ChatRoomRepository chatRoomRepository; private final UserChatRoomRepository userChatRoomRepository; private final ChatMessageConverter chatMessageConverter; + private final UserRepository userRepository; // 메시지를 특정 채팅방으로 전송 @Override @Transactional - public void sendChatMessage(Long chatRoomId, ChatMessageRequest request) { + public void sendChatMessage(Long chatRoomId, ChatMessageRequest request) throws IOException, FirebaseMessagingException { + User user = userRepository.findById(request.getSenderId()) + .orElseThrow(() -> new BaseException(ErrorCode.USER_NOT_FOUND)); + String destination = "/topic/chat." + chatRoomId; if (request.getMessageType() == MessageType.TALK) { @@ -54,13 +62,11 @@ public void sendChatMessage(Long chatRoomId, ChatMessageRequest request) { ChatRoom chatRoom = chatRoomRepository.findById(chatRoomId) .orElseThrow(() -> new BaseException(ErrorCode.CHATROOM_NOT_FOUND)); - chatRoom.updateLastMessageContent(request.getContent()); chatRoom.updateLastMessageTime(); - // 채팅방에 알림 전송 (FCM 토픽을 사용) - String topic = "chat_room_" + chatRoomId; - fcmService.sendMessageToTopic(topic, chatRoom.getBoard().getTitle(), request.getContent()); + // 자신을 제외한 채팅방에 FCM 알림 전송 + fcmService.sendMessagesByTokens(chatRoomId, chatRoom.getBoard().getTitle(), request.getContent(), user.getFcmToken()); } log.info("Sending message to: {}", destination); diff --git a/src/main/java/com/back/catchmate/domain/enroll/service/EnrollServiceImpl.java b/src/main/java/com/back/catchmate/domain/enroll/service/EnrollServiceImpl.java index a0199fb..ffcaf41 100644 --- a/src/main/java/com/back/catchmate/domain/enroll/service/EnrollServiceImpl.java +++ b/src/main/java/com/back/catchmate/domain/enroll/service/EnrollServiceImpl.java @@ -180,8 +180,7 @@ public UpdateEnrollInfo acceptEnroll(Long enrollId, Long userId) throws IOExcept throw new BaseException(ErrorCode.ENROLL_ACCEPT_INVALID); } - Long chatRoomId = enterChatRoom(enrollApplicant, board); - subscribeToChatRoomTopic(enrollApplicant.getFcmToken(), chatRoomId); + enterChatRoom(enrollApplicant, board); String title = ENROLLMENT_ACCEPT_TITLE; String body = ENROLLMENT_ACCEPT_BODY; @@ -195,7 +194,7 @@ public UpdateEnrollInfo acceptEnroll(Long enrollId, Long userId) throws IOExcept return enrollConverter.toUpdateEnrollInfo(enroll, AcceptStatus.ACCEPTED); } - private Long enterChatRoom(User user, Board board) { + private void enterChatRoom(User user, Board board) { ChatRoom chatRoom = chatRoomRepository.findByBoardId(board.getId()) .orElseThrow(() -> new BaseException(ErrorCode.CHATROOM_NOT_FOUND)); @@ -206,12 +205,6 @@ private Long enterChatRoom(User user, Board board) { String content = user.getNickName() + " 님이 채팅에 참여했어요"; chatService.sendEnterLeaveMessage(chatRoom.getId(), content, user.getId(), MessageType.ENTER); - - return chatRoom.getId(); - } - - private void subscribeToChatRoomTopic(String fcmToken, Long chatRoomId) { - fcmService.subscribeToTopic(fcmToken, chatRoomId); } @Override diff --git a/src/main/java/com/back/catchmate/domain/notification/service/FCMService.java b/src/main/java/com/back/catchmate/domain/notification/service/FCMService.java index 3f20a7d..d19faef 100644 --- a/src/main/java/com/back/catchmate/domain/notification/service/FCMService.java +++ b/src/main/java/com/back/catchmate/domain/notification/service/FCMService.java @@ -1,5 +1,6 @@ package com.back.catchmate.domain.notification.service; +import com.back.catchmate.domain.chat.repository.UserChatRoomRepository; import com.back.catchmate.domain.enroll.entity.AcceptStatus; import com.back.catchmate.domain.notification.dto.FCMMessageRequest; import com.back.catchmate.global.error.ErrorCode; @@ -7,9 +8,10 @@ import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.ObjectMapper; import com.google.auth.oauth2.GoogleCredentials; +import com.google.firebase.messaging.BatchResponse; import com.google.firebase.messaging.FirebaseMessaging; import com.google.firebase.messaging.FirebaseMessagingException; -import com.google.firebase.messaging.Message; +import com.google.firebase.messaging.MulticastMessage; import com.google.firebase.messaging.Notification; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -25,7 +27,6 @@ import org.springframework.stereotype.Service; import java.io.IOException; -import java.util.Collections; import java.util.List; @Slf4j @@ -38,6 +39,7 @@ public class FCMService { private String FIREBASE_ALARM_SEND_API_URI; private final ObjectMapper objectMapper; + private final UserChatRoomRepository userChatRoomRepository; // Firebase로 부터 Access Token을 가져오는 메서드 private String getAccessToken() throws IOException { @@ -50,8 +52,8 @@ private String getAccessToken() throws IOException { return googleCredentials.getAccessToken().getTokenValue(); } - // 알림 파라미터들을 요구하는 body 형태로 가공 - public String makeMessage(String targetToken, String title, String body, Long boardId, AcceptStatus acceptStatus) throws JsonProcessingException { + // 신청 알림 파라미터들을 요구하는 body 형태로 가공 + public String makeEnrollMessage(String targetToken, String title, String body, Long boardId, AcceptStatus acceptStatus) throws JsonProcessingException { FCMMessageRequest fcmMessage = FCMMessageRequest.builder() .message( FCMMessageRequest.Message.builder() @@ -79,7 +81,7 @@ public String makeMessage(String targetToken, String title, String body, Long bo // 사용자의 FCM 토큰을 사용하여 푸쉬 알림을 보내는 역할을 하는 메서드 @Async("asyncTask") public void sendMessageByToken(String targetToken, String title, String body, Long boardId, AcceptStatus acceptStatus) throws IOException { - String message = makeMessage(targetToken, title, body, boardId, acceptStatus); + String message = makeEnrollMessage(targetToken, title, body, boardId, acceptStatus); OkHttpClient client = new OkHttpClient(); RequestBody requestBody = RequestBody.create(message, MediaType.get("application/json; charset=utf-8")); @@ -100,38 +102,35 @@ public void sendMessageByToken(String targetToken, String title, String body, Lo log.info(response.body().string()); } - public void sendMessageToTopic(String topic, String title, String body) { - Message message = Message.builder() + // 특정 채팅방의 모든 사용자에게 FCM 메시지 전송 + @Async("asyncTask") + public void sendMessagesByTokens(Long chatRoomId, String title, String body, String senderToken) throws IOException, FirebaseMessagingException { + List targetTokenList = userChatRoomRepository.findByChatRoomId(chatRoomId) + .stream() + .map(userChatRoom -> userChatRoom.getUser().getFcmToken()) // User 엔티티에서 FCM 토큰 가져오기 + .filter(token -> token != null && !token.isEmpty()) + .filter(token -> !token.equals(senderToken)) + .toList(); + + MulticastMessage message = MulticastMessage.builder() .setNotification(Notification.builder() .setTitle(title) .setBody(body) .build()) - .setTopic(topic) + .putData("chatRoomId", String.valueOf(chatRoomId)) + .addAllTokens(targetTokenList) .build(); - try { - String response = FirebaseMessaging.getInstance().send(message); - log.info("Successfully sent message: {}", response); - } catch (FirebaseMessagingException e) { - throw new BaseException(ErrorCode.FCM_SUBSCRIBE_BAD_REQUEST); - } - } + // FCM에 메시지 전송 + BatchResponse response = FirebaseMessaging.getInstance().sendEachForMulticast(message); - public void subscribeToTopic(String fcmToken, Long chatRoomId) { - try { - String topic = "chat_room_" + chatRoomId; - FirebaseMessaging.getInstance().subscribeToTopic(Collections.singletonList(fcmToken), topic); - } catch (FirebaseMessagingException e) { - throw new BaseException(ErrorCode.FCM_SUBSCRIBE_BAD_REQUEST); + // 전송 결과 확인 + if (response.getFailureCount() > 0) { + log.error("일부 메시지 전송에 실패했습니다. 성공한 메시지 수: {}, 실패한 메시지 수: {}", + response.getSuccessCount(), response.getFailureCount()); + throw new BaseException(ErrorCode.FCM_TOKEN_SEND_BAD_REQUEST); } - } - public void unsubscribeFromTopic(String fcmToken, Long chatRoomId) { - try { - String topic = "chat_room_" + chatRoomId; - FirebaseMessaging.getInstance().unsubscribeFromTopic(Collections.singletonList(fcmToken), topic); - } catch (FirebaseMessagingException e) { - throw new BaseException(ErrorCode.FCM_UNSUBSCRIBE_BAD_REQUEST); - } + log.info("FCM 응답: {}개의 메시지가 성공적으로 전송되었습니다.", response.getSuccessCount()); } } diff --git a/src/main/java/com/back/catchmate/global/error/ErrorCode.java b/src/main/java/com/back/catchmate/global/error/ErrorCode.java index 755ef9c..3458f99 100644 --- a/src/main/java/com/back/catchmate/global/error/ErrorCode.java +++ b/src/main/java/com/back/catchmate/global/error/ErrorCode.java @@ -46,6 +46,7 @@ public enum ErrorCode { NOTIFICATION_NOT_FOUND(HttpStatus.NOT_FOUND, "존재하지 않는 알림입니다."), EMPTY_FCM_RESPONSE(HttpStatus.BAD_REQUEST, "알림 데이터가 존재하지 않습니다."), FCM_TOPIC_SEND_BAD_REQUEST(HttpStatus.BAD_REQUEST, "토픽 알람 전송중 에러가 발생했습니다."), + FCM_TOKEN_SEND_BAD_REQUEST(HttpStatus.BAD_REQUEST, "토픽 알람 전송중 에러가 발생했습니다."), FCM_SUBSCRIBE_BAD_REQUEST(HttpStatus.BAD_REQUEST, "토픽 구독중 에러가 발생했습니다."), FCM_UNSUBSCRIBE_BAD_REQUEST(HttpStatus.BAD_REQUEST, "토픽 구독 취소중 에러가 발생했습니다."),