diff --git a/src/main/java/inha/gdgoc/domain/auth/service/AuthService.java b/src/main/java/inha/gdgoc/domain/auth/service/AuthService.java index 048cff2..a0e7adc 100644 --- a/src/main/java/inha/gdgoc/domain/auth/service/AuthService.java +++ b/src/main/java/inha/gdgoc/domain/auth/service/AuthService.java @@ -50,6 +50,30 @@ public class AuthService { private final RestTemplate restTemplate = new RestTemplate(); private final TokenProvider tokenProvider; + /** + * Google OAuth 인증 코드를 처리하여 사용자 정보 확인 및 로그인 토큰을 발급한다. + * + *

작업 흐름: + *

    + *
  1. 전달된 authorization code로 Google 토큰 엔드포인트에 요청하여 Google access token을 획득한다.
  2. + *
  3. 획득한 Google access token으로 Google 사용자정보(email, name)를 조회한다.
  4. + *
  5. 조회한 이메일로 로컬 사용자 조회: + * + *
  6. + *
+ * + * @param code Google이 발급한 authorization code + * @param response 존재하는 사용자일 때 refresh_token 쿠키를 HttpServletResponse의 Set-Cookie 헤더로 추가하기 위해 사용되는 응답 객체 + * @return 결과 맵: + * + */ public Map processOAuthLogin(String code, HttpServletResponse response) { // 1. code → access token 요청 HttpHeaders headers = new HttpHeaders(); diff --git a/src/main/java/inha/gdgoc/domain/recruit/controller/RecruitMemberController.java b/src/main/java/inha/gdgoc/domain/recruit/controller/RecruitMemberController.java index 9637e21..2a2ae5b 100644 --- a/src/main/java/inha/gdgoc/domain/recruit/controller/RecruitMemberController.java +++ b/src/main/java/inha/gdgoc/domain/recruit/controller/RecruitMemberController.java @@ -33,6 +33,15 @@ public class RecruitMemberController { private final RecruitMemberService recruitMemberService; + /** + * 지원서 신청을 등록합니다. + * + * 요청 본문의 ApplicationRequest를 받아 가입 신청을 저장하도록 서비스에 위임하고, + * 저장 성공 시 MEMBER_SAVE_SUCCESS 메시지를 담은 200 OK 응답을 반환합니다. + * + * @param applicationRequest 클라이언트로부터 전달된 가입 신청 DTO + * @return 저장 성공 메시지를 포함한 200 OK 응답 (ApiResponse 래퍼) + */ @PostMapping("/apply") public ResponseEntity> recruitMemberAdd( @RequestBody ApplicationRequest applicationRequest @@ -42,6 +51,14 @@ public ResponseEntity> recruitMemberAdd( return ResponseEntity.ok(ApiResponse.ok(MEMBER_SAVE_SUCCESS)); } + /** + * 학번 중복 여부를 조회합니다. + * + * 주어진 학번으로 등록 여부를 확인한 후 검사 결과를 담은 {@link CheckStudentIdResponse}를 ApiResponse로 래핑하여 반환합니다. + * + * @param studentId 확인할 학번(형식: "12XXXXXX", 총 8자리) + * @return 중복 검사 성공 메시지와 함께 검사 결과를 포함한 {@code ResponseEntity>} + */ @GetMapping("/studentId") public ResponseEntity> duplicatedStudentIdDetails( @RequestParam @@ -54,6 +71,15 @@ public ResponseEntity> duplicatedStude return ResponseEntity.ok(ApiResponse.ok(STUDENT_ID_DUPLICATION_CHECK_SUCCESS, response)); } + /** + * 전화번호 중복 여부를 조회한다. + * + * 요청된 전화번호(형식: 010-XXXX-XXXX, 예: 010-1234-5678)의 가입 여부를 검사하여 + * 중복 체크 결과를 담은 ApiResponse를 포함한 HTTP 200 응답을 반환한다. + * + * @param phoneNumber 검사할 전화번호(필수, 패턴: ^010-\d{4}-\d{4}$) + * @return 중복 검사 결과를 포함한 ApiResponse를 래핑한 ResponseEntity + */ @GetMapping("/check/phoneNumber") public ResponseEntity> duplicatedPhoneNumberDetails( @RequestParam @@ -67,6 +93,17 @@ public ResponseEntity> duplicatedPho return ResponseEntity.ok(ApiResponse.ok(PHONE_NUMBER_DUPLICATION_CHECK_SUCCESS, response)); } + /** + * 특정 멤버의 가입 신청서를 조회하여 반환합니다. + * + * 요청된 멤버 ID에 해당하는 가입 신청 정보(SpecifiedMemberResponse)를 조회하고, 성공 시 + * 표준 ApiResponse로 감싸서 200 OK 응답을 반환합니다. + * + * 관리자 권한(ROLE_ADMIN)이 필요합니다. + * + * @param memberId 조회할 멤버의 고유 ID + * @return 조회된 가입 신청 정보를 담은 ApiResponse를 포함한 ResponseEntity + */ @Operation(summary = "특정 멤버 가입 신청서 조회", security = { @SecurityRequirement(name = "BearerAuth") }) @PreAuthorize("hasRole('ADMIN')") @GetMapping("/recruit/members/{memberId}") diff --git a/src/main/java/inha/gdgoc/domain/recruit/dto/request/RecruitMemberRequest.java b/src/main/java/inha/gdgoc/domain/recruit/dto/request/RecruitMemberRequest.java index 3baa07a..d973935 100644 --- a/src/main/java/inha/gdgoc/domain/recruit/dto/request/RecruitMemberRequest.java +++ b/src/main/java/inha/gdgoc/domain/recruit/dto/request/RecruitMemberRequest.java @@ -27,6 +27,16 @@ public class RecruitMemberRequest { private String doubleMajor; private Boolean isPayed; + /** + * DTO의 필드 값을 바탕으로 RecruitMember 엔티티를 생성하여 반환합니다. + * + * EnrolledClassification과 Gender는 문자열을 변환하는 팩토리 메서드를 통해 매핑되며, + * 생성된 엔티티의 isPayed 필드는 항상 false로 설정됩니다(요청의 isPayed 값은 사용되지 않음). + * + * 주의: EnrolledClassification.fromStatus 및 Gender.fromType 호출은 입력 문자열이 유효하지 않을 경우 예외를 던질 수 있습니다. + * + * @return 변환된 RecruitMember 엔티티 + */ public RecruitMember toEntity() { return RecruitMember.builder() .name(name) diff --git a/src/main/java/inha/gdgoc/domain/recruit/dto/response/AnswerResponse.java b/src/main/java/inha/gdgoc/domain/recruit/dto/response/AnswerResponse.java index 3ac3d17..2c0aef5 100644 --- a/src/main/java/inha/gdgoc/domain/recruit/dto/response/AnswerResponse.java +++ b/src/main/java/inha/gdgoc/domain/recruit/dto/response/AnswerResponse.java @@ -13,6 +13,16 @@ public record AnswerResponse( InputType inputType, Object responseValue ) { + /** + * Answer 도메인 객체를 AnswerResponse DTO로 변환하여 생성합니다. + * + * 응답값(responseValue)은 내부적으로 JSON 문자열을 가능한 네이티브 Java 타입(문자열, 숫자, 불리언, + * List, Map 등)으로 변환하여 설정합니다. JSON이 null 또는 빈 문자열이면 responseValue는 null이 됩니다; + * 파싱/변환 오류가 발생하면 원본 JSON 문자열이 그대로 사용됩니다. + * + * @param answer DTO로 변환할 도메인 Answer 객체 + * @return 변환된 AnswerResponse 인스턴스 + */ public static AnswerResponse from(Answer answer, ObjectMapper om) { return new AnswerResponse( answer.getId(), @@ -21,6 +31,15 @@ public static AnswerResponse from(Answer answer, ObjectMapper om) { ); } + /** + * JSON 문자열을 가능한 한 친숙한 Java 값(문자열, 숫자, 불리언, List, Map 등)으로 변환한다. + * + * 입력이 null 또는 빈 문자열이면 null을 반환한다. 파싱에 실패하면 원본 JSON 문자열을 그대로 반환한다. + * + * @param json 변환할 JSON 문자열(Answer에 저장된 responseValue) + * @return 변환된 값(String, Number, Boolean, List, Map) 또는 + * 입력이 null/빈 문자열이면 null, 파싱 실패 시 원본 json 문자열 + */ private static Object toFriendlyValue(String json, ObjectMapper om) { if (json == null || json.isBlank()) return null; try { diff --git a/src/main/java/inha/gdgoc/domain/recruit/dto/response/AnswersResponse.java b/src/main/java/inha/gdgoc/domain/recruit/dto/response/AnswersResponse.java index 896c8cc..61d29d8 100644 --- a/src/main/java/inha/gdgoc/domain/recruit/dto/response/AnswersResponse.java +++ b/src/main/java/inha/gdgoc/domain/recruit/dto/response/AnswersResponse.java @@ -7,6 +7,14 @@ public record AnswersResponse( List answers ) { + /** + * Answer 엔티티 목록을 AnswerResponse 목록으로 변환해 포함한 AnswersResponse를 생성한다. + * + * entities의 각 Answer는 {@link AnswerResponse#from(Answer, com.fasterxml.jackson.databind.ObjectMapper)} 를 통해 변환된다. + * + * @param entities 변환할 Answer 엔티티 리스트 + * @return 변환된 AnswerResponse 리스트를 담은 새 AnswersResponse 인스턴스 + */ public static AnswersResponse from(List entities, ObjectMapper objectMapper) { return new AnswersResponse( entities.stream() diff --git a/src/main/java/inha/gdgoc/domain/recruit/dto/response/SpecifiedMemberResponse.java b/src/main/java/inha/gdgoc/domain/recruit/dto/response/SpecifiedMemberResponse.java index 838bd30..abef9d8 100644 --- a/src/main/java/inha/gdgoc/domain/recruit/dto/response/SpecifiedMemberResponse.java +++ b/src/main/java/inha/gdgoc/domain/recruit/dto/response/SpecifiedMemberResponse.java @@ -13,6 +13,16 @@ public record SpecifiedMemberResponse( AnswersResponse answers ) { + /** + * RecruitMember와 관련 응답 데이터를 이용해 SpecifiedMemberResponse를 생성한다. + * + * RecruitMember에서 이름, 전공, 학번을 추출하고 isPayed는 null 안전하게 Boolean.TRUE.equals로 판정한다. + * answers는 AnswersResponse.from으로 변환되어 응답에 포함된다. + * + * @param member 변환할 원본 RecruitMember + * @param answers RecruitMember에 대한 Answer 목록 (AnswersResponse로 변환됨) + * @return 생성된 SpecifiedMemberResponse 인스턴스 + */ public static SpecifiedMemberResponse from( RecruitMember member, List answers, diff --git a/src/main/java/inha/gdgoc/domain/recruit/repository/AnswerRepository.java b/src/main/java/inha/gdgoc/domain/recruit/repository/AnswerRepository.java index 3ae036d..fa90161 100644 --- a/src/main/java/inha/gdgoc/domain/recruit/repository/AnswerRepository.java +++ b/src/main/java/inha/gdgoc/domain/recruit/repository/AnswerRepository.java @@ -8,6 +8,13 @@ public interface AnswerRepository extends JpaRepository { + /** + * 주어진 모집 멤버와 설문 타입에 해당하는 모든 Answer 엔티티를 조회합니다. + * + * @param recruitMember 조회할 Answer의 recruitMember 필터 + * @param surveyType 조회할 Answer의 surveyType 필터 + * @return 해당 조건을 만족하는 Answer 목록(없으면 빈 리스트) + */ List findByRecruitMemberAndSurveyType( RecruitMember recruitMember, SurveyType surveyType diff --git a/src/main/java/inha/gdgoc/domain/recruit/service/RecruitMemberService.java b/src/main/java/inha/gdgoc/domain/recruit/service/RecruitMemberService.java index f6f2255..ec90196 100644 --- a/src/main/java/inha/gdgoc/domain/recruit/service/RecruitMemberService.java +++ b/src/main/java/inha/gdgoc/domain/recruit/service/RecruitMemberService.java @@ -26,6 +26,15 @@ public class RecruitMemberService { private final AnswerRepository answerRepository; private final ObjectMapper objectMapper; + /** + * 모집 지원 정보를 저장한다. + * + *

요청에 포함된 지원자 정보(ApplicationRequest.member)를 RecruitMember 엔티티로 변환하여 저장하고, + * 요청의 답변 맵(ApplicationRequest.answers)의 각 항목을 JSON 문자열로 직렬화하여 Answer 엔티티 목록을 생성한 뒤 일괄 저장한다. + * + * @param applicationRequest 저장할 지원자 정보와 답변을 담은 요청 객체 + * @throws RuntimeException JSON 직렬화 오류 발생 시 ("JSON 변환 오류" 메시지와 원인 예외 포함) + */ @Transactional public void addRecruitMember(ApplicationRequest applicationRequest) { RecruitMember member = applicationRequest.getMember().toEntity(); @@ -47,18 +56,39 @@ public void addRecruitMember(ApplicationRequest applicationRequest) { answerRepository.saveAll(answers); } + /** + * 주어진 학번이 이미 등록되어 있는지 확인한다. + * + * @param studentId 확인할 학번 + * @return 등록 여부를 담은 CheckStudentIdResponse (exists=true이면 등록된 학번) + */ public CheckStudentIdResponse isRegisteredStudentId(String studentId) { boolean exists = recruitMemberRepository.existsByStudentId(studentId); return new CheckStudentIdResponse(exists); } + /** + * 주어진 휴대전화 번호가 이미 등록되어 있는지 확인하고 그 결과를 반환합니다. + * + * @param phoneNumber 확인할 휴대전화 번호 + * @return 등록 여부를 담은 {@link CheckPhoneNumberResponse} + */ public CheckPhoneNumberResponse isRegisteredPhoneNumber(String phoneNumber) { boolean exists = recruitMemberRepository.existsByPhoneNumber(phoneNumber); return new CheckPhoneNumberResponse(exists); } + /** + * 주어진 ID에 해당하는 모집 멤버와 해당 멤버의 모집 설문 응답을 조회하여 지정된 응답 DTO로 변환해 반환합니다. + * + * 조회된 멤버가 존재하면 해당 멤버를 기준으로 SurveyType.RECRUIT에 해당하는 Answer 목록을 함께 조회하고, + * ObjectMapper를 이용해 SpecifiedMemberResponse를 생성하여 반환합니다. + * + * @return 조회된 멤버와 응답을 조합한 {@link SpecifiedMemberResponse} + * @throws RecruitMemberException 멤버를 찾을 수 없을 경우(상수 RECRUIT_MEMBER_NOT_FOUND) + */ public SpecifiedMemberResponse findSpecifiedMember(Long id) { RecruitMember member = recruitMemberRepository.findById(id) .orElseThrow(() -> new RecruitMemberException(RECRUIT_MEMBER_NOT_FOUND)); diff --git a/src/main/java/inha/gdgoc/global/config/jwt/TokenProvider.java b/src/main/java/inha/gdgoc/global/config/jwt/TokenProvider.java index ae7cb26..9de1139 100644 --- a/src/main/java/inha/gdgoc/global/config/jwt/TokenProvider.java +++ b/src/main/java/inha/gdgoc/global/config/jwt/TokenProvider.java @@ -67,6 +67,17 @@ public Claims validToken(String token) throws ExpiredJwtException, UnsupportedJw return getClaims(token); } + /** + * JWT에서 인증 정보를 추출해 Spring Security Authentication 객체를 생성합니다. + * + * 토큰의 클레임에서 숫자형 "id"를 읽어 사용자 ID(Long)로 변환하고, 서브젝트를 사용자명(이메일)으로 사용합니다. + * "role" 클레임을 UserRole로 변환한 뒤 "ROLE_" 형태의 권한을 하나 생성하여 CustomUserDetails를 만들고 + * 해당 사용자 정보를 담은 UsernamePasswordAuthenticationToken을 반환합니다. + * + * @param token JWT 문자열 + * @return 토큰으로부터 생성된 Authentication (principal: CustomUserDetails, credentials: null, authorities 포함) + * @throws BusinessException token에 숫자형 "id" 클레임이 없을 경우 INVALID_JWT_REQUEST로 발생합니다. + */ public Authentication getAuthentication(String token) { Claims claims = getClaims(token); diff --git a/src/main/java/inha/gdgoc/global/config/openapi/OpenApiConfig.java b/src/main/java/inha/gdgoc/global/config/openapi/OpenApiConfig.java index 85d88d1..8ab10bd 100644 --- a/src/main/java/inha/gdgoc/global/config/openapi/OpenApiConfig.java +++ b/src/main/java/inha/gdgoc/global/config/openapi/OpenApiConfig.java @@ -15,6 +15,14 @@ @Configuration public class OpenApiConfig { + /** + * OpenAPI 메인 빈을 생성합니다. + * + * OpenAPI 인스턴스에 API 정보(제목 "GDGoC API", 버전 "v1")를 설정하고, + * HTTP Bearer JWT 형식의 보안 스킴 "BearerAuth"를 Components에 추가합니다. + * + * @return 구성된 OpenAPI 인스턴스 + */ @Bean public OpenAPI openAPI() { String schemeName = "BearerAuth"; @@ -31,6 +39,13 @@ public OpenAPI openAPI() { ); } + /** + * 모든 엔드포인트를 포함하는 GroupedOpenApi 빈을 생성합니다. + * + *

그룹 이름은 "all"이며, 모든 경로(" /**")를 매칭하도록 구성됩니다. + * + * @return 모든 경로를 포함하는 GroupedOpenApi 인스턴스 + */ @Bean public GroupedOpenApi all() { return GroupedOpenApi.builder() @@ -39,16 +54,41 @@ public GroupedOpenApi all() { .build(); } + /** + * v1 API 그룹을 위한 GroupedOpenApi 빈을 생성합니다. + * + * "v1" 그룹 이름과 "/api/v1" 경로 접두사를 사용하여 groupedApi(...)를 호출하여, + * 해당 접두사가 제거된 경로와 그룹 전용 Server URL이 설정된 GroupedOpenApi 인스턴스를 반환합니다. + * + * @return "/api/v1" 접두사가 적용된 엔드포인트들을 그룹화하고 프리픽스를 제거한 GroupedOpenApi + */ @Bean public GroupedOpenApi v1Api() { return groupedApi("v1", "/api/v1"); } + /** + * v2 버전의 API 문서 그룹(GroupedOpenApi)을 생성하여 반환합니다. + * + * 이 그룹은 이름 "v2"를 사용하고 경로 접두사 "/api/v2" 아래의 모든 엔드포인트를 포함하도록 구성됩니다. + * + * @return v2 그룹에 대응하는 GroupedOpenApi 인스턴스 + */ @Bean public GroupedOpenApi v2Api() { return groupedApi("v2", "/api/v2"); } + /** + * 지정한 그룹 이름과 전체 경로 접두사로 GroupedOpenApi 인스턴스를 생성한다. + * + * 생성된 GroupedOpenApi는 fullPrefix 이하의 모든 경로(fullPrefix/**)에 매칭되며, + * stripPrefixAndSetServer(fullPrefix) 커스터마이저를 적용해 문서 내 경로에서 접두사를 제거하고 서버 URL을 설정한다. + * + * @param group 문서화될 API 그룹 이름 (예: "v1") + * @param fullPrefix 그룹에 대응되는 전체 경로 접두사 (예: "/api/v1") + * @return 구성된 GroupedOpenApi 인스턴스 + */ private GroupedOpenApi groupedApi(String group, String fullPrefix) { return GroupedOpenApi.builder() .group(group) @@ -57,6 +97,25 @@ private GroupedOpenApi groupedApi(String group, String fullPrefix) { .build(); } + /** + * 그룹화된 API 문서에서 엔드포인트 경로의 그룹 접두사를 제거하고 해당 그룹의 서버 URL을 설정하는 OpenApiCustomizer를 생성한다. + * + *

동작: + *

    + *
  • openApi.getPaths()가 null이거나 비어 있으면 아무 작업도 수행하지 않는다.
  • + *
  • 각 경로에 대해 + *
      + *
    • 경로가 fullPrefix와 정확히 같으면 "/"로 매핑한다.
    • + *
    • 경로가 fullPrefix + "/"로 시작하면 접두사 부분을 제거한다.
    • + *
    • 그 외의 경로는 그대로 유지된다.
    • + *
    + *
  • + *
  • 변환된 경로 집합으로 OpenAPI의 paths를 교체하고, servers를 fullPrefix를 URL로 갖는 단일 Server로 설정한다.
  • + *
+ * + * @param fullPrefix 경로에서 제거할 그룹 접두사이자 설정할 서버 URL (예: "/api/v1") + * @return 접두사 제거 및 서버 설정을 적용하는 OpenApiCustomizer + */ private OpenApiCustomizer stripPrefixAndSetServer(String fullPrefix) { return openApi -> { Paths src = openApi.getPaths(); diff --git a/src/main/java/inha/gdgoc/global/exception/GlobalExceptionHandler.java b/src/main/java/inha/gdgoc/global/exception/GlobalExceptionHandler.java index a139c84..c7c5493 100644 --- a/src/main/java/inha/gdgoc/global/exception/GlobalExceptionHandler.java +++ b/src/main/java/inha/gdgoc/global/exception/GlobalExceptionHandler.java @@ -38,6 +38,16 @@ public ResponseEntity> handleBusinessException( .body(ApiResponse.error(errorCode, meta)); } + /** + * 요청에서 필요한 헤더가 누락된 경우 400 Bad Request 응답을 반환합니다. + * + * 상세: 누락된 헤더 이름을 기반으로 오류 메시지를 생성하고 요청 정보를 사용해 ErrorMeta를 구성한 뒤, + * ApiResponse 형태의 에러 바디와 함께 HTTP 400 상태로 응답합니다. + * + * @param ex 누락된 헤더 정보를 포함하는 MissingRequestHeaderException + * @param request 에러 발생 시점의 HttpServletRequest (응답용 ErrorMeta 생성에 사용) + * @return HTTP 400 상태와 ApiResponse 형태의 에러 응답을 담은 ResponseEntity + */ @ExceptionHandler(MissingRequestHeaderException.class) public ResponseEntity> handleMissingRequestException( MissingRequestHeaderException ex, @@ -69,6 +79,14 @@ public ResponseEntity> handleValidationException( .body(ApiResponse.error(HttpStatus.BAD_REQUEST.value(), message, meta)); } + /** + * 요청 파라미터의 타입 불일치(MethodArgumentTypeMismatchException)를 처리합니다. + * + * 예외로부터 불일치한 파라미터 이름을 사용해 사용자용 메시지를 생성하고, + * 요청 정보로부터 ErrorMeta를 만들어 HTTP 400(Bad Request) 상태의 ApiResponse 오류 응답을 반환합니다. + * + * @return HTTP 400 상태와 에러 메시지·메타를 포함한 ApiResponse를 담은 ResponseEntity + */ @ExceptionHandler(MethodArgumentTypeMismatchException.class) public ResponseEntity> handleTypeMismatch( MethodArgumentTypeMismatchException ex, @@ -83,6 +101,17 @@ public ResponseEntity> handleTypeMismatch( .body(ApiResponse.error(HttpStatus.BAD_REQUEST.value(), message, meta)); } + /** + * 검증(Bean Validation) 제약 위반 예외를 처리하고 HTTP 400 응답을 반환합니다. + * + *

발생한 ConstraintViolationException에서 첫 번째 제약 위반 메시지를 추출하여 + * ApiResponse 형태의 오류 본문과 함께 상태 코드 400(BAD_REQUEST)을 반환합니다. + * 제약 위반 메시지가 없으면 "유효하지 않은 요청입니다."를 사용하며, 요청 정보로부터 생성한 ErrorMeta를 포함합니다. + * + * @param ex 처리할 ConstraintViolationException + * @param request 현재 HTTP 요청 (응답에 포함할 메타 정보 생성에 사용) + * @return 상태 코드 400과 ApiResponse 오류 본문을 담은 ResponseEntity + */ @ExceptionHandler(jakarta.validation.ConstraintViolationException.class) public ResponseEntity> handleConstraintViolation( jakarta.validation.ConstraintViolationException ex, @@ -101,6 +130,14 @@ public ResponseEntity> handleConstraintViolation( .body(ApiResponse.error(HttpStatus.BAD_REQUEST.value(), message, meta)); } + /** + * 인증 관련 예외(AuthenticationCredentialsNotFoundException, AuthenticationException)를 처리하여 + * HTTP 401 Unauthorized 상태와 함께 표준 ApiResponse 오류 응답(코드: UNAUTHORIZED_USER, meta 포함)을 반환한다. + * + * ErrorMeta는 요청 URI와 현재 타임스탬프를 기반으로 생성된다. + * + * @return HTTP 401 상태와 ApiResponse 오류 본문을 담은 ResponseEntity + */ @ExceptionHandler({ AuthenticationCredentialsNotFoundException.class, AuthenticationException.class }) public ResponseEntity> handleAuthentication( AuthenticationException ex, @@ -113,6 +150,16 @@ public ResponseEntity> handleAuthentication( .body(ApiResponse.error(UNAUTHORIZED_USER, meta)); } + /** + * 인증된 사용자가 접근 권한이 없어 요청을 수행할 수 없을 때 이를 처리한다. + * + * 요청 URI와 현재 시각으로 구성된 ErrorMeta를 생성하여 + * HTTP 403 Forbidden 상태와 함께 표준 에러 응답(ApiResponse)으로 반환한다. + * + * @param ex 발생한 AccessDeniedException + * @param request 에러 메타 생성에 사용되는 HttpServletRequest + * @return HTTP 403 상태와 GlobalErrorCode.FORBIDDEN_USER 및 생성된 ErrorMeta를 포함한 ApiResponse + */ @ExceptionHandler(AccessDeniedException.class) public ResponseEntity> handleAccessDenied( AccessDeniedException ex, @@ -125,6 +172,16 @@ public ResponseEntity> handleAccessDenied( .body(ApiResponse.error(FORBIDDEN_USER, meta)); } + /** + * 매핑되지 않은 요청(등록된 핸들러가 없음)을 처리하고 404 Not Found 응답을 반환합니다. + * + * 요청된 경로에 대응하는 핸들러를 찾지 못했을 때 호출됩니다. 요청 정보로부터 생성한 ErrorMeta를 포함한 + * ApiResponse를 바디로 하여 HTTP 404 상태 코드를 반환합니다. + * + * @param ex 처리된 NoHandlerFoundException 인스턴스 + * @param request 요청 URI를 사용해 ErrorMeta를 생성하는 HttpServletRequest + * @return HTTP 404 상태와 RESOURCE_NOT_FOUND 코드 및 생성된 ErrorMeta를 포함한 ApiResponse + */ @ExceptionHandler(NoHandlerFoundException.class) public ResponseEntity> handleNotFound( NoHandlerFoundException ex, @@ -138,6 +195,14 @@ public ResponseEntity> handleNotFound( .body(ApiResponse.error(RESOURCE_NOT_FOUND, meta)); } + /** + * 모든 예외의 최상위 캐치핸들러로, 처리되지 않은 예외 발생 시 500 Internal Server Error 응답을 반환합니다. + * + * 상세: 발생한 예외로부터 에러 메시지를 기록하고 요청 정보를 바탕으로 ErrorMeta를 생성한 뒤, + * ErrorCode.INTERNAL_SERVER_ERROR와 함께 ApiResponse 형태의 ResponseEntity를 상태 코드 500으로 반환합니다. + * + * @return 500 상태와 INTERNAL_SERVER_ERROR 코드 및 요청 메타 정보가 포함된 ApiResponse + */ @ExceptionHandler(Exception.class) public ResponseEntity> handleUnhandledException( Exception ex, @@ -151,6 +216,12 @@ public ResponseEntity> handleUnhandledException( .body(ApiResponse.error(INTERNAL_SERVER_ERROR, meta)); } + /** + * 요청 정보를 바탕으로 ErrorMeta 객체를 생성합니다. + * + * @param request 메타를 만들 때 사용할 요청; ErrorMeta에는 요청의 URI와 현재 시스템 타임스탬프(밀리초)가 들어갑니다. + * @return 요청 URI와 생성 시각을 담은 ErrorMeta 인스턴스 + */ private ErrorMeta createMeta(HttpServletRequest request) { return new ErrorMeta(request.getRequestURI(), System.currentTimeMillis()); } diff --git a/src/main/java/inha/gdgoc/global/security/SecurityConfig.java b/src/main/java/inha/gdgoc/global/security/SecurityConfig.java index e38bac4..cca1215 100644 --- a/src/main/java/inha/gdgoc/global/security/SecurityConfig.java +++ b/src/main/java/inha/gdgoc/global/security/SecurityConfig.java @@ -29,6 +29,24 @@ public class SecurityConfig { private final TokenAuthenticationFilter tokenAuthenticationFilter; + /** + * 애플리케이션의 Spring Security 필터 체인을 구성하고 반환한다. + * + * 구성 내용: + * - CSRF 비활성화 + * - CORS 설정은 corsConfigurationSource() 사용 + * - form 로그인 및 HTTP Basic 인증 비활성화 + * - 다음 경로들은 인증 없이 접근 허용: + * /swagger-ui/**, /v3/api-docs/**, /swagger-ui.html, + * /api/v1/auth/**, /api/v1/game/**, /api/v1/apply/**, /api/v1/check/**, /api/v1/password-reset/** + * - 그 외 모든 요청은 인증 필요 + * - 세션 정책: STATELESS + * - TokenAuthenticationFilter를 UsernamePasswordAuthenticationFilter 앞에 추가 + * - 인증 실패 시 401 상태와 GlobalErrorCode.UNAUTHORIZED_USER 기반의 JSON ErrorResponse 반환 + * - 권한 부족 시 403 상태와 GlobalErrorCode.FORBIDDEN_USER 기반의 JSON ErrorResponse 반환 + * + * @return 구성된 SecurityFilterChain 빈 + */ @Bean public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { http