diff --git a/.gitignore b/.gitignore index 153f1dc8..f95987a2 100644 --- a/.gitignore +++ b/.gitignore @@ -43,3 +43,6 @@ api.pdf /pinpoint-agent/ /logs/.DS_Store .DS_Store + +### Firebase ### +**/src/main/resources/**/**.json diff --git a/build.gradle b/build.gradle index 77aa6b4d..37ee2329 100644 --- a/build.gradle +++ b/build.gradle @@ -62,7 +62,8 @@ dependencies { implementation 'com.googlecode.json-simple:json-simple:1.1.1' // firebase sdk - implementation 'com.google.firebase:firebase-admin:9.1.1' + implementation 'com.google.firebase:firebase-admin:9.2.0' + implementation 'com.squareup.okhttp3:okhttp:4.10.0' // swagger annotationProcessor 'com.github.therapi:therapi-runtime-javadoc-scribe:0.15.0' diff --git a/src/main/java/com/dku/council/domain/chat/controller/ChatController.java b/src/main/java/com/dku/council/domain/chat/controller/ChatController.java index 356d9689..6b4e2ecc 100644 --- a/src/main/java/com/dku/council/domain/chat/controller/ChatController.java +++ b/src/main/java/com/dku/council/domain/chat/controller/ChatController.java @@ -10,6 +10,8 @@ import com.dku.council.domain.chatmessage.service.ChatRoomMessageService; import com.dku.council.domain.user.service.UserService; import com.dku.council.global.auth.role.UserAuth; +import com.dku.council.infra.fcm.service.FirebaseCloudMessageService; +import com.google.firebase.messaging.FirebaseMessagingException; import io.swagger.v3.oas.annotations.tags.Tag; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -49,6 +51,7 @@ public class ChatController { private final ChatService chatService; private final ChatRoomMessageService chatRoomMessageService; private final MessageSender sender; + private final FirebaseCloudMessageService firebaseCloudMessageService; /** * 채팅방 별, 입장 이벤트 발생시 처리되는 기능 @@ -62,32 +65,40 @@ public class ChatController { @MessageMapping("/chat/enterUser") public void enterUser(@Payload RequestChatDto chat, SimpMessageHeaderAccessor headerAccessor) { - // 채팅방 유저+1 - chatService.plusUserCnt(chat.getRoomId()); - // 채팅방에 유저 추가 및 UserUUID 반환 - String username = chatService.addUser(chat.getRoomId(), chat.getSender()); + if(chatService.alreadyInRoom(chat.getRoomId(), chat.getUserId())) { + // 반환 결과를 socket session 에 userUUID 로 저장 + headerAccessor.getSessionAttributes().put("username", chat.getSender()); + headerAccessor.getSessionAttributes().put("userId", chat.getUserId()); + headerAccessor.getSessionAttributes().put("roomId", chat.getRoomId()); + } else { + // 채팅방 유저+1 + chatService.plusUserCnt(chat.getRoomId()); - // 반환 결과를 socket session 에 userUUID 로 저장 - headerAccessor.getSessionAttributes().put("username", username); - headerAccessor.getSessionAttributes().put("userId", chat.getUserId()); - headerAccessor.getSessionAttributes().put("roomId", chat.getRoomId()); + // 채팅방에 유저 추가 및 UserUUID 반환 + String username = chatService.addUser(chat.getRoomId(), chat.getSender()); - String enterMessage = chat.getSender() + " 님 입장!!"; - LocalDateTime messageTime = LocalDateTime.now(); + // 반환 결과를 socket session 에 userUUID 로 저장 + headerAccessor.getSessionAttributes().put("username", username); + headerAccessor.getSessionAttributes().put("userId", chat.getUserId()); + headerAccessor.getSessionAttributes().put("roomId", chat.getRoomId()); - // 입장 메시지 저장 - chatRoomMessageService.create(chat.getRoomId(), chat.getType().toString(), chat.getUserId(), chat.getSender(), enterMessage, messageTime); + String enterMessage = chat.getSender() + " 님 입장!!"; + LocalDateTime messageTime = LocalDateTime.now(); - Message message = Message.builder() - .type(chat.getType()) - .roomId(chat.getRoomId()) - .sender(chat.getSender()) - .message(enterMessage) - .messageTime(messageTime) - .build(); + // 입장 메시지 저장 + chatRoomMessageService.create(chat.getRoomId(), chat.getType().toString(), chat.getUserId(), chat.getSender(), enterMessage, messageTime); - sender.send(topic, message); + Message message = Message.builder() + .type(chat.getType()) + .roomId(chat.getRoomId()) + .sender(chat.getSender()) + .message(enterMessage) + .messageTime(messageTime) + .build(); + + sender.send(topic, message); + } } /** @@ -117,47 +128,47 @@ public void sendMessage(@Payload RequestChatDto chat) { * * 유저 퇴장 시에는 EventListener 을 통해서 유저 퇴장을 확인 */ - @EventListener - public void webSocketDisconnectListener(SessionDisconnectEvent event) { - log.info("DisConnEvent {}", event); - - StompHeaderAccessor headerAccessor = StompHeaderAccessor.wrap(event.getMessage()); - - // stomp 세션에 있던 username과 roomId 를 확인해서 채팅방 유저 리스트와 room 에서 해당 유저를 삭제 - String username = (String) headerAccessor.getSessionAttributes().get("username"); - Long userId = (Long) headerAccessor.getSessionAttributes().get("userId"); - String roomId = (String) headerAccessor.getSessionAttributes().get("roomId"); - log.info("퇴장 controller에서 uuid " + username); - log.info("퇴장 controller에서 roomId " + roomId); - - log.info("headAccessor {}", headerAccessor); - - // 채팅방 유저 -1 - chatService.minusUserCnt(roomId); - - // 채팅방 유저 리스트에서 유저 삭제 - chatService.delUser(roomId, username); - - if (username != null) { - log.info("User Disconnected : ", username); - - String exitMessage = username + " 님 퇴장!!"; - LocalDateTime messageTime = LocalDateTime.now(); - - // 퇴장 메시지 저장 - chatRoomMessageService.create(roomId, MessageType.LEAVE.toString(), userId, username, exitMessage, messageTime); - // builder 어노테이션 활용 - Message message = Message.builder() - .type(MessageType.LEAVE) - .sender(username) - .roomId(roomId) - .message(exitMessage) - .messageTime(messageTime) - .build(); - - sender.send(topic, message); - } - } +// @EventListener +// public void webSocketDisconnectListener(SessionDisconnectEvent event) { +// log.info("DisConnEvent {}", event); +// +// StompHeaderAccessor headerAccessor = StompHeaderAccessor.wrap(event.getMessage()); +// +// // stomp 세션에 있던 username과 roomId 를 확인해서 채팅방 유저 리스트와 room 에서 해당 유저를 삭제 +// String username = (String) headerAccessor.getSessionAttributes().get("username"); +// Long userId = (Long) headerAccessor.getSessionAttributes().get("userId"); +// String roomId = (String) headerAccessor.getSessionAttributes().get("roomId"); +// log.info("퇴장 controller에서 uuid " + username); +// log.info("퇴장 controller에서 roomId " + roomId); +// +// log.info("headAccessor {}", headerAccessor); +// +// // 채팅방 유저 -1 +// chatService.minusUserCnt(roomId); +// +// // 채팅방 유저 리스트에서 유저 삭제 +// chatService.delUser(roomId, username); +// +// if (username != null) { +// log.info("User Disconnected : ", username); +// +// String exitMessage = username + " 님 퇴장!!"; +// LocalDateTime messageTime = LocalDateTime.now(); +// +// // 퇴장 메시지 저장 +// chatRoomMessageService.create(roomId, MessageType.LEAVE.toString(), userId, username, exitMessage, messageTime); +// // builder 어노테이션 활용 +// Message message = Message.builder() +// .type(MessageType.LEAVE) +// .sender(username) +// .roomId(roomId) +// .message(exitMessage) +// .messageTime(messageTime) +// .build(); +// +// sender.send(topic, message); +// } +// } /** * 채팅방 별, 채팅에 참여한 유저 리스트 반환 diff --git a/src/main/java/com/dku/council/domain/chat/repository/ChatRoomUserRepository.java b/src/main/java/com/dku/council/domain/chat/repository/ChatRoomUserRepository.java index 347a22c7..ee16b1f8 100644 --- a/src/main/java/com/dku/council/domain/chat/repository/ChatRoomUserRepository.java +++ b/src/main/java/com/dku/council/domain/chat/repository/ChatRoomUserRepository.java @@ -7,6 +7,7 @@ import org.springframework.data.repository.query.Param; import java.util.List; +import java.util.Optional; public interface ChatRoomUserRepository extends JpaRepository { @Query("select u.participant.nickname from ChatRoomUser u " + @@ -20,4 +21,10 @@ public interface ChatRoomUserRepository extends JpaRepository existsUserByRoomIdAndUserId(@Param("chatRoomId") Long chatRoomId, @Param("userId") Long userId); } diff --git a/src/main/java/com/dku/council/domain/chat/service/ChatService.java b/src/main/java/com/dku/council/domain/chat/service/ChatService.java index 2b28577c..babca038 100644 --- a/src/main/java/com/dku/council/domain/chat/service/ChatService.java +++ b/src/main/java/com/dku/council/domain/chat/service/ChatService.java @@ -204,4 +204,9 @@ public void delChatRoom(Long userId, String roomId, boolean isAdmin) { } log.info("삭제 완료 roomId : {}", roomId); } + + public boolean alreadyInRoom(String roomId, Long userId) { + long chatRoomId = chatRoomRepository.findChatRoomByRoomId(roomId).orElseThrow(ChatRoomNotFoundException::new).getId(); + return chatRoomUserRepository.existsUserByRoomIdAndUserId(chatRoomId, userId).isPresent(); + } } diff --git a/src/main/java/com/dku/council/infra/fcm/config/FCMConfig.java b/src/main/java/com/dku/council/infra/fcm/config/FCMConfig.java index 7a84baf7..c7ecd7b7 100644 --- a/src/main/java/com/dku/council/infra/fcm/config/FCMConfig.java +++ b/src/main/java/com/dku/council/infra/fcm/config/FCMConfig.java @@ -1,35 +1,45 @@ package com.dku.council.infra.fcm.config; +import com.dku.council.infra.fcm.model.dto.request.FCMPushRequestDto; import com.google.auth.oauth2.GoogleCredentials; import com.google.firebase.FirebaseApp; import com.google.firebase.FirebaseOptions; +import com.google.firebase.messaging.ApnsConfig; +import com.google.firebase.messaging.Aps; +import com.google.firebase.messaging.ApsAlert; import com.google.firebase.messaging.FirebaseMessaging; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.core.io.ClassPathResource; +import javax.annotation.PostConstruct; import java.io.IOException; import java.io.InputStream; -// TODO 향후 사용시 활성화 -//@Configuration +@Configuration +@Slf4j public class FCMConfig { - @Bean - public FirebaseMessaging firebaseMessaging() { - try (InputStream keyStream = FCMConfig.class.getResourceAsStream("/serviceAccountKey.json")) { - if (keyStream == null) { - throw new IOException("Not found serviceAccountKey.json file"); - } - if (!FirebaseApp.getApps().isEmpty()) { - return FirebaseMessaging.getInstance(); - } + @Value("${fcm.key.path}") + private String SERVICE_ACCOUNT_JSON; + + @PostConstruct + public void init() { + try { + ClassPathResource resource = new ClassPathResource(SERVICE_ACCOUNT_JSON); + InputStream inputStream = resource.getInputStream(); + FirebaseOptions options = FirebaseOptions.builder() - .setCredentials(GoogleCredentials.fromStream(keyStream)) + .setCredentials(GoogleCredentials.fromStream(inputStream)) + .setProjectId("next-dku-push-server") .build(); - FirebaseApp app = FirebaseApp.initializeApp(options); - return FirebaseMessaging.getInstance(app); + + FirebaseApp.initializeApp(options); + log.info("파이어베이스 서버와의 연결에 성공했습니다."); } catch (IOException e) { - throw new RuntimeException(e); + log.error("파이어베이스 서버와의 연결에 실패했습니다.", e); } } - } diff --git a/src/main/java/com/dku/council/infra/fcm/model/dto/FCMMessage.java b/src/main/java/com/dku/council/infra/fcm/model/dto/FCMMessage.java new file mode 100644 index 00000000..02d21d0b --- /dev/null +++ b/src/main/java/com/dku/council/infra/fcm/model/dto/FCMMessage.java @@ -0,0 +1,33 @@ +package com.dku.council.infra.fcm.model.dto; + +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; + +@Getter +@Builder +@AllArgsConstructor(access = AccessLevel.PRIVATE) +public class FCMMessage { + + private boolean validateOnly; + private Message message; + + @Builder + @AllArgsConstructor + @Getter + public static class Message { + private Notification notification; + private String token; + private String topic; + } + + @Builder + @AllArgsConstructor + @Getter + public static class Notification { + private String title; + private String body; + private String image; + } +} diff --git a/src/main/java/com/dku/council/infra/fcm/model/dto/request/FCMPushRequestDto.java b/src/main/java/com/dku/council/infra/fcm/model/dto/request/FCMPushRequestDto.java new file mode 100644 index 00000000..d81bb81c --- /dev/null +++ b/src/main/java/com/dku/council/infra/fcm/model/dto/request/FCMPushRequestDto.java @@ -0,0 +1,21 @@ +package com.dku.council.infra.fcm.model.dto.request; + +import com.fasterxml.jackson.annotation.JsonInclude; +import lombok.*; +import lombok.extern.slf4j.Slf4j; + +@Getter +@Slf4j +@Builder +@AllArgsConstructor(access = AccessLevel.PRIVATE) +@NoArgsConstructor(access = AccessLevel.PRIVATE) +public class FCMPushRequestDto { + + private String targetToken; + + @JsonInclude(JsonInclude.Include.NON_NULL) + private String title; + + @JsonInclude(JsonInclude.Include.NON_NULL) + private String body; +} diff --git a/src/main/java/com/dku/council/infra/fcm/service/FirebaseCloudMessageService.java b/src/main/java/com/dku/council/infra/fcm/service/FirebaseCloudMessageService.java index 0eba6679..12f8b3f4 100644 --- a/src/main/java/com/dku/council/infra/fcm/service/FirebaseCloudMessageService.java +++ b/src/main/java/com/dku/council/infra/fcm/service/FirebaseCloudMessageService.java @@ -1,28 +1,211 @@ package com.dku.council.infra.fcm.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.dku.council.infra.fcm.model.dto.FCMMessage; +import com.dku.council.infra.fcm.model.dto.request.FCMPushRequestDto; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.google.auth.oauth2.GoogleCredentials; +import com.google.firebase.messaging.*; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import okhttp3.*; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.core.io.ClassPathResource; +import org.springframework.http.HttpHeaders; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; -// TODO 향후 사용시 활성화 -//@Service +import java.io.IOException; +import java.util.Arrays; +import java.util.List; + + +@Service +@Slf4j @RequiredArgsConstructor public class FirebaseCloudMessageService { - private final FirebaseMessaging firebaseMessaging; - public void send(String token, String title, String body) throws FirebaseMessagingException { - Notification notification = Notification.builder() - .setTitle(title) - .setBody(body) + @Value("${fcm.key.path}") + private String SERVICE_ACCOUNT_JSON; + + @Value("${fcm.api.url}") + private String FCM_API_URL; + + @Value("${fcm.topic}") + private String topic; + + private final ObjectMapper objectMapper; + + /** + * 단일 기기 + * - Firebase에 메시지를 수신하는 함수 (헤더와 바디 직접 만들기) + */ + @Transactional + public String pushAlarm(FCMPushRequestDto dto) throws IOException { + String message = makeSingleMessage(dto); + sendPushMessage(message); + return "success"; + } + +// /** +// * 다수 기기 +// * - Firebase에 메시지를 수신하는 함수 (동일한 메시지를 2명 이상의 유저에게 발송) +// */ +// public String multipleSendByToken(FCMPushRequestDto request, List userList) { +// +// // User 리스트에서 FCM 토큰만 꺼내와서 리스트로 저장 +// List tokenList = userList.stream() +// .map(User::getFcmToken).toList(); +// +// // 2명만 있다고 가정 +// log.info("tokenList: {}🌈, {}🌈",tokenList.get(0), tokenList.get(1)); +// +// MulticastMessage message = makeMultipleMessage(request, tokenList); +// +// try { +// BatchResponse response = FirebaseMessaging.getInstance().sendMulticast(message); +// log.info("다수 기기 알림 전송 성공 ! successCount: " + response.getSuccessCount() + " messages were sent successfully"); +// log.info("알림 전송: {}", response.getResponses().toString()); +// +// return "알림을 성공적으로 전송했습니다. \ntargetUserId = 1." + tokenList.get(0) + ", \n\n2." + tokenList.get(1); +// } catch (FirebaseMessagingException e) { +// log.error("다수기기 푸시메시지 전송 실패 - FirebaseMessagingException: {}", e.getMessage()); +// throw new IllegalArgumentException(ErrorType.FAIL_TO_SEND_PUSH_ALARM.getMessage()); +// } +// } + + /** + * 주제 구독 등록 및 취소 + * - 특정 타깃 토큰 없이 해당 주제를 구독한 모든 유저에 푸시 전송 + */ + @Transactional + public String pushTopicAlarm(FCMPushRequestDto request) throws IOException { + + String message = makeTopicMessage(request); + sendPushMessage(message); + return "알림을 성공적으로 전송했습니다. targetUserId = " + request.getTargetToken(); + } + + // Topic 구독 설정 - application.yml에서 topic명 관리 + // 단일 요청으로 최대 1000개의 기기를 Topic에 구독 등록 및 취소할 수 있다. + + public void subscribe() throws FirebaseMessagingException { + // These registration tokens come from the client FCM SDKs. + List registrationTokens = Arrays.asList( + "YOUR_REGISTRATION_TOKEN_1", + // ... + "YOUR_REGISTRATION_TOKEN_n" + ); + + // Subscribe the devices corresponding to the registration tokens to the topic. + TopicManagementResponse response = FirebaseMessaging.getInstance().subscribeToTopic( + registrationTokens, topic); + + log.info(response.getSuccessCount() + " tokens were subscribed successfully"); + } + + // Topic 구독 취소 + public void unsubscribe() throws FirebaseMessagingException { + // These registration tokens come from the client FCM SDKs. + List registrationTokens = Arrays.asList( + "YOUR_REGISTRATION_TOKEN_1", + // ... + "YOUR_REGISTRATION_TOKEN_n" + ); + + // Unsubscribe the devices corresponding to the registration tokens from the topic. + TopicManagementResponse response = FirebaseMessaging.getInstance().unsubscribeFromTopic( + registrationTokens, topic); + + log.info(response.getSuccessCount() + " tokens were unsubscribed successfully"); + } + + // 요청 파라미터를 FCM의 body 형태로 만들어주는 메서드 [단일 기기] + private String makeSingleMessage(FCMPushRequestDto request) throws JsonProcessingException { + + FCMMessage fcmMessage = FCMMessage.builder() + .message(FCMMessage.Message.builder() + .token(request.getTargetToken()) // 1:1 전송 시 반드시 필요한 대상 토큰 설정 + .notification(FCMMessage.Notification.builder() + .title(request.getTitle()) + .body(request.getBody()) + .image("") + .build()) + .build() + ).validateOnly(false) .build(); - Message message = Message.builder() - .setToken(token) - .setNotification(notification) + return objectMapper.writeValueAsString(fcmMessage); + } + + // 요청 파라미터를 FCM의 body 형태로 만들어주는 메서드 [주제 구독] + private String makeTopicMessage(FCMPushRequestDto request) throws JsonProcessingException { + + FCMMessage fcmMessage = FCMMessage.builder() + .message(FCMMessage.Message.builder() + .topic(topic) // 토픽 구독에서 반드시 필요한 설정 (token 지정 x) + .notification(FCMMessage.Notification.builder() + .title(request.getTitle()) + .body(request.getBody()) + .image("") + .build()) + .build() + ).validateOnly(false) .build(); - firebaseMessaging.send(message); + return objectMapper.writeValueAsString(fcmMessage); + } + + // 요청 파라미터를 FCM의 body 형태로 만들어주는 메서드 [다수 기기] + private static MulticastMessage makeMultipleMessage(FCMPushRequestDto request, List tokenList) { + MulticastMessage message = MulticastMessage.builder() + .setNotification(Notification.builder() + .setTitle(request.getTitle()) + .setBody(request.getBody()) + .setImage("") + .build()) + .addAllTokens(tokenList) + .build(); + + log.info("message: {}", request.getTitle() +" "+ request.getBody()); + return message; + } + + // 실제 파이어베이스 서버로 푸시 메시지를 전송하는 메서드 + private void sendPushMessage(String message) throws IOException { + + OkHttpClient client = new OkHttpClient(); + RequestBody requestBody = RequestBody.create(message, MediaType.get("application/json; charset=utf-8")); + Request httpRequest = new Request.Builder() + .url(FCM_API_URL) + .post(requestBody) + .addHeader(HttpHeaders.AUTHORIZATION, "Bearer " + getAccessToken()) + .addHeader(HttpHeaders.CONTENT_TYPE, "application/json; UTF-8") + .build(); + + Response response = client.newCall(httpRequest).execute(); + + log.info("단일 기기 알림 전송 성공 ! successCount: 1 messages were sent successfully"); + log.info("알림 전송: {}", response.body().string()); + } + + // Firebase에서 Access Token 가져오기 + private String getAccessToken() throws IOException { + + GoogleCredentials googleCredentials = GoogleCredentials + .fromStream(new ClassPathResource(SERVICE_ACCOUNT_JSON).getInputStream()) + .createScoped(List.of("https://www.googleapis.com/auth/cloud-platform")); + googleCredentials.refreshIfExpired(); + log.info("getAccessToken() - googleCredentials: {} ", googleCredentials.getAccessToken().getTokenValue()); + + return googleCredentials.getAccessToken().getTokenValue(); + } + + public static FCMPushRequestDto sendTestPush(String targetToken) { + return FCMPushRequestDto.builder() + .targetToken(targetToken) + .title("❗️FCM 테스트 ❗️") + .body("백엔드 개발 화이팅 !") + .build(); } }