diff --git a/src/main/java/com/goi/erp/SecurityApplication.java b/src/main/java/com/goi/erp/SecurityApplication.java index 295217b..bb307c9 100644 --- a/src/main/java/com/goi/erp/SecurityApplication.java +++ b/src/main/java/com/goi/erp/SecurityApplication.java @@ -5,11 +5,13 @@ import org.springframework.boot.autoconfigure.SpringBootApplication; import org.springframework.boot.autoconfigure.domain.EntityScan; import org.springframework.data.jpa.repository.config.EnableJpaAuditing; import org.springframework.data.jpa.repository.config.EnableJpaRepositories; +import org.springframework.scheduling.annotation.EnableScheduling; @SpringBootApplication(scanBasePackages = {"com.goi.erp"}) @EnableJpaAuditing(auditorAwareRef = "auditorAware") @EntityScan(basePackages = {"com.goi.erp.entity"}) @EnableJpaRepositories(basePackages = {"com.goi.erp.repository"}) +@EnableScheduling public class SecurityApplication { public static void main(String[] args) { diff --git a/src/main/java/com/goi/erp/common/permission/PermissionChecker.java b/src/main/java/com/goi/erp/common/permission/PermissionChecker.java index 31a3387..9864ea9 100644 --- a/src/main/java/com/goi/erp/common/permission/PermissionChecker.java +++ b/src/main/java/com/goi/erp/common/permission/PermissionChecker.java @@ -2,31 +2,31 @@ package com.goi.erp.common.permission; public class PermissionChecker { - public static boolean canCreateCRM(PermissionSet set) { + public static boolean canCreateSYS(PermissionSet set) { 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; - 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; - 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; - 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; return set.hasFull( - PermissionEnums.Module.C, + PermissionEnums.Module.S, PermissionEnums.Action.R, PermissionEnums.Scope.A ); diff --git a/src/main/java/com/goi/erp/controller/ConfigController.java b/src/main/java/com/goi/erp/controller/ConfigController.java index 87f8792..665ed26 100644 --- a/src/main/java/com/goi/erp/controller/ConfigController.java +++ b/src/main/java/com/goi/erp/controller/ConfigController.java @@ -1,14 +1,19 @@ 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.ConfigResponseDto; import com.goi.erp.entity.ConfigChangeLog; -import com.goi.erp.service.ConfigService; import com.goi.erp.repository.ConfigChangeLogRepository; +import com.goi.erp.service.ConfigService; +import com.goi.erp.token.PermissionAuthenticationToken; import lombok.RequiredArgsConstructor; 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; @@ -27,13 +32,18 @@ public class ConfigController { /** * 단일 config 조회 - * GET /configs/{module}/{key} + * GET /config/{module}/{key} */ @GetMapping("/{module}/{key}") public ResponseEntity getConfig( @PathVariable String module, @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( configService.getOne(module, key) ); @@ -41,12 +51,17 @@ public class ConfigController { /** * 모듈별 config 목록 - * GET /configs/{module} + * GET /config/{module} */ @GetMapping("/{module}") public ResponseEntity> getConfigsByModule( @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( configService.getAllByModule(module) ); @@ -60,25 +75,34 @@ public class ConfigController { public ResponseEntity save( @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( configService.save(dto) ); } - /* =============================== CHANGE HISTORY =============================== */ /** * config 변경 이력 - * GET /configs/{module}/{key}/history + * GET /config/{module}/{key}/history */ @GetMapping("/{module}/{key}/history") public ResponseEntity> getHistory( @PathVariable String module, @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( configChangeLogRepository .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(); + } } diff --git a/src/main/java/com/goi/erp/controller/ScheduleJobConfigController.java b/src/main/java/com/goi/erp/controller/ScheduleJobConfigController.java new file mode 100644 index 0000000..671bbc7 --- /dev/null +++ b/src/main/java/com/goi/erp/controller/ScheduleJobConfigController.java @@ -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 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 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 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> 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 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 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 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 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(); + } +} diff --git a/src/main/java/com/goi/erp/dto/ScheduleJobConfigRequestDto.java b/src/main/java/com/goi/erp/dto/ScheduleJobConfigRequestDto.java new file mode 100644 index 0000000..03ca82b --- /dev/null +++ b/src/main/java/com/goi/erp/dto/ScheduleJobConfigRequestDto.java @@ -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> sjcRequiredConfigs; +} diff --git a/src/main/java/com/goi/erp/dto/ScheduleJobConfigResponseDto.java b/src/main/java/com/goi/erp/dto/ScheduleJobConfigResponseDto.java new file mode 100644 index 0000000..b73e063 --- /dev/null +++ b/src/main/java/com/goi/erp/dto/ScheduleJobConfigResponseDto.java @@ -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> sjcRequiredConfigs; +} diff --git a/src/main/java/com/goi/erp/dto/ScheduleWorkerRequestDto.java b/src/main/java/com/goi/erp/dto/ScheduleWorkerRequestDto.java new file mode 100644 index 0000000..eed244e --- /dev/null +++ b/src/main/java/com/goi/erp/dto/ScheduleWorkerRequestDto.java @@ -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> config; +} diff --git a/src/main/java/com/goi/erp/dto/ScheduleWorkerResponseDto.java b/src/main/java/com/goi/erp/dto/ScheduleWorkerResponseDto.java new file mode 100644 index 0000000..ece7881 --- /dev/null +++ b/src/main/java/com/goi/erp/dto/ScheduleWorkerResponseDto.java @@ -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; +} diff --git a/src/main/java/com/goi/erp/entity/ScheduleJobConfig.java b/src/main/java/com/goi/erp/entity/ScheduleJobConfig.java new file mode 100644 index 0000000..17fedfd --- /dev/null +++ b/src/main/java/com/goi/erp/entity/ScheduleJobConfig.java @@ -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> sjcRequiredConfigs; + +} diff --git a/src/main/java/com/goi/erp/entity/ScheduleJobLog.java b/src/main/java/com/goi/erp/entity/ScheduleJobLog.java new file mode 100644 index 0000000..c39e3f5 --- /dev/null +++ b/src/main/java/com/goi/erp/entity/ScheduleJobLog.java @@ -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; +} diff --git a/src/main/java/com/goi/erp/repository/ScheduleJobConfigRepository.java b/src/main/java/com/goi/erp/repository/ScheduleJobConfigRepository.java new file mode 100644 index 0000000..1e43a89 --- /dev/null +++ b/src/main/java/com/goi/erp/repository/ScheduleJobConfigRepository.java @@ -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 { + + /** + * job_code 로 단건 조회 + */ + Optional findBySjcJobCode(String sjcJobCode); + + /** + * job_code 존재 여부 확인 + */ + boolean existsBySjcJobCode(String sjcJobCode); + + /** + * 활성화된 스케줄 목록 조회 + * (Scheduler Core에서 주기적으로 로딩) + */ + List findBySjcEnabledTrue(); +} diff --git a/src/main/java/com/goi/erp/repository/ScheduleJobLogRepository.java b/src/main/java/com/goi/erp/repository/ScheduleJobLogRepository.java new file mode 100644 index 0000000..5dd6dc9 --- /dev/null +++ b/src/main/java/com/goi/erp/repository/ScheduleJobLogRepository.java @@ -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 { + + /** + * 특정 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 findLastSuccessStartedAt( + @Param("jobId") Long jobId + ); + + /** + * 현재 실행 중인 로그 존재 여부 + * (중복 실행 방지용) + */ + boolean existsBySjlJobIdAndSjlStatus(Long sjlJobId, String sjlStatus); + + /** + * 특정 job의 최근 실행 로그 목록 + * (Admin UI / 모니터링용) + */ + List findTop50BySjlJobIdOrderBySjlStartedAtDesc( + Long sjlJobId + ); + + /** + * 특정 기간 동안 실행된 로그 조회 + * (재처리 / 분석용) + */ + List findBySjlJobIdAndSjlStartedAtBetween( + Long sjlJobId, + LocalDateTime from, + LocalDateTime to + ); +} diff --git a/src/main/java/com/goi/erp/service/ConfigService.java b/src/main/java/com/goi/erp/service/ConfigService.java index 8fed558..8e39589 100644 --- a/src/main/java/com/goi/erp/service/ConfigService.java +++ b/src/main/java/com/goi/erp/service/ConfigService.java @@ -198,4 +198,12 @@ public class ConfigService { .cfgUpdatedBy(entity.getCfgUpdatedBy()) .build(); } + + /* =============================== + Scheduler + =============================== */ + @Transactional(readOnly = true) + public String getRawValue(String module, String key) { + return getConfig(module, key).getCfgValue(); + } } diff --git a/src/main/java/com/goi/erp/service/ScheduleJobConfigService.java b/src/main/java/com/goi/erp/service/ScheduleJobConfigService.java new file mode 100644 index 0000000..a77f747 --- /dev/null +++ b/src/main/java/com/goi/erp/service/ScheduleJobConfigService.java @@ -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 listAll() { + return repository.findAll() + .stream() + .map(this::toResponseDto) + .collect(Collectors.toList()); + } + + @Transactional + public List 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(); + } +} diff --git a/src/main/java/com/goi/erp/service/ScheduleJobLogService.java b/src/main/java/com/goi/erp/service/ScheduleJobLogService.java new file mode 100644 index 0000000..0c6fda1 --- /dev/null +++ b/src/main/java/com/goi/erp/service/ScheduleJobLogService.java @@ -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 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); + } +} diff --git a/src/main/java/com/goi/erp/service/SchedulerCoreService.java b/src/main/java/com/goi/erp/service/SchedulerCoreService.java new file mode 100644 index 0000000..54ddea4 --- /dev/null +++ b/src/main/java/com/goi/erp/service/SchedulerCoreService.java @@ -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 lastTickMap = new ConcurrentHashMap<>(); + + /** + * Scheduler Tick + * - 1분마다 실행 + * - 실제 cron 판단은 내부에서 수행 + */ + @Scheduled(fixedDelay = 60_000) + public void tick() { + + List 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> 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 entity = + new HttpEntity<>(request); + + ResponseEntity 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> buildConfigSnapshot( + ScheduleJobConfig job + ) { + if (job.getSjcRequiredConfigs() == null || + job.getSjcRequiredConfigs().isEmpty()) { + return Map.of(); + } + + Map> snapshot = new HashMap<>(); + + job.getSjcRequiredConfigs().forEach((module, keys) -> { + Map values = new HashMap<>(); + for (String key : keys) { + values.put( + key, + configService.getRawValue(module, key) + ); + } + snapshot.put(module, values); + }); + + return snapshot; + } + + +}