diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index dbd455a6..9f88ea5f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -20,13 +20,24 @@ jobs: build: runs-on: ubuntu-latest + services: + redis: + image: redis + ports: + - 6379:6379 + options: >- + --health-cmd "redis-cli ping" + --health-interval 10s + --health-timeout 5s + --health-retries 5 + steps: - uses: actions/checkout@v4 - uses: actions/setup-java@v4 with: distribution: temurin - java-version: 17 + java-version: 21 - uses: gradle/actions/setup-gradle@v3 if: ${{ !env.ACT }} @@ -35,7 +46,14 @@ jobs: gradle-home-cache-cleanup: true - name: Create dummy .env for CI - run: echo "# ci dummy" > .env + env: + AUDIENCE_SECRET: ${{ secrets.JWT_AUDIENCE }} + run: | + AUDIENCE_VALUE="${AUDIENCE_SECRET:-ci-audience}" + cat > .env < result = adminService.searchApplications(session, status, team, pageable); + java.util.List content = result + .map(RecruitCoreApplicantSummaryResponse::from) + .getContent(); + return RecruitCoreApplicationPageResponse.from( + content, + result.getNumber(), + result.getSize(), + result.getTotalElements(), + result.getTotalPages(), + result.isLast() + ); + } + + @PreAuthorize(ORGANIZER_OR_HR_LEAD_RULE) + @PostMapping("/{applicationId}/accept") + public ResponseEntity accept( + @AuthenticationPrincipal CustomUserDetails reviewer, + @PathVariable Long applicationId, + @Valid @RequestBody RecruitCoreApplicationAcceptRequest request + ) { + RecruitCoreApplicationDecisionResponse response = + adminService.accept(applicationId, reviewer.getUserId(), request); + return ResponseEntity.ok(response); + } + + @PreAuthorize(ORGANIZER_OR_HR_LEAD_RULE) + @PostMapping("/{applicationId}/reject") + public ResponseEntity reject( + @AuthenticationPrincipal CustomUserDetails reviewer, + @PathVariable Long applicationId, + @Valid @RequestBody RecruitCoreApplicationRejectRequest request + ) { + RecruitCoreApplicationDecisionResponse response = + adminService.reject(applicationId, reviewer.getUserId(), request); + return ResponseEntity.ok(response); + } +} diff --git a/src/main/java/inha/gdgoc/domain/admin/recruit/core/dto/request/RecruitCoreApplicationAcceptRequest.java b/src/main/java/inha/gdgoc/domain/admin/recruit/core/dto/request/RecruitCoreApplicationAcceptRequest.java new file mode 100644 index 00000000..276fb13a --- /dev/null +++ b/src/main/java/inha/gdgoc/domain/admin/recruit/core/dto/request/RecruitCoreApplicationAcceptRequest.java @@ -0,0 +1,10 @@ +package inha.gdgoc.domain.admin.recruit.core.dto.request; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; + +public record RecruitCoreApplicationAcceptRequest( + @NotBlank String resultNote, + @NotNull Boolean overwriteTeamIfExists +) { +} diff --git a/src/main/java/inha/gdgoc/domain/admin/recruit/core/dto/request/RecruitCoreApplicationRejectRequest.java b/src/main/java/inha/gdgoc/domain/admin/recruit/core/dto/request/RecruitCoreApplicationRejectRequest.java new file mode 100644 index 00000000..656f6ac9 --- /dev/null +++ b/src/main/java/inha/gdgoc/domain/admin/recruit/core/dto/request/RecruitCoreApplicationRejectRequest.java @@ -0,0 +1,8 @@ +package inha.gdgoc.domain.admin.recruit.core.dto.request; + +import jakarta.validation.constraints.NotBlank; + +public record RecruitCoreApplicationRejectRequest( + @NotBlank String resultNote +) { +} diff --git a/src/main/java/inha/gdgoc/domain/admin/recruit/core/dto/response/RecruitCoreApplicantSummaryResponse.java b/src/main/java/inha/gdgoc/domain/admin/recruit/core/dto/response/RecruitCoreApplicantSummaryResponse.java new file mode 100644 index 00000000..8babc286 --- /dev/null +++ b/src/main/java/inha/gdgoc/domain/admin/recruit/core/dto/response/RecruitCoreApplicantSummaryResponse.java @@ -0,0 +1,30 @@ +package inha.gdgoc.domain.admin.recruit.core.dto.response; + +import inha.gdgoc.domain.recruit.core.entity.RecruitCoreApplication; +import inha.gdgoc.domain.recruit.core.enums.RecruitCoreResultStatus; +import java.time.Instant; + +public record RecruitCoreApplicantSummaryResponse( + Long applicationId, + String name, + String studentId, + String major, + String team, + RecruitCoreResultStatus resultStatus, + String session, + Instant createdAt +) { + + public static RecruitCoreApplicantSummaryResponse from(RecruitCoreApplication entity) { + return new RecruitCoreApplicantSummaryResponse( + entity.getId(), + entity.getName(), + entity.getStudentId(), + entity.getMajor(), + entity.getTeam(), + entity.getResultStatus(), + entity.getSession(), + entity.getCreatedAt() + ); + } +} diff --git a/src/main/java/inha/gdgoc/domain/admin/recruit/core/dto/response/RecruitCoreApplicationDecisionResponse.java b/src/main/java/inha/gdgoc/domain/admin/recruit/core/dto/response/RecruitCoreApplicationDecisionResponse.java new file mode 100644 index 00000000..6d47f6e2 --- /dev/null +++ b/src/main/java/inha/gdgoc/domain/admin/recruit/core/dto/response/RecruitCoreApplicationDecisionResponse.java @@ -0,0 +1,44 @@ +package inha.gdgoc.domain.admin.recruit.core.dto.response; + +import com.fasterxml.jackson.annotation.JsonInclude; +import inha.gdgoc.domain.recruit.core.entity.RecruitCoreApplication; +import inha.gdgoc.domain.recruit.core.enums.RecruitCoreResultStatus; +import inha.gdgoc.domain.user.enums.TeamType; +import inha.gdgoc.domain.user.enums.UserRole; +import java.time.Instant; + +@JsonInclude(JsonInclude.Include.NON_NULL) +public record RecruitCoreApplicationDecisionResponse( + Long applicationId, + RecruitCoreResultStatus resultStatus, + Instant reviewedAt, + Long reviewedBy, + UserUpdated userUpdated +) { + + public static RecruitCoreApplicationDecisionResponse accepted( + RecruitCoreApplication application, + UserRole userRole, + TeamType team + ) { + return new RecruitCoreApplicationDecisionResponse( + application.getId(), + application.getResultStatus(), + application.getReviewedAt(), + application.getReviewedBy(), + new UserUpdated(userRole, team) + ); + } + + public static RecruitCoreApplicationDecisionResponse rejected(RecruitCoreApplication application) { + return new RecruitCoreApplicationDecisionResponse( + application.getId(), + application.getResultStatus(), + application.getReviewedAt(), + application.getReviewedBy(), + null + ); + } + + public record UserUpdated(UserRole userRole, TeamType team) {} +} diff --git a/src/main/java/inha/gdgoc/domain/admin/recruit/core/dto/response/RecruitCoreApplicationPageResponse.java b/src/main/java/inha/gdgoc/domain/admin/recruit/core/dto/response/RecruitCoreApplicationPageResponse.java new file mode 100644 index 00000000..02ce05cc --- /dev/null +++ b/src/main/java/inha/gdgoc/domain/admin/recruit/core/dto/response/RecruitCoreApplicationPageResponse.java @@ -0,0 +1,31 @@ +package inha.gdgoc.domain.admin.recruit.core.dto.response; + +import java.util.List; + +public record RecruitCoreApplicationPageResponse( + List content, + Pageable pageable, + long totalElements, + int totalPages, + boolean last +) { + + public static RecruitCoreApplicationPageResponse from( + List items, + int pageNumber, + int pageSize, + long totalElements, + int totalPages, + boolean last + ) { + return new RecruitCoreApplicationPageResponse( + items, + new Pageable(pageNumber, pageSize), + totalElements, + totalPages, + last + ); + } + + public record Pageable(int pageNumber, int pageSize) {} +} diff --git a/src/main/java/inha/gdgoc/domain/admin/recruit/core/service/RecruitCoreAdminService.java b/src/main/java/inha/gdgoc/domain/admin/recruit/core/service/RecruitCoreAdminService.java new file mode 100644 index 00000000..2b6a41f4 --- /dev/null +++ b/src/main/java/inha/gdgoc/domain/admin/recruit/core/service/RecruitCoreAdminService.java @@ -0,0 +1,114 @@ +package inha.gdgoc.domain.admin.recruit.core.service; + +import inha.gdgoc.domain.admin.recruit.core.dto.request.RecruitCoreApplicationAcceptRequest; +import inha.gdgoc.domain.admin.recruit.core.dto.request.RecruitCoreApplicationRejectRequest; +import inha.gdgoc.domain.admin.recruit.core.dto.response.RecruitCoreApplicationDecisionResponse; +import inha.gdgoc.domain.recruit.core.entity.RecruitCoreApplication; +import inha.gdgoc.domain.recruit.core.enums.RecruitCoreResultStatus; +import inha.gdgoc.domain.recruit.core.repository.RecruitCoreApplicationRepository; +import inha.gdgoc.domain.user.entity.User; +import inha.gdgoc.domain.user.enums.TeamType; +import inha.gdgoc.domain.user.enums.UserRole; +import inha.gdgoc.global.exception.BusinessException; +import inha.gdgoc.global.exception.GlobalErrorCode; +import java.time.Instant; +import java.util.Objects; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.domain.Specification; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +public class RecruitCoreAdminService { + + private final RecruitCoreApplicationRepository repository; + + @Transactional(readOnly = true) + public Page searchApplications( + String session, + RecruitCoreResultStatus status, + TeamType team, + Pageable pageable + ) { + Specification spec = Specification.where(bySession(session)); + if (status != null) { + spec = spec.and((root, query, builder) -> builder.equal(root.get("resultStatus"), status)); + } + if (team != null) { + spec = spec.and((root, query, builder) -> builder.equal(root.get("team"), team.name())); + } + return repository.findAll(spec, pageable); + } + + @Transactional + public RecruitCoreApplicationDecisionResponse accept( + Long applicationId, + Long reviewerId, + RecruitCoreApplicationAcceptRequest request + ) { + RecruitCoreApplication application = getApplication(applicationId); + ensureDecidable(application); + Instant now = Instant.now(); + application.accept(reviewerId, request.resultNote(), now); + + User applicant = application.getUser(); + if (!UserRole.hasAtLeast(applicant.getUserRole(), UserRole.CORE)) { + applicant.changeRole(UserRole.CORE); + } + TeamType applicantTeam = applicant.getTeam(); + TeamType applicationTeam = teamTypeOf(application.getTeam()); + if (applicationTeam != null && (Boolean.TRUE.equals(request.overwriteTeamIfExists()) || applicantTeam == null)) { + applicant.changeTeam(applicationTeam); + applicantTeam = applicationTeam; + } + + return RecruitCoreApplicationDecisionResponse.accepted( + application, + applicant.getUserRole(), + applicantTeam + ); + } + + @Transactional + public RecruitCoreApplicationDecisionResponse reject( + Long applicationId, + Long reviewerId, + RecruitCoreApplicationRejectRequest request + ) { + RecruitCoreApplication application = getApplication(applicationId); + ensureDecidable(application); + Instant now = Instant.now(); + application.reject(reviewerId, request.resultNote(), now); + return RecruitCoreApplicationDecisionResponse.rejected(application); + } + + private Specification bySession(String session) { + return (root, query, builder) -> builder.equal(root.get("session"), Objects.requireNonNull(session)); + } + + private RecruitCoreApplication getApplication(Long id) { + return repository.findById(id) + .orElseThrow(() -> new BusinessException(GlobalErrorCode.RESOURCE_NOT_FOUND)); + } + + private void ensureDecidable(RecruitCoreApplication application) { + if (application.getResultStatus() == RecruitCoreResultStatus.ACCEPTED + || application.getResultStatus() == RecruitCoreResultStatus.REJECTED) { + throw new BusinessException(GlobalErrorCode.BAD_REQUEST, "이미 처리된 지원서입니다."); + } + } + + private TeamType teamTypeOf(String team) { + if (team == null) { + return null; + } + try { + return TeamType.valueOf(team); + } catch (IllegalArgumentException ex) { + return null; + } + } +} diff --git a/src/main/java/inha/gdgoc/domain/user/controller/UserAdminController.java b/src/main/java/inha/gdgoc/domain/admin/user/controller/UserAdminController.java similarity index 61% rename from src/main/java/inha/gdgoc/domain/user/controller/UserAdminController.java rename to src/main/java/inha/gdgoc/domain/admin/user/controller/UserAdminController.java index 56135d94..3e9cb60e 100644 --- a/src/main/java/inha/gdgoc/domain/user/controller/UserAdminController.java +++ b/src/main/java/inha/gdgoc/domain/admin/user/controller/UserAdminController.java @@ -1,11 +1,9 @@ -package inha.gdgoc.domain.user.controller; +package inha.gdgoc.domain.admin.user.controller; -import inha.gdgoc.domain.user.dto.request.UpdateRoleRequest; -import inha.gdgoc.domain.user.dto.request.UpdateUserRoleTeamRequest; -import inha.gdgoc.domain.user.dto.response.UserSummaryResponse; -import inha.gdgoc.domain.user.enums.TeamType; -import inha.gdgoc.domain.user.enums.UserRole; -import inha.gdgoc.domain.user.service.UserAdminService; +import inha.gdgoc.domain.admin.user.dto.request.UpdateRoleRequest; +import inha.gdgoc.domain.admin.user.dto.request.UpdateUserRoleTeamRequest; +import inha.gdgoc.domain.admin.user.dto.response.UserSummaryResponse; +import inha.gdgoc.domain.admin.user.service.UserAdminService; import inha.gdgoc.global.config.jwt.TokenProvider.CustomUserDetails; import inha.gdgoc.global.dto.response.ApiResponse; import inha.gdgoc.global.dto.response.PageMeta; @@ -13,22 +11,43 @@ import io.swagger.v3.oas.annotations.security.SecurityRequirement; import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; -import org.springframework.data.domain.*; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Sort; import org.springframework.http.ResponseEntity; import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.security.core.annotation.AuthenticationPrincipal; -import org.springframework.web.bind.annotation.*; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PatchMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; @RequiredArgsConstructor @RestController @RequestMapping("/api/v1/admin/users") public class UserAdminController { + private static final String LEAD_OR_HR_RULE = + "@accessGuard.check(authentication," + + " T(inha.gdgoc.global.security.AccessGuard$AccessCondition).atLeast(" + + "T(inha.gdgoc.domain.user.enums.UserRole).LEAD)," + + " T(inha.gdgoc.global.security.AccessGuard$AccessCondition).of(" + + "T(inha.gdgoc.domain.user.enums.UserRole).CORE," + + " T(inha.gdgoc.domain.user.enums.TeamType).HR))"; + private static final String LEAD_OR_HIGHER_RULE = + "@accessGuard.check(authentication," + + " T(inha.gdgoc.global.security.AccessGuard$AccessCondition).atLeast(" + + "T(inha.gdgoc.domain.user.enums.UserRole).LEAD))"; + private final UserAdminService userAdminService; - // q(검색) + role/team(필터) + pageable @Operation(summary = "사용자 요약 목록 조회", security = {@SecurityRequirement(name = "BearerAuth")}) - @PreAuthorize("hasAnyRole('LEAD','ORGANIZER','ADMIN') or T(inha.gdgoc.domain.user.enums.TeamType).HR == principal.team") + @PreAuthorize(LEAD_OR_HR_RULE) @GetMapping public ResponseEntity, PageMeta>> list( @RequestParam(required = false) String q, @@ -44,7 +63,7 @@ public ResponseEntity, PageMeta>> list( } @Operation(summary = "사용자 역할/팀 수정", security = {@SecurityRequirement(name = "BearerAuth")}) - @PreAuthorize("hasAnyRole('LEAD','ORGANIZER','ADMIN')") + @PreAuthorize(LEAD_OR_HIGHER_RULE) @PatchMapping("/{userId}/role-team") public ResponseEntity> updateRoleTeam( @AuthenticationPrincipal CustomUserDetails me, @@ -56,7 +75,7 @@ public ResponseEntity> updateRoleTeam( } @Operation(summary = "사용자 역할 수정", security = {@SecurityRequirement(name = "BearerAuth")}) - @PreAuthorize("hasAnyRole('LEAD','ORGANIZER','ADMIN') or T(inha.gdgoc.domain.user.enums.TeamType).HR == principal.team") + @PreAuthorize(LEAD_OR_HR_RULE) @PatchMapping("/{userId}/role") public ResponseEntity> updateUserRole( @AuthenticationPrincipal CustomUserDetails me, @@ -68,7 +87,7 @@ public ResponseEntity> updateUserRole( } @Operation(summary = "사용자 삭제", security = {@SecurityRequirement(name = "BearerAuth")}) - @PreAuthorize("hasAnyRole('LEAD','ORGANIZER','ADMIN')") + @PreAuthorize(LEAD_OR_HIGHER_RULE) @DeleteMapping("/{userId}") public ResponseEntity> deleteUser( @AuthenticationPrincipal CustomUserDetails me, @@ -77,4 +96,4 @@ public ResponseEntity> deleteUser( userAdminService.deleteUserWithRules(me, userId); return ResponseEntity.ok(ApiResponse.ok("USER_DELETED")); } -} \ No newline at end of file +} diff --git a/src/main/java/inha/gdgoc/domain/user/dto/request/UpdateRoleRequest.java b/src/main/java/inha/gdgoc/domain/admin/user/dto/request/UpdateRoleRequest.java similarity index 74% rename from src/main/java/inha/gdgoc/domain/user/dto/request/UpdateRoleRequest.java rename to src/main/java/inha/gdgoc/domain/admin/user/dto/request/UpdateRoleRequest.java index 201a45ca..315123d8 100644 --- a/src/main/java/inha/gdgoc/domain/user/dto/request/UpdateRoleRequest.java +++ b/src/main/java/inha/gdgoc/domain/admin/user/dto/request/UpdateRoleRequest.java @@ -1,8 +1,8 @@ -package inha.gdgoc.domain.user.dto.request; +package inha.gdgoc.domain.admin.user.dto.request; import inha.gdgoc.domain.user.enums.UserRole; import jakarta.validation.constraints.NotNull; public record UpdateRoleRequest( @NotNull UserRole role -) {} \ No newline at end of file +) {} diff --git a/src/main/java/inha/gdgoc/domain/admin/user/dto/request/UpdateUserRoleTeamRequest.java b/src/main/java/inha/gdgoc/domain/admin/user/dto/request/UpdateUserRoleTeamRequest.java new file mode 100644 index 00000000..4dbb306e --- /dev/null +++ b/src/main/java/inha/gdgoc/domain/admin/user/dto/request/UpdateUserRoleTeamRequest.java @@ -0,0 +1,9 @@ +package inha.gdgoc.domain.admin.user.dto.request; + +import inha.gdgoc.domain.user.enums.TeamType; +import inha.gdgoc.domain.user.enums.UserRole; + +public record UpdateUserRoleTeamRequest( + UserRole role, + TeamType team +) {} diff --git a/src/main/java/inha/gdgoc/domain/user/dto/response/UserSummaryResponse.java b/src/main/java/inha/gdgoc/domain/admin/user/dto/response/UserSummaryResponse.java similarity index 83% rename from src/main/java/inha/gdgoc/domain/user/dto/response/UserSummaryResponse.java rename to src/main/java/inha/gdgoc/domain/admin/user/dto/response/UserSummaryResponse.java index 0d9ff9fc..501ec9df 100644 --- a/src/main/java/inha/gdgoc/domain/user/dto/response/UserSummaryResponse.java +++ b/src/main/java/inha/gdgoc/domain/admin/user/dto/response/UserSummaryResponse.java @@ -1,4 +1,4 @@ -package inha.gdgoc.domain.user.dto.response; +package inha.gdgoc.domain.admin.user.dto.response; import inha.gdgoc.domain.user.enums.TeamType; import inha.gdgoc.domain.user.enums.UserRole; @@ -11,4 +11,4 @@ public record UserSummaryResponse( String email, UserRole userRole, TeamType team -) {} \ No newline at end of file +) {} diff --git a/src/main/java/inha/gdgoc/domain/user/service/UserAdminService.java b/src/main/java/inha/gdgoc/domain/admin/user/service/UserAdminService.java similarity index 91% rename from src/main/java/inha/gdgoc/domain/user/service/UserAdminService.java rename to src/main/java/inha/gdgoc/domain/admin/user/service/UserAdminService.java index cca2cb0b..40daf625 100644 --- a/src/main/java/inha/gdgoc/domain/user/service/UserAdminService.java +++ b/src/main/java/inha/gdgoc/domain/admin/user/service/UserAdminService.java @@ -1,7 +1,7 @@ -package inha.gdgoc.domain.user.service; +package inha.gdgoc.domain.admin.user.service; -import inha.gdgoc.domain.user.dto.request.UpdateUserRoleTeamRequest; -import inha.gdgoc.domain.user.dto.response.UserSummaryResponse; +import inha.gdgoc.domain.admin.user.dto.request.UpdateUserRoleTeamRequest; +import inha.gdgoc.domain.admin.user.dto.response.UserSummaryResponse; import inha.gdgoc.domain.user.entity.User; import inha.gdgoc.domain.user.enums.TeamType; import inha.gdgoc.domain.user.enums.UserRole; @@ -26,8 +26,6 @@ public class UserAdminService { private final UserRepository userRepository; - /* ======================= 목록 ======================= */ - @Transactional(readOnly = true) public Page listUsers(String q, Pageable pageable) { Pageable fixed = rewriteSort(pageable); @@ -36,7 +34,9 @@ public Page listUsers(String q, Pageable pageable) { private Pageable rewriteSort(Pageable pageable) { Sort original = pageable.getSort(); - if (original.isUnsorted()) return pageable; + if (original.isUnsorted()) { + return pageable; + } Sort composed = Sort.unsorted(); boolean hasUserRoleOrder = false; @@ -50,6 +50,7 @@ private Pageable rewriteSort(Pageable pageable) { " WHEN u.userRole = 'ORGANIZER' THEN 4 " + " WHEN u.userRole = 'ADMIN' THEN 5 " + " ELSE -1 END)"; + for (Sort.Order o : original) { String prop = o.getProperty(); Sort.Direction dir = o.getDirection(); @@ -73,11 +74,10 @@ private Pageable rewriteSort(Pageable pageable) { composed = composed.and(JpaSort.unsafe(Sort.Direction.DESC, roleRankCase)); composed = composed.and(Sort.by("name").ascending()); } + return PageRequest.of(pageable.getPageNumber(), pageable.getPageSize(), composed); } - /* ======================= 수정 ======================= */ - @Transactional public void updateRoleAndTeam(CustomUserDetails editor, Long targetUserId, UpdateUserRoleTeamRequest req) { User editorUser = getEditor(editor); @@ -90,10 +90,8 @@ public void updateRoleAndTeam(CustomUserDetails editor, Long targetUserId, Updat UserRole newRole = (req.role() != null ? req.role() : targetCurrentRole); TeamType requestedTeam = (req.team() != null ? req.team() : target.getTeam()); - // 팀 보유 가능한 역할만 팀 허용 (CORE, LEAD) TeamType newTeam = isTeamAssignableRole(newRole) ? requestedTeam : null; - // 공통: 에디터는 대상의 현재/신규 role보다 엄격히 높아야 함 if (!(editorRole.rank() > targetCurrentRole.rank())) { throw new BusinessException(GlobalErrorCode.FORBIDDEN_USER, "동급/상위 사용자의 정보는 변경할 수 없습니다."); } @@ -124,7 +122,6 @@ public void updateRoleAndTeam(CustomUserDetails editor, Long targetUserId, Updat } if (editor.getTeam() == TeamType.HR) { - // HR-LEAD: 본인 제외 타인지원 팀 변경 가능 if (editorUser.getId().equals(target.getId())) { if (req.team() != null && !Objects.equals(req.team(), target.getTeam())) { throw new BusinessException(GlobalErrorCode.FORBIDDEN_USER, "HR-LEAD도 자기 자신의 팀은 변경할 수 없습니다."); @@ -159,12 +156,13 @@ public void updateUserRoleWithRules(CustomUserDetails me, Long targetUserId, Use UserRole current = target.getUserRole(); - // HR-CORE 특례: GUEST -> MEMBER boolean isHrCore = (meRole == UserRole.CORE) && (meTeam == TeamType.HR); if (isHrCore) { if (current == UserRole.GUEST && newRole == UserRole.MEMBER) { target.changeRole(UserRole.MEMBER); - if (!isTeamAssignableRole(UserRole.MEMBER)) target.changeTeam(null); + if (!isTeamAssignableRole(UserRole.MEMBER)) { + target.changeTeam(null); + } userRepository.save(target); return; } @@ -178,7 +176,9 @@ public void updateUserRoleWithRules(CustomUserDetails me, Long targetUserId, Use } target.changeRole(newRole); - if (!isTeamAssignableRole(newRole)) target.changeTeam(null); + if (!isTeamAssignableRole(newRole)) { + target.changeTeam(null); + } userRepository.save(target); } @@ -203,18 +203,22 @@ public void deleteUserWithRules(CustomUserDetails me, Long targetUserId) { } switch (editorRole) { - case ADMIN -> {} + case ADMIN -> { + } case ORGANIZER -> { if (targetRole == UserRole.ADMIN) { throw new BusinessException(GlobalErrorCode.FORBIDDEN_USER, "ADMIN 사용자는 삭제할 수 없습니다."); } } case LEAD -> { + if (editorTeam == null) { + throw new BusinessException(GlobalErrorCode.FORBIDDEN_USER, "LEAD 토큰에 팀 정보가 없습니다."); + } if (!(targetRole == UserRole.MEMBER || targetRole == UserRole.CORE)) { throw new BusinessException(GlobalErrorCode.FORBIDDEN_USER, "LEAD는 MEMBER/CORE만 삭제할 수 있습니다."); } if (editorTeam != TeamType.HR) { - if (editorTeam == null || targetTeam != editorTeam) { + if (targetTeam != editorTeam) { throw new BusinessException(GlobalErrorCode.FORBIDDEN_USER, "다른 팀 사용자는 삭제할 수 없습니다."); } } @@ -225,19 +229,21 @@ public void deleteUserWithRules(CustomUserDetails me, Long targetUserId) { userRepository.delete(target); } + private User getEditor(CustomUserDetails editor) { + return userRepository.findById(editor.getUserId()) + .orElseThrow(() -> new BusinessException(GlobalErrorCode.UNAUTHORIZED_USER)); + } + private void targetChange(User target, UserRole newRole, TeamType newTeam) { target.changeRole(newRole); - if (!isTeamAssignableRole(newRole)) newTeam = null; + if (!isTeamAssignableRole(newRole)) { + newTeam = null; + } target.changeTeam(newTeam); userRepository.save(target); } - private User getEditor(CustomUserDetails editor) { - return userRepository.findById(editor.getUserId()) - .orElseThrow(() -> new BusinessException(GlobalErrorCode.UNAUTHORIZED_USER)); - } - private boolean isTeamAssignableRole(UserRole role) { return role == UserRole.CORE || role == UserRole.LEAD; } -} \ No newline at end of file +} diff --git a/src/main/java/inha/gdgoc/domain/auth/controller/AuthController.java b/src/main/java/inha/gdgoc/domain/auth/controller/AuthController.java index 52ca638c..05f2c3d8 100644 --- a/src/main/java/inha/gdgoc/domain/auth/controller/AuthController.java +++ b/src/main/java/inha/gdgoc/domain/auth/controller/AuthController.java @@ -1,199 +1,133 @@ package inha.gdgoc.domain.auth.controller; -import inha.gdgoc.domain.auth.dto.request.CodeVerificationRequest; -import inha.gdgoc.domain.auth.dto.request.PasswordResetRequest; -import inha.gdgoc.domain.auth.dto.request.SendingCodeRequest; -import inha.gdgoc.domain.auth.dto.request.UserLoginRequest; +import static inha.gdgoc.domain.auth.controller.message.AuthMessage.*; + +import inha.gdgoc.domain.auth.dto.request.CheckPhoneNumberRequest; +import inha.gdgoc.domain.auth.dto.request.CheckStudentIdRequest; +import inha.gdgoc.domain.auth.dto.request.LoginRequest; +import inha.gdgoc.domain.auth.dto.request.SignupRequest; +import inha.gdgoc.domain.auth.dto.request.TokenRefreshRequest; import inha.gdgoc.domain.auth.dto.response.AccessTokenResponse; -import inha.gdgoc.domain.auth.dto.response.CodeVerificationResponse; -import inha.gdgoc.domain.auth.dto.response.LoginResponse; +import inha.gdgoc.domain.auth.dto.response.AuthUserResponse; +import inha.gdgoc.domain.auth.dto.response.CheckPhoneNumberResponse; +import inha.gdgoc.domain.auth.dto.response.CheckStudentIdResponse; import inha.gdgoc.domain.auth.exception.AuthErrorCode; import inha.gdgoc.domain.auth.exception.AuthException; -import inha.gdgoc.domain.auth.service.AuthCodeService; import inha.gdgoc.domain.auth.service.AuthService; -import inha.gdgoc.domain.auth.service.MailService; -import inha.gdgoc.domain.auth.service.RefreshTokenService; -import inha.gdgoc.domain.user.entity.User; import inha.gdgoc.domain.user.enums.TeamType; import inha.gdgoc.domain.user.enums.UserRole; -import inha.gdgoc.domain.user.repository.UserRepository; import inha.gdgoc.global.config.jwt.TokenProvider; import inha.gdgoc.global.dto.response.ApiResponse; import inha.gdgoc.global.exception.GlobalErrorCode; -import jakarta.servlet.http.HttpServletResponse; import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; -import org.springframework.security.access.prepost.PreAuthorize; -import org.springframework.security.core.Authentication; import org.springframework.security.core.annotation.AuthenticationPrincipal; -import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.util.StringUtils; import org.springframework.web.bind.annotation.*; -import java.security.InvalidKeyException; -import java.security.NoSuchAlgorithmException; -import java.util.Map; -import java.util.Optional; - -import static inha.gdgoc.domain.auth.controller.message.AuthMessage.*; -import static inha.gdgoc.domain.auth.exception.AuthErrorCode.UNAUTHORIZED_USER; -import static inha.gdgoc.domain.auth.exception.AuthErrorCode.USER_NOT_FOUND; - @Slf4j @RequestMapping("/api/v1/auth") @RestController @RequiredArgsConstructor public class AuthController { - private final UserRepository userRepository; private final AuthService authService; - private final RefreshTokenService refreshTokenService; - private final MailService mailService; - private final AuthCodeService authCodeService; - - @GetMapping("/oauth2/google/callback") - public ResponseEntity, Void>> handleGoogleCallback(@RequestParam String code, HttpServletResponse response) { - Map data = authService.processOAuthLogin(code, response); - return ResponseEntity.ok(ApiResponse.ok(OAUTH_LOGIN_SIGNUP_SUCCESS, data)); - } - - @PostMapping("/refresh") - public ResponseEntity refreshAccessToken(@CookieValue(value = "refresh_token", required = false) String refreshToken) { - log.info("리프레시 토큰 요청 받음. 토큰 존재 여부: {}", refreshToken != null); - - if (refreshToken == null) { - throw new AuthException(AuthErrorCode.INVALID_COOKIE); + // 1. 구글 로그인 (ID Token 검증) + @PostMapping("/login") + public ResponseEntity login(@RequestBody LoginRequest request) { + try { + Object response = authService.login(request.getIdToken()); + return ResponseEntity.ok().body(ApiResponse.ok(LOGIN_SUCCESS, response)); + } catch (IllegalArgumentException e) { + return ResponseEntity.status(HttpStatus.BAD_REQUEST) + .body(ApiResponse.error(AuthErrorCode.INVALID_TOKEN.getStatus().value(), e.getMessage(), null)); } + } + // 2. 회원가입 (추가 정보 입력) + @PostMapping("/signup") + public ResponseEntity signup(@Valid @RequestBody SignupRequest request) { try { - String newAccessToken = refreshTokenService.refreshAccessToken(refreshToken); - AccessTokenResponse accessTokenResponse = new AccessTokenResponse(newAccessToken); - - return ResponseEntity.ok(ApiResponse.ok(ACCESS_TOKEN_REFRESH_SUCCESS, accessTokenResponse, null)); - } catch (Exception e) { - log.error("리프레시 토큰 처리 중 오류: {}", e.getMessage(), e); - throw new AuthException(AuthErrorCode.INVALID_REFRESH_TOKEN); + Object response = authService.signup(request); + return ResponseEntity.status(HttpStatus.CREATED).body(ApiResponse.ok(SIGNUP_SUCCESS, response)); + } catch (IllegalArgumentException e) { + return ResponseEntity.status(HttpStatus.BAD_REQUEST) + .body(ApiResponse.error(HttpStatus.BAD_REQUEST.value(), e.getMessage(), null)); } } - @PostMapping("/login") - public ResponseEntity> login(@Valid @RequestBody UserLoginRequest req, HttpServletResponse response) throws NoSuchAlgorithmException, InvalidKeyException { - String email = req.email().trim(); - LoginResponse loginResponse = authService.loginWithPassword(email, req.password(), response); - return ResponseEntity.ok(ApiResponse.ok(LOGIN_WITH_PASSWORD_SUCCESS, loginResponse)); + @PostMapping("/check/student-id") + public ResponseEntity> duplicatedStudentIdDetails( + @Valid @RequestBody CheckStudentIdRequest request + ) { + CheckStudentIdResponse response = authService.isRegisteredStudentId(request.getStudentId()); + return ResponseEntity.ok(ApiResponse.ok(STUDENT_ID_DUPLICATION_CHECK_SUCCESS, response)); } - @PostMapping("/logout") - @PreAuthorize("isAuthenticated()") - public ResponseEntity> logout() { - // TODO 서비스로 넘기기 - Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); - - // 1) 익명 방어 - if (authentication == null || !authentication.isAuthenticated() || "anonymousUser".equals(authentication.getName())) { - throw new AuthException(UNAUTHORIZED_USER); - } - - // 2) principal 캐스팅해서 확정적으로 userId/email 사용 - Object principal = authentication.getPrincipal(); - if (!(principal instanceof TokenProvider.CustomUserDetails userDetails)) { - throw new AuthException(UNAUTHORIZED_USER); - } - - Long userId = userDetails.getUserId(); - String email = userDetails.getUsername(); - - log.info("로그아웃 시도: 사용자 ID: {}, 이메일: {}", userId, email); - - if (userId != null) { - boolean deleted = refreshTokenService.logout(userId); - - if (!deleted) { - log.warn("사용자 ID: {}의 리프레시 토큰 삭제에 실패했습니다.", userId); - } else { - log.info("사용자 ID: {}의 리프레시 토큰이 성공적으로 삭제되었습니다.", userId); - } - } else { - log.warn("사용자를 찾을 수 없습니다."); - } - - return ResponseEntity.ok(ApiResponse.ok(LOGOUT_SUCCESS)); + @PostMapping("/check/phone-number") + public ResponseEntity> duplicatedPhoneNumberDetails( + @Valid @RequestBody CheckPhoneNumberRequest request + ) { + CheckPhoneNumberResponse response = authService.isRegisteredPhoneNumber(request.getPhoneNumber()); + return ResponseEntity.ok(ApiResponse.ok(PHONE_NUMBER_DUPLICATION_CHECK_SUCCESS, response)); } - @PostMapping("/password-reset/request") - public ResponseEntity> responseResponseEntity(@RequestBody SendingCodeRequest sendingCodeRequest) { - // TODO 서비스로 넘기기 - if (userRepository.existsByNameAndEmail(sendingCodeRequest.name(), sendingCodeRequest.email())) { - String code = mailService.sendAuthCode(sendingCodeRequest.email()); - authCodeService.saveAuthCode(sendingCodeRequest.email(), code); - - return ResponseEntity.ok(ApiResponse.ok(CODE_CREATION_SUCCESS)); + // 3. 토큰 재발급 (Refresh) + @PostMapping("/refresh") + public ResponseEntity refreshAccessToken(@Valid @RequestBody TokenRefreshRequest request) { + try { + AuthService.RefreshResult result = authService.refresh(request.getRefreshToken()); + return ResponseEntity.ok() + .body(ApiResponse.ok( + ACCESS_TOKEN_REFRESH_SUCCESS, + new AccessTokenResponse(result.accessToken(), AuthUserResponse.from(result.user())) + )); + } catch (Exception e) { + log.error("Token refresh failed", e); + throw new AuthException(AuthErrorCode.INVALID_REFRESH_TOKEN); } - throw new AuthException(USER_NOT_FOUND); } - @PostMapping("/password-reset/verify") - public ResponseEntity> verifyCode(@RequestBody CodeVerificationRequest request) { - // TODO 서비스 단 DTO 추가 - boolean verified = authCodeService.verify(request.email(), request.code()); - CodeVerificationResponse response = new CodeVerificationResponse(verified); - - return ResponseEntity.ok(ApiResponse.ok(PASSWORD_RESET_VERIFICATION_SUCCESS, response)); - } - - @PostMapping("/password-reset/confirm") - public ResponseEntity> resetPassword(@RequestBody PasswordResetRequest passwordResetRequest) throws NoSuchAlgorithmException, InvalidKeyException { - // TODO 서비스 단으로 - Optional user = userRepository.findByEmail(passwordResetRequest.email()); - if (user.isEmpty()) { - throw new AuthException(USER_NOT_FOUND); + // 4. 로그아웃 + @PostMapping("/logout") + public ResponseEntity logout(@RequestBody(required = false) TokenRefreshRequest request) { + if (request == null || !StringUtils.hasText(request.getRefreshToken())) { + log.warn("로그아웃 실패: 요청 바디에 리프레시 토큰이 누락되었습니다."); + return ResponseEntity.badRequest() + .body(ApiResponse.error(HttpStatus.BAD_REQUEST.value(), "리프레시 토큰은 필수입니다.", null)); } - - User foundUser = user.get(); - foundUser.updatePassword(passwordResetRequest.password()); - userRepository.save(foundUser); - - return ResponseEntity.ok(ApiResponse.ok(PASSWORD_CHANGE_SUCCESS)); + authService.logout(request.getRefreshToken()); + return ResponseEntity.ok().body(ApiResponse.ok(LOGOUT_SUCCESS)); } - /** - * 요구 권한(role) 이상이면 200, 아니면 403 - * 미인증이면 401 - - * 예) /api/v1/auth/LEAD, /api/v1/auth/ORGANIZER, /api/v1/auth/ADMIN - */ + // 5. 권한 체크 (Role or Team) @GetMapping("/{role}") - public ResponseEntity> checkRoleOrTeam(@AuthenticationPrincipal TokenProvider.CustomUserDetails me, @PathVariable UserRole role, @RequestParam(value = "team", required = false) TeamType requiredTeam) { - // 1) 인증 체크 + public ResponseEntity> checkRoleOrTeam( + @AuthenticationPrincipal TokenProvider.CustomUserDetails me, + @PathVariable UserRole role, + @RequestParam(value = "team", required = false) TeamType requiredTeam + ) { if (me == null) { return ResponseEntity.status(HttpStatus.UNAUTHORIZED) - .body(ApiResponse.error(GlobalErrorCode.UNAUTHORIZED_USER.getStatus() - .value(), GlobalErrorCode.UNAUTHORIZED_USER.getMessage(), null)); + .body(ApiResponse.error( + GlobalErrorCode.UNAUTHORIZED_USER.getStatus().value(), + GlobalErrorCode.UNAUTHORIZED_USER.getMessage(), + null + )); } - - // 2) role check - final boolean roleOk = UserRole.hasAtLeast(me.getRole(), role); - - // 3) team check if team parameter exists - boolean teamOk = false; - if (requiredTeam != null) { - if (UserRole.hasAtLeast(me.getRole(), UserRole.ORGANIZER)) { - teamOk = true; - } else { - teamOk = (me.getTeam() != null && me.getTeam() == requiredTeam); - } - } - - // 4) OR 조건으로 최종 판정 - if (roleOk || teamOk) { + if (authService.hasRequiredAccess(me, role, requiredTeam)) { return ResponseEntity.ok(ApiResponse.ok("ROLE_OR_TEAM_CHECK_PASSED", null)); } return ResponseEntity.status(HttpStatus.FORBIDDEN) - .body(ApiResponse.error(GlobalErrorCode.FORBIDDEN_USER.getStatus() - .value(), GlobalErrorCode.FORBIDDEN_USER.getMessage(), null)); + .body(ApiResponse.error( + GlobalErrorCode.FORBIDDEN_USER.getStatus().value(), + GlobalErrorCode.FORBIDDEN_USER.getMessage(), + null + )); } } diff --git a/src/main/java/inha/gdgoc/domain/auth/controller/message/AuthMessage.java b/src/main/java/inha/gdgoc/domain/auth/controller/message/AuthMessage.java index 6d900266..6a6ef8f3 100644 --- a/src/main/java/inha/gdgoc/domain/auth/controller/message/AuthMessage.java +++ b/src/main/java/inha/gdgoc/domain/auth/controller/message/AuthMessage.java @@ -1,11 +1,15 @@ package inha.gdgoc.domain.auth.controller.message; +import lombok.AccessLevel; +import lombok.NoArgsConstructor; + +@NoArgsConstructor(access = AccessLevel.PRIVATE) public class AuthMessage { - public static final String OAUTH_LOGIN_SIGNUP_SUCCESS = "로그인/회원가입 요청이 성공적으로 실행됐습니다."; - public static final String ACCESS_TOKEN_REFRESH_SUCCESS = "액세스 토큰이 성공적으로 재발급되었습니다."; - public static final String LOGIN_WITH_PASSWORD_SUCCESS = "성공적으로 비밀번호를 사용하여 로그인했습니다."; - public static final String LOGOUT_SUCCESS = "성공적으로 로그아웃했습니다."; - public static final String CODE_CREATION_SUCCESS = "성공적으로 인증 코드를 발급했습니다."; - public static final String PASSWORD_RESET_VERIFICATION_SUCCESS = "성공적으로 비밀번호 변경을 위한 인증 코드 검증이 완료되었습니다."; - public static final String PASSWORD_CHANGE_SUCCESS = "성공적으로 비밀번호를 변경했습니다."; + public static final String LOGIN_SUCCESS = "로그인에 성공하였습니다."; + public static final String SIGNUP_SUCCESS = "회원가입에 성공하였습니다."; + public static final String ACCESS_TOKEN_REFRESH_SUCCESS = "토큰 재발급에 성공하였습니다."; + public static final String LOGOUT_SUCCESS = "로그아웃에 성공하였습니다."; + public static final String OAUTH_LOGIN_SIGNUP_SUCCESS = "OAuth 로그인/회원가입에 성공하였습니다."; + public static final String STUDENT_ID_DUPLICATION_CHECK_SUCCESS = "학번 중복 확인에 성공하였습니다."; + public static final String PHONE_NUMBER_DUPLICATION_CHECK_SUCCESS = "전화번호 중복 확인에 성공하였습니다."; } diff --git a/src/main/java/inha/gdgoc/domain/auth/dto/GoogleUserInfo.java b/src/main/java/inha/gdgoc/domain/auth/dto/GoogleUserInfo.java new file mode 100644 index 00000000..04e930ac --- /dev/null +++ b/src/main/java/inha/gdgoc/domain/auth/dto/GoogleUserInfo.java @@ -0,0 +1,17 @@ +package inha.gdgoc.domain.auth.dto; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; + +@Data +@Builder +@AllArgsConstructor +public class GoogleUserInfo { + private String sub; + private String email; + private String name; + private String givenName; + private String familyName; + private String picture; +} diff --git a/src/main/java/inha/gdgoc/domain/recruit/dto/request/CheckPhoneNumberRequest.java b/src/main/java/inha/gdgoc/domain/auth/dto/request/CheckPhoneNumberRequest.java similarity index 62% rename from src/main/java/inha/gdgoc/domain/recruit/dto/request/CheckPhoneNumberRequest.java rename to src/main/java/inha/gdgoc/domain/auth/dto/request/CheckPhoneNumberRequest.java index a448edd7..8cc0a1e8 100644 --- a/src/main/java/inha/gdgoc/domain/recruit/dto/request/CheckPhoneNumberRequest.java +++ b/src/main/java/inha/gdgoc/domain/auth/dto/request/CheckPhoneNumberRequest.java @@ -1,4 +1,4 @@ -package inha.gdgoc.domain.recruit.dto.request; +package inha.gdgoc.domain.auth.dto.request; import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.Pattern; @@ -8,7 +8,8 @@ @Getter @Setter public class CheckPhoneNumberRequest { + @NotBlank(message = "전화번호는 필수 입력 값입니다.") - @Pattern(regexp = "^010-\\d{4}-\\d{4}$", message = "전화번호 형식은 010-XXXX-XXXX 이어야 합니다.") + @Pattern(regexp = "^010-?\\d{4}-?\\d{4}$", message = "전화번호 형식은 010-XXXX-XXXX 또는 010XXXXXXXX 이어야 합니다.") private String phoneNumber; } diff --git a/src/main/java/inha/gdgoc/domain/auth/dto/request/CheckStudentIdRequest.java b/src/main/java/inha/gdgoc/domain/auth/dto/request/CheckStudentIdRequest.java new file mode 100644 index 00000000..afea5191 --- /dev/null +++ b/src/main/java/inha/gdgoc/domain/auth/dto/request/CheckStudentIdRequest.java @@ -0,0 +1,15 @@ +package inha.gdgoc.domain.auth.dto.request; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Pattern; +import lombok.Getter; +import lombok.Setter; + +@Getter +@Setter +public class CheckStudentIdRequest { + + @NotBlank(message = "학번은 필수 입력 값입니다.") + @Pattern(regexp = "^12[0-9]{6}$", message = "유효하지 않은 학번 값입니다.") + private String studentId; +} diff --git a/src/main/java/inha/gdgoc/domain/auth/dto/request/LoginRequest.java b/src/main/java/inha/gdgoc/domain/auth/dto/request/LoginRequest.java new file mode 100644 index 00000000..750b0408 --- /dev/null +++ b/src/main/java/inha/gdgoc/domain/auth/dto/request/LoginRequest.java @@ -0,0 +1,10 @@ +package inha.gdgoc.domain.auth.dto.request; + +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@NoArgsConstructor +public class LoginRequest { + private String idToken; +} \ No newline at end of file diff --git a/src/main/java/inha/gdgoc/domain/auth/dto/request/PasswordResetRequest.java b/src/main/java/inha/gdgoc/domain/auth/dto/request/PasswordResetRequest.java deleted file mode 100644 index 8efe10f0..00000000 --- a/src/main/java/inha/gdgoc/domain/auth/dto/request/PasswordResetRequest.java +++ /dev/null @@ -1,4 +0,0 @@ -package inha.gdgoc.domain.auth.dto.request; - -public record PasswordResetRequest(String email, String password) { -} diff --git a/src/main/java/inha/gdgoc/domain/auth/dto/request/SignupRequest.java b/src/main/java/inha/gdgoc/domain/auth/dto/request/SignupRequest.java new file mode 100644 index 00000000..01fc5f90 --- /dev/null +++ b/src/main/java/inha/gdgoc/domain/auth/dto/request/SignupRequest.java @@ -0,0 +1,23 @@ +package inha.gdgoc.domain.auth.dto.request; + +import jakarta.validation.constraints.NotBlank; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@NoArgsConstructor +public class SignupRequest { + @NotBlank + private String oauthSubject; + @NotBlank + private String email; + @NotBlank + private String name; + @NotBlank + private String studentId; + @NotBlank + private String phoneNumber; + @NotBlank + private String major; + private String image; +} \ No newline at end of file diff --git a/src/main/java/inha/gdgoc/domain/auth/dto/request/TokenRefreshRequest.java b/src/main/java/inha/gdgoc/domain/auth/dto/request/TokenRefreshRequest.java new file mode 100644 index 00000000..f0b3286d --- /dev/null +++ b/src/main/java/inha/gdgoc/domain/auth/dto/request/TokenRefreshRequest.java @@ -0,0 +1,12 @@ +package inha.gdgoc.domain.auth.dto.request; + +import jakarta.validation.constraints.NotBlank; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@NoArgsConstructor +public class TokenRefreshRequest { + @NotBlank + private String refreshToken; +} diff --git a/src/main/java/inha/gdgoc/domain/auth/dto/request/UserLoginRequest.java b/src/main/java/inha/gdgoc/domain/auth/dto/request/UserLoginRequest.java deleted file mode 100644 index b7f95638..00000000 --- a/src/main/java/inha/gdgoc/domain/auth/dto/request/UserLoginRequest.java +++ /dev/null @@ -1,4 +0,0 @@ -package inha.gdgoc.domain.auth.dto.request; - -public record UserLoginRequest(String email, String password) { -} diff --git a/src/main/java/inha/gdgoc/domain/auth/dto/response/AccessTokenResponse.java b/src/main/java/inha/gdgoc/domain/auth/dto/response/AccessTokenResponse.java index f8cc949d..b445b765 100644 --- a/src/main/java/inha/gdgoc/domain/auth/dto/response/AccessTokenResponse.java +++ b/src/main/java/inha/gdgoc/domain/auth/dto/response/AccessTokenResponse.java @@ -5,9 +5,11 @@ @Getter public class AccessTokenResponse extends BaseEntity { - private final String access_token; + private final String accessToken; + private final AuthUserResponse user; - public AccessTokenResponse(String accessToken) { - this.access_token = accessToken; + public AccessTokenResponse(String accessToken, AuthUserResponse user) { + this.accessToken = accessToken; + this.user = user; } } diff --git a/src/main/java/inha/gdgoc/domain/auth/dto/response/AuthUserResponse.java b/src/main/java/inha/gdgoc/domain/auth/dto/response/AuthUserResponse.java new file mode 100644 index 00000000..0349293b --- /dev/null +++ b/src/main/java/inha/gdgoc/domain/auth/dto/response/AuthUserResponse.java @@ -0,0 +1,34 @@ +package inha.gdgoc.domain.auth.dto.response; + +import inha.gdgoc.domain.user.entity.User; +import inha.gdgoc.domain.user.enums.TeamType; +import inha.gdgoc.domain.user.enums.UserRole; +import lombok.Builder; +import lombok.Data; + +@Data +@Builder +public class AuthUserResponse { + private Long id; + private String name; + private String email; + private UserRole userRole; + private TeamType team; + private User.MembershipStatus membershipStatus; + private String image; + + public static AuthUserResponse from(User user) { + if (user == null) { + return null; + } + return AuthUserResponse.builder() + .id(user.getId()) + .name(user.getName()) + .email(user.getEmail()) + .userRole(user.getUserRole()) + .team(user.getTeam()) + .membershipStatus(user.getMembershipStatus()) + .image(user.getImage()) + .build(); + } +} diff --git a/src/main/java/inha/gdgoc/domain/recruit/dto/response/CheckPhoneNumberResponse.java b/src/main/java/inha/gdgoc/domain/auth/dto/response/CheckPhoneNumberResponse.java similarity index 55% rename from src/main/java/inha/gdgoc/domain/recruit/dto/response/CheckPhoneNumberResponse.java rename to src/main/java/inha/gdgoc/domain/auth/dto/response/CheckPhoneNumberResponse.java index 759f42f1..9d1612d8 100644 --- a/src/main/java/inha/gdgoc/domain/recruit/dto/response/CheckPhoneNumberResponse.java +++ b/src/main/java/inha/gdgoc/domain/auth/dto/response/CheckPhoneNumberResponse.java @@ -1,5 +1,4 @@ -package inha.gdgoc.domain.recruit.dto.response; +package inha.gdgoc.domain.auth.dto.response; public record CheckPhoneNumberResponse(boolean isExists) { - } diff --git a/src/main/java/inha/gdgoc/domain/recruit/dto/response/CheckStudentIdResponse.java b/src/main/java/inha/gdgoc/domain/auth/dto/response/CheckStudentIdResponse.java similarity index 55% rename from src/main/java/inha/gdgoc/domain/recruit/dto/response/CheckStudentIdResponse.java rename to src/main/java/inha/gdgoc/domain/auth/dto/response/CheckStudentIdResponse.java index 8537486b..6c88a262 100644 --- a/src/main/java/inha/gdgoc/domain/recruit/dto/response/CheckStudentIdResponse.java +++ b/src/main/java/inha/gdgoc/domain/auth/dto/response/CheckStudentIdResponse.java @@ -1,5 +1,4 @@ -package inha.gdgoc.domain.recruit.dto.response; +package inha.gdgoc.domain.auth.dto.response; public record CheckStudentIdResponse(boolean isExists) { - } diff --git a/src/main/java/inha/gdgoc/domain/auth/dto/response/LoginSuccessResponse.java b/src/main/java/inha/gdgoc/domain/auth/dto/response/LoginSuccessResponse.java new file mode 100644 index 00000000..f6da3d6a --- /dev/null +++ b/src/main/java/inha/gdgoc/domain/auth/dto/response/LoginSuccessResponse.java @@ -0,0 +1,25 @@ +package inha.gdgoc.domain.auth.dto.response; + +import com.fasterxml.jackson.annotation.JsonIgnore; +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.Builder; +import lombok.Data; + +@Data +@Builder +public class LoginSuccessResponse { + @JsonProperty("isNewUser") + private boolean isNewUser; + private String accessToken; + private AuthUserResponse user; + private String refreshToken; + + public static LoginSuccessResponse of(TokenDto tokens, AuthUserResponse user) { + return LoginSuccessResponse.builder() + .isNewUser(false) + .accessToken(tokens.getAccessToken()) + .refreshToken(tokens.getRefreshToken()) + .user(user) + .build(); + } +} diff --git a/src/main/java/inha/gdgoc/domain/auth/dto/response/SignupNeededResponse.java b/src/main/java/inha/gdgoc/domain/auth/dto/response/SignupNeededResponse.java new file mode 100644 index 00000000..60bcb7e2 --- /dev/null +++ b/src/main/java/inha/gdgoc/domain/auth/dto/response/SignupNeededResponse.java @@ -0,0 +1,16 @@ +package inha.gdgoc.domain.auth.dto.response; + +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.Builder; +import lombok.Data; + +@Data +@Builder +public class SignupNeededResponse { + @JsonProperty("isNewUser") + private boolean isNewUser; + private String oauthSubject; + private String email; + private String name; + private String picture; +} diff --git a/src/main/java/inha/gdgoc/domain/auth/dto/response/TokenDto.java b/src/main/java/inha/gdgoc/domain/auth/dto/response/TokenDto.java new file mode 100644 index 00000000..5cc9754f --- /dev/null +++ b/src/main/java/inha/gdgoc/domain/auth/dto/response/TokenDto.java @@ -0,0 +1,13 @@ +package inha.gdgoc.domain.auth.dto.response; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; + +@Data +@Builder +@AllArgsConstructor +public class TokenDto { + private String accessToken; + private String refreshToken; +} \ No newline at end of file diff --git a/src/main/java/inha/gdgoc/domain/auth/exception/AuthErrorCode.java b/src/main/java/inha/gdgoc/domain/auth/exception/AuthErrorCode.java index f054597c..ab8817f0 100644 --- a/src/main/java/inha/gdgoc/domain/auth/exception/AuthErrorCode.java +++ b/src/main/java/inha/gdgoc/domain/auth/exception/AuthErrorCode.java @@ -12,6 +12,8 @@ public enum AuthErrorCode implements ErrorCode { INVALID_COOKIE(HttpStatus.FORBIDDEN, "Refresh Token 이 비어있습니다."), INVALID_REFRESH_TOKEN(HttpStatus.FORBIDDEN, "잘못된 Refresh Token 값입니다."), + INVALID_TOKEN(HttpStatus.UNAUTHORIZED, "유효하지 않은 토큰입니다."), + // 404 Not Found USER_NOT_FOUND(HttpStatus.NOT_FOUND, "사용자를 찾을 수 없습니다"); 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 a8aa0842..3cdded81 100644 --- a/src/main/java/inha/gdgoc/domain/auth/service/AuthService.java +++ b/src/main/java/inha/gdgoc/domain/auth/service/AuthService.java @@ -1,138 +1,316 @@ package inha.gdgoc.domain.auth.service; -import inha.gdgoc.domain.auth.dto.response.LoginResponse; -import inha.gdgoc.domain.auth.enums.LoginType; +import com.google.api.client.googleapis.auth.oauth2.GoogleIdToken; +import com.google.api.client.googleapis.auth.oauth2.GoogleIdTokenVerifier; +import com.google.api.client.http.javanet.NetHttpTransport; +import com.google.api.client.json.gson.GsonFactory; +import inha.gdgoc.domain.auth.dto.GoogleUserInfo; +import inha.gdgoc.domain.auth.dto.request.SignupRequest; +import inha.gdgoc.domain.auth.dto.response.AuthUserResponse; +import inha.gdgoc.domain.auth.dto.response.CheckPhoneNumberResponse; +import inha.gdgoc.domain.auth.dto.response.CheckStudentIdResponse; +import inha.gdgoc.domain.auth.dto.response.LoginSuccessResponse; +import inha.gdgoc.domain.auth.dto.response.SignupNeededResponse; +import inha.gdgoc.domain.auth.dto.response.TokenDto; import inha.gdgoc.domain.user.entity.User; +import inha.gdgoc.domain.user.enums.TeamType; +import inha.gdgoc.domain.user.enums.UserRole; import inha.gdgoc.domain.user.repository.UserRepository; import inha.gdgoc.global.config.jwt.TokenProvider; -import jakarta.servlet.http.HttpServletResponse; +import inha.gdgoc.global.config.jwt.TokenProvider.CustomUserDetails; +import inha.gdgoc.global.security.AccessGuard; +import java.io.IOException; +import java.security.GeneralSecurityException; +import java.time.Duration; +import java.util.Collections; +import java.util.UUID; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Value; +import org.springframework.data.redis.core.StringRedisTemplate; import org.springframework.http.*; import org.springframework.security.core.Authentication; import org.springframework.stereotype.Service; -import org.springframework.util.LinkedMultiValueMap; -import org.springframework.util.MultiValueMap; -import org.springframework.web.client.RestTemplate; - -import java.security.InvalidKeyException; -import java.security.NoSuchAlgorithmException; -import java.time.Duration; -import java.util.Map; -import java.util.Optional; - -import static inha.gdgoc.global.util.EncryptUtil.encrypt; +import org.springframework.transaction.annotation.Transactional; @Slf4j @Service @RequiredArgsConstructor public class AuthService { - private final RefreshTokenService refreshTokenService; - private final UserRepository userRepository; - private final RestTemplate restTemplate = new RestTemplate(); - private final TokenProvider tokenProvider; + public static final Duration REFRESH_TOKEN_TTL = Duration.ofDays(14); + private static final String REFRESH_TOKEN_PREFIX = "RT:"; + private static final String SESSION_VALUE_DELIMITER = "::"; - @Value("${google.client-id}") - private String clientId; + private final UserRepository userRepository; + private final TokenProvider tokenProvider; + private final StringRedisTemplate redisTemplate; + private final AccessGuard accessGuard; - @Value("${google.client-secret}") - private String clientSecret; + @Value("${google.client-id}") + private String googleClientId; - @Value("${google.redirect-uri}") - private String redirectUri; + // 로그인 + @Transactional + public Object login(String idToken) { + log.info("로그인 시도 - ID Token 존재 여부: {}", (idToken != null && !idToken.isBlank())); + // Google ID Token 검증 + GoogleUserInfo googleUser = verifyGoogleToken(idToken); + log.info("Google 토큰 검증 성공 - Email: {}, Sub: {}", googleUser.getEmail(), googleUser.getSub()); + + // 도메인 검증 (인하대 메일만 허용) + if (!googleUser.getEmail().endsWith("@inha.edu")) { + log.warn("허용되지 않은 도메인 로그인 시도: {}", googleUser.getEmail()); + throw new IllegalArgumentException("인하대학교(@inha.edu) 계정만 이용 가능합니다."); + } + + // DB에서 유저 조회 (OAuth Subject 기준) + User user = userRepository.findByOauthSubject(googleUser.getSub()).orElse(null); + + // 신규 유저 -> 회원가입 필요 응답 (202 or 200 with isNewUser=true) + if (user == null) { + log.info("신규 유저 감지 - Email: {}", googleUser.getEmail()); + String preferredName = + hasText(googleUser.getFamilyName()) ? googleUser.getFamilyName() : googleUser.getName(); + return SignupNeededResponse.builder() + .isNewUser(true) + .oauthSubject(googleUser.getSub()) + .email(googleUser.getEmail()) + .name(preferredName) + .picture(googleUser.getPicture()) + .build(); + } + + log.info("기존 유저 로그인 - UserID: {}, Email: {}", user.getId(), user.getEmail()); + // 기존 유저 -> 토큰 발급 및 로그인 성공 응답 + TokenDto tokens = generateTokens(user); + return LoginSuccessResponse.of(tokens, AuthUserResponse.from(user)); + } + // 회원가입 + @Transactional + public LoginSuccessResponse signup(SignupRequest request) { + // 학번 중복 체크 + if (userRepository.existsByStudentId(request.getStudentId())) { + throw new IllegalArgumentException("이미 존재하는 학번입니다."); + } - public Map processOAuthLogin(String code, HttpServletResponse response) { - // 1. code → access token 요청 - HttpHeaders headers = new HttpHeaders(); - headers.setContentType(MediaType.APPLICATION_FORM_URLENCODED); + // 전화번호 정규화 (숫자만 남김) + String cleanPhone = request.getPhoneNumber().replaceAll("[^0-9]", ""); + if (userRepository.existsByPhoneNumber(cleanPhone)) { + throw new IllegalArgumentException("이미 존재하는 전화번호입니다."); + } + + // 유저 엔티티 생성 및 저장 + User newUser = + User.builder() + .oauthSubject(request.getOauthSubject()) // 구글 sub + .email(request.getEmail()) + .name(request.getName()) + .studentId(request.getStudentId()) + .major(request.getMajor()) + .phoneNumber(cleanPhone) + .image(request.getImage()) + // Role(GUEST), Status(PENDING) 등은 User 엔티티 생성자에서 기본값 처리됨 + .build(); - MultiValueMap params = new LinkedMultiValueMap<>(); - params.add("code", code); - params.add("client_id", clientId); - params.add("client_secret", clientSecret); - params.add("redirect_uri", redirectUri); - params.add("grant_type", "authorization_code"); + userRepository.save(newUser); - HttpEntity> tokenRequest = new HttpEntity<>(params, headers); - ResponseEntity tokenResponse = restTemplate.postForEntity("https://oauth2.googleapis.com/token", tokenRequest, Map.class); + // 토큰 발급 + TokenDto tokens = generateTokens(newUser); + return LoginSuccessResponse.of(tokens, AuthUserResponse.from(newUser)); + } - String googleAccessToken = (String) tokenResponse.getBody().get("access_token"); + @Transactional(readOnly = true) + public CheckStudentIdResponse isRegisteredStudentId(String studentId) { + boolean exists = userRepository.existsByStudentId(studentId); + return new CheckStudentIdResponse(exists); + } - // 2. access token → 사용자 정보 요청 - HttpHeaders userInfoHeaders = new HttpHeaders(); - userInfoHeaders.setBearerAuth(googleAccessToken); - HttpEntity userInfoRequest = new HttpEntity<>(userInfoHeaders); + @Transactional(readOnly = true) + public CheckPhoneNumberResponse isRegisteredPhoneNumber(String phoneNumber) { + String cleanPhone = phoneNumber.replaceAll("[^0-9]", ""); + boolean exists = userRepository.existsByPhoneNumber(cleanPhone); + return new CheckPhoneNumberResponse(exists); + } - ResponseEntity userInfoResponse = restTemplate.exchange("https://www.googleapis.com/oauth2/v2/userinfo", HttpMethod.GET, userInfoRequest, Map.class); + public boolean hasRequiredAccess(CustomUserDetails me, UserRole role, TeamType requiredTeam) { + var conditions = new java.util.ArrayList(); + conditions.add(AccessGuard.AccessCondition.atLeast(role)); - // 3. Google에서 가져온 이름, 이메일로 가입된 정보가 없으면 회원가입, 있으면 로그인 - Map userInfo = userInfoResponse.getBody(); - String email = (String) userInfo.get("email"); - String name = (String) userInfo.get("name"); + if (requiredTeam != null) { + conditions.add(AccessGuard.AccessCondition.atLeast(UserRole.ORGANIZER)); + conditions.add(AccessGuard.AccessCondition.of(UserRole.GUEST, requiredTeam)); + } - Optional foundUser = userRepository.findByEmail(email); - if (foundUser.isEmpty()) { - return Map.of("isExists", false, "email", email, "name", name); - } + return accessGuard.check(me, conditions.toArray(AccessGuard.AccessCondition[]::new)); + } - User user = foundUser.get(); + public RefreshResult refresh(String refreshToken) { + RefreshSession session = resolveRefreshSession(refreshToken); - String jwtAccessToken = tokenProvider.generateGoogleLoginToken(user, Duration.ofHours(1)); - String refreshToken = refreshTokenService.getOrCreateRefreshToken(user, Duration.ofDays(1), LoginType.GOOGLE_LOGIN); + User user = + userRepository + .findById(session.userId()) + .orElseThrow(() -> new IllegalArgumentException("존재하지 않는 사용자입니다.")); - ResponseCookie refreshCookie = ResponseCookie.from("refresh_token", refreshToken) - .httpOnly(true) - .secure(true) - .sameSite("None") - .domain(".gdgocinha.com") - .path("/") - .maxAge(Duration.ofDays(1)) - .build(); + // Access Token만 새로 발급 (Refresh Token은 그대로 유지하거나, 정책에 따라 재발급 가능) + String accessToken = tokenProvider.createAccessToken(user, session.sessionId()); + return new RefreshResult(accessToken, user); + } - // Set-Cookie 헤더로 추가 - log.info("Response Cookie에 저장된 Refresh Token: {}", refreshCookie); - response.addHeader(HttpHeaders.SET_COOKIE, refreshCookie.toString()); + // 로그아웃 + public void logout(String refreshToken) { + // Redis에서 Refresh Token 삭제 + String redisKey = refreshTokenKey(refreshToken); + redisTemplate.delete(redisKey); + } - return Map.of("isExists", true, "access_token", jwtAccessToken); + public Long getAuthenticationUserId(Authentication authentication) { + Object principal = authentication.getPrincipal(); + if (principal instanceof TokenProvider.CustomUserDetails user) { + return user.getUserId(); } + throw new IllegalArgumentException("User ID not found in authentication"); + } + + // 토큰 발급 및 Redis 저장 + + private TokenDto generateTokens(User user) { + String sessionId = UUID.randomUUID().toString(); + // Access Token 생성 (JWT) + String accessToken = tokenProvider.createAccessToken(user, sessionId); - public LoginResponse loginWithPassword(String email, String password, HttpServletResponse response) throws NoSuchAlgorithmException, InvalidKeyException { - Optional user = userRepository.findByEmail(email); - if (user.isEmpty()) { - return new LoginResponse(false, null); - } - - User foundUser = user.get(); - String hashedInputPassword = encrypt(password, foundUser.getSalt()); - if (!foundUser.getPassword().equals(hashedInputPassword)) { - return new LoginResponse(false, null); - } - - String accessToken = tokenProvider.generateSelfSignupToken(foundUser, Duration.ofHours(1)); - String refreshToken = refreshTokenService.getOrCreateRefreshToken(foundUser, Duration.ofDays(1), LoginType.SELF_SIGNUP); - - ResponseCookie refreshCookie = ResponseCookie.from("refresh_token", refreshToken) - .httpOnly(true) - .secure(true) - .sameSite("None") - .path("/") - .maxAge(Duration.ofDays(1)) - .build(); - - log.info("Response Cookie에 저장된 Refresh Token: {}", refreshCookie); - response.addHeader(HttpHeaders.SET_COOKIE, refreshCookie.toString()); - - return new LoginResponse(true, accessToken); + // Refresh Token 생성 (Random UUID) + String refreshToken = tokenProvider.createRefreshToken(); + + storeRefreshSession(refreshToken, new RefreshSession(sessionId, user.getId())); + + return new TokenDto(accessToken, refreshToken); + } + + // Google ID Token 검증 + private GoogleUserInfo verifyGoogleToken(String idTokenString) { + try { + GoogleIdTokenVerifier verifier = + new GoogleIdTokenVerifier.Builder(new NetHttpTransport(), new GsonFactory()) + .setAudience(Collections.singletonList(googleClientId)) + .setIssuers(java.util.Arrays.asList("https://accounts.google.com", "accounts.google.com")) + .build(); + + GoogleIdToken idToken = verifier.verify(idTokenString); + + if (idToken != null) { + GoogleIdToken.Payload payload = idToken.getPayload(); + return buildGoogleUserInfo(payload); + } else { + throw new IllegalArgumentException("유효하지 않은 토큰입니다."); + } + } catch (GeneralSecurityException | IOException e) { + log.error("Google Token Verification Failed", e); + throw new IllegalArgumentException("토큰 검증 실패", e); } + } - public Long getAuthenticationUserId(Authentication authentication) { - Object principal = authentication.getPrincipal(); + private void storeRefreshSession(String refreshToken, RefreshSession session) { + redisTemplate + .opsForValue() + .set(refreshTokenKey(refreshToken), encodeSessionValue(session), REFRESH_TOKEN_TTL); + } - if (principal instanceof TokenProvider.CustomUserDetails user) { - return user.getUserId(); - } - throw new IllegalArgumentException("user Id is null"); + private RefreshSession resolveRefreshSession(String refreshToken) { + String redisKey = refreshTokenKey(refreshToken); + String storedValue = redisTemplate.opsForValue().get(redisKey); + + if (storedValue == null) { + throw new IllegalArgumentException("유효하지 않거나 만료된 리프레시 토큰입니다."); + } + + if (storedValue.contains(SESSION_VALUE_DELIMITER)) { + return decodeSessionValue(storedValue); } + + // 레거시 포맷(oauthSubject만 저장) 호환 처리 + User user = + userRepository + .findByOauthSubject(storedValue) + .orElseThrow(() -> new IllegalArgumentException("존재하지 않는 사용자입니다.")); + + RefreshSession upgraded = new RefreshSession(UUID.randomUUID().toString(), user.getId()); + storeRefreshSession(refreshToken, upgraded); + return upgraded; + } + + private RefreshSession decodeSessionValue(String storedValue) { + String[] parts = storedValue.split(SESSION_VALUE_DELIMITER, 2); + if (parts.length != 2) { + throw new IllegalArgumentException("잘못된 세션 정보입니다."); + } + try { + Long userId = Long.parseLong(parts[1]); + return new RefreshSession(parts[0], userId); + } catch (NumberFormatException e) { + throw new IllegalArgumentException("잘못된 세션 정보입니다.", e); + } + } + + private String encodeSessionValue(RefreshSession session) { + return session.sessionId() + SESSION_VALUE_DELIMITER + session.userId(); + } + + private String refreshTokenKey(String refreshToken) { + return REFRESH_TOKEN_PREFIX + refreshToken; + } + + private record RefreshSession(String sessionId, Long userId) {} + + public record RefreshResult(String accessToken, User user) {} + + private GoogleUserInfo buildGoogleUserInfo(GoogleIdToken.Payload payload) { + String fullName = (String) payload.get("name"); + String givenName = (String) payload.get("given_name"); + String familyName = (String) payload.get("family_name"); + + NameParts parts = deriveNameParts(fullName); + + String resolvedGiven = hasText(givenName) ? givenName : parts.givenName(); + String resolvedFamily = hasText(familyName) ? familyName : parts.familyName(); + + return GoogleUserInfo.builder() + .sub(payload.getSubject()) + .email(payload.getEmail()) + .name(fullName) + .givenName(resolvedGiven) + .familyName(resolvedFamily) + .picture((String) payload.get("picture")) + .build(); + } + + private NameParts deriveNameParts(String rawName) { + if (!hasText(rawName)) { + return new NameParts("", ""); + } + String trimmed = rawName.trim(); + + if (trimmed.contains(" ")) { + String[] tokens = trimmed.split("\\s+"); + if (tokens.length == 1) { + return new NameParts("", tokens[0]); + } + String given = tokens[tokens.length - 1]; + String family = String.join(" ", java.util.Arrays.copyOf(tokens, tokens.length - 1)).trim(); + return new NameParts(family, given); + } + + if (trimmed.length() >= 2) { + return new NameParts(trimmed.substring(0, 1), trimmed.substring(1)); + } + + return new NameParts(trimmed, ""); + } + + private boolean hasText(String value) { + return value != null && !value.trim().isEmpty(); + } + + private record NameParts(String familyName, String givenName) {} } diff --git a/src/main/java/inha/gdgoc/domain/auth/service/RefreshTokenService.java b/src/main/java/inha/gdgoc/domain/auth/service/RefreshTokenService.java deleted file mode 100644 index df4297d2..00000000 --- a/src/main/java/inha/gdgoc/domain/auth/service/RefreshTokenService.java +++ /dev/null @@ -1,146 +0,0 @@ -package inha.gdgoc.domain.auth.service; - -import inha.gdgoc.global.config.jwt.TokenProvider; -import inha.gdgoc.domain.auth.entity.RefreshToken; -import inha.gdgoc.domain.auth.enums.LoginType; -import inha.gdgoc.domain.auth.repository.RefreshTokenRepository; -import inha.gdgoc.domain.user.entity.User; -import inha.gdgoc.domain.user.repository.UserRepository; -import io.jsonwebtoken.Claims; -import jakarta.transaction.Transactional; -import java.time.Duration; -import java.time.LocalDateTime; -import java.util.Optional; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.springframework.stereotype.Service; - -@Slf4j -@Service -@RequiredArgsConstructor -@Transactional -public class RefreshTokenService { - - private final UserRepository userRepository; - private final TokenProvider tokenProvider; - private final RefreshTokenRepository refreshTokenRepository; - - @Transactional - public String getOrCreateRefreshToken(User user, Duration duration, LoginType loginType) { - Optional existingToken = refreshTokenRepository.findByUser(user); - - // 1. 유효한 토큰이 있으면 재사용 - if (existingToken.isPresent()) { - RefreshToken refreshToken = existingToken.get(); - - // 로컬 시간 기준으로 만료 시간 체크 - if (refreshToken.getExpiryDate().isAfter(LocalDateTime.now())) { - log.info("유효한 Refresh Token이 존재합니다. 재사용합니다: {}", refreshToken.getToken()); - return refreshToken.getToken(); - } - } - - // 2. 없거나 만료되었으면 새로 생성 - String newToken = tokenProvider.generateRefreshToken(user, duration, loginType); - log.info("새로운 Refresh Token 생성됨: {}", newToken); - - // 3. 토큰 저장 (Private 메서드 활용) - saveRefreshToken(newToken, user, duration); - - return newToken; - } - - @Transactional - public String refreshAccessToken(String refreshToken) { - log.info("리프레시 토큰 서비스 호출됨. 토큰: {}", refreshToken); - - // 1. JWT 파싱하여 이메일 추출 - Claims claims = tokenProvider.validToken(refreshToken); - if (claims == null) { - throw new RuntimeException("유효하지 않은 리프레시 토큰입니다."); - } - - String email = claims.getSubject(); - Optional optionalUser = userRepository.findByEmail(email); - - if (optionalUser.isEmpty()) { - throw new RuntimeException("해당 이메일로 등록된 유저를 찾을 수 없습니다."); - } - - User user = optionalUser.get(); - - // 2. DB에서 RefreshToken 조회 - RefreshToken storedToken = refreshTokenRepository.findByUser(user) - .orElseThrow(() -> new RuntimeException("DB에 저장된 리프레시 토큰이 없습니다.")); - - // 만료 시간 체크 (로컬 시간 기준) - if (storedToken.getExpiryDate().isBefore(LocalDateTime.now())) { - throw new RuntimeException("리프레시 토큰이 만료되었습니다."); - } - - if (!storedToken.getToken().equals(refreshToken)) { - log.info("DB에 저장된 토큰: {}", storedToken.getToken()); - throw new RuntimeException("리프레시 토큰이 일치하지 않습니다."); - } - - // 3. AccessToken 새로 발급 - String loginTypeStr = claims.get("loginType", String.class); - LoginType loginType = LoginType.valueOf(loginTypeStr); - - return (loginType == LoginType.SELF_SIGNUP) - ? tokenProvider.generateSelfSignupToken(user, Duration.ofHours(1)) - : tokenProvider.generateGoogleLoginToken(user, Duration.ofHours(1)); - } - - @Transactional - public boolean logout(Long userId) { - try { - Optional tokenEntity = refreshTokenRepository.findByUserId(userId); - if (tokenEntity.isPresent()) { - log.info("사용자 ID: {}에 대한 토큰을 DB에서 찾았습니다. 토큰 ID: {}", userId, tokenEntity.get().getId()); - } else { - log.warn("사용자 ID: {}에 대한 토큰이 DB에 존재하지 않습니다.", userId); - return false; - } - - // 토큰 삭제 실행 및 삭제된 행 수 확인 - refreshTokenRepository.deleteByUserId(userId); - log.info("사용자 ID: {} 로그아웃 처리", userId); - return true; - } catch (Exception e) { - log.error("사용자 ID: {} 로그아웃 중 오류 발생: {}", userId, e.getMessage(), e); - return false; - } - } - - private void saveRefreshToken(String refreshToken, User user, Duration expiredAt) { - // 1. 만료 시간 로컬 시간으로 설정 (KST) - LocalDateTime expiryDate = LocalDateTime.now().plus(expiredAt); - - // 2. 기존 토큰이 있는지 조회 - Optional existingToken = refreshTokenRepository.findByUser(user); - - if (existingToken.isPresent()) { - RefreshToken tokenEntity = existingToken.get(); - log.info("Before update: {}", tokenEntity.getToken()); - - // 기존 엔티티 업데이트 - tokenEntity.update(refreshToken, expiryDate); - - log.info("After update: {}", tokenEntity.getToken()); - refreshTokenRepository.save(tokenEntity); - return; - } - - // 3. 없으면 새로운 엔티티 생성 - RefreshToken tokenEntity = RefreshToken.builder() - .token(refreshToken) - .user(user) - .expiryDate(expiryDate) - .build(); - - log.info("새로운 Refresh Token 생성: {}", tokenEntity.getToken()); - - refreshTokenRepository.save(tokenEntity); - } -} diff --git a/src/main/java/inha/gdgoc/domain/core/attendance/controller/CoreAttendanceController.java b/src/main/java/inha/gdgoc/domain/core/attendance/controller/CoreAttendanceController.java index 552ce67d..aee3fe5d 100644 --- a/src/main/java/inha/gdgoc/domain/core/attendance/controller/CoreAttendanceController.java +++ b/src/main/java/inha/gdgoc/domain/core/attendance/controller/CoreAttendanceController.java @@ -8,7 +8,6 @@ import inha.gdgoc.domain.core.attendance.dto.response.TeamResponse; import inha.gdgoc.domain.core.attendance.service.CoreAttendanceService; import inha.gdgoc.domain.user.enums.TeamType; -import inha.gdgoc.domain.user.enums.UserRole; import inha.gdgoc.global.config.jwt.TokenProvider.CustomUserDetails; import inha.gdgoc.global.dto.response.ApiResponse; import inha.gdgoc.global.dto.response.PageMeta; @@ -30,16 +29,19 @@ @RestController @RequestMapping("/api/v1/core-attendance/meetings") @RequiredArgsConstructor -@PreAuthorize("hasAnyRole('LEAD','ORGANIZER','ADMIN')") +@PreAuthorize(CoreAttendanceController.LEAD_OR_HIGHER_RULE) public class CoreAttendanceController { - private final CoreAttendanceService service; + public static final String LEAD_OR_HIGHER_RULE = + "@accessGuard.check(authentication," + + " T(inha.gdgoc.global.security.AccessGuard$AccessCondition).atLeast(" + + "T(inha.gdgoc.domain.user.enums.UserRole).LEAD))"; + public static final String ORGANIZER_OR_HIGHER_RULE = + "@accessGuard.check(authentication," + + " T(inha.gdgoc.global.security.AccessGuard$AccessCondition).atLeast(" + + "T(inha.gdgoc.domain.user.enums.UserRole).ORGANIZER))"; - /* ===== helpers ===== */ - private static TeamType requiredTeamFrom(CustomUserDetails me) { - if (me.getTeam() == null) throw new IllegalArgumentException("LEAD 권한 토큰에 team 정보가 없습니다."); - return me.getTeam(); - } + private final CoreAttendanceService service; private static ResponseEntity, Void>> okUpdated(long updated, List ignored) { return ResponseEntity.ok(ApiResponse.ok(CoreAttendanceMessage.ATTENDANCE_ALL_SET_SUCCESS, Map.of("updated", updated, "ignoredUserIds", ignored))); @@ -51,14 +53,14 @@ public ResponseEntity> listDates() { return ResponseEntity.ok(ApiResponse.ok(CoreAttendanceMessage.DATE_LIST_RETRIEVED_SUCCESS, new DateListResponse(service.getDates()))); } - @PreAuthorize("hasAnyRole('ORGANIZER', 'ADMIN')") + @PreAuthorize(ORGANIZER_OR_HIGHER_RULE) @PostMapping public ResponseEntity> createDate(@Valid @RequestBody CreateDateRequest request) { service.addDate(request.getDate()); return ResponseEntity.ok(ApiResponse.ok(CoreAttendanceMessage.DATE_CREATED_SUCCESS, new DateListResponse(service.getDates()))); } - @PreAuthorize("hasAnyRole('ORGANIZER', 'ADMIN')") + @PreAuthorize(ORGANIZER_OR_HIGHER_RULE) @DeleteMapping("/{date}") public ResponseEntity> deleteDate(@PathVariable @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate date) { service.deleteDate(date.toString()); @@ -68,7 +70,9 @@ public ResponseEntity> deleteDate(@PathVaria /* ===== 팀 목록 (리드=본인 팀만 / 관리자=전체) ===== */ @GetMapping("/teams") public ResponseEntity, PageMeta>> getTeams(@AuthenticationPrincipal CustomUserDetails me) { - List list = (me.getRole() == UserRole.LEAD && me.getTeam() != TeamType.HR) ? service.getTeamsForLead(requiredTeamFrom(me)) : service.getTeamsForOrganizerOrAdmin(); + List list = service.isLeadScoped(me.getRole(), me.getTeam()) + ? service.getTeamsForLead(service.resolveEffectiveTeam(me.getRole(), me.getTeam(), null)) + : service.getTeamsForOrganizerOrAdmin(); var page = new PageImpl<>(list, PageRequest.of(0, Math.max(1, list.size()), Sort.by(Sort.Direction.DESC, "createdAt")), list.size()); return ResponseEntity.ok(ApiResponse.ok(CoreAttendanceMessage.TEAM_LIST_RETRIEVED_SUCCESS, list, PageMeta.of(page))); @@ -79,7 +83,7 @@ public ResponseEntity, PageMeta>> getTeams(@Authe @GetMapping("/{date}/members") public ResponseEntity>, Void>> membersOfMeeting(@AuthenticationPrincipal CustomUserDetails me, @PathVariable @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate date, @RequestParam(required = false) TeamType team // 관리자만 사용, 리드는 무시 ) { - TeamType effectiveTeam = (me.getRole() == UserRole.LEAD && me.getTeam() != TeamType.HR) ? requiredTeamFrom(me) : team; + TeamType effectiveTeam = service.resolveEffectiveTeam(me.getRole(), me.getTeam(), team); var list = service.getMembersWithPresence(date.toString(), effectiveTeam); // list 원소 예시: { "userId": "123", "name": "홍길동", "present": true, "lastModifiedAt": "..." } return ResponseEntity.ok(ApiResponse.ok(CoreAttendanceMessage.TEAM_LIST_RETRIEVED_SUCCESS, list)); @@ -90,34 +94,28 @@ public ResponseEntity>, Void>> membersOfMee @PutMapping("/{date}/attendance") public ResponseEntity, Void>> saveAttendanceSnapshot(@AuthenticationPrincipal CustomUserDetails me, @PathVariable @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate date, @RequestBody @Valid SetAttendanceRequest req) { var userIds = req.safeUserIds(); - - // LEAD → 본인 팀 검증 - if (me.getRole() == UserRole.LEAD && me.getTeam() != TeamType.HR) { - TeamType myTeam = requiredTeamFrom(me); - var validation = service.filterUserIdsNotInTeam(myTeam, userIds); - if (validation.validIds().isEmpty()) { - return okUpdated(0L, validation.invalidIds()); - } - long updated = service.setAttendance(date.toString(), validation.validIds(), req.presentValue()); - return okUpdated(updated, validation.invalidIds()); - } - - // ORGANIZER / ADMIN → 팀 추론/검증 없이 바로 업서트 - long updated = service.setAttendance(date.toString(), userIds, req.presentValue()); - return okUpdated(updated, List.of()); + CoreAttendanceService.AttendanceUpdateResult result = service.saveAttendanceSnapshot( + date.toString(), + userIds, + req.presentValue(), + me.getRole(), + me.getTeam() + ); + return okUpdated(result.updatedCount(), result.ignoredUserIds()); } /* ===== 날짜 요약(JSON) ===== */ @GetMapping("/{date}/summary") public ResponseEntity> summary(@AuthenticationPrincipal CustomUserDetails me, @PathVariable @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate date, @RequestParam(required = false) TeamType team) { - DaySummaryResponse body = (me.getRole() == UserRole.LEAD && me.getTeam() != TeamType.HR) ? service.summary(date.toString(), requiredTeamFrom(me)) : service.summary(date.toString(), team); + TeamType effectiveTeam = service.resolveEffectiveTeam(me.getRole(), me.getTeam(), team); + DaySummaryResponse body = service.summary(date.toString(), effectiveTeam); return ResponseEntity.ok(ApiResponse.ok(CoreAttendanceMessage.SUMMARY_RETRIEVED_SUCCESS, body)); } /* ===== 날짜 요약(CSV) ===== */ @GetMapping(value = "/{date}/summary.csv", produces = "text/csv; charset=UTF-8") public ResponseEntity summaryCsv(@AuthenticationPrincipal CustomUserDetails me, @PathVariable @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate date, @RequestParam(required = false) TeamType team) { - TeamType effective = (me.getRole() == UserRole.LEAD && me.getTeam() != TeamType.HR) ? requiredTeamFrom(me) : team; + TeamType effective = service.resolveEffectiveTeam(me.getRole(), me.getTeam(), team); String csv = service.buildSummaryCsv(date.toString(), effective); return ResponseEntity.ok() .header("Content-Disposition", "attachment; filename=\"attendance-" + date + ".csv\"") @@ -129,10 +127,7 @@ public ResponseEntity summaryCsvAll( @AuthenticationPrincipal CustomUserDetails me, @RequestParam(required = false) TeamType team ) { - // LEAD & not HR → 자신의 팀만 - TeamType effective = (me.getRole() == UserRole.LEAD && me.getTeam() != TeamType.HR) - ? requiredTeamFrom(me) - : team; + TeamType effective = service.resolveEffectiveTeam(me.getRole(), me.getTeam(), team); String csv = service.buildFullMatrixCsv(effective); return ResponseEntity.ok() @@ -140,4 +135,4 @@ public ResponseEntity summaryCsvAll( .body(csv); } -} \ No newline at end of file +} diff --git a/src/main/java/inha/gdgoc/domain/core/attendance/service/CoreAttendanceService.java b/src/main/java/inha/gdgoc/domain/core/attendance/service/CoreAttendanceService.java index f0a14283..fbeaf12a 100644 --- a/src/main/java/inha/gdgoc/domain/core/attendance/service/CoreAttendanceService.java +++ b/src/main/java/inha/gdgoc/domain/core/attendance/service/CoreAttendanceService.java @@ -78,6 +78,14 @@ public List getTeamsForOrganizerOrAdmin() { return toTeamResponsesGrouped(users); } + public boolean isLeadScoped(UserRole role, TeamType team) { + return role == UserRole.LEAD && team != TeamType.HR; + } + + public TeamType resolveEffectiveTeam(UserRole role, TeamType principalTeam, TeamType requestedTeam) { + return isLeadScoped(role, principalTeam) ? requireTeam(principalTeam) : requestedTeam; + } + /* ===================== Attendance ===================== */ private List toTeamResponsesGrouped(List users) { @@ -129,6 +137,28 @@ public long setAttendance(String date, List userIds, boolean present) { return Math.max(affected, 0); } + @Transactional + public AttendanceUpdateResult saveAttendanceSnapshot( + String date, + List userIds, + boolean present, + UserRole role, + TeamType principalTeam + ) { + if (isLeadScoped(role, principalTeam)) { + TeamType myTeam = requireTeam(principalTeam); + UserIdValidationResult validation = filterUserIdsNotInTeam(myTeam, userIds); + if (validation.validIds().isEmpty()) { + return new AttendanceUpdateResult(0L, validation.invalidIds()); + } + long updated = setAttendance(date, validation.validIds(), present); + return new AttendanceUpdateResult(updated, validation.invalidIds()); + } + + long updated = setAttendance(date, userIds, present); + return new AttendanceUpdateResult(updated, List.of()); + } + /** * 특정 날짜에 대해 팀원 + 현재 출석 여부 목록 */ @@ -334,4 +364,15 @@ protected Map getPresenceMap(LocalDate date) { public record UserIdValidationResult(List validIds, List invalidIds) { } -} \ No newline at end of file + + public record AttendanceUpdateResult(long updatedCount, List ignoredUserIds) { + + } + + private TeamType requireTeam(TeamType team) { + if (team == null) { + throw new IllegalArgumentException("LEAD 권한 토큰에 team 정보가 없습니다."); + } + return team; + } +} diff --git a/src/main/java/inha/gdgoc/domain/core/recruit/controller/CoreRecruitController.java b/src/main/java/inha/gdgoc/domain/core/recruit/controller/CoreRecruitController.java deleted file mode 100644 index 7efdd337..00000000 --- a/src/main/java/inha/gdgoc/domain/core/recruit/controller/CoreRecruitController.java +++ /dev/null @@ -1,104 +0,0 @@ -package inha.gdgoc.domain.core.recruit.controller; - -import inha.gdgoc.domain.core.recruit.dto.request.CoreRecruitApplicationRequest; -import inha.gdgoc.domain.core.recruit.dto.response.CoreRecruitApplicantDetailResponse; -import inha.gdgoc.domain.core.recruit.dto.response.CoreRecruitApplicantSummaryResponse; -import inha.gdgoc.domain.core.recruit.service.CoreRecruitApplicationService; -import inha.gdgoc.domain.core.recruit.entity.CoreRecruitApplication; -import inha.gdgoc.domain.core.recruit.controller.message.CoreRecruitApplicationMessage; -import inha.gdgoc.global.dto.response.ApiResponse; -import inha.gdgoc.global.dto.response.PageMeta; -import jakarta.validation.Valid; -import lombok.RequiredArgsConstructor; -import io.swagger.v3.oas.annotations.Operation; -import io.swagger.v3.oas.annotations.Parameter; -import io.swagger.v3.oas.annotations.security.SecurityRequirement; -import org.springframework.http.ResponseEntity; -import org.springframework.security.access.prepost.PreAuthorize; -import org.springframework.data.domain.Page; -import org.springframework.data.domain.Pageable; -import org.springframework.data.domain.PageRequest; -import org.springframework.data.domain.Sort; -import org.springframework.data.domain.Sort.Direction; -import org.springframework.web.bind.annotation.GetMapping; -import org.springframework.web.bind.annotation.PathVariable; -import org.springframework.web.bind.annotation.PostMapping; -import org.springframework.web.bind.annotation.RequestBody; -import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RequestParam; -import org.springframework.web.bind.annotation.RestController; -import io.swagger.v3.oas.annotations.tags.Tag; - -@Tag(name = "Core Recruit - Applicants", description = "코어 리쿠르트 지원자 조회 API") -@RestController -@RequestMapping("/api/v1/core-recruit") -@RequiredArgsConstructor -public class CoreRecruitController { - - private final CoreRecruitApplicationService service; - - private record CreateResponse(Long id, String status) {} - - @PostMapping - public ResponseEntity> create( - @Valid @RequestBody CoreRecruitApplicationRequest request - ) { - Long id = service.create(request); - return ResponseEntity.ok(ApiResponse.ok("OK", new CreateResponse(id, "OK"))); - } - - @Operation( - summary = "코어 리쿠르트 지원자 목록 조회", - description = "전체 목록 또는 이름 검색 결과를 반환합니다.", - security = { @SecurityRequirement(name = "BearerAuth") } - ) - @PreAuthorize("hasAnyRole('LEAD', 'ORGANIZER', 'ADMIN')") - @GetMapping("/applicants") - public ResponseEntity, PageMeta>> getApplicants( - @Parameter(description = "검색어(이름 부분 일치). 없으면 전체 조회", example = "홍길동") - @RequestParam(required = false) String question, - - @Parameter(description = "페이지(0부터 시작)", example = "0") - @RequestParam(defaultValue = "0") int page, - - @Parameter(description = "페이지 크기", example = "20") - @RequestParam(defaultValue = "20") int size, - - @Parameter(description = "정렬 필드", example = "createdAt") - @RequestParam(defaultValue = "createdAt") String sort, - - @Parameter(description = "정렬 방향 ASC/DESC", example = "DESC") - @RequestParam(defaultValue = "DESC") String dir - ) { - Direction direction = "ASC".equalsIgnoreCase(dir) ? Direction.ASC : Direction.DESC; - Pageable pageable = PageRequest.of(page, size, Sort.by(direction, sort)); - - Page pageResult = service.findApplicantsPage(question, pageable); - - java.util.List list = pageResult - .map(CoreRecruitApplicantSummaryResponse::from) - .getContent(); - PageMeta meta = PageMeta.of(pageResult); - - return ResponseEntity.ok( - ApiResponse.ok(CoreRecruitApplicationMessage.APPLICANT_LIST_RETRIEVED_SUCCESS, list, meta) - ); - } - - @Operation( - summary = "코어 리쿠르트 지원자 상세 조회", - security = { @SecurityRequirement(name = "BearerAuth") } - ) - @PreAuthorize("hasAnyRole('LEAD', 'ORGANIZER', 'ADMIN')") - @GetMapping("/applicants/{id}") - public ResponseEntity> getApplicant( - @PathVariable Long id - ) { - CoreRecruitApplicantDetailResponse response = service.getApplicantDetail(id); - return ResponseEntity.ok( - ApiResponse.ok(CoreRecruitApplicationMessage.APPLICANT_RETRIEVED_SUCCESS, response) - ); - } -} - - diff --git a/src/main/java/inha/gdgoc/domain/core/recruit/dto/request/CoreRecruitApplicationRequest.java b/src/main/java/inha/gdgoc/domain/core/recruit/dto/request/CoreRecruitApplicationRequest.java deleted file mode 100644 index eb46f35b..00000000 --- a/src/main/java/inha/gdgoc/domain/core/recruit/dto/request/CoreRecruitApplicationRequest.java +++ /dev/null @@ -1,65 +0,0 @@ -package inha.gdgoc.domain.core.recruit.dto.request; - -import jakarta.validation.constraints.Email; -import jakarta.validation.constraints.NotBlank; -import jakarta.validation.constraints.NotNull; -import java.util.List; -import lombok.Builder; -import lombok.Getter; - -@Getter -public class CoreRecruitApplicationRequest { - - @NotBlank - private String name; - - @NotBlank - private String studentId; - - @NotBlank - private String phone; - - @NotBlank - private String major; - - @Email - @NotBlank - private String email; - - @NotBlank - private String team; - - @NotBlank - private String motivation; - - @NotBlank - private String wish; - - @NotBlank - private String strengths; - - @NotBlank - private String pledge; - - @NotNull - private List fileUrls; - - @Builder - public CoreRecruitApplicationRequest(String name, String studentId, String phone, String major, - String email, String team, String motivation, String wish, String strengths, String pledge, - List fileUrls) { - this.name = name; - this.studentId = studentId; - this.phone = phone; - this.major = major; - this.email = email; - this.team = team; - this.motivation = motivation; - this.wish = wish; - this.strengths = strengths; - this.pledge = pledge; - this.fileUrls = fileUrls; - } -} - - diff --git a/src/main/java/inha/gdgoc/domain/core/recruit/dto/response/CoreRecruitApplicantDetailResponse.java b/src/main/java/inha/gdgoc/domain/core/recruit/dto/response/CoreRecruitApplicantDetailResponse.java deleted file mode 100644 index efbd4d0f..00000000 --- a/src/main/java/inha/gdgoc/domain/core/recruit/dto/response/CoreRecruitApplicantDetailResponse.java +++ /dev/null @@ -1,44 +0,0 @@ -package inha.gdgoc.domain.core.recruit.dto.response; - -import inha.gdgoc.domain.core.recruit.entity.CoreRecruitApplication; -import java.time.Instant; -import java.util.List; - -public record CoreRecruitApplicantDetailResponse( - Long id, - String name, - String studentId, - String phone, - String major, - String email, - String team, - String motivation, - String wish, - String strengths, - String pledge, - List fileUrls, - Instant createdAt, - Instant updatedAt -) { - - public static CoreRecruitApplicantDetailResponse from(CoreRecruitApplication entity) { - return new CoreRecruitApplicantDetailResponse( - entity.getId(), - entity.getName(), - entity.getStudentId(), - entity.getPhone(), - entity.getMajor(), - entity.getEmail(), - entity.getTeam(), - entity.getMotivation(), - entity.getWish(), - entity.getStrengths(), - entity.getPledge(), - entity.getFileUrls(), - entity.getCreatedAt(), - entity.getUpdatedAt() - ); - } -} - - diff --git a/src/main/java/inha/gdgoc/domain/core/recruit/dto/response/CoreRecruitApplicantSummaryResponse.java b/src/main/java/inha/gdgoc/domain/core/recruit/dto/response/CoreRecruitApplicantSummaryResponse.java deleted file mode 100644 index e000398c..00000000 --- a/src/main/java/inha/gdgoc/domain/core/recruit/dto/response/CoreRecruitApplicantSummaryResponse.java +++ /dev/null @@ -1,31 +0,0 @@ -package inha.gdgoc.domain.core.recruit.dto.response; - -import inha.gdgoc.domain.core.recruit.entity.CoreRecruitApplication; -import java.time.Instant; - -public record CoreRecruitApplicantSummaryResponse( - Long id, - String name, - String studentId, - String major, - String email, - String phone, - String team, - Instant createdAt -) { - - public static CoreRecruitApplicantSummaryResponse from(CoreRecruitApplication entity) { - return new CoreRecruitApplicantSummaryResponse( - entity.getId(), - entity.getName(), - entity.getStudentId(), - entity.getMajor(), - entity.getEmail(), - entity.getPhone(), - entity.getTeam(), - entity.getCreatedAt() - ); - } -} - - diff --git a/src/main/java/inha/gdgoc/domain/core/recruit/entity/CoreRecruitApplication.java b/src/main/java/inha/gdgoc/domain/core/recruit/entity/CoreRecruitApplication.java deleted file mode 100644 index e5611f85..00000000 --- a/src/main/java/inha/gdgoc/domain/core/recruit/entity/CoreRecruitApplication.java +++ /dev/null @@ -1,71 +0,0 @@ -package inha.gdgoc.domain.core.recruit.entity; - -import com.vladmihalcea.hibernate.type.json.JsonType; -import inha.gdgoc.global.entity.BaseEntity; -import jakarta.persistence.Column; -import jakarta.persistence.Entity; -import jakarta.persistence.GeneratedValue; -import jakarta.persistence.GenerationType; -import jakarta.persistence.Id; -import jakarta.persistence.Table; -import java.util.List; -import lombok.AccessLevel; -import lombok.AllArgsConstructor; -import lombok.Builder; -import lombok.Getter; -import lombok.NoArgsConstructor; -import org.hibernate.annotations.Type; - -@Entity -@Table(name = "core_recruit_applications") -@Getter -@NoArgsConstructor(access = AccessLevel.PROTECTED) -@AllArgsConstructor -@Builder -public class CoreRecruitApplication extends BaseEntity { - - @Id - @GeneratedValue(strategy = GenerationType.IDENTITY) - @Column(name = "id", nullable = false) - private Long id; - - @Column(name = "name", nullable = false) - private String name; - - @Column(name = "student_id", nullable = false) - private String studentId; - - @Column(name = "phone", nullable = false) - private String phone; - - @Column(name = "major", nullable = false) - private String major; - - @Column(name = "email", nullable = false) - private String email; - - @Column(name = "team", nullable = false) - private String team; - - @Column(name = "motivation", nullable = false, columnDefinition = "text") - private String motivation; - - @Column(name = "wish", nullable = false, columnDefinition = "text") - private String wish; - - @Column(name = "strengths", nullable = false, columnDefinition = "text") - private String strengths; - - @Column(name = "pledge", nullable = false, columnDefinition = "text") - private String pledge; - - @Type(JsonType.class) - @Column(name = "file_urls", nullable = false, columnDefinition = "jsonb") - private List fileUrls; - - public Long getId() { - return id; - } -} - - diff --git a/src/main/java/inha/gdgoc/domain/core/recruit/repository/CoreRecruitApplicationRepository.java b/src/main/java/inha/gdgoc/domain/core/recruit/repository/CoreRecruitApplicationRepository.java deleted file mode 100644 index f6a62818..00000000 --- a/src/main/java/inha/gdgoc/domain/core/recruit/repository/CoreRecruitApplicationRepository.java +++ /dev/null @@ -1,12 +0,0 @@ -package inha.gdgoc.domain.core.recruit.repository; - -import inha.gdgoc.domain.core.recruit.entity.CoreRecruitApplication; -import org.springframework.data.domain.Page; -import org.springframework.data.domain.Pageable; -import org.springframework.data.jpa.repository.JpaRepository; - -public interface CoreRecruitApplicationRepository extends JpaRepository { - Page findByNameContainingIgnoreCase(String name, Pageable pageable); -} - - diff --git a/src/main/java/inha/gdgoc/domain/core/recruit/service/CoreRecruitApplicationService.java b/src/main/java/inha/gdgoc/domain/core/recruit/service/CoreRecruitApplicationService.java deleted file mode 100644 index b251e91a..00000000 --- a/src/main/java/inha/gdgoc/domain/core/recruit/service/CoreRecruitApplicationService.java +++ /dev/null @@ -1,57 +0,0 @@ -package inha.gdgoc.domain.core.recruit.service; - -import inha.gdgoc.domain.core.recruit.dto.request.CoreRecruitApplicationRequest; -import inha.gdgoc.domain.core.recruit.dto.response.CoreRecruitApplicantDetailResponse; -import inha.gdgoc.domain.core.recruit.dto.response.CoreRecruitApplicantSummaryResponse; -import inha.gdgoc.domain.core.recruit.entity.CoreRecruitApplication; -import inha.gdgoc.domain.core.recruit.repository.CoreRecruitApplicationRepository; -import inha.gdgoc.global.exception.BusinessException; -import inha.gdgoc.global.exception.GlobalErrorCode; -import lombok.RequiredArgsConstructor; -import org.springframework.data.domain.Page; -import org.springframework.data.domain.Pageable; -import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; - -@Service -@RequiredArgsConstructor -public class CoreRecruitApplicationService { - - private final CoreRecruitApplicationRepository repository; - - @Transactional - public Long create(CoreRecruitApplicationRequest request) { - CoreRecruitApplication entity = CoreRecruitApplication.builder() - .name(request.getName()) - .studentId(request.getStudentId()) - .phone(request.getPhone()) - .major(request.getMajor()) - .email(request.getEmail()) - .team(request.getTeam()) - .motivation(request.getMotivation()) - .wish(request.getWish()) - .strengths(request.getStrengths()) - .pledge(request.getPledge()) - .fileUrls(request.getFileUrls()) - .build(); - - return repository.save(entity).getId(); - } - - @Transactional(readOnly = true) - public Page findApplicantsPage(String question, Pageable pageable) { - if (question == null || question.isBlank()) { - return repository.findAll(pageable); - } - return repository.findByNameContainingIgnoreCase(question, pageable); - } - - @Transactional(readOnly = true) - public CoreRecruitApplicantDetailResponse getApplicantDetail(Long id) { - CoreRecruitApplication app = repository.findById(id) - .orElseThrow(() -> new BusinessException(GlobalErrorCode.RESOURCE_NOT_FOUND)); - return CoreRecruitApplicantDetailResponse.from(app); - } -} - - diff --git a/src/main/java/inha/gdgoc/domain/guestbook/controller/GuestbookController.java b/src/main/java/inha/gdgoc/domain/guestbook/controller/GuestbookController.java index 673495a9..1767ebba 100644 --- a/src/main/java/inha/gdgoc/domain/guestbook/controller/GuestbookController.java +++ b/src/main/java/inha/gdgoc/domain/guestbook/controller/GuestbookController.java @@ -19,7 +19,9 @@ @RestController @RequestMapping("/api/v1/guestbook") @RequiredArgsConstructor -@PreAuthorize("hasAnyRole('ORGANIZER','ADMIN')") +@PreAuthorize("@accessGuard.check(authentication," + + " T(inha.gdgoc.global.security.AccessGuard$AccessCondition).atLeast(" + + "T(inha.gdgoc.domain.user.enums.UserRole).LEAD))") public class GuestbookController { private final GuestbookService service; diff --git a/src/main/java/inha/gdgoc/domain/recruit/core/config/RecruitCoreSessionResolver.java b/src/main/java/inha/gdgoc/domain/recruit/core/config/RecruitCoreSessionResolver.java new file mode 100644 index 00000000..6f3cade3 --- /dev/null +++ b/src/main/java/inha/gdgoc/domain/recruit/core/config/RecruitCoreSessionResolver.java @@ -0,0 +1,30 @@ +package inha.gdgoc.domain.recruit.core.config; + +import java.time.Clock; +import java.time.LocalDate; +import java.time.ZoneId; +import org.springframework.stereotype.Component; + +/** + * 운영진 리크루팅 회차(예: 2026-1)를 현재 날짜 기준으로 계산한다. + * 1~6월은 1학기, 7~12월은 2학기로 본다. + */ +@Component +public class RecruitCoreSessionResolver { + + private final Clock clock; + + public RecruitCoreSessionResolver() { + this(Clock.system(ZoneId.of("Asia/Seoul"))); + } + + public RecruitCoreSessionResolver(Clock clock) { + this.clock = clock; + } + + public String currentSession() { + LocalDate today = LocalDate.now(clock); + int semester = (today.getMonthValue() <= 6) ? 1 : 2; + return today.getYear() + "-" + semester; + } +} diff --git a/src/main/java/inha/gdgoc/domain/recruit/core/controller/RecruitCoreController.java b/src/main/java/inha/gdgoc/domain/recruit/core/controller/RecruitCoreController.java new file mode 100644 index 00000000..50c1fbcf --- /dev/null +++ b/src/main/java/inha/gdgoc/domain/recruit/core/controller/RecruitCoreController.java @@ -0,0 +1,90 @@ +package inha.gdgoc.domain.recruit.core.controller; + +import inha.gdgoc.domain.recruit.core.dto.request.RecruitCoreApplicationCreateRequest; +import inha.gdgoc.domain.recruit.core.dto.response.RecruitCoreApplicantDetailResponse; +import inha.gdgoc.domain.recruit.core.dto.response.RecruitCoreApplicationCreateResponse; +import inha.gdgoc.domain.recruit.core.dto.response.RecruitCoreEligibilityResponse; +import inha.gdgoc.domain.recruit.core.dto.response.RecruitCoreMyApplicationResponse; +import inha.gdgoc.domain.recruit.core.dto.response.RecruitCorePrefillResponse; +import inha.gdgoc.domain.recruit.core.service.RecruitCoreApplicationService; +import inha.gdgoc.global.config.jwt.TokenProvider.CustomUserDetails; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.security.SecurityRequirement; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@Tag(name = "Recruit Core - Guest", description = "운영진 리크루팅 지원 API") +@RestController +@RequestMapping("/api/v1/recruit/core") +@RequiredArgsConstructor +public class RecruitCoreController { + + private final RecruitCoreApplicationService service; + + @Operation(summary = "지원 가능 여부 확인", security = {@SecurityRequirement(name = "BearerAuth")}) + @PreAuthorize("isAuthenticated()") + @GetMapping("/eligibility") + public ResponseEntity eligibility( + @AuthenticationPrincipal CustomUserDetails me + ) { + RecruitCoreEligibilityResponse response = service.checkEligibility(me.getUserId()); + return ResponseEntity.ok(response); + } + + @Operation(summary = "지원서 기본 정보 자동 채움", security = {@SecurityRequirement(name = "BearerAuth")}) + @PreAuthorize("isAuthenticated()") + @GetMapping("/prefill") + public ResponseEntity prefill( + @AuthenticationPrincipal CustomUserDetails me + ) { + RecruitCorePrefillResponse response = service.prefill(me.getUserId()); + return ResponseEntity.ok(response); + } + + @Operation(summary = "운영진 지원서 제출", security = {@SecurityRequirement(name = "BearerAuth")}) + @PreAuthorize("isAuthenticated()") + @PostMapping("/applications") + public ResponseEntity submit( + @AuthenticationPrincipal CustomUserDetails me, + @Valid @RequestBody RecruitCoreApplicationCreateRequest request + ) { + RecruitCoreApplicationCreateResponse response = service.submit(me.getUserId(), request); + return ResponseEntity.status(HttpStatus.CREATED).body(response); + } + + @Operation(summary = "나의 지원서 조회", security = {@SecurityRequirement(name = "BearerAuth")}) + @PreAuthorize("isAuthenticated()") + @GetMapping("/applications/me") + public ResponseEntity myApplication( + @AuthenticationPrincipal CustomUserDetails me + ) { + RecruitCoreMyApplicationResponse response = service.getMyApplication(me.getUserId()); + return ResponseEntity.ok(response); + } + + @Operation( + summary = "지원서 상세 조회 (본인/운영진)", + security = {@SecurityRequirement(name = "BearerAuth")} + ) + @PreAuthorize("isAuthenticated()") + @GetMapping("/applications/{applicationId}") + public ResponseEntity getApplication( + @AuthenticationPrincipal CustomUserDetails me, + @PathVariable Long applicationId + ) { + RecruitCoreApplicantDetailResponse response = + service.getApplicantDetailForViewer(applicationId, me.getUserId(), me.getRole()); + return ResponseEntity.ok(response); + } +} diff --git a/src/main/java/inha/gdgoc/domain/core/recruit/controller/message/CoreRecruitApplicationMessage.java b/src/main/java/inha/gdgoc/domain/recruit/core/controller/message/RecruitCoreApplicationMessage.java similarity index 73% rename from src/main/java/inha/gdgoc/domain/core/recruit/controller/message/CoreRecruitApplicationMessage.java rename to src/main/java/inha/gdgoc/domain/recruit/core/controller/message/RecruitCoreApplicationMessage.java index daad2a55..e94ae515 100644 --- a/src/main/java/inha/gdgoc/domain/core/recruit/controller/message/CoreRecruitApplicationMessage.java +++ b/src/main/java/inha/gdgoc/domain/recruit/core/controller/message/RecruitCoreApplicationMessage.java @@ -1,8 +1,7 @@ -package inha.gdgoc.domain.core.recruit.controller.message; +package inha.gdgoc.domain.recruit.core.controller.message; -public class CoreRecruitApplicationMessage { +public class RecruitCoreApplicationMessage { public static final String APPLICANT_LIST_RETRIEVED_SUCCESS = "성공적으로 코어 리쿠르트 지원자 목록을 조회했습니다."; public static final String APPLICANT_RETRIEVED_SUCCESS = "성공적으로 코어 리쿠르트 지원자 상세를 조회했습니다."; } - diff --git a/src/main/java/inha/gdgoc/domain/recruit/core/dto/request/RecruitCoreApplicationCreateRequest.java b/src/main/java/inha/gdgoc/domain/recruit/core/dto/request/RecruitCoreApplicationCreateRequest.java new file mode 100644 index 00000000..dc946b04 --- /dev/null +++ b/src/main/java/inha/gdgoc/domain/recruit/core/dto/request/RecruitCoreApplicationCreateRequest.java @@ -0,0 +1,28 @@ +package inha.gdgoc.domain.recruit.core.dto.request; + +import jakarta.validation.Valid; +import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; +import java.util.List; + +public record RecruitCoreApplicationCreateRequest( + @Valid @NotNull RecruitCoreApplicationSnapshotRequest snapshot, + @NotBlank String team, + @NotBlank String motivation, + @NotBlank String wish, + @NotBlank String strengths, + @NotBlank String pledge, + @NotNull @Size(min = 0) List<@NotBlank String> fileUrls +) { + + public record RecruitCoreApplicationSnapshotRequest( + @NotBlank String name, + @NotBlank String studentId, + @NotBlank String phone, + @NotBlank String major, + @NotBlank @Email String email + ) { + } +} diff --git a/src/main/java/inha/gdgoc/domain/recruit/core/dto/response/RecruitCoreApplicantDetailResponse.java b/src/main/java/inha/gdgoc/domain/recruit/core/dto/response/RecruitCoreApplicantDetailResponse.java new file mode 100644 index 00000000..55bbcda1 --- /dev/null +++ b/src/main/java/inha/gdgoc/domain/recruit/core/dto/response/RecruitCoreApplicantDetailResponse.java @@ -0,0 +1,41 @@ +package inha.gdgoc.domain.recruit.core.dto.response; + +import inha.gdgoc.domain.recruit.core.entity.RecruitCoreApplication; +import inha.gdgoc.domain.recruit.core.enums.RecruitCoreResultStatus; +import java.time.Instant; +import java.util.List; + +public record RecruitCoreApplicantDetailResponse( + Long applicationId, + String session, + RecruitCoreApplicationSnapshotResponse snapshot, + String team, + String motivation, + String wish, + String strengths, + String pledge, + List fileUrls, + RecruitCoreResultStatus resultStatus, + RecruitCoreApplicationReviewResponse review, + Instant createdAt, + Instant updatedAt +) { + + public static RecruitCoreApplicantDetailResponse from(RecruitCoreApplication entity) { + return new RecruitCoreApplicantDetailResponse( + entity.getId(), + entity.getSession(), + RecruitCoreApplicationSnapshotResponse.from(entity), + entity.getTeam(), + entity.getMotivation(), + entity.getWish(), + entity.getStrengths(), + entity.getPledge(), + entity.getFileUrls(), + entity.getResultStatus(), + RecruitCoreApplicationReviewResponse.from(entity), + entity.getCreatedAt(), + entity.getUpdatedAt() + ); + } +} diff --git a/src/main/java/inha/gdgoc/domain/recruit/core/dto/response/RecruitCoreApplicationCreateResponse.java b/src/main/java/inha/gdgoc/domain/recruit/core/dto/response/RecruitCoreApplicationCreateResponse.java new file mode 100644 index 00000000..f61236e2 --- /dev/null +++ b/src/main/java/inha/gdgoc/domain/recruit/core/dto/response/RecruitCoreApplicationCreateResponse.java @@ -0,0 +1,22 @@ +package inha.gdgoc.domain.recruit.core.dto.response; + +import inha.gdgoc.domain.recruit.core.entity.RecruitCoreApplication; +import inha.gdgoc.domain.recruit.core.enums.RecruitCoreResultStatus; +import java.time.Instant; + +public record RecruitCoreApplicationCreateResponse( + Long applicationId, + String session, + RecruitCoreResultStatus resultStatus, + Instant submittedAt +) { + + public static RecruitCoreApplicationCreateResponse from(RecruitCoreApplication application) { + return new RecruitCoreApplicationCreateResponse( + application.getId(), + application.getSession(), + application.getResultStatus(), + application.getCreatedAt() + ); + } +} diff --git a/src/main/java/inha/gdgoc/domain/recruit/core/dto/response/RecruitCoreApplicationErrorResponse.java b/src/main/java/inha/gdgoc/domain/recruit/core/dto/response/RecruitCoreApplicationErrorResponse.java new file mode 100644 index 00000000..fe5b5167 --- /dev/null +++ b/src/main/java/inha/gdgoc/domain/recruit/core/dto/response/RecruitCoreApplicationErrorResponse.java @@ -0,0 +1,29 @@ +package inha.gdgoc.domain.recruit.core.dto.response; + +import com.fasterxml.jackson.annotation.JsonInclude; + +@JsonInclude(JsonInclude.Include.NON_NULL) +public record RecruitCoreApplicationErrorResponse( + String code, + String message, + Details details +) { + + public static RecruitCoreApplicationErrorResponse of( + String code, + String message + ) { + return new RecruitCoreApplicationErrorResponse(code, message, null); + } + + public static RecruitCoreApplicationErrorResponse of( + String code, + String message, + String session, + Long applicationId + ) { + return new RecruitCoreApplicationErrorResponse(code, message, new Details(session, applicationId)); + } + + public record Details(String session, Long applicationId) {} +} diff --git a/src/main/java/inha/gdgoc/domain/recruit/core/dto/response/RecruitCoreApplicationReviewResponse.java b/src/main/java/inha/gdgoc/domain/recruit/core/dto/response/RecruitCoreApplicationReviewResponse.java new file mode 100644 index 00000000..bfa101a0 --- /dev/null +++ b/src/main/java/inha/gdgoc/domain/recruit/core/dto/response/RecruitCoreApplicationReviewResponse.java @@ -0,0 +1,26 @@ +package inha.gdgoc.domain.recruit.core.dto.response; + +import com.fasterxml.jackson.annotation.JsonInclude; +import inha.gdgoc.domain.recruit.core.entity.RecruitCoreApplication; +import java.time.Instant; + +@JsonInclude(JsonInclude.Include.NON_NULL) +public record RecruitCoreApplicationReviewResponse( + Instant reviewedAt, + Long reviewedBy, + String resultNote +) { + + public static RecruitCoreApplicationReviewResponse from(RecruitCoreApplication application) { + if (application.getReviewedAt() == null + && application.getReviewedBy() == null + && application.getResultNote() == null) { + return new RecruitCoreApplicationReviewResponse(null, null, null); + } + return new RecruitCoreApplicationReviewResponse( + application.getReviewedAt(), + application.getReviewedBy(), + application.getResultNote() + ); + } +} diff --git a/src/main/java/inha/gdgoc/domain/recruit/core/dto/response/RecruitCoreApplicationSnapshotResponse.java b/src/main/java/inha/gdgoc/domain/recruit/core/dto/response/RecruitCoreApplicationSnapshotResponse.java new file mode 100644 index 00000000..3a583eb7 --- /dev/null +++ b/src/main/java/inha/gdgoc/domain/recruit/core/dto/response/RecruitCoreApplicationSnapshotResponse.java @@ -0,0 +1,22 @@ +package inha.gdgoc.domain.recruit.core.dto.response; + +import inha.gdgoc.domain.recruit.core.entity.RecruitCoreApplication; + +public record RecruitCoreApplicationSnapshotResponse( + String name, + String studentId, + String phone, + String major, + String email +) { + + public static RecruitCoreApplicationSnapshotResponse from(RecruitCoreApplication application) { + return new RecruitCoreApplicationSnapshotResponse( + application.getName(), + application.getStudentId(), + application.getPhone(), + application.getMajor(), + application.getEmail() + ); + } +} diff --git a/src/main/java/inha/gdgoc/domain/recruit/core/dto/response/RecruitCoreEligibilityResponse.java b/src/main/java/inha/gdgoc/domain/recruit/core/dto/response/RecruitCoreEligibilityResponse.java new file mode 100644 index 00000000..81eae377 --- /dev/null +++ b/src/main/java/inha/gdgoc/domain/recruit/core/dto/response/RecruitCoreEligibilityResponse.java @@ -0,0 +1,20 @@ +package inha.gdgoc.domain.recruit.core.dto.response; + +import com.fasterxml.jackson.annotation.JsonInclude; + +@JsonInclude(JsonInclude.Include.NON_NULL) +public record RecruitCoreEligibilityResponse( + boolean eligible, + String session, + String reason, + Long applicationId +) { + + public static RecruitCoreEligibilityResponse eligible(String session) { + return new RecruitCoreEligibilityResponse(true, session, null, null); + } + + public static RecruitCoreEligibilityResponse ineligible(String session, String reason, Long applicationId) { + return new RecruitCoreEligibilityResponse(false, session, reason, applicationId); + } +} diff --git a/src/main/java/inha/gdgoc/domain/recruit/core/dto/response/RecruitCoreMyApplicationResponse.java b/src/main/java/inha/gdgoc/domain/recruit/core/dto/response/RecruitCoreMyApplicationResponse.java new file mode 100644 index 00000000..51becba4 --- /dev/null +++ b/src/main/java/inha/gdgoc/domain/recruit/core/dto/response/RecruitCoreMyApplicationResponse.java @@ -0,0 +1,26 @@ +package inha.gdgoc.domain.recruit.core.dto.response; + +import inha.gdgoc.domain.recruit.core.entity.RecruitCoreApplication; +import inha.gdgoc.domain.recruit.core.enums.RecruitCoreResultStatus; +import java.time.Instant; + +public record RecruitCoreMyApplicationResponse( + Long applicationId, + String session, + String team, + RecruitCoreResultStatus resultStatus, + Instant createdAt, + Instant updatedAt +) { + + public static RecruitCoreMyApplicationResponse from(RecruitCoreApplication application) { + return new RecruitCoreMyApplicationResponse( + application.getId(), + application.getSession(), + application.getTeam(), + application.getResultStatus(), + application.getCreatedAt(), + application.getUpdatedAt() + ); + } +} diff --git a/src/main/java/inha/gdgoc/domain/recruit/core/dto/response/RecruitCorePrefillResponse.java b/src/main/java/inha/gdgoc/domain/recruit/core/dto/response/RecruitCorePrefillResponse.java new file mode 100644 index 00000000..dae4cd5b --- /dev/null +++ b/src/main/java/inha/gdgoc/domain/recruit/core/dto/response/RecruitCorePrefillResponse.java @@ -0,0 +1,22 @@ +package inha.gdgoc.domain.recruit.core.dto.response; + +import inha.gdgoc.domain.user.entity.User; + +public record RecruitCorePrefillResponse( + String name, + String studentId, + String phone, + String major, + String email +) { + + public static RecruitCorePrefillResponse from(User user) { + return new RecruitCorePrefillResponse( + user.getName(), + user.getStudentId(), + user.getPhoneNumber(), + user.getMajor(), + user.getEmail() + ); + } +} diff --git a/src/main/java/inha/gdgoc/domain/recruit/core/entity/RecruitCoreApplication.java b/src/main/java/inha/gdgoc/domain/recruit/core/entity/RecruitCoreApplication.java new file mode 100644 index 00000000..85d50c31 --- /dev/null +++ b/src/main/java/inha/gdgoc/domain/recruit/core/entity/RecruitCoreApplication.java @@ -0,0 +1,122 @@ +package inha.gdgoc.domain.recruit.core.entity; + +import com.vladmihalcea.hibernate.type.json.JsonType; +import inha.gdgoc.domain.recruit.core.enums.RecruitCoreResultStatus; +import inha.gdgoc.domain.user.entity.User; +import inha.gdgoc.global.entity.BaseEntity; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; +import java.time.Instant; +import java.util.List; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import org.hibernate.annotations.Type; + +@Entity +@Table(name = "core_recruit_applications") +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor +@Builder +public class RecruitCoreApplication extends BaseEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "id", nullable = false) + private Long id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "user_id", nullable = false) + private User user; + + @Column(name = "session", nullable = false, length = 32) + private String session; + + @Column(name = "name", nullable = false) + private String name; + + @Column(name = "student_id", nullable = false) + private String studentId; + + @Column(name = "phone", nullable = false) + private String phone; + + @Column(name = "major", nullable = false) + private String major; + + @Column(name = "email", nullable = false) + private String email; + + @Column(name = "team", nullable = false) + private String team; + + @Column(name = "motivation", nullable = false, columnDefinition = "text") + private String motivation; + + @Column(name = "wish", nullable = false, columnDefinition = "text") + private String wish; + + @Column(name = "strengths", nullable = false, columnDefinition = "text") + private String strengths; + + @Column(name = "pledge", nullable = false, columnDefinition = "text") + private String pledge; + + @Type(JsonType.class) + @Column(name = "file_urls", nullable = false, columnDefinition = "jsonb") + private List fileUrls; + + @Builder.Default + @Enumerated(EnumType.STRING) + @Column(name = "result_status", nullable = false, length = 32) + private RecruitCoreResultStatus resultStatus = RecruitCoreResultStatus.SUBMITTED; + + @Column(name = "reviewed_at") + private Instant reviewedAt; + + @Column(name = "reviewed_by") + private Long reviewedBy; + + @Column(name = "result_note", columnDefinition = "text") + private String resultNote; + + public Long getId() { + return id; + } + + public boolean isOwnedBy(Long userId) { + return userId != null && user != null && userId.equals(user.getId()); + } + + public void accept(Long reviewerId, String note, Instant reviewedAt) { + this.resultStatus = RecruitCoreResultStatus.ACCEPTED; + this.reviewedAt = reviewedAt; + this.reviewedBy = reviewerId; + this.resultNote = note; + } + + public void reject(Long reviewerId, String note, Instant reviewedAt) { + this.resultStatus = RecruitCoreResultStatus.REJECTED; + this.reviewedAt = reviewedAt; + this.reviewedBy = reviewerId; + this.resultNote = note; + } + + public void moveToReview(Long reviewerId, Instant reviewedAt) { + this.resultStatus = RecruitCoreResultStatus.IN_REVIEW; + this.reviewedAt = reviewedAt; + this.reviewedBy = reviewerId; + } +} diff --git a/src/main/java/inha/gdgoc/domain/recruit/core/enums/RecruitCoreResultStatus.java b/src/main/java/inha/gdgoc/domain/recruit/core/enums/RecruitCoreResultStatus.java new file mode 100644 index 00000000..33f56f9c --- /dev/null +++ b/src/main/java/inha/gdgoc/domain/recruit/core/enums/RecruitCoreResultStatus.java @@ -0,0 +1,8 @@ +package inha.gdgoc.domain.recruit.core.enums; + +public enum RecruitCoreResultStatus { + SUBMITTED, + IN_REVIEW, + ACCEPTED, + REJECTED +} diff --git a/src/main/java/inha/gdgoc/domain/recruit/core/exception/RecruitCoreAlreadyAppliedException.java b/src/main/java/inha/gdgoc/domain/recruit/core/exception/RecruitCoreAlreadyAppliedException.java new file mode 100644 index 00000000..ef91aeb2 --- /dev/null +++ b/src/main/java/inha/gdgoc/domain/recruit/core/exception/RecruitCoreAlreadyAppliedException.java @@ -0,0 +1,18 @@ +package inha.gdgoc.domain.recruit.core.exception; + +import lombok.Getter; + +@Getter +public class RecruitCoreAlreadyAppliedException extends RuntimeException { + + private final RecruitCoreApplicationErrorCode errorCode; + private final String session; + private final Long applicationId; + + public RecruitCoreAlreadyAppliedException(String session, Long applicationId) { + super(RecruitCoreApplicationErrorCode.ALREADY_APPLIED.getMessage()); + this.errorCode = RecruitCoreApplicationErrorCode.ALREADY_APPLIED; + this.session = session; + this.applicationId = applicationId; + } +} diff --git a/src/main/java/inha/gdgoc/domain/recruit/core/exception/RecruitCoreApplicationErrorCode.java b/src/main/java/inha/gdgoc/domain/recruit/core/exception/RecruitCoreApplicationErrorCode.java new file mode 100644 index 00000000..5287f021 --- /dev/null +++ b/src/main/java/inha/gdgoc/domain/recruit/core/exception/RecruitCoreApplicationErrorCode.java @@ -0,0 +1,20 @@ +package inha.gdgoc.domain.recruit.core.exception; + +import lombok.Getter; +import org.springframework.http.HttpStatus; + +@Getter +public enum RecruitCoreApplicationErrorCode { + ALREADY_APPLIED("ALREADY_APPLIED", "이미 지원이 완료되었습니다.", HttpStatus.CONFLICT), + APPLICATION_NOT_FOUND("APPLICATION_NOT_FOUND", "제출된 운영진 지원서가 없습니다.", HttpStatus.NOT_FOUND); + + private final String code; + private final String message; + private final HttpStatus status; + + RecruitCoreApplicationErrorCode(String code, String message, HttpStatus status) { + this.code = code; + this.message = message; + this.status = status; + } +} diff --git a/src/main/java/inha/gdgoc/domain/recruit/core/exception/RecruitCoreApplicationNotFoundException.java b/src/main/java/inha/gdgoc/domain/recruit/core/exception/RecruitCoreApplicationNotFoundException.java new file mode 100644 index 00000000..1b779323 --- /dev/null +++ b/src/main/java/inha/gdgoc/domain/recruit/core/exception/RecruitCoreApplicationNotFoundException.java @@ -0,0 +1,13 @@ +package inha.gdgoc.domain.recruit.core.exception; + +import lombok.Getter; + +@Getter +public class RecruitCoreApplicationNotFoundException extends RuntimeException { + + private final RecruitCoreApplicationErrorCode errorCode = RecruitCoreApplicationErrorCode.APPLICATION_NOT_FOUND; + + public RecruitCoreApplicationNotFoundException() { + super(RecruitCoreApplicationErrorCode.APPLICATION_NOT_FOUND.getMessage()); + } +} diff --git a/src/main/java/inha/gdgoc/domain/recruit/core/exception/RecruitCoreControllerExceptionHandler.java b/src/main/java/inha/gdgoc/domain/recruit/core/exception/RecruitCoreControllerExceptionHandler.java new file mode 100644 index 00000000..0c4a9b76 --- /dev/null +++ b/src/main/java/inha/gdgoc/domain/recruit/core/exception/RecruitCoreControllerExceptionHandler.java @@ -0,0 +1,42 @@ +package inha.gdgoc.domain.recruit.core.exception; + +import inha.gdgoc.domain.recruit.core.dto.response.RecruitCoreApplicationErrorResponse; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.RestControllerAdvice; + +import inha.gdgoc.domain.recruit.core.controller.RecruitCoreController; + +@Slf4j +@RestControllerAdvice(assignableTypes = RecruitCoreController.class) +public class RecruitCoreControllerExceptionHandler { + + @ExceptionHandler(RecruitCoreAlreadyAppliedException.class) + public ResponseEntity handleAlreadyApplied( + RecruitCoreAlreadyAppliedException ex + ) { + log.debug("RecruitCoreAlreadyAppliedException: {}", ex.getMessage()); + var code = ex.getErrorCode(); + RecruitCoreApplicationErrorResponse body = RecruitCoreApplicationErrorResponse.of( + code.getCode(), + code.getMessage(), + ex.getSession(), + ex.getApplicationId() + ); + return ResponseEntity.status(code.getStatus()).body(body); + } + + @ExceptionHandler(RecruitCoreApplicationNotFoundException.class) + public ResponseEntity handleNotFound( + RecruitCoreApplicationNotFoundException ex + ) { + log.debug("RecruitCoreApplicationNotFoundException: {}", ex.getMessage()); + var code = ex.getErrorCode(); + RecruitCoreApplicationErrorResponse body = RecruitCoreApplicationErrorResponse.of( + code.getCode(), + code.getMessage() + ); + return ResponseEntity.status(code.getStatus()).body(body); + } +} diff --git a/src/main/java/inha/gdgoc/domain/recruit/core/repository/RecruitCoreApplicationRepository.java b/src/main/java/inha/gdgoc/domain/recruit/core/repository/RecruitCoreApplicationRepository.java new file mode 100644 index 00000000..cab8f3fe --- /dev/null +++ b/src/main/java/inha/gdgoc/domain/recruit/core/repository/RecruitCoreApplicationRepository.java @@ -0,0 +1,17 @@ +package inha.gdgoc.domain.recruit.core.repository; + +import inha.gdgoc.domain.recruit.core.entity.RecruitCoreApplication; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; + +import org.springframework.data.jpa.repository.JpaSpecificationExecutor; + +public interface RecruitCoreApplicationRepository extends JpaRepository, + JpaSpecificationExecutor { + Page findByNameContainingIgnoreCase(String name, Pageable pageable); + + java.util.Optional findByUserIdAndSession(Long userId, String session); + + java.util.Optional findByIdAndUserId(Long id, Long userId); +} diff --git a/src/main/java/inha/gdgoc/domain/recruit/core/service/RecruitCoreApplicationService.java b/src/main/java/inha/gdgoc/domain/recruit/core/service/RecruitCoreApplicationService.java new file mode 100644 index 00000000..ee71ba88 --- /dev/null +++ b/src/main/java/inha/gdgoc/domain/recruit/core/service/RecruitCoreApplicationService.java @@ -0,0 +1,126 @@ +package inha.gdgoc.domain.recruit.core.service; + +import inha.gdgoc.domain.recruit.core.config.RecruitCoreSessionResolver; +import inha.gdgoc.domain.recruit.core.dto.request.RecruitCoreApplicationCreateRequest; +import inha.gdgoc.domain.recruit.core.dto.response.RecruitCoreApplicantDetailResponse; +import inha.gdgoc.domain.recruit.core.dto.response.RecruitCoreApplicationCreateResponse; +import inha.gdgoc.domain.recruit.core.dto.response.RecruitCoreEligibilityResponse; +import inha.gdgoc.domain.recruit.core.dto.response.RecruitCoreMyApplicationResponse; +import inha.gdgoc.domain.recruit.core.dto.response.RecruitCorePrefillResponse; +import inha.gdgoc.domain.recruit.core.entity.RecruitCoreApplication; +import inha.gdgoc.domain.recruit.core.enums.RecruitCoreResultStatus; +import inha.gdgoc.domain.recruit.core.exception.RecruitCoreAlreadyAppliedException; +import inha.gdgoc.domain.recruit.core.exception.RecruitCoreApplicationNotFoundException; +import inha.gdgoc.domain.recruit.core.repository.RecruitCoreApplicationRepository; +import inha.gdgoc.domain.user.entity.User; +import inha.gdgoc.domain.user.enums.UserRole; +import inha.gdgoc.domain.user.repository.UserRepository; +import inha.gdgoc.global.exception.BusinessException; +import inha.gdgoc.global.exception.GlobalErrorCode; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +public class RecruitCoreApplicationService { + + private final RecruitCoreApplicationRepository repository; + private final UserRepository userRepository; + private final RecruitCoreSessionResolver recruitCoreSessionResolver; + + @Transactional(readOnly = true) + public RecruitCoreApplicantDetailResponse getApplicantDetail(Long id) { + RecruitCoreApplication app = getApplication(id); + return RecruitCoreApplicantDetailResponse.from(app); + } + + @Transactional(readOnly = true) + public RecruitCoreEligibilityResponse checkEligibility(Long userId) { + String session = recruitCoreSessionResolver.currentSession(); + return repository.findByUserIdAndSession(userId, session) + .map(app -> RecruitCoreEligibilityResponse.ineligible(session, "ALREADY_APPLIED", app.getId())) + .orElseGet(() -> RecruitCoreEligibilityResponse.eligible(session)); + } + + @Transactional(readOnly = true) + public RecruitCorePrefillResponse prefill(Long userId) { + String session = recruitCoreSessionResolver.currentSession(); + repository.findByUserIdAndSession(userId, session) + .ifPresent(existing -> { + throw new RecruitCoreAlreadyAppliedException(session, existing.getId()); + }); + + User user = getUser(userId); + return RecruitCorePrefillResponse.from(user); + } + + @Transactional + public RecruitCoreApplicationCreateResponse submit(Long userId, RecruitCoreApplicationCreateRequest request) { + String session = recruitCoreSessionResolver.currentSession(); + repository.findByUserIdAndSession(userId, session) + .ifPresent(existing -> { + throw new RecruitCoreAlreadyAppliedException(session, existing.getId()); + }); + + User user = getUser(userId); + List fileUrls = request.fileUrls() == null + ? List.of() + : List.copyOf(request.fileUrls()); + String cleanPhone = request.snapshot().phone().replaceAll("[^0-9]", ""); + RecruitCoreApplication application = RecruitCoreApplication.builder() + .user(user) + .session(session) + .name(request.snapshot().name()) + .studentId(request.snapshot().studentId()) + .phone(cleanPhone) + .major(request.snapshot().major()) + .email(request.snapshot().email()) + .team(request.team()) + .motivation(request.motivation()) + .wish(request.wish()) + .strengths(request.strengths()) + .pledge(request.pledge()) + .fileUrls(fileUrls) + .resultStatus(RecruitCoreResultStatus.SUBMITTED) + .build(); + + RecruitCoreApplication saved = repository.save(application); + return RecruitCoreApplicationCreateResponse.from(saved); + } + + @Transactional(readOnly = true) + public RecruitCoreMyApplicationResponse getMyApplication(Long userId) { + String session = recruitCoreSessionResolver.currentSession(); + RecruitCoreApplication application = repository.findByUserIdAndSession(userId, session) + .orElseThrow(RecruitCoreApplicationNotFoundException::new); + return RecruitCoreMyApplicationResponse.from(application); + } + + @Transactional(readOnly = true) + public RecruitCoreApplicantDetailResponse getApplicantDetailForViewer( + Long applicationId, + Long viewerId, + UserRole viewerRole + ) { + RecruitCoreApplication application = repository.findById(applicationId) + .orElseThrow(RecruitCoreApplicationNotFoundException::new); + boolean privileged = UserRole.hasAtLeast(viewerRole, UserRole.LEAD); + if (!privileged && !application.isOwnedBy(viewerId)) { + throw new BusinessException(GlobalErrorCode.FORBIDDEN_USER); + } + return RecruitCoreApplicantDetailResponse.from(application); + } + + private RecruitCoreApplication getApplication(Long id) { + return repository.findById(id) + .orElseThrow(() -> new BusinessException(GlobalErrorCode.RESOURCE_NOT_FOUND)); + } + + private User getUser(Long userId) { + return userRepository.findById(userId) + .orElseThrow(() -> new BusinessException(GlobalErrorCode.RESOURCE_NOT_FOUND)); + } + +} diff --git a/src/main/java/inha/gdgoc/domain/recruit/enums/AdmissionSemester.java b/src/main/java/inha/gdgoc/domain/recruit/enums/AdmissionSemester.java deleted file mode 100644 index 4485f2a2..00000000 --- a/src/main/java/inha/gdgoc/domain/recruit/enums/AdmissionSemester.java +++ /dev/null @@ -1,5 +0,0 @@ -package inha.gdgoc.domain.recruit.enums; - -public enum AdmissionSemester { - Y25_1, Y25_2, Y26_1, Y26_2 -} diff --git a/src/main/java/inha/gdgoc/domain/recruit/controller/RecruitMemberController.java b/src/main/java/inha/gdgoc/domain/recruit/member/controller/RecruitMemberController.java similarity index 57% rename from src/main/java/inha/gdgoc/domain/recruit/controller/RecruitMemberController.java rename to src/main/java/inha/gdgoc/domain/recruit/member/controller/RecruitMemberController.java index 9cd59b23..320ba171 100644 --- a/src/main/java/inha/gdgoc/domain/recruit/controller/RecruitMemberController.java +++ b/src/main/java/inha/gdgoc/domain/recruit/member/controller/RecruitMemberController.java @@ -1,29 +1,33 @@ -package inha.gdgoc.domain.recruit.controller; - -import static inha.gdgoc.domain.recruit.controller.message.RecruitMemberMessage.MEMBER_LIST_RETRIEVED_SUCCESS; -import static inha.gdgoc.domain.recruit.controller.message.RecruitMemberMessage.MEMBER_RETRIEVED_SUCCESS; -import static inha.gdgoc.domain.recruit.controller.message.RecruitMemberMessage.MEMBER_SAVE_SUCCESS; -import static inha.gdgoc.domain.recruit.controller.message.RecruitMemberMessage.PAYMENT_MARKED_COMPLETE_SUCCESS; -import static inha.gdgoc.domain.recruit.controller.message.RecruitMemberMessage.PAYMENT_MARKED_INCOMPLETE_SUCCESS; -import static inha.gdgoc.domain.recruit.controller.message.RecruitMemberMessage.PHONE_NUMBER_DUPLICATION_CHECK_SUCCESS; -import static inha.gdgoc.domain.recruit.controller.message.RecruitMemberMessage.STUDENT_ID_DUPLICATION_CHECK_SUCCESS; - -import inha.gdgoc.domain.recruit.dto.request.ApplicationRequest; -import inha.gdgoc.domain.recruit.dto.request.PaymentUpdateRequest; -import inha.gdgoc.domain.recruit.dto.response.CheckPhoneNumberResponse; -import inha.gdgoc.domain.recruit.dto.response.CheckStudentIdResponse; -import inha.gdgoc.domain.recruit.dto.response.RecruitMemberSummaryResponse; -import inha.gdgoc.domain.recruit.dto.response.SpecifiedMemberResponse; -import inha.gdgoc.domain.recruit.entity.RecruitMember; -import inha.gdgoc.domain.recruit.service.RecruitMemberService; +package inha.gdgoc.domain.recruit.member.controller; + +import static inha.gdgoc.domain.recruit.member.controller.message.RecruitMemberMessage.EMAIL_DUPLICATION_CHECK_SUCCESS; +import static inha.gdgoc.domain.recruit.member.controller.message.RecruitMemberMessage.MEMBER_LIST_RETRIEVED_SUCCESS; +import static inha.gdgoc.domain.recruit.member.controller.message.RecruitMemberMessage.MEMBER_RETRIEVED_SUCCESS; +import static inha.gdgoc.domain.recruit.member.controller.message.RecruitMemberMessage.MEMBER_SAVE_SUCCESS; +import static inha.gdgoc.domain.recruit.member.controller.message.RecruitMemberMessage.PAYMENT_MARKED_COMPLETE_SUCCESS; +import static inha.gdgoc.domain.recruit.member.controller.message.RecruitMemberMessage.PAYMENT_MARKED_INCOMPLETE_SUCCESS; +import static inha.gdgoc.domain.recruit.member.controller.message.RecruitMemberMessage.PHONE_NUMBER_DUPLICATION_CHECK_SUCCESS; +import static inha.gdgoc.domain.recruit.member.controller.message.RecruitMemberMessage.STUDENT_ID_DUPLICATION_CHECK_SUCCESS; + +import inha.gdgoc.domain.recruit.member.dto.request.ApplicationRequest; +import inha.gdgoc.domain.recruit.member.dto.request.CheckEmailRequest; +import inha.gdgoc.domain.recruit.member.dto.request.CheckPhoneNumberRequest; +import inha.gdgoc.domain.recruit.member.dto.request.CheckStudentIdRequest; +import inha.gdgoc.domain.recruit.member.dto.request.PaymentUpdateRequest; +import inha.gdgoc.domain.recruit.member.dto.response.CheckEmailResponse; +import inha.gdgoc.domain.recruit.member.dto.response.CheckPhoneNumberResponse; +import inha.gdgoc.domain.recruit.member.dto.response.CheckStudentIdResponse; +import inha.gdgoc.domain.recruit.member.dto.response.RecruitMemberSummaryResponse; +import inha.gdgoc.domain.recruit.member.dto.response.SpecifiedMemberResponse; +import inha.gdgoc.domain.recruit.member.entity.RecruitMember; +import inha.gdgoc.domain.recruit.member.service.RecruitMemberService; import inha.gdgoc.global.dto.response.ApiResponse; import inha.gdgoc.global.dto.response.PageMeta; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Parameter; import io.swagger.v3.oas.annotations.security.SecurityRequirement; import io.swagger.v3.oas.annotations.tags.Tag; -import jakarta.validation.constraints.NotBlank; -import jakarta.validation.constraints.Pattern; +import jakarta.validation.Valid; import java.util.List; import lombok.RequiredArgsConstructor; import org.springframework.data.domain.Page; @@ -31,6 +35,7 @@ import org.springframework.data.domain.Pageable; import org.springframework.data.domain.Sort; import org.springframework.data.domain.Sort.Direction; +import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.web.bind.annotation.GetMapping; @@ -40,17 +45,27 @@ import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RequestPart; import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.multipart.MultipartFile; @Tag(name = "Recruit - Members", description = "리크루팅 지원자 관리 API") -@RequestMapping("/api/v1") +@RequestMapping("/api/v1/recruit/member") @RequiredArgsConstructor @RestController public class RecruitMemberController { + private static final String LEAD_OR_HR_RULE = + "@accessGuard.check(authentication," + + " T(inha.gdgoc.global.security.AccessGuard$AccessCondition).atLeast(" + + "T(inha.gdgoc.domain.user.enums.UserRole).LEAD)," + + " T(inha.gdgoc.global.security.AccessGuard$AccessCondition).of(" + + "T(inha.gdgoc.domain.user.enums.UserRole).CORE," + + " T(inha.gdgoc.domain.user.enums.TeamType).HR))"; + private final RecruitMemberService recruitMemberService; - @PostMapping("/apply") + @PostMapping(value = "/apply", consumes = MediaType.APPLICATION_JSON_VALUE) public ResponseEntity> recruitMemberAdd( @RequestBody ApplicationRequest applicationRequest ) { @@ -59,34 +74,47 @@ public ResponseEntity> recruitMemberAdd( return ResponseEntity.ok(ApiResponse.ok(MEMBER_SAVE_SUCCESS)); } - @GetMapping("/check/student-id") + @PostMapping(value = "/apply", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) + public ResponseEntity> recruitMemberAddMultipart( + @RequestPart("request") ApplicationRequest applicationRequest, + @RequestPart(value = "file", required = false) MultipartFile file + ) { + recruitMemberService.addRecruitMember(applicationRequest); + + return ResponseEntity.ok(ApiResponse.ok(MEMBER_SAVE_SUCCESS)); + } + + @PostMapping("/check/student-id") public ResponseEntity> duplicatedStudentIdDetails( - @RequestParam - @NotBlank(message = "학번은 필수 입력 값입니다.") - @Pattern(regexp = "^12[0-9]{6}$", message = "유효하지 않은 학번 값입니다.") - String studentId + @Valid @RequestBody CheckStudentIdRequest request ) { - CheckStudentIdResponse response = recruitMemberService.isRegisteredStudentId(studentId); + CheckStudentIdResponse response = recruitMemberService.isRegisteredStudentId(request.getStudentId()); return ResponseEntity.ok(ApiResponse.ok(STUDENT_ID_DUPLICATION_CHECK_SUCCESS, response)); } - @GetMapping("/check/phone-number") + @PostMapping("/check/phone-number") public ResponseEntity> duplicatedPhoneNumberDetails( - @RequestParam - @NotBlank(message = "전화번호는 필수 입력 값입니다.") - @Pattern(regexp = "^010-\\d{4}-\\d{4}$", message = "전화번호 형식은 010-XXXX-XXXX 이어야 합니다.") - String phoneNumber + @Valid @RequestBody CheckPhoneNumberRequest request ) { CheckPhoneNumberResponse response = recruitMemberService - .isRegisteredPhoneNumber(phoneNumber); + .isRegisteredPhoneNumber(request.getPhoneNumber()); return ResponseEntity.ok(ApiResponse.ok(PHONE_NUMBER_DUPLICATION_CHECK_SUCCESS, response)); } + @PostMapping("/check/email") + public ResponseEntity> duplicatedEmailDetails( + @Valid @RequestBody CheckEmailRequest request + ) { + CheckEmailResponse response = recruitMemberService.isRegisteredEmail(request.getEmail()); + + return ResponseEntity.ok(ApiResponse.ok(EMAIL_DUPLICATION_CHECK_SUCCESS, response)); + } + @Operation(summary = "특정 멤버 가입 신청서 조회", security = {@SecurityRequirement(name = "BearerAuth")}) - @PreAuthorize("hasAnyRole('LEAD','ORGANIZER','ADMIN') or T(inha.gdgoc.domain.user.enums.TeamType).HR == principal.team") - @GetMapping("/recruit/members/{memberId}") + @PreAuthorize(LEAD_OR_HR_RULE) + @GetMapping("/{memberId}") public ResponseEntity> getSpecifiedMember( @PathVariable Long memberId ) { @@ -100,8 +128,8 @@ public ResponseEntity> getSpecifiedMe description = "설정하려는 상태(NOT 현재 상태)를 body에 보내주세요. true=입금 완료, false=입금 미완료", security = { @SecurityRequirement(name = "BearerAuth") } ) - @PreAuthorize("hasAnyRole('LEAD','ORGANIZER','ADMIN') or T(inha.gdgoc.domain.user.enums.TeamType).HR == principal.team") - @PatchMapping("/recruit/members/{memberId}/payment") + @PreAuthorize(LEAD_OR_HR_RULE) + @PatchMapping("/{memberId}/payment") public ResponseEntity> updatePayment( @PathVariable Long memberId, @RequestBody PaymentUpdateRequest paymentUpdateRequest @@ -122,8 +150,8 @@ public ResponseEntity> updatePayment( description = "전체 목록 또는 이름 검색 결과를 반환합니다. 검색어(question)를 주면 이름 포함 검색, 없으면 전체 조회. sort랑 dir은 example 값 그대로 코딩하는 것 추천...", security = { @SecurityRequirement(name = "BearerAuth") } ) - @PreAuthorize("hasAnyRole('LEAD','ORGANIZER','ADMIN') or T(inha.gdgoc.domain.user.enums.TeamType).HR == principal.team") - @GetMapping("/recruit/members") + @PreAuthorize(LEAD_OR_HR_RULE) + @GetMapping("") public ResponseEntity, PageMeta>> getMembers( @Parameter(description = "검색어(이름 부분 일치). 없으면 전체 조회", example = "소연") @RequestParam(required = false) String question, diff --git a/src/main/java/inha/gdgoc/domain/recruit/controller/message/RecruitMemberMessage.java b/src/main/java/inha/gdgoc/domain/recruit/member/controller/message/RecruitMemberMessage.java similarity index 82% rename from src/main/java/inha/gdgoc/domain/recruit/controller/message/RecruitMemberMessage.java rename to src/main/java/inha/gdgoc/domain/recruit/member/controller/message/RecruitMemberMessage.java index 8e8a3ea8..c883a59f 100644 --- a/src/main/java/inha/gdgoc/domain/recruit/controller/message/RecruitMemberMessage.java +++ b/src/main/java/inha/gdgoc/domain/recruit/member/controller/message/RecruitMemberMessage.java @@ -1,9 +1,10 @@ -package inha.gdgoc.domain.recruit.controller.message; +package inha.gdgoc.domain.recruit.member.controller.message; public class RecruitMemberMessage { public static final String MEMBER_SAVE_SUCCESS = "성공적으로 해당 학기 멤버 가입을 완료했습니다."; public static final String STUDENT_ID_DUPLICATION_CHECK_SUCCESS = "성공적으로 학번 중복 조회를 완료했습니다."; public static final String PHONE_NUMBER_DUPLICATION_CHECK_SUCCESS = "성공적으로 전화번호 중복 조회를 완료했습니다."; + public static final String EMAIL_DUPLICATION_CHECK_SUCCESS = "성공적으로 이메일 중복 조회를 완료했습니다."; public static final String MEMBER_RETRIEVED_SUCCESS = "성공적으로 특정 멤버의 지원서를 조회했습니다."; public static final String PAYMENT_MARKED_COMPLETE_SUCCESS = "성공적으로 입금 완료로 변경했습니다."; public static final String PAYMENT_MARKED_INCOMPLETE_SUCCESS = "성공적으로 입금 미완료로 변경했습니다."; diff --git a/src/main/java/inha/gdgoc/domain/recruit/dto/request/ApplicationRequest.java b/src/main/java/inha/gdgoc/domain/recruit/member/dto/request/ApplicationRequest.java similarity index 83% rename from src/main/java/inha/gdgoc/domain/recruit/dto/request/ApplicationRequest.java rename to src/main/java/inha/gdgoc/domain/recruit/member/dto/request/ApplicationRequest.java index 53a001d2..2f0eb459 100644 --- a/src/main/java/inha/gdgoc/domain/recruit/dto/request/ApplicationRequest.java +++ b/src/main/java/inha/gdgoc/domain/recruit/member/dto/request/ApplicationRequest.java @@ -1,4 +1,4 @@ -package inha.gdgoc.domain.recruit.dto.request; +package inha.gdgoc.domain.recruit.member.dto.request; import java.util.Map; import lombok.AllArgsConstructor; diff --git a/src/main/java/inha/gdgoc/domain/recruit/member/dto/request/CheckEmailRequest.java b/src/main/java/inha/gdgoc/domain/recruit/member/dto/request/CheckEmailRequest.java new file mode 100644 index 00000000..3d1fe236 --- /dev/null +++ b/src/main/java/inha/gdgoc/domain/recruit/member/dto/request/CheckEmailRequest.java @@ -0,0 +1,15 @@ +package inha.gdgoc.domain.recruit.member.dto.request; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Pattern; +import lombok.Getter; +import lombok.Setter; + +@Getter +@Setter +public class CheckEmailRequest { + + @NotBlank(message = "이메일은 필수 입력 값입니다.") + @Pattern(regexp = "^[a-zA-Z0-9+-\\_.]+@[a-zA-Z0-9-]+\\.[a-zA-Z0-9-.]+$", message = "유효하지 않은 이메일 형식입니다.") + private String email; +} diff --git a/src/main/java/inha/gdgoc/domain/recruit/member/dto/request/CheckPhoneNumberRequest.java b/src/main/java/inha/gdgoc/domain/recruit/member/dto/request/CheckPhoneNumberRequest.java new file mode 100644 index 00000000..e79d0128 --- /dev/null +++ b/src/main/java/inha/gdgoc/domain/recruit/member/dto/request/CheckPhoneNumberRequest.java @@ -0,0 +1,14 @@ +package inha.gdgoc.domain.recruit.member.dto.request; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Pattern; +import lombok.Getter; +import lombok.Setter; + +@Getter +@Setter +public class CheckPhoneNumberRequest { + @NotBlank(message = "전화번호는 필수 입력 값입니다.") + @Pattern(regexp = "^010-?\\d{4}-?\\d{4}$", message = "전화번호 형식은 010-XXXX-XXXX 또는 010XXXXXXXX 이어야 합니다.") + private String phoneNumber; +} diff --git a/src/main/java/inha/gdgoc/domain/recruit/member/dto/request/CheckStudentIdRequest.java b/src/main/java/inha/gdgoc/domain/recruit/member/dto/request/CheckStudentIdRequest.java new file mode 100644 index 00000000..ccd0eb68 --- /dev/null +++ b/src/main/java/inha/gdgoc/domain/recruit/member/dto/request/CheckStudentIdRequest.java @@ -0,0 +1,15 @@ +package inha.gdgoc.domain.recruit.member.dto.request; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Pattern; +import lombok.Getter; +import lombok.Setter; + +@Getter +@Setter +public class CheckStudentIdRequest { + + @NotBlank(message = "학번은 필수 입력 값입니다.") + @Pattern(regexp = "^12[0-9]{6}$", message = "유효하지 않은 학번 값입니다.") + private String studentId; +} diff --git a/src/main/java/inha/gdgoc/domain/recruit/dto/request/PaymentUpdateRequest.java b/src/main/java/inha/gdgoc/domain/recruit/member/dto/request/PaymentUpdateRequest.java similarity index 55% rename from src/main/java/inha/gdgoc/domain/recruit/dto/request/PaymentUpdateRequest.java rename to src/main/java/inha/gdgoc/domain/recruit/member/dto/request/PaymentUpdateRequest.java index 3a6f1765..816e4900 100644 --- a/src/main/java/inha/gdgoc/domain/recruit/dto/request/PaymentUpdateRequest.java +++ b/src/main/java/inha/gdgoc/domain/recruit/member/dto/request/PaymentUpdateRequest.java @@ -1,4 +1,4 @@ -package inha.gdgoc.domain.recruit.dto.request; +package inha.gdgoc.domain.recruit.member.dto.request; public record PaymentUpdateRequest( boolean isPayed diff --git a/src/main/java/inha/gdgoc/domain/recruit/dto/request/RecruitMemberRequest.java b/src/main/java/inha/gdgoc/domain/recruit/member/dto/request/RecruitMemberRequest.java similarity index 61% rename from src/main/java/inha/gdgoc/domain/recruit/dto/request/RecruitMemberRequest.java rename to src/main/java/inha/gdgoc/domain/recruit/member/dto/request/RecruitMemberRequest.java index 6f0886cf..6b53560e 100644 --- a/src/main/java/inha/gdgoc/domain/recruit/dto/request/RecruitMemberRequest.java +++ b/src/main/java/inha/gdgoc/domain/recruit/member/dto/request/RecruitMemberRequest.java @@ -1,9 +1,10 @@ -package inha.gdgoc.domain.recruit.dto.request; +package inha.gdgoc.domain.recruit.member.dto.request; -import inha.gdgoc.domain.recruit.entity.RecruitMember; -import inha.gdgoc.domain.recruit.enums.EnrolledClassification; -import inha.gdgoc.domain.recruit.enums.Gender; -import inha.gdgoc.global.util.SemesterCalculator; +import com.fasterxml.jackson.annotation.JsonFormat; +import inha.gdgoc.domain.recruit.member.entity.RecruitMember; +import inha.gdgoc.domain.recruit.member.enums.AdmissionSemester; +import inha.gdgoc.domain.recruit.member.enums.EnrolledClassification; +import inha.gdgoc.domain.recruit.member.enums.Gender; import java.time.LocalDate; import lombok.AllArgsConstructor; import lombok.Builder; @@ -23,26 +24,26 @@ public class RecruitMemberRequest { private String nationality; private String email; private String gender; + @JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy.MM.dd") private LocalDate birth; private String major; - private String doubleMajor; private Boolean isPayed; - public RecruitMember toEntity() { + public RecruitMember toEntity(AdmissionSemester admissionSemester) { + String cleanPhone = phoneNumber.replaceAll("[^0-9]", ""); return RecruitMember.builder() .name(name) .grade(grade) .studentId(studentId) .enrolledClassification(EnrolledClassification.fromStatus(enrolledClassification)) - .phoneNumber(phoneNumber) + .phoneNumber(cleanPhone) .nationality(nationality) .email(email) .gender(Gender.fromType(gender)) .birth(birth) .major(major) - .doubleMajor(doubleMajor) .isPayed(false) - .admissionSemester(SemesterCalculator.currentSemester()) + .admissionSemester(admissionSemester) .build(); } } diff --git a/src/main/java/inha/gdgoc/domain/recruit/dto/response/AnswerResponse.java b/src/main/java/inha/gdgoc/domain/recruit/member/dto/response/AnswerResponse.java similarity index 89% rename from src/main/java/inha/gdgoc/domain/recruit/dto/response/AnswerResponse.java rename to src/main/java/inha/gdgoc/domain/recruit/member/dto/response/AnswerResponse.java index 3ac3d171..e8688e84 100644 --- a/src/main/java/inha/gdgoc/domain/recruit/dto/response/AnswerResponse.java +++ b/src/main/java/inha/gdgoc/domain/recruit/member/dto/response/AnswerResponse.java @@ -1,10 +1,10 @@ -package inha.gdgoc.domain.recruit.dto.response; +package inha.gdgoc.domain.recruit.member.dto.response; import com.fasterxml.jackson.core.type.TypeReference; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; -import inha.gdgoc.domain.recruit.entity.Answer; -import inha.gdgoc.domain.recruit.enums.InputType; +import inha.gdgoc.domain.recruit.member.entity.Answer; +import inha.gdgoc.domain.recruit.member.enums.InputType; import java.util.List; import java.util.Map; diff --git a/src/main/java/inha/gdgoc/domain/recruit/dto/response/AnswersResponse.java b/src/main/java/inha/gdgoc/domain/recruit/member/dto/response/AnswersResponse.java similarity index 79% rename from src/main/java/inha/gdgoc/domain/recruit/dto/response/AnswersResponse.java rename to src/main/java/inha/gdgoc/domain/recruit/member/dto/response/AnswersResponse.java index 896c8cc8..d3f9bfca 100644 --- a/src/main/java/inha/gdgoc/domain/recruit/dto/response/AnswersResponse.java +++ b/src/main/java/inha/gdgoc/domain/recruit/member/dto/response/AnswersResponse.java @@ -1,7 +1,7 @@ -package inha.gdgoc.domain.recruit.dto.response; +package inha.gdgoc.domain.recruit.member.dto.response; import com.fasterxml.jackson.databind.ObjectMapper; -import inha.gdgoc.domain.recruit.entity.Answer; +import inha.gdgoc.domain.recruit.member.entity.Answer; import java.util.List; public record AnswersResponse( diff --git a/src/main/java/inha/gdgoc/domain/recruit/member/dto/response/CheckEmailResponse.java b/src/main/java/inha/gdgoc/domain/recruit/member/dto/response/CheckEmailResponse.java new file mode 100644 index 00000000..0bb9cc45 --- /dev/null +++ b/src/main/java/inha/gdgoc/domain/recruit/member/dto/response/CheckEmailResponse.java @@ -0,0 +1,4 @@ +package inha.gdgoc.domain.recruit.member.dto.response; + +public record CheckEmailResponse(boolean isExists) { +} diff --git a/src/main/java/inha/gdgoc/domain/recruit/member/dto/response/CheckPhoneNumberResponse.java b/src/main/java/inha/gdgoc/domain/recruit/member/dto/response/CheckPhoneNumberResponse.java new file mode 100644 index 00000000..8ad4cdba --- /dev/null +++ b/src/main/java/inha/gdgoc/domain/recruit/member/dto/response/CheckPhoneNumberResponse.java @@ -0,0 +1,5 @@ +package inha.gdgoc.domain.recruit.member.dto.response; + +public record CheckPhoneNumberResponse(boolean isExists) { + +} diff --git a/src/main/java/inha/gdgoc/domain/recruit/member/dto/response/CheckStudentIdResponse.java b/src/main/java/inha/gdgoc/domain/recruit/member/dto/response/CheckStudentIdResponse.java new file mode 100644 index 00000000..77c4de04 --- /dev/null +++ b/src/main/java/inha/gdgoc/domain/recruit/member/dto/response/CheckStudentIdResponse.java @@ -0,0 +1,5 @@ +package inha.gdgoc.domain.recruit.member.dto.response; + +public record CheckStudentIdResponse(boolean isExists) { + +} diff --git a/src/main/java/inha/gdgoc/domain/recruit/dto/response/RecruitMemberSummaryResponse.java b/src/main/java/inha/gdgoc/domain/recruit/member/dto/response/RecruitMemberSummaryResponse.java similarity index 88% rename from src/main/java/inha/gdgoc/domain/recruit/dto/response/RecruitMemberSummaryResponse.java rename to src/main/java/inha/gdgoc/domain/recruit/member/dto/response/RecruitMemberSummaryResponse.java index 1e6618ca..8b078eda 100644 --- a/src/main/java/inha/gdgoc/domain/recruit/dto/response/RecruitMemberSummaryResponse.java +++ b/src/main/java/inha/gdgoc/domain/recruit/member/dto/response/RecruitMemberSummaryResponse.java @@ -1,6 +1,6 @@ -package inha.gdgoc.domain.recruit.dto.response; +package inha.gdgoc.domain.recruit.member.dto.response; -import inha.gdgoc.domain.recruit.entity.RecruitMember; +import inha.gdgoc.domain.recruit.member.entity.RecruitMember; public record RecruitMemberSummaryResponse( Long id, diff --git a/src/main/java/inha/gdgoc/domain/recruit/dto/response/SpecifiedMemberResponse.java b/src/main/java/inha/gdgoc/domain/recruit/member/dto/response/SpecifiedMemberResponse.java similarity index 80% rename from src/main/java/inha/gdgoc/domain/recruit/dto/response/SpecifiedMemberResponse.java rename to src/main/java/inha/gdgoc/domain/recruit/member/dto/response/SpecifiedMemberResponse.java index 838bd30e..11e912aa 100644 --- a/src/main/java/inha/gdgoc/domain/recruit/dto/response/SpecifiedMemberResponse.java +++ b/src/main/java/inha/gdgoc/domain/recruit/member/dto/response/SpecifiedMemberResponse.java @@ -1,8 +1,8 @@ -package inha.gdgoc.domain.recruit.dto.response; +package inha.gdgoc.domain.recruit.member.dto.response; import com.fasterxml.jackson.databind.ObjectMapper; -import inha.gdgoc.domain.recruit.entity.Answer; -import inha.gdgoc.domain.recruit.entity.RecruitMember; +import inha.gdgoc.domain.recruit.member.entity.Answer; +import inha.gdgoc.domain.recruit.member.entity.RecruitMember; import java.util.List; public record SpecifiedMemberResponse( diff --git a/src/main/java/inha/gdgoc/domain/recruit/entity/Answer.java b/src/main/java/inha/gdgoc/domain/recruit/member/entity/Answer.java similarity index 91% rename from src/main/java/inha/gdgoc/domain/recruit/entity/Answer.java rename to src/main/java/inha/gdgoc/domain/recruit/member/entity/Answer.java index 924089c6..763b67a3 100644 --- a/src/main/java/inha/gdgoc/domain/recruit/entity/Answer.java +++ b/src/main/java/inha/gdgoc/domain/recruit/member/entity/Answer.java @@ -1,7 +1,7 @@ -package inha.gdgoc.domain.recruit.entity; +package inha.gdgoc.domain.recruit.member.entity; -import inha.gdgoc.domain.recruit.enums.InputType; -import inha.gdgoc.domain.recruit.enums.SurveyType; +import inha.gdgoc.domain.recruit.member.enums.InputType; +import inha.gdgoc.domain.recruit.member.enums.SurveyType; import inha.gdgoc.global.entity.BaseEntity; import jakarta.persistence.Column; import jakarta.persistence.Entity; diff --git a/src/main/java/inha/gdgoc/domain/recruit/entity/RecruitMember.java b/src/main/java/inha/gdgoc/domain/recruit/member/entity/RecruitMember.java similarity index 89% rename from src/main/java/inha/gdgoc/domain/recruit/entity/RecruitMember.java rename to src/main/java/inha/gdgoc/domain/recruit/member/entity/RecruitMember.java index b09d9789..6b8c9031 100644 --- a/src/main/java/inha/gdgoc/domain/recruit/entity/RecruitMember.java +++ b/src/main/java/inha/gdgoc/domain/recruit/member/entity/RecruitMember.java @@ -1,9 +1,9 @@ -package inha.gdgoc.domain.recruit.entity; +package inha.gdgoc.domain.recruit.member.entity; import com.fasterxml.jackson.annotation.JsonFormat; -import inha.gdgoc.domain.recruit.enums.AdmissionSemester; -import inha.gdgoc.domain.recruit.enums.EnrolledClassification; -import inha.gdgoc.domain.recruit.enums.Gender; +import inha.gdgoc.domain.recruit.member.enums.AdmissionSemester; +import inha.gdgoc.domain.recruit.member.enums.EnrolledClassification; +import inha.gdgoc.domain.recruit.member.enums.Gender; import inha.gdgoc.global.entity.BaseEntity; import jakarta.persistence.CascadeType; import jakarta.persistence.Column; @@ -61,16 +61,13 @@ public class RecruitMember extends BaseEntity { @Column(name = "gender", nullable = false) private Gender gender; - @JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd") + @JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy.MM.dd") @Column(name = "birth", nullable = false) private LocalDate birth; @Column(name = "major", nullable = false) private String major; - @Column(name = "double_major") - private String doubleMajor; - @Column(name = "is_payed", nullable = false) private Boolean isPayed; diff --git a/src/main/java/inha/gdgoc/domain/recruit/member/enums/AdmissionSemester.java b/src/main/java/inha/gdgoc/domain/recruit/member/enums/AdmissionSemester.java new file mode 100644 index 00000000..1bb68b57 --- /dev/null +++ b/src/main/java/inha/gdgoc/domain/recruit/member/enums/AdmissionSemester.java @@ -0,0 +1,5 @@ +package inha.gdgoc.domain.recruit.member.enums; + +public enum AdmissionSemester { + Y21_2, Y22_1, Y22_2, Y23_1, Y23_2, Y24_1, Y24_2, Y25_1, Y25_2, Y26_1 +} diff --git a/src/main/java/inha/gdgoc/domain/recruit/enums/EnrolledClassification.java b/src/main/java/inha/gdgoc/domain/recruit/member/enums/EnrolledClassification.java similarity index 85% rename from src/main/java/inha/gdgoc/domain/recruit/enums/EnrolledClassification.java rename to src/main/java/inha/gdgoc/domain/recruit/member/enums/EnrolledClassification.java index 3e0089d7..133747ae 100644 --- a/src/main/java/inha/gdgoc/domain/recruit/enums/EnrolledClassification.java +++ b/src/main/java/inha/gdgoc/domain/recruit/member/enums/EnrolledClassification.java @@ -1,11 +1,12 @@ -package inha.gdgoc.domain.recruit.enums; +package inha.gdgoc.domain.recruit.member.enums; import lombok.Getter; @Getter public enum EnrolledClassification { - FULL_REGISTRATION("정등록"), + FULL_REGISTRATION("재학"), LEAVE_OF_ABSENCE("휴학"), + MILITARY_LEAVE("군휴학"), GRADUATION("졸업"), PARTIAL_REGISTRATION("부분등록"), COMPLETION("수료"); diff --git a/src/main/java/inha/gdgoc/domain/recruit/enums/Gender.java b/src/main/java/inha/gdgoc/domain/recruit/member/enums/Gender.java similarity index 78% rename from src/main/java/inha/gdgoc/domain/recruit/enums/Gender.java rename to src/main/java/inha/gdgoc/domain/recruit/member/enums/Gender.java index 9a5c6ac9..f4d15a56 100644 --- a/src/main/java/inha/gdgoc/domain/recruit/enums/Gender.java +++ b/src/main/java/inha/gdgoc/domain/recruit/member/enums/Gender.java @@ -1,11 +1,12 @@ -package inha.gdgoc.domain.recruit.enums; +package inha.gdgoc.domain.recruit.member.enums; import lombok.Getter; @Getter public enum Gender { - MALE("남자"), - FEMALE("여자"); + MALE("남성"), + FEMALE("여성"), + PRIVATE("비공개"); private final String type; diff --git a/src/main/java/inha/gdgoc/domain/recruit/enums/InputType.java b/src/main/java/inha/gdgoc/domain/recruit/member/enums/InputType.java similarity index 94% rename from src/main/java/inha/gdgoc/domain/recruit/enums/InputType.java rename to src/main/java/inha/gdgoc/domain/recruit/member/enums/InputType.java index ed9f5cc3..40db4f2c 100644 --- a/src/main/java/inha/gdgoc/domain/recruit/enums/InputType.java +++ b/src/main/java/inha/gdgoc/domain/recruit/member/enums/InputType.java @@ -1,4 +1,4 @@ -package inha.gdgoc.domain.recruit.enums; +package inha.gdgoc.domain.recruit.member.enums; import lombok.Getter; diff --git a/src/main/java/inha/gdgoc/domain/recruit/enums/SurveyType.java b/src/main/java/inha/gdgoc/domain/recruit/member/enums/SurveyType.java similarity index 92% rename from src/main/java/inha/gdgoc/domain/recruit/enums/SurveyType.java rename to src/main/java/inha/gdgoc/domain/recruit/member/enums/SurveyType.java index 5c4c9014..667942f8 100644 --- a/src/main/java/inha/gdgoc/domain/recruit/enums/SurveyType.java +++ b/src/main/java/inha/gdgoc/domain/recruit/member/enums/SurveyType.java @@ -1,4 +1,4 @@ -package inha.gdgoc.domain.recruit.enums; +package inha.gdgoc.domain.recruit.member.enums; import lombok.Getter; diff --git a/src/main/java/inha/gdgoc/domain/recruit/exception/RecruitMemberErrorCode.java b/src/main/java/inha/gdgoc/domain/recruit/member/exception/RecruitMemberErrorCode.java similarity index 91% rename from src/main/java/inha/gdgoc/domain/recruit/exception/RecruitMemberErrorCode.java rename to src/main/java/inha/gdgoc/domain/recruit/member/exception/RecruitMemberErrorCode.java index e78520aa..f1b0c431 100644 --- a/src/main/java/inha/gdgoc/domain/recruit/exception/RecruitMemberErrorCode.java +++ b/src/main/java/inha/gdgoc/domain/recruit/member/exception/RecruitMemberErrorCode.java @@ -1,4 +1,4 @@ -package inha.gdgoc.domain.recruit.exception; +package inha.gdgoc.domain.recruit.member.exception; import inha.gdgoc.global.exception.ErrorCode; import lombok.RequiredArgsConstructor; diff --git a/src/main/java/inha/gdgoc/domain/recruit/exception/RecruitMemberException.java b/src/main/java/inha/gdgoc/domain/recruit/member/exception/RecruitMemberException.java similarity index 83% rename from src/main/java/inha/gdgoc/domain/recruit/exception/RecruitMemberException.java rename to src/main/java/inha/gdgoc/domain/recruit/member/exception/RecruitMemberException.java index 8d07d430..2c5800c4 100644 --- a/src/main/java/inha/gdgoc/domain/recruit/exception/RecruitMemberException.java +++ b/src/main/java/inha/gdgoc/domain/recruit/member/exception/RecruitMemberException.java @@ -1,4 +1,4 @@ -package inha.gdgoc.domain.recruit.exception; +package inha.gdgoc.domain.recruit.member.exception; import inha.gdgoc.global.exception.BusinessException; import inha.gdgoc.global.exception.ErrorCode; diff --git a/src/main/java/inha/gdgoc/domain/recruit/repository/AnswerRepository.java b/src/main/java/inha/gdgoc/domain/recruit/member/repository/AnswerRepository.java similarity index 56% rename from src/main/java/inha/gdgoc/domain/recruit/repository/AnswerRepository.java rename to src/main/java/inha/gdgoc/domain/recruit/member/repository/AnswerRepository.java index 3ae036d5..ad00ce90 100644 --- a/src/main/java/inha/gdgoc/domain/recruit/repository/AnswerRepository.java +++ b/src/main/java/inha/gdgoc/domain/recruit/member/repository/AnswerRepository.java @@ -1,8 +1,8 @@ -package inha.gdgoc.domain.recruit.repository; +package inha.gdgoc.domain.recruit.member.repository; -import inha.gdgoc.domain.recruit.entity.Answer; -import inha.gdgoc.domain.recruit.entity.RecruitMember; -import inha.gdgoc.domain.recruit.enums.SurveyType; +import inha.gdgoc.domain.recruit.member.entity.Answer; +import inha.gdgoc.domain.recruit.member.entity.RecruitMember; +import inha.gdgoc.domain.recruit.member.enums.SurveyType; import java.util.List; import org.springframework.data.jpa.repository.JpaRepository; diff --git a/src/main/java/inha/gdgoc/domain/recruit/repository/RecruitMemberRepository.java b/src/main/java/inha/gdgoc/domain/recruit/member/repository/RecruitMemberRepository.java similarity index 72% rename from src/main/java/inha/gdgoc/domain/recruit/repository/RecruitMemberRepository.java rename to src/main/java/inha/gdgoc/domain/recruit/member/repository/RecruitMemberRepository.java index 04c0c089..b6d013c2 100644 --- a/src/main/java/inha/gdgoc/domain/recruit/repository/RecruitMemberRepository.java +++ b/src/main/java/inha/gdgoc/domain/recruit/member/repository/RecruitMemberRepository.java @@ -1,6 +1,6 @@ -package inha.gdgoc.domain.recruit.repository; +package inha.gdgoc.domain.recruit.member.repository; -import inha.gdgoc.domain.recruit.entity.RecruitMember; +import inha.gdgoc.domain.recruit.member.entity.RecruitMember; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; @@ -8,5 +8,6 @@ public interface RecruitMemberRepository extends JpaRepository { boolean existsByStudentId(String studentId); boolean existsByPhoneNumber(String phoneNumber); + boolean existsByEmailIgnoreCase(String email); Page findByNameContainingIgnoreCase(String name, Pageable pageable); } diff --git a/src/main/java/inha/gdgoc/domain/recruit/service/RecruitMemberService.java b/src/main/java/inha/gdgoc/domain/recruit/member/service/RecruitMemberService.java similarity index 66% rename from src/main/java/inha/gdgoc/domain/recruit/service/RecruitMemberService.java rename to src/main/java/inha/gdgoc/domain/recruit/member/service/RecruitMemberService.java index 3d7a7f5d..cefa022b 100644 --- a/src/main/java/inha/gdgoc/domain/recruit/service/RecruitMemberService.java +++ b/src/main/java/inha/gdgoc/domain/recruit/member/service/RecruitMemberService.java @@ -1,19 +1,21 @@ -package inha.gdgoc.domain.recruit.service; +package inha.gdgoc.domain.recruit.member.service; -import static inha.gdgoc.domain.recruit.exception.RecruitMemberErrorCode.RECRUIT_MEMBER_NOT_FOUND; +import static inha.gdgoc.domain.recruit.member.exception.RecruitMemberErrorCode.RECRUIT_MEMBER_NOT_FOUND; import com.fasterxml.jackson.databind.ObjectMapper; -import inha.gdgoc.domain.recruit.dto.request.ApplicationRequest; -import inha.gdgoc.domain.recruit.dto.response.CheckPhoneNumberResponse; -import inha.gdgoc.domain.recruit.dto.response.CheckStudentIdResponse; -import inha.gdgoc.domain.recruit.dto.response.SpecifiedMemberResponse; -import inha.gdgoc.domain.recruit.entity.Answer; -import inha.gdgoc.domain.recruit.entity.RecruitMember; -import inha.gdgoc.domain.recruit.enums.InputType; -import inha.gdgoc.domain.recruit.enums.SurveyType; -import inha.gdgoc.domain.recruit.exception.RecruitMemberException; -import inha.gdgoc.domain.recruit.repository.AnswerRepository; -import inha.gdgoc.domain.recruit.repository.RecruitMemberRepository; +import inha.gdgoc.domain.recruit.member.dto.request.ApplicationRequest; +import inha.gdgoc.domain.recruit.member.dto.response.CheckEmailResponse; +import inha.gdgoc.domain.recruit.member.dto.response.CheckPhoneNumberResponse; +import inha.gdgoc.domain.recruit.member.dto.response.CheckStudentIdResponse; +import inha.gdgoc.domain.recruit.member.dto.response.SpecifiedMemberResponse; +import inha.gdgoc.domain.recruit.member.entity.Answer; +import inha.gdgoc.domain.recruit.member.entity.RecruitMember; +import inha.gdgoc.domain.recruit.member.enums.InputType; +import inha.gdgoc.domain.recruit.member.enums.SurveyType; +import inha.gdgoc.domain.recruit.member.exception.RecruitMemberException; +import inha.gdgoc.domain.recruit.member.repository.AnswerRepository; +import inha.gdgoc.domain.recruit.member.repository.RecruitMemberRepository; +import inha.gdgoc.global.util.SemesterCalculator; import java.util.List; import lombok.RequiredArgsConstructor; import org.springframework.data.domain.Page; @@ -27,10 +29,12 @@ public class RecruitMemberService { private final RecruitMemberRepository recruitMemberRepository; private final AnswerRepository answerRepository; private final ObjectMapper objectMapper; + private final SemesterCalculator semesterCalculator; @Transactional public void addRecruitMember(ApplicationRequest applicationRequest) { - RecruitMember member = applicationRequest.getMember().toEntity(); + RecruitMember member = applicationRequest.getMember() + .toEntity(semesterCalculator.currentSemester()); recruitMemberRepository.save(member); List answers = applicationRequest.getAnswers().entrySet().stream() @@ -56,11 +60,18 @@ public CheckStudentIdResponse isRegisteredStudentId(String studentId) { } public CheckPhoneNumberResponse isRegisteredPhoneNumber(String phoneNumber) { - boolean exists = recruitMemberRepository.existsByPhoneNumber(phoneNumber); + String cleanPhone = phoneNumber.replaceAll("[^0-9]", ""); + boolean exists = recruitMemberRepository.existsByPhoneNumber(cleanPhone); return new CheckPhoneNumberResponse(exists); } + public CheckEmailResponse isRegisteredEmail(String email) { + boolean exists = recruitMemberRepository.existsByEmailIgnoreCase(email.trim()); + + return new CheckEmailResponse(exists); + } + 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/domain/resource/controller/ResourceController.java b/src/main/java/inha/gdgoc/domain/resource/controller/ResourceController.java index 208a6290..da2d07d0 100644 --- a/src/main/java/inha/gdgoc/domain/resource/controller/ResourceController.java +++ b/src/main/java/inha/gdgoc/domain/resource/controller/ResourceController.java @@ -2,14 +2,10 @@ import static inha.gdgoc.domain.resource.controller.message.ResourceMessage.IMAGE_SAVE_SUCCESS; -import inha.gdgoc.domain.auth.service.AuthService; import inha.gdgoc.domain.resource.dto.response.S3ResultResponse; import inha.gdgoc.domain.resource.enums.S3KeyType; -import inha.gdgoc.domain.resource.exception.ResourceErrorCode; -import inha.gdgoc.domain.resource.exception.ResourceException; -import inha.gdgoc.domain.resource.service.S3Service; +import inha.gdgoc.domain.resource.service.ResourceService; import inha.gdgoc.global.dto.response.ApiResponse; -import java.io.IOException; import lombok.RequiredArgsConstructor; import org.springframework.http.ResponseEntity; import org.springframework.security.core.Authentication; @@ -24,30 +20,15 @@ @RequiredArgsConstructor public class ResourceController { - private static final long MAX_FILE_SIZE = 10 * 1024 * 1024; + private final ResourceService resourceService; - private final S3Service s3Service; - private final AuthService authService; - - // TODO 책임 분리 @PostMapping("/image") public ResponseEntity> uploadImage( Authentication authentication, @RequestParam("file") MultipartFile file, @RequestParam("s3key") S3KeyType s3key ) { - if (file.getSize() > MAX_FILE_SIZE) { - throw new ResourceException(ResourceErrorCode.INVALID_BIG_FILE); - } - - Long userId = authService.getAuthenticationUserId(authentication); - try { - String result_s3Key = s3Service.upload(userId, s3key, file); - S3ResultResponse response = new S3ResultResponse(result_s3Key); - - return ResponseEntity.ok(ApiResponse.ok(IMAGE_SAVE_SUCCESS, response)); - } catch (IOException e) { - throw new RuntimeException("s3 upload fail" + e); - } + S3ResultResponse response = resourceService.uploadImage(authentication, file, s3key); + return ResponseEntity.ok(ApiResponse.ok(IMAGE_SAVE_SUCCESS, response)); } } diff --git a/src/main/java/inha/gdgoc/domain/resource/exception/ResourceErrorCode.java b/src/main/java/inha/gdgoc/domain/resource/exception/ResourceErrorCode.java index 00a93d6f..8b103428 100644 --- a/src/main/java/inha/gdgoc/domain/resource/exception/ResourceErrorCode.java +++ b/src/main/java/inha/gdgoc/domain/resource/exception/ResourceErrorCode.java @@ -6,7 +6,8 @@ public enum ResourceErrorCode implements ErrorCode { // 413 - INVALID_BIG_FILE(HttpStatus.PAYLOAD_TOO_LARGE, "파일 크기는 10Mb를 넘을 수 없습니다."); + INVALID_BIG_FILE(HttpStatus.PAYLOAD_TOO_LARGE, "파일 크기는 10Mb를 넘을 수 없습니다."), + RESOURCE_UPLOAD_FAILED(HttpStatus.INTERNAL_SERVER_ERROR, "파일 업로드에 실패했습니다."); private final HttpStatus status; private final String message; diff --git a/src/main/java/inha/gdgoc/domain/resource/service/ResourceService.java b/src/main/java/inha/gdgoc/domain/resource/service/ResourceService.java new file mode 100644 index 00000000..4ce1d077 --- /dev/null +++ b/src/main/java/inha/gdgoc/domain/resource/service/ResourceService.java @@ -0,0 +1,38 @@ +package inha.gdgoc.domain.resource.service; + +import inha.gdgoc.domain.auth.service.AuthService; +import inha.gdgoc.domain.resource.dto.response.S3ResultResponse; +import inha.gdgoc.domain.resource.enums.S3KeyType; +import inha.gdgoc.domain.resource.exception.ResourceErrorCode; +import inha.gdgoc.domain.resource.exception.ResourceException; +import java.io.IOException; +import lombok.RequiredArgsConstructor; +import org.springframework.security.core.Authentication; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.multipart.MultipartFile; + +@Service +@RequiredArgsConstructor +public class ResourceService { + + private static final long MAX_FILE_SIZE = 10 * 1024 * 1024; + + private final S3Service s3Service; + private final AuthService authService; + + @Transactional + public S3ResultResponse uploadImage(Authentication authentication, MultipartFile file, S3KeyType s3KeyType) { + if (file.getSize() > MAX_FILE_SIZE) { + throw new ResourceException(ResourceErrorCode.INVALID_BIG_FILE); + } + + Long userId = authService.getAuthenticationUserId(authentication); + try { + String savedS3Key = s3Service.upload(userId, s3KeyType, file); + return new S3ResultResponse(savedS3Key); + } catch (IOException e) { + throw new ResourceException(ResourceErrorCode.RESOURCE_UPLOAD_FAILED); + } + } +} diff --git a/src/main/java/inha/gdgoc/domain/resource/service/S3Service.java b/src/main/java/inha/gdgoc/domain/resource/service/S3Service.java index 15464313..6f4a2b29 100644 --- a/src/main/java/inha/gdgoc/domain/resource/service/S3Service.java +++ b/src/main/java/inha/gdgoc/domain/resource/service/S3Service.java @@ -1,10 +1,10 @@ package inha.gdgoc.domain.resource.service; import inha.gdgoc.domain.resource.enums.S3KeyType; +import inha.gdgoc.global.config.s3.S3Properties; import java.io.IOException; import java.util.UUID; import lombok.RequiredArgsConstructor; -import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Service; import org.springframework.web.multipart.MultipartFile; import software.amazon.awssdk.core.sync.RequestBody; @@ -17,16 +17,14 @@ public class S3Service { private final S3Client s3Client; - - @Value("${cloud.aws.s3.bucket}") - private String bucketName; + private final S3Properties s3Properties; public String upload(Long userId, S3KeyType s3key, MultipartFile file) throws IOException { String fileName = UUID.randomUUID() + "-" + file.getOriginalFilename(); String key = "user/%d/%s/%s".formatted(userId, s3key.getValue(), fileName); PutObjectRequest putReq = PutObjectRequest.builder() - .bucket(bucketName) + .bucket(s3Properties.getBucket()) .key(key) .contentType(file.getContentType()) .build(); @@ -37,7 +35,7 @@ public String upload(Long userId, S3KeyType s3key, MultipartFile file) throws IO public String getS3FileUrl(String key) { return s3Client.utilities() - .getUrl(GetUrlRequest.builder().bucket(bucketName).key(key).build()) + .getUrl(GetUrlRequest.builder().bucket(s3Properties.getBucket()).key(key).build()) .toExternalForm(); } } diff --git a/src/main/java/inha/gdgoc/domain/test/controller/TestController.java b/src/main/java/inha/gdgoc/domain/test/controller/TestController.java deleted file mode 100644 index ae5b1214..00000000 --- a/src/main/java/inha/gdgoc/domain/test/controller/TestController.java +++ /dev/null @@ -1,31 +0,0 @@ -package inha.gdgoc.domain.test.controller; - -import inha.gdgoc.global.dto.response.ApiResponse; -import java.util.Map; -import org.springframework.http.ResponseEntity; -import org.springframework.web.bind.annotation.CookieValue; -import org.springframework.web.bind.annotation.GetMapping; -import org.springframework.web.bind.annotation.RequestHeader; -import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RestController; - -@RestController -@RequestMapping("/api/v1/test") -public class TestController { - - @GetMapping("/login_test") - public ResponseEntity, Void>> loginTest( - @CookieValue(value = "refresh_token", required = false) String refreshToken, - @RequestHeader(value = "Authorization", required = false) String authorization - ) { - boolean hasRefreshToken = refreshToken != null && !refreshToken.isBlank(); - boolean hasAuthorization = authorization != null && !authorization.isBlank(); - - Map data = Map.of( - "has_refresh_token", hasRefreshToken, - "has_authorization", hasAuthorization - ); - - return ResponseEntity.ok(ApiResponse.ok("LOGIN_TEST_OK", data)); - } -} diff --git a/src/main/java/inha/gdgoc/domain/user/controller/UserController.java b/src/main/java/inha/gdgoc/domain/user/controller/UserController.java index 56b6003c..d8af2aa7 100644 --- a/src/main/java/inha/gdgoc/domain/user/controller/UserController.java +++ b/src/main/java/inha/gdgoc/domain/user/controller/UserController.java @@ -1,18 +1,14 @@ package inha.gdgoc.domain.user.controller; -import static inha.gdgoc.domain.user.controller.message.UserMessage.USER_CREATE_SUCCESS; import static inha.gdgoc.domain.user.controller.message.UserMessage.USER_EMAIL_DUPLICATION_RETRIEVED_SUCCESS; import static inha.gdgoc.domain.user.controller.message.UserMessage.USER_EMAIL_RETRIEVED_SUCCESS; import inha.gdgoc.domain.auth.dto.request.FindIdRequest; import inha.gdgoc.domain.user.dto.request.CheckDuplicatedEmailRequest; -import inha.gdgoc.domain.user.dto.request.UserSignupRequest; import inha.gdgoc.domain.auth.dto.response.FindIdResponse; import inha.gdgoc.domain.user.dto.response.CheckDuplicatedEmailResponse; import inha.gdgoc.domain.user.service.UserService; import inha.gdgoc.global.dto.response.ApiResponse; -import java.security.InvalidKeyException; -import java.security.NoSuchAlgorithmException; import lombok.RequiredArgsConstructor; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.GetMapping; @@ -29,7 +25,7 @@ public class UserController { private final UserService userService; - // TODO 진짜 돌았냐? POST로 바꿔라 + // 이메일 중복 체크 @GetMapping("/auth/check") public ResponseEntity> checkDuplicatedEmail( @RequestParam String email @@ -40,15 +36,8 @@ public ResponseEntity> checkDupl return ResponseEntity.ok(ApiResponse.ok(USER_EMAIL_DUPLICATION_RETRIEVED_SUCCESS, response)); } - @PostMapping("/auth/signup") - public ResponseEntity> userSignup( - @RequestBody UserSignupRequest userSignupRequest - ) throws NoSuchAlgorithmException, InvalidKeyException { - userService.saveUser(userSignupRequest); - - return ResponseEntity.ok(ApiResponse.ok(USER_CREATE_SUCCESS)); - } + // 아이디(이메일) 찾기 @PostMapping("/auth/findId") public ResponseEntity> findEmail( @RequestBody FindIdRequest findIdRequest @@ -57,4 +46,4 @@ public ResponseEntity> findEmail( return ResponseEntity.ok(ApiResponse.ok(USER_EMAIL_RETRIEVED_SUCCESS, response)); } -} +} \ No newline at end of file diff --git a/src/main/java/inha/gdgoc/domain/user/dto/request/UpdateUserRoleTeamRequest.java b/src/main/java/inha/gdgoc/domain/user/dto/request/UpdateUserRoleTeamRequest.java deleted file mode 100644 index 3b942e61..00000000 --- a/src/main/java/inha/gdgoc/domain/user/dto/request/UpdateUserRoleTeamRequest.java +++ /dev/null @@ -1,9 +0,0 @@ -package inha.gdgoc.domain.user.dto.request; - -import inha.gdgoc.domain.user.enums.TeamType; -import inha.gdgoc.domain.user.enums.UserRole; - -public record UpdateUserRoleTeamRequest( - UserRole role, // null 이면 변경 안 함 - TeamType team // null 이면 변경 안 함 -) {} \ No newline at end of file diff --git a/src/main/java/inha/gdgoc/domain/user/dto/request/UserSignupRequest.java b/src/main/java/inha/gdgoc/domain/user/dto/request/UserSignupRequest.java deleted file mode 100644 index 13907d09..00000000 --- a/src/main/java/inha/gdgoc/domain/user/dto/request/UserSignupRequest.java +++ /dev/null @@ -1,30 +0,0 @@ -package inha.gdgoc.domain.user.dto.request; - -import inha.gdgoc.domain.user.entity.User; -import inha.gdgoc.domain.user.enums.UserRole; -import lombok.Getter; -import lombok.Setter; - -@Getter -@Setter -public class UserSignupRequest { - private String name; - private String major; - private String studentId; - private String phoneNumber; - private String email; - private String password; - - public User toEntity(String hashedPassword, byte[] salt) { - return User.builder() - .name(name) - .major(major) - .studentId(studentId) - .phoneNumber(phoneNumber) - .email(email) - .password(hashedPassword) - .salt(salt) - .userRole(UserRole.GUEST) - .build(); - } -} diff --git a/src/main/java/inha/gdgoc/domain/user/entity/User.java b/src/main/java/inha/gdgoc/domain/user/entity/User.java index dcff2f1d..66102c8e 100644 --- a/src/main/java/inha/gdgoc/domain/user/entity/User.java +++ b/src/main/java/inha/gdgoc/domain/user/entity/User.java @@ -24,6 +24,7 @@ import lombok.Builder; import lombok.Getter; import lombok.NoArgsConstructor; +import lombok.Builder.Default; import org.hibernate.annotations.JdbcTypeCode; import org.hibernate.type.SqlTypes; @@ -46,6 +47,9 @@ public class User extends BaseEntity { @Column(name = "name", nullable = false) private String name; + @Column(name = "oauth_subject", nullable = false, unique = true) + private String oauthSubject; + @Column(name = "major", nullable = false) private String major; @@ -58,19 +62,19 @@ public class User extends BaseEntity { @Column(name = "email", nullable = false) private String email; - @Column(name = "password", nullable = false) - private String password; - @Enumerated(EnumType.STRING) @Column(name = "user_role", nullable = false) - private UserRole userRole; + @Default + private UserRole userRole = UserRole.GUEST; @Enumerated(EnumType.STRING) @Column(name = "team") private TeamType team; - @Column(name = "salt", nullable = false) - private byte[] salt; + @Enumerated(EnumType.STRING) + @Column(name = "membership_status", nullable = false) + @Default + private MembershipStatus membershipStatus = MembershipStatus.PENDING; @Column(name = "image") private String image; @@ -91,20 +95,19 @@ public class User extends BaseEntity { @Builder public User( - String name, String major, String studentId, String phoneNumber, - String email, String password, UserRole userRole, + String name, String oauthSubject, String major, String studentId, String phoneNumber, + String email, UserRole userRole, TeamType team, - byte[] salt, String image, SocialUrls social, Careers careers + String image, SocialUrls social, Careers careers ) { + this.oauthSubject = oauthSubject; this.name = name; this.major = major; this.studentId = studentId; this.phoneNumber = phoneNumber; this.email = email; - this.password = password; this.userRole = userRole; this.team = team; - this.salt = salt; this.image = image; this.social = (social != null ? social : new SocialUrls()); this.careers = (careers != null ? careers : new Careers()); @@ -124,13 +127,20 @@ public void addStudyAttendee(StudyAttendee studyAttendee) { } } - public void updatePassword(String password) throws NoSuchAlgorithmException, InvalidKeyException { - this.password = EncryptUtil.encrypt(password, this.salt); + public void approve() { + this.membershipStatus = MembershipStatus.APPROVED; + if (this.userRole == UserRole.GUEST) { + this.userRole = UserRole.MEMBER; + } } - + public void reject() { + this.membershipStatus = MembershipStatus.REJECTED; + } + public enum MembershipStatus { PENDING, APPROVED, REJECTED } + public boolean isGuest() { return this.userRole == UserRole.GUEST; } public void changeRole(UserRole role) { this.userRole = role; } public void changeTeam(TeamType team) { this.team = team; } -} \ No newline at end of file +} diff --git a/src/main/java/inha/gdgoc/domain/user/enums/MembershipStatus.java b/src/main/java/inha/gdgoc/domain/user/enums/MembershipStatus.java new file mode 100644 index 00000000..02f50d6c --- /dev/null +++ b/src/main/java/inha/gdgoc/domain/user/enums/MembershipStatus.java @@ -0,0 +1,7 @@ +package inha.gdgoc.domain.user.enums; + +public enum MembershipStatus { + PENDING, // 승인 대기 + APPROVED, // 승인 완료 + REJECTED // 승인 거절 +} \ No newline at end of file diff --git a/src/main/java/inha/gdgoc/domain/user/repository/UserRepository.java b/src/main/java/inha/gdgoc/domain/user/repository/UserRepository.java index 3836883c..bacf83ae 100644 --- a/src/main/java/inha/gdgoc/domain/user/repository/UserRepository.java +++ b/src/main/java/inha/gdgoc/domain/user/repository/UserRepository.java @@ -1,6 +1,6 @@ package inha.gdgoc.domain.user.repository; -import inha.gdgoc.domain.user.dto.response.UserSummaryResponse; +import inha.gdgoc.domain.admin.user.dto.response.UserSummaryResponse; import inha.gdgoc.domain.user.entity.User; import inha.gdgoc.domain.user.enums.TeamType; import inha.gdgoc.domain.user.enums.UserRole; @@ -19,6 +19,10 @@ @Repository public interface UserRepository extends JpaRepository, UserRepositoryCustom { + Optional findByOauthSubject(String oauthSubject); + + boolean existsByStudentId(String studentId); + boolean existsByPhoneNumber(String phoneNumber); boolean existsByNameAndEmail(String name, String email); boolean existsByEmail(String email); @@ -37,7 +41,7 @@ public interface UserRepository extends JpaRepository, UserRepositor List findByTeam(TeamType team); @Query(""" - select new inha.gdgoc.domain.user.dto.response.UserSummaryResponse( + select new inha.gdgoc.domain.admin.user.dto.response.UserSummaryResponse( u.id, u.name, u.major, u.studentId, u.email, u.userRole, u.team ) from User u @@ -46,4 +50,4 @@ public interface UserRepository extends JpaRepository, UserRepositor Page findSummaries(@Param("q") String q, Pageable pageable); @NotNull Optional findById(@NotNull Long id); -} \ No newline at end of file +} diff --git a/src/main/java/inha/gdgoc/domain/user/service/UserService.java b/src/main/java/inha/gdgoc/domain/user/service/UserService.java index b7fcc048..70b363a1 100644 --- a/src/main/java/inha/gdgoc/domain/user/service/UserService.java +++ b/src/main/java/inha/gdgoc/domain/user/service/UserService.java @@ -1,19 +1,14 @@ package inha.gdgoc.domain.user.service; import static inha.gdgoc.domain.user.exception.UserErrorCode.USER_NOT_FOUND; -import static inha.gdgoc.global.util.EncryptUtil.encrypt; -import static inha.gdgoc.global.util.EncryptUtil.generateSalt; import inha.gdgoc.domain.auth.dto.request.FindIdRequest; import inha.gdgoc.domain.auth.dto.response.FindIdResponse; import inha.gdgoc.domain.user.dto.request.CheckDuplicatedEmailRequest; -import inha.gdgoc.domain.user.dto.request.UserSignupRequest; import inha.gdgoc.domain.user.dto.response.CheckDuplicatedEmailResponse; import inha.gdgoc.domain.user.entity.User; import inha.gdgoc.domain.user.exception.UserException; import inha.gdgoc.domain.user.repository.UserRepository; -import java.security.InvalidKeyException; -import java.security.NoSuchAlgorithmException; import java.util.Optional; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -36,7 +31,7 @@ public User findUserById(Long userId) { return userRepository.findByUserId(userId) .orElseThrow(() -> new UserException(USER_NOT_FOUND)); } - + public FindIdResponse findId(FindIdRequest findIdRequest) { Optional user = userRepository.findByNameAndMajorAndPhoneNumber( findIdRequest.getName(), @@ -54,13 +49,6 @@ public FindIdResponse findId(FindIdRequest findIdRequest) { return new FindIdResponse(maskedEmail); } - public void saveUser(UserSignupRequest userSignupRequest) throws NoSuchAlgorithmException, InvalidKeyException { - byte[] salt = generateSalt(); - String hashedPassword = encrypt(userSignupRequest.getPassword(), salt); - - User user = userSignupRequest.toEntity(hashedPassword, salt); - userRepository.save(user); - } private String maskEmail(String email) { int atIndex = email.indexOf("@"); @@ -80,4 +68,4 @@ private String maskEmail(String email) { + localPart.substring(localPart.length() - endLen) + domainPart; } -} +} \ No newline at end of file diff --git a/src/main/java/inha/gdgoc/global/config/jwt/JwtProperties.java b/src/main/java/inha/gdgoc/global/config/jwt/JwtProperties.java index fec214af..3d832433 100644 --- a/src/main/java/inha/gdgoc/global/config/jwt/JwtProperties.java +++ b/src/main/java/inha/gdgoc/global/config/jwt/JwtProperties.java @@ -1,16 +1,18 @@ package inha.gdgoc.global.config.jwt; -import lombok.Getter; -import lombok.Setter; +import lombok.Getter; +import lombok.Setter; import org.springframework.boot.context.properties.ConfigurationProperties; import org.springframework.stereotype.Component; -@Setter -@Getter +@Getter +@Setter @Component -@ConfigurationProperties("jwt") +@ConfigurationProperties(prefix = "jwt") public class JwtProperties { - private String selfIssuer; // 자체 로그인 발급자 - private String googleIssuer; // 구글 로그인 발급자 private String secretKey; + private long accessTokenValidity; + private String googleIssuer; + private String selfIssuer; + private String audience; } 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 124a9e8d..72955383 100644 --- a/src/main/java/inha/gdgoc/global/config/jwt/TokenProvider.java +++ b/src/main/java/inha/gdgoc/global/config/jwt/TokenProvider.java @@ -1,127 +1,215 @@ package inha.gdgoc.global.config.jwt; -import inha.gdgoc.domain.auth.enums.LoginType; import inha.gdgoc.domain.user.entity.User; import inha.gdgoc.domain.user.enums.TeamType; import inha.gdgoc.domain.user.enums.UserRole; +import inha.gdgoc.domain.user.repository.UserRepository; import inha.gdgoc.global.exception.BusinessException; import io.jsonwebtoken.*; +import io.jsonwebtoken.io.Decoders; +import io.jsonwebtoken.security.Keys; import lombok.Getter; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; import org.springframework.security.core.Authentication; import org.springframework.security.core.GrantedAuthority; import org.springframework.security.core.authority.SimpleGrantedAuthority; import org.springframework.stereotype.Service; -import java.time.Duration; +import jakarta.annotation.PostConstruct; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; import java.util.*; +import javax.crypto.SecretKey; import static inha.gdgoc.global.exception.GlobalErrorCode.INVALID_JWT_REQUEST; +@Slf4j @RequiredArgsConstructor @Service public class TokenProvider { - private final JwtProperties jwtProperties; + private static final long ALLOWED_CLOCK_SKEW_SECONDS = 5L; - // 자체 로그인용 토큰 생성 - public String generateSelfSignupToken(User user, Duration expiredAt) { - Date now = new Date(); - return makeToken(new Date(now.getTime() + expiredAt.toMillis()), user, LoginType.SELF_SIGNUP); + private final JwtProperties jwtProperties; + private final UserRepository userRepository; + private static final String CLAIM_USER_ID = "uid"; + private static final String CLAIM_SESSION_ID = "sid"; + private SecretKey cachedSigningKey; + + @PostConstruct + void initSigningKey() { + this.cachedSigningKey = buildSigningKey(jwtProperties.getSecretKey()); } - // 구글 로그인용 토큰 생성 - public String generateGoogleLoginToken(User user, Duration expiredAt) { + // Access Token 생성 (JWT) + public String createAccessToken(User user, String sessionId) { Date now = new Date(); - return makeToken(new Date(now.getTime() + expiredAt.toMillis()), user, LoginType.GOOGLE_LOGIN); + Date validity = new Date(now.getTime() + jwtProperties.getAccessTokenValidity()); + + var builder = Jwts.builder() + .issuer(jwtProperties.getSelfIssuer()) + .audience().add(jwtProperties.getAudience()).and() + .issuedAt(now) + .expiration(validity) + .subject(String.valueOf(user.getId())) + .id(UUID.randomUUID().toString()) + .claim(CLAIM_USER_ID, user.getId()) + .claim(CLAIM_SESSION_ID, sessionId); + + return builder + .signWith(signingKey()) + .compact(); } - public String generateRefreshToken(User user, Duration expiredAt, LoginType loginType) { - Date now = new Date(); - return makeToken(new Date(now.getTime() + expiredAt.toMillis()), user, loginType); - } - public Claims validToken(String token) throws ExpiredJwtException, UnsupportedJwtException, MalformedJwtException, SignatureException, IllegalArgumentException { - return getClaims(token); + // Refresh Token 생성 (Random UUID) + // JWT가 아니라, 단순 랜덤 문자열로 생성하여 Redis 저장용으로 씁니다. + public String createRefreshToken() { + return UUID.randomUUID().toString(); } + // Authentication 객체 생성 (Spring Security용) public Authentication getAuthentication(String token) { Claims claims = getClaims(token); - Number idNum = claims.get("id", Number.class); - if (idNum == null) throw new BusinessException(INVALID_JWT_REQUEST); - Long userId = idNum.longValue(); + Long userId = extractUserId(claims); + String sessionId = claims.get(CLAIM_SESSION_ID, String.class); + if (sessionId == null || sessionId.isBlank()) { + log.warn("JWT 검증 실패: sessionId(sid) 클레임이 누락되었습니다."); + throw new BusinessException(INVALID_JWT_REQUEST); + } - String username = claims.getSubject(); + validateAudienceClaim(claims.get(Claims.AUDIENCE)); - // role (필수) - String roleStr = claims.get("role", String.class); - if (roleStr == null) throw new BusinessException(INVALID_JWT_REQUEST); - UserRole userRole = UserRole.valueOf(roleStr); + User user = userRepository.findById(userId) + .orElseThrow(() -> { + log.warn("JWT 검증 실패: ID가 {}인 유저를 찾을 수 없습니다.", userId); + return new BusinessException(INVALID_JWT_REQUEST); + }); - // 권한 세트 구성 + UserRole userRole = user.getUserRole(); Set authorities = new HashSet<>(); - // 1) 역할 권한 authorities.add(new SimpleGrantedAuthority("ROLE_" + userRole.name())); - // 2) 팀 권한 (선택) - TeamType team = null; - String teamStr = claims.get("team", String.class); - if (teamStr != null && !teamStr.isBlank()) { - try { - team = TeamType.valueOf(teamStr); - authorities.add(new SimpleGrantedAuthority("TEAM_" + team.name())); - } catch (IllegalArgumentException ignored) { - } + TeamType team = user.getTeam(); + if (team != null) { + authorities.add(new SimpleGrantedAuthority("TEAM_" + team.name())); } - CustomUserDetails userDetails = new CustomUserDetails(userId, username, "", authorities, userRole, team); + CustomUserDetails userDetails = + new CustomUserDetails(userId, user.getEmail(), sessionId, authorities, userRole, team); return new UsernamePasswordAuthenticationToken(userDetails, null, authorities); } - private String makeToken(Date expiry, User user, LoginType loginType) { - Date now = new Date(); - String issuer = (loginType == LoginType.SELF_SIGNUP) ? jwtProperties.getSelfIssuer() : jwtProperties.getGoogleIssuer(); - - // team: enum name 저장(예: "PR_DESIGN"), 없으면 null - String teamEnumName = (user.getTeam() == null) ? null : user.getTeam().name(); - - return Jwts.builder() - .setHeaderParam(Header.TYPE, Header.JWT_TYPE) - .setIssuer(issuer) - .setIssuedAt(now) - .setExpiration(expiry) - .setSubject(user.getEmail()) - .claim("id", user.getId()) - .claim("loginType", loginType.name()) - .claim("role", user.getUserRole().name()) - .claim("team", teamEnumName) - .signWith(SignatureAlgorithm.HS256, Base64.getEncoder() - .encodeToString(jwtProperties.getSecretKey().getBytes())) - .compact(); + private Claims getClaims(String token) { + try { + return Jwts.parser() + .clockSkewSeconds(ALLOWED_CLOCK_SKEW_SECONDS) + .requireIssuer(jwtProperties.getSelfIssuer()) + .verifyWith(signingKey()) + .build() + .parseSignedClaims(token) + .getPayload(); + } catch (ExpiredJwtException e) { + log.warn("JWT 검증 실패: 만료된 토큰입니다."); + throw e; + } catch (UnsupportedJwtException e) { + log.warn("JWT 검증 실패: 지원되지 않는 토큰 형식입니다."); + throw e; + } catch (MalformedJwtException e) { + log.warn("JWT 검증 실패: 잘못된 구조의 토큰입니다."); + throw e; + } catch (SignatureException e) { + log.warn("JWT 검증 실패: 서명이 일치하지 않습니다."); + throw e; + } catch (Exception e) { + log.warn("JWT 검증 실패: 알 수 없는 오류 발생 - {}", e.getMessage()); + throw e; + } } - private Claims getClaims(String token) { - return Jwts.parser() - .setSigningKey(Base64.getEncoder().encodeToString(jwtProperties.getSecretKey().getBytes())) - .parseClaimsJws(token) - .getBody(); + private SecretKey signingKey() { + return cachedSigningKey; + } + + private SecretKey buildSigningKey(String rawSecret) { + byte[] candidateKey; + try { + candidateKey = Decoders.BASE64.decode(rawSecret); + } catch (IllegalArgumentException ignore) { + candidateKey = rawSecret.getBytes(StandardCharsets.UTF_8); + } + + if (candidateKey.length < 32) { + try { + candidateKey = MessageDigest.getInstance("SHA-256").digest(candidateKey); + } catch (NoSuchAlgorithmException e) { + throw new IllegalStateException("SHA-256 algorithm not available", e); + } + } + + return Keys.hmacShaKeyFor(candidateKey); + } + + private Long extractUserId(Claims claims) { + Number idNum = claims.get(CLAIM_USER_ID, Number.class); + if (idNum == null) { + log.warn("JWT 검증 실패: userId(uid) 클레임이 누락되었습니다."); + throw new BusinessException(INVALID_JWT_REQUEST); + } + return idNum.longValue(); + } + + private void validateAudienceClaim(Object audienceClaim) { + if (audienceClaim == null) { + log.warn("JWT 검증 실패: audience(aud) 클레임이 누락되었습니다."); + throw new BusinessException(INVALID_JWT_REQUEST); + } + + String expectedAudience = jwtProperties.getAudience(); + if (audienceClaim instanceof Collection collection) { + boolean matches = collection.stream() + .filter(Objects::nonNull) + .map(Object::toString) + .anyMatch(expectedAudience::equals); + if (!matches) { + log.warn("JWT 검증 실패: audience 불일치. (기대값: {}, 실제값: {})", expectedAudience, collection); + throw new BusinessException(INVALID_JWT_REQUEST); + } + return; + } + + if (!expectedAudience.equals(audienceClaim.toString())) { + log.warn("JWT 검증 실패: audience 불일치. (기대값: {}, 실제값: {})", expectedAudience, audienceClaim); + throw new BusinessException(INVALID_JWT_REQUEST); + } } @Getter public static class CustomUserDetails extends org.springframework.security.core.userdetails.User { private final Long userId; + private final String sessionId; private final UserRole role; private final TeamType team; - public CustomUserDetails(Long userId, String username, String password, Collection authorities, UserRole role, TeamType team) { - super(username, password, authorities); + public CustomUserDetails( + Long userId, + String username, + String sessionId, + Collection authorities, + UserRole role, + TeamType team + ) { + super(username, "", authorities); this.userId = userId; + this.sessionId = sessionId; this.role = role; this.team = team; } } -} \ No newline at end of file +} diff --git a/src/main/java/inha/gdgoc/global/config/s3/S3Config.java b/src/main/java/inha/gdgoc/global/config/s3/S3Config.java index fef0a06b..14d27a0d 100644 --- a/src/main/java/inha/gdgoc/global/config/s3/S3Config.java +++ b/src/main/java/inha/gdgoc/global/config/s3/S3Config.java @@ -11,21 +11,18 @@ @Configuration public class S3Config { - @Bean - public Region awsRegion(@Value("${cloud.aws.region.static}") String region) { - return Region.of(region); - } + @Bean + public Region awsRegion(@Value("${spring.cloud.aws.region.static}") String region) { + return Region.of(region); + } - @Bean - public AwsCredentialsProvider awsCredentialsProvider() { - return DefaultCredentialsProvider.create(); - } + @Bean + public AwsCredentialsProvider awsCredentialsProvider() { + return DefaultCredentialsProvider.create(); + } - @Bean - public S3Client s3Client(Region region, AwsCredentialsProvider provider) { - return S3Client.builder() - .region(region) - .credentialsProvider(provider) - .build(); - } + @Bean + public S3Client s3Client(Region region, AwsCredentialsProvider provider) { + return S3Client.builder().region(region).credentialsProvider(provider).build(); + } } diff --git a/src/main/java/inha/gdgoc/global/config/s3/S3Properties.java b/src/main/java/inha/gdgoc/global/config/s3/S3Properties.java new file mode 100644 index 00000000..a7e562c4 --- /dev/null +++ b/src/main/java/inha/gdgoc/global/config/s3/S3Properties.java @@ -0,0 +1,14 @@ +package inha.gdgoc.global.config.s3; + +import lombok.Getter; +import lombok.Setter; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.stereotype.Component; + +@Getter +@Setter +@Component +@ConfigurationProperties("app.s3") +public class S3Properties { + private String bucket; +} diff --git a/src/main/java/inha/gdgoc/global/security/AccessGuard.java b/src/main/java/inha/gdgoc/global/security/AccessGuard.java new file mode 100644 index 00000000..6341332e --- /dev/null +++ b/src/main/java/inha/gdgoc/global/security/AccessGuard.java @@ -0,0 +1,90 @@ +package inha.gdgoc.global.security; + +import inha.gdgoc.domain.user.enums.TeamType; +import inha.gdgoc.domain.user.enums.UserRole; +import inha.gdgoc.global.config.jwt.TokenProvider.CustomUserDetails; +import java.util.Arrays; +import java.util.List; +import org.springframework.security.access.AccessDeniedException; +import org.springframework.security.core.Authentication; +import org.springframework.stereotype.Component; + +/** + * 중앙 집중 권한 검사기. + * - {@link #check(Authentication, AccessCondition...)}는 SpEL @PreAuthorize에서 사용. + * - {@link #require(CustomUserDetails, AccessCondition...)}는 서비스/컨트롤러에서 명시적으로 사용. + */ +@Component("accessGuard") +public class AccessGuard { + + public boolean check(Authentication authentication, AccessCondition... anyOf) { + CustomUserDetails user = extract(authentication); + return matches(user, anyOf); + } + + public boolean check(CustomUserDetails user, AccessCondition... anyOf) { + return matches(user, anyOf); + } + + public void require(CustomUserDetails user, AccessCondition... anyOf) { + if (!matches(user, anyOf)) { + throw new AccessDeniedException("FORBIDDEN_USER"); + } + } + + private boolean matches(CustomUserDetails user, AccessCondition... anyOf) { + if (user == null || anyOf == null || anyOf.length == 0) { + return false; + } + + for (AccessCondition condition : anyOf) { + if (condition != null && condition.matches(user.getRole(), user.getTeam())) { + return true; + } + } + + return false; + } + + private CustomUserDetails extract(Authentication authentication) { + if (authentication == null) { + return null; + } + Object principal = authentication.getPrincipal(); + if (principal instanceof CustomUserDetails user) { + return user; + } + return null; + } + + public record AccessCondition(UserRole minRole, List teams) { + + public static AccessCondition of(UserRole minRole, List teams) { + List list = (teams == null || teams.isEmpty()) + ? List.of() + : List.copyOf(teams); + return new AccessCondition(minRole, list); + } + + public static AccessCondition of(UserRole minRole, TeamType... teams) { + if (teams == null || teams.length == 0) { + return new AccessCondition(minRole, List.of()); + } + return new AccessCondition(minRole, List.copyOf(Arrays.asList(teams))); + } + + public static AccessCondition atLeast(UserRole minRole) { + return of(minRole); + } + + private boolean matches(UserRole currentRole, TeamType currentTeam) { + if (minRole != null && !UserRole.hasAtLeast(currentRole, minRole)) { + return false; + } + if (teams.isEmpty()) { + return true; + } + return currentTeam != null && teams.contains(currentTeam); + } + } +} diff --git a/src/main/java/inha/gdgoc/global/security/SecurityConfig.java b/src/main/java/inha/gdgoc/global/security/SecurityConfig.java index 4ef5b2eb..e5eb10f1 100644 --- a/src/main/java/inha/gdgoc/global/security/SecurityConfig.java +++ b/src/main/java/inha/gdgoc/global/security/SecurityConfig.java @@ -39,7 +39,7 @@ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { .httpBasic(AbstractHttpConfigurer::disable) .authorizeHttpRequests(auth -> auth .requestMatchers(HttpMethod.OPTIONS, "/**").permitAll() - .requestMatchers("/api/v1/auth/logout").authenticated() + .requestMatchers("/api/v1/auth/logout").permitAll() .requestMatchers( "/swagger-ui/**", "/v3/api-docs/**", @@ -47,9 +47,8 @@ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { "/api/v1/auth/**", "/api/v1/test/**", "/api/v1/game/**", - "/api/v1/apply/**", - "/api/v1/check/**", - "/api/v1/core-recruit", + "/api/v1/recruit/member/apply/**", + "/api/v1/recruit/member/check/**", "/api/v1/fileupload", "/api/v1/manito/verify") .permitAll() @@ -104,7 +103,7 @@ public CorsConfigurationSource corsConfigurationSource() { )); config.setAllowedMethods(List.of("GET","POST","PUT","DELETE","OPTIONS","PATCH")); config.setAllowedHeaders(List.of("Origin","X-Requested-With","Content-Type","Accept","Authorization")); - config.setExposedHeaders(List.of("Authorization","Set-Cookie")); // 필요시 노출 + config.setExposedHeaders(List.of()); // 필요시 노출 config.setAllowCredentials(true); config.setMaxAge(3600L); // 프리플라이트 캐시 @@ -118,4 +117,3 @@ public PasswordEncoder passwordEncoder() { return new BCryptPasswordEncoder(); } } - diff --git a/src/main/java/inha/gdgoc/global/security/TokenAuthenticationFilter.java b/src/main/java/inha/gdgoc/global/security/TokenAuthenticationFilter.java index 21965c7d..f356c68c 100644 --- a/src/main/java/inha/gdgoc/global/security/TokenAuthenticationFilter.java +++ b/src/main/java/inha/gdgoc/global/security/TokenAuthenticationFilter.java @@ -3,6 +3,7 @@ import inha.gdgoc.global.config.jwt.TokenProvider; import jakarta.servlet.FilterChain; import jakarta.servlet.ServletException; +import jakarta.servlet.http.Cookie; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; import java.io.IOException; @@ -65,7 +66,7 @@ protected void doFilterInternal( log.warn("JWT 인증 실패: {}", e.getMessage()); } } else { - log.info("Authorization 헤더 없음 → 인증 시도 안함"); + log.info("access token 없음 → 인증 시도 안함"); } filterChain.doFilter(request, response); @@ -75,23 +76,34 @@ private String getAccessToken(HttpServletRequest request) { final String HEADER_AUTHORIZATION = "Authorization"; final String TOKEN_PREFIX = "Bearer "; - String bearerToken = request.getHeader(HEADER_AUTHORIZATION); - - if (bearerToken == null || !bearerToken.startsWith(TOKEN_PREFIX)) { - return null; + String authorizationHeader = request.getHeader(HEADER_AUTHORIZATION); + if (authorizationHeader != null && authorizationHeader.startsWith(TOKEN_PREFIX)) { + return sanitizeToken(authorizationHeader.substring(TOKEN_PREFIX.length()).trim()); } - String token = bearerToken.substring(TOKEN_PREFIX.length()); + return null; + } - token = token.trim(); + private String readCookieToken(HttpServletRequest request, String name) { + Cookie[] cookies = request.getCookies(); + if (cookies == null) { + return null; + } + for (Cookie cookie : cookies) { + if (name.equals(cookie.getName())) { + return cookie.getValue(); + } + } + return null; + } + private String sanitizeToken(String token) { for (char c : token.toCharArray()) { if (c < 32) { log.info("토큰에 유효하지 않은 제어 문자가 포함되어 있습니다."); throw new IllegalArgumentException("토큰에 유효하지 않은 제어 문자가 포함되어 있습니다."); } } - return token; } diff --git a/src/main/java/inha/gdgoc/global/util/SemesterCalculator.java b/src/main/java/inha/gdgoc/global/util/SemesterCalculator.java index fdbc60b9..1d196596 100644 --- a/src/main/java/inha/gdgoc/global/util/SemesterCalculator.java +++ b/src/main/java/inha/gdgoc/global/util/SemesterCalculator.java @@ -1,20 +1,33 @@ package inha.gdgoc.global.util; -import inha.gdgoc.domain.recruit.enums.AdmissionSemester; - +import inha.gdgoc.domain.recruit.member.enums.AdmissionSemester; +import java.time.Clock; import java.time.LocalDate; import java.time.ZoneId; +import org.springframework.stereotype.Component; + +/** + * 현재 날짜를 기반으로 학기를 계산하는 컴포넌트. + * env 값 대신 서버 시간이 기준이 되도록 고정. + */ +@Component +public class SemesterCalculator { -public final class SemesterCalculator { - private static final ZoneId KST = ZoneId.of("Asia/Seoul"); + private final Clock clock; - private SemesterCalculator() {} + public SemesterCalculator() { + this(Clock.system(ZoneId.of("Asia/Seoul"))); + } + + public SemesterCalculator(Clock clock) { + this.clock = clock; + } - public static AdmissionSemester currentSemester() { - return of(LocalDate.now(KST)); + public AdmissionSemester currentSemester() { + return of(LocalDate.now(clock)); } - public static AdmissionSemester of(LocalDate date) { + public AdmissionSemester of(LocalDate date) { int year = date.getYear(); int month = date.getMonthValue(); diff --git a/src/main/resources/application-dev.yml b/src/main/resources/application-dev.yml index 47d075ed..9a9ab2db 100644 --- a/src/main/resources/application-dev.yml +++ b/src/main/resources/application-dev.yml @@ -2,24 +2,30 @@ server: forward-headers-strategy: framework spring: - web: - resources: - add-mappings: false + cloud: + aws: + credentials: + access-key: ${AWS_ACCESS_KEY_ID} + secret-key: ${AWS_SECRET_ACCESS_KEY} + region: + static: ${AWS_REGION} config: import: optional:file:.env[.properties] - jackson: - time-zone: Asia/Seoul datasource: + driver-class-name: org.postgresql.Driver + password: ${SPRING_DATASOURCE_PASSWORD} url: ${SPRING_DATASOURCE_URL} username: ${SPRING_DATASOURCE_USERNAME} - password: ${SPRING_DATASOURCE_PASSWORD} - driver-class-name: org.postgresql.Driver - servlet: - multipart: - max-file-size: 10MB - max-request-size: 12MB + flyway: + baseline-on-migrate: true + clean-disabled: true + enabled: true + locations: classpath:db/migration + validate-migration-naming: true + ignore-migration-patterns: "*:missing,*:future" + jackson: + time-zone: Asia/Seoul jpa: - database: postgresql hibernate: ddl-auto: none properties: @@ -28,47 +34,46 @@ spring: jdbc: time_zone: UTC show-sql: false - database-platform: org.hibernate.dialect.PostgreSQLDialect - flyway: - enabled: true - baseline-on-migrate: false - clean-disabled: true - validate-migration-naming: true - locations: classpath:db/migration mail: host: smtp.gmail.com - port: 587 - username: ${GMAIL} password: ${GMAIL_PASSWORD} + port: 587 properties: mail: smtp: auth: true starttls: enable: true + username: ${GMAIL} + servlet: + multipart: + max-file-size: 10MB + max-request-size: 12MB + web: + resources: + add-mappings: false -logging: - level: - org.hibernate.SQL: debug - org.hibernate.type: off +springdoc: + api-docs: + enabled: false + swagger-ui: + enabled: false +app: + s3: + bucket: ${AWS_TEST_RESOURCE_BUCKET} google: client-id: ${GOOGLE_CLIENT_ID} - client-secret: ${GOOGLE_CLIENT_SECRET} - redirect-uri: ${GOOGLE_REDIRECT_URI} jwt: googleIssuer: ${GOOGLE_ISSUER} - selfIssuer: ${SELF_ISSUER} secretKey: ${SECRET_KEY} + selfIssuer: ${SELF_ISSUER} + audience: ${JWT_AUDIENCE} + accessTokenValidity: ${JWT_ACCESS_TOKEN_VALIDITY:3600000} -cloud: - aws: - credentials: - access-key: ${AWS_ACCESS_KEY_ID} - secret-key: ${AWS_SECRET_ACCESS_KEY} - region: - static: ${AWS_REGION} - s3: - bucket: ${AWS_TEST_RESOURCE_BUCKET} +logging: + level: + org.hibernate.SQL: debug + org.hibernate.type: off diff --git a/src/main/resources/application-local.yml b/src/main/resources/application-local.yml index 5a665bf0..64b37826 100644 --- a/src/main/resources/application-local.yml +++ b/src/main/resources/application-local.yml @@ -2,23 +2,31 @@ server: forward-headers-strategy: framework spring: - web: - resources: - add-mappings: false + cloud: + aws: + credentials: + access-key: ${AWS_ACCESS_KEY_ID} + secret-key: ${AWS_SECRET_ACCESS_KEY} + region: + static: ${AWS_REGION} config: import: optional:file:.env[.properties] + datasource: + driver-class-name: org.postgresql.Driver + password: ${DB_PASSWORD} + url: ${DB_URL} + username: ${DB_USERNAME} + flyway: + baseline-description: "Baseline existing schema" + baseline-on-migrate: true + baseline-version: 1 + enabled: true + locations: classpath:db/migration + schemas: public + ignore-migration-patterns: "*:missing,*:future" jackson: time-zone: Asia/Seoul - datasource: - url: jdbc:postgresql://localhost:5432/gdgoc - username: postgres - password: - servlet: - multipart: - max-file-size: 10MB - max-request-size: 12MB jpa: - database: postgresql hibernate: ddl-auto: none properties: @@ -26,47 +34,46 @@ spring: default_batch_fetch_size: 100 jdbc: time_zone: UTC - flyway: - enabled: true - locations: classpath:db/migration - schemas: public - baseline-on-migrate: false - baseline-version: 1 - baseline-description: "Baseline existing schema" - mail: host: smtp.gmail.com - port: 587 - username: ${GMAIL} password: ${GMAIL_PASSWORD} + port: 587 properties: mail: smtp: auth: true starttls: enable: true + username: ${GMAIL} + servlet: + multipart: + max-file-size: 10MB + max-request-size: 12MB + web: + resources: + add-mappings: false + +springdoc: + api-docs: + enabled: false + swagger-ui: + enabled: false + +app: + s3: + bucket: ${AWS_TEST_RESOURCE_BUCKET} google: client-id: ${GOOGLE_CLIENT_ID} - client-secret: ${GOOGLE_CLIENT_SECRET} - redirect-uri: ${GOOGLE_REDIRECT_URI} - -logging: - level: - org.hibernate.SQL: debug - org.hibername.type: trace jwt: googleIssuer: ${GOOGLE_ISSUER} - selfIssuer: ${SELF_ISSUER} secretKey: ${SECRET_KEY} + selfIssuer: ${SELF_ISSUER} + audience: ${JWT_AUDIENCE} + accessTokenValidity: ${JWT_ACCESS_TOKEN_VALIDITY:3600000} -cloud: - aws: - credentials: - access-key: ${AWS_ACCESS_KEY_ID} - secret-key: ${AWS_SECRET_ACCESS_KEY} - region: - static: ${AWS_REGION} - s3: - bucket: ${AWS_TEST_RESOURCE_BUCKET} +logging: + level: + org.hibernate.SQL: debug + org.hibernate.type: trace diff --git a/src/main/resources/application-prod.yml b/src/main/resources/application-prod.yml index bd3381c6..510846a4 100644 --- a/src/main/resources/application-prod.yml +++ b/src/main/resources/application-prod.yml @@ -2,24 +2,29 @@ server: forward-headers-strategy: framework spring: - web: - resources: - add-mappings: false + cloud: + aws: + credentials: + access-key: ${AWS_ACCESS_KEY_ID} + secret-key: ${AWS_SECRET_ACCESS_KEY} + region: + static: ${AWS_REGION} config: import: optional:file:.env[.properties] - jackson: - time-zone: Asia/Seoul datasource: + driver-class-name: org.postgresql.Driver + password: ${SPRING_DATASOURCE_PASSWORD} url: ${SPRING_DATASOURCE_URL} username: ${SPRING_DATASOURCE_USERNAME} - password: ${SPRING_DATASOURCE_PASSWORD} - driver-class-name: org.postgresql.Driver - servlet: - multipart: - max-file-size: 10MB - max-request-size: 12MB + flyway: + baseline-on-migrate: false + clean-disabled: true + enabled: true + locations: classpath:db/migration + validate-migration-naming: true + jackson: + time-zone: Asia/Seoul jpa: - database: postgresql hibernate: ddl-auto: none properties: @@ -28,48 +33,46 @@ spring: jdbc: time_zone: UTC show-sql: false - database-platform: org.hibernate.dialect.PostgreSQLDialect - flyway: - enabled: true - baseline-on-migrate: false - clean-disabled: true - validate-migration-naming: true - locations: classpath:db/migration mail: host: smtp.gmail.com - port: 587 - username: ${GMAIL} password: ${GMAIL_PASSWORD} + port: 587 properties: mail: smtp: auth: true starttls: enable: true + username: ${GMAIL} + servlet: + multipart: + max-file-size: 10MB + max-request-size: 12MB + web: + resources: + add-mappings: false -logging: - level: - org.hibernate.SQL: debug - org.hibernate.orm.jdbc.bind: trace - org.hibernate.type: trace +springdoc: + api-docs: + enabled: false + swagger-ui: + enabled: false +app: + s3: + bucket: ${AWS_RESOURCE_BUCKET} google: client-id: ${GOOGLE_CLIENT_ID} - client-secret: ${GOOGLE_CLIENT_SECRET} - redirect-uri: ${GOOGLE_REDIRECT_URI} jwt: googleIssuer: ${GOOGLE_ISSUER} - selfIssuer: ${SELF_ISSUER} secretKey: ${SECRET_KEY} + selfIssuer: ${SELF_ISSUER} + audience: ${JWT_AUDIENCE} + accessTokenValidity: ${JWT_ACCESS_TOKEN_VALIDITY:3600000} -cloud: - aws: - credentials: - access-key: ${AWS_ACCESS_KEY_ID} - secret-key: ${AWS_SECRET_ACCESS_KEY} - region: - static: ${AWS_REGION} - s3: - bucket: ${AWS_RESOURCE_BUCKET} +logging: + level: + org.hibernate.SQL: debug + org.hibernate.type: off diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index ef46c2ad..73c22704 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -1,3 +1,11 @@ spring: profiles: - active: local \ No newline at end of file + active: local + jpa: + open-in-view: false + data: + redis: + host: ${REDIS_HOST:127.0.0.1} + port: ${REDIS_PORT:6379} + password: ${REDIS_PASSWORD:} + timeout: 2s \ No newline at end of file diff --git a/src/main/resources/db/migration/.gitkeep b/src/main/resources/db/migration/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/src/main/resources/db/migration/V20250823__user_role_ordinal_to_string.sql b/src/main/resources/db/migration/V20250823__user_role_ordinal_to_string.sql deleted file mode 100644 index 8dff9689..00000000 --- a/src/main/resources/db/migration/V20250823__user_role_ordinal_to_string.sql +++ /dev/null @@ -1,38 +0,0 @@ -DO $$ -DECLARE r record; -BEGIN - FOR r IN - SELECT conname - FROM pg_constraint c - JOIN pg_class t ON t.oid = c.conrelid - JOIN pg_namespace n ON n.oid = t.relnamespace - WHERE t.relname = 'users' - AND n.nspname = 'public' - AND c.contype = 'c' - AND pg_get_constraintdef(c.oid) ILIKE '%user_role%' -- user_role 관련 CHECK - LOOP - EXECUTE format('ALTER TABLE public.users DROP CONSTRAINT %I', r.conname); - END LOOP; -END $$; - -ALTER TABLE public.users ALTER COLUMN user_role DROP DEFAULT; - -ALTER TABLE public.users - ALTER COLUMN user_role TYPE varchar(32) - USING user_role::text; - - -UPDATE public.users -SET user_role = CASE user_role - WHEN '0' THEN 'GUEST' - WHEN '1' THEN 'MEMBER' - WHEN '2' THEN 'ADMIN' - ELSE user_role - END; - --- 5) 새 디폴트/체크 제약조건 설정 -ALTER TABLE public.users ALTER COLUMN user_role SET DEFAULT 'GUEST'; - -ALTER TABLE public.users - ADD CONSTRAINT users_user_role_check - CHECK (user_role IN ('GUEST','MEMBER','ADMIN')); diff --git a/src/main/resources/db/migration/V20250825__convert_created_updated_to_timestamptz.sql b/src/main/resources/db/migration/V20250825__convert_created_updated_to_timestamptz.sql deleted file mode 100644 index 6ae8dfa6..00000000 --- a/src/main/resources/db/migration/V20250825__convert_created_updated_to_timestamptz.sql +++ /dev/null @@ -1,32 +0,0 @@ -DO $$ -DECLARE - r RECORD; -BEGIN - FOR r IN - WITH target_cols AS ( - SELECT - n.nspname AS schema_name, - c.relname AS table_name, - a.attname AS column_name - FROM pg_catalog.pg_attribute a - JOIN pg_catalog.pg_class c ON c.oid = a.attrelid - JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace - WHERE a.attnum > 0 - AND NOT a.attisdropped - AND c.relkind IN ('r','p') - AND n.nspname NOT IN ('pg_catalog','information_schema') - AND a.attname IN ('created_at','updated_at') - AND pg_catalog.format_type(a.atttypid, a.atttypmod) ILIKE 'timestamp% without time zone%' - ) - SELECT - format( - 'ALTER TABLE %I.%I ALTER COLUMN %I TYPE timestamptz(6) USING (%I AT TIME ZONE %L);', - schema_name, table_name, column_name, column_name, 'Asia/Seoul' - ) AS alter_sql - FROM target_cols - ORDER BY schema_name, table_name, column_name - LOOP - RAISE NOTICE 'Executing: %', r.alter_sql; - EXECUTE r.alter_sql; - END LOOP; -END $$; diff --git a/src/main/resources/db/migration/V20250826__admission_semester_add_backfill_enforce.sql b/src/main/resources/db/migration/V20250826__admission_semester_add_backfill_enforce.sql deleted file mode 100644 index e56653ca..00000000 --- a/src/main/resources/db/migration/V20250826__admission_semester_add_backfill_enforce.sql +++ /dev/null @@ -1,24 +0,0 @@ --- 1) 컬럼 추가 (처음엔 NULL 허용) -ALTER TABLE recruit_member - ADD COLUMN admission_semester VARCHAR(10); - --- 2) 기존 데이터 백필 --- 학기 규칙: 2~7월 = YYY_1, 8~12월 = YYY_2, 1월 = (전년도) YYY_2 -UPDATE recruit_member -SET admission_semester = CASE - WHEN EXTRACT(MONTH FROM created_at) BETWEEN 8 AND 12 - THEN 'Y' || to_char(created_at, 'YY') || '_2' - WHEN EXTRACT(MONTH FROM created_at) BETWEEN 2 AND 7 - THEN 'Y' || to_char(created_at, 'YY') || '_1' - WHEN EXTRACT(MONTH FROM created_at) = 1 - THEN 'Y' || to_char(created_at - INTERVAL '1 year', 'YY') || '_2' - END -WHERE admission_semester IS NULL; - --- 3) NOT NULL 전환 (형식 제약은 생략 가능) -ALTER TABLE recruit_member - ALTER COLUMN admission_semester SET NOT NULL; - --- (선택) 인덱스 -CREATE INDEX idx_recruit_member_admission_semester - ON recruit_member (admission_semester); diff --git a/src/main/resources/db/migration/V20250907__core_recruit_applications.sql b/src/main/resources/db/migration/V20250907__core_recruit_applications.sql deleted file mode 100644 index 6209566b..00000000 --- a/src/main/resources/db/migration/V20250907__core_recruit_applications.sql +++ /dev/null @@ -1,18 +0,0 @@ -create table if not exists core_recruit_applications ( - id bigserial primary key, - name varchar(255) not null, - student_id varchar(64) not null, - phone varchar(64) not null, - major varchar(255) not null, - email varchar(255) not null, - team varchar(64) not null, - motivation text not null, - wish text not null, - strengths text not null, - pledge text not null, - file_urls jsonb not null default '[]'::jsonb, - created_at timestamptz not null default (now()), - updated_at timestamptz not null default (now()) -); - - diff --git a/src/main/resources/db/migration/V2__recruit_auth_api_indexes.sql b/src/main/resources/db/migration/V2__recruit_auth_api_indexes.sql new file mode 100644 index 00000000..a20c6d9b --- /dev/null +++ b/src/main/resources/db/migration/V2__recruit_auth_api_indexes.sql @@ -0,0 +1,22 @@ +-- recruit/core, recruit/member, login, signup API hot-path indexes +-- Safe additions only (no destructive DDL). + +-- auth/login, signup duplicate checks +CREATE INDEX IF NOT EXISTS idx_users_student_id ON users(student_id); +CREATE INDEX IF NOT EXISTS idx_users_phone_number ON users(phone_number); +CREATE INDEX IF NOT EXISTS idx_users_email_lower ON users((lower(email))); + +-- recruit/member duplicate checks + admin list/search +CREATE INDEX IF NOT EXISTS idx_recruit_member_email_lower ON recruit_member((lower(email))); +CREATE INDEX IF NOT EXISTS idx_recruit_member_created_at ON recruit_member(created_at DESC); +CREATE INDEX IF NOT EXISTS idx_recruit_member_name_lower ON recruit_member((lower(name))); + +-- recruit/member detail answers lookup +CREATE INDEX IF NOT EXISTS idx_answer_recruit_member_survey_type + ON answer(recruit_member, survey_type); + +-- recruit/core user/session lookup + admin filtering +CREATE INDEX IF NOT EXISTS idx_core_recruit_user_session + ON core_recruit_applications(user_id, session); +CREATE INDEX IF NOT EXISTS idx_core_recruit_session_status_team_created + ON core_recruit_applications(session, result_status, team, created_at DESC); diff --git a/src/test/java/inha/gdgoc/domain/admin/recruit/core/service/RecruitCoreAdminServiceTest.java b/src/test/java/inha/gdgoc/domain/admin/recruit/core/service/RecruitCoreAdminServiceTest.java new file mode 100644 index 00000000..853ebf97 --- /dev/null +++ b/src/test/java/inha/gdgoc/domain/admin/recruit/core/service/RecruitCoreAdminServiceTest.java @@ -0,0 +1,171 @@ +package inha.gdgoc.domain.admin.recruit.core.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import inha.gdgoc.domain.admin.recruit.core.dto.request.RecruitCoreApplicationAcceptRequest; +import inha.gdgoc.domain.admin.recruit.core.dto.request.RecruitCoreApplicationRejectRequest; +import inha.gdgoc.domain.admin.recruit.core.dto.response.RecruitCoreApplicationDecisionResponse; +import inha.gdgoc.domain.recruit.core.entity.RecruitCoreApplication; +import inha.gdgoc.domain.recruit.core.enums.RecruitCoreResultStatus; +import inha.gdgoc.domain.recruit.core.repository.RecruitCoreApplicationRepository; +import inha.gdgoc.domain.user.entity.User; +import inha.gdgoc.domain.user.enums.TeamType; +import inha.gdgoc.domain.user.enums.UserRole; +import inha.gdgoc.global.exception.BusinessException; +import java.time.Instant; +import java.util.List; +import java.util.Optional; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.jpa.domain.Specification; + +@ExtendWith(MockitoExtension.class) +class RecruitCoreAdminServiceTest { + + @Mock + private RecruitCoreApplicationRepository repository; + + @InjectMocks + private RecruitCoreAdminService adminService; + + @Test + void searchApplications_buildsSpecificationAndDelegates() { + RecruitCoreApplication app = createApplication(1L, createUser(1L)); + Page page = new PageImpl<>(List.of(app)); + when(repository.findAll(any(Specification.class), any(PageRequest.class))).thenReturn(page); + + Page result = adminService.searchApplications( + "2026-1", + RecruitCoreResultStatus.SUBMITTED, + TeamType.TECH, + PageRequest.of(0, 20) + ); + + assertThat(result.getContent()).hasSize(1); + verify(repository).findAll(any(Specification.class), any(PageRequest.class)); + } + + @Test + void accept_setsReviewerInfoAndUpdatesUser() { + User user = createUser(5L); + RecruitCoreApplication application = createApplication(100L, user); + when(repository.findById(100L)).thenReturn(Optional.of(application)); + + RecruitCoreApplicationDecisionResponse response = adminService.accept( + 100L, + 9L, + new RecruitCoreApplicationAcceptRequest("함께 하시죠", true) + ); + + assertThat(response.resultStatus()).isEqualTo(RecruitCoreResultStatus.ACCEPTED); + assertThat(application.getResultStatus()).isEqualTo(RecruitCoreResultStatus.ACCEPTED); + assertThat(application.getReviewedBy()).isEqualTo(9L); + assertThat(application.getReviewedAt()).isNotNull(); + assertThat(user.getUserRole()).isEqualTo(UserRole.CORE); + assertThat(user.getTeam()).isEqualTo(TeamType.TECH); + } + + @Test + void reject_setsRejectedStatus() { + User user = createUser(5L); + RecruitCoreApplication application = createApplication(200L, user); + when(repository.findById(200L)).thenReturn(Optional.of(application)); + + RecruitCoreApplicationDecisionResponse response = adminService.reject( + 200L, + 8L, + new RecruitCoreApplicationRejectRequest("죄송합니다.") + ); + + assertThat(response.resultStatus()).isEqualTo(RecruitCoreResultStatus.REJECTED); + assertThat(application.getReviewedBy()).isEqualTo(8L); + assertThat(application.getResultStatus()).isEqualTo(RecruitCoreResultStatus.REJECTED); + } + + @Test + void accept_afterDecision_throwsException() { + User user = createUser(1L); + RecruitCoreApplication application = createApplication(1L, user); + application.accept(3L, "이미 처리", Instant.now()); + when(repository.findById(1L)).thenReturn(Optional.of(application)); + + assertThatThrownBy(() -> adminService.accept( + 1L, + 2L, + new RecruitCoreApplicationAcceptRequest("다시", true) + )).isInstanceOf(BusinessException.class); + } + + private RecruitCoreApplication createApplication(Long id, User user) { + RecruitCoreApplication application = RecruitCoreApplication.builder() + .user(user) + .session("2026-1") + .name("홍길동") + .studentId("12201234") + .phone("01012345678") + .major("컴퓨터공학과") + .email("hong@inha.edu") + .team("TECH") + .motivation("motivation") + .wish("wish") + .strengths("strengths") + .pledge("pledge") + .fileUrls(List.of()) + .resultStatus(RecruitCoreResultStatus.SUBMITTED) + .build(); + setId(application, id); + setTimeStamps(application); + return application; + } + + private User createUser(Long id) { + User user = User.builder() + .name("홍길동") + .major("컴퓨터공학과") + .studentId("12201234") + .phoneNumber("01012345678") + .email("hong@inha.edu") + .userRole(UserRole.GUEST) + .team(null) + .image(null) + .social(null) + .careers(null) + .build(); + setId(user, id); + return user; + } + + private void setId(Object target, Long id) { + try { + java.lang.reflect.Field field = target.getClass().getDeclaredField("id"); + field.setAccessible(true); + field.set(target, id); + } catch (NoSuchFieldException | IllegalAccessException e) { + throw new RuntimeException(e); + } + } + + private void setTimeStamps(RecruitCoreApplication application) { + try { + java.lang.reflect.Field created = application.getClass().getSuperclass().getDeclaredField("createdAt"); + java.lang.reflect.Field updated = application.getClass().getSuperclass().getDeclaredField("updatedAt"); + created.setAccessible(true); + updated.setAccessible(true); + Instant now = Instant.now(); + created.set(application, now); + updated.set(application, now); + } catch (NoSuchFieldException | IllegalAccessException e) { + throw new RuntimeException(e); + } + } +} diff --git a/src/test/java/inha/gdgoc/domain/recruit/core/service/RecruitCoreApplicationServiceTest.java b/src/test/java/inha/gdgoc/domain/recruit/core/service/RecruitCoreApplicationServiceTest.java new file mode 100644 index 00000000..b42a7a9d --- /dev/null +++ b/src/test/java/inha/gdgoc/domain/recruit/core/service/RecruitCoreApplicationServiceTest.java @@ -0,0 +1,222 @@ +package inha.gdgoc.domain.recruit.core.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.lenient; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import inha.gdgoc.domain.recruit.core.config.RecruitCoreSessionResolver; +import inha.gdgoc.domain.recruit.core.dto.request.RecruitCoreApplicationCreateRequest; +import inha.gdgoc.domain.recruit.core.dto.request.RecruitCoreApplicationCreateRequest.RecruitCoreApplicationSnapshotRequest; +import inha.gdgoc.domain.recruit.core.dto.response.RecruitCoreApplicantDetailResponse; +import inha.gdgoc.domain.recruit.core.dto.response.RecruitCoreEligibilityResponse; +import inha.gdgoc.domain.recruit.core.dto.response.RecruitCoreApplicationCreateResponse; +import inha.gdgoc.domain.recruit.core.dto.response.RecruitCoreMyApplicationResponse; +import inha.gdgoc.domain.recruit.core.entity.RecruitCoreApplication; +import inha.gdgoc.domain.recruit.core.exception.RecruitCoreAlreadyAppliedException; +import inha.gdgoc.domain.recruit.core.exception.RecruitCoreApplicationNotFoundException; +import inha.gdgoc.domain.recruit.core.repository.RecruitCoreApplicationRepository; +import inha.gdgoc.domain.recruit.core.enums.RecruitCoreResultStatus; +import inha.gdgoc.domain.user.entity.User; +import inha.gdgoc.domain.user.enums.UserRole; +import inha.gdgoc.domain.user.repository.UserRepository; +import inha.gdgoc.global.exception.BusinessException; +import java.time.Instant; +import java.util.List; +import java.util.Optional; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.test.util.ReflectionTestUtils; + +@ExtendWith(MockitoExtension.class) +class RecruitCoreApplicationServiceTest { + + private static final String SESSION = "2026-1"; + + @Mock + private RecruitCoreApplicationRepository repository; + + @Mock + private UserRepository userRepository; + + @Mock + private RecruitCoreSessionResolver recruitCoreSessionResolver; + + @InjectMocks + private RecruitCoreApplicationService service; + + @BeforeEach + void setUp() { + lenient().when(recruitCoreSessionResolver.currentSession()).thenReturn(SESSION); + } + + @Test + void checkEligibility_whenNoApplication_returnsEligible() { + when(repository.findByUserIdAndSession(1L, SESSION)).thenReturn(Optional.empty()); + + RecruitCoreEligibilityResponse response = service.checkEligibility(1L); + + assertThat(response.eligible()).isTrue(); + assertThat(response.session()).isEqualTo(SESSION); + assertThat(response.applicationId()).isNull(); + } + + @Test + void checkEligibility_whenApplicationExists_returnsIneligible() { + RecruitCoreApplication existing = createApplication(10L, createUser(1L), SESSION); + when(repository.findByUserIdAndSession(1L, SESSION)).thenReturn(Optional.of(existing)); + + RecruitCoreEligibilityResponse response = service.checkEligibility(1L); + + assertThat(response.eligible()).isFalse(); + assertThat(response.reason()).isEqualTo("ALREADY_APPLIED"); + assertThat(response.applicationId()).isEqualTo(10L); + } + + @Test + void submit_whenEligible_savesApplication() { + RecruitCoreApplicationCreateRequest request = sampleRequest(); + User user = createUser(1L); + RecruitCoreApplication saved = createApplication(55L, user, SESSION); + when(repository.findByUserIdAndSession(1L, SESSION)).thenReturn(Optional.empty()); + when(userRepository.findById(1L)).thenReturn(Optional.of(user)); + when(repository.save(any())).thenReturn(saved); + + RecruitCoreApplicationCreateResponse response = service.submit(1L, request); + + assertThat(response.applicationId()).isEqualTo(55L); + assertThat(response.session()).isEqualTo(SESSION); + assertThat(response.resultStatus()).isEqualTo(RecruitCoreResultStatus.SUBMITTED); + assertThat(response.submittedAt()).isNotNull(); + + ArgumentCaptor captor = + ArgumentCaptor.forClass(RecruitCoreApplication.class); + verify(repository).save(captor.capture()); + RecruitCoreApplication toSave = captor.getValue(); + assertThat(toSave.getUser()).isEqualTo(user); + assertThat(toSave.getSession()).isEqualTo(SESSION); + assertThat(toSave.getTeam()).isEqualTo("TECH"); + assertThat(toSave.getFileUrls()).containsExactly("https://file"); + } + + @Test + void submit_whenAlreadyApplied_throwsException() { + RecruitCoreApplication existing = createApplication(77L, createUser(1L), SESSION); + when(repository.findByUserIdAndSession(1L, SESSION)).thenReturn(Optional.of(existing)); + + assertThatThrownBy(() -> service.submit(1L, sampleRequest())) + .isInstanceOf(RecruitCoreAlreadyAppliedException.class); + } + + @Test + void getMyApplication_whenExists_returnsResponse() { + RecruitCoreApplication existing = createApplication(33L, createUser(1L), SESSION); + when(repository.findByUserIdAndSession(1L, SESSION)).thenReturn(Optional.of(existing)); + + RecruitCoreMyApplicationResponse response = service.getMyApplication(1L); + + assertThat(response.applicationId()).isEqualTo(33L); + assertThat(response.session()).isEqualTo(SESSION); + assertThat(response.team()).isEqualTo("TECH"); + } + + @Test + void getMyApplication_whenMissing_throwsException() { + when(repository.findByUserIdAndSession(1L, SESSION)).thenReturn(Optional.empty()); + + assertThatThrownBy(() -> service.getMyApplication(1L)) + .isInstanceOf(RecruitCoreApplicationNotFoundException.class); + } + + @Test + void getApplicantDetailForViewer_whenOwnerAlllowed() { + RecruitCoreApplication application = createApplication(99L, createUser(1L), SESSION); + when(repository.findById(99L)).thenReturn(Optional.of(application)); + + RecruitCoreApplicantDetailResponse detail = + service.getApplicantDetailForViewer(99L, 1L, UserRole.MEMBER); + + assertThat(detail.applicationId()).isEqualTo(99L); + } + + @Test + void getApplicantDetailForViewer_whenUnauthorized_throwsException() { + RecruitCoreApplication application = createApplication(99L, createUser(2L), SESSION); + when(repository.findById(99L)).thenReturn(Optional.of(application)); + + assertThatThrownBy(() -> service.getApplicantDetailForViewer(99L, 1L, UserRole.MEMBER)) + .isInstanceOf(BusinessException.class); + } + + @Test + void prefill_returnsUserSnapshot() { + User user = createUser(1L); + when(userRepository.findById(1L)).thenReturn(Optional.of(user)); + + var response = service.prefill(1L); + + assertThat(response.name()).isEqualTo("홍길동"); + assertThat(response.email()).isEqualTo("hong@inha.edu"); + } + + private RecruitCoreApplicationCreateRequest sampleRequest() { + RecruitCoreApplicationSnapshotRequest snapshot = + new RecruitCoreApplicationSnapshotRequest( + "홍길동", "12201234", "01012345678", "컴퓨터공학과", "hong@inha.edu"); + return new RecruitCoreApplicationCreateRequest( + snapshot, + "TECH", + "motivation", + "wish", + "strengths", + "pledge", + List.of("https://file")); + } + + private User createUser(Long id) { + User user = User.builder() + .name("홍길동") + .major("컴퓨터공학과") + .studentId("12201234") + .phoneNumber("01012345678") + .email("hong@inha.edu") + .userRole(UserRole.GUEST) + .team(null) + .image(null) + .social(null) + .careers(null) + .build(); + ReflectionTestUtils.setField(user, "id", id); + return user; + } + + private RecruitCoreApplication createApplication(Long id, User user, String session) { + RecruitCoreApplication application = RecruitCoreApplication.builder() + .user(user) + .session(session) + .name("홍길동") + .studentId("12201234") + .phone("01012345678") + .major("컴퓨터공학과") + .email(user.getEmail()) + .team("TECH") + .motivation("motivation") + .wish("wish") + .strengths("strengths") + .pledge("pledge") + .fileUrls(List.of()) + .resultStatus(RecruitCoreResultStatus.SUBMITTED) + .build(); + ReflectionTestUtils.setField(application, "id", id); + ReflectionTestUtils.setField(application, "createdAt", Instant.now()); + ReflectionTestUtils.setField(application, "updatedAt", Instant.now()); + return application; + } +} diff --git a/src/test/java/inha/gdgoc/domain/recruit/service/RecruitMemberServiceTest.java b/src/test/java/inha/gdgoc/domain/recruit/service/RecruitMemberServiceTest.java index 49dfd53b..4d984dd0 100644 --- a/src/test/java/inha/gdgoc/domain/recruit/service/RecruitMemberServiceTest.java +++ b/src/test/java/inha/gdgoc/domain/recruit/service/RecruitMemberServiceTest.java @@ -1,22 +1,15 @@ package inha.gdgoc.domain.recruit.service; import static org.assertj.core.api.Assertions.assertThat; -import static org.mockito.Mockito.*; -import com.fasterxml.jackson.databind.ObjectMapper; -import inha.gdgoc.domain.recruit.dto.request.ApplicationRequest; -import inha.gdgoc.domain.recruit.dto.request.RecruitMemberRequest; -import inha.gdgoc.domain.recruit.entity.RecruitMember; -import inha.gdgoc.domain.recruit.enums.EnrolledClassification; -import inha.gdgoc.domain.recruit.enums.Gender; -import inha.gdgoc.domain.recruit.repository.AnswerRepository; -import inha.gdgoc.domain.recruit.repository.RecruitMemberRepository; +import inha.gdgoc.domain.recruit.member.dto.request.RecruitMemberRequest; +import inha.gdgoc.domain.recruit.member.entity.RecruitMember; +import inha.gdgoc.domain.recruit.member.enums.EnrolledClassification; +import inha.gdgoc.domain.recruit.member.enums.Gender; + import java.time.LocalDate; import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.extension.ExtendWith; -import org.mockito.InjectMocks; -import org.mockito.Mock; -import org.mockito.junit.jupiter.MockitoExtension; + import java.util.List; import java.util.Map; @@ -29,14 +22,13 @@ void addMember_ShouldSaveRecruitMemberAndAnswers() { .name("김소연") .grade("4") .studentId("122123388") - .enrolledClassification("정등록") + .enrolledClassification("재학") .phoneNumber("010-1111-2332") .nationality("대한민국") .email("abc@gmail.com") - .gender("여자") + .gender("여성") .birth(LocalDate.of(2002, 8, 18)) .major("컴퓨터공학과") - .doubleMajor("복수전공 인공지능공학과") .isPayed(true) .build(); @@ -64,7 +56,6 @@ void addMember_ShouldSaveRecruitMemberAndAnswers() { .gender(Gender.FEMALE) .birth(LocalDate.of(2002, 8, 18)) .major("컴퓨터공학과") - .doubleMajor("복수전공 인공지능공학과") .isPayed(true) .build(); diff --git a/src/test/java/inha/gdgoc/domain/study/service/StudyAttendeeServiceTest.java b/src/test/java/inha/gdgoc/domain/study/service/StudyAttendeeServiceTest.java deleted file mode 100644 index 1d1a01ee..00000000 --- a/src/test/java/inha/gdgoc/domain/study/service/StudyAttendeeServiceTest.java +++ /dev/null @@ -1,351 +0,0 @@ -package inha.gdgoc.domain.study.service; - -import inha.gdgoc.domain.study.dto.AttendeeUpdateDto; -import inha.gdgoc.domain.study.dto.StudyAttendeeListWithMetaDto; -import inha.gdgoc.domain.study.dto.request.AttendeeCreateRequest; -import inha.gdgoc.domain.study.dto.request.AttendeeUpdateRequest; -import inha.gdgoc.domain.study.dto.response.GetStudyAttendeeResponse; -import inha.gdgoc.domain.study.entity.Study; -import inha.gdgoc.domain.study.entity.StudyAttendee; -import inha.gdgoc.domain.study.enums.AttendeeStatus; -import inha.gdgoc.domain.study.enums.CreatorType; -import inha.gdgoc.domain.study.enums.StudyStatus; -import inha.gdgoc.domain.study.repository.StudyAttendeeRepository; -import inha.gdgoc.domain.study.repository.StudyRepository; -import inha.gdgoc.domain.user.entity.User; -import inha.gdgoc.domain.user.enums.UserRole; -import inha.gdgoc.domain.user.repository.UserRepository; -import jakarta.transaction.Transactional; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Test; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.boot.test.context.SpringBootTest; - -import java.security.SecureRandom; -import java.time.LocalDateTime; -import java.util.ArrayList; -import java.util.List; -import java.util.Optional; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.assertj.core.api.AssertionsForClassTypes.assertThatThrownBy; - -@SpringBootTest -@Transactional -class StudyAttendeeServiceTest { - - @Autowired - private StudyAttendeeService studyAttendeeService; - - @Autowired - private StudyRepository studyRepository; - - @Autowired - private StudyAttendeeRepository studyAttendeeRepository; - - @Autowired - private UserRepository userRepository; - - private User user; - - @BeforeEach - void setUp() { - user = createUser(UserRole.GUEST); - userRepository.save(user); - } - - @DisplayName("스터디 참석자 목록을 페이징하여 조회한다.") - @Test - void getAttendeeListPaging() { - // given - Study study = createStudy("페이징 참석자 테스트", user); - studyRepository.save(study); - - for (int i = 0; i < 15; i++) { - User attendeeUser = createUser(UserRole.GUEST); - userRepository.save(attendeeUser); - - StudyAttendee attendee = StudyAttendee.builder() - .study(study) - .user(attendeeUser) - .status(AttendeeStatus.APPROVED) - .introduce("소개 " + i) - .activityTime("시간 " + i) - .build(); - - studyAttendeeRepository.save(attendee); - } - - // when - StudyAttendeeListWithMetaDto pageOneResult = studyAttendeeService.getStudyAttendeeList( - study.getId(), - Optional.of(1L) - ); - - StudyAttendeeListWithMetaDto pageTwoResult = studyAttendeeService.getStudyAttendeeList( - study.getId(), - Optional.of(2L) - ); - - // then - assertThat(pageOneResult).isNotNull(); - assertThat(pageOneResult.getAttendees()).hasSize(10); - assertThat(pageOneResult.getPage()).isEqualTo(1); - assertThat(pageOneResult.getPageCount()).isGreaterThanOrEqualTo(15); - - assertThat(pageTwoResult).isNotNull(); - assertThat(pageTwoResult.getAttendees()).hasSize(5); - assertThat(pageTwoResult.getPage()).isEqualTo(2); - assertThat(pageTwoResult.getPageCount()).isGreaterThanOrEqualTo(15); - } - - @DisplayName("스터디 지원자의 상세 정보를 조회한다.") - @Test - void getStudyAttendeeDetail() { - // given - Study study = createStudy("상세 정보 테스트 스터디", user); - studyRepository.save(study); - - String findName = "테스트"; - String findPhoneNumber = "010-1234-5678"; - String findMajor = "컴퓨터공학과"; - String findStudentId = "12212444"; - - String findIntroduce = "저는 사실 엄청 멋있는 사람입니다!"; - String findActivityTime = "수요일만 아니면 다 5시 이후로 가능!"; - - User attendeeUser = User.builder() - .name(findName) - .phoneNumber(findPhoneNumber) - .major(findMajor) - .studentId(findStudentId) - .email("email@example.com") - .password("pass") - .salt(new byte[16]) - .userRole(UserRole.GUEST) - .build(); - userRepository.save(attendeeUser); - - StudyAttendee attendee = StudyAttendee.builder() - .study(study) - .user(attendeeUser) - .status(AttendeeStatus.REQUESTED) - .introduce(findIntroduce) - .activityTime(findActivityTime) - .build(); - studyAttendeeRepository.save(attendee); - - // when - GetStudyAttendeeResponse response = studyAttendeeService.getStudyAttendee(user.getId(), study.getId(), - attendeeUser.getId()); - - // then - assertThat(response).isNotNull(); - assertThat(response.getName()).isEqualTo(findName); - assertThat(response.getPhone()).isEqualTo(findPhoneNumber); - assertThat(response.getMajor()).isEqualTo(findMajor); - assertThat(response.getStudentId()).isEqualTo(findStudentId); - assertThat(response.getIntroduce()).isEqualTo(findIntroduce); - assertThat(response.getActivityTime()).isEqualTo(findActivityTime); - } - - - @DisplayName("스터디에 정상적으로 지원자를 등록한다.") - @Test - void createAttendee() { - // given - Study study = createStudy("정상 지원 스터디", user); - studyRepository.save(study); - - User attendeeUser = createUser(UserRole.MEMBER); - userRepository.save(attendeeUser); - - String findIntroduce = "저는 열정 가득한 사람입니다."; - String findActivityTime = "주말 오후"; - - AttendeeCreateRequest request = AttendeeCreateRequest.builder() - .introduce(findIntroduce) - .activityTime(findActivityTime) - .build(); - - // when - GetStudyAttendeeResponse response = studyAttendeeService.createAttendee(attendeeUser.getId(), study.getId(), request); - - // then - assertThat(response).isNotNull(); - assertThat(response.getName()).isEqualTo(attendeeUser.getName()); - assertThat(response.getIntroduce()).isEqualTo(findIntroduce); - assertThat(response.getActivityTime()).isEqualTo(findActivityTime); - } - - - @DisplayName("GUEST 유저는 스터디에 지원할 수 없다.") - @Test - void createAttendee_guestUserForbidden() { - // given - Study study = createStudy("게스트 예외 스터디", user); - studyRepository.save(study); - - User guestUser = createUser(UserRole.GUEST); - userRepository.save(guestUser); - - AttendeeCreateRequest request = AttendeeCreateRequest.builder() - .introduce("참여하고 싶어요.") - .activityTime("평일 오후") - .build(); - - // when & then - assertThatThrownBy(() -> studyAttendeeService.createAttendee(guestUser.getId(), study.getId(), request)) - .isInstanceOf(RuntimeException.class) - .hasMessageContaining("사용 권한이 없는 유저입니다."); - } - - @DisplayName("스터디 참석자들의 상태를 일괄 수정한다.") - @Test - void updateAttendeeStatusBulk() { - // given - Study study = createStudy("상태 수정 테스트용 스터디", user); - studyRepository.save(study); - - User user1 = createUser(UserRole.GUEST); - User user2 = createUser(UserRole.GUEST); - userRepository.saveAll(List.of(user1, user2)); - - StudyAttendee attendee1 = StudyAttendee.builder() - .study(study) - .user(user1) - .status(AttendeeStatus.REQUESTED) - .introduce("참석자1") - .activityTime("월요일") - .build(); - - StudyAttendee attendee2 = StudyAttendee.builder() - .study(study) - .user(user2) - .status(AttendeeStatus.REQUESTED) - .introduce("참석자2") - .activityTime("화요일") - .build(); - - studyAttendeeRepository.saveAll(List.of(attendee1, attendee2)); - - // when - AttendeeStatus findStatus_1 = AttendeeStatus.APPROVED; - AttendeeStatus findStatus_2 = AttendeeStatus.REJECTED; - - AttendeeUpdateRequest updateRequest = AttendeeUpdateRequest.builder() - .attendees(List.of( - AttendeeUpdateDto.builder() - .attendeeId(attendee1.getId()) - .status(findStatus_1) - .build(), - AttendeeUpdateDto.builder() - .attendeeId(attendee2.getId()) - .status(findStatus_2) - .build() - )) - .build(); - - studyAttendeeService.updateAttendee(user.getId(), study.getId(), updateRequest); - - // then - StudyAttendee updated1 = studyAttendeeRepository.findById(attendee1.getId()).orElseThrow(); - StudyAttendee updated2 = studyAttendeeRepository.findById(attendee2.getId()).orElseThrow(); - - assertThat(updated1.getStatus()).isEqualTo(findStatus_1); - assertThat(updated2.getStatus()).isEqualTo(findStatus_2); - } - - @DisplayName("스터디 참석자들의 상태를 일괄 수정한다. 단, 생성자만 수정할 수 있다.") - @Test - void updateAttendeeStatusBulkOnlyCreatorUser() { - // given - Study study = createStudy("상태 수정 테스트용 스터디", user); - studyRepository.save(study); - - User user1 = createUser(UserRole.GUEST); - User user2 = createUser(UserRole.GUEST); - userRepository.saveAll(List.of(user1, user2)); - - StudyAttendee attendee1 = StudyAttendee.builder() - .study(study) - .user(user1) - .status(AttendeeStatus.REQUESTED) - .introduce("참석자1") - .activityTime("월요일") - .build(); - - StudyAttendee attendee2 = StudyAttendee.builder() - .study(study) - .user(user2) - .status(AttendeeStatus.REQUESTED) - .introduce("참석자2") - .activityTime("화요일") - .build(); - - studyAttendeeRepository.saveAll(List.of(attendee1, attendee2)); - - // when - AttendeeStatus findStatus_1 = AttendeeStatus.APPROVED; - AttendeeStatus findStatus_2 = AttendeeStatus.REJECTED; - - AttendeeUpdateRequest updateRequest = AttendeeUpdateRequest.builder() - .attendees(List.of( - AttendeeUpdateDto.builder() - .attendeeId(attendee1.getId()) - .status(findStatus_1) - .build(), - AttendeeUpdateDto.builder() - .attendeeId(attendee2.getId()) - .status(findStatus_2) - .build() - )) - .build(); - - assertThatThrownBy(() -> studyAttendeeService.updateAttendee(user1.getId(), study.getId(), updateRequest)) - .isInstanceOf(IllegalArgumentException.class); - } - - private User createUser( - UserRole userRole - ) { - byte[] salt = new byte[16]; - SecureRandom random = new SecureRandom(); - random.nextBytes(salt); - - return User.builder() - .name("name") - .major("major") - .studentId("studentId") - .phoneNumber("phoneNumber") - .email("email") - .password("hashedPassword") - .salt(salt) - .userRole(userRole) - .studies(new ArrayList<>()) - .studyAttendees(new ArrayList<>()) - .build(); - } - - private Study createStudy( - String title, - User user - ) { - return Study.builder() - .title(title) - .simpleIntroduce("간단한 소개") - .activityIntroduce("활동 소개") - .imagePath("test url") - .creatorType(CreatorType.PERSONAL) - .status(StudyStatus.RECRUITED) - .expectedTime("매일매일") - .expectedPlace("인하대정문") - .recruitStartDate(LocalDateTime.now()) - .recruitEndDate(LocalDateTime.now()) - .activityStartDate(LocalDateTime.now()) - .activityEndDate(LocalDateTime.now()) - .user(user) - .build(); - } -} \ No newline at end of file diff --git a/src/test/java/inha/gdgoc/domain/study/service/StudyServiceTest.java b/src/test/java/inha/gdgoc/domain/study/service/StudyServiceTest.java deleted file mode 100644 index 3680ebed..00000000 --- a/src/test/java/inha/gdgoc/domain/study/service/StudyServiceTest.java +++ /dev/null @@ -1,322 +0,0 @@ -package inha.gdgoc.domain.study.service; - -import inha.gdgoc.domain.resource.service.S3Service; -import inha.gdgoc.domain.study.dto.StudyAttendeeResultDto; -import inha.gdgoc.domain.study.dto.StudyDto; -import inha.gdgoc.domain.study.dto.StudyListWithMetaDto; -import inha.gdgoc.domain.study.dto.request.StudyCreateRequest; -import inha.gdgoc.domain.study.dto.response.GetCreatorResponse; -import inha.gdgoc.domain.study.dto.response.GetDetailedStudyResponse; -import inha.gdgoc.domain.study.dto.response.MyStudyRecruitResponse; -import inha.gdgoc.domain.study.entity.Study; -import inha.gdgoc.domain.study.entity.StudyAttendee; -import inha.gdgoc.domain.study.enums.AttendeeStatus; -import inha.gdgoc.domain.study.enums.CreatorType; -import inha.gdgoc.domain.study.enums.StudyStatus; -import inha.gdgoc.domain.study.repository.StudyAttendeeRepository; -import inha.gdgoc.domain.study.repository.StudyRepository; -import inha.gdgoc.domain.user.entity.User; -import inha.gdgoc.domain.user.enums.UserRole; -import inha.gdgoc.domain.user.repository.UserRepository; -import jakarta.transaction.Transactional; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Test; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.boot.test.context.SpringBootTest; -import org.springframework.test.context.bean.override.mockito.MockitoBean; - -import java.security.SecureRandom; -import java.time.LocalDateTime; -import java.util.ArrayList; -import java.util.List; -import java.util.Optional; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.assertj.core.api.Assertions.assertThatThrownBy; -import static org.mockito.ArgumentMatchers.anyString; -import static org.mockito.Mockito.when; - - -@SpringBootTest -@Transactional -class StudyServiceTest { - - @MockitoBean - private S3Service s3Service; - - @Autowired - private StudyService studyService; - - @Autowired - private StudyAttendeeService studyAttendeeService; - - @Autowired - private StudyRepository studyRepository; - - @Autowired - private StudyAttendeeRepository studyAttendeeRepository; - - @Autowired - private UserRepository userRepository; - - private User user; - - @BeforeEach - void setUp() { - user = createUser(); - userRepository.save(user); - when(s3Service.getS3FileUrl(anyString())).thenReturn("http://test.image"); - } - - - @DisplayName("해당 스터디 정보를 id로 조회한다.") - @Test - void getStudyById() { - // given - String findTitle = "테스트제목"; - Study findStudy = createStudy(findTitle, user); - studyRepository.save(findStudy); - - // when - GetDetailedStudyResponse resultStudy = studyService.getStudyById(findStudy.getId()); - - // then - assertThat(resultStudy).isNotNull(); - assertThat(resultStudy.creator()).isEqualTo(GetCreatorResponse.from(user)); - assertThat(resultStudy.title()).isEqualTo(findTitle); - } - - @DisplayName("해당 스터디 id가 없다면 에러가 발생한다.") - @Test - void getStudyByIdNotFound() { - // then - assertThatThrownBy(() -> { - studyService.getStudyById(99999L); - }).isInstanceOf(RuntimeException.class); - } - - @DisplayName("스터디 목록을 페이징하여 조회한다.") - @Test - void getStudyList() { - // given - for (int i = 0; i < 15; i++) { - studyRepository.save(createStudy("스터디" + i, user)); - } - - // when - StudyListWithMetaDto page_ONE_Result = studyService.getStudyList( - Optional.of(1L), - Optional.empty(), - Optional.empty() - ); - - StudyListWithMetaDto page_TWO_Result = studyService.getStudyList( - Optional.of(2L), - Optional.empty(), - Optional.empty() - ); - - // then - assertThat(page_ONE_Result).isNotNull(); - assertThat(page_ONE_Result.getStudyList()).hasSize(10); - assertThat(page_ONE_Result.getPage()).isEqualTo(1L); - assertThat(page_ONE_Result.getPageCount()).isGreaterThanOrEqualTo(15); - - assertThat(page_TWO_Result).isNotNull(); - assertThat(page_TWO_Result.getStudyList()).hasSize(5); - assertThat(page_TWO_Result.getPage()).isEqualTo(2L); - assertThat(page_TWO_Result.getPageCount()).isGreaterThanOrEqualTo(15); - } - - @DisplayName("page가 1보다 작으면 예외가 발생한다.") - @Test - void getStudyListInvalidPage() { - // then - assertThatThrownBy(() -> { - studyService.getStudyList( - Optional.of(0L), - Optional.empty(), - Optional.empty() - ); - }).isInstanceOf(RuntimeException.class) - .hasMessageContaining("page가 1보다 작을 수 없습니다"); - } - - - @DisplayName("스터디를 생성한다.") - @Test - void createStudy() { - // given - String findTitle = "스터디 제목"; - StudyCreateRequest request = StudyCreateRequest.builder() - .title(findTitle) - .simpleIntroduce("간단한 소개") - .activityIntroduce("활동 소개") - .creatorType(CreatorType.PERSONAL) - .expectedTime("오후 2시") - .expectedPlace("인하대학교 도서관") - .recruitStartDate(LocalDateTime.of(2025, 5, 10, 12, 0)) - .recruitEndDate(LocalDateTime.of(2025, 5, 15, 18, 0)) - .activityStartDate(LocalDateTime.of(2025, 5, 20, 14, 0)) - .activityEndDate(LocalDateTime.of(2025, 6, 20, 16, 0)) - .build(); - - // when - StudyDto result = studyService.createStudy(user.getId(), request); - - // then - assertThat(result).isNotNull(); - assertThat(result.getCreatorId()).isEqualTo(user.getId()); - assertThat(result.getTitle()).isEqualTo(findTitle); - - Study saved = studyRepository.findById(result.getId()).orElseThrow(); - assertThat(saved.getUser().getId()).isEqualTo(user.getId()); - assertThat(saved.getTitle()).isEqualTo(findTitle); - } - - @DisplayName("특정 지원자의 스터디 결과 리스트를 조회한다.") - @Test - void getStudyAttendeeResultListByUserId() { - // given - String resultTitle_1 = "AI 스터디"; - String resultIntroduce_1 = "AI에 관심 많습니다."; - String resultActivityTime_1 = "저녁"; - AttendeeStatus resultStatus_1 = AttendeeStatus.APPROVED; - - String resultTitle_2 = "블록체인 스터디"; - String resultIntroduce_2 = "블록체인도 배우고 싶어요."; - String resultActivityTime_2 = "주말"; - AttendeeStatus resultStatus_2 = AttendeeStatus.REQUESTED; - - Study study1 = createStudy(resultTitle_1, user); - Study study2 = createStudy(resultTitle_2, user); - studyRepository.saveAll(List.of(study1, study2)); - - - StudyAttendee attendee1 = StudyAttendee.builder() - .study(study1) - .user(user) - .status(resultStatus_1) - .introduce(resultIntroduce_1) - .activityTime(resultActivityTime_1) - .build(); - - StudyAttendee attendee2 = StudyAttendee.builder() - .study(study2) - .user(user) - .status(resultStatus_2) - .introduce(resultIntroduce_2) - .activityTime(resultActivityTime_2) - .build(); - - studyAttendeeRepository.saveAll(List.of(attendee1, attendee2)); - - // when - List result = studyAttendeeService.getStudyAttendeeResultListByUserId(user.getId()); - - // then - StudyAttendeeResultDto dto1 = result.get(1); - StudyAttendeeResultDto dto2 = result.get(0); - - assertThat(result).hasSize(2); - assertThat(dto1.getStudyId()).isEqualTo(study1.getId()); - assertThat(dto1.getTitle()).isEqualTo(resultTitle_1); - assertThat(dto1.getStatus()).isEqualTo(resultStatus_1); - - assertThat(dto2.getStudyId()).isEqualTo(study2.getId()); - assertThat(dto2.getTitle()).isEqualTo(resultTitle_2); - assertThat(dto2.getStatus()).isEqualTo(resultStatus_2); - } - - @DisplayName("내가 만든 스터디 목록을 모집 상태별로 조회한다.") - @Test - void getMyStudyList() { - // given - User creator = createUser(); - userRepository.save(creator); - - String find_recruiting_title = "AI 스터디"; - String find_recruited_title = "블록체인 스터디"; - - Study recruitingStudy1 = createRecruitStudy( - find_recruiting_title, - LocalDateTime.of(2025, 4, 10, 0, 0), - LocalDateTime.of(2025, 6, 10, 0, 0), - StudyStatus.RECRUITING, - creator - ); - - Study recruitedStudy1 = createRecruitStudy( - find_recruited_title, - LocalDateTime.of(2025, 3, 1, 0, 0), - LocalDateTime.of(2025, 4, 30, 0, 0), - StudyStatus.RECRUITED, - creator - ); - - studyRepository.saveAll(List.of(recruitingStudy1, recruitedStudy1)); - - // when - MyStudyRecruitResponse response = studyService.getMyStudyList(creator.getId()); - - // then - assertThat(response).isNotNull(); - assertThat(response.getRecruiting()).hasSize(1); - assertThat(response.getRecruiting().get(0).getTitle()).isEqualTo(find_recruiting_title); - - assertThat(response.getRecruited()).hasSize(1); - assertThat(response.getRecruited().get(0).getTitle()).isEqualTo(find_recruited_title); - } - - - private User createUser() { - byte[] salt = new byte[16]; - SecureRandom random = new SecureRandom(); - random.nextBytes(salt); - - return User.builder() - .name("name") - .major("major") - .studentId("studentId") - .phoneNumber("phoneNumber") - .email("email") - .password("hashedPassword") - .salt(salt) - .userRole(UserRole.GUEST) - .studies(new ArrayList<>()) - .studyAttendees(new ArrayList<>()) - .build(); - } - - private Study createStudy( - String title, - User user - ) { - return this.createRecruitStudy(title, LocalDateTime.now(), LocalDateTime.now(), StudyStatus.RECRUITED, user); - } - - private Study createRecruitStudy( - String title, - LocalDateTime activityStartDate, - LocalDateTime activityEndDate, - StudyStatus status, - User user - ) { - return Study.builder() - .title(title) - .simpleIntroduce("간단한 소개") - .activityIntroduce("활동 소개") - .imagePath("test url") - .creatorType(CreatorType.PERSONAL) - .status(status) - .expectedTime("매일매일") - .expectedPlace("인하대정문") - .recruitStartDate(LocalDateTime.now()) - .recruitEndDate(LocalDateTime.now()) - .activityStartDate(activityStartDate) - .activityEndDate(activityEndDate) - .user(user) - .build(); - } -} \ No newline at end of file diff --git a/src/test/resources/application-test.yml b/src/test/resources/application-test.yml index f9473ec3..e42b4eee 100644 --- a/src/test/resources/application-test.yml +++ b/src/test/resources/application-test.yml @@ -2,22 +2,25 @@ server: forward-headers-strategy: none spring: - jackson: - time-zone: Asia/Seoul - + cloud: + aws: + credentials: + access-key: test + secret-key: test + region: + static: ap-northeast-2 datasource: driver-class-name: org.h2.Driver + password: url: jdbc:h2:mem:gdgoc-test;MODE=PostgreSQL;DB_CLOSE_DELAY=-1;DATABASE_TO_LOWER=TRUE username: sa - password: - - servlet: - multipart: - max-file-size: 10MB - max-request-size: 12MB - + flyway: + enabled: false + jackson: + time-zone: Asia/Seoul jpa: database: h2 + database-platform: org.hibernate.dialect.H2Dialect hibernate: ddl-auto: create-drop properties: @@ -26,30 +29,28 @@ spring: format_sql: true show_sql: false time_zone: Asia/Seoul - database-platform: org.hibernate.dialect.H2Dialect show-sql: false - - flyway: - enabled: false - mail: host: localhost - port: 2525 - username: test password: test + port: 2525 properties: mail: smtp: auth: false starttls: enable: false + username: test main: allow-bean-definition-overriding: true + servlet: + multipart: + max-file-size: 10MB + max-request-size: 12MB -logging: - level: - org.hibernate.SQL: warn - org.hibernate.type: warn +app: + s3: + bucket: test-bucket google: client-id: test-client-id @@ -58,15 +59,10 @@ google: jwt: googleIssuer: test-google-issuer - selfIssuer: test-self-issuer secretKey: MDEyMzQ1Njc4OWFiY2RlZjAxMjM0NTY3ODlhYmNkZWY= + selfIssuer: test-self-issuer -cloud: - aws: - credentials: - access-key: test - secret-key: test - region: - static: ap-northeast-2 - s3: - bucket: test-bucket +logging: + level: + org.hibernate.SQL: warn + org.hibernate.type: warn