diff --git a/.gitignore b/.gitignore index d6a8d1f..7de1e9d 100644 --- a/.gitignore +++ b/.gitignore @@ -44,4 +44,6 @@ out/ application-secret.yml ### -.env** \ No newline at end of file +.env** + +AGENTS.md diff --git a/src/main/java/ceos/backend/BackendApplication.java b/src/main/java/ceos/backend/BackendApplication.java index 3127eed..a3b2da7 100644 --- a/src/main/java/ceos/backend/BackendApplication.java +++ b/src/main/java/ceos/backend/BackendApplication.java @@ -5,9 +5,11 @@ import org.springframework.boot.autoconfigure.SpringBootApplication; import org.springframework.data.jpa.repository.config.EnableJpaAuditing; import org.springframework.scheduling.annotation.EnableAsync; +import org.springframework.scheduling.annotation.EnableScheduling; @SpringBootApplication @EnableAsync +@EnableScheduling @EnableJpaAuditing public class BackendApplication { diff --git a/src/main/java/ceos/backend/domain/admin/AdminController.java b/src/main/java/ceos/backend/domain/admin/AdminController.java index 439152f..9bd8c51 100644 --- a/src/main/java/ceos/backend/domain/admin/AdminController.java +++ b/src/main/java/ceos/backend/domain/admin/AdminController.java @@ -41,7 +41,8 @@ public void signUp(@RequestBody @Valid SignUpRequest signUpRequest) { @Operation(summary = "로그인") @PostMapping("/signin") - public TokenResponse signIn(HttpServletRequest request, @RequestBody @Valid SignInRequest signInRequest) { + public TokenResponse signIn( + HttpServletRequest request, @RequestBody @Valid SignInRequest signInRequest) { log.info("로그인"); String device = request.getHeader("User-Agent").contains("mobile") ? MOBILE : WEB; return adminService.signIn(device, signInRequest); @@ -72,7 +73,8 @@ public void resetPwd( @Operation(summary = "로그아웃") @PostMapping("/logout") - public void logout(HttpServletRequest request, @AuthenticationPrincipal AdminDetails adminUser) { + public void logout( + HttpServletRequest request, @AuthenticationPrincipal AdminDetails adminUser) { log.info("로그아웃"); String device = request.getHeader("User-Agent").contains("mobile") ? MOBILE : WEB; adminService.logout(device, adminUser); diff --git a/src/main/java/ceos/backend/domain/admin/service/AdminService.java b/src/main/java/ceos/backend/domain/admin/service/AdminService.java index 7179b5a..9474bb3 100644 --- a/src/main/java/ceos/backend/domain/admin/service/AdminService.java +++ b/src/main/java/ceos/backend/domain/admin/service/AdminService.java @@ -62,7 +62,8 @@ public TokenResponse signIn(String device, SignInRequest signInRequest) { // 토큰 발급 final String accessToken = tokenProvider.createAccessToken(admin.getId(), authentication); - final String refreshToken = tokenProvider.createRefreshToken(admin.getId(), authentication, redisKey); + final String refreshToken = + tokenProvider.createRefreshToken(admin.getId(), authentication, redisKey); return adminMapper.toTokenResponse(accessToken, refreshToken); } diff --git a/src/main/java/ceos/backend/domain/application/ApplicationController.java b/src/main/java/ceos/backend/domain/application/ApplicationController.java index cf32828..b926b1e 100644 --- a/src/main/java/ceos/backend/domain/application/ApplicationController.java +++ b/src/main/java/ceos/backend/domain/application/ApplicationController.java @@ -1,4 +1,3 @@ - package ceos.backend.domain.application; @@ -38,13 +37,25 @@ public class ApplicationController { @Operation(summary = "지원자 목록 보기") @GetMapping public GetApplications getApplications( - @Parameter(schema = @Schema(allowableValues = {"PRODUCT", "DESIGN", "FRONTEND", "BACKEND"})) - @RequestParam(value = "part", required = false, defaultValue = "") Part part, + @Parameter( + schema = + @Schema( + allowableValues = { + "PRODUCT", + "DESIGN", + "FRONTEND", + "BACKEND" + })) + @RequestParam(value = "part", required = false, defaultValue = "") + Part part, @Parameter(schema = @Schema(allowableValues = {"PASS", "FAIL"})) - @RequestParam(value = "docPass", required = false, defaultValue = "") Pass docPass, + @RequestParam(value = "docPass", required = false, defaultValue = "") + Pass docPass, @Parameter(schema = @Schema(allowableValues = {"PASS", "FAIL"})) - @RequestParam(value = "finalPass", required = false, defaultValue = "") Pass finalPass, - @RequestParam(value = "applicantName", required = false, defaultValue = "") String applicantName, + @RequestParam(value = "finalPass", required = false, defaultValue = "") + Pass finalPass, + @RequestParam(value = "applicantName", required = false, defaultValue = "") + String applicantName, @RequestParam("pageNum") int pageNum, @RequestParam("limit") int limit) { log.info("지원자 목록 보기"); @@ -145,7 +156,8 @@ public void updateDocumentPassStatus( @Operation(summary = "면접 참여 가능 여부 확인", description = "resultDateDoc ~ resultDateFinal 전날") @GetMapping(value = "/{applicationId}/interview/availability") - public GetInterviewAvailability getInterviewAvailability(@PathVariable("applicationId") Long applicationId) { + public GetInterviewAvailability getInterviewAvailability( + @PathVariable("applicationId") Long applicationId) { log.info("면접 참여 가능 여부 확인"); return applicationService.getInterviewAvailability(applicationId); } diff --git a/src/main/java/ceos/backend/domain/application/domain/Application.java b/src/main/java/ceos/backend/domain/application/domain/Application.java index 5ea4acb..395371c 100644 --- a/src/main/java/ceos/backend/domain/application/domain/Application.java +++ b/src/main/java/ceos/backend/domain/application/domain/Application.java @@ -7,15 +7,14 @@ import jakarta.persistence.*; import jakarta.validation.constraints.NotNull; import jakarta.validation.constraints.Size; +import java.util.ArrayList; +import java.util.List; import lombok.AccessLevel; import lombok.Builder; import lombok.Getter; import lombok.NoArgsConstructor; import org.hibernate.annotations.DynamicInsert; -import java.util.ArrayList; -import java.util.List; - @DynamicInsert @Getter @NoArgsConstructor(access = AccessLevel.PROTECTED) diff --git a/src/main/java/ceos/backend/domain/application/domain/AvailableCheck.java b/src/main/java/ceos/backend/domain/application/domain/AvailableCheck.java index 3aff797..3b4df5b 100644 --- a/src/main/java/ceos/backend/domain/application/domain/AvailableCheck.java +++ b/src/main/java/ceos/backend/domain/application/domain/AvailableCheck.java @@ -1,12 +1,12 @@ package ceos.backend.domain.application.domain; + import com.fasterxml.jackson.annotation.JsonCreator; import com.fasterxml.jackson.annotation.JsonValue; +import java.util.stream.Stream; import lombok.Getter; import lombok.RequiredArgsConstructor; -import java.util.stream.Stream; - @Getter @RequiredArgsConstructor public enum AvailableCheck { diff --git a/src/main/java/ceos/backend/domain/application/dto/response/GetFinalAvailability.java b/src/main/java/ceos/backend/domain/application/dto/response/GetFinalAvailability.java index 7f5159b..08ce3c9 100644 --- a/src/main/java/ceos/backend/domain/application/dto/response/GetFinalAvailability.java +++ b/src/main/java/ceos/backend/domain/application/dto/response/GetFinalAvailability.java @@ -1,5 +1,6 @@ package ceos.backend.domain.application.dto.response; + import ceos.backend.domain.application.domain.Application; import ceos.backend.domain.application.domain.AvailableCheck; import lombok.Builder; @@ -23,5 +24,4 @@ public static GetFinalAvailability of(Application application) { .reason(application.getFinalUnableReason()) .build(); } - } diff --git a/src/main/java/ceos/backend/domain/application/dto/response/GetInterviewAvailability.java b/src/main/java/ceos/backend/domain/application/dto/response/GetInterviewAvailability.java index 662b867..3e4d5b7 100644 --- a/src/main/java/ceos/backend/domain/application/dto/response/GetInterviewAvailability.java +++ b/src/main/java/ceos/backend/domain/application/dto/response/GetInterviewAvailability.java @@ -1,5 +1,6 @@ package ceos.backend.domain.application.dto.response; + import ceos.backend.domain.application.domain.Application; import ceos.backend.domain.application.domain.AvailableCheck; import lombok.Builder; diff --git a/src/main/java/ceos/backend/domain/application/exception/exceptions/NotDeletableDuringRecruitment.java b/src/main/java/ceos/backend/domain/application/exception/exceptions/NotDeletableDuringRecruitment.java index 4a0e2e6..a498acb 100644 --- a/src/main/java/ceos/backend/domain/application/exception/exceptions/NotDeletableDuringRecruitment.java +++ b/src/main/java/ceos/backend/domain/application/exception/exceptions/NotDeletableDuringRecruitment.java @@ -1,10 +1,12 @@ package ceos.backend.domain.application.exception.exceptions; + import ceos.backend.domain.application.exception.ApplicationErrorCode; import ceos.backend.global.error.BaseErrorException; public class NotDeletableDuringRecruitment extends BaseErrorException { - public static final NotDeletableDuringRecruitment EXCEPTION = new NotDeletableDuringRecruitment(); + public static final NotDeletableDuringRecruitment EXCEPTION = + new NotDeletableDuringRecruitment(); private NotDeletableDuringRecruitment() { super(ApplicationErrorCode.NOT_DELETABLE_DURING_RECRUITMENT); 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 cd47369..83c9ddd 100644 --- a/src/main/java/ceos/backend/domain/application/helper/ApplicationHelper.java +++ b/src/main/java/ceos/backend/domain/application/helper/ApplicationHelper.java @@ -11,13 +11,12 @@ 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 @@ -84,5 +83,4 @@ public Application getApplicationByUuidAndEmailForUpdate(String uuid, String ema 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 25bbae7..5b50a6b 100644 --- a/src/main/java/ceos/backend/domain/application/repository/ApplicationRepository.java +++ b/src/main/java/ceos/backend/domain/application/repository/ApplicationRepository.java @@ -5,6 +5,7 @@ import ceos.backend.domain.application.domain.Pass; import ceos.backend.global.common.entity.Part; import jakarta.persistence.LockModeType; +import java.util.Optional; import org.springframework.data.domain.Page; import org.springframework.data.domain.PageRequest; import org.springframework.data.jpa.repository.JpaRepository; @@ -12,8 +13,6 @@ 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,7 +21,6 @@ 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" @@ -38,7 +36,6 @@ Optional findByUuidAndEmail( 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); @@ -77,9 +74,7 @@ Page findAllByPartAndDocumentPassAndFinalPass( @Param("convertedFinalPass") Pass convertedFinalPass, PageRequest pageRequest); - @Lock(LockModeType.PESSIMISTIC_WRITE) @Query("select a from Application a where a.id = :id") Optional findByIdWithPessimisticLock(@Param("id") Long id); - } 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 8f5d507..ce90b51 100644 --- a/src/main/java/ceos/backend/domain/application/service/ApplicationService.java +++ b/src/main/java/ceos/backend/domain/application/service/ApplicationService.java @@ -1,5 +1,7 @@ package ceos.backend.domain.application.service; +import static ceos.backend.domain.application.domain.AvailableCheck.AVAILABLE; +import static ceos.backend.domain.application.domain.AvailableCheck.UNAVAILABLE; import ceos.backend.domain.application.domain.*; import ceos.backend.domain.application.dto.request.CreateApplicationRequest; @@ -27,7 +29,6 @@ import ceos.backend.global.common.entity.Part; import ceos.backend.global.util.InterviewDateTimeConvertor; import ceos.backend.global.util.ParsedDurationConvertor; - import java.time.LocalDateTime; import java.util.List; import lombok.RequiredArgsConstructor; @@ -36,9 +37,6 @@ import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; -import static ceos.backend.domain.application.domain.AvailableCheck.AVAILABLE; -import static ceos.backend.domain.application.domain.AvailableCheck.UNAVAILABLE; - @Service @RequiredArgsConstructor public class ApplicationService { @@ -161,6 +159,7 @@ public void updateInterviewAttendance( if (request.getAvailable() == AVAILABLE) { application.updateInterviewCheck(AVAILABLE); + application.updateInterviewUnableReason(null); } else { application.updateInterviewCheck(UNAVAILABLE); application.updateInterviewUnableReason(request.getReason()); @@ -191,6 +190,7 @@ public void updateParticipationAvailability( if (request.getAvailable() == AVAILABLE) { application.updateFinalCheck(AVAILABLE); + application.updateFinalUnableReason(null); } else { application.updateFinalCheck(UNAVAILABLE); application.updateFinalUnableReason(request.getReason()); @@ -237,7 +237,8 @@ public GetInterviewTime getInterviewTime(Long applicationId) { public void updateInterviewTime(Long applicationId, UpdateInterviewTime updateInterviewTime) { recruitmentValidator.validateBetweenStartDateDocAndResultDateDoc(); // 기간 검증 applicationValidator.validateExistingApplicant(applicationId); // 유저 검증 - final Application application = applicationHelper.getApplicationByIdForUpdate(applicationId); + final Application application = + applicationHelper.getApplicationByIdForUpdate(applicationId); applicationValidator.validateDocumentPassStatus(application); // 서류 통과 검증 final List interviews = interviewRepository.findAll(); final String duration = @@ -256,14 +257,14 @@ public GetInterviewAvailability getInterviewAvailability(Long applicationId) { return GetInterviewAvailability.of(application); } - @Transactional @TransactionLog public void updateDocumentPassStatus(Long applicationId, UpdatePassStatus updatePassStatus) { recruitmentValidator.validateBetweenStartDateDocAndResultDateDoc(); // 기간 검증 applicationValidator.validateExistingApplicant(applicationId); // 유저 검증 - final Application application = applicationHelper.getApplicationByIdForUpdate(applicationId); + final Application application = + applicationHelper.getApplicationByIdForUpdate(applicationId); application.updateDocumentPass(updatePassStatus.getPass()); } @@ -272,7 +273,8 @@ public void updateDocumentPassStatus(Long applicationId, UpdatePassStatus update public void updateFinalPassStatus(Long applicationId, UpdatePassStatus updatePassStatus) { recruitmentValidator.validateBetweenResultDateDocAndResultDateFinal(); // 기간 검증 applicationValidator.validateExistingApplicant(applicationId); // 유저 검증 - final Application application = applicationHelper.getApplicationByIdForUpdate(applicationId); + final Application application = + applicationHelper.getApplicationByIdForUpdate(applicationId); applicationValidator.validateDocumentPassStatus(application); // 서류 통과 검증 application.updateFinalPass(updatePassStatus.getPass()); @@ -287,16 +289,14 @@ public GetFinalAvailability getFinalAvailability(Long applicationId) { return GetFinalAvailability.of(application); } - @Transactional public void deleteAllApplications() { Recruitment recruitment = recruitmentHelper.takeRecruitment(); // 현재 시간이 resultDateFinal 이전이면 삭제 불가 - if(LocalDateTime.now().isBefore(recruitment.getResultDateFinal())) { + if (LocalDateTime.now().isBefore(recruitment.getResultDateFinal())) { throw NotDeletableDuringRecruitment.EXCEPTION; } // application, applicationAnswer, applicationInterview 삭제 (cascade) applicationRepository.deleteAll(); } - } diff --git a/src/main/java/ceos/backend/domain/application/validator/ApplicationValidator.java b/src/main/java/ceos/backend/domain/application/validator/ApplicationValidator.java index efa6e67..e64c165 100644 --- a/src/main/java/ceos/backend/domain/application/validator/ApplicationValidator.java +++ b/src/main/java/ceos/backend/domain/application/validator/ApplicationValidator.java @@ -48,13 +48,11 @@ public void validateApplicantDocumentPass(Application application) { public void validateApplicantInterviewCheckStatus(Application application) { application.validateDocumentPass(); - application.validateNotInterviewCheck(); } public void validateApplicantActivityCheckStatus(Application application) { application.validateDocumentPass(); application.validateFinalPass(); - application.validateNotFinalCheck(); } public void validateExistingApplicant(Long applicationId) { diff --git a/src/main/java/ceos/backend/domain/management/domain/Management.java b/src/main/java/ceos/backend/domain/management/domain/Management.java index f903556..9c500e6 100644 --- a/src/main/java/ceos/backend/domain/management/domain/Management.java +++ b/src/main/java/ceos/backend/domain/management/domain/Management.java @@ -1,5 +1,6 @@ package ceos.backend.domain.management.domain; + import ceos.backend.domain.management.dto.request.UpdateManagementRequest; import ceos.backend.domain.management.vo.ManagementVo; import ceos.backend.global.common.entity.BaseEntity; diff --git a/src/main/java/ceos/backend/domain/management/dto/response/GetAllPartManagementsResponse.java b/src/main/java/ceos/backend/domain/management/dto/response/GetAllPartManagementsResponse.java index 5295729..6322630 100644 --- a/src/main/java/ceos/backend/domain/management/dto/response/GetAllPartManagementsResponse.java +++ b/src/main/java/ceos/backend/domain/management/dto/response/GetAllPartManagementsResponse.java @@ -1,5 +1,6 @@ package ceos.backend.domain.management.dto.response; + import ceos.backend.domain.management.dto.ManagementDto; import java.util.List; import lombok.Builder; diff --git a/src/main/java/ceos/backend/domain/management/service/ManagementService.java b/src/main/java/ceos/backend/domain/management/service/ManagementService.java index 64dc770..2278209 100644 --- a/src/main/java/ceos/backend/domain/management/service/ManagementService.java +++ b/src/main/java/ceos/backend/domain/management/service/ManagementService.java @@ -67,8 +67,7 @@ public GetAllPartManagementsResponse getAllPartManagements() { managementRepository.findManagementAllByRoleOrderByNameAsc( ManagementRole.PRESIDENCY); List findAdvisors = - managementRepository.findManagementAllByRoleOrderByNameAsc( - ManagementRole.ADVISOR); + managementRepository.findManagementAllByRoleOrderByNameAsc(ManagementRole.ADVISOR); List findGeneralAffairs = managementRepository.findManagementAllByRoleOrderByNameAsc( ManagementRole.GENERAL_AFFAIRS); @@ -81,7 +80,11 @@ public GetAllPartManagementsResponse getAllPartManagements() { GetAllPartManagementsResponse response = managementMapper.toPartManagementList( - findPresidency, findAdvisors, findGeneralAffairs, findPartLeaders, findManagements); + findPresidency, + findAdvisors, + findGeneralAffairs, + findPartLeaders, + findManagements); return response; } diff --git a/src/main/java/ceos/backend/domain/recruitment/service/RecruitmentService.java b/src/main/java/ceos/backend/domain/recruitment/service/RecruitmentService.java index 4717279..48152a5 100644 --- a/src/main/java/ceos/backend/domain/recruitment/service/RecruitmentService.java +++ b/src/main/java/ceos/backend/domain/recruitment/service/RecruitmentService.java @@ -6,13 +6,12 @@ import ceos.backend.domain.recruitment.dto.UserRecruitmentDTO; import ceos.backend.domain.recruitment.helper.RecruitmentHelper; import ceos.backend.domain.recruitment.repository.RecruitmentRepository; +import java.time.LocalDateTime; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; -import java.time.LocalDateTime; - @Slf4j @Service @RequiredArgsConstructor diff --git a/src/main/java/ceos/backend/domain/startup/StartupController.java b/src/main/java/ceos/backend/domain/startup/StartupController.java index 126bbd0..c93765c 100644 --- a/src/main/java/ceos/backend/domain/startup/StartupController.java +++ b/src/main/java/ceos/backend/domain/startup/StartupController.java @@ -1,5 +1,6 @@ package ceos.backend.domain.startup; + import ceos.backend.domain.startup.dto.request.StartupRequest; import ceos.backend.domain.startup.dto.response.StartupResponse; import ceos.backend.domain.startup.dto.response.StartupsResponse; @@ -45,8 +46,9 @@ public StartupResponse getStartup(@PathVariable("startupId") Long startupId) { @Operation(summary = "창업 리스트 수정") @PatchMapping(value = "/{startupId}") - public StartupResponse updateStartup(@PathVariable("startupId") Long startupId, - @RequestBody @Valid StartupRequest startupRequest) { + public StartupResponse updateStartup( + @PathVariable("startupId") Long startupId, + @RequestBody @Valid StartupRequest startupRequest) { log.info("창업 리스트 수정"); return startupService.updateStartup(startupId, startupRequest); } @@ -64,5 +66,4 @@ public AwsS3Url getImageUrl() { log.info("서비스 이미지 url 생성하기"); return startupService.getImageUrl(); } - } diff --git a/src/main/java/ceos/backend/domain/startup/domain/Startup.java b/src/main/java/ceos/backend/domain/startup/domain/Startup.java index 73e16c1..bd218eb 100644 --- a/src/main/java/ceos/backend/domain/startup/domain/Startup.java +++ b/src/main/java/ceos/backend/domain/startup/domain/Startup.java @@ -1,5 +1,6 @@ package ceos.backend.domain.startup.domain; + import ceos.backend.domain.startup.dto.request.StartupRequest; import jakarta.persistence.*; import jakarta.validation.constraints.NotBlank; @@ -19,25 +20,26 @@ public class Startup { @Column(name = "startup_id") private Long id; - @NotBlank - private String serviceName; + @NotBlank private String serviceName; private String companyName; - @NotBlank - private String imageUrl; + @NotBlank private String imageUrl; - @NotBlank - private String serviceUrl; + @NotBlank private String serviceUrl; - @NotNull - private Integer generation; + @NotNull private Integer generation; - @NotBlank - private String founder; + @NotBlank private String founder; @Builder - public Startup(String serviceName, String companyName, String imageUrl, String serviceUrl, Integer generation, String founder) { + public Startup( + String serviceName, + String companyName, + String imageUrl, + String serviceUrl, + Integer generation, + String founder) { this.serviceName = serviceName; this.companyName = companyName; this.imageUrl = imageUrl; @@ -54,5 +56,4 @@ public void update(StartupRequest startupRequest) { this.generation = startupRequest.getGeneration(); this.founder = startupRequest.getFounder(); } - } diff --git a/src/main/java/ceos/backend/domain/startup/dto/request/StartupRequest.java b/src/main/java/ceos/backend/domain/startup/dto/request/StartupRequest.java index 54779c2..3860360 100644 --- a/src/main/java/ceos/backend/domain/startup/dto/request/StartupRequest.java +++ b/src/main/java/ceos/backend/domain/startup/dto/request/StartupRequest.java @@ -1,5 +1,6 @@ package ceos.backend.domain.startup.dto.request; + import ceos.backend.domain.startup.domain.Startup; import io.swagger.v3.oas.annotations.media.Schema; import jakarta.validation.constraints.NotBlank; @@ -10,10 +11,10 @@ public class StartupRequest { @NotBlank - @Schema(defaultValue = "Repick",description = "서비스명") + @Schema(defaultValue = "Repick", description = "서비스명") private String serviceName; - @Schema(defaultValue = "(주)Repick",description = "회사명(생략가능)") + @Schema(defaultValue = "(주)Repick", description = "회사명(생략가능)") private String companyName; @NotBlank @@ -42,5 +43,4 @@ public Startup toEntity() { .founder(founder) .build(); } - } diff --git a/src/main/java/ceos/backend/domain/startup/dto/response/StartupResponse.java b/src/main/java/ceos/backend/domain/startup/dto/response/StartupResponse.java index 51bf04b..0e6450f 100644 --- a/src/main/java/ceos/backend/domain/startup/dto/response/StartupResponse.java +++ b/src/main/java/ceos/backend/domain/startup/dto/response/StartupResponse.java @@ -1,5 +1,6 @@ package ceos.backend.domain.startup.dto.response; + import ceos.backend.domain.startup.domain.Startup; import lombok.Builder; import lombok.Getter; @@ -22,7 +23,14 @@ public class StartupResponse { private String founder; @Builder - public StartupResponse(Long startupId, String serviceName, String companyName, String imageUrl, String serviceUrl, Integer generation, String founder) { + public StartupResponse( + Long startupId, + String serviceName, + String companyName, + String imageUrl, + String serviceUrl, + Integer generation, + String founder) { this.startupId = startupId; this.serviceName = serviceName; this.companyName = companyName; @@ -43,5 +51,4 @@ public static StartupResponse fromEntity(Startup startup) { .founder(startup.getFounder()) .build(); } - } diff --git a/src/main/java/ceos/backend/domain/startup/dto/response/StartupsResponse.java b/src/main/java/ceos/backend/domain/startup/dto/response/StartupsResponse.java index c2ad7e4..d04ca07 100644 --- a/src/main/java/ceos/backend/domain/startup/dto/response/StartupsResponse.java +++ b/src/main/java/ceos/backend/domain/startup/dto/response/StartupsResponse.java @@ -1,13 +1,13 @@ package ceos.backend.domain.startup.dto.response; + import ceos.backend.domain.startup.domain.Startup; import ceos.backend.global.common.dto.PageInfo; +import java.util.List; import lombok.AllArgsConstructor; import lombok.Getter; import org.springframework.data.domain.Page; -import java.util.List; - @Getter @AllArgsConstructor public class StartupsResponse { @@ -22,9 +22,6 @@ public static StartupsResponse fromPageable(Page startups) { startups.getNumber(), startups.getSize(), startups.getTotalPages(), - startups.getTotalElements() - ) - ); + startups.getTotalElements())); } - } diff --git a/src/main/java/ceos/backend/domain/startup/exception/StartupErrorCode.java b/src/main/java/ceos/backend/domain/startup/exception/StartupErrorCode.java index c439363..2c0d8fa 100644 --- a/src/main/java/ceos/backend/domain/startup/exception/StartupErrorCode.java +++ b/src/main/java/ceos/backend/domain/startup/exception/StartupErrorCode.java @@ -1,17 +1,16 @@ package ceos.backend.domain.startup.exception; +import static org.springframework.http.HttpStatus.BAD_REQUEST; + import ceos.backend.global.common.dto.ErrorReason; import ceos.backend.global.error.BaseErrorCode; import lombok.AllArgsConstructor; import lombok.Getter; import org.springframework.http.HttpStatus; -import static org.springframework.http.HttpStatus.BAD_REQUEST; - @Getter @AllArgsConstructor public enum StartupErrorCode implements BaseErrorCode { - STARTUP_NOT_FOUND(BAD_REQUEST, "STARTUP_404_1", "존재하지 않는 창업 서비스입니다."); private HttpStatus status; @@ -22,5 +21,4 @@ public enum StartupErrorCode implements BaseErrorCode { public ErrorReason getErrorReason() { return ErrorReason.of(status.value(), code, reason); } - } diff --git a/src/main/java/ceos/backend/domain/startup/exception/StartupNotFound.java b/src/main/java/ceos/backend/domain/startup/exception/StartupNotFound.java index 33ff73f..a59409e 100644 --- a/src/main/java/ceos/backend/domain/startup/exception/StartupNotFound.java +++ b/src/main/java/ceos/backend/domain/startup/exception/StartupNotFound.java @@ -10,5 +10,4 @@ public class StartupNotFound extends BaseErrorException { public StartupNotFound() { super(StartupErrorCode.STARTUP_NOT_FOUND); } - } diff --git a/src/main/java/ceos/backend/domain/startup/repository/StartupRepository.java b/src/main/java/ceos/backend/domain/startup/repository/StartupRepository.java index 3e3e9e4..02a4498 100644 --- a/src/main/java/ceos/backend/domain/startup/repository/StartupRepository.java +++ b/src/main/java/ceos/backend/domain/startup/repository/StartupRepository.java @@ -1,8 +1,7 @@ package ceos.backend.domain.startup.repository; + import ceos.backend.domain.startup.domain.Startup; import org.springframework.data.jpa.repository.JpaRepository; -public interface StartupRepository extends JpaRepository { - -} +public interface StartupRepository extends JpaRepository {} diff --git a/src/main/java/ceos/backend/domain/startup/service/StartupService.java b/src/main/java/ceos/backend/domain/startup/service/StartupService.java index 30434dc..69b60fe 100644 --- a/src/main/java/ceos/backend/domain/startup/service/StartupService.java +++ b/src/main/java/ceos/backend/domain/startup/service/StartupService.java @@ -1,5 +1,6 @@ package ceos.backend.domain.startup.service; + import ceos.backend.domain.startup.domain.Startup; import ceos.backend.domain.startup.dto.request.StartupRequest; import ceos.backend.domain.startup.dto.response.StartupResponse; @@ -38,18 +39,16 @@ public StartupsResponse getStartups(Integer pageNum, Integer limit) { @Transactional(readOnly = true) public StartupResponse getStartup(Long startupId) { - Startup startup = startupRepository.findById(startupId).orElseThrow( - () -> StartupNotFound.EXCEPTION - ); + Startup startup = + startupRepository.findById(startupId).orElseThrow(() -> StartupNotFound.EXCEPTION); return StartupResponse.fromEntity(startup); } @Transactional public StartupResponse updateStartup(Long startupId, StartupRequest startupRequest) { - Startup startup = startupRepository.findById(startupId).orElseThrow( - () -> StartupNotFound.EXCEPTION - ); + Startup startup = + startupRepository.findById(startupId).orElseThrow(() -> StartupNotFound.EXCEPTION); startup.update(startupRequest); return StartupResponse.fromEntity(startup); @@ -57,9 +56,8 @@ public StartupResponse updateStartup(Long startupId, StartupRequest startupReque @Transactional public void deleteStartup(Long startupId) { - Startup startup = startupRepository.findById(startupId).orElseThrow( - () -> StartupNotFound.EXCEPTION - ); + Startup startup = + startupRepository.findById(startupId).orElseThrow(() -> StartupNotFound.EXCEPTION); startupRepository.delete(startup); } @@ -68,5 +66,4 @@ public void deleteStartup(Long startupId) { public AwsS3Url getImageUrl() { return awsS3UrlHandler.handle("startups"); } - } diff --git a/src/main/java/ceos/backend/domain/subscriber/domain/Subscriber.java b/src/main/java/ceos/backend/domain/subscriber/domain/Subscriber.java index 313b696..310c79d 100644 --- a/src/main/java/ceos/backend/domain/subscriber/domain/Subscriber.java +++ b/src/main/java/ceos/backend/domain/subscriber/domain/Subscriber.java @@ -34,9 +34,6 @@ private Subscriber(String email, String phoneNum) { } public static Subscriber from(String email, String phoneNum) { - return Subscriber.builder() - .email(email) - .phoneNum(phoneNum) - .build(); + return Subscriber.builder().email(email).phoneNum(phoneNum).build(); } } diff --git a/src/main/java/ceos/backend/domain/subscriber/exception/SubscriberErrorCode.java b/src/main/java/ceos/backend/domain/subscriber/exception/SubscriberErrorCode.java index 33df5c7..f4a8b34 100644 --- a/src/main/java/ceos/backend/domain/subscriber/exception/SubscriberErrorCode.java +++ b/src/main/java/ceos/backend/domain/subscriber/exception/SubscriberErrorCode.java @@ -1,13 +1,13 @@ package ceos.backend.domain.subscriber.exception; +import static org.springframework.http.HttpStatus.*; + import ceos.backend.global.common.dto.ErrorReason; import ceos.backend.global.error.BaseErrorCode; import lombok.AllArgsConstructor; import lombok.Getter; import org.springframework.http.HttpStatus; -import static org.springframework.http.HttpStatus.*; - @Getter @AllArgsConstructor public enum SubscriberErrorCode implements BaseErrorCode { @@ -17,7 +17,7 @@ public enum SubscriberErrorCode implements BaseErrorCode { INVALID_ACTION_AFTER(BAD_REQUEST, "SUBSCRIBER_400_2", "리쿠르팅 마감 후입니다."), DUPLICATE_DATA(CONFLICT, "SUBSCRIBER_409_1", "이미 존재하는 데이터입니다"); - private HttpStatus status; + private HttpStatus status; private String code; private String reason; diff --git a/src/main/java/ceos/backend/domain/subscriber/helper/SubscriberHelper.java b/src/main/java/ceos/backend/domain/subscriber/helper/SubscriberHelper.java index eac1488..f68f411 100644 --- a/src/main/java/ceos/backend/domain/subscriber/helper/SubscriberHelper.java +++ b/src/main/java/ceos/backend/domain/subscriber/helper/SubscriberHelper.java @@ -1,16 +1,16 @@ package ceos.backend.domain.subscriber.helper; + import ceos.backend.domain.subscriber.exception.DuplicateData; import ceos.backend.domain.subscriber.exception.InvalidActionAfter; import ceos.backend.domain.subscriber.exception.InvalidActionBefore; import ceos.backend.domain.subscriber.repository.SubscriberRepository; import ceos.backend.global.common.dto.AwsSESRecruitMail; import ceos.backend.global.common.event.Event; +import java.time.LocalDate; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Component; -import java.time.LocalDate; - @Component @RequiredArgsConstructor public class SubscriberHelper { @@ -23,7 +23,7 @@ public void validateEmail(String email) { } } - public void validateDate(LocalDate startDate, LocalDate endDate, LocalDate now) { + public void validateDate(LocalDate startDate, LocalDate endDate, LocalDate now) { if (now.isBefore(startDate)) { throw InvalidActionBefore.EXCEPTION; } else if (now.isAfter(endDate)) { @@ -34,5 +34,4 @@ public void validateDate(LocalDate startDate, LocalDate endDate, LocalDate now) public void sendRecruitEmail(String email) { Event.raise(AwsSESRecruitMail.from(email)); } - } diff --git a/src/main/java/ceos/backend/domain/subscriber/repository/SubscriberRepository.java b/src/main/java/ceos/backend/domain/subscriber/repository/SubscriberRepository.java index 9c6c1cd..e57bebc 100644 --- a/src/main/java/ceos/backend/domain/subscriber/repository/SubscriberRepository.java +++ b/src/main/java/ceos/backend/domain/subscriber/repository/SubscriberRepository.java @@ -2,9 +2,8 @@ import ceos.backend.domain.subscriber.domain.Subscriber; -import org.springframework.data.jpa.repository.JpaRepository; - import java.util.Optional; +import org.springframework.data.jpa.repository.JpaRepository; public interface SubscriberRepository extends JpaRepository { Optional findByEmail(String email); diff --git a/src/main/java/ceos/backend/domain/subscriber/service/SubscriberService.java b/src/main/java/ceos/backend/domain/subscriber/service/SubscriberService.java index 1fba5b8..5ff4f3c 100644 --- a/src/main/java/ceos/backend/domain/subscriber/service/SubscriberService.java +++ b/src/main/java/ceos/backend/domain/subscriber/service/SubscriberService.java @@ -6,14 +6,13 @@ import ceos.backend.domain.subscriber.dto.request.SubscribeRequest; import ceos.backend.domain.subscriber.helper.SubscriberHelper; import ceos.backend.domain.subscriber.repository.SubscriberRepository; +import java.time.LocalDate; +import java.util.List; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; -import java.time.LocalDate; -import java.util.List; - @Slf4j @Service @RequiredArgsConstructor @@ -26,21 +25,23 @@ public class SubscriberService { @Transactional public void subscribeMail(SubscribeRequest subscribeRequest) { - //이메일 중복 검증 + // 이메일 중복 검증 subscriberHelper.validateEmail(subscribeRequest.getEmail()); - Subscriber subscriber = Subscriber.from(subscribeRequest.getEmail(), subscribeRequest.getPhoneNum()); + Subscriber subscriber = + Subscriber.from(subscribeRequest.getEmail(), subscribeRequest.getPhoneNum()); subscriberRepository.save(subscriber); } @Transactional(readOnly = true) public void sendRecruitingMail() { - LocalDate startDate = recruitmentRepository.findAll().get(0).getStartDateDoc().toLocalDate(); + LocalDate startDate = + recruitmentRepository.findAll().get(0).getStartDateDoc().toLocalDate(); LocalDate endDate = recruitmentRepository.findAll().get(0).getEndDateDoc().toLocalDate(); LocalDate now = LocalDate.now(); List subscribers = subscriberRepository.findAll(); - //리쿠르팅 기간 검증 + // 리쿠르팅 기간 검증 subscriberHelper.validateDate(startDate, endDate, now); // 메일 보내기 diff --git a/src/main/java/ceos/backend/global/common/annotation/TransactionLog.java b/src/main/java/ceos/backend/global/common/annotation/TransactionLog.java index ca49134..ad9e14a 100644 --- a/src/main/java/ceos/backend/global/common/annotation/TransactionLog.java +++ b/src/main/java/ceos/backend/global/common/annotation/TransactionLog.java @@ -1,10 +1,9 @@ package ceos.backend.global.common.annotation; -import java.lang.annotation.*; +import java.lang.annotation.*; @Target(ElementType.METHOD) @Retention(RetentionPolicy.RUNTIME) @Documented -public @interface TransactionLog { -} \ No newline at end of file +public @interface TransactionLog {} diff --git a/src/main/java/ceos/backend/global/common/aop/TransactionLoggingAspect.java b/src/main/java/ceos/backend/global/common/aop/TransactionLoggingAspect.java index b3bf3c4..16984d3 100644 --- a/src/main/java/ceos/backend/global/common/aop/TransactionLoggingAspect.java +++ b/src/main/java/ceos/backend/global/common/aop/TransactionLoggingAspect.java @@ -1,5 +1,6 @@ package ceos.backend.global.common.aop; + import lombok.extern.slf4j.Slf4j; import org.aspectj.lang.ProceedingJoinPoint; import org.aspectj.lang.annotation.Around; @@ -7,7 +8,6 @@ import org.springframework.stereotype.Component; import org.springframework.transaction.support.TransactionSynchronizationManager; - @Slf4j @Aspect @Component @@ -21,7 +21,6 @@ public Object logTxMethod(ProceedingJoinPoint joinPoint) throws Throwable { String txName = TransactionSynchronizationManager.getCurrentTransactionName(); log.info("[TX NAME] = {}", txName); - try { Object result = joinPoint.proceed(); log.info("[TX COMMIT] {}", methodName); diff --git a/src/main/java/ceos/backend/global/common/dto/AwsSESRecruitMail.java b/src/main/java/ceos/backend/global/common/dto/AwsSESRecruitMail.java index 406a32d..cf48ad9 100644 --- a/src/main/java/ceos/backend/global/common/dto/AwsSESRecruitMail.java +++ b/src/main/java/ceos/backend/global/common/dto/AwsSESRecruitMail.java @@ -6,7 +6,7 @@ @Getter public class AwsSESRecruitMail { - //수정 예정 -> 이름을 받을 것인가 말 것인가... + // 수정 예정 -> 이름을 받을 것인가 말 것인가... private String email; @Builder @@ -15,7 +15,6 @@ private AwsSESRecruitMail(String email) { } public static AwsSESRecruitMail from(String email) { - return AwsSESRecruitMail.builder().email(email) - .build(); + return AwsSESRecruitMail.builder().email(email).build(); } } diff --git a/src/main/java/ceos/backend/global/common/dto/mail/EmailInfo.java b/src/main/java/ceos/backend/global/common/dto/mail/EmailInfo.java index 66b5b8a..13bdaf4 100644 --- a/src/main/java/ceos/backend/global/common/dto/mail/EmailInfo.java +++ b/src/main/java/ceos/backend/global/common/dto/mail/EmailInfo.java @@ -15,8 +15,6 @@ private EmailInfo(String email) { } public static EmailInfo from(AwsSESRecruitMail awsSESRecruitMail) { - return EmailInfo.builder() - .email(awsSESRecruitMail.getEmail()) - .build(); + return EmailInfo.builder().email(awsSESRecruitMail.getEmail()).build(); } } diff --git a/src/main/java/ceos/backend/global/config/WebSecurityConfig.java b/src/main/java/ceos/backend/global/config/WebSecurityConfig.java index 94f8d99..2485ef6 100644 --- a/src/main/java/ceos/backend/global/config/WebSecurityConfig.java +++ b/src/main/java/ceos/backend/global/config/WebSecurityConfig.java @@ -7,6 +7,8 @@ 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; @@ -32,9 +34,6 @@ import org.springframework.web.cors.CorsUtils; import org.springframework.web.cors.UrlBasedCorsConfigurationSource; -import java.util.Arrays; -import java.util.List; - @EnableWebSecurity @Configuration() @ConditionalOnDefaultWebSecurity diff --git a/src/main/java/ceos/backend/infra/ses/ApplicationEmailRetryScheduler.java b/src/main/java/ceos/backend/infra/ses/ApplicationEmailRetryScheduler.java new file mode 100644 index 0000000..4c04e99 --- /dev/null +++ b/src/main/java/ceos/backend/infra/ses/ApplicationEmailRetryScheduler.java @@ -0,0 +1,80 @@ +package ceos.backend.infra.ses; + + +import ceos.backend.domain.application.domain.Application; +import ceos.backend.domain.application.domain.ApplicationAnswer; +import ceos.backend.domain.application.domain.ApplicationInterview; +import ceos.backend.domain.application.repository.ApplicationAnswerRepository; +import ceos.backend.domain.application.repository.ApplicationInterviewRepository; +import ceos.backend.domain.application.repository.ApplicationRepository; +import ceos.backend.infra.ses.domain.EmailSendHistory; +import ceos.backend.infra.ses.domain.EmailType; +import ceos.backend.infra.ses.domain.SendStatus; +import ceos.backend.infra.ses.repository.EmailSendHistoryRepository; +import java.util.List; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.data.domain.PageRequest; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; +import org.thymeleaf.context.Context; + +@Slf4j +@Component +@RequiredArgsConstructor +public class ApplicationEmailRetryScheduler { + private final EmailSendHistoryRepository emailSendHistoryRepository; + private final ApplicationRepository applicationRepository; + private final ApplicationAnswerRepository applicationAnswerRepository; + private final ApplicationInterviewRepository applicationInterviewRepository; + private final AwsSESMailGenerator awsSESMailGenerator; + private final AwsSESUtils awsSESUtils; + + @Value("${aws.ses.retry.max-count:5}") + private int maxRetryCount; + + @Scheduled(fixedDelayString = "${aws.ses.retry.fixed-delay-ms:60000}") + public void retryFailedApplicationEmails() { + final List failures = + emailSendHistoryRepository.findRetryTargets( + SendStatus.FAILURE, + EmailType.APPLICATION, + maxRetryCount, + PageRequest.of(0, 20)); + + if (failures.isEmpty()) { + return; + } + + for (EmailSendHistory history : failures) { + try { + final Application application = + applicationRepository + .findByEmail(history.getRecipientEmail()) + .orElseThrow( + () -> + new IllegalStateException( + "application not found by email")); + final List answers = + applicationAnswerRepository.findAllByApplication(application); + final List interviews = + applicationInterviewRepository.findAllByApplication(application); + final Context context = + awsSESMailGenerator.generateApplicationMailContext( + application, answers, interviews); + + awsSESUtils.retryEmailRequest(history, context); + } catch (Exception e) { + history.markRetryAttempt(); + history.markFailure(e.getMessage()); + emailSendHistoryRepository.save(history); + log.error( + "Failed to retry application email. historyId={}, recipient={}", + history.getId(), + history.getRecipientEmail(), + e); + } + } + } +} diff --git a/src/main/java/ceos/backend/infra/ses/AwsSESMailGenerator.java b/src/main/java/ceos/backend/infra/ses/AwsSESMailGenerator.java index 30beabd..19f5033 100644 --- a/src/main/java/ceos/backend/infra/ses/AwsSESMailGenerator.java +++ b/src/main/java/ceos/backend/infra/ses/AwsSESMailGenerator.java @@ -1,11 +1,11 @@ package ceos.backend.infra.ses; -import ceos.backend.domain.application.domain.ApplicationQuestion; -import ceos.backend.domain.application.domain.QuestionCategory; +import ceos.backend.domain.application.domain.*; import ceos.backend.domain.application.dto.request.CreateApplicationRequest; import ceos.backend.domain.application.exception.exceptions.QuestionNotFound; import ceos.backend.domain.application.vo.AnswerVo; +import ceos.backend.domain.application.vo.ApplicantInfoVo; import ceos.backend.domain.recruitment.domain.Recruitment; import ceos.backend.domain.recruitment.helper.RecruitmentHelper; import ceos.backend.global.common.dto.AwsSESMail; @@ -14,6 +14,7 @@ import ceos.backend.global.common.dto.ParsedDuration; import ceos.backend.global.common.dto.mail.*; import ceos.backend.global.common.entity.Part; +import ceos.backend.global.util.InterviewConvertor; import ceos.backend.global.util.InterviewDateTimeConvertor; import ceos.backend.global.util.ParsedDurationConvertor; import lombok.RequiredArgsConstructor; @@ -22,7 +23,6 @@ import java.time.format.DateTimeFormatter; import java.util.*; -import java.util.stream.Collectors; @Component @RequiredArgsConstructor @@ -32,71 +32,199 @@ public class AwsSESMailGenerator { public Context generateApplicationMailContext(AwsSESMail awsSESMail) { final CreateApplicationRequest request = awsSESMail.getCreateApplicationRequest(); - final List questions = awsSESMail.getApplicationQuestions(); - final String UUID = awsSESMail.getUUID(); + final Part part = request.getPart(); + final MailQuestionSection questionSection = + buildQuestionSectionFromRequest( + awsSESMail.getApplicationQuestions(), + request.getCommonAnswers(), + request.getPartAnswers(), + part); + + return createApplicationMailContext( + GreetInfo.of(request, awsSESMail.getGeneration()), + UuidInfo.of(request.getApplicantInfoVo(), awsSESMail.getUUID()), + PersonalInfo.from(request.getApplicantInfoVo()), + SchoolInfo.from(request.getApplicantInfoVo()), + CeosQuestionInfo.from(request), + CommonQuestionInfo.of( + questionSection.commonQuestions(), questionSection.commonAnswers()), + PartQuestionInfo.of( + part.getPart(), + questionSection.partQuestions(), + questionSection.partAnswers()), + buildInterviewDateInfo( + InterviewDateTimeConvertor.toStringDuration(request.getUnableTimes()))); + } + + public Context generateApplicationMailContext( + Application application, + List applicationAnswers, + List applicationInterviews) { + + final Part part = application.getApplicationDetail().getPart(); + final MailQuestionSection questionSection = + buildQuestionSectionFromSavedAnswers(applicationAnswers, part); + final List unableTimes = + applicationInterviews.stream() + .map(ApplicationInterview::getInterview) + .filter(Objects::nonNull) + .map(InterviewConvertor::interviewDateFormatter) + .toList(); + + final ApplicantInfoVo applicantInfoVo = ApplicantInfoVo.from(application); + + return createApplicationMailContext( + GreetInfo.builder() + .name(applicantInfoVo.getName()) + .generation( + Integer.toString( + application.getApplicationDetail().getGeneration())) + .build(), + UuidInfo.of(applicantInfoVo, application.getApplicantInfo().getUuid()), + PersonalInfo.from(applicantInfoVo), + SchoolInfo.from(applicantInfoVo), + CeosQuestionInfo.builder() + .otDate( + application + .getApplicationDetail() + .getOtDate() + .format(DateTimeFormatter.ISO_DATE)) + .demodayDate( + application + .getApplicationDetail() + .getDemodayDate() + .format(DateTimeFormatter.ISO_DATE)) + .otherActivities(application.getApplicationDetail().getOtherActivities()) + .build(), + CommonQuestionInfo.of( + questionSection.commonQuestions(), questionSection.commonAnswers()), + PartQuestionInfo.of( + part.getPart(), + questionSection.partQuestions(), + questionSection.partAnswers()), + buildInterviewDateInfo(unableTimes)); + } + + private MailQuestionSection buildQuestionSectionFromRequest( + List questions, + List commonAnswers, + List partAnswers, + Part part) { + List commonQ = new ArrayList<>(); + List commonA = new ArrayList<>(); + List partQ = new ArrayList<>(); + List partA = new ArrayList<>(); - List commonQ = new ArrayList<>(), commonA = new ArrayList<>(); questions.stream() - .filter(question -> question.getCategory() == QuestionCategory.COMMON) - .sorted(Comparator.comparing(ApplicationQuestion::getNumber)) + .sorted(Comparator.comparingInt(ApplicationQuestion::getNumber)) .forEach( question -> { - commonQ.add(generateQuestion(question)); - commonA.add(generateAnswer(request.getCommonAnswers(), question)); + if (isCommonQuestion(question)) { + commonQ.add(generateQuestion(question)); + commonA.add(generateAnswer(commonAnswers, question)); + return; + } + if (isPartQuestion(question, part)) { + partQ.add(generateQuestion(question)); + partA.add(generateAnswer(partAnswers, question)); + } }); - List partQ = new ArrayList<>(), partA = new ArrayList<>(); - final Part part = request.getPart(); - questions.stream() - .filter(question -> question.getCategory().toString().equals(part.toString())) - .sorted(Comparator.comparing(ApplicationQuestion::getNumber)) + return new MailQuestionSection(commonQ, commonA, partQ, partA); + } + + private MailQuestionSection buildQuestionSectionFromSavedAnswers( + List applicationAnswers, Part part) { + List commonQ = new ArrayList<>(); + List commonA = new ArrayList<>(); + List partQ = new ArrayList<>(); + List partA = new ArrayList<>(); + + applicationAnswers.stream() + .filter(answer -> answer.getApplicationQuestion() != null) + .sorted( + Comparator.comparingInt( + answer -> answer.getApplicationQuestion().getNumber())) .forEach( - question -> { - partQ.add(generateQuestion(question)); - partA.add(generateAnswer(request.getPartAnswers(), question)); + answer -> { + final ApplicationQuestion question = answer.getApplicationQuestion(); + if (isCommonQuestion(question)) { + commonQ.add(generateQuestion(question)); + commonA.add(answer.getAnswer()); + return; + } + if (isPartQuestion(question, part)) { + partQ.add(generateQuestion(question)); + partA.add(answer.getAnswer()); + } }); - final List unableTimes = - InterviewDateTimeConvertor.toStringDuration(request.getUnableTimes()); + return new MailQuestionSection(commonQ, commonA, partQ, partA); + } + + private InterviewDateInfo buildInterviewDateInfo(List unableTimes) { List parsedDurations = unableTimes.stream() .map(ParsedDurationConvertor::parsingDuration) - .sorted(Comparator.comparing(ParsedDuration::getDuration)) - .sorted(Comparator.comparing(ParsedDuration::getDate)) + .sorted( + Comparator.comparing(ParsedDuration::getDate) + .thenComparing(ParsedDuration::getDuration)) .toList(); List dates = - parsedDurations.stream() - .map(ParsedDuration::getDate) - .distinct() - .collect(Collectors.toList()); - - List> times = new ArrayList<>(); - dates.forEach( - date -> - times.add( - parsedDurations.stream() - .filter( - parsedDuration -> - Objects.equals( - parsedDuration.getDate(), date)) - .map(ParsedDuration::getDuration) - .collect(Collectors.toList()))); + parsedDurations.stream().map(ParsedDuration::getDate).distinct().toList(); + List> times = + dates.stream() + .map( + date -> + parsedDurations.stream() + .filter( + parsedDuration -> + Objects.equals( + parsedDuration.getDate(), + date)) + .map(ParsedDuration::getDuration) + .toList()) + .toList(); - Context context = new Context(); - context.setVariable("greetInfo", GreetInfo.of(request, awsSESMail.getGeneration())); - context.setVariable("uuidInfo", UuidInfo.of(request.getApplicantInfoVo(), UUID)); - context.setVariable("personalInfo", PersonalInfo.from(request.getApplicantInfoVo())); - context.setVariable("schoolInfo", SchoolInfo.from(request.getApplicantInfoVo())); - context.setVariable("ceosQuestionInfo", CeosQuestionInfo.from(request)); - context.setVariable("commonQuestionInfo", CommonQuestionInfo.of(commonQ, commonA)); - context.setVariable( - "partQuestionInfo", PartQuestionInfo.of(request.getPart().getPart(), partQ, partA)); - context.setVariable("interviewDateInfo", InterviewDateInfo.of(times, dates)); + return InterviewDateInfo.of(times, dates); + } + private Context createApplicationMailContext( + GreetInfo greetInfo, + UuidInfo uuidInfo, + PersonalInfo personalInfo, + SchoolInfo schoolInfo, + CeosQuestionInfo ceosQuestionInfo, + CommonQuestionInfo commonQuestionInfo, + PartQuestionInfo partQuestionInfo, + InterviewDateInfo interviewDateInfo) { + Context context = new Context(); + context.setVariable("greetInfo", greetInfo); + context.setVariable("uuidInfo", uuidInfo); + context.setVariable("personalInfo", personalInfo); + context.setVariable("schoolInfo", schoolInfo); + context.setVariable("ceosQuestionInfo", ceosQuestionInfo); + context.setVariable("commonQuestionInfo", commonQuestionInfo); + context.setVariable("partQuestionInfo", partQuestionInfo); + context.setVariable("interviewDateInfo", interviewDateInfo); return context; } + private boolean isCommonQuestion(ApplicationQuestion question) { + return question.getCategory() == QuestionCategory.COMMON; + } + + private boolean isPartQuestion(ApplicationQuestion question, Part part) { + return question.getCategory().name().equals(part.name()); + } + + private record MailQuestionSection( + List commonQuestions, + List commonAnswers, + List partQuestions, + List partAnswers) {} + private String generateQuestion(ApplicationQuestion applicationQuestion) { return applicationQuestion.getNumber() + " : " + applicationQuestion.getQuestion(); } @@ -155,11 +283,9 @@ private void addRecruitDateToContext(Context context, Recruitment recruitment) { DateTimeFormatter dateTimeFormatter = DateTimeFormatter.ofPattern("M월 d일 (E) HH:mm", Locale.KOREAN); - context.setVariable( - "startDateDoc", recruitment.getStartDateDoc().format(dateFormatter)); + context.setVariable("startDateDoc", recruitment.getStartDateDoc().format(dateFormatter)); context.setVariable("endDateDoc", recruitment.getEndDateDoc().format(dateTimeFormatter)); - context.setVariable( - "resultDateDoc", recruitment.getResultDateDoc().format(dateFormatter)); + context.setVariable("resultDateDoc", recruitment.getResultDateDoc().format(dateFormatter)); context.setVariable( "startDateInterview", recruitment.getStartDateInterview().format(dateFormatter)); context.setVariable( diff --git a/src/main/java/ceos/backend/infra/ses/AwsSESSendMailHandler.java b/src/main/java/ceos/backend/infra/ses/AwsSESSendMailHandler.java index d257172..e487a77 100644 --- a/src/main/java/ceos/backend/infra/ses/AwsSESSendMailHandler.java +++ b/src/main/java/ceos/backend/infra/ses/AwsSESSendMailHandler.java @@ -36,7 +36,8 @@ 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, EmailType.PASSWORD); + awsSesUtils.singleEmailRequest( + TO, SUBJECT, "sendPasswordMail", CONTEXT, EmailType.PASSWORD); } @EventListener(AwsSESRecruitMail.class) diff --git a/src/main/java/ceos/backend/infra/ses/AwsSESUtils.java b/src/main/java/ceos/backend/infra/ses/AwsSESUtils.java index b9a4035..2a186dc 100644 --- a/src/main/java/ceos/backend/infra/ses/AwsSESUtils.java +++ b/src/main/java/ceos/backend/infra/ses/AwsSESUtils.java @@ -4,6 +4,8 @@ import ceos.backend.infra.ses.domain.EmailSendHistory; import ceos.backend.infra.ses.domain.EmailType; import ceos.backend.infra.ses.repository.EmailSendHistoryRepository; +import java.util.Optional; +import java.util.concurrent.CompletableFuture; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Component; @@ -12,8 +14,6 @@ import software.amazon.awssdk.services.ses.SesAsyncClient; import software.amazon.awssdk.services.ses.model.*; -import java.util.concurrent.CompletableFuture; - @Slf4j @Component @RequiredArgsConstructor @@ -29,16 +29,45 @@ public void singleEmailRequest( final SendEmailRequest.Builder sendEmailRequestBuilder = SendEmailRequest.builder(); sendEmailRequestBuilder.destination(Destination.builder().toAddresses(to).build()); - SendEmailRequest request = sendEmailRequestBuilder - .message(newMessage(subject, html)) - .source("ceos@ceos-sinchon.com") - .build(); + SendEmailRequest request = + sendEmailRequestBuilder + .message(newMessage(subject, html)) + .source("ceos@ceos-sinchon.com") + .build(); CompletableFuture future = sesAsyncClient.sendEmail(request); saveHistory(to, subject, template, emailType, future); } + public void retryEmailRequest(EmailSendHistory history, Context context) { + try { + history.markRetryAttempt(); + emailSendHistoryRepository.save(history); + + final String html = templateEngine.process(history.getTemplateName(), context); + SendEmailRequest request = + SendEmailRequest.builder() + .destination( + Destination.builder() + .toAddresses(history.getRecipientEmail()) + .build()) + .message(newMessage(history.getSubject(), html)) + .source("ceos@ceos-sinchon.com") + .build(); + + CompletableFuture future = sesAsyncClient.sendEmail(request); + updateRetryHistory(history.getId(), future); + } catch (Exception e) { + log.error( + "Failed to enqueue retry email. historyId={}, recipient={}", + history.getId(), + history.getRecipientEmail(), + e); + updateRetryFailure(history.getId(), e); + } + } + private Message newMessage(String subject, String html) { final Content content = Content.builder().data(subject).build(); return Message.builder() @@ -47,14 +76,22 @@ private Message newMessage(String subject, String html) { .build(); } - private void saveHistory(String to, String subject, String template, EmailType emailType, CompletableFuture future) { + 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()); + log.info( + "Successfully sent email to: {}, messageId: {}", + to, + response.messageId()); saveSuccessHistory(to, subject, template, emailType, response.messageId()); } }); @@ -83,4 +120,54 @@ private void saveFailureHistory( } } + private void updateRetryHistory(Long historyId, CompletableFuture future) { + future.whenComplete( + (response, exception) -> { + if (exception != null) { + log.error("Failed to retry email. historyId={}", historyId, exception); + updateRetryFailure(historyId, exception); + } else { + updateRetrySuccess(historyId, response.messageId()); + } + }); + } + + private void updateRetrySuccess(Long historyId, String messageId) { + try { + Optional optionalHistory = + emailSendHistoryRepository.findById(historyId); + if (optionalHistory.isEmpty()) { + log.warn("Retry success history not found. historyId={}", historyId); + return; + } + + EmailSendHistory history = optionalHistory.get(); + history.markSuccess(messageId); + emailSendHistoryRepository.save(history); + log.info( + "Successfully retried email. historyId={}, recipient={}, messageId={}", + historyId, + history.getRecipientEmail(), + messageId); + } catch (Exception e) { + log.error("Failed to update retry success history. historyId={}", historyId, e); + } + } + + private void updateRetryFailure(Long historyId, Throwable exception) { + try { + Optional optionalHistory = + emailSendHistoryRepository.findById(historyId); + if (optionalHistory.isEmpty()) { + log.warn("Retry failure history not found. historyId={}", historyId); + return; + } + + EmailSendHistory history = optionalHistory.get(); + history.markFailure(exception.getMessage()); + emailSendHistoryRepository.save(history); + } catch (Exception e) { + log.error("Failed to update retry failure history. historyId={}", historyId, 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 index f704c28..1204ecd 100644 --- a/src/main/java/ceos/backend/infra/ses/domain/EmailSendHistory.java +++ b/src/main/java/ceos/backend/infra/ses/domain/EmailSendHistory.java @@ -3,6 +3,7 @@ import ceos.backend.global.common.entity.BaseEntity; import jakarta.persistence.*; +import java.time.LocalDateTime; import lombok.AccessLevel; import lombok.Builder; import lombok.Getter; @@ -14,7 +15,8 @@ @Table(name = "email_send_history") public class EmailSendHistory extends BaseEntity { - @Id @GeneratedValue(strategy = GenerationType.IDENTITY) + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; @Column(name = "recipient_email") @@ -40,6 +42,12 @@ public class EmailSendHistory extends BaseEntity { @Column(name = "error_message", length = 1000) private String errorMessage; + @Column(name = "retry_count") + private Integer retryCount; + + @Column(name = "last_retried_at") + private LocalDateTime lastRetriedAt; + @Builder(access = AccessLevel.PRIVATE) private EmailSendHistory( String recipientEmail, @@ -48,7 +56,9 @@ private EmailSendHistory( EmailType emailType, SendStatus sendStatus, String messageId, - String errorMessage) { + String errorMessage, + Integer retryCount, + LocalDateTime lastRetriedAt) { this.recipientEmail = recipientEmail; this.subject = subject; this.templateName = templateName; @@ -56,6 +66,8 @@ private EmailSendHistory( this.sendStatus = sendStatus; this.messageId = messageId; this.errorMessage = errorMessage; + this.retryCount = retryCount; + this.lastRetriedAt = lastRetriedAt; } public static EmailSendHistory createSuccess( @@ -71,6 +83,7 @@ public static EmailSendHistory createSuccess( .emailType(emailType) .sendStatus(SendStatus.SUCCESS) .messageId(messageId) + .retryCount(0) .build(); } @@ -87,6 +100,25 @@ public static EmailSendHistory createFailure( .emailType(emailType) .sendStatus(SendStatus.FAILURE) .errorMessage(errorMessage) + .retryCount(0) .build(); } + + public void markRetryAttempt() { + int currentRetryCount = this.retryCount == null ? 0 : this.retryCount; + this.retryCount = currentRetryCount + 1; + this.lastRetriedAt = LocalDateTime.now(); + } + + public void markSuccess(String messageId) { + this.sendStatus = SendStatus.SUCCESS; + this.messageId = messageId; + this.errorMessage = null; + } + + public void markFailure(String errorMessage) { + this.sendStatus = SendStatus.FAILURE; + this.errorMessage = errorMessage; + this.messageId = null; + } } diff --git a/src/main/java/ceos/backend/infra/ses/domain/EmailType.java b/src/main/java/ceos/backend/infra/ses/domain/EmailType.java index cab8025..bbb0899 100644 --- a/src/main/java/ceos/backend/infra/ses/domain/EmailType.java +++ b/src/main/java/ceos/backend/infra/ses/domain/EmailType.java @@ -1,6 +1,5 @@ package ceos.backend.infra.ses.domain; - public enum EmailType { APPLICATION, // 지원서 접수 확인 PASSWORD, // 임시 비밀번호 발급 diff --git a/src/main/java/ceos/backend/infra/ses/domain/SendStatus.java b/src/main/java/ceos/backend/infra/ses/domain/SendStatus.java index 5286d46..65721c1 100644 --- a/src/main/java/ceos/backend/infra/ses/domain/SendStatus.java +++ b/src/main/java/ceos/backend/infra/ses/domain/SendStatus.java @@ -1,6 +1,5 @@ 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 index 5644356..cdd23c7 100644 --- a/src/main/java/ceos/backend/infra/ses/repository/EmailSendHistoryRepository.java +++ b/src/main/java/ceos/backend/infra/ses/repository/EmailSendHistoryRepository.java @@ -2,6 +2,25 @@ import ceos.backend.infra.ses.domain.EmailSendHistory; +import ceos.backend.infra.ses.domain.EmailType; +import ceos.backend.infra.ses.domain.SendStatus; +import java.util.List; +import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; -public interface EmailSendHistoryRepository extends JpaRepository {} +public interface EmailSendHistoryRepository extends JpaRepository { + + @Query( + "select e from EmailSendHistory e " + + "where e.sendStatus = :sendStatus " + + "and e.emailType = :emailType " + + "and coalesce(e.retryCount, 0) < :maxRetryCount " + + "order by e.id asc") + List findRetryTargets( + @Param("sendStatus") SendStatus sendStatus, + @Param("emailType") EmailType emailType, + @Param("maxRetryCount") int maxRetryCount, + Pageable pageable); +} diff --git a/src/test/java/ceos/backend/domain/application/ApplicationControllerTest.java b/src/test/java/ceos/backend/domain/application/ApplicationControllerTest.java index e135090..32bc453 100644 --- a/src/test/java/ceos/backend/domain/application/ApplicationControllerTest.java +++ b/src/test/java/ceos/backend/domain/application/ApplicationControllerTest.java @@ -31,9 +31,12 @@ void getApplicationExcelCreationTime() throws Exception { void getApplicationsWithZeroStrings() throws Exception { Authentication authentication = new TestingAuthenticationToken(null, null, "ROLE_ADMIN"); - mockMvc.perform(MockMvcRequestBuilders - .get("/applications?part=&docPass=&finalPass=&applicantName=&pageNum=0&limit=7") - .with(SecurityMockMvcRequestPostProcessors.authentication(authentication))) + mockMvc.perform( + MockMvcRequestBuilders.get( + "/applications?part=&docPass=&finalPass=&applicantName=&pageNum=0&limit=7") + .with( + SecurityMockMvcRequestPostProcessors.authentication( + authentication))) .andExpect(MockMvcResultMatchers.status().isOk()); } @@ -42,9 +45,11 @@ void getApplicationsWithZeroStrings() throws Exception { void getApplicationsWithoutRequiredFalse() throws Exception { Authentication authentication = new TestingAuthenticationToken(null, null, "ROLE_ADMIN"); - mockMvc.perform(MockMvcRequestBuilders - .get("/applications?pageNum=0&limit=7") - .with(SecurityMockMvcRequestPostProcessors.authentication(authentication))) + mockMvc.perform( + MockMvcRequestBuilders.get("/applications?pageNum=0&limit=7") + .with( + SecurityMockMvcRequestPostProcessors.authentication( + authentication))) .andExpect(MockMvcResultMatchers.status().isOk()); } @@ -53,9 +58,12 @@ void getApplicationsWithoutRequiredFalse() throws Exception { void successGetApplications() throws Exception { Authentication authentication = new TestingAuthenticationToken(null, null, "ROLE_ADMIN"); - mockMvc.perform(MockMvcRequestBuilders - .get("/applications?part=PRODUCT&docPass=PASS&finalPass=FAIL&applicantName=&pageNum=0&limit=7") - .with(SecurityMockMvcRequestPostProcessors.authentication(authentication))) + mockMvc.perform( + MockMvcRequestBuilders.get( + "/applications?part=PRODUCT&docPass=PASS&finalPass=FAIL&applicantName=&pageNum=0&limit=7") + .with( + SecurityMockMvcRequestPostProcessors.authentication( + authentication))) .andExpect(MockMvcResultMatchers.status().isOk()); } @@ -64,9 +72,12 @@ void successGetApplications() throws Exception { void failGetApplications() throws Exception { Authentication authentication = new TestingAuthenticationToken(null, null, "ROLE_ADMIN"); - mockMvc.perform(MockMvcRequestBuilders - .get("/applications?part=기획&docPass=합격&finalPass=불합격&applicantName=&pageNum=0&limit=7") - .with(SecurityMockMvcRequestPostProcessors.authentication(authentication))) + mockMvc.perform( + MockMvcRequestBuilders.get( + "/applications?part=기획&docPass=합격&finalPass=불합격&applicantName=&pageNum=0&limit=7") + .with( + SecurityMockMvcRequestPostProcessors.authentication( + authentication))) .andExpect(MockMvcResultMatchers.status().is(400)); } }