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

release: 채팅 기능 배포 #33

Merged
merged 11 commits into from
Apr 11, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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 build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,9 @@ dependencies {
implementation "com.redis.om:redis-om-spring:0.8.9"
annotationProcessor "com.redis.om:redis-om-spring:0.8.9"

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

// Lombok
compileOnly 'org.projectlombok:lombok'
annotationProcessor 'org.projectlombok:lombok'
Expand Down
28 changes: 28 additions & 0 deletions src/docs/asciidoc/api/room.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -57,3 +57,31 @@ include::{snippets}/fetch-room-list-success/http-request.adoc[]

include::{snippets}/fetch-room-list-success/http-response.adoc[]
include::{snippets}/fetch-room-list-success/response-fields.adoc[]

{nbsp}

[[enter-room-success]]
=== 방 입장 성공

==== HTTP Request

include::{snippets}/enter-room-success/http-request.adoc[]

==== HTTP Response

include::{snippets}/enter-room-success/http-response.adoc[]
include::{snippets}/enter-room-success/response-fields.adoc[]

{nbsp}

[[enter-room-fail-single-room-participant-violation]]
=== 방 입장 실패: 다수의 방에 참가할 수 없습니다

==== HTTP Request

include::{snippets}/enter-room-fail-single-room-participant-violation/http-request.adoc[]

==== HTTP Response

include::{snippets}/enter-room-fail-single-room-participant-violation/http-response.adoc[]
include::{snippets}/enter-room-fail-single-room-participant-violation/response-fields.adoc[]
32 changes: 32 additions & 0 deletions src/docs/asciidoc/api/user.adoc
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
[[User-API]]
== User API

[[update-nickname-success]]
=== 닉네임 변경 성공

==== HTTP Request

include::{snippets}/update-nickname-success/http-request.adoc[]
include::{snippets}/update-nickname-success/request-fields.adoc[]

==== HTTP Response

include::{snippets}/update-nickname-success/http-response.adoc[]
include::{snippets}/update-nickname-success/response-fields.adoc[]

{nbsp}

[[update-nickname-fail]]
=== 닉네임 변경 실패: 요청 데이터 오류가 발생했습니다

==== HTTP Request

include::{snippets}/update-nickname-fail/http-request.adoc[]
include::{snippets}/update-nickname-fail/request-fields.adoc[]

==== HTTP Response

include::{snippets}/update-nickname-fail/http-response.adoc[]
include::{snippets}/update-nickname-fail/response-fields.adoc[]

{nbsp}
1 change: 1 addition & 0 deletions src/docs/asciidoc/index.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -12,3 +12,4 @@ endif::[]
:docinfo: shared

include::api/room.adoc[]
include::api/user.adoc[]
43 changes: 43 additions & 0 deletions src/main/java/site/youtogether/config/RedisConfig.java
Original file line number Diff line number Diff line change
Expand Up @@ -3,15 +3,20 @@
import org.springframework.boot.autoconfigure.data.redis.RedisProperties;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.redis.connection.RedisConnectionFactory;
import org.springframework.data.redis.connection.RedisStandaloneConfiguration;
import org.springframework.data.redis.connection.jedis.JedisConnectionFactory;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.listener.ChannelTopic;
import org.springframework.data.redis.listener.RedisMessageListenerContainer;
import org.springframework.data.redis.listener.adapter.MessageListenerAdapter;
import org.springframework.data.redis.serializer.GenericToStringSerializer;
import org.springframework.data.redis.serializer.StringRedisSerializer;

import com.redis.om.spring.annotations.EnableRedisDocumentRepositories;

import lombok.RequiredArgsConstructor;
import site.youtogether.message.application.RedisSubscriber;

@Configuration
@EnableRedisDocumentRepositories(basePackages = "site.youtogether.*")
Expand All @@ -20,6 +25,44 @@ public class RedisConfig {

private final RedisProperties redisProperties;

// 채팅 메시지를 관리하는 채널
@Bean
public ChannelTopic chatChannelTopic() {
return new ChannelTopic("chat");
}

// 채팅 메시지를 처리할 subscriber 메서드 설정
@Bean
public MessageListenerAdapter chatListenerAdapter(RedisSubscriber subscriber) {
return new MessageListenerAdapter(subscriber, "sendChat");
}

// 방에 참가한 인원 정보를 관리하는 채널
@Bean
public ChannelTopic participantChannelTopic() {
return new ChannelTopic("participant");
}

// 참가 인원 정보를 처리할 subscriber 메서드 설정
@Bean
public MessageListenerAdapter participantsInfoListenerAdapter(RedisSubscriber subscriber) {
return new MessageListenerAdapter(subscriber, "sendParticipantsInfo");
}

// 채널에 발행된 메시지 처리를 위한 subscriber 메서드를 컨테이너에 등록
@Bean
public RedisMessageListenerContainer redisMessageListenerContainer(RedisConnectionFactory redisConnectionFactory,
MessageListenerAdapter chatListenerAdapter, MessageListenerAdapter participantsInfoListenerAdapter,
ChannelTopic chatChannelTopic, ChannelTopic participantChannelTopic) {

RedisMessageListenerContainer container = new RedisMessageListenerContainer();
container.setConnectionFactory(redisConnectionFactory);
container.addMessageListener(chatListenerAdapter, chatChannelTopic);
container.addMessageListener(participantsInfoListenerAdapter, participantChannelTopic);

return container;
}

@Bean
public JedisConnectionFactory redisConnectionFactory() {
RedisStandaloneConfiguration redisStandaloneConfiguration = new RedisStandaloneConfiguration(redisProperties.getHost(),
Expand Down
34 changes: 34 additions & 0 deletions src/main/java/site/youtogether/config/WebSocketConfig.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
package site.youtogether.config;

import static site.youtogether.util.AppConstants.*;

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;

import lombok.RequiredArgsConstructor;
import site.youtogether.util.interceptor.StompHandshakeInterceptor;

@Configuration
@EnableWebSocketMessageBroker
@RequiredArgsConstructor
public class WebSocketConfig implements WebSocketMessageBrokerConfigurer {

private final StompHandshakeInterceptor stompHandshakeInterceptor;

@Override
public void configureMessageBroker(MessageBrokerRegistry registry) {
registry.enableSimpleBroker("/sub");
registry.setApplicationDestinationPrefixes("/pub");
}

@Override
public void registerStompEndpoints(StompEndpointRegistry registry) {
registry.addEndpoint(STOMP_ENDPOINT).setAllowedOriginPatterns("http://localhost:3000", "https://you-together-web.vercel.app")
.addInterceptors(stompHandshakeInterceptor)
.withSockJS();
}

}
4 changes: 4 additions & 0 deletions src/main/java/site/youtogether/exception/ErrorType.java
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,11 @@ public enum ErrorType {
COOKIE_NO_EXISTENCE(HttpStatus.UNAUTHORIZED, "세션 쿠키가 없습니다"),
COOKIE_INVALID(HttpStatus.BAD_REQUEST, "입력으로 들어온 세션 쿠키값과 대응되는 유저 아이디가 없습니다"),

// User
USER_NO_EXISTENCE(HttpStatus.NOT_FOUND, "유저가 존재하지 않습니다"),

// Room
ROOM_NO_EXISTENCE(HttpStatus.NOT_FOUND, "방이 없습니다"),
SINGLE_ROOM_PARTICIPATION_VIOLATION(HttpStatus.BAD_REQUEST, "하나의 방에만 참가할 수 있습니다");

private final HttpStatus status;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
package site.youtogether.exception.room;

import site.youtogether.exception.CustomException;
import site.youtogether.exception.ErrorType;

public class RoomNoExistenceException extends CustomException {

public RoomNoExistenceException() {
super(ErrorType.ROOM_NO_EXISTENCE);
}

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
package site.youtogether.exception.user;

import site.youtogether.exception.CustomException;
import site.youtogether.exception.ErrorType;

public class UserNoExistenceException extends CustomException {

public UserNoExistenceException() {
super(ErrorType.USER_NO_EXISTENCE);
}

}
19 changes: 19 additions & 0 deletions src/main/java/site/youtogether/message/ChatMessage.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
package site.youtogether.message;

import lombok.AllArgsConstructor;
import lombok.Getter;
import lombok.Setter;

@AllArgsConstructor
@Getter
@Setter
public class ChatMessage {

private final MessageType messageType = MessageType.CHAT;

private String roomCode;
private Long userId;
private String nickname;
private String content;

}
7 changes: 7 additions & 0 deletions src/main/java/site/youtogether/message/MessageType.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
package site.youtogether.message;

public enum MessageType {

CHAT, PARTICIPANTS_INFO, ROOM_TITLE

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
package site.youtogether.message;

import java.util.List;

import lombok.Getter;
import lombok.RequiredArgsConstructor;
import site.youtogether.user.dto.UserInfo;

@RequiredArgsConstructor
@Getter
public class ParticipantsInfoMessage {

private final MessageType messageType = MessageType.PARTICIPANTS_INFO;

private final List<UserInfo> participants;

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
package site.youtogether.message.application;

import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.listener.ChannelTopic;
import org.springframework.stereotype.Service;

import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;

import lombok.RequiredArgsConstructor;
import site.youtogether.message.ChatMessage;

@Service
@RequiredArgsConstructor
public class RedisPublisher {

private final ChannelTopic chatChannelTopic;
private final ChannelTopic participantChannelTopic;
private final RedisTemplate<String, String> redisTemplate;
private final ObjectMapper objectMapper;

public void publishChat(ChatMessage chatMessage) {
try {
redisTemplate.convertAndSend(chatChannelTopic.getTopic(), objectMapper.writeValueAsString(chatMessage));
} catch (JsonProcessingException e) {
throw new RuntimeException(e);
}
}

public void publishParticipantsInfo(String roomCode) {
redisTemplate.convertAndSend(participantChannelTopic.getTopic(), roomCode);
}

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
package site.youtogether.message.application;

import java.util.List;

import org.springframework.messaging.simp.SimpMessageSendingOperations;
import org.springframework.stereotype.Service;

import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;

import lombok.RequiredArgsConstructor;
import site.youtogether.exception.room.RoomNoExistenceException;
import site.youtogether.message.ChatMessage;
import site.youtogether.message.ParticipantsInfoMessage;
import site.youtogether.room.Room;
import site.youtogether.room.infrastructure.RoomStorage;
import site.youtogether.user.dto.UserInfo;

@Service
@RequiredArgsConstructor
public class RedisSubscriber {

private final RoomStorage roomStorage;
private final ObjectMapper objectMapper;
private final SimpMessageSendingOperations messagingTemplate;

public void sendChat(String publishMessage) {
try {
ChatMessage chatMessage = objectMapper.readValue(publishMessage, ChatMessage.class);
messagingTemplate.convertAndSend("/sub/messages/rooms/" + chatMessage.getRoomCode(), chatMessage);
} catch (JsonProcessingException e) {
throw new RuntimeException(e);
}
}

public void sendParticipantsInfo(String roomCode) {
Room room = roomStorage.findById(roomCode)
.orElseThrow(RoomNoExistenceException::new);

List<UserInfo> participants = room.getParticipants().values().stream()
.map(UserInfo::new)
.toList();

ParticipantsInfoMessage participantsInfoMessage = new ParticipantsInfoMessage(participants);
messagingTemplate.convertAndSend("/sub/messages/rooms/" + roomCode, participantsInfoMessage);
}

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
package site.youtogether.message.presentation;

import static site.youtogether.util.AppConstants.*;

import org.springframework.messaging.handler.annotation.MessageMapping;
import org.springframework.messaging.simp.SimpMessageHeaderAccessor;
import org.springframework.web.bind.annotation.RestController;

import lombok.RequiredArgsConstructor;
import site.youtogether.exception.user.UserNoExistenceException;
import site.youtogether.message.ChatMessage;
import site.youtogether.message.application.RedisPublisher;
import site.youtogether.user.User;
import site.youtogether.user.infrastructure.UserStorage;

@RestController
@RequiredArgsConstructor
public class MessageController {

private final UserStorage userStorage;
private final RedisPublisher redisPublisher;

@MessageMapping("/messages")
public void handleMessage(ChatMessage chatMessage, SimpMessageHeaderAccessor headerAccessor) {
Long userId = (Long)headerAccessor.getSessionAttributes().get(USER_ID);
User user = userStorage.findById(userId)
.orElseThrow(UserNoExistenceException::new);

chatMessage.setUserId(user.getUserId());
chatMessage.setNickname(user.getNickname());

redisPublisher.publishChat(chatMessage);
}

}
Loading
Loading