Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions apps/user-service/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,9 @@ dependencies {
implementation 'org.springframework.boot:spring-boot-starter-aop'
implementation 'org.springframework.boot:spring-boot-starter-validation'

// Java 표준 라이프사이클 어노테이션 (@PostConstruct, @PreDestroy)
implementation 'javax.annotation:javax.annotation-api:1.3.2'

// MyBatis
implementation 'org.mybatis.spring.boot:mybatis-spring-boot-starter:3.0.5'

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
package site.icebang.domain.execution.mapper;

import org.apache.ibatis.annotations.Mapper;
import site.icebang.domain.execution.model.JobRun;

@Mapper
public interface JobRunMapper {
void save(JobRun jobRun);
void update(JobRun jobRun);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
package site.icebang.domain.execution.mapper;

import org.apache.ibatis.annotations.Mapper;
import site.icebang.domain.execution.model.WorkflowRun;

@Mapper
public interface WorkflowRunMapper {
void save(WorkflowRun workflowRun);
void update(WorkflowRun workflowRun);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
package site.icebang.domain.execution.model;

import java.time.LocalDateTime;
import lombok.AccessLevel;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;

@Getter
@Setter
@Builder
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@AllArgsConstructor
public class JobRun {
private Long id;
private Long workflowRunId;
private Long jobId;
private String status;
private LocalDateTime startedAt;
private LocalDateTime finishedAt;
private Integer executionOrder;
private LocalDateTime createdAt;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
package site.icebang.domain.execution.model;

import java.time.LocalDateTime;
import java.util.UUID;
import lombok.AccessLevel;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;

@Getter
@Setter
@Builder
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@AllArgsConstructor
public class WorkflowRun {

private Long id;
private Long workflowId;
private String traceId;
private String runNumber;
private String status;
private String triggerType;
private LocalDateTime startedAt;
private LocalDateTime finishedAt;
private Long createdBy;
private LocalDateTime createdAt;

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
package site.icebang.domain.job.mapper;

import java.util.Optional;
import org.apache.ibatis.annotations.Mapper;
import site.icebang.domain.job.model.Job;

@Mapper
public interface JobMapper {
Optional<Job> findById(Long id);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
package site.icebang.domain.job.model;

import java.time.LocalDateTime;
import lombok.AccessLevel;
import lombok.AllArgsConstructor;
import lombok.Getter;
import lombok.NoArgsConstructor;

@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@AllArgsConstructor
public class Job {
private Long id;
private String name;
private String description;
private boolean isEnabled;
private LocalDateTime createdAt;
private Long createdBy;
private LocalDateTime updatedAt;
private Long updatedBy;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
package site.icebang.domain.mapping.mapper;

import java.util.List;
import org.apache.ibatis.annotations.Mapper;

@Mapper
public interface WorkflowJobMapper {
// A workflow can have multiple jobs, ordered by execution_order
List<Long> findJobIdsByWorkflowId(Long workflowId);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
package site.icebang.domain.schedule.mapper;

import java.util.List;
import java.util.Optional;
import org.apache.ibatis.annotations.Mapper;
import site.icebang.domain.schedule.model.Schedule;

@Mapper
public interface ScheduleMapper {
void save(Schedule schedule);
void update(Schedule schedule);
Optional<Schedule> findById(Long id);
List<Schedule> findAllActive();
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
package site.icebang.domain.schedule.model;

import java.time.LocalDateTime;
import lombok.AccessLevel;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;

@Getter
@Setter // 서비스 레이어에서의 상태 변경 및 MyBatis 매핑을 위해 사용
@Builder
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@AllArgsConstructor
public class Schedule {

private Long id;
private Long workflowId;
private String cronExpression;
private String parameters; // JSON format
private boolean isActive;
private String lastRunStatus;
private LocalDateTime lastRunAt;
private LocalDateTime createdAt;
private Long createdBy;
private LocalDateTime updatedAt;
private Long updatedBy;
private String scheduleText;
}
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
package site.icebang.schedule.runner;
package site.icebang.domain.schedule.runner;

import java.util.List;

Expand All @@ -9,9 +9,9 @@
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;

import site.icebang.schedule.mapper.ScheduleMapper;
import site.icebang.schedule.model.Schedule;
import site.icebang.schedule.service.DynamicSchedulerService;
import site.icebang.domain.schedule.mapper.ScheduleMapper;
import site.icebang.domain.schedule.model.Schedule;
import site.icebang.domain.schedule.service.DynamicSchedulerService;

@Slf4j
@Component
Expand All @@ -24,7 +24,7 @@ public class SchedulerInitializer implements ApplicationRunner {
@Override
public void run(ApplicationArguments args) {
log.info(">>>> Initializing schedules from database...");
List<Schedule> activeSchedules = scheduleMapper.findAllByIsActive(true);
List<Schedule> activeSchedules = scheduleMapper.findAllActive();
activeSchedules.forEach(dynamicSchedulerService::register);
log.info(">>>> {} active schedules have been registered.", activeSchedules.size());
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
package site.icebang.domain.schedule.service;

import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ScheduledFuture;
import org.springframework.scheduling.TaskScheduler;
import org.springframework.scheduling.support.CronTrigger;
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.service.WorkflowExecutionService;

@Slf4j
@Service
@RequiredArgsConstructor
public class DynamicSchedulerService {

private final TaskScheduler taskScheduler;
private final WorkflowExecutionService workflowExecutionService;

private final Map<Long, ScheduledFuture<?>> scheduledTasks = new ConcurrentHashMap<>();

public void register(Schedule schedule) {
// 스케줄 실행 시 WorkflowExecutionService를 호출하는 Runnable 생성
Runnable runnable = () -> {
try {
log.debug("Triggering workflow execution for scheduleId: {}", schedule.getId());
// 실제 워크플로우 실행은 WorkflowExecutionService에 위임 (비동기 호출)
workflowExecutionService.execute(schedule.getWorkflowId(), "SCHEDULE", schedule.getId());
} catch (Exception e) {
// Async 예외는 기본적으로 처리되지 않으므로 여기서 로그를 남기는 것이 중요
log.error("Failed to submit workflow execution for scheduleId: {}", schedule.getId(), e);
}
};

CronTrigger trigger = new CronTrigger(schedule.getCronExpression());
ScheduledFuture<?> future = taskScheduler.schedule(runnable, trigger);

// 기존에 등록된 스케줄이 있다면 취소하고 새로 등록 (업데이트 지원)
ScheduledFuture<?> oldFuture = scheduledTasks.put(schedule.getId(), future);
if (oldFuture != null) {
oldFuture.cancel(false);
}

log.info(">>>> Schedule registered/updated: id={}, workflowId={}, cron='{}'",
schedule.getId(), schedule.getWorkflowId(), schedule.getCronExpression());
}

public void remove(Long scheduleId) {
ScheduledFuture<?> future = scheduledTasks.remove(scheduleId);
if (future != null) {
future.cancel(true); // true: 실행 중인 태스크를 인터럽트
log.info(">>>> Schedule removed: id={}", scheduleId);
} else {
log.warn(">>>> Attempted to remove a schedule that was not found in the scheduler: id={}", scheduleId);
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
package site.icebang.domain.schedule.service;

import java.util.List;
import javax.annotation.PostConstruct;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import site.icebang.domain.schedule.model.Schedule;
import site.icebang.domain.schedule.mapper.ScheduleMapper;

@Slf4j
@Service
@RequiredArgsConstructor
public class ScheduleService {

private final ScheduleMapper scheduleMapper;
private final DynamicSchedulerService dynamicSchedulerService;

/**
* 애플리케이션 시작 시 활성화된 모든 스케줄을 초기화합니다.
* 이를 통해 서버가 재시작되어도 스케줄이 자동으로 복원됩니다.
*/
@PostConstruct
public void initializeSchedules() {
log.info("Initializing active schedules from database...");
List<Schedule> activeSchedules = scheduleMapper.findAllActive();
activeSchedules.forEach(dynamicSchedulerService::register);
log.info("{} active schedules have been initialized.", activeSchedules.size());
}

@Transactional
public Schedule createSchedule(Schedule schedule) {
// 1. DB에 스케줄 저장
scheduleMapper.save(schedule);

// 2. 메모리의 스케줄러에 동적으로 등록
if (schedule.isActive()) { // Lombok Getter for boolean is isActive()
dynamicSchedulerService.register(schedule);
}

return schedule;
}

// TODO: 스케줄 수정 로직(updateSchedule) 구현이 필요합니다.

@Transactional
public void deactivateSchedule(Long scheduleId) {
// 1. DB에서 스케줄을 비활성화
Schedule schedule = scheduleMapper.findById(scheduleId)
.orElseThrow(() -> new IllegalArgumentException("Schedule not found with id: " + scheduleId));
schedule.setActive(false);
scheduleMapper.update(schedule);

// 2. 메모리의 스케줄러에서 제거
dynamicSchedulerService.remove(scheduleId);
}
}
Original file line number Diff line number Diff line change
@@ -1,28 +1,48 @@
package site.icebang.domain.workflow.controller;

import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.ModelAttribute;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;

import lombok.RequiredArgsConstructor;

import site.icebang.common.dto.ApiResponse;
import site.icebang.common.dto.PageParams;
import site.icebang.common.dto.PageResult;
import site.icebang.domain.workflow.dto.WorkflowCardDto;
import site.icebang.domain.workflow.service.WorkflowExecutionService;
import site.icebang.domain.workflow.service.WorkflowService;

@RestController
@RequestMapping("/v0/workflows")
@RequiredArgsConstructor
public class WorkflowController {
private final WorkflowService workflowService;
private final WorkflowExecutionService workflowExecutionService;

@GetMapping("")
public ApiResponse<PageResult<WorkflowCardDto>> getWorkflowList(
@ModelAttribute PageParams pageParams) {
PageResult<WorkflowCardDto> result = workflowService.getPagedResult(pageParams);
return ApiResponse.success(result);
}


/**
* 지정된 ID의 워크플로우를 수동으로 실행합니다.
*
* @param workflowId 실행할 워크플로우의 ID
* @return HTTP 202 Accepted
*/
@PostMapping("/{workflowId}/execute")
public ResponseEntity<Void> executeWorkflow(@PathVariable Long workflowId) {
// TODO: Spring Security 등 인증 체계에서 실제 사용자 ID를 가져와야 합니다.
Long currentUserId = 1L; // 임시 사용자 ID

// 워크플로우 실행 서비스 호출. 'MANUAL' 타입으로 실행을 요청합니다.
// @Async로 동작하므로, 이 호출은 즉시 반환되고 워크플로우는 백그라운드에서 실행됩니다.
workflowExecutionService.execute(workflowId, "MANUAL", currentUserId);

// 작업이 성공적으로 접수되었음을 알리는 202 Accepted 상태를 반환합니다.
return ResponseEntity.accepted().build();
}
}
Loading
Loading