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

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

332 lines
9.6 KiB
PHP

<?php
namespace App\Models;
use App\Enums\ExtensionOwnerType;
use App\Enums\ScheduleFrequency;
use App\Enums\ScheduleResultStatus;
use App\Enums\ScheduleType;
use App\Models\Concerns\HasUserOverrides;
use Carbon\Carbon;
use Cron\CronExpression;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
/**
* 스케줄 모델.
*
* 스케줄(크론) 작업을 관리하는 모델입니다.
*
* @since 7.0.0-beta.2 (HasUserOverrides 사전 설계)
*
* 현재는 시더 없이 사용자 수동 생성 중심입니다.
* 사용자가 trackable 필드를 수정하면 trait 의 Eloquent updating 이벤트가
* user_overrides 에 자동 기록합니다.
*
* 다음 버전에서 스케줄을 DB 로 관리할 때, 코어·확장 seeder 는
* `GenericEntitySyncHelper` 를 호출하여 아래 패턴으로 동기화하면 됩니다
* (사용자 수정 보존 + stale cleanup 자동 동작):
*
* ```php
* $helper = app(\App\Extension\Helpers\GenericEntitySyncHelper::class);
* $definedNames = [];
* foreach ($defaultSchedules as $data) {
* $helper->sync(Schedule::class, ['name' => $data['name']], $data);
* $definedNames[] = $data['name'];
* }
* $helper->cleanupStale(Schedule::class,
* ['extension_type' => $extType, 'extension_identifier' => $extId],
* 'name', $definedNames);
* ```
*/
class Schedule extends Model
{
use HasFactory, HasUserOverrides;
/**
* 사용자 수정 보존 대상 필드.
*
* @var array<int, string>
*/
protected array $trackableFields = [
'expression',
'command',
'timeout',
'is_active',
];
/** @var array<string, array> 활동 로그 추적 필드 */
public static array $activityLogFields = [
'name' => ['label_key' => 'activity_log.fields.name', 'type' => 'text'],
'description' => ['label_key' => 'activity_log.fields.description', 'type' => 'text'],
'command' => ['label_key' => 'activity_log.fields.command', 'type' => 'text'],
'expression' => ['label_key' => 'activity_log.fields.expression', 'type' => 'text'],
'without_overlapping' => ['label_key' => 'activity_log.fields.without_overlapping', 'type' => 'boolean'],
'run_in_maintenance' => ['label_key' => 'activity_log.fields.run_in_maintenance', 'type' => 'boolean'],
'timeout' => ['label_key' => 'activity_log.fields.timeout', 'type' => 'number'],
'is_active' => ['label_key' => 'activity_log.fields.is_active', 'type' => 'boolean'],
];
/**
* 테이블명
*
* @var string
*/
protected $table = 'schedules';
/**
* 대량 할당 가능한 속성
*
* @var array<int, string>
*/
protected $fillable = [
'name',
'description',
'type',
'command',
'expression',
'frequency',
'without_overlapping',
'run_in_maintenance',
'timeout',
'is_active',
'last_result',
'last_run_at',
'next_run_at',
'extension_type',
'extension_identifier',
'created_by',
'user_overrides',
];
/**
* 속성 캐스팅
*
* @return array<string, string>
*/
protected function casts(): array
{
return [
'type' => ScheduleType::class,
'frequency' => ScheduleFrequency::class,
'last_result' => ScheduleResultStatus::class,
'without_overlapping' => 'boolean',
'run_in_maintenance' => 'boolean',
'is_active' => 'boolean',
'extension_type' => ExtensionOwnerType::class,
'timeout' => 'integer',
'last_run_at' => 'datetime',
'next_run_at' => 'datetime',
'user_overrides' => 'array',
];
}
/**
* 모델 부팅 시 이벤트 등록
*/
protected static function boot(): void
{
parent::boot();
static::creating(function (Schedule $schedule) {
$schedule->calculateNextRunAt();
});
static::updating(function (Schedule $schedule) {
if ($schedule->isDirty(['expression', 'is_active'])) {
$schedule->calculateNextRunAt();
}
});
}
/**
* 다음 실행 시간을 계산합니다.
*/
public function calculateNextRunAt(): void
{
if (! $this->is_active) {
$this->next_run_at = null;
return;
}
try {
// cron 식은 사이트 설정 시간대로 해석한다 — 코드 예약(app.schedule_timezone)과
// 같은 기준이어야 같은 화면에서 "새벽 4시" 의 근거가 어긋나지 않는다.
// 계산 결과는 저장 타임존(app.timezone = UTC)으로 되돌려 저장한다.
$storeTimezone = (string) config('app.timezone', 'UTC');
$scheduleTimezone = (string) config('app.schedule_timezone', $storeTimezone);
$cron = new CronExpression($this->expression);
$nextRun = $cron->getNextRunDate('now', 0, false, $scheduleTimezone);
$this->next_run_at = Carbon::instance($nextRun)->setTimezone($storeTimezone);
} catch (\Exception $e) {
$this->next_run_at = null;
}
}
/**
* 등록자 관계
*/
public function creator(): BelongsTo
{
return $this->belongsTo(User::class, 'created_by');
}
/**
* 실행 이력 관계
*/
public function histories(): HasMany
{
return $this->hasMany(ScheduleHistory::class)->orderByDesc('started_at');
}
/**
* 마지막 실행 이력을 반환합니다.
*/
public function getLastHistoryAttribute(): ?ScheduleHistory
{
return $this->histories()->first();
}
/**
* 마지막 실행 소요 시간을 포맷팅하여 반환합니다.
*/
public function getLastDurationAttribute(): ?string
{
$lastHistory = $this->lastHistory;
if (! $lastHistory || ! $lastHistory->duration) {
return null;
}
$seconds = $lastHistory->duration;
if ($seconds < 60) {
return $seconds.__('schedule.duration.seconds');
}
$minutes = floor($seconds / 60);
$remainingSeconds = $seconds % 60;
return $minutes.__('schedule.duration.minutes').' '.$remainingSeconds.__('schedule.duration.seconds');
}
/**
* 작업 유형 라벨을 반환합니다.
*/
public function getTypeLabelAttribute(): string
{
return $this->type->label();
}
/**
* 실행 주기 라벨을 반환합니다.
*/
public function getFrequencyLabelAttribute(): string
{
return $this->frequency->label();
}
/**
* 마지막 실행 결과 라벨을 반환합니다.
*/
public function getLastResultLabelAttribute(): string
{
return $this->last_result->label();
}
/**
* 활성화된 스케줄만 필터링하는 스코프
*
* @param Builder $query 쿼리 빌더
* @return Builder 필터링된 쿼리 빌더
*/
public function scopeActive(Builder $query): Builder
{
return $query->where('is_active', true);
}
/**
* 비활성화된 스케줄만 필터링하는 스코프
*
* @param Builder $query 쿼리 빌더
* @return Builder 필터링된 쿼리 빌더
*/
public function scopeInactive(Builder $query): Builder
{
return $query->where('is_active', false);
}
/**
* 특정 타입으로 필터링하는 스코프
*
* @param Builder $query 쿼리 빌더
* @param ScheduleType $type 스케줄 타입
* @return Builder 필터링된 쿼리 빌더
*/
public function scopeByType(Builder $query, ScheduleType $type): Builder
{
return $query->where('type', $type);
}
/**
* 특정 주기로 필터링하는 스코프
*
* @param Builder $query 쿼리 빌더
* @param ScheduleFrequency $frequency 실행 주기
* @return Builder 필터링된 쿼리 빌더
*/
public function scopeByFrequency(Builder $query, ScheduleFrequency $frequency): Builder
{
return $query->where('frequency', $frequency);
}
/**
* 특정 확장으로 필터링하는 스코프
*
* @param Builder $query 쿼리 빌더
* @param ExtensionOwnerType $type 확장 타입
* @param string|null $identifier 확장 식별자
* @return Builder 필터링된 쿼리 빌더
*/
public function scopeByExtension(Builder $query, ExtensionOwnerType $type, ?string $identifier = null): Builder
{
$query->where('extension_type', $type);
if ($identifier !== null) {
$query->where('extension_identifier', $identifier);
}
return $query;
}
/**
* 마지막 실행 결과로 필터링하는 스코프
*
* @param Builder $query 쿼리 빌더
* @param ScheduleResultStatus $result 실행 결과
* @return Builder 필터링된 쿼리 빌더
*/
public function scopeByLastResult(Builder $query, ScheduleResultStatus $result): Builder
{
return $query->where('last_result', $result);
}
/**
* 실행 대기 중인 스케줄만 필터링하는 스코프
*
* @param Builder $query 쿼리 빌더
* @return Builder 필터링된 쿼리 빌더
*/
public function scopeDue(Builder $query): Builder
{
return $query->active()
->where('next_run_at', '<=', now());
}
}