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 2b7b4b7c..eeac3049 100644 --- a/src/main/java/com/back/domain/studyroom/controller/RoomController.java +++ b/src/main/java/com/back/domain/studyroom/controller/RoomController.java @@ -126,9 +126,140 @@ public ResponseEntity> leaveRoom( .body(RsData.success("방 퇴장 완료", null)); } - @GetMapping + @GetMapping("/all") + @Operation( + summary = "모든 방 목록 조회", + description = "공개 방과 비공개 방 전체를 조회합니다. 비공개 방은 제목과 방장 정보가 마스킹됩니다. 열린 방(WAITING, ACTIVE)이 우선 표시되고, 닫힌 방(PAUSED, TERMINATED)은 뒤로 밀립니다." + ) + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "조회 성공"), + @ApiResponse(responseCode = "401", description = "인증 실패") + }) + public ResponseEntity>> getAllRooms( + @Parameter(description = "페이지 번호 (0부터 시작)") @RequestParam(defaultValue = "0") int page, + @Parameter(description = "페이지 크기") @RequestParam(defaultValue = "20") int size) { + + Pageable pageable = PageRequest.of(page, size); + Page rooms = roomService.getAllRooms(pageable); + + // 비공개 방 마스킹 포함한 변환 + List roomList = roomService.toRoomResponseListWithMasking(rooms.getContent()); + + Map response = new HashMap<>(); + response.put("rooms", roomList); + response.put("page", rooms.getNumber()); + response.put("size", rooms.getSize()); + response.put("totalElements", rooms.getTotalElements()); + response.put("totalPages", rooms.getTotalPages()); + response.put("hasNext", rooms.hasNext()); + + return ResponseEntity + .status(HttpStatus.OK) + .body(RsData.success("모든 방 목록 조회 완료", response)); + } + + @GetMapping("/public") @Operation( summary = "공개 방 목록 조회", + description = "공개 방 전체를 조회합니다. includeInactive=true로 설정하면 닫힌 방도 포함됩니다 (기본값: true). 열린 방이 우선 표시됩니다." + ) + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "조회 성공"), + @ApiResponse(responseCode = "401", description = "인증 실패") + }) + public ResponseEntity>> getPublicRooms( + @Parameter(description = "페이지 번호 (0부터 시작)") @RequestParam(defaultValue = "0") int page, + @Parameter(description = "페이지 크기") @RequestParam(defaultValue = "20") int size, + @Parameter(description = "닫힌 방 포함 여부") @RequestParam(defaultValue = "true") boolean includeInactive) { + + Pageable pageable = PageRequest.of(page, size); + Page rooms = roomService.getPublicRooms(includeInactive, pageable); + + List roomList = roomService.toRoomResponseList(rooms.getContent()); + + Map response = new HashMap<>(); + response.put("rooms", roomList); + response.put("page", rooms.getNumber()); + response.put("size", rooms.getSize()); + response.put("totalElements", rooms.getTotalElements()); + response.put("totalPages", rooms.getTotalPages()); + response.put("hasNext", rooms.hasNext()); + + return ResponseEntity + .status(HttpStatus.OK) + .body(RsData.success("공개 방 목록 조회 완료", response)); + } + + @GetMapping("/private") + @Operation( + summary = "내 비공개 방 목록 조회", + description = "내가 멤버로 등록된 비공개 방을 조회합니다. includeInactive=true로 설정하면 닫힌 방도 포함됩니다 (기본값: true). 열린 방이 우선 표시됩니다." + ) + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "조회 성공"), + @ApiResponse(responseCode = "401", description = "인증 실패") + }) + public ResponseEntity>> getMyPrivateRooms( + @Parameter(description = "페이지 번호 (0부터 시작)") @RequestParam(defaultValue = "0") int page, + @Parameter(description = "페이지 크기") @RequestParam(defaultValue = "20") int size, + @Parameter(description = "닫힌 방 포함 여부") @RequestParam(defaultValue = "true") boolean includeInactive) { + + Long currentUserId = currentUser.getUserId(); + + Pageable pageable = PageRequest.of(page, size); + Page rooms = roomService.getMyPrivateRooms(currentUserId, includeInactive, pageable); + + List roomList = roomService.toRoomResponseList(rooms.getContent()); + + Map response = new HashMap<>(); + response.put("rooms", roomList); + response.put("page", rooms.getNumber()); + response.put("size", rooms.getSize()); + response.put("totalElements", rooms.getTotalElements()); + response.put("totalPages", rooms.getTotalPages()); + response.put("hasNext", rooms.hasNext()); + + return ResponseEntity + .status(HttpStatus.OK) + .body(RsData.success("내 비공개 방 목록 조회 완료", response)); + } + + @GetMapping("/my/hosting") + @Operation( + summary = "내가 호스트인 방 목록 조회", + description = "내가 방장으로 있는 방을 조회합니다. 열린 방이 우선 표시되고, 닫힌 방은 뒤로 밀립니다." + ) + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "조회 성공"), + @ApiResponse(responseCode = "401", description = "인증 실패") + }) + public ResponseEntity>> getMyHostingRooms( + @Parameter(description = "페이지 번호 (0부터 시작)") @RequestParam(defaultValue = "0") int page, + @Parameter(description = "페이지 크기") @RequestParam(defaultValue = "20") int size) { + + Long currentUserId = currentUser.getUserId(); + + Pageable pageable = PageRequest.of(page, size); + Page rooms = roomService.getMyHostingRooms(currentUserId, pageable); + + List roomList = roomService.toRoomResponseList(rooms.getContent()); + + Map response = new HashMap<>(); + response.put("rooms", roomList); + response.put("page", rooms.getNumber()); + response.put("size", rooms.getSize()); + response.put("totalElements", rooms.getTotalElements()); + response.put("totalPages", rooms.getTotalPages()); + response.put("hasNext", rooms.hasNext()); + + return ResponseEntity + .status(HttpStatus.OK) + .body(RsData.success("내가 호스트인 방 목록 조회 완료", response)); + } + + @GetMapping + @Operation( + summary = "입장 가능한 공개 방 목록 조회 (기존)", description = "입장 가능한 공개 스터디 룸 목록을 페이징하여 조회합니다. 최신 생성 순으로 정렬됩니다." ) @ApiResponses({ @@ -262,6 +393,54 @@ public ResponseEntity> deleteRoom( .body(RsData.success("방 종료 완료", null)); } + @PutMapping("/{roomId}/pause") + @Operation( + summary = "방 일시정지", + description = "방을 일시정지 상태로 변경합니다. 일시정지된 방은 입장할 수 없으며, 방 목록에서 뒤로 밀립니다. 방장만 실행 가능합니다." + ) + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "일시정지 성공"), + @ApiResponse(responseCode = "400", description = "이미 종료되었거나 일시정지 불가능한 상태"), + @ApiResponse(responseCode = "403", description = "방장 권한 없음"), + @ApiResponse(responseCode = "404", description = "존재하지 않는 방"), + @ApiResponse(responseCode = "401", description = "인증 실패") + }) + public ResponseEntity> pauseRoom( + @Parameter(description = "방 ID", required = true) @PathVariable Long roomId) { + + Long currentUserId = currentUser.getUserId(); + + roomService.pauseRoom(roomId, currentUserId); + + return ResponseEntity + .status(HttpStatus.OK) + .body(RsData.success("방 일시정지 완료", null)); + } + + @PutMapping("/{roomId}/activate") + @Operation( + summary = "방 활성화/재개", + description = "일시정지된 방을 다시 활성화합니다. 활성화된 방은 다시 입장 가능하며, 방 목록 앞쪽에 표시됩니다. 방장만 실행 가능합니다." + ) + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "활성화 성공"), + @ApiResponse(responseCode = "400", description = "이미 종료되었거나 활성화 불가능한 상태"), + @ApiResponse(responseCode = "403", description = "방장 권한 없음"), + @ApiResponse(responseCode = "404", description = "존재하지 않는 방"), + @ApiResponse(responseCode = "401", description = "인증 실패") + }) + public ResponseEntity> activateRoom( + @Parameter(description = "방 ID", required = true) @PathVariable Long roomId) { + + Long currentUserId = currentUser.getUserId(); + + roomService.activateRoom(roomId, currentUserId); + + return ResponseEntity + .status(HttpStatus.OK) + .body(RsData.success("방 활성화 완료", null)); + } + @GetMapping("/{roomId}/members") @Operation( summary = "방 멤버 목록 조회", diff --git a/src/main/java/com/back/domain/studyroom/dto/MyRoomResponse.java b/src/main/java/com/back/domain/studyroom/dto/MyRoomResponse.java index 7a5c710d..548a6f09 100644 --- a/src/main/java/com/back/domain/studyroom/dto/MyRoomResponse.java +++ b/src/main/java/com/back/domain/studyroom/dto/MyRoomResponse.java @@ -14,6 +14,7 @@ public class MyRoomResponse { private Long roomId; private String title; private String description; + private Boolean isPrivate; // 비공개 방 여부 (UI에서 🔒 아이콘 표시용) private int currentParticipants; private int maxParticipants; private RoomStatus status; @@ -25,6 +26,7 @@ public static MyRoomResponse of(Room room, long currentParticipants, RoomRole my .roomId(room.getId()) .title(room.getTitle()) .description(room.getDescription() != null ? room.getDescription() : "") + .isPrivate(room.isPrivate()) // 비공개 방 여부 .currentParticipants((int) currentParticipants) // Redis에서 조회한 실시간 값 .maxParticipants(room.getMaxParticipants()) .status(room.getStatus()) diff --git a/src/main/java/com/back/domain/studyroom/dto/RoomResponse.java b/src/main/java/com/back/domain/studyroom/dto/RoomResponse.java index 1f22a5aa..e35968aa 100644 --- a/src/main/java/com/back/domain/studyroom/dto/RoomResponse.java +++ b/src/main/java/com/back/domain/studyroom/dto/RoomResponse.java @@ -13,6 +13,7 @@ public class RoomResponse { private Long roomId; private String title; private String description; + private Boolean isPrivate; // 비공개 방 여부 (UI에서 🔒 아이콘 표시용) private int currentParticipants; private int maxParticipants; private RoomStatus status; @@ -29,6 +30,7 @@ public static RoomResponse from(Room room, long currentParticipants) { .roomId(room.getId()) .title(room.getTitle()) .description(room.getDescription() != null ? room.getDescription() : "") + .isPrivate(room.isPrivate()) // 비공개 방 여부 .currentParticipants((int) currentParticipants) // Redis에서 조회한 실시간 값 .maxParticipants(room.getMaxParticipants()) .status(room.getStatus()) @@ -39,4 +41,25 @@ public static RoomResponse from(Room room, long currentParticipants) { .allowScreenShare(room.isAllowScreenShare()) .build(); } + + /** + * 비공개 방 정보 마스킹 버전 (전체 목록에서 볼 때 사용) + * "모든 방" 조회 시 사용 - 비공개 방의 민감한 정보를 숨김 + */ + public static RoomResponse fromMasked(Room room) { + return RoomResponse.builder() + .roomId(room.getId()) + .title("🔒 비공개 방") // 제목 마스킹 + .description("비공개 방입니다") // 설명 마스킹 + .isPrivate(true) + .currentParticipants(0) // 참가자 수 숨김 + .maxParticipants(0) // 정원 숨김 + .status(room.getStatus()) + .createdBy("익명") // 방장 정보 숨김 + .createdAt(room.getCreatedAt()) + .allowCamera(false) // RTC 정보 숨김 + .allowAudio(false) + .allowScreenShare(false) + .build(); + } } diff --git a/src/main/java/com/back/domain/studyroom/repository/RoomRepositoryCustom.java b/src/main/java/com/back/domain/studyroom/repository/RoomRepositoryCustom.java index 5673b101..31ae6f13 100644 --- a/src/main/java/com/back/domain/studyroom/repository/RoomRepositoryCustom.java +++ b/src/main/java/com/back/domain/studyroom/repository/RoomRepositoryCustom.java @@ -47,4 +47,41 @@ public interface RoomRepositoryCustom { * 비관적 락으로 방 조회 (동시성 제어용) */ Optional findByIdWithLock(Long roomId); + + /** + * 모든 방 조회 (공개 + 비공개 전체) + * 정렬: 열린 방(WAITING, ACTIVE) 우선 → 최신순 + * 비공개 방은 정보 마스킹하여 반환 + * @param pageable 페이징 정보 + * @return 페이징된 방 목록 + */ + Page findAllRooms(Pageable pageable); + + /** + * 공개 방 전체 조회 + * 정렬: 열린 방 우선 → 최신순 + * @param includeInactive 닫힌 방(PAUSED, TERMINATED) 포함 여부 + * @param pageable 페이징 정보 + * @return 페이징된 공개 방 목록 + */ + Page findPublicRoomsWithStatus(boolean includeInactive, Pageable pageable); + + /** + * 내가 멤버인 비공개 방 조회 + * 정렬: 열린 방 우선 → 최신순 + * @param userId 사용자 ID + * @param includeInactive 닫힌 방 포함 여부 + * @param pageable 페이징 정보 + * @return 페이징된 비공개 방 목록 + */ + Page findMyPrivateRooms(Long userId, boolean includeInactive, Pageable pageable); + + /** + * 내가 호스트(방장)인 방 조회 + * 정렬: 열린 방 우선 → 최신순 + * @param userId 사용자 ID + * @param pageable 페이징 정보 + * @return 페이징된 방 목록 + */ + Page findRoomsByHostId(Long userId, Pageable pageable); } diff --git a/src/main/java/com/back/domain/studyroom/repository/RoomRepositoryImpl.java b/src/main/java/com/back/domain/studyroom/repository/RoomRepositoryImpl.java index d9ab9ac0..48714b45 100644 --- a/src/main/java/com/back/domain/studyroom/repository/RoomRepositoryImpl.java +++ b/src/main/java/com/back/domain/studyroom/repository/RoomRepositoryImpl.java @@ -270,4 +270,157 @@ public Optional findByIdWithLock(Long roomId) { return Optional.ofNullable(foundRoom); } + + /** + * 모든 방 조회 (공개 + 비공개 전체) + * 조회 조건: + * - 모든 방 (공개 + 비공개) + * 정렬: + * 1. 열린 방(WAITING, ACTIVE) 우선 + * 2. 닫힌 방(PAUSED, TERMINATED) 뒤로 + * 3. 최신 생성순 + * + * 비공개 방은 컨트롤러/서비스 레이어에서 정보 마스킹 합니당 + */ + @Override + public Page findAllRooms(Pageable pageable) { + List rooms = queryFactory + .selectFrom(room) + .leftJoin(room.createdBy, user).fetchJoin() + .orderBy( + // 열린 방 우선 (0), 닫힌 방 뒤로 (1) + room.status.when(RoomStatus.WAITING).then(0) + .when(RoomStatus.ACTIVE).then(0) + .otherwise(1).asc(), + room.createdAt.desc() // 최신순 + ) + .offset(pageable.getOffset()) + .limit(pageable.getPageSize()) + .fetch(); + + // 전체 개수 조회 + Long totalCount = queryFactory + .select(room.count()) + .from(room) + .fetchOne(); + + return new PageImpl<>(rooms, pageable, totalCount != null ? totalCount : 0); + } + + /** + * 공개 방 전체 조회 + * 조회 조건: + * - isPrivate = false + * - includeInactive에 따라 닫힌 방 포함 여부 결정 + * 정렬: 열린 방 우선 → 최신순 + */ + @Override + public Page findPublicRoomsWithStatus(boolean includeInactive, Pageable pageable) { + BooleanExpression whereClause = room.isPrivate.eq(false); + + // 닫힌 방 제외 옵션 + if (!includeInactive) { + whereClause = whereClause.and( + room.status.in(RoomStatus.WAITING, RoomStatus.ACTIVE) + ); + } + + List rooms = queryFactory + .selectFrom(room) + .leftJoin(room.createdBy, user).fetchJoin() + .where(whereClause) + .orderBy( + room.status.when(RoomStatus.WAITING).then(0) + .when(RoomStatus.ACTIVE).then(0) + .otherwise(1).asc(), + room.createdAt.desc() + ) + .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 = true + * - 내가 멤버로 등록된 방 + * - includeInactive에 따라 닫힌 방 포함 여부 결정 + * 정렬: 열린 방 우선 → 최신순 + */ + @Override + public Page findMyPrivateRooms(Long userId, boolean includeInactive, Pageable pageable) { + BooleanExpression whereClause = room.isPrivate.eq(true) + .and(roomMember.user.id.eq(userId)); + + // 닫힌 방 제외 옵션 + if (!includeInactive) { + whereClause = whereClause.and( + room.status.in(RoomStatus.WAITING, RoomStatus.ACTIVE) + ); + } + + List rooms = queryFactory + .selectFrom(room) + .leftJoin(room.createdBy, user).fetchJoin() + .join(room.roomMembers, roomMember) + .where(whereClause) + .orderBy( + room.status.when(RoomStatus.WAITING).then(0) + .when(RoomStatus.ACTIVE).then(0) + .otherwise(1).asc(), + room.createdAt.desc() + ) + .offset(pageable.getOffset()) + .limit(pageable.getPageSize()) + .fetch(); + + Long totalCount = queryFactory + .select(room.count()) + .from(room) + .join(room.roomMembers, roomMember) + .where(whereClause) + .fetchOne(); + + return new PageImpl<>(rooms, pageable, totalCount != null ? totalCount : 0); + } + + /** + * 내가 호스트(방장)인 방 조회 + * 조회 조건: + * - room.createdBy.id = userId + * 정렬: 열린 방 우선 → 최신순 + */ + @Override + public Page findRoomsByHostId(Long userId, Pageable pageable) { + List rooms = queryFactory + .selectFrom(room) + .leftJoin(room.createdBy, user).fetchJoin() + .where(room.createdBy.id.eq(userId)) + .orderBy( + room.status.when(RoomStatus.WAITING).then(0) + .when(RoomStatus.ACTIVE).then(0) + .otherwise(1).asc(), + room.createdAt.desc() + ) + .offset(pageable.getOffset()) + .limit(pageable.getPageSize()) + .fetch(); + + Long totalCount = queryFactory + .select(room.count()) + .from(room) + .where(room.createdBy.id.eq(userId)) + .fetchOne(); + + return new PageImpl<>(rooms, pageable, totalCount != null ? totalCount : 0); + } } 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 fd2281d7..f6d6db64 100644 --- a/src/main/java/com/back/domain/studyroom/service/RoomService.java +++ b/src/main/java/com/back/domain/studyroom/service/RoomService.java @@ -1,6 +1,7 @@ package com.back.domain.studyroom.service; import com.back.domain.studyroom.config.StudyRoomProperties; +import com.back.domain.studyroom.dto.RoomResponse; import com.back.domain.studyroom.entity.*; import com.back.domain.studyroom.repository.*; import com.back.domain.user.entity.User; @@ -183,6 +184,69 @@ public Page getJoinableRooms(Pageable pageable) { return roomRepository.findJoinablePublicRooms(pageable); } + /** + * 모든 방 조회 (공개 + 비공개 전체) + * 비공개 방은 정보 마스킹 + */ + public Page getAllRooms(Pageable pageable) { + return roomRepository.findAllRooms(pageable); + } + + /** + * 공개 방 전체 조회 + * @param includeInactive 닫힌 방 포함 여부 (기본: true) + */ + public Page getPublicRooms(boolean includeInactive, Pageable pageable) { + return roomRepository.findPublicRoomsWithStatus(includeInactive, pageable); + } + + /** + * 내가 멤버인 비공개 방 조회 + * @param includeInactive 닫힌 방 포함 여부 (기본: true) + */ + public Page getMyPrivateRooms(Long userId, boolean includeInactive, Pageable pageable) { + return roomRepository.findMyPrivateRooms(userId, includeInactive, pageable); + } + + /** + * 내가 호스트인 방 조회 + */ + public Page getMyHostingRooms(Long userId, Pageable pageable) { + return roomRepository.findRoomsByHostId(userId, pageable); + } + + /** + * 모든 방을 RoomResponse로 변환 (비공개 방 마스킹 포함) + * @param rooms 방 목록 + * @return 마스킹된 RoomResponse 리스트 + */ + public java.util.List toRoomResponseListWithMasking(java.util.List rooms) { + java.util.List roomIds = rooms.stream() + .map(Room::getId) + .collect(java.util.stream.Collectors.toList()); + + // Redis에서 참가자 수 일괄 조회 + java.util.Map participantCounts = roomIds.stream() + .collect(java.util.stream.Collectors.toMap( + roomId -> roomId, + roomId -> roomParticipantService.getParticipantCount(roomId) + )); + + return rooms.stream() + .map(room -> { + long count = participantCounts.getOrDefault(room.getId(), 0L); + + // 비공개 방이면 마스킹된 버전 반환 + if (room.isPrivate()) { + return RoomResponse.fromMasked(room); + } + + // 공개 방은 일반 버전 반환 + return RoomResponse.from(room, count); + }) + .collect(java.util.stream.Collectors.toList()); + } + public Room getRoomDetail(Long roomId, Long userId) { Room room = roomRepository.findById(roomId) .orElseThrow(() -> new CustomException(ErrorCode.ROOM_NOT_FOUND)); @@ -245,6 +309,40 @@ public void terminateRoom(Long roomId, Long userId) { roomId, userId, onlineUserIds.size()); } + /** + * 방 일시정지 (방장만 가능) + */ + @Transactional + public void pauseRoom(Long roomId, Long userId) { + Room room = roomRepository.findById(roomId) + .orElseThrow(() -> new CustomException(ErrorCode.ROOM_NOT_FOUND)); + + if (!room.isOwner(userId)) { + throw new CustomException(ErrorCode.NOT_ROOM_MANAGER); + } + + room.pause(); + + log.info("방 일시정지 완료 - RoomId: {}, UserId: {}", roomId, userId); + } + + /** + * 방 재개/활성화 (방장만 가능) + */ + @Transactional + public void activateRoom(Long roomId, Long userId) { + Room room = roomRepository.findById(roomId) + .orElseThrow(() -> new CustomException(ErrorCode.ROOM_NOT_FOUND)); + + if (!room.isOwner(userId)) { + throw new CustomException(ErrorCode.NOT_ROOM_MANAGER); + } + + room.activate(); + + log.info("방 활성화 완료 - RoomId: {}, UserId: {}", roomId, userId); + } + /** * 멤버 역할 변경 * 1. 방장만 역할 변경 가능