diff --git a/core/common/src/main/java/kr/ac/kookmin/stream/common/event/EventApplyClosingTomorrow.java b/core/common/src/main/java/kr/ac/kookmin/stream/common/event/EventApplyClosingTomorrow.java new file mode 100644 index 00000000..01c6765c --- /dev/null +++ b/core/common/src/main/java/kr/ac/kookmin/stream/common/event/EventApplyClosingTomorrow.java @@ -0,0 +1,6 @@ +package kr.ac.kookmin.stream.common.event; + +import java.util.List; + +/** 모집 중인 행사의 신청이 내일 마감된다. 알림은 신청자를 뺀 회원에게 간다. */ +public record EventApplyClosingTomorrow(Long eventId, String eventTitle, List applicantIds) implements DomainEvent {} diff --git a/core/common/src/main/java/kr/ac/kookmin/stream/common/event/EventStartingTomorrow.java b/core/common/src/main/java/kr/ac/kookmin/stream/common/event/EventStartingTomorrow.java new file mode 100644 index 00000000..748fe414 --- /dev/null +++ b/core/common/src/main/java/kr/ac/kookmin/stream/common/event/EventStartingTomorrow.java @@ -0,0 +1,6 @@ +package kr.ac.kookmin.stream.common.event; + +import java.util.List; + +/** 행사가 내일 시작한다. 알림은 신청자에게 간다. */ +public record EventStartingTomorrow(Long eventId, String eventTitle, List applicantIds) implements DomainEvent {} diff --git a/core/common/src/main/java/kr/ac/kookmin/stream/common/event/LockerApplyClosingTomorrow.java b/core/common/src/main/java/kr/ac/kookmin/stream/common/event/LockerApplyClosingTomorrow.java new file mode 100644 index 00000000..e686c37a --- /dev/null +++ b/core/common/src/main/java/kr/ac/kookmin/stream/common/event/LockerApplyClosingTomorrow.java @@ -0,0 +1,6 @@ +package kr.ac.kookmin.stream.common.event; + +import java.util.List; + +/** 사물함 운영 회차의 신청이 내일 마감된다. 알림은 그 회차 신청자를 뺀 회원에게 간다. */ +public record LockerApplyClosingTomorrow(Long lockerPeriodId, List applicantIds) implements DomainEvent {} diff --git a/core/common/src/main/java/kr/ac/kookmin/stream/common/event/LockerUsageEndingSoon.java b/core/common/src/main/java/kr/ac/kookmin/stream/common/event/LockerUsageEndingSoon.java new file mode 100644 index 00000000..1150da70 --- /dev/null +++ b/core/common/src/main/java/kr/ac/kookmin/stream/common/event/LockerUsageEndingSoon.java @@ -0,0 +1,6 @@ +package kr.ac.kookmin.stream.common.event; + +import java.util.List; + +/** 사물함 운영 회차의 이용 기간이 곧 끝난다. 알림은 그 회차 신청자에게 간다. */ +public record LockerUsageEndingSoon(Long lockerPeriodId, List applicantIds) implements DomainEvent {} diff --git a/core/common/src/main/java/kr/ac/kookmin/stream/common/event/RentalDueSoon.java b/core/common/src/main/java/kr/ac/kookmin/stream/common/event/RentalDueSoon.java new file mode 100644 index 00000000..7f8e270b --- /dev/null +++ b/core/common/src/main/java/kr/ac/kookmin/stream/common/event/RentalDueSoon.java @@ -0,0 +1,4 @@ +package kr.ac.kookmin.stream.common.event; + +/** 대여 물품의 반납 기한이 1시간 남았다. 물품명은 알림 문구에 쓰려고 기록 시점의 값을 싣는다. */ +public record RentalDueSoon(Long memberId, Long rentalHistoryId, String itemName) implements DomainEvent {} diff --git a/core/common/src/main/java/kr/ac/kookmin/stream/common/event/RentalOverdue.java b/core/common/src/main/java/kr/ac/kookmin/stream/common/event/RentalOverdue.java new file mode 100644 index 00000000..c75c5be5 --- /dev/null +++ b/core/common/src/main/java/kr/ac/kookmin/stream/common/event/RentalOverdue.java @@ -0,0 +1,4 @@ +package kr.ac.kookmin.stream.common.event; + +/** 대여 물품의 반납 기한이 지났는데 아직 반납하지 않았다. 반납할 때까지 매일 기록된다. */ +public record RentalOverdue(Long memberId, Long rentalHistoryId, String itemName) implements DomainEvent {} diff --git a/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/event/repository/EventRepository.java b/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/event/repository/EventRepository.java index 76c32f6f..21154de0 100644 --- a/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/event/repository/EventRepository.java +++ b/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/event/repository/EventRepository.java @@ -48,6 +48,21 @@ List findPublishedSlice( */ Map countAppliedByEventIds(List eventIds); + /** + * 게시되고 삭제되지 않은 행사 중 시작 일시가 {@code [from, to)}인 행사. 행사 전날 알림 대상을 고를 때 쓴다. + */ + List findPublishedStartingBetween(LocalDateTime from, LocalDateTime to); + + /** + * 게시되고 삭제되지 않은 행사 중 신청 마감 일시가 {@code [from, to)}인 행사. 모집 마감 임박 알림 대상을 고를 때 쓴다. + */ + List findPublishedApplyEndingBetween(LocalDateTime from, LocalDateTime to); + + /** + * 행사의 유효 신청(취소하지 않은 신청)을 낸 회원 식별자. + */ + List findAppliedMemberIds(Long eventId); + List findQuestionsByEventId(Long eventId); long countAppliedByEventId(Long eventId); diff --git a/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/event/service/impl/EventNotificationScheduler.java b/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/event/service/impl/EventNotificationScheduler.java new file mode 100644 index 00000000..3f37f64a --- /dev/null +++ b/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/event/service/impl/EventNotificationScheduler.java @@ -0,0 +1,59 @@ +package kr.ac.kookmin.stream.event.domain.event.service.impl; + +import java.time.LocalDateTime; +import java.util.List; +import kr.ac.kookmin.stream.common.DateUtil; +import kr.ac.kookmin.stream.common.OutboxWriter; +import kr.ac.kookmin.stream.common.event.EventApplyClosingTomorrow; +import kr.ac.kookmin.stream.common.event.EventStartingTomorrow; +import kr.ac.kookmin.stream.event.domain.event.domain.Event; +import kr.ac.kookmin.stream.event.domain.event.domain.RecruitStatus; +import kr.ac.kookmin.stream.event.domain.event.repository.EventRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +/** + * 행사 예약 알림을 아웃박스에 기록한다. 매일 10시에 내일 시작하는 행사와 내일 신청이 마감되는 행사를 고른다. + *

+ * 발송 기록을 따로 남기지 않고 "날짜가 내일"인 대상만 고르므로, 서버가 그 시각에 내려가 있었으면 그날 알림은 건너뛴다. + */ +@Component +@RequiredArgsConstructor +class EventNotificationScheduler { + + private final EventRepository eventRepository; + private final OutboxWriter outboxWriter; + + @Scheduled(cron = "0 0 10 * * *", zone = "Asia/Seoul") + @Transactional + public void recordDailyNotifications() { + LocalDateTime now = LocalDateTime.now(DateUtil.KST); + LocalDateTime tomorrow = now.toLocalDate().plusDays(1).atStartOfDay(); + LocalDateTime dayAfterTomorrow = tomorrow.plusDays(1); + + recordStartingTomorrow(tomorrow, dayAfterTomorrow); + recordApplyClosingTomorrow(now, tomorrow, dayAfterTomorrow); + } + + // 신청자가 없으면 받을 사람이 없어 기록하지 않는다 + private void recordStartingTomorrow(LocalDateTime from, LocalDateTime to) { + for (Event event : eventRepository.findPublishedStartingBetween(from, to)) { + List applicantIds = eventRepository.findAppliedMemberIds(event.getId()); + if (!applicantIds.isEmpty()) { + outboxWriter.record(new EventStartingTomorrow(event.getId(), event.getTitle(), applicantIds)); + } + } + } + + // 지금 신청할 수 있는 행사만 알린다. 강제 마감·정원 마감된 행사는 신청할 수 없어 뺀다 + private void recordApplyClosingTomorrow(LocalDateTime now, LocalDateTime from, LocalDateTime to) { + for (Event event : eventRepository.findPublishedApplyEndingBetween(from, to)) { + List applicantIds = eventRepository.findAppliedMemberIds(event.getId()); + if (event.calculateRecruitStatus(now, applicantIds.size()) == RecruitStatus.OPEN) { + outboxWriter.record(new EventApplyClosingTomorrow(event.getId(), event.getTitle(), applicantIds)); + } + } + } +} diff --git a/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/locker/repository/LockerApplicationRepository.java b/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/locker/repository/LockerApplicationRepository.java index 2817b5e2..c5075e20 100644 --- a/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/locker/repository/LockerApplicationRepository.java +++ b/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/locker/repository/LockerApplicationRepository.java @@ -20,6 +20,11 @@ public interface LockerApplicationRepository { */ Set findAppliedLockerIds(Long lockerPeriodId); + /** + * 해당 운영 회차에 신청한 회원 식별자. 취소가 없어 신청 행이 곧 사물함을 가진 회원이다. + */ + List findMemberIds(Long lockerPeriodId); + /** * 해당 운영 회차에서 회원이 신청한 사물함 식별자. 회차당 한 건만 신청할 수 있고 취소가 없어 최대 하나다. */ diff --git a/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/locker/repository/LockerRepository.java b/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/locker/repository/LockerRepository.java index 928f5e48..12a599b5 100644 --- a/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/locker/repository/LockerRepository.java +++ b/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/locker/repository/LockerRepository.java @@ -1,5 +1,7 @@ package kr.ac.kookmin.stream.event.domain.locker.repository; +import java.time.LocalDate; +import java.time.LocalDateTime; import java.util.Collection; import java.util.List; import java.util.Optional; @@ -28,6 +30,16 @@ public interface LockerRepository { */ List findPublishedPeriodsByIds(Collection lockerPeriodIds); + /** + * 게시된 운영 회차 중 신청 마감 일시가 {@code [from, to)}인 회차. 모집 마감 임박 알림 대상을 고를 때 쓴다. + */ + List findPublishedPeriodsApplyEndingBetween(LocalDateTime from, LocalDateTime to); + + /** + * 게시된 운영 회차 중 이용 종료일이 {@code usageEndAt}인 회차. 이용 기간 만료 임박 알림 대상을 고를 때 쓴다. + */ + List findPublishedPeriodsUsageEndingOn(LocalDate usageEndAt); + /** * 전체 구역을 식별자 오름차순으로 조회한다. */ diff --git a/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/locker/service/impl/LockerNotificationScheduler.java b/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/locker/service/impl/LockerNotificationScheduler.java new file mode 100644 index 00000000..408e1c59 --- /dev/null +++ b/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/locker/service/impl/LockerNotificationScheduler.java @@ -0,0 +1,66 @@ +package kr.ac.kookmin.stream.event.domain.locker.service.impl; + +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.List; +import kr.ac.kookmin.stream.common.DateUtil; +import kr.ac.kookmin.stream.common.OutboxWriter; +import kr.ac.kookmin.stream.common.event.LockerApplyClosingTomorrow; +import kr.ac.kookmin.stream.common.event.LockerUsageEndingSoon; +import kr.ac.kookmin.stream.event.domain.locker.domain.LockerPeriod; +import kr.ac.kookmin.stream.event.domain.locker.repository.LockerApplicationRepository; +import kr.ac.kookmin.stream.event.domain.locker.repository.LockerRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +/** + * 사물함 예약 알림을 아웃박스에 기록한다. 매일 10시에 내일 신청이 마감되는 회차와 이용 기간이 곧 끝나는 회차를 고른다. + *

+ * 발송 기록을 따로 남기지 않고 날짜로 대상을 고르므로, 서버가 그 시각에 내려가 있었으면 그날 알림은 건너뛴다. + */ +@Component +@RequiredArgsConstructor +class LockerNotificationScheduler { + + // 이용 종료일 며칠 전에 만료 임박 알림을 보낼지 + private static final long USAGE_ENDING_NOTICE_DAYS = 3; + + private final LockerRepository lockerRepository; + private final LockerApplicationRepository lockerApplicationRepository; + private final OutboxWriter outboxWriter; + + @Scheduled(cron = "0 0 10 * * *", zone = "Asia/Seoul") + @Transactional + public void recordDailyNotifications() { + LocalDateTime now = LocalDateTime.now(DateUtil.KST); + LocalDate today = now.toLocalDate(); + + recordApplyClosingTomorrow(now, today.plusDays(1)); + recordUsageEndingSoon(today.plusDays(USAGE_ENDING_NOTICE_DAYS)); + } + + // 아직 신청 기간이 시작되지 않은 회차는 지금 신청할 수 없어 뺀다 + private void recordApplyClosingTomorrow(LocalDateTime now, LocalDate tomorrow) { + List periods = lockerRepository.findPublishedPeriodsApplyEndingBetween( + tomorrow.atStartOfDay(), tomorrow.plusDays(1).atStartOfDay()); + for (LockerPeriod period : periods) { + if (now.isBefore(period.getApplyStartAt())) { + continue; + } + List applicantIds = lockerApplicationRepository.findMemberIds(period.getId()); + outboxWriter.record(new LockerApplyClosingTomorrow(period.getId(), applicantIds)); + } + } + + // 신청자가 없으면 받을 사람이 없어 기록하지 않는다 + private void recordUsageEndingSoon(LocalDate usageEndAt) { + for (LockerPeriod period : lockerRepository.findPublishedPeriodsUsageEndingOn(usageEndAt)) { + List applicantIds = lockerApplicationRepository.findMemberIds(period.getId()); + if (!applicantIds.isEmpty()) { + outboxWriter.record(new LockerUsageEndingSoon(period.getId(), applicantIds)); + } + } + } +} diff --git a/core/domain/event/src/test/java/kr/ac/kookmin/stream/event/domain/event/service/impl/EventServiceImplTest.java b/core/domain/event/src/test/java/kr/ac/kookmin/stream/event/domain/event/service/impl/EventServiceImplTest.java index a1d636ad..d417cf29 100644 --- a/core/domain/event/src/test/java/kr/ac/kookmin/stream/event/domain/event/service/impl/EventServiceImplTest.java +++ b/core/domain/event/src/test/java/kr/ac/kookmin/stream/event/domain/event/service/impl/EventServiceImplTest.java @@ -167,6 +167,21 @@ public Optional findPublishedById(Long id) { throw new UnsupportedOperationException(); } + @Override + public List findPublishedStartingBetween(LocalDateTime from, LocalDateTime to) { + throw new UnsupportedOperationException(); + } + + @Override + public List findPublishedApplyEndingBetween(LocalDateTime from, LocalDateTime to) { + throw new UnsupportedOperationException(); + } + + @Override + public List findAppliedMemberIds(Long eventId) { + throw new UnsupportedOperationException(); + } + @Override public List findQuestionsByEventId(Long eventId) { throw new UnsupportedOperationException(); diff --git a/core/domain/event/src/test/java/kr/ac/kookmin/stream/event/domain/locker/service/impl/LockerApplicationServiceImplTest.java b/core/domain/event/src/test/java/kr/ac/kookmin/stream/event/domain/locker/service/impl/LockerApplicationServiceImplTest.java index cee547a3..8c7f3186 100644 --- a/core/domain/event/src/test/java/kr/ac/kookmin/stream/event/domain/locker/service/impl/LockerApplicationServiceImplTest.java +++ b/core/domain/event/src/test/java/kr/ac/kookmin/stream/event/domain/locker/service/impl/LockerApplicationServiceImplTest.java @@ -347,6 +347,16 @@ public List findPublishedPeriodsByIds(Collection lockerPerio .toList(); } + @Override + public List findPublishedPeriodsApplyEndingBetween(LocalDateTime from, LocalDateTime to) { + throw new UnsupportedOperationException(); + } + + @Override + public List findPublishedPeriodsUsageEndingOn(LocalDate usageEndAt) { + throw new UnsupportedOperationException(); + } + @Override public List findLockersByIdsIncludingDeleted(Collection lockerIds) { batchLookups++; @@ -427,6 +437,11 @@ public Set findAppliedLockerIds(Long lockerPeriodId) { throw new UnsupportedOperationException(); } + @Override + public List findMemberIds(Long lockerPeriodId) { + throw new UnsupportedOperationException(); + } + @Override public Optional findAppliedLockerId(Long lockerPeriodId, Long memberId) { throw new UnsupportedOperationException(); diff --git a/core/domain/event/src/test/java/kr/ac/kookmin/stream/event/domain/locker/service/impl/LockerServiceImplTest.java b/core/domain/event/src/test/java/kr/ac/kookmin/stream/event/domain/locker/service/impl/LockerServiceImplTest.java index bc7c28f3..001afc7e 100644 --- a/core/domain/event/src/test/java/kr/ac/kookmin/stream/event/domain/locker/service/impl/LockerServiceImplTest.java +++ b/core/domain/event/src/test/java/kr/ac/kookmin/stream/event/domain/locker/service/impl/LockerServiceImplTest.java @@ -4,6 +4,8 @@ import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; +import java.time.LocalDate; +import java.time.LocalDateTime; import java.util.Collection; import java.util.List; import java.util.Optional; @@ -291,6 +293,16 @@ public List findPublishedPeriodsByIds(Collection lockerPerio throw new UnsupportedOperationException(); } + @Override + public List findPublishedPeriodsApplyEndingBetween(LocalDateTime from, LocalDateTime to) { + throw new UnsupportedOperationException(); + } + + @Override + public List findPublishedPeriodsUsageEndingOn(LocalDate usageEndAt) { + throw new UnsupportedOperationException(); + } + @Override public List findLockersByIdsIncludingDeleted(Collection lockerIds) { throw new UnsupportedOperationException(); @@ -307,6 +319,11 @@ public Set findAppliedLockerIds(Long lockerPeriodId) { return appliedLockerIds; } + @Override + public List findMemberIds(Long lockerPeriodId) { + throw new UnsupportedOperationException(); + } + @Override public Optional findAppliedLockerId(Long lockerPeriodId, Long memberId) { return Optional.ofNullable(myLockerId); diff --git a/core/domain/member/src/main/java/kr/ac/kookmin/stream/member/domain/notification/service/impl/NotificationEventListener.java b/core/domain/member/src/main/java/kr/ac/kookmin/stream/member/domain/notification/service/impl/NotificationEventListener.java index f860f4d6..408234cf 100644 --- a/core/domain/member/src/main/java/kr/ac/kookmin/stream/member/domain/notification/service/impl/NotificationEventListener.java +++ b/core/domain/member/src/main/java/kr/ac/kookmin/stream/member/domain/notification/service/impl/NotificationEventListener.java @@ -3,8 +3,14 @@ import java.util.List; import kr.ac.kookmin.stream.common.event.EventApplicationCanceled; import kr.ac.kookmin.stream.common.event.EventApplied; +import kr.ac.kookmin.stream.common.event.EventApplyClosingTomorrow; +import kr.ac.kookmin.stream.common.event.EventStartingTomorrow; +import kr.ac.kookmin.stream.common.event.LockerApplyClosingTomorrow; import kr.ac.kookmin.stream.common.event.LockerAssigned; +import kr.ac.kookmin.stream.common.event.LockerUsageEndingSoon; import kr.ac.kookmin.stream.common.event.NoticeCreated; +import kr.ac.kookmin.stream.common.event.RentalDueSoon; +import kr.ac.kookmin.stream.common.event.RentalOverdue; import kr.ac.kookmin.stream.member.domain.notification.domain.NotificationType; import lombok.RequiredArgsConstructor; import org.springframework.context.event.EventListener; @@ -43,4 +49,41 @@ public void on(LockerAssigned event) { public void on(NoticeCreated event) { notificationDispatcher.dispatchToAll(NotificationType.NOTICE_CREATED, List.of(), event.noticeId(), List.of()); } + + @EventListener + public void on(EventStartingTomorrow event) { + notificationDispatcher.dispatch( + NotificationType.EVENT_STARTING_TOMORROW, List.of(event.eventTitle()), event.eventId(), event.applicantIds()); + } + + // 마감 임박은 아직 신청하지 않은 회원에게 간다 + @EventListener + public void on(EventApplyClosingTomorrow event) { + notificationDispatcher.dispatchToAll( + NotificationType.EVENT_APPLY_CLOSING, List.of(event.eventTitle()), event.eventId(), event.applicantIds()); + } + + @EventListener + public void on(LockerApplyClosingTomorrow event) { + notificationDispatcher.dispatchToAll( + NotificationType.LOCKER_APPLY_CLOSING, List.of(), event.lockerPeriodId(), event.applicantIds()); + } + + @EventListener + public void on(LockerUsageEndingSoon event) { + notificationDispatcher.dispatch( + NotificationType.LOCKER_USAGE_ENDING, List.of(), event.lockerPeriodId(), event.applicantIds()); + } + + @EventListener + public void on(RentalDueSoon event) { + notificationDispatcher.dispatch( + NotificationType.RENTAL_DUE_SOON, List.of(event.itemName()), event.rentalHistoryId(), List.of(event.memberId())); + } + + @EventListener + public void on(RentalOverdue event) { + notificationDispatcher.dispatch( + NotificationType.RENTAL_OVERDUE, List.of(event.itemName()), event.rentalHistoryId(), List.of(event.memberId())); + } } diff --git a/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/rental/repository/RentalHistoryRepository.java b/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/rental/repository/RentalHistoryRepository.java index 77b41c6d..b3b9021f 100644 --- a/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/rental/repository/RentalHistoryRepository.java +++ b/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/rental/repository/RentalHistoryRepository.java @@ -14,6 +14,9 @@ public interface RentalHistoryRepository { */ List findAllByMemberId(Long memberId, RentalStatus status); + /** 모든 회원의 대여 이력 중 해당 상태인 것. 반납 알림 대상을 고를 때 쓴다. */ + List findAllByStatus(RentalStatus status); + RentalHistory save(RentalHistory history); /** 같은 회원이 같은 물품을 이미 대여 중인지(상태가 RENTAL인 이력이 있는지) 확인한다. */ diff --git a/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/rental/service/RentalHistoryService.java b/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/rental/service/RentalHistoryService.java index 9ddbb604..a7cade1d 100644 --- a/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/rental/service/RentalHistoryService.java +++ b/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/rental/service/RentalHistoryService.java @@ -22,6 +22,12 @@ public interface RentalHistoryService { */ List getReturnRequiredRentals(Long memberId); + /** + * 모든 회원의 반납해야 하는 대여를 조회한다. 반납 기한 임박·연체 알림 대상을 고를 때 쓴다. + * 반납 정책이 없는 물품의 대여는 기한을 계산할 수 없어 제외한다. + */ + List getAllReturnRequiredRentals(); + /** * 반납 신청. 본인 소유의 대여 중(RENTAL) 이력만 대상이며, 신청 즉시 반납 완료로 전이한다 * (운영진의 최종 확인 단계가 없어서다). diff --git a/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/rental/service/impl/RentalHistoryServiceImpl.java b/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/rental/service/impl/RentalHistoryServiceImpl.java index 5b9b3bf3..4ad1e4b4 100644 --- a/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/rental/service/impl/RentalHistoryServiceImpl.java +++ b/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/rental/service/impl/RentalHistoryServiceImpl.java @@ -44,6 +44,14 @@ public List getReturnRequiredRentals(Long memberId) { .toList(); } + // getReturnRequiredRentals와 같은 이유로 트랜잭션이 필요 없다 + @Override + public List getAllReturnRequiredRentals() { + return toRecords(rentalHistoryRepository.findAllByStatus(RentalStatus.RENTAL)).stream() + .filter(RentalRecord::isReturnRequired) + .toList(); + } + // 조회(대상 확인) + 쓰기(상태 전이)가 원자적으로 묶여야 하므로 일반 트랜잭션이다. @Override @Transactional diff --git a/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/rental/service/impl/RentalNotificationScheduler.java b/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/rental/service/impl/RentalNotificationScheduler.java new file mode 100644 index 00000000..be077786 --- /dev/null +++ b/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/rental/service/impl/RentalNotificationScheduler.java @@ -0,0 +1,68 @@ +package kr.ac.kookmin.stream.welfare.domain.rental.service.impl; + +import java.time.LocalDateTime; +import java.time.temporal.ChronoUnit; +import kr.ac.kookmin.stream.common.DateUtil; +import kr.ac.kookmin.stream.common.OutboxWriter; +import kr.ac.kookmin.stream.common.event.RentalDueSoon; +import kr.ac.kookmin.stream.common.event.RentalOverdue; +import kr.ac.kookmin.stream.welfare.domain.rental.domain.RentalRecord; +import kr.ac.kookmin.stream.welfare.domain.rental.service.RentalHistoryService; +import lombok.RequiredArgsConstructor; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +/** + * 대여 반납 알림을 아웃박스에 기록한다. + *

    + *
  • 반납 기한 임박: 10분마다 돌며, "반납 기한 1시간 전" 시각이 직전 실행 이후부터 지금까지 사이에 든 대여를 고른다.
  • + *
  • 반납 연체: 매일 10시에 기한이 지난 대여를 고른다. 반납할 때까지 매일 간다.
  • + *
+ * 발송 기록을 따로 남기지 않으므로, 서버가 그 시각에 내려가 있었으면 그 회차 알림은 건너뛴다. + */ +@Component +@RequiredArgsConstructor +class RentalNotificationScheduler { + + // 반납 기한 임박 확인 간격. recordDueSoon의 cron과 맞춰야 구간이 비거나 겹치지 않는다 + private static final int DUE_SOON_CHECK_INTERVAL_MINUTES = 10; + private static final long DUE_SOON_LEAD_HOURS = 1; + + private final RentalHistoryService rentalHistoryService; + private final OutboxWriter outboxWriter; + + // 실행이 조금 늦어져도 같은 구간을 보도록, 지금 시각을 확인 간격 단위로 내린 시각까지를 본다 + @Scheduled(cron = "0 */10 * * * *", zone = "Asia/Seoul") + @Transactional + public void recordDueSoon() { + LocalDateTime windowEnd = floorToCheckInterval(LocalDateTime.now(DateUtil.KST)); + LocalDateTime windowStart = windowEnd.minusMinutes(DUE_SOON_CHECK_INTERVAL_MINUTES); + + for (RentalRecord rental : rentalHistoryService.getAllReturnRequiredRentals()) { + LocalDateTime notifyAt = rental.dueAt().minusHours(DUE_SOON_LEAD_HOURS); + if (notifyAt.isAfter(windowStart) && !notifyAt.isAfter(windowEnd)) { + outboxWriter.record(new RentalDueSoon( + rental.history().getMemberId(), rental.history().getId(), rental.itemName())); + } + } + } + + @Scheduled(cron = "0 0 10 * * *", zone = "Asia/Seoul") + @Transactional + public void recordOverdue() { + LocalDateTime now = LocalDateTime.now(DateUtil.KST); + + for (RentalRecord rental : rentalHistoryService.getAllReturnRequiredRentals()) { + if (rental.dueAt().isBefore(now)) { + outboxWriter.record(new RentalOverdue( + rental.history().getMemberId(), rental.history().getId(), rental.itemName())); + } + } + } + + private static LocalDateTime floorToCheckInterval(LocalDateTime time) { + LocalDateTime minute = time.truncatedTo(ChronoUnit.MINUTES); + return minute.minusMinutes(minute.getMinute() % DUE_SOON_CHECK_INTERVAL_MINUTES); + } +} diff --git a/docs/conventions/architecture.md b/docs/conventions/architecture.md index e0dabab2..14883103 100644 --- a/docs/conventions/architecture.md +++ b/docs/conventions/architecture.md @@ -367,6 +367,9 @@ Data Access {Domain}Repository / {Domain}Client 인터페이스 → core:dom - **발행은 `OutboxWriter` 포트로 기록**한다(원 비즈니스 트랜잭션과 원자적). 인프로세스 재발행은 릴레이만 수행한다. - **구독은 `@EventListener`**로 한다. 릴레이가 트랜잭션 밖에서 발행하므로 `@TransactionalEventListener`는 호출되지 않는다. 릴레이는 리스너가 끝난 뒤에 발행 완료로 표시하므로, 리스너가 예외를 던지면 재시도된다. `core:domain`에 Modulith 이벤트 의존을 넣지 않는다. - 소비 리스너는 **멱등**하게 작성한다(at-least-once). 리스너가 끝난 뒤 완료 표시 전에 서버가 내려가면 한 번 더 전달된다. 푸시 알림처럼 드문 중복을 감수할 수 있으면 그대로 둔다. +- **정해진 시각에 기록할 이벤트**(예: 행사 전날·반납 기한 임박 알림)는 그 데이터를 가진 도메인의 `service.impl`에 package-private `@Scheduled` 컴포넌트(`{Domain}NotificationScheduler`)를 두고, 대상만 골라 `OutboxWriter`로 기록한다. 다른 도메인을 조회해야 하는 판단(예: 회원 전체 − 신청자)은 이벤트에 필요한 ID를 실어 구독 측에서 한다. + - cron에는 `zone = "Asia/Seoul"`을 명시하고, 기록이 트랜잭션 안에서 일어나도록 메서드에 `@Transactional`을 건다. + - 발송 기록을 따로 남기지 않고 날짜·시간 구간으로 대상을 고른다. 서버가 그 시각에 내려가 있었으면 그 회차는 건너뛴다. - 아웃박스 테이블 스키마는 `flyway-migration.md`로 관리한다. - 최종적 일관성 경로다. "한 요청 안에서 쓰기 직후 결과를 즉시 조회해 응답에 실어야" 하면 이벤트가 아니라 6-1의 동기 UseCase로 처리한다. diff --git a/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/event/EventApplicationJpaRepository.java b/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/event/EventApplicationJpaRepository.java index 38099a9d..005cb843 100644 --- a/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/event/EventApplicationJpaRepository.java +++ b/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/event/EventApplicationJpaRepository.java @@ -17,6 +17,15 @@ public interface EventApplicationJpaRepository extends JpaRepository findByIdAndMemberId(Long id, Long memberId); + @Query(""" + SELECT a.memberId FROM EventApplicationJpaEntity a + WHERE a.eventId = :eventId AND a.status = :status + """) + List findMemberIdsByEventIdAndStatus( + @Param("eventId") Long eventId, + @Param("status") EventApplicationStatus status + ); + /** * 한 회원의 신청 내역을 (applied_at, event_application_id) keyset 커서로 최신순 조회한다. * 취소된 신청도 이력으로 함께 내려간다. diff --git a/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/event/EventJpaRepository.java b/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/event/EventJpaRepository.java index 268bf3f7..aa121fb7 100644 --- a/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/event/EventJpaRepository.java +++ b/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/event/EventJpaRepository.java @@ -66,6 +66,24 @@ List findPublishedSlice( Pageable pageable ); + @Query(""" + SELECT e FROM EventJpaEntity e + WHERE e.isDeleted = false + AND e.isPublished = true + AND e.eventStartAt >= :from + AND e.eventStartAt < :to + """) + List findPublishedStartingBetween(@Param("from") LocalDateTime from, @Param("to") LocalDateTime to); + + @Query(""" + SELECT e FROM EventJpaEntity e + WHERE e.isDeleted = false + AND e.isPublished = true + AND e.applyEndAt >= :from + AND e.applyEndAt < :to + """) + List findPublishedApplyEndingBetween(@Param("from") LocalDateTime from, @Param("to") LocalDateTime to); + @Query(""" SELECT new kr.ac.kookmin.stream.db.event.EventApplicantCountProjection(a.eventId, COUNT(a)) FROM EventApplicationJpaEntity a diff --git a/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/event/EventRepositoryImpl.java b/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/event/EventRepositoryImpl.java index ba03a9c9..0c2ec71d 100644 --- a/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/event/EventRepositoryImpl.java +++ b/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/event/EventRepositoryImpl.java @@ -68,6 +68,21 @@ public List findPublishedSlice( ).stream().map(EventJpaEntity::toDomain).toList(); } + @Override + public List findPublishedStartingBetween(LocalDateTime from, LocalDateTime to) { + return eventJpaRepository.findPublishedStartingBetween(from, to).stream().map(EventJpaEntity::toDomain).toList(); + } + + @Override + public List findPublishedApplyEndingBetween(LocalDateTime from, LocalDateTime to) { + return eventJpaRepository.findPublishedApplyEndingBetween(from, to).stream().map(EventJpaEntity::toDomain).toList(); + } + + @Override + public List findAppliedMemberIds(Long eventId) { + return eventApplicationJpaRepository.findMemberIdsByEventIdAndStatus(eventId, EventApplicationStatus.APPLIED); + } + @Override public Map countAppliedByEventIds(List eventIds) { if (eventIds.isEmpty()) { diff --git a/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/event/LockerApplicationJpaRepository.java b/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/event/LockerApplicationJpaRepository.java index 66a51e51..175e975a 100644 --- a/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/event/LockerApplicationJpaRepository.java +++ b/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/event/LockerApplicationJpaRepository.java @@ -22,6 +22,12 @@ public interface LockerApplicationJpaRepository extends JpaRepository findLockerIdsByLockerPeriodId(@Param("lockerPeriodId") Long lockerPeriodId); + @Query(""" + SELECT a.memberId FROM LockerApplicationJpaEntity a + WHERE a.lockerPeriodId = :lockerPeriodId + """) + List findMemberIdsByLockerPeriodId(@Param("lockerPeriodId") Long lockerPeriodId); + /** * 회차당 회원 신청은 유니크 제약으로 한 건까지만 존재한다. */ diff --git a/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/event/LockerApplicationRepositoryImpl.java b/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/event/LockerApplicationRepositoryImpl.java index 5363247b..5911c0f3 100644 --- a/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/event/LockerApplicationRepositoryImpl.java +++ b/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/event/LockerApplicationRepositoryImpl.java @@ -30,6 +30,11 @@ public Set findAppliedLockerIds(Long lockerPeriodId) { return lockerApplicationJpaRepository.findLockerIdsByLockerPeriodId(lockerPeriodId); } + @Override + public List findMemberIds(Long lockerPeriodId) { + return lockerApplicationJpaRepository.findMemberIdsByLockerPeriodId(lockerPeriodId); + } + @Override public Optional findAppliedLockerId(Long lockerPeriodId, Long memberId) { return lockerApplicationJpaRepository.findLockerIdByLockerPeriodIdAndMemberId(lockerPeriodId, memberId); diff --git a/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/event/LockerPeriodJpaRepository.java b/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/event/LockerPeriodJpaRepository.java index aa76e596..eb7c279a 100644 --- a/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/event/LockerPeriodJpaRepository.java +++ b/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/event/LockerPeriodJpaRepository.java @@ -1,9 +1,13 @@ package kr.ac.kookmin.stream.db.event; +import java.time.LocalDate; +import java.time.LocalDateTime; import java.util.Collection; import java.util.List; import java.util.Optional; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; public interface LockerPeriodJpaRepository extends JpaRepository { @@ -12,4 +16,14 @@ public interface LockerPeriodJpaRepository extends JpaRepository findByIdAndIsPublishedTrue(Long id); List findAllByIdInAndIsPublishedTrue(Collection ids); + + @Query(""" + SELECT p FROM LockerPeriodJpaEntity p + WHERE p.isPublished = true + AND p.applyEndAt >= :from + AND p.applyEndAt < :to + """) + List findPublishedApplyEndingBetween(@Param("from") LocalDateTime from, @Param("to") LocalDateTime to); + + List findAllByIsPublishedTrueAndUsageEndAt(LocalDate usageEndAt); } diff --git a/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/event/LockerRepositoryImpl.java b/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/event/LockerRepositoryImpl.java index 949b3805..d624f516 100644 --- a/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/event/LockerRepositoryImpl.java +++ b/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/event/LockerRepositoryImpl.java @@ -1,5 +1,7 @@ package kr.ac.kookmin.stream.db.event; +import java.time.LocalDate; +import java.time.LocalDateTime; import java.util.Collection; import java.util.List; import java.util.Optional; @@ -38,6 +40,20 @@ public List findPublishedPeriodsByIds(Collection lockerPerio .toList(); } + @Override + public List findPublishedPeriodsApplyEndingBetween(LocalDateTime from, LocalDateTime to) { + return lockerPeriodJpaRepository.findPublishedApplyEndingBetween(from, to).stream() + .map(LockerPeriodJpaEntity::toDomain) + .toList(); + } + + @Override + public List findPublishedPeriodsUsageEndingOn(LocalDate usageEndAt) { + return lockerPeriodJpaRepository.findAllByIsPublishedTrueAndUsageEndAt(usageEndAt).stream() + .map(LockerPeriodJpaEntity::toDomain) + .toList(); + } + @Override public List findAllSections() { return lockerSectionJpaRepository.findAllByOrderByIdAsc().stream() diff --git a/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/welfare/RentalHistoryJpaRepository.java b/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/welfare/RentalHistoryJpaRepository.java index c6e7184a..ebc15052 100644 --- a/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/welfare/RentalHistoryJpaRepository.java +++ b/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/welfare/RentalHistoryJpaRepository.java @@ -20,6 +20,8 @@ List findAllByMemberId( @Param("status") RentalStatus status ); + List findAllByRentalStatus(RentalStatus rentalStatus); + boolean existsByItemIdAndMemberIdAndRentalStatus(Long itemId, Long memberId, RentalStatus rentalStatus); Optional findByIdAndMemberIdAndRentalStatus(Long id, Long memberId, RentalStatus rentalStatus); diff --git a/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/welfare/RentalHistoryRepositoryImpl.java b/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/welfare/RentalHistoryRepositoryImpl.java index 9f56e190..a75d5e09 100644 --- a/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/welfare/RentalHistoryRepositoryImpl.java +++ b/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/welfare/RentalHistoryRepositoryImpl.java @@ -21,6 +21,13 @@ public List findAllByMemberId(Long memberId, RentalStatus status) .toList(); } + @Override + public List findAllByStatus(RentalStatus status) { + return rentalHistoryJpaRepository.findAllByRentalStatus(status).stream() + .map(RentalHistoryJpaEntity::toDomain) + .toList(); + } + @Override public RentalHistory save(RentalHistory history) { return rentalHistoryJpaRepository.save(RentalHistoryJpaEntity.from(history)).toDomain();