scheduleRepository->getPaginatedSchedules($filters); } /** * 새로운 스케줄을 생성합니다. * * @param array $data 스케줄 생성 데이터 * @return Schedule 생성된 스케줄 모델 * * @throws ValidationException 생성 실패 시 */ public function create(array $data): Schedule { try { // before_create 훅 실행 HookManager::doAction('core.schedule.before_create', $data); $data = HookManager::applyFilters('core.schedule.filter_create_data', $data); $schedule = $this->scheduleRepository->create($data); // after_create 훅 실행 HookManager::doAction('core.schedule.after_create', $schedule); return $schedule->fresh('creator'); } catch (Exception $e) { if ($e instanceof ValidationException) { throw $e; } throw ValidationException::withMessages([ 'general' => [__('schedule.create_failed', ['error' => $e->getMessage()])], ]); } } /** * 기존 스케줄 정보를 수정합니다. * * @param Schedule $schedule 수정할 스케줄 모델 * @param array $data 수정할 데이터 * @return Schedule 수정된 스케줄 모델 * * @throws ValidationException 수정 실패 시 */ public function update(Schedule $schedule, array $data): Schedule { try { // before_update 훅 실행 HookManager::doAction('core.schedule.before_update', $schedule, $data); // 스냅샷 캡처 (ChangeDetector용) $snapshot = $schedule->toArray(); $data = HookManager::applyFilters('core.schedule.filter_update_data', $data, $schedule); $this->scheduleRepository->update($schedule, $data); // after_update 훅 실행 (스냅샷 전달) HookManager::doAction('core.schedule.after_update', $schedule, $snapshot); return $schedule->fresh('creator'); } catch (Exception $e) { if ($e instanceof ValidationException) { throw $e; } throw ValidationException::withMessages([ 'general' => [__('schedule.update_failed', ['error' => $e->getMessage()])], ]); } } /** * 스케줄을 삭제합니다. * * @param Schedule $schedule 삭제할 스케줄 모델 * @return bool 삭제 성공 여부 * * @throws ValidationException 삭제 실패 시 */ public function delete(Schedule $schedule): bool { try { // 이력만 지워지고 스케줄은 남는 상태를 막기 위해 두 단계를 하나로 묶는다. $result = DB::transaction(function () use ($schedule) { // before_delete 훅 실행 HookManager::doAction('core.schedule.before_delete', $schedule); // 실행 이력 삭제 (명시적 삭제 - CASCADE 의존 금지) $schedule->histories()->delete(); return $this->scheduleRepository->delete($schedule); }); // after_delete 훅 실행 HookManager::doAction('core.schedule.after_delete', $schedule->id); return $result; } catch (Exception $e) { if ($e instanceof ValidationException) { throw $e; } throw ValidationException::withMessages([ 'general' => [__('schedule.delete_failed', ['error' => $e->getMessage()])], ]); } } /** * ID로 스케줄 상세 정보를 조회합니다. * * @param int $id 스케줄 ID * @return Schedule|null 스케줄 모델 또는 null */ public function getScheduleById(int $id): ?Schedule { return $this->scheduleRepository->findById($id); } /** * 스케줄을 즉시 실행합니다. * * @param Schedule $schedule 실행할 스케줄 * @param int|null $triggeredBy 수동 실행자 ID * @return ScheduleHistory 실행 이력 * * @throws ValidationException 실행 실패 시 */ public function runSchedule(Schedule $schedule, ?int $triggeredBy = null): ScheduleHistory { // before_run 훅 실행 HookManager::doAction('core.schedule.before_run', $schedule); // 실행 이력 생성 $history = $this->historyRepository->create([ 'schedule_id' => $schedule->id, 'started_at' => now(), 'status' => ScheduleResultStatus::Running, 'trigger_type' => $triggeredBy ? ScheduleTriggerType::Manual : ScheduleTriggerType::Scheduled, 'triggered_by' => $triggeredBy, ]); // 스케줄 상태 업데이트 $this->scheduleRepository->update($schedule, [ 'last_result' => ScheduleResultStatus::Running, 'last_run_at' => now(), ]); try { $startTime = microtime(true); $memoryStart = memory_get_usage(); // 타입별 실행 $result = match ($schedule->type) { ScheduleType::Artisan => $this->executeArtisanCommand($schedule), ScheduleType::Shell => $this->executeShellCommand($schedule), ScheduleType::Url => $this->executeUrlCall($schedule), }; $endTime = microtime(true); $memoryEnd = memory_get_usage(); // 이력 업데이트 (성공) $this->historyRepository->update($history, [ 'ended_at' => now(), 'duration' => (int) ($endTime - $startTime), 'status' => ScheduleResultStatus::Success, 'exit_code' => 0, 'memory_usage' => $memoryEnd - $memoryStart, 'output' => $result['output'] ?? null, ]); // 스케줄 상태 업데이트 (다음 실행 시각은 모델이 계산, 영속화는 Repository 경유) $schedule->last_result = ScheduleResultStatus::Success; $schedule->calculateNextRunAt(); $this->scheduleRepository->update($schedule, [ 'last_result' => ScheduleResultStatus::Success, 'next_run_at' => $schedule->next_run_at, ]); // after_run 훅 실행 HookManager::doAction('core.schedule.after_run', $schedule, $history); return $history->fresh(); } catch (Exception $e) { // 이력 업데이트 (실패) $this->historyRepository->update($history, [ 'ended_at' => now(), 'status' => ScheduleResultStatus::Failed, 'exit_code' => $e->getCode() ?: 1, 'error_output' => $e->getMessage(), ]); // 스케줄 상태 업데이트 (다음 실행 시각은 모델이 계산, 영속화는 Repository 경유) $schedule->last_result = ScheduleResultStatus::Failed; $schedule->calculateNextRunAt(); $this->scheduleRepository->update($schedule, [ 'last_result' => ScheduleResultStatus::Failed, 'next_run_at' => $schedule->next_run_at, ]); // 거부 사유는 error 레벨 한 줄만 보는 알림 파이프라인에서도 보여야 한다 — // 별도 warning 로그에만 남기면 그 경로에서는 "왜 막혔는지" 가 유실된다. Log::error('Schedule execution failed', [ 'schedule_id' => $schedule->id, 'type' => $schedule->type->value, 'command' => $schedule->command, 'reason' => $e instanceof ScheduleExecutionException ? $e->reasonCode : null, 'error' => $e->getMessage(), ]); throw ValidationException::withMessages([ 'general' => [__('schedule.run_failed', ['error' => $e->getMessage()])], ]); } } /** * Artisan 커맨드를 실행합니다. * * @param Schedule $schedule 스케줄 * @return array 실행 결과 * * @throws ScheduleExecutionException 차단목록에 걸린 명령일 때 */ private function executeArtisanCommand(Schedule $schedule): array { // 저장 시점 검증 도입 이전 데이터나 DB 직접 수정으로 들어온 값을 방어한다 // (isUrlCallAllowed 와 동일 사유 — 실행 직전이 마지막 방어선). $verdict = ScheduleCommandValidator::inspectArtisanCommand($schedule->command); if (! $verdict['allowed']) { // 업그레이드 점검 스텝을 두지 않으므로, 거부 사유는 이 로그가 유일한 운영 진단 통로다. Log::warning('Schedule artisan command rejected', [ 'schedule_id' => $schedule->id, 'command' => $schedule->command, 'reason' => $verdict['reason'], ]); throw ScheduleExecutionException::artisanNotAllowed($verdict['reason']); } // 문자열이 아닌 (명령명, 인자배열) 로 넘겨 StringInput 재파싱을 경유하지 않는다 — // parameters 가 비어 있으면 Laravel 이 문자열 전체를 다시 파싱해 // 검증한 이름과 다른 명령이 실행될 수 있다. Artisan::call($verdict['name'], $verdict['parameters'], new BufferedOutput); return [ 'output' => Artisan::output(), 'exit_code' => 0, ]; } /** * 쉘 명령을 실행합니다. * * @param Schedule $schedule 스케줄 * @return array 실행 결과 * * @throws ScheduleExecutionException 차단목록에 걸렸거나 실행이 실패했을 때 */ private function executeShellCommand(Schedule $schedule): array { // 저장 시점 검증 도입 이전 데이터나 DB 직접 수정으로 들어온 값을 방어한다 // (isUrlCallAllowed 와 동일 사유 — 실행 직전이 마지막 방어선). $arguments = ScheduleCommandValidator::tokenizeShellCommand($schedule->command); if ($arguments === null || ! ScheduleCommandValidator::isShellCommandAllowed($schedule->command)) { throw ScheduleExecutionException::shellNotAllowed(); } $timeout = $schedule->timeout ?? 60; // 문자열이 아닌 인자 배열로 넘겨 `/bin/sh -c` 를 경유하지 않는다 — // 파이프·`;`·`$()` 같은 셸 메타문자가 명령으로 해석될 여지를 없앤다. $result = Process::timeout($timeout)->run($arguments); if ($result->failed()) { throw ScheduleExecutionException::shellCommandFailed($result->errorOutput(), $result->exitCode()); } return [ 'output' => $result->output(), 'exit_code' => $result->exitCode(), ]; } /** * URL 호출을 실행합니다. * * @param Schedule $schedule 스케줄 * @return array 실행 결과 * * @throws ScheduleExecutionException 내부망 URL 이거나 호출이 실패했을 때 */ private function executeUrlCall(Schedule $schedule): array { // 저장된 URL 이 그대로 서버의 outbound 목적지가 되므로, 실행 직전에도 내부망 주소를 차단한다 // (저장 시점 검증 도입 이전 데이터나 DB 직접 수정으로 들어온 값 방어). if (! $this->isUrlCallAllowed($schedule->command)) { throw ScheduleExecutionException::urlNotPublic(); } $timeout = $schedule->timeout ?? 30; $response = Http::timeout($timeout)->get($schedule->command); if ($response->failed()) { throw ScheduleExecutionException::httpRequestFailed($response->status()); } return [ 'output' => 'HTTP Status: '.$response->status()."\n".$response->body(), 'exit_code' => 0, ]; } /** * 스케줄의 URL 호출이 허용되는 목적지인지 판정합니다. * * 사설 IP·localhost 등 내부 네트워크 주소는 기본 차단하되, 사내 엔드포인트를 주기 호출하는 * 정당한 운영을 위해 `security.allow_internal_outbound_urls` 로 허용할 수 있습니다. * 허용하더라도 userinfo 위장·비 HTTP scheme 은 계속 거부합니다. * * @param string $url 스케줄에 저장된 호출 URL * @return bool 호출을 허용하면 true */ private function isUrlCallAllowed(string $url): bool { $options = ['schemes' => ['http', 'https']]; if ((bool) g7_core_settings('security.allow_internal_outbound_urls', false)) { return OutboundUrlValidator::isStructurallySafeUrl($url, $options); } return OutboundUrlValidator::isPublicHttpUrl($url, $options); } /** * 스케줄을 복제합니다. * * @param Schedule $schedule 복제할 스케줄 * @return Schedule 복제된 스케줄 */ public function duplicate(Schedule $schedule): Schedule { return $this->scheduleRepository->duplicate($schedule); } /** * 여러 스케줄의 상태를 일괄 업데이트합니다. * * @param array $ids 스케줄 ID 배열 * @param bool $isActive 활성화 여부 * @return array 업데이트 결과 */ public function bulkUpdateStatus(array $ids, bool $isActive): array { // before_bulk_update 훅 실행 HookManager::doAction('core.schedule.before_bulk_update', $ids, $isActive); $updatedCount = DB::transaction(function () use ($ids, $isActive) { return $this->scheduleRepository->bulkUpdateStatus($ids, $isActive); }); // after_bulk_update 훅 실행 HookManager::doAction('core.schedule.after_bulk_update', $ids, $isActive, $updatedCount); return [ 'updated_count' => $updatedCount, ]; } /** * 여러 스케줄을 일괄 삭제합니다. * * @param array $ids 스케줄 ID 배열 * @return array 삭제 결과 */ public function bulkDelete(array $ids): array { // before_bulk_delete 훅 실행 HookManager::doAction('core.schedule.before_bulk_delete', $ids); $deletedCount = DB::transaction(function () use ($ids) { return $this->scheduleRepository->bulkDelete($ids); }); // after_bulk_delete 훅 실행 HookManager::doAction('core.schedule.after_bulk_delete', $ids, $deletedCount); return [ 'deleted_count' => $deletedCount, ]; } /** * 스케줄 관련 통계 정보를 조회합니다. * * @return array 스케줄 통계 데이터 */ public function getStatistics(): array { return $this->scheduleRepository->getStatistics(); } /** * 특정 스케줄의 실행 이력을 페이지네이션으로 조회합니다. * * @param int $scheduleId 스케줄 ID * @param array $filters 필터 조건 배열 * @return LengthAwarePaginator 페이지네이션된 이력 목록 */ public function getHistory(int $scheduleId, array $filters = []): LengthAwarePaginator { return $this->historyRepository->getPaginatedByScheduleId($scheduleId, $filters); } /** * 실행 이력을 삭제합니다. * * @param int $historyId 이력 ID * @return bool 삭제 성공 여부 */ public function deleteHistory(int $historyId): bool { $history = $this->historyRepository->findById($historyId); if (! $history) { return false; } return $this->historyRepository->delete($history); } /** * 여러 실행 이력을 일괄 삭제합니다. * * @param array $ids 이력 ID 배열 * @return array 삭제 결과 */ public function bulkDeleteHistory(array $ids): array { $deletedCount = $this->historyRepository->bulkDelete($ids); return [ 'deleted_count' => $deletedCount, ]; } /** * 보존 기간이 지난 실행 이력을 정리합니다 (자동 파기). * * 운영자가 고른 ID 를 지우는 bulkDeleteHistory 와 달리 대상을 기간으로 정하고 * 사람 없이 예약 실행되므로 별개의 훅을 발행합니다. * * @param int $days 보존 기간 (일) * @return int 삭제된 건수 */ public function pruneHistory(int $days): int { HookManager::doAction('core.schedule.before_prune_history', $days); $deletedCount = $this->historyRepository->deleteOlderThan($days); HookManager::doAction('core.schedule.after_prune_history', $days, $deletedCount); return $deletedCount; } /** * 활성화된 스케줄들을 조회합니다. * * @return Collection 활성화된 스케줄 컬렉션 */ public function getActiveSchedules(): Collection { return $this->scheduleRepository->getActiveSchedules(); } /** * 실행 대기 중인 스케줄들을 조회합니다. * * @return Collection 실행 대기 중인 스케줄 컬렉션 */ public function getDueSchedules(): Collection { return $this->scheduleRepository->getDueSchedules(); } }