Skip to content

Commit 7c04a19

Browse files
authored
Deploy: Merge to main (#85)
* feat : stomp import 및 인증 설정 진행 * feat : 웹소켓 subscribe, unsubscribe, disconnect 시 유저의 실시간 채팅방 접속 정보 수정 * feat : chatMessage 엔티티 추가 * feat : 채팅 전송, 채팅이력 조회 API 생성 * fix : 채팅 기능 고도화 * fix : minor change * fix : 웹소켓에서 오류 발생 시 에러 메시지 사용자에게 보냄 * fix : 참여 요청 거절 수정
1 parent d4e760f commit 7c04a19

21 files changed

+617
-17
lines changed

build.gradle

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,9 @@ dependencies {
7171
// s3
7272
implementation(platform("software.amazon.awssdk:bom:2.25.8"))
7373
implementation 'software.amazon.awssdk:s3'
74+
75+
// websocket
76+
implementation 'org.springframework.boot:spring-boot-starter-websocket'
7477
}
7578

7679
tasks.named('test') {
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
package ita.tinybite.domain.chat.controller;
2+
3+
import ita.tinybite.domain.chat.dto.req.ChatMessageReqDto;
4+
import ita.tinybite.domain.chat.dto.res.ChatMessageResDto;
5+
import ita.tinybite.domain.chat.entity.ChatMessage;
6+
import ita.tinybite.domain.chat.service.ChatService;
7+
import ita.tinybite.global.response.APIResponse;
8+
import lombok.RequiredArgsConstructor;
9+
import org.springframework.messaging.handler.annotation.MessageMapping;
10+
import org.springframework.messaging.simp.SimpMessageHeaderAccessor;
11+
import org.springframework.messaging.simp.SimpMessagingTemplate;
12+
import org.springframework.stereotype.Controller;
13+
import org.springframework.web.bind.annotation.*;
14+
15+
16+
import static ita.tinybite.global.response.APIResponse.*;
17+
18+
19+
@Controller
20+
@RequiredArgsConstructor
21+
public class ChatController {
22+
23+
private final SimpMessagingTemplate simpMessagingTemplate;
24+
private final ChatService chatService;
25+
26+
/**
27+
* 1. ws://{server name}/publish/send 으로 요청 <br>
28+
* 2. message 저장 <br>
29+
* 3. 해당 채팅방을 구독중인 세션에게 해당 메시지 전달 <br>
30+
* 4. 만약 해당 채팅방을 구독중이지 않다면 (채팅방을 나간상태), 알림을 보냄 <br>
31+
*/
32+
@MessageMapping("/send")
33+
public void sendMessage(ChatMessageReqDto req,
34+
SimpMessageHeaderAccessor accessor) {
35+
// message entity 생성
36+
ChatMessage message = ChatMessage.builder()
37+
.chatRoomId(req.chatRoomId())
38+
.senderId((Long) accessor.getSessionAttributes().get("userId"))
39+
.senderName(req.nickname())
40+
.content(req.content()).build();
41+
42+
// message 저장
43+
ChatMessage saved = chatService.saveMessage(message);
44+
45+
// subscribe 한 사용자에게 전송
46+
simpMessagingTemplate.convertAndSend("/subscribe/chat/room/" + saved.getChatRoomId(), ChatMessageResDto.of(saved));
47+
48+
// subscribe하지 않았으나, 채팅방에 존재하는 사람들에게 전송
49+
chatService.sendNotification(saved, req.chatRoomId());
50+
}
51+
52+
@ResponseBody
53+
@GetMapping("/api/v1/chat/{chatRoomId}")
54+
public APIResponse<?> getChatMessages(@PathVariable Long chatRoomId,
55+
@RequestParam(defaultValue = "0") int page,
56+
@RequestParam(defaultValue = "20") int size) {
57+
return success(chatService.getChatMessage(chatRoomId, page, size));
58+
}
59+
}
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
package ita.tinybite.domain.chat.dto.req;
2+
3+
import lombok.Builder;
4+
5+
@Builder
6+
public record ChatMessageReqDto(
7+
Long chatRoomId,
8+
Long userId,
9+
String nickname,
10+
String content
11+
) {
12+
13+
}
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
package ita.tinybite.domain.chat.dto.res;
2+
3+
import ita.tinybite.domain.chat.entity.ChatMessage;
4+
import lombok.Builder;
5+
6+
@Builder
7+
public record ChatMessageResDto(
8+
Long userId,
9+
String nickname,
10+
String content
11+
) {
12+
public static ChatMessageResDto of(ChatMessage chatMessage) {
13+
return ChatMessageResDto.builder()
14+
.userId(chatMessage.getSenderId())
15+
.nickname(chatMessage.getSenderName())
16+
.content(chatMessage.getContent())
17+
.build();
18+
}
19+
}
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
package ita.tinybite.domain.chat.dto.res;
2+
3+
import lombok.Builder;
4+
5+
import java.util.List;
6+
7+
@Builder
8+
public record ChatMessageSliceResDto(
9+
List<ChatMessageResDto> messages,
10+
Boolean hasNext
11+
) {
12+
13+
}
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
package ita.tinybite.domain.chat.entity;
2+
3+
import ita.tinybite.global.entity.BaseEntity;
4+
import jakarta.persistence.*;
5+
import lombok.*;
6+
7+
@Entity
8+
@Table(name = "chat_messages")
9+
@Getter
10+
@Builder
11+
@AllArgsConstructor
12+
@NoArgsConstructor(access = AccessLevel.PROTECTED)
13+
public class ChatMessage extends BaseEntity {
14+
15+
@Id
16+
@GeneratedValue(strategy = GenerationType.IDENTITY)
17+
private Long id;
18+
19+
// 채팅룸 아이디
20+
private Long chatRoomId;
21+
22+
// 전송자 아이디
23+
private Long senderId;
24+
25+
// 전송자 이름 (nickname)
26+
private String senderName;
27+
28+
// 메시지 내용
29+
private String content;
30+
}
31+

src/main/java/ita/tinybite/domain/chat/entity/ChatRoom.java

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
import ita.tinybite.domain.chat.enums.ChatRoomType;
44
import ita.tinybite.domain.party.entity.Party;
5+
import ita.tinybite.domain.user.entity.User;
56
import jakarta.persistence.*;
67
import lombok.*;
78
import org.hibernate.annotations.CreationTimestamp;
@@ -43,20 +44,24 @@ public class ChatRoom {
4344

4445
@OneToMany(mappedBy = "chatRoom", cascade = CascadeType.ALL, orphanRemoval = true)
4546
@Builder.Default
46-
private List<ChatRoomMember> members = new ArrayList<>();
47+
private List<ChatRoomMember> participants = new ArrayList<>();
4748

4849
// ========== 비즈니스 메서드 ==========
4950

51+
public void addParticipants(ChatRoomMember... participants) {
52+
this.participants.addAll(List.of(participants));
53+
}
54+
5055
/**
5156
* 멤버 추가
5257
*/
53-
public void addMember(ita.tinybite.domain.user.entity.User user) {
58+
public void addMember(User user) {
5459
ChatRoomMember member = ChatRoomMember.builder()
5560
.chatRoom(this)
5661
.user(user)
5762
.isActive(true)
5863
.build();
59-
members.add(member);
64+
participants.add(member);
6065
}
6166

6267
/**

src/main/java/ita/tinybite/domain/chat/entity/ChatRoomMember.java

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -28,10 +28,6 @@ public class ChatRoomMember {
2828
@JoinColumn(name = "user_id", nullable = false)
2929
private User user;
3030

31-
@ManyToOne(fetch = FetchType.LAZY)
32-
@JoinColumn(name = "party_id")
33-
private Party party;
34-
3531
@Column(nullable = false)
3632
@Builder.Default
3733
private Boolean isActive = true;
@@ -42,6 +38,8 @@ public class ChatRoomMember {
4238

4339
private LocalDateTime leftAt;
4440

41+
private LocalDateTime lastReadAt;
42+
4543
/**
4644
* 퇴장
4745
*/
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
package ita.tinybite.domain.chat.repository;
2+
3+
import ita.tinybite.domain.chat.entity.ChatMessage;
4+
import org.springframework.data.domain.Pageable;
5+
import org.springframework.data.domain.Slice;
6+
import org.springframework.data.jpa.repository.JpaRepository;
7+
import org.springframework.stereotype.Repository;
8+
9+
10+
@Repository
11+
public interface ChatMessageRepository extends JpaRepository<ChatMessage, Long> {
12+
13+
Slice<ChatMessage> findByChatRoomId(Long roomId, Pageable pageable);
14+
}
Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
package ita.tinybite.domain.chat.service;
2+
3+
import lombok.RequiredArgsConstructor;
4+
import org.springframework.context.event.EventListener;
5+
import org.springframework.messaging.simp.stomp.StompHeaderAccessor;
6+
import org.springframework.stereotype.Component;
7+
import org.springframework.web.socket.messaging.SessionDisconnectEvent;
8+
import org.springframework.web.socket.messaging.SessionSubscribeEvent;
9+
import org.springframework.web.socket.messaging.SessionUnsubscribeEvent;
10+
11+
/**
12+
* subscribe, unsubscribe, disconnect 이벤트 발생 시 registry에 자동으로 값을 변경하는 스프링 빈 클래스
13+
*/
14+
@Component
15+
@RequiredArgsConstructor
16+
public class ChatEventListener {
17+
18+
private final ChatSubscribeRegistry registry;
19+
20+
/**
21+
* 구독 시, 호출되는 메서드 <br>
22+
* 1. StompHeader에서 userId, destination, sessionId, subscriptionId 받아옴 (destination 예 : /subscribe/chat/room/{chatRoomId}) <br>
23+
* 2. destination에서 roomId resolve <br>
24+
* 3. 이후 세션정보를 registry에 등록
25+
*/
26+
@EventListener
27+
public void onSubscribe(SessionSubscribeEvent event) {
28+
StompHeaderAccessor acc = StompHeaderAccessor.wrap(event.getMessage());
29+
30+
Long userId = (Long) acc.getSessionAttributes().get("userId");
31+
String destination = acc.getDestination();
32+
String sessionId = acc.getSessionId();
33+
String subscriptionId = acc.getSubscriptionId();
34+
35+
if (userId == null || destination == null) return;
36+
37+
// /subscribe/chat/room/1
38+
Long roomId = extractRoomId(destination);
39+
registry.register(sessionId, subscriptionId, roomId, userId);
40+
}
41+
42+
/**
43+
* 구독취소시 호출되는 메서드 <br>
44+
* 1. StompHeader에서 sessionId, subscriptionId resolve <br>
45+
* 2. 해당 구독정보를 가지고 있는 사용자를 registry에서 삭제 <br>
46+
* 3. 만약 네트워크 에러 등으로 유저 정보가 없을 시, 유저에 해당하는 구독 정보를 삭제
47+
*/
48+
@EventListener
49+
public void onUnsubscribe(SessionUnsubscribeEvent event) {
50+
StompHeaderAccessor acc = StompHeaderAccessor.wrap(event.getMessage());
51+
52+
String sessionId = acc.getSessionId();
53+
String subscriptionId = acc.getSubscriptionId();
54+
55+
if (sessionId != null && subscriptionId != null) {
56+
registry.unregister(sessionId, subscriptionId);
57+
}
58+
}
59+
60+
@EventListener
61+
public void onDisconnect(SessionDisconnectEvent event) {
62+
StompHeaderAccessor acc = StompHeaderAccessor.wrap(event.getMessage());
63+
64+
String sessionId = acc.getSessionId();
65+
if (sessionId != null) {
66+
registry.unregisterSession(sessionId);
67+
return;
68+
}
69+
70+
Long userId = acc.getSessionAttributes() != null ? (Long) acc.getSessionAttributes().get("userId") : null;
71+
if (userId != null) {
72+
registry.removeUserEverywhere(userId);
73+
}
74+
}
75+
76+
private Long extractRoomId(String destination) {
77+
// /subscribe/chat/room/{roomId}
78+
try {
79+
return Long.parseLong(destination.substring("/subscribe/chat/room/".length()));
80+
} catch (NumberFormatException e) {
81+
return null;
82+
}
83+
}
84+
}

0 commit comments

Comments
 (0)