From c65998dd851774cbebd563b531acdc5db4a15c82 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=A1=B0=EC=9C=A4=EC=9E=AC?= Date: Thu, 21 Aug 2025 02:43:33 +0900 Subject: [PATCH 1/6] =?UTF-8?q?DASOMBE-18=20=EC=A7=80=EC=9B=90=EC=9E=90=20?= =?UTF-8?q?=ED=95=99=EB=B2=88,=EC=9D=B4=EB=A9=94=EC=9D=BC=20=ED=99=95?= =?UTF-8?q?=EC=9D=B8=ED=9B=84=20=EB=A9=B4=EC=A0=91=20=EC=98=88=EC=95=BD=20?= =?UTF-8?q?=EB=B0=8F=20=EB=82=A0=EC=9E=90=20=EB=B3=80=EA=B2=BD=20=EA=B8=B0?= =?UTF-8?q?=EB=8A=A5(=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EB=AA=BB=ED=95=B4?= =?UTF-8?q?=EB=B4=84.)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../repository/ApplicantRepository.java | 1 + .../domain/common/exception/ErrorCode.java | 7 +-- .../controller/InterviewController.java | 25 +++++++++++ .../InterviewReservationModifyRequestDto.java | 33 ++++++++++++++ .../entity/InterviewReservation.java | 4 ++ .../InterviewReservationRepository.java | 5 +++ .../interview/service/InterviewService.java | 4 ++ .../service/InterviewServiceImpl.java | 43 ++++++++++++++++++- 8 files changed, 118 insertions(+), 4 deletions(-) create mode 100644 src/main/java/dmu/dasom/api/domain/interview/controller/InterviewController.java create mode 100644 src/main/java/dmu/dasom/api/domain/interview/dto/InterviewReservationModifyRequestDto.java 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 a0a9226..97d1320 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 @@ -25,4 +25,5 @@ public interface ApplicantRepository extends JpaRepository { Optional findByStudentNoAndContactEndsWith(@Param("studentNo") String studentNo, @Param("contactLastDigits") String contactLastDigits); + Optional findByStudentNoAndEmail(String studentNo, String email); } 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 8910414..c726284 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 @@ -34,9 +34,10 @@ public enum ErrorCode { SLOT_FULL(400, "C025", "해당 슬롯이 가득 찼습니다."), RESERVATION_NOT_FOUND(400, "C026", "예약을 찾을 수 없습니다."), SLOT_NOT_ACTIVE(400, "C027", "해당 슬롯이 비활성화 되었습니다."), - FILE_ENCODE_FAIL(400, "C028", "파일 인코딩에 실패하였습니다."), - RECRUITMENT_NOT_ACTIVE(400, "C029", "모집 기간이 아닙니다."), - NOT_FOUND_PARTICIPANT(400, "C030", "참가자를 찾을 수 없습니다.") + SLOT_UNAVAILABLE(400, "C028", "해당 슬롯을 예약할 수 없습니다."), + FILE_ENCODE_FAIL(400, "C029", "파일 인코딩에 실패하였습니다."), + RECRUITMENT_NOT_ACTIVE(400, "C030", "모집 기간이 아닙니다."), + NOT_FOUND_PARTICIPANT(400, "C031", "참가자를 찾을 수 없습니다.") ; private final int status; diff --git a/src/main/java/dmu/dasom/api/domain/interview/controller/InterviewController.java b/src/main/java/dmu/dasom/api/domain/interview/controller/InterviewController.java new file mode 100644 index 0000000..aa7d8c1 --- /dev/null +++ b/src/main/java/dmu/dasom/api/domain/interview/controller/InterviewController.java @@ -0,0 +1,25 @@ +package dmu.dasom.api.domain.interview.controller; + +import dmu.dasom.api.domain.interview.dto.InterviewReservationModifyRequestDto; +import dmu.dasom.api.domain.interview.service.InterviewService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +@Tag(name = "Interview", description = "면접 관련 API") +@RestController +@RequiredArgsConstructor +@RequestMapping("/api/interview") +public class InterviewController { + + private final InterviewService interviewService; + + @Operation(summary = "면접 예약 수정", description = "학번과 이메일 인증 후 면접 날짜 및 시간을 수정합니다.") + @PutMapping("/reservation/modify") + public ResponseEntity modifyInterviewReservation(@RequestBody InterviewReservationModifyRequestDto request) { + interviewService.modifyInterviewReservation(request); + return ResponseEntity.ok().build(); + } +} diff --git a/src/main/java/dmu/dasom/api/domain/interview/dto/InterviewReservationModifyRequestDto.java b/src/main/java/dmu/dasom/api/domain/interview/dto/InterviewReservationModifyRequestDto.java new file mode 100644 index 0000000..6e30847 --- /dev/null +++ b/src/main/java/dmu/dasom/api/domain/interview/dto/InterviewReservationModifyRequestDto.java @@ -0,0 +1,33 @@ +package dmu.dasom.api.domain.interview.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Pattern; +import jakarta.validation.constraints.Size; +import lombok.*; + +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +@Builder +@Schema(name = "InterviewReservationModifyRequestDto", description = "면접 예약 수정 요청 DTO") +public class InterviewReservationModifyRequestDto { + + @NotNull(message = "학번은 필수 값입니다.") + @Pattern(regexp = "^[0-9]{8}$", message = "학번은 8자리 숫자로 구성되어야 합니다.") + @Size(min = 8, max = 8) + @Schema(description = "지원자 학번", example = "20250001") + private String studentNo; + + @NotNull(message = "이메일은 필수 값입니다.") + @Email(message = "유효한 이메일 주소를 입력해주세요.") + @Size(max = 64) + @Schema(description = "지원자 이메일", example = "test@example.com") + private String email; + + @NotNull(message = "새로운 슬롯 ID는 필수 값입니다.") + @Schema(description = "새롭게 예약할 면접 슬롯의 ID", example = "2") + private Long newSlotId; +} 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 682c47f..33ceb73 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 @@ -36,4 +36,8 @@ public class InterviewReservation { @CreatedDate @Column(name = "created_at", nullable = false, updatable = false) private LocalDateTime createdAt; // 생성 + + public void setSlot(InterviewSlot slot) { + this.slot = slot; + } } diff --git a/src/main/java/dmu/dasom/api/domain/interview/repository/InterviewReservationRepository.java b/src/main/java/dmu/dasom/api/domain/interview/repository/InterviewReservationRepository.java index 4b7e402..a221c15 100644 --- a/src/main/java/dmu/dasom/api/domain/interview/repository/InterviewReservationRepository.java +++ b/src/main/java/dmu/dasom/api/domain/interview/repository/InterviewReservationRepository.java @@ -1,10 +1,15 @@ package dmu.dasom.api.domain.interview.repository; import dmu.dasom.api.domain.interview.entity.InterviewReservation; +import dmu.dasom.api.domain.applicant.entity.Applicant; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.stereotype.Repository; +import java.util.Optional; + @Repository public interface InterviewReservationRepository extends JpaRepository { boolean existsByReservationCode(String reservationCode); + Optional findByReservationCode(String reservationCode); + Optional findByApplicant(Applicant applicant); } 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 f58f8ce..e332477 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 @@ -2,6 +2,7 @@ import dmu.dasom.api.domain.interview.dto.InterviewReservationApplicantResponseDto; import dmu.dasom.api.domain.interview.dto.InterviewReservationRequestDto; +import dmu.dasom.api.domain.interview.dto.InterviewReservationModifyRequestDto; import dmu.dasom.api.domain.interview.dto.InterviewSlotResponseDto; import java.time.LocalDate; @@ -27,4 +28,7 @@ public interface InterviewService { List getAllInterviewApplicants(); + // 면접 예약 수정 + void modifyInterviewReservation(InterviewReservationModifyRequestDto request); + } 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 6a94895..190e954 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 @@ -6,6 +6,7 @@ import dmu.dasom.api.domain.common.exception.ErrorCode; import dmu.dasom.api.domain.interview.dto.InterviewReservationApplicantResponseDto; import dmu.dasom.api.domain.interview.dto.InterviewReservationRequestDto; +import dmu.dasom.api.domain.interview.dto.InterviewReservationModifyRequestDto; import dmu.dasom.api.domain.interview.dto.InterviewSlotResponseDto; import dmu.dasom.api.domain.interview.entity.InterviewReservation; import dmu.dasom.api.domain.interview.entity.InterviewSlot; @@ -148,7 +149,7 @@ public List getAllInterviewSlots() { .toList(); } - @Override + @Override public List getAllInterviewApplicants() { List reservations = interviewReservationRepository.findAll(); @@ -172,4 +173,44 @@ public List getAllInterviewApplicants( .collect(Collectors.toList()); } + @Override + @Transactional + public void modifyInterviewReservation(InterviewReservationModifyRequestDto request) { + // 1. 지원자 학번과 이메일로 지원자 조회 및 검증 + Applicant applicant = applicantRepository.findByStudentNoAndEmail(request.getStudentNo(), request.getEmail()) + .orElseThrow(() -> new CustomException(ErrorCode.APPLICANT_NOT_FOUND)); + + // 2. 해당 지원자의 기존 면접 예약 조회 + InterviewReservation existingReservation = interviewReservationRepository.findByApplicant(applicant) + .orElseThrow(() -> new CustomException(ErrorCode.RESERVATION_NOT_FOUND)); + + // 3. 새로운 면접 슬롯 조회 및 검증 + InterviewSlot newSlot = interviewSlotRepository.findById(request.getNewSlotId()) + .orElseThrow(() -> new CustomException(ErrorCode.SLOT_NOT_FOUND)); + + // 4. 새로운 슬롯이 현재 예약된 슬롯과 동일한지 확인 (불필요한 업데이트 방지) + if (existingReservation.getSlot().getId().equals(newSlot.getId())) { + return; // 동일한 슬롯으로 변경 요청 시 아무것도 하지 않음 + } + + // 5. 새로운 슬롯의 가용성 확인 + if (newSlot.getCurrentCandidates() >= newSlot.getMaxCandidates() || newSlot.getInterviewStatus() != InterviewStatus.ACTIVE) { + throw new CustomException(ErrorCode.SLOT_UNAVAILABLE); + } + + // 6. 기존 슬롯의 예약 인원 감소 + InterviewSlot oldSlot = existingReservation.getSlot(); + oldSlot.decrementCurrentCandidates(); + interviewSlotRepository.save(oldSlot); // 변경된 oldSlot 저장 + + // 7. 예약 정보 업데이트 (새로운 슬롯으로 변경) + existingReservation.setSlot(newSlot); // InterviewReservation 엔티티에 setSlot 메서드가 없으므로 추가해야 함. + + // 8. 새로운 슬롯의 예약 인원 증가 + newSlot.incrementCurrentCandidates(); + interviewSlotRepository.save(newSlot); // 변경된 newSlot 저장 + + // 9. 업데이트된 예약 정보 저장 + interviewReservationRepository.save(existingReservation); + } } From e8425b8abbde3532d708d56b3dcca3457b7fb939 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=A1=B0=EC=9C=A4=EC=9E=AC?= Date: Thu, 21 Aug 2025 17:32:32 +0900 Subject: [PATCH 2/6] =?UTF-8?q?DASOMBE-18=20=EB=A9=B4=EC=A0=91=20=EC=A7=80?= =?UTF-8?q?=EC=9B=90=EC=9E=90=20=EC=98=88=EC=95=BD=20=EB=82=A0=EC=A7=9C=20?= =?UTF-8?q?=EB=B3=80=EA=B2=BD=20=ED=85=8C=EC=8A=A4=ED=8A=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../repository/InterviewSlotRepository.java | 2 +- .../recruit/controller/RecruitController.java | 14 ++++++++++++++ .../domain/recruit/service/RecruitService.java | 4 +++- .../recruit/service/RecruitServiceImpl.java | 15 +++++++++++++++ 4 files changed, 33 insertions(+), 2 deletions(-) diff --git a/src/main/java/dmu/dasom/api/domain/interview/repository/InterviewSlotRepository.java b/src/main/java/dmu/dasom/api/domain/interview/repository/InterviewSlotRepository.java index 67d005c..38a3303 100644 --- a/src/main/java/dmu/dasom/api/domain/interview/repository/InterviewSlotRepository.java +++ b/src/main/java/dmu/dasom/api/domain/interview/repository/InterviewSlotRepository.java @@ -18,7 +18,7 @@ public interface InterviewSlotRepository extends JpaRepository findAllByStatusAndCurrentCandidatesLessThanMaxCandidates(InterviewStatus interviewStatus); + List findAllByStatusAndCurrentCandidatesLessThanMaxCandidates(@org.springframework.data.repository.query.Param("status") InterviewStatus interviewStatus); // 슬롯이 하나라도 존재하는지 확인 @Query("SELECT COUNT(s) > 0 FROM InterviewSlot s") 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 f08f00c..5444652 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 @@ -67,6 +67,20 @@ public ResponseEntity> getRecruitSchedule() { return ResponseEntity.ok(recruitService.getRecruitSchedule()); } + @Operation(summary = "모집 일정 수정") + @PutMapping("/schedule") + public ResponseEntity modifyRecruitSchedule(@RequestBody dmu.dasom.api.domain.recruit.dto.RecruitScheduleModifyRequestDto request) { + recruitService.modifyRecruitSchedule(request); + return ResponseEntity.ok().build(); + } + + @Operation(summary = "TEMP: 모집 일정 초기화") + @GetMapping("/init-schedule") + public ResponseEntity initSchedule() { + recruitService.initRecruitSchedule(); + return ResponseEntity.ok("Recruit schedule initialized successfully."); + } + // 합격 결과 확인 @Operation(summary = "합격 결과 확인") @ApiResponses(value = { 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 ac272dd..8864045 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 @@ -13,7 +13,9 @@ public interface RecruitService { List getRecruitSchedule(); - void modifyRecruitSchedule(final RecruitScheduleModifyRequestDto requestDto); + void modifyRecruitSchedule(RecruitScheduleModifyRequestDto request); + + void initRecruitSchedule(); String generateReservationCode(String studentNo, String contactLastDigits); 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 7dbd6ce..8054b53 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 @@ -74,6 +74,21 @@ public LocalDateTime getResultAnnouncementSchedule(ResultCheckType type) { return parseDateTimeFormat(recruit.getValue()); } + @Override + @Transactional + public void initRecruitSchedule() { + if (recruitRepository.count() > 0) { + return; // Already initialized + } + for (dmu.dasom.api.domain.recruit.enums.ConfigKey key : dmu.dasom.api.domain.recruit.enums.ConfigKey.values()) { + dmu.dasom.api.domain.recruit.entity.Recruit recruit = dmu.dasom.api.domain.recruit.entity.Recruit.builder() + .key(key) + .value("2025-01-01T00:00:00") // Default value + .build(); + recruitRepository.save(recruit); + } + } + // DB에 저장된 모든 Recruit 객체를 찾아 반환 private List findAll() { return recruitRepository.findAll(); From 4c5cc6f17c12a65994c11dd46b17b7d84d808635 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=A1=B0=EC=9C=A4=EC=9E=AC?= Date: Thu, 21 Aug 2025 20:52:11 +0900 Subject: [PATCH 3/6] =?UTF-8?q?DASOMBE-18=20=EC=9D=B4=EB=A9=94=EC=9D=BC=20?= =?UTF-8?q?=EC=9D=B8=EC=A6=9D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/common/exception/ErrorCode.java | 3 +- .../domain/email/service/EmailService.java | 96 +++++++++++++++++++ .../api/domain/google/enums/MailType.java | 3 +- .../controller/InterviewController.java | 38 +++++++- .../InterviewReservationModifyRequestDto.java | 4 + .../dto/VerificationCodeRequestDto.java | 17 ++++ .../service/InterviewServiceImpl.java | 7 +- .../global/util/VerificationCodeManager.java | 48 ++++++++++ 8 files changed, 211 insertions(+), 5 deletions(-) create mode 100644 src/main/java/dmu/dasom/api/domain/email/service/EmailService.java create mode 100644 src/main/java/dmu/dasom/api/domain/interview/dto/VerificationCodeRequestDto.java create mode 100644 src/main/java/dmu/dasom/api/global/util/VerificationCodeManager.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 c726284..827d026 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 @@ -37,7 +37,8 @@ public enum ErrorCode { SLOT_UNAVAILABLE(400, "C028", "해당 슬롯을 예약할 수 없습니다."), FILE_ENCODE_FAIL(400, "C029", "파일 인코딩에 실패하였습니다."), RECRUITMENT_NOT_ACTIVE(400, "C030", "모집 기간이 아닙니다."), - NOT_FOUND_PARTICIPANT(400, "C031", "참가자를 찾을 수 없습니다.") + NOT_FOUND_PARTICIPANT(400, "C031", "참가자를 찾을 수 없습니다."), + VERIFICATION_CODE_NOT_VALID(400, "C032", "인증 코드가 유효하지 않습니다.") ; private final int status; diff --git a/src/main/java/dmu/dasom/api/domain/email/service/EmailService.java b/src/main/java/dmu/dasom/api/domain/email/service/EmailService.java new file mode 100644 index 0000000..60474b0 --- /dev/null +++ b/src/main/java/dmu/dasom/api/domain/email/service/EmailService.java @@ -0,0 +1,96 @@ +package dmu.dasom.api.domain.email.service; + +import dmu.dasom.api.domain.common.exception.CustomException; +import dmu.dasom.api.domain.common.exception.ErrorCode; +import dmu.dasom.api.domain.google.enums.MailType; +import jakarta.mail.MessagingException; +import jakarta.mail.internet.MimeMessage; +import lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.mail.javamail.JavaMailSender; +import org.springframework.mail.javamail.MimeMessageHelper; +import org.springframework.stereotype.Service; +import org.thymeleaf.TemplateEngine; +import org.thymeleaf.context.Context; + +@RequiredArgsConstructor +@Service +public class EmailService { + + private final TemplateEngine templateEngine; + private final JavaMailSender javaMailSender; + + @Value("${spring.mail.username}") + private String from; + + public void sendVerificationEmail(String to, String name, String code) throws MessagingException { + String subject = "DASOM 면접 시간 변경을 위한 이메일 인증 코드 안내"; + String emailContent = "요청하신 인증 코드는 다음과 같습니다.
" + + "인증 코드: " + code + "
" + + "해당 코드를 3분 내에 입력하여 인증을 완료해주세요."; + + Context context = new Context(); + context.setVariable("name", name); + context.setVariable("emailContent", emailContent); + context.setVariable("buttonUrl", "https://dmu-dasom.or.kr"); // Or some other relevant link + context.setVariable("buttonText", "다솜 홈페이지로 이동"); + + String htmlBody = templateEngine.process("email-template", context); + send(to, subject, htmlBody); + } + + public void sendEmail(String to, String name, MailType mailType) throws MessagingException { + if (mailType == null){ + throw new CustomException(ErrorCode.MAIL_TYPE_NOT_VALID); + } + + // 메일 제목 및 템플릿 설정 + String subject; + String emailContent; + String buttonUrl = "https://dmu-dasom.or.kr/recruit/result"; + String buttonText; + + switch (mailType) { + case DOCUMENT_RESULT -> { + subject = "동양미래대학교 컴퓨터소프트웨어공학과 전공 동아리 DASOM 서류 결과 안내"; + emailContent = "먼저 다솜 34기에 많은 관심을 두고 지원해 주셔서 감사드리며,
" + + "내부 서류 평가 결과 및 추후 일정에 관해 안내해드리고자 이메일을 발송하게 되었습니다.
" + + "서류 전형 결과는 아래 버튼 혹은 홈페이지를 통해 확인이 가능합니다."; + buttonText = "서류 결과 확인하기"; + } + case FINAL_RESULT -> { + subject = "동양미래대학교 컴퓨터소프트웨어공학과 전공 동아리 DASOM 최종 면접 결과 안내"; + emailContent = "먼저 다솜 34기에 많은 관심을 두고 지원해 주셔서 감사드리며,
" + + "최종 면접 결과 및 추후 일정에 관해 안내해드리고자 이메일을 발송하게 되었습니다.
" + + "최종 면접 결과는 아래 버튼 혹은 홈페이지를 통해 확인이 가능합니다."; + buttonText = "최종 결과 확인하기"; + } + default -> throw new IllegalStateException("Unexpected value: " + mailType); + } + + // HTML 템플릿에 전달할 데이터 설정 + Context context = new Context(); + context.setVariable("name", name); // 지원자 이름 전달 + context.setVariable("emailContent", emailContent); // 이메일 내용 전달 + context.setVariable("buttonUrl", buttonUrl); // 버튼 링크 전달 + context.setVariable("buttonText", buttonText); + + // HTML 템플릿 처리 + String htmlBody = templateEngine.process("email-template", context); + send(to, subject, htmlBody); + } + + private void send(String to, String subject, String htmlBody) throws MessagingException { + MimeMessage message = javaMailSender.createMimeMessage(); + MimeMessageHelper helper = new MimeMessageHelper(message, true, "UTF-8"); + + helper.setTo(to); + helper.setSubject(subject); + helper.setText(htmlBody, true); + helper.setFrom((from != null && !from.isEmpty()) ? from : "dasomdmu@gmail.com"); + + message.setContent(htmlBody, "text/html; charset=utf-8"); + + javaMailSender.send(message); + } +} diff --git a/src/main/java/dmu/dasom/api/domain/google/enums/MailType.java b/src/main/java/dmu/dasom/api/domain/google/enums/MailType.java index 9ed0ed6..3805dc4 100644 --- a/src/main/java/dmu/dasom/api/domain/google/enums/MailType.java +++ b/src/main/java/dmu/dasom/api/domain/google/enums/MailType.java @@ -2,5 +2,6 @@ public enum MailType { DOCUMENT_RESULT, // 서류 합격 - FINAL_RESULT // 최종 합격 + FINAL_RESULT, // 최종 합격 + VERIFICATION // 이메일 인증 } diff --git a/src/main/java/dmu/dasom/api/domain/interview/controller/InterviewController.java b/src/main/java/dmu/dasom/api/domain/interview/controller/InterviewController.java index aa7d8c1..b7f103c 100644 --- a/src/main/java/dmu/dasom/api/domain/interview/controller/InterviewController.java +++ b/src/main/java/dmu/dasom/api/domain/interview/controller/InterviewController.java @@ -1,13 +1,25 @@ package dmu.dasom.api.domain.interview.controller; +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.email.service.EmailService; import dmu.dasom.api.domain.interview.dto.InterviewReservationModifyRequestDto; +import dmu.dasom.api.domain.interview.dto.VerificationCodeRequestDto; import dmu.dasom.api.domain.interview.service.InterviewService; +import dmu.dasom.api.global.util.VerificationCodeManager; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.mail.MessagingException; +import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.*; +import java.util.List; +import dmu.dasom.api.domain.interview.dto.InterviewReservationApplicantResponseDto; + @Tag(name = "Interview", description = "면접 관련 API") @RestController @RequiredArgsConstructor @@ -15,11 +27,33 @@ public class InterviewController { private final InterviewService interviewService; + private final ApplicantRepository applicantRepository; + private final VerificationCodeManager verificationCodeManager; + private final EmailService emailService; + + @Operation(summary = "면접 예약 수정을 위한 인증 코드 발송", description = "지원자의 학번을 받아 이메일로 인증 코드를 발송합니다.") + @PostMapping("/send-verification") + public ResponseEntity sendVerificationCode(@Valid @RequestBody VerificationCodeRequestDto request) throws MessagingException { + Applicant applicant = applicantRepository.findByStudentNo(request.getStudentNo()) + .orElseThrow(() -> new CustomException(ErrorCode.APPLICANT_NOT_FOUND)); + + String code = verificationCodeManager.generateAndStoreCode(applicant.getStudentNo()); + emailService.sendVerificationEmail(applicant.getEmail(), applicant.getName(), code); + + return ResponseEntity.ok().build(); + } - @Operation(summary = "면접 예약 수정", description = "학번과 이메일 인증 후 면접 날짜 및 시간을 수정합니다.") + @Operation(summary = "면접 예약 수정", description = "이메일로 발송된 인증 코드를 통해 인증 후, 면접 날짜 및 시간을 수정합니다.") @PutMapping("/reservation/modify") - public ResponseEntity modifyInterviewReservation(@RequestBody InterviewReservationModifyRequestDto request) { + public ResponseEntity modifyInterviewReservation(@Valid @RequestBody InterviewReservationModifyRequestDto request) { interviewService.modifyInterviewReservation(request); return ResponseEntity.ok().build(); } + + @Operation(summary = "모든 면접 지원자 목록 조회", description = "모든 면접 지원자의 상세 정보와 예약 정보를 조회합니다.") + @GetMapping("/applicants") + public ResponseEntity> getAllInterviewApplicants() { + List applicants = interviewService.getAllInterviewApplicants(); + return ResponseEntity.ok(applicants); + } } diff --git a/src/main/java/dmu/dasom/api/domain/interview/dto/InterviewReservationModifyRequestDto.java b/src/main/java/dmu/dasom/api/domain/interview/dto/InterviewReservationModifyRequestDto.java index 6e30847..5658fcb 100644 --- a/src/main/java/dmu/dasom/api/domain/interview/dto/InterviewReservationModifyRequestDto.java +++ b/src/main/java/dmu/dasom/api/domain/interview/dto/InterviewReservationModifyRequestDto.java @@ -30,4 +30,8 @@ public class InterviewReservationModifyRequestDto { @NotNull(message = "새로운 슬롯 ID는 필수 값입니다.") @Schema(description = "새롭게 예약할 면접 슬롯의 ID", example = "2") private Long newSlotId; + + @NotNull(message = "인증 코드는 필수 값입니다.") + @Schema(description = "이메일로 발송된 6자리 인증 코드", example = "123456") + private String verificationCode; } diff --git a/src/main/java/dmu/dasom/api/domain/interview/dto/VerificationCodeRequestDto.java b/src/main/java/dmu/dasom/api/domain/interview/dto/VerificationCodeRequestDto.java new file mode 100644 index 0000000..a43d463 --- /dev/null +++ b/src/main/java/dmu/dasom/api/domain/interview/dto/VerificationCodeRequestDto.java @@ -0,0 +1,17 @@ +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 jakarta.validation.constraints.Size; +import lombok.Getter; + +@Getter +public class VerificationCodeRequestDto { + + @NotNull(message = "학번은 필수 값입니다.") + @Pattern(regexp = "^[0-9]{8}$", message = "학번은 8자리 숫자로 구성되어야 합니다.") + @Size(min = 8, max = 8) + @Schema(description = "지원자 학번", example = "20250001") + private String studentNo; +} 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 190e954..89cc912 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 @@ -14,6 +14,7 @@ import dmu.dasom.api.domain.interview.repository.InterviewReservationRepository; import dmu.dasom.api.domain.interview.repository.InterviewSlotRepository; import dmu.dasom.api.domain.recruit.service.RecruitServiceImpl; +import dmu.dasom.api.global.util.VerificationCodeManager; import jakarta.persistence.EntityListeners; import lombok.RequiredArgsConstructor; import org.springframework.data.jpa.domain.support.AuditingEntityListener; @@ -36,6 +37,7 @@ public class InterviewServiceImpl implements InterviewService{ private final InterviewReservationRepository interviewReservationRepository; private final ApplicantRepository applicantRepository; private final RecruitServiceImpl recruitService; + private final VerificationCodeManager verificationCodeManager; @Override @Transactional @@ -176,6 +178,9 @@ public List getAllInterviewApplicants( @Override @Transactional public void modifyInterviewReservation(InterviewReservationModifyRequestDto request) { + // 0. 인증 코드 검증 + verificationCodeManager.verifyCode(request.getStudentNo(), request.getVerificationCode()); + // 1. 지원자 학번과 이메일로 지원자 조회 및 검증 Applicant applicant = applicantRepository.findByStudentNoAndEmail(request.getStudentNo(), request.getEmail()) .orElseThrow(() -> new CustomException(ErrorCode.APPLICANT_NOT_FOUND)); @@ -204,7 +209,7 @@ public void modifyInterviewReservation(InterviewReservationModifyRequestDto requ interviewSlotRepository.save(oldSlot); // 변경된 oldSlot 저장 // 7. 예약 정보 업데이트 (새로운 슬롯으로 변경) - existingReservation.setSlot(newSlot); // InterviewReservation 엔티티에 setSlot 메서드가 없으므로 추가해야 함. + existingReservation.setSlot(newSlot); // 8. 새로운 슬롯의 예약 인원 증가 newSlot.incrementCurrentCandidates(); diff --git a/src/main/java/dmu/dasom/api/global/util/VerificationCodeManager.java b/src/main/java/dmu/dasom/api/global/util/VerificationCodeManager.java new file mode 100644 index 0000000..29e3fd5 --- /dev/null +++ b/src/main/java/dmu/dasom/api/global/util/VerificationCodeManager.java @@ -0,0 +1,48 @@ +package dmu.dasom.api.global.util; + +import dmu.dasom.api.domain.common.exception.CustomException; +import dmu.dasom.api.domain.common.exception.ErrorCode; +import org.springframework.stereotype.Component; + +import java.time.Instant; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ThreadLocalRandom; + +@Component +public class VerificationCodeManager { + + private static final long EXPIRATION_TIME_SECONDS = 180; // 3분 + private final ConcurrentHashMap codeStore = new ConcurrentHashMap<>(); + private final ConcurrentHashMap expirationStore = new ConcurrentHashMap<>(); + + public String generateAndStoreCode(String key) { + String code = String.valueOf(ThreadLocalRandom.current().nextInt(100000, 1000000)); + codeStore.put(key, code); + expirationStore.put(key, Instant.now().plusSeconds(EXPIRATION_TIME_SECONDS)); + return code; + } + + public void verifyCode(String key, String code) { + if (!isCodeValid(key, code)) { + throw new CustomException(ErrorCode.VERIFICATION_CODE_NOT_VALID); + } + removeCode(key); + } + + private boolean isCodeValid(String key, String code) { + if (code == null || code.isEmpty()) { + return false; + } + Instant expirationTime = expirationStore.get(key); + if (expirationTime == null || Instant.now().isAfter(expirationTime)) { + removeCode(key); + return false; + } + return code.equals(codeStore.get(key)); + } + + private void removeCode(String key) { + codeStore.remove(key); + expirationStore.remove(key); + } +} From 6d0e134a1b9ed96e6f8e0397c062c469507462ef Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=A1=B0=EC=9C=A4=EC=9E=AC?= Date: Wed, 27 Aug 2025 05:38:22 +0900 Subject: [PATCH 4/6] =?UTF-8?q?DASOMBE-18=20<=ED=94=BC=EB=93=9C=EB=B0=B1?= =?UTF-8?q?=20=EB=B0=98=EC=98=81.=20=EC=B6=94=EA=B0=80=20=EC=84=A4?= =?UTF-8?q?=EB=AA=85=20=ED=91=9C=EC=8B=9C.=20=EC=9D=B8=EC=A6=9D=EC=BD=94?= =?UTF-8?q?=EB=93=9C=20=ED=85=9C=ED=94=8C=EB=A6=BF=20=EB=B0=98=EC=98=81>?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/common/exception/ErrorCode.java | 10 +- .../domain/email/service/EmailService.java | 96 ------------------- .../domain/google/service/EmailService.java | 39 +++++++- .../controller/InterviewController.java | 24 ++++- .../repository/InterviewSlotRepository.java | 4 +- .../service/InterviewServiceImpl.java | 2 +- .../recruit/controller/RecruitController.java | 11 +++ .../recruit/service/RecruitService.java | 2 +- .../recruit/service/RecruitServiceImpl.java | 27 ++++-- .../global/util/VerificationCodeManager.java | 55 ++++++----- .../resources/templates/verify-num-email.html | 48 ++++++++++ 11 files changed, 168 insertions(+), 150 deletions(-) delete mode 100644 src/main/java/dmu/dasom/api/domain/email/service/EmailService.java create mode 100644 src/main/resources/templates/verify-num-email.html 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 827d026..9f3ad11 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 @@ -34,11 +34,11 @@ public enum ErrorCode { SLOT_FULL(400, "C025", "해당 슬롯이 가득 찼습니다."), RESERVATION_NOT_FOUND(400, "C026", "예약을 찾을 수 없습니다."), SLOT_NOT_ACTIVE(400, "C027", "해당 슬롯이 비활성화 되었습니다."), - SLOT_UNAVAILABLE(400, "C028", "해당 슬롯을 예약할 수 없습니다."), - FILE_ENCODE_FAIL(400, "C029", "파일 인코딩에 실패하였습니다."), - RECRUITMENT_NOT_ACTIVE(400, "C030", "모집 기간이 아닙니다."), - NOT_FOUND_PARTICIPANT(400, "C031", "참가자를 찾을 수 없습니다."), - VERIFICATION_CODE_NOT_VALID(400, "C032", "인증 코드가 유효하지 않습니다.") + FILE_ENCODE_FAIL(400, "C028", "파일 인코딩에 실패하였습니다."), + RECRUITMENT_NOT_ACTIVE(400, "C029", "모집 기간이 아닙니다."), + NOT_FOUND_PARTICIPANT(400, "C030", "참가자를 찾을 수 없습니다."), + VERIFICATION_CODE_NOT_VALID(400, "C031", "인증 코드가 유효하지 않습니다."), + SLOT_UNAVAILABLE(400, "C032", "해당 슬롯을 예약할 수 없습니다.") ; private final int status; diff --git a/src/main/java/dmu/dasom/api/domain/email/service/EmailService.java b/src/main/java/dmu/dasom/api/domain/email/service/EmailService.java deleted file mode 100644 index 60474b0..0000000 --- a/src/main/java/dmu/dasom/api/domain/email/service/EmailService.java +++ /dev/null @@ -1,96 +0,0 @@ -package dmu.dasom.api.domain.email.service; - -import dmu.dasom.api.domain.common.exception.CustomException; -import dmu.dasom.api.domain.common.exception.ErrorCode; -import dmu.dasom.api.domain.google.enums.MailType; -import jakarta.mail.MessagingException; -import jakarta.mail.internet.MimeMessage; -import lombok.RequiredArgsConstructor; -import org.springframework.beans.factory.annotation.Value; -import org.springframework.mail.javamail.JavaMailSender; -import org.springframework.mail.javamail.MimeMessageHelper; -import org.springframework.stereotype.Service; -import org.thymeleaf.TemplateEngine; -import org.thymeleaf.context.Context; - -@RequiredArgsConstructor -@Service -public class EmailService { - - private final TemplateEngine templateEngine; - private final JavaMailSender javaMailSender; - - @Value("${spring.mail.username}") - private String from; - - public void sendVerificationEmail(String to, String name, String code) throws MessagingException { - String subject = "DASOM 면접 시간 변경을 위한 이메일 인증 코드 안내"; - String emailContent = "요청하신 인증 코드는 다음과 같습니다.
" + - "인증 코드: " + code + "
" + - "해당 코드를 3분 내에 입력하여 인증을 완료해주세요."; - - Context context = new Context(); - context.setVariable("name", name); - context.setVariable("emailContent", emailContent); - context.setVariable("buttonUrl", "https://dmu-dasom.or.kr"); // Or some other relevant link - context.setVariable("buttonText", "다솜 홈페이지로 이동"); - - String htmlBody = templateEngine.process("email-template", context); - send(to, subject, htmlBody); - } - - public void sendEmail(String to, String name, MailType mailType) throws MessagingException { - if (mailType == null){ - throw new CustomException(ErrorCode.MAIL_TYPE_NOT_VALID); - } - - // 메일 제목 및 템플릿 설정 - String subject; - String emailContent; - String buttonUrl = "https://dmu-dasom.or.kr/recruit/result"; - String buttonText; - - switch (mailType) { - case DOCUMENT_RESULT -> { - subject = "동양미래대학교 컴퓨터소프트웨어공학과 전공 동아리 DASOM 서류 결과 안내"; - emailContent = "먼저 다솜 34기에 많은 관심을 두고 지원해 주셔서 감사드리며,
" + - "내부 서류 평가 결과 및 추후 일정에 관해 안내해드리고자 이메일을 발송하게 되었습니다.
" + - "서류 전형 결과는 아래 버튼 혹은 홈페이지를 통해 확인이 가능합니다."; - buttonText = "서류 결과 확인하기"; - } - case FINAL_RESULT -> { - subject = "동양미래대학교 컴퓨터소프트웨어공학과 전공 동아리 DASOM 최종 면접 결과 안내"; - emailContent = "먼저 다솜 34기에 많은 관심을 두고 지원해 주셔서 감사드리며,
" + - "최종 면접 결과 및 추후 일정에 관해 안내해드리고자 이메일을 발송하게 되었습니다.
" + - "최종 면접 결과는 아래 버튼 혹은 홈페이지를 통해 확인이 가능합니다."; - buttonText = "최종 결과 확인하기"; - } - default -> throw new IllegalStateException("Unexpected value: " + mailType); - } - - // HTML 템플릿에 전달할 데이터 설정 - Context context = new Context(); - context.setVariable("name", name); // 지원자 이름 전달 - context.setVariable("emailContent", emailContent); // 이메일 내용 전달 - context.setVariable("buttonUrl", buttonUrl); // 버튼 링크 전달 - context.setVariable("buttonText", buttonText); - - // HTML 템플릿 처리 - String htmlBody = templateEngine.process("email-template", context); - send(to, subject, htmlBody); - } - - private void send(String to, String subject, String htmlBody) throws MessagingException { - MimeMessage message = javaMailSender.createMimeMessage(); - MimeMessageHelper helper = new MimeMessageHelper(message, true, "UTF-8"); - - helper.setTo(to); - helper.setSubject(subject); - helper.setText(htmlBody, true); - helper.setFrom((from != null && !from.isEmpty()) ? from : "dasomdmu@gmail.com"); - - message.setContent(htmlBody, "text/html; charset=utf-8"); - - javaMailSender.send(message); - } -} diff --git a/src/main/java/dmu/dasom/api/domain/google/service/EmailService.java b/src/main/java/dmu/dasom/api/domain/google/service/EmailService.java index 0b5e0c1..40d50a2 100644 --- a/src/main/java/dmu/dasom/api/domain/google/service/EmailService.java +++ b/src/main/java/dmu/dasom/api/domain/google/service/EmailService.java @@ -44,9 +44,8 @@ public void sendEmail(String to, String name, MailType mailType) { // HTML 템플릿에 전달할 데이터 설정 Context context = new Context(); - context.setVariable("name", name); // 지원자 이름 전달 - context.setVariable("buttonUrl", buttonUrl); // 버튼 링크 전달 - + context.setVariable("name", name); + context.setVariable("buttonUrl", buttonUrl); // HTML 템플릿 처리 String htmlBody = templateEngine.process(mailTemplate.getTemplateName(), context); @@ -60,11 +59,10 @@ public void sendEmail(String to, String name, MailType mailType) { helper.setText(htmlBody, true); helper.setFrom((from != null && !from.isEmpty()) ? from : "dasomdmu@gmail.com"); - // Content-Type을 명시적으로 설정 message.setContent(htmlBody, "text/html; charset=utf-8"); javaMailSender.send(message); - log.info("Email sent successfull {}", to); + log.info("Email sent successfully {}", to); } catch (MessagingException e) { log.error("Failed to send email to {}: {}", to, e.getMessage()); mailSendStatus = MailSendStatus.FAILURE; @@ -76,4 +74,35 @@ public void sendEmail(String to, String name, MailType mailType) { } emailLogService.logEmailSending(to, mailSendStatus, errorMessage); } + + /* + * 면접 예약 변경을 위한 인증코드 발송 + * - VerificationCodeManager에서 생성된 코드를 이메일로 전송 + * - verify-num-email.html 템플릿을 이용해 코드와 버튼 링크 포함 + */ + public void sendVerificationEmail(String to, String name, String code) throws MessagingException { + String subject = "DASOM 면접 시간 변경을 위한 이메일 인증 코드 안내"; + + // 인증 코드만 템플릿으로 전달 + String emailContent = "인증 코드: " + code + ""; + + Context context = new Context(); + context.setVariable("name", name); + context.setVariable("emailContent", emailContent); + context.setVariable("buttonUrl", "https://dmu-dasom.or.kr"); + context.setVariable("buttonText", "인증 완료"); + + String htmlBody = templateEngine.process("verify-num-email", context); + + MimeMessage message = javaMailSender.createMimeMessage(); + MimeMessageHelper helper = new MimeMessageHelper(message, true, "UTF-8"); + + helper.setTo(to); + helper.setSubject(subject); + helper.setText(htmlBody, true); + helper.setFrom((from != null && !from.isEmpty()) ? from : "dasomdmu@gmail.com"); + + javaMailSender.send(message); + } + } diff --git a/src/main/java/dmu/dasom/api/domain/interview/controller/InterviewController.java b/src/main/java/dmu/dasom/api/domain/interview/controller/InterviewController.java index b7f103c..0612399 100644 --- a/src/main/java/dmu/dasom/api/domain/interview/controller/InterviewController.java +++ b/src/main/java/dmu/dasom/api/domain/interview/controller/InterviewController.java @@ -4,7 +4,8 @@ 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.email.service.EmailService; +import dmu.dasom.api.domain.google.service.EmailService; + import dmu.dasom.api.domain.interview.dto.InterviewReservationModifyRequestDto; import dmu.dasom.api.domain.interview.dto.VerificationCodeRequestDto; import dmu.dasom.api.domain.interview.service.InterviewService; @@ -29,20 +30,35 @@ public class InterviewController { private final InterviewService interviewService; private final ApplicantRepository applicantRepository; private final VerificationCodeManager verificationCodeManager; - private final EmailService emailService; + private final EmailService emailService; // 이메일 발송 서비스 (Google 기반) + /* + * 면접 예약 수정을 위한 인증 코드 발송 + * - 지원자의 학번을 입력받아 해당 지원자를 조회 + * - VerificationCodeManager를 통해 인증 코드 생성 및 Redis 저장 + * - EmailService를 이용해 지원자 이메일로 인증 코드 발송 + */ @Operation(summary = "면접 예약 수정을 위한 인증 코드 발송", description = "지원자의 학번을 받아 이메일로 인증 코드를 발송합니다.") @PostMapping("/send-verification") public ResponseEntity sendVerificationCode(@Valid @RequestBody VerificationCodeRequestDto request) throws MessagingException { + // 학번으로 지원자 조회 (없으면 예외 발생) Applicant applicant = applicantRepository.findByStudentNo(request.getStudentNo()) .orElseThrow(() -> new CustomException(ErrorCode.APPLICANT_NOT_FOUND)); + // 인증 코드 생성 후 Redis에 저장 String code = verificationCodeManager.generateAndStoreCode(applicant.getStudentNo()); + + // 이메일 발송 (받는 사람 이메일, 이름, 코드 전달) emailService.sendVerificationEmail(applicant.getEmail(), applicant.getName(), code); return ResponseEntity.ok().build(); } + /* + * 면접 예약 수정 + * - 사용자가 받은 인증 코드를 검증한 후 + * - InterviewService를 통해 예약 날짜/시간 수정 처리 + */ @Operation(summary = "면접 예약 수정", description = "이메일로 발송된 인증 코드를 통해 인증 후, 면접 날짜 및 시간을 수정합니다.") @PutMapping("/reservation/modify") public ResponseEntity modifyInterviewReservation(@Valid @RequestBody InterviewReservationModifyRequestDto request) { @@ -50,6 +66,10 @@ public ResponseEntity modifyInterviewReservation(@Valid @RequestBody Inter return ResponseEntity.ok().build(); } + /* + * 모든 면접 지원자 조회 + * - InterviewService를 통해 모든 지원자 + 예약 정보 반환 + */ @Operation(summary = "모든 면접 지원자 목록 조회", description = "모든 면접 지원자의 상세 정보와 예약 정보를 조회합니다.") @GetMapping("/applicants") public ResponseEntity> getAllInterviewApplicants() { diff --git a/src/main/java/dmu/dasom/api/domain/interview/repository/InterviewSlotRepository.java b/src/main/java/dmu/dasom/api/domain/interview/repository/InterviewSlotRepository.java index 38a3303..b13c265 100644 --- a/src/main/java/dmu/dasom/api/domain/interview/repository/InterviewSlotRepository.java +++ b/src/main/java/dmu/dasom/api/domain/interview/repository/InterviewSlotRepository.java @@ -4,6 +4,7 @@ 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.data.repository.query.Param; // ★ import 추가 import org.springframework.stereotype.Repository; import java.util.Collection; @@ -12,13 +13,12 @@ @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(@org.springframework.data.repository.query.Param("status") InterviewStatus interviewStatus); + List findAllByStatusAndCurrentCandidatesLessThanMaxCandidates(@Param("status") 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 89cc912..67d40cd 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 @@ -151,7 +151,7 @@ public List getAllInterviewSlots() { .toList(); } - @Override + @Override public List getAllInterviewApplicants() { List reservations = interviewReservationRepository.findAll(); 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 5444652..f5a561e 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 @@ -67,6 +67,11 @@ public ResponseEntity> getRecruitSchedule() { return ResponseEntity.ok(recruitService.getRecruitSchedule()); } + /* + * 모집 일정 수정 + * - 관리자가 모집 일정을 수정할 때 사용 + * - RecruitService.modifyRecruitSchedule()을 호출하여 DB 반영 + */ @Operation(summary = "모집 일정 수정") @PutMapping("/schedule") public ResponseEntity modifyRecruitSchedule(@RequestBody dmu.dasom.api.domain.recruit.dto.RecruitScheduleModifyRequestDto request) { @@ -74,6 +79,11 @@ public ResponseEntity modifyRecruitSchedule(@RequestBody dmu.dasom.api.dom return ResponseEntity.ok().build(); } + /* + * 모집 일정 초기화 (테스트용) + * - 테스트 시 초기화를 위해 전체 모집 일정을 초기 상태로 되돌림 + * - RecruitService.initRecruitSchedule()을 호출 + */ @Operation(summary = "TEMP: 모집 일정 초기화") @GetMapping("/init-schedule") public ResponseEntity initSchedule() { @@ -81,6 +91,7 @@ public ResponseEntity initSchedule() { return ResponseEntity.ok("Recruit schedule initialized successfully."); } + // 합격 결과 확인 @Operation(summary = "합격 결과 확인") @ApiResponses(value = { 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 8864045..d87a2b2 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 @@ -13,7 +13,7 @@ public interface RecruitService { List getRecruitSchedule(); - void modifyRecruitSchedule(RecruitScheduleModifyRequestDto request); + void modifyRecruitSchedule(RecruitScheduleModifyRequestDto request); void initRecruitSchedule(); 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 8054b53..c35e5ff 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 @@ -29,9 +29,9 @@ public class RecruitServiceImpl implements RecruitService { @Override public List getRecruitSchedule() { return findAll().stream() - .map(config -> config.getKey() == ConfigKey.INTERVIEW_TIME_START || config.getKey() == ConfigKey.INTERVIEW_TIME_END - ? config.toTimeResponse() : config.toResponse()) - .toList(); + .map(config -> config.getKey() == ConfigKey.INTERVIEW_TIME_START || config.getKey() == ConfigKey.INTERVIEW_TIME_END + ? config.toTimeResponse() : config.toResponse()) + .toList(); } // 모집 일정 설정 수정 @@ -74,16 +74,25 @@ public LocalDateTime getResultAnnouncementSchedule(ResultCheckType type) { return parseDateTimeFormat(recruit.getValue()); } + /* + * 모집 일정 초기화 + * - DB에 Recruit 데이터가 존재하지 않을 경우 기본 값으로 초기화 + * - 각 ConfigKey에 대해 Recruit 엔티티를 생성하여 저장 + * - 기본 값은 "2025-01-01T00:00:00"으로 설정됨 + */ @Override @Transactional public void initRecruitSchedule() { + // 이미 데이터가 존재하면 초기화하지 않음 if (recruitRepository.count() > 0) { - return; // Already initialized + return; } - for (dmu.dasom.api.domain.recruit.enums.ConfigKey key : dmu.dasom.api.domain.recruit.enums.ConfigKey.values()) { - dmu.dasom.api.domain.recruit.entity.Recruit recruit = dmu.dasom.api.domain.recruit.entity.Recruit.builder() + + // 모든 ConfigKey를 순회하며 기본 Recruit 데이터 생성 + for (ConfigKey key : ConfigKey.values()) { + Recruit recruit = Recruit.builder() .key(key) - .value("2025-01-01T00:00:00") // Default value + .value("2025-01-01T00:00:00") // 초기 기본 값 .build(); recruitRepository.save(recruit); } @@ -97,8 +106,7 @@ private List findAll() { // DB에서 key에 해당하는 Recruit 객체를 찾아 반환 private Recruit findByKey(final ConfigKey key) { return recruitRepository.findByKey(key) - .orElseThrow(() -> new CustomException(ErrorCode.ARGUMENT_NOT_VALID)); - + .orElseThrow(() -> new CustomException(ErrorCode.ARGUMENT_NOT_VALID)); } // 시간 형식 변환 및 검증 @@ -118,5 +126,4 @@ private LocalDateTime parseDateTimeFormat(String value) { throw new CustomException(ErrorCode.INVALID_DATETIME_FORMAT); } } - } diff --git a/src/main/java/dmu/dasom/api/global/util/VerificationCodeManager.java b/src/main/java/dmu/dasom/api/global/util/VerificationCodeManager.java index 29e3fd5..333da44 100644 --- a/src/main/java/dmu/dasom/api/global/util/VerificationCodeManager.java +++ b/src/main/java/dmu/dasom/api/global/util/VerificationCodeManager.java @@ -2,47 +2,46 @@ import dmu.dasom.api.domain.common.exception.CustomException; import dmu.dasom.api.domain.common.exception.ErrorCode; +import org.springframework.data.redis.core.StringRedisTemplate; import org.springframework.stereotype.Component; -import java.time.Instant; -import java.util.concurrent.ConcurrentHashMap; -import java.util.concurrent.ThreadLocalRandom; +import java.util.concurrent.TimeUnit; @Component public class VerificationCodeManager { - private static final long EXPIRATION_TIME_SECONDS = 180; // 3분 - private final ConcurrentHashMap codeStore = new ConcurrentHashMap<>(); - private final ConcurrentHashMap expirationStore = new ConcurrentHashMap<>(); + private static final long EXPIRATION_TIME_SECONDS = 180; // 인증 코드 유효 시간 (3분) + private final StringRedisTemplate redisTemplate; + // RedisTemplate 주입 + public VerificationCodeManager(StringRedisTemplate redisTemplate) { + this.redisTemplate = redisTemplate; + } + + /** + * 인증 코드 생성 및 Redis에 저장 + * @param key - 사용자 식별값 (예: 이메일 주소) + * @return 생성된 6자리 인증 코드 + */ public String generateAndStoreCode(String key) { - String code = String.valueOf(ThreadLocalRandom.current().nextInt(100000, 1000000)); - codeStore.put(key, code); - expirationStore.put(key, Instant.now().plusSeconds(EXPIRATION_TIME_SECONDS)); + String code = String.valueOf((int)(Math.random() * 900000) + 100000); // 6자리 난수 생성 + // Redis에 key=코드 저장, TTL=3분 + redisTemplate.opsForValue().set(key, code, EXPIRATION_TIME_SECONDS, TimeUnit.SECONDS); return code; } + /** + * Redis에서 인증 코드 검증 + * @param key - 사용자 식별값 (예: 이메일 주소) + * @param code - 사용자가 입력한 인증 코드 + * @throws CustomException - 코드가 없거나 일치하지 않을 때 발생 + */ public void verifyCode(String key, String code) { - if (!isCodeValid(key, code)) { + String storedCode = redisTemplate.opsForValue().get(key); // Redis에서 코드 조회 + if (storedCode == null || !storedCode.equals(code)) { throw new CustomException(ErrorCode.VERIFICATION_CODE_NOT_VALID); } - removeCode(key); - } - - private boolean isCodeValid(String key, String code) { - if (code == null || code.isEmpty()) { - return false; - } - Instant expirationTime = expirationStore.get(key); - if (expirationTime == null || Instant.now().isAfter(expirationTime)) { - removeCode(key); - return false; - } - return code.equals(codeStore.get(key)); - } - - private void removeCode(String key) { - codeStore.remove(key); - expirationStore.remove(key); + // 검증 성공 시 코드 삭제 (재사용 방지) + redisTemplate.delete(key); } } diff --git a/src/main/resources/templates/verify-num-email.html b/src/main/resources/templates/verify-num-email.html new file mode 100644 index 0000000..88a70d9 --- /dev/null +++ b/src/main/resources/templates/verify-num-email.html @@ -0,0 +1,48 @@ + + + + + + + + 이메일 인증 안내 + + +
+ +
+
+ 로고 +
+
+ +
DASOM
+ +

+ +

+ 컴퓨터공학부 전공동아리 다솜입니다. +

+ +

+ 요청하신 인증 코드는 다음과 같습니다.
+
+ 해당 코드를 3분 내에 입력하여 인증을 완료해 주세요. +

+ +
+ + + +
+ +

+ 또한, 문의 사항은 본 메일에 회신 또는 아래 번호로 편하게 연락 부탁드립니다.
+ 010-6361-3481 +

+
+ + From 6884171459c7cbf376a13bd01bd58b48cfd4ac0f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=A1=B0=EC=9C=A4=EC=9E=AC?= Date: Thu, 28 Aug 2025 04:54:57 +0900 Subject: [PATCH 5/6] =?UTF-8?q?[refactor]=20DASOMBE-18=20<=EC=97=90?= =?UTF-8?q?=EB=9F=AC=20=EC=BD=94=EB=93=9C=20=EB=AA=85=EC=84=B8=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80>?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../recruit/controller/RecruitController.java | 85 ++++++++++--------- 1 file changed, 45 insertions(+), 40 deletions(-) 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 f5a561e..26c2375 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 @@ -21,8 +21,6 @@ import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.*; -import java.time.LocalDate; -import java.time.LocalTime; import java.util.List; @RestController @@ -38,25 +36,21 @@ public class RecruitController { // 지원하기 @Operation(summary = "부원 지원하기") @ApiResponses(value = { - @ApiResponse(responseCode = "200", description = "지원 성공"), - @ApiResponse(responseCode = "400", description = "실패 케이스", - content = @Content( - mediaType = "application/json", - schema = @Schema(implementation = ErrorResponse.class), - examples = { - @ExampleObject( - name = "학번 중복", - value = "{ \"code\": \"C013\", \"message\": \"이미 등록된 학번입니다.\" }"), - @ExampleObject( - name = "모집 기간 아님", - value = "{ \"code\": \"C029\", \"message\": \"모집 기간이 아닙니다.\" }") - })) + @ApiResponse(responseCode = "200", description = "지원 성공"), + @ApiResponse(responseCode = "400", description = "실패 케이스", + content = @Content( + mediaType = "application/json", + schema = @Schema(implementation = ErrorResponse.class), + examples = { + @ExampleObject(name = "학번 중복", value = "{ \"code\": \"C013\", \"message\": \"이미 등록된 학번입니다.\" }"), + @ExampleObject(name = "모집 기간 아님", value = "{ \"code\": \"C029\", \"message\": \"모집 기간이 아닙니다.\" }"), + @ExampleObject(name = "잘못된 요청 값", value = "{ \"code\": \"C007\", \"message\": \"요청한 값이 올바르지 않습니다.\" }") + })) }) @PostMapping("/apply") public ResponseEntity apply(@Valid @RequestBody final ApplicantCreateRequestDto request) { applicantService.apply(request); - return ResponseEntity.ok() - .build(); + return ResponseEntity.ok().build(); } // 모집 일정 조회 @@ -67,23 +61,27 @@ public ResponseEntity> getRecruitSchedule() { return ResponseEntity.ok(recruitService.getRecruitSchedule()); } - /* - * 모집 일정 수정 - * - 관리자가 모집 일정을 수정할 때 사용 - * - RecruitService.modifyRecruitSchedule()을 호출하여 DB 반영 - */ + // 모집 일정 수정 @Operation(summary = "모집 일정 수정") + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "모집 일정 수정 성공"), + @ApiResponse(responseCode = "400", description = "실패 케이스", + content = @Content( + mediaType = "application/json", + schema = @Schema(implementation = ErrorResponse.class), + examples = { + @ExampleObject(name = "잘못된 날짜 형식", value = "{ \"code\": \"C016\", \"message\": \"날짜 형식이 올바르지 않습니다.\" }"), + @ExampleObject(name = "잘못된 시간 형식", value = "{ \"code\": \"C017\", \"message\": \"시간 형식이 올바르지 않습니다.\" }"), + @ExampleObject(name = "잘못된 요청 값", value = "{ \"code\": \"C007\", \"message\": \"요청한 값이 올바르지 않습니다.\" }") + })) + }) @PutMapping("/schedule") public ResponseEntity modifyRecruitSchedule(@RequestBody dmu.dasom.api.domain.recruit.dto.RecruitScheduleModifyRequestDto request) { recruitService.modifyRecruitSchedule(request); return ResponseEntity.ok().build(); } - /* - * 모집 일정 초기화 (테스트용) - * - 테스트 시 초기화를 위해 전체 모집 일정을 초기 상태로 되돌림 - * - RecruitService.initRecruitSchedule()을 호출 - */ + // 모집 일정 초기화 (테스트용) @Operation(summary = "TEMP: 모집 일정 초기화") @GetMapping("/init-schedule") public ResponseEntity initSchedule() { @@ -91,20 +89,18 @@ public ResponseEntity initSchedule() { return ResponseEntity.ok("Recruit schedule initialized successfully."); } - // 합격 결과 확인 @Operation(summary = "합격 결과 확인") @ApiResponses(value = { - @ApiResponse(responseCode = "200", description = "합격 결과 확인 성공"), - @ApiResponse(responseCode = "400", description = "실패 케이스", - content = @Content( - mediaType = "application/json", - schema = @Schema(implementation = ErrorResponse.class), - examples = { - @ExampleObject( - name = "학번 조회 실패 혹은 전화번호 뒷자리 일치하지 않음", - value = "{ \"code\": \"C007\", \"message\": \"요청한 값이 올바르지 않습니다.\" }") - })) + @ApiResponse(responseCode = "200", description = "합격 결과 확인 성공"), + @ApiResponse(responseCode = "400", description = "실패 케이스", + content = @Content( + mediaType = "application/json", + schema = @Schema(implementation = ErrorResponse.class), + examples = { + @ExampleObject(name = "지원자 없음", value = "{ \"code\": \"C022\", \"message\": \"지원자를 찾을 수 없습니다.\" }"), + @ExampleObject(name = "잘못된 요청 값", value = "{ \"code\": \"C007\", \"message\": \"요청한 값이 올바르지 않습니다.\" }") + })) }) @GetMapping("/result") public ResponseEntity checkResult(@ModelAttribute final ResultCheckRequestDto request) { @@ -115,7 +111,17 @@ public ResponseEntity checkResult(@ModelAttribute final @Operation(summary = "면접 예약", description = "지원자가 특정 면접 슬롯을 예약합니다.") @ApiResponses(value = { @ApiResponse(responseCode = "200", description = "면접 예약 성공"), - @ApiResponse(responseCode = "400", description = "잘못된 요청 데이터") + @ApiResponse(responseCode = "400", description = "실패 케이스", + content = @Content( + mediaType = "application/json", + schema = @Schema(implementation = ErrorResponse.class), + examples = { + @ExampleObject(name = "지원자 없음", value = "{ \"code\": \"C022\", \"message\": \"지원자를 찾을 수 없습니다.\" }"), + @ExampleObject(name = "슬롯 없음", value = "{ \"code\": \"C021\", \"message\": \"슬롯을 찾을 수 없습니다.\" }"), + @ExampleObject(name = "이미 예약됨", value = "{ \"code\": \"C023\", \"message\": \"이미 예약된 지원자입니다.\" }"), + @ExampleObject(name = "슬롯 가득 참", value = "{ \"code\": \"C025\", \"message\": \"해당 슬롯이 가득 찼습니다.\" }"), + @ExampleObject(name = "슬롯 불가", value = "{ \"code\": \"C032\", \"message\": \"해당 슬롯을 예약할 수 없습니다.\" }") + })) }) @PostMapping("/interview/reserve") public ResponseEntity reserveInterviewSlot(@Valid @RequestBody InterviewReservationRequestDto request) { @@ -140,5 +146,4 @@ public ResponseEntity> getAllInterviewSlots() { List allSlots = interviewService.getAllInterviewSlots(); return ResponseEntity.ok(allSlots); } - } From 3c800e317f2cc3aa6681855e21d837435bde610f Mon Sep 17 00:00:00 2001 From: hodoon Date: Sat, 30 Aug 2025 21:07:42 +0900 Subject: [PATCH 6/6] =?UTF-8?q?refactor:=20=EB=A9=B4=EC=A0=91=20=EC=98=88?= =?UTF-8?q?=EC=95=BD=20=EB=82=A0=EC=A7=9C=20=EB=B3=80=EA=B2=BD=20=EA=B8=B0?= =?UTF-8?q?=EB=8A=A5=20=EC=B6=94=EA=B0=80(DASOMBE-18)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../dmu/dasom/api/domain/common/exception/ErrorCode.java | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) 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 2ac2100..16759dd 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 @@ -39,9 +39,10 @@ public enum ErrorCode { NOT_FOUND_PARTICIPANT(400, "C030", "참가자를 찾을 수 없습니다."), EXECUTIVE_NOT_FOUND(400, "C031", "임원진을 찾을 수 없습니다."), GENERATION_NOT_FOUND(400, "C032", "저장된 기수를 찾을 수 없습니다."), - INVALID_GENERATION_FORMAT(400, "C033", "유효하지 않은 기수 형식입니다. (예: '1기')"); + INVALID_GENERATION_FORMAT(400, "C033", "유효하지 않은 기수 형식입니다. (예: '1기')"), VERIFICATION_CODE_NOT_VALID(400, "C034", "인증 코드가 유효하지 않습니다."), - SLOT_UNAVAILABLE(400, "C035", "해당 슬롯을 예약할 수 없습니다."); + SLOT_UNAVAILABLE(400, "C035", "해당 슬롯을 예약할 수 없습니다.") + ; private final int status; private final String code;