From 21d087bd4ca9262218f4d930819db7ca6f9a24b5 Mon Sep 17 00:00:00 2001 From: Kkang Date: Mon, 23 Feb 2026 19:00:14 +0900 Subject: [PATCH 1/4] =?UTF-8?q?feat:=20=EC=9D=BC=EC=A0=95=20=EC=95=8C?= =?UTF-8?q?=EB=A6=BC=20=EB=8D=B0=EC=9D=B4=ED=84=B0=20=EA=B3=84=EC=B8=B5=20?= =?UTF-8?q?=EA=B5=AC=ED=98=84=20(#162)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ScheduleAlram enum에 minutes 필드 추가 (TEN_MINUTE=10, THIRTY_MINUTE=30, ONE_DAY=1440) - ScheduleReminderData 완성 (scheduleId, scheduleTitle, reminderType, isAllDay) - ScheduleReminderJob 엔티티 생성 (중복 방지 유니크 제약 포함) - ScheduleReminderJobRepository + QueryDSL Custom 구현 --- .../domain/data/ScheduleReminderData.java | 20 ++++- .../entity/ScheduleReminderJob.java | 59 ++++++++++++++ .../ScheduleReminderJobRepository.java | 12 +++ .../ScheduleReminderJobRepositoryCustom.java | 22 ++++++ ...heduleReminderJobRepositoryCustomImpl.java | 76 +++++++++++++++++++ .../persistence/vo/ScheduleAlram.java | 15 +++- 6 files changed, 199 insertions(+), 5 deletions(-) create mode 100644 src/main/java/im/toduck/domain/schedule/persistence/entity/ScheduleReminderJob.java create mode 100644 src/main/java/im/toduck/domain/schedule/persistence/repository/ScheduleReminderJobRepository.java create mode 100644 src/main/java/im/toduck/domain/schedule/persistence/repository/querydsl/ScheduleReminderJobRepositoryCustom.java create mode 100644 src/main/java/im/toduck/domain/schedule/persistence/repository/querydsl/ScheduleReminderJobRepositoryCustomImpl.java diff --git a/src/main/java/im/toduck/domain/notification/domain/data/ScheduleReminderData.java b/src/main/java/im/toduck/domain/notification/domain/data/ScheduleReminderData.java index aa919faf..39d86397 100644 --- a/src/main/java/im/toduck/domain/notification/domain/data/ScheduleReminderData.java +++ b/src/main/java/im/toduck/domain/notification/domain/data/ScheduleReminderData.java @@ -1,5 +1,9 @@ package im.toduck.domain.notification.domain.data; +import com.fasterxml.jackson.annotation.JsonProperty; + +import im.toduck.domain.schedule.persistence.vo.ScheduleAlram; +import lombok.AllArgsConstructor; import lombok.Getter; import lombok.NoArgsConstructor; @@ -8,7 +12,19 @@ */ @Getter @NoArgsConstructor +@AllArgsConstructor public class ScheduleReminderData extends AbstractNotificationData { - // TODO: 필요한 필드 추가 - // 예상 필드: 일정 ID, 일정 제목, 일정 시작 시간, 남은 시간 등 + private Long scheduleId; + private String scheduleTitle; + private ScheduleAlram reminderType; + @JsonProperty("allDay") + private boolean isAllDay; + + public static ScheduleReminderData of( + final Long scheduleId, + final String scheduleTitle, + final ScheduleAlram reminderType, + final boolean isAllDay) { + return new ScheduleReminderData(scheduleId, scheduleTitle, reminderType, isAllDay); + } } diff --git a/src/main/java/im/toduck/domain/schedule/persistence/entity/ScheduleReminderJob.java b/src/main/java/im/toduck/domain/schedule/persistence/entity/ScheduleReminderJob.java new file mode 100644 index 00000000..061ef9e8 --- /dev/null +++ b/src/main/java/im/toduck/domain/schedule/persistence/entity/ScheduleReminderJob.java @@ -0,0 +1,59 @@ +package im.toduck.domain.schedule.persistence.entity; + +import java.time.LocalDate; +import java.time.LocalTime; + +import im.toduck.global.base.entity.BaseEntity; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import jakarta.persistence.UniqueConstraint; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Getter +@Table(name = "schedule_reminder_job", uniqueConstraints = { + @UniqueConstraint(name = "uk_schedule_reminder_date_time", columnNames = { "schedule_id", "reminder_date", + "reminder_time" }) +}) +@NoArgsConstructor +public class ScheduleReminderJob extends BaseEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "schedule_id", nullable = false) + private Long scheduleId; + + @Column(name = "user_id", nullable = false) + private Long userId; + + @Column(name = "reminder_date", nullable = false) + private LocalDate reminderDate; + + @Column(name = "reminder_time", nullable = false) + private LocalTime reminderTime; + + @Column(name = "job_key", nullable = false) + private String jobKey; + + @Builder + private ScheduleReminderJob( + final Long scheduleId, + final Long userId, + final LocalDate reminderDate, + final LocalTime reminderTime, + final String jobKey) { + this.scheduleId = scheduleId; + this.userId = userId; + this.reminderDate = reminderDate; + this.reminderTime = reminderTime; + this.jobKey = jobKey; + } +} diff --git a/src/main/java/im/toduck/domain/schedule/persistence/repository/ScheduleReminderJobRepository.java b/src/main/java/im/toduck/domain/schedule/persistence/repository/ScheduleReminderJobRepository.java new file mode 100644 index 00000000..c3946dbe --- /dev/null +++ b/src/main/java/im/toduck/domain/schedule/persistence/repository/ScheduleReminderJobRepository.java @@ -0,0 +1,12 @@ +package im.toduck.domain.schedule.persistence.repository; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import im.toduck.domain.schedule.persistence.entity.ScheduleReminderJob; +import im.toduck.domain.schedule.persistence.repository.querydsl.ScheduleReminderJobRepositoryCustom; + +@Repository +public interface ScheduleReminderJobRepository + extends JpaRepository, ScheduleReminderJobRepositoryCustom { +} diff --git a/src/main/java/im/toduck/domain/schedule/persistence/repository/querydsl/ScheduleReminderJobRepositoryCustom.java b/src/main/java/im/toduck/domain/schedule/persistence/repository/querydsl/ScheduleReminderJobRepositoryCustom.java new file mode 100644 index 00000000..0991c685 --- /dev/null +++ b/src/main/java/im/toduck/domain/schedule/persistence/repository/querydsl/ScheduleReminderJobRepositoryCustom.java @@ -0,0 +1,22 @@ +package im.toduck.domain.schedule.persistence.repository.querydsl; + +import java.time.LocalDate; +import java.time.LocalTime; +import java.util.List; + +import im.toduck.domain.schedule.persistence.entity.ScheduleReminderJob; + +public interface ScheduleReminderJobRepositoryCustom { + + List findByScheduleId(final Long scheduleId); + + List findByScheduleIdAndReminderDateGreaterThanEqual(final Long scheduleId, + final LocalDate date); + + void deleteByScheduleId(final Long scheduleId); + + void deleteByScheduleIdAndReminderDateAfter(final Long scheduleId, final LocalDate date); + + boolean existsByScheduleIdAndReminderDateAndReminderTime(final Long scheduleId, final LocalDate reminderDate, + final LocalTime reminderTime); +} diff --git a/src/main/java/im/toduck/domain/schedule/persistence/repository/querydsl/ScheduleReminderJobRepositoryCustomImpl.java b/src/main/java/im/toduck/domain/schedule/persistence/repository/querydsl/ScheduleReminderJobRepositoryCustomImpl.java new file mode 100644 index 00000000..365eaf7e --- /dev/null +++ b/src/main/java/im/toduck/domain/schedule/persistence/repository/querydsl/ScheduleReminderJobRepositoryCustomImpl.java @@ -0,0 +1,76 @@ +package im.toduck.domain.schedule.persistence.repository.querydsl; + +import java.time.LocalDate; +import java.time.LocalTime; +import java.util.List; + +import org.springframework.stereotype.Repository; + +import com.querydsl.jpa.impl.JPAQueryFactory; + +import im.toduck.domain.schedule.persistence.entity.QScheduleReminderJob; +import im.toduck.domain.schedule.persistence.entity.ScheduleReminderJob; +import lombok.RequiredArgsConstructor; + +@Repository +@RequiredArgsConstructor +public class ScheduleReminderJobRepositoryCustomImpl implements ScheduleReminderJobRepositoryCustom { + + private final JPAQueryFactory queryFactory; + private final QScheduleReminderJob qScheduleReminderJob = QScheduleReminderJob.scheduleReminderJob; + + @Override + public List findByScheduleId(final Long scheduleId) { + return queryFactory + .selectFrom(qScheduleReminderJob) + .where(qScheduleReminderJob.scheduleId.eq(scheduleId)) + .fetch(); + } + + @Override + public List findByScheduleIdAndReminderDateGreaterThanEqual( + final Long scheduleId, + final LocalDate date) { + return queryFactory + .selectFrom(qScheduleReminderJob) + .where( + qScheduleReminderJob.scheduleId.eq(scheduleId), + qScheduleReminderJob.reminderDate.goe(date)) + .fetch(); + } + + @Override + public void deleteByScheduleId(final Long scheduleId) { + queryFactory + .delete(qScheduleReminderJob) + .where(qScheduleReminderJob.scheduleId.eq(scheduleId)) + .execute(); + } + + @Override + public void deleteByScheduleIdAndReminderDateAfter(final Long scheduleId, final LocalDate date) { + queryFactory + .delete(qScheduleReminderJob) + .where( + qScheduleReminderJob.scheduleId.eq(scheduleId), + qScheduleReminderJob.reminderDate.goe(date)) + .execute(); + } + + @Override + public boolean existsByScheduleIdAndReminderDateAndReminderTime( + final Long scheduleId, + final LocalDate reminderDate, + final LocalTime reminderTime) { + Integer fetchOne = queryFactory + .selectOne() + .from(qScheduleReminderJob) + .where( + qScheduleReminderJob.scheduleId.eq(scheduleId), + qScheduleReminderJob.reminderDate.eq(reminderDate), + qScheduleReminderJob.reminderTime.eq(reminderTime)) + .fetchFirst(); + + return fetchOne != null; + } +} diff --git a/src/main/java/im/toduck/domain/schedule/persistence/vo/ScheduleAlram.java b/src/main/java/im/toduck/domain/schedule/persistence/vo/ScheduleAlram.java index d504352b..23cf38d4 100644 --- a/src/main/java/im/toduck/domain/schedule/persistence/vo/ScheduleAlram.java +++ b/src/main/java/im/toduck/domain/schedule/persistence/vo/ScheduleAlram.java @@ -1,7 +1,16 @@ package im.toduck.domain.schedule.persistence.vo; +import lombok.Getter; + +@Getter public enum ScheduleAlram { - TEN_MINUTE, // 10분전 - THIRTY_MINUTE, // 30분전 - ONE_DAY, // 1일전 + TEN_MINUTE(10), // 10분전 + THIRTY_MINUTE(30), // 30분전 + ONE_DAY(1440); // 1일전 (24 * 60) + + private final int minutes; + + ScheduleAlram(final int minutes) { + this.minutes = minutes; + } } From 502b30015c06f7d5a90d4a76b15fc17dfdab1ba8 Mon Sep 17 00:00:00 2001 From: Kkang Date: Mon, 23 Feb 2026 19:00:38 +0900 Subject: [PATCH 2/4] =?UTF-8?q?feat:=20=EC=9D=BC=EC=A0=95=20=EC=95=8C?= =?UTF-8?q?=EB=A6=BC=20=EC=8A=A4=EC=BC=80=EC=A4=84=EB=9F=AC=20=EB=B0=8F=20?= =?UTF-8?q?=EC=95=8C=EB=A6=BC=20=EC=9D=B4=EB=B2=A4=ED=8A=B8=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84=20(#162)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ScheduleReminderNotificationEvent 생성 (종일/일반 메시지 분기) - ScheduleReminderQuartzJob Quartz Job 구현 - ScheduleReminderSchedulerService Quartz 스케줄링 서비스 구현 --- .../ScheduleReminderNotificationEvent.java | 58 ++++ .../ScheduleReminderSchedulerService.java | 263 ++++++++++++++++++ .../job/ScheduleReminderQuartzJob.java | 48 ++++ 3 files changed, 369 insertions(+) create mode 100644 src/main/java/im/toduck/domain/notification/domain/event/ScheduleReminderNotificationEvent.java create mode 100644 src/main/java/im/toduck/domain/schedule/domain/service/ScheduleReminderSchedulerService.java create mode 100644 src/main/java/im/toduck/domain/schedule/infrastructure/scheduler/job/ScheduleReminderQuartzJob.java diff --git a/src/main/java/im/toduck/domain/notification/domain/event/ScheduleReminderNotificationEvent.java b/src/main/java/im/toduck/domain/notification/domain/event/ScheduleReminderNotificationEvent.java new file mode 100644 index 00000000..55beaa4f --- /dev/null +++ b/src/main/java/im/toduck/domain/notification/domain/event/ScheduleReminderNotificationEvent.java @@ -0,0 +1,58 @@ +package im.toduck.domain.notification.domain.event; + +import im.toduck.domain.notification.domain.data.ScheduleReminderData; +import im.toduck.domain.notification.persistence.entity.NotificationType; +import im.toduck.domain.schedule.persistence.vo.ScheduleAlram; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@NoArgsConstructor(access = AccessLevel.PRIVATE) +public class ScheduleReminderNotificationEvent extends NotificationEvent { + + private ScheduleReminderNotificationEvent(final Long userId, final ScheduleReminderData data) { + super(userId, NotificationType.SCHEDULE_REMINDER, data); + } + + public static ScheduleReminderNotificationEvent of( + final Long userId, + final Long scheduleId, + final String scheduleTitle, + final ScheduleAlram reminderType, + final boolean isAllDay) { + return new ScheduleReminderNotificationEvent( + userId, + ScheduleReminderData.of(scheduleId, scheduleTitle, reminderType, isAllDay)); + } + + @Override + public String getInAppTitle() { + return ""; + } + + @Override + public String getInAppBody() { + return ""; + } + + @Override + public String getPushTitle() { + return getData().getScheduleTitle(); + } + + @Override + public String getPushBody() { + if (getData().isAllDay()) { + return "일정 하루 전! 준비된 하루를 시작해볼까요? 📅"; + } + + ScheduleAlram reminderType = getData().getReminderType(); + return String.format("일정 %d분 전! 준비된 하루를 시작해볼까요? 📅", reminderType.getMinutes()); + } + + @Override + public String getActionUrl() { + return "toduck://todo"; + } +} diff --git a/src/main/java/im/toduck/domain/schedule/domain/service/ScheduleReminderSchedulerService.java b/src/main/java/im/toduck/domain/schedule/domain/service/ScheduleReminderSchedulerService.java new file mode 100644 index 00000000..d3b9726a --- /dev/null +++ b/src/main/java/im/toduck/domain/schedule/domain/service/ScheduleReminderSchedulerService.java @@ -0,0 +1,263 @@ +package im.toduck.domain.schedule.domain.service; + +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.LocalTime; +import java.time.ZoneId; +import java.util.Date; +import java.util.List; + +import org.quartz.JobBuilder; +import org.quartz.JobDataMap; +import org.quartz.JobDetail; +import org.quartz.JobKey; +import org.quartz.Scheduler; +import org.quartz.SchedulerException; +import org.quartz.SimpleScheduleBuilder; +import org.quartz.Trigger; +import org.quartz.TriggerBuilder; +import org.quartz.TriggerKey; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import im.toduck.domain.schedule.infrastructure.scheduler.job.ScheduleReminderQuartzJob; +import im.toduck.domain.schedule.persistence.entity.Schedule; +import im.toduck.domain.schedule.persistence.entity.ScheduleReminderJob; +import im.toduck.domain.schedule.persistence.repository.ScheduleReminderJobRepository; +import im.toduck.domain.schedule.persistence.vo.ScheduleAlram; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@Service +@RequiredArgsConstructor +@ConditionalOnProperty(name = "spring.quartz.auto-startup", havingValue = "true", matchIfMissing = true) +public class ScheduleReminderSchedulerService { + + private final Scheduler scheduler; + private final ScheduleReminderJobRepository scheduleReminderJobRepository; + + private static final String JOB_GROUP = "SCHEDULE_REMINDER"; + private static final String TRIGGER_GROUP = "SCHEDULE_REMINDER_TRIGGER"; + private static final LocalTime ALL_DAY_REMINDER_TIME = LocalTime.of(10, 0); + private static final LocalTime BATCH_EXECUTION_TIME = LocalTime.of(3, 58); + + @Transactional + public void scheduleScheduleReminders( + final Schedule schedule, + final LocalDateTime currentDateTime, + final boolean isBatchScheduling) { + ScheduleAlram alarm = schedule.getScheduleTime().getAlarm(); + if (alarm == null) { + log.debug("알림이 비활성화된 일정 스킵 - ScheduleId: {}", schedule.getId()); + return; + } + + try { + LocalDateTime nextBatchTime = calculateNextBatchExecutionTime(currentDateTime); + LocalDate startDate = schedule.getScheduleDate().getStartDate(); + LocalDate endDate = schedule.getScheduleDate().getEndDate(); + + // 현재 날짜 이전의 날짜는 스킵 + LocalDate effectiveStartDate = startDate.isBefore(currentDateTime.toLocalDate()) + ? currentDateTime.toLocalDate() + : startDate; + + // 다음 배치 시간까지만 스케줄링 (배치가 아닌 경우 하루 더) + LocalDateTime scheduleUntil = isBatchScheduling ? nextBatchTime : nextBatchTime.plusDays(1); + + scheduleRemindersInDateRange(schedule, effectiveStartDate, endDate, currentDateTime, scheduleUntil); + + log.debug("일정 알림 스케줄링 완료 - ScheduleId: {}, isBatch: {}", schedule.getId(), isBatchScheduling); + } catch (Exception e) { + log.error("일정 알림 스케줄링 실패 - ScheduleId: {}", schedule.getId(), e); + throw new RuntimeException("일정 알림 스케줄링 중 오류가 발생했습니다", e); + } + } + + private LocalDateTime calculateNextBatchExecutionTime(final LocalDateTime currentTime) { + LocalDate currentDate = currentTime.toLocalDate(); + LocalDateTime todayBatchTime = currentDate.atTime(BATCH_EXECUTION_TIME); + + if (currentTime.isBefore(todayBatchTime)) { + return todayBatchTime; + } + return currentDate.plusDays(1).atTime(BATCH_EXECUTION_TIME); + } + + private void scheduleRemindersInDateRange( + final Schedule schedule, + final LocalDate startDate, + final LocalDate endDate, + final LocalDateTime currentDateTime, + final LocalDateTime scheduleUntil) { + LocalDate currentDate = startDate; + while (!currentDate.isAfter(endDate)) { + if (shouldScheduleForDate(schedule, currentDate)) { + LocalDateTime reminderTime = calculateReminderTime(schedule, currentDate); + + if (shouldScheduleReminder(reminderTime, currentDateTime, scheduleUntil)) { + scheduleReminderForDate(schedule, currentDate, reminderTime); + } + } + currentDate = currentDate.plusDays(1); + } + } + + private boolean shouldScheduleForDate(final Schedule schedule, final LocalDate date) { + if (schedule.getDaysOfWeekBitmask() == null) { + return true; // 반복 없는 일정은 모든 날짜에서 스케줄링 + } + return schedule.getDaysOfWeekBitmask().includesDayOf(date); + } + + private LocalDateTime calculateReminderTime(final Schedule schedule, final LocalDate scheduleDate) { + ScheduleAlram alarm = schedule.getScheduleTime().getAlarm(); + + if (schedule.getScheduleTime().getIsAllDay()) { + // 종일 일정: 하루 전 10시에 알림 + return scheduleDate.minusDays(1).atTime(ALL_DAY_REMINDER_TIME); + } + + // 일반 일정: 일정 시간에서 알림 분만큼 이전 + LocalDateTime scheduleDateTime = scheduleDate.atTime(schedule.getScheduleTime().getTime()); + return scheduleDateTime.minusMinutes(alarm.getMinutes()); + } + + private boolean shouldScheduleReminder( + final LocalDateTime reminderTime, + final LocalDateTime currentTime, + final LocalDateTime scheduleUntil) { + return !reminderTime.isBefore(currentTime) && reminderTime.isBefore(scheduleUntil); + } + + private void scheduleReminderForDate( + final Schedule schedule, + final LocalDate scheduleDate, + final LocalDateTime reminderDateTime) { + try { + if (isReminderAlreadyScheduled(schedule.getId(), reminderDateTime)) { + log.debug("이미 스케줄링된 알림 스킵 - ScheduleId: {}, DateTime: {}", + schedule.getId(), reminderDateTime); + return; + } + + String jobKey = createJobKey(schedule.getId(), scheduleDate, reminderDateTime.toLocalTime()); + JobDetail jobDetail = createJobDetail(schedule, jobKey); + Trigger trigger = createTrigger(jobDetail, reminderDateTime, jobKey); + + scheduler.scheduleJob(jobDetail, trigger); + saveReminderJobRecord(schedule, reminderDateTime, jobKey); + + log.debug("일정 알림 스케줄링 성공 - ScheduleId: {}, ScheduleDate: {}, ReminderDateTime: {}", + schedule.getId(), scheduleDate, reminderDateTime); + + } catch (SchedulerException e) { + log.error("일정 알림 스케줄링 실패 - ScheduleId: {}, ScheduleDate: {}", + schedule.getId(), scheduleDate, e); + } + } + + private boolean isReminderAlreadyScheduled(final Long scheduleId, final LocalDateTime reminderDateTime) { + return scheduleReminderJobRepository.existsByScheduleIdAndReminderDateAndReminderTime( + scheduleId, reminderDateTime.toLocalDate(), reminderDateTime.toLocalTime()); + } + + private String createJobKey(final Long scheduleId, final LocalDate scheduleDate, final LocalTime reminderTime) { + return String.format("schedule_%d_%s_%s", + scheduleId, + scheduleDate.toString(), + reminderTime.toString().replace(":", "")); + } + + private JobDetail createJobDetail(final Schedule schedule, final String jobKey) { + JobDataMap jobDataMap = new JobDataMap(); + jobDataMap.put("userId", schedule.getUser().getId()); + jobDataMap.put("scheduleId", schedule.getId()); + jobDataMap.put("scheduleTitle", schedule.getTitle()); + jobDataMap.put("reminderType", schedule.getScheduleTime().getAlarm().name()); + jobDataMap.put("isAllDay", schedule.getScheduleTime().getIsAllDay()); + + return JobBuilder.newJob(ScheduleReminderQuartzJob.class) + .withIdentity(new JobKey(jobKey, JOB_GROUP)) + .withDescription("일정 알림: " + schedule.getTitle()) + .usingJobData(jobDataMap) + .build(); + } + + private Trigger createTrigger( + final JobDetail jobDetail, + final LocalDateTime reminderDateTime, + final String jobKey) { + Date triggerTime = Date.from(reminderDateTime.atZone(ZoneId.systemDefault()).toInstant()); + + return TriggerBuilder.newTrigger() + .forJob(jobDetail) + .withIdentity(new TriggerKey(jobKey, TRIGGER_GROUP)) + .startAt(triggerTime) + .withSchedule(SimpleScheduleBuilder.simpleSchedule() + .withMisfireHandlingInstructionFireNow()) + .build(); + } + + private void saveReminderJobRecord( + final Schedule schedule, + final LocalDateTime reminderDateTime, + final String jobKey) { + ScheduleReminderJob reminderJob = ScheduleReminderJob.builder() + .scheduleId(schedule.getId()) + .userId(schedule.getUser().getId()) + .reminderDate(reminderDateTime.toLocalDate()) + .reminderTime(reminderDateTime.toLocalTime()) + .jobKey(jobKey) + .build(); + + scheduleReminderJobRepository.save(reminderJob); + } + + @Transactional + public void cancelAllScheduleReminders(final Long scheduleId) { + log.info("일정의 모든 알림 취소 시작 - ScheduleId: {}", scheduleId); + + List reminderJobs = scheduleReminderJobRepository.findByScheduleId(scheduleId); + int cancelledCount = deleteScheduledJobs(reminderJobs); + + scheduleReminderJobRepository.deleteByScheduleId(scheduleId); + log.info("일정의 모든 알림 취소 완료 - ScheduleId: {}, 취소된 알림 수: {}", + scheduleId, cancelledCount); + } + + @Transactional + public void cancelFutureScheduleReminders(final Long scheduleId, final LocalDate fromDate) { + log.info("일정의 미래 알림 취소 시작 - ScheduleId: {}, FromDate: {}", scheduleId, fromDate); + + List reminderJobs = scheduleReminderJobRepository + .findByScheduleIdAndReminderDateGreaterThanEqual(scheduleId, fromDate); + + int cancelledCount = deleteScheduledJobs(reminderJobs); + + scheduleReminderJobRepository.deleteByScheduleIdAndReminderDateAfter(scheduleId, fromDate); + log.info("일정의 미래 알림 취소 완료 - ScheduleId: {}, 취소된 알림 수: {}", + scheduleId, cancelledCount); + } + + private int deleteScheduledJobs(final List reminderJobs) { + int successCount = 0; + + for (ScheduleReminderJob reminderJob : reminderJobs) { + try { + JobKey jobKey = new JobKey(reminderJob.getJobKey(), JOB_GROUP); + if (scheduler.checkExists(jobKey)) { + scheduler.deleteJob(jobKey); + successCount++; + log.debug("Quartz Job 삭제 성공 - JobKey: {}", reminderJob.getJobKey()); + } + } catch (SchedulerException e) { + log.error("Quartz Job 삭제 실패 - JobKey: {}", reminderJob.getJobKey(), e); + } + } + + return successCount; + } +} diff --git a/src/main/java/im/toduck/domain/schedule/infrastructure/scheduler/job/ScheduleReminderQuartzJob.java b/src/main/java/im/toduck/domain/schedule/infrastructure/scheduler/job/ScheduleReminderQuartzJob.java new file mode 100644 index 00000000..ad8e4cd3 --- /dev/null +++ b/src/main/java/im/toduck/domain/schedule/infrastructure/scheduler/job/ScheduleReminderQuartzJob.java @@ -0,0 +1,48 @@ +package im.toduck.domain.schedule.infrastructure.scheduler.job; + +import org.quartz.JobDataMap; +import org.quartz.JobExecutionContext; +import org.quartz.JobExecutionException; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.scheduling.quartz.QuartzJobBean; +import org.springframework.stereotype.Component; + +import im.toduck.domain.notification.domain.event.ScheduleReminderNotificationEvent; +import im.toduck.domain.notification.messaging.NotificationMessagePublisher; +import im.toduck.domain.schedule.persistence.vo.ScheduleAlram; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@Component +public class ScheduleReminderQuartzJob extends QuartzJobBean { + + @Autowired + private NotificationMessagePublisher notificationMessagePublisher; + + @Override + protected void executeInternal(final JobExecutionContext context) throws JobExecutionException { + JobDataMap jobDataMap = context.getMergedJobDataMap(); + + Long userId = jobDataMap.getLong("userId"); + Long scheduleId = jobDataMap.getLong("scheduleId"); + String scheduleTitle = jobDataMap.getString("scheduleTitle"); + String reminderTypeName = jobDataMap.getString("reminderType"); + boolean isAllDay = jobDataMap.getBoolean("isAllDay"); + + ScheduleAlram reminderType = ScheduleAlram.valueOf(reminderTypeName); + + log.info("일정 알림 발송 - UserId: {}, ScheduleId: {}, Title: {}", userId, scheduleId, scheduleTitle); + + try { + ScheduleReminderNotificationEvent event = ScheduleReminderNotificationEvent.of( + userId, scheduleId, scheduleTitle, reminderType, isAllDay); + + notificationMessagePublisher.publishNotificationEvent(event); + + log.info("일정 알림 이벤트 발행 완료 - ScheduleId: {}", scheduleId); + } catch (Exception e) { + log.error("일정 알림 이벤트 발행 실패 - ScheduleId: {}", scheduleId, e); + throw new JobExecutionException("일정 알림 이벤트 발행 실패", e); + } + } +} From 67bdbe276f6ef1583b6e85a544622d905fd5d245 Mon Sep 17 00:00:00 2001 From: Kkang Date: Mon, 23 Feb 2026 19:00:55 +0900 Subject: [PATCH 3/4] =?UTF-8?q?feat:=20=EC=9D=BC=EC=A0=95=20=EC=95=8C?= =?UTF-8?q?=EB=A6=BC=20=EC=9D=B4=EB=B2=A4=ED=8A=B8=20=EC=8B=9C=EC=8A=A4?= =?UTF-8?q?=ED=85=9C=20=EB=B0=8F=20UseCase=20=EC=97=B0=EB=8F=99=20(#162)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ScheduleCreated/Updated/DeletedEvent 도메인 이벤트 생성 - ScheduleReminderEventListener 이벤트 리스너 구현 - ScheduleModifyUseCase에 이벤트 발행 추가 (생성/수정/삭제) - ScheduleReminderBatchSchedulerUseCase 일일 배치 스케줄러 구현 - ScheduleReadService에 활성 일정 조회 메서드 추가 - ScheduleRepositoryCustom에 findActiveSchedulesWithAlarmForDates 추가 --- .../domain/event/ScheduleCreatedEvent.java | 14 ++++ .../domain/event/ScheduleDeletedEvent.java | 12 +++ .../event/ScheduleReminderEventListener.java | 75 +++++++++++++++++++ .../domain/event/ScheduleUpdatedEvent.java | 39 ++++++++++ .../domain/service/ScheduleReadService.java | 29 +++---- .../domain/usecase/ScheduleModifyUseCase.java | 42 +++++++++-- ...ScheduleReminderBatchSchedulerUseCase.java | 44 +++++++++++ .../querydsl/ScheduleRepositoryCustom.java | 7 +- .../ScheduleRepositoryCustomImpl.java | 52 +++++++------ 9 files changed, 268 insertions(+), 46 deletions(-) create mode 100644 src/main/java/im/toduck/domain/schedule/domain/event/ScheduleCreatedEvent.java create mode 100644 src/main/java/im/toduck/domain/schedule/domain/event/ScheduleDeletedEvent.java create mode 100644 src/main/java/im/toduck/domain/schedule/domain/event/ScheduleReminderEventListener.java create mode 100644 src/main/java/im/toduck/domain/schedule/domain/event/ScheduleUpdatedEvent.java create mode 100644 src/main/java/im/toduck/domain/schedule/domain/usecase/ScheduleReminderBatchSchedulerUseCase.java diff --git a/src/main/java/im/toduck/domain/schedule/domain/event/ScheduleCreatedEvent.java b/src/main/java/im/toduck/domain/schedule/domain/event/ScheduleCreatedEvent.java new file mode 100644 index 00000000..e08591a6 --- /dev/null +++ b/src/main/java/im/toduck/domain/schedule/domain/event/ScheduleCreatedEvent.java @@ -0,0 +1,14 @@ +package im.toduck.domain.schedule.domain.event; + +import lombok.Getter; + +@Getter +public class ScheduleCreatedEvent { + private final Long scheduleId; + private final Long userId; + + public ScheduleCreatedEvent(final Long scheduleId, final Long userId) { + this.scheduleId = scheduleId; + this.userId = userId; + } +} diff --git a/src/main/java/im/toduck/domain/schedule/domain/event/ScheduleDeletedEvent.java b/src/main/java/im/toduck/domain/schedule/domain/event/ScheduleDeletedEvent.java new file mode 100644 index 00000000..779e03ef --- /dev/null +++ b/src/main/java/im/toduck/domain/schedule/domain/event/ScheduleDeletedEvent.java @@ -0,0 +1,12 @@ +package im.toduck.domain.schedule.domain.event; + +import lombok.Getter; + +@Getter +public class ScheduleDeletedEvent { + private final Long scheduleId; + + public ScheduleDeletedEvent(final Long scheduleId) { + this.scheduleId = scheduleId; + } +} diff --git a/src/main/java/im/toduck/domain/schedule/domain/event/ScheduleReminderEventListener.java b/src/main/java/im/toduck/domain/schedule/domain/event/ScheduleReminderEventListener.java new file mode 100644 index 00000000..57b15810 --- /dev/null +++ b/src/main/java/im/toduck/domain/schedule/domain/event/ScheduleReminderEventListener.java @@ -0,0 +1,75 @@ +package im.toduck.domain.schedule.domain.event; + +import java.time.LocalDate; +import java.time.LocalDateTime; + +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.scheduling.annotation.Async; +import org.springframework.stereotype.Component; +import org.springframework.transaction.event.TransactionPhase; +import org.springframework.transaction.event.TransactionalEventListener; + +import im.toduck.domain.schedule.domain.service.ScheduleReadService; +import im.toduck.domain.schedule.domain.service.ScheduleReminderSchedulerService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@Component +@RequiredArgsConstructor +@ConditionalOnProperty(name = "spring.quartz.auto-startup", havingValue = "true", matchIfMissing = true) +public class ScheduleReminderEventListener { + + private final ScheduleReadService scheduleReadService; + private final ScheduleReminderSchedulerService scheduleReminderSchedulerService; + + @Async + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) + public void handleScheduleCreated(final ScheduleCreatedEvent event) { + log.info("일정 생성 이벤트 처리 시작 - ScheduleId: {}", event.getScheduleId()); + + try { + scheduleReadService.getScheduleById(event.getScheduleId()) + .ifPresent(schedule -> { + LocalDateTime currentDateTime = LocalDateTime.now(); + scheduleReminderSchedulerService.scheduleScheduleReminders(schedule, currentDateTime, false); + }); + } catch (Exception e) { + log.error("일정 생성 이벤트 처리 중 오류 발생 - ScheduleId: {}", event.getScheduleId(), e); + } + } + + @Async + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) + public void handleScheduleUpdated(final ScheduleUpdatedEvent event) { + log.info("일정 수정 이벤트 처리 시작 - ScheduleId: {}", event.getScheduleId()); + + try { + if (event.isReminderRelatedChanged()) { + scheduleReminderSchedulerService.cancelFutureScheduleReminders( + event.getScheduleId(), LocalDate.now()); + + scheduleReadService.getScheduleById(event.getScheduleId()) + .ifPresent(schedule -> { + LocalDateTime currentDateTime = LocalDateTime.now(); + scheduleReminderSchedulerService.scheduleScheduleReminders( + schedule, currentDateTime, false); + }); + } + } catch (Exception e) { + log.error("일정 수정 이벤트 처리 중 오류 발생 - ScheduleId: {}", event.getScheduleId(), e); + } + } + + @Async + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) + public void handleScheduleDeleted(final ScheduleDeletedEvent event) { + log.info("일정 삭제 이벤트 처리 시작 - ScheduleId: {}", event.getScheduleId()); + + try { + scheduleReminderSchedulerService.cancelAllScheduleReminders(event.getScheduleId()); + } catch (Exception e) { + log.error("일정 삭제 이벤트 처리 중 오류 발생 - ScheduleId: {}", event.getScheduleId(), e); + } + } +} diff --git a/src/main/java/im/toduck/domain/schedule/domain/event/ScheduleUpdatedEvent.java b/src/main/java/im/toduck/domain/schedule/domain/event/ScheduleUpdatedEvent.java new file mode 100644 index 00000000..1a3c3241 --- /dev/null +++ b/src/main/java/im/toduck/domain/schedule/domain/event/ScheduleUpdatedEvent.java @@ -0,0 +1,39 @@ +package im.toduck.domain.schedule.domain.event; + +import lombok.Getter; + +@Getter +public class ScheduleUpdatedEvent { + private final Long scheduleId; + private final Long userId; + private final boolean isAlarmChanged; + private final boolean isTimeChanged; + private final boolean isAllDayChanged; + private final boolean isTitleChanged; + private final boolean isDateChanged; + private final boolean isDaysOfWeekChanged; + + public ScheduleUpdatedEvent( + final Long scheduleId, + final Long userId, + final boolean isAlarmChanged, + final boolean isTimeChanged, + final boolean isAllDayChanged, + final boolean isTitleChanged, + final boolean isDateChanged, + final boolean isDaysOfWeekChanged) { + this.scheduleId = scheduleId; + this.userId = userId; + this.isAlarmChanged = isAlarmChanged; + this.isTimeChanged = isTimeChanged; + this.isAllDayChanged = isAllDayChanged; + this.isTitleChanged = isTitleChanged; + this.isDateChanged = isDateChanged; + this.isDaysOfWeekChanged = isDaysOfWeekChanged; + } + + public boolean isReminderRelatedChanged() { + return isAlarmChanged || isTimeChanged || isAllDayChanged || isTitleChanged + || isDateChanged || isDaysOfWeekChanged; + } +} diff --git a/src/main/java/im/toduck/domain/schedule/domain/service/ScheduleReadService.java b/src/main/java/im/toduck/domain/schedule/domain/service/ScheduleReadService.java index ace73f3c..f71c4d1f 100644 --- a/src/main/java/im/toduck/domain/schedule/domain/service/ScheduleReadService.java +++ b/src/main/java/im/toduck/domain/schedule/domain/service/ScheduleReadService.java @@ -35,19 +35,19 @@ public class ScheduleReadService { public ScheduleHeadResponse getRangeSchedule(User user, LocalDate startDate, LocalDate endDate) { List scheduleHeadDtos = new ArrayList<>(); scheduleRepository.findSchedules(user.getId(), startDate, endDate) - .forEach(schedule -> { - List scheduleRecordList = scheduleRecordRepository - .findByScheduleAndBetweenStartDateAndEndDate(schedule.getId(), startDate, endDate); - scheduleHeadDtos.add(ScheduleMapper.toScheduleHeadDto(schedule, scheduleRecordList)); - }); + .forEach(schedule -> { + List scheduleRecordList = scheduleRecordRepository + .findByScheduleAndBetweenStartDateAndEndDate(schedule.getId(), startDate, endDate); + scheduleHeadDtos.add(ScheduleMapper.toScheduleHeadDto(schedule, scheduleRecordList)); + }); return ScheduleMapper.toScheduleHeadResponse(startDate, endDate, scheduleHeadDtos); } @Transactional(readOnly = true) public ScheduleInfoResponse getSchedule(Long scheduleRecordId) { return scheduleRecordRepository.findScheduleRecordFetchJoinSchedule(scheduleRecordId) - .map(ScheduleMapper::toScheduleInfoResponse) - .orElseThrow(() -> CommonException.from(ExceptionCode.NOT_FOUND_SCHEDULE_RECORD)); + .map(ScheduleMapper::toScheduleInfoResponse) + .orElseThrow(() -> CommonException.from(ExceptionCode.NOT_FOUND_SCHEDULE_RECORD)); } public Optional getScheduleById(Long scheduleId) { @@ -56,7 +56,7 @@ public Optional getScheduleById(Long scheduleId) { public Schedule validateScheduleById(Long scheduleId) { return scheduleRepository.findById(scheduleId) - .orElseThrow(() -> CommonException.from(ExceptionCode.NOT_FOUND_SCHEDULE)); + .orElseThrow(() -> CommonException.from(ExceptionCode.NOT_FOUND_SCHEDULE)); } @Transactional(readOnly = true) @@ -80,16 +80,19 @@ public long getSchedulesCountByDateRange(final LocalDate startDate, final LocalD @Transactional(readOnly = true) public Map getSchedulesCountByDateRangeGroupByDate( - final LocalDate startDate, - final LocalDate endDate - ) { + final LocalDate startDate, + final LocalDate endDate) { LocalDateTime startDateTime = startDate.atStartOfDay(); LocalDateTime endDateTime = endDate.atTime(LocalTime.MAX); List dailyCounts = scheduleRepository.countByCreatedAtBetweenGroupByDate( - startDateTime, endDateTime - ); + startDateTime, endDateTime); return dailyCounts.stream().collect(Collectors.toMap(DailyCount::date, DailyCount::count)); } + + @Transactional(readOnly = true) + public List findActiveSchedulesWithAlarmForDates(final LocalDate startDate, final LocalDate endDate) { + return scheduleRepository.findActiveSchedulesWithAlarmForDates(startDate, endDate); + } } diff --git a/src/main/java/im/toduck/domain/schedule/domain/usecase/ScheduleModifyUseCase.java b/src/main/java/im/toduck/domain/schedule/domain/usecase/ScheduleModifyUseCase.java index ab33699f..75a378a8 100644 --- a/src/main/java/im/toduck/domain/schedule/domain/usecase/ScheduleModifyUseCase.java +++ b/src/main/java/im/toduck/domain/schedule/domain/usecase/ScheduleModifyUseCase.java @@ -2,9 +2,13 @@ import java.time.LocalDate; +import org.springframework.context.ApplicationEventPublisher; import org.springframework.transaction.annotation.Transactional; import org.springframework.validation.annotation.Validated; +import im.toduck.domain.schedule.domain.event.ScheduleCreatedEvent; +import im.toduck.domain.schedule.domain.event.ScheduleDeletedEvent; +import im.toduck.domain.schedule.domain.event.ScheduleUpdatedEvent; import im.toduck.domain.schedule.domain.service.ScheduleModifyService; import im.toduck.domain.schedule.domain.service.ScheduleReadService; import im.toduck.domain.schedule.persistence.entity.Schedule; @@ -31,14 +35,19 @@ public class ScheduleModifyUseCase { private final ScheduleReadService scheduleReadService; private final UserService userService; private final ScheduleModifyService scheduleModifyService; + private final ApplicationEventPublisher eventPublisher; @Transactional public ScheduleIdResponse createSchedule(final Long userId, final ScheduleCreateRequest request) { User user = userService.validateUserById(userId); Schedule schedule = Schedule.create(user, request); + Schedule savedSchedule = scheduleModifyService.save(schedule); - return ScheduleIdResponse.of(scheduleModifyService.save(schedule)); + log.info("일정 생성 - UserId: {}, ScheduleId: {}", userId, savedSchedule.getId()); + eventPublisher.publishEvent(new ScheduleCreatedEvent(savedSchedule.getId(), userId)); + + return ScheduleIdResponse.of(savedSchedule); } @Transactional(readOnly = true) @@ -71,25 +80,32 @@ public void deleteSchedule(Long userId, ScheduleDeleteRequest scheduleDeleteRequ userService.validateUserById(userId); Schedule schedule = scheduleReadService.getScheduleById(scheduleDeleteRequest.scheduleId()) - .orElseThrow(() -> CommonException.from(ExceptionCode.NOT_FOUND_SCHEDULE)); + .orElseThrow(() -> CommonException.from(ExceptionCode.NOT_FOUND_SCHEDULE)); if (isSingleDaySchedule(schedule)) { scheduleModifyService.deleteSingleDaySchedule(schedule, scheduleDeleteRequest); log.info("반복 X 하루 일정 삭제 성공 : {}", scheduleDeleteRequest.scheduleId()); + eventPublisher.publishEvent(new ScheduleDeletedEvent(scheduleDeleteRequest.scheduleId())); return; } if (scheduleDeleteRequest.isOneDayDeleted()) { scheduleModifyService.deleteOneDayDeletionForRepeatingSchedule(schedule, scheduleDeleteRequest); log.info("반복 일정 중 하루 삭제 성공 : {}", scheduleDeleteRequest.scheduleId()); + eventPublisher.publishEvent(new ScheduleUpdatedEvent( + scheduleDeleteRequest.scheduleId(), userId, + false, false, false, false, true, true)); return; } scheduleModifyService.deleteAfterDeletionForRepeatingSchedule(schedule, scheduleDeleteRequest); log.info("반복 일정 중 기간 삭제 성공 : {}", scheduleDeleteRequest.scheduleId()); + eventPublisher.publishEvent(new ScheduleUpdatedEvent( + scheduleDeleteRequest.scheduleId(), userId, + false, false, false, false, true, false)); } private boolean isSingleDaySchedule(Schedule schedule) { return schedule.getScheduleDate().getStartDate().equals(schedule.getScheduleDate().getEndDate()) - && schedule.getDaysOfWeekBitmask() == null; + && schedule.getDaysOfWeekBitmask() == null; } @Transactional @@ -97,22 +113,32 @@ public ScheduleIdResponse updateSchedule(Long userId, ScheduleModifyRequest requ userService.validateUserById(userId); Schedule schedule = scheduleReadService.getScheduleById(request.scheduleId()) - .orElseThrow(() -> CommonException.from(ExceptionCode.NOT_FOUND_SCHEDULE)); + .orElseThrow(() -> CommonException.from(ExceptionCode.NOT_FOUND_SCHEDULE)); if (isSingleDaySchedule(schedule) && !request.isOneDayDeleted()) { log.info("반복 X 하루 일정은 하루 삭제만 가능 : {}", request.scheduleId()); throw CommonException.from(ExceptionCode.ONE_DAY__NONREPEATABLE_SCHEDULE_CANNOT_AFTER_DATE_UPDATE); } if (!request.scheduleData().startDate().equals(request.scheduleData().endDate()) - && !request.isOneDayDeleted()) { + && !request.isOneDayDeleted()) { log.info("기간 일정으로 수정은 하루 삭제만 가능 scheduleId : {}", request.scheduleId()); throw CommonException.from(ExceptionCode.PERIOD_SCHEDULE_CANNOT_AFTER_DATE_UPDATE); } + + ScheduleIdResponse result; if (request.isOneDayDeleted()) { log.info("하루의 일정만 수정 : {}", request.scheduleId()); - return scheduleModifyService.updateSingleDate(schedule, request); + result = scheduleModifyService.updateSingleDate(schedule, request); + } else { + log.info("특정 날짜 이후 일괄 일정 수정 : {}", request.scheduleId()); + result = scheduleModifyService.updateAfterDate(schedule, request); } - log.info("특정 날짜 이후 일괄 일정 수정 : {}", request.scheduleId()); - return scheduleModifyService.updateAfterDate(schedule, request); + + // 원본 일정의 알림 재스케줄링을 위한 이벤트 발행 + eventPublisher.publishEvent(new ScheduleUpdatedEvent( + result.scheduleId(), userId, + true, true, true, true, true, true)); + + return result; } } diff --git a/src/main/java/im/toduck/domain/schedule/domain/usecase/ScheduleReminderBatchSchedulerUseCase.java b/src/main/java/im/toduck/domain/schedule/domain/usecase/ScheduleReminderBatchSchedulerUseCase.java new file mode 100644 index 00000000..a0069aa2 --- /dev/null +++ b/src/main/java/im/toduck/domain/schedule/domain/usecase/ScheduleReminderBatchSchedulerUseCase.java @@ -0,0 +1,44 @@ +package im.toduck.domain.schedule.domain.usecase; + +import java.time.LocalDate; +import java.time.LocalDateTime; + +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.transaction.annotation.Transactional; + +import net.javacrumbs.shedlock.spring.annotation.SchedulerLock; + +import im.toduck.domain.schedule.domain.service.ScheduleReadService; +import im.toduck.domain.schedule.domain.service.ScheduleReminderSchedulerService; +import im.toduck.global.annotation.UseCase; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@UseCase +@RequiredArgsConstructor +@ConditionalOnProperty(name = "spring.quartz.auto-startup", havingValue = "true", matchIfMissing = true) +public class ScheduleReminderBatchSchedulerUseCase { + + private final ScheduleReadService scheduleReadService; + private final ScheduleReminderSchedulerService scheduleReminderSchedulerService; + + @Scheduled(cron = "0 58 3 * * *", zone = "Asia/Seoul") + @SchedulerLock(name = "ScheduleReminderBatchScheduler_scheduleDailyScheduleReminders", lockAtMostFor = "55m", lockAtLeastFor = "1m") + @Transactional + public void scheduleDailyScheduleReminders() { + LocalDateTime currentDateTime = LocalDateTime.now(); + LocalDate today = currentDateTime.toLocalDate(); + LocalDate tomorrow = today.plusDays(1); + + log.info("일일 일정 알림 배치 작업 시작 - 현재시간: {}", currentDateTime); + + scheduleReadService.findActiveSchedulesWithAlarmForDates(today, tomorrow) + .forEach( + schedule -> scheduleReminderSchedulerService.scheduleScheduleReminders( + schedule, currentDateTime, true)); + + log.info("일일 일정 알림 배치 작업 완료"); + } +} diff --git a/src/main/java/im/toduck/domain/schedule/persistence/repository/querydsl/ScheduleRepositoryCustom.java b/src/main/java/im/toduck/domain/schedule/persistence/repository/querydsl/ScheduleRepositoryCustom.java index 9a4b32f8..2af235bf 100644 --- a/src/main/java/im/toduck/domain/schedule/persistence/repository/querydsl/ScheduleRepositoryCustom.java +++ b/src/main/java/im/toduck/domain/schedule/persistence/repository/querydsl/ScheduleRepositoryCustom.java @@ -11,7 +11,8 @@ public interface ScheduleRepositoryCustom { List findSchedules(Long userId, LocalDate startDate, LocalDate endDate); List countByCreatedAtBetweenGroupByDate( - final LocalDateTime startDateTime, - final LocalDateTime endDateTime - ); + final LocalDateTime startDateTime, + final LocalDateTime endDateTime); + + List findActiveSchedulesWithAlarmForDates(final LocalDate startDate, final LocalDate endDate); } diff --git a/src/main/java/im/toduck/domain/schedule/persistence/repository/querydsl/ScheduleRepositoryCustomImpl.java b/src/main/java/im/toduck/domain/schedule/persistence/repository/querydsl/ScheduleRepositoryCustomImpl.java index 067964b6..a44d6acf 100644 --- a/src/main/java/im/toduck/domain/schedule/persistence/repository/querydsl/ScheduleRepositoryCustomImpl.java +++ b/src/main/java/im/toduck/domain/schedule/persistence/repository/querydsl/ScheduleRepositoryCustomImpl.java @@ -25,47 +25,55 @@ public class ScheduleRepositoryCustomImpl implements ScheduleRepositoryCustom { @Override public List findSchedules(Long userId, LocalDate startDate, LocalDate endDate) { return queryFactory - .selectDistinct(schedule) - .from(schedule) - .where( - schedule.user.id.eq(userId) - .and( - isSingleDayNonRepeating(startDate, endDate) - .or(isSingleDayRepeating(endDate)) - .or(isPeriodEvent(startDate, endDate)) - ) - ) - .fetch(); + .selectDistinct(schedule) + .from(schedule) + .where( + schedule.user.id.eq(userId) + .and( + isSingleDayNonRepeating(startDate, endDate) + .or(isSingleDayRepeating(endDate)) + .or(isPeriodEvent(startDate, endDate)))) + .fetch(); } // 1. 기간 X, 반복 X: 단일 날짜 일정이며, 반복이 없음 private BooleanExpression isSingleDayNonRepeating(LocalDate startDate, LocalDate endDate) { return schedule.scheduleDate.startDate.eq(schedule.scheduleDate.endDate) - .and(schedule.daysOfWeekBitmask.isNull()) - .and(schedule.scheduleDate.startDate.between(startDate, endDate)); + .and(schedule.daysOfWeekBitmask.isNull()) + .and(schedule.scheduleDate.startDate.between(startDate, endDate)); } // 2. 기간 X, 반복 O: 단일 날짜 일정이지만 반복 일정 private BooleanExpression isSingleDayRepeating(LocalDate endDate) { return schedule.scheduleDate.startDate.eq(schedule.scheduleDate.endDate) - .and(schedule.daysOfWeekBitmask.isNotNull()) - .and(schedule.scheduleDate.startDate.loe(endDate)); // startDate가 endDate 이전이면 조회 대상 + .and(schedule.daysOfWeekBitmask.isNotNull()) + .and(schedule.scheduleDate.startDate.loe(endDate)); // startDate가 endDate 이전이면 조회 대상 } // 3. 기간 O, 반복 여부 관계없음: 두 기간이 겹치는지 확인 private BooleanExpression isPeriodEvent(LocalDate startDate, LocalDate endDate) { return schedule.scheduleDate.startDate.ne(schedule.scheduleDate.endDate) // 시작일 ≠ 종료일 → 기간 일정 - .and(schedule.scheduleDate.endDate.goe(startDate)) // 조회 시작일이 일정 종료일보다 같거나 작아야 함 - .and(schedule.scheduleDate.startDate.loe(endDate)); // 일정 시작일이 조회 종료일보다 같거나 작아야 함 + .and(schedule.scheduleDate.endDate.goe(startDate)) // 조회 시작일이 일정 종료일보다 같거나 작아야 함 + .and(schedule.scheduleDate.startDate.loe(endDate)); // 일정 시작일이 조회 종료일보다 같거나 작아야 함 } @Override public List countByCreatedAtBetweenGroupByDate( - final LocalDateTime startDateTime, - final LocalDateTime endDateTime - ) { + final LocalDateTime startDateTime, + final LocalDateTime endDateTime) { return DailyCountQueryHelper.countGroupByDate( - queryFactory, schedule, schedule.createdAt, schedule.count(), startDateTime, endDateTime - ); + queryFactory, schedule, schedule.createdAt, schedule.count(), startDateTime, endDateTime); + } + + @Override + public List findActiveSchedulesWithAlarmForDates(final LocalDate startDate, final LocalDate endDate) { + return queryFactory + .selectFrom(schedule) + .where( + schedule.scheduleTime.alarm.isNotNull(), + schedule.deletedAt.isNull(), + schedule.scheduleDate.startDate.loe(endDate), + schedule.scheduleDate.endDate.goe(startDate)) + .fetch(); } } From 6f135b46973d595bba703608044b0520dd34e29f Mon Sep 17 00:00:00 2001 From: Kkang Date: Mon, 23 Feb 2026 19:01:43 +0900 Subject: [PATCH 4/4] =?UTF-8?q?test:=20=EC=9D=BC=EC=A0=95=20=EC=95=8C?= =?UTF-8?q?=EB=A6=BC=20=EA=B8=B0=EB=8A=A5=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20?= =?UTF-8?q?=EC=BD=94=EB=93=9C=20=EC=9E=91=EC=84=B1=20(#162)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ScheduleAlramTest: enum 변환 단위 테스트 - ScheduleReminderSchedulerServiceTest: 스케줄링 로직 테스트 (8개) - ScheduleReminderEventListenerTest: 이벤트 리스너 테스트 (6개) - ScheduleEventTest: 도메인 이벤트 테스트 (9개) - ScheduleReminderNotificationEventTest: 알림 이벤트 테스트 (6개) - ScheduleReminderDataTest: 알림 데이터 테스트 (3개) - LayeredArchitectureTest에 schedule event 패키지 제외 추가 --- .../main/domain/LayeredArchitectureTest.java | 79 ++--- .../domain/data/ScheduleReminderDataTest.java | 70 +++++ ...ScheduleReminderNotificationEventTest.java | 143 +++++++++ .../domain/event/ScheduleEventTest.java | 130 ++++++++ .../ScheduleReminderEventListenerTest.java | 194 ++++++++++++ .../ScheduleReminderSchedulerServiceTest.java | 297 ++++++++++++++++++ .../persistence/vo/ScheduleAlramTest.java | 69 ++++ 7 files changed, 945 insertions(+), 37 deletions(-) create mode 100644 src/test/java/im/toduck/domain/notification/domain/data/ScheduleReminderDataTest.java create mode 100644 src/test/java/im/toduck/domain/notification/domain/event/ScheduleReminderNotificationEventTest.java create mode 100644 src/test/java/im/toduck/domain/schedule/domain/event/ScheduleEventTest.java create mode 100644 src/test/java/im/toduck/domain/schedule/domain/event/ScheduleReminderEventListenerTest.java create mode 100644 src/test/java/im/toduck/domain/schedule/domain/service/ScheduleReminderSchedulerServiceTest.java create mode 100644 src/test/java/im/toduck/domain/schedule/persistence/vo/ScheduleAlramTest.java diff --git a/src/test/java/im/toduck/architecture/main/domain/LayeredArchitectureTest.java b/src/test/java/im/toduck/architecture/main/domain/LayeredArchitectureTest.java index 18b10109..538554ca 100644 --- a/src/test/java/im/toduck/architecture/main/domain/LayeredArchitectureTest.java +++ b/src/test/java/im/toduck/architecture/main/domain/LayeredArchitectureTest.java @@ -11,15 +11,13 @@ import com.tngtech.archunit.junit.ArchTest; import com.tngtech.archunit.lang.ArchRule; -@AnalyzeClasses( - packages = "im.toduck.domain", - importOptions = { +@AnalyzeClasses(packages = "im.toduck.domain", importOptions = { ImportOption.DoNotIncludeTests.class, LayeredArchitectureTest.NotificationPackageIgnore.class, LayeredArchitectureTest.RoutineEventPackageIgnore.class, - LayeredArchitectureTest.BackofficeEventPackageIgnore.class - } -) + LayeredArchitectureTest.BackofficeEventPackageIgnore.class, + LayeredArchitectureTest.ScheduleEventPackageIgnore.class +}) public class LayeredArchitectureTest { // notification 패키지 제외를 위한 커스텀 ImportOption public static class NotificationPackageIgnore implements ImportOption { @@ -45,41 +43,47 @@ public boolean includes(Location location) { } } + // schedule.domain.event 패키지 제외를 위한 커스텀 ImportOption + public static class ScheduleEventPackageIgnore implements ImportOption { + @Override + public boolean includes(Location location) { + return !location.contains("schedule/domain/event"); + } + } + @ArchTest static final ArchRule 레이어_의존성_규칙을_준수한다 = layeredArchitecture() - .consideringAllDependencies() - .layer(API.name()).definedBy(API.getFullPackageName()) - .layer(CONTROLLER.name()).definedBy(CONTROLLER.getFullPackageName()) - .layer(DTO.name()).definedBy(DTO.getFullPackageName()) - .layer(SERVICE.name()).definedBy(SERVICE.getFullPackageName()) - .layer(USECASE.name()).definedBy(USECASE.getFullPackageName()) - .layer(REPOSITORY.name()).definedBy(REPOSITORY.getFullPackageName()) - .layer(ENTITY.name()).definedBy(ENTITY.getFullPackageName()) - .layer(MAPPER.name()).definedBy(MAPPER.getFullPackageName()) + .consideringAllDependencies() + .layer(API.name()).definedBy(API.getFullPackageName()) + .layer(CONTROLLER.name()).definedBy(CONTROLLER.getFullPackageName()) + .layer(DTO.name()).definedBy(DTO.getFullPackageName()) + .layer(SERVICE.name()).definedBy(SERVICE.getFullPackageName()) + .layer(USECASE.name()).definedBy(USECASE.getFullPackageName()) + .layer(REPOSITORY.name()).definedBy(REPOSITORY.getFullPackageName()) + .layer(ENTITY.name()).definedBy(ENTITY.getFullPackageName()) + .layer(MAPPER.name()).definedBy(MAPPER.getFullPackageName()) - .whereLayer(API.name()).mayOnlyBeAccessedByLayers(CONTROLLER.name()) - .whereLayer(CONTROLLER.name()).mayNotBeAccessedByAnyLayer() - .whereLayer(SERVICE.name()).mayOnlyBeAccessedByLayers(USECASE.name(), SERVICE.name()) - .whereLayer(USECASE.name()).mayOnlyBeAccessedByLayers(API.name(), CONTROLLER.name()) - .whereLayer(REPOSITORY.name()).mayOnlyBeAccessedByLayers(SERVICE.name()) - .whereLayer(ENTITY.name()) - .mayOnlyBeAccessedByLayers( - SERVICE.name(), USECASE.name(), REPOSITORY.name(), MAPPER.name(), ENTITY.name(), DTO.name(), API.name(), CONTROLLER.name() - ) - .whereLayer(MAPPER.name()).mayOnlyBeAccessedByLayers(SERVICE.name(), USECASE.name()) + .whereLayer(API.name()).mayOnlyBeAccessedByLayers(CONTROLLER.name()) + .whereLayer(CONTROLLER.name()).mayNotBeAccessedByAnyLayer() + .whereLayer(SERVICE.name()).mayOnlyBeAccessedByLayers(USECASE.name(), SERVICE.name()) + .whereLayer(USECASE.name()).mayOnlyBeAccessedByLayers(API.name(), CONTROLLER.name()) + .whereLayer(REPOSITORY.name()).mayOnlyBeAccessedByLayers(SERVICE.name()) + .whereLayer(ENTITY.name()) + .mayOnlyBeAccessedByLayers( + SERVICE.name(), USECASE.name(), REPOSITORY.name(), MAPPER.name(), ENTITY.name(), DTO.name(), + API.name(), CONTROLLER.name()) + .whereLayer(MAPPER.name()).mayOnlyBeAccessedByLayers(SERVICE.name(), USECASE.name()) - // QueryDSL 제외 - .ignoreDependency(JavaClass.Predicates.simpleNameStartingWith("Q"), - JavaClass.Predicates.resideInAnyPackage("..")) - .ignoreDependency( - JavaClass.Predicates.resideInAPackage("..global..") - .or(JavaClass.Predicates.resideInAPackage("..common.dto..")), - JavaClass.Predicates.resideInAnyPackage("..") - ); + // QueryDSL 제외 + .ignoreDependency(JavaClass.Predicates.simpleNameStartingWith("Q"), + JavaClass.Predicates.resideInAnyPackage("..")) + .ignoreDependency( + JavaClass.Predicates.resideInAPackage("..global..") + .or(JavaClass.Predicates.resideInAPackage("..common.dto..")), + JavaClass.Predicates.resideInAnyPackage("..")); @ArchTest - static final ArchRule 오직_Entity_레이어의_enum_만_DTO_에서_사용될_수_있다 = - classes() + static final ArchRule 오직_Entity_레이어의_enum_만_DTO_에서_사용될_수_있다 = classes() .that() .resideInAPackage(ENTITY.getFullPackageName()) .and() @@ -89,6 +93,7 @@ public boolean includes(Location location) { .should() .onlyBeAccessed() .byAnyPackage(ENTITY.getFullPackageName(), DTO.getFullPackageName(), MAPPER.getFullPackageName(), - API.getFullPackageName(), CONTROLLER.getFullPackageName(), USECASE.getFullPackageName(), SERVICE.getFullPackageName(), - REPOSITORY.getFullPackageName()); + API.getFullPackageName(), CONTROLLER.getFullPackageName(), USECASE.getFullPackageName(), + SERVICE.getFullPackageName(), + REPOSITORY.getFullPackageName()); } diff --git a/src/test/java/im/toduck/domain/notification/domain/data/ScheduleReminderDataTest.java b/src/test/java/im/toduck/domain/notification/domain/data/ScheduleReminderDataTest.java new file mode 100644 index 00000000..f9babb2e --- /dev/null +++ b/src/test/java/im/toduck/domain/notification/domain/data/ScheduleReminderDataTest.java @@ -0,0 +1,70 @@ +package im.toduck.domain.notification.domain.data; + +import static org.assertj.core.api.Assertions.*; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import im.toduck.domain.schedule.persistence.vo.ScheduleAlram; + +class ScheduleReminderDataTest { + + @Nested + @DisplayName("of 팩토리 메서드") + class OfFactoryMethodTest { + + @Test + @DisplayName("모든 필드가 올바르게 설정된 ScheduleReminderData를 생성한다") + void createWithAllFields() { + // given + Long scheduleId = 1L; + String title = "팀 미팅"; + ScheduleAlram reminderType = ScheduleAlram.TEN_MINUTE; + boolean isAllDay = false; + + // when + ScheduleReminderData data = ScheduleReminderData.of(scheduleId, title, reminderType, isAllDay); + + // then + assertThat(data.getScheduleId()).isEqualTo(scheduleId); + assertThat(data.getScheduleTitle()).isEqualTo(title); + assertThat(data.getReminderType()).isEqualTo(reminderType); + assertThat(data.isAllDay()).isFalse(); + } + + @Test + @DisplayName("종일 일정 데이터를 생성한다") + void createAllDayData() { + // given + Long scheduleId = 2L; + String title = "출장"; + ScheduleAlram reminderType = ScheduleAlram.ONE_DAY; + + // when + ScheduleReminderData data = ScheduleReminderData.of(scheduleId, title, reminderType, true); + + // then + assertThat(data.isAllDay()).isTrue(); + assertThat(data.getReminderType()).isEqualTo(ScheduleAlram.ONE_DAY); + } + } + + @Nested + @DisplayName("기본 생성자") + class NoArgsConstructorTest { + + @Test + @DisplayName("기본 생성자로 생성 시 모든 필드가 null 또는 기본값이다") + void createWithNoArgs() { + // given & when + ScheduleReminderData data = new ScheduleReminderData(); + + // then + assertThat(data.getScheduleId()).isNull(); + assertThat(data.getScheduleTitle()).isNull(); + assertThat(data.getReminderType()).isNull(); + assertThat(data.isAllDay()).isFalse(); + } + } +} diff --git a/src/test/java/im/toduck/domain/notification/domain/event/ScheduleReminderNotificationEventTest.java b/src/test/java/im/toduck/domain/notification/domain/event/ScheduleReminderNotificationEventTest.java new file mode 100644 index 00000000..d8ade0bf --- /dev/null +++ b/src/test/java/im/toduck/domain/notification/domain/event/ScheduleReminderNotificationEventTest.java @@ -0,0 +1,143 @@ +package im.toduck.domain.notification.domain.event; + +import static org.assertj.core.api.Assertions.*; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import im.toduck.domain.notification.persistence.entity.NotificationType; +import im.toduck.domain.schedule.persistence.vo.ScheduleAlram; + +class ScheduleReminderNotificationEventTest { + + @Nested + @DisplayName("of 팩토리 메서드") + class OfFactoryMethodTest { + + @Test + @DisplayName("일반 일정 알림 이벤트를 생성한다") + void createNormalScheduleReminderEvent() { + // given + Long userId = 1L; + Long scheduleId = 10L; + String title = "회의"; + ScheduleAlram reminderType = ScheduleAlram.TEN_MINUTE; + + // when + ScheduleReminderNotificationEvent event = ScheduleReminderNotificationEvent.of( + userId, scheduleId, title, reminderType, false); + + // then + assertThat(event.getUserId()).isEqualTo(userId); + assertThat(event.getType()).isEqualTo(NotificationType.SCHEDULE_REMINDER); + assertThat(event.getData().getScheduleId()).isEqualTo(scheduleId); + assertThat(event.getData().getScheduleTitle()).isEqualTo(title); + assertThat(event.getData().getReminderType()).isEqualTo(reminderType); + assertThat(event.getData().isAllDay()).isFalse(); + } + + @Test + @DisplayName("종일 일정 알림 이벤트를 생성한다") + void createAllDayScheduleReminderEvent() { + // given + Long userId = 2L; + Long scheduleId = 20L; + String title = "출장"; + ScheduleAlram reminderType = ScheduleAlram.ONE_DAY; + + // when + ScheduleReminderNotificationEvent event = ScheduleReminderNotificationEvent.of( + userId, scheduleId, title, reminderType, true); + + // then + assertThat(event.getData().isAllDay()).isTrue(); + assertThat(event.getData().getReminderType()).isEqualTo(ScheduleAlram.ONE_DAY); + } + } + + @Nested + @DisplayName("getPushTitle 메서드") + class GetPushTitleTest { + + @Test + @DisplayName("일정 제목을 푸시 제목으로 반환한다") + void returnScheduleTitleAsPushTitle() { + // given + ScheduleReminderNotificationEvent event = ScheduleReminderNotificationEvent.of( + 1L, 10L, "팀 미팅", ScheduleAlram.THIRTY_MINUTE, false); + + // when + String pushTitle = event.getPushTitle(); + + // then + assertThat(pushTitle).isEqualTo("팀 미팅"); + } + } + + @Nested + @DisplayName("getPushBody 메서드") + class GetPushBodyTest { + + @Test + @DisplayName("일반 일정은 알림 분을 포함한 메시지를 반환한다") + void returnMinutesMessageForNormalSchedule() { + // given + ScheduleReminderNotificationEvent event = ScheduleReminderNotificationEvent.of( + 1L, 10L, "미팅", ScheduleAlram.TEN_MINUTE, false); + + // when + String pushBody = event.getPushBody(); + + // then + assertThat(pushBody).contains("10분 전"); + } + + @Test + @DisplayName("30분 알림은 30분 전 메시지를 반환한다") + void returnThirtyMinutesMessage() { + // given + ScheduleReminderNotificationEvent event = ScheduleReminderNotificationEvent.of( + 1L, 10L, "미팅", ScheduleAlram.THIRTY_MINUTE, false); + + // when + String pushBody = event.getPushBody(); + + // then + assertThat(pushBody).contains("30분 전"); + } + + @Test + @DisplayName("종일 일정은 하루 전 메시지를 반환한다") + void returnOneDayMessageForAllDaySchedule() { + // given + ScheduleReminderNotificationEvent event = ScheduleReminderNotificationEvent.of( + 1L, 10L, "출장", ScheduleAlram.ONE_DAY, true); + + // when + String pushBody = event.getPushBody(); + + // then + assertThat(pushBody).contains("하루 전"); + } + } + + @Nested + @DisplayName("getActionUrl 메서드") + class GetActionUrlTest { + + @Test + @DisplayName("toduck://todo URL을 반환한다") + void returnTodoUrl() { + // given + ScheduleReminderNotificationEvent event = ScheduleReminderNotificationEvent.of( + 1L, 10L, "미팅", ScheduleAlram.TEN_MINUTE, false); + + // when + String actionUrl = event.getActionUrl(); + + // then + assertThat(actionUrl).isEqualTo("toduck://todo"); + } + } +} diff --git a/src/test/java/im/toduck/domain/schedule/domain/event/ScheduleEventTest.java b/src/test/java/im/toduck/domain/schedule/domain/event/ScheduleEventTest.java new file mode 100644 index 00000000..a63ea9d3 --- /dev/null +++ b/src/test/java/im/toduck/domain/schedule/domain/event/ScheduleEventTest.java @@ -0,0 +1,130 @@ +package im.toduck.domain.schedule.domain.event; + +import static org.assertj.core.api.Assertions.*; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +class ScheduleEventTest { + + @Nested + @DisplayName("ScheduleCreatedEvent") + class ScheduleCreatedEventTest { + + @Test + @DisplayName("일정 ID와 사용자 ID를 포함하여 생성된다") + void createWithScheduleIdAndUserId() { + // given + Long scheduleId = 1L; + Long userId = 2L; + + // when + ScheduleCreatedEvent event = new ScheduleCreatedEvent(scheduleId, userId); + + // then + assertThat(event.getScheduleId()).isEqualTo(scheduleId); + assertThat(event.getUserId()).isEqualTo(userId); + } + } + + @Nested + @DisplayName("ScheduleUpdatedEvent") + class ScheduleUpdatedEventTest { + + @Test + @DisplayName("알림 설정이 변경되면 isReminderRelatedChanged가 true를 반환한다") + void returnTrueWhenAlarmChanged() { + // given + ScheduleUpdatedEvent event = new ScheduleUpdatedEvent( + 1L, 1L, true, false, false, false, false, false); + + // when & then + assertThat(event.isReminderRelatedChanged()).isTrue(); + } + + @Test + @DisplayName("시간이 변경되면 isReminderRelatedChanged가 true를 반환한다") + void returnTrueWhenTimeChanged() { + // given + ScheduleUpdatedEvent event = new ScheduleUpdatedEvent( + 1L, 1L, false, true, false, false, false, false); + + // when & then + assertThat(event.isReminderRelatedChanged()).isTrue(); + } + + @Test + @DisplayName("종일 여부가 변경되면 isReminderRelatedChanged가 true를 반환한다") + void returnTrueWhenAllDayChanged() { + // given + ScheduleUpdatedEvent event = new ScheduleUpdatedEvent( + 1L, 1L, false, false, true, false, false, false); + + // when & then + assertThat(event.isReminderRelatedChanged()).isTrue(); + } + + @Test + @DisplayName("제목이 변경되면 isReminderRelatedChanged가 true를 반환한다") + void returnTrueWhenTitleChanged() { + // given + ScheduleUpdatedEvent event = new ScheduleUpdatedEvent( + 1L, 1L, false, false, false, true, false, false); + + // when & then + assertThat(event.isReminderRelatedChanged()).isTrue(); + } + + @Test + @DisplayName("날짜가 변경되면 isReminderRelatedChanged가 true를 반환한다") + void returnTrueWhenDateChanged() { + // given + ScheduleUpdatedEvent event = new ScheduleUpdatedEvent( + 1L, 1L, false, false, false, false, true, false); + + // when & then + assertThat(event.isReminderRelatedChanged()).isTrue(); + } + + @Test + @DisplayName("반복 요일이 변경되면 isReminderRelatedChanged가 true를 반환한다") + void returnTrueWhenDaysOfWeekChanged() { + // given + ScheduleUpdatedEvent event = new ScheduleUpdatedEvent( + 1L, 1L, false, false, false, false, false, true); + + // when & then + assertThat(event.isReminderRelatedChanged()).isTrue(); + } + + @Test + @DisplayName("아무것도 변경되지 않으면 isReminderRelatedChanged가 false를 반환한다") + void returnFalseWhenNothingChanged() { + // given + ScheduleUpdatedEvent event = new ScheduleUpdatedEvent( + 1L, 1L, false, false, false, false, false, false); + + // when & then + assertThat(event.isReminderRelatedChanged()).isFalse(); + } + } + + @Nested + @DisplayName("ScheduleDeletedEvent") + class ScheduleDeletedEventTest { + + @Test + @DisplayName("일정 ID를 포함하여 생성된다") + void createWithScheduleId() { + // given + Long scheduleId = 1L; + + // when + ScheduleDeletedEvent event = new ScheduleDeletedEvent(scheduleId); + + // then + assertThat(event.getScheduleId()).isEqualTo(scheduleId); + } + } +} diff --git a/src/test/java/im/toduck/domain/schedule/domain/event/ScheduleReminderEventListenerTest.java b/src/test/java/im/toduck/domain/schedule/domain/event/ScheduleReminderEventListenerTest.java new file mode 100644 index 00000000..d15c1040 --- /dev/null +++ b/src/test/java/im/toduck/domain/schedule/domain/event/ScheduleReminderEventListenerTest.java @@ -0,0 +1,194 @@ +package im.toduck.domain.schedule.domain.event; + +import static im.toduck.fixtures.user.UserFixtures.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.BDDMockito.*; + +import java.time.LocalDate; +import java.time.LocalTime; +import java.util.Optional; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.test.util.ReflectionTestUtils; + +import im.toduck.domain.person.persistence.entity.PlanCategory; +import im.toduck.domain.routine.persistence.vo.PlanCategoryColor; +import im.toduck.domain.schedule.domain.service.ScheduleReadService; +import im.toduck.domain.schedule.domain.service.ScheduleReminderSchedulerService; +import im.toduck.domain.schedule.persistence.entity.Schedule; +import im.toduck.domain.schedule.persistence.vo.ScheduleAlram; +import im.toduck.domain.schedule.persistence.vo.ScheduleDate; +import im.toduck.domain.schedule.persistence.vo.ScheduleTime; +import im.toduck.domain.user.persistence.entity.User; + +@ExtendWith(MockitoExtension.class) +class ScheduleReminderEventListenerTest { + + @InjectMocks + private ScheduleReminderEventListener eventListener; + + @Mock + private ScheduleReadService scheduleReadService; + + @Mock + private ScheduleReminderSchedulerService scheduleReminderSchedulerService; + + private Schedule createTestSchedule(final Long id) { + User user = GENERAL_USER(); + ReflectionTestUtils.setField(user, "id", 1L); + + Schedule schedule = Schedule.builder() + .user(user) + .title("테스트 일정") + .category(PlanCategory.COMPUTER) + .color(PlanCategoryColor.from("#FF5733")) + .scheduleDate(ScheduleDate.of(LocalDate.of(2026, 2, 24), LocalDate.of(2026, 2, 24))) + .scheduleTime(ScheduleTime.of(false, LocalTime.of(14, 0), ScheduleAlram.TEN_MINUTE)) + .build(); + + ReflectionTestUtils.setField(schedule, "id", id); + return schedule; + } + + @Nested + @DisplayName("handleScheduleCreated 메서드") + class HandleScheduleCreatedTest { + + @Test + @DisplayName("일정 생성 이벤트를 받으면 알림을 스케줄링한다") + void scheduleReminderOnScheduleCreated() { + // given + Long scheduleId = 1L; + Long userId = 1L; + ScheduleCreatedEvent event = new ScheduleCreatedEvent(scheduleId, userId); + Schedule schedule = createTestSchedule(scheduleId); + + given(scheduleReadService.getScheduleById(scheduleId)).willReturn(Optional.of(schedule)); + + // when + eventListener.handleScheduleCreated(event); + + // then + then(scheduleReminderSchedulerService).should() + .scheduleScheduleReminders(eq(schedule), any(), eq(false)); + } + + @Test + @DisplayName("일정을 찾지 못하면 스케줄링을 하지 않는다") + void doNotScheduleWhenScheduleNotFound() { + // given + Long scheduleId = 999L; + Long userId = 1L; + ScheduleCreatedEvent event = new ScheduleCreatedEvent(scheduleId, userId); + + given(scheduleReadService.getScheduleById(scheduleId)).willReturn(Optional.empty()); + + // when + eventListener.handleScheduleCreated(event); + + // then + then(scheduleReminderSchedulerService).shouldHaveNoInteractions(); + } + + @Test + @DisplayName("예외가 발생해도 이벤트 처리는 실패하지 않는다") + void doNotFailOnException() { + // given + Long scheduleId = 1L; + Long userId = 1L; + ScheduleCreatedEvent event = new ScheduleCreatedEvent(scheduleId, userId); + + given(scheduleReadService.getScheduleById(scheduleId)) + .willThrow(new RuntimeException("DB 오류")); + + // when & then (예외가 전파되지 않음) + org.junit.jupiter.api.Assertions.assertDoesNotThrow( + () -> eventListener.handleScheduleCreated(event)); + } + } + + @Nested + @DisplayName("handleScheduleUpdated 메서드") + class HandleScheduleUpdatedTest { + + @Test + @DisplayName("알림 관련 필드가 변경되면 기존 알림을 취소하고 재스케줄링한다") + void rescheduleOnReminderRelatedChange() { + // given + Long scheduleId = 1L; + Long userId = 1L; + ScheduleUpdatedEvent event = new ScheduleUpdatedEvent( + scheduleId, userId, true, false, false, false, false, false); + Schedule schedule = createTestSchedule(scheduleId); + + given(scheduleReadService.getScheduleById(scheduleId)).willReturn(Optional.of(schedule)); + + // when + eventListener.handleScheduleUpdated(event); + + // then + then(scheduleReminderSchedulerService).should() + .cancelFutureScheduleReminders(eq(scheduleId), any(LocalDate.class)); + then(scheduleReminderSchedulerService).should() + .scheduleScheduleReminders(eq(schedule), any(), eq(false)); + } + + @Test + @DisplayName("알림 관련 필드가 변경되지 않으면 아무 작업도 하지 않는다") + void doNothingOnNonReminderChange() { + // given + Long scheduleId = 1L; + Long userId = 1L; + ScheduleUpdatedEvent event = new ScheduleUpdatedEvent( + scheduleId, userId, false, false, false, false, false, false); + + // when + eventListener.handleScheduleUpdated(event); + + // then + then(scheduleReminderSchedulerService).shouldHaveNoInteractions(); + } + } + + @Nested + @DisplayName("handleScheduleDeleted 메서드") + class HandleScheduleDeletedTest { + + @Test + @DisplayName("일정 삭제 이벤트를 받으면 모든 알림을 취소한다") + void cancelAllRemindersOnDelete() { + // given + Long scheduleId = 1L; + ScheduleDeletedEvent event = new ScheduleDeletedEvent(scheduleId); + + // when + eventListener.handleScheduleDeleted(event); + + // then + then(scheduleReminderSchedulerService).should() + .cancelAllScheduleReminders(scheduleId); + } + + @Test + @DisplayName("예외가 발생해도 이벤트 처리는 실패하지 않는다") + void doNotFailOnException() { + // given + Long scheduleId = 1L; + ScheduleDeletedEvent event = new ScheduleDeletedEvent(scheduleId); + + willThrow(new RuntimeException("스케줄러 오류")) + .given(scheduleReminderSchedulerService) + .cancelAllScheduleReminders(scheduleId); + + // when & then + org.junit.jupiter.api.Assertions.assertDoesNotThrow( + () -> eventListener.handleScheduleDeleted(event)); + } + } +} diff --git a/src/test/java/im/toduck/domain/schedule/domain/service/ScheduleReminderSchedulerServiceTest.java b/src/test/java/im/toduck/domain/schedule/domain/service/ScheduleReminderSchedulerServiceTest.java new file mode 100644 index 00000000..37ba3788 --- /dev/null +++ b/src/test/java/im/toduck/domain/schedule/domain/service/ScheduleReminderSchedulerServiceTest.java @@ -0,0 +1,297 @@ +package im.toduck.domain.schedule.domain.service; + +import static im.toduck.fixtures.user.UserFixtures.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.BDDMockito.*; + +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.LocalTime; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +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.quartz.JobDetail; +import org.quartz.Scheduler; +import org.quartz.SchedulerException; +import org.quartz.Trigger; +import org.springframework.test.util.ReflectionTestUtils; + +import im.toduck.domain.person.persistence.entity.PlanCategory; +import im.toduck.domain.routine.persistence.vo.PlanCategoryColor; +import im.toduck.domain.schedule.persistence.entity.Schedule; +import im.toduck.domain.schedule.persistence.entity.ScheduleReminderJob; +import im.toduck.domain.schedule.persistence.repository.ScheduleReminderJobRepository; +import im.toduck.domain.schedule.persistence.vo.ScheduleAlram; +import im.toduck.domain.schedule.persistence.vo.ScheduleDate; +import im.toduck.domain.schedule.persistence.vo.ScheduleTime; +import im.toduck.domain.user.persistence.entity.User; + +@ExtendWith(MockitoExtension.class) +class ScheduleReminderSchedulerServiceTest { + + @InjectMocks + private ScheduleReminderSchedulerService schedulerService; + + @Mock + private Scheduler scheduler; + + @Mock + private ScheduleReminderJobRepository scheduleReminderJobRepository; + + private User testUser; + + @BeforeEach + void setUp() { + testUser = GENERAL_USER(); + ReflectionTestUtils.setField(testUser, "id", 1L); + } + + private Schedule createSchedule( + final Long id, + final Boolean isAllDay, + final LocalTime time, + final ScheduleAlram alarm, + final LocalDate startDate, + final LocalDate endDate) { + Schedule schedule = Schedule.builder() + .user(testUser) + .title("테스트 일정") + .category(PlanCategory.COMPUTER) + .color(PlanCategoryColor.from("#FF5733")) + .scheduleDate(ScheduleDate.of(startDate, endDate)) + .scheduleTime(ScheduleTime.of(isAllDay, time, alarm)) + .build(); + + ReflectionTestUtils.setField(schedule, "id", id); + return schedule; + } + + @Nested + @DisplayName("scheduleScheduleReminders 메서드") + class ScheduleScheduleRemindersTest { + + @Test + @DisplayName("알림이 null이면 스케줄링을 스킵한다") + void skipWhenAlarmIsNull() throws SchedulerException { + // given + Schedule schedule = createSchedule( + 1L, false, LocalTime.of(14, 0), null, + LocalDate.of(2026, 2, 24), LocalDate.of(2026, 2, 24)); + LocalDateTime currentDateTime = LocalDateTime.of(2026, 2, 23, 12, 0); + + // when + schedulerService.scheduleScheduleReminders(schedule, currentDateTime, false); + + // then + then(scheduler).shouldHaveNoInteractions(); + then(scheduleReminderJobRepository).shouldHaveNoInteractions(); + } + + @Test + @DisplayName("일반 일정에 TEN_MINUTE 알림이 설정된 경우 10분 전에 알림을 스케줄링한다") + void scheduleReminderForNormalScheduleWithTenMinute() throws SchedulerException { + // given + LocalDate scheduleDate = LocalDate.of(2026, 2, 24); + Schedule schedule = createSchedule( + 1L, false, LocalTime.of(14, 0), ScheduleAlram.TEN_MINUTE, + scheduleDate, scheduleDate); + LocalDateTime currentDateTime = LocalDateTime.of(2026, 2, 23, 12, 0); + + given(scheduleReminderJobRepository.existsByScheduleIdAndReminderDateAndReminderTime( + anyLong(), any(LocalDate.class), any(LocalTime.class))).willReturn(false); + + given(scheduler.scheduleJob(any(JobDetail.class), any(Trigger.class))) + .willReturn(null); + + // when + schedulerService.scheduleScheduleReminders(schedule, currentDateTime, false); + + // then + then(scheduler).should(atLeastOnce()).scheduleJob(any(JobDetail.class), any(Trigger.class)); + then(scheduleReminderJobRepository).should(atLeastOnce()).save(any(ScheduleReminderJob.class)); + } + + @Test + @DisplayName("일반 일정에 THIRTY_MINUTE 알림이 설정된 경우 30분 전에 알림을 스케줄링한다") + void scheduleReminderForNormalScheduleWithThirtyMinute() throws SchedulerException { + // given + LocalDate scheduleDate = LocalDate.of(2026, 2, 24); + Schedule schedule = createSchedule( + 2L, false, LocalTime.of(14, 0), ScheduleAlram.THIRTY_MINUTE, + scheduleDate, scheduleDate); + LocalDateTime currentDateTime = LocalDateTime.of(2026, 2, 23, 12, 0); + + given(scheduleReminderJobRepository.existsByScheduleIdAndReminderDateAndReminderTime( + anyLong(), any(LocalDate.class), any(LocalTime.class))).willReturn(false); + + given(scheduler.scheduleJob(any(JobDetail.class), any(Trigger.class))) + .willReturn(null); + + // when + schedulerService.scheduleScheduleReminders(schedule, currentDateTime, false); + + // then + then(scheduler).should(atLeastOnce()).scheduleJob(any(JobDetail.class), any(Trigger.class)); + then(scheduleReminderJobRepository).should(atLeastOnce()).save(any(ScheduleReminderJob.class)); + } + + @Test + @DisplayName("종일 일정에 ONE_DAY 알림이 설정된 경우 하루 전 10시에 알림을 스케줄링한다") + void scheduleReminderForAllDaySchedule() throws SchedulerException { + // given + LocalDate scheduleDate = LocalDate.of(2026, 2, 24); + Schedule schedule = createSchedule( + 3L, true, null, ScheduleAlram.ONE_DAY, + scheduleDate, scheduleDate); + // 알림 시간: 2026-02-23 10:00 (하루 전 10시) + LocalDateTime currentDateTime = LocalDateTime.of(2026, 2, 23, 5, 0); + + given(scheduleReminderJobRepository.existsByScheduleIdAndReminderDateAndReminderTime( + anyLong(), any(LocalDate.class), any(LocalTime.class))).willReturn(false); + + given(scheduler.scheduleJob(any(JobDetail.class), any(Trigger.class))) + .willReturn(null); + + // when + schedulerService.scheduleScheduleReminders(schedule, currentDateTime, false); + + // then + then(scheduler).should(atLeastOnce()).scheduleJob(any(JobDetail.class), any(Trigger.class)); + then(scheduleReminderJobRepository).should(atLeastOnce()).save(any(ScheduleReminderJob.class)); + } + + @Test + @DisplayName("이미 스케줄링된 알림은 중복 스케줄링하지 않는다") + void skipDuplicateScheduling() throws SchedulerException { + // given + LocalDate scheduleDate = LocalDate.of(2026, 2, 24); + Schedule schedule = createSchedule( + 4L, false, LocalTime.of(14, 0), ScheduleAlram.TEN_MINUTE, + scheduleDate, scheduleDate); + LocalDateTime currentDateTime = LocalDateTime.of(2026, 2, 23, 12, 0); + + given(scheduleReminderJobRepository.existsByScheduleIdAndReminderDateAndReminderTime( + anyLong(), any(LocalDate.class), any(LocalTime.class))).willReturn(true); + + // when + schedulerService.scheduleScheduleReminders(schedule, currentDateTime, false); + + // then + then(scheduler).shouldHaveNoInteractions(); + } + + @Test + @DisplayName("이미 지나간 알림 시간은 스케줄링하지 않는다") + void skipPastReminderTime() throws SchedulerException { + // given + LocalDate scheduleDate = LocalDate.of(2026, 2, 23); + Schedule schedule = createSchedule( + 5L, false, LocalTime.of(10, 0), ScheduleAlram.TEN_MINUTE, + scheduleDate, scheduleDate); + // 알림 시간: 09:50, 현재 시간: 12:00 → 이미 지남 + LocalDateTime currentDateTime = LocalDateTime.of(2026, 2, 23, 12, 0); + + // when + schedulerService.scheduleScheduleReminders(schedule, currentDateTime, false); + + // then + then(scheduler).shouldHaveNoInteractions(); + then(scheduleReminderJobRepository).should(never()).save(any()); + } + + @Test + @DisplayName("시작일이 지나간 기간 일정은 현재 날짜부터 스케줄링한다") + void scheduleFromCurrentDateForPastStartDate() throws SchedulerException { + // given + Schedule schedule = createSchedule( + 6L, false, LocalTime.of(14, 0), ScheduleAlram.TEN_MINUTE, + LocalDate.of(2026, 2, 20), LocalDate.of(2026, 2, 28)); + LocalDateTime currentDateTime = LocalDateTime.of(2026, 2, 23, 12, 0); + + given(scheduleReminderJobRepository.existsByScheduleIdAndReminderDateAndReminderTime( + anyLong(), any(LocalDate.class), any(LocalTime.class))).willReturn(false); + + given(scheduler.scheduleJob(any(JobDetail.class), any(Trigger.class))) + .willReturn(null); + + // when + schedulerService.scheduleScheduleReminders(schedule, currentDateTime, false); + + // then + then(scheduler).should(atLeastOnce()).scheduleJob(any(JobDetail.class), any(Trigger.class)); + } + } + + @Nested + @DisplayName("cancelAllScheduleReminders 메서드") + class CancelAllScheduleRemindersTest { + + @Test + @DisplayName("일정의 모든 알림을 취소한다") + void cancelAllReminders() throws SchedulerException { + // given + Long scheduleId = 1L; + + ScheduleReminderJob job = ScheduleReminderJob.builder() + .scheduleId(scheduleId) + .userId(1L) + .reminderDate(LocalDate.of(2026, 2, 24)) + .reminderTime(LocalTime.of(13, 50)) + .jobKey("schedule_1_2026-02-24_1350") + .build(); + + given(scheduleReminderJobRepository.findByScheduleId(scheduleId)) + .willReturn(java.util.List.of(job)); + given(scheduler.checkExists(any(org.quartz.JobKey.class))).willReturn(true); + given(scheduler.deleteJob(any(org.quartz.JobKey.class))).willReturn(true); + + // when + schedulerService.cancelAllScheduleReminders(scheduleId); + + // then + then(scheduler).should().deleteJob(any(org.quartz.JobKey.class)); + then(scheduleReminderJobRepository).should().deleteByScheduleId(scheduleId); + } + } + + @Nested + @DisplayName("cancelFutureScheduleReminders 메서드") + class CancelFutureScheduleRemindersTest { + + @Test + @DisplayName("특정 날짜 이후의 알림을 취소한다") + void cancelFutureReminders() throws SchedulerException { + // given + Long scheduleId = 1L; + LocalDate fromDate = LocalDate.of(2026, 2, 25); + + ScheduleReminderJob job = ScheduleReminderJob.builder() + .scheduleId(scheduleId) + .userId(1L) + .reminderDate(LocalDate.of(2026, 2, 26)) + .reminderTime(LocalTime.of(13, 50)) + .jobKey("schedule_1_2026-02-26_1350") + .build(); + + given(scheduleReminderJobRepository.findByScheduleIdAndReminderDateGreaterThanEqual( + scheduleId, fromDate)) + .willReturn(java.util.List.of(job)); + given(scheduler.checkExists(any(org.quartz.JobKey.class))).willReturn(true); + given(scheduler.deleteJob(any(org.quartz.JobKey.class))).willReturn(true); + + // when + schedulerService.cancelFutureScheduleReminders(scheduleId, fromDate); + + // then + then(scheduler).should().deleteJob(any(org.quartz.JobKey.class)); + then(scheduleReminderJobRepository).should() + .deleteByScheduleIdAndReminderDateAfter(scheduleId, fromDate); + } + } +} diff --git a/src/test/java/im/toduck/domain/schedule/persistence/vo/ScheduleAlramTest.java b/src/test/java/im/toduck/domain/schedule/persistence/vo/ScheduleAlramTest.java new file mode 100644 index 00000000..1e31494d --- /dev/null +++ b/src/test/java/im/toduck/domain/schedule/persistence/vo/ScheduleAlramTest.java @@ -0,0 +1,69 @@ +package im.toduck.domain.schedule.persistence.vo; + +import static org.assertj.core.api.Assertions.*; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +class ScheduleAlramTest { + + @Nested + @DisplayName("getMinutes 메서드") + class GetMinutesTest { + + @Test + @DisplayName("TEN_MINUTE은 10분을 반환한다") + void tenMinuteReturns10() { + // given & when + int minutes = ScheduleAlram.TEN_MINUTE.getMinutes(); + + // then + assertThat(minutes).isEqualTo(10); + } + + @Test + @DisplayName("THIRTY_MINUTE은 30분을 반환한다") + void thirtyMinuteReturns30() { + // given & when + int minutes = ScheduleAlram.THIRTY_MINUTE.getMinutes(); + + // then + assertThat(minutes).isEqualTo(30); + } + + @Test + @DisplayName("ONE_DAY는 1440분(24시간)을 반환한다") + void oneDayReturns1440() { + // given & when + int minutes = ScheduleAlram.ONE_DAY.getMinutes(); + + // then + assertThat(minutes).isEqualTo(1440); + } + } + + @Nested + @DisplayName("enum 값 검증") + class EnumValuesTest { + + @Test + @DisplayName("ScheduleAlram은 3개의 enum 값을 가진다") + void hasThreeValues() { + // given & when + ScheduleAlram[] values = ScheduleAlram.values(); + + // then + assertThat(values).hasSize(3); + } + + @Test + @DisplayName("enum 이름으로 값을 찾을 수 있다") + void canFindByName() { + // given & when & then + assertThat(ScheduleAlram.valueOf("TEN_MINUTE")).isEqualTo(ScheduleAlram.TEN_MINUTE); + assertThat(ScheduleAlram.valueOf("THIRTY_MINUTE")).isEqualTo(ScheduleAlram.THIRTY_MINUTE); + assertThat(ScheduleAlram.valueOf("ONE_DAY")).isEqualTo(ScheduleAlram.ONE_DAY); + } + } +}