Skip to content

Commit 4c5cc6f

Browse files
NADELYSSysw789
authored andcommitted
DASOMBE-18 이메일 인증
1 parent e8425b8 commit 4c5cc6f

File tree

8 files changed

+211
-5
lines changed

8 files changed

+211
-5
lines changed

src/main/java/dmu/dasom/api/domain/common/exception/ErrorCode.java

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,8 @@ public enum ErrorCode {
3737
SLOT_UNAVAILABLE(400, "C028", "해당 슬롯을 예약할 수 없습니다."),
3838
FILE_ENCODE_FAIL(400, "C029", "파일 인코딩에 실패하였습니다."),
3939
RECRUITMENT_NOT_ACTIVE(400, "C030", "모집 기간이 아닙니다."),
40-
NOT_FOUND_PARTICIPANT(400, "C031", "참가자를 찾을 수 없습니다.")
40+
NOT_FOUND_PARTICIPANT(400, "C031", "참가자를 찾을 수 없습니다."),
41+
VERIFICATION_CODE_NOT_VALID(400, "C032", "인증 코드가 유효하지 않습니다.")
4142
;
4243

4344
private final int status;
Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
package dmu.dasom.api.domain.email.service;
2+
3+
import dmu.dasom.api.domain.common.exception.CustomException;
4+
import dmu.dasom.api.domain.common.exception.ErrorCode;
5+
import dmu.dasom.api.domain.google.enums.MailType;
6+
import jakarta.mail.MessagingException;
7+
import jakarta.mail.internet.MimeMessage;
8+
import lombok.RequiredArgsConstructor;
9+
import org.springframework.beans.factory.annotation.Value;
10+
import org.springframework.mail.javamail.JavaMailSender;
11+
import org.springframework.mail.javamail.MimeMessageHelper;
12+
import org.springframework.stereotype.Service;
13+
import org.thymeleaf.TemplateEngine;
14+
import org.thymeleaf.context.Context;
15+
16+
@RequiredArgsConstructor
17+
@Service
18+
public class EmailService {
19+
20+
private final TemplateEngine templateEngine;
21+
private final JavaMailSender javaMailSender;
22+
23+
@Value("${spring.mail.username}")
24+
private String from;
25+
26+
public void sendVerificationEmail(String to, String name, String code) throws MessagingException {
27+
String subject = "DASOM 면접 시간 변경을 위한 이메일 인증 코드 안내";
28+
String emailContent = "요청하신 인증 코드는 다음과 같습니다.<br>" +
29+
"인증 코드: <strong>" + code + "</strong><br>" +
30+
"해당 코드를 3분 내에 입력하여 인증을 완료해주세요.";
31+
32+
Context context = new Context();
33+
context.setVariable("name", name);
34+
context.setVariable("emailContent", emailContent);
35+
context.setVariable("buttonUrl", "https://dmu-dasom.or.kr"); // Or some other relevant link
36+
context.setVariable("buttonText", "다솜 홈페이지로 이동");
37+
38+
String htmlBody = templateEngine.process("email-template", context);
39+
send(to, subject, htmlBody);
40+
}
41+
42+
public void sendEmail(String to, String name, MailType mailType) throws MessagingException {
43+
if (mailType == null){
44+
throw new CustomException(ErrorCode.MAIL_TYPE_NOT_VALID);
45+
}
46+
47+
// 메일 제목 및 템플릿 설정
48+
String subject;
49+
String emailContent;
50+
String buttonUrl = "https://dmu-dasom.or.kr/recruit/result";
51+
String buttonText;
52+
53+
switch (mailType) {
54+
case DOCUMENT_RESULT -> {
55+
subject = "동양미래대학교 컴퓨터소프트웨어공학과 전공 동아리 DASOM 서류 결과 안내";
56+
emailContent = "먼저 다솜 34기에 많은 관심을 두고 지원해 주셔서 감사드리며,<br>" +
57+
"내부 서류 평가 결과 및 추후 일정에 관해 안내해드리고자 이메일을 발송하게 되었습니다.<br>" +
58+
"서류 전형 결과는 아래 버튼 혹은 홈페이지를 통해 확인이 가능합니다.";
59+
buttonText = "서류 결과 확인하기";
60+
}
61+
case FINAL_RESULT -> {
62+
subject = "동양미래대학교 컴퓨터소프트웨어공학과 전공 동아리 DASOM 최종 면접 결과 안내";
63+
emailContent = "먼저 다솜 34기에 많은 관심을 두고 지원해 주셔서 감사드리며,<br>" +
64+
"최종 면접 결과 및 추후 일정에 관해 안내해드리고자 이메일을 발송하게 되었습니다.<br>" +
65+
"최종 면접 결과는 아래 버튼 혹은 홈페이지를 통해 확인이 가능합니다.";
66+
buttonText = "최종 결과 확인하기";
67+
}
68+
default -> throw new IllegalStateException("Unexpected value: " + mailType);
69+
}
70+
71+
// HTML 템플릿에 전달할 데이터 설정
72+
Context context = new Context();
73+
context.setVariable("name", name); // 지원자 이름 전달
74+
context.setVariable("emailContent", emailContent); // 이메일 내용 전달
75+
context.setVariable("buttonUrl", buttonUrl); // 버튼 링크 전달
76+
context.setVariable("buttonText", buttonText);
77+
78+
// HTML 템플릿 처리
79+
String htmlBody = templateEngine.process("email-template", context);
80+
send(to, subject, htmlBody);
81+
}
82+
83+
private void send(String to, String subject, String htmlBody) throws MessagingException {
84+
MimeMessage message = javaMailSender.createMimeMessage();
85+
MimeMessageHelper helper = new MimeMessageHelper(message, true, "UTF-8");
86+
87+
helper.setTo(to);
88+
helper.setSubject(subject);
89+
helper.setText(htmlBody, true);
90+
helper.setFrom((from != null && !from.isEmpty()) ? from : "[email protected]");
91+
92+
message.setContent(htmlBody, "text/html; charset=utf-8");
93+
94+
javaMailSender.send(message);
95+
}
96+
}

src/main/java/dmu/dasom/api/domain/google/enums/MailType.java

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,5 +2,6 @@
22

33
public enum MailType {
44
DOCUMENT_RESULT, // 서류 합격
5-
FINAL_RESULT // 최종 합격
5+
FINAL_RESULT, // 최종 합격
6+
VERIFICATION // 이메일 인증
67
}
Lines changed: 36 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,25 +1,59 @@
11
package dmu.dasom.api.domain.interview.controller;
22

3+
import dmu.dasom.api.domain.applicant.entity.Applicant;
4+
import dmu.dasom.api.domain.applicant.repository.ApplicantRepository;
5+
import dmu.dasom.api.domain.common.exception.CustomException;
6+
import dmu.dasom.api.domain.common.exception.ErrorCode;
7+
import dmu.dasom.api.domain.email.service.EmailService;
38
import dmu.dasom.api.domain.interview.dto.InterviewReservationModifyRequestDto;
9+
import dmu.dasom.api.domain.interview.dto.VerificationCodeRequestDto;
410
import dmu.dasom.api.domain.interview.service.InterviewService;
11+
import dmu.dasom.api.global.util.VerificationCodeManager;
512
import io.swagger.v3.oas.annotations.Operation;
613
import io.swagger.v3.oas.annotations.tags.Tag;
14+
import jakarta.mail.MessagingException;
15+
import jakarta.validation.Valid;
716
import lombok.RequiredArgsConstructor;
817
import org.springframework.http.ResponseEntity;
918
import org.springframework.web.bind.annotation.*;
1019

20+
import java.util.List;
21+
import dmu.dasom.api.domain.interview.dto.InterviewReservationApplicantResponseDto;
22+
1123
@Tag(name = "Interview", description = "면접 관련 API")
1224
@RestController
1325
@RequiredArgsConstructor
1426
@RequestMapping("/api/interview")
1527
public class InterviewController {
1628

1729
private final InterviewService interviewService;
30+
private final ApplicantRepository applicantRepository;
31+
private final VerificationCodeManager verificationCodeManager;
32+
private final EmailService emailService;
33+
34+
@Operation(summary = "면접 예약 수정을 위한 인증 코드 발송", description = "지원자의 학번을 받아 이메일로 인증 코드를 발송합니다.")
35+
@PostMapping("/send-verification")
36+
public ResponseEntity<Void> sendVerificationCode(@Valid @RequestBody VerificationCodeRequestDto request) throws MessagingException {
37+
Applicant applicant = applicantRepository.findByStudentNo(request.getStudentNo())
38+
.orElseThrow(() -> new CustomException(ErrorCode.APPLICANT_NOT_FOUND));
39+
40+
String code = verificationCodeManager.generateAndStoreCode(applicant.getStudentNo());
41+
emailService.sendVerificationEmail(applicant.getEmail(), applicant.getName(), code);
42+
43+
return ResponseEntity.ok().build();
44+
}
1845

19-
@Operation(summary = "면접 예약 수정", description = "학번과 이메일 인증 면접 날짜 및 시간을 수정합니다.")
46+
@Operation(summary = "면접 예약 수정", description = "이메일로 발송된 인증 코드를 통해 인증 후, 면접 날짜 및 시간을 수정합니다.")
2047
@PutMapping("/reservation/modify")
21-
public ResponseEntity<Void> modifyInterviewReservation(@RequestBody InterviewReservationModifyRequestDto request) {
48+
public ResponseEntity<Void> modifyInterviewReservation(@Valid @RequestBody InterviewReservationModifyRequestDto request) {
2249
interviewService.modifyInterviewReservation(request);
2350
return ResponseEntity.ok().build();
2451
}
52+
53+
@Operation(summary = "모든 면접 지원자 목록 조회", description = "모든 면접 지원자의 상세 정보와 예약 정보를 조회합니다.")
54+
@GetMapping("/applicants")
55+
public ResponseEntity<List<InterviewReservationApplicantResponseDto>> getAllInterviewApplicants() {
56+
List<InterviewReservationApplicantResponseDto> applicants = interviewService.getAllInterviewApplicants();
57+
return ResponseEntity.ok(applicants);
58+
}
2559
}

src/main/java/dmu/dasom/api/domain/interview/dto/InterviewReservationModifyRequestDto.java

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,4 +30,8 @@ public class InterviewReservationModifyRequestDto {
3030
@NotNull(message = "새로운 슬롯 ID는 필수 값입니다.")
3131
@Schema(description = "새롭게 예약할 면접 슬롯의 ID", example = "2")
3232
private Long newSlotId;
33+
34+
@NotNull(message = "인증 코드는 필수 값입니다.")
35+
@Schema(description = "이메일로 발송된 6자리 인증 코드", example = "123456")
36+
private String verificationCode;
3337
}
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
package dmu.dasom.api.domain.interview.dto;
2+
3+
import io.swagger.v3.oas.annotations.media.Schema;
4+
import jakarta.validation.constraints.NotNull;
5+
import jakarta.validation.constraints.Pattern;
6+
import jakarta.validation.constraints.Size;
7+
import lombok.Getter;
8+
9+
@Getter
10+
public class VerificationCodeRequestDto {
11+
12+
@NotNull(message = "학번은 필수 값입니다.")
13+
@Pattern(regexp = "^[0-9]{8}$", message = "학번은 8자리 숫자로 구성되어야 합니다.")
14+
@Size(min = 8, max = 8)
15+
@Schema(description = "지원자 학번", example = "20250001")
16+
private String studentNo;
17+
}

src/main/java/dmu/dasom/api/domain/interview/service/InterviewServiceImpl.java

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
import dmu.dasom.api.domain.interview.repository.InterviewReservationRepository;
1515
import dmu.dasom.api.domain.interview.repository.InterviewSlotRepository;
1616
import dmu.dasom.api.domain.recruit.service.RecruitServiceImpl;
17+
import dmu.dasom.api.global.util.VerificationCodeManager;
1718
import jakarta.persistence.EntityListeners;
1819
import lombok.RequiredArgsConstructor;
1920
import org.springframework.data.jpa.domain.support.AuditingEntityListener;
@@ -36,6 +37,7 @@ public class InterviewServiceImpl implements InterviewService{
3637
private final InterviewReservationRepository interviewReservationRepository;
3738
private final ApplicantRepository applicantRepository;
3839
private final RecruitServiceImpl recruitService;
40+
private final VerificationCodeManager verificationCodeManager;
3941

4042
@Override
4143
@Transactional
@@ -176,6 +178,9 @@ public List<InterviewReservationApplicantResponseDto> getAllInterviewApplicants(
176178
@Override
177179
@Transactional
178180
public void modifyInterviewReservation(InterviewReservationModifyRequestDto request) {
181+
// 0. 인증 코드 검증
182+
verificationCodeManager.verifyCode(request.getStudentNo(), request.getVerificationCode());
183+
179184
// 1. 지원자 학번과 이메일로 지원자 조회 및 검증
180185
Applicant applicant = applicantRepository.findByStudentNoAndEmail(request.getStudentNo(), request.getEmail())
181186
.orElseThrow(() -> new CustomException(ErrorCode.APPLICANT_NOT_FOUND));
@@ -204,7 +209,7 @@ public void modifyInterviewReservation(InterviewReservationModifyRequestDto requ
204209
interviewSlotRepository.save(oldSlot); // 변경된 oldSlot 저장
205210

206211
// 7. 예약 정보 업데이트 (새로운 슬롯으로 변경)
207-
existingReservation.setSlot(newSlot); // InterviewReservation 엔티티에 setSlot 메서드가 없으므로 추가해야 함.
212+
existingReservation.setSlot(newSlot);
208213

209214
// 8. 새로운 슬롯의 예약 인원 증가
210215
newSlot.incrementCurrentCandidates();
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
package dmu.dasom.api.global.util;
2+
3+
import dmu.dasom.api.domain.common.exception.CustomException;
4+
import dmu.dasom.api.domain.common.exception.ErrorCode;
5+
import org.springframework.stereotype.Component;
6+
7+
import java.time.Instant;
8+
import java.util.concurrent.ConcurrentHashMap;
9+
import java.util.concurrent.ThreadLocalRandom;
10+
11+
@Component
12+
public class VerificationCodeManager {
13+
14+
private static final long EXPIRATION_TIME_SECONDS = 180; // 3분
15+
private final ConcurrentHashMap<String, String> codeStore = new ConcurrentHashMap<>();
16+
private final ConcurrentHashMap<String, Instant> expirationStore = new ConcurrentHashMap<>();
17+
18+
public String generateAndStoreCode(String key) {
19+
String code = String.valueOf(ThreadLocalRandom.current().nextInt(100000, 1000000));
20+
codeStore.put(key, code);
21+
expirationStore.put(key, Instant.now().plusSeconds(EXPIRATION_TIME_SECONDS));
22+
return code;
23+
}
24+
25+
public void verifyCode(String key, String code) {
26+
if (!isCodeValid(key, code)) {
27+
throw new CustomException(ErrorCode.VERIFICATION_CODE_NOT_VALID);
28+
}
29+
removeCode(key);
30+
}
31+
32+
private boolean isCodeValid(String key, String code) {
33+
if (code == null || code.isEmpty()) {
34+
return false;
35+
}
36+
Instant expirationTime = expirationStore.get(key);
37+
if (expirationTime == null || Instant.now().isAfter(expirationTime)) {
38+
removeCode(key);
39+
return false;
40+
}
41+
return code.equals(codeStore.get(key));
42+
}
43+
44+
private void removeCode(String key) {
45+
codeStore.remove(key);
46+
expirationStore.remove(key);
47+
}
48+
}

0 commit comments

Comments
 (0)