feat(cron): add scheduled jobs configuration in admin (#59)
* feat(cron): add database schedule jobs instead of file * feat(cron): add scheduled jobs configuration in admin (#59) * reformat: fix codestyle (#59) * reformat: fix codestyle (#59) * feat: disable cron debug (#59)
This commit is contained in:
48
frontend/admin/src/components/CronExpressionSelect.vue
Normal file
48
frontend/admin/src/components/CronExpressionSelect.vue
Normal file
@@ -0,0 +1,48 @@
|
||||
<template>
|
||||
<Dropdown
|
||||
:model-value="modelValue"
|
||||
:options="options"
|
||||
option-label="label"
|
||||
option-value="value"
|
||||
placeholder="Интервал"
|
||||
class="tw:w-[14rem] tw:shrink-0"
|
||||
@update:model-value="$emit('update:modelValue', $event ?? '')"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import Dropdown from 'primevue/dropdown';
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
});
|
||||
|
||||
defineEmits(['update:modelValue']);
|
||||
|
||||
/** Пресеты интервалов (label — для отображения, value — cron expression) */
|
||||
const PRESETS = [
|
||||
{ label: 'Раз в минуту', value: '* * * * *' },
|
||||
{ label: 'Раз в 5 минут', value: '*/5 * * * *' },
|
||||
{ label: 'Раз в 10 минут', value: '*/10 * * * *' },
|
||||
{ label: 'Раз в час', value: '0 * * * *' },
|
||||
{ label: 'Раз в 3 часа', value: '0 */3 * * *' },
|
||||
{ label: 'Раз в 6 часов', value: '0 */6 * * *' },
|
||||
{ label: 'Раз в сутки', value: '0 0 * * *' },
|
||||
{ label: 'Раз в неделю', value: '0 0 * * 0' },
|
||||
];
|
||||
|
||||
const presetValues = new Set(PRESETS.map((p) => p.value));
|
||||
|
||||
/** Только пресеты; если текущее значение не из списка — показываем его в списке (уже сохранённое в БД), чтобы не терять отображение */
|
||||
const options = computed(() => {
|
||||
const current = props.modelValue ?? '';
|
||||
if (!current || presetValues.has(current)) {
|
||||
return PRESETS;
|
||||
}
|
||||
return [{ label: current, value: current }, ...PRESETS];
|
||||
});
|
||||
</script>
|
||||
104
frontend/admin/src/components/CronJobOrgUrlField.vue
Normal file
104
frontend/admin/src/components/CronJobOrgUrlField.vue
Normal file
@@ -0,0 +1,104 @@
|
||||
<template>
|
||||
<SettingsItem label="URL для cron-job.org">
|
||||
<template #default>
|
||||
<InputGroup>
|
||||
<Button
|
||||
icon="fa fa-refresh"
|
||||
severity="secondary"
|
||||
:loading="regeneratingUrl"
|
||||
v-tooltip.top="'Перегенерировать URL'"
|
||||
@click="confirmRegenerateUrl"
|
||||
/>
|
||||
<Button icon="fa fa-copy" severity="secondary" @click="copyToClipboard"/>
|
||||
<InputText readonly :model-value="cronJobOrgUrl" class="tw:w-full"/>
|
||||
</InputGroup>
|
||||
</template>
|
||||
<template #help>
|
||||
Создайте задачу на <a href="https://cron-job.org/" target="_blank" rel="noopener" class="tw:underline">cron-job.org</a>, укажите этот URL и интервал (например, каждые 5 минут). Метод: GET. Учитывайте лимиты по времени запроса на вашем хостинге — для тяжёлых задач возможны таймауты. При утечке URL нажмите «Перегенерировать URL» и обновите задачу на cron-job.org.
|
||||
</template>
|
||||
</SettingsItem>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, ref } from 'vue';
|
||||
import { useSettingsStore } from '@/stores/settings.js';
|
||||
import SettingsItem from '@/components/SettingsItem.vue';
|
||||
import InputText from 'primevue/inputtext';
|
||||
import Button from 'primevue/button';
|
||||
import InputGroup from 'primevue/inputgroup';
|
||||
import { useConfirm } from 'primevue/useconfirm';
|
||||
import { toastBus } from '@/utils/toastHelper.js';
|
||||
import { apiPost } from '@/utils/http.js';
|
||||
|
||||
const settings = useSettingsStore();
|
||||
const confirm = useConfirm();
|
||||
const regeneratingUrl = ref(false);
|
||||
|
||||
const cronJobOrgUrl = computed(() => settings.items.cron?.schedule_url ?? '');
|
||||
|
||||
function confirmRegenerateUrl(event) {
|
||||
confirm.require({
|
||||
group: 'popup',
|
||||
target: event.currentTarget,
|
||||
message: 'После смены URL его нужно будет обновить в задаче на cron-job.org. Продолжить?',
|
||||
icon: 'pi pi-exclamation-triangle',
|
||||
rejectProps: { label: 'Отмена', severity: 'secondary', outlined: true },
|
||||
acceptProps: { label: 'Перегенерировать', severity: 'secondary' },
|
||||
accept: () => regenerateUrl(),
|
||||
});
|
||||
}
|
||||
|
||||
async function regenerateUrl() {
|
||||
regeneratingUrl.value = true;
|
||||
try {
|
||||
const res = await apiPost('regenerateCronScheduleUrl', {});
|
||||
if (res?.success && res.data?.api_key) {
|
||||
settings.items.cron.api_key = res.data.api_key;
|
||||
if (res.data.schedule_url !== undefined) {
|
||||
settings.items.cron.schedule_url = res.data.schedule_url;
|
||||
}
|
||||
toastBus.emit('show', {
|
||||
severity: 'success',
|
||||
summary: 'URL обновлён',
|
||||
detail: 'Обновите URL в задаче на cron-job.org',
|
||||
life: 4000,
|
||||
});
|
||||
} else {
|
||||
toastBus.emit('show', {
|
||||
severity: 'error',
|
||||
summary: 'Ошибка',
|
||||
detail: res?.data?.error ?? 'Не удалось перегенерировать URL',
|
||||
life: 4000,
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
toastBus.emit('show', {
|
||||
severity: 'error',
|
||||
summary: 'Ошибка',
|
||||
detail: err?.response?.data?.error ?? 'Не удалось перегенерировать URL',
|
||||
life: 4000,
|
||||
});
|
||||
} finally {
|
||||
regeneratingUrl.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function copyToClipboard() {
|
||||
try {
|
||||
await navigator.clipboard.writeText(cronJobOrgUrl.value);
|
||||
toastBus.emit('show', {
|
||||
severity: 'success',
|
||||
summary: 'Скопировано',
|
||||
detail: 'URL скопирован в буфер обмена',
|
||||
life: 2000,
|
||||
});
|
||||
} catch (err) {
|
||||
toastBus.emit('show', {
|
||||
severity: 'error',
|
||||
summary: 'Ошибка',
|
||||
detail: 'Не удалось скопировать текст',
|
||||
life: 2000,
|
||||
});
|
||||
}
|
||||
}
|
||||
</script>
|
||||
108
frontend/admin/src/components/ScheduledJobsList.vue
Normal file
108
frontend/admin/src/components/ScheduledJobsList.vue
Normal file
@@ -0,0 +1,108 @@
|
||||
<template>
|
||||
<div class="tw:border tw:border-gray-300 tw:rounded-md tw:bg-white tw:overflow-hidden">
|
||||
<div class="tw:flex tw:flex-col">
|
||||
<div
|
||||
v-for="job in scheduledJobs"
|
||||
:key="job.id"
|
||||
class="tw:flex tw:items-center tw:gap-4 tw:py-3 tw:px-3 tw:border-b tw:border-gray-200 tw:last:border-b-0"
|
||||
>
|
||||
<ToggleSwitch
|
||||
:model-value="Boolean(job.is_enabled)"
|
||||
@update:model-value="onJobToggle(job, $event)"
|
||||
/>
|
||||
<div class="tw:min-w-0 tw:flex-1 tw:flex tw:flex-col tw:gap-0.5">
|
||||
<span class="tw:text-[inherit] tw:font-bold">{{ hasJobMeta(job.name) ? jobMeta(job.name).friendlyName : job.name }}</span>
|
||||
<span v-if="hasJobMeta(job.name) && jobMeta(job.name).description" class="tw:text-sm tw:text-gray-500">
|
||||
{{ jobMeta(job.name).description }}
|
||||
</span>
|
||||
</div>
|
||||
<span
|
||||
v-if="job.failed_reason"
|
||||
class="tw:inline-flex tw:items-center tw:gap-1 tw:shrink-0 tw:px-2 tw:py-1 tw:rounded tw:text-sm tw:bg-red-100 tw:text-red-700 tw:cursor-default"
|
||||
role="img"
|
||||
:aria-label="'Ошибка: ' + job.failed_reason"
|
||||
v-tooltip.top="errorTooltip(job)"
|
||||
>
|
||||
<i class="fa fa-exclamation-circle" aria-hidden="true"/>
|
||||
Ошибка
|
||||
</span>
|
||||
<div
|
||||
v-else
|
||||
class="tw:flex tw:flex-col tw:gap-0.5 tw:shrink-0 tw:items-end"
|
||||
>
|
||||
<span
|
||||
class="tw:inline-flex tw:items-center tw:shrink-0 tw:px-2 tw:py-1 tw:rounded tw:text-sm tw:bg-green-100 tw:text-green-800 tw:cursor-default"
|
||||
v-tooltip.top="'Дата последнего успешного запуска'"
|
||||
>
|
||||
{{ formatLastRun(job.last_success_at) }}
|
||||
</span>
|
||||
<span
|
||||
v-if="formatDuration(job.last_duration_seconds)"
|
||||
class="tw:text-xs tw:text-gray-500"
|
||||
>
|
||||
Время выполнения: {{ formatDuration(job.last_duration_seconds) }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="tw:shrink-0 tw:w-[14rem]">
|
||||
<CronExpressionSelect
|
||||
:model-value="job.cron_expression"
|
||||
@update:model-value="job.cron_expression = $event"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="!scheduledJobs.length" class="tw:text-gray-500 tw:py-4 tw:px-3">
|
||||
Нет запланированных задач
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import { useSettingsStore } from '@/stores/settings.js';
|
||||
import ToggleSwitch from 'primevue/toggleswitch';
|
||||
import CronExpressionSelect from '@/components/CronExpressionSelect.vue';
|
||||
|
||||
const settings = useSettingsStore();
|
||||
|
||||
/** Человекочитаемое имя и описание задач (ключ — job.name с бэкенда) */
|
||||
const JOB_META = {
|
||||
telecart_pulse_send_events: {
|
||||
friendlyName: 'Отправка данных в TeleCart Pulse',
|
||||
description: 'Отправка данных телеметрии о действиях в TeleCart. Требуется для сбора метрик по рассылкам и кампаниям, сделанных через сервис TeleCart Pulse',
|
||||
},
|
||||
};
|
||||
|
||||
function hasJobMeta(jobName) {
|
||||
return jobName in JOB_META;
|
||||
}
|
||||
|
||||
function jobMeta(jobName) {
|
||||
return JOB_META[jobName];
|
||||
}
|
||||
|
||||
const scheduledJobs = computed(() => settings.items.scheduled_jobs ?? []);
|
||||
|
||||
function formatLastRun(value) {
|
||||
if (!value) return '—';
|
||||
const d = typeof value === 'string' ? new Date(value.replace(' ', 'T')) : new Date(value);
|
||||
return Number.isNaN(d.getTime()) ? value : d.toLocaleString('ru-RU');
|
||||
}
|
||||
|
||||
function formatDuration(seconds) {
|
||||
if (seconds == null || seconds === '' || Number(seconds) <= 0) return '';
|
||||
const n = Number(seconds);
|
||||
if (Number.isNaN(n)) return '';
|
||||
if (n < 1) return 'менее 1с';
|
||||
return `${n % 1 ? n.toFixed(1) : Math.round(n)}с`;
|
||||
}
|
||||
|
||||
function errorTooltip(job) {
|
||||
const dateStr = formatLastRun(job.failed_at);
|
||||
return `${dateStr}\n${job.failed_reason ?? ''}`.trim();
|
||||
}
|
||||
|
||||
function onJobToggle(job, isEnabled) {
|
||||
job.is_enabled = isEnabled ? 1 : 0;
|
||||
}
|
||||
</script>
|
||||
@@ -1,24 +1,65 @@
|
||||
<template>
|
||||
<div class="form-group">
|
||||
<label class="col-sm-2 control-label" for="module_tgshop_status">
|
||||
<div class="col-sm-2 tw:flex tw:flex-col tw:gap-1">
|
||||
<label class="control-label" for="module_tgshop_status">
|
||||
{{ label }}
|
||||
</label>
|
||||
<a
|
||||
v-if="docHref"
|
||||
:href="docHref"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="tw:inline-flex tw:items-center tw:gap-1 tw:text-sm tw:text-gray-500 hover:tw:text-gray-700 tw:underline tw:decoration-dotted tw:decoration-1 tw:underline-offset-2 tw:w-fit tw:self-end"
|
||||
>
|
||||
<i class="fa fa-external-link tw:text-xs" aria-hidden="true"></i>
|
||||
<span>Документация</span>
|
||||
</a>
|
||||
</div>
|
||||
<div class="col-sm-10">
|
||||
<slot name="default"></slot>
|
||||
<div class="help-block">
|
||||
<slot name="help"></slot>
|
||||
</div>
|
||||
<div v-if="hasExpandable">
|
||||
<Button
|
||||
:label="expandableLabel"
|
||||
severity="info"
|
||||
link
|
||||
size="small"
|
||||
@click="expanded = !expanded"
|
||||
/>
|
||||
<div v-show="expanded" class="tw:mt-2 tw:space-y-2">
|
||||
<slot name="expandable"></slot>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, useSlots, computed } from 'vue';
|
||||
import Button from 'primevue/button';
|
||||
|
||||
const props = defineProps({
|
||||
label: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
/** Ссылка на документацию: отображается под label, открывается в новой вкладке */
|
||||
docHref: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
/** Подпись кнопки раскрытия блока #expandable (по умолчанию «Подробнее») */
|
||||
expandableLabel: {
|
||||
type: String,
|
||||
default: 'Подробнее',
|
||||
},
|
||||
});
|
||||
|
||||
const slots = useSlots();
|
||||
const hasExpandable = computed(() => !!slots.expandable);
|
||||
const expanded = ref(false);
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
|
||||
@@ -93,7 +93,11 @@ export const useSettingsStore = defineStore('settings', {
|
||||
|
||||
cron: {
|
||||
mode: 'disabled',
|
||||
api_key: '',
|
||||
schedule_url: '',
|
||||
},
|
||||
|
||||
scheduled_jobs: [],
|
||||
},
|
||||
}),
|
||||
|
||||
@@ -121,6 +125,14 @@ export const useSettingsStore = defineStore('settings', {
|
||||
...this.items,
|
||||
...response.data,
|
||||
};
|
||||
// Нормализуем типы у запланированных задач (is_enabled — число 0/1), чтобы переключение туда-обратно не меняло хеш
|
||||
const jobs = this.items.scheduled_jobs;
|
||||
if (Array.isArray(jobs)) {
|
||||
this.items.scheduled_jobs = jobs.map((job) => ({
|
||||
...job,
|
||||
is_enabled: job.is_enabled === true || job.is_enabled === 1 || job.is_enabled === '1' ? 1 : 0,
|
||||
}));
|
||||
}
|
||||
// Сохраняем хеш исходного состояния после загрузки
|
||||
this.originalItemsHash = md5(JSON.stringify(this.items));
|
||||
} else {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<SettingsItem label="Режим работы планировщика">
|
||||
<SettingsItem label="Режим работы планировщика" doc-href="https://docs.telecart.pro/features/cron/">
|
||||
<template #default>
|
||||
<SelectButton
|
||||
v-model="settings.items.cron.mode"
|
||||
@@ -13,22 +13,35 @@
|
||||
<div v-if="settings.items.cron.mode === 'disabled'" class="tw:text-red-600 tw:font-bold">
|
||||
Все фоновые задачи отключены.
|
||||
</div>
|
||||
<div v-else-if="settings.items.cron.mode === 'cron_job_org'">
|
||||
Задачи запускаются по вызову URL с сервиса <a href="https://cron-job.org/" target="_blank" rel="noopener" class="tw:underline">cron-job.org</a>. Подходит для лёгких задач; при большом количестве товаров или тяжёлых операциях возможны таймауты.
|
||||
</div>
|
||||
<div v-else>
|
||||
Рекомендуемый режим. Использует системный планировщик задач Linux.
|
||||
</div>
|
||||
|
||||
<div class="tw:mt-2">
|
||||
</template>
|
||||
<template #expandable>
|
||||
<p>
|
||||
<strong>Системный CRON (рекомендуется):</strong> Стабильное выполнение задач по расписанию, независимо от
|
||||
посещаемости сайта. Добавьте команду в CRON для автоматического выполнения каждые 5 минут.
|
||||
<strong>Системный CRON (рекомендуется):</strong> Задачи выполняются через команду PHP в оболочке сервера (CLI), без HTTP и без ограничений по времени запроса. Не зависит от посещаемости сайта и подходит для любых объёмов данных, в том числе для тяжёлых задач и больших каталогов. Требует доступа к серверу (SSH или панель с CRON). Добавьте команду ниже в планировщик (обычно <code class="tw:px-1 tw:py-0.5 tw:bg-gray-100 tw:dark:bg-gray-800 tw:rounded">crontab -e</code>) для запуска каждые 5 минут.
|
||||
</p>
|
||||
<p>
|
||||
<strong>cron-job.org:</strong> Внешний сервис по расписанию вызывает URL вашего сайта по HTTP. Не требует доступа к серверу — удобно для shared-хостинга без CRON. Ограничения: выполнение идёт через веб-запрос, поэтому есть лимиты по времени (timeout у хостинга и у cron-job.org). <strong>Не подходит для тяжёлых сайтов</strong> (много товаров, большие каталоги, тяжёлые задачи): запрос может обрываться по таймауту, задачи не успеют завершиться. Выбирайте этот способ только если нет доступа к системному CRON и нагрузка на планировщик небольшая.
|
||||
</p>
|
||||
<p>
|
||||
<strong>Выключено:</strong> Все фоновые задачи отключены. Планировщик не будет выполнять никаких задач.
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
</SettingsItem>
|
||||
|
||||
<div class="tw:relative tw:mt-4">
|
||||
<div
|
||||
:class="[
|
||||
'tw:transition-all tw:duration-200',
|
||||
settings.items.cron.mode === 'disabled'
|
||||
? 'tw:blur-[2px] tw:pointer-events-none tw:select-none'
|
||||
: '',
|
||||
]"
|
||||
>
|
||||
<SettingsItem label="Последний запуск CRON">
|
||||
<template #default>
|
||||
<div v-if="lastRunDate" class="tw:text-green-600 tw:font-bold tw:py-2">
|
||||
@@ -58,22 +71,48 @@
|
||||
5 минут.
|
||||
</template>
|
||||
</SettingsItem>
|
||||
|
||||
<CronJobOrgUrlField v-if="settings.items.cron.mode === 'cron_job_org'"/>
|
||||
|
||||
<SettingsItem label="Задачи планировщика">
|
||||
<template #default>
|
||||
<ScheduledJobsList />
|
||||
</template>
|
||||
<template #help>
|
||||
Включение и отключение задач планировщика. Дата последнего успешного запуска; при ошибке отображается иконка с подсказкой.
|
||||
</template>
|
||||
</SettingsItem>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="settings.items.cron.mode === 'disabled'"
|
||||
class="tw:absolute tw:inset-0 tw:flex tw:items-center tw:justify-center tw:rounded-lg tw:bg-white/80 tw:dark:bg-gray-900/80 tw:z-10"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<span class="tw:text-lg tw:font-semibold tw:text-gray-600 tw:dark:text-gray-400">
|
||||
Планировщик выключен
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import {computed} from 'vue';
|
||||
import {useSettingsStore} from "@/stores/settings.js";
|
||||
import SettingsItem from "@/components/SettingsItem.vue";
|
||||
import SelectButton from "primevue/selectbutton";
|
||||
import InputText from "primevue/inputtext";
|
||||
import Button from "primevue/button";
|
||||
import { computed } from 'vue';
|
||||
import { useSettingsStore } from '@/stores/settings.js';
|
||||
import SettingsItem from '@/components/SettingsItem.vue';
|
||||
import ScheduledJobsList from '@/components/ScheduledJobsList.vue';
|
||||
import CronJobOrgUrlField from '@/components/CronJobOrgUrlField.vue';
|
||||
import SelectButton from 'primevue/selectbutton';
|
||||
import InputText from 'primevue/inputtext';
|
||||
import Button from 'primevue/button';
|
||||
import InputGroup from 'primevue/inputgroup';
|
||||
import {toastBus} from "@/utils/toastHelper.js";
|
||||
import { toastBus } from '@/utils/toastHelper.js';
|
||||
|
||||
const settings = useSettingsStore();
|
||||
|
||||
const cronModes = [
|
||||
{value: 'system', label: 'Системный CRON (Linux)'},
|
||||
{value: 'cron_job_org', label: 'cron-job.org'},
|
||||
{value: 'disabled', label: 'Выключено'},
|
||||
];
|
||||
|
||||
|
||||
@@ -216,6 +216,7 @@ class ControllerExtensionModuleTgshop extends Controller
|
||||
'app' => [
|
||||
'shop_base_url' => HTTPS_CATALOG, // for catalog: HTTPS_SERVER, for admin: HTTPS_CATALOG
|
||||
'language_id' => (int) $this->config->get('config_language_id'),
|
||||
'oc_timezone' => $this->config->get('config_timezone'),
|
||||
],
|
||||
'paths' => [
|
||||
'images' => DIR_IMAGE,
|
||||
|
||||
@@ -5,7 +5,6 @@ use Console\ApplicationFactory;
|
||||
use Console\Commands\CacheClearCommand;
|
||||
use Console\Commands\CustomerCountsCommand;
|
||||
use Console\Commands\PulseSendEventsCommand;
|
||||
use Console\Commands\ScheduleListCommand;
|
||||
use Console\Commands\ScheduleRunCommand;
|
||||
use Console\Commands\VersionCommand;
|
||||
use Console\Commands\ImagesWarmupCacheCommand;
|
||||
@@ -21,7 +20,7 @@ if (PHP_SAPI !== 'cli') {
|
||||
}
|
||||
|
||||
$baseDir = __DIR__;
|
||||
$debug = true;
|
||||
$debug = false;
|
||||
|
||||
if (is_readable($baseDir . '/oc_telegram_shop.phar')) {
|
||||
require_once "phar://{$baseDir}/oc_telegram_shop.phar/vendor/autoload.php";
|
||||
@@ -33,8 +32,6 @@ if (is_readable($baseDir . '/oc_telegram_shop.phar')) {
|
||||
throw new RuntimeException('Unable to locate application directory.');
|
||||
}
|
||||
|
||||
date_default_timezone_set('UTC');
|
||||
|
||||
// Get Settings from Database
|
||||
$host = DB_HOSTNAME;
|
||||
$username = DB_USERNAME;
|
||||
@@ -46,11 +43,14 @@ $dsn = "mysql:host=$host;port=$port;dbname=$dbName";
|
||||
$pdo = new PDO($dsn, $username, $password);
|
||||
$connection = new MySqlConnection($pdo);
|
||||
$raw = $connection->select("SELECT value FROM `{$prefix}setting` WHERE `key` = 'module_telecart_settings'");
|
||||
$timezone = $connection->select("SELECT value FROM `{$prefix}setting` WHERE `key` = 'config_timezone'");
|
||||
$timezone = $timezone[0]['value'] ?? 'UTC';
|
||||
$json = json_decode($raw[0]['value'], true, 512, JSON_THROW_ON_ERROR);
|
||||
$items = Arr::mergeArraysRecursively($json, [
|
||||
'app' => [
|
||||
'shop_base_url' => HTTPS_CATALOG, // for catalog: HTTPS_SERVER, for admin: HTTPS_CATALOG
|
||||
'language_id' => 1,
|
||||
'oc_timezone' => $timezone,
|
||||
],
|
||||
'paths' => [
|
||||
'images' => DIR_IMAGE,
|
||||
@@ -96,7 +96,6 @@ $app->boot();
|
||||
$console = new Application('TeleCart', module_version());
|
||||
$console->add($app->get(VersionCommand::class));
|
||||
$console->add($app->get(ScheduleRunCommand::class));
|
||||
$console->add($app->get(ScheduleListCommand::class));
|
||||
$console->add($app->get(PulseSendEventsCommand::class));
|
||||
$console->add($app->get(ImagesWarmupCacheCommand::class));
|
||||
$console->add($app->get(ImagesCacheClearCommand::class));
|
||||
|
||||
@@ -4,7 +4,9 @@ namespace Bastion\Handlers;
|
||||
|
||||
use Bastion\Exceptions\BotTokenConfiguratorException;
|
||||
use Bastion\Services\BotTokenConfigurator;
|
||||
use Bastion\Services\CronApiKeyRegenerator;
|
||||
use Bastion\Services\SettingsService;
|
||||
use Carbon\Carbon;
|
||||
use Exception;
|
||||
use Openguru\OpenCartFramework\Cache\CacheInterface;
|
||||
use Openguru\OpenCartFramework\Config\Settings;
|
||||
@@ -13,35 +15,56 @@ use Openguru\OpenCartFramework\Http\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Openguru\OpenCartFramework\QueryBuilder\Builder;
|
||||
use Openguru\OpenCartFramework\QueryBuilder\Connections\ConnectionInterface;
|
||||
use Openguru\OpenCartFramework\Scheduler\Models\ScheduledJob;
|
||||
use Openguru\OpenCartFramework\Support\Arr;
|
||||
use Psr\Log\LoggerInterface;
|
||||
|
||||
class SettingsHandler
|
||||
{
|
||||
private BotTokenConfigurator $botTokenConfigurator;
|
||||
private CronApiKeyRegenerator $cronApiKeyRegenerator;
|
||||
private Settings $settings;
|
||||
private SettingsService $settingsUpdateService;
|
||||
private CacheInterface $cache;
|
||||
private LoggerInterface $logger;
|
||||
private Builder $builder;
|
||||
private ConnectionInterface $connection;
|
||||
private ScheduledJob $scheduledJob;
|
||||
|
||||
public function __construct(
|
||||
BotTokenConfigurator $botTokenConfigurator,
|
||||
CronApiKeyRegenerator $cronApiKeyRegenerator,
|
||||
Settings $settings,
|
||||
SettingsService $settingsUpdateService,
|
||||
CacheInterface $cache,
|
||||
LoggerInterface $logger,
|
||||
Builder $builder,
|
||||
ConnectionInterface $connection
|
||||
ConnectionInterface $connection,
|
||||
ScheduledJob $scheduledJob
|
||||
) {
|
||||
$this->botTokenConfigurator = $botTokenConfigurator;
|
||||
$this->cronApiKeyRegenerator = $cronApiKeyRegenerator;
|
||||
$this->settings = $settings;
|
||||
$this->settingsUpdateService = $settingsUpdateService;
|
||||
$this->cache = $cache;
|
||||
$this->logger = $logger;
|
||||
$this->builder = $builder;
|
||||
$this->connection = $connection;
|
||||
$this->scheduledJob = $scheduledJob;
|
||||
}
|
||||
|
||||
/**
|
||||
* Перегенерировать секретный ключ в URL для cron-job.org (сохраняет cron.api_key).
|
||||
*/
|
||||
public function regenerateCronScheduleUrl(Request $request): JsonResponse
|
||||
{
|
||||
$newApiKey = $this->cronApiKeyRegenerator->regenerate();
|
||||
$scheduleUrl = $this->buildCronScheduleUrl(
|
||||
$this->settings->get('app.shop_base_url', ''),
|
||||
$newApiKey
|
||||
);
|
||||
|
||||
return new JsonResponse(['api_key' => $newApiKey, 'schedule_url' => $scheduleUrl]);
|
||||
}
|
||||
|
||||
public function configureBotToken(Request $request): JsonResponse
|
||||
@@ -81,6 +104,12 @@ class SettingsHandler
|
||||
// Add CRON system details (read-only)
|
||||
$data['cron']['cli_path'] = BP_REAL_BASE_PATH . '/cli.php';
|
||||
$data['cron']['last_run'] = $this->getLastCronRunDate();
|
||||
$data['cron']['schedule_url'] = $this->buildCronScheduleUrl(
|
||||
$this->settings->get('app.shop_base_url', ''),
|
||||
$this->settings->get('cron.api_key', '')
|
||||
);
|
||||
|
||||
$data['scheduled_jobs'] = $this->scheduledJob->all();
|
||||
|
||||
$forms = $this->builder->newQuery()
|
||||
->from('telecart_forms')
|
||||
@@ -106,6 +135,21 @@ class SettingsHandler
|
||||
return new JsonResponse(compact('data'));
|
||||
}
|
||||
|
||||
private function buildCronScheduleUrl(string $shopBaseUrl, string $apiKey): string
|
||||
{
|
||||
$base = rtrim($shopBaseUrl, '/');
|
||||
if ($base === '') {
|
||||
return '';
|
||||
}
|
||||
$params = http_build_query([
|
||||
'route' => 'extension/tgshop/handle',
|
||||
'api_action' => 'runSchedule',
|
||||
'api_key' => $apiKey,
|
||||
]);
|
||||
|
||||
return $base . '/index.php?' . $params;
|
||||
}
|
||||
|
||||
public function saveSettingsForm(Request $request): JsonResponse
|
||||
{
|
||||
$input = $request->json();
|
||||
@@ -116,6 +160,7 @@ class SettingsHandler
|
||||
if (isset($input['cron'])) {
|
||||
unset($input['cron']['cli_path']);
|
||||
unset($input['cron']['last_run']);
|
||||
unset($input['cron']['schedule_url']);
|
||||
}
|
||||
|
||||
$this->settingsUpdateService->update(
|
||||
@@ -146,6 +191,25 @@ class SettingsHandler
|
||||
]);
|
||||
}
|
||||
|
||||
// Update scheduled jobs is_enabled and cron_expression
|
||||
$scheduledJobs = Arr::get($input, 'scheduled_jobs', []);
|
||||
foreach ($scheduledJobs as $job) {
|
||||
$id = (int) ($job['id'] ?? 0);
|
||||
if ($id <= 0) {
|
||||
continue;
|
||||
}
|
||||
$isEnabled = filter_var($job['is_enabled'] ?? false, FILTER_VALIDATE_BOOLEAN);
|
||||
if ($isEnabled) {
|
||||
$this->scheduledJob->enable($id);
|
||||
} else {
|
||||
$this->scheduledJob->disable($id);
|
||||
}
|
||||
$cronExpression = trim((string) ($job['cron_expression'] ?? ''));
|
||||
if ($cronExpression !== '') {
|
||||
$this->scheduledJob->updateCronExpression($id, $cronExpression);
|
||||
}
|
||||
}
|
||||
|
||||
return new JsonResponse([], Response::HTTP_ACCEPTED);
|
||||
}
|
||||
|
||||
@@ -174,7 +238,7 @@ class SettingsHandler
|
||||
$lastRunTimestamp = $this->cache->get("scheduler.global_last_run");
|
||||
|
||||
if ($lastRunTimestamp) {
|
||||
return date('d.m.Y H:i:s', (int)$lastRunTimestamp);
|
||||
return Carbon::createFromTimestamp($lastRunTimestamp)->toDateTimeString();
|
||||
}
|
||||
|
||||
return null;
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Bastion\Services;
|
||||
|
||||
use Openguru\OpenCartFramework\Config\Settings;
|
||||
|
||||
class CronApiKeyRegenerator
|
||||
{
|
||||
private Settings $settings;
|
||||
private SettingsService $settingsUpdateService;
|
||||
|
||||
public function __construct(Settings $settings, SettingsService $settingsUpdateService)
|
||||
{
|
||||
$this->settings = $settings;
|
||||
$this->settingsUpdateService = $settingsUpdateService;
|
||||
}
|
||||
|
||||
/**
|
||||
* Генерирует новый API-ключ для URL cron-job.org и сохраняет в настройки.
|
||||
*
|
||||
* @return string новый api_key
|
||||
*/
|
||||
public function regenerate(): string
|
||||
{
|
||||
$newApiKey = bin2hex(random_bytes(32));
|
||||
$all = $this->settings->getAll();
|
||||
if (! isset($all['cron'])) {
|
||||
$all['cron'] = [];
|
||||
}
|
||||
$all['cron']['api_key'] = $newApiKey;
|
||||
$this->settingsUpdateService->update($all);
|
||||
|
||||
return $newApiKey;
|
||||
}
|
||||
}
|
||||
@@ -27,6 +27,7 @@ return [
|
||||
'getSettingsForm' => [SettingsHandler::class, 'getSettingsForm'],
|
||||
'getTelegramCustomers' => [TelegramCustomersHandler::class, 'getCustomers'],
|
||||
'resetCache' => [SettingsHandler::class, 'resetCache'],
|
||||
'regenerateCronScheduleUrl' => [SettingsHandler::class, 'regenerateCronScheduleUrl'],
|
||||
'saveSettingsForm' => [SettingsHandler::class, 'saveSettingsForm'],
|
||||
'getSystemInfo' => [SettingsHandler::class, 'getSystemInfo'],
|
||||
'sendMessageToCustomer' => [SendMessageHandler::class, 'sendMessage'],
|
||||
|
||||
@@ -117,5 +117,6 @@ HTML,
|
||||
|
||||
'cron' => [
|
||||
'mode' => 'disabled',
|
||||
'api_key' => '',
|
||||
],
|
||||
];
|
||||
|
||||
@@ -1,21 +0,0 @@
|
||||
<?php
|
||||
|
||||
/** @var \Openguru\OpenCartFramework\Scheduler\ScheduleJobRegistry $scheduler */
|
||||
|
||||
// Define your scheduled tasks here.
|
||||
// The $scheduler variable is available in this scope.
|
||||
|
||||
// Example: Running a task class every 5 minutes. Class should have execute method.
|
||||
// $scheduler->add(\My\Task\Class::class)->everyFiveMinutes();
|
||||
|
||||
// Example: Running a closure every hour
|
||||
// $scheduler->add(function () {
|
||||
// // Do something
|
||||
// }, 'my_closure_task')->everyHour();
|
||||
|
||||
// Example: Custom cron expression
|
||||
// $scheduler->add(\My\Task\Class::class)->at('0 12 * * *');
|
||||
|
||||
use Bastion\ScheduledTasks\TeleCartPulseSendEventsTask;
|
||||
|
||||
$scheduler->add(TeleCartPulseSendEventsTask::class, 'telecart_pulse_send_events')->everyTenMinutes();
|
||||
@@ -1,72 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace Console\Commands;
|
||||
|
||||
use Openguru\OpenCartFramework\Container\Container;
|
||||
use Openguru\OpenCartFramework\Scheduler\ScheduleJobRegistry;
|
||||
use Openguru\OpenCartFramework\Scheduler\SchedulerService;
|
||||
use Symfony\Component\Console\Command\Command;
|
||||
use Symfony\Component\Console\Helper\Table;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
|
||||
class ScheduleListCommand extends TeleCartCommand
|
||||
{
|
||||
private SchedulerService $schedulerService;
|
||||
private Container $container;
|
||||
|
||||
protected static $defaultName = 'schedule:list';
|
||||
protected static $defaultDescription = 'List all scheduled tasks and their status';
|
||||
|
||||
public function __construct(SchedulerService $schedulerService, Container $container)
|
||||
{
|
||||
parent::__construct();
|
||||
$this->schedulerService = $schedulerService;
|
||||
$this->container = $container;
|
||||
}
|
||||
|
||||
protected function execute(InputInterface $input, OutputInterface $output): int
|
||||
{
|
||||
$registry = new ScheduleJobRegistry($this->container);
|
||||
|
||||
// Load schedule config
|
||||
$configFile = BP_PHAR_BASE_PATH . '/configs/schedule.php';
|
||||
if (file_exists($configFile)) {
|
||||
$scheduler = $registry; // Variable name used in config file
|
||||
require $configFile;
|
||||
}
|
||||
|
||||
$jobs = $registry->getJobs();
|
||||
$table = new Table($output);
|
||||
$table->setHeaders(['Name / Class', 'Cron Expression', 'Last Run', 'Last Failure']);
|
||||
|
||||
foreach ($jobs as $job) {
|
||||
$id = $job->getId();
|
||||
$lastRun = $this->schedulerService->getLastRun($id);
|
||||
$lastFailure = $this->schedulerService->getLastFailure($id);
|
||||
|
||||
$lastRunText = $lastRun ? date('Y-m-d H:i:s', $lastRun) : 'Never';
|
||||
|
||||
$lastFailureText = 'None';
|
||||
if ($lastFailure) {
|
||||
$lastFailureText = date('Y-m-d H:i:s', $lastFailure['time']) . "\n" . substr($lastFailure['message'], 0, 50);
|
||||
}
|
||||
|
||||
$table->addRow([
|
||||
$job->getName(),
|
||||
// We don't have getExpression public yet on Job, assuming we might need to add it or it's not critical.
|
||||
// Wait, Job class stores expression but doesn't expose it via public getter.
|
||||
// I should add getExpression() to Job.php if I want to show it.
|
||||
// For now, let's assume we add it.
|
||||
method_exists($job, 'getExpression') ? $job->getExpression() : '???',
|
||||
$lastRunText,
|
||||
$lastFailureText
|
||||
]);
|
||||
}
|
||||
|
||||
$table->render();
|
||||
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ use Openguru\OpenCartFramework\Config\Settings;
|
||||
use Openguru\OpenCartFramework\Scheduler\SchedulerService;
|
||||
use Symfony\Component\Console\Command\Command;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
use Symfony\Component\Console\Input\InputOption;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
|
||||
class ScheduleRunCommand extends TeleCartCommand
|
||||
@@ -24,21 +25,34 @@ class ScheduleRunCommand extends TeleCartCommand
|
||||
$this->settings = $settings;
|
||||
}
|
||||
|
||||
protected function configure(): void
|
||||
{
|
||||
$this->addOption(
|
||||
'ignore-global-lock',
|
||||
null,
|
||||
InputOption::VALUE_NONE,
|
||||
'Ignore global scheduler lock (e.g. when running multiple cron instances)'
|
||||
);
|
||||
}
|
||||
|
||||
protected function execute(InputInterface $input, OutputInterface $output): int
|
||||
{
|
||||
$mode = $this->settings->get('cron.mode', 'disabled');
|
||||
if ($mode !== 'system') {
|
||||
$output->writeln('<comment>Scheduler is disabled. Skipping CLI execution.</comment>');
|
||||
$output->writeln('<comment>Scheduler not in CRON mode. Skipping CLI execution.</comment>');
|
||||
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
|
||||
$output->writeln(sprintf(
|
||||
$output->writeln(
|
||||
sprintf(
|
||||
'[%s] <info>TeleCart Scheduler Running...</info>',
|
||||
Carbon::now()->toJSON(),
|
||||
));
|
||||
)
|
||||
);
|
||||
|
||||
$result = $this->scheduler->run();
|
||||
$ignoreGlobalLock = (bool) $input->getOption('ignore-global-lock');
|
||||
$result = $this->scheduler->run($ignoreGlobalLock);
|
||||
|
||||
// Print Executed
|
||||
if (empty($result->executed)) {
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
<?php
|
||||
|
||||
use Openguru\OpenCartFramework\Migrations\Migration;
|
||||
|
||||
return new class extends Migration {
|
||||
public function up(): void
|
||||
{
|
||||
$sql = <<<SQL
|
||||
CREATE TABLE IF NOT EXISTS `telecart_scheduled_jobs` (
|
||||
`id` int(10) UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
|
||||
`name` varchar(255) COLLATE utf8mb4_unicode_ci NOT NULL,
|
||||
`task` varchar(1024) COLLATE utf8mb4_unicode_ci NOT NULL,
|
||||
`is_enabled` tinyint(1) NOT NULL DEFAULT 1,
|
||||
`cron_expression` varchar(64) COLLATE utf8mb4_unicode_ci NOT NULL,
|
||||
`last_success_at` DATETIME DEFAULT NULL,
|
||||
`last_duration_seconds` FLOAT UNSIGNED NOT NULL DEFAULT 0.0,
|
||||
`failed_at` DATETIME DEFAULT NULL,
|
||||
`failed_reason` varchar(1024) COLLATE utf8mb4_unicode_ci DEFAULT NULL
|
||||
)
|
||||
ENGINE=InnoDB
|
||||
DEFAULT CHARSET=utf8mb4
|
||||
COLLATE=utf8mb4_unicode_ci
|
||||
SQL;
|
||||
|
||||
$this->database->statement($sql);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
<?php
|
||||
|
||||
use Bastion\ScheduledTasks\TeleCartPulseSendEventsTask;
|
||||
use Openguru\OpenCartFramework\Migrations\Migration;
|
||||
|
||||
return new class extends Migration {
|
||||
public function up(): void
|
||||
{
|
||||
$this->database->insert('telecart_scheduled_jobs', [
|
||||
'name' => 'telecart_pulse_send_events',
|
||||
'task' => TeleCartPulseSendEventsTask::class,
|
||||
'is_enabled' => 0,
|
||||
'cron_expression' => '*/10 * * * *',
|
||||
'last_success_at' => null,
|
||||
'last_duration_seconds' => 0,
|
||||
'failed_at' => null,
|
||||
'failed_reason' => null,
|
||||
]);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
<?php
|
||||
|
||||
use Bastion\Services\CronApiKeyRegenerator;
|
||||
use Openguru\OpenCartFramework\Config\Settings;
|
||||
use Openguru\OpenCartFramework\Migrations\Migration;
|
||||
|
||||
return new class extends Migration {
|
||||
public function up(): void
|
||||
{
|
||||
$settings = $this->app->get(Settings::class);
|
||||
$currentKey = $settings->get('cron.api_key', '');
|
||||
|
||||
if ($currentKey !== '') {
|
||||
$this->logger->info('cron.api_key already set, migration skipped');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$regenerator = $this->app->get(CronApiKeyRegenerator::class);
|
||||
$regenerator->regenerate();
|
||||
$this->logger->info('cron.api_key initialized');
|
||||
}
|
||||
};
|
||||
@@ -54,6 +54,9 @@ class Application extends Container implements LoggerAwareInterface
|
||||
|
||||
public function boot(): Application
|
||||
{
|
||||
$timezone = $this->getConfigValue('app.oc_timezone', 'UTC');
|
||||
date_default_timezone_set($timezone);
|
||||
|
||||
$this->loadEnvironmentVariables();
|
||||
|
||||
$action = $_GET['api_action'] ?? null;
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
namespace Openguru\OpenCartFramework\Scheduler;
|
||||
|
||||
use Cron\CronExpression;
|
||||
use InvalidArgumentException;
|
||||
use Openguru\OpenCartFramework\Container\Container;
|
||||
|
||||
class Job
|
||||
@@ -11,14 +12,15 @@ class Job
|
||||
|
||||
/** @var string|callable|TaskInterface */
|
||||
private $action;
|
||||
private int $id;
|
||||
private ?string $name;
|
||||
|
||||
private string $expression = '* * * * *';
|
||||
|
||||
private ?string $name;
|
||||
|
||||
public function __construct(Container $container, $action, ?string $name = null)
|
||||
public function __construct(Container $container, int $id, $action, ?string $name = null)
|
||||
{
|
||||
$this->container = $container;
|
||||
$this->id = $id;
|
||||
$this->action = $action;
|
||||
$this->name = $name;
|
||||
}
|
||||
@@ -74,6 +76,16 @@ class Job
|
||||
call_user_func($this->action);
|
||||
} elseif ($this->action instanceof TaskInterface) {
|
||||
$this->action->execute();
|
||||
} else {
|
||||
$actionType = is_object($this->action) ? get_class($this->action) : gettype($this->action);
|
||||
throw new InvalidArgumentException(
|
||||
sprintf(
|
||||
'Job "%s" (id: %d): action is not valid (expected class name, callable or TaskInterface, got %s).',
|
||||
$this->getName(),
|
||||
$this->id,
|
||||
$actionType
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -90,9 +102,9 @@ class Job
|
||||
return 'Closure';
|
||||
}
|
||||
|
||||
public function getId(): string
|
||||
public function getId(): int
|
||||
{
|
||||
return md5($this->getName());
|
||||
return $this->id;
|
||||
}
|
||||
|
||||
public function getExpression(): string
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Openguru\OpenCartFramework\Scheduler\Models;
|
||||
|
||||
use Carbon\Carbon;
|
||||
use Carbon\CarbonTimeZone;
|
||||
use Openguru\OpenCartFramework\QueryBuilder\Builder;
|
||||
|
||||
class ScheduledJob
|
||||
{
|
||||
private const TABLE_NAME = 'telecart_scheduled_jobs';
|
||||
|
||||
private Builder $builder;
|
||||
|
||||
public function __construct(Builder $builder)
|
||||
{
|
||||
$this->builder = $builder;
|
||||
}
|
||||
|
||||
public function all(): array
|
||||
{
|
||||
return $this->builder->newQuery()
|
||||
->from(self::TABLE_NAME)
|
||||
->get();
|
||||
}
|
||||
|
||||
public function getEnabledScheduledJobs(): array
|
||||
{
|
||||
return $this->builder->newQuery()
|
||||
->from(self::TABLE_NAME)
|
||||
->where('is_enabled', '=', 1)
|
||||
->get();
|
||||
}
|
||||
|
||||
public function enable(int $id): bool
|
||||
{
|
||||
return $this->builder->newQuery()
|
||||
->where('id', '=', $id)
|
||||
->update(self::TABLE_NAME, [
|
||||
'is_enabled' => 1,
|
||||
]);
|
||||
}
|
||||
|
||||
public function disable(int $id): bool
|
||||
{
|
||||
return $this->builder->newQuery()
|
||||
->where('id', '=', $id)
|
||||
->update(self::TABLE_NAME, [
|
||||
'is_enabled' => 0,
|
||||
]);
|
||||
}
|
||||
|
||||
public function updateCronExpression(int $id, string $cronExpression): bool
|
||||
{
|
||||
return $this->builder->newQuery()
|
||||
->where('id', '=', $id)
|
||||
->update(self::TABLE_NAME, [
|
||||
'cron_expression' => mb_substr($cronExpression, 0, 64),
|
||||
]);
|
||||
}
|
||||
|
||||
public function updateLastSuccessAt(int $id, float $durationSeconds): void
|
||||
{
|
||||
$this->builder->newQuery()
|
||||
->where('id', '=', $id)
|
||||
->update(self::TABLE_NAME, [
|
||||
'last_success_at' => Carbon::now(),
|
||||
'last_duration_seconds' => $durationSeconds,
|
||||
]);
|
||||
}
|
||||
|
||||
public function updateFailedAt(int $id, string $message): void
|
||||
{
|
||||
$this->builder->newQuery()
|
||||
->where('id', '=', $id)
|
||||
->update(self::TABLE_NAME, [
|
||||
'failed_at' => Carbon::now(),
|
||||
'failed_reason' => mb_substr($message, 0, 1000),
|
||||
]);
|
||||
}
|
||||
|
||||
public function clearFailedInfo(int $id): void
|
||||
{
|
||||
$this->builder->newQuery()
|
||||
->where('id', '=', $id)
|
||||
->update(self::TABLE_NAME, [
|
||||
'failed_at' => null,
|
||||
'failed_reason' => null,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@
|
||||
namespace Openguru\OpenCartFramework\Scheduler;
|
||||
|
||||
use Openguru\OpenCartFramework\Container\Container;
|
||||
use Openguru\OpenCartFramework\Scheduler\Models\ScheduledJob;
|
||||
|
||||
class ScheduleJobRegistry
|
||||
{
|
||||
@@ -10,10 +11,12 @@ class ScheduleJobRegistry
|
||||
|
||||
/** @var Job[] */
|
||||
private array $jobs = [];
|
||||
private ScheduledJob $scheduledJob;
|
||||
|
||||
public function __construct(Container $container)
|
||||
public function __construct(Container $container, ScheduledJob $scheduledJob)
|
||||
{
|
||||
$this->container = $container;
|
||||
$this->scheduledJob = $scheduledJob;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -21,9 +24,9 @@ class ScheduleJobRegistry
|
||||
* @param string|null $name
|
||||
* @return Job
|
||||
*/
|
||||
public function add($job, ?string $name = null): Job
|
||||
public function add(int $id, $job, ?string $name = null): Job
|
||||
{
|
||||
$newJob = new Job($this->container, $job, $name);
|
||||
$newJob = new Job($this->container, $id, $job, $name);
|
||||
$this->jobs[] = $newJob;
|
||||
|
||||
return $newJob;
|
||||
@@ -36,4 +39,14 @@ class ScheduleJobRegistry
|
||||
{
|
||||
return $this->jobs;
|
||||
}
|
||||
|
||||
public function loadJobsFromDatabase(): void
|
||||
{
|
||||
$jobs = $this->scheduledJob->getEnabledScheduledJobs();
|
||||
|
||||
foreach ($jobs as $job) {
|
||||
$this->add($job['id'], $job['task'], $job['name'])
|
||||
->at($job['cron_expression']);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Openguru\OpenCartFramework\Scheduler;
|
||||
|
||||
use DateTime;
|
||||
use Openguru\OpenCartFramework\Cache\CacheInterface;
|
||||
use Openguru\OpenCartFramework\Config\Settings;
|
||||
use Openguru\OpenCartFramework\Container\Container;
|
||||
use Openguru\OpenCartFramework\Scheduler\Models\ScheduledJob;
|
||||
use Psr\Log\LoggerInterface;
|
||||
use Throwable;
|
||||
|
||||
@@ -13,23 +15,25 @@ class SchedulerService
|
||||
{
|
||||
private LoggerInterface $logger;
|
||||
private CacheInterface $cache;
|
||||
private Container $container;
|
||||
private Settings $settings;
|
||||
private ?ScheduleJobRegistry $registry = null;
|
||||
private ScheduleJobRegistry $registry;
|
||||
|
||||
private const GLOBAL_LOCK_KEY = 'scheduler.global_lock';
|
||||
private const GLOBAL_LOCK_TTL = 300; // 5 minutes
|
||||
private ScheduledJob $scheduledJob;
|
||||
|
||||
public function __construct(
|
||||
LoggerInterface $logger,
|
||||
CacheInterface $cache,
|
||||
Container $container,
|
||||
Settings $settings
|
||||
Settings $settings,
|
||||
ScheduleJobRegistry $registry,
|
||||
ScheduledJob $scheduledJob
|
||||
) {
|
||||
$this->logger = $logger;
|
||||
$this->cache = $cache;
|
||||
$this->container = $container;
|
||||
$this->settings = $settings;
|
||||
$this->registry = $registry;
|
||||
$this->scheduledJob = $scheduledJob;
|
||||
}
|
||||
|
||||
// For testing purposes
|
||||
@@ -38,7 +42,7 @@ class SchedulerService
|
||||
$this->registry = $registry;
|
||||
}
|
||||
|
||||
public function run(): SchedulerResult
|
||||
public function run(bool $ignoreGlobalLock = false): SchedulerResult
|
||||
{
|
||||
$result = new SchedulerResult();
|
||||
|
||||
@@ -49,41 +53,31 @@ class SchedulerService
|
||||
return $result;
|
||||
}
|
||||
|
||||
if ($this->isGlobalLocked()) {
|
||||
if (! $ignoreGlobalLock && $this->isGlobalLocked()) {
|
||||
$result->addSkipped('Global', 'Global scheduler lock active');
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
if (! $ignoreGlobalLock) {
|
||||
$this->acquireGlobalLock();
|
||||
// Since we want to run every 5 minutes, running it more frequently won't trigger jobs earlier than due,
|
||||
// but locking might prevent overlap if previous run takes > 5 mins.
|
||||
// However, updating global last run on every attempt might be useful for diagnostics,
|
||||
// but strictly speaking, we only care if tasks were processed.
|
||||
}
|
||||
$this->updateGlobalLastRun();
|
||||
|
||||
try {
|
||||
$scheduler = $this->registry ?: new ScheduleJobRegistry($this->container);
|
||||
$this->registry->loadJobsFromDatabase();
|
||||
|
||||
// Only load config file if registry was not injected (for production use)
|
||||
if (! $this->registry) {
|
||||
$configFile = BP_PHAR_BASE_PATH . '/configs/schedule.php';
|
||||
if (file_exists($configFile)) {
|
||||
require $configFile;
|
||||
} else {
|
||||
$this->logger->warning('Scheduler config file not found: ' . $configFile);
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($scheduler->getJobs() as $job) {
|
||||
foreach ($this->registry->getJobs() as $job) {
|
||||
$this->processJob($job, $result);
|
||||
}
|
||||
} catch (Throwable $e) {
|
||||
$this->logger->error('Scheduler run failed: ' . $e->getMessage(), ['exception' => $e]);
|
||||
$result->addFailed('Scheduler', $e->getMessage());
|
||||
} finally {
|
||||
if (! $ignoreGlobalLock) {
|
||||
$this->releaseGlobalLock();
|
||||
}
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
@@ -93,7 +87,6 @@ class SchedulerService
|
||||
$name = $job->getName();
|
||||
$id = $job->getId();
|
||||
|
||||
try {
|
||||
// 1. Check if due by Cron expression
|
||||
if (! $job->isDue()) {
|
||||
$result->addSkipped($name, 'Not due');
|
||||
@@ -115,29 +108,24 @@ class SchedulerService
|
||||
return;
|
||||
}
|
||||
|
||||
// Lock and Run
|
||||
$this->lockJob($id);
|
||||
$startTime = microtime(true);
|
||||
|
||||
try {
|
||||
$this->scheduledJob->clearFailedInfo($id);
|
||||
$startTime = microtime(true);
|
||||
$job->run();
|
||||
|
||||
$duration = microtime(true) - $startTime;
|
||||
$this->updateLastRun($id);
|
||||
|
||||
$this->scheduledJob->updateLastSuccessAt($id, $duration);
|
||||
$this->logger->debug("Job executed: {$name}", ['duration' => $duration]);
|
||||
$result->addExecuted($name, $duration);
|
||||
} catch (Throwable $e) {
|
||||
$this->updateLastFailure($id, $e->getMessage());
|
||||
$this->logger->error("Job failed: {$name}", ['exception' => $e]);
|
||||
$this->scheduledJob->updateFailedAt($id, $e->getMessage());
|
||||
$result->addFailed($name, $e->getMessage());
|
||||
} finally {
|
||||
$this->updateLastRun($id);
|
||||
$this->unlockJob($id);
|
||||
}
|
||||
} catch (Throwable $e) {
|
||||
$this->logger->error("Error processing job {$name}: " . $e->getMessage());
|
||||
$result->addFailed($name, 'Processing error: ' . $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
private function isGlobalLocked(): bool
|
||||
@@ -155,36 +143,36 @@ class SchedulerService
|
||||
$this->cache->delete(self::GLOBAL_LOCK_KEY);
|
||||
}
|
||||
|
||||
private function isJobLocked(string $id): bool
|
||||
private function isJobLocked(int $id): bool
|
||||
{
|
||||
return (bool) $this->cache->get("scheduler.lock.{$id}");
|
||||
}
|
||||
|
||||
private function lockJob(string $id): void
|
||||
private function lockJob(int $id): void
|
||||
{
|
||||
// 30 minutes max execution time for a single job safe-guard
|
||||
$this->cache->set("scheduler.lock.{$id}", 1, 1800);
|
||||
}
|
||||
|
||||
private function unlockJob(string $id): void
|
||||
private function unlockJob(int $id): void
|
||||
{
|
||||
$this->cache->delete("scheduler.lock.{$id}");
|
||||
}
|
||||
|
||||
private function hasRanRecently(string $id): bool
|
||||
private function hasRanRecently(int $id): bool
|
||||
{
|
||||
$lastRun = $this->getLastRun($id);
|
||||
if (! $lastRun) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$lastRunDate = (new DateTime())->setTimestamp((int) $lastRun);
|
||||
$lastRunDate = (new DateTime())->setTimestamp($lastRun);
|
||||
$now = new DateTime();
|
||||
|
||||
return $lastRunDate->format('Y-m-d H:i') === $now->format('Y-m-d H:i');
|
||||
}
|
||||
|
||||
private function updateLastRun(string $id): void
|
||||
private function updateLastRun(int $id): void
|
||||
{
|
||||
$this->cache->set("scheduler.last_run.{$id}", time());
|
||||
}
|
||||
@@ -194,34 +182,8 @@ class SchedulerService
|
||||
$this->cache->set("scheduler.global_last_run", time());
|
||||
}
|
||||
|
||||
public function getGlobalLastRun(): ?int
|
||||
{
|
||||
$time = $this->cache->get("scheduler.global_last_run");
|
||||
|
||||
return $time ? (int) $time : null;
|
||||
}
|
||||
|
||||
private function updateLastFailure(string $id, string $message): void
|
||||
{
|
||||
$this->cache->set("scheduler.last_failure.{$id}", time());
|
||||
$this->cache->set("scheduler.last_failure_msg.{$id}", $message);
|
||||
}
|
||||
|
||||
public function getLastRun(string $id): ?int
|
||||
public function getLastRun(int $id): ?int
|
||||
{
|
||||
return $this->cache->get("scheduler.last_run.{$id}");
|
||||
}
|
||||
|
||||
public function getLastFailure(string $id): ?array
|
||||
{
|
||||
$time = $this->cache->get("scheduler.last_failure.{$id}");
|
||||
if (! $time) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return [
|
||||
'time' => (int) $time,
|
||||
'message' => $this->cache->get("scheduler.last_failure_msg.{$id}"),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,23 +2,12 @@
|
||||
|
||||
namespace Openguru\OpenCartFramework\Scheduler;
|
||||
|
||||
use Openguru\OpenCartFramework\Cache\CacheInterface;
|
||||
use Openguru\OpenCartFramework\Config\Settings;
|
||||
use Openguru\OpenCartFramework\Container\Container;
|
||||
use Openguru\OpenCartFramework\Container\ServiceProvider;
|
||||
use Psr\Log\LoggerInterface;
|
||||
|
||||
class SchedulerServiceProvider extends ServiceProvider
|
||||
{
|
||||
public function register(): void
|
||||
{
|
||||
$this->container->singleton(SchedulerService::class, function (Container $container) {
|
||||
return new SchedulerService(
|
||||
$container->get(LoggerInterface::class),
|
||||
$container->get(CacheInterface::class),
|
||||
$container,
|
||||
$container->get(Settings::class)
|
||||
);
|
||||
});
|
||||
//
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@ class TelegramValidateInitDataMiddleware
|
||||
'health',
|
||||
'etlCustomers',
|
||||
'etlCustomersMeta',
|
||||
'runSchedule',
|
||||
];
|
||||
|
||||
public function __construct(SignatureValidator $signatureValidator)
|
||||
|
||||
@@ -11,6 +11,7 @@ use Openguru\OpenCartFramework\QueryBuilder\QueryBuilderServiceProvider;
|
||||
use Openguru\OpenCartFramework\Router\RouteServiceProvider;
|
||||
use Openguru\OpenCartFramework\Support\Arr;
|
||||
use Openguru\OpenCartFramework\TeleCartPulse\TeleCartPulseServiceProvider;
|
||||
use Openguru\OpenCartFramework\Scheduler\SchedulerServiceProvider;
|
||||
use Openguru\OpenCartFramework\Telegram\TelegramServiceProvider;
|
||||
use Openguru\OpenCartFramework\Telegram\TelegramValidateInitDataMiddleware;
|
||||
use Openguru\OpenCartFramework\Validator\ValidatorServiceProvider;
|
||||
@@ -31,6 +32,7 @@ class ApplicationFactory
|
||||
RouteServiceProvider::class,
|
||||
AppServiceProvider::class,
|
||||
TelegramServiceProvider::class,
|
||||
SchedulerServiceProvider::class,
|
||||
ValidatorServiceProvider::class,
|
||||
TeleCartPulseServiceProvider::class,
|
||||
ImageToolServiceProvider::class,
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
<?php
|
||||
|
||||
namespace App\Handlers;
|
||||
|
||||
use Openguru\OpenCartFramework\Config\Settings;
|
||||
use Openguru\OpenCartFramework\Http\Request;
|
||||
use Openguru\OpenCartFramework\Scheduler\SchedulerService;
|
||||
use Symfony\Component\HttpFoundation\JsonResponse;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
class CronHandler
|
||||
{
|
||||
private Settings $settings;
|
||||
private SchedulerService $schedulerService;
|
||||
|
||||
public function __construct(Settings $settings, SchedulerService $schedulerService)
|
||||
{
|
||||
$this->settings = $settings;
|
||||
$this->schedulerService = $schedulerService;
|
||||
}
|
||||
|
||||
/**
|
||||
* Запуск планировщика по HTTP (для cron-job.org и аналогов).
|
||||
* Требует api_key в query, совпадающий с настройкой cron.api_key.
|
||||
*/
|
||||
public function runSchedule(Request $request): JsonResponse
|
||||
{
|
||||
$mode = $this->settings->get('cron.mode', 'disabled');
|
||||
if ($mode !== 'cron_job_org') {
|
||||
return new JsonResponse(['error' => 'Scheduler is not in cron-job.org mode'], Response::HTTP_FORBIDDEN);
|
||||
}
|
||||
|
||||
$apiKey = $request->get('api_key', '');
|
||||
$expectedKey = $this->settings->get('cron.api_key', '');
|
||||
if ($expectedKey === '' || $apiKey === '' || !hash_equals($expectedKey, $apiKey)) {
|
||||
return new JsonResponse(['error' => 'Invalid or missing API key'], Response::HTTP_UNAUTHORIZED);
|
||||
}
|
||||
|
||||
// Увеличиваем лимит времени выполнения при запуске по HTTP, чтобы снизить риск timeout
|
||||
$limit = 300; // 5 минут
|
||||
if (function_exists('set_time_limit')) {
|
||||
@set_time_limit($limit);
|
||||
}
|
||||
if (function_exists('ini_set')) {
|
||||
@ini_set('max_execution_time', (string) $limit);
|
||||
}
|
||||
|
||||
$result = $this->schedulerService->run(true);
|
||||
$data = [
|
||||
'success' => true,
|
||||
'executed' => count($result->executed),
|
||||
'failed' => count($result->failed),
|
||||
'skipped' => count($result->skipped),
|
||||
];
|
||||
|
||||
return new JsonResponse($data);
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@
|
||||
use App\Handlers\BlocksHandler;
|
||||
use App\Handlers\CartHandler;
|
||||
use App\Handlers\CategoriesHandler;
|
||||
use App\Handlers\CronHandler;
|
||||
use App\Handlers\ETLHandler;
|
||||
use App\Handlers\FiltersHandler;
|
||||
use App\Handlers\FormsHandler;
|
||||
@@ -24,6 +25,7 @@ return [
|
||||
'getForm' => [FormsHandler::class, 'getForm'],
|
||||
'health' => [HealthCheckHandler::class, 'handle'],
|
||||
'ingest' => [TelemetryHandler::class, 'ingest'],
|
||||
'runSchedule' => [CronHandler::class, 'runSchedule'],
|
||||
'heartbeat' => [TelemetryHandler::class, 'heartbeat'],
|
||||
'processBlock' => [BlocksHandler::class, 'processBlock'],
|
||||
'product_show' => [ProductsHandler::class, 'show'],
|
||||
|
||||
@@ -14,11 +14,11 @@ class JobTest extends TestCase
|
||||
$container = $this->app;
|
||||
|
||||
// Act
|
||||
$job = new Job($container, function() {}, 'TestJob');
|
||||
$job = new Job($container, 1, function() {}, 'TestJob');
|
||||
|
||||
// Assert
|
||||
$this->assertEquals('TestJob', $job->getName());
|
||||
$this->assertEquals(md5('TestJob'), $job->getId());
|
||||
$this->assertEquals(1, $job->getId());
|
||||
}
|
||||
|
||||
public function testJobWithoutNameUsesClassName()
|
||||
@@ -27,7 +27,7 @@ class JobTest extends TestCase
|
||||
$container = $this->app;
|
||||
|
||||
// Act
|
||||
$job = new Job($container, TestTask::class);
|
||||
$job = new Job($container, 1, TestTask::class);
|
||||
|
||||
// Assert
|
||||
$this->assertEquals(TestTask::class, $job->getName());
|
||||
@@ -39,7 +39,7 @@ class JobTest extends TestCase
|
||||
$container = $this->app;
|
||||
|
||||
// Act
|
||||
$job = new Job($container, function() {});
|
||||
$job = new Job($container, 1, function() {});
|
||||
|
||||
// Assert
|
||||
$this->assertEquals('Closure', $job->getName());
|
||||
@@ -49,7 +49,7 @@ class JobTest extends TestCase
|
||||
{
|
||||
// Arrange
|
||||
$container = $this->app;
|
||||
$job = new Job($container, function() {});
|
||||
$job = new Job($container, 1, function() {});
|
||||
|
||||
// Act
|
||||
$job->everyMinute();
|
||||
@@ -62,7 +62,7 @@ class JobTest extends TestCase
|
||||
{
|
||||
// Arrange
|
||||
$container = $this->app;
|
||||
$job = new Job($container, function() {});
|
||||
$job = new Job($container, 1, function() {});
|
||||
|
||||
// Act
|
||||
$job->everyFiveMinutes();
|
||||
@@ -75,7 +75,7 @@ class JobTest extends TestCase
|
||||
{
|
||||
// Arrange
|
||||
$container = $this->app;
|
||||
$job = new Job($container, function() {});
|
||||
$job = new Job($container, 1, function() {});
|
||||
|
||||
// Act
|
||||
$job->everyHour();
|
||||
@@ -88,7 +88,7 @@ class JobTest extends TestCase
|
||||
{
|
||||
// Arrange
|
||||
$container = $this->app;
|
||||
$job = new Job($container, function() {});
|
||||
$job = new Job($container, 1, function() {});
|
||||
|
||||
// Act
|
||||
$job->dailyAt(9, 30);
|
||||
@@ -101,7 +101,7 @@ class JobTest extends TestCase
|
||||
{
|
||||
// Arrange
|
||||
$container = $this->app;
|
||||
$job = new Job($container, function() {});
|
||||
$job = new Job($container, 1, function() {});
|
||||
|
||||
// Act
|
||||
$job->at('0 12 * * 1');
|
||||
@@ -114,7 +114,7 @@ class JobTest extends TestCase
|
||||
{
|
||||
// Arrange
|
||||
$container = $this->app;
|
||||
$job = new Job($container, function() {});
|
||||
$job = new Job($container, 1, function() {});
|
||||
$job->everyMinute();
|
||||
|
||||
// Act
|
||||
@@ -128,7 +128,7 @@ class JobTest extends TestCase
|
||||
{
|
||||
// Arrange
|
||||
$container = $this->app;
|
||||
$job = new Job($container, function() {});
|
||||
$job = new Job($container, 1, function() {});
|
||||
// Set to run at a specific future time
|
||||
$job->at('0 23 * * *'); // 23:00 every day
|
||||
|
||||
@@ -146,7 +146,7 @@ class JobTest extends TestCase
|
||||
$container = $this->app;
|
||||
$executed = false;
|
||||
|
||||
$job = new Job($container, function() use (&$executed) {
|
||||
$job = new Job($container, 1, function() use (&$executed) {
|
||||
$executed = true;
|
||||
});
|
||||
|
||||
@@ -167,7 +167,7 @@ class JobTest extends TestCase
|
||||
return new TestTask();
|
||||
});
|
||||
|
||||
$job = new Job($container, TestTask::class);
|
||||
$job = new Job($container, 1, TestTask::class);
|
||||
|
||||
// Act
|
||||
$job->run();
|
||||
@@ -182,7 +182,7 @@ class JobTest extends TestCase
|
||||
// Arrange
|
||||
$container = $this->app;
|
||||
$task = new TestTask();
|
||||
$job = new Job($container, $task);
|
||||
$job = new Job($container, 1, $task);
|
||||
|
||||
// Act
|
||||
$job->run();
|
||||
|
||||
@@ -2,16 +2,23 @@
|
||||
|
||||
namespace Tests\Unit\Framework\Scheduler;
|
||||
|
||||
use Mockery;
|
||||
use Openguru\OpenCartFramework\Scheduler\Job;
|
||||
use Openguru\OpenCartFramework\Scheduler\Models\ScheduledJob;
|
||||
use Openguru\OpenCartFramework\Scheduler\ScheduleJobRegistry;
|
||||
use Tests\TestCase;
|
||||
|
||||
class ScheduleJobRegistryTest extends TestCase
|
||||
{
|
||||
private function createScheduledJobMock(): ScheduledJob
|
||||
{
|
||||
return Mockery::mock(ScheduledJob::class);
|
||||
}
|
||||
|
||||
public function testRegistryCreation()
|
||||
{
|
||||
// Arrange & Act
|
||||
$registry = new ScheduleJobRegistry($this->app);
|
||||
$registry = new ScheduleJobRegistry($this->app, $this->createScheduledJobMock());
|
||||
|
||||
// Assert
|
||||
$this->assertInstanceOf(ScheduleJobRegistry::class, $registry);
|
||||
@@ -21,10 +28,10 @@ class ScheduleJobRegistryTest extends TestCase
|
||||
public function testAddJobWithoutName()
|
||||
{
|
||||
// Arrange
|
||||
$registry = new ScheduleJobRegistry($this->app);
|
||||
$registry = new ScheduleJobRegistry($this->app, $this->createScheduledJobMock());
|
||||
|
||||
// Act
|
||||
$job = $registry->add(function() {});
|
||||
$job = $registry->add(1, function() {});
|
||||
|
||||
// Assert
|
||||
$this->assertInstanceOf(Job::class, $job);
|
||||
@@ -35,10 +42,10 @@ class ScheduleJobRegistryTest extends TestCase
|
||||
public function testAddJobWithName()
|
||||
{
|
||||
// Arrange
|
||||
$registry = new ScheduleJobRegistry($this->app);
|
||||
$registry = new ScheduleJobRegistry($this->app, $this->createScheduledJobMock());
|
||||
|
||||
// Act
|
||||
$job = $registry->add(function() {}, 'MyCustomJob');
|
||||
$job = $registry->add(1, function() {}, 'MyCustomJob');
|
||||
|
||||
// Assert
|
||||
$this->assertInstanceOf(Job::class, $job);
|
||||
@@ -49,12 +56,12 @@ class ScheduleJobRegistryTest extends TestCase
|
||||
public function testAddMultipleJobs()
|
||||
{
|
||||
// Arrange
|
||||
$registry = new ScheduleJobRegistry($this->app);
|
||||
$registry = new ScheduleJobRegistry($this->app, $this->createScheduledJobMock());
|
||||
|
||||
// Act
|
||||
$job1 = $registry->add(function() {}, 'Job1');
|
||||
$job2 = $registry->add(function() {}, 'Job2');
|
||||
$job3 = $registry->add(TestTask::class, 'Job3');
|
||||
$job1 = $registry->add(1, function() {}, 'Job1');
|
||||
$job2 = $registry->add(2, function() {}, 'Job2');
|
||||
$job3 = $registry->add(3, TestTask::class, 'Job3');
|
||||
|
||||
// Assert
|
||||
$jobs = $registry->getJobs();
|
||||
@@ -67,8 +74,8 @@ class ScheduleJobRegistryTest extends TestCase
|
||||
public function testGetJobsReturnsArray()
|
||||
{
|
||||
// Arrange
|
||||
$registry = new ScheduleJobRegistry($this->app);
|
||||
$registry->add(function() {}, 'TestJob');
|
||||
$registry = new ScheduleJobRegistry($this->app, $this->createScheduledJobMock());
|
||||
$registry->add(1, function() {}, 'TestJob');
|
||||
|
||||
// Act
|
||||
$jobs = $registry->getJobs();
|
||||
@@ -82,10 +89,10 @@ class ScheduleJobRegistryTest extends TestCase
|
||||
public function testJobSchedulingMethods()
|
||||
{
|
||||
// Arrange
|
||||
$registry = new ScheduleJobRegistry($this->app);
|
||||
$registry = new ScheduleJobRegistry($this->app, $this->createScheduledJobMock());
|
||||
|
||||
// Act
|
||||
$job = $registry->add(function() {}, 'TestJob')
|
||||
$job = $registry->add(1, function() {}, 'TestJob')
|
||||
->everyFiveMinutes();
|
||||
|
||||
// Assert
|
||||
|
||||
@@ -6,6 +6,7 @@ use Mockery;
|
||||
use Openguru\OpenCartFramework\Cache\CacheInterface;
|
||||
use Openguru\OpenCartFramework\Config\Settings;
|
||||
use Openguru\OpenCartFramework\Scheduler\Job;
|
||||
use Openguru\OpenCartFramework\Scheduler\Models\ScheduledJob;
|
||||
use Openguru\OpenCartFramework\Scheduler\SchedulerService;
|
||||
use Openguru\OpenCartFramework\Scheduler\ScheduleJobRegistry;
|
||||
use Psr\Log\LoggerInterface;
|
||||
@@ -26,11 +27,21 @@ class SchedulerServiceTest extends TestCase
|
||||
$this->settingsMock = Mockery::mock(Settings::class);
|
||||
$this->loggerMock = Mockery::mock(LoggerInterface::class);
|
||||
|
||||
$registryMock = Mockery::mock(ScheduleJobRegistry::class);
|
||||
$registryMock->shouldReceive('loadJobsFromDatabase')->andReturnNull();
|
||||
$registryMock->shouldReceive('getJobs')->andReturn([]);
|
||||
|
||||
$scheduledJobMock = Mockery::mock(ScheduledJob::class);
|
||||
$scheduledJobMock->shouldReceive('clearFailedInfo')->zeroOrMoreTimes()->andReturnNull();
|
||||
$scheduledJobMock->shouldReceive('updateLastSuccessAt')->zeroOrMoreTimes()->andReturnNull();
|
||||
$scheduledJobMock->shouldReceive('updateFailedAt')->zeroOrMoreTimes()->andReturnNull();
|
||||
|
||||
$this->scheduler = new SchedulerService(
|
||||
$this->loggerMock,
|
||||
$this->cacheMock,
|
||||
$this->app,
|
||||
$this->settingsMock
|
||||
$this->settingsMock,
|
||||
$registryMock,
|
||||
$scheduledJobMock
|
||||
);
|
||||
}
|
||||
|
||||
@@ -91,32 +102,33 @@ class SchedulerServiceTest extends TestCase
|
||||
->with('scheduler.global_lock', 1, 300)
|
||||
->once();
|
||||
|
||||
$this->cacheMock->shouldReceive('delete')
|
||||
->with('scheduler.global_lock')
|
||||
->once();
|
||||
|
||||
$this->cacheMock->shouldReceive('set')
|
||||
->with('scheduler.global_last_run', Mockery::type('int'))
|
||||
->once();
|
||||
|
||||
// Mock registry and job
|
||||
$registryMock = Mockery::mock(ScheduleJobRegistry::class);
|
||||
$registryMock->shouldReceive('loadJobsFromDatabase')->andReturnNull();
|
||||
$jobMock = Mockery::mock(Job::class);
|
||||
|
||||
$jobMock->shouldReceive('getName')->andReturn('TestJob');
|
||||
$jobMock->shouldReceive('getId')->andReturn('test_job_id');
|
||||
$jobMock->shouldReceive('getId')->andReturn(42);
|
||||
$jobMock->shouldReceive('isDue')->andReturn(true);
|
||||
|
||||
// Job has not run recently (getLastRun returns null)
|
||||
$this->cacheMock->shouldReceive('get')
|
||||
->with('scheduler.last_run.test_job_id')
|
||||
->with('scheduler.last_run.42')
|
||||
->andReturn(null);
|
||||
|
||||
// Job is locked
|
||||
$this->cacheMock->shouldReceive('get')
|
||||
->with('scheduler.lock.test_job_id')
|
||||
->with('scheduler.lock.42')
|
||||
->andReturn('1');
|
||||
|
||||
$this->cacheMock->shouldReceive('delete')
|
||||
->with('scheduler.global_lock')
|
||||
->once();
|
||||
|
||||
$registryMock->shouldReceive('getJobs')->andReturn([$jobMock]);
|
||||
|
||||
// Logger should not be called for this test
|
||||
@@ -147,32 +159,33 @@ class SchedulerServiceTest extends TestCase
|
||||
->with('scheduler.global_lock', 1, 300)
|
||||
->once();
|
||||
|
||||
$this->cacheMock->shouldReceive('delete')
|
||||
->with('scheduler.global_lock')
|
||||
->once();
|
||||
|
||||
$this->cacheMock->shouldReceive('set')
|
||||
->with('scheduler.global_last_run', Mockery::type('int'))
|
||||
->once();
|
||||
|
||||
// Mock registry and job
|
||||
$registryMock = Mockery::mock(ScheduleJobRegistry::class);
|
||||
$registryMock->shouldReceive('loadJobsFromDatabase')->andReturnNull();
|
||||
$jobMock = Mockery::mock(Job::class);
|
||||
|
||||
$jobMock->shouldReceive('getName')->andReturn('TestJob');
|
||||
$jobMock->shouldReceive('getId')->andReturn('test_job_id');
|
||||
$jobMock->shouldReceive('getId')->andReturn(42);
|
||||
$jobMock->shouldReceive('isDue')->andReturn(true);
|
||||
|
||||
$this->cacheMock->shouldReceive('get')
|
||||
->with('scheduler.lock.test_job_id')
|
||||
->with('scheduler.lock.42')
|
||||
->andReturn(null); // Not locked
|
||||
|
||||
// Job was recently executed (same minute)
|
||||
$recentTime = time();
|
||||
$this->cacheMock->shouldReceive('get')
|
||||
->with('scheduler.last_run.test_job_id')
|
||||
->with('scheduler.last_run.42')
|
||||
->andReturn($recentTime);
|
||||
|
||||
$this->cacheMock->shouldReceive('delete')
|
||||
->with('scheduler.global_lock')
|
||||
->once();
|
||||
|
||||
$registryMock->shouldReceive('getJobs')->andReturn([$jobMock]);
|
||||
|
||||
// Inject registry for testing
|
||||
@@ -200,22 +213,23 @@ class SchedulerServiceTest extends TestCase
|
||||
->with('scheduler.global_lock', 1, 300)
|
||||
->once();
|
||||
|
||||
$this->cacheMock->shouldReceive('delete')
|
||||
->with('scheduler.global_lock')
|
||||
->once();
|
||||
|
||||
$this->cacheMock->shouldReceive('set')
|
||||
->with('scheduler.global_last_run', Mockery::type('int'))
|
||||
->once();
|
||||
|
||||
// Mock registry and job
|
||||
$registryMock = Mockery::mock(ScheduleJobRegistry::class);
|
||||
$registryMock->shouldReceive('loadJobsFromDatabase')->andReturnNull();
|
||||
$jobMock = Mockery::mock(Job::class);
|
||||
|
||||
$jobMock->shouldReceive('getName')->andReturn('TestJob');
|
||||
$jobMock->shouldReceive('getId')->andReturn('test_job_id');
|
||||
$jobMock->shouldReceive('getId')->andReturn(42);
|
||||
$jobMock->shouldReceive('isDue')->andReturn(false); // Not due
|
||||
|
||||
$this->cacheMock->shouldReceive('delete')
|
||||
->with('scheduler.global_lock')
|
||||
->once();
|
||||
|
||||
$registryMock->shouldReceive('getJobs')->andReturn([$jobMock]);
|
||||
|
||||
// Inject registry for testing
|
||||
@@ -243,42 +257,43 @@ class SchedulerServiceTest extends TestCase
|
||||
->with('scheduler.global_lock', 1, 300)
|
||||
->once();
|
||||
|
||||
$this->cacheMock->shouldReceive('delete')
|
||||
->with('scheduler.global_lock')
|
||||
->once();
|
||||
|
||||
$this->cacheMock->shouldReceive('set')
|
||||
->with('scheduler.global_last_run', Mockery::type('int'))
|
||||
->once();
|
||||
|
||||
// Mock registry and job
|
||||
$registryMock = Mockery::mock(ScheduleJobRegistry::class);
|
||||
$registryMock->shouldReceive('loadJobsFromDatabase')->andReturnNull();
|
||||
$jobMock = Mockery::mock(Job::class);
|
||||
|
||||
$jobMock->shouldReceive('getName')->andReturn('TestJob');
|
||||
$jobMock->shouldReceive('getId')->andReturn('test_job_id');
|
||||
$jobMock->shouldReceive('getId')->andReturn(42);
|
||||
$jobMock->shouldReceive('isDue')->andReturn(true);
|
||||
$jobMock->shouldReceive('run')->once();
|
||||
|
||||
$this->cacheMock->shouldReceive('get')
|
||||
->with('scheduler.lock.test_job_id')
|
||||
->with('scheduler.lock.42')
|
||||
->andReturn(null);
|
||||
|
||||
$this->cacheMock->shouldReceive('get')
|
||||
->with('scheduler.last_run.test_job_id')
|
||||
->with('scheduler.last_run.42')
|
||||
->andReturn(null); // Never ran before
|
||||
|
||||
// Lock and unlock operations
|
||||
$this->cacheMock->shouldReceive('set')
|
||||
->with('scheduler.lock.test_job_id', 1, 1800)
|
||||
->with('scheduler.lock.42', 1, 1800)
|
||||
->once();
|
||||
|
||||
$this->cacheMock->shouldReceive('delete')
|
||||
->with('scheduler.lock.test_job_id')
|
||||
->with('scheduler.lock.42')
|
||||
->once();
|
||||
|
||||
$this->cacheMock->shouldReceive('set')
|
||||
->with('scheduler.last_run.test_job_id', Mockery::type('int'))
|
||||
->with('scheduler.last_run.42', Mockery::type('int'))
|
||||
->once();
|
||||
|
||||
$this->cacheMock->shouldReceive('delete')
|
||||
->with('scheduler.global_lock')
|
||||
->once();
|
||||
|
||||
$registryMock->shouldReceive('getJobs')->andReturn([$jobMock]);
|
||||
@@ -320,46 +335,43 @@ class SchedulerServiceTest extends TestCase
|
||||
->with('scheduler.global_lock', 1, 300)
|
||||
->once();
|
||||
|
||||
$this->cacheMock->shouldReceive('delete')
|
||||
->with('scheduler.global_lock')
|
||||
->once();
|
||||
|
||||
$this->cacheMock->shouldReceive('set')
|
||||
->with('scheduler.global_last_run', Mockery::type('int'))
|
||||
->once();
|
||||
|
||||
// Mock registry and job
|
||||
$registryMock = Mockery::mock(ScheduleJobRegistry::class);
|
||||
$registryMock->shouldReceive('loadJobsFromDatabase')->andReturnNull();
|
||||
$jobMock = Mockery::mock(Job::class);
|
||||
|
||||
$jobMock->shouldReceive('getName')->andReturn('TestJob');
|
||||
$jobMock->shouldReceive('getId')->andReturn('test_job_id');
|
||||
$jobMock->shouldReceive('getId')->andReturn(42);
|
||||
$jobMock->shouldReceive('isDue')->andReturn(true);
|
||||
$jobMock->shouldReceive('run')->andThrow(new \Exception('Job failed'));
|
||||
|
||||
$this->cacheMock->shouldReceive('get')
|
||||
->with('scheduler.lock.test_job_id')
|
||||
->with('scheduler.lock.42')
|
||||
->andReturn(null);
|
||||
|
||||
$this->cacheMock->shouldReceive('get')
|
||||
->with('scheduler.last_run.test_job_id')
|
||||
->with('scheduler.last_run.42')
|
||||
->andReturn(null);
|
||||
|
||||
// Lock and unlock operations
|
||||
$this->cacheMock->shouldReceive('set')
|
||||
->with('scheduler.lock.test_job_id', 1, 1800)
|
||||
->with('scheduler.lock.42', 1, 1800)
|
||||
->once();
|
||||
|
||||
$this->cacheMock->shouldReceive('delete')
|
||||
->with('scheduler.lock.test_job_id')
|
||||
->with('scheduler.lock.42')
|
||||
->once();
|
||||
|
||||
$this->cacheMock->shouldReceive('set')
|
||||
->with('scheduler.last_failure.test_job_id', Mockery::type('int'))
|
||||
->with('scheduler.last_run.42', Mockery::type('int'))
|
||||
->once();
|
||||
|
||||
$this->cacheMock->shouldReceive('set')
|
||||
->with('scheduler.last_failure_msg.test_job_id', 'Job failed')
|
||||
$this->cacheMock->shouldReceive('delete')
|
||||
->with('scheduler.global_lock')
|
||||
->once();
|
||||
|
||||
$registryMock->shouldReceive('getJobs')->andReturn([$jobMock]);
|
||||
|
||||
Reference in New Issue
Block a user