Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions src/main/java/inha/gdgoc/domain/auth/service/AuthService.java
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,30 @@ public class AuthService {
private final RestTemplate restTemplate = new RestTemplate();
private final TokenProvider tokenProvider;

/**
* Google OAuth 인증 코드를 처리하여 사용자 정보 확인 및 로그인 토큰을 발급한다.
*
* <p>작업 흐름:
* <ol>
* <li>전달된 authorization code로 Google 토큰 엔드포인트에 요청하여 Google access token을 획득한다.</li>
* <li>획득한 Google access token으로 Google 사용자정보(email, name)를 조회한다.</li>
* <li>조회한 이메일로 로컬 사용자 조회:
* <ul>
* <li>사용자가 존재하지 않으면 사용자 존재 여부와 Google에서의 email, name을 반환한다.</li>
* <li>사용자가 존재하면 서비스용 JWT access token을 생성하고(유효기간 1시간),/ 로그인용 refresh token을 생성 또는 조회(유효기간 1일)하여
* HttpOnly Secure 쿠키("refresh_token", SameSite=None, domain=".gdgocinha.com", path="/")로 응답에 설정한 뒤 access token을 반환한다.</li>
* </ul>
* </li>
* </ol>
*
* @param code Google이 발급한 authorization code
* @param response 존재하는 사용자일 때 refresh_token 쿠키를 HttpServletResponse의 Set-Cookie 헤더로 추가하기 위해 사용되는 응답 객체
* @return 결과 맵:
* <ul>
* <li>사용자가 존재하지 않을 때: {"isExists": false, "email": &lt;google 이메일&gt;, "name": &lt;google 이름&gt;}</li>
* <li>사용자가 존재할 때: {"isExists": true, "access_token": &lt;서비스용 JWT access token&gt;}</li>
* </ul>
*/
public Map<String, Object> processOAuthLogin(String code, HttpServletResponse response) {
// 1. code → access token 요청
HttpHeaders headers = new HttpHeaders();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,15 @@ public class RecruitMemberController {

private final RecruitMemberService recruitMemberService;

/**
* 지원서 신청을 등록합니다.
*
* 요청 본문의 ApplicationRequest를 받아 가입 신청을 저장하도록 서비스에 위임하고,
* 저장 성공 시 MEMBER_SAVE_SUCCESS 메시지를 담은 200 OK 응답을 반환합니다.
*
* @param applicationRequest 클라이언트로부터 전달된 가입 신청 DTO
* @return 저장 성공 메시지를 포함한 200 OK 응답 (ApiResponse<Void, Void> 래퍼)
*/
@PostMapping("/apply")
public ResponseEntity<ApiResponse<Void, Void>> recruitMemberAdd(
@RequestBody ApplicationRequest applicationRequest
Expand All @@ -42,6 +51,14 @@ public ResponseEntity<ApiResponse<Void, Void>> recruitMemberAdd(
return ResponseEntity.ok(ApiResponse.ok(MEMBER_SAVE_SUCCESS));
}

/**
* 학번 중복 여부를 조회합니다.
*
* 주어진 학번으로 등록 여부를 확인한 후 검사 결과를 담은 {@link CheckStudentIdResponse}를 ApiResponse로 래핑하여 반환합니다.
*
* @param studentId 확인할 학번(형식: "12XXXXXX", 총 8자리)
* @return 중복 검사 성공 메시지와 함께 검사 결과를 포함한 {@code ResponseEntity<ApiResponse<CheckStudentIdResponse, Void>>}
*/
@GetMapping("/studentId")
public ResponseEntity<ApiResponse<CheckStudentIdResponse, Void>> duplicatedStudentIdDetails(
@RequestParam
Expand All @@ -54,6 +71,15 @@ public ResponseEntity<ApiResponse<CheckStudentIdResponse, Void>> 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<ApiResponse<CheckPhoneNumberResponse, Void>> duplicatedPhoneNumberDetails(
@RequestParam
Expand All @@ -67,6 +93,17 @@ public ResponseEntity<ApiResponse<CheckPhoneNumberResponse, Void>> 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}")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand All @@ -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<Object>, Map<String,Object>) 또는
* 입력이 null/빈 문자열이면 null, 파싱 실패 시 원본 json 문자열
*/
private static Object toFriendlyValue(String json, ObjectMapper om) {
if (json == null || json.isBlank()) return null;
try {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,14 @@
public record AnswersResponse(
List<AnswerResponse> 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<Answer> entities, ObjectMapper objectMapper) {
return new AnswersResponse(
entities.stream()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<Answer> answers,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,13 @@

public interface AnswerRepository extends JpaRepository<Answer, Long> {

/**
* 주어진 모집 멤버와 설문 타입에 해당하는 모든 Answer 엔티티를 조회합니다.
*
* @param recruitMember 조회할 Answer의 recruitMember 필터
* @param surveyType 조회할 Answer의 surveyType 필터
* @return 해당 조건을 만족하는 Answer 목록(없으면 빈 리스트)
*/
List<Answer> findByRecruitMemberAndSurveyType(
RecruitMember recruitMember,
SurveyType surveyType
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,15 @@ public class RecruitMemberService {
private final AnswerRepository answerRepository;
private final ObjectMapper objectMapper;

/**
* 모집 지원 정보를 저장한다.
*
* <p>요청에 포함된 지원자 정보(ApplicationRequest.member)를 RecruitMember 엔티티로 변환하여 저장하고,
* 요청의 답변 맵(ApplicationRequest.answers)의 각 항목을 JSON 문자열로 직렬화하여 Answer 엔티티 목록을 생성한 뒤 일괄 저장한다.
*
* @param applicationRequest 저장할 지원자 정보와 답변을 담은 요청 객체
* @throws RuntimeException JSON 직렬화 오류 발생 시 ("JSON 변환 오류" 메시지와 원인 예외 포함)
*/
@Transactional
public void addRecruitMember(ApplicationRequest applicationRequest) {
RecruitMember member = applicationRequest.getMember().toEntity();
Expand All @@ -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));
Expand Down
11 changes: 11 additions & 0 deletions src/main/java/inha/gdgoc/global/config/jwt/TokenProvider.java
Original file line number Diff line number Diff line change
Expand Up @@ -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_<ROLE_NAME>" 형태의 권한을 하나 생성하여 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);

Expand Down
59 changes: 59 additions & 0 deletions src/main/java/inha/gdgoc/global/config/openapi/OpenApiConfig.java
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -31,6 +39,13 @@ public OpenAPI openAPI() {
);
}

/**
* 모든 엔드포인트를 포함하는 GroupedOpenApi 빈을 생성합니다.
*
* <p>그룹 이름은 "all"이며, 모든 경로(" /**")를 매칭하도록 구성됩니다.
*
* @return 모든 경로를 포함하는 GroupedOpenApi 인스턴스
*/
@Bean
public GroupedOpenApi all() {
return GroupedOpenApi.builder()
Expand All @@ -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)
Expand All @@ -57,6 +97,25 @@ private GroupedOpenApi groupedApi(String group, String fullPrefix) {
.build();
}

/**
* 그룹화된 API 문서에서 엔드포인트 경로의 그룹 접두사를 제거하고 해당 그룹의 서버 URL을 설정하는 OpenApiCustomizer를 생성한다.
*
* <p>동작:
* <ul>
* <li>openApi.getPaths()가 null이거나 비어 있으면 아무 작업도 수행하지 않는다.</li>
* <li>각 경로에 대해
* <ul>
* <li>경로가 fullPrefix와 정확히 같으면 "/"로 매핑한다.</li>
* <li>경로가 fullPrefix + "/"로 시작하면 접두사 부분을 제거한다.</li>
* <li>그 외의 경로는 그대로 유지된다.</li>
* </ul>
* </li>
* <li>변환된 경로 집합으로 OpenAPI의 paths를 교체하고, servers를 fullPrefix를 URL로 갖는 단일 Server로 설정한다.</li>
* </ul>
*
* @param fullPrefix 경로에서 제거할 그룹 접두사이자 설정할 서버 URL (예: "/api/v1")
* @return 접두사 제거 및 서버 설정을 적용하는 OpenApiCustomizer
*/
private OpenApiCustomizer stripPrefixAndSetServer(String fullPrefix) {
return openApi -> {
Paths src = openApi.getPaths();
Expand Down
Loading