Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
package kr.ac.kookmin.stream.common.event;

import java.util.List;

/** 모집 중인 행사의 신청이 내일 마감된다. 알림은 신청자를 뺀 회원에게 간다. */
public record EventApplyClosingTomorrow(Long eventId, String eventTitle, List<Long> applicantIds) implements DomainEvent {}
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
package kr.ac.kookmin.stream.common.event;

import java.util.List;

/** 행사가 내일 시작한다. 알림은 신청자에게 간다. */
public record EventStartingTomorrow(Long eventId, String eventTitle, List<Long> applicantIds) implements DomainEvent {}
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
package kr.ac.kookmin.stream.common.event;

import java.util.List;

/** 사물함 운영 회차의 신청이 내일 마감된다. 알림은 그 회차 신청자를 뺀 회원에게 간다. */
public record LockerApplyClosingTomorrow(Long lockerPeriodId, List<Long> applicantIds) implements DomainEvent {}
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
package kr.ac.kookmin.stream.common.event;

import java.util.List;

/** 사물함 운영 회차의 이용 기간이 곧 끝난다. 알림은 그 회차 신청자에게 간다. */
public record LockerUsageEndingSoon(Long lockerPeriodId, List<Long> applicantIds) implements DomainEvent {}
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
package kr.ac.kookmin.stream.common.event;

/** 대여 물품의 반납 기한이 1시간 남았다. 물품명은 알림 문구에 쓰려고 기록 시점의 값을 싣는다. */
public record RentalDueSoon(Long memberId, Long rentalHistoryId, String itemName) implements DomainEvent {}
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
package kr.ac.kookmin.stream.common.event;

/** 대여 물품의 반납 기한이 지났는데 아직 반납하지 않았다. 반납할 때까지 매일 기록된다. */
public record RentalOverdue(Long memberId, Long rentalHistoryId, String itemName) implements DomainEvent {}
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,21 @@ List<Event> findPublishedSlice(
*/
Map<Long, Long> countAppliedByEventIds(List<Long> eventIds);

/**
* 게시되고 삭제되지 않은 행사 중 시작 일시가 {@code [from, to)}인 행사. 행사 전날 알림 대상을 고를 때 쓴다.
*/
List<Event> findPublishedStartingBetween(LocalDateTime from, LocalDateTime to);

/**
* 게시되고 삭제되지 않은 행사 중 신청 마감 일시가 {@code [from, to)}인 행사. 모집 마감 임박 알림 대상을 고를 때 쓴다.
*/
List<Event> findPublishedApplyEndingBetween(LocalDateTime from, LocalDateTime to);

/**
* 행사의 유효 신청(취소하지 않은 신청)을 낸 회원 식별자.
*/
List<Long> findAppliedMemberIds(Long eventId);

List<EventQuestion> findQuestionsByEventId(Long eventId);

long countAppliedByEventId(Long eventId);
Expand Down
Original file line number Diff line number Diff line change
@@ -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시에 내일 시작하는 행사와 내일 신청이 마감되는 행사를 고른다.
* <p>
* 발송 기록을 따로 남기지 않고 "날짜가 내일"인 대상만 고르므로, 서버가 그 시각에 내려가 있었으면 그날 알림은 건너뛴다.
*/
@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<Long> 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<Long> applicantIds = eventRepository.findAppliedMemberIds(event.getId());
if (event.calculateRecruitStatus(now, applicantIds.size()) == RecruitStatus.OPEN) {
outboxWriter.record(new EventApplyClosingTomorrow(event.getId(), event.getTitle(), applicantIds));
}
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,11 @@ public interface LockerApplicationRepository {
*/
Set<Long> findAppliedLockerIds(Long lockerPeriodId);

/**
* 해당 운영 회차에 신청한 회원 식별자. 취소가 없어 신청 행이 곧 사물함을 가진 회원이다.
*/
List<Long> findMemberIds(Long lockerPeriodId);

/**
* 해당 운영 회차에서 회원이 신청한 사물함 식별자. 회차당 한 건만 신청할 수 있고 취소가 없어 최대 하나다.
*/
Expand Down
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -28,6 +30,16 @@ public interface LockerRepository {
*/
List<LockerPeriod> findPublishedPeriodsByIds(Collection<Long> lockerPeriodIds);

/**
* 게시된 운영 회차 중 신청 마감 일시가 {@code [from, to)}인 회차. 모집 마감 임박 알림 대상을 고를 때 쓴다.
*/
List<LockerPeriod> findPublishedPeriodsApplyEndingBetween(LocalDateTime from, LocalDateTime to);

/**
* 게시된 운영 회차 중 이용 종료일이 {@code usageEndAt}인 회차. 이용 기간 만료 임박 알림 대상을 고를 때 쓴다.
*/
List<LockerPeriod> findPublishedPeriodsUsageEndingOn(LocalDate usageEndAt);

/**
* 전체 구역을 식별자 오름차순으로 조회한다.
*/
Expand Down
Original file line number Diff line number Diff line change
@@ -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시에 내일 신청이 마감되는 회차와 이용 기간이 곧 끝나는 회차를 고른다.
* <p>
* 발송 기록을 따로 남기지 않고 날짜로 대상을 고르므로, 서버가 그 시각에 내려가 있었으면 그날 알림은 건너뛴다.
*/
@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<LockerPeriod> periods = lockerRepository.findPublishedPeriodsApplyEndingBetween(
tomorrow.atStartOfDay(), tomorrow.plusDays(1).atStartOfDay());
for (LockerPeriod period : periods) {
if (now.isBefore(period.getApplyStartAt())) {
continue;
}
List<Long> applicantIds = lockerApplicationRepository.findMemberIds(period.getId());
outboxWriter.record(new LockerApplyClosingTomorrow(period.getId(), applicantIds));
}
}

// 신청자가 없으면 받을 사람이 없어 기록하지 않는다
private void recordUsageEndingSoon(LocalDate usageEndAt) {
for (LockerPeriod period : lockerRepository.findPublishedPeriodsUsageEndingOn(usageEndAt)) {
List<Long> applicantIds = lockerApplicationRepository.findMemberIds(period.getId());
if (!applicantIds.isEmpty()) {
outboxWriter.record(new LockerUsageEndingSoon(period.getId(), applicantIds));
}
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -167,6 +167,21 @@ public Optional<Event> findPublishedById(Long id) {
throw new UnsupportedOperationException();
}

@Override
public List<Event> findPublishedStartingBetween(LocalDateTime from, LocalDateTime to) {
throw new UnsupportedOperationException();
}

@Override
public List<Event> findPublishedApplyEndingBetween(LocalDateTime from, LocalDateTime to) {
throw new UnsupportedOperationException();
}

@Override
public List<Long> findAppliedMemberIds(Long eventId) {
throw new UnsupportedOperationException();
}

@Override
public List<EventQuestion> findQuestionsByEventId(Long eventId) {
throw new UnsupportedOperationException();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -347,6 +347,16 @@ public List<LockerPeriod> findPublishedPeriodsByIds(Collection<Long> lockerPerio
.toList();
}

@Override
public List<LockerPeriod> findPublishedPeriodsApplyEndingBetween(LocalDateTime from, LocalDateTime to) {
throw new UnsupportedOperationException();
}

@Override
public List<LockerPeriod> findPublishedPeriodsUsageEndingOn(LocalDate usageEndAt) {
throw new UnsupportedOperationException();
}

@Override
public List<Locker> findLockersByIdsIncludingDeleted(Collection<Long> lockerIds) {
batchLookups++;
Expand Down Expand Up @@ -427,6 +437,11 @@ public Set<Long> findAppliedLockerIds(Long lockerPeriodId) {
throw new UnsupportedOperationException();
}

@Override
public List<Long> findMemberIds(Long lockerPeriodId) {
throw new UnsupportedOperationException();
}

@Override
public Optional<Long> findAppliedLockerId(Long lockerPeriodId, Long memberId) {
throw new UnsupportedOperationException();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -291,6 +293,16 @@ public List<LockerPeriod> findPublishedPeriodsByIds(Collection<Long> lockerPerio
throw new UnsupportedOperationException();
}

@Override
public List<LockerPeriod> findPublishedPeriodsApplyEndingBetween(LocalDateTime from, LocalDateTime to) {
throw new UnsupportedOperationException();
}

@Override
public List<LockerPeriod> findPublishedPeriodsUsageEndingOn(LocalDate usageEndAt) {
throw new UnsupportedOperationException();
}

@Override
public List<Locker> findLockersByIdsIncludingDeleted(Collection<Long> lockerIds) {
throw new UnsupportedOperationException();
Expand All @@ -307,6 +319,11 @@ public Set<Long> findAppliedLockerIds(Long lockerPeriodId) {
return appliedLockerIds;
}

@Override
public List<Long> findMemberIds(Long lockerPeriodId) {
throw new UnsupportedOperationException();
}

@Override
public Optional<Long> findAppliedLockerId(Long lockerPeriodId, Long memberId) {
return Optional.ofNullable(myLockerId);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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()));
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,9 @@ public interface RentalHistoryRepository {
*/
List<RentalHistory> findAllByMemberId(Long memberId, RentalStatus status);

/** 모든 회원의 대여 이력 중 해당 상태인 것. 반납 알림 대상을 고를 때 쓴다. */
List<RentalHistory> findAllByStatus(RentalStatus status);

RentalHistory save(RentalHistory history);

/** 같은 회원이 같은 물품을 이미 대여 중인지(상태가 RENTAL인 이력이 있는지) 확인한다. */
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,12 @@ public interface RentalHistoryService {
*/
List<RentalRecord> getReturnRequiredRentals(Long memberId);

/**
* 모든 회원의 반납해야 하는 대여를 조회한다. 반납 기한 임박·연체 알림 대상을 고를 때 쓴다.
* 반납 정책이 없는 물품의 대여는 기한을 계산할 수 없어 제외한다.
*/
List<RentalRecord> getAllReturnRequiredRentals();

/**
* 반납 신청. 본인 소유의 대여 중(RENTAL) 이력만 대상이며, 신청 즉시 반납 완료로 전이한다
* (운영진의 최종 확인 단계가 없어서다).
Expand Down
Loading
Loading