diff --git a/.gitignore b/.gitignore index ebf4940..0021b38 100644 --- a/.gitignore +++ b/.gitignore @@ -42,8 +42,3 @@ out/ ### dev ### application-dev.yml -### google ### -src/main/resources/credentials/dasomGoogle.json - -### google ### -src/main/resources/credentials/dasomGoogle.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/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/common/exception/ErrorCode.java b/src/main/java/dmu/dasom/api/domain/common/exception/ErrorCode.java index ff8b82d..1e0f24d 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", "해당 슬롯이 비활성화 되었습니다."), FILE_ENCODE_FAIL(400, "C028", "파일 인코딩에 실패하였습니다.") ; 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..9217622 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,52 @@ 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 +68,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 +87,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 +107,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 +124,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 +135,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 +154,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 +172,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 +182,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/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..844f216 --- /dev/null +++ b/src/main/java/dmu/dasom/api/domain/interview/dto/InterviewReservationRequestDto.java @@ -0,0 +1,24 @@ +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.*; + +@Getter +@Setter +@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 new file mode 100644 index 0000000..6077f78 --- /dev/null +++ b/src/main/java/dmu/dasom/api/domain/interview/dto/InterviewReservationResponseDto.java @@ -0,0 +1,31 @@ +package dmu.dasom.api.domain.interview.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotNull; +import lombok.*; + +@Getter +@Setter +@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 new file mode 100644 index 0000000..c3ba722 --- /dev/null +++ b/src/main/java/dmu/dasom/api/domain/interview/dto/InterviewSlotCreateRequestDto.java @@ -0,0 +1,33 @@ +package dmu.dasom.api.domain.interview.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotNull; +import lombok.*; + +import java.time.LocalDate; +import java.time.LocalTime; + +@Getter +@Setter +@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 new file mode 100644 index 0000000..e1892e7 --- /dev/null +++ b/src/main/java/dmu/dasom/api/domain/interview/dto/InterviewSlotRequestDto.java @@ -0,0 +1,37 @@ +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; +import lombok.*; + +import java.time.LocalDate; +import java.time.LocalTime; + +@Getter +@Setter +@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명까지 가능합니다.") + @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 new file mode 100644 index 0000000..a5ba624 --- /dev/null +++ b/src/main/java/dmu/dasom/api/domain/interview/dto/InterviewSlotResponseDto.java @@ -0,0 +1,57 @@ +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; +import lombok.*; + +import java.time.LocalDate; +import java.time.LocalTime; + +@Getter +@Setter +@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){ + 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..c9b9e65 --- /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 = 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 new file mode 100644 index 0000000..a71dde9 --- /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.InterviewStatus; +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 InterviewStatus interviewStatus; // 면접 슬롯 상태 (ACTIVE, INACTIVE, CLOSED) + + public void incrementCurrentCandidates() { + this.currentCandidates++; + if (this.currentCandidates >= this.maxCandidates) { + this.interviewStatus = InterviewStatus.CLOSED; // 최대 지원자 수에 도달하면 상태 변경 + } + } + + public void decrementCurrentCandidates() { + this.currentCandidates--; + if (interviewStatus == InterviewStatus.CLOSED && this.currentCandidates < this.maxCandidates) { + this.interviewStatus = InterviewStatus.ACTIVE; // 지원자 수가 줄어들면 다시 활성화 + } + } + +} diff --git a/src/main/java/dmu/dasom/api/domain/interview/enums/InterviewStatus.java b/src/main/java/dmu/dasom/api/domain/interview/enums/InterviewStatus.java new file mode 100644 index 0000000..758f13c --- /dev/null +++ b/src/main/java/dmu/dasom/api/domain/interview/enums/InterviewStatus.java @@ -0,0 +1,7 @@ +package dmu.dasom.api.domain.interview.enums; + +public enum InterviewStatus { + 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..9c718f9 --- /dev/null +++ b/src/main/java/dmu/dasom/api/domain/interview/repositoty/InterviewSlotRepository.java @@ -0,0 +1,26 @@ +package dmu.dasom.api.domain.interview.repositoty; + +import dmu.dasom.api.domain.interview.entity.InterviewSlot; +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; + +import java.util.Collection; +import java.util.List; + +@Repository +public interface InterviewSlotRepository extends JpaRepository { + // 현재 인원이 최대 인원보다 작은 슬롯 조회 + // 현재 예약된 인원이 최대 지원자 수보다 적은 슬롯 조회 + @Query("SELECT s FROM InterviewSlot s WHERE s.currentCandidates < s.maxCandidates") + Collection findAllByCurrentCandidatesLessThanMaxCandidates(); + + // 상태에 따른 슬롯 조회 + @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") + boolean existsAny(); +} 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..3dfe8b2 --- /dev/null +++ b/src/main/java/dmu/dasom/api/domain/interview/service/InterviewServiceImpl.java @@ -0,0 +1,135 @@ +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.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; +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.existsAny(); + + 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) + .interviewStatus(InterviewStatus.ACTIVE) + .build(); + + interviewSlotRepository.save(slot); + newSlots.add(new InterviewSlotResponseDto(slot)); + currentTime = slotEndTime; + } + } + + return newSlots; + } + + // 예약 가능한 면접 슬롯 조회 + @Override + public List getAvailableSlots() { + return interviewSlotRepository.findAllByStatusAndCurrentCandidatesLessThanMaxCandidates(InterviewStatus.ACTIVE) + .stream() + .map(InterviewSlotResponseDto::new) + .toList(); + } + + @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(interviewReservationRepository.existsByReservationCode(reservationCode)){ + throw new CustomException(ErrorCode.ALREADY_RESERVED); + } + + if (slot.getCurrentCandidates() >= slot.getMaxCandidates()) { + throw new CustomException(ErrorCode.SLOT_FULL); + } + + // 예약 정보 저장 + InterviewReservation reservation = InterviewReservation.builder() + .slot(slot) + .applicant(applicant) + .reservationCode(reservationCode) // 학번 + 전화번호 뒤 4자리 + .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..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 @@ -3,6 +3,11 @@ 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.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; import dmu.dasom.api.domain.recruit.dto.ResultCheckResponseDto; import dmu.dasom.api.domain.recruit.dto.RecruitConfigResponseDto; @@ -18,6 +23,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 +34,7 @@ public class RecruitController { private final ApplicantService applicantService; private final RecruitService recruitService; + private final InterviewService interviewService; // 지원하기 @Operation(summary = "부원 지원하기") @@ -76,4 +84,31 @@ 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(@Valid @RequestBody InterviewSlotCreateRequestDto request) { + + List slots = + interviewService.createInterviewSlots(request.getStartDate(), request.getEndDate(), request.getStartTime(), request.getEndTime()); + 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/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/RecruitService.java b/src/main/java/dmu/dasom/api/domain/recruit/service/RecruitService.java index 6e0da17..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 @@ -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 { 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..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 @@ -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; @@ -59,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); @@ -70,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()); // 연락처 뒷자리가 일치하지 않을 경우 예외 발생 @@ -81,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) : @@ -119,4 +126,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 3160243..8f27694 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,98 @@ 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(1234L, "202500010542"); + + // when + interviewService.reserveInterviewSlot(request); + + // then + verify(interviewService, times(1)).reserveInterviewSlot(request); + } + + + @Test + @DisplayName("면접 예약 - 실패 (슬롯 없음)") + void reserveInterviewSlot_fail_slotNotFound() { + // given + InterviewReservationRequestDto request = new InterviewReservationRequestDto(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(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(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()); + } + }