-
Notifications
You must be signed in to change notification settings - Fork 1
core recruit 기능 이관 및 권한/학기 공통 컴포넌트 적용 #276
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
6 commits
Select commit
Hold shift + click to select a range
749fb53
feat: add core recruit application workflows
CSE-Shaco 441cdea
feat: add AccessGuard for reusable authorization checks
CSE-Shaco 56c2064
refactor: inject SemesterCalculator instead of env-driven semester
CSE-Shaco f234af5
feat: add core recruit application workflows
CSE-Shaco d476cc5
refactor: move recruit member module under dedicated namespace
CSE-Shaco 16698f6
fix: correct AccessGuard SpEL references and expose attendance rules
CSE-Shaco File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
91 changes: 91 additions & 0 deletions
91
...main/java/inha/gdgoc/domain/admin/recruit/core/controller/RecruitCoreAdminController.java
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,91 @@ | ||
| package inha.gdgoc.domain.admin.recruit.core.controller; | ||
|
|
||
| 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.RecruitCoreApplicantSummaryResponse; | ||
| import inha.gdgoc.domain.admin.recruit.core.dto.response.RecruitCoreApplicationDecisionResponse; | ||
| import inha.gdgoc.domain.admin.recruit.core.dto.response.RecruitCoreApplicationPageResponse; | ||
| import inha.gdgoc.domain.admin.recruit.core.service.RecruitCoreAdminService; | ||
| 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.global.config.jwt.TokenProvider.CustomUserDetails; | ||
| import jakarta.validation.Valid; | ||
| import lombok.RequiredArgsConstructor; | ||
| import org.springframework.data.domain.Page; | ||
| import org.springframework.data.domain.PageRequest; | ||
| import org.springframework.data.domain.Pageable; | ||
| 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.RequestParam; | ||
| import org.springframework.web.bind.annotation.RestController; | ||
|
|
||
| @RestController | ||
| @RequestMapping("/api/v1/admin/recruit/core/applications") | ||
| @RequiredArgsConstructor | ||
| public class RecruitCoreAdminController { | ||
|
|
||
| private static final String ORGANIZER_OR_HR_LEAD_RULE = | ||
| "@accessGuard.check(authentication," | ||
| + " T(inha.gdgoc.global.security.AccessGuard$AccessCondition).atLeast(" | ||
| + "T(inha.gdgoc.domain.user.enums.UserRole).ORGANIZER)," | ||
| + " T(inha.gdgoc.global.security.AccessGuard$AccessCondition).of(" | ||
| + "T(inha.gdgoc.domain.user.enums.UserRole).LEAD," | ||
| + " T(inha.gdgoc.domain.user.enums.TeamType).HR))"; | ||
|
|
||
| private final RecruitCoreAdminService adminService; | ||
|
|
||
| @PreAuthorize("hasAnyRole('ADMIN','ORGANIZER')") | ||
| @GetMapping | ||
| public RecruitCoreApplicationPageResponse list( | ||
| @RequestParam String session, | ||
| @RequestParam(required = false) RecruitCoreResultStatus status, | ||
| @RequestParam(required = false) TeamType team, | ||
| @RequestParam(defaultValue = "0") int page, | ||
| @RequestParam(defaultValue = "20") int size | ||
| ) { | ||
| Pageable pageable = PageRequest.of(page, size); | ||
| Page<RecruitCoreApplication> result = adminService.searchApplications(session, status, team, pageable); | ||
| java.util.List<RecruitCoreApplicantSummaryResponse> 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<RecruitCoreApplicationDecisionResponse> 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<RecruitCoreApplicationDecisionResponse> reject( | ||
| @AuthenticationPrincipal CustomUserDetails reviewer, | ||
| @PathVariable Long applicationId, | ||
| @Valid @RequestBody RecruitCoreApplicationRejectRequest request | ||
| ) { | ||
| RecruitCoreApplicationDecisionResponse response = | ||
| adminService.reject(applicationId, reviewer.getUserId(), request); | ||
| return ResponseEntity.ok(response); | ||
| } | ||
| } |
10 changes: 10 additions & 0 deletions
10
...inha/gdgoc/domain/admin/recruit/core/dto/request/RecruitCoreApplicationAcceptRequest.java
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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 | ||
| ) { | ||
| } |
8 changes: 8 additions & 0 deletions
8
...inha/gdgoc/domain/admin/recruit/core/dto/request/RecruitCoreApplicationRejectRequest.java
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,8 @@ | ||
| package inha.gdgoc.domain.admin.recruit.core.dto.request; | ||
|
|
||
| import jakarta.validation.constraints.NotBlank; | ||
|
|
||
| public record RecruitCoreApplicationRejectRequest( | ||
| @NotBlank String resultNote | ||
| ) { | ||
| } |
30 changes: 30 additions & 0 deletions
30
...nha/gdgoc/domain/admin/recruit/core/dto/response/RecruitCoreApplicantSummaryResponse.java
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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() | ||
| ); | ||
| } | ||
| } |
44 changes: 44 additions & 0 deletions
44
.../gdgoc/domain/admin/recruit/core/dto/response/RecruitCoreApplicationDecisionResponse.java
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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) {} | ||
| } |
31 changes: 31 additions & 0 deletions
31
...inha/gdgoc/domain/admin/recruit/core/dto/response/RecruitCoreApplicationPageResponse.java
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,31 @@ | ||
| package inha.gdgoc.domain.admin.recruit.core.dto.response; | ||
|
|
||
| import java.util.List; | ||
|
|
||
| public record RecruitCoreApplicationPageResponse( | ||
| List<RecruitCoreApplicantSummaryResponse> content, | ||
| Pageable pageable, | ||
| long totalElements, | ||
| int totalPages, | ||
| boolean last | ||
| ) { | ||
|
|
||
| public static RecruitCoreApplicationPageResponse from( | ||
| List<RecruitCoreApplicantSummaryResponse> 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) {} | ||
| } |
114 changes: 114 additions & 0 deletions
114
src/main/java/inha/gdgoc/domain/admin/recruit/core/service/RecruitCoreAdminService.java
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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<RecruitCoreApplication> searchApplications( | ||
| String session, | ||
| RecruitCoreResultStatus status, | ||
| TeamType team, | ||
| Pageable pageable | ||
| ) { | ||
| Specification<RecruitCoreApplication> 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<RecruitCoreApplication> 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; | ||
| } | ||
| } | ||
| } | ||
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🧩 Analysis chain
🏁 Script executed:
Repository: GDGoCINHA/24-2_GDGoC_Server
Length of output: 539
🏁 Script executed:
Repository: GDGoCINHA/24-2_GDGoC_Server
Length of output: 241
🏁 Script executed:
Repository: GDGoCINHA/24-2_GDGoC_Server
Length of output: 20848
🏁 Script executed:
Repository: GDGoCINHA/24-2_GDGoC_Server
Length of output: 53
🏁 Script executed:
Repository: GDGoCINHA/24-2_GDGoC_Server
Length of output: 3832
🏁 Script executed:
# Check ensureDecidable method in the service file cat src/main/java/inha/gdgoc/domain/admin/recruit/core/service/RecruitCoreAdminService.javaRepository: GDGoCINHA/24-2_GDGoC_Server
Length of output: 4612
🏁 Script executed:
# Check if repository uses pessimistic locking cat src/main/java/inha/gdgoc/domain/recruit/core/repository/RecruitCoreApplicationRepository.javaRepository: GDGoCINHA/24-2_GDGoC_Server
Length of output: 871
🏁 Script executed:
# Check BaseEntity to see if it has `@Version` cat src/main/java/inha/gdgoc/global/entity/BaseEntity.javaRepository: GDGoCINHA/24-2_GDGoC_Server
Length of output: 804
동시 요청 시 race condition 발생 가능 - 락 메커니즘 필수
동일한 지원서에 대해 여러 관리자가 동시에
accept()/reject()를 호출할 경우,ensureDecidable()검사는 원자적(atomic)이지 않아 둘 다 통과한 후 중복 처리될 수 있습니다. 현재RecruitCoreApplication엔티티에는@Version어노테이션이 없고, 저장소 메서드도@Lock을 사용하지 않으므로 낙관적/비관적 잠금이 적용되지 않아 있습니다.이 경우 두 스레드가 동시에 상태 업데이트를 시도할 때, 첫 번째 스레드의
reviewedBy,reviewedAt,resultNote값이 두 번째 스레드에 의해 덮어씌워져 데이터 손실이 발생할 수 있습니다. 낙관적 잠금(@Version)이나 비관적 잠금(@Lock(LockModeType.PESSIMISTIC_WRITE))을 추가하여 동시성 제어를 해야 합니다.🤖 Prompt for AI Agents