diff --git a/src/main/java/com/manchui/domain/controller/ChatController.java b/src/main/java/com/manchui/domain/controller/ChatController.java index 622b73d..9488dcb 100644 --- a/src/main/java/com/manchui/domain/controller/ChatController.java +++ b/src/main/java/com/manchui/domain/controller/ChatController.java @@ -19,7 +19,9 @@ import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; import reactor.core.publisher.Mono; +import reactor.core.scheduler.Schedulers; +import java.security.Principal; import java.time.LocalDateTime; @RestController @@ -54,6 +56,25 @@ public Mono>> chat(@DestinationVariable Str }).then(Mono.just(ResponseEntity.ok().body(SuccessResponse.successWithNoData("메시지 전송 성공")))); } + @MessageMapping("chat.leave.{roomId}") + public Mono>> chatRoomLeave(@DestinationVariable String roomId, + @RequestBody ChatMessageRequest chatMessageRequest, + Principal principal){ + + return chatMessageService.chatQuiteMessageSave(chatMessageRequest, roomId).flatMap(chatMessage -> { + // 채팅방 나가기 메시지 전송 + rabbitTemplate.convertAndSend("chat.exchange", "room." + roomId, new ChatMessageResponse( + chatMessageRequest.getSender(), chatMessageRequest.getSender() + chatMessageRequest.getMessage(), chatMessage.getChatMessageType(), LocalDateTime.now())); + + // 블로킹 JPA 메서드는 별도 스레드에서 실행 + // 채팅방 유저 목록에서 유저 softDelete + return Mono.fromCallable(() -> { + chatRoomService.chatRoomQuite(principal.getName(), roomId); + return null; + }).subscribeOn(Schedulers.boundedElastic()); + }).then(Mono.just(ResponseEntity.ok().body(SuccessResponse.successWithNoData("채팅방 나기기 성공")))); + } + @GetMapping("/api/chat/user/list/{roomId}") public ResponseEntity> chatRoomUserList(@PathVariable String roomId) { diff --git a/src/main/java/com/manchui/domain/dto/chat/ChatMessageRequest.java b/src/main/java/com/manchui/domain/dto/chat/ChatMessageRequest.java index 4a08fc4..699dcd2 100644 --- a/src/main/java/com/manchui/domain/dto/chat/ChatMessageRequest.java +++ b/src/main/java/com/manchui/domain/dto/chat/ChatMessageRequest.java @@ -1,8 +1,10 @@ package com.manchui.domain.dto.chat; +import lombok.AllArgsConstructor; import lombok.Getter; @Getter +@AllArgsConstructor public class ChatMessageRequest { private String sender; diff --git a/src/main/java/com/manchui/domain/dto/chat/ChatRoomListDetail.java b/src/main/java/com/manchui/domain/dto/chat/ChatRoomListDetail.java index 862c03b..27bc5dc 100644 --- a/src/main/java/com/manchui/domain/dto/chat/ChatRoomListDetail.java +++ b/src/main/java/com/manchui/domain/dto/chat/ChatRoomListDetail.java @@ -15,4 +15,5 @@ public class ChatRoomListDetail { private int userNum; private LocalDateTime lastMessageTime; private String lastMessage; + private String lastMessageUserName; } diff --git a/src/main/java/com/manchui/domain/entity/mongodb/ChatMessageType.java b/src/main/java/com/manchui/domain/entity/mongodb/ChatMessageType.java index dc18455..b8681af 100644 --- a/src/main/java/com/manchui/domain/entity/mongodb/ChatMessageType.java +++ b/src/main/java/com/manchui/domain/entity/mongodb/ChatMessageType.java @@ -2,5 +2,5 @@ public enum ChatMessageType { - ENTER, MESSAGE + ENTER, MESSAGE, QUITE, OPEN } diff --git a/src/main/java/com/manchui/domain/repository/ChatRoomUserRepository.java b/src/main/java/com/manchui/domain/repository/ChatRoomUserRepository.java index fa9d16c..c6b7efb 100644 --- a/src/main/java/com/manchui/domain/repository/ChatRoomUserRepository.java +++ b/src/main/java/com/manchui/domain/repository/ChatRoomUserRepository.java @@ -11,9 +11,11 @@ public interface ChatRoomUserRepository extends JpaRepository { - List findByChatRoomEquals(ChatRoom chatRoom); + List findByChatRoomEqualsAndDeletedAtIsNull(ChatRoom chatRoom); + + Optional findByUserEqualsAndChatRoomEqualsAndDeletedAtIsNull(User user, ChatRoom chatRoom); Optional findByUserEqualsAndChatRoomEquals(User user, ChatRoom chatRoom); - List findByUser(User user); + List findByUserAndDeletedAtIsNull(User user); } diff --git a/src/main/java/com/manchui/domain/repository/mongodb/ChatMessageRepository.java b/src/main/java/com/manchui/domain/repository/mongodb/ChatMessageRepository.java index bd1f4b3..d7d6251 100644 --- a/src/main/java/com/manchui/domain/repository/mongodb/ChatMessageRepository.java +++ b/src/main/java/com/manchui/domain/repository/mongodb/ChatMessageRepository.java @@ -7,13 +7,14 @@ import reactor.core.publisher.Flux; import reactor.core.publisher.Mono; +import java.time.LocalDateTime; public interface ChatMessageRepository extends ReactiveMongoRepository { - Flux findByRoomIdOrderByCreatedAtDesc(String roomId); + Flux findByRoomIdAndCreatedAtGreaterThanEqualOrderByCreatedAtDesc(String roomId, LocalDateTime createdAt); - @Query(value = "{ 'roomId': ?0, '_id': { $lt: ?1 } }", sort = "{ '_id': -1 }") - Flux findByRoomIdAndIdLessThanOrderByIdDesc(String roomId, ObjectId lastMessageId); + @Query(value = "{ 'roomId': ?0, '_id': { $lt: ?1 }, 'createdAt': { $lt: ?2 } }", sort = "{ '_id': -1 }") + Flux findByRoomIdAndIdLessThanAndCreatedAtGreaterThanEqualOrderByIdDesc(String roomId, ObjectId lastMessageId, LocalDateTime createdAt); Mono findFirstByRoomIdOrderByCreatedAtDesc(String roomId); } diff --git a/src/main/java/com/manchui/domain/service/ChatMessageService.java b/src/main/java/com/manchui/domain/service/ChatMessageService.java index 929e66a..1ba53a4 100644 --- a/src/main/java/com/manchui/domain/service/ChatMessageService.java +++ b/src/main/java/com/manchui/domain/service/ChatMessageService.java @@ -18,8 +18,11 @@ import org.bson.types.ObjectId; import org.springframework.amqp.rabbit.core.RabbitTemplate; import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.transaction.support.TransactionTemplate; import reactor.core.publisher.Mono; import reactor.core.scheduler.Schedulers; +import reactor.util.function.Tuples; import java.time.LocalDateTime; import java.util.List; @@ -35,6 +38,7 @@ public class ChatMessageService { private final UserRepository userRepository; private final ChatRoomRepository chatRoomRepository; private final ChatRoomUserRepository chatRoomUserRepository; + private final TransactionTemplate transactionTemplate; // 채팅 저장 메서드 public Mono chatMessageSave(ChatMessageRequest chatMessageRequest, String roomId) { @@ -43,46 +47,80 @@ public Mono chatMessageSave(ChatMessageRequest chatMessageRequest, chatMessageRequest.getMessage(), LocalDateTime.now())); } + public Mono chatQuiteMessageSave(ChatMessageRequest chatMessageRequest, String roomId){ + + return chatMessageRepository.save(new ChatMessage(roomId, ChatMessageType.QUITE,chatMessageRequest.getSender(), + chatMessageRequest.getSender() + chatMessageRequest.getMessage(), LocalDateTime.now())); + } + + public Mono chatRoomOpenMessageSave(ChatMessageRequest chatMessageRequest, String roomId){ + return chatMessageRepository.save(new ChatMessage(roomId, ChatMessageType.OPEN,chatMessageRequest.getSender(), + chatMessageRequest.getMessage(), LocalDateTime.now())); + } + //채팅방 입장 및 채팅 목록 조회 메서드 + @Transactional public Mono findChatList(CustomUserDetails customUserDetails, String roomId, ObjectId lastMessageId, int limit, RabbitTemplate rabbitTemplate) { // Blocking(JPA) 작업을 다른 스레드풀에서 수행하기 위해 fromCallable 사용 - return Mono.fromCallable(() -> { - - // 블로킹(JPA) 코드 실행 영역. - // 이벤트 루프가 아닌 별도 쓰레드에서 처리할 예정이므로, - // 여기서 JPA 호출을 수행해도 WebFlux 이벤트 루프를 방해하지 않는다. - - // 유저 정보와 채팅방 정보를 블로킹 방식으로 조회 - String userEmail = customUserDetails.getUsername(); - User user = userRepository.findByEmail(userEmail); - ChatRoom chatRoom = chatRoomRepository.findByRoomId(roomId); - - // 해당 유저가 채팅방 참여자가 맞는지 확인 후, 없으면 새로 저장 및 입장 메시지 - Optional chatRoomUser = chatRoomUserRepository.findByUserEqualsAndChatRoomEquals(user, chatRoom); - if (chatRoomUser.isEmpty()) { - chatRoomUserRepository.save(new ChatRoomUser(user, chatRoom)); - chatMessageRepository.save(new ChatMessage(roomId, ChatMessageType.ENTER, user.getName(), user.getName() + " 님이 입장 하셨습니다.", LocalDateTime.now())).block(); - rabbitTemplate.convertAndSend("chat.exchange", "room." + roomId, new ChatMessageResponse( - user.getName(), user.getName() + " 님이 입장 하셨습니다.", ChatMessageType.MESSAGE,LocalDateTime.now())); - } + return Mono.fromCallable(() -> + transactionTemplate.execute(status -> { + // 블로킹(JPA) 코드 실행 영역. + // 이벤트 루프가 아닌 별도 쓰레드에서 처리할 예정이므로, + // 여기서 JPA 호출을 수행해도 WebFlux 이벤트 루프를 방해하지 않는다. - // 블로킹 영역에서 최종적으로 roomId만 반환 - // (이후 flatMapMany로 ReactiveMongoRepository를 호출하기 위해서) - return roomId; - }) + // 유저 정보와 채팅방 정보를 블로킹 방식으로 조회 + String userEmail = customUserDetails.getUsername(); + User user = userRepository.findByEmail(userEmail); + ChatRoom chatRoom = chatRoomRepository.findByRoomId(roomId); + + // 해당 유저가 채팅방 참여자가 맞는지 확인 후, 없으면 새로 저장 및 입장 메시지 + // DeleteatAt 상관없이 조회 후에 delete 가 null 이면 그냥 새로운 채팅방 회원, deleteatat의 값이 있으면 deleatedat null로 변환후, + Optional chatRoomUser = chatRoomUserRepository.findByUserEqualsAndChatRoomEquals(user, chatRoom); + LocalDateTime chatRoomUserUpdatedAt = null; + // 채팅방에 유저가 속해있으면 채팅방 입장 시간 조회 + if (chatRoomUser.isPresent()) { + chatRoomUserUpdatedAt = chatRoomUser.get().getUpdatedAt(); + } + + if (chatRoomUser.isEmpty()) { + // 채팅방 첫입장 + chatRoomUserUpdatedAt = chatRoomUserRepository.save(new ChatRoomUser(user, chatRoom)).getUser().getUpdatedAt(); + chatMessageRepository.save(new ChatMessage(roomId, ChatMessageType.ENTER, user.getName(), user.getName() + " 님이 입장 하셨습니다.", LocalDateTime.now())).block(); + rabbitTemplate.convertAndSend("chat.exchange", "room." + roomId, new ChatMessageResponse( + user.getName(), user.getName() + " 님이 입장 하셨습니다.", ChatMessageType.ENTER, LocalDateTime.now())); + }else if(chatRoomUser.get().getDeletedAt() != null){ + // 채팅방 재입장 + chatRoomUser.get().restore(); + chatRoomUser.get().updateTime(); + chatRoomUserRepository.flush(); + chatRoomUserUpdatedAt = chatRoomUser.get().getUpdatedAt(); + + chatMessageRepository.save(new ChatMessage(roomId, ChatMessageType.ENTER, user.getName(), user.getName() + " 님이 입장 하셨습니다.", chatRoomUserUpdatedAt)).block(); + rabbitTemplate.convertAndSend("chat.exchange", "room." + roomId, new ChatMessageResponse( + user.getName(), user.getName() + " 님이 입장 하셨습니다.", ChatMessageType.ENTER, chatRoomUserUpdatedAt)); + } + + // 블로킹 영역에서 최종적으로 roomId만 반환 + // (이후 flatMapMany로 ReactiveMongoRepository를 호출하기 위해서) + return Tuples.of(roomId, chatRoomUserUpdatedAt); + }) + ) // subscribeOn: 위의 fromCallable 블록을 별도의 쓰레드 풀(boundedElastic)에서 실행 .subscribeOn(Schedulers.boundedElastic()) // flatMapMany로 넘겨 받은 roomId로 리액티브 MongoDB 쿼리 수행 - .flatMapMany(rId -> { + .flatMapMany(tuple -> { + + String rId = tuple.getT1(); + LocalDateTime updatedAt = tuple.getT2(); // lastMessageId == null이면 최신 메시지 조회 if (lastMessageId == null) { // 초기 요청 (최신 메시지 limit + 1개) - return chatMessageRepository.findByRoomIdOrderByCreatedAtDesc(rId) - .take(limit + 1); // // limit+1개 가져와서 다음 페이지 여부 확인 + return chatMessageRepository.findByRoomIdAndCreatedAtGreaterThanEqualOrderByCreatedAtDesc(rId, updatedAt) + .take(limit + 1); // limit+1개 가져와서 다음 페이지 여부 확인 } else { // 이후 요청 (lastMessageId 기준) - return chatMessageRepository.findByRoomIdAndIdLessThanOrderByIdDesc(rId, lastMessageId) + return chatMessageRepository.findByRoomIdAndIdLessThanAndCreatedAtGreaterThanEqualOrderByIdDesc(rId, lastMessageId, updatedAt) .take(limit + 1); } }) diff --git a/src/main/java/com/manchui/domain/service/ChatRoomService.java b/src/main/java/com/manchui/domain/service/ChatRoomService.java index 7d83a75..1fe0c96 100644 --- a/src/main/java/com/manchui/domain/service/ChatRoomService.java +++ b/src/main/java/com/manchui/domain/service/ChatRoomService.java @@ -12,7 +12,9 @@ import com.manchui.global.exception.CustomException; import com.manchui.global.exception.ErrorCode; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; import java.util.Comparator; @@ -21,6 +23,7 @@ @Service @RequiredArgsConstructor +@Slf4j public class ChatRoomService { private final ChatRoomUserRepository chatRoomUserRepository; @@ -35,7 +38,7 @@ public ChatRoomUserListResponse chatRoomUserList(String roomId) { ChatRoom chatRoom = chatRoomRepository.findByRoomId(roomId); - List userList = chatRoomUserRepository.findByChatRoomEquals(chatRoom); + List userList = chatRoomUserRepository.findByChatRoomEqualsAndDeletedAtIsNull(chatRoom); List userInfoList = userList.stream().map(m -> new UserInfo( m.getUser().getName(), @@ -51,7 +54,7 @@ public ChatRoomListResponse chatRoomList(CustomUserDetails customUserDetails) { String userEmail = customUserDetails.getUsername(); User user = userRepository.findByEmail(userEmail); // 사용자가 속하 ChatRoomUser 조회 - List chatRoomUsers = chatRoomUserRepository.findByUser(user); + List chatRoomUsers = chatRoomUserRepository.findByUserAndDeletedAtIsNull(user); // ChatRoomUser -> DTO(ChatRoomListDetail) 변환 List chatRoomListDetails = chatRoomUsers.stream().map((m -> { ChatRoom chatRoom = m.getChatRoom(); @@ -59,13 +62,25 @@ public ChatRoomListResponse chatRoomList(CustomUserDetails customUserDetails) { () -> new CustomException(ErrorCode.GATHERING_NOT_FOUND)); Image image = imageRepository.findByGatheringId(gathering.getId()); - List chatRoomEquals = chatRoomUserRepository.findByChatRoomEquals(chatRoom); + List chatRoomEquals = chatRoomUserRepository.findByChatRoomEqualsAndDeletedAtIsNull(chatRoom); ChatMessage lastMessage = chatMessageRepository.findFirstByRoomIdOrderByCreatedAtDesc(chatRoom.getRoomId()).block(); return new ChatRoomListDetail(m.getChatRoom().getRoomId(), image.getFilePath(), gathering.getGroupName(), - chatRoomEquals.size(), lastMessage.getCreatedAt(), lastMessage.getMessage()); + chatRoomEquals.size(), lastMessage.getCreatedAt(), lastMessage.getMessage(), lastMessage.getSender()); })).sorted(Comparator.comparing(ChatRoomListDetail::getLastMessageTime).reversed()).collect(Collectors.toList()); return new ChatRoomListResponse(chatRoomListDetails); } + + // 채팅방에 속한 사용자 softDelete + @Transactional + public void chatRoomQuite(String email, String roomId){ + + User user = userRepository.findByEmail(email); + ChatRoom chatRoom = chatRoomRepository.findByRoomId(roomId); + ChatRoomUser chatRoomUser = chatRoomUserRepository.findByUserEqualsAndChatRoomEqualsAndDeletedAtIsNull(user, chatRoom).orElseThrow( + () -> new CustomException(ErrorCode.MEMBER_NOT_IN_CHATROOM) + ); + chatRoomUser.softDelete(); + } } diff --git a/src/main/java/com/manchui/domain/service/GatheringServiceImpl.java b/src/main/java/com/manchui/domain/service/GatheringServiceImpl.java index 5032dba..7210bd7 100644 --- a/src/main/java/com/manchui/domain/service/GatheringServiceImpl.java +++ b/src/main/java/com/manchui/domain/service/GatheringServiceImpl.java @@ -2,6 +2,7 @@ import com.manchui.domain.dto.CustomUserDetails; import com.manchui.domain.dto.UserInfo; +import com.manchui.domain.dto.chat.ChatMessageRequest; import com.manchui.domain.dto.gathering.*; import com.manchui.domain.dto.review.ReviewDetailPagingResponse; import com.manchui.domain.dto.review.ReviewInfo; @@ -57,6 +58,9 @@ public class GatheringServiceImpl implements GatheringService { private final ChatRoomUserRepository chatRoomUserRepository; + private final ChatMessageService chatMessageService; + + /** * 0. 모임 생성 * 작성자 : 오예령 @@ -117,8 +121,10 @@ public GatheringCreateResponse createGathering(String email, GatheringCreateRequ } else { // 2. 모임 및 이미지 객체 저장 ChatRoom chatRoom = new ChatRoom(UUID.randomUUID().toString()); - chatRoomRepository.save(chatRoom); + String roomId = chatRoomRepository.save(chatRoom).getRoomId(); chatRoomUserRepository.save(new ChatRoomUser(user, chatRoom)); + chatMessageService.chatRoomOpenMessageSave(new ChatMessageRequest(user.getName(), user.getName() + "님이 채팅방을 개설하였습니다."), roomId).block(); + Gathering gathering = gatheringStore.saveGathering(createRequest, user, gatheringDate, dueDate, chatRoom); imageService.uploadGatheringImage(createRequest.getGatheringImage(), gathering.getId(), false); diff --git a/src/main/java/com/manchui/global/exception/ErrorCode.java b/src/main/java/com/manchui/global/exception/ErrorCode.java index 0fe3c1b..4503daa 100644 --- a/src/main/java/com/manchui/global/exception/ErrorCode.java +++ b/src/main/java/com/manchui/global/exception/ErrorCode.java @@ -75,7 +75,8 @@ public enum ErrorCode { NOTIFICATION_CANNOT_BE_DELETED(HttpStatus.BAD_REQUEST, "삭제할 수 없는 알림입니다.?"), // chat - CHATROOM_NOT_FOUND(HttpStatus.BAD_REQUEST, "존재하지 않는 채팅방 입니다."); + CHATROOM_NOT_FOUND(HttpStatus.BAD_REQUEST, "존재하지 않는 채팅방 입니다."), + MEMBER_NOT_IN_CHATROOM(HttpStatus.NOT_FOUND, "회원이 해당 채팅방에 속하지 않습니다."); private final HttpStatus httpStatus; private final String message; diff --git a/src/main/java/com/manchui/global/handler/StompHandler.java b/src/main/java/com/manchui/global/handler/StompHandler.java index 7c4b129..309429c 100644 --- a/src/main/java/com/manchui/global/handler/StompHandler.java +++ b/src/main/java/com/manchui/global/handler/StompHandler.java @@ -1,5 +1,8 @@ package com.manchui.global.handler; +import com.manchui.domain.dto.CustomUserDetails; +import com.manchui.domain.entity.User; +import com.manchui.domain.repository.UserRepository; import com.manchui.domain.service.RedisRefreshTokenService; import com.manchui.global.exception.CustomException; import com.manchui.global.exception.ErrorCode; @@ -15,6 +18,10 @@ import org.springframework.messaging.simp.stomp.StompCommand; import org.springframework.messaging.simp.stomp.StompHeaderAccessor; import org.springframework.messaging.support.ChannelInterceptor; +import org.springframework.messaging.support.MessageHeaderAccessor; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.stereotype.Component; @Component @@ -25,18 +32,25 @@ public class StompHandler implements ChannelInterceptor { private final JWTUtil jwtUtil; private final RedisRefreshTokenService redisRefreshTokenService; + private final UserRepository userRepository; // STOMP 메시지 전송 전에 가로채서 처리할 로직 @Override public Message preSend(Message message, MessageChannel channel) { // STOMP 헤더를 핸들링하기 위한 액세서 생성 - StompHeaderAccessor accessor = StompHeaderAccessor.wrap(message); + StompHeaderAccessor accessor = MessageHeaderAccessor.getAccessor(message, StompHeaderAccessor.class); // 만약 CONNECT 커맨드라면, JWT 검증 수행 if(StompCommand.CONNECT.equals(accessor.getCommand())){ String authorizationHeader = String.valueOf(accessor.getFirstNativeHeader("Authorization")); validateAccessToken(authorizationHeader); + + // 인증 정보(Authentication)를 SecurityContext에서 가져와 STOMP 메시지의 사용자(User)로 설정 + Authentication auth = SecurityContextHolder.getContext().getAuthentication(); + if (auth != null) { + accessor.setUser(auth); // STOMP 메시지의 Principal에 사용자 정보를 저장 + } } // 정상 처리된 경우 메시지를 그대로 리턴 @@ -78,5 +92,14 @@ private void validateAccessToken(String authorization) { if (!redisRefreshTokenService.existsByAccessToken(userEmail)) { throw new CustomException(ErrorCode.INVALID_ACCESS_TOKEN); } + + User user = userRepository.findByEmail(userEmail); + CustomUserDetails customUserDetails = new CustomUserDetails(user); + + // Authentication 객체 생성 + Authentication authentication = new UsernamePasswordAuthenticationToken(customUserDetails, null, customUserDetails.getAuthorities()); + + // SecurityContext 에 등록 + SecurityContextHolder.getContext().setAuthentication(authentication); } }