Skip to content
Open
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
2 changes: 2 additions & 0 deletions build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,8 @@ dependencies {
// redis
implementation 'org.springframework.boot:spring-boot-starter-data-redis'

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

tasks.named('test') {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.sopt.assignment.article.domain.ESearchType;
import org.sopt.assignment.article.domain.ETag;
import org.sopt.assignment.article.dto.command.SaveArticleCommandDto;
import org.sopt.assignment.article.dto.request.SaveArticleRequestDto;
import org.sopt.assignment.article.dto.response.ArticleResponseDto;
Expand Down
4 changes: 2 additions & 2 deletions src/main/java/org/sopt/assignment/article/domain/Article.java
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,8 @@
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Getter
@Table(name = "articles", indexes = {
@Index(name = "idx_member_id", columnList = "member_id"),
@Index(name = "idx_title", columnList = "title"),
@Index(name = "idx_member_created", columnList = "member_id, created_at"),

@Index(name = "idx_created_at", columnList = "created_at")
})
public class Article extends BaseTimeEntity {
Expand Down
1 change: 0 additions & 1 deletion src/main/java/org/sopt/assignment/article/domain/ETag.java
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
package org.sopt.assignment.article.domain;

import lombok.Getter;
import lombok.RequiredArgsConstructor;

@Getter
public enum ETag {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,18 @@ public GetListArticleResponseDto searchArticle(ESearchType searchType, String ke
return GetListArticleResponseDto.of(articlePage.map(GetListArticleResponse::from));
}

@Transactional(readOnly = true)
public Article get(Long articleId){
return articleRepository.findById(articleId)
.orElseThrow(()-> BaseException.type(ArticleErrorCode.NOT_FOUND_ARTICLE));
}

@Transactional(readOnly = true)
public void validateArticleExists(Long articleId){
if(!articleRepository.existsById(articleId))
throw BaseException.type(ArticleErrorCode.NOT_FOUND_ARTICLE);
}

private void validateDuplicateTitle(String title){
if(articleRepository.existsByTitle(title)){
throw BaseException.type(ArticleErrorCode.ALREADY_USED_ARTICLE_TITLE);
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
package org.sopt.assignment.chat.controller;

import lombok.RequiredArgsConstructor;
import org.sopt.assignment.chat.domain.ChatMessage;
import org.sopt.assignment.chat.domain.ConnectedUser;
import org.sopt.assignment.chat.service.ChatService;
import org.sopt.assignment.chat.service.ChatSessionManager;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;

import java.util.List;

@RestController
@RequestMapping("/api/chat")
@RequiredArgsConstructor
public class ChatController {
private final ChatService chatService;
private final ChatSessionManager chatSessionManager;

@GetMapping("/messages")
public List<ChatMessage> getMessages(
@RequestParam(defaultValue = "50") int count
) {
return chatService.getRecentMessages(count);
}

@GetMapping("/users")
public List<ConnectedUser> getConnectedUsers() {
return chatSessionManager.getConnectedUsers();
}
}
19 changes: 19 additions & 0 deletions src/main/java/org/sopt/assignment/chat/domain/ChatMessage.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
package org.sopt.assignment.chat.domain;

import java.time.LocalDateTime;

public record ChatMessage(
EMessageType type,

String sender,

String content,

LocalDateTime timestamp

) {

public static ChatMessage of(EMessageType type, String sender, String content) {
return new ChatMessage(type, sender, content, LocalDateTime.now());
}
}
13 changes: 13 additions & 0 deletions src/main/java/org/sopt/assignment/chat/domain/ConnectedUser.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
package org.sopt.assignment.chat.domain;

public record ConnectedUser(
Long userId,

String userName,

String sessionId
) {
public static ConnectedUser of(Long userId, String userName, String sessionId) {
return new ConnectedUser(userId, userName, sessionId);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
package org.sopt.assignment.chat.domain;

public enum EMessageType {
CHAT,
ENTER,
LEAVE,
TYPING_START,
TYPING_END
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
package org.sopt.assignment.chat.exception;

import lombok.Getter;
import lombok.RequiredArgsConstructor;
import org.sopt.assignment.global.exception.ErrorCode;
import org.springframework.http.HttpStatus;

@RequiredArgsConstructor
@Getter
public enum ChatErrorCode implements ErrorCode {

FAILED_SAVE_MESSAGE(HttpStatus.INTERNAL_SERVER_ERROR, "CHAT_001", "๋ฉ”์‹œ์ง€ ์ €์žฅ์— ์‹คํŒจํ–ˆ์Šต๋‹ˆ๋‹ค."),;

private final HttpStatus status;
private final String errorCode;
private final String message;

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
package org.sopt.assignment.chat.handler;

import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.sopt.assignment.chat.domain.ChatMessage;
import org.sopt.assignment.chat.service.ChatService;
import org.sopt.assignment.chat.service.ChatSessionManager;
import org.springframework.stereotype.Component;
import org.springframework.web.socket.CloseStatus;
import org.springframework.web.socket.TextMessage;
import org.springframework.web.socket.WebSocketSession;
import org.springframework.web.socket.handler.TextWebSocketHandler;

import java.io.IOException;
import java.util.Set;
import java.util.concurrent.CopyOnWriteArraySet;

@Slf4j
@Component
@RequiredArgsConstructor
public class ChatWebSocketHandler extends TextWebSocketHandler {

private final Set<WebSocketSession> sessions = new CopyOnWriteArraySet<>();
private final ObjectMapper objectMapper;
private final ChatService chatService;
private final ChatSessionManager chatSessionManager;

@Override
public void afterConnectionEstablished(WebSocketSession session) throws Exception {
sessions.add(session);

Long memberId = getUserId(session);
log.info("์ƒˆ๋กœ์šด ์—ฐ๊ฒฐ: {}, ํ˜„์žฌ ์ ‘์†์ž: {}", session.getId(), sessions.size());

chatSessionManager.addUser(memberId, session.getId());

ChatMessage enterMessage = chatService.createEnterMessage(memberId);
broadcast(enterMessage);
}


@Override
protected void handleTextMessage(WebSocketSession session, TextMessage message) throws Exception {
String payload = message.getPayload();
Long memberId = getUserId(session);
log.info("๋ฐ›์€ ๋ฉ”์‹œ์ง€: {} from {}", payload, session.getId());

try {
ChatMessage chatMessage = chatService.processMessage(payload, memberId);
broadcast(chatMessage);
} catch (Exception e) {
log.error("๋ฉ”์‹œ์ง€ ์ฒ˜๋ฆฌ ์‹คํŒจ: sessionId = {}", session.getId(), e);
}
}


@Override
public void afterConnectionClosed(WebSocketSession session, CloseStatus status) throws Exception {
sessions.remove(session);
log.info("์—ฐ๊ฒฐ ์ข…๋ฃŒ: {}, ํ˜„์žฌ ์ ‘์†์ž: {}", session.getId(), sessions.size());
Long memberId = getUserId(session);
chatSessionManager.removeUserBySessionId(session.getId());
ChatMessage leaveMessage = chatService.createLeaveMessage(memberId);
broadcast(leaveMessage);
}

@Override
public void handleTransportError(WebSocketSession session, Throwable exception) throws Exception {
log.error("์—๋Ÿฌ ๋ฐœ์ƒ: {}", session.getId(), exception);
if (!session.isOpen()) {
log.warn("์„ธ์…˜์ด ์‹ค์ œ๋กœ ๋‹ซํ˜”์Œ, ์ œ๊ฑฐ ์ฒ˜๋ฆฌ: sessionId={}", session.getId());
sessions.remove(session);
chatSessionManager.removeUserBySessionId(session.getId());
}
}

private void broadcast(ChatMessage message) {
sessions.forEach(session -> {
try{
String json = objectMapper.writeValueAsString(message);
session.sendMessage(new TextMessage(json));
} catch (IOException e){
log.error("๋ฉ”์‹œ์ง€ ์ „์†ก ์‹คํŒจ: {}", session.getId(), e);
}
});
}

private Long getUserId(WebSocketSession session) {
return (Long) session.getAttributes().get("userId");
}
}
111 changes: 111 additions & 0 deletions src/main/java/org/sopt/assignment/chat/service/ChatService.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
package org.sopt.assignment.chat.service;

import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.sopt.assignment.chat.domain.EMessageType;
import org.sopt.assignment.chat.domain.ChatMessage;
import org.sopt.assignment.chat.exception.ChatErrorCode;
import org.sopt.assignment.global.constants.Constants;
import org.sopt.assignment.global.exception.BaseException;
import org.sopt.assignment.member.service.MemberService;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.stereotype.Service;

import java.util.ArrayList;
import java.util.List;

import static org.sopt.assignment.global.constants.Constants.CHAT_MESSAGES_KEY;

@Slf4j
@Service
@RequiredArgsConstructor
public class ChatService {

private final ObjectMapper objectMapper;
private final RedisTemplate<String, String> redisTemplate;
private final MemberService memberService;

public ChatMessage processMessage(String payload, Long memberId){
try{
ChatMessage message = objectMapper.readValue(payload, ChatMessage.class);

String memberName = memberService.getMemberById(memberId).getName();

if(message.type() == EMessageType.TYPING_START || message.type() == EMessageType.TYPING_END){
return ChatMessage.of(message.type(), memberName, "");
}

ChatMessage chatMessage = ChatMessage.of(message.type(), memberName, message.content());

if(message.type() == EMessageType.CHAT){
saveMessage(chatMessage);
}

return chatMessage;
} catch (JsonProcessingException e){
log.error("๋ฉ”์‹œ์ง€ ์ €์žฅ ์‹คํŒจ", e);
throw BaseException.type(ChatErrorCode.FAILED_SAVE_MESSAGE);
}

}


public ChatMessage createEnterMessage(Long memberId) {
String memberName = memberService.getMemberById(memberId).getName();

return ChatMessage.of(
EMessageType.ENTER,
memberName,
memberName + "๋‹˜์ด ์ž…์žฅํ–ˆ์Šต๋‹ˆ๋‹ค."
);
}

// ํ‡ด์žฅ ๋ฉ”์‹œ์ง€ ์ƒ์„ฑ
public ChatMessage createLeaveMessage(Long memberId) {
String memberName = memberService.getMemberById(memberId).getName();

return ChatMessage.of(
EMessageType.LEAVE,
memberName,
memberName + "๋‹˜์ด ํ‡ด์žฅํ–ˆ์Šต๋‹ˆ๋‹ค."
);
}

public void saveMessage(ChatMessage message) {
try {
String json = objectMapper.writeValueAsString(message);

redisTemplate.opsForList().leftPush(CHAT_MESSAGES_KEY, json);

redisTemplate.opsForList().trim(CHAT_MESSAGES_KEY, 0, Constants.MAX_MESSAGE_COUNT - 1);

log.info("Redis ์ €์žฅ: {}", message.content());
} catch (JsonProcessingException e){
log.error("๋ฉ”์‹œ์ง€ ์ €์žฅ ์‹คํŒจ", e);
}
}

public List<ChatMessage> getRecentMessages(int count) {
List<String> messages = redisTemplate.opsForList().range(
CHAT_MESSAGES_KEY,
0,
count - 1
);

List<ChatMessage> result = new ArrayList<>();
if (messages != null) {
for (String json : messages) {
try {
result.add(objectMapper.readValue(json, ChatMessage.class));
} catch (JsonProcessingException e) {
log.error("๋ฉ”์‹œ์ง€ ํŒŒ์‹ฑ ์‹คํŒจ: {}", json, e);
}
}
}

return result;
}

}
Loading