diff --git a/build.gradle b/build.gradle index d2c1c8e..c2fdc7d 100644 --- a/build.gradle +++ b/build.gradle @@ -39,7 +39,9 @@ dependencies { implementation 'org.springframework.boot:spring-boot-starter-data-redis' implementation 'org.springframework.boot:spring-boot-starter-security' implementation 'org.springframework.boot:spring-boot-starter-validation' + implementation 'org.springframework.boot:spring-boot-starter-mail' implementation 'org.springframework.boot:spring-boot-starter-web' + implementation 'org.springframework.boot:spring-boot-starter-thymeleaf' compileOnly 'org.projectlombok:lombok' developmentOnly 'org.springframework.boot:spring-boot-devtools' runtimeOnly 'com.oracle.database.jdbc:ojdbc11' @@ -53,6 +55,9 @@ dependencies { implementation 'com.google.auth:google-auth-library-oauth2-http:0.20.0' + + + implementation 'io.jsonwebtoken:jjwt-api:0.12.6' runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.12.6' runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.12.6' 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 28f316a..48035ac 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 @@ -1,11 +1,14 @@ package dmu.dasom.api.domain.applicant.repository; import dmu.dasom.api.domain.applicant.entity.Applicant; +import dmu.dasom.api.domain.applicant.enums.ApplicantStatus; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.Query; +import java.util.List; + import java.util.Optional; public interface ApplicantRepository extends JpaRepository { @@ -13,6 +16,10 @@ public interface ApplicantRepository extends JpaRepository { @Query("SELECT a FROM Applicant a ORDER BY a.id DESC") Page findAllWithPageRequest(final Pageable pageable); + // 상태별 지원자 조회 + List findByStatus(ApplicantStatus status); + List findByStatusIn(List statuses); + Optional findByStudentNo(final String studentNo); } diff --git a/src/main/java/dmu/dasom/api/domain/applicant/service/ApplicantService.java b/src/main/java/dmu/dasom/api/domain/applicant/service/ApplicantService.java index 1015833..687c350 100644 --- a/src/main/java/dmu/dasom/api/domain/applicant/service/ApplicantService.java +++ b/src/main/java/dmu/dasom/api/domain/applicant/service/ApplicantService.java @@ -4,6 +4,7 @@ import dmu.dasom.api.domain.applicant.dto.ApplicantDetailsResponseDto; import dmu.dasom.api.domain.applicant.dto.ApplicantResponseDto; import dmu.dasom.api.domain.applicant.dto.ApplicantStatusUpdateRequestDto; +import dmu.dasom.api.domain.email.enums.MailType; import dmu.dasom.api.global.dto.PageResponse; public interface ApplicantService { @@ -16,4 +17,6 @@ public interface ApplicantService { ApplicantDetailsResponseDto updateApplicantStatus(final Long id, final ApplicantStatusUpdateRequestDto request); + void sendEmailsToApplicants(MailType mailType); + } diff --git a/src/main/java/dmu/dasom/api/domain/applicant/service/ApplicantServiceImpl.java b/src/main/java/dmu/dasom/api/domain/applicant/service/ApplicantServiceImpl.java index 637af13..8bce02c 100644 --- a/src/main/java/dmu/dasom/api/domain/applicant/service/ApplicantServiceImpl.java +++ b/src/main/java/dmu/dasom/api/domain/applicant/service/ApplicantServiceImpl.java @@ -5,11 +5,16 @@ import dmu.dasom.api.domain.applicant.dto.ApplicantResponseDto; import dmu.dasom.api.domain.applicant.dto.ApplicantStatusUpdateRequestDto; import dmu.dasom.api.domain.applicant.entity.Applicant; +import dmu.dasom.api.domain.applicant.enums.ApplicantStatus; 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.enums.MailType; +import dmu.dasom.api.domain.email.service.EmailService; import dmu.dasom.api.global.dto.PageResponse; +import jakarta.mail.MessagingException; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; import org.springframework.data.domain.Page; import org.springframework.data.domain.PageRequest; import org.springframework.stereotype.Service; @@ -17,6 +22,9 @@ import java.util.Optional; +import java.util.List; + +@Slf4j @RequiredArgsConstructor @Service @Transactional @@ -25,6 +33,7 @@ public class ApplicantServiceImpl implements ApplicantService { private final static int DEFAULT_PAGE_SIZE = 20; private final ApplicantRepository applicantRepository; + private final EmailService emailService; // 지원자 저장 @Override @@ -73,6 +82,35 @@ public ApplicantDetailsResponseDto updateApplicantStatus(final Long id, final Ap return applicant.toApplicantDetailsResponse(); } + @Override + public void sendEmailsToApplicants(MailType mailType) { + List applicants; + + // MailType에 따라 지원자 조회 + switch (mailType) { + case DOCUMENT_RESULT: + applicants = applicantRepository.findAll(); + break; + case FINAL_RESULT: + applicants = applicantRepository.findByStatusIn( + List.of(ApplicantStatus.INTERVIEW_FAILED, + ApplicantStatus.INTERVIEW_PASSED) + ); + break; + default: + throw new CustomException(ErrorCode.INTERNAL_SERVER_ERROR); + } + + for (Applicant applicant : applicants) { + try { + emailService.sendEmail(applicant.getEmail(), applicant.getName(), mailType); + } catch (MessagingException e) { + System.err.println("Failed to send email to: " + applicant.getEmail()); + } + } + } + + // Repository에서 ID로 지원자 조회 private Applicant findById(final Long id) { return applicantRepository.findById(id) 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 dcb6c58..bce69bd 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 @@ -20,6 +20,8 @@ public enum ErrorCode { WRITE_FAIL(400, "C011", "데이터를 쓰는데 실패하였습니다."), EMPTY_RESULT(400, "C012", "조회 결과가 없습니다."), DUPLICATED_STUDENT_NO(400, "C013", "이미 등록된 학번입니다."), + SEND_EMAIL_FAIL(400, "C014", "이메일 전송에 실패하였습니다."), + MAIL_TYPE_NOT_VALID(400, "C015", "메일 타입이 올바르지 않습니다.") ; private final int status; diff --git a/src/main/java/dmu/dasom/api/domain/email/enums/MailType.java b/src/main/java/dmu/dasom/api/domain/email/enums/MailType.java new file mode 100644 index 0000000..8a1940c --- /dev/null +++ b/src/main/java/dmu/dasom/api/domain/email/enums/MailType.java @@ -0,0 +1,6 @@ +package dmu.dasom.api.domain.email.enums; + +public enum MailType { + DOCUMENT_RESULT, // 서류 합격 + FINAL_RESULT // 최종 합격 +} 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..bac1c87 --- /dev/null +++ b/src/main/java/dmu/dasom/api/domain/email/service/EmailService.java @@ -0,0 +1,62 @@ +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.email.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 JavaMailSender javaMailSender; + private TemplateEngine templateEngine; + @Value("${spring.mail.username}") + private String from; + + 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 templateName = switch (mailType) { + case DOCUMENT_RESULT -> { + subject = "서류 합격 안내"; + yield "document-pass-template"; + } + case FINAL_RESULT -> { + subject = "최종 합격 안내"; + yield "final-pass-template"; + } + default -> throw new IllegalStateException("Unexpected value: " + mailType); + }; + + // HTML 템플릿에 전달할 데이터 설정 + Context context = new Context(); + context.setVariable("name", name); // 지원자 이름 전달 + + // HTML 템플릿 처리 + String htmlBody = templateEngine.process(templateName, 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); + + javaMailSender.send(message); + } + +} diff --git a/src/main/java/dmu/dasom/api/global/admin/controller/AdminController.java b/src/main/java/dmu/dasom/api/global/admin/controller/AdminController.java index ebfadc2..c8b5536 100644 --- a/src/main/java/dmu/dasom/api/global/admin/controller/AdminController.java +++ b/src/main/java/dmu/dasom/api/global/admin/controller/AdminController.java @@ -4,8 +4,10 @@ import dmu.dasom.api.domain.applicant.dto.ApplicantResponseDto; import dmu.dasom.api.domain.applicant.dto.ApplicantStatusUpdateRequestDto; import dmu.dasom.api.domain.applicant.service.ApplicantService; +import dmu.dasom.api.domain.email.enums.MailType; import dmu.dasom.api.global.dto.PageResponse; import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; import io.swagger.v3.oas.annotations.media.Content; import io.swagger.v3.oas.annotations.media.ExampleObject; import io.swagger.v3.oas.annotations.media.Schema; @@ -79,4 +81,34 @@ public ResponseEntity updateApplicantStatus(@PathVa return ResponseEntity.ok(applicantService.updateApplicantStatus(id, request)); } + @Operation( + summary = "메일 전송", + description = "지원자들에게 서류 결과 또는 최종 결과 이메일을 발송합니다." + ) + @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\": \"C014\", \"message\": \"이메일 전송에 실패하였습니다.\" }" + ) + } + ) + ) + }) + @PostMapping("/applicants/send-email") + public ResponseEntity sendEmailsToApplicants( + @RequestParam + @Parameter(description = "메일 발송 타입", examples = { + @ExampleObject(name = "서류 합격 메일", value = "DOCUMENT_PASS"), + @ExampleObject(name = "최종 결과 메일", value = "FINAL_RESULT") + }) MailType mailType) { + applicantService.sendEmailsToApplicants(mailType); + return ResponseEntity.ok().build(); + } + } diff --git a/src/main/resources/application-credentials.yml b/src/main/resources/application-credentials.yml index e9a7ac3..f300773 100644 --- a/src/main/resources/application-credentials.yml +++ b/src/main/resources/application-credentials.yml @@ -15,6 +15,17 @@ spring: redis: host: ${REDIS_HOST} port: ${REDIS_PORT} + mail: + host: smtp.gmail.com + port: 587 + username: ${MAIL_USERNAME} + password: ${MAIL_PASSWORD} + properties: + mail: + smtp: + auth: true + starttls: + enable: true jwt: secret: ${JWT_SECRET} access-token-expiration: ${JWT_ACCESS_TOKEN_EXPIRATION} diff --git a/src/main/resources/template/email-template.html b/src/main/resources/template/email-template.html new file mode 100644 index 0000000..462ab74 --- /dev/null +++ b/src/main/resources/template/email-template.html @@ -0,0 +1,79 @@ + + + + + + 지원 결과 확인 + + + +
+ +
DASOM
+ + +
컴퓨터소프트웨어공학과 전공동아리 다솜
34기 서류 합격자 조회
+ + +
+

안녕하세요, 님.

+

학번 마지막 4자리 + 전화번호 마지막 4자리를 입력하여
지원 결과를 확인할 수 있습니다.

+

예시) 08470542

+ + + 결과 확인하기 +
+
+ + diff --git a/src/test/java/dmu/dasom/api/domain/applicant/ApplicantServiceTest.java b/src/test/java/dmu/dasom/api/domain/applicant/ApplicantServiceTest.java index 256a302..f9b89af 100644 --- a/src/test/java/dmu/dasom/api/domain/applicant/ApplicantServiceTest.java +++ b/src/test/java/dmu/dasom/api/domain/applicant/ApplicantServiceTest.java @@ -3,11 +3,15 @@ import dmu.dasom.api.domain.applicant.dto.ApplicantCreateRequestDto; import dmu.dasom.api.domain.applicant.dto.ApplicantResponseDto; import dmu.dasom.api.domain.applicant.entity.Applicant; +import dmu.dasom.api.domain.applicant.enums.ApplicantStatus; import dmu.dasom.api.domain.applicant.repository.ApplicantRepository; import dmu.dasom.api.domain.applicant.service.ApplicantServiceImpl; import dmu.dasom.api.domain.common.exception.CustomException; import dmu.dasom.api.domain.common.exception.ErrorCode; +import dmu.dasom.api.domain.email.enums.MailType; +import dmu.dasom.api.domain.email.service.EmailService; import dmu.dasom.api.global.dto.PageResponse; +import jakarta.mail.MessagingException; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; @@ -19,6 +23,7 @@ import org.springframework.data.domain.PageRequest; import java.util.Collections; +import java.util.List; import java.util.Optional; import static org.junit.jupiter.api.Assertions.*; @@ -30,6 +35,9 @@ class ApplicantServiceTest { @Mock private ApplicantRepository applicantRepository; + @Mock + private EmailService emailService; + @InjectMocks private ApplicantServiceImpl applicantService; @@ -123,4 +131,49 @@ void getApplicants_fail_emptyResult() { assertEquals(ErrorCode.EMPTY_RESULT, exception.getErrorCode()); verify(applicantRepository).findAllWithPageRequest(pageRequest); } + + @Test + @DisplayName("메일 전송 - 서류 결과 메일 (DOCUMENT_RESULT)") + void sendEmailsToApplicants_documentResult() throws MessagingException { + // given + MailType mailType = MailType.DOCUMENT_RESULT; + Applicant applicant = mock(Applicant.class); + when(applicantRepository.findAll()).thenReturn(Collections.singletonList(applicant)); + when(applicant.getEmail()).thenReturn("test@example.com"); + when(applicant.getName()).thenReturn("지원자"); + + // when + assertDoesNotThrow(() -> applicantService.sendEmailsToApplicants(mailType)); + + // then + verify(applicantRepository).findAll(); + verify(emailService).sendEmail("test@example.com", "지원자", mailType); + } + + @Test + @DisplayName("메일 전송 - 최종 결과 메일 (FINAL_RESULT)") + void sendEmailsToApplicants_finalResult() throws MessagingException { + // given + MailType mailType = MailType.FINAL_RESULT; + Applicant passedApplicant = mock(Applicant.class); + Applicant failedApplicant = mock(Applicant.class); + + when(applicantRepository.findByStatusIn( + List.of(ApplicantStatus.INTERVIEW_FAILED, ApplicantStatus.INTERVIEW_PASSED))) + .thenReturn(List.of(passedApplicant, failedApplicant)); + + when(passedApplicant.getEmail()).thenReturn("passed@example.com"); + when(passedApplicant.getName()).thenReturn("합격자"); + when(failedApplicant.getEmail()).thenReturn("failed@example.com"); + when(failedApplicant.getName()).thenReturn("불합격자"); + + // when + assertDoesNotThrow(() -> applicantService.sendEmailsToApplicants(mailType)); + + // then + verify(applicantRepository).findByStatusIn( + List.of(ApplicantStatus.INTERVIEW_FAILED, ApplicantStatus.INTERVIEW_PASSED)); + verify(emailService).sendEmail("passed@example.com", "합격자", mailType); + verify(emailService).sendEmail("failed@example.com", "불합격자", mailType); + } } \ No newline at end of file diff --git a/src/test/java/dmu/dasom/api/domain/email/EmailServiceTest.java b/src/test/java/dmu/dasom/api/domain/email/EmailServiceTest.java new file mode 100644 index 0000000..ab580be --- /dev/null +++ b/src/test/java/dmu/dasom/api/domain/email/EmailServiceTest.java @@ -0,0 +1,109 @@ +package dmu.dasom.api.domain.email; + +import dmu.dasom.api.domain.common.exception.CustomException; +import dmu.dasom.api.domain.common.exception.ErrorCode; +import dmu.dasom.api.domain.email.enums.MailType; +import dmu.dasom.api.domain.email.service.EmailService; +import jakarta.mail.MessagingException; +import jakarta.mail.internet.MimeMessage; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.MockitoAnnotations; +import org.springframework.mail.javamail.JavaMailSender; +import org.springframework.test.util.ReflectionTestUtils; +import org.thymeleaf.TemplateEngine; +import org.thymeleaf.context.Context; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +class EmailServiceTest { + + @Mock + private JavaMailSender javaMailSender; + + @Mock + private TemplateEngine templateEngine; + + @InjectMocks + private EmailService emailService; + + private MimeMessage mimeMessage; + + @BeforeEach + void setUp() { + MockitoAnnotations.openMocks(this); + mimeMessage = mock(MimeMessage.class); + when(javaMailSender.createMimeMessage()).thenReturn(mimeMessage); + + // 테스트 환경에서 from 값을 설정 + ReflectionTestUtils.setField(emailService, "from", "test_email@example.com"); + } + + @Test + @DisplayName("서류 합격 메일 발송 테스트") + void sendEmail_documentResult() throws MessagingException { + // given + String to = "applicant@example.com"; + String name = "지원자"; + MailType mailType = MailType.DOCUMENT_RESULT; + + String expectedTemplate = "document-pass-template"; + String expectedHtmlBody = "Document Pass"; + when(templateEngine.process(eq(expectedTemplate), any(Context.class))).thenReturn(expectedHtmlBody); + + // when + emailService.sendEmail(to, name, mailType); + + // then + ArgumentCaptor messageCaptor = ArgumentCaptor.forClass(MimeMessage.class); + verify(javaMailSender).send(messageCaptor.capture()); + + MimeMessage sentMessage = messageCaptor.getValue(); + assertNotNull(sentMessage); + verify(templateEngine).process(eq(expectedTemplate), any(Context.class)); + } + + @Test + @DisplayName("최종 합격 메일 발송 테스트") + void sendEmail_finalResult() throws MessagingException { + // given + String to = "applicant@example.com"; + String name = "지원자"; + MailType mailType = MailType.FINAL_RESULT; + + String expectedTemplate = "final-pass-template"; + String expectedHtmlBody = "Final Pass"; + when(templateEngine.process(eq(expectedTemplate), any(Context.class))).thenReturn(expectedHtmlBody); + + // when + emailService.sendEmail(to, name, mailType); + + // then + ArgumentCaptor messageCaptor = ArgumentCaptor.forClass(MimeMessage.class); + verify(javaMailSender).send(messageCaptor.capture()); + + MimeMessage sentMessage = messageCaptor.getValue(); + assertNotNull(sentMessage); + verify(templateEngine).process(eq(expectedTemplate), any(Context.class)); + } + + @Test + @DisplayName("잘못된 MailType 처리 테스트") + void sendEmail_invalidMailType() { + // given + String to = "applicant@example.com"; + String name = "지원자"; + + // when & then + CustomException exception = assertThrows(CustomException.class, () -> { + emailService.sendEmail(to, name, null); + }); + + assertEquals(ErrorCode.MAIL_TYPE_NOT_VALID, exception.getErrorCode()); + } +} \ No newline at end of file