From 66256b9cf1c46a157f9aa28d678d3f5e6cafdb1d Mon Sep 17 00:00:00 2001 From: Gothax Date: Sat, 7 Feb 2026 21:51:19 +0900 Subject: [PATCH 1/4] =?UTF-8?q?fix:=20=ED=94=84=EB=A1=A0=ED=8A=B8=20?= =?UTF-8?q?=EC=8A=A4=ED=85=8C=EC=9D=B4=EC=A7=95=20CORS=20=ED=97=88?= =?UTF-8?q?=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../ceos/backend/global/config/WebSecurityConfig.java | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) 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("*")); From ed3a641f44b7985d2cd8fe94bad1748f9d157789 Mon Sep 17 00:00:00 2001 From: Gothax Date: Sun, 8 Feb 2026 01:20:02 +0900 Subject: [PATCH 2/4] =?UTF-8?q?chore:=202026=EC=9C=BC=EB=A1=9C=20=EA=B8=B0?= =?UTF-8?q?=EA=B0=84=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/resources/templates/component/copyright.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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. From 115bfabcd1595f1225e409ba165b64ecc0591aac Mon Sep 17 00:00:00 2001 From: Gothax Date: Sun, 8 Feb 2026 17:19:55 +0900 Subject: [PATCH 3/4] =?UTF-8?q?feat:=20=EC=9D=B4=EB=A9=94=EC=9D=BC=20?= =?UTF-8?q?=EC=A0=84=EC=86=A1=20=EC=9D=B4=EB=A0=A5=20=EA=B8=B0=EB=8A=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../infra/ses/AwsSESSendMailHandler.java | 8 +- .../ceos/backend/infra/ses/AwsSESUtils.java | 55 ++++++++++- .../infra/ses/domain/EmailSendHistory.java | 92 +++++++++++++++++++ .../backend/infra/ses/domain/EmailType.java | 8 ++ .../backend/infra/ses/domain/SendStatus.java | 7 ++ .../EmailSendHistoryRepository.java | 7 ++ 6 files changed, 171 insertions(+), 6 deletions(-) create mode 100644 src/main/java/ceos/backend/infra/ses/domain/EmailSendHistory.java create mode 100644 src/main/java/ceos/backend/infra/ses/domain/EmailType.java create mode 100644 src/main/java/ceos/backend/infra/ses/domain/SendStatus.java create mode 100644 src/main/java/ceos/backend/infra/ses/repository/EmailSendHistoryRepository.java 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 {} From fdfb3e25c6812f42c957cfb8ac60b5bcb46c75e3 Mon Sep 17 00:00:00 2001 From: Gothax Date: Sun, 8 Feb 2026 17:48:49 +0900 Subject: [PATCH 4/4] =?UTF-8?q?feat:=20=ED=95=A9=EA=B2=A9=EC=9E=90=20?= =?UTF-8?q?=EA=B4=80=EB=A6=AC=EC=8B=9C=20=EB=B9=84=EA=B4=80=EB=9D=BD=20?= =?UTF-8?q?=EC=A0=81=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../application/helper/ApplicationHelper.java | 15 +++++++++++++-- .../repository/ApplicationRepository.java | 14 ++++++++++++-- .../application/service/ApplicationService.java | 8 ++++---- 3 files changed, 29 insertions(+), 8 deletions(-) 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);