Skip to content
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
Original file line number Diff line number Diff line change
@@ -1,30 +1,36 @@
package com.back.domain.chat.room.controller;

import com.back.domain.chat.room.dto.ChatClearRequest;
import com.back.domain.chat.room.dto.ChatClearResponse;
import com.back.domain.chat.room.dto.ChatClearedNotification;
import com.back.domain.chat.room.dto.RoomChatPageResponse;
import com.back.domain.chat.room.service.RoomChatService;
import com.back.global.common.dto.RsData;
import com.back.global.security.user.CustomUserDetails;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.format.annotation.DateTimeFormat;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.messaging.simp.SimpMessagingTemplate;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.*;

import java.time.LocalDateTime;

@RestController
@RequiredArgsConstructor
@RequestMapping("/api")
@Tag(name = "RoomChat API", description = "스터디룸 채팅 메시지 조회 관련 API")
@RequestMapping("/api/rooms/{roomId}/messages")
@Tag(name = "RoomChat API", description = "스터디룸 채팅 메시지 관련 API")
public class RoomChatApiController {

private final RoomChatService roomChatService;
private final SimpMessagingTemplate messagingTemplate;

// 방 채팅 메시지 조회 (페이징, 특정 시간 이전 메시지)
@GetMapping("/rooms/{roomId}/messages")
@GetMapping
@Operation(summary = "스터디룸 채팅방 메시지 목록 조회", description = "특정 채팅방의 이전 메시지 기록을 페이징하여 조회합니다.")
public ResponseEntity<RsData<RoomChatPageResponse>> getRoomChatMessages(
@PathVariable Long roomId,
Expand All @@ -40,4 +46,48 @@ public ResponseEntity<RsData<RoomChatPageResponse>> getRoomChatMessages(
.body(RsData.success("채팅 기록 조회 성공", chatHistory));
}

// 방 채팅 메시지 일괄 삭제 (방장, 부방장 권한)
@DeleteMapping
@Operation(
summary = "스터디룸 채팅 일괄 삭제",
description = "방장 또는 부방장이 해당 방의 모든 채팅 메시지를 삭제합니다. 실행 후 실시간으로 모든 방 멤버에게 알림이 전송됩니다."
)
public ResponseEntity<RsData<ChatClearResponse>> clearRoomMessages(
@PathVariable Long roomId,
@Valid @RequestBody ChatClearRequest request,
@AuthenticationPrincipal CustomUserDetails userDetails) {

// 삭제 전 메시지 수 조회
int messageCount = roomChatService.getRoomChatCount(roomId);

// 채팅 일괄 삭제 실행
ChatClearedNotification.ClearedByDto clearedByInfo =
roomChatService.clearRoomChat(roomId, userDetails.getUserId());

// 응답 데이터 생성
ChatClearResponse responseData = ChatClearResponse.create(
roomId,
messageCount,
clearedByInfo.userId(),
clearedByInfo.nickname(),
clearedByInfo.role()
);

// WebSocket을 통해 실시간 알림 전송
ChatClearedNotification notification = ChatClearedNotification.create(
roomId,
messageCount,
clearedByInfo.userId(),
clearedByInfo.nickname(),
clearedByInfo.profileImageUrl(),
clearedByInfo.role()
);

messagingTemplate.convertAndSend("/topic/room/" + roomId + "/chat-cleared", notification);

return ResponseEntity
.status(HttpStatus.OK)
.body(RsData.success("채팅 메시지 일괄 삭제 완료", responseData));
}

}
Original file line number Diff line number Diff line change
@@ -1,37 +1,39 @@
package com.back.domain.chat.room.controller;

import com.back.domain.chat.room.dto.ChatClearRequest;
import com.back.domain.chat.room.dto.ChatClearedNotification;
import com.back.domain.studyroom.entity.RoomChatMessage;
import com.back.domain.chat.room.dto.RoomChatMessageDto;
import com.back.global.exception.CustomException;
import com.back.global.security.user.CustomUserDetails;
import com.back.global.websocket.dto.WebSocketErrorResponse;
import com.back.domain.chat.room.service.RoomChatService;
import com.back.global.websocket.util.WebSocketErrorHelper;
import io.swagger.v3.oas.annotations.tags.Tag;
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.handler.annotation.Payload;
import org.springframework.messaging.simp.SimpMessageHeaderAccessor;
import org.springframework.messaging.simp.SimpMessagingTemplate;
import org.springframework.security.core.Authentication;
import org.springframework.stereotype.Controller;

import java.security.Principal;

@Slf4j
@Controller
@RequiredArgsConstructor
@Tag(name = "RoomChat WebSocket", description = "STOMP를 이용한 실시간 채팅 WebSocket 컨트롤러 (Swagger에서 직접 테스트 불가)")
public class RoomChatWebSocketController {

private final RoomChatService roomChatService;
private final SimpMessagingTemplate messagingTemplate;
private final WebSocketErrorHelper errorHelper;

/**
* 방 채팅 메시지 처리
* 클라이언트가 /app/chat/room/{roomId}로 메시지 전송 시 호출
*
* @param roomId 스터디룸 ID
* @param chatMessage 채팅 메시지 (content, messageType, attachmentId)
* @param headerAccessor WebSocket 헤더 정보
* @param principal 인증된 사용자 정보
*/
@MessageMapping("/chat/room/{roomId}")
public void handleRoomChat(@DestinationVariable Long roomId,
Expand All @@ -43,7 +45,7 @@ public void handleRoomChat(@DestinationVariable Long roomId,
// WebSocket에서 인증된 사용자 정보 추출
CustomUserDetails userDetails = extractUserDetails(principal);
if (userDetails == null) {
sendErrorToUser(headerAccessor.getSessionId(), "WS_UNAUTHORIZED", "인증이 필요합니다");
errorHelper.sendUnauthorizedError(headerAccessor.getSessionId());
return;
}

Expand Down Expand Up @@ -75,16 +77,74 @@ public void handleRoomChat(@DestinationVariable Long roomId,
// 해당 방의 모든 구독자에게 브로드캐스트
messagingTemplate.convertAndSend("/topic/room/" + roomId, responseMessage);

} catch (CustomException e) {
log.warn("채팅 메시지 처리 실패 - roomId: {}, error: {}", roomId, e.getMessage());
errorHelper.sendCustomExceptionToUser(headerAccessor.getSessionId(), e);

} catch (Exception e) {
// 에러 응답을 해당 사용자에게만 전송
WebSocketErrorResponse errorResponse = WebSocketErrorResponse.create(
"WS_ROOM_NOT_FOUND",
"존재하지 않는 방입니다"
log.error("채팅 메시지 처리 중 예상치 못한 오류 발생 - roomId: {}", roomId, e);
errorHelper.sendGenericErrorToUser(headerAccessor.getSessionId(), e, "메시지 전송 중 오류가 발생했습니다");
}
}

/**
* 스터디룸 채팅 일괄 삭제 처리
* 클라이언트가 /app/chat/room/{roomId}/clear로 삭제 요청 시 호출
*/
@MessageMapping("/chat/room/{roomId}/clear")
public void clearRoomChat(@DestinationVariable Long roomId,
@Payload ChatClearRequest request,
SimpMessageHeaderAccessor headerAccessor,
Principal principal) {

try {
log.info("WebSocket 채팅 일괄 삭제 요청 - roomId: {}", roomId);

// 사용자 인증 확인
CustomUserDetails userDetails = extractUserDetails(principal);
if (userDetails == null) {
errorHelper.sendUnauthorizedError(headerAccessor.getSessionId());
return;
}

// 삭제 확인 메시지 검증
if (!request.isValidConfirmMessage()) {
errorHelper.sendErrorToUser(headerAccessor.getSessionId(), "WS_011",
"삭제 확인 메시지가 일치하지 않습니다");
return;
}

Long currentUserId = userDetails.getUserId();

// 삭제 전에 메시지 수 먼저 조회 (삭제 후에는 0이 되므로)
int deletedCount = roomChatService.getRoomChatCount(roomId);

// 채팅 일괄 삭제 실행
ChatClearedNotification.ClearedByDto clearedByInfo = roomChatService.clearRoomChat(roomId, currentUserId);

// 알림 생성
ChatClearedNotification notification = ChatClearedNotification.create(
roomId,
deletedCount, // 삭제 전에 조회한 수 사용
clearedByInfo.userId(),
clearedByInfo.nickname(),
clearedByInfo.profileImageUrl(),
clearedByInfo.role()
);

// 에러를 발생시킨 사용자에게만 전송
String sessionId = headerAccessor.getSessionId();
messagingTemplate.convertAndSendToUser(sessionId, "/queue/errors", errorResponse);
// 해당 방의 모든 구독자에게 브로드캐스트
messagingTemplate.convertAndSend("/topic/room/" + roomId + "/chat-cleared", notification);

log.info("WebSocket 채팅 일괄 삭제 완료 - roomId: {}, deletedCount: {}, userId: {}",
roomId, deletedCount, currentUserId);

} catch (CustomException e) {
log.warn("채팅 삭제 실패 - roomId: {}, error: {}", roomId, e.getMessage());
errorHelper.sendCustomExceptionToUser(headerAccessor.getSessionId(), e);

} catch (Exception e) {
log.error("채팅 일괄 삭제 중 예상치 못한 오류 발생 - roomId: {}", roomId, e);
errorHelper.sendGenericErrorToUser(headerAccessor.getSessionId(), e, "채팅 삭제 중 오류가 발생했습니다");
}
}

Expand All @@ -99,10 +159,4 @@ private CustomUserDetails extractUserDetails(Principal principal) {
return null;
}

// 특정 사용자에게 에러 메시지 전송
private void sendErrorToUser(String sessionId, String errorCode, String errorMessage) {
WebSocketErrorResponse errorResponse = WebSocketErrorResponse.create(errorCode, errorMessage);
messagingTemplate.convertAndSendToUser(sessionId, "/queue/errors", errorResponse);
}

}
17 changes: 17 additions & 0 deletions src/main/java/com/back/domain/chat/room/dto/ChatClearRequest.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
package com.back.domain.chat.room.dto;

import jakarta.validation.constraints.NotBlank;

public record ChatClearRequest(
@NotBlank(message = "삭제 확인 메시지는 필수입니다")
String confirmMessage
) {

// 확인 메시지 상수
public static final String REQUIRED_CONFIRM_MESSAGE = "모든 채팅을 삭제하겠습니다";

// 확인 메시지인지 검증
public boolean isValidConfirmMessage() {
return REQUIRED_CONFIRM_MESSAGE.equals(confirmMessage);
}
}
26 changes: 26 additions & 0 deletions src/main/java/com/back/domain/chat/room/dto/ChatClearResponse.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
package com.back.domain.chat.room.dto;

public record ChatClearResponse(
Long roomId,
Integer deletedCount,
java.time.LocalDateTime clearedAt,
ClearedByDto clearedBy
) {

public record ClearedByDto(
Long userId,
String nickname,
String role
) {}

// 성공 응답 생성 헬퍼
public static ChatClearResponse create(Long roomId, int deletedCount,
Long userId, String nickname, String role) {
return new ChatClearResponse(
roomId,
deletedCount,
java.time.LocalDateTime.now(),
new ClearedByDto(userId, nickname, role)
);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
package com.back.domain.chat.room.dto;

/**
* WebSocket 브로드캐스트용 채팅 삭제 알림 DTO
*/
public record ChatClearedNotification(
String type,
Long roomId,
java.time.LocalDateTime clearedAt,
ClearedByDto clearedBy,
Integer deletedCount,
String message
) {

public record ClearedByDto(
Long userId,
String nickname,
String profileImageUrl,
String role
) {}

// 알림 생성 헬퍼
public static ChatClearedNotification create(Long roomId, int deletedCount,
Long userId, String nickname, String profileImageUrl, String role) {
ClearedByDto clearedBy = new ClearedByDto(userId, nickname, profileImageUrl, role);
String message = nickname + "님이 모든 채팅을 삭제했습니다.";

return new ChatClearedNotification(
"CHAT_CLEARED",
roomId,
java.time.LocalDateTime.now(),
clearedBy,
deletedCount,
message
);
}
}
Loading