Skip to content

Commit 233a3b9

Browse files
authored
Merge pull request #61 from prgrms-web-devcourse-final-project/Feat/48
Feat: 스터디룸 채팅 구현 마무리 (#48)
2 parents 23a830b + 3d7984e commit 233a3b9

File tree

21 files changed

+1383
-138
lines changed

21 files changed

+1383
-138
lines changed

src/main/java/com/back/domain/chat/controller/ChatApiController.java

Lines changed: 9 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -3,37 +3,35 @@
33
import com.back.domain.chat.dto.ChatPageResponse;
44
import com.back.domain.chat.service.ChatService;
55
import com.back.global.common.dto.RsData;
6+
import com.back.global.security.CustomUserDetails;
7+
import io.swagger.v3.oas.annotations.Operation;
8+
import io.swagger.v3.oas.annotations.tags.Tag;
69
import lombok.RequiredArgsConstructor;
710
import org.springframework.format.annotation.DateTimeFormat;
811
import org.springframework.http.HttpStatus;
912
import org.springframework.http.ResponseEntity;
13+
import org.springframework.security.core.annotation.AuthenticationPrincipal;
1014
import org.springframework.web.bind.annotation.*;
1115

1216
import java.time.LocalDateTime;
13-
import java.util.Map;
1417

1518
@RestController
16-
@RequestMapping("/api")
1719
@RequiredArgsConstructor
20+
@RequestMapping("/api")
21+
@Tag(name = "Chat API", description = "채팅 메시지 조회 관련 API")
1822
public class ChatApiController {
1923

2024
private final ChatService chatService;
2125

2226
// 방 채팅 메시지 조회 (페이징, 특정 시간 이전 메시지)
2327
@GetMapping("/rooms/{roomId}/messages")
28+
@Operation(summary = "채팅방 메시지 목록 조회", description = "특정 채팅방의 이전 메시지 기록을 페이징하여 조회합니다.")
2429
public ResponseEntity<RsData<ChatPageResponse>> getRoomChatMessages(
2530
@PathVariable Long roomId,
2631
@RequestParam(defaultValue = "0") int page,
27-
@RequestParam(defaultValue = "50") int size,
32+
@RequestParam(defaultValue = "20") int size,
2833
@RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) LocalDateTime before,
29-
@RequestHeader("Authorization") String authorization) {
30-
31-
// size 최대값 제한 (임시: max 100)
32-
if (size > 100) {
33-
size = 100;
34-
}
35-
36-
// TODO: JWT 토큰에서 사용자 정보 추출 및 권한 확인
34+
@AuthenticationPrincipal CustomUserDetails userDetails) {
3735

3836
ChatPageResponse chatHistory = chatService.getRoomChatHistory(roomId, page, size, before);
3937

@@ -42,28 +40,4 @@ public ResponseEntity<RsData<ChatPageResponse>> getRoomChatMessages(
4240
.body(RsData.success("채팅 기록 조회 성공", chatHistory));
4341
}
4442

45-
// 방 채팅 메시지 삭제
46-
@DeleteMapping("/rooms/{roomId}/messages/{messageId}")
47-
public ResponseEntity<RsData<Map<String, Object>>> deleteRoomMessage(
48-
@PathVariable Long roomId,
49-
@PathVariable Long messageId,
50-
@RequestHeader("Authorization") String authorization) {
51-
52-
// TODO: JWT 토큰에서 사용자 정보 추출
53-
54-
// 임시로 하드코딩 (테스트용)
55-
Long currentUserId = 1L;
56-
57-
// 메시지 삭제 로직 실행
58-
chatService.deleteRoomMessage(roomId, messageId, currentUserId);
59-
60-
Map<String, Object> responseData = Map.of(
61-
"messageId", messageId,
62-
"deletedAt", LocalDateTime.now()
63-
);
64-
65-
return ResponseEntity
66-
.status(HttpStatus.OK)
67-
.body(RsData.success("메시지 삭제 성공", responseData));
68-
}
6943
}

src/main/java/com/back/domain/chat/controller/ChatWebSocketController.java

Lines changed: 35 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,17 +2,23 @@
22

33
import com.back.domain.studyroom.entity.RoomChatMessage;
44
import com.back.domain.chat.dto.ChatMessageDto;
5+
import com.back.global.security.CustomUserDetails;
56
import com.back.global.websocket.dto.WebSocketErrorResponse;
67
import com.back.domain.chat.service.ChatService;
8+
import io.swagger.v3.oas.annotations.tags.Tag;
79
import lombok.RequiredArgsConstructor;
810
import org.springframework.messaging.handler.annotation.DestinationVariable;
911
import org.springframework.messaging.handler.annotation.MessageMapping;
1012
import org.springframework.messaging.simp.SimpMessageHeaderAccessor;
1113
import org.springframework.messaging.simp.SimpMessagingTemplate;
14+
import org.springframework.security.core.Authentication;
1215
import org.springframework.stereotype.Controller;
1316

17+
import java.security.Principal;
18+
1419
@Controller
1520
@RequiredArgsConstructor
21+
@Tag(name = "Chat WebSocket", description = "STOMP를 이용한 실시간 채팅 WebSocket 컨트롤러 (Swagger에서 직접 테스트 불가)")
1622
public class ChatWebSocketController {
1723

1824
private final ChatService chatService;
@@ -25,18 +31,24 @@ public class ChatWebSocketController {
2531
* @param roomId 스터디룸 ID
2632
* @param chatMessage 채팅 메시지 (content, messageType, attachmentId)
2733
* @param headerAccessor WebSocket 헤더 정보
34+
* @param principal 인증된 사용자 정보
2835
*/
2936
@MessageMapping("/chat/room/{roomId}")
3037
public void handleRoomChat(@DestinationVariable Long roomId,
3138
ChatMessageDto chatMessage,
32-
SimpMessageHeaderAccessor headerAccessor) {
39+
SimpMessageHeaderAccessor headerAccessor,
40+
Principal principal) {
3341

3442
try {
35-
// TODO: WebSocket 세션에서 사용자 정보 추출
43+
// WebSocket에서 인증된 사용자 정보 추출
44+
CustomUserDetails userDetails = extractUserDetails(principal);
45+
if (userDetails == null) {
46+
sendErrorToUser(headerAccessor.getSessionId(), "WS_UNAUTHORIZED", "인증이 필요합니다");
47+
return;
48+
}
3649

37-
// 임시 하드코딩 (나중에 JWT 인증으로 교체)
38-
Long currentUserId = 1L;
39-
String currentUserNickname = "테스트사용자";
50+
Long currentUserId = userDetails.getUserId();
51+
String currentUserNickname = userDetails.getUsername();
4052

4153
// 메시지 정보 보완
4254
chatMessage.setRoomId(roomId);
@@ -74,4 +86,22 @@ public void handleRoomChat(@DestinationVariable Long roomId,
7486
messagingTemplate.convertAndSendToUser(sessionId, "/queue/errors", errorResponse);
7587
}
7688
}
89+
90+
// WebSocket Principal에서 CustomUserDetails 추출
91+
private CustomUserDetails extractUserDetails(Principal principal) {
92+
if (principal instanceof Authentication auth) {
93+
Object principalObj = auth.getPrincipal();
94+
if (principalObj instanceof CustomUserDetails userDetails) {
95+
return userDetails;
96+
}
97+
}
98+
return null;
99+
}
100+
101+
// 특정 사용자에게 에러 메시지 전송
102+
private void sendErrorToUser(String sessionId, String errorCode, String errorMessage) {
103+
WebSocketErrorResponse errorResponse = WebSocketErrorResponse.create(errorCode, errorMessage);
104+
messagingTemplate.convertAndSendToUser(sessionId, "/queue/errors", errorResponse);
105+
}
106+
77107
}

src/main/java/com/back/domain/chat/dto/ChatPageResponse.java

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ public class ChatPageResponse {
1515

1616
private List<ChatMessageDto> content;
1717
private PageableDto pageable;
18+
private long totalElements;
1819

1920
// 페이징 정보 DTO
2021
@Data
@@ -36,6 +37,7 @@ public static ChatPageResponse from(org.springframework.data.domain.Page<ChatMes
3637
.size(page.getSize())
3738
.hasNext(page.hasNext())
3839
.build())
40+
.totalElements(page.getTotalElements())
3941
.build();
4042
}
4143
}

src/main/java/com/back/domain/chat/service/ChatService.java

Lines changed: 20 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
import com.back.domain.chat.dto.ChatPageResponse;
1111
import com.back.global.exception.CustomException;
1212
import com.back.global.exception.ErrorCode;
13+
import com.back.global.security.CurrentUser;
1314
import lombok.RequiredArgsConstructor;
1415
import org.springframework.data.domain.Page;
1516
import org.springframework.data.domain.PageRequest;
@@ -27,6 +28,11 @@ public class ChatService {
2728
private final RoomChatMessageRepository roomChatMessageRepository;
2829
private final RoomRepository roomRepository;
2930
private final UserRepository userRepository;
31+
private final CurrentUser currentUser;
32+
33+
// 페이징 설정 상수
34+
private static final int DEFAULT_PAGE_SIZE = 20;
35+
private static final int MAX_PAGE_SIZE = 100;
3036

3137
// 방 채팅 메시지 저장
3238
@Transactional
@@ -54,22 +60,32 @@ public ChatPageResponse getRoomChatHistory(Long roomId, int page, int size, Loca
5460
roomRepository.findById(roomId)
5561
.orElseThrow(() -> new CustomException(ErrorCode.ROOM_NOT_FOUND));
5662

57-
Pageable pageable = PageRequest.of(page, size);
63+
// size 값 검증 및 제한
64+
int validatedSize = validateAndLimitPageSize(size);
65+
66+
Pageable pageable = PageRequest.of(page, validatedSize);
5867

5968
// before 파라미터가 있으면 해당 시점 이전 메시지만 조회
6069
Page<RoomChatMessage> messagesPage;
6170
if (before != null) {
62-
// TODO: before 조건 추가한 Repository 메서드 필요
63-
messagesPage = roomChatMessageRepository.findByRoomIdOrderByCreatedAtDesc(roomId, pageable);
71+
messagesPage = roomChatMessageRepository.findMessagesByRoomIdBefore(roomId, before, pageable);
6472
} else {
65-
messagesPage = roomChatMessageRepository.findByRoomIdOrderByCreatedAtDesc(roomId, pageable);
73+
messagesPage = roomChatMessageRepository.findMessagesByRoomId(roomId, pageable);
6674
}
6775

6876
Page<ChatMessageDto> dtoPage = messagesPage.map(this::convertToDto);
6977

7078
return ChatPageResponse.from(dtoPage);
7179
}
7280

81+
// size 값 검증 및 최대값 제한
82+
private int validateAndLimitPageSize(int size) {
83+
if (size <= 0) {
84+
return DEFAULT_PAGE_SIZE; // 0 이하면 기본값 사용
85+
}
86+
return Math.min(size, MAX_PAGE_SIZE); // 최대값 제한
87+
}
88+
7389
// 메시지 엔티티를 DTO로 변환
7490
private ChatMessageDto convertToDto(RoomChatMessage message) {
7591
return ChatMessageDto.builder()
@@ -85,24 +101,4 @@ private ChatMessageDto convertToDto(RoomChatMessage message) {
85101
.build();
86102
}
87103

88-
// 방 채팅 메시지 삭제
89-
@Transactional
90-
public void deleteRoomMessage(Long roomId, Long messageId, Long currentUserId) {
91-
// 메시지 존재 여부 확인
92-
RoomChatMessage message = roomChatMessageRepository.findById(messageId)
93-
.orElseThrow(() -> new CustomException(ErrorCode.MESSAGE_NOT_FOUND));
94-
95-
// 방 ID 검증
96-
if (!message.getRoom().getId().equals(roomId)) {
97-
throw new CustomException(ErrorCode.BAD_REQUEST);
98-
}
99-
100-
// 작성자 권한 확인
101-
if (!message.getUser().getId().equals(currentUserId)) {
102-
throw new CustomException(ErrorCode.MESSAGE_FORBIDDEN);
103-
}
104-
105-
// 메시지 삭제
106-
roomChatMessageRepository.delete(message);
107-
}
108104
}

src/main/java/com/back/domain/studyroom/entity/Room.java

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,15 +4,20 @@
44
import com.back.domain.user.entity.User;
55
import com.back.global.entity.BaseEntity;
66
import jakarta.persistence.*;
7+
import lombok.AllArgsConstructor;
8+
import lombok.Builder;
79
import lombok.Getter;
810
import lombok.NoArgsConstructor;
11+
import lombok.experimental.SuperBuilder;
912

1013
import java.util.ArrayList;
1114
import java.util.List;
1215

1316
@Entity
14-
@NoArgsConstructor
1517
@Getter
18+
@SuperBuilder
19+
@NoArgsConstructor
20+
@AllArgsConstructor
1621
public class Room extends BaseEntity {
1722
private String title;
1823
private String description;
@@ -25,31 +30,43 @@ public class Room extends BaseEntity {
2530
private boolean allowScreenShare;
2631

2732
// 방 상태
33+
@Builder.Default
2834
@Enumerated(EnumType.STRING)
2935
@Column(nullable = false)
3036
private RoomStatus status = RoomStatus.WAITING;
37+
3138
// 현재 참여자
39+
@Builder.Default
3240
@Column(nullable = false)
3341
private int currentParticipants = 0;
42+
3443
// 방장
3544
@ManyToOne(fetch = FetchType.LAZY)
3645
@JoinColumn(name = "created_by")
3746
private User createdBy;
47+
3848
// 테마
3949
@ManyToOne(fetch = FetchType.LAZY)
4050
@JoinColumn(name = "theme_id")
4151
private RoomTheme theme;
4252

4353
// 연관관계 설정
54+
@Builder.Default
4455
@OneToMany(mappedBy = "room", cascade = CascadeType.ALL, orphanRemoval = true)
4556
private List<RoomMember> roomMembers = new ArrayList<>();
57+
4658
// 채팅 메시지
59+
@Builder.Default
4760
@OneToMany(mappedBy = "room", cascade = CascadeType.ALL, orphanRemoval = true)
4861
private List<RoomChatMessage> roomChatMessages = new ArrayList<>();
62+
4963
// 참가자 기록
64+
@Builder.Default
5065
@OneToMany(mappedBy = "room", cascade = CascadeType.ALL, orphanRemoval = true)
5166
private List<RoomParticipantHistory> roomParticipantHistories = new ArrayList<>();
67+
5268
// 스터디 기록
69+
@Builder.Default
5370
@OneToMany(mappedBy = "room", cascade = CascadeType.ALL, orphanRemoval = true)
5471
public List<StudyRecord> studyRecords = new ArrayList<>();
5572

src/main/java/com/back/domain/studyroom/entity/RoomChatMessage.java

Lines changed: 5 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -6,12 +6,17 @@
66
import jakarta.persistence.FetchType;
77
import jakarta.persistence.JoinColumn;
88
import jakarta.persistence.ManyToOne;
9+
import lombok.AllArgsConstructor;
10+
import lombok.Builder;
911
import lombok.Getter;
1012
import lombok.NoArgsConstructor;
13+
import lombok.experimental.SuperBuilder;
1114

1215
@Entity
1316
@Getter
17+
@SuperBuilder
1418
@NoArgsConstructor
19+
@AllArgsConstructor
1520
public class RoomChatMessage extends BaseEntity {
1621
@ManyToOne(fetch = FetchType.LAZY)
1722
@JoinColumn(name = "room_id")
@@ -23,10 +28,4 @@ public class RoomChatMessage extends BaseEntity {
2328

2429
private String content;
2530

26-
// 채팅 메세지 생성자
27-
public RoomChatMessage(Room room, User user, String content) {
28-
this.room = room;
29-
this.user = user;
30-
this.content = content;
31-
}
3231
}
Lines changed: 2 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -1,36 +1,9 @@
11
package com.back.domain.studyroom.repository;
22

33
import com.back.domain.studyroom.entity.RoomChatMessage;
4-
import org.springframework.data.domain.Page;
5-
import org.springframework.data.domain.Pageable;
64
import org.springframework.data.jpa.repository.JpaRepository;
7-
import org.springframework.data.jpa.repository.Query;
8-
import org.springframework.data.repository.query.Param;
95
import org.springframework.stereotype.Repository;
106

11-
import java.time.LocalDateTime;
12-
import java.util.List;
13-
147
@Repository
15-
public interface RoomChatMessageRepository extends JpaRepository<RoomChatMessage, Long> {
16-
17-
// 방별 페이징된 채팅 메시지 조회 (무한 스크롤용)
18-
@Query("SELECT m FROM RoomChatMessage m " +
19-
"WHERE m.room.id = :roomId " +
20-
"ORDER BY m.createdAt DESC")
21-
Page<RoomChatMessage> findByRoomIdOrderByCreatedAtDesc(@Param("roomId") Long roomId, Pageable pageable);
22-
23-
// 특정 타임스탬프 이후의 메시지 조회 (실시간 업데이트용)
24-
@Query("SELECT m FROM RoomChatMessage m " +
25-
"WHERE m.room.id = :roomId " +
26-
"AND m.createdAt > :timestamp " +
27-
"ORDER BY m.createdAt ASC")
28-
List<RoomChatMessage> findByRoomIdAfterTimestamp(@Param("roomId") Long roomId,
29-
@Param("timestamp") LocalDateTime timestamp);
30-
31-
// 방별 최근 20개 메시지 조회
32-
List<RoomChatMessage> findTop20ByRoomIdOrderByCreatedAtDesc(Long roomId);
33-
34-
// 방별 전체 메시지 수 조회
35-
long countByRoomId(Long roomId);
36-
}
8+
public interface RoomChatMessageRepository extends JpaRepository<RoomChatMessage, Long>, RoomChatMessageRepositoryCustom {
9+
}

0 commit comments

Comments
 (0)