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 @@ -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;
Expand Down Expand Up @@ -63,4 +67,32 @@ public ResponseEntity<Map<String, Object>> 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<DeleteSessionsResponse> 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();

}
}
}
Original file line number Diff line number Diff line change
@@ -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<Long> sessionIds;
}
Original file line number Diff line number Diff line change
@@ -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<Long> deletedSessionIds; // 삭제된 세션 ID 목록
private List<SessionDeleteFailure> failures; // 실패한 세션 정보

@Getter
@Builder
@NoArgsConstructor
@AllArgsConstructor
public static class SessionDeleteFailure {
private Long sessionId; // 실패한 세션 ID
private String reason; // 실패 이유 (사용자용 메시지)
private String errorCode; // 오류 코드 (클라이언트 처리용)
}
}
32 changes: 32 additions & 0 deletions src/main/java/com/_1/spring_rest_api/security/SecurityUtils.java
Original file line number Diff line number Diff line change
@@ -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;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -63,11 +74,70 @@ public void completeSession(Long sessionId) {
}
}

/**
* ID로 퀴즈 세션 조회
*/
@Override
public DeleteSessionsResponse deleteSessions(DeleteSessionsRequest request) {
// JWT 토큰에서 현재 사용자 정보 추출
User currentUser = getCurrentUser();
List<Long> sessionIds = request.getSessionIds();
List<Long> deletedSessionIds = new ArrayList<>();
List<DeleteSessionsResponse.SessionDeleteFailure> 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();
}
}