From f25fc45f969738aaeff38300ab0f40211a64c64b Mon Sep 17 00:00:00 2001 From: dmori Date: Mon, 2 Jun 2025 19:08:51 +0900 Subject: [PATCH 1/5] =?UTF-8?q?feat:=20=EB=B0=B0=EC=B9=98=20=EC=84=B8?= =?UTF-8?q?=EC=85=98=20=EC=82=AD=EC=A0=9C=20=EC=9A=94=EC=B2=AD/=EC=9D=91?= =?UTF-8?q?=EB=8B=B5=20DTO=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../api/dto/DeleteSessionsRequest.java | 21 +++++++++++++ .../api/dto/DeleteSessionsResponse.java | 31 +++++++++++++++++++ 2 files changed, 52 insertions(+) create mode 100644 src/main/java/com/_1/spring_rest_api/api/dto/DeleteSessionsRequest.java create mode 100644 src/main/java/com/_1/spring_rest_api/api/dto/DeleteSessionsResponse.java 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 From 224d35e99fc5309a1c5cc8289ea9e7388cfe7a15 Mon Sep 17 00:00:00 2001 From: dmori Date: Mon, 2 Jun 2025 19:11:31 +0900 Subject: [PATCH 2/5] =?UTF-8?q?feat:=20JWT=20=ED=86=A0=ED=81=B0=EC=97=90?= =?UTF-8?q?=EC=84=9C=20=EC=82=AC=EC=9A=A9=EC=9E=90=20=EC=A0=95=EB=B3=B4=20?= =?UTF-8?q?=EC=B6=94=EC=B6=9C=20=EC=9C=A0=ED=8B=B8=EB=A6=AC=ED=8B=B0=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../security/SecurityUtils.java | 32 +++++++++++++++++++ 1 file changed, 32 insertions(+) create mode 100644 src/main/java/com/_1/spring_rest_api/security/SecurityUtils.java 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 From d09b71107c031e02ac1b4d6503465483278bda8b Mon Sep 17 00:00:00 2001 From: dmori Date: Mon, 2 Jun 2025 19:17:27 +0900 Subject: [PATCH 3/5] =?UTF-8?q?feat:=20=EB=B0=B0=EC=B9=98=20=EC=84=B8?= =?UTF-8?q?=EC=85=98=20=EC=82=AD=EC=A0=9C=20=EB=B9=84=EC=A6=88=EB=8B=88?= =?UTF-8?q?=EC=8A=A4=20=EB=A1=9C=EC=A7=81=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../service/QuizSessionCommandService.java | 4 + .../QuizSessionCommandServiceImpl.java | 90 ++++++++++++++++++- 2 files changed, 90 insertions(+), 4 deletions(-) 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..210a793 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,82 @@ 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); + + // 권한 검증 - 세션 소유자와 현재 사용자가 일치하는지 확인 + validateUserPermission(session, currentUser); + + 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 (SecurityException e) { + failures.add(createFailure(sessionId, "삭제 권한이 없습니다", "PERMISSION_DENIED")); + log.warn("권한 없는 삭제 시도: sessionId={}, userId={}", sessionId, currentUser.getId()); + } 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 void validateUserPermission(QuizSession session, User user) { + if (!session.getUser().getId().equals(user.getId())) { + throw new SecurityException("해당 세션에 대한 삭제 권한이 없습니다."); + } + } + + 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 From 76537f51e32db416a3a7db9e4a7c30106aad6b17 Mon Sep 17 00:00:00 2001 From: dmori Date: Mon, 2 Jun 2025 19:19:23 +0900 Subject: [PATCH 4/5] =?UTF-8?q?feat:=20=EB=B0=B0=EC=B9=98=20=EC=84=B8?= =?UTF-8?q?=EC=85=98=20=EC=82=AD=EC=A0=9C=20API=20=EC=97=94=EB=93=9C?= =?UTF-8?q?=ED=8F=AC=EC=9D=B8=ED=8A=B8=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../api/controller/QuizSessionController.java | 36 +++++++++++++++++++ 1 file changed, 36 insertions(+) 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..9836334 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,36 @@ 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 토큰 없음 또는 유효하지 않음)"), + @ApiResponse(responseCode = "403", description = "권한 없음 (다른 사용자의 세션 삭제 시도)") + }) + 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(); + + } catch (SecurityException e) { + // 다른 사용자의 세션을 삭제하려고 하는 경우 + return ResponseEntity.status(HttpStatus.FORBIDDEN).build(); + } + } } \ No newline at end of file From f2d4bfe7411563ac75b59a86a16ba0bf10b637bc Mon Sep 17 00:00:00 2001 From: dmori Date: Mon, 2 Jun 2025 19:27:35 +0900 Subject: [PATCH 5/5] =?UTF-8?q?feat:=20=EC=84=B8=EC=85=98=20=EC=82=AD?= =?UTF-8?q?=EC=A0=9C=20=EC=A4=91=20=EB=B3=B8=EC=9D=B8=20=EC=86=8C=EC=9C=A0?= =?UTF-8?q?=20=EC=84=B8=EC=85=98=20=EC=B2=B4=ED=81=AC=20=EB=A1=9C=EC=A7=81?= =?UTF-8?q?=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../api/controller/QuizSessionController.java | 4 ---- .../service/QuizSessionCommandServiceImpl.java | 12 ------------ 2 files changed, 16 deletions(-) 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 9836334..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 @@ -81,7 +81,6 @@ public ResponseEntity> completeSession( @ApiResponse(responseCode = "200", description = "배치 삭제 완료 (일부 실패 포함 가능)"), @ApiResponse(responseCode = "400", description = "잘못된 요청 (세션 ID 목록이 비어있거나 너무 많음)"), @ApiResponse(responseCode = "401", description = "인증되지 않은 사용자 (JWT 토큰 없음 또는 유효하지 않음)"), - @ApiResponse(responseCode = "403", description = "권한 없음 (다른 사용자의 세션 삭제 시도)") }) public ResponseEntity deleteSessions( @Valid @RequestBody DeleteSessionsRequest request) { @@ -94,9 +93,6 @@ public ResponseEntity deleteSessions( // JWT 토큰이 없거나 유효하지 않은 경우 return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build(); - } catch (SecurityException e) { - // 다른 사용자의 세션을 삭제하려고 하는 경우 - return ResponseEntity.status(HttpStatus.FORBIDDEN).build(); } } } \ No newline at end of file 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 210a793..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 @@ -88,9 +88,6 @@ public DeleteSessionsResponse deleteSessions(DeleteSessionsRequest request) { try { QuizSession session = findSessionById(sessionId); - // 권한 검증 - 세션 소유자와 현재 사용자가 일치하는지 확인 - validateUserPermission(session, currentUser); - quizSessionRepository.delete(session); deletedSessionIds.add(sessionId); @@ -99,9 +96,6 @@ public DeleteSessionsResponse deleteSessions(DeleteSessionsRequest request) { } catch (EntityNotFoundException e) { failures.add(createFailure(sessionId, "세션을 찾을 수 없습니다", "SESSION_NOT_FOUND")); log.warn("세션을 찾을 수 없음: sessionId={}", sessionId); - } catch (SecurityException e) { - failures.add(createFailure(sessionId, "삭제 권한이 없습니다", "PERMISSION_DENIED")); - log.warn("권한 없는 삭제 시도: sessionId={}, userId={}", sessionId, currentUser.getId()); } catch (Exception e) { log.error("세션 삭제 중 예상치 못한 오류: sessionId={}", sessionId, e); failures.add(createFailure(sessionId, "알 수 없는 오류가 발생했습니다", "UNKNOWN_ERROR")); @@ -139,12 +133,6 @@ private QuizSession findSessionById(Long sessionId) { .orElseThrow(() -> new EntityNotFoundException("Session not found with id: " + sessionId)); } - private void validateUserPermission(QuizSession session, User user) { - if (!session.getUser().getId().equals(user.getId())) { - throw new SecurityException("해당 세션에 대한 삭제 권한이 없습니다."); - } - } - private DeleteSessionsResponse.SessionDeleteFailure createFailure(Long sessionId, String reason, String errorCode) { return DeleteSessionsResponse.SessionDeleteFailure.builder() .sessionId(sessionId)