Files
Gnuboard7/app/Services/ScheduleService.php
T
HeuJung 4e1ec6e001 fix(core,admin_basic,board,ecommerce,page,gdpr,verification): 설정 미러·자동 정리·탈퇴 원자성·일괄 선택 범위 정비
공개이슈 ··· 및 그 전수 해소분.

- 설정 저장·복원·초기화가 상주 프로세스 미러를 갱신하도록 단일 소유자화
- 만료 데이터 자동 정리 예약 12종 신설, 파기는 기본키 배치로 분할
- 회원 탈퇴를 전 경로 원자화하고 관리자 상태변경 경로를 정식 탈퇴로 통일
- 일괄 처리 목록의 선택을 화면에 보이는 행으로 한정(selectionScope)
- 비밀번호 정책 문구를 도메인 중립 라벨로 정정
- 정적 검사 2종·검증 스킬 설계 정합성 축 신설
2026-08-14 11:06:25 +09:00

534 lines
19 KiB
PHP

<?php
namespace App\Services;
use App\Contracts\Repositories\ScheduleHistoryRepositoryInterface;
use App\Contracts\Repositories\ScheduleRepositoryInterface;
use App\Enums\ScheduleResultStatus;
use App\Enums\ScheduleTriggerType;
use App\Enums\ScheduleType;
use App\Exceptions\ScheduleExecutionException;
use App\Extension\HookManager;
use App\Models\Schedule;
use App\Models\ScheduleHistory;
use App\Support\OutboundUrlValidator;
use App\Support\ScheduleCommandValidator;
use Exception;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Pagination\LengthAwarePaginator;
use Illuminate\Support\Facades\Artisan;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Process;
use Illuminate\Validation\ValidationException;
use Symfony\Component\Console\Output\BufferedOutput;
class ScheduleService
{
public function __construct(
private ScheduleRepositoryInterface $scheduleRepository,
private ScheduleHistoryRepositoryInterface $historyRepository
) {}
/**
* 필터링된 스케줄 목록을 페이지네이션으로 조회합니다.
*
* @param array $filters 필터 조건 배열
* @return LengthAwarePaginator 페이지네이션된 스케줄 목록
*/
public function getPaginatedSchedules(array $filters = []): LengthAwarePaginator
{
return $this->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();
}
}