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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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);
}
}
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
) {
}
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
) {
}
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()
);
}
}
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) {}
}
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) {}
}
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
);
}
Comment on lines +46 to +73
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

find . -type f -name "*.java" | xargs grep -l "class RecruitCoreApplication" | head -5

Repository: GDGoCINHA/24-2_GDGoC_Server

Length of output: 539


🏁 Script executed:

# Find the RecruitCoreApplication entity
fd -e java -type f | xargs grep -l "RecruitCoreApplication" | grep -E "(entity|domain)" | head -10

Repository: GDGoCINHA/24-2_GDGoC_Server

Length of output: 241


🏁 Script executed:

# Search for RecruitCoreApplication class definition
rg "class RecruitCoreApplication" -A 50 --max-count=1

Repository: GDGoCINHA/24-2_GDGoC_Server

Length of output: 20848


🏁 Script executed:

# Check for `@Version` annotation in the project
rg "@Version" --type java -B 2 -A 2

Repository: GDGoCINHA/24-2_GDGoC_Server

Length of output: 53


🏁 Script executed:

cat src/main/java/inha/gdgoc/domain/recruit/core/entity/RecruitCoreApplication.java

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.java

Repository: 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.java

Repository: 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.java

Repository: 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
In
`@src/main/java/inha/gdgoc/domain/admin/recruit/core/service/RecruitCoreAdminService.java`
around lines 46 - 73, The accept(...) flow has a race where ensureDecidable()
can pass concurrently; fix by adding concurrency control: either add an
optimistic lock field (`@Version`) to the RecruitCoreApplication entity and
persist it so concurrent commits will fail fast, or load the application with a
pessimistic lock in getApplication() by adding a repository method annotated
with `@Lock`(LockModeType.PESSIMISTIC_WRITE) (e.g.,
RecruitCoreApplicationRepository.findByIdWithLock(...) used by
getApplication()). Keep the `@Transactional` on accept(), handle
OptimisticLockException / LockTimeoutException to return an appropriate error,
and ensure ensureDecidable() runs after acquiring the lock so only one thread
proceeds to application.accept(...).


@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;
}
}
}
Loading
Loading