Skip to content

Commit

Permalink
feat: #767 채팅 웹소켓 구현 (#771)
Browse files Browse the repository at this point in the history
* chore: 웹소켓 의존성 추가

* feat: 채팅 웹소켓 인터셉터 생성

* feat: 웹소켓 세션 등록 일급컬렉션 추가

* feat: 웹소켓 configuration 추가

* feat: 웹소켓 연결 해제 기능 구현

* refactor: 인터셉터 서비스 구현 및 인가 인터셉터 분리

* feat: 웹소켓 핸들러 생성

* feat: 채팅 웹소켓 url과 인터셉터 지정

* feat: 채팅 웹소켓 url과 인터셉터 지정

* chore: 작업을 위한 임시 세팅

* feat: 채팅 웹소켓 인터셉터 생성

* feat: 웹소켓 세션 등록 일급컬렉션 추가

* feat: 웹소켓 configuration 추가

* feat: 웹소켓 연결 해제 기능 구현

* feat: 웹소켓 핸들러 생성

* feat: 채팅 웹소켓 url과 인터셉터 지정

* chore: 작업을 위한 임시 세팅

* fix: 이미지 절대 url 가져오는 문제 해결

* refactor: 웹소켓 요청 path 변경

* refactor: TextMessage 형식 변경

* refactor: 웹소켓 핸들러 추상화

* fix: 오류가 발생하는 테스트 해결

* test: 테스트 추가

- WebSocketHandleTextMessageProviderCompositeTest

* refactor: map 타입에 대한 dto 생성

* refactor: 코드 리팩터링

* refactor: 로직 이동

* refactor: 채팅 알림 전송 로직을 웹 소켓쪽으로 이동

* refactor: 전송자에 대한 변수명 변경

sender -> writer

* refactor: attribute 키의 상수명 변경

* refactor: 핸들링하는 메서드에 대한 이름 변경

handle -> handleCreateSendMessage

* refactor: dto 변수명 수정

SendMessagesDto -> SendMessageDto
SendMessageDto -> MessageDto

* refactor: 기존 메시지 생성 로직 제거

* refactor: final 키워드 추가

* test: 테스트 추가

* fix: 전송할 메시지 생성 시 발신자 session으로만 전송되는 문제 수정

* refactor: 메시지 로그 업데이트 이벤트에서 수신자, 채팅방 객체 대신 id 받도록 변경

* style: 메서드 순서 정렬

* feat: 마지막 읽은 메시지 업데이트 이벤트 발행 추가

* test: 실패하는 테스트 수정

* test: 메시지 전송 시 메시지 읽음 처리 이벤트 호출 테스트 추가

* refactor: 중복되는 탈퇴 회원 검증 메서드 삭제

* style: 와일드카드 제거

* style: 불필요한 필드 삭제

* refactor: 메시지 로그 업데이트 시 마지막 메시지 아이디만 받도록 수정

---------

Co-authored-by: JJ503 <[email protected]>
  • Loading branch information
swonny and JJ503 authored Mar 25, 2024
1 parent 8d9ca92 commit dea1232
Show file tree
Hide file tree
Showing 49 changed files with 1,366 additions and 358 deletions.
3 changes: 3 additions & 0 deletions backend/ddang/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,9 @@ dependencies {
annotationProcessor 'jakarta.annotation:jakarta.annotation-api'
annotationProcessor 'jakarta.persistence:jakarta.persistence-api'
annotationProcessor 'org.springframework.boot:spring-boot-configuration-processor'

// web socket
implementation 'org.springframework.boot:spring-boot-starter-websocket'
}

tasks.withType(JavaCompile) {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,13 +1,5 @@
package com.ddang.ddang.authentication.configuration;

import com.ddang.ddang.authentication.application.AuthenticationUserService;
import com.ddang.ddang.authentication.application.BlackListTokenService;
import com.ddang.ddang.authentication.infrastructure.jwt.PrivateClaims;
import com.ddang.ddang.authentication.domain.TokenDecoder;
import com.ddang.ddang.authentication.domain.TokenType;
import com.ddang.ddang.authentication.domain.dto.AuthenticationStore;
import com.ddang.ddang.authentication.domain.dto.AuthenticationUserInfo;
import com.ddang.ddang.authentication.domain.exception.InvalidTokenException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
Expand All @@ -19,10 +11,7 @@
@RequiredArgsConstructor
public class AuthenticationInterceptor implements HandlerInterceptor {

private final BlackListTokenService blackListTokenService;
private final AuthenticationUserService authenticationUserService;
private final TokenDecoder tokenDecoder;
private final AuthenticationStore store;
private final AuthenticationInterceptorService authenticationInterceptorService;

@Override
public boolean preHandle(
Expand All @@ -31,44 +20,18 @@ public boolean preHandle(
final Object handler
) {
final String accessToken = request.getHeader(HttpHeaders.AUTHORIZATION);
authenticationInterceptorService.handleAccessToken(accessToken);

if (isNotRequiredAuthenticate(accessToken)) {
store.set(new AuthenticationUserInfo(null));
return true;
}

validateLogoutToken(accessToken);

final PrivateClaims privateClaims = tokenDecoder.decode(TokenType.ACCESS, accessToken)
.orElseThrow(() ->
new InvalidTokenException("유효한 토큰이 아닙니다.")
);

if (authenticationUserService.isWithdrawal(privateClaims.userId())) {
throw new InvalidTokenException("유효한 토큰이 아닙니다.");
}

store.set(new AuthenticationUserInfo(privateClaims.userId()));
return true;
}

private boolean isNotRequiredAuthenticate(final String token) {
return token == null || token.length() == 0;
}

private void validateLogoutToken(final String accessToken) {
if (blackListTokenService.existsBlackListToken(TokenType.ACCESS, accessToken)) {
throw new InvalidTokenException("유효한 토큰이 아닙니다.");
}
}

@Override
public void afterCompletion(
final HttpServletRequest request,
final HttpServletResponse response,
final Object handler,
final Exception ex
) {
store.remove();
authenticationInterceptorService.removeStore();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
package com.ddang.ddang.authentication.configuration;

import com.ddang.ddang.authentication.application.AuthenticationUserService;
import com.ddang.ddang.authentication.application.BlackListTokenService;
import com.ddang.ddang.authentication.domain.TokenDecoder;
import com.ddang.ddang.authentication.domain.TokenType;
import com.ddang.ddang.authentication.domain.dto.AuthenticationStore;
import com.ddang.ddang.authentication.domain.dto.AuthenticationUserInfo;
import com.ddang.ddang.authentication.domain.exception.InvalidTokenException;
import com.ddang.ddang.authentication.infrastructure.jwt.PrivateClaims;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Component;

@Component
@RequiredArgsConstructor
public class AuthenticationInterceptorService {

private final BlackListTokenService blackListTokenService;
private final AuthenticationUserService authenticationUserService;
private final TokenDecoder tokenDecoder;
private final AuthenticationStore store;

public boolean handleAccessToken(final String accessToken) {
if (isNotRequiredAuthenticate(accessToken)) {
store.set(new AuthenticationUserInfo(null));
return true;
}

validateLogoutToken(accessToken);

final PrivateClaims privateClaims = tokenDecoder.decode(TokenType.ACCESS, accessToken)
.orElseThrow(() ->
new InvalidTokenException("유효한 토큰이 아닙니다.")
);

if (authenticationUserService.isWithdrawal(privateClaims.userId())) {
throw new InvalidTokenException("유효한 토큰이 아닙니다.");
}

store.set(new AuthenticationUserInfo(privateClaims.userId()));
return true;
}

private boolean isNotRequiredAuthenticate(final String token) {
return token == null || token.length() == 0;
}

private void validateLogoutToken(final String accessToken) {
if (blackListTokenService.existsBlackListToken(TokenType.ACCESS, accessToken)) {
throw new InvalidTokenException("유효한 토큰이 아닙니다.");
}
}

public void removeStore() {
store.remove();
}

public AuthenticationUserInfo getAuthenticationUserInfo() {
return store.get();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -33,14 +33,14 @@ public void create(final CreateReadMessageLogEvent createReadMessageLogEvent) {

@Transactional
public void update(final UpdateReadMessageLogEvent updateReadMessageLogEvent) {
final User reader = updateReadMessageLogEvent.reader();
final ChatRoom chatRoom = updateReadMessageLogEvent.chatRoom();
final ReadMessageLog messageLog = readMessageLogRepository.findBy(reader.getId(), chatRoom.getId())
final Long readerId = updateReadMessageLogEvent.readerId();
final Long chatRoomId = updateReadMessageLogEvent.chatRoomId();
final ReadMessageLog messageLog = readMessageLogRepository.findBy(readerId, chatRoomId)
.orElseThrow(() ->
new ReadMessageLogNotFoundException(
"메시지 조회 로그가 존재하지 않습니다."
));

messageLog.updateLastReadMessage(updateReadMessageLogEvent.lastReadMessage().getId());
messageLog.updateLastReadMessage(updateReadMessageLogEvent.lastReadMessageId());
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,9 @@

import com.ddang.ddang.chat.application.dto.CreateMessageDto;
import com.ddang.ddang.chat.application.dto.ReadMessageDto;
import com.ddang.ddang.chat.application.event.MessageNotificationEvent;
import com.ddang.ddang.chat.application.event.UpdateReadMessageLogEvent;
import com.ddang.ddang.chat.application.exception.ChatRoomNotFoundException;
import com.ddang.ddang.chat.application.exception.MessageNotFoundException;
import com.ddang.ddang.chat.application.exception.UnableToChatException;
import com.ddang.ddang.chat.domain.ChatRoom;
import com.ddang.ddang.chat.domain.Message;
import com.ddang.ddang.chat.domain.repository.ChatRoomRepository;
Expand All @@ -28,34 +26,28 @@
public class MessageService {

private final ApplicationEventPublisher messageLogEventPublisher;
private final ApplicationEventPublisher messageNotificationEventPublisher;
private final MessageRepository messageRepository;
private final ChatRoomRepository chatRoomRepository;
private final UserRepository userRepository;

@Transactional
public Long create(final CreateMessageDto dto, final String profileImageAbsoluteUrl) {
public Message create(final CreateMessageDto dto) {
final ChatRoom chatRoom = chatRoomRepository.findById(dto.chatRoomId())
.orElseThrow(() -> new ChatRoomNotFoundException(
"지정한 아이디에 대한 채팅방을 찾을 수 없습니다."));
"지정한 아이디에 대한 채팅방을 찾을 수 없습니다."
));
final User writer = userRepository.findByIdWithProfileImage(dto.writerId())
.orElseThrow(() -> new UserNotFoundException(
"지정한 아이디에 대한 발신자를 찾을 수 없습니다."));
"지정한 아이디에 대한 발신자를 찾을 수 없습니다."
));
final User receiver = userRepository.findById(dto.receiverId())
.orElseThrow(() -> new UserNotFoundException(
"지정한 아이디에 대한 수신자를 찾을 수 없습니다."));

if (!chatRoom.isChatAvailablePartner(receiver)) {
throw new UnableToChatException("탈퇴한 사용자에게는 메시지 전송이 불가능합니다.");
}
"지정한 아이디에 대한 수신자를 찾을 수 없습니다."
));

final Message message = dto.toEntity(chatRoom, writer, receiver);

final Message persistMessage = messageRepository.save(message);

messageNotificationEventPublisher.publishEvent(new MessageNotificationEvent(persistMessage, profileImageAbsoluteUrl));

return persistMessage.getId();
return messageRepository.save(message);
}

public List<ReadMessageDto> readAllByLastMessageId(final ReadMessageRequest request) {
Expand All @@ -76,13 +68,16 @@ public List<ReadMessageDto> readAllByLastMessageId(final ReadMessageRequest requ
);

if (!readMessages.isEmpty()) {
final Message lastReadMessage = readMessages.get(readMessages.size() - 1);

messageLogEventPublisher.publishEvent(new UpdateReadMessageLogEvent(reader, chatRoom, lastReadMessage));
final UpdateReadMessageLogEvent updateReadMessageLogEvent = new UpdateReadMessageLogEvent(
reader.getId(),
chatRoom.getId(),
readMessages.get(readMessages.size() - 1).getId()
);
messageLogEventPublisher.publishEvent(updateReadMessageLogEvent);
}

return readMessages.stream()
.map(message -> ReadMessageDto.from(message, chatRoom))
.map(message -> ReadMessageDto.of(message, chatRoom))
.toList();
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ public record ReadMessageDto(
String contents
) {

public static ReadMessageDto from(
public static ReadMessageDto of(
final Message message,
final ChatRoom chatRoom
) {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,8 +1,4 @@
package com.ddang.ddang.chat.application.event;

import com.ddang.ddang.chat.domain.ChatRoom;
import com.ddang.ddang.chat.domain.Message;
import com.ddang.ddang.user.domain.User;

public record UpdateReadMessageLogEvent(User reader, ChatRoom chatRoom, Message lastReadMessage) {
public record UpdateReadMessageLogEvent(Long readerId, Long chatRoomId, Long lastReadMessageId) {
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
package com.ddang.ddang.chat.domain;

import lombok.Getter;
import org.springframework.stereotype.Component;
import org.springframework.web.socket.WebSocketSession;

import java.util.Map;
import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;

import static com.ddang.ddang.chat.domain.WebSocketSessions.CHAT_ROOM_ID_KEY;

@Getter
@Component
public class WebSocketChatSessions {

private final Map<Long, WebSocketSessions> chatRoomSessions = new ConcurrentHashMap<>();

public void add(final WebSocketSession session, final Long chatRoomId) {
chatRoomSessions.putIfAbsent(chatRoomId, new WebSocketSessions());
final WebSocketSessions webSocketSessions = chatRoomSessions.get(chatRoomId);
webSocketSessions.putIfAbsent(session, chatRoomId);
}

public Set<WebSocketSession> getSessionsByChatRoomId(final Long chatRoomId) {
final WebSocketSessions webSocketSessions = chatRoomSessions.get(chatRoomId);

return webSocketSessions.getSessions();
}

public boolean containsByUserId(final Long chatRoomId, final Long userId) {
final WebSocketSessions webSocketSessions = chatRoomSessions.get(chatRoomId);

return webSocketSessions.contains(userId);
}

public void remove(final WebSocketSession session) {
final long chatRoomId = Long.parseLong(String.valueOf(session.getAttributes().get(CHAT_ROOM_ID_KEY)));
final WebSocketSessions webSocketSessions = chatRoomSessions.get(chatRoomId);
webSocketSessions.remove(session);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
package com.ddang.ddang.chat.domain;

import lombok.Getter;
import org.springframework.web.socket.WebSocketSession;

import java.util.Collections;
import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;

@Getter
public class WebSocketSessions {

protected static final String CHAT_ROOM_ID_KEY = "chatRoomId";
private static final String USER_ID_KEY = "userId";

private final Set<WebSocketSession> sessions = Collections.newSetFromMap(new ConcurrentHashMap<>());

public void putIfAbsent(final WebSocketSession session, final Long chatRoomId) {
if (!sessions.contains(session)) {
session.getAttributes().put(CHAT_ROOM_ID_KEY, chatRoomId);
sessions.add(session);
}
}

public boolean contains(final Long userId) {
return sessions.stream()
.anyMatch(session -> session.getAttributes().get(USER_ID_KEY) == userId);
}

public void remove(final WebSocketSession session) {
sessions.remove(session);
}
}
Loading

0 comments on commit dea1232

Please sign in to comment.