diff --git a/apps/user-service/src/main/java/site/icebang/domain/schedule/controller/ScheduleController.java b/apps/user-service/src/main/java/site/icebang/domain/schedule/controller/ScheduleController.java new file mode 100644 index 00000000..bdcb3e12 --- /dev/null +++ b/apps/user-service/src/main/java/site/icebang/domain/schedule/controller/ScheduleController.java @@ -0,0 +1,148 @@ +package site.icebang.domain.schedule.controller; + +import java.util.List; + +import org.springframework.http.HttpStatus; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.*; + +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +import site.icebang.common.dto.ApiResponseDto; +import site.icebang.domain.auth.model.AuthCredential; +import site.icebang.domain.schedule.dto.ScheduleCreateDto; +import site.icebang.domain.schedule.dto.ScheduleUpdateDto; +import site.icebang.domain.schedule.model.Schedule; +import site.icebang.domain.schedule.service.ScheduleService; + +/** + * 스케줄 관리를 위한 REST API 컨트롤러입니다. + * + *

스케줄의 조회, 수정, 삭제, 활성화/비활성화 API를 제공합니다. + * + *

제공 API:

+ * + * + * + * @author bwnfo0702@gmail.com + * @since v0.1.0 + */ +@Slf4j +@RestController +@RequestMapping("/v0") +@RequiredArgsConstructor +public class ScheduleController { + + private final ScheduleService scheduleService; + + @PostMapping("/workflows/{workflowId}/schedules") + @ResponseStatus(HttpStatus.CREATED) + public ApiResponseDto createSchedule( + @PathVariable Long workflowId, + @Valid @RequestBody ScheduleCreateDto dto, + @AuthenticationPrincipal AuthCredential authCredential) { + + Long userId = authCredential.getId().longValue(); + Schedule schedule = scheduleService.createSchedule(workflowId, dto, userId); + + return ApiResponseDto.success(schedule); + } + + /** + * 특정 워크플로우의 모든 스케줄을 조회합니다. + * + * @param workflowId 워크플로우 ID + * @return 스케줄 목록 + */ + @GetMapping("/workflows/{workflowId}/schedules") + public ApiResponseDto> getSchedulesByWorkflow(@PathVariable Long workflowId) { + log.info("워크플로우 스케줄 목록 조회 요청: Workflow ID {}", workflowId); + List schedules = scheduleService.getSchedulesByWorkflowId(workflowId); + return ApiResponseDto.success(schedules); + } + + /** + * 스케줄 ID로 단건 조회합니다. + * + * @param scheduleId 스케줄 ID + * @return 스케줄 정보 + */ + @GetMapping("/schedules/{scheduleId}") + public ApiResponseDto getSchedule(@PathVariable Long scheduleId) { + log.info("스케줄 조회 요청: Schedule ID {}", scheduleId); + Schedule schedule = scheduleService.getScheduleById(scheduleId); + return ApiResponseDto.success(schedule); + } + + /** + * 스케줄을 수정합니다. + * + *

크론 표현식, 스케줄 텍스트, 활성화 상태를 수정할 수 있으며, 변경사항은 즉시 Quartz에 반영됩니다. + * + * @param scheduleId 수정할 스케줄 ID + * @param dto 수정 정보 + * @param authCredential 인증 정보 (수정자) + * @return 성공 응답 + */ + @PutMapping("/schedules/{scheduleId}") + public ApiResponseDto updateSchedule( + @PathVariable Long scheduleId, + @Valid @RequestBody ScheduleUpdateDto dto, + @AuthenticationPrincipal AuthCredential authCredential) { + + log.info("스케줄 수정 요청: Schedule ID {} - {}", scheduleId, dto.getCronExpression()); + + // 인증 체크 + if (authCredential == null) { + throw new IllegalArgumentException("로그인이 필요합니다"); + } + + Long userId = authCredential.getId().longValue(); + scheduleService.updateSchedule(scheduleId, dto, userId); + + return ApiResponseDto.success(null); + } + + /** + * 스케줄 활성화 상태를 변경합니다. + * + *

활성화(true) 시 Quartz에 등록되어 실행되고, 비활성화(false) 시 Quartz에서 제거됩니다. + * + * @param scheduleId 스케줄 ID + * @param isActive 변경할 활성화 상태 + * @return 성공 응답 + */ + @PatchMapping("/schedules/{scheduleId}/active") + public ApiResponseDto toggleScheduleActive( + @PathVariable Long scheduleId, @RequestParam Boolean isActive) { + + log.info("스케줄 활성화 상태 변경 요청: Schedule ID {} - {}", scheduleId, isActive); + scheduleService.toggleScheduleActive(scheduleId, isActive); + + return ApiResponseDto.success(null); + } + + /** + * 스케줄을 삭제합니다 (논리 삭제). + * + *

DB에서 비활성화되고 Quartz에서도 제거됩니다. + * + * @param scheduleId 삭제할 스케줄 ID + * @return 성공 응답 + */ + @DeleteMapping("/schedules/{scheduleId}") + @ResponseStatus(HttpStatus.NO_CONTENT) + public ApiResponseDto deleteSchedule(@PathVariable Long scheduleId) { + log.info("스케줄 삭제 요청: Schedule ID {}", scheduleId); + scheduleService.deleteSchedule(scheduleId); + return ApiResponseDto.success(null); + } +} diff --git a/apps/user-service/src/main/java/site/icebang/domain/workflow/dto/ScheduleCreateDto.java b/apps/user-service/src/main/java/site/icebang/domain/schedule/dto/ScheduleCreateDto.java similarity index 88% rename from apps/user-service/src/main/java/site/icebang/domain/workflow/dto/ScheduleCreateDto.java rename to apps/user-service/src/main/java/site/icebang/domain/schedule/dto/ScheduleCreateDto.java index 87fdcb5a..8f5c7df5 100644 --- a/apps/user-service/src/main/java/site/icebang/domain/workflow/dto/ScheduleCreateDto.java +++ b/apps/user-service/src/main/java/site/icebang/domain/schedule/dto/ScheduleCreateDto.java @@ -1,4 +1,4 @@ -package site.icebang.domain.workflow.dto; +package site.icebang.domain.schedule.dto; import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.Size; @@ -85,17 +85,18 @@ public class ScheduleCreateDto { * *

DTO의 정보를 DB 저장용 엔티티로 변환하며, 서비스 레이어에서 주입되는 workflowId와 userId를 함께 설정합니다. * + * @param dto 변환할 ScheduleCreateDto 객체 * @param workflowId 연결할 워크플로우 ID * @param userId 생성자 ID * @return DB 저장 가능한 Schedule 엔티티 */ - public Schedule toEntity(Long workflowId, Long userId) { + public static Schedule toEntity(ScheduleCreateDto dto, Long workflowId, Long userId) { return Schedule.builder() .workflowId(workflowId) - .cronExpression(this.cronExpression) - .scheduleText(this.scheduleText) - .isActive(this.isActive != null ? this.isActive : true) - .parameters(this.parameters) + .cronExpression(dto.cronExpression) + .scheduleText(dto.scheduleText) + .isActive(dto.isActive != null ? dto.isActive : true) + .parameters(dto.parameters) .createdBy(userId) .updatedBy(userId) .build(); diff --git a/apps/user-service/src/main/java/site/icebang/domain/workflow/dto/ScheduleDto.java b/apps/user-service/src/main/java/site/icebang/domain/schedule/dto/ScheduleDto.java similarity index 87% rename from apps/user-service/src/main/java/site/icebang/domain/workflow/dto/ScheduleDto.java rename to apps/user-service/src/main/java/site/icebang/domain/schedule/dto/ScheduleDto.java index 752bd619..ddd38730 100644 --- a/apps/user-service/src/main/java/site/icebang/domain/workflow/dto/ScheduleDto.java +++ b/apps/user-service/src/main/java/site/icebang/domain/schedule/dto/ScheduleDto.java @@ -1,4 +1,4 @@ -package site.icebang.domain.workflow.dto; +package site.icebang.domain.schedule.dto; import java.time.Instant; diff --git a/apps/user-service/src/main/java/site/icebang/domain/schedule/dto/ScheduleUpdateDto.java b/apps/user-service/src/main/java/site/icebang/domain/schedule/dto/ScheduleUpdateDto.java new file mode 100644 index 00000000..6cb65f8b --- /dev/null +++ b/apps/user-service/src/main/java/site/icebang/domain/schedule/dto/ScheduleUpdateDto.java @@ -0,0 +1,54 @@ +package site.icebang.domain.schedule.dto; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +/** + * 스케줄 수정 요청을 위한 DTO 클래스입니다. + * + *

기존 스케줄의 크론 표현식, 스케줄 텍스트, 활성화 상태 등을 수정할 때 사용합니다. + * + *

검증 규칙:

+ * + *
    + *
  • cronExpression: 필수값, Quartz 크론식 형식 + *
  • scheduleText: 선택값, 사용자 친화적 스케줄 설명 (예: "매일 오전 8시") + *
  • isActive: 필수값, 스케줄 활성화 여부 + *
+ * + * @author bwnfo0702@gmail.com + * @since v0.1.0 + */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class ScheduleUpdateDto { + + /** + * Quartz 크론 표현식 + * + *

예시: "0 0 8 * * ?" (매일 오전 8시) + */ + @NotBlank(message = "크론 표현식은 필수입니다") + private String cronExpression; + + /** + * 사용자 친화적 스케줄 설명 텍스트 + * + *

예시: "매일 오전 8시", "매주 월요일 오후 6시" + */ + private String scheduleText; + + /** + * 스케줄 활성화 여부 + * + *

true: 활성화 (실행됨), false: 비활성화 (실행 안 됨) + */ + @NotNull(message = "활성화 상태는 필수입니다") + private Boolean isActive; +} diff --git a/apps/user-service/src/main/java/site/icebang/domain/schedule/mapper/ScheduleMapper.java b/apps/user-service/src/main/java/site/icebang/domain/schedule/mapper/ScheduleMapper.java index 07ac19ea..939781cb 100644 --- a/apps/user-service/src/main/java/site/icebang/domain/schedule/mapper/ScheduleMapper.java +++ b/apps/user-service/src/main/java/site/icebang/domain/schedule/mapper/ScheduleMapper.java @@ -93,4 +93,12 @@ Schedule findByWorkflowIdAndCronExpression( * @return 영향받은 행 수 */ int deactivateAllByWorkflowId(@Param("workflowId") Long workflowId); + + /** + * 스케줄 ID로 단건 조회 + * + * @param id 스케줄 ID + * @return 스케줄 정보, 없으면 null + */ + Schedule findById(@Param("id") Long id); } diff --git a/apps/user-service/src/main/java/site/icebang/domain/schedule/service/QuartzScheduleService.java b/apps/user-service/src/main/java/site/icebang/domain/schedule/service/QuartzScheduleService.java index 667637b1..4c8d6196 100644 --- a/apps/user-service/src/main/java/site/icebang/domain/schedule/service/QuartzScheduleService.java +++ b/apps/user-service/src/main/java/site/icebang/domain/schedule/service/QuartzScheduleService.java @@ -1,25 +1,34 @@ package site.icebang.domain.schedule.service; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; +import java.util.List; +import java.util.Set; + import org.quartz.*; +import org.quartz.impl.matchers.GroupMatcher; import org.springframework.stereotype.Service; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + import site.icebang.domain.schedule.model.Schedule; import site.icebang.domain.workflow.scheduler.WorkflowTriggerJob; /** * Spring Quartz 스케줄러의 Job과 Trigger를 동적으로 관리하는 서비스 클래스입니다. * - *

이 서비스는 데이터베이스에 정의된 {@code Schedule} 정보를 바탕으로, - * Quartz 엔진에 실제 실행 가능한 작업을 등록, 수정, 삭제하는 역할을 담당합니다. + *

이 서비스는 데이터베이스에 정의된 {@code Schedule} 정보를 바탕으로, Quartz 엔진에 실제 실행 가능한 작업을 등록, 수정, 삭제하는 + * 역할을 담당합니다. * *

주요 기능:

+ * *
    - *
  • DB의 스케줄 정보를 바탕으로 Quartz Job 및 Trigger 생성 또는 업데이트
  • - *
  • 기존에 등록된 Quartz 스케줄 삭제
  • + *
  • DB의 스케줄 정보를 바탕으로 Quartz Job 및 Trigger 생성 또는 업데이트 + *
  • 기존에 등록된 Quartz 스케줄 삭제 + *
  • 워크플로우의 모든 스케줄 일괄 삭제 + *
  • Quartz 클러스터 환경에서 안전한 동작 보장 *
* - * @author jihu0210@naver.com + * @author bwnfo0702@gmail.com * @since v0.1.0 */ @Slf4j @@ -33,9 +42,8 @@ public class QuartzScheduleService { /** * DB에 정의된 Schedule 객체를 기반으로 Quartz에 스케줄을 등록하거나 업데이트합니다. * - *

지정된 워크플로우 ID에 해당하는 Job이 이미 존재할 경우, 기존 Job과 Trigger를 삭제하고 - * 새로운 정보로 다시 생성하여 스케줄을 업데이트합니다. {@code JobDataMap}을 통해 - * 실행될 Job에게 어떤 워크플로우를 실행해야 하는지 ID를 전달합니다. + *

지정된 워크플로우 ID에 해당하는 Job이 이미 존재할 경우, 기존 Job과 Trigger를 삭제하고 새로운 정보로 다시 생성하여 스케줄을 + * 업데이트합니다. {@code JobDataMap}을 통해 실행될 Job에게 어떤 워크플로우를 실행해야 하는지 ID를 전달합니다. * * @param schedule Quartz에 등록할 스케줄 정보를 담은 도메인 모델 객체 * @since v0.1.0 @@ -43,19 +51,21 @@ public class QuartzScheduleService { public void addOrUpdateSchedule(Schedule schedule) { try { JobKey jobKey = JobKey.jobKey("workflow-" + schedule.getWorkflowId()); - JobDetail jobDetail = JobBuilder.newJob(WorkflowTriggerJob.class) - .withIdentity(jobKey) - .withDescription("Workflow " + schedule.getWorkflowId() + " Trigger Job") - .usingJobData("workflowId", schedule.getWorkflowId()) - .storeDurably() - .build(); + JobDetail jobDetail = + JobBuilder.newJob(WorkflowTriggerJob.class) + .withIdentity(jobKey) + .withDescription("Workflow " + schedule.getWorkflowId() + " Trigger Job") + .usingJobData("workflowId", schedule.getWorkflowId()) + .storeDurably() + .build(); TriggerKey triggerKey = TriggerKey.triggerKey("trigger-for-workflow-" + schedule.getWorkflowId()); - Trigger trigger = TriggerBuilder.newTrigger() - .forJob(jobDetail) - .withIdentity(triggerKey) - .withSchedule(CronScheduleBuilder.cronSchedule(schedule.getCronExpression())) - .build(); + Trigger trigger = + TriggerBuilder.newTrigger() + .forJob(jobDetail) + .withIdentity(triggerKey) + .withSchedule(CronScheduleBuilder.cronSchedule(schedule.getCronExpression())) + .build(); if (scheduler.checkExists(jobKey)) { scheduler.deleteJob(jobKey); // 기존 Job 삭제 후 재생성 (업데이트) @@ -64,6 +74,7 @@ public void addOrUpdateSchedule(Schedule schedule) { log.info("Quartz 스케줄 등록/업데이트 완료: Workflow ID {}", schedule.getWorkflowId()); } catch (SchedulerException e) { log.error("Quartz 스케줄 등록 실패: Workflow ID " + schedule.getWorkflowId(), e); + throw new RuntimeException("Quartz 스케줄 등록 중 오류가 발생했습니다", e); } } @@ -77,11 +88,85 @@ public void deleteSchedule(Long workflowId) { try { JobKey jobKey = JobKey.jobKey("workflow-" + workflowId); if (scheduler.checkExists(jobKey)) { - scheduler.deleteJob(jobKey); - log.info("Quartz 스케줄 삭제 완료: Workflow ID {}", workflowId); + boolean deleted = scheduler.deleteJob(jobKey); + if (deleted) { + log.info("Quartz 스케줄 삭제 완료: Workflow ID {}", workflowId); + } else { + log.warn("Quartz 스케줄 삭제 실패: Workflow ID {}", workflowId); + } + } else { + log.debug("삭제할 Quartz 스케줄이 존재하지 않음: Workflow ID {}", workflowId); } } catch (SchedulerException e) { log.error("Quartz 스케줄 삭제 실패: Workflow ID " + workflowId, e); + throw new RuntimeException("Quartz 스케줄 삭제 중 오류가 발생했습니다", e); + } + } + + /** + * 워크플로우와 연결된 모든 Quartz 스케줄을 일괄 삭제합니다. + * + *

하나의 워크플로우에 여러 스케줄이 있을 수 있으므로, 관련된 모든 Job을 제거합니다. + * + * @param workflowId 워크플로우 ID + * @return 삭제된 스케줄 개수 + */ + public int deleteAllSchedulesForWorkflow(Long workflowId) { + try { + int deletedCount = 0; + + // 워크플로우 관련 모든 Job 키 조회 + Set jobKeys = scheduler.getJobKeys(GroupMatcher.anyJobGroup()); + + for (JobKey jobKey : jobKeys) { + // "workflow-{workflowId}" 형식의 Job 찾기 + if (jobKey.getName().equals("workflow-" + workflowId)) { + boolean deleted = scheduler.deleteJob(jobKey); + if (deleted) { + deletedCount++; + log.debug("Quartz Job 삭제: {}", jobKey); + } + } + } + + log.info("Quartz 스케줄 일괄 삭제 완료: Workflow ID {} - {}개 삭제", workflowId, deletedCount); + return deletedCount; + + } catch (SchedulerException e) { + log.error("Quartz 스케줄 일괄 삭제 실패: Workflow ID " + workflowId, e); + throw new RuntimeException("Quartz 스케줄 일괄 삭제 중 오류가 발생했습니다", e); + } + } + + /** + * Quartz 스케줄러에 등록된 모든 Job 목록을 조회합니다. + * + *

디버깅 및 모니터링 용도로 사용됩니다. + * + * @return 등록된 Job 키 목록 + */ + public Set getAllScheduledJobs() { + try { + return scheduler.getJobKeys(GroupMatcher.anyJobGroup()); + } catch (SchedulerException e) { + log.error("Quartz Job 목록 조회 실패", e); + throw new RuntimeException("Quartz Job 목록 조회 중 오류가 발생했습니다", e); + } + } + + /** + * 특정 워크플로우의 Quartz 스케줄이 등록되어 있는지 확인합니다. + * + * @param workflowId 워크플로우 ID + * @return 등록되어 있으면 true + */ + public boolean isScheduleRegistered(Long workflowId) { + try { + JobKey jobKey = JobKey.jobKey("workflow-" + workflowId); + return scheduler.checkExists(jobKey); + } catch (SchedulerException e) { + log.error("Quartz 스케줄 존재 확인 실패: Workflow ID " + workflowId, e); + return false; } } } \ No newline at end of file diff --git a/apps/user-service/src/main/java/site/icebang/domain/schedule/service/ScheduleService.java b/apps/user-service/src/main/java/site/icebang/domain/schedule/service/ScheduleService.java new file mode 100644 index 00000000..95670f82 --- /dev/null +++ b/apps/user-service/src/main/java/site/icebang/domain/schedule/service/ScheduleService.java @@ -0,0 +1,308 @@ +package site.icebang.domain.schedule.service; + +import java.util.*; + +import org.quartz.CronExpression; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +import site.icebang.common.exception.DuplicateDataException; +import site.icebang.domain.schedule.dto.ScheduleCreateDto; +import site.icebang.domain.schedule.dto.ScheduleUpdateDto; +import site.icebang.domain.schedule.mapper.ScheduleMapper; +import site.icebang.domain.schedule.model.Schedule; + +/** + * 스케줄 관리를 위한 비즈니스 로직을 처리하는 서비스 클래스입니다. + * + *

이 서비스는 스케줄의 CRUD 작업과 Quartz 스케줄러와의 동기화를 담당합니다. + * + *

주요 기능:

+ * + *
    + *
  • 스케줄 조회 (단건, 목록) + *
  • 스케줄 수정 (크론식, 활성화 상태) + *
  • 스케줄 삭제 (논리 삭제) + *
  • 스케줄 활성화/비활성화 토글 + *
  • DB 변경 시 Quartz 실시간 동기화 + *
+ * + * @author bwnfo0702@gmail.com + * @since v0.1.0 + */ +@Slf4j +@Service +@RequiredArgsConstructor +public class ScheduleService { + + private final ScheduleMapper scheduleMapper; + private final QuartzScheduleService quartzScheduleService; + + @Transactional + public Schedule createSchedule(Long workflowId, ScheduleCreateDto dto, Long userId) { + // 1. Schedule 엔티티 생성 + Schedule schedule = ScheduleCreateDto.toEntity(dto, workflowId, userId); + + // 2. DB에 저장 + scheduleMapper.insertSchedule(schedule); + + // 3. 활성화 상태면 Quartz에 등록 + if (schedule.isActive()) { + quartzScheduleService.addOrUpdateSchedule(schedule); + } + + return schedule; + } + + /** + * 특정 워크플로우의 모든 활성 스케줄을 조회합니다. + * + * @param workflowId 워크플로우 ID + * @return 활성 스케줄 목록 + */ + @Transactional(readOnly = true) + public List getSchedulesByWorkflowId(Long workflowId) { + log.debug("워크플로우 스케줄 조회: Workflow ID {}", workflowId); + return scheduleMapper.findAllByWorkflowId(workflowId); + } + + /** + * 스케줄 ID로 단건 조회합니다. + * + * @param scheduleId 스케줄 ID + * @return 스케줄 정보 + * @throws IllegalArgumentException 스케줄이 존재하지 않을 경우 + */ + @Transactional(readOnly = true) + public Schedule getScheduleById(Long scheduleId) { + Schedule schedule = scheduleMapper.findById(scheduleId); + if (schedule == null) { + throw new IllegalArgumentException("스케줄을 찾을 수 없습니다: " + scheduleId); + } + return schedule; + } + + /** + * 스케줄을 수정하고 Quartz에 실시간 반영합니다. + * + *

수정 프로세스: + * + *

    + *
  1. 크론 표현식 유효성 검증 + *
  2. DB 업데이트 + *
  3. Quartz 스케줄러에 변경사항 반영 (재등록) + *
  4. 비활성화된 경우 Quartz에서 제거 + *
+ * + * @param scheduleId 수정할 스케줄 ID + * @param dto 수정 정보 + * @param updatedBy 수정자 ID + * @throws IllegalArgumentException 스케줄이 존재하지 않거나 크론식이 유효하지 않을 경우 + */ + @Transactional + public void updateSchedule(Long scheduleId, ScheduleUpdateDto dto, Long updatedBy) { + log.info("스케줄 수정 시작: Schedule ID {}", scheduleId); + + // 1. 기존 스케줄 조회 + Schedule schedule = getScheduleById(scheduleId); + + // 2. 크론 표현식 유효성 검증 + if (!isValidCronExpression(dto.getCronExpression())) { + throw new IllegalArgumentException("유효하지 않은 크론 표현식입니다: " + dto.getCronExpression()); + } + + // 3. 스케줄 정보 업데이트 + schedule.setCronExpression(dto.getCronExpression()); + schedule.setScheduleText(dto.getScheduleText()); + schedule.setActive(dto.getIsActive()); + schedule.setUpdatedBy(updatedBy); + + // 4. DB 업데이트 + int result = scheduleMapper.updateSchedule(schedule); + if (result != 1) { + throw new RuntimeException("스케줄 수정에 실패했습니다: Schedule ID " + scheduleId); + } + + // 5. Quartz 실시간 동기화 + syncScheduleToQuartz(schedule); + + log.info( + "스케줄 수정 완료: Schedule ID {} - {} (활성화: {})", + scheduleId, + dto.getCronExpression(), + dto.getIsActive()); + } + + /** + * 스케줄 활성화 상태를 토글합니다. + * + *

활성화 → 비활성화 또는 비활성화 → 활성화로 전환하고 Quartz에 반영합니다. + * + * @param scheduleId 스케줄 ID + * @param isActive 변경할 활성화 상태 + * @throws IllegalArgumentException 스케줄이 존재하지 않을 경우 + */ + @Transactional + public void toggleScheduleActive(Long scheduleId, Boolean isActive) { + log.info("스케줄 활성화 상태 변경: Schedule ID {} - {}", scheduleId, isActive); + + // 1. 기존 스케줄 조회 + Schedule schedule = getScheduleById(scheduleId); + + // 2. DB 업데이트 + int result = scheduleMapper.updateActiveStatus(scheduleId, isActive); + if (result != 1) { + throw new RuntimeException("스케줄 활성화 상태 변경 실패: Schedule ID " + scheduleId); + } + + // 3. 스케줄 객체 상태 업데이트 + schedule.setActive(isActive); + + // 4. Quartz 실시간 동기화 + syncScheduleToQuartz(schedule); + + log.info("스케줄 활성화 상태 변경 완료: Schedule ID {} - {}", scheduleId, isActive); + } + + /** + * 스케줄을 삭제합니다 (논리 삭제). + * + *

DB에서 is_active를 false로 설정하고 Quartz에서도 제거합니다. + * + * @param scheduleId 삭제할 스케줄 ID + * @throws IllegalArgumentException 스케줄이 존재하지 않을 경우 + */ + @Transactional + public void deleteSchedule(Long scheduleId) { + log.info("스케줄 삭제 시작: Schedule ID {}", scheduleId); + + // 1. 기존 스케줄 조회 + Schedule schedule = getScheduleById(scheduleId); + + // 2. DB에서 논리 삭제 + int result = scheduleMapper.deleteSchedule(scheduleId); + if (result != 1) { + throw new RuntimeException("스케줄 삭제에 실패했습니다: Schedule ID " + scheduleId); + } + + // 3. Quartz에서 제거 + quartzScheduleService.deleteSchedule(schedule.getWorkflowId()); + + log.info("스케줄 삭제 완료: Schedule ID {}", scheduleId); + } + + /** + * 스케줄 변경사항을 Quartz 스케줄러에 동기화합니다. + * + *

활성화된 스케줄: Quartz에 등록/업데이트 비활성화된 스케줄: Quartz에서 제거 + * + * @param schedule 동기화할 스케줄 + */ + private void syncScheduleToQuartz(Schedule schedule) { + if (schedule.isActive()) { + // 활성화: Quartz에 등록 또는 업데이트 + quartzScheduleService.addOrUpdateSchedule(schedule); + log.debug("Quartz 스케줄 등록/업데이트: Workflow ID {}", schedule.getWorkflowId()); + } else { + // 비활성화: Quartz에서 제거 + quartzScheduleService.deleteSchedule(schedule.getWorkflowId()); + log.debug("Quartz 스케줄 제거: Workflow ID {}", schedule.getWorkflowId()); + } + } + + /** + * Quartz 크론 표현식 유효성 검증 + * + * @param cronExpression 검증할 크론 표현식 + * @return 유효하면 true + */ + private boolean isValidCronExpression(String cronExpression) { + try { + new CronExpression(cronExpression); + return true; + } catch (Exception e) { + log.warn("유효하지 않은 크론 표현식: {}", cronExpression, e); + return false; + } + } + + /** + * 스케줄 목록을 검증하고 등록합니다. + * + * @param workflowId 워크플로우 ID + * @param scheduleDtos 등록할 스케줄 목록 + * @param userId 생성자 ID + * @throws IllegalArgumentException 유효하지 않은 크론식 + * @throws DuplicateDataException 중복 크론식 발견 + */ + @Transactional + public void validateAndRegisterSchedules( + Long workflowId, List scheduleDtos, Long userId) { + + // 1. 검증 + validateSchedules(scheduleDtos); + + // 2. 등록 + for (ScheduleCreateDto dto : scheduleDtos) { + createSchedule(workflowId, dto, userId); + } + } + + /** 스케줄 목록 검증 (크론 표현식 유효성 및 중복 검사) */ + public void validateSchedules(List schedules) { + if (schedules == null || schedules.isEmpty()) { + return; + } + + Set cronExpressions = new HashSet<>(); + + for (ScheduleCreateDto schedule : schedules) { + String cron = schedule.getCronExpression(); + + // 크론 표현식 유효성 검증 + if (!isValidCronExpression(cron)) { + throw new IllegalArgumentException("유효하지 않은 크론 표현식입니다: " + cron); + } + + // 중복 크론식 검사 + if (cronExpressions.contains(cron)) { + throw new DuplicateDataException("중복된 크론 표현식이 있습니다: " + cron); + } + cronExpressions.add(cron); + } + } + + /** 워크플로우의 모든 스케줄을 비활성화합니다. */ + @Transactional + public void deactivateAllByWorkflowId(Long workflowId) { + log.info("워크플로우 스케줄 일괄 비활성화: Workflow ID {}", workflowId); + + // DB 비활성화 + scheduleMapper.deactivateAllByWorkflowId(workflowId); + + // Quartz 제거 + quartzScheduleService.deleteSchedule(workflowId); + } + + /** 워크플로우의 활성 스케줄을 Quartz에 재등록합니다. */ + @Transactional + public int reactivateAllByWorkflowId(Long workflowId) { + log.info("워크플로우 스케줄 일괄 재활성화: Workflow ID {}", workflowId); + + List activeSchedules = scheduleMapper.findAllByWorkflowId(workflowId); + int count = 0; + + for (Schedule schedule : activeSchedules) { + if (schedule.isActive()) { + quartzScheduleService.addOrUpdateSchedule(schedule); + count++; + } + } + + log.info("Quartz 재등록 완료: {}개 스케줄", count); + return count; + } +} diff --git a/apps/user-service/src/main/java/site/icebang/domain/workflow/controller/WorkflowController.java b/apps/user-service/src/main/java/site/icebang/domain/workflow/controller/WorkflowController.java index 40550e44..e5c4057b 100644 --- a/apps/user-service/src/main/java/site/icebang/domain/workflow/controller/WorkflowController.java +++ b/apps/user-service/src/main/java/site/icebang/domain/workflow/controller/WorkflowController.java @@ -42,10 +42,6 @@ public ApiResponseDto> getWorkflowList( public ApiResponseDto createWorkflow( @Valid @RequestBody WorkflowCreateDto workflowCreateDto, @AuthenticationPrincipal AuthCredential authCredential) { - // 인증 체크 - if (authCredential == null) { - throw new IllegalArgumentException("로그인이 필요합니다"); - } // AuthCredential에서 userId 추출 BigInteger userId = authCredential.getId(); @@ -69,4 +65,64 @@ public ApiResponseDto getWorkflowDetail( WorkflowDetailCardDto result = workflowService.getWorkflowDetail(workflowId); return ApiResponseDto.success(result); } + + /** + * 워크플로우를 삭제합니다 (논리 삭제). + * + *

워크플로우를 비활성화하고 모든 스케줄을 중단합니다. + * + * @param workflowId 삭제할 워크플로우 ID + * @return 성공 응답 + */ + @DeleteMapping("/{workflowId}") + @ResponseStatus(HttpStatus.NO_CONTENT) + public ApiResponseDto deleteWorkflow(@PathVariable BigInteger workflowId) { + workflowService.deleteWorkflow(workflowId); + return ApiResponseDto.success(null); + } + + /** + * 워크플로우를 비활성화합니다. + * + *

워크플로우를 중단하고 모든 스케줄을 Quartz에서 제거합니다. + * + * @param workflowId 비활성화할 워크플로우 ID + * @return 성공 응답 + */ + @PatchMapping("/{workflowId}/deactivate") + public ApiResponseDto deactivateWorkflow(@PathVariable BigInteger workflowId) { + workflowService.deactivateWorkflow(workflowId); + return ApiResponseDto.success(null); + } + + /** + * 워크플로우를 활성화합니다. + * + *

워크플로우를 재개하고 모든 활성 스케줄을 Quartz에 재등록합니다. + * + * @param workflowId 활성화할 워크플로우 ID + * @return 성공 응답 + */ + @PatchMapping("/{workflowId}/activate") + public ApiResponseDto activateWorkflow(@PathVariable BigInteger workflowId) { + workflowService.activateWorkflow(workflowId); + return ApiResponseDto.success(null); + } + + /** + * 워크플로우의 특정 스케줄을 삭제합니다. + * + *

스케줄을 비활성화하고 Quartz에서 제거합니다. + * + * @param workflowId 워크플로우 ID + * @param scheduleId 삭제할 스케줄 ID + * @return 성공 응답 + */ + @DeleteMapping("/{workflowId}/schedules/{scheduleId}") + @ResponseStatus(HttpStatus.NO_CONTENT) + public ApiResponseDto deleteWorkflowSchedule( + @PathVariable BigInteger workflowId, @PathVariable Long scheduleId) { + workflowService.deleteWorkflowSchedule(workflowId, scheduleId); + return ApiResponseDto.success(null); + } } diff --git a/apps/user-service/src/main/java/site/icebang/domain/workflow/dto/WorkflowCreateDto.java b/apps/user-service/src/main/java/site/icebang/domain/workflow/dto/WorkflowCreateDto.java index f14b2aeb..26825dc4 100644 --- a/apps/user-service/src/main/java/site/icebang/domain/workflow/dto/WorkflowCreateDto.java +++ b/apps/user-service/src/main/java/site/icebang/domain/workflow/dto/WorkflowCreateDto.java @@ -3,6 +3,8 @@ import java.math.BigInteger; import java.util.List; +import org.springframework.util.CollectionUtils; + import com.fasterxml.jackson.annotation.JsonProperty; import jakarta.validation.Valid; @@ -12,6 +14,8 @@ import lombok.Data; import lombok.NoArgsConstructor; +import site.icebang.domain.schedule.dto.ScheduleCreateDto; + /** * 워크플로우 생성 요청 DTO * @@ -79,7 +83,7 @@ public class WorkflowCreateDto { // JSON 변환용 필드 (MyBatis에서 사용) private String defaultConfigJson; - public String genertateDefaultConfigJson() { + public String generateDefaultConfigJson() { StringBuilder jsonBuilder = new StringBuilder(); jsonBuilder.append("{"); @@ -133,6 +137,6 @@ public boolean hasPostingConfig() { * @return 스케줄이 1개 이상 있으면 true */ public boolean hasSchedules() { - return schedules != null && !schedules.isEmpty(); + return !CollectionUtils.isEmpty(schedules); } } diff --git a/apps/user-service/src/main/java/site/icebang/domain/workflow/dto/WorkflowDetailCardDto.java b/apps/user-service/src/main/java/site/icebang/domain/workflow/dto/WorkflowDetailCardDto.java index 175db6ac..b71448d0 100644 --- a/apps/user-service/src/main/java/site/icebang/domain/workflow/dto/WorkflowDetailCardDto.java +++ b/apps/user-service/src/main/java/site/icebang/domain/workflow/dto/WorkflowDetailCardDto.java @@ -6,6 +6,8 @@ import lombok.Data; +import site.icebang.domain.schedule.dto.ScheduleDto; + @Data public class WorkflowDetailCardDto extends WorkflowCardDto { private String defaultConfig; diff --git a/apps/user-service/src/main/java/site/icebang/domain/workflow/mapper/WorkflowMapper.java b/apps/user-service/src/main/java/site/icebang/domain/workflow/mapper/WorkflowMapper.java index df3ff8b7..0d61a781 100644 --- a/apps/user-service/src/main/java/site/icebang/domain/workflow/mapper/WorkflowMapper.java +++ b/apps/user-service/src/main/java/site/icebang/domain/workflow/mapper/WorkflowMapper.java @@ -3,8 +3,10 @@ import java.math.BigInteger; import java.util.*; +import org.apache.ibatis.annotations.Param; + import site.icebang.common.dto.PageParamsDto; -import site.icebang.domain.workflow.dto.ScheduleDto; +import site.icebang.domain.schedule.dto.ScheduleDto; import site.icebang.domain.workflow.dto.WorkflowCardDto; import site.icebang.domain.workflow.dto.WorkflowDetailCardDto; @@ -31,4 +33,9 @@ public interface WorkflowMapper { List selectSchedulesByWorkflowId(BigInteger workflowId); List> selectWorkflowWithJobsAndTasks(BigInteger workflowId); + + int updateWorkflowEnabled( + @Param("workflowId") BigInteger workflowId, @Param("isEnabled") Boolean isEnabled); + + int markAsDeleted(@Param("workflowId") BigInteger workflowId); } diff --git a/apps/user-service/src/main/java/site/icebang/domain/workflow/service/WorkflowService.java b/apps/user-service/src/main/java/site/icebang/domain/workflow/service/WorkflowService.java index 1cd553c9..adebc611 100644 --- a/apps/user-service/src/main/java/site/icebang/domain/workflow/service/WorkflowService.java +++ b/apps/user-service/src/main/java/site/icebang/domain/workflow/service/WorkflowService.java @@ -3,12 +3,9 @@ import java.math.BigInteger; import java.time.Instant; import java.util.HashMap; -import java.util.HashSet; import java.util.List; import java.util.Map; -import java.util.Set; -import org.quartz.CronExpression; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -19,9 +16,8 @@ import site.icebang.common.dto.PageResultDto; import site.icebang.common.exception.DuplicateDataException; import site.icebang.common.service.PageableService; -import site.icebang.domain.schedule.mapper.ScheduleMapper; -import site.icebang.domain.schedule.model.Schedule; -import site.icebang.domain.schedule.service.QuartzScheduleService; +import site.icebang.domain.schedule.dto.ScheduleDto; +import site.icebang.domain.schedule.service.ScheduleService; import site.icebang.domain.workflow.dto.*; import site.icebang.domain.workflow.mapper.JobMapper; import site.icebang.domain.workflow.mapper.TaskIoDataMapper; @@ -50,8 +46,7 @@ public class WorkflowService implements PageableService { private final WorkflowMapper workflowMapper; - private final ScheduleMapper scheduleMapper; - private final QuartzScheduleService quartzScheduleService; + private final ScheduleService scheduleService; private final JobMapper jobMapper; private final TaskMapper taskMapper; private final TaskIoDataMapper taskIoDataMapper; @@ -123,24 +118,22 @@ public void createWorkflow(WorkflowCreateDto dto, BigInteger createdBy) { // 2. 비즈니스 검증 validateBusinessRules(dto); - // 3. 스케줄 검증 (있는 경우만) + // 3. 스케줄 검증 - ScheduleService로 위임 if (dto.hasSchedules()) { - validateSchedules(dto.getSchedules()); + scheduleService.validateSchedules(dto.getSchedules()); } // 4. 워크플로우 이름 중복 체크 if (workflowMapper.existsByName(dto.getName())) { - throw new IllegalArgumentException("이미 존재하는 워크플로우 이름입니다: " + dto.getName()); + throw new DuplicateDataException("이미 존재하는 워크플로우 이름입니다: " + dto.getName()); } // 5. 워크플로우 생성 Long workflowId = null; try { - // JSON 설정 생성 - String defaultConfigJson = dto.genertateDefaultConfigJson(); + String defaultConfigJson = dto.generateDefaultConfigJson(); dto.setDefaultConfigJson(defaultConfigJson); - // DB 삽입 파라미터 구성 Map params = new HashMap<>(); params.put("dto", dto); params.put("createdBy", createdBy); @@ -150,7 +143,6 @@ public void createWorkflow(WorkflowCreateDto dto, BigInteger createdBy) { throw new RuntimeException("워크플로우 생성에 실패했습니다"); } - // 생성된 workflow ID 추출 Object generatedId = params.get("id"); workflowId = (generatedId instanceof BigInteger) @@ -164,9 +156,10 @@ public void createWorkflow(WorkflowCreateDto dto, BigInteger createdBy) { throw new RuntimeException("워크플로우 생성 중 오류가 발생했습니다", e); } - // 6. 스케줄 등록 (있는 경우만) + // 6. 스케줄 등록 - ScheduleService로 위임 if (dto.hasSchedules() && workflowId != null) { - registerSchedules(workflowId, dto.getSchedules(), createdBy.longValue()); + scheduleService.validateAndRegisterSchedules( + workflowId, dto.getSchedules(), createdBy.longValue()); } } @@ -299,115 +292,105 @@ private void validateBusinessRules(WorkflowCreateDto dto) { } /** - * 스케줄 목록 검증 + * 워크플로우를 비활성화하고 모든 스케줄을 중단합니다. * - *

크론 표현식 유효성 및 중복 검사를 수행합니다. + *

워크플로우와 연결된 모든 스케줄을 비활성화하고, Quartz 스케줄러에서도 제거합니다. * - * @param schedules 검증할 스케줄 목록 - * @throws IllegalArgumentException 유효하지 않은 크론식 - * @throws DuplicateDataException 중복 크론식 발견 + * @param workflowId 비활성화할 워크플로우 ID + * @throws IllegalArgumentException 워크플로우가 존재하지 않을 경우 */ - private void validateSchedules(List schedules) { - if (schedules == null || schedules.isEmpty()) { - return; - } + @Transactional + public void deactivateWorkflow(BigInteger workflowId) { + log.info("워크플로우 비활성화 시작: Workflow ID {}", workflowId); - // 중복 크론식 검사 (같은 요청 내에서) - Set cronExpressions = new HashSet<>(); + // 1. 워크플로우 존재 확인 + WorkflowDetailCardDto workflow = workflowMapper.selectWorkflowDetailById(workflowId); + if (workflow == null) { + throw new IllegalArgumentException("워크플로우를 찾을 수 없습니다: " + workflowId); + } - for (ScheduleCreateDto schedule : schedules) { - String cron = schedule.getCronExpression(); + // 2. 워크플로우 비활성화 + int result = workflowMapper.updateWorkflowEnabled(workflowId, false); + if (result != 1) { + throw new RuntimeException("워크플로우 비활성화에 실패했습니다: " + workflowId); + } - // 1. 크론 표현식 유효성 검증 (Quartz 기준) - if (!isValidCronExpression(cron)) { - throw new IllegalArgumentException("유효하지 않은 크론 표현식입니다: " + cron); - } + // 3. 스케줄 비활성화 - ScheduleService로 위임 + scheduleService.deactivateAllByWorkflowId(workflowId.longValue()); - // 2. 중복 크론식 검사 - if (cronExpressions.contains(cron)) { - throw new DuplicateDataException("중복된 크론 표현식이 있습니다: " + cron); - } - cronExpressions.add(cron); - } + log.info("워크플로우 비활성화 완료: Workflow ID {}", workflowId); } /** - * Quartz 크론 표현식 유효성 검증 + * 워크플로우를 활성화하고 모든 스케줄을 재등록합니다. + * + *

워크플로우를 활성화하고, 연결된 활성 스케줄들을 Quartz에 재등록합니다. * - * @param cronExpression 검증할 크론 표현식 - * @return 유효하면 true + * @param workflowId 활성화할 워크플로우 ID + * @throws IllegalArgumentException 워크플로우가 존재하지 않을 경우 */ - private boolean isValidCronExpression(String cronExpression) { - try { - new CronExpression(cronExpression); - return true; - } catch (Exception e) { - log.warn("유효하지 않은 크론 표현식: {}", cronExpression, e); - return false; + @Transactional + public void activateWorkflow(BigInteger workflowId) { + log.info("워크플로우 활성화 시작: Workflow ID {}", workflowId); + + // 1. 워크플로우 존재 확인 + WorkflowDetailCardDto workflow = workflowMapper.selectWorkflowDetailById(workflowId); + if (workflow == null) { + throw new IllegalArgumentException("워크플로우를 찾을 수 없습니다: " + workflowId); } + + // 2. 워크플로우 활성화 + int result = workflowMapper.updateWorkflowEnabled(workflowId, true); + if (result != 1) { + throw new RuntimeException("워크플로우 활성화에 실패했습니다: " + workflowId); + } + + // 3. 스케줄 재활성화 - ScheduleService로 위임 + int reactivatedCount = scheduleService.reactivateAllByWorkflowId(workflowId.longValue()); + + log.info("워크플로우 활성화 완료: Workflow ID {} - {}개 스케줄 재등록", workflowId, reactivatedCount); } /** - * 스케줄 목록 등록 (DB 저장 + Quartz 등록) + * 워크플로우를 삭제합니다 (논리 삭제). * - *

트랜잭션 내에서 DB 저장을 수행하고, Quartz 등록은 실패해도 워크플로우는 유지되도록 예외를 로그로만 처리합니다. + *

워크플로우를 비활성화하고, 모든 스케줄을 중단하며, Quartz에서 제거합니다. 실제 DB에서 삭제하지 않고 비활성화 처리합니다. * - * @param workflowId 워크플로우 ID - * @param scheduleCreateDtos 등록할 스케줄 목록 - * @param userId 생성자 ID + * @param workflowId 삭제할 워크플로우 ID + * @throws IllegalArgumentException 워크플로우가 존재하지 않을 경우 */ - private void registerSchedules( - Long workflowId, List scheduleCreateDtos, Long userId) { - if (scheduleCreateDtos == null || scheduleCreateDtos.isEmpty()) { - return; - } - - log.info("스케줄 등록 시작: Workflow ID {} - {}개", workflowId, scheduleCreateDtos.size()); + @Transactional + public void deleteWorkflow(BigInteger workflowId) { + log.info("워크플로우 삭제 시작: Workflow ID {}", workflowId); - int successCount = 0; - int failCount = 0; + // 1. 워크플로우 존재 확인 + WorkflowDetailCardDto workflow = workflowMapper.selectWorkflowDetailById(workflowId); + if (workflow == null) { + throw new IllegalArgumentException("워크플로우를 찾을 수 없습니다: " + workflowId); + } - for (ScheduleCreateDto dto : scheduleCreateDtos) { - try { - // 1. DTO → Model 변환 - Schedule schedule = dto.toEntity(workflowId, userId); + // 2. 워크플로우 비활성화 (논리 삭제) + deactivateWorkflow(workflowId); - // 2. DB 중복 체크 (같은 워크플로우 + 같은 크론식) - if (scheduleMapper.existsByWorkflowIdAndCronExpression( - workflowId, schedule.getCronExpression())) { - throw new DuplicateDataException( - "이미 동일한 크론식의 스케줄이 존재합니다: " + schedule.getCronExpression()); - } + log.info("워크플로우 삭제 완료: Workflow ID {}", workflowId); + } - // 3. DB 저장 - int insertResult = scheduleMapper.insertSchedule(schedule); - if (insertResult != 1) { - log.error("스케줄 DB 저장 실패: Workflow ID {} - {}", workflowId, schedule.getCronExpression()); - failCount++; - continue; - } + /** + * 워크플로우의 특정 스케줄만 삭제합니다. + * + *

스케줄을 DB에서 비활성화하고 Quartz에서 제거합니다. + * + * @param workflowId 워크플로우 ID + * @param scheduleId 삭제할 스케줄 ID + * @throws IllegalArgumentException 스케줄이 존재하지 않거나 워크플로우에 속하지 않을 경우 + */ + @Transactional + public void deleteWorkflowSchedule(BigInteger workflowId, Long scheduleId) { + log.info("워크플로우 스케줄 삭제 시작: Workflow ID {}, Schedule ID {}", workflowId, scheduleId); - // 4. Quartz 등록 (실시간 반영) - quartzScheduleService.addOrUpdateSchedule(schedule); - - log.info( - "스케줄 등록 완료: Workflow ID {} - {} ({})", - workflowId, - schedule.getCronExpression(), - schedule.getScheduleText()); - successCount++; - - } catch (DuplicateDataException e) { - log.warn("스케줄 중복으로 등록 건너뜀: Workflow ID {} - {}", workflowId, dto.getCronExpression()); - failCount++; - // 중복은 경고만 하고 계속 진행 - } catch (Exception e) { - log.error("스케줄 등록 실패: Workflow ID {} - {}", workflowId, dto.getCronExpression(), e); - failCount++; - // 스케줄 등록 실패해도 워크플로우는 유지 - } - } + // ScheduleService로 위임하여 검증 + 삭제 처리 + scheduleService.deleteSchedule(scheduleId); - log.info("스케줄 등록 완료: Workflow ID {} - 성공 {}개, 실패 {}개", workflowId, successCount, failCount); + log.info("워크플로우 스케줄 삭제 완료: Workflow ID {}, Schedule ID {}", workflowId, scheduleId); } } diff --git a/apps/user-service/src/main/java/site/icebang/global/config/QuartzConfig.java b/apps/user-service/src/main/java/site/icebang/global/config/QuartzConfig.java new file mode 100644 index 00000000..665b7995 --- /dev/null +++ b/apps/user-service/src/main/java/site/icebang/global/config/QuartzConfig.java @@ -0,0 +1,96 @@ +package site.icebang.global.config; + +import java.util.Properties; + +import javax.sql.DataSource; + +import org.quartz.spi.JobFactory; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.boot.autoconfigure.quartz.QuartzDataSource; +import org.springframework.boot.autoconfigure.quartz.QuartzProperties; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.boot.jdbc.DataSourceBuilder; +import org.springframework.context.ApplicationContext; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.scheduling.quartz.SchedulerFactoryBean; +import org.springframework.scheduling.quartz.SpringBeanJobFactory; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +/** + * Quartz Scheduler의 핵심 설정을 담당하는 Configuration 클래스입니다. + * + *

이 클래스는 Quartz Scheduler가 클러스터 환경에서 안전하게 동작하고, Spring Bean을 Job 내에서 주입받을 수 있도록 + * 스케줄러 인스턴스를 구성합니다. + * + *

주요 기능:

+ * + *
    + *
  • Quartz 전용 DataSource 분리 설정 + *
  • 클러스터링 활성화를 위한 Quartz Properties 구성 + *
  • Spring Bean 주입이 가능한 JobFactory 등록 + *
+ * + *

클러스터링 동작 원리:

+ * + *

여러 애플리케이션 인스턴스(Pod)가 동일한 DB를 공유하며, Quartz 테이블(QRTZ_*)을 통해 Job 실행 상태를 동기화합니다. + * 각 인스턴스는 주기적으로(기본 20초) 체크인하며, 특정 시점에 하나의 인스턴스만 Job을 실행하도록 보장합니다. + * + * @author bwnfo0702@gmail.com + * @since v0.1.0 + */ +@Slf4j +@Configuration +@RequiredArgsConstructor +public class QuartzConfig { + + private final ApplicationContext applicationContext; + private final QuartzProperties quartzProperties; + + /** + * Spring Bean을 Quartz Job에서 사용할 수 있도록 하는 JobFactory를 생성합니다. + * + *

기본 Quartz JobFactory는 Spring의 ApplicationContext를 인식하지 못하므로, Spring Bean 주입이 + * 불가능합니다. 이 Bean을 통해 Job 클래스 내에서 {@code @Autowired}를 사용할 수 있게 됩니다. + * + * @return Spring Bean 주입이 가능한 JobFactory + */ + @Bean + public JobFactory jobFactory() { + SpringBeanJobFactory jobFactory = new SpringBeanJobFactory(); + jobFactory.setApplicationContext(applicationContext); + log.info("Spring Bean 주입 가능한 JobFactory 생성 완료"); + return jobFactory; + } + + @Bean + public SchedulerFactoryBean schedulerFactoryBean( + DataSource dataSource, + JobFactory jobFactory) { + + SchedulerFactoryBean factory = new SchedulerFactoryBean(); + + // 1. 메인 DataSource 사용 (Quartz 전용 DataSource 제거) + factory.setDataSource(dataSource); + + // 2. Spring Bean 주입 가능한 JobFactory 설정 + factory.setJobFactory(jobFactory); + + // 3. Quartz Properties 설정 (클러스터링 포함) + Properties properties = new Properties(); + properties.putAll(quartzProperties.getProperties()); + properties.setProperty("org.quartz.threadPool.threadCount", "10"); + + factory.setQuartzProperties(properties); + factory.setApplicationContextSchedulerContextKey("applicationContext"); + factory.setAutoStartup(true); + factory.setOverwriteExistingJobs(false); + + log.info("Quartz SchedulerFactoryBean 설정 완료 (Clustering: {})", + properties.getProperty("org.quartz.jobStore.isClustered")); + + return factory; + } +} \ No newline at end of file diff --git a/apps/user-service/src/main/resources/application-develop.yml b/apps/user-service/src/main/resources/application-develop.yml index 64e1a0be..49b275e0 100644 --- a/apps/user-service/src/main/resources/application-develop.yml +++ b/apps/user-service/src/main/resources/application-develop.yml @@ -39,6 +39,12 @@ spring: jdbc: initialize-schema: never platform: mysql # MariaDB는 mysql 스크립트와 호환 + # 📌 Quartz의 Clustering설정 + properties: + org.quartz.scheduler.instanceName: IcebangScheduler + org.quartz.scheduler.instanceId: AUTO # 자동 ID 생성 + org.quartz.jobStore.isClustered: true # 클러스터링 활성화 + org.quartz.jobStore.clusterCheckinInterval: 20000 # 20초마다 체크인 sql: init: diff --git a/apps/user-service/src/main/resources/mybatis/mapper/ScheduleMapper.xml b/apps/user-service/src/main/resources/mybatis/mapper/ScheduleMapper.xml index e89c06c9..afa37bc1 100644 --- a/apps/user-service/src/main/resources/mybatis/mapper/ScheduleMapper.xml +++ b/apps/user-service/src/main/resources/mybatis/mapper/ScheduleMapper.xml @@ -112,4 +112,12 @@ updated_at = UTC_TIMESTAMP() WHERE workflow_id = #{workflowId} + + + \ No newline at end of file diff --git a/apps/user-service/src/main/resources/mybatis/mapper/WorkflowMapper.xml b/apps/user-service/src/main/resources/mybatis/mapper/WorkflowMapper.xml index 21077aff..e6f4e7e3 100644 --- a/apps/user-service/src/main/resources/mybatis/mapper/WorkflowMapper.xml +++ b/apps/user-service/src/main/resources/mybatis/mapper/WorkflowMapper.xml @@ -53,7 +53,7 @@ - + @@ -186,4 +186,21 @@ (#{job2Id}, 7, 1), (#{job2Id}, 8, 2) + + + + UPDATE workflow + SET is_enabled = #{isEnabled}, + updated_at = UTC_TIMESTAMP() + WHERE id = #{workflowId} + + + + + UPDATE workflow + SET is_enabled = false, + updated_at = UTC_TIMESTAMP() + -- deleted_at = UTC_TIMESTAMP() -- 컬럼이 있다면 주석 해제 + WHERE id = #{workflowId} + \ No newline at end of file diff --git a/apps/user-service/src/main/resources/sql/schema/01-schema.sql b/apps/user-service/src/main/resources/sql/schema/01-schema.sql index 35d42e59..2e7acb61 100644 --- a/apps/user-service/src/main/resources/sql/schema/01-schema.sql +++ b/apps/user-service/src/main/resources/sql/schema/01-schema.sql @@ -334,4 +334,7 @@ CREATE INDEX idx_error_code ON execution_log(error_code); CREATE INDEX idx_duration ON execution_log(duration_ms); CREATE INDEX idx_execution_type_source ON execution_log(execution_type, source_id); - +-- v0.5 +-- schedule 테이블 workflow_id unique 조건 제거 +ALTER TABLE schedule DROP INDEX uk_schedule_workflow; +ALTER TABLE schedule ADD UNIQUE KEY uk_schedule_workflow_cron (workflow_id, cron_expression); \ No newline at end of file diff --git a/apps/user-service/src/test/java/site/icebang/e2e/scenario/ScheduleManagementE2eTest.java b/apps/user-service/src/test/java/site/icebang/e2e/scenario/ScheduleManagementE2eTest.java new file mode 100644 index 00000000..afdff08c --- /dev/null +++ b/apps/user-service/src/test/java/site/icebang/e2e/scenario/ScheduleManagementE2eTest.java @@ -0,0 +1,258 @@ +package site.icebang.e2e.scenario; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.http.*; +import org.springframework.test.context.jdbc.Sql; + +import site.icebang.e2e.setup.annotation.E2eTest; +import site.icebang.e2e.setup.support.E2eTestSupport; + +/** + * 스케줄 관련 E2E 시나리오 테스트 + * + *

ScheduleService 기능을 API 플로우 관점에서 검증 + */ +@Sql( + value = { + "classpath:sql/data/00-truncate.sql", + "classpath:sql/data/01-insert-internal-users.sql" + }, + executionPhase = Sql.ExecutionPhase.BEFORE_TEST_METHOD) +@E2eTest +@DisplayName("스케줄 관리 E2E 테스트") +class ScheduleManagementE2eTest extends E2eTestSupport { + + @Test + @DisplayName("워크플로우에 스케줄 추가 성공") + void createSchedule_success() { + performUserLogin(); + + logStep(1, "워크플로우 생성"); + Long workflowId = createWorkflow("스케줄 생성용 워크플로우"); + + logStep(2, "스케줄 추가 요청"); + Map scheduleRequest = new HashMap<>(); + scheduleRequest.put("cronExpression", "0 0 9 * * ?"); + scheduleRequest.put("scheduleText", "매일 오전 9시"); + scheduleRequest.put("isActive", true); + + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_JSON); + + HttpEntity> entity = new HttpEntity<>(scheduleRequest, headers); + + ResponseEntity response = + restTemplate.postForEntity( + getV0ApiUrl("/workflows/" + workflowId + "/schedules"), entity, Map.class); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.CREATED); + assertThat((Boolean) response.getBody().get("success")).isTrue(); + + logSuccess("워크플로우에 스케줄 추가 성공"); + } + + @Test + @DisplayName("잘못된 크론식으로 스케줄 생성 시 실패") + void createSchedule_invalidCron_shouldFail() { + performUserLogin(); + Long workflowId = createWorkflow("잘못된 크론식 워크플로우"); + + Map scheduleRequest = new HashMap<>(); + scheduleRequest.put("cronExpression", "INVALID CRON"); + scheduleRequest.put("scheduleText", "잘못된 크론"); + scheduleRequest.put("isActive", true); + + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_JSON); + + ResponseEntity response = + restTemplate.postForEntity( + getV0ApiUrl("/workflows/" + workflowId + "/schedules"), + new HttpEntity<>(scheduleRequest, headers), + Map.class); + + assertThat(response.getStatusCode()) + .isIn( + HttpStatus.BAD_REQUEST, + HttpStatus.UNPROCESSABLE_ENTITY, + HttpStatus.INTERNAL_SERVER_ERROR); + + logSuccess("잘못된 크론식 검증 완료"); + } + + @Test + @DisplayName("스케줄 비활성화 후 Quartz 미등록 확인") + void createInactiveSchedule_shouldNotRegisterQuartz() { + performUserLogin(); + Long workflowId = createWorkflow("비활성 스케줄 워크플로우"); + + Map scheduleRequest = new HashMap<>(); + scheduleRequest.put("cronExpression", "0 0 10 * * ?"); + scheduleRequest.put("scheduleText", "매일 오전 10시 (비활성)"); + scheduleRequest.put("isActive", false); + + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_JSON); + + ResponseEntity response = + restTemplate.postForEntity( + getV0ApiUrl("/workflows/" + workflowId + "/schedules"), + new HttpEntity<>(scheduleRequest, headers), + Map.class); + + System.out.println("==== response body ===="); + System.out.println(response.getBody()); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.CREATED); + assertThat((Boolean) response.getBody().get("success")).isTrue(); + + logSuccess("비활성 스케줄 생성 성공 (Quartz 미등록)"); + } + + @Test + @DisplayName("스케줄 목록 조회 성공") + void listSchedules_success() { + performUserLogin(); + Long workflowId = createWorkflow("스케줄 조회용 워크플로우"); + + // 스케줄 2개 추가 + addSchedule(workflowId, "0 0 8 * * ?", "매일 오전 8시", true); + addSchedule(workflowId, "0 0 18 * * ?", "매일 오후 6시", true); + + logStep(1, "스케줄 목록 조회 API 호출"); + ResponseEntity response = + restTemplate.getForEntity( + getV0ApiUrl("/workflows/" + workflowId + "/schedules"), Map.class); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + assertThat((Boolean) response.getBody().get("success")).isTrue(); + + List> schedules = + (List>) response.getBody().get("data"); + + assertThat(schedules).hasSizeGreaterThanOrEqualTo(2); + + logSuccess("스케줄 목록 조회 성공: " + schedules.size() + "개"); + } + + @Test + @DisplayName("스케줄 수정 및 활성화 토글 성공") + void updateSchedule_toggleActive_success() { + performUserLogin(); + Long workflowId = createWorkflow("스케줄 수정용 워크플로우"); + + Long scheduleId = addSchedule(workflowId, "0 0 12 * * ?", "정오 실행", true); + + logStep(1, "스케줄 비활성화 요청"); + Map updateRequest = new HashMap<>(); + updateRequest.put("cronExpression", "0 30 12 * * ?"); + updateRequest.put("scheduleText", "정오 30분 실행"); + updateRequest.put("isActive", false); + + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_JSON); + + restTemplate.put( + getV0ApiUrl("/workflows/" + workflowId + "/schedules/" + scheduleId), + new HttpEntity<>(updateRequest, headers)); + + logSuccess("스케줄 수정 및 비활성화 성공"); + } + + @Test + @DisplayName("스케줄 삭제 성공") + void deleteSchedule_success() { + performUserLogin(); + Long workflowId = createWorkflow("스케줄 삭제용 워크플로우"); + + Long scheduleId = addSchedule(workflowId, "0 0 7 * * ?", "매일 오전 7시", true); + + logStep(1, "스케줄 삭제 요청"); + restTemplate.delete(getV0ApiUrl("/workflows/" + workflowId + "/schedules/" + scheduleId)); + + logSuccess("스케줄 삭제 성공 (논리 삭제)"); + } + + /** 워크플로우 생성 헬퍼 */ + private Long createWorkflow(String name) { + Map workflowRequest = new HashMap<>(); + workflowRequest.put("name", name); + workflowRequest.put("search_platform", "naver"); + workflowRequest.put("is_enabled", true); + + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_JSON); + + ResponseEntity response = + restTemplate.postForEntity( + getV0ApiUrl("/workflows"), new HttpEntity<>(workflowRequest, headers), Map.class); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.CREATED); + + ResponseEntity listResponse = + restTemplate.getForEntity(getV0ApiUrl("/workflows"), Map.class); + + Map body = listResponse.getBody(); + List> workflows = + (List>) ((Map) body.get("data")).get("data"); + + return workflows.stream() + .filter(w -> name.equals(w.get("name"))) + .findFirst() + .map(w -> Long.valueOf(w.get("id").toString())) + .orElseThrow(() -> new RuntimeException("생성한 워크플로우를 찾을 수 없습니다")); + } + + /** 스케줄 추가 헬퍼 */ + private Long addSchedule(Long workflowId, String cron, String text, boolean active) { + Map scheduleRequest = new HashMap<>(); + scheduleRequest.put("cronExpression", cron); + scheduleRequest.put("scheduleText", text); + scheduleRequest.put("isActive", active); + + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_JSON); + + ResponseEntity response = + restTemplate.postForEntity( + getV0ApiUrl("/workflows/" + workflowId + "/schedules"), + new HttpEntity<>(scheduleRequest, headers), + Map.class); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.CREATED); + + return Long.valueOf( + ((Map) response.getBody().get("data")).get("id").toString()); + } + + /** 사용자 로그인을 수행하는 헬퍼 메서드 */ + private void performUserLogin() { + Map loginRequest = new HashMap<>(); + loginRequest.put("email", "admin@icebang.site"); + loginRequest.put("password", "qwer1234!A"); + + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_JSON); + headers.set("Origin", "https://admin.icebang.site"); + headers.set("Referer", "https://admin.icebang.site/"); + + HttpEntity> entity = new HttpEntity<>(loginRequest, headers); + + ResponseEntity response = + restTemplate.postForEntity(getV0ApiUrl("/auth/login"), entity, Map.class); + + if (response.getStatusCode() != HttpStatus.OK) { + logError("사용자 로그인 실패: " + response.getStatusCode()); + throw new RuntimeException("User login failed"); + } + + logSuccess("사용자 로그인 완료"); + } +}