Skip to content
Original file line number Diff line number Diff line change
Expand Up @@ -25,4 +25,5 @@ public interface ApplicantRepository extends JpaRepository<Applicant, Long> {
Optional<Applicant> findByStudentNoAndContactEndsWith(@Param("studentNo") String studentNo,
@Param("contactLastDigits") String contactLastDigits);

Optional<Applicant> findByStudentNoAndEmail(String studentNo, String email);
}
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,9 @@ public enum ErrorCode {
SLOT_NOT_ACTIVE(400, "C027", "해당 슬롯이 비활성화 되었습니다."),
FILE_ENCODE_FAIL(400, "C028", "파일 인코딩에 실패하였습니다."),
RECRUITMENT_NOT_ACTIVE(400, "C029", "모집 기간이 아닙니다."),
NOT_FOUND_PARTICIPANT(400, "C030", "참가자를 찾을 수 없습니다.")
NOT_FOUND_PARTICIPANT(400, "C030", "참가자를 찾을 수 없습니다."),
VERIFICATION_CODE_NOT_VALID(400, "C031", "인증 코드가 유효하지 않습니다."),
SLOT_UNAVAILABLE(400, "C032", "해당 슬롯을 예약할 수 없습니다.")
;

private final int status;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,5 +2,6 @@

public enum MailType {
DOCUMENT_RESULT, // 서류 합격
FINAL_RESULT // 최종 합격
FINAL_RESULT, // 최종 합격
VERIFICATION // 이메일 인증
}
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -60,11 +59,10 @@ public void sendEmail(String to, String name, MailType mailType) {
helper.setText(htmlBody, true);
helper.setFrom((from != null && !from.isEmpty()) ? from : "[email protected]");

// 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;
Expand All @@ -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 = "인증 코드: <strong>" + code + "</strong>";

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 : "[email protected]");

javaMailSender.send(message);
}

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
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.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;
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
@RequestMapping("/api/interview")
public class InterviewController {

private final InterviewService interviewService;
private final ApplicantRepository applicantRepository;
private final VerificationCodeManager verificationCodeManager;
private final EmailService emailService; // 이메일 발송 서비스 (Google 기반)

/*
* 면접 예약 수정을 위한 인증 코드 발송
* - 지원자의 학번을 입력받아 해당 지원자를 조회
* - VerificationCodeManager를 통해 인증 코드 생성 및 Redis 저장
* - EmailService를 이용해 지원자 이메일로 인증 코드 발송
*/
@Operation(summary = "면접 예약 수정을 위한 인증 코드 발송", description = "지원자의 학번을 받아 이메일로 인증 코드를 발송합니다.")
@PostMapping("/send-verification")
public ResponseEntity<Void> 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<Void> modifyInterviewReservation(@Valid @RequestBody InterviewReservationModifyRequestDto request) {
interviewService.modifyInterviewReservation(request);
return ResponseEntity.ok().build();
}

/*
* 모든 면접 지원자 조회
* - InterviewService를 통해 모든 지원자 + 예약 정보 반환
*/
@Operation(summary = "모든 면접 지원자 목록 조회", description = "모든 면접 지원자의 상세 정보와 예약 정보를 조회합니다.")
@GetMapping("/applicants")
public ResponseEntity<List<InterviewReservationApplicantResponseDto>> getAllInterviewApplicants() {
List<InterviewReservationApplicantResponseDto> applicants = interviewService.getAllInterviewApplicants();
return ResponseEntity.ok(applicants);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
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 = "[email protected]")
private String email;

@NotNull(message = "새로운 슬롯 ID는 필수 값입니다.")
@Schema(description = "새롭게 예약할 면접 슬롯의 ID", example = "2")
private Long newSlotId;

@NotNull(message = "인증 코드는 필수 값입니다.")
@Schema(description = "이메일로 발송된 6자리 인증 코드", example = "123456")
private String verificationCode;
}
Original file line number Diff line number Diff line change
@@ -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;
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
}
Original file line number Diff line number Diff line change
@@ -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<InterviewReservation, Long> {
boolean existsByReservationCode(String reservationCode);
Optional<InterviewReservation> findByReservationCode(String reservationCode);
Optional<InterviewReservation> findByApplicant(Applicant applicant);
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -12,13 +13,12 @@
@Repository
public interface InterviewSlotRepository extends JpaRepository<InterviewSlot, Long> {
// 현재 인원이 최대 인원보다 작은 슬롯 조회
// 현재 예약된 인원이 최대 지원자 수보다 적은 슬롯 조회
@Query("SELECT s FROM InterviewSlot s WHERE s.currentCandidates < s.maxCandidates")
Collection<InterviewSlot> findAllByCurrentCandidatesLessThanMaxCandidates();

// 상태에 따른 슬롯 조회
@Query("SELECT s FROM InterviewSlot s WHERE s.interviewStatus = :status AND s.currentCandidates < s.maxCandidates")
List<InterviewSlot> findAllByStatusAndCurrentCandidatesLessThanMaxCandidates(InterviewStatus interviewStatus);
List<InterviewSlot> findAllByStatusAndCurrentCandidatesLessThanMaxCandidates(@Param("status") InterviewStatus interviewStatus);

// 슬롯이 하나라도 존재하는지 확인
@Query("SELECT COUNT(s) > 0 FROM InterviewSlot s")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -27,4 +28,7 @@ public interface InterviewService {

List<InterviewReservationApplicantResponseDto> getAllInterviewApplicants();

// 면접 예약 수정
void modifyInterviewReservation(InterviewReservationModifyRequestDto request);

}
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,15 @@
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;
import dmu.dasom.api.domain.interview.enums.InterviewStatus;
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;
Expand All @@ -35,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
Expand Down Expand Up @@ -172,4 +175,47 @@ public List<InterviewReservationApplicantResponseDto> getAllInterviewApplicants(
.collect(Collectors.toList());
}

@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));

// 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);

// 8. 새로운 슬롯의 예약 인원 증가
newSlot.incrementCurrentCandidates();
interviewSlotRepository.save(newSlot); // 변경된 newSlot 저장

// 9. 업데이트된 예약 정보 저장
interviewReservationRepository.save(existingReservation);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,31 @@ public ResponseEntity<List<RecruitConfigResponseDto>> getRecruitSchedule() {
return ResponseEntity.ok(recruitService.getRecruitSchedule());
}

/*
* 모집 일정 수정
* - 관리자가 모집 일정을 수정할 때 사용
* - RecruitService.modifyRecruitSchedule()을 호출하여 DB 반영
*/
@Operation(summary = "모집 일정 수정")
@PutMapping("/schedule")
public ResponseEntity<Void> 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<String> initSchedule() {
recruitService.initRecruitSchedule();
return ResponseEntity.ok("Recruit schedule initialized successfully.");
}


// 합격 결과 확인
@Operation(summary = "합격 결과 확인")
@ApiResponses(value = {
Expand Down
Loading
Loading