diff --git a/src/main/java/inha/gdgoc/domain/admin/recruit/core/controller/RecruitCoreAdminController.java b/src/main/java/inha/gdgoc/domain/admin/recruit/core/controller/RecruitCoreAdminController.java new file mode 100644 index 0000000..0a129b8 --- /dev/null +++ b/src/main/java/inha/gdgoc/domain/admin/recruit/core/controller/RecruitCoreAdminController.java @@ -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 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 0000000..276fb13 --- /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 0000000..656f6ac --- /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 0000000..8babc28 --- /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 0000000..6d47f6e --- /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 0000000..02ce05c --- /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 0000000..2b6a41f --- /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/auth/controller/AuthController.java b/src/main/java/inha/gdgoc/domain/auth/controller/AuthController.java index 52ca638..67bc054 100644 --- a/src/main/java/inha/gdgoc/domain/auth/controller/AuthController.java +++ b/src/main/java/inha/gdgoc/domain/auth/controller/AuthController.java @@ -20,6 +20,7 @@ import inha.gdgoc.global.config.jwt.TokenProvider; import inha.gdgoc.global.dto.response.ApiResponse; import inha.gdgoc.global.exception.GlobalErrorCode; +import inha.gdgoc.global.security.AccessGuard; import jakarta.servlet.http.HttpServletResponse; import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; @@ -52,6 +53,7 @@ public class AuthController { private final RefreshTokenService refreshTokenService; private final MailService mailService; private final AuthCodeService authCodeService; + private final AccessGuard accessGuard; @GetMapping("/oauth2/google/callback") public ResponseEntity, Void>> handleGoogleCallback(@RequestParam String code, HttpServletResponse response) { @@ -166,34 +168,37 @@ public ResponseEntity> resetPassword(@RequestBody Passwo * 예) /api/v1/auth/LEAD, /api/v1/auth/ORGANIZER, /api/v1/auth/ADMIN */ @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); + var conditions = new java.util.ArrayList(); + conditions.add(AccessGuard.AccessCondition.atLeast(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); - } + conditions.add(AccessGuard.AccessCondition.atLeast(UserRole.ORGANIZER)); + conditions.add(AccessGuard.AccessCondition.of(UserRole.GUEST, requiredTeam)); } - // 4) OR 조건으로 최종 판정 - if (roleOk || teamOk) { + if (accessGuard.check(me, conditions.toArray(AccessGuard.AccessCondition[]::new))) { 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/core/attendance/controller/CoreAttendanceController.java b/src/main/java/inha/gdgoc/domain/core/attendance/controller/CoreAttendanceController.java index 552ce67..c97d4c9 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 @@ -30,9 +30,18 @@ @RestController @RequestMapping("/api/v1/core-attendance/meetings") @RequiredArgsConstructor -@PreAuthorize("hasAnyRole('LEAD','ORGANIZER','ADMIN')") +@PreAuthorize(CoreAttendanceController.LEAD_OR_HIGHER_RULE) public class CoreAttendanceController { + 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))"; + private final CoreAttendanceService service; /* ===== helpers ===== */ @@ -51,14 +60,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()); @@ -140,4 +149,4 @@ public ResponseEntity summaryCsvAll( .body(csv); } -} \ No newline at end of file +} 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 7efdd33..0000000 --- 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 eb46f35..0000000 --- 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 efbd4d0..0000000 --- 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 e000398..0000000 --- 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 e5611f8..0000000 --- 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 f6a6281..0000000 --- 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 b251e91..0000000 --- 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 673495a..1767ebb 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 0000000..6f3cade --- /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 0000000..50c1fbc --- /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 daad2a5..e94ae51 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 0000000..dc946b0 --- /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 0000000..55bbcda --- /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 0000000..f61236e --- /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 0000000..fe5b516 --- /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 0000000..bfa101a --- /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 0000000..3a583eb --- /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 0000000..81eae37 --- /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 0000000..51becba --- /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 0000000..dae4cd5 --- /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 0000000..85d50c3 --- /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 0000000..33f56f9 --- /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 0000000..ef91aeb --- /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 0000000..5287f02 --- /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 0000000..1b77932 --- /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 0000000..0c4a9b7 --- /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 0000000..1ae4b87 --- /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 findByUser_IdAndSession(Long userId, String session); + + java.util.Optional findByIdAndUser_Id(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 0000000..b38b5f9 --- /dev/null +++ b/src/main/java/inha/gdgoc/domain/recruit/core/service/RecruitCoreApplicationService.java @@ -0,0 +1,119 @@ +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.findByUser_IdAndSession(userId, session) + .map(app -> RecruitCoreEligibilityResponse.ineligible(session, "ALREADY_APPLIED", app.getId())) + .orElseGet(() -> RecruitCoreEligibilityResponse.eligible(session)); + } + + @Transactional(readOnly = true) + public RecruitCorePrefillResponse prefill(Long userId) { + User user = getUser(userId); + return RecruitCorePrefillResponse.from(user); + } + + @Transactional + public RecruitCoreApplicationCreateResponse submit(Long userId, RecruitCoreApplicationCreateRequest request) { + String session = recruitCoreSessionResolver.currentSession(); + repository.findByUser_IdAndSession(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()); + RecruitCoreApplication application = RecruitCoreApplication.builder() + .user(user) + .session(session) + .name(request.snapshot().name()) + .studentId(request.snapshot().studentId()) + .phone(request.snapshot().phone()) + .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.findByUser_IdAndSession(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 4485f2a..0000000 --- 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 75% 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 9cd59b2..7b368c4 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,21 +1,21 @@ -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.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.PaymentUpdateRequest; +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; @@ -48,6 +48,14 @@ @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).atLeast(" + + "T(inha.gdgoc.domain.user.enums.UserRole).CORE," + + " T(inha.gdgoc.domain.user.enums.TeamType).HR))"; + private final RecruitMemberService recruitMemberService; @PostMapping("/apply") @@ -85,7 +93,7 @@ public ResponseEntity> duplicatedPho } @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("/recruit/members/{memberId}") public ResponseEntity> getSpecifiedMember( @PathVariable Long memberId @@ -100,7 +108,7 @@ 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") + @PreAuthorize(LEAD_OR_HR_RULE) @PatchMapping("/recruit/members/{memberId}/payment") public ResponseEntity> updatePayment( @PathVariable Long memberId, @@ -122,7 +130,7 @@ 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") + @PreAuthorize(LEAD_OR_HR_RULE) @GetMapping("/recruit/members") public ResponseEntity, PageMeta>> getMembers( @Parameter(description = "검색어(이름 부분 일치). 없으면 전체 조회", example = "소연") 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 93% 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 8e8a3ea..813abaf 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,4 +1,4 @@ -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 = "성공적으로 해당 학기 멤버 가입을 완료했습니다."; 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 53a001d..2f0eb45 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/dto/request/CheckPhoneNumberRequest.java b/src/main/java/inha/gdgoc/domain/recruit/member/dto/request/CheckPhoneNumberRequest.java similarity index 88% rename from src/main/java/inha/gdgoc/domain/recruit/dto/request/CheckPhoneNumberRequest.java rename to src/main/java/inha/gdgoc/domain/recruit/member/dto/request/CheckPhoneNumberRequest.java index a448edd..15e3541 100644 --- a/src/main/java/inha/gdgoc/domain/recruit/dto/request/CheckPhoneNumberRequest.java +++ b/src/main/java/inha/gdgoc/domain/recruit/member/dto/request/CheckPhoneNumberRequest.java @@ -1,4 +1,4 @@ -package inha.gdgoc.domain.recruit.dto.request; +package inha.gdgoc.domain.recruit.member.dto.request; import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.Pattern; 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 3a6f176..816e490 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 72% 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 6f0886c..695ab49 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,9 @@ -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 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; @@ -28,7 +28,7 @@ public class RecruitMemberRequest { private String doubleMajor; private Boolean isPayed; - public RecruitMember toEntity() { + public RecruitMember toEntity(AdmissionSemester admissionSemester) { return RecruitMember.builder() .name(name) .grade(grade) @@ -42,7 +42,7 @@ public RecruitMember toEntity() { .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 3ac3d17..e8688e8 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 896c8cc..d3f9bfc 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/dto/response/CheckPhoneNumberResponse.java b/src/main/java/inha/gdgoc/domain/recruit/member/dto/response/CheckPhoneNumberResponse.java similarity index 53% rename from src/main/java/inha/gdgoc/domain/recruit/dto/response/CheckPhoneNumberResponse.java rename to src/main/java/inha/gdgoc/domain/recruit/member/dto/response/CheckPhoneNumberResponse.java index 759f42f..8ad4cdb 100644 --- a/src/main/java/inha/gdgoc/domain/recruit/dto/response/CheckPhoneNumberResponse.java +++ b/src/main/java/inha/gdgoc/domain/recruit/member/dto/response/CheckPhoneNumberResponse.java @@ -1,4 +1,4 @@ -package inha.gdgoc.domain.recruit.dto.response; +package inha.gdgoc.domain.recruit.member.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/recruit/member/dto/response/CheckStudentIdResponse.java similarity index 52% rename from src/main/java/inha/gdgoc/domain/recruit/dto/response/CheckStudentIdResponse.java rename to src/main/java/inha/gdgoc/domain/recruit/member/dto/response/CheckStudentIdResponse.java index 8537486..77c4de0 100644 --- a/src/main/java/inha/gdgoc/domain/recruit/dto/response/CheckStudentIdResponse.java +++ b/src/main/java/inha/gdgoc/domain/recruit/member/dto/response/CheckStudentIdResponse.java @@ -1,4 +1,4 @@ -package inha.gdgoc.domain.recruit.dto.response; +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 1e6618c..8b078ed 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 838bd30..11e912a 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 924089c..763b67a 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 91% 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 b09d978..28292af 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; 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 0000000..1bb68b5 --- /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 93% 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 3e0089d..f5ba541 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,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/Gender.java b/src/main/java/inha/gdgoc/domain/recruit/member/enums/Gender.java similarity index 90% 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 9a5c6ac..a7170fe 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,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/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 ed9f5cc..40db4f2 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 5c4c901..667942f 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 e78520a..f1b0c43 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 8d07d43..2c5800c 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 3ae036d..ad00ce9 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 79% 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 04c0c08..88a6669 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; 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 72% 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 3d7a7f5..24ff954 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,20 @@ -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.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 +28,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() 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 ae5b121..0000000 --- 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/UserAdminController.java b/src/main/java/inha/gdgoc/domain/user/controller/UserAdminController.java index 56135d9..3bb20d7 100644 --- a/src/main/java/inha/gdgoc/domain/user/controller/UserAdminController.java +++ b/src/main/java/inha/gdgoc/domain/user/controller/UserAdminController.java @@ -24,11 +24,23 @@ @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).atLeast(" + + "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 +56,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 +68,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 +80,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 +89,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/global/security/AccessGuard.java b/src/main/java/inha/gdgoc/global/security/AccessGuard.java new file mode 100644 index 0000000..6341332 --- /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 4ef5b2e..775ccb3 100644 --- a/src/main/java/inha/gdgoc/global/security/SecurityConfig.java +++ b/src/main/java/inha/gdgoc/global/security/SecurityConfig.java @@ -49,7 +49,6 @@ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { "/api/v1/game/**", "/api/v1/apply/**", "/api/v1/check/**", - "/api/v1/core-recruit", "/api/v1/fileupload", "/api/v1/manito/verify") .permitAll() @@ -118,4 +117,3 @@ public PasswordEncoder passwordEncoder() { return new BCryptPasswordEncoder(); } } - diff --git a/src/main/java/inha/gdgoc/global/util/SemesterCalculator.java b/src/main/java/inha/gdgoc/global/util/SemesterCalculator.java index fdbc60b..1d19659 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 855549c..e7cdf2e 100644 --- a/src/main/resources/application-dev.yml +++ b/src/main/resources/application-dev.yml @@ -2,24 +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] - 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 + database-platform: org.hibernate.dialect.PostgreSQLDialect hibernate: ddl-auto: none properties: @@ -28,43 +35,29 @@ 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 - cloud: - aws: - credentials: - access-key: ${AWS_ACCESS_KEY_ID} - secret-key: ${AWS_SECRET_ACCESS_KEY} - region: - static: ${AWS_REGION} - + username: ${GMAIL} + servlet: + multipart: + max-file-size: 10MB + max-request-size: 12MB + web: + resources: + add-mappings: false app: s3: bucket: ${AWS_TEST_RESOURCE_BUCKET} -logging: - level: - org.hibernate.SQL: debug - org.hibernate.type: off - - google: client-id: ${GOOGLE_CLIENT_ID} client-secret: ${GOOGLE_CLIENT_SECRET} @@ -72,5 +65,10 @@ google: jwt: googleIssuer: ${GOOGLE_ISSUER} - selfIssuer: ${SELF_ISSUER} secretKey: ${SECRET_KEY} + selfIssuer: ${SELF_ISSUER} + +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 96971d3..524a8a8 100644 --- a/src/main/resources/application-local.yml +++ b/src/main/resources/application-local.yml @@ -2,21 +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: ${DB_PASSWORD} url: ${DB_URL} username: ${DB_USERNAME} - password: ${DB_PASSWORD} - servlet: - multipart: - max-file-size: 10MB - max-request-size: 12MB + flyway: + baseline-description: "Baseline existing schema" + baseline-on-migrate: false + baseline-version: 1 + enabled: true + locations: classpath:db/migration + schemas: public + jackson: + time-zone: Asia/Seoul jpa: database: postgresql hibernate: @@ -26,32 +34,24 @@ 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 - cloud: - aws: - credentials: - access-key: ${AWS_ACCESS_KEY_ID} - secret-key: ${AWS_SECRET_ACCESS_KEY} - region: - static: ${AWS_REGION} + username: ${GMAIL} + servlet: + multipart: + max-file-size: 10MB + max-request-size: 12MB + web: + resources: + add-mappings: false app: s3: @@ -62,13 +62,12 @@ google: client-secret: ${GOOGLE_CLIENT_SECRET} redirect-uri: ${GOOGLE_REDIRECT_URI} -logging: - level: - org.hibernate.SQL: debug - org.hibernate.type: trace - jwt: googleIssuer: ${GOOGLE_ISSUER} - selfIssuer: ${SELF_ISSUER} secretKey: ${SECRET_KEY} + selfIssuer: ${SELF_ISSUER} +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 d71a1dd..1f5f5c6 100644 --- a/src/main/resources/application-prod.yml +++ b/src/main/resources/application-prod.yml @@ -2,24 +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] - 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 + database-platform: org.hibernate.dialect.PostgreSQLDialect hibernate: ddl-auto: none properties: @@ -28,42 +35,29 @@ 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 - cloud: - aws: - credentials: - access-key: ${AWS_ACCESS_KEY_ID} - secret-key: ${AWS_SECRET_ACCESS_KEY} - region: - static: ${AWS_REGION} + username: ${GMAIL} + servlet: + multipart: + max-file-size: 10MB + max-request-size: 12MB + web: + resources: + add-mappings: false app: s3: bucket: ${AWS_RESOURCE_BUCKET} -logging: - level: - org.hibernate.SQL: debug - org.hibernate.type: off - - google: client-id: ${GOOGLE_CLIENT_ID} client-secret: ${GOOGLE_CLIENT_SECRET} @@ -71,6 +65,10 @@ google: jwt: googleIssuer: ${GOOGLE_ISSUER} - selfIssuer: ${SELF_ISSUER} secretKey: ${SECRET_KEY} + selfIssuer: ${SELF_ISSUER} +logging: + level: + org.hibernate.SQL: debug + org.hibernate.type: off diff --git a/src/main/resources/db/migration/V20260114__core_recruit_applications_session_and_status.sql b/src/main/resources/db/migration/V20260114__core_recruit_applications_session_and_status.sql new file mode 100644 index 0000000..91e8c17 --- /dev/null +++ b/src/main/resources/db/migration/V20260114__core_recruit_applications_session_and_status.sql @@ -0,0 +1,32 @@ +alter table core_recruit_applications + add column if not exists user_id bigint, + add column if not exists session varchar(32), + add column if not exists result_status varchar(32) default 'SUBMITTED', + add column if not exists reviewed_at timestamptz, + add column if not exists reviewed_by bigint, + add column if not exists result_note text; + +update core_recruit_applications +set session = coalesce(session, 'UNKNOWN'); + +alter table core_recruit_applications + alter column session set not null; + +alter table core_recruit_applications + alter column result_status set not null; + +update core_recruit_applications cra +set user_id = u.id +from users u +where cra.user_id is null + and u.email = cra.email; + +alter table core_recruit_applications + alter column user_id set not null; + +alter table core_recruit_applications + add constraint fk_core_recruit_applications_user + foreign key (user_id) references users (id) on delete cascade; + +create unique index if not exists uq_core_recruit_user_session + on core_recruit_applications (user_id, session); 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 0000000..541f8e4 --- /dev/null +++ b/src/test/java/inha/gdgoc/domain/admin/recruit/core/service/RecruitCoreAdminServiceTest.java @@ -0,0 +1,173 @@ +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") + .password("encoded") + .userRole(UserRole.GUEST) + .team(null) + .salt(new byte[]{1}) + .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 0000000..36fe58c --- /dev/null +++ b/src/test/java/inha/gdgoc/domain/recruit/core/service/RecruitCoreApplicationServiceTest.java @@ -0,0 +1,224 @@ +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.findByUser_IdAndSession(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.findByUser_IdAndSession(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.findByUser_IdAndSession(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.findByUser_IdAndSession(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.findByUser_IdAndSession(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.findByUser_IdAndSession(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") + .password("encoded") + .userRole(UserRole.GUEST) + .team(null) + .salt(new byte[]{1}) + .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 49dfd53..5e88882 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; 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 1d1a01e..0000000 --- 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 3680ebe..0000000 --- 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 50c6866..e42b4ee 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,43 +29,29 @@ 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 - - cloud: - aws: - credentials: - access-key: test - secret-key: test - region: - static: ap-northeast-2 + servlet: + multipart: + max-file-size: 10MB + max-request-size: 12MB app: s3: bucket: test-bucket -logging: - level: - org.hibernate.SQL: warn - org.hibernate.type: warn - google: client-id: test-client-id client-secret: test-client-secret @@ -70,6 +59,10 @@ google: jwt: googleIssuer: test-google-issuer - selfIssuer: test-self-issuer secretKey: MDEyMzQ1Njc4OWFiY2RlZjAxMjM0NTY3ODlhYmNkZWY= + selfIssuer: test-self-issuer +logging: + level: + org.hibernate.SQL: warn + org.hibernate.type: warn