From 94eb07cbc0f70691f1d0ec84bf842eb2fb563e8b Mon Sep 17 00:00:00 2001 From: hodoon Date: Thu, 27 Feb 2025 20:18:01 +0900 Subject: [PATCH 1/9] =?UTF-8?q?feat:=20=EB=A9=B4=EC=A0=91=20=EC=98=88?= =?UTF-8?q?=EC=95=BD=20API=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/common/exception/ErrorCode.java | 9 +- .../dto/InterviewReservationRequestDto.java | 17 +++ .../dto/InterviewReservationResponseDto.java | 20 +++ .../dto/InterviewSlotRequestDto.java | 22 +++ .../dto/InterviewSlotResponseDto.java | 32 +++++ .../entity/InterviewReservation.java | 29 ++++ .../interview/entity/InterviewSlot.java | 54 ++++++++ .../api/domain/interview/enums/Status.java | 7 + .../InterviewReservationRepository.java | 10 ++ .../repositoty/InterviewSlotRepository.java | 18 +++ .../interview/service/InterviewService.java | 26 ++++ .../service/InterviewServiceImpl.java | 131 ++++++++++++++++++ .../recruit/controller/RecruitController.java | 37 +++++ .../recruit/dto/RecruitScheduleDto.java | 20 +++ .../recruit/service/RecruitService.java | 13 ++ .../recruit/service/RecruitServiceImpl.java | 21 +++ .../domain/recruit/RecruitServiceTest.java | 103 ++++++++++++++ 17 files changed, 568 insertions(+), 1 deletion(-) create mode 100644 src/main/java/dmu/dasom/api/domain/interview/dto/InterviewReservationRequestDto.java create mode 100644 src/main/java/dmu/dasom/api/domain/interview/dto/InterviewReservationResponseDto.java create mode 100644 src/main/java/dmu/dasom/api/domain/interview/dto/InterviewSlotRequestDto.java create mode 100644 src/main/java/dmu/dasom/api/domain/interview/dto/InterviewSlotResponseDto.java create mode 100644 src/main/java/dmu/dasom/api/domain/interview/entity/InterviewReservation.java create mode 100644 src/main/java/dmu/dasom/api/domain/interview/entity/InterviewSlot.java create mode 100644 src/main/java/dmu/dasom/api/domain/interview/enums/Status.java create mode 100644 src/main/java/dmu/dasom/api/domain/interview/repositoty/InterviewReservationRepository.java create mode 100644 src/main/java/dmu/dasom/api/domain/interview/repositoty/InterviewSlotRepository.java create mode 100644 src/main/java/dmu/dasom/api/domain/interview/service/InterviewService.java create mode 100644 src/main/java/dmu/dasom/api/domain/interview/service/InterviewServiceImpl.java create mode 100644 src/main/java/dmu/dasom/api/domain/recruit/dto/RecruitScheduleDto.java diff --git a/src/main/java/dmu/dasom/api/domain/common/exception/ErrorCode.java b/src/main/java/dmu/dasom/api/domain/common/exception/ErrorCode.java index 30ca062..85f354e 100644 --- a/src/main/java/dmu/dasom/api/domain/common/exception/ErrorCode.java +++ b/src/main/java/dmu/dasom/api/domain/common/exception/ErrorCode.java @@ -26,7 +26,14 @@ public enum ErrorCode { INVALID_TIME_FORMAT(400, "C017", "시간 형식이 올바르지 않습니다."), INVALID_INQUIRY_PERIOD(400, "C018", "조회 기간이 아닙니다."), SHEET_WRITE_FAIL(400, "C019", "시트에 데이터를 쓰는데 실패하였습니다."), - SHEET_READ_FAIL(400, "C200", "시트에 데이터를 쓰는데 실패하였습니다."), + SHEET_READ_FAIL(400, "C200", "시트에 데이터를 읽는데 실패하였습니다."), + SLOT_NOT_FOUND(400, "C021", "슬롯을 찾을 수 없습니다."), + APPLICANT_NOT_FOUND(400, "C022", "지원자를 찾을 수 없습니다."), + ALREADY_RESERVED(400, "C023", "이미 예약된 지원자입니다."), + RESERVED_SLOT_CANNOT_BE_DELETED(400, "C024", "예약된 슬롯은 삭제할 수 없습니다."), + SLOT_FULL(400, "C025", "해당 슬롯이 가득 찼습니다."), + RESERVATION_NOT_FOUND(400, "C026", "예약을 찾을 수 없습니다."), + SLOT_NOT_ACTIVE(400, "C027", "해당 슬롯이 비활성화 되었습니다."), ; private final int status; diff --git a/src/main/java/dmu/dasom/api/domain/interview/dto/InterviewReservationRequestDto.java b/src/main/java/dmu/dasom/api/domain/interview/dto/InterviewReservationRequestDto.java new file mode 100644 index 0000000..c65f643 --- /dev/null +++ b/src/main/java/dmu/dasom/api/domain/interview/dto/InterviewReservationRequestDto.java @@ -0,0 +1,17 @@ +package dmu.dasom.api.domain.interview.dto; + +import lombok.*; + +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class InterviewReservationRequestDto { + + private Long slotId; // 예약할 슬롯 ID + + private Long applicantId; // 지원자 ID + + private String reservationCode; // 학번 뒤 4자리 + 전화번호 뒤 4자리 조합 코드 +} diff --git a/src/main/java/dmu/dasom/api/domain/interview/dto/InterviewReservationResponseDto.java b/src/main/java/dmu/dasom/api/domain/interview/dto/InterviewReservationResponseDto.java new file mode 100644 index 0000000..c1742ce --- /dev/null +++ b/src/main/java/dmu/dasom/api/domain/interview/dto/InterviewReservationResponseDto.java @@ -0,0 +1,20 @@ +package dmu.dasom.api.domain.interview.dto; + +import lombok.*; + +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class InterviewReservationResponseDto { + + private Long reservationId; // 예약 ID + + private Long slotId; // 슬롯 ID + + private Long applicantId; // 지원자 ID + + private String reservationCode; // 예약 코드 (학번+전화번호 조합) + +} diff --git a/src/main/java/dmu/dasom/api/domain/interview/dto/InterviewSlotRequestDto.java b/src/main/java/dmu/dasom/api/domain/interview/dto/InterviewSlotRequestDto.java new file mode 100644 index 0000000..e4013f3 --- /dev/null +++ b/src/main/java/dmu/dasom/api/domain/interview/dto/InterviewSlotRequestDto.java @@ -0,0 +1,22 @@ +package dmu.dasom.api.domain.interview.dto; + +import lombok.*; + +import java.time.LocalDate; +import java.time.LocalTime; + +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class InterviewSlotRequestDto { + + private LocalDate interviewDate; // 면접 날짜 + + private LocalTime startTime; // 시작 시간 + + private LocalTime endTime; // 종료 시간 + + private Integer maxCandidates; // 최대 지원자 수 +} diff --git a/src/main/java/dmu/dasom/api/domain/interview/dto/InterviewSlotResponseDto.java b/src/main/java/dmu/dasom/api/domain/interview/dto/InterviewSlotResponseDto.java new file mode 100644 index 0000000..4381540 --- /dev/null +++ b/src/main/java/dmu/dasom/api/domain/interview/dto/InterviewSlotResponseDto.java @@ -0,0 +1,32 @@ +package dmu.dasom.api.domain.interview.dto; + +import dmu.dasom.api.domain.interview.entity.InterviewSlot; +import lombok.*; + +import java.time.LocalDate; +import java.time.LocalTime; + +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class InterviewSlotResponseDto { + + private Long id; // 슬롯 ID + private LocalDate interviewDate; // 면접 날짜 + private LocalTime startTime; // 시작 시간 + private LocalTime endTime; // 종료 시간 + private Integer maxCandidates; // 최대 지원자 수 + private Integer currentCandidates; // 현재 예약된 지원자 수 + + public InterviewSlotResponseDto(InterviewSlot slot){ + this.id = slot.getId(); + this.interviewDate = slot.getInterviewDate(); + this.startTime = slot.getStartTime(); + this.endTime = slot.getEndTime(); + this.maxCandidates = slot.getMaxCandidates(); + this.currentCandidates = slot.getCurrentCandidates(); + } + +} diff --git a/src/main/java/dmu/dasom/api/domain/interview/entity/InterviewReservation.java b/src/main/java/dmu/dasom/api/domain/interview/entity/InterviewReservation.java new file mode 100644 index 0000000..898fdd7 --- /dev/null +++ b/src/main/java/dmu/dasom/api/domain/interview/entity/InterviewReservation.java @@ -0,0 +1,29 @@ +package dmu.dasom.api.domain.interview.entity; + +import dmu.dasom.api.domain.applicant.entity.Applicant; +import jakarta.persistence.*; +import lombok.*; + +@Entity +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class InterviewReservation { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "slot_id", nullable = false) + private InterviewSlot slot; // 연관된 면접 슬롯 + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "applicant_id", nullable = false) + private Applicant applicant; // 지원자 + + @Column(nullable = false, unique = true, length = 8) + private String reservationCode; // 학번 뒤 4자리 + 전화번호 뒤 4자리 조합 코드 +} diff --git a/src/main/java/dmu/dasom/api/domain/interview/entity/InterviewSlot.java b/src/main/java/dmu/dasom/api/domain/interview/entity/InterviewSlot.java new file mode 100644 index 0000000..286c22a --- /dev/null +++ b/src/main/java/dmu/dasom/api/domain/interview/entity/InterviewSlot.java @@ -0,0 +1,54 @@ +package dmu.dasom.api.domain.interview.entity; + +import dmu.dasom.api.domain.interview.enums.Status; +import jakarta.persistence.*; +import lombok.*; + +import java.time.LocalDate; +import java.time.LocalTime; + +@Entity +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class InterviewSlot { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(nullable = false) + private LocalDate interviewDate; + + @Column(nullable = false) + private LocalTime startTime; + + @Column(nullable = false) + private LocalTime endTime; // 종료 시간 + + @Column(nullable = false) + private Integer maxCandidates; // 최대 지원자 수 + + @Column(nullable = false) + private Integer currentCandidates; // 현재 예약된 지원자 수 + + @Enumerated(EnumType.STRING) + @Column(nullable = false, length = 16) + private Status status; // 면접 슬롯 상태 (ACTIVE, INACTIVE, CLOSED) + + public void incrementCurrentCandidates() { + this.currentCandidates++; + if (this.currentCandidates >= this.maxCandidates) { + this.status = Status.CLOSED; // 최대 지원자 수에 도달하면 상태 변경 + } + } + + public void decrementCurrentCandidates() { + this.currentCandidates--; + if (status == Status.CLOSED && this.currentCandidates < this.maxCandidates) { + this.status = Status.ACTIVE; // 지원자 수가 줄어들면 다시 활성화 + } + } + +} diff --git a/src/main/java/dmu/dasom/api/domain/interview/enums/Status.java b/src/main/java/dmu/dasom/api/domain/interview/enums/Status.java new file mode 100644 index 0000000..8c90c9d --- /dev/null +++ b/src/main/java/dmu/dasom/api/domain/interview/enums/Status.java @@ -0,0 +1,7 @@ +package dmu.dasom.api.domain.interview.enums; + +public enum Status { + ACTIVE, // 활성화된 슬롯 + INACTIVE, // 비활성화된 슬롯 + CLOSED // 예약 마감된 슬롯 +} diff --git a/src/main/java/dmu/dasom/api/domain/interview/repositoty/InterviewReservationRepository.java b/src/main/java/dmu/dasom/api/domain/interview/repositoty/InterviewReservationRepository.java new file mode 100644 index 0000000..65a8f0a --- /dev/null +++ b/src/main/java/dmu/dasom/api/domain/interview/repositoty/InterviewReservationRepository.java @@ -0,0 +1,10 @@ +package dmu.dasom.api.domain.interview.repositoty; + +import dmu.dasom.api.domain.interview.entity.InterviewReservation; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +@Repository +public interface InterviewReservationRepository extends JpaRepository { + boolean existsByReservationCode(String reservationCode); +} diff --git a/src/main/java/dmu/dasom/api/domain/interview/repositoty/InterviewSlotRepository.java b/src/main/java/dmu/dasom/api/domain/interview/repositoty/InterviewSlotRepository.java new file mode 100644 index 0000000..078f30b --- /dev/null +++ b/src/main/java/dmu/dasom/api/domain/interview/repositoty/InterviewSlotRepository.java @@ -0,0 +1,18 @@ +package dmu.dasom.api.domain.interview.repositoty; + +import dmu.dasom.api.domain.interview.entity.InterviewSlot; +import dmu.dasom.api.domain.interview.enums.Status; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import java.util.Collection; +import java.util.List; + +@Repository +public interface InterviewSlotRepository extends JpaRepository { + Collection findAllByCurrentCandidatesLessThanMaxCandidates(); + List findAllByStatusAndCurrentCandidatesLessThanMaxCandidates( + Status status); + + boolean exists(); +} diff --git a/src/main/java/dmu/dasom/api/domain/interview/service/InterviewService.java b/src/main/java/dmu/dasom/api/domain/interview/service/InterviewService.java new file mode 100644 index 0000000..4f7c278 --- /dev/null +++ b/src/main/java/dmu/dasom/api/domain/interview/service/InterviewService.java @@ -0,0 +1,26 @@ +package dmu.dasom.api.domain.interview.service; + +import dmu.dasom.api.domain.interview.dto.InterviewReservationRequestDto; +import dmu.dasom.api.domain.interview.dto.InterviewSlotResponseDto; + +import java.time.LocalDate; +import java.time.LocalTime; +import java.util.List; + + +public interface InterviewService { + + // 면접 슬롯 생성 + List createInterviewSlots(LocalDate newStartDate, LocalDate newEndDate, LocalTime newStartTime, LocalTime newEndTime); + + // 예약 가능한 면접 슬롯 조회 + List getAvailableSlots(); + + // 면접 예약 + void reserveInterviewSlot(InterviewReservationRequestDto request); + + // 면접 예약 취소 + void cancelReservation(Long reservationId, Long applicantId); + + +} diff --git a/src/main/java/dmu/dasom/api/domain/interview/service/InterviewServiceImpl.java b/src/main/java/dmu/dasom/api/domain/interview/service/InterviewServiceImpl.java new file mode 100644 index 0000000..2343562 --- /dev/null +++ b/src/main/java/dmu/dasom/api/domain/interview/service/InterviewServiceImpl.java @@ -0,0 +1,131 @@ +package dmu.dasom.api.domain.interview.service; + +import dmu.dasom.api.domain.applicant.entity.Applicant; +import dmu.dasom.api.domain.applicant.repository.ApplicantRepository; +import dmu.dasom.api.domain.common.exception.CustomException; +import dmu.dasom.api.domain.common.exception.ErrorCode; +import dmu.dasom.api.domain.interview.dto.InterviewReservationRequestDto; +import dmu.dasom.api.domain.interview.dto.InterviewSlotResponseDto; +import dmu.dasom.api.domain.interview.entity.InterviewReservation; +import dmu.dasom.api.domain.interview.entity.InterviewSlot; +import dmu.dasom.api.domain.interview.enums.Status; +import dmu.dasom.api.domain.interview.repositoty.InterviewReservationRepository; +import dmu.dasom.api.domain.interview.repositoty.InterviewSlotRepository; +import dmu.dasom.api.domain.recruit.service.RecruitServiceImpl; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDate; +import java.time.LocalTime; +import java.util.ArrayList; +import java.util.List; + +@RequiredArgsConstructor +@Service +@Transactional(readOnly = true) +public class InterviewServiceImpl implements InterviewService{ + + private final InterviewSlotRepository interviewSlotRepository; + private final InterviewReservationRepository interviewReservationRepository; + private final ApplicantRepository applicantRepository; + private final RecruitServiceImpl recruitService; + + // 면접 슬롯 생성 + @Override + @Transactional + public List createInterviewSlots(LocalDate newStartDate, LocalDate newEndDate, LocalTime newStartTime, LocalTime newEndTime) { + boolean slotsExist = interviewSlotRepository.exists(); + + if(slotsExist){ + interviewSlotRepository.deleteAll(); + } + + List newSlots = new ArrayList<>(); + for(LocalDate date = newStartDate; !date.isAfter(newEndDate); date = date.plusDays(1)){ + LocalTime currentTime = newStartTime; + while (currentTime.isBefore(newEndTime)){ + LocalTime slotEndTime = currentTime.plusMinutes(20); + + InterviewSlot slot = InterviewSlot.builder() + .interviewDate(date) + .startTime(currentTime) + .endTime(slotEndTime) + .maxCandidates(2) + .currentCandidates(0) + .status(Status.ACTIVE) + .build(); + + interviewSlotRepository.save(slot); + newSlots.add(new InterviewSlotResponseDto(slot)); + currentTime = slotEndTime; + } + } + + return newSlots; + } + + // 예약 가능한 면접 슬롯 조회 + @Override + public List getAvailableSlots() { + return interviewSlotRepository.findAllByStatusAndCurrentCandidatesLessThanMaxCandidates(Status.ACTIVE) + .stream() + .map(InterviewSlotResponseDto::new) + .toList(); + } + + @Override + @Transactional + public void reserveInterviewSlot(InterviewReservationRequestDto request) { + InterviewSlot slot = interviewSlotRepository.findById(request.getSlotId()) + .orElseThrow(() -> new CustomException(ErrorCode.SLOT_NOT_FOUND)); + + if(!slot.getStatus().equals(Status.ACTIVE)){ + throw new CustomException(ErrorCode.SLOT_NOT_ACTIVE); + } + + if (slot.getCurrentCandidates() >= slot.getMaxCandidates()) { + throw new CustomException(ErrorCode.SLOT_FULL); + } + + Applicant applicant = applicantRepository.findById(request.getApplicantId()) + .orElseThrow(() -> new CustomException(ErrorCode.APPLICANT_NOT_FOUND)); + + boolean alreadyReserved = interviewReservationRepository.existsByReservationCode(request.getReservationCode()); + if (alreadyReserved) { + throw new CustomException(ErrorCode.ALREADY_RESERVED); + } + + // 예약 정보 저장 + InterviewReservation reservation = InterviewReservation.builder() + .slot(slot) + .applicant(applicant) + .reservationCode(request.getReservationCode()) + .build(); + + interviewReservationRepository.save(reservation); + + // 현재 예약 인원 증가 + slot.incrementCurrentCandidates(); + } + + // 면접 예약 취소 + @Override + @Transactional + public void cancelReservation(Long reservationId, Long applicantId) { + InterviewReservation reservation = interviewReservationRepository.findById(reservationId) + .orElseThrow(() -> new CustomException(ErrorCode.RESERVATION_NOT_FOUND)); + + if (!reservation.getApplicant().getId().equals(applicantId)) { + throw new CustomException(ErrorCode.UNAUTHORIZED); + } + + // 현재 예약 인원 감소 + InterviewSlot slot = reservation.getSlot(); + slot.decrementCurrentCandidates(); + + // 예약 삭제 + interviewReservationRepository.delete(reservation); + } + +} diff --git a/src/main/java/dmu/dasom/api/domain/recruit/controller/RecruitController.java b/src/main/java/dmu/dasom/api/domain/recruit/controller/RecruitController.java index 920c18d..1e7405f 100644 --- a/src/main/java/dmu/dasom/api/domain/recruit/controller/RecruitController.java +++ b/src/main/java/dmu/dasom/api/domain/recruit/controller/RecruitController.java @@ -3,6 +3,9 @@ import dmu.dasom.api.domain.applicant.dto.ApplicantCreateRequestDto; import dmu.dasom.api.domain.applicant.service.ApplicantService; import dmu.dasom.api.domain.common.exception.ErrorResponse; +import dmu.dasom.api.domain.interview.dto.InterviewReservationRequestDto; +import dmu.dasom.api.domain.interview.dto.InterviewSlotResponseDto; +import dmu.dasom.api.domain.interview.service.InterviewService; import dmu.dasom.api.domain.recruit.dto.ResultCheckRequestDto; import dmu.dasom.api.domain.recruit.dto.ResultCheckResponseDto; import dmu.dasom.api.domain.recruit.dto.RecruitConfigResponseDto; @@ -18,6 +21,8 @@ import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.*; +import java.time.LocalDate; +import java.time.LocalTime; import java.util.List; @RestController @@ -27,6 +32,7 @@ public class RecruitController { private final ApplicantService applicantService; private final RecruitService recruitService; + private final InterviewService interviewService; // 지원하기 @Operation(summary = "부원 지원하기") @@ -76,4 +82,35 @@ public ResponseEntity checkResult(@ModelAttribute final return ResponseEntity.ok(recruitService.checkResult(request)); } + // 면접 일정 생성 + @Operation(summary = "면접 일정 생성", description = "새로운 면접 일정을 생성합니다.") + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "면접 일정 생성 성공"), + @ApiResponse(responseCode = "400", description = "잘못된 요청 데이터") + }) + @PostMapping("/interview/schedule") + public ResponseEntity> createInterviewSlots( + @RequestParam("startDate") LocalDate startDate, + @RequestParam("endDate") LocalDate endDate, + @RequestParam("startTime") LocalTime startTime, + @RequestParam("endTime") LocalTime endTime) { + + List slots = + interviewService.createInterviewSlots(startDate, endDate, startTime, endTime); + return ResponseEntity.ok(slots); + } + + // 면접 예약 + @Operation(summary = "면접 예약", description = "지원자가 특정 면접 슬롯을 예약합니다.") + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "면접 예약 성공"), + @ApiResponse(responseCode = "400", description = "잘못된 요청 데이터") + }) + @PostMapping("/interview/reserve") + public ResponseEntity reserveInterviewSlot(@Valid @RequestBody InterviewReservationRequestDto request) { + interviewService.reserveInterviewSlot(request); + return ResponseEntity.ok().build(); + } + + } diff --git a/src/main/java/dmu/dasom/api/domain/recruit/dto/RecruitScheduleDto.java b/src/main/java/dmu/dasom/api/domain/recruit/dto/RecruitScheduleDto.java new file mode 100644 index 0000000..a564714 --- /dev/null +++ b/src/main/java/dmu/dasom/api/domain/recruit/dto/RecruitScheduleDto.java @@ -0,0 +1,20 @@ +package dmu.dasom.api.domain.recruit.dto; + +import lombok.Builder; +import lombok.Getter; + +import java.time.LocalDate; +import java.time.LocalTime; + +@Getter +@Builder +public class RecruitScheduleDto { + private LocalDate recruitStartDate; // 모집 시작 날짜 + private LocalDate recruitEndDate; // 모집 종료 날짜 + private LocalDate interviewStartDate; // 면접 시작 날짜 + private LocalDate interviewEndDate; // 면접 종료 날짜 + private LocalTime interviewStartTime; // 면접 시작 시간 + private LocalTime interviewEndTime; // 면접 종료 시간 + private LocalDate documentPassDate; // 1차 합격 발표 날짜 + private LocalDate finalPassDate; // 최종 합격 발표 날짜 +} diff --git a/src/main/java/dmu/dasom/api/domain/recruit/service/RecruitService.java b/src/main/java/dmu/dasom/api/domain/recruit/service/RecruitService.java index 6e0da17..67cf2cf 100644 --- a/src/main/java/dmu/dasom/api/domain/recruit/service/RecruitService.java +++ b/src/main/java/dmu/dasom/api/domain/recruit/service/RecruitService.java @@ -4,7 +4,11 @@ import dmu.dasom.api.domain.recruit.dto.ResultCheckResponseDto; import dmu.dasom.api.domain.recruit.dto.RecruitConfigResponseDto; import dmu.dasom.api.domain.recruit.dto.RecruitScheduleModifyRequestDto; +import dmu.dasom.api.domain.recruit.entity.Recruit; +import dmu.dasom.api.domain.recruit.enums.ConfigKey; +import java.time.LocalDate; +import java.time.LocalTime; import java.util.List; public interface RecruitService { @@ -15,4 +19,13 @@ public interface RecruitService { ResultCheckResponseDto checkResult(final ResultCheckRequestDto request); + LocalDate getInterviewStartDate(); + + LocalDate getInterviewEndDate(); + + LocalTime getInterviewStartTime(); + + LocalTime getInterviewEndTime(); + + } diff --git a/src/main/java/dmu/dasom/api/domain/recruit/service/RecruitServiceImpl.java b/src/main/java/dmu/dasom/api/domain/recruit/service/RecruitServiceImpl.java index 08afc0c..59987b1 100644 --- a/src/main/java/dmu/dasom/api/domain/recruit/service/RecruitServiceImpl.java +++ b/src/main/java/dmu/dasom/api/domain/recruit/service/RecruitServiceImpl.java @@ -17,6 +17,7 @@ import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import java.time.LocalDate; import java.time.LocalDateTime; import java.time.LocalTime; import java.time.format.DateTimeFormatter; @@ -89,6 +90,26 @@ public ResultCheckResponseDto checkResult(final ResultCheckRequestDto request) { .build(); } + @Override + public LocalDate getInterviewStartDate() { + return LocalDate.parse(findByKey(ConfigKey.INTERVIEW_PERIOD_START).getValue()); + } + + @Override + public LocalDate getInterviewEndDate() { + return LocalDate.parse(findByKey(ConfigKey.INTERVIEW_PERIOD_END).getValue()); + } + + @Override + public LocalTime getInterviewStartTime() { + return LocalTime.parse(findByKey(ConfigKey.INTERVIEW_TIME_START).getValue()); + } + + @Override + public LocalTime getInterviewEndTime() { + return LocalTime.parse(findByKey(ConfigKey.INTERVIEW_TIME_END).getValue()); + } + // DB에 저장된 모든 Recruit 객체를 찾아 반환 private List findAll() { return recruitRepository.findAll(); diff --git a/src/test/java/dmu/dasom/api/domain/recruit/RecruitServiceTest.java b/src/test/java/dmu/dasom/api/domain/recruit/RecruitServiceTest.java index 3160243..6c7aec2 100644 --- a/src/test/java/dmu/dasom/api/domain/recruit/RecruitServiceTest.java +++ b/src/test/java/dmu/dasom/api/domain/recruit/RecruitServiceTest.java @@ -5,6 +5,9 @@ import dmu.dasom.api.domain.applicant.service.ApplicantServiceImpl; import dmu.dasom.api.domain.common.exception.CustomException; import dmu.dasom.api.domain.common.exception.ErrorCode; +import dmu.dasom.api.domain.interview.dto.InterviewReservationRequestDto; +import dmu.dasom.api.domain.interview.dto.InterviewSlotResponseDto; +import dmu.dasom.api.domain.interview.service.InterviewServiceImpl; import dmu.dasom.api.domain.recruit.dto.ResultCheckRequestDto; import dmu.dasom.api.domain.recruit.dto.ResultCheckResponseDto; import dmu.dasom.api.domain.recruit.dto.RecruitConfigResponseDto; @@ -21,7 +24,9 @@ import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; +import java.time.LocalDate; import java.time.LocalDateTime; +import java.time.LocalTime; import java.time.format.DateTimeFormatter; import java.util.List; import java.util.Optional; @@ -39,6 +44,9 @@ class RecruitServiceTest { @Mock private ApplicantServiceImpl applicantService; + @Mock + private InterviewServiceImpl interviewService; + @InjectMocks private RecruitServiceImpl recruitService; @@ -179,4 +187,99 @@ void checkResult_fail_contactMismatch() { verify(applicantService, times(1)).getApplicantByStudentNo("20210000"); } + @Test + @DisplayName("면접 일정 생성 - 성공") + void createInterviewSlots_success() { + // given + LocalDate startDate = LocalDate.of(2025, 3, 12); + LocalDate endDate = LocalDate.of(2025, 3, 14); + LocalTime startTime = LocalTime.of(14, 0); + LocalTime endTime = LocalTime.of(20, 0); + + InterviewSlotResponseDto slot1 = mock(InterviewSlotResponseDto.class); + InterviewSlotResponseDto slot2 = mock(InterviewSlotResponseDto.class); + + when(interviewService.createInterviewSlots(startDate, endDate, startTime, endTime)) + .thenReturn(List.of(slot1, slot2)); + + // when + List slots = interviewService.createInterviewSlots(startDate, endDate, startTime, endTime); + + // then + assertNotNull(slots); + assertEquals(2, slots.size()); + verify(interviewService, times(1)).createInterviewSlots(startDate, endDate, startTime, endTime); + } + + @Test + @DisplayName("면접 예약 - 성공") + void reserveInterviewSlot_success() { + // given + InterviewReservationRequestDto request = new InterviewReservationRequestDto(1L, 1234L, "00006789"); + + // when + interviewService.reserveInterviewSlot(request); + + // then + verify(interviewService, times(1)).reserveInterviewSlot(request); + } + + + @Test + @DisplayName("면접 예약 - 실패 (슬롯 없음)") + void reserveInterviewSlot_fail_slotNotFound() { + // given + InterviewReservationRequestDto request = new InterviewReservationRequestDto(1L, 1234L, "00006789"); + + doThrow(new CustomException(ErrorCode.SLOT_NOT_FOUND)) + .when(interviewService).reserveInterviewSlot(request); + + // when + CustomException exception = assertThrows(CustomException.class, () -> { + interviewService.reserveInterviewSlot(request); + }); + + // then + assertEquals(ErrorCode.SLOT_NOT_FOUND, exception.getErrorCode()); + } + + + @Test + @DisplayName("면접 예약 - 실패 (최대 지원자 수 초과)") + void reserveInterviewSlot_fail_slotFull() { + // given + InterviewReservationRequestDto request = new InterviewReservationRequestDto(1L, 1234L, "00006789"); + + doThrow(new CustomException(ErrorCode.SLOT_FULL)) + .when(interviewService).reserveInterviewSlot(request); + + // when + CustomException exception = assertThrows(CustomException.class, () -> { + interviewService.reserveInterviewSlot(request); + }); + + // then + assertEquals(ErrorCode.SLOT_FULL, exception.getErrorCode()); + } + + + @Test + @DisplayName("면접 예약 - 실패 (이미 예약됨)") + void reserveInterviewSlot_fail_alreadyReserved() { + // given + InterviewReservationRequestDto request = new InterviewReservationRequestDto(1L, 1234L, "00006789"); + + doThrow(new CustomException(ErrorCode.ALREADY_RESERVED)) + .when(interviewService).reserveInterviewSlot(request); + + // when + CustomException exception = assertThrows(CustomException.class, () -> { + interviewService.reserveInterviewSlot(request); + }); + + // then + assertEquals(ErrorCode.ALREADY_RESERVED, exception.getErrorCode()); + } + + } From 6b966e8209ef25a836cae4cb3d2e3947af35023f Mon Sep 17 00:00:00 2001 From: hodoon Date: Fri, 28 Feb 2025 01:15:18 +0900 Subject: [PATCH 2/9] =?UTF-8?q?feat:=20=EB=A9=B4=EC=A0=91=20=EC=98=88?= =?UTF-8?q?=EC=95=BD=20API=20InterviewSlotRepository=20=EC=BF=BC=EB=A6=AC?= =?UTF-8?q?=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../repositoty/InterviewSlotRepository.java | 14 +++++++++++--- .../interview/service/InterviewServiceImpl.java | 2 +- 2 files changed, 12 insertions(+), 4 deletions(-) diff --git a/src/main/java/dmu/dasom/api/domain/interview/repositoty/InterviewSlotRepository.java b/src/main/java/dmu/dasom/api/domain/interview/repositoty/InterviewSlotRepository.java index 078f30b..2d80c1d 100644 --- a/src/main/java/dmu/dasom/api/domain/interview/repositoty/InterviewSlotRepository.java +++ b/src/main/java/dmu/dasom/api/domain/interview/repositoty/InterviewSlotRepository.java @@ -3,6 +3,7 @@ import dmu.dasom.api.domain.interview.entity.InterviewSlot; import dmu.dasom.api.domain.interview.enums.Status; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; import org.springframework.stereotype.Repository; import java.util.Collection; @@ -10,9 +11,16 @@ @Repository public interface InterviewSlotRepository extends JpaRepository { + // 현재 인원이 최대 인원보다 작은 슬롯 조회 + // 현재 예약된 인원이 최대 지원자 수보다 적은 슬롯 조회 + @Query("SELECT s FROM InterviewSlot s WHERE s.currentCandidates < s.maxCandidates") Collection findAllByCurrentCandidatesLessThanMaxCandidates(); - List findAllByStatusAndCurrentCandidatesLessThanMaxCandidates( - Status status); - boolean exists(); + // 상태에 따른 슬롯 조회 + @Query("SELECT s FROM InterviewSlot s WHERE s.status = :status AND s.currentCandidates < s.maxCandidates") + List findAllByStatusAndCurrentCandidatesLessThanMaxCandidates(Status status); + + // 슬롯이 하나라도 존재하는지 확인 + @Query("SELECT COUNT(s) > 0 FROM InterviewSlot s") + boolean existsAny(); } diff --git a/src/main/java/dmu/dasom/api/domain/interview/service/InterviewServiceImpl.java b/src/main/java/dmu/dasom/api/domain/interview/service/InterviewServiceImpl.java index 2343562..6ce6fc2 100644 --- a/src/main/java/dmu/dasom/api/domain/interview/service/InterviewServiceImpl.java +++ b/src/main/java/dmu/dasom/api/domain/interview/service/InterviewServiceImpl.java @@ -35,7 +35,7 @@ public class InterviewServiceImpl implements InterviewService{ @Override @Transactional public List createInterviewSlots(LocalDate newStartDate, LocalDate newEndDate, LocalTime newStartTime, LocalTime newEndTime) { - boolean slotsExist = interviewSlotRepository.exists(); + boolean slotsExist = interviewSlotRepository.existsAny(); if(slotsExist){ interviewSlotRepository.deleteAll(); From 3c98cc7e077e53c716baf7bda745fb58fb2808f2 Mon Sep 17 00:00:00 2001 From: hodoon Date: Sat, 1 Mar 2025 14:52:30 +0900 Subject: [PATCH 3/9] =?UTF-8?q?feat:=20=EB=A9=B4=EC=A0=91=20=EC=98=88?= =?UTF-8?q?=EC=95=BD=20API?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../repository/ApplicantRepository.java | 5 +++ .../dto/InterviewReservationRequestDto.java | 9 +++-- .../dto/InterviewReservationResponseDto.java | 5 +++ .../dto/InterviewSlotCreateRequestDto.java | 27 +++++++++++++++ .../dto/InterviewSlotRequestDto.java | 9 +++++ .../dto/InterviewSlotResponseDto.java | 17 ++++++++++ .../entity/InterviewReservation.java | 4 +-- .../interview/entity/InterviewSlot.java | 10 +++--- .../{Status.java => InterviewStatus.java} | 2 +- .../repositoty/InterviewSlotRepository.java | 6 ++-- .../service/InterviewServiceImpl.java | 34 +++++++++++-------- .../recruit/controller/RecruitController.java | 10 +++--- .../recruit/dto/ResultCheckResponseDto.java | 4 ++- .../recruit/service/RecruitServiceImpl.java | 11 ++++++ .../domain/recruit/RecruitServiceTest.java | 9 +++-- 15 files changed, 121 insertions(+), 41 deletions(-) create mode 100644 src/main/java/dmu/dasom/api/domain/interview/dto/InterviewSlotCreateRequestDto.java rename src/main/java/dmu/dasom/api/domain/interview/enums/{Status.java => InterviewStatus.java} (85%) diff --git a/src/main/java/dmu/dasom/api/domain/applicant/repository/ApplicantRepository.java b/src/main/java/dmu/dasom/api/domain/applicant/repository/ApplicantRepository.java index dff1b1f..a0a9226 100644 --- a/src/main/java/dmu/dasom/api/domain/applicant/repository/ApplicantRepository.java +++ b/src/main/java/dmu/dasom/api/domain/applicant/repository/ApplicantRepository.java @@ -2,6 +2,7 @@ import dmu.dasom.api.domain.applicant.entity.Applicant; import dmu.dasom.api.domain.applicant.enums.ApplicantStatus; +import io.lettuce.core.dynamic.annotation.Param; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.JpaRepository; @@ -20,4 +21,8 @@ public interface ApplicantRepository extends JpaRepository { Optional findByStudentNo(final String studentNo); + @Query("SELECT a FROM Applicant a WHERE a.studentNo = :studentNo AND a.contact LIKE %:contactLastDigits") + Optional findByStudentNoAndContactEndsWith(@Param("studentNo") String studentNo, + @Param("contactLastDigits") String contactLastDigits); + } diff --git a/src/main/java/dmu/dasom/api/domain/interview/dto/InterviewReservationRequestDto.java b/src/main/java/dmu/dasom/api/domain/interview/dto/InterviewReservationRequestDto.java index c65f643..8d2de1d 100644 --- a/src/main/java/dmu/dasom/api/domain/interview/dto/InterviewReservationRequestDto.java +++ b/src/main/java/dmu/dasom/api/domain/interview/dto/InterviewReservationRequestDto.java @@ -1,5 +1,7 @@ package dmu.dasom.api.domain.interview.dto; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Pattern; import lombok.*; @Getter @@ -9,9 +11,10 @@ @Builder public class InterviewReservationRequestDto { + @NotNull(message = "슬롯 ID는 필수 값입니다.") private Long slotId; // 예약할 슬롯 ID - private Long applicantId; // 지원자 ID - - private String reservationCode; // 학번 뒤 4자리 + 전화번호 뒤 4자리 조합 코드 + @NotNull(message = "예약 코드는 필수 값입니다.") + @Pattern(regexp = "^[0-9]{8}[0-9]{4}$", message = "예약 코드는 학번 전체와 전화번호 뒤 4자리로 구성되어야 합니다.") + private String reservationCode; // 학번 전체 + 전화번호 뒤 4자리 조합 코드 } diff --git a/src/main/java/dmu/dasom/api/domain/interview/dto/InterviewReservationResponseDto.java b/src/main/java/dmu/dasom/api/domain/interview/dto/InterviewReservationResponseDto.java index c1742ce..f05c544 100644 --- a/src/main/java/dmu/dasom/api/domain/interview/dto/InterviewReservationResponseDto.java +++ b/src/main/java/dmu/dasom/api/domain/interview/dto/InterviewReservationResponseDto.java @@ -1,5 +1,6 @@ package dmu.dasom.api.domain.interview.dto; +import jakarta.validation.constraints.NotNull; import lombok.*; @Getter @@ -9,12 +10,16 @@ @Builder public class InterviewReservationResponseDto { + @NotNull(message = "예약 ID는 필수 값입니다.") private Long reservationId; // 예약 ID + @NotNull(message = "슬롯 ID는 필수 값입니다.") private Long slotId; // 슬롯 ID + @NotNull(message = "지원자 ID는 필수 값입니다.") private Long applicantId; // 지원자 ID + @NotNull(message = "예약 코드는 필수 값입니다.") private String reservationCode; // 예약 코드 (학번+전화번호 조합) } diff --git a/src/main/java/dmu/dasom/api/domain/interview/dto/InterviewSlotCreateRequestDto.java b/src/main/java/dmu/dasom/api/domain/interview/dto/InterviewSlotCreateRequestDto.java new file mode 100644 index 0000000..fd3f3b2 --- /dev/null +++ b/src/main/java/dmu/dasom/api/domain/interview/dto/InterviewSlotCreateRequestDto.java @@ -0,0 +1,27 @@ +package dmu.dasom.api.domain.interview.dto; + +import jakarta.validation.constraints.NotNull; +import lombok.*; + +import java.time.LocalDate; +import java.time.LocalTime; + +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class InterviewSlotCreateRequestDto { + + @NotNull(message = "시작 날짜는 필수 값입니다.") + private LocalDate startDate; // 면접 시작 날짜 + + @NotNull(message = "종료 날짜는 필수 값입니다.") + private LocalDate endDate; // 면접 종료 날짜 + + @NotNull(message = "시작 시간은 필수 값입니다.") + private LocalTime startTime; // 하루의 시작 시간 + + @NotNull(message = "종료 시간은 필수 값입니다.") + private LocalTime endTime; // 하루의 종료 시간 +} \ No newline at end of file diff --git a/src/main/java/dmu/dasom/api/domain/interview/dto/InterviewSlotRequestDto.java b/src/main/java/dmu/dasom/api/domain/interview/dto/InterviewSlotRequestDto.java index e4013f3..63c6c11 100644 --- a/src/main/java/dmu/dasom/api/domain/interview/dto/InterviewSlotRequestDto.java +++ b/src/main/java/dmu/dasom/api/domain/interview/dto/InterviewSlotRequestDto.java @@ -1,5 +1,8 @@ package dmu.dasom.api.domain.interview.dto; +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotNull; import lombok.*; import java.time.LocalDate; @@ -12,11 +15,17 @@ @Builder public class InterviewSlotRequestDto { + @NotNull(message = "면접 날짜는 필수 입력 값입니다.") private LocalDate interviewDate; // 면접 날짜 + @NotNull(message = "시작 시간은 필수 입력 값입니다.") private LocalTime startTime; // 시작 시간 + @NotNull(message = "종료 시간은 필수 입력 값입니다.") private LocalTime endTime; // 종료 시간 + @NotNull(message = "최대 지원자 수는 필수 입력 값입니다.") + @Min(value = 1, message = "최대 지원자 수는 최소 1명 이상이어야 합니다.") + @Max(value = 100, message = "최대 지원자 수는 최대 100명까지 가능합니다.") // 필요에 따라 수정 가능 private Integer maxCandidates; // 최대 지원자 수 } diff --git a/src/main/java/dmu/dasom/api/domain/interview/dto/InterviewSlotResponseDto.java b/src/main/java/dmu/dasom/api/domain/interview/dto/InterviewSlotResponseDto.java index 4381540..1187620 100644 --- a/src/main/java/dmu/dasom/api/domain/interview/dto/InterviewSlotResponseDto.java +++ b/src/main/java/dmu/dasom/api/domain/interview/dto/InterviewSlotResponseDto.java @@ -1,6 +1,9 @@ package dmu.dasom.api.domain.interview.dto; import dmu.dasom.api.domain.interview.entity.InterviewSlot; +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotNull; import lombok.*; import java.time.LocalDate; @@ -13,11 +16,25 @@ @Builder public class InterviewSlotResponseDto { + @NotNull(message = "슬롯 ID는 필수 입력 값입니다.") private Long id; // 슬롯 ID + + @NotNull(message = "면접 날짜는 필수 입력 값입니다.") private LocalDate interviewDate; // 면접 날짜 + + @NotNull(message = "시작 시간은 필수 입력 값입니다.") private LocalTime startTime; // 시작 시간 + + @NotNull(message = "종료 시간은 필수 입력 값입니다.") private LocalTime endTime; // 종료 시간 + + @NotNull(message = "최대 지원자 수는 필수 입력 값입니다.") + @Min(value = 1, message = "최대 지원자 수는 최소 1명 이상이어야 합니다.") + @Max(value = 100, message = "최대 지원자 수는 최대 100명까지 가능합니다.") private Integer maxCandidates; // 최대 지원자 수 + + @NotNull(message = "현재 예약된 지원자 수는 필수 입력 값입니다.") + @Min(value = 0, message = "현재 예약된 지원자 수는 0명 이상이어야 합니다.") private Integer currentCandidates; // 현재 예약된 지원자 수 public InterviewSlotResponseDto(InterviewSlot slot){ diff --git a/src/main/java/dmu/dasom/api/domain/interview/entity/InterviewReservation.java b/src/main/java/dmu/dasom/api/domain/interview/entity/InterviewReservation.java index 898fdd7..c9b9e65 100644 --- a/src/main/java/dmu/dasom/api/domain/interview/entity/InterviewReservation.java +++ b/src/main/java/dmu/dasom/api/domain/interview/entity/InterviewReservation.java @@ -24,6 +24,6 @@ public class InterviewReservation { @JoinColumn(name = "applicant_id", nullable = false) private Applicant applicant; // 지원자 - @Column(nullable = false, unique = true, length = 8) - private String reservationCode; // 학번 뒤 4자리 + 전화번호 뒤 4자리 조합 코드 + @Column(nullable = false, unique = true, length = 12) + private String reservationCode; // 학번 전체 + 전화번호 뒤 4자리 조합 코드 } diff --git a/src/main/java/dmu/dasom/api/domain/interview/entity/InterviewSlot.java b/src/main/java/dmu/dasom/api/domain/interview/entity/InterviewSlot.java index 286c22a..a71dde9 100644 --- a/src/main/java/dmu/dasom/api/domain/interview/entity/InterviewSlot.java +++ b/src/main/java/dmu/dasom/api/domain/interview/entity/InterviewSlot.java @@ -1,6 +1,6 @@ package dmu.dasom.api.domain.interview.entity; -import dmu.dasom.api.domain.interview.enums.Status; +import dmu.dasom.api.domain.interview.enums.InterviewStatus; import jakarta.persistence.*; import lombok.*; @@ -35,19 +35,19 @@ public class InterviewSlot { @Enumerated(EnumType.STRING) @Column(nullable = false, length = 16) - private Status status; // 면접 슬롯 상태 (ACTIVE, INACTIVE, CLOSED) + private InterviewStatus interviewStatus; // 면접 슬롯 상태 (ACTIVE, INACTIVE, CLOSED) public void incrementCurrentCandidates() { this.currentCandidates++; if (this.currentCandidates >= this.maxCandidates) { - this.status = Status.CLOSED; // 최대 지원자 수에 도달하면 상태 변경 + this.interviewStatus = InterviewStatus.CLOSED; // 최대 지원자 수에 도달하면 상태 변경 } } public void decrementCurrentCandidates() { this.currentCandidates--; - if (status == Status.CLOSED && this.currentCandidates < this.maxCandidates) { - this.status = Status.ACTIVE; // 지원자 수가 줄어들면 다시 활성화 + if (interviewStatus == InterviewStatus.CLOSED && this.currentCandidates < this.maxCandidates) { + this.interviewStatus = InterviewStatus.ACTIVE; // 지원자 수가 줄어들면 다시 활성화 } } diff --git a/src/main/java/dmu/dasom/api/domain/interview/enums/Status.java b/src/main/java/dmu/dasom/api/domain/interview/enums/InterviewStatus.java similarity index 85% rename from src/main/java/dmu/dasom/api/domain/interview/enums/Status.java rename to src/main/java/dmu/dasom/api/domain/interview/enums/InterviewStatus.java index 8c90c9d..758f13c 100644 --- a/src/main/java/dmu/dasom/api/domain/interview/enums/Status.java +++ b/src/main/java/dmu/dasom/api/domain/interview/enums/InterviewStatus.java @@ -1,6 +1,6 @@ package dmu.dasom.api.domain.interview.enums; -public enum Status { +public enum InterviewStatus { ACTIVE, // 활성화된 슬롯 INACTIVE, // 비활성화된 슬롯 CLOSED // 예약 마감된 슬롯 diff --git a/src/main/java/dmu/dasom/api/domain/interview/repositoty/InterviewSlotRepository.java b/src/main/java/dmu/dasom/api/domain/interview/repositoty/InterviewSlotRepository.java index 2d80c1d..9c718f9 100644 --- a/src/main/java/dmu/dasom/api/domain/interview/repositoty/InterviewSlotRepository.java +++ b/src/main/java/dmu/dasom/api/domain/interview/repositoty/InterviewSlotRepository.java @@ -1,7 +1,7 @@ package dmu.dasom.api.domain.interview.repositoty; import dmu.dasom.api.domain.interview.entity.InterviewSlot; -import dmu.dasom.api.domain.interview.enums.Status; +import dmu.dasom.api.domain.interview.enums.InterviewStatus; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.Query; import org.springframework.stereotype.Repository; @@ -17,8 +17,8 @@ public interface InterviewSlotRepository extends JpaRepository findAllByCurrentCandidatesLessThanMaxCandidates(); // 상태에 따른 슬롯 조회 - @Query("SELECT s FROM InterviewSlot s WHERE s.status = :status AND s.currentCandidates < s.maxCandidates") - List findAllByStatusAndCurrentCandidatesLessThanMaxCandidates(Status status); + @Query("SELECT s FROM InterviewSlot s WHERE s.interviewStatus = :status AND s.currentCandidates < s.maxCandidates") + List findAllByStatusAndCurrentCandidatesLessThanMaxCandidates(InterviewStatus interviewStatus); // 슬롯이 하나라도 존재하는지 확인 @Query("SELECT COUNT(s) > 0 FROM InterviewSlot s") diff --git a/src/main/java/dmu/dasom/api/domain/interview/service/InterviewServiceImpl.java b/src/main/java/dmu/dasom/api/domain/interview/service/InterviewServiceImpl.java index 6ce6fc2..3dfe8b2 100644 --- a/src/main/java/dmu/dasom/api/domain/interview/service/InterviewServiceImpl.java +++ b/src/main/java/dmu/dasom/api/domain/interview/service/InterviewServiceImpl.java @@ -8,7 +8,7 @@ import dmu.dasom.api.domain.interview.dto.InterviewSlotResponseDto; import dmu.dasom.api.domain.interview.entity.InterviewReservation; import dmu.dasom.api.domain.interview.entity.InterviewSlot; -import dmu.dasom.api.domain.interview.enums.Status; +import dmu.dasom.api.domain.interview.enums.InterviewStatus; import dmu.dasom.api.domain.interview.repositoty.InterviewReservationRepository; import dmu.dasom.api.domain.interview.repositoty.InterviewSlotRepository; import dmu.dasom.api.domain.recruit.service.RecruitServiceImpl; @@ -53,7 +53,7 @@ public List createInterviewSlots(LocalDate newStartDat .endTime(slotEndTime) .maxCandidates(2) .currentCandidates(0) - .status(Status.ACTIVE) + .interviewStatus(InterviewStatus.ACTIVE) .build(); interviewSlotRepository.save(slot); @@ -68,7 +68,7 @@ public List createInterviewSlots(LocalDate newStartDat // 예약 가능한 면접 슬롯 조회 @Override public List getAvailableSlots() { - return interviewSlotRepository.findAllByStatusAndCurrentCandidatesLessThanMaxCandidates(Status.ACTIVE) + return interviewSlotRepository.findAllByStatusAndCurrentCandidatesLessThanMaxCandidates(InterviewStatus.ACTIVE) .stream() .map(InterviewSlotResponseDto::new) .toList(); @@ -77,35 +77,39 @@ public List getAvailableSlots() { @Override @Transactional public void reserveInterviewSlot(InterviewReservationRequestDto request) { + + // 예약 코드에서 학번과 전화번호 뒷자리 추출 + String reservationCode = request.getReservationCode(); + String studentNo = reservationCode.substring(0, 8); + String contactLastDigits = reservationCode.substring(8); + + // 지원자 조회 및 검증 + Applicant applicant = applicantRepository.findByStudentNoAndContactEndsWith(studentNo, contactLastDigits) + .orElseThrow(() -> new CustomException(ErrorCode.APPLICANT_NOT_FOUND)); + + // 면접 슬롯 조회 및 검증 InterviewSlot slot = interviewSlotRepository.findById(request.getSlotId()) .orElseThrow(() -> new CustomException(ErrorCode.SLOT_NOT_FOUND)); - if(!slot.getStatus().equals(Status.ACTIVE)){ - throw new CustomException(ErrorCode.SLOT_NOT_ACTIVE); + // 중복 예약 확인 + if(interviewReservationRepository.existsByReservationCode(reservationCode)){ + throw new CustomException(ErrorCode.ALREADY_RESERVED); } if (slot.getCurrentCandidates() >= slot.getMaxCandidates()) { throw new CustomException(ErrorCode.SLOT_FULL); } - Applicant applicant = applicantRepository.findById(request.getApplicantId()) - .orElseThrow(() -> new CustomException(ErrorCode.APPLICANT_NOT_FOUND)); - - boolean alreadyReserved = interviewReservationRepository.existsByReservationCode(request.getReservationCode()); - if (alreadyReserved) { - throw new CustomException(ErrorCode.ALREADY_RESERVED); - } - // 예약 정보 저장 InterviewReservation reservation = InterviewReservation.builder() .slot(slot) .applicant(applicant) - .reservationCode(request.getReservationCode()) + .reservationCode(reservationCode) // 학번 + 전화번호 뒤 4자리 .build(); interviewReservationRepository.save(reservation); - // 현재 예약 인원 증가 + // 현재 예약 인원 증가 및 상태 업데이트 slot.incrementCurrentCandidates(); } diff --git a/src/main/java/dmu/dasom/api/domain/recruit/controller/RecruitController.java b/src/main/java/dmu/dasom/api/domain/recruit/controller/RecruitController.java index 1e7405f..463a0a1 100644 --- a/src/main/java/dmu/dasom/api/domain/recruit/controller/RecruitController.java +++ b/src/main/java/dmu/dasom/api/domain/recruit/controller/RecruitController.java @@ -4,6 +4,8 @@ import dmu.dasom.api.domain.applicant.service.ApplicantService; import dmu.dasom.api.domain.common.exception.ErrorResponse; import dmu.dasom.api.domain.interview.dto.InterviewReservationRequestDto; +import dmu.dasom.api.domain.interview.dto.InterviewSlotCreateRequestDto; +import dmu.dasom.api.domain.interview.dto.InterviewSlotRequestDto; import dmu.dasom.api.domain.interview.dto.InterviewSlotResponseDto; import dmu.dasom.api.domain.interview.service.InterviewService; import dmu.dasom.api.domain.recruit.dto.ResultCheckRequestDto; @@ -89,14 +91,10 @@ public ResponseEntity checkResult(@ModelAttribute final @ApiResponse(responseCode = "400", description = "잘못된 요청 데이터") }) @PostMapping("/interview/schedule") - public ResponseEntity> createInterviewSlots( - @RequestParam("startDate") LocalDate startDate, - @RequestParam("endDate") LocalDate endDate, - @RequestParam("startTime") LocalTime startTime, - @RequestParam("endTime") LocalTime endTime) { + public ResponseEntity> createInterviewSlots(@Valid @RequestBody InterviewSlotCreateRequestDto request) { List slots = - interviewService.createInterviewSlots(startDate, endDate, startTime, endTime); + interviewService.createInterviewSlots(request.getStartDate(), request.getEndDate(), request.getStartTime(), request.getEndTime()); return ResponseEntity.ok(slots); } diff --git a/src/main/java/dmu/dasom/api/domain/recruit/dto/ResultCheckResponseDto.java b/src/main/java/dmu/dasom/api/domain/recruit/dto/ResultCheckResponseDto.java index 5cd93b1..7619370 100644 --- a/src/main/java/dmu/dasom/api/domain/recruit/dto/ResultCheckResponseDto.java +++ b/src/main/java/dmu/dasom/api/domain/recruit/dto/ResultCheckResponseDto.java @@ -26,8 +26,10 @@ public class ResultCheckResponseDto { @Size(max = 16) String name; + @Schema(description = "예약 코드", example = "202100005678") + String reservationCode; // 학번 전체 + 전화번호 뒤 4자리 조합 코드 + @NotNull @Schema(description = "결과", example = "true") Boolean isPassed; - } diff --git a/src/main/java/dmu/dasom/api/domain/recruit/service/RecruitServiceImpl.java b/src/main/java/dmu/dasom/api/domain/recruit/service/RecruitServiceImpl.java index 59987b1..3e74e5e 100644 --- a/src/main/java/dmu/dasom/api/domain/recruit/service/RecruitServiceImpl.java +++ b/src/main/java/dmu/dasom/api/domain/recruit/service/RecruitServiceImpl.java @@ -60,6 +60,10 @@ public void modifyRecruitSchedule(final RecruitScheduleModifyRequestDto request) // 합격 결과 확인 @Override public ResultCheckResponseDto checkResult(final ResultCheckRequestDto request) { + // 예약 코드 생성 + String reservationCode = generateReservationCode(request.getStudentNo(), request.getContactLastDigit()); + + // 결과 발표 시간 검증 final Recruit recruit = switch (request.getType()) { case DOCUMENT_PASS -> findByKey(ConfigKey.DOCUMENT_PASS_ANNOUNCEMENT); case INTERVIEW_PASS -> findByKey(ConfigKey.INTERVIEW_PASS_ANNOUNCEMENT); @@ -71,6 +75,7 @@ public ResultCheckResponseDto checkResult(final ResultCheckRequestDto request) { if (now.isBefore(parsedTime)) throw new CustomException(ErrorCode.INVALID_INQUIRY_PERIOD); + // 지원자 정보 조회 final ApplicantDetailsResponseDto applicant = applicantService.getApplicantByStudentNo(request.getStudentNo()); // 연락처 뒷자리가 일치하지 않을 경우 예외 발생 @@ -82,6 +87,7 @@ public ResultCheckResponseDto checkResult(final ResultCheckRequestDto request) { .type(request.getType()) .studentNo(applicant.getStudentNo()) .name(applicant.getName()) + .reservationCode(reservationCode) .isPassed(request.getType().equals(ResultCheckType.DOCUMENT_PASS) ? applicant.getStatus() .equals(ApplicantStatus.DOCUMENT_PASSED) : @@ -140,4 +146,9 @@ private LocalDateTime parseDateTimeFormat(String value) { } } + public String generateReservationCode(String studentNo, String contactLastDigits) { + return studentNo + contactLastDigits; // 학번 전체 + 전화번호 뒤 4자리 + } + + } diff --git a/src/test/java/dmu/dasom/api/domain/recruit/RecruitServiceTest.java b/src/test/java/dmu/dasom/api/domain/recruit/RecruitServiceTest.java index 6c7aec2..8f27694 100644 --- a/src/test/java/dmu/dasom/api/domain/recruit/RecruitServiceTest.java +++ b/src/test/java/dmu/dasom/api/domain/recruit/RecruitServiceTest.java @@ -215,7 +215,7 @@ void createInterviewSlots_success() { @DisplayName("면접 예약 - 성공") void reserveInterviewSlot_success() { // given - InterviewReservationRequestDto request = new InterviewReservationRequestDto(1L, 1234L, "00006789"); + InterviewReservationRequestDto request = new InterviewReservationRequestDto(1234L, "202500010542"); // when interviewService.reserveInterviewSlot(request); @@ -229,7 +229,7 @@ void reserveInterviewSlot_success() { @DisplayName("면접 예약 - 실패 (슬롯 없음)") void reserveInterviewSlot_fail_slotNotFound() { // given - InterviewReservationRequestDto request = new InterviewReservationRequestDto(1L, 1234L, "00006789"); + InterviewReservationRequestDto request = new InterviewReservationRequestDto(1234L, "00006789"); doThrow(new CustomException(ErrorCode.SLOT_NOT_FOUND)) .when(interviewService).reserveInterviewSlot(request); @@ -248,7 +248,7 @@ void reserveInterviewSlot_fail_slotNotFound() { @DisplayName("면접 예약 - 실패 (최대 지원자 수 초과)") void reserveInterviewSlot_fail_slotFull() { // given - InterviewReservationRequestDto request = new InterviewReservationRequestDto(1L, 1234L, "00006789"); + InterviewReservationRequestDto request = new InterviewReservationRequestDto(1234L, "00006789"); doThrow(new CustomException(ErrorCode.SLOT_FULL)) .when(interviewService).reserveInterviewSlot(request); @@ -267,7 +267,7 @@ void reserveInterviewSlot_fail_slotFull() { @DisplayName("면접 예약 - 실패 (이미 예약됨)") void reserveInterviewSlot_fail_alreadyReserved() { // given - InterviewReservationRequestDto request = new InterviewReservationRequestDto(1L, 1234L, "00006789"); + InterviewReservationRequestDto request = new InterviewReservationRequestDto(1234L, "00006789"); doThrow(new CustomException(ErrorCode.ALREADY_RESERVED)) .when(interviewService).reserveInterviewSlot(request); @@ -281,5 +281,4 @@ void reserveInterviewSlot_fail_alreadyReserved() { assertEquals(ErrorCode.ALREADY_RESERVED, exception.getErrorCode()); } - } From 79fb59ce103d2697d2fc6c4ffbe2ecd38620cc87 Mon Sep 17 00:00:00 2001 From: hodoon Date: Sat, 1 Mar 2025 15:18:56 +0900 Subject: [PATCH 4/9] =?UTF-8?q?fix:=20=EB=A9=B4=EC=A0=91=20=EC=98=88?= =?UTF-8?q?=EC=95=BD=20API=20=EC=88=98=EC=A0=95=20=EC=99=84=EB=A3=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../dto/InterviewReservationRequestDto.java | 4 ++++ .../dto/InterviewReservationResponseDto.java | 6 ++++++ .../dto/InterviewSlotCreateRequestDto.java | 6 ++++++ .../dto/InterviewSlotRequestDto.java | 8 +++++++- .../dto/InterviewSlotResponseDto.java | 8 ++++++++ .../recruit/dto/RecruitScheduleDto.java | 20 ------------------- .../recruit/service/RecruitService.java | 9 --------- .../recruit/service/RecruitServiceImpl.java | 20 ------------------- 8 files changed, 31 insertions(+), 50 deletions(-) delete mode 100644 src/main/java/dmu/dasom/api/domain/recruit/dto/RecruitScheduleDto.java diff --git a/src/main/java/dmu/dasom/api/domain/interview/dto/InterviewReservationRequestDto.java b/src/main/java/dmu/dasom/api/domain/interview/dto/InterviewReservationRequestDto.java index 8d2de1d..844f216 100644 --- a/src/main/java/dmu/dasom/api/domain/interview/dto/InterviewReservationRequestDto.java +++ b/src/main/java/dmu/dasom/api/domain/interview/dto/InterviewReservationRequestDto.java @@ -1,5 +1,6 @@ package dmu.dasom.api.domain.interview.dto; +import io.swagger.v3.oas.annotations.media.Schema; import jakarta.validation.constraints.NotNull; import jakarta.validation.constraints.Pattern; import lombok.*; @@ -9,12 +10,15 @@ @NoArgsConstructor @AllArgsConstructor @Builder +@Schema(name = "InterviewReservationResponseDto", description = "면접 예약 응답 DTO") public class InterviewReservationRequestDto { @NotNull(message = "슬롯 ID는 필수 값입니다.") + @Schema(description = "예약할 면접 슬롯의 ID", example = "1") private Long slotId; // 예약할 슬롯 ID @NotNull(message = "예약 코드는 필수 값입니다.") @Pattern(regexp = "^[0-9]{8}[0-9]{4}$", message = "예약 코드는 학번 전체와 전화번호 뒤 4자리로 구성되어야 합니다.") + @Schema(description = "학번 전체와 전화번호 뒤 4자리로 구성된 예약 코드", example = "202500010542") private String reservationCode; // 학번 전체 + 전화번호 뒤 4자리 조합 코드 } diff --git a/src/main/java/dmu/dasom/api/domain/interview/dto/InterviewReservationResponseDto.java b/src/main/java/dmu/dasom/api/domain/interview/dto/InterviewReservationResponseDto.java index f05c544..6077f78 100644 --- a/src/main/java/dmu/dasom/api/domain/interview/dto/InterviewReservationResponseDto.java +++ b/src/main/java/dmu/dasom/api/domain/interview/dto/InterviewReservationResponseDto.java @@ -1,5 +1,6 @@ package dmu.dasom.api.domain.interview.dto; +import io.swagger.v3.oas.annotations.media.Schema; import jakarta.validation.constraints.NotNull; import lombok.*; @@ -8,18 +9,23 @@ @NoArgsConstructor @AllArgsConstructor @Builder +@Schema(name = "InterviewSlotCreateRequestDto", description = "면접 슬롯 생성 요청 DTO") public class InterviewReservationResponseDto { @NotNull(message = "예약 ID는 필수 값입니다.") + @Schema(description = "예약의 고유 ID", example = "1") private Long reservationId; // 예약 ID @NotNull(message = "슬롯 ID는 필수 값입니다.") + @Schema(description = "예약된 면접 슬롯의 ID", example = "10") private Long slotId; // 슬롯 ID @NotNull(message = "지원자 ID는 필수 값입니다.") + @Schema(description = "예약한 지원자의 ID", example = "1001") private Long applicantId; // 지원자 ID @NotNull(message = "예약 코드는 필수 값입니다.") + @Schema(description = "학번 전체와 전화번호 뒤 4자리로 구성된 예약 코드", example = "202500010542") private String reservationCode; // 예약 코드 (학번+전화번호 조합) } diff --git a/src/main/java/dmu/dasom/api/domain/interview/dto/InterviewSlotCreateRequestDto.java b/src/main/java/dmu/dasom/api/domain/interview/dto/InterviewSlotCreateRequestDto.java index fd3f3b2..c3ba722 100644 --- a/src/main/java/dmu/dasom/api/domain/interview/dto/InterviewSlotCreateRequestDto.java +++ b/src/main/java/dmu/dasom/api/domain/interview/dto/InterviewSlotCreateRequestDto.java @@ -1,5 +1,6 @@ package dmu.dasom.api.domain.interview.dto; +import io.swagger.v3.oas.annotations.media.Schema; import jakarta.validation.constraints.NotNull; import lombok.*; @@ -11,17 +12,22 @@ @NoArgsConstructor @AllArgsConstructor @Builder +@Schema(name = "InterviewSlotCreateRequestDto", description = "면접 슬롯 생성 요청 DTO") public class InterviewSlotCreateRequestDto { @NotNull(message = "시작 날짜는 필수 값입니다.") + @Schema(description = "면접 시작 날짜", example = "2025-03-12") private LocalDate startDate; // 면접 시작 날짜 @NotNull(message = "종료 날짜는 필수 값입니다.") + @Schema(description = "면접 종료 날짜", example = "2025-03-14") private LocalDate endDate; // 면접 종료 날짜 @NotNull(message = "시작 시간은 필수 값입니다.") + @Schema(description = "하루의 시작 시간", example = "10:00") private LocalTime startTime; // 하루의 시작 시간 @NotNull(message = "종료 시간은 필수 값입니다.") + @Schema(description = "하루의 종료 시간", example = "17:00") private LocalTime endTime; // 하루의 종료 시간 } \ No newline at end of file diff --git a/src/main/java/dmu/dasom/api/domain/interview/dto/InterviewSlotRequestDto.java b/src/main/java/dmu/dasom/api/domain/interview/dto/InterviewSlotRequestDto.java index 63c6c11..e1892e7 100644 --- a/src/main/java/dmu/dasom/api/domain/interview/dto/InterviewSlotRequestDto.java +++ b/src/main/java/dmu/dasom/api/domain/interview/dto/InterviewSlotRequestDto.java @@ -1,5 +1,6 @@ package dmu.dasom.api.domain.interview.dto; +import io.swagger.v3.oas.annotations.media.Schema; import jakarta.validation.constraints.Max; import jakarta.validation.constraints.Min; import jakarta.validation.constraints.NotNull; @@ -13,19 +14,24 @@ @NoArgsConstructor @AllArgsConstructor @Builder +@Schema(name = "InterviewSlotRequestDto", description = "면접 슬롯 요청 DTO") public class InterviewSlotRequestDto { @NotNull(message = "면접 날짜는 필수 입력 값입니다.") + @Schema(description = "면접이 진행되는 날짜", example = "2025-03-12") private LocalDate interviewDate; // 면접 날짜 @NotNull(message = "시작 시간은 필수 입력 값입니다.") + @Schema(description = "면접 시작 시간", example = "10:00") private LocalTime startTime; // 시작 시간 @NotNull(message = "종료 시간은 필수 입력 값입니다.") + @Schema(description = "면접 종료 시간", example = "10:20") private LocalTime endTime; // 종료 시간 @NotNull(message = "최대 지원자 수는 필수 입력 값입니다.") @Min(value = 1, message = "최대 지원자 수는 최소 1명 이상이어야 합니다.") - @Max(value = 100, message = "최대 지원자 수는 최대 100명까지 가능합니다.") // 필요에 따라 수정 가능 + @Max(value = 100, message = "최대 지원자 수는 최대 100명까지 가능합니다.") + @Schema(description = "해당 슬롯에서 허용되는 최대 지원자 수", example = "2") private Integer maxCandidates; // 최대 지원자 수 } diff --git a/src/main/java/dmu/dasom/api/domain/interview/dto/InterviewSlotResponseDto.java b/src/main/java/dmu/dasom/api/domain/interview/dto/InterviewSlotResponseDto.java index 1187620..a5ba624 100644 --- a/src/main/java/dmu/dasom/api/domain/interview/dto/InterviewSlotResponseDto.java +++ b/src/main/java/dmu/dasom/api/domain/interview/dto/InterviewSlotResponseDto.java @@ -1,6 +1,7 @@ package dmu.dasom.api.domain.interview.dto; import dmu.dasom.api.domain.interview.entity.InterviewSlot; +import io.swagger.v3.oas.annotations.media.Schema; import jakarta.validation.constraints.Max; import jakarta.validation.constraints.Min; import jakarta.validation.constraints.NotNull; @@ -14,27 +15,34 @@ @NoArgsConstructor @AllArgsConstructor @Builder +@Schema(name = "InterviewSlotResponseDto", description = "면접 슬롯 응답 DTO") public class InterviewSlotResponseDto { @NotNull(message = "슬롯 ID는 필수 입력 값입니다.") + @Schema(description = "슬롯의 고유 ID", example = "1") private Long id; // 슬롯 ID @NotNull(message = "면접 날짜는 필수 입력 값입니다.") + @Schema(description = "면접이 진행되는 날짜", example = "2025-03-12") private LocalDate interviewDate; // 면접 날짜 @NotNull(message = "시작 시간은 필수 입력 값입니다.") + @Schema(description = "면접 시작 시간", example = "10:00") private LocalTime startTime; // 시작 시간 @NotNull(message = "종료 시간은 필수 입력 값입니다.") + @Schema(description = "면접 종료 시간", example = "10:20") private LocalTime endTime; // 종료 시간 @NotNull(message = "최대 지원자 수는 필수 입력 값입니다.") @Min(value = 1, message = "최대 지원자 수는 최소 1명 이상이어야 합니다.") @Max(value = 100, message = "최대 지원자 수는 최대 100명까지 가능합니다.") + @Schema(description = "해당 슬롯에서 허용되는 최대 지원자 수", example = "2") private Integer maxCandidates; // 최대 지원자 수 @NotNull(message = "현재 예약된 지원자 수는 필수 입력 값입니다.") @Min(value = 0, message = "현재 예약된 지원자 수는 0명 이상이어야 합니다.") + @Schema(description = "현재 해당 슬롯에 예약된 지원자 수", example = "1") private Integer currentCandidates; // 현재 예약된 지원자 수 public InterviewSlotResponseDto(InterviewSlot slot){ diff --git a/src/main/java/dmu/dasom/api/domain/recruit/dto/RecruitScheduleDto.java b/src/main/java/dmu/dasom/api/domain/recruit/dto/RecruitScheduleDto.java deleted file mode 100644 index a564714..0000000 --- a/src/main/java/dmu/dasom/api/domain/recruit/dto/RecruitScheduleDto.java +++ /dev/null @@ -1,20 +0,0 @@ -package dmu.dasom.api.domain.recruit.dto; - -import lombok.Builder; -import lombok.Getter; - -import java.time.LocalDate; -import java.time.LocalTime; - -@Getter -@Builder -public class RecruitScheduleDto { - private LocalDate recruitStartDate; // 모집 시작 날짜 - private LocalDate recruitEndDate; // 모집 종료 날짜 - private LocalDate interviewStartDate; // 면접 시작 날짜 - private LocalDate interviewEndDate; // 면접 종료 날짜 - private LocalTime interviewStartTime; // 면접 시작 시간 - private LocalTime interviewEndTime; // 면접 종료 시간 - private LocalDate documentPassDate; // 1차 합격 발표 날짜 - private LocalDate finalPassDate; // 최종 합격 발표 날짜 -} diff --git a/src/main/java/dmu/dasom/api/domain/recruit/service/RecruitService.java b/src/main/java/dmu/dasom/api/domain/recruit/service/RecruitService.java index 67cf2cf..08621b3 100644 --- a/src/main/java/dmu/dasom/api/domain/recruit/service/RecruitService.java +++ b/src/main/java/dmu/dasom/api/domain/recruit/service/RecruitService.java @@ -19,13 +19,4 @@ public interface RecruitService { ResultCheckResponseDto checkResult(final ResultCheckRequestDto request); - LocalDate getInterviewStartDate(); - - LocalDate getInterviewEndDate(); - - LocalTime getInterviewStartTime(); - - LocalTime getInterviewEndTime(); - - } diff --git a/src/main/java/dmu/dasom/api/domain/recruit/service/RecruitServiceImpl.java b/src/main/java/dmu/dasom/api/domain/recruit/service/RecruitServiceImpl.java index 3e74e5e..695ab9e 100644 --- a/src/main/java/dmu/dasom/api/domain/recruit/service/RecruitServiceImpl.java +++ b/src/main/java/dmu/dasom/api/domain/recruit/service/RecruitServiceImpl.java @@ -96,26 +96,6 @@ public ResultCheckResponseDto checkResult(final ResultCheckRequestDto request) { .build(); } - @Override - public LocalDate getInterviewStartDate() { - return LocalDate.parse(findByKey(ConfigKey.INTERVIEW_PERIOD_START).getValue()); - } - - @Override - public LocalDate getInterviewEndDate() { - return LocalDate.parse(findByKey(ConfigKey.INTERVIEW_PERIOD_END).getValue()); - } - - @Override - public LocalTime getInterviewStartTime() { - return LocalTime.parse(findByKey(ConfigKey.INTERVIEW_TIME_START).getValue()); - } - - @Override - public LocalTime getInterviewEndTime() { - return LocalTime.parse(findByKey(ConfigKey.INTERVIEW_TIME_END).getValue()); - } - // DB에 저장된 모든 Recruit 객체를 찾아 반환 private List findAll() { return recruitRepository.findAll(); From 438fc5c3cf796ddf3a19396ec4f4768a4220e9a8 Mon Sep 17 00:00:00 2001 From: Seungwan Yoo Date: Sat, 1 Mar 2025 22:36:59 +0900 Subject: [PATCH 5/9] =?UTF-8?q?feat:=20Google=20Credentials=20=EC=A3=BC?= =?UTF-8?q?=EC=9E=85=20=EB=B0=A9=EC=8B=9D=20=ED=8C=8C=EC=9D=BC=EC=9D=B4=20?= =?UTF-8?q?=EC=95=84=EB=8B=8C=20JSON=20=EA=B0=92=EC=9C=BC=EB=A1=9C=20?= =?UTF-8?q?=EB=B0=9B=EB=8F=84=EB=A1=9D=20=EC=88=98=EC=A0=95=20-=20?= =?UTF-8?q?=EA=B7=B8=20=EC=99=B8=20=EC=BD=94=EB=93=9C=20=ED=8F=AC=EB=A7=B7?= =?UTF-8?q?=ED=8C=85=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../google/service/GoogleApiService.java | 104 ++++++++++-------- .../resources/application-credentials.yml | 5 +- 2 files changed, 59 insertions(+), 50 deletions(-) diff --git a/src/main/java/dmu/dasom/api/domain/google/service/GoogleApiService.java b/src/main/java/dmu/dasom/api/domain/google/service/GoogleApiService.java index 1751a1d..71962af 100644 --- a/src/main/java/dmu/dasom/api/domain/google/service/GoogleApiService.java +++ b/src/main/java/dmu/dasom/api/domain/google/service/GoogleApiService.java @@ -14,44 +14,51 @@ import dmu.dasom.api.domain.common.exception.CustomException; import dmu.dasom.api.domain.common.exception.ErrorCode; import lombok.RequiredArgsConstructor; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; +import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Value; -import org.springframework.core.io.ClassPathResource; import org.springframework.stereotype.Service; +import java.io.ByteArrayInputStream; import java.io.IOException; import java.security.GeneralSecurityException; import java.util.ArrayList; +import java.util.Base64; import java.util.Collections; import java.util.List; @RequiredArgsConstructor @Service +@Slf4j public class GoogleApiService { - private static final Logger logger = LoggerFactory.getLogger(GoogleApiService.class); - private static final String APPLICATION_NAME = "Recruit Form"; - private static final JsonFactory JSON_FACTORY = JacksonFactory.getDefaultInstance(); - @Value("${google.credentials.file.path}") - private String credentialsFilePath; + @Value("${google.credentials.json}") + private String credentialsJson; + @Value("${google.spreadsheet.id}") private String spreadSheetId; + + private static final String APPLICATION_NAME = "Recruit Form"; + private static final JsonFactory JSON_FACTORY = JacksonFactory.getDefaultInstance(); + private Sheets sheetsService; // Google Sheets API 서비스 객체를 생성하는 메소드 - private Sheets getSheetsService() throws IOException, GeneralSecurityException{ - if(sheetsService == null){ - ClassPathResource resource = new ClassPathResource(credentialsFilePath); + private Sheets getSheetsService() throws IOException, GeneralSecurityException { + if (sheetsService == null) { + ByteArrayInputStream decodedStream = new ByteArrayInputStream(Base64.getDecoder() + .decode(credentialsJson)); + GoogleCredentials credentials = GoogleCredentials - .fromStream(resource.getInputStream()) - .createScoped(Collections.singletonList("https://www.googleapis.com/auth/spreadsheets")); - - sheetsService = new Sheets.Builder(GoogleNetHttpTransport.newTrustedTransport(), - JSON_FACTORY, - new HttpCredentialsAdapter(credentials)) - .setApplicationName(APPLICATION_NAME) - .build(); + .fromStream(decodedStream) + .createScoped(Collections.singletonList("https://www.googleapis.com/auth/spreadsheets")); + + sheetsService = new Sheets.Builder( + GoogleNetHttpTransport.newTrustedTransport(), + JSON_FACTORY, + new HttpCredentialsAdapter(credentials) + ) + .setApplicationName(APPLICATION_NAME) + .build(); } return sheetsService; } @@ -60,12 +67,13 @@ public void writeToSheet(String spreadsheetId, String range, List> try { Sheets service = getSheetsService(); ValueRange body = new ValueRange().setValues(values); - service.spreadsheets().values() - .update(spreadsheetId, range, body) - .setValueInputOption("USER_ENTERED") - .execute(); - } catch (IOException | GeneralSecurityException e) { - logger.error("구글 시트에 데이터를 쓰는 데 실패했습니다.", e); + service.spreadsheets() + .values() + .update(spreadsheetId, range, body) + .setValueInputOption("USER_ENTERED") + .execute(); + } catch (IOException | GeneralSecurityException e) { + log.error("시트에 데이터를 쓰는 데 실패했습니다."); throw new CustomException(ErrorCode.WRITE_FAIL); } } @@ -78,18 +86,19 @@ public void updateSheet(List applicants) { processSheetsUpdate(applicants, false); } - public int findRowIndexByStudentNo(String spreadSheetId, String sheetName, String studentNo){ + public int findRowIndexByStudentNo(String spreadSheetId, String sheetName, String studentNo) { try { List> rows = readSheet(spreadSheetId, sheetName + "!A:L"); // A열부터 L열까지 읽기 - for (int i = 0; i < rows.size(); i++){ + for (int i = 0; i < rows.size(); i++) { List row = rows.get(i); - if(!row.isEmpty() && row.get(2).equals(studentNo)){ // 학번(Student No)이 3번째 열(A=0 기준) + if (!row.isEmpty() && row.get(2) + .equals(studentNo)) { // 학번(Student No)이 3번째 열(A=0 기준) return i + 1; } } } catch (Exception e) { - logger.error("구글시트에서 행 찾기 실패", e); + log.error("시트에서 행 찾기 실패"); } return -1; } @@ -97,13 +106,14 @@ public int findRowIndexByStudentNo(String spreadSheetId, String sheetName, Strin public List> readSheet(String spreadsheetId, String range) { try { Sheets service = getSheetsService(); - ValueRange response = service.spreadsheets().values() - .get(spreadsheetId, range) - .execute(); + ValueRange response = service.spreadsheets() + .values() + .get(spreadsheetId, range) + .execute(); return response.getValues(); } catch (IOException | GeneralSecurityException e) { - logger.error("시트에서 데이터를 읽어오는데 실패했습니다.", e); + log.error("시트에서 데이터를 읽어오는 데 실패했습니다."); throw new CustomException(ErrorCode.SHEET_READ_FAIL); } } @@ -113,7 +123,7 @@ public int getLastRow(String spreadsheetId, String sheetName) { List> rows = readSheet(spreadsheetId, sheetName + "!A:L"); // A~L 열까지 읽기 return rows == null ? 0 : rows.size(); // 데이터가 없으면 0 반환 } catch (Exception e) { - logger.error("Failed to retrieve last row from Google Sheet", e); + log.error("시트에서 마지막 행 찾기 실패"); throw new CustomException(ErrorCode.SHEET_READ_FAIL); } } @@ -124,17 +134,18 @@ public void batchUpdateSheet(String spreadsheetId, List valueRanges) // BatchUpdate 요청 생성 BatchUpdateValuesRequest batchUpdateRequest = new BatchUpdateValuesRequest() - .setValueInputOption("USER_ENTERED") // 사용자 입력 형식으로 값 설정 - .setData(valueRanges); // 여러 ValueRange 추가 + .setValueInputOption("USER_ENTERED") // 사용자 입력 형식으로 값 설정 + .setData(valueRanges); // 여러 ValueRange 추가 // BatchUpdate 실행 - BatchUpdateValuesResponse response = service.spreadsheets().values() - .batchUpdate(spreadsheetId, batchUpdateRequest) - .execute(); + BatchUpdateValuesResponse response = service.spreadsheets() + .values() + .batchUpdate(spreadsheetId, batchUpdateRequest) + .execute(); - logger.info("Batch update completed. Total updated rows: {}", response.getTotalUpdatedRows()); + log.info("시트 업데이트 성공. {}", response.getTotalUpdatedRows()); } catch (IOException | GeneralSecurityException e) { - logger.error("Batch update failed", e); + log.error("시트 업데이트 실패."); throw new CustomException(ErrorCode.SHEET_WRITE_FAIL); } } @@ -142,9 +153,9 @@ public void batchUpdateSheet(String spreadsheetId, List valueRanges) private ValueRange createValueRange(String range, List> values) { return new ValueRange() - .setRange(range) - .setMajorDimension("ROWS") // 행 단위로 데이터 설정 - .setValues(values); + .setRange(range) + .setMajorDimension("ROWS") // 행 단위로 데이터 설정 + .setValues(values); } public void processSheetsUpdate(List applicants, boolean isAppend) { @@ -160,7 +171,7 @@ public void processSheetsUpdate(List applicants, boolean isAppend) { } else { int rowIndex = findRowIndexByStudentNo(spreadSheetId, "Sheet1", applicant.getStudentNo()); if (rowIndex == -1) { - logger.warn("구글시트에서 사용자를 찾을 수 없습니다. : {}", applicant.getStudentNo()); + log.warn("시트에서 지원자를 찾을 수 없습니다. : {}", applicant.getStudentNo()); continue; } range = "Sheet1!A" + rowIndex + ":L" + rowIndex; @@ -170,11 +181,10 @@ public void processSheetsUpdate(List applicants, boolean isAppend) { batchUpdateSheet(spreadSheetId, valueRanges); } catch (Exception e) { - logger.error("구글시트 업데이트에 실패했습니다.", e); + log.error("시트 업데이트에 실패했습니다.", e); throw new CustomException(ErrorCode.SHEET_WRITE_FAIL); } } - } diff --git a/src/main/resources/application-credentials.yml b/src/main/resources/application-credentials.yml index be47469..06720cf 100644 --- a/src/main/resources/application-credentials.yml +++ b/src/main/resources/application-credentials.yml @@ -32,7 +32,6 @@ jwt: refresh-token-expiration: ${JWT_REFRESH_TOKEN_EXPIRATION} google: credentials: - file: - path: ${GOOGLE_CREDENTIALS_PATH} + json: ${GOOGLE_CREDENTIALS_JSON} spreadsheet: - id: ${GOOGLE_SPREADSHEET_ID} \ No newline at end of file + id: ${GOOGLE_SPREADSHEET_ID} From be796880d32e3301297066500c2243f005aee0a2 Mon Sep 17 00:00:00 2001 From: Seungwan Yoo Date: Sat, 1 Mar 2025 22:37:22 +0900 Subject: [PATCH 6/9] =?UTF-8?q?chore:=20.gitignore=20=EC=A0=81=EC=9A=A9?= =?UTF-8?q?=EB=90=98=EC=A7=80=20=EC=95=8A=EB=8A=94=20=ED=95=AD=EB=AA=A9=20?= =?UTF-8?q?=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 9 --------- 1 file changed, 9 deletions(-) diff --git a/.gitignore b/.gitignore index ebf4940..0caf866 100644 --- a/.gitignore +++ b/.gitignore @@ -38,12 +38,3 @@ out/ ### Mac OS ### .DS_Store - -### dev ### -application-dev.yml - -### google ### -src/main/resources/credentials/dasomGoogle.json - -### google ### -src/main/resources/credentials/dasomGoogle.json From 051c4a44ec6f200623ee73da086bd1d1c6f8087a Mon Sep 17 00:00:00 2001 From: hodoon Date: Sat, 1 Mar 2025 23:39:43 +0900 Subject: [PATCH 7/9] =?UTF-8?q?fix:=20=EB=A9=B4=EC=A0=91=20=EC=98=88?= =?UTF-8?q?=EC=95=BD=20API=20=EC=88=98=EC=A0=95=20=EC=99=84=EB=A3=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 5 +---- .../dmu/dasom/api/domain/applicant/entity/Applicant.java | 2 +- .../dasom/api/domain/google/service/GoogleApiService.java | 2 -- 3 files changed, 2 insertions(+), 7 deletions(-) diff --git a/.gitignore b/.gitignore index ebf4940..90d0a9d 100644 --- a/.gitignore +++ b/.gitignore @@ -43,7 +43,4 @@ out/ application-dev.yml ### google ### -src/main/resources/credentials/dasomGoogle.json - -### google ### -src/main/resources/credentials/dasomGoogle.json +src/main/resources/credentials/dasomGoogleSheet.json diff --git a/src/main/java/dmu/dasom/api/domain/applicant/entity/Applicant.java b/src/main/java/dmu/dasom/api/domain/applicant/entity/Applicant.java index 4164caa..dd6b6bf 100644 --- a/src/main/java/dmu/dasom/api/domain/applicant/entity/Applicant.java +++ b/src/main/java/dmu/dasom/api/domain/applicant/entity/Applicant.java @@ -92,7 +92,7 @@ public List toGoogleSheetRow(){ this.email, this.grade, this.reasonForApply, - this.activityWish, + this.activityWish != null ? this.activityWish : "없음", // null일 경우 "없음" 반환 this.isPrivacyPolicyAgreed, this.status.name(), this.createdAt.toString(), diff --git a/src/main/java/dmu/dasom/api/domain/google/service/GoogleApiService.java b/src/main/java/dmu/dasom/api/domain/google/service/GoogleApiService.java index 1751a1d..56eac6b 100644 --- a/src/main/java/dmu/dasom/api/domain/google/service/GoogleApiService.java +++ b/src/main/java/dmu/dasom/api/domain/google/service/GoogleApiService.java @@ -175,6 +175,4 @@ public void processSheetsUpdate(List applicants, boolean isAppend) { } } - - } From ba34fa73671a5777d9121f1e519a39e2d81834d8 Mon Sep 17 00:00:00 2001 From: hodoon Date: Sat, 1 Mar 2025 23:46:27 +0900 Subject: [PATCH 8/9] =?UTF-8?q?fix:=20=EB=A9=B4=EC=A0=91=20=EC=98=88?= =?UTF-8?q?=EC=95=BD=20API=20=EC=88=98=EC=A0=95=20=EC=99=84=EB=A3=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 2 -- 1 file changed, 2 deletions(-) diff --git a/.gitignore b/.gitignore index 90d0a9d..0021b38 100644 --- a/.gitignore +++ b/.gitignore @@ -42,5 +42,3 @@ out/ ### dev ### application-dev.yml -### google ### -src/main/resources/credentials/dasomGoogleSheet.json From 800679244989572a44451d5dbe54fd19a2553fd1 Mon Sep 17 00:00:00 2001 From: hodoon Date: Sun, 2 Mar 2025 16:55:33 +0900 Subject: [PATCH 9/9] =?UTF-8?q?fix:=20=EB=A9=B4=EC=A0=91=20=EC=98=88?= =?UTF-8?q?=EC=95=BD=20API=20=EC=A1=B0=ED=9A=8C=20=EA=B8=B0=EB=8A=A5=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../interview/service/InterviewService.java | 2 ++ .../service/InterviewServiceImpl.java | 8 ++++++++ .../recruit/controller/RecruitController.java | 18 ++++++++++++++++++ 3 files changed, 28 insertions(+) diff --git a/src/main/java/dmu/dasom/api/domain/interview/service/InterviewService.java b/src/main/java/dmu/dasom/api/domain/interview/service/InterviewService.java index 4f7c278..4697595 100644 --- a/src/main/java/dmu/dasom/api/domain/interview/service/InterviewService.java +++ b/src/main/java/dmu/dasom/api/domain/interview/service/InterviewService.java @@ -22,5 +22,7 @@ public interface InterviewService { // 면접 예약 취소 void cancelReservation(Long reservationId, Long applicantId); + List getAllInterviewSlots(); + } diff --git a/src/main/java/dmu/dasom/api/domain/interview/service/InterviewServiceImpl.java b/src/main/java/dmu/dasom/api/domain/interview/service/InterviewServiceImpl.java index 3dfe8b2..5f589f4 100644 --- a/src/main/java/dmu/dasom/api/domain/interview/service/InterviewServiceImpl.java +++ b/src/main/java/dmu/dasom/api/domain/interview/service/InterviewServiceImpl.java @@ -132,4 +132,12 @@ public void cancelReservation(Long reservationId, Long applicantId) { interviewReservationRepository.delete(reservation); } + @Override + public List getAllInterviewSlots() { + return interviewSlotRepository.findAll() + .stream() + .map(InterviewSlotResponseDto::new) + .toList(); + } + } diff --git a/src/main/java/dmu/dasom/api/domain/recruit/controller/RecruitController.java b/src/main/java/dmu/dasom/api/domain/recruit/controller/RecruitController.java index 463a0a1..54c87ad 100644 --- a/src/main/java/dmu/dasom/api/domain/recruit/controller/RecruitController.java +++ b/src/main/java/dmu/dasom/api/domain/recruit/controller/RecruitController.java @@ -110,5 +110,23 @@ public ResponseEntity reserveInterviewSlot(@Valid @RequestBody InterviewRe return ResponseEntity.ok().build(); } + // 예약 가능한 면접 일정 조회 + @Operation(summary = "예약 가능한 면접 일정 조회", description = "예약 가능한 면접 슬롯 목록을 조회합니다.") + @ApiResponse(responseCode = "200", description = "예약 가능한 면접 슬롯 조회 성공") + @GetMapping("/interview/available") + public ResponseEntity> getAvailableInterviewSlots() { + List availableSlots = interviewService.getAvailableSlots(); + return ResponseEntity.ok(availableSlots); + } + + // 모든 면접 일정 조회 + @Operation(summary = "모든 면접 일정 조회", description = "모든 면접 슬롯 목록을 조회합니다.") + @ApiResponse(responseCode = "200", description = "모든 면접 슬롯 조회 성공") + @GetMapping("/interview/all") + public ResponseEntity> getAllInterviewSlots() { + List allSlots = interviewService.getAllInterviewSlots(); + return ResponseEntity.ok(allSlots); + } + }