From e4a1912f647c30c7f2343c6cf43d01e2f7088e56 Mon Sep 17 00:00:00 2001 From: Jiwon Kwak Date: Tue, 15 Jul 2025 17:08:51 +0900 Subject: [PATCH 1/7] =?UTF-8?q?:sparkles:=20[feat]=20=EC=82=AC=EC=9A=A9?= =?UTF-8?q?=EC=9E=90=20=ED=9A=8C=EC=9B=90=ED=83=88=ED=87=B4,=20=EB=8B=89?= =?UTF-8?q?=EB=84=A4=EC=9E=84=20=EC=88=98=EC=A0=95=20(#40)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * :sparkles: feat: 회원 탈퇴 기능 구현 * :sparkles: feat: 닉네임 수정 기능 구현 * chore: Java 스타일 수정 * :bug: fix: @Controller -> @RestController 수정 --------- Co-authored-by: github-actions <> --- .../domain/user/api/UserController.java | 45 +++++++++++++++++++ .../backend/domain/user/app/UserService.java | 23 +++++++++- .../backend/global/config/SecurityConfig.java | 2 + .../f1/backend/global/config/WebConfig.java | 7 +++ .../f1/backend/global/util/SecurityUtils.java | 13 ++++++ 5 files changed, 88 insertions(+), 2 deletions(-) create mode 100644 backend/src/main/java/io/f1/backend/domain/user/api/UserController.java diff --git a/backend/src/main/java/io/f1/backend/domain/user/api/UserController.java b/backend/src/main/java/io/f1/backend/domain/user/api/UserController.java new file mode 100644 index 00000000..4cbe5343 --- /dev/null +++ b/backend/src/main/java/io/f1/backend/domain/user/api/UserController.java @@ -0,0 +1,45 @@ +package io.f1.backend.domain.user.api; + +import static io.f1.backend.global.util.SecurityUtils.logout; + +import io.f1.backend.domain.user.app.UserService; +import io.f1.backend.domain.user.dto.SignupRequestDto; +import io.f1.backend.domain.user.dto.UserPrincipal; + +import jakarta.servlet.http.HttpSession; + +import lombok.RequiredArgsConstructor; + +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.DeleteMapping; +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; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/user/me") +public class UserController { + + private final UserService userService; + + @DeleteMapping + public ResponseEntity deleteCurrentUser( + @AuthenticationPrincipal UserPrincipal userPrincipal, HttpSession httpSession) { + userService.deleteUser(userPrincipal.getUserId()); + logout(httpSession); + return ResponseEntity.noContent().build(); + } + + @PutMapping + public ResponseEntity updateNickname( + @AuthenticationPrincipal UserPrincipal userPrincipal, + @RequestBody SignupRequestDto signupRequest, + HttpSession httpSession) { + userService.updateNickname( + userPrincipal.getUserId(), signupRequest.nickname(), httpSession); + return ResponseEntity.noContent().build(); + } +} diff --git a/backend/src/main/java/io/f1/backend/domain/user/app/UserService.java b/backend/src/main/java/io/f1/backend/domain/user/app/UserService.java index c7e48e56..f23e2b0f 100644 --- a/backend/src/main/java/io/f1/backend/domain/user/app/UserService.java +++ b/backend/src/main/java/io/f1/backend/domain/user/app/UserService.java @@ -32,7 +32,7 @@ public SignupResponseDto signup(HttpSession session, SignupRequestDto signupRequ validateNicknameFormat(nickname); validateNicknameDuplicate(nickname); - User user = updateUserNickname(authenticationUser.userId(), nickname); + User user = initNickname(authenticationUser.userId(), nickname); updateSessionAfterSignup(session, user); SecurityUtils.setAuthentication(user); @@ -68,7 +68,7 @@ public void validateNicknameDuplicate(String nickname) { } @Transactional - public User updateUserNickname(Long userId, String nickname) { + public User initNickname(Long userId, String nickname) { User user = userRepository .findById(userId) @@ -82,4 +82,23 @@ private void updateSessionAfterSignup(HttpSession session, User user) { session.removeAttribute(OAUTH_USER); session.setAttribute(USER, AuthenticationUser.from(user)); } + + @Transactional + public void deleteUser(Long userId) { + User user = + userRepository + .findById(userId) + .orElseThrow(() -> new RuntimeException("E404001: 존재하지 않는 회원입니다.")); + userRepository.delete(user); + } + + @Transactional + public void updateNickname(Long userId, String newNickname, HttpSession session) { + validateNicknameFormat(newNickname); + validateNicknameDuplicate(newNickname); + + User user = initNickname(userId, newNickname); + session.setAttribute(USER, AuthenticationUser.from(user)); + SecurityUtils.setAuthentication(user); + } } diff --git a/backend/src/main/java/io/f1/backend/global/config/SecurityConfig.java b/backend/src/main/java/io/f1/backend/global/config/SecurityConfig.java index ebcfc42e..2866dc7e 100644 --- a/backend/src/main/java/io/f1/backend/global/config/SecurityConfig.java +++ b/backend/src/main/java/io/f1/backend/global/config/SecurityConfig.java @@ -42,6 +42,8 @@ public SecurityFilterChain userFilterChain(HttpSecurity http) throws Exception { .permitAll() .requestMatchers("/ws/**") .authenticated() + .requestMatchers("/user/me") + .hasRole("USER") .anyRequest() .authenticated()) .formLogin(AbstractHttpConfigurer::disable) diff --git a/backend/src/main/java/io/f1/backend/global/config/WebConfig.java b/backend/src/main/java/io/f1/backend/global/config/WebConfig.java index 8d093eec..a80ae64c 100644 --- a/backend/src/main/java/io/f1/backend/global/config/WebConfig.java +++ b/backend/src/main/java/io/f1/backend/global/config/WebConfig.java @@ -1,6 +1,8 @@ package io.f1.backend.global.config; +import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; +import org.springframework.web.filter.HiddenHttpMethodFilter; import org.springframework.web.servlet.config.annotation.ResourceHandlerRegistry; import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; @@ -14,4 +16,9 @@ public void addResourceHandlers(ResourceHandlerRegistry registry) { registry.addResourceHandler("/images/thumbnail/**") .addResourceLocations("file:images/thumbnail/"); } + + @Bean + public HiddenHttpMethodFilter hiddenHttpMethodFilter() { + return new HiddenHttpMethodFilter(); + } } diff --git a/backend/src/main/java/io/f1/backend/global/util/SecurityUtils.java b/backend/src/main/java/io/f1/backend/global/util/SecurityUtils.java index 5915e5d2..9a4530a0 100644 --- a/backend/src/main/java/io/f1/backend/global/util/SecurityUtils.java +++ b/backend/src/main/java/io/f1/backend/global/util/SecurityUtils.java @@ -3,6 +3,8 @@ import io.f1.backend.domain.user.dto.UserPrincipal; import io.f1.backend.domain.user.entity.User; +import jakarta.servlet.http.HttpSession; + import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; import org.springframework.security.core.Authentication; import org.springframework.security.core.context.SecurityContextHolder; @@ -37,4 +39,15 @@ public static Long getCurrentUserId() { public static String getCurrentUserNickname() { return getCurrentUserPrincipal().getUserNickname(); } + + public static void logout(HttpSession session) { + if (session != null) { + session.invalidate(); + } + clearAuthentication(); + } + + private static void clearAuthentication() { + SecurityContextHolder.clearContext(); + } } From 509026e99793fa2a75c56eecd509fde1804713fc Mon Sep 17 00:00:00 2001 From: kanghyun Date: Tue, 15 Jul 2025 21:33:51 +0900 Subject: [PATCH 2/7] =?UTF-8?q?=E2=9C=A8=20feat=20:=20=EC=BB=A4=EC=8A=A4?= =?UTF-8?q?=ED=85=80=20=EC=98=88=EC=99=B8=20=EA=B3=B5=ED=86=B5=20=EC=B2=98?= =?UTF-8?q?=EB=A6=AC=20(#41)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * :sparkles: feat: 글로벌 예외 처리 핸들러 구현 및 validation 예외 처리 추가 * chore: Java 스타일 수정 * :bug: fix: password validation @NotBlank로 변경 * :bug: fix: password validation @NotNull로 변경 * :recycle: 리뷰 반영 수정 및 예외 로그 추가 * chore: Java 스타일 수정 --------- Co-authored-by: github-actions <> --- .../global/exception/CustomException.java | 13 +++- .../exception/errorcode/AuthErrorCode.java | 24 +++++++ .../exception/errorcode/CommonErrorCode.java | 22 +++++++ .../global/exception/errorcode/ErrorCode.java | 12 ++++ .../errorcode/QuestionErrorCode.java | 18 +++++ .../exception/errorcode/QuizErrorCode.java | 21 ++++++ .../exception/errorcode/RoomErrorCode.java | 21 ++++++ .../exception/errorcode/UserErrorCode.java | 22 +++++++ .../handler/GlobalExceptionHandler.java | 66 +++++++++++++++++++ .../exception/response/ErrorResponse.java | 12 ++++ 10 files changed, 229 insertions(+), 2 deletions(-) create mode 100644 backend/src/main/java/io/f1/backend/global/exception/errorcode/AuthErrorCode.java create mode 100644 backend/src/main/java/io/f1/backend/global/exception/errorcode/CommonErrorCode.java create mode 100644 backend/src/main/java/io/f1/backend/global/exception/errorcode/ErrorCode.java create mode 100644 backend/src/main/java/io/f1/backend/global/exception/errorcode/QuestionErrorCode.java create mode 100644 backend/src/main/java/io/f1/backend/global/exception/errorcode/QuizErrorCode.java create mode 100644 backend/src/main/java/io/f1/backend/global/exception/errorcode/RoomErrorCode.java create mode 100644 backend/src/main/java/io/f1/backend/global/exception/errorcode/UserErrorCode.java create mode 100644 backend/src/main/java/io/f1/backend/global/exception/handler/GlobalExceptionHandler.java create mode 100644 backend/src/main/java/io/f1/backend/global/exception/response/ErrorResponse.java diff --git a/backend/src/main/java/io/f1/backend/global/exception/CustomException.java b/backend/src/main/java/io/f1/backend/global/exception/CustomException.java index 2cd6202f..a2a14cdd 100644 --- a/backend/src/main/java/io/f1/backend/global/exception/CustomException.java +++ b/backend/src/main/java/io/f1/backend/global/exception/CustomException.java @@ -1,8 +1,17 @@ package io.f1.backend.global.exception; +import io.f1.backend.global.exception.errorcode.ErrorCode; + public class CustomException extends RuntimeException { - public CustomException(String message) { - super(message); + private final ErrorCode errorCode; + + public CustomException(ErrorCode errorCode) { + super(errorCode.getMessage()); + this.errorCode = errorCode; + } + + public ErrorCode getErrorCode() { + return errorCode; } } diff --git a/backend/src/main/java/io/f1/backend/global/exception/errorcode/AuthErrorCode.java b/backend/src/main/java/io/f1/backend/global/exception/errorcode/AuthErrorCode.java new file mode 100644 index 00000000..54ffb652 --- /dev/null +++ b/backend/src/main/java/io/f1/backend/global/exception/errorcode/AuthErrorCode.java @@ -0,0 +1,24 @@ +package io.f1.backend.global.exception.errorcode; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +import org.springframework.http.HttpStatus; + +@Getter +@RequiredArgsConstructor +public enum AuthErrorCode implements ErrorCode { + UNAUTHORIZED("E401001", HttpStatus.UNAUTHORIZED, "로그인이 필요합니다."), + AUTH_SESSION_NOT_FOUND("E401002", HttpStatus.UNAUTHORIZED, "세션이 존재하지 않습니다. 로그인 후 이용해주세요."), + AUTH_SESSION_EXPIRED("E401003", HttpStatus.UNAUTHORIZED, "세션이 만료되었습니다. 다시 로그인해주세요."), + AUTH_SESSION_LOST("E401004", HttpStatus.UNAUTHORIZED, "세션 정보가 유실되었습니다. 다시 로그인해주세요."), + FORBIDDEN("E403001", HttpStatus.FORBIDDEN, "권한이 없습니다."), + + LOGIN_FAILED("E401005", HttpStatus.UNAUTHORIZED, "아이디 또는 비밀번호가 일치하지 않습니다."); + + private final String code; + + private final HttpStatus httpStatus; + + private final String message; +} diff --git a/backend/src/main/java/io/f1/backend/global/exception/errorcode/CommonErrorCode.java b/backend/src/main/java/io/f1/backend/global/exception/errorcode/CommonErrorCode.java new file mode 100644 index 00000000..a998d9c5 --- /dev/null +++ b/backend/src/main/java/io/f1/backend/global/exception/errorcode/CommonErrorCode.java @@ -0,0 +1,22 @@ +package io.f1.backend.global.exception.errorcode; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +import org.springframework.http.HttpStatus; + +@Getter +@RequiredArgsConstructor +public enum CommonErrorCode implements ErrorCode { + BAD_REQUEST_DATA("E400001", HttpStatus.BAD_REQUEST, "잘못된 요청 데이터입니다."), + INVALID_PAGINATION("E400006", HttpStatus.BAD_REQUEST, "page와 size는 1 이상의 정수여야 합니다."), + INTERNAL_SERVER_ERROR( + "E500001", HttpStatus.INTERNAL_SERVER_ERROR, "서버에러가 발생했습니다. 관리자에게 문의해주세요."), + INVALID_JSON_FORMAT("E400008", HttpStatus.BAD_REQUEST, "요청 형식이 올바르지 않습니다. JSON 문법을 확인해주세요."); + + private final String code; + + private final HttpStatus httpStatus; + + private final String message; +} diff --git a/backend/src/main/java/io/f1/backend/global/exception/errorcode/ErrorCode.java b/backend/src/main/java/io/f1/backend/global/exception/errorcode/ErrorCode.java new file mode 100644 index 00000000..a77eaa2d --- /dev/null +++ b/backend/src/main/java/io/f1/backend/global/exception/errorcode/ErrorCode.java @@ -0,0 +1,12 @@ +package io.f1.backend.global.exception.errorcode; + +import org.springframework.http.HttpStatus; + +public interface ErrorCode { + + String getCode(); + + HttpStatus getHttpStatus(); + + String getMessage(); +} diff --git a/backend/src/main/java/io/f1/backend/global/exception/errorcode/QuestionErrorCode.java b/backend/src/main/java/io/f1/backend/global/exception/errorcode/QuestionErrorCode.java new file mode 100644 index 00000000..9b7c0c1e --- /dev/null +++ b/backend/src/main/java/io/f1/backend/global/exception/errorcode/QuestionErrorCode.java @@ -0,0 +1,18 @@ +package io.f1.backend.global.exception.errorcode; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +import org.springframework.http.HttpStatus; + +@Getter +@RequiredArgsConstructor +public enum QuestionErrorCode implements ErrorCode { + QUESTION_NOT_FOUND("E404003", HttpStatus.NOT_FOUND, "존재하지 않는 문제입니다."); + + private final String code; + + private final HttpStatus httpStatus; + + private final String message; +} diff --git a/backend/src/main/java/io/f1/backend/global/exception/errorcode/QuizErrorCode.java b/backend/src/main/java/io/f1/backend/global/exception/errorcode/QuizErrorCode.java new file mode 100644 index 00000000..e03983b1 --- /dev/null +++ b/backend/src/main/java/io/f1/backend/global/exception/errorcode/QuizErrorCode.java @@ -0,0 +1,21 @@ +package io.f1.backend.global.exception.errorcode; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +import org.springframework.http.HttpStatus; + +@Getter +@RequiredArgsConstructor +public enum QuizErrorCode implements ErrorCode { + FILE_SIZE_TOO_LARGE("E400005", HttpStatus.BAD_REQUEST, "파일 크기가 너무 큽니다."), + UNSUPPORTED_MEDIA_TYPE("E415001", HttpStatus.UNSUPPORTED_MEDIA_TYPE, "지원하지 않는 파일 형식입니다."), + INVALID_FILTER("E400007", HttpStatus.BAD_REQUEST, "title 또는 creator 중 하나만 입력 가능합니다."), + QUIZ_NOT_FOUND("E404002", HttpStatus.NOT_FOUND, "존재하지 않는 퀴즈입니다."); + + private final String code; + + private final HttpStatus httpStatus; + + private final String message; +} diff --git a/backend/src/main/java/io/f1/backend/global/exception/errorcode/RoomErrorCode.java b/backend/src/main/java/io/f1/backend/global/exception/errorcode/RoomErrorCode.java new file mode 100644 index 00000000..191147ac --- /dev/null +++ b/backend/src/main/java/io/f1/backend/global/exception/errorcode/RoomErrorCode.java @@ -0,0 +1,21 @@ +package io.f1.backend.global.exception.errorcode; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +import org.springframework.http.HttpStatus; + +@Getter +@RequiredArgsConstructor +public enum RoomErrorCode implements ErrorCode { + ROOM_USER_LIMIT_REACHED("E403002", HttpStatus.FORBIDDEN, "정원이 모두 찼습니다."), + ROOM_GAME_IN_PROGRESS("E403003", HttpStatus.FORBIDDEN, "게임이 진행 중 입니다."), + ROOM_NOT_FOUND("E404005", HttpStatus.NOT_FOUND, "존재하지 않는 방입니다."), + WRONG_PASSWORD("E401006", HttpStatus.UNAUTHORIZED, "비밀번호가 일치하지않습니다."); + + private final String code; + + private final HttpStatus httpStatus; + + private final String message; +} diff --git a/backend/src/main/java/io/f1/backend/global/exception/errorcode/UserErrorCode.java b/backend/src/main/java/io/f1/backend/global/exception/errorcode/UserErrorCode.java new file mode 100644 index 00000000..a53f35a4 --- /dev/null +++ b/backend/src/main/java/io/f1/backend/global/exception/errorcode/UserErrorCode.java @@ -0,0 +1,22 @@ +package io.f1.backend.global.exception.errorcode; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +import org.springframework.http.HttpStatus; + +@Getter +@RequiredArgsConstructor +public enum UserErrorCode implements ErrorCode { + NICKNAME_EMPTY("E400002", HttpStatus.BAD_REQUEST, "닉네임은 필수 입력입니다."), + NICKNAME_TOO_LONG("E400003", HttpStatus.BAD_REQUEST, "닉네임은 6글자 이하로 입력해야 합니다."), + NICKNAME_NOT_ALLOWED("E400004", HttpStatus.BAD_REQUEST, "한글, 영문, 숫자만 입력해주세요."), + NICKNAME_CONFLICT("E409001", HttpStatus.CONFLICT, "중복된 닉네임입니다."), + USER_NOT_FOUND("E404001", HttpStatus.NOT_FOUND, "존재하지 않는 회원입니다."); + + private final String code; + + private final HttpStatus httpStatus; + + private final String message; +} diff --git a/backend/src/main/java/io/f1/backend/global/exception/handler/GlobalExceptionHandler.java b/backend/src/main/java/io/f1/backend/global/exception/handler/GlobalExceptionHandler.java new file mode 100644 index 00000000..c5da75e0 --- /dev/null +++ b/backend/src/main/java/io/f1/backend/global/exception/handler/GlobalExceptionHandler.java @@ -0,0 +1,66 @@ +package io.f1.backend.global.exception.handler; + +import io.f1.backend.global.exception.CustomException; +import io.f1.backend.global.exception.errorcode.CommonErrorCode; +import io.f1.backend.global.exception.errorcode.ErrorCode; +import io.f1.backend.global.exception.response.ErrorResponse; + +import lombok.extern.slf4j.Slf4j; + +import org.springframework.http.ResponseEntity; +import org.springframework.http.converter.HttpMessageNotReadableException; +import org.springframework.validation.FieldError; +import org.springframework.web.bind.MethodArgumentNotValidException; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.RestControllerAdvice; + +@Slf4j +@RestControllerAdvice +public class GlobalExceptionHandler { + + @ExceptionHandler(CustomException.class) + public ResponseEntity handleCustomException(CustomException e) { + log.warn(e.getMessage()); + ErrorCode errorCode = e.getErrorCode(); + + ErrorResponse response = new ErrorResponse(errorCode.getCode(), errorCode.getMessage()); + return new ResponseEntity<>(response, errorCode.getHttpStatus()); + } + + @ExceptionHandler(Exception.class) + public ResponseEntity handleException(Exception e) { + log.warn("handleException: {}", e.getMessage()); + CommonErrorCode errorCode = CommonErrorCode.INTERNAL_SERVER_ERROR; + + ErrorResponse response = new ErrorResponse(errorCode.getCode(), errorCode.getMessage()); + return new ResponseEntity<>(response, errorCode.getHttpStatus()); + } + + @ExceptionHandler(MethodArgumentNotValidException.class) + public ResponseEntity handleMethodArgumentNotValidException( + MethodArgumentNotValidException e) { + log.warn("MethodArgumentNotValidException: {}", e.getMessage()); + CommonErrorCode code = CommonErrorCode.BAD_REQUEST_DATA; + + String message = + e.getBindingResult().getFieldErrors().stream() + .map(FieldError::getDefaultMessage) + .findFirst() + .orElse(code.getMessage()); + + ErrorResponse response = new ErrorResponse(code.getCode(), message); + + return new ResponseEntity<>(response, code.getHttpStatus()); + } + + @ExceptionHandler(HttpMessageNotReadableException.class) + public ResponseEntity handleHttpMessageNotReadableException( + HttpMessageNotReadableException e) { + log.warn("HttpMessageNotReadableException: {}", e.getMessage()); + CommonErrorCode code = CommonErrorCode.INVALID_JSON_FORMAT; + + ErrorResponse response = new ErrorResponse(code.getCode(), code.getMessage()); + + return new ResponseEntity<>(response, code.getHttpStatus()); + } +} diff --git a/backend/src/main/java/io/f1/backend/global/exception/response/ErrorResponse.java b/backend/src/main/java/io/f1/backend/global/exception/response/ErrorResponse.java new file mode 100644 index 00000000..42f423c0 --- /dev/null +++ b/backend/src/main/java/io/f1/backend/global/exception/response/ErrorResponse.java @@ -0,0 +1,12 @@ +package io.f1.backend.global.exception.response; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Getter +@RequiredArgsConstructor +public class ErrorResponse { + + private final String code; + private final String message; +} From 26ecfe954d94b0c8b0362105feed7450c68265f4 Mon Sep 17 00:00:00 2001 From: "Lee Eunjoo (Silver)" <80297399+silver-eunjoo@users.noreply.github.com> Date: Wed, 16 Jul 2025 12:06:13 +0900 Subject: [PATCH 3/7] =?UTF-8?q?:sparkles:=20feat=20:=20=EB=AC=B8=EC=A0=9C?= =?UTF-8?q?=20=EC=88=98=EC=A0=95,=20=EC=82=AD=EC=A0=9C=20API=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80=20+=20TrimmedSize=20=EC=9C=A0=ED=9A=A8=EC=84=B1=20?= =?UTF-8?q?=EA=B2=80=EC=82=AC=20=EC=96=B4=EB=85=B8=ED=85=8C=EC=9D=B4?= =?UTF-8?q?=EC=85=98=20=EC=A0=95=EC=9D=98=20(#44)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * :sparkles: feat : 문제 수정, 삭제 API 추가 + TrimmedSize 유효성 검사 어노테이션 정의 * :wrench: chore : 시큐리티 임시 허용해줬던 거 다시 원래대로 * chore: Java 스타일 수정 * :wrench: chore : 충돌 해결 * chore: Java 스타일 수정 * :recycle: refactor : Quiz 수정시에도 컨트롤러, 서비스 역할 분리 * chore: Java 스타일 수정 --------- Co-authored-by: github-actions <> --- .../question/api/QuestionController.java | 44 ++++++++++++++++ .../domain/question/app/QuestionService.java | 52 +++++++++++++++++++ .../domain/question/dto/QuestionRequest.java | 4 ++ .../question/dto/QuestionUpdateRequest.java | 3 ++ .../domain/question/entity/Question.java | 4 ++ .../domain/question/entity/TextQuestion.java | 4 ++ .../domain/quiz/api/QuizController.java | 12 ++++- .../backend/domain/quiz/app/QuizService.java | 51 +++++++++++++----- .../domain/quiz/dto/QuizCreateRequest.java | 3 ++ .../global/validation/TrimmedSize.java | 23 ++++++++ .../validation/TrimmedSizeValidator.java | 26 ++++++++++ 11 files changed, 211 insertions(+), 15 deletions(-) create mode 100644 backend/src/main/java/io/f1/backend/domain/question/api/QuestionController.java create mode 100644 backend/src/main/java/io/f1/backend/domain/question/dto/QuestionUpdateRequest.java create mode 100644 backend/src/main/java/io/f1/backend/global/validation/TrimmedSize.java create mode 100644 backend/src/main/java/io/f1/backend/global/validation/TrimmedSizeValidator.java diff --git a/backend/src/main/java/io/f1/backend/domain/question/api/QuestionController.java b/backend/src/main/java/io/f1/backend/domain/question/api/QuestionController.java new file mode 100644 index 00000000..328ba7d9 --- /dev/null +++ b/backend/src/main/java/io/f1/backend/domain/question/api/QuestionController.java @@ -0,0 +1,44 @@ +package io.f1.backend.domain.question.api; + +import io.f1.backend.domain.question.app.QuestionService; +import io.f1.backend.domain.question.dto.QuestionUpdateRequest; + +import lombok.RequiredArgsConstructor; + +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.PathVariable; +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; + +@RestController +@RequestMapping("/questions") +@RequiredArgsConstructor +public class QuestionController { + + private final QuestionService questionService; + + @PutMapping("/{questionId}") + public ResponseEntity updateQuestion( + @PathVariable Long questionId, @RequestBody QuestionUpdateRequest request) { + + if (request.content() != null) { + questionService.updateQuestionContent(questionId, request.content()); + } + + if (request.content() != null) { + questionService.updateQuestionAnswer(questionId, request.answer()); + } + + return ResponseEntity.noContent().build(); + } + + @DeleteMapping("/{questionId}") + public ResponseEntity deleteQuestion(@PathVariable Long questionId) { + questionService.deleteQuestion(questionId); + + return ResponseEntity.noContent().build(); + } +} diff --git a/backend/src/main/java/io/f1/backend/domain/question/app/QuestionService.java b/backend/src/main/java/io/f1/backend/domain/question/app/QuestionService.java index 87a0e222..6fc7c954 100644 --- a/backend/src/main/java/io/f1/backend/domain/question/app/QuestionService.java +++ b/backend/src/main/java/io/f1/backend/domain/question/app/QuestionService.java @@ -15,6 +15,8 @@ import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import java.util.NoSuchElementException; + @Service @RequiredArgsConstructor public class QuestionService { @@ -33,4 +35,54 @@ public void saveQuestion(Quiz quiz, QuestionRequest request) { textQuestionRepository.save(textQuestion); question.addTextQuestion(textQuestion); } + + @Transactional + public void updateQuestionContent(Long questionId, String content) { + + validateContent(content); + + Question question = + questionRepository + .findById(questionId) + .orElseThrow(() -> new NoSuchElementException("존재하지 않는 문제입니다.")); + + TextQuestion textQuestion = question.getTextQuestion(); + textQuestion.changeContent(content); + } + + @Transactional + public void updateQuestionAnswer(Long questionId, String answer) { + + validateAnswer(answer); + + Question question = + questionRepository + .findById(questionId) + .orElseThrow(() -> new NoSuchElementException("존재하지 않는 문제입니다.")); + + question.changeAnswer(answer); + } + + @Transactional + public void deleteQuestion(Long questionId) { + + Question question = + questionRepository + .findById(questionId) + .orElseThrow(() -> new NoSuchElementException("존재하지 않는 문제입니다.")); + + questionRepository.delete(question); + } + + private void validateAnswer(String answer) { + if (answer.trim().length() < 5 || answer.trim().length() > 30) { + throw new IllegalArgumentException("정답은 1자 이상 30자 이하로 입력해주세요."); + } + } + + private void validateContent(String content) { + if (content.trim().length() < 5 || content.trim().length() > 30) { + throw new IllegalArgumentException("문제는 5자 이상 30자 이하로 입력해주세요."); + } + } } diff --git a/backend/src/main/java/io/f1/backend/domain/question/dto/QuestionRequest.java b/backend/src/main/java/io/f1/backend/domain/question/dto/QuestionRequest.java index 9374a9b9..4cdce5ff 100644 --- a/backend/src/main/java/io/f1/backend/domain/question/dto/QuestionRequest.java +++ b/backend/src/main/java/io/f1/backend/domain/question/dto/QuestionRequest.java @@ -1,5 +1,7 @@ package io.f1.backend.domain.question.dto; +import io.f1.backend.global.validation.TrimmedSize; + import jakarta.validation.constraints.NotBlank; import lombok.AccessLevel; @@ -10,9 +12,11 @@ @NoArgsConstructor(access = AccessLevel.PROTECTED) public class QuestionRequest { + @TrimmedSize(min = 5, max = 30) @NotBlank(message = "문제를 입력해주세요.") private String content; + @TrimmedSize(min = 1, max = 30) @NotBlank(message = "정답을 입력해주세요.") private String answer; } diff --git a/backend/src/main/java/io/f1/backend/domain/question/dto/QuestionUpdateRequest.java b/backend/src/main/java/io/f1/backend/domain/question/dto/QuestionUpdateRequest.java new file mode 100644 index 00000000..e77a3dea --- /dev/null +++ b/backend/src/main/java/io/f1/backend/domain/question/dto/QuestionUpdateRequest.java @@ -0,0 +1,3 @@ +package io.f1.backend.domain.question.dto; + +public record QuestionUpdateRequest(String content, String answer) {} diff --git a/backend/src/main/java/io/f1/backend/domain/question/entity/Question.java b/backend/src/main/java/io/f1/backend/domain/question/entity/Question.java index b197ac6e..fe6d0783 100644 --- a/backend/src/main/java/io/f1/backend/domain/question/entity/Question.java +++ b/backend/src/main/java/io/f1/backend/domain/question/entity/Question.java @@ -45,4 +45,8 @@ public Question(Quiz quiz, String answer) { public void addTextQuestion(TextQuestion textQuestion) { this.textQuestion = textQuestion; } + + public void changeAnswer(String answer) { + this.answer = answer; + } } diff --git a/backend/src/main/java/io/f1/backend/domain/question/entity/TextQuestion.java b/backend/src/main/java/io/f1/backend/domain/question/entity/TextQuestion.java index 7adf05c1..b10be109 100644 --- a/backend/src/main/java/io/f1/backend/domain/question/entity/TextQuestion.java +++ b/backend/src/main/java/io/f1/backend/domain/question/entity/TextQuestion.java @@ -32,4 +32,8 @@ public TextQuestion(Question question, String content) { this.question = question; this.content = content; } + + public void changeContent(String content) { + this.content = content; + } } diff --git a/backend/src/main/java/io/f1/backend/domain/quiz/api/QuizController.java b/backend/src/main/java/io/f1/backend/domain/quiz/api/QuizController.java index 64b4846e..ee8d03a6 100644 --- a/backend/src/main/java/io/f1/backend/domain/quiz/api/QuizController.java +++ b/backend/src/main/java/io/f1/backend/domain/quiz/api/QuizController.java @@ -61,7 +61,17 @@ public ResponseEntity updateQuiz( @RequestPart QuizUpdateRequest request) throws IOException { - quizService.updateQuiz(quizId, thumbnailFile, request); + if (request.title() != null) { + quizService.updateQuizTitle(quizId, request.title()); + } + + if (request.description() != null) { + quizService.updateQuizDesc(quizId, request.description()); + } + + if (thumbnailFile != null && !thumbnailFile.isEmpty()) { + quizService.updateThumbnail(quizId, thumbnailFile); + } return ResponseEntity.noContent().build(); } diff --git a/backend/src/main/java/io/f1/backend/domain/quiz/app/QuizService.java b/backend/src/main/java/io/f1/backend/domain/quiz/app/QuizService.java index 37c0585b..a74f0658 100644 --- a/backend/src/main/java/io/f1/backend/domain/quiz/app/QuizService.java +++ b/backend/src/main/java/io/f1/backend/domain/quiz/app/QuizService.java @@ -12,7 +12,6 @@ import io.f1.backend.domain.quiz.dto.QuizListPageResponse; import io.f1.backend.domain.quiz.dto.QuizListResponse; import io.f1.backend.domain.quiz.dto.QuizQuestionListResponse; -import io.f1.backend.domain.quiz.dto.QuizUpdateRequest; import io.f1.backend.domain.quiz.entity.Quiz; import io.f1.backend.domain.user.dao.UserRepository; import io.f1.backend.domain.user.entity.User; @@ -123,28 +122,52 @@ public void deleteQuiz(Long quizId) { } @Transactional - public void updateQuiz(Long quizId, MultipartFile thumbnailFile, QuizUpdateRequest request) - throws IOException { + public void updateQuizTitle(Long quizId, String title) { + Quiz quiz = + quizRepository + .findById(quizId) + .orElseThrow(() -> new NoSuchElementException("존재하지 않는 퀴즈입니다.")); + + validateTitle(title); + quiz.changeTitle(title); + } + + @Transactional + public void updateQuizDesc(Long quizId, String description) { Quiz quiz = quizRepository .findById(quizId) .orElseThrow(() -> new NoSuchElementException("존재하지 않는 퀴즈입니다.")); - if (request.title() != null) { - quiz.changeTitle(request.title()); - } + validateDesc(description); + quiz.changeDescription(description); + } - if (request.description() != null) { - quiz.changeDescription(request.description()); - } + @Transactional + public void updateThumbnail(Long quizId, MultipartFile thumbnailFile) throws IOException { - if (thumbnailFile != null && !thumbnailFile.isEmpty()) { - validateImageFile(thumbnailFile); - String newThumbnailPath = convertToThumbnailPath(thumbnailFile); + Quiz quiz = + quizRepository + .findById(quizId) + .orElseThrow(() -> new NoSuchElementException("존재하지 않는 퀴즈입니다.")); + + validateImageFile(thumbnailFile); + String newThumbnailPath = convertToThumbnailPath(thumbnailFile); + + deleteThumbnailFile(quiz.getThumbnailUrl()); + quiz.changeThumbnailUrl(newThumbnailPath); + } + + private void validateDesc(String desc) { + if (desc.trim().length() < 10 || desc.trim().length() > 50) { + throw new IllegalArgumentException("설명은 10자 이상 50자 이하로 입력해주세요."); + } + } - deleteThumbnailFile(quiz.getThumbnailUrl()); - quiz.changeThumbnailUrl(newThumbnailPath); + private void validateTitle(String title) { + if (title.trim().length() < 2 || title.trim().length() > 30) { + throw new IllegalArgumentException("제목은 2자 이상 30자 이하로 입력해주세요."); } } diff --git a/backend/src/main/java/io/f1/backend/domain/quiz/dto/QuizCreateRequest.java b/backend/src/main/java/io/f1/backend/domain/quiz/dto/QuizCreateRequest.java index 418477fb..313a519a 100644 --- a/backend/src/main/java/io/f1/backend/domain/quiz/dto/QuizCreateRequest.java +++ b/backend/src/main/java/io/f1/backend/domain/quiz/dto/QuizCreateRequest.java @@ -2,6 +2,7 @@ import io.f1.backend.domain.question.dto.QuestionRequest; import io.f1.backend.domain.quiz.entity.QuizType; +import io.f1.backend.global.validation.TrimmedSize; import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.NotNull; @@ -17,12 +18,14 @@ @NoArgsConstructor(access = AccessLevel.PROTECTED) public class QuizCreateRequest { + @TrimmedSize(min = 2, max = 30) @NotBlank(message = "퀴즈 제목을 설정해주세요.") private String title; @NotNull(message = "퀴즈 종류를 선택해주세요.") private QuizType quizType; + @TrimmedSize(min = 10, max = 50) @NotBlank(message = "퀴즈 설명을 적어주세요.") private String description; diff --git a/backend/src/main/java/io/f1/backend/global/validation/TrimmedSize.java b/backend/src/main/java/io/f1/backend/global/validation/TrimmedSize.java new file mode 100644 index 00000000..9b0d9fa9 --- /dev/null +++ b/backend/src/main/java/io/f1/backend/global/validation/TrimmedSize.java @@ -0,0 +1,23 @@ +package io.f1.backend.global.validation; + +import jakarta.validation.Constraint; +import jakarta.validation.Payload; + +import java.lang.annotation.*; + +@Documented +@Constraint(validatedBy = TrimmedSizeValidator.class) +@Target({ElementType.FIELD, ElementType.PARAMETER}) +@Retention(RetentionPolicy.RUNTIME) +public @interface TrimmedSize { + + String message() default "공백 제외 길이가 {min}자 이상 {min}자 이하여야 합니다."; + + int min() default 0; + + int max() default 50; + + Class[] groups() default {}; + + Class[] payload() default {}; +} diff --git a/backend/src/main/java/io/f1/backend/global/validation/TrimmedSizeValidator.java b/backend/src/main/java/io/f1/backend/global/validation/TrimmedSizeValidator.java new file mode 100644 index 00000000..f3c32e99 --- /dev/null +++ b/backend/src/main/java/io/f1/backend/global/validation/TrimmedSizeValidator.java @@ -0,0 +1,26 @@ +package io.f1.backend.global.validation; + +import jakarta.validation.ConstraintValidator; +import jakarta.validation.ConstraintValidatorContext; + +public class TrimmedSizeValidator implements ConstraintValidator { + + private int min; + private int max; + + @Override + public void initialize(TrimmedSize constraintAnnotation) { + this.min = constraintAnnotation.min(); + this.max = constraintAnnotation.max(); + } + + @Override + public boolean isValid(String value, ConstraintValidatorContext context) { + if (value == null) return true; + + String trimmed = value.trim(); + int length = trimmed.length(); + + return length >= min && length <= max; + } +} From 693240ffe8f56751febf7cf971bd6a923042953a Mon Sep 17 00:00:00 2001 From: hwangsehee <65377385+sehee123@users.noreply.github.com> Date: Wed, 16 Jul 2025 12:07:34 +0900 Subject: [PATCH 4/7] =?UTF-8?q?:sparkles:=20feat:=20=EC=9E=85=EC=9E=A5=20?= =?UTF-8?q?=EB=A1=9C=EC=A7=81=20=EB=B3=80=EA=B2=BD=20=EB=B0=8F=20=EB=8F=99?= =?UTF-8?q?=EC=8B=9C=EC=84=B1=20=EC=A0=81=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * :recycle: 소켓 연결 전 validation 체크 + enter 로직으로 변경 * :recycle: NPE 수정 * :sparkles: 동시성 추가 및 테스트 * chore: Java 스타일 수정 * :recycle: 리뷰 반영(메소드 분리) * :sparkles: 방장은 입장 시 ready=true * :recycle: test 중복 코드 정리 * chore: Java 스타일 수정 * :recycle: test code 빌더 수정 * chore: Java 스타일 수정 * :recycle: 컨트롤러 이름 통일 --------- Co-authored-by: github-actions <> --- .../domain/game/api/RoomController.java | 6 +- .../backend/domain/game/app/RoomService.java | 166 ++++++++++----- .../f1/backend/domain/game/model/Player.java | 4 + .../io/f1/backend/domain/game/model/Room.java | 14 ++ .../game/websocket/GameSocketController.java | 7 +- .../domain/game/app/RoomServiceTests.java | 190 ++++++++++++++++++ 6 files changed, 331 insertions(+), 56 deletions(-) create mode 100644 backend/src/test/java/io/f1/backend/domain/game/app/RoomServiceTests.java diff --git a/backend/src/main/java/io/f1/backend/domain/game/api/RoomController.java b/backend/src/main/java/io/f1/backend/domain/game/api/RoomController.java index 79a245de..e492d754 100644 --- a/backend/src/main/java/io/f1/backend/domain/game/api/RoomController.java +++ b/backend/src/main/java/io/f1/backend/domain/game/api/RoomController.java @@ -31,10 +31,10 @@ public RoomCreateResponse saveRoom(@RequestBody @Valid RoomCreateRequest request return roomService.saveRoom(request); } - @PostMapping("/validation") + @PostMapping("/enterRoom") @ResponseStatus(HttpStatus.NO_CONTENT) - public void validateRoom(@RequestBody @Valid RoomValidationRequest request) { - roomService.validateRoom(request); + public void enterRoom(@RequestBody @Valid RoomValidationRequest request) { + roomService.enterRoom(request); } @GetMapping diff --git a/backend/src/main/java/io/f1/backend/domain/game/app/RoomService.java b/backend/src/main/java/io/f1/backend/domain/game/app/RoomService.java index 723c8355..27c1522c 100644 --- a/backend/src/main/java/io/f1/backend/domain/game/app/RoomService.java +++ b/backend/src/main/java/io/f1/backend/domain/game/app/RoomService.java @@ -33,15 +33,19 @@ import io.f1.backend.domain.quiz.entity.Quiz; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.hibernate.boot.model.naming.IllegalIdentifierException; import org.springframework.context.ApplicationEventPublisher; import org.springframework.stereotype.Service; import java.util.List; import java.util.Map; import java.util.Optional; +import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.atomic.AtomicLong; +@Slf4j @Service @RequiredArgsConstructor public class RoomService { @@ -50,6 +54,8 @@ public class RoomService { private final RoomRepository roomRepository; private final AtomicLong roomIdGenerator = new AtomicLong(0); private final ApplicationEventPublisher eventPublisher; + private final Map roomLocks = new ConcurrentHashMap<>(); + private static final String PENDING_SESSION_ID = "PENDING_SESSION_ID"; public RoomCreateResponse saveRoom(RoomCreateRequest request) { @@ -66,6 +72,8 @@ public RoomCreateResponse saveRoom(RoomCreateRequest request) { Room room = new Room(newId, roomSetting, gameSetting, host); + room.getUserIdSessionMap().put(host.id, PENDING_SESSION_ID); + roomRepository.saveRoom(room); eventPublisher.publishEvent(new RoomCreatedEvent(room, quiz)); @@ -73,41 +81,53 @@ public RoomCreateResponse saveRoom(RoomCreateRequest request) { return new RoomCreateResponse(newId); } - public void validateRoom(RoomValidationRequest request) { + public void enterRoom(RoomValidationRequest request) { - Room room = - roomRepository - .findRoom(request.roomId()) - .orElseThrow(() -> new IllegalArgumentException("404 존재하지 않는 방입니다.-1")); + Long roomId = request.roomId(); - if (room.getState().equals(RoomState.PLAYING)) { - throw new IllegalArgumentException("403 게임이 진행중입니다."); - } + Object lock = roomLocks.computeIfAbsent(roomId, k -> new Object()); - int maxUserCnt = room.getRoomSetting().maxUserCount(); - int currentCnt = room.getPlayerSessionMap().size(); - if (maxUserCnt == currentCnt) { - throw new IllegalArgumentException("403 정원이 모두 찼습니다."); - } + synchronized (lock) { + Room room = findRoom(request.roomId()); + + if (room.getState().equals(RoomState.PLAYING)) { + throw new IllegalArgumentException("403 게임이 진행중입니다."); + } + + int maxUserCnt = room.getRoomSetting().maxUserCount(); + int currentCnt = room.getUserIdSessionMap().size(); + if (maxUserCnt == currentCnt) { + throw new IllegalArgumentException("403 정원이 모두 찼습니다."); + } - if (room.getRoomSetting().locked() - && !room.getRoomSetting().password().equals(request.password())) { - throw new IllegalArgumentException("401 비밀번호가 일치하지 않습니다."); + if (room.getRoomSetting().locked() + && !room.getRoomSetting().password().equals(request.password())) { + throw new IllegalArgumentException("401 비밀번호가 일치하지 않습니다."); + } + + room.getUserIdSessionMap().put(getCurrentUserId(), PENDING_SESSION_ID); } } - public RoomInitialData enterRoom(Long roomId, String sessionId) { + public RoomInitialData initializeRoomSocket(Long roomId, String sessionId) { - Room room = - roomRepository - .findRoom(roomId) - .orElseThrow(() -> new IllegalArgumentException("404 존재하지 않는 방입니다.")); + Room room = findRoom(roomId); Player player = createPlayer(); Map playerSessionMap = room.getPlayerSessionMap(); + Map userIdSessionMap = room.getUserIdSessionMap(); + + if (room.isHost(player.getId())) { + player.toggleReady(); + } playerSessionMap.put(sessionId, player); + String existingSession = userIdSessionMap.get(player.getId()); + /* 정상 흐름 or 재연결 */ + if (existingSession.equals(PENDING_SESSION_ID) || !existingSession.equals(sessionId)) { + userIdSessionMap.put(player.getId(), sessionId); + } RoomSettingResponse roomSettingResponse = toRoomSettingResponse(room); @@ -130,42 +150,36 @@ public RoomInitialData enterRoom(Long roomId, String sessionId) { } public RoomExitData exitRoom(Long roomId, String sessionId) { - Room room = - roomRepository - .findRoom(roomId) - .orElseThrow(() -> new IllegalArgumentException("404 존재하지 않는 방입니다.")); - Map playerSessionMap = room.getPlayerSessionMap(); + Object lock = roomLocks.computeIfAbsent(roomId, k -> new Object()); - String destination = getDestination(roomId); + synchronized (lock) { + Room room = findRoom(roomId); - if (playerSessionMap.size() == 1 && playerSessionMap.get(sessionId) != null) { - roomRepository.removeRoom(roomId); - return RoomExitData.builder().destination(destination).removedRoom(true).build(); - } + String destination = getDestination(roomId); - Player removedPlayer = playerSessionMap.remove(sessionId); - if (removedPlayer == null) { - throw new IllegalArgumentException("퇴장 처리 불가 - 404 해당 세션 플레이어는 존재하지않습니다."); - } + Player removePlayer = getRemovePlayer(room, sessionId); - if (room.getHost().getId().equals(removedPlayer.getId())) { - Optional nextHostSessionId = playerSessionMap.keySet().stream().findFirst(); - Player nextHost = - playerSessionMap.get( - nextHostSessionId.orElseThrow( - () -> - new IllegalArgumentException( - "방장 교체 불가 - 404 해당 세션 플레이어는 존재하지않습니다."))); - room.updateHost(nextHost); - } + /* 방 삭제 */ + if (isLastPlayer(room, sessionId)) { + return removeRoom(room, destination); + } - SystemNoticeResponse systemNoticeResponse = - ofPlayerEvent(removedPlayer, RoomEventType.EXIT); + /* 방장 변경 */ + if (room.isHost(removePlayer.getId())) { + changeHost(room, sessionId); + } - PlayerListResponse playerListResponse = toPlayerListResponse(room); + /* 플레이어 삭제 */ + removePlayer(room, sessionId, removePlayer); - return new RoomExitData(destination, playerListResponse, systemNoticeResponse, false); + SystemNoticeResponse systemNoticeResponse = + ofPlayerEvent(removePlayer, RoomEventType.EXIT); + + PlayerListResponse playerListResponse = toPlayerListResponse(room); + + return new RoomExitData(destination, playerListResponse, systemNoticeResponse, false); + } } public RoomListResponse getAllRooms() { @@ -183,11 +197,63 @@ public RoomListResponse getAllRooms() { return new RoomListResponse(roomResponses); } + private Player getRemovePlayer(Room room, String sessionId) { + Player removePlayer = room.getPlayerSessionMap().get(sessionId); + if (removePlayer == null) { + room.removeUserId(getCurrentUserId()); + throw new IllegalIdentifierException("404 세션 없음 비정상적인 퇴장 요청"); + } + return removePlayer; + } + private static String getDestination(Long roomId) { return "/sub/room/" + roomId; } - private static Player createPlayer() { + private Player createPlayer() { return new Player(getCurrentUserId(), getCurrentUserNickname()); } + + private Room findRoom(Long roomId) { + return roomRepository + .findRoom(roomId) + .orElseThrow(() -> new IllegalArgumentException("404 존재하지 않는 방입니다.")); + } + + private boolean isLastPlayer(Room room, String sessionId) { + Map playerSessionMap = room.getPlayerSessionMap(); + return playerSessionMap.size() == 1 && playerSessionMap.containsKey(sessionId); + } + + private RoomExitData removeRoom(Room room, String destination) { + Long roomId = room.getId(); + roomRepository.removeRoom(roomId); + roomLocks.remove(roomId); + log.info("{}번 방 삭제", roomId); + return RoomExitData.builder().destination(destination).removedRoom(true).build(); + } + + private void changeHost(Room room, String hostSessionId) { + Map playerSessionMap = room.getPlayerSessionMap(); + + Optional nextHostSessionId = + playerSessionMap.keySet().stream() + .filter(key -> !key.equals(hostSessionId)) + .findFirst(); + + Player nextHost = + playerSessionMap.get( + nextHostSessionId.orElseThrow( + () -> + new IllegalArgumentException( + "방장 교체 불가 - 404 해당 세션 플레이어는 존재하지않습니다."))); + + room.updateHost(nextHost); + log.info("user_id:{} 방장 변경 완료 ", nextHost.getId()); + } + + private void removePlayer(Room room, String sessionId, Player removePlayer) { + room.removeUserId(removePlayer.getId()); + room.removeSessionId(sessionId); + } } diff --git a/backend/src/main/java/io/f1/backend/domain/game/model/Player.java b/backend/src/main/java/io/f1/backend/domain/game/model/Player.java index 3d3230c1..a5b1241c 100644 --- a/backend/src/main/java/io/f1/backend/domain/game/model/Player.java +++ b/backend/src/main/java/io/f1/backend/domain/game/model/Player.java @@ -19,4 +19,8 @@ public Player(Long id, String nickname) { this.id = id; this.nickname = nickname; } + + public void toggleReady() { + this.isReady = !this.isReady; + } } diff --git a/backend/src/main/java/io/f1/backend/domain/game/model/Room.java b/backend/src/main/java/io/f1/backend/domain/game/model/Room.java index 00b7e71b..64310c3f 100644 --- a/backend/src/main/java/io/f1/backend/domain/game/model/Room.java +++ b/backend/src/main/java/io/f1/backend/domain/game/model/Room.java @@ -27,6 +27,8 @@ public class Room { private Map playerSessionMap = new ConcurrentHashMap<>(); + private Map userIdSessionMap = new ConcurrentHashMap<>(); + private final LocalDateTime createdAt = LocalDateTime.now(); public Room(Long id, RoomSetting roomSetting, GameSetting gameSetting, Player host) { @@ -36,7 +38,19 @@ public Room(Long id, RoomSetting roomSetting, GameSetting gameSetting, Player ho this.host = host; } + public boolean isHost(Long id) { + return this.host.getId().equals(id); + } + public void updateHost(Player nextHost) { this.host = nextHost; } + + public void removeUserId(Long id) { + this.userIdSessionMap.remove(id); + } + + public void removeSessionId(String sessionId) { + this.playerSessionMap.remove(sessionId); + } } diff --git a/backend/src/main/java/io/f1/backend/domain/game/websocket/GameSocketController.java b/backend/src/main/java/io/f1/backend/domain/game/websocket/GameSocketController.java index 9bf2f17f..4cebfc09 100644 --- a/backend/src/main/java/io/f1/backend/domain/game/websocket/GameSocketController.java +++ b/backend/src/main/java/io/f1/backend/domain/game/websocket/GameSocketController.java @@ -20,12 +20,13 @@ public class GameSocketController { private final MessageSender messageSender; private final RoomService roomService; - @MessageMapping("/room/enter/{roomId}") - public void roomEnter(@DestinationVariable Long roomId, Message message) { + @MessageMapping("/room/initializeRoomSocket/{roomId}") + public void initializeRoomSocket(@DestinationVariable Long roomId, Message message) { String websocketSessionId = getSessionId(message); - RoomInitialData roomInitialData = roomService.enterRoom(roomId, websocketSessionId); + RoomInitialData roomInitialData = + roomService.initializeRoomSocket(roomId, websocketSessionId); String destination = roomInitialData.destination(); messageSender.send( diff --git a/backend/src/test/java/io/f1/backend/domain/game/app/RoomServiceTests.java b/backend/src/test/java/io/f1/backend/domain/game/app/RoomServiceTests.java new file mode 100644 index 00000000..2c9df079 --- /dev/null +++ b/backend/src/test/java/io/f1/backend/domain/game/app/RoomServiceTests.java @@ -0,0 +1,190 @@ +package io.f1.backend.domain.game.app; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.doNothing; +import static org.mockito.Mockito.when; + +import io.f1.backend.domain.game.dto.request.RoomValidationRequest; +import io.f1.backend.domain.game.model.GameSetting; +import io.f1.backend.domain.game.model.Player; +import io.f1.backend.domain.game.model.Room; +import io.f1.backend.domain.game.model.RoomSetting; +import io.f1.backend.domain.game.store.RoomRepository; +import io.f1.backend.domain.quiz.app.QuizService; +import io.f1.backend.domain.user.entity.User; +import io.f1.backend.global.util.SecurityUtils; + +import lombok.extern.slf4j.Slf4j; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.MockitoAnnotations; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.security.core.context.SecurityContextHolder; + +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; + +@Slf4j +@ExtendWith(MockitoExtension.class) +class RoomServiceTests { + + private RoomService roomService; + + @Mock private RoomRepository roomRepository; + @Mock private QuizService quizService; + @Mock private ApplicationEventPublisher eventPublisher; + + @BeforeEach + void setUp() { + MockitoAnnotations.openMocks(this); // @Mock 어노테이션이 붙은 필드들을 초기화합니다. + roomService = new RoomService(quizService, roomRepository, eventPublisher); + + SecurityContextHolder.clearContext(); + } + + @AfterEach + void afterEach() { + SecurityContextHolder.clearContext(); + } + + @Test + @DisplayName("enterRoom_동시성_테스트") + void enterRoom_synchronized() throws Exception { + Long roomId = 1L; + Long quizId = 1L; + Long playerId = 1L; + int maxUserCount = 5; + String password = "123"; + boolean locked = true; + + Room room = createRoom(roomId, playerId, quizId, password, maxUserCount, locked); + + when(roomRepository.findRoom(roomId)).thenReturn(Optional.of(room)); + + int threadCount = 10; + ExecutorService executorService = Executors.newFixedThreadPool(threadCount); + CountDownLatch countDownLatch = new CountDownLatch(threadCount); + RoomValidationRequest roomValidationRequest = new RoomValidationRequest(roomId, password); + for (int i = 1; i <= threadCount; i++) { + User user = createUser(i); + + executorService.submit( + () -> { + try { + SecurityUtils.setAuthentication(user); + roomService.enterRoom(roomValidationRequest); + } catch (Exception e) { + e.printStackTrace(); + } finally { + SecurityContextHolder.clearContext(); + countDownLatch.countDown(); + } + }); + } + countDownLatch.await(); + assertThat(room.getUserIdSessionMap()).hasSize(room.getRoomSetting().maxUserCount()); + } + + @Test + @DisplayName("exitRoom_동시성_테스트") + void exitRoom_synchronized() throws Exception { + Long roomId = 1L; + Long quizId = 1L; + Long playerId = 1L; + int maxUserCount = 5; + String password = "123"; + boolean locked = true; + + Room room = createRoom(roomId, playerId, quizId, password, maxUserCount, locked); + + int threadCount = 10; + + List players = new ArrayList<>(); + for (int i = 1; i <= threadCount; i++) { + Long id = i + 1L; + String nickname = "nickname " + i; + + Player player = new Player(id, nickname); + players.add(player); + } + Player host = players.getFirst(); + room.updateHost(host); + + for (int i = 1; i <= threadCount; i++) { + String sessionId = "sessionId" + i; + Player player = players.get(i - 1); + room.getPlayerSessionMap().put(sessionId, player); + room.getUserIdSessionMap().put(player.getId(), sessionId); + } + + log.info("room.getPlayerSessionMap().size() = {}", room.getPlayerSessionMap().size()); + + when(roomRepository.findRoom(roomId)).thenReturn(Optional.of(room)); + doNothing().when(roomRepository).removeRoom(roomId); + + ExecutorService executorService = Executors.newFixedThreadPool(threadCount); + CountDownLatch countDownLatch = new CountDownLatch(threadCount); + + for (int i = 1; i <= threadCount; i++) { + String sessionId = "sessionId" + i; + User user = createUser(i); + executorService.submit( + () -> { + try { + SecurityUtils.setAuthentication(user); + log.info("room.getHost().getId() = {}", room.getHost().getId()); + roomService.exitRoom(roomId, sessionId); + } catch (Exception e) { + e.printStackTrace(); + } finally { + SecurityContextHolder.clearContext(); + countDownLatch.countDown(); + } + }); + } + countDownLatch.await(); + assertThat(room.getUserIdSessionMap()).hasSize(1); + } + + private Room createRoom( + Long roomId, + Long playerId, + Long quizId, + String password, + int maxUserCount, + boolean locked) { + RoomSetting roomSetting = new RoomSetting("방제목", maxUserCount, locked, password); + GameSetting gameSetting = new GameSetting(quizId, 10, 60); + Player host = new Player(playerId, "nickname"); + + return new Room(roomId, roomSetting, gameSetting, host); + } + + private User createUser(int i) { + Long userId = i + 1L; + String provider = "provider +" + i; + String providerId = "providerId" + i; + LocalDateTime lastLogin = LocalDateTime.now(); + + User user = + User.builder() + .provider(provider) + .providerId(providerId) + .lastLogin(lastLogin) + .build(); + user.setId(userId); + + return user; + } +} From a2048dda48ff13572867c678fe164315d905b386 Mon Sep 17 00:00:00 2001 From: "Lee Eunjoo (Silver)" <80297399+silver-eunjoo@users.noreply.github.com> Date: Wed, 16 Jul 2025 15:07:54 +0900 Subject: [PATCH 5/7] =?UTF-8?q?:sparkles:=20feat=20:=20=EB=B0=A9=20?= =?UTF-8?q?=EA=B2=8C=EC=9E=84=20=EC=8B=9C=EC=9E=91=20API=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84=20+=20SSE=20=EC=9D=B4=EB=B2=A4=ED=8A=B8=20=ED=8D=BC?= =?UTF-8?q?=EB=B8=94=EB=A6=AC=EC=8B=B1=20(#51)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * :sparkles: feat : 정답없이 문제 조회 * :sparkles: feat : 방 게임 시작 구현 * chore: Java 스타일 수정 * :wrench: chore : 시큐리티 필터 임시 허용 해제 * :recycle: refactor : GameService 분리 * chore: Java 스타일 수정 * :wrench: chore : merge 잘못 한 거 수정 * chore: Java 스타일 수정 * :sparkles: 게임 시작 상태 SSE로 이벤트 퍼블리시 * chore: Java 스타일 수정 * :recycle: refactor : PR 리뷰 반영(stream().allMatch, gameSetting 내부 메서드) * chore: Java 스타일 수정 --------- Co-authored-by: github-actions <> --- .../backend/domain/game/app/GameService.java | 77 +++++++++++++++++++ .../backend/domain/game/app/RoomService.java | 6 +- .../domain/game/dto/GameStartData.java | 5 ++ .../backend/domain/game/dto/MessageType.java | 1 + .../game/dto/request/GameStartRequest.java | 3 + .../game/dto/response/GameStartResponse.java | 7 ++ .../domain/game/model/GameSetting.java | 7 ++ .../io/f1/backend/domain/game/model/Room.java | 4 + .../game/websocket/GameSocketController.java | 16 ++++ .../backend/domain/quiz/app/QuizService.java | 19 ++++- .../domain/quiz/dao/QuizRepository.java | 12 +++ .../domain/quiz/dto/GameQuestionResponse.java | 3 + .../domain/quiz/mapper/QuizMapper.java | 14 ++++ 13 files changed, 168 insertions(+), 6 deletions(-) create mode 100644 backend/src/main/java/io/f1/backend/domain/game/app/GameService.java create mode 100644 backend/src/main/java/io/f1/backend/domain/game/dto/GameStartData.java create mode 100644 backend/src/main/java/io/f1/backend/domain/game/dto/request/GameStartRequest.java create mode 100644 backend/src/main/java/io/f1/backend/domain/game/dto/response/GameStartResponse.java create mode 100644 backend/src/main/java/io/f1/backend/domain/quiz/dto/GameQuestionResponse.java diff --git a/backend/src/main/java/io/f1/backend/domain/game/app/GameService.java b/backend/src/main/java/io/f1/backend/domain/game/app/GameService.java new file mode 100644 index 00000000..12cbd7ed --- /dev/null +++ b/backend/src/main/java/io/f1/backend/domain/game/app/GameService.java @@ -0,0 +1,77 @@ +package io.f1.backend.domain.game.app; + +import io.f1.backend.domain.game.dto.GameStartData; +import io.f1.backend.domain.game.dto.response.GameStartResponse; +import io.f1.backend.domain.game.event.RoomUpdatedEvent; +import io.f1.backend.domain.game.model.GameSetting; +import io.f1.backend.domain.game.model.Player; +import io.f1.backend.domain.game.model.Room; +import io.f1.backend.domain.game.model.RoomState; +import io.f1.backend.domain.game.store.RoomRepository; +import io.f1.backend.domain.quiz.app.QuizService; +import io.f1.backend.domain.quiz.entity.Quiz; + +import lombok.RequiredArgsConstructor; + +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.stereotype.Service; + +import java.util.Map; + +@Service +@RequiredArgsConstructor +public class GameService { + + private final QuizService quizService; + private final RoomRepository roomRepository; + private final ApplicationEventPublisher eventPublisher; + + public GameStartData gameStart(Long roomId, Long quizId) { + + Room room = + roomRepository + .findRoom(roomId) + .orElseThrow(() -> new IllegalArgumentException("404 존재하지 않는 방입니다.")); + + if (!validateReadyStatus(room)) { + throw new IllegalArgumentException("E403004 : 레디 상태가 아닙니다."); + } + + // 방의 gameSetting에 설정된 퀴즈랑 요청 퀴즈랑 같은지 체크 후 GameSetting에서 라운드 가져오기 + Integer round = checkGameSetting(room, quizId); + + Quiz quiz = quizService.getQuizWithQuestionsById(quizId); + + // 라운드 수만큼 랜덤 Question 추출 + GameStartResponse questions = quizService.getRandomQuestionsWithoutAnswer(quizId, round); + + // 방 정보 게임 중으로 변경 + room.updateRoomState(RoomState.PLAYING); + + eventPublisher.publishEvent(new RoomUpdatedEvent(room, quiz)); + + return new GameStartData(getDestination(roomId), questions); + } + + private Integer checkGameSetting(Room room, Long quizId) { + + GameSetting gameSetting = room.getGameSetting(); + + if (!gameSetting.checkQuizId(quizId)) { + throw new IllegalArgumentException("E409002 : 게임 설정이 다릅니다. (게임을 시작할 수 없습니다.)"); + } + + return gameSetting.getRound(); + } + + private boolean validateReadyStatus(Room room) { + + Map playerSessionMap = room.getPlayerSessionMap(); + + return playerSessionMap.values().stream().allMatch(Player::isReady); + } + + private static String getDestination(Long roomId) { + return "/sub/room/" + roomId; + } +} diff --git a/backend/src/main/java/io/f1/backend/domain/game/app/RoomService.java b/backend/src/main/java/io/f1/backend/domain/game/app/RoomService.java index 27c1522c..dd31a875 100644 --- a/backend/src/main/java/io/f1/backend/domain/game/app/RoomService.java +++ b/backend/src/main/java/io/f1/backend/domain/game/app/RoomService.java @@ -60,7 +60,7 @@ public class RoomService { public RoomCreateResponse saveRoom(RoomCreateRequest request) { Long quizMinId = quizService.getQuizMinId(); - Quiz quiz = quizService.getQuizById(quizMinId); + Quiz quiz = quizService.getQuizWithQuestionsById(quizMinId); GameSetting gameSetting = toGameSetting(quiz); @@ -132,7 +132,7 @@ public RoomInitialData initializeRoomSocket(Long roomId, String sessionId) { RoomSettingResponse roomSettingResponse = toRoomSettingResponse(room); Long quizId = room.getGameSetting().getQuizId(); - Quiz quiz = quizService.getQuizById(quizId); + Quiz quiz = quizService.getQuizWithQuestionsById(quizId); GameSettingResponse gameSettingResponse = toGameSettingResponse(room.getGameSetting(), quiz); @@ -189,7 +189,7 @@ public RoomListResponse getAllRooms() { .map( room -> { Long quizId = room.getGameSetting().getQuizId(); - Quiz quiz = quizService.getQuizById(quizId); + Quiz quiz = quizService.getQuizWithQuestionsById(quizId); return toRoomResponse(room, quiz); }) diff --git a/backend/src/main/java/io/f1/backend/domain/game/dto/GameStartData.java b/backend/src/main/java/io/f1/backend/domain/game/dto/GameStartData.java new file mode 100644 index 00000000..c68d056e --- /dev/null +++ b/backend/src/main/java/io/f1/backend/domain/game/dto/GameStartData.java @@ -0,0 +1,5 @@ +package io.f1.backend.domain.game.dto; + +import io.f1.backend.domain.game.dto.response.GameStartResponse; + +public record GameStartData(String destination, GameStartResponse gameStartResponse) {} diff --git a/backend/src/main/java/io/f1/backend/domain/game/dto/MessageType.java b/backend/src/main/java/io/f1/backend/domain/game/dto/MessageType.java index f6d40420..22dfdee2 100644 --- a/backend/src/main/java/io/f1/backend/domain/game/dto/MessageType.java +++ b/backend/src/main/java/io/f1/backend/domain/game/dto/MessageType.java @@ -5,4 +5,5 @@ public enum MessageType { GAME_SETTING, PLAYER_LIST, SYSTEM_NOTICE, + GAME_START, } diff --git a/backend/src/main/java/io/f1/backend/domain/game/dto/request/GameStartRequest.java b/backend/src/main/java/io/f1/backend/domain/game/dto/request/GameStartRequest.java new file mode 100644 index 00000000..61f792ea --- /dev/null +++ b/backend/src/main/java/io/f1/backend/domain/game/dto/request/GameStartRequest.java @@ -0,0 +1,3 @@ +package io.f1.backend.domain.game.dto.request; + +public record GameStartRequest(Long quizId) {} diff --git a/backend/src/main/java/io/f1/backend/domain/game/dto/response/GameStartResponse.java b/backend/src/main/java/io/f1/backend/domain/game/dto/response/GameStartResponse.java new file mode 100644 index 00000000..9545a8ff --- /dev/null +++ b/backend/src/main/java/io/f1/backend/domain/game/dto/response/GameStartResponse.java @@ -0,0 +1,7 @@ +package io.f1.backend.domain.game.dto.response; + +import io.f1.backend.domain.quiz.dto.GameQuestionResponse; + +import java.util.List; + +public record GameStartResponse(List questions) {} diff --git a/backend/src/main/java/io/f1/backend/domain/game/model/GameSetting.java b/backend/src/main/java/io/f1/backend/domain/game/model/GameSetting.java index 7643d861..7634d114 100644 --- a/backend/src/main/java/io/f1/backend/domain/game/model/GameSetting.java +++ b/backend/src/main/java/io/f1/backend/domain/game/model/GameSetting.java @@ -10,4 +10,11 @@ public class GameSetting { private Long quizId; private Integer round; // 게임 변경 시 해당 게임의 총 문제 수로 설정 private int timeLimit = 60; + + public boolean checkQuizId(Long quizId) { + if (this.quizId != null && this.quizId.equals(quizId)) { + return false; + } + return true; + } } diff --git a/backend/src/main/java/io/f1/backend/domain/game/model/Room.java b/backend/src/main/java/io/f1/backend/domain/game/model/Room.java index 64310c3f..d42abc8d 100644 --- a/backend/src/main/java/io/f1/backend/domain/game/model/Room.java +++ b/backend/src/main/java/io/f1/backend/domain/game/model/Room.java @@ -46,6 +46,10 @@ public void updateHost(Player nextHost) { this.host = nextHost; } + public void updateRoomState(RoomState newState) { + this.state = newState; + } + public void removeUserId(Long id) { this.userIdSessionMap.remove(id); } diff --git a/backend/src/main/java/io/f1/backend/domain/game/websocket/GameSocketController.java b/backend/src/main/java/io/f1/backend/domain/game/websocket/GameSocketController.java index 4cebfc09..e84e9c36 100644 --- a/backend/src/main/java/io/f1/backend/domain/game/websocket/GameSocketController.java +++ b/backend/src/main/java/io/f1/backend/domain/game/websocket/GameSocketController.java @@ -1,9 +1,12 @@ package io.f1.backend.domain.game.websocket; +import io.f1.backend.domain.game.app.GameService; import io.f1.backend.domain.game.app.RoomService; +import io.f1.backend.domain.game.dto.GameStartData; import io.f1.backend.domain.game.dto.MessageType; import io.f1.backend.domain.game.dto.RoomExitData; import io.f1.backend.domain.game.dto.RoomInitialData; +import io.f1.backend.domain.game.dto.request.GameStartRequest; import lombok.RequiredArgsConstructor; @@ -19,6 +22,7 @@ public class GameSocketController { private final MessageSender messageSender; private final RoomService roomService; + private final GameService gameService; @MessageMapping("/room/initializeRoomSocket/{roomId}") public void initializeRoomSocket(@DestinationVariable Long roomId, Message message) { @@ -56,6 +60,18 @@ public void exitRoom(@DestinationVariable Long roomId, Message message) { } } + @MessageMapping("/room/start/{roomId}") + public void gameStart(@DestinationVariable Long roomId, Message message) { + + Long quizId = message.getPayload().quizId(); + + GameStartData gameStartData = gameService.gameStart(roomId, quizId); + + String destination = gameStartData.destination(); + + messageSender.send(destination, MessageType.GAME_START, gameStartData.gameStartResponse()); + } + private static String getSessionId(Message message) { StompHeaderAccessor accessor = StompHeaderAccessor.wrap(message); return accessor.getSessionId(); diff --git a/backend/src/main/java/io/f1/backend/domain/quiz/app/QuizService.java b/backend/src/main/java/io/f1/backend/domain/quiz/app/QuizService.java index a74f0658..ce705fb6 100644 --- a/backend/src/main/java/io/f1/backend/domain/quiz/app/QuizService.java +++ b/backend/src/main/java/io/f1/backend/domain/quiz/app/QuizService.java @@ -4,8 +4,10 @@ import static java.nio.file.Files.deleteIfExists; +import io.f1.backend.domain.game.dto.response.GameStartResponse; import io.f1.backend.domain.question.app.QuestionService; import io.f1.backend.domain.question.dto.QuestionRequest; +import io.f1.backend.domain.question.entity.Question; import io.f1.backend.domain.quiz.dao.QuizRepository; import io.f1.backend.domain.quiz.dto.QuizCreateRequest; import io.f1.backend.domain.quiz.dto.QuizCreateResponse; @@ -214,12 +216,11 @@ public QuizListPageResponse getQuizzes(String title, String creator, Pageable pa } @Transactional(readOnly = true) - public Quiz getQuizById(Long quizId) { + public Quiz getQuizWithQuestionsById(Long quizId) { Quiz quiz = quizRepository - .findById(quizId) + .findQuizWithQuestionsById(quizId) .orElseThrow(() -> new RuntimeException("E404002: 존재하지 않는 퀴즈입니다.")); - quiz.getQuestions().size(); return quiz; } @@ -228,6 +229,7 @@ public Long getQuizMinId() { return quizRepository.getQuizMinId(); } + @Transactional(readOnly = true) public QuizQuestionListResponse getQuizWithQuestions(Long quizId) { Quiz quiz = quizRepository @@ -236,4 +238,15 @@ public QuizQuestionListResponse getQuizWithQuestions(Long quizId) { return quizToQuizQuestionListResponse(quiz); } + + @Transactional(readOnly = true) + public GameStartResponse getRandomQuestionsWithoutAnswer(Long quizId, Integer round) { + quizRepository + .findById(quizId) + .orElseThrow(() -> new NoSuchElementException("존재하지 않는 퀴즈입니다.")); + + List randomQuestions = quizRepository.findRandQuestionsByQuizId(quizId, round); + + return toGameStartResponse(randomQuestions); + } } diff --git a/backend/src/main/java/io/f1/backend/domain/quiz/dao/QuizRepository.java b/backend/src/main/java/io/f1/backend/domain/quiz/dao/QuizRepository.java index d5021088..b2d1728d 100644 --- a/backend/src/main/java/io/f1/backend/domain/quiz/dao/QuizRepository.java +++ b/backend/src/main/java/io/f1/backend/domain/quiz/dao/QuizRepository.java @@ -1,5 +1,6 @@ package io.f1.backend.domain.quiz.dao; +import io.f1.backend.domain.question.entity.Question; import io.f1.backend.domain.quiz.entity.Quiz; import org.springframework.data.domain.Page; @@ -7,12 +8,23 @@ import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.Query; +import java.util.List; +import java.util.Optional; + public interface QuizRepository extends JpaRepository { Page findQuizzesByTitleContaining(String title, Pageable pageable); Page findQuizzesByCreator_NicknameContaining(String creator, Pageable pageable); + @Query("SELECT q FROM Quiz q LEFT JOIN FETCH q.questions WHERE q.id = :quizId") + Optional findQuizWithQuestionsById(Long quizId); + @Query("SELECT MIN(q.id) FROM Quiz q") Long getQuizMinId(); + + @Query( + value = "SELECT * FROM question WHERE quiz_id = :quizId ORDER BY RAND() LIMIT :round", + nativeQuery = true) + List findRandQuestionsByQuizId(Long quizId, Integer round); } diff --git a/backend/src/main/java/io/f1/backend/domain/quiz/dto/GameQuestionResponse.java b/backend/src/main/java/io/f1/backend/domain/quiz/dto/GameQuestionResponse.java new file mode 100644 index 00000000..a47b93d9 --- /dev/null +++ b/backend/src/main/java/io/f1/backend/domain/quiz/dto/GameQuestionResponse.java @@ -0,0 +1,3 @@ +package io.f1.backend.domain.quiz.dto; + +public record GameQuestionResponse(Long id, String question) {} diff --git a/backend/src/main/java/io/f1/backend/domain/quiz/mapper/QuizMapper.java b/backend/src/main/java/io/f1/backend/domain/quiz/mapper/QuizMapper.java index b515f679..0c0c0513 100644 --- a/backend/src/main/java/io/f1/backend/domain/quiz/mapper/QuizMapper.java +++ b/backend/src/main/java/io/f1/backend/domain/quiz/mapper/QuizMapper.java @@ -1,7 +1,9 @@ package io.f1.backend.domain.quiz.mapper; +import io.f1.backend.domain.game.dto.response.GameStartResponse; import io.f1.backend.domain.question.dto.QuestionResponse; import io.f1.backend.domain.question.entity.Question; +import io.f1.backend.domain.quiz.dto.GameQuestionResponse; import io.f1.backend.domain.quiz.dto.QuizCreateRequest; import io.f1.backend.domain.quiz.dto.QuizCreateResponse; import io.f1.backend.domain.quiz.dto.QuizListPageResponse; @@ -83,4 +85,16 @@ public static QuizQuestionListResponse quizToQuizQuestionListResponse(Quiz quiz) quiz.getQuestions().size(), questionsToQuestionResponses(quiz.getQuestions())); } + + public static List toGameQuestionResponseList(List questions) { + return questions.stream().map(QuizMapper::toGameQuestionResponse).toList(); + } + + public static GameQuestionResponse toGameQuestionResponse(Question question) { + return new GameQuestionResponse(question.getId(), question.getTextQuestion().getContent()); + } + + public static GameStartResponse toGameStartResponse(List questions) { + return new GameStartResponse(toGameQuestionResponseList(questions)); + } } From c4834dedd2ae9c43ed79bc1c4baa90ea8f93cd65 Mon Sep 17 00:00:00 2001 From: hwangsehee <65377385+sehee123@users.noreply.github.com> Date: Wed, 16 Jul 2025 15:12:58 +0900 Subject: [PATCH 6/7] =?UTF-8?q?:recycle:=20refactor:=20=EC=BB=A4=EC=8A=A4?= =?UTF-8?q?=ED=85=80=20=EC=98=88=EC=99=B8=20=EC=A0=81=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * :recycle: 커스텀 예외 적용 * chore: Java 스타일 수정 --------- Co-authored-by: github-actions <> --- .../backend/domain/game/app/RoomService.java | 17 +++++++------- .../config/StompChannelInterceptor.java | 22 ++++++++----------- .../exception/errorcode/RoomErrorCode.java | 3 ++- 3 files changed, 19 insertions(+), 23 deletions(-) diff --git a/backend/src/main/java/io/f1/backend/domain/game/app/RoomService.java b/backend/src/main/java/io/f1/backend/domain/game/app/RoomService.java index dd31a875..d4544e9f 100644 --- a/backend/src/main/java/io/f1/backend/domain/game/app/RoomService.java +++ b/backend/src/main/java/io/f1/backend/domain/game/app/RoomService.java @@ -31,11 +31,12 @@ import io.f1.backend.domain.game.store.RoomRepository; import io.f1.backend.domain.quiz.app.QuizService; import io.f1.backend.domain.quiz.entity.Quiz; +import io.f1.backend.global.exception.CustomException; +import io.f1.backend.global.exception.errorcode.RoomErrorCode; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; -import org.hibernate.boot.model.naming.IllegalIdentifierException; import org.springframework.context.ApplicationEventPublisher; import org.springframework.stereotype.Service; @@ -91,18 +92,18 @@ public void enterRoom(RoomValidationRequest request) { Room room = findRoom(request.roomId()); if (room.getState().equals(RoomState.PLAYING)) { - throw new IllegalArgumentException("403 게임이 진행중입니다."); + throw new CustomException(RoomErrorCode.ROOM_GAME_IN_PROGRESS); } int maxUserCnt = room.getRoomSetting().maxUserCount(); int currentCnt = room.getUserIdSessionMap().size(); if (maxUserCnt == currentCnt) { - throw new IllegalArgumentException("403 정원이 모두 찼습니다."); + throw new CustomException(RoomErrorCode.ROOM_USER_LIMIT_REACHED); } if (room.getRoomSetting().locked() && !room.getRoomSetting().password().equals(request.password())) { - throw new IllegalArgumentException("401 비밀번호가 일치하지 않습니다."); + throw new CustomException(RoomErrorCode.WRONG_PASSWORD); } room.getUserIdSessionMap().put(getCurrentUserId(), PENDING_SESSION_ID); @@ -201,7 +202,7 @@ private Player getRemovePlayer(Room room, String sessionId) { Player removePlayer = room.getPlayerSessionMap().get(sessionId); if (removePlayer == null) { room.removeUserId(getCurrentUserId()); - throw new IllegalIdentifierException("404 세션 없음 비정상적인 퇴장 요청"); + throw new CustomException(RoomErrorCode.SOCKET_SESSION_NOT_FOUND); } return removePlayer; } @@ -217,7 +218,7 @@ private Player createPlayer() { private Room findRoom(Long roomId) { return roomRepository .findRoom(roomId) - .orElseThrow(() -> new IllegalArgumentException("404 존재하지 않는 방입니다.")); + .orElseThrow(() -> new CustomException(RoomErrorCode.ROOM_NOT_FOUND)); } private boolean isLastPlayer(Room room, String sessionId) { @@ -244,9 +245,7 @@ private void changeHost(Room room, String hostSessionId) { Player nextHost = playerSessionMap.get( nextHostSessionId.orElseThrow( - () -> - new IllegalArgumentException( - "방장 교체 불가 - 404 해당 세션 플레이어는 존재하지않습니다."))); + () -> new CustomException(RoomErrorCode.SOCKET_SESSION_NOT_FOUND))); room.updateHost(nextHost); log.info("user_id:{} 방장 변경 완료 ", nextHost.getId()); diff --git a/backend/src/main/java/io/f1/backend/global/config/StompChannelInterceptor.java b/backend/src/main/java/io/f1/backend/global/config/StompChannelInterceptor.java index 4a89c04c..dea375a8 100644 --- a/backend/src/main/java/io/f1/backend/global/config/StompChannelInterceptor.java +++ b/backend/src/main/java/io/f1/backend/global/config/StompChannelInterceptor.java @@ -27,20 +27,16 @@ public Message preSend(Message message, MessageChannel channel) { throw new IllegalArgumentException("Stomp command required"); } - switch (command) { - case CONNECT -> log.info("CONNECT : 세션 연결 - sessionId = {}", sessionId); - - case SUBSCRIBE -> { - if (destination != null && sessionId != null) { - log.info("SUBSCRIBE : 구독 시작 destination = {}", destination); - } + if (command.equals(StompCommand.CONNECT)) { + log.info("CONNECT : 세션 연결 - sessionId = {}", sessionId); + } else if (command.equals(StompCommand.SUBSCRIBE)) { + if (destination != null && sessionId != null) { + log.info("SUBSCRIBE : 구독 시작 destination = {}", destination); } - - case SEND -> log.info("SEND : 요청 destination = {}", destination); - - case DISCONNECT -> log.info("DISCONNECT : 연결 해제 sessionId = {}", sessionId); - - default -> throw new IllegalStateException("Unexpected command: " + command); + } else if (command.equals(StompCommand.SEND)) { + log.info("SEND : 요청 destination = {}", destination); + } else if (command.equals(StompCommand.DISCONNECT)) { + log.info("DISCONNECT : 연결 해제 sessionId = {}", sessionId); } return message; diff --git a/backend/src/main/java/io/f1/backend/global/exception/errorcode/RoomErrorCode.java b/backend/src/main/java/io/f1/backend/global/exception/errorcode/RoomErrorCode.java index 191147ac..0626fab0 100644 --- a/backend/src/main/java/io/f1/backend/global/exception/errorcode/RoomErrorCode.java +++ b/backend/src/main/java/io/f1/backend/global/exception/errorcode/RoomErrorCode.java @@ -11,7 +11,8 @@ public enum RoomErrorCode implements ErrorCode { ROOM_USER_LIMIT_REACHED("E403002", HttpStatus.FORBIDDEN, "정원이 모두 찼습니다."), ROOM_GAME_IN_PROGRESS("E403003", HttpStatus.FORBIDDEN, "게임이 진행 중 입니다."), ROOM_NOT_FOUND("E404005", HttpStatus.NOT_FOUND, "존재하지 않는 방입니다."), - WRONG_PASSWORD("E401006", HttpStatus.UNAUTHORIZED, "비밀번호가 일치하지않습니다."); + WRONG_PASSWORD("E401006", HttpStatus.UNAUTHORIZED, "비밀번호가 일치하지않습니다."), + SOCKET_SESSION_NOT_FOUND("E404006", HttpStatus.NOT_FOUND, "존재하지 않는 소켓 세션입니다."); private final String code; From 51c5ff368d4d8a2666cd61558f9ede8d2b208da4 Mon Sep 17 00:00:00 2001 From: Jiwon Kwak Date: Wed, 16 Jul 2025 18:45:51 +0900 Subject: [PATCH 7/7] =?UTF-8?q?:sparkles:=20[feat]=20=EA=B4=80=EB=A6=AC?= =?UTF-8?q?=EC=9E=90=20=EB=A1=9C=EA=B7=B8=EC=9D=B8/=EB=A1=9C=EA=B7=B8?= =?UTF-8?q?=EC=95=84=EC=9B=83,=20=EC=9D=B8=EC=A6=9D=20=EC=A0=95=EB=B3=B4?= =?UTF-8?q?=20=EC=A1=B0=ED=9A=8C=20=EA=B8=B0=EB=8A=A5=20(#55)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * :sparkles: feat: 관리자 form 로그인, 로그아웃 기능 * :truck: rename: 로그아웃 성공 핸들러 이름 변경 * :sparkles: feat: 사용자/관리자 인증 정보 조회 기능 * :bug: fix: 관리자 로그인 성공 시, last_login 업데이트 * chore: Java 스타일 수정 * :truck: rename: dto 클래스 이름 변경 * chore: Java 스타일 수정 * :bug: fix: CORS 설정 추가 * chore: Java 스타일 수정 * :bug: fix: OAuth 인증 성공 시 redirect 경로 지정 * chore: Java 스타일 수정 * :recycle: refactor: CORS 요청 경로 모두 허용 * chore: Java 스타일 수정 * :bug: fix: OAuth 인증 후 리다이렉트 경로 수정 --------- Co-authored-by: github-actions <> --- .../domain/admin/app/AdminDetailService.java | 30 ++++++++++ .../app/handler/AdminLoginFailureHandler.java | 38 ++++++++++++ .../app/handler/AdminLoginSuccessHandler.java | 47 +++++++++++++++ .../domain/admin/dao/AdminRepository.java | 14 +++++ .../admin/dto/AdminLoginFailResponse.java | 11 ++++ .../domain/admin/dto/AdminPrincipal.java | 59 +++++++++++++++++++ .../domain/admin/dto/AuthenticationAdmin.java | 10 ++++ .../f1/backend/domain/admin/entity/Admin.java | 7 +++ .../domain/auth/api/AuthController.java | 32 ++++++++++ .../auth/dto/CurrentUserAndAdminResponse.java | 21 +++++++ .../domain/user/api/SignupController.java | 10 ++-- .../domain/user/api/UserController.java | 4 +- .../backend/domain/user/app/UserService.java | 6 +- .../user/app/handler/OAuthSuccessHandler.java | 13 +--- ... => UserAndAdminLogoutSuccessHandler.java} | 2 +- .../domain/user/constants/SessionKeys.java | 1 + .../domain/user/dto/SignupRequest.java | 5 ++ .../domain/user/dto/SignupRequestDto.java | 5 -- .../domain/user/dto/SignupResponse.java | 3 + .../domain/user/dto/SignupResponseDto.java | 3 - .../domain/user/mapper/UserMapper.java | 6 +- .../f1/backend/global/config/CorsConfig.java | 24 ++++++++ .../backend/global/config/SecurityConfig.java | 26 ++++++-- .../f1/backend/global/util/SecurityUtils.java | 14 +++++ 24 files changed, 353 insertions(+), 38 deletions(-) create mode 100644 backend/src/main/java/io/f1/backend/domain/admin/app/AdminDetailService.java create mode 100644 backend/src/main/java/io/f1/backend/domain/admin/app/handler/AdminLoginFailureHandler.java create mode 100644 backend/src/main/java/io/f1/backend/domain/admin/app/handler/AdminLoginSuccessHandler.java create mode 100644 backend/src/main/java/io/f1/backend/domain/admin/dao/AdminRepository.java create mode 100644 backend/src/main/java/io/f1/backend/domain/admin/dto/AdminLoginFailResponse.java create mode 100644 backend/src/main/java/io/f1/backend/domain/admin/dto/AdminPrincipal.java create mode 100644 backend/src/main/java/io/f1/backend/domain/admin/dto/AuthenticationAdmin.java create mode 100644 backend/src/main/java/io/f1/backend/domain/auth/api/AuthController.java create mode 100644 backend/src/main/java/io/f1/backend/domain/auth/dto/CurrentUserAndAdminResponse.java rename backend/src/main/java/io/f1/backend/domain/user/app/handler/{OAuthLogoutSuccessHandler.java => UserAndAdminLogoutSuccessHandler.java} (88%) create mode 100644 backend/src/main/java/io/f1/backend/domain/user/dto/SignupRequest.java delete mode 100644 backend/src/main/java/io/f1/backend/domain/user/dto/SignupRequestDto.java create mode 100644 backend/src/main/java/io/f1/backend/domain/user/dto/SignupResponse.java delete mode 100644 backend/src/main/java/io/f1/backend/domain/user/dto/SignupResponseDto.java create mode 100644 backend/src/main/java/io/f1/backend/global/config/CorsConfig.java diff --git a/backend/src/main/java/io/f1/backend/domain/admin/app/AdminDetailService.java b/backend/src/main/java/io/f1/backend/domain/admin/app/AdminDetailService.java new file mode 100644 index 00000000..18bcedf8 --- /dev/null +++ b/backend/src/main/java/io/f1/backend/domain/admin/app/AdminDetailService.java @@ -0,0 +1,30 @@ +package io.f1.backend.domain.admin.app; + +import io.f1.backend.domain.admin.dao.AdminRepository; +import io.f1.backend.domain.admin.dto.AdminPrincipal; +import io.f1.backend.domain.admin.entity.Admin; + +import lombok.RequiredArgsConstructor; + +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.security.core.userdetails.UserDetailsService; +import org.springframework.security.core.userdetails.UsernameNotFoundException; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +public class AdminDetailService implements UserDetailsService { + + private final AdminRepository adminRepository; + + @Override + public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException { + Admin admin = + adminRepository + .findByUsername(username) + .orElseThrow( + () -> new UsernameNotFoundException("E404007: 존재하지 않는 관리자입니다.")); + // 프론트엔드로 내려가지 않는 예외 + return new AdminPrincipal(admin); + } +} diff --git a/backend/src/main/java/io/f1/backend/domain/admin/app/handler/AdminLoginFailureHandler.java b/backend/src/main/java/io/f1/backend/domain/admin/app/handler/AdminLoginFailureHandler.java new file mode 100644 index 00000000..fe16eef5 --- /dev/null +++ b/backend/src/main/java/io/f1/backend/domain/admin/app/handler/AdminLoginFailureHandler.java @@ -0,0 +1,38 @@ +package io.f1.backend.domain.admin.app.handler; + +import com.fasterxml.jackson.databind.ObjectMapper; + +import io.f1.backend.domain.admin.dto.AdminLoginFailResponse; + +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; + +import lombok.RequiredArgsConstructor; + +import org.springframework.security.core.AuthenticationException; +import org.springframework.security.web.authentication.AuthenticationFailureHandler; +import org.springframework.stereotype.Component; + +import java.io.IOException; + +@Component +@RequiredArgsConstructor +public class AdminLoginFailureHandler implements AuthenticationFailureHandler { + + private final ObjectMapper objectMapper; + + @Override + public void onAuthenticationFailure( + HttpServletRequest request, + HttpServletResponse response, + AuthenticationException exception) + throws IOException { + response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); // 401 + response.setContentType("application/json;charset=UTF-8"); + + AdminLoginFailResponse errorResponse = + new AdminLoginFailResponse("E401005", "아이디 또는 비밀번호가 일치하지 않습니다."); + + response.getWriter().write(objectMapper.writeValueAsString(errorResponse)); + } +} diff --git a/backend/src/main/java/io/f1/backend/domain/admin/app/handler/AdminLoginSuccessHandler.java b/backend/src/main/java/io/f1/backend/domain/admin/app/handler/AdminLoginSuccessHandler.java new file mode 100644 index 00000000..7150d35a --- /dev/null +++ b/backend/src/main/java/io/f1/backend/domain/admin/app/handler/AdminLoginSuccessHandler.java @@ -0,0 +1,47 @@ +package io.f1.backend.domain.admin.app.handler; + +import static io.f1.backend.domain.user.constants.SessionKeys.ADMIN; +import static io.f1.backend.global.util.SecurityUtils.getCurrentAdminPrincipal; + +import io.f1.backend.domain.admin.dao.AdminRepository; +import io.f1.backend.domain.admin.dto.AdminPrincipal; +import io.f1.backend.domain.admin.entity.Admin; + +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import jakarta.servlet.http.HttpSession; + +import lombok.RequiredArgsConstructor; + +import org.springframework.security.core.Authentication; +import org.springframework.security.web.authentication.AuthenticationSuccessHandler; +import org.springframework.stereotype.Component; + +import java.time.LocalDateTime; + +@Component +@RequiredArgsConstructor +public class AdminLoginSuccessHandler implements AuthenticationSuccessHandler { + + private final AdminRepository adminRepository; + private final HttpSession httpSession; + + @Override + public void onAuthenticationSuccess( + HttpServletRequest request, + HttpServletResponse response, + Authentication authentication) { + + AdminPrincipal principal = getCurrentAdminPrincipal(); + Admin admin = + adminRepository + .findByUsername(principal.getUsername()) + .orElseThrow(() -> new RuntimeException("E404007: 존재하지 않는 관리자입니다.")); + + admin.updateLastLogin(LocalDateTime.now()); + adminRepository.save(admin); + httpSession.setAttribute(ADMIN, principal.getAuthenticationAdmin()); + + response.setStatus(HttpServletResponse.SC_OK); // 200 + } +} diff --git a/backend/src/main/java/io/f1/backend/domain/admin/dao/AdminRepository.java b/backend/src/main/java/io/f1/backend/domain/admin/dao/AdminRepository.java new file mode 100644 index 00000000..21cc9d13 --- /dev/null +++ b/backend/src/main/java/io/f1/backend/domain/admin/dao/AdminRepository.java @@ -0,0 +1,14 @@ +package io.f1.backend.domain.admin.dao; + +import io.f1.backend.domain.admin.entity.Admin; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import java.util.Optional; + +@Repository +public interface AdminRepository extends JpaRepository { + + Optional findByUsername(String username); +} diff --git a/backend/src/main/java/io/f1/backend/domain/admin/dto/AdminLoginFailResponse.java b/backend/src/main/java/io/f1/backend/domain/admin/dto/AdminLoginFailResponse.java new file mode 100644 index 00000000..4daa6810 --- /dev/null +++ b/backend/src/main/java/io/f1/backend/domain/admin/dto/AdminLoginFailResponse.java @@ -0,0 +1,11 @@ +package io.f1.backend.domain.admin.dto; + +import lombok.AllArgsConstructor; +import lombok.Getter; + +@Getter +@AllArgsConstructor +public class AdminLoginFailResponse { + private String code; + private String message; +} diff --git a/backend/src/main/java/io/f1/backend/domain/admin/dto/AdminPrincipal.java b/backend/src/main/java/io/f1/backend/domain/admin/dto/AdminPrincipal.java new file mode 100644 index 00000000..048e7076 --- /dev/null +++ b/backend/src/main/java/io/f1/backend/domain/admin/dto/AdminPrincipal.java @@ -0,0 +1,59 @@ +package io.f1.backend.domain.admin.dto; + +import io.f1.backend.domain.admin.entity.Admin; + +import lombok.Getter; + +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.userdetails.UserDetails; + +import java.util.Collection; +import java.util.Collections; + +@Getter +public class AdminPrincipal implements UserDetails { + + public static final String ROLE_ADMIN = "ROLE_ADMIN"; + private final AuthenticationAdmin authenticationAdmin; + private final String password; + + public AdminPrincipal(Admin admin) { + this.authenticationAdmin = AuthenticationAdmin.from(admin); + this.password = admin.getPassword(); + } + + @Override + public Collection getAuthorities() { + return Collections.singleton(() -> ROLE_ADMIN); + } + + @Override + public String getPassword() { + return this.password; + } + + @Override + public String getUsername() { + return authenticationAdmin.username(); + } + + @Override + public boolean isAccountNonExpired() { + return true; + } + + @Override + public boolean isAccountNonLocked() { + return true; + } + + @Override + public boolean isCredentialsNonExpired() { + return true; + } + + @Override + public boolean isEnabled() { + return true; + } +} diff --git a/backend/src/main/java/io/f1/backend/domain/admin/dto/AuthenticationAdmin.java b/backend/src/main/java/io/f1/backend/domain/admin/dto/AuthenticationAdmin.java new file mode 100644 index 00000000..99565de0 --- /dev/null +++ b/backend/src/main/java/io/f1/backend/domain/admin/dto/AuthenticationAdmin.java @@ -0,0 +1,10 @@ +package io.f1.backend.domain.admin.dto; + +import io.f1.backend.domain.admin.entity.Admin; + +public record AuthenticationAdmin(Long adminId, String username) { + + public static AuthenticationAdmin from(Admin admin) { + return new AuthenticationAdmin(admin.getId(), admin.getUsername()); + } +} diff --git a/backend/src/main/java/io/f1/backend/domain/admin/entity/Admin.java b/backend/src/main/java/io/f1/backend/domain/admin/entity/Admin.java index 05c8fe3c..f457e6dc 100644 --- a/backend/src/main/java/io/f1/backend/domain/admin/entity/Admin.java +++ b/backend/src/main/java/io/f1/backend/domain/admin/entity/Admin.java @@ -8,9 +8,12 @@ import jakarta.persistence.GenerationType; import jakarta.persistence.Id; +import lombok.Getter; + import java.time.LocalDateTime; @Entity +@Getter public class Admin extends BaseEntity { @Id @@ -25,4 +28,8 @@ public class Admin extends BaseEntity { @Column(nullable = false) private LocalDateTime lastLogin; + + public void updateLastLogin(LocalDateTime lastLogin) { + this.lastLogin = lastLogin; + } } diff --git a/backend/src/main/java/io/f1/backend/domain/auth/api/AuthController.java b/backend/src/main/java/io/f1/backend/domain/auth/api/AuthController.java new file mode 100644 index 00000000..b8296262 --- /dev/null +++ b/backend/src/main/java/io/f1/backend/domain/auth/api/AuthController.java @@ -0,0 +1,32 @@ +package io.f1.backend.domain.auth.api; + +import static io.f1.backend.global.util.SecurityUtils.getAuthentication; + +import io.f1.backend.domain.admin.dto.AdminPrincipal; +import io.f1.backend.domain.auth.dto.CurrentUserAndAdminResponse; +import io.f1.backend.domain.user.dto.UserPrincipal; + +import lombok.RequiredArgsConstructor; + +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.Authentication; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("/auth") +@RequiredArgsConstructor +public class AuthController { + + @GetMapping("/me") + public ResponseEntity getCurrentUserOrAdmin() { + Authentication authentication = getAuthentication(); + Object principal = authentication.getPrincipal(); + + if (principal instanceof UserPrincipal userPrincipal) { + return ResponseEntity.ok(CurrentUserAndAdminResponse.from(userPrincipal)); + } + return ResponseEntity.ok(CurrentUserAndAdminResponse.from((AdminPrincipal) principal)); + } +} diff --git a/backend/src/main/java/io/f1/backend/domain/auth/dto/CurrentUserAndAdminResponse.java b/backend/src/main/java/io/f1/backend/domain/auth/dto/CurrentUserAndAdminResponse.java new file mode 100644 index 00000000..d8579951 --- /dev/null +++ b/backend/src/main/java/io/f1/backend/domain/auth/dto/CurrentUserAndAdminResponse.java @@ -0,0 +1,21 @@ +package io.f1.backend.domain.auth.dto; + +import io.f1.backend.domain.admin.dto.AdminPrincipal; +import io.f1.backend.domain.user.dto.UserPrincipal; + +public record CurrentUserAndAdminResponse(Long id, String name, String role) { + + public static CurrentUserAndAdminResponse from(UserPrincipal userPrincipal) { + return new CurrentUserAndAdminResponse( + userPrincipal.getUserId(), + userPrincipal.getUserNickname(), + UserPrincipal.ROLE_USER); + } + + public static CurrentUserAndAdminResponse from(AdminPrincipal adminPrincipal) { + return new CurrentUserAndAdminResponse( + adminPrincipal.getAuthenticationAdmin().adminId(), + adminPrincipal.getUsername(), + AdminPrincipal.ROLE_ADMIN); + } +} diff --git a/backend/src/main/java/io/f1/backend/domain/user/api/SignupController.java b/backend/src/main/java/io/f1/backend/domain/user/api/SignupController.java index d0cb45ac..9fa1d7de 100644 --- a/backend/src/main/java/io/f1/backend/domain/user/api/SignupController.java +++ b/backend/src/main/java/io/f1/backend/domain/user/api/SignupController.java @@ -1,8 +1,8 @@ package io.f1.backend.domain.user.api; import io.f1.backend.domain.user.app.UserService; -import io.f1.backend.domain.user.dto.SignupRequestDto; -import io.f1.backend.domain.user.dto.SignupResponseDto; +import io.f1.backend.domain.user.dto.SignupRequest; +import io.f1.backend.domain.user.dto.SignupResponse; import jakarta.servlet.http.HttpSession; @@ -21,9 +21,9 @@ public class SignupController { private final UserService userService; @PostMapping("/signup") - public ResponseEntity completeSignup( - @RequestBody SignupRequestDto signupRequest, HttpSession httpSession) { - SignupResponseDto response = userService.signup(httpSession, signupRequest); + public ResponseEntity completeSignup( + @RequestBody SignupRequest signupRequest, HttpSession httpSession) { + SignupResponse response = userService.signup(httpSession, signupRequest); return ResponseEntity.status(HttpStatus.CREATED).body(response); } } diff --git a/backend/src/main/java/io/f1/backend/domain/user/api/UserController.java b/backend/src/main/java/io/f1/backend/domain/user/api/UserController.java index 4cbe5343..4a56d6e7 100644 --- a/backend/src/main/java/io/f1/backend/domain/user/api/UserController.java +++ b/backend/src/main/java/io/f1/backend/domain/user/api/UserController.java @@ -3,7 +3,7 @@ import static io.f1.backend.global.util.SecurityUtils.logout; import io.f1.backend.domain.user.app.UserService; -import io.f1.backend.domain.user.dto.SignupRequestDto; +import io.f1.backend.domain.user.dto.SignupRequest; import io.f1.backend.domain.user.dto.UserPrincipal; import jakarta.servlet.http.HttpSession; @@ -36,7 +36,7 @@ public ResponseEntity deleteCurrentUser( @PutMapping public ResponseEntity updateNickname( @AuthenticationPrincipal UserPrincipal userPrincipal, - @RequestBody SignupRequestDto signupRequest, + @RequestBody SignupRequest signupRequest, HttpSession httpSession) { userService.updateNickname( userPrincipal.getUserId(), signupRequest.nickname(), httpSession); diff --git a/backend/src/main/java/io/f1/backend/domain/user/app/UserService.java b/backend/src/main/java/io/f1/backend/domain/user/app/UserService.java index f23e2b0f..d2f035d9 100644 --- a/backend/src/main/java/io/f1/backend/domain/user/app/UserService.java +++ b/backend/src/main/java/io/f1/backend/domain/user/app/UserService.java @@ -6,8 +6,8 @@ import io.f1.backend.domain.user.dao.UserRepository; import io.f1.backend.domain.user.dto.AuthenticationUser; -import io.f1.backend.domain.user.dto.SignupRequestDto; -import io.f1.backend.domain.user.dto.SignupResponseDto; +import io.f1.backend.domain.user.dto.SignupRequest; +import io.f1.backend.domain.user.dto.SignupResponse; import io.f1.backend.domain.user.entity.User; import io.f1.backend.global.util.SecurityUtils; @@ -25,7 +25,7 @@ public class UserService { private final UserRepository userRepository; @Transactional - public SignupResponseDto signup(HttpSession session, SignupRequestDto signupRequest) { + public SignupResponse signup(HttpSession session, SignupRequest signupRequest) { AuthenticationUser authenticationUser = extractSessionUser(session); String nickname = signupRequest.nickname(); diff --git a/backend/src/main/java/io/f1/backend/domain/user/app/handler/OAuthSuccessHandler.java b/backend/src/main/java/io/f1/backend/domain/user/app/handler/OAuthSuccessHandler.java index a465c885..37f410f0 100644 --- a/backend/src/main/java/io/f1/backend/domain/user/app/handler/OAuthSuccessHandler.java +++ b/backend/src/main/java/io/f1/backend/domain/user/app/handler/OAuthSuccessHandler.java @@ -1,7 +1,5 @@ package io.f1.backend.domain.user.app.handler; -import com.fasterxml.jackson.databind.ObjectMapper; - import io.f1.backend.domain.user.dto.UserPrincipal; import jakarta.servlet.http.HttpServletRequest; @@ -14,14 +12,11 @@ import org.springframework.stereotype.Component; import java.io.IOException; -import java.util.Map; @Component @RequiredArgsConstructor public class OAuthSuccessHandler extends SimpleUrlAuthenticationSuccessHandler { - private final ObjectMapper objectMapper; - @Override public void onAuthenticationSuccess( HttpServletRequest request, HttpServletResponse response, Authentication authentication) @@ -30,13 +25,9 @@ public void onAuthenticationSuccess( response.setContentType("application/json;charset=UTF-8"); if (principal.getUserNickname() == null) { - // 닉네임 설정 필요 → 202 Accepted - response.setStatus(HttpServletResponse.SC_ACCEPTED); - objectMapper.writeValue(response.getWriter(), Map.of("message", "닉네임을 설정하세요.")); + getRedirectStrategy().sendRedirect(request, response, "/signup"); } else { - // 정상 로그인 → 200 OK - response.setStatus(HttpServletResponse.SC_OK); - objectMapper.writeValue(response.getWriter(), Map.of("message", "로그인 성공")); + getRedirectStrategy().sendRedirect(request, response, "/room"); } } } diff --git a/backend/src/main/java/io/f1/backend/domain/user/app/handler/OAuthLogoutSuccessHandler.java b/backend/src/main/java/io/f1/backend/domain/user/app/handler/UserAndAdminLogoutSuccessHandler.java similarity index 88% rename from backend/src/main/java/io/f1/backend/domain/user/app/handler/OAuthLogoutSuccessHandler.java rename to backend/src/main/java/io/f1/backend/domain/user/app/handler/UserAndAdminLogoutSuccessHandler.java index 8f0528b6..9ed53381 100644 --- a/backend/src/main/java/io/f1/backend/domain/user/app/handler/OAuthLogoutSuccessHandler.java +++ b/backend/src/main/java/io/f1/backend/domain/user/app/handler/UserAndAdminLogoutSuccessHandler.java @@ -8,7 +8,7 @@ import org.springframework.stereotype.Component; @Component -public class OAuthLogoutSuccessHandler implements LogoutSuccessHandler { +public class UserAndAdminLogoutSuccessHandler implements LogoutSuccessHandler { @Override public void onLogoutSuccess( diff --git a/backend/src/main/java/io/f1/backend/domain/user/constants/SessionKeys.java b/backend/src/main/java/io/f1/backend/domain/user/constants/SessionKeys.java index dc549453..a728bee8 100644 --- a/backend/src/main/java/io/f1/backend/domain/user/constants/SessionKeys.java +++ b/backend/src/main/java/io/f1/backend/domain/user/constants/SessionKeys.java @@ -6,4 +6,5 @@ private SessionKeys() {} public static final String OAUTH_USER = "OAuthUser"; public static final String USER = "user"; + public static final String ADMIN = "admin"; } diff --git a/backend/src/main/java/io/f1/backend/domain/user/dto/SignupRequest.java b/backend/src/main/java/io/f1/backend/domain/user/dto/SignupRequest.java new file mode 100644 index 00000000..95436b65 --- /dev/null +++ b/backend/src/main/java/io/f1/backend/domain/user/dto/SignupRequest.java @@ -0,0 +1,5 @@ +package io.f1.backend.domain.user.dto; + +import jakarta.validation.constraints.NotBlank; + +public record SignupRequest(@NotBlank(message = "닉네임을 입력하세요") String nickname) {} diff --git a/backend/src/main/java/io/f1/backend/domain/user/dto/SignupRequestDto.java b/backend/src/main/java/io/f1/backend/domain/user/dto/SignupRequestDto.java deleted file mode 100644 index efb34bc4..00000000 --- a/backend/src/main/java/io/f1/backend/domain/user/dto/SignupRequestDto.java +++ /dev/null @@ -1,5 +0,0 @@ -package io.f1.backend.domain.user.dto; - -import jakarta.validation.constraints.NotBlank; - -public record SignupRequestDto(@NotBlank(message = "닉네임을 입력하세요") String nickname) {} diff --git a/backend/src/main/java/io/f1/backend/domain/user/dto/SignupResponse.java b/backend/src/main/java/io/f1/backend/domain/user/dto/SignupResponse.java new file mode 100644 index 00000000..afada0fd --- /dev/null +++ b/backend/src/main/java/io/f1/backend/domain/user/dto/SignupResponse.java @@ -0,0 +1,3 @@ +package io.f1.backend.domain.user.dto; + +public record SignupResponse(Long id, String nickname) {} diff --git a/backend/src/main/java/io/f1/backend/domain/user/dto/SignupResponseDto.java b/backend/src/main/java/io/f1/backend/domain/user/dto/SignupResponseDto.java deleted file mode 100644 index 96ebbec2..00000000 --- a/backend/src/main/java/io/f1/backend/domain/user/dto/SignupResponseDto.java +++ /dev/null @@ -1,3 +0,0 @@ -package io.f1.backend.domain.user.dto; - -public record SignupResponseDto(Long id, String nickname) {} diff --git a/backend/src/main/java/io/f1/backend/domain/user/mapper/UserMapper.java b/backend/src/main/java/io/f1/backend/domain/user/mapper/UserMapper.java index 1fd129a8..be828638 100644 --- a/backend/src/main/java/io/f1/backend/domain/user/mapper/UserMapper.java +++ b/backend/src/main/java/io/f1/backend/domain/user/mapper/UserMapper.java @@ -1,13 +1,13 @@ package io.f1.backend.domain.user.mapper; -import io.f1.backend.domain.user.dto.SignupResponseDto; +import io.f1.backend.domain.user.dto.SignupResponse; import io.f1.backend.domain.user.entity.User; public class UserMapper { private UserMapper() {} - public static SignupResponseDto toSignupResponse(User user) { - return new SignupResponseDto(user.getId(), user.getNickname()); + public static SignupResponse toSignupResponse(User user) { + return new SignupResponse(user.getId(), user.getNickname()); } } diff --git a/backend/src/main/java/io/f1/backend/global/config/CorsConfig.java b/backend/src/main/java/io/f1/backend/global/config/CorsConfig.java new file mode 100644 index 00000000..2b3a4573 --- /dev/null +++ b/backend/src/main/java/io/f1/backend/global/config/CorsConfig.java @@ -0,0 +1,24 @@ +package io.f1.backend.global.config; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.web.cors.CorsConfiguration; +import org.springframework.web.cors.CorsConfigurationSource; +import org.springframework.web.cors.UrlBasedCorsConfigurationSource; + +@Configuration +public class CorsConfig { + @Bean + public CorsConfigurationSource corsConfigurationSource() { + CorsConfiguration config = new CorsConfiguration(); + + config.addAllowedOriginPattern("*"); + config.addAllowedHeader("*"); + config.addAllowedMethod("*"); + config.setAllowCredentials(true); + + UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); + source.registerCorsConfiguration("/**", config); + return source; + } +} diff --git a/backend/src/main/java/io/f1/backend/global/config/SecurityConfig.java b/backend/src/main/java/io/f1/backend/global/config/SecurityConfig.java index 2866dc7e..26a726da 100644 --- a/backend/src/main/java/io/f1/backend/global/config/SecurityConfig.java +++ b/backend/src/main/java/io/f1/backend/global/config/SecurityConfig.java @@ -1,14 +1,17 @@ package io.f1.backend.global.config; +import io.f1.backend.domain.admin.app.handler.AdminLoginFailureHandler; +import io.f1.backend.domain.admin.app.handler.AdminLoginSuccessHandler; import io.f1.backend.domain.user.app.CustomOAuthUserService; import io.f1.backend.domain.user.app.handler.CustomAuthenticationEntryPoint; -import io.f1.backend.domain.user.app.handler.OAuthLogoutSuccessHandler; import io.f1.backend.domain.user.app.handler.OAuthSuccessHandler; +import io.f1.backend.domain.user.app.handler.UserAndAdminLogoutSuccessHandler; import lombok.RequiredArgsConstructor; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; +import org.springframework.security.config.Customizer; import org.springframework.security.config.annotation.web.builders.HttpSecurity; import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer; @@ -22,11 +25,14 @@ public class SecurityConfig { private final CustomAuthenticationEntryPoint customAuthenticationEntryPoint; private final CustomOAuthUserService customOAuthUserService; private final OAuthSuccessHandler oAuthSuccessHandler; - private final OAuthLogoutSuccessHandler oAuthLogoutSuccessHandler; + private final UserAndAdminLogoutSuccessHandler userAndAdminLogoutSuccessHandler; + private final AdminLoginSuccessHandler adminLoginSuccessHandler; + private final AdminLoginFailureHandler adminLoginFailureHandler; @Bean public SecurityFilterChain userFilterChain(HttpSecurity http) throws Exception { http.csrf(AbstractHttpConfigurer::disable) + .cors(Customizer.withDefaults()) .exceptionHandling( exception -> exception.authenticationEntryPoint(customAuthenticationEntryPoint)) @@ -38,15 +44,25 @@ public SecurityFilterChain userFilterChain(HttpSecurity http) throws Exception { "/oauth2/**", "/signup", "/css/**", - "/js/**") + "/js/**", + "/admin/login") .permitAll() .requestMatchers("/ws/**") .authenticated() .requestMatchers("/user/me") .hasRole("USER") + .requestMatchers("/admin/**") + .hasRole("ADMIN") + .requestMatchers("/auth/me") + .hasAnyRole("USER", "ADMIN") .anyRequest() .authenticated()) - .formLogin(AbstractHttpConfigurer::disable) + .formLogin( + form -> + form.loginProcessingUrl("/admin/login") // 로그인 form action 경로 + .successHandler(adminLoginSuccessHandler) + .failureHandler(adminLoginFailureHandler) + .permitAll()) .oauth2Login( oauth2 -> oauth2.userInfoEndpoint( @@ -57,7 +73,7 @@ public SecurityFilterChain userFilterChain(HttpSecurity http) throws Exception { .logout( logout -> logout.logoutUrl("/logout") - .logoutSuccessHandler(oAuthLogoutSuccessHandler) + .logoutSuccessHandler(userAndAdminLogoutSuccessHandler) .clearAuthentication(true) .invalidateHttpSession(true) .permitAll()) diff --git a/backend/src/main/java/io/f1/backend/global/util/SecurityUtils.java b/backend/src/main/java/io/f1/backend/global/util/SecurityUtils.java index 9a4530a0..2735545a 100644 --- a/backend/src/main/java/io/f1/backend/global/util/SecurityUtils.java +++ b/backend/src/main/java/io/f1/backend/global/util/SecurityUtils.java @@ -1,5 +1,6 @@ package io.f1.backend.global.util; +import io.f1.backend.domain.admin.dto.AdminPrincipal; import io.f1.backend.domain.user.dto.UserPrincipal; import io.f1.backend.domain.user.entity.User; @@ -50,4 +51,17 @@ public static void logout(HttpSession session) { private static void clearAuthentication() { SecurityContextHolder.clearContext(); } + + public static AdminPrincipal getCurrentAdminPrincipal() { + Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); + if (authentication != null + && authentication.getPrincipal() instanceof AdminPrincipal adminPrincipal) { + return adminPrincipal; + } + throw new RuntimeException("E401001: 로그인이 필요합니다."); + } + + public static Authentication getAuthentication() { + return SecurityContextHolder.getContext().getAuthentication(); + } }