[Schedule]

- Created schedule config. Call workers only based on db schedule setting
- Created schedule log
- Enabled scheduling
This commit is contained in:
Hyojin Ahn 2026-01-14 13:36:22 -05:00
parent 62d453b926
commit aef400e67f
16 changed files with 1189 additions and 15 deletions

View File

@ -5,11 +5,13 @@ import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.boot.autoconfigure.domain.EntityScan; import org.springframework.boot.autoconfigure.domain.EntityScan;
import org.springframework.data.jpa.repository.config.EnableJpaAuditing; import org.springframework.data.jpa.repository.config.EnableJpaAuditing;
import org.springframework.data.jpa.repository.config.EnableJpaRepositories; import org.springframework.data.jpa.repository.config.EnableJpaRepositories;
import org.springframework.scheduling.annotation.EnableScheduling;
@SpringBootApplication(scanBasePackages = {"com.goi.erp"}) @SpringBootApplication(scanBasePackages = {"com.goi.erp"})
@EnableJpaAuditing(auditorAwareRef = "auditorAware") @EnableJpaAuditing(auditorAwareRef = "auditorAware")
@EntityScan(basePackages = {"com.goi.erp.entity"}) @EntityScan(basePackages = {"com.goi.erp.entity"})
@EnableJpaRepositories(basePackages = {"com.goi.erp.repository"}) @EnableJpaRepositories(basePackages = {"com.goi.erp.repository"})
@EnableScheduling
public class SecurityApplication { public class SecurityApplication {
public static void main(String[] args) { public static void main(String[] args) {

View File

@ -2,31 +2,31 @@ package com.goi.erp.common.permission;
public class PermissionChecker { public class PermissionChecker {
public static boolean canCreateCRM(PermissionSet set) { public static boolean canCreateSYS(PermissionSet set) {
if (set.hasAll()) return true; if (set.hasAll()) return true;
return set.has(PermissionEnums.Module.C, PermissionEnums.Action.C); return set.has(PermissionEnums.Module.S, PermissionEnums.Action.C);
} }
public static boolean canReadCRM(PermissionSet set) { public static boolean canReadSYS(PermissionSet set) {
if (set.hasAll()) return true; if (set.hasAll()) return true;
return set.has(PermissionEnums.Module.C, PermissionEnums.Action.R); return set.has(PermissionEnums.Module.S, PermissionEnums.Action.R);
} }
public static boolean canUpdateCRM(PermissionSet set) { public static boolean canUpdateSYS(PermissionSet set) {
if (set.hasAll()) return true; if (set.hasAll()) return true;
return set.has(PermissionEnums.Module.C, PermissionEnums.Action.U); return set.has(PermissionEnums.Module.S, PermissionEnums.Action.U);
} }
public static boolean canDeleteCRM(PermissionSet set) { public static boolean canDeleteSYS(PermissionSet set) {
if (set.hasAll()) return true; if (set.hasAll()) return true;
return set.has(PermissionEnums.Module.C, PermissionEnums.Action.D); return set.has(PermissionEnums.Module.S, PermissionEnums.Action.D);
} }
// 범위까지 체크 // 범위까지 체크
public static boolean canReadCRMAll(PermissionSet set) { public static boolean canReadSYSAll(PermissionSet set) {
if (set.hasAll()) return true; if (set.hasAll()) return true;
return set.hasFull( return set.hasFull(
PermissionEnums.Module.C, PermissionEnums.Module.S,
PermissionEnums.Action.R, PermissionEnums.Action.R,
PermissionEnums.Scope.A PermissionEnums.Scope.A
); );

View File

@ -1,14 +1,19 @@
package com.goi.erp.controller; package com.goi.erp.controller;
import com.goi.erp.common.permission.PermissionChecker;
import com.goi.erp.common.permission.PermissionSet;
import com.goi.erp.dto.ConfigRequestDto; import com.goi.erp.dto.ConfigRequestDto;
import com.goi.erp.dto.ConfigResponseDto; import com.goi.erp.dto.ConfigResponseDto;
import com.goi.erp.entity.ConfigChangeLog; import com.goi.erp.entity.ConfigChangeLog;
import com.goi.erp.service.ConfigService;
import com.goi.erp.repository.ConfigChangeLogRepository; import com.goi.erp.repository.ConfigChangeLogRepository;
import com.goi.erp.service.ConfigService;
import com.goi.erp.token.PermissionAuthenticationToken;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity; import org.springframework.http.ResponseEntity;
import org.springframework.security.access.AccessDeniedException;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.web.bind.annotation.*; import org.springframework.web.bind.annotation.*;
import java.util.List; import java.util.List;
@ -27,13 +32,18 @@ public class ConfigController {
/** /**
* 단일 config 조회 * 단일 config 조회
* GET /configs/{module}/{key} * GET /config/{module}/{key}
*/ */
@GetMapping("/{module}/{key}") @GetMapping("/{module}/{key}")
public ResponseEntity<ConfigResponseDto> getConfig( public ResponseEntity<ConfigResponseDto> getConfig(
@PathVariable String module, @PathVariable String module,
@PathVariable String key @PathVariable String key
) { ) {
PermissionSet permissionSet = getPermissionSet();
if (!PermissionChecker.canReadSYS(permissionSet)) {
throw new AccessDeniedException("You do not have permission to read SYS config");
}
return ResponseEntity.ok( return ResponseEntity.ok(
configService.getOne(module, key) configService.getOne(module, key)
); );
@ -41,12 +51,17 @@ public class ConfigController {
/** /**
* 모듈별 config 목록 * 모듈별 config 목록
* GET /configs/{module} * GET /config/{module}
*/ */
@GetMapping("/{module}") @GetMapping("/{module}")
public ResponseEntity<List<ConfigResponseDto>> getConfigsByModule( public ResponseEntity<List<ConfigResponseDto>> getConfigsByModule(
@PathVariable String module @PathVariable String module
) { ) {
PermissionSet permissionSet = getPermissionSet();
if (!PermissionChecker.canReadSYSAll(permissionSet)) {
throw new AccessDeniedException("You do not have permission to read all SYS configs");
}
return ResponseEntity.ok( return ResponseEntity.ok(
configService.getAllByModule(module) configService.getAllByModule(module)
); );
@ -60,25 +75,34 @@ public class ConfigController {
public ResponseEntity<ConfigResponseDto> save( public ResponseEntity<ConfigResponseDto> save(
@RequestBody ConfigRequestDto dto @RequestBody ConfigRequestDto dto
) { ) {
PermissionSet permissionSet = getPermissionSet();
if (!PermissionChecker.canCreateSYS(permissionSet)) {
throw new AccessDeniedException("You do not have permission to create or update SYS config");
}
return ResponseEntity.ok( return ResponseEntity.ok(
configService.save(dto) configService.save(dto)
); );
} }
/* =============================== /* ===============================
CHANGE HISTORY CHANGE HISTORY
=============================== */ =============================== */
/** /**
* config 변경 이력 * config 변경 이력
* GET /configs/{module}/{key}/history * GET /config/{module}/{key}/history
*/ */
@GetMapping("/{module}/{key}/history") @GetMapping("/{module}/{key}/history")
public ResponseEntity<List<ConfigChangeLog>> getHistory( public ResponseEntity<List<ConfigChangeLog>> getHistory(
@PathVariable String module, @PathVariable String module,
@PathVariable String key @PathVariable String key
) { ) {
PermissionSet permissionSet = getPermissionSet();
if (!PermissionChecker.canReadSYS(permissionSet)) {
throw new AccessDeniedException("You do not have permission to read SYS config history");
}
return ResponseEntity.ok( return ResponseEntity.ok(
configChangeLogRepository configChangeLogRepository
.findAllByCclModuleAndCclKeyOrderByCclChangedAtDesc( .findAllByCclModuleAndCclKeyOrderByCclChangedAtDesc(
@ -86,4 +110,21 @@ public class ConfigController {
) )
); );
} }
/* ===============================
permission helper
=============================== */
private PermissionSet getPermissionSet() {
PermissionAuthenticationToken auth =
(PermissionAuthenticationToken) SecurityContextHolder
.getContext()
.getAuthentication();
if (auth == null || auth.getPermissionSet() == null) {
throw new AccessDeniedException("Permission information is missing");
}
return auth.getPermissionSet();
}
} }

View File

@ -0,0 +1,150 @@
package com.goi.erp.controller;
import com.goi.erp.common.permission.PermissionChecker;
import com.goi.erp.common.permission.PermissionSet;
import com.goi.erp.dto.ScheduleJobConfigRequestDto;
import com.goi.erp.dto.ScheduleJobConfigResponseDto;
import com.goi.erp.service.ScheduleJobConfigService;
import com.goi.erp.token.PermissionAuthenticationToken;
import lombok.RequiredArgsConstructor;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.AccessDeniedException;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.web.bind.annotation.*;
import java.util.List;
@RestController
@RequestMapping("/schedule/jobs")
@RequiredArgsConstructor
public class ScheduleJobConfigController {
private final ScheduleJobConfigService service;
/* =========================
* CREATE
* ========================= */
@PostMapping
public ResponseEntity<ScheduleJobConfigResponseDto> create(
@RequestBody ScheduleJobConfigRequestDto request
) {
PermissionSet permissionSet = getPermissionSet();
if (!PermissionChecker.canCreateSYS(permissionSet)) {
throw new AccessDeniedException("You do not have permission to create SYS schedule job");
}
ScheduleJobConfigResponseDto response = service.create(request);
return ResponseEntity.status(HttpStatus.CREATED).body(response);
}
/* =========================
* READ
* ========================= */
@GetMapping("/{id}")
public ResponseEntity<ScheduleJobConfigResponseDto> get(@PathVariable Long id) {
PermissionSet permissionSet = getPermissionSet();
if (!PermissionChecker.canReadSYS(permissionSet)) {
throw new AccessDeniedException("You do not have permission to read SYS schedule job");
}
return ResponseEntity.ok(service.get(id));
}
@GetMapping("/code/{jobCode}")
public ResponseEntity<ScheduleJobConfigResponseDto> getByJobCode(
@PathVariable String jobCode
) {
PermissionSet permissionSet = getPermissionSet();
if (!PermissionChecker.canReadSYS(permissionSet)) {
throw new AccessDeniedException("You do not have permission to read SYS schedule job");
}
return ResponseEntity.ok(service.getByJobCode(jobCode));
}
@GetMapping
public ResponseEntity<List<ScheduleJobConfigResponseDto>> listAll() {
PermissionSet permissionSet = getPermissionSet();
if (!PermissionChecker.canReadSYSAll(permissionSet)) {
throw new AccessDeniedException("You do not have permission to read all SYS schedule jobs");
}
return ResponseEntity.ok(service.listAll());
}
/* =========================
* UPDATE
* ========================= */
@PutMapping("/{id}")
public ResponseEntity<ScheduleJobConfigResponseDto> update(
@PathVariable Long id,
@RequestBody ScheduleJobConfigRequestDto request
) {
PermissionSet permissionSet = getPermissionSet();
if (!PermissionChecker.canUpdateSYS(permissionSet)) {
throw new AccessDeniedException("You do not have permission to update SYS schedule job");
}
return ResponseEntity.ok(service.update(id, request));
}
@PatchMapping("/{id}/enable")
public ResponseEntity<Void> enable(@PathVariable Long id) {
PermissionSet permissionSet = getPermissionSet();
if (!PermissionChecker.canUpdateSYS(permissionSet)) {
throw new AccessDeniedException("You do not have permission to enable SYS schedule job");
}
service.setEnabled(id, true);
return ResponseEntity.noContent().build();
}
@PatchMapping("/{id}/disable")
public ResponseEntity<Void> disable(@PathVariable Long id) {
PermissionSet permissionSet = getPermissionSet();
if (!PermissionChecker.canUpdateSYS(permissionSet)) {
throw new AccessDeniedException("You do not have permission to disable SYS schedule job");
}
service.setEnabled(id, false);
return ResponseEntity.noContent().build();
}
/* =========================
* DELETE
* ========================= */
@DeleteMapping("/{id}")
public ResponseEntity<Void> delete(@PathVariable Long id) {
PermissionSet permissionSet = getPermissionSet();
if (!PermissionChecker.canDeleteSYS(permissionSet)) {
throw new AccessDeniedException("You do not have permission to delete SYS schedule job");
}
service.delete(id);
return ResponseEntity.noContent().build();
}
/* =========================
* permission helper
* ========================= */
private PermissionSet getPermissionSet() {
PermissionAuthenticationToken auth =
(PermissionAuthenticationToken) SecurityContextHolder
.getContext()
.getAuthentication();
if (auth == null || auth.getPermissionSet() == null) {
throw new AccessDeniedException("Permission information is missing");
}
return auth.getPermissionSet();
}
}

View File

@ -0,0 +1,49 @@
package com.goi.erp.dto;
import java.util.List;
import java.util.Map;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
@Data
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class ScheduleJobConfigRequestDto {
private String sjcJobCode; // : SAMSARA_DVIR
private String sjcJobName; // : Samsara DVIR Ingest
private String sjcJobGroup; // : SAMSARA
private Boolean sjcEnabled;
private String sjcCronExpression; // : 0 */10 * * * *
private String sjcTimezone; // : UTC, Asia/Seoul
private String sjcWorkerMethod; // POST
private String sjcWorkerUrl; // worker endpoint
private Integer sjcWorkerTimeoutSeconds; // seconds
// execution parameters
private Integer sjcLookbackHours;
private Integer sjcOverlapMinutes;
private Integer sjcMaxRecords;
private String sjcDescription;
/* =========================
* required config references
* ========================= */
/**
* :
* {
* "SYS": ["HOME_LATITUDE", "HOME_LONGITUDE"],
* "OPR": ["HOME_RADIUS_METERS", "PAUSED_CLOSE_MINUTES"]
* }
*/
private Map<String, List<String>> sjcRequiredConfigs;
}

View File

@ -0,0 +1,50 @@
package com.goi.erp.dto;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Map;
@Data
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class ScheduleJobConfigResponseDto {
private Long sjcId;
private String sjcJobCode;
private String sjcJobName;
private String sjcJobGroup;
private Boolean sjcEnabled;
private String sjcCronExpression;
private String sjcTimezone;
private String sjcWorkerMethod;
private String sjcWorkerUrl;
private Integer sjcWorkerTimeoutSeconds;
// execution parameters
private Integer sjcLookbackHours;
private Integer sjcOverlapMinutes;
private Integer sjcMaxRecords;
private String sjcDescription;
private LocalDateTime sjcCreatedAt;
private LocalDateTime sjcUpdatedAt;
private String sjcCreatedBy;
private String sjcUpdatedBy;
/* =========================
* required config references
* ========================= */
private Map<String, List<String>> sjcRequiredConfigs;
}

View File

@ -0,0 +1,22 @@
package com.goi.erp.dto;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.time.LocalDateTime;
import java.util.Map;
@Data
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class ScheduleWorkerRequestDto {
private String jobCode;
private LocalDateTime from;
private LocalDateTime to;
private Integer maxRecords;
private Map<String, Map<String, String>> config;
}

View File

@ -0,0 +1,22 @@
package com.goi.erp.dto;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
@Data
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class ScheduleWorkerResponseDto {
private boolean success;
private int processedCount;
private int successCount;
private int failCount;
private String errorCode;
private String errorMessage;
}

View File

@ -0,0 +1,110 @@
package com.goi.erp.entity;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.EntityListeners;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
import jakarta.persistence.UniqueConstraint;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Map;
import org.hibernate.annotations.JdbcTypeCode;
import org.hibernate.type.SqlTypes;
import org.springframework.data.annotation.CreatedBy;
import org.springframework.data.annotation.LastModifiedBy;
import org.springframework.data.jpa.domain.support.AuditingEntityListener;
@Entity
@Table(
name = "schedule_job_config",
uniqueConstraints = {
@UniqueConstraint(
name = "uk_schedule_job_config_job_code",
columnNames = {"sjc_job_code"}
)
}
)
@Data
@NoArgsConstructor
@AllArgsConstructor
@Builder
@EntityListeners(AuditingEntityListener.class)
public class ScheduleJobConfig {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "sjc_id")
private Long sjcId;
@Column(name = "sjc_job_code", nullable = false, length = 50)
private String sjcJobCode;
@Column(name = "sjc_job_name", nullable = false, length = 100)
private String sjcJobName;
@Column(name = "sjc_job_group", length = 50)
private String sjcJobGroup;
@Column(name = "sjc_enabled", nullable = false)
private Boolean sjcEnabled;
@Column(name = "sjc_cron_expression", nullable = false, length = 50)
private String sjcCronExpression;
@Column(name = "sjc_timezone", length = 50)
private String sjcTimezone;
@Column(name = "sjc_worker_method", length = 10)
private String sjcWorkerMethod;
@Column(name = "sjc_worker_url", length = 255, nullable = false)
private String sjcWorkerUrl;
@Column(name = "sjc_worker_timeout_seconds")
private Integer sjcWorkerTimeoutSeconds;
// execution parameters
@Column(name = "sjc_lookback_hours")
private Integer sjcLookbackHours;
@Column(name = "sjc_overlap_minutes")
private Integer sjcOverlapMinutes;
@Column(name = "sjc_max_records")
private Integer sjcMaxRecords;
@Column(name = "sjc_description", columnDefinition = "TEXT")
private String sjcDescription;
@Column(name = "sjc_created_at")
private LocalDateTime sjcCreatedAt;
@CreatedBy
@Column(name = "sjc_created_by", length = 50)
private String sjcCreatedBy;
@Column(name = "sjc_updated_at")
private LocalDateTime sjcUpdatedAt;
@LastModifiedBy
@Column(name = "sjc_updated_by", length = 50)
private String sjcUpdatedBy;
/* =========================
* required config references
* ========================= */
@JdbcTypeCode(SqlTypes.JSON)
@Column(name = "sjc_required_configs", columnDefinition = "jsonb")
private Map<String, List<String>> sjcRequiredConfigs;
}

View File

@ -0,0 +1,71 @@
package com.goi.erp.entity;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.time.LocalDateTime;
@Entity
@Table(name = "schedule_job_log")
@Data
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class ScheduleJobLog {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "sjl_id")
private Long sjlId;
@Column(name = "sjl_job_id", nullable = false)
private Long sjlJobId;
@Column(name = "sjl_status", nullable = false, length = 20)
private String sjlStatus;
@Column(name = "sjl_scheduled_at")
private LocalDateTime sjlScheduledAt;
@Column(name = "sjl_started_at", nullable = false)
private LocalDateTime sjlStartedAt;
@Column(name = "sjl_finished_at")
private LocalDateTime sjlFinishedAt;
@Column(name = "sjl_lookback_hours")
private Integer sjlLookbackHours;
@Column(name = "sjl_overlap_minutes")
private Integer sjlOverlapMinutes;
@Column(name = "sjl_max_records")
private Integer sjlMaxRecords;
@Column(name = "sjl_processed_count")
private Integer sjlProcessedCount;
@Column(name = "sjl_success_count")
private Integer sjlSuccessCount;
@Column(name = "sjl_fail_count")
private Integer sjlFailCount;
@Column(name = "sjl_error_code", length = 100)
private String sjlErrorCode;
@Column(name = "sjl_error_message", columnDefinition = "TEXT")
private String sjlErrorMessage;
@Column(name = "sjl_executed_by", length = 50)
private String sjlExecutedBy;
}

View File

@ -0,0 +1,30 @@
package com.goi.erp.repository;
import com.goi.erp.entity.ScheduleJobConfig;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
import java.util.List;
import java.util.Optional;
@Repository
public interface ScheduleJobConfigRepository
extends JpaRepository<ScheduleJobConfig, Long> {
/**
* job_code 단건 조회
*/
Optional<ScheduleJobConfig> findBySjcJobCode(String sjcJobCode);
/**
* job_code 존재 여부 확인
*/
boolean existsBySjcJobCode(String sjcJobCode);
/**
* 활성화된 스케줄 목록 조회
* (Scheduler Core에서 주기적으로 로딩)
*/
List<ScheduleJobConfig> findBySjcEnabledTrue();
}

View File

@ -0,0 +1,56 @@
package com.goi.erp.repository;
import com.goi.erp.entity.ScheduleJobLog;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import org.springframework.stereotype.Repository;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Optional;
@Repository
public interface ScheduleJobLogRepository
extends JpaRepository<ScheduleJobLog, Long> {
/**
* 특정 job의 가장 최근 성공 실행 시작 시간
* (Scheduler Core에서 lookback 기준 계산용)
*/
@Query("""
SELECT l.sjlStartedAt
FROM ScheduleJobLog l
WHERE l.sjlJobId = :jobId
AND l.sjlStatus = 'SUCCESS'
ORDER BY l.sjlStartedAt DESC
""")
Optional<LocalDateTime> findLastSuccessStartedAt(
@Param("jobId") Long jobId
);
/**
* 현재 실행 중인 로그 존재 여부
* (중복 실행 방지용)
*/
boolean existsBySjlJobIdAndSjlStatus(Long sjlJobId, String sjlStatus);
/**
* 특정 job의 최근 실행 로그 목록
* (Admin UI / 모니터링용)
*/
List<ScheduleJobLog> findTop50BySjlJobIdOrderBySjlStartedAtDesc(
Long sjlJobId
);
/**
* 특정 기간 동안 실행된 로그 조회
* (재처리 / 분석용)
*/
List<ScheduleJobLog> findBySjlJobIdAndSjlStartedAtBetween(
Long sjlJobId,
LocalDateTime from,
LocalDateTime to
);
}

View File

@ -198,4 +198,12 @@ public class ConfigService {
.cfgUpdatedBy(entity.getCfgUpdatedBy()) .cfgUpdatedBy(entity.getCfgUpdatedBy())
.build(); .build();
} }
/* ===============================
Scheduler
=============================== */
@Transactional(readOnly = true)
public String getRawValue(String module, String key) {
return getConfig(module, key).getCfgValue();
}
} }

View File

@ -0,0 +1,214 @@
package com.goi.erp.service;
import com.goi.erp.dto.ScheduleJobConfigRequestDto;
import com.goi.erp.dto.ScheduleJobConfigResponseDto;
import com.goi.erp.entity.ScheduleJobConfig;
import com.goi.erp.repository.ScheduleJobConfigRepository;
import jakarta.persistence.EntityNotFoundException;
import jakarta.transaction.Transactional;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import java.util.List;
import java.util.stream.Collectors;
@Service
@RequiredArgsConstructor
public class ScheduleJobConfigService {
private final ScheduleJobConfigRepository repository;
/* =========================
* CREATE
* ========================= */
@Transactional
public ScheduleJobConfigResponseDto create(ScheduleJobConfigRequestDto request) {
if (repository.existsBySjcJobCode(request.getSjcJobCode())) {
throw new IllegalArgumentException(
"Schedule job already exists. jobCode=" + request.getSjcJobCode()
);
}
if (request.getSjcWorkerUrl() == null || request.getSjcWorkerUrl().isBlank()) {
throw new IllegalArgumentException("Worker URL must not be empty");
}
ScheduleJobConfig entity = ScheduleJobConfig.builder()
.sjcJobCode(request.getSjcJobCode())
.sjcJobName(request.getSjcJobName())
.sjcJobGroup(request.getSjcJobGroup())
.sjcEnabled(
request.getSjcEnabled() != null ? request.getSjcEnabled() : Boolean.TRUE
)
.sjcCronExpression(request.getSjcCronExpression())
.sjcTimezone(
request.getSjcTimezone() != null ? request.getSjcTimezone() : "UTC"
)
/* execution parameters */
.sjcLookbackHours(request.getSjcLookbackHours())
.sjcOverlapMinutes(
request.getSjcOverlapMinutes() != null ? request.getSjcOverlapMinutes() : 0
)
.sjcMaxRecords(request.getSjcMaxRecords())
/* worker execution info */
.sjcWorkerMethod(
request.getSjcWorkerMethod() != null ? request.getSjcWorkerMethod() : "POST"
)
.sjcWorkerUrl(request.getSjcWorkerUrl())
.sjcWorkerTimeoutSeconds(
request.getSjcWorkerTimeoutSeconds() != null
? request.getSjcWorkerTimeoutSeconds()
: 300
)
.sjcDescription(request.getSjcDescription())
.build();
ScheduleJobConfig saved = repository.save(entity);
return toResponseDto(saved);
}
/* =========================
* READ
* ========================= */
@Transactional
public ScheduleJobConfigResponseDto get(Long sjcId) {
return repository.findById(sjcId)
.map(this::toResponseDto)
.orElseThrow(() ->
new EntityNotFoundException("Schedule job not found. id=" + sjcId)
);
}
@Transactional
public ScheduleJobConfigResponseDto getByJobCode(String jobCode) {
return repository.findBySjcJobCode(jobCode)
.map(this::toResponseDto)
.orElseThrow(() ->
new EntityNotFoundException("Schedule job not found. jobCode=" + jobCode)
);
}
@Transactional
public List<ScheduleJobConfigResponseDto> listAll() {
return repository.findAll()
.stream()
.map(this::toResponseDto)
.collect(Collectors.toList());
}
@Transactional
public List<ScheduleJobConfigResponseDto> listEnabled() {
return repository.findBySjcEnabledTrue()
.stream()
.map(this::toResponseDto)
.collect(Collectors.toList());
}
/* =========================
* UPDATE
* ========================= */
@Transactional
public ScheduleJobConfigResponseDto update(
Long sjcId,
ScheduleJobConfigRequestDto request
) {
ScheduleJobConfig entity = repository.findById(sjcId)
.orElseThrow(() ->
new EntityNotFoundException("Schedule job not found. id=" + sjcId)
);
entity.setSjcJobName(request.getSjcJobName());
entity.setSjcJobGroup(request.getSjcJobGroup());
entity.setSjcEnabled(request.getSjcEnabled());
entity.setSjcCronExpression(request.getSjcCronExpression());
entity.setSjcTimezone(request.getSjcTimezone());
/* execution parameters */
entity.setSjcLookbackHours(request.getSjcLookbackHours());
entity.setSjcOverlapMinutes(request.getSjcOverlapMinutes());
entity.setSjcMaxRecords(request.getSjcMaxRecords());
/* worker execution info */
if (request.getSjcWorkerMethod() != null) {
entity.setSjcWorkerMethod(request.getSjcWorkerMethod());
}
if (request.getSjcWorkerUrl() != null) {
entity.setSjcWorkerUrl(request.getSjcWorkerUrl());
}
if (request.getSjcWorkerTimeoutSeconds() != null) {
entity.setSjcWorkerTimeoutSeconds(request.getSjcWorkerTimeoutSeconds());
}
entity.setSjcDescription(request.getSjcDescription());
return toResponseDto(entity);
}
/* =========================
* ENABLE / DISABLE
* ========================= */
@Transactional
public void setEnabled(Long sjcId, boolean enabled) {
ScheduleJobConfig entity = repository.findById(sjcId)
.orElseThrow(() ->
new EntityNotFoundException("Schedule job not found. id=" + sjcId)
);
entity.setSjcEnabled(enabled);
}
/* =========================
* DELETE
* ========================= */
@Transactional
public void delete(Long sjcId) {
if (!repository.existsById(sjcId)) {
throw new EntityNotFoundException("Schedule job not found. id=" + sjcId);
}
repository.deleteById(sjcId);
}
/* =========================
* private mapper
* ========================= */
private ScheduleJobConfigResponseDto toResponseDto(ScheduleJobConfig entity) {
return ScheduleJobConfigResponseDto.builder()
.sjcId(entity.getSjcId())
.sjcJobCode(entity.getSjcJobCode())
.sjcJobName(entity.getSjcJobName())
.sjcJobGroup(entity.getSjcJobGroup())
.sjcEnabled(entity.getSjcEnabled())
.sjcCronExpression(entity.getSjcCronExpression())
.sjcTimezone(entity.getSjcTimezone())
/* execution parameters */
.sjcLookbackHours(entity.getSjcLookbackHours())
.sjcOverlapMinutes(entity.getSjcOverlapMinutes())
.sjcMaxRecords(entity.getSjcMaxRecords())
/* worker execution info */
.sjcWorkerMethod(entity.getSjcWorkerMethod())
.sjcWorkerUrl(entity.getSjcWorkerUrl())
.sjcWorkerTimeoutSeconds(entity.getSjcWorkerTimeoutSeconds())
.sjcDescription(entity.getSjcDescription())
.sjcCreatedAt(entity.getSjcCreatedAt())
.sjcUpdatedAt(entity.getSjcUpdatedAt())
.sjcCreatedBy(entity.getSjcCreatedBy())
.sjcUpdatedBy(entity.getSjcUpdatedBy())
.build();
}
}

View File

@ -0,0 +1,129 @@
package com.goi.erp.service;
import com.goi.erp.entity.ScheduleJobLog;
import com.goi.erp.repository.ScheduleJobLogRepository;
import jakarta.transaction.Transactional;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import java.time.LocalDateTime;
import java.util.Optional;
@Service
@RequiredArgsConstructor
public class ScheduleJobLogService {
private final ScheduleJobLogRepository repository;
/**
* 최근 성공 실행 시작 시각 조회
* (없으면 Optional.empty)
*/
@Transactional
public Optional<LocalDateTime> getLastSuccessStartedAt(Long jobId) {
return repository.findLastSuccessStartedAt(jobId);
}
/**
* 현재 실행 중인지 여부
* (중복 실행 방지)
*/
@Transactional
public boolean isRunning(Long jobId) {
return repository.existsBySjlJobIdAndSjlStatus(jobId, "RUNNING");
}
/**
* 실행 시작 로그 생성
*/
@Transactional
public ScheduleJobLog createRunningLog(
Long jobId,
LocalDateTime scheduledAt,
Integer lookbackHours,
Integer overlapMinutes,
Integer maxRecords,
String executedBy
) {
ScheduleJobLog log = ScheduleJobLog.builder()
.sjlJobId(jobId)
.sjlStatus("RUNNING")
.sjlScheduledAt(scheduledAt)
.sjlStartedAt(LocalDateTime.now())
.sjlLookbackHours(lookbackHours)
.sjlOverlapMinutes(overlapMinutes)
.sjlMaxRecords(maxRecords)
.sjlExecutedBy(executedBy)
.build();
return repository.save(log);
}
/**
* 실행 성공 처리
*/
@Transactional
public void markSuccess(
Long logId,
int processedCount,
int successCount,
int failCount
) {
ScheduleJobLog log = repository.findById(logId)
.orElseThrow(() ->
new IllegalStateException("ScheduleJobLog not found. id=" + logId)
);
log.setSjlStatus("SUCCESS");
log.setSjlFinishedAt(LocalDateTime.now());
log.setSjlProcessedCount(processedCount);
log.setSjlSuccessCount(successCount);
log.setSjlFailCount(failCount);
}
/**
* 실행 실패 처리
*/
@Transactional
public void markFailed(
Long logId,
String errorCode,
String errorMessage
) {
ScheduleJobLog log = repository.findById(logId)
.orElseThrow(() ->
new IllegalStateException("ScheduleJobLog not found. id=" + logId)
);
log.setSjlStatus("FAILED");
log.setSjlFinishedAt(LocalDateTime.now());
log.setSjlErrorCode(errorCode);
log.setSjlErrorMessage(errorMessage);
}
/**
* 실행 스킵 처리 (중복 실행, 비활성화 )
*/
@Transactional
public ScheduleJobLog markSkipped(
Long jobId,
LocalDateTime scheduledAt,
String reason,
String executedBy
) {
ScheduleJobLog log = ScheduleJobLog.builder()
.sjlJobId(jobId)
.sjlStatus("SKIPPED")
.sjlScheduledAt(scheduledAt)
.sjlStartedAt(LocalDateTime.now())
.sjlFinishedAt(LocalDateTime.now())
.sjlErrorMessage(reason)
.sjlExecutedBy(executedBy)
.build();
return repository.save(log);
}
}

View File

@ -0,0 +1,220 @@
package com.goi.erp.service;
import com.goi.erp.dto.ScheduleWorkerRequestDto;
import com.goi.erp.dto.ScheduleWorkerResponseDto;
import com.goi.erp.entity.ScheduleJobConfig;
import com.goi.erp.entity.ScheduleJobLog;
import com.goi.erp.repository.ScheduleJobConfigRepository;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.HttpEntity;
import org.springframework.http.HttpMethod;
import org.springframework.http.ResponseEntity;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.scheduling.support.CronExpression;
import org.springframework.stereotype.Service;
import org.springframework.web.client.RestTemplate;
import java.time.LocalDateTime;
import java.time.ZoneId;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
@Service
@RequiredArgsConstructor
@Slf4j
public class SchedulerCoreService {
private final ScheduleJobConfigRepository configRepository;
private final ScheduleJobLogService logService;
private final RestTemplate restTemplate;
private final ConfigService configService;
private final Map<Long, LocalDateTime> lastTickMap = new ConcurrentHashMap<>();
/**
* Scheduler Tick
* - 1분마다 실행
* - 실제 cron 판단은 내부에서 수행
*/
@Scheduled(fixedDelay = 60_000)
public void tick() {
List<ScheduleJobConfig> jobs = configRepository.findBySjcEnabledTrue();
for (ScheduleJobConfig job : jobs) {
try {
evaluateAndRun(job);
} catch (Exception e) {
log.error("Scheduler error. jobCode={}", job.getSjcJobCode(), e);
}
}
}
/* =========================
* core logic
* ========================= */
private void evaluateAndRun(ScheduleJobConfig job) {
ZoneId zoneId = ZoneId.of(
job.getSjcTimezone() != null ? job.getSjcTimezone() : "UTC"
);
LocalDateTime now = LocalDateTime.now(zoneId);
// 1. cron 판단
if (!shouldRun(job.getSjcId(), job.getSjcCronExpression(), now)) {
System.out.println(job.getSjcJobCode() + " shouldRun is false");
return;
}
// 2. 중복 실행 방지
if (Boolean.TRUE.equals(job.getSjcEnabled())
&& logService.isRunning(job.getSjcId())) {
log.info("Job already running. jobCode={}", job.getSjcJobCode());
System.out.println("Job already running. jobCode=" + job.getSjcJobCode());
return;
}
// 3. 실행 범위 계산
LocalDateTime from = now
.minusHours(job.getSjcLookbackHours() != null ? job.getSjcLookbackHours() : 0)
.minusMinutes(job.getSjcOverlapMinutes() != null ? job.getSjcOverlapMinutes() : 0);
LocalDateTime to = now;
// 4. RUNNING 로그 생성
ScheduleJobLog runningLog = logService.createRunningLog(
job.getSjcId(),
now,
job.getSjcLookbackHours(),
job.getSjcOverlapMinutes(),
job.getSjcMaxRecords(),
"scheduler"
);
// 5. worker 실행
try {
System.out.println(job.getSjcJobCode() + " worker start");
executeWorker(job, from, to, runningLog);
} catch (Exception e) {
logService.markFailed(
runningLog.getSjlId(),
"WORKER_ERROR",
e.getMessage()
);
throw e;
}
}
/* =========================
* worker execution
* ========================= */
private void executeWorker(
ScheduleJobConfig job,
LocalDateTime from,
LocalDateTime to,
ScheduleJobLog log
) {
Map<String, Map<String, String>> configSnapshot = buildConfigSnapshot(job);
ScheduleWorkerRequestDto request = ScheduleWorkerRequestDto.builder()
.jobCode(job.getSjcJobCode())
.from(from)
.to(to)
.maxRecords(job.getSjcMaxRecords())
.config(configSnapshot)
.build();
HttpMethod method = HttpMethod.valueOf(
job.getSjcWorkerMethod() != null
? job.getSjcWorkerMethod()
: "POST"
);
HttpEntity<ScheduleWorkerRequestDto> entity =
new HttpEntity<>(request);
ResponseEntity<ScheduleWorkerResponseDto> response =
restTemplate.exchange(
job.getSjcWorkerUrl(),
method,
entity,
ScheduleWorkerResponseDto.class
);
ScheduleWorkerResponseDto body = response.getBody();
System.out.println("response: "+body);
if (body == null) {
throw new IllegalStateException("Worker response is null");
}
if (body.isSuccess()) {
logService.markSuccess(
log.getSjlId(),
body.getProcessedCount(),
body.getSuccessCount(),
body.getFailCount()
);
} else {
logService.markFailed(
log.getSjlId(),
body.getErrorCode(),
body.getErrorMessage()
);
}
}
/* =========================
* cron 판단
* ========================= */
private boolean shouldRun(Long jobId, String cronExpression, LocalDateTime now) {
CronExpression cron = CronExpression.parse(cronExpression);
LocalDateTime last = lastTickMap.get(jobId);
if (last == null) {
lastTickMap.put(jobId, now);
return true; // 최초 1회 허용 (원하면 false로 바꿔도 )
}
LocalDateTime next = cron.next(last);
boolean shouldRun = next != null && !next.isAfter(now);
lastTickMap.put(jobId, now);
return shouldRun;
}
private Map<String, Map<String, String>> buildConfigSnapshot(
ScheduleJobConfig job
) {
if (job.getSjcRequiredConfigs() == null ||
job.getSjcRequiredConfigs().isEmpty()) {
return Map.of();
}
Map<String, Map<String, String>> snapshot = new HashMap<>();
job.getSjcRequiredConfigs().forEach((module, keys) -> {
Map<String, String> values = new HashMap<>();
for (String key : keys) {
values.put(
key,
configService.getRawValue(module, key)
);
}
snapshot.put(module, values);
});
return snapshot;
}
}