Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,9 @@
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.entity.RoomRole;
import com.back.domain.studyroom.service.RoomService;
import com.back.domain.user.entity.User;
import com.back.global.common.dto.RsData;
import com.back.global.security.user.CurrentUser;
import io.swagger.v3.oas.annotations.Operation;
Expand Down Expand Up @@ -317,4 +319,44 @@ public ResponseEntity<RsData<Map<String, Object>>> getPopularRooms(
.status(HttpStatus.OK)
.body(RsData.success("인기 방 목록 조회 완료", response));
}

@PutMapping("/{roomId}/members/{userId}/role")
@Operation(
summary = "멤버 역할 변경",
description = "방 멤버의 역할을 변경합니다. 방장만 실행 가능합니다. VISITOR를 포함한 모든 사용자의 역할을 변경할 수 있으며, HOST로 변경 시 기존 방장은 자동으로 MEMBER로 강등됩니다."
)
@ApiResponses({
@ApiResponse(responseCode = "200", description = "역할 변경 성공"),
@ApiResponse(responseCode = "400", description = "자신의 역할은 변경 불가"),
@ApiResponse(responseCode = "403", description = "방장 권한 없음"),
@ApiResponse(responseCode = "404", description = "존재하지 않는 방 또는 사용자"),
@ApiResponse(responseCode = "401", description = "인증 실패")
})
public ResponseEntity<RsData<ChangeRoleResponse>> changeUserRole(
@Parameter(description = "방 ID", required = true) @PathVariable Long roomId,
@Parameter(description = "대상 사용자 ID", required = true) @PathVariable Long userId,
@Valid @RequestBody ChangeRoleRequest request) {

Long currentUserId = currentUser.getUserId();

// 변경 전 역할 조회
RoomRole oldRole = roomService.getUserRoomRole(roomId, userId);

// 역할 변경
roomService.changeUserRole(roomId, userId, request.getNewRole(), currentUserId);

// 사용자 정보 조회
User targetUser = roomService.getUserById(userId);

ChangeRoleResponse response = ChangeRoleResponse.of(
userId,
targetUser.getNickname(),
oldRole,
request.getNewRole()
);

return ResponseEntity
.status(HttpStatus.OK)
.body(RsData.success("역할 변경 완료", response));
}
}
21 changes: 21 additions & 0 deletions src/main/java/com/back/domain/studyroom/dto/ChangeRoleRequest.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
package com.back.domain.studyroom.dto;

import com.back.domain.studyroom.entity.RoomRole;
import jakarta.validation.constraints.NotNull;
import lombok.AllArgsConstructor;
import lombok.Getter;
import lombok.NoArgsConstructor;

/**
* 멤버 역할 변경 요청 DTO
* - VISITOR → MEMBER/SUB_HOST/HOST 모두 가능
* - HOST로 변경 시 기존 방장은 자동으로 MEMBER로 강등
*/
@Getter
@NoArgsConstructor
@AllArgsConstructor
public class ChangeRoleRequest {

@NotNull(message = "역할은 필수입니다")
private RoomRole newRole;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
package com.back.domain.studyroom.dto;

import com.back.domain.studyroom.entity.RoomRole;
import lombok.Builder;
import lombok.Getter;

/**
* 역할 변경 응답 DTO
*/
@Getter
@Builder
public class ChangeRoleResponse {

private Long userId;
private String nickname;
private RoomRole oldRole;
private RoomRole newRole;
private String message;

public static ChangeRoleResponse of(Long userId, String nickname,
RoomRole oldRole, RoomRole newRole) {
String message = buildMessage(oldRole, newRole);

return ChangeRoleResponse.builder()
.userId(userId)
.nickname(nickname)
.oldRole(oldRole)
.newRole(newRole)
.message(message)
.build();
}

private static String buildMessage(RoomRole oldRole, RoomRole newRole) {
if (newRole == RoomRole.HOST) {
return "방장으로 임명되었습니다.";
} else if (oldRole == RoomRole.HOST) {
return "방장 권한이 해제되었습니다.";
} else if (newRole == RoomRole.SUB_HOST) {
return "부방장으로 승격되었습니다.";
} else if (newRole == RoomRole.MEMBER && oldRole == RoomRole.VISITOR) {
return "정식 멤버로 승격되었습니다.";
} else if (newRole == RoomRole.MEMBER) {
return "일반 멤버로 강등되었습니다.";
}
return "역할이 변경되었습니다.";
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,15 @@ public interface RoomMemberRepositoryCustom {
*/
boolean existsByRoomIdAndUserId(Long roomId, Long userId);

/**
* 여러 사용자의 멤버십 일괄 조회 (IN 절)
* Redis에서 온라인 사용자 목록을 받아서 DB 멤버십 조회 시 사용
* @param roomId 방 ID
* @param userIds 사용자 ID 목록
* @return 멤버십 목록 (MEMBER 이상만 DB에 있음)
*/
List<RoomMember> findByRoomIdAndUserIdIn(Long roomId, java.util.Set<Long> userIds);

/**
* 특정 역할의 멤버 수 조회
* TODO: Redis 기반으로 변경 예정
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -255,6 +255,35 @@ public boolean existsByRoomIdAndUserId(Long roomId, Long userId) {
return count != null && count > 0;
}

/**
* 여러 사용자의 멤버십 일괄 조회 (IN 절)
* - Redis 온라인 목록으로 DB 멤버십 조회
* - N+1 문제 해결
* - VISITOR는 DB에 없으므로 결과에 포함 안됨
* @param roomId 방 ID
* @param userIds 사용자 ID Set
* @return DB에 저장된 멤버 목록 (MEMBER 이상)
*/
@Override
public List<RoomMember> findByRoomIdAndUserIdIn(Long roomId, java.util.Set<Long> userIds) {
if (userIds == null || userIds.isEmpty()) {
return List.of();
}

return queryFactory
.selectFrom(roomMember)
.leftJoin(roomMember.user, user).fetchJoin() // N+1 방지
.where(
roomMember.room.id.eq(roomId),
roomMember.user.id.in(userIds)
)
.orderBy(
roomMember.role.asc(), // 역할순
roomMember.joinedAt.asc() // 입장 시간순
)
.fetch();
}

/**
* 특정 역할의 멤버 수 조회
* TODO: Redis 기반으로 변경 예정
Expand Down
116 changes: 116 additions & 0 deletions src/main/java/com/back/domain/studyroom/service/RoomRedisService.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
package com.back.domain.studyroom.service;

import com.back.global.websocket.service.WebSocketSessionManager;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;

import java.util.Map;
import java.util.Set;

/**
* 방 상태 관리를 위한 Redis 전용 서비스
* 방의 온라인 사용자 관리 (입장/퇴장)
* 실시간 참가자 수 조회
* 온라인 사용자 목록 조회
* Redis: 실시간 온라인 상태만 관리 (휘발성 데이터)
* DB: 영구 멤버십 + 역할 정보 (MEMBER 이상만 저장)
* 역할(Role)은 Redis에 저장하지 않음!
* 이유 1: DB가 Single Source of Truth (데이터 일관성)
* 이유 2: Redis-DB 동기화 복잡도 제거
* 이유 3: 멤버 목록 조회 시 IN 절로 효율적 조회 가능
* @see com.back.global.websocket.service.WebSocketSessionManager WebSocket 세션 관리
* @see com.back.domain.studyroom.repository.RoomMemberRepository DB 멤버십 조회
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class RoomRedisService {

private final WebSocketSessionManager sessionManager;

// ==================== 방 입장/퇴장 ====================

/**
* 사용자가 방에 입장 (Redis 온라인 상태 업데이트)
* - Redis Set에 userId 추가
* - 역할(Role)은 DB에서만 관리
*
* @param userId 사용자 ID
* @param roomId 방 ID
*/
public void enterRoom(Long userId, Long roomId) {
sessionManager.joinRoom(userId, roomId);
log.info("방 입장 완료 (Redis) - 사용자: {}, 방: {}", userId, roomId);
}

/**
* 사용자가 방에서 퇴장 (Redis 온라인 상태 업데이트)
* - Redis Set에서 userId 제거
* - DB 멤버십은 유지됨 (재입장 시 역할 유지)
*
* @param userId 사용자 ID
* @param roomId 방 ID
*/
public void exitRoom(Long userId, Long roomId) {
sessionManager.leaveRoom(userId, roomId);
log.info("방 퇴장 완료 (Redis) - 사용자: {}, 방: {}", userId, roomId);
}

// ==================== 조회 ====================

/**
* 방의 현재 온라인 사용자 수 조회
* - 실시간 참가자 수 (DB currentParticipants와 무관)
*
* @param roomId 방 ID
* @return 온라인 사용자 수
*/
public long getRoomUserCount(Long roomId) {
return sessionManager.getRoomOnlineUserCount(roomId);
}

/**
* 방의 온라인 사용자 ID 목록 조회
* - 멤버 목록 조회 시 이 ID로 DB 조회
* - DB에 없는 ID = VISITOR
*
* @param roomId 방 ID
* @return 온라인 사용자 ID Set
*/
public Set<Long> getRoomUsers(Long roomId) {
return sessionManager.getOnlineUsersInRoom(roomId);
}

/**
* 사용자가 현재 특정 방에 있는지 확인
*
* @param userId 사용자 ID
* @param roomId 방 ID
* @return 온라인 여부
*/
public boolean isUserInRoom(Long userId, Long roomId) {
return sessionManager.isUserInRoom(userId, roomId);
}

/**
* 사용자의 현재 방 ID 조회
*
* @param userId 사용자 ID
* @return 방 ID (없으면 null)
*/
public Long getCurrentRoomId(Long userId) {
return sessionManager.getUserCurrentRoomId(userId);
}

/**
* 여러 방의 온라인 사용자 수 일괄 조회 (N+1 방지)
* - 방 목록 조회 시 사용
*
* @param roomIds 방 ID 목록
* @return Map<RoomId, OnlineCount>
*/
public Map<Long, Long> getBulkRoomOnlineUserCounts(java.util.List<Long> roomIds) {
return sessionManager.getBulkRoomOnlineUserCounts(roomIds);
}
}
Loading
Loading