diff --git a/src/main/java/DGU_AI_LAB/admin_be/domain/scheduler/RequestSchedulerService.java b/src/main/java/DGU_AI_LAB/admin_be/domain/scheduler/RequestSchedulerService.java index 07838c4..2bf469d 100644 --- a/src/main/java/DGU_AI_LAB/admin_be/domain/scheduler/RequestSchedulerService.java +++ b/src/main/java/DGU_AI_LAB/admin_be/domain/scheduler/RequestSchedulerService.java @@ -14,6 +14,7 @@ import org.springframework.transaction.annotation.Transactional; import java.time.LocalDateTime; +import java.time.ZoneId; import java.util.List; /** @@ -33,7 +34,7 @@ public class RequestSchedulerService { @Scheduled(cron = "0 00 08 * * ?", zone = "Asia/Seoul") public void runScheduler() { log.info("๐Ÿ—“๏ธ [์Šค์ผ€์ค„๋Ÿฌ ์‹œ์ž‘] ๋งŒ๋ฃŒ ๊ณ„์ • ๊ด€๋ฆฌ ์ž‘์—…"); - LocalDateTime now = LocalDateTime.now(); + LocalDateTime now = LocalDateTime.now(ZoneId.of("Asia/Seoul")); sendPreExpiryNotification(now.plusDays(7), "7์ผ"); sendPreExpiryNotification(now.plusDays(3), "3์ผ"); diff --git a/src/main/java/DGU_AI_LAB/admin_be/domain/scheduler/UserLifecycleTransactionalService.java b/src/main/java/DGU_AI_LAB/admin_be/domain/scheduler/UserLifecycleTransactionalService.java new file mode 100644 index 0000000..7572114 --- /dev/null +++ b/src/main/java/DGU_AI_LAB/admin_be/domain/scheduler/UserLifecycleTransactionalService.java @@ -0,0 +1,94 @@ +package DGU_AI_LAB.admin_be.domain.scheduler; + +import DGU_AI_LAB.admin_be.domain.alarm.service.AlarmService; +import DGU_AI_LAB.admin_be.domain.users.entity.User; +import DGU_AI_LAB.admin_be.domain.users.repository.UserRepository; +import DGU_AI_LAB.admin_be.global.util.MessageUtils; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDateTime; +import java.time.temporal.ChronoUnit; + +/** + * ์œ ์ € ์ƒ๋ช…์ฃผ๊ธฐ ์ฒ˜๋ฆฌ๋ฅผ ํŠธ๋žœ์žญ์…˜ ๊ฒฝ๊ณ„ ์•ˆ์—์„œ ์‹คํ–‰ํ•ฉ๋‹ˆ๋‹ค. + * UserSchedulerService์˜ self-call ํ•œ๊ณ„๋ฅผ ์šฐํšŒํ•˜๊ณ , ์œ ์ €๋ณ„ ๋…๋ฆฝ ํŠธ๋žœ์žญ์…˜์„ ๋ณด์žฅํ•ฉ๋‹ˆ๋‹ค. + * (H-7: LazyInitializationException ๋ฐฉ์ง€, H-11: ๋‹จ์ผ ํŠธ๋žœ์žญ์…˜์œผ๋กœ ์ธํ•œ ๋กค๋ฐฑ ์ „ํŒŒ ๋ฐฉ์ง€) + */ +@Service +@RequiredArgsConstructor +@Slf4j +public class UserLifecycleTransactionalService { + + private final UserRepository userRepository; + private final AlarmService alarmService; + private final MessageUtils messageUtils; + + private static final int INACTIVE_MONTHS = 3; + + /** + * ํŠน์ • ์œ ์ €์˜ ๋น„ํ™œ์„ฑ ์—ฌ๋ถ€๋ฅผ ํŒ๋‹จํ•˜๊ณ , ๊ฒฝ๊ณ  ์•Œ๋ฆผ ๋ฐœ์†ก ๋˜๋Š” Soft Delete๋ฅผ ์ˆ˜ํ–‰ํ•ฉ๋‹ˆ๋‹ค. + * ์œ ์ €๋ณ„ ๋…๋ฆฝ ํŠธ๋žœ์žญ์…˜์œผ๋กœ ์‹คํ–‰๋˜์–ด, ํ•œ ์œ ์ € ์‹คํŒจ๊ฐ€ ๋‹ค๋ฅธ ์œ ์ € ์ฒ˜๋ฆฌ์— ์˜ํ–ฅ์„ ์ฃผ์ง€ ์•Š์Šต๋‹ˆ๋‹ค. + */ + @Transactional + public void processInactiveUser(Long userId, LocalDateTime now) { + User user = userRepository.findById(userId).orElseThrow(); + + LocalDateTime lastActivity = user.getLastLoginAt(); + + // Lazy ์ปฌ๋ ‰์…˜์„ ํŠธ๋žœ์žญ์…˜ ๋‚ด์—์„œ ์ ‘๊ทผ (H-7 fix) + if (!user.getRequests().isEmpty()) { + LocalDateTime lastPodExpire = user.getRequests().stream() + .map(req -> req.getExpiresAt()) + .max(LocalDateTime::compareTo) + .orElse(LocalDateTime.MIN); + + if (lastPodExpire.isAfter(lastActivity)) { + lastActivity = lastPodExpire; + } + } + + LocalDateTime deleteDate = lastActivity.plusMonths(INACTIVE_MONTHS); + long daysLeft = ChronoUnit.DAYS.between(now.toLocalDate(), deleteDate.toLocalDate()); + + if (daysLeft == 7 || daysLeft == 3 || daysLeft == 1) { + sendWarningAlert(user, daysLeft, deleteDate); + } else if (daysLeft <= 0) { + softDeleteUser(user); + } + } + + /** + * ํŠน์ • ์œ ์ €๋ฅผ DB์—์„œ ์™„์ „ ์‚ญ์ œ(Hard Delete)ํ•ฉ๋‹ˆ๋‹ค. + * ๋…๋ฆฝ ํŠธ๋žœ์žญ์…˜์œผ๋กœ ์‹คํ–‰๋˜์–ด, ์‹คํŒจ ์‹œ ๋‹ค๋ฅธ ์œ ์ €์˜ ์ฒ˜๋ฆฌ์— ์˜ํ–ฅ์„ ์ฃผ์ง€ ์•Š์Šต๋‹ˆ๋‹ค. + */ + @Transactional + public void hardDeleteUser(Long userId, String email) { + userRepository.findById(userId).ifPresent(user -> { + userRepository.delete(user); + log.info("๊ณ„์ • ์˜๊ตฌ ์‚ญ์ œ(Hard Delete) ์™„๋ฃŒ: ID={}, Email={}", userId, email); + }); + } + + private void sendWarningAlert(User user, long daysLeft, LocalDateTime deleteDate) { + String dateStr = deleteDate.toLocalDate().toString(); + String subject = messageUtils.get("notification.user.delete-warning.subject", String.valueOf(daysLeft)); + String body = messageUtils.get("notification.user.delete-warning.body", + user.getName(), String.valueOf(daysLeft), dateStr); + + alarmService.sendAllAlerts(user.getName(), user.getEmail(), subject, body); + log.info("๊ฒฝ๊ณ  ์•Œ๋ฆผ ๋ฐœ์†ก: {} ({}์ผ ์ „)", user.getEmail(), daysLeft); + } + + private void softDeleteUser(User user) { + user.withdraw(); + + String subject = messageUtils.get("notification.user.soft-delete.subject"); + String body = messageUtils.get("notification.user.soft-delete.body", user.getName()); + + alarmService.sendAllAlerts(user.getName(), user.getEmail(), subject, body); + log.info("๊ณ„์ • ๋น„ํ™œ์„ฑํ™”(Soft Delete) ์™„๋ฃŒ: {}", user.getEmail()); + } +} diff --git a/src/main/java/DGU_AI_LAB/admin_be/domain/scheduler/UserSchedulerService.java b/src/main/java/DGU_AI_LAB/admin_be/domain/scheduler/UserSchedulerService.java index 93f5be1..7bf03cf 100644 --- a/src/main/java/DGU_AI_LAB/admin_be/domain/scheduler/UserSchedulerService.java +++ b/src/main/java/DGU_AI_LAB/admin_be/domain/scheduler/UserSchedulerService.java @@ -1,18 +1,14 @@ package DGU_AI_LAB.admin_be.domain.scheduler; -import DGU_AI_LAB.admin_be.domain.alarm.service.AlarmService; import DGU_AI_LAB.admin_be.domain.users.entity.User; import DGU_AI_LAB.admin_be.domain.users.repository.UserRepository; -import DGU_AI_LAB.admin_be.global.util.MessageUtils; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.scheduling.annotation.Scheduled; import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; -import java.time.LocalDate; import java.time.LocalDateTime; -import java.time.temporal.ChronoUnit; +import java.time.ZoneId; import java.util.List; @Service @@ -21,114 +17,48 @@ public class UserSchedulerService { private final UserRepository userRepository; - private final AlarmService alarmService; - private final MessageUtils messageUtils; + private final UserLifecycleTransactionalService userLifecycleService; private static final int INACTIVE_MONTHS = 3; private static final int HARD_DELETE_YEARS = 1; // ๋งค์ผ ์˜ค์ „ 09:00 ์‹คํ–‰ @Scheduled(cron = "0 0 9 * * ?", zone = "Asia/Seoul") - @Transactional public void runUserLifecycleScheduler() { log.info("๐Ÿ‘ค [์Šค์ผ€์ค„๋Ÿฌ ์‹œ์ž‘] ์‚ฌ์šฉ์ž ๊ณ„์ • ์ˆ˜๋ช…์ฃผ๊ธฐ ๊ด€๋ฆฌ"); - LocalDateTime now = LocalDateTime.now(); + LocalDateTime now = LocalDateTime.now(ZoneId.of("Asia/Seoul")); - // 1. Soft Delete ๋Œ€์ƒ์ž ์ฒ˜๋ฆฌ (๋ฐ ์˜ˆ๊ณ  ์•Œ๋ฆผ) processInactiveUsers(now); - - // 2. Hard Delete ๋Œ€์ƒ์ž ์ฒ˜๋ฆฌ processHardDeleteUsers(now); log.info("๐Ÿ‘ค [์Šค์ผ€์ค„๋Ÿฌ ์ข…๋ฃŒ]"); } - /** - * ์žฅ๊ธฐ ๋ฏธ์ ‘์†์ž ์กฐํšŒ ๋ฐ ์ฒ˜๋ฆฌ (๊ฒฝ๊ณ  ์•Œ๋ฆผ OR Soft Delete) - */ private void processInactiveUsers(LocalDateTime now) { - // ๊ธฐ์ค€์ผ: (์˜ค๋Š˜ + 8์ผ) - 3๊ฐœ์›” (7์ผ ์ „ ์•Œ๋ฆผ์„ ์œ„ํ•ด ์—ฌ์œ  ์žˆ๊ฒŒ ์กฐํšŒ ํ›„ ๋กœ์ง์—์„œ ํ•„ํ„ฐ๋ง) - // plusDays ํ›„ minusMonths ์ˆœ์„œ์—ฌ์•ผ ๋‹ฌ๋ ฅ ์—ฐ์‚ฐ ์˜ค์ฐจ ์—†์ด D-7 ๋Œ€์ƒ์ž๋ฅผ ์ •ํ™•ํžˆ ํฌํ•จํ•จ LocalDateTime searchThreshold = now.plusDays(8).minusMonths(INACTIVE_MONTHS); List inactiveCandidates = userRepository.findInactiveUsers(searchThreshold); for (User user : inactiveCandidates) { try { - // ์ด ์œ ์ €์˜ "ํ™œ๋™ ๋งŒ๋ฃŒ์ผ(์‚ญ์ œ ์˜ˆ์ •์ผ)" ๊ณ„์‚ฐ - // ๋งŒ๋ฃŒ์ผ = Max(LastLogin, LastPodExpire) + 3๊ฐœ์›” - LocalDateTime lastActivity = user.getLastLoginAt(); - - // ์ฟผ๋ฆฌ์—์„œ ์ด๋ฏธ ํ•„ํ„ฐ๋งํ–ˆ์ง€๋งŒ, Java ๋‹จ์—์„œ ์ •ํ™•ํ•œ D-Day ๊ณ„์‚ฐ์„ ์œ„ํ•ด ๋‹ค์‹œ ํ™•์ธ - // Request๋Š” Lazy Loading์ด๋ฏ€๋กœ ํŠธ๋žœ์žญ์…˜ ๋‚ด์—์„œ ์ ‘๊ทผ ๊ฐ€๋Šฅ - if (!user.getRequests().isEmpty()) { - LocalDateTime lastPodExpire = user.getRequests().stream() - .map(req -> req.getExpiresAt()) - .max(LocalDateTime::compareTo) - .orElse(LocalDateTime.MIN); - - if (lastPodExpire.isAfter(lastActivity)) { - lastActivity = lastPodExpire; - } - } - - LocalDateTime deleteDate = lastActivity.plusMonths(INACTIVE_MONTHS); - long daysLeft = ChronoUnit.DAYS.between(now.toLocalDate(), deleteDate.toLocalDate()); - - // 1) ์˜ˆ๊ณ  ์•Œ๋ฆผ (D-7, D-3, D-1) - if (daysLeft == 7 || daysLeft == 3 || daysLeft == 1) { - sendWarningAlert(user, daysLeft, deleteDate); - } - // 2) Soft Delete ์‹คํ–‰ (D-Day ๋˜๋Š” ๊ทธ ์ดํ›„) - else if (daysLeft <= 0) { - softDeleteUser(user); - } - + // ์œ ์ €๋ณ„ ๋…๋ฆฝ ํŠธ๋žœ์žญ์…˜์œผ๋กœ ์ฒ˜๋ฆฌ โ€” H-7(LazyInit), H-11(๋กค๋ฐฑ ์ „ํŒŒ) ๋ฐฉ์ง€ + userLifecycleService.processInactiveUser(user.getUserId(), now); } catch (Exception e) { log.error("์œ ์ €({}) ์ˆ˜๋ช…์ฃผ๊ธฐ ์ฒ˜๋ฆฌ ์ค‘ ์˜ค๋ฅ˜: {}", user.getEmail(), e.getMessage()); } } } - private void sendWarningAlert(User user, long daysLeft, LocalDateTime deleteDate) { - String dateStr = deleteDate.toLocalDate().toString(); - - String subject = messageUtils.get("notification.user.delete-warning.subject", String.valueOf(daysLeft)); - String body = messageUtils.get("notification.user.delete-warning.body", - user.getName(), String.valueOf(daysLeft), dateStr); - - alarmService.sendAllAlerts(user.getName(), user.getEmail(), subject, body); - log.info("๊ฒฝ๊ณ  ์•Œ๋ฆผ ๋ฐœ์†ก: {} ({}์ผ ์ „)", user.getEmail(), daysLeft); - } - - private void softDeleteUser(User user) { - user.withdraw(); // isActive = false, deletedAt = now - - String subject = messageUtils.get("notification.user.soft-delete.subject"); - String body = messageUtils.get("notification.user.soft-delete.body", user.getName()); - - alarmService.sendAllAlerts(user.getName(), user.getEmail(), subject, body); - log.info("๊ณ„์ • ๋น„ํ™œ์„ฑํ™”(Soft Delete) ์™„๋ฃŒ: {}", user.getEmail()); - } - - /** - * Hard Delete (๊ฐœ์ธ์ •๋ณด ์™„์ „ ์‚ญ์ œ) - */ private void processHardDeleteUsers(LocalDateTime now) { LocalDateTime hardDeleteThreshold = now.minusYears(HARD_DELETE_YEARS); List hardDeleteTargets = userRepository.findUsersForHardDelete(hardDeleteThreshold); for (User user : hardDeleteTargets) { try { - Long userId = user.getUserId(); - String email = user.getEmail(); - - // DB ์™„์ „ ์‚ญ์ œ (Cascade ์„ค์ •์œผ๋กœ ์ธํ•ด ์—ฐ๊ด€ Request๋„ ์‚ญ์ œ๋จ) - userRepository.delete(user); - - log.info("๊ณ„์ • ์˜๊ตฌ ์‚ญ์ œ(Hard Delete) ์™„๋ฃŒ: ID={}, Email={}", userId, email); + // ์œ ์ €๋ณ„ ๋…๋ฆฝ ํŠธ๋žœ์žญ์…˜์œผ๋กœ ์ฒ˜๋ฆฌ โ€” processInactiveUsers ๊ฒฐ๊ณผ์— ์˜ํ–ฅ ์—†์Œ + userLifecycleService.hardDeleteUser(user.getUserId(), user.getEmail()); } catch (Exception e) { log.error("Hard Delete ์‹คํŒจ: {}", user.getEmail(), e); } } } -} \ No newline at end of file +} diff --git a/src/test/java/DGU_AI_LAB/admin_be/domain/scheduler/RequestSchedulerServiceTest.java b/src/test/java/DGU_AI_LAB/admin_be/domain/scheduler/RequestSchedulerServiceTest.java index 176fd35..60c2aad 100644 --- a/src/test/java/DGU_AI_LAB/admin_be/domain/scheduler/RequestSchedulerServiceTest.java +++ b/src/test/java/DGU_AI_LAB/admin_be/domain/scheduler/RequestSchedulerServiceTest.java @@ -25,6 +25,7 @@ import org.springframework.test.context.bean.override.mockito.MockitoBean; import java.time.LocalDateTime; +import java.time.ZoneId; import java.util.List; import static org.assertj.core.api.Assertions.assertThat; @@ -115,6 +116,7 @@ void runScheduler_IntegrationTest() { // --- Given: ์‹œ๊ฐ„ ๊ณ ์ • & ์Šค์ผ€์ค„๋Ÿฌ ์‹คํ–‰ --- try (MockedStatic mockedTime = Mockito.mockStatic(LocalDateTime.class, Mockito.CALLS_REAL_METHODS)) { mockedTime.when(LocalDateTime::now).thenReturn(MOCK_NOW); + mockedTime.when(() -> LocalDateTime.now(any(ZoneId.class))).thenReturn(MOCK_NOW); requestSchedulerService.runScheduler(); }