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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
import org.springframework.transaction.annotation.Transactional;

import java.time.LocalDateTime;
import java.time.ZoneId;
import java.util.List;

/**
Expand All @@ -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일");
Expand Down
Original file line number Diff line number Diff line change
@@ -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());
}
}
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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<User> 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<User> 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);
}
}
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -115,6 +116,7 @@ void runScheduler_IntegrationTest() {
// --- Given: 시간 고정 & 스케줄러 실행 ---
try (MockedStatic<LocalDateTime> 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();
}
Expand Down