diff --git a/src/main/java/com/back/domain/studyroom/controller/RoomController.java b/src/main/java/com/back/domain/studyroom/controller/RoomController.java new file mode 100644 index 00000000..6a5729d3 --- /dev/null +++ b/src/main/java/com/back/domain/studyroom/controller/RoomController.java @@ -0,0 +1,384 @@ +package com.back.domain.studyroom.controller; + +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 lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +/** + * 현재 잡아 놓은 API 목록 + - 방 CRUD + - 방 입장/퇴장 처리 + - 멤버 관리 (목록 조회, 권한 변경, 추방) + - 방 목록 조회 (공개방, 인기방, 내 참여방) + + 인증: + - 모든 API는 Authorization 헤더 필요 (JWT 토큰) + - 현재는 임시로 하드코딩된 사용자 ID 사용, 예원님이 잡아준 임시 jwt 토큰과 연결 예정 + */ + + +@RestController +@RequestMapping("/api/rooms") +@RequiredArgsConstructor +public class RoomController { + private final RoomService roomService; + // 방 생성 + @PostMapping + public ResponseEntity>> createRoom( + @RequestBody Map request, + @RequestHeader("Authorization") String authorization) { + + Long currentUserId = 1L; // 임시 하드코딩 데이터 + + 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), + 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() + ); + 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, + @RequestHeader("Authorization") String authorization) { + + Long currentUserId = 1L; // 임시 하드코딩 데이터 + + String password = null; + if (request != null) { + password = (String) request.get("password"); + } + + RoomMember member = roomService.joinRoom(roomId, password, currentUserId); + + 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") + public ResponseEntity> leaveRoom( + @PathVariable Long roomId, + @RequestHeader("Authorization") String authorization) { + + Long currentUserId = 1L; // 임시 하드코딩 데이터 + + roomService.leaveRoom(roomId, currentUserId); + + return ResponseEntity + .status(HttpStatus.OK) + .body(RsData.success("방 퇴장 완료", null)); + } + + /** + * 공개 방 목록 조회 API + - 공개 방만 조회 (isPrivate = false) + - 입장 가능한 방만 조회 (활성화 + 정원 미초과) + - 최신 생성 순으로 정렬 + + * 현재 쿼리 파라미터: + - page: 페이지 번호 (기본값: 0) + - size: 페이지 크기 (기본값: 20) + - search: 검색어 (향후 구현 예정) + */ + @GetMapping + public ResponseEntity>> getRooms( + @RequestParam(defaultValue = "0") int page, + @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; + }) + .collect(Collectors.toList()); + + 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)); + } + + /** + * 방 상세 정보 조회 API + + * 조회 정보: + - 방 기본 정보 (제목, 설명, 설정 등) + - 현재 온라인 멤버 목록 (닉네임, 역할, 상태) + - 방 설정 (카메라, 오디오, 화면공유 허용 여부) + + * 접근 제한: + - 공개 방: 누구나 조회 가능 + - 비공개 방: 해당 방 멤버만 조회 가능 + */ + @GetMapping("/api/rooms/{roomId}") + public ResponseEntity>> getRoomDetail( + @PathVariable Long roomId, + @RequestHeader("Authorization") String authorization) { + + Long currentUserId = 1L; // 임시 하드코딩 + + 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; + }) + .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); + + return ResponseEntity + .status(HttpStatus.OK) + .body(RsData.success("방 상세 정보 조회 완료", response)); + } + + //사용자 참여 방 목록 조회 API + @GetMapping("/api/rooms/{roomId}/{id}/participants") + 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; + }) + .collect(Collectors.toList()); + + return ResponseEntity + .status(HttpStatus.OK) + .body(RsData.success("내 방 목록 조회 완료", roomList)); + } + + /** + * 방 설정 수정 API + 권한: 방장만 수정 가능 + + * 제약 사항: + - 최대 참가자 수는 현재 참가자 수보다 작게 설정할 수 없음 + */ + @PutMapping("/api/rooms/{roomId}") + public ResponseEntity> updateRoom( + @PathVariable Long roomId, + @RequestBody Map 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), + currentUserId + ); + + return ResponseEntity + .status(HttpStatus.OK) + .body(RsData.success("방 설정 변경 완료", null)); + } + + /** + * 방 종료 API + 권한: 방장만 종료 가능 + + * 종료 처리: + - 방 상태를 TERMINATED로 변경 + - 모든 멤버를 강제 오프라인 처리 (강퇴처리 식으로 진행 해야 할 지, 로직 처리 필요) + - 더 이상 입장 불가능한 상태로 변경 + */ + @DeleteMapping("/api/rooms/{roomId}") + public ResponseEntity> deleteRoom( + @PathVariable Long roomId, + @RequestHeader("Authorization") String authorization) { + + Long currentUserId = 1L; // 임시 하드코딩 + + roomService.terminateRoom(roomId, currentUserId); + + return ResponseEntity + .status(HttpStatus.OK) + .body(RsData.success("방 종료 완료", null)); + } + + /** + * 방 멤버 목록 조회 API + - 현재 온라인 상태인 인원만 조회 (룸 내에서든 외에서든) + - 역할별로 정렬 (방장 > 부방장 > 멤버 > 방문객) + + * 접근 제한: + - 공개 방: 누구나 조회 가능 + - 비공개 방: 해당 방 멤버만 조회 가능 + */ + @GetMapping("/api/rooms/{roomId}/participants") + public ResponseEntity>>> getRoomMembers( + @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; + }) + .collect(Collectors.toList()); + + return ResponseEntity + .status(HttpStatus.OK) + .body(RsData.success("방 멤버 목록 조회 완료", memberList)); + } + + /** + * 인기 방 목록 조회 API + * 정렬 기준: + - 1순위: 현재 참가자 수 (내림차순) + - 2순위: 생성 시간 (최신순) + + * 조회 조건: + - 공개 방만 조회 + - 활성화된 방만 조회 + */ + @GetMapping("/popular") + public ResponseEntity>> getPopularRooms( + @RequestParam(defaultValue = "0") int page, + @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; + }) + .collect(Collectors.toList()); + + 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)); + } +} 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 e07fd5a6..0514a8a1 100644 --- a/src/main/java/com/back/domain/studyroom/entity/Room.java +++ b/src/main/java/com/back/domain/studyroom/entity/Room.java @@ -15,40 +15,172 @@ @Getter public class Room extends BaseEntity { private String title; - private String description; - private boolean isPrivate; - private String password; - private int maxParticipants; - private boolean isActive; - private boolean allowCamera; - private boolean allowAudio; - private boolean allowScreenShare; + // 방 상태 + @Enumerated(EnumType.STRING) + @Column(nullable = false) + private RoomStatus status = RoomStatus.WAITING; + // 현재 참여자 + @Column(nullable = false) + private int currentParticipants = 0; + // 방장 @ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name = "created_by") private User createdBy; - + // 테마 @ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name = "theme_id") private RoomTheme theme; + // 연관관계 설정 @OneToMany(mappedBy = "room", cascade = CascadeType.ALL, orphanRemoval = true) private List roomMembers = new ArrayList<>(); - + // 채팅 메시지 @OneToMany(mappedBy = "room", cascade = CascadeType.ALL, orphanRemoval = true) private List roomChatMessages = new ArrayList<>(); - + // 참가자 기록 @OneToMany(mappedBy = "room", cascade = CascadeType.ALL, orphanRemoval = true) private List roomParticipantHistories = new ArrayList<>(); - + // 스터디 기록 @OneToMany(mappedBy = "room", cascade = CascadeType.ALL, orphanRemoval = true) public List studyRecords = new ArrayList<>(); + + + /** + * 방에 입장할 수 있는지 확인 + * 사용 상황: 사용자가 방 입장을 시도할 때 입장 가능 여부를 미리 체크 + 방이 활성화되어 있고, 입장 가능한 상태이며, 정원이 초과되지 않은 경우 + */ + public boolean canJoin() { + return isActive && status.isJoinable() && currentParticipants < maxParticipants; + } + + /** + * 방의 정원이 가득 찼는지 확인 + 방 목록에서 "만석" 표시하거나, 입장 제한할 때 + */ + public boolean isFull() { + return currentParticipants >= maxParticipants; + } + + /** + * 참가자 수 증가 (최대 정원까지만) + 누군가 방에 입장했을 때 참가자 수를 1 증가시킴 + 최대 정원을 초과하지 않도록 체크 + */ + public void incrementParticipant() { + if (currentParticipants < maxParticipants) { + this.currentParticipants++; + } + } + + /** + * 참가자 수 감소 (0 이하로는 감소하지 않음) + 누군가 방에서 나갔을 때 참가자 수를 1 감소시킴 + 음수가 되지 않도록 체크 + */ + public void decrementParticipant() { + if (this.currentParticipants > 0) { + this.currentParticipants--; + } + } + + /** + * 비밀번호 입력이 필요한 방인지 확인 + 비공개 방에 입장할 때 비밀번호 입력 폼을 보여줄지 결정 + 비공개 방이면서 실제로 비밀번호가 설정되어 있는 경우 + */ + public boolean needsPassword() { + return isPrivate && password != null && !password.trim().isEmpty(); + } + + /** + * 방을 활성 상태로 변경 + 방장이 스터디를 시작할 때 또는 대기 중인 방을 활성화할 때 + */ + public void activate() { + this.status = RoomStatus.ACTIVE; + this.isActive = true; + } + + /** + * 방을 일시 정지 상태로 변경 + */ + public void pause() { + this.status = RoomStatus.PAUSED; + } + + /** + * 방을 종료 상태로 변경 + 방장이 스터디를 완전히 끝내거나, 빈 방을 자동 정리할 때 (종료 처리를 어떻게 뺄지에 따라 변경 될 예정) + */ + public void terminate() { + this.status = RoomStatus.TERMINATED; + this.isActive = false; + } + + /** + * 특정 사용자가 이 방의 소유자(방장)인지 확인 + 방 설정 변경, 방 종료, 멤버 추방 등의 권한이 필요한 작업 전에 체크 + */ + public boolean isOwner(Long userId) { + return createdBy != null && createdBy.getId().equals(userId); + } + + /** + * 방 생성을 위한 정적 팩토리 메서드 + 새로운 방을 생성할 때 모든 기본값을 설정 해주는 초기 메서드 + 기본 상태에서 방장이 임의로 변형하고 싶은 부분만 변경해서 사용 가능 + */ + public static Room create(String title, String description, boolean isPrivate, + String password, int maxParticipants, User creator, RoomTheme theme) { + Room room = new Room(); + room.title = title; + room.description = description; + room.isPrivate = isPrivate; + room.password = password; + room.maxParticipants = maxParticipants; + room.isActive = true; // 생성 시 기본적으로 활성화 + room.allowCamera = true; // 기본적으로 카메라 허용 + room.allowAudio = true; // 기본적으로 오디오 허용 + room.allowScreenShare = true; // 기본적으로 화면 공유 허용 + room.status = RoomStatus.WAITING; // 생성 시 대기 상태 + room.currentParticipants = 0; // 생성 시 참가자 0명 + room.createdBy = creator; + room.theme = theme; + + return room; + } + + /** + * 방 설정 일괄 업데이트 메서드 + 방장이 방 설정을 변경할 때 여러 필드를 한 번에 업데이트 + 주된 생성 이유.. rtc 단체 제어를 위해 잡아놓았음. 잡아준 필드 변경 가능성 농후!! + */ + public void updateSettings(String title, String description, int maxParticipants, + boolean allowCamera, boolean allowAudio, boolean allowScreenShare) { + this.title = title; + this.description = description; + this.maxParticipants = maxParticipants; + this.allowCamera = allowCamera; + this.allowAudio = allowAudio; + this.allowScreenShare = allowScreenShare; + } + + /** + * 방 비밀번호 변경 메서드 + 방장이 방의 비밀번호를 변경할 때 + 별도 메서드로 분리한 이유: 비밀번호는 보안상 별도로 관리되어야 하기 때문 (ai의 추천) + */ + public void updatePassword(String newPassword) { + this.password = newPassword; + } } 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 c937bbb3..3c80a5ba 100644 --- a/src/main/java/com/back/domain/studyroom/entity/RoomMember.java +++ b/src/main/java/com/back/domain/studyroom/entity/RoomMember.java @@ -8,22 +8,187 @@ import java.time.LocalDateTime; +/* + RoomMember 엔티티 - 방과 사용자 간의 멤버십 관계를 나타냄 + 연관관계 : + - Room (1) : RoomMember (N) - 한 방에 여러 멤버가 있을 수 있음 + - User (1) : RoomMember (N) - 한 사용자가 여러 방의 멤버가 될 수 있음 + @JoinColumn vs @JoinTable 선택 이유: + - @JoinColumn: 외래키를 이용한 직접 관계 (현재 변경) + - @JoinTable: 별도의 연결 테이블을 만드는 관계 + RoomMember 테이블에서 그냥 room_id와 user_id 외래키로 직접 연결. + */ @Entity @NoArgsConstructor @Getter +@Table(uniqueConstraints = @UniqueConstraint(columnNames = {"room_id", "user_id"})) public class RoomMember extends BaseEntity { + + // 방 정보 - 어떤 방의 멤버인지 @ManyToOne(fetch = FetchType.LAZY) - @JoinColumn(name = "room_id") + @JoinColumn(name = "room_id") // room_member 테이블의 room_id 컬럼이 room 테이블의 id를 참조 private Room room; + // 사용자 정보 - 누가 이 방의 멤버인지 @ManyToOne(fetch = FetchType.LAZY) - @JoinTable(name = "user_id") + @JoinColumn(name = "user_id") // room_member 테이블의 user_id 컬럼이 users 테이블의 id를 참조 private User user; + // 방 내에서의 역할 (방장, 부방장, 멤버, 방문객) @Enumerated(EnumType.STRING) - private RoomRole role; + @Column(nullable = false) + private RoomRole role = RoomRole.VISITOR; - private LocalDateTime joinedAt; + // 멤버십 기본 정보 + @Column(nullable = false) + private LocalDateTime joinedAt; // 방에 처음 입장한 시간 + private LocalDateTime lastActiveAt; // 마지막으로 활동한 시간 - private LocalDateTime lastActiveAt; + // 실시간 상태 관리 필드들 + @Column(nullable = false) + private boolean isOnline = false; // 현재 방에 온라인 상태인지 + + private String connectionId; // WebSocket 연결 ID (실시간 통신용) + + private LocalDateTime lastHeartbeat; // 마지막 heartbeat 시간 (연결 상태 확인용) + + // 💡 권한 확인 메서드들 (RoomRole enum의 메서드를 위임) + + /** + * 방 관리 권한이 있는지 확인 (방장, 부방장) + 방 설정 변경, 공지사항 작성 등의 권한이 필요할 때 + */ + public boolean canManageRoom() { + return role.canManageRoom(); + } + + /** + * 멤버 추방 권한이 있는지 확인 (방장, 부방장) + 다른 멤버를 추방하려고 할 때 + */ + public boolean canKickMember() { + return role.canKickMember(); + } + + /** + * 공지사항 관리 권한이 있는지 확인 (방장, 부방장) + 공지사항을 작성하거나 삭제할 때 + */ + public boolean canManageNotices() { + return role.canManageNotices(); + } + + /** + * 방장인지 확인 + 방 소유자만 가능한 작업 (방 삭제, 호스트 권한 이양 등) + */ + public boolean isHost() { + return role.isHost(); + } + + /** + * 정식 멤버인지 확인 (방문객이 아닌 멤버, 부방장, 방장) + 멤버만 접근 가능한 기능 (파일 업로드, 학습 기록 등) + */ + public boolean isMember() { + return role.isMember(); + } + + /** + * 현재 활성 상태인지 확인 + 온라인 멤버 목록 표시, 비활성 사용자 정리 등 + 온라인 상태이고 최근 5분 이내에 heartbeat가 있었던 경우 + */ + public boolean isActive() { + return isOnline && lastHeartbeat != null && + lastHeartbeat.isAfter(LocalDateTime.now().minusMinutes(5)); + } + + + /** + 기본 멤버 생성 메서드, 처음 입장 시 사용 + */ + public static RoomMember create(Room room, User user, RoomRole role) { + RoomMember member = new RoomMember(); + member.room = room; + member.user = user; + member.role = role; + member.joinedAt = LocalDateTime.now(); + member.lastActiveAt = LocalDateTime.now(); + member.isOnline = true; // 생성 시 온라인 상태 + member.lastHeartbeat = LocalDateTime.now(); + + return member; + } + + // 방장 멤버 생성 -> 새로운 방을 생성할 때 방 생성자를 방장으로 등록 + public static RoomMember createHost(Room room, User user) { + return create(room, user, RoomRole.HOST); + } + + /** + * 일반 멤버 생성, 권한 자동 변경 + - 비공개 방에서 초대받은 사용자를 정식 멤버로 등록할 때 (로직 검토 중) + */ + public static RoomMember createMember(Room room, User user) { + return create(room, user, RoomRole.MEMBER); + } + + /** + * 방문객 생성 + * 사용 상황: 공개 방에 처음 입장하는 사용자를 임시 방문객으로 등록 + */ + public static RoomMember createVisitor(Room room, User user) { + return create(room, user, RoomRole.VISITOR); + } + + /** + * 멤버의 역할 변경 + 방장이 멤버를 부방장으로 승격시키거나 강등시킬 때 + */ + public void updateRole(RoomRole newRole) { + this.role = newRole; + } + + /** + * 온라인 상태 변경 + * 사용 상황: 멤버가 방에 입장하거나 퇴장할 때 + 활동 시간도 함께 업데이트, 온라인이 되면 heartbeat도 갱신 + */ + public void updateOnlineStatus(boolean online) { + this.isOnline = online; + this.lastActiveAt = LocalDateTime.now(); + if (online) { + this.lastHeartbeat = LocalDateTime.now(); + } + } + + /** + * WebSocket 연결 ID 업데이트 + * 사용 상황: 멤버가 웹소켓으로 방에 연결될 때 + + heartbeat도 함께 갱신 + */ + public void updateConnectionId(String connectionId) { + this.connectionId = connectionId; + this.lastHeartbeat = LocalDateTime.now(); + } + + /** + * 사용 : 클라이언트에서 주기적으로 서버에 연결 상태를 알릴 때 + * 목적: 연결이 끊어진 멤버를 자동으로 감지하기 위해 사용, 별도의 다른 것으로 변경 가능 + */ + public void heartbeat() { + this.lastHeartbeat = LocalDateTime.now(); + this.lastActiveAt = LocalDateTime.now(); + this.isOnline = true; + } + + /** + * 방 퇴장 처리 (명시적 퇴장과 연결 끊김 상태 로직 분할 예정임.. 일단은 임시로 통합 상태) + 멤버가 방을 나가거나 연결이 끊어졌을 때, 오프라인 상태로 변경하고 연결 ID 제거 + */ + public void leave() { + this.isOnline = false; + this.connectionId = null; + } } diff --git a/src/main/java/com/back/domain/studyroom/entity/RoomRole.java b/src/main/java/com/back/domain/studyroom/entity/RoomRole.java index 52fffd98..ff7f4b9e 100644 --- a/src/main/java/com/back/domain/studyroom/entity/RoomRole.java +++ b/src/main/java/com/back/domain/studyroom/entity/RoomRole.java @@ -1,5 +1,68 @@ package com.back.domain.studyroom.entity; +/** + * 방 내에서 사용자의 역할을 정의하는 Enum + * + * 역할: + * - HOST: 방장 (최고 권한, 방 소유자) + * - SUB_HOST: 부방장 (방장의 권한 일부 위임받음) + * - MEMBER: 정식 멤버 (기본 권한) + * - VISITOR: 방문객 (제한된 권한, 임시 사용자) + * + * 권한 : HOST > SUB_HOST > MEMBER > VISITOR + */ public enum RoomRole { - HOST, MEMBER + HOST("방장"), + SUB_HOST("부방장"), + MEMBER("멤버"), + VISITOR("방문객"); + + private final String description; + + RoomRole(String description) { + this.description = description; + } + + public String getDescription() { + return description; + } + + /* + 방 관리 권한 확인 (방 설정 변경, 공지사항 관리) + 허용 역할: HOST, SUB_HOST + */ + public boolean canManageRoom() { + return this == HOST || this == SUB_HOST; + } + + /* + 멤버 추방 권한 확인 + 허용 역할: HOST, SUB_HOST + */ + public boolean canKickMember() { + return this == HOST || this == SUB_HOST; + } + + /* + 공지사항 관리 권한 확인 + 허용 역할: HOST, SUB_HOST + */ + public boolean canManageNotices() { + return this == HOST || this == SUB_HOST; + } + + /* + 방장 여부 확인 + */ + public boolean isHost() { + return this == HOST; + } + + /* + 정식 멤버 여부 확인 (방문객 제외) + 멤버만 가능한 기능(아직 미정..)에서 사용 + */ + public boolean isMember() { + return this == MEMBER || this == SUB_HOST || this == HOST; + } } diff --git a/src/main/java/com/back/domain/studyroom/entity/RoomStatus.java b/src/main/java/com/back/domain/studyroom/entity/RoomStatus.java new file mode 100644 index 00000000..6752714c --- /dev/null +++ b/src/main/java/com/back/domain/studyroom/entity/RoomStatus.java @@ -0,0 +1,48 @@ +package com.back.domain.studyroom.entity; + +/* + 방의 현재 상태를 정의하는 Enum + (이쪽 로직은 구현에 따라서 상태 축소 가능.. 현재는 임의 상태) + 상태 전환 흐름: + WAITING → ACTIVE → PAUSED ⟷ ACTIVE → TERMINATED + + 상태별 의미: + WAITING: 방이 생성되었지만 아직 스터디를 시작하지 않은 상태 + ACTIVE: 현재 스터디가 진행 중인 상태 + PAUSED: 스터디를 일시 정지한 상태 (휴식 시간 등) + TERMINATED: 스터디가 완전히 종료된 상태 (더 이상 사용 불가) + */ +public enum RoomStatus { + WAITING("대기 중"), + ACTIVE("진행 중"), + PAUSED("일시 정지"), + TERMINATED("종료됨"); + + private final String description; + + RoomStatus(String description) { + this.description = description; + } + + public String getDescription() { + return description; + } + + /** + * 방에 입장 가능한 상태인지 확인 + * 사용 상황: 사용자가 방 입장을 시도할 때 + * 허용 상태: WAITING, ACTIVE (대기 중이거나 진행 중일 때만 입장 가능) + */ + public boolean isJoinable() { + return this == WAITING || this == ACTIVE; + } + + /** + * 방이 활성 상태인지 확인 + * 사용 상황: 실제 스터디가 진행되고 있는 방인지 체크 + * 활성 상태: ACTIVE (실제 스터디가 진행 중인 상태만) + */ + public boolean isActive() { + return this == ACTIVE; + } +} diff --git a/src/main/java/com/back/domain/studyroom/entity/RoomTheme.java b/src/main/java/com/back/domain/studyroom/entity/RoomTheme.java index baea5a95..2c13da8d 100644 --- a/src/main/java/com/back/domain/studyroom/entity/RoomTheme.java +++ b/src/main/java/com/back/domain/studyroom/entity/RoomTheme.java @@ -14,9 +14,7 @@ public class RoomTheme extends BaseEntity { @Enumerated(EnumType.STRING) private RoomType type; - private String name; - private String imageUrl; @OneToMany(mappedBy = "theme", cascade = CascadeType.ALL, orphanRemoval = true) diff --git a/src/main/java/com/back/domain/studyroom/repository/RoomMemberRepository.java b/src/main/java/com/back/domain/studyroom/repository/RoomMemberRepository.java new file mode 100644 index 00000000..d91496b6 --- /dev/null +++ b/src/main/java/com/back/domain/studyroom/repository/RoomMemberRepository.java @@ -0,0 +1,82 @@ +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로 멤버 조회 + 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/RoomRepository.java b/src/main/java/com/back/domain/studyroom/repository/RoomRepository.java index 16100948..92ab18a2 100644 --- a/src/main/java/com/back/domain/studyroom/repository/RoomRepository.java +++ b/src/main/java/com/back/domain/studyroom/repository/RoomRepository.java @@ -1,27 +1,103 @@ 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 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.util.List; +import java.util.Optional; +/* + - 방 검색 및 필터링 (제목, 상태, 공개/비공개) + - 참여 가능한 방 목록 조회 + - 사용자별 방 관리 (생성한 방, 참여 중인 방) + - 방 통계 및 관리 (인기 방, 참가자 수 동기화) + */ @Repository public interface RoomRepository extends JpaRepository { - - // 제목으로 방 검색 (부분 일치) + /* + 제목으로 방 검색 + 사용 상황: 사용자가 검색창에서 방 이름을 검색할 때 + */ 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") + @Query("SELECT r FROM Room r WHERE r.createdBy.id = :createdById ORDER BY r.createdAt DESC") List findByCreatedById(@Param("createdById") Long createdById); - // 방 존재 여부 확인 - boolean existsById(Long roomId); -} \ No newline at end of file + /* + 사용자가 참여 중인 방 조회 + 해당 사용자가 멤버로 등록되어 있고 현재 온라인 상태인 방 + */ + @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") + boolean existsByIdAndActive(@Param("roomId") Long roomId); + + // 비밀번호 검증용 (비공개 방) + @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/service/RoomService.java b/src/main/java/com/back/domain/studyroom/service/RoomService.java new file mode 100644 index 00000000..4f41a74d --- /dev/null +++ b/src/main/java/com/back/domain/studyroom/service/RoomService.java @@ -0,0 +1,347 @@ +package com.back.domain.studyroom.service; + +import com.back.domain.studyroom.entity.*; +import com.back.domain.studyroom.repository.*; +import com.back.domain.user.entity.User; +import com.back.domain.user.repository.UserRepository; +import com.back.global.exception.CustomException; +import com.back.global.exception.ErrorCode; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; +import java.util.Optional; + +/** + * 스터디룸 관련 비즈니스 로직을 담당하는 Service 클래스 + * + * 🎯 주요 책임: + * - 방 생성, 입장, 퇴장 로직 처리 + * - 멤버 권한 관리 (승격, 강등, 추방) + * - 방 상태 관리 (활성화, 일시정지, 종료) + * - 방장 위임 로직 (방장이 나갈 때 자동 위임) + * - 실시간 참가자 수 동기화 + * + * 🔐 보안: + * - 모든 권한 검증을 서비스 레이어에서 처리 + * - 비공개 방 접근 권한 체크 + * - 방장/부방장 권한이 필요한 작업들의 권한 검증 + */ +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +@Slf4j +public class RoomService { + + private final RoomRepository roomRepository; + private final RoomMemberRepository roomMemberRepository; + private final UserRepository userRepository; + + /** + * 방 생성 메서드 + * + * 🏗️ 생성 과정: + * 1. 사용자 존재 확인 + * 2. Room 엔티티 생성 (기본값 설정) + * 3. 방장을 RoomMember로 등록 + * 4. 참가자 수 1로 설정 + * + * 💡 기본 설정: + * - 상태: WAITING (대기 중) + * - 카메라/오디오/화면공유: 모두 허용 + * - 참가자 수: 0명에서 시작 후 방장 추가로 1명 + */ + @Transactional + public Room createRoom(String title, String description, boolean isPrivate, + String password, int maxParticipants, Long creatorId) { + + User creator = userRepository.findById(creatorId) + .orElseThrow(() -> new CustomException(ErrorCode.USER_NOT_FOUND)); + + Room room = Room.create(title, description, isPrivate, password, maxParticipants, creator, null); + Room savedRoom = roomRepository.save(room); + + RoomMember hostMember = RoomMember.createHost(savedRoom, creator); + roomMemberRepository.save(hostMember); + + savedRoom.incrementParticipant(); + + log.info("방 생성 완료 - RoomId: {}, Title: {}, CreatorId: {}", + savedRoom.getId(), title, creatorId); + + return savedRoom; + } + + /** + * 방 입장 메서드 + * + * 🔐 입장 검증 과정: + * 1. 방 존재 및 활성 상태 확인 + * 2. 방 상태가 입장 가능한지 확인 (WAITING, ACTIVE) + * 3. 정원 초과 여부 확인 + * 4. 비공개 방인 경우 비밀번호 확인 + * 5. 이미 참여 중인지 확인 (재입장 처리) + * + * 👤 멤버 등록: + * - 신규 사용자: VISITOR 역할로 등록 + * - 기존 사용자: 온라인 상태로 변경 + */ + @Transactional + public RoomMember joinRoom(Long roomId, String password, Long userId) { + + Room room = roomRepository.findById(roomId) + .orElseThrow(() -> new CustomException(ErrorCode.ROOM_NOT_FOUND)); + + if (!room.isActive()) { + throw new CustomException(ErrorCode.ROOM_INACTIVE); + } + + if (room.getStatus() == RoomStatus.TERMINATED) { + throw new CustomException(ErrorCode.ROOM_TERMINATED); + } + + if (!room.canJoin()) { + if (room.isFull()) { + throw new CustomException(ErrorCode.ROOM_FULL); + } + throw new CustomException(ErrorCode.ROOM_INACTIVE); + } + + if (room.needsPassword() && !room.getPassword().equals(password)) { + throw new CustomException(ErrorCode.ROOM_PASSWORD_INCORRECT); + } + + User user = userRepository.findById(userId) + .orElseThrow(() -> new CustomException(ErrorCode.USER_NOT_FOUND)); + + Optional existingMember = roomMemberRepository.findByRoomIdAndUserId(roomId, userId); + if (existingMember.isPresent()) { + RoomMember member = existingMember.get(); + if (member.isOnline()) { + throw new CustomException(ErrorCode.ALREADY_JOINED_ROOM); + } + member.updateOnlineStatus(true); + room.incrementParticipant(); + return member; + } + + RoomMember newMember = RoomMember.createVisitor(room, user); + RoomMember savedMember = roomMemberRepository.save(newMember); + + room.incrementParticipant(); + + log.info("방 입장 완료 - RoomId: {}, UserId: {}, Role: {}", + roomId, userId, newMember.getRole()); + + return savedMember; + } + + /** + * 방 나가기 메서드 + * + * 🚪 퇴장 처리: + * - 일반 멤버: 단순 오프라인 처리 및 참가자 수 감소 + * - 방장: 특별 처리 로직 실행 (handleHostLeaving) + * + * 🔄 방장 퇴장 시 처리: + * - 다른 멤버가 없으면 → 방 자동 종료 + * - 다른 멤버가 있으면 → 새 방장 자동 위임 + */ + @Transactional + public void leaveRoom(Long roomId, Long userId) { + + Room room = roomRepository.findById(roomId) + .orElseThrow(() -> new CustomException(ErrorCode.ROOM_NOT_FOUND)); + + RoomMember member = roomMemberRepository.findByRoomIdAndUserId(roomId, userId) + .orElseThrow(() -> new CustomException(ErrorCode.NOT_ROOM_MEMBER)); + + if (!member.isOnline()) { + return; + } + + if (member.isHost()) { + handleHostLeaving(room, member); + } else { + member.leave(); + room.decrementParticipant(); + } + + log.info("방 퇴장 완료 - RoomId: {}, UserId: {}", roomId, userId); + } + + private void handleHostLeaving(Room room, RoomMember hostMember) { + List onlineMembers = roomMemberRepository.findOnlineMembersByRoomId(room.getId()); + + List otherOnlineMembers = onlineMembers.stream() + .filter(m -> !m.getId().equals(hostMember.getId())) + .toList(); + + if (otherOnlineMembers.isEmpty()) { + room.terminate(); + hostMember.leave(); + room.decrementParticipant(); + } else { + RoomMember newHost = otherOnlineMembers.stream() + .filter(m -> m.getRole() == RoomRole.SUB_HOST) + .findFirst() + .orElse(otherOnlineMembers.stream() + .min((m1, m2) -> m1.getJoinedAt().compareTo(m2.getJoinedAt())) + .orElse(null)); + + if (newHost != null) { + newHost.updateRole(RoomRole.HOST); + hostMember.leave(); + room.decrementParticipant(); + + log.info("새 방장 지정 - RoomId: {}, NewHostId: {}", + room.getId(), newHost.getUser().getId()); + } + } + } + + public Page getJoinableRooms(Pageable pageable) { + return roomRepository.findJoinablePublicRooms(pageable); + } + + public Room getRoomDetail(Long roomId, Long userId) { + Room room = roomRepository.findById(roomId) + .orElseThrow(() -> new CustomException(ErrorCode.ROOM_NOT_FOUND)); + + if (room.isPrivate()) { + boolean isMember = roomMemberRepository.existsByRoomIdAndUserId(roomId, userId); + if (!isMember) { + throw new CustomException(ErrorCode.ROOM_FORBIDDEN); + } + } + + return room; + } + + public List getUserRooms(Long userId) { + return roomRepository.findRoomsByUserId(userId); + } + + @Transactional + public void updateRoomSettings(Long roomId, String title, String description, + int maxParticipants, boolean allowCamera, + boolean allowAudio, boolean allowScreenShare, 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); + } + + if (maxParticipants < room.getCurrentParticipants()) { + throw new CustomException(ErrorCode.BAD_REQUEST); + } + + room.updateSettings(title, description, maxParticipants, + allowCamera, allowAudio, allowScreenShare); + + log.info("방 설정 변경 완료 - RoomId: {}, UserId: {}", roomId, userId); + } + + @Transactional + public void terminateRoom(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.terminate(); + roomMemberRepository.disconnectAllMembers(roomId); + + log.info("방 종료 완료 - RoomId: {}, UserId: {}", roomId, userId); + } + + @Transactional + public void changeUserRole(Long roomId, Long targetUserId, RoomRole newRole, Long requesterId) { + + RoomMember requester = roomMemberRepository.findByRoomIdAndUserId(roomId, requesterId) + .orElseThrow(() -> new CustomException(ErrorCode.NOT_ROOM_MEMBER)); + + if (!requester.canManageRoom()) { + throw new CustomException(ErrorCode.NOT_ROOM_MANAGER); + } + + RoomMember targetMember = roomMemberRepository.findByRoomIdAndUserId(roomId, targetUserId) + .orElseThrow(() -> new CustomException(ErrorCode.NOT_ROOM_MEMBER)); + + if (targetMember.isHost()) { + throw new CustomException(ErrorCode.CANNOT_CHANGE_HOST_ROLE); + } + + targetMember.updateRole(newRole); + + log.info("멤버 권한 변경 완료 - RoomId: {}, TargetUserId: {}, NewRole: {}, RequesterId: {}", + roomId, targetUserId, newRole, requesterId); + } + + public List getRoomMembers(Long roomId, Long userId) { + + Room room = roomRepository.findById(roomId) + .orElseThrow(() -> new CustomException(ErrorCode.ROOM_NOT_FOUND)); + + if (room.isPrivate()) { + boolean isMember = roomMemberRepository.existsByRoomIdAndUserId(roomId, userId); + if (!isMember) { + throw new CustomException(ErrorCode.ROOM_FORBIDDEN); + } + } + + return roomMemberRepository.findOnlineMembersByRoomId(roomId); + } + + public RoomRole getUserRoomRole(Long roomId, Long userId) { + return roomMemberRepository.findByRoomIdAndUserId(roomId, userId) + .map(RoomMember::getRole) + .orElse(RoomRole.VISITOR); + } + + /** + * 인기 방 목록 조회 (참가자 수 기준) + */ + public Page getPopularRooms(Pageable pageable) { + return roomRepository.findPopularRooms(pageable); + } + + /** + * 멤버 추방 (방장, 부방장만 가능) + */ + @Transactional + public void kickMember(Long roomId, Long targetUserId, Long requesterId) { + + RoomMember requester = roomMemberRepository.findByRoomIdAndUserId(roomId, requesterId) + .orElseThrow(() -> new CustomException(ErrorCode.NOT_ROOM_MEMBER)); + + if (!requester.canKickMember()) { + throw new CustomException(ErrorCode.NOT_ROOM_MANAGER); + } + + RoomMember targetMember = roomMemberRepository.findByRoomIdAndUserId(roomId, targetUserId) + .orElseThrow(() -> new CustomException(ErrorCode.NOT_ROOM_MEMBER)); + + if (targetMember.isHost()) { + throw new CustomException(ErrorCode.CANNOT_KICK_HOST); + } + + targetMember.leave(); + + Room room = roomRepository.findById(roomId) + .orElseThrow(() -> new CustomException(ErrorCode.ROOM_NOT_FOUND)); + room.decrementParticipant(); + + log.info("멤버 추방 완료 - RoomId: {}, TargetUserId: {}, RequesterId: {}", + roomId, targetUserId, requesterId); + } +} diff --git a/src/main/java/com/back/global/exception/ErrorCode.java b/src/main/java/com/back/global/exception/ErrorCode.java index 4040695a..2e723757 100644 --- a/src/main/java/com/back/global/exception/ErrorCode.java +++ b/src/main/java/com/back/global/exception/ErrorCode.java @@ -14,6 +14,15 @@ public enum ErrorCode { // ======================== 스터디룸 관련 ======================== ROOM_NOT_FOUND(HttpStatus.NOT_FOUND, "ROOM_001", "존재하지 않는 방입니다."), ROOM_FORBIDDEN(HttpStatus.FORBIDDEN, "ROOM_002", "방에 대한 접근 권한이 없습니다."), + ROOM_FULL(HttpStatus.BAD_REQUEST, "ROOM_003", "방이 가득 찼습니다."), + ROOM_PASSWORD_INCORRECT(HttpStatus.BAD_REQUEST, "ROOM_004", "방 비밀번호가 틀렸습니다."), + ROOM_INACTIVE(HttpStatus.BAD_REQUEST, "ROOM_005", "비활성화된 방입니다."), + ROOM_TERMINATED(HttpStatus.BAD_REQUEST, "ROOM_006", "종료된 방입니다."), + ALREADY_JOINED_ROOM(HttpStatus.BAD_REQUEST, "ROOM_007", "이미 참여 중인 방입니다."), + NOT_ROOM_MEMBER(HttpStatus.FORBIDDEN, "ROOM_008", "방 멤버가 아닙니다."), + NOT_ROOM_MANAGER(HttpStatus.FORBIDDEN, "ROOM_009", "방 관리자 권한이 필요합니다."), + CANNOT_KICK_HOST(HttpStatus.BAD_REQUEST, "ROOM_010", "방장은 추방할 수 없습니다."), + CANNOT_CHANGE_HOST_ROLE(HttpStatus.BAD_REQUEST, "ROOM_011", "방장의 권한은 변경할 수 없습니다."), // ======================== 메시지 관련 ======================== MESSAGE_NOT_FOUND(HttpStatus.NOT_FOUND, "MESSAGE_001", "존재하지 않는 메시지입니다."), diff --git a/src/main/java/com/back/global/security/SecurityConfig.java b/src/main/java/com/back/global/security/SecurityConfig.java index 2a1f8c29..8f7061ad 100644 --- a/src/main/java/com/back/global/security/SecurityConfig.java +++ b/src/main/java/com/back/global/security/SecurityConfig.java @@ -29,6 +29,9 @@ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { .authorizeHttpRequests( auth -> auth .requestMatchers("/api/auth/**").permitAll() + .requestMatchers("/api/rooms/**").permitAll() // 테스트용 임시 허용 + .requestMatchers("/swagger-ui/**", "/v3/api-docs/**").permitAll() // Swagger 허용 + .requestMatchers("/h2-console/**").permitAll() // H2 Console 허용 .anyRequest().authenticated() )