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 @@ -34,9 +34,11 @@ 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", "참가자를 찾을 수 없습니다."),
VERIFICATION_CODE_NOT_VALID(400, "C032", "인증 코드가 유효하지 않습니다.")
;

private final int status;
Expand Down
96 changes: 96 additions & 0 deletions src/main/java/dmu/dasom/api/domain/email/service/EmailService.java
Original file line number Diff line number Diff line change
@@ -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 = "요청하신 인증 코드는 다음과 같습니다.<br>" +
"인증 코드: <strong>" + code + "</strong><br>" +
"해당 코드를 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기에 많은 관심을 두고 지원해 주셔서 감사드리며,<br>" +
"내부 서류 평가 결과 및 추후 일정에 관해 안내해드리고자 이메일을 발송하게 되었습니다.<br>" +
"서류 전형 결과는 아래 버튼 혹은 홈페이지를 통해 확인이 가능합니다.";
buttonText = "서류 결과 확인하기";
}
case FINAL_RESULT -> {
subject = "동양미래대학교 컴퓨터소프트웨어공학과 전공 동아리 DASOM 최종 면접 결과 안내";
emailContent = "먼저 다솜 34기에 많은 관심을 두고 지원해 주셔서 감사드리며,<br>" +
"최종 면접 결과 및 추후 일정에 관해 안내해드리고자 이메일을 발송하게 되었습니다.<br>" +
"최종 면접 결과는 아래 버튼 혹은 홈페이지를 통해 확인이 가능합니다.";
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 : "[email protected]");

message.setContent(htmlBody, "text/html; charset=utf-8");

javaMailSender.send(message);
}
}
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
@@ -0,0 +1,59 @@
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
@RequestMapping("/api/interview")
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<Void> 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 = "이메일로 발송된 인증 코드를 통해 인증 후, 면접 날짜 및 시간을 수정합니다.")
@PutMapping("/reservation/modify")
public ResponseEntity<Void> modifyInterviewReservation(@Valid @RequestBody InterviewReservationModifyRequestDto request) {
interviewService.modifyInterviewReservation(request);
return ResponseEntity.ok().build();
}

@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 @@ -18,7 +18,7 @@ public interface InterviewSlotRepository extends JpaRepository<InterviewSlot, Lo

// 상태에 따른 슬롯 조회
@Query("SELECT s FROM InterviewSlot s WHERE s.interviewStatus = :status AND s.currentCandidates < s.maxCandidates")
List<InterviewSlot> findAllByStatusAndCurrentCandidatesLessThanMaxCandidates(InterviewStatus interviewStatus);
List<InterviewSlot> findAllByStatusAndCurrentCandidatesLessThanMaxCandidates(@org.springframework.data.repository.query.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 @@ -148,7 +151,7 @@ public List<InterviewSlotResponseDto> getAllInterviewSlots() {
.toList();
}

@Override
@Override
public List<InterviewReservationApplicantResponseDto> getAllInterviewApplicants() {
List<InterviewReservation> reservations = interviewReservationRepository.findAll();

Expand All @@ -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,20 @@ public ResponseEntity<List<RecruitConfigResponseDto>> getRecruitSchedule() {
return ResponseEntity.ok(recruitService.getRecruitSchedule());
}

@Operation(summary = "모집 일정 수정")
@PutMapping("/schedule")
public ResponseEntity<Void> 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<String> initSchedule() {
recruitService.initRecruitSchedule();
return ResponseEntity.ok("Recruit schedule initialized successfully.");
}

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