Files
Gnuboard7/app/Services/ScheduleService.php
T
HeuJung 7f8fb6870f fix(core,board,ecommerce): 조건부 검증 결함 패밀리 일괄 수정 및 실패 안내 노출
여러 화면에서 조건부(FormRequest) 검증이 우회되거나 과도하게 제한되던 결함을 한 계열로 묶어 수정한다.

- 검증 규칙 확장 필터 이름 10개를 나머지 92개와 동일 규칙으로 통일
- 설명 길이를 바이트가 아닌 글자 수로 계산(한글 오검출 해소)
- 비활성 언어팩 번역 보존한 채 수정 허용
- 알림 템플릿 제목 nullable 존중, 미지원 발송 수단 저장 차단
- 일정/실행 이력 목록 조회 검증 정합
- 게시판 첨부 미사용 시 빈 확장자 통과, new_display_hours/최소 길이 0 허용, 일괄 적용 override_values 범위 검증(SSoT)
- 이커머스 클레임 사유 조회 검증, 용어 정정
- 원문 i18n 키 노출 다수 해소, 저장/삭제 실패 시 사유 화면 노출
- 아이콘 크기/다크모드 대비 회귀 수정

번들 확장(모듈3·플러그인6·템플릿2·언어팩5) 버전 new-bump 및 CHANGELOG 반영, ja 언어팩 동기.
2026-07-23 10:54:17 +09:00

494 lines
17 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 {
// before_delete 훅 실행
HookManager::doAction('core.schedule.before_delete', $schedule);
// 실행 이력 삭제 (명시적 삭제 - CASCADE 의존 금지)
$schedule->histories()->delete();
$result = $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,
]);
Log::error('Schedule execution failed', [
'schedule_id' => $schedule->id,
'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 와 동일 사유 — 실행 직전이 마지막 방어선).
if (! ScheduleCommandValidator::isArtisanCommandAllowed($schedule->command)) {
throw ScheduleExecutionException::artisanNotAllowed();
}
Artisan::call($schedule->command, [], 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,
];
}
/**
* 활성화된 스케줄들을 조회합니다.
*
* @return Collection 활성화된 스케줄 컬렉션
*/
public function getActiveSchedules(): Collection
{
return $this->scheduleRepository->getActiveSchedules();
}
/**
* 실행 대기 중인 스케줄들을 조회합니다.
*
* @return Collection 실행 대기 중인 스케줄 컬렉션
*/
public function getDueSchedules(): Collection
{
return $this->scheduleRepository->getDueSchedules();
}
}