From 44b66b48eda316118f5c6d17c06c36b6b4f39c0c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9B=90=EC=A7=80=EC=9C=A4?= Date: Sat, 14 Mar 2026 12:24:00 +0900 Subject: [PATCH 01/11] =?UTF-8?q?feat:=20=EC=B0=B8=EC=97=AC=EC=9E=90=20?= =?UTF-8?q?=EB=AA=A8=EB=91=90=20=EC=9E=85=EA=B8=88=20=EC=99=84=EB=A3=8C?= =?UTF-8?q?=EC=8B=9C=20=EC=9E=90=EB=8F=99=20=EC=BA=90=EB=A6=AD=ED=84=B0=20?= =?UTF-8?q?=EC=A7=80=EA=B8=89=20=EC=9D=B4=EB=B2=A4=ED=8A=B8=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80=20=EB=B0=8F=20=EC=9E=AC=EC=8B=9C=EB=8F=84=20=EB=A1=9C?= =?UTF-8?q?=EC=A7=81=20=EC=B6=94=EA=B0=80(Outbox=ED=8C=A8=ED=84=B4)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/com/dnd/moddo/ModdoApplication.java | 2 + .../command/CommandMemberService.java | 18 +--- .../command/CommandPaymentRequest.java | 3 + .../command/CommandSettlementService.java | 5 +- .../event/application/impl/MemberReader.java | 10 ++ .../impl/SettlementCompletionProcessor.java | 32 +++++++ .../application/impl/SettlementUpdater.java | 11 +++ .../infrastructure/MemberRepository.java | 8 ++ .../command/CommandEventTaskService.java | 19 ++++ .../command/CommandOutboxEventService.java | 22 +++++ .../event/OutboxEventCreatedEvent.java | 4 + .../OutboxEventCreatedEventListener.java | 20 ++++ .../impl/EventTaskFailureNotifier.java | 38 ++++++++ .../application/impl/EventTaskProcessor.java | 43 +++++++++ .../impl/EventTaskRetryPolicy.java | 8 ++ .../application/impl/EventTaskScheduler.java | 29 ++++++ .../application/impl/OutboxEventCreator.java | 28 ++++++ .../impl/OutboxEventPublisher.java | 51 ++++++++++ .../impl/OutboxEventTaskAppender.java | 38 ++++++++ .../outbox/domain/event/OutboxEvent.java | 76 +++++++++++++++ .../domain/event/type/AggregateType.java | 5 + .../domain/event/type/OutboxEventStatus.java | 7 ++ .../domain/event/type/OutboxEventType.java | 5 + .../moddo/outbox/domain/task/EventTask.java | 92 +++++++++++++++++++ .../domain/task/type/EventTaskStatus.java | 8 ++ .../domain/task/type/EventTaskType.java | 6 ++ .../infrastructure/EventTaskRepository.java | 22 +++++ .../infrastructure/OutboxEventRepository.java | 19 ++++ .../EventTaskAdminController.java | 38 ++++++++ .../application/CommandRewardService.java | 19 ++++ .../application/impl/RewardGrantHandler.java | 36 ++++++++ .../reward/domain/character/Collection.java | 26 +++++- .../SettlementCharacterNotFoundException.java | 11 +++ .../infrastructure/CollectionRepository.java | 1 + .../RewardQueryRepositoryImpl.java | 8 +- .../presentation/RewardAdminController.java | 42 +++++++++ src/main/resources/config | 2 +- 37 files changed, 791 insertions(+), 21 deletions(-) create mode 100644 src/main/java/com/dnd/moddo/event/application/impl/SettlementCompletionProcessor.java create mode 100644 src/main/java/com/dnd/moddo/outbox/application/command/CommandEventTaskService.java create mode 100644 src/main/java/com/dnd/moddo/outbox/application/command/CommandOutboxEventService.java create mode 100644 src/main/java/com/dnd/moddo/outbox/application/event/OutboxEventCreatedEvent.java create mode 100644 src/main/java/com/dnd/moddo/outbox/application/event/OutboxEventCreatedEventListener.java create mode 100644 src/main/java/com/dnd/moddo/outbox/application/impl/EventTaskFailureNotifier.java create mode 100644 src/main/java/com/dnd/moddo/outbox/application/impl/EventTaskProcessor.java create mode 100644 src/main/java/com/dnd/moddo/outbox/application/impl/EventTaskRetryPolicy.java create mode 100644 src/main/java/com/dnd/moddo/outbox/application/impl/EventTaskScheduler.java create mode 100644 src/main/java/com/dnd/moddo/outbox/application/impl/OutboxEventCreator.java create mode 100644 src/main/java/com/dnd/moddo/outbox/application/impl/OutboxEventPublisher.java create mode 100644 src/main/java/com/dnd/moddo/outbox/application/impl/OutboxEventTaskAppender.java create mode 100644 src/main/java/com/dnd/moddo/outbox/domain/event/OutboxEvent.java create mode 100644 src/main/java/com/dnd/moddo/outbox/domain/event/type/AggregateType.java create mode 100644 src/main/java/com/dnd/moddo/outbox/domain/event/type/OutboxEventStatus.java create mode 100644 src/main/java/com/dnd/moddo/outbox/domain/event/type/OutboxEventType.java create mode 100644 src/main/java/com/dnd/moddo/outbox/domain/task/EventTask.java create mode 100644 src/main/java/com/dnd/moddo/outbox/domain/task/type/EventTaskStatus.java create mode 100644 src/main/java/com/dnd/moddo/outbox/domain/task/type/EventTaskType.java create mode 100644 src/main/java/com/dnd/moddo/outbox/infrastructure/EventTaskRepository.java create mode 100644 src/main/java/com/dnd/moddo/outbox/infrastructure/OutboxEventRepository.java create mode 100644 src/main/java/com/dnd/moddo/outbox/presentation/EventTaskAdminController.java create mode 100644 src/main/java/com/dnd/moddo/reward/application/CommandRewardService.java create mode 100644 src/main/java/com/dnd/moddo/reward/application/impl/RewardGrantHandler.java create mode 100644 src/main/java/com/dnd/moddo/reward/domain/character/exception/SettlementCharacterNotFoundException.java create mode 100644 src/main/java/com/dnd/moddo/reward/presentation/RewardAdminController.java diff --git a/src/main/java/com/dnd/moddo/ModdoApplication.java b/src/main/java/com/dnd/moddo/ModdoApplication.java index fdb8f8ac..5faa7475 100644 --- a/src/main/java/com/dnd/moddo/ModdoApplication.java +++ b/src/main/java/com/dnd/moddo/ModdoApplication.java @@ -5,10 +5,12 @@ import org.springframework.boot.autoconfigure.security.servlet.SecurityAutoConfiguration; import org.springframework.boot.context.properties.ConfigurationPropertiesScan; import org.springframework.cloud.openfeign.EnableFeignClients; +import org.springframework.scheduling.annotation.EnableScheduling; @SpringBootApplication(exclude = SecurityAutoConfiguration.class) @ConfigurationPropertiesScan @EnableFeignClients +@EnableScheduling public class ModdoApplication { public static void main(String[] args) { diff --git a/src/main/java/com/dnd/moddo/event/application/command/CommandMemberService.java b/src/main/java/com/dnd/moddo/event/application/command/CommandMemberService.java index 2a4f715d..821bd67d 100644 --- a/src/main/java/com/dnd/moddo/event/application/command/CommandMemberService.java +++ b/src/main/java/com/dnd/moddo/event/application/command/CommandMemberService.java @@ -1,13 +1,11 @@ package com.dnd.moddo.event.application.command; -import java.util.List; - import org.springframework.stereotype.Service; +import com.dnd.moddo.event.application.impl.SettlementCompletionProcessor; import com.dnd.moddo.event.application.impl.MemberCreator; import com.dnd.moddo.event.application.impl.MemberDeleter; import com.dnd.moddo.event.application.impl.MemberUpdater; -import com.dnd.moddo.event.application.query.QueryMemberService; import com.dnd.moddo.event.domain.member.Member; import com.dnd.moddo.event.domain.settlement.Settlement; import com.dnd.moddo.event.presentation.request.MemberSaveRequest; @@ -22,8 +20,7 @@ public class CommandMemberService { private final MemberCreator memberCreator; private final MemberUpdater memberUpdater; private final MemberDeleter memberDeleter; - - private final QueryMemberService queryMemberService; + private final SettlementCompletionProcessor settlementCompletionProcessor; public MemberResponse createManager(Settlement settlement, Long userId) { Member member = memberCreator.createManagerForSettlement(settlement, userId); @@ -38,16 +35,7 @@ public MemberResponse addMember(Long settlementId, MemberSaveRequest request) { public MemberResponse updatePaymentStatus(Long appointmentMemberId, PaymentStatusUpdateRequest request) { Member member = memberUpdater.updatePaymentStatus(appointmentMemberId, request); - List members = queryMemberService.findAllBySettlementId( - member.getSettlementId()); - - boolean allPaid = members.stream() - .allMatch(Member::isPaid); - - if (allPaid) { - member.getSettlement().complete(); - } - + settlementCompletionProcessor.completeIfAllPaid(member.getSettlementId()); return MemberResponse.of(member); } diff --git a/src/main/java/com/dnd/moddo/event/application/command/CommandPaymentRequest.java b/src/main/java/com/dnd/moddo/event/application/command/CommandPaymentRequest.java index cf16d810..edf9ef78 100644 --- a/src/main/java/com/dnd/moddo/event/application/command/CommandPaymentRequest.java +++ b/src/main/java/com/dnd/moddo/event/application/command/CommandPaymentRequest.java @@ -2,6 +2,7 @@ import org.springframework.stereotype.Service; +import com.dnd.moddo.event.application.impl.SettlementCompletionProcessor; import com.dnd.moddo.event.application.impl.PaymentRequestCreator; import com.dnd.moddo.event.application.impl.PaymentRequestUpdater; import com.dnd.moddo.event.domain.paymentRequest.PaymentRequest; @@ -14,6 +15,7 @@ public class CommandPaymentRequest { private final PaymentRequestCreator paymentRequestCreator; private final PaymentRequestUpdater paymentRequestUpdater; + private final SettlementCompletionProcessor settlementCompletionProcessor; public PaymentRequestResponse createPaymentRequest(Long settlementId, Long userId) { PaymentRequest paymentRequest = paymentRequestCreator.createPaymentRequest(settlementId, userId); @@ -22,6 +24,7 @@ public PaymentRequestResponse createPaymentRequest(Long settlementId, Long userI public PaymentRequestResponse approvePaymentRequest(Long paymentRequestId, Long userId) { PaymentRequest paymentRequest = paymentRequestUpdater.approvePaymentRequest(paymentRequestId, userId); + settlementCompletionProcessor.completeIfAllPaid(paymentRequest.getSettlementId()); return PaymentRequestResponse.of(paymentRequest); } diff --git a/src/main/java/com/dnd/moddo/event/application/command/CommandSettlementService.java b/src/main/java/com/dnd/moddo/event/application/command/CommandSettlementService.java index fbbbb7ea..6a317b59 100644 --- a/src/main/java/com/dnd/moddo/event/application/command/CommandSettlementService.java +++ b/src/main/java/com/dnd/moddo/event/application/command/CommandSettlementService.java @@ -3,6 +3,7 @@ import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import com.dnd.moddo.event.application.impl.MemberCreator; import com.dnd.moddo.event.application.impl.SettlementCreator; import com.dnd.moddo.event.application.impl.SettlementReader; import com.dnd.moddo.event.application.impl.SettlementUpdater; @@ -24,11 +25,11 @@ public class CommandSettlementService { private final SettlementUpdater settlementUpdater; private final SettlementValidator settlementValidator; private final SettlementReader settlementReader; - private final CommandMemberService commandMemberService; + private final MemberCreator memberCreator; public SettlementSaveResponse createSettlement(SettlementRequest request, Long userId) { Settlement settlement = settlementCreator.createSettlement(request, userId); - MemberResponse manager = commandMemberService.createManager(settlement, userId); + MemberResponse manager = MemberResponse.of(memberCreator.createManagerForSettlement(settlement, userId)); return new SettlementSaveResponse(settlement.getCode(), manager); } diff --git a/src/main/java/com/dnd/moddo/event/application/impl/MemberReader.java b/src/main/java/com/dnd/moddo/event/application/impl/MemberReader.java index 5e3a7d34..eef13ff7 100644 --- a/src/main/java/com/dnd/moddo/event/application/impl/MemberReader.java +++ b/src/main/java/com/dnd/moddo/event/application/impl/MemberReader.java @@ -41,4 +41,14 @@ public Member findBySettlementIdAndUserId(Long settlementId, Long userId) { .orElseThrow(() -> new MemberNotFoundException(userId)); } + public boolean existsUnpaidMember(Long settlementId) { + return memberRepository.existsBySettlementIdAndIsPaidFalse(settlementId); + } + + public List findAssignedMembersBySettlementId(Long settlementId) { + return findAllBySettlementId(settlementId).stream() + .filter(Member::isAssigned) + .toList(); + } + } diff --git a/src/main/java/com/dnd/moddo/event/application/impl/SettlementCompletionProcessor.java b/src/main/java/com/dnd/moddo/event/application/impl/SettlementCompletionProcessor.java new file mode 100644 index 00000000..ccf59d7c --- /dev/null +++ b/src/main/java/com/dnd/moddo/event/application/impl/SettlementCompletionProcessor.java @@ -0,0 +1,32 @@ +package com.dnd.moddo.event.application.impl; + +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import com.dnd.moddo.outbox.application.command.CommandOutboxEventService; +import com.dnd.moddo.outbox.domain.event.type.AggregateType; +import com.dnd.moddo.outbox.domain.event.type.OutboxEventType; + +import lombok.RequiredArgsConstructor; + +@Service +@RequiredArgsConstructor +public class SettlementCompletionProcessor { + private final MemberReader memberReader; + private final SettlementUpdater settlementUpdater; + private final CommandOutboxEventService commandOutboxEventService; + + @Transactional + public boolean completeIfAllPaid(Long settlementId) { + if (memberReader.existsUnpaidMember(settlementId)) { + return false; + } + + boolean completed = settlementUpdater.complete(settlementId); + if (completed) { + commandOutboxEventService.create(OutboxEventType.SETTLEMENT_COMPLETED, AggregateType.SETTLEMENT, + settlementId); + } + return completed; + } +} diff --git a/src/main/java/com/dnd/moddo/event/application/impl/SettlementUpdater.java b/src/main/java/com/dnd/moddo/event/application/impl/SettlementUpdater.java index f001e8f8..384f956b 100644 --- a/src/main/java/com/dnd/moddo/event/application/impl/SettlementUpdater.java +++ b/src/main/java/com/dnd/moddo/event/application/impl/SettlementUpdater.java @@ -20,4 +20,15 @@ public Settlement updateAccount(SettlementAccountRequest request, Long settlemen settlement.updateAccount(request); return settlement; } + + public boolean complete(Long settlementId) { + Settlement settlement = settlementRepository.getById(settlementId); + + if (settlement.getCompletedAt() != null) { + return false; + } + + settlement.complete(); + return true; + } } diff --git a/src/main/java/com/dnd/moddo/event/infrastructure/MemberRepository.java b/src/main/java/com/dnd/moddo/event/infrastructure/MemberRepository.java index 7424671f..658b7862 100644 --- a/src/main/java/com/dnd/moddo/event/infrastructure/MemberRepository.java +++ b/src/main/java/com/dnd/moddo/event/infrastructure/MemberRepository.java @@ -31,6 +31,14 @@ select count(gm) > 0 """) Optional findBySettlementIdAndUserId(@Param("settlementId") Long settlementId, @Param("userId") Long userId); + @Query(""" + select count(gm) > 0 + from Member gm + where gm.settlement.id = :settlementId + and gm.isPaid = false + """) + boolean existsBySettlementIdAndIsPaidFalse(@Param("settlementId") Long settlementId); + default Member getById(Long id) { return findById(id) .orElseThrow(() -> new MemberNotFoundException(id)); diff --git a/src/main/java/com/dnd/moddo/outbox/application/command/CommandEventTaskService.java b/src/main/java/com/dnd/moddo/outbox/application/command/CommandEventTaskService.java new file mode 100644 index 00000000..d5b27f57 --- /dev/null +++ b/src/main/java/com/dnd/moddo/outbox/application/command/CommandEventTaskService.java @@ -0,0 +1,19 @@ +package com.dnd.moddo.outbox.application.command; + +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import com.dnd.moddo.outbox.application.impl.EventTaskProcessor; + +import lombok.RequiredArgsConstructor; + +@Service +@RequiredArgsConstructor +@Transactional +public class CommandEventTaskService { + private final EventTaskProcessor eventTaskProcessor; + + public void retry(Long eventTaskId) { + eventTaskProcessor.process(eventTaskId); + } +} diff --git a/src/main/java/com/dnd/moddo/outbox/application/command/CommandOutboxEventService.java b/src/main/java/com/dnd/moddo/outbox/application/command/CommandOutboxEventService.java new file mode 100644 index 00000000..96bac509 --- /dev/null +++ b/src/main/java/com/dnd/moddo/outbox/application/command/CommandOutboxEventService.java @@ -0,0 +1,22 @@ +package com.dnd.moddo.outbox.application.command; + +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import com.dnd.moddo.outbox.application.impl.OutboxEventCreator; +import com.dnd.moddo.outbox.domain.event.OutboxEvent; +import com.dnd.moddo.outbox.domain.event.type.AggregateType; +import com.dnd.moddo.outbox.domain.event.type.OutboxEventType; + +import lombok.RequiredArgsConstructor; + +@Service +@RequiredArgsConstructor +@Transactional +public class CommandOutboxEventService { + private final OutboxEventCreator outboxEventCreator; + + public OutboxEvent create(OutboxEventType type, AggregateType aggregateType, Long aggregateId) { + return outboxEventCreator.create(type, aggregateType, aggregateId); + } +} diff --git a/src/main/java/com/dnd/moddo/outbox/application/event/OutboxEventCreatedEvent.java b/src/main/java/com/dnd/moddo/outbox/application/event/OutboxEventCreatedEvent.java new file mode 100644 index 00000000..26a4fdb1 --- /dev/null +++ b/src/main/java/com/dnd/moddo/outbox/application/event/OutboxEventCreatedEvent.java @@ -0,0 +1,4 @@ +package com.dnd.moddo.outbox.application.event; + +public record OutboxEventCreatedEvent(Long outboxEventId) { +} diff --git a/src/main/java/com/dnd/moddo/outbox/application/event/OutboxEventCreatedEventListener.java b/src/main/java/com/dnd/moddo/outbox/application/event/OutboxEventCreatedEventListener.java new file mode 100644 index 00000000..33ce4c97 --- /dev/null +++ b/src/main/java/com/dnd/moddo/outbox/application/event/OutboxEventCreatedEventListener.java @@ -0,0 +1,20 @@ +package com.dnd.moddo.outbox.application.event; + +import org.springframework.stereotype.Component; +import org.springframework.transaction.event.TransactionPhase; +import org.springframework.transaction.event.TransactionalEventListener; + +import com.dnd.moddo.outbox.application.impl.OutboxEventPublisher; + +import lombok.RequiredArgsConstructor; + +@Component +@RequiredArgsConstructor +public class OutboxEventCreatedEventListener { + private final OutboxEventPublisher outboxEventPublisher; + + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) + public void handle(OutboxEventCreatedEvent event) { + outboxEventPublisher.publish(event.outboxEventId()); + } +} diff --git a/src/main/java/com/dnd/moddo/outbox/application/impl/EventTaskFailureNotifier.java b/src/main/java/com/dnd/moddo/outbox/application/impl/EventTaskFailureNotifier.java new file mode 100644 index 00000000..b4ac33f3 --- /dev/null +++ b/src/main/java/com/dnd/moddo/outbox/application/impl/EventTaskFailureNotifier.java @@ -0,0 +1,38 @@ +package com.dnd.moddo.outbox.application.impl; + +import java.util.List; + +import org.springframework.stereotype.Component; + +import com.dnd.moddo.common.logging.DiscordMessage; +import com.dnd.moddo.common.logging.ErrorNotifier; +import com.dnd.moddo.outbox.domain.task.EventTask; + +import lombok.RequiredArgsConstructor; + +@Component +@RequiredArgsConstructor +public class EventTaskFailureNotifier { + private final ErrorNotifier errorNotifier; + + public void notifyRetryExhausted(EventTask eventTask) { + errorNotifier.notifyError( + DiscordMessage.createDiscordMessage( + "# EventTask 재시도 초과", + List.of( + DiscordMessage.Embed.builder() + .title("EventTask 최종 실패") + .description( + "taskId: " + eventTask.getId() + "\n" + + "taskType: " + eventTask.getTaskType() + "\n" + + "targetUserId: " + eventTask.getTargetUserId() + "\n" + + "aggregateId: " + eventTask.getOutboxEvent().getAggregateId() + "\n" + + "attemptCount: " + eventTask.getAttemptCount() + "\n" + + "lastErrorMessage: " + eventTask.getLastErrorMessage() + ) + .build() + ) + ) + ); + } +} diff --git a/src/main/java/com/dnd/moddo/outbox/application/impl/EventTaskProcessor.java b/src/main/java/com/dnd/moddo/outbox/application/impl/EventTaskProcessor.java new file mode 100644 index 00000000..0b76dd2b --- /dev/null +++ b/src/main/java/com/dnd/moddo/outbox/application/impl/EventTaskProcessor.java @@ -0,0 +1,43 @@ +package com.dnd.moddo.outbox.application.impl; + +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import com.dnd.moddo.outbox.domain.task.EventTask; +import com.dnd.moddo.outbox.domain.task.type.EventTaskStatus; +import com.dnd.moddo.outbox.domain.task.type.EventTaskType; +import com.dnd.moddo.outbox.infrastructure.EventTaskRepository; +import com.dnd.moddo.reward.application.impl.RewardGrantHandler; + +import lombok.RequiredArgsConstructor; + +@Service +@RequiredArgsConstructor +public class EventTaskProcessor { + private final EventTaskRepository eventTaskRepository; + private final RewardGrantHandler rewardGrantHandler; + private final EventTaskFailureNotifier eventTaskFailureNotifier; + + @Transactional + public void process(Long eventTaskId) { + EventTask eventTask = eventTaskRepository.getById(eventTaskId); + if (eventTask.getStatus() == EventTaskStatus.COMPLETED) { + return; + } + + eventTask.markProcessing(); + + try { + if (eventTask.getTaskType() == EventTaskType.REWARD_GRANT) { + rewardGrantHandler.handle(eventTask.getOutboxEvent().getAggregateId(), eventTask.getTargetUserId()); + } + + eventTask.markCompleted(); + } catch (Exception exception) { + eventTask.markFailed(exception.getMessage()); + if (eventTask.getAttemptCount() >= EventTaskRetryPolicy.MAX_RETRY_COUNT) { + eventTaskFailureNotifier.notifyRetryExhausted(eventTask); + } + } + } +} diff --git a/src/main/java/com/dnd/moddo/outbox/application/impl/EventTaskRetryPolicy.java b/src/main/java/com/dnd/moddo/outbox/application/impl/EventTaskRetryPolicy.java new file mode 100644 index 00000000..0f63eeb0 --- /dev/null +++ b/src/main/java/com/dnd/moddo/outbox/application/impl/EventTaskRetryPolicy.java @@ -0,0 +1,8 @@ +package com.dnd.moddo.outbox.application.impl; + +public final class EventTaskRetryPolicy { + public static final int MAX_RETRY_COUNT = 5; + + private EventTaskRetryPolicy() { + } +} diff --git a/src/main/java/com/dnd/moddo/outbox/application/impl/EventTaskScheduler.java b/src/main/java/com/dnd/moddo/outbox/application/impl/EventTaskScheduler.java new file mode 100644 index 00000000..a20aa0ad --- /dev/null +++ b/src/main/java/com/dnd/moddo/outbox/application/impl/EventTaskScheduler.java @@ -0,0 +1,29 @@ +package com.dnd.moddo.outbox.application.impl; + +import java.util.List; + +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; + +import com.dnd.moddo.outbox.domain.task.EventTask; +import com.dnd.moddo.outbox.domain.task.type.EventTaskStatus; +import com.dnd.moddo.outbox.infrastructure.EventTaskRepository; + +import lombok.RequiredArgsConstructor; + +@Component +@RequiredArgsConstructor +public class EventTaskScheduler { + private final EventTaskRepository eventTaskRepository; + private final EventTaskProcessor eventTaskProcessor; + + @Scheduled(fixedDelay = 5000) + public void processPendingTasks() { + for (EventTask eventTask : eventTaskRepository.findTop30ByStatusInAndAttemptCountLessThanOrderByCreatedAtAsc( + List.of(EventTaskStatus.PENDING, EventTaskStatus.FAILED), + EventTaskRetryPolicy.MAX_RETRY_COUNT + )) { + eventTaskProcessor.process(eventTask.getId()); + } + } +} diff --git a/src/main/java/com/dnd/moddo/outbox/application/impl/OutboxEventCreator.java b/src/main/java/com/dnd/moddo/outbox/application/impl/OutboxEventCreator.java new file mode 100644 index 00000000..8b146046 --- /dev/null +++ b/src/main/java/com/dnd/moddo/outbox/application/impl/OutboxEventCreator.java @@ -0,0 +1,28 @@ +package com.dnd.moddo.outbox.application.impl; + +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import com.dnd.moddo.outbox.application.event.OutboxEventCreatedEvent; +import com.dnd.moddo.outbox.domain.event.OutboxEvent; +import com.dnd.moddo.outbox.domain.event.type.AggregateType; +import com.dnd.moddo.outbox.domain.event.type.OutboxEventType; +import com.dnd.moddo.outbox.infrastructure.OutboxEventRepository; + +import lombok.RequiredArgsConstructor; + +@Service +@RequiredArgsConstructor +@Transactional +public class OutboxEventCreator { + private final OutboxEventRepository outboxEventRepository; + private final ApplicationEventPublisher eventPublisher; + + public OutboxEvent create(OutboxEventType type, AggregateType aggregateType, Long aggregateId) { + OutboxEvent outboxEvent = OutboxEvent.pending(type, aggregateType, aggregateId); + OutboxEvent savedOutboxEvent = outboxEventRepository.save(outboxEvent); + eventPublisher.publishEvent(new OutboxEventCreatedEvent(savedOutboxEvent.getId())); + return savedOutboxEvent; + } +} diff --git a/src/main/java/com/dnd/moddo/outbox/application/impl/OutboxEventPublisher.java b/src/main/java/com/dnd/moddo/outbox/application/impl/OutboxEventPublisher.java new file mode 100644 index 00000000..dd9c640a --- /dev/null +++ b/src/main/java/com/dnd/moddo/outbox/application/impl/OutboxEventPublisher.java @@ -0,0 +1,51 @@ +package com.dnd.moddo.outbox.application.impl; + +import java.util.List; + +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Propagation; +import org.springframework.transaction.annotation.Transactional; + +import com.dnd.moddo.outbox.domain.event.OutboxEvent; +import com.dnd.moddo.outbox.domain.event.type.OutboxEventStatus; +import com.dnd.moddo.outbox.infrastructure.OutboxEventRepository; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@Service +@RequiredArgsConstructor +@Slf4j +public class OutboxEventPublisher { + private final OutboxEventRepository outboxEventRepository; + private final OutboxEventTaskAppender outboxEventTaskAppender; + + @Transactional + public void publishPendingEvents() { + List pendingEvents = outboxEventRepository.findAllByStatus((OutboxEventStatus.PENDING)); + + for (OutboxEvent outboxEvent : pendingEvents) { + publish(outboxEvent.getId()); + } + } + + @Transactional(propagation = Propagation.REQUIRES_NEW) + public void publish(Long outboxEventId) { + OutboxEvent outboxEvent = outboxEventRepository.getById(outboxEventId); + if (outboxEvent.getStatus() != OutboxEventStatus.PENDING) { + return; + } + + try { + outboxEventTaskAppender.appendTasks(outboxEvent); + outboxEvent.markPublished(); + } catch (Exception exception) { + log.error("Failed to publish outbox event. outboxEventId={}, eventType={}, aggregateId={}", + outboxEvent.getId(), + outboxEvent.getEventType(), + outboxEvent.getAggregateId(), + exception); + outboxEvent.markFailed(); + } + } +} diff --git a/src/main/java/com/dnd/moddo/outbox/application/impl/OutboxEventTaskAppender.java b/src/main/java/com/dnd/moddo/outbox/application/impl/OutboxEventTaskAppender.java new file mode 100644 index 00000000..f46297f2 --- /dev/null +++ b/src/main/java/com/dnd/moddo/outbox/application/impl/OutboxEventTaskAppender.java @@ -0,0 +1,38 @@ +package com.dnd.moddo.outbox.application.impl; + +import org.springframework.stereotype.Component; + +import com.dnd.moddo.event.application.impl.MemberReader; +import com.dnd.moddo.event.domain.member.Member; +import com.dnd.moddo.outbox.domain.event.OutboxEvent; +import com.dnd.moddo.outbox.domain.task.EventTask; +import com.dnd.moddo.outbox.domain.event.type.OutboxEventType; +import com.dnd.moddo.outbox.domain.task.type.EventTaskType; +import com.dnd.moddo.outbox.infrastructure.EventTaskRepository; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@Component +@RequiredArgsConstructor +@Slf4j +public class OutboxEventTaskAppender { + private final EventTaskRepository eventTaskRepository; + private final MemberReader memberReader; + + public void appendTasks(OutboxEvent outboxEvent) { + if (outboxEvent.getEventType() == OutboxEventType.SETTLEMENT_COMPLETED) { + appendSettlementCompletedTasks(outboxEvent); + } + } + + private void appendSettlementCompletedTasks(OutboxEvent outboxEvent) { + for (Member member : memberReader.findAssignedMembersBySettlementId(outboxEvent.getAggregateId())) { + Long targetUserId = member.getUserId(); + eventTaskRepository.saveAndFlush(EventTask.pending(outboxEvent, EventTaskType.REWARD_GRANT, targetUserId)); + eventTaskRepository.saveAndFlush( + EventTask.pending(outboxEvent, EventTaskType.NOTIFICATION_SEND, targetUserId) + ); + } + } +} diff --git a/src/main/java/com/dnd/moddo/outbox/domain/event/OutboxEvent.java b/src/main/java/com/dnd/moddo/outbox/domain/event/OutboxEvent.java new file mode 100644 index 00000000..b6bde6c5 --- /dev/null +++ b/src/main/java/com/dnd/moddo/outbox/domain/event/OutboxEvent.java @@ -0,0 +1,76 @@ +package com.dnd.moddo.outbox.domain.event; + +import java.time.LocalDateTime; + +import com.dnd.moddo.outbox.domain.event.type.AggregateType; +import com.dnd.moddo.outbox.domain.event.type.OutboxEventStatus; +import com.dnd.moddo.outbox.domain.event.type.OutboxEventType; + +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 lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Table(name = "outbox") +@Entity +public class OutboxEvent { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Enumerated(EnumType.STRING) + @Column(nullable = false) + private OutboxEventType eventType; + + @Enumerated(EnumType.STRING) + @Column(nullable = false) + private AggregateType aggregateType; + + @Column(nullable = false) + private Long aggregateId; + + @Enumerated(EnumType.STRING) + @Column(nullable = false) + private OutboxEventStatus status; + + @Column(nullable = false, updatable = false) + private LocalDateTime createdAt; + + private LocalDateTime publishedAt; + + @Builder + private OutboxEvent(OutboxEventType eventType, AggregateType aggregateType, Long aggregateId) { + this.eventType = eventType; + this.aggregateType = aggregateType; + this.aggregateId = aggregateId; + this.status = OutboxEventStatus.PENDING; + this.createdAt = LocalDateTime.now(); + } + + public static OutboxEvent pending(OutboxEventType eventType, AggregateType aggregateType, Long aggregateId) { + return OutboxEvent.builder() + .eventType(eventType) + .aggregateType(aggregateType) + .aggregateId(aggregateId) + .build(); + } + + public void markPublished() { + this.status = OutboxEventStatus.PUBLISHED; + this.publishedAt = LocalDateTime.now(); + } + + public void markFailed() { + this.status = OutboxEventStatus.FAILED; + } +} diff --git a/src/main/java/com/dnd/moddo/outbox/domain/event/type/AggregateType.java b/src/main/java/com/dnd/moddo/outbox/domain/event/type/AggregateType.java new file mode 100644 index 00000000..5727a103 --- /dev/null +++ b/src/main/java/com/dnd/moddo/outbox/domain/event/type/AggregateType.java @@ -0,0 +1,5 @@ +package com.dnd.moddo.outbox.domain.event.type; + +public enum AggregateType { + SETTLEMENT, +} diff --git a/src/main/java/com/dnd/moddo/outbox/domain/event/type/OutboxEventStatus.java b/src/main/java/com/dnd/moddo/outbox/domain/event/type/OutboxEventStatus.java new file mode 100644 index 00000000..56b9281f --- /dev/null +++ b/src/main/java/com/dnd/moddo/outbox/domain/event/type/OutboxEventStatus.java @@ -0,0 +1,7 @@ +package com.dnd.moddo.outbox.domain.event.type; + +public enum OutboxEventStatus { + PENDING, + PUBLISHED, + FAILED, +} diff --git a/src/main/java/com/dnd/moddo/outbox/domain/event/type/OutboxEventType.java b/src/main/java/com/dnd/moddo/outbox/domain/event/type/OutboxEventType.java new file mode 100644 index 00000000..5658c827 --- /dev/null +++ b/src/main/java/com/dnd/moddo/outbox/domain/event/type/OutboxEventType.java @@ -0,0 +1,5 @@ +package com.dnd.moddo.outbox.domain.event.type; + +public enum OutboxEventType { + SETTLEMENT_COMPLETED, +} diff --git a/src/main/java/com/dnd/moddo/outbox/domain/task/EventTask.java b/src/main/java/com/dnd/moddo/outbox/domain/task/EventTask.java new file mode 100644 index 00000000..6d57d686 --- /dev/null +++ b/src/main/java/com/dnd/moddo/outbox/domain/task/EventTask.java @@ -0,0 +1,92 @@ +package com.dnd.moddo.outbox.domain.task; + +import java.time.LocalDateTime; + +import com.dnd.moddo.outbox.domain.event.OutboxEvent; +import com.dnd.moddo.outbox.domain.task.type.EventTaskStatus; +import com.dnd.moddo.outbox.domain.task.type.EventTaskType; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Table(name = "event_task") +@Entity +public class EventTask { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "outbox_id", nullable = false) + private OutboxEvent outboxEvent; + + @Enumerated(EnumType.STRING) + @Column(nullable = false) + private EventTaskType taskType; + + @Enumerated(EnumType.STRING) + @Column(nullable = false) + private EventTaskStatus status; + + @Column(name = "target_user_id") + private Long targetUserId; + + @Column(nullable = false) + private int attemptCount; + + private String lastErrorMessage; + + @Column(nullable = false, updatable = false) + private LocalDateTime createdAt; + + private LocalDateTime processedAt; + + @Builder + private EventTask(OutboxEvent outboxEvent, EventTaskType taskType, Long targetUserId) { + this.outboxEvent = outboxEvent; + this.taskType = taskType; + this.targetUserId = targetUserId; + this.status = EventTaskStatus.PENDING; + this.attemptCount = 0; + this.createdAt = LocalDateTime.now(); + } + + public static EventTask pending(OutboxEvent outboxEvent, EventTaskType taskType, Long targetUserId) { + return EventTask.builder() + .outboxEvent(outboxEvent) + .taskType(taskType) + .targetUserId(targetUserId) + .build(); + } + + public void markProcessing() { + this.status = EventTaskStatus.PROCESSING; + } + + public void markCompleted() { + this.status = EventTaskStatus.COMPLETED; + this.processedAt = LocalDateTime.now(); + this.lastErrorMessage = null; + } + + public void markFailed(String errorMessage) { + this.status = EventTaskStatus.FAILED; + this.attemptCount++; + this.lastErrorMessage = errorMessage; + } +} diff --git a/src/main/java/com/dnd/moddo/outbox/domain/task/type/EventTaskStatus.java b/src/main/java/com/dnd/moddo/outbox/domain/task/type/EventTaskStatus.java new file mode 100644 index 00000000..8aacd076 --- /dev/null +++ b/src/main/java/com/dnd/moddo/outbox/domain/task/type/EventTaskStatus.java @@ -0,0 +1,8 @@ +package com.dnd.moddo.outbox.domain.task.type; + +public enum EventTaskStatus { + PENDING, + PROCESSING, + COMPLETED, + FAILED, +} diff --git a/src/main/java/com/dnd/moddo/outbox/domain/task/type/EventTaskType.java b/src/main/java/com/dnd/moddo/outbox/domain/task/type/EventTaskType.java new file mode 100644 index 00000000..3d250517 --- /dev/null +++ b/src/main/java/com/dnd/moddo/outbox/domain/task/type/EventTaskType.java @@ -0,0 +1,6 @@ +package com.dnd.moddo.outbox.domain.task.type; + +public enum EventTaskType { + REWARD_GRANT, + NOTIFICATION_SEND, +} diff --git a/src/main/java/com/dnd/moddo/outbox/infrastructure/EventTaskRepository.java b/src/main/java/com/dnd/moddo/outbox/infrastructure/EventTaskRepository.java new file mode 100644 index 00000000..d0b11f2c --- /dev/null +++ b/src/main/java/com/dnd/moddo/outbox/infrastructure/EventTaskRepository.java @@ -0,0 +1,22 @@ +package com.dnd.moddo.outbox.infrastructure; + +import java.util.List; + +import org.springframework.data.jpa.repository.JpaRepository; + +import com.dnd.moddo.outbox.domain.task.EventTask; +import com.dnd.moddo.outbox.domain.task.type.EventTaskStatus; + +import jakarta.persistence.EntityNotFoundException; + +public interface EventTaskRepository extends JpaRepository { + List findTop30ByStatusInAndAttemptCountLessThanOrderByCreatedAtAsc( + List statuses, + int attemptCount + ); + + default EventTask getById(Long id) { + return findById(id) + .orElseThrow(() -> new EntityNotFoundException("Event task not found: " + id)); + } +} diff --git a/src/main/java/com/dnd/moddo/outbox/infrastructure/OutboxEventRepository.java b/src/main/java/com/dnd/moddo/outbox/infrastructure/OutboxEventRepository.java new file mode 100644 index 00000000..9cbad09b --- /dev/null +++ b/src/main/java/com/dnd/moddo/outbox/infrastructure/OutboxEventRepository.java @@ -0,0 +1,19 @@ +package com.dnd.moddo.outbox.infrastructure; + +import java.util.List; + +import org.springframework.data.jpa.repository.JpaRepository; + +import com.dnd.moddo.outbox.domain.event.OutboxEvent; +import com.dnd.moddo.outbox.domain.event.type.OutboxEventStatus; + +import jakarta.persistence.EntityNotFoundException; + +public interface OutboxEventRepository extends JpaRepository { + List findAllByStatus(OutboxEventStatus status); + + default OutboxEvent getById(Long id) { + return findById(id) + .orElseThrow(() -> new EntityNotFoundException("Outbox event not found: " + id)); + } +} diff --git a/src/main/java/com/dnd/moddo/outbox/presentation/EventTaskAdminController.java b/src/main/java/com/dnd/moddo/outbox/presentation/EventTaskAdminController.java new file mode 100644 index 00000000..adaa8e89 --- /dev/null +++ b/src/main/java/com/dnd/moddo/outbox/presentation/EventTaskAdminController.java @@ -0,0 +1,38 @@ +package com.dnd.moddo.outbox.presentation; + +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import com.dnd.moddo.auth.infrastructure.security.LoginUser; +import com.dnd.moddo.auth.model.exception.UserPermissionException; +import com.dnd.moddo.auth.presentation.response.LoginUserInfo; +import com.dnd.moddo.outbox.application.command.CommandEventTaskService; +import com.dnd.moddo.user.domain.Authority; + +import lombok.RequiredArgsConstructor; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/api/v1/admin/event-tasks") +public class EventTaskAdminController { + private final CommandEventTaskService commandEventTaskService; + + @PostMapping("/{eventTaskId}/retry") + public ResponseEntity retry( + @PathVariable Long eventTaskId, + @LoginUser LoginUserInfo loginUser + ) { + validateAdmin(loginUser); + commandEventTaskService.retry(eventTaskId); + return ResponseEntity.ok().build(); + } + + private void validateAdmin(LoginUserInfo loginUser) { + if (!Authority.ADMIN.name().equals(loginUser.role())) { + throw new UserPermissionException(); + } + } +} diff --git a/src/main/java/com/dnd/moddo/reward/application/CommandRewardService.java b/src/main/java/com/dnd/moddo/reward/application/CommandRewardService.java new file mode 100644 index 00000000..bdd288de --- /dev/null +++ b/src/main/java/com/dnd/moddo/reward/application/CommandRewardService.java @@ -0,0 +1,19 @@ +package com.dnd.moddo.reward.application; + +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import com.dnd.moddo.reward.application.impl.RewardGrantHandler; + +import lombok.RequiredArgsConstructor; + +@Service +@RequiredArgsConstructor +@Transactional +public class CommandRewardService { + private final RewardGrantHandler rewardGrantHandler; + + public void manualGrant(Long settlementId, Long targetUserId) { + rewardGrantHandler.handle(settlementId, targetUserId); + } +} diff --git a/src/main/java/com/dnd/moddo/reward/application/impl/RewardGrantHandler.java b/src/main/java/com/dnd/moddo/reward/application/impl/RewardGrantHandler.java new file mode 100644 index 00000000..fb37942c --- /dev/null +++ b/src/main/java/com/dnd/moddo/reward/application/impl/RewardGrantHandler.java @@ -0,0 +1,36 @@ +package com.dnd.moddo.reward.application.impl; + +import org.springframework.dao.DataIntegrityViolationException; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import com.dnd.moddo.reward.domain.character.Character; +import com.dnd.moddo.reward.domain.character.Collection; +import com.dnd.moddo.reward.domain.character.exception.SettlementCharacterNotFoundException; +import com.dnd.moddo.reward.infrastructure.CollectionRepository; +import com.dnd.moddo.reward.infrastructure.RewardQueryRepository; + +import lombok.RequiredArgsConstructor; + +@Service +@RequiredArgsConstructor +public class RewardGrantHandler { + private final RewardQueryRepository rewardQueryRepository; + private final CollectionRepository collectionRepository; + + @Transactional + public void handle(Long settlementId, Long targetUserId) { + Character character = rewardQueryRepository.findBySettlementId(settlementId) + .orElseThrow(() -> new SettlementCharacterNotFoundException(settlementId)); + + if (collectionRepository.existsByUserIdAndCharacterId(targetUserId, character.getId())) { + return; + } + + try { + collectionRepository.save(Collection.acquire(targetUserId, character.getId())); + } catch (DataIntegrityViolationException exception) { + // Concurrent/manual duplicate grants are treated as idempotent success. + } + } +} diff --git a/src/main/java/com/dnd/moddo/reward/domain/character/Collection.java b/src/main/java/com/dnd/moddo/reward/domain/character/Collection.java index 3f04a059..92ac5335 100644 --- a/src/main/java/com/dnd/moddo/reward/domain/character/Collection.java +++ b/src/main/java/com/dnd/moddo/reward/domain/character/Collection.java @@ -8,14 +8,21 @@ import jakarta.persistence.GenerationType; import jakarta.persistence.Id; import jakarta.persistence.Table; +import jakarta.persistence.UniqueConstraint; import lombok.AccessLevel; +import lombok.Builder; import lombok.Getter; import lombok.NoArgsConstructor; @Entity @Getter @NoArgsConstructor(access = AccessLevel.PROTECTED) -@Table(name = "collections") +@Table( + name = "collections", + uniqueConstraints = { + @UniqueConstraint(name = "uk_collections_user_character", columnNames = {"user_id", "character_id"}) + } +) public class Collection { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) @@ -24,8 +31,25 @@ public class Collection { @Column(nullable = false) private LocalDateTime acquiredAt; + @Column(name = "character_id", nullable = false) private Long characterId; + @Column(name = "user_id", nullable = false) private Long userId; + @Builder + private Collection(LocalDateTime acquiredAt, Long characterId, Long userId) { + this.acquiredAt = acquiredAt; + this.characterId = characterId; + this.userId = userId; + } + + public static Collection acquire(Long userId, Long characterId) { + return Collection.builder() + .userId(userId) + .characterId(characterId) + .acquiredAt(LocalDateTime.now()) + .build(); + } + } diff --git a/src/main/java/com/dnd/moddo/reward/domain/character/exception/SettlementCharacterNotFoundException.java b/src/main/java/com/dnd/moddo/reward/domain/character/exception/SettlementCharacterNotFoundException.java new file mode 100644 index 00000000..d09949a3 --- /dev/null +++ b/src/main/java/com/dnd/moddo/reward/domain/character/exception/SettlementCharacterNotFoundException.java @@ -0,0 +1,11 @@ +package com.dnd.moddo.reward.domain.character.exception; + +import org.springframework.http.HttpStatus; + +import com.dnd.moddo.common.exception.ModdoException; + +public class SettlementCharacterNotFoundException extends ModdoException { + public SettlementCharacterNotFoundException(Long settlementId) { + super(HttpStatus.INTERNAL_SERVER_ERROR, "정산에 연결된 캐릭터를 찾을 수 없습니다. settlementId=" + settlementId); + } +} diff --git a/src/main/java/com/dnd/moddo/reward/infrastructure/CollectionRepository.java b/src/main/java/com/dnd/moddo/reward/infrastructure/CollectionRepository.java index 98bea464..43f52b38 100644 --- a/src/main/java/com/dnd/moddo/reward/infrastructure/CollectionRepository.java +++ b/src/main/java/com/dnd/moddo/reward/infrastructure/CollectionRepository.java @@ -5,4 +5,5 @@ import com.dnd.moddo.reward.domain.character.Collection; public interface CollectionRepository extends JpaRepository { + boolean existsByUserIdAndCharacterId(Long userId, Long characterId); } diff --git a/src/main/java/com/dnd/moddo/reward/infrastructure/RewardQueryRepositoryImpl.java b/src/main/java/com/dnd/moddo/reward/infrastructure/RewardQueryRepositoryImpl.java index 23e83162..692d1960 100644 --- a/src/main/java/com/dnd/moddo/reward/infrastructure/RewardQueryRepositoryImpl.java +++ b/src/main/java/com/dnd/moddo/reward/infrastructure/RewardQueryRepositoryImpl.java @@ -65,10 +65,14 @@ public Optional findBySettlementId(Long settlementId) { QCharacter character = QCharacter.character; QSettlement settlement = QSettlement.settlement; - Character response = queryFactory.selectFrom(character) - .leftJoin(settlement).on(settlement.characterId.eq(character.id)) + Character response = queryFactory + .select(character) + .from(settlement) + .join(character).on(settlement.characterId.eq(character.id)) + .where(settlement.id.eq(settlementId)) .fetchOne(); return Optional.ofNullable(response); } + } diff --git a/src/main/java/com/dnd/moddo/reward/presentation/RewardAdminController.java b/src/main/java/com/dnd/moddo/reward/presentation/RewardAdminController.java new file mode 100644 index 00000000..76905602 --- /dev/null +++ b/src/main/java/com/dnd/moddo/reward/presentation/RewardAdminController.java @@ -0,0 +1,42 @@ +package com.dnd.moddo.reward.presentation; + +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import com.dnd.moddo.auth.infrastructure.security.LoginUser; +import com.dnd.moddo.auth.model.exception.UserPermissionException; +import com.dnd.moddo.auth.presentation.response.LoginUserInfo; +import com.dnd.moddo.event.application.query.QuerySettlementService; +import com.dnd.moddo.reward.application.CommandRewardService; +import com.dnd.moddo.user.domain.Authority; + +import lombok.RequiredArgsConstructor; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/api/v1/admin/rewards") +public class RewardAdminController { + private final CommandRewardService commandRewardService; + private final QuerySettlementService querySettlementService; + + @PostMapping("/groups/{code}/users/{userId}") + public ResponseEntity manualGrant( + @PathVariable String code, + @PathVariable Long userId, + @LoginUser LoginUserInfo loginUser + ) { + validateAdmin(loginUser); + Long settlementId = querySettlementService.findIdByCode(code); + commandRewardService.manualGrant(settlementId, userId); + return ResponseEntity.ok().build(); + } + + private void validateAdmin(LoginUserInfo loginUser) { + if (!Authority.ADMIN.name().equals(loginUser.role())) { + throw new UserPermissionException(); + } + } +} diff --git a/src/main/resources/config b/src/main/resources/config index 76a409ca..b94d1fea 160000 --- a/src/main/resources/config +++ b/src/main/resources/config @@ -1 +1 @@ -Subproject commit 76a409ca54e5acf352f138204a240a106da3681f +Subproject commit b94d1feafc0446a8c480c8a3c1f672254a6ad7fa From 13531ef177cf060f014ce37b137ba30700c8e446 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9B=90=EC=A7=80=EC=9C=A4?= Date: Sat, 14 Mar 2026 12:24:17 +0900 Subject: [PATCH 02/11] =?UTF-8?q?test:=20=EC=BA=90=EB=A6=AD=ED=84=B0=20?= =?UTF-8?q?=EC=A7=80=EA=B8=89=20=EB=B0=8F=20=EC=9E=AC=EC=8B=9C=EB=8F=84=20?= =?UTF-8?q?=EC=A0=84=EB=9E=B5=EC=97=90=20=EB=8C=80=ED=95=9C=20=ED=85=8C?= =?UTF-8?q?=EC=8A=A4=ED=8A=B8=20=EC=BD=94=EB=93=9C=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../service/CommandMemberServiceTest.java | 13 +-- .../EventTaskAdminControllerTest.java | 45 ++++++++++ .../domain/outbox/entity/EventTaskTest.java | 81 +++++++++++++++++ .../domain/outbox/entity/OutboxEventTest.java | 60 +++++++++++++ .../service/CommandEventTaskServiceTest.java | 31 +++++++ .../CommandOutboxEventServiceTest.java | 44 +++++++++ .../EventTaskProcessorTest.java | 88 ++++++++++++++++++ .../EventTaskSchedulerTest.java | 50 +++++++++++ .../OutboxEventCreatedEventListenerTest.java | 32 +++++++ .../OutboxEventCreatorTest.java | 60 +++++++++++++ .../OutboxEventPublisherTest.java | 57 ++++++++++++ .../OutboxEventTaskAppenderTest.java | 65 ++++++++++++++ .../service/CommandPaymentRequestTest.java | 15 ++-- .../PaymentRequestUpdaterTest.java | 18 ++-- .../controller/RewardAdminControllerTest.java | 58 ++++++++++++ .../service/CommandRewardServiceTest.java | 31 +++++++ .../RewardGrantHandlerTest.java | 90 +++++++++++++++++++ .../service/CommandSettlementServiceTest.java | 28 ++++-- .../SettlementCompletionProcessorTest.java | 72 +++++++++++++++ 19 files changed, 905 insertions(+), 33 deletions(-) create mode 100644 src/test/java/com/dnd/moddo/domain/outbox/controller/EventTaskAdminControllerTest.java create mode 100644 src/test/java/com/dnd/moddo/domain/outbox/entity/EventTaskTest.java create mode 100644 src/test/java/com/dnd/moddo/domain/outbox/entity/OutboxEventTest.java create mode 100644 src/test/java/com/dnd/moddo/domain/outbox/service/CommandEventTaskServiceTest.java create mode 100644 src/test/java/com/dnd/moddo/domain/outbox/service/CommandOutboxEventServiceTest.java create mode 100644 src/test/java/com/dnd/moddo/domain/outbox/service/implementation/EventTaskProcessorTest.java create mode 100644 src/test/java/com/dnd/moddo/domain/outbox/service/implementation/EventTaskSchedulerTest.java create mode 100644 src/test/java/com/dnd/moddo/domain/outbox/service/implementation/OutboxEventCreatedEventListenerTest.java create mode 100644 src/test/java/com/dnd/moddo/domain/outbox/service/implementation/OutboxEventCreatorTest.java create mode 100644 src/test/java/com/dnd/moddo/domain/outbox/service/implementation/OutboxEventPublisherTest.java create mode 100644 src/test/java/com/dnd/moddo/domain/outbox/service/implementation/OutboxEventTaskAppenderTest.java create mode 100644 src/test/java/com/dnd/moddo/domain/reward/controller/RewardAdminControllerTest.java create mode 100644 src/test/java/com/dnd/moddo/domain/reward/service/CommandRewardServiceTest.java create mode 100644 src/test/java/com/dnd/moddo/domain/reward/service/implementation/RewardGrantHandlerTest.java create mode 100644 src/test/java/com/dnd/moddo/domain/settlement/service/implementation/SettlementCompletionProcessorTest.java diff --git a/src/test/java/com/dnd/moddo/domain/Member/service/CommandMemberServiceTest.java b/src/test/java/com/dnd/moddo/domain/Member/service/CommandMemberServiceTest.java index 98ef257e..e26d0d3f 100644 --- a/src/test/java/com/dnd/moddo/domain/Member/service/CommandMemberServiceTest.java +++ b/src/test/java/com/dnd/moddo/domain/Member/service/CommandMemberServiceTest.java @@ -18,7 +18,7 @@ import com.dnd.moddo.event.application.impl.MemberCreator; import com.dnd.moddo.event.application.impl.MemberDeleter; import com.dnd.moddo.event.application.impl.MemberUpdater; -import com.dnd.moddo.event.application.query.QueryMemberService; +import com.dnd.moddo.event.application.impl.SettlementCompletionProcessor; import com.dnd.moddo.event.domain.member.ExpenseRole; import com.dnd.moddo.event.domain.member.Member; import com.dnd.moddo.event.domain.settlement.Settlement; @@ -38,7 +38,7 @@ public class CommandMemberServiceTest { @Mock private MemberDeleter memberDeleter; @Mock - private QueryMemberService queryMemberService; + private SettlementCompletionProcessor settlementCompletionProcessor; @InjectMocks private CommandMemberService commandMemberService; @@ -104,8 +104,6 @@ void whenValidInfo_thenAddAppointmentMemberSuccess() { @Test void whenAllMembersPaid_thenSettlementCompleted() { // given - Settlement mockSettlement = mock(Settlement.class); - Member paidMember = Member.builder() .name("김반숙") .settlement(mockSettlement) @@ -121,9 +119,6 @@ void whenAllMembersPaid_thenSettlementCompleted() { when(memberUpdater.updatePaymentStatus(any(), eq(request))) .thenReturn(paidMember); - when(queryMemberService.findAllBySettlementId(any())) - .thenReturn(List.of(paidMember)); - // when MemberResponse response = commandMemberService.updatePaymentStatus(1L, request); @@ -134,9 +129,7 @@ void whenAllMembersPaid_thenSettlementCompleted() { assertThat(response.isPaid()).isTrue(); verify(memberUpdater).updatePaymentStatus(any(), eq(request)); - verify(queryMemberService).findAllBySettlementId(any()); - - verify(mockSettlement).complete(); + verify(settlementCompletionProcessor).completeIfAllPaid(mockSettlement.getId()); } @DisplayName("로그인 사용자가 참여자를 선택할 수 있다.") diff --git a/src/test/java/com/dnd/moddo/domain/outbox/controller/EventTaskAdminControllerTest.java b/src/test/java/com/dnd/moddo/domain/outbox/controller/EventTaskAdminControllerTest.java new file mode 100644 index 00000000..640349a6 --- /dev/null +++ b/src/test/java/com/dnd/moddo/domain/outbox/controller/EventTaskAdminControllerTest.java @@ -0,0 +1,45 @@ +package com.dnd.moddo.domain.outbox.controller; + +import static org.assertj.core.api.Assertions.*; +import static org.mockito.Mockito.*; + +import org.junit.jupiter.api.DisplayName; +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.ResponseEntity; + +import com.dnd.moddo.auth.model.exception.UserPermissionException; +import com.dnd.moddo.auth.presentation.response.LoginUserInfo; +import com.dnd.moddo.outbox.application.command.CommandEventTaskService; +import com.dnd.moddo.outbox.presentation.EventTaskAdminController; + +@ExtendWith(MockitoExtension.class) +class EventTaskAdminControllerTest { + + @Mock + private CommandEventTaskService commandEventTaskService; + + @InjectMocks + private EventTaskAdminController eventTaskAdminController; + + @Test + @DisplayName("관리자는 이벤트 태스크 재시도를 요청할 수 있다.") + void retryByAdmin() { + ResponseEntity response = eventTaskAdminController.retry(1L, new LoginUserInfo(1L, "ADMIN")); + + assertThat(response.getStatusCode().is2xxSuccessful()).isTrue(); + verify(commandEventTaskService).retry(1L); + } + + @Test + @DisplayName("관리자가 아니면 이벤트 태스크 재시도를 요청할 수 없다.") + void retryForbiddenWhenNotAdmin() { + assertThatThrownBy(() -> eventTaskAdminController.retry(1L, new LoginUserInfo(1L, "USER"))) + .isInstanceOf(UserPermissionException.class); + + verify(commandEventTaskService, never()).retry(anyLong()); + } +} diff --git a/src/test/java/com/dnd/moddo/domain/outbox/entity/EventTaskTest.java b/src/test/java/com/dnd/moddo/domain/outbox/entity/EventTaskTest.java new file mode 100644 index 00000000..20bdaed6 --- /dev/null +++ b/src/test/java/com/dnd/moddo/domain/outbox/entity/EventTaskTest.java @@ -0,0 +1,81 @@ +package com.dnd.moddo.domain.outbox.entity; + +import static org.assertj.core.api.Assertions.*; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import com.dnd.moddo.outbox.domain.event.OutboxEvent; +import com.dnd.moddo.outbox.domain.event.type.AggregateType; +import com.dnd.moddo.outbox.domain.event.type.OutboxEventType; +import com.dnd.moddo.outbox.domain.task.EventTask; +import com.dnd.moddo.outbox.domain.task.type.EventTaskStatus; +import com.dnd.moddo.outbox.domain.task.type.EventTaskType; + +class EventTaskTest { + + @Test + @DisplayName("pending event task를 생성하면 기본 상태가 설정된다.") + void createPendingEventTask() { + OutboxEvent outboxEvent = OutboxEvent.pending( + OutboxEventType.SETTLEMENT_COMPLETED, + AggregateType.SETTLEMENT, + 1L + ); + + EventTask eventTask = EventTask.pending(outboxEvent, EventTaskType.REWARD_GRANT, 10L); + + assertThat(eventTask.getOutboxEvent()).isEqualTo(outboxEvent); + assertThat(eventTask.getTaskType()).isEqualTo(EventTaskType.REWARD_GRANT); + assertThat(eventTask.getTargetUserId()).isEqualTo(10L); + assertThat(eventTask.getStatus()).isEqualTo(EventTaskStatus.PENDING); + assertThat(eventTask.getAttemptCount()).isZero(); + assertThat(eventTask.getCreatedAt()).isNotNull(); + } + + @Test + @DisplayName("처리 중 상태로 변경할 수 있다.") + void markProcessing() { + EventTask eventTask = EventTask.pending( + OutboxEvent.pending(OutboxEventType.SETTLEMENT_COMPLETED, AggregateType.SETTLEMENT, 1L), + EventTaskType.REWARD_GRANT, + 10L + ); + + eventTask.markProcessing(); + + assertThat(eventTask.getStatus()).isEqualTo(EventTaskStatus.PROCESSING); + } + + @Test + @DisplayName("완료 처리되면 completed 상태와 처리 시각이 설정된다.") + void markCompleted() { + EventTask eventTask = EventTask.pending( + OutboxEvent.pending(OutboxEventType.SETTLEMENT_COMPLETED, AggregateType.SETTLEMENT, 1L), + EventTaskType.REWARD_GRANT, + 10L + ); + + eventTask.markCompleted(); + + assertThat(eventTask.getStatus()).isEqualTo(EventTaskStatus.COMPLETED); + assertThat(eventTask.getProcessedAt()).isNotNull(); + assertThat(eventTask.getLastErrorMessage()).isNull(); + } + + @Test + @DisplayName("실패 처리되면 failed 상태와 시도 횟수, 오류 메시지가 기록된다.") + void markFailed() { + EventTask eventTask = EventTask.pending( + OutboxEvent.pending(OutboxEventType.SETTLEMENT_COMPLETED, AggregateType.SETTLEMENT, 1L), + EventTaskType.REWARD_GRANT, + 10L + ); + + eventTask.markFailed("failed"); + + assertThat(eventTask.getStatus()).isEqualTo(EventTaskStatus.FAILED); + assertThat(eventTask.getAttemptCount()).isEqualTo(1); + assertThat(eventTask.getLastErrorMessage()).isEqualTo("failed"); + } +} diff --git a/src/test/java/com/dnd/moddo/domain/outbox/entity/OutboxEventTest.java b/src/test/java/com/dnd/moddo/domain/outbox/entity/OutboxEventTest.java new file mode 100644 index 00000000..a8ad3bb6 --- /dev/null +++ b/src/test/java/com/dnd/moddo/domain/outbox/entity/OutboxEventTest.java @@ -0,0 +1,60 @@ +package com.dnd.moddo.domain.outbox.entity; + +import static org.assertj.core.api.Assertions.*; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import com.dnd.moddo.outbox.domain.event.OutboxEvent; +import com.dnd.moddo.outbox.domain.event.type.AggregateType; +import com.dnd.moddo.outbox.domain.event.type.OutboxEventStatus; +import com.dnd.moddo.outbox.domain.event.type.OutboxEventType; + +class OutboxEventTest { + + @Test + @DisplayName("pending outbox event를 생성하면 기본 상태가 설정된다.") + void createPendingOutboxEvent() { + OutboxEvent outboxEvent = OutboxEvent.pending( + OutboxEventType.SETTLEMENT_COMPLETED, + AggregateType.SETTLEMENT, + 1L + ); + + assertThat(outboxEvent.getEventType()).isEqualTo(OutboxEventType.SETTLEMENT_COMPLETED); + assertThat(outboxEvent.getAggregateType()).isEqualTo(AggregateType.SETTLEMENT); + assertThat(outboxEvent.getAggregateId()).isEqualTo(1L); + assertThat(outboxEvent.getStatus()).isEqualTo(OutboxEventStatus.PENDING); + assertThat(outboxEvent.getCreatedAt()).isNotNull(); + assertThat(outboxEvent.getPublishedAt()).isNull(); + } + + @Test + @DisplayName("publish 처리되면 published 상태와 시각이 설정된다.") + void markPublished() { + OutboxEvent outboxEvent = OutboxEvent.pending( + OutboxEventType.SETTLEMENT_COMPLETED, + AggregateType.SETTLEMENT, + 1L + ); + + outboxEvent.markPublished(); + + assertThat(outboxEvent.getStatus()).isEqualTo(OutboxEventStatus.PUBLISHED); + assertThat(outboxEvent.getPublishedAt()).isNotNull(); + } + + @Test + @DisplayName("실패 처리되면 failed 상태가 된다.") + void markFailed() { + OutboxEvent outboxEvent = OutboxEvent.pending( + OutboxEventType.SETTLEMENT_COMPLETED, + AggregateType.SETTLEMENT, + 1L + ); + + outboxEvent.markFailed(); + + assertThat(outboxEvent.getStatus()).isEqualTo(OutboxEventStatus.FAILED); + } +} diff --git a/src/test/java/com/dnd/moddo/domain/outbox/service/CommandEventTaskServiceTest.java b/src/test/java/com/dnd/moddo/domain/outbox/service/CommandEventTaskServiceTest.java new file mode 100644 index 00000000..a0ffd1c6 --- /dev/null +++ b/src/test/java/com/dnd/moddo/domain/outbox/service/CommandEventTaskServiceTest.java @@ -0,0 +1,31 @@ +package com.dnd.moddo.domain.outbox.service; + +import static org.mockito.Mockito.*; + +import org.junit.jupiter.api.DisplayName; +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 com.dnd.moddo.outbox.application.command.CommandEventTaskService; +import com.dnd.moddo.outbox.application.impl.EventTaskProcessor; + +@ExtendWith(MockitoExtension.class) +class CommandEventTaskServiceTest { + + @Mock + private EventTaskProcessor eventTaskProcessor; + + @InjectMocks + private CommandEventTaskService commandEventTaskService; + + @Test + @DisplayName("이벤트 태스크 재처리를 요청할 수 있다.") + void retry() { + commandEventTaskService.retry(1L); + + verify(eventTaskProcessor).process(1L); + } +} diff --git a/src/test/java/com/dnd/moddo/domain/outbox/service/CommandOutboxEventServiceTest.java b/src/test/java/com/dnd/moddo/domain/outbox/service/CommandOutboxEventServiceTest.java new file mode 100644 index 00000000..7f9ea153 --- /dev/null +++ b/src/test/java/com/dnd/moddo/domain/outbox/service/CommandOutboxEventServiceTest.java @@ -0,0 +1,44 @@ +package com.dnd.moddo.domain.outbox.service; + +import static org.assertj.core.api.Assertions.*; +import static org.mockito.Mockito.*; + +import org.junit.jupiter.api.DisplayName; +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 com.dnd.moddo.outbox.application.command.CommandOutboxEventService; +import com.dnd.moddo.outbox.application.impl.OutboxEventCreator; +import com.dnd.moddo.outbox.domain.event.OutboxEvent; +import com.dnd.moddo.outbox.domain.event.type.AggregateType; +import com.dnd.moddo.outbox.domain.event.type.OutboxEventType; + +@ExtendWith(MockitoExtension.class) +class CommandOutboxEventServiceTest { + + @Mock + private OutboxEventCreator outboxEventCreator; + + @InjectMocks + private CommandOutboxEventService commandOutboxEventService; + + @Test + @DisplayName("아웃박스 이벤트 생성을 위임한다.") + void create() { + OutboxEvent outboxEvent = mock(OutboxEvent.class); + when(outboxEventCreator.create(OutboxEventType.SETTLEMENT_COMPLETED, AggregateType.SETTLEMENT, 1L)) + .thenReturn(outboxEvent); + + OutboxEvent result = commandOutboxEventService.create( + OutboxEventType.SETTLEMENT_COMPLETED, + AggregateType.SETTLEMENT, + 1L + ); + + assertThat(result).isEqualTo(outboxEvent); + verify(outboxEventCreator).create(OutboxEventType.SETTLEMENT_COMPLETED, AggregateType.SETTLEMENT, 1L); + } +} diff --git a/src/test/java/com/dnd/moddo/domain/outbox/service/implementation/EventTaskProcessorTest.java b/src/test/java/com/dnd/moddo/domain/outbox/service/implementation/EventTaskProcessorTest.java new file mode 100644 index 00000000..bdd91fd8 --- /dev/null +++ b/src/test/java/com/dnd/moddo/domain/outbox/service/implementation/EventTaskProcessorTest.java @@ -0,0 +1,88 @@ +package com.dnd.moddo.domain.outbox.service.implementation; + +import static org.mockito.Mockito.*; + +import org.junit.jupiter.api.DisplayName; +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 com.dnd.moddo.outbox.application.impl.EventTaskFailureNotifier; +import com.dnd.moddo.outbox.application.impl.EventTaskProcessor; +import com.dnd.moddo.outbox.domain.event.OutboxEvent; +import com.dnd.moddo.outbox.domain.task.EventTask; +import com.dnd.moddo.outbox.domain.task.type.EventTaskStatus; +import com.dnd.moddo.outbox.domain.task.type.EventTaskType; +import com.dnd.moddo.outbox.infrastructure.EventTaskRepository; +import com.dnd.moddo.reward.application.impl.RewardGrantHandler; + +@ExtendWith(MockitoExtension.class) +class EventTaskProcessorTest { + + @Mock + private EventTaskRepository eventTaskRepository; + + @Mock + private RewardGrantHandler rewardGrantHandler; + + @Mock + private EventTaskFailureNotifier eventTaskFailureNotifier; + + @InjectMocks + private EventTaskProcessor eventTaskProcessor; + + @Test + @DisplayName("완료된 태스크는 다시 처리하지 않는다.") + void skipCompletedTask() { + EventTask eventTask = mock(EventTask.class); + when(eventTaskRepository.getById(1L)).thenReturn(eventTask); + when(eventTask.getStatus()).thenReturn(EventTaskStatus.COMPLETED); + + eventTaskProcessor.process(1L); + + verify(eventTask, never()).markProcessing(); + verifyNoInteractions(rewardGrantHandler); + } + + @Test + @DisplayName("REWARD_GRANT 태스크를 성공적으로 처리하면 완료 처리한다.") + void processRewardGrantTask() { + EventTask eventTask = mock(EventTask.class); + OutboxEvent outboxEvent = mock(OutboxEvent.class); + when(eventTaskRepository.getById(1L)).thenReturn(eventTask); + when(eventTask.getStatus()).thenReturn(EventTaskStatus.PENDING); + when(eventTask.getTaskType()).thenReturn(EventTaskType.REWARD_GRANT); + when(eventTask.getOutboxEvent()).thenReturn(outboxEvent); + when(outboxEvent.getAggregateId()).thenReturn(10L); + when(eventTask.getTargetUserId()).thenReturn(20L); + + eventTaskProcessor.process(1L); + + verify(eventTask).markProcessing(); + verify(rewardGrantHandler).handle(10L, 20L); + verify(eventTask).markCompleted(); + verify(eventTask, never()).markFailed(anyString()); + } + + @Test + @DisplayName("최대 재시도에 도달한 실패 태스크는 운영 알림을 보낸다.") + void notifyWhenRetryExhausted() { + EventTask eventTask = mock(EventTask.class); + OutboxEvent outboxEvent = mock(OutboxEvent.class); + when(eventTaskRepository.getById(1L)).thenReturn(eventTask); + when(eventTask.getStatus()).thenReturn(EventTaskStatus.PENDING); + when(eventTask.getTaskType()).thenReturn(EventTaskType.REWARD_GRANT); + when(eventTask.getOutboxEvent()).thenReturn(outboxEvent); + when(outboxEvent.getAggregateId()).thenReturn(10L); + when(eventTask.getTargetUserId()).thenReturn(20L); + doThrow(new RuntimeException("grant failed")).when(rewardGrantHandler).handle(10L, 20L); + when(eventTask.getAttemptCount()).thenReturn(5); + + eventTaskProcessor.process(1L); + + verify(eventTask).markFailed("grant failed"); + verify(eventTaskFailureNotifier).notifyRetryExhausted(eventTask); + } +} diff --git a/src/test/java/com/dnd/moddo/domain/outbox/service/implementation/EventTaskSchedulerTest.java b/src/test/java/com/dnd/moddo/domain/outbox/service/implementation/EventTaskSchedulerTest.java new file mode 100644 index 00000000..f4c5130c --- /dev/null +++ b/src/test/java/com/dnd/moddo/domain/outbox/service/implementation/EventTaskSchedulerTest.java @@ -0,0 +1,50 @@ +package com.dnd.moddo.domain.outbox.service.implementation; + +import static org.mockito.Mockito.*; + +import java.util.List; + +import org.junit.jupiter.api.DisplayName; +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 com.dnd.moddo.outbox.application.impl.EventTaskRetryPolicy; +import com.dnd.moddo.outbox.application.impl.EventTaskProcessor; +import com.dnd.moddo.outbox.application.impl.EventTaskScheduler; +import com.dnd.moddo.outbox.domain.task.EventTask; +import com.dnd.moddo.outbox.domain.task.type.EventTaskStatus; +import com.dnd.moddo.outbox.infrastructure.EventTaskRepository; + +@ExtendWith(MockitoExtension.class) +class EventTaskSchedulerTest { + + @Mock + private EventTaskRepository eventTaskRepository; + + @Mock + private EventTaskProcessor eventTaskProcessor; + + @InjectMocks + private EventTaskScheduler eventTaskScheduler; + + @Test + @DisplayName("스케줄러는 처리 대상 태스크를 최대 30개 조회해 순서대로 처리한다.") + void processPendingTasks() { + EventTask first = mock(EventTask.class); + EventTask second = mock(EventTask.class); + when(first.getId()).thenReturn(1L); + when(second.getId()).thenReturn(2L); + when(eventTaskRepository.findTop30ByStatusInAndAttemptCountLessThanOrderByCreatedAtAsc( + List.of(EventTaskStatus.PENDING, EventTaskStatus.FAILED), + EventTaskRetryPolicy.MAX_RETRY_COUNT + )).thenReturn(List.of(first, second)); + + eventTaskScheduler.processPendingTasks(); + + verify(eventTaskProcessor).process(1L); + verify(eventTaskProcessor).process(2L); + } +} diff --git a/src/test/java/com/dnd/moddo/domain/outbox/service/implementation/OutboxEventCreatedEventListenerTest.java b/src/test/java/com/dnd/moddo/domain/outbox/service/implementation/OutboxEventCreatedEventListenerTest.java new file mode 100644 index 00000000..2efbeee9 --- /dev/null +++ b/src/test/java/com/dnd/moddo/domain/outbox/service/implementation/OutboxEventCreatedEventListenerTest.java @@ -0,0 +1,32 @@ +package com.dnd.moddo.domain.outbox.service.implementation; + +import static org.mockito.Mockito.*; + +import org.junit.jupiter.api.DisplayName; +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 com.dnd.moddo.outbox.application.event.OutboxEventCreatedEvent; +import com.dnd.moddo.outbox.application.event.OutboxEventCreatedEventListener; +import com.dnd.moddo.outbox.application.impl.OutboxEventPublisher; + +@ExtendWith(MockitoExtension.class) +class OutboxEventCreatedEventListenerTest { + + @Mock + private OutboxEventPublisher outboxEventPublisher; + + @InjectMocks + private OutboxEventCreatedEventListener listener; + + @Test + @DisplayName("아웃박스 생성 이벤트를 받으면 publish를 호출한다.") + void handle() { + listener.handle(new OutboxEventCreatedEvent(1L)); + + verify(outboxEventPublisher).publish(1L); + } +} diff --git a/src/test/java/com/dnd/moddo/domain/outbox/service/implementation/OutboxEventCreatorTest.java b/src/test/java/com/dnd/moddo/domain/outbox/service/implementation/OutboxEventCreatorTest.java new file mode 100644 index 00000000..25f5b629 --- /dev/null +++ b/src/test/java/com/dnd/moddo/domain/outbox/service/implementation/OutboxEventCreatorTest.java @@ -0,0 +1,60 @@ +package com.dnd.moddo.domain.outbox.service.implementation; + +import static org.assertj.core.api.Assertions.*; +import static org.mockito.Mockito.*; + +import org.junit.jupiter.api.DisplayName; +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.context.ApplicationEventPublisher; + +import com.dnd.moddo.outbox.application.event.OutboxEventCreatedEvent; +import com.dnd.moddo.outbox.application.impl.OutboxEventCreator; +import com.dnd.moddo.outbox.domain.event.OutboxEvent; +import com.dnd.moddo.outbox.domain.event.type.AggregateType; +import com.dnd.moddo.outbox.domain.event.type.OutboxEventType; +import com.dnd.moddo.outbox.infrastructure.OutboxEventRepository; + +@ExtendWith(MockitoExtension.class) +class OutboxEventCreatorTest { + + @Mock + private OutboxEventRepository outboxEventRepository; + + @Mock + private ApplicationEventPublisher eventPublisher; + + @InjectMocks + private OutboxEventCreator outboxEventCreator; + + @Test + @DisplayName("아웃박스 이벤트를 저장하고 생성 이벤트를 발행한다.") + void create() { + OutboxEvent savedEvent = OutboxEvent.pending(OutboxEventType.SETTLEMENT_COMPLETED, AggregateType.SETTLEMENT, 1L); + setOutboxEventId(savedEvent, 10L); + when(outboxEventRepository.save(any(OutboxEvent.class))).thenReturn(savedEvent); + + OutboxEvent result = outboxEventCreator.create(OutboxEventType.SETTLEMENT_COMPLETED, AggregateType.SETTLEMENT, 1L); + + assertThat(result).isEqualTo(savedEvent); + + ArgumentCaptor captor = ArgumentCaptor.forClass(OutboxEvent.class); + verify(outboxEventRepository).save(captor.capture()); + assertThat(captor.getValue().getEventType()).isEqualTo(OutboxEventType.SETTLEMENT_COMPLETED); + verify(eventPublisher).publishEvent(new OutboxEventCreatedEvent(10L)); + } + + private void setOutboxEventId(OutboxEvent outboxEvent, Long id) { + try { + java.lang.reflect.Field idField = OutboxEvent.class.getDeclaredField("id"); + idField.setAccessible(true); + idField.set(outboxEvent, id); + } catch (ReflectiveOperationException exception) { + throw new RuntimeException(exception); + } + } +} diff --git a/src/test/java/com/dnd/moddo/domain/outbox/service/implementation/OutboxEventPublisherTest.java b/src/test/java/com/dnd/moddo/domain/outbox/service/implementation/OutboxEventPublisherTest.java new file mode 100644 index 00000000..96fa46dc --- /dev/null +++ b/src/test/java/com/dnd/moddo/domain/outbox/service/implementation/OutboxEventPublisherTest.java @@ -0,0 +1,57 @@ +package com.dnd.moddo.domain.outbox.service.implementation; + +import static org.mockito.Mockito.*; + +import org.junit.jupiter.api.DisplayName; +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 com.dnd.moddo.outbox.application.impl.OutboxEventPublisher; +import com.dnd.moddo.outbox.application.impl.OutboxEventTaskAppender; +import com.dnd.moddo.outbox.domain.event.OutboxEvent; +import com.dnd.moddo.outbox.domain.event.type.OutboxEventStatus; +import com.dnd.moddo.outbox.infrastructure.OutboxEventRepository; + +@ExtendWith(MockitoExtension.class) +class OutboxEventPublisherTest { + + @Mock + private OutboxEventRepository outboxEventRepository; + + @Mock + private OutboxEventTaskAppender outboxEventTaskAppender; + + @InjectMocks + private OutboxEventPublisher outboxEventPublisher; + + @Test + @DisplayName("PENDING 아웃박스 이벤트를 publish하면 태스크를 추가하고 published 상태로 변경한다.") + void publishPendingOutboxEvent() { + OutboxEvent outboxEvent = mock(OutboxEvent.class); + when(outboxEventRepository.getById(1L)).thenReturn(outboxEvent); + when(outboxEvent.getStatus()).thenReturn(OutboxEventStatus.PENDING); + + outboxEventPublisher.publish(1L); + + verify(outboxEventTaskAppender).appendTasks(outboxEvent); + verify(outboxEvent).markPublished(); + verify(outboxEvent, never()).markFailed(); + } + + @Test + @DisplayName("태스크 추가 중 예외가 발생하면 failed 상태로 변경한다.") + void markFailedWhenAppendTaskThrowsException() { + OutboxEvent outboxEvent = mock(OutboxEvent.class); + when(outboxEventRepository.getById(1L)).thenReturn(outboxEvent); + when(outboxEvent.getStatus()).thenReturn(OutboxEventStatus.PENDING); + doThrow(new RuntimeException("append failed")).when(outboxEventTaskAppender).appendTasks(outboxEvent); + + outboxEventPublisher.publish(1L); + + verify(outboxEvent).markFailed(); + verify(outboxEvent, never()).markPublished(); + } +} diff --git a/src/test/java/com/dnd/moddo/domain/outbox/service/implementation/OutboxEventTaskAppenderTest.java b/src/test/java/com/dnd/moddo/domain/outbox/service/implementation/OutboxEventTaskAppenderTest.java new file mode 100644 index 00000000..c8ce9e78 --- /dev/null +++ b/src/test/java/com/dnd/moddo/domain/outbox/service/implementation/OutboxEventTaskAppenderTest.java @@ -0,0 +1,65 @@ +package com.dnd.moddo.domain.outbox.service.implementation; + +import static org.mockito.Mockito.*; + +import java.util.List; + +import org.junit.jupiter.api.DisplayName; +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 com.dnd.moddo.event.application.impl.MemberReader; +import com.dnd.moddo.event.domain.member.Member; +import com.dnd.moddo.outbox.application.impl.OutboxEventTaskAppender; +import com.dnd.moddo.outbox.domain.event.OutboxEvent; +import com.dnd.moddo.outbox.domain.task.EventTask; +import com.dnd.moddo.outbox.domain.event.type.AggregateType; +import com.dnd.moddo.outbox.domain.event.type.OutboxEventType; +import com.dnd.moddo.outbox.domain.task.type.EventTaskType; +import com.dnd.moddo.outbox.infrastructure.EventTaskRepository; + +@ExtendWith(MockitoExtension.class) +class OutboxEventTaskAppenderTest { + + @Mock + private EventTaskRepository eventTaskRepository; + + @Mock + private MemberReader memberReader; + + @InjectMocks + private OutboxEventTaskAppender outboxEventTaskAppender; + + @Test + @DisplayName("정산 완료 이벤트면 연결된 멤버마다 REWARD_GRANT와 NOTIFICATION_SEND 태스크를 생성한다.") + void appendSettlementCompletedTasks() { + OutboxEvent outboxEvent = OutboxEvent.pending(OutboxEventType.SETTLEMENT_COMPLETED, AggregateType.SETTLEMENT, 1L); + Member firstMember = mock(Member.class); + Member secondMember = mock(Member.class); + + when(firstMember.getUserId()).thenReturn(10L); + when(secondMember.getUserId()).thenReturn(20L); + when(memberReader.findAssignedMembersBySettlementId(1L)).thenReturn(List.of(firstMember, secondMember)); + + outboxEventTaskAppender.appendTasks(outboxEvent); + + ArgumentCaptor captor = ArgumentCaptor.forClass(EventTask.class); + verify(eventTaskRepository, times(4)).saveAndFlush(captor.capture()); + + List savedTasks = captor.getAllValues(); + verifySavedTask(savedTasks.get(0), outboxEvent, EventTaskType.REWARD_GRANT, 10L); + verifySavedTask(savedTasks.get(1), outboxEvent, EventTaskType.NOTIFICATION_SEND, 10L); + verifySavedTask(savedTasks.get(2), outboxEvent, EventTaskType.REWARD_GRANT, 20L); + verifySavedTask(savedTasks.get(3), outboxEvent, EventTaskType.NOTIFICATION_SEND, 20L); + } + + private void verifySavedTask(EventTask eventTask, OutboxEvent outboxEvent, EventTaskType taskType, Long targetUserId) { + org.assertj.core.api.Assertions.assertThat(eventTask.getOutboxEvent()).isEqualTo(outboxEvent); + org.assertj.core.api.Assertions.assertThat(eventTask.getTaskType()).isEqualTo(taskType); + org.assertj.core.api.Assertions.assertThat(eventTask.getTargetUserId()).isEqualTo(targetUserId); + } +} diff --git a/src/test/java/com/dnd/moddo/domain/paymentRequest/service/CommandPaymentRequestTest.java b/src/test/java/com/dnd/moddo/domain/paymentRequest/service/CommandPaymentRequestTest.java index 15745d65..c9212ff2 100644 --- a/src/test/java/com/dnd/moddo/domain/paymentRequest/service/CommandPaymentRequestTest.java +++ b/src/test/java/com/dnd/moddo/domain/paymentRequest/service/CommandPaymentRequestTest.java @@ -13,6 +13,7 @@ import com.dnd.moddo.event.application.command.CommandPaymentRequest; import com.dnd.moddo.event.application.impl.PaymentRequestCreator; import com.dnd.moddo.event.application.impl.PaymentRequestUpdater; +import com.dnd.moddo.event.application.impl.SettlementCompletionProcessor; import com.dnd.moddo.event.domain.paymentRequest.PaymentRequest; import com.dnd.moddo.event.domain.paymentRequest.PaymentRequestStatus; import com.dnd.moddo.event.presentation.response.PaymentRequestResponse; @@ -26,6 +27,9 @@ class CommandPaymentRequestTest { @Mock private PaymentRequestUpdater paymentRequestUpdater; + @Mock + private SettlementCompletionProcessor settlementCompletionProcessor; + @InjectMocks private CommandPaymentRequest commandPaymentRequest; @@ -47,13 +51,14 @@ void createPaymentRequest() { void approvePaymentRequest() { PaymentRequest paymentRequest = mock(PaymentRequest.class); stubPaymentRequest(paymentRequest, PaymentRequestStatus.APPROVED); - when(paymentRequestUpdater.approvePaymentRequest(1L, 2L)).thenReturn(paymentRequest); + when(paymentRequestUpdater.approvePaymentRequest(1L, 2L)).thenReturn(paymentRequest); - PaymentRequestResponse response = commandPaymentRequest.approvePaymentRequest(1L, 2L); + PaymentRequestResponse response = commandPaymentRequest.approvePaymentRequest(1L, 2L); - assertThat(response.id()).isEqualTo(1L); - assertThat(response.status()).isEqualTo(PaymentRequestStatus.APPROVED); - } + assertThat(response.id()).isEqualTo(1L); + assertThat(response.status()).isEqualTo(PaymentRequestStatus.APPROVED); + verify(settlementCompletionProcessor).completeIfAllPaid(2L); + } @Test @DisplayName("입금 확인 요청을 거절할 수 있다.") diff --git a/src/test/java/com/dnd/moddo/domain/paymentRequest/service/implementation/PaymentRequestUpdaterTest.java b/src/test/java/com/dnd/moddo/domain/paymentRequest/service/implementation/PaymentRequestUpdaterTest.java index 6c0e6054..da5b00f4 100644 --- a/src/test/java/com/dnd/moddo/domain/paymentRequest/service/implementation/PaymentRequestUpdaterTest.java +++ b/src/test/java/com/dnd/moddo/domain/paymentRequest/service/implementation/PaymentRequestUpdaterTest.java @@ -36,18 +36,18 @@ class PaymentRequestUpdaterTest { void approvePaymentRequestSuccess() { Long paymentRequestId = 1L; Long userId = 100L; - PaymentRequest paymentRequest = mock(PaymentRequest.class); + PaymentRequest paymentRequest = mock(PaymentRequest.class); - when(paymentRequestRepository.getById(paymentRequestId)).thenReturn(paymentRequest); - when(paymentRequest.getRequestMemberId()).thenReturn(10L); + when(paymentRequestRepository.getById(paymentRequestId)).thenReturn(paymentRequest); + when(paymentRequest.getRequestMemberId()).thenReturn(10L); - PaymentRequest result = paymentRequestUpdater.approvePaymentRequest(paymentRequestId, userId); + PaymentRequest result = paymentRequestUpdater.approvePaymentRequest(paymentRequestId, userId); - assertThat(result).isEqualTo(paymentRequest); - verify(paymentRequestValidator).validateProcessRequest(paymentRequest, userId); - verify(memberUpdater).updatePaymentStatus(10L, true); - verify(paymentRequest).approve(); - } + assertThat(result).isEqualTo(paymentRequest); + verify(paymentRequestValidator).validateProcessRequest(paymentRequest, userId); + verify(memberUpdater).updatePaymentStatus(10L, true); + verify(paymentRequest).approve(); + } @Test @DisplayName("입금 확인 요청을 거절할 수 있다.") diff --git a/src/test/java/com/dnd/moddo/domain/reward/controller/RewardAdminControllerTest.java b/src/test/java/com/dnd/moddo/domain/reward/controller/RewardAdminControllerTest.java new file mode 100644 index 00000000..c4671153 --- /dev/null +++ b/src/test/java/com/dnd/moddo/domain/reward/controller/RewardAdminControllerTest.java @@ -0,0 +1,58 @@ +package com.dnd.moddo.domain.reward.controller; + +import static org.assertj.core.api.Assertions.*; +import static org.mockito.Mockito.*; + +import org.junit.jupiter.api.DisplayName; +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.ResponseEntity; + +import com.dnd.moddo.auth.model.exception.UserPermissionException; +import com.dnd.moddo.auth.presentation.response.LoginUserInfo; +import com.dnd.moddo.event.application.query.QuerySettlementService; +import com.dnd.moddo.reward.application.CommandRewardService; +import com.dnd.moddo.reward.presentation.RewardAdminController; + +@ExtendWith(MockitoExtension.class) +class RewardAdminControllerTest { + + @Mock + private CommandRewardService commandRewardService; + + @Mock + private QuerySettlementService querySettlementService; + + @InjectMocks + private RewardAdminController rewardAdminController; + + @Test + @DisplayName("관리자는 수동 보상 지급을 요청할 수 있다.") + void manualGrantByAdmin() { + when(querySettlementService.findIdByCode("group-code")).thenReturn(10L); + + ResponseEntity response = rewardAdminController.manualGrant( + "group-code", + 2L, + new LoginUserInfo(1L, "ADMIN") + ); + + assertThat(response.getStatusCode().is2xxSuccessful()).isTrue(); + verify(commandRewardService).manualGrant(10L, 2L); + } + + @Test + @DisplayName("관리자가 아니면 수동 보상 지급을 요청할 수 없다.") + void manualGrantForbiddenWhenNotAdmin() { + assertThatThrownBy(() -> rewardAdminController.manualGrant( + "group-code", + 2L, + new LoginUserInfo(1L, "USER") + )).isInstanceOf(UserPermissionException.class); + + verify(commandRewardService, never()).manualGrant(anyLong(), anyLong()); + } +} diff --git a/src/test/java/com/dnd/moddo/domain/reward/service/CommandRewardServiceTest.java b/src/test/java/com/dnd/moddo/domain/reward/service/CommandRewardServiceTest.java new file mode 100644 index 00000000..7b7423a6 --- /dev/null +++ b/src/test/java/com/dnd/moddo/domain/reward/service/CommandRewardServiceTest.java @@ -0,0 +1,31 @@ +package com.dnd.moddo.domain.reward.service; + +import static org.mockito.Mockito.*; + +import org.junit.jupiter.api.DisplayName; +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 com.dnd.moddo.reward.application.CommandRewardService; +import com.dnd.moddo.reward.application.impl.RewardGrantHandler; + +@ExtendWith(MockitoExtension.class) +class CommandRewardServiceTest { + + @Mock + private RewardGrantHandler rewardGrantHandler; + + @InjectMocks + private CommandRewardService commandRewardService; + + @Test + @DisplayName("수동 보상 지급을 위임한다.") + void manualGrant() { + commandRewardService.manualGrant(1L, 2L); + + verify(rewardGrantHandler).handle(1L, 2L); + } +} diff --git a/src/test/java/com/dnd/moddo/domain/reward/service/implementation/RewardGrantHandlerTest.java b/src/test/java/com/dnd/moddo/domain/reward/service/implementation/RewardGrantHandlerTest.java new file mode 100644 index 00000000..cef03ec2 --- /dev/null +++ b/src/test/java/com/dnd/moddo/domain/reward/service/implementation/RewardGrantHandlerTest.java @@ -0,0 +1,90 @@ +package com.dnd.moddo.domain.reward.service.implementation; + +import static org.assertj.core.api.Assertions.*; +import static org.mockito.Mockito.*; + +import java.util.Optional; + +import org.junit.jupiter.api.DisplayName; +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.dao.DataIntegrityViolationException; + +import com.dnd.moddo.reward.application.impl.RewardGrantHandler; +import com.dnd.moddo.reward.domain.character.Character; +import com.dnd.moddo.reward.domain.character.exception.SettlementCharacterNotFoundException; +import com.dnd.moddo.reward.infrastructure.CollectionRepository; +import com.dnd.moddo.reward.infrastructure.RewardQueryRepository; + +@ExtendWith(MockitoExtension.class) +class RewardGrantHandlerTest { + + @Mock + private RewardQueryRepository rewardQueryRepository; + + @Mock + private CollectionRepository collectionRepository; + + @InjectMocks + private RewardGrantHandler rewardGrantHandler; + + @Test + @DisplayName("정산에 연결된 캐릭터가 없으면 예외가 발생한다.") + void throwExceptionWhenSettlementCharacterNotFound() { + when(rewardQueryRepository.findBySettlementId(1L)).thenReturn(Optional.empty()); + + assertThatThrownBy(() -> rewardGrantHandler.handle(1L, 2L)) + .isInstanceOf(SettlementCharacterNotFoundException.class); + } + + @Test + @DisplayName("이미 같은 캐릭터를 보유 중이면 저장하지 않는다.") + void doNotSaveWhenCharacterAlreadyGranted() { + Character character = Character.builder() + .name("모또") + .rarity(1) + .imageUrl("image") + .imageBigUrl("image-big") + .build(); + setCharacterId(character, 3L); + + when(rewardQueryRepository.findBySettlementId(1L)).thenReturn(Optional.of(character)); + when(collectionRepository.existsByUserIdAndCharacterId(2L, 3L)).thenReturn(true); + + rewardGrantHandler.handle(1L, 2L); + + verify(collectionRepository, never()).save(any()); + } + + @Test + @DisplayName("중복 지급으로 인한 제약조건 예외는 멱등 성공으로 처리한다.") + void swallowDuplicateGrantException() { + Character character = Character.builder() + .name("모또") + .rarity(1) + .imageUrl("image") + .imageBigUrl("image-big") + .build(); + setCharacterId(character, 3L); + + when(rewardQueryRepository.findBySettlementId(1L)).thenReturn(Optional.of(character)); + when(collectionRepository.existsByUserIdAndCharacterId(2L, 3L)).thenReturn(false); + doThrow(new DataIntegrityViolationException("duplicate")).when(collectionRepository).save(any()); + + assertThatCode(() -> rewardGrantHandler.handle(1L, 2L)) + .doesNotThrowAnyException(); + } + + private void setCharacterId(Character character, Long id) { + try { + java.lang.reflect.Field idField = Character.class.getDeclaredField("id"); + idField.setAccessible(true); + idField.set(character, id); + } catch (ReflectiveOperationException exception) { + throw new RuntimeException(exception); + } + } +} diff --git a/src/test/java/com/dnd/moddo/domain/settlement/service/CommandSettlementServiceTest.java b/src/test/java/com/dnd/moddo/domain/settlement/service/CommandSettlementServiceTest.java index 00809e58..dc3eca48 100644 --- a/src/test/java/com/dnd/moddo/domain/settlement/service/CommandSettlementServiceTest.java +++ b/src/test/java/com/dnd/moddo/domain/settlement/service/CommandSettlementServiceTest.java @@ -15,12 +15,14 @@ import org.mockito.junit.jupiter.MockitoExtension; import com.dnd.moddo.auth.infrastructure.security.JwtProvider; -import com.dnd.moddo.event.application.command.CommandMemberService; import com.dnd.moddo.event.application.command.CommandSettlementService; +import com.dnd.moddo.event.application.impl.MemberCreator; +import com.dnd.moddo.event.application.impl.MemberReader; import com.dnd.moddo.event.application.impl.SettlementCreator; import com.dnd.moddo.event.application.impl.SettlementReader; import com.dnd.moddo.event.application.impl.SettlementUpdater; import com.dnd.moddo.event.application.impl.SettlementValidator; +import com.dnd.moddo.event.domain.member.Member; import com.dnd.moddo.event.domain.member.ExpenseRole; import com.dnd.moddo.event.domain.settlement.Settlement; import com.dnd.moddo.event.presentation.request.SettlementAccountRequest; @@ -46,7 +48,9 @@ class CommandSettlementServiceTest { @Mock private JwtProvider jwtProvider; @Mock - private CommandMemberService commandMemberService; + private MemberCreator memberCreator; + @Mock + private MemberReader memberReader; @InjectMocks private CommandSettlementService commandSettlementService; @@ -75,9 +79,15 @@ void createSettlement() { "김모또", null, 1L, true, LocalDateTime.now()); - when(settlementCreator.createSettlement(any(SettlementRequest.class), anyLong())).thenReturn(settlement); - when(settlement.getCode()).thenReturn("code"); - when(commandMemberService.createManager(any(), any())).thenReturn(memberResponse); + when(settlementCreator.createSettlement(any(SettlementRequest.class), anyLong())).thenReturn(settlement); + when(settlement.getCode()).thenReturn("code"); + Member manager = Member.builder() + .name("김모또") + .settlement(settlement) + .profileId(0) + .role(ExpenseRole.MANAGER) + .build(); + when(memberCreator.createManagerForSettlement(any(), any())).thenReturn(manager); // When SettlementSaveResponse response = commandSettlementService.createSettlement(settlementRequest, 1L); @@ -85,11 +95,11 @@ void createSettlement() { // Then assertThat(response).isNotNull(); assertThat(response.groupToken()).isEqualTo("code"); - assertThat(response.manager().role()).isEqualTo(ExpenseRole.MANAGER); + assertThat(response.manager().role()).isEqualTo(ExpenseRole.MANAGER); - verify(settlementCreator, times(1)).createSettlement(any(SettlementRequest.class), anyLong()); - verify(commandMemberService, times(1)).createManager(any(), any()); - } + verify(settlementCreator, times(1)).createSettlement(any(SettlementRequest.class), anyLong()); + verify(memberCreator, times(1)).createManagerForSettlement(any(), any()); + } @Test @DisplayName("그룹의 계좌 정보를 업데이트할 수 있다.") diff --git a/src/test/java/com/dnd/moddo/domain/settlement/service/implementation/SettlementCompletionProcessorTest.java b/src/test/java/com/dnd/moddo/domain/settlement/service/implementation/SettlementCompletionProcessorTest.java new file mode 100644 index 00000000..2000a4a0 --- /dev/null +++ b/src/test/java/com/dnd/moddo/domain/settlement/service/implementation/SettlementCompletionProcessorTest.java @@ -0,0 +1,72 @@ +package com.dnd.moddo.domain.settlement.service.implementation; + +import static org.assertj.core.api.Assertions.*; +import static org.mockito.Mockito.*; + +import org.junit.jupiter.api.DisplayName; +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 com.dnd.moddo.event.application.impl.MemberReader; +import com.dnd.moddo.event.application.impl.SettlementCompletionProcessor; +import com.dnd.moddo.event.application.impl.SettlementUpdater; +import com.dnd.moddo.outbox.application.command.CommandOutboxEventService; +import com.dnd.moddo.outbox.domain.event.type.AggregateType; +import com.dnd.moddo.outbox.domain.event.type.OutboxEventType; + +@ExtendWith(MockitoExtension.class) +class SettlementCompletionProcessorTest { + + @Mock + private MemberReader memberReader; + + @Mock + private SettlementUpdater settlementUpdater; + + @Mock + private CommandOutboxEventService commandOutboxEventService; + + @InjectMocks + private SettlementCompletionProcessor settlementCompletionProcessor; + + @Test + @DisplayName("미납 멤버가 있으면 정산을 완료하지 않는다.") + void doesNotCompleteWhenUnpaidMemberExists() { + when(memberReader.existsUnpaidMember(1L)).thenReturn(true); + + boolean result = settlementCompletionProcessor.completeIfAllPaid(1L); + + assertThat(result).isFalse(); + verify(settlementUpdater, never()).complete(anyLong()); + verify(commandOutboxEventService, never()).create(any(), any(), anyLong()); + } + + @Test + @DisplayName("정산이 방금 완료되면 아웃박스 이벤트를 생성한다.") + void createsOutboxEventWhenSettlementCompleted() { + when(memberReader.existsUnpaidMember(1L)).thenReturn(false); + when(settlementUpdater.complete(1L)).thenReturn(true); + + boolean result = settlementCompletionProcessor.completeIfAllPaid(1L); + + assertThat(result).isTrue(); + verify(settlementUpdater).complete(1L); + verify(commandOutboxEventService).create(OutboxEventType.SETTLEMENT_COMPLETED, AggregateType.SETTLEMENT, 1L); + } + + @Test + @DisplayName("이미 완료된 정산이면 아웃박스 이벤트를 생성하지 않는다.") + void doesNotCreateOutboxEventWhenSettlementAlreadyCompleted() { + when(memberReader.existsUnpaidMember(1L)).thenReturn(false); + when(settlementUpdater.complete(1L)).thenReturn(false); + + boolean result = settlementCompletionProcessor.completeIfAllPaid(1L); + + assertThat(result).isFalse(); + verify(settlementUpdater).complete(1L); + verify(commandOutboxEventService, never()).create(any(), any(), anyLong()); + } +} From f041423fa66bbb86a2443ed2578c0f2ae7d69658 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9B=90=EC=A7=80=EC=9C=A4?= Date: Sat, 14 Mar 2026 12:34:57 +0900 Subject: [PATCH 03/11] =?UTF-8?q?test:=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20?= =?UTF-8?q?=EC=BD=94=EB=93=9C=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../logging}/EventTaskFailureNotifier.java | 4 +- .../application/impl/EventTaskProcessor.java | 1 + .../implementation/MemberReaderTest.java | 41 +++++++++ .../logging/EventTaskFailureNotifierTest.java | 86 +++++++++++++++++++ .../EventTaskProcessorTest.java | 2 +- .../OutboxEventPublisherTest.java | 35 ++++++++ .../RewardGrantHandlerTest.java | 19 ++++ .../implementation/SettlementUpdaterTest.java | 28 ++++++ 8 files changed, 212 insertions(+), 4 deletions(-) rename src/main/java/com/dnd/moddo/{outbox/application/impl => common/logging}/EventTaskFailureNotifier.java (86%) create mode 100644 src/test/java/com/dnd/moddo/domain/common/logging/EventTaskFailureNotifierTest.java diff --git a/src/main/java/com/dnd/moddo/outbox/application/impl/EventTaskFailureNotifier.java b/src/main/java/com/dnd/moddo/common/logging/EventTaskFailureNotifier.java similarity index 86% rename from src/main/java/com/dnd/moddo/outbox/application/impl/EventTaskFailureNotifier.java rename to src/main/java/com/dnd/moddo/common/logging/EventTaskFailureNotifier.java index b4ac33f3..def50c8a 100644 --- a/src/main/java/com/dnd/moddo/outbox/application/impl/EventTaskFailureNotifier.java +++ b/src/main/java/com/dnd/moddo/common/logging/EventTaskFailureNotifier.java @@ -1,11 +1,9 @@ -package com.dnd.moddo.outbox.application.impl; +package com.dnd.moddo.common.logging; import java.util.List; import org.springframework.stereotype.Component; -import com.dnd.moddo.common.logging.DiscordMessage; -import com.dnd.moddo.common.logging.ErrorNotifier; import com.dnd.moddo.outbox.domain.task.EventTask; import lombok.RequiredArgsConstructor; diff --git a/src/main/java/com/dnd/moddo/outbox/application/impl/EventTaskProcessor.java b/src/main/java/com/dnd/moddo/outbox/application/impl/EventTaskProcessor.java index 0b76dd2b..513a4a05 100644 --- a/src/main/java/com/dnd/moddo/outbox/application/impl/EventTaskProcessor.java +++ b/src/main/java/com/dnd/moddo/outbox/application/impl/EventTaskProcessor.java @@ -3,6 +3,7 @@ import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import com.dnd.moddo.common.logging.EventTaskFailureNotifier; import com.dnd.moddo.outbox.domain.task.EventTask; import com.dnd.moddo.outbox.domain.task.type.EventTaskStatus; import com.dnd.moddo.outbox.domain.task.type.EventTaskType; diff --git a/src/test/java/com/dnd/moddo/domain/Member/service/implementation/MemberReaderTest.java b/src/test/java/com/dnd/moddo/domain/Member/service/implementation/MemberReaderTest.java index d500eba2..273c8e27 100644 --- a/src/test/java/com/dnd/moddo/domain/Member/service/implementation/MemberReaderTest.java +++ b/src/test/java/com/dnd/moddo/domain/Member/service/implementation/MemberReaderTest.java @@ -23,6 +23,8 @@ import com.dnd.moddo.event.infrastructure.MemberQueryRepository; import com.dnd.moddo.event.infrastructure.MemberRepository; import com.dnd.moddo.global.support.GroupTestFactory; +import com.dnd.moddo.global.support.UserTestFactory; +import com.dnd.moddo.user.domain.User; @ExtendWith(MockitoExtension.class) public class MemberReaderTest { @@ -182,4 +184,43 @@ void findBySettlementIdAndUserIdFail() { .isInstanceOf(MemberNotFoundException.class); } + @DisplayName("정산에 미납 멤버가 있는지 확인할 수 있다.") + @Test + void existsUnpaidMemberSuccess() { + Long settlementId = 1L; + when(memberRepository.existsBySettlementIdAndIsPaidFalse(settlementId)).thenReturn(true); + + boolean result = memberReader.existsUnpaidMember(settlementId); + + assertThat(result).isTrue(); + verify(memberRepository).existsBySettlementIdAndIsPaidFalse(settlementId); + } + + @DisplayName("연결된 멤버만 조회할 수 있다.") + @Test + void findAssignedMembersBySettlementIdSuccess() { + Long settlementId = mockSettlement.getId(); + User assignedUser = UserTestFactory.createWithEmail("assigned@test.com"); + List members = List.of( + Member.builder() + .name("연결된 참여자") + .settlement(mockSettlement) + .role(ExpenseRole.PARTICIPANT) + .user(assignedUser) + .build(), + Member.builder() + .name("미연결 참여자") + .settlement(mockSettlement) + .role(ExpenseRole.PARTICIPANT) + .build() + ); + + when(memberQueryRepository.findAllBySettlementId(eq(settlementId), eq(MemberSortType.CREATED))).thenReturn(members); + + List result = memberReader.findAssignedMembersBySettlementId(settlementId); + + assertThat(result.size()).isEqualTo(1); + assertThat(result.get(0).getName()).isEqualTo("연결된 참여자"); + } + } diff --git a/src/test/java/com/dnd/moddo/domain/common/logging/EventTaskFailureNotifierTest.java b/src/test/java/com/dnd/moddo/domain/common/logging/EventTaskFailureNotifierTest.java new file mode 100644 index 00000000..6aa1d3e3 --- /dev/null +++ b/src/test/java/com/dnd/moddo/domain/common/logging/EventTaskFailureNotifierTest.java @@ -0,0 +1,86 @@ +package com.dnd.moddo.domain.common.logging; + +import static org.assertj.core.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +import java.util.List; + +import org.junit.jupiter.api.DisplayName; +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 com.dnd.moddo.common.logging.DiscordMessage; +import com.dnd.moddo.common.logging.ErrorNotifier; +import com.dnd.moddo.common.logging.EventTaskFailureNotifier; +import com.dnd.moddo.outbox.domain.event.OutboxEvent; +import com.dnd.moddo.outbox.domain.event.type.AggregateType; +import com.dnd.moddo.outbox.domain.event.type.OutboxEventType; +import com.dnd.moddo.outbox.domain.task.EventTask; +import com.dnd.moddo.outbox.domain.task.type.EventTaskType; + +@ExtendWith(MockitoExtension.class) +class EventTaskFailureNotifierTest { + + @Mock + private ErrorNotifier errorNotifier; + + @InjectMocks + private EventTaskFailureNotifier eventTaskFailureNotifier; + + @Test + @DisplayName("재시도 초과한 이벤트 태스크 정보를 Discord 알림으로 전송한다.") + void notifyRetryExhausted() { + OutboxEvent outboxEvent = OutboxEvent.pending( + OutboxEventType.SETTLEMENT_COMPLETED, + AggregateType.SETTLEMENT, + 1L + ); + setOutboxEventId(outboxEvent, 10L); + + EventTask eventTask = EventTask.pending(outboxEvent, EventTaskType.REWARD_GRANT, 20L); + setEventTaskId(eventTask, 30L); + eventTask.markFailed("failed-1"); + eventTask.markFailed("failed-2"); + + eventTaskFailureNotifier.notifyRetryExhausted(eventTask); + + ArgumentCaptor captor = ArgumentCaptor.forClass(DiscordMessage.class); + verify(errorNotifier).notifyError(captor.capture()); + + DiscordMessage message = captor.getValue(); + assertThat(message.content()).isEqualTo("# EventTask 재시도 초과"); + assertThat(message.embeds()).hasSize(1); + assertThat(message.embeds().get(0).getDescription()) + .contains("taskId: 30") + .contains("taskType: REWARD_GRANT") + .contains("targetUserId: 20") + .contains("aggregateId: 1") + .contains("attemptCount: 2") + .contains("lastErrorMessage: failed-2"); + } + + private void setOutboxEventId(OutboxEvent outboxEvent, Long id) { + try { + java.lang.reflect.Field idField = OutboxEvent.class.getDeclaredField("id"); + idField.setAccessible(true); + idField.set(outboxEvent, id); + } catch (ReflectiveOperationException exception) { + throw new RuntimeException(exception); + } + } + + private void setEventTaskId(EventTask eventTask, Long id) { + try { + java.lang.reflect.Field idField = EventTask.class.getDeclaredField("id"); + idField.setAccessible(true); + idField.set(eventTask, id); + } catch (ReflectiveOperationException exception) { + throw new RuntimeException(exception); + } + } +} diff --git a/src/test/java/com/dnd/moddo/domain/outbox/service/implementation/EventTaskProcessorTest.java b/src/test/java/com/dnd/moddo/domain/outbox/service/implementation/EventTaskProcessorTest.java index bdd91fd8..483caa30 100644 --- a/src/test/java/com/dnd/moddo/domain/outbox/service/implementation/EventTaskProcessorTest.java +++ b/src/test/java/com/dnd/moddo/domain/outbox/service/implementation/EventTaskProcessorTest.java @@ -9,7 +9,7 @@ import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; -import com.dnd.moddo.outbox.application.impl.EventTaskFailureNotifier; +import com.dnd.moddo.common.logging.EventTaskFailureNotifier; import com.dnd.moddo.outbox.application.impl.EventTaskProcessor; import com.dnd.moddo.outbox.domain.event.OutboxEvent; import com.dnd.moddo.outbox.domain.task.EventTask; diff --git a/src/test/java/com/dnd/moddo/domain/outbox/service/implementation/OutboxEventPublisherTest.java b/src/test/java/com/dnd/moddo/domain/outbox/service/implementation/OutboxEventPublisherTest.java index 96fa46dc..2684c0c8 100644 --- a/src/test/java/com/dnd/moddo/domain/outbox/service/implementation/OutboxEventPublisherTest.java +++ b/src/test/java/com/dnd/moddo/domain/outbox/service/implementation/OutboxEventPublisherTest.java @@ -2,6 +2,8 @@ import static org.mockito.Mockito.*; +import java.util.List; + import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; @@ -54,4 +56,37 @@ void markFailedWhenAppendTaskThrowsException() { verify(outboxEvent).markFailed(); verify(outboxEvent, never()).markPublished(); } + + @Test + @DisplayName("pending 아웃박스 이벤트 목록을 순서대로 publish한다.") + void publishPendingEvents() { + OutboxEvent first = mock(OutboxEvent.class); + OutboxEvent second = mock(OutboxEvent.class); + when(first.getId()).thenReturn(1L); + when(second.getId()).thenReturn(2L); + when(outboxEventRepository.findAllByStatus(OutboxEventStatus.PENDING)).thenReturn(List.of(first, second)); + when(outboxEventRepository.getById(1L)).thenReturn(first); + when(outboxEventRepository.getById(2L)).thenReturn(second); + when(first.getStatus()).thenReturn(OutboxEventStatus.PENDING); + when(second.getStatus()).thenReturn(OutboxEventStatus.PENDING); + + outboxEventPublisher.publishPendingEvents(); + + verify(outboxEventTaskAppender).appendTasks(first); + verify(outboxEventTaskAppender).appendTasks(second); + } + + @Test + @DisplayName("pending 상태가 아니면 publish를 건너뛴다.") + void skipWhenOutboxEventAlreadyProcessed() { + OutboxEvent outboxEvent = mock(OutboxEvent.class); + when(outboxEventRepository.getById(1L)).thenReturn(outboxEvent); + when(outboxEvent.getStatus()).thenReturn(OutboxEventStatus.PUBLISHED); + + outboxEventPublisher.publish(1L); + + verifyNoInteractions(outboxEventTaskAppender); + verify(outboxEvent, never()).markPublished(); + verify(outboxEvent, never()).markFailed(); + } } diff --git a/src/test/java/com/dnd/moddo/domain/reward/service/implementation/RewardGrantHandlerTest.java b/src/test/java/com/dnd/moddo/domain/reward/service/implementation/RewardGrantHandlerTest.java index cef03ec2..a9c90f51 100644 --- a/src/test/java/com/dnd/moddo/domain/reward/service/implementation/RewardGrantHandlerTest.java +++ b/src/test/java/com/dnd/moddo/domain/reward/service/implementation/RewardGrantHandlerTest.java @@ -78,6 +78,25 @@ void swallowDuplicateGrantException() { .doesNotThrowAnyException(); } + @Test + @DisplayName("보상을 지급할 수 있으면 컬렉션을 저장한다.") + void saveCollectionWhenGrantAvailable() { + Character character = Character.builder() + .name("모또") + .rarity(1) + .imageUrl("image") + .imageBigUrl("image-big") + .build(); + setCharacterId(character, 3L); + + when(rewardQueryRepository.findBySettlementId(1L)).thenReturn(Optional.of(character)); + when(collectionRepository.existsByUserIdAndCharacterId(2L, 3L)).thenReturn(false); + + rewardGrantHandler.handle(1L, 2L); + + verify(collectionRepository).save(any()); + } + private void setCharacterId(Character character, Long id) { try { java.lang.reflect.Field idField = Character.class.getDeclaredField("id"); diff --git a/src/test/java/com/dnd/moddo/domain/settlement/service/implementation/SettlementUpdaterTest.java b/src/test/java/com/dnd/moddo/domain/settlement/service/implementation/SettlementUpdaterTest.java index 3a85f0f4..888c4438 100644 --- a/src/test/java/com/dnd/moddo/domain/settlement/service/implementation/SettlementUpdaterTest.java +++ b/src/test/java/com/dnd/moddo/domain/settlement/service/implementation/SettlementUpdaterTest.java @@ -55,4 +55,32 @@ void updateAccountNotFoundGroup() { .isInstanceOf(GroupNotFoundException.class); } + @DisplayName("정산이 아직 완료되지 않았다면 완료 처리할 수 있다.") + @Test + void completeSuccess() { + Long settlementId = 1L; + Settlement settlement = mock(Settlement.class); + when(settlementRepository.getById(settlementId)).thenReturn(settlement); + when(settlement.getCompletedAt()).thenReturn(null); + + boolean result = settlementUpdater.complete(settlementId); + + assertThat(result).isTrue(); + verify(settlement).complete(); + } + + @DisplayName("이미 완료된 정산이면 다시 완료 처리하지 않는다.") + @Test + void completeAlreadyCompletedSettlement() { + Long settlementId = 1L; + Settlement settlement = mock(Settlement.class); + when(settlementRepository.getById(settlementId)).thenReturn(settlement); + when(settlement.getCompletedAt()).thenReturn(java.time.LocalDateTime.now()); + + boolean result = settlementUpdater.complete(settlementId); + + assertThat(result).isFalse(); + verify(settlement, never()).complete(); + } + } From bfca51a0592e1788b032fdfb1b1b2ec7d14e6c1b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9B=90=EC=A7=80=EC=9C=A4?= Date: Sat, 14 Mar 2026 12:48:02 +0900 Subject: [PATCH 04/11] =?UTF-8?q?refactor:=20=EB=B6=88=ED=95=84=EC=9A=94?= =?UTF-8?q?=ED=95=9C=20=ED=81=B4=EB=9E=98=EC=8A=A4=20=EB=B3=91=ED=95=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../auth/infrastructure/security/JwtAuth.java | 12 +++- .../moddo/common/config/SecurityConfig.java | 19 +++++- .../impl/SettlementCompletionProcessor.java | 2 +- .../infrastructure/SettlementRepository.java | 28 ++++++++ ...ssor.java => CommandEventTaskService.java} | 16 +++-- ...or.java => CommandOutboxEventService.java} | 4 +- .../command/CommandEventTaskService.java | 19 ------ .../command/CommandOutboxEventService.java | 22 ------- .../application/impl/EventTaskCreator.java | 22 +++++++ .../application/impl/EventTaskScheduler.java | 5 +- .../impl/OutboxEventPublisher.java | 30 +++++++-- .../impl/OutboxEventTaskAppender.java | 38 ----------- .../outbox/application/impl/OutboxReader.java | 27 ++++++++ .../infrastructure/EventTaskRepository.java | 2 + .../EventTaskAdminController.java | 2 +- .../application/CommandRewardService.java | 19 ------ ...rdGrantHandler.java => RewardService.java} | 12 ++-- .../infrastructure/CharacterRepository.java | 4 +- .../presentation/RewardAdminController.java | 6 +- .../EventTaskAdminControllerTest.java | 4 +- ...> CommandEventTaskServiceProcessTest.java} | 24 +++---- .../service/CommandEventTaskServiceTest.java | 29 +++++++-- .../CommandOutboxEventServiceTest.java | 32 ++++++--- .../implementation/EventTaskCreatorTest.java | 45 +++++++++++++ .../EventTaskSchedulerTest.java | 8 +-- .../OutboxEventCreatorTest.java | 60 ----------------- .../OutboxEventPublisherTest.java | 52 ++++++++++----- .../OutboxEventTaskAppenderTest.java | 65 ------------------- .../implementation/OutboxReaderTest.java | 51 +++++++++++++++ .../controller/RewardAdminControllerTest.java | 8 +-- .../service/CommandRewardServiceTest.java | 31 --------- ...rTest.java => RewardServiceGrantTest.java} | 16 ++--- .../reward/service/RewardServiceTest.java | 38 +++++++++++ .../SettlementCompletionProcessorTest.java | 2 +- 34 files changed, 415 insertions(+), 339 deletions(-) rename src/main/java/com/dnd/moddo/outbox/application/{impl/EventTaskProcessor.java => CommandEventTaskService.java} (73%) rename src/main/java/com/dnd/moddo/outbox/application/{impl/OutboxEventCreator.java => CommandOutboxEventService.java} (92%) delete mode 100644 src/main/java/com/dnd/moddo/outbox/application/command/CommandEventTaskService.java delete mode 100644 src/main/java/com/dnd/moddo/outbox/application/command/CommandOutboxEventService.java create mode 100644 src/main/java/com/dnd/moddo/outbox/application/impl/EventTaskCreator.java delete mode 100644 src/main/java/com/dnd/moddo/outbox/application/impl/OutboxEventTaskAppender.java create mode 100644 src/main/java/com/dnd/moddo/outbox/application/impl/OutboxReader.java delete mode 100644 src/main/java/com/dnd/moddo/reward/application/CommandRewardService.java rename src/main/java/com/dnd/moddo/reward/application/{impl/RewardGrantHandler.java => RewardService.java} (82%) rename src/test/java/com/dnd/moddo/domain/outbox/service/{implementation/EventTaskProcessorTest.java => CommandEventTaskServiceProcessTest.java} (81%) create mode 100644 src/test/java/com/dnd/moddo/domain/outbox/service/implementation/EventTaskCreatorTest.java delete mode 100644 src/test/java/com/dnd/moddo/domain/outbox/service/implementation/OutboxEventCreatorTest.java delete mode 100644 src/test/java/com/dnd/moddo/domain/outbox/service/implementation/OutboxEventTaskAppenderTest.java create mode 100644 src/test/java/com/dnd/moddo/domain/outbox/service/implementation/OutboxReaderTest.java delete mode 100644 src/test/java/com/dnd/moddo/domain/reward/service/CommandRewardServiceTest.java rename src/test/java/com/dnd/moddo/domain/reward/service/{implementation/RewardGrantHandlerTest.java => RewardServiceGrantTest.java} (88%) create mode 100644 src/test/java/com/dnd/moddo/domain/reward/service/RewardServiceTest.java diff --git a/src/main/java/com/dnd/moddo/auth/infrastructure/security/JwtAuth.java b/src/main/java/com/dnd/moddo/auth/infrastructure/security/JwtAuth.java index f6c817ac..a92b2d4c 100644 --- a/src/main/java/com/dnd/moddo/auth/infrastructure/security/JwtAuth.java +++ b/src/main/java/com/dnd/moddo/auth/infrastructure/security/JwtAuth.java @@ -6,8 +6,10 @@ import org.springframework.stereotype.Component; import com.dnd.moddo.auth.application.AuthDetailsService; +import com.dnd.moddo.auth.model.AuthDetails; import com.dnd.moddo.auth.infrastructure.security.exception.MissingTokenException; import com.dnd.moddo.auth.infrastructure.security.exception.TokenInvalidException; +import com.dnd.moddo.user.domain.Authority; import io.jsonwebtoken.Claims; import lombok.RequiredArgsConstructor; @@ -25,8 +27,16 @@ public Authentication getAuthentication(String token, String expectedTokenType) throw new MissingTokenException(); } + Long userId = claims.get(JwtConstants.AUTH_ID.message, Long.class); + String role = claims.get(JwtConstants.ROLE.message, String.class); + + if (Authority.ADMIN.name().equals(role)) { + UserDetails adminDetails = new AuthDetails(userId, "admin", role); + return new UsernamePasswordAuthenticationToken(adminDetails, "", adminDetails.getAuthorities()); + } + UserDetails userDetails = authDetailsService.loadUserByUsername( - claims.get(JwtConstants.AUTH_ID.message).toString()); + userId.toString()); return new UsernamePasswordAuthenticationToken(userDetails, "", userDetails.getAuthorities()); } diff --git a/src/main/java/com/dnd/moddo/common/config/SecurityConfig.java b/src/main/java/com/dnd/moddo/common/config/SecurityConfig.java index 8dad4dd3..f5ced818 100644 --- a/src/main/java/com/dnd/moddo/common/config/SecurityConfig.java +++ b/src/main/java/com/dnd/moddo/common/config/SecurityConfig.java @@ -2,6 +2,7 @@ import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; +import org.springframework.http.HttpStatus; import org.springframework.security.config.Customizer; import org.springframework.security.config.annotation.web.builders.HttpSecurity; import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; @@ -41,10 +42,26 @@ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { (sessionManagement) -> sessionManagement.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) .authorizeHttpRequests(request -> request .requestMatchers(CorsUtils::isPreFlightRequest).permitAll() + .requestMatchers( + "/admin/login", + "/api/v1/admin/login", + "/admin/css/**", + "/admin/js/**" + ).permitAll() + .requestMatchers("/admin/**", "/api/v1/admin/**").hasAuthority("ADMIN") .anyRequest().permitAll() ) + .exceptionHandling(exception -> exception + .authenticationEntryPoint((request, response, authException) -> { + if (request.getRequestURI().startsWith("/admin/")) { + response.sendRedirect("/admin/login"); + return; + } + response.sendError(HttpStatus.UNAUTHORIZED.value()); + }) + ) .addFilterBefore(new JwtFilter(jwtAuth, jwtUtil), UsernamePasswordAuthenticationFilter.class); return http.build(); } -} \ No newline at end of file +} diff --git a/src/main/java/com/dnd/moddo/event/application/impl/SettlementCompletionProcessor.java b/src/main/java/com/dnd/moddo/event/application/impl/SettlementCompletionProcessor.java index ccf59d7c..15a4f5a7 100644 --- a/src/main/java/com/dnd/moddo/event/application/impl/SettlementCompletionProcessor.java +++ b/src/main/java/com/dnd/moddo/event/application/impl/SettlementCompletionProcessor.java @@ -3,7 +3,7 @@ import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; -import com.dnd.moddo.outbox.application.command.CommandOutboxEventService; +import com.dnd.moddo.outbox.application.CommandOutboxEventService; import com.dnd.moddo.outbox.domain.event.type.AggregateType; import com.dnd.moddo.outbox.domain.event.type.OutboxEventType; diff --git a/src/main/java/com/dnd/moddo/event/infrastructure/SettlementRepository.java b/src/main/java/com/dnd/moddo/event/infrastructure/SettlementRepository.java index e5a220f4..7543c63b 100644 --- a/src/main/java/com/dnd/moddo/event/infrastructure/SettlementRepository.java +++ b/src/main/java/com/dnd/moddo/event/infrastructure/SettlementRepository.java @@ -1,6 +1,7 @@ package com.dnd.moddo.event.infrastructure; import java.time.LocalDateTime; +import java.util.List; import java.util.Optional; import org.springframework.data.jpa.repository.JpaRepository; @@ -23,6 +24,33 @@ default Settlement getById(Long groupId) { @Query("SELECT COUNT(s) FROM Settlement s WHERE s.createdAt BETWEEN :start AND :end") long countCreatedSettlement(@Param("start") LocalDateTime start, @Param("end") LocalDateTime end); + @Query(""" + SELECT YEAR(s.createdAt), MONTH(s.createdAt), COUNT(s) + FROM Settlement s + WHERE s.createdAt >= :start + GROUP BY YEAR(s.createdAt), MONTH(s.createdAt) + ORDER BY YEAR(s.createdAt), MONTH(s.createdAt) + """) + List countMonthlySettlements(@Param("start") LocalDateTime start); + + @Query(""" + SELECT HOUR(s.createdAt), COUNT(s) + FROM Settlement s + GROUP BY HOUR(s.createdAt) + ORDER BY HOUR(s.createdAt) + """) + List countHourlySettlements(); + + List findByCompletedAtIsNotNull(); + + @Query(""" + SELECT s.writer + FROM Settlement s + GROUP BY s.writer + HAVING COUNT(s) > 1 + """) + List findRepeatWriters(); + @Query("SELECT COUNT(s) FROM Settlement s WHERE s.completedAt BETWEEN :start AND :end") long countCompletedSettlement(@Param("start") LocalDateTime start, @Param("end") LocalDateTime end); diff --git a/src/main/java/com/dnd/moddo/outbox/application/impl/EventTaskProcessor.java b/src/main/java/com/dnd/moddo/outbox/application/CommandEventTaskService.java similarity index 73% rename from src/main/java/com/dnd/moddo/outbox/application/impl/EventTaskProcessor.java rename to src/main/java/com/dnd/moddo/outbox/application/CommandEventTaskService.java index 513a4a05..3955bc4f 100644 --- a/src/main/java/com/dnd/moddo/outbox/application/impl/EventTaskProcessor.java +++ b/src/main/java/com/dnd/moddo/outbox/application/CommandEventTaskService.java @@ -1,22 +1,23 @@ -package com.dnd.moddo.outbox.application.impl; +package com.dnd.moddo.outbox.application; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import com.dnd.moddo.common.logging.EventTaskFailureNotifier; +import com.dnd.moddo.outbox.application.impl.EventTaskRetryPolicy; import com.dnd.moddo.outbox.domain.task.EventTask; import com.dnd.moddo.outbox.domain.task.type.EventTaskStatus; import com.dnd.moddo.outbox.domain.task.type.EventTaskType; import com.dnd.moddo.outbox.infrastructure.EventTaskRepository; -import com.dnd.moddo.reward.application.impl.RewardGrantHandler; +import com.dnd.moddo.reward.application.RewardService; import lombok.RequiredArgsConstructor; @Service @RequiredArgsConstructor -public class EventTaskProcessor { +public class CommandEventTaskService { private final EventTaskRepository eventTaskRepository; - private final RewardGrantHandler rewardGrantHandler; + private final RewardService rewardService; private final EventTaskFailureNotifier eventTaskFailureNotifier; @Transactional @@ -30,7 +31,7 @@ public void process(Long eventTaskId) { try { if (eventTask.getTaskType() == EventTaskType.REWARD_GRANT) { - rewardGrantHandler.handle(eventTask.getOutboxEvent().getAggregateId(), eventTask.getTargetUserId()); + rewardService.grant(eventTask.getOutboxEvent().getAggregateId(), eventTask.getTargetUserId()); } eventTask.markCompleted(); @@ -41,4 +42,9 @@ public void process(Long eventTaskId) { } } } + + @Transactional + public void retry(Long eventTaskId) { + process(eventTaskId); + } } diff --git a/src/main/java/com/dnd/moddo/outbox/application/impl/OutboxEventCreator.java b/src/main/java/com/dnd/moddo/outbox/application/CommandOutboxEventService.java similarity index 92% rename from src/main/java/com/dnd/moddo/outbox/application/impl/OutboxEventCreator.java rename to src/main/java/com/dnd/moddo/outbox/application/CommandOutboxEventService.java index 8b146046..160903c9 100644 --- a/src/main/java/com/dnd/moddo/outbox/application/impl/OutboxEventCreator.java +++ b/src/main/java/com/dnd/moddo/outbox/application/CommandOutboxEventService.java @@ -1,4 +1,4 @@ -package com.dnd.moddo.outbox.application.impl; +package com.dnd.moddo.outbox.application; import org.springframework.context.ApplicationEventPublisher; import org.springframework.stereotype.Service; @@ -15,7 +15,7 @@ @Service @RequiredArgsConstructor @Transactional -public class OutboxEventCreator { +public class CommandOutboxEventService { private final OutboxEventRepository outboxEventRepository; private final ApplicationEventPublisher eventPublisher; diff --git a/src/main/java/com/dnd/moddo/outbox/application/command/CommandEventTaskService.java b/src/main/java/com/dnd/moddo/outbox/application/command/CommandEventTaskService.java deleted file mode 100644 index d5b27f57..00000000 --- a/src/main/java/com/dnd/moddo/outbox/application/command/CommandEventTaskService.java +++ /dev/null @@ -1,19 +0,0 @@ -package com.dnd.moddo.outbox.application.command; - -import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; - -import com.dnd.moddo.outbox.application.impl.EventTaskProcessor; - -import lombok.RequiredArgsConstructor; - -@Service -@RequiredArgsConstructor -@Transactional -public class CommandEventTaskService { - private final EventTaskProcessor eventTaskProcessor; - - public void retry(Long eventTaskId) { - eventTaskProcessor.process(eventTaskId); - } -} diff --git a/src/main/java/com/dnd/moddo/outbox/application/command/CommandOutboxEventService.java b/src/main/java/com/dnd/moddo/outbox/application/command/CommandOutboxEventService.java deleted file mode 100644 index 96bac509..00000000 --- a/src/main/java/com/dnd/moddo/outbox/application/command/CommandOutboxEventService.java +++ /dev/null @@ -1,22 +0,0 @@ -package com.dnd.moddo.outbox.application.command; - -import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; - -import com.dnd.moddo.outbox.application.impl.OutboxEventCreator; -import com.dnd.moddo.outbox.domain.event.OutboxEvent; -import com.dnd.moddo.outbox.domain.event.type.AggregateType; -import com.dnd.moddo.outbox.domain.event.type.OutboxEventType; - -import lombok.RequiredArgsConstructor; - -@Service -@RequiredArgsConstructor -@Transactional -public class CommandOutboxEventService { - private final OutboxEventCreator outboxEventCreator; - - public OutboxEvent create(OutboxEventType type, AggregateType aggregateType, Long aggregateId) { - return outboxEventCreator.create(type, aggregateType, aggregateId); - } -} diff --git a/src/main/java/com/dnd/moddo/outbox/application/impl/EventTaskCreator.java b/src/main/java/com/dnd/moddo/outbox/application/impl/EventTaskCreator.java new file mode 100644 index 00000000..1906796f --- /dev/null +++ b/src/main/java/com/dnd/moddo/outbox/application/impl/EventTaskCreator.java @@ -0,0 +1,22 @@ +package com.dnd.moddo.outbox.application.impl; + +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import com.dnd.moddo.outbox.domain.event.OutboxEvent; +import com.dnd.moddo.outbox.domain.task.EventTask; +import com.dnd.moddo.outbox.domain.task.type.EventTaskType; +import com.dnd.moddo.outbox.infrastructure.EventTaskRepository; + +import lombok.RequiredArgsConstructor; + +@Service +@RequiredArgsConstructor +@Transactional +public class EventTaskCreator { + private final EventTaskRepository eventTaskRepository; + + public EventTask create(OutboxEvent outboxEvent, EventTaskType taskType, Long targetUserId) { + return eventTaskRepository.saveAndFlush(EventTask.pending(outboxEvent, taskType, targetUserId)); + } +} diff --git a/src/main/java/com/dnd/moddo/outbox/application/impl/EventTaskScheduler.java b/src/main/java/com/dnd/moddo/outbox/application/impl/EventTaskScheduler.java index a20aa0ad..952c897f 100644 --- a/src/main/java/com/dnd/moddo/outbox/application/impl/EventTaskScheduler.java +++ b/src/main/java/com/dnd/moddo/outbox/application/impl/EventTaskScheduler.java @@ -5,6 +5,7 @@ import org.springframework.scheduling.annotation.Scheduled; import org.springframework.stereotype.Component; +import com.dnd.moddo.outbox.application.CommandEventTaskService; import com.dnd.moddo.outbox.domain.task.EventTask; import com.dnd.moddo.outbox.domain.task.type.EventTaskStatus; import com.dnd.moddo.outbox.infrastructure.EventTaskRepository; @@ -15,7 +16,7 @@ @RequiredArgsConstructor public class EventTaskScheduler { private final EventTaskRepository eventTaskRepository; - private final EventTaskProcessor eventTaskProcessor; + private final CommandEventTaskService commandEventTaskService; @Scheduled(fixedDelay = 5000) public void processPendingTasks() { @@ -23,7 +24,7 @@ public void processPendingTasks() { List.of(EventTaskStatus.PENDING, EventTaskStatus.FAILED), EventTaskRetryPolicy.MAX_RETRY_COUNT )) { - eventTaskProcessor.process(eventTask.getId()); + commandEventTaskService.process(eventTask.getId()); } } } diff --git a/src/main/java/com/dnd/moddo/outbox/application/impl/OutboxEventPublisher.java b/src/main/java/com/dnd/moddo/outbox/application/impl/OutboxEventPublisher.java index dd9c640a..3277d101 100644 --- a/src/main/java/com/dnd/moddo/outbox/application/impl/OutboxEventPublisher.java +++ b/src/main/java/com/dnd/moddo/outbox/application/impl/OutboxEventPublisher.java @@ -6,9 +6,12 @@ import org.springframework.transaction.annotation.Propagation; import org.springframework.transaction.annotation.Transactional; +import com.dnd.moddo.event.application.impl.MemberReader; +import com.dnd.moddo.event.domain.member.Member; import com.dnd.moddo.outbox.domain.event.OutboxEvent; import com.dnd.moddo.outbox.domain.event.type.OutboxEventStatus; -import com.dnd.moddo.outbox.infrastructure.OutboxEventRepository; +import com.dnd.moddo.outbox.domain.event.type.OutboxEventType; +import com.dnd.moddo.outbox.domain.task.type.EventTaskType; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -17,12 +20,13 @@ @RequiredArgsConstructor @Slf4j public class OutboxEventPublisher { - private final OutboxEventRepository outboxEventRepository; - private final OutboxEventTaskAppender outboxEventTaskAppender; + private final OutboxReader outboxReader; + private final EventTaskCreator eventTaskCreator; + private final MemberReader memberReader; @Transactional public void publishPendingEvents() { - List pendingEvents = outboxEventRepository.findAllByStatus((OutboxEventStatus.PENDING)); + List pendingEvents = outboxReader.findAllByStatus(OutboxEventStatus.PENDING); for (OutboxEvent outboxEvent : pendingEvents) { publish(outboxEvent.getId()); @@ -31,13 +35,13 @@ public void publishPendingEvents() { @Transactional(propagation = Propagation.REQUIRES_NEW) public void publish(Long outboxEventId) { - OutboxEvent outboxEvent = outboxEventRepository.getById(outboxEventId); + OutboxEvent outboxEvent = outboxReader.findById(outboxEventId); if (outboxEvent.getStatus() != OutboxEventStatus.PENDING) { return; } try { - outboxEventTaskAppender.appendTasks(outboxEvent); + appendTasks(outboxEvent); outboxEvent.markPublished(); } catch (Exception exception) { log.error("Failed to publish outbox event. outboxEventId={}, eventType={}, aggregateId={}", @@ -48,4 +52,18 @@ public void publish(Long outboxEventId) { outboxEvent.markFailed(); } } + + private void appendTasks(OutboxEvent outboxEvent) { + if (outboxEvent.getEventType() == OutboxEventType.SETTLEMENT_COMPLETED) { + appendSettlementCompletedTasks(outboxEvent); + } + } + + private void appendSettlementCompletedTasks(OutboxEvent outboxEvent) { + for (Member member : memberReader.findAssignedMembersBySettlementId(outboxEvent.getAggregateId())) { + Long targetUserId = member.getUserId(); + eventTaskCreator.create(outboxEvent, EventTaskType.REWARD_GRANT, targetUserId); + eventTaskCreator.create(outboxEvent, EventTaskType.NOTIFICATION_SEND, targetUserId); + } + } } diff --git a/src/main/java/com/dnd/moddo/outbox/application/impl/OutboxEventTaskAppender.java b/src/main/java/com/dnd/moddo/outbox/application/impl/OutboxEventTaskAppender.java deleted file mode 100644 index f46297f2..00000000 --- a/src/main/java/com/dnd/moddo/outbox/application/impl/OutboxEventTaskAppender.java +++ /dev/null @@ -1,38 +0,0 @@ -package com.dnd.moddo.outbox.application.impl; - -import org.springframework.stereotype.Component; - -import com.dnd.moddo.event.application.impl.MemberReader; -import com.dnd.moddo.event.domain.member.Member; -import com.dnd.moddo.outbox.domain.event.OutboxEvent; -import com.dnd.moddo.outbox.domain.task.EventTask; -import com.dnd.moddo.outbox.domain.event.type.OutboxEventType; -import com.dnd.moddo.outbox.domain.task.type.EventTaskType; -import com.dnd.moddo.outbox.infrastructure.EventTaskRepository; - -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; - -@Component -@RequiredArgsConstructor -@Slf4j -public class OutboxEventTaskAppender { - private final EventTaskRepository eventTaskRepository; - private final MemberReader memberReader; - - public void appendTasks(OutboxEvent outboxEvent) { - if (outboxEvent.getEventType() == OutboxEventType.SETTLEMENT_COMPLETED) { - appendSettlementCompletedTasks(outboxEvent); - } - } - - private void appendSettlementCompletedTasks(OutboxEvent outboxEvent) { - for (Member member : memberReader.findAssignedMembersBySettlementId(outboxEvent.getAggregateId())) { - Long targetUserId = member.getUserId(); - eventTaskRepository.saveAndFlush(EventTask.pending(outboxEvent, EventTaskType.REWARD_GRANT, targetUserId)); - eventTaskRepository.saveAndFlush( - EventTask.pending(outboxEvent, EventTaskType.NOTIFICATION_SEND, targetUserId) - ); - } - } -} diff --git a/src/main/java/com/dnd/moddo/outbox/application/impl/OutboxReader.java b/src/main/java/com/dnd/moddo/outbox/application/impl/OutboxReader.java new file mode 100644 index 00000000..3c4d273f --- /dev/null +++ b/src/main/java/com/dnd/moddo/outbox/application/impl/OutboxReader.java @@ -0,0 +1,27 @@ +package com.dnd.moddo.outbox.application.impl; + +import java.util.List; + +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import com.dnd.moddo.outbox.domain.event.OutboxEvent; +import com.dnd.moddo.outbox.domain.event.type.OutboxEventStatus; +import com.dnd.moddo.outbox.infrastructure.OutboxEventRepository; + +import lombok.RequiredArgsConstructor; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class OutboxReader { + private final OutboxEventRepository outboxEventRepository; + + public List findAllByStatus(OutboxEventStatus status) { + return outboxEventRepository.findAllByStatus(status); + } + + public OutboxEvent findById(Long outboxEventId) { + return outboxEventRepository.getById(outboxEventId); + } +} diff --git a/src/main/java/com/dnd/moddo/outbox/infrastructure/EventTaskRepository.java b/src/main/java/com/dnd/moddo/outbox/infrastructure/EventTaskRepository.java index d0b11f2c..7364caa6 100644 --- a/src/main/java/com/dnd/moddo/outbox/infrastructure/EventTaskRepository.java +++ b/src/main/java/com/dnd/moddo/outbox/infrastructure/EventTaskRepository.java @@ -15,6 +15,8 @@ List findTop30ByStatusInAndAttemptCountLessThanOrderByCreatedAtAsc( int attemptCount ); + List findTop10ByStatusOrderByCreatedAtDesc(EventTaskStatus status); + default EventTask getById(Long id) { return findById(id) .orElseThrow(() -> new EntityNotFoundException("Event task not found: " + id)); diff --git a/src/main/java/com/dnd/moddo/outbox/presentation/EventTaskAdminController.java b/src/main/java/com/dnd/moddo/outbox/presentation/EventTaskAdminController.java index adaa8e89..e9f259c1 100644 --- a/src/main/java/com/dnd/moddo/outbox/presentation/EventTaskAdminController.java +++ b/src/main/java/com/dnd/moddo/outbox/presentation/EventTaskAdminController.java @@ -9,7 +9,7 @@ import com.dnd.moddo.auth.infrastructure.security.LoginUser; import com.dnd.moddo.auth.model.exception.UserPermissionException; import com.dnd.moddo.auth.presentation.response.LoginUserInfo; -import com.dnd.moddo.outbox.application.command.CommandEventTaskService; +import com.dnd.moddo.outbox.application.CommandEventTaskService; import com.dnd.moddo.user.domain.Authority; import lombok.RequiredArgsConstructor; diff --git a/src/main/java/com/dnd/moddo/reward/application/CommandRewardService.java b/src/main/java/com/dnd/moddo/reward/application/CommandRewardService.java deleted file mode 100644 index bdd288de..00000000 --- a/src/main/java/com/dnd/moddo/reward/application/CommandRewardService.java +++ /dev/null @@ -1,19 +0,0 @@ -package com.dnd.moddo.reward.application; - -import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; - -import com.dnd.moddo.reward.application.impl.RewardGrantHandler; - -import lombok.RequiredArgsConstructor; - -@Service -@RequiredArgsConstructor -@Transactional -public class CommandRewardService { - private final RewardGrantHandler rewardGrantHandler; - - public void manualGrant(Long settlementId, Long targetUserId) { - rewardGrantHandler.handle(settlementId, targetUserId); - } -} diff --git a/src/main/java/com/dnd/moddo/reward/application/impl/RewardGrantHandler.java b/src/main/java/com/dnd/moddo/reward/application/RewardService.java similarity index 82% rename from src/main/java/com/dnd/moddo/reward/application/impl/RewardGrantHandler.java rename to src/main/java/com/dnd/moddo/reward/application/RewardService.java index fb37942c..d04afbfe 100644 --- a/src/main/java/com/dnd/moddo/reward/application/impl/RewardGrantHandler.java +++ b/src/main/java/com/dnd/moddo/reward/application/RewardService.java @@ -1,4 +1,4 @@ -package com.dnd.moddo.reward.application.impl; +package com.dnd.moddo.reward.application; import org.springframework.dao.DataIntegrityViolationException; import org.springframework.stereotype.Service; @@ -14,12 +14,16 @@ @Service @RequiredArgsConstructor -public class RewardGrantHandler { +@Transactional +public class RewardService { private final RewardQueryRepository rewardQueryRepository; private final CollectionRepository collectionRepository; - @Transactional - public void handle(Long settlementId, Long targetUserId) { + public void manualGrant(Long settlementId, Long targetUserId) { + grant(settlementId, targetUserId); + } + + public void grant(Long settlementId, Long targetUserId) { Character character = rewardQueryRepository.findBySettlementId(settlementId) .orElseThrow(() -> new SettlementCharacterNotFoundException(settlementId)); diff --git a/src/main/java/com/dnd/moddo/reward/infrastructure/CharacterRepository.java b/src/main/java/com/dnd/moddo/reward/infrastructure/CharacterRepository.java index 5f398f43..7a9ac06b 100644 --- a/src/main/java/com/dnd/moddo/reward/infrastructure/CharacterRepository.java +++ b/src/main/java/com/dnd/moddo/reward/infrastructure/CharacterRepository.java @@ -10,7 +10,9 @@ public interface CharacterRepository extends JpaRepository { List findByRarity(int rarity); + List findAllByOrderByRarityDescIdDesc(); + default Character getById(Long characterId) { return findById(characterId).orElseThrow(() -> new CharacterNotFoundException()); } -} \ No newline at end of file +} diff --git a/src/main/java/com/dnd/moddo/reward/presentation/RewardAdminController.java b/src/main/java/com/dnd/moddo/reward/presentation/RewardAdminController.java index 76905602..bf033ff0 100644 --- a/src/main/java/com/dnd/moddo/reward/presentation/RewardAdminController.java +++ b/src/main/java/com/dnd/moddo/reward/presentation/RewardAdminController.java @@ -10,7 +10,7 @@ import com.dnd.moddo.auth.model.exception.UserPermissionException; import com.dnd.moddo.auth.presentation.response.LoginUserInfo; import com.dnd.moddo.event.application.query.QuerySettlementService; -import com.dnd.moddo.reward.application.CommandRewardService; +import com.dnd.moddo.reward.application.RewardService; import com.dnd.moddo.user.domain.Authority; import lombok.RequiredArgsConstructor; @@ -19,7 +19,7 @@ @RequiredArgsConstructor @RequestMapping("/api/v1/admin/rewards") public class RewardAdminController { - private final CommandRewardService commandRewardService; + private final RewardService rewardService; private final QuerySettlementService querySettlementService; @PostMapping("/groups/{code}/users/{userId}") @@ -30,7 +30,7 @@ public ResponseEntity manualGrant( ) { validateAdmin(loginUser); Long settlementId = querySettlementService.findIdByCode(code); - commandRewardService.manualGrant(settlementId, userId); + rewardService.manualGrant(settlementId, userId); return ResponseEntity.ok().build(); } diff --git a/src/test/java/com/dnd/moddo/domain/outbox/controller/EventTaskAdminControllerTest.java b/src/test/java/com/dnd/moddo/domain/outbox/controller/EventTaskAdminControllerTest.java index 640349a6..176f6e35 100644 --- a/src/test/java/com/dnd/moddo/domain/outbox/controller/EventTaskAdminControllerTest.java +++ b/src/test/java/com/dnd/moddo/domain/outbox/controller/EventTaskAdminControllerTest.java @@ -13,7 +13,7 @@ import com.dnd.moddo.auth.model.exception.UserPermissionException; import com.dnd.moddo.auth.presentation.response.LoginUserInfo; -import com.dnd.moddo.outbox.application.command.CommandEventTaskService; +import com.dnd.moddo.outbox.application.CommandEventTaskService; import com.dnd.moddo.outbox.presentation.EventTaskAdminController; @ExtendWith(MockitoExtension.class) @@ -40,6 +40,6 @@ void retryForbiddenWhenNotAdmin() { assertThatThrownBy(() -> eventTaskAdminController.retry(1L, new LoginUserInfo(1L, "USER"))) .isInstanceOf(UserPermissionException.class); - verify(commandEventTaskService, never()).retry(anyLong()); + verify(eventTaskService, never()).retry(anyLong()); } } diff --git a/src/test/java/com/dnd/moddo/domain/outbox/service/implementation/EventTaskProcessorTest.java b/src/test/java/com/dnd/moddo/domain/outbox/service/CommandEventTaskServiceProcessTest.java similarity index 81% rename from src/test/java/com/dnd/moddo/domain/outbox/service/implementation/EventTaskProcessorTest.java rename to src/test/java/com/dnd/moddo/domain/outbox/service/CommandEventTaskServiceProcessTest.java index 483caa30..1fc716f8 100644 --- a/src/test/java/com/dnd/moddo/domain/outbox/service/implementation/EventTaskProcessorTest.java +++ b/src/test/java/com/dnd/moddo/domain/outbox/service/CommandEventTaskServiceProcessTest.java @@ -1,4 +1,4 @@ -package com.dnd.moddo.domain.outbox.service.implementation; +package com.dnd.moddo.domain.outbox.service; import static org.mockito.Mockito.*; @@ -10,28 +10,28 @@ import org.mockito.junit.jupiter.MockitoExtension; import com.dnd.moddo.common.logging.EventTaskFailureNotifier; -import com.dnd.moddo.outbox.application.impl.EventTaskProcessor; +import com.dnd.moddo.outbox.application.CommandEventTaskService; import com.dnd.moddo.outbox.domain.event.OutboxEvent; import com.dnd.moddo.outbox.domain.task.EventTask; import com.dnd.moddo.outbox.domain.task.type.EventTaskStatus; import com.dnd.moddo.outbox.domain.task.type.EventTaskType; import com.dnd.moddo.outbox.infrastructure.EventTaskRepository; -import com.dnd.moddo.reward.application.impl.RewardGrantHandler; +import com.dnd.moddo.reward.application.RewardService; @ExtendWith(MockitoExtension.class) -class EventTaskProcessorTest { +class CommandEventTaskServiceProcessTest { @Mock private EventTaskRepository eventTaskRepository; @Mock - private RewardGrantHandler rewardGrantHandler; + private RewardService rewardService; @Mock private EventTaskFailureNotifier eventTaskFailureNotifier; @InjectMocks - private EventTaskProcessor eventTaskProcessor; + private CommandEventTaskService commandEventTaskService; @Test @DisplayName("완료된 태스크는 다시 처리하지 않는다.") @@ -40,10 +40,10 @@ void skipCompletedTask() { when(eventTaskRepository.getById(1L)).thenReturn(eventTask); when(eventTask.getStatus()).thenReturn(EventTaskStatus.COMPLETED); - eventTaskProcessor.process(1L); + commandEventTaskService.process(1L); verify(eventTask, never()).markProcessing(); - verifyNoInteractions(rewardGrantHandler); + verifyNoInteractions(rewardService); } @Test @@ -58,10 +58,10 @@ void processRewardGrantTask() { when(outboxEvent.getAggregateId()).thenReturn(10L); when(eventTask.getTargetUserId()).thenReturn(20L); - eventTaskProcessor.process(1L); + commandEventTaskService.process(1L); verify(eventTask).markProcessing(); - verify(rewardGrantHandler).handle(10L, 20L); + verify(rewardService).grant(10L, 20L); verify(eventTask).markCompleted(); verify(eventTask, never()).markFailed(anyString()); } @@ -77,10 +77,10 @@ void notifyWhenRetryExhausted() { when(eventTask.getOutboxEvent()).thenReturn(outboxEvent); when(outboxEvent.getAggregateId()).thenReturn(10L); when(eventTask.getTargetUserId()).thenReturn(20L); - doThrow(new RuntimeException("grant failed")).when(rewardGrantHandler).handle(10L, 20L); + doThrow(new RuntimeException("grant failed")).when(rewardService).grant(10L, 20L); when(eventTask.getAttemptCount()).thenReturn(5); - eventTaskProcessor.process(1L); + commandEventTaskService.process(1L); verify(eventTask).markFailed("grant failed"); verify(eventTaskFailureNotifier).notifyRetryExhausted(eventTask); diff --git a/src/test/java/com/dnd/moddo/domain/outbox/service/CommandEventTaskServiceTest.java b/src/test/java/com/dnd/moddo/domain/outbox/service/CommandEventTaskServiceTest.java index a0ffd1c6..005cff65 100644 --- a/src/test/java/com/dnd/moddo/domain/outbox/service/CommandEventTaskServiceTest.java +++ b/src/test/java/com/dnd/moddo/domain/outbox/service/CommandEventTaskServiceTest.java @@ -9,14 +9,26 @@ import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; -import com.dnd.moddo.outbox.application.command.CommandEventTaskService; -import com.dnd.moddo.outbox.application.impl.EventTaskProcessor; +import com.dnd.moddo.common.logging.EventTaskFailureNotifier; +import com.dnd.moddo.outbox.application.CommandEventTaskService; +import com.dnd.moddo.outbox.domain.event.OutboxEvent; +import com.dnd.moddo.outbox.domain.task.EventTask; +import com.dnd.moddo.outbox.domain.task.type.EventTaskStatus; +import com.dnd.moddo.outbox.domain.task.type.EventTaskType; +import com.dnd.moddo.outbox.infrastructure.EventTaskRepository; +import com.dnd.moddo.reward.application.RewardService; @ExtendWith(MockitoExtension.class) class CommandEventTaskServiceTest { @Mock - private EventTaskProcessor eventTaskProcessor; + private EventTaskRepository eventTaskRepository; + + @Mock + private RewardService rewardService; + + @Mock + private EventTaskFailureNotifier eventTaskFailureNotifier; @InjectMocks private CommandEventTaskService commandEventTaskService; @@ -24,8 +36,17 @@ class CommandEventTaskServiceTest { @Test @DisplayName("이벤트 태스크 재처리를 요청할 수 있다.") void retry() { + EventTask eventTask = mock(EventTask.class); + OutboxEvent outboxEvent = mock(OutboxEvent.class); + when(eventTaskRepository.getById(1L)).thenReturn(eventTask); + when(eventTask.getStatus()).thenReturn(EventTaskStatus.PENDING); + when(eventTask.getTaskType()).thenReturn(EventTaskType.REWARD_GRANT); + when(eventTask.getOutboxEvent()).thenReturn(outboxEvent); + when(outboxEvent.getAggregateId()).thenReturn(10L); + when(eventTask.getTargetUserId()).thenReturn(20L); + commandEventTaskService.retry(1L); - verify(eventTaskProcessor).process(1L); + verify(rewardService).grant(10L, 20L); } } diff --git a/src/test/java/com/dnd/moddo/domain/outbox/service/CommandOutboxEventServiceTest.java b/src/test/java/com/dnd/moddo/domain/outbox/service/CommandOutboxEventServiceTest.java index 7f9ea153..f03a3892 100644 --- a/src/test/java/com/dnd/moddo/domain/outbox/service/CommandOutboxEventServiceTest.java +++ b/src/test/java/com/dnd/moddo/domain/outbox/service/CommandOutboxEventServiceTest.java @@ -9,28 +9,33 @@ import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.context.ApplicationEventPublisher; -import com.dnd.moddo.outbox.application.command.CommandOutboxEventService; -import com.dnd.moddo.outbox.application.impl.OutboxEventCreator; +import com.dnd.moddo.outbox.application.CommandOutboxEventService; +import com.dnd.moddo.outbox.application.event.OutboxEventCreatedEvent; import com.dnd.moddo.outbox.domain.event.OutboxEvent; import com.dnd.moddo.outbox.domain.event.type.AggregateType; import com.dnd.moddo.outbox.domain.event.type.OutboxEventType; +import com.dnd.moddo.outbox.infrastructure.OutboxEventRepository; @ExtendWith(MockitoExtension.class) class CommandOutboxEventServiceTest { @Mock - private OutboxEventCreator outboxEventCreator; + private OutboxEventRepository outboxEventRepository; + + @Mock + private ApplicationEventPublisher eventPublisher; @InjectMocks private CommandOutboxEventService commandOutboxEventService; @Test - @DisplayName("아웃박스 이벤트 생성을 위임한다.") + @DisplayName("아웃박스 이벤트를 저장하고 생성 이벤트를 발행한다.") void create() { - OutboxEvent outboxEvent = mock(OutboxEvent.class); - when(outboxEventCreator.create(OutboxEventType.SETTLEMENT_COMPLETED, AggregateType.SETTLEMENT, 1L)) - .thenReturn(outboxEvent); + OutboxEvent outboxEvent = OutboxEvent.pending(OutboxEventType.SETTLEMENT_COMPLETED, AggregateType.SETTLEMENT, 1L); + setOutboxEventId(outboxEvent, 10L); + when(outboxEventRepository.save(any(OutboxEvent.class))).thenReturn(outboxEvent); OutboxEvent result = commandOutboxEventService.create( OutboxEventType.SETTLEMENT_COMPLETED, @@ -39,6 +44,17 @@ void create() { ); assertThat(result).isEqualTo(outboxEvent); - verify(outboxEventCreator).create(OutboxEventType.SETTLEMENT_COMPLETED, AggregateType.SETTLEMENT, 1L); + verify(outboxEventRepository).save(any(OutboxEvent.class)); + verify(eventPublisher).publishEvent(new OutboxEventCreatedEvent(10L)); + } + + private void setOutboxEventId(OutboxEvent outboxEvent, Long id) { + try { + java.lang.reflect.Field idField = OutboxEvent.class.getDeclaredField("id"); + idField.setAccessible(true); + idField.set(outboxEvent, id); + } catch (ReflectiveOperationException exception) { + throw new RuntimeException(exception); + } } } diff --git a/src/test/java/com/dnd/moddo/domain/outbox/service/implementation/EventTaskCreatorTest.java b/src/test/java/com/dnd/moddo/domain/outbox/service/implementation/EventTaskCreatorTest.java new file mode 100644 index 00000000..78b96dd6 --- /dev/null +++ b/src/test/java/com/dnd/moddo/domain/outbox/service/implementation/EventTaskCreatorTest.java @@ -0,0 +1,45 @@ +package com.dnd.moddo.domain.outbox.service.implementation; + +import static org.assertj.core.api.Assertions.*; +import static org.mockito.Mockito.*; + +import org.junit.jupiter.api.DisplayName; +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 com.dnd.moddo.outbox.application.impl.EventTaskCreator; +import com.dnd.moddo.outbox.domain.event.OutboxEvent; +import com.dnd.moddo.outbox.domain.task.EventTask; +import com.dnd.moddo.outbox.domain.task.type.EventTaskType; +import com.dnd.moddo.outbox.infrastructure.EventTaskRepository; + +@ExtendWith(MockitoExtension.class) +class EventTaskCreatorTest { + + @Mock + private EventTaskRepository eventTaskRepository; + + @InjectMocks + private EventTaskCreator eventTaskCreator; + + @Test + @DisplayName("이벤트 태스크를 생성해서 저장한다.") + void create() { + OutboxEvent outboxEvent = mock(OutboxEvent.class); + EventTask savedTask = mock(EventTask.class); + when(eventTaskRepository.saveAndFlush(any(EventTask.class))).thenReturn(savedTask); + + EventTask result = eventTaskCreator.create(outboxEvent, EventTaskType.REWARD_GRANT, 20L); + + ArgumentCaptor captor = ArgumentCaptor.forClass(EventTask.class); + verify(eventTaskRepository).saveAndFlush(captor.capture()); + assertThat(captor.getValue().getOutboxEvent()).isEqualTo(outboxEvent); + assertThat(captor.getValue().getTaskType()).isEqualTo(EventTaskType.REWARD_GRANT); + assertThat(captor.getValue().getTargetUserId()).isEqualTo(20L); + assertThat(result).isEqualTo(savedTask); + } +} diff --git a/src/test/java/com/dnd/moddo/domain/outbox/service/implementation/EventTaskSchedulerTest.java b/src/test/java/com/dnd/moddo/domain/outbox/service/implementation/EventTaskSchedulerTest.java index f4c5130c..adf11d91 100644 --- a/src/test/java/com/dnd/moddo/domain/outbox/service/implementation/EventTaskSchedulerTest.java +++ b/src/test/java/com/dnd/moddo/domain/outbox/service/implementation/EventTaskSchedulerTest.java @@ -11,8 +11,8 @@ import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; +import com.dnd.moddo.outbox.application.CommandEventTaskService; import com.dnd.moddo.outbox.application.impl.EventTaskRetryPolicy; -import com.dnd.moddo.outbox.application.impl.EventTaskProcessor; import com.dnd.moddo.outbox.application.impl.EventTaskScheduler; import com.dnd.moddo.outbox.domain.task.EventTask; import com.dnd.moddo.outbox.domain.task.type.EventTaskStatus; @@ -25,7 +25,7 @@ class EventTaskSchedulerTest { private EventTaskRepository eventTaskRepository; @Mock - private EventTaskProcessor eventTaskProcessor; + private CommandEventTaskService commandEventTaskService; @InjectMocks private EventTaskScheduler eventTaskScheduler; @@ -44,7 +44,7 @@ void processPendingTasks() { eventTaskScheduler.processPendingTasks(); - verify(eventTaskProcessor).process(1L); - verify(eventTaskProcessor).process(2L); + verify(commandEventTaskService).process(1L); + verify(commandEventTaskService).process(2L); } } diff --git a/src/test/java/com/dnd/moddo/domain/outbox/service/implementation/OutboxEventCreatorTest.java b/src/test/java/com/dnd/moddo/domain/outbox/service/implementation/OutboxEventCreatorTest.java deleted file mode 100644 index 25f5b629..00000000 --- a/src/test/java/com/dnd/moddo/domain/outbox/service/implementation/OutboxEventCreatorTest.java +++ /dev/null @@ -1,60 +0,0 @@ -package com.dnd.moddo.domain.outbox.service.implementation; - -import static org.assertj.core.api.Assertions.*; -import static org.mockito.Mockito.*; - -import org.junit.jupiter.api.DisplayName; -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.context.ApplicationEventPublisher; - -import com.dnd.moddo.outbox.application.event.OutboxEventCreatedEvent; -import com.dnd.moddo.outbox.application.impl.OutboxEventCreator; -import com.dnd.moddo.outbox.domain.event.OutboxEvent; -import com.dnd.moddo.outbox.domain.event.type.AggregateType; -import com.dnd.moddo.outbox.domain.event.type.OutboxEventType; -import com.dnd.moddo.outbox.infrastructure.OutboxEventRepository; - -@ExtendWith(MockitoExtension.class) -class OutboxEventCreatorTest { - - @Mock - private OutboxEventRepository outboxEventRepository; - - @Mock - private ApplicationEventPublisher eventPublisher; - - @InjectMocks - private OutboxEventCreator outboxEventCreator; - - @Test - @DisplayName("아웃박스 이벤트를 저장하고 생성 이벤트를 발행한다.") - void create() { - OutboxEvent savedEvent = OutboxEvent.pending(OutboxEventType.SETTLEMENT_COMPLETED, AggregateType.SETTLEMENT, 1L); - setOutboxEventId(savedEvent, 10L); - when(outboxEventRepository.save(any(OutboxEvent.class))).thenReturn(savedEvent); - - OutboxEvent result = outboxEventCreator.create(OutboxEventType.SETTLEMENT_COMPLETED, AggregateType.SETTLEMENT, 1L); - - assertThat(result).isEqualTo(savedEvent); - - ArgumentCaptor captor = ArgumentCaptor.forClass(OutboxEvent.class); - verify(outboxEventRepository).save(captor.capture()); - assertThat(captor.getValue().getEventType()).isEqualTo(OutboxEventType.SETTLEMENT_COMPLETED); - verify(eventPublisher).publishEvent(new OutboxEventCreatedEvent(10L)); - } - - private void setOutboxEventId(OutboxEvent outboxEvent, Long id) { - try { - java.lang.reflect.Field idField = OutboxEvent.class.getDeclaredField("id"); - idField.setAccessible(true); - idField.set(outboxEvent, id); - } catch (ReflectiveOperationException exception) { - throw new RuntimeException(exception); - } - } -} diff --git a/src/test/java/com/dnd/moddo/domain/outbox/service/implementation/OutboxEventPublisherTest.java b/src/test/java/com/dnd/moddo/domain/outbox/service/implementation/OutboxEventPublisherTest.java index 2684c0c8..ad4efc12 100644 --- a/src/test/java/com/dnd/moddo/domain/outbox/service/implementation/OutboxEventPublisherTest.java +++ b/src/test/java/com/dnd/moddo/domain/outbox/service/implementation/OutboxEventPublisherTest.java @@ -7,24 +7,32 @@ import org.junit.jupiter.api.DisplayName; 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 com.dnd.moddo.event.application.impl.MemberReader; +import com.dnd.moddo.event.domain.member.Member; +import com.dnd.moddo.outbox.application.impl.EventTaskCreator; +import com.dnd.moddo.outbox.application.impl.OutboxReader; import com.dnd.moddo.outbox.application.impl.OutboxEventPublisher; -import com.dnd.moddo.outbox.application.impl.OutboxEventTaskAppender; import com.dnd.moddo.outbox.domain.event.OutboxEvent; import com.dnd.moddo.outbox.domain.event.type.OutboxEventStatus; -import com.dnd.moddo.outbox.infrastructure.OutboxEventRepository; +import com.dnd.moddo.outbox.domain.event.type.OutboxEventType; +import com.dnd.moddo.outbox.domain.task.type.EventTaskType; @ExtendWith(MockitoExtension.class) class OutboxEventPublisherTest { @Mock - private OutboxEventRepository outboxEventRepository; + private OutboxReader outboxReader; @Mock - private OutboxEventTaskAppender outboxEventTaskAppender; + private EventTaskCreator eventTaskCreator; + + @Mock + private MemberReader memberReader; @InjectMocks private OutboxEventPublisher outboxEventPublisher; @@ -33,12 +41,18 @@ class OutboxEventPublisherTest { @DisplayName("PENDING 아웃박스 이벤트를 publish하면 태스크를 추가하고 published 상태로 변경한다.") void publishPendingOutboxEvent() { OutboxEvent outboxEvent = mock(OutboxEvent.class); - when(outboxEventRepository.getById(1L)).thenReturn(outboxEvent); + Member member = mock(Member.class); + when(outboxReader.findById(1L)).thenReturn(outboxEvent); when(outboxEvent.getStatus()).thenReturn(OutboxEventStatus.PENDING); + when(outboxEvent.getEventType()).thenReturn(OutboxEventType.SETTLEMENT_COMPLETED); + when(outboxEvent.getAggregateId()).thenReturn(10L); + when(memberReader.findAssignedMembersBySettlementId(10L)).thenReturn(List.of(member)); + when(member.getUserId()).thenReturn(20L); outboxEventPublisher.publish(1L); - verify(outboxEventTaskAppender).appendTasks(outboxEvent); + verify(eventTaskCreator).create(outboxEvent, EventTaskType.REWARD_GRANT, 20L); + verify(eventTaskCreator).create(outboxEvent, EventTaskType.NOTIFICATION_SEND, 20L); verify(outboxEvent).markPublished(); verify(outboxEvent, never()).markFailed(); } @@ -47,9 +61,11 @@ void publishPendingOutboxEvent() { @DisplayName("태스크 추가 중 예외가 발생하면 failed 상태로 변경한다.") void markFailedWhenAppendTaskThrowsException() { OutboxEvent outboxEvent = mock(OutboxEvent.class); - when(outboxEventRepository.getById(1L)).thenReturn(outboxEvent); + when(outboxReader.findById(1L)).thenReturn(outboxEvent); when(outboxEvent.getStatus()).thenReturn(OutboxEventStatus.PENDING); - doThrow(new RuntimeException("append failed")).when(outboxEventTaskAppender).appendTasks(outboxEvent); + when(outboxEvent.getEventType()).thenReturn(OutboxEventType.SETTLEMENT_COMPLETED); + when(outboxEvent.getAggregateId()).thenReturn(10L); + doThrow(new RuntimeException("append failed")).when(memberReader).findAssignedMembersBySettlementId(10L); outboxEventPublisher.publish(1L); @@ -64,28 +80,34 @@ void publishPendingEvents() { OutboxEvent second = mock(OutboxEvent.class); when(first.getId()).thenReturn(1L); when(second.getId()).thenReturn(2L); - when(outboxEventRepository.findAllByStatus(OutboxEventStatus.PENDING)).thenReturn(List.of(first, second)); - when(outboxEventRepository.getById(1L)).thenReturn(first); - when(outboxEventRepository.getById(2L)).thenReturn(second); + when(outboxReader.findAllByStatus(OutboxEventStatus.PENDING)).thenReturn(List.of(first, second)); + when(outboxReader.findById(1L)).thenReturn(first); + when(outboxReader.findById(2L)).thenReturn(second); when(first.getStatus()).thenReturn(OutboxEventStatus.PENDING); when(second.getStatus()).thenReturn(OutboxEventStatus.PENDING); + when(first.getEventType()).thenReturn(OutboxEventType.SETTLEMENT_COMPLETED); + when(second.getEventType()).thenReturn(OutboxEventType.SETTLEMENT_COMPLETED); + when(first.getAggregateId()).thenReturn(10L); + when(second.getAggregateId()).thenReturn(20L); + when(memberReader.findAssignedMembersBySettlementId(10L)).thenReturn(List.of()); + when(memberReader.findAssignedMembersBySettlementId(20L)).thenReturn(List.of()); outboxEventPublisher.publishPendingEvents(); - verify(outboxEventTaskAppender).appendTasks(first); - verify(outboxEventTaskAppender).appendTasks(second); + verify(first).markPublished(); + verify(second).markPublished(); } @Test @DisplayName("pending 상태가 아니면 publish를 건너뛴다.") void skipWhenOutboxEventAlreadyProcessed() { OutboxEvent outboxEvent = mock(OutboxEvent.class); - when(outboxEventRepository.getById(1L)).thenReturn(outboxEvent); + when(outboxReader.findById(1L)).thenReturn(outboxEvent); when(outboxEvent.getStatus()).thenReturn(OutboxEventStatus.PUBLISHED); outboxEventPublisher.publish(1L); - verifyNoInteractions(outboxEventTaskAppender); + verifyNoInteractions(eventTaskCreator, memberReader); verify(outboxEvent, never()).markPublished(); verify(outboxEvent, never()).markFailed(); } diff --git a/src/test/java/com/dnd/moddo/domain/outbox/service/implementation/OutboxEventTaskAppenderTest.java b/src/test/java/com/dnd/moddo/domain/outbox/service/implementation/OutboxEventTaskAppenderTest.java deleted file mode 100644 index c8ce9e78..00000000 --- a/src/test/java/com/dnd/moddo/domain/outbox/service/implementation/OutboxEventTaskAppenderTest.java +++ /dev/null @@ -1,65 +0,0 @@ -package com.dnd.moddo.domain.outbox.service.implementation; - -import static org.mockito.Mockito.*; - -import java.util.List; - -import org.junit.jupiter.api.DisplayName; -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 com.dnd.moddo.event.application.impl.MemberReader; -import com.dnd.moddo.event.domain.member.Member; -import com.dnd.moddo.outbox.application.impl.OutboxEventTaskAppender; -import com.dnd.moddo.outbox.domain.event.OutboxEvent; -import com.dnd.moddo.outbox.domain.task.EventTask; -import com.dnd.moddo.outbox.domain.event.type.AggregateType; -import com.dnd.moddo.outbox.domain.event.type.OutboxEventType; -import com.dnd.moddo.outbox.domain.task.type.EventTaskType; -import com.dnd.moddo.outbox.infrastructure.EventTaskRepository; - -@ExtendWith(MockitoExtension.class) -class OutboxEventTaskAppenderTest { - - @Mock - private EventTaskRepository eventTaskRepository; - - @Mock - private MemberReader memberReader; - - @InjectMocks - private OutboxEventTaskAppender outboxEventTaskAppender; - - @Test - @DisplayName("정산 완료 이벤트면 연결된 멤버마다 REWARD_GRANT와 NOTIFICATION_SEND 태스크를 생성한다.") - void appendSettlementCompletedTasks() { - OutboxEvent outboxEvent = OutboxEvent.pending(OutboxEventType.SETTLEMENT_COMPLETED, AggregateType.SETTLEMENT, 1L); - Member firstMember = mock(Member.class); - Member secondMember = mock(Member.class); - - when(firstMember.getUserId()).thenReturn(10L); - when(secondMember.getUserId()).thenReturn(20L); - when(memberReader.findAssignedMembersBySettlementId(1L)).thenReturn(List.of(firstMember, secondMember)); - - outboxEventTaskAppender.appendTasks(outboxEvent); - - ArgumentCaptor captor = ArgumentCaptor.forClass(EventTask.class); - verify(eventTaskRepository, times(4)).saveAndFlush(captor.capture()); - - List savedTasks = captor.getAllValues(); - verifySavedTask(savedTasks.get(0), outboxEvent, EventTaskType.REWARD_GRANT, 10L); - verifySavedTask(savedTasks.get(1), outboxEvent, EventTaskType.NOTIFICATION_SEND, 10L); - verifySavedTask(savedTasks.get(2), outboxEvent, EventTaskType.REWARD_GRANT, 20L); - verifySavedTask(savedTasks.get(3), outboxEvent, EventTaskType.NOTIFICATION_SEND, 20L); - } - - private void verifySavedTask(EventTask eventTask, OutboxEvent outboxEvent, EventTaskType taskType, Long targetUserId) { - org.assertj.core.api.Assertions.assertThat(eventTask.getOutboxEvent()).isEqualTo(outboxEvent); - org.assertj.core.api.Assertions.assertThat(eventTask.getTaskType()).isEqualTo(taskType); - org.assertj.core.api.Assertions.assertThat(eventTask.getTargetUserId()).isEqualTo(targetUserId); - } -} diff --git a/src/test/java/com/dnd/moddo/domain/outbox/service/implementation/OutboxReaderTest.java b/src/test/java/com/dnd/moddo/domain/outbox/service/implementation/OutboxReaderTest.java new file mode 100644 index 00000000..166ee28a --- /dev/null +++ b/src/test/java/com/dnd/moddo/domain/outbox/service/implementation/OutboxReaderTest.java @@ -0,0 +1,51 @@ +package com.dnd.moddo.domain.outbox.service.implementation; + +import static org.assertj.core.api.Assertions.*; +import static org.mockito.Mockito.*; + +import java.util.List; + +import org.junit.jupiter.api.DisplayName; +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 com.dnd.moddo.outbox.application.impl.OutboxReader; +import com.dnd.moddo.outbox.domain.event.OutboxEvent; +import com.dnd.moddo.outbox.domain.event.type.OutboxEventStatus; +import com.dnd.moddo.outbox.infrastructure.OutboxEventRepository; + +@ExtendWith(MockitoExtension.class) +class OutboxReaderTest { + + @Mock + private OutboxEventRepository outboxEventRepository; + + @InjectMocks + private OutboxReader outboxReader; + + @Test + @DisplayName("상태로 아웃박스 이벤트 목록을 조회한다.") + void findAllByStatus() { + OutboxEvent first = mock(OutboxEvent.class); + OutboxEvent second = mock(OutboxEvent.class); + when(outboxEventRepository.findAllByStatus(OutboxEventStatus.PENDING)).thenReturn(List.of(first, second)); + + List result = outboxReader.findAllByStatus(OutboxEventStatus.PENDING); + + assertThat(result).containsExactly(first, second); + } + + @Test + @DisplayName("아이디로 아웃박스 이벤트를 조회한다.") + void findById() { + OutboxEvent outboxEvent = mock(OutboxEvent.class); + when(outboxEventRepository.getById(1L)).thenReturn(outboxEvent); + + OutboxEvent result = outboxReader.findById(1L); + + assertThat(result).isEqualTo(outboxEvent); + } +} diff --git a/src/test/java/com/dnd/moddo/domain/reward/controller/RewardAdminControllerTest.java b/src/test/java/com/dnd/moddo/domain/reward/controller/RewardAdminControllerTest.java index c4671153..fc1c2439 100644 --- a/src/test/java/com/dnd/moddo/domain/reward/controller/RewardAdminControllerTest.java +++ b/src/test/java/com/dnd/moddo/domain/reward/controller/RewardAdminControllerTest.java @@ -14,14 +14,14 @@ import com.dnd.moddo.auth.model.exception.UserPermissionException; import com.dnd.moddo.auth.presentation.response.LoginUserInfo; import com.dnd.moddo.event.application.query.QuerySettlementService; -import com.dnd.moddo.reward.application.CommandRewardService; +import com.dnd.moddo.reward.application.RewardService; import com.dnd.moddo.reward.presentation.RewardAdminController; @ExtendWith(MockitoExtension.class) class RewardAdminControllerTest { @Mock - private CommandRewardService commandRewardService; + private RewardService rewardService; @Mock private QuerySettlementService querySettlementService; @@ -41,7 +41,7 @@ void manualGrantByAdmin() { ); assertThat(response.getStatusCode().is2xxSuccessful()).isTrue(); - verify(commandRewardService).manualGrant(10L, 2L); + verify(rewardService).manualGrant(10L, 2L); } @Test @@ -53,6 +53,6 @@ void manualGrantForbiddenWhenNotAdmin() { new LoginUserInfo(1L, "USER") )).isInstanceOf(UserPermissionException.class); - verify(commandRewardService, never()).manualGrant(anyLong(), anyLong()); + verify(rewardService, never()).manualGrant(anyLong(), anyLong()); } } diff --git a/src/test/java/com/dnd/moddo/domain/reward/service/CommandRewardServiceTest.java b/src/test/java/com/dnd/moddo/domain/reward/service/CommandRewardServiceTest.java deleted file mode 100644 index 7b7423a6..00000000 --- a/src/test/java/com/dnd/moddo/domain/reward/service/CommandRewardServiceTest.java +++ /dev/null @@ -1,31 +0,0 @@ -package com.dnd.moddo.domain.reward.service; - -import static org.mockito.Mockito.*; - -import org.junit.jupiter.api.DisplayName; -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 com.dnd.moddo.reward.application.CommandRewardService; -import com.dnd.moddo.reward.application.impl.RewardGrantHandler; - -@ExtendWith(MockitoExtension.class) -class CommandRewardServiceTest { - - @Mock - private RewardGrantHandler rewardGrantHandler; - - @InjectMocks - private CommandRewardService commandRewardService; - - @Test - @DisplayName("수동 보상 지급을 위임한다.") - void manualGrant() { - commandRewardService.manualGrant(1L, 2L); - - verify(rewardGrantHandler).handle(1L, 2L); - } -} diff --git a/src/test/java/com/dnd/moddo/domain/reward/service/implementation/RewardGrantHandlerTest.java b/src/test/java/com/dnd/moddo/domain/reward/service/RewardServiceGrantTest.java similarity index 88% rename from src/test/java/com/dnd/moddo/domain/reward/service/implementation/RewardGrantHandlerTest.java rename to src/test/java/com/dnd/moddo/domain/reward/service/RewardServiceGrantTest.java index a9c90f51..869d6490 100644 --- a/src/test/java/com/dnd/moddo/domain/reward/service/implementation/RewardGrantHandlerTest.java +++ b/src/test/java/com/dnd/moddo/domain/reward/service/RewardServiceGrantTest.java @@ -1,4 +1,4 @@ -package com.dnd.moddo.domain.reward.service.implementation; +package com.dnd.moddo.domain.reward.service; import static org.assertj.core.api.Assertions.*; import static org.mockito.Mockito.*; @@ -13,14 +13,14 @@ import org.mockito.junit.jupiter.MockitoExtension; import org.springframework.dao.DataIntegrityViolationException; -import com.dnd.moddo.reward.application.impl.RewardGrantHandler; +import com.dnd.moddo.reward.application.RewardService; import com.dnd.moddo.reward.domain.character.Character; import com.dnd.moddo.reward.domain.character.exception.SettlementCharacterNotFoundException; import com.dnd.moddo.reward.infrastructure.CollectionRepository; import com.dnd.moddo.reward.infrastructure.RewardQueryRepository; @ExtendWith(MockitoExtension.class) -class RewardGrantHandlerTest { +class RewardServiceGrantTest { @Mock private RewardQueryRepository rewardQueryRepository; @@ -29,14 +29,14 @@ class RewardGrantHandlerTest { private CollectionRepository collectionRepository; @InjectMocks - private RewardGrantHandler rewardGrantHandler; + private RewardService rewardService; @Test @DisplayName("정산에 연결된 캐릭터가 없으면 예외가 발생한다.") void throwExceptionWhenSettlementCharacterNotFound() { when(rewardQueryRepository.findBySettlementId(1L)).thenReturn(Optional.empty()); - assertThatThrownBy(() -> rewardGrantHandler.handle(1L, 2L)) + assertThatThrownBy(() -> rewardService.grant(1L, 2L)) .isInstanceOf(SettlementCharacterNotFoundException.class); } @@ -54,7 +54,7 @@ void doNotSaveWhenCharacterAlreadyGranted() { when(rewardQueryRepository.findBySettlementId(1L)).thenReturn(Optional.of(character)); when(collectionRepository.existsByUserIdAndCharacterId(2L, 3L)).thenReturn(true); - rewardGrantHandler.handle(1L, 2L); + rewardService.grant(1L, 2L); verify(collectionRepository, never()).save(any()); } @@ -74,7 +74,7 @@ void swallowDuplicateGrantException() { when(collectionRepository.existsByUserIdAndCharacterId(2L, 3L)).thenReturn(false); doThrow(new DataIntegrityViolationException("duplicate")).when(collectionRepository).save(any()); - assertThatCode(() -> rewardGrantHandler.handle(1L, 2L)) + assertThatCode(() -> rewardService.grant(1L, 2L)) .doesNotThrowAnyException(); } @@ -92,7 +92,7 @@ void saveCollectionWhenGrantAvailable() { when(rewardQueryRepository.findBySettlementId(1L)).thenReturn(Optional.of(character)); when(collectionRepository.existsByUserIdAndCharacterId(2L, 3L)).thenReturn(false); - rewardGrantHandler.handle(1L, 2L); + rewardService.grant(1L, 2L); verify(collectionRepository).save(any()); } diff --git a/src/test/java/com/dnd/moddo/domain/reward/service/RewardServiceTest.java b/src/test/java/com/dnd/moddo/domain/reward/service/RewardServiceTest.java new file mode 100644 index 00000000..c90305b1 --- /dev/null +++ b/src/test/java/com/dnd/moddo/domain/reward/service/RewardServiceTest.java @@ -0,0 +1,38 @@ +package com.dnd.moddo.domain.reward.service; + +import static org.mockito.Mockito.*; + +import org.junit.jupiter.api.DisplayName; +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 com.dnd.moddo.reward.application.RewardService; +import com.dnd.moddo.reward.infrastructure.CollectionRepository; +import com.dnd.moddo.reward.infrastructure.RewardQueryRepository; + +@ExtendWith(MockitoExtension.class) +class RewardServiceTest { + + @Mock + private RewardQueryRepository rewardQueryRepository; + + @Mock + private CollectionRepository collectionRepository; + + @InjectMocks + private RewardService rewardService; + + @Test + @DisplayName("수동 보상 지급을 위임한다.") + void manualGrant() { + RewardService spyService = spy(rewardService); + doNothing().when(spyService).grant(1L, 2L); + + spyService.manualGrant(1L, 2L); + + verify(spyService).grant(1L, 2L); + } +} diff --git a/src/test/java/com/dnd/moddo/domain/settlement/service/implementation/SettlementCompletionProcessorTest.java b/src/test/java/com/dnd/moddo/domain/settlement/service/implementation/SettlementCompletionProcessorTest.java index 2000a4a0..3611d7da 100644 --- a/src/test/java/com/dnd/moddo/domain/settlement/service/implementation/SettlementCompletionProcessorTest.java +++ b/src/test/java/com/dnd/moddo/domain/settlement/service/implementation/SettlementCompletionProcessorTest.java @@ -13,7 +13,7 @@ import com.dnd.moddo.event.application.impl.MemberReader; import com.dnd.moddo.event.application.impl.SettlementCompletionProcessor; import com.dnd.moddo.event.application.impl.SettlementUpdater; -import com.dnd.moddo.outbox.application.command.CommandOutboxEventService; +import com.dnd.moddo.outbox.application.CommandOutboxEventService; import com.dnd.moddo.outbox.domain.event.type.AggregateType; import com.dnd.moddo.outbox.domain.event.type.OutboxEventType; From 9c0bf05105cdcf063f26ca252b0d5127c4deb89a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9B=90=EC=A7=80=EC=9C=A4?= Date: Sat, 14 Mar 2026 13:06:28 +0900 Subject: [PATCH 05/11] =?UTF-8?q?refactor:=20=EC=BD=94=EB=93=9C=20?= =?UTF-8?q?=EB=A6=AC=ED=8C=A9=ED=86=A0=EB=A7=81=20=EB=B0=8F=20=EC=9D=BC?= =?UTF-8?q?=EB=B6=80=20=EB=A1=9C=EC=A7=81=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../logging/EventTaskFailureNotifier.java | 40 +++++++----- .../impl/SettlementCompletionProcessor.java | 2 +- .../application/impl/SettlementUpdater.java | 9 +-- .../infrastructure/SettlementRepository.java | 10 +++ .../CommandEventTaskService.java | 15 +++-- .../CommandOutboxEventService.java | 2 +- .../event/OutboxEventCreatedEvent.java | 5 ++ .../application/impl/EventTaskScheduler.java | 2 +- .../infrastructure/EventTaskRepository.java | 18 +++++ .../EventTaskAdminController.java | 2 +- .../EventTaskAdminControllerTest.java | 4 +- .../CommandEventTaskServiceProcessTest.java | 65 ++++++++++++++++--- .../service/CommandEventTaskServiceTest.java | 11 +++- .../CommandOutboxEventServiceTest.java | 5 +- .../EventTaskSchedulerTest.java | 2 +- .../SettlementCompletionProcessorTest.java | 2 +- 16 files changed, 143 insertions(+), 51 deletions(-) rename src/main/java/com/dnd/moddo/outbox/application/{ => command}/CommandEventTaskService.java (82%) rename src/main/java/com/dnd/moddo/outbox/application/{ => command}/CommandOutboxEventService.java (95%) diff --git a/src/main/java/com/dnd/moddo/common/logging/EventTaskFailureNotifier.java b/src/main/java/com/dnd/moddo/common/logging/EventTaskFailureNotifier.java index def50c8a..40683174 100644 --- a/src/main/java/com/dnd/moddo/common/logging/EventTaskFailureNotifier.java +++ b/src/main/java/com/dnd/moddo/common/logging/EventTaskFailureNotifier.java @@ -7,30 +7,36 @@ import com.dnd.moddo.outbox.domain.task.EventTask; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; @Component @RequiredArgsConstructor +@Slf4j public class EventTaskFailureNotifier { private final ErrorNotifier errorNotifier; public void notifyRetryExhausted(EventTask eventTask) { - errorNotifier.notifyError( - DiscordMessage.createDiscordMessage( - "# EventTask 재시도 초과", - List.of( - DiscordMessage.Embed.builder() - .title("EventTask 최종 실패") - .description( - "taskId: " + eventTask.getId() + "\n" + - "taskType: " + eventTask.getTaskType() + "\n" + - "targetUserId: " + eventTask.getTargetUserId() + "\n" + - "aggregateId: " + eventTask.getOutboxEvent().getAggregateId() + "\n" + - "attemptCount: " + eventTask.getAttemptCount() + "\n" + - "lastErrorMessage: " + eventTask.getLastErrorMessage() - ) - .build() + try { + errorNotifier.notifyError( + DiscordMessage.createDiscordMessage( + "# EventTask 재시도 초과", + List.of( + DiscordMessage.Embed.builder() + .title("EventTask 최종 실패") + .description( + "taskId: " + eventTask.getId() + "\n" + + "taskType: " + eventTask.getTaskType() + "\n" + + "targetUserId: " + eventTask.getTargetUserId() + "\n" + + "aggregateId: " + eventTask.getOutboxEvent().getAggregateId() + "\n" + + "attemptCount: " + eventTask.getAttemptCount() + "\n" + + "lastErrorMessage: " + eventTask.getLastErrorMessage() + ) + .build() + ) ) - ) - ); + ); + } catch (Exception e) { + log.warn("Failed to notify exhausted EventTask. taskId={}", eventTask.getId(), e); + } } } diff --git a/src/main/java/com/dnd/moddo/event/application/impl/SettlementCompletionProcessor.java b/src/main/java/com/dnd/moddo/event/application/impl/SettlementCompletionProcessor.java index 15a4f5a7..ccf59d7c 100644 --- a/src/main/java/com/dnd/moddo/event/application/impl/SettlementCompletionProcessor.java +++ b/src/main/java/com/dnd/moddo/event/application/impl/SettlementCompletionProcessor.java @@ -3,7 +3,7 @@ import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; -import com.dnd.moddo.outbox.application.CommandOutboxEventService; +import com.dnd.moddo.outbox.application.command.CommandOutboxEventService; import com.dnd.moddo.outbox.domain.event.type.AggregateType; import com.dnd.moddo.outbox.domain.event.type.OutboxEventType; diff --git a/src/main/java/com/dnd/moddo/event/application/impl/SettlementUpdater.java b/src/main/java/com/dnd/moddo/event/application/impl/SettlementUpdater.java index 384f956b..f3e061de 100644 --- a/src/main/java/com/dnd/moddo/event/application/impl/SettlementUpdater.java +++ b/src/main/java/com/dnd/moddo/event/application/impl/SettlementUpdater.java @@ -22,13 +22,6 @@ public Settlement updateAccount(SettlementAccountRequest request, Long settlemen } public boolean complete(Long settlementId) { - Settlement settlement = settlementRepository.getById(settlementId); - - if (settlement.getCompletedAt() != null) { - return false; - } - - settlement.complete(); - return true; + return settlementRepository.markCompletedIfNotCompleted(settlementId) == 1; } } diff --git a/src/main/java/com/dnd/moddo/event/infrastructure/SettlementRepository.java b/src/main/java/com/dnd/moddo/event/infrastructure/SettlementRepository.java index 7543c63b..7172a2ad 100644 --- a/src/main/java/com/dnd/moddo/event/infrastructure/SettlementRepository.java +++ b/src/main/java/com/dnd/moddo/event/infrastructure/SettlementRepository.java @@ -5,6 +5,7 @@ import java.util.Optional; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; @@ -62,6 +63,15 @@ SELECT COUNT(s) """) long countOverdue(@Param("limit") LocalDateTime limit); + @Modifying + @Query(""" + update Settlement s + set s.completedAt = CURRENT_TIMESTAMP + where s.id = :settlementId + and s.completedAt is null + """) + int markCompletedIfNotCompleted(@Param("settlementId") Long settlementId); + default Long getIdByCode(String code) { return findIdByCode(code).orElseThrow(() -> new GroupNotFoundException(code)); } diff --git a/src/main/java/com/dnd/moddo/outbox/application/CommandEventTaskService.java b/src/main/java/com/dnd/moddo/outbox/application/command/CommandEventTaskService.java similarity index 82% rename from src/main/java/com/dnd/moddo/outbox/application/CommandEventTaskService.java rename to src/main/java/com/dnd/moddo/outbox/application/command/CommandEventTaskService.java index 3955bc4f..1233ea6d 100644 --- a/src/main/java/com/dnd/moddo/outbox/application/CommandEventTaskService.java +++ b/src/main/java/com/dnd/moddo/outbox/application/command/CommandEventTaskService.java @@ -1,4 +1,6 @@ -package com.dnd.moddo.outbox.application; +package com.dnd.moddo.outbox.application.command; + +import java.util.List; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -22,12 +24,17 @@ public class CommandEventTaskService { @Transactional public void process(Long eventTaskId) { - EventTask eventTask = eventTaskRepository.getById(eventTaskId); - if (eventTask.getStatus() == EventTaskStatus.COMPLETED) { + int updatedCount = eventTaskRepository.claimProcessing( + eventTaskId, + EventTaskStatus.PROCESSING, + List.of(EventTaskStatus.PENDING, EventTaskStatus.FAILED), + EventTaskRetryPolicy.MAX_RETRY_COUNT + ); + if (updatedCount == 0) { return; } - eventTask.markProcessing(); + EventTask eventTask = eventTaskRepository.getById(eventTaskId); try { if (eventTask.getTaskType() == EventTaskType.REWARD_GRANT) { diff --git a/src/main/java/com/dnd/moddo/outbox/application/CommandOutboxEventService.java b/src/main/java/com/dnd/moddo/outbox/application/command/CommandOutboxEventService.java similarity index 95% rename from src/main/java/com/dnd/moddo/outbox/application/CommandOutboxEventService.java rename to src/main/java/com/dnd/moddo/outbox/application/command/CommandOutboxEventService.java index 160903c9..bf645b03 100644 --- a/src/main/java/com/dnd/moddo/outbox/application/CommandOutboxEventService.java +++ b/src/main/java/com/dnd/moddo/outbox/application/command/CommandOutboxEventService.java @@ -1,4 +1,4 @@ -package com.dnd.moddo.outbox.application; +package com.dnd.moddo.outbox.application.command; import org.springframework.context.ApplicationEventPublisher; import org.springframework.stereotype.Service; diff --git a/src/main/java/com/dnd/moddo/outbox/application/event/OutboxEventCreatedEvent.java b/src/main/java/com/dnd/moddo/outbox/application/event/OutboxEventCreatedEvent.java index 26a4fdb1..8d1e8ca8 100644 --- a/src/main/java/com/dnd/moddo/outbox/application/event/OutboxEventCreatedEvent.java +++ b/src/main/java/com/dnd/moddo/outbox/application/event/OutboxEventCreatedEvent.java @@ -1,4 +1,9 @@ package com.dnd.moddo.outbox.application.event; public record OutboxEventCreatedEvent(Long outboxEventId) { + public OutboxEventCreatedEvent { + if (outboxEventId <= 0) { + throw new IllegalArgumentException("outboxEventId가 0이상이어야 합니다."); + } + } } diff --git a/src/main/java/com/dnd/moddo/outbox/application/impl/EventTaskScheduler.java b/src/main/java/com/dnd/moddo/outbox/application/impl/EventTaskScheduler.java index 952c897f..332e37b0 100644 --- a/src/main/java/com/dnd/moddo/outbox/application/impl/EventTaskScheduler.java +++ b/src/main/java/com/dnd/moddo/outbox/application/impl/EventTaskScheduler.java @@ -5,7 +5,7 @@ import org.springframework.scheduling.annotation.Scheduled; import org.springframework.stereotype.Component; -import com.dnd.moddo.outbox.application.CommandEventTaskService; +import com.dnd.moddo.outbox.application.command.CommandEventTaskService; import com.dnd.moddo.outbox.domain.task.EventTask; import com.dnd.moddo.outbox.domain.task.type.EventTaskStatus; import com.dnd.moddo.outbox.infrastructure.EventTaskRepository; diff --git a/src/main/java/com/dnd/moddo/outbox/infrastructure/EventTaskRepository.java b/src/main/java/com/dnd/moddo/outbox/infrastructure/EventTaskRepository.java index 7364caa6..fcb6988d 100644 --- a/src/main/java/com/dnd/moddo/outbox/infrastructure/EventTaskRepository.java +++ b/src/main/java/com/dnd/moddo/outbox/infrastructure/EventTaskRepository.java @@ -3,6 +3,9 @@ import java.util.List; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; import com.dnd.moddo.outbox.domain.task.EventTask; import com.dnd.moddo.outbox.domain.task.type.EventTaskStatus; @@ -17,6 +20,21 @@ List findTop30ByStatusInAndAttemptCountLessThanOrderByCreatedAtAsc( List findTop10ByStatusOrderByCreatedAtDesc(EventTaskStatus status); + @Modifying(clearAutomatically = true, flushAutomatically = true) + @Query(""" + update EventTask eventTask + set eventTask.status = :processingStatus + where eventTask.id = :eventTaskId + and eventTask.status in :claimableStatuses + and eventTask.attemptCount < :maxRetryCount + """) + int claimProcessing( + @Param("eventTaskId") Long eventTaskId, + @Param("processingStatus") EventTaskStatus processingStatus, + @Param("claimableStatuses") List claimableStatuses, + @Param("maxRetryCount") int maxRetryCount + ); + default EventTask getById(Long id) { return findById(id) .orElseThrow(() -> new EntityNotFoundException("Event task not found: " + id)); diff --git a/src/main/java/com/dnd/moddo/outbox/presentation/EventTaskAdminController.java b/src/main/java/com/dnd/moddo/outbox/presentation/EventTaskAdminController.java index e9f259c1..adaa8e89 100644 --- a/src/main/java/com/dnd/moddo/outbox/presentation/EventTaskAdminController.java +++ b/src/main/java/com/dnd/moddo/outbox/presentation/EventTaskAdminController.java @@ -9,7 +9,7 @@ import com.dnd.moddo.auth.infrastructure.security.LoginUser; import com.dnd.moddo.auth.model.exception.UserPermissionException; import com.dnd.moddo.auth.presentation.response.LoginUserInfo; -import com.dnd.moddo.outbox.application.CommandEventTaskService; +import com.dnd.moddo.outbox.application.command.CommandEventTaskService; import com.dnd.moddo.user.domain.Authority; import lombok.RequiredArgsConstructor; diff --git a/src/test/java/com/dnd/moddo/domain/outbox/controller/EventTaskAdminControllerTest.java b/src/test/java/com/dnd/moddo/domain/outbox/controller/EventTaskAdminControllerTest.java index 176f6e35..640349a6 100644 --- a/src/test/java/com/dnd/moddo/domain/outbox/controller/EventTaskAdminControllerTest.java +++ b/src/test/java/com/dnd/moddo/domain/outbox/controller/EventTaskAdminControllerTest.java @@ -13,7 +13,7 @@ import com.dnd.moddo.auth.model.exception.UserPermissionException; import com.dnd.moddo.auth.presentation.response.LoginUserInfo; -import com.dnd.moddo.outbox.application.CommandEventTaskService; +import com.dnd.moddo.outbox.application.command.CommandEventTaskService; import com.dnd.moddo.outbox.presentation.EventTaskAdminController; @ExtendWith(MockitoExtension.class) @@ -40,6 +40,6 @@ void retryForbiddenWhenNotAdmin() { assertThatThrownBy(() -> eventTaskAdminController.retry(1L, new LoginUserInfo(1L, "USER"))) .isInstanceOf(UserPermissionException.class); - verify(eventTaskService, never()).retry(anyLong()); + verify(commandEventTaskService, never()).retry(anyLong()); } } diff --git a/src/test/java/com/dnd/moddo/domain/outbox/service/CommandEventTaskServiceProcessTest.java b/src/test/java/com/dnd/moddo/domain/outbox/service/CommandEventTaskServiceProcessTest.java index 1fc716f8..6ac5aa6b 100644 --- a/src/test/java/com/dnd/moddo/domain/outbox/service/CommandEventTaskServiceProcessTest.java +++ b/src/test/java/com/dnd/moddo/domain/outbox/service/CommandEventTaskServiceProcessTest.java @@ -2,6 +2,8 @@ import static org.mockito.Mockito.*; +import java.util.List; + import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; @@ -10,7 +12,7 @@ import org.mockito.junit.jupiter.MockitoExtension; import com.dnd.moddo.common.logging.EventTaskFailureNotifier; -import com.dnd.moddo.outbox.application.CommandEventTaskService; +import com.dnd.moddo.outbox.application.command.CommandEventTaskService; import com.dnd.moddo.outbox.domain.event.OutboxEvent; import com.dnd.moddo.outbox.domain.task.EventTask; import com.dnd.moddo.outbox.domain.task.type.EventTaskStatus; @@ -34,15 +36,18 @@ class CommandEventTaskServiceProcessTest { private CommandEventTaskService commandEventTaskService; @Test - @DisplayName("완료된 태스크는 다시 처리하지 않는다.") - void skipCompletedTask() { - EventTask eventTask = mock(EventTask.class); - when(eventTaskRepository.getById(1L)).thenReturn(eventTask); - when(eventTask.getStatus()).thenReturn(EventTaskStatus.COMPLETED); + @DisplayName("선점에 실패한 태스크는 처리하지 않는다.") + void skipWhenClaimProcessingFails() { + when(eventTaskRepository.claimProcessing( + 1L, + EventTaskStatus.PROCESSING, + List.of(EventTaskStatus.PENDING, EventTaskStatus.FAILED), + 5 + )).thenReturn(0); commandEventTaskService.process(1L); - verify(eventTask, never()).markProcessing(); + verify(eventTaskRepository, never()).getById(anyLong()); verifyNoInteractions(rewardService); } @@ -51,8 +56,13 @@ void skipCompletedTask() { void processRewardGrantTask() { EventTask eventTask = mock(EventTask.class); OutboxEvent outboxEvent = mock(OutboxEvent.class); + when(eventTaskRepository.claimProcessing( + 1L, + EventTaskStatus.PROCESSING, + List.of(EventTaskStatus.PENDING, EventTaskStatus.FAILED), + 5 + )).thenReturn(1); when(eventTaskRepository.getById(1L)).thenReturn(eventTask); - when(eventTask.getStatus()).thenReturn(EventTaskStatus.PENDING); when(eventTask.getTaskType()).thenReturn(EventTaskType.REWARD_GRANT); when(eventTask.getOutboxEvent()).thenReturn(outboxEvent); when(outboxEvent.getAggregateId()).thenReturn(10L); @@ -60,19 +70,54 @@ void processRewardGrantTask() { commandEventTaskService.process(1L); - verify(eventTask).markProcessing(); + verify(eventTaskRepository).claimProcessing( + 1L, + EventTaskStatus.PROCESSING, + List.of(EventTaskStatus.PENDING, EventTaskStatus.FAILED), + 5 + ); verify(rewardService).grant(10L, 20L); verify(eventTask).markCompleted(); verify(eventTask, never()).markFailed(anyString()); } + @Test + @DisplayName("실패했지만 최대 재시도 전이면 운영 알림은 보내지 않는다.") + void doesNotNotifyWhenRetryNotExhausted() { + EventTask eventTask = mock(EventTask.class); + OutboxEvent outboxEvent = mock(OutboxEvent.class); + when(eventTaskRepository.claimProcessing( + 1L, + EventTaskStatus.PROCESSING, + List.of(EventTaskStatus.PENDING, EventTaskStatus.FAILED), + 5 + )).thenReturn(1); + when(eventTaskRepository.getById(1L)).thenReturn(eventTask); + when(eventTask.getTaskType()).thenReturn(EventTaskType.REWARD_GRANT); + when(eventTask.getOutboxEvent()).thenReturn(outboxEvent); + when(outboxEvent.getAggregateId()).thenReturn(10L); + when(eventTask.getTargetUserId()).thenReturn(20L); + doThrow(new RuntimeException("grant failed")).when(rewardService).grant(10L, 20L); + when(eventTask.getAttemptCount()).thenReturn(3); + + commandEventTaskService.process(1L); + + verify(eventTask).markFailed("grant failed"); + verify(eventTaskFailureNotifier, never()).notifyRetryExhausted(any()); + } + @Test @DisplayName("최대 재시도에 도달한 실패 태스크는 운영 알림을 보낸다.") void notifyWhenRetryExhausted() { EventTask eventTask = mock(EventTask.class); OutboxEvent outboxEvent = mock(OutboxEvent.class); + when(eventTaskRepository.claimProcessing( + 1L, + EventTaskStatus.PROCESSING, + List.of(EventTaskStatus.PENDING, EventTaskStatus.FAILED), + 5 + )).thenReturn(1); when(eventTaskRepository.getById(1L)).thenReturn(eventTask); - when(eventTask.getStatus()).thenReturn(EventTaskStatus.PENDING); when(eventTask.getTaskType()).thenReturn(EventTaskType.REWARD_GRANT); when(eventTask.getOutboxEvent()).thenReturn(outboxEvent); when(outboxEvent.getAggregateId()).thenReturn(10L); diff --git a/src/test/java/com/dnd/moddo/domain/outbox/service/CommandEventTaskServiceTest.java b/src/test/java/com/dnd/moddo/domain/outbox/service/CommandEventTaskServiceTest.java index 005cff65..4152b62d 100644 --- a/src/test/java/com/dnd/moddo/domain/outbox/service/CommandEventTaskServiceTest.java +++ b/src/test/java/com/dnd/moddo/domain/outbox/service/CommandEventTaskServiceTest.java @@ -2,6 +2,8 @@ import static org.mockito.Mockito.*; +import java.util.List; + import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; @@ -10,7 +12,7 @@ import org.mockito.junit.jupiter.MockitoExtension; import com.dnd.moddo.common.logging.EventTaskFailureNotifier; -import com.dnd.moddo.outbox.application.CommandEventTaskService; +import com.dnd.moddo.outbox.application.command.CommandEventTaskService; import com.dnd.moddo.outbox.domain.event.OutboxEvent; import com.dnd.moddo.outbox.domain.task.EventTask; import com.dnd.moddo.outbox.domain.task.type.EventTaskStatus; @@ -38,8 +40,13 @@ class CommandEventTaskServiceTest { void retry() { EventTask eventTask = mock(EventTask.class); OutboxEvent outboxEvent = mock(OutboxEvent.class); + when(eventTaskRepository.claimProcessing( + 1L, + EventTaskStatus.PROCESSING, + List.of(EventTaskStatus.PENDING, EventTaskStatus.FAILED), + 5 + )).thenReturn(1); when(eventTaskRepository.getById(1L)).thenReturn(eventTask); - when(eventTask.getStatus()).thenReturn(EventTaskStatus.PENDING); when(eventTask.getTaskType()).thenReturn(EventTaskType.REWARD_GRANT); when(eventTask.getOutboxEvent()).thenReturn(outboxEvent); when(outboxEvent.getAggregateId()).thenReturn(10L); diff --git a/src/test/java/com/dnd/moddo/domain/outbox/service/CommandOutboxEventServiceTest.java b/src/test/java/com/dnd/moddo/domain/outbox/service/CommandOutboxEventServiceTest.java index f03a3892..5eef5fb2 100644 --- a/src/test/java/com/dnd/moddo/domain/outbox/service/CommandOutboxEventServiceTest.java +++ b/src/test/java/com/dnd/moddo/domain/outbox/service/CommandOutboxEventServiceTest.java @@ -11,7 +11,7 @@ import org.mockito.junit.jupiter.MockitoExtension; import org.springframework.context.ApplicationEventPublisher; -import com.dnd.moddo.outbox.application.CommandOutboxEventService; +import com.dnd.moddo.outbox.application.command.CommandOutboxEventService; import com.dnd.moddo.outbox.application.event.OutboxEventCreatedEvent; import com.dnd.moddo.outbox.domain.event.OutboxEvent; import com.dnd.moddo.outbox.domain.event.type.AggregateType; @@ -33,7 +33,8 @@ class CommandOutboxEventServiceTest { @Test @DisplayName("아웃박스 이벤트를 저장하고 생성 이벤트를 발행한다.") void create() { - OutboxEvent outboxEvent = OutboxEvent.pending(OutboxEventType.SETTLEMENT_COMPLETED, AggregateType.SETTLEMENT, 1L); + OutboxEvent outboxEvent = OutboxEvent.pending(OutboxEventType.SETTLEMENT_COMPLETED, AggregateType.SETTLEMENT, + 1L); setOutboxEventId(outboxEvent, 10L); when(outboxEventRepository.save(any(OutboxEvent.class))).thenReturn(outboxEvent); diff --git a/src/test/java/com/dnd/moddo/domain/outbox/service/implementation/EventTaskSchedulerTest.java b/src/test/java/com/dnd/moddo/domain/outbox/service/implementation/EventTaskSchedulerTest.java index adf11d91..79e5febd 100644 --- a/src/test/java/com/dnd/moddo/domain/outbox/service/implementation/EventTaskSchedulerTest.java +++ b/src/test/java/com/dnd/moddo/domain/outbox/service/implementation/EventTaskSchedulerTest.java @@ -11,7 +11,7 @@ import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; -import com.dnd.moddo.outbox.application.CommandEventTaskService; +import com.dnd.moddo.outbox.application.command.CommandEventTaskService; import com.dnd.moddo.outbox.application.impl.EventTaskRetryPolicy; import com.dnd.moddo.outbox.application.impl.EventTaskScheduler; import com.dnd.moddo.outbox.domain.task.EventTask; diff --git a/src/test/java/com/dnd/moddo/domain/settlement/service/implementation/SettlementCompletionProcessorTest.java b/src/test/java/com/dnd/moddo/domain/settlement/service/implementation/SettlementCompletionProcessorTest.java index 3611d7da..2000a4a0 100644 --- a/src/test/java/com/dnd/moddo/domain/settlement/service/implementation/SettlementCompletionProcessorTest.java +++ b/src/test/java/com/dnd/moddo/domain/settlement/service/implementation/SettlementCompletionProcessorTest.java @@ -13,7 +13,7 @@ import com.dnd.moddo.event.application.impl.MemberReader; import com.dnd.moddo.event.application.impl.SettlementCompletionProcessor; import com.dnd.moddo.event.application.impl.SettlementUpdater; -import com.dnd.moddo.outbox.application.CommandOutboxEventService; +import com.dnd.moddo.outbox.application.command.CommandOutboxEventService; import com.dnd.moddo.outbox.domain.event.type.AggregateType; import com.dnd.moddo.outbox.domain.event.type.OutboxEventType; From 3f4fc403095d80c14896787a560049dea84f5807 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9B=90=EC=A7=80=EC=9C=A4?= Date: Sat, 14 Mar 2026 13:09:23 +0900 Subject: [PATCH 06/11] =?UTF-8?q?test:=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20?= =?UTF-8?q?=EC=BD=94=EB=93=9C=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../implementation/SettlementUpdaterTest.java | 12 ++++-------- 1 file changed, 4 insertions(+), 8 deletions(-) diff --git a/src/test/java/com/dnd/moddo/domain/settlement/service/implementation/SettlementUpdaterTest.java b/src/test/java/com/dnd/moddo/domain/settlement/service/implementation/SettlementUpdaterTest.java index 888c4438..8f097439 100644 --- a/src/test/java/com/dnd/moddo/domain/settlement/service/implementation/SettlementUpdaterTest.java +++ b/src/test/java/com/dnd/moddo/domain/settlement/service/implementation/SettlementUpdaterTest.java @@ -59,28 +59,24 @@ void updateAccountNotFoundGroup() { @Test void completeSuccess() { Long settlementId = 1L; - Settlement settlement = mock(Settlement.class); - when(settlementRepository.getById(settlementId)).thenReturn(settlement); - when(settlement.getCompletedAt()).thenReturn(null); + when(settlementRepository.markCompletedIfNotCompleted(settlementId)).thenReturn(1); boolean result = settlementUpdater.complete(settlementId); assertThat(result).isTrue(); - verify(settlement).complete(); + verify(settlementRepository).markCompletedIfNotCompleted(settlementId); } @DisplayName("이미 완료된 정산이면 다시 완료 처리하지 않는다.") @Test void completeAlreadyCompletedSettlement() { Long settlementId = 1L; - Settlement settlement = mock(Settlement.class); - when(settlementRepository.getById(settlementId)).thenReturn(settlement); - when(settlement.getCompletedAt()).thenReturn(java.time.LocalDateTime.now()); + when(settlementRepository.markCompletedIfNotCompleted(settlementId)).thenReturn(0); boolean result = settlementUpdater.complete(settlementId); assertThat(result).isFalse(); - verify(settlement, never()).complete(); + verify(settlementRepository).markCompletedIfNotCompleted(settlementId); } } From df898faf2c93907808935254961496e3588d4eb0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9B=90=EC=A7=80=EC=9C=A4?= Date: Sat, 14 Mar 2026 13:11:32 +0900 Subject: [PATCH 07/11] =?UTF-8?q?refactor:=20=EC=9D=BC=EB=B6=80=20?= =?UTF-8?q?=EC=BD=94=EB=93=9C=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../outbox/application/command/CommandEventTaskService.java | 2 ++ .../exception/SettlementCharacterNotFoundException.java | 2 +- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/src/main/java/com/dnd/moddo/outbox/application/command/CommandEventTaskService.java b/src/main/java/com/dnd/moddo/outbox/application/command/CommandEventTaskService.java index 1233ea6d..69fffc50 100644 --- a/src/main/java/com/dnd/moddo/outbox/application/command/CommandEventTaskService.java +++ b/src/main/java/com/dnd/moddo/outbox/application/command/CommandEventTaskService.java @@ -39,6 +39,8 @@ public void process(Long eventTaskId) { try { if (eventTask.getTaskType() == EventTaskType.REWARD_GRANT) { rewardService.grant(eventTask.getOutboxEvent().getAggregateId(), eventTask.getTargetUserId()); + } else { + return; } eventTask.markCompleted(); diff --git a/src/main/java/com/dnd/moddo/reward/domain/character/exception/SettlementCharacterNotFoundException.java b/src/main/java/com/dnd/moddo/reward/domain/character/exception/SettlementCharacterNotFoundException.java index d09949a3..08297f40 100644 --- a/src/main/java/com/dnd/moddo/reward/domain/character/exception/SettlementCharacterNotFoundException.java +++ b/src/main/java/com/dnd/moddo/reward/domain/character/exception/SettlementCharacterNotFoundException.java @@ -6,6 +6,6 @@ public class SettlementCharacterNotFoundException extends ModdoException { public SettlementCharacterNotFoundException(Long settlementId) { - super(HttpStatus.INTERNAL_SERVER_ERROR, "정산에 연결된 캐릭터를 찾을 수 없습니다. settlementId=" + settlementId); + super(HttpStatus.NOT_FOUND, "정산에 연결된 캐릭터를 찾을 수 없습니다. settlementId=" + settlementId); } } From baa713684e71a2237127e63ef19646ca1ffc2628 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9B=90=EC=A7=80=EC=9C=A4?= Date: Tue, 24 Mar 2026 21:16:37 +0900 Subject: [PATCH 08/11] =?UTF-8?q?refactor:=20outbox=20=ED=8C=A8=ED=84=B4?= =?UTF-8?q?=20=EA=B0=9C=EC=84=A0=20=EB=B0=8F=20task=20status=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../auth/infrastructure/security/JwtAuth.java | 5 +- .../moddo/common/config/SecurityConfig.java | 17 ----- .../command/CommandEventTaskService.java | 3 +- .../impl/OutboxEventPublishExecutor.java | 62 ++++++++++++++++++ .../impl/OutboxEventPublisher.java | 33 ++-------- .../domain/event/type/OutboxEventStatus.java | 1 + .../moddo/outbox/domain/task/EventTask.java | 5 ++ .../domain/task/type/EventTaskStatus.java | 1 + .../infrastructure/OutboxEventRepository.java | 16 +++++ .../CommandEventTaskServiceProcessTest.java | 24 +++++++ .../OutboxEventPublisherTest.java | 65 +++++++------------ 11 files changed, 143 insertions(+), 89 deletions(-) create mode 100644 src/main/java/com/dnd/moddo/outbox/application/impl/OutboxEventPublishExecutor.java diff --git a/src/main/java/com/dnd/moddo/auth/infrastructure/security/JwtAuth.java b/src/main/java/com/dnd/moddo/auth/infrastructure/security/JwtAuth.java index a92b2d4c..01b1a5f4 100644 --- a/src/main/java/com/dnd/moddo/auth/infrastructure/security/JwtAuth.java +++ b/src/main/java/com/dnd/moddo/auth/infrastructure/security/JwtAuth.java @@ -6,9 +6,9 @@ import org.springframework.stereotype.Component; import com.dnd.moddo.auth.application.AuthDetailsService; -import com.dnd.moddo.auth.model.AuthDetails; import com.dnd.moddo.auth.infrastructure.security.exception.MissingTokenException; import com.dnd.moddo.auth.infrastructure.security.exception.TokenInvalidException; +import com.dnd.moddo.auth.model.AuthDetails; import com.dnd.moddo.user.domain.Authority; import io.jsonwebtoken.Claims; @@ -29,6 +29,9 @@ public Authentication getAuthentication(String token, String expectedTokenType) Long userId = claims.get(JwtConstants.AUTH_ID.message, Long.class); String role = claims.get(JwtConstants.ROLE.message, String.class); + if (userId == null || role == null) { + throw new TokenInvalidException(); + } if (Authority.ADMIN.name().equals(role)) { UserDetails adminDetails = new AuthDetails(userId, "admin", role); diff --git a/src/main/java/com/dnd/moddo/common/config/SecurityConfig.java b/src/main/java/com/dnd/moddo/common/config/SecurityConfig.java index f5ced818..bcf98703 100644 --- a/src/main/java/com/dnd/moddo/common/config/SecurityConfig.java +++ b/src/main/java/com/dnd/moddo/common/config/SecurityConfig.java @@ -2,7 +2,6 @@ import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; -import org.springframework.http.HttpStatus; import org.springframework.security.config.Customizer; import org.springframework.security.config.annotation.web.builders.HttpSecurity; import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; @@ -42,24 +41,8 @@ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { (sessionManagement) -> sessionManagement.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) .authorizeHttpRequests(request -> request .requestMatchers(CorsUtils::isPreFlightRequest).permitAll() - .requestMatchers( - "/admin/login", - "/api/v1/admin/login", - "/admin/css/**", - "/admin/js/**" - ).permitAll() - .requestMatchers("/admin/**", "/api/v1/admin/**").hasAuthority("ADMIN") .anyRequest().permitAll() ) - .exceptionHandling(exception -> exception - .authenticationEntryPoint((request, response, authException) -> { - if (request.getRequestURI().startsWith("/admin/")) { - response.sendRedirect("/admin/login"); - return; - } - response.sendError(HttpStatus.UNAUTHORIZED.value()); - }) - ) .addFilterBefore(new JwtFilter(jwtAuth, jwtUtil), UsernamePasswordAuthenticationFilter.class); return http.build(); diff --git a/src/main/java/com/dnd/moddo/outbox/application/command/CommandEventTaskService.java b/src/main/java/com/dnd/moddo/outbox/application/command/CommandEventTaskService.java index 69fffc50..5ab64912 100644 --- a/src/main/java/com/dnd/moddo/outbox/application/command/CommandEventTaskService.java +++ b/src/main/java/com/dnd/moddo/outbox/application/command/CommandEventTaskService.java @@ -40,13 +40,14 @@ public void process(Long eventTaskId) { if (eventTask.getTaskType() == EventTaskType.REWARD_GRANT) { rewardService.grant(eventTask.getOutboxEvent().getAggregateId(), eventTask.getTargetUserId()); } else { - return; + throw new IllegalStateException("Unsupported task type: " + eventTask.getTaskType()); } eventTask.markCompleted(); } catch (Exception exception) { eventTask.markFailed(exception.getMessage()); if (eventTask.getAttemptCount() >= EventTaskRetryPolicy.MAX_RETRY_COUNT) { + eventTask.markDead(exception.getMessage()); eventTaskFailureNotifier.notifyRetryExhausted(eventTask); } } diff --git a/src/main/java/com/dnd/moddo/outbox/application/impl/OutboxEventPublishExecutor.java b/src/main/java/com/dnd/moddo/outbox/application/impl/OutboxEventPublishExecutor.java new file mode 100644 index 00000000..3d930d1b --- /dev/null +++ b/src/main/java/com/dnd/moddo/outbox/application/impl/OutboxEventPublishExecutor.java @@ -0,0 +1,62 @@ +package com.dnd.moddo.outbox.application.impl; + +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Propagation; +import org.springframework.transaction.annotation.Transactional; + +import com.dnd.moddo.event.application.impl.MemberReader; +import com.dnd.moddo.event.domain.member.Member; +import com.dnd.moddo.outbox.domain.event.OutboxEvent; +import com.dnd.moddo.outbox.domain.event.type.OutboxEventStatus; +import com.dnd.moddo.outbox.domain.event.type.OutboxEventType; +import com.dnd.moddo.outbox.domain.task.type.EventTaskType; +import com.dnd.moddo.outbox.infrastructure.OutboxEventRepository; + +import lombok.RequiredArgsConstructor; + +@Service +@RequiredArgsConstructor +public class OutboxEventPublishExecutor { + private final OutboxReader outboxReader; + private final EventTaskCreator eventTaskCreator; + private final MemberReader memberReader; + private final OutboxEventRepository outboxEventRepository; + + @Transactional(propagation = Propagation.REQUIRES_NEW) + public boolean claimProcessing(Long outboxEventId) { + int updatedCount = outboxEventRepository.claimProcessing( + outboxEventId, + OutboxEventStatus.PROCESSING, + OutboxEventStatus.PENDING + ); + return updatedCount > 0; + } + + @Transactional(propagation = Propagation.REQUIRES_NEW) + public void appendTasks(Long outboxEventId) { + OutboxEvent outboxEvent = outboxReader.findById(outboxEventId); + if (outboxEvent.getEventType() == OutboxEventType.SETTLEMENT_COMPLETED) { + appendSettlementCompletedTasks(outboxEvent); + } + } + + @Transactional(propagation = Propagation.REQUIRES_NEW) + public void markPublished(Long outboxEventId) { + OutboxEvent outboxEvent = outboxReader.findById(outboxEventId); + outboxEvent.markPublished(); + } + + @Transactional(propagation = Propagation.REQUIRES_NEW) + public void markFailed(Long outboxEventId) { + OutboxEvent outboxEvent = outboxReader.findById(outboxEventId); + outboxEvent.markFailed(); + } + + private void appendSettlementCompletedTasks(OutboxEvent outboxEvent) { + for (Member member : memberReader.findAssignedMembersBySettlementId(outboxEvent.getAggregateId())) { + Long targetUserId = member.getUserId(); + eventTaskCreator.create(outboxEvent, EventTaskType.REWARD_GRANT, targetUserId); + eventTaskCreator.create(outboxEvent, EventTaskType.NOTIFICATION_SEND, targetUserId); + } + } +} diff --git a/src/main/java/com/dnd/moddo/outbox/application/impl/OutboxEventPublisher.java b/src/main/java/com/dnd/moddo/outbox/application/impl/OutboxEventPublisher.java index 3277d101..4a2b1901 100644 --- a/src/main/java/com/dnd/moddo/outbox/application/impl/OutboxEventPublisher.java +++ b/src/main/java/com/dnd/moddo/outbox/application/impl/OutboxEventPublisher.java @@ -3,15 +3,10 @@ import java.util.List; import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Propagation; import org.springframework.transaction.annotation.Transactional; -import com.dnd.moddo.event.application.impl.MemberReader; -import com.dnd.moddo.event.domain.member.Member; import com.dnd.moddo.outbox.domain.event.OutboxEvent; import com.dnd.moddo.outbox.domain.event.type.OutboxEventStatus; -import com.dnd.moddo.outbox.domain.event.type.OutboxEventType; -import com.dnd.moddo.outbox.domain.task.type.EventTaskType; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -21,8 +16,7 @@ @Slf4j public class OutboxEventPublisher { private final OutboxReader outboxReader; - private final EventTaskCreator eventTaskCreator; - private final MemberReader memberReader; + private final OutboxEventPublishExecutor outboxEventPublishExecutor; @Transactional public void publishPendingEvents() { @@ -33,37 +27,22 @@ public void publishPendingEvents() { } } - @Transactional(propagation = Propagation.REQUIRES_NEW) public void publish(Long outboxEventId) { - OutboxEvent outboxEvent = outboxReader.findById(outboxEventId); - if (outboxEvent.getStatus() != OutboxEventStatus.PENDING) { + if (!outboxEventPublishExecutor.claimProcessing(outboxEventId)) { return; } try { - appendTasks(outboxEvent); - outboxEvent.markPublished(); + outboxEventPublishExecutor.appendTasks(outboxEventId); + outboxEventPublishExecutor.markPublished(outboxEventId); } catch (Exception exception) { + OutboxEvent outboxEvent = outboxReader.findById(outboxEventId); log.error("Failed to publish outbox event. outboxEventId={}, eventType={}, aggregateId={}", outboxEvent.getId(), outboxEvent.getEventType(), outboxEvent.getAggregateId(), exception); - outboxEvent.markFailed(); - } - } - - private void appendTasks(OutboxEvent outboxEvent) { - if (outboxEvent.getEventType() == OutboxEventType.SETTLEMENT_COMPLETED) { - appendSettlementCompletedTasks(outboxEvent); - } - } - - private void appendSettlementCompletedTasks(OutboxEvent outboxEvent) { - for (Member member : memberReader.findAssignedMembersBySettlementId(outboxEvent.getAggregateId())) { - Long targetUserId = member.getUserId(); - eventTaskCreator.create(outboxEvent, EventTaskType.REWARD_GRANT, targetUserId); - eventTaskCreator.create(outboxEvent, EventTaskType.NOTIFICATION_SEND, targetUserId); + outboxEventPublishExecutor.markFailed(outboxEventId); } } } diff --git a/src/main/java/com/dnd/moddo/outbox/domain/event/type/OutboxEventStatus.java b/src/main/java/com/dnd/moddo/outbox/domain/event/type/OutboxEventStatus.java index 56b9281f..df043f6f 100644 --- a/src/main/java/com/dnd/moddo/outbox/domain/event/type/OutboxEventStatus.java +++ b/src/main/java/com/dnd/moddo/outbox/domain/event/type/OutboxEventStatus.java @@ -2,6 +2,7 @@ public enum OutboxEventStatus { PENDING, + PROCESSING, PUBLISHED, FAILED, } diff --git a/src/main/java/com/dnd/moddo/outbox/domain/task/EventTask.java b/src/main/java/com/dnd/moddo/outbox/domain/task/EventTask.java index 6d57d686..6fad66b5 100644 --- a/src/main/java/com/dnd/moddo/outbox/domain/task/EventTask.java +++ b/src/main/java/com/dnd/moddo/outbox/domain/task/EventTask.java @@ -89,4 +89,9 @@ public void markFailed(String errorMessage) { this.attemptCount++; this.lastErrorMessage = errorMessage; } + + public void markDead(String errorMessage) { + this.status = EventTaskStatus.DEAD; + this.lastErrorMessage = errorMessage; + } } diff --git a/src/main/java/com/dnd/moddo/outbox/domain/task/type/EventTaskStatus.java b/src/main/java/com/dnd/moddo/outbox/domain/task/type/EventTaskStatus.java index 8aacd076..899077bd 100644 --- a/src/main/java/com/dnd/moddo/outbox/domain/task/type/EventTaskStatus.java +++ b/src/main/java/com/dnd/moddo/outbox/domain/task/type/EventTaskStatus.java @@ -5,4 +5,5 @@ public enum EventTaskStatus { PROCESSING, COMPLETED, FAILED, + DEAD } diff --git a/src/main/java/com/dnd/moddo/outbox/infrastructure/OutboxEventRepository.java b/src/main/java/com/dnd/moddo/outbox/infrastructure/OutboxEventRepository.java index 9cbad09b..0ba4a378 100644 --- a/src/main/java/com/dnd/moddo/outbox/infrastructure/OutboxEventRepository.java +++ b/src/main/java/com/dnd/moddo/outbox/infrastructure/OutboxEventRepository.java @@ -3,6 +3,9 @@ import java.util.List; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; import com.dnd.moddo.outbox.domain.event.OutboxEvent; import com.dnd.moddo.outbox.domain.event.type.OutboxEventStatus; @@ -12,6 +15,19 @@ public interface OutboxEventRepository extends JpaRepository { List findAllByStatus(OutboxEventStatus status); + @Modifying(clearAutomatically = true, flushAutomatically = true) + @Query(""" + update OutboxEvent outboxEvent + set outboxEvent.status = :processingStatus + where outboxEvent.id = :outboxEventId + and outboxEvent.status = :pendingStatus + """) + int claimProcessing( + @Param("outboxEventId") Long outboxEventId, + @Param("processingStatus") OutboxEventStatus processingStatus, + @Param("pendingStatus") OutboxEventStatus pendingStatus + ); + default OutboxEvent getById(Long id) { return findById(id) .orElseThrow(() -> new EntityNotFoundException("Outbox event not found: " + id)); diff --git a/src/test/java/com/dnd/moddo/domain/outbox/service/CommandEventTaskServiceProcessTest.java b/src/test/java/com/dnd/moddo/domain/outbox/service/CommandEventTaskServiceProcessTest.java index 6ac5aa6b..10bf1893 100644 --- a/src/test/java/com/dnd/moddo/domain/outbox/service/CommandEventTaskServiceProcessTest.java +++ b/src/test/java/com/dnd/moddo/domain/outbox/service/CommandEventTaskServiceProcessTest.java @@ -128,6 +128,30 @@ void notifyWhenRetryExhausted() { commandEventTaskService.process(1L); verify(eventTask).markFailed("grant failed"); + verify(eventTask).markDead("grant failed"); verify(eventTaskFailureNotifier).notifyRetryExhausted(eventTask); } + + @Test + @DisplayName("지원하지 않는 태스크 타입은 실패 처리한다.") + void failWhenTaskTypeUnsupported() { + EventTask eventTask = mock(EventTask.class); + when(eventTaskRepository.claimProcessing( + 1L, + EventTaskStatus.PROCESSING, + List.of(EventTaskStatus.PENDING, EventTaskStatus.FAILED), + 5 + )).thenReturn(1); + when(eventTaskRepository.getById(1L)).thenReturn(eventTask); + when(eventTask.getTaskType()).thenReturn(EventTaskType.NOTIFICATION_SEND); + when(eventTask.getAttemptCount()).thenReturn(1); + + commandEventTaskService.process(1L); + + verifyNoInteractions(rewardService); + verify(eventTask).markFailed("Unsupported task type: NOTIFICATION_SEND"); + verify(eventTask, never()).markCompleted(); + verify(eventTask, never()).markDead(anyString()); + verify(eventTaskFailureNotifier, never()).notifyRetryExhausted(any()); + } } diff --git a/src/test/java/com/dnd/moddo/domain/outbox/service/implementation/OutboxEventPublisherTest.java b/src/test/java/com/dnd/moddo/domain/outbox/service/implementation/OutboxEventPublisherTest.java index ad4efc12..9fd15e33 100644 --- a/src/test/java/com/dnd/moddo/domain/outbox/service/implementation/OutboxEventPublisherTest.java +++ b/src/test/java/com/dnd/moddo/domain/outbox/service/implementation/OutboxEventPublisherTest.java @@ -12,15 +12,12 @@ import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; -import com.dnd.moddo.event.application.impl.MemberReader; -import com.dnd.moddo.event.domain.member.Member; -import com.dnd.moddo.outbox.application.impl.EventTaskCreator; import com.dnd.moddo.outbox.application.impl.OutboxReader; +import com.dnd.moddo.outbox.application.impl.OutboxEventPublishExecutor; import com.dnd.moddo.outbox.application.impl.OutboxEventPublisher; import com.dnd.moddo.outbox.domain.event.OutboxEvent; import com.dnd.moddo.outbox.domain.event.type.OutboxEventStatus; import com.dnd.moddo.outbox.domain.event.type.OutboxEventType; -import com.dnd.moddo.outbox.domain.task.type.EventTaskType; @ExtendWith(MockitoExtension.class) class OutboxEventPublisherTest { @@ -29,10 +26,7 @@ class OutboxEventPublisherTest { private OutboxReader outboxReader; @Mock - private EventTaskCreator eventTaskCreator; - - @Mock - private MemberReader memberReader; + private OutboxEventPublishExecutor outboxEventPublishExecutor; @InjectMocks private OutboxEventPublisher outboxEventPublisher; @@ -40,37 +34,29 @@ class OutboxEventPublisherTest { @Test @DisplayName("PENDING 아웃박스 이벤트를 publish하면 태스크를 추가하고 published 상태로 변경한다.") void publishPendingOutboxEvent() { - OutboxEvent outboxEvent = mock(OutboxEvent.class); - Member member = mock(Member.class); - when(outboxReader.findById(1L)).thenReturn(outboxEvent); - when(outboxEvent.getStatus()).thenReturn(OutboxEventStatus.PENDING); - when(outboxEvent.getEventType()).thenReturn(OutboxEventType.SETTLEMENT_COMPLETED); - when(outboxEvent.getAggregateId()).thenReturn(10L); - when(memberReader.findAssignedMembersBySettlementId(10L)).thenReturn(List.of(member)); - when(member.getUserId()).thenReturn(20L); + when(outboxEventPublishExecutor.claimProcessing(1L)).thenReturn(true); outboxEventPublisher.publish(1L); - verify(eventTaskCreator).create(outboxEvent, EventTaskType.REWARD_GRANT, 20L); - verify(eventTaskCreator).create(outboxEvent, EventTaskType.NOTIFICATION_SEND, 20L); - verify(outboxEvent).markPublished(); - verify(outboxEvent, never()).markFailed(); + verify(outboxEventPublishExecutor).appendTasks(1L); + verify(outboxEventPublishExecutor).markPublished(1L); + verify(outboxEventPublishExecutor, never()).markFailed(1L); } @Test @DisplayName("태스크 추가 중 예외가 발생하면 failed 상태로 변경한다.") void markFailedWhenAppendTaskThrowsException() { OutboxEvent outboxEvent = mock(OutboxEvent.class); + when(outboxEventPublishExecutor.claimProcessing(1L)).thenReturn(true); when(outboxReader.findById(1L)).thenReturn(outboxEvent); - when(outboxEvent.getStatus()).thenReturn(OutboxEventStatus.PENDING); when(outboxEvent.getEventType()).thenReturn(OutboxEventType.SETTLEMENT_COMPLETED); when(outboxEvent.getAggregateId()).thenReturn(10L); - doThrow(new RuntimeException("append failed")).when(memberReader).findAssignedMembersBySettlementId(10L); + doThrow(new RuntimeException("append failed")).when(outboxEventPublishExecutor).appendTasks(1L); outboxEventPublisher.publish(1L); - verify(outboxEvent).markFailed(); - verify(outboxEvent, never()).markPublished(); + verify(outboxEventPublishExecutor).markFailed(1L); + verify(outboxEventPublishExecutor, never()).markPublished(1L); } @Test @@ -81,34 +67,27 @@ void publishPendingEvents() { when(first.getId()).thenReturn(1L); when(second.getId()).thenReturn(2L); when(outboxReader.findAllByStatus(OutboxEventStatus.PENDING)).thenReturn(List.of(first, second)); - when(outboxReader.findById(1L)).thenReturn(first); - when(outboxReader.findById(2L)).thenReturn(second); - when(first.getStatus()).thenReturn(OutboxEventStatus.PENDING); - when(second.getStatus()).thenReturn(OutboxEventStatus.PENDING); - when(first.getEventType()).thenReturn(OutboxEventType.SETTLEMENT_COMPLETED); - when(second.getEventType()).thenReturn(OutboxEventType.SETTLEMENT_COMPLETED); - when(first.getAggregateId()).thenReturn(10L); - when(second.getAggregateId()).thenReturn(20L); - when(memberReader.findAssignedMembersBySettlementId(10L)).thenReturn(List.of()); - when(memberReader.findAssignedMembersBySettlementId(20L)).thenReturn(List.of()); + when(outboxEventPublishExecutor.claimProcessing(1L)).thenReturn(true); + when(outboxEventPublishExecutor.claimProcessing(2L)).thenReturn(true); outboxEventPublisher.publishPendingEvents(); - verify(first).markPublished(); - verify(second).markPublished(); + verify(outboxEventPublishExecutor).appendTasks(1L); + verify(outboxEventPublishExecutor).markPublished(1L); + verify(outboxEventPublishExecutor).appendTasks(2L); + verify(outboxEventPublishExecutor).markPublished(2L); } @Test - @DisplayName("pending 상태가 아니면 publish를 건너뛴다.") + @DisplayName("이미 다른 트랜잭션이 선점했으면 publish를 건너뛴다.") void skipWhenOutboxEventAlreadyProcessed() { - OutboxEvent outboxEvent = mock(OutboxEvent.class); - when(outboxReader.findById(1L)).thenReturn(outboxEvent); - when(outboxEvent.getStatus()).thenReturn(OutboxEventStatus.PUBLISHED); + when(outboxEventPublishExecutor.claimProcessing(1L)).thenReturn(false); outboxEventPublisher.publish(1L); - verifyNoInteractions(eventTaskCreator, memberReader); - verify(outboxEvent, never()).markPublished(); - verify(outboxEvent, never()).markFailed(); + verifyNoInteractions(outboxReader); + verify(outboxEventPublishExecutor, never()).appendTasks(1L); + verify(outboxEventPublishExecutor, never()).markPublished(1L); + verify(outboxEventPublishExecutor, never()).markFailed(1L); } } From f46e2000759339ea334d0cdc1b59557613bf96a2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9B=90=EC=A7=80=EC=9C=A4?= Date: Tue, 24 Mar 2026 22:10:01 +0900 Subject: [PATCH 09/11] =?UTF-8?q?test:=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20?= =?UTF-8?q?=EC=BD=94=EB=93=9C=20=EC=B6=94=EA=B0=80=20=EB=B0=8F=20=EC=BD=94?= =?UTF-8?q?=EB=93=9C=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../impl/OutboxEventPublisher.java | 15 +-- .../impl/OutboxEventScheduler.java | 17 +++ .../service/OutboxEventCreatedEventTest.java | 31 +++++ .../OutboxEventPublishExecutorTest.java | 113 ++++++++++++++++++ .../OutboxEventPublisherTest.java | 23 ++-- .../OutboxEventSchedulerTest.java | 31 +++++ 6 files changed, 216 insertions(+), 14 deletions(-) create mode 100644 src/main/java/com/dnd/moddo/outbox/application/impl/OutboxEventScheduler.java create mode 100644 src/test/java/com/dnd/moddo/domain/outbox/service/OutboxEventCreatedEventTest.java create mode 100644 src/test/java/com/dnd/moddo/domain/outbox/service/implementation/OutboxEventPublishExecutorTest.java create mode 100644 src/test/java/com/dnd/moddo/domain/outbox/service/implementation/OutboxEventSchedulerTest.java diff --git a/src/main/java/com/dnd/moddo/outbox/application/impl/OutboxEventPublisher.java b/src/main/java/com/dnd/moddo/outbox/application/impl/OutboxEventPublisher.java index 4a2b1901..9821a7ee 100644 --- a/src/main/java/com/dnd/moddo/outbox/application/impl/OutboxEventPublisher.java +++ b/src/main/java/com/dnd/moddo/outbox/application/impl/OutboxEventPublisher.java @@ -36,13 +36,14 @@ public void publish(Long outboxEventId) { outboxEventPublishExecutor.appendTasks(outboxEventId); outboxEventPublishExecutor.markPublished(outboxEventId); } catch (Exception exception) { - OutboxEvent outboxEvent = outboxReader.findById(outboxEventId); - log.error("Failed to publish outbox event. outboxEventId={}, eventType={}, aggregateId={}", - outboxEvent.getId(), - outboxEvent.getEventType(), - outboxEvent.getAggregateId(), - exception); - outboxEventPublishExecutor.markFailed(outboxEventId); + log.error("Failed to publish outbox event. outboxEventId={}", outboxEventId, exception); + try { + outboxEventPublishExecutor.markFailed(outboxEventId); + } catch (Exception markFailedException) { + log.error("Failed to mark outbox event as FAILED. outboxEventId={}", outboxEventId, + markFailedException); + throw markFailedException; + } } } } diff --git a/src/main/java/com/dnd/moddo/outbox/application/impl/OutboxEventScheduler.java b/src/main/java/com/dnd/moddo/outbox/application/impl/OutboxEventScheduler.java new file mode 100644 index 00000000..b2409c5f --- /dev/null +++ b/src/main/java/com/dnd/moddo/outbox/application/impl/OutboxEventScheduler.java @@ -0,0 +1,17 @@ +package com.dnd.moddo.outbox.application.impl; + +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; + +import lombok.RequiredArgsConstructor; + +@Component +@RequiredArgsConstructor +public class OutboxEventScheduler { + private final OutboxEventPublisher outboxEventPublisher; + + @Scheduled(fixedDelay = 5000) + public void publishPendingEvents() { + outboxEventPublisher.publishPendingEvents(); + } +} diff --git a/src/test/java/com/dnd/moddo/domain/outbox/service/OutboxEventCreatedEventTest.java b/src/test/java/com/dnd/moddo/domain/outbox/service/OutboxEventCreatedEventTest.java new file mode 100644 index 00000000..f134b2be --- /dev/null +++ b/src/test/java/com/dnd/moddo/domain/outbox/service/OutboxEventCreatedEventTest.java @@ -0,0 +1,31 @@ +package com.dnd.moddo.domain.outbox.service; + +import static org.assertj.core.api.Assertions.*; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import com.dnd.moddo.outbox.application.event.OutboxEventCreatedEvent; + +class OutboxEventCreatedEventTest { + + @Test + @DisplayName("양수 outboxEventId로 이벤트를 생성할 수 있다.") + void createEvent() { + OutboxEventCreatedEvent event = new OutboxEventCreatedEvent(1L); + + assertThat(event.outboxEventId()).isEqualTo(1L); + } + + @Test + @DisplayName("0 이하 outboxEventId로 이벤트를 생성하면 예외가 발생한다.") + void throwExceptionWhenOutboxEventIdIsNotPositive() { + assertThatThrownBy(() -> new OutboxEventCreatedEvent(0L)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("outboxEventId가 0이상이어야 합니다."); + + assertThatThrownBy(() -> new OutboxEventCreatedEvent(-1L)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("outboxEventId가 0이상이어야 합니다."); + } +} diff --git a/src/test/java/com/dnd/moddo/domain/outbox/service/implementation/OutboxEventPublishExecutorTest.java b/src/test/java/com/dnd/moddo/domain/outbox/service/implementation/OutboxEventPublishExecutorTest.java new file mode 100644 index 00000000..61317115 --- /dev/null +++ b/src/test/java/com/dnd/moddo/domain/outbox/service/implementation/OutboxEventPublishExecutorTest.java @@ -0,0 +1,113 @@ +package com.dnd.moddo.domain.outbox.service.implementation; + +import static org.mockito.Mockito.*; + +import java.util.List; + +import org.junit.jupiter.api.DisplayName; +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 com.dnd.moddo.event.application.impl.MemberReader; +import com.dnd.moddo.event.domain.member.Member; +import com.dnd.moddo.outbox.application.impl.EventTaskCreator; +import com.dnd.moddo.outbox.application.impl.OutboxEventPublishExecutor; +import com.dnd.moddo.outbox.application.impl.OutboxReader; +import com.dnd.moddo.outbox.domain.event.OutboxEvent; +import com.dnd.moddo.outbox.domain.event.type.OutboxEventStatus; +import com.dnd.moddo.outbox.domain.event.type.OutboxEventType; +import com.dnd.moddo.outbox.domain.task.type.EventTaskType; +import com.dnd.moddo.outbox.infrastructure.OutboxEventRepository; + +@ExtendWith(MockitoExtension.class) +class OutboxEventPublishExecutorTest { + + @Mock + private OutboxReader outboxReader; + + @Mock + private EventTaskCreator eventTaskCreator; + + @Mock + private MemberReader memberReader; + + @Mock + private OutboxEventRepository outboxEventRepository; + + @InjectMocks + private OutboxEventPublishExecutor outboxEventPublishExecutor; + + @Test + @DisplayName("claimProcessing은 선점 update 결과를 boolean으로 반환한다.") + void claimProcessing() { + when(outboxEventRepository.claimProcessing(1L, OutboxEventStatus.PROCESSING, OutboxEventStatus.PENDING)) + .thenReturn(1) + .thenReturn(0); + + boolean claimed = outboxEventPublishExecutor.claimProcessing(1L); + boolean notClaimed = outboxEventPublishExecutor.claimProcessing(1L); + + org.assertj.core.api.Assertions.assertThat(claimed).isTrue(); + org.assertj.core.api.Assertions.assertThat(notClaimed).isFalse(); + } + + @Test + @DisplayName("SETTLEMENT_COMPLETED 이벤트는 구성원별 태스크를 생성한다.") + void appendTasksForSettlementCompleted() { + OutboxEvent outboxEvent = mock(OutboxEvent.class); + Member first = mock(Member.class); + Member second = mock(Member.class); + when(outboxReader.findById(1L)).thenReturn(outboxEvent); + when(outboxEvent.getEventType()).thenReturn(OutboxEventType.SETTLEMENT_COMPLETED); + when(outboxEvent.getAggregateId()).thenReturn(10L); + when(memberReader.findAssignedMembersBySettlementId(10L)).thenReturn(List.of(first, second)); + when(first.getUserId()).thenReturn(20L); + when(second.getUserId()).thenReturn(30L); + + outboxEventPublishExecutor.appendTasks(1L); + + verify(eventTaskCreator).create(outboxEvent, EventTaskType.REWARD_GRANT, 20L); + verify(eventTaskCreator).create(outboxEvent, EventTaskType.NOTIFICATION_SEND, 20L); + verify(eventTaskCreator).create(outboxEvent, EventTaskType.REWARD_GRANT, 30L); + verify(eventTaskCreator).create(outboxEvent, EventTaskType.NOTIFICATION_SEND, 30L); + } + + @Test + @DisplayName("할당된 구성원이 없으면 태스크를 추가하지 않는다.") + void skipAppendTasksWhenNoAssignedMembers() { + OutboxEvent outboxEvent = mock(OutboxEvent.class); + when(outboxReader.findById(1L)).thenReturn(outboxEvent); + when(outboxEvent.getEventType()).thenReturn(OutboxEventType.SETTLEMENT_COMPLETED); + when(outboxEvent.getAggregateId()).thenReturn(10L); + when(memberReader.findAssignedMembersBySettlementId(10L)).thenReturn(List.of()); + + outboxEventPublishExecutor.appendTasks(1L); + + verifyNoInteractions(eventTaskCreator); + } + + @Test + @DisplayName("markPublished는 아웃박스 이벤트를 published 상태로 변경한다.") + void markPublished() { + OutboxEvent outboxEvent = mock(OutboxEvent.class); + when(outboxReader.findById(1L)).thenReturn(outboxEvent); + + outboxEventPublishExecutor.markPublished(1L); + + verify(outboxEvent).markPublished(); + } + + @Test + @DisplayName("markFailed는 아웃박스 이벤트를 failed 상태로 변경한다.") + void markFailed() { + OutboxEvent outboxEvent = mock(OutboxEvent.class); + when(outboxReader.findById(1L)).thenReturn(outboxEvent); + + outboxEventPublishExecutor.markFailed(1L); + + verify(outboxEvent).markFailed(); + } +} diff --git a/src/test/java/com/dnd/moddo/domain/outbox/service/implementation/OutboxEventPublisherTest.java b/src/test/java/com/dnd/moddo/domain/outbox/service/implementation/OutboxEventPublisherTest.java index 9fd15e33..347c019d 100644 --- a/src/test/java/com/dnd/moddo/domain/outbox/service/implementation/OutboxEventPublisherTest.java +++ b/src/test/java/com/dnd/moddo/domain/outbox/service/implementation/OutboxEventPublisherTest.java @@ -1,5 +1,6 @@ package com.dnd.moddo.domain.outbox.service.implementation; +import static org.assertj.core.api.Assertions.*; import static org.mockito.Mockito.*; import java.util.List; @@ -7,7 +8,6 @@ import org.junit.jupiter.api.DisplayName; 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; @@ -17,7 +17,6 @@ import com.dnd.moddo.outbox.application.impl.OutboxEventPublisher; import com.dnd.moddo.outbox.domain.event.OutboxEvent; import com.dnd.moddo.outbox.domain.event.type.OutboxEventStatus; -import com.dnd.moddo.outbox.domain.event.type.OutboxEventType; @ExtendWith(MockitoExtension.class) class OutboxEventPublisherTest { @@ -46,11 +45,7 @@ void publishPendingOutboxEvent() { @Test @DisplayName("태스크 추가 중 예외가 발생하면 failed 상태로 변경한다.") void markFailedWhenAppendTaskThrowsException() { - OutboxEvent outboxEvent = mock(OutboxEvent.class); when(outboxEventPublishExecutor.claimProcessing(1L)).thenReturn(true); - when(outboxReader.findById(1L)).thenReturn(outboxEvent); - when(outboxEvent.getEventType()).thenReturn(OutboxEventType.SETTLEMENT_COMPLETED); - when(outboxEvent.getAggregateId()).thenReturn(10L); doThrow(new RuntimeException("append failed")).when(outboxEventPublishExecutor).appendTasks(1L); outboxEventPublisher.publish(1L); @@ -85,9 +80,23 @@ void skipWhenOutboxEventAlreadyProcessed() { outboxEventPublisher.publish(1L); - verifyNoInteractions(outboxReader); verify(outboxEventPublishExecutor, never()).appendTasks(1L); verify(outboxEventPublishExecutor, never()).markPublished(1L); verify(outboxEventPublishExecutor, never()).markFailed(1L); } + + @Test + @DisplayName("failed 상태 변경에도 실패하면 예외를 다시 던진다.") + void throwWhenMarkFailedThrowsException() { + when(outboxEventPublishExecutor.claimProcessing(1L)).thenReturn(true); + doThrow(new RuntimeException("append failed")).when(outboxEventPublishExecutor).appendTasks(1L); + doThrow(new RuntimeException("mark failed")).when(outboxEventPublishExecutor).markFailed(1L); + + assertThatThrownBy(() -> outboxEventPublisher.publish(1L)) + .isInstanceOf(RuntimeException.class) + .hasMessage("mark failed"); + + verify(outboxEventPublishExecutor).markFailed(1L); + verify(outboxEventPublishExecutor, never()).markPublished(1L); + } } diff --git a/src/test/java/com/dnd/moddo/domain/outbox/service/implementation/OutboxEventSchedulerTest.java b/src/test/java/com/dnd/moddo/domain/outbox/service/implementation/OutboxEventSchedulerTest.java new file mode 100644 index 00000000..df81df54 --- /dev/null +++ b/src/test/java/com/dnd/moddo/domain/outbox/service/implementation/OutboxEventSchedulerTest.java @@ -0,0 +1,31 @@ +package com.dnd.moddo.domain.outbox.service.implementation; + +import static org.mockito.Mockito.*; + +import org.junit.jupiter.api.DisplayName; +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 com.dnd.moddo.outbox.application.impl.OutboxEventPublisher; +import com.dnd.moddo.outbox.application.impl.OutboxEventScheduler; + +@ExtendWith(MockitoExtension.class) +class OutboxEventSchedulerTest { + + @Mock + private OutboxEventPublisher outboxEventPublisher; + + @InjectMocks + private OutboxEventScheduler outboxEventScheduler; + + @Test + @DisplayName("스케줄러는 pending 아웃박스 이벤트 발행을 주기적으로 위임한다.") + void publishPendingEvents() { + outboxEventScheduler.publishPendingEvents(); + + verify(outboxEventPublisher).publishPendingEvents(); + } +} From 0e01654b341c7c9c36246f5d80ee83a13dde6fd3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9B=90=EC=A7=80=EC=9C=A4?= Date: Tue, 24 Mar 2026 22:57:45 +0900 Subject: [PATCH 10/11] =?UTF-8?q?refactor:=20outbox=ED=8C=A8=ED=84=B4=20?= =?UTF-8?q?=EA=B5=AC=EC=A1=B0=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../application/impl/EventTaskCreator.java | 8 +++++- .../impl/OutboxEventPublishExecutor.java | 14 ++++++++-- .../impl/OutboxEventPublisher.java | 21 +++++++++----- .../outbox/application/impl/OutboxReader.java | 8 +++--- .../moddo/outbox/domain/task/EventTask.java | 1 + .../infrastructure/OutboxEventRepository.java | 6 ++-- .../domain/outbox/entity/EventTaskTest.java | 16 +++++++++++ .../implementation/EventTaskCreatorTest.java | 19 +++++++++++-- .../OutboxEventPublishExecutorTest.java | 20 +++++++++---- .../OutboxEventPublisherTest.java | 28 ++++++++++++++++++- .../implementation/OutboxReaderTest.java | 17 ++++++----- 11 files changed, 126 insertions(+), 32 deletions(-) diff --git a/src/main/java/com/dnd/moddo/outbox/application/impl/EventTaskCreator.java b/src/main/java/com/dnd/moddo/outbox/application/impl/EventTaskCreator.java index 1906796f..c2618bd3 100644 --- a/src/main/java/com/dnd/moddo/outbox/application/impl/EventTaskCreator.java +++ b/src/main/java/com/dnd/moddo/outbox/application/impl/EventTaskCreator.java @@ -1,5 +1,7 @@ package com.dnd.moddo.outbox.application.impl; +import java.util.List; + import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -17,6 +19,10 @@ public class EventTaskCreator { private final EventTaskRepository eventTaskRepository; public EventTask create(OutboxEvent outboxEvent, EventTaskType taskType, Long targetUserId) { - return eventTaskRepository.saveAndFlush(EventTask.pending(outboxEvent, taskType, targetUserId)); + return eventTaskRepository.save(EventTask.pending(outboxEvent, taskType, targetUserId)); + } + + public List createAll(List eventTasks) { + return eventTaskRepository.saveAll(eventTasks); } } diff --git a/src/main/java/com/dnd/moddo/outbox/application/impl/OutboxEventPublishExecutor.java b/src/main/java/com/dnd/moddo/outbox/application/impl/OutboxEventPublishExecutor.java index 3d930d1b..2a0aebcf 100644 --- a/src/main/java/com/dnd/moddo/outbox/application/impl/OutboxEventPublishExecutor.java +++ b/src/main/java/com/dnd/moddo/outbox/application/impl/OutboxEventPublishExecutor.java @@ -1,5 +1,8 @@ package com.dnd.moddo.outbox.application.impl; +import java.util.ArrayList; +import java.util.List; + import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Propagation; import org.springframework.transaction.annotation.Transactional; @@ -9,6 +12,7 @@ import com.dnd.moddo.outbox.domain.event.OutboxEvent; import com.dnd.moddo.outbox.domain.event.type.OutboxEventStatus; import com.dnd.moddo.outbox.domain.event.type.OutboxEventType; +import com.dnd.moddo.outbox.domain.task.EventTask; import com.dnd.moddo.outbox.domain.task.type.EventTaskType; import com.dnd.moddo.outbox.infrastructure.OutboxEventRepository; @@ -53,10 +57,16 @@ public void markFailed(Long outboxEventId) { } private void appendSettlementCompletedTasks(OutboxEvent outboxEvent) { + List eventTasks = new ArrayList<>(); + for (Member member : memberReader.findAssignedMembersBySettlementId(outboxEvent.getAggregateId())) { Long targetUserId = member.getUserId(); - eventTaskCreator.create(outboxEvent, EventTaskType.REWARD_GRANT, targetUserId); - eventTaskCreator.create(outboxEvent, EventTaskType.NOTIFICATION_SEND, targetUserId); + eventTasks.add(EventTask.pending(outboxEvent, EventTaskType.REWARD_GRANT, targetUserId)); + eventTasks.add(EventTask.pending(outboxEvent, EventTaskType.NOTIFICATION_SEND, targetUserId)); + } + + if (!eventTasks.isEmpty()) { + eventTaskCreator.createAll(eventTasks); } } } diff --git a/src/main/java/com/dnd/moddo/outbox/application/impl/OutboxEventPublisher.java b/src/main/java/com/dnd/moddo/outbox/application/impl/OutboxEventPublisher.java index 9821a7ee..e01e6204 100644 --- a/src/main/java/com/dnd/moddo/outbox/application/impl/OutboxEventPublisher.java +++ b/src/main/java/com/dnd/moddo/outbox/application/impl/OutboxEventPublisher.java @@ -1,9 +1,8 @@ package com.dnd.moddo.outbox.application.impl; -import java.util.List; - +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Slice; import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; import com.dnd.moddo.outbox.domain.event.OutboxEvent; import com.dnd.moddo.outbox.domain.event.type.OutboxEventStatus; @@ -15,15 +14,23 @@ @RequiredArgsConstructor @Slf4j public class OutboxEventPublisher { + private static final int PENDING_OUTBOX_BATCH_SIZE = 100; + private final OutboxReader outboxReader; private final OutboxEventPublishExecutor outboxEventPublishExecutor; - @Transactional public void publishPendingEvents() { - List pendingEvents = outboxReader.findAllByStatus(OutboxEventStatus.PENDING); + Slice pendingEvents = outboxReader.findByStatus( + OutboxEventStatus.PENDING, + PageRequest.of(0, PENDING_OUTBOX_BATCH_SIZE) + ); - for (OutboxEvent outboxEvent : pendingEvents) { - publish(outboxEvent.getId()); + for (OutboxEvent outboxEvent : pendingEvents.getContent()) { + try { + publish(outboxEvent.getId()); + } catch (Exception e) { + log.error("Error publishing event to outbox publisher. outboxId={}", outboxEvent.getId(), e); + } } } diff --git a/src/main/java/com/dnd/moddo/outbox/application/impl/OutboxReader.java b/src/main/java/com/dnd/moddo/outbox/application/impl/OutboxReader.java index 3c4d273f..a6404446 100644 --- a/src/main/java/com/dnd/moddo/outbox/application/impl/OutboxReader.java +++ b/src/main/java/com/dnd/moddo/outbox/application/impl/OutboxReader.java @@ -1,7 +1,7 @@ package com.dnd.moddo.outbox.application.impl; -import java.util.List; - +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Slice; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -17,8 +17,8 @@ public class OutboxReader { private final OutboxEventRepository outboxEventRepository; - public List findAllByStatus(OutboxEventStatus status) { - return outboxEventRepository.findAllByStatus(status); + public Slice findByStatus(OutboxEventStatus status, Pageable pageable) { + return outboxEventRepository.findByStatusOrderByIdAsc(status, pageable); } public OutboxEvent findById(Long outboxEventId) { diff --git a/src/main/java/com/dnd/moddo/outbox/domain/task/EventTask.java b/src/main/java/com/dnd/moddo/outbox/domain/task/EventTask.java index 6fad66b5..7fb89de8 100644 --- a/src/main/java/com/dnd/moddo/outbox/domain/task/EventTask.java +++ b/src/main/java/com/dnd/moddo/outbox/domain/task/EventTask.java @@ -92,6 +92,7 @@ public void markFailed(String errorMessage) { public void markDead(String errorMessage) { this.status = EventTaskStatus.DEAD; + this.processedAt = LocalDateTime.now(); this.lastErrorMessage = errorMessage; } } diff --git a/src/main/java/com/dnd/moddo/outbox/infrastructure/OutboxEventRepository.java b/src/main/java/com/dnd/moddo/outbox/infrastructure/OutboxEventRepository.java index 0ba4a378..bf8f6607 100644 --- a/src/main/java/com/dnd/moddo/outbox/infrastructure/OutboxEventRepository.java +++ b/src/main/java/com/dnd/moddo/outbox/infrastructure/OutboxEventRepository.java @@ -1,7 +1,7 @@ package com.dnd.moddo.outbox.infrastructure; -import java.util.List; - +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Slice; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.Modifying; import org.springframework.data.jpa.repository.Query; @@ -13,7 +13,7 @@ import jakarta.persistence.EntityNotFoundException; public interface OutboxEventRepository extends JpaRepository { - List findAllByStatus(OutboxEventStatus status); + Slice findByStatusOrderByIdAsc(OutboxEventStatus status, Pageable pageable); @Modifying(clearAutomatically = true, flushAutomatically = true) @Query(""" diff --git a/src/test/java/com/dnd/moddo/domain/outbox/entity/EventTaskTest.java b/src/test/java/com/dnd/moddo/domain/outbox/entity/EventTaskTest.java index 20bdaed6..87e205db 100644 --- a/src/test/java/com/dnd/moddo/domain/outbox/entity/EventTaskTest.java +++ b/src/test/java/com/dnd/moddo/domain/outbox/entity/EventTaskTest.java @@ -78,4 +78,20 @@ void markFailed() { assertThat(eventTask.getAttemptCount()).isEqualTo(1); assertThat(eventTask.getLastErrorMessage()).isEqualTo("failed"); } + + @Test + @DisplayName("dead 처리되면 dead 상태와 처리 시각, 오류 메시지가 기록된다.") + void markDead() { + EventTask eventTask = EventTask.pending( + OutboxEvent.pending(OutboxEventType.SETTLEMENT_COMPLETED, AggregateType.SETTLEMENT, 1L), + EventTaskType.REWARD_GRANT, + 10L + ); + + eventTask.markDead("dead"); + + assertThat(eventTask.getStatus()).isEqualTo(EventTaskStatus.DEAD); + assertThat(eventTask.getProcessedAt()).isNotNull(); + assertThat(eventTask.getLastErrorMessage()).isEqualTo("dead"); + } } diff --git a/src/test/java/com/dnd/moddo/domain/outbox/service/implementation/EventTaskCreatorTest.java b/src/test/java/com/dnd/moddo/domain/outbox/service/implementation/EventTaskCreatorTest.java index 78b96dd6..93c20d6d 100644 --- a/src/test/java/com/dnd/moddo/domain/outbox/service/implementation/EventTaskCreatorTest.java +++ b/src/test/java/com/dnd/moddo/domain/outbox/service/implementation/EventTaskCreatorTest.java @@ -3,6 +3,8 @@ import static org.assertj.core.api.Assertions.*; import static org.mockito.Mockito.*; +import java.util.List; + import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; @@ -31,15 +33,28 @@ class EventTaskCreatorTest { void create() { OutboxEvent outboxEvent = mock(OutboxEvent.class); EventTask savedTask = mock(EventTask.class); - when(eventTaskRepository.saveAndFlush(any(EventTask.class))).thenReturn(savedTask); + when(eventTaskRepository.save(any(EventTask.class))).thenReturn(savedTask); EventTask result = eventTaskCreator.create(outboxEvent, EventTaskType.REWARD_GRANT, 20L); ArgumentCaptor captor = ArgumentCaptor.forClass(EventTask.class); - verify(eventTaskRepository).saveAndFlush(captor.capture()); + verify(eventTaskRepository).save(captor.capture()); assertThat(captor.getValue().getOutboxEvent()).isEqualTo(outboxEvent); assertThat(captor.getValue().getTaskType()).isEqualTo(EventTaskType.REWARD_GRANT); assertThat(captor.getValue().getTargetUserId()).isEqualTo(20L); assertThat(result).isEqualTo(savedTask); } + + @Test + @DisplayName("이벤트 태스크 목록을 배치 저장한다.") + void createAll() { + EventTask first = mock(EventTask.class); + EventTask second = mock(EventTask.class); + when(eventTaskRepository.saveAll(List.of(first, second))).thenReturn(List.of(first, second)); + + List result = eventTaskCreator.createAll(List.of(first, second)); + + verify(eventTaskRepository).saveAll(List.of(first, second)); + assertThat(result).containsExactly(first, second); + } } diff --git a/src/test/java/com/dnd/moddo/domain/outbox/service/implementation/OutboxEventPublishExecutorTest.java b/src/test/java/com/dnd/moddo/domain/outbox/service/implementation/OutboxEventPublishExecutorTest.java index 61317115..ad44e6f0 100644 --- a/src/test/java/com/dnd/moddo/domain/outbox/service/implementation/OutboxEventPublishExecutorTest.java +++ b/src/test/java/com/dnd/moddo/domain/outbox/service/implementation/OutboxEventPublishExecutorTest.java @@ -1,5 +1,6 @@ package com.dnd.moddo.domain.outbox.service.implementation; +import static org.assertj.core.api.Assertions.*; import static org.mockito.Mockito.*; import java.util.List; @@ -7,6 +8,7 @@ import org.junit.jupiter.api.DisplayName; 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; @@ -17,6 +19,7 @@ import com.dnd.moddo.outbox.application.impl.OutboxEventPublishExecutor; import com.dnd.moddo.outbox.application.impl.OutboxReader; import com.dnd.moddo.outbox.domain.event.OutboxEvent; +import com.dnd.moddo.outbox.domain.task.EventTask; import com.dnd.moddo.outbox.domain.event.type.OutboxEventStatus; import com.dnd.moddo.outbox.domain.event.type.OutboxEventType; import com.dnd.moddo.outbox.domain.task.type.EventTaskType; @@ -69,10 +72,17 @@ void appendTasksForSettlementCompleted() { outboxEventPublishExecutor.appendTasks(1L); - verify(eventTaskCreator).create(outboxEvent, EventTaskType.REWARD_GRANT, 20L); - verify(eventTaskCreator).create(outboxEvent, EventTaskType.NOTIFICATION_SEND, 20L); - verify(eventTaskCreator).create(outboxEvent, EventTaskType.REWARD_GRANT, 30L); - verify(eventTaskCreator).create(outboxEvent, EventTaskType.NOTIFICATION_SEND, 30L); + ArgumentCaptor> captor = ArgumentCaptor.forClass(List.class); + verify(eventTaskCreator).createAll(captor.capture()); + assertThat(captor.getValue()).hasSize(4); + assertThat(captor.getValue()) + .extracting(EventTask::getTaskType, EventTask::getTargetUserId) + .containsExactly( + tuple(EventTaskType.REWARD_GRANT, 20L), + tuple(EventTaskType.NOTIFICATION_SEND, 20L), + tuple(EventTaskType.REWARD_GRANT, 30L), + tuple(EventTaskType.NOTIFICATION_SEND, 30L) + ); } @Test @@ -86,7 +96,7 @@ void skipAppendTasksWhenNoAssignedMembers() { outboxEventPublishExecutor.appendTasks(1L); - verifyNoInteractions(eventTaskCreator); + verify(eventTaskCreator, never()).createAll(anyList()); } @Test diff --git a/src/test/java/com/dnd/moddo/domain/outbox/service/implementation/OutboxEventPublisherTest.java b/src/test/java/com/dnd/moddo/domain/outbox/service/implementation/OutboxEventPublisherTest.java index 347c019d..4afba791 100644 --- a/src/test/java/com/dnd/moddo/domain/outbox/service/implementation/OutboxEventPublisherTest.java +++ b/src/test/java/com/dnd/moddo/domain/outbox/service/implementation/OutboxEventPublisherTest.java @@ -11,6 +11,9 @@ import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Slice; import com.dnd.moddo.outbox.application.impl.OutboxReader; import com.dnd.moddo.outbox.application.impl.OutboxEventPublishExecutor; @@ -59,9 +62,10 @@ void markFailedWhenAppendTaskThrowsException() { void publishPendingEvents() { OutboxEvent first = mock(OutboxEvent.class); OutboxEvent second = mock(OutboxEvent.class); + Slice pendingEvents = new PageImpl<>(List.of(first, second), PageRequest.of(0, 100), 2); when(first.getId()).thenReturn(1L); when(second.getId()).thenReturn(2L); - when(outboxReader.findAllByStatus(OutboxEventStatus.PENDING)).thenReturn(List.of(first, second)); + when(outboxReader.findByStatus(OutboxEventStatus.PENDING, PageRequest.of(0, 100))).thenReturn(pendingEvents); when(outboxEventPublishExecutor.claimProcessing(1L)).thenReturn(true); when(outboxEventPublishExecutor.claimProcessing(2L)).thenReturn(true); @@ -73,6 +77,28 @@ void publishPendingEvents() { verify(outboxEventPublishExecutor).markPublished(2L); } + @Test + @DisplayName("배치 발행 중 한 이벤트가 실패해도 다음 pending 이벤트 처리를 계속한다.") + void continuePublishingPendingEventsWhenOneEventFails() { + OutboxEvent first = mock(OutboxEvent.class); + OutboxEvent second = mock(OutboxEvent.class); + Slice pendingEvents = new PageImpl<>(List.of(first, second), PageRequest.of(0, 100), 2); + when(first.getId()).thenReturn(1L); + when(second.getId()).thenReturn(2L); + when(outboxReader.findByStatus(OutboxEventStatus.PENDING, PageRequest.of(0, 100))).thenReturn(pendingEvents); + when(outboxEventPublishExecutor.claimProcessing(1L)).thenReturn(true); + when(outboxEventPublishExecutor.claimProcessing(2L)).thenReturn(true); + doThrow(new RuntimeException("append failed")).when(outboxEventPublishExecutor).appendTasks(1L); + doThrow(new RuntimeException("mark failed")).when(outboxEventPublishExecutor).markFailed(1L); + + outboxEventPublisher.publishPendingEvents(); + + verify(outboxEventPublishExecutor).appendTasks(1L); + verify(outboxEventPublishExecutor).markFailed(1L); + verify(outboxEventPublishExecutor).appendTasks(2L); + verify(outboxEventPublishExecutor).markPublished(2L); + } + @Test @DisplayName("이미 다른 트랜잭션이 선점했으면 publish를 건너뛴다.") void skipWhenOutboxEventAlreadyProcessed() { diff --git a/src/test/java/com/dnd/moddo/domain/outbox/service/implementation/OutboxReaderTest.java b/src/test/java/com/dnd/moddo/domain/outbox/service/implementation/OutboxReaderTest.java index 166ee28a..29aeca5b 100644 --- a/src/test/java/com/dnd/moddo/domain/outbox/service/implementation/OutboxReaderTest.java +++ b/src/test/java/com/dnd/moddo/domain/outbox/service/implementation/OutboxReaderTest.java @@ -3,14 +3,15 @@ import static org.assertj.core.api.Assertions.*; import static org.mockito.Mockito.*; -import java.util.List; - import org.junit.jupiter.api.DisplayName; 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.PageImpl; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Slice; import com.dnd.moddo.outbox.application.impl.OutboxReader; import com.dnd.moddo.outbox.domain.event.OutboxEvent; @@ -27,15 +28,17 @@ class OutboxReaderTest { private OutboxReader outboxReader; @Test - @DisplayName("상태로 아웃박스 이벤트 목록을 조회한다.") - void findAllByStatus() { + @DisplayName("상태로 아웃박스 이벤트 목록을 배치 조회한다.") + void findByStatus() { OutboxEvent first = mock(OutboxEvent.class); OutboxEvent second = mock(OutboxEvent.class); - when(outboxEventRepository.findAllByStatus(OutboxEventStatus.PENDING)).thenReturn(List.of(first, second)); + PageRequest pageRequest = PageRequest.of(0, 100); + Slice events = new PageImpl<>(java.util.List.of(first, second), pageRequest, 2); + when(outboxEventRepository.findByStatusOrderByIdAsc(OutboxEventStatus.PENDING, pageRequest)).thenReturn(events); - List result = outboxReader.findAllByStatus(OutboxEventStatus.PENDING); + Slice result = outboxReader.findByStatus(OutboxEventStatus.PENDING, pageRequest); - assertThat(result).containsExactly(first, second); + assertThat(result.getContent()).containsExactly(first, second); } @Test From c18e5ca8b7cec7c843039e9f93106e278807095c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9B=90=EC=A7=80=EC=9C=A4?= Date: Tue, 24 Mar 2026 23:00:35 +0900 Subject: [PATCH 11/11] =?UTF-8?q?refactor:=20outbox=ED=8C=A8=ED=84=B4=20pe?= =?UTF-8?q?nding=20=EC=8A=A4=EC=BC=80=EC=A5=B4=EB=9F=AC=20=EC=98=88?= =?UTF-8?q?=EC=99=B8=20=EC=B2=98=EB=A6=AC=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../outbox/application/impl/OutboxEventScheduler.java | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/main/java/com/dnd/moddo/outbox/application/impl/OutboxEventScheduler.java b/src/main/java/com/dnd/moddo/outbox/application/impl/OutboxEventScheduler.java index b2409c5f..8eb6ebe9 100644 --- a/src/main/java/com/dnd/moddo/outbox/application/impl/OutboxEventScheduler.java +++ b/src/main/java/com/dnd/moddo/outbox/application/impl/OutboxEventScheduler.java @@ -4,14 +4,20 @@ import org.springframework.stereotype.Component; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; @Component @RequiredArgsConstructor +@Slf4j public class OutboxEventScheduler { private final OutboxEventPublisher outboxEventPublisher; @Scheduled(fixedDelay = 5000) public void publishPendingEvents() { - outboxEventPublisher.publishPendingEvents(); + try { + outboxEventPublisher.publishPendingEvents(); + } catch (Exception e) { + log.error("Error publishing events to outbox events queue", e); + } } }