From 77b2ec35fba059aaefd511519769781f92793043 Mon Sep 17 00:00:00 2001 From: bwnfo3 Date: Mon, 29 Sep 2025 18:44:12 +0900 Subject: [PATCH 01/25] =?UTF-8?q?feat:=20applcation-develop.yml=EC=97=90?= =?UTF-8?q?=20quartz=EC=84=A4=EC=A0=95=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../user-service/src/main/resources/application-develop.yml | 6 ++++++ 1 file changed, 6 insertions(+) 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: From d0dc7d4df69e30841b076e2458b1d693a98dec89 Mon Sep 17 00:00:00 2001 From: bwnfo3 Date: Mon, 29 Sep 2025 18:44:43 +0900 Subject: [PATCH 02/25] feat: QuartzConfig --- .../icebang/global/config/QuartzConfig.java | 96 +++++++++++++++++++ 1 file changed, 96 insertions(+) create mode 100644 apps/user-service/src/main/java/site/icebang/global/config/QuartzConfig.java 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 내에서 주입받을 수 있도록 + * 스케줄러 인스턴스를 구성합니다. + * + *

주요 기능:

+ * + * + * + *

클러스터링 동작 원리:

+ * + *

여러 애플리케이션 인스턴스(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 From e36905272a9c75573f5824632f72911c8bd744e7 Mon Sep 17 00:00:00 2001 From: bwnfo3 Date: Mon, 29 Sep 2025 18:45:29 +0900 Subject: [PATCH 03/25] =?UTF-8?q?feat:=20Quartz=EC=97=90=20=EC=8A=A4?= =?UTF-8?q?=EC=BC=80=EC=A4=84=20=EB=8F=99=EA=B8=B0=ED=99=94=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../service/QuartzScheduleService.java | 131 +++++++++++++++--- 1 file changed, 108 insertions(+), 23 deletions(-) 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 엔진에 실제 실행 가능한 작업을 등록, 수정, 삭제하는 + * 역할을 담당합니다. * *

주요 기능:

+ * * * - * @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 From dccde186cf045b01f5be07ad794bf9fc43b47247 Mon Sep 17 00:00:00 2001 From: bwnfo3 Date: Mon, 29 Sep 2025 18:46:00 +0900 Subject: [PATCH 04/25] feat: ScheduleController --- .../controller/ScheduleController.java | 146 ++++++++++++++++++ 1 file changed, 146 insertions(+) create mode 100644 apps/user-service/src/main/java/site/icebang/domain/workflow/controller/ScheduleController.java diff --git a/apps/user-service/src/main/java/site/icebang/domain/workflow/controller/ScheduleController.java b/apps/user-service/src/main/java/site/icebang/domain/workflow/controller/ScheduleController.java new file mode 100644 index 00000000..a4e19f11 --- /dev/null +++ b/apps/user-service/src/main/java/site/icebang/domain/workflow/controller/ScheduleController.java @@ -0,0 +1,146 @@ +package site.icebang.domain.workflow.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.ApiResponse; +import site.icebang.domain.auth.model.AuthCredential; +import site.icebang.domain.schedule.model.Schedule; +import site.icebang.domain.schedule.service.ScheduleService; +import site.icebang.domain.workflow.dto.ScheduleCreateDto; +import site.icebang.domain.workflow.dto.ScheduleUpdateDto; + +/** + * 스케줄 관리를 위한 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") + public ApiResponse createSchedule( + @PathVariable Long workflowId, + @Valid @RequestBody ScheduleCreateDto dto, + @AuthenticationPrincipal AuthCredential authCredential) { + + Long userId = authCredential.getId().longValue(); + Schedule schedule = scheduleService.createSchedule(workflowId, dto, userId); + + return ApiResponse.success(schedule); + } + + /** + * 특정 워크플로우의 모든 스케줄을 조회합니다. + * + * @param workflowId 워크플로우 ID + * @return 스케줄 목록 + */ + @GetMapping("/workflows/{workflowId}/schedules") + public ApiResponse> getSchedulesByWorkflow(@PathVariable Long workflowId) { + log.info("워크플로우 스케줄 목록 조회 요청: Workflow ID {}", workflowId); + List schedules = scheduleService.getSchedulesByWorkflowId(workflowId); + return ApiResponse.success(schedules); + } + + /** + * 스케줄 ID로 단건 조회합니다. + * + * @param scheduleId 스케줄 ID + * @return 스케줄 정보 + */ + @GetMapping("/schedules/{scheduleId}") + public ApiResponse getSchedule(@PathVariable Long scheduleId) { + log.info("스케줄 조회 요청: Schedule ID {}", scheduleId); + Schedule schedule = scheduleService.getScheduleById(scheduleId); + return ApiResponse.success(schedule); + } + + /** + * 스케줄을 수정합니다. + * + *

크론 표현식, 스케줄 텍스트, 활성화 상태를 수정할 수 있으며, 변경사항은 즉시 Quartz에 반영됩니다. + * + * @param scheduleId 수정할 스케줄 ID + * @param dto 수정 정보 + * @param authCredential 인증 정보 (수정자) + * @return 성공 응답 + */ + @PutMapping("/schedules/{scheduleId}") + public ApiResponse 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 ApiResponse.success(null); + } + + /** + * 스케줄 활성화 상태를 변경합니다. + * + *

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

DB에서 비활성화되고 Quartz에서도 제거됩니다. + * + * @param scheduleId 삭제할 스케줄 ID + * @return 성공 응답 + */ + @DeleteMapping("/schedules/{scheduleId}") + @ResponseStatus(HttpStatus.NO_CONTENT) + public ApiResponse deleteSchedule(@PathVariable Long scheduleId) { + log.info("스케줄 삭제 요청: Schedule ID {}", scheduleId); + scheduleService.deleteSchedule(scheduleId); + return ApiResponse.success(null); + } +} \ No newline at end of file From 3909a1ab939a4ec86f6141fb4a54e70f95f34d12 Mon Sep 17 00:00:00 2001 From: bwnfo3 Date: Mon, 29 Sep 2025 18:46:38 +0900 Subject: [PATCH 05/25] =?UTF-8?q?feat:=20Schedule=20=EB=8B=A8=EA=B1=B4?= =?UTF-8?q?=EC=A1=B0=ED=9A=8C,=20=EC=8A=A4=EC=BC=80=EC=A4=84=20=ED=99=9C?= =?UTF-8?q?=EC=84=B1=EC=83=81=ED=83=9C=20=EB=B3=80=EA=B2=BD=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/schedule/mapper/ScheduleMapper.java | 17 +++++++++++++++++ .../resources/mybatis/mapper/ScheduleMapper.xml | 8 ++++++++ 2 files changed, 25 insertions(+) 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..3414e792 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,21 @@ Schedule findByWorkflowIdAndCronExpression( * @return 영향받은 행 수 */ int deactivateAllByWorkflowId(@Param("workflowId") Long workflowId); + + /** + * 스케줄 ID로 단건 조회 + * + * @param id 스케줄 ID + * @return 스케줄 정보, 없으면 null + */ + Schedule findById(@Param("id") Long id); + + /** + * 스케줄 활성화 상태만 변경 + * + * @param id 스케줄 ID + * @param isActive 활성화 상태 + * @return 업데이트된 행 수 + */ + int updateActiveStatus(@Param("id") Long id, @Param("isActive") Boolean isActive); } 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 From 4a01cb04cb605f9476e97192e55a18fa1a91b0a2 Mon Sep 17 00:00:00 2001 From: bwnfo3 Date: Mon, 29 Sep 2025 18:46:59 +0900 Subject: [PATCH 06/25] feat: ScheduleService --- .../schedule/service/ScheduleService.java | 235 ++++++++++++++++++ 1 file changed, 235 insertions(+) create mode 100644 apps/user-service/src/main/java/site/icebang/domain/schedule/service/ScheduleService.java 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..114bd658 --- /dev/null +++ b/apps/user-service/src/main/java/site/icebang/domain/schedule/service/ScheduleService.java @@ -0,0 +1,235 @@ +package site.icebang.domain.schedule.service; + +import java.util.List; + +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.domain.schedule.mapper.ScheduleMapper; +import site.icebang.domain.schedule.model.Schedule; +import site.icebang.domain.workflow.dto.ScheduleCreateDto; +import site.icebang.domain.workflow.dto.ScheduleDto; +import site.icebang.domain.workflow.dto.ScheduleUpdateDto; + +/** + * 스케줄 관리를 위한 비즈니스 로직을 처리하는 서비스 클래스입니다. + * + *

이 서비스는 스케줄의 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 = Schedule.builder() + .workflowId(workflowId) + .cronExpression(dto.getCronExpression()) + .scheduleText(dto.getScheduleText()) + .isActive(dto.getIsActive()) + .createdBy(userId) + .build(); + + // 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; + } + } +} \ No newline at end of file From 7a70c572a13c504083f49f9fa37d43887db3bf8a Mon Sep 17 00:00:00 2001 From: bwnfo3 Date: Mon, 29 Sep 2025 18:47:08 +0900 Subject: [PATCH 07/25] feat: ScheduleUpdateDto --- .../workflow/dto/ScheduleUpdateDto.java | 53 +++++++++++++++++++ 1 file changed, 53 insertions(+) create mode 100644 apps/user-service/src/main/java/site/icebang/domain/workflow/dto/ScheduleUpdateDto.java diff --git a/apps/user-service/src/main/java/site/icebang/domain/workflow/dto/ScheduleUpdateDto.java b/apps/user-service/src/main/java/site/icebang/domain/workflow/dto/ScheduleUpdateDto.java new file mode 100644 index 00000000..47868d76 --- /dev/null +++ b/apps/user-service/src/main/java/site/icebang/domain/workflow/dto/ScheduleUpdateDto.java @@ -0,0 +1,53 @@ +package site.icebang.domain.workflow.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; +} \ No newline at end of file From 294c1edac9c28e78ed75018a055d4bf8a12011d9 Mon Sep 17 00:00:00 2001 From: bwnfo3 Date: Mon, 29 Sep 2025 18:47:57 +0900 Subject: [PATCH 08/25] =?UTF-8?q?feat:=20workflowController=20delete,patch?= =?UTF-8?q?,=ED=99=9C=EC=84=B1=ED=99=94=EB=B3=80=EA=B2=BD,=20=EC=8A=A4?= =?UTF-8?q?=EC=BC=80=EC=A4=84=20=EC=82=AD=EC=A0=9C=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../controller/WorkflowController.java | 60 +++++++++++++++++++ 1 file changed, 60 insertions(+) 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 2bc388af..970c936e 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 @@ -68,4 +68,64 @@ public ApiResponse getWorkflowDetail(@PathVariable BigInt WorkflowDetailCardDto result = workflowService.getWorkflowDetail(workflowId); return ApiResponse.success(result); } + + /** + * 워크플로우를 삭제합니다 (논리 삭제). + * + *

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

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

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

스케줄을 비활성화하고 Quartz에서 제거합니다. + * + * @param workflowId 워크플로우 ID + * @param scheduleId 삭제할 스케줄 ID + * @return 성공 응답 + */ + @DeleteMapping("/{workflowId}/schedules/{scheduleId}") + @ResponseStatus(HttpStatus.NO_CONTENT) + public ApiResponse deleteWorkflowSchedule( + @PathVariable BigInteger workflowId, @PathVariable Long scheduleId) { + workflowService.deleteWorkflowSchedule(workflowId, scheduleId); + return ApiResponse.success(null); + } } From fd16ab83cb1cca202fab78ad269fbcc201b5c968 Mon Sep 17 00:00:00 2001 From: bwnfo3 Date: Mon, 29 Sep 2025 18:48:17 +0900 Subject: [PATCH 09/25] =?UTF-8?q?feat:=20workflowMapper=20=EC=9B=8C?= =?UTF-8?q?=ED=81=AC=ED=94=8C=EB=A1=9C=EC=9A=B0=20=ED=99=9C=EC=84=B1?= =?UTF-8?q?=ED=99=94=EC=83=81=ED=83=9C=20=EB=B3=80=EA=B2=BD=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/workflow/mapper/WorkflowMapper.java | 6 ++++++ .../resources/mybatis/mapper/WorkflowMapper.xml | 17 +++++++++++++++++ 2 files changed, 23 insertions(+) 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 417dfd1d..e64b17b7 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,6 +3,7 @@ import java.math.BigInteger; import java.util.*; +import org.apache.ibatis.annotations.Param; import site.icebang.common.dto.PageParams; import site.icebang.domain.workflow.dto.ScheduleDto; import site.icebang.domain.workflow.dto.WorkflowCardDto; @@ -31,4 +32,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/resources/mybatis/mapper/WorkflowMapper.xml b/apps/user-service/src/main/resources/mybatis/mapper/WorkflowMapper.xml index ea5a0d01..121813f6 100644 --- a/apps/user-service/src/main/resources/mybatis/mapper/WorkflowMapper.xml +++ b/apps/user-service/src/main/resources/mybatis/mapper/WorkflowMapper.xml @@ -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 From 05d40871b2f64316ff814a8acbee0d1800779ebc Mon Sep 17 00:00:00 2001 From: bwnfo3 Date: Mon, 29 Sep 2025 18:48:44 +0900 Subject: [PATCH 10/25] =?UTF-8?q?feat:=20Schedule=20Quartz=20=EC=8B=A4?= =?UTF-8?q?=EC=8B=9C=EA=B0=84=20=EB=B0=98=EC=98=81=20=EA=B4=80=EB=A0=A8=20?= =?UTF-8?q?=EB=82=B4=EC=9A=A9=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../workflow/service/WorkflowService.java | 173 +++++++++++++----- 1 file changed, 130 insertions(+), 43 deletions(-) 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 69a55002..d1cba5ad 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 @@ -22,6 +22,7 @@ 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.service.ScheduleService; import site.icebang.domain.workflow.dto.*; import site.icebang.domain.workflow.mapper.JobMapper; import site.icebang.domain.workflow.mapper.TaskMapper; @@ -50,6 +51,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; @@ -333,61 +335,146 @@ private boolean isValidCronExpression(String cronExpression) { *

트랜잭션 내에서 DB 저장을 수행하고, Quartz 등록은 실패해도 워크플로우는 유지되도록 예외를 로그로만 처리합니다. * * @param workflowId 워크플로우 ID - * @param scheduleCreateDtos 등록할 스케줄 목록 + * @param scheduleDtos 등록할 스케줄 목록 * @param userId 생성자 ID */ - private void registerSchedules( - Long workflowId, List scheduleCreateDtos, Long userId) { - if (scheduleCreateDtos == null || scheduleCreateDtos.isEmpty()) { - return; + @Transactional + public void registerSchedules(Long workflowId, List scheduleDtos, Long userId) { + for (ScheduleCreateDto dto : scheduleDtos) { + scheduleService.createSchedule(workflowId, dto, userId); } + } - log.info("스케줄 등록 시작: Workflow ID {} - {}개", workflowId, scheduleCreateDtos.size()); + /** + * 워크플로우를 비활성화하고 모든 스케줄을 중단합니다. + * + *

워크플로우와 연결된 모든 스케줄을 비활성화하고, Quartz 스케줄러에서도 제거합니다. + * + * @param workflowId 비활성화할 워크플로우 ID + * @throws IllegalArgumentException 워크플로우가 존재하지 않을 경우 + */ + @Transactional + public void deactivateWorkflow(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. 워크플로우 비활성화 + int result = workflowMapper.updateWorkflowEnabled(workflowId, false); + if (result != 1) { + throw new RuntimeException("워크플로우 비활성화에 실패했습니다: " + workflowId); + } - // 2. DB 중복 체크 (같은 워크플로우 + 같은 크론식) - if (scheduleMapper.existsByWorkflowIdAndCronExpression( - workflowId, schedule.getCronExpression())) { - throw new DuplicateDataException( - "이미 동일한 크론식의 스케줄이 존재합니다: " + schedule.getCronExpression()); - } + // 3. 연결된 모든 스케줄 비활성화 (DB) + scheduleMapper.deactivateAllByWorkflowId(workflowId.longValue()); - // 3. DB 저장 - int insertResult = scheduleMapper.insertSchedule(schedule); - if (insertResult != 1) { - log.error("스케줄 DB 저장 실패: Workflow ID {} - {}", workflowId, schedule.getCronExpression()); - failCount++; - continue; - } + // 4. Quartz에서 스케줄 제거 + quartzScheduleService.deleteSchedule(workflowId.longValue()); - // 4. Quartz 등록 (실시간 반영) - quartzScheduleService.addOrUpdateSchedule(schedule); + log.info("워크플로우 비활성화 완료: Workflow ID {}", workflowId); + } + + /** + * 워크플로우를 활성화하고 모든 스케줄을 재등록합니다. + * + *

워크플로우를 활성화하고, 연결된 활성 스케줄들을 Quartz에 재등록합니다. + * + * @param workflowId 활성화할 워크플로우 ID + * @throws IllegalArgumentException 워크플로우가 존재하지 않을 경우 + */ + @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. 연결된 활성 스케줄 조회 + List activeSchedules = scheduleMapper.findAllByWorkflowId(workflowId.longValue()); - 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++; - // 스케줄 등록 실패해도 워크플로우는 유지 + // 4. Quartz에 스케줄 재등록 + for (Schedule schedule : activeSchedules) { + if (schedule.isActive()) { + quartzScheduleService.addOrUpdateSchedule(schedule); + log.debug("스케줄 Quartz 재등록: Schedule ID {}", schedule.getId()); } } - log.info("스케줄 등록 완료: Workflow ID {} - 성공 {}개, 실패 {}개", workflowId, successCount, failCount); + log.info("워크플로우 활성화 완료: Workflow ID {} - {}개 스케줄 재등록", workflowId, activeSchedules.size()); + } + + /** + * 워크플로우를 삭제합니다 (논리 삭제). + * + *

워크플로우를 비활성화하고, 모든 스케줄을 중단하며, Quartz에서 제거합니다. 실제 DB에서 삭제하지 않고 비활성화 처리합니다. + * + * @param workflowId 삭제할 워크플로우 ID + * @throws IllegalArgumentException 워크플로우가 존재하지 않을 경우 + */ + @Transactional + public void deleteWorkflow(BigInteger workflowId) { + log.info("워크플로우 삭제 시작: Workflow ID {}", workflowId); + + // 1. 워크플로우 존재 확인 + WorkflowDetailCardDto workflow = workflowMapper.selectWorkflowDetailById(workflowId); + if (workflow == null) { + throw new IllegalArgumentException("워크플로우를 찾을 수 없습니다: " + workflowId); + } + + // 2. 워크플로우 비활성화 (논리 삭제) + deactivateWorkflow(workflowId); + + // 3. 추가로 삭제 플래그 설정 (선택사항: deleted_at 컬럼이 있다면) + // workflowMapper.markAsDeleted(workflowId); + + log.info("워크플로우 삭제 완료: Workflow ID {}", workflowId); + } + + /** + * 워크플로우의 특정 스케줄만 삭제합니다. + * + *

스케줄을 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); + + // 1. 스케줄 조회 및 검증 + Schedule schedule = scheduleMapper.findById(scheduleId); + if (schedule == null) { + throw new IllegalArgumentException("스케줄을 찾을 수 없습니다: " + scheduleId); + } + if (!schedule.getWorkflowId().equals(workflowId.longValue())) { + throw new IllegalArgumentException( + "스케줄이 해당 워크플로우에 속하지 않습니다: Schedule ID " + scheduleId); + } + + // 2. DB에서 스케줄 비활성화 + int result = scheduleMapper.deleteSchedule(scheduleId); + if (result != 1) { + throw new RuntimeException("스케줄 삭제에 실패했습니다: Schedule ID " + scheduleId); + } + + // 3. Quartz에서 스케줄 제거 + quartzScheduleService.deleteSchedule(workflowId.longValue()); + + log.info("워크플로우 스케줄 삭제 완료: Workflow ID {}, Schedule ID {}", workflowId, scheduleId); } } From b60c3c7889a922b905ce5d67974afade94133fec Mon Sep 17 00:00:00 2001 From: bwnfo3 Date: Mon, 29 Sep 2025 18:49:02 +0900 Subject: [PATCH 11/25] =?UTF-8?q?feat:=20ScheduleManagementE2eTest=20?= =?UTF-8?q?=EC=9E=91=EC=84=B1=EC=A4=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../scenario/ScheduleManagementE2eTest.java | 243 ++++++++++++++++++ 1 file changed, 243 insertions(+) create mode 100644 apps/user-service/src/test/java/site/icebang/e2e/scenario/ScheduleManagementE2eTest.java 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..993eae7c --- /dev/null +++ b/apps/user-service/src/test/java/site/icebang/e2e/scenario/ScheduleManagementE2eTest.java @@ -0,0 +1,243 @@ +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_CLASS) +@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 9 * * ?", "매일 오전 9시", 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(); + + Map data = (Map) response.getBody().get("data"); + List> schedules = (List>) data.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); + + System.out.println("==== response body ===="); + System.out.println(response.getBody()); + + 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 Long.valueOf(workflows.get(workflows.size() - 1).get("id").toString()); + } + + /** 스케줄 추가 헬퍼 */ + 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("사용자 로그인 완료"); + } +} From 79392f782cbfe26db587456ac7e5c3b55cf9f946 Mon Sep 17 00:00:00 2001 From: bwnfo3 Date: Mon, 29 Sep 2025 18:50:08 +0900 Subject: [PATCH 12/25] chore: spotlessApply --- .../schedule/service/ScheduleService.java | 370 +++++++++--------- .../controller/ScheduleController.java | 205 +++++----- .../controller/WorkflowController.java | 2 +- .../workflow/dto/ScheduleUpdateDto.java | 43 +- .../workflow/mapper/WorkflowMapper.java | 5 +- .../workflow/service/WorkflowService.java | 6 +- .../scenario/ScheduleManagementE2eTest.java | 58 +-- 7 files changed, 354 insertions(+), 335 deletions(-) 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 index 114bd658..d49e290d 100644 --- 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 @@ -12,7 +12,6 @@ import site.icebang.domain.schedule.mapper.ScheduleMapper; import site.icebang.domain.schedule.model.Schedule; import site.icebang.domain.workflow.dto.ScheduleCreateDto; -import site.icebang.domain.workflow.dto.ScheduleDto; import site.icebang.domain.workflow.dto.ScheduleUpdateDto; /** @@ -21,6 +20,7 @@ *

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

주요 기능:

+ * *
    *
  • 스케줄 조회 (단건, 목록) *
  • 스케줄 수정 (크론식, 활성화 상태) @@ -37,199 +37,201 @@ @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 = Schedule.builder() - .workflowId(workflowId) - .cronExpression(dto.getCronExpression()) - .scheduleText(dto.getScheduleText()) - .isActive(dto.getIsActive()) - .createdBy(userId) - .build(); - - // 2. DB에 저장 - scheduleMapper.insertSchedule(schedule); - - // 3. 활성화 상태면 Quartz에 등록 - if (schedule.isActive()) { - quartzScheduleService.addOrUpdateSchedule(schedule); - } - - return schedule; + private final ScheduleMapper scheduleMapper; + private final QuartzScheduleService quartzScheduleService; + + @Transactional + public Schedule createSchedule(Long workflowId, ScheduleCreateDto dto, Long userId) { + // 1. Schedule 엔티티 생성 + Schedule schedule = + Schedule.builder() + .workflowId(workflowId) + .cronExpression(dto.getCronExpression()) + .scheduleText(dto.getScheduleText()) + .isActive(dto.getIsActive()) + .createdBy(userId) + .build(); + + // 2. DB에 저장 + scheduleMapper.insertSchedule(schedule); + + // 3. 활성화 상태면 Quartz에 등록 + if (schedule.isActive()) { + quartzScheduleService.addOrUpdateSchedule(schedule); } - /** - * 특정 워크플로우의 모든 활성 스케줄을 조회합니다. - * - * @param workflowId 워크플로우 ID - * @return 활성 스케줄 목록 - */ - @Transactional(readOnly = true) - public List getSchedulesByWorkflowId(Long workflowId) { - log.debug("워크플로우 스케줄 조회: Workflow ID {}", workflowId); - return scheduleMapper.findAllByWorkflowId(workflowId); + 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); } - - /** - * 스케줄 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; + 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()); } - /** - * 스케줄을 수정하고 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()); - } + // 3. 스케줄 정보 업데이트 + schedule.setCronExpression(dto.getCronExpression()); + schedule.setScheduleText(dto.getScheduleText()); + schedule.setActive(dto.getIsActive()); + schedule.setUpdatedBy(updatedBy); - /** - * 스케줄 활성화 상태를 토글합니다. - * - *

    활성화 → 비활성화 또는 비활성화 → 활성화로 전환하고 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); + // 4. DB 업데이트 + int result = scheduleMapper.updateSchedule(schedule); + if (result != 1) { + throw new RuntimeException("스케줄 수정에 실패했습니다: Schedule ID " + scheduleId); } - /** - * 스케줄을 삭제합니다 (논리 삭제). - * - *

    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); + // 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); } - /** - * 스케줄 변경사항을 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()); - } + // 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); } - /** - * 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; - } + // 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; } -} \ No newline at end of file + } +} diff --git a/apps/user-service/src/main/java/site/icebang/domain/workflow/controller/ScheduleController.java b/apps/user-service/src/main/java/site/icebang/domain/workflow/controller/ScheduleController.java index a4e19f11..74c619c9 100644 --- a/apps/user-service/src/main/java/site/icebang/domain/workflow/controller/ScheduleController.java +++ b/apps/user-service/src/main/java/site/icebang/domain/workflow/controller/ScheduleController.java @@ -23,6 +23,7 @@ *

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

    제공 API:

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

      크론 표현식, 스케줄 텍스트, 활성화 상태를 수정할 수 있으며, 변경사항은 즉시 Quartz에 반영됩니다. + * + * @param scheduleId 수정할 스케줄 ID + * @param dto 수정 정보 + * @param authCredential 인증 정보 (수정자) + * @return 성공 응답 + */ + @PutMapping("/schedules/{scheduleId}") + public ApiResponse updateSchedule( + @PathVariable Long scheduleId, + @Valid @RequestBody ScheduleUpdateDto dto, + @AuthenticationPrincipal AuthCredential authCredential) { + + log.info("스케줄 수정 요청: Schedule ID {} - {}", scheduleId, dto.getCronExpression()); + + // 인증 체크 + if (authCredential == null) { + throw new IllegalArgumentException("로그인이 필요합니다"); } - /** - * 특정 워크플로우의 모든 스케줄을 조회합니다. - * - * @param workflowId 워크플로우 ID - * @return 스케줄 목록 - */ - @GetMapping("/workflows/{workflowId}/schedules") - public ApiResponse> getSchedulesByWorkflow(@PathVariable Long workflowId) { - log.info("워크플로우 스케줄 목록 조회 요청: Workflow ID {}", workflowId); - List schedules = scheduleService.getSchedulesByWorkflowId(workflowId); - return ApiResponse.success(schedules); - } - - /** - * 스케줄 ID로 단건 조회합니다. - * - * @param scheduleId 스케줄 ID - * @return 스케줄 정보 - */ - @GetMapping("/schedules/{scheduleId}") - public ApiResponse getSchedule(@PathVariable Long scheduleId) { - log.info("스케줄 조회 요청: Schedule ID {}", scheduleId); - Schedule schedule = scheduleService.getScheduleById(scheduleId); - return ApiResponse.success(schedule); - } - - /** - * 스케줄을 수정합니다. - * - *

      크론 표현식, 스케줄 텍스트, 활성화 상태를 수정할 수 있으며, 변경사항은 즉시 Quartz에 반영됩니다. - * - * @param scheduleId 수정할 스케줄 ID - * @param dto 수정 정보 - * @param authCredential 인증 정보 (수정자) - * @return 성공 응답 - */ - @PutMapping("/schedules/{scheduleId}") - public ApiResponse 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 ApiResponse.success(null); - } - - /** - * 스케줄 활성화 상태를 변경합니다. - * - *

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

      DB에서 비활성화되고 Quartz에서도 제거됩니다. - * - * @param scheduleId 삭제할 스케줄 ID - * @return 성공 응답 - */ - @DeleteMapping("/schedules/{scheduleId}") - @ResponseStatus(HttpStatus.NO_CONTENT) - public ApiResponse deleteSchedule(@PathVariable Long scheduleId) { - log.info("스케줄 삭제 요청: Schedule ID {}", scheduleId); - scheduleService.deleteSchedule(scheduleId); - return ApiResponse.success(null); - } -} \ No newline at end of file + Long userId = authCredential.getId().longValue(); + scheduleService.updateSchedule(scheduleId, dto, userId); + + return ApiResponse.success(null); + } + + /** + * 스케줄 활성화 상태를 변경합니다. + * + *

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

      DB에서 비활성화되고 Quartz에서도 제거됩니다. + * + * @param scheduleId 삭제할 스케줄 ID + * @return 성공 응답 + */ + @DeleteMapping("/schedules/{scheduleId}") + @ResponseStatus(HttpStatus.NO_CONTENT) + public ApiResponse deleteSchedule(@PathVariable Long scheduleId) { + log.info("스케줄 삭제 요청: Schedule ID {}", scheduleId); + scheduleService.deleteSchedule(scheduleId); + return ApiResponse.success(null); + } +} 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 970c936e..dccfe108 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 @@ -124,7 +124,7 @@ public ApiResponse activateWorkflow(@PathVariable BigInteger workflowId) { @DeleteMapping("/{workflowId}/schedules/{scheduleId}") @ResponseStatus(HttpStatus.NO_CONTENT) public ApiResponse deleteWorkflowSchedule( - @PathVariable BigInteger workflowId, @PathVariable Long scheduleId) { + @PathVariable BigInteger workflowId, @PathVariable Long scheduleId) { workflowService.deleteWorkflowSchedule(workflowId, scheduleId); return ApiResponse.success(null); } diff --git a/apps/user-service/src/main/java/site/icebang/domain/workflow/dto/ScheduleUpdateDto.java b/apps/user-service/src/main/java/site/icebang/domain/workflow/dto/ScheduleUpdateDto.java index 47868d76..649b8da7 100644 --- a/apps/user-service/src/main/java/site/icebang/domain/workflow/dto/ScheduleUpdateDto.java +++ b/apps/user-service/src/main/java/site/icebang/domain/workflow/dto/ScheduleUpdateDto.java @@ -13,6 +13,7 @@ *

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

      검증 규칙:

      + * *
        *
      • cronExpression: 필수값, Quartz 크론식 형식 *
      • scheduleText: 선택값, 사용자 친화적 스케줄 설명 (예: "매일 오전 8시") @@ -28,26 +29,26 @@ @AllArgsConstructor public class ScheduleUpdateDto { - /** - * Quartz 크론 표현식 - * - *

        예시: "0 0 8 * * ?" (매일 오전 8시) - */ - @NotBlank(message = "크론 표현식은 필수입니다") - private String cronExpression; + /** + * Quartz 크론 표현식 + * + *

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

        예시: "매일 오전 8시", "매주 월요일 오후 6시" - */ - private String scheduleText; + /** + * 사용자 친화적 스케줄 설명 텍스트 + * + *

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

        true: 활성화 (실행됨), false: 비활성화 (실행 안 됨) - */ - @NotNull(message = "활성화 상태는 필수입니다") - private Boolean isActive; -} \ No newline at end of file + /** + * 스케줄 활성화 여부 + * + *

        true: 활성화 (실행됨), false: 비활성화 (실행 안 됨) + */ + @NotNull(message = "활성화 상태는 필수입니다") + private Boolean isActive; +} 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 e64b17b7..636b3387 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 @@ -4,6 +4,7 @@ import java.util.*; import org.apache.ibatis.annotations.Param; + import site.icebang.common.dto.PageParams; import site.icebang.domain.workflow.dto.ScheduleDto; import site.icebang.domain.workflow.dto.WorkflowCardDto; @@ -34,7 +35,7 @@ public interface WorkflowMapper { List> selectWorkflowWithJobsAndTasks(BigInteger workflowId); int updateWorkflowEnabled( - @Param("workflowId") BigInteger workflowId, @Param("isEnabled") Boolean isEnabled); - int markAsDeleted(@Param("workflowId") BigInteger workflowId); + @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 9fc973a8..3382cf8c 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 @@ -359,7 +359,8 @@ private boolean isValidCronExpression(String cronExpression) { * @param userId 생성자 ID */ @Transactional - public void registerSchedules(Long workflowId, List scheduleDtos, Long userId) { + public void registerSchedules( + Long workflowId, List scheduleDtos, Long userId) { for (ScheduleCreateDto dto : scheduleDtos) { scheduleService.createSchedule(workflowId, dto, userId); } @@ -482,8 +483,7 @@ public void deleteWorkflowSchedule(BigInteger workflowId, Long scheduleId) { throw new IllegalArgumentException("스케줄을 찾을 수 없습니다: " + scheduleId); } if (!schedule.getWorkflowId().equals(workflowId.longValue())) { - throw new IllegalArgumentException( - "스케줄이 해당 워크플로우에 속하지 않습니다: Schedule ID " + scheduleId); + throw new IllegalArgumentException("스케줄이 해당 워크플로우에 속하지 않습니다: Schedule ID " + scheduleId); } // 2. DB에서 스케줄 비활성화 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 index 993eae7c..1e88ec55 100644 --- 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 @@ -20,11 +20,11 @@ *

        ScheduleService 기능을 API 플로우 관점에서 검증 */ @Sql( - value = { - "classpath:sql/data/00-truncate.sql", - "classpath:sql/data/01-insert-internal-users.sql" - }, - executionPhase = Sql.ExecutionPhase.BEFORE_TEST_CLASS) + value = { + "classpath:sql/data/00-truncate.sql", + "classpath:sql/data/01-insert-internal-users.sql" + }, + executionPhase = Sql.ExecutionPhase.BEFORE_TEST_CLASS) @E2eTest @DisplayName("스케줄 관리 E2E 테스트") class ScheduleManagementE2eTest extends E2eTestSupport { @@ -49,7 +49,8 @@ void createSchedule_success() { HttpEntity> entity = new HttpEntity<>(scheduleRequest, headers); ResponseEntity response = - restTemplate.postForEntity(getV0ApiUrl("/workflows/" + workflowId + "/schedules"), entity, Map.class); + restTemplate.postForEntity( + getV0ApiUrl("/workflows/" + workflowId + "/schedules"), entity, Map.class); assertThat(response.getStatusCode()).isEqualTo(HttpStatus.CREATED); assertThat((Boolean) response.getBody().get("success")).isTrue(); @@ -72,11 +73,16 @@ void createSchedule_invalidCron_shouldFail() { headers.setContentType(MediaType.APPLICATION_JSON); ResponseEntity response = - restTemplate.postForEntity(getV0ApiUrl("/workflows/" + workflowId + "/schedules"), - new HttpEntity<>(scheduleRequest, headers), Map.class); + 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); + .isIn( + HttpStatus.BAD_REQUEST, + HttpStatus.UNPROCESSABLE_ENTITY, + HttpStatus.INTERNAL_SERVER_ERROR); logSuccess("잘못된 크론식 검증 완료"); } @@ -96,8 +102,10 @@ void createInactiveSchedule_shouldNotRegisterQuartz() { headers.setContentType(MediaType.APPLICATION_JSON); ResponseEntity response = - restTemplate.postForEntity(getV0ApiUrl("/workflows/" + workflowId + "/schedules"), - new HttpEntity<>(scheduleRequest, headers), Map.class); + restTemplate.postForEntity( + getV0ApiUrl("/workflows/" + workflowId + "/schedules"), + new HttpEntity<>(scheduleRequest, headers), + Map.class); System.out.println("==== response body ===="); System.out.println(response.getBody()); @@ -120,7 +128,8 @@ void listSchedules_success() { logStep(1, "스케줄 목록 조회 API 호출"); ResponseEntity response = - restTemplate.getForEntity(getV0ApiUrl("/workflows/" + workflowId + "/schedules"), Map.class); + restTemplate.getForEntity( + getV0ApiUrl("/workflows/" + workflowId + "/schedules"), Map.class); assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); assertThat((Boolean) response.getBody().get("success")).isTrue(); @@ -150,8 +159,9 @@ void updateSchedule_toggleActive_success() { HttpHeaders headers = new HttpHeaders(); headers.setContentType(MediaType.APPLICATION_JSON); - restTemplate.put(getV0ApiUrl("/workflows/" + workflowId + "/schedules/" + scheduleId), - new HttpEntity<>(updateRequest, headers)); + restTemplate.put( + getV0ApiUrl("/workflows/" + workflowId + "/schedules/" + scheduleId), + new HttpEntity<>(updateRequest, headers)); logSuccess("스케줄 수정 및 비활성화 성공"); } @@ -181,7 +191,8 @@ private Long createWorkflow(String name) { headers.setContentType(MediaType.APPLICATION_JSON); ResponseEntity response = - restTemplate.postForEntity(getV0ApiUrl("/workflows"), new HttpEntity<>(workflowRequest, headers), Map.class); + restTemplate.postForEntity( + getV0ApiUrl("/workflows"), new HttpEntity<>(workflowRequest, headers), Map.class); System.out.println("==== response body ===="); System.out.println(response.getBody()); @@ -189,11 +200,11 @@ private Long createWorkflow(String name) { assertThat(response.getStatusCode()).isEqualTo(HttpStatus.CREATED); ResponseEntity listResponse = - restTemplate.getForEntity(getV0ApiUrl("/workflows"), Map.class); + restTemplate.getForEntity(getV0ApiUrl("/workflows"), Map.class); Map body = listResponse.getBody(); - List> workflows = (List>) - ((Map) body.get("data")).get("data"); + List> workflows = + (List>) ((Map) body.get("data")).get("data"); return Long.valueOf(workflows.get(workflows.size() - 1).get("id").toString()); } @@ -209,12 +220,15 @@ private Long addSchedule(Long workflowId, String cron, String text, boolean acti headers.setContentType(MediaType.APPLICATION_JSON); ResponseEntity response = - restTemplate.postForEntity(getV0ApiUrl("/workflows/" + workflowId + "/schedules"), - new HttpEntity<>(scheduleRequest, headers), Map.class); + 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()); + return Long.valueOf( + ((Map) response.getBody().get("data")).get("id").toString()); } /** 사용자 로그인을 수행하는 헬퍼 메서드 */ @@ -231,7 +245,7 @@ private void performUserLogin() { HttpEntity> entity = new HttpEntity<>(loginRequest, headers); ResponseEntity response = - restTemplate.postForEntity(getV0ApiUrl("/auth/login"), entity, Map.class); + restTemplate.postForEntity(getV0ApiUrl("/auth/login"), entity, Map.class); if (response.getStatusCode() != HttpStatus.OK) { logError("사용자 로그인 실패: " + response.getStatusCode()); From 3de6725072f01e6284b2e893beb6dd19bfb3020c Mon Sep 17 00:00:00 2001 From: bwnfo3 Date: Mon, 29 Sep 2025 20:38:26 +0900 Subject: [PATCH 13/25] =?UTF-8?q?feat:=20schedule=20workflow=5Fid=20unique?= =?UTF-8?q?=20=EC=A1=B0=EA=B1=B4=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../user-service/src/main/resources/sql/schema/01-schema.sql | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) 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 From 71fba1fd07ea2febc7c4923c3a32045d64b12eb1 Mon Sep 17 00:00:00 2001 From: bwnfo3 Date: Mon, 29 Sep 2025 20:39:13 +0900 Subject: [PATCH 14/25] =?UTF-8?q?fix:=20schedule=20=EA=B4=80=EB=A0=A8=20?= =?UTF-8?q?=ED=8C=8C=EC=9D=BC=EB=93=A4=20schedule=20=ED=8F=B4=EB=8D=94?= =?UTF-8?q?=EB=A1=9C=20=EC=9D=B4=EB=8F=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../controller/ScheduleController.java | 7 ++++--- .../{workflow => schedule}/dto/ScheduleCreateDto.java | 2 +- .../domain/{workflow => schedule}/dto/ScheduleDto.java | 2 +- .../{workflow => schedule}/dto/ScheduleUpdateDto.java | 2 +- .../icebang/domain/schedule/service/ScheduleService.java | 4 ++-- .../icebang/domain/workflow/dto/WorkflowCreateDto.java | 1 + .../icebang/domain/workflow/dto/WorkflowDetailCardDto.java | 1 + .../icebang/domain/workflow/mapper/WorkflowMapper.java | 2 +- .../icebang/domain/workflow/service/WorkflowService.java | 2 ++ .../src/main/resources/mybatis/mapper/WorkflowMapper.xml | 2 +- 10 files changed, 15 insertions(+), 10 deletions(-) rename apps/user-service/src/main/java/site/icebang/domain/{workflow => schedule}/controller/ScheduleController.java (95%) rename apps/user-service/src/main/java/site/icebang/domain/{workflow => schedule}/dto/ScheduleCreateDto.java (98%) rename apps/user-service/src/main/java/site/icebang/domain/{workflow => schedule}/dto/ScheduleDto.java (87%) rename apps/user-service/src/main/java/site/icebang/domain/{workflow => schedule}/dto/ScheduleUpdateDto.java (97%) diff --git a/apps/user-service/src/main/java/site/icebang/domain/workflow/controller/ScheduleController.java b/apps/user-service/src/main/java/site/icebang/domain/schedule/controller/ScheduleController.java similarity index 95% rename from apps/user-service/src/main/java/site/icebang/domain/workflow/controller/ScheduleController.java rename to apps/user-service/src/main/java/site/icebang/domain/schedule/controller/ScheduleController.java index 74c619c9..dd005e2b 100644 --- a/apps/user-service/src/main/java/site/icebang/domain/workflow/controller/ScheduleController.java +++ b/apps/user-service/src/main/java/site/icebang/domain/schedule/controller/ScheduleController.java @@ -1,4 +1,4 @@ -package site.icebang.domain.workflow.controller; +package site.icebang.domain.schedule.controller; import java.util.List; @@ -14,8 +14,8 @@ import site.icebang.domain.auth.model.AuthCredential; import site.icebang.domain.schedule.model.Schedule; import site.icebang.domain.schedule.service.ScheduleService; -import site.icebang.domain.workflow.dto.ScheduleCreateDto; -import site.icebang.domain.workflow.dto.ScheduleUpdateDto; +import site.icebang.domain.schedule.dto.ScheduleCreateDto; +import site.icebang.domain.schedule.dto.ScheduleUpdateDto; /** * 스케줄 관리를 위한 REST API 컨트롤러입니다. @@ -44,6 +44,7 @@ public class ScheduleController { private final ScheduleService scheduleService; @PostMapping("/workflows/{workflowId}/schedules") + @ResponseStatus(HttpStatus.CREATED) public ApiResponse createSchedule( @PathVariable Long workflowId, @Valid @RequestBody ScheduleCreateDto dto, 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 98% 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..30460ebf 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; 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/workflow/dto/ScheduleUpdateDto.java b/apps/user-service/src/main/java/site/icebang/domain/schedule/dto/ScheduleUpdateDto.java similarity index 97% rename from apps/user-service/src/main/java/site/icebang/domain/workflow/dto/ScheduleUpdateDto.java rename to apps/user-service/src/main/java/site/icebang/domain/schedule/dto/ScheduleUpdateDto.java index 649b8da7..6cb65f8b 100644 --- a/apps/user-service/src/main/java/site/icebang/domain/workflow/dto/ScheduleUpdateDto.java +++ b/apps/user-service/src/main/java/site/icebang/domain/schedule/dto/ScheduleUpdateDto.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.NotNull; 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 index d49e290d..40d56e08 100644 --- 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 @@ -11,8 +11,8 @@ import site.icebang.domain.schedule.mapper.ScheduleMapper; import site.icebang.domain.schedule.model.Schedule; -import site.icebang.domain.workflow.dto.ScheduleCreateDto; -import site.icebang.domain.workflow.dto.ScheduleUpdateDto; +import site.icebang.domain.schedule.dto.ScheduleCreateDto; +import site.icebang.domain.schedule.dto.ScheduleUpdateDto; /** * 스케줄 관리를 위한 비즈니스 로직을 처리하는 서비스 클래스입니다. 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..e12cd2f5 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 @@ -11,6 +11,7 @@ import lombok.Builder; import lombok.Data; import lombok.NoArgsConstructor; +import site.icebang.domain.schedule.dto.ScheduleCreateDto; /** * 워크플로우 생성 요청 DTO 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..f49b0dc5 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 @@ -5,6 +5,7 @@ import java.util.Map; import lombok.Data; +import site.icebang.domain.schedule.dto.ScheduleDto; @Data public class WorkflowDetailCardDto extends WorkflowCardDto { 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 636b3387..3c035340 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 @@ -6,7 +6,7 @@ import org.apache.ibatis.annotations.Param; import site.icebang.common.dto.PageParams; -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; 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 3382cf8c..d8d011d4 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 @@ -19,6 +19,8 @@ import site.icebang.common.dto.PageResult; import site.icebang.common.exception.DuplicateDataException; import site.icebang.common.service.PageableService; +import site.icebang.domain.schedule.dto.ScheduleCreateDto; +import site.icebang.domain.schedule.dto.ScheduleDto; import site.icebang.domain.schedule.mapper.ScheduleMapper; import site.icebang.domain.schedule.model.Schedule; import site.icebang.domain.schedule.service.QuartzScheduleService; 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 121813f6..d58f1176 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 @@ - + From 1d49e8538166397cf303e351b52df7cd5b0cf723 Mon Sep 17 00:00:00 2001 From: bwnfo3 Date: Mon, 29 Sep 2025 20:39:26 +0900 Subject: [PATCH 15/25] =?UTF-8?q?feat:=20scheduleE2eTest=20=EC=88=98?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../scenario/ScheduleManagementE2eTest.java | 31 ++++++++++--------- 1 file changed, 17 insertions(+), 14 deletions(-) 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 index 1e88ec55..df969948 100644 --- 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 @@ -24,7 +24,7 @@ "classpath:sql/data/00-truncate.sql", "classpath:sql/data/01-insert-internal-users.sql" }, - executionPhase = Sql.ExecutionPhase.BEFORE_TEST_CLASS) + executionPhase = Sql.ExecutionPhase.BEFORE_TEST_METHOD) @E2eTest @DisplayName("스케줄 관리 E2E 테스트") class ScheduleManagementE2eTest extends E2eTestSupport { @@ -123,19 +123,19 @@ void listSchedules_success() { Long workflowId = createWorkflow("스케줄 조회용 워크플로우"); // 스케줄 2개 추가 - addSchedule(workflowId, "0 0 9 * * ?", "매일 오전 9시", true); + 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); + restTemplate.getForEntity( + getV0ApiUrl("/workflows/" + workflowId + "/schedules"), Map.class); assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); assertThat((Boolean) response.getBody().get("success")).isTrue(); - Map data = (Map) response.getBody().get("data"); - List> schedules = (List>) data.get("data"); + List> schedules = + (List>) response.getBody().get("data"); assertThat(schedules).hasSizeGreaterThanOrEqualTo(2); @@ -191,22 +191,25 @@ private Long createWorkflow(String name) { headers.setContentType(MediaType.APPLICATION_JSON); ResponseEntity response = - restTemplate.postForEntity( - getV0ApiUrl("/workflows"), new HttpEntity<>(workflowRequest, headers), Map.class); - - System.out.println("==== response body ===="); - System.out.println(response.getBody()); + restTemplate.postForEntity( + getV0ApiUrl("/workflows"), + new HttpEntity<>(workflowRequest, headers), + Map.class); assertThat(response.getStatusCode()).isEqualTo(HttpStatus.CREATED); ResponseEntity listResponse = - restTemplate.getForEntity(getV0ApiUrl("/workflows"), Map.class); + restTemplate.getForEntity(getV0ApiUrl("/workflows"), Map.class); Map body = listResponse.getBody(); List> workflows = - (List>) ((Map) body.get("data")).get("data"); + (List>) ((Map) body.get("data")).get("data"); - return Long.valueOf(workflows.get(workflows.size() - 1).get("id").toString()); + return workflows.stream() + .filter(w -> name.equals(w.get("name"))) + .findFirst() + .map(w -> Long.valueOf(w.get("id").toString())) + .orElseThrow(() -> new RuntimeException("생성한 워크플로우를 찾을 수 없습니다")); } /** 스케줄 추가 헬퍼 */ From a1c1aa7c5e6c69cdcf52d06f14a4aa504375b661 Mon Sep 17 00:00:00 2001 From: bwnfo3 Date: Mon, 29 Sep 2025 21:08:23 +0900 Subject: [PATCH 16/25] =?UTF-8?q?fix:=20=EC=A4=91=EB=B3=B5=20=EC=BD=94?= =?UTF-8?q?=EB=93=9C=20=EC=82=AD=EC=A0=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../icebang/domain/schedule/mapper/ScheduleMapper.java | 8 -------- 1 file changed, 8 deletions(-) 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 3414e792..ea2a1c35 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 @@ -102,12 +102,4 @@ Schedule findByWorkflowIdAndCronExpression( */ Schedule findById(@Param("id") Long id); - /** - * 스케줄 활성화 상태만 변경 - * - * @param id 스케줄 ID - * @param isActive 활성화 상태 - * @return 업데이트된 행 수 - */ - int updateActiveStatus(@Param("id") Long id, @Param("isActive") Boolean isActive); } From e4f2964a52760ba65a2442e73e4ef441d8fd6849 Mon Sep 17 00:00:00 2001 From: bwnfo3 Date: Mon, 29 Sep 2025 21:09:01 +0900 Subject: [PATCH 17/25] =?UTF-8?q?fix:=20=EA=B2=80=EC=A6=9D=EC=BD=94?= =?UTF-8?q?=EB=93=9C=20workflowservice=20->=20scheduleService=EB=A1=9C=20?= =?UTF-8?q?=EC=9D=B4=EB=8F=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../schedule/service/ScheduleService.java | 88 ++++++++++- .../workflow/service/WorkflowService.java | 141 +++--------------- 2 files changed, 106 insertions(+), 123 deletions(-) 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 index 40d56e08..f9237032 100644 --- 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 @@ -1,6 +1,6 @@ package site.icebang.domain.schedule.service; -import java.util.List; +import java.util.*; import org.quartz.CronExpression; import org.springframework.stereotype.Service; @@ -13,6 +13,7 @@ import site.icebang.domain.schedule.model.Schedule; import site.icebang.domain.schedule.dto.ScheduleCreateDto; import site.icebang.domain.schedule.dto.ScheduleUpdateDto; +import site.icebang.common.exception.DuplicateDataException; /** * 스케줄 관리를 위한 비즈니스 로직을 처리하는 서비스 클래스입니다. @@ -234,4 +235,89 @@ private boolean isValidCronExpression(String cronExpression) { 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/service/WorkflowService.java b/apps/user-service/src/main/java/site/icebang/domain/workflow/service/WorkflowService.java index d8d011d4..179ae2c3 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; @@ -17,13 +14,8 @@ import site.icebang.common.dto.PageParams; import site.icebang.common.dto.PageResult; -import site.icebang.common.exception.DuplicateDataException; import site.icebang.common.service.PageableService; -import site.icebang.domain.schedule.dto.ScheduleCreateDto; import site.icebang.domain.schedule.dto.ScheduleDto; -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.service.ScheduleService; import site.icebang.domain.workflow.dto.*; import site.icebang.domain.workflow.mapper.JobMapper; @@ -53,8 +45,6 @@ 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; @@ -127,9 +117,9 @@ public void createWorkflow(WorkflowCreateDto dto, BigInteger createdBy) { // 2. 비즈니스 검증 validateBusinessRules(dto); - // 3. 스케줄 검증 (있는 경우만) + // 3. 스케줄 검증 - ScheduleService로 위임 if (dto.hasSchedules()) { - validateSchedules(dto.getSchedules()); + scheduleService.validateSchedules(dto.getSchedules()); } // 4. 워크플로우 이름 중복 체크 @@ -140,11 +130,9 @@ public void createWorkflow(WorkflowCreateDto dto, BigInteger createdBy) { // 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); @@ -154,12 +142,11 @@ public void createWorkflow(WorkflowCreateDto dto, BigInteger createdBy) { throw new RuntimeException("워크플로우 생성에 실패했습니다"); } - // 생성된 workflow ID 추출 Object generatedId = params.get("id"); workflowId = - (generatedId instanceof BigInteger) - ? ((BigInteger) generatedId).longValue() - : ((Number) generatedId).longValue(); + (generatedId instanceof BigInteger) + ? ((BigInteger) generatedId).longValue() + : ((Number) generatedId).longValue(); log.info("워크플로우 생성 완료: {} (ID: {}, 생성자: {})", dto.getName(), workflowId, createdBy); @@ -168,9 +155,13 @@ 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() + ); } } @@ -302,72 +293,6 @@ private void validateBusinessRules(WorkflowCreateDto dto) { } } - /** - * 스케줄 목록 검증 - * - *

        크론 표현식 유효성 및 중복 검사를 수행합니다. - * - * @param schedules 검증할 스케줄 목록 - * @throws IllegalArgumentException 유효하지 않은 크론식 - * @throws DuplicateDataException 중복 크론식 발견 - */ - private void validateSchedules(List schedules) { - if (schedules == null || schedules.isEmpty()) { - return; - } - - // 중복 크론식 검사 (같은 요청 내에서) - Set cronExpressions = new HashSet<>(); - - for (ScheduleCreateDto schedule : schedules) { - String cron = schedule.getCronExpression(); - - // 1. 크론 표현식 유효성 검증 (Quartz 기준) - if (!isValidCronExpression(cron)) { - throw new IllegalArgumentException("유효하지 않은 크론 표현식입니다: " + cron); - } - - // 2. 중복 크론식 검사 - if (cronExpressions.contains(cron)) { - throw new DuplicateDataException("중복된 크론 표현식이 있습니다: " + cron); - } - cronExpressions.add(cron); - } - } - - /** - * 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; - } - } - - /** - * 스케줄 목록 등록 (DB 저장 + Quartz 등록) - * - *

        트랜잭션 내에서 DB 저장을 수행하고, Quartz 등록은 실패해도 워크플로우는 유지되도록 예외를 로그로만 처리합니다. - * - * @param workflowId 워크플로우 ID - * @param scheduleDtos 등록할 스케줄 목록 - * @param userId 생성자 ID - */ - @Transactional - public void registerSchedules( - Long workflowId, List scheduleDtos, Long userId) { - for (ScheduleCreateDto dto : scheduleDtos) { - scheduleService.createSchedule(workflowId, dto, userId); - } - } - /** * 워크플로우를 비활성화하고 모든 스케줄을 중단합니다. * @@ -392,11 +317,8 @@ public void deactivateWorkflow(BigInteger workflowId) { throw new RuntimeException("워크플로우 비활성화에 실패했습니다: " + workflowId); } - // 3. 연결된 모든 스케줄 비활성화 (DB) - scheduleMapper.deactivateAllByWorkflowId(workflowId.longValue()); - - // 4. Quartz에서 스케줄 제거 - quartzScheduleService.deleteSchedule(workflowId.longValue()); + // 3. 스케줄 비활성화 - ScheduleService로 위임 + scheduleService.deactivateAllByWorkflowId(workflowId.longValue()); log.info("워크플로우 비활성화 완료: Workflow ID {}", workflowId); } @@ -425,18 +347,10 @@ public void activateWorkflow(BigInteger workflowId) { throw new RuntimeException("워크플로우 활성화에 실패했습니다: " + workflowId); } - // 3. 연결된 활성 스케줄 조회 - List activeSchedules = scheduleMapper.findAllByWorkflowId(workflowId.longValue()); + // 3. 스케줄 재활성화 - ScheduleService로 위임 + int reactivatedCount = scheduleService.reactivateAllByWorkflowId(workflowId.longValue()); - // 4. Quartz에 스케줄 재등록 - for (Schedule schedule : activeSchedules) { - if (schedule.isActive()) { - quartzScheduleService.addOrUpdateSchedule(schedule); - log.debug("스케줄 Quartz 재등록: Schedule ID {}", schedule.getId()); - } - } - - log.info("워크플로우 활성화 완료: Workflow ID {} - {}개 스케줄 재등록", workflowId, activeSchedules.size()); + log.info("워크플로우 활성화 완료: Workflow ID {} - {}개 스케줄 재등록", workflowId, reactivatedCount); } /** @@ -460,8 +374,6 @@ public void deleteWorkflow(BigInteger workflowId) { // 2. 워크플로우 비활성화 (논리 삭제) deactivateWorkflow(workflowId); - // 3. 추가로 삭제 플래그 설정 (선택사항: deleted_at 컬럼이 있다면) - // workflowMapper.markAsDeleted(workflowId); log.info("워크플로우 삭제 완료: Workflow ID {}", workflowId); } @@ -479,23 +391,8 @@ public void deleteWorkflow(BigInteger workflowId) { public void deleteWorkflowSchedule(BigInteger workflowId, Long scheduleId) { log.info("워크플로우 스케줄 삭제 시작: Workflow ID {}, Schedule ID {}", workflowId, scheduleId); - // 1. 스케줄 조회 및 검증 - Schedule schedule = scheduleMapper.findById(scheduleId); - if (schedule == null) { - throw new IllegalArgumentException("스케줄을 찾을 수 없습니다: " + scheduleId); - } - if (!schedule.getWorkflowId().equals(workflowId.longValue())) { - throw new IllegalArgumentException("스케줄이 해당 워크플로우에 속하지 않습니다: Schedule ID " + scheduleId); - } - - // 2. DB에서 스케줄 비활성화 - int result = scheduleMapper.deleteSchedule(scheduleId); - if (result != 1) { - throw new RuntimeException("스케줄 삭제에 실패했습니다: Schedule ID " + scheduleId); - } - - // 3. Quartz에서 스케줄 제거 - quartzScheduleService.deleteSchedule(workflowId.longValue()); + // ScheduleService로 위임하여 검증 + 삭제 처리 + scheduleService.deleteSchedule(scheduleId); log.info("워크플로우 스케줄 삭제 완료: Workflow ID {}, Schedule ID {}", workflowId, scheduleId); } From 33418ae655b1d1901e328c0d844565d8cbea4126 Mon Sep 17 00:00:00 2001 From: bwnfo3 Date: Mon, 29 Sep 2025 21:09:09 +0900 Subject: [PATCH 18/25] =?UTF-8?q?fix:=20=EC=98=A4=ED=83=80=20=EC=88=98?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../site/icebang/domain/workflow/dto/WorkflowCreateDto.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 e12cd2f5..2f0a84e9 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 @@ -80,7 +80,7 @@ public class WorkflowCreateDto { // JSON 변환용 필드 (MyBatis에서 사용) private String defaultConfigJson; - public String genertateDefaultConfigJson() { + public String generateDefaultConfigJson() { StringBuilder jsonBuilder = new StringBuilder(); jsonBuilder.append("{"); From 26a2e9e3e11010bcbb1a436e2f48f4e6a9e44e89 Mon Sep 17 00:00:00 2001 From: bwnfo3 Date: Mon, 29 Sep 2025 21:23:57 +0900 Subject: [PATCH 19/25] =?UTF-8?q?fix:=20=EC=A0=95=EC=A0=81=20=EB=A9=94?= =?UTF-8?q?=EC=84=9C=EB=93=9C=EB=A1=9C=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../schedule/dto/ScheduleCreateDto.java | 19 ++++++++++--------- 1 file changed, 10 insertions(+), 9 deletions(-) diff --git a/apps/user-service/src/main/java/site/icebang/domain/schedule/dto/ScheduleCreateDto.java b/apps/user-service/src/main/java/site/icebang/domain/schedule/dto/ScheduleCreateDto.java index 30460ebf..88b39d0c 100644 --- a/apps/user-service/src/main/java/site/icebang/domain/schedule/dto/ScheduleCreateDto.java +++ b/apps/user-service/src/main/java/site/icebang/domain/schedule/dto/ScheduleCreateDto.java @@ -85,19 +85,20 @@ 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) - .createdBy(userId) - .updatedBy(userId) - .build(); + .workflowId(workflowId) + .cronExpression(dto.cronExpression) + .scheduleText(dto.scheduleText) + .isActive(dto.isActive != null ? dto.isActive : true) + .parameters(dto.parameters) + .createdBy(userId) + .updatedBy(userId) + .build(); } } From 9a72ff4411eaf8a67e5a83e06e765ff77ada128c Mon Sep 17 00:00:00 2001 From: bwnfo3 Date: Mon, 29 Sep 2025 21:24:14 +0900 Subject: [PATCH 20/25] =?UTF-8?q?fix:=20=EC=A0=95=EC=A0=81=20=EB=A9=94?= =?UTF-8?q?=EC=84=9C=EB=93=9C=EB=A1=9C=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../icebang/domain/schedule/service/ScheduleService.java | 9 +-------- 1 file changed, 1 insertion(+), 8 deletions(-) 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 index f9237032..72c8733c 100644 --- 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 @@ -44,14 +44,7 @@ public class ScheduleService { @Transactional public Schedule createSchedule(Long workflowId, ScheduleCreateDto dto, Long userId) { // 1. Schedule 엔티티 생성 - Schedule schedule = - Schedule.builder() - .workflowId(workflowId) - .cronExpression(dto.getCronExpression()) - .scheduleText(dto.getScheduleText()) - .isActive(dto.getIsActive()) - .createdBy(userId) - .build(); + Schedule schedule = ScheduleCreateDto.toEntity(dto, workflowId, userId); // 2. DB에 저장 scheduleMapper.insertSchedule(schedule); From 839eb4de14a599bf18341e898f7649b9016062ef Mon Sep 17 00:00:00 2001 From: bwnfo3 Date: Mon, 29 Sep 2025 21:24:27 +0900 Subject: [PATCH 21/25] =?UTF-8?q?fix:=20=EC=9D=B8=EC=A6=9D=20=EC=A4=91?= =?UTF-8?q?=EB=B3=B5=20=EC=BD=94=EB=93=9C=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/workflow/controller/WorkflowController.java | 4 ---- 1 file changed, 4 deletions(-) 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 dccfe108..6222b1a0 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 ApiResponse> getWorkflowList( public ApiResponse createWorkflow( @Valid @RequestBody WorkflowCreateDto workflowCreateDto, @AuthenticationPrincipal AuthCredential authCredential) { - // 인증 체크 - if (authCredential == null) { - throw new IllegalArgumentException("로그인이 필요합니다"); - } // AuthCredential에서 userId 추출 BigInteger userId = authCredential.getId(); From bcbb8f93149f133f2a8a678fe285bc6acf45ed40 Mon Sep 17 00:00:00 2001 From: bwnfo3 Date: Mon, 29 Sep 2025 21:24:50 +0900 Subject: [PATCH 22/25] =?UTF-8?q?fix:=20hasSchedules=20collection=EC=9C=BC?= =?UTF-8?q?=EB=A1=9C=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../site/icebang/domain/workflow/dto/WorkflowCreateDto.java | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) 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 2f0a84e9..7d5323bd 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 @@ -11,6 +11,7 @@ import lombok.Builder; import lombok.Data; import lombok.NoArgsConstructor; +import org.springframework.util.CollectionUtils; import site.icebang.domain.schedule.dto.ScheduleCreateDto; /** @@ -134,6 +135,6 @@ public boolean hasPostingConfig() { * @return 스케줄이 1개 이상 있으면 true */ public boolean hasSchedules() { - return schedules != null && !schedules.isEmpty(); + return !CollectionUtils.isEmpty(schedules); } } From fb59ff39d77d63b2f48b5e831c2553d2999b1c62 Mon Sep 17 00:00:00 2001 From: bwnfo3 Date: Mon, 29 Sep 2025 21:25:11 +0900 Subject: [PATCH 23/25] =?UTF-8?q?chore:=20import=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../site/icebang/domain/workflow/service/WorkflowService.java | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) 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 179ae2c3..be1cfc29 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 @@ -14,6 +14,7 @@ import site.icebang.common.dto.PageParams; import site.icebang.common.dto.PageResult; +import site.icebang.common.exception.DuplicateDataException; import site.icebang.common.service.PageableService; import site.icebang.domain.schedule.dto.ScheduleDto; import site.icebang.domain.schedule.service.ScheduleService; @@ -124,7 +125,7 @@ public void createWorkflow(WorkflowCreateDto dto, BigInteger createdBy) { // 4. 워크플로우 이름 중복 체크 if (workflowMapper.existsByName(dto.getName())) { - throw new IllegalArgumentException("이미 존재하는 워크플로우 이름입니다: " + dto.getName()); + throw new DuplicateDataException("이미 존재하는 워크플로우 이름입니다: " + dto.getName()); } // 5. 워크플로우 생성 From a5efe5afff6182f6b9f099c2273a6830017e126e Mon Sep 17 00:00:00 2001 From: bwnfo3 Date: Mon, 29 Sep 2025 21:25:37 +0900 Subject: [PATCH 24/25] chore: spotlessApply --- .../controller/ScheduleController.java | 4 ++-- .../schedule/dto/ScheduleCreateDto.java | 16 ++++++------- .../schedule/mapper/ScheduleMapper.java | 1 - .../schedule/service/ScheduleService.java | 22 ++++++----------- .../workflow/dto/WorkflowCreateDto.java | 4 +++- .../workflow/dto/WorkflowDetailCardDto.java | 1 + .../workflow/service/WorkflowService.java | 12 ++++------ .../scenario/ScheduleManagementE2eTest.java | 24 +++++++++---------- 8 files changed, 36 insertions(+), 48 deletions(-) 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 index dd005e2b..0616bebf 100644 --- 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 @@ -12,10 +12,10 @@ import site.icebang.common.dto.ApiResponse; import site.icebang.domain.auth.model.AuthCredential; -import site.icebang.domain.schedule.model.Schedule; -import site.icebang.domain.schedule.service.ScheduleService; 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 컨트롤러입니다. diff --git a/apps/user-service/src/main/java/site/icebang/domain/schedule/dto/ScheduleCreateDto.java b/apps/user-service/src/main/java/site/icebang/domain/schedule/dto/ScheduleCreateDto.java index 88b39d0c..8f5c7df5 100644 --- a/apps/user-service/src/main/java/site/icebang/domain/schedule/dto/ScheduleCreateDto.java +++ b/apps/user-service/src/main/java/site/icebang/domain/schedule/dto/ScheduleCreateDto.java @@ -92,13 +92,13 @@ public class ScheduleCreateDto { */ public static Schedule toEntity(ScheduleCreateDto dto, Long workflowId, Long userId) { return Schedule.builder() - .workflowId(workflowId) - .cronExpression(dto.cronExpression) - .scheduleText(dto.scheduleText) - .isActive(dto.isActive != null ? dto.isActive : true) - .parameters(dto.parameters) - .createdBy(userId) - .updatedBy(userId) - .build(); + .workflowId(workflowId) + .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/schedule/mapper/ScheduleMapper.java b/apps/user-service/src/main/java/site/icebang/domain/schedule/mapper/ScheduleMapper.java index ea2a1c35..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 @@ -101,5 +101,4 @@ Schedule findByWorkflowIdAndCronExpression( * @return 스케줄 정보, 없으면 null */ Schedule findById(@Param("id") Long id); - } 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 index 72c8733c..95670f82 100644 --- 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 @@ -9,11 +9,11 @@ import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; -import site.icebang.domain.schedule.mapper.ScheduleMapper; -import site.icebang.domain.schedule.model.Schedule; +import site.icebang.common.exception.DuplicateDataException; import site.icebang.domain.schedule.dto.ScheduleCreateDto; import site.icebang.domain.schedule.dto.ScheduleUpdateDto; -import site.icebang.common.exception.DuplicateDataException; +import site.icebang.domain.schedule.mapper.ScheduleMapper; +import site.icebang.domain.schedule.model.Schedule; /** * 스케줄 관리를 위한 비즈니스 로직을 처리하는 서비스 클래스입니다. @@ -240,9 +240,7 @@ private boolean isValidCronExpression(String cronExpression) { */ @Transactional public void validateAndRegisterSchedules( - Long workflowId, - List scheduleDtos, - Long userId) { + Long workflowId, List scheduleDtos, Long userId) { // 1. 검증 validateSchedules(scheduleDtos); @@ -253,9 +251,7 @@ public void validateAndRegisterSchedules( } } - /** - * 스케줄 목록 검증 (크론 표현식 유효성 및 중복 검사) - */ + /** 스케줄 목록 검증 (크론 표현식 유효성 및 중복 검사) */ public void validateSchedules(List schedules) { if (schedules == null || schedules.isEmpty()) { return; @@ -279,9 +275,7 @@ public void validateSchedules(List schedules) { } } - /** - * 워크플로우의 모든 스케줄을 비활성화합니다. - */ + /** 워크플로우의 모든 스케줄을 비활성화합니다. */ @Transactional public void deactivateAllByWorkflowId(Long workflowId) { log.info("워크플로우 스케줄 일괄 비활성화: Workflow ID {}", workflowId); @@ -293,9 +287,7 @@ public void deactivateAllByWorkflowId(Long workflowId) { quartzScheduleService.deleteSchedule(workflowId); } - /** - * 워크플로우의 활성 스케줄을 Quartz에 재등록합니다. - */ + /** 워크플로우의 활성 스케줄을 Quartz에 재등록합니다. */ @Transactional public int reactivateAllByWorkflowId(Long workflowId) { log.info("워크플로우 스케줄 일괄 재활성화: Workflow ID {}", workflowId); 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 7d5323bd..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; @@ -11,7 +13,7 @@ import lombok.Builder; import lombok.Data; import lombok.NoArgsConstructor; -import org.springframework.util.CollectionUtils; + import site.icebang.domain.schedule.dto.ScheduleCreateDto; /** 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 f49b0dc5..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 @@ -5,6 +5,7 @@ import java.util.Map; import lombok.Data; + import site.icebang.domain.schedule.dto.ScheduleDto; @Data 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 be1cfc29..45141f5c 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 @@ -145,9 +145,9 @@ public void createWorkflow(WorkflowCreateDto dto, BigInteger createdBy) { Object generatedId = params.get("id"); workflowId = - (generatedId instanceof BigInteger) - ? ((BigInteger) generatedId).longValue() - : ((Number) generatedId).longValue(); + (generatedId instanceof BigInteger) + ? ((BigInteger) generatedId).longValue() + : ((Number) generatedId).longValue(); log.info("워크플로우 생성 완료: {} (ID: {}, 생성자: {})", dto.getName(), workflowId, createdBy); @@ -159,10 +159,7 @@ public void createWorkflow(WorkflowCreateDto dto, BigInteger createdBy) { // 6. 스케줄 등록 - ScheduleService로 위임 if (dto.hasSchedules() && workflowId != null) { scheduleService.validateAndRegisterSchedules( - workflowId, - dto.getSchedules(), - createdBy.longValue() - ); + workflowId, dto.getSchedules(), createdBy.longValue()); } } @@ -375,7 +372,6 @@ public void deleteWorkflow(BigInteger workflowId) { // 2. 워크플로우 비활성화 (논리 삭제) deactivateWorkflow(workflowId); - log.info("워크플로우 삭제 완료: Workflow ID {}", workflowId); } 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 index df969948..afdff08c 100644 --- 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 @@ -128,14 +128,14 @@ void listSchedules_success() { logStep(1, "스케줄 목록 조회 API 호출"); ResponseEntity response = - restTemplate.getForEntity( - getV0ApiUrl("/workflows/" + workflowId + "/schedules"), Map.class); + 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"); + (List>) response.getBody().get("data"); assertThat(schedules).hasSizeGreaterThanOrEqualTo(2); @@ -191,25 +191,23 @@ private Long createWorkflow(String name) { headers.setContentType(MediaType.APPLICATION_JSON); ResponseEntity response = - restTemplate.postForEntity( - getV0ApiUrl("/workflows"), - new HttpEntity<>(workflowRequest, headers), - Map.class); + restTemplate.postForEntity( + getV0ApiUrl("/workflows"), new HttpEntity<>(workflowRequest, headers), Map.class); assertThat(response.getStatusCode()).isEqualTo(HttpStatus.CREATED); ResponseEntity listResponse = - restTemplate.getForEntity(getV0ApiUrl("/workflows"), Map.class); + restTemplate.getForEntity(getV0ApiUrl("/workflows"), Map.class); Map body = listResponse.getBody(); List> workflows = - (List>) ((Map) body.get("data")).get("data"); + (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("생성한 워크플로우를 찾을 수 없습니다")); + .filter(w -> name.equals(w.get("name"))) + .findFirst() + .map(w -> Long.valueOf(w.get("id").toString())) + .orElseThrow(() -> new RuntimeException("생성한 워크플로우를 찾을 수 없습니다")); } /** 스케줄 추가 헬퍼 */ From 0eba2fc74d00d3efcc71fc343ca8ee2ac9e80c30 Mon Sep 17 00:00:00 2001 From: jihukimme Date: Thu, 2 Oct 2025 16:11:00 +0900 Subject: [PATCH 25/25] =?UTF-8?q?refactor:=20DTO=20=EB=84=A4=EC=9D=B4?= =?UTF-8?q?=EB=B0=8D=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../controller/ScheduleController.java | 26 +++++++++---------- 1 file changed, 13 insertions(+), 13 deletions(-) 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 index 0616bebf..bdcb3e12 100644 --- 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 @@ -10,7 +10,7 @@ import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; -import site.icebang.common.dto.ApiResponse; +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; @@ -45,7 +45,7 @@ public class ScheduleController { @PostMapping("/workflows/{workflowId}/schedules") @ResponseStatus(HttpStatus.CREATED) - public ApiResponse createSchedule( + public ApiResponseDto createSchedule( @PathVariable Long workflowId, @Valid @RequestBody ScheduleCreateDto dto, @AuthenticationPrincipal AuthCredential authCredential) { @@ -53,7 +53,7 @@ public ApiResponse createSchedule( Long userId = authCredential.getId().longValue(); Schedule schedule = scheduleService.createSchedule(workflowId, dto, userId); - return ApiResponse.success(schedule); + return ApiResponseDto.success(schedule); } /** @@ -63,10 +63,10 @@ public ApiResponse createSchedule( * @return 스케줄 목록 */ @GetMapping("/workflows/{workflowId}/schedules") - public ApiResponse> getSchedulesByWorkflow(@PathVariable Long workflowId) { + public ApiResponseDto> getSchedulesByWorkflow(@PathVariable Long workflowId) { log.info("워크플로우 스케줄 목록 조회 요청: Workflow ID {}", workflowId); List schedules = scheduleService.getSchedulesByWorkflowId(workflowId); - return ApiResponse.success(schedules); + return ApiResponseDto.success(schedules); } /** @@ -76,10 +76,10 @@ public ApiResponse> getSchedulesByWorkflow(@PathVariable Long wor * @return 스케줄 정보 */ @GetMapping("/schedules/{scheduleId}") - public ApiResponse getSchedule(@PathVariable Long scheduleId) { + public ApiResponseDto getSchedule(@PathVariable Long scheduleId) { log.info("스케줄 조회 요청: Schedule ID {}", scheduleId); Schedule schedule = scheduleService.getScheduleById(scheduleId); - return ApiResponse.success(schedule); + return ApiResponseDto.success(schedule); } /** @@ -93,7 +93,7 @@ public ApiResponse getSchedule(@PathVariable Long scheduleId) { * @return 성공 응답 */ @PutMapping("/schedules/{scheduleId}") - public ApiResponse updateSchedule( + public ApiResponseDto updateSchedule( @PathVariable Long scheduleId, @Valid @RequestBody ScheduleUpdateDto dto, @AuthenticationPrincipal AuthCredential authCredential) { @@ -108,7 +108,7 @@ public ApiResponse updateSchedule( Long userId = authCredential.getId().longValue(); scheduleService.updateSchedule(scheduleId, dto, userId); - return ApiResponse.success(null); + return ApiResponseDto.success(null); } /** @@ -121,13 +121,13 @@ public ApiResponse updateSchedule( * @return 성공 응답 */ @PatchMapping("/schedules/{scheduleId}/active") - public ApiResponse toggleScheduleActive( + public ApiResponseDto toggleScheduleActive( @PathVariable Long scheduleId, @RequestParam Boolean isActive) { log.info("스케줄 활성화 상태 변경 요청: Schedule ID {} - {}", scheduleId, isActive); scheduleService.toggleScheduleActive(scheduleId, isActive); - return ApiResponse.success(null); + return ApiResponseDto.success(null); } /** @@ -140,9 +140,9 @@ public ApiResponse toggleScheduleActive( */ @DeleteMapping("/schedules/{scheduleId}") @ResponseStatus(HttpStatus.NO_CONTENT) - public ApiResponse deleteSchedule(@PathVariable Long scheduleId) { + public ApiResponseDto deleteSchedule(@PathVariable Long scheduleId) { log.info("스케줄 삭제 요청: Schedule ID {}", scheduleId); scheduleService.deleteSchedule(scheduleId); - return ApiResponse.success(null); + return ApiResponseDto.success(null); } }