From fc6ce63df8b8fca2d13dc1156fe62f9edee359b3 Mon Sep 17 00:00:00 2001 From: You-Hyuk Date: Wed, 16 Sep 2026 17:35:17 +0900 Subject: [PATCH 01/15] =?UTF-8?q?[feat]=20=EC=A0=95=EC=B1=85=20=EB=B2=84?= =?UTF-8?q?=EC=A0=84=20=EB=93=B1=EB=A1=9D=20API=20=EB=B0=8F=20PolicyDocume?= =?UTF-8?q?nt=20=EB=8F=84=EB=A9=94=EC=9D=B8=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 이슈 #118: 약관 변경 이메일 고지 시스템의 기반이 되는 정책 버전 메타데이터(PolicyDocument)를 도입하고, 관리자가 새 정책 버전을 등록하는 POST /api/admin/policies API를 추가한다. 원문 전체는 프론트엔드가 SSOT로 관리하므로 detailUrl로만 연결한다. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01JNjhau1bE58LegkwKtPBq2 --- .../Backend/common/exception/ErrorCode.java | 3 + .../policy/controller/PolicyController.java | 32 +++++++ .../policy/dto/PolicyRegisterRequest.java | 30 ++++++ .../Backend/policy/dto/PolicyResponse.java | 27 ++++++ .../Backend/policy/entity/PolicyDocument.java | 49 ++++++++++ .../Backend/policy/entity/PolicyType.java | 5 + .../PolicyVersionDuplicateException.java | 11 +++ .../repository/PolicyDocumentRepository.java | 10 ++ .../Backend/policy/service/PolicyService.java | 39 ++++++++ .../migration/V30__create_policy_document.sql | 11 +++ .../policy/service/PolicyServiceTest.java | 94 +++++++++++++++++++ 11 files changed, 311 insertions(+) create mode 100644 src/main/java/com/Coming/Backend/policy/controller/PolicyController.java create mode 100644 src/main/java/com/Coming/Backend/policy/dto/PolicyRegisterRequest.java create mode 100644 src/main/java/com/Coming/Backend/policy/dto/PolicyResponse.java create mode 100644 src/main/java/com/Coming/Backend/policy/entity/PolicyDocument.java create mode 100644 src/main/java/com/Coming/Backend/policy/entity/PolicyType.java create mode 100644 src/main/java/com/Coming/Backend/policy/exception/PolicyVersionDuplicateException.java create mode 100644 src/main/java/com/Coming/Backend/policy/repository/PolicyDocumentRepository.java create mode 100644 src/main/java/com/Coming/Backend/policy/service/PolicyService.java create mode 100644 src/main/resources/db/migration/V30__create_policy_document.sql create mode 100644 src/test/java/com/Coming/Backend/policy/service/PolicyServiceTest.java diff --git a/src/main/java/com/Coming/Backend/common/exception/ErrorCode.java b/src/main/java/com/Coming/Backend/common/exception/ErrorCode.java index dfd5f653..071545fb 100644 --- a/src/main/java/com/Coming/Backend/common/exception/ErrorCode.java +++ b/src/main/java/com/Coming/Backend/common/exception/ErrorCode.java @@ -55,6 +55,9 @@ public enum ErrorCode { PIPELINE_SERVER_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "Data 파이프라인 서버 오류가 발생했습니다."), PIPELINE_TIMEOUT(HttpStatus.GATEWAY_TIMEOUT, "Data 파이프라인 응답이 지연되고 있습니다. 잠시 후 다시 확인해주세요."), + // Policy + POLICY_VERSION_DUPLICATE(HttpStatus.CONFLICT, "이미 등록된 정책 버전입니다."), + // Common RATE_LIMIT_EXCEEDED(HttpStatus.TOO_MANY_REQUESTS, "요청이 너무 많습니다. 잠시 후 다시 시도해주세요."), INVALID_INPUT(HttpStatus.BAD_REQUEST, "잘못된 입력값입니다."), diff --git a/src/main/java/com/Coming/Backend/policy/controller/PolicyController.java b/src/main/java/com/Coming/Backend/policy/controller/PolicyController.java new file mode 100644 index 00000000..ad7d817a --- /dev/null +++ b/src/main/java/com/Coming/Backend/policy/controller/PolicyController.java @@ -0,0 +1,32 @@ +package com.Coming.Backend.policy.controller; + +import com.Coming.Backend.policy.dto.PolicyRegisterRequest; +import com.Coming.Backend.policy.dto.PolicyResponse; +import com.Coming.Backend.policy.service.PolicyService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@Tag(name = "Policy") +@RestController +@RequestMapping("/api/admin/policies") +@RequiredArgsConstructor +public class PolicyController { + + private final PolicyService policyService; + + @Operation(summary = "정책 버전 등록") + @ApiResponse(responseCode = "409", description = "POLICY_VERSION_DUPLICATE") + @PostMapping + public ResponseEntity registerPolicy(@Valid @RequestBody PolicyRegisterRequest request) { + return ResponseEntity.status(HttpStatus.CREATED).body(policyService.registerPolicy(request)); + } +} diff --git a/src/main/java/com/Coming/Backend/policy/dto/PolicyRegisterRequest.java b/src/main/java/com/Coming/Backend/policy/dto/PolicyRegisterRequest.java new file mode 100644 index 00000000..84f05bc5 --- /dev/null +++ b/src/main/java/com/Coming/Backend/policy/dto/PolicyRegisterRequest.java @@ -0,0 +1,30 @@ +package com.Coming.Backend.policy.dto; + +import com.Coming.Backend.policy.entity.PolicyType; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; +import java.time.LocalDate; + +public record PolicyRegisterRequest( + @NotNull + PolicyType type, + + @NotBlank + @Size(max = 50) + String version, + + @NotNull + LocalDate effectiveDate, + + @NotBlank + String changeSummary, + + @NotBlank + @Size(max = 500) + String detailUrl, + + @NotNull + Boolean requiresReconsent +) { +} diff --git a/src/main/java/com/Coming/Backend/policy/dto/PolicyResponse.java b/src/main/java/com/Coming/Backend/policy/dto/PolicyResponse.java new file mode 100644 index 00000000..2a232cf7 --- /dev/null +++ b/src/main/java/com/Coming/Backend/policy/dto/PolicyResponse.java @@ -0,0 +1,27 @@ +package com.Coming.Backend.policy.dto; + +import com.Coming.Backend.policy.entity.PolicyDocument; +import com.Coming.Backend.policy.entity.PolicyType; +import java.time.LocalDate; + +public record PolicyResponse( + Long id, + PolicyType type, + String version, + LocalDate effectiveDate, + String changeSummary, + String detailUrl, + boolean requiresReconsent +) { + public static PolicyResponse from(PolicyDocument policyDocument) { + return new PolicyResponse( + policyDocument.getId(), + policyDocument.getType(), + policyDocument.getVersion(), + policyDocument.getEffectiveDate(), + policyDocument.getChangeSummary(), + policyDocument.getDetailUrl(), + policyDocument.isRequiresReconsent() + ); + } +} diff --git a/src/main/java/com/Coming/Backend/policy/entity/PolicyDocument.java b/src/main/java/com/Coming/Backend/policy/entity/PolicyDocument.java new file mode 100644 index 00000000..f7ac6a0d --- /dev/null +++ b/src/main/java/com/Coming/Backend/policy/entity/PolicyDocument.java @@ -0,0 +1,49 @@ +package com.Coming.Backend.policy.entity; + +import com.Coming.Backend.common.entity.BaseCreatedEntity; +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 java.time.LocalDate; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Table(name = "policy_document") +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor(access = AccessLevel.PRIVATE) +@Builder +public class PolicyDocument extends BaseCreatedEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Enumerated(EnumType.STRING) + @Column(name = "type", nullable = false, length = 20) + private PolicyType type; + + @Column(name = "version", nullable = false, length = 50) + private String version; + + @Column(name = "effective_date", nullable = false) + private LocalDate effectiveDate; + + @Column(name = "change_summary", nullable = false, columnDefinition = "text") + private String changeSummary; + + @Column(name = "detail_url", nullable = false, length = 500) + private String detailUrl; + + @Column(name = "requires_reconsent", nullable = false) + private boolean requiresReconsent; +} diff --git a/src/main/java/com/Coming/Backend/policy/entity/PolicyType.java b/src/main/java/com/Coming/Backend/policy/entity/PolicyType.java new file mode 100644 index 00000000..d6eaf4a6 --- /dev/null +++ b/src/main/java/com/Coming/Backend/policy/entity/PolicyType.java @@ -0,0 +1,5 @@ +package com.Coming.Backend.policy.entity; + +public enum PolicyType { + TERMS, PRIVACY +} diff --git a/src/main/java/com/Coming/Backend/policy/exception/PolicyVersionDuplicateException.java b/src/main/java/com/Coming/Backend/policy/exception/PolicyVersionDuplicateException.java new file mode 100644 index 00000000..ab706a51 --- /dev/null +++ b/src/main/java/com/Coming/Backend/policy/exception/PolicyVersionDuplicateException.java @@ -0,0 +1,11 @@ +package com.Coming.Backend.policy.exception; + +import com.Coming.Backend.common.exception.BusinessException; +import com.Coming.Backend.common.exception.ErrorCode; + +public class PolicyVersionDuplicateException extends BusinessException { + + public PolicyVersionDuplicateException() { + super(ErrorCode.POLICY_VERSION_DUPLICATE); + } +} diff --git a/src/main/java/com/Coming/Backend/policy/repository/PolicyDocumentRepository.java b/src/main/java/com/Coming/Backend/policy/repository/PolicyDocumentRepository.java new file mode 100644 index 00000000..5c0f3921 --- /dev/null +++ b/src/main/java/com/Coming/Backend/policy/repository/PolicyDocumentRepository.java @@ -0,0 +1,10 @@ +package com.Coming.Backend.policy.repository; + +import com.Coming.Backend.policy.entity.PolicyDocument; +import com.Coming.Backend.policy.entity.PolicyType; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface PolicyDocumentRepository extends JpaRepository { + + boolean existsByTypeAndVersion(PolicyType type, String version); +} diff --git a/src/main/java/com/Coming/Backend/policy/service/PolicyService.java b/src/main/java/com/Coming/Backend/policy/service/PolicyService.java new file mode 100644 index 00000000..f4ee7610 --- /dev/null +++ b/src/main/java/com/Coming/Backend/policy/service/PolicyService.java @@ -0,0 +1,39 @@ +package com.Coming.Backend.policy.service; + +import com.Coming.Backend.policy.dto.PolicyRegisterRequest; +import com.Coming.Backend.policy.dto.PolicyResponse; +import com.Coming.Backend.policy.entity.PolicyDocument; +import com.Coming.Backend.policy.exception.PolicyVersionDuplicateException; +import com.Coming.Backend.policy.repository.PolicyDocumentRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class PolicyService { + + private final PolicyDocumentRepository policyDocumentRepository; + + /** + * 새 정책 버전을 등록한다. + * + * @throws PolicyVersionDuplicateException 동일 type·version이 이미 등록된 경우 + */ + @Transactional + public PolicyResponse registerPolicy(PolicyRegisterRequest request) { + if (policyDocumentRepository.existsByTypeAndVersion(request.type(), request.version())) { + throw new PolicyVersionDuplicateException(); + } + PolicyDocument policyDocument = PolicyDocument.builder() + .type(request.type()) + .version(request.version()) + .effectiveDate(request.effectiveDate()) + .changeSummary(request.changeSummary()) + .detailUrl(request.detailUrl()) + .requiresReconsent(Boolean.TRUE.equals(request.requiresReconsent())) + .build(); + return PolicyResponse.from(policyDocumentRepository.save(policyDocument)); + } +} diff --git a/src/main/resources/db/migration/V30__create_policy_document.sql b/src/main/resources/db/migration/V30__create_policy_document.sql new file mode 100644 index 00000000..6e1be507 --- /dev/null +++ b/src/main/resources/db/migration/V30__create_policy_document.sql @@ -0,0 +1,11 @@ +CREATE TABLE policy_document ( + id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + type varchar(20) NOT NULL, + version varchar(50) NOT NULL, + effective_date date NOT NULL, + change_summary text NOT NULL, + detail_url varchar(500) NOT NULL, + requires_reconsent boolean NOT NULL DEFAULT false, + created_at timestamp, + UNIQUE (type, version) +); diff --git a/src/test/java/com/Coming/Backend/policy/service/PolicyServiceTest.java b/src/test/java/com/Coming/Backend/policy/service/PolicyServiceTest.java new file mode 100644 index 00000000..0917026e --- /dev/null +++ b/src/test/java/com/Coming/Backend/policy/service/PolicyServiceTest.java @@ -0,0 +1,94 @@ +package com.Coming.Backend.policy.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; + +import com.Coming.Backend.common.exception.ErrorCode; +import com.Coming.Backend.policy.dto.PolicyRegisterRequest; +import com.Coming.Backend.policy.dto.PolicyResponse; +import com.Coming.Backend.policy.entity.PolicyDocument; +import com.Coming.Backend.policy.entity.PolicyType; +import com.Coming.Backend.policy.exception.PolicyVersionDuplicateException; +import com.Coming.Backend.policy.repository.PolicyDocumentRepository; +import java.time.LocalDate; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +@ExtendWith(MockitoExtension.class) +class PolicyServiceTest { + + @InjectMocks + private PolicyService policyService; + + @Mock + private PolicyDocumentRepository policyDocumentRepository; + + private static final PolicyType POLICY_TYPE = PolicyType.TERMS; + private static final String VERSION = "1.0.0"; + + private PolicyRegisterRequest buildRequest() { + return new PolicyRegisterRequest( + POLICY_TYPE, + VERSION, + LocalDate.of(2026, 1, 1), + "이용약관 최초 등록", + "https://coming.example.com/policy/terms/1.0.0", + true + ); + } + + private PolicyDocument buildPolicyDocument(Long id, PolicyRegisterRequest request) { + return PolicyDocument.builder() + .id(id) + .type(request.type()) + .version(request.version()) + .effectiveDate(request.effectiveDate()) + .changeSummary(request.changeSummary()) + .detailUrl(request.detailUrl()) + .requiresReconsent(request.requiresReconsent()) + .build(); + } + + @Test + void should_return_policy_response_when_valid_request_given() { + // given + PolicyRegisterRequest request = buildRequest(); + PolicyDocument savedPolicyDocument = buildPolicyDocument(1L, request); + + given(policyDocumentRepository.existsByTypeAndVersion(POLICY_TYPE, VERSION)).willReturn(false); + given(policyDocumentRepository.save(any(PolicyDocument.class))).willReturn(savedPolicyDocument); + + // when + PolicyResponse result = policyService.registerPolicy(request); + + // then + assertThat(result.id()).isEqualTo(1L); + assertThat(result.type()).isEqualTo(POLICY_TYPE); + assertThat(result.version()).isEqualTo(VERSION); + assertThat(result.effectiveDate()).isEqualTo(request.effectiveDate()); + assertThat(result.changeSummary()).isEqualTo(request.changeSummary()); + assertThat(result.detailUrl()).isEqualTo(request.detailUrl()); + assertThat(result.requiresReconsent()).isTrue(); + } + + @Test + void should_throw_policy_version_duplicate_exception_when_same_type_and_version_already_exists() { + // given + PolicyRegisterRequest request = buildRequest(); + + given(policyDocumentRepository.existsByTypeAndVersion(POLICY_TYPE, VERSION)).willReturn(true); + + // when & then + assertThatThrownBy(() -> policyService.registerPolicy(request)) + .isInstanceOf(PolicyVersionDuplicateException.class) + .hasMessage(ErrorCode.POLICY_VERSION_DUPLICATE.getMessage()); + verify(policyDocumentRepository, never()).save(any(PolicyDocument.class)); + } +} From 53ac45f0a1798c32fd8ce1986ddb56f8e5f068c0 Mon Sep 17 00:00:00 2001 From: You-Hyuk Date: Wed, 16 Sep 2026 17:39:32 +0900 Subject: [PATCH 02/15] =?UTF-8?q?[feat]=20UserPolicyAgreement=20=EC=9D=B4?= =?UTF-8?q?=EB=A0=A5=20=ED=85=8C=EC=9D=B4=EB=B8=94=20=EB=B0=8F=20=EB=A0=88?= =?UTF-8?q?=EA=B1=B0=EC=8B=9C=20=EB=8F=99=EC=9D=98=20=EB=8D=B0=EC=9D=B4?= =?UTF-8?q?=ED=84=B0=20=EB=B0=B1=ED=95=84=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 이슈 #118: User의 agreed_terms/agreed_privacy boolean 필드를 대체할 UserPolicyAgreement(userId, policyId, agreedAt) 이력 테이블을 추가한다. 마이그레이션에서 레거시 PolicyDocument(TERMS/PRIVACY, version=legacy)를 시드하고, 기존에 agreed_terms/agreed_privacy=true였던 유저의 동의 이력을 agreed_at 기준으로 백필한다. 로컬 DB에 다른 브랜치의 V30/V31(post/comment 테이블)이 이미 적용되어 있어 정책 관련 마이그레이션을 V32/V33으로 재넘버링했다(V30__create_policy_document.sql 커밋 포함). Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01JNjhau1bE58LegkwKtPBq2 --- .../policy/entity/UserPolicyAgreement.java | 36 +++++++++++++++++++ .../UserPolicyAgreementRepository.java | 9 +++++ ...nt.sql => V32__create_policy_document.sql} | 0 .../V33__create_user_policy_agreement.sql | 22 ++++++++++++ 4 files changed, 67 insertions(+) create mode 100644 src/main/java/com/Coming/Backend/policy/entity/UserPolicyAgreement.java create mode 100644 src/main/java/com/Coming/Backend/policy/repository/UserPolicyAgreementRepository.java rename src/main/resources/db/migration/{V30__create_policy_document.sql => V32__create_policy_document.sql} (100%) create mode 100644 src/main/resources/db/migration/V33__create_user_policy_agreement.sql diff --git a/src/main/java/com/Coming/Backend/policy/entity/UserPolicyAgreement.java b/src/main/java/com/Coming/Backend/policy/entity/UserPolicyAgreement.java new file mode 100644 index 00000000..2c9158ca --- /dev/null +++ b/src/main/java/com/Coming/Backend/policy/entity/UserPolicyAgreement.java @@ -0,0 +1,36 @@ +package com.Coming.Backend.policy.entity; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import java.time.LocalDateTime; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Table(name = "user_policy_agreement") +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor(access = AccessLevel.PRIVATE) +@Builder +public class UserPolicyAgreement { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "user_id", nullable = false) + private Long userId; + + @Column(name = "policy_id", nullable = false) + private Long policyId; + + @Column(name = "agreed_at", nullable = false) + private LocalDateTime agreedAt; +} diff --git a/src/main/java/com/Coming/Backend/policy/repository/UserPolicyAgreementRepository.java b/src/main/java/com/Coming/Backend/policy/repository/UserPolicyAgreementRepository.java new file mode 100644 index 00000000..32f5ebb0 --- /dev/null +++ b/src/main/java/com/Coming/Backend/policy/repository/UserPolicyAgreementRepository.java @@ -0,0 +1,9 @@ +package com.Coming.Backend.policy.repository; + +import com.Coming.Backend.policy.entity.UserPolicyAgreement; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface UserPolicyAgreementRepository extends JpaRepository { + + boolean existsByUserIdAndPolicyId(Long userId, Long policyId); +} diff --git a/src/main/resources/db/migration/V30__create_policy_document.sql b/src/main/resources/db/migration/V32__create_policy_document.sql similarity index 100% rename from src/main/resources/db/migration/V30__create_policy_document.sql rename to src/main/resources/db/migration/V32__create_policy_document.sql diff --git a/src/main/resources/db/migration/V33__create_user_policy_agreement.sql b/src/main/resources/db/migration/V33__create_user_policy_agreement.sql new file mode 100644 index 00000000..402e0462 --- /dev/null +++ b/src/main/resources/db/migration/V33__create_user_policy_agreement.sql @@ -0,0 +1,22 @@ +CREATE TABLE user_policy_agreement ( + id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + user_id bigint NOT NULL, + policy_id bigint NOT NULL, + agreed_at timestamp NOT NULL, + UNIQUE (user_id, policy_id) +); + +INSERT INTO policy_document (type, version, effective_date, change_summary, detail_url, requires_reconsent, created_at) +VALUES + ('TERMS', 'legacy', '2020-01-01', '시스템 마이그레이션 이전 동의 이력 보존용 레코드입니다.', '', false, now()), + ('PRIVACY', 'legacy', '2020-01-01', '시스템 마이그레이션 이전 동의 이력 보존용 레코드입니다.', '', false, now()); + +INSERT INTO user_policy_agreement (user_id, policy_id, agreed_at) +SELECT id, (SELECT id FROM policy_document WHERE type = 'TERMS' AND version = 'legacy'), agreed_at +FROM "user" +WHERE agreed_terms = true AND agreed_at IS NOT NULL; + +INSERT INTO user_policy_agreement (user_id, policy_id, agreed_at) +SELECT id, (SELECT id FROM policy_document WHERE type = 'PRIVACY' AND version = 'legacy'), agreed_at +FROM "user" +WHERE agreed_privacy = true AND agreed_at IS NOT NULL; From 22785c21a26988c08e4f4f370f59d5b6b0eaf07e Mon Sep 17 00:00:00 2001 From: You-Hyuk Date: Wed, 16 Sep 2026 17:45:10 +0900 Subject: [PATCH 03/15] =?UTF-8?q?[feat]=20User=20=EC=95=BD=EA=B4=80=20?= =?UTF-8?q?=EB=8F=99=EC=9D=98=20boolean=20=ED=95=84=EB=93=9C=EB=A5=BC=20Us?= =?UTF-8?q?erPolicyAgreement=20=EC=9D=B4=EB=A0=A5=EC=9C=BC=EB=A1=9C=20?= =?UTF-8?q?=EC=A0=84=ED=99=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 이슈 #118: User.agreedTerms/agreedPrivacy/agreedAt 필드를 제거하고, 회원가입 완료 시 현재 시행 중인 TERMS/PRIVACY PolicyDocument에 대한 UserPolicyAgreement를 기록하도록 AuthService.register()를 리팩터링한다. 이미 동의 이력이 있으면 건너뛰어 탈퇴 후 재가입 시 유니크 제약 위반을 방지한다. 레거시 컬럼은 V34 마이그레이션으로 제거한다. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01JNjhau1bE58LegkwKtPBq2 --- .../com/Coming/Backend/auth/entity/User.java | 19 +-- .../Backend/auth/service/AuthService.java | 33 ++++- .../Backend/common/exception/ErrorCode.java | 1 + .../exception/PolicyNotFoundException.java | 11 ++ .../repository/PolicyDocumentRepository.java | 5 + .../V34__drop_legacy_agreement_columns.sql | 3 + .../auth/service/AuthServiceRegisterTest.java | 30 +++++ .../Backend/auth/service/AuthServiceTest.java | 121 +++++++++++++++++- 8 files changed, 196 insertions(+), 27 deletions(-) create mode 100644 src/main/java/com/Coming/Backend/policy/exception/PolicyNotFoundException.java create mode 100644 src/main/resources/db/migration/V34__drop_legacy_agreement_columns.sql diff --git a/src/main/java/com/Coming/Backend/auth/entity/User.java b/src/main/java/com/Coming/Backend/auth/entity/User.java index acb8070c..1cc5b810 100644 --- a/src/main/java/com/Coming/Backend/auth/entity/User.java +++ b/src/main/java/com/Coming/Backend/auth/entity/User.java @@ -9,7 +9,6 @@ import jakarta.persistence.GenerationType; import jakarta.persistence.Id; import jakarta.persistence.Table; -import java.time.LocalDateTime; import lombok.AccessLevel; import lombok.AllArgsConstructor; import lombok.Builder; @@ -51,26 +50,13 @@ public class User extends BaseTimeEntity { @Column(name = "birth_year") private Integer birthYear; - @Column(name = "agreed_terms") - private Boolean agreedTerms; - - @Column(name = "agreed_privacy") - private Boolean agreedPrivacy; - @Column(name = "agreed_marketing") private Boolean agreedMarketing; - @Column(name = "agreed_at") - private LocalDateTime agreedAt; - - public void completeRegistration(String nickname, int birthYear, - boolean agreedTerms, boolean agreedPrivacy, boolean agreedMarketing) { + public void completeRegistration(String nickname, int birthYear, boolean agreedMarketing) { this.nickname = nickname; this.birthYear = birthYear; - this.agreedTerms = agreedTerms; - this.agreedPrivacy = agreedPrivacy; this.agreedMarketing = agreedMarketing; - this.agreedAt = LocalDateTime.now(); this.role = UserRole.USER; } @@ -96,9 +82,6 @@ public void reactivate() { this.role = UserRole.PENDING; this.nickname = null; this.birthYear = null; - this.agreedTerms = null; - this.agreedPrivacy = null; this.agreedMarketing = null; - this.agreedAt = null; } } diff --git a/src/main/java/com/Coming/Backend/auth/service/AuthService.java b/src/main/java/com/Coming/Backend/auth/service/AuthService.java index 9439be4a..59b7687d 100644 --- a/src/main/java/com/Coming/Backend/auth/service/AuthService.java +++ b/src/main/java/com/Coming/Backend/auth/service/AuthService.java @@ -25,6 +25,14 @@ import com.Coming.Backend.auth.repository.UserRepository; import com.Coming.Backend.calendar.repository.UserConcertCalendarRepository; import com.Coming.Backend.inquiry.repository.InquiryRepository; +import com.Coming.Backend.policy.entity.PolicyDocument; +import com.Coming.Backend.policy.entity.PolicyType; +import com.Coming.Backend.policy.entity.UserPolicyAgreement; +import com.Coming.Backend.policy.exception.PolicyNotFoundException; +import com.Coming.Backend.policy.repository.PolicyDocumentRepository; +import com.Coming.Backend.policy.repository.UserPolicyAgreementRepository; +import java.time.LocalDate; +import java.time.LocalDateTime; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.dao.DataIntegrityViolationException; @@ -43,6 +51,8 @@ public class AuthService { private final UserFollowArtistRepository userFollowArtistRepository; private final UserConcertCalendarRepository userConcertCalendarRepository; private final InquiryRepository inquiryRepository; + private final PolicyDocumentRepository policyDocumentRepository; + private final UserPolicyAgreementRepository userPolicyAgreementRepository; /** * Refresh Token을 검증하고 새 Access Token과 새 Refresh Token을 발급한다. @@ -128,10 +138,10 @@ public TokenResponse register(Long userId, RegisterRequest request) { user.completeRegistration( request.nickname(), request.birthYear(), - request.agreedTerms(), - request.agreedPrivacy(), Boolean.TRUE.equals(request.agreedMarketing()) ); + recordPolicyAgreement(userId, PolicyType.TERMS); + recordPolicyAgreement(userId, PolicyType.PRIVACY); try { userRepository.flush(); } catch (DataIntegrityViolationException e) { @@ -142,6 +152,25 @@ public TokenResponse register(Long userId, RegisterRequest request) { return new TokenResponse(accessToken); } + /** + * 현재 시행 중인 정책 버전에 대한 사용자 동의 이력을 기록한다. 이미 동의 이력이 있으면 건너뛴다(탈퇴 후 재가입 대비). + */ + private void recordPolicyAgreement(Long userId, PolicyType type) { + PolicyDocument currentPolicy = policyDocumentRepository + .findFirstByTypeAndEffectiveDateLessThanEqualOrderByEffectiveDateDesc(type, LocalDate.now()) + .orElseThrow(PolicyNotFoundException::new); + if (userPolicyAgreementRepository.existsByUserIdAndPolicyId(userId, currentPolicy.getId())) { + return; + } + userPolicyAgreementRepository.save( + UserPolicyAgreement.builder() + .userId(userId) + .policyId(currentPolicy.getId()) + .agreedAt(LocalDateTime.now()) + .build() + ); + } + private void validateRegisterRequest(RegisterRequest request) { if (!Boolean.TRUE.equals(request.agreedTerms()) || !Boolean.TRUE.equals(request.agreedPrivacy())) { throw new TermsNotAgreedException(); diff --git a/src/main/java/com/Coming/Backend/common/exception/ErrorCode.java b/src/main/java/com/Coming/Backend/common/exception/ErrorCode.java index 071545fb..7a7cf9cc 100644 --- a/src/main/java/com/Coming/Backend/common/exception/ErrorCode.java +++ b/src/main/java/com/Coming/Backend/common/exception/ErrorCode.java @@ -57,6 +57,7 @@ public enum ErrorCode { // Policy POLICY_VERSION_DUPLICATE(HttpStatus.CONFLICT, "이미 등록된 정책 버전입니다."), + POLICY_NOT_FOUND(HttpStatus.NOT_FOUND, "존재하지 않는 정책입니다."), // Common RATE_LIMIT_EXCEEDED(HttpStatus.TOO_MANY_REQUESTS, "요청이 너무 많습니다. 잠시 후 다시 시도해주세요."), diff --git a/src/main/java/com/Coming/Backend/policy/exception/PolicyNotFoundException.java b/src/main/java/com/Coming/Backend/policy/exception/PolicyNotFoundException.java new file mode 100644 index 00000000..4318931c --- /dev/null +++ b/src/main/java/com/Coming/Backend/policy/exception/PolicyNotFoundException.java @@ -0,0 +1,11 @@ +package com.Coming.Backend.policy.exception; + +import com.Coming.Backend.common.exception.BusinessException; +import com.Coming.Backend.common.exception.ErrorCode; + +public class PolicyNotFoundException extends BusinessException { + + public PolicyNotFoundException() { + super(ErrorCode.POLICY_NOT_FOUND); + } +} diff --git a/src/main/java/com/Coming/Backend/policy/repository/PolicyDocumentRepository.java b/src/main/java/com/Coming/Backend/policy/repository/PolicyDocumentRepository.java index 5c0f3921..9b76acea 100644 --- a/src/main/java/com/Coming/Backend/policy/repository/PolicyDocumentRepository.java +++ b/src/main/java/com/Coming/Backend/policy/repository/PolicyDocumentRepository.java @@ -2,9 +2,14 @@ import com.Coming.Backend.policy.entity.PolicyDocument; import com.Coming.Backend.policy.entity.PolicyType; +import java.time.LocalDate; +import java.util.Optional; import org.springframework.data.jpa.repository.JpaRepository; public interface PolicyDocumentRepository extends JpaRepository { boolean existsByTypeAndVersion(PolicyType type, String version); + + Optional findFirstByTypeAndEffectiveDateLessThanEqualOrderByEffectiveDateDesc( + PolicyType type, LocalDate today); } diff --git a/src/main/resources/db/migration/V34__drop_legacy_agreement_columns.sql b/src/main/resources/db/migration/V34__drop_legacy_agreement_columns.sql new file mode 100644 index 00000000..2b2b9b13 --- /dev/null +++ b/src/main/resources/db/migration/V34__drop_legacy_agreement_columns.sql @@ -0,0 +1,3 @@ +ALTER TABLE "user" DROP COLUMN agreed_terms; +ALTER TABLE "user" DROP COLUMN agreed_privacy; +ALTER TABLE "user" DROP COLUMN agreed_at; diff --git a/src/test/java/com/Coming/Backend/auth/service/AuthServiceRegisterTest.java b/src/test/java/com/Coming/Backend/auth/service/AuthServiceRegisterTest.java index b3dd54f7..fa5d97bb 100644 --- a/src/test/java/com/Coming/Backend/auth/service/AuthServiceRegisterTest.java +++ b/src/test/java/com/Coming/Backend/auth/service/AuthServiceRegisterTest.java @@ -24,9 +24,15 @@ import com.Coming.Backend.calendar.repository.UserConcertCalendarRepository; import com.Coming.Backend.common.exception.ErrorCode; import com.Coming.Backend.inquiry.repository.InquiryRepository; +import com.Coming.Backend.policy.entity.PolicyDocument; +import com.Coming.Backend.policy.entity.PolicyType; +import com.Coming.Backend.policy.repository.PolicyDocumentRepository; +import com.Coming.Backend.policy.repository.UserPolicyAgreementRepository; +import java.time.LocalDate; import java.util.Optional; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentMatchers; import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; @@ -58,6 +64,12 @@ class AuthServiceRegisterTest { @Mock private InquiryRepository inquiryRepository; + @Mock + private PolicyDocumentRepository policyDocumentRepository; + + @Mock + private UserPolicyAgreementRepository userPolicyAgreementRepository; + private static final Long USER_ID = 1L; private static final String NEW_ACCESS_TOKEN = "new-access-token"; @@ -72,6 +84,18 @@ private User buildPendingUser() { .build(); } + private PolicyDocument buildPolicyDocument(PolicyType type) { + return PolicyDocument.builder() + .id(type == PolicyType.TERMS ? 1L : 2L) + .type(type) + .version("1.0.0") + .effectiveDate(LocalDate.of(2026, 1, 1)) + .changeSummary("변경 요약") + .detailUrl("https://coming.example.com/policy") + .requiresReconsent(false) + .build(); + } + // ------------------------------------------------------------------------- // register // ------------------------------------------------------------------------- @@ -83,6 +107,12 @@ void should_return_token_and_change_role_to_user_when_registration_is_valid() { User user = buildPendingUser(); given(userRepository.existsByNickname("IU")).willReturn(false); given(userRepository.findById(USER_ID)).willReturn(Optional.of(user)); + given(policyDocumentRepository.findFirstByTypeAndEffectiveDateLessThanEqualOrderByEffectiveDateDesc( + ArgumentMatchers.eq(PolicyType.TERMS), ArgumentMatchers.any(LocalDate.class))) + .willReturn(Optional.of(buildPolicyDocument(PolicyType.TERMS))); + given(policyDocumentRepository.findFirstByTypeAndEffectiveDateLessThanEqualOrderByEffectiveDateDesc( + ArgumentMatchers.eq(PolicyType.PRIVACY), ArgumentMatchers.any(LocalDate.class))) + .willReturn(Optional.of(buildPolicyDocument(PolicyType.PRIVACY))); given(jwtProvider.generateAccessToken(USER_ID, UserRole.USER.name())).willReturn(NEW_ACCESS_TOKEN); // when diff --git a/src/test/java/com/Coming/Backend/auth/service/AuthServiceTest.java b/src/test/java/com/Coming/Backend/auth/service/AuthServiceTest.java index 65fd9b21..b865b6b8 100644 --- a/src/test/java/com/Coming/Backend/auth/service/AuthServiceTest.java +++ b/src/test/java/com/Coming/Backend/auth/service/AuthServiceTest.java @@ -2,9 +2,14 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.assertj.core.groups.Tuple.tuple; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; import static org.mockito.BDDMockito.given; import static org.mockito.BDDMockito.willDoNothing; import static org.mockito.BDDMockito.willThrow; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; import com.Coming.Backend.auth.dto.MarketingUpdateRequest; @@ -29,9 +34,17 @@ import com.Coming.Backend.calendar.repository.UserConcertCalendarRepository; import com.Coming.Backend.common.exception.ErrorCode; import com.Coming.Backend.inquiry.repository.InquiryRepository; +import com.Coming.Backend.policy.entity.PolicyDocument; +import com.Coming.Backend.policy.entity.PolicyType; +import com.Coming.Backend.policy.entity.UserPolicyAgreement; +import com.Coming.Backend.policy.exception.PolicyNotFoundException; +import com.Coming.Backend.policy.repository.PolicyDocumentRepository; +import com.Coming.Backend.policy.repository.UserPolicyAgreementRepository; +import java.time.LocalDate; import java.util.Optional; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; @@ -63,6 +76,12 @@ class AuthServiceTest { @Mock private InquiryRepository inquiryRepository; + @Mock + private PolicyDocumentRepository policyDocumentRepository; + + @Mock + private UserPolicyAgreementRepository userPolicyAgreementRepository; + private static final Long USER_ID = 1L; private static final String REFRESH_TOKEN = "valid-refresh-token"; private static final String NEW_REFRESH_TOKEN = "new-refresh-token"; @@ -80,6 +99,28 @@ private User buildUser() { .build(); } + private User buildPendingUser() { + return User.builder() + .id(USER_ID) + .role(UserRole.PENDING) + .status(UserStatus.ACTIVE) + .provider("google") + .providerId("google-123") + .build(); + } + + private PolicyDocument buildPolicyDocument(Long policyId, PolicyType type) { + return PolicyDocument.builder() + .id(policyId) + .type(type) + .version("1.0") + .effectiveDate(LocalDate.now()) + .changeSummary("최초 시행") + .detailUrl("https://coming.example.com/policy") + .requiresReconsent(false) + .build(); + } + // ------------------------------------------------------------------------- // refreshToken // ------------------------------------------------------------------------- @@ -329,16 +370,13 @@ void should_return_me_response_with_updated_nickname_when_nickname_is_given() { @Test void should_throw_NicknameDuplicateException_when_nickname_conflict_occurs_on_register() { // given - User user = User.builder() - .id(USER_ID) - .role(UserRole.PENDING) - .status(UserStatus.ACTIVE) - .provider("google") - .providerId("google-123") - .build(); + User user = buildPendingUser(); RegisterRequest request = new RegisterRequest("IU", 1993, true, true, false); given(userRepository.findById(USER_ID)).willReturn(Optional.of(user)); given(userRepository.existsByNickname("IU")).willReturn(false); + given(policyDocumentRepository.findFirstByTypeAndEffectiveDateLessThanEqualOrderByEffectiveDateDesc( + any(PolicyType.class), any(LocalDate.class))) + .willReturn(Optional.of(buildPolicyDocument(10L, PolicyType.TERMS))); willThrow(DataIntegrityViolationException.class).given(userRepository).flush(); // when & then @@ -346,4 +384,73 @@ void should_throw_NicknameDuplicateException_when_nickname_conflict_occurs_on_re .isInstanceOf(NicknameDuplicateException.class) .hasMessage(ErrorCode.NICKNAME_DUPLICATE.getMessage()); } + + @Test + void should_save_user_policy_agreement_for_terms_and_privacy_when_register_succeeds() { + // given + User user = buildPendingUser(); + RegisterRequest request = new RegisterRequest("IU", 1993, true, true, false); + PolicyDocument termsPolicy = buildPolicyDocument(10L, PolicyType.TERMS); + PolicyDocument privacyPolicy = buildPolicyDocument(20L, PolicyType.PRIVACY); + given(userRepository.findById(USER_ID)).willReturn(Optional.of(user)); + given(userRepository.existsByNickname("IU")).willReturn(false); + given(policyDocumentRepository.findFirstByTypeAndEffectiveDateLessThanEqualOrderByEffectiveDateDesc( + eq(PolicyType.TERMS), any(LocalDate.class))) + .willReturn(Optional.of(termsPolicy)); + given(policyDocumentRepository.findFirstByTypeAndEffectiveDateLessThanEqualOrderByEffectiveDateDesc( + eq(PolicyType.PRIVACY), any(LocalDate.class))) + .willReturn(Optional.of(privacyPolicy)); + + // when + authService.register(USER_ID, request); + + // then + ArgumentCaptor captor = ArgumentCaptor.forClass(UserPolicyAgreement.class); + verify(userPolicyAgreementRepository, times(2)).save(captor.capture()); + assertThat(captor.getAllValues()) + .extracting(UserPolicyAgreement::getUserId, UserPolicyAgreement::getPolicyId) + .containsExactlyInAnyOrder(tuple(USER_ID, 10L), tuple(USER_ID, 20L)); + } + + @Test + void should_not_save_user_policy_agreement_when_agreement_already_exists() { + // given + User user = buildPendingUser(); + RegisterRequest request = new RegisterRequest("IU", 1993, true, true, false); + PolicyDocument termsPolicy = buildPolicyDocument(10L, PolicyType.TERMS); + PolicyDocument privacyPolicy = buildPolicyDocument(20L, PolicyType.PRIVACY); + given(userRepository.findById(USER_ID)).willReturn(Optional.of(user)); + given(userRepository.existsByNickname("IU")).willReturn(false); + given(policyDocumentRepository.findFirstByTypeAndEffectiveDateLessThanEqualOrderByEffectiveDateDesc( + eq(PolicyType.TERMS), any(LocalDate.class))) + .willReturn(Optional.of(termsPolicy)); + given(policyDocumentRepository.findFirstByTypeAndEffectiveDateLessThanEqualOrderByEffectiveDateDesc( + eq(PolicyType.PRIVACY), any(LocalDate.class))) + .willReturn(Optional.of(privacyPolicy)); + given(userPolicyAgreementRepository.existsByUserIdAndPolicyId(USER_ID, 10L)).willReturn(true); + given(userPolicyAgreementRepository.existsByUserIdAndPolicyId(USER_ID, 20L)).willReturn(true); + + // when + authService.register(USER_ID, request); + + // then + verify(userPolicyAgreementRepository, never()).save(any()); + } + + @Test + void should_throw_PolicyNotFoundException_when_no_current_policy_exists() { + // given + User user = buildPendingUser(); + RegisterRequest request = new RegisterRequest("IU", 1993, true, true, false); + given(userRepository.findById(USER_ID)).willReturn(Optional.of(user)); + given(userRepository.existsByNickname("IU")).willReturn(false); + given(policyDocumentRepository.findFirstByTypeAndEffectiveDateLessThanEqualOrderByEffectiveDateDesc( + eq(PolicyType.TERMS), any(LocalDate.class))) + .willReturn(Optional.empty()); + + // when & then + assertThatThrownBy(() -> authService.register(USER_ID, request)) + .isInstanceOf(PolicyNotFoundException.class) + .hasMessage(ErrorCode.POLICY_NOT_FOUND.getMessage()); + } } From 211551fc2a5489a838cb09f77bda58909f95bfa2 Mon Sep 17 00:00:00 2001 From: You-Hyuk Date: Wed, 16 Sep 2026 17:46:12 +0900 Subject: [PATCH 04/15] =?UTF-8?q?[feat]=20PolicyNotificationTarget=20?= =?UTF-8?q?=EB=8F=84=EB=A9=94=EC=9D=B8=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 이슈 #118: 정책 변경 이메일 발송 대상과 발송 상태(PENDING/SENT/FAILED), 재시도 횟수를 추적하는 PolicyNotificationTarget을 추가한다. 배치 Job이 이 테이블을 기준으로 대상 생성과 발송 상태 갱신을 수행한다(후속 커밋). Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01JNjhau1bE58LegkwKtPBq2 --- .../policy/entity/NotificationStatus.java | 5 ++ .../entity/PolicyNotificationTarget.java | 46 +++++++++++++++++++ .../PolicyNotificationTargetRepository.java | 7 +++ ...V35__create_policy_notification_target.sql | 10 ++++ 4 files changed, 68 insertions(+) create mode 100644 src/main/java/com/Coming/Backend/policy/entity/NotificationStatus.java create mode 100644 src/main/java/com/Coming/Backend/policy/entity/PolicyNotificationTarget.java create mode 100644 src/main/java/com/Coming/Backend/policy/repository/PolicyNotificationTargetRepository.java create mode 100644 src/main/resources/db/migration/V35__create_policy_notification_target.sql diff --git a/src/main/java/com/Coming/Backend/policy/entity/NotificationStatus.java b/src/main/java/com/Coming/Backend/policy/entity/NotificationStatus.java new file mode 100644 index 00000000..774b45cc --- /dev/null +++ b/src/main/java/com/Coming/Backend/policy/entity/NotificationStatus.java @@ -0,0 +1,5 @@ +package com.Coming.Backend.policy.entity; + +public enum NotificationStatus { + PENDING, SENT, FAILED +} diff --git a/src/main/java/com/Coming/Backend/policy/entity/PolicyNotificationTarget.java b/src/main/java/com/Coming/Backend/policy/entity/PolicyNotificationTarget.java new file mode 100644 index 00000000..f39b14d0 --- /dev/null +++ b/src/main/java/com/Coming/Backend/policy/entity/PolicyNotificationTarget.java @@ -0,0 +1,46 @@ +package com.Coming.Backend.policy.entity; + +import com.Coming.Backend.common.entity.BaseCreatedEntity; +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 java.time.LocalDateTime; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Table(name = "policy_notification_target") +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor(access = AccessLevel.PRIVATE) +@Builder +public class PolicyNotificationTarget extends BaseCreatedEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "policy_id", nullable = false) + private Long policyId; + + @Column(name = "user_id", nullable = false) + private Long userId; + + @Enumerated(EnumType.STRING) + @Column(name = "status", nullable = false, length = 20) + private NotificationStatus status; + + @Column(name = "sent_at") + private LocalDateTime sentAt; + + @Column(name = "retry_count", nullable = false) + private int retryCount; +} diff --git a/src/main/java/com/Coming/Backend/policy/repository/PolicyNotificationTargetRepository.java b/src/main/java/com/Coming/Backend/policy/repository/PolicyNotificationTargetRepository.java new file mode 100644 index 00000000..dd975dea --- /dev/null +++ b/src/main/java/com/Coming/Backend/policy/repository/PolicyNotificationTargetRepository.java @@ -0,0 +1,7 @@ +package com.Coming.Backend.policy.repository; + +import com.Coming.Backend.policy.entity.PolicyNotificationTarget; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface PolicyNotificationTargetRepository extends JpaRepository { +} diff --git a/src/main/resources/db/migration/V35__create_policy_notification_target.sql b/src/main/resources/db/migration/V35__create_policy_notification_target.sql new file mode 100644 index 00000000..4e01116d --- /dev/null +++ b/src/main/resources/db/migration/V35__create_policy_notification_target.sql @@ -0,0 +1,10 @@ +CREATE TABLE policy_notification_target ( + id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + policy_id bigint NOT NULL, + user_id bigint NOT NULL, + status varchar(20) NOT NULL DEFAULT 'PENDING', + sent_at timestamp, + retry_count integer NOT NULL DEFAULT 0, + created_at timestamp, + UNIQUE (policy_id, user_id) +); From 3c542e0cfb729a55b1c5845eee591fd90310ef6f Mon Sep 17 00:00:00 2001 From: You-Hyuk Date: Wed, 16 Sep 2026 17:49:54 +0900 Subject: [PATCH 05/15] =?UTF-8?q?[feat]=20=EC=A0=95=EC=B1=85=20=EB=B3=80?= =?UTF-8?q?=EA=B2=BD=20=EA=B3=A0=EC=A7=80=20=EB=A9=94=EC=9D=BC=20=EB=B0=9C?= =?UTF-8?q?=EC=86=A1=20=EC=9D=B8=ED=94=84=EB=9D=BC=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 이슈 #118: Gmail SMTP 기반 JavaMailSender 설정과 Thymeleaf 이메일 템플릿(policy-change-notice.html)을 추가한다. 법적 고지 목적상 수신거부 링크는 넣지 않고, 발신자 정보·변경사항 요약(changeSummary)· 시행일자를 본문에 필수로 노출한다. requiresReconsent인 경우 재동의 필요 안내를 추가로 표시한다. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01JNjhau1bE58LegkwKtPBq2 --- build.gradle | 2 + .../policy/mail/PolicyNoticeMailSender.java | 65 +++++++++ src/main/resources/application.yaml | 12 ++ .../templates/mail/policy-change-notice.html | 48 +++++++ .../mail/PolicyNoticeMailSenderTest.java | 133 ++++++++++++++++++ 5 files changed, 260 insertions(+) create mode 100644 src/main/java/com/Coming/Backend/policy/mail/PolicyNoticeMailSender.java create mode 100644 src/main/resources/templates/mail/policy-change-notice.html create mode 100644 src/test/java/com/Coming/Backend/policy/mail/PolicyNoticeMailSenderTest.java diff --git a/build.gradle b/build.gradle index 78e00387..08a77f29 100644 --- a/build.gradle +++ b/build.gradle @@ -29,6 +29,8 @@ dependencies { implementation 'org.springframework.boot:spring-boot-starter-security' implementation 'org.springframework.boot:spring-boot-starter-oauth2-client' implementation 'org.springframework.boot:spring-boot-starter-data-redis' + implementation 'org.springframework.boot:spring-boot-starter-mail' + implementation 'org.springframework.boot:spring-boot-starter-thymeleaf' implementation 'org.flywaydb:flyway-database-postgresql' implementation 'io.jsonwebtoken:jjwt-api:0.12.6' implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.8.6' diff --git a/src/main/java/com/Coming/Backend/policy/mail/PolicyNoticeMailSender.java b/src/main/java/com/Coming/Backend/policy/mail/PolicyNoticeMailSender.java new file mode 100644 index 00000000..e725d8dd --- /dev/null +++ b/src/main/java/com/Coming/Backend/policy/mail/PolicyNoticeMailSender.java @@ -0,0 +1,65 @@ +package com.Coming.Backend.policy.mail; + +import com.Coming.Backend.policy.entity.PolicyDocument; +import com.Coming.Backend.policy.entity.PolicyType; +import jakarta.mail.MessagingException; +import jakarta.mail.internet.MimeMessage; +import lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.mail.javamail.JavaMailSender; +import org.springframework.mail.javamail.MimeMessageHelper; +import org.springframework.stereotype.Component; +import org.thymeleaf.TemplateEngine; +import org.thymeleaf.context.Context; + +@Component +@RequiredArgsConstructor +public class PolicyNoticeMailSender { + + private static final String TEMPLATE_NAME = "mail/policy-change-notice"; + + private final JavaMailSender javaMailSender; + private final TemplateEngine templateEngine; + + @Value("${spring.mail.username}") + private String fromAddress; + + /** + * 정책 변경 고지 메일을 발송한다. + * + * @throws IllegalStateException 메일 메시지 생성에 실패한 경우 + */ + public void send(String toEmail, PolicyDocument policyDocument) { + try { + MimeMessage message = javaMailSender.createMimeMessage(); + MimeMessageHelper helper = new MimeMessageHelper(message, "UTF-8"); + helper.setTo(toEmail); + helper.setFrom(fromAddress); + helper.setSubject(buildSubject(policyDocument)); + helper.setText(renderHtml(policyDocument), true); + javaMailSender.send(message); + } catch (MessagingException e) { + throw new IllegalStateException("정책 변경 고지 메일 메시지 생성에 실패했습니다.", e); + } + } + + private String buildSubject(PolicyDocument policyDocument) { + return "[Coming] " + labelOf(policyDocument.getType()) + " 변경 안내"; + } + + private String renderHtml(PolicyDocument policyDocument) { + Context context = new Context(); + context.setVariable("policyTypeLabel", labelOf(policyDocument.getType())); + context.setVariable("version", policyDocument.getVersion()); + context.setVariable("effectiveDate", policyDocument.getEffectiveDate()); + context.setVariable("changeSummary", policyDocument.getChangeSummary()); + context.setVariable("detailUrl", policyDocument.getDetailUrl()); + context.setVariable("requiresReconsent", policyDocument.isRequiresReconsent()); + context.setVariable("fromAddress", fromAddress); + return templateEngine.process(TEMPLATE_NAME, context); + } + + private String labelOf(PolicyType type) { + return type == PolicyType.TERMS ? "이용약관" : "개인정보처리방침"; + } +} diff --git a/src/main/resources/application.yaml b/src/main/resources/application.yaml index 66b29c4a..4a24e9d7 100644 --- a/src/main/resources/application.yaml +++ b/src/main/resources/application.yaml @@ -61,6 +61,18 @@ spring: pageable: max-page-size: 50 + mail: + host: smtp.gmail.com + port: 587 + username: ${MAIL_USERNAME:} + password: ${MAIL_PASSWORD:} + properties: + mail: + smtp: + auth: true + starttls: + enable: true + jwt: secret: ${JWT_SECRET} access-token-expiry: 1800000 diff --git a/src/main/resources/templates/mail/policy-change-notice.html b/src/main/resources/templates/mail/policy-change-notice.html new file mode 100644 index 00000000..a6972b63 --- /dev/null +++ b/src/main/resources/templates/mail/policy-change-notice.html @@ -0,0 +1,48 @@ + + + + + 정책 변경 안내 + + +
+

+ 이용약관 변경 안내 +

+ +

+ 안녕하세요, Coming입니다.
+ 이용약관이 아래와 같이 변경되어 안내드립니다. +

+ + + + + + + + + + +
시행일자2026-01-01
변경 내용변경 내용 요약
+ +

+ 본 변경사항은 재동의가 필요한 사항입니다. 서비스 계속 이용을 위해 앱 또는 웹에서 재동의를 완료해주세요. +

+ +

+ 전체 원문은 아래 링크에서 확인하실 수 있습니다.
+ + https://coming.example.com/policy + +

+ +
+ +

+ 본 메일은 관련 법령에 따른 정책 변경 사전 고지 목적으로 발송되는 필수 안내이며, 수신거부가 적용되지 않습니다.
+ 발신: Coming (coming@example.com) +

+
+ + diff --git a/src/test/java/com/Coming/Backend/policy/mail/PolicyNoticeMailSenderTest.java b/src/test/java/com/Coming/Backend/policy/mail/PolicyNoticeMailSenderTest.java new file mode 100644 index 00000000..91668e60 --- /dev/null +++ b/src/test/java/com/Coming/Backend/policy/mail/PolicyNoticeMailSenderTest.java @@ -0,0 +1,133 @@ +package com.Coming.Backend.policy.mail; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.verify; + +import com.Coming.Backend.policy.entity.PolicyDocument; +import com.Coming.Backend.policy.entity.PolicyType; +import jakarta.mail.Session; +import jakarta.mail.internet.MimeMessage; +import java.time.LocalDate; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.mail.javamail.JavaMailSender; +import org.springframework.test.util.ReflectionTestUtils; +import org.thymeleaf.TemplateEngine; +import org.thymeleaf.context.Context; + +@ExtendWith(MockitoExtension.class) +class PolicyNoticeMailSenderTest { + + @InjectMocks + private PolicyNoticeMailSender policyNoticeMailSender; + + @Mock + private JavaMailSender javaMailSender; + + @Mock + private TemplateEngine templateEngine; + + private static final String FROM_ADDRESS = "coming@example.com"; + private static final String TO_EMAIL = "user@example.com"; + + private PolicyDocument buildPolicyDocument(PolicyType type) { + return PolicyDocument.builder() + .id(1L) + .type(type) + .version("1.0.0") + .effectiveDate(LocalDate.of(2026, 1, 1)) + .changeSummary("개인정보 수집 항목 변경") + .detailUrl("https://coming.example.com/policy/1.0.0") + .requiresReconsent(true) + .build(); + } + + @Test + void should_send_mail_when_valid_policy_document_given() { + // given + ReflectionTestUtils.setField(policyNoticeMailSender, "fromAddress", FROM_ADDRESS); + PolicyDocument policyDocument = buildPolicyDocument(PolicyType.TERMS); + + given(javaMailSender.createMimeMessage()).willReturn(new MimeMessage((Session) null)); + given(templateEngine.process(eq("mail/policy-change-notice"), any(Context.class))) + .willReturn("notice"); + + // when + policyNoticeMailSender.send(TO_EMAIL, policyDocument); + + // then + verify(templateEngine).process(eq("mail/policy-change-notice"), any(Context.class)); + verify(javaMailSender).send(any(MimeMessage.class)); + } + + @Test + void should_pass_policy_document_fields_to_template_context_when_send_called() { + // given + ReflectionTestUtils.setField(policyNoticeMailSender, "fromAddress", FROM_ADDRESS); + PolicyDocument policyDocument = buildPolicyDocument(PolicyType.PRIVACY); + + given(javaMailSender.createMimeMessage()).willReturn(new MimeMessage((Session) null)); + given(templateEngine.process(eq("mail/policy-change-notice"), any(Context.class))) + .willReturn("notice"); + + ArgumentCaptor contextCaptor = ArgumentCaptor.forClass(Context.class); + + // when + policyNoticeMailSender.send(TO_EMAIL, policyDocument); + + // then + verify(templateEngine).process(eq("mail/policy-change-notice"), contextCaptor.capture()); + Context capturedContext = contextCaptor.getValue(); + assertThat(capturedContext.getVariable("changeSummary")).isEqualTo(policyDocument.getChangeSummary()); + assertThat(capturedContext.getVariable("effectiveDate")).isEqualTo(policyDocument.getEffectiveDate()); + assertThat(capturedContext.getVariable("detailUrl")).isEqualTo(policyDocument.getDetailUrl()); + assertThat(capturedContext.getVariable("requiresReconsent")).isEqualTo(policyDocument.isRequiresReconsent()); + } + + @Test + void should_set_terms_label_when_policy_type_is_terms() { + // given + ReflectionTestUtils.setField(policyNoticeMailSender, "fromAddress", FROM_ADDRESS); + PolicyDocument policyDocument = buildPolicyDocument(PolicyType.TERMS); + + given(javaMailSender.createMimeMessage()).willReturn(new MimeMessage((Session) null)); + given(templateEngine.process(eq("mail/policy-change-notice"), any(Context.class))) + .willReturn("notice"); + + ArgumentCaptor contextCaptor = ArgumentCaptor.forClass(Context.class); + + // when + policyNoticeMailSender.send(TO_EMAIL, policyDocument); + + // then + verify(templateEngine).process(eq("mail/policy-change-notice"), contextCaptor.capture()); + assertThat(contextCaptor.getValue().getVariable("policyTypeLabel")).isEqualTo("이용약관"); + } + + @Test + void should_set_privacy_label_when_policy_type_is_privacy() { + // given + ReflectionTestUtils.setField(policyNoticeMailSender, "fromAddress", FROM_ADDRESS); + PolicyDocument policyDocument = buildPolicyDocument(PolicyType.PRIVACY); + + given(javaMailSender.createMimeMessage()).willReturn(new MimeMessage((Session) null)); + given(templateEngine.process(eq("mail/policy-change-notice"), any(Context.class))) + .willReturn("notice"); + + ArgumentCaptor contextCaptor = ArgumentCaptor.forClass(Context.class); + + // when + policyNoticeMailSender.send(TO_EMAIL, policyDocument); + + // then + verify(templateEngine).process(eq("mail/policy-change-notice"), contextCaptor.capture()); + assertThat(contextCaptor.getValue().getVariable("policyTypeLabel")).isEqualTo("개인정보처리방침"); + } +} From f88ab4ebaf4e923791d6a5803d1fa9ed4ef6222d Mon Sep 17 00:00:00 2001 From: You-Hyuk Date: Wed, 16 Sep 2026 17:55:33 +0900 Subject: [PATCH 06/15] =?UTF-8?q?[feat]=20=EC=A0=95=EC=B1=85=20=EC=95=8C?= =?UTF-8?q?=EB=A6=BC=20=EB=8C=80=EC=83=81=20=EC=83=9D=EC=84=B1=20Batch=20S?= =?UTF-8?q?tep1=20Tasklet=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 이슈 #118: Spring Batch를 도입하고, 정책 발행 시 ACTIVE 유저 중 이메일이 있고 아직 대상이 아닌 유저를 PENDING 상태의 PolicyNotificationTarget으로 등록하는 Tasklet을 추가한다. 배치 스키마는 Flyway(V36)로 관리하고, spring.batch.job.enabled=false로 앱 기동 시 자동 실행되지 않도록 한다(트리거는 후속 커밋). Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01JNjhau1bE58LegkwKtPBq2 --- build.gradle | 1 + .../auth/repository/UserRepository.java | 3 + ...licyNotificationTargetCreationTasklet.java | 62 +++++++ .../PolicyNotificationTargetRepository.java | 3 + src/main/resources/application.yaml | 6 + .../V36__create_spring_batch_schema.sql | 79 +++++++++ ...NotificationTargetCreationTaskletTest.java | 151 ++++++++++++++++++ 7 files changed, 305 insertions(+) create mode 100644 src/main/java/com/Coming/Backend/policy/batch/PolicyNotificationTargetCreationTasklet.java create mode 100644 src/main/resources/db/migration/V36__create_spring_batch_schema.sql create mode 100644 src/test/java/com/Coming/Backend/policy/batch/PolicyNotificationTargetCreationTaskletTest.java diff --git a/build.gradle b/build.gradle index 08a77f29..786eb837 100644 --- a/build.gradle +++ b/build.gradle @@ -31,6 +31,7 @@ dependencies { implementation 'org.springframework.boot:spring-boot-starter-data-redis' implementation 'org.springframework.boot:spring-boot-starter-mail' implementation 'org.springframework.boot:spring-boot-starter-thymeleaf' + implementation 'org.springframework.boot:spring-boot-starter-batch' implementation 'org.flywaydb:flyway-database-postgresql' implementation 'io.jsonwebtoken:jjwt-api:0.12.6' implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.8.6' diff --git a/src/main/java/com/Coming/Backend/auth/repository/UserRepository.java b/src/main/java/com/Coming/Backend/auth/repository/UserRepository.java index cf1542f0..d4626609 100644 --- a/src/main/java/com/Coming/Backend/auth/repository/UserRepository.java +++ b/src/main/java/com/Coming/Backend/auth/repository/UserRepository.java @@ -1,6 +1,7 @@ package com.Coming.Backend.auth.repository; import com.Coming.Backend.auth.entity.User; +import com.Coming.Backend.auth.entity.UserStatus; import org.springframework.data.jpa.repository.JpaRepository; import java.util.Collection; @@ -14,4 +15,6 @@ public interface UserRepository extends JpaRepository { List findAllByIdIn(Collection ids); boolean existsByNickname(String nickname); + + List findByStatus(UserStatus status); } diff --git a/src/main/java/com/Coming/Backend/policy/batch/PolicyNotificationTargetCreationTasklet.java b/src/main/java/com/Coming/Backend/policy/batch/PolicyNotificationTargetCreationTasklet.java new file mode 100644 index 00000000..0e229a81 --- /dev/null +++ b/src/main/java/com/Coming/Backend/policy/batch/PolicyNotificationTargetCreationTasklet.java @@ -0,0 +1,62 @@ +package com.Coming.Backend.policy.batch; + +import com.Coming.Backend.auth.entity.User; +import com.Coming.Backend.auth.entity.UserStatus; +import com.Coming.Backend.auth.repository.UserRepository; +import com.Coming.Backend.policy.entity.NotificationStatus; +import com.Coming.Backend.policy.entity.PolicyNotificationTarget; +import com.Coming.Backend.policy.repository.PolicyNotificationTargetRepository; +import java.util.List; +import java.util.Set; +import java.util.stream.Collectors; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.batch.core.scope.context.ChunkContext; +import org.springframework.batch.core.step.StepContribution; +import org.springframework.batch.core.step.tasklet.Tasklet; +import org.springframework.batch.infrastructure.repeat.RepeatStatus; +import org.springframework.stereotype.Component; + +/** + * 정책 발행 시 ACTIVE 유저 중 아직 발송 대상이 아닌 유저를 PENDING 상태의 PolicyNotificationTarget으로 등록한다. + */ +@Slf4j +@Component +@RequiredArgsConstructor +public class PolicyNotificationTargetCreationTasklet implements Tasklet { + + private final UserRepository userRepository; + private final PolicyNotificationTargetRepository policyNotificationTargetRepository; + + @Override + public RepeatStatus execute(StepContribution contribution, ChunkContext chunkContext) { + Long policyId = chunkContext.getStepContext().getStepExecution().getJobParameters().getLong("policyId"); + + Set existingTargetUserIds = policyNotificationTargetRepository.findByPolicyId(policyId).stream() + .map(PolicyNotificationTarget::getUserId) + .collect(Collectors.toSet()); + + List activeUsers = userRepository.findByStatus(UserStatus.ACTIVE); + int created = 0; + for (User user : activeUsers) { + if (existingTargetUserIds.contains(user.getId())) { + continue; + } + if (user.getEmail() == null || user.getEmail().isBlank()) { + log.warn("이메일이 없어 정책 알림 대상에서 제외 — userId: {}", user.getId()); + continue; + } + policyNotificationTargetRepository.save( + PolicyNotificationTarget.builder() + .policyId(policyId) + .userId(user.getId()) + .status(NotificationStatus.PENDING) + .retryCount(0) + .build() + ); + created++; + } + log.info("정책 알림 대상 생성 완료 — policyId: {}, 생성 건수: {}", policyId, created); + return RepeatStatus.FINISHED; + } +} diff --git a/src/main/java/com/Coming/Backend/policy/repository/PolicyNotificationTargetRepository.java b/src/main/java/com/Coming/Backend/policy/repository/PolicyNotificationTargetRepository.java index dd975dea..109c7beb 100644 --- a/src/main/java/com/Coming/Backend/policy/repository/PolicyNotificationTargetRepository.java +++ b/src/main/java/com/Coming/Backend/policy/repository/PolicyNotificationTargetRepository.java @@ -1,7 +1,10 @@ package com.Coming.Backend.policy.repository; import com.Coming.Backend.policy.entity.PolicyNotificationTarget; +import java.util.List; import org.springframework.data.jpa.repository.JpaRepository; public interface PolicyNotificationTargetRepository extends JpaRepository { + + List findByPolicyId(Long policyId); } diff --git a/src/main/resources/application.yaml b/src/main/resources/application.yaml index 4a24e9d7..72f5938a 100644 --- a/src/main/resources/application.yaml +++ b/src/main/resources/application.yaml @@ -73,6 +73,12 @@ spring: starttls: enable: true + batch: + job: + enabled: false + jdbc: + initialize-schema: never + jwt: secret: ${JWT_SECRET} access-token-expiry: 1800000 diff --git a/src/main/resources/db/migration/V36__create_spring_batch_schema.sql b/src/main/resources/db/migration/V36__create_spring_batch_schema.sql new file mode 100644 index 00000000..b52b1b23 --- /dev/null +++ b/src/main/resources/db/migration/V36__create_spring_batch_schema.sql @@ -0,0 +1,79 @@ +-- Spring Batch 6.0.3 공식 PostgreSQL 스키마(schema-postgresql.sql)를 그대로 사용한다. +-- spring.batch.jdbc.initialize-schema=never로 설정해 부트 자동 초기화 대신 Flyway로 관리한다. + +CREATE TABLE BATCH_JOB_INSTANCE ( + JOB_INSTANCE_ID BIGINT NOT NULL PRIMARY KEY, + VERSION BIGINT, + JOB_NAME VARCHAR(100) NOT NULL, + JOB_KEY VARCHAR(32) NOT NULL, + constraint JOB_INST_UN unique (JOB_NAME, JOB_KEY) +) ; + +CREATE TABLE BATCH_JOB_EXECUTION ( + JOB_EXECUTION_ID BIGINT NOT NULL PRIMARY KEY, + VERSION BIGINT, + JOB_INSTANCE_ID BIGINT NOT NULL, + CREATE_TIME TIMESTAMP NOT NULL, + START_TIME TIMESTAMP DEFAULT NULL, + END_TIME TIMESTAMP DEFAULT NULL, + STATUS VARCHAR(10), + EXIT_CODE VARCHAR(2500), + EXIT_MESSAGE VARCHAR(2500), + LAST_UPDATED TIMESTAMP, + constraint JOB_INST_EXEC_FK foreign key (JOB_INSTANCE_ID) + references BATCH_JOB_INSTANCE(JOB_INSTANCE_ID) +) ; + +CREATE TABLE BATCH_JOB_EXECUTION_PARAMS ( + JOB_EXECUTION_ID BIGINT NOT NULL, + PARAMETER_NAME VARCHAR(100) NOT NULL, + PARAMETER_TYPE VARCHAR(100) NOT NULL, + PARAMETER_VALUE VARCHAR(2500), + IDENTIFYING CHAR(1) NOT NULL, + constraint JOB_EXEC_PARAMS_FK foreign key (JOB_EXECUTION_ID) + references BATCH_JOB_EXECUTION(JOB_EXECUTION_ID) +) ; + +CREATE TABLE BATCH_STEP_EXECUTION ( + STEP_EXECUTION_ID BIGINT NOT NULL PRIMARY KEY, + VERSION BIGINT NOT NULL, + STEP_NAME VARCHAR(100) NOT NULL, + JOB_EXECUTION_ID BIGINT NOT NULL, + CREATE_TIME TIMESTAMP NOT NULL, + START_TIME TIMESTAMP DEFAULT NULL, + END_TIME TIMESTAMP DEFAULT NULL, + STATUS VARCHAR(10), + COMMIT_COUNT BIGINT, + READ_COUNT BIGINT, + FILTER_COUNT BIGINT, + WRITE_COUNT BIGINT, + READ_SKIP_COUNT BIGINT, + WRITE_SKIP_COUNT BIGINT, + PROCESS_SKIP_COUNT BIGINT, + ROLLBACK_COUNT BIGINT, + EXIT_CODE VARCHAR(2500), + EXIT_MESSAGE VARCHAR(2500), + LAST_UPDATED TIMESTAMP, + constraint JOB_EXEC_STEP_FK foreign key (JOB_EXECUTION_ID) + references BATCH_JOB_EXECUTION(JOB_EXECUTION_ID) +) ; + +CREATE TABLE BATCH_STEP_EXECUTION_CONTEXT ( + STEP_EXECUTION_ID BIGINT NOT NULL PRIMARY KEY, + SHORT_CONTEXT VARCHAR(2500) NOT NULL, + SERIALIZED_CONTEXT TEXT, + constraint STEP_EXEC_CTX_FK foreign key (STEP_EXECUTION_ID) + references BATCH_STEP_EXECUTION(STEP_EXECUTION_ID) +) ; + +CREATE TABLE BATCH_JOB_EXECUTION_CONTEXT ( + JOB_EXECUTION_ID BIGINT NOT NULL PRIMARY KEY, + SHORT_CONTEXT VARCHAR(2500) NOT NULL, + SERIALIZED_CONTEXT TEXT, + constraint JOB_EXEC_CTX_FK foreign key (JOB_EXECUTION_ID) + references BATCH_JOB_EXECUTION(JOB_EXECUTION_ID) +) ; + +CREATE SEQUENCE BATCH_STEP_EXECUTION_SEQ MAXVALUE 9223372036854775807 NO CYCLE; +CREATE SEQUENCE BATCH_JOB_EXECUTION_SEQ MAXVALUE 9223372036854775807 NO CYCLE; +CREATE SEQUENCE BATCH_JOB_INSTANCE_SEQ MAXVALUE 9223372036854775807 NO CYCLE; diff --git a/src/test/java/com/Coming/Backend/policy/batch/PolicyNotificationTargetCreationTaskletTest.java b/src/test/java/com/Coming/Backend/policy/batch/PolicyNotificationTargetCreationTaskletTest.java new file mode 100644 index 00000000..18a3702e --- /dev/null +++ b/src/test/java/com/Coming/Backend/policy/batch/PolicyNotificationTargetCreationTaskletTest.java @@ -0,0 +1,151 @@ +package com.Coming.Backend.policy.batch; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; + +import com.Coming.Backend.auth.entity.User; +import com.Coming.Backend.auth.entity.UserRole; +import com.Coming.Backend.auth.entity.UserStatus; +import com.Coming.Backend.auth.repository.UserRepository; +import com.Coming.Backend.policy.entity.NotificationStatus; +import com.Coming.Backend.policy.entity.PolicyNotificationTarget; +import com.Coming.Backend.policy.repository.PolicyNotificationTargetRepository; +import java.util.List; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.batch.core.job.JobExecution; +import org.springframework.batch.core.job.JobInstance; +import org.springframework.batch.core.job.parameters.JobParameters; +import org.springframework.batch.core.job.parameters.JobParametersBuilder; +import org.springframework.batch.core.scope.context.ChunkContext; +import org.springframework.batch.core.scope.context.StepContext; +import org.springframework.batch.core.step.StepContribution; +import org.springframework.batch.core.step.StepExecution; +import org.springframework.batch.infrastructure.repeat.RepeatStatus; + +@ExtendWith(MockitoExtension.class) +class PolicyNotificationTargetCreationTaskletTest { + + @InjectMocks + private PolicyNotificationTargetCreationTasklet policyNotificationTargetCreationTasklet; + + @Mock + private UserRepository userRepository; + + @Mock + private PolicyNotificationTargetRepository policyNotificationTargetRepository; + + private static final Long POLICY_ID = 1L; + + private User buildActiveUser(Long userId, String email) { + return User.builder() + .id(userId) + .email(email) + .provider("google") + .providerId("provider-" + userId) + .nickname("nickname" + userId) + .role(UserRole.USER) + .status(UserStatus.ACTIVE) + .birthYear(1995) + .agreedMarketing(true) + .build(); + } + + private ChunkContext buildChunkContext(Long policyId) { + JobParameters jobParameters = new JobParametersBuilder() + .addLong("policyId", policyId) + .toJobParameters(); + JobInstance jobInstance = new JobInstance(1L, "policyNotificationJob"); + JobExecution jobExecution = new JobExecution(1L, jobInstance, jobParameters); + StepExecution stepExecution = new StepExecution(1L, "targetCreationStep", jobExecution); + return new ChunkContext(new StepContext(stepExecution)); + } + + @Test + void should_save_pending_target_when_active_user_with_email_and_no_existing_target_given() { + // given + ChunkContext chunkContext = buildChunkContext(POLICY_ID); + StepContribution stepContribution = new StepContribution(chunkContext.getStepContext().getStepExecution()); + User user = buildActiveUser(1L, "iu@coming.com"); + + given(policyNotificationTargetRepository.findByPolicyId(POLICY_ID)).willReturn(List.of()); + given(userRepository.findByStatus(UserStatus.ACTIVE)).willReturn(List.of(user)); + + ArgumentCaptor targetCaptor = ArgumentCaptor.forClass(PolicyNotificationTarget.class); + + // when + policyNotificationTargetCreationTasklet.execute(stepContribution, chunkContext); + + // then + verify(policyNotificationTargetRepository).save(targetCaptor.capture()); + PolicyNotificationTarget savedTarget = targetCaptor.getValue(); + assertThat(savedTarget.getPolicyId()).isEqualTo(POLICY_ID); + assertThat(savedTarget.getUserId()).isEqualTo(1L); + assertThat(savedTarget.getStatus()).isEqualTo(NotificationStatus.PENDING); + assertThat(savedTarget.getRetryCount()).isZero(); + } + + @Test + void should_skip_user_when_target_already_exists_for_policy_given() { + // given + ChunkContext chunkContext = buildChunkContext(POLICY_ID); + StepContribution stepContribution = new StepContribution(chunkContext.getStepContext().getStepExecution()); + User user = buildActiveUser(1L, "iu@coming.com"); + PolicyNotificationTarget existingTarget = PolicyNotificationTarget.builder() + .id(10L) + .policyId(POLICY_ID) + .userId(1L) + .status(NotificationStatus.PENDING) + .retryCount(0) + .build(); + + given(policyNotificationTargetRepository.findByPolicyId(POLICY_ID)).willReturn(List.of(existingTarget)); + given(userRepository.findByStatus(UserStatus.ACTIVE)).willReturn(List.of(user)); + + // when + policyNotificationTargetCreationTasklet.execute(stepContribution, chunkContext); + + // then + verify(policyNotificationTargetRepository, never()).save(any(PolicyNotificationTarget.class)); + } + + @Test + void should_skip_user_when_email_is_null_given() { + // given + ChunkContext chunkContext = buildChunkContext(POLICY_ID); + StepContribution stepContribution = new StepContribution(chunkContext.getStepContext().getStepExecution()); + User user = buildActiveUser(1L, null); + + given(policyNotificationTargetRepository.findByPolicyId(POLICY_ID)).willReturn(List.of()); + given(userRepository.findByStatus(UserStatus.ACTIVE)).willReturn(List.of(user)); + + // when + policyNotificationTargetCreationTasklet.execute(stepContribution, chunkContext); + + // then + verify(policyNotificationTargetRepository, never()).save(any(PolicyNotificationTarget.class)); + } + + @Test + void should_return_finished_when_execute_completes_given() { + // given + ChunkContext chunkContext = buildChunkContext(POLICY_ID); + StepContribution stepContribution = new StepContribution(chunkContext.getStepContext().getStepExecution()); + + given(policyNotificationTargetRepository.findByPolicyId(POLICY_ID)).willReturn(List.of()); + given(userRepository.findByStatus(UserStatus.ACTIVE)).willReturn(List.of()); + + // when + RepeatStatus result = policyNotificationTargetCreationTasklet.execute(stepContribution, chunkContext); + + // then + assertThat(result).isEqualTo(RepeatStatus.FINISHED); + } +} From ce9ce77c783961866137d724d92b362fe9b02b9f Mon Sep 17 00:00:00 2001 From: You-Hyuk Date: Wed, 16 Sep 2026 18:05:01 +0900 Subject: [PATCH 07/15] =?UTF-8?q?[feat]=20=EC=A0=95=EC=B1=85=20=EC=95=8C?= =?UTF-8?q?=EB=A6=BC=20=EB=A9=94=EC=9D=BC=20=EB=B0=9C=EC=86=A1=20Batch=20S?= =?UTF-8?q?tep2=20=EB=B0=8F=20Job=20=EC=A1=B0=EB=A6=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 이슈 #118: PENDING 상태 PolicyNotificationTarget을 청크 단위로 읽어 메일을 발송하고 상태(SENT/FAILED)를 갱신하는 Step2를 추가하고, Step1(대상 생성)과 Step2(메일 발송)를 policyNotificationJob으로 조립한다. 발송 실패(SMTP 예외 포함)는 FAILED로 마킹되어 재시도 대상이 된다(재시도 스케줄러는 후속 커밋). 임시 DB에 V1~V36 전체 마이그레이션을 적용해 실제 앱을 기동, Job/Step/ Reader/Processor/Writer 빈 그래프가 정상 등록되는지 확인했다. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01JNjhau1bE58LegkwKtPBq2 --- .../batch/PolicyNotificationBatchConfig.java | 77 ++++++++ .../PolicyNotificationMailProcessor.java | 59 ++++++ .../batch/PolicyNotificationTargetWriter.java | 20 ++ .../entity/PolicyNotificationTarget.java | 10 + .../PolicyNotificationTargetRepository.java | 5 + .../PolicyNotificationMailProcessorTest.java | 173 ++++++++++++++++++ .../PolicyNotificationTargetWriterTest.java | 56 ++++++ 7 files changed, 400 insertions(+) create mode 100644 src/main/java/com/Coming/Backend/policy/batch/PolicyNotificationBatchConfig.java create mode 100644 src/main/java/com/Coming/Backend/policy/batch/PolicyNotificationMailProcessor.java create mode 100644 src/main/java/com/Coming/Backend/policy/batch/PolicyNotificationTargetWriter.java create mode 100644 src/test/java/com/Coming/Backend/policy/batch/PolicyNotificationMailProcessorTest.java create mode 100644 src/test/java/com/Coming/Backend/policy/batch/PolicyNotificationTargetWriterTest.java diff --git a/src/main/java/com/Coming/Backend/policy/batch/PolicyNotificationBatchConfig.java b/src/main/java/com/Coming/Backend/policy/batch/PolicyNotificationBatchConfig.java new file mode 100644 index 00000000..87af58e8 --- /dev/null +++ b/src/main/java/com/Coming/Backend/policy/batch/PolicyNotificationBatchConfig.java @@ -0,0 +1,77 @@ +package com.Coming.Backend.policy.batch; + +import com.Coming.Backend.policy.entity.NotificationStatus; +import com.Coming.Backend.policy.entity.PolicyNotificationTarget; +import com.Coming.Backend.policy.repository.PolicyNotificationTargetRepository; +import java.util.List; +import java.util.Map; +import lombok.RequiredArgsConstructor; +import org.springframework.batch.core.configuration.annotation.StepScope; +import org.springframework.batch.core.job.Job; +import org.springframework.batch.core.job.builder.JobBuilder; +import org.springframework.batch.core.job.parameters.RunIdIncrementer; +import org.springframework.batch.core.repository.JobRepository; +import org.springframework.batch.core.step.Step; +import org.springframework.batch.core.step.builder.StepBuilder; +import org.springframework.batch.infrastructure.item.data.RepositoryItemReader; +import org.springframework.batch.infrastructure.item.data.builder.RepositoryItemReaderBuilder; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.data.domain.Sort; +import org.springframework.transaction.PlatformTransactionManager; + +@Configuration +@RequiredArgsConstructor +public class PolicyNotificationBatchConfig { + + private static final int CHUNK_SIZE = 20; + + private final JobRepository jobRepository; + private final PlatformTransactionManager transactionManager; + private final PolicyNotificationTargetCreationTasklet policyNotificationTargetCreationTasklet; + private final PolicyNotificationTargetRepository policyNotificationTargetRepository; + private final PolicyNotificationMailProcessor policyNotificationMailProcessor; + private final PolicyNotificationTargetWriter policyNotificationTargetWriter; + + @Bean + public Job policyNotificationJob() { + return new JobBuilder("policyNotificationJob", jobRepository) + .incrementer(new RunIdIncrementer()) + .start(targetCreationStep()) + .next(mailSendStep()) + .build(); + } + + @Bean + public Step targetCreationStep() { + return new StepBuilder("targetCreationStep", jobRepository) + .tasklet(policyNotificationTargetCreationTasklet, transactionManager) + .build(); + } + + @Bean + public Step mailSendStep() { + return new StepBuilder("mailSendStep", jobRepository) + .chunk(CHUNK_SIZE) + .reader(mailSendItemReader(null)) + .processor(policyNotificationMailProcessor) + .writer(policyNotificationTargetWriter) + .transactionManager(transactionManager) + .build(); + } + + @Bean + @StepScope + public RepositoryItemReader mailSendItemReader( + @Value("#{jobParameters['policyId']}") Long policyId) { + return new RepositoryItemReaderBuilder() + .name("mailSendItemReader") + .repository(policyNotificationTargetRepository) + .methodName("findByPolicyIdAndStatus") + .arguments(List.of(policyId, NotificationStatus.PENDING)) + .sorts(Map.of("id", Sort.Direction.ASC)) + .pageSize(CHUNK_SIZE) + .build(); + } +} diff --git a/src/main/java/com/Coming/Backend/policy/batch/PolicyNotificationMailProcessor.java b/src/main/java/com/Coming/Backend/policy/batch/PolicyNotificationMailProcessor.java new file mode 100644 index 00000000..7ebafe4d --- /dev/null +++ b/src/main/java/com/Coming/Backend/policy/batch/PolicyNotificationMailProcessor.java @@ -0,0 +1,59 @@ +package com.Coming.Backend.policy.batch; + +import com.Coming.Backend.auth.entity.User; +import com.Coming.Backend.auth.repository.UserRepository; +import com.Coming.Backend.policy.entity.PolicyDocument; +import com.Coming.Backend.policy.entity.PolicyNotificationTarget; +import com.Coming.Backend.policy.exception.PolicyNotFoundException; +import com.Coming.Backend.policy.mail.PolicyNoticeMailSender; +import com.Coming.Backend.policy.repository.PolicyDocumentRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.batch.core.configuration.annotation.StepScope; +import org.springframework.batch.infrastructure.item.ItemProcessor; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +/** + * PENDING 상태의 PolicyNotificationTarget에 대해 정책 변경 고지 메일을 발송하고 상태(SENT/FAILED)를 갱신한다. + */ +@Slf4j +@Component +@StepScope +@RequiredArgsConstructor +public class PolicyNotificationMailProcessor implements ItemProcessor { + + private final UserRepository userRepository; + private final PolicyDocumentRepository policyDocumentRepository; + private final PolicyNoticeMailSender policyNoticeMailSender; + + @Value("#{jobParameters['policyId']}") + private Long policyId; + + private PolicyDocument policyDocument; + + @Override + public PolicyNotificationTarget process(PolicyNotificationTarget target) { + User user = userRepository.findById(target.getUserId()).orElse(null); + if (user == null || user.getEmail() == null || user.getEmail().isBlank()) { + log.warn("수신 이메일이 없어 발송 실패 처리 — targetId: {}", target.getId()); + target.markFailed(); + return target; + } + try { + policyNoticeMailSender.send(user.getEmail(), resolvePolicyDocument()); + target.markSent(); + } catch (Exception e) { + log.warn("정책 변경 고지 메일 발송 실패 — targetId: {}", target.getId(), e); + target.markFailed(); + } + return target; + } + + private PolicyDocument resolvePolicyDocument() { + if (policyDocument == null) { + policyDocument = policyDocumentRepository.findById(policyId).orElseThrow(PolicyNotFoundException::new); + } + return policyDocument; + } +} diff --git a/src/main/java/com/Coming/Backend/policy/batch/PolicyNotificationTargetWriter.java b/src/main/java/com/Coming/Backend/policy/batch/PolicyNotificationTargetWriter.java new file mode 100644 index 00000000..b9f946e0 --- /dev/null +++ b/src/main/java/com/Coming/Backend/policy/batch/PolicyNotificationTargetWriter.java @@ -0,0 +1,20 @@ +package com.Coming.Backend.policy.batch; + +import com.Coming.Backend.policy.entity.PolicyNotificationTarget; +import com.Coming.Backend.policy.repository.PolicyNotificationTargetRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.batch.infrastructure.item.Chunk; +import org.springframework.batch.infrastructure.item.ItemWriter; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +public class PolicyNotificationTargetWriter implements ItemWriter { + + private final PolicyNotificationTargetRepository policyNotificationTargetRepository; + + @Override + public void write(Chunk chunk) { + policyNotificationTargetRepository.saveAll(chunk.getItems()); + } +} diff --git a/src/main/java/com/Coming/Backend/policy/entity/PolicyNotificationTarget.java b/src/main/java/com/Coming/Backend/policy/entity/PolicyNotificationTarget.java index f39b14d0..a8aad822 100644 --- a/src/main/java/com/Coming/Backend/policy/entity/PolicyNotificationTarget.java +++ b/src/main/java/com/Coming/Backend/policy/entity/PolicyNotificationTarget.java @@ -43,4 +43,14 @@ public class PolicyNotificationTarget extends BaseCreatedEntity { @Column(name = "retry_count", nullable = false) private int retryCount; + + public void markSent() { + this.status = NotificationStatus.SENT; + this.sentAt = LocalDateTime.now(); + } + + public void markFailed() { + this.status = NotificationStatus.FAILED; + this.retryCount += 1; + } } diff --git a/src/main/java/com/Coming/Backend/policy/repository/PolicyNotificationTargetRepository.java b/src/main/java/com/Coming/Backend/policy/repository/PolicyNotificationTargetRepository.java index 109c7beb..74e5f65a 100644 --- a/src/main/java/com/Coming/Backend/policy/repository/PolicyNotificationTargetRepository.java +++ b/src/main/java/com/Coming/Backend/policy/repository/PolicyNotificationTargetRepository.java @@ -1,10 +1,15 @@ package com.Coming.Backend.policy.repository; +import com.Coming.Backend.policy.entity.NotificationStatus; import com.Coming.Backend.policy.entity.PolicyNotificationTarget; import java.util.List; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.JpaRepository; public interface PolicyNotificationTargetRepository extends JpaRepository { List findByPolicyId(Long policyId); + + Page findByPolicyIdAndStatus(Long policyId, NotificationStatus status, Pageable pageable); } diff --git a/src/test/java/com/Coming/Backend/policy/batch/PolicyNotificationMailProcessorTest.java b/src/test/java/com/Coming/Backend/policy/batch/PolicyNotificationMailProcessorTest.java new file mode 100644 index 00000000..2d92015f --- /dev/null +++ b/src/test/java/com/Coming/Backend/policy/batch/PolicyNotificationMailProcessorTest.java @@ -0,0 +1,173 @@ +package com.Coming.Backend.policy.batch; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.BDDMockito.given; +import static org.mockito.BDDMockito.willThrow; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; + +import com.Coming.Backend.auth.entity.User; +import com.Coming.Backend.auth.entity.UserRole; +import com.Coming.Backend.auth.entity.UserStatus; +import com.Coming.Backend.auth.repository.UserRepository; +import com.Coming.Backend.policy.entity.NotificationStatus; +import com.Coming.Backend.policy.entity.PolicyDocument; +import com.Coming.Backend.policy.entity.PolicyNotificationTarget; +import com.Coming.Backend.policy.entity.PolicyType; +import com.Coming.Backend.policy.mail.PolicyNoticeMailSender; +import com.Coming.Backend.policy.repository.PolicyDocumentRepository; +import java.time.LocalDate; +import java.util.Optional; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.test.util.ReflectionTestUtils; + +@ExtendWith(MockitoExtension.class) +class PolicyNotificationMailProcessorTest { + + @InjectMocks + private PolicyNotificationMailProcessor policyNotificationMailProcessor; + + @Mock + private UserRepository userRepository; + + @Mock + private PolicyDocumentRepository policyDocumentRepository; + + @Mock + private PolicyNoticeMailSender policyNoticeMailSender; + + private static final Long POLICY_ID = 1L; + + private User buildUser(Long userId, String email) { + return User.builder() + .id(userId) + .email(email) + .provider("google") + .providerId("provider-" + userId) + .nickname("nickname" + userId) + .role(UserRole.USER) + .status(UserStatus.ACTIVE) + .birthYear(1995) + .agreedMarketing(true) + .build(); + } + + private PolicyDocument buildPolicyDocument() { + return PolicyDocument.builder() + .id(POLICY_ID) + .type(PolicyType.TERMS) + .version("1.0") + .effectiveDate(LocalDate.of(2026, 1, 1)) + .changeSummary("이용약관 개정") + .detailUrl("https://coming.com/policy/terms/1.0") + .requiresReconsent(false) + .build(); + } + + private PolicyNotificationTarget buildTarget(Long userId) { + return PolicyNotificationTarget.builder() + .id(10L) + .policyId(POLICY_ID) + .userId(userId) + .status(NotificationStatus.PENDING) + .retryCount(0) + .build(); + } + + @Test + void should_mark_sent_when_user_with_email_given() { + // given + ReflectionTestUtils.setField(policyNotificationMailProcessor, "policyId", POLICY_ID); + PolicyNotificationTarget target = buildTarget(1L); + User user = buildUser(1L, "iu@coming.com"); + PolicyDocument policyDocument = buildPolicyDocument(); + + given(userRepository.findById(1L)).willReturn(Optional.of(user)); + given(policyDocumentRepository.findById(POLICY_ID)).willReturn(Optional.of(policyDocument)); + + // when + PolicyNotificationTarget result = policyNotificationMailProcessor.process(target); + + // then + assertThat(result.getStatus()).isEqualTo(NotificationStatus.SENT); + assertThat(result.getSentAt()).isNotNull(); + verify(policyNoticeMailSender).send("iu@coming.com", policyDocument); + } + + @Test + void should_mark_failed_when_user_not_found_given() { + // given + ReflectionTestUtils.setField(policyNotificationMailProcessor, "policyId", POLICY_ID); + PolicyNotificationTarget target = buildTarget(1L); + + given(userRepository.findById(1L)).willReturn(Optional.empty()); + + // when + PolicyNotificationTarget result = policyNotificationMailProcessor.process(target); + + // then + assertThat(result.getStatus()).isEqualTo(NotificationStatus.FAILED); + verify(policyNoticeMailSender, never()).send(any(), any()); + } + + @Test + void should_mark_failed_when_user_email_is_null_given() { + // given + ReflectionTestUtils.setField(policyNotificationMailProcessor, "policyId", POLICY_ID); + PolicyNotificationTarget target = buildTarget(1L); + User user = buildUser(1L, null); + + given(userRepository.findById(1L)).willReturn(Optional.of(user)); + + // when + PolicyNotificationTarget result = policyNotificationMailProcessor.process(target); + + // then + assertThat(result.getStatus()).isEqualTo(NotificationStatus.FAILED); + verify(policyNoticeMailSender, never()).send(any(), any()); + } + + @Test + void should_mark_failed_and_increment_retry_count_when_mail_send_throws_exception_given() { + // given + ReflectionTestUtils.setField(policyNotificationMailProcessor, "policyId", POLICY_ID); + PolicyNotificationTarget target = buildTarget(1L); + User user = buildUser(1L, "iu@coming.com"); + PolicyDocument policyDocument = buildPolicyDocument(); + + given(userRepository.findById(1L)).willReturn(Optional.of(user)); + given(policyDocumentRepository.findById(POLICY_ID)).willReturn(Optional.of(policyDocument)); + willThrow(new RuntimeException("smtp down")).given(policyNoticeMailSender).send("iu@coming.com", policyDocument); + + // when + PolicyNotificationTarget result = policyNotificationMailProcessor.process(target); + + // then + assertThat(result.getStatus()).isEqualTo(NotificationStatus.FAILED); + assertThat(result.getRetryCount()).isEqualTo(1); + } + + @Test + void should_call_policy_document_repository_only_once_when_process_called_twice_given() { + // given + ReflectionTestUtils.setField(policyNotificationMailProcessor, "policyId", POLICY_ID); + User user = buildUser(1L, "iu@coming.com"); + PolicyDocument policyDocument = buildPolicyDocument(); + + given(userRepository.findById(1L)).willReturn(Optional.of(user)); + given(policyDocumentRepository.findById(POLICY_ID)).willReturn(Optional.of(policyDocument)); + + // when + policyNotificationMailProcessor.process(buildTarget(1L)); + policyNotificationMailProcessor.process(buildTarget(1L)); + + // then + verify(policyDocumentRepository, times(1)).findById(POLICY_ID); + } +} diff --git a/src/test/java/com/Coming/Backend/policy/batch/PolicyNotificationTargetWriterTest.java b/src/test/java/com/Coming/Backend/policy/batch/PolicyNotificationTargetWriterTest.java new file mode 100644 index 00000000..1d3f3eb1 --- /dev/null +++ b/src/test/java/com/Coming/Backend/policy/batch/PolicyNotificationTargetWriterTest.java @@ -0,0 +1,56 @@ +package com.Coming.Backend.policy.batch; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.verify; + +import com.Coming.Backend.policy.entity.NotificationStatus; +import com.Coming.Backend.policy.entity.PolicyNotificationTarget; +import com.Coming.Backend.policy.repository.PolicyNotificationTargetRepository; +import java.util.List; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.batch.infrastructure.item.Chunk; + +@ExtendWith(MockitoExtension.class) +class PolicyNotificationTargetWriterTest { + + @InjectMocks + private PolicyNotificationTargetWriter policyNotificationTargetWriter; + + @Mock + private PolicyNotificationTargetRepository policyNotificationTargetRepository; + + private static final Long POLICY_ID = 1L; + + private PolicyNotificationTarget buildTarget(Long userId, NotificationStatus status) { + return PolicyNotificationTarget.builder() + .id(userId) + .policyId(POLICY_ID) + .userId(userId) + .status(status) + .retryCount(0) + .build(); + } + + @Test + void should_save_all_chunk_items_when_write_called_given() { + // given + PolicyNotificationTarget sentTarget = buildTarget(1L, NotificationStatus.SENT); + PolicyNotificationTarget failedTarget = buildTarget(2L, NotificationStatus.FAILED); + Chunk chunk = new Chunk<>(List.of(sentTarget, failedTarget)); + + @SuppressWarnings("unchecked") + ArgumentCaptor> captor = ArgumentCaptor.forClass(List.class); + + // when + policyNotificationTargetWriter.write(chunk); + + // then + verify(policyNotificationTargetRepository).saveAll(captor.capture()); + assertThat(captor.getValue()).containsExactly(sentTarget, failedTarget); + } +} From 6e11f2aac8c692e938472db6986a0e485b802618 Mon Sep 17 00:00:00 2001 From: You-Hyuk Date: Wed, 16 Sep 2026 18:11:22 +0900 Subject: [PATCH 08/15] =?UTF-8?q?[feat]=20=EC=A0=95=EC=B1=85=20=EC=95=8C?= =?UTF-8?q?=EB=A6=BC=20=EB=B0=B0=EC=B9=98=20=ED=8A=B8=EB=A6=AC=EA=B1=B0=20?= =?UTF-8?q?=EB=B0=8F=20=EB=B0=9C=EC=86=A1=20=EC=8B=A4=ED=8C=A8=20=EC=9E=AC?= =?UTF-8?q?=EC=8B=9C=EB=8F=84=20=EC=8A=A4=EC=BC=80=EC=A4=84=EB=9F=AC=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 이슈 #118: 정책 등록 트랜잭션 커밋 후(@TransactionalEventListener AFTER_COMMIT) policyNotificationJob을 동기로 실행하는 트리거를 추가한다. 발송 실패(FAILED) 대상은 별도 스케줄러가 1시간 간격으로 최대 3회까지 재시도한다. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01JNjhau1bE58LegkwKtPBq2 --- .../Coming/Backend/BackendApplication.java | 2 + .../batch/PolicyNotificationJobTrigger.java | 38 ++++ .../PolicyNotificationRetryScheduler.java | 66 +++++++ .../policy/event/PolicyRegisteredEvent.java | 4 + .../PolicyNotificationTargetRepository.java | 2 + .../Backend/policy/service/PolicyService.java | 9 +- .../PolicyNotificationJobTriggerTest.java | 61 ++++++ .../PolicyNotificationRetrySchedulerTest.java | 179 ++++++++++++++++++ .../policy/service/PolicyServiceTest.java | 7 + 9 files changed, 366 insertions(+), 2 deletions(-) create mode 100644 src/main/java/com/Coming/Backend/policy/batch/PolicyNotificationJobTrigger.java create mode 100644 src/main/java/com/Coming/Backend/policy/batch/PolicyNotificationRetryScheduler.java create mode 100644 src/main/java/com/Coming/Backend/policy/event/PolicyRegisteredEvent.java create mode 100644 src/test/java/com/Coming/Backend/policy/batch/PolicyNotificationJobTriggerTest.java create mode 100644 src/test/java/com/Coming/Backend/policy/batch/PolicyNotificationRetrySchedulerTest.java diff --git a/src/main/java/com/Coming/Backend/BackendApplication.java b/src/main/java/com/Coming/Backend/BackendApplication.java index 43fa2ad5..11530061 100644 --- a/src/main/java/com/Coming/Backend/BackendApplication.java +++ b/src/main/java/com/Coming/Backend/BackendApplication.java @@ -3,9 +3,11 @@ import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; import org.springframework.data.jpa.repository.config.EnableJpaAuditing; +import org.springframework.scheduling.annotation.EnableScheduling; @SpringBootApplication @EnableJpaAuditing +@EnableScheduling public class BackendApplication { public static void main(String[] args) { diff --git a/src/main/java/com/Coming/Backend/policy/batch/PolicyNotificationJobTrigger.java b/src/main/java/com/Coming/Backend/policy/batch/PolicyNotificationJobTrigger.java new file mode 100644 index 00000000..e6cf97b4 --- /dev/null +++ b/src/main/java/com/Coming/Backend/policy/batch/PolicyNotificationJobTrigger.java @@ -0,0 +1,38 @@ +package com.Coming.Backend.policy.batch; + +import com.Coming.Backend.policy.event.PolicyRegisteredEvent; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.batch.core.job.Job; +import org.springframework.batch.core.job.parameters.JobParameters; +import org.springframework.batch.core.job.parameters.JobParametersBuilder; +import org.springframework.batch.core.launch.JobOperator; +import org.springframework.stereotype.Component; +import org.springframework.transaction.event.TransactionPhase; +import org.springframework.transaction.event.TransactionalEventListener; + +/** + * 정책 등록 트랜잭션이 커밋된 후, 해당 정책에 대한 알림 대상 생성·메일 발송 배치를 동기로 실행한다. + */ +@Slf4j +@Component +@RequiredArgsConstructor +public class PolicyNotificationJobTrigger { + + private final JobOperator jobOperator; + private final Job policyNotificationJob; + + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) + public void onPolicyRegistered(PolicyRegisteredEvent event) { + try { + JobParameters jobParameters = policyNotificationJob.getJobParametersIncrementer().getNext( + new JobParametersBuilder() + .addLong("policyId", event.policyId()) + .toJobParameters() + ); + jobOperator.start(policyNotificationJob, jobParameters); + } catch (Exception e) { + log.error("정책 알림 배치 실행 실패 — policyId: {}", event.policyId(), e); + } + } +} diff --git a/src/main/java/com/Coming/Backend/policy/batch/PolicyNotificationRetryScheduler.java b/src/main/java/com/Coming/Backend/policy/batch/PolicyNotificationRetryScheduler.java new file mode 100644 index 00000000..c2115ade --- /dev/null +++ b/src/main/java/com/Coming/Backend/policy/batch/PolicyNotificationRetryScheduler.java @@ -0,0 +1,66 @@ +package com.Coming.Backend.policy.batch; + +import com.Coming.Backend.auth.entity.User; +import com.Coming.Backend.auth.repository.UserRepository; +import com.Coming.Backend.policy.entity.NotificationStatus; +import com.Coming.Backend.policy.entity.PolicyDocument; +import com.Coming.Backend.policy.entity.PolicyNotificationTarget; +import com.Coming.Backend.policy.mail.PolicyNoticeMailSender; +import com.Coming.Backend.policy.repository.PolicyDocumentRepository; +import com.Coming.Backend.policy.repository.PolicyNotificationTargetRepository; +import java.util.List; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +/** + * 발송 실패(FAILED)한 정책 알림 대상을 최대 3회까지 1시간 간격으로 재시도한다. + */ +@Slf4j +@Component +@RequiredArgsConstructor +public class PolicyNotificationRetryScheduler { + + private static final int MAX_RETRY_COUNT = 3; + + private final PolicyNotificationTargetRepository policyNotificationTargetRepository; + private final UserRepository userRepository; + private final PolicyDocumentRepository policyDocumentRepository; + private final PolicyNoticeMailSender policyNoticeMailSender; + + @Scheduled(fixedRate = 3_600_000) + @Transactional + public void retryFailedNotifications() { + List retryTargets = policyNotificationTargetRepository + .findByStatusAndRetryCountLessThan(NotificationStatus.FAILED, MAX_RETRY_COUNT); + if (retryTargets.isEmpty()) { + return; + } + log.info("정책 알림 재시도 시작 — 대상 건수: {}", retryTargets.size()); + for (PolicyNotificationTarget target : retryTargets) { + retryOne(target); + } + } + + private void retryOne(PolicyNotificationTarget target) { + User user = userRepository.findById(target.getUserId()).orElse(null); + if (user == null || user.getEmail() == null || user.getEmail().isBlank()) { + target.markFailed(); + return; + } + PolicyDocument policyDocument = policyDocumentRepository.findById(target.getPolicyId()).orElse(null); + if (policyDocument == null) { + target.markFailed(); + return; + } + try { + policyNoticeMailSender.send(user.getEmail(), policyDocument); + target.markSent(); + } catch (Exception e) { + log.warn("정책 알림 재시도 실패 — targetId: {}", target.getId(), e); + target.markFailed(); + } + } +} diff --git a/src/main/java/com/Coming/Backend/policy/event/PolicyRegisteredEvent.java b/src/main/java/com/Coming/Backend/policy/event/PolicyRegisteredEvent.java new file mode 100644 index 00000000..60e08867 --- /dev/null +++ b/src/main/java/com/Coming/Backend/policy/event/PolicyRegisteredEvent.java @@ -0,0 +1,4 @@ +package com.Coming.Backend.policy.event; + +public record PolicyRegisteredEvent(Long policyId) { +} diff --git a/src/main/java/com/Coming/Backend/policy/repository/PolicyNotificationTargetRepository.java b/src/main/java/com/Coming/Backend/policy/repository/PolicyNotificationTargetRepository.java index 74e5f65a..ac915c0c 100644 --- a/src/main/java/com/Coming/Backend/policy/repository/PolicyNotificationTargetRepository.java +++ b/src/main/java/com/Coming/Backend/policy/repository/PolicyNotificationTargetRepository.java @@ -12,4 +12,6 @@ public interface PolicyNotificationTargetRepository extends JpaRepository findByPolicyId(Long policyId); Page findByPolicyIdAndStatus(Long policyId, NotificationStatus status, Pageable pageable); + + List findByStatusAndRetryCountLessThan(NotificationStatus status, int retryCount); } diff --git a/src/main/java/com/Coming/Backend/policy/service/PolicyService.java b/src/main/java/com/Coming/Backend/policy/service/PolicyService.java index f4ee7610..977f069f 100644 --- a/src/main/java/com/Coming/Backend/policy/service/PolicyService.java +++ b/src/main/java/com/Coming/Backend/policy/service/PolicyService.java @@ -3,9 +3,11 @@ import com.Coming.Backend.policy.dto.PolicyRegisterRequest; import com.Coming.Backend.policy.dto.PolicyResponse; import com.Coming.Backend.policy.entity.PolicyDocument; +import com.Coming.Backend.policy.event.PolicyRegisteredEvent; import com.Coming.Backend.policy.exception.PolicyVersionDuplicateException; import com.Coming.Backend.policy.repository.PolicyDocumentRepository; import lombok.RequiredArgsConstructor; +import org.springframework.context.ApplicationEventPublisher; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -15,9 +17,10 @@ public class PolicyService { private final PolicyDocumentRepository policyDocumentRepository; + private final ApplicationEventPublisher eventPublisher; /** - * 새 정책 버전을 등록한다. + * 새 정책 버전을 등록한다. 등록 완료(커밋) 후 정책 알림 배치가 트리거된다. * * @throws PolicyVersionDuplicateException 동일 type·version이 이미 등록된 경우 */ @@ -34,6 +37,8 @@ public PolicyResponse registerPolicy(PolicyRegisterRequest request) { .detailUrl(request.detailUrl()) .requiresReconsent(Boolean.TRUE.equals(request.requiresReconsent())) .build(); - return PolicyResponse.from(policyDocumentRepository.save(policyDocument)); + PolicyDocument saved = policyDocumentRepository.save(policyDocument); + eventPublisher.publishEvent(new PolicyRegisteredEvent(saved.getId())); + return PolicyResponse.from(saved); } } diff --git a/src/test/java/com/Coming/Backend/policy/batch/PolicyNotificationJobTriggerTest.java b/src/test/java/com/Coming/Backend/policy/batch/PolicyNotificationJobTriggerTest.java new file mode 100644 index 00000000..28dc0c15 --- /dev/null +++ b/src/test/java/com/Coming/Backend/policy/batch/PolicyNotificationJobTriggerTest.java @@ -0,0 +1,61 @@ +package com.Coming.Backend.policy.batch; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatCode; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.BDDMockito.given; +import static org.mockito.BDDMockito.willThrow; +import static org.mockito.Mockito.verify; + +import com.Coming.Backend.policy.event.PolicyRegisteredEvent; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.batch.core.job.Job; +import org.springframework.batch.core.job.parameters.JobParameters; +import org.springframework.batch.core.job.parameters.RunIdIncrementer; +import org.springframework.batch.core.launch.JobOperator; + +@ExtendWith(MockitoExtension.class) +class PolicyNotificationJobTriggerTest { + + @InjectMocks + private PolicyNotificationJobTrigger policyNotificationJobTrigger; + + @Mock + private JobOperator jobOperator; + + @Mock + private Job policyNotificationJob; + + @Test + void should_start_job_with_policy_id_when_policy_registered_event_given() throws Exception { + // given + given(policyNotificationJob.getJobParametersIncrementer()).willReturn(new RunIdIncrementer()); + PolicyRegisteredEvent event = new PolicyRegisteredEvent(1L); + + // when + policyNotificationJobTrigger.onPolicyRegistered(event); + + // then + ArgumentCaptor jobParametersCaptor = ArgumentCaptor.forClass(JobParameters.class); + verify(jobOperator).start(eq(policyNotificationJob), jobParametersCaptor.capture()); + assertThat(jobParametersCaptor.getValue().getLong("policyId")).isEqualTo(1L); + } + + @Test + void should_not_throw_exception_when_job_operator_start_throws_exception() throws Exception { + // given + given(policyNotificationJob.getJobParametersIncrementer()).willReturn(new RunIdIncrementer()); + willThrow(new RuntimeException("job launch failed")).given(jobOperator).start(any(Job.class), any(JobParameters.class)); + PolicyRegisteredEvent event = new PolicyRegisteredEvent(1L); + + // when & then + assertThatCode(() -> policyNotificationJobTrigger.onPolicyRegistered(event)) + .doesNotThrowAnyException(); + } +} diff --git a/src/test/java/com/Coming/Backend/policy/batch/PolicyNotificationRetrySchedulerTest.java b/src/test/java/com/Coming/Backend/policy/batch/PolicyNotificationRetrySchedulerTest.java new file mode 100644 index 00000000..ca813e14 --- /dev/null +++ b/src/test/java/com/Coming/Backend/policy/batch/PolicyNotificationRetrySchedulerTest.java @@ -0,0 +1,179 @@ +package com.Coming.Backend.policy.batch; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.BDDMockito.given; +import static org.mockito.BDDMockito.willThrow; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; + +import com.Coming.Backend.auth.entity.User; +import com.Coming.Backend.auth.entity.UserRole; +import com.Coming.Backend.auth.entity.UserStatus; +import com.Coming.Backend.auth.repository.UserRepository; +import com.Coming.Backend.policy.entity.NotificationStatus; +import com.Coming.Backend.policy.entity.PolicyDocument; +import com.Coming.Backend.policy.entity.PolicyNotificationTarget; +import com.Coming.Backend.policy.entity.PolicyType; +import com.Coming.Backend.policy.mail.PolicyNoticeMailSender; +import com.Coming.Backend.policy.repository.PolicyDocumentRepository; +import com.Coming.Backend.policy.repository.PolicyNotificationTargetRepository; +import java.time.LocalDate; +import java.util.Collections; +import java.util.List; +import java.util.Optional; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +@ExtendWith(MockitoExtension.class) +class PolicyNotificationRetrySchedulerTest { + + @InjectMocks + private PolicyNotificationRetryScheduler policyNotificationRetryScheduler; + + @Mock + private PolicyNotificationTargetRepository policyNotificationTargetRepository; + + @Mock + private UserRepository userRepository; + + @Mock + private PolicyDocumentRepository policyDocumentRepository; + + @Mock + private PolicyNoticeMailSender policyNoticeMailSender; + + private static final Long POLICY_ID = 1L; + + private User buildUser(Long userId, String email) { + return User.builder() + .id(userId) + .email(email) + .provider("google") + .providerId("provider-" + userId) + .nickname("nickname" + userId) + .role(UserRole.USER) + .status(UserStatus.ACTIVE) + .birthYear(1995) + .agreedMarketing(true) + .build(); + } + + private PolicyDocument buildPolicyDocument() { + return PolicyDocument.builder() + .id(POLICY_ID) + .type(PolicyType.TERMS) + .version("1.0") + .effectiveDate(LocalDate.of(2026, 1, 1)) + .changeSummary("이용약관 개정") + .detailUrl("https://coming.com/policy/terms/1.0") + .requiresReconsent(false) + .build(); + } + + private PolicyNotificationTarget buildTarget(Long userId) { + return PolicyNotificationTarget.builder() + .id(10L) + .policyId(POLICY_ID) + .userId(userId) + .status(NotificationStatus.FAILED) + .retryCount(1) + .build(); + } + + @Test + void should_do_nothing_when_no_retry_targets_given() { + // given + given(policyNotificationTargetRepository.findByStatusAndRetryCountLessThan(NotificationStatus.FAILED, 3)) + .willReturn(Collections.emptyList()); + + // when + policyNotificationRetryScheduler.retryFailedNotifications(); + + // then + verifyNoInteractions(userRepository, policyDocumentRepository, policyNoticeMailSender); + } + + @Test + void should_mark_sent_when_retry_succeeds() { + // given + PolicyNotificationTarget target = buildTarget(1L); + User user = buildUser(1L, "iu@coming.com"); + PolicyDocument policyDocument = buildPolicyDocument(); + + given(policyNotificationTargetRepository.findByStatusAndRetryCountLessThan(NotificationStatus.FAILED, 3)) + .willReturn(List.of(target)); + given(userRepository.findById(1L)).willReturn(Optional.of(user)); + given(policyDocumentRepository.findById(POLICY_ID)).willReturn(Optional.of(policyDocument)); + + // when + policyNotificationRetryScheduler.retryFailedNotifications(); + + // then + assertThat(target.getStatus()).isEqualTo(NotificationStatus.SENT); + verify(policyNoticeMailSender).send("iu@coming.com", policyDocument); + } + + @Test + void should_mark_failed_when_user_not_found() { + // given + PolicyNotificationTarget target = buildTarget(1L); + + given(policyNotificationTargetRepository.findByStatusAndRetryCountLessThan(NotificationStatus.FAILED, 3)) + .willReturn(List.of(target)); + given(userRepository.findById(1L)).willReturn(Optional.empty()); + + // when + policyNotificationRetryScheduler.retryFailedNotifications(); + + // then + assertThat(target.getStatus()).isEqualTo(NotificationStatus.FAILED); + assertThat(target.getRetryCount()).isEqualTo(2); + verify(policyNoticeMailSender, never()).send(any(), any()); + } + + @Test + void should_mark_failed_when_policy_document_not_found() { + // given + PolicyNotificationTarget target = buildTarget(1L); + User user = buildUser(1L, "iu@coming.com"); + + given(policyNotificationTargetRepository.findByStatusAndRetryCountLessThan(NotificationStatus.FAILED, 3)) + .willReturn(List.of(target)); + given(userRepository.findById(1L)).willReturn(Optional.of(user)); + given(policyDocumentRepository.findById(POLICY_ID)).willReturn(Optional.empty()); + + // when + policyNotificationRetryScheduler.retryFailedNotifications(); + + // then + assertThat(target.getStatus()).isEqualTo(NotificationStatus.FAILED); + assertThat(target.getRetryCount()).isEqualTo(2); + verify(policyNoticeMailSender, never()).send(any(), any()); + } + + @Test + void should_mark_failed_and_increment_retry_count_when_mail_send_throws_exception() { + // given + PolicyNotificationTarget target = buildTarget(1L); + User user = buildUser(1L, "iu@coming.com"); + PolicyDocument policyDocument = buildPolicyDocument(); + + given(policyNotificationTargetRepository.findByStatusAndRetryCountLessThan(NotificationStatus.FAILED, 3)) + .willReturn(List.of(target)); + given(userRepository.findById(1L)).willReturn(Optional.of(user)); + given(policyDocumentRepository.findById(POLICY_ID)).willReturn(Optional.of(policyDocument)); + willThrow(new RuntimeException("smtp down")).given(policyNoticeMailSender).send("iu@coming.com", policyDocument); + + // when + policyNotificationRetryScheduler.retryFailedNotifications(); + + // then + assertThat(target.getStatus()).isEqualTo(NotificationStatus.FAILED); + assertThat(target.getRetryCount()).isEqualTo(2); + } +} diff --git a/src/test/java/com/Coming/Backend/policy/service/PolicyServiceTest.java b/src/test/java/com/Coming/Backend/policy/service/PolicyServiceTest.java index 0917026e..1bf8e0e6 100644 --- a/src/test/java/com/Coming/Backend/policy/service/PolicyServiceTest.java +++ b/src/test/java/com/Coming/Backend/policy/service/PolicyServiceTest.java @@ -12,6 +12,7 @@ import com.Coming.Backend.policy.dto.PolicyResponse; import com.Coming.Backend.policy.entity.PolicyDocument; import com.Coming.Backend.policy.entity.PolicyType; +import com.Coming.Backend.policy.event.PolicyRegisteredEvent; import com.Coming.Backend.policy.exception.PolicyVersionDuplicateException; import com.Coming.Backend.policy.repository.PolicyDocumentRepository; import java.time.LocalDate; @@ -20,6 +21,7 @@ import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.context.ApplicationEventPublisher; @ExtendWith(MockitoExtension.class) class PolicyServiceTest { @@ -30,6 +32,9 @@ class PolicyServiceTest { @Mock private PolicyDocumentRepository policyDocumentRepository; + @Mock + private ApplicationEventPublisher eventPublisher; + private static final PolicyType POLICY_TYPE = PolicyType.TERMS; private static final String VERSION = "1.0.0"; @@ -76,6 +81,7 @@ void should_return_policy_response_when_valid_request_given() { assertThat(result.changeSummary()).isEqualTo(request.changeSummary()); assertThat(result.detailUrl()).isEqualTo(request.detailUrl()); assertThat(result.requiresReconsent()).isTrue(); + verify(eventPublisher).publishEvent(new PolicyRegisteredEvent(1L)); } @Test @@ -90,5 +96,6 @@ void should_throw_policy_version_duplicate_exception_when_same_type_and_version_ .isInstanceOf(PolicyVersionDuplicateException.class) .hasMessage(ErrorCode.POLICY_VERSION_DUPLICATE.getMessage()); verify(policyDocumentRepository, never()).save(any(PolicyDocument.class)); + verify(eventPublisher, never()).publishEvent(any()); } } From 38507762cb696d8c32a3f018f180c33d9cefa0fb Mon Sep 17 00:00:00 2001 From: You-Hyuk Date: Wed, 16 Sep 2026 18:20:03 +0900 Subject: [PATCH 09/15] =?UTF-8?q?[fix]=20=EC=A0=95=EC=B1=85=20=EC=95=8C?= =?UTF-8?q?=EB=A6=BC=20=EB=B0=B0=EC=B9=98=20=ED=8A=B8=EB=A6=AC=EA=B1=B0?= =?UTF-8?q?=EC=9D=98=20JobOperator=20=EC=82=AC=EC=9A=A9=20=EC=98=A4?= =?UTF-8?q?=EB=A5=98=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 임시 DB에 실제 앱을 기동해 관리자 API로 정책 등록 → 배치 실행까지 end-to-end로 검증한 결과 두 가지 실행 시점 버그를 발견해 수정했다. - JobOperator.start(Job, JobParameters)는 인크리멘터가 설정된 Job에서 run.id 외의 커스텀 파라미터(policyId)를 무시하고 버림 → run(...)으로 전환 - @TransactionalEventListener(AFTER_COMMIT) 콜백과 같은 스레드에서 자체 트랜잭션을 가진 배치를 실행하면 트랜잭션 동기화 상태 충돌로 JobInterruptedException 발생 → 별도 스레드에서 실행 후 join으로 대기 수정 후 Step1(대상 생성) → Step2(메일 발송, 실패 시 FAILED 처리) → Job COMPLETED까지 실제 동작을 확인했다. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01JNjhau1bE58LegkwKtPBq2 --- .../batch/PolicyNotificationJobTrigger.java | 21 +++++++++++++++---- .../PolicyNotificationJobTriggerTest.java | 8 +++---- 2 files changed, 21 insertions(+), 8 deletions(-) diff --git a/src/main/java/com/Coming/Backend/policy/batch/PolicyNotificationJobTrigger.java b/src/main/java/com/Coming/Backend/policy/batch/PolicyNotificationJobTrigger.java index e6cf97b4..784d555f 100644 --- a/src/main/java/com/Coming/Backend/policy/batch/PolicyNotificationJobTrigger.java +++ b/src/main/java/com/Coming/Backend/policy/batch/PolicyNotificationJobTrigger.java @@ -12,7 +12,10 @@ import org.springframework.transaction.event.TransactionalEventListener; /** - * 정책 등록 트랜잭션이 커밋된 후, 해당 정책에 대한 알림 대상 생성·메일 발송 배치를 동기로 실행한다. + * 정책 등록 트랜잭션이 커밋된 후, 해당 정책에 대한 알림 대상 생성·메일 발송 배치를 실행한다. + * AFTER_COMMIT 콜백과 같은 스레드에서 배치(자체 트랜잭션 포함)를 직접 실행하면 커밋 중이던 + * 트랜잭션 동기화 상태와 충돌해 JobInterruptedException이 발생하므로, 별도 스레드에서 실행하고 + * join으로 대기해 호출자 입장에서는 동기로 완료를 기다리게 한다. */ @Slf4j @Component @@ -24,15 +27,25 @@ public class PolicyNotificationJobTrigger { @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) public void onPolicyRegistered(PolicyRegisteredEvent event) { + Thread jobThread = Thread.ofVirtual().start(() -> runJob(event.policyId())); + try { + jobThread.join(); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + log.error("정책 알림 배치 대기 중 인터럽트 발생 — policyId: {}", event.policyId(), e); + } + } + + private void runJob(Long policyId) { try { JobParameters jobParameters = policyNotificationJob.getJobParametersIncrementer().getNext( new JobParametersBuilder() - .addLong("policyId", event.policyId()) + .addLong("policyId", policyId) .toJobParameters() ); - jobOperator.start(policyNotificationJob, jobParameters); + jobOperator.run(policyNotificationJob, jobParameters); } catch (Exception e) { - log.error("정책 알림 배치 실행 실패 — policyId: {}", event.policyId(), e); + log.error("정책 알림 배치 실행 실패 — policyId: {}", policyId, e); } } } diff --git a/src/test/java/com/Coming/Backend/policy/batch/PolicyNotificationJobTriggerTest.java b/src/test/java/com/Coming/Backend/policy/batch/PolicyNotificationJobTriggerTest.java index 28dc0c15..87daa753 100644 --- a/src/test/java/com/Coming/Backend/policy/batch/PolicyNotificationJobTriggerTest.java +++ b/src/test/java/com/Coming/Backend/policy/batch/PolicyNotificationJobTriggerTest.java @@ -33,7 +33,7 @@ class PolicyNotificationJobTriggerTest { private Job policyNotificationJob; @Test - void should_start_job_with_policy_id_when_policy_registered_event_given() throws Exception { + void should_run_job_with_policy_id_when_policy_registered_event_given() throws Exception { // given given(policyNotificationJob.getJobParametersIncrementer()).willReturn(new RunIdIncrementer()); PolicyRegisteredEvent event = new PolicyRegisteredEvent(1L); @@ -43,15 +43,15 @@ void should_start_job_with_policy_id_when_policy_registered_event_given() throws // then ArgumentCaptor jobParametersCaptor = ArgumentCaptor.forClass(JobParameters.class); - verify(jobOperator).start(eq(policyNotificationJob), jobParametersCaptor.capture()); + verify(jobOperator).run(eq(policyNotificationJob), jobParametersCaptor.capture()); assertThat(jobParametersCaptor.getValue().getLong("policyId")).isEqualTo(1L); } @Test - void should_not_throw_exception_when_job_operator_start_throws_exception() throws Exception { + void should_not_throw_exception_when_job_operator_run_throws_exception() throws Exception { // given given(policyNotificationJob.getJobParametersIncrementer()).willReturn(new RunIdIncrementer()); - willThrow(new RuntimeException("job launch failed")).given(jobOperator).start(any(Job.class), any(JobParameters.class)); + willThrow(new RuntimeException("job launch failed")).given(jobOperator).run(any(Job.class), any(JobParameters.class)); PolicyRegisteredEvent event = new PolicyRegisteredEvent(1L); // when & then From 9a482e6c397bf52427da1144f3435f074ca5839b Mon Sep 17 00:00:00 2001 From: You-Hyuk Date: Wed, 16 Sep 2026 18:31:09 +0900 Subject: [PATCH 10/15] =?UTF-8?q?[refactor]=20=EC=A0=95=EC=B1=85=20?= =?UTF-8?q?=EC=95=8C=EB=A6=BC=20=EB=B0=9C=EC=86=A1=20=EB=A1=9C=EC=A7=81=20?= =?UTF-8?q?=EA=B3=B5=ED=86=B5=ED=99=94=20=EB=B0=8F=20PolicyService=20?= =?UTF-8?q?=EB=8F=99=EC=8B=9C=EC=84=B1=20=EC=95=88=EC=A0=84=20=EC=B2=98?= =?UTF-8?q?=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit /simplify 4개 관점(Reuse/Simplification/Efficiency/Altitude) 병렬 리뷰 결과를 반영한다. - PolicyNotificationMailProcessor와 PolicyNotificationRetryScheduler에 중복되던 "유저 조회 → 이메일 확인 → 발송 시도 → SENT/FAILED 마킹" 로직을 PolicyNotificationSender로 추출 - User.hasNoEmail()을 추가해 3곳에 흩어져 있던 이메일 공백 체크를 통합 - PolicyService.registerPolicy()에 기존 코드베이스 관례(AuthService, CalendarService와 동일한 존재 체크 + DataIntegrityViolationException catch)를 적용해 동시 등록 시 유니크 제약 위반을 도메인 예외로 변환 리팩터링 후 임시 DB에 실제 앱을 재기동해 정책 등록 → 배치 실행까지 동일하게 정상 동작함을 재검증했다. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01JNjhau1bE58LegkwKtPBq2 --- .../com/Coming/Backend/auth/entity/User.java | 4 + .../PolicyNotificationMailProcessor.java | 18 +-- .../PolicyNotificationRetryScheduler.java | 15 +- .../batch/PolicyNotificationSender.java | 36 +++++ ...licyNotificationTargetCreationTasklet.java | 2 +- .../Backend/policy/service/PolicyService.java | 8 +- .../PolicyNotificationMailProcessorTest.java | 77 ++++------ .../PolicyNotificationRetrySchedulerTest.java | 48 ++----- .../batch/PolicyNotificationSenderTest.java | 133 ++++++++++++++++++ .../policy/service/PolicyServiceTest.java | 22 ++- 10 files changed, 247 insertions(+), 116 deletions(-) create mode 100644 src/main/java/com/Coming/Backend/policy/batch/PolicyNotificationSender.java create mode 100644 src/test/java/com/Coming/Backend/policy/batch/PolicyNotificationSenderTest.java diff --git a/src/main/java/com/Coming/Backend/auth/entity/User.java b/src/main/java/com/Coming/Backend/auth/entity/User.java index 1cc5b810..7d9421e5 100644 --- a/src/main/java/com/Coming/Backend/auth/entity/User.java +++ b/src/main/java/com/Coming/Backend/auth/entity/User.java @@ -77,6 +77,10 @@ public void updateEmail(String email) { this.email = email; } + public boolean hasNoEmail() { + return email == null || email.isBlank(); + } + public void reactivate() { this.status = UserStatus.ACTIVE; this.role = UserRole.PENDING; diff --git a/src/main/java/com/Coming/Backend/policy/batch/PolicyNotificationMailProcessor.java b/src/main/java/com/Coming/Backend/policy/batch/PolicyNotificationMailProcessor.java index 7ebafe4d..00c0ef1c 100644 --- a/src/main/java/com/Coming/Backend/policy/batch/PolicyNotificationMailProcessor.java +++ b/src/main/java/com/Coming/Backend/policy/batch/PolicyNotificationMailProcessor.java @@ -5,10 +5,8 @@ import com.Coming.Backend.policy.entity.PolicyDocument; import com.Coming.Backend.policy.entity.PolicyNotificationTarget; import com.Coming.Backend.policy.exception.PolicyNotFoundException; -import com.Coming.Backend.policy.mail.PolicyNoticeMailSender; import com.Coming.Backend.policy.repository.PolicyDocumentRepository; import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; import org.springframework.batch.core.configuration.annotation.StepScope; import org.springframework.batch.infrastructure.item.ItemProcessor; import org.springframework.beans.factory.annotation.Value; @@ -17,7 +15,6 @@ /** * PENDING 상태의 PolicyNotificationTarget에 대해 정책 변경 고지 메일을 발송하고 상태(SENT/FAILED)를 갱신한다. */ -@Slf4j @Component @StepScope @RequiredArgsConstructor @@ -25,7 +22,7 @@ public class PolicyNotificationMailProcessor implements ItemProcessor targetCaptor = ArgumentCaptor.forClass(PolicyNotificationTarget.class); + ArgumentCaptor userCaptor = ArgumentCaptor.forClass(User.class); + ArgumentCaptor policyDocumentCaptor = ArgumentCaptor.forClass(PolicyDocument.class); // when PolicyNotificationTarget result = policyNotificationMailProcessor.process(target); // then - assertThat(result.getStatus()).isEqualTo(NotificationStatus.FAILED); - verify(policyNoticeMailSender, never()).send(any(), any()); + assertThat(result).isSameAs(target); + verify(policyNotificationSender).sendAndMark(targetCaptor.capture(), userCaptor.capture(), policyDocumentCaptor.capture()); + assertThat(targetCaptor.getValue()).isSameAs(target); + assertThat(userCaptor.getValue()).isSameAs(user); + assertThat(policyDocumentCaptor.getValue()).isSameAs(policyDocument); } @Test - void should_mark_failed_when_user_email_is_null_given() { + void should_call_sender_with_null_user_when_user_not_found_given() { // given ReflectionTestUtils.setField(policyNotificationMailProcessor, "policyId", POLICY_ID); PolicyNotificationTarget target = buildTarget(1L); - User user = buildUser(1L, null); + PolicyDocument policyDocument = buildPolicyDocument(); - given(userRepository.findById(1L)).willReturn(Optional.of(user)); + given(userRepository.findById(1L)).willReturn(Optional.empty()); + given(policyDocumentRepository.findById(POLICY_ID)).willReturn(Optional.of(policyDocument)); // when - PolicyNotificationTarget result = policyNotificationMailProcessor.process(target); + policyNotificationMailProcessor.process(target); // then - assertThat(result.getStatus()).isEqualTo(NotificationStatus.FAILED); - verify(policyNoticeMailSender, never()).send(any(), any()); + verify(policyNotificationSender).sendAndMark(target, null, policyDocument); } @Test - void should_mark_failed_and_increment_retry_count_when_mail_send_throws_exception_given() { + void should_call_policy_document_repository_only_once_when_process_called_twice_given() { // given ReflectionTestUtils.setField(policyNotificationMailProcessor, "policyId", POLICY_ID); - PolicyNotificationTarget target = buildTarget(1L); User user = buildUser(1L, "iu@coming.com"); PolicyDocument policyDocument = buildPolicyDocument(); given(userRepository.findById(1L)).willReturn(Optional.of(user)); given(policyDocumentRepository.findById(POLICY_ID)).willReturn(Optional.of(policyDocument)); - willThrow(new RuntimeException("smtp down")).given(policyNoticeMailSender).send("iu@coming.com", policyDocument); // when - PolicyNotificationTarget result = policyNotificationMailProcessor.process(target); + policyNotificationMailProcessor.process(buildTarget(1L)); + policyNotificationMailProcessor.process(buildTarget(1L)); // then - assertThat(result.getStatus()).isEqualTo(NotificationStatus.FAILED); - assertThat(result.getRetryCount()).isEqualTo(1); + verify(policyDocumentRepository, times(1)).findById(POLICY_ID); } @Test - void should_call_policy_document_repository_only_once_when_process_called_twice_given() { + void should_throw_policy_not_found_exception_when_policy_document_not_found_given() { // given ReflectionTestUtils.setField(policyNotificationMailProcessor, "policyId", POLICY_ID); - User user = buildUser(1L, "iu@coming.com"); - PolicyDocument policyDocument = buildPolicyDocument(); - - given(userRepository.findById(1L)).willReturn(Optional.of(user)); - given(policyDocumentRepository.findById(POLICY_ID)).willReturn(Optional.of(policyDocument)); + PolicyNotificationTarget target = buildTarget(1L); - // when - policyNotificationMailProcessor.process(buildTarget(1L)); - policyNotificationMailProcessor.process(buildTarget(1L)); + given(userRepository.findById(1L)).willReturn(Optional.of(buildUser(1L, "iu@coming.com"))); + given(policyDocumentRepository.findById(POLICY_ID)).willReturn(Optional.empty()); - // then - verify(policyDocumentRepository, times(1)).findById(POLICY_ID); + // when & then + assertThatThrownBy(() -> policyNotificationMailProcessor.process(target)) + .isInstanceOf(PolicyNotFoundException.class); } } diff --git a/src/test/java/com/Coming/Backend/policy/batch/PolicyNotificationRetrySchedulerTest.java b/src/test/java/com/Coming/Backend/policy/batch/PolicyNotificationRetrySchedulerTest.java index ca813e14..e26a8f8c 100644 --- a/src/test/java/com/Coming/Backend/policy/batch/PolicyNotificationRetrySchedulerTest.java +++ b/src/test/java/com/Coming/Backend/policy/batch/PolicyNotificationRetrySchedulerTest.java @@ -3,7 +3,6 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.ArgumentMatchers.any; import static org.mockito.BDDMockito.given; -import static org.mockito.BDDMockito.willThrow; import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.verifyNoInteractions; @@ -16,7 +15,6 @@ import com.Coming.Backend.policy.entity.PolicyDocument; import com.Coming.Backend.policy.entity.PolicyNotificationTarget; import com.Coming.Backend.policy.entity.PolicyType; -import com.Coming.Backend.policy.mail.PolicyNoticeMailSender; import com.Coming.Backend.policy.repository.PolicyDocumentRepository; import com.Coming.Backend.policy.repository.PolicyNotificationTargetRepository; import java.time.LocalDate; @@ -45,7 +43,7 @@ class PolicyNotificationRetrySchedulerTest { private PolicyDocumentRepository policyDocumentRepository; @Mock - private PolicyNoticeMailSender policyNoticeMailSender; + private PolicyNotificationSender policyNotificationSender; private static final Long POLICY_ID = 1L; @@ -95,37 +93,19 @@ void should_do_nothing_when_no_retry_targets_given() { policyNotificationRetryScheduler.retryFailedNotifications(); // then - verifyNoInteractions(userRepository, policyDocumentRepository, policyNoticeMailSender); + verifyNoInteractions(userRepository, policyDocumentRepository, policyNotificationSender); } @Test - void should_mark_sent_when_retry_succeeds() { + void should_mark_failed_without_calling_sender_when_policy_document_not_found_given() { // given PolicyNotificationTarget target = buildTarget(1L); User user = buildUser(1L, "iu@coming.com"); - PolicyDocument policyDocument = buildPolicyDocument(); given(policyNotificationTargetRepository.findByStatusAndRetryCountLessThan(NotificationStatus.FAILED, 3)) .willReturn(List.of(target)); given(userRepository.findById(1L)).willReturn(Optional.of(user)); - given(policyDocumentRepository.findById(POLICY_ID)).willReturn(Optional.of(policyDocument)); - - // when - policyNotificationRetryScheduler.retryFailedNotifications(); - - // then - assertThat(target.getStatus()).isEqualTo(NotificationStatus.SENT); - verify(policyNoticeMailSender).send("iu@coming.com", policyDocument); - } - - @Test - void should_mark_failed_when_user_not_found() { - // given - PolicyNotificationTarget target = buildTarget(1L); - - given(policyNotificationTargetRepository.findByStatusAndRetryCountLessThan(NotificationStatus.FAILED, 3)) - .willReturn(List.of(target)); - given(userRepository.findById(1L)).willReturn(Optional.empty()); + given(policyDocumentRepository.findById(POLICY_ID)).willReturn(Optional.empty()); // when policyNotificationRetryScheduler.retryFailedNotifications(); @@ -133,47 +113,43 @@ void should_mark_failed_when_user_not_found() { // then assertThat(target.getStatus()).isEqualTo(NotificationStatus.FAILED); assertThat(target.getRetryCount()).isEqualTo(2); - verify(policyNoticeMailSender, never()).send(any(), any()); + verify(policyNotificationSender, never()).sendAndMark(any(), any(), any()); } @Test - void should_mark_failed_when_policy_document_not_found() { + void should_call_sender_with_found_user_and_policy_document_when_both_exist_given() { // given PolicyNotificationTarget target = buildTarget(1L); User user = buildUser(1L, "iu@coming.com"); + PolicyDocument policyDocument = buildPolicyDocument(); given(policyNotificationTargetRepository.findByStatusAndRetryCountLessThan(NotificationStatus.FAILED, 3)) .willReturn(List.of(target)); given(userRepository.findById(1L)).willReturn(Optional.of(user)); - given(policyDocumentRepository.findById(POLICY_ID)).willReturn(Optional.empty()); + given(policyDocumentRepository.findById(POLICY_ID)).willReturn(Optional.of(policyDocument)); // when policyNotificationRetryScheduler.retryFailedNotifications(); // then - assertThat(target.getStatus()).isEqualTo(NotificationStatus.FAILED); - assertThat(target.getRetryCount()).isEqualTo(2); - verify(policyNoticeMailSender, never()).send(any(), any()); + verify(policyNotificationSender).sendAndMark(target, user, policyDocument); } @Test - void should_mark_failed_and_increment_retry_count_when_mail_send_throws_exception() { + void should_call_sender_with_null_user_when_user_not_found_but_policy_document_found_given() { // given PolicyNotificationTarget target = buildTarget(1L); - User user = buildUser(1L, "iu@coming.com"); PolicyDocument policyDocument = buildPolicyDocument(); given(policyNotificationTargetRepository.findByStatusAndRetryCountLessThan(NotificationStatus.FAILED, 3)) .willReturn(List.of(target)); - given(userRepository.findById(1L)).willReturn(Optional.of(user)); + given(userRepository.findById(1L)).willReturn(Optional.empty()); given(policyDocumentRepository.findById(POLICY_ID)).willReturn(Optional.of(policyDocument)); - willThrow(new RuntimeException("smtp down")).given(policyNoticeMailSender).send("iu@coming.com", policyDocument); // when policyNotificationRetryScheduler.retryFailedNotifications(); // then - assertThat(target.getStatus()).isEqualTo(NotificationStatus.FAILED); - assertThat(target.getRetryCount()).isEqualTo(2); + verify(policyNotificationSender).sendAndMark(target, null, policyDocument); } } diff --git a/src/test/java/com/Coming/Backend/policy/batch/PolicyNotificationSenderTest.java b/src/test/java/com/Coming/Backend/policy/batch/PolicyNotificationSenderTest.java new file mode 100644 index 00000000..121a9192 --- /dev/null +++ b/src/test/java/com/Coming/Backend/policy/batch/PolicyNotificationSenderTest.java @@ -0,0 +1,133 @@ +package com.Coming.Backend.policy.batch; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.BDDMockito.given; +import static org.mockito.BDDMockito.willThrow; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; + +import com.Coming.Backend.auth.entity.User; +import com.Coming.Backend.auth.entity.UserRole; +import com.Coming.Backend.auth.entity.UserStatus; +import com.Coming.Backend.policy.entity.NotificationStatus; +import com.Coming.Backend.policy.entity.PolicyDocument; +import com.Coming.Backend.policy.entity.PolicyNotificationTarget; +import com.Coming.Backend.policy.entity.PolicyType; +import com.Coming.Backend.policy.mail.PolicyNoticeMailSender; +import java.time.LocalDate; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +@ExtendWith(MockitoExtension.class) +class PolicyNotificationSenderTest { + + @InjectMocks + private PolicyNotificationSender policyNotificationSender; + + @Mock + private PolicyNoticeMailSender policyNoticeMailSender; + + private static final Long POLICY_ID = 1L; + + private User buildUser(Long userId, String email) { + return User.builder() + .id(userId) + .email(email) + .provider("google") + .providerId("provider-" + userId) + .nickname("nickname" + userId) + .role(UserRole.USER) + .status(UserStatus.ACTIVE) + .birthYear(1995) + .agreedMarketing(true) + .build(); + } + + private PolicyDocument buildPolicyDocument() { + return PolicyDocument.builder() + .id(POLICY_ID) + .type(PolicyType.TERMS) + .version("1.0") + .effectiveDate(LocalDate.of(2026, 1, 1)) + .changeSummary("이용약관 개정") + .detailUrl("https://coming.com/policy/terms/1.0") + .requiresReconsent(false) + .build(); + } + + private PolicyNotificationTarget buildTarget() { + return PolicyNotificationTarget.builder() + .id(10L) + .policyId(POLICY_ID) + .userId(1L) + .status(NotificationStatus.PENDING) + .retryCount(0) + .build(); + } + + @Test + void should_mark_failed_when_user_is_null_given() { + // given + PolicyNotificationTarget target = buildTarget(); + PolicyDocument policyDocument = buildPolicyDocument(); + + // when + policyNotificationSender.sendAndMark(target, null, policyDocument); + + // then + assertThat(target.getStatus()).isEqualTo(NotificationStatus.FAILED); + verify(policyNoticeMailSender, never()).send(any(), any()); + } + + @Test + void should_mark_failed_when_user_has_no_email_given() { + // given + PolicyNotificationTarget target = buildTarget(); + User user = buildUser(1L, null); + PolicyDocument policyDocument = buildPolicyDocument(); + + // when + policyNotificationSender.sendAndMark(target, user, policyDocument); + + // then + assertThat(target.getStatus()).isEqualTo(NotificationStatus.FAILED); + verify(policyNoticeMailSender, never()).send(any(), any()); + } + + @Test + void should_mark_sent_when_send_succeeds_given() { + // given + PolicyNotificationTarget target = buildTarget(); + User user = buildUser(1L, "iu@coming.com"); + PolicyDocument policyDocument = buildPolicyDocument(); + + // when + policyNotificationSender.sendAndMark(target, user, policyDocument); + + // then + assertThat(target.getStatus()).isEqualTo(NotificationStatus.SENT); + assertThat(target.getSentAt()).isNotNull(); + verify(policyNoticeMailSender).send("iu@coming.com", policyDocument); + } + + @Test + void should_mark_failed_and_increment_retry_count_when_send_throws_exception_given() { + // given + PolicyNotificationTarget target = buildTarget(); + User user = buildUser(1L, "iu@coming.com"); + PolicyDocument policyDocument = buildPolicyDocument(); + + willThrow(new RuntimeException("smtp down")).given(policyNoticeMailSender).send("iu@coming.com", policyDocument); + + // when + policyNotificationSender.sendAndMark(target, user, policyDocument); + + // then + assertThat(target.getStatus()).isEqualTo(NotificationStatus.FAILED); + assertThat(target.getRetryCount()).isEqualTo(1); + } +} diff --git a/src/test/java/com/Coming/Backend/policy/service/PolicyServiceTest.java b/src/test/java/com/Coming/Backend/policy/service/PolicyServiceTest.java index 1bf8e0e6..13744045 100644 --- a/src/test/java/com/Coming/Backend/policy/service/PolicyServiceTest.java +++ b/src/test/java/com/Coming/Backend/policy/service/PolicyServiceTest.java @@ -4,6 +4,7 @@ import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.mockito.ArgumentMatchers.any; import static org.mockito.BDDMockito.given; +import static org.mockito.BDDMockito.willThrow; import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; @@ -22,6 +23,7 @@ import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; import org.springframework.context.ApplicationEventPublisher; +import org.springframework.dao.DataIntegrityViolationException; @ExtendWith(MockitoExtension.class) class PolicyServiceTest { @@ -68,7 +70,7 @@ void should_return_policy_response_when_valid_request_given() { PolicyDocument savedPolicyDocument = buildPolicyDocument(1L, request); given(policyDocumentRepository.existsByTypeAndVersion(POLICY_TYPE, VERSION)).willReturn(false); - given(policyDocumentRepository.save(any(PolicyDocument.class))).willReturn(savedPolicyDocument); + given(policyDocumentRepository.saveAndFlush(any(PolicyDocument.class))).willReturn(savedPolicyDocument); // when PolicyResponse result = policyService.registerPolicy(request); @@ -95,7 +97,23 @@ void should_throw_policy_version_duplicate_exception_when_same_type_and_version_ assertThatThrownBy(() -> policyService.registerPolicy(request)) .isInstanceOf(PolicyVersionDuplicateException.class) .hasMessage(ErrorCode.POLICY_VERSION_DUPLICATE.getMessage()); - verify(policyDocumentRepository, never()).save(any(PolicyDocument.class)); + verify(policyDocumentRepository, never()).saveAndFlush(any(PolicyDocument.class)); + verify(eventPublisher, never()).publishEvent(any()); + } + + @Test + void should_throw_policy_version_duplicate_exception_when_concurrent_insert_violates_unique_constraint() { + // given + PolicyRegisterRequest request = buildRequest(); + + given(policyDocumentRepository.existsByTypeAndVersion(POLICY_TYPE, VERSION)).willReturn(false); + willThrow(new DataIntegrityViolationException("duplicate key")) + .given(policyDocumentRepository).saveAndFlush(any(PolicyDocument.class)); + + // when & then + assertThatThrownBy(() -> policyService.registerPolicy(request)) + .isInstanceOf(PolicyVersionDuplicateException.class) + .hasMessage(ErrorCode.POLICY_VERSION_DUPLICATE.getMessage()); verify(eventPublisher, never()).publishEvent(any()); } } From 2c3aff1addc5440e843c7ea83481377239c0762f Mon Sep 17 00:00:00 2001 From: You-Hyuk Date: Fri, 18 Sep 2026 15:15:47 +0900 Subject: [PATCH 11/15] =?UTF-8?q?[chore]=20=EB=A9=94=EC=9D=BC=20=EB=B0=9C?= =?UTF-8?q?=EC=86=A1=EC=9D=84=20=EC=9C=84=ED=95=9C=20prod=20=ED=99=98?= =?UTF-8?q?=EA=B2=BD=EB=B3=80=EC=88=98=20=EC=84=A4=EC=A0=95=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 이슈 #118: application-prod.yaml에서 spring.mail.username/password를 필수 환경변수(MAIL_USERNAME/MAIL_PASSWORD)로 요구하도록 설정했으나 cd.yml의 배포 스크립트가 이를 be.env에 반영하지 않고 있었다. 기존 DISCORD_WEBHOOK_* 패턴과 동일하게 GitHub Secrets → be.env 전달 과정에 추가한다. 배포 전 GitHub 저장소 Secrets에 MAIL_USERNAME/MAIL_PASSWORD 등록 필요. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01JNjhau1bE58LegkwKtPBq2 --- .github/workflows/cd.yml | 6 +++++- src/main/resources/application-prod.yaml | 7 ++++++- 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/.github/workflows/cd.yml b/.github/workflows/cd.yml index 7606011a..0c44771a 100644 --- a/.github/workflows/cd.yml +++ b/.github/workflows/cd.yml @@ -67,12 +67,14 @@ jobs: V_DISCORD_WEBHOOK_5XX_URL: ${{ secrets.DISCORD_WEBHOOK_5XX_URL }} V_DISCORD_WEBHOOK_4XX_URL: ${{ secrets.DISCORD_WEBHOOK_4XX_URL }} V_DISCORD_WEBHOOK_INQUIRY_URL: ${{ secrets.DISCORD_WEBHOOK_INQUIRY_URL }} + V_MAIL_USERNAME: ${{ secrets.MAIL_USERNAME }} + V_MAIL_PASSWORD: ${{ secrets.MAIL_PASSWORD }} with: host: ${{ secrets.SERVER_HOST }} username: ${{ secrets.SERVER_USER }} key: ${{ secrets.SSH_PRIVATE_KEY }} port: 2222 - envs: V_GHCR_TOKEN,V_GHCR_USER,V_DB_URL,V_DB_USERNAME,V_DB_PASSWORD,V_JWT_SECRET,V_GOOGLE_CLIENT_ID,V_GOOGLE_CLIENT_SECRET,V_KAKAO_CLIENT_ID,V_KAKAO_CLIENT_SECRET,V_CORS_ALLOWED_ORIGINS,V_OAUTH2_REDIRECT_BASE_URI,V_GOOGLE_REDIRECT_URI,V_KAKAO_REDIRECT_URI,V_DATA_PIPELINE_SECRET,V_DATA_PIPELINE_BASE_URL,V_REDIS_PASSWORD,V_DISCORD_WEBHOOK_5XX_URL,V_DISCORD_WEBHOOK_4XX_URL,V_DISCORD_WEBHOOK_INQUIRY_URL + envs: V_GHCR_TOKEN,V_GHCR_USER,V_DB_URL,V_DB_USERNAME,V_DB_PASSWORD,V_JWT_SECRET,V_GOOGLE_CLIENT_ID,V_GOOGLE_CLIENT_SECRET,V_KAKAO_CLIENT_ID,V_KAKAO_CLIENT_SECRET,V_CORS_ALLOWED_ORIGINS,V_OAUTH2_REDIRECT_BASE_URI,V_GOOGLE_REDIRECT_URI,V_KAKAO_REDIRECT_URI,V_DATA_PIPELINE_SECRET,V_DATA_PIPELINE_BASE_URL,V_REDIS_PASSWORD,V_DISCORD_WEBHOOK_5XX_URL,V_DISCORD_WEBHOOK_4XX_URL,V_DISCORD_WEBHOOK_INQUIRY_URL,V_MAIL_USERNAME,V_MAIL_PASSWORD script: | echo "$V_GHCR_TOKEN" | docker login ghcr.io -u "$V_GHCR_USER" --password-stdin @@ -98,6 +100,8 @@ jobs: printf 'DISCORD_WEBHOOK_5XX_URL=%s\n' "$V_DISCORD_WEBHOOK_5XX_URL" >> ~/compose/be.env printf 'DISCORD_WEBHOOK_4XX_URL=%s\n' "$V_DISCORD_WEBHOOK_4XX_URL" >> ~/compose/be.env printf 'DISCORD_WEBHOOK_INQUIRY_URL=%s\n' "$V_DISCORD_WEBHOOK_INQUIRY_URL" >> ~/compose/be.env + printf 'MAIL_USERNAME=%s\n' "$V_MAIL_USERNAME" >> ~/compose/be.env + printf 'MAIL_PASSWORD=%s\n' "$V_MAIL_PASSWORD" >> ~/compose/be.env chmod +x ~/scripts/deploy.sh bash ~/scripts/deploy.sh diff --git a/src/main/resources/application-prod.yaml b/src/main/resources/application-prod.yaml index 9640fc22..7b55b7b3 100644 --- a/src/main/resources/application-prod.yaml +++ b/src/main/resources/application-prod.yaml @@ -2,4 +2,9 @@ discord: webhook: 5xx-url: ${DISCORD_WEBHOOK_5XX_URL} 4xx-url: ${DISCORD_WEBHOOK_4XX_URL} - inquiry-url: ${DISCORD_WEBHOOK_INQUIRY_URL} \ No newline at end of file + inquiry-url: ${DISCORD_WEBHOOK_INQUIRY_URL} + +spring: + mail: + username: ${MAIL_USERNAME} + password: ${MAIL_PASSWORD} \ No newline at end of file From 623a633ead992853e2c2226658c5c790a9edaf94 Mon Sep 17 00:00:00 2001 From: You-Hyuk Date: Fri, 18 Sep 2026 16:01:42 +0900 Subject: [PATCH 12/15] =?UTF-8?q?[feat]=20=EC=A0=95=EC=B1=85=20=EC=95=8C?= =?UTF-8?q?=EB=A6=BC=20=EB=A9=94=EC=9D=BC=20=EB=B0=9C=EC=8B=A0=EC=9E=90=20?= =?UTF-8?q?=ED=91=9C=EC=8B=9C=20=EC=9D=B4=EB=A6=84=20=EB=B0=8F=20=EB=A1=9C?= =?UTF-8?q?=EA=B3=A0=20=EC=82=BD=EC=9E=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 발신자가 이메일 주소로만 노출되던 것을 "커밍" 표시 이름으로 변경 - 정책 변경 안내 메일 템플릿에 커밍 로고를 인라인(CID) 첨부로 삽입 - 본문 표기를 "Coming"에서 "커밍"으로, 안내 링크 도메인을 comingg.com으로 통일 Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01JNjhau1bE58LegkwKtPBq2 --- .../policy/mail/PolicyNoticeMailSender.java | 15 +++++++++++---- src/main/resources/mail-assets/logo.png | Bin 0 -> 24707 bytes .../templates/mail/policy-change-notice.html | 6 ++++-- 3 files changed, 15 insertions(+), 6 deletions(-) create mode 100644 src/main/resources/mail-assets/logo.png diff --git a/src/main/java/com/Coming/Backend/policy/mail/PolicyNoticeMailSender.java b/src/main/java/com/Coming/Backend/policy/mail/PolicyNoticeMailSender.java index e725d8dd..80108a36 100644 --- a/src/main/java/com/Coming/Backend/policy/mail/PolicyNoticeMailSender.java +++ b/src/main/java/com/Coming/Backend/policy/mail/PolicyNoticeMailSender.java @@ -4,8 +4,10 @@ import com.Coming.Backend.policy.entity.PolicyType; import jakarta.mail.MessagingException; import jakarta.mail.internet.MimeMessage; +import java.io.UnsupportedEncodingException; import lombok.RequiredArgsConstructor; import org.springframework.beans.factory.annotation.Value; +import org.springframework.core.io.ClassPathResource; import org.springframework.mail.javamail.JavaMailSender; import org.springframework.mail.javamail.MimeMessageHelper; import org.springframework.stereotype.Component; @@ -17,6 +19,9 @@ public class PolicyNoticeMailSender { private static final String TEMPLATE_NAME = "mail/policy-change-notice"; + private static final String FROM_NAME = "커밍"; + private static final String LOGO_CONTENT_ID = "coming-logo"; + private static final String LOGO_PATH = "mail-assets/logo.png"; private final JavaMailSender javaMailSender; private final TemplateEngine templateEngine; @@ -32,19 +37,20 @@ public class PolicyNoticeMailSender { public void send(String toEmail, PolicyDocument policyDocument) { try { MimeMessage message = javaMailSender.createMimeMessage(); - MimeMessageHelper helper = new MimeMessageHelper(message, "UTF-8"); + MimeMessageHelper helper = new MimeMessageHelper(message, true, "UTF-8"); helper.setTo(toEmail); - helper.setFrom(fromAddress); + helper.setFrom(fromAddress, FROM_NAME); helper.setSubject(buildSubject(policyDocument)); helper.setText(renderHtml(policyDocument), true); + helper.addInline(LOGO_CONTENT_ID, new ClassPathResource(LOGO_PATH)); javaMailSender.send(message); - } catch (MessagingException e) { + } catch (MessagingException | UnsupportedEncodingException e) { throw new IllegalStateException("정책 변경 고지 메일 메시지 생성에 실패했습니다.", e); } } private String buildSubject(PolicyDocument policyDocument) { - return "[Coming] " + labelOf(policyDocument.getType()) + " 변경 안내"; + return "[커밍] " + labelOf(policyDocument.getType()) + " 변경 안내"; } private String renderHtml(PolicyDocument policyDocument) { @@ -56,6 +62,7 @@ private String renderHtml(PolicyDocument policyDocument) { context.setVariable("detailUrl", policyDocument.getDetailUrl()); context.setVariable("requiresReconsent", policyDocument.isRequiresReconsent()); context.setVariable("fromAddress", fromAddress); + context.setVariable("logoContentId", LOGO_CONTENT_ID); return templateEngine.process(TEMPLATE_NAME, context); } diff --git a/src/main/resources/mail-assets/logo.png b/src/main/resources/mail-assets/logo.png new file mode 100644 index 0000000000000000000000000000000000000000..94ec3917b967431a52340eb66529f21d6e96bf3d GIT binary patch literal 24707 zcmeEu`9IWO`1X732}8&hV@b9o*|Q8G$-Zx)B1?9%FEhwa2vK&Do$M-%J(0-1Ga|B% zeK2O8x6k+a{t?gX_0$i%%*;9GeV_YW_jOq3BtNM*EygJ1!@&zWc}pW5;=455YUVkdVx8~}Q%Ix0XFX(7 z_^=i)+nX~XYhF%I7#qNyzJ+_4Ho&i# zQLdVq?%LESO*h>^hV0{FrNEXEQjQpnvMR%_<}=6f!+zOdEwc5w|0ozfjd;fFbTt!) zO0Qh4oS@_`@B=N&tE6XVn0@x!@zzaF!F1n%cQER%MOUn^(bb;vySjQeyG;7eY9*aW zuj!dM1JTa`)y_kYt zbgXzF$?{UcRJZnoG{hLm|C$;&3dl|UwLa>*8~RW83u%}L$6OaCvEg-kCyKr&irdrL zWf89f`?im^V-uo(aYGUqbJ}M1e~~C!b{@8d+3Ucqu zU5;(Y0b{1!O7U)uUZ7F3I@G%y-1aHfcZ{TiDA#cz4Hcr^ny?Wq7e@`eXtCte-mNz4 zU{g5^1SppyNV?!>*H}34?Biui&v=SY+d9(**yx!atynq({5Fn`rKnoRsoRVfSCG#E z59Kg4EI7uB0nff|tQ_*haJV^Rku^`VhYWddnjEO039Iw7=r`y+p(>N{}eq=Ps@i){aT6)w90R2}fYqRtfeV*9+>u zgX29IZ+=fa`1b;CxMT;&Wc%FvpSLzOu?(MU)eQ}4Oo&X`cswkq2PckZdEyKnuuQFNBSi*W;QBVo;8`}A)lB295Y%4m`4H?$kmir{SC?c$L%*=n-NI#*z0Tc7R&-R?(75<^LUnqDvk8Lf zVb9`1S0`PEG?p#9M7xUPru19T|FS2ps>6-`*wN4=B12Yw=YN?Met+AzW)Zf86W=*_*^g3Mr2A;AWnBpIve0C;lEm7c=FPgKYAe*GSn22iz(g#4udtJ_gxp42 z88`dv$;Q0le9R~bq)PfEX33e5LW$mHyMJ!#B=C@i6b{F7Wu_yZ78Y0)A6JxXi-)%X zhIgXY*PuIludu{K{KL0n*{)71;Qb1CV)R2nuxUd#(>o5;L#A<^T!<9Mgu_hpk0JY=X2>t5-yMk2^9W;9#< zFkgGV(GNacxej8DjnA95eLuZm#`a_mC;>`pI&*t45mxi1V4`=!)?n~sY4}fKnH16t z;-!@g;!#-~Ph*GpFOerr>s_yTVxE8~8_>P;J3Y7ZCGNUnm0A~yF^RIP`=V#}&Us;i z(GRY1`$1-EgTKVr6u-M&^W(wssN!}UEIA{~%q%PL^5jQt94}Y5IEbO-Z<3mN+?QBsMDw`^P*RV$+6&{Yv z_~bxg?t4Orqk^KC#G9TpY`|j{QrnZhAMl8!sC?lZO2W48+%7qX$lP0ev0tTpppUD? zKxkonp6G+np)0x-1>s!$*T=aT1;~b(6cGS1?e;fpQXdLYM-)in0DOcD%M%$A)v^y~ zBX^MKSvv8s+|&#nh3(Rmg)z+!_xqf8#4L~&0M+Kc+lblOYs5+x8>T)%*^eNtTw`vdUD#;m=FRJ5hzIr^VFY zwEO#icGuDIMo|M_w22|stEk{Oqq{uyJ@jbnt9@M|H2LT1zC3#?%q-RRE79WF{HtlJ&r2lj>L}_PzwdVvv^~TE7 zJOa@zb}HOe_4i-+4;N>k&j=x_kYt%;BgYf@gmvcaF&YJJmk(K5$f3ax^^%?0X`uEZ z*Ja8)Z>i{zEbWT-v4KG+xQ9j*6DU#B!SKhsi8btlx&e7U@0u)fk6rP+_YS{ydEVkvP=28FJ-M-8zAjwHV!A}2I``nzhvb%?aMnDvb!s-gQ-=AUMoLM^?A2p*l z**@{umFGt9fv0j0c1P9LRxyc<%6mEzD@G72;oC3gI2jp*CCk)H&VH8Dh{adP8i@+; zSjTeE5RSRZs<#K(-KXgo<(|=g2?wqCd@^SAoTvQg1;sBa7xhI9QwPk@7QZy8gL1L4 z`)-){9`o+#yVpRd^o_Fc8#kbLu9ySK3qfd?t9JA-qmy$9VmaXLi*G$KdJ#?D zcFVd}nB37B*9qN;(&Wr&3oSPWu~rL#+oY;BF*g@Do=-hM%kh}EO$s5 zB8$udqxg+{eleeHTq5stPMoHoa2LS?j>$F*{)*Z-ELZCkcQbx^uOQnY3XLd1FM7u! zlrg$q@Wnq}#;Ln?(l$~J_CvpnNOs9=jmL-r@8Wne8TJi3GPuP2a{x~w5H&s!)jF-L zkaB0R&AjzNTUQP{MLGNb=@8`jCtEW`rpU?V!g8jV@o%CK>D1?d|0F!*V_F{ zuOc?#CUiJy#A-qAEvsmLNiZa(Vj_Y=7{8_)qY;WKZbBhz@1y2JJ{d~5&ZttJTJvjb zN@t*VeXzb$oC&n8FthVrp(l<67Z~YBpU$n*vVdYh#=}Yd8CI>IKCS$~MC8dkKc#~8 zDPWjuR_3K=DNqivEjGYj#h48b4@A0#{Iz5gn>G%f8T)-}g+sB;?jOj_D7#Q8<|PYe zx>MNU{4AZkoh_%fm$7?tNIE@}$H7>&de-)jEk^J{);n;u6Fjj?xYpQKQX(dm|f{@R2cW-osf&YbYbI>s=bQM zs}>3G>>*jBjzu|F34M7Fv3IMM#v>37o+Nr|ARaZAC67ju6}gBZc}DqhjDo1!GPS4V z+FAU!SlMM{%LVNhD|H3J^AcByS@7~4_-r461zOR+Ocla}_UA_VT|^LT7iD~6-Wd-h zUYvr^m*{I@Y&@F*5rNIDv6q}YCsu3>(!XD*vnWq42{5NUM5D+BN6As=hjEg2H7`ya zO>}6-)d2a4kUWs%R_2Cb;3d@U7w5+`xIMbe?QAF1-z~HB*wA9&P$wKi4)EJ*DB!Hu zV#4}p3X89Dy8ZjM)?WCi8nfuAs4-7T=>IJiX_zBWY!<2^PyHlWsa@`p)$DAy(==G42yr<4NG@wH;& zoyOHhWAUksp0_i}p>q>kS=N-cK2b*%HrPM`9CU$UJ|b_|B4YD;<>ObcRK``s$j@l7 z2;!wDPMhDD@XrP#S^s#M2xG(3%u0Phrg>%ik`&H-vf%PG?d&M^|BI^lSV@qmZD<+q zz5Q;BLpHcTF_*-4s^iE-vmJ9{hlSY+y8Aw>#Pw|~icpi(ZnEwpNS{8&9a%yNea!@H z(BD2KA({*4=rLI^L2ItBA43HWB{d*}axtcn@Z7h4qxv2cEXnvT7s2t?RP3MK;a~h zJhm6aR`~v1!wa50`tfe{Ad7~uQDeexe6*M)rLt!ei5fo=lfpq1LzCO`+?=*wo2AeX zvgT+XOv;7SDel%8W_&|K6@OLiHf@p{(-bbmRRg-3X*|_(^9&W_OL4My8HaF2A)?s% zVz(yIaNch^nfH|pbI5d^`8e-C!`d5So1ihc3X+pm37Ajjxcz7Y{n{Max}_+S7aYeU zHKW;uGe-->Nm{%Q3xG)i3`X01imq{30?|@mGqC|Jo+t=qJFx%F`R7yh)hS0Q;$|jf zBHGRSVu2A)?$#G6iQ3(NJ9&NUu^h3ktj93{*gx>?GjY-7%W};4odPLyUwl4%X zEV0)C)$!-H!iub~;VL1^udthYp{g098!K$ z%aAC+kn82CB#hFGc)&dsB@wkoo>zjU1ib444^vCa&R)$a3Hbl5hQ4$5w`)saxi%xZ z-_MO`-1P^n<0?j;DQ&he;e`POQd?QhZz>JuBu12DLMuSGvv{|Ikwa$22X+}lRZoU{ zikx3`**|jglD#S+>f&T3d+NS?)CvNFct(47fyAoCwIPZz?TiBrindv05c;_cD6K3o z6={y8CtwDgy>&y(zq$TLE@Y+mM(SI8ys7E_ul>#L4vkaeocpV$%)NqunzzthuTGR6 z5+#TUrC2b@K%AV{;vTsLHKusqK z{<&f$+osxA9QWPo$f*5E%&~n13q78|&@ub6|5lf{6q7USo^LUVmre&%t13T0W52boSV2@FN>}OrtZ2`-9>u1`POQ5Y*3#LY=M~o14A8#|BOeY_WQ; z)&T{%hc_kAD6#d?8f~koV$YxT16-zWj+?&9G2#b_Ds-Q!C%biTXetz1jI)R(ijkyn z-6Kvy>yZ~(W1}*9|B+UU%%%E;C4Kc;qcK^xQ;byktMv>U#WeQfv|7$5E$xLsT@8eD z>xOsF&CosbvFy?-gy2v4_Eht@pWh_yVw_Gg{-sB+djYkbaXzMty($7w#Kj|{>xVyn zuZB+uHw3--s+srV#l!91?X~u?Y1${zeM5IFyI@4ob4H(EqEF~`JasQ#`}1fKqoN!L zsZofGvL~jPnZjyQvz&WOY)GrBmvcWuJN{lEh}~71z}XQI{k-9e!Dp&$b=?h{_2qXY z$10G%oz{$g?A*}ns0Q1j@$^(NvvC=*sF;2&qW8p{BFB9kR0x2`EE5%gmWkopsIVK! z8ldWPyLKJ@02}4vFE!Y79hZB@o1J zuTfW)zU1|(^Rnf9Fx{VcJIRda>Zyi*DRkJTZ^}F$98y}H1sZ)}<8>gWP|DxmQ<-P1 zdU9yN*!unKj3yEV&{FNDGz?7$F{S!rDHTN;K`<#aJW{@tr9SfGxJ*qvRR&aw+j6aB zTxa|;Ov4We?bf=a1H9|s3jIO5lC8e>5;A6RGzMvAj zam>(iE9uH!x`#9l`cjfANakdfln{#oUI?}k4ab#!ILTk@B)~h2y=o26D6hHk?_J#* zkIpfZlT`G4X)Q-%<5Y;vhZWYIdG6cGx4-^jgAK?;lGoD?fP=N!xf&e z5W*}#0~MKh7R*Nfg#{l5k6V^iWy>+QFXWDWc49V3bIgWi;S{4=i-{iq?YGNVVJ?1e zue(MII2$cq z$qZS1{Lu|7Rkn^R=s15Uipv)Bn_r%fs20h2!FrFiKu^RsGYk_&w9A(x-8MIVb&+u$ z8psU>Lk8$ePFlj-#nj{};PAn>#?!UD3)`EI9I?cv2Do2hZ=Pgpg2Gd!QsD!x?@>(K zpW6~*<|q+MQg?%`Z~Hs_3wO!z&jq8$PciiyQVGn>I z8?MCGe4e$!Mm6P(eMb4&;Zz0a_k@vFpZ2lsKwULqF*<8cBn@4y&$|JyS%GTYwlt7W z1_Krc;O^!3n`U!1qdOZ@eUJ2j%*}7ZpDj)IEtu|I$S_*+w|_Kz4M(<)6rW;nOxZGC zFUD1+s1BOQ7Nhd+{^k7M1+O*m*;d~CeVLYM=-zrk3p&L>MsS_$1D)4!K*GJ!1`W46 zqxxurEpM^?Yn$DU(P<59SS1MfUF;ojHFLCwq<6u9cb73y7q`zN3p`=-LI#3crF`Z% zakLky_ZKY$v_AU3k(@e@a~utQbh;$0GgKpLq4}*>6FyFRA&<-wrz8*qu6Gu&hF=l{ z`*;d;kxFz-43rSPVLXj6 z+#Ar5>|xb@A5je)au2@sz8Y{7S0naq8j+bW{ai;JZ_yLEegHCAf~gkE07eHEA;c7% zu{$8cSa!9L=F!f3IEiNp8@TRIXKt$B>0w2;aF9HHjq~}`5{3R7m!4d5-XGs$hdAI% zGA%ap7REu+-0pic!bv~A{8?|pDJ^2zY0WUCVKMu&Q+R;Q#ZXDWQ0&=+W~f|jpA7d~ z8pK>W>gDgI>@txLGY5aIIFlGb$Bhoi_^an%tzev9@^D`Pw7iD+`3KY_JzAu_I{W^( z+mWMzn^qylBpUCA{!~mvML0BW{=3IVowf%wsuK3DRz$*)f&0e7XK%t+RgEw+ohF=q zT*nk9oiJd3?C#(1)o<>Rf@ryfDt=-_0dtLSo9wa2D^_;4O!vl%8s$oQ*Oz*#eRxfP z00wbVw=WQ#UMGPM7B=JX>nTf*AYX%|#d;TGb~2qHngXT`>)%Sx-ZwRnx&gGS%8^wO zh!yPV(b~{}j&zb-BDJDOQjyD~l4#yJhY1MeLB5!G_9-E`^_M{Xso#s}$By)u7r!DA zm5$8zjBY_2W^9bQtbLXjDlYhq{Q<+nCyJ7$RD_3gUnX;lzJsScbtKn+Z(~2ZARut9 zawihu_f`qR;#Em~OXQC|aBuGWcg=p5jNB&b_M~5dbJPf6H_chbr#|mzy{MOAn$0J9 z%H&O&Dz`sR?Yj_BL*jhbTUxiOB?a@ujl6eS{!op!=#msdZcr;J7-n)kX$w+L$-Q?J1X$m1<^^Bag0h~$CGFn)$Za5K1K(dy zG*a9}9_o|!p*RiH>SNTu-f%$Nsd2yBz$GxzuUM^`L@5jve;T*NNGizTChA0An*g zO>IL49AvC2*+2)m6hGX`5P{0;h0ZzI9G5V>i0t%ZKvt7mA6M&lN!zcV@y>!)Sz$d| zoukg9d8CDn$~N;tAAPsTOlpawR{V6$%NY9*3>ZMjzixGpvR?dR6X*R;mx?TP_GOSw zbQ!q5D*wN|0N_YvI3`$$Nk^*y7}Jr~@l#Cv+&Z9jLN5ylS3AbWB2k9wx$j6Njibx( zhXSX-&Q=G;O+xdD@&3dJe7Xe5&pUIrqy(ht`F^Fv(<-VKJN=7Sn7G9!@(diL)*?6^ zqMy`hkJtSz`*^}+tET3$q41|&z9h_QtZ?zDQTSCrlnWH|i3l zfrc4RWk_K%C$4i|4iwYfESZLeK0U{0Rjiv>);*gP7**qcdhxb{CJIghO?5V{?cP6A zk$;+7uTU12z0G#%*vOYF!m9l@LDT*`)*F38e77w){2UF9Sdi@u6^m5A9&$_3XF1tj z0T=46+NOhYa(PgYBj&7ns-X9}jV>t+19iLY(!BJ^rc(H!RR|)8FalBlM)oK3&}{JU}mg8Wsp&ur#-E(J#nJCoB^m5t(JV z=ZS$%tJ`Oo!*aLoGo+7myb#U`?RzG074gXF?tou+?@PUHRw-{PHicZZYxwns7}Zp$5`NOVm0yqo~#iL*68`5~{UeT#8%i43?CLXk457A3gO+ zom3hXjP3fvu~t!<$MrHVvBZF8xErx8CAbZWAn~BRsbG+9gHCJ>k;tu4FpK9k_uv5# zTSxO0i27)#*XaoS1?6a82y58I{E0LS&lONgMx-!6sz39bI0;gA3}q+2DIHwpg@ziP zde>TCT(z!V+L+q>b`7tad>%I;avIg}BWA=J%dKRoK(%fR&Q2yp78T!U4l5Fo1l}tq zc>US&1$RL)=z1F>OYzNb`&fYV_oOc$4uWIqUC1@No!lojHntyr@`Rd@#CglCJbr`) z#L&F58dyB&_ecU8?s#Nyf-gZyRO;-k3iK#!4n5Ot?#EK3Lf4&1FbIJwgy9UV&iy{H z3B&Kn>UujZMH{Pk@c|kdDrX-&vVPeKL5b9xPxlhVg~AzKf&~A~#EhiN!GgWwfBz;k zC8UX#{mb;yxSjZ^zMHIl^JXA-L7VpOLo9LGKHDa}T5%W)_*n_uGYVHG#Y*7^D@tXk zoyNgzkl#iM$k@+HhmFv(0o5t|B7|=Yc-kOwzk!jHN}Yvl4p^VDL*8chyBp83#D3+N z%Hj!A@M%-_jpfcbkcqQW%}n@wL`HuTMD7hWGRl#2$v}1O&Ri=Xtd(nON@*e-n)WXi{75Ays?5FU}$u67X|-JhhKe5 z5~{f?>9HJsA0O&)^4!I^LzdS~{^;EiWlW2Ps*wIQeE#_3V_ea(hNNAdco$jw02Rw* z$m}X6s?T0oQiRa{(0hI??N?v;5)M4WKy)d*=?Q1@_f=H$(h#7q`E~vj1HB@sT_<|A zh(!|M_!O&4yySx=)*YYR8oRD#QRmM=A_Jb@rSO6T4eJIy?&K4J`@su6m7s)3$Ij$U z?*HI$^z&P_^=6QL0wv5sE)3VxSkDqJ=PFq*#I3-+>(>gL$fcT2+g?WRk?P$bf7(nG z4jhu-rB!4@-U6s;$0!bS%dn5~5JU4)heNe(6_tFOP2KH}`Kusv)C3G*#oX7mg)_<% z*OzeJzgsuhG`|n|@ftq-oOxQcCo{*n*1$#$rt?*luc;>gTE2DTo(wG>`0(_MM&py+ zzQU#STGc0|L?zD@nFC4tqa1h*w-ML&?Mk}^vLz<(Gzec7F?&OJf=eRB^~TD;#7ju+ z1B*FI0xgQU>kNfy*Oryg4_FccIEs`A|K8=!-2$rvY$?F%Fv?Ra9;VRhnS;cq<*}9%tW8}3#}&4MQm$_1L^DTq!t1jXT#Ih^ z-)>0x`=&fo+&3Z`&(b~!Jibq`lv%;-el_64eh|EVVnitRW_n^`jKH_xl*?!QDJ!&9 zu~GP?HJ~My8mU(Lc)8r;(n!d>njg>2_Oj@GtHaUIj$CrLM?0@oSA-MRe#ut(jAV@y zf%*f48=*0Gg;3=iM}ieq!CU&`j}98@E52Ks9kJBbT)c_91bj?ClB0*LJzV^7>(77( zxeOyd>2GUr0^Gd1>f5F6NY+tPlfHgPUNEmw|ApS*o!RXKxt+Rp;owca!3#6p!{AWO z^?ET0^x~dtPV~a!-KQrowjorN8j3@zeHX!`7`KUr&A5l{AK2!A2RmF)O@}4aZ?{;M z2@ibszO2kUr%omau%1t&M$ynK4`y;2j9L86j$Th&Gk>-x$Wr0}MW5pAQYhh#g$~G+ zH$__TcfgX-9+PjB;T#GU2DgCcUBIsQdKY~;N`FF-8F12@c}l#b`aFv{(7KbFyvZEf z!#DiV8qd`C-s7G7Jsc#tDOO5uQ0Jz7Gbo){ZXcZDDe}&3^3^W$ERuE?YVyYuJFT|I z!&ud3mF^iEsZtDd8&gUki8In;eFlzP@S6>Y0>f0;TiiL}0|Bhh*@HsEO7^NR#u zE*uwf$iB^~y7|{j?d$+)hA1_Q&OHn{ZR(Q7vEa?>Va0Y6xZ{EiWvkEz^6)Alnnz1w z!L>!YA@^#Z>p%eC4gk7X5GeKffhZ`VfACTJ1dL9A(T}#Qg?Fo)xGT??x~!4V$d-mJ z`as6FO#GZjfs_M`_ocwms0qLR z?4&lcufD$@2IgRvB1y9aJBQj>wOay`;eNtKeAN1N8pKQ5SYkzEWsf5|$$>(SUT`?g zD>URw0obUW$NK4n@!{&;6azXuU>+L6R!DUVS`K&n#jn>YwV}NWmZE=e90+Ul_^!#y zN6v!;3n}72)ns(zNLmueDeSuZ80V*3N7og%OAVmmoYm)tZ=&Epi$!fn2Z!vDUO@SI zFHX+rR05ghD%;A2Pu5zSv<~VDa-zR+KMi9uwTgjOzKH*9!hR$UTCOk&o!!(-j|UEs z;)Hq6XL|K7*uMMbE~H_|IrrdunhT>NK~QAjSC({Y;)z_L?{aq};SU?rcSRtl-%YrDW&$x@w0dM!MGN8CMjwWw?u8 zI^`GxNLL;ZLvsHq%Y4O$^jBaKrN7~q*{VKE{^N=xjGK~o!s9_7qhbSgS$0CO#DY_H zrkrL89TRD`+l>fzGf5NBy7Jn}OH0~UQb z3i!nUftbdjA7Cu{O)|uK`!}5c1w!%2ji@A{lV2+WB{Om=gm}*miukW=thCx^h>l~J zXRlSjyrNdYkEmX$rd$^WO#^n2CQ{O_+W!UHW{{6O-5?H*rLZKfnEn`p&?9T!=Rpn) zcOJVPtqp#mU>_1pKfMIJae{}C+@OQonhZNWKNKG+aIgo7sH6ePDQAsDMNqx>UKs6S`71Q_HDZIH@H?Qj*y5Vi(6dGN z%3pRU8w0(lZqUiETrN?ofnaDyDX#mr>>6X00X;3;545zo>epPxp9vTy#c^C;FJ0=V z<0r4SpOXH949|j5LdHR}sWDIfWd?aQ`4Y%jfG~aAa_P&SFF6&i?fHgGG?XI9tZki% zuI7G1sa`dZ;qEcpKos)h>}RiD>L>HrAOi?C@CC>Ml9Ae9YtGdZ-+oj=c()v}gPx9g zA4J-&3=*_xE_0G*7#!Qqd?W93W5 zlPAmo>iW+cH2YnutY?+LW~?>-1wGPEgYhQl-GK5bB{B+h30l3CB2FAc$#V#=T*q6| z(AB35MF3Nd;4lWK!JfSW!+fu*eIwx{y7czuLY<9w5*?X9GG6v^MKEdwOm)EJNbf@L zGvMErpKcM)_;bnz7pVH6r@` z5~?TP(L)yWE|m&Cfz&sbI}z>U?>`uUfeMd3w`nwWvh8^?mydzYIu+l3Y@zmjZZs1- zmxhTo`tO7Vx3yX=KudB5U2WI6{Ak(l{BJ&&6jc0_jB`T_^1Ta2E3GGVl!p;;_bS}k zaslgrM%e_;?AKP~UShbeKmXU-N#VQIUzqVoF*nGgNwW3`a9cMX)jg1RIC;H$NW8S! zSjYrwIqmH#JX`vCJx3U5Fn<(y>M7U&sGd^eCK@Vza2@Fbt^#D=fOmoMhF&DX`lNlc z0kk(dh?q0^0BB|bvCDGt)5ljJI2+u@H9xtEU^_6+9xlQ}0lMX=-#^3xJh0#Tw=sM} zU`XXEuv?$5+C=ZRoA{GdR)Kgc`jZ(Zd9(WN?3qNv;xnvro>q+7w9Q4Azq*2*>RZHl zl0JxvQT!i!wlV3$gcLX6Q1|6_5T72nrGoHRE8gLgb9GR~Bs7dg=#3Qc`7lGf?OD{n z_O&Sf7ni1+I_*B^ii9Vv$A=qFW8D|HA`w^xHXxB+k;@@kw)1TMj;uVWODErFQ!^`g zbV!LLM#H`i4z2u;5%iqqy|00P*lw?TDQdo5vAFl<5|twUz%mLr_Nv!215L0|!Onjk zIgNgoSFJVx?UM1oC$A)8=RSyfB!b#OWbK+kVL7&4U{)!&de=W}snCJVWp2B6vTm$!?s*Pw#;L^vg1-JKtZUyLV+t<~*8YY+vZTgx|j!gZMW%%s=o9nS} z*$D4p04RD_5_|2#Yed6)yl?i~%z(M}gEh9#2G@^nU!E1#HR1p0g6u!10Jh<&T^sP= zJOi~0B-j40j=)w)9OP-aVX6$QVC2YSIS8~UC7fiP_i}#Zdj5t32uiVAV~h#Cl??~k zH^JpaPM5}w7|iFiW|Bw`4}ipIVmhPe{x&wlhYzGR&}c$GtO)FchWwVnDmaV zN^U-6t20}6`rUclGY>{WZZeUfZ7%SEy2V$Y`fh_TtEd~K-b$UiRCI~+B43-jEe2H8<`;^Gbs>iLF*lnZ9_wW&* z85G-=!f${S=CK;_9AS`0;CVFAkmKrdZ?x}##_G67@8 z@E%oseTvCpr*+xyV=gf;Xm73@s5Qo(t;!F36>{% zOAzF{i6D>U3YUcGK_3l|EZqbvy^hTZBVS?GdUPbVYTrrct{~b;+HC9O9vgr{b|X<3 zG{68GrG|R~G>F!{5WEf>PzOco30$nTxOrt<0LP_qQ{MxE!c<$>|9HPyK96(i^#RGI zE_lnSU&D#h`;k=OCE(l3k>rnt{zg#m?^b*ktrLF7G4KQ2&C*xpF=&enu*>@Miq17l$EKx>Kh99=hanJbxFK{400(ukkI(a z@A^W&9&dpfk_RgQo*hc(Iuqvqs2ESdY{BmT<}m|#T(S>;X>WW0B4eQ2l?Vqk%qoFe zk?e434}l}6*V;G2LOAX3nYHhM2k{e!A} zOLy>UQX=}XF`G44yh&XcNWAnA8>fgAH6Gx0bC943VjDT)Ruc6S!9hA)!b48h;VuEsd(nz<0&0*j;HYatkB@X+yu z$Y41^(M9>>C;gVuq>CgeCJMVLdkqgH6;1d04fG`QF|^>O&UL@={slxL;g}i@ht|jX zdolj>*|4rVAmglOxdlG&<>II>$noTg!`tA3OKu)fD*7$JKXT%_qBhKo%ei;^0#}nG zhWV|j!3CKpxrZ#2C-$^GX$&_!9MF5Y9#CATOo0F@{lD$wo$BN^K){jvI|cRuDq}>K zJV8u3ylK&+YP0@Lbh7@lz*+up^4OJwM8U#^FM!-C8P`)DG)Ayh7c8Y$Jf3ldW#gHb}RLaOg_?(YxZ)3>zHn5i#<8X6A2dJ-wy_|Wh%;CJBg^U|=#Ia} z;BjGosGnOUvY^{WA4}*K{`nrX0#@_S1JHuSk5pCfy7d%JDtQE^%Zt)Hs+W0zMrj3k z&g3o1f1d6YRKLpFJCzC^awGpmV~^D9pi?LDcS0q7L9jFo{!=l*`2FlyqA>MY3>b`o zk|)j{5BE!27!&ai@d6##X-Z53?)XW`T70A6qWmOK9`vbrWD*{PH>YM4&TE* zUY0@ge@Fq56&mW;D9J1z3;+{~5ky{U z4TU88-)VervwKj0si$!9tn|I!ps$~1TRgtQo?jZsFmR%QCaVkC!hMn!rSL28U-E&` z&*}%dq3)bT{J3&1Y>XqT+L{7kiAVmZ0wpGPY%?*9i7La3?(shcy9VX@NhBfea2ywH zpN!<2Hwr~uLi%902V0$R)kBt>gdS4FufVsIF%y}qtWIufV>vZ6nkZ(@*|5echTs z#^OJXK1}a~aT8dvbFFfx@6=A|q4}?h@|z%$YKxn;;C%>rCPjT}q+`DZx2Qt?t~+?V z%jWXhAA~5zaFgL(pYb9k&;N5h@CY!Aq38ZN%6`TiU9U(#Q7qBe#K zC^y~$%b9VIY;TW<80Ct^*^_;=Qig=CcbpI`sR$Tq@FanBwJQvx=8pk+%Co4+9B(82 z+zJuU@IZ%9_N1>6P8q>G3^T1v?AtuH4A?XQZjf<;HN9vO8@Vf7B4BR)j8{1mU#2+N zAq-~goJf{K(k-K@Y{7TzS)=nH%a1lF`}M&;vS&Y9PIz5RIq^3Gm_^MK=d==(+0+a^ zfGGP6ycU;wd~$F8vaZy>+DI8+Q1Tn4AG(qH8j-=ZZxFXi=b!nQQx%k>%?5`i{lJEz zMEagG@KuMJA(MB%u(6dy-?W<=T8xm^7J`E_WLLkf9L{)EX`P8tsr;)z$7ZT2yK7!@ z7^-zaHZfgR@H~1{KCgJ)ULVw+9|_L!14Ws4H{^w*j2g%!J_-0bn6VJr40^8}S>}{s z$e+Hn&^U1lN-D-M6?~_j$CL|F;B6h<-+~aT_djaPBEblDAQ-e(*lCZ3`>(l0)P19&E}iFGUAa}Zib8~IfCVIGJclYUG~d@ z;{D44DZRvuf3|IhXZi-{<5GrKM@SXv@^#}+`)eXMELmoeq*4}e@uopHDp9>-GwnDDf!nzs6O4qL4H#T2-Srw&^>kn z(}$Q>L2T`ykMB0O9B)nM))`vd__X|sAqrtTH@V4nfUN}fKYsmj5T(32G8O@U%33qD ztiI4i#u?5)2>J04AgF7BECBbiao)D3B_nphZ`qR6*i=~HFAxZtDo+qzmFvF!9=xyf zSR^Fv6p58}c?H&4f+QVIgDspZ6dm7y`frUh@1MJ>R5gHx@Fy zn%a8@T!|xnuR%glUEH-H#Z-<<0IWWUB7j8`(fBN*C4D7H$k_DfAOfc#r z21m(QPS7A+>9Lsi0oA{JIMtU`@Iq(Xm4|#eRf8u41_x~qY!ttCwNu|WzCC9Zc@2@Q zc^zaXSdD_=X_p4)igyX=32Fb0#7!J0JMRlVDiE80aYmhGzt@+sKPfVIYdGS{^)~@x zJL@WFg!^HF1ijs$=H2KLHHjm3KVoDD06M>J@^_aC6%KHFi6xW}SM#nT&(ScuvLMuD z$A{j-*_7ZV0+**k_|D}8jks0}l$tI%LSpULhYY{FdK*S$RcxHYZp4qwu*CXeoPq6e zlEyrVU{N>pvegVoS}mm{V9=^0$XZSp3Ox4&a;u^Y>}J8{&sYm)(*&a#xbiYikpBm6F^}0dg?* zY?3LQ$Dzz}qg!5sZe$?3BkwnKgh`CQ@Ui?CkG^jIrWBj7e**aPA3qR%>2V9kgWxEA z*=7~oG1qlX=iy4uS-bDUlJ>LGrfiT!>0&ZPS>nNQgaQs?CiAd&FZHjP$-~o~TIs+i zgTbp&fWppuj2j9OPjWO^=H<%#&QG7)7aI@WqdGHX-7>@yBen@!^W~K~53Mc_$dx?q zX7~(^6a5r5!N+7Q+kbHKcGb1f&y1_*PoI)~&EdK`Gg1ZOe+vJbqf>J!NzIlwCeV+m zPG4~oV!)T~k*8oHgk0c}JFtFOci?etKQEDQJ|hK%*yRHvi5{t9iD~&U#hrUmiJr^- z&l>3MSffZdG8%xcc0?)se_ZzAD7hi+_l9|-oFSLi$jpCyl}JQPC-tV>;*k+Va~i&E4FhU_43QXz{+m4LqGU(#r62S&+>#sQ|mglYj%2P%vTH zt5H*5*;VApOXL|qOy=3|R%+|TY=5X~UcIl#p(Q;G>KvSlFwEBco0c@Se{o>H)3D!! zQH1a(1RD}^gsEKO zwaS~Sn+R@wO?vA+RYumeum4dE)!T3=7`O>Oy!J!s3S6TO^fIXD?rXRL2U!vH> z5C4{Yqf!TK6&eB#iNFrpN(e@`GgKRFB;wx>##eN2n(MaM?HTK}wg!W@P!Pb|!my%- zwO`+q2l}zh`^Ym5Hx}McUl^mVgI#)@VfL&dnTV!O>oEqyNoq-Fm?-`od+Z4fZgpG( zJJ!OcX!#SO9fgpkHq7-Nlv*~i*Xo$*fl}J4Lcy!NFz&O9Ghd(9rS2bzeewzEb3q9Z zFJdHmPpy}V@TP6_)xV%g6zin;>1;YAn$YaQ1&Sn1hmL{6XWc6=oPmHC+>yqHD&0ls zHuzlywfeS7;2~&Hk+{W;JR7WVgJ9hfqX->ZOd9X@tS(Lu9-6-o)mO9sxjyRm52s3d z`(bDGuo)$xZ`gv3=%AR-!uobY=&Wb{nIew$2`EIgi=1wuz&7~d%tMzpn4(s(3kb|p zx}&@2FADZ6nCo|bvl;}Y6GbCj_YGeYl+;Q~GhYs6du6A+?+XTn_J0FfT3>dF?#u2u zuz|RTRSv9b@yBp6F{?HHJ_C?be7T-NI8vi?^J97EV9)*gl3-)w`U6={%oY-CfbQ!x zlURE~A8nm4p<2$=CdBQvRSC0E+egEadAJiNvZdD2%3|uG%-^SF6(hx;c#bB#?1KSW zNmvRHINvcjA6@kMgkBN1+}qY*$e;@hp6rCtUZ`F+6HPF2R{v=N0^VO?m6d1rfQN0fqDneug>%rS=mUSc>Lr& zq{!sgZ=KS&+^x{(yu8C%)h+;8P$4;L7|`p{{Mg8WILAcIUkyY7#A5z@nmALlB*E_; zRVw4D30++P(J_j%B(f!~;6uL9r~&5K8w}pDMn|u?A9RAhC@{x(^DNW2C=G!rfE&er zS+r7gjVmH=8jvEo(LgoIRcb!59I~rY&p5yO_Hn^)Yf!W4h?V>RSE|yreG?!uN@w7k zRXE?5Nbji8!ydt1$mKKV&!<6Cf}aQ6bGH0`qmA#3xWfBxbFNpPb(Y^p-aBvfOlxt^ z#^pmPpXi`)#C_$+mco-(k*_W}3v|7`#=dMbRCytplKDOlwbLs!9$K`ao@yFo7KDT_ z@>ZLCn+Mng4w~UBpLVddCfSeFmW#p=)5rQ@7AA^w&)6b(Md3%a868`jZLqWYBx$=a z-t-dZZ}mHi0uxgB@w^^U<{^e*TH_Wed|oMP1j@P0@xF7uX~2q073^*~#uR>$ejLpA zi#JZt=D>?`pHJEn#O`4I2>0il?$WN?K||Qz{>rAb3#?-d;^K_fn;h5eRPFRNEB65e zx$z=>J$)TEe`HR^s=yNDRFx-EK4tLnaNtI#DwK*I8}l=Gd%O-d_w0;zhuevKbrEH- z#yxvZCmG2RGe-D}2_!Epqwt9;IP@yJ_zPy)$c2S*_~>CE7I6ltyZsyt4k?j`d%U2` z?Kg=>!P06)g4$t8RHz)Xh+bM!>`~BrS6T0p&ZeQ{fUoL4W;ME@gWiMvRlK#Wf zEO`(ar@H^k!v##OQ{(CMePeez;w;o)Sl&$+K{|T;Z@@F8H48diz9_y zo8jie%t^`{_G$97c#GfF75_OL=uKPs3zlanuM&;)I3Cs!w*MUYFWJGkgbpQ?tgpNG z&LA_!Ig!l9PW&Q4g9`Ptij6IU@F{?U#__ffoYP0H6h}U<16KPtlL`)j*Lc>bhG_MP zs(jyeya4LP-6KMnAi2)%0o<(@vm$5?kW9K|U^Gv?6c8*AZ2BN#z#Ol6lh_ z%?vUPn;7ev@|tcDq4Cghc%HF9PFn4d;mq-7GhPIP)m>N$w&#S#X@AsEY{$<*cOa6r z+#&64EUd`Dv4ZO+{L-rEcem>W{G#*x2>nSHJsF#lEbTB??Cq*has#n2$D6pAwd`Yv zQ=YCHM9#{f4IY)+96X@B=6h!ex|dYDPF!bWc=i5axY*^%(Mf13*r?zR*KM&SLGJo-1p;2&3a4_{PlYN* zSom^Lt!eGB&}AigEIyHW_HB}~y4F`nJM2g|$79GMm-oAU2NNhMuk*|T%Wam~RagGx zYE4o!BU1<=^xY{-#tN5nY|BP8wN~_xvndga*S{4p#m*>S(GQhof32;@-=OYbNFwd1 zpFVkS?x!c@+$n?|*Ha8sviwyyZ3{&?{@xBvtha^DQi`wMMVfN`C!<4;O_zjZQ!!o2T>ce*SdJx4{;&oI<7CF9R#Ij^EN*TVCjdPoc?o3RR| z7UgO7y*fw7UEZ(vxlNWNWW6WH{?Xp-{dFn1g+fo+rc!@xne?vdqqD>sL~sliL9SUD z`C|{q-K4Asl5>PO2kSD2a8wM9;(GusfS$j>YxYnkuJs)=NVyPwXO1^Xd{b=@Ka1Bk zBNJi_2If1S!IRwtR|{Y|f!&$ICuON7?%X|Ggb=lsjKoH46|MkCsKDnLeMVMBgK%BsozW#%yW2Hm&J&MS1D7Vv(TA(2R|YU<9)Ft>dnnJ*>XPGC z8_Mb_^q973d{aNZ^_39adizNP^jZ|&dD3xCYQc(=ffZ)Qku)x7o_7NcKJ5E`iMQKy z+P^}bjS+6!i%LA|P#dGn+{Jdr`fD|GSeLl$b=a+bK@34jcKq^}>1yo53-%>>LA{i6 zXu3`Pt!Jbw82-N6zWGVD+JrHl?|Jvkmp7u4xUnHsgw7xEH_#Y#z{fo=J10p`txuj^ z(N{Pb(Xyu{cp(AD6*(y$P0w2I%iE4UpF(gV8!4%6#ShmW#d27Jg!91{+*Iugpe7A_ zng^SU!$H~*yF<=qyreQDTh&f!bmJF1F=SvD5RqN2O|6qT%O*EKu|{9?+Y- z4a$uKHgK?=tzZ$(5Fg1EfFX+R(#AUby8wu`*Xn_(5(s8y8jOt{w>zlcAww}QwW)U?+jLlU58r$EQq&+i{BjaGmls{WXthE7{nLO2n5vub5gXFw9PXMf@)t$KlS2`TX3UQ^yM zkcA7(bg%lIQ?mF|tC;Fsrw?RVx_Qo)Hl%{P=tJf1~5LtPbJ2$wm!Ix^L~*NEF2izxhr}?0ICQ4x(Wu5_+RDbJF(VxK);d0qgi*&e zQO-x%8W|&*YD>NM=(f6DUw{gCZJEsRtqP^FKLUM!C%aTJ^torvIoF;ZY0fG2?IK0b zu0Da3%x=oz6{`G<%qY4bfhy3q6D4R*EngB1rs+#tS>D}mxx4^A?h*6E z$Ssu)!gJ>(*3*GJN@MV18=hd$5YyN4m9o^cLga&2lo5Od&p(-jpvmuk?$+?r<#skN zmd@Y8-;tFDy*~#}{(<_cfZTjFb(*0jvoif+w6HE{w+FF&*3DMUiU-lv_b8Tfstp44 zHX0QyILvHui9UY!Jo_P80ud|%_*yE$m`b2EKs~UCx z%J_BSZ81-a2NSd43-uWmh^PKNl~&?17RO;-XBa2XFnDx5ZHlFf?T!v!opPLu+WGP^ z;EQyJAXgaF^)MNKXt+ay+Bh3p)M{m&9#rE2bzlGIYl7a>Ot)+H1^VK*HiZWbUdY0t0&##;K(bMdYiP(VipP&W6AbZM|+YEz~;=;6K z^bNyoi4AYQiCLJ;IKL9Iu#nO&vA0C0Z73Mvd|s>Ch?9TWskj|_Akmppr>Q-A-*UW% zB$ytw1N1w47C9=|(uI-&67ql!h4A=A&tAAUVS*c(V2qXdnQ?b1SEIKe`jvRPf`+li z@m{L{eGhBn+4sWM5lZk!)60+B-PtI)Jn zU8|g|=Gc(x{TVAjK#=vvzoDv%FJ|d8nwgTBrF^56PCb&WUP<#X1@ax;zpdx#;?3*j zupbotr)7qzrlhK?D^JP4;BdU{W_8HQhR?fx;3-dR9}mi^B?I}-e!y=Hgvebg@s%zM zsHq4d-8cq-vP*LMbkRDvfimVD(foIw`oW;DOd@)iAf6#jbQ2ZeC-+%#AmziC7c0K5 zDkUMf7suuEIl*D_E2od0O#A{JBjZ0h4e!dOY9XBV@H@!L-_5Uk8YhIpv(_2XrjhgZ2b&88oV|6@Q$wLa<)6+i6FPOrxJ9)# z4lMbI!4_LaQT$G>PMU0-ikzJ~>RLF(=Tb=uHU-q5B52Xux*l>C$@&I7g^#j*{fID| zz4Bf~=4?E%c(g%JU2QSLE6QT}?v9>g`)hRlYHSM2W;)`*_*taV&gGCMnWGDAAPNpc zu&{l)2ubrA+phw=p1@`A66k)B0aR~{wpErXajVkn6T-H&RpfFy%fx;^oVX}(j&ofK z)t6#g-Qe&$5?Rw*p;Kx@u?LI}O9C^fI%nfuiTGEF30jZkF#w=Oq{bMxnW1mTq#22s z5|J%~?>`}p?J+WBCc?I|f~yKOmyur%k6Pj}2P4FfL|^s-eyP;`qv~bcgft&+UPwEah zha^robFhJ&M@Ea}IedI#88rk*dbQ>Tkp9!CPcOyxwTI~m;dVlt+SA@WBh=dkuNV2W zvisW#yU(|bzPdzrrQiQ`1@fixD6Q`wDIS1S2C7fM&KKN3fE&a_zv8B6G|!c1H8x>7 zzR}&4k@O*l-lSL5^SauB>mQtEh1@yBQBoJc%>HHAoV)+{0u0S3BjjS~B|q54@S79B zGz`v`t2;5%O<2lOP|FqX!EE=*L$(CZw^6!{H&ezLYjh=9VW9If&nmV!YF|4GG#CVg zM_+!X+QL8e3VwJ&Ef+bdPE`?_5hcn@d&k$+tD9E`KD4d9A3^LSk8#g$dZ`$c*VszQ zyMM~F+hD)+ewOh_7$=NhQM}3bupf}}C*qxa>gRgdU>%D{th#2bPuX35Pfixq3{8k*JXkNV4o#3vz&hBWip%7gw~{i9m60HC=^>R@-%`PI9p zj1Tr_DA7?^1xEHme4O2tWf-vO9giQ2Jj|n5n_(ACX=)gs@GsHB^rRVu@#x3&0_IR_ zpY--Y#fIp1k51K4YZLz5K+m0@zTAtK*myJ zu$0TvvoeP?q56yc0@k)4Ox`MdmgfQS$*ut7%Rb;p`Fn|B)1KF(kdaDjE7c~(@#!NU zo5pRe4Lm-uWHZkUR^hl~SWoT<`z)S8MH3XBln+hRxxEb4C2uUgUwMc^M2J#XZ4_%N zJsjYawtIV(pl>gYRr0AK8hp6PeEB^AF#wDh-i_$qJ;SiWjIub~k!NI!Z9b4;cjjdS zvFq%*_32?Lngr#{P;qLQw=mTt5tlU!^W663H3gTyv%A=RR-;}5w3e7ucca+gD6&VY zZb>pM5?OIJ61nm`j2#J`b~dhS_zuxMaQgg*9dxP*TT_uK9F^+PP{cJ$Y6s>fo}|s= zHMW6s+mWa(b9!E|A7uaShOK`WQ6+sRQGO@gU_#?yU-o1h<6avQ2PJ`l)X>8NC)wWs zu~bZTV+>2xXu3gQkurk@04rVzs>MhIXr$7-i$AH<^o~XRhM?0k(lehNZ-~t_o@el<|>xiaVyL5b>`FIA5 zd8@2i>?A~8-l|yY1|YgKNRu*XkM$E^zPA|?e-$oa{|xY#SKW4gR`7O)&wJ@Abzf68 zzq9So2AQaAyg(QChL3StYt_y?31w6tDY!DR2Lr4)kEHa0S^pp!2^Gl!2j#i{52;eN z8t`8aZ5^PKoN*Fp6d}JO=4@eUgp{gXR$hMPg26`woDljQIfD{sw2pTmFQqN?Jcj21s53 zRTQ4aAS{BaV;qbN^71rs0oSO+LXzhaKWdavimlu&gPYqBe!slZnXMk?TX$}86nG*Pt~eY@^7u0?GUE35QJ?7coP9S!!8L(ZPtFH z1_Wi4ZL~-UQ&j*Yf~KWUWwCEh2;K;OHqPSy?muAw1>b`dL6Gz=X?DX~I|I1Yi@M0N zfJ_D1;C9AIw3=Hs#P^mOieV9juZxS5jXwukV*!?=d8zo$WP=wa(y2Rs;ab&BqB4T> zK&(eL3t}K$Tm5-X$%q#7YE713x~EkI0{L~<7g-36o=+5V@lcsiz0=y8I6({kbFLY$ z@}&)LHascbyitIf4>fp&&$O(gz{^Kj{7wR|vnwPdq$#@q$jeehrd~9$C|^4r-jp7X zQm`AgBsc<)+B=E%aQGBT@mD#7q~NZZ&f?4C`>3hCBlMn+(+Lne(=P9TR9(j3i20CQ zZZ_?naKMU+?hhGr$?u=L8J{A`Y3k+rYHH3{7~SwS>}mBWT{(9gE-<*)+R5j(T!}kd zJ3q5V{1a=>wt9Pi^1!$
+ 커밍 +

이용약관 변경 안내

- 안녕하세요, Coming입니다.
+ 안녕하세요, 커밍입니다.
이용약관이 아래와 같이 변경되어 안내드립니다.

@@ -33,7 +35,7 @@

전체 원문은 아래 링크에서 확인하실 수 있습니다.
- https://coming.example.com/policy + https://comingg.com/policy

From 70c38430881443c564a80013296eb04621d93416 Mon Sep 17 00:00:00 2001 From: You-Hyuk Date: Fri, 18 Sep 2026 16:59:55 +0900 Subject: [PATCH 13/15] =?UTF-8?q?[fix]=20=EC=A0=95=EC=B1=85=20=EC=95=8C?= =?UTF-8?q?=EB=A6=BC=20=EB=B0=B0=EC=B9=98=20=EC=9E=AC=EC=8B=9C=EB=8F=84=20?= =?UTF-8?q?=EB=A1=9C=EC=A7=81=20=EB=B0=8F=20=EC=9B=90=EC=9E=90=EC=84=B1=20?= =?UTF-8?q?=EB=B2=84=EA=B7=B8=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 오프셋 페이징으로 인해 PENDING 대상이 누락되던 문제를 id 커서 기반 리더로 교체 - 최초 발송 실패 시 재시도 횟수가 먼저 소진되던 markFailed 로직 수정 - 동시 가입 시 약관 동의 저장 실패가 예외 변환 범위 밖에 있던 문제 수정 - SMTP 연결/응답 타임아웃 및 STARTTLS 필수화 설정 추가 - PolicyController 등록 API 테스트 보강 Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01JNjhau1bE58LegkwKtPBq2 --- .../Backend/auth/service/AuthService.java | 4 +- .../batch/PolicyNotificationBatchConfig.java | 27 +--- ...PolicyNotificationPendingTargetReader.java | 49 +++++++ .../entity/PolicyNotificationTarget.java | 4 +- .../PolicyNotificationTargetRepository.java | 4 +- src/main/resources/application.yaml | 3 + ...cyNotificationPendingTargetReaderTest.java | 101 ++++++++++++++ .../batch/PolicyNotificationSenderTest.java | 25 +++- .../controller/PolicyControllerTest.java | 132 ++++++++++++++++++ 9 files changed, 318 insertions(+), 31 deletions(-) create mode 100644 src/main/java/com/Coming/Backend/policy/batch/PolicyNotificationPendingTargetReader.java create mode 100644 src/test/java/com/Coming/Backend/policy/batch/PolicyNotificationPendingTargetReaderTest.java create mode 100644 src/test/java/com/Coming/Backend/policy/controller/PolicyControllerTest.java diff --git a/src/main/java/com/Coming/Backend/auth/service/AuthService.java b/src/main/java/com/Coming/Backend/auth/service/AuthService.java index 59b7687d..8d5a6b38 100644 --- a/src/main/java/com/Coming/Backend/auth/service/AuthService.java +++ b/src/main/java/com/Coming/Backend/auth/service/AuthService.java @@ -140,9 +140,9 @@ public TokenResponse register(Long userId, RegisterRequest request) { request.birthYear(), Boolean.TRUE.equals(request.agreedMarketing()) ); - recordPolicyAgreement(userId, PolicyType.TERMS); - recordPolicyAgreement(userId, PolicyType.PRIVACY); try { + recordPolicyAgreement(userId, PolicyType.TERMS); + recordPolicyAgreement(userId, PolicyType.PRIVACY); userRepository.flush(); } catch (DataIntegrityViolationException e) { throw new NicknameDuplicateException(); diff --git a/src/main/java/com/Coming/Backend/policy/batch/PolicyNotificationBatchConfig.java b/src/main/java/com/Coming/Backend/policy/batch/PolicyNotificationBatchConfig.java index 87af58e8..7077595b 100644 --- a/src/main/java/com/Coming/Backend/policy/batch/PolicyNotificationBatchConfig.java +++ b/src/main/java/com/Coming/Backend/policy/batch/PolicyNotificationBatchConfig.java @@ -1,24 +1,15 @@ package com.Coming.Backend.policy.batch; -import com.Coming.Backend.policy.entity.NotificationStatus; import com.Coming.Backend.policy.entity.PolicyNotificationTarget; -import com.Coming.Backend.policy.repository.PolicyNotificationTargetRepository; -import java.util.List; -import java.util.Map; import lombok.RequiredArgsConstructor; -import org.springframework.batch.core.configuration.annotation.StepScope; import org.springframework.batch.core.job.Job; import org.springframework.batch.core.job.builder.JobBuilder; import org.springframework.batch.core.job.parameters.RunIdIncrementer; import org.springframework.batch.core.repository.JobRepository; import org.springframework.batch.core.step.Step; import org.springframework.batch.core.step.builder.StepBuilder; -import org.springframework.batch.infrastructure.item.data.RepositoryItemReader; -import org.springframework.batch.infrastructure.item.data.builder.RepositoryItemReaderBuilder; -import org.springframework.beans.factory.annotation.Value; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; -import org.springframework.data.domain.Sort; import org.springframework.transaction.PlatformTransactionManager; @Configuration @@ -30,7 +21,7 @@ public class PolicyNotificationBatchConfig { private final JobRepository jobRepository; private final PlatformTransactionManager transactionManager; private final PolicyNotificationTargetCreationTasklet policyNotificationTargetCreationTasklet; - private final PolicyNotificationTargetRepository policyNotificationTargetRepository; + private final PolicyNotificationPendingTargetReader policyNotificationPendingTargetReader; private final PolicyNotificationMailProcessor policyNotificationMailProcessor; private final PolicyNotificationTargetWriter policyNotificationTargetWriter; @@ -54,24 +45,10 @@ public Step targetCreationStep() { public Step mailSendStep() { return new StepBuilder("mailSendStep", jobRepository) .chunk(CHUNK_SIZE) - .reader(mailSendItemReader(null)) + .reader(policyNotificationPendingTargetReader) .processor(policyNotificationMailProcessor) .writer(policyNotificationTargetWriter) .transactionManager(transactionManager) .build(); } - - @Bean - @StepScope - public RepositoryItemReader mailSendItemReader( - @Value("#{jobParameters['policyId']}") Long policyId) { - return new RepositoryItemReaderBuilder() - .name("mailSendItemReader") - .repository(policyNotificationTargetRepository) - .methodName("findByPolicyIdAndStatus") - .arguments(List.of(policyId, NotificationStatus.PENDING)) - .sorts(Map.of("id", Sort.Direction.ASC)) - .pageSize(CHUNK_SIZE) - .build(); - } } diff --git a/src/main/java/com/Coming/Backend/policy/batch/PolicyNotificationPendingTargetReader.java b/src/main/java/com/Coming/Backend/policy/batch/PolicyNotificationPendingTargetReader.java new file mode 100644 index 00000000..f98831bc --- /dev/null +++ b/src/main/java/com/Coming/Backend/policy/batch/PolicyNotificationPendingTargetReader.java @@ -0,0 +1,49 @@ +package com.Coming.Backend.policy.batch; + +import com.Coming.Backend.policy.entity.NotificationStatus; +import com.Coming.Backend.policy.entity.PolicyNotificationTarget; +import com.Coming.Backend.policy.repository.PolicyNotificationTargetRepository; +import java.util.Iterator; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.batch.core.configuration.annotation.StepScope; +import org.springframework.batch.infrastructure.item.ItemReader; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.data.domain.PageRequest; +import org.springframework.stereotype.Component; + +/** + * PENDING 상태의 정책 알림 대상을 id 오름차순 커서 기반으로 읽는다. + * offset 페이징을 사용하면 처리된 항목이 PENDING에서 벗어나며 결과 집합이 줄어들어 + * 다음 페이지 조회 시 대상을 건너뛰는 문제가 있어, 마지막으로 읽은 id를 커서로 다음 배치를 조회한다. + */ +@Component +@StepScope +@RequiredArgsConstructor +public class PolicyNotificationPendingTargetReader implements ItemReader { + + private static final int PAGE_SIZE = 20; + + private final PolicyNotificationTargetRepository policyNotificationTargetRepository; + + @Value("#{jobParameters['policyId']}") + private Long policyId; + + private Long lastId = 0L; + private Iterator currentBatch = List.of().iterator(); + + @Override + public PolicyNotificationTarget read() { + if (!currentBatch.hasNext()) { + List batch = policyNotificationTargetRepository + .findByPolicyIdAndStatusAndIdGreaterThanOrderByIdAsc( + policyId, NotificationStatus.PENDING, lastId, PageRequest.of(0, PAGE_SIZE)); + if (batch.isEmpty()) { + return null; + } + lastId = batch.get(batch.size() - 1).getId(); + currentBatch = batch.iterator(); + } + return currentBatch.next(); + } +} diff --git a/src/main/java/com/Coming/Backend/policy/entity/PolicyNotificationTarget.java b/src/main/java/com/Coming/Backend/policy/entity/PolicyNotificationTarget.java index a8aad822..7a6ba985 100644 --- a/src/main/java/com/Coming/Backend/policy/entity/PolicyNotificationTarget.java +++ b/src/main/java/com/Coming/Backend/policy/entity/PolicyNotificationTarget.java @@ -50,7 +50,9 @@ public void markSent() { } public void markFailed() { + if (this.status == NotificationStatus.FAILED) { + this.retryCount += 1; + } this.status = NotificationStatus.FAILED; - this.retryCount += 1; } } diff --git a/src/main/java/com/Coming/Backend/policy/repository/PolicyNotificationTargetRepository.java b/src/main/java/com/Coming/Backend/policy/repository/PolicyNotificationTargetRepository.java index ac915c0c..ae0fdb10 100644 --- a/src/main/java/com/Coming/Backend/policy/repository/PolicyNotificationTargetRepository.java +++ b/src/main/java/com/Coming/Backend/policy/repository/PolicyNotificationTargetRepository.java @@ -3,7 +3,6 @@ import com.Coming.Backend.policy.entity.NotificationStatus; import com.Coming.Backend.policy.entity.PolicyNotificationTarget; import java.util.List; -import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.JpaRepository; @@ -11,7 +10,8 @@ public interface PolicyNotificationTargetRepository extends JpaRepository findByPolicyId(Long policyId); - Page findByPolicyIdAndStatus(Long policyId, NotificationStatus status, Pageable pageable); + List findByPolicyIdAndStatusAndIdGreaterThanOrderByIdAsc( + Long policyId, NotificationStatus status, Long id, Pageable pageable); List findByStatusAndRetryCountLessThan(NotificationStatus status, int retryCount); } diff --git a/src/main/resources/application.yaml b/src/main/resources/application.yaml index 72f5938a..b4078e34 100644 --- a/src/main/resources/application.yaml +++ b/src/main/resources/application.yaml @@ -70,8 +70,11 @@ spring: mail: smtp: auth: true + connectiontimeout: 5000 + timeout: 10000 starttls: enable: true + required: true batch: job: diff --git a/src/test/java/com/Coming/Backend/policy/batch/PolicyNotificationPendingTargetReaderTest.java b/src/test/java/com/Coming/Backend/policy/batch/PolicyNotificationPendingTargetReaderTest.java new file mode 100644 index 00000000..dd15ff97 --- /dev/null +++ b/src/test/java/com/Coming/Backend/policy/batch/PolicyNotificationPendingTargetReaderTest.java @@ -0,0 +1,101 @@ +package com.Coming.Backend.policy.batch; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.verify; + +import com.Coming.Backend.policy.entity.NotificationStatus; +import com.Coming.Backend.policy.entity.PolicyNotificationTarget; +import com.Coming.Backend.policy.repository.PolicyNotificationTargetRepository; +import java.util.List; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.data.domain.PageRequest; +import org.springframework.test.util.ReflectionTestUtils; + +@ExtendWith(MockitoExtension.class) +class PolicyNotificationPendingTargetReaderTest { + + @InjectMocks + private PolicyNotificationPendingTargetReader policyNotificationPendingTargetReader; + + @Mock + private PolicyNotificationTargetRepository policyNotificationTargetRepository; + + private static final Long POLICY_ID = 1L; + + private PolicyNotificationTarget buildTarget(Long id) { + return PolicyNotificationTarget.builder() + .id(id) + .policyId(POLICY_ID) + .userId(id) + .status(NotificationStatus.PENDING) + .retryCount(0) + .build(); + } + + @Test + void should_return_items_in_order_when_first_batch_fetched_given() { + // given + ReflectionTestUtils.setField(policyNotificationPendingTargetReader, "policyId", POLICY_ID); + PolicyNotificationTarget target1 = buildTarget(1L); + PolicyNotificationTarget target2 = buildTarget(2L); + given(policyNotificationTargetRepository.findByPolicyIdAndStatusAndIdGreaterThanOrderByIdAsc( + POLICY_ID, NotificationStatus.PENDING, 0L, PageRequest.of(0, 20))) + .willReturn(List.of(target1, target2)); + + // when + PolicyNotificationTarget firstResult = policyNotificationPendingTargetReader.read(); + PolicyNotificationTarget secondResult = policyNotificationPendingTargetReader.read(); + + // then + assertThat(firstResult).isSameAs(target1); + assertThat(secondResult).isSameAs(target2); + verify(policyNotificationTargetRepository) + .findByPolicyIdAndStatusAndIdGreaterThanOrderByIdAsc( + POLICY_ID, NotificationStatus.PENDING, 0L, PageRequest.of(0, 20)); + } + + @Test + void should_fetch_next_batch_using_last_read_id_as_cursor_when_current_batch_exhausted_given() { + // given + ReflectionTestUtils.setField(policyNotificationPendingTargetReader, "policyId", POLICY_ID); + List firstBatch = List.of(buildTarget(19L), buildTarget(20L)); + PolicyNotificationTarget target21 = buildTarget(21L); + given(policyNotificationTargetRepository.findByPolicyIdAndStatusAndIdGreaterThanOrderByIdAsc( + POLICY_ID, NotificationStatus.PENDING, 0L, PageRequest.of(0, 20))) + .willReturn(firstBatch); + given(policyNotificationTargetRepository.findByPolicyIdAndStatusAndIdGreaterThanOrderByIdAsc( + POLICY_ID, NotificationStatus.PENDING, 20L, PageRequest.of(0, 20))) + .willReturn(List.of(target21)); + + // when + policyNotificationPendingTargetReader.read(); + policyNotificationPendingTargetReader.read(); + PolicyNotificationTarget resultFromNextBatch = policyNotificationPendingTargetReader.read(); + + // then + assertThat(resultFromNextBatch).isSameAs(target21); + verify(policyNotificationTargetRepository) + .findByPolicyIdAndStatusAndIdGreaterThanOrderByIdAsc( + POLICY_ID, NotificationStatus.PENDING, 20L, PageRequest.of(0, 20)); + } + + @Test + void should_return_null_when_repository_returns_empty_batch_given() { + // given + ReflectionTestUtils.setField(policyNotificationPendingTargetReader, "policyId", POLICY_ID); + given(policyNotificationTargetRepository.findByPolicyIdAndStatusAndIdGreaterThanOrderByIdAsc( + POLICY_ID, NotificationStatus.PENDING, 0L, PageRequest.of(0, 20))) + .willReturn(List.of()); + + // when + PolicyNotificationTarget result = policyNotificationPendingTargetReader.read(); + + // then + assertThat(result).isNull(); + } +} diff --git a/src/test/java/com/Coming/Backend/policy/batch/PolicyNotificationSenderTest.java b/src/test/java/com/Coming/Backend/policy/batch/PolicyNotificationSenderTest.java index 121a9192..da24bd27 100644 --- a/src/test/java/com/Coming/Backend/policy/batch/PolicyNotificationSenderTest.java +++ b/src/test/java/com/Coming/Backend/policy/batch/PolicyNotificationSenderTest.java @@ -115,7 +115,7 @@ void should_mark_sent_when_send_succeeds_given() { } @Test - void should_mark_failed_and_increment_retry_count_when_send_throws_exception_given() { + void should_mark_failed_without_incrementing_retry_count_when_initial_send_throws_exception_given() { // given PolicyNotificationTarget target = buildTarget(); User user = buildUser(1L, "iu@coming.com"); @@ -126,6 +126,29 @@ void should_mark_failed_and_increment_retry_count_when_send_throws_exception_giv // when policyNotificationSender.sendAndMark(target, user, policyDocument); + // then + assertThat(target.getStatus()).isEqualTo(NotificationStatus.FAILED); + assertThat(target.getRetryCount()).isEqualTo(0); + } + + @Test + void should_mark_failed_and_increment_retry_count_when_already_failed_send_throws_exception_again_given() { + // given + PolicyNotificationTarget target = PolicyNotificationTarget.builder() + .id(10L) + .policyId(POLICY_ID) + .userId(1L) + .status(NotificationStatus.FAILED) + .retryCount(0) + .build(); + User user = buildUser(1L, "iu@coming.com"); + PolicyDocument policyDocument = buildPolicyDocument(); + + willThrow(new RuntimeException("smtp down")).given(policyNoticeMailSender).send("iu@coming.com", policyDocument); + + // when + policyNotificationSender.sendAndMark(target, user, policyDocument); + // then assertThat(target.getStatus()).isEqualTo(NotificationStatus.FAILED); assertThat(target.getRetryCount()).isEqualTo(1); diff --git a/src/test/java/com/Coming/Backend/policy/controller/PolicyControllerTest.java b/src/test/java/com/Coming/Backend/policy/controller/PolicyControllerTest.java new file mode 100644 index 00000000..4fc48b84 --- /dev/null +++ b/src/test/java/com/Coming/Backend/policy/controller/PolicyControllerTest.java @@ -0,0 +1,132 @@ +package com.Coming.Backend.policy.controller; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.BDDMockito.given; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import com.Coming.Backend.common.discord.NoOpDiscordNotifier; +import com.Coming.Backend.common.exception.ErrorCode; +import com.Coming.Backend.common.exception.GlobalExceptionHandler; +import com.Coming.Backend.policy.dto.PolicyRegisterRequest; +import com.Coming.Backend.policy.dto.PolicyResponse; +import com.Coming.Backend.policy.entity.PolicyType; +import com.Coming.Backend.policy.exception.PolicyVersionDuplicateException; +import com.Coming.Backend.policy.service.PolicyService; +import com.fasterxml.jackson.databind.ObjectMapper; +import java.time.LocalDate; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.http.MediaType; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; +import org.springframework.validation.beanvalidation.LocalValidatorFactoryBean; + +@ExtendWith(MockitoExtension.class) +class PolicyControllerTest { + + private MockMvc mockMvc; + + @Mock + private PolicyService policyService; + + @InjectMocks + private PolicyController policyController; + + private final ObjectMapper objectMapper = new ObjectMapper().findAndRegisterModules(); + + @BeforeEach + void setUp() { + LocalValidatorFactoryBean validator = new LocalValidatorFactoryBean(); + validator.afterPropertiesSet(); + mockMvc = MockMvcBuilders.standaloneSetup(policyController) + .setControllerAdvice(new GlobalExceptionHandler(new NoOpDiscordNotifier())) + .setValidator(validator) + .build(); + } + + private PolicyRegisterRequest validRequest() { + return new PolicyRegisterRequest( + PolicyType.TERMS, + "1.0.0", + LocalDate.of(2026, 1, 1), + "이용약관 개정", + "https://coming.example.com/policies/terms/1.0.0" + ); + } + + @Test + void should_return_201_when_register_request_is_valid() throws Exception { + // given + PolicyRegisterRequest request = validRequest(); + PolicyResponse response = new PolicyResponse( + 1L, PolicyType.TERMS, "1.0.0", LocalDate.of(2026, 1, 1), + "이용약관 개정", "https://coming.example.com/policies/terms/1.0.0"); + given(policyService.registerPolicy(any(PolicyRegisterRequest.class))).willReturn(response); + + // when & then + mockMvc.perform(post("/api/admin/policies") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isCreated()) + .andExpect(jsonPath("$.id").value(1L)) + .andExpect(jsonPath("$.type").value("TERMS")) + .andExpect(jsonPath("$.version").value("1.0.0")) + .andExpect(jsonPath("$.effectiveDate").value("2026-01-01")) + .andExpect(jsonPath("$.changeSummary").value("이용약관 개정")) + .andExpect(jsonPath("$.detailUrl").value("https://coming.example.com/policies/terms/1.0.0")); + } + + @Test + void should_return_400_when_type_is_null_on_register() throws Exception { + // given + PolicyRegisterRequest request = new PolicyRegisterRequest( + null, "1.0.0", LocalDate.of(2026, 1, 1), + "이용약관 개정", "https://coming.example.com/policies/terms/1.0.0"); + + // when & then + mockMvc.perform(post("/api/admin/policies") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value(ErrorCode.INVALID_INPUT.name())) + .andExpect(jsonPath("$.message").exists()); + } + + @Test + void should_return_400_when_version_exceeds_max_length_on_register() throws Exception { + // given + PolicyRegisterRequest request = new PolicyRegisterRequest( + PolicyType.TERMS, "1".repeat(51), LocalDate.of(2026, 1, 1), + "이용약관 개정", "https://coming.example.com/policies/terms/1.0.0"); + + // when & then + mockMvc.perform(post("/api/admin/policies") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value(ErrorCode.INVALID_INPUT.name())) + .andExpect(jsonPath("$.message").exists()); + } + + @Test + void should_return_409_when_policy_version_already_registered() throws Exception { + // given + PolicyRegisterRequest request = validRequest(); + given(policyService.registerPolicy(any(PolicyRegisterRequest.class))) + .willThrow(new PolicyVersionDuplicateException()); + + // when & then + mockMvc.perform(post("/api/admin/policies") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isConflict()) + .andExpect(jsonPath("$.code").value(ErrorCode.POLICY_VERSION_DUPLICATE.name())) + .andExpect(jsonPath("$.message").exists()); + } +} From d5a1245fa57977d99dfd98b8e199eff1c582bc3c Mon Sep 17 00:00:00 2001 From: You-Hyuk Date: Fri, 18 Sep 2026 17:00:42 +0900 Subject: [PATCH 14/15] =?UTF-8?q?[refactor]=20=EC=95=BD=EA=B4=80=20?= =?UTF-8?q?=EC=9E=AC=EB=8F=99=EC=9D=98(requiresReconsent)=20=ED=95=84?= =?UTF-8?q?=EB=93=9C=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 기존 약관에 명시된 변경사항 묵시적 동의 조항으로 충분하다는 PM 판단에 따라 재동의 필요 여부를 별도로 관리하지 않기로 결정. 등록 API 요청/응답, 엔티티, 메일 발송 로직·템플릿에서 관련 필드를 제거하고 컬럼을 삭제하는 마이그레이션을 추가. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01JNjhau1bE58LegkwKtPBq2 --- .../Coming/Backend/policy/dto/PolicyRegisterRequest.java | 5 +---- .../java/com/Coming/Backend/policy/dto/PolicyResponse.java | 6 ++---- .../com/Coming/Backend/policy/entity/PolicyDocument.java | 3 --- .../Coming/Backend/policy/mail/PolicyNoticeMailSender.java | 1 - .../com/Coming/Backend/policy/service/PolicyService.java | 1 - .../V37__drop_policy_document_requires_reconsent.sql | 1 + src/main/resources/templates/mail/policy-change-notice.html | 4 ---- .../Backend/auth/service/AuthServiceRegisterTest.java | 1 - .../com/Coming/Backend/auth/service/AuthServiceTest.java | 1 - .../policy/batch/PolicyNotificationMailProcessorTest.java | 1 - .../policy/batch/PolicyNotificationRetrySchedulerTest.java | 1 - .../Backend/policy/batch/PolicyNotificationSenderTest.java | 1 - .../Backend/policy/mail/PolicyNoticeMailSenderTest.java | 2 -- .../Coming/Backend/policy/service/PolicyServiceTest.java | 5 +---- 14 files changed, 5 insertions(+), 28 deletions(-) create mode 100644 src/main/resources/db/migration/V37__drop_policy_document_requires_reconsent.sql diff --git a/src/main/java/com/Coming/Backend/policy/dto/PolicyRegisterRequest.java b/src/main/java/com/Coming/Backend/policy/dto/PolicyRegisterRequest.java index 84f05bc5..2ab920d4 100644 --- a/src/main/java/com/Coming/Backend/policy/dto/PolicyRegisterRequest.java +++ b/src/main/java/com/Coming/Backend/policy/dto/PolicyRegisterRequest.java @@ -22,9 +22,6 @@ public record PolicyRegisterRequest( @NotBlank @Size(max = 500) - String detailUrl, - - @NotNull - Boolean requiresReconsent + String detailUrl ) { } diff --git a/src/main/java/com/Coming/Backend/policy/dto/PolicyResponse.java b/src/main/java/com/Coming/Backend/policy/dto/PolicyResponse.java index 2a232cf7..722f989c 100644 --- a/src/main/java/com/Coming/Backend/policy/dto/PolicyResponse.java +++ b/src/main/java/com/Coming/Backend/policy/dto/PolicyResponse.java @@ -10,8 +10,7 @@ public record PolicyResponse( String version, LocalDate effectiveDate, String changeSummary, - String detailUrl, - boolean requiresReconsent + String detailUrl ) { public static PolicyResponse from(PolicyDocument policyDocument) { return new PolicyResponse( @@ -20,8 +19,7 @@ public static PolicyResponse from(PolicyDocument policyDocument) { policyDocument.getVersion(), policyDocument.getEffectiveDate(), policyDocument.getChangeSummary(), - policyDocument.getDetailUrl(), - policyDocument.isRequiresReconsent() + policyDocument.getDetailUrl() ); } } diff --git a/src/main/java/com/Coming/Backend/policy/entity/PolicyDocument.java b/src/main/java/com/Coming/Backend/policy/entity/PolicyDocument.java index f7ac6a0d..2058304b 100644 --- a/src/main/java/com/Coming/Backend/policy/entity/PolicyDocument.java +++ b/src/main/java/com/Coming/Backend/policy/entity/PolicyDocument.java @@ -43,7 +43,4 @@ public class PolicyDocument extends BaseCreatedEntity { @Column(name = "detail_url", nullable = false, length = 500) private String detailUrl; - - @Column(name = "requires_reconsent", nullable = false) - private boolean requiresReconsent; } diff --git a/src/main/java/com/Coming/Backend/policy/mail/PolicyNoticeMailSender.java b/src/main/java/com/Coming/Backend/policy/mail/PolicyNoticeMailSender.java index 80108a36..cd4ae23f 100644 --- a/src/main/java/com/Coming/Backend/policy/mail/PolicyNoticeMailSender.java +++ b/src/main/java/com/Coming/Backend/policy/mail/PolicyNoticeMailSender.java @@ -60,7 +60,6 @@ private String renderHtml(PolicyDocument policyDocument) { context.setVariable("effectiveDate", policyDocument.getEffectiveDate()); context.setVariable("changeSummary", policyDocument.getChangeSummary()); context.setVariable("detailUrl", policyDocument.getDetailUrl()); - context.setVariable("requiresReconsent", policyDocument.isRequiresReconsent()); context.setVariable("fromAddress", fromAddress); context.setVariable("logoContentId", LOGO_CONTENT_ID); return templateEngine.process(TEMPLATE_NAME, context); diff --git a/src/main/java/com/Coming/Backend/policy/service/PolicyService.java b/src/main/java/com/Coming/Backend/policy/service/PolicyService.java index ef702560..12ce0f01 100644 --- a/src/main/java/com/Coming/Backend/policy/service/PolicyService.java +++ b/src/main/java/com/Coming/Backend/policy/service/PolicyService.java @@ -36,7 +36,6 @@ public PolicyResponse registerPolicy(PolicyRegisterRequest request) { .effectiveDate(request.effectiveDate()) .changeSummary(request.changeSummary()) .detailUrl(request.detailUrl()) - .requiresReconsent(Boolean.TRUE.equals(request.requiresReconsent())) .build(); PolicyDocument saved; try { diff --git a/src/main/resources/db/migration/V37__drop_policy_document_requires_reconsent.sql b/src/main/resources/db/migration/V37__drop_policy_document_requires_reconsent.sql new file mode 100644 index 00000000..06d64aff --- /dev/null +++ b/src/main/resources/db/migration/V37__drop_policy_document_requires_reconsent.sql @@ -0,0 +1 @@ +ALTER TABLE policy_document DROP COLUMN requires_reconsent; diff --git a/src/main/resources/templates/mail/policy-change-notice.html b/src/main/resources/templates/mail/policy-change-notice.html index 78e96ae4..fef7e746 100644 --- a/src/main/resources/templates/mail/policy-change-notice.html +++ b/src/main/resources/templates/mail/policy-change-notice.html @@ -28,10 +28,6 @@

-

- 본 변경사항은 재동의가 필요한 사항입니다. 서비스 계속 이용을 위해 앱 또는 웹에서 재동의를 완료해주세요. -

-

전체 원문은 아래 링크에서 확인하실 수 있습니다.
diff --git a/src/test/java/com/Coming/Backend/auth/service/AuthServiceRegisterTest.java b/src/test/java/com/Coming/Backend/auth/service/AuthServiceRegisterTest.java index fa5d97bb..64430287 100644 --- a/src/test/java/com/Coming/Backend/auth/service/AuthServiceRegisterTest.java +++ b/src/test/java/com/Coming/Backend/auth/service/AuthServiceRegisterTest.java @@ -92,7 +92,6 @@ private PolicyDocument buildPolicyDocument(PolicyType type) { .effectiveDate(LocalDate.of(2026, 1, 1)) .changeSummary("변경 요약") .detailUrl("https://coming.example.com/policy") - .requiresReconsent(false) .build(); } diff --git a/src/test/java/com/Coming/Backend/auth/service/AuthServiceTest.java b/src/test/java/com/Coming/Backend/auth/service/AuthServiceTest.java index b865b6b8..97056881 100644 --- a/src/test/java/com/Coming/Backend/auth/service/AuthServiceTest.java +++ b/src/test/java/com/Coming/Backend/auth/service/AuthServiceTest.java @@ -117,7 +117,6 @@ private PolicyDocument buildPolicyDocument(Long policyId, PolicyType type) { .effectiveDate(LocalDate.now()) .changeSummary("최초 시행") .detailUrl("https://coming.example.com/policy") - .requiresReconsent(false) .build(); } diff --git a/src/test/java/com/Coming/Backend/policy/batch/PolicyNotificationMailProcessorTest.java b/src/test/java/com/Coming/Backend/policy/batch/PolicyNotificationMailProcessorTest.java index b1dc853c..b1658203 100644 --- a/src/test/java/com/Coming/Backend/policy/batch/PolicyNotificationMailProcessorTest.java +++ b/src/test/java/com/Coming/Backend/policy/batch/PolicyNotificationMailProcessorTest.java @@ -65,7 +65,6 @@ private PolicyDocument buildPolicyDocument() { .effectiveDate(LocalDate.of(2026, 1, 1)) .changeSummary("이용약관 개정") .detailUrl("https://coming.com/policy/terms/1.0") - .requiresReconsent(false) .build(); } diff --git a/src/test/java/com/Coming/Backend/policy/batch/PolicyNotificationRetrySchedulerTest.java b/src/test/java/com/Coming/Backend/policy/batch/PolicyNotificationRetrySchedulerTest.java index e26a8f8c..ecb88792 100644 --- a/src/test/java/com/Coming/Backend/policy/batch/PolicyNotificationRetrySchedulerTest.java +++ b/src/test/java/com/Coming/Backend/policy/batch/PolicyNotificationRetrySchedulerTest.java @@ -69,7 +69,6 @@ private PolicyDocument buildPolicyDocument() { .effectiveDate(LocalDate.of(2026, 1, 1)) .changeSummary("이용약관 개정") .detailUrl("https://coming.com/policy/terms/1.0") - .requiresReconsent(false) .build(); } diff --git a/src/test/java/com/Coming/Backend/policy/batch/PolicyNotificationSenderTest.java b/src/test/java/com/Coming/Backend/policy/batch/PolicyNotificationSenderTest.java index da24bd27..42b5d00b 100644 --- a/src/test/java/com/Coming/Backend/policy/batch/PolicyNotificationSenderTest.java +++ b/src/test/java/com/Coming/Backend/policy/batch/PolicyNotificationSenderTest.java @@ -55,7 +55,6 @@ private PolicyDocument buildPolicyDocument() { .effectiveDate(LocalDate.of(2026, 1, 1)) .changeSummary("이용약관 개정") .detailUrl("https://coming.com/policy/terms/1.0") - .requiresReconsent(false) .build(); } diff --git a/src/test/java/com/Coming/Backend/policy/mail/PolicyNoticeMailSenderTest.java b/src/test/java/com/Coming/Backend/policy/mail/PolicyNoticeMailSenderTest.java index 91668e60..7c9218af 100644 --- a/src/test/java/com/Coming/Backend/policy/mail/PolicyNoticeMailSenderTest.java +++ b/src/test/java/com/Coming/Backend/policy/mail/PolicyNoticeMailSenderTest.java @@ -45,7 +45,6 @@ private PolicyDocument buildPolicyDocument(PolicyType type) { .effectiveDate(LocalDate.of(2026, 1, 1)) .changeSummary("개인정보 수집 항목 변경") .detailUrl("https://coming.example.com/policy/1.0.0") - .requiresReconsent(true) .build(); } @@ -88,7 +87,6 @@ void should_pass_policy_document_fields_to_template_context_when_send_called() { assertThat(capturedContext.getVariable("changeSummary")).isEqualTo(policyDocument.getChangeSummary()); assertThat(capturedContext.getVariable("effectiveDate")).isEqualTo(policyDocument.getEffectiveDate()); assertThat(capturedContext.getVariable("detailUrl")).isEqualTo(policyDocument.getDetailUrl()); - assertThat(capturedContext.getVariable("requiresReconsent")).isEqualTo(policyDocument.isRequiresReconsent()); } @Test diff --git a/src/test/java/com/Coming/Backend/policy/service/PolicyServiceTest.java b/src/test/java/com/Coming/Backend/policy/service/PolicyServiceTest.java index 13744045..654c346f 100644 --- a/src/test/java/com/Coming/Backend/policy/service/PolicyServiceTest.java +++ b/src/test/java/com/Coming/Backend/policy/service/PolicyServiceTest.java @@ -46,8 +46,7 @@ private PolicyRegisterRequest buildRequest() { VERSION, LocalDate.of(2026, 1, 1), "이용약관 최초 등록", - "https://coming.example.com/policy/terms/1.0.0", - true + "https://coming.example.com/policy/terms/1.0.0" ); } @@ -59,7 +58,6 @@ private PolicyDocument buildPolicyDocument(Long id, PolicyRegisterRequest reques .effectiveDate(request.effectiveDate()) .changeSummary(request.changeSummary()) .detailUrl(request.detailUrl()) - .requiresReconsent(request.requiresReconsent()) .build(); } @@ -82,7 +80,6 @@ void should_return_policy_response_when_valid_request_given() { assertThat(result.effectiveDate()).isEqualTo(request.effectiveDate()); assertThat(result.changeSummary()).isEqualTo(request.changeSummary()); assertThat(result.detailUrl()).isEqualTo(request.detailUrl()); - assertThat(result.requiresReconsent()).isTrue(); verify(eventPublisher).publishEvent(new PolicyRegisteredEvent(1L)); } From 0831dd95431957b9e13d127f00e7f3e3fe24cc3a Mon Sep 17 00:00:00 2001 From: You-Hyuk Date: Fri, 18 Sep 2026 17:00:53 +0900 Subject: [PATCH 15/15] =?UTF-8?q?[style]=20=EC=A0=95=EC=B1=85=20=EB=B3=80?= =?UTF-8?q?=EA=B2=BD=20=EB=A9=94=EC=9D=BC=20=ED=85=9C=ED=94=8C=EB=A6=BF=20?= =?UTF-8?q?=EB=A7=81=ED=81=AC=20=EC=97=AC=EB=B0=B1=20=EB=B0=8F=20=EB=B0=9C?= =?UTF-8?q?=EC=8B=A0=EC=9E=90=20=ED=91=9C=EA=B8=B0=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 원문 링크 안내 문구와 링크 사이에 빈 줄 추가 - 발신자 표시명을 "커밍"으로 통일 Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01JNjhau1bE58LegkwKtPBq2 --- src/main/resources/templates/mail/policy-change-notice.html | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/main/resources/templates/mail/policy-change-notice.html b/src/main/resources/templates/mail/policy-change-notice.html index fef7e746..4625db73 100644 --- a/src/main/resources/templates/mail/policy-change-notice.html +++ b/src/main/resources/templates/mail/policy-change-notice.html @@ -29,7 +29,7 @@

- 전체 원문은 아래 링크에서 확인하실 수 있습니다.
+ 전체 원문은 아래 링크에서 확인하실 수 있습니다.

https://comingg.com/policy @@ -39,7 +39,7 @@

본 메일은 관련 법령에 따른 정책 변경 사전 고지 목적으로 발송되는 필수 안내이며, 수신거부가 적용되지 않습니다.
- 발신: Coming (coming@example.com) + 발신: 커밍 (coming@example.com)