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);