diff --git a/build.gradle b/build.gradle index dffee5b..a8a4199 100644 --- a/build.gradle +++ b/build.gradle @@ -74,6 +74,8 @@ dependencies { implementation 'com.fasterxml.jackson.datatype:jackson-datatype-jsr310:2.13.0' + // Websocket + implementation 'org.springframework.boot:spring-boot-starter-websocket' } tasks.named('test') { diff --git a/src/main/java/com/leets/xcellentbe/domain/chatRoom/controller/ChatRoomController.java b/src/main/java/com/leets/xcellentbe/domain/chatRoom/controller/ChatRoomController.java new file mode 100644 index 0000000..5213d51 --- /dev/null +++ b/src/main/java/com/leets/xcellentbe/domain/chatRoom/controller/ChatRoomController.java @@ -0,0 +1,65 @@ +package com.leets.xcellentbe.domain.chatRoom.controller; + +import java.util.List; +import java.util.UUID; + +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PatchMapping; +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.RestController; + +import com.leets.xcellentbe.domain.chatRoom.dto.ChatRoomDto; +import com.leets.xcellentbe.domain.chatRoom.service.ChatRoomService; +import com.leets.xcellentbe.domain.dm.dto.request.DMRequest; +import com.leets.xcellentbe.domain.dm.dto.response.DMResponse; +import com.leets.xcellentbe.global.response.GlobalResponseDto; + +import io.swagger.v3.oas.annotations.Operation; +import lombok.RequiredArgsConstructor; + +@RestController +@RequestMapping("/api/chat-room") +@RequiredArgsConstructor +public class ChatRoomController { + + private final ChatRoomService chatRoomService; + + @PostMapping() + @Operation(summary = "채팅방 생성", description = "채팅방을 생성합니다.") + public ResponseEntity> createChatRoom(@RequestBody DMRequest dmRequest, + @AuthenticationPrincipal UserDetails userDetails) { + return ResponseEntity.status(HttpStatus.CREATED) + .body(GlobalResponseDto.success(chatRoomService.createChatRoom(dmRequest, userDetails))); + } + + @GetMapping("/all") + @Operation(summary = "사용자 채팅방 전체 조회", description = "사용자의 모든 채팅방을 조회합니다.") + public ResponseEntity>> findAllChatRoomByUser( + @AuthenticationPrincipal UserDetails userDetails) { + return ResponseEntity.status(HttpStatus.OK) + .body(GlobalResponseDto.success(chatRoomService.findAllChatRoomByUser(userDetails))); + } + + @GetMapping("/{chatRoomId}") + @Operation(summary = "사용자 채팅방 조회", description = "사용자의 채팅방을 조회합니다.") + public ResponseEntity> findChatRoom(@PathVariable UUID chatRoomId, + @AuthenticationPrincipal UserDetails userDetails) { + return ResponseEntity.status(HttpStatus.OK) + .body(GlobalResponseDto.success(chatRoomService.findChatRoom(chatRoomId, userDetails))); + } + + @PatchMapping("/{chatRoomId}") + @Operation(summary = "채팅방 삭제", description = "채팅방을 삭제합니다.") + public ResponseEntity> deleteChatRoom(@PathVariable UUID chatRoomId, + @AuthenticationPrincipal UserDetails userDetails) { + return ResponseEntity.status(HttpStatus.OK) + .body(GlobalResponseDto.success(chatRoomService.deleteChatRoom(chatRoomId, userDetails))); + } +} diff --git a/src/main/java/com/leets/xcellentbe/domain/chatRoom/domain/ChatRoom.java b/src/main/java/com/leets/xcellentbe/domain/chatRoom/domain/ChatRoom.java new file mode 100644 index 0000000..7fb89f8 --- /dev/null +++ b/src/main/java/com/leets/xcellentbe/domain/chatRoom/domain/ChatRoom.java @@ -0,0 +1,76 @@ +package com.leets.xcellentbe.domain.chatRoom.domain; + +import java.util.UUID; + +import com.leets.xcellentbe.domain.shared.BaseTimeEntity; +import com.leets.xcellentbe.domain.shared.DeletedStatus; +import com.leets.xcellentbe.domain.user.domain.User; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.validation.constraints.NotNull; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class ChatRoom extends BaseTimeEntity { + + @Id + @Column(name = "chatRoom_id") + @GeneratedValue(strategy = GenerationType.UUID) + private UUID chatRoomId; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "sender_id") + private User sender; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "receiver_id") + private User receiver; + + @Column + private String lastMessage; + + @NotNull + @Column + @Enumerated(EnumType.STRING) + private DeletedStatus deletedStatus; + + public static ChatRoom create(User sender, User receiver) { + return ChatRoom.builder() + .sender(sender) + .receiver(receiver) + .build(); + } + + @Builder + private ChatRoom(User sender, User receiver) { + this.sender = sender; + this.receiver = receiver; + this.deletedStatus = DeletedStatus.NOT_DELETED; + } + + public void updateReceiver(User receiver) { + this.receiver = receiver; + } + + public void updateLastMessage(String lastMessage) { + this.lastMessage = lastMessage; + } + + public void delete() { + this.deletedStatus = DeletedStatus.DELETED; + } +} diff --git a/src/main/java/com/leets/xcellentbe/domain/chatRoom/domain/repository/ChatRoomRepository.java b/src/main/java/com/leets/xcellentbe/domain/chatRoom/domain/repository/ChatRoomRepository.java new file mode 100644 index 0000000..3090f3c --- /dev/null +++ b/src/main/java/com/leets/xcellentbe/domain/chatRoom/domain/repository/ChatRoomRepository.java @@ -0,0 +1,23 @@ +package com.leets.xcellentbe.domain.chatRoom.domain.repository; + +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +import org.springframework.data.jpa.repository.JpaRepository; + +import com.leets.xcellentbe.domain.chatRoom.domain.ChatRoom; +import com.leets.xcellentbe.domain.shared.DeletedStatus; +import com.leets.xcellentbe.domain.user.domain.User; + +public interface ChatRoomRepository extends JpaRepository { + + List findBySenderOrReceiverAndDeletedStatusNot(User sender, User receiver, DeletedStatus deletedStatus); + + Optional findByChatRoomIdAndSenderOrChatRoomIdAndReceiverAndDeletedStatusNot(UUID ChatRoomId, User sender, + UUID ChatRoomId1, User receiver, DeletedStatus deletedStatus); + + ChatRoom findBySenderAndReceiverAndDeletedStatusNot(User sender, User receiver, DeletedStatus deletedStatus); + + Optional findByChatRoomIdAndDeletedStatusNot(UUID charRoomId, DeletedStatus deletedStatus); +} diff --git a/src/main/java/com/leets/xcellentbe/domain/chatRoom/dto/ChatRoomDto.java b/src/main/java/com/leets/xcellentbe/domain/chatRoom/dto/ChatRoomDto.java new file mode 100644 index 0000000..8128280 --- /dev/null +++ b/src/main/java/com/leets/xcellentbe/domain/chatRoom/dto/ChatRoomDto.java @@ -0,0 +1,24 @@ +package com.leets.xcellentbe.domain.chatRoom.dto; + +import java.io.Serial; +import java.io.Serializable; +import java.util.UUID; + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.leets.xcellentbe.domain.dm.dto.request.DMRequest; +import com.leets.xcellentbe.domain.user.domain.User; + +@JsonInclude(JsonInclude.Include.NON_NULL) +public record ChatRoomDto(UUID chatRoomId, Long senderId, Long receiverId) implements Serializable { + + @Serial + private static final long serialVersionUID = 6494678977089006639L; + + public static ChatRoomDto of(DMRequest dmRequest, User user) { + return new ChatRoomDto(null, user.getUserId(), dmRequest.receiverId()); + } + + public static ChatRoomDto of(UUID chatRoomId, User sender, User receiver) { + return new ChatRoomDto(chatRoomId, sender.getUserId(), receiver.getUserId()); + } +} diff --git a/src/main/java/com/leets/xcellentbe/domain/chatroom/domain/exception/ChatRoomNotFoundException.java b/src/main/java/com/leets/xcellentbe/domain/chatRoom/exception/ChatRoomNotFoundException.java similarity index 81% rename from src/main/java/com/leets/xcellentbe/domain/chatroom/domain/exception/ChatRoomNotFoundException.java rename to src/main/java/com/leets/xcellentbe/domain/chatRoom/exception/ChatRoomNotFoundException.java index 105f827..71ad22a 100644 --- a/src/main/java/com/leets/xcellentbe/domain/chatroom/domain/exception/ChatRoomNotFoundException.java +++ b/src/main/java/com/leets/xcellentbe/domain/chatRoom/exception/ChatRoomNotFoundException.java @@ -1,11 +1,11 @@ -package com.leets.xcellentbe.domain.chatroom.domain.exception; +package com.leets.xcellentbe.domain.chatRoom.exception; import com.leets.xcellentbe.global.error.ErrorCode; import com.leets.xcellentbe.global.error.exception.CommonException; public class ChatRoomNotFoundException extends CommonException { - public ChatRoomNotFoundException() { + public ChatRoomNotFoundException() { super(ErrorCode.CHAT_ROOM_NOT_FOUND); } } diff --git a/src/main/java/com/leets/xcellentbe/domain/chatRoom/service/ChatRoomService.java b/src/main/java/com/leets/xcellentbe/domain/chatRoom/service/ChatRoomService.java new file mode 100644 index 0000000..461d255 --- /dev/null +++ b/src/main/java/com/leets/xcellentbe/domain/chatRoom/service/ChatRoomService.java @@ -0,0 +1,148 @@ +package com.leets.xcellentbe.domain.chatRoom.service; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.UUID; + +import org.springframework.data.redis.core.HashOperations; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.data.redis.listener.ChannelTopic; +import org.springframework.data.redis.listener.RedisMessageListenerContainer; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.stereotype.Service; + +import com.leets.xcellentbe.domain.chatRoom.domain.ChatRoom; +import com.leets.xcellentbe.domain.chatRoom.domain.repository.ChatRoomRepository; +import com.leets.xcellentbe.domain.chatRoom.dto.ChatRoomDto; +import com.leets.xcellentbe.domain.chatRoom.exception.ChatRoomNotFoundException; +import com.leets.xcellentbe.domain.dm.domain.DM; +import com.leets.xcellentbe.domain.dm.domain.repository.DMRepository; +import com.leets.xcellentbe.domain.dm.dto.request.DMRequest; +import com.leets.xcellentbe.domain.dm.dto.response.DMResponse; +import com.leets.xcellentbe.domain.dm.redis.RedisSubscriber; +import com.leets.xcellentbe.domain.shared.DeletedStatus; +import com.leets.xcellentbe.domain.user.domain.User; +import com.leets.xcellentbe.domain.user.domain.repository.UserRepository; +import com.leets.xcellentbe.domain.user.exception.UserNotFoundException; + +import jakarta.annotation.PostConstruct; +import lombok.RequiredArgsConstructor; + +@Service +@RequiredArgsConstructor +public class ChatRoomService { + + private final ChatRoomRepository chatRoomRepository; + private final DMRepository dmRepository; + private final UserRepository userRepository; + private final RedisMessageListenerContainer redisMessageListener; + private final RedisSubscriber redisSubscriber; + + private static final String Message_Rooms = "MESSAGE_ROOM"; + private final RedisTemplate redisTemplate; + private HashOperations opsHashMessageRoom; + + private Map topics; + + @PostConstruct + private void init() { + opsHashMessageRoom = redisTemplate.opsForHash(); + topics = new HashMap<>(); + } + + public DMResponse createChatRoom(DMRequest dmRequest, UserDetails userDetails) { + User sender = userRepository.findByEmail(userDetails.getUsername()).orElseThrow(UserNotFoundException::new); + + User receiver = userRepository.findById(dmRequest.receiverId()).orElseThrow(UserNotFoundException::new); + + ChatRoom chatRoom = chatRoomRepository.findBySenderAndReceiverAndDeletedStatusNot(sender, receiver, + DeletedStatus.DELETED); + + if ((chatRoom == null) || (!sender.equals(chatRoom.getSender()) && !receiver.equals( + chatRoom.getReceiver()))) { + ChatRoomDto chatRoomDto = ChatRoomDto.of(dmRequest, sender); + opsHashMessageRoom.put(Message_Rooms, sender.getUserName(), chatRoomDto); + + chatRoom = chatRoomRepository.save(ChatRoom.create(sender, receiver)); + + return DMResponse.from(chatRoom); + } else { + return DMResponse.from(chatRoom.getChatRoomId()); + } + } + + public List findAllChatRoomByUser(UserDetails userDetails) { + User user = userRepository.findByEmail(userDetails.getUsername()).orElseThrow(UserNotFoundException::new); + + List chatRooms = chatRoomRepository.findBySenderOrReceiverAndDeletedStatusNot(user, user, + DeletedStatus.DELETED); + + List dmResponses = new ArrayList<>(); + + for (ChatRoom chatRoom : chatRooms) { + DMResponse messageRoomDto; + + messageRoomDto = DMResponse.of(chatRoom.getChatRoomId(), chatRoom.getSender(), chatRoom.getReceiver()); + + DM latestMessage = dmRepository.findTopByChatRoomAndDeletedStatusNotOrderByCreatedAtDesc(chatRoom, + DeletedStatus.DELETED); + + if (latestMessage != null) { + messageRoomDto.updateLatestMessageCreatedAt(latestMessage.getCreatedAt()); + messageRoomDto.updateLatestMessageContent(latestMessage.getMessage()); + } + + dmResponses.add(messageRoomDto); + } + + return dmResponses; + } + + public ChatRoomDto findChatRoom(UUID chatRoomId, UserDetails userDetails) { + User user = userRepository.findByEmail(userDetails.getUsername()).orElseThrow(UserNotFoundException::new); + + ChatRoom chatRoom = chatRoomRepository.findByChatRoomIdAndDeletedStatusNot(chatRoomId, DeletedStatus.DELETED) + .orElseThrow(ChatRoomNotFoundException::new); + + User receiver = chatRoom.getReceiver(); + + chatRoom = chatRoomRepository.findByChatRoomIdAndSenderOrChatRoomIdAndReceiverAndDeletedStatusNot(chatRoomId, + user, chatRoomId, receiver, DeletedStatus.DELETED).orElseThrow(ChatRoomNotFoundException::new); + + return ChatRoomDto.of(chatRoom.getChatRoomId(), chatRoom.getSender(), chatRoom.getReceiver()); + } + + public String deleteChatRoom(UUID chatRoomId, UserDetails userDetails) { + User user = userRepository.findByEmail(userDetails.getUsername()).orElseThrow(UserNotFoundException::new); + + ChatRoom chatRoom = chatRoomRepository.findByChatRoomIdAndSenderOrChatRoomIdAndReceiverAndDeletedStatusNot( + chatRoomId, user, chatRoomId, user, DeletedStatus.DELETED).orElseThrow(ChatRoomNotFoundException::new); + + chatRoom.delete(); + chatRoomRepository.save(chatRoom); + + if (user.equals(chatRoom.getSender())) { + opsHashMessageRoom.delete(Message_Rooms, chatRoom.getChatRoomId()); + } + + return "대화방을 삭제했습니다."; + } + + public void enterChatRoom(Long receiverId) { + String receiverName = userRepository.findById(receiverId).orElseThrow(UserNotFoundException::new).getUserName(); + ChannelTopic topic = topics.get(receiverName); + + if (topic == null) { + topic = new ChannelTopic(receiverName); + redisMessageListener.addMessageListener(redisSubscriber, topic); + topics.put(receiverName, topic); + } + } + + public ChannelTopic getTopic(Long receiverId) { + String receiverName = userRepository.findById(receiverId).orElseThrow(UserNotFoundException::new).getUserName(); + return topics.get(receiverName); + } +} diff --git a/src/main/java/com/leets/xcellentbe/domain/chatroom/domain/Chatroom.java b/src/main/java/com/leets/xcellentbe/domain/chatroom/domain/Chatroom.java deleted file mode 100644 index 2e901d0..0000000 --- a/src/main/java/com/leets/xcellentbe/domain/chatroom/domain/Chatroom.java +++ /dev/null @@ -1,30 +0,0 @@ -package com.leets.xcellentbe.domain.chatroom.domain; - -import java.util.UUID; - -import com.leets.xcellentbe.domain.shared.BaseTimeEntity; - -import jakarta.persistence.Column; -import jakarta.persistence.Entity; -import jakarta.persistence.GeneratedValue; -import jakarta.persistence.GenerationType; -import jakarta.persistence.Id; -import lombok.AccessLevel; -import lombok.Getter; -import lombok.NoArgsConstructor; - -@Entity -@Getter -@NoArgsConstructor(access = AccessLevel.PROTECTED) -public class Chatroom extends BaseTimeEntity { - - @Id - @GeneratedValue(strategy = GenerationType.UUID) - private UUID ChatroomId; - - @Column(length = 50) - private String lastParticipantName; - - @Column - private String lastMessage; -} diff --git a/src/main/java/com/leets/xcellentbe/domain/chatroom/domain/controller/ChatroomController.java b/src/main/java/com/leets/xcellentbe/domain/chatroom/domain/controller/ChatroomController.java deleted file mode 100644 index 092b246..0000000 --- a/src/main/java/com/leets/xcellentbe/domain/chatroom/domain/controller/ChatroomController.java +++ /dev/null @@ -1,12 +0,0 @@ -package com.leets.xcellentbe.domain.chatroom.domain.controller; - -import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RestController; - -import lombok.RequiredArgsConstructor; - -@RestController -@RequestMapping("/chatroom") -@RequiredArgsConstructor -public class ChatroomController { -} diff --git a/src/main/java/com/leets/xcellentbe/domain/chatroom/domain/exception/ChatRoomForbiddenException.java b/src/main/java/com/leets/xcellentbe/domain/chatroom/domain/exception/ChatRoomForbiddenException.java deleted file mode 100644 index 507176f..0000000 --- a/src/main/java/com/leets/xcellentbe/domain/chatroom/domain/exception/ChatRoomForbiddenException.java +++ /dev/null @@ -1,11 +0,0 @@ -package com.leets.xcellentbe.domain.chatroom.domain.exception; - -import com.leets.xcellentbe.global.error.ErrorCode; -import com.leets.xcellentbe.global.error.exception.CommonException; - -public class ChatRoomForbiddenException extends CommonException { - public ChatRoomForbiddenException() { - - super(ErrorCode.CHAT_ROOM_FORBIDDEN); - } -} diff --git a/src/main/java/com/leets/xcellentbe/domain/chatroom/domain/repository/ChatroomRepository.java b/src/main/java/com/leets/xcellentbe/domain/chatroom/domain/repository/ChatroomRepository.java deleted file mode 100644 index 02e4180..0000000 --- a/src/main/java/com/leets/xcellentbe/domain/chatroom/domain/repository/ChatroomRepository.java +++ /dev/null @@ -1,10 +0,0 @@ -package com.leets.xcellentbe.domain.chatroom.domain.repository; - -import java.util.UUID; - -import org.springframework.data.jpa.repository.JpaRepository; - -import com.leets.xcellentbe.domain.chatroom.domain.Chatroom; - -public interface ChatroomRepository extends JpaRepository { -} diff --git a/src/main/java/com/leets/xcellentbe/domain/dm/controller/DMController.java b/src/main/java/com/leets/xcellentbe/domain/dm/controller/DMController.java new file mode 100644 index 0000000..9f036c3 --- /dev/null +++ b/src/main/java/com/leets/xcellentbe/domain/dm/controller/DMController.java @@ -0,0 +1,66 @@ +package com.leets.xcellentbe.domain.dm.controller; + +import java.util.List; +import java.util.UUID; + +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.messaging.handler.annotation.MessageMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PatchMapping; +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.RestController; + +import com.leets.xcellentbe.domain.chatRoom.service.ChatRoomService; +import com.leets.xcellentbe.domain.dm.dto.DMDto; +import com.leets.xcellentbe.domain.dm.service.DMService; +import com.leets.xcellentbe.domain.dm.redis.RedisPublisher; +import com.leets.xcellentbe.global.response.GlobalResponseDto; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import lombok.RequiredArgsConstructor; + +@RestController +@RequiredArgsConstructor +public class DMController { + + private final RedisPublisher redisPublisher; + private final ChatRoomService chatRoomService; + private final DMService dmService; + + @MessageMapping("/dm") + @Operation(summary = "채팅을 시작", description = "채팅을 시작합니다.") + public void message(DMDto dmDto) { + chatRoomService.enterChatRoom(dmDto.receiverID()); + redisPublisher.publish(chatRoomService.getTopic(dmDto.receiverID()), dmDto); + + dmService.saveMessage(dmDto); + } + + @PostMapping("/dm") + @Operation(summary = "채팅을 시작", description = "채팅을 시작합니다.") + public void startChat( + @Parameter(description = "메시지 전송 객체") @RequestBody DMDto dmDto) { + chatRoomService.enterChatRoom(dmDto.receiverID()); + redisPublisher.publish(chatRoomService.getTopic(dmDto.receiverID()), dmDto); + + dmService.saveMessage(dmDto); + + } + + @GetMapping("/api/chat-room/{chatRoomId}/dm") + @Operation(summary = "채팅방 로드", description = "채팅방을 로드합니다.") + public ResponseEntity>> loadMessage(@PathVariable UUID chatRoomId) { + return ResponseEntity.status(HttpStatus.CREATED) + .body(GlobalResponseDto.success(dmService.loadMessage(chatRoomId))); + } + + @PatchMapping("/api/chat-room/delete/{dmId}") + @Operation(summary = "메시지 삭제", description = "메시지를 삭제합니다.") + public ResponseEntity> deleteDM(@PathVariable UUID dmId) { + return ResponseEntity.status(HttpStatus.OK).body(GlobalResponseDto.success(dmService.deleteDM(dmId))); + } +} diff --git a/src/main/java/com/leets/xcellentbe/domain/dm/domain/DM.java b/src/main/java/com/leets/xcellentbe/domain/dm/domain/DM.java index e90ece8..0b8c9d3 100644 --- a/src/main/java/com/leets/xcellentbe/domain/dm/domain/DM.java +++ b/src/main/java/com/leets/xcellentbe/domain/dm/domain/DM.java @@ -2,13 +2,15 @@ import java.util.UUID; -import com.leets.xcellentbe.domain.chatroom.domain.Chatroom; +import com.leets.xcellentbe.domain.chatRoom.domain.ChatRoom; import com.leets.xcellentbe.domain.shared.BaseTimeEntity; import com.leets.xcellentbe.domain.shared.DeletedStatus; import com.leets.xcellentbe.domain.user.domain.User; import jakarta.persistence.Column; import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; import jakarta.persistence.FetchType; import jakarta.persistence.GeneratedValue; import jakarta.persistence.GenerationType; @@ -17,6 +19,7 @@ import jakarta.persistence.ManyToOne; import jakarta.validation.constraints.NotNull; import lombok.AccessLevel; +import lombok.Builder; import lombok.Getter; import lombok.NoArgsConstructor; @@ -27,17 +30,22 @@ public class DM extends BaseTimeEntity { @Id @GeneratedValue(strategy = GenerationType.UUID) + @Column(columnDefinition = "BINARY(16)") private UUID DMId; @NotNull @ManyToOne(fetch = FetchType.LAZY) - @JoinColumn(name = "chatroom_id") - private Chatroom chatRoom; + @JoinColumn(name = "chatRoom_id") + private ChatRoom chatRoom; @NotNull @ManyToOne(fetch = FetchType.LAZY) - @JoinColumn(name = "participant_id") - private User participant; + @JoinColumn(name = "sender_id") + private User sender; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "receiver_id") + private User receiver; @NotNull @Column @@ -45,5 +53,28 @@ public class DM extends BaseTimeEntity { @NotNull @Column + @Enumerated(EnumType.STRING) private DeletedStatus deletedStatus; + + public static DM create(User sender, User receiver, ChatRoom chatRoom, String message) { + return DM.builder() + .sender(sender) + .receiver(receiver) + .chatRoom(chatRoom) + .message(message) + .build(); + } + + @Builder + private DM(User sender, User receiver, ChatRoom chatRoom, String message) { + this.sender = sender; + this.receiver = receiver; + this.chatRoom = chatRoom; + this.message = message; + this.deletedStatus = DeletedStatus.NOT_DELETED; + } + + public void delete() { + this.deletedStatus = DeletedStatus.DELETED; + } } diff --git a/src/main/java/com/leets/xcellentbe/domain/dm/domain/repository/DMRepository.java b/src/main/java/com/leets/xcellentbe/domain/dm/domain/repository/DMRepository.java index 2ca8980..d146ead 100644 --- a/src/main/java/com/leets/xcellentbe/domain/dm/domain/repository/DMRepository.java +++ b/src/main/java/com/leets/xcellentbe/domain/dm/domain/repository/DMRepository.java @@ -1,10 +1,17 @@ package com.leets.xcellentbe.domain.dm.domain.repository; +import java.util.List; import java.util.UUID; import org.springframework.data.jpa.repository.JpaRepository; +import com.leets.xcellentbe.domain.chatRoom.domain.ChatRoom; import com.leets.xcellentbe.domain.dm.domain.DM; +import com.leets.xcellentbe.domain.shared.DeletedStatus; public interface DMRepository extends JpaRepository { + + List findTop100ByChatRoomAndDeletedStatusNotOrderByCreatedAtAsc(ChatRoom chatRoom, DeletedStatus deletedStatus); + + DM findTopByChatRoomAndDeletedStatusNotOrderByCreatedAtDesc(ChatRoom chatRoom, DeletedStatus deletedStatus); } diff --git a/src/main/java/com/leets/xcellentbe/domain/dm/dto/DMDto.java b/src/main/java/com/leets/xcellentbe/domain/dm/dto/DMDto.java new file mode 100644 index 0000000..b3e9389 --- /dev/null +++ b/src/main/java/com/leets/xcellentbe/domain/dm/dto/DMDto.java @@ -0,0 +1,22 @@ +package com.leets.xcellentbe.domain.dm.dto; + +import java.util.UUID; + +import com.leets.xcellentbe.domain.dm.domain.DM; + +public record DMDto( + Long senderId, + Long receiverID, + UUID chatRoomId, + String message) { + + private DMDto(DM dm) { + this(dm.getSender().getUserId(), dm.getReceiver().getUserId(), dm.getChatRoom().getChatRoomId(), + dm.getMessage()); + } + + public static DMDto from(DM dm) { + return new DMDto(dm.getSender().getUserId(), dm.getReceiver().getUserId(), dm.getChatRoom().getChatRoomId(), + dm.getMessage()); + } +} diff --git a/src/main/java/com/leets/xcellentbe/domain/dm/dto/request/DMRequest.java b/src/main/java/com/leets/xcellentbe/domain/dm/dto/request/DMRequest.java new file mode 100644 index 0000000..27050a0 --- /dev/null +++ b/src/main/java/com/leets/xcellentbe/domain/dm/dto/request/DMRequest.java @@ -0,0 +1,7 @@ +package com.leets.xcellentbe.domain.dm.dto.request; + +public record DMRequest( + Long receiverId) +{ + +} diff --git a/src/main/java/com/leets/xcellentbe/domain/dm/dto/response/DMResponse.java b/src/main/java/com/leets/xcellentbe/domain/dm/dto/response/DMResponse.java new file mode 100644 index 0000000..353f5c8 --- /dev/null +++ b/src/main/java/com/leets/xcellentbe/domain/dm/dto/response/DMResponse.java @@ -0,0 +1,63 @@ +package com.leets.xcellentbe.domain.dm.dto.response; + +import java.time.LocalDateTime; +import java.util.UUID; + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.leets.xcellentbe.domain.chatRoom.domain.ChatRoom; +import com.leets.xcellentbe.domain.user.domain.User; + +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@NoArgsConstructor +@JsonInclude(JsonInclude.Include.NON_NULL) +public class DMResponse { + private UUID chatRoomId; + private Long senderId; + private Long receiverId; + private String message; + private LocalDateTime createdAt; + + @Builder + private DMResponse(UUID chatRoomId, Long senderId, Long receiverId, String message, LocalDateTime createdAt) { + this.chatRoomId = chatRoomId; + this.senderId = senderId; + this.receiverId = receiverId; + this.message = message; + this.createdAt = createdAt; + } + + public static DMResponse from(UUID chatRoomId) { + return builder() + .chatRoomId(chatRoomId) + .build(); + } + + public static DMResponse from(ChatRoom chatRoom) { + return builder() + .chatRoomId(chatRoom.getChatRoomId()) + .senderId(chatRoom.getSender().getUserId()) + .receiverId(chatRoom.getReceiver().getUserId()) + .build(); + } + + public static DMResponse of(UUID chatRoomId, User sender, User receiver) { + return builder() + .chatRoomId(chatRoomId) + .senderId(sender.getUserId()) + .receiverId(receiver.getUserId()) + .build(); + } + + public void updateLatestMessageContent(String message) { + this.message = message; + } + + public void updateLatestMessageCreatedAt(LocalDateTime createdAt) { + this.createdAt = createdAt; + } +} + diff --git a/src/main/java/com/leets/xcellentbe/domain/dm/exception/DMNotFoundException.java b/src/main/java/com/leets/xcellentbe/domain/dm/exception/DMNotFoundException.java new file mode 100644 index 0000000..4761ee2 --- /dev/null +++ b/src/main/java/com/leets/xcellentbe/domain/dm/exception/DMNotFoundException.java @@ -0,0 +1,11 @@ +package com.leets.xcellentbe.domain.dm.exception; + +import com.leets.xcellentbe.global.error.ErrorCode; +import com.leets.xcellentbe.global.error.exception.CommonException; + +public class DMNotFoundException extends CommonException { + + public DMNotFoundException() { + super(ErrorCode.DM_NOT_FOUND); + } +} diff --git a/src/main/java/com/leets/xcellentbe/domain/dm/redis/RedisPublisher.java b/src/main/java/com/leets/xcellentbe/domain/dm/redis/RedisPublisher.java new file mode 100644 index 0000000..416251c --- /dev/null +++ b/src/main/java/com/leets/xcellentbe/domain/dm/redis/RedisPublisher.java @@ -0,0 +1,19 @@ +package com.leets.xcellentbe.domain.dm.redis; + +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.data.redis.listener.ChannelTopic; +import org.springframework.stereotype.Service; + +import com.leets.xcellentbe.domain.dm.dto.DMDto; + +import lombok.RequiredArgsConstructor; + +@Service +@RequiredArgsConstructor +public class RedisPublisher { + private final RedisTemplate redisTemplate; + + public void publish(ChannelTopic topic, DMDto dmDto) { + redisTemplate.convertAndSend(topic.getTopic(), dmDto); + } +} diff --git a/src/main/java/com/leets/xcellentbe/domain/dm/redis/RedisSubscriber.java b/src/main/java/com/leets/xcellentbe/domain/dm/redis/RedisSubscriber.java new file mode 100644 index 0000000..475bb23 --- /dev/null +++ b/src/main/java/com/leets/xcellentbe/domain/dm/redis/RedisSubscriber.java @@ -0,0 +1,35 @@ +package com.leets.xcellentbe.domain.dm.redis; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.JsonMappingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.leets.xcellentbe.domain.dm.dto.DMDto; + +import lombok.RequiredArgsConstructor; + +import org.springframework.data.redis.connection.Message; +import org.springframework.data.redis.connection.MessageListener; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.messaging.simp.SimpMessageSendingOperations; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +public class RedisSubscriber implements MessageListener { + private final ObjectMapper objectMapper; + private final RedisTemplate redisTemplate; + private final SimpMessageSendingOperations simpMessageSendingOperations; + + @Override + public void onMessage(Message message, byte[] pattern) { + try { + String publishMessage = redisTemplate.getStringSerializer().deserialize(message.getBody()); + DMDto dmDto = objectMapper.readValue(publishMessage, DMDto.class); + simpMessageSendingOperations.convertAndSend("/sub/chat/" + dmDto.chatRoomId(), dmDto); + } catch (JsonMappingException e) { + throw new RuntimeException(); + } catch (JsonProcessingException e) { + throw new RuntimeException(e); + } + } +} diff --git a/src/main/java/com/leets/xcellentbe/domain/dm/service/DMService.java b/src/main/java/com/leets/xcellentbe/domain/dm/service/DMService.java new file mode 100644 index 0000000..78c3b3f --- /dev/null +++ b/src/main/java/com/leets/xcellentbe/domain/dm/service/DMService.java @@ -0,0 +1,101 @@ +package com.leets.xcellentbe.domain.dm.service; + +import java.util.ArrayList; +import java.util.List; +import java.util.UUID; +import java.util.concurrent.TimeUnit; + +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.data.redis.serializer.Jackson2JsonRedisSerializer; +import org.springframework.stereotype.Service; + +import com.leets.xcellentbe.domain.chatRoom.domain.ChatRoom; +import com.leets.xcellentbe.domain.chatRoom.domain.repository.ChatRoomRepository; +import com.leets.xcellentbe.domain.chatRoom.exception.ChatRoomNotFoundException; +import com.leets.xcellentbe.domain.dm.domain.DM; +import com.leets.xcellentbe.domain.dm.domain.repository.DMRepository; +import com.leets.xcellentbe.domain.dm.dto.DMDto; +import com.leets.xcellentbe.domain.shared.DeletedStatus; +import com.leets.xcellentbe.domain.user.domain.User; +import com.leets.xcellentbe.domain.user.domain.repository.UserRepository; +import com.leets.xcellentbe.domain.user.exception.UserNotFoundException; +import com.leets.xcellentbe.domain.dm.exception.DMNotFoundException; + +import lombok.RequiredArgsConstructor; + +@Service +@RequiredArgsConstructor +public class DMService { + + private final RedisTemplate redisTemplateMessage; + private final ChatRoomRepository chatRoomRepository; + private final UserRepository userRepository; + private final DMRepository dmRepository; + + public void saveMessage(DMDto dmDto) { + ChatRoom chatRoom = chatRoomRepository.findByChatRoomIdAndDeletedStatusNot(dmDto.chatRoomId(), DeletedStatus.DELETED).orElseThrow( + ChatRoomNotFoundException::new); + + User sender = userRepository.findById(dmDto.senderId()).orElseThrow(UserNotFoundException::new); + User receiver = userRepository.findById(dmDto.receiverID()).orElseThrow(UserNotFoundException::new); + String message = dmDto.message(); + + DM dm = DM.create(sender, receiver, chatRoom, message); + dmRepository.save(dm); + + chatRoom.updateLastMessage(message); + chatRoomRepository.save(chatRoom); + + String receiverName = receiver.getUserName(); + + redisTemplateMessage.opsForList().rightPush(receiverName, dmDto); + + redisTemplateMessage.expire(receiverName, 1, TimeUnit.MINUTES); + } + + public List loadMessage(UUID chatRoomId) { + List messageList = new ArrayList<>(); + ChatRoom chatRoom = chatRoomRepository.findByChatRoomIdAndDeletedStatusNot(chatRoomId, DeletedStatus.DELETED).orElseThrow(ChatRoomNotFoundException::new); + String receiverName = chatRoom.getReceiver().getUserName(); + + List redisMessageList = redisTemplateMessage.opsForList().range(receiverName, 0, 99); + + if (redisMessageList == null || redisMessageList.isEmpty()) { + List dbMessageList = dmRepository.findTop100ByChatRoomAndDeletedStatusNotOrderByCreatedAtAsc(chatRoom, DeletedStatus.DELETED); + + for (DM dm : dbMessageList) { + DMDto messageDto = DMDto.from(dm); + messageList.add(messageDto); + redisTemplateMessage.opsForList().rightPush(receiverName, messageDto); + } + } else { + messageList.addAll(redisMessageList); + } + + return messageList; + } + + public String deleteDM(UUID dmId) { + DM dm = dmRepository.findById(dmId).orElseThrow(DMNotFoundException::new); + + dm.delete(); + dmRepository.save(dm); + + String receiverName = dm.getReceiver().getUserName(); + List redisMessageList = redisTemplateMessage.opsForList().range(receiverName, 0, -1); + + if (redisMessageList != null) { + for (DMDto dmDto : redisMessageList) { + if (dmDto.chatRoomId().equals(dm.getChatRoom().getChatRoomId()) && + dmDto.message().equals(dm.getMessage()) && + dmDto.senderId().equals(dm.getSender().getUserId()) && + dmDto.receiverID().equals(dm.getReceiver().getUserId())) { + redisTemplateMessage.opsForList().remove(receiverName, 1, dmDto); + break; + } + } + } + + return "메시지를 삭제했습니다."; + } +} diff --git a/src/main/java/com/leets/xcellentbe/global/config/SecurityConfig.java b/src/main/java/com/leets/xcellentbe/global/config/SecurityConfig.java index 677c83e..85e54e1 100644 --- a/src/main/java/com/leets/xcellentbe/global/config/SecurityConfig.java +++ b/src/main/java/com/leets/xcellentbe/global/config/SecurityConfig.java @@ -72,7 +72,7 @@ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { authorize -> authorize .requestMatchers("/v3/api-docs", "/v3/api-docs/**", "/swagger-ui.html", "/swagger-ui/**", - "/swagger/**", "/index.html", "/api/auth/**").permitAll() + "/swagger/**", "/index.html", "/api/auth/**", "/api/chat-room/**", "/dm").permitAll() .anyRequest().authenticated() ) .oauth2Login(oauth2 -> oauth2.successHandler(oAuthLoginSuccessHandler)); diff --git a/src/main/java/com/leets/xcellentbe/global/config/WebSocketConfig.java b/src/main/java/com/leets/xcellentbe/global/config/WebSocketConfig.java new file mode 100644 index 0000000..71ee8da --- /dev/null +++ b/src/main/java/com/leets/xcellentbe/global/config/WebSocketConfig.java @@ -0,0 +1,23 @@ +package com.leets.xcellentbe.global.config; + +import org.springframework.context.annotation.Configuration; +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; + +@Configuration +@EnableWebSocketMessageBroker +public class WebSocketConfig implements WebSocketMessageBrokerConfigurer { + + @Override + public void configureMessageBroker(MessageBrokerRegistry config) { + config.enableSimpleBroker("/sub"); + config.setApplicationDestinationPrefixes("/pub"); + } + + @Override + public void registerStompEndpoints(StompEndpointRegistry registry) { + registry.addEndpoint("/ws").setAllowedOrigins("*").withSockJS(); + } +} diff --git a/src/main/java/com/leets/xcellentbe/global/config/redis/RedisConfig.java b/src/main/java/com/leets/xcellentbe/global/config/redis/RedisConfig.java new file mode 100644 index 0000000..d2698cc --- /dev/null +++ b/src/main/java/com/leets/xcellentbe/global/config/redis/RedisConfig.java @@ -0,0 +1,40 @@ +package com.leets.xcellentbe.global.config.redis; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.data.redis.connection.RedisConnectionFactory; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.data.redis.listener.RedisMessageListenerContainer; +import org.springframework.data.redis.serializer.Jackson2JsonRedisSerializer; +import org.springframework.data.redis.serializer.StringRedisSerializer; + +import com.leets.xcellentbe.domain.dm.dto.DMDto; + +@Configuration +public class RedisConfig { + + @Bean + public RedisMessageListenerContainer redisMessageListener(RedisConnectionFactory connectionFactory) { + RedisMessageListenerContainer container = new RedisMessageListenerContainer(); + container.setConnectionFactory(connectionFactory); + return container; + } + + @Bean + public RedisTemplate redisTemplate(RedisConnectionFactory connectionFactory) { + RedisTemplate redisTemplate = new RedisTemplate<>(); + redisTemplate.setConnectionFactory(connectionFactory); + redisTemplate.setKeySerializer(new StringRedisSerializer()); + redisTemplate.setValueSerializer(new Jackson2JsonRedisSerializer<>(String.class)); + return redisTemplate; + } + + @Bean + public RedisTemplate redisTemplateMessage(RedisConnectionFactory connectionFactory) { + RedisTemplate redisTemplateMessage = new RedisTemplate<>(); + redisTemplateMessage.setConnectionFactory(connectionFactory); + redisTemplateMessage.setKeySerializer(new StringRedisSerializer()); + redisTemplateMessage.setValueSerializer(new Jackson2JsonRedisSerializer<>(String.class)); + return redisTemplateMessage; + } +} diff --git a/src/main/java/com/leets/xcellentbe/global/config/swagger/SwaggerConfig.java b/src/main/java/com/leets/xcellentbe/global/config/swagger/SwaggerConfig.java index 612f91d..fe168de 100644 --- a/src/main/java/com/leets/xcellentbe/global/config/swagger/SwaggerConfig.java +++ b/src/main/java/com/leets/xcellentbe/global/config/swagger/SwaggerConfig.java @@ -22,7 +22,7 @@ public class SwaggerConfig { @Bean public OpenAPI openAPI() { SecurityScheme securityScheme = getSecurityScheme(); - SecurityRequirement securityRequirement = getSecurityRequireMent(); + SecurityRequirement securityRequirement = getSecurityRequirement(); Server server = new Server(); server.setUrl("/"); @@ -34,11 +34,15 @@ public OpenAPI openAPI() { } private SecurityScheme getSecurityScheme() { - return new SecurityScheme().type(SecurityScheme.Type.HTTP).scheme("bearer").bearerFormat("JWT") - .in(SecurityScheme.In.HEADER).name("Authorization"); + return new SecurityScheme() + .type(SecurityScheme.Type.HTTP) + .scheme("bearer") + .bearerFormat("JWT") + .in(SecurityScheme.In.HEADER) + .name("Authorization"); } - private SecurityRequirement getSecurityRequireMent() { - return new SecurityRequirement().addList("bearer"); + private SecurityRequirement getSecurityRequirement() { + return new SecurityRequirement().addList("jwt token"); } } diff --git a/src/main/java/com/leets/xcellentbe/global/error/ErrorCode.java b/src/main/java/com/leets/xcellentbe/global/error/ErrorCode.java index 95a4df6..68324af 100644 --- a/src/main/java/com/leets/xcellentbe/global/error/ErrorCode.java +++ b/src/main/java/com/leets/xcellentbe/global/error/ErrorCode.java @@ -18,6 +18,7 @@ public enum ErrorCode { USER_ALREADY_EXISTS(412, "ALREADY_EXISTS_EXCEPTION", "이미 존재하는 사용자입니다."), ARTICLE_NOT_FOUND(404, "ARTICLE_NOT_FOUND", "게시물을 찾을 수 없습니다."), CHAT_ROOM_NOT_FOUND(404, "CHAT_ROOM_NOT_FOUND", "채팅방을 찾을 수 없습니다."), + DM_NOT_FOUND(404, "DM_NOT_FOUND", "메시지를 찾을 수 없습니다."), REJECT_DUPLICATION(409, "REJECT_DUPLICATION", "중복된 값입니다."), AUTH_CODE_ALREADY_SENT(429, "AUTH_CODE_ALREADY_SENT", "이미 인증번호를 전송했습니다."), INTERNAL_SERVER_ERROR(500, "INTERNAL_SERVER_ERROR", "서버 오류가 발생했습니다."),