diff --git a/src/main/java/ceos/backend/domain/application/helper/ApplicationHelper.java b/src/main/java/ceos/backend/domain/application/helper/ApplicationHelper.java index 5c319b0..cd47369 100644 --- a/src/main/java/ceos/backend/domain/application/helper/ApplicationHelper.java +++ b/src/main/java/ceos/backend/domain/application/helper/ApplicationHelper.java @@ -11,12 +11,13 @@ import ceos.backend.global.common.dto.AwsSESMail; import ceos.backend.global.common.dto.SlackUnavailableReason; import ceos.backend.global.common.event.Event; -import java.util.List; -import java.util.UUID; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Component; +import java.util.List; +import java.util.UUID; + @Slf4j @Component @RequiredArgsConstructor @@ -74,4 +75,14 @@ public Application getApplicationByUuidAndEmail(String uuid, String email) { throw ApplicantNotFound.EXCEPTION; }); } + + public Application getApplicationByUuidAndEmailForUpdate(String uuid, String email) { + return applicationRepository + .findByUuidAndEmailWithPessimisticLock(uuid, email) + .orElseThrow( + () -> { + throw ApplicantNotFound.EXCEPTION; + }); + } + } diff --git a/src/main/java/ceos/backend/domain/application/repository/ApplicationRepository.java b/src/main/java/ceos/backend/domain/application/repository/ApplicationRepository.java index c1f7544..25bbae7 100644 --- a/src/main/java/ceos/backend/domain/application/repository/ApplicationRepository.java +++ b/src/main/java/ceos/backend/domain/application/repository/ApplicationRepository.java @@ -4,8 +4,6 @@ import ceos.backend.domain.application.domain.Application; import ceos.backend.domain.application.domain.Pass; import ceos.backend.global.common.entity.Part; -import java.util.Optional; - import jakarta.persistence.LockModeType; import org.springframework.data.domain.Page; import org.springframework.data.domain.PageRequest; @@ -14,6 +12,8 @@ import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; +import java.util.Optional; + public interface ApplicationRepository extends JpaRepository, ApplicationRepositoryCustom { @Query("select distinct a from Application a" + " where a.applicantInfo.email = :email") @@ -22,6 +22,7 @@ public interface ApplicationRepository @Query("select distinct a from Application a" + " where a.applicantInfo.uuid = :uuid") Optional findByUuid(@Param("uuid") String uuid); + @Query( "select a from Application a" + " where a.applicantInfo.uuid = :uuid" @@ -29,6 +30,15 @@ public interface ApplicationRepository Optional findByUuidAndEmail( @Param("uuid") String uuid, @Param("email") String email); + @Lock(LockModeType.PESSIMISTIC_WRITE) + @Query( + "select a from Application a" + + " where a.applicantInfo.uuid = :uuid" + + " and a.applicantInfo.email = :email") + Optional findByUuidAndEmailWithPessimisticLock( + @Param("uuid") String uuid, @Param("email") String email); + + @Query("select count(a) > 0 from Application a" + " where a.applicantInfo.email = :email") boolean existsByEmail(@Param("email") String email); diff --git a/src/main/java/ceos/backend/domain/application/service/ApplicationService.java b/src/main/java/ceos/backend/domain/application/service/ApplicationService.java index 369cffa..245c614 100644 --- a/src/main/java/ceos/backend/domain/application/service/ApplicationService.java +++ b/src/main/java/ceos/backend/domain/application/service/ApplicationService.java @@ -158,12 +158,12 @@ public void updateInterviewAttendance( String uuid, String email, UpdateAttendanceRequest request) { recruitmentValidator.validateBetweenResultDateDocAndResultDateFinal(); // 서류 합격 기간 검증 applicationValidator.validateApplicantAccessible(uuid, email); // 유저 검증 - final Application application = applicationHelper.getApplicationByUuidAndEmail(uuid, email); + final Application application = + applicationHelper.getApplicationByUuidAndEmailForUpdate(uuid, email); applicationValidator.validateApplicantInterviewCheckStatus(application); // 서류합격, 인터뷰 체크 검증 if (request.isAvailable()) { application.updateInterviewCheck(true); - applicationRepository.save(application); } else { application.updateUnableReason(request.getReason()); applicationHelper.sendSlackUnableReasonMessage(application, request, false); @@ -187,12 +187,12 @@ public void updateParticipationAvailability( String uuid, String email, UpdateAttendanceRequest request) { recruitmentValidator.validateFinalResultAbleDuration(); // 최종 합격 기간 검증 applicationValidator.validateApplicantAccessible(uuid, email); // 유저 검증 - final Application application = applicationHelper.getApplicationByUuidAndEmail(uuid, email); + final Application application = + applicationHelper.getApplicationByUuidAndEmailForUpdate(uuid, email); applicationValidator.validateApplicantActivityCheckStatus(application); // 유저 확인 여부 검증 if (request.isAvailable()) { application.updateFinalCheck(true); - applicationRepository.save(application); } else { application.updateUnableReason(request.getReason()); applicationHelper.sendSlackUnableReasonMessage(application, request, true); diff --git a/src/main/java/ceos/backend/global/config/WebSecurityConfig.java b/src/main/java/ceos/backend/global/config/WebSecurityConfig.java index c68a0e1..24c09ba 100644 --- a/src/main/java/ceos/backend/global/config/WebSecurityConfig.java +++ b/src/main/java/ceos/backend/global/config/WebSecurityConfig.java @@ -7,8 +7,6 @@ import ceos.backend.global.config.jwt.JwtAuthenticationEntryPoint; import ceos.backend.global.config.jwt.JwtAuthenticationFilter; import ceos.backend.global.config.jwt.JwtExceptionHandlerFilter; -import java.util.Arrays; -import java.util.List; import lombok.RequiredArgsConstructor; import org.springframework.beans.factory.annotation.Value; import org.springframework.boot.autoconfigure.condition.ConditionalOnWebApplication; @@ -34,6 +32,9 @@ import org.springframework.web.cors.CorsUtils; import org.springframework.web.cors.UrlBasedCorsConfigurationSource; +import java.util.Arrays; +import java.util.List; + @EnableWebSecurity @Configuration() @ConditionalOnDefaultWebSecurity @@ -191,8 +192,13 @@ private CorsConfiguration getDefaultCorsConfiguration() { "http://localhost:8080", "http://localhost:3000", "http://localhost:3001", + // 프론트 테스트 + "dev-ceos.netlify.app", + "dev-admin-ceos.netlify.app", + // 프론트 운영 USER_URL, ADMIN_URL, + // SERVER_URL, DEV_URL)); configuration.setAllowedHeaders(List.of("*")); diff --git a/src/main/java/ceos/backend/infra/ses/AwsSESSendMailHandler.java b/src/main/java/ceos/backend/infra/ses/AwsSESSendMailHandler.java index d6d56b0..d257172 100644 --- a/src/main/java/ceos/backend/infra/ses/AwsSESSendMailHandler.java +++ b/src/main/java/ceos/backend/infra/ses/AwsSESSendMailHandler.java @@ -5,6 +5,7 @@ import ceos.backend.global.common.dto.AwsSESMail; import ceos.backend.global.common.dto.AwsSESPasswordMail; import ceos.backend.global.common.dto.AwsSESRecruitMail; +import ceos.backend.infra.ses.domain.EmailType; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.context.event.EventListener; @@ -26,7 +27,8 @@ public void handle(AwsSESMail awsSESMail) { final String SUBJECT = awsSESMailGenerator.generateApplicationMailSubject(awsSESMail.getGeneration()); final Context CONTEXT = awsSESMailGenerator.generateApplicationMailContext(awsSESMail); - awsSesUtils.singleEmailRequest(TO, SUBJECT, "sendApplicationMail", CONTEXT); + awsSesUtils.singleEmailRequest( + TO, SUBJECT, "sendApplicationMail", CONTEXT, EmailType.APPLICATION); } @EventListener(AwsSESPasswordMail.class) @@ -34,7 +36,7 @@ public void handle(AwsSESPasswordMail awsSESPasswordMail) { final String TO = awsSESPasswordMail.getEmail(); final String SUBJECT = awsSESMailGenerator.generatePasswordMailSubject(); final Context CONTEXT = awsSESMailGenerator.generatePasswordMailContext(awsSESPasswordMail); - awsSesUtils.singleEmailRequest(TO, SUBJECT, "sendPasswordMail", CONTEXT); + awsSesUtils.singleEmailRequest(TO, SUBJECT, "sendPasswordMail", CONTEXT, EmailType.PASSWORD); } @EventListener(AwsSESRecruitMail.class) @@ -42,6 +44,6 @@ public void handle(AwsSESRecruitMail awsSESRecruitMail) { final String TO = awsSESRecruitMail.getEmail(); final String SUBJECT = awsSESMailGenerator.generateRecruitMailSubject(); final Context CONTEXT = awsSESMailGenerator.generateRecruitMailContext(awsSESRecruitMail); - awsSesUtils.singleEmailRequest(TO, SUBJECT, "sendRecruitMail", CONTEXT); + awsSesUtils.singleEmailRequest(TO, SUBJECT, "sendRecruitMail", CONTEXT, EmailType.RECRUIT); } } diff --git a/src/main/java/ceos/backend/infra/ses/AwsSESUtils.java b/src/main/java/ceos/backend/infra/ses/AwsSESUtils.java index d3e4be9..b9a4035 100644 --- a/src/main/java/ceos/backend/infra/ses/AwsSESUtils.java +++ b/src/main/java/ceos/backend/infra/ses/AwsSESUtils.java @@ -1,30 +1,42 @@ package ceos.backend.infra.ses; +import ceos.backend.infra.ses.domain.EmailSendHistory; +import ceos.backend.infra.ses.domain.EmailType; +import ceos.backend.infra.ses.repository.EmailSendHistoryRepository; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Component; import org.thymeleaf.context.Context; import org.thymeleaf.spring6.SpringTemplateEngine; import software.amazon.awssdk.services.ses.SesAsyncClient; import software.amazon.awssdk.services.ses.model.*; +import java.util.concurrent.CompletableFuture; + +@Slf4j @Component @RequiredArgsConstructor public class AwsSESUtils { private final SesAsyncClient sesAsyncClient; private final SpringTemplateEngine templateEngine; + private final EmailSendHistoryRepository emailSendHistoryRepository; - public void singleEmailRequest(String to, String subject, String template, Context context) { + public void singleEmailRequest( + String to, String subject, String template, Context context, EmailType emailType) { final String html = templateEngine.process(template, context); final SendEmailRequest.Builder sendEmailRequestBuilder = SendEmailRequest.builder(); sendEmailRequestBuilder.destination(Destination.builder().toAddresses(to).build()); - sendEmailRequestBuilder + + SendEmailRequest request = sendEmailRequestBuilder .message(newMessage(subject, html)) .source("ceos@ceos-sinchon.com") .build(); - sesAsyncClient.sendEmail(sendEmailRequestBuilder.build()); + CompletableFuture future = sesAsyncClient.sendEmail(request); + + saveHistory(to, subject, template, emailType, future); } private Message newMessage(String subject, String html) { @@ -34,4 +46,41 @@ private Message newMessage(String subject, String html) { .body(Body.builder().html(builder -> builder.data(html)).build()) .build(); } + + private void saveHistory(String to, String subject, String template, EmailType emailType, CompletableFuture future) { + future.whenComplete( + (response, exception) -> { + if (exception != null) { + log.error("Failed to send email to: {}", to, exception); + saveFailureHistory(to, subject, template, emailType, exception); + } else { + log.info("Successfully sent email to: {}, messageId: {}", to, response.messageId()); + saveSuccessHistory(to, subject, template, emailType, response.messageId()); + } + }); + } + + private void saveSuccessHistory( + String to, String subject, String template, EmailType emailType, String messageId) { + try { + EmailSendHistory history = + EmailSendHistory.createSuccess(to, subject, template, emailType, messageId); + emailSendHistoryRepository.save(history); + } catch (Exception e) { + log.error("Failed to save email send success history", e); + } + } + + private void saveFailureHistory( + String to, String subject, String template, EmailType emailType, Throwable exception) { + try { + EmailSendHistory history = + EmailSendHistory.createFailure( + to, subject, template, emailType, exception.getMessage()); + emailSendHistoryRepository.save(history); + } catch (Exception e) { + log.error("Failed to save email send failure history", e); + } + } + } diff --git a/src/main/java/ceos/backend/infra/ses/domain/EmailSendHistory.java b/src/main/java/ceos/backend/infra/ses/domain/EmailSendHistory.java new file mode 100644 index 0000000..f704c28 --- /dev/null +++ b/src/main/java/ceos/backend/infra/ses/domain/EmailSendHistory.java @@ -0,0 +1,92 @@ +package ceos.backend.infra.ses.domain; + + +import ceos.backend.global.common.entity.BaseEntity; +import jakarta.persistence.*; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Table(name = "email_send_history") +public class EmailSendHistory extends BaseEntity { + + @Id @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "recipient_email") + private String recipientEmail; + + @Column(name = "subject") + private String subject; + + @Column(name = "template_name") + private String templateName; + + @Enumerated(EnumType.STRING) + @Column(name = "email_type") + private EmailType emailType; + + @Enumerated(EnumType.STRING) + @Column(name = "send_status") + private SendStatus sendStatus; + + @Column(name = "message_id", length = 255) + private String messageId; + + @Column(name = "error_message", length = 1000) + private String errorMessage; + + @Builder(access = AccessLevel.PRIVATE) + private EmailSendHistory( + String recipientEmail, + String subject, + String templateName, + EmailType emailType, + SendStatus sendStatus, + String messageId, + String errorMessage) { + this.recipientEmail = recipientEmail; + this.subject = subject; + this.templateName = templateName; + this.emailType = emailType; + this.sendStatus = sendStatus; + this.messageId = messageId; + this.errorMessage = errorMessage; + } + + public static EmailSendHistory createSuccess( + String recipientEmail, + String subject, + String templateName, + EmailType emailType, + String messageId) { + return EmailSendHistory.builder() + .recipientEmail(recipientEmail) + .subject(subject) + .templateName(templateName) + .emailType(emailType) + .sendStatus(SendStatus.SUCCESS) + .messageId(messageId) + .build(); + } + + public static EmailSendHistory createFailure( + String recipientEmail, + String subject, + String templateName, + EmailType emailType, + String errorMessage) { + return EmailSendHistory.builder() + .recipientEmail(recipientEmail) + .subject(subject) + .templateName(templateName) + .emailType(emailType) + .sendStatus(SendStatus.FAILURE) + .errorMessage(errorMessage) + .build(); + } +} diff --git a/src/main/java/ceos/backend/infra/ses/domain/EmailType.java b/src/main/java/ceos/backend/infra/ses/domain/EmailType.java new file mode 100644 index 0000000..cab8025 --- /dev/null +++ b/src/main/java/ceos/backend/infra/ses/domain/EmailType.java @@ -0,0 +1,8 @@ +package ceos.backend.infra.ses.domain; + + +public enum EmailType { + APPLICATION, // 지원서 접수 확인 + PASSWORD, // 임시 비밀번호 발급 + RECRUIT // 리크루팅 안내 +} diff --git a/src/main/java/ceos/backend/infra/ses/domain/SendStatus.java b/src/main/java/ceos/backend/infra/ses/domain/SendStatus.java new file mode 100644 index 0000000..5286d46 --- /dev/null +++ b/src/main/java/ceos/backend/infra/ses/domain/SendStatus.java @@ -0,0 +1,7 @@ +package ceos.backend.infra.ses.domain; + + +public enum SendStatus { + SUCCESS, // 전송 성공 + FAILURE // 전송 실패 +} diff --git a/src/main/java/ceos/backend/infra/ses/repository/EmailSendHistoryRepository.java b/src/main/java/ceos/backend/infra/ses/repository/EmailSendHistoryRepository.java new file mode 100644 index 0000000..5644356 --- /dev/null +++ b/src/main/java/ceos/backend/infra/ses/repository/EmailSendHistoryRepository.java @@ -0,0 +1,7 @@ +package ceos.backend.infra.ses.repository; + + +import ceos.backend.infra.ses.domain.EmailSendHistory; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface EmailSendHistoryRepository extends JpaRepository {} diff --git a/src/main/resources/templates/component/copyright.html b/src/main/resources/templates/component/copyright.html index c64f423..d3f3b66 100644 --- a/src/main/resources/templates/component/copyright.html +++ b/src/main/resources/templates/component/copyright.html @@ -21,7 +21,7 @@ font-size: 20px; line-height: 150%; color: #D6DADF;"> - © 2016-2025 Ceos ALL RIGHTS RESERVED. + © 2016-2026 Ceos ALL RIGHTS RESERVED.