From 0fe0f65a7eee37de0dea180470bb75109a92559e Mon Sep 17 00:00:00 2001 From: jueunk617 Date: Tue, 23 Sep 2025 14:17:12 +0900 Subject: [PATCH 1/3] =?UTF-8?q?Feat:=20=EC=8B=A4=EC=8B=9C=EA=B0=84=20?= =?UTF-8?q?=EC=8A=A4=ED=84=B0=EB=94=94=EB=A3=B8=20=EC=B1=84=ED=8C=85=20?= =?UTF-8?q?=EA=B8=B0=EB=8A=A5=20=EA=B5=AC=ED=98=84=20-=20WebSocket=20+=20S?= =?UTF-8?q?TOMP=20=EA=B8=B0=EB=B0=98=20=EC=8B=A4=EC=8B=9C=EA=B0=84=20?= =?UTF-8?q?=EB=A9=94=EC=8B=9C=EC=A7=80=20=EC=A0=84=EC=86=A1=20-=20?= =?UTF-8?q?=ED=8E=98=EC=9D=B4=EC=A7=95=20=EA=B8=B0=EB=B0=98=20=EC=B1=84?= =?UTF-8?q?=ED=8C=85=20=EC=9D=B4=EB=A0=A5=20=EC=A1=B0=ED=9A=8C=20API=20-?= =?UTF-8?q?=20=EB=B0=A9=20=EC=B1=84=ED=8C=85=20=EB=A9=94=EC=8B=9C=EC=A7=80?= =?UTF-8?q?=20=EC=82=AD=EC=A0=9C=20=EA=B8=B0=EB=8A=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- build.gradle.kts | 29 ++++- .../back/domain/studyroom/entity/Room.java | 6 + .../studyroom/entity/RoomChatMessage.java | 7 ++ .../repository/RoomChatMessageRepository.java | 36 ++++++ .../studyroom/repository/RoomRepository.java | 27 +++++ .../user/entity/PrivateChatMessage.java | 2 + .../com/back/domain/user/entity/User.java | 18 +++ .../PrivateChatMessageRepository.java | 58 ++++++++++ .../user/repository/UserRepository.java | 9 ++ .../controller/ChatApiController.java | 69 +++++++++++ .../controller/ChatWebSocketController.java | 78 +++++++++++++ .../domain/websocket/dto/ChatMessageDto.java | 51 +++++++++ .../websocket/dto/ChatPageResponse.java | 41 +++++++ .../websocket/dto/WebSocketErrorResponse.java | 40 +++++++ .../domain/websocket/service/ChatService.java | 108 ++++++++++++++++++ .../com/back/global/exception/ErrorCode.java | 15 +++ .../back/global/security/SecurityConfig.java | 2 +- src/main/resources/application.yml | 4 +- 18 files changed, 592 insertions(+), 8 deletions(-) create mode 100644 src/main/java/com/back/domain/studyroom/repository/RoomChatMessageRepository.java create mode 100644 src/main/java/com/back/domain/studyroom/repository/RoomRepository.java create mode 100644 src/main/java/com/back/domain/user/repository/PrivateChatMessageRepository.java create mode 100644 src/main/java/com/back/domain/user/repository/UserRepository.java create mode 100644 src/main/java/com/back/domain/websocket/controller/ChatApiController.java create mode 100644 src/main/java/com/back/domain/websocket/controller/ChatWebSocketController.java create mode 100644 src/main/java/com/back/domain/websocket/dto/ChatMessageDto.java create mode 100644 src/main/java/com/back/domain/websocket/dto/ChatPageResponse.java create mode 100644 src/main/java/com/back/domain/websocket/dto/WebSocketErrorResponse.java create mode 100644 src/main/java/com/back/domain/websocket/service/ChatService.java diff --git a/build.gradle.kts b/build.gradle.kts index aeefb0b6..6c60f4e4 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -25,19 +25,36 @@ repositories { } dependencies { - implementation("org.springframework.boot:spring-boot-starter-data-jpa") + // Spring implementation("org.springframework.boot:spring-boot-starter-web") implementation("org.springframework.boot:spring-boot-starter-websocket") - implementation("org.springframework.boot:spring-boot-starter-security") - testImplementation("org.springframework.security:spring-security-test") - compileOnly("org.projectlombok:lombok") - developmentOnly("org.springframework.boot:spring-boot-devtools") + + // Database & JPA + implementation("org.springframework.boot:spring-boot-starter-data-jpa") runtimeOnly("com.h2database:h2") runtimeOnly("com.mysql:mysql-connector-j") + + // QueryDSL + implementation("com.querydsl:querydsl-jpa:5.0.0:jakarta") + annotationProcessor("com.querydsl:querydsl-apt:5.0.0:jakarta") + annotationProcessor("jakarta.persistence:jakarta.persistence-api") + annotationProcessor("jakarta.annotation:jakarta.annotation-api") + + // Security + implementation("org.springframework.boot:spring-boot-starter-security") + + // Development Tools + compileOnly("org.projectlombok:lombok") annotationProcessor("org.projectlombok:lombok") + developmentOnly("org.springframework.boot:spring-boot-devtools") + + // Swagger + implementation("org.springdoc:springdoc-openapi-starter-webmvc-ui:2.8.13") + + // Test testImplementation("org.springframework.boot:spring-boot-starter-test") + testImplementation("org.springframework.security:spring-security-test") testRuntimeOnly("org.junit.platform:junit-platform-launcher") - implementation("org.springdoc:springdoc-openapi-starter-webmvc-ui:2.8.13") } tasks.withType { diff --git a/src/main/java/com/back/domain/studyroom/entity/Room.java b/src/main/java/com/back/domain/studyroom/entity/Room.java index becb3f0c..706f2c90 100644 --- a/src/main/java/com/back/domain/studyroom/entity/Room.java +++ b/src/main/java/com/back/domain/studyroom/entity/Room.java @@ -1,6 +1,7 @@ package com.back.domain.studyroom.entity; import com.back.domain.study.entity.StudyRecord; +import com.back.domain.user.entity.User; import com.back.global.entity.BaseEntity; import jakarta.persistence.*; import lombok.Getter; @@ -14,6 +15,7 @@ @Getter public class Room extends BaseEntity { private String title; + private String description; private boolean isPrivate; @@ -30,6 +32,10 @@ public class Room extends BaseEntity { private boolean allowScreenShare; + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "created_by") + private User createdBy; + @ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name = "theme_id") private RoomTheme theme; diff --git a/src/main/java/com/back/domain/studyroom/entity/RoomChatMessage.java b/src/main/java/com/back/domain/studyroom/entity/RoomChatMessage.java index 1eb79872..0028f3b2 100644 --- a/src/main/java/com/back/domain/studyroom/entity/RoomChatMessage.java +++ b/src/main/java/com/back/domain/studyroom/entity/RoomChatMessage.java @@ -22,4 +22,11 @@ public class RoomChatMessage extends BaseEntity { private User user; private String content; + + // 채팅 메세지 생성자 + public RoomChatMessage(Room room, User user, String content) { + this.room = room; + this.user = user; + this.content = content; + } } diff --git a/src/main/java/com/back/domain/studyroom/repository/RoomChatMessageRepository.java b/src/main/java/com/back/domain/studyroom/repository/RoomChatMessageRepository.java new file mode 100644 index 00000000..3809ccb1 --- /dev/null +++ b/src/main/java/com/back/domain/studyroom/repository/RoomChatMessageRepository.java @@ -0,0 +1,36 @@ +package com.back.domain.studyroom.repository; + +import com.back.domain.studyroom.entity.RoomChatMessage; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import org.springframework.stereotype.Repository; + +import java.time.LocalDateTime; +import java.util.List; + +@Repository +public interface RoomChatMessageRepository extends JpaRepository { + + // 방별 페이징된 채팅 메시지 조회 (무한 스크롤용) + @Query("SELECT m FROM RoomChatMessage m " + + "WHERE m.room.id = :roomId " + + "ORDER BY m.createdAt DESC") + Page findByRoomIdOrderByCreatedAtDesc(@Param("roomId") Long roomId, Pageable pageable); + + // 특정 타임스탬프 이후의 메시지 조회 (실시간 업데이트용) + @Query("SELECT m FROM RoomChatMessage m " + + "WHERE m.room.id = :roomId " + + "AND m.createdAt > :timestamp " + + "ORDER BY m.createdAt ASC") + List findByRoomIdAfterTimestamp(@Param("roomId") Long roomId, + @Param("timestamp") LocalDateTime timestamp); + + // 방별 최근 20개 메시지 조회 + List findTop20ByRoomIdOrderByCreatedAtDesc(Long roomId); + + // 방별 전체 메시지 수 조회 + long countByRoomId(Long roomId); +} \ No newline at end of file diff --git a/src/main/java/com/back/domain/studyroom/repository/RoomRepository.java b/src/main/java/com/back/domain/studyroom/repository/RoomRepository.java new file mode 100644 index 00000000..16100948 --- /dev/null +++ b/src/main/java/com/back/domain/studyroom/repository/RoomRepository.java @@ -0,0 +1,27 @@ +package com.back.domain.studyroom.repository; + +import com.back.domain.studyroom.entity.Room; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import org.springframework.stereotype.Repository; + +import java.util.List; + +@Repository +public interface RoomRepository extends JpaRepository { + + // 제목으로 방 검색 (부분 일치) + List findByTitleContaining(String title); + + // 활성화된 방 목록 조회 + @Query("SELECT r FROM Room r WHERE r.isActive = true") + List findActiveRooms(); + + // 사용자가 생성한 방 목록 조회 + @Query("SELECT r FROM Room r WHERE r.createdBy.id = :createdById") + List findByCreatedById(@Param("createdById") Long createdById); + + // 방 존재 여부 확인 + boolean existsById(Long roomId); +} \ No newline at end of file diff --git a/src/main/java/com/back/domain/user/entity/PrivateChatMessage.java b/src/main/java/com/back/domain/user/entity/PrivateChatMessage.java index 592415dd..12226f5d 100644 --- a/src/main/java/com/back/domain/user/entity/PrivateChatMessage.java +++ b/src/main/java/com/back/domain/user/entity/PrivateChatMessage.java @@ -21,4 +21,6 @@ public class PrivateChatMessage extends BaseEntity { private User toUser; private String content; + + private boolean isRead = false; } diff --git a/src/main/java/com/back/domain/user/entity/User.java b/src/main/java/com/back/domain/user/entity/User.java index 42e02741..a08fb0b3 100644 --- a/src/main/java/com/back/domain/user/entity/User.java +++ b/src/main/java/com/back/domain/user/entity/User.java @@ -80,4 +80,22 @@ public class User extends BaseEntity { @OneToMany(mappedBy = "user", cascade = CascadeType.ALL, orphanRemoval = true) private List fileAttachments = new ArrayList<>(); + + // -------------------- 헬퍼 메서드 -------------------- + // 현재 사용자의 닉네임 조회 + public String getNickname() { + return userProfiles.stream() + .findFirst() + .map(UserProfile::getNickname) + .filter(nickname -> nickname != null && !nickname.trim().isEmpty()) + .orElse(this.username); + } + + // 현재 사용자의 프로필 이미지 URL 조회 + public String getProfileImageUrl() { + return userProfiles.stream() + .findFirst() + .map(UserProfile::getProfileImageUrl) + .orElse(null); + } } diff --git a/src/main/java/com/back/domain/user/repository/PrivateChatMessageRepository.java b/src/main/java/com/back/domain/user/repository/PrivateChatMessageRepository.java new file mode 100644 index 00000000..fa7c9700 --- /dev/null +++ b/src/main/java/com/back/domain/user/repository/PrivateChatMessageRepository.java @@ -0,0 +1,58 @@ +package com.back.domain.user.repository; + +import com.back.domain.user.entity.PrivateChatMessage; +import com.back.domain.user.entity.User; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import org.springframework.stereotype.Repository; + +import java.time.LocalDateTime; +import java.util.List; + +@Repository +public interface PrivateChatMessageRepository extends JpaRepository { + + // 두 사용자 간의 페이징된 대화 메시지 조회 (무한 스크롤용) + @Query("SELECT m FROM PrivateChatMessage m " + + "WHERE (m.fromUser.id = :userId1 AND m.toUser.id = :userId2) " + + "OR (m.fromUser.id = :userId2 AND m.toUser.id = :userId1) " + + "ORDER BY m.createdAt DESC") + Page findConversationBetweenUsers(@Param("userId1") Long userId1, + @Param("userId2") Long userId2, + Pageable pageable); + + // 두 사용자 간의 페이징된 대화 메시지 조회 (무한 스크롤용) - 최신 메시지부터 + @Query("SELECT m FROM PrivateChatMessage m " + + "WHERE ((m.fromUser.id = :userId1 AND m.toUser.id = :userId2) " + + "OR (m.fromUser.id = :userId2 AND m.toUser.id = :userId1)) " + + "AND m.createdAt > :timestamp " + + "ORDER BY m.createdAt ASC") + List findNewMessagesBetweenUsers(@Param("userId1") Long userId1, + @Param("userId2") Long userId2, + @Param("timestamp") LocalDateTime timestamp); + + // 두 사용자 간의 최근 20개 메시지 조회 (초기 로드용) + @Query("SELECT DISTINCT " + + "CASE WHEN m.fromUser.id = :userId THEN m.toUser ELSE m.fromUser END " + + "FROM PrivateChatMessage m " + + "WHERE m.fromUser.id = :userId OR m.toUser.id = :userId") + List findConversationPartners(@Param("userId") Long userId); + + // 두 사용자 간의 최신 메시지 조회 + @Query("SELECT m FROM PrivateChatMessage m " + + "WHERE (m.fromUser.id = :userId1 AND m.toUser.id = :userId2) " + + "OR (m.fromUser.id = :userId2 AND m.toUser.id = :userId1) " + + "ORDER BY m.createdAt DESC " + + "LIMIT 1") + PrivateChatMessage findLatestMessageBetweenUsers(@Param("userId1") Long userId1, + @Param("userId2") Long userId2); + + // 두 사용자 간의 전체 메시지 수 조회 + @Query("SELECT COUNT(m) FROM PrivateChatMessage m " + + "WHERE (m.fromUser.id = :userId1 AND m.toUser.id = :userId2) " + + "OR (m.fromUser.id = :userId2 AND m.toUser.id = :userId1)") + long countMessagesBetweenUsers(@Param("userId1") Long userId1, @Param("userId2") Long userId2); +} \ No newline at end of file diff --git a/src/main/java/com/back/domain/user/repository/UserRepository.java b/src/main/java/com/back/domain/user/repository/UserRepository.java new file mode 100644 index 00000000..89086370 --- /dev/null +++ b/src/main/java/com/back/domain/user/repository/UserRepository.java @@ -0,0 +1,9 @@ +package com.back.domain.user.repository; + +import com.back.domain.user.entity.User; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +@Repository +public interface UserRepository extends JpaRepository { +} \ No newline at end of file diff --git a/src/main/java/com/back/domain/websocket/controller/ChatApiController.java b/src/main/java/com/back/domain/websocket/controller/ChatApiController.java new file mode 100644 index 00000000..8de652fc --- /dev/null +++ b/src/main/java/com/back/domain/websocket/controller/ChatApiController.java @@ -0,0 +1,69 @@ +package com.back.domain.websocket.controller; + +import com.back.domain.websocket.dto.ChatPageResponse; +import com.back.domain.websocket.service.ChatService; +import com.back.global.common.dto.RsData; +import lombok.RequiredArgsConstructor; +import org.springframework.format.annotation.DateTimeFormat; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +import java.time.LocalDateTime; +import java.util.Map; + +@RestController +@RequestMapping("/api") +@RequiredArgsConstructor +public class ChatApiController { + + private final ChatService chatService; + + // 방 채팅 메시지 조회 (페이징, 특정 시간 이전 메시지) + @GetMapping("/rooms/{roomId}/messages") + public ResponseEntity> getRoomChatMessages( + @PathVariable Long roomId, + @RequestParam(defaultValue = "0") int page, + @RequestParam(defaultValue = "50") int size, + @RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) LocalDateTime before, + @RequestHeader("Authorization") String authorization) { + + // size 최대값 제한 (임시: max 100) + if (size > 100) { + size = 100; + } + + // TODO: JWT 토큰에서 사용자 정보 추출 및 권한 확인 + + ChatPageResponse chatHistory = chatService.getRoomChatHistory(roomId, page, size, before); + + return ResponseEntity + .status(HttpStatus.OK) + .body(RsData.success("채팅 기록 조회 성공", chatHistory)); + } + + // 방 채팅 메시지 삭제 + @DeleteMapping("/rooms/{roomId}/messages/{messageId}") + public ResponseEntity>> deleteRoomMessage( + @PathVariable Long roomId, + @PathVariable Long messageId, + @RequestHeader("Authorization") String authorization) { + + // TODO: JWT 토큰에서 사용자 정보 추출 + + // 임시로 하드코딩 (테스트용) + Long currentUserId = 1L; + + // 메시지 삭제 로직 실행 + chatService.deleteRoomMessage(roomId, messageId, currentUserId); + + Map responseData = Map.of( + "messageId", messageId, + "deletedAt", LocalDateTime.now() + ); + + return ResponseEntity + .status(HttpStatus.OK) + .body(RsData.success("메시지 삭제 성공", responseData)); + } +} \ No newline at end of file diff --git a/src/main/java/com/back/domain/websocket/controller/ChatWebSocketController.java b/src/main/java/com/back/domain/websocket/controller/ChatWebSocketController.java new file mode 100644 index 00000000..09db2357 --- /dev/null +++ b/src/main/java/com/back/domain/websocket/controller/ChatWebSocketController.java @@ -0,0 +1,78 @@ +package com.back.domain.websocket.controller; + +import com.back.domain.studyroom.entity.RoomChatMessage; +import com.back.domain.websocket.dto.ChatMessageDto; +import com.back.domain.websocket.dto.WebSocketErrorResponse; +import com.back.domain.websocket.service.ChatService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.messaging.handler.annotation.DestinationVariable; +import org.springframework.messaging.handler.annotation.MessageMapping; +import org.springframework.messaging.simp.SimpMessageHeaderAccessor; +import org.springframework.messaging.simp.SimpMessagingTemplate; +import org.springframework.stereotype.Controller; + +@Controller +@RequiredArgsConstructor +public class ChatWebSocketController { + + private final ChatService chatService; + private final SimpMessagingTemplate messagingTemplate; + + /** + * 방 채팅 메시지 처리 + * 클라이언트가 /app/chat/room/{roomId}로 메시지 전송 시 호출 + * + * @param roomId 스터디룸 ID + * @param chatMessage 채팅 메시지 (content, messageType, attachmentId) + * @param headerAccessor WebSocket 헤더 정보 + */ + @MessageMapping("/chat/room/{roomId}") + public void handleRoomChat(@DestinationVariable Long roomId, + ChatMessageDto chatMessage, + SimpMessageHeaderAccessor headerAccessor) { + + try { + // TODO: WebSocket 세션에서 사용자 정보 추출 + + // 임시 하드코딩 (나중에 JWT 인증으로 교체) + Long currentUserId = 1L; + String currentUserNickname = "테스트사용자"; + + // 메시지 정보 보완 + chatMessage.setRoomId(roomId); + chatMessage.setUserId(currentUserId); + chatMessage.setNickname(currentUserNickname); + + // DB에 메시지 저장 + RoomChatMessage savedMessage = chatService.saveRoomChatMessage(chatMessage); + + // 저장된 메시지 정보로 응답 DTO 생성 + ChatMessageDto responseMessage = ChatMessageDto.builder() + .messageId(savedMessage.getId()) + .roomId(roomId) + .userId(savedMessage.getUser().getId()) + .nickname(savedMessage.getUser().getNickname()) + .profileImageUrl(savedMessage.getUser().getProfileImageUrl()) + .content(savedMessage.getContent()) + .messageType(chatMessage.getMessageType()) + .attachment(null) // 텍스트 채팅에서는 null + .createdAt(savedMessage.getCreatedAt()) + .build(); + + // 해당 방의 모든 구독자에게 브로드캐스트 + messagingTemplate.convertAndSend("/topic/room/" + roomId, responseMessage); + + } catch (Exception e) { + // 에러 응답을 해당 사용자에게만 전송 + WebSocketErrorResponse errorResponse = WebSocketErrorResponse.create( + "WS_ROOM_NOT_FOUND", + "존재하지 않는 방입니다" + ); + + // 에러를 발생시킨 사용자에게만 전송 + String sessionId = headerAccessor.getSessionId(); + messagingTemplate.convertAndSendToUser(sessionId, "/queue/errors", errorResponse); + } + } +} \ No newline at end of file diff --git a/src/main/java/com/back/domain/websocket/dto/ChatMessageDto.java b/src/main/java/com/back/domain/websocket/dto/ChatMessageDto.java new file mode 100644 index 00000000..20ccf04b --- /dev/null +++ b/src/main/java/com/back/domain/websocket/dto/ChatMessageDto.java @@ -0,0 +1,51 @@ +package com.back.domain.websocket.dto; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class ChatMessageDto { + + // WebSocket Request + private String content; + private String messageType; + private Long attachmentId; + + // WebSocket Response + private Long messageId; + private Long roomId; + private Long userId; + private String nickname; + private String profileImageUrl; + private AttachmentDto attachment; + private LocalDateTime createdAt; + + // 첨부파일 DTO (나중에 파일 기능 구현 시 사용) + @Data + @Builder + @NoArgsConstructor + @AllArgsConstructor + public static class AttachmentDto { + private Long id; + private String originalName; + private String url; + private Long size; + private String mimeType; + } + + // 텍스트 채팅 요청 생성 헬퍼 + public static ChatMessageDto createRequest(String content, String messageType) { + return ChatMessageDto.builder() + .content(content) + .messageType(messageType) + .attachmentId(null) // 텍스트 채팅에서는 null + .build(); + } +} \ No newline at end of file diff --git a/src/main/java/com/back/domain/websocket/dto/ChatPageResponse.java b/src/main/java/com/back/domain/websocket/dto/ChatPageResponse.java new file mode 100644 index 00000000..df3827e2 --- /dev/null +++ b/src/main/java/com/back/domain/websocket/dto/ChatPageResponse.java @@ -0,0 +1,41 @@ +package com.back.domain.websocket.dto; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.util.List; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class ChatPageResponse { + + private List content; + private PageableDto pageable; + + // 페이징 정보 DTO + @Data + @Builder + @NoArgsConstructor + @AllArgsConstructor + public static class PageableDto { + private int page; + private int size; + private boolean hasNext; + } + + // Page -> ChatPageResponse 변환 헬퍼 + public static ChatPageResponse from(org.springframework.data.domain.Page page) { + return ChatPageResponse.builder() + .content(page.getContent()) + .pageable(PageableDto.builder() + .page(page.getNumber()) + .size(page.getSize()) + .hasNext(page.hasNext()) + .build()) + .build(); + } +} \ No newline at end of file diff --git a/src/main/java/com/back/domain/websocket/dto/WebSocketErrorResponse.java b/src/main/java/com/back/domain/websocket/dto/WebSocketErrorResponse.java new file mode 100644 index 00000000..131dce27 --- /dev/null +++ b/src/main/java/com/back/domain/websocket/dto/WebSocketErrorResponse.java @@ -0,0 +1,40 @@ +package com.back.domain.websocket.dto; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class WebSocketErrorResponse { + + private String type = "ERROR"; + private ErrorDto error; + private LocalDateTime timestamp; + + @Data + @Builder + @NoArgsConstructor + @AllArgsConstructor + public static class ErrorDto { + private String code; + private String message; + } + + // 에러 응답 생성 헬퍼 + public static WebSocketErrorResponse create(String code, String message) { + return WebSocketErrorResponse.builder() + .type("ERROR") + .error(ErrorDto.builder() + .code(code) + .message(message) + .build()) + .timestamp(LocalDateTime.now()) + .build(); + } +} \ No newline at end of file diff --git a/src/main/java/com/back/domain/websocket/service/ChatService.java b/src/main/java/com/back/domain/websocket/service/ChatService.java new file mode 100644 index 00000000..7585269c --- /dev/null +++ b/src/main/java/com/back/domain/websocket/service/ChatService.java @@ -0,0 +1,108 @@ +package com.back.domain.websocket.service; + +import com.back.domain.studyroom.entity.Room; +import com.back.domain.studyroom.entity.RoomChatMessage; +import com.back.domain.studyroom.repository.RoomChatMessageRepository; +import com.back.domain.studyroom.repository.RoomRepository; +import com.back.domain.user.entity.User; +import com.back.domain.user.repository.UserRepository; +import com.back.domain.websocket.dto.ChatMessageDto; +import com.back.domain.websocket.dto.ChatPageResponse; +import com.back.global.exception.CustomException; +import com.back.global.exception.ErrorCode; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDateTime; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class ChatService { + + private final RoomChatMessageRepository roomChatMessageRepository; + private final RoomRepository roomRepository; + private final UserRepository userRepository; + + // 방 채팅 메시지 저장 + @Transactional + public RoomChatMessage saveRoomChatMessage(ChatMessageDto chatMessageDto) { + + // 방 존재 여부 확인 + Room room = roomRepository.findById(chatMessageDto.getRoomId()) + .orElseThrow(() -> new CustomException(ErrorCode.ROOM_NOT_FOUND)); + + // 사용자 존재 여부 확인 + User user = userRepository.findById(chatMessageDto.getUserId()) + .orElseThrow(() -> new CustomException(ErrorCode.USER_NOT_FOUND)); + + // RoomChatMessage 엔티티 생성 및 저장 + RoomChatMessage message = new RoomChatMessage(room, user, chatMessageDto.getContent()); + RoomChatMessage savedMessage = roomChatMessageRepository.save(message); + + return savedMessage; + } + + // 방 채팅 기록 조회 + public ChatPageResponse getRoomChatHistory(Long roomId, int page, int size, LocalDateTime before) { + + // 방 존재 여부 확인 + roomRepository.findById(roomId) + .orElseThrow(() -> new CustomException(ErrorCode.ROOM_NOT_FOUND)); + + Pageable pageable = PageRequest.of(page, size); + + // before 파라미터가 있으면 해당 시점 이전 메시지만 조회 + Page messagesPage; + if (before != null) { + // TODO: before 조건 추가한 Repository 메서드 필요 + messagesPage = roomChatMessageRepository.findByRoomIdOrderByCreatedAtDesc(roomId, pageable); + } else { + messagesPage = roomChatMessageRepository.findByRoomIdOrderByCreatedAtDesc(roomId, pageable); + } + + Page dtoPage = messagesPage.map(this::convertToDto); + + return ChatPageResponse.from(dtoPage); + } + + // 메시지 엔티티를 DTO로 변환 + private ChatMessageDto convertToDto(RoomChatMessage message) { + return ChatMessageDto.builder() + .messageId(message.getId()) + .roomId(message.getRoom().getId()) + .userId(message.getUser().getId()) + .nickname(message.getUser().getNickname()) + .profileImageUrl(message.getUser().getProfileImageUrl()) + .content(message.getContent()) + .messageType("TEXT") // 현재는 텍스트만 지원 + .attachment(null) // 텍스트 채팅에서는 null + .createdAt(message.getCreatedAt()) + .build(); + } + + // 방 채팅 메시지 삭제 + @Transactional + public void deleteRoomMessage(Long roomId, Long messageId, Long currentUserId) { + // 메시지 존재 여부 확인 + RoomChatMessage message = roomChatMessageRepository.findById(messageId) + .orElseThrow(() -> new CustomException(ErrorCode.MESSAGE_NOT_FOUND)); + + // 방 ID 검증 + if (!message.getRoom().getId().equals(roomId)) { + throw new CustomException(ErrorCode.BAD_REQUEST); + } + + // 작성자 권한 확인 + if (!message.getUser().getId().equals(currentUserId)) { + throw new CustomException(ErrorCode.MESSAGE_FORBIDDEN); + } + + // 메시지 삭제 + roomChatMessageRepository.delete(message); + } +} \ No newline at end of file diff --git a/src/main/java/com/back/global/exception/ErrorCode.java b/src/main/java/com/back/global/exception/ErrorCode.java index f3b82695..8f61e40e 100644 --- a/src/main/java/com/back/global/exception/ErrorCode.java +++ b/src/main/java/com/back/global/exception/ErrorCode.java @@ -7,6 +7,21 @@ @Getter @AllArgsConstructor public enum ErrorCode { + + // ======================== 사용자 관련 ======================== + USER_NOT_FOUND(HttpStatus.NOT_FOUND, "USER_001", "존재하지 않는 사용자입니다."), + + // ======================== 스터디룸 관련 ======================== + ROOM_NOT_FOUND(HttpStatus.NOT_FOUND, "ROOM_001", "존재하지 않는 방입니다."), + ROOM_FORBIDDEN(HttpStatus.FORBIDDEN, "ROOM_002", "방에 대한 접근 권한이 없습니다."), + + // ======================== 메시지 관련 ======================== + MESSAGE_NOT_FOUND(HttpStatus.NOT_FOUND, "MESSAGE_001", "존재하지 않는 메시지입니다."), + MESSAGE_FORBIDDEN(HttpStatus.FORBIDDEN, "MESSAGE_002", "자신의 메시지만 삭제할 수 있습니다."), + + // ======================== WebSocket 관련 ======================== + WS_ROOM_NOT_FOUND(HttpStatus.NOT_FOUND, "WS_001", "존재하지 않는 방입니다"), + // ======================== 공통 에러 ======================== BAD_REQUEST(HttpStatus.BAD_REQUEST, "COMMON_400", "잘못된 요청입니다."), FORBIDDEN(HttpStatus.FORBIDDEN, "COMMON_403", "접근 권한이 없습니다."), diff --git a/src/main/java/com/back/global/security/SecurityConfig.java b/src/main/java/com/back/global/security/SecurityConfig.java index 908e06be..4fd61f3d 100644 --- a/src/main/java/com/back/global/security/SecurityConfig.java +++ b/src/main/java/com/back/global/security/SecurityConfig.java @@ -40,7 +40,7 @@ public WebMvcConfigurer corsConfigurer() { return new WebMvcConfigurer() { @Override public void addCorsMappings(CorsRegistry registry) { - registry.addMapping("/api/**") + registry.addMapping("/**") .allowedOrigins( "http://localhost:3000" // Next.js 개발 서버 ) diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 9fa0422f..dc5b1754 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -27,4 +27,6 @@ springdoc: logging: level: - org.hibernate.orm.jdbc.bind: trace \ No newline at end of file + org.hibernate.orm.jdbc.bind: trace + org.springframework.web.socket: DEBUG + org.springframework.messaging: DEBUG \ No newline at end of file From ef8eaee89e7f027fb7087078d298596a78e100df Mon Sep 17 00:00:00 2001 From: jueunk617 Date: Tue, 23 Sep 2025 14:30:06 +0900 Subject: [PATCH 2/3] =?UTF-8?q?Chore:=20=EC=9D=98=EC=A1=B4=EC=84=B1=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80=20+=20=EB=A1=9C=EA=B9=85=20=EB=A0=88?= =?UTF-8?q?=EB=B2=A8=20=EC=84=A4=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- build.gradle.kts | 3 +++ src/main/resources/application-dev.yml | 4 +++- 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/build.gradle.kts b/build.gradle.kts index 6c60f4e4..6e85b259 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -51,6 +51,9 @@ dependencies { // Swagger implementation("org.springdoc:springdoc-openapi-starter-webmvc-ui:2.8.13") + // Env + implementation ("io.github.cdimascio:dotenv-java:3.0.0") + // Test testImplementation("org.springframework.boot:spring-boot-starter-test") testImplementation("org.springframework.security:spring-security-test") diff --git a/src/main/resources/application-dev.yml b/src/main/resources/application-dev.yml index bb334003..1c380c17 100644 --- a/src/main/resources/application-dev.yml +++ b/src/main/resources/application-dev.yml @@ -24,4 +24,6 @@ springdoc: logging: level: - org.hibernate.orm.jdbc.bind: trace \ No newline at end of file + org.hibernate.orm.jdbc.bind: trace + org.springframework.web.socket: DEBUG + org.springframework.messaging: DEBUG From 49f160701c89c12a77e054f1634fb4c618c1c889 Mon Sep 17 00:00:00 2001 From: jueunk617 Date: Tue, 23 Sep 2025 15:44:00 +0900 Subject: [PATCH 3/3] =?UTF-8?q?Refactor:=20=ED=8C=A8=ED=82=A4=EC=A7=80=20?= =?UTF-8?q?=EA=B5=AC=EC=A1=B0=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .env.default | 1 - build.gradle.kts | 33 +++++++++++++++---- .../controller/ChatApiController.java | 6 ++-- .../controller/ChatWebSocketController.java | 9 +++-- .../dto/ChatMessageDto.java | 2 +- .../dto/ChatPageResponse.java | 2 +- .../service/ChatService.java | 6 ++-- .../websocket/config/WebSocketConfig.java | 2 +- .../controller/WebSocketTestController.java | 2 +- .../websocket/dto/WebSocketErrorResponse.java | 2 +- src/main/resources/application-dev.yml | 2 ++ 11 files changed, 43 insertions(+), 24 deletions(-) delete mode 100644 .env.default rename src/main/java/com/back/domain/{websocket => chat}/controller/ChatApiController.java (93%) rename src/main/java/com/back/domain/{websocket => chat}/controller/ChatWebSocketController.java (92%) rename src/main/java/com/back/domain/{websocket => chat}/dto/ChatMessageDto.java (97%) rename src/main/java/com/back/domain/{websocket => chat}/dto/ChatPageResponse.java (96%) rename src/main/java/com/back/domain/{websocket => chat}/service/ChatService.java (96%) rename src/main/java/com/back/{domain => global}/websocket/config/WebSocketConfig.java (97%) rename src/main/java/com/back/{domain => global}/websocket/controller/WebSocketTestController.java (98%) rename src/main/java/com/back/{domain => global}/websocket/dto/WebSocketErrorResponse.java (96%) diff --git a/.env.default b/.env.default deleted file mode 100644 index 6a8bfc78..00000000 --- a/.env.default +++ /dev/null @@ -1 +0,0 @@ -JWT_SECRET=your-secret-key \ No newline at end of file diff --git a/build.gradle.kts b/build.gradle.kts index c00f4b47..8029184d 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -25,25 +25,44 @@ repositories { } dependencies { - implementation("org.springframework.boot:spring-boot-starter-data-jpa") + // Spring implementation("org.springframework.boot:spring-boot-starter-web") implementation("org.springframework.boot:spring-boot-starter-websocket") - implementation("org.springframework.boot:spring-boot-starter-security") - testImplementation("org.springframework.security:spring-security-test") - compileOnly("org.projectlombok:lombok") - developmentOnly("org.springframework.boot:spring-boot-devtools") + + // Database & JPA + implementation("org.springframework.boot:spring-boot-starter-data-jpa") runtimeOnly("com.h2database:h2") runtimeOnly("com.mysql:mysql-connector-j") + + // QueryDSL + implementation("com.querydsl:querydsl-jpa:5.0.0:jakarta") + annotationProcessor("com.querydsl:querydsl-apt:5.0.0:jakarta") + annotationProcessor("jakarta.persistence:jakarta.persistence-api") + annotationProcessor("jakarta.annotation:jakarta.annotation-api") + + // Security + implementation("org.springframework.boot:spring-boot-starter-security") + + // Development Tools + compileOnly("org.projectlombok:lombok") annotationProcessor("org.projectlombok:lombok") - testImplementation("org.springframework.boot:spring-boot-starter-test") - testRuntimeOnly("org.junit.platform:junit-platform-launcher") + developmentOnly("org.springframework.boot:spring-boot-devtools") + + // Swagger implementation("org.springdoc:springdoc-openapi-starter-webmvc-ui:2.8.13") + + // Env implementation ("io.github.cdimascio:dotenv-java:3.0.0") // JWT implementation("io.jsonwebtoken:jjwt-api:0.12.6") runtimeOnly("io.jsonwebtoken:jjwt-impl:0.12.6") runtimeOnly("io.jsonwebtoken:jjwt-jackson:0.12.6") + + // Test + testImplementation("org.springframework.boot:spring-boot-starter-test") + testImplementation("org.springframework.security:spring-security-test") + testRuntimeOnly("org.junit.platform:junit-platform-launcher") } tasks.withType { diff --git a/src/main/java/com/back/domain/websocket/controller/ChatApiController.java b/src/main/java/com/back/domain/chat/controller/ChatApiController.java similarity index 93% rename from src/main/java/com/back/domain/websocket/controller/ChatApiController.java rename to src/main/java/com/back/domain/chat/controller/ChatApiController.java index 8de652fc..48185403 100644 --- a/src/main/java/com/back/domain/websocket/controller/ChatApiController.java +++ b/src/main/java/com/back/domain/chat/controller/ChatApiController.java @@ -1,7 +1,7 @@ -package com.back.domain.websocket.controller; +package com.back.domain.chat.controller; -import com.back.domain.websocket.dto.ChatPageResponse; -import com.back.domain.websocket.service.ChatService; +import com.back.domain.chat.dto.ChatPageResponse; +import com.back.domain.chat.service.ChatService; import com.back.global.common.dto.RsData; import lombok.RequiredArgsConstructor; import org.springframework.format.annotation.DateTimeFormat; diff --git a/src/main/java/com/back/domain/websocket/controller/ChatWebSocketController.java b/src/main/java/com/back/domain/chat/controller/ChatWebSocketController.java similarity index 92% rename from src/main/java/com/back/domain/websocket/controller/ChatWebSocketController.java rename to src/main/java/com/back/domain/chat/controller/ChatWebSocketController.java index 09db2357..a21e40ec 100644 --- a/src/main/java/com/back/domain/websocket/controller/ChatWebSocketController.java +++ b/src/main/java/com/back/domain/chat/controller/ChatWebSocketController.java @@ -1,11 +1,10 @@ -package com.back.domain.websocket.controller; +package com.back.domain.chat.controller; import com.back.domain.studyroom.entity.RoomChatMessage; -import com.back.domain.websocket.dto.ChatMessageDto; -import com.back.domain.websocket.dto.WebSocketErrorResponse; -import com.back.domain.websocket.service.ChatService; +import com.back.domain.chat.dto.ChatMessageDto; +import com.back.global.websocket.dto.WebSocketErrorResponse; +import com.back.domain.chat.service.ChatService; import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; import org.springframework.messaging.handler.annotation.DestinationVariable; import org.springframework.messaging.handler.annotation.MessageMapping; import org.springframework.messaging.simp.SimpMessageHeaderAccessor; diff --git a/src/main/java/com/back/domain/websocket/dto/ChatMessageDto.java b/src/main/java/com/back/domain/chat/dto/ChatMessageDto.java similarity index 97% rename from src/main/java/com/back/domain/websocket/dto/ChatMessageDto.java rename to src/main/java/com/back/domain/chat/dto/ChatMessageDto.java index 20ccf04b..a8e6de43 100644 --- a/src/main/java/com/back/domain/websocket/dto/ChatMessageDto.java +++ b/src/main/java/com/back/domain/chat/dto/ChatMessageDto.java @@ -1,4 +1,4 @@ -package com.back.domain.websocket.dto; +package com.back.domain.chat.dto; import lombok.AllArgsConstructor; import lombok.Builder; diff --git a/src/main/java/com/back/domain/websocket/dto/ChatPageResponse.java b/src/main/java/com/back/domain/chat/dto/ChatPageResponse.java similarity index 96% rename from src/main/java/com/back/domain/websocket/dto/ChatPageResponse.java rename to src/main/java/com/back/domain/chat/dto/ChatPageResponse.java index df3827e2..989454ff 100644 --- a/src/main/java/com/back/domain/websocket/dto/ChatPageResponse.java +++ b/src/main/java/com/back/domain/chat/dto/ChatPageResponse.java @@ -1,4 +1,4 @@ -package com.back.domain.websocket.dto; +package com.back.domain.chat.dto; import lombok.AllArgsConstructor; import lombok.Builder; diff --git a/src/main/java/com/back/domain/websocket/service/ChatService.java b/src/main/java/com/back/domain/chat/service/ChatService.java similarity index 96% rename from src/main/java/com/back/domain/websocket/service/ChatService.java rename to src/main/java/com/back/domain/chat/service/ChatService.java index 7585269c..aaeec3e0 100644 --- a/src/main/java/com/back/domain/websocket/service/ChatService.java +++ b/src/main/java/com/back/domain/chat/service/ChatService.java @@ -1,4 +1,4 @@ -package com.back.domain.websocket.service; +package com.back.domain.chat.service; import com.back.domain.studyroom.entity.Room; import com.back.domain.studyroom.entity.RoomChatMessage; @@ -6,8 +6,8 @@ import com.back.domain.studyroom.repository.RoomRepository; import com.back.domain.user.entity.User; import com.back.domain.user.repository.UserRepository; -import com.back.domain.websocket.dto.ChatMessageDto; -import com.back.domain.websocket.dto.ChatPageResponse; +import com.back.domain.chat.dto.ChatMessageDto; +import com.back.domain.chat.dto.ChatPageResponse; import com.back.global.exception.CustomException; import com.back.global.exception.ErrorCode; import lombok.RequiredArgsConstructor; diff --git a/src/main/java/com/back/domain/websocket/config/WebSocketConfig.java b/src/main/java/com/back/global/websocket/config/WebSocketConfig.java similarity index 97% rename from src/main/java/com/back/domain/websocket/config/WebSocketConfig.java rename to src/main/java/com/back/global/websocket/config/WebSocketConfig.java index af0cf877..9f9f9d60 100644 --- a/src/main/java/com/back/domain/websocket/config/WebSocketConfig.java +++ b/src/main/java/com/back/global/websocket/config/WebSocketConfig.java @@ -1,4 +1,4 @@ -package com.back.domain.websocket.config; +package com.back.global.websocket.config; import org.springframework.context.annotation.Configuration; import org.springframework.messaging.simp.config.MessageBrokerRegistry; diff --git a/src/main/java/com/back/domain/websocket/controller/WebSocketTestController.java b/src/main/java/com/back/global/websocket/controller/WebSocketTestController.java similarity index 98% rename from src/main/java/com/back/domain/websocket/controller/WebSocketTestController.java rename to src/main/java/com/back/global/websocket/controller/WebSocketTestController.java index 4e1d8539..7b14b023 100644 --- a/src/main/java/com/back/domain/websocket/controller/WebSocketTestController.java +++ b/src/main/java/com/back/global/websocket/controller/WebSocketTestController.java @@ -1,4 +1,4 @@ -package com.back.domain.websocket.controller; +package com.back.global.websocket.controller; import com.back.global.common.dto.RsData; import lombok.extern.slf4j.Slf4j; diff --git a/src/main/java/com/back/domain/websocket/dto/WebSocketErrorResponse.java b/src/main/java/com/back/global/websocket/dto/WebSocketErrorResponse.java similarity index 96% rename from src/main/java/com/back/domain/websocket/dto/WebSocketErrorResponse.java rename to src/main/java/com/back/global/websocket/dto/WebSocketErrorResponse.java index 131dce27..581186ab 100644 --- a/src/main/java/com/back/domain/websocket/dto/WebSocketErrorResponse.java +++ b/src/main/java/com/back/global/websocket/dto/WebSocketErrorResponse.java @@ -1,4 +1,4 @@ -package com.back.domain.websocket.dto; +package com.back.global.websocket.dto; import lombok.AllArgsConstructor; import lombok.Builder; diff --git a/src/main/resources/application-dev.yml b/src/main/resources/application-dev.yml index 43b932a8..f79524c8 100644 --- a/src/main/resources/application-dev.yml +++ b/src/main/resources/application-dev.yml @@ -25,6 +25,8 @@ springdoc: logging: level: org.hibernate.orm.jdbc.bind: trace + org.springframework.web.socket: DEBUG + org.springframework.messaging: DEBUG jwt: secret: ${JWT_SECRET:test-jwt-secret-key-12345678901234567890} # 운영 시에는 반드시 환경 변수로 설정할 것