diff --git a/src/main/java/org/ject/support/admin/mail/domain/MailDispatchJob.java b/src/main/java/org/ject/support/admin/mail/domain/MailDispatchJob.java new file mode 100644 index 00000000..f380b303 --- /dev/null +++ b/src/main/java/org/ject/support/admin/mail/domain/MailDispatchJob.java @@ -0,0 +1,162 @@ +package org.ject.support.admin.mail.domain; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import jakarta.persistence.UniqueConstraint; +import jakarta.persistence.Version; +import java.time.LocalDateTime; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; +import org.ject.support.admin.mail.exception.MailErrorCode; +import org.ject.support.admin.mail.exception.MailException; +import org.ject.support.domain.base.BaseTimeEntity; + +@Entity +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Table(name = "mail_dispatch_job", uniqueConstraints = @UniqueConstraint( + name = "uk_mail_dispatch_job_requester_key", + columnNames = {"requested_by_admin_id", "idempotency_key"})) +public class MailDispatchJob extends BaseTimeEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "scenario_id", nullable = false) + private Long scenarioId; + + @Column(name = "recruit_id", nullable = false) + private Long recruitId; + + @Column(name = "requested_by_admin_id", nullable = false) + private Long requestedByAdminId; + + @Column(name = "idempotency_key", nullable = false, length = 255) + private String idempotencyKey; + + @Enumerated(EnumType.STRING) + @Column(nullable = false, length = 30) + private MailDispatchJobStatus status; + + @Column(name = "target_count", nullable = false) + private int targetCount; + + @Column(name = "processing_count", nullable = false) + private int processingCount; + + @Column(name = "success_count", nullable = false) + private int successCount; + + @Column(name = "failed_count", nullable = false) + private int failedCount; + + @Column(name = "requested_at", nullable = false) + private LocalDateTime requestedAt; + + @Column(name = "subject_template", nullable = false, columnDefinition = "TEXT") + private String subjectTemplate; + + @Column(name = "body_template", nullable = false, columnDefinition = "TEXT") + private String bodyTemplate; + + @Column(name = "input_variables_json", columnDefinition = "TEXT") + private String inputVariablesJson; + + @Column(name = "started_at") + private LocalDateTime startedAt; + + @Column(name = "finished_at") + private LocalDateTime finishedAt; + + @Version + private Long version; + + private MailDispatchJob(Long scenarioId, + Long recruitId, + Long requestedByAdminId, + String idempotencyKey, + String subjectTemplate, + String bodyTemplate, + String inputVariablesJson, + int targetCount) { + this.scenarioId = scenarioId; + this.recruitId = recruitId; + this.requestedByAdminId = requestedByAdminId; + this.idempotencyKey = idempotencyKey; + this.subjectTemplate = subjectTemplate; + this.bodyTemplate = bodyTemplate; + this.inputVariablesJson = inputVariablesJson; + this.targetCount = targetCount; + this.status = MailDispatchJobStatus.REQUESTED; + this.requestedAt = LocalDateTime.now(); + } + + public static MailDispatchJob create(Long scenarioId, + Long recruitId, + Long requestedByAdminId, + String idempotencyKey, + String subjectTemplate, + String bodyTemplate, + String inputVariablesJson, + int targetCount) { + if (targetCount <= 0) { + throw new MailException(MailErrorCode.INVALID_DISPATCH_TARGET_COUNT); + } + return new MailDispatchJob( + scenarioId, + recruitId, + requestedByAdminId, + idempotencyKey, + subjectTemplate, + bodyTemplate, + inputVariablesJson, + targetCount + ); + } + + public void startProcessing() { + validateStatus(MailDispatchJobStatus.REQUESTED); + status = MailDispatchJobStatus.PROCESSING; + processingCount = targetCount; + startedAt = LocalDateTime.now(); + } + + public void recordSuccess() { + validateStatus(MailDispatchJobStatus.PROCESSING); + processingCount--; + successCount++; + finishIfCompleted(); + } + + public void recordFailure() { + validateStatus(MailDispatchJobStatus.PROCESSING); + processingCount--; + failedCount++; + finishIfCompleted(); + } + + private void finishIfCompleted() { + if (processingCount > 0) { + return; + } + // 모든 대상이 실패한 경우에만 작업을 실패로 마무리한다. + status = failedCount == targetCount + ? MailDispatchJobStatus.FAILED + : MailDispatchJobStatus.COMPLETED; + finishedAt = LocalDateTime.now(); + } + + private void validateStatus(MailDispatchJobStatus expectedStatus) { + if (status != expectedStatus) { + throw new MailException(MailErrorCode.INVALID_DISPATCH_JOB_STATUS); + } + } +} diff --git a/src/main/java/org/ject/support/admin/mail/domain/MailDispatchJobStatus.java b/src/main/java/org/ject/support/admin/mail/domain/MailDispatchJobStatus.java new file mode 100644 index 00000000..61ecb437 --- /dev/null +++ b/src/main/java/org/ject/support/admin/mail/domain/MailDispatchJobStatus.java @@ -0,0 +1,8 @@ +package org.ject.support.admin.mail.domain; + +public enum MailDispatchJobStatus { + REQUESTED, + PROCESSING, + COMPLETED, + FAILED +} diff --git a/src/main/java/org/ject/support/admin/mail/domain/MailDispatchTarget.java b/src/main/java/org/ject/support/admin/mail/domain/MailDispatchTarget.java new file mode 100644 index 00000000..656bdb8e --- /dev/null +++ b/src/main/java/org/ject/support/admin/mail/domain/MailDispatchTarget.java @@ -0,0 +1,86 @@ +package org.ject.support.admin.mail.domain; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; +import jakarta.persistence.Version; +import java.time.LocalDateTime; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; +import org.ject.support.admin.mail.exception.MailErrorCode; +import org.ject.support.admin.mail.exception.MailException; +import org.ject.support.domain.base.BaseTimeEntity; + +@Entity +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Table(name = "mail_dispatch_target") +public class MailDispatchTarget extends BaseTimeEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(fetch = FetchType.LAZY, optional = false) + @JoinColumn(name = "dispatch_job_id", nullable = false) + private MailDispatchJob dispatchJob; + + @Column(name = "apply_id", nullable = false) + private Long applyId; + + @Column(nullable = false, length = 255) + private String email; + + @Enumerated(EnumType.STRING) + @Column(nullable = false, length = 30) + private MailDispatchTargetStatus status; + + @Column(name = "failure_reason", columnDefinition = "TEXT") + private String failureReason; + + @Column(name = "sent_at") + private LocalDateTime sentAt; + + @Version + private Long version; + + private MailDispatchTarget(MailDispatchJob dispatchJob, Long applyId, String email) { + this.dispatchJob = dispatchJob; + this.applyId = applyId; + this.email = email; + this.status = MailDispatchTargetStatus.PENDING; + } + + public static MailDispatchTarget pending(MailDispatchJob dispatchJob, Long applyId, String email) { + return new MailDispatchTarget(dispatchJob, applyId, email); + } + + public void markSent() { + validatePending(); + status = MailDispatchTargetStatus.SENT; + sentAt = LocalDateTime.now(); + failureReason = null; + } + + public void markFailed(String failureReason) { + validatePending(); + status = MailDispatchTargetStatus.FAILED; + this.failureReason = failureReason; + sentAt = null; + } + + private void validatePending() { + if (status != MailDispatchTargetStatus.PENDING) { + throw new MailException(MailErrorCode.INVALID_DISPATCH_TARGET_STATUS); + } + } +} diff --git a/src/main/java/org/ject/support/admin/mail/domain/MailDispatchTargetStatus.java b/src/main/java/org/ject/support/admin/mail/domain/MailDispatchTargetStatus.java new file mode 100644 index 00000000..724b250e --- /dev/null +++ b/src/main/java/org/ject/support/admin/mail/domain/MailDispatchTargetStatus.java @@ -0,0 +1,7 @@ +package org.ject.support.admin.mail.domain; + +public enum MailDispatchTargetStatus { + PENDING, + SENT, + FAILED +} diff --git a/src/main/java/org/ject/support/admin/mail/exception/MailErrorCode.java b/src/main/java/org/ject/support/admin/mail/exception/MailErrorCode.java index f2f84676..e222fc6a 100644 --- a/src/main/java/org/ject/support/admin/mail/exception/MailErrorCode.java +++ b/src/main/java/org/ject/support/admin/mail/exception/MailErrorCode.java @@ -29,6 +29,8 @@ public enum MailErrorCode implements ErrorCode { TEST_MAIL_SEND_FAILURE(SERVICE_UNAVAILABLE, "MAIL-11", "테스트 메일 발송에 실패했습니다."), INVALID_VARIABLE_VALUE(BAD_REQUEST, "MAIL-12", "메일 입력 변수 값이 올바르지 않습니다."), UNRESOLVED_TEMPLATE_VARIABLE(BAD_REQUEST, "MAIL-13", "렌더링되지 않은 템플릿 변수가 있습니다."), + INVALID_DISPATCH_TARGET_STATUS(BAD_REQUEST, "MAIL-14", "현재 상태에서는 발송 대상 처리를 수행할 수 없습니다."), + INVALID_DISPATCH_TARGET_COUNT(BAD_REQUEST, "MAIL-15", "메일 발송 대상은 1~500명이어야 합니다."), ; private final HttpStatus httpStatus; diff --git a/src/main/java/org/ject/support/admin/mail/repository/MailDispatchJobRepository.java b/src/main/java/org/ject/support/admin/mail/repository/MailDispatchJobRepository.java new file mode 100644 index 00000000..54453bdf --- /dev/null +++ b/src/main/java/org/ject/support/admin/mail/repository/MailDispatchJobRepository.java @@ -0,0 +1,11 @@ +package org.ject.support.admin.mail.repository; + +import java.util.Optional; +import org.ject.support.admin.mail.domain.MailDispatchJob; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface MailDispatchJobRepository extends JpaRepository { + + Optional findByRequestedByAdminIdAndIdempotencyKey( + Long requestedByAdminId, String idempotencyKey); +} diff --git a/src/main/java/org/ject/support/admin/mail/repository/MailDispatchTargetRepository.java b/src/main/java/org/ject/support/admin/mail/repository/MailDispatchTargetRepository.java new file mode 100644 index 00000000..9ca21030 --- /dev/null +++ b/src/main/java/org/ject/support/admin/mail/repository/MailDispatchTargetRepository.java @@ -0,0 +1,13 @@ +package org.ject.support.admin.mail.repository; + +import java.util.List; +import java.util.Optional; +import org.ject.support.admin.mail.domain.MailDispatchTarget; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface MailDispatchTargetRepository extends JpaRepository { + + Optional findByDispatchJobIdAndApplyId(Long dispatchJobId, Long applyId); + + List findAllByDispatchJobIdOrderByIdAsc(Long dispatchJobId); +} diff --git a/src/main/resources/db/migration/V39__create_mail_dispatch_tables.sql b/src/main/resources/db/migration/V39__create_mail_dispatch_tables.sql new file mode 100644 index 00000000..51bf01b7 --- /dev/null +++ b/src/main/resources/db/migration/V39__create_mail_dispatch_tables.sql @@ -0,0 +1,45 @@ +CREATE TABLE mail_dispatch_job +( + id BIGINT AUTO_INCREMENT NOT NULL, + scenario_id BIGINT NOT NULL, + recruit_id BIGINT NOT NULL, + requested_by_admin_id BIGINT NOT NULL, + idempotency_key VARCHAR(255) NOT NULL, + status VARCHAR(30) NOT NULL, + target_count INT NOT NULL, + processing_count INT NOT NULL DEFAULT 0, + success_count INT NOT NULL DEFAULT 0, + failed_count INT NOT NULL DEFAULT 0, + requested_at datetime(6) NOT NULL, + subject_template TEXT NOT NULL, + body_template TEXT NOT NULL, + input_variables_json TEXT, + started_at datetime(6), + finished_at datetime(6), + version BIGINT NOT NULL DEFAULT 0, + created_at datetime(6), + updated_at datetime(6), + CONSTRAINT `PRIMARY` PRIMARY KEY (id), + CONSTRAINT uk_mail_dispatch_job_requester_key UNIQUE (requested_by_admin_id, idempotency_key) +) ENGINE = InnoDB; + +CREATE TABLE mail_dispatch_target +( + id BIGINT AUTO_INCREMENT NOT NULL, + dispatch_job_id BIGINT NOT NULL, + apply_id BIGINT NOT NULL, + email VARCHAR(255) NOT NULL, + status VARCHAR(30) NOT NULL, + failure_reason TEXT, + sent_at datetime(6), + version BIGINT NOT NULL DEFAULT 0, + created_at datetime(6), + updated_at datetime(6), + CONSTRAINT `PRIMARY` PRIMARY KEY (id), + CONSTRAINT fk_mail_dispatch_target_job + FOREIGN KEY (dispatch_job_id) REFERENCES mail_dispatch_job (id) ON DELETE CASCADE, + CONSTRAINT uk_mail_dispatch_target_job_apply UNIQUE (dispatch_job_id, apply_id) +) ENGINE = InnoDB; + +CREATE INDEX idx_mail_dispatch_target_job_status + ON mail_dispatch_target (dispatch_job_id, status); diff --git a/src/test/java/org/ject/support/admin/mail/domain/MailDispatchJobTest.java b/src/test/java/org/ject/support/admin/mail/domain/MailDispatchJobTest.java new file mode 100644 index 00000000..c53b780a --- /dev/null +++ b/src/test/java/org/ject/support/admin/mail/domain/MailDispatchJobTest.java @@ -0,0 +1,115 @@ +package org.ject.support.admin.mail.domain; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.DisplayName; +import org.ject.support.admin.mail.exception.MailErrorCode; +import org.ject.support.admin.mail.exception.MailException; + +class MailDispatchJobTest { + + @Test + @DisplayName("발송 작업을 생성하면 요청 상태와 대상 건수가 설정된다") + void 발송_작업을_생성하면_요청_상태와_대상_건수가_설정된다() { + // given + MailDispatchJob job = MailDispatchJob.create( + 1L, + 2L, + 3L, + "dispatch-key", + "제목 템플릿", + "본문 템플릿", + "{}", + 2); + + // when & then + assertThat(job.getScenarioId()).isEqualTo(1L); + assertThat(job.getRecruitId()).isEqualTo(2L); + assertThat(job.getRequestedByAdminId()).isEqualTo(3L); + assertThat(job.getIdempotencyKey()).isEqualTo("dispatch-key"); + assertThat(job.getStatus()).isEqualTo(MailDispatchJobStatus.REQUESTED); + assertThat(job.getTargetCount()).isEqualTo(2); + assertThat(job.getRequestedAt()).isNotNull(); + assertThat(job.getProcessingCount()).isZero(); + assertThat(job.getSuccessCount()).isZero(); + assertThat(job.getFailedCount()).isZero(); + } + + @Test + @DisplayName("발송 작업은 대상별 처리가 끝나면 성공 건수와 실패 건수를 기록한다") + void 발송_작업은_대상별_처리가_끝나면_성공_건수와_실패_건수를_기록한다() { + // given + MailDispatchJob job = createJob(2); + + // when + job.startProcessing(); + job.recordSuccess(); + job.recordFailure(); + + // then + assertThat(job.getStatus()).isEqualTo(MailDispatchJobStatus.COMPLETED); + assertThat(job.getProcessingCount()).isZero(); + assertThat(job.getSuccessCount()).isEqualTo(1); + assertThat(job.getFailedCount()).isEqualTo(1); + assertThat(job.getStartedAt()).isNotNull(); + assertThat(job.getFinishedAt()).isNotNull(); + } + + @Test + @DisplayName("모든 대상 발송에 실패하면 실패 상태가 된다") + void 모든_대상_발송에_실패하면_실패_상태가_된다() { + // given + MailDispatchJob job = createJob(2); + + // when + job.startProcessing(); + job.recordFailure(); + job.recordFailure(); + + // then + assertThat(job.getStatus()).isEqualTo(MailDispatchJobStatus.FAILED); + assertThat(job.getFailedCount()).isEqualTo(2); + assertThat(job.getFinishedAt()).isNotNull(); + } + + @Test + @DisplayName("처리가 끝난 발송 작업은 다시 처리할 수 없다") + void 처리가_끝난_발송_작업은_다시_처리할_수_없다() { + // given + MailDispatchJob job = createJob(1); + job.startProcessing(); + job.recordSuccess(); + + // when & then + assertThatThrownBy(job::startProcessing) + .isInstanceOf(MailException.class); + } + + @Test + @DisplayName("대상 건수가 0이면 발송 작업을 생성하지 않는다") + void 대상_건수가_0이면_발송_작업을_생성하지_않는다() { + // when & then + assertThatThrownBy(() -> MailDispatchJob.create( + 1L, 2L, 3L, "dispatch-key", "제목", "본문", "{}", 0)) + .isInstanceOf(MailException.class) + .extracting("errorCode") + .isEqualTo(MailErrorCode.INVALID_DISPATCH_TARGET_COUNT); + } + + @Test + @DisplayName("대상 건수가 음수이면 발송 작업을 생성하지 않는다") + void 대상_건수가_음수이면_발송_작업을_생성하지_않는다() { + // when & then + assertThatThrownBy(() -> MailDispatchJob.create( + 1L, 2L, 3L, "dispatch-key", "제목", "본문", "{}", -1)) + .isInstanceOf(MailException.class) + .extracting("errorCode") + .isEqualTo(MailErrorCode.INVALID_DISPATCH_TARGET_COUNT); + } + + private MailDispatchJob createJob(int targetCount) { + return MailDispatchJob.create(1L, 2L, 3L, "dispatch-key", "제목", "본문", "{}", targetCount); + } +} diff --git a/src/test/java/org/ject/support/admin/mail/domain/MailDispatchTargetTest.java b/src/test/java/org/ject/support/admin/mail/domain/MailDispatchTargetTest.java new file mode 100644 index 00000000..5c29a523 --- /dev/null +++ b/src/test/java/org/ject/support/admin/mail/domain/MailDispatchTargetTest.java @@ -0,0 +1,78 @@ +package org.ject.support.admin.mail.domain; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.DisplayName; +import org.ject.support.admin.mail.exception.MailException; + +class MailDispatchTargetTest { + + @Test + @DisplayName("발송 대상을 생성하면 대기 상태와 이메일 snapshot이 저장된다") + void 발송_대상을_생성하면_대기_상태와_이메일_snapshot이_저장된다() { + // given + MailDispatchJob job = MailDispatchJob.create(1L, 2L, 3L, "dispatch-key", "제목", "본문", "{}", 1); + + // when + MailDispatchTarget target = MailDispatchTarget.pending(job, 10L, "applicant@ject.kr"); + + // then + assertThat(target.getDispatchJob()).isSameAs(job); + assertThat(target.getApplyId()).isEqualTo(10L); + assertThat(target.getEmail()).isEqualTo("applicant@ject.kr"); + assertThat(target.getStatus()).isEqualTo(MailDispatchTargetStatus.PENDING); + } + + @Test + @DisplayName("발송 대상을 성공 상태로 변경한다") + void 발송_대상을_성공_상태로_변경한다() { + // given + MailDispatchTarget target = MailDispatchTarget.pending( + MailDispatchJob.create(1L, 2L, 3L, "dispatch-key", "제목", "본문", "{}", 1), + 10L, + "applicant@ject.kr"); + + // when + target.markSent(); + + // then + assertThat(target.getStatus()).isEqualTo(MailDispatchTargetStatus.SENT); + assertThat(target.getSentAt()).isNotNull(); + assertThat(target.getFailureReason()).isNull(); + } + + @Test + @DisplayName("발송 대상을 실패 상태와 실패 사유로 변경한다") + void 발송_대상을_실패_상태와_실패_사유로_변경한다() { + // given + MailDispatchTarget target = MailDispatchTarget.pending( + MailDispatchJob.create(1L, 2L, 3L, "dispatch-key", "제목", "본문", "{}", 1), + 10L, + "applicant@ject.kr"); + + // when + target.markFailed("이메일 전송에 실패했습니다."); + + // then + assertThat(target.getStatus()).isEqualTo(MailDispatchTargetStatus.FAILED); + assertThat(target.getFailureReason()).isEqualTo("이메일 전송에 실패했습니다."); + assertThat(target.getSentAt()).isNull(); + } + + @Test + @DisplayName("처리된 발송 대상은 다시 처리할 수 없다") + void 처리된_발송_대상은_다시_처리할_수_없다() { + // given + MailDispatchTarget target = MailDispatchTarget.pending( + MailDispatchJob.create(1L, 2L, 3L, "dispatch-key", "제목", "본문", "{}", 1), + 10L, + "applicant@ject.kr"); + target.markSent(); + + // when & then + assertThatThrownBy(target::markSent) + .isInstanceOf(MailException.class); + } +}