diff --git a/build.gradle.kts b/build.gradle.kts index 7b0f4824..2845fbac 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -28,6 +28,7 @@ dependencies { // Spring implementation("org.springframework.boot:spring-boot-starter-web") implementation("org.springframework.boot:spring-boot-starter-websocket") + implementation("org.springframework.boot:spring-boot-starter-validation") // Database & JPA implementation("org.springframework.boot:spring-boot-starter-data-jpa") diff --git a/src/main/java/com/back/domain/studyroom/config/StudyRoomProperties.java b/src/main/java/com/back/domain/studyroom/config/StudyRoomProperties.java new file mode 100644 index 00000000..915b33fb --- /dev/null +++ b/src/main/java/com/back/domain/studyroom/config/StudyRoomProperties.java @@ -0,0 +1,31 @@ +package com.back.domain.studyroom.config; + +import lombok.Getter; +import lombok.Setter; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.stereotype.Component; + +@Getter +@Setter +@Component +@ConfigurationProperties(prefix = "studyroom") +public class StudyRoomProperties { + + private Heartbeat heartbeat = new Heartbeat(); + private Default defaultSettings = new Default(); + + @Getter + @Setter + public static class Heartbeat { + private int timeoutMinutes = 5; + } + + @Getter + @Setter + public static class Default { + private int maxParticipants = 10; + private boolean allowCamera = true; + private boolean allowAudio = true; + private boolean allowScreenShare = true; + } +} diff --git a/src/main/java/com/back/domain/studyroom/controller/RoomController.java b/src/main/java/com/back/domain/studyroom/controller/RoomController.java index 6a5729d3..f775a04c 100644 --- a/src/main/java/com/back/domain/studyroom/controller/RoomController.java +++ b/src/main/java/com/back/domain/studyroom/controller/RoomController.java @@ -1,9 +1,18 @@ package com.back.domain.studyroom.controller; +import com.back.domain.studyroom.dto.*; import com.back.domain.studyroom.entity.Room; import com.back.domain.studyroom.entity.RoomMember; import com.back.domain.studyroom.service.RoomService; import com.back.global.common.dto.RsData; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; import org.springframework.data.domain.Page; import org.springframework.data.domain.PageRequest; @@ -18,95 +27,95 @@ import java.util.stream.Collectors; /** - * 현재 잡아 놓은 API 목록 - - 방 CRUD - - 방 입장/퇴장 처리 - - 멤버 관리 (목록 조회, 권한 변경, 추방) - - 방 목록 조회 (공개방, 인기방, 내 참여방) - - 인증: - - 모든 API는 Authorization 헤더 필요 (JWT 토큰) - - 현재는 임시로 하드코딩된 사용자 ID 사용, 예원님이 잡아준 임시 jwt 토큰과 연결 예정 + * - 모든 API는 Authorization 헤더 필요 (JWT 토큰) + * - 현재는 임시로 하드코딩된 사용자 ID 사용 + * - JWT 연동 시 @CurrentUser 애노테이션으로 교체 예정 */ - - @RestController @RequestMapping("/api/rooms") @RequiredArgsConstructor +@Tag(name = "Room API", description = "스터디 룸 관련 API") public class RoomController { private final RoomService roomService; - // 방 생성 + @PostMapping - public ResponseEntity>> createRoom( - @RequestBody Map request, + @Operation( + summary = "방 생성", + description = "새로운 스터디 룸을 생성합니다. 방 생성자는 자동으로 방장(HOST)이 됩니다." + ) + @ApiResponses({ + @ApiResponse(responseCode = "201", description = "방 생성 성공"), + @ApiResponse(responseCode = "400", description = "잘못된 요청 데이터"), + @ApiResponse(responseCode = "401", description = "인증 실패") + }) + public ResponseEntity> createRoom( + @Valid @RequestBody CreateRoomRequest request, @RequestHeader("Authorization") String authorization) { - Long currentUserId = 1L; // 임시 하드코딩 데이터 + Long currentUserId = 1L; // 임시 하드코딩 - JWT 연동 시 @CurrentUser로 교체 Room room = roomService.createRoom( - (String) request.get("title"), - (String) request.get("description"), - (Boolean) request.getOrDefault("isPrivate", false), - (String) request.get("password"), - (Integer) request.getOrDefault("maxParticipants", 10), + request.getTitle(), + request.getDescription(), + request.getIsPrivate() != null ? request.getIsPrivate() : false, + request.getPassword(), + request.getMaxParticipants() != null ? request.getMaxParticipants() : 10, currentUserId ); - Map response = Map.of( - "roomId", room.getId(), - "title", room.getTitle(), - "description", room.getDescription(), - "isPrivate", room.isPrivate(), - "maxParticipants", room.getMaxParticipants(), - "currentParticipants", room.getCurrentParticipants(), - "status", room.getStatus(), - "createdAt", room.getCreatedAt() - ); + + RoomResponse response = RoomResponse.from(room); + return ResponseEntity .status(HttpStatus.CREATED) .body(RsData.success("방 생성 완료", response)); } - /** - 방 입장 - 입장 과정: - - 공개 방: 바로 입장 가능 - - 비공개 방: password 필드에 비밀번호 전송 필요 - -- password: 비공개 방의 비밀번호 - - 멤버십 정보 (방 ID, 사용자 ID, 역할, 입장 시간) - */ - @PostMapping("/api/rooms/{roomId}/{id}/join") - public ResponseEntity>> joinRoom( - @PathVariable Long roomId, - @RequestBody(required = false) Map request, + @PostMapping("/{roomId}/join") + @Operation( + summary = "방 입장", + description = "특정 스터디 룸에 입장합니다. 공개방은 바로 입장 가능하며, 비공개방은 비밀번호가 필요합니다." + ) + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "방 입장 성공"), + @ApiResponse(responseCode = "400", description = "방이 가득 찼거나 비밀번호가 틀림"), + @ApiResponse(responseCode = "404", description = "존재하지 않는 방"), + @ApiResponse(responseCode = "401", description = "인증 실패") + }) + public ResponseEntity> joinRoom( + @Parameter(description = "방 ID", required = true) @PathVariable Long roomId, + @RequestBody(required = false) JoinRoomRequest request, @RequestHeader("Authorization") String authorization) { - Long currentUserId = 1L; // 임시 하드코딩 데이터 + Long currentUserId = 1L; // 임시 하드코딩 String password = null; if (request != null) { - password = (String) request.get("password"); + password = request.getPassword(); } RoomMember member = roomService.joinRoom(roomId, password, currentUserId); + JoinRoomResponse response = JoinRoomResponse.from(member); - Map response = Map.of( - "roomId", member.getRoom().getId(), - "userId", member.getUser().getId(), - "role", member.getRole(), - "joinedAt", member.getJoinedAt() - ); return ResponseEntity .status(HttpStatus.OK) .body(RsData.success("방 입장 완료", response)); } - // 방 나가기 API - @PostMapping("/api/rooms/{roomId}/{id}/leave") + @PostMapping("/{roomId}/leave") + @Operation( + summary = "방 나가기", + description = "특정 스터디 룸에서 퇴장합니다." + ) + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "방 퇴장 성공"), + @ApiResponse(responseCode = "404", description = "존재하지 않는 방 또는 멤버가 아님"), + @ApiResponse(responseCode = "401", description = "인증 실패") + }) public ResponseEntity> leaveRoom( - @PathVariable Long roomId, + @Parameter(description = "방 ID", required = true) @PathVariable Long roomId, @RequestHeader("Authorization") String authorization) { - Long currentUserId = 1L; // 임시 하드코딩 데이터 + Long currentUserId = 1L; // 임시 하드코딩 roomService.leaveRoom(roomId, currentUserId); @@ -115,38 +124,24 @@ public ResponseEntity> leaveRoom( .body(RsData.success("방 퇴장 완료", null)); } - /** - * 공개 방 목록 조회 API - - 공개 방만 조회 (isPrivate = false) - - 입장 가능한 방만 조회 (활성화 + 정원 미초과) - - 최신 생성 순으로 정렬 - - * 현재 쿼리 파라미터: - - page: 페이지 번호 (기본값: 0) - - size: 페이지 크기 (기본값: 20) - - search: 검색어 (향후 구현 예정) - */ @GetMapping + @Operation( + summary = "공개 방 목록 조회", + description = "입장 가능한 공개 스터디 룸 목록을 페이징하여 조회합니다. 최신 생성 순으로 정렬됩니다." + ) + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "조회 성공"), + @ApiResponse(responseCode = "401", description = "인증 실패") + }) public ResponseEntity>> getRooms( - @RequestParam(defaultValue = "0") int page, - @RequestParam(defaultValue = "20") int size) { + @Parameter(description = "페이지 번호 (0부터 시작)") @RequestParam(defaultValue = "0") int page, + @Parameter(description = "페이지 크기") @RequestParam(defaultValue = "20") int size) { Pageable pageable = PageRequest.of(page, size); Page rooms = roomService.getJoinableRooms(pageable); - List> roomList = rooms.getContent().stream() - .map(room -> { - Map roomMap = new HashMap<>(); - roomMap.put("roomId", room.getId()); - roomMap.put("title", room.getTitle()); - roomMap.put("description", room.getDescription() != null ? room.getDescription() : ""); - roomMap.put("currentParticipants", room.getCurrentParticipants()); - roomMap.put("maxParticipants", room.getMaxParticipants()); - roomMap.put("status", room.getStatus()); - roomMap.put("createdBy", room.getCreatedBy().getNickname()); - roomMap.put("createdAt", room.getCreatedAt()); - return roomMap; - }) + List roomList = rooms.getContent().stream() + .map(RoomResponse::from) .collect(Collectors.toList()); Map response = new HashMap<>(); @@ -162,21 +157,19 @@ public ResponseEntity>> getRooms( .body(RsData.success("방 목록 조회 완료", response)); } - /** - * 방 상세 정보 조회 API - - * 조회 정보: - - 방 기본 정보 (제목, 설명, 설정 등) - - 현재 온라인 멤버 목록 (닉네임, 역할, 상태) - - 방 설정 (카메라, 오디오, 화면공유 허용 여부) - - * 접근 제한: - - 공개 방: 누구나 조회 가능 - - 비공개 방: 해당 방 멤버만 조회 가능 - */ - @GetMapping("/api/rooms/{roomId}") - public ResponseEntity>> getRoomDetail( - @PathVariable Long roomId, + @GetMapping("/{roomId}") + @Operation( + summary = "방 상세 정보 조회", + description = "특정 방의 상세 정보와 현재 온라인 멤버 목록을 조회합니다. 비공개 방은 멤버만 조회 가능합니다." + ) + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "조회 성공"), + @ApiResponse(responseCode = "403", description = "비공개 방에 대한 접근 권한 없음"), + @ApiResponse(responseCode = "404", description = "존재하지 않는 방"), + @ApiResponse(responseCode = "401", description = "인증 실패") + }) + public ResponseEntity> getRoomDetail( + @Parameter(description = "방 ID", required = true) @PathVariable Long roomId, @RequestHeader("Authorization") String authorization) { Long currentUserId = 1L; // 임시 하드코딩 @@ -184,61 +177,38 @@ public ResponseEntity>> getRoomDetail( Room room = roomService.getRoomDetail(roomId, currentUserId); List members = roomService.getRoomMembers(roomId, currentUserId); - List> memberList = members.stream() - .map(member -> { - Map memberMap = new HashMap<>(); - memberMap.put("userId", member.getUser().getId()); - memberMap.put("nickname", member.getUser().getNickname()); - memberMap.put("role", member.getRole()); - memberMap.put("isOnline", member.isOnline()); - memberMap.put("joinedAt", member.getJoinedAt()); - memberMap.put("lastActiveAt", member.getLastActiveAt() != null ? member.getLastActiveAt() : member.getJoinedAt()); - return memberMap; - }) + List memberResponses = members.stream() + .map(RoomMemberResponse::from) .collect(Collectors.toList()); - Map response = new HashMap<>(); - response.put("roomId", room.getId()); - response.put("title", room.getTitle()); - response.put("description", room.getDescription() != null ? room.getDescription() : ""); - response.put("isPrivate", room.isPrivate()); - response.put("maxParticipants", room.getMaxParticipants()); - response.put("currentParticipants", room.getCurrentParticipants()); - response.put("status", room.getStatus()); - response.put("allowCamera", room.isAllowCamera()); - response.put("allowAudio", room.isAllowAudio()); - response.put("allowScreenShare", room.isAllowScreenShare()); - response.put("createdBy", room.getCreatedBy().getNickname()); - response.put("createdAt", room.getCreatedAt()); - response.put("members", memberList); + RoomDetailResponse response = RoomDetailResponse.of(room, memberResponses); return ResponseEntity .status(HttpStatus.OK) .body(RsData.success("방 상세 정보 조회 완료", response)); } - //사용자 참여 방 목록 조회 API - @GetMapping("/api/rooms/{roomId}/{id}/participants") - public ResponseEntity>>> getMyRooms( + @GetMapping("/my") + @Operation( + summary = "내 참여 방 목록 조회", + description = "현재 사용자가 참여 중인 방(멤버 이상) 목록을 조회합니다." + ) + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "조회 성공"), + @ApiResponse(responseCode = "401", description = "인증 실패") + }) + public ResponseEntity>> getMyRooms( @RequestHeader("Authorization") String authorization) { Long currentUserId = 1L; // 임시 하드코딩 List rooms = roomService.getUserRooms(currentUserId); - List> roomList = rooms.stream() - .map(room -> { - Map roomMap = new HashMap<>(); - roomMap.put("roomId", room.getId()); - roomMap.put("title", room.getTitle()); - roomMap.put("description", room.getDescription() != null ? room.getDescription() : ""); - roomMap.put("currentParticipants", room.getCurrentParticipants()); - roomMap.put("maxParticipants", room.getMaxParticipants()); - roomMap.put("status", room.getStatus()); - roomMap.put("myRole", roomService.getUserRoomRole(room.getId(), currentUserId)); - roomMap.put("createdAt", room.getCreatedAt()); - return roomMap; - }) + List roomList = rooms.stream() + .map(room -> MyRoomResponse.of( + room, + roomService.getUserRoomRole(room.getId(), currentUserId) + )) .collect(Collectors.toList()); return ResponseEntity @@ -246,29 +216,33 @@ public ResponseEntity>>> getMyRooms( .body(RsData.success("내 방 목록 조회 완료", roomList)); } - /** - * 방 설정 수정 API - 권한: 방장만 수정 가능 - - * 제약 사항: - - 최대 참가자 수는 현재 참가자 수보다 작게 설정할 수 없음 - */ - @PutMapping("/api/rooms/{roomId}") + @PutMapping("/{roomId}") + @Operation( + summary = "방 설정 수정", + description = "방의 제목, 설명, 정원, RTC 설정 등을 수정합니다. 방장만 수정 가능합니다." + ) + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "수정 성공"), + @ApiResponse(responseCode = "400", description = "잘못된 요청 (현재 참가자보다 작은 정원 등)"), + @ApiResponse(responseCode = "403", description = "방장 권한 없음"), + @ApiResponse(responseCode = "404", description = "존재하지 않는 방"), + @ApiResponse(responseCode = "401", description = "인증 실패") + }) public ResponseEntity> updateRoom( - @PathVariable Long roomId, - @RequestBody Map request, + @Parameter(description = "방 ID", required = true) @PathVariable Long roomId, + @Valid @RequestBody UpdateRoomSettingsRequest request, @RequestHeader("Authorization") String authorization) { Long currentUserId = 1L; // 임시 하드코딩 roomService.updateRoomSettings( roomId, - (String) request.get("title"), - (String) request.get("description"), - (Integer) request.get("maxParticipants"), - (Boolean) request.getOrDefault("allowCamera", true), - (Boolean) request.getOrDefault("allowAudio", true), - (Boolean) request.getOrDefault("allowScreenShare", true), + request.getTitle(), + request.getDescription(), + request.getMaxParticipants(), + request.getAllowCamera() != null ? request.getAllowCamera() : true, + request.getAllowAudio() != null ? request.getAllowAudio() : true, + request.getAllowScreenShare() != null ? request.getAllowScreenShare() : true, currentUserId ); @@ -277,18 +251,19 @@ public ResponseEntity> updateRoom( .body(RsData.success("방 설정 변경 완료", null)); } - /** - * 방 종료 API - 권한: 방장만 종료 가능 - - * 종료 처리: - - 방 상태를 TERMINATED로 변경 - - 모든 멤버를 강제 오프라인 처리 (강퇴처리 식으로 진행 해야 할 지, 로직 처리 필요) - - 더 이상 입장 불가능한 상태로 변경 - */ - @DeleteMapping("/api/rooms/{roomId}") + @DeleteMapping("/{roomId}") + @Operation( + summary = "방 종료", + description = "방을 종료합니다. 모든 멤버가 강제 퇴장되며 더 이상 입장할 수 없습니다. 방장만 실행 가능합니다." + ) + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "종료 성공"), + @ApiResponse(responseCode = "403", description = "방장 권한 없음"), + @ApiResponse(responseCode = "404", description = "존재하지 않는 방"), + @ApiResponse(responseCode = "401", description = "인증 실패") + }) public ResponseEntity> deleteRoom( - @PathVariable Long roomId, + @Parameter(description = "방 ID", required = true) @PathVariable Long roomId, @RequestHeader("Authorization") String authorization) { Long currentUserId = 1L; // 임시 하드코딩 @@ -300,35 +275,27 @@ public ResponseEntity> deleteRoom( .body(RsData.success("방 종료 완료", null)); } - /** - * 방 멤버 목록 조회 API - - 현재 온라인 상태인 인원만 조회 (룸 내에서든 외에서든) - - 역할별로 정렬 (방장 > 부방장 > 멤버 > 방문객) - - * 접근 제한: - - 공개 방: 누구나 조회 가능 - - 비공개 방: 해당 방 멤버만 조회 가능 - */ - @GetMapping("/api/rooms/{roomId}/participants") - public ResponseEntity>>> getRoomMembers( - @PathVariable Long roomId, + @GetMapping("/{roomId}/members") + @Operation( + summary = "방 멤버 목록 조회", + description = "방의 현재 온라인 멤버 목록을 조회합니다. 역할별로 정렬됩니다(방장>부방장>멤버>방문객)." + ) + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "조회 성공"), + @ApiResponse(responseCode = "403", description = "비공개 방에 대한 접근 권한 없음"), + @ApiResponse(responseCode = "404", description = "존재하지 않는 방"), + @ApiResponse(responseCode = "401", description = "인증 실패") + }) + public ResponseEntity>> getRoomMembers( + @Parameter(description = "방 ID", required = true) @PathVariable Long roomId, @RequestHeader("Authorization") String authorization) { Long currentUserId = 1L; // 임시 하드코딩 List members = roomService.getRoomMembers(roomId, currentUserId); - List> memberList = members.stream() - .map(member -> { - Map memberMap = new HashMap<>(); - memberMap.put("userId", member.getUser().getId()); - memberMap.put("nickname", member.getUser().getNickname()); - memberMap.put("role", member.getRole()); - memberMap.put("isOnline", member.isOnline()); - memberMap.put("joinedAt", member.getJoinedAt()); - memberMap.put("lastActiveAt", member.getLastActiveAt() != null ? member.getLastActiveAt() : member.getJoinedAt()); - return memberMap; - }) + List memberList = members.stream() + .map(RoomMemberResponse::from) .collect(Collectors.toList()); return ResponseEntity @@ -336,37 +303,24 @@ public ResponseEntity>>> getRoomMembers( .body(RsData.success("방 멤버 목록 조회 완료", memberList)); } - /** - * 인기 방 목록 조회 API - * 정렬 기준: - - 1순위: 현재 참가자 수 (내림차순) - - 2순위: 생성 시간 (최신순) - - * 조회 조건: - - 공개 방만 조회 - - 활성화된 방만 조회 - */ @GetMapping("/popular") + @Operation( + summary = "인기 방 목록 조회", + description = "참가자 수가 많은 인기 방 목록을 페이징하여 조회합니다. 참가자 수 내림차순에서 최신순으로 정렬됩니다." + ) + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "조회 성공"), + @ApiResponse(responseCode = "401", description = "인증 실패") + }) public ResponseEntity>> getPopularRooms( - @RequestParam(defaultValue = "0") int page, - @RequestParam(defaultValue = "20") int size) { + @Parameter(description = "페이지 번호 (0부터 시작)") @RequestParam(defaultValue = "0") int page, + @Parameter(description = "페이지 크기") @RequestParam(defaultValue = "20") int size) { Pageable pageable = PageRequest.of(page, size); Page rooms = roomService.getPopularRooms(pageable); - List> roomList = rooms.getContent().stream() - .map(room -> { - Map roomMap = new HashMap<>(); - roomMap.put("roomId", room.getId()); - roomMap.put("title", room.getTitle()); - roomMap.put("description", room.getDescription() != null ? room.getDescription() : ""); - roomMap.put("currentParticipants", room.getCurrentParticipants()); - roomMap.put("maxParticipants", room.getMaxParticipants()); - roomMap.put("status", room.getStatus()); - roomMap.put("createdBy", room.getCreatedBy().getNickname()); - roomMap.put("createdAt", room.getCreatedAt()); - return roomMap; - }) + List roomList = rooms.getContent().stream() + .map(RoomResponse::from) .collect(Collectors.toList()); Map response = new HashMap<>(); diff --git a/src/main/java/com/back/domain/studyroom/dto/CreateRoomRequest.java b/src/main/java/com/back/domain/studyroom/dto/CreateRoomRequest.java new file mode 100644 index 00000000..46a4672d --- /dev/null +++ b/src/main/java/com/back/domain/studyroom/dto/CreateRoomRequest.java @@ -0,0 +1,26 @@ +package com.back.domain.studyroom.dto; + +import jakarta.validation.constraints.*; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@NoArgsConstructor +@AllArgsConstructor +public class CreateRoomRequest { + @NotBlank(message = "방 제목은 필수입니다") + @Size(max = 100, message = "방 제목은 100자를 초과할 수 없습니다") + private String title; + + @Size(max = 500, message = "방 설명은 500자를 초과할 수 없습니다") + private String description; + + private Boolean isPrivate = false; + + private String password; + + @Min(value = 2, message = "최소 2명 이상이어야 합니다") + @Max(value = 100, message = "최대 100명까지 가능합니다") + private Integer maxParticipants = 10; +} diff --git a/src/main/java/com/back/domain/studyroom/dto/JoinRoomRequest.java b/src/main/java/com/back/domain/studyroom/dto/JoinRoomRequest.java new file mode 100644 index 00000000..42a7bc39 --- /dev/null +++ b/src/main/java/com/back/domain/studyroom/dto/JoinRoomRequest.java @@ -0,0 +1,12 @@ +package com.back.domain.studyroom.dto; + +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@NoArgsConstructor +@AllArgsConstructor +public class JoinRoomRequest { + private String password; +} diff --git a/src/main/java/com/back/domain/studyroom/dto/JoinRoomResponse.java b/src/main/java/com/back/domain/studyroom/dto/JoinRoomResponse.java new file mode 100644 index 00000000..2efefa91 --- /dev/null +++ b/src/main/java/com/back/domain/studyroom/dto/JoinRoomResponse.java @@ -0,0 +1,26 @@ +package com.back.domain.studyroom.dto; + +import com.back.domain.studyroom.entity.RoomMember; +import com.back.domain.studyroom.entity.RoomRole; +import lombok.Builder; +import lombok.Getter; + +import java.time.LocalDateTime; + +@Getter +@Builder +public class JoinRoomResponse { + private Long roomId; + private Long userId; + private RoomRole role; + private LocalDateTime joinedAt; + + public static JoinRoomResponse from(RoomMember member) { + return JoinRoomResponse.builder() + .roomId(member.getRoom().getId()) + .userId(member.getUser().getId()) + .role(member.getRole()) + .joinedAt(member.getJoinedAt()) + .build(); + } +} diff --git a/src/main/java/com/back/domain/studyroom/dto/MyRoomResponse.java b/src/main/java/com/back/domain/studyroom/dto/MyRoomResponse.java new file mode 100644 index 00000000..78b90d55 --- /dev/null +++ b/src/main/java/com/back/domain/studyroom/dto/MyRoomResponse.java @@ -0,0 +1,35 @@ +package com.back.domain.studyroom.dto; + +import com.back.domain.studyroom.entity.Room; +import com.back.domain.studyroom.entity.RoomRole; +import com.back.domain.studyroom.entity.RoomStatus; +import lombok.Builder; +import lombok.Getter; + +import java.time.LocalDateTime; + +@Getter +@Builder +public class MyRoomResponse { + private Long roomId; + private String title; + private String description; + private int currentParticipants; + private int maxParticipants; + private RoomStatus status; + private RoomRole myRole; + private LocalDateTime createdAt; + + public static MyRoomResponse of(Room room, RoomRole myRole) { + return MyRoomResponse.builder() + .roomId(room.getId()) + .title(room.getTitle()) + .description(room.getDescription() != null ? room.getDescription() : "") + .currentParticipants(room.getCurrentParticipants()) + .maxParticipants(room.getMaxParticipants()) + .status(room.getStatus()) + .myRole(myRole) + .createdAt(room.getCreatedAt()) + .build(); + } +} diff --git a/src/main/java/com/back/domain/studyroom/dto/RoomDetailResponse.java b/src/main/java/com/back/domain/studyroom/dto/RoomDetailResponse.java new file mode 100644 index 00000000..f282d46e --- /dev/null +++ b/src/main/java/com/back/domain/studyroom/dto/RoomDetailResponse.java @@ -0,0 +1,45 @@ +package com.back.domain.studyroom.dto; + +import com.back.domain.studyroom.entity.Room; +import com.back.domain.studyroom.entity.RoomStatus; +import lombok.Builder; +import lombok.Getter; + +import java.time.LocalDateTime; +import java.util.List; + +@Getter +@Builder +public class RoomDetailResponse { + private Long roomId; + private String title; + private String description; + private boolean isPrivate; + private int maxParticipants; + private int currentParticipants; + private RoomStatus status; + private boolean allowCamera; + private boolean allowAudio; + private boolean allowScreenShare; + private String createdBy; + private LocalDateTime createdAt; + private List members; + + public static RoomDetailResponse of(Room room, List members) { + return RoomDetailResponse.builder() + .roomId(room.getId()) + .title(room.getTitle()) + .description(room.getDescription() != null ? room.getDescription() : "") + .isPrivate(room.isPrivate()) + .maxParticipants(room.getMaxParticipants()) + .currentParticipants(room.getCurrentParticipants()) + .status(room.getStatus()) + .allowCamera(room.isAllowCamera()) + .allowAudio(room.isAllowAudio()) + .allowScreenShare(room.isAllowScreenShare()) + .createdBy(room.getCreatedBy().getNickname()) + .createdAt(room.getCreatedAt()) + .members(members) + .build(); + } +} diff --git a/src/main/java/com/back/domain/studyroom/dto/RoomMemberResponse.java b/src/main/java/com/back/domain/studyroom/dto/RoomMemberResponse.java new file mode 100644 index 00000000..2d12c824 --- /dev/null +++ b/src/main/java/com/back/domain/studyroom/dto/RoomMemberResponse.java @@ -0,0 +1,30 @@ +package com.back.domain.studyroom.dto; + +import com.back.domain.studyroom.entity.RoomMember; +import com.back.domain.studyroom.entity.RoomRole; +import lombok.Builder; +import lombok.Getter; + +import java.time.LocalDateTime; + +@Getter +@Builder +public class RoomMemberResponse { + private Long userId; + private String nickname; + private RoomRole role; + private boolean isOnline; + private LocalDateTime joinedAt; + private LocalDateTime lastActiveAt; + + public static RoomMemberResponse from(RoomMember member) { + return RoomMemberResponse.builder() + .userId(member.getUser().getId()) + .nickname(member.getUser().getNickname()) + .role(member.getRole()) + .isOnline(member.isOnline()) + .joinedAt(member.getJoinedAt()) + .lastActiveAt(member.getLastActiveAt() != null ? member.getLastActiveAt() : member.getJoinedAt()) + .build(); + } +} diff --git a/src/main/java/com/back/domain/studyroom/dto/RoomResponse.java b/src/main/java/com/back/domain/studyroom/dto/RoomResponse.java new file mode 100644 index 00000000..905a35ad --- /dev/null +++ b/src/main/java/com/back/domain/studyroom/dto/RoomResponse.java @@ -0,0 +1,34 @@ +package com.back.domain.studyroom.dto; + +import com.back.domain.studyroom.entity.Room; +import com.back.domain.studyroom.entity.RoomStatus; +import lombok.Builder; +import lombok.Getter; + +import java.time.LocalDateTime; + +@Getter +@Builder +public class RoomResponse { + private Long roomId; + private String title; + private String description; + private int currentParticipants; + private int maxParticipants; + private RoomStatus status; + private String createdBy; + private LocalDateTime createdAt; + + public static RoomResponse from(Room room) { + return RoomResponse.builder() + .roomId(room.getId()) + .title(room.getTitle()) + .description(room.getDescription() != null ? room.getDescription() : "") + .currentParticipants(room.getCurrentParticipants()) + .maxParticipants(room.getMaxParticipants()) + .status(room.getStatus()) + .createdBy(room.getCreatedBy().getNickname()) + .createdAt(room.getCreatedAt()) + .build(); + } +} diff --git a/src/main/java/com/back/domain/studyroom/dto/UpdateRoomSettingsRequest.java b/src/main/java/com/back/domain/studyroom/dto/UpdateRoomSettingsRequest.java new file mode 100644 index 00000000..d3cc8b95 --- /dev/null +++ b/src/main/java/com/back/domain/studyroom/dto/UpdateRoomSettingsRequest.java @@ -0,0 +1,26 @@ +package com.back.domain.studyroom.dto; + +import jakarta.validation.constraints.*; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@NoArgsConstructor +@AllArgsConstructor +public class UpdateRoomSettingsRequest { + @NotBlank(message = "방 제목은 필수입니다") + @Size(max = 100, message = "방 제목은 100자를 초과할 수 없습니다") + private String title; + + @Size(max = 500, message = "방 설명은 500자를 초과할 수 없습니다") + private String description; + + @Min(value = 2, message = "최소 2명 이상이어야 합니다") + @Max(value = 100, message = "최대 100명까지 가능합니다") + private Integer maxParticipants; + + private Boolean allowCamera = true; + private Boolean allowAudio = true; + private Boolean allowScreenShare = true; +} 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 56bb1955..98d681cf 100644 --- a/src/main/java/com/back/domain/studyroom/entity/Room.java +++ b/src/main/java/com/back/domain/studyroom/entity/Room.java @@ -124,6 +124,9 @@ public boolean needsPassword() { 방장이 스터디를 시작할 때 또는 대기 중인 방을 활성화할 때 */ public void activate() { + if (this.status == RoomStatus.TERMINATED) { + throw new IllegalStateException("종료된 방은 활성화할 수 없습니다."); + } this.status = RoomStatus.ACTIVE; this.isActive = true; } @@ -132,6 +135,12 @@ public void activate() { * 방을 일시 정지 상태로 변경 */ public void pause() { + if (this.status == RoomStatus.TERMINATED) { + throw new IllegalStateException("종료된 방은 일시정지할 수 없습니다."); + } + if (this.status != RoomStatus.ACTIVE) { + throw new IllegalStateException("활성화된 방만 일시정지할 수 있습니다."); + } this.status = RoomStatus.PAUSED; } @@ -140,6 +149,9 @@ public void pause() { 방장이 스터디를 완전히 끝내거나, 빈 방을 자동 정리할 때 (종료 처리를 어떻게 뺄지에 따라 변경 될 예정) */ public void terminate() { + if (this.status == RoomStatus.TERMINATED) { + return; // 이미 종료된 방은 무시 + } this.status = RoomStatus.TERMINATED; this.isActive = false; } diff --git a/src/main/java/com/back/domain/studyroom/entity/RoomMember.java b/src/main/java/com/back/domain/studyroom/entity/RoomMember.java index 3c80a5ba..e442c692 100644 --- a/src/main/java/com/back/domain/studyroom/entity/RoomMember.java +++ b/src/main/java/com/back/domain/studyroom/entity/RoomMember.java @@ -97,11 +97,11 @@ public boolean isMember() { /** * 현재 활성 상태인지 확인 온라인 멤버 목록 표시, 비활성 사용자 정리 등 - 온라인 상태이고 최근 5분 이내에 heartbeat가 있었던 경우 + 온라인 상태이고 최근 설정된 시간 이내에 heartbeat가 있었던 경우 */ - public boolean isActive() { + public boolean isActive(int timeoutMinutes) { return isOnline && lastHeartbeat != null && - lastHeartbeat.isAfter(LocalDateTime.now().minusMinutes(5)); + lastHeartbeat.isAfter(LocalDateTime.now().minusMinutes(timeoutMinutes)); } diff --git a/src/main/java/com/back/domain/studyroom/repository/RoomMemberRepository.java b/src/main/java/com/back/domain/studyroom/repository/RoomMemberRepository.java index d91496b6..213f3890 100644 --- a/src/main/java/com/back/domain/studyroom/repository/RoomMemberRepository.java +++ b/src/main/java/com/back/domain/studyroom/repository/RoomMemberRepository.java @@ -1,82 +1,15 @@ package com.back.domain.studyroom.repository; import com.back.domain.studyroom.entity.RoomMember; -import com.back.domain.studyroom.entity.RoomRole; import org.springframework.data.jpa.repository.JpaRepository; -import org.springframework.data.jpa.repository.Modifying; -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; import java.util.Optional; @Repository -public interface RoomMemberRepository extends JpaRepository { - - // 방의 특정 사용자 멤버십 조회 - @Query("SELECT rm FROM RoomMember rm WHERE rm.room.id = :roomId AND rm.user.id = :userId") - Optional findByRoomIdAndUserId(@Param("roomId") Long roomId, @Param("userId") Long userId); - - // 방의 모든 멤버 조회 - @Query("SELECT rm FROM RoomMember rm WHERE rm.room.id = :roomId ORDER BY rm.role, rm.joinedAt") - List findByRoomIdOrderByRole(@Param("roomId") Long roomId); - - // 방의 온라인 멤버 조회 - @Query("SELECT rm FROM RoomMember rm WHERE rm.room.id = :roomId AND rm.isOnline = true " + - "ORDER BY rm.role, rm.lastActiveAt DESC") - List findOnlineMembersByRoomId(@Param("roomId") Long roomId); - - // 방의 활성 멤버 수 조회 - @Query("SELECT COUNT(rm) FROM RoomMember rm WHERE rm.room.id = :roomId AND rm.isOnline = true") - int countActiveMembersByRoomId(@Param("roomId") Long roomId); - - // 사용자가 참여 중인 모든 방의 멤버십 조회 - @Query("SELECT rm FROM RoomMember rm WHERE rm.user.id = :userId AND rm.isOnline = true") - List findActiveByUserId(@Param("userId") Long userId); - - // 특정 역할의 멤버 조회 - @Query("SELECT rm FROM RoomMember rm WHERE rm.room.id = :roomId AND rm.role = :role") - List findByRoomIdAndRole(@Param("roomId") Long roomId, @Param("role") RoomRole role); - - // 방장 조회 - @Query("SELECT rm FROM RoomMember rm WHERE rm.room.id = :roomId AND rm.role = 'HOST'") - Optional findHostByRoomId(@Param("roomId") Long roomId); - - // 관리자 권한을 가진 멤버들 조회 (HOST, SUB_HOST) - @Query("SELECT rm FROM RoomMember rm WHERE rm.room.id = :roomId " + - "AND rm.role IN ('HOST', 'SUB_HOST') ORDER BY rm.role") - List findManagersByRoomId(@Param("roomId") Long roomId); - - // 사용자가 특정 방에서 관리자 권한을 가지고 있는지 확인 - @Query("SELECT CASE WHEN COUNT(rm) > 0 THEN true ELSE false END FROM RoomMember rm " + - "WHERE rm.room.id = :roomId AND rm.user.id = :userId " + - "AND rm.role IN ('HOST', 'SUB_HOST')") - boolean isManager(@Param("roomId") Long roomId, @Param("userId") Long userId); - - // 사용자가 이미 해당 방의 멤버인지 확인 - @Query("SELECT CASE WHEN COUNT(rm) > 0 THEN true ELSE false END FROM RoomMember rm " + - "WHERE rm.room.id = :roomId AND rm.user.id = :userId") - boolean existsByRoomIdAndUserId(@Param("roomId") Long roomId, @Param("userId") Long userId); - - // WebSocket 연결 ID로 멤버 조회 +public interface RoomMemberRepository extends JpaRepository, RoomMemberRepositoryCustom { + /** + * WebSocket 연결 ID로 멤버 조회 + */ Optional findByConnectionId(String connectionId); - - // 방 퇴장 처리 - @Modifying - @Query("UPDATE RoomMember rm SET rm.isOnline = false, rm.connectionId = null " + - "WHERE rm.room.id = :roomId AND rm.user.id = :userId") - void leaveRoom(@Param("roomId") Long roomId, @Param("userId") Long userId); - - // 방의 모든 멤버를 오프라인 처리 (방 종료 시) - @Modifying - @Query("UPDATE RoomMember rm SET rm.isOnline = false, rm.connectionId = null " + - "WHERE rm.room.id = :roomId") - void disconnectAllMembers(@Param("roomId") Long roomId); - - // 특정 역할의 멤버 수 조회 - @Query("SELECT COUNT(rm) FROM RoomMember rm WHERE rm.room.id = :roomId " + - "AND rm.role = :role AND rm.isOnline = true") - int countByRoomIdAndRole(@Param("roomId") Long roomId, @Param("role") RoomRole role); } diff --git a/src/main/java/com/back/domain/studyroom/repository/RoomMemberRepositoryCustom.java b/src/main/java/com/back/domain/studyroom/repository/RoomMemberRepositoryCustom.java new file mode 100644 index 00000000..7740e0d1 --- /dev/null +++ b/src/main/java/com/back/domain/studyroom/repository/RoomMemberRepositoryCustom.java @@ -0,0 +1,75 @@ +package com.back.domain.studyroom.repository; + +import com.back.domain.studyroom.entity.RoomMember; +import com.back.domain.studyroom.entity.RoomRole; + +import java.util.List; +import java.util.Optional; + +public interface RoomMemberRepositoryCustom { + + /** + * 방의 특정 사용자 멤버십 조회 + */ + Optional findByRoomIdAndUserId(Long roomId, Long userId); + + /** + * 방의 모든 멤버 조회 (역할순 정렬) + */ + List findByRoomIdOrderByRole(Long roomId); + + /** + * 방의 온라인 멤버 조회 + */ + List findOnlineMembersByRoomId(Long roomId); + + /** + * 방의 활성 멤버 수 조회 + */ + int countActiveMembersByRoomId(Long roomId); + + /** + * 사용자가 참여 중인 모든 방의 멤버십 조회 + */ + List findActiveByUserId(Long userId); + + /** + * 특정 역할의 멤버 조회 + */ + List findByRoomIdAndRole(Long roomId, RoomRole role); + + /** + * 방장 조회 + */ + Optional findHostByRoomId(Long roomId); + + /** + * 관리자 권한을 가진 멤버들 조회 (HOST, SUB_HOST) + */ + List findManagersByRoomId(Long roomId); + + /** + * 사용자가 특정 방에서 관리자 권한을 가지고 있는지 확인 + */ + boolean isManager(Long roomId, Long userId); + + /** + * 사용자가 이미 해당 방의 멤버인지 확인 + */ + boolean existsByRoomIdAndUserId(Long roomId, Long userId); + + /** + * 특정 역할의 멤버 수 조회 + */ + int countByRoomIdAndRole(Long roomId, RoomRole role); + + /** + * 방 퇴장 처리 (벌크 업데이트) + */ + void leaveRoom(Long roomId, Long userId); + + /** + * 방의 모든 멤버를 오프라인 처리 (방 종료 시) + */ + void disconnectAllMembers(Long roomId); +} diff --git a/src/main/java/com/back/domain/studyroom/repository/RoomMemberRepositoryImpl.java b/src/main/java/com/back/domain/studyroom/repository/RoomMemberRepositoryImpl.java new file mode 100644 index 00000000..81bd17a7 --- /dev/null +++ b/src/main/java/com/back/domain/studyroom/repository/RoomMemberRepositoryImpl.java @@ -0,0 +1,372 @@ +package com.back.domain.studyroom.repository; + +import com.back.domain.studyroom.entity.QRoomMember; +import com.back.domain.studyroom.entity.RoomMember; +import com.back.domain.studyroom.entity.RoomRole; +import com.back.domain.user.entity.QUser; +import com.querydsl.jpa.impl.JPAQueryFactory; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Repository; + +import java.util.List; +import java.util.Optional; + +/** + * 주요 기능: + * - 방별/사용자별 멤버십 조회 + * - 역할(Role)별 멤버 필터링 + * - 온라인 상태 관리 + * - JOIN FETCH를 통한 N+1 문제 해결 + * - 벌크 업데이트 쿼리 + */ +@Repository +@RequiredArgsConstructor +public class RoomMemberRepositoryImpl implements RoomMemberRepositoryCustom { + + private final JPAQueryFactory queryFactory; + + // QueryDSL Q 클래스 인스턴스 + private final QRoomMember roomMember = QRoomMember.roomMember; + private final QUser user = QUser.user; + + /** + * 방의 특정 사용자 멤버십 조회 + * - 사용자가 특정 방에 참여 중인지 확인 + * - 방 입장 시 기존 멤버십 존재 여부 확인 + * - 사용자의 방 내 역할 확인 + * @param roomId 방 ID + * @param userId 사용자 ID + * @return 멤버십 정보 (Optional) + */ + @Override + public Optional findByRoomIdAndUserId(Long roomId, Long userId) { + RoomMember member = queryFactory + .selectFrom(roomMember) + .where( + roomMember.room.id.eq(roomId), + roomMember.user.id.eq(userId) + ) + .fetchOne(); + + return Optional.ofNullable(member); + } + + /** + * 방의 모든 멤버 조회 (역할순 정렬) + * - 1순위: 역할 (HOST > SUB_HOST > MEMBER > VISITOR) + * - 2순위: 입장 시간 (먼저 입장한 순) + * - 방 설정 페이지에서 전체 멤버 목록 표시 + * - 멤버 관리 기능 + * @param roomId 방 ID + * @return 정렬된 멤버 목록 + */ + @Override + public List findByRoomIdOrderByRole(Long roomId) { + return queryFactory + .selectFrom(roomMember) + .where(roomMember.room.id.eq(roomId)) + .orderBy( + roomMember.role.asc(), // 역할순 (HOST가 먼저) + roomMember.joinedAt.asc() // 입장 시간순 + ) + .fetch(); + } + + /** + * 방의 온라인 멤버 조회 + * - 현재 온라인 상태인 멤버만 (isOnline = true) + * - 1순위: 역할 (HOST > SUB_HOST > MEMBER > VISITOR) + * - 2순위: 마지막 활동 시간 (최근 활동 순) + * - 방 상세 페이지에서 현재 접속 중인 멤버 표시 + * - 실시간 멤버 목록 업데이트 + * @param roomId 방 ID + * @return 온라인 멤버 목록 + */ + @Override + public List findOnlineMembersByRoomId(Long roomId) { + return queryFactory + .selectFrom(roomMember) + .leftJoin(roomMember.user, user).fetchJoin() // N+1 방지 + .where( + roomMember.room.id.eq(roomId), + roomMember.isOnline.eq(true) + ) + .orderBy( + roomMember.role.asc(), // 역할순 + roomMember.lastActiveAt.desc() // 최근 활동순 + ) + .fetch(); + } + + /** + * 방의 활성 멤버 수 조회 + * - 현재 온라인 상태인 멤버 (isOnline = true) + * - 방 목록에서 현재 참가자 수 표시 + * - 정원 체크 (현재 참가자 vs 최대 참가자) + * - 통계 데이터 수집 로직 구현 시 연결 해야함.. + * @param roomId 방 ID + * @return 활성 멤버 수 + */ + @Override + public int countActiveMembersByRoomId(Long roomId) { + Long count = queryFactory + .select(roomMember.count()) + .from(roomMember) + .where( + roomMember.room.id.eq(roomId), + roomMember.isOnline.eq(true) + ) + .fetchOne(); + + return count != null ? count.intValue() : 0; + } + + /** + * 사용자가 참여 중인 모든 방의 멤버십 조회 + * @param userId 사용자 ID + * @return 참여 중인 방의 멤버십 목록 + */ + @Override + public List findActiveByUserId(Long userId) { + return queryFactory + .selectFrom(roomMember) + .where( + roomMember.user.id.eq(userId), + roomMember.isOnline.eq(true) + ) + .fetch(); + } + + /** + * 특정 역할의 멤버 조회 + * - 방장(HOST) 찾기 + * - 부방장(SUB_HOST) 목록 조회 + * - 역할별 멤버 필터링 + * 예시: + * ```java + * // 방의 모든 부방장 조회 + * List subHosts = findByRoomIdAndRole(roomId, RoomRole.SUB_HOST); + * ``` + * @param roomId 방 ID + * @param role 역할 (HOST, SUB_HOST, MEMBER, VISITOR) + * @return 해당 역할의 멤버 목록 + */ + @Override + public List findByRoomIdAndRole(Long roomId, RoomRole role) { + return queryFactory + .selectFrom(roomMember) + .where( + roomMember.room.id.eq(roomId), + roomMember.role.eq(role) + ) + .fetch(); + } + + /** + * 방장 조회 + * - 방장 권한 확인 + * - 방 소유자 정보 표시 + * - 정상적인 방이라면 반드시 방장이 1명 존재 + * - Optional.empty()인 경우는 데이터 오류 상태 + * @param roomId 방 ID + * @return 방장 멤버십 (Optional) + */ + @Override + public Optional findHostByRoomId(Long roomId) { + RoomMember host = queryFactory + .selectFrom(roomMember) + .where( + roomMember.room.id.eq(roomId), + roomMember.role.eq(RoomRole.HOST) + ) + .fetchOne(); + + return Optional.ofNullable(host); + } + + /** + * 관리자 권한을 가진 멤버들 조회 (HOST, SUB_HOST) + * - HOST: 방장 (최고 권한) + * - SUB_HOST: 부방장 (방장이 위임한 권한) + * - 관리자 목록 표시 + * - 권한 체크 (이 목록에 있는 사용자만 특정 작업 가능) + * @param roomId 방 ID + * @return 관리자 멤버 목록 (HOST, SUB_HOST) + */ + @Override + public List findManagersByRoomId(Long roomId) { + return queryFactory + .selectFrom(roomMember) + .where( + roomMember.room.id.eq(roomId), + roomMember.role.in(RoomRole.HOST, RoomRole.SUB_HOST) + ) + .orderBy(roomMember.role.asc()) // HOST가 먼저 + .fetch(); + } + + /** + * 사용자가 특정 방에서 관리자 권한을 가지고 있는지 확인 + * - HOST 또는 SUB_HOST 역할 + * - 방 설정 변경 권한 체크 + * - 멤버 추방 권한 체크 + * - 공지사항 작성 권한 체크 + * 사용 예시: + * ```java + * if (!roomMemberRepository.isManager(roomId, userId)) { + * throw new CustomException(ErrorCode.NOT_ROOM_MANAGER); + * } + * ``` + * @param roomId 방 ID + * @param userId 사용자 ID + * @return 관리자 권한 여부 + */ + @Override + public boolean isManager(Long roomId, Long userId) { + Long count = queryFactory + .select(roomMember.count()) + .from(roomMember) + .where( + roomMember.room.id.eq(roomId), + roomMember.user.id.eq(userId), + roomMember.role.in(RoomRole.HOST, RoomRole.SUB_HOST) + ) + .fetchOne(); + + return count != null && count > 0; + } + + /** + * 사용자가 이미 해당 방의 멤버인지 확인 + * ( 해당 로직 활용해서 유저 밴 등으로 추후에 확장 가능) + * - 방 입장 전 중복 참여 체크 + * - 비공개 방 접근 권한 확인 + * - 멤버 전용 기능 접근 권한 확인 + * 사용 예시: + * ```java + * if (room.isPrivate() && !roomMemberRepository.existsByRoomIdAndUserId(roomId, userId)) { + * throw new CustomException(ErrorCode.ROOM_FORBIDDEN); + * } + * ``` + * @param roomId 방 ID + * @param userId 사용자 ID + * @return 멤버 여부 + */ + @Override + public boolean existsByRoomIdAndUserId(Long roomId, Long userId) { + Long count = queryFactory + .select(roomMember.count()) + .from(roomMember) + .where( + roomMember.room.id.eq(roomId), + roomMember.user.id.eq(userId) + ) + .fetchOne(); + + return count != null && count > 0; + } + + /** + * 특정 역할의 온라인 멤버 수 조회 + * - 특정 역할의 멤버 + * - 현재 온라인 상태만 + * 예시: + * ```java + * int hostCount = countByRoomIdAndRole(roomId, RoomRole.HOST); + * if (hostCount == 0) { + * + * } + * ``` + * @param roomId 방 ID + * @param role 역할 + * @return 해당 역할의 온라인 멤버 수 + */ + @Override + public int countByRoomIdAndRole(Long roomId, RoomRole role) { + Long count = queryFactory + .select(roomMember.count()) + .from(roomMember) + .where( + roomMember.room.id.eq(roomId), + roomMember.role.eq(role), + roomMember.isOnline.eq(true) + ) + .fetchOne(); + + return count != null ? count.intValue() : 0; + } + + /** + * 방 퇴장 처리 (벌크 업데이트) + * - isOnline을 false로 변경 + * - connectionId를 null로 초기화 + * + * ai 코드 리뷰 결과 : + * - 한 번의 쿼리로 처리하여 성능 최적화 상태 + * - 벌크 연산은 영속성 컨텍스트를 무시 + * - 이후 해당 엔티티를 조회하면 DB와 불일치 가능 + * - 필요시 em.clear() 또는 em.refresh() 사용 + * ( 추후 기초 기능 개발 완료 후 개선 예정) + * + * - 사용자가 명시적으로 방을 나갈 때 + * - WebSocket 연결 끊김 감지 시 + * - 타임아웃으로 자동 퇴장 처리 시 + * @param roomId 방 ID + * @param userId 사용자 ID + */ + @Override + public void leaveRoom(Long roomId, Long userId) { + queryFactory + .update(roomMember) + .set(roomMember.isOnline, false) + .setNull(roomMember.connectionId) + .where( + roomMember.room.id.eq(roomId), + roomMember.user.id.eq(userId) + ) + .execute(); + } + + /** + * 방의 모든 멤버를 오프라인 처리 (방 종료 시) + * - 해당 방의 모든 멤버를 오프라인으로 변경 + * - 모든 멤버의 connectionId 제거 + * + * - 방장이 방을 종료할 때 + * - 방이 자동으로 종료될 때 (참가자 0명 + 일정 시간 경과) + * - 긴급 상황으로 방을 강제 종료할 때 + * + * ai 코드 리뷰 결과 : + * 해당 부분도 쿼리 한번으로 동작되는 내용이기 때문에, + * 그렇게 동작 시에는 웹소켓에 미리 종료 알림을 주는 형식으로 변경하라고 함. + * 이 작업 후 방 상태를 TERMINATED로 변경해야 함 + * + * 사용 예시: + * ```java + * @Transactional + * public void terminateRoom(Long roomId) { + * Room room = roomRepository.findById(roomId)...; + * + * // 모든 멤버 오프라인 처리 + * roomMemberRepository.disconnectAllMembers(roomId); + * + * // 방 종료 + * room.terminate(); + * + * // WebSocket으로 종료 알림 + * notifyRoomTermination(roomId); + * } + * ``` + * + * @param roomId 방 ID + */ + @Override + public void disconnectAllMembers(Long roomId) { + queryFactory + .update(roomMember) + .set(roomMember.isOnline, false) + .setNull(roomMember.connectionId) + .where(roomMember.room.id.eq(roomId)) + .execute(); + } +} diff --git a/src/main/java/com/back/domain/studyroom/repository/RoomRepository.java b/src/main/java/com/back/domain/studyroom/repository/RoomRepository.java index 92ab18a2..dea97567 100644 --- a/src/main/java/com/back/domain/studyroom/repository/RoomRepository.java +++ b/src/main/java/com/back/domain/studyroom/repository/RoomRepository.java @@ -2,8 +2,6 @@ import com.back.domain.studyroom.entity.Room; import com.back.domain.studyroom.entity.RoomStatus; -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.Modifying; import org.springframework.data.jpa.repository.Query; @@ -13,56 +11,24 @@ import java.util.List; import java.util.Optional; -/* - - 방 검색 및 필터링 (제목, 상태, 공개/비공개) - - 참여 가능한 방 목록 조회 - - 사용자별 방 관리 (생성한 방, 참여 중인 방) - - 방 통계 및 관리 (인기 방, 참가자 수 동기화) - */ @Repository -public interface RoomRepository extends JpaRepository { - /* - 제목으로 방 검색 - 사용 상황: 사용자가 검색창에서 방 이름을 검색할 때 - */ +public interface RoomRepository extends JpaRepository, RoomRepositoryCustom { + + // 제목으로 방 검색 (단순 쿼리) List findByTitleContaining(String title); - /* - 활성화된 방 목록 조회 - 현재 사용 가능한 모든 방을 조회할 때(수정 예정, isActive에 만석 조건 추가 예정) - */ + // 활성화된 방 목록 조회 @Query("SELECT r FROM Room r WHERE r.isActive = true") List findActiveRooms(); - /* - 특정 상태의 방 목록 조회 - 상태별로 방을 관리하거나 통계를 낼 때 - */ + // 특정 상태의 방 목록 조회 @Query("SELECT r FROM Room r WHERE r.status = :status") List findByStatus(@Param("status") RoomStatus status); - - /* - 공개 방 중 입장 가능한 방들 조회 (페이징) - - 메인 페이지에서 사용자에게 입장 가능한 방 목록을 보여줄 때 - 비공개가 아니고, 활성화되어 있고, 입장 가능한 상태이며, 정원이 가득 차지 않은 방 - */ - @Query("SELECT r FROM Room r WHERE r.isPrivate = false AND r.isActive = true " + - "AND r.status IN ('WAITING', 'ACTIVE') AND r.currentParticipants < r.maxParticipants " + - "ORDER BY r.createdAt DESC") - Page findJoinablePublicRooms(Pageable pageable); // 사용자가 생성한 방 목록 조회 @Query("SELECT r FROM Room r WHERE r.createdBy.id = :createdById ORDER BY r.createdAt DESC") List findByCreatedById(@Param("createdById") Long createdById); - /* - 사용자가 참여 중인 방 조회 - 해당 사용자가 멤버로 등록되어 있고 현재 온라인 상태인 방 - */ - @Query("SELECT r FROM Room r JOIN r.roomMembers rm " + - "WHERE rm.user.id = :userId AND rm.isOnline = true") - List findRoomsByUserId(@Param("userId") Long userId); - // 방 존재 및 활성 상태 확인 @Query("SELECT CASE WHEN COUNT(r) > 0 THEN true ELSE false END FROM Room r " + "WHERE r.id = :roomId AND r.isActive = true") @@ -72,32 +38,10 @@ public interface RoomRepository extends JpaRepository { @Query("SELECT r FROM Room r WHERE r.id = :roomId AND r.isPrivate = true AND r.password = :password") Optional findByIdAndPassword(@Param("roomId") Long roomId, @Param("password") String password); - // 제목과 상태로 검색 - @Query("SELECT r FROM Room r WHERE " + - "(:title IS NULL OR r.title LIKE %:title%) AND " + - "(:status IS NULL OR r.status = :status) AND " + - "(:isPrivate IS NULL OR r.isPrivate = :isPrivate)") - Page findRoomsWithFilters(@Param("title") String title, - @Param("status") RoomStatus status, - @Param("isPrivate") Boolean isPrivate, - Pageable pageable); - // 참가자 수 업데이트 @Modifying @Query("UPDATE Room r SET r.currentParticipants = " + "(SELECT COUNT(rm) FROM RoomMember rm WHERE rm.room.id = r.id AND rm.isOnline = true) " + "WHERE r.id = :roomId") void updateCurrentParticipants(@Param("roomId") Long roomId); - - // 비활성 방 정리 (배치용) - @Modifying - @Query("UPDATE Room r SET r.status = 'TERMINATED', r.isActive = false " + - "WHERE r.currentParticipants = 0 AND r.status = 'ACTIVE' " + - "AND r.updatedAt < :cutoffTime") - int terminateInactiveRooms(@Param("cutoffTime") java.time.LocalDateTime cutoffTime); - - // 인기 방 조회 (참가자 수 기준, 로직에 따라 수정 가능) - @Query("SELECT r FROM Room r WHERE r.isPrivate = false AND r.isActive = true " + - "ORDER BY r.currentParticipants DESC, r.createdAt DESC") - Page findPopularRooms(Pageable pageable); } diff --git a/src/main/java/com/back/domain/studyroom/repository/RoomRepositoryCustom.java b/src/main/java/com/back/domain/studyroom/repository/RoomRepositoryCustom.java new file mode 100644 index 00000000..5673b101 --- /dev/null +++ b/src/main/java/com/back/domain/studyroom/repository/RoomRepositoryCustom.java @@ -0,0 +1,50 @@ +package com.back.domain.studyroom.repository; + +import com.back.domain.studyroom.entity.Room; +import com.back.domain.studyroom.entity.RoomStatus; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.Optional; + +public interface RoomRepositoryCustom { + + /** + * 공개 방 중 입장 가능한 방들 조회 (페이징) + * - 비공개가 아니고, 활성화되어 있고, 입장 가능한 상태이며, 정원이 가득 차지 않은 방 + * - JOIN FETCH로 N+1 문제 방지 + */ + Page findJoinablePublicRooms(Pageable pageable); + + /** + * 사용자가 참여 중인 방 조회 + * - 해당 사용자가 멤버로 등록되어 있고 현재 온라인 상태인 방 + * - JOIN FETCH로 N+1 문제 방지 + */ + List findRoomsByUserId(Long userId); + + /** + * 제목과 상태로 검색 (동적 쿼리) + */ + Page findRoomsWithFilters(String title, RoomStatus status, Boolean isPrivate, Pageable pageable); + + /** + * 인기 방 조회 (참가자 수 기준) + * - JOIN FETCH로 N+1 문제 방지 + */ + Page findPopularRooms(Pageable pageable); + + /** + * 비활성 방 정리 (배치용) + * - 참가자가 0명이고 일정 시간 이상 비활성 상태인 방 종료 + * @return 종료된 방 개수 + */ + int terminateInactiveRooms(LocalDateTime cutoffTime); + + /** + * 비관적 락으로 방 조회 (동시성 제어용) + */ + Optional findByIdWithLock(Long roomId); +} diff --git a/src/main/java/com/back/domain/studyroom/repository/RoomRepositoryImpl.java b/src/main/java/com/back/domain/studyroom/repository/RoomRepositoryImpl.java new file mode 100644 index 00000000..bf0e81c2 --- /dev/null +++ b/src/main/java/com/back/domain/studyroom/repository/RoomRepositoryImpl.java @@ -0,0 +1,276 @@ +package com.back.domain.studyroom.repository; + +import com.back.domain.studyroom.entity.QRoom; +import com.back.domain.studyroom.entity.QRoomMember; +import com.back.domain.studyroom.entity.Room; +import com.back.domain.studyroom.entity.RoomStatus; +import com.back.domain.user.entity.QUser; +import com.querydsl.core.types.dsl.BooleanExpression; +import com.querydsl.jpa.impl.JPAQuery; +import com.querydsl.jpa.impl.JPAQueryFactory; +import jakarta.persistence.EntityManager; +import jakarta.persistence.LockModeType; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Repository; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.Optional; + +@Repository +@RequiredArgsConstructor +public class RoomRepositoryImpl implements RoomRepositoryCustom { + + private final JPAQueryFactory queryFactory; + private final EntityManager entityManager; + + // QueryDSL Q 클래스 인스턴스 + private final QRoom room = QRoom.room; + private final QRoomMember roomMember = QRoomMember.roomMember; + private final QUser user = QUser.user; + + /** + * 공개 방 중 입장 가능한 방들 조회 (페이징) + * 조회 조건: + * - 비공개가 아닌 방 (isPrivate = false) + * - 활성화된 방 (isActive = true) + * - 입장 가능한 상태 (WAITING 또는 ACTIVE) + * - 정원이 가득 차지 않은 방 + * @param pageable 페이징 정보 + * @return 페이징된 방 목록 + */ + @Override + public Page findJoinablePublicRooms(Pageable pageable) { + // 방 목록 조회 + List rooms = queryFactory + .selectFrom(room) + .leftJoin(room.createdBy, user).fetchJoin() // N+1 방지 + .where( + room.isPrivate.eq(false), + room.isActive.eq(true), + room.status.in(RoomStatus.WAITING, RoomStatus.ACTIVE), + room.currentParticipants.lt(room.maxParticipants) + ) + .orderBy(room.createdAt.desc()) + .offset(pageable.getOffset()) + .limit(pageable.getPageSize()) + .fetch(); + + // 전체 개수 조회 (페이징 정보 생성용) + Long totalCount = queryFactory + .select(room.count()) + .from(room) + .where( + room.isPrivate.eq(false), + room.isActive.eq(true), + room.status.in(RoomStatus.WAITING, RoomStatus.ACTIVE), + room.currentParticipants.lt(room.maxParticipants) + ) + .fetchOne(); + + return new PageImpl<>(rooms, pageable, totalCount != null ? totalCount : 0); + } + + /** + * 사용자가 참여 중인 방 조회 + * 조회 조건: + * - 특정 사용자가 멤버로 등록된 방 + * - 현재 온라인 상태인 방만 + * @param userId 사용자 ID + * @return 참여 중인 방 목록 + */ + @Override + public List findRoomsByUserId(Long userId) { + return queryFactory + .selectFrom(room) + .leftJoin(room.createdBy, user).fetchJoin() // N+1 방지 + .join(room.roomMembers, roomMember) // 멤버 조인 + .where( + roomMember.user.id.eq(userId), + roomMember.isOnline.eq(true) + ) + .fetch(); + } + + /** + * 제목과 상태로 방 검색 (동적 쿼리) + * 검색 조건 (모두 선택적): + * - title: 방 제목에 포함된 문자열 (대소문자 무시) + * - status: 방 상태 (WAITING, ACTIVE, PAUSED, TERMINATED) + * - isPrivate: 공개/비공개 여부 + * 사용 예시: + * - 제목만: "스터디" 포함된 모든 방 + * - 상태만: ACTIVE 상태인 모든 방 + * - 조합: "자바" 포함 + ACTIVE + 공개방 + * @param title 검색할 제목 (null 가능) + * @param status 방 상태 (null 가능) + * @param isPrivate 공개/비공개 (null 가능) + * @param pageable 페이징 정보 + * @return 페이징된 검색 결과 + */ + @Override + public Page findRoomsWithFilters(String title, RoomStatus status, Boolean isPrivate, Pageable pageable) { + BooleanExpression whereClause = null; + + // 제목 검색 조건 (대소문자 무시) + if (title != null && !title.isEmpty()) { + whereClause = room.title.containsIgnoreCase(title); + } + + // 상태 조건 추가 + if (status != null) { + whereClause = whereClause != null + ? whereClause.and(room.status.eq(status)) + : room.status.eq(status); + } + + // 공개/비공개 조건 추가 + if (isPrivate != null) { + whereClause = whereClause != null + ? whereClause.and(room.isPrivate.eq(isPrivate)) + : room.isPrivate.eq(isPrivate); + } + + // 쿼리 빌더 생성 + JPAQuery query = queryFactory + .selectFrom(room) + .leftJoin(room.createdBy, user).fetchJoin(); // N+1 방지 + + // 동적으로 WHERE 절 추가 + if (whereClause != null) { + query.where(whereClause); + } + + // 방 목록 조회 + List rooms = query + .offset(pageable.getOffset()) + .limit(pageable.getPageSize()) + .fetch(); + + // 전체 개수 조회 + Long totalCount = queryFactory + .select(room.count()) + .from(room) + .where(whereClause) + .fetchOne(); + + return new PageImpl<>(rooms, pageable, totalCount != null ? totalCount : 0); + } + + /** + * 인기 방 조회 (참가자 수 기준) + * 조회 조건: + * - 공개 방만 (isPrivate = false) + * - 활성화된 방만 (isActive = true) + * @param pageable 페이징 정보 + * @return 페이징된 인기 방 목록 + */ + @Override + public Page findPopularRooms(Pageable pageable) { + List rooms = queryFactory + .selectFrom(room) + .leftJoin(room.createdBy, user).fetchJoin() // N+1 방지 + .where( + room.isPrivate.eq(false), + room.isActive.eq(true) + ) + .orderBy( + room.currentParticipants.desc(), // 참가자 수 많은 순 + room.createdAt.desc() // 최신순 + ) + .offset(pageable.getOffset()) + .limit(pageable.getPageSize()) + .fetch(); + + // 전체 개수 조회 + Long totalCount = queryFactory + .select(room.count()) + .from(room) + .where( + room.isPrivate.eq(false), + room.isActive.eq(true) + ) + .fetchOne(); + + return new PageImpl<>(rooms, pageable, totalCount != null ? totalCount : 0); + } + + /** + * 비활성 방 정리 (배치 작업용) + * 대상: + * - 참가자가 0명인 방 + * - ACTIVE 상태인 방 + * - cutoffTime 이전에 마지막으로 업데이트된 방 + * 처리: + * - 상태를 TERMINATED로 변경 + * - isActive를 false로 변경 + * 사용 예시: + * ``` + * // 1시간 이상 비어있는 방 정리 + * LocalDateTime cutoff = LocalDateTime.now().minusHours(1); + * int count = terminateInactiveRooms(cutoff); + * log.info("정리된 방 개수: {}", count); + * ``` + * @param cutoffTime 기준 시간 (이 시간 이전에 업데이트된 방 정리) + * @return 정리된 방 개수 + */ + @Override + public int terminateInactiveRooms(LocalDateTime cutoffTime) { + long affectedRows = queryFactory + .update(room) + .set(room.status, RoomStatus.TERMINATED) + .set(room.isActive, false) + .where( + room.currentParticipants.eq(0), + room.status.eq(RoomStatus.ACTIVE), + room.updatedAt.lt(cutoffTime) + ) + .execute(); + + return (int) affectedRows; + } + + /** + * 비관적 락으로 방 조회 (동시성 제어용) + * 동시성 제어: + * - PESSIMISTIC_WRITE 락 사용 + * - 트랜잭션 종료 시까지 다른 트랜잭션의 읽기/쓰기 차단 + * + * 주의사항: + * - 반드시 @Transactional 내에서 사용!!! + * - 락 대기 시간이 길어질 수 있으므로 빠르게 처리 + * - 데드락 가능성 주의 + * + * 사용 예시: + * ```java + * @Transactional + * public RoomMember joinRoom(Long roomId, Long userId) { + * Room room = roomRepository.findByIdWithLock(roomId) + * .orElseThrow(() -> new CustomException(ErrorCode.ROOM_NOT_FOUND)); + * + * if (!room.canJoin()) { + * throw new CustomException(ErrorCode.ROOM_FULL); + * } + * + * room.incrementParticipant(); + * // ... + * } + * ``` + * + * @param roomId 방 ID + * @return 락이 걸린 방 (Optional) + */ + @Override + public Optional findByIdWithLock(Long roomId) { + Room foundRoom = queryFactory + .selectFrom(room) + .where(room.id.eq(roomId)) + .setLockMode(LockModeType.PESSIMISTIC_WRITE) // 비관적 쓰기 락 + .fetchOne(); + + return Optional.ofNullable(foundRoom); + } +} diff --git a/src/main/java/com/back/domain/studyroom/service/RoomService.java b/src/main/java/com/back/domain/studyroom/service/RoomService.java index 4f41a74d..c7d021e4 100644 --- a/src/main/java/com/back/domain/studyroom/service/RoomService.java +++ b/src/main/java/com/back/domain/studyroom/service/RoomService.java @@ -1,5 +1,6 @@ package com.back.domain.studyroom.service; +import com.back.domain.studyroom.config.StudyRoomProperties; import com.back.domain.studyroom.entity.*; import com.back.domain.studyroom.repository.*; import com.back.domain.user.entity.User; @@ -17,19 +18,17 @@ import java.util.Optional; /** - * 스터디룸 관련 비즈니스 로직을 담당하는 Service 클래스 - * - * 🎯 주요 책임: - * - 방 생성, 입장, 퇴장 로직 처리 - * - 멤버 권한 관리 (승격, 강등, 추방) - * - 방 상태 관리 (활성화, 일시정지, 종료) - * - 방장 위임 로직 (방장이 나갈 때 자동 위임) - * - 실시간 참가자 수 동기화 - * - * 🔐 보안: - * - 모든 권한 검증을 서비스 레이어에서 처리 - * - 비공개 방 접근 권한 체크 - * - 방장/부방장 권한이 필요한 작업들의 권한 검증 + - 방 생성, 입장, 퇴장 로직 처리 + - 멤버 권한 관리 (승격, 강등, 추방) + - 방 상태 관리 (활성화, 일시정지, 종료) + - 방장 위임 로직 (방장이 나갈 때 자동 위임) + - 실시간 참가자 수 동기화 + + - 모든 권한 검증을 서비스 레이어에서 처리 + - 비공개 방 접근 권한 체크 + - 방장/부방장 권한이 필요한 작업들의 권한 검증 + + * 설정값 주입을 StudyRoomProperties를 통해 외부 설정 관리 */ @Service @RequiredArgsConstructor @@ -40,20 +39,20 @@ public class RoomService { private final RoomRepository roomRepository; private final RoomMemberRepository roomMemberRepository; private final UserRepository userRepository; + private final StudyRoomProperties properties; /** * 방 생성 메서드 - * - * 🏗️ 생성 과정: + * 생성 과정: * 1. 사용자 존재 확인 - * 2. Room 엔티티 생성 (기본값 설정) + * 2. Room 엔티티 생성 (외부 설정값 적용) * 3. 방장을 RoomMember로 등록 * 4. 참가자 수 1로 설정 - * - * 💡 기본 설정: - * - 상태: WAITING (대기 중) - * - 카메라/오디오/화면공유: 모두 허용 - * - 참가자 수: 0명에서 시작 후 방장 추가로 1명 + + * 기본 설정: + - 상태: WAITING (대기 중) + - 카메라/오디오/화면공유: application.yml의 설정값 사용 + - 참가자 수: 0명에서 시작 후 방장 추가로 1명 */ @Transactional public Room createRoom(String title, String description, boolean isPrivate, @@ -79,21 +78,24 @@ public Room createRoom(String title, String description, boolean isPrivate, /** * 방 입장 메서드 * - * 🔐 입장 검증 과정: - * 1. 방 존재 및 활성 상태 확인 + * 입장 검증 과정: + * 1. 방 존재 및 활성 상태 확인 (비관적 락으로 동시성 제어) * 2. 방 상태가 입장 가능한지 확인 (WAITING, ACTIVE) * 3. 정원 초과 여부 확인 * 4. 비공개 방인 경우 비밀번호 확인 * 5. 이미 참여 중인지 확인 (재입장 처리) - * - * 👤 멤버 등록: + + * 멤버 등록: (현재는 visitor로 등록이지만 추후 역할 부여가 안된 인원을 visitor로 띄우는 식으로 저장 데이터 줄일 예정) * - 신규 사용자: VISITOR 역할로 등록 * - 기존 사용자: 온라인 상태로 변경 + * + * 동시성 제어: 비관적 락(PESSIMISTIC_WRITE)으로 정원 초과 방지 */ @Transactional public RoomMember joinRoom(Long roomId, String password, Long userId) { - Room room = roomRepository.findById(roomId) + // 비관적 락으로 방 조회 - 동시 입장 시 정원 초과 방지 + Room room = roomRepository.findByIdWithLock(roomId) .orElseThrow(() -> new CustomException(ErrorCode.ROOM_NOT_FOUND)); if (!room.isActive()) { diff --git a/src/main/resources/application-dev.yml b/src/main/resources/application-dev.yml index 34da7aa3..ee8c0fcb 100644 --- a/src/main/resources/application-dev.yml +++ b/src/main/resources/application-dev.yml @@ -36,4 +36,14 @@ logging: jwt: secret: ${JWT_SECRET:test-jwt-secret-key-12345678901234567890} # 운영 시에는 반드시 환경 변수로 설정할 것 access-token-expiration: ${JWT_ACCESS_TOKEN_EXPIRATION:1800} # 30분 (초 단위) - refresh-token-expiration: ${JWT_REFRESH_TOKEN_EXPIRATION:604800} # 7일 (초 단위) \ No newline at end of file + refresh-token-expiration: ${JWT_REFRESH_TOKEN_EXPIRATION:604800} # 7일 (초 단위) + +# 스터디룸 설정 +studyroom: + heartbeat: + timeout-minutes: 5 # Heartbeat 타임아웃 (분) + default: + max-participants: 10 # 기본 최대 참가자 수 + allow-camera: true + allow-audio: true + allow-screen-share: true \ No newline at end of file diff --git a/src/test/java/com/back/domain/studyroom/controller/RoomControllerTest.java b/src/test/java/com/back/domain/studyroom/controller/RoomControllerTest.java new file mode 100644 index 00000000..f7497f40 --- /dev/null +++ b/src/test/java/com/back/domain/studyroom/controller/RoomControllerTest.java @@ -0,0 +1,314 @@ +package com.back.domain.studyroom.controller; + +import com.back.domain.studyroom.dto.*; +import com.back.domain.studyroom.entity.*; +import com.back.domain.studyroom.service.RoomService; +import com.back.domain.user.entity.Role; +import com.back.domain.user.entity.User; +import com.back.domain.user.entity.UserProfile; +import com.back.domain.user.entity.UserStatus; +import com.back.global.common.dto.RsData; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.PageRequest; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; + +import java.util.Arrays; +import java.util.List; +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; + +@ExtendWith(MockitoExtension.class) +@DisplayName("RoomController 테스트") +class RoomControllerTest { + + @Mock + private RoomService roomService; + + @InjectMocks + private RoomController roomController; + + private User testUser; + private Room testRoom; + private RoomMember testMember; + + @BeforeEach + void setUp() { + // 테스트 사용자 생성 + testUser = User.builder() + .id(1L) + .username("testuser") + .email("test@test.com") + .password("password123") + .role(Role.USER) + .userStatus(UserStatus.ACTIVE) + .build(); + + // UserProfile 설정 + UserProfile userProfile = new UserProfile(); + userProfile.setNickname("테스트유저"); + testUser.setUserProfile(userProfile); + + // 테스트 방 생성 + testRoom = Room.create( + "테스트 방", + "테스트 설명", + false, + null, + 10, + testUser, + null + ); + + // 테스트 멤버 생성 + testMember = RoomMember.createHost(testRoom, testUser); + } + + @Test + @DisplayName("방 생성 API 테스트") + void createRoom() { + // given + CreateRoomRequest request = new CreateRoomRequest( + "테스트 방", + "테스트 설명", + false, + null, + 10 + ); + + given(roomService.createRoom( + anyString(), + anyString(), + anyBoolean(), + any(), + anyInt(), + anyLong() + )).willReturn(testRoom); + + // when + ResponseEntity> response = roomController.createRoom(request, "Bearer token"); + + // then + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.CREATED); + assertThat(response.getBody()).isNotNull(); + assertThat(response.getBody().isSuccess()).isTrue(); + assertThat(response.getBody().getData().getTitle()).isEqualTo("테스트 방"); + + verify(roomService, times(1)).createRoom( + anyString(), + anyString(), + anyBoolean(), + any(), + anyInt(), + anyLong() + ); + } + + @Test + @DisplayName("방 입장 API 테스트") + void joinRoom() { + // given + JoinRoomRequest request = new JoinRoomRequest(null); + given(roomService.joinRoom(anyLong(), any(), anyLong())).willReturn(testMember); + + // when + ResponseEntity> response = roomController.joinRoom(1L, request, "Bearer token"); + + // then + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + assertThat(response.getBody()).isNotNull(); + assertThat(response.getBody().isSuccess()).isTrue(); + + verify(roomService, times(1)).joinRoom(anyLong(), any(), anyLong()); + } + + @Test + @DisplayName("방 나가기 API 테스트") + void leaveRoom() { + // given + // when + ResponseEntity> response = roomController.leaveRoom(1L, "Bearer token"); + + // then + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + assertThat(response.getBody()).isNotNull(); + assertThat(response.getBody().isSuccess()).isTrue(); + + verify(roomService, times(1)).leaveRoom(anyLong(), anyLong()); + } + + @Test + @DisplayName("공개 방 목록 조회 API 테스트") + void getRooms() { + // given + Page roomPage = new PageImpl<>( + Arrays.asList(testRoom), + PageRequest.of(0, 20), + 1 + ); + given(roomService.getJoinableRooms(any())).willReturn(roomPage); + + // when + ResponseEntity>> response = roomController.getRooms(0, 20); + + // then + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + assertThat(response.getBody()).isNotNull(); + assertThat(response.getBody().isSuccess()).isTrue(); + assertThat(response.getBody().getData().get("rooms")).isNotNull(); + + verify(roomService, times(1)).getJoinableRooms(any()); + } + + @Test + @DisplayName("방 상세 정보 조회 API 테스트") + void getRoomDetail() { + // given + given(roomService.getRoomDetail(anyLong(), anyLong())).willReturn(testRoom); + given(roomService.getRoomMembers(anyLong(), anyLong())).willReturn(Arrays.asList(testMember)); + + // when + ResponseEntity> response = roomController.getRoomDetail(1L, "Bearer token"); + + // then + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + assertThat(response.getBody()).isNotNull(); + assertThat(response.getBody().isSuccess()).isTrue(); + assertThat(response.getBody().getData().getTitle()).isEqualTo("테스트 방"); + + verify(roomService, times(1)).getRoomDetail(anyLong(), anyLong()); + verify(roomService, times(1)).getRoomMembers(anyLong(), anyLong()); + } + + @Test + @DisplayName("내 참여 방 목록 조회 API 테스트") + void getMyRooms() { + // given + // Room에 ID 설정 (리플렉션 사용) + try { + java.lang.reflect.Field idField = testRoom.getClass().getSuperclass().getDeclaredField("id"); + idField.setAccessible(true); + idField.set(testRoom, 1L); + } catch (Exception e) { + throw new RuntimeException(e); + } + + given(roomService.getUserRooms(anyLong())).willReturn(Arrays.asList(testRoom)); + given(roomService.getUserRoomRole(eq(1L), anyLong())).willReturn(RoomRole.HOST); + + // when + ResponseEntity>> response = roomController.getMyRooms("Bearer token"); + + // then + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + assertThat(response.getBody()).isNotNull(); + assertThat(response.getBody().isSuccess()).isTrue(); + assertThat(response.getBody().getData()).hasSize(1); + assertThat(response.getBody().getData().get(0).getTitle()).isEqualTo("테스트 방"); + + verify(roomService, times(1)).getUserRooms(anyLong()); + } + + @Test + @DisplayName("방 설정 수정 API 테스트") + void updateRoom() { + // given + UpdateRoomSettingsRequest request = new UpdateRoomSettingsRequest( + "변경된 제목", + "변경된 설명", + 15, + true, + true, + false + ); + + // when + ResponseEntity> response = roomController.updateRoom(1L, request, "Bearer token"); + + // then + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + assertThat(response.getBody()).isNotNull(); + assertThat(response.getBody().isSuccess()).isTrue(); + + verify(roomService, times(1)).updateRoomSettings( + anyLong(), + anyString(), + anyString(), + anyInt(), + anyBoolean(), + anyBoolean(), + anyBoolean(), + anyLong() + ); + } + + @Test + @DisplayName("방 종료 API 테스트") + void deleteRoom() { + // given + // when + ResponseEntity> response = roomController.deleteRoom(1L, "Bearer token"); + + // then + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + assertThat(response.getBody()).isNotNull(); + assertThat(response.getBody().isSuccess()).isTrue(); + + verify(roomService, times(1)).terminateRoom(anyLong(), anyLong()); + } + + @Test + @DisplayName("방 멤버 목록 조회 API 테스트") + void getRoomMembers() { + // given + given(roomService.getRoomMembers(anyLong(), anyLong())).willReturn(Arrays.asList(testMember)); + + // when + ResponseEntity>> response = roomController.getRoomMembers(1L, "Bearer token"); + + // then + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + assertThat(response.getBody()).isNotNull(); + assertThat(response.getBody().isSuccess()).isTrue(); + assertThat(response.getBody().getData()).hasSize(1); + assertThat(response.getBody().getData().get(0).getNickname()).isEqualTo("테스트유저"); + + verify(roomService, times(1)).getRoomMembers(anyLong(), anyLong()); + } + + @Test + @DisplayName("인기 방 목록 조회 API 테스트") + void getPopularRooms() { + // given + Page roomPage = new PageImpl<>( + Arrays.asList(testRoom), + PageRequest.of(0, 20), + 1 + ); + given(roomService.getPopularRooms(any())).willReturn(roomPage); + + // when + ResponseEntity>> response = roomController.getPopularRooms(0, 20); + + // then + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + assertThat(response.getBody()).isNotNull(); + assertThat(response.getBody().isSuccess()).isTrue(); + assertThat(response.getBody().getData().get("rooms")).isNotNull(); + + verify(roomService, times(1)).getPopularRooms(any()); + } +} diff --git a/src/test/java/com/back/domain/studyroom/service/RoomServiceTest.java b/src/test/java/com/back/domain/studyroom/service/RoomServiceTest.java new file mode 100644 index 00000000..4ccd73dd --- /dev/null +++ b/src/test/java/com/back/domain/studyroom/service/RoomServiceTest.java @@ -0,0 +1,390 @@ +package com.back.domain.studyroom.service; + +import com.back.domain.studyroom.config.StudyRoomProperties; +import com.back.domain.studyroom.entity.*; +import com.back.domain.studyroom.repository.RoomMemberRepository; +import com.back.domain.studyroom.repository.RoomRepository; +import com.back.domain.user.entity.Role; +import com.back.domain.user.entity.User; +import com.back.domain.user.entity.UserProfile; +import com.back.domain.user.entity.UserStatus; +import com.back.domain.user.repository.UserRepository; +import com.back.global.exception.CustomException; +import com.back.global.exception.ErrorCode; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; + +import java.util.Arrays; +import java.util.List; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.BDDMockito.*; + +@ExtendWith(MockitoExtension.class) +@DisplayName("RoomService 테스트") +class RoomServiceTest { + + @Mock + private RoomRepository roomRepository; + + @Mock + private RoomMemberRepository roomMemberRepository; + + @Mock + private UserRepository userRepository; + + @Mock + private StudyRoomProperties properties; + + @InjectMocks + private RoomService roomService; + + private User testUser; + private Room testRoom; + private RoomMember testMember; + + @BeforeEach + void setUp() { + // 테스트 사용자 생성 + testUser = User.builder() + .id(1L) + .username("testuser") + .email("test@test.com") + .password("password123") + .role(Role.USER) + .userStatus(UserStatus.ACTIVE) + .build(); + + // UserProfile 설정 (nickname을 위해) + UserProfile userProfile = new UserProfile(); + userProfile.setNickname("테스트유저"); + testUser.setUserProfile(userProfile); + + // 테스트 방 생성 + testRoom = Room.create( + "테스트 방", + "테스트 설명", + false, + null, + 10, + testUser, + null + ); + + // 테스트 멤버 생성 + testMember = RoomMember.createHost(testRoom, testUser); + } + + @Test + @DisplayName("방 생성 - 성공") + void createRoom_Success() { + // given + given(userRepository.findById(1L)).willReturn(Optional.of(testUser)); + given(roomRepository.save(any(Room.class))).willReturn(testRoom); + given(roomMemberRepository.save(any(RoomMember.class))).willReturn(testMember); + + // when + Room createdRoom = roomService.createRoom( + "테스트 방", + "테스트 설명", + false, + null, + 10, + 1L + ); + + // then + assertThat(createdRoom).isNotNull(); + assertThat(createdRoom.getTitle()).isEqualTo("테스트 방"); + assertThat(createdRoom.getDescription()).isEqualTo("테스트 설명"); + verify(roomRepository, times(1)).save(any(Room.class)); + verify(roomMemberRepository, times(1)).save(any(RoomMember.class)); + } + + @Test + @DisplayName("방 생성 - 사용자 없음 실패") + void createRoom_UserNotFound() { + // given + given(userRepository.findById(999L)).willReturn(Optional.empty()); + + // when & then + assertThatThrownBy(() -> roomService.createRoom( + "테스트 방", + "테스트 설명", + false, + null, + 10, + 999L + )) + .isInstanceOf(CustomException.class) + .hasFieldOrPropertyWithValue("errorCode", ErrorCode.USER_NOT_FOUND); + } + + @Test + @DisplayName("방 입장 - 성공") + void joinRoom_Success() { + // given + given(roomRepository.findByIdWithLock(1L)).willReturn(Optional.of(testRoom)); + given(userRepository.findById(2L)).willReturn(Optional.of(testUser)); + given(roomMemberRepository.findByRoomIdAndUserId(1L, 2L)).willReturn(Optional.empty()); + given(roomMemberRepository.save(any(RoomMember.class))).willReturn(testMember); + + // when + RoomMember joinedMember = roomService.joinRoom(1L, null, 2L); + + // then + assertThat(joinedMember).isNotNull(); + verify(roomMemberRepository, times(1)).save(any(RoomMember.class)); + } + + @Test + @DisplayName("방 입장 - 방 없음 실패") + void joinRoom_RoomNotFound() { + // given + given(roomRepository.findByIdWithLock(999L)).willReturn(Optional.empty()); + + // when & then + assertThatThrownBy(() -> roomService.joinRoom(999L, null, 1L)) + .isInstanceOf(CustomException.class) + .hasFieldOrPropertyWithValue("errorCode", ErrorCode.ROOM_NOT_FOUND); + } + + @Test + @DisplayName("방 입장 - 비밀번호 틀림") + void joinRoom_WrongPassword() { + // given + Room privateRoom = Room.create( + "비공개 방", + "설명", + true, + "1234", + 10, + testUser, + null + ); + given(roomRepository.findByIdWithLock(1L)).willReturn(Optional.of(privateRoom)); + + // when & then + assertThatThrownBy(() -> roomService.joinRoom(1L, "wrong", 1L)) + .isInstanceOf(CustomException.class) + .hasFieldOrPropertyWithValue("errorCode", ErrorCode.ROOM_PASSWORD_INCORRECT); + } + + @Test + @DisplayName("방 나가기 - 성공") + void leaveRoom_Success() { + // given + testMember.updateOnlineStatus(true); + given(roomRepository.findById(1L)).willReturn(Optional.of(testRoom)); + given(roomMemberRepository.findByRoomIdAndUserId(1L, 1L)).willReturn(Optional.of(testMember)); + + // when + roomService.leaveRoom(1L, 1L); + + // then + verify(roomMemberRepository, times(1)).findByRoomIdAndUserId(1L, 1L); + } + + @Test + @DisplayName("입장 가능한 공개 방 목록 조회") + void getJoinableRooms_Success() { + // given + Pageable pageable = PageRequest.of(0, 10); + List rooms = Arrays.asList(testRoom); + Page roomPage = new PageImpl<>(rooms, pageable, 1); + + given(roomRepository.findJoinablePublicRooms(pageable)).willReturn(roomPage); + + // when + Page result = roomService.getJoinableRooms(pageable); + + // then + assertThat(result).isNotNull(); + assertThat(result.getContent()).hasSize(1); + assertThat(result.getContent().get(0).getTitle()).isEqualTo("테스트 방"); + verify(roomRepository, times(1)).findJoinablePublicRooms(pageable); + } + + @Test + @DisplayName("방 상세 정보 조회 - 성공") + void getRoomDetail_Success() { + // given + given(roomRepository.findById(1L)).willReturn(Optional.of(testRoom)); + // 공개방이므로 existsByRoomIdAndUserId는 호출되지 않음 - stub 제거 + + // when + Room result = roomService.getRoomDetail(1L, 1L); + + // then + assertThat(result).isNotNull(); + assertThat(result.getTitle()).isEqualTo("테스트 방"); + } + + @Test + @DisplayName("방 상세 정보 조회 - 비공개 방 권한 없음") + void getRoomDetail_PrivateRoomForbidden() { + // given + Room privateRoom = Room.create( + "비공개 방", + "설명", + true, + "1234", + 10, + testUser, + null + ); + given(roomRepository.findById(1L)).willReturn(Optional.of(privateRoom)); + given(roomMemberRepository.existsByRoomIdAndUserId(1L, 2L)).willReturn(false); + + // when & then + assertThatThrownBy(() -> roomService.getRoomDetail(1L, 2L)) + .isInstanceOf(CustomException.class) + .hasFieldOrPropertyWithValue("errorCode", ErrorCode.ROOM_FORBIDDEN); + } + + @Test + @DisplayName("방 설정 변경 - 성공") + void updateRoomSettings_Success() { + // given + given(roomRepository.findById(1L)).willReturn(Optional.of(testRoom)); + + // when + roomService.updateRoomSettings( + 1L, + "변경된 제목", + "변경된 설명", + 15, + true, + true, + false, + 1L + ); + + // then + assertThat(testRoom.getTitle()).isEqualTo("변경된 제목"); + assertThat(testRoom.getDescription()).isEqualTo("변경된 설명"); + assertThat(testRoom.getMaxParticipants()).isEqualTo(15); + } + + @Test + @DisplayName("방 설정 변경 - 방장 권한 없음") + void updateRoomSettings_NotOwner() { + // given + given(roomRepository.findById(1L)).willReturn(Optional.of(testRoom)); + + // when & then + assertThatThrownBy(() -> roomService.updateRoomSettings( + 1L, + "변경된 제목", + "변경된 설명", + 15, + true, + true, + false, + 999L // 다른 사용자 + )) + .isInstanceOf(CustomException.class) + .hasFieldOrPropertyWithValue("errorCode", ErrorCode.NOT_ROOM_MANAGER); + } + + @Test + @DisplayName("방 종료 - 성공") + void terminateRoom_Success() { + // given + given(roomRepository.findById(1L)).willReturn(Optional.of(testRoom)); + willDoNothing().given(roomMemberRepository).disconnectAllMembers(1L); + + // when + roomService.terminateRoom(1L, 1L); + + // then + assertThat(testRoom.getStatus()).isEqualTo(RoomStatus.TERMINATED); + assertThat(testRoom.isActive()).isFalse(); + verify(roomMemberRepository, times(1)).disconnectAllMembers(1L); + } + + @Test + @DisplayName("방 종료 - 방장 권한 없음") + void terminateRoom_NotOwner() { + // given + given(roomRepository.findById(1L)).willReturn(Optional.of(testRoom)); + + // when & then + assertThatThrownBy(() -> roomService.terminateRoom(1L, 999L)) + .isInstanceOf(CustomException.class) + .hasFieldOrPropertyWithValue("errorCode", ErrorCode.NOT_ROOM_MANAGER); + } + + @Test + @DisplayName("인기 방 목록 조회") + void getPopularRooms_Success() { + // given + Pageable pageable = PageRequest.of(0, 10); + List rooms = Arrays.asList(testRoom); + Page roomPage = new PageImpl<>(rooms, pageable, 1); + + given(roomRepository.findPopularRooms(pageable)).willReturn(roomPage); + + // when + Page result = roomService.getPopularRooms(pageable); + + // then + assertThat(result).isNotNull(); + assertThat(result.getContent()).hasSize(1); + verify(roomRepository, times(1)).findPopularRooms(pageable); + } + + @Test + @DisplayName("멤버 추방 - 성공") + void kickMember_Success() { + // given + RoomMember hostMember = RoomMember.createHost(testRoom, testUser); + + User targetUser = User.builder() + .id(2L) + .username("target") + .email("target@test.com") + .role(Role.USER) + .build(); + UserProfile targetProfile = new UserProfile(); + targetProfile.setNickname("대상유저"); + targetUser.setUserProfile(targetProfile); + + RoomMember targetMember = RoomMember.createVisitor(testRoom, targetUser); + + given(roomMemberRepository.findByRoomIdAndUserId(1L, 1L)).willReturn(Optional.of(hostMember)); + given(roomMemberRepository.findByRoomIdAndUserId(1L, 2L)).willReturn(Optional.of(targetMember)); + given(roomRepository.findById(1L)).willReturn(Optional.of(testRoom)); + + // when + roomService.kickMember(1L, 2L, 1L); + + // then + verify(roomMemberRepository, times(1)).findByRoomIdAndUserId(1L, 2L); + } + + @Test + @DisplayName("멤버 추방 - 권한 없음") + void kickMember_NoPermission() { + // given + RoomMember visitorMember = RoomMember.createVisitor(testRoom, testUser); + + given(roomMemberRepository.findByRoomIdAndUserId(1L, 1L)).willReturn(Optional.of(visitorMember)); + + // when & then + assertThatThrownBy(() -> roomService.kickMember(1L, 2L, 1L)) + .isInstanceOf(CustomException.class) + .hasFieldOrPropertyWithValue("errorCode", ErrorCode.NOT_ROOM_MANAGER); + } +}