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
@@ -0,0 +1,134 @@
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.util.List;

@Service
@RequiredArgsConstructor
@Slf4j
public class UserSchedulerService {

private final UserRepository userRepository;
private final AlarmService alarmService;
private final MessageUtils messageUtils;

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

// 1. Soft Delete 대상자 처리 (및 예고 알림)
processInactiveUsers(now);

// 2. Hard Delete 대상자 처리
processHardDeleteUsers(now);

log.info("👤 [스케줄러 종료]");
}

/**
* 장기 미접속자 조회 및 처리 (경고 알림 OR Soft Delete)
*/
private void processInactiveUsers(LocalDateTime now) {
// 기준일: 오늘 - 3개월 + 7일 (7일 전 알림을 위해 여유 있게 조회 후 로직에서 필터링)
// 사실상 3개월 전 즈음에 활동이 멈춘 사람들을 모두 가져옴
LocalDateTime searchThreshold = now.minusMonths(INACTIVE_MONTHS).plusDays(8);
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);
}

} 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);
} catch (Exception e) {
log.error("Hard Delete 실패: {}", user.getEmail(), e);
}
}
}
}
24 changes: 24 additions & 0 deletions src/main/java/DGU_AI_LAB/admin_be/domain/users/entity/User.java
Original file line number Diff line number Diff line change
@@ -1,9 +1,14 @@
package DGU_AI_LAB.admin_be.domain.users.entity;

import DGU_AI_LAB.admin_be.domain.requests.entity.Request;
import DGU_AI_LAB.admin_be.global.common.BaseTimeEntity;
import jakarta.persistence.*;
import lombok.*;

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

@Entity
@Table(name = "users")
@Getter
Expand Down Expand Up @@ -41,6 +46,15 @@ public class User extends BaseTimeEntity {
@Column(name = "is_active", nullable = false)
private Boolean isActive = true;

@Column(name = "last_login_at")
private LocalDateTime lastLoginAt;

@Column(name = "deleted_at")
private LocalDateTime deletedAt;

@OneToMany(mappedBy = "user", cascade = CascadeType.REMOVE, orphanRemoval = true)
private List<Request> requests = new ArrayList<>();

@Builder
public User(String email, String password, String name, String studentId, String phone, String department) {
this.email = email;
Expand All @@ -49,6 +63,7 @@ public User(String email, String password, String name, String studentId, String
this.studentId = studentId;
this.phone = phone;
this.department = department;
this.lastLoginAt = LocalDateTime.now();
}

// ===== 비즈니스 메서드 =====
Expand All @@ -65,4 +80,13 @@ public void updatePassword(String newEncodedPassword) {
public void updatePhone(String newPhone) {
this.phone = newPhone;
}

public void recordLogin() {
this.lastLoginAt = LocalDateTime.now();
}

public void withdraw() {
this.isActive = false;
this.deletedAt = LocalDateTime.now();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,41 @@

import DGU_AI_LAB.admin_be.domain.users.entity.User;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import org.springframework.stereotype.Repository;

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

@Repository
public interface UserRepository extends JpaRepository<User,Long> {

Optional<User> findByEmail(String email);

/**
* [자동 탈퇴 대상 조회 쿼리]
* 조건:
* 1. Active 상태인 유저
* 2. (현재 - 마지막 로그인) > 3개월
* 3. (현재 - 가장 최근 만료된 Pod 날짜) > 3개월 (Pod 사용 기록이 없으면 로그인 날짜만 봄)
* * 주의: COALESCE를 사용하여 Pod 기록이 없으면 아주 먼 과거(1900년)로 취급해 조건 통과시킴
*/
@Query("SELECT u FROM User u " +
"LEFT JOIN u.requests r " +
"WHERE u.isActive = true " +
"GROUP BY u " +
"HAVING " +
" (u.lastLoginAt IS NULL OR u.lastLoginAt < :thresholdDate) " +
" AND " +
" (MAX(r.expiresAt) IS NULL OR MAX(r.expiresAt) < :thresholdDate)")
List<User> findInactiveUsers(@Param("thresholdDate") LocalDateTime thresholdDate);

/**
* [Hard Delete 대상 조회]
* 조건: Soft Delete 된 지 1년 지난 유저
*/
@Query("SELECT u FROM User u WHERE u.isActive = false AND u.deletedAt < :hardDeleteThreshold")
List<User> findUsersForHardDelete(@Param("hardDeleteThreshold") LocalDateTime hardDeleteThreshold);
}
Original file line number Diff line number Diff line change
Expand Up @@ -60,10 +60,20 @@ public UserTokenResponseDTO login(UserLoginRequestDTO request) {
User user = userRepository.findByEmail(request.email())
.orElseThrow(() -> new UnauthorizedException(ErrorCode.INVALID_LOGIN_INFO));

if (!user.getIsActive()) {
throw new UnauthorizedException(ErrorCode.ACCOUNT_DISABLED);
}

if (!passwordEncoder.matches(request.password(), user.getPassword())) {
throw new UnauthorizedException(ErrorCode.INVALID_LOGIN_INFO);
}

if (!passwordEncoder.matches(request.password(), user.getPassword())) {
throw new UnauthorizedException(ErrorCode.INVALID_LOGIN_INFO);
}

user.recordLogin();

String accessToken = jwtProvider.getIssueToken(user.getUserId(), true);
String refreshToken = jwtProvider.getIssueToken(user.getUserId(), false);

Expand Down
1 change: 1 addition & 0 deletions src/main/java/DGU_AI_LAB/admin_be/error/ErrorCode.java
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,7 @@ public enum ErrorCode {
GROUP_NOT_FOUND(HttpStatus.NOT_FOUND, "지정된 그룹을 찾을 수 없습니다."),
UID_ALLOCATION_FAILED(HttpStatus.BAD_REQUEST, "UID를 할당에 실패했습니다."),
DUPLICATE_USERNAME(HttpStatus.CONFLICT, "이미 사용하고 있는 username입니다. 같은 사용자이더라도 다른 username을 입력해주세요."),
ACCOUNT_DISABLED(HttpStatus.NOT_FOUND,"비활성화된 유저입니다. 관리자에게 문의하세요."),

SLACK_USER_NOT_FOUND(HttpStatus.NOT_FOUND, "Slack 사용자를 찾을 수 없습니다."),
SLACK_USER_EMAIL_NOT_MATCH(HttpStatus.NOT_FOUND, "이메일이 일치하는 Slack 사용자를 찾을 수 없습니다."),
Expand Down
54 changes: 45 additions & 9 deletions src/main/resources/messages.properties
Original file line number Diff line number Diff line change
Expand Up @@ -2,26 +2,62 @@
# Slack & Email Notification Messages
# ==============================================================================

# ?? ?? (???)
# 1. ?? ?? (???)
# {0}: ??? ??
notification.expired.dm=????? {0}?, ???? GPU ?? ?? ??? ???? ???? ???????.

# {0}: ??? ??, {1}: ???, {2}: ???
notification.expired.detail.subject=[DGU AI LAB] ?? ?? ?? ?? ??
notification.expired.detail.body=?????, {0}?.\n\n?? ??? ?? ?? ?? ???? ???????.\n\n- ??: {1}\n- ??: {2}\n\n??? ??? ?????.
notification.expired.detail.body=?????, {0}?.\n\
\n?? ??? ?? ?? ?? ???? ???????.\n\
\n- ??: {1}\n\
- ??: {2}\n\
\n??? ??? ?????.

# 2. ?? ?? (7?, 3?, 1? ?)
# Subject ???: {1} -> {0} (??? dayLabel ????)
notification.pre-expiry.subject=[DGU AI LAB] ?? ?? ?? ?? ?? ({0} ?)

# ?? ?? (7?, 3?, 1? ?)
# {0}: ??? ??, {1}: ?? ??(ex. 7?), {2}: ???, {3}: ???, {4}: ???
notification.pre-expiry.subject=[DGU AI LAB] ?? ?? ?? ?? ?? ({1} ?)
notification.pre-expiry.body=?????, {0}?.\n\n?? ?? GPU ?? ??? {1} ? ({2})? ???? ??? ?????.\n\n- ??: {3}\n- ??: {4}\n\n??? ???? ??? ? ???, ??? ???? ?? ??? ??? ????.\n??? ????? ????? ?????.
notification.pre-expiry.body=?????, {0}?.\n\
\n?? ?? GPU ?? ??? {1} ? ({2})? ???? ??? ?????.\n\
\n- ??: {3}\n\
- ??: {4}\n\
\n??? ???? ??? ? ???, ??? ???? ?? ??? ??? ????.\n\
??? ????? ????? ?????.

# ??? ??
# 3. ??? ??
# {0}: ??(Farm/Lab), {1}: ???, {2}: ???, {3}: ????
notification.admin.delete.success=?? [{0}] ??? ?? ??: {1} ({2})
notification.admin.delete.fail=? [{0}] ??? ?? ??!\n- ??: {1}\n- ??: {2}\n- ??: {3}
notification.admin.new-request=? ??? ?? ?? ??! ?\n? ???: {0}\n? ??: {1}\n(??? ??? ?? ??)

# ?? ??
# 4. ?? ??
notification.approval.subject=[DGU AI LAB] ?? ?? ?? ??
notification.approval.body=? {0}?? ??? ???????.

# ??? ?? ??? (Fallback)
notification.error.redis-fallback=\n[?? Redis ??? ?? ???]
# 5. ??? ?? ??? (Fallback)
notification.error.redis-fallback=\n[?? Redis ??? ?? ???]

# ==============================================================================
# User Auto-Deletion Messages
# ==============================================================================

# 1. ?? ?? ?? (D-7, D-3, D-1)
# Subject: {0}: ?? ??
notification.user.delete-warning.subject=[DGU AI LAB] ?? ??? ?? ?? ?? ?? ({0}? ?)

# Body: {0}: ??, {1}: ?? ??, {2}: ?? ???
notification.user.delete-warning.body=?????, {0}?.\n\
\n???? ??? ?? ???(3?? ??) ? ??? ????? ?? \
{1}? ? ({2})? ???? ??? ?????.\n\
\n?? ??? ???? ????? ???? ???.\n\
??? ? ?? ?? ???? ?? ?????.

# 2. Soft Delete ??
# {0}: ??? ??
notification.user.soft-delete.subject=[DGU AI LAB] ?? ??? ?? ???? ??
notification.user.soft-delete.body=?????, {0}?.\n\
\n?? ????? ?? ??? ????(?? ??) ???????.\n\
\n???? ???? ?????, 1? ? ????? ???????? ?? ?????.\n\
?? ??? ???? ?? ????? ?? ????.
Loading