diff --git a/build.gradle b/build.gradle index 05093d5..a0b7664 100644 --- a/build.gradle +++ b/build.gradle @@ -53,6 +53,12 @@ dependencies { annotationProcessor "jakarta.annotation:jakarta.annotation-api" // java.lang.NoClassDefFoundError (javax.annotation.Generated) 대응 코드 annotationProcessor "jakarta.persistence:jakarta.persistence-api" // java.lang.NoClassDefFoundError (javax.annotation.Entity) 대응 코드 + // websocket + implementation 'org.springframework.boot:spring-boot-starter-websocket' + + // Messaging + implementation 'org.springframework:spring-messaging:6.1.0' + // 이메일 SMTP implementation 'org.springframework.boot:spring-boot-starter-mail' @@ -60,9 +66,11 @@ dependencies { implementation 'org.springframework.boot:spring-boot-starter-thymeleaf' implementation 'nz.net.ultraq.thymeleaf:thymeleaf-layout-dialect' + // mongoDB + implementation 'org.springframework.boot:spring-boot-starter-data-mongodb' + // redis implementation 'org.springframework.boot:spring-boot-starter-data-redis' - } tasks.named('test') { @@ -85,4 +93,4 @@ sourceSets { // gradle clean 시에 QClass 디렉토리 삭제 clean { delete file(generated) -} \ No newline at end of file +} diff --git a/src/main/java/org/gachon/checkmate/domain/chat/controller/ChatController.java b/src/main/java/org/gachon/checkmate/domain/chat/controller/ChatController.java new file mode 100644 index 0000000..fabf3d0 --- /dev/null +++ b/src/main/java/org/gachon/checkmate/domain/chat/controller/ChatController.java @@ -0,0 +1,55 @@ +package org.gachon.checkmate.domain.chat.controller; + +import lombok.RequiredArgsConstructor; +import org.gachon.checkmate.domain.chat.dto.MessageType; +import org.gachon.checkmate.domain.chat.dto.request.ChatListRequestDto; +import org.gachon.checkmate.domain.chat.dto.request.ChatRequestDto; +import org.gachon.checkmate.domain.chat.dto.response.*; +import org.gachon.checkmate.domain.chat.service.ChatService; +import org.springframework.messaging.handler.annotation.Header; +import org.springframework.messaging.handler.annotation.MessageMapping; +import org.springframework.messaging.handler.annotation.Payload; +import org.springframework.messaging.simp.SimpMessageSendingOperations; +import org.springframework.web.bind.annotation.RestController; + +import java.util.Map; + +@RequiredArgsConstructor +@RestController +public class ChatController { + + private final ChatService chatService; + + private final SimpMessageSendingOperations sendingOperations; + + // 채팅 전송 + @MessageMapping("/chat") + public void sendChat(@Header("simpSessionAttributes") Map simpSessionAttributes, + @Payload final ChatRequestDto request) { + ChatResponseDto response = chatService.sendChat(simpSessionAttributes, request); + sendingOperations.convertAndSend("/queue/chat/"+simpSessionAttributes.get("roomId"), SocketBaseResponse.of(MessageType.CHAT, response)); + } + + // 채팅방 정보 조회 + @MessageMapping("/room-list") + public void getChatRoomList(@Header("simpSessionAttributes") Map simpSessionAttributes) { + ChatRoomListResponseDto response = chatService.getChatRoomList(simpSessionAttributes); + sendingOperations.convertAndSend("/queue/user/"+simpSessionAttributes.get("userId"), SocketBaseResponse.of(MessageType.ROOM_LIST, response)); + } + + // 이전 채팅 불러오기 + @MessageMapping("/chat-list") + public void getChatList(@Header("simpSessionAttributes") Map simpSessionAttributes, + @Payload final ChatListRequestDto request) { + final ChatListResponseDto response = chatService.getChatList(simpSessionAttributes, request); + sendingOperations.convertAndSend("/queue/user/" + simpSessionAttributes.get("userId"), SocketBaseResponse.of(MessageType.CHAT_LIST, response)); + } + + // 채팅방 입장하기 + @MessageMapping("/room-enter") + public void enterRoom(@Header("simpSessionAttributes") Map simpSessionAttributes, + @Payload final ChatListRequestDto request) { + ChatRoomEnterResponseDto response = chatService.enterChatRoom(simpSessionAttributes, request); + sendingOperations.convertAndSend("/queue/chat/" + response.chatRoomId(), SocketBaseResponse.of(MessageType.ROOM_ENTER, response)); + } +} diff --git a/src/main/java/org/gachon/checkmate/domain/chat/dto/ChatLastMessageDto.java b/src/main/java/org/gachon/checkmate/domain/chat/dto/ChatLastMessageDto.java new file mode 100644 index 0000000..2a901ff --- /dev/null +++ b/src/main/java/org/gachon/checkmate/domain/chat/dto/ChatLastMessageDto.java @@ -0,0 +1,25 @@ +package org.gachon.checkmate.domain.chat.dto; + +import lombok.Builder; +import org.gachon.checkmate.domain.chat.entity.Chat; + +import java.time.LocalDateTime; + +@Builder +public record ChatLastMessageDto ( + String content, + LocalDateTime sendTime +) { + public static ChatLastMessageDto of(Chat chat) { + return ChatLastMessageDto.builder() + .content(chat.getContent()) + .sendTime(chat.getSendTime()) + .build(); + } + public static ChatLastMessageDto createEmptyChat() { + return ChatLastMessageDto.builder() + .content(null) + .sendTime(null) + .build(); + } +} diff --git a/src/main/java/org/gachon/checkmate/domain/chat/dto/ChatMessageDto.java b/src/main/java/org/gachon/checkmate/domain/chat/dto/ChatMessageDto.java new file mode 100644 index 0000000..5fdfb49 --- /dev/null +++ b/src/main/java/org/gachon/checkmate/domain/chat/dto/ChatMessageDto.java @@ -0,0 +1,24 @@ +package org.gachon.checkmate.domain.chat.dto; + + +import lombok.Builder; +import org.gachon.checkmate.domain.chat.entity.Chat; + +import java.time.LocalDateTime; + +@Builder +public record ChatMessageDto ( + Long userId, + String content, + Boolean isRead, + LocalDateTime sendTime +) { + public static ChatMessageDto of(Chat chat) { + return ChatMessageDto.builder() + .userId(chat.getSenderId()) + .content(chat.getContent()) + .isRead(chat.getIsRead()) + .sendTime(chat.getSendTime()) + .build(); + } +} diff --git a/src/main/java/org/gachon/checkmate/domain/chat/dto/ChatRoomListDto.java b/src/main/java/org/gachon/checkmate/domain/chat/dto/ChatRoomListDto.java new file mode 100644 index 0000000..e664cec --- /dev/null +++ b/src/main/java/org/gachon/checkmate/domain/chat/dto/ChatRoomListDto.java @@ -0,0 +1,19 @@ +package org.gachon.checkmate.domain.chat.dto; + +import lombok.Builder; + + +@Builder +public record ChatRoomListDto( + ChatLastMessageDto lastChatInfo, + Long notReadCount, + ChatRoomListUserInfoDto userInfo +) { + public static ChatRoomListDto of(ChatLastMessageDto lastChatInfo, Long notReadCount, ChatRoomListUserInfoDto chatRoomListUserInfoDto) { + return ChatRoomListDto.builder() + .lastChatInfo(lastChatInfo) + .notReadCount(notReadCount) + .userInfo(chatRoomListUserInfoDto) + .build(); + } +} diff --git a/src/main/java/org/gachon/checkmate/domain/chat/dto/ChatRoomListUserInfoDto.java b/src/main/java/org/gachon/checkmate/domain/chat/dto/ChatRoomListUserInfoDto.java new file mode 100644 index 0000000..9a7ec18 --- /dev/null +++ b/src/main/java/org/gachon/checkmate/domain/chat/dto/ChatRoomListUserInfoDto.java @@ -0,0 +1,25 @@ +package org.gachon.checkmate.domain.chat.dto; + +import com.querydsl.core.annotations.QueryProjection; +import org.gachon.checkmate.domain.member.entity.GenderType; + +import java.time.LocalDate; + +public record ChatRoomListUserInfoDto( + Long userId, + String name, + String profile, + String major, + GenderType gender, + LocalDate endDate +) { + @QueryProjection + public ChatRoomListUserInfoDto(Long userId, String name, String profile, String major, GenderType gender, LocalDate endDate) { + this.userId = userId; + this.name = name; + this.profile = profile; + this.major = major; + this.gender = gender; + this.endDate = endDate; + } +} diff --git a/src/main/java/org/gachon/checkmate/domain/chat/dto/ChatUserInfoDto.java b/src/main/java/org/gachon/checkmate/domain/chat/dto/ChatUserInfoDto.java new file mode 100644 index 0000000..94aa551 --- /dev/null +++ b/src/main/java/org/gachon/checkmate/domain/chat/dto/ChatUserInfoDto.java @@ -0,0 +1,24 @@ +package org.gachon.checkmate.domain.chat.dto; + +import com.querydsl.core.annotations.QueryProjection; + +import java.time.LocalDate; + +public record ChatUserInfoDto( + Long userId, + String name, + String profile, + Long postId, + String title, + LocalDate endDate +) { + @QueryProjection + public ChatUserInfoDto(Long userId, String name, String profile, Long postId, String title, LocalDate endDate) { + this.userId = userId; + this.name = name; + this.profile = profile; + this.postId = postId; + this.title = title; + this.endDate = endDate; + } +} diff --git a/src/main/java/org/gachon/checkmate/domain/chat/dto/MessageType.java b/src/main/java/org/gachon/checkmate/domain/chat/dto/MessageType.java new file mode 100644 index 0000000..880446f --- /dev/null +++ b/src/main/java/org/gachon/checkmate/domain/chat/dto/MessageType.java @@ -0,0 +1,17 @@ +package org.gachon.checkmate.domain.chat.dto; + +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Getter; + +@AllArgsConstructor(access = AccessLevel.PRIVATE) +@Getter +public enum MessageType { + CHAT("CHAT"), + NEW_CHAT_NOTIFICATION("NEW_CHAT_NOTIFICATION"), + ROOM_ENTER("ROOM_ENTER"), + ROOM_LIST("ROOM_LIST"), + CHAT_LIST("CHAT_LIST"); + + private final String desc; +} diff --git a/src/main/java/org/gachon/checkmate/domain/chat/dto/request/ChatListRequestDto.java b/src/main/java/org/gachon/checkmate/domain/chat/dto/request/ChatListRequestDto.java new file mode 100644 index 0000000..7187777 --- /dev/null +++ b/src/main/java/org/gachon/checkmate/domain/chat/dto/request/ChatListRequestDto.java @@ -0,0 +1,13 @@ +package org.gachon.checkmate.domain.chat.dto.request; + +public record ChatListRequestDto( + Long otherUserId, + Integer pageNumber, + Integer pageSize +) { + public ChatListRequestDto(Long otherUserId, Integer pageNumber, Integer pageSize) { + this.otherUserId = otherUserId; + this.pageNumber = (pageNumber != null) ? pageNumber : 0; + this.pageSize = pageSize != null ? pageSize : 20; + } +} diff --git a/src/main/java/org/gachon/checkmate/domain/chat/dto/request/ChatRequestDto.java b/src/main/java/org/gachon/checkmate/domain/chat/dto/request/ChatRequestDto.java new file mode 100644 index 0000000..9dfc0ef --- /dev/null +++ b/src/main/java/org/gachon/checkmate/domain/chat/dto/request/ChatRequestDto.java @@ -0,0 +1,6 @@ +package org.gachon.checkmate.domain.chat.dto.request; + +public record ChatRequestDto( + String content +) { +} diff --git a/src/main/java/org/gachon/checkmate/domain/chat/dto/response/ChatListResponseDto.java b/src/main/java/org/gachon/checkmate/domain/chat/dto/response/ChatListResponseDto.java new file mode 100644 index 0000000..2835388 --- /dev/null +++ b/src/main/java/org/gachon/checkmate/domain/chat/dto/response/ChatListResponseDto.java @@ -0,0 +1,44 @@ +package org.gachon.checkmate.domain.chat.dto.response; + +import lombok.Builder; +import lombok.Getter; +import org.gachon.checkmate.domain.chat.dto.ChatMessageDto; +import org.gachon.checkmate.domain.chat.dto.ChatUserInfoDto; +import org.gachon.checkmate.domain.chat.entity.Chat; + +import java.util.ArrayList; +import java.util.List; + +@Builder +@Getter +public class ChatListResponseDto{ + + private String chatRoomId; + + private ChatUserInfoDto chatUserInfoDto; + + @Builder.Default + private List chatMessageList = new ArrayList<>(); + + @Builder.Default + private Boolean hasNextPage = null; + + @Builder.Default + private Integer pageNumber = null; + + public static ChatListResponseDto of(String chatRoomId, ChatUserInfoDto chatUserInfoDto) { + return ChatListResponseDto.builder() + .chatRoomId(chatRoomId) + .chatUserInfoDto(chatUserInfoDto) + .build(); + } + + public void addChatMessage(Chat chat) { + this.chatMessageList.add(ChatMessageDto.of(chat)); + } + + public void updatePageInfo(Boolean hasNextPage, Integer pageNumber) { + this.hasNextPage = hasNextPage; + this.pageNumber = pageNumber; + } +} diff --git a/src/main/java/org/gachon/checkmate/domain/chat/dto/response/ChatResponseDto.java b/src/main/java/org/gachon/checkmate/domain/chat/dto/response/ChatResponseDto.java new file mode 100644 index 0000000..6a357e7 --- /dev/null +++ b/src/main/java/org/gachon/checkmate/domain/chat/dto/response/ChatResponseDto.java @@ -0,0 +1,23 @@ +package org.gachon.checkmate.domain.chat.dto.response; + +import lombok.Builder; +import org.gachon.checkmate.domain.chat.entity.Chat; + +import java.time.LocalDateTime; + +@Builder +public record ChatResponseDto ( + Long senderId, + String content, + Boolean isRead, + LocalDateTime sendTime +) { + public static ChatResponseDto of(Chat chat) { + return ChatResponseDto.builder() + .senderId(chat.getSenderId()) + .content(chat.getContent()) + .isRead(chat.getIsRead()) + .sendTime(chat.getSendTime()) + .build(); + } +} diff --git a/src/main/java/org/gachon/checkmate/domain/chat/dto/response/ChatRoomEnterResponseDto.java b/src/main/java/org/gachon/checkmate/domain/chat/dto/response/ChatRoomEnterResponseDto.java new file mode 100644 index 0000000..df3a9cb --- /dev/null +++ b/src/main/java/org/gachon/checkmate/domain/chat/dto/response/ChatRoomEnterResponseDto.java @@ -0,0 +1,16 @@ +package org.gachon.checkmate.domain.chat.dto.response; + +import lombok.Builder; + +@Builder +public record ChatRoomEnterResponseDto ( + Long userId, + String chatRoomId +) { + public static ChatRoomEnterResponseDto of(Long userId, String chatRoomId) { + return ChatRoomEnterResponseDto.builder() + .userId(userId) + .chatRoomId(chatRoomId) + .build(); + } +} diff --git a/src/main/java/org/gachon/checkmate/domain/chat/dto/response/ChatRoomListResponseDto.java b/src/main/java/org/gachon/checkmate/domain/chat/dto/response/ChatRoomListResponseDto.java new file mode 100644 index 0000000..33b78c8 --- /dev/null +++ b/src/main/java/org/gachon/checkmate/domain/chat/dto/response/ChatRoomListResponseDto.java @@ -0,0 +1,18 @@ +package org.gachon.checkmate.domain.chat.dto.response; + +import lombok.Builder; +import org.gachon.checkmate.domain.chat.dto.ChatRoomListDto; + +import java.util.List; + + +@Builder +public record ChatRoomListResponseDto( + List chatRoomList +) { + public static ChatRoomListResponseDto of(List chatRoomListDto) { + return ChatRoomListResponseDto.builder() + .chatRoomList(chatRoomListDto) + .build(); + } +} diff --git a/src/main/java/org/gachon/checkmate/domain/chat/dto/response/NewChatResponseDto.java b/src/main/java/org/gachon/checkmate/domain/chat/dto/response/NewChatResponseDto.java new file mode 100644 index 0000000..314a6fd --- /dev/null +++ b/src/main/java/org/gachon/checkmate/domain/chat/dto/response/NewChatResponseDto.java @@ -0,0 +1,23 @@ +package org.gachon.checkmate.domain.chat.dto.response; + +import lombok.Builder; +import org.gachon.checkmate.domain.chat.entity.Chat; + +import java.time.LocalDateTime; + +@Builder +public record NewChatResponseDto( + String chatRoomId, + Long senderId, + String content, + LocalDateTime sendTime +) { + public static NewChatResponseDto of(Chat chat) { + return NewChatResponseDto.builder() + .chatRoomId(chat.getChatRoomId()) + .senderId(chat.getSenderId()) + .content(chat.getContent()) + .sendTime(chat.getSendTime()) + .build(); + } +} diff --git a/src/main/java/org/gachon/checkmate/domain/chat/dto/response/SocketBaseResponse.java b/src/main/java/org/gachon/checkmate/domain/chat/dto/response/SocketBaseResponse.java new file mode 100644 index 0000000..a7a50c1 --- /dev/null +++ b/src/main/java/org/gachon/checkmate/domain/chat/dto/response/SocketBaseResponse.java @@ -0,0 +1,22 @@ +package org.gachon.checkmate.domain.chat.dto.response; + +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import org.gachon.checkmate.domain.chat.dto.MessageType; + +@AllArgsConstructor(access = AccessLevel.PRIVATE) +@Builder +@Getter +public class SocketBaseResponse { + private MessageType messageType; + private T data; + + public static SocketBaseResponse of(MessageType messageType, T data) { + return SocketBaseResponse.builder() + .messageType(messageType) + .data(data) + .build(); + } +} diff --git a/src/main/java/org/gachon/checkmate/domain/chat/entity/Chat.java b/src/main/java/org/gachon/checkmate/domain/chat/entity/Chat.java new file mode 100644 index 0000000..edaa1c9 --- /dev/null +++ b/src/main/java/org/gachon/checkmate/domain/chat/entity/Chat.java @@ -0,0 +1,52 @@ +package org.gachon.checkmate.domain.chat.entity; + +import jakarta.persistence.Entity; +import jakarta.persistence.Id; +import lombok.*; +import org.springframework.data.annotation.LastModifiedDate; +import org.springframework.data.mongodb.core.mapping.Document; +import org.springframework.data.mongodb.core.mapping.Field; + +import java.time.LocalDateTime; + +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor(access = AccessLevel.PRIVATE) +@Getter +@Builder +@Entity +@Document(collection = "chatting") +/** + * 채팅 정보를 담는 객체로 몽고db를 통해 관리됩니다. + * isRead라는 것을 통해 상대가 이 메시지를 읽었는지 판단합니다. + */ +public class Chat { + + @Id + @Field(name="_id") + private String id; + + @Field(name="chat_room_id") + private String chatRoomId; + + @Field(name="sender_id") + private Long senderId; + + @Field(name="content") + private String content; + + @Field(name="is_read") + private Boolean isRead; + + @LastModifiedDate + @Field(name="send_time") + private LocalDateTime sendTime; + + public static Chat createChat(String chatRoomId, Long senderId, String content, Boolean isRead) { + return Chat.builder() + .chatRoomId(chatRoomId) + .senderId(senderId) + .content(content) + .isRead(isRead) + .build(); + } +} diff --git a/src/main/java/org/gachon/checkmate/domain/chat/entity/ChatRoom.java b/src/main/java/org/gachon/checkmate/domain/chat/entity/ChatRoom.java new file mode 100644 index 0000000..2e0bb43 --- /dev/null +++ b/src/main/java/org/gachon/checkmate/domain/chat/entity/ChatRoom.java @@ -0,0 +1,43 @@ +package org.gachon.checkmate.domain.chat.entity; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import lombok.*; +import org.gachon.checkmate.global.common.BaseTimeEntity; + +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor(access = AccessLevel.PRIVATE) +@Builder(access = AccessLevel.PRIVATE) +@Entity +@Getter +@Table(name = "chat_room") +/** + * 채팅방에 포함된 두명의 사용자를 판단하기 위해 만들었습니다. + * 이때 채팅방의 id는 autoIncrement 되는것이 아닌 두 사용자의 id를 사용해 만들어지게 됩니다. + * ex) user1 id = 1, user2 id = 2라면 채팅방 id는 "1+2" 이 되고 항상 작은 숫자가 앞으로 가게 됩니다. + * ex) user1 id = 10, user2 id = 3 , 채팅방 id = "3+10" + */ +// ChatRoom의 경우 DB에 저장해서 해당 채팅방 id를 통해 채팅방 속 두명의 사용자를 판단하기 위해 만들었습니다. +public class ChatRoom extends BaseTimeEntity { + + @Id + @Column(name = "chat_room_id", length = 100) + private String id; + + @Column(name = "first_member_id") + private Long firstMemberId; + + @Column(name = "second_member_id") + private Long secondMemberId; + + public static ChatRoom createChatRoom(String id, Long firstMemberId, Long secondMemberId) { + return ChatRoom.builder() + .id(id) + .firstMemberId(firstMemberId) + .secondMemberId(secondMemberId) + .build(); + } + +} diff --git a/src/main/java/org/gachon/checkmate/domain/chat/entity/LiveChatRoom.java b/src/main/java/org/gachon/checkmate/domain/chat/entity/LiveChatRoom.java new file mode 100644 index 0000000..7e53757 --- /dev/null +++ b/src/main/java/org/gachon/checkmate/domain/chat/entity/LiveChatRoom.java @@ -0,0 +1,38 @@ +package org.gachon.checkmate.domain.chat.entity; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import org.springframework.data.annotation.Id; +import org.springframework.data.redis.core.RedisHash; +import org.springframework.data.redis.core.index.Indexed; + +@Builder +@Getter +@AllArgsConstructor +@NoArgsConstructor +@RedisHash(value = "chatRoom") +/** + * 채팅을 보내고 읽음/안읽음 표시를 하기 위해 유저의 채팅방 입장 상황을 redis로 받기 위한 객체입니다. + * .채팅방에 입장할 때 LiveChatRoom에 유저의 저장을 하고 Unsubscribe하거나 disconnect됐을 때 삭제합니다 + */ +public class LiveChatRoom { + + @Id + private String id; + + @Indexed + private String chatRoomId; + + @Indexed + private Long userId; + + + public static LiveChatRoom createLiveChatRoom(String chatRoomId, Long userId) { + return LiveChatRoom.builder() + .chatRoomId(chatRoomId) + .userId(userId) + .build(); + } +} diff --git a/src/main/java/org/gachon/checkmate/domain/chat/mongorepository/ChatCustomRepository.java b/src/main/java/org/gachon/checkmate/domain/chat/mongorepository/ChatCustomRepository.java new file mode 100644 index 0000000..1d706f4 --- /dev/null +++ b/src/main/java/org/gachon/checkmate/domain/chat/mongorepository/ChatCustomRepository.java @@ -0,0 +1,17 @@ +package org.gachon.checkmate.domain.chat.mongorepository; + +import org.gachon.checkmate.domain.chat.dto.ChatLastMessageDto; +import org.gachon.checkmate.domain.chat.entity.Chat; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Slice; + +public interface ChatCustomRepository { + + Slice findBeforeChatList(final String chatRoomId, final Pageable pageable); + + void updateChatRead(final String chatRoomId, final Long userId); + + ChatLastMessageDto findLastChatRoomContent(final String chatRoomId); + + Long findUserNotReadCount(final String chatRoomId, final Long userId); +} diff --git a/src/main/java/org/gachon/checkmate/domain/chat/mongorepository/ChatCustomRepositoryImpl.java b/src/main/java/org/gachon/checkmate/domain/chat/mongorepository/ChatCustomRepositoryImpl.java new file mode 100644 index 0000000..f0cdcb8 --- /dev/null +++ b/src/main/java/org/gachon/checkmate/domain/chat/mongorepository/ChatCustomRepositoryImpl.java @@ -0,0 +1,92 @@ +package org.gachon.checkmate.domain.chat.mongorepository; + +import lombok.RequiredArgsConstructor; +import org.gachon.checkmate.domain.chat.dto.ChatLastMessageDto; +import org.gachon.checkmate.domain.chat.entity.Chat; +import org.springframework.data.domain.*; +import org.springframework.data.mongodb.core.MongoTemplate; +import org.springframework.data.mongodb.core.query.Criteria; +import org.springframework.data.mongodb.core.query.Query; +import org.springframework.data.mongodb.core.query.Update; + +import java.util.List; + +import static org.gachon.checkmate.domain.chat.dto.ChatLastMessageDto.createEmptyChat; + +@RequiredArgsConstructor +public class ChatCustomRepositoryImpl implements ChatCustomRepository { + private final MongoTemplate mongoTemplate; + + /** + * 채팅방의 이전 채팅들을 확인하는 메소드 + */ + @Override + public Slice findBeforeChatList(final String chatRoomId, final Pageable pageable) { + Query query = new Query() + .with(pageable) + .skip(pageable.getPageSize() * pageable.getPageNumber()) // offset + .limit(pageable.getPageSize()+1); + query.with(Sort.by(Sort.Order.desc("sendTime"))); + + query.addCriteria(Criteria.where("chatRoomId").is(chatRoomId)); + List chats = mongoTemplate.find(query, Chat.class, "chatting"); + + return new SliceImpl<>(chats, pageable, hasNextPage(chats, pageable.getPageSize())); + } + + /** + * 채팅방에 입장하고 안읽은 메시지를 읽음 처리해주는 메소드 + */ + @Override + public void updateChatRead(final String chatRoomId, final Long userId) { + Query query = new Query(); + Update update = new Update(); + + query.addCriteria(Criteria.where("chatRoomId").is(chatRoomId) + .and("sender").ne(userId)); + + update.set("isRead", true); + mongoTemplate.updateMulti(query, update, Chat.class); + } + + /** + * 채팅방에 남겨진 마지막 채팅 메시지를 가져오는 메소드 + */ + @Override + public ChatLastMessageDto findLastChatRoomContent(final String chatRoomId) { + + Pageable pageable = PageRequest.of(0, 1); + + Query query = new Query() + .with(pageable) + .skip(pageable.getPageSize() * pageable.getPageNumber()) // offset + .limit(pageable.getPageSize()); + query.with(Sort.by(Sort.Order.desc("sendTime"))); + query.addCriteria(Criteria.where("chatRoomId").is(chatRoomId)); + + List chats = mongoTemplate.find(query, Chat.class, "chatting"); + return chats.isEmpty() ? createEmptyChat() : ChatLastMessageDto.of(chats.get(0)); + } + + /** + * 채팅방에 유저가 읽지않은 메시지의 수를 가져오는 메소드 + */ + @Override + public Long findUserNotReadCount(final String chatRoomId, final Long userId) { + Query query = new Query(); + + query.addCriteria(Criteria.where("chatRoomId").is(chatRoomId) + .and("senderId").ne(userId) + .and("isRead").is(false)); + + return mongoTemplate.count(query, Chat.class); + } + + private boolean hasNextPage(List chats, int pageSize) { + if (chats.size() > pageSize) { + chats.remove(pageSize); + return true; + } + return false; + } +} diff --git a/src/main/java/org/gachon/checkmate/domain/chat/mongorepository/ChatRepository.java b/src/main/java/org/gachon/checkmate/domain/chat/mongorepository/ChatRepository.java new file mode 100644 index 0000000..642de06 --- /dev/null +++ b/src/main/java/org/gachon/checkmate/domain/chat/mongorepository/ChatRepository.java @@ -0,0 +1,9 @@ +package org.gachon.checkmate.domain.chat.mongorepository; + + +import org.gachon.checkmate.domain.chat.entity.Chat; +import org.springframework.data.mongodb.repository.MongoRepository; + +public interface ChatRepository extends MongoRepository, ChatCustomRepository { + +} diff --git a/src/main/java/org/gachon/checkmate/domain/chat/repository/ChatRoomRepository.java b/src/main/java/org/gachon/checkmate/domain/chat/repository/ChatRoomRepository.java new file mode 100644 index 0000000..2da602f --- /dev/null +++ b/src/main/java/org/gachon/checkmate/domain/chat/repository/ChatRoomRepository.java @@ -0,0 +1,10 @@ +package org.gachon.checkmate.domain.chat.repository; + +import org.gachon.checkmate.domain.chat.entity.ChatRoom; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; + +public interface ChatRoomRepository extends JpaRepository { + List findAllByFirstMemberIdOrSecondMemberId(Long firstMemberId, Long secondMemberId); +} diff --git a/src/main/java/org/gachon/checkmate/domain/chat/repository/LiveChatRoomRepository.java b/src/main/java/org/gachon/checkmate/domain/chat/repository/LiveChatRoomRepository.java new file mode 100644 index 0000000..59e9e85 --- /dev/null +++ b/src/main/java/org/gachon/checkmate/domain/chat/repository/LiveChatRoomRepository.java @@ -0,0 +1,14 @@ +package org.gachon.checkmate.domain.chat.repository; + +import org.gachon.checkmate.domain.chat.entity.LiveChatRoom; +import org.springframework.data.repository.CrudRepository; + +import java.util.List; + +public interface LiveChatRoomRepository extends CrudRepository { + + List findAllByUserId(Long userId); + + Boolean existsLiveChatRoomByChatRoomIdAndUserId(String chatRoomId, Long userId); + +} diff --git a/src/main/java/org/gachon/checkmate/domain/chat/service/ChatService.java b/src/main/java/org/gachon/checkmate/domain/chat/service/ChatService.java new file mode 100644 index 0000000..d3ee534 --- /dev/null +++ b/src/main/java/org/gachon/checkmate/domain/chat/service/ChatService.java @@ -0,0 +1,216 @@ +package org.gachon.checkmate.domain.chat.service; + +import lombok.RequiredArgsConstructor; +import org.gachon.checkmate.domain.chat.dto.*; +import org.gachon.checkmate.domain.chat.dto.request.ChatListRequestDto; +import org.gachon.checkmate.domain.chat.dto.request.ChatRequestDto; +import org.gachon.checkmate.domain.chat.dto.response.*; +import org.gachon.checkmate.domain.chat.entity.Chat; +import org.gachon.checkmate.domain.chat.entity.ChatRoom; +import org.gachon.checkmate.domain.chat.entity.LiveChatRoom; +import org.gachon.checkmate.domain.chat.mongorepository.ChatRepository; +import org.gachon.checkmate.domain.chat.repository.ChatRoomRepository; +import org.gachon.checkmate.domain.chat.repository.LiveChatRoomRepository; +import org.gachon.checkmate.domain.chat.util.ListComparatorChatSendTime; +import org.gachon.checkmate.domain.member.repository.UserQuerydslRepository; +import org.gachon.checkmate.global.socket.error.SocketErrorCode; +import org.gachon.checkmate.global.socket.error.SocketNotFoundException; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Slice; +import org.springframework.messaging.simp.SimpMessageSendingOperations; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +import static org.gachon.checkmate.domain.chat.entity.ChatRoom.createChatRoom; +import static org.gachon.checkmate.domain.chat.entity.LiveChatRoom.createLiveChatRoom; + +@Service +@Transactional +@RequiredArgsConstructor +public class ChatService { + + private final SimpMessageSendingOperations sendingOperations; + private final ChatRepository chatRepository; + private final ChatRoomRepository chatRoomRepository; + private final LiveChatRoomRepository liveChatRoomRepository; + private final UserQuerydslRepository userQuerydslRepository; + + /** + * 채팅을 전송하는 코드입니다. + * 1. 채팅상대가 채팅방에 들어와있는지 확인하고 앍맞은 isRead값을 넣은 채팅을 MongoDB에 저장합니다. + * 2. 만약 다른 유저가 채팅에 들어와있지 않다면 해당 유저의 /queue/user/{다른 유저 번호}로 알림 메세지를 보냅니다. + * 3. 채팅을 return해주고 controller에서 /queue/chat/{채팅방Id}로 메세지를 보냅니다. + */ + public ChatResponseDto sendChat(Map simpSessionAttributes, + ChatRequestDto request) { + String roomId = getRoomIdInAttributes(simpSessionAttributes); + Long userId = getUserIdInAttributes(simpSessionAttributes); + ChatRoom chatRoom = getChatRoomById(roomId); + Long otherUserId = getOtherUserIdInChatRoom(chatRoom, userId); + // 채팅상대가 이 채팅방에 들어와있는지 확인함 + Boolean isOtherUserInChatRoom = isUserInChatRoom(roomId, otherUserId); + Chat chat = Chat.createChat(roomId, userId, request.content(), isOtherUserInChatRoom); + Chat savedChat = chatRepository.save(chat); + + // 만약 채팅방에 다른 유저가 안들어와있을 때 알림가도록 메세지 전송해줌 + sendNotificationToOtherUser(isOtherUserInChatRoom, savedChat, otherUserId); + + return ChatResponseDto.of(savedChat); + } + + /** + * 유저의 채팅방들을 불러오는 코드입니다. + * 1. 유저가 속한 채팅방을 가져옵니다. + * 2. 해당 채팅방에서 각각 채팅 상대 유저의 정보, 채팅방의 마지막 채팅내용, 읽지않은 채팅 수를 나타냅니다. + * 3. 채팅방의 마지막 채팅 최신순으로 정렬해줍니다. + */ + public ChatRoomListResponseDto getChatRoomList(Map simpSessionAttributes) { + Long userId = getUserIdInAttributes(simpSessionAttributes); + List chatRooms = getUserChatRoomsByUserId(userId); + List response = chatRooms.stream() + .map(chatRoom -> getChatRoomListResponse(chatRoom, userId)) + .collect(Collectors.toList()); + + // 최신 메시지순으로 정렬 + sortChatRoomListDtoByLastSendTime(response); + return ChatRoomListResponseDto.of(response); + } + + /** + * 채팅방에 들어가 이전 채팅목록을 불러오는 코드입니다. + * 1. 다른 유저의 유저 정보를 받아옵니다. + * 2. 다른 유저가 작성한 post의 정보를 받아옵니다. + * 3. 해당 채팅방이 처음 만들어지는 건지 아니면 이미 있는 채팅방인지 확인합니다. + * 3-1. 만약 채팅방 있다면 mongoDB의 chatting에서 해당 채팅방의 채팅 내역을 20개씩 끊어 받아옵니다. + * 3-2. 만약 채팅방이 없다면(처음 생성된것이라면) 새로운 채팅방을 만들어 저장해줍니다. (이때 이전 채팅들은 null로 들어갑니다.) + */ + public ChatListResponseDto getChatList(Map simpSessionAttributes, + ChatListRequestDto request) { + Long userId = getUserIdInAttributes(simpSessionAttributes); + String chatRoomId = getChatRoomId(request.otherUserId(), userId); + + ChatUserInfoDto chatUserInfoDto = getUserChatUserInfoByUserId(request); + ChatListResponseDto response = ChatListResponseDto.of(chatRoomId, chatUserInfoDto); + + PageRequest pageRequest = PageRequest.of(request.pageNumber(), request.pageSize()); + Slice chatMessages = chatRepository.findBeforeChatList(chatRoomId, pageRequest); + chatMessages.getContent().forEach(response::addChatMessage); + response.updatePageInfo(chatMessages.hasNext(), chatMessages.getNumber()); + + return response; + } + + /** + * 채팅방을 들어올 때 실행합니다. + * 1. redis에 해당 채팅방에 유저가 들어왔다는 것을 저장합니다. + * 2. 이전에 유저가 읽지 않은 채팅을 모두 읽음처리 해줍니다. + */ + public ChatRoomEnterResponseDto enterChatRoom(Map simpSessionAttributes, + ChatListRequestDto request) { + Long userId = getUserIdInAttributes(simpSessionAttributes); + String chatRoomId = getChatRoomId(request.otherUserId(), userId); + + // 해당 채팅방에 유저 접속상태 확인 위해 redis에 저장 + saveUserInLiveChatRoom(chatRoomId, userId); + + // 채팅방이 있는지 확인하고 없다면 하나 새로 만들어줌 (채팅방 id : 유저 두명의 아이디인데 작은 id가 앞으로감 + // ex) 유저 아이디가 10, 3 인 채팅방이면 3:10이 있는지 확인 + if(validateChatRoomExist(chatRoomId)) { + // 안읽은 메세지들 읽음 처리 해줌 + chatRepository.updateChatRead(chatRoomId, userId); + } + else { + ChatRoom chatRoom = createChatRoom(chatRoomId, userId, request.otherUserId()); + chatRoomRepository.save(chatRoom); + } + return ChatRoomEnterResponseDto.of(userId, chatRoomId); + } + + private void sendNotificationToOtherUser(Boolean isOtherUserInChatRoom, Chat savedChat, Long otherUserId) { + if(!isOtherUserInChatRoom) { + NewChatResponseDto notificationChat = NewChatResponseDto.of(savedChat); + sendingOperations.convertAndSend("/queue/user/" + otherUserId, SocketBaseResponse.of(MessageType.NEW_CHAT_NOTIFICATION, notificationChat)); + } + } + + private void sortChatRoomListDtoByLastSendTime(List chatRoomListDtos) { + if(!chatRoomListDtos.isEmpty()) { + chatRoomListDtos.sort(new ListComparatorChatSendTime()); + } + } + + private ChatRoomListDto getChatRoomListResponse(ChatRoom chatRoom, Long userId) { + Long otherUserId = getChatRoomOtherUserId(chatRoom, userId); + // 채팅방 목록에 표시돼야하는 상대 유저의 정보, 게시물 마감일을 가져옴 + ChatRoomListUserInfoDto otherUserInfo = getUserChatRoomListInfoByUserId(otherUserId); + // 채팅방에 있는 마지막 채팅 내용 가져옴 + ChatLastMessageDto chatLastMessageDto = getLastChatRoomContent(chatRoom); + // 채팅방 유저가 안읽은 메세지 수 가져옴 + Long userNotReadCount = getUserNotReadCount(chatRoom, userId); + + return ChatRoomListDto.of(chatLastMessageDto,userNotReadCount, otherUserInfo); + } + + private ChatRoomListUserInfoDto getUserChatRoomListInfoByUserId(Long userId) { + return userQuerydslRepository.findUserChatRoomListInfo(userId); + } + + private Long getUserNotReadCount(ChatRoom chatRoom, Long userId) { + return chatRepository.findUserNotReadCount(chatRoom.getId(), userId); + } + + private ChatLastMessageDto getLastChatRoomContent(ChatRoom chatRoom) { + return chatRepository.findLastChatRoomContent(chatRoom.getId()); + } + + private Long getChatRoomOtherUserId(ChatRoom chatRoom, Long userId) { + return chatRoom.getFirstMemberId().equals(userId) ? chatRoom.getSecondMemberId() : chatRoom.getFirstMemberId(); + } + + private List getUserChatRoomsByUserId(Long userId) { + return chatRoomRepository.findAllByFirstMemberIdOrSecondMemberId(userId, userId); + } + + private ChatUserInfoDto getUserChatUserInfoByUserId(ChatListRequestDto request) { + return userQuerydslRepository.findUserChatUserInfo(request.otherUserId()); + } + + private void saveUserInLiveChatRoom(String chatRoomId, Long userId) { + LiveChatRoom liveChatRoom = createLiveChatRoom(chatRoomId, userId); + liveChatRoomRepository.save(liveChatRoom); + } + + private Boolean isUserInChatRoom(String roomId, Long userId) { + return liveChatRoomRepository.existsLiveChatRoomByChatRoomIdAndUserId(roomId, userId); + } + + private ChatRoom getChatRoomById(String roomId) { + return chatRoomRepository.findById(roomId) + .orElseThrow(() -> new SocketNotFoundException(SocketErrorCode.CHATROOM_NOT_FOUND)); + } + + private Long getOtherUserIdInChatRoom(ChatRoom chatRoom, Long myUserId) { + return chatRoom.getFirstMemberId().equals(myUserId) ? chatRoom.getSecondMemberId() : chatRoom.getFirstMemberId(); + } + + private boolean validateChatRoomExist(String chatRoomId) { + return chatRoomRepository.existsById(chatRoomId); + } + + private String getChatRoomId(Long otherUserId, Long userId) { + return userId < otherUserId ? userId + "+" + otherUserId : otherUserId + "+" + userId; + } + + private Long getUserIdInAttributes(Map simpSessionAttributes) { + return (Long)simpSessionAttributes.get("userId"); + } + + private String getRoomIdInAttributes(Map simpSessionAttributes) { + return (String)simpSessionAttributes.get("roomId"); + } + +} diff --git a/src/main/java/org/gachon/checkmate/domain/chat/util/ListComparatorChatSendTime.java b/src/main/java/org/gachon/checkmate/domain/chat/util/ListComparatorChatSendTime.java new file mode 100644 index 0000000..d10c6a5 --- /dev/null +++ b/src/main/java/org/gachon/checkmate/domain/chat/util/ListComparatorChatSendTime.java @@ -0,0 +1,15 @@ +package org.gachon.checkmate.domain.chat.util; + +import org.gachon.checkmate.domain.chat.dto.ChatRoomListDto; + +import java.util.Comparator; + +public class ListComparatorChatSendTime implements Comparator { + @Override + public int compare(ChatRoomListDto o1, ChatRoomListDto o2) { + if(o1.lastChatInfo().sendTime() == null || o2.lastChatInfo().sendTime() == null) { + return 0; + } + return o2.lastChatInfo().sendTime().compareTo(o1.lastChatInfo().sendTime()); + } +} diff --git a/src/main/java/org/gachon/checkmate/domain/member/repository/UserQuerydslRepository.java b/src/main/java/org/gachon/checkmate/domain/member/repository/UserQuerydslRepository.java new file mode 100644 index 0000000..d9824ae --- /dev/null +++ b/src/main/java/org/gachon/checkmate/domain/member/repository/UserQuerydslRepository.java @@ -0,0 +1,68 @@ +package org.gachon.checkmate.domain.member.repository; + +import com.querydsl.core.types.OrderSpecifier; +import com.querydsl.core.types.dsl.BooleanExpression; +import com.querydsl.jpa.impl.JPAQueryFactory; +import lombok.RequiredArgsConstructor; +import org.gachon.checkmate.domain.chat.dto.ChatRoomListUserInfoDto; +import org.gachon.checkmate.domain.chat.dto.ChatUserInfoDto; +import org.gachon.checkmate.domain.chat.dto.QChatRoomListUserInfoDto; +import org.gachon.checkmate.domain.chat.dto.QChatUserInfoDto; +import org.springframework.stereotype.Repository; + +import static org.gachon.checkmate.domain.member.entity.QUser.user; +import static org.gachon.checkmate.domain.post.entity.QPost.post; + + +@RequiredArgsConstructor +@Repository +public class UserQuerydslRepository { + private final JPAQueryFactory queryFactory; + + public ChatRoomListUserInfoDto findUserChatRoomListInfo(Long userId) { + return queryFactory + .select(new QChatRoomListUserInfoDto( + user.id, + user.name, + user.profile, + user.major, + user.gender, + post.endDate + )) + .from(user) + .leftJoin(user.postList, post) + .where( + eqUserId(userId) + ) + .orderBy(postEndDateDesc()) + .limit(1) + .fetchOne(); + } + public ChatUserInfoDto findUserChatUserInfo(Long userId) { + return queryFactory + .select(new QChatUserInfoDto( + user.id, + user.name, + user.profile, + post.id, + post.title, + post.endDate + )) + .from(user) + .leftJoin(user.postList, post) + .where( + eqUserId(userId) + ) + .orderBy(post.endDate.desc()) + .limit(1) + .fetchOne(); + } + + private static OrderSpecifier postEndDateDesc() { + return post.endDate != null ? post.endDate.desc() : null; + } + + private BooleanExpression eqUserId(Long userId) { + return user.id.eq(userId); + } +} diff --git a/src/main/java/org/gachon/checkmate/global/config/CorsConfig.java b/src/main/java/org/gachon/checkmate/global/config/CorsConfig.java index 4672fd3..9936a15 100644 --- a/src/main/java/org/gachon/checkmate/global/config/CorsConfig.java +++ b/src/main/java/org/gachon/checkmate/global/config/CorsConfig.java @@ -16,6 +16,7 @@ public CorsFilter corsFilter() { CorsConfiguration config = new CorsConfiguration(); config.setAllowCredentials(true); config.addAllowedOrigin("http://localhost:3000"); + config.addAllowedOrigin("https://jxy.me"); //stomp 테스트를 위한 사이트 cors 등록 config.addAllowedHeader("*"); config.setAllowedMethods(Arrays.asList("HEAD", "GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS")); source.registerCorsConfiguration("/**", config); diff --git a/src/main/java/org/gachon/checkmate/global/config/JpaConfig.java b/src/main/java/org/gachon/checkmate/global/config/JpaConfig.java index 1492bf5..1d2ad06 100644 --- a/src/main/java/org/gachon/checkmate/global/config/JpaConfig.java +++ b/src/main/java/org/gachon/checkmate/global/config/JpaConfig.java @@ -2,9 +2,11 @@ import org.springframework.context.annotation.Configuration; import org.springframework.data.jpa.repository.config.EnableJpaAuditing; +import org.springframework.data.jpa.repository.config.EnableJpaRepositories; -@EnableJpaAuditing @Configuration +@EnableJpaAuditing +@EnableJpaRepositories(basePackages = "org.gachon.checkmate.domain.*.repository") public class JpaConfig { } diff --git a/src/main/java/org/gachon/checkmate/global/config/MongoDbConfig.java b/src/main/java/org/gachon/checkmate/global/config/MongoDbConfig.java new file mode 100644 index 0000000..d22ab19 --- /dev/null +++ b/src/main/java/org/gachon/checkmate/global/config/MongoDbConfig.java @@ -0,0 +1,33 @@ +package org.gachon.checkmate.global.config; + +import lombok.RequiredArgsConstructor; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.data.mongodb.MongoDatabaseFactory; +import org.springframework.data.mongodb.config.EnableMongoAuditing; +import org.springframework.data.mongodb.core.convert.DbRefResolver; +import org.springframework.data.mongodb.core.convert.DefaultDbRefResolver; +import org.springframework.data.mongodb.core.convert.DefaultMongoTypeMapper; +import org.springframework.data.mongodb.core.convert.MappingMongoConverter; +import org.springframework.data.mongodb.core.mapping.MongoMappingContext; +import org.springframework.data.mongodb.repository.config.EnableMongoRepositories; + +@Configuration +@RequiredArgsConstructor +@EnableMongoAuditing +@EnableMongoRepositories(basePackages = "org.gachon.checkmate.domain.chat.mongorepository") +public class MongoDbConfig { + + private final MongoMappingContext mongoMappingContext; + + // 데이터 저장시 "_class" 필드가 자동 추가되는 현상 방지를 위한 설정 + @Bean + public MappingMongoConverter mappingMongoConverter(MongoDatabaseFactory mongoDatabaseFactory, + MongoMappingContext mongoMappingContext) { + DbRefResolver dbRefResolver = new DefaultDbRefResolver(mongoDatabaseFactory); + MappingMongoConverter converter = new MappingMongoConverter(dbRefResolver, mongoMappingContext); + converter.setTypeMapper(new DefaultMongoTypeMapper(null)); + return converter; + } + +} diff --git a/src/main/java/org/gachon/checkmate/global/config/WebSocketConfig.java b/src/main/java/org/gachon/checkmate/global/config/WebSocketConfig.java new file mode 100644 index 0000000..92df108 --- /dev/null +++ b/src/main/java/org/gachon/checkmate/global/config/WebSocketConfig.java @@ -0,0 +1,39 @@ +package org.gachon.checkmate.global.config; + +import lombok.RequiredArgsConstructor; +import org.gachon.checkmate.global.socket.handler.StompErrorHandler; +import org.gachon.checkmate.global.socket.interceptor.StompInterceptor; +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; + +@RequiredArgsConstructor +@Configuration +@EnableWebSocketMessageBroker +public class WebSocketConfig implements WebSocketMessageBrokerConfigurer { + + private final StompInterceptor stompInterceptor; + private final StompErrorHandler stompErrorHandler; + + @Override + public void configureMessageBroker(MessageBrokerRegistry registry) { + registry.setApplicationDestinationPrefixes("/app"); // 발행자가 "/app"의 경로로 메시지를 주면 가공을 해서 구독자들에게 전달 + registry.enableSimpleBroker("/queue", "/topic"); //관습적으로 "/queue"의 경우 1-1 연결, "/topic"은 1-N의 경우 사용한다함 + } + + @Override + public void registerStompEndpoints(StompEndpointRegistry registry) { + registry.addEndpoint("/ws").setAllowedOrigins("*"); + registry.addEndpoint("/ws").setAllowedOrigins("*").withSockJS(); + + registry.setErrorHandler(stompErrorHandler); + } + + @Override + public void configureClientInboundChannel(ChannelRegistration registration) { + registration.interceptors(stompInterceptor); + } +} diff --git a/src/main/java/org/gachon/checkmate/global/config/auth/SecurityConfig.java b/src/main/java/org/gachon/checkmate/global/config/auth/SecurityConfig.java index 6f133ba..acc0f8b 100644 --- a/src/main/java/org/gachon/checkmate/global/config/auth/SecurityConfig.java +++ b/src/main/java/org/gachon/checkmate/global/config/auth/SecurityConfig.java @@ -29,7 +29,9 @@ public class SecurityConfig { "api/member/email", "api/member/signup", "api/member/signin", - "api/member/reissue" + "api/member/reissue", + "/ws/*", + "/ws/**" }; @Bean @@ -59,4 +61,4 @@ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { public PasswordEncoder passwordEncoder() { return new BCryptPasswordEncoder(); } -} \ No newline at end of file +} diff --git a/src/main/java/org/gachon/checkmate/global/config/auth/jwt/JwtProvider.java b/src/main/java/org/gachon/checkmate/global/config/auth/jwt/JwtProvider.java index b27e46e..aabe4ef 100644 --- a/src/main/java/org/gachon/checkmate/global/config/auth/jwt/JwtProvider.java +++ b/src/main/java/org/gachon/checkmate/global/config/auth/jwt/JwtProvider.java @@ -94,4 +94,4 @@ private Key getSigningKey() { String encoded = Base64.getEncoder().encodeToString(secretKey.getBytes()); return Keys.hmacShaKeyFor(encoded.getBytes()); } -} \ No newline at end of file +} diff --git a/src/main/java/org/gachon/checkmate/global/socket/SocketJwtProvider.java b/src/main/java/org/gachon/checkmate/global/socket/SocketJwtProvider.java new file mode 100644 index 0000000..d7ca9d5 --- /dev/null +++ b/src/main/java/org/gachon/checkmate/global/socket/SocketJwtProvider.java @@ -0,0 +1,78 @@ +package org.gachon.checkmate.global.socket; + + +import io.jsonwebtoken.*; +import io.jsonwebtoken.security.Keys; +import lombok.Getter; +import org.gachon.checkmate.global.socket.error.SocketErrorCode; +import org.gachon.checkmate.global.socket.error.SocketUnauthorizedException; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +import java.security.Key; +import java.util.Base64; +import java.util.Date; + +@Getter +@Component +public class SocketJwtProvider { + @Value("${jwt.secret}") + private String secretKey; + @Value("${jwt.access-token-expire-time}") + private long ACCESS_TOKEN_EXPIRE_TIME; + @Value("${jwt.refresh-token-expire-time}") + private long REFRESH_TOKEN_EXPIRE_TIME; + + private static final String BEARER_TYPE = "Bearer"; + + public String getIssueToken(Long userId, boolean isAccessToken) { + if (isAccessToken) return generateToken(userId, ACCESS_TOKEN_EXPIRE_TIME); + else return generateToken(userId, REFRESH_TOKEN_EXPIRE_TIME); + } + + public void validateAccessToken(String accessToken) { + try { + getJwtParser().parseClaimsJws(accessToken); + } catch (ExpiredJwtException e) { + throw new SocketUnauthorizedException(SocketErrorCode.EXPIRED_ACCESS_TOKEN); + } catch (Exception e) { + throw new SocketUnauthorizedException(SocketErrorCode.INVALID_ACCESS_TOKEN_VALUE); + } + } + + public static String extractToken(String authHeaderValue) { + if (authHeaderValue.toLowerCase().startsWith(BEARER_TYPE.toLowerCase())) { + return authHeaderValue.substring(BEARER_TYPE.length()).trim(); + } + return null; + } + + public Long getSubject(String token) { + return Long.valueOf(getJwtParser().parseClaimsJws(token) + .getBody() + .getSubject()); + } + + private String generateToken(Long userId, long tokenTime) { + final Date now = new Date(); + final Date expiration = new Date(now.getTime() + tokenTime); + return Jwts.builder() + .setHeaderParam(Header.TYPE, Header.JWT_TYPE) + .setSubject(String.valueOf(userId)) + .setIssuedAt(now) + .setExpiration(expiration) + .signWith(getSigningKey(), SignatureAlgorithm.HS256) + .compact(); + } + + private JwtParser getJwtParser() { + return Jwts.parserBuilder() + .setSigningKey(getSigningKey()) + .build(); + } + + private Key getSigningKey() { + String encoded = Base64.getEncoder().encodeToString(secretKey.getBytes()); + return Keys.hmacShaKeyFor(encoded.getBytes()); + } +} diff --git a/src/main/java/org/gachon/checkmate/global/socket/error/SocketErrorCode.java b/src/main/java/org/gachon/checkmate/global/socket/error/SocketErrorCode.java new file mode 100644 index 0000000..827dfe2 --- /dev/null +++ b/src/main/java/org/gachon/checkmate/global/socket/error/SocketErrorCode.java @@ -0,0 +1,36 @@ +package org.gachon.checkmate.global.socket.error; + + +import lombok.AccessLevel; +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Getter +@RequiredArgsConstructor(access = AccessLevel.PRIVATE) +public enum SocketErrorCode { + + /** + * 1401 UnAuthorized + */ + USER_NOT_AUTHORIZED(1401, "접근할 수 있는 권한이 없습니다."), + EXPIRED_ACCESS_TOKEN(1401, "액세스 토큰이 만료 되었습니다."), + INVALID_ACCESS_TOKEN_VALUE(1401, "액세스 토큰의 값이 올바르지 않습니다."), + NOT_ORIGINAL_USER(1401, "처음 접속한 유저와 다른 유저의 토큰입니다."), + + + /** + * 1404 Not found + */ + SESSION_ATTRIBUTE_NOT_FOUND(1404, "SessionAttributes를 찾을 수 없습니다."), + SESSION_ATTRIBUTE_NULL_VALUE(1404, "세션 속성값이 null입니다."), + USER_NOT_FOUND(1404, "유저를 찾을 수 없습니다."), + CHATROOM_NOT_FOUND(1404, "해당 채팅방을 찾을 수 없습니다."), + + /** + * 1500 Socket Server + */ + SOCKET_SERVER_ERROR(1500, "소켓 서버 오류입니다."); + + private final int code; + private final String message; +} diff --git a/src/main/java/org/gachon/checkmate/global/socket/error/SocketException.java b/src/main/java/org/gachon/checkmate/global/socket/error/SocketException.java new file mode 100644 index 0000000..d8b6043 --- /dev/null +++ b/src/main/java/org/gachon/checkmate/global/socket/error/SocketException.java @@ -0,0 +1,14 @@ +package org.gachon.checkmate.global.socket.error; + +import lombok.Getter; +import org.springframework.messaging.MessageDeliveryException; + +@Getter +public class SocketException extends MessageDeliveryException { + private final SocketErrorCode socketErrorCode; + + public SocketException(SocketErrorCode socketErrorCode) { + super(socketErrorCode.getMessage()); + this.socketErrorCode = socketErrorCode; + } +} diff --git a/src/main/java/org/gachon/checkmate/global/socket/error/SocketNotFoundException.java b/src/main/java/org/gachon/checkmate/global/socket/error/SocketNotFoundException.java new file mode 100644 index 0000000..a08fe10 --- /dev/null +++ b/src/main/java/org/gachon/checkmate/global/socket/error/SocketNotFoundException.java @@ -0,0 +1,7 @@ +package org.gachon.checkmate.global.socket.error; + +public class SocketNotFoundException extends SocketException { + public SocketNotFoundException(SocketErrorCode socketErrorCode) { + super(socketErrorCode); + } +} diff --git a/src/main/java/org/gachon/checkmate/global/socket/error/SocketUnauthorizedException.java b/src/main/java/org/gachon/checkmate/global/socket/error/SocketUnauthorizedException.java new file mode 100644 index 0000000..030d85e --- /dev/null +++ b/src/main/java/org/gachon/checkmate/global/socket/error/SocketUnauthorizedException.java @@ -0,0 +1,7 @@ +package org.gachon.checkmate.global.socket.error; + +public class SocketUnauthorizedException extends SocketException { + public SocketUnauthorizedException(SocketErrorCode socketErrorCode) { + super(socketErrorCode); + } +} diff --git a/src/main/java/org/gachon/checkmate/global/socket/handler/StompErrorHandler.java b/src/main/java/org/gachon/checkmate/global/socket/handler/StompErrorHandler.java new file mode 100644 index 0000000..23bfd47 --- /dev/null +++ b/src/main/java/org/gachon/checkmate/global/socket/handler/StompErrorHandler.java @@ -0,0 +1,35 @@ +package org.gachon.checkmate.global.socket.handler; + +import lombok.RequiredArgsConstructor; +import org.gachon.checkmate.global.socket.error.SocketErrorCode; +import org.springframework.messaging.Message; +import org.springframework.messaging.simp.stomp.StompCommand; +import org.springframework.messaging.simp.stomp.StompHeaderAccessor; +import org.springframework.messaging.support.MessageBuilder; +import org.springframework.stereotype.Component; +import org.springframework.web.socket.messaging.StompSubProtocolErrorHandler; + +import java.nio.charset.StandardCharsets; + +@RequiredArgsConstructor +@Component +public class StompErrorHandler extends StompSubProtocolErrorHandler { + @Override + public Message handleClientMessageProcessingError(MessageclientMessage, Throwable ex) { + return super.handleClientMessageProcessingError(clientMessage, ex); + } + + // JWT 예외 + private Message errorMessage(SocketErrorCode socketErrorCode){ + String code = String.valueOf(socketErrorCode.getMessage()); + StompHeaderAccessor accessor = getStompHeaderAccessor(socketErrorCode); + return MessageBuilder.createMessage(code.getBytes(StandardCharsets.UTF_8), accessor.getMessageHeaders()); + } + + private StompHeaderAccessor getStompHeaderAccessor(SocketErrorCode socketErrorCode) { + StompHeaderAccessor accessor = StompHeaderAccessor.create(StompCommand.ERROR); + accessor.setMessage(socketErrorCode.getMessage()); + accessor.setLeaveMutable(true); + return accessor; + } +} diff --git a/src/main/java/org/gachon/checkmate/global/socket/interceptor/StompInterceptor.java b/src/main/java/org/gachon/checkmate/global/socket/interceptor/StompInterceptor.java new file mode 100644 index 0000000..b2d7100 --- /dev/null +++ b/src/main/java/org/gachon/checkmate/global/socket/interceptor/StompInterceptor.java @@ -0,0 +1,168 @@ +package org.gachon.checkmate.global.socket.interceptor; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.gachon.checkmate.domain.chat.entity.LiveChatRoom; +import org.gachon.checkmate.domain.chat.repository.LiveChatRoomRepository; +import org.gachon.checkmate.domain.member.repository.UserRepository; +import org.gachon.checkmate.global.socket.SocketJwtProvider; +import org.gachon.checkmate.global.socket.error.SocketErrorCode; +import org.gachon.checkmate.global.socket.error.SocketException; +import org.gachon.checkmate.global.socket.error.SocketNotFoundException; +import org.gachon.checkmate.global.socket.error.SocketUnauthorizedException; +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.stereotype.Component; + +import java.util.List; +import java.util.Map; +import java.util.Objects; + +import static org.gachon.checkmate.global.socket.error.SocketErrorCode.*; + +@Slf4j +@Component +@RequiredArgsConstructor +public class StompInterceptor implements ChannelInterceptor { + + private static final String AUTHORIZATION = "Authorization"; + private static final String BEARER = "Bearer "; + public static final String DEFAULT_PATH = "/queue/chat/"; + private final SocketJwtProvider socketJwtProvider; + private final UserRepository userRepository; + private final LiveChatRoomRepository liveChatRoomRepository; + + // websocket을 통해 들어온 요청이 처리 되기전 실행된다. + @Override + public Message preSend(Message message, MessageChannel channel) { + StompHeaderAccessor accessor = StompHeaderAccessor.wrap(message); + StompCommand command = accessor.getCommand(); + + if (StompCommand.CONNECT.equals(command)) { // websocket 연결요청 -> JWT 인증 + // JWT 인증 부분 + Long userId = getUserByAuthorizationHeader( + accessor.getFirstNativeHeader("Authorization") + ); + + setValue(accessor, "userId", userId); + log.info("[CONNECT]:: userId : " + userId); + + } else if (StompCommand.SEND.equals(command)) { + // 매 요청마다 accessToken 검증 + validateToken(accessor); + } else if (StompCommand.SUBSCRIBE.equals(command)) { // 채팅방 구독요청(진입) + Long userId = (Long)getValue(accessor, "userId"); + if(checkIsDestinationChatRoom(accessor)) { + String enterRoomId = parseRoomIdFromPath(accessor); + setValue(accessor, "roomId", enterRoomId); + } + log.debug("userId : " + userId); + } else if (StompCommand.UNSUBSCRIBE.equals(command)) { + Long userId = (Long)getValue(accessor, "userId"); + if(checkIsDestinationChatRoom(accessor)) { + deleteLiveChatRoom(accessor); + deleteValue(accessor, "roomId"); + } + log.info("UNSUBSCRIBE userId : {}", userId); + } else if (StompCommand.DISCONNECT == command) { // Websocket 연결 종료 + Long userId = (Long)getValue(accessor, "userId"); + deleteLiveChatRoom(accessor); + log.info("DISCONNECTED userId : {}", userId); + } + return message; + } + + private Long getUserByAuthorizationHeader(String authHeaderValue) { + String accessToken = getTokenByAuthorizationHeader(authHeaderValue); + Long userId = socketJwtProvider.getSubject(accessToken); + validateUserExist(userId); + return userId; + } + + private void validateUserExist(Long userId) { + if(!userRepository.existsById(userId)) { + throw new SocketNotFoundException(USER_NOT_FOUND); + } + } + + private void deleteLiveChatRoom(StompHeaderAccessor accessor) { + if(isChatRoomAttributeExist(accessor)) { + String roomId = (String) getValue(accessor, "roomId"); + Long userId = (Long) getValue(accessor, "userId"); + List liveChatRooms = liveChatRoomRepository.findAllByUserId(userId); + liveChatRoomRepository.deleteAll(liveChatRooms); + } + } + + private String getTokenByAuthorizationHeader(String authHeaderValue) { + if (Objects.isNull(authHeaderValue) || authHeaderValue.isBlank()) { + throw new SocketUnauthorizedException(SocketErrorCode.USER_NOT_AUTHORIZED); + } + String accessToken = SocketJwtProvider.extractToken(authHeaderValue); + socketJwtProvider.validateAccessToken(accessToken); + + return accessToken; + } + + private void validateToken(StompHeaderAccessor accessor) { + String authHeaderValue = accessor.getFirstNativeHeader("Authorization"); + if (Objects.isNull(authHeaderValue) || authHeaderValue.isBlank()) { + throw new SocketUnauthorizedException(SocketErrorCode.USER_NOT_AUTHORIZED); + } + String accessToken = SocketJwtProvider.extractToken(authHeaderValue); + socketJwtProvider.validateAccessToken(accessToken); + Long userId = socketJwtProvider.getSubject(accessToken); + Long originalUserId = (Long) getValue(accessor, "userId"); + if(!originalUserId.equals(userId)) { + throw new SocketUnauthorizedException(SocketErrorCode.NOT_ORIGINAL_USER); + } + } + + private Boolean checkIsDestinationChatRoom(StompHeaderAccessor accessor) { + return accessor.getDestination().contains(DEFAULT_PATH); + } + + private String parseRoomIdFromPath(StompHeaderAccessor accessor) { + String destination = accessor.getDestination(); + return destination.substring(DEFAULT_PATH.length()); + } + + private Object getValue(StompHeaderAccessor accessor, String key) { + Map sessionAttributes = getSessionAttributes(accessor); + Object value = sessionAttributes.get(key); + + if (Objects.isNull(value)) { + throw new SocketException(SOCKET_SERVER_ERROR); + } + return value; + } + + private Boolean isChatRoomAttributeExist(StompHeaderAccessor accessor) { + Map sessionAttributes = getSessionAttributes(accessor); + Object value = sessionAttributes.get("roomId"); + return !Objects.isNull(value); + } + + private void setValue(StompHeaderAccessor accessor, String key, Object value) { + Map sessionAttributes = getSessionAttributes(accessor); + sessionAttributes.put(key, value); + } + + private void deleteValue(StompHeaderAccessor accessor, String key) { + Map sessionAttributes = getSessionAttributes(accessor); + sessionAttributes.remove(key); + } + + private Map getSessionAttributes(StompHeaderAccessor accessor) { + Map sessionAttributes = accessor.getSessionAttributes(); + if (Objects.isNull(sessionAttributes)) { + throw new SocketException(SESSION_ATTRIBUTE_NOT_FOUND); + } + return sessionAttributes; + } + +} +