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..1aaff65 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,58 @@ 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 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 (관리자 채널) --- @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; - @Value("${spring.mail.username}") - private String from; + private static final String SLACK_QUEUE_KEY = "slack:notification:queue"; - /** - * Slack Webhook을 사용하여 특정 채널에 메시지를 전송합니다. - */ - 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); + // --- Public Methods --- + public void sendSlackAlert(String message, String webhookUrl) { 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 +61,69 @@ 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); + "🔔 새로운 서버 사용 신청! 🔔\n▶ 신청자: %s\n▶ 서버: %s\n(관리자 페이지 확인 요망)", + 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 = "[DGU AI LAB] 서버 사용 신청 승인"; + String message = String.format("🎉 %s님의 신청이 승인되었습니다.", 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 = "\n[⚠️ Redis 장애로 직접 발송됨]"; + String fullMessage = dto.getMessage() + notice; + + try { + if (dto.getType() == SlackMessageDto.MessageType.WEBHOOK) { + // 이제 SlackApiService를 사용하여 Fallback 처리 -> 코드 중복 제거됨 + 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..6d76016 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,9 +1,10 @@ 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 lombok.RequiredArgsConstructor; -import lombok.extern.log4j.Log4j2; +import lombok.extern.slf4j.Slf4j; // Log4j2 -> Slf4j (Spring Boot 표준 권장) import org.springframework.beans.factory.annotation.Value; import org.springframework.http.*; import org.springframework.stereotype.Service; @@ -13,133 +14,138 @@ import java.util.Map; import java.util.stream.Collectors; -/** - * Slack Bot Token을 사용하여 Slack API와 직접 통신하는 서비스 - * (DM 전송 등) - */ @Service @RequiredArgsConstructor -@Log4j2 +@Slf4j public class SlackApiService { @Value("${slack.bot-token}") private String botToken; private final RestTemplate restTemplate = new RestTemplate(); - /** - * 사용자에게 Slack DM을 전송합니다. - */ + // ========================================================================= + // 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 sendDM(User user) { + String message = String.format("안녕하세요 %s님, 요청하신 GPU 서버 사용 기간이 만료되어 리소스가 정리되었습니다.", user.getName()); + this.sendDM(user.getName(), user.getEmail(), message); + } + public void sendDM(String username, String email, String message) { String userId = getSlackUser(username, email, botToken); if (userId == null) { - log.warn("Slack DM 전송 실패: 사용자를 찾을 수 없습니다. (이름: {}, 이메일: {})", username, email); + // 여기서 throw하면 Worker나 Listener에서 잡힘 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); } - try { - sendMessageToSlackChannel(channelId, message, botToken); - } catch (Exception e) { - log.error("Slack DM 전송 중 오류 발생", e); - throw new BusinessException(ErrorCode.SLACK_SEND_FAILED); - } + sendMessageToSlackChannel(channelId, message, botToken); } - /** - * 이름과 이메일로 Slack 사용자 ID를 찾습니다. - */ + // --- Private Helper Methods --- + private String getSlackUser(String username, String email, String token) { String url = "https://slack.com/api/users.list"; HttpHeaders headers = new HttpHeaders(); headers.setBearerAuth(token); HttpEntity request = new HttpEntity<>(headers); - 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"); - - // 이름이 일치하는 사용자 목록 필터링 - List> matchedUsers = members.stream() - .filter(user -> { - Map profile = (Map) user.get("profile"); - 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()); - - if (matchedUsers.isEmpty()) { + 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"); + + // 1차: 이름 매칭 + List> matchedUsers = members.stream() + .filter(user -> { + Map profile = (Map) user.get("profile"); + String displayName = (String) profile.get("display_name"); + String realName = (String) profile.get("real_name"); + String name = (String) user.get("name"); + return (displayName != null && displayName.equals(username)) || + (realName != null && realName.equals(username)) || + (name != null && name.equals(username)); + }).collect(Collectors.toList()); + + if (matchedUsers.isEmpty()) throw new BusinessException(ErrorCode.SLACK_USER_NOT_FOUND); + 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)); + + return (String) selectedUser.get("id"); + } catch (Exception e) { throw new BusinessException(ErrorCode.SLACK_USER_NOT_FOUND); } - - if (matchedUsers.size() == 1) { - return (String) matchedUsers.get(0).get("id"); - } - - // 이름이 중복될 경우, 이메일로 재검색 - 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)); - - return (String) selectedUser.get("id"); } - /** - * 사용자 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..b8a2c06 --- /dev/null +++ b/src/main/java/DGU_AI_LAB/admin_be/domain/scheduler/RequestEventListener.java @@ -0,0 +1,67 @@ +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 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; + + // DB 커밋이 완료된 후에만 실행됨 + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) + public void handleExpiredEvent(RequestExpiredEvent event) { + User user = event.user(); + String serverName = event.serverName(); + String username = event.ubuntuUsername(); + + // 사용자 삭제 알림 + try { + String subject = "[DGU AI LAB] 서버 계정 삭제 완료 안내"; + String message = String.format( + """ + 안녕하세요, %s님. + + 기간 만료로 인해 아래 서버 리소스가 삭제되었습니다. + + - 서버: %s + - 계정: %s + + 이용해 주셔서 감사합니다. + """, + user.getName(), serverName, username + ); + // 이메일 + 개인 DM 전송 + alarmService.sendAllAlerts(user.getName(), user.getEmail(), subject, message); + } catch (Exception e) { + log.warn("사용자 삭제 알림 전송 실패: {}", e.getMessage()); + } + + // 2. [요구사항 1] 관리자 알림: Lab/Farm 구분하여 간단히 보고 + try { + String type = getServerType(serverName); + // 간단 명료한 메시지 + String adminMsg = String.format("🗑️ [%s] 리소스 삭제 완료: %s (%s)", 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..e7ec40a 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,11 @@ 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 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 +29,141 @@ 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; - /** - * 매일 오전 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일"); + // 1. 만료 예고 (삭제 예정 알림) + sendPreExpiryNotification(now.plusDays(7), "7일"); + sendPreExpiryNotification(now.plusDays(3), "3일"); + sendPreExpiryNotification(now.plusDays(1), "1일"); - // 2. 만료 1일 전 알림 (읽기 전용 트랜잭션) - self.processPreExpiryNotifications(now.plusDays(1), "1일"); - } catch (Exception e) { - log.error("만료 전 알림 처리 중 오류 발생", e); - } + // 2. 만료 처리 (삭제 및 결과 알림) + processExpiredRequests(now); - // 3. 만료된 계정 목록 조회 - List expiredRequests; - try { - expiredRequests = requestRepository.findAllByExpiresAtBeforeAndStatus(now, Status.FULFILLED); - } catch (Exception e) { - log.error("만료 계정 조회 중 DB 오류. 스케줄러를 종료합니다.", e); - return; - } + log.info("🗓️ [스케줄러 종료]"); + } - log.info("만료되어 삭제할 계정 {}건 발견.", expiredRequests.size()); + public void processExpiredRequests(LocalDateTime now) { + List expiredRequests = requestRepository.findAllWithUserByExpiredDateBefore(now); + if (expiredRequests.isEmpty()) return; + + RequestSchedulerService self = applicationContext.getBean(RequestSchedulerService.class); - // 4. 만료된 계정 개별 삭제 처리 (개별 트랜잭션) for (Request request : expiredRequests) { + String serverName = "Unknown"; + String username = request.getUbuntuUsername(); + 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); + // 에러 발생 시 알림을 위해 미리 정보 추출 + if (request.getResourceGroup() != null) { + serverName = request.getResourceGroup().getServerName(); } + + // 트랜잭션 메서드 호출 + self.deleteExpiredRequest(request.getRequestId()); + + } catch (Exception e) { + log.error("계정 삭제 실패 (ID: {}): {}", request.getRequestId(), e.getMessage()); + + // [요구사항 3] 리소스 삭제 실패 시 관리자 채널에만 알림 + sendFailureAlertToAdmin(serverName, username, e.getMessage()); } } - 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); + @Transactional + public void deleteExpiredRequest(Long requestId) { + Request request = requestRepository.findById(requestId) + .orElseThrow(() -> new IllegalArgumentException("Request not found")); + + if (request.getStatus() != Status.FULFILLED) return; + + // 이벤트 발행을 위해 정보 미리 저장 + String serverName = request.getResourceGroup().getServerName(); + String ubuntuUsername = request.getUbuntuUsername(); + User user = request.getUser(); - List requests = requestRepository.findAllByExpiresAtBetweenAndStatus(startOfDay, endOfDay, Status.FULFILLED); + // 1. 외부 계정 삭제 + ubuntuAccountService.deleteUbuntuAccount(ubuntuUsername); - if (!requests.isEmpty()) { - log.info("[{}] 후 만료 예정인 계정 {}건 발견.", daysRemaining, requests.size()); + // 2. UID 반환 + UsedId usedId = request.getUbuntuUid(); + if (usedId != null) { + request.assignUbuntuUid(null); + idAllocationService.releaseId(usedId); } + // 3. DB Soft Delete + request.delete(); + + // 4. 이벤트 발행 (트랜잭션 커밋 후 리스너 실행) + // 성공 시 알림은 리스너에게 위임 + eventPublisher.publishEvent(new RequestExpiredEvent(user, ubuntuUsername, serverName)); + + log.info("삭제 트랜잭션 성공: {}", ubuntuUsername); + } + + @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 subject = String.format("[DGU AI LAB] 서버 사용 만료 %s 전 안내", daysRemaining); + String serverName = request.getResourceGroup().getServerName(); + String expireDate = request.getExpiresAt().toLocalDate().toString(); + + // 삭제 예정임을 명시 + String subject = String.format("[DGU AI LAB] 서버 계정 삭제 예정 안내 (%s 전)", dayLabel); String message = String.format( """ - %s님의 서버 사용 기간이 %s 후 (%s) 만료될 예정입니다. + 안녕하세요, %s님. + + 사용 중인 GPU 서버 계정이 %s 후 (%s)에 만료되어 삭제될 예정입니다. - - Ubuntu 사용자 이름: %s - - 할당된 서버: %s + - 서버: %s + - 계정: %s - 기간 연장이 필요하신 경우, 관리자 페이지에서 연장 신청을 해 주시기 바랍니다. - 별도 조치가 없을 시 계정은 자동 삭제됩니다. + 삭제된 데이터는 복구할 수 없으니, 중요한 데이터는 미리 백업해 주시기 바랍니다. + 연장이 필요하시면 관리자에게 문의하세요. """, - user.getName(), - daysRemaining, - request.getExpiresAt().toLocalDate().toString(), - request.getUbuntuUsername(), - request.getResourceGroup().getServerName() + user.getName(), dayLabel, expireDate, serverName, request.getUbuntuUsername() ); - // 1. 사용자에게 이메일 + 슬랙 DM + // 사용자에게만 전송 (이메일 + 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); - } catch (Exception e) { - log.error("만료 {}일 전 알림 전송 실패. Request ID: {}", daysRemaining, request.getRequestId(), e); + log.warn("{} 전 알림 실패: {}", dayLabel, e.getMessage()); } } } - /** - * 만료된 개별 Request를 트랜잭션 단위로 처리합니다. - */ - @Transactional - public void processSingleExpiredRequest(Long requestId) { - Request request = requestRepository.findById(requestId) - .orElseThrow(() -> new BusinessException("Request not found: " + requestId, ErrorCode.RESOURCE_NOT_FOUND)); - - if (request.getStatus() != Status.FULFILLED) { - log.warn("이미 처리되었거나 FULFILLED 상태가 아닌 Request. ID: {}, Status: {}", requestId, request.getStatus()); - return; - } - - User user = request.getUser(); - String username = request.getUbuntuUsername(); - UsedId usedId = request.getUbuntuUid(); - String serverName = request.getResourceGroup().getServerName(); - - // --- 트랜잭션 시작 --- - // 1. 실제 우분투 계정 및 PVC 삭제 요청 (외부 서버) - // 이 메서드가 실패하면 BusinessException을 발생시켜 트랜잭션이 롤백됨. - ubuntuAccountService.deleteUbuntuAccount(username); - log.info("외부 서버 계정/PVC 삭제 성공: {}", username); - - // 2. UsedId 반환 (DB에서 UsedId 삭제) - if (usedId != null) { - request.assignUbuntuUid(null); // 연관관계 제거 (Dirty checking) - idAllocationService.releaseId(usedId); - log.info("UID 반환 성공: {}", usedId.getIdValue()); - } + private void sendFailureAlertToAdmin(String serverName, String username, String errorMsg) { + try { + // 관리자에게 Lab/Farm 구분하여 실패 알림 + String type = getServerType(serverName); + String msg = String.format("🚨 [%s] 리소스 삭제 실패!\n- 서버: %s\n- 계정: %s\n- 원인: %s", + type, serverName, username, errorMsg); - // 3. Request 상태 DELETED로 변경 (Soft delete) - request.delete(); - log.info("Request 상태 DELETED로 변경: {}", username); + alarmService.sendAdminSlackNotification(serverName, msg); + } catch (Exception ignored) {} + } - // --- 트랜잭션 커밋 --- - // 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); - } + // Lab/Farm 구분 헬퍼 메서드 + 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..1856297 --- /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 확인 +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/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/global/config/RedisConfig.java b/src/main/java/DGU_AI_LAB/admin_be/global/config/RedisConfig.java new file mode 100644 index 0000000..3771abb --- /dev/null +++ b/src/main/java/DGU_AI_LAB/admin_be/global/config/RedisConfig.java @@ -0,0 +1,22 @@ +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.Jackson2JsonRedisSerializer; +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, Value는 JSON 형태로 저장 + template.setKeySerializer(new StringRedisSerializer()); + template.setValueSerializer(new Jackson2JsonRedisSerializer<>(Object.class)); + 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/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..e31be4e --- /dev/null +++ b/src/test/java/DGU_AI_LAB/admin_be/domain/scheduler/RequestSchedulerServiceTest.java @@ -0,0 +1,207 @@ +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 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; + + +/** + * 작동하지 않을 시 ./gradlew clean test 시도해보세요. + */ +@SpringBootTest(classes = AdminBeApplication.class) +@DirtiesContext(classMode = DirtiesContext.ClassMode.AFTER_EACH_TEST_METHOD) +class RequestSchedulerServiceTest { + + @Autowired + private RequestSchedulerService requestSchedulerService; + + // --- 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 생성 + 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()); + + // 3. 시나리오별 Request 생성 + // (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일 남은 요청 (3일 뒤 만료) + Request req3Day = createTestRequest(MOCK_NOW.plusDays(3).withHour(14), Status.FULFILLED, uid3Day, "user-3day", testUser, testRg, testImage); + + // (4) 7일 남은 요청 (7일 뒤 만료) + Request req7Day = createTestRequest(MOCK_NOW.plusDays(7).withHour(15), Status.FULFILLED, uid7Day, "user-7day", testUser, testRg, testImage); + + // (5) 아직 넉넉한 요청 + Request reqOk = 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); + + // --- When: 스케줄러 실행 --- + requestSchedulerService.runScheduler(); + } + + // --- Then: 검증 --- + + // 1. [삭제 검증] reqExpired + Request deletedResult = requestRepository.findById(reqExpired.getRequestId()).orElseThrow(); + assertThat(deletedResult.getStatus()).isEqualTo(Status.DELETED); + assertThat(deletedResult.getUbuntuUid()).isNull(); + + verify(ubuntuAccountService, times(1)).deleteUbuntuAccount("user-expired"); + + // [이벤트 리스너 검증] -> 삭제 완료 알림 + verify(alarmService).sendAllAlerts( + eq(testUser.getName()), + eq(testUser.getEmail()), + contains("삭제 완료 안내"), + contains("삭제되었습니다") + ); + + verify(alarmService).sendAdminSlackNotification( + eq("FARM-01"), + contains("삭제 완료") + ); + + + // 2. [알림 검증] 1일 전 (req1Day) + Request result1Day = requestRepository.findById(req1Day.getRequestId()).get(); + assertThat(result1Day.getStatus()).isEqualTo(Status.FULFILLED); + verify(alarmService).sendAllAlerts( + eq(testUser.getName()), + eq(testUser.getEmail()), + contains("안내 (1일 전)"), + contains("삭제될 예정") + ); + + + // 3. [알림 검증] 3일 전 (req3Day) + Request result3Day = requestRepository.findById(req3Day.getRequestId()).get(); + assertThat(result3Day.getStatus()).isEqualTo(Status.FULFILLED); + verify(alarmService).sendAllAlerts( + eq(testUser.getName()), + eq(testUser.getEmail()), + contains("안내 (3일 전)"), + contains("삭제될 예정") + ); + + + // 4. [알림 검증] 7일 전 (req7Day) + Request result7Day = requestRepository.findById(req7Day.getRequestId()).get(); + assertThat(result7Day.getStatus()).isEqualTo(Status.FULFILLED); + verify(alarmService).sendAllAlerts( + eq(testUser.getName()), + eq(testUser.getEmail()), + contains("안내 (7일 전)"), + contains("삭제될 예정") + ); + + + // 5. [총 호출 횟수 검증] + verify(alarmService, times(4)).sendAllAlerts(anyString(), anyString(), anyString(), anyString()); + verify(alarmService, times(1)).sendAdminSlackNotification(anyString(), anyString()); + } + + // --- Helper Method --- + 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/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