diff --git a/.idea/dataSources.xml b/.idea/dataSources.xml new file mode 100644 index 0000000..6faad9e --- /dev/null +++ b/.idea/dataSources.xml @@ -0,0 +1,19 @@ + + + + + mysql.8 + true + true + $PROJECT_DIR$/ontime-back/src/main/resources/application.properties + com.mysql.cj.jdbc.Driver + jdbc:mysql://localhost:3306/ontime + + + + + + $ProjectFileDir$ + + + \ No newline at end of file diff --git a/.idea/modules/devkor.ontime-back.main.iml b/.idea/modules/devkor.ontime-back.main.iml new file mode 100644 index 0000000..2b3b8af --- /dev/null +++ b/.idea/modules/devkor.ontime-back.main.iml @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/.idea/modules/ontime-back.main.iml b/.idea/modules/ontime-back.main.iml new file mode 100644 index 0000000..f4cd67e --- /dev/null +++ b/.idea/modules/ontime-back.main.iml @@ -0,0 +1,7 @@ + + + + + + + \ No newline at end of file diff --git a/.idea/sqlDataSources.xml b/.idea/sqlDataSources.xml new file mode 100644 index 0000000..f87c123 --- /dev/null +++ b/.idea/sqlDataSources.xml @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/ontime-back/src/main/java/devkor/ontime_back/config/SchedulerConfig.java b/ontime-back/src/main/java/devkor/ontime_back/config/SchedulerConfig.java new file mode 100644 index 0000000..924d6d1 --- /dev/null +++ b/ontime-back/src/main/java/devkor/ontime_back/config/SchedulerConfig.java @@ -0,0 +1,19 @@ +package devkor.ontime_back.config; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.scheduling.TaskScheduler; +import org.springframework.scheduling.concurrent.ThreadPoolTaskScheduler; + +@Configuration +public class SchedulerConfig { + + @Bean + public TaskScheduler taskScheduler() { + ThreadPoolTaskScheduler scheduler = new ThreadPoolTaskScheduler(); + scheduler.setPoolSize(20); // 필요시 더 늘릴 수 있음 + scheduler.setThreadNamePrefix("scheduler-"); + scheduler.initialize(); + return scheduler; + } +} \ No newline at end of file diff --git a/ontime-back/src/main/java/devkor/ontime_back/config/SwaggerConfig.java b/ontime-back/src/main/java/devkor/ontime_back/config/SwaggerConfig.java index 002969b..54f58a8 100644 --- a/ontime-back/src/main/java/devkor/ontime_back/config/SwaggerConfig.java +++ b/ontime-back/src/main/java/devkor/ontime_back/config/SwaggerConfig.java @@ -15,7 +15,8 @@ @Configuration @OpenAPIDefinition( servers = { - @Server(url = "https://ontime.devkor.club", description = "Production Server") + @Server(url = "https://ontime.devkor.club", description = "Production Server"), + @Server(url = "http://localhost:8080", description = "Local Serever") } ) public class SwaggerConfig { diff --git a/ontime-back/src/main/java/devkor/ontime_back/entity/NotificationSchedule.java b/ontime-back/src/main/java/devkor/ontime_back/entity/NotificationSchedule.java new file mode 100644 index 0000000..047a841 --- /dev/null +++ b/ontime-back/src/main/java/devkor/ontime_back/entity/NotificationSchedule.java @@ -0,0 +1,52 @@ +package devkor.ontime_back.entity; + +import jakarta.persistence.*; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; + +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Entity +public class NotificationSchedule { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + private LocalDateTime notificationTime; + + private Boolean isSent; + + @OneToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "schedule_id") + private Schedule schedule; + + @Builder + public NotificationSchedule(LocalDateTime notificationTime, Boolean isSent, Schedule schedule) { + this.notificationTime = notificationTime; + this.isSent = isSent; + this.schedule = schedule; + } + + public void changeStatusToSent() { + if(Boolean.FALSE.equals(this.isSent)) { + this.isSent = true; + } + } + + public void updateNotificationTime(LocalDateTime localDateTime) { + this.notificationTime = localDateTime; + } + + public void markAsUnsent() { + this.isSent = false; + } + + public void disconnectSchedule() { + this.schedule = null; + } +} diff --git a/ontime-back/src/main/java/devkor/ontime_back/repository/NotificationScheduleRepository.java b/ontime-back/src/main/java/devkor/ontime_back/repository/NotificationScheduleRepository.java new file mode 100644 index 0000000..0e88702 --- /dev/null +++ b/ontime-back/src/main/java/devkor/ontime_back/repository/NotificationScheduleRepository.java @@ -0,0 +1,22 @@ +package devkor.ontime_back.repository; + +import devkor.ontime_back.entity.NotificationSchedule; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.stereotype.Repository; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +@Repository +public interface NotificationScheduleRepository extends JpaRepository { + @Query("SELECT n FROM NotificationSchedule n " + + "JOIN FETCH n.schedule s " + + "JOIN FETCH s.user " + + "WHERE n.notificationTime > :now AND n.isSent = false") + List findAllWithScheduleAndUser(LocalDateTime now); + + Optional findByScheduleScheduleId(UUID scheduleId); +} diff --git a/ontime-back/src/main/java/devkor/ontime_back/response/ErrorCode.java b/ontime-back/src/main/java/devkor/ontime_back/response/ErrorCode.java index fd30c82..fbd8a5c 100644 --- a/ontime-back/src/main/java/devkor/ontime_back/response/ErrorCode.java +++ b/ontime-back/src/main/java/devkor/ontime_back/response/ErrorCode.java @@ -28,10 +28,10 @@ public enum ErrorCode { SCHEDULE_NOT_FOUND("1010", "해당 약속이 존재하지 않습니다.", HttpStatus.BAD_REQUEST), FIREBASE("1011", "FIREBASE로 메세지를 발송하였으나 오류가 발생했습니다.(유효하지 않은 토큰 등)", HttpStatus.BAD_REQUEST), FIRST_PREPARATION_NOT_FOUND("1012", "해당 ID의 사용자의 준비과정을 찾을 수 없습니다.", HttpStatus.BAD_REQUEST), - + NOTIFICATION_NOT_FOUND("1013", "알림을 찾을 수 없습니다.", HttpStatus.BAD_REQUEST ), // 공통 오류 메시지 - UNEXPECTED_ERROR("1000", "Unexpected Error: An unexpected error occurred.", HttpStatus.INTERNAL_SERVER_ERROR); + UNEXPECTED_ERROR("1000", "Unexpected Error: An unexpected error occurred.", HttpStatus.INTERNAL_SERVER_ERROR),; private final String code; private final String message; diff --git a/ontime-back/src/main/java/devkor/ontime_back/scheduler/NotificationScheduler.java b/ontime-back/src/main/java/devkor/ontime_back/scheduler/NotificationScheduler.java index 6c46be1..85e0f4f 100644 --- a/ontime-back/src/main/java/devkor/ontime_back/scheduler/NotificationScheduler.java +++ b/ontime-back/src/main/java/devkor/ontime_back/scheduler/NotificationScheduler.java @@ -48,24 +48,5 @@ public void sendMorningReminder() { List schedulesForToday = scheduleRepository.findSchedulesBetween(startOfToday, endOfToday); notificationService.sendReminder(schedulesForToday, "오늘 예정된 약속이 있습니다."); } - - @Scheduled(cron = "0 * * * * *") // 매 분의 0초에 실행 - public void sendFiveMinutesBeforeReminder() { - LocalDateTime baseTime = LocalDateTime.now().plusMinutes(5); // 현재 시간 - LocalDateTime startTime = baseTime.withSecond(0).withNano(0); // 초와 나노초 제거 (분 단위로 설정) - LocalDateTime endTime = startTime.plusMinutes(1).minusNanos(1); // 다음 분의 직전까지 - - System.out.println("5분 후 시간: " + baseTime); - - // 5분 후의 scheduleTime과 일치하는 약속 조회 - List schedulesStartingSoon = scheduleRepository.findSchedulesBetween(startTime, endTime); - - for(Schedule schedule : schedulesStartingSoon) { - System.out.println("5분 뒤의 약속: " + schedule.getScheduleName()); - } - - // 알림 전송 - notificationService.sendReminder(schedulesStartingSoon, "약속 5분 전입니다. 준비하세요."); - } } diff --git a/ontime-back/src/main/java/devkor/ontime_back/service/NotificationRecoveryService.java b/ontime-back/src/main/java/devkor/ontime_back/service/NotificationRecoveryService.java new file mode 100644 index 0000000..09326c2 --- /dev/null +++ b/ontime-back/src/main/java/devkor/ontime_back/service/NotificationRecoveryService.java @@ -0,0 +1,36 @@ +package devkor.ontime_back.service; + +import devkor.ontime_back.entity.NotificationSchedule; +import devkor.ontime_back.repository.NotificationScheduleRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.boot.context.event.ApplicationReadyEvent; +import org.springframework.context.event.EventListener; +import org.springframework.stereotype.Component; + +import java.time.LocalDateTime; +import java.util.List; + +@Slf4j +@Component +@RequiredArgsConstructor +public class NotificationRecoveryService { + + private final NotificationScheduleRepository notificationScheduleRepository; + private final NotificationService notificationService; + + @EventListener(ApplicationReadyEvent.class) + public void recoverNotificationSchedules() { + log.info("서버 부팅 완료: 알림 스케줄 복구 시작"); + + LocalDateTime now = LocalDateTime.now(); + List pendingNotifications = notificationScheduleRepository.findAllWithScheduleAndUser(now); + + + for (NotificationSchedule notification : pendingNotifications) { + notificationService.scheduleReminder(notification); + } + + log.info("알림 스케줄 복구 완료: 복구된 알림 수 = {}", pendingNotifications.size()); + } +} \ No newline at end of file diff --git a/ontime-back/src/main/java/devkor/ontime_back/service/NotificationService.java b/ontime-back/src/main/java/devkor/ontime_back/service/NotificationService.java index 65b1292..db9a597 100644 --- a/ontime-back/src/main/java/devkor/ontime_back/service/NotificationService.java +++ b/ontime-back/src/main/java/devkor/ontime_back/service/NotificationService.java @@ -2,22 +2,82 @@ import com.google.firebase.messaging.FirebaseMessaging; import com.google.firebase.messaging.Message; +import devkor.ontime_back.entity.NotificationSchedule; import devkor.ontime_back.entity.Schedule; import devkor.ontime_back.entity.User; import devkor.ontime_back.entity.UserSetting; +import devkor.ontime_back.repository.NotificationScheduleRepository; import devkor.ontime_back.repository.UserSettingRepository; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.scheduling.TaskScheduler; +import org.springframework.scheduling.annotation.Async; +import org.springframework.scheduling.annotation.EnableAsync; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import java.time.LocalDateTime; +import java.time.ZoneId; +import java.util.Date; import java.util.List; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ScheduledFuture; +@Slf4j +@EnableAsync @Service @RequiredArgsConstructor @Transactional(readOnly = true) public class NotificationService { private final UserSettingRepository userSettingRepository; + private final TaskScheduler taskScheduler; + private final NotificationScheduleRepository notificationScheduleRepository; + private final ConcurrentHashMap> scheduledTasks = new ConcurrentHashMap<>(); + + public void scheduleReminder(NotificationSchedule notificationSchedule) { + LocalDateTime reminderTime = notificationSchedule.getNotificationTime(); + + if (reminderTime.isBefore(LocalDateTime.now())) { + log.warn("약속 알림 시간이 과거인 경우 알림 스케줄링하지 않습니다. {} ({})", notificationSchedule.getSchedule().getScheduleName(), reminderTime); + return; + } + + ScheduledFuture future = taskScheduler.schedule( + () -> sendReminder(notificationSchedule, "약속 5분 전입니다"), + Date.from(reminderTime.atZone(ZoneId.systemDefault()).toInstant()) + ); + + scheduledTasks.put(notificationSchedule.getId(), future); + + log.info("스케줄 등록 완료 {} ({})", notificationSchedule.getSchedule().getScheduleName(), reminderTime); + } + + public void cancelScheduledNotification(Long notificationId) { + ScheduledFuture future = scheduledTasks.get(notificationId); + if (future != null && !future.isCancelled()) { + future.cancel(true); + scheduledTasks.remove(notificationId); + log.info("스케줄 취소 완료: notificationId={}", notificationId); + } + } + + @Async + @Transactional + public void sendReminder(NotificationSchedule notificationSchedule, String message) { + Long userId = notificationSchedule.getSchedule().getUser().getId(); + + if (userId != null) { + UserSetting userSetting = userSettingRepository.findByUserId(userId) + .orElseThrow(() -> new IllegalArgumentException("No UserSetting found in schedule's user"));// Repository 메서드 가정 + + if (Boolean.TRUE.equals(userSetting.getIsNotificationsEnabled())) { + sendNotificationToUser(notificationSchedule.getSchedule(), message); + notificationSchedule.changeStatusToSent(); + notificationScheduleRepository.save(notificationSchedule); + } + } + } public void sendReminder(List schedules, String message) { for (Schedule schedule : schedules) { @@ -25,11 +85,9 @@ public void sendReminder(List schedules, String message) { Long userId = user.getId(); if (userId != null) { - // UserSetting 테이블에서 해당 유저의 알림 설정 가져오기 UserSetting userSetting = userSettingRepository.findByUserId(userId) .orElseThrow(() -> new IllegalArgumentException("No UserSetting found in schedule's user"));// Repository 메서드 가정 - // 알림 설정 확인 if (userSetting != null && userSetting.getIsNotificationsEnabled()) { sendNotificationToUser(schedule, message); } @@ -37,7 +95,8 @@ public void sendReminder(List schedules, String message) { } } - private void sendNotificationToUser(Schedule schedule, String message) { + @Transactional + public void sendNotificationToUser(Schedule schedule, String message) { User user = schedule.getUser(); String firebaseToken = user.getFirebaseToken(); @@ -48,8 +107,8 @@ private void sendNotificationToUser(Schedule schedule, String message) { .build(); try { - String response = FirebaseMessaging.getInstance().send(firebaseMessage); - System.out.println("Firebase에 성공적으로 push notification 요청을 보냈으며, Firebase로부터 적절한 응답을 받았습니다 \n응답 내용:" + response); + FirebaseMessaging.getInstance().send(firebaseMessage); + log.info("Firebase에 성공적으로 push notification 요청을 보냈으며, Firebase로부터 적절한 응답을 받았습니다 \n알림 푸시한 약속:" + schedule.getScheduleName()); } catch (Exception e) { e.printStackTrace(); } diff --git a/ontime-back/src/main/java/devkor/ontime_back/service/ScheduleService.java b/ontime-back/src/main/java/devkor/ontime_back/service/ScheduleService.java index 3f46c28..e93e8ea 100644 --- a/ontime-back/src/main/java/devkor/ontime_back/service/ScheduleService.java +++ b/ontime-back/src/main/java/devkor/ontime_back/service/ScheduleService.java @@ -1,6 +1,7 @@ package devkor.ontime_back.service; import devkor.ontime_back.dto.*; +import devkor.ontime_back.entity.NotificationSchedule; import devkor.ontime_back.entity.Place; import devkor.ontime_back.entity.Schedule; import devkor.ontime_back.entity.User; @@ -26,12 +27,14 @@ public class ScheduleService { private final UserService userService; + private final NotificationService notificationService; private final ScheduleRepository scheduleRepository; private final UserRepository userRepository; private final PlaceRepository placeRepository; private final PreparationScheduleRepository preparationScheduleRepository; private final PreparationUserRepository preparationUserRepository; + private final NotificationScheduleRepository notificationScheduleRepository; // scheduleId, userId를 통한 권한 확인 private Schedule getScheduleWithAuthorization(UUID scheduleId, Long userId) { @@ -82,11 +85,23 @@ public ScheduleDto showScheduleByScheduleId(Long userId, UUID scheduleId) { @Transactional public void deleteSchedule(UUID scheduleId, Long userId) { Schedule schedule = getScheduleWithAuthorization(scheduleId, userId); + NotificationSchedule notification = notificationScheduleRepository.findByScheduleScheduleId(scheduleId) + .orElseThrow(() -> new GeneralException(NOTIFICATION_NOT_FOUND)); + cancleAndDeleteNotification(notification); + notificationScheduleRepository.flush(); preparationScheduleRepository.deleteBySchedule(schedule); scheduleRepository.deleteByScheduleId(scheduleId); } + private void cancleAndDeleteNotification(NotificationSchedule notification) { + log.info("{}에 대한 알림 취소 및 삭제 됨", notification.getSchedule().getScheduleName()); + notification.disconnectSchedule(); + notificationService.cancelScheduledNotification(notification.getId()); + notificationScheduleRepository.delete(notification); + log.info("알림 삭제 완료"); + } + // schedule 수정 @Transactional public void modifySchedule(Long userId, UUID scheduleId, ScheduleModDto scheduleModDto) { @@ -103,6 +118,24 @@ public void modifySchedule(Long userId, UUID scheduleId, ScheduleModDto schedule scheduleModDto.getScheduleSpareTime(), scheduleModDto.getLatenessTime(), scheduleModDto.getScheduleNote()); + scheduleRepository.save(schedule); + + + NotificationSchedule notification = notificationScheduleRepository.findByScheduleScheduleId(scheduleId) + .orElseThrow(() -> new GeneralException(NOTIFICATION_NOT_FOUND)); + LocalDateTime newNotificationTime = scheduleModDto.getScheduleTime().minusMinutes(5); + updateAndRescheduleNotification(newNotificationTime, notification); + } + + private void updateAndRescheduleNotification(LocalDateTime newNotificationTime, NotificationSchedule notification) { + if(newNotificationTime == notification.getNotificationTime()) return; + + notificationService.cancelScheduledNotification(notification.getId()); + notification.updateNotificationTime(newNotificationTime); + notification.markAsUnsent(); + notificationScheduleRepository.save(notification); + notificationService.scheduleReminder(notification); + log.info("{}에 대한 알림정보 업데이트되고 스케줄링 계획도 리스케줄됨", notification.getSchedule().getScheduleName()); } // schedule 추가 @@ -126,8 +159,16 @@ public void addSchedule(ScheduleAddDto scheduleAddDto, Long userId) { .isStarted(false) .latenessTime(-1) .build(); - scheduleRepository.save(schedule); + + NotificationSchedule notification = NotificationSchedule.builder() + .notificationTime(schedule.getScheduleTime().minusMinutes(5)) // 차후 알림보내야하는 시각으로 수정 필요 + .isSent(false) + .schedule(schedule) + .build(); + notificationScheduleRepository.save(notification); + + notificationService.scheduleReminder(notification); } // 지각 히스토리 반환 diff --git a/ontime-back/src/main/resources/db/migration/V2__create_notification_schedule.sql b/ontime-back/src/main/resources/db/migration/V2__create_notification_schedule.sql new file mode 100644 index 0000000..68bbe01 --- /dev/null +++ b/ontime-back/src/main/resources/db/migration/V2__create_notification_schedule.sql @@ -0,0 +1,10 @@ +CREATE TABLE notification_schedule ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + notification_time TIMESTAMP, + is_sent BOOLEAN, + schedule_id BINARY(16), + CONSTRAINT fk_notification_schedule_schedule + FOREIGN KEY (schedule_id) + REFERENCES schedule (schedule_id) + ON DELETE CASCADE +); \ No newline at end of file diff --git a/ontime-back/src/test/java/devkor/ontime_back/service/ScheduleServiceTest.java b/ontime-back/src/test/java/devkor/ontime_back/service/ScheduleServiceTest.java index 694482c..9b1887e 100644 --- a/ontime-back/src/test/java/devkor/ontime_back/service/ScheduleServiceTest.java +++ b/ontime-back/src/test/java/devkor/ontime_back/service/ScheduleServiceTest.java @@ -47,6 +47,8 @@ class ScheduleServiceTest { @Autowired private PasswordEncoder passwordEncoder; + @Autowired + private NotificationScheduleRepository notificationScheduleRepository; @AfterEach void tearDown() { @@ -647,8 +649,13 @@ void deleteSchedule_success() { .place(place1) .user(newUser) .build(); - + NotificationSchedule notificationSchedule = NotificationSchedule.builder() + .notificationTime(LocalDateTime.of(2025, 2, 23, 6, 55)) + .isSent(false) + .schedule(addedSchedule1) + .build(); scheduleRepository.save(addedSchedule1); + notificationScheduleRepository.save(notificationSchedule); // when scheduleService.deleteSchedule(addedSchedule1.getScheduleId(), newUser.getId()); @@ -799,6 +806,12 @@ void modifySchedule_success() { .scheduleSpareTime(5) .latenessTime(10) .build(); + NotificationSchedule notificationSchedule = NotificationSchedule.builder() + .notificationTime(LocalDateTime.of(2025, 2, 23, 6, 55)) + .isSent(false) + .schedule(addedSchedule1) + .build(); + notificationScheduleRepository.save(notificationSchedule); // when scheduleService.modifySchedule(newUser.getId(), addedSchedule1.getScheduleId(), scheduleModDto); @@ -859,6 +872,12 @@ void modifySchedule_withNewPlace() { .scheduleSpareTime(5) .latenessTime(10) .build(); + NotificationSchedule notificationSchedule = NotificationSchedule.builder() + .notificationTime(LocalDateTime.of(2025, 2, 23, 6, 55)) + .isSent(false) + .schedule(addedSchedule1) + .build(); + notificationScheduleRepository.save(notificationSchedule); // when scheduleService.modifySchedule(newUser.getId(), scheduleId, scheduleModDto);