Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: 실시간 채팅 - 기본 메세지 송수신, 세션 처리 #164

Open
wants to merge 43 commits into
base: develop
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
43 commits
Select commit Hold shift + click to select a range
1434bba
feat: ws 기본 연결 확인
zzo3ozz Jan 7, 2024
1f76d21
refactor: 클래스 패키지 변경
zzo3ozz Jan 7, 2024
3c40269
feat: 다중 접속 시 메세지 송수신 구현
zzo3ozz Jan 7, 2024
835d8a6
feat: stomp를 사용한 message 송수신 구현
zzo3ozz Jan 25, 2024
d3d4015
feat: message 발신 시 db에 저장
zzo3ozz Jan 25, 2024
a13d714
feat: 마지막 접속시간 기록을 위한 추가 entity
zzo3ozz Mar 28, 2024
1340986
feat: Response에 chatroom 정보 추가
zzo3ozz Mar 28, 2024
b9f7a8f
feat: Redis - Set collection을 위한 저장/조회 method
zzo3ozz Mar 30, 2024
5ad4b4c
feat: 웹소켓 연결/종료 시 세션 정보 저장/삭제
zzo3ozz Mar 30, 2024
b05c13b
feat: Redis - Set collection 저장 method 수정
zzo3ozz Mar 30, 2024
acb39f6
feat: Redis - Set collection member remove method
zzo3ozz Apr 3, 2024
33c51f0
feat: 채팅방 subscribe/unscribe 동작 시 세션 관리
zzo3ozz Apr 3, 2024
7c8f351
fix: 잘못된 return type 수정
zzo3ozz Apr 3, 2024
54c817d
feat: userId 및 familyId를 통한 마지막 접속 시간 갱신 method
zzo3ozz Apr 3, 2024
3d5d0a2
refactor: 세션 관련 로직 service단으로 분리
zzo3ozz Apr 3, 2024
350c519
feat: unsbscribe/disconnect 시 마지막 접속 시간 갱신 처리
zzo3ozz Apr 3, 2024
02348fe
feat: session 처리 시점을 메세지 수신 직후로 수정
zzo3ozz Apr 4, 2024
c202545
feat: 메세지 발신 시 messageId를 포함함
zzo3ozz Apr 4, 2024
4ec1d37
refactor: 채팅방 정보 조회 시 member가 조회되지 않는 문제 fix
zzo3ozz Apr 5, 2024
bbd0771
feat: 최초 connect 시 session 정보 저장
zzo3ozz May 31, 2024
71656cd
feat: sub/unsub 시그니처
zzo3ozz May 31, 2024
51fe3df
feat: subscribe 시 unsub session에서 제거
zzo3ozz May 31, 2024
f7f1297
feat: unsubscribe 시 unsub session에 추가
zzo3ozz May 31, 2024
bb00ec9
feat: disconnect 시 session 삭제
zzo3ozz May 31, 2024
1f1b34f
refactor: redis 저장 prefix enum으로 관리
zzo3ozz May 31, 2024
9878aad
feat: ChatInfo 정보 UserFamily로 병합
zzo3ozz May 31, 2024
2070ee0
refactor: 불필요한 Service Layer 삭제 및 Method 이동
zzo3ozz May 31, 2024
4ff4de6
feat: disconnect 시 sub 중인 가족 채팅에 대한 lastAccessedTime 갱신
zzo3ozz May 31, 2024
7eee8d4
refactor: 불필요한 method 삭제
zzo3ozz May 31, 2024
b99bedf
chore: REST API 관련 커밋 삭제에 따른 수정 & TODO 주석 추가
zzo3ozz May 31, 2024
1359e40
feat: message 구분을 위한 template 추가
zzo3ozz May 31, 2024
c986220
fix: session 변경 시 잘못된 session/user 정보 파싱 수정
zzo3ozz May 31, 2024
22e43fa
feat: Transactional 어노테이션 추가
zzo3ozz May 31, 2024
8c44e54
feat: 메세지 알림 시그니처
zzo3ozz May 31, 2024
b4ab5f8
feat: online-unsubscribe인 유저에게 알림 발송
zzo3ozz May 31, 2024
dc8d031
feat: ACTIVE 상태인 가족에만 참여할 수 있도록 수정
zzo3ozz May 31, 2024
9272be8
Merge branch 'develop' into feature/chatting-session
zzo3ozz Jun 1, 2024
06b9b2e
Merge branch 'develop' into feature/chatting-session
zzo3ozz Jun 4, 2024
76389ca
chore: .idea/ignore 수정
zzo3ozz Jun 4, 2024
150b15f
Merge remote-tracking branch 'origin/feature/chatting-session' into f…
zzo3ozz Jun 4, 2024
d8dd955
Delete .idea/modules/family-moments-server.main.iml
zzo3ozz Jun 4, 2024
d412ae5
chore: .idea/ignore & 충돌 xml 수정
zzo3ozz Jun 4, 2024
0623762
Merge remote-tracking branch 'origin/feature/chatting-session' into f…
zzo3ozz Jun 4, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .idea/.gitignore

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions family-moments/.gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -263,3 +263,4 @@ gradle-app.setting
### Gradle Patch ###
# Java heap dump
*.hprof
/src/main/java/com/spring/familymoments/domain/chat/ChatRedisPrefix.java
7 changes: 4 additions & 3 deletions family-moments/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -55,22 +55,23 @@ dependencies {
implementation 'org.springframework.boot:spring-boot-starter-web'
// S3
implementation 'org.springframework.cloud:spring-cloud-starter-aws:2.2.6.RELEASE'

// Mail
implementation 'org.springframework.boot:spring-boot-starter-mail'
// Random Code
implementation 'org.apache.commons:commons-lang3:3.12.0'

// jsch 라이브러리, for ssh tunneling
implementation 'com.github.mwiede:jsch:0.2.12'
// application.yml의 환경 변수를 class에서 사용하기 위한 라이브러리
annotationProcessor 'org.springframework.boot:spring-boot-configuration-processor'
// swagger
implementation 'org.springdoc:springdoc-openapi-ui:1.7.0'

// mongoDB
implementation 'org.springframework.boot:spring-boot-starter-data-mongodb'

// for chatting
// websocket
implementation 'org.springframework.boot:spring-boot-starter-websocket'

// OpenFeign - social login
implementation 'org.springframework.cloud:spring-cloud-starter-openfeign'

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
package com.spring.familymoments.domain.chat;

import com.spring.familymoments.domain.chat.document.ChatDocument;
import org.bson.types.ObjectId;
import org.springframework.data.mongodb.repository.MongoRepository;

public interface ChatDocumentRepository extends MongoRepository<ChatDocument, ObjectId> {

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
package com.spring.familymoments.domain.chat;

import com.spring.familymoments.domain.user.UserService;
import com.spring.familymoments.domain.user.entity.User;
import lombok.RequiredArgsConstructor;
import org.springframework.context.event.EventListener;
import org.springframework.messaging.Message;
import org.springframework.messaging.simp.stomp.StompHeaderAccessor;
import org.springframework.stereotype.Component;
import org.springframework.web.socket.messaging.SessionConnectedEvent;
import org.springframework.web.socket.messaging.SessionDisconnectEvent;

@Component
@RequiredArgsConstructor
public class ChatEventHandler {
private final UserService userService; // TODO: 임시, 차후 삭제
private final SessionService sessionService;

@EventListener
public void handleSessionConnected(SessionConnectedEvent event) {
StompHeaderAccessor accessor = StompHeaderAccessor.wrap(event.getMessage());
StompHeaderAccessor headerAccessor = StompHeaderAccessor.wrap((Message<?>) accessor.getHeader("simpConnectMessage"));

// TODO: 유저 & 세션 정보 확인, Authentication 필요
String sessionId = headerAccessor.getSessionId();

// TODO: User 정보 검증 과정 수정 필요
String userId = headerAccessor.getNativeHeader("id").get(0);
User user = userService.getUserById(userId);

sessionService.connect(sessionId, user);
}

@EventListener
public void handleSessionDisconnected(SessionDisconnectEvent event) {
String sessionId = event.getSessionId();

sessionService.disconnect(sessionId);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
package com.spring.familymoments.domain.chat;

import com.spring.familymoments.domain.chat.document.ChatDocument;
import com.spring.familymoments.domain.chat.model.MessageReq;
import com.spring.familymoments.domain.chat.model.MessageRes;
import com.spring.familymoments.domain.chat.model.MessageTemplate;
import com.spring.familymoments.domain.common.UserFamilyRepository;
import com.spring.familymoments.domain.redis.RedisService;
import com.spring.familymoments.domain.user.entity.User;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.messaging.simp.SimpMessagingTemplate;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import java.time.LocalDateTime;
import java.util.Set;


@Slf4j
@Service
@RequiredArgsConstructor
public class ChatService {
private final ChatDocumentRepository chatDocumentRepository;
private final RedisService redisService;
private final UserFamilyRepository userFamilyRepository;
private final SimpMessagingTemplate simpMessagingTemplate;

// chat Document에 저장
public MessageRes createChat(Long familyId, MessageReq messageReq) {
ChatDocument chatDocument = ChatDocument.builder()
.familyId(familyId)
.sender(messageReq.getSender())
.message(messageReq.getMessage())
.sendedTime(LocalDateTime.now())
.build();

chatDocument = chatDocumentRepository.save(chatDocument);

MessageRes messageRes = MessageRes.builder()
.messageId(chatDocument.getId().toString())
.familyId(chatDocument.getFamilyId())
.sender(chatDocument.getSender())
.message(chatDocument.getMessage())
.sendedTime(chatDocument.getSendedTime())
.build();

return messageRes;
}

@Transactional(readOnly = true)
public void sendAlarm(long familyId, MessageRes messageRes) {
Set<String> unsubMembers = redisService.getMembers(ChatRedisPrefix.FAMILY_UNSUB.value + familyId);
Set<String> offlineMembers = redisService.getMembers(ChatRedisPrefix.FAMILY_OFF.value + familyId);

// Online-unsub 유저에게 알림 발송
for(String uuid : unsubMembers) {
User user = userFamilyRepository.findActiveUserByFamilyIdAndUuid(familyId, uuid).orElseThrow();
MessageTemplate messageTemplate = new MessageTemplate(MessageTemplate.MessageType.NOTIFICATION, messageRes);

simpMessagingTemplate.convertAndSend("/sub/notification." + user.getId(), messageTemplate);
}
// TODO: offline 유저에게 알림 발송
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
package com.spring.familymoments.domain.chat;

import com.spring.familymoments.config.BaseException;
import com.spring.familymoments.config.BaseResponseStatus;
import com.spring.familymoments.domain.common.UserFamilyRepository;
import com.spring.familymoments.domain.common.entity.UserFamily;
import com.spring.familymoments.domain.family.FamilyRepository;
import com.spring.familymoments.domain.family.entity.Family;
import com.spring.familymoments.domain.redis.RedisService;
import com.spring.familymoments.domain.user.UserRepository;
import com.spring.familymoments.domain.user.entity.User;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import java.time.LocalDateTime;
import java.util.List;
import java.util.Set;

import static com.spring.familymoments.config.BaseResponseStatus.minnie_FAMILY_INVALID_USER;
import static com.spring.familymoments.domain.chat.ChatRedisPrefix.*;

@Slf4j
@Service
@RequiredArgsConstructor
public class SessionService {
private final RedisService redisService;
private final UserFamilyRepository userFamilyRepository;
private final FamilyRepository familyRepository;
private final UserRepository userRepository;

// 연결
@Transactional
public void connect(String sessionId, User user) {
saveSessionInfo(sessionId, user.getUuid());

List<UserFamily> userFamilyList = userFamilyRepository.findAllActiveUserFamilyByUser(user);

for(UserFamily userFamily : userFamilyList) {
Family family = userFamily.getFamilyId();

// offline 세션에서 제거
redisService.removeMember(FAMILY_OFF.value + family.getFamilyId(), user.getUuid());

// unsub 세션에 추가
redisService.addValues(FAMILY_UNSUB.value + family.getFamilyId(), user.getUuid());
}
}

// 연결 해제
@Transactional
public void disconnect(String sessionId) {
String uuid = redisService.getValues(SESSION_ID.value + sessionId);
User user = userRepository.findUserByUuid(uuid).orElseThrow(() -> new BaseException(BaseResponseStatus.FIND_FAIL_USER));

// userID를 바탕으로 unsub 중인 내역이 있다면 offline으로 변경
List<UserFamily> userFamilyList = userFamilyRepository.findAllActiveUserFamilyByUser(user);

for(UserFamily userFamily : userFamilyList) {
Family family = userFamily.getFamilyId();

Set<String> members = redisService.getMembers(FAMILY_UNSUB.value + family.getFamilyId());
boolean isSubscribing = !members.contains(uuid);

// unsub 세션에서 제거
redisService.removeMember(FAMILY_UNSUB.value + family.getFamilyId(), uuid);

// sub 중인 채팅방이 있다면 lastAccessedTime 갱신
if(isSubscribing) {
userFamily.updateLastAccessedTime(LocalDateTime.now());
}

// offline 세션에 추가
redisService.addValues(FAMILY_OFF.value + family.getFamilyId(), uuid);
}

// sessionId:userID 삭제
redisService.deleteValues(SESSION_ID.value + sessionId);
}


// 가족 방 구독
@Transactional(readOnly = true)
public void subscribeFamily(User user, Long familyId) {
Family family = familyRepository.findById(familyId).orElseThrow(() -> new BaseException(BaseResponseStatus.FIND_FAIL_FAMILY));
UserFamily userFamily = userFamilyRepository.findActiveUserFamilyByFamilyAndUser(family, user)
.orElseThrow(() -> new BaseException(BaseResponseStatus.minnie_FAMILY_INVALID_USER));

// unsub 세션에서 제거
redisService.removeMember(FAMILY_UNSUB.value + familyId, user.getUuid());
}

// 가족 방 구독 해제
@Transactional
public void unsubscribeFamily(User user, Long familyId) {
Family family = familyRepository.findById(familyId).orElseThrow(() -> new BaseException(BaseResponseStatus.FIND_FAIL_FAMILY));
UserFamily userFamily = userFamilyRepository.findActiveUserFamilyByFamilyAndUser(family, user)
.orElseThrow(() -> new BaseException(BaseResponseStatus.minnie_FAMILY_INVALID_USER));

unsubscribeFamily(userFamily);
}

// 가족 방 구독 해제
@Transactional
public void unsubscribeFamily(UserFamily userFamily) {
// 마지막 접속 시간 갱신
userFamily.updateLastAccessedTime(LocalDateTime.now());

// unsub 세션에 추가
redisService.addValues(FAMILY_UNSUB.value + userFamily.getFamilyId().getFamilyId(), userFamily.getUserId().getUuid());
}

// 접속한 유저의 세션 정보 저장
public void saveSessionInfo(String sessionId, String userId) {
redisService.setValues(SESSION_ID.value + sessionId, userId);
}

// 마지막 접속 시간 조회 TODO: 차후 삭제
@Transactional
public LocalDateTime getLastAccessedTime(User user, Family family) {
UserFamily userFamily = userFamilyRepository.findActiveUserFamilyByFamilyAndUser(family, user)
.orElseThrow(()-> new BaseException(minnie_FAMILY_INVALID_USER));

return userFamily.getLastAccessedTime();
}

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
package com.spring.familymoments.domain.chat;

import com.spring.familymoments.domain.chat.model.MessageReq;
import com.spring.familymoments.domain.chat.model.MessageRes;
import com.spring.familymoments.domain.chat.model.MessageTemplate;
import lombok.RequiredArgsConstructor;
import org.springframework.messaging.handler.annotation.DestinationVariable;
import org.springframework.messaging.handler.annotation.MessageMapping;
import org.springframework.stereotype.Controller;
import org.springframework.messaging.simp.SimpMessagingTemplate;

import java.time.LocalDateTime;

@Controller
@MessageMapping("")
@RequiredArgsConstructor
public class StompController {
private final SimpMessagingTemplate simpMessagingTemplate;
private final ChatService chatService;

@MessageMapping("{familyId}.send")
public void handleSend(@DestinationVariable("familyId") long familyId, MessageReq messageReq) {
MessageRes messageRes = chatService.createChat(familyId, messageReq);
MessageTemplate response = new MessageTemplate(MessageTemplate.MessageType.MESSAGE, messageRes);

simpMessagingTemplate.convertAndSend("/sub/" + familyId, response);

// TODO: online-unsub & offline 유저에게 알림 발송
chatService.sendAlarm(familyId, messageRes);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
package com.spring.familymoments.domain.chat;

import com.spring.familymoments.domain.user.UserService;
import com.spring.familymoments.domain.user.entity.User;
import lombok.RequiredArgsConstructor;
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;

@Component
@RequiredArgsConstructor
public class StompInterceptor implements ChannelInterceptor {
private static final String NOTIFICATION = "notification";

private final SessionService sessionService;
private final UserService userService; // TODO: 임시, 차후 삭제

@Override
public Message<?> preSend(Message<?> message, MessageChannel channel) {
StompHeaderAccessor headerAccessor = StompHeaderAccessor.wrap(message);
StompCommand command = headerAccessor.getCommand();
String destination = headerAccessor.getDestination();

if(command.equals(StompCommand.SUBSCRIBE) && !destination.contains(NOTIFICATION)) {
// 가족 채팅방 구독 시
// TODO: Authentication - user & familyId (SubscriptionId 검증)
// TODO: User 정보 검증 과정 수정 필요
String userInfo = headerAccessor.getNativeHeader("id").get(0);
String userId = userInfo.substring(0, userInfo.lastIndexOf("-"));
Long familyId = Long.valueOf(userInfo.substring(userInfo.lastIndexOf("-") + 1));
User user = userService.getUserById(userId);

sessionService.subscribeFamily(user, familyId);
} else if (command.equals(StompCommand.UNSUBSCRIBE)) {
//구독 해제 시
// TODO: 가족 채팅방 - noti 채널 구분 필요
// TODO: Authentication - user & familyId (SubscriptionId 검증)
// TODO: User 정보 검증 과정 수정 필요
String userInfo = headerAccessor.getNativeHeader("id").get(0);
String userId = userInfo.substring(0, userInfo.lastIndexOf("-"));
Long familyId = Long.valueOf(userInfo.substring(userInfo.lastIndexOf("-") + 1));
User user = userService.getUserById(userId);

sessionService.unsubscribeFamily(user, familyId);
}

return message;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
package com.spring.familymoments.domain.chat.document;

import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import org.bson.types.ObjectId;
import org.springframework.data.annotation.Id;
import org.springframework.data.mongodb.core.mapping.Document;

import java.time.LocalDateTime;

@Getter
@AllArgsConstructor
@Builder
@Document(collection = "chat")
public class ChatDocument {
@Id
private ObjectId id;
private Long familyId;
private String sender;
private String message;
private LocalDateTime sendedTime;
}
Loading