diff --git a/src/main/java/com/assu/server/domain/chat/controller/ChatController.java b/src/main/java/com/assu/server/domain/chat/controller/ChatController.java index d7893a9b..4f780063 100644 --- a/src/main/java/com/assu/server/domain/chat/controller/ChatController.java +++ b/src/main/java/com/assu/server/domain/chat/controller/ChatController.java @@ -2,8 +2,11 @@ import com.assu.server.domain.chat.dto.ChatRequestDTO; import com.assu.server.domain.chat.dto.ChatResponseDTO; +import com.assu.server.domain.chat.dto.ChatRoomUpdateDTO; +import com.assu.server.domain.chat.repository.MessageRepository; import com.assu.server.domain.chat.service.ChatService; import com.assu.server.global.apiPayload.code.status.SuccessStatus; +import com.assu.server.global.util.PresenceTracker; import com.assu.server.global.util.PrincipalDetails; import io.swagger.v3.oas.annotations.Operation; import lombok.RequiredArgsConstructor; @@ -24,6 +27,8 @@ public class ChatController { private final ChatService chatService; private final SimpMessagingTemplate simpMessagingTemplate; + private final PresenceTracker presenceTracker; + private final MessageRepository messageRepository; @Operation( summary = "채팅방을 생성하는 API", @@ -61,10 +66,33 @@ public BaseResponse> ) @MessageMapping("/send") public void handleMessage(@Payload ChatRequestDTO.ChatMessageRequestDTO request) { - log.info("[WS] handleMessage IN: {}", request); // ★ 호출 여부 확인 - ChatResponseDTO.SendMessageResponseDTO response = chatService.handleMessage(request); - log.info("[WS] handleMessage SAVED id={}", response.messageId()); // 저장 확인용 - simpMessagingTemplate.convertAndSend("/sub/chat/" + request.roomId(), response); + // 먼저 접속 여부 확인 후 unreadCount 계산 + boolean receiverInRoom = presenceTracker.isInRoom(request.getReceiverId(), request.getRoomId()); + int unreadForSender = receiverInRoom ? 0 : 1; + request.setUnreadCountForSender(unreadForSender); + + ChatResponseDTO.SendMessageResponseDTO saved = chatService.handleMessage(request); + simpMessagingTemplate.convertAndSend("/sub/chat/" + request.getRoomId(), saved); + + if (!receiverInRoom) { + Long totalUnreadCount = messageRepository.countUnreadMessagesByRoomAndReceiver( + request.getRoomId(), + request.getReceiverId() + ); + + ChatRoomUpdateDTO updateDTO = ChatRoomUpdateDTO.builder() + .roomId(request.getRoomId()) + .lastMessage(saved.message()) + .lastMessageTime(saved.sentAt()) + .unreadCount(totalUnreadCount) + .build(); + + simpMessagingTemplate.convertAndSendToUser( + request.getReceiverId().toString(), + "/queue/updates", + updateDTO + ); + } } @Operation( diff --git a/src/main/java/com/assu/server/domain/chat/converter/ChatConverter.java b/src/main/java/com/assu/server/domain/chat/converter/ChatConverter.java index 6082b4c3..b0b7877d 100644 --- a/src/main/java/com/assu/server/domain/chat/converter/ChatConverter.java +++ b/src/main/java/com/assu/server/domain/chat/converter/ChatConverter.java @@ -57,7 +57,8 @@ public static Message toMessageEntity(ChatRequestDTO.ChatMessageRequestDTO reque .chattingRoom(room) .sender(sender) .receiver(receiver) - .message(request.message()) + .message(request.getMessage()) + .unreadCount(request.getUnreadCountForSender()) .build(); } @@ -70,6 +71,7 @@ public static ChatResponseDTO.SendMessageResponseDTO toSendMessageDTO(Message me .message(message.getMessage()) .sentAt(message.getCreatedAt()) .messageType(message.getType()) + .unreadCountForSender(message.getUnreadCount()) .build(); } diff --git a/src/main/java/com/assu/server/domain/chat/dto/ChatMessageDTO.java b/src/main/java/com/assu/server/domain/chat/dto/ChatMessageDTO.java index ad20a6f1..1e0f3ac9 100644 --- a/src/main/java/com/assu/server/domain/chat/dto/ChatMessageDTO.java +++ b/src/main/java/com/assu/server/domain/chat/dto/ChatMessageDTO.java @@ -22,6 +22,9 @@ public class ChatMessageDTO { private String message; private LocalDateTime sendTime; + @JsonProperty("unreadCountForSender") + private Integer unreadCount; + @JsonProperty("isRead") private boolean isRead; diff --git a/src/main/java/com/assu/server/domain/chat/dto/ChatRequestDTO.java b/src/main/java/com/assu/server/domain/chat/dto/ChatRequestDTO.java index 90798fcd..575c46b9 100644 --- a/src/main/java/com/assu/server/domain/chat/dto/ChatRequestDTO.java +++ b/src/main/java/com/assu/server/domain/chat/dto/ChatRequestDTO.java @@ -1,6 +1,7 @@ package com.assu.server.domain.chat.dto; import lombok.Getter; +import lombok.Setter; public class ChatRequestDTO { @Getter @@ -9,10 +10,13 @@ public static class CreateChatRoomRequestDTO { private Long partnerId; } - public record ChatMessageRequestDTO( - Long roomId, - Long senderId, - Long receiverId, - String message - ) {} + @Getter + @Setter + public static class ChatMessageRequestDTO { + private Long roomId; + private Long senderId; + private Long receiverId; + private String message; + private int unreadCountForSender; + } } \ No newline at end of file diff --git a/src/main/java/com/assu/server/domain/chat/dto/ChatResponseDTO.java b/src/main/java/com/assu/server/domain/chat/dto/ChatResponseDTO.java index 29bbaa16..37df4725 100644 --- a/src/main/java/com/assu/server/domain/chat/dto/ChatResponseDTO.java +++ b/src/main/java/com/assu/server/domain/chat/dto/ChatResponseDTO.java @@ -33,8 +33,15 @@ public record SendMessageResponseDTO( String message, MessageType messageType, @JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd HH:mm:ss") - LocalDateTime sentAt - ) {} + LocalDateTime sentAt, + Integer unreadCountForSender + ) { + public SendMessageResponseDTO withUnreadCountForSender(Integer count) { + return new SendMessageResponseDTO( + messageId, roomId, senderId, receiverId, message, messageType, sentAt, count + ); + } + } // 메시지 읽음 처리 public record ReadMessageResponseDTO( diff --git a/src/main/java/com/assu/server/domain/chat/dto/ChatRoomUpdateDTO.java b/src/main/java/com/assu/server/domain/chat/dto/ChatRoomUpdateDTO.java new file mode 100644 index 00000000..b83ee0c0 --- /dev/null +++ b/src/main/java/com/assu/server/domain/chat/dto/ChatRoomUpdateDTO.java @@ -0,0 +1,15 @@ +package com.assu.server.domain.chat.dto; + +import lombok.Builder; +import lombok.Getter; + +import java.time.LocalDateTime; + +@Getter +@Builder +public class ChatRoomUpdateDTO { + private Long roomId; + private String lastMessage; + private LocalDateTime lastMessageTime; + private Long unreadCount; // 해당 채팅방의 총 안읽은 메시지 수 +} diff --git a/src/main/java/com/assu/server/domain/chat/entity/Message.java b/src/main/java/com/assu/server/domain/chat/entity/Message.java index e668060a..ba06e811 100644 --- a/src/main/java/com/assu/server/domain/chat/entity/Message.java +++ b/src/main/java/com/assu/server/domain/chat/entity/Message.java @@ -36,6 +36,8 @@ public class Message extends BaseEntity { private String message; + private Integer unreadCount; + // private LocalDateTime sendTime; // private LocalDateTime readTime; @@ -47,5 +49,6 @@ public class Message extends BaseEntity { public void markAsRead() { this.isRead = true; + this.unreadCount = 0; } } \ No newline at end of file diff --git a/src/main/java/com/assu/server/domain/chat/repository/MessageRepository.java b/src/main/java/com/assu/server/domain/chat/repository/MessageRepository.java index 8d5a316c..9fe4581c 100644 --- a/src/main/java/com/assu/server/domain/chat/repository/MessageRepository.java +++ b/src/main/java/com/assu/server/domain/chat/repository/MessageRepository.java @@ -7,7 +7,6 @@ import org.springframework.data.repository.query.Param; import java.util.List; -import java.util.Optional; public interface MessageRepository extends JpaRepository { @Query(""" @@ -18,6 +17,15 @@ public interface MessageRepository extends JpaRepository { """) List findUnreadMessagesByRoomAndReceiver(Long roomId, Long receiverId); + @Query(""" + SELECT COUNT(m) + FROM Message m + WHERE m.chattingRoom.id = :roomId + AND m.receiver.id = :receiverId + AND m.isRead = false + """) + Long countUnreadMessagesByRoomAndReceiver(@Param("roomId") Long roomId, @Param("receiverId") Long receiverId); + @Query(""" SELECT new com.assu.server.domain.chat.dto.ChatMessageDTO ( @@ -25,6 +33,7 @@ public interface MessageRepository extends JpaRepository { m.id, m.message, m.createdAt, + m.unreadCount, m.isRead, CASE WHEN m.sender.id = :memberId THEN true ELSE false diff --git a/src/main/java/com/assu/server/domain/chat/service/ChatServiceImpl.java b/src/main/java/com/assu/server/domain/chat/service/ChatServiceImpl.java index df09fde5..62feb6d2 100644 --- a/src/main/java/com/assu/server/domain/chat/service/ChatServiceImpl.java +++ b/src/main/java/com/assu/server/domain/chat/service/ChatServiceImpl.java @@ -83,22 +83,18 @@ public ChatResponseDTO.CreateChatRoomResponseDTO createChatRoom(ChatRequestDTO.C @Transactional public ChatResponseDTO.SendMessageResponseDTO handleMessage(ChatRequestDTO.ChatMessageRequestDTO request) { // 유효성 검사 - ChattingRoom room = chatRepository.findById(request.roomId()) + ChattingRoom room = chatRepository.findById(request.getRoomId()) .orElseThrow(() -> new DatabaseException(ErrorStatus.NO_SUCH_ROOM)); - Member sender = memberRepository.findById(request.senderId()) + Member sender = memberRepository.findById(request.getSenderId()) .orElseThrow(() -> new DatabaseException(ErrorStatus.NO_SUCH_MEMBER)); - Member receiver = memberRepository.findById(request.receiverId()) + Member receiver = memberRepository.findById(request.getReceiverId()) .orElseThrow(() -> new DatabaseException(ErrorStatus.NO_SUCH_MEMBER)); Message message = ChatConverter.toMessageEntity(request, room, sender, receiver); -// messageRepository.save(message); - log.info("saved message start"); Message saved = messageRepository.saveAndFlush(message); - log.info("saved message middle"); log.info("saved message id={}, roomId={}, senderId={}, receiverId={}", saved.getId(), room.getId(), sender.getId(), receiver.getId()); - log.info("saved message end"); boolean exists = messageRepository.existsById(saved.getId()); log.info("Saved? {}", exists); // true 아니면 트랜잭션/DB 문제 return ChatConverter.toSendMessageDTO(saved); diff --git a/src/main/java/com/assu/server/global/util/PresenceTracker.java b/src/main/java/com/assu/server/global/util/PresenceTracker.java new file mode 100644 index 00000000..e6f139b7 --- /dev/null +++ b/src/main/java/com/assu/server/global/util/PresenceTracker.java @@ -0,0 +1,90 @@ +package com.assu.server.global.util; + +import lombok.extern.slf4j.Slf4j; +import org.springframework.context.event.EventListener; +import org.springframework.messaging.simp.stomp.StompHeaderAccessor; +import org.springframework.stereotype.Component; +import org.springframework.web.socket.messaging.SessionDisconnectEvent; +import org.springframework.web.socket.messaging.SessionSubscribeEvent; +import org.springframework.web.socket.messaging.SessionUnsubscribeEvent; + +import java.security.Principal; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; + +@Component +@Slf4j +public class PresenceTracker { + private final Map> roomSubscribers = new ConcurrentHashMap<>(); + private final Map sessionToMember = new ConcurrentHashMap<>(); + private final Map> sessionToRooms = new ConcurrentHashMap<>(); + + private Long parseRoomId(String dest) { // "/sub/chat/26" -> 26 + if (dest == null) return null; + String[] p = dest.split("/"); + if (p.length >= 4 && "chat".equals(p[2])) return Long.valueOf(p[3]); + return null; + } + + private Long memberIdFrom(Principal user) { + if (user == null) return null; + // StompAuthChannelInterceptor 에서 Principal.name을 memberId로 넣어두었다고 가정 + return Long.valueOf(user.getName()); + } + + @EventListener + public void onSubscribe(SessionSubscribeEvent e) { + var acc = StompHeaderAccessor.wrap(e.getMessage()); + Long roomId = parseRoomId(acc.getDestination()); + Long memberId = memberIdFrom(e.getUser()); + if (roomId == null || memberId == null) return; + + String sessionId = acc.getSessionId(); + sessionToMember.put(sessionId, memberId); + sessionToRooms.computeIfAbsent(sessionId, k -> ConcurrentHashMap.newKeySet()).add(roomId); + roomSubscribers.computeIfAbsent(roomId, k -> ConcurrentHashMap.newKeySet()).add(memberId); + + log.debug("SUB: member {} -> room {}", memberId, roomId); + } + + @EventListener + public void onUnsubscribe(SessionUnsubscribeEvent e) { + var acc = StompHeaderAccessor.wrap(e.getMessage()); + String sessionId = acc.getSessionId(); + var rooms = sessionToRooms.getOrDefault(sessionId, Set.of()); + Long memberId = sessionToMember.get(sessionId); + if (memberId != null) { + for (Long roomId : rooms) { + var set = roomSubscribers.get(roomId); + if (set != null) { + set.remove(memberId); + if (set.isEmpty()) roomSubscribers.remove(roomId); + } + } + } + sessionToRooms.remove(sessionId); + log.debug("UNSUB: session {}", sessionId); + } + + @EventListener + public void onDisconnect(SessionDisconnectEvent e) { + String sessionId = e.getSessionId(); + Long memberId = sessionToMember.remove(sessionId); + var rooms = sessionToRooms.remove(sessionId); + if (memberId != null && rooms != null) { + for (Long roomId : rooms) { + var set = roomSubscribers.get(roomId); + if (set != null) { + set.remove(memberId); + if (set.isEmpty()) roomSubscribers.remove(roomId); + } + } + } + log.debug("DISCONNECT: session {}", sessionId); + } + + public boolean isInRoom(Long memberId, Long roomId) { + return roomSubscribers.getOrDefault(roomId, Set.of()).contains(memberId); + } +}