diff --git a/src/main/java/com/_1/spring_rest_api/api/controller/QuizSessionController.java b/src/main/java/com/_1/spring_rest_api/api/controller/QuizSessionController.java index 72f6ebe..415ef48 100644 --- a/src/main/java/com/_1/spring_rest_api/api/controller/QuizSessionController.java +++ b/src/main/java/com/_1/spring_rest_api/api/controller/QuizSessionController.java @@ -5,10 +5,14 @@ import com._1.spring_rest_api.service.QuizSessionQueryService; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Parameter; +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.http.HttpStatus; import org.springframework.http.ResponseEntity; +import org.springframework.security.access.AccessDeniedException; import org.springframework.web.bind.annotation.*; import java.util.HashMap; @@ -63,4 +67,32 @@ public ResponseEntity> completeSession( return ResponseEntity.ok(response); } + + @DeleteMapping("/batch") + @Operation(summary = "여러 퀴즈 세션 배치 삭제 (JWT 토큰 기반)", + description = """ + 여러 퀴즈 세션을 한 번에 삭제합니다. + - JWT 토큰으로 현재 사용자를 인증합니다 + - 본인이 소유한 세션만 삭제 가능합니다 + - 최대 100개까지 처리 가능합니다 + """ + ) + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "배치 삭제 완료 (일부 실패 포함 가능)"), + @ApiResponse(responseCode = "400", description = "잘못된 요청 (세션 ID 목록이 비어있거나 너무 많음)"), + @ApiResponse(responseCode = "401", description = "인증되지 않은 사용자 (JWT 토큰 없음 또는 유효하지 않음)"), + }) + public ResponseEntity deleteSessions( + @Valid @RequestBody DeleteSessionsRequest request) { + + try { + DeleteSessionsResponse response = quizSessionCommandService.deleteSessions(request); + return ResponseEntity.ok(response); + + } catch (AccessDeniedException e) { + // JWT 토큰이 없거나 유효하지 않은 경우 + return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build(); + + } + } } \ No newline at end of file diff --git a/src/main/java/com/_1/spring_rest_api/api/dto/DeleteSessionsRequest.java b/src/main/java/com/_1/spring_rest_api/api/dto/DeleteSessionsRequest.java new file mode 100644 index 0000000..1b1c5aa --- /dev/null +++ b/src/main/java/com/_1/spring_rest_api/api/dto/DeleteSessionsRequest.java @@ -0,0 +1,21 @@ +package com._1.spring_rest_api.api.dto; + +import jakarta.validation.constraints.NotEmpty; +import jakarta.validation.constraints.Size; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.util.List; + +@Getter +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class DeleteSessionsRequest { + + @NotEmpty(message = "삭제할 세션 ID 목록은 필수입니다") + @Size(max = 100, message = "한 번에 최대 100개 세션까지 삭제 가능합니다") + private List sessionIds; +} \ No newline at end of file diff --git a/src/main/java/com/_1/spring_rest_api/api/dto/DeleteSessionsResponse.java b/src/main/java/com/_1/spring_rest_api/api/dto/DeleteSessionsResponse.java new file mode 100644 index 0000000..1e91441 --- /dev/null +++ b/src/main/java/com/_1/spring_rest_api/api/dto/DeleteSessionsResponse.java @@ -0,0 +1,31 @@ +package com._1.spring_rest_api.api.dto; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.util.List; + +@Getter +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class DeleteSessionsResponse { + + private Integer totalRequested; // 삭제 요청된 총 세션 수 + private Integer successCount; // 성공적으로 삭제된 세션 수 + private Integer failureCount; // 삭제 실패한 세션 수 + private List deletedSessionIds; // 삭제된 세션 ID 목록 + private List failures; // 실패한 세션 정보 + + @Getter + @Builder + @NoArgsConstructor + @AllArgsConstructor + public static class SessionDeleteFailure { + private Long sessionId; // 실패한 세션 ID + private String reason; // 실패 이유 (사용자용 메시지) + private String errorCode; // 오류 코드 (클라이언트 처리용) + } +} \ No newline at end of file diff --git a/src/main/java/com/_1/spring_rest_api/security/SecurityUtils.java b/src/main/java/com/_1/spring_rest_api/security/SecurityUtils.java new file mode 100644 index 0000000..622278b --- /dev/null +++ b/src/main/java/com/_1/spring_rest_api/security/SecurityUtils.java @@ -0,0 +1,32 @@ +package com._1.spring_rest_api.security; + +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.stereotype.Component; + +@Component +public class SecurityUtils { + + /** + * 현재 인증된 사용자의 이메일을 반환합니다. + * @return 사용자 이메일 또는 null (인증되지 않은 경우) + */ + public static String getCurrentUserEmail() { + Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); + + if (authentication == null || !authentication.isAuthenticated()) { + return null; + } + + Object principal = authentication.getPrincipal(); + + if (principal instanceof UserDetails) { + return ((UserDetails) principal).getUsername(); + } else if (principal instanceof String) { + return (String) principal; + } + + return null; + } +} \ No newline at end of file diff --git a/src/main/java/com/_1/spring_rest_api/service/QuizSessionCommandService.java b/src/main/java/com/_1/spring_rest_api/service/QuizSessionCommandService.java index e2fbc1f..996fcf1 100644 --- a/src/main/java/com/_1/spring_rest_api/service/QuizSessionCommandService.java +++ b/src/main/java/com/_1/spring_rest_api/service/QuizSessionCommandService.java @@ -2,10 +2,14 @@ import com._1.spring_rest_api.api.dto.AnswerRequest; import com._1.spring_rest_api.api.dto.AnswerResponse; +import com._1.spring_rest_api.api.dto.DeleteSessionsRequest; +import com._1.spring_rest_api.api.dto.DeleteSessionsResponse; public interface QuizSessionCommandService { AnswerResponse answerQuestion(Long sessionId, AnswerRequest request); void completeSession(Long sessionId); + + DeleteSessionsResponse deleteSessions(DeleteSessionsRequest request); } diff --git a/src/main/java/com/_1/spring_rest_api/service/QuizSessionCommandServiceImpl.java b/src/main/java/com/_1/spring_rest_api/service/QuizSessionCommandServiceImpl.java index a614a6e..62bb6e1 100644 --- a/src/main/java/com/_1/spring_rest_api/service/QuizSessionCommandServiceImpl.java +++ b/src/main/java/com/_1/spring_rest_api/service/QuizSessionCommandServiceImpl.java @@ -2,25 +2,36 @@ import com._1.spring_rest_api.api.dto.AnswerRequest; import com._1.spring_rest_api.api.dto.AnswerResponse; +import com._1.spring_rest_api.api.dto.DeleteSessionsRequest; +import com._1.spring_rest_api.api.dto.DeleteSessionsResponse; import com._1.spring_rest_api.converter.AnswerResponseConverter; -import com._1.spring_rest_api.converter.QuestionConverter; import com._1.spring_rest_api.entity.Question; import com._1.spring_rest_api.entity.QuizSession; +import com._1.spring_rest_api.entity.User; import com._1.spring_rest_api.entity.UserAnswer; import com._1.spring_rest_api.repository.QuizSessionRepository; import com._1.spring_rest_api.repository.UserAnswerRepository; +import com._1.spring_rest_api.repository.UserRepository; +import com._1.spring_rest_api.security.SecurityUtils; import jakarta.persistence.EntityNotFoundException; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.security.access.AccessDeniedException; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import java.util.ArrayList; +import java.util.List; + @Service @RequiredArgsConstructor @Transactional +@Slf4j public class QuizSessionCommandServiceImpl implements QuizSessionCommandService { private final QuizSessionRepository quizSessionRepository; private final UserAnswerRepository userAnswerRepository; + private final UserRepository userRepository; private final AnswerResponseConverter answerResponseConverter; @Override @@ -63,11 +74,70 @@ public void completeSession(Long sessionId) { } } - /** - * ID로 퀴즈 세션 조회 - */ + @Override + public DeleteSessionsResponse deleteSessions(DeleteSessionsRequest request) { + // JWT 토큰에서 현재 사용자 정보 추출 + User currentUser = getCurrentUser(); + List sessionIds = request.getSessionIds(); + List deletedSessionIds = new ArrayList<>(); + List failures = new ArrayList<>(); + + log.info("배치 세션 삭제 시작: userId={}, sessionCount={}", currentUser.getId(), sessionIds.size()); + + for (Long sessionId : sessionIds) { + try { + QuizSession session = findSessionById(sessionId); + + quizSessionRepository.delete(session); + deletedSessionIds.add(sessionId); + + log.debug("세션 삭제 성공: sessionId={}", sessionId); + + } catch (EntityNotFoundException e) { + failures.add(createFailure(sessionId, "세션을 찾을 수 없습니다", "SESSION_NOT_FOUND")); + log.warn("세션을 찾을 수 없음: sessionId={}", sessionId); + } catch (Exception e) { + log.error("세션 삭제 중 예상치 못한 오류: sessionId={}", sessionId, e); + failures.add(createFailure(sessionId, "알 수 없는 오류가 발생했습니다", "UNKNOWN_ERROR")); + } + } + + int successCount = deletedSessionIds.size(); + int failureCount = failures.size(); + + log.info("배치 세션 삭제 완료: userId={}, total={}, success={}, failure={}", + currentUser.getId(), sessionIds.size(), successCount, failureCount); + + return DeleteSessionsResponse.builder() + .totalRequested(sessionIds.size()) + .successCount(successCount) + .failureCount(failureCount) + .deletedSessionIds(deletedSessionIds) + .failures(failures) + .build(); + } + + private User getCurrentUser() { + String email = SecurityUtils.getCurrentUserEmail(); + if (email == null) { + throw new AccessDeniedException("인증되지 않은 사용자입니다"); + } + + return userRepository.findByEmail(email) + .orElseThrow(() -> new EntityNotFoundException("User not found with email: " + email)); + } + + private QuizSession findSessionById(Long sessionId) { return quizSessionRepository.findById(sessionId) .orElseThrow(() -> new EntityNotFoundException("Session not found with id: " + sessionId)); } + + private DeleteSessionsResponse.SessionDeleteFailure createFailure(Long sessionId, String reason, String errorCode) { + return DeleteSessionsResponse.SessionDeleteFailure.builder() + .sessionId(sessionId) + .reason(reason) + .errorCode(errorCode) + .build(); + } } \ No newline at end of file