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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -46,8 +46,10 @@ public SseEmitter connect(UUID userId) {

// 연결 직후 더미 이벤트를 하나 보내는 이유:
// 클라이언트가 아무 데이터도 안 보내고 가만히 있으면 연결이 대기상태로 오해받아 타임아웃/503이 날 수 있음
// id를 함께 실어 보내는 이유: 클라이언트가 이 이벤트를 "연결이 실제로 확립된 시점"의 기준점으로 삼아,
// 그 이후에 초기 목록(GET)을 조회하도록 순서를 맞추기 위함 (목록 조회~SSE 연결 사이 알림 유실 방지)
try {
emitter.send(SseEmitter.event().name("connect").data("connected"));
emitter.send(SseEmitter.event().name("connect").id(UUID.randomUUID().toString()).data("connected"));
} catch (IOException e) {
// 이 시점 실패는 서블릿 컨테이너의 오류 디스패치로 정리되므로 로그만 남김
log.warn("SSE 초기 연결 이벤트 전송 실패 - userId: {}", userId, e);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,16 @@

import com.momogo.core.domain.problem.dto.request.CategoryCreateRequest;
import com.momogo.core.domain.problem.dto.request.CategoryUpdateRequest;
import com.momogo.core.domain.problem.dto.request.ProblemUpdateRequest;
import com.momogo.core.domain.problem.dto.response.CategoryResponse;
import com.momogo.core.domain.problem.dto.response.ProblemDetailResponse;
import com.momogo.core.domain.problem.dto.response.ProblemResponse;
import com.momogo.core.domain.problem.service.ProblemCategoryService;
import com.momogo.core.domain.problem.service.ProblemService;
import com.momogo.core.domain.space.dto.request.SpaceUpdateRequest;
import com.momogo.core.domain.space.dto.response.SpaceResponse;
import com.momogo.core.domain.space.entity.Space;
import com.momogo.core.domain.space.mapper.SpaceMapper;
import com.momogo.core.domain.space.service.SpaceService;
import com.momogo.core.domain.user.dto.request.UserBannedRequest;
import com.momogo.core.domain.user.dto.request.UserPageRequest;
Expand All @@ -20,11 +27,13 @@
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.ModelAttribute;
import org.springframework.web.bind.annotation.PatchMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
Expand All @@ -36,8 +45,10 @@
public class SuperAdminController {

private final SpaceService spaceService;
private final SpaceMapper spaceMapper;
private final UserService userService;
private final ProblemCategoryService problemCategoryService;
private final ProblemService problemService;

/**
* 전체 공간 조회
Expand All @@ -51,6 +62,67 @@ public ResponseEntity<Page<SpaceResponse>> getAllSpaces(
return ResponseEntity.ok(spaceService.getAllSpaces(pageable));
}

/**
* 공간 강제 수정 (SUPER_ADMIN 전용)
* @param spaceId 공간 ID
* @param request 공간 수정 요청 DTO
* @return 수정된 공간 정보
*/
@PutMapping("/spaces/{spaceId}")
public ResponseEntity<SpaceResponse> forceUpdateSpace(
@PathVariable UUID spaceId,
@Valid @RequestBody SpaceUpdateRequest request
) {
Space space = spaceService.forceUpdateSpace(spaceId, request);
return ResponseEntity.ok(spaceMapper.toResponse(space));
}

/**
* 공간 강제 폐쇄 (SUPER_ADMIN 전용)
* @param spaceId 공간 ID
*/
@DeleteMapping("/spaces/{spaceId}")
public ResponseEntity<Void> forceDeleteSpace(@PathVariable UUID spaceId) {
spaceService.forceDeleteSpace(spaceId);
return ResponseEntity.noContent().build();
}

/**
* 전체 문제 조회 (SUPER_ADMIN 전용, 공간 무관, 정답 포함)
* @param pageable 페이징 정보
* @return 전체 문제 정보
*/
@GetMapping("/problems")
public ResponseEntity<Page<ProblemDetailResponse>> getAllProblems(
@PageableDefault(size = 10) Pageable pageable
) {
return ResponseEntity.ok(problemService.getAllProblems(pageable));
}

/**
* 문제 강제 수정 (SUPER_ADMIN 전용)
* @param problemId 문제 ID
* @param request 문제 수정 요청 DTO
* @return 수정된 문제 정보
*/
@PutMapping("/problems/{problemId}")
public ResponseEntity<ProblemResponse> forceUpdateProblem(
@PathVariable UUID problemId,
@Valid @RequestBody ProblemUpdateRequest request
) {
return ResponseEntity.ok(problemService.updateProblemAsSuperAdmin(problemId, request));
}

/**
* 문제 강제 삭제 (SUPER_ADMIN 전용)
* @param problemId 문제 ID
*/
@DeleteMapping("/problems/{problemId}")
public ResponseEntity<Void> forceDeleteProblem(@PathVariable UUID problemId) {
problemService.deleteProblemAsSuperAdmin(problemId);
return ResponseEntity.noContent().build();
}


/**
* 전체 유저 목록을 페이징(커서 기반) 조회합니다. (SUPER_ADMIN 전용)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@
import com.momogo.core.domain.problem.entity.Problem;
import java.util.Optional;
import java.util.UUID;
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.Query;
import org.springframework.data.repository.query.Param;
Expand All @@ -19,6 +21,16 @@ public interface ProblemRepository extends JpaRepository<Problem, UUID>, Problem
@Query("SELECT p FROM Problem p JOIN FETCH p.category JOIN FETCH p.space WHERE p.id = :id")
Optional<Problem> findByIdWithCategory(@Param("id") UUID id);

/**
* 전체 문제 목록 조회 (SUPER_ADMIN 전용, 공간 무관)
* - 카테고리/공간을 JOIN FETCH 하여 N+1 방지
*/
@Query(
value = "SELECT p FROM Problem p JOIN FETCH p.category JOIN FETCH p.space",
countQuery = "SELECT count(p) FROM Problem p"
)
Page<Problem> findAllWithCategory(Pageable pageable);

/**
* 카테고리 삭제 전 확인 메서드
* - 카테고리 삭제 전 관련되어 있는 문제가 남아 있으면
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@
import java.time.OffsetDateTime;
import java.util.List;
import java.util.UUID;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;

public interface ProblemService {

Expand Down Expand Up @@ -77,4 +79,23 @@ ProblemCursorResponse getProblems(
* @param request AI 문제 자동 생성 요청 DTO
*/
List<ProblemResponse> createProblemsByAi(UUID spaceId, UUID idempotencyKey, ProblemAiCreateRequest request);

/**
* 전체 문제 목록 조회 (SUPER_ADMIN 전용, 공간 무관, 정답 포함)
* @param pageable 페이징 정보
*/
Page<ProblemDetailResponse> getAllProblems(Pageable pageable);

/**
* 문제 강제 수정 (SUPER_ADMIN 전용, 공간 소속 ADMIN 검증 없이 임의 문제 수정)
* @param problemId 문제 ID
* @param request 문제 수정 DTO
*/
ProblemResponse updateProblemAsSuperAdmin(UUID problemId, ProblemUpdateRequest request);

/**
* 문제 강제 삭제 (SUPER_ADMIN 전용, 공간 소속 ADMIN 검증 없이 임의 문제 삭제)
* @param problemId 문제 ID
*/
void deleteProblemAsSuperAdmin(UUID problemId);
}
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,8 @@
import java.util.UUID;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.core.script.RedisScript;
import org.springframework.stereotype.Service;
Expand Down Expand Up @@ -347,4 +349,38 @@ public List<ProblemResponse> createProblemsByAi(UUID spaceId, UUID idempotencyKe
redisTemplate.execute(UNLOCK_SCRIPT, List.of(lockKey), ownerToken);
}
}

/**
* 전체 문제 목록 조회 (SUPER_ADMIN 전용, 공간 무관, 정답 포함)
*/
@Override
public Page<ProblemDetailResponse> getAllProblems(Pageable pageable) {
return problemRepository.findAllWithCategory(pageable).map(problemMapper::toDetailResponse);
}

/**
* 문제 강제 수정 (SUPER_ADMIN 전용)
* - 문제가 소속된 공간을 조회한 뒤 기존 updateProblem 검증/수정 로직에 위임한다.
*/
@Override
@Transactional
public ProblemResponse updateProblemAsSuperAdmin(UUID problemId, ProblemUpdateRequest request) {
Problem problem = problemRepository.findById(problemId)
.orElseThrow(() -> new BusinessException(ProblemErrorCode.PROBLEM_NOT_FOUND));

return updateProblem(problem.getSpace().getId(), problemId, request);
}

/**
* 문제 강제 삭제 (SUPER_ADMIN 전용)
* - 문제가 소속된 공간을 조회한 뒤 기존 deleteProblem 검증/삭제 로직에 위임한다.
*/
@Override
@Transactional
public void deleteProblemAsSuperAdmin(UUID problemId) {
Problem problem = problemRepository.findById(problemId)
.orElseThrow(() -> new BusinessException(ProblemErrorCode.PROBLEM_NOT_FOUND));

deleteProblem(problem.getSpace().getId(), problemId);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -37,4 +37,10 @@ public interface SpaceService {

// 전체 공간 조회 (SUPER_ADMIN 권한 검증)
Page<SpaceResponse> getAllSpaces(Pageable pageable);

// 공간 강제 수정 (SUPER_ADMIN 전용, 소유 ADMIN 검증 없이 임의 공간 수정)
Space forceUpdateSpace(UUID spaceId, SpaceUpdateRequest request);

// 공간 강제 폐쇄 (SUPER_ADMIN 전용, 소유 ADMIN 검증 없이 임의 공간 삭제)
void forceDeleteSpace(UUID spaceId);
}
Original file line number Diff line number Diff line change
Expand Up @@ -200,6 +200,35 @@ public Page<SpaceResponse> getAllSpaces(Pageable pageable) {
return spaceRepository.findAll(pageable).map(spaceMapper::toResponse);
}

@Override
@Transactional
public Space forceUpdateSpace(UUID spaceId, SpaceUpdateRequest request) {

Space space = spaceRepository.findById(spaceId)
.orElseThrow(() -> new BusinessException(SpaceErrorCode.SPACE_NOT_FOUND));

spaceMapper.updateFromDto(request, space); // MapStruct 더티 체킹

if (request.spacePassword() != null && !request.spacePassword().isBlank()) {
space.updateSpacePassword(passwordEncryptor.encrypt(request.spacePassword()));
}

return space;
}

@Override
@Transactional
public void forceDeleteSpace(UUID spaceId) {

Space space = spaceRepository.findById(spaceId)
.orElseThrow(() -> new BusinessException(SpaceErrorCode.SPACE_NOT_FOUND));

// 벌크 쿼리로 공간 소속 유저 탈퇴 처리
userRepository.bulkLeaveSpace(spaceId, UserRole.USER);

spaceRepository.delete(space);
}

// 공통 헬퍼 메소드: 사용자 조회 및 공간 관리자 권한 검증
private User validateAndGetSpaceAdmin(UUID userId, UUID spaceId) {
User user = userRepository.findById(userId)
Expand Down
Loading