From 957f451500bf66fde41090b861fab9f8815085fe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=EC=84=9C=EC=A4=80?= <104981505+xeoxxn@users.noreply.github.com> Date: Mon, 19 Oct 2026 20:59:44 +0900 Subject: [PATCH 1/2] =?UTF-8?q?feat:=20=ED=9A=8C=EC=9B=90=20=ED=9A=8C?= =?UTF-8?q?=EB=B9=84=20=EB=82=A9=EB=B6=80=C2=B7=EB=82=A9=EB=B6=80=20?= =?UTF-8?q?=EC=B7=A8=EC=86=8C=20=EC=9D=B4=EB=B2=A4=ED=8A=B8=EB=A5=BC=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../ac/kookmin/stream/common/event/MemberFeePaid.java | 10 ++++++++++ .../stream/common/event/MemberFeePaymentCanceled.java | 10 ++++++++++ 2 files changed, 20 insertions(+) create mode 100644 core/common/src/main/java/kr/ac/kookmin/stream/common/event/MemberFeePaid.java create mode 100644 core/common/src/main/java/kr/ac/kookmin/stream/common/event/MemberFeePaymentCanceled.java diff --git a/core/common/src/main/java/kr/ac/kookmin/stream/common/event/MemberFeePaid.java b/core/common/src/main/java/kr/ac/kookmin/stream/common/event/MemberFeePaid.java new file mode 100644 index 00000000..22e080c9 --- /dev/null +++ b/core/common/src/main/java/kr/ac/kookmin/stream/common/event/MemberFeePaid.java @@ -0,0 +1,10 @@ +package kr.ac.kookmin.stream.common.event; + +import java.time.LocalDateTime; + +/** + * 회원의 학생회비 납부가 확인되었다 — 운영진이 회원의 최신 납부 요청을 납부완료로 처리했다. + * occurredAt은 같은 회원의 납부·납부 취소가 재시도로 순서가 뒤바뀌어 도착할 때 늦은 쪽을 가려내는 데 쓴다. + */ +public record MemberFeePaid(Long memberId, Long studentTransferStatusId, LocalDateTime occurredAt) + implements DomainEvent {} diff --git a/core/common/src/main/java/kr/ac/kookmin/stream/common/event/MemberFeePaymentCanceled.java b/core/common/src/main/java/kr/ac/kookmin/stream/common/event/MemberFeePaymentCanceled.java new file mode 100644 index 00000000..e502402b --- /dev/null +++ b/core/common/src/main/java/kr/ac/kookmin/stream/common/event/MemberFeePaymentCanceled.java @@ -0,0 +1,10 @@ +package kr.ac.kookmin.stream.common.event; + +import java.time.LocalDateTime; + +/** + * 운영진이 회원의 납부완료를 정정했다 — 납부완료였던 최신 요청을 확인필요·납부확인중으로 바꿨다. + * occurredAt의 쓰임은 {@link MemberFeePaid}와 같다. + */ +public record MemberFeePaymentCanceled(Long memberId, Long studentTransferStatusId, LocalDateTime occurredAt) + implements DomainEvent {} From 72f0919dce0fa7bba0b390007b3925a1cd4b69cc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=EC=84=9C=EC=A4=80?= <104981505+xeoxxn@users.noreply.github.com> Date: Mon, 19 Oct 2026 21:05:20 +0900 Subject: [PATCH 2/2] =?UTF-8?q?feat:=20=EC=9A=B4=EC=98=81=EC=A7=84=20?= =?UTF-8?q?=EC=B2=98=EB=A6=AC=EB=A1=9C=20=EB=82=A9=EB=B6=80=20=EC=97=AC?= =?UTF-8?q?=EB=B6=80=EA=B0=80=20=EB=B0=94=EB=80=8C=EB=A9=B4=20=EC=95=84?= =?UTF-8?q?=EC=9B=83=EB=B0=95=EC=8A=A4=EC=97=90=20=EB=82=A8=EA=B8=B4?= =?UTF-8?q?=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../fee/usecase/AdminFeeReviewUseCase.java | 1 + .../service/StudentTransferStatusService.java | 2 ++ .../StudentTransferStatusServiceImpl.java | 24 +++++++++++++++++-- 3 files changed, 25 insertions(+), 2 deletions(-) diff --git a/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/welfare/fee/usecase/AdminFeeReviewUseCase.java b/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/welfare/fee/usecase/AdminFeeReviewUseCase.java index bb59ae3e..a4269b61 100644 --- a/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/welfare/fee/usecase/AdminFeeReviewUseCase.java +++ b/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/welfare/fee/usecase/AdminFeeReviewUseCase.java @@ -14,6 +14,7 @@ /** * 납부 상태 처리. 상태 변경과 납부자 명부 반영이 어긋나면 안 되므로 한 트랜잭션으로 묶는다. + * 서비스가 남기는 아웃박스 이벤트(납부 여부 변경)도 이 트랜잭션에 함께 커밋·롤백된다. */ @Component @RequiredArgsConstructor diff --git a/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/fee/service/StudentTransferStatusService.java b/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/fee/service/StudentTransferStatusService.java index a9671aaa..41744370 100644 --- a/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/fee/service/StudentTransferStatusService.java +++ b/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/fee/service/StudentTransferStatusService.java @@ -30,6 +30,8 @@ public interface StudentTransferStatusService { /** * 관리자 처리. 이미 처리된 요청도 다시 바꿀 수 있다. adminNote는 확인필요일 때만 남는다. * 회원의 최신 요청만 바꿀 수 있고, 지난 요청이면 {@code FEE_REQUEST_NOT_LATEST}다. + * 납부 여부가 바뀌면(납부완료가 되거나 납부완료에서 벗어나면) 같은 트랜잭션에서 아웃박스에 + * {@code MemberFeePaid} · {@code MemberFeePaymentCanceled}를 남긴다. */ StudentTransferStatus review(Long transferStatusId, TransferStatus status, String adminNote); } diff --git a/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/fee/service/impl/StudentTransferStatusServiceImpl.java b/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/fee/service/impl/StudentTransferStatusServiceImpl.java index 615f52fa..48b43c58 100644 --- a/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/fee/service/impl/StudentTransferStatusServiceImpl.java +++ b/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/fee/service/impl/StudentTransferStatusServiceImpl.java @@ -8,8 +8,11 @@ import java.util.stream.Collectors; import kr.ac.kookmin.stream.common.BusinessException; import kr.ac.kookmin.stream.common.DateUtil; +import kr.ac.kookmin.stream.common.OutboxWriter; import kr.ac.kookmin.stream.common.PageOffset; import kr.ac.kookmin.stream.common.PageResult; +import kr.ac.kookmin.stream.common.event.MemberFeePaid; +import kr.ac.kookmin.stream.common.event.MemberFeePaymentCanceled; import kr.ac.kookmin.stream.welfare.domain.fee.domain.FeeErrorCode; import kr.ac.kookmin.stream.welfare.domain.fee.domain.StudentTransferStatus; import kr.ac.kookmin.stream.welfare.domain.fee.domain.TransferStatus; @@ -24,6 +27,7 @@ class StudentTransferStatusServiceImpl implements StudentTransferStatusService { private final StudentTransferStatusRepository studentTransferStatusRepository; + private final OutboxWriter outboxWriter; // 조회 쿼리가 1개라 트랜잭션을 걸지 않는다(PR#67/#69 원칙 — 조회니까 붙이는 게 아니라 실제 이득이 있을 때만 붙인다). @Override @@ -68,8 +72,24 @@ public StudentTransferStatus review(Long transferStatusId, TransferStatus status .orElseThrow(() -> new BusinessException(FeeErrorCode.FEE_REQUEST_NOT_FOUND)); requireLatest(transferStatus); - transferStatus.review(status, adminNote, LocalDateTime.now(DateUtil.KST)); - return studentTransferStatusRepository.save(transferStatus); + boolean wasPaid = transferStatus.getStatus() == TransferStatus.PAID; + LocalDateTime now = LocalDateTime.now(DateUtil.KST); + transferStatus.review(status, adminNote, now); + StudentTransferStatus reviewed = studentTransferStatusRepository.save(transferStatus); + + recordPaidChange(wasPaid, reviewed, now); + return reviewed; + } + + // 회원의 납부 여부(최신 요청이 PAID인가)가 바뀔 때만 남긴다. 최신 요청만 처리하므로 이 요청의 변화가 곧 회원의 변화다. + // 상태 변경과 같은 트랜잭션에서 기록해서, 처리가 롤백되면 이벤트도 함께 사라진다(OutboxWriter는 트랜잭션 밖에서 부르면 실패한다). + private void recordPaidChange(boolean wasPaid, StudentTransferStatus reviewed, LocalDateTime now) { + boolean paid = reviewed.getStatus() == TransferStatus.PAID; + if (!wasPaid && paid) { + outboxWriter.record(new MemberFeePaid(reviewed.getMemberId(), reviewed.getId(), now)); + } else if (wasPaid && !paid) { + outboxWriter.record(new MemberFeePaymentCanceled(reviewed.getMemberId(), reviewed.getId(), now)); + } } // 지난 요청은 이력이라 바꾸지 않는다. 회원의 지금 상태는 최신 요청 하나뿐이고, 지난 요청을 되살리면