diff --git a/build.gradle b/build.gradle index a6512c35..8cec1a07 100644 --- a/build.gradle +++ b/build.gradle @@ -61,6 +61,8 @@ dependencies { implementation 'org.redisson:redisson-spring-boot-starter:3.27.2' // OAuth implementation 'org.springframework.boot:spring-boot-starter-oauth2-client:3.5.0' + // WebSocket + implementation 'org.springframework.boot:spring-boot-starter-websocket' } tasks.named('test') { diff --git a/src/main/java/hanium/modic/backend/common/error/ErrorCode.java b/src/main/java/hanium/modic/backend/common/error/ErrorCode.java index 4afd8372..641ba03e 100644 --- a/src/main/java/hanium/modic/backend/common/error/ErrorCode.java +++ b/src/main/java/hanium/modic/backend/common/error/ErrorCode.java @@ -76,6 +76,12 @@ public enum ErrorCode { AI_IMAGE_PERMISSION_NOT_FOUND(HttpStatus.FORBIDDEN, "A-004", "AI 이미지 생성 권한이 없습니다."), AI_REQUEST_TICKET_PROCESSING_FAIL_EXCEPTION(HttpStatus.INTERNAL_SERVER_ERROR, "A-005", "티켓 처리에 실패했습니다."), AI_REQUEST_TICKET_NOT_ENOUGH_EXCEPTION(HttpStatus.BAD_REQUEST, "AI-006", "티켓이 부족합니다."), + + // Chat + CHAT_ROOM_NOT_FOUND_EXCEPTION(HttpStatus.NOT_FOUND, "CH-001", "해당 채팅방을 찾을 수 없습니다."), + CHAT_MESSAGE_NOT_FOUND_EXCEPTION(HttpStatus.NOT_FOUND, "CH-002", "해당 채팅 메시지를 찾을 수 없습니다."), + CHAT_SELF_ROOM_CREATION_EXCEPTION(HttpStatus.BAD_REQUEST, "CH-003", "자기 자신과는 채팅방을 만들 수 없습니다."), + CHAT_ROOM_ACCESS_DENIED_EXCEPTION(HttpStatus.FORBIDDEN, "CH-004", "해당 채팅방에 접근할 권한이 없습니다."), ; private final HttpStatus status; diff --git a/src/main/java/hanium/modic/backend/common/websocket/config/WebSocketConfig.java b/src/main/java/hanium/modic/backend/common/websocket/config/WebSocketConfig.java new file mode 100644 index 00000000..114f60ff --- /dev/null +++ b/src/main/java/hanium/modic/backend/common/websocket/config/WebSocketConfig.java @@ -0,0 +1,47 @@ +package hanium.modic.backend.common.websocket.config; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.messaging.simp.config.ChannelRegistration; +import org.springframework.messaging.simp.config.MessageBrokerRegistry; +import org.springframework.web.socket.config.annotation.EnableWebSocketMessageBroker; +import org.springframework.web.socket.config.annotation.StompEndpointRegistry; +import org.springframework.web.socket.config.annotation.WebSocketMessageBrokerConfigurer; + +import hanium.modic.backend.common.jwt.JwtTokenProvider; +import hanium.modic.backend.common.websocket.interceptor.StompChannelInterceptor; +import hanium.modic.backend.common.websocket.interceptor.WebSocketAuthInterceptor; +import lombok.RequiredArgsConstructor; + +@Configuration +@EnableWebSocketMessageBroker +@RequiredArgsConstructor +public class WebSocketConfig implements WebSocketMessageBrokerConfigurer { + + private final JwtTokenProvider jwtTokenProvider; + private final StompChannelInterceptor stompChannelInterceptor; + + @Override + public void configureMessageBroker(MessageBrokerRegistry config) { + config.enableSimpleBroker("/topic"); // 브로커가 발행한 메세지를 클라이언트가 구독 + config.setApplicationDestinationPrefixes("/app"); // 클라이언트가 보낼 메세지를 Controller로 전달 + } + + @Override + public void registerStompEndpoints(StompEndpointRegistry registry) { + registry.addEndpoint("/ws/chat") + .setAllowedOriginPatterns("*") // TODO: 실제 도메인으로 변경 필요 + .addInterceptors(webSocketAuthInterceptor()) // 핸드세이크 과정 인터셉터, 인증용 + .withSockJS(); + } + + @Override + public void configureClientInboundChannel(ChannelRegistration registration) { + registration.interceptors(stompChannelInterceptor); + } + + @Bean + public WebSocketAuthInterceptor webSocketAuthInterceptor() { + return new WebSocketAuthInterceptor(jwtTokenProvider); + } +} \ No newline at end of file diff --git a/src/main/java/hanium/modic/backend/common/websocket/interceptor/StompChannelInterceptor.java b/src/main/java/hanium/modic/backend/common/websocket/interceptor/StompChannelInterceptor.java new file mode 100644 index 00000000..65ed462f --- /dev/null +++ b/src/main/java/hanium/modic/backend/common/websocket/interceptor/StompChannelInterceptor.java @@ -0,0 +1,67 @@ +package hanium.modic.backend.common.websocket.interceptor; + +import static hanium.modic.backend.common.error.ErrorCode.*; + +import java.security.Principal; +import java.util.Optional; + +import org.springframework.messaging.Message; +import org.springframework.messaging.MessageChannel; +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.stereotype.Component; + +import hanium.modic.backend.common.error.exception.AppException; +import hanium.modic.backend.common.jwt.JwtTokenProvider; +import hanium.modic.backend.domain.user.entity.UserEntity; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@Component +@RequiredArgsConstructor +public class StompChannelInterceptor implements ChannelInterceptor { + + private final JwtTokenProvider jwtTokenProvider; + + @Override + public Message preSend(Message message, MessageChannel channel) { + StompHeaderAccessor accessor = MessageHeaderAccessor.getAccessor(message, StompHeaderAccessor.class); + + // STOMP CONNECT 명령어가 아닌 경우, 메시지를 그대로 반환 + if (accessor == null || !StompCommand.CONNECT.equals(accessor.getCommand())) { + return message; + } + + // Authorization 헤더에서 Bearer 토큰 추출 + String token = Optional.ofNullable(accessor.getFirstNativeHeader("Authorization")) + .filter(auth -> auth.startsWith("Bearer ")) + .map(auth -> auth.substring(7)) + .orElse(null); + + if (token == null) { + log.warn("StompChannelInterceptor - STOMP 연결 실패 - Authorization 헤더 누락 또는 형식 오류"); + return null; + } + + // 토큰 유효성 검사 및 사용자 정보 추출 + try { + jwtTokenProvider.validateToken(token); + + UserEntity user = jwtTokenProvider.getUser(token) + .orElseThrow(() -> new AppException(USER_NOT_FOUND_EXCEPTION)); + + Principal principal = new UsernamePasswordAuthenticationToken(user.getId().toString(), null); + accessor.setUser(principal); + + log.debug("StompChannelInterceptor - STOMP 연결 인증 성공 - 사용자 ID: {}", user.getId()); + return message; + } catch (Exception e) { + log.warn("StompChannelInterceptor - STOMP 연결 인증 실패: {}", e.getMessage()); + return null; + } + } +} \ No newline at end of file diff --git a/src/main/java/hanium/modic/backend/common/websocket/interceptor/WebSocketAuthInterceptor.java b/src/main/java/hanium/modic/backend/common/websocket/interceptor/WebSocketAuthInterceptor.java new file mode 100644 index 00000000..1a666517 --- /dev/null +++ b/src/main/java/hanium/modic/backend/common/websocket/interceptor/WebSocketAuthInterceptor.java @@ -0,0 +1,85 @@ +package hanium.modic.backend.common.websocket.interceptor; + +import static hanium.modic.backend.common.error.ErrorCode.*; + +import java.security.Principal; +import java.util.Map; + +import org.springframework.http.server.ServerHttpRequest; +import org.springframework.http.server.ServerHttpResponse; +import org.springframework.web.socket.WebSocketHandler; +import org.springframework.web.socket.server.HandshakeInterceptor; + +import hanium.modic.backend.common.error.ErrorCode; +import hanium.modic.backend.common.error.exception.AppException; +import hanium.modic.backend.common.jwt.JwtTokenProvider; +import hanium.modic.backend.domain.user.entity.UserEntity; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@RequiredArgsConstructor +public class WebSocketAuthInterceptor implements HandshakeInterceptor { + + private final JwtTokenProvider jwtTokenProvider; + + // WebSocket 핸드세이킹 허용 여부 결정 메서드 + @Override + public boolean beforeHandshake( + ServerHttpRequest request, + ServerHttpResponse response, + WebSocketHandler wsHandler, + Map attributes + ) throws Exception { + String token = extractTokenFromRequest(request); + + if (token != null) { + try { + jwtTokenProvider.validateToken(token); + + UserEntity user = jwtTokenProvider.getUser(token) + .orElseThrow(() -> new AppException(USER_NOT_FOUND_EXCEPTION)); + attributes.put("userId", user.getId()); + log.debug("WebSocket 연결 인증 성공 - 사용자 ID: {}", user.getId()); + return true; + } catch (Exception e) { + log.error("WebSocket 토큰 파싱 실패: {}", e.getMessage()); + } + } + + log.warn("WebSocket 연결 인증 실패"); + return false; + } + + @Override + public void afterHandshake( + ServerHttpRequest request, + ServerHttpResponse response, + WebSocketHandler wsHandler, + Exception exception + ) { + // 핸드셰이크 후 처리 로직 + } + + // 추출된 토큰을 요청에서 가져오는 메서드 + private String extractTokenFromRequest(ServerHttpRequest request) { + // 헤더에서 Bearer 토큰 추출 시도 + String token = request.getHeaders().getFirst("Authorization"); + if (token != null && token.startsWith("Bearer ")) { + return token.substring(7); + } + + // 실패 시 쿼리 파라미터에서 token 추출 + String queryToken = request.getURI().getQuery(); + if (queryToken != null && queryToken.contains("token=")) { + String[] params = queryToken.split("&"); + for (String param : params) { + if (param.startsWith("token=")) { + return param.substring(6); + } + } + } + + return null; + } +} \ No newline at end of file diff --git a/src/main/java/hanium/modic/backend/domain/chat/entity/ChatMessageEntity.java b/src/main/java/hanium/modic/backend/domain/chat/entity/ChatMessageEntity.java new file mode 100644 index 00000000..b32fb814 --- /dev/null +++ b/src/main/java/hanium/modic/backend/domain/chat/entity/ChatMessageEntity.java @@ -0,0 +1,60 @@ +package hanium.modic.backend.domain.chat.entity; + +import hanium.modic.backend.common.entity.BaseEntity; +import hanium.modic.backend.domain.user.entity.UserEntity; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.Lob; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Table(name = "chat_messages") +@Entity +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class ChatMessageEntity extends BaseEntity { + + @Id + @Column(name = "id") + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "message_id", unique = true, nullable = false) + private String messageId; + + @Column(name = "chatRoom_id", nullable = false) + private Long chatRoomId; + + @Column(name = "sender_id", nullable = false) + private Long senderId; + + @Lob + @Column(name = "message", nullable = false) + private String message; + + @Column(name = "is_read", nullable = false) + private Boolean isRead = false; + + @Builder + private ChatMessageEntity(String messageId, ChatRoomEntity chatRoom, UserEntity sender, String message) { + this.messageId = messageId; + this.chatRoomId = chatRoom.getId(); + this.senderId = sender.getId(); + this.message = message; + this.isRead = false; + } + + // 메시지 읽음 처리 + public void markAsRead() { + this.isRead = true; + } +} \ No newline at end of file diff --git a/src/main/java/hanium/modic/backend/domain/chat/entity/ChatRoomEntity.java b/src/main/java/hanium/modic/backend/domain/chat/entity/ChatRoomEntity.java new file mode 100644 index 00000000..fcd2e8c7 --- /dev/null +++ b/src/main/java/hanium/modic/backend/domain/chat/entity/ChatRoomEntity.java @@ -0,0 +1,78 @@ +package hanium.modic.backend.domain.chat.entity; + +import hanium.modic.backend.common.entity.BaseEntity; +import hanium.modic.backend.domain.user.entity.UserEntity; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Table(name = "chat_rooms") +@Entity +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class ChatRoomEntity extends BaseEntity { + + @Id + @Column(name = "id") + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "user1_id", nullable = false) + private Long user1Id; + + @Column(name = "user2_id", nullable = false) + private Long user2Id; + + @Column(name = "user1_deleted", nullable = false) + private Boolean user1Deleted = false; + + @Column(name = "user2_deleted", nullable = false) + private Boolean user2Deleted = false; + + @Builder + private ChatRoomEntity(UserEntity user1, UserEntity user2) { + this.user1Id = user1.getId(); + this.user2Id = user2.getId(); + this.user1Deleted = false; + this.user2Deleted = false; + } + + // 유저 소프트 삭제 + public void deleteForUser(Long userId) { + if (user1Id.equals(userId)) { + this.user1Deleted = true; + } else if (user2Id.equals(userId)) { + this.user2Deleted = true; + } + } + + // 상대방 유저 반환 + public Long getOpponent(Long userId) { + if (user1Id.equals(userId)) { + return user2Id; + } + return user1Id; + } + + // 채팅방을 모든 유저가 삭제했는지 확인 + public boolean isDeleted() { + return user1Deleted && user2Deleted; + } + + // 특정 유저가 채팅방을 삭제했는지 확인 + public boolean isDeletedForUser(Long userId) { + if (user1Id.equals(userId)) { + return user1Deleted; + } else if (user2Id.equals(userId)) { + return user2Deleted; + } + return false; + } +} \ No newline at end of file diff --git a/src/main/java/hanium/modic/backend/domain/chat/repository/ChatMessageRepository.java b/src/main/java/hanium/modic/backend/domain/chat/repository/ChatMessageRepository.java new file mode 100644 index 00000000..dfd8c9d2 --- /dev/null +++ b/src/main/java/hanium/modic/backend/domain/chat/repository/ChatMessageRepository.java @@ -0,0 +1,48 @@ +package hanium.modic.backend.domain.chat.repository; + +import java.util.List; +import java.util.Optional; + +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import org.springframework.stereotype.Repository; + +import hanium.modic.backend.domain.chat.entity.ChatMessageEntity; + +@Repository +public interface ChatMessageRepository extends JpaRepository { + + Optional findByMessageId(String messageId); + + @Query("SELECT cm FROM ChatMessageEntity cm " + + "WHERE cm.chatRoomId = :chatRoomId " + + "ORDER BY cm.createAt DESC") + List findByChatRoomOrderByCreateAtDesc(@Param("chatRoomId") Long chatRoomId, Pageable pageable); + + @Query("SELECT cm FROM ChatMessageEntity cm " + + "WHERE cm.chatRoomId = :chatRoomId " + + "AND cm.id < :lastMessageId " + + "ORDER BY cm.createAt DESC") + List findByChatRoomWithPagination(@Param("chatRoomId") Long chatRoomId, + @Param("lastMessageId") Long lastMessageId, Pageable pageable); + + @Query("SELECT cm FROM ChatMessageEntity cm " + + "WHERE cm.chatRoomId = :chatRoomId " + + "ORDER BY cm.createAt DESC " + + "LIMIT 1") + Optional findLatestMessageByChatRoomId(@Param("chatRoomId") Long chatRoomId); + + @Query("SELECT COUNT(cm) FROM ChatMessageEntity cm " + + "WHERE cm.chatRoomId = :chatRoomId " + + "AND cm.senderId != :userId " + + "AND cm.isRead = false") + Long countUnreadMessages(@Param("chatRoomId") Long chatRoomId, @Param("userId") Long userId); + + @Query("SELECT cm FROM ChatMessageEntity cm " + + "WHERE cm.chatRoomId = :chatRoomId " + + "AND cm.senderId != :userId " + + "AND cm.isRead = false") + List findUnreadMessages(@Param("chatRoomId") Long chatRoomId, @Param("userId") Long userId); +} \ No newline at end of file diff --git a/src/main/java/hanium/modic/backend/domain/chat/repository/ChatRoomRepository.java b/src/main/java/hanium/modic/backend/domain/chat/repository/ChatRoomRepository.java new file mode 100644 index 00000000..e98ee64b --- /dev/null +++ b/src/main/java/hanium/modic/backend/domain/chat/repository/ChatRoomRepository.java @@ -0,0 +1,26 @@ +package hanium.modic.backend.domain.chat.repository; + +import java.util.List; +import java.util.Optional; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import org.springframework.stereotype.Repository; + +import hanium.modic.backend.domain.chat.entity.ChatRoomEntity; + +@Repository +public interface ChatRoomRepository extends JpaRepository { + + + @Query("SELECT cr FROM ChatRoomEntity cr " + + "WHERE (cr.user1Id = :userId AND cr.user1Deleted = false) " + + "OR (cr.user2Id = :userId AND cr.user2Deleted = false)") + List findActiveRoomsByUserId(@Param("userId") Long userId); + + @Query("SELECT cr FROM ChatRoomEntity cr " + + "WHERE ((cr.user1Id = :user1Id AND cr.user2Id = :user2Id) " + + "OR (cr.user1Id = :user2Id AND cr.user2Id = :user1Id))") + Optional findByUsers(@Param("user1Id") Long user1Id, @Param("user2Id") Long user2Id); +} \ No newline at end of file diff --git a/src/main/java/hanium/modic/backend/domain/chat/service/ChatService.java b/src/main/java/hanium/modic/backend/domain/chat/service/ChatService.java new file mode 100644 index 00000000..84f883dd --- /dev/null +++ b/src/main/java/hanium/modic/backend/domain/chat/service/ChatService.java @@ -0,0 +1,215 @@ +package hanium.modic.backend.domain.chat.service; + +import static hanium.modic.backend.common.error.ErrorCode.*; + +import java.util.List; +import java.util.UUID; +import java.util.stream.Collectors; + +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import hanium.modic.backend.common.error.exception.AppException; +import hanium.modic.backend.web.chat.dto.dto.ChatMessageDto; +import hanium.modic.backend.web.chat.dto.response.GetChatRoomsResponse; +import hanium.modic.backend.web.chat.dto.response.GetMessagesResponse; +import hanium.modic.backend.domain.chat.entity.ChatMessageEntity; +import hanium.modic.backend.domain.chat.entity.ChatRoomEntity; +import hanium.modic.backend.domain.chat.repository.ChatMessageRepository; +import hanium.modic.backend.domain.chat.repository.ChatRoomRepository; +import hanium.modic.backend.domain.user.entity.UserEntity; +import hanium.modic.backend.domain.user.repository.UserEntityRepository; +import lombok.RequiredArgsConstructor; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class ChatService { + + private final ChatRoomRepository chatRoomRepository; + private final ChatMessageRepository chatMessageRepository; + private final UserEntityRepository userRepository; + + // 채팅방 생성, 이미 있으면 기존 채팅방 반환 메서드 + @Transactional + public Long createOrGetChatRoom(final Long user1Id, final Long user2Id) { + // 자기 자신과의 채팅방 생성 방지 + if (user1Id.equals(user2Id)) { + throw new AppException(CHAT_SELF_ROOM_CREATION_EXCEPTION); + } + + return chatRoomRepository.findByUsers(user1Id, user2Id) + .map(ChatRoomEntity::getId) + .orElseGet(() -> { + final UserEntity user1 = getUserById(user1Id); + final UserEntity user2 = getUserById(user2Id); + + final ChatRoomEntity chatRoom = ChatRoomEntity.builder() + .user1(user1) + .user2(user2) + .build(); + + return chatRoomRepository.save(chatRoom).getId(); + }); + } + + // 채팅방 목록 조회 메서드 + public List getChatRooms(final Long userId) { + final List chatRooms = chatRoomRepository.findActiveRoomsByUserId(userId); + + return chatRooms.stream() + .map(room -> { + final UserEntity opponent = getUserById(room.getOpponent(userId)); + final ChatMessageEntity lastMessage = chatMessageRepository.findLatestMessageByChatRoomId(room.getId()) + .orElse(null); + final Long unreadCount = chatMessageRepository.countUnreadMessages(room.getId(), userId); + + return GetChatRoomsResponse.builder() + .chatRoomId(room.getId()) + .opponent(GetChatRoomsResponse.OpponentDto.builder() + .userId(opponent.getId()) + .nickname(opponent.getName()) + .profileImageUrl(opponent.getUserImageUrl()) + .build()) + .lastMessage(lastMessage != null ? lastMessage.getMessage() : null) + .lastMessageTime(lastMessage != null ? lastMessage.getCreateAt() : room.getCreateAt()) + .unreadCount(unreadCount) + .build(); + }) + .collect(Collectors.toList()); + } + + // 메시지 목록 조회 메서드 + public List getMessages( + final Long chatRoomId, + final String lastMessageId, + final int limit, + final Long userId + ) { + final ChatRoomEntity chatRoom = getChatRoomById(chatRoomId); + + validateChatRoomAccess(chatRoom, userId); // 채팅방 참여 권한 검증 + validateDeleteChatRoom(chatRoom, userId); // 채팅방이 유저에 의해 삭제되었는지 검증 + + final Pageable pageable = PageRequest.of(0, limit); + final List messages; + + if (lastMessageId != null) { + final ChatMessageEntity lastMessage = chatMessageRepository.findByMessageId(lastMessageId) + .orElseThrow(() -> new AppException(CHAT_MESSAGE_NOT_FOUND_EXCEPTION)); + + // lastMessageId 이전 메세지를 페이지네이션으로 조회 + messages = chatMessageRepository.findByChatRoomWithPagination(chatRoomId, lastMessage.getId(), pageable); + } else { + messages = chatMessageRepository.findByChatRoomOrderByCreateAtDesc(chatRoomId, pageable); + } + + return messages.stream() + .map(GetMessagesResponse::from) + .collect(Collectors.toList()); + } + + // 메시지 전송 메서드 + @Transactional + public ChatMessageDto sendMessage(final Long chatRoomId, final Long senderId, final String message) { + final ChatRoomEntity chatRoom = getChatRoomById(chatRoomId); + final UserEntity sender = getUserById(senderId); + + + validateChatRoomAccess(chatRoom, senderId); // 채팅방 참여 권한 검증 + validateDeleteChatRoom(chatRoom, senderId); // 채팅방이 유저에 의해 삭제되었는지 검증 + + final String messageId = UUID.randomUUID().toString(); + + final ChatMessageEntity chatMessage = ChatMessageEntity.builder() + .messageId(messageId) + .chatRoom(chatRoom) + .sender(sender) + .message(message) + .build(); + + final ChatMessageEntity savedMessage = chatMessageRepository.save(chatMessage); + + return ChatMessageDto.builder() + .messageId(messageId) + .roomId(chatRoomId.toString()) + .senderId(senderId) + .senderName(sender.getName()) + .message(message) + .timestamp(savedMessage.getCreateAt()) + .type(ChatMessageDto.MessageType.CHAT) + .build(); + } + + // 메시지 읽음 처리 메서드 + @Transactional + public void markMessagesAsRead(final Long chatRoomId, final String lastReadMessageId, final Long userId) { + final ChatRoomEntity chatRoom = getChatRoomById(chatRoomId); + + validateChatRoomAccess(chatRoom, userId); // 채팅방 참여 권한 검증 + validateDeleteChatRoom(chatRoom, userId); // 채팅방이 유저에 의해 삭제되었는지 검증 + + final ChatMessageEntity lastReadMessage = chatMessageRepository.findByMessageId(lastReadMessageId) + .orElseThrow(() -> new AppException(CHAT_MESSAGE_NOT_FOUND_EXCEPTION)); + + final List unreadMessages = chatMessageRepository.findUnreadMessages(chatRoomId, userId); + + for (final ChatMessageEntity message : unreadMessages) { + if (message.getId() <= lastReadMessage.getId()) { + message.markAsRead(); + } + } + + chatMessageRepository.saveAll(unreadMessages); + } + + // 채팅방 나가기 메서드 + @Transactional + public void deleteChatRoom(final Long chatRoomId, final Long userId) { + final ChatRoomEntity chatRoom = getChatRoomById(chatRoomId); + + validateChatRoomAccess(chatRoom, userId); // 채팅방 참여 권한 검증 + + chatRoom.deleteForUser(userId); + + if (chatRoom.isDeleted()) { + chatRoomRepository.delete(chatRoom); + } else { + chatRoomRepository.save(chatRoom); + } + } + + // User 조회 메서드 + private UserEntity getUserById(final Long userId) { + return userRepository.findById(userId) + .orElseThrow(() -> new AppException(USER_NOT_FOUND_EXCEPTION)); + } + + // ChatRoom 조회 메서드 + private ChatRoomEntity getChatRoomById(final Long chatRoomId) { + return chatRoomRepository.findById(chatRoomId) + .orElseThrow(() -> new AppException(CHAT_ROOM_NOT_FOUND_EXCEPTION)); + } + + // 채팅방을 유저나 나왔는지 확인하는 메서드 + private void validateDeleteChatRoom(final ChatRoomEntity chatRoom, final Long userId) { + if (chatRoom.isDeletedForUser(userId)) { + throw new AppException(CHAT_ROOM_NOT_FOUND_EXCEPTION); + } + } + + // 채팅방 참여 권한 검증 메서드 + private void validateChatRoomAccess(final ChatRoomEntity chatRoom, final Long userId) { + if (!chatRoom.getUser1Id().equals(userId) && !chatRoom.getUser2Id().equals(userId)) { + throw new AppException(CHAT_ROOM_ACCESS_DENIED_EXCEPTION); + } + } + + // WebSocket에서 사용할 권한 검증 메서드 (public) + public void validateUserAccessToChatRoom(final Long chatRoomId, final Long userId) { + final ChatRoomEntity chatRoom = getChatRoomById(chatRoomId); + validateChatRoomAccess(chatRoom, userId); + } +} \ No newline at end of file diff --git a/src/main/java/hanium/modic/backend/web/chat/controller/ChatController.java b/src/main/java/hanium/modic/backend/web/chat/controller/ChatController.java new file mode 100644 index 00000000..17ae7eee --- /dev/null +++ b/src/main/java/hanium/modic/backend/web/chat/controller/ChatController.java @@ -0,0 +1,127 @@ +package hanium.modic.backend.web.chat.controller; + +import java.util.List; + +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +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.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import hanium.modic.backend.common.annotation.user.CurrentUser; +import hanium.modic.backend.common.response.AppResponse; +import hanium.modic.backend.web.chat.dto.response.GetMessagesResponse; +import hanium.modic.backend.web.chat.dto.response.GetChatRoomsResponse; +import hanium.modic.backend.web.chat.dto.request.MarkMessagesAsReadRequest; +import hanium.modic.backend.domain.chat.service.ChatService; +import hanium.modic.backend.domain.user.entity.UserEntity; +import hanium.modic.backend.web.chat.dto.response.ChatRoomCreateResponse; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; + +@Tag(name = "Chat API", description = "1:1 채팅 API") +@RestController +@RequestMapping("/api/chat") +@RequiredArgsConstructor +public class ChatController { + + private final ChatService chatService; + + @Operation( + summary = "채팅방 생성", + description = "두 사용자 간의 채팅방을 생성하거나 기존 채팅방을 반환합니다.", + responses = { + @ApiResponse(responseCode = "400", description = "자기 자신과는 채팅방을 만들 수 없습니다.[CH-003]"), + @ApiResponse(responseCode = "404", description = "해당 유저를 찾을 수 없습니다.[U-002]") + } + ) + @PostMapping("/rooms/{receiverId}") + public ResponseEntity> createChatRoom( + @CurrentUser UserEntity user, + @Parameter(description = "상대방 사용자 ID", required = true) + @PathVariable Long receiverId + ) { + Long roomId = chatService.createOrGetChatRoom(user.getId(), receiverId); + ChatRoomCreateResponse response = ChatRoomCreateResponse.builder() + .roomId(roomId) + .build(); + return ResponseEntity.ok(AppResponse.ok(response)); + } + + @Operation( + summary = "채팅방 목록 조회", + description = "사용자의 모든 활성 채팅방 목록을 조회합니다.", + responses = { + @ApiResponse(responseCode = "404", description = "해당 유저를 찾을 수 없습니다.[U-002]") + } + ) + @GetMapping("/rooms") + public ResponseEntity>> getChatRooms( + @CurrentUser UserEntity user + ) { + List chatRooms = chatService.getChatRooms(user.getId()); + return ResponseEntity.ok(AppResponse.ok(chatRooms)); + } + + @Operation( + summary = "메시지 목록 조회", + description = "채팅방의 메시지 목록을 페이징으로 조회합니다.", + responses = { + @ApiResponse(responseCode = "403", description = "해당 채팅방에 접근할 권한이 없습니다.[CH-004]"), + @ApiResponse(responseCode = "404", description = "해당 채팅방을 찾을 수 없습니다.[CH-001] / 해당 채팅 메시지를 찾을 수 없습니다.[CH-002]") + } + ) + @GetMapping("/messages") + public ResponseEntity>> getMessages( + @CurrentUser UserEntity user, + @Parameter(description = "채팅방 ID", required = true) @RequestParam Long roomId, + @Parameter(description = "마지막 메시지 ID (페이징용), 없으면 파라미터 제외") @RequestParam(required = false) String lastMessageId, + @Parameter(description = "조회할 메시지 수(기본값 20)") @RequestParam(defaultValue = "20") int limit + ) { + List messages = chatService.getMessages(roomId, lastMessageId, limit, user.getId()); + return ResponseEntity.ok(AppResponse.ok(messages)); + } + + @Operation( + summary = "메시지 읽음 처리", + description = "지정된 메시지까지 읽음 처리합니다.", + responses = { + @ApiResponse(responseCode = "400", description = "사용자 입력 오류[C-001]"), + @ApiResponse(responseCode = "403", description = "해당 채팅방에 접근할 권한이 없습니다.[CH-004]"), + @ApiResponse(responseCode = "404", description = "해당 채팅방을 찾을 수 없습니다.[CH-001] / 해당 채팅 메시지를 찾을 수 없습니다.[CH-002]") + } + ) + @PostMapping("/read") + public ResponseEntity> markMessagesAsRead( + @CurrentUser UserEntity user, + @Valid @RequestBody MarkMessagesAsReadRequest request + ) { + chatService.markMessagesAsRead(request.getChatRoomId(), request.getLastReadMessageId(), user.getId()); + return ResponseEntity.ok(AppResponse.noContent()); + } + + @Operation( + summary = "채팅방 나가기", + description = "채팅방에서 나가기 (Soft Delete)", + responses = { + @ApiResponse(responseCode = "403", description = "해당 채팅방에 접근할 권한이 없습니다.[CH-004]"), + @ApiResponse(responseCode = "404", description = "해당 채팅방을 찾을 수 없습니다.[CH-001]") + } + ) + @DeleteMapping("/room/{chatRoomId}") + public ResponseEntity> deleteChatRoom( + @CurrentUser UserEntity user, + @Parameter(description = "채팅방 ID", required = true) @PathVariable Long chatRoomId + ) { + chatService.deleteChatRoom(chatRoomId, user.getId()); + return ResponseEntity.ok(AppResponse.noContent()); + } +} \ No newline at end of file diff --git a/src/main/java/hanium/modic/backend/web/chat/controller/ChatWebSocketController.java b/src/main/java/hanium/modic/backend/web/chat/controller/ChatWebSocketController.java new file mode 100644 index 00000000..3e39c092 --- /dev/null +++ b/src/main/java/hanium/modic/backend/web/chat/controller/ChatWebSocketController.java @@ -0,0 +1,78 @@ +package hanium.modic.backend.web.chat.controller; + +import java.security.Principal; + +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.stereotype.Controller; + +import hanium.modic.backend.web.chat.dto.dto.ChatMessageDto; +import hanium.modic.backend.web.chat.dto.request.SendMessageRequest; +import hanium.modic.backend.domain.chat.service.ChatService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@Controller +@RequiredArgsConstructor +public class ChatWebSocketController { + + private final ChatService chatService; + private final SimpMessagingTemplate messagingTemplate; + + @MessageMapping("/chat.sendMessage/{roomId}") + public void sendMessage( + @DestinationVariable Long roomId, + @Payload SendMessageRequest message, + Principal principal + ) { + try { + Long senderId = Long.parseLong(principal.getName()); + + ChatMessageDto chatMessage = chatService.sendMessage(roomId, senderId, message.getMessage()); + + messagingTemplate.convertAndSend("/topic/chat/" + roomId, chatMessage); + + } catch (Exception e) { + log.error("메시지 전송 중 오류 발생: {}", e.getMessage()); + } + } + + @MessageMapping("/chat.addUser/{roomId}") + public void addUser( + @DestinationVariable Long roomId, + Principal principal + ) { + try { + Long userId = Long.parseLong(principal.getName()); + + // 채팅방 참여 권한 검증 + chatService.validateUserAccessToChatRoom(roomId, userId); + + log.info("사용자 {}가 채팅방 {}에 입장했습니다.", userId, roomId); + + } catch (Exception e) { + log.error("사용자 입장 처리 중 오류 발생: {}", e.getMessage()); + } + } + + @MessageMapping("/chat.removeUser/{roomId}") + public void removeUser( + @DestinationVariable Long roomId, + Principal principal + ) { + try { + Long userId = Long.parseLong(principal.getName()); + + // 채팅방 참여 권한 검증 + chatService.validateUserAccessToChatRoom(roomId, userId); + + log.info("사용자 {}가 채팅방 {}에서 나갔습니다.", userId, roomId); + + } catch (Exception e) { + log.error("사용자 퇴장 처리 중 오류 발생: {}", e.getMessage()); + } + } +} \ No newline at end of file diff --git a/src/main/java/hanium/modic/backend/web/chat/dto/dto/ChatMessageDto.java b/src/main/java/hanium/modic/backend/web/chat/dto/dto/ChatMessageDto.java new file mode 100644 index 00000000..8bcfa898 --- /dev/null +++ b/src/main/java/hanium/modic/backend/web/chat/dto/dto/ChatMessageDto.java @@ -0,0 +1,25 @@ +package hanium.modic.backend.web.chat.dto.dto; + +import java.time.LocalDateTime; + +import lombok.Builder; +import lombok.Getter; + +@Getter +@Builder +public class ChatMessageDto { + + private String messageId; + private String roomId; + private Long senderId; + private String senderName; + private String message; + private LocalDateTime timestamp; + private MessageType type; + + public enum MessageType { + CHAT, + JOIN, + LEAVE + } +} \ No newline at end of file diff --git a/src/main/java/hanium/modic/backend/web/chat/dto/request/MarkMessagesAsReadRequest.java b/src/main/java/hanium/modic/backend/web/chat/dto/request/MarkMessagesAsReadRequest.java new file mode 100644 index 00000000..ad32ac24 --- /dev/null +++ b/src/main/java/hanium/modic/backend/web/chat/dto/request/MarkMessagesAsReadRequest.java @@ -0,0 +1,16 @@ +package hanium.modic.backend.web.chat.dto.request; + +import jakarta.validation.constraints.NotBlank; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@NoArgsConstructor +public class MarkMessagesAsReadRequest { + + @NotBlank(message = "채팅방 ID는 필수입니다.") + private Long chatRoomId; + + @NotBlank(message = "마지막 읽은 메시지 ID는 필수입니다.") + private String lastReadMessageId; +} \ No newline at end of file diff --git a/src/main/java/hanium/modic/backend/web/chat/dto/request/SendMessageRequest.java b/src/main/java/hanium/modic/backend/web/chat/dto/request/SendMessageRequest.java new file mode 100644 index 00000000..28727486 --- /dev/null +++ b/src/main/java/hanium/modic/backend/web/chat/dto/request/SendMessageRequest.java @@ -0,0 +1,17 @@ +package hanium.modic.backend.web.chat.dto.request; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@NoArgsConstructor +public class SendMessageRequest { + + @NotBlank(message = "메시지 내용은 필수입니다.") + private String message; + + @NotNull(message = "수신자 ID는 필수입니다.") + private Long receiverId; +} \ No newline at end of file diff --git a/src/main/java/hanium/modic/backend/web/chat/dto/response/ChatRoomCreateResponse.java b/src/main/java/hanium/modic/backend/web/chat/dto/response/ChatRoomCreateResponse.java new file mode 100644 index 00000000..2c3f3ae7 --- /dev/null +++ b/src/main/java/hanium/modic/backend/web/chat/dto/response/ChatRoomCreateResponse.java @@ -0,0 +1,11 @@ +package hanium.modic.backend.web.chat.dto.response; + +import lombok.Builder; +import lombok.Getter; + +@Getter +@Builder +public class ChatRoomCreateResponse { + + private Long roomId; +} \ No newline at end of file diff --git a/src/main/java/hanium/modic/backend/web/chat/dto/response/GetChatRoomsResponse.java b/src/main/java/hanium/modic/backend/web/chat/dto/response/GetChatRoomsResponse.java new file mode 100644 index 00000000..29b55821 --- /dev/null +++ b/src/main/java/hanium/modic/backend/web/chat/dto/response/GetChatRoomsResponse.java @@ -0,0 +1,25 @@ +package hanium.modic.backend.web.chat.dto.response; + +import java.time.LocalDateTime; + +import lombok.Builder; +import lombok.Getter; + +@Getter +@Builder +public class GetChatRoomsResponse { + + private Long chatRoomId; + private OpponentDto opponent; + private String lastMessage; + private LocalDateTime lastMessageTime; + private Long unreadCount; + + @Getter + @Builder + public static class OpponentDto { + private Long userId; + private String nickname; + private String profileImageUrl; + } +} \ No newline at end of file diff --git a/src/main/java/hanium/modic/backend/web/chat/dto/response/GetMessagesResponse.java b/src/main/java/hanium/modic/backend/web/chat/dto/response/GetMessagesResponse.java new file mode 100644 index 00000000..112a9125 --- /dev/null +++ b/src/main/java/hanium/modic/backend/web/chat/dto/response/GetMessagesResponse.java @@ -0,0 +1,24 @@ +package hanium.modic.backend.web.chat.dto.response; + +import java.time.LocalDateTime; + +import hanium.modic.backend.domain.chat.entity.ChatMessageEntity; + +public record GetMessagesResponse( + String messageId, + Long senderId, + String message, + LocalDateTime sentAt, + Boolean isRead +) { + + public static GetMessagesResponse from(ChatMessageEntity chatMessage) { + return new GetMessagesResponse( + chatMessage.getMessageId(), + chatMessage.getSenderId(), + chatMessage.getMessage(), + chatMessage.getCreateAt(), + chatMessage.getIsRead() + ); + } +} \ No newline at end of file