diff --git a/src/main/java/DGU_AI_LAB/admin_be/AdminBeApplication.java b/src/main/java/DGU_AI_LAB/admin_be/AdminBeApplication.java index 605bf62..5d29671 100644 --- a/src/main/java/DGU_AI_LAB/admin_be/AdminBeApplication.java +++ b/src/main/java/DGU_AI_LAB/admin_be/AdminBeApplication.java @@ -3,9 +3,11 @@ import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; import org.springframework.data.jpa.repository.config.EnableJpaAuditing; +import org.springframework.scheduling.annotation.EnableScheduling; @SpringBootApplication @EnableJpaAuditing +@EnableScheduling public class AdminBeApplication { public static void main(String[] args) { diff --git a/src/main/java/DGU_AI_LAB/admin_be/domain/alarm/controller/AlarmController.java b/src/main/java/DGU_AI_LAB/admin_be/domain/alarm/controller/AlarmController.java deleted file mode 100644 index 1a6bbcb..0000000 --- a/src/main/java/DGU_AI_LAB/admin_be/domain/alarm/controller/AlarmController.java +++ /dev/null @@ -1,45 +0,0 @@ -package DGU_AI_LAB.admin_be.domain.alarm.controller; - -import DGU_AI_LAB.admin_be.domain.alarm.controller.docs.AlarmApi; -import DGU_AI_LAB.admin_be.domain.alarm.dto.request.CombinedAlertRequestDTO; -import DGU_AI_LAB.admin_be.domain.alarm.dto.request.EmailRequestDTO; -import DGU_AI_LAB.admin_be.domain.alarm.dto.request.SlackDMRequestDTO; -import DGU_AI_LAB.admin_be.domain.alarm.service.AlarmService; -import jakarta.validation.Valid; -import lombok.RequiredArgsConstructor; -import org.springframework.http.ResponseEntity; -import org.springframework.web.bind.annotation.PostMapping; -import org.springframework.web.bind.annotation.RequestBody; -import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RestController; - -@RestController -@RequiredArgsConstructor -@RequestMapping("/api/alert") -public class AlarmController implements AlarmApi { - - private final AlarmService alarmService; - - @PostMapping("/dm") - public ResponseEntity sendSlackDMAlert(@RequestBody @Valid SlackDMRequestDTO request) { - alarmService.sendDMAlert(request.username(), request.email(), request.message()); - return ResponseEntity.ok("Alert sent to Slack DM"); - } - - @PostMapping("/email") - public ResponseEntity sendEmailAlert(@RequestBody @Valid EmailRequestDTO request) { - alarmService.sendMailAlert(request.to(), request.subject(), request.body()); - return ResponseEntity.ok("Alert sent to Email"); - } - - @PostMapping - public ResponseEntity sendAllAlerts(@RequestBody @Valid CombinedAlertRequestDTO request) { - alarmService.sendAllAlerts( - request.username(), - request.email(), - request.subject(), - request.message() - ); - return ResponseEntity.ok("Alert sent to Slack DM and Email"); - } -} \ No newline at end of file diff --git a/src/main/java/DGU_AI_LAB/admin_be/domain/alarm/controller/docs/AlarmApi.java b/src/main/java/DGU_AI_LAB/admin_be/domain/alarm/controller/docs/AlarmApi.java deleted file mode 100644 index 53ffa2b..0000000 --- a/src/main/java/DGU_AI_LAB/admin_be/domain/alarm/controller/docs/AlarmApi.java +++ /dev/null @@ -1,57 +0,0 @@ -package DGU_AI_LAB.admin_be.domain.alarm.controller.docs; - -import DGU_AI_LAB.admin_be.domain.alarm.dto.request.CombinedAlertRequestDTO; -import DGU_AI_LAB.admin_be.domain.alarm.dto.request.EmailRequestDTO; -import DGU_AI_LAB.admin_be.domain.alarm.dto.request.SlackDMRequestDTO; -import DGU_AI_LAB.admin_be.global.common.SuccessResponse; -import io.swagger.v3.oas.annotations.Operation; -import io.swagger.v3.oas.annotations.media.Content; -import io.swagger.v3.oas.annotations.media.Schema; -import io.swagger.v3.oas.annotations.parameters.RequestBody; -import io.swagger.v3.oas.annotations.responses.ApiResponse; -import io.swagger.v3.oas.annotations.tags.Tag; -import jakarta.validation.Valid; -import org.springframework.http.ResponseEntity; - -@Tag(name = "0. Slack 및 E-mail", description = "Slack 및 Email 알림 API") -public interface AlarmApi { - - @Operation( - summary = "Slack DM 알림 전송", - description = "Slack 사용자에게 개인 DM으로 알림 메시지를 전송합니다. 이름이 중복될 경우 이메일이 일치하는 사용자에게 전송됩니다." - ) - @ApiResponse( - responseCode = "200", description = "Slack DM 전송 성공", - content = @Content(schema = @Schema(implementation = SuccessResponse.class)) - ) - ResponseEntity sendSlackDMAlert( - @RequestBody(description = "Slack DM 알림 요청 DTO", required = true) - @Valid SlackDMRequestDTO request - ); - - @Operation( - summary = "Email 알림 전송", - description = "Email로 알림 메시지를 전송합니다." - ) - @ApiResponse( - responseCode = "200", description = "Email 전송 성공", - content = @Content(schema = @Schema(implementation = SuccessResponse.class)) - ) - ResponseEntity sendEmailAlert( - @RequestBody(description = "이메일 알림 요청 DTO", required = true) - @Valid EmailRequestDTO request - ); - - @Operation( - summary = "Slack DM + Email 통합 알림 전송", - description = "Slack DM과 Email을 동시에 전송합니다." - ) - @ApiResponse( - responseCode = "200", description = "Slack + Email 알림 전송 성공", - content = @Content(schema = @Schema(implementation = SuccessResponse.class)) - ) - ResponseEntity sendAllAlerts( - @RequestBody(description = "통합 알림 요청 DTO", required = true) - @Valid CombinedAlertRequestDTO request - ); -} \ No newline at end of file diff --git a/src/main/java/DGU_AI_LAB/admin_be/domain/alarm/dto/SlackMessageDto.java b/src/main/java/DGU_AI_LAB/admin_be/domain/alarm/dto/SlackMessageDto.java new file mode 100644 index 0000000..84cf3ed --- /dev/null +++ b/src/main/java/DGU_AI_LAB/admin_be/domain/alarm/dto/SlackMessageDto.java @@ -0,0 +1,30 @@ +package DGU_AI_LAB.admin_be.domain.alarm.dto; // 패키지 위치 확인 + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.io.Serializable; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class SlackMessageDto implements Serializable { + + public enum MessageType { + WEBHOOK, // 관리자 채널 알림 + DM // 사용자 개인 DM + } + + private MessageType type; // 메시지 타입 구분 + private String message; // 보낼 메시지 내용 + + // Webhook용 필드 + private String webhookUrl; + + // DM용 필드 + private String username; + private String email; +} \ No newline at end of file diff --git a/src/main/java/DGU_AI_LAB/admin_be/domain/alarm/dto/request/CombinedAlertRequestDTO.java b/src/main/java/DGU_AI_LAB/admin_be/domain/alarm/dto/request/CombinedAlertRequestDTO.java deleted file mode 100644 index 196885c..0000000 --- a/src/main/java/DGU_AI_LAB/admin_be/domain/alarm/dto/request/CombinedAlertRequestDTO.java +++ /dev/null @@ -1,21 +0,0 @@ -package DGU_AI_LAB.admin_be.domain.alarm.dto.request; - -import jakarta.validation.constraints.Email; -import jakarta.validation.constraints.NotBlank; -import lombok.Builder; - -@Builder -public record CombinedAlertRequestDTO( - @NotBlank - String username, - - @NotBlank - @Email - String email, - - @NotBlank - String subject, - - @NotBlank - String message -) {} \ No newline at end of file diff --git a/src/main/java/DGU_AI_LAB/admin_be/domain/alarm/dto/request/EmailRequestDTO.java b/src/main/java/DGU_AI_LAB/admin_be/domain/alarm/dto/request/EmailRequestDTO.java deleted file mode 100644 index 8b37dcc..0000000 --- a/src/main/java/DGU_AI_LAB/admin_be/domain/alarm/dto/request/EmailRequestDTO.java +++ /dev/null @@ -1,18 +0,0 @@ -package DGU_AI_LAB.admin_be.domain.alarm.dto.request; - -import jakarta.validation.constraints.Email; -import jakarta.validation.constraints.NotBlank; -import lombok.Builder; - -@Builder -public record EmailRequestDTO( - @NotBlank - @Email - String to, - - @NotBlank - String subject, - - @NotBlank - String body -) {} \ No newline at end of file diff --git a/src/main/java/DGU_AI_LAB/admin_be/domain/alarm/dto/request/SlackDMRequestDTO.java b/src/main/java/DGU_AI_LAB/admin_be/domain/alarm/dto/request/SlackDMRequestDTO.java deleted file mode 100644 index c15c705..0000000 --- a/src/main/java/DGU_AI_LAB/admin_be/domain/alarm/dto/request/SlackDMRequestDTO.java +++ /dev/null @@ -1,18 +0,0 @@ -package DGU_AI_LAB.admin_be.domain.alarm.dto.request; - -import jakarta.validation.constraints.Email; -import jakarta.validation.constraints.NotBlank; -import lombok.Builder; - -@Builder -public record SlackDMRequestDTO( - @NotBlank - String username, - - @NotBlank - @Email - String email, - - @NotBlank - String message -) {} \ No newline at end of file diff --git a/src/main/java/DGU_AI_LAB/admin_be/domain/alarm/service/AlarmService.java b/src/main/java/DGU_AI_LAB/admin_be/domain/alarm/service/AlarmService.java index fa1eced..8294f69 100644 --- a/src/main/java/DGU_AI_LAB/admin_be/domain/alarm/service/AlarmService.java +++ b/src/main/java/DGU_AI_LAB/admin_be/domain/alarm/service/AlarmService.java @@ -1,69 +1,64 @@ package DGU_AI_LAB.admin_be.domain.alarm.service; +import DGU_AI_LAB.admin_be.domain.alarm.dto.SlackMessageDto; import DGU_AI_LAB.admin_be.domain.requests.entity.Request; import DGU_AI_LAB.admin_be.domain.users.entity.User; +import DGU_AI_LAB.admin_be.global.util.MessageUtils; import lombok.RequiredArgsConstructor; -import lombok.extern.log4j.Log4j2; +import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Value; -import org.springframework.http.*; +import org.springframework.data.redis.core.RedisTemplate; import org.springframework.mail.SimpleMailMessage; import org.springframework.mail.javamail.JavaMailSender; import org.springframework.stereotype.Service; -import org.springframework.web.client.RestTemplate; -import java.util.Map; - -/** - * 사용자(이메일, DM) 및 관리자(Slack 채널)에게 알림을 전송하는 서비스 - */ @Service @RequiredArgsConstructor -@Log4j2 +@Slf4j public class AlarmService { - // --- Slack Webhook (관리자 채널) --- + /** + * 모든 클래스에서 알림에 들어갈 메시지는 MessageUtil에서 관리하고 있어요. + * 알림 문구를 수정하려면, resources/messages.properties에서 수정해주세요. + */ + @Value("${slack-webhook-url.monitoring}") private String defaultWebhookUrl; @Value("${slack-webhook-url.farm-admin}") private String farmAdminWebhookUrl; @Value("${slack-webhook-url.lab-admin}") private String labAdminWebhookUrl; + @Value("${spring.mail.username}") + private String from; - // --- 외부 서비스 의존성 --- private final JavaMailSender mailSender; private final SlackApiService slackApiService; - private final RestTemplate restTemplate = new RestTemplate(); + private final RedisTemplate redisTemplate; + private final MessageUtils messageUtils; - @Value("${spring.mail.username}") - private String from; + private static final String SLACK_QUEUE_KEY = "slack:notification:queue"; - /** - * Slack Webhook을 사용하여 특정 채널에 메시지를 전송합니다. - */ + // --- Public Methods --- public void sendSlackAlert(String message, String webhookUrl) { - HttpHeaders headers = new HttpHeaders(); - headers.setContentType(MediaType.APPLICATION_JSON); - - Map payload = Map.of("text", message); - HttpEntity> request = new HttpEntity<>(payload, headers); - String urlToUse = (webhookUrl != null && !webhookUrl.isEmpty()) ? webhookUrl : defaultWebhookUrl; + SlackMessageDto dto = SlackMessageDto.builder() + .type(SlackMessageDto.MessageType.WEBHOOK) + .webhookUrl(urlToUse) + .message(message) + .build(); + pushToQueue(dto); + } - try { - ResponseEntity response = restTemplate.postForEntity(urlToUse, request, String.class); - if (!response.getStatusCode().is2xxSuccessful()) { - log.warn("Slack 알림 전송 실패: {}", response.getStatusCode()); - } else { - log.debug("Slack 알림 전송 성공"); - } - } catch (Exception e) { - log.error("Slack 알림 전송 중 예외 발생: (URL: {})", urlToUse, e); - } + public void sendDMAlert(String username, String email, String message) { + SlackMessageDto dto = SlackMessageDto.builder() + .type(SlackMessageDto.MessageType.DM) + .username(username) + .email(email) + .message(message) + .build(); + pushToQueue(dto); } - /** - * 사용자에게 이메일을 전송합니다. - */ public void sendMailAlert(String to, String subject, String body) { try { SimpleMailMessage message = new SimpleMailMessage(); @@ -72,121 +67,67 @@ public void sendMailAlert(String to, String subject, String body) { message.setSubject(subject); message.setText(body); mailSender.send(message); - log.info("메일 전송 성공: 수신자={}, 제목={}", to, subject); } catch (Exception e) { log.error("메일 전송 실패: 수신자={}", to, e); } } - /** - * 사용자에게 Slack DM을 전송합니다. - */ - public void sendDMAlert(String username, String email, String message) { - slackApiService.sendDM(username, email, message); - } - - /** - * 사용자에게 DM과 메일을 모두 전송합니다. (주로 사용자 대상 알림) - */ public void sendAllAlerts(String username, String email, String subject, String message) { - try { - sendMailAlert(email, subject, message); - } catch (Exception e) { - log.error("sendAllAlerts 중 메일 전송 실패: {}", email, e); - } - - try { - sendDMAlert(username, email, message); - } catch (Exception e) { - log.error("sendAllAlerts 중 DM 전송 실패: {}", username, e); - } + sendMailAlert(email, subject, message); + sendDMAlert(username, email, message); } - /** - * serverName에 따라 적절한 Webhook URL을 반환합니다. - */ + // --- Helper / Formatting Methods --- private String getAdminWebhookUrl(String serverName) { - if ("FARM".equalsIgnoreCase(serverName)) { - return farmAdminWebhookUrl; - } else if ("LAB".equalsIgnoreCase(serverName)) { - return labAdminWebhookUrl; - } else { - // FARM이나 LAB이 아닌 잘못된 입력값이 있을 경우, 기본 모니토링 채널로 전송 - log.warn("알 수 없는 serverName '{}'에 대한 요청 알림입니다. 기본 채널로 전송합니다.", serverName); - return defaultWebhookUrl; - } + if ("FARM".equalsIgnoreCase(serverName)) return farmAdminWebhookUrl; + else if ("LAB".equalsIgnoreCase(serverName)) return labAdminWebhookUrl; + else return defaultWebhookUrl; } - /** - * 관리자 채널(FARM/LAB)로 신규 신청 알림을 보냅니다. - */ public void sendNewRequestNotification(Request request) { String serverName = request.getResourceGroup().getServerName(); - String targetWebhookUrl = getAdminWebhookUrl(serverName); // 중복 로직 제거 - - // 슬랙 메시지 내용을 생성합니다. - String message = String.format( - "🔔 새로운 서버 사용 신청이 도착했습니다! 🔔\n" + - "------------------------------------------\n" + - "▶ 신청자: %s (%s)\n" + - "▶ 신청 서버: %s\n" + - "▶ Ubuntu 사용자 이름: %s\n" + - "▶ 요청 이미지: %s:%s\n" + - "▶ 요청 볼륨: %dGiB\n" + - "------------------------------------------\n" + - "관리자 페이지에서 확인 후 승인해 주세요.", - request.getUser().getName(), - request.getUser().getStudentId(), - serverName, - request.getUbuntuUsername(), - request.getContainerImage().getImageName(), - request.getContainerImage().getImageVersion(), - request.getVolumeSizeGiB() - ); - - sendSlackAlert(message, targetWebhookUrl); + String message = messageUtils.get("notification.admin.new-request", + request.getUser().getName(), serverName); + + sendSlackAlert(message, getAdminWebhookUrl(serverName)); } - /** - * 사용자에게 서버 사용 신청 승인 알림을 보냅니다. (DM + Email) - */ public void sendApprovalNotification(Request request) { User user = request.getUser(); - String subject = "[DGU AI LAB] 서버 사용 신청이 승인되었습니다."; - String message = String.format( - """ - 🎉 %s님의 서버 사용 신청이 성공적으로 승인되었습니다! 🎉 - - 아래 정보를 사용하여 서버에 접속해 주세요. - ------------------------------------- - - Ubuntu 사용자 이름: %s - - 할당된 서버: %s - - 컨테이너 이미지: %s:%s - - 할당된 볼륨 크기: %d GiB - - 만료일: %s - ------------------------------------- - - 궁금한 점이 있다면 관리자에게 문의해 주세요. - """, - user.getName(), - request.getUbuntuUsername(), - request.getResourceGroup().getServerName(), - request.getContainerImage().getImageName(), - request.getContainerImage().getImageVersion(), - request.getVolumeSizeGiB(), - request.getExpiresAt().toLocalDate().toString() - ); + String subject = messageUtils.get("notification.approval.subject"); + String message = messageUtils.get("notification.approval.body", user.getName()); sendAllAlerts(user.getName(), user.getEmail(), subject, message); } - /** - * 서버 이름에 따라 적절한 관리자 채널로 메시지를 보냅니다. - * @param serverName "FARM", "LAB" 등 - * @param message 보낼 메시지 - */ public void sendAdminSlackNotification(String serverName, String message) { - String targetWebhookUrl = getAdminWebhookUrl(serverName); - sendSlackAlert(message, targetWebhookUrl); + sendSlackAlert(message, getAdminWebhookUrl(serverName)); + } + + // --- Private Queue Logic with Fallback --- + private void pushToQueue(SlackMessageDto dto) { + try { + redisTemplate.opsForList().rightPush(SLACK_QUEUE_KEY, dto); + log.debug("Slack 큐 적재: {}", dto.getType()); + } catch (Exception e) { + log.error("⚠️ Redis 장애! 직접 전송 시도. ({})", e.getMessage()); + handleFallbackDirectSend(dto); + } + } + + private void handleFallbackDirectSend(SlackMessageDto dto) { + String notice = messageUtils.get("notification.error.redis-fallback"); + String fullMessage = dto.getMessage() + notice; + + try { + if (dto.getType() == SlackMessageDto.MessageType.WEBHOOK) { + slackApiService.sendWebhook(dto.getWebhookUrl(), fullMessage); + } else { + slackApiService.sendDM(dto.getUsername(), dto.getEmail(), fullMessage); + } + log.info("✅ Fallback 직접 전송 성공"); + } catch (Exception ex) { + log.error("❌ Fallback 실패 (전송 불가)", ex); + } } } \ No newline at end of file diff --git a/src/main/java/DGU_AI_LAB/admin_be/domain/alarm/service/SlackApiService.java b/src/main/java/DGU_AI_LAB/admin_be/domain/alarm/service/SlackApiService.java index d20ed30..afa15c0 100644 --- a/src/main/java/DGU_AI_LAB/admin_be/domain/alarm/service/SlackApiService.java +++ b/src/main/java/DGU_AI_LAB/admin_be/domain/alarm/service/SlackApiService.java @@ -1,145 +1,201 @@ package DGU_AI_LAB.admin_be.domain.alarm.service; +import DGU_AI_LAB.admin_be.domain.users.entity.User; import DGU_AI_LAB.admin_be.error.ErrorCode; import DGU_AI_LAB.admin_be.error.exception.BusinessException; +import DGU_AI_LAB.admin_be.global.util.MessageUtils; import lombok.RequiredArgsConstructor; -import lombok.extern.log4j.Log4j2; +import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Value; +import org.springframework.data.redis.core.RedisTemplate; import org.springframework.http.*; import org.springframework.stereotype.Service; import org.springframework.web.client.RestTemplate; +import java.time.Duration; import java.util.List; import java.util.Map; import java.util.stream.Collectors; -/** - * Slack Bot Token을 사용하여 Slack API와 직접 통신하는 서비스 - * (DM 전송 등) - */ @Service @RequiredArgsConstructor -@Log4j2 +@Slf4j public class SlackApiService { + /** + * 모든 클래스에서 알림에 들어갈 메시지는 MessageUtil에서 관리하고 있어요. + * 알림 문구를 수정하려면, resources/messages.properties에서 수정해주세요. + */ + @Value("${slack.bot-token}") private String botToken; + private final RestTemplate restTemplate = new RestTemplate(); + private final RedisTemplate redisTemplate; + private final MessageUtils messageUtils; + + private static final String SLACK_USERS_CACHE_KEY = "slack:cache:users:list"; + private static final long CACHE_TTL_HOURS = 1; // 캐시 유지 시간 (1시간) + + // ========================================================================= + // 1. Webhook 전송 (관리자 알림용) + // ========================================================================= + public void sendWebhook(String webhookUrl, String message) { + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_JSON); + Map payload = Map.of("text", message); + HttpEntity> request = new HttpEntity<>(payload, headers); + + try { + ResponseEntity response = restTemplate.postForEntity(webhookUrl, request, String.class); + if (!response.getStatusCode().is2xxSuccessful()) { + log.warn("Slack Webhook 전송 응답 이상: {}", response.getStatusCode()); + throw new BusinessException(ErrorCode.SLACK_SEND_FAILED); + } + } catch (Exception e) { + log.error("Slack Webhook 전송 실패 (URL: {}): {}", webhookUrl, e.getMessage()); + throw new BusinessException(ErrorCode.SLACK_SEND_FAILED); + } + } + + // ========================================================================= + // 2. DM 전송 (사용자 알림용) + // ========================================================================= + public void sendExpiredDM(User user) { + String message = messageUtils.get("notification.expired.dm", user.getName()); + this.sendDM(user.getName(), user.getEmail(), message); + } - /** - * 사용자에게 Slack DM을 전송합니다. - */ public void sendDM(String username, String email, String message) { - String userId = getSlackUser(username, email, botToken); + String userId = getSlackUserId(username, email); + if (userId == null) { - log.warn("Slack DM 전송 실패: 사용자를 찾을 수 없습니다. (이름: {}, 이메일: {})", username, email); throw new BusinessException(ErrorCode.SLACK_USER_NOT_FOUND); } String channelId = openDMChannel(userId, botToken); if (channelId == null) { - log.warn("Slack DM 채널 오픈 실패: (사용자 ID: {})", userId); throw new BusinessException(ErrorCode.SLACK_DM_CHANNEL_FAILED); } + sendMessageToSlackChannel(channelId, message, botToken); + } + // --- Private Helper Methods --- + /** + * Redis 캐싱을 적용하여 Slack User 목록 조회 + * 1. Redis 조회 -> 2. 없으면 API 호출 -> 3. Redis 저장 + */ + private List> getSlackMembersWithCache() { try { - sendMessageToSlackChannel(channelId, message, botToken); + // 1. Redis 캐시 조회 + Object cachedData = redisTemplate.opsForValue().get(SLACK_USERS_CACHE_KEY); + if (cachedData != null) { + log.debug("Slack User List: Redis 캐시 히트 (API 호출 생략)"); + return (List>) cachedData; + } } catch (Exception e) { - log.error("Slack DM 전송 중 오류 발생", e); - throw new BusinessException(ErrorCode.SLACK_SEND_FAILED); + log.warn("Redis 조회 실패, API 직접 호출 진행: {}", e.getMessage()); } - } - /** - * 이름과 이메일로 Slack 사용자 ID를 찾습니다. - */ - private String getSlackUser(String username, String email, String token) { + // 2. 캐시가 없으면 API 호출 + log.info("Slack User List: API 직접 호출 (Refresh)"); String url = "https://slack.com/api/users.list"; HttpHeaders headers = new HttpHeaders(); - headers.setBearerAuth(token); + headers.setBearerAuth(botToken); HttpEntity request = new HttpEntity<>(headers); - ResponseEntity response = restTemplate.exchange(url, HttpMethod.GET, request, Map.class); - if (!Boolean.TRUE.equals(response.getBody().get("ok"))) { + try { + ResponseEntity response = restTemplate.exchange(url, HttpMethod.GET, request, Map.class); + if (!Boolean.TRUE.equals(response.getBody().get("ok"))) { + throw new BusinessException(ErrorCode.SLACK_USER_NOT_FOUND); + } + + List> members = (List>) response.getBody().get("members"); + + // 3. Redis에 저장 (1시간 유지) + try { + redisTemplate.opsForValue().set(SLACK_USERS_CACHE_KEY, members, Duration.ofHours(CACHE_TTL_HOURS)); + } catch (Exception e) { + log.error("Redis 저장 실패 (기능은 계속 수행됨): {}", e.getMessage()); + } + + return members; + + } catch (Exception e) { + log.error("Slack users.list API 호출 실패", e); throw new BusinessException(ErrorCode.SLACK_USER_NOT_FOUND); } + } - List> members = (List>) response.getBody().get("members"); + /** + * 캐시된 목록에서 이름/이메일로 사용자 ID 매칭 + */ + private String getSlackUserId(String username, String email) { + // 캐시 적용된 목록 가져오기 + List> members = getSlackMembersWithCache(); - // 이름이 일치하는 사용자 목록 필터링 + // 1차: 이름 매칭 List> matchedUsers = members.stream() .filter(user -> { Map profile = (Map) user.get("profile"); + if (profile == null) return false; + String displayName = (String) profile.get("display_name"); String realName = (String) profile.get("real_name"); String name = (String) user.get("name"); - // 하나라도 null이 아닌 이름 필드가 username과 일치하는지 확인 return (displayName != null && displayName.equals(username)) || (realName != null && realName.equals(username)) || (name != null && name.equals(username)); - }) - .collect(Collectors.toList()); + }).collect(Collectors.toList()); - if (matchedUsers.isEmpty()) { - throw new BusinessException(ErrorCode.SLACK_USER_NOT_FOUND); - } + if (matchedUsers.isEmpty()) return null; // 상위에서 Exception 처리 + if (matchedUsers.size() == 1) return (String) matchedUsers.get(0).get("id"); - if (matchedUsers.size() == 1) { - return (String) matchedUsers.get(0).get("id"); - } - - // 이름이 중복될 경우, 이메일로 재검색 + // 2차: 이메일 매칭 (동명이인 또는 사용자가 이름을 잘못 저장했을 경우 처리) Map selectedUser = matchedUsers.stream() .filter(user -> { Map profile = (Map) user.get("profile"); String userEmail = (String) profile.get("email"); return userEmail != null && userEmail.equalsIgnoreCase(email); - }) - .findFirst() - .orElseThrow(() -> new BusinessException(ErrorCode.SLACK_USER_EMAIL_NOT_MATCH)); + }).findFirst().orElse(null); - return (String) selectedUser.get("id"); + return selectedUser != null ? (String) selectedUser.get("id") : null; } - /** - * 사용자 ID로 DM 채널 ID를 엽니다. - */ private String openDMChannel(String userId, String token) { String url = "https://slack.com/api/conversations.open"; HttpHeaders headers = new HttpHeaders(); headers.setBearerAuth(token); headers.setContentType(MediaType.APPLICATION_JSON); + HttpEntity> request = new HttpEntity<>(Map.of("users", userId), headers); - Map body = Map.of("users", userId); - HttpEntity> request = new HttpEntity<>(body, headers); - - ResponseEntity response = restTemplate.postForEntity(url, request, Map.class); - if (Boolean.TRUE.equals(response.getBody().get("ok"))) { - Map channel = (Map) response.getBody().get("channel"); - return (String) channel.get("id"); + try { + ResponseEntity response = restTemplate.postForEntity(url, request, Map.class); + if (Boolean.TRUE.equals(response.getBody().get("ok"))) { + Map channel = (Map) response.getBody().get("channel"); + return (String) channel.get("id"); + } + } catch (Exception e) { + log.error("DM 채널 오픈 API 오류", e); } return null; } - /** - * 채널 ID로 메시지를 전송합니다. (DM, 공개채널 공용) - */ private void sendMessageToSlackChannel(String channelId, String message, String token) { String url = "https://slack.com/api/chat.postMessage"; HttpHeaders headers = new HttpHeaders(); headers.setBearerAuth(token); headers.setContentType(MediaType.APPLICATION_JSON); + HttpEntity> request = new HttpEntity<>(Map.of("channel", channelId, "text", message), headers); - Map body = Map.of( - "channel", channelId, - "text", message - ); - HttpEntity> request = new HttpEntity<>(body, headers); - - ResponseEntity response = restTemplate.postForEntity(url, request, Map.class); - if (!Boolean.TRUE.equals(response.getBody().get("ok"))) { - log.error("Slack 메시지 전송 실패 (채널 ID: {}): {}", channelId, response.getBody().get("error")); + try { + ResponseEntity response = restTemplate.postForEntity(url, request, Map.class); + if (!Boolean.TRUE.equals(response.getBody().get("ok"))) { + log.error("Slack 메시지 전송 실패: {}", response.getBody().get("error")); + throw new BusinessException(ErrorCode.SLACK_SEND_FAILED); + } + } catch (Exception e) { throw new BusinessException(ErrorCode.SLACK_SEND_FAILED); } } diff --git a/src/main/java/DGU_AI_LAB/admin_be/domain/groups/entity/Group.java b/src/main/java/DGU_AI_LAB/admin_be/domain/groups/entity/Group.java index e3fc2a4..f528f17 100644 --- a/src/main/java/DGU_AI_LAB/admin_be/domain/groups/entity/Group.java +++ b/src/main/java/DGU_AI_LAB/admin_be/domain/groups/entity/Group.java @@ -1,9 +1,13 @@ package DGU_AI_LAB.admin_be.domain.groups.entity; +import DGU_AI_LAB.admin_be.domain.requests.entity.RequestGroup; // 임포트 추가 import DGU_AI_LAB.admin_be.domain.usedIds.entity.UsedId; import jakarta.persistence.*; import lombok.*; +import java.util.HashSet; +import java.util.Set; + @Entity @Table(name = "`groups`") @Getter @@ -26,6 +30,9 @@ public class Group { @JoinColumn(name = "ubuntu_gid", referencedColumnName = "id_value", insertable = false, updatable = false) private UsedId usedId; + @OneToMany(mappedBy = "group", cascade = CascadeType.ALL, orphanRemoval = true) + private Set requestGroups = new HashSet<>(); + @Builder public Group(String groupName, Long ubuntuGid) { this.groupName = groupName; diff --git a/src/main/java/DGU_AI_LAB/admin_be/domain/requests/entity/Request.java b/src/main/java/DGU_AI_LAB/admin_be/domain/requests/entity/Request.java index 6c360c9..2a9eda6 100644 --- a/src/main/java/DGU_AI_LAB/admin_be/domain/requests/entity/Request.java +++ b/src/main/java/DGU_AI_LAB/admin_be/domain/requests/entity/Request.java @@ -65,7 +65,7 @@ public class Request extends BaseTimeEntity { @JoinColumn(name = "user_id", nullable = false) private User user; - @ManyToOne(fetch = FetchType.LAZY) + @ManyToOne(fetch = FetchType.LAZY, cascade = CascadeType.REMOVE) @JoinColumn(name = "ubuntuUid", referencedColumnName = "id_value", nullable = true) private UsedId ubuntuUid; diff --git a/src/main/java/DGU_AI_LAB/admin_be/domain/requests/repository/RequestRepository.java b/src/main/java/DGU_AI_LAB/admin_be/domain/requests/repository/RequestRepository.java index 801408d..948b012 100644 --- a/src/main/java/DGU_AI_LAB/admin_be/domain/requests/repository/RequestRepository.java +++ b/src/main/java/DGU_AI_LAB/admin_be/domain/requests/repository/RequestRepository.java @@ -14,6 +14,7 @@ @Repository public interface RequestRepository extends JpaRepository { + List findAllByUser(User user); Optional findByUbuntuUsername(String username); List findAllByUser_UserId(Long userId); @@ -24,8 +25,14 @@ public interface RequestRepository extends JpaRepository { boolean existsByUbuntuUsername(String ubuntuUsername); List findAllByUser_UserIdAndStatus(Long userId, Status status); boolean existsByUbuntuUsernameAndUser_UserId(String ubuntuUsername, Long userId); + @Query("SELECT r.ubuntuUsername FROM Request r WHERE r.status = :status") List findUbuntuUsernamesByStatus(@Param("status") Status status); - List findAllByExpiresAtBetweenAndStatus(LocalDateTime start, LocalDateTime end, Status status); - List findAllByExpiresAtBeforeAndStatus(LocalDateTime before, Status status); -} + + @Query("SELECT r FROM Request r JOIN FETCH r.user JOIN FETCH r.resourceGroup WHERE r.expiresAt BETWEEN :start AND :end AND r.status = :status") + List findAllByExpiresAtBetweenAndStatus(@Param("start") LocalDateTime start, @Param("end") LocalDateTime end, @Param("status") Status status); + + + @Query("SELECT r FROM Request r JOIN FETCH r.user JOIN FETCH r.resourceGroup WHERE r.expiresAt < :now AND r.status = 'FULFILLED'") + List findAllWithUserByExpiredDateBefore(@Param("now") LocalDateTime now); +} \ No newline at end of file diff --git a/src/main/java/DGU_AI_LAB/admin_be/domain/scheduler/RequestEventListener.java b/src/main/java/DGU_AI_LAB/admin_be/domain/scheduler/RequestEventListener.java new file mode 100644 index 0000000..3ff2482 --- /dev/null +++ b/src/main/java/DGU_AI_LAB/admin_be/domain/scheduler/RequestEventListener.java @@ -0,0 +1,60 @@ +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.global.event.RequestExpiredEvent; +import DGU_AI_LAB.admin_be.global.util.MessageUtils; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; +import org.springframework.transaction.event.TransactionPhase; +import org.springframework.transaction.event.TransactionalEventListener; + +@Component +@RequiredArgsConstructor +@Slf4j +public class RequestEventListener { + + private final AlarmService alarmService; + private final MessageUtils messageUtils; + + // DB 커밋이 완료된 후에만 실행됨 + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) + public void handleExpiredEvent(RequestExpiredEvent event) { + User user = event.user(); + String serverName = event.serverName(); + String username = event.ubuntuUsername(); + + // 1. 사용자 삭제 알림 + try { + String subject = messageUtils.get("notification.expired.detail.subject"); + String message = messageUtils.get("notification.expired.detail.body", + user.getName(), serverName, username); + + alarmService.sendAllAlerts(user.getName(), user.getEmail(), subject, message); + } catch (Exception e) { + log.warn("사용자 삭제 알림 전송 실패: {}", e.getMessage()); + } + + // 2. 관리자 알림 + try { + String type = getServerType(serverName); + + // properties: notification.admin.delete.success ({0}타입, {1}계정, {2}서버) + String adminMsg = messageUtils.get("notification.admin.delete.success", + type, username, serverName); + + alarmService.sendAdminSlackNotification(serverName, adminMsg); + } catch (Exception e) { + log.warn("관리자 알림 전송 실패: {}", e.getMessage()); + } + } + + private String getServerType(String serverName) { + if (serverName == null) return "ETC"; + String lower = serverName.toLowerCase(); + if (lower.contains("farm")) return "FARM"; + if (lower.contains("lab") || lower.contains("dgx")) return "LAB"; + return "SERVER"; + } +} \ No newline at end of file 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 e8efa78..57e4544 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 @@ -1,4 +1,4 @@ -package DGU_AI_LAB.admin_be.domain.scheduler; // 새로운 패키지 +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.requests.entity.Request; @@ -8,11 +8,12 @@ import DGU_AI_LAB.admin_be.domain.usedIds.entity.UsedId; import DGU_AI_LAB.admin_be.domain.usedIds.service.IdAllocationService; import DGU_AI_LAB.admin_be.domain.users.entity.User; -import DGU_AI_LAB.admin_be.error.ErrorCode; -import DGU_AI_LAB.admin_be.error.exception.BusinessException; +import DGU_AI_LAB.admin_be.global.event.RequestExpiredEvent; +import DGU_AI_LAB.admin_be.global.util.MessageUtils; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.context.ApplicationContext; +import org.springframework.context.ApplicationEventPublisher; import org.springframework.scheduling.annotation.Scheduled; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -29,206 +30,110 @@ public class RequestSchedulerService { private final AlarmService alarmService; private final UbuntuAccountService ubuntuAccountService; private final IdAllocationService idAllocationService; - // Self-invocation으로 트랜잭션 분리 + private final ApplicationEventPublisher eventPublisher; private final ApplicationContext applicationContext; + private final MessageUtils messageUtils; - /** - * 매일 오전 10시에 실행되는 주 스케줄러 메서드 - */ - //@Scheduled(cron = "0 0 10 * * ?") - @Scheduled(cron = "0 55 15 * * ?") - public void checkAndProcessExpiredRequests() { - log.info("만료 계정 확인 스케줄러 시작..."); - - RequestSchedulerService self = applicationContext.getBean(RequestSchedulerService.class); + @Scheduled(cron = "0 46 22 * * ?", zone = "Asia/Seoul") + public void runScheduler() { + log.info("🗓️ [스케줄러 시작] 만료 계정 관리 작업"); LocalDateTime now = LocalDateTime.now(); - try { - // 1. 만료 7일 전 알림 (읽기 전용 트랜잭션) - self.processPreExpiryNotifications(now.plusDays(7), "7일"); - - // 2. 만료 1일 전 알림 (읽기 전용 트랜잭션) - self.processPreExpiryNotifications(now.plusDays(1), "1일"); - } catch (Exception e) { - log.error("만료 전 알림 처리 중 오류 발생", e); - } - - // 3. 만료된 계정 목록 조회 - List expiredRequests; - try { - expiredRequests = requestRepository.findAllByExpiresAtBeforeAndStatus(now, Status.FULFILLED); - } catch (Exception e) { - log.error("만료 계정 조회 중 DB 오류. 스케줄러를 종료합니다.", e); - return; - } + sendPreExpiryNotification(now.plusDays(7), "7일"); + sendPreExpiryNotification(now.plusDays(3), "3일"); + sendPreExpiryNotification(now.plusDays(1), "1일"); - log.info("만료되어 삭제할 계정 {}건 발견.", expiredRequests.size()); + processExpiredRequests(now); - // 4. 만료된 계정 개별 삭제 처리 (개별 트랜잭션) - for (Request request : expiredRequests) { - try { - // 개별 Request에 대해 별도의 트랜잭션으로 처리 - self.processSingleExpiredRequest(request.getRequestId()); - } catch (Exception e) { - // 개별 처리 실패. 로깅하고 다음 대상으로 넘어가기 - log.error("만료 계정 삭제 처리 실패. Request ID: {}. 원인: {}", request.getRequestId(), e.getMessage(), e); - - // 관리자에게 실패 알림 - try { - alarmService.sendAdminSlackNotification( - request.getResourceGroup().getServerName(), - String.format( - "❌ 계정 삭제 실패 ❌\n" + - "▶ 계정: %s (Request ID: %d)\n" + - "▶ 서버: %s\n" + - "▶ 오류: %s\n" + - "▶ 수동 확인이 필요합니다.", - request.getUbuntuUsername(), request.getRequestId(), - request.getResourceGroup().getServerName(), - e.getMessage() - ) - ); - } catch (Exception slackEx) { - log.error("삭제 실패 알림 전송조차 실패. Request ID: {}", request.getRequestId(), slackEx); - } - } - } - log.info("만료 계정 확인 스케줄러 종료."); + log.info("🗓️ [스케줄러 종료]"); } - /** - * 만료 전 알림을 처리합니다. (읽기 전용 트랜잭션) - */ - @Transactional(readOnly = true) - public void processPreExpiryNotifications(LocalDateTime targetExpiryDate, String daysRemaining) { - LocalDateTime startOfDay = targetExpiryDate.toLocalDate().atStartOfDay(); - LocalDateTime endOfDay = targetExpiryDate.toLocalDate().atTime(23, 59, 59); + public void processExpiredRequests(LocalDateTime now) { + List expiredRequests = requestRepository.findAllWithUserByExpiredDateBefore(now); + if (expiredRequests.isEmpty()) return; - List requests = requestRepository.findAllByExpiresAtBetweenAndStatus(startOfDay, endOfDay, Status.FULFILLED); + RequestSchedulerService self = applicationContext.getBean(RequestSchedulerService.class); - if (!requests.isEmpty()) { - log.info("[{}] 후 만료 예정인 계정 {}건 발견.", daysRemaining, requests.size()); - } + for (Request request : expiredRequests) { + String serverName = "Unknown"; + String username = request.getUbuntuUsername(); - for (Request request : requests) { try { - User user = request.getUser(); - String subject = String.format("[DGU AI LAB] 서버 사용 만료 %s 전 안내", daysRemaining); - String message = String.format( - """ - %s님의 서버 사용 기간이 %s 후 (%s) 만료될 예정입니다. - - - Ubuntu 사용자 이름: %s - - 할당된 서버: %s - - 기간 연장이 필요하신 경우, 관리자 페이지에서 연장 신청을 해 주시기 바랍니다. - 별도 조치가 없을 시 계정은 자동 삭제됩니다. - """, - user.getName(), - daysRemaining, - request.getExpiresAt().toLocalDate().toString(), - request.getUbuntuUsername(), - request.getResourceGroup().getServerName() - ); - - // 1. 사용자에게 이메일 + 슬랙 DM - alarmService.sendAllAlerts(user.getName(), user.getEmail(), subject, message); - - // 2. 관리자에게 슬랙 알림 - String adminMessage = String.format( - "🔔 계정 만료 %s 전 알림 🔔\n" + - "▶ 사용자: %s (%s)\n" + - "▶ 계정: %s\n" + - "▶ 서버: %s\n" + - "▶ 만료일: %s", - daysRemaining, - user.getName(), user.getEmail(), - request.getUbuntuUsername(), - request.getResourceGroup().getServerName(), - request.getExpiresAt().toLocalDate().toString() - ); - alarmService.sendAdminSlackNotification(request.getResourceGroup().getServerName(), adminMessage); + if (request.getResourceGroup() != null) { + serverName = request.getResourceGroup().getServerName(); + } + self.deleteExpiredRequest(request.getRequestId()); } catch (Exception e) { - log.error("만료 {}일 전 알림 전송 실패. Request ID: {}", daysRemaining, request.getRequestId(), e); + log.error("계정 삭제 실패 (ID: {}): {}", request.getRequestId(), e.getMessage()); + sendFailureAlertToAdmin(serverName, username, e.getMessage()); } } } - /** - * 만료된 개별 Request를 트랜잭션 단위로 처리합니다. - */ @Transactional - public void processSingleExpiredRequest(Long requestId) { + public void deleteExpiredRequest(Long requestId) { Request request = requestRepository.findById(requestId) - .orElseThrow(() -> new BusinessException("Request not found: " + requestId, ErrorCode.RESOURCE_NOT_FOUND)); + .orElseThrow(() -> new IllegalArgumentException("Request not found")); - if (request.getStatus() != Status.FULFILLED) { - log.warn("이미 처리되었거나 FULFILLED 상태가 아닌 Request. ID: {}, Status: {}", requestId, request.getStatus()); - return; - } + if (request.getStatus() != Status.FULFILLED) return; - User user = request.getUser(); - String username = request.getUbuntuUsername(); - UsedId usedId = request.getUbuntuUid(); String serverName = request.getResourceGroup().getServerName(); + String ubuntuUsername = request.getUbuntuUsername(); + User user = request.getUser(); - // --- 트랜잭션 시작 --- - // 1. 실제 우분투 계정 및 PVC 삭제 요청 (외부 서버) - // 이 메서드가 실패하면 BusinessException을 발생시켜 트랜잭션이 롤백됨. - ubuntuAccountService.deleteUbuntuAccount(username); - log.info("외부 서버 계정/PVC 삭제 성공: {}", username); + ubuntuAccountService.deleteUbuntuAccount(ubuntuUsername); - // 2. UsedId 반환 (DB에서 UsedId 삭제) + UsedId usedId = request.getUbuntuUid(); if (usedId != null) { - request.assignUbuntuUid(null); // 연관관계 제거 (Dirty checking) + request.assignUbuntuUid(null); idAllocationService.releaseId(usedId); - log.info("UID 반환 성공: {}", usedId.getIdValue()); } - // 3. Request 상태 DELETED로 변경 (Soft delete) request.delete(); - log.info("Request 상태 DELETED로 변경: {}", username); + eventPublisher.publishEvent(new RequestExpiredEvent(user, ubuntuUsername, serverName)); + log.info("삭제 트랜잭션 성공: {}", ubuntuUsername); + } - // --- 트랜잭션 커밋 --- - // 4. 삭제 완료 알림 (트랜잭션이 성공적으로 커밋된 후에 실행) - try { - String subject = "[DGU AI LAB] 서버 사용 기간 만료 및 계정 삭제 안내"; - String message = String.format( - """ - %s님의 서버 사용 기간(%s)이 만료되어 계정이 삭제되었습니다. - - - Ubuntu 사용자 이름: %s - - 할당된 서버: %s - - 데이터는 모두 삭제되었으며, 복구가 불가능합니다. - 서버 재사용이 필요하신 경우, 신규 신청을 해 주시기 바랍니다. - """, - user.getName(), - request.getExpiresAt().toLocalDate().toString(), - username, - serverName - ); - alarmService.sendAllAlerts(user.getName(), user.getEmail(), subject, message); - - // 관리자 알림 - String adminMessage = String.format( - "✅ 계정 삭제 완료 ✅\n" + - "▶ 사용자: %s (%s)\n" + - "▶ 계정: %s\n" + - "▶ 서버: %s\n" + - "▶ 만료일: %s", - user.getName(), user.getEmail(), - username, - serverName, - request.getExpiresAt().toLocalDate().toString() - ); - alarmService.sendAdminSlackNotification(serverName, adminMessage); - - log.info("계정 삭제 및 알림 처리 완료: {}", username); - - } catch (Exception e) { - log.error("삭제 완료 알림 전송 실패. Request ID: {}", requestId, e); + @Transactional(readOnly = true) + public void sendPreExpiryNotification(LocalDateTime targetDate, String dayLabel) { + LocalDateTime start = targetDate.toLocalDate().atStartOfDay(); + LocalDateTime end = targetDate.toLocalDate().atTime(23, 59, 59); + + List requests = requestRepository.findAllByExpiresAtBetweenAndStatus(start, end, Status.FULFILLED); + + for (Request request : requests) { + try { + User user = request.getUser(); + String serverName = request.getResourceGroup().getServerName(); + String expireDate = request.getExpiresAt().toLocalDate().toString(); + String subject = messageUtils.get("notification.pre-expiry.subject", dayLabel); + String message = messageUtils.get("notification.pre-expiry.body", + user.getName(), dayLabel, expireDate, serverName, request.getUbuntuUsername()); + + alarmService.sendAllAlerts(user.getName(), user.getEmail(), subject, message); + + } catch (Exception e) { + log.warn("{} 전 알림 실패: {}", dayLabel, e.getMessage()); + } } } + + private void sendFailureAlertToAdmin(String serverName, String username, String errorMsg) { + try { + String type = getServerType(serverName); + String msg = messageUtils.get("notification.admin.delete.fail", + type, serverName, username, errorMsg); + + alarmService.sendAdminSlackNotification(serverName, msg); + } catch (Exception ignored) {} + } + + private String getServerType(String serverName) { + if (serverName == null) return "UNKNOWN"; + String lower = serverName.toLowerCase(); + if (lower.contains("farm")) return "FARM"; + if (lower.contains("lab") || lower.contains("dgx")) return "LAB"; + return "SERVER"; + } } \ No newline at end of file diff --git a/src/main/java/DGU_AI_LAB/admin_be/domain/scheduler/SlackNotificationWorker.java b/src/main/java/DGU_AI_LAB/admin_be/domain/scheduler/SlackNotificationWorker.java new file mode 100644 index 0000000..4d89266 --- /dev/null +++ b/src/main/java/DGU_AI_LAB/admin_be/domain/scheduler/SlackNotificationWorker.java @@ -0,0 +1,48 @@ +package DGU_AI_LAB.admin_be.domain.scheduler; + +import DGU_AI_LAB.admin_be.domain.alarm.dto.SlackMessageDto; +import DGU_AI_LAB.admin_be.domain.alarm.service.SlackApiService; +import DGU_AI_LAB.admin_be.error.exception.BusinessException; +import com.fasterxml.jackson.databind.ObjectMapper; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +@Slf4j +public class SlackNotificationWorker { + + private final RedisTemplate redisTemplate; + private final SlackApiService slackApiService; + private final ObjectMapper objectMapper; + + private static final String SLACK_QUEUE_KEY = "slack:notification:queue"; + + @Scheduled(fixedDelay = 1000) + public void processSlackQueue() { + try { + Object messageObj = redisTemplate.opsForList().leftPop(SLACK_QUEUE_KEY); + if (messageObj == null) return; + + SlackMessageDto dto = objectMapper.convertValue(messageObj, SlackMessageDto.class); + + if (dto.getType() == SlackMessageDto.MessageType.WEBHOOK) { + slackApiService.sendWebhook(dto.getWebhookUrl(), dto.getMessage()); + log.info("Slack Webhook 전송 성공 (Queue)"); + + } else if (dto.getType() == SlackMessageDto.MessageType.DM) { + slackApiService.sendDM(dto.getUsername(), dto.getEmail(), dto.getMessage()); + log.info("Slack DM 전송 성공 (Queue): {}", dto.getUsername()); + } + + } catch (BusinessException e) { + log.warn("Slack 알림 처리 실패 (Business): {}", e.getMessage()); + + } catch (Exception e) { + log.error("Slack 큐 처리 중 시스템 오류 (재시도 필요 시 큐 복귀 고려)", e); + } + } +} \ No newline at end of file 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 new file mode 100644 index 0000000..1027bda --- /dev/null +++ b/src/main/java/DGU_AI_LAB/admin_be/domain/scheduler/UserSchedulerService.java @@ -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 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 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); + } + } + } +} \ No newline at end of file diff --git a/src/main/java/DGU_AI_LAB/admin_be/domain/usedIds/entity/UsedId.java b/src/main/java/DGU_AI_LAB/admin_be/domain/usedIds/entity/UsedId.java index 18c40b5..854da6c 100644 --- a/src/main/java/DGU_AI_LAB/admin_be/domain/usedIds/entity/UsedId.java +++ b/src/main/java/DGU_AI_LAB/admin_be/domain/usedIds/entity/UsedId.java @@ -1,9 +1,7 @@ package DGU_AI_LAB.admin_be.domain.usedIds.entity; -import jakarta.persistence.Column; -import jakarta.persistence.Entity; -import jakarta.persistence.Id; -import jakarta.persistence.Table; +import DGU_AI_LAB.admin_be.domain.groups.entity.Group; // Group 임포트 필수 +import jakarta.persistence.*; import lombok.*; @Entity @@ -17,8 +15,11 @@ public class UsedId { @Column(name = "id_value", nullable = false) private Long idValue; + @OneToOne(mappedBy = "usedId", cascade = CascadeType.ALL, orphanRemoval = true) + private Group group; + @Builder public UsedId(Long idValue) { this.idValue = idValue; } -} +} \ No newline at end of file diff --git a/src/main/java/DGU_AI_LAB/admin_be/domain/users/entity/User.java b/src/main/java/DGU_AI_LAB/admin_be/domain/users/entity/User.java index 594eaf6..fcf6ac8 100644 --- a/src/main/java/DGU_AI_LAB/admin_be/domain/users/entity/User.java +++ b/src/main/java/DGU_AI_LAB/admin_be/domain/users/entity/User.java @@ -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 @@ -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 requests = new ArrayList<>(); + @Builder public User(String email, String password, String name, String studentId, String phone, String department) { this.email = email; @@ -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(); } // ===== 비즈니스 메서드 ===== @@ -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(); + } } diff --git a/src/main/java/DGU_AI_LAB/admin_be/domain/users/repository/UserRepository.java b/src/main/java/DGU_AI_LAB/admin_be/domain/users/repository/UserRepository.java index 00a3b85..1292248 100644 --- a/src/main/java/DGU_AI_LAB/admin_be/domain/users/repository/UserRepository.java +++ b/src/main/java/DGU_AI_LAB/admin_be/domain/users/repository/UserRepository.java @@ -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 { + Optional 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 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 findUsersForHardDelete(@Param("hardDeleteThreshold") LocalDateTime hardDeleteThreshold); } diff --git a/src/main/java/DGU_AI_LAB/admin_be/domain/users/service/UserLoginService.java b/src/main/java/DGU_AI_LAB/admin_be/domain/users/service/UserLoginService.java index 429b624..987ac6b 100644 --- a/src/main/java/DGU_AI_LAB/admin_be/domain/users/service/UserLoginService.java +++ b/src/main/java/DGU_AI_LAB/admin_be/domain/users/service/UserLoginService.java @@ -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); diff --git a/src/main/java/DGU_AI_LAB/admin_be/error/ErrorCode.java b/src/main/java/DGU_AI_LAB/admin_be/error/ErrorCode.java index ed96f73..4da8cac 100644 --- a/src/main/java/DGU_AI_LAB/admin_be/error/ErrorCode.java +++ b/src/main/java/DGU_AI_LAB/admin_be/error/ErrorCode.java @@ -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 사용자를 찾을 수 없습니다."), diff --git a/src/main/java/DGU_AI_LAB/admin_be/global/config/RedisConfig.java b/src/main/java/DGU_AI_LAB/admin_be/global/config/RedisConfig.java new file mode 100644 index 0000000..9480611 --- /dev/null +++ b/src/main/java/DGU_AI_LAB/admin_be/global/config/RedisConfig.java @@ -0,0 +1,29 @@ +package DGU_AI_LAB.admin_be.global.config; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.data.redis.connection.RedisConnectionFactory; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.data.redis.serializer.GenericJackson2JsonRedisSerializer; +import org.springframework.data.redis.serializer.StringRedisSerializer; + +@Configuration +public class RedisConfig { + + @Bean + public RedisTemplate redisTemplate(RedisConnectionFactory connectionFactory) { + RedisTemplate template = new RedisTemplate<>(); + template.setConnectionFactory(connectionFactory); + + // Key는 String으로 + template.setKeySerializer(new StringRedisSerializer()); + + // Value는 GenericJackson2JsonRedisSerializer 사용 (Class Type 정보 포함) + template.setValueSerializer(new GenericJackson2JsonRedisSerializer()); + + template.setHashKeySerializer(new StringRedisSerializer()); + template.setHashValueSerializer(new GenericJackson2JsonRedisSerializer()); + + return template; + } +} \ No newline at end of file diff --git a/src/main/java/DGU_AI_LAB/admin_be/global/event/RequestExpiredEvent.java b/src/main/java/DGU_AI_LAB/admin_be/global/event/RequestExpiredEvent.java new file mode 100644 index 0000000..bc70caa --- /dev/null +++ b/src/main/java/DGU_AI_LAB/admin_be/global/event/RequestExpiredEvent.java @@ -0,0 +1,9 @@ +package DGU_AI_LAB.admin_be.global.event; + +import DGU_AI_LAB.admin_be.domain.users.entity.User; + +public record RequestExpiredEvent( + User user, + String ubuntuUsername, + String serverName // Lab/Farm 구분용 +) {} \ No newline at end of file diff --git a/src/main/java/DGU_AI_LAB/admin_be/global/util/MessageUtils.java b/src/main/java/DGU_AI_LAB/admin_be/global/util/MessageUtils.java new file mode 100644 index 0000000..da412b8 --- /dev/null +++ b/src/main/java/DGU_AI_LAB/admin_be/global/util/MessageUtils.java @@ -0,0 +1,18 @@ +package DGU_AI_LAB.admin_be.global.util; + +import lombok.RequiredArgsConstructor; +import org.springframework.context.MessageSource; +import org.springframework.stereotype.Component; + +import java.util.Locale; + +@Component +@RequiredArgsConstructor +public class MessageUtils { + + private final MessageSource messageSource; + + public String get(String code, Object... args) { + return messageSource.getMessage(code, args, Locale.KOREA); + } +} \ No newline at end of file diff --git a/src/main/resources/messages.properties b/src/main/resources/messages.properties new file mode 100644 index 0000000..9aaaaff --- /dev/null +++ b/src/main/resources/messages.properties @@ -0,0 +1,63 @@ +# ============================================================================== +# 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??? ??? ?????. + +# 2. ?? ?? (7?, 3?, 1? ?) +# Subject ???: {1} -> {0} (??? dayLabel ????) +notification.pre-expiry.subject=[DGU AI LAB] ?? ?? ?? ?? ?? ({0} ?) + +# {0}: ??? ??, {1}: ?? ??(ex. 7?), {2}: ???, {3}: ???, {4}: ??? +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}?? ??? ???????. + +# 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\ + ?? ??? ???? ?? ????? ?? ????. \ 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 new file mode 100644 index 0000000..d1638bd --- /dev/null +++ b/src/test/java/DGU_AI_LAB/admin_be/domain/scheduler/RequestSchedulerServiceTest.java @@ -0,0 +1,209 @@ +package DGU_AI_LAB.admin_be.domain.scheduler; + +import DGU_AI_LAB.admin_be.AdminBeApplication; +import DGU_AI_LAB.admin_be.domain.alarm.service.AlarmService; +import DGU_AI_LAB.admin_be.domain.containerImage.entity.ContainerImage; +import DGU_AI_LAB.admin_be.domain.containerImage.repository.ContainerImageRepository; +import DGU_AI_LAB.admin_be.domain.requests.entity.Request; +import DGU_AI_LAB.admin_be.domain.requests.entity.Status; +import DGU_AI_LAB.admin_be.domain.requests.repository.RequestRepository; +import DGU_AI_LAB.admin_be.domain.requests.service.UbuntuAccountService; +import DGU_AI_LAB.admin_be.domain.resourceGroups.entity.ResourceGroup; +import DGU_AI_LAB.admin_be.domain.resourceGroups.repository.ResourceGroupRepository; +import DGU_AI_LAB.admin_be.domain.usedIds.entity.UsedId; +import DGU_AI_LAB.admin_be.domain.usedIds.repository.UsedIdRepository; +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 org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.mockito.MockedStatic; +import org.mockito.Mockito; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.annotation.DirtiesContext; +import org.springframework.test.context.bean.override.mockito.MockitoBean; + +import java.time.LocalDateTime; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; + +@SpringBootTest(classes = AdminBeApplication.class) +@DirtiesContext(classMode = DirtiesContext.ClassMode.AFTER_EACH_TEST_METHOD) +public class RequestSchedulerServiceTest { + + @Autowired + private RequestSchedulerService requestSchedulerService; + + @Autowired + private MessageUtils messageUtils; + + // --- Mocks --- + @MockitoBean + private AlarmService alarmService; + + @MockitoBean + private UbuntuAccountService ubuntuAccountService; + + // --- Repositories --- + @Autowired private RequestRepository requestRepository; + @Autowired private UserRepository userRepository; + @Autowired private UsedIdRepository usedIdRepository; + @Autowired private ResourceGroupRepository resourceGroupRepository; + @Autowired private ContainerImageRepository containerImageRepository; + + private final LocalDateTime MOCK_NOW = LocalDateTime.of(2025, 11, 10, 10, 30, 0); + + @Test + @DisplayName("스케줄러 통합 테스트: 만료 삭제(이벤트) 및 1/3/7일 전 알림 발송 검증") + void runScheduler_IntegrationTest() { + + // 1. 기초 데이터 세팅 + User testUser = userRepository.save(User.builder() + .email("test@dgu.ac.kr") + .name("테스트유저") + .password("encoded_pw") + .studentId("2020111111") + .phone("010-1234-5678") + .department("AI융합학부") + .build()); + + ResourceGroup testRg = resourceGroupRepository.save(ResourceGroup.builder() + .serverName("FARM-01") + .resourceGroupName("RTX 3090") + .build()); + + ContainerImage testImage = containerImageRepository.save(ContainerImage.builder() + .imageName("cuda") + .imageVersion("11.8") + .cudaVersion("11.8") + .description("Test Image") + .build()); + + // 2. UsedId 및 Request 생성 + UsedId uidExpired = usedIdRepository.save(UsedId.builder().idValue(1000L).build()); + UsedId uid1Day = usedIdRepository.save(UsedId.builder().idValue(1001L).build()); + UsedId uid3Day = usedIdRepository.save(UsedId.builder().idValue(1002L).build()); + UsedId uid7Day = usedIdRepository.save(UsedId.builder().idValue(1003L).build()); + UsedId uidOk = usedIdRepository.save(UsedId.builder().idValue(1004L).build()); + + // (1) 만료 (어제) + Request reqExpired = createTestRequest(MOCK_NOW.minusDays(1), Status.FULFILLED, uidExpired, "user-expired", testUser, testRg, testImage); + // (2) 1일 전 (내일) + Request req1Day = createTestRequest(MOCK_NOW.plusDays(1).withHour(12), Status.FULFILLED, uid1Day, "user-1day", testUser, testRg, testImage); + // (3) 3일 전 + Request req3Day = createTestRequest(MOCK_NOW.plusDays(3).withHour(14), Status.FULFILLED, uid3Day, "user-3day", testUser, testRg, testImage); + // (4) 7일 전 + Request req7Day = createTestRequest(MOCK_NOW.plusDays(7).withHour(15), Status.FULFILLED, uid7Day, "user-7day", testUser, testRg, testImage); + // (5) 넉넉함 + createTestRequest(MOCK_NOW.plusDays(30), Status.FULFILLED, uidOk, "user-ok", testUser, testRg, testImage); + + + // --- Given: 시간 고정 & 스케줄러 실행 --- + try (MockedStatic mockedTime = Mockito.mockStatic(LocalDateTime.class, Mockito.CALLS_REAL_METHODS)) { + mockedTime.when(LocalDateTime::now).thenReturn(MOCK_NOW); + + requestSchedulerService.runScheduler(); + } + + // --- Then: 검증 --- + + // 1. [삭제 검증] reqExpired + Request deletedResult = requestRepository.findById(reqExpired.getRequestId()).orElseThrow(); + assertThat(deletedResult.getStatus()).isEqualTo(Status.DELETED); + verify(ubuntuAccountService, times(1)).deleteUbuntuAccount("user-expired"); + + // [이벤트 리스너 검증] -> 삭제 완료 알림 (MessageUtils 사용 검증) + // subject: notification.expired.detail.subject + // body: notification.expired.detail.body ({0}이름, {1}서버, {2}계정) + String expectedDelSubject = messageUtils.get("notification.expired.detail.subject"); + String expectedDelBody = messageUtils.get("notification.expired.detail.body", + testUser.getName(), "FARM-01", "user-expired"); + + verify(alarmService).sendAllAlerts( + eq(testUser.getName()), + eq(testUser.getEmail()), + eq(expectedDelSubject), + eq(expectedDelBody) + ); + + // 관리자 알림 검증 + // notification.admin.delete.success ({0}타입, {1}계정, {2}서버) + String expectedAdminMsg = messageUtils.get("notification.admin.delete.success", + "FARM", "user-expired", "FARM-01"); + + verify(alarmService).sendAdminSlackNotification( + eq("FARM-01"), + eq(expectedAdminMsg) + ); + + + // 2. [알림 검증] 1일 전 (req1Day) + verifyPreExpiryAlert(req1Day, "1일", testUser); + + // 3. [알림 검증] 3일 전 (req3Day) + verifyPreExpiryAlert(req3Day, "3일", testUser); + + // 4. [알림 검증] 7일 전 (req7Day) + verifyPreExpiryAlert(req7Day, "7일", testUser); + + + // 5. [총 호출 횟수 검증] + // 사용자 알림: 삭제1 + 1일전1 + 3일전1 + 7일전1 = 4회 + verify(alarmService, times(4)).sendAllAlerts(anyString(), anyString(), anyString(), anyString()); + // 관리자 알림: 삭제1회 + verify(alarmService, times(1)).sendAdminSlackNotification(anyString(), anyString()); + } + + // [헬퍼] 만료 예고 알림 검증 로직 분리 + private void verifyPreExpiryAlert(Request request, String dayLabel, User user) { + // subject: notification.pre-expiry.subject ({0} 기간) + String expectedSubject = messageUtils.get("notification.pre-expiry.subject", dayLabel); + + // body: notification.pre-expiry.body ({0}이름, {1}기간, {2}날짜, {3}서버, {4}계정) + String expectedBody = messageUtils.get("notification.pre-expiry.body", + user.getName(), + dayLabel, + request.getExpiresAt().toLocalDate().toString(), + request.getResourceGroup().getServerName(), + request.getUbuntuUsername() + ); + + verify(alarmService).sendAllAlerts( + eq(user.getName()), + eq(user.getEmail()), + eq(expectedSubject), + eq(expectedBody) + ); + } + + private Request createTestRequest(LocalDateTime expiresAt, Status status, UsedId usedId, String ubuntuUsername, + User testUser, ResourceGroup testRg, ContainerImage testImage) { + Request req = Request.builder() + .ubuntuUsername(ubuntuUsername) + .ubuntuPassword("password") + .volumeSizeGiB(10L) + .expiresAt(expiresAt) + .usagePurpose("test") + .formAnswers("{}") + .user(testUser) + .resourceGroup(testRg) + .containerImage(testImage) + .build(); + + if (status == Status.FULFILLED || status == Status.DELETED) { + req.approve(testImage, testRg, 10L, "approved"); + req.assignUbuntuUid(usedId); + } + + if (status == Status.DELETED) { + req.delete(); + req.assignUbuntuUid(null); + } + + return requestRepository.saveAndFlush(req); + } +} \ No newline at end of file diff --git a/src/test/java/DGU_AI_LAB/admin_be/domain/scheduler/UserSchedulerServiceTest.java b/src/test/java/DGU_AI_LAB/admin_be/domain/scheduler/UserSchedulerServiceTest.java new file mode 100644 index 0000000..ec2cb7c --- /dev/null +++ b/src/test/java/DGU_AI_LAB/admin_be/domain/scheduler/UserSchedulerServiceTest.java @@ -0,0 +1,215 @@ +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.containerImage.entity.ContainerImage; +import DGU_AI_LAB.admin_be.domain.containerImage.repository.ContainerImageRepository; +import DGU_AI_LAB.admin_be.domain.requests.entity.Request; +import DGU_AI_LAB.admin_be.domain.requests.repository.RequestRepository; +import DGU_AI_LAB.admin_be.domain.resourceGroups.entity.ResourceGroup; +import DGU_AI_LAB.admin_be.domain.resourceGroups.repository.ResourceGroupRepository; +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 org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.annotation.DirtiesContext; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDateTime; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.verify; + +@SpringBootTest +@Transactional +@DirtiesContext(classMode = DirtiesContext.ClassMode.AFTER_EACH_TEST_METHOD) +class UserSchedulerServiceTest { + + @Autowired + private UserSchedulerService userSchedulerService; + + @Autowired + private UserRepository userRepository; + @Autowired + private RequestRepository requestRepository; + @Autowired + private ResourceGroupRepository resourceGroupRepository; + @Autowired + private ContainerImageRepository containerImageRepository; + + @Autowired + private MessageUtils messageUtils; + + @MockitoBean + private AlarmService alarmService; + + + @Test + @DisplayName("유저 수명주기 통합 테스트: 알림(D-7, D-1), Soft Delete, Hard Delete, 활동 유저 보호") + void userLifecycleScheduler_IntegrationTest() { + // --- Given --- + LocalDateTime now = LocalDateTime.now(); + + // 1. [정상 유저] + User activeUser = createUser("active@test.com", "ActiveUser"); + updateLastLogin(activeUser, now.minusDays(1)); + + // 2. [보호 유저] + User podUser = createUser("pod@test.com", "PodUser"); + updateLastLogin(podUser, now.minusMonths(4)); + createRequestForUser(podUser, now.minusDays(1)); + + // 3. [경고 대상 D-7] (Login = Now - 3개월 + 7일) + User d7User = createUser("d7@test.com", "D7User"); + LocalDateTime d7LoginDate = now.minusMonths(3).plusDays(7); + updateLastLogin(d7User, d7LoginDate); + + // D-7 예상 메시지 생성 + LocalDateTime d7DeleteDate = d7LoginDate.plusMonths(3); + String d7Subject = messageUtils.get("notification.user.delete-warning.subject", "7"); + String d7Body = messageUtils.get("notification.user.delete-warning.body", + "D7User", "7", d7DeleteDate.toLocalDate().toString()); + + + // 4. [경고 대상 D-1] (Login = Now - 3개월 + 1일) + User d1User = createUser("d1@test.com", "D1User"); + LocalDateTime d1LoginDate = now.minusMonths(3).plusDays(1); + updateLastLogin(d1User, d1LoginDate); + + // D-1 예상 메시지 생성 + LocalDateTime d1DeleteDate = d1LoginDate.plusMonths(3); + String d1Subject = messageUtils.get("notification.user.delete-warning.subject", "1"); + String d1Body = messageUtils.get("notification.user.delete-warning.body", + "D1User", "1", d1DeleteDate.toLocalDate().toString()); + + + // 5. [Soft Delete 대상] + User softTarget = createUser("soft@test.com", "SoftTarget"); + updateLastLogin(softTarget, now.minusMonths(3).minusDays(1)); + + // Soft Delete 예상 메시지 + String softSubject = messageUtils.get("notification.user.soft-delete.subject"); + String softBody = messageUtils.get("notification.user.soft-delete.body", "SoftTarget"); + + + // 6. [Hard Delete 대상] + User hardTarget = createUser("hard@test.com", "HardTarget"); + hardTarget.withdraw(); + updateDeletedAt(hardTarget, now.minusYears(1).minusDays(1)); + + // 7. [Hard Delete 미대상] + User hardSafe = createUser("hardsafe@test.com", "HardSafe"); + hardSafe.withdraw(); + updateDeletedAt(hardSafe, now.minusMonths(11)); + + + // --- When --- + userSchedulerService.runUserLifecycleScheduler(); + + + // --- Then --- + + // 1. 정상 유저 생존 + assertThat(userRepository.findById(activeUser.getUserId()).get().getIsActive()).isTrue(); + + // 2. 보호 유저 생존 + assertThat(userRepository.findById(podUser.getUserId()).get().getIsActive()).isTrue(); + + // 3. [D-7] 알림 검증 (정확한 메시지 매칭) + verify(alarmService).sendAllAlerts( + eq("D7User"), + eq("d7@test.com"), + eq(d7Subject), + eq(d7Body) + ); + + // 4. [D-1] 알림 검증 + verify(alarmService).sendAllAlerts( + eq("D1User"), + eq("d1@test.com"), + eq(d1Subject), + eq(d1Body) + ); + + // 5. [Soft Delete] 알림 검증 및 상태 확인 + User resSoft = userRepository.findById(softTarget.getUserId()).get(); + assertThat(resSoft.getIsActive()).isFalse(); + assertThat(resSoft.getDeletedAt()).isNotNull(); + + verify(alarmService).sendAllAlerts( + eq("SoftTarget"), + eq("soft@test.com"), + eq(softSubject), + eq(softBody) + ); + + // 6. [Hard Delete] 삭제 확인 + assertThat(userRepository.findById(hardTarget.getUserId())).isEmpty(); + + // 7. [Hard Delete 미대상] 생존 확인 + assertThat(userRepository.findById(hardSafe.getUserId())).isPresent(); + } + + + // --- Helper Methods --- + + private User createUser(String email, String name) { + return userRepository.save(User.builder() + .email(email) + .name(name) + .password("pw") + .studentId("1234") + .phone("010-0000-0000") + .department("CS") + .build()); + } + + private void updateLastLogin(User user, LocalDateTime time) { + try { + var field = User.class.getDeclaredField("lastLoginAt"); + field.setAccessible(true); + field.set(user, time); + userRepository.saveAndFlush(user); + } catch (Exception e) { + throw new RuntimeException(e); + } + } + + private void updateDeletedAt(User user, LocalDateTime time) { + try { + var field = User.class.getDeclaredField("deletedAt"); + field.setAccessible(true); + field.set(user, time); + userRepository.saveAndFlush(user); + } catch (Exception e) { + throw new RuntimeException(e); + } + } + + private void createRequestForUser(User user, LocalDateTime expiresAt) { + ResourceGroup rg = resourceGroupRepository.findAll().stream().findFirst() + .orElseGet(() -> resourceGroupRepository.save(ResourceGroup.builder().serverName("TestServer").resourceGroupName("G").build())); + ContainerImage img = containerImageRepository.findAll().stream().findFirst() + .orElseGet(() -> containerImageRepository.save(ContainerImage.builder().imageName("cuda").imageVersion("1").cudaVersion("1").description("d").build())); + + Request req = Request.builder() + .user(user) + .ubuntuUsername("user_" + user.getUserId()) + .ubuntuPassword("pw") + .volumeSizeGiB(10L) + .expiresAt(expiresAt) + .usagePurpose("test") + .formAnswers("{}") + .resourceGroup(rg) + .containerImage(img) + .build(); + + req.approve(img, rg, 10L, "approved"); + requestRepository.saveAndFlush(req); + } +} \ No newline at end of file diff --git a/src/test/java/DGU_AI_LAB/admin_be/scheduler/RequestSchedulerServiceTest.java b/src/test/java/DGU_AI_LAB/admin_be/scheduler/RequestSchedulerServiceTest.java deleted file mode 100644 index aa4063e..0000000 --- a/src/test/java/DGU_AI_LAB/admin_be/scheduler/RequestSchedulerServiceTest.java +++ /dev/null @@ -1,190 +0,0 @@ -package DGU_AI_LAB.admin_be.scheduler; - -import DGU_AI_LAB.admin_be.AdminBeApplication; -import DGU_AI_LAB.admin_be.domain.alarm.service.AlarmService; -import DGU_AI_LAB.admin_be.domain.containerImage.entity.ContainerImage; -import DGU_AI_LAB.admin_be.domain.containerImage.repository.ContainerImageRepository; -import DGU_AI_LAB.admin_be.domain.requests.entity.Request; -import DGU_AI_LAB.admin_be.domain.requests.entity.Status; -import DGU_AI_LAB.admin_be.domain.requests.repository.RequestRepository; -import DGU_AI_LAB.admin_be.domain.requests.service.UbuntuAccountService; -import DGU_AI_LAB.admin_be.domain.resourceGroups.entity.ResourceGroup; -import DGU_AI_LAB.admin_be.domain.resourceGroups.repository.ResourceGroupRepository; -import DGU_AI_LAB.admin_be.domain.scheduler.RequestSchedulerService; -import DGU_AI_LAB.admin_be.domain.usedIds.entity.UsedId; -import DGU_AI_LAB.admin_be.domain.usedIds.repository.UsedIdRepository; -import DGU_AI_LAB.admin_be.domain.users.entity.User; -import DGU_AI_LAB.admin_be.domain.users.repository.UserRepository; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Test; -import org.mockito.MockedStatic; -import org.mockito.Mockito; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.boot.test.context.SpringBootTest; -import org.springframework.test.annotation.DirtiesContext; -import org.springframework.test.context.bean.override.mockito.MockitoBean; - -import java.time.LocalDateTime; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.mockito.ArgumentMatchers.anyString; -import static org.mockito.ArgumentMatchers.contains; -import static org.mockito.ArgumentMatchers.eq; -import static org.mockito.Mockito.*; - -/** - 스케줄러를 위한 통합 테스트 - 통합 테스트에서는 H2 database & create-drop 옵션을 사용합니다. - */ -@SpringBootTest(classes = AdminBeApplication.class) -@DirtiesContext(classMode = DirtiesContext.ClassMode.AFTER_EACH_TEST_METHOD) -class RequestSchedulerServiceTest { - - // --- System Under Test (SUT) --- - @Autowired - private RequestSchedulerService requestSchedulerService; - - // --- Mocks --- - @MockitoBean - private AlarmService alarmService; - @MockitoBean - private UbuntuAccountService ubuntuAccountService; - - // --- Real Repositories (for DB setup & verification) --- - @Autowired private RequestRepository requestRepository; - @Autowired private UserRepository userRepository; - @Autowired private UsedIdRepository usedIdRepository; - @Autowired private ResourceGroupRepository resourceGroupRepository; - @Autowired private ContainerImageRepository containerImageRepository; - - // --- Test "Current" Time --- - private final LocalDateTime MOCK_NOW = LocalDateTime.of(2025, 11, 10, 10, 30, 0); - - - @Test - @DisplayName("스케줄러: 만료/알림/삭제 로직 통합 테스트") - void checkAndProcessExpiredRequests_IntegrationTest() { - - // 1. 공통 의존 데이터 생성 - User testUser = userRepository.save(User.builder() - .email("test@dgu.ac.kr") - .name("테스트유저") - .password("test1234") - .studentId("2020111111") - .phone("010-1234-5678") - .department("컴퓨터공학과") - .build()); - - ResourceGroup testRg = resourceGroupRepository.save(ResourceGroup.builder() - .serverName("FARM") - .resourceGroupName("RTX 3090 Ti Cluster") - .description("Test Description") - .build()); - - ContainerImage testImage = containerImageRepository.save(ContainerImage.builder() - .imageName("cuda") - .imageVersion("11.8") - .cudaVersion("11.8") - .description("CUDA 11.8 test env") - .build()); - - // 2. UsedId 생성 - UsedId expiredUsedId = usedIdRepository.save(UsedId.builder().idValue(1001L).build()); - UsedId usedId1 = usedIdRepository.save(UsedId.builder().idValue(1002L).build()); - UsedId usedId7 = usedIdRepository.save(UsedId.builder().idValue(1003L).build()); - UsedId usedIdOk = usedIdRepository.save(UsedId.builder().idValue(1004L).build()); - - // 3. 시나리오별 Request 데이터 생성 (헬퍼 메서드 대신 직접 생성) - Request expiredRequest = createTestRequest(MOCK_NOW.minusDays(1), Status.FULFILLED, expiredUsedId, "expired-user", testUser, testRg, testImage); - Request request1Day = createTestRequest(MOCK_NOW.plusDays(1).withHour(12), Status.FULFILLED, usedId1, "1day-user", testUser, testRg, testImage); - Request request7Day = createTestRequest(MOCK_NOW.plusDays(7).withHour(14), Status.FULFILLED, usedId7, "7day-user", testUser, testRg, testImage); - Request requestOk = createTestRequest(MOCK_NOW.plusDays(30), Status.FULFILLED, usedIdOk, "ok-user", testUser, testRg, testImage); - Request requestPending = createTestRequest(MOCK_NOW.minusDays(1), Status.PENDING, null, "pending-user", testUser, testRg, testImage); - Request requestDeleted = createTestRequest(MOCK_NOW.minusDays(10), Status.DELETED, null, "deleted-user", testUser, testRg, testImage); - - - // --- Given (Arrange) --- - // 1. LocalDateTime.now()를 MOCK_NOW로 고정 - try (MockedStatic mockedTime = Mockito.mockStatic(LocalDateTime.class, Mockito.CALLS_REAL_METHODS)) { - - mockedTime.when(LocalDateTime::now).thenReturn(MOCK_NOW); - - // 2. 현재 DB 상태 확인 - assertThat(requestRepository.count()).isEqualTo(6); - assertThat(usedIdRepository.count()).isEqualTo(4); - - // --- When (Act) --- - // 3. 스케줄러 메서드 직접 호출 - requestSchedulerService.checkAndProcessExpiredRequests(); - } - - // --- Then (Assert) --- - Request deletedReq = requestRepository.findById(expiredRequest.getRequestId()).get(); - Request verifiedRequest1Day = requestRepository.findById(request1Day.getRequestId()).get(); - Request verifiedRequest7Day = requestRepository.findById(request7Day.getRequestId()).get(); - Request verifiedRequestOk = requestRepository.findById(requestOk.getRequestId()).get(); - Request verifiedRequestPending = requestRepository.findById(requestPending.getRequestId()).get(); - Request verifiedRequestDeleted = requestRepository.findById(requestDeleted.getRequestId()).get(); - - - // **검증 1: [삭제 대상] expiredRequest** - assertThat(deletedReq.getStatus()).isEqualTo(Status.DELETED); - assertThat(deletedReq.getUbuntuUid()).isNull(); - assertThat(usedIdRepository.findById(expiredUsedId.getIdValue())).isEmpty(); - verify(ubuntuAccountService, times(1)).deleteUbuntuAccount(eq("expired-user")); - verify(alarmService, times(1)).sendAllAlerts(eq(testUser.getName()), eq(testUser.getEmail()), contains("삭제 안내"), anyString()); - verify(alarmService, times(1)).sendAdminSlackNotification(eq(testRg.getServerName()), contains("삭제 완료")); - - - // **검증 2: [1일 전 알림] request1Day** - assertThat(verifiedRequest1Day.getStatus()).isEqualTo(Status.FULFILLED); - verify(alarmService, times(1)).sendAllAlerts(eq(testUser.getName()), eq(testUser.getEmail()), contains("1일 전 안내"), anyString()); - verify(alarmService, times(1)).sendAdminSlackNotification(eq(testRg.getServerName()), contains("1일 전 알림")); - - - // **검증 3: [7일 전 알림] request7Day** - assertThat(verifiedRequest7Day.getStatus()).isEqualTo(Status.FULFILLED); - verify(alarmService, times(1)).sendAllAlerts(eq(testUser.getName()), eq(testUser.getEmail()), contains("7일 전 안내"), anyString()); - verify(alarmService, times(1)).sendAdminSlackNotification(eq(testRg.getServerName()), contains("7일 전 알림")); - - - // **검증 4: [무시 대상] requestOk, requestPending, requestDeleted** - assertThat(verifiedRequestOk.getStatus()).isEqualTo(Status.FULFILLED); - assertThat(verifiedRequestPending.getStatus()).isEqualTo(Status.PENDING); - assertThat(verifiedRequestDeleted.getStatus()).isEqualTo(Status.DELETED); - - - // **검증 5: [전체 호출 횟수 검증]** - verify(ubuntuAccountService, times(1)).deleteUbuntuAccount(anyString()); - verify(alarmService, times(3)).sendAllAlerts(anyString(), anyString(), anyString(), anyString()); - verify(alarmService, times(3)).sendAdminSlackNotification(anyString(), anyString()); - } - - // --- 테스트 데이터 생성을 위한 헬퍼 메서드 --- - private Request createTestRequest(LocalDateTime expiresAt, Status status, UsedId usedId, String ubuntuUsername, - User testUser, ResourceGroup testRg, ContainerImage testImage) { - Request req = Request.builder() - .ubuntuUsername(ubuntuUsername) - .ubuntuPassword("password") - .volumeSizeGiB(10L) - .expiresAt(expiresAt) - .usagePurpose("test") - .formAnswers("{}") - .user(testUser) - .resourceGroup(testRg) - .containerImage(testImage) - .build(); - - if (status == Status.FULFILLED) { - req.approve(testImage, testRg, 10L, "test approve"); - req.assignUbuntuUid(usedId); - } else if (status == Status.PENDING) { // 기본값이 PENDING - } else if (status == Status.DELETED) { - req.approve(testImage, testRg, 10L, "test approve"); - req.assignUbuntuUid(usedId); // 삭제 전 UID가 있었다고 가정 - req.delete(); // DELETED로 상태 변경 - req.assignUbuntuUid(null); // UID 반납 - } - return requestRepository.saveAndFlush(req); - } -} \ No newline at end of file