Compare commits
4 Commits
docs/featu
...
980f656a0a
| Author | SHA1 | Date | |
|---|---|---|---|
| 980f656a0a | |||
| c0ca0c731d | |||
| 0295a4b28b | |||
| 91c5d56903 |
64
.cursor/agents.md
Normal file
64
.cursor/agents.md
Normal file
@@ -0,0 +1,64 @@
|
||||
# Cursor AI Agents Configuration
|
||||
|
||||
## Роли и правила поведения ИИ
|
||||
|
||||
### Основная роль: Senior Full-Stack Developer
|
||||
|
||||
Вы - опытный full-stack разработчик, специализирующийся на:
|
||||
|
||||
- OpenCart модульной разработке
|
||||
- Кастомных фреймворках (OpenCart Framework)
|
||||
- PHP 7.4+ с современными практиками
|
||||
- Vue.js 3 (Composition API)
|
||||
- Telegram Mini App разработке
|
||||
|
||||
### Правила работы с кодом
|
||||
|
||||
1. **Всегда используй существующие паттерны проекта**
|
||||
2. **Не создавай дубликаты - используй существующие утилиты**
|
||||
3. **Следуй соглашениям именования проекта**
|
||||
4. **Тестируй изменения перед коммитом**
|
||||
5. **Документируй публичные API**
|
||||
6. **Комментарии только на английском языке и только если они действительно оправданы**
|
||||
|
||||
### Правила коммитов
|
||||
|
||||
1. **Следование Conventional Commits**
|
||||
- Используй префиксы: `feat:`, `fix:`, `chore:`, `refactor:`, `style:`, `test:`, `docs:`
|
||||
- Формат: `<type>: <subject>` (первая строка до 72 символов)
|
||||
- После пустой строки - подробное описание изменений
|
||||
|
||||
2. **Язык коммитов**
|
||||
- Все коммиты на **английском языке**
|
||||
- Подробное описание изменений в теле коммита
|
||||
- Перечисляй все измененные файлы и ключевые изменения
|
||||
|
||||
3. **Примеры правильных коммитов**
|
||||
```
|
||||
feat: add setting to control category products button visibility
|
||||
|
||||
- Add show_category_products_button field to StoreDTO
|
||||
- Update SettingsSerializerService to support new field
|
||||
- Add setting in admin panel on 'Store' tab with toggle
|
||||
- Pass setting to SPA through SettingsHandler
|
||||
- Button displays only for categories with child categories
|
||||
- Add default value true to configuration
|
||||
```
|
||||
|
||||
### Запрещено
|
||||
|
||||
- Хардкод значений (используй конфиги/настройки)
|
||||
- Игнорирование обработки ошибок
|
||||
- Создание циклических зависимостей
|
||||
|
||||
Для разработки FrontEnd используй:
|
||||
|
||||
- Vue.js 3 (Composition API)
|
||||
- Старайся избегать функций watch там, где это возможно и где можно сделать более красиво.
|
||||
- Для frontend/admin используй Tailwind 4 с префиксом `tw:`.
|
||||
- Для frontend/spa используй Tailwind 4 без префикса.
|
||||
- Для frontend/admin используй иконки от FontAwesome 4, потому что это уже встроено в OpenCart 3.
|
||||
- Для frontend/admin используй компоненты VuePrime 4.
|
||||
- Для frontend/spa используй Daisy UI.
|
||||
- Чтобы получить название стандартной таблицы OpenCart, используй хелпер `db_table`, либо добавляй константу DB_PREFIX перед названием таблицы. Так ты получишь название таблицы с префиксом.
|
||||
- Все таблицы моего модуля TeleCart начинаются с префикса `telecart_`. Примеры миграций лежат в `module/oc_telegram_shop/upload/oc_telegram_shop/database/migrations`
|
||||
44
.cursor/config.json
Normal file
44
.cursor/config.json
Normal file
@@ -0,0 +1,44 @@
|
||||
{
|
||||
"rules": {
|
||||
"preferCompositionAPI": true,
|
||||
"strictTypes": true,
|
||||
"noHardcodedValues": true,
|
||||
"useDependencyInjection": true
|
||||
},
|
||||
"paths": {
|
||||
"telecart_module": "module/oc_telegram_shop/upload/oc_telegram_shop",
|
||||
"frontendAdmin": "frontend/admin",
|
||||
"telegramShopSpa": "frontend/spa",
|
||||
"migrations": "module/oc_telegram_shop/upload/oc_telegram_shop/database/migrations",
|
||||
"telecartHandlers": "module/oc_telegram_shop/upload/oc_telegram_shop/src/Handlers",
|
||||
"adminHandlers": "module/oc_telegram_shop/upload/oc_telegram_shop/bastion/Handlers",
|
||||
"models": "module/oc_telegram_shop/upload/oc_telegram_shop/src/Models",
|
||||
"framework": "module/oc_telegram_shop/upload/oc_telegram_shop/framework"
|
||||
},
|
||||
"naming": {
|
||||
"classes": "PascalCase",
|
||||
"methods": "camelCase",
|
||||
"variables": "camelCase",
|
||||
"constants": "UPPER_SNAKE_CASE",
|
||||
"files": "PascalCase for classes, kebab-case for others",
|
||||
"tables": "snake_case with telecart_ prefix"
|
||||
},
|
||||
"php": {
|
||||
"version": "7.4+",
|
||||
"preferVersion": "7.4+",
|
||||
"psr12": true
|
||||
},
|
||||
"javascript": {
|
||||
"version": "ES2020+",
|
||||
"framework": "Vue 3 Composition API",
|
||||
"stateManagement": "Pinia",
|
||||
"uiLibrary": "PrimeVue (admin), Tailwind (spa)"
|
||||
},
|
||||
"database": {
|
||||
"queryBuilder": true,
|
||||
"migrations": true,
|
||||
"tablePrefix": "telecart_",
|
||||
"noForeignKeys": true
|
||||
}
|
||||
}
|
||||
|
||||
38
.cursor/features/telecart-pulse-heartbeat.md
Normal file
38
.cursor/features/telecart-pulse-heartbeat.md
Normal file
@@ -0,0 +1,38 @@
|
||||
## TeleCart Pulse Heartbeat Telemetry
|
||||
|
||||
### Цель
|
||||
Раз в час отправлять телеметрию (heartbeat) на TeleCart Pulse, чтобы фиксировать состояние магазина и версии окружения без участия пользователя.
|
||||
|
||||
### Backend (`module/oc_telegram_shop/upload/oc_telegram_shop`)
|
||||
- `framework/TeleCartPulse/TeleCartPulseService.php`
|
||||
- Новый метод `handleHeartbeat()` собирает данные: домен (через `Utils::getCurrentDomain()`), username бота (через `TelegramService::getMe()`), версии PHP, модуля (из `composer.json`), OpenCart (`VERSION` и `VERSION_CORE`), текущий UTC timestamp.
|
||||
- Последний успешный пинг кешируется (ключ `telecart_pulse_heartbeat`, TTL 1 час) через существующий `CacheInterface`.
|
||||
- Подпись heartbeat выполняется через отдельный `PayloadSigner`, который использует секрет `pulse.heartbeat_secret`/`PULSE_HEARTBEAT_SECRET`. Логируются предупреждения при ошибках кеша/бота/подписи.
|
||||
- Отправка идет на эндпоинт `heartbeat` с таймаутом 2 секунды и заголовком `X-TELECART-VERSION`, взятым из `composer.json`.
|
||||
- `framework/TeleCartPulse/TeleCartPulseServiceProvider.php`
|
||||
- Регистрирует основной `PayloadSigner` (по `pulse.api_key`) и отдельный heartbeat signer (по `pulse.heartbeat_secret` или `PULSE_HEARTBEAT_SECRET`), инжектит `LoggerInterface`.
|
||||
- `src/Handlers/TelemetryHandler.php` + `src/routes.php`
|
||||
- Добавлен маршрут `heartbeat`, который вызывает `handleHeartbeat()` и возвращает `{ status: "ok" }`. Логгер пишет warning при проблемах.
|
||||
|
||||
### Frontend (`frontend/spa`)
|
||||
- `src/utils/ftch.js`: новая функция `heartbeat()` вызывает `api_action=heartbeat`.
|
||||
- `src/stores/Pulse.js`: добавлен action `heartbeat`, использующий новую API-функцию и логирующий результат.
|
||||
- `src/main.js`: после `pulse.ingest(...)` вызывается `pulse.heartbeat()` без блокировки цепочки.
|
||||
|
||||
### Конфигурация / ENV
|
||||
- `PULSE_API_HOST` — базовый URL TeleCart Pulse (используется и для events, и для heartbeat).
|
||||
- `PULSE_TIMEOUT` — общий таймаут HTTP (для heartbeat принудительно 2 секунды).
|
||||
- `PULSE_HEARTBEAT_SECRET` (или `pulse.heartbeat_secret` в настройках) — общий секрет для подписания heartbeat. Обязателен, иначе heartbeat не будет отправляться.
|
||||
- `pulse.api_key` — прежний API ключ, используется только для event-инджеста.
|
||||
|
||||
### Поведение
|
||||
1. Frontend (SPA) вызывает `heartbeat` при инициализации приложения (fire-and-forget).
|
||||
2. Backend проверяет кеш. Если часа еще не прошло, `handleHeartbeat()` возвращает без запросов.
|
||||
3. При необходимости собираются данные, подписываются через heartbeat signer и отправляются POST-запросом на `/heartbeat`.
|
||||
4. Любые сбои (bot info, подпись, HTTP) логируются как warning, чтобы не тревожить пользователей.
|
||||
|
||||
### TODO / Возможные улучшения
|
||||
- При необходимости вынести heartbeat запуск в крон/CLI, чтобы не зависеть от фронтенда.
|
||||
- Добавить метрики успешности heartbeat в админку.
|
||||
|
||||
|
||||
128
.cursor/prompts/api-generation.md
Normal file
128
.cursor/prompts/api-generation.md
Normal file
@@ -0,0 +1,128 @@
|
||||
# Промпты для генерации API
|
||||
|
||||
## Создание нового API endpoint
|
||||
|
||||
```
|
||||
Создай новый API endpoint [ENDPOINT_NAME] для [DESCRIPTION]:
|
||||
|
||||
1. Handler в [HANDLER_PATH]:
|
||||
- Метод handle() принимает Request
|
||||
- Валидация входных данных
|
||||
- Использование Service для бизнес-логики
|
||||
- Возврат JsonResponse с правильной структурой
|
||||
- Обработка ошибок с логированием
|
||||
|
||||
2. Service в [SERVICE_PATH]:
|
||||
- Бизнес-логика
|
||||
- Работа с Model
|
||||
- Валидация данных
|
||||
- Обработка исключений
|
||||
|
||||
3. Model в [MODEL_PATH] (если нужен):
|
||||
- Методы для работы с БД
|
||||
- Использование Query Builder
|
||||
- Типизация методов
|
||||
|
||||
4. Route в routes.php:
|
||||
- Добавь маршрут с правильным именем
|
||||
|
||||
5. Миграция (если нужна новая таблица):
|
||||
- Создай миграцию в database/migrations/
|
||||
- Используй фиксированный префикс telecart_
|
||||
- Добавь индексы где необходимо
|
||||
|
||||
Следуй архитектуре MVC-L проекта и используй существующие паттерны.
|
||||
```
|
||||
|
||||
## Создание CRUD API
|
||||
|
||||
```
|
||||
Создай полный CRUD API для сущности [ENTITY_NAME]:
|
||||
|
||||
1. Handler с методами:
|
||||
- list() - список с пагинацией и фильтрацией
|
||||
- get() - получение одной записи
|
||||
- create() - создание
|
||||
- update() - обновление
|
||||
- delete() - удаление
|
||||
|
||||
2. Service с бизнес-логикой для всех операций
|
||||
|
||||
3. Model с методами:
|
||||
- findAll() - список
|
||||
- findById() - по ID
|
||||
- create() - создание
|
||||
- update() - обновление
|
||||
- delete() - удаление
|
||||
|
||||
4. DTO для валидации данных
|
||||
|
||||
5. Миграция для таблицы [TABLE_NAME]
|
||||
|
||||
6. Routes для всех endpoints
|
||||
|
||||
Используй серверную пагинацию, фильтрацию и сортировку для list().
|
||||
```
|
||||
|
||||
## Создание Admin API endpoint
|
||||
|
||||
```
|
||||
Создай Admin API endpoint [ENDPOINT_NAME] в bastion/Handlers/:
|
||||
|
||||
1. Handler в bastion/Handlers/[HANDLER_NAME].php:
|
||||
- Используй Request для получения параметров
|
||||
- Валидация данных
|
||||
- Работа через Service
|
||||
- Возврат JsonResponse с структурой { data: { data: [...], totalRecords: ... } }
|
||||
- Обработка ошибок
|
||||
|
||||
2. Service в bastion/Services/ (если нужен):
|
||||
- Бизнес-логика для админки
|
||||
- Работа с Models
|
||||
|
||||
3. Route в bastion/routes.php
|
||||
|
||||
4. Frontend компонент (если нужен UI):
|
||||
- Vue компонент в frontend/admin/src/views/
|
||||
- Используй PrimeVue компоненты
|
||||
- Серверная пагинация/фильтрация
|
||||
- Обработка ошибок с toast уведомлениями
|
||||
|
||||
Следуй существующим паттернам проекта.
|
||||
```
|
||||
|
||||
## Создание Frontend API клиента
|
||||
|
||||
```
|
||||
Создай функцию для работы с API endpoint [ENDPOINT_NAME]:
|
||||
|
||||
1. В frontend/[admin|spa]/src/utils/http.js:
|
||||
- Функция api[Method] для вызова endpoint
|
||||
- Правильная обработка ошибок
|
||||
- Возврат структурированного ответа
|
||||
|
||||
2. Использование:
|
||||
- В компонентах через import
|
||||
- Обработка loading states
|
||||
- Toast уведомления для ошибок
|
||||
|
||||
Следуй существующим паттернам в http.js.
|
||||
```
|
||||
|
||||
## Создание миграции
|
||||
|
||||
```
|
||||
Создай миграцию для таблицы [TABLE_NAME]:
|
||||
|
||||
1. Файл: database/migrations/[TIMESTAMP]_[DESCRIPTION].php
|
||||
2. Используй фиксированный префикс telecart_ для таблицы
|
||||
3. Добавь все необходимые поля с правильными типами
|
||||
4. Добавь индексы для часто используемых полей
|
||||
5. Используй utf8mb4_unicode_ci collation
|
||||
6. Используй InnoDB engine
|
||||
7. Добавь created_at и updated_at timestamps
|
||||
8. Не создавай foreign keys (используй только индексы)
|
||||
|
||||
Следуй структуре существующих миграций.
|
||||
```
|
||||
|
||||
106
.cursor/prompts/changelog.md
Normal file
106
.cursor/prompts/changelog.md
Normal file
@@ -0,0 +1,106 @@
|
||||
# Правила оформления Changelog для документации
|
||||
|
||||
## Общие требования
|
||||
|
||||
- **Формат**: Markdown
|
||||
- **Структура**: Одна версия = одна страница
|
||||
- **Стиль**: Профессиональный, лаконичный, без маркетинговых лозунгов
|
||||
- **Язык**: Русский
|
||||
- **Целевая аудитория**: Разработчики и владельцы магазинов
|
||||
- **Содержимое**: Только ключевые изменения, без лишних технических деталей
|
||||
|
||||
## Структура страницы
|
||||
|
||||
### Вводный абзац
|
||||
- Краткое описание релиза (1-2 предложения)
|
||||
- Дополнить 1-2 предложениями о ключевых изменениях
|
||||
- Упоминать основные фичи и улучшения
|
||||
|
||||
### Разделы (строго в этом порядке)
|
||||
|
||||
1. **🚀 Добавлено** - новые функции и возможности
|
||||
2. **✨ Улучшено** - улучшения существующих функций
|
||||
3. **🐞 Исправлено** - исправленные ошибки
|
||||
4. **⚠️ Несовместимые изменения** - изменения без обратной совместимости
|
||||
5. **🔧 Технические изменения** - технические изменения (отдельный раздел)
|
||||
|
||||
### Правила для разделов
|
||||
|
||||
- Раздел **НЕ** добавлять, если в нём нет пунктов
|
||||
- Использовать маркдаун-списки, без нумерации
|
||||
- Не добавлять ссылок, если они явно не указаны
|
||||
- Не добавлять лишних пояснений, выводов и заключений
|
||||
- Выводить только содержимое Markdown-файла, без комментариев
|
||||
|
||||
## Разделение изменений
|
||||
|
||||
### Бизнес-логика и процессы
|
||||
Разделы "Добавлено", "Улучшено", "Исправлено", "Несовместимые изменения" содержат только изменения, связанные с:
|
||||
- Бизнес-процессами магазина
|
||||
- Пользовательским опытом
|
||||
- Функциональностью для владельцев магазинов
|
||||
- Продающими фичами
|
||||
|
||||
### Технические изменения
|
||||
Все технические изменения выносятся в отдельный раздел **🔧 Технические изменения**:
|
||||
- Без подразделов, всё в одном списке
|
||||
- Только самые ключевые технические изменения
|
||||
- Не расписывать детали, которые не интересны пользователю
|
||||
|
||||
## Стиль написания
|
||||
|
||||
### Терминология
|
||||
- Английские термины писать по-английски (например: "navbar", а не "навбар")
|
||||
- Избегать технических терминов в бизнес-разделах (например: "customer_id" → "автоматическое связывание покупателей")
|
||||
|
||||
### Описания
|
||||
|
||||
**Для ключевых продающих фич:**
|
||||
- Давать подробное описание с деталями
|
||||
- Указывать возможности и преимущества
|
||||
- Подчеркивать ценность для пользователя
|
||||
|
||||
**Для технических изменений:**
|
||||
- Кратко, только ключевое
|
||||
- Без лишних деталей
|
||||
|
||||
**Для обычных функций:**
|
||||
- Лаконично, но информативно
|
||||
- Фокус на пользе для пользователя
|
||||
|
||||
## Порядок размещения
|
||||
|
||||
### В разделе "Добавлено"
|
||||
Ключевые продающие фичи размещать **первыми**:
|
||||
1. Система конфигурации главной страницы через блоки
|
||||
2. Конструктор форм на базе FormKit
|
||||
3. Интеграция с Яндекс.Метрикой
|
||||
4. Политика конфиденциальности
|
||||
5. Поддержка купонов и подарочных сертификатов
|
||||
6. Остальные функции
|
||||
|
||||
## Примеры правильных формулировок
|
||||
|
||||
### ✅ Хорошо
|
||||
- "Автоматическое связывание покупателей Telegram с покупателями OpenCart: система автоматически находит и связывает покупателей из Telegram-магазина с существующими покупателями в OpenCart по email или номеру телефона, создавая единую базу клиентов"
|
||||
|
||||
### ❌ Плохо
|
||||
- "Сохранение customer_id вместе с заказом для связи с клиентами OpenCart: автоматическая связь заказов из Telegram с покупателями в OpenCart, единая база клиентов"
|
||||
|
||||
### ✅ Хорошо
|
||||
- "Navbar с логотипом и названием приложения"
|
||||
|
||||
### ❌ Плохо
|
||||
- "Навбар с логотипом и названием приложения"
|
||||
|
||||
## Что не включать
|
||||
|
||||
- Внутренние детали разработки (тесты, статический анализ, обфускация)
|
||||
- Технические детали, не интересные пользователям
|
||||
- Избыточные технические описания
|
||||
- Маркетинговые лозунги и призывы
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
62
.cursor/prompts/documentation.md
Normal file
62
.cursor/prompts/documentation.md
Normal file
@@ -0,0 +1,62 @@
|
||||
# Промпты для документирования
|
||||
|
||||
## Документирование класса
|
||||
|
||||
```
|
||||
Добавь PHPDoc документацию для класса [CLASS_NAME]:
|
||||
1. Описание класса и его назначения
|
||||
2. @package тег
|
||||
3. @author тег
|
||||
4. Документация для всех публичных методов
|
||||
5. Документация для публичных свойств
|
||||
6. Примеры использования где уместно
|
||||
```
|
||||
|
||||
## Документирование метода
|
||||
|
||||
```
|
||||
Добавь PHPDoc для метода [METHOD_NAME]:
|
||||
1. Описание метода
|
||||
2. @param для всех параметров с типами
|
||||
3. @return с типом возвращаемого значения
|
||||
4. @throws для всех исключений
|
||||
5. Примеры использования если сложная логика
|
||||
```
|
||||
|
||||
## Документирование API endpoint
|
||||
|
||||
```
|
||||
Создай документацию для API endpoint [ENDPOINT_NAME]:
|
||||
1. Описание назначения
|
||||
2. HTTP метод и путь
|
||||
3. Параметры запроса (query/body)
|
||||
4. Формат ответа (JSON структура)
|
||||
5. Коды ошибок
|
||||
6. Примеры запросов/ответов
|
||||
7. Требования к авторизации
|
||||
```
|
||||
|
||||
## Документирование Vue компонента
|
||||
|
||||
```
|
||||
Добавь документацию для Vue компонента [COMPONENT_NAME]:
|
||||
1. Описание компонента
|
||||
2. Props с типами и описаниями
|
||||
3. Emits с описаниями
|
||||
4. Slots если есть
|
||||
5. Примеры использования
|
||||
6. Зависимости от других компонентов
|
||||
```
|
||||
|
||||
## Создание README
|
||||
|
||||
```
|
||||
Создай README.md для [MODULE/COMPONENT]:
|
||||
1. Описание назначения
|
||||
2. Установка/настройка
|
||||
3. Использование с примерами
|
||||
4. API документация
|
||||
5. Конфигурация
|
||||
6. Troubleshooting
|
||||
```
|
||||
|
||||
88
.cursor/prompts/refactoring.md
Normal file
88
.cursor/prompts/refactoring.md
Normal file
@@ -0,0 +1,88 @@
|
||||
# Промпты для рефакторинга
|
||||
|
||||
## Общий рефакторинг
|
||||
|
||||
```
|
||||
Проанализируй код в файле [FILE_PATH] и выполни рефакторинг:
|
||||
1. Убери дублирование кода
|
||||
2. Улучши читаемость
|
||||
3. Примени принципы SOLID
|
||||
4. Добавь обработку ошибок где необходимо
|
||||
5. Улучши типизацию
|
||||
6. Добавь документацию для публичных методов
|
||||
7. Убедись что код следует архитектуре MVC-L проекта
|
||||
8. Используй существующие утилиты и сервисы проекта вместо создания новых
|
||||
```
|
||||
|
||||
## Рефакторинг Handler
|
||||
|
||||
```
|
||||
Рефакторинг Handler [HANDLER_NAME]:
|
||||
1. Вынеси бизнес-логику в отдельный Service
|
||||
2. Добавь валидацию входных данных
|
||||
3. Улучши обработку ошибок с логированием
|
||||
4. Используй DTO для передачи данных
|
||||
5. Добавь PHPDoc комментарии
|
||||
6. Убедись что используется Dependency Injection
|
||||
7. Оптимизируй запросы к БД если необходимо
|
||||
```
|
||||
|
||||
## Рефакторинг Model
|
||||
|
||||
```
|
||||
Рефакторинг Model [MODEL_NAME]:
|
||||
1. Убедись что все запросы используют Query Builder
|
||||
2. Добавь методы для частых операций (findBy, findAll, create, update)
|
||||
3. Добавь валидацию данных перед сохранением
|
||||
4. Улучши типизацию методов
|
||||
5. Добавь PHPDoc комментарии
|
||||
6. Используй транзакции для сложных операций
|
||||
```
|
||||
|
||||
## Рефакторинг Vue компонента
|
||||
|
||||
```
|
||||
Рефакторинг Vue компонента [COMPONENT_NAME]:
|
||||
1. Вынеси логику в composable функции
|
||||
2. Улучши типизацию props и emits
|
||||
3. Оптимизируй computed properties
|
||||
4. Добавь обработку ошибок
|
||||
5. Улучши структуру template
|
||||
6. Добавь loading states
|
||||
7. Используй существующие утилиты проекта
|
||||
```
|
||||
|
||||
## Удаление дублирования
|
||||
|
||||
```
|
||||
Найди и устрани дублирование кода в:
|
||||
- [FILE_PATH_1]
|
||||
- [FILE_PATH_2]
|
||||
- [FILE_PATH_3]
|
||||
|
||||
Создай общие утилиты/сервисы где необходимо, следуя архитектуре проекта.
|
||||
```
|
||||
|
||||
## Улучшение производительности
|
||||
|
||||
```
|
||||
Проанализируй производительность кода в [FILE_PATH]:
|
||||
1. Оптимизируй запросы к БД (используй индексы, избегай N+1)
|
||||
2. Добавь кэширование где уместно
|
||||
3. Оптимизируй алгоритмы
|
||||
4. Уменьши количество запросов к API
|
||||
5. Используй ленивую загрузку на фронтенде
|
||||
```
|
||||
|
||||
## Улучшение безопасности
|
||||
|
||||
```
|
||||
Улучши безопасность кода в [FILE_PATH]:
|
||||
1. Добавь валидацию всех входных данных
|
||||
2. Используй prepared statements (Query Builder)
|
||||
3. Добавь CSRF защиту где необходимо
|
||||
4. Валидируй права доступа
|
||||
5. Санитизируй выходные данные
|
||||
6. Добавь rate limiting где необходимо
|
||||
```
|
||||
|
||||
53
.cursor/prompts/testing.md
Normal file
53
.cursor/prompts/testing.md
Normal file
@@ -0,0 +1,53 @@
|
||||
# Промпты для тестирования
|
||||
|
||||
## Создание unit теста
|
||||
|
||||
```
|
||||
Создай unit тест для [CLASS_NAME] в tests/Unit/:
|
||||
|
||||
1. Используй PHPUnit
|
||||
2. Покрой все публичные методы
|
||||
3. Тестируй успешные сценарии
|
||||
4. Тестируй обработку ошибок
|
||||
5. Используй моки для зависимостей
|
||||
6. Следуй структуре существующих тестов
|
||||
7. Используй TestCase базовый класс проекта
|
||||
```
|
||||
|
||||
## Создание integration теста
|
||||
|
||||
```
|
||||
Создай integration тест для [FEATURE_NAME] в tests/Integration/:
|
||||
|
||||
1. Тестируй полный flow от запроса до ответа
|
||||
2. Используй тестовую БД
|
||||
3. Очищай данные после тестов
|
||||
4. Тестируй реальные сценарии использования
|
||||
5. Проверяй валидацию данных
|
||||
6. Проверяй обработку ошибок
|
||||
```
|
||||
|
||||
## Создание Vue компонент теста
|
||||
|
||||
```
|
||||
Создай тест для Vue компонента [COMPONENT_NAME] в frontend/[admin|spa]/tests/:
|
||||
|
||||
1. Используй Vitest
|
||||
2. Тестируй рендеринг компонента
|
||||
3. Тестируй props
|
||||
4. Тестируй события (emits)
|
||||
5. Тестируй пользовательские взаимодействия
|
||||
6. Используй моки для API вызовов
|
||||
7. Следуй структуре существующих тестов
|
||||
```
|
||||
|
||||
## Покрытие тестами
|
||||
|
||||
```
|
||||
Проанализируй покрытие тестами для [FILE_PATH]:
|
||||
1. Определи какие методы не покрыты тестами
|
||||
2. Создай тесты для критичных методов
|
||||
3. Убедись что тестируются граничные случаи
|
||||
4. Добавь тесты для обработки ошибок
|
||||
```
|
||||
|
||||
201
.cursor/rules/architecture.md
Normal file
201
.cursor/rules/architecture.md
Normal file
@@ -0,0 +1,201 @@
|
||||
# Архитектурные правила
|
||||
|
||||
## OpenCart Framework Architecture
|
||||
|
||||
### MVC-L Pattern
|
||||
|
||||
Проект использует модифицированный паттерн MVC-L (Model-View-Controller-Language):
|
||||
|
||||
- **Model**: Классы в `src/Models/` - работа с данными, доступ к БД
|
||||
- **View**: Vue компоненты на фронтенде, JSON ответы на бэкенде
|
||||
- **Controller**: Handlers в `src/Handlers/` и `bastion/Handlers/`
|
||||
- **Language**: Переводчик в `framework/Translator/`
|
||||
|
||||
### Dependency Injection
|
||||
|
||||
Все зависимости внедряются через Container:
|
||||
|
||||
```php
|
||||
// ✅ Правильно
|
||||
public function __construct(
|
||||
private Builder $builder,
|
||||
private TelegramCustomer $telegramCustomerModel
|
||||
) {}
|
||||
|
||||
// ❌ Неправильно
|
||||
public function __construct() {
|
||||
$this->builder = new Builder(...);
|
||||
}
|
||||
```
|
||||
|
||||
### Service Providers
|
||||
|
||||
Регистрация сервисов через Service Providers:
|
||||
|
||||
```php
|
||||
class MyServiceProvider extends ServiceProvider
|
||||
{
|
||||
public function register(): void
|
||||
{
|
||||
$this->app->singleton(MyService::class, function ($app) {
|
||||
return new MyService($app->get(Dependency::class));
|
||||
});
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Routes
|
||||
|
||||
Маршруты определяются в `routes.php`:
|
||||
|
||||
```php
|
||||
return [
|
||||
'actionName' => [HandlerClass::class, 'methodName'],
|
||||
];
|
||||
```
|
||||
|
||||
### Handlers (Controllers)
|
||||
|
||||
Handlers обрабатывают HTTP запросы:
|
||||
|
||||
```php
|
||||
class MyHandler
|
||||
{
|
||||
public function handle(Request $request): JsonResponse
|
||||
{
|
||||
// Валидация
|
||||
// Бизнес-логика через Services
|
||||
// Возврат JsonResponse
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Models
|
||||
|
||||
Models работают с данными:
|
||||
|
||||
```php
|
||||
class MyModel
|
||||
{
|
||||
public function __construct(
|
||||
private ConnectionInterface $database,
|
||||
private Builder $builder
|
||||
) {}
|
||||
|
||||
public function findById(int $id): ?array
|
||||
{
|
||||
return $this->builder->newQuery()
|
||||
->from($this->tableName)
|
||||
->where('id', '=', $id)
|
||||
->firstOrNull();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Services
|
||||
|
||||
Services содержат бизнес-логику:
|
||||
|
||||
```php
|
||||
class MyService
|
||||
{
|
||||
public function __construct(
|
||||
private MyModel $model
|
||||
) {}
|
||||
|
||||
public function doSomething(array $data): array
|
||||
{
|
||||
// Бизнес-логика
|
||||
return $this->model->create($data);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Migrations
|
||||
|
||||
Миграции в `database/migrations/`:
|
||||
|
||||
```php
|
||||
return new class extends Migration {
|
||||
public function up(): void
|
||||
{
|
||||
$this->database->statement('CREATE TABLE ...');
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
### Query Builder
|
||||
|
||||
Всегда используй Query Builder вместо прямых SQL:
|
||||
|
||||
```php
|
||||
// ✅ Правильно
|
||||
$query = $this->builder->newQuery()
|
||||
->select(['id', 'name'])
|
||||
->from('table_name')
|
||||
->where('status', '=', 'active')
|
||||
->get();
|
||||
|
||||
// ❌ Неправильно
|
||||
$result = $this->database->query("SELECT * FROM table_name WHERE status = 'active'");
|
||||
```
|
||||
|
||||
### Frontend Architecture
|
||||
|
||||
#### Admin Panel (Vue 3)
|
||||
|
||||
- Composition API
|
||||
- Pinia для state management
|
||||
- PrimeVue для UI компонентов
|
||||
- Axios для HTTP запросов
|
||||
- Vue Router для навигации
|
||||
|
||||
#### SPA (Telegram Mini App)
|
||||
|
||||
- Composition API
|
||||
- Pinia stores
|
||||
- Tailwind CSS для стилей
|
||||
- Telegram WebApp API
|
||||
- Vue Router
|
||||
|
||||
### Naming Conventions
|
||||
|
||||
- **Classes**: PascalCase (`TelegramCustomerService`)
|
||||
- **Methods**: camelCase (`getCustomers`)
|
||||
- **Variables**: camelCase (`$customerData`)
|
||||
- **Constants**: UPPER_SNAKE_CASE (`MAX_RETRIES`)
|
||||
- **Files**: PascalCase для классов, kebab-case для остального
|
||||
- **Tables**: snake_case с префиксом `telecart_`
|
||||
|
||||
### Error Handling
|
||||
|
||||
Всегда обрабатывай ошибки:
|
||||
|
||||
```php
|
||||
try {
|
||||
$result = $this->service->doSomething();
|
||||
} catch (SpecificException $e) {
|
||||
$this->logger->error('Error message', ['exception' => $e]);
|
||||
throw new UserFriendlyException('User message');
|
||||
}
|
||||
```
|
||||
|
||||
### Configuration
|
||||
|
||||
Используй конфигурационные файлы в `configs/`:
|
||||
|
||||
```php
|
||||
$config = $this->app->getConfigValue('app.setting_name');
|
||||
```
|
||||
|
||||
### Caching
|
||||
|
||||
Используй Cache Service для кэширования:
|
||||
|
||||
```php
|
||||
$cache = $this->app->get(CacheInterface::class);
|
||||
$value = $cache->get('key', function() {
|
||||
return expensiveOperation();
|
||||
});
|
||||
```
|
||||
|
||||
70
.cursor/rules/form-builder.md
Normal file
70
.cursor/rules/form-builder.md
Normal file
@@ -0,0 +1,70 @@
|
||||
# FormBuilder System Context
|
||||
|
||||
## Architectural Overview
|
||||
The FormBuilder ecosystem is a strictly typed Vue 3 application module designed to generate standard FormKit Schema JSON. It eschews internal DTOs in favor of direct schema manipulation.
|
||||
|
||||
### Core Components
|
||||
1. **FormBuilderView (`views/FormBuilderView.vue`)**:
|
||||
* **Role**: Smart container / Data fetcher.
|
||||
* **Responsibility**: Fetches form data from API (`GET /api/admin/forms/{alias}`), handles loading states, and passes data to `FormBuilder`.
|
||||
* **Contract**: Expects API response `{ data: { schema: Array, is_custom: Boolean, ... } }`.
|
||||
|
||||
2. **FormBuilder (`components/FormBuilder/FormBuilder.vue`)**:
|
||||
* **Role**: Main Orchestrator / State Owner.
|
||||
* **Responsibility**: Manages `v-model` (schema), mode switching (Visual/Code/Preview), and provides state to children.
|
||||
* **State Management**: Uses `defineModel` for `formFields` (schema) and `isCustom` (mode flag). Uses `provide('formFields')` and `provide('selectedFieldId')` for deep dependency injection.
|
||||
* **Modes**:
|
||||
* **Visual**: Drag-and-drop interface using `vuedraggable`.
|
||||
* **Code**: Direct JSON editing of the FormKit schema. Sets `isCustom = true`.
|
||||
* **Preview**: Renders the current schema using `FormKit`.
|
||||
|
||||
3. **FormCanvas (`components/FormBuilder/FormCanvas.vue`)**:
|
||||
* **Role**: Visual Editor Surface.
|
||||
* **Responsibility**: Renders the draggable list of fields.
|
||||
* **Implementation**: Uses `vuedraggable` bound to `formFields`.
|
||||
* **UX**: Implements "Ghost" and "Drag" classes for visual feedback. Handles selection logic.
|
||||
|
||||
4. **FieldsPanel (`components/FormBuilder/FieldsPanel.vue`)**:
|
||||
* **Role**: Component Palette.
|
||||
* **Responsibility**: Source of truth for available field types.
|
||||
* **Implementation**: Uses `vuedraggable` with `pull: 'clone', put: false` to spawn new fields.
|
||||
|
||||
5. **FieldSettings (`components/FormBuilder/FieldSettings.vue`)**:
|
||||
* **Role**: Property Editor.
|
||||
* **Responsibility**: Edits properties of the `selectedFieldId`.
|
||||
* **Constraint**: Must use PrimeVue components for all inputs.
|
||||
|
||||
## Data Flow & Invariants
|
||||
1. **Schema Authority**: The FormKit Schema JSON is the single source of truth. There is no "internal model" separate from the schema.
|
||||
2. **Reactivity**:
|
||||
* `formFields` is an Array of Objects.
|
||||
* Mutations must preserve reactivity. When using `v-model` or `provide/inject`, ensure array methods (splice, push, filter) are used correctly or replace the entire array reference if needed to trigger watchers.
|
||||
* **Immutability**: `useFormFields` composable uses immutable patterns (returning new array references) to ensure `defineModel` in parent detects changes.
|
||||
3. **Mode Logic**:
|
||||
* Switching to **Code** mode sets `isCustom = true`.
|
||||
* Switching to **Visual** mode sets `isCustom = false`.
|
||||
* **Safety**: Switching modes triggers JSON validation. Invalid JSON prevents mode switch.
|
||||
4. **Drag and Drop**:
|
||||
* Powered by `vuedraggable` (Sortable.js).
|
||||
* **Clone Logic**: `FieldsPanel` clones from `availableFields`. `FormCanvas` receives the clone.
|
||||
* **ID Generation**: Unique IDs are generated upon cloning/addition to ensure key stability.
|
||||
|
||||
## Naming & Conventions
|
||||
* **Tailwind**: Use `tw:` prefix for all utility classes (e.g., `tw:flex`, `tw:p-4`).
|
||||
* **Components**: PrimeVue components are the standard UI kit (Button, Panel, InputText, etc.).
|
||||
* **Icons**: FontAwesome (`fa fa-*`).
|
||||
* **Files**: PascalCase for components (`FormBuilder.vue`), camelCase for logic (`useFormFields.js`).
|
||||
|
||||
## Integration Rules
|
||||
* **Backend**: The backend stores the JSON blob directly. `FormBuilder` does not transform data before save; it emits the raw schema.
|
||||
* **API**: `useFormsStore` handles API communication.
|
||||
|
||||
## Pitfalls & Warnings
|
||||
* **vuedraggable vs @formkit/drag-and-drop**: We strictly use `vuedraggable`. Do not re-introduce `@formkit/drag-and-drop`.
|
||||
* **Watchers**: Avoid `watch` where `computed` or event handlers suffice, to prevent infinite loops in bidirectional data flow.
|
||||
* **Tailwind Config**: Do not use `@apply` with `tw:` prefixed classes in `<style>` blocks; standard CSS properties should be used if custom classes are needed.
|
||||
|
||||
## Future Modifications
|
||||
* **Adding Fields**: Update `constants/availableFields.js` and ensure `utils/fieldHelpers.js` supports the new type.
|
||||
* **Validation**: FormKit validation rules string (e.g., "required|email") is edited as a raw string in `FieldSettings`. Complex validation builders would require a new UI component.
|
||||
|
||||
332
.cursor/rules/javascript.md
Normal file
332
.cursor/rules/javascript.md
Normal file
@@ -0,0 +1,332 @@
|
||||
# JavaScript/TypeScript Code Style Rules
|
||||
|
||||
## JavaScript Version
|
||||
|
||||
- ES2020+ features
|
||||
- Modern async/await
|
||||
- Optional chaining (`?.`)
|
||||
- Nullish coalescing (`??`)
|
||||
- Template literals
|
||||
|
||||
## Code Style
|
||||
|
||||
### Variable Declarations
|
||||
|
||||
```javascript
|
||||
// ✅ Используй const по умолчанию
|
||||
const customers = [];
|
||||
const totalRecords = 0;
|
||||
|
||||
// ✅ let только когда нужно переназначение
|
||||
let currentPage = 1;
|
||||
currentPage = 2;
|
||||
|
||||
// ❌ Не используй var
|
||||
var oldVariable = 'bad';
|
||||
```
|
||||
|
||||
### Arrow Functions
|
||||
|
||||
```javascript
|
||||
// ✅ Предпочтительно для коротких функций
|
||||
const filtered = items.filter(item => item.isActive);
|
||||
|
||||
// ✅ Для методов объектов
|
||||
const api = {
|
||||
get: async (url) => {
|
||||
return await fetch(url);
|
||||
}
|
||||
};
|
||||
|
||||
// ✅ Для сложной логики - обычные функции
|
||||
function complexCalculation(data) {
|
||||
// много строк кода
|
||||
return result;
|
||||
}
|
||||
```
|
||||
|
||||
### Template Literals
|
||||
|
||||
```javascript
|
||||
// ✅ Предпочтительно
|
||||
const message = `User ${userId} not found`;
|
||||
const url = `${baseUrl}/api/${endpoint}`;
|
||||
|
||||
// ❌ Не используй конкатенацию
|
||||
const message = 'User ' + userId + ' not found';
|
||||
```
|
||||
|
||||
### Optional Chaining
|
||||
|
||||
```javascript
|
||||
// ✅ Используй optional chaining
|
||||
const name = user?.profile?.name;
|
||||
const count = data?.items?.length ?? 0;
|
||||
|
||||
// ❌ Избегай длинных проверок
|
||||
const name = user && user.profile && user.profile.name;
|
||||
```
|
||||
|
||||
### Nullish Coalescing
|
||||
|
||||
```javascript
|
||||
// ✅ Используй ?? для значений по умолчанию
|
||||
const page = params.page ?? 1;
|
||||
const name = user.name ?? 'Unknown';
|
||||
|
||||
// ❌ Не используй || для чисел/булевых
|
||||
const page = params.page || 1; // 0 будет заменено на 1
|
||||
```
|
||||
|
||||
### Destructuring
|
||||
|
||||
```javascript
|
||||
// ✅ Используй деструктуризацию
|
||||
const { data, totalRecords } = response.data;
|
||||
const [first, second] = items;
|
||||
|
||||
// ✅ В параметрах функций
|
||||
function processUser({ id, name, email }) {
|
||||
// ...
|
||||
}
|
||||
|
||||
// ✅ С значениями по умолчанию
|
||||
const { page = 1, limit = 20 } = params;
|
||||
```
|
||||
|
||||
### Async/Await
|
||||
|
||||
```javascript
|
||||
// ✅ Предпочтительно
|
||||
async function loadCustomers() {
|
||||
try {
|
||||
const response = await apiGet('getCustomers', params);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
console.error('Error:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// ❌ Избегай .then() цепочек
|
||||
function loadCustomers() {
|
||||
return apiGet('getCustomers', params)
|
||||
.then(response => response.data)
|
||||
.catch(error => console.error(error));
|
||||
}
|
||||
```
|
||||
|
||||
## Vue.js 3 Composition API
|
||||
|
||||
### Script Setup
|
||||
|
||||
```vue
|
||||
<script setup>
|
||||
// ✅ Используй <script setup>
|
||||
import { ref, computed, onMounted } from 'vue';
|
||||
import { apiGet } from '@/utils/http.js';
|
||||
|
||||
const customers = ref([]);
|
||||
const loading = ref(false);
|
||||
|
||||
const totalRecords = computed(() => customers.value.length);
|
||||
|
||||
async function loadCustomers() {
|
||||
loading.value = true;
|
||||
try {
|
||||
const result = await apiGet('getCustomers');
|
||||
customers.value = result.data || [];
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadCustomers();
|
||||
});
|
||||
</script>
|
||||
```
|
||||
|
||||
### Reactive State
|
||||
|
||||
```javascript
|
||||
// ✅ Используй ref для примитивов
|
||||
const count = ref(0);
|
||||
const name = ref('');
|
||||
|
||||
// ✅ Используй reactive для объектов
|
||||
import { reactive } from 'vue';
|
||||
const state = reactive({
|
||||
customers: [],
|
||||
loading: false
|
||||
});
|
||||
|
||||
// ✅ Или ref для объектов (предпочтительно)
|
||||
const state = ref({
|
||||
customers: [],
|
||||
loading: false
|
||||
});
|
||||
```
|
||||
|
||||
### Computed Properties
|
||||
|
||||
```javascript
|
||||
// ✅ Используй computed для производных значений
|
||||
const filteredCustomers = computed(() => {
|
||||
return customers.value.filter(c => c.isActive);
|
||||
});
|
||||
|
||||
// ❌ Не используй методы для вычислений
|
||||
function filteredCustomers() {
|
||||
return customers.value.filter(c => c.isActive);
|
||||
}
|
||||
```
|
||||
|
||||
### Props
|
||||
|
||||
```vue
|
||||
<script setup>
|
||||
// ✅ Определяй props с типами
|
||||
const props = defineProps({
|
||||
customerId: {
|
||||
type: Number,
|
||||
required: true
|
||||
},
|
||||
showDetails: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
}
|
||||
});
|
||||
</script>
|
||||
```
|
||||
|
||||
### Emits
|
||||
|
||||
```vue
|
||||
<script setup>
|
||||
// ✅ Определяй emits
|
||||
const emit = defineEmits(['update', 'delete']);
|
||||
|
||||
function handleUpdate() {
|
||||
emit('update', data);
|
||||
}
|
||||
</script>
|
||||
```
|
||||
|
||||
## Pinia Stores
|
||||
|
||||
```javascript
|
||||
// ✅ Используй setup stores
|
||||
import { defineStore } from 'pinia';
|
||||
import { ref, computed } from 'vue';
|
||||
|
||||
export const useCustomersStore = defineStore('customers', () => {
|
||||
const customers = ref([]);
|
||||
const loading = ref(false);
|
||||
|
||||
const totalRecords = computed(() => customers.value.length);
|
||||
|
||||
async function loadCustomers() {
|
||||
loading.value = true;
|
||||
try {
|
||||
const result = await apiGet('getCustomers');
|
||||
customers.value = result.data || [];
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
customers,
|
||||
loading,
|
||||
totalRecords,
|
||||
loadCustomers
|
||||
};
|
||||
});
|
||||
```
|
||||
|
||||
## Error Handling
|
||||
|
||||
```javascript
|
||||
// ✅ Всегда обрабатывай ошибки
|
||||
async function loadData() {
|
||||
try {
|
||||
const result = await apiGet('endpoint');
|
||||
if (result.success) {
|
||||
return result.data;
|
||||
} else {
|
||||
throw new Error(result.error);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load data:', error);
|
||||
toast.error('Не удалось загрузить данные');
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Naming Conventions
|
||||
|
||||
### Variables and Functions
|
||||
|
||||
```javascript
|
||||
// ✅ camelCase
|
||||
const customerData = {};
|
||||
const totalRecords = 0;
|
||||
function loadCustomers() {}
|
||||
|
||||
// ✅ Константы UPPER_SNAKE_CASE
|
||||
const MAX_RETRIES = 3;
|
||||
const API_BASE_URL = '/api';
|
||||
```
|
||||
|
||||
### Components
|
||||
|
||||
```vue
|
||||
<!-- ✅ PascalCase для компонентов -->
|
||||
<CustomerCard />
|
||||
<ProductsList />
|
||||
```
|
||||
|
||||
### Files
|
||||
|
||||
```javascript
|
||||
// ✅ kebab-case для файлов
|
||||
// customers-view.vue
|
||||
// http-utils.js
|
||||
// customer-service.js
|
||||
```
|
||||
|
||||
## Imports
|
||||
|
||||
```javascript
|
||||
// ✅ Группируй импорты
|
||||
// 1. Vue core
|
||||
import { ref, computed, onMounted } from 'vue';
|
||||
|
||||
// 2. Third-party
|
||||
import { apiGet } from '@/utils/http.js';
|
||||
import { useToast } from 'primevue';
|
||||
|
||||
// 3. Local components
|
||||
import CustomerCard from '@/components/CustomerCard.vue';
|
||||
|
||||
// 4. Types (если TypeScript)
|
||||
import type { Customer } from '@/types';
|
||||
```
|
||||
|
||||
## TypeScript (где используется)
|
||||
|
||||
```typescript
|
||||
// ✅ Используй типы
|
||||
interface Customer {
|
||||
id: number;
|
||||
name: string;
|
||||
email?: string;
|
||||
}
|
||||
|
||||
function getCustomer(id: number): Promise<Customer> {
|
||||
return apiGet(`customers/${id}`);
|
||||
}
|
||||
```
|
||||
|
||||
243
.cursor/rules/php.md
Normal file
243
.cursor/rules/php.md
Normal file
@@ -0,0 +1,243 @@
|
||||
# PHP Code Style Rules
|
||||
|
||||
## PHP Version
|
||||
|
||||
Проект поддерживает PHP 7.4+
|
||||
|
||||
## PSR Standards
|
||||
|
||||
- **PSR-1**: Basic Coding Standard
|
||||
- **PSR-4**: Autoloading Standard
|
||||
- **PSR-12**: Extended Coding Style
|
||||
|
||||
## Code Style
|
||||
|
||||
### Type Declarations
|
||||
|
||||
```php
|
||||
// ✅ Правильно - строгая типизация
|
||||
public function getCustomers(Request $request): JsonResponse
|
||||
{
|
||||
$id = (int) $request->get('id');
|
||||
return new JsonResponse(['data' => $customers]);
|
||||
}
|
||||
|
||||
// ❌ Неправильно - без типов
|
||||
public function getCustomers($request)
|
||||
{
|
||||
return ['data' => $customers];
|
||||
}
|
||||
```
|
||||
|
||||
### Nullable Types
|
||||
|
||||
```php
|
||||
// ✅ Правильно
|
||||
public function findById(?int $id): ?array
|
||||
{
|
||||
if ($id === null) {
|
||||
return null;
|
||||
}
|
||||
return $this->query->where('id', '=', $id)->firstOrNull();
|
||||
}
|
||||
```
|
||||
|
||||
### Strict Types
|
||||
|
||||
Всегда используй `declare(strict_types=1);`:
|
||||
|
||||
```php
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services;
|
||||
```
|
||||
|
||||
### Array Syntax
|
||||
|
||||
```php
|
||||
// ✅ Предпочтительно - короткий синтаксис
|
||||
$array = ['key' => 'value'];
|
||||
|
||||
// ❌ Не использовать
|
||||
$array = array('key' => 'value');
|
||||
```
|
||||
|
||||
### String Interpolation
|
||||
|
||||
```php
|
||||
// ✅ Предпочтительно
|
||||
$message = "User {$userId} not found";
|
||||
|
||||
// ✅ Альтернатива
|
||||
$message = sprintf('User %d not found', $userId);
|
||||
```
|
||||
|
||||
### Arrow Functions (PHP 7.4+)
|
||||
|
||||
```php
|
||||
// ✅ Для простых операций
|
||||
$filtered = array_filter($items, fn($item) => $item->isActive());
|
||||
|
||||
// ❌ Для сложной логики - используй обычные функции
|
||||
```
|
||||
|
||||
### Nullsafe Operator (PHP 8.0+)
|
||||
|
||||
```php
|
||||
// ✅ Для PHP 7.4
|
||||
$name = $user && $user->profile ? $user->profile->name : null;
|
||||
```
|
||||
|
||||
## Naming Conventions
|
||||
|
||||
### Classes
|
||||
|
||||
```php
|
||||
// ✅ PascalCase
|
||||
class TelegramCustomerService {}
|
||||
class UserRepository {}
|
||||
```
|
||||
|
||||
### Methods
|
||||
|
||||
```php
|
||||
// ✅ camelCase
|
||||
public function getCustomers(): array {}
|
||||
public function saveOrUpdate(array $data): array {}
|
||||
```
|
||||
|
||||
### Variables
|
||||
|
||||
```php
|
||||
// ✅ camelCase
|
||||
$customerData = [];
|
||||
$totalRecords = 0;
|
||||
```
|
||||
|
||||
### Constants
|
||||
|
||||
```php
|
||||
// ✅ UPPER_SNAKE_CASE
|
||||
private const MAX_RETRIES = 3;
|
||||
public const DEFAULT_PAGE_SIZE = 20;
|
||||
```
|
||||
|
||||
### Private Properties
|
||||
|
||||
```php
|
||||
// ✅ camelCase с модификатором доступа
|
||||
private string $tableName;
|
||||
private Builder $builder;
|
||||
```
|
||||
|
||||
## Documentation
|
||||
|
||||
### PHPDoc
|
||||
|
||||
```php
|
||||
/**
|
||||
* @throws ValidationException Если параметры невалидны
|
||||
*/
|
||||
public function getCustomers(Request $request): JsonResponse
|
||||
{
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
### Inline Comments
|
||||
|
||||
```php
|
||||
// ✅ Полезные комментарии
|
||||
// Применяем фильтры для подсчета общего количества записей
|
||||
$countQuery = $this->buildCountQuery($filters);
|
||||
|
||||
// ❌ Очевидные комментарии
|
||||
// Получаем данные
|
||||
$data = $this->getData();
|
||||
```
|
||||
|
||||
## Error Handling
|
||||
|
||||
### Exceptions
|
||||
|
||||
```php
|
||||
// ✅ Специфичные исключения
|
||||
if (!$userId) {
|
||||
throw new InvalidArgumentException('User ID is required');
|
||||
}
|
||||
|
||||
// ✅ Логирование
|
||||
try {
|
||||
$result = $this->service->process();
|
||||
} catch (Exception $e) {
|
||||
$this->logger->error('Processing failed', [
|
||||
'exception' => $e,
|
||||
'context' => $context,
|
||||
]);
|
||||
throw new ProcessingException('Failed to process', 0, $e);
|
||||
}
|
||||
```
|
||||
|
||||
## Query Builder Usage
|
||||
|
||||
### Always Use Query Builder
|
||||
|
||||
```php
|
||||
// ✅ Правильно
|
||||
$customers = $this->builder->newQuery()
|
||||
->select(['id', 'name', 'email'])
|
||||
->from('telecart_customers')
|
||||
->where('status', '=', 'active')
|
||||
->orderBy('created_at', 'DESC')
|
||||
->get();
|
||||
|
||||
// В крайних случаях можно использовать прямые SQL
|
||||
$result = $this->database->query("SELECT * FROM telecart_customers");
|
||||
```
|
||||
|
||||
### Parameter Binding
|
||||
|
||||
```php
|
||||
// ✅ Query Builder автоматически биндит параметры
|
||||
$query->where('name', 'LIKE', "%{$search}%");
|
||||
|
||||
// ❌ Никогда не конкатенируй значения в SQL, избегай SQL Injection.
|
||||
```
|
||||
|
||||
## Array Access
|
||||
|
||||
### Safe Array Access
|
||||
|
||||
```php
|
||||
// ✅ Используй Arr::get()
|
||||
use Openguru\OpenCartFramework\Support\Arr;
|
||||
|
||||
$value = Arr::get($data, 'key', 'default');
|
||||
|
||||
// ❌ Небезопасно
|
||||
$value = $data['key']; // может вызвать ошибку
|
||||
```
|
||||
|
||||
## Return Types
|
||||
|
||||
```php
|
||||
// ✅ Всегда указывай return type
|
||||
public function getData(): array {}
|
||||
public function findById(int $id): ?array {}
|
||||
public function process(): void {}
|
||||
|
||||
// ❌ Без типа
|
||||
public function getData() {}
|
||||
```
|
||||
|
||||
## Visibility Modifiers
|
||||
|
||||
```php
|
||||
// ✅ Всегда указывай модификатор доступа
|
||||
private string $tableName;
|
||||
protected Builder $builder;
|
||||
public function getData(): array {}
|
||||
```
|
||||
|
||||
370
.cursor/rules/vue.md
Normal file
370
.cursor/rules/vue.md
Normal file
@@ -0,0 +1,370 @@
|
||||
# Vue.js 3 Rules
|
||||
|
||||
## Component Structure
|
||||
|
||||
### Template
|
||||
|
||||
```vue
|
||||
<template>
|
||||
<!-- ✅ Логическая структура -->
|
||||
<div class="container">
|
||||
<header>
|
||||
<h2>{{ title }}</h2>
|
||||
</header>
|
||||
|
||||
<main>
|
||||
<DataTable :value="items" />
|
||||
</main>
|
||||
|
||||
<footer>
|
||||
<Button @click="handleSave">Save</Button>
|
||||
</footer>
|
||||
</div>
|
||||
</template>
|
||||
```
|
||||
|
||||
### Script Setup
|
||||
|
||||
```vue
|
||||
<script setup>
|
||||
// ✅ Всегда используй <script setup>
|
||||
import { ref, computed, onMounted } from 'vue';
|
||||
import DataTable from 'primevue/datatable';
|
||||
import { apiGet } from '@/utils/http.js';
|
||||
|
||||
// Props
|
||||
const props = defineProps({
|
||||
title: {
|
||||
type: String,
|
||||
required: true
|
||||
}
|
||||
});
|
||||
|
||||
// Emits
|
||||
const emit = defineEmits(['update', 'delete']);
|
||||
|
||||
// State
|
||||
const items = ref([]);
|
||||
const loading = ref(false);
|
||||
|
||||
// Computed
|
||||
const totalItems = computed(() => items.value.length);
|
||||
|
||||
// Methods
|
||||
async function loadItems() {
|
||||
loading.value = true;
|
||||
try {
|
||||
const result = await apiGet('getItems');
|
||||
items.value = result.data || [];
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Lifecycle
|
||||
onMounted(() => {
|
||||
loadItems();
|
||||
});
|
||||
</script>
|
||||
```
|
||||
|
||||
### Styles
|
||||
|
||||
```vue
|
||||
<style scoped>
|
||||
/* ✅ Используй scoped стили */
|
||||
.container {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
/* ✅ Используй :deep() для доступа к дочерним компонентам */
|
||||
:deep(.p-datatable) {
|
||||
border: 1px solid #ccc;
|
||||
}
|
||||
</style>
|
||||
```
|
||||
|
||||
## Component Naming
|
||||
|
||||
```vue
|
||||
<!-- ✅ PascalCase для компонентов -->
|
||||
<CustomerCard />
|
||||
<ProductsList />
|
||||
<OrderDetails />
|
||||
|
||||
<!-- ✅ kebab-case в шаблоне тоже работает -->
|
||||
<customer-card />
|
||||
```
|
||||
|
||||
## Props
|
||||
|
||||
```vue
|
||||
<script setup>
|
||||
// ✅ Всегда определяй типы и валидацию
|
||||
const props = defineProps({
|
||||
customerId: {
|
||||
type: Number,
|
||||
required: true,
|
||||
validator: (value) => value > 0
|
||||
},
|
||||
showDetails: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
items: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
}
|
||||
});
|
||||
</script>
|
||||
```
|
||||
|
||||
## Emits
|
||||
|
||||
```vue
|
||||
<script setup>
|
||||
// ✅ Определяй emits с типами
|
||||
const emit = defineEmits<{
|
||||
update: [id: number, data: object];
|
||||
delete: [id: number];
|
||||
cancel: [];
|
||||
}>();
|
||||
|
||||
// ✅ Или с валидацией
|
||||
const emit = defineEmits({
|
||||
update: (id: number, data: object) => {
|
||||
if (id > 0 && typeof data === 'object') {
|
||||
return true;
|
||||
}
|
||||
console.warn('Invalid emit arguments');
|
||||
return false;
|
||||
}
|
||||
});
|
||||
</script>
|
||||
```
|
||||
|
||||
## Reactive State
|
||||
|
||||
```vue
|
||||
<script setup>
|
||||
// ✅ ref для примитивов
|
||||
const count = ref(0);
|
||||
const name = ref('');
|
||||
|
||||
// ✅ ref для объектов (предпочтительно)
|
||||
const customer = ref({
|
||||
id: null,
|
||||
name: '',
|
||||
email: ''
|
||||
});
|
||||
|
||||
// ✅ reactive только если нужно
|
||||
import { reactive } from 'vue';
|
||||
const state = reactive({
|
||||
items: [],
|
||||
loading: false
|
||||
});
|
||||
</script>
|
||||
```
|
||||
|
||||
## Computed Properties
|
||||
|
||||
```vue
|
||||
<script setup>
|
||||
// ✅ Используй computed для производных значений
|
||||
const filteredItems = computed(() => {
|
||||
return items.value.filter(item => item.isActive);
|
||||
});
|
||||
|
||||
// ✅ Computed с getter/setter
|
||||
const fullName = computed({
|
||||
get: () => `${firstName.value} ${lastName.value}`,
|
||||
set: (value) => {
|
||||
const parts = value.split(' ');
|
||||
firstName.value = parts[0];
|
||||
lastName.value = parts[1];
|
||||
}
|
||||
});
|
||||
</script>
|
||||
```
|
||||
|
||||
## Event Handlers
|
||||
|
||||
```vue
|
||||
<template>
|
||||
<!-- ✅ Используй kebab-case для событий -->
|
||||
<Button @click="handleClick" />
|
||||
<Input @input="handleInput" />
|
||||
<Form @submit.prevent="handleSubmit" />
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
// ✅ Именуй обработчики с префиксом handle
|
||||
function handleClick() {
|
||||
// ...
|
||||
}
|
||||
|
||||
function handleInput(event) {
|
||||
// ...
|
||||
}
|
||||
|
||||
function handleSubmit() {
|
||||
// ...
|
||||
}
|
||||
</script>
|
||||
```
|
||||
|
||||
## Conditional Rendering
|
||||
|
||||
```vue
|
||||
<template>
|
||||
<!-- ✅ Используй v-if для условного рендеринга -->
|
||||
<div v-if="loading">
|
||||
<LoadingSpinner />
|
||||
</div>
|
||||
|
||||
<!-- ✅ v-show для частых переключений -->
|
||||
<div v-show="hasItems">
|
||||
<ItemsList :items="items" />
|
||||
</div>
|
||||
|
||||
<!-- ✅ v-else для альтернатив -->
|
||||
<div v-else>
|
||||
<EmptyState />
|
||||
</div>
|
||||
</template>
|
||||
```
|
||||
|
||||
## Lists
|
||||
|
||||
```vue
|
||||
<template>
|
||||
<!-- ✅ Всегда используй :key -->
|
||||
<div v-for="item in items" :key="item.id">
|
||||
{{ item.name }}
|
||||
</div>
|
||||
|
||||
<!-- ✅ Для индексов -->
|
||||
<div v-for="(item, index) in items" :key="`item-${index}`">
|
||||
{{ item.name }}
|
||||
</div>
|
||||
</template>
|
||||
```
|
||||
|
||||
## Form Handling
|
||||
|
||||
```vue
|
||||
<template>
|
||||
<form @submit.prevent="handleSubmit">
|
||||
<!-- ✅ Используй v-model -->
|
||||
<InputText v-model="form.name" />
|
||||
<Textarea v-model="form.description" />
|
||||
|
||||
<!-- ✅ Для кастомных компонентов -->
|
||||
<CustomInput v-model="form.email" />
|
||||
</form>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
const form = ref({
|
||||
name: '',
|
||||
description: '',
|
||||
email: ''
|
||||
});
|
||||
|
||||
function handleSubmit() {
|
||||
// Валидация и отправка
|
||||
}
|
||||
</script>
|
||||
```
|
||||
|
||||
## PrimeVue Components
|
||||
|
||||
```vue
|
||||
<template>
|
||||
<!-- ✅ Используй PrimeVue компоненты в админке -->
|
||||
<DataTable
|
||||
:value="customers"
|
||||
:loading="loading"
|
||||
paginator
|
||||
:rows="20"
|
||||
@page="onPage"
|
||||
>
|
||||
<Column field="name" header="Name" sortable />
|
||||
</DataTable>
|
||||
</template>
|
||||
```
|
||||
|
||||
## Styling
|
||||
|
||||
```vue
|
||||
<style scoped>
|
||||
/* ✅ Используй scoped -->
|
||||
.container {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
/* ✅ :deep() для дочерних компонентов */
|
||||
:deep(.p-datatable) {
|
||||
border: 1px solid #ccc;
|
||||
}
|
||||
|
||||
/* ✅ :slotted() для слотов */
|
||||
:slotted(.header) {
|
||||
font-weight: bold;
|
||||
}
|
||||
</style>
|
||||
```
|
||||
|
||||
## Composition Functions
|
||||
|
||||
```vue
|
||||
<script setup>
|
||||
// ✅ Выноси сложную логику в composables
|
||||
import { useCustomers } from '@/composables/useCustomers.js';
|
||||
|
||||
const {
|
||||
customers,
|
||||
loading,
|
||||
loadCustomers,
|
||||
totalRecords
|
||||
} = useCustomers();
|
||||
|
||||
onMounted(() => {
|
||||
loadCustomers();
|
||||
});
|
||||
</script>
|
||||
```
|
||||
|
||||
## Error Handling
|
||||
|
||||
```vue
|
||||
<script setup>
|
||||
import { useToast } from 'primevue';
|
||||
|
||||
const toast = useToast();
|
||||
|
||||
async function loadData() {
|
||||
try {
|
||||
const result = await apiGet('endpoint');
|
||||
if (result.success) {
|
||||
data.value = result.data;
|
||||
} else {
|
||||
toast.add({
|
||||
severity: 'error',
|
||||
summary: 'Ошибка',
|
||||
detail: result.error
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error:', error);
|
||||
toast.add({
|
||||
severity: 'error',
|
||||
summary: 'Ошибка',
|
||||
detail: 'Не удалось загрузить данные'
|
||||
});
|
||||
}
|
||||
}
|
||||
</script>
|
||||
```
|
||||
|
||||
6
.github/workflows/main.yaml
vendored
6
.github/workflows/main.yaml
vendored
@@ -4,6 +4,7 @@ on:
|
||||
push:
|
||||
branches:
|
||||
- master
|
||||
- 'issue/**'
|
||||
- develop
|
||||
pull_request:
|
||||
types:
|
||||
@@ -134,11 +135,6 @@ jobs:
|
||||
MODULE_ROOT="module/oc_telegram_shop/upload/oc_telegram_shop"
|
||||
echo "${{ needs.version_meta.outputs.tag }}" > "${MODULE_ROOT}/version.txt"
|
||||
|
||||
- name: Set version in install.xml
|
||||
run: |
|
||||
TAG="${{ needs.version_meta.outputs.tag }}"
|
||||
sed -i "s#<version>.*</version>#<version>${TAG}</version>#" module/oc_telegram_shop/install.xml
|
||||
|
||||
- name: Build module
|
||||
run: |
|
||||
bash scripts/ci/build.sh "${GITHUB_WORKSPACE}"
|
||||
|
||||
37
CHANGELOG.md
37
CHANGELOG.md
@@ -4,43 +4,6 @@
|
||||
All notable changes to this project will be documented in this file.
|
||||
<!--- END HEADER -->
|
||||
|
||||
## [2.3.0](https://github.com/telecart-labs/telecart/compare/v2.2.2...v2.3.0) (2026-05-25)
|
||||
|
||||
### Features
|
||||
|
||||
|
||||
##### Admin
|
||||
|
||||
* Telegram settings improvements (#63) ([80c246](https://github.com/telecart-labs/telecart/commit/80c2467b4a7c2cb1c4101c483fe3ac31a5ad0cf1))
|
||||
|
||||
##### Ocmod
|
||||
|
||||
* Add TeleCart admin sidebar menu and local dev setup (#57) ([28c8ce](https://github.com/telecart-labs/telecart/commit/28c8ce3b6841ca1d4cc98c8e7bb275e1ad3adcf3))
|
||||
|
||||
##### Spa
|
||||
|
||||
* Increase max page limit to product lists (#88) ([a3f54a](https://github.com/telecart-labs/telecart/commit/a3f54a87dc8799b3355b294fc155c3cf9d22c3eb))
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
|
||||
##### Spa
|
||||
|
||||
* Handle empty category and products in carousel block ([d43564](https://github.com/telecart-labs/telecart/commit/d43564481d1e35ea8bb92310d20bff2989ca6ea7))
|
||||
* Visual fixes for SPA (#106) ([c24e30](https://github.com/telecart-labs/telecart/commit/c24e30f14cef8a4b6956aaa2ada5dc9b12fbb9cb))
|
||||
|
||||
|
||||
---
|
||||
|
||||
## [2.2.2](https://github.com/telecart-labs/telecart/compare/v2.2.1...v2.2.2) (2026-03-15)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* Normalize order response to valid UTF-8 before JSON encoding (#83) ([f894d3](https://github.com/telecart-labs/telecart/commit/f894d33c464e28a15cbdcd5c72dfd90bd5379e92))
|
||||
|
||||
|
||||
---
|
||||
|
||||
## [2.2.1](https://github.com/telecart-labs/telecart/compare/v2.2.0...v2.2.1) (2026-02-22)
|
||||
|
||||
|
||||
|
||||
147
CLAUDE.md
147
CLAUDE.md
@@ -1,147 +0,0 @@
|
||||
# CLAUDE.md
|
||||
|
||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||
|
||||
## Overview
|
||||
|
||||
TeleCart — коммерческий модуль для OpenCart, превращающий магазин в Telegram Mini App. Это монорепозиторий из трёх
|
||||
частей:
|
||||
|
||||
- **PHP-модуль OpenCart** — `module/oc_telegram_shop/upload/` (вся бизнес-логика и собственный фреймворк).
|
||||
- **SPA** — `frontend/spa/` (Telegram Mini App, витрина для покупателей).
|
||||
- **Admin** — `frontend/admin/` (панель управления настройками модуля).
|
||||
|
||||
`src/` в корне — это распакованная установка OpenCart (генерируется скриптами, **не редактировать**; код модуля
|
||||
«линкуется» в неё через `make link`).
|
||||
|
||||
## Документация по фичам (`docs/features/`)
|
||||
|
||||
Контекстная документация хранится в `docs/features/` — по одной поддиректории на фичу, внутри индексный
|
||||
`CLAUDE.md`. Вложенные `CLAUDE.md` Claude Code подхватывает автоматически при чтении файлов из их директории,
|
||||
поэтому индекс фичи читается «по требованию», когда работа касается этой фичи.
|
||||
|
||||
Перед тем как трогать незнакомую часть системы — загляни в её папку в `docs/features/`. Конвенции, шаблон новой
|
||||
фичи и **реестр всех фич с прямыми путями** — в [`docs/features/CLAUDE.md`](docs/features/CLAUDE.md). Шаблон для
|
||||
новой фичи — `docs/features/_TEMPLATE.md`. При изменении фичи поддерживай её доку в актуальном состоянии (код —
|
||||
источник истины; при расхождении правь доку).
|
||||
|
||||
## Где живёт код модуля
|
||||
|
||||
Сердце проекта — `module/oc_telegram_shop/upload/oc_telegram_shop/` (далее «корень модуля»). Его структура:
|
||||
|
||||
| Каталог | Namespace | Назначение |
|
||||
|------------------------|-------------------------------|-----------------------------------------------------------------------------------------------------------------------------|
|
||||
| `framework/` | `Openguru\OpenCartFramework\` | Собственный Laravel-подобный фреймворк: Container (DI), Router, QueryBuilder, Cache, Migrations, Telegram, Http, Translator |
|
||||
| `src/` | `App\` | API витрины/SPA (catalog). Handlers, Services, Models, DTO, Filters, ServiceProviders |
|
||||
| `bastion/` | `Bastion\` | API админ-панели. Handlers, Services, Tasks, ScheduledTasks |
|
||||
| `console/` | `Console\` | CLI-команды (Symfony Console), точка входа `upload/cli.php` |
|
||||
| `database/migrations/` | — | Миграции БД |
|
||||
| `configs/` | — | `app.php`, `maintenance.php`, `schedule.php` |
|
||||
| `tests/` | `Tests\` | `Unit/`, `Integration/`, `Telegram/` |
|
||||
|
||||
**Маршрутизация.** Запросы приходят через action-параметр и резолвятся по двум картам:
|
||||
|
||||
- `src/routes.php` — действия SPA (`products`, `getCart`, `checkout`, `webhook`, …)
|
||||
- `bastion/routes.php` — действия админки (`getSettingsForm`, `getTelegramCustomers`, `getDashboardStats`, …)
|
||||
|
||||
Каждая запись — `'actionName' => [HandlerClass::class, 'method']`.
|
||||
|
||||
**Точки входа OpenCart** (тонкие обёртки, грузят autoload и поднимают `App\ApplicationFactory`):
|
||||
|
||||
- `module/.../upload/catalog/controller/extension/tgshop/handle.php` — SPA API
|
||||
- `module/.../upload/admin/controller/extension/module/tgshop.php` — админка
|
||||
|
||||
В продакшене модуль собирается в `oc_telegram_shop.phar` и подключается из `system/library/oc_telegram_shop/`; в dev —
|
||||
из исходников через `vendor/autoload.php`.
|
||||
|
||||
## Архитектурные правила (бэкенд)
|
||||
|
||||
Паттерн — MVC-L поверх собственного фреймворка. Соблюдай строго:
|
||||
|
||||
- **Совместимость с PHP 7.4.** Минимальные требования OpenCart и модуля — PHP 7.4 (`composer.json`: `^7.4`). Не используй синтаксис и функции PHP 8.0+ (named arguments, `match`, constructor property promotion, `enum`, `readonly`, nullsafe `?->`, union types и т.п.).
|
||||
- **Dependency Injection через конструктор.** Никогда не создавай зависимости через `new` внутри классов — внедряй их.
|
||||
Регистрация сервисов идёт в Service Providers (`src/ServiceProviders/`) через `$this->app->singleton(...)`.
|
||||
- **Query Builder вместо сырого SQL.** Используй `$this->builder->newQuery()->from(...)->where(...)->get()`. Прямой
|
||||
SQL — только в крайних случаях; никогда не конкатенируй значения в запрос (SQL injection).
|
||||
- **Слои:** Handler (валидация + вызов сервиса + `JsonResponse`) → Service (бизнес-логика) → Model (доступ к данным). Не
|
||||
смешивай.
|
||||
- **Строгая типизация:** `declare(strict_types=1)`, типы аргументов и возвращаемых значений, nullable где нужно. PSR-12.
|
||||
- **Безопасный доступ к массивам:** `Arr::get($data, 'key', $default)` вместо `$data['key']`.
|
||||
- **Таблицы:** префикс OpenCart (`oc_`) + собственные таблицы модуля.
|
||||
|
||||
## Архитектура фронтенда
|
||||
|
||||
Оба приложения — Vue 3, **только `<script setup>` + Composition API**, Pinia (setup-stores), Vue Router.
|
||||
|
||||
- **SPA** (`frontend/spa/`): Tailwind CSS, FormKit, Swiper, `ofetch`. Сборка →
|
||||
`module/.../upload/image/catalog/tgshopspa/`. Тесты — Vitest.
|
||||
- **Admin** (`frontend/admin/`): PrimeVue (основной UI-кит), Tailwind/DaisyUI, axios, CodeMirror, `vuedraggable`.
|
||||
Сборка → `module/.../upload/admin/view/javascript/telecart/`.
|
||||
|
||||
Конвенции: компоненты — PascalCase, файлы логики — camelCase; обработчики событий с префиксом `handle`; `computed` для
|
||||
производных значений; `??` (не `||`) для дефолтов чисел/булевых; сложную логику выносить в composables. Ошибки API
|
||||
ловить и показывать через toast (admin: `useToast` из PrimeVue).
|
||||
|
||||
**FormBuilder** (admin) — отдельная подсистема визуального конструктора форм. Единственный источник истины — FormKit
|
||||
Schema JSON (без промежуточных DTO). Drag-and-drop строго на `vuedraggable` (не `@formkit/drag-and-drop`).
|
||||
Tailwind-классы там идут с префиксом `tw:`.
|
||||
|
||||
## Команды
|
||||
|
||||
Все `make`-цели выполняются **внутри Docker-контейнера `web`** — нужен запущенный `docker compose`.
|
||||
|
||||
**Старт / окружение:**
|
||||
|
||||
```bash
|
||||
make setup # первичная установка: скачать OpenCart, установить, поднять, link
|
||||
make start # поднять контейнеры
|
||||
make dev # link + параллельно vite dev для SPA и Admin
|
||||
make dev-spa # только SPA (vite)
|
||||
make dev-admin # только Admin (vite)
|
||||
make link # «слинковать» код модуля в установку OpenCart (после правок структуры)
|
||||
make ssh # шелл в контейнер в каталоге модуля
|
||||
```
|
||||
|
||||
**Тесты (PHP, PHPUnit в контейнере):**
|
||||
|
||||
```bash
|
||||
make test # все тесты (--testdox)
|
||||
make test-unit # tests/Unit
|
||||
make test-integration # tests/Integration
|
||||
make test-telegram # tests/Telegram
|
||||
make test-coverage # HTML-покрытие в coverage/
|
||||
```
|
||||
|
||||
Запуск одного теста — через ssh в контейнер:
|
||||
|
||||
```bash
|
||||
make ssh
|
||||
./vendor/bin/phpunit --filter testMethodName tests/Unit/SomeTest.php
|
||||
```
|
||||
|
||||
**Тесты фронтенда (SPA):** `cd frontend/spa && npm run test` (Vitest), `npm run test:run` для однократного прогона.
|
||||
|
||||
**Линтеры / статический анализ:**
|
||||
|
||||
```bash
|
||||
make lint # PHPStan (level 1) по src, bastion, framework, console
|
||||
make phpcs # PHP_CodeSniffer PSR-12 (проверка)
|
||||
make phpcbf # PHP_CodeSniffer PSR-12 (автофикс)
|
||||
```
|
||||
|
||||
Admin-фронтенд: `cd frontend/admin && npm run lint` (oxlint + eslint), `npm run format` (prettier).
|
||||
|
||||
**Сборка / релиз:**
|
||||
|
||||
```bash
|
||||
make phar # собрать oc_telegram_shop.phar (через docker/build.dockerfile)
|
||||
make ocmod # применить OCMOD-модификации
|
||||
make cli ARGS="..." # запустить console-команду модуля
|
||||
make changelog # сгенерировать CHANGELOG из conventional commits
|
||||
make release # changelog + commit релиза
|
||||
```
|
||||
|
||||
## Коммиты
|
||||
|
||||
Используются **Conventional Commits** (`feat:`, `fix:`, `chore:`, `build(deps):` …) — CHANGELOG и версии генерируются
|
||||
автоматически. **Не делать `git commit`/`git push` без явного указания пользователя.**
|
||||
3
Makefile
3
Makefile
@@ -45,9 +45,6 @@ dev-admin:
|
||||
rm -rf src/upload/admin/view/javascript/telecart && \
|
||||
cd frontend/admin && npm run dev
|
||||
|
||||
ocmod:
|
||||
./scripts/apply_ocmod.sh
|
||||
|
||||
lint:
|
||||
docker compose exec -w /module/oc_telegram_shop/upload/oc_telegram_shop web bash -c "./vendor/bin/phpstan analyse"
|
||||
|
||||
|
||||
@@ -1,43 +0,0 @@
|
||||
# docs/features — документация по фичам
|
||||
|
||||
Эта директория хранит описания **фич** проекта TeleCart: по одной поддиректории на фичу.
|
||||
Цель — дать агенту (и человеку) возможность быстро восстановить контекст по конкретной
|
||||
части системы, не перечитывая весь код: что фича делает, через какие точки входит, какие
|
||||
файлы за неё отвечают и как данные текут по слоям.
|
||||
|
||||
Вложенные `CLAUDE.md` подхватываются Claude Code автоматически при чтении файлов из их
|
||||
директории — поэтому индекс фичи читается «по требованию», когда работа касается этой фичи.
|
||||
|
||||
## Структура
|
||||
|
||||
```
|
||||
docs/features/
|
||||
├── CLAUDE.md # этот файл: конвенции + реестр фич
|
||||
├── _TEMPLATE.md # шаблон для новой фичи (копировать при описании)
|
||||
└── <feature-slug>/
|
||||
└── CLAUDE.md # индексный файл конкретной фичи
|
||||
```
|
||||
|
||||
## Конвенции
|
||||
|
||||
- **Имя папки** — `kebab-case`, по сути фичи: `cart`, `telegram-webhook`, `form-builder`.
|
||||
- **Один индекс на фичу** — `<feature-slug>/CLAUDE.md`, заполняется по `_TEMPLATE.md`.
|
||||
- **Ссылки на код — обязательны.** Указывай реальные пути (`module/.../src/Handlers/CartHandler.php`)
|
||||
и `action` из карт маршрутов. Код — источник истины; документация лишь навигирует к нему.
|
||||
- **Не дублируй код.** Описывай назначение, поток и нюансы, а не построчный пересказ — он устареет.
|
||||
- **Связывай фичи** через ссылки на их папки (например `[checkout](../checkout/CLAUDE.md)`).
|
||||
- Если описанное в доке расходится с кодом — прав код; обнови доку.
|
||||
|
||||
## Как добавить фичу
|
||||
|
||||
1. `cp _TEMPLATE.md <feature-slug>/CLAUDE.md` (создав папку).
|
||||
2. Заполни секции шаблона, опираясь на реальный код.
|
||||
3. Добавь строку в реестр ниже.
|
||||
|
||||
## Реестр фич
|
||||
|
||||
| Фича | Путь | Краткое описание |
|
||||
|------|----------------------------------|-------------------------------------------------------------|
|
||||
| cart | [cart/CLAUDE.md](cart/CLAUDE.md) | Корзина: наполнение, просмотр, передача в оформление заказа |
|
||||
|
||||
<!-- Новые фичи добавляй строкой в таблицу выше, по алфавиту. -->
|
||||
@@ -1,38 +0,0 @@
|
||||
# <Название фичи>
|
||||
|
||||
> Скопируй этот файл в `docs/features/<feature-slug>/CLAUDE.md` и заполни секции.
|
||||
> Удали подсказки в кавычках после заполнения. Не оставляй пустых «TODO».
|
||||
|
||||
## Назначение
|
||||
|
||||
> 1–3 предложения: какую задачу решает фича и для кого (SPA-покупатель / админ / системное).
|
||||
|
||||
## Точки входа
|
||||
|
||||
> `action` из карт маршрутов и где они объявлены. Таблица:
|
||||
|
||||
| action | Метод | Где объявлен |
|
||||
|-----------------|-------------------|-------------------------------------------|
|
||||
| `exampleAction` | `Handler::method` | `src/routes.php` или `bastion/routes.php` |
|
||||
|
||||
## Ключевые файлы по слоям
|
||||
|
||||
> Путь от корня репозитория. Только то, что реально относится к фиче.
|
||||
|
||||
- **Handler:** `module/.../src/Handlers/XxxHandler.php` — валидация, вызов сервиса, `JsonResponse`.
|
||||
- **Service:** `module/.../src/Services/XxxService.php` — бизнес-логика.
|
||||
- **Model / данные:** `module/.../src/Models/Xxx.php` или таблицы БД.
|
||||
- **Фронтенд:** `frontend/spa/...` или `frontend/admin/...` (store, view, компоненты).
|
||||
- **Регистрация в DI:** `src/ServiceProviders/...` (если есть нестандартная привязка).
|
||||
|
||||
## Поток данных
|
||||
|
||||
> Кратко: запрос → слои → ответ. Можно списком шагов или стрелками.
|
||||
|
||||
## Связанные фичи
|
||||
|
||||
> Ссылки на соседние папки: `[checkout](../checkout/CLAUDE.md)`.
|
||||
|
||||
## Нюансы и подводные камни
|
||||
|
||||
> Неочевидное: ограничения PHP 7.4, особенности OpenCart-реестра, кэш, edge-cases, известные баги.
|
||||
@@ -1,59 +0,0 @@
|
||||
# Cart — корзина
|
||||
|
||||
## Назначение
|
||||
|
||||
Корзина покупателя в SPA (Telegram Mini App): отдаёт текущее содержимое корзины с
|
||||
посчитанными ценами/итогами, принимает набор позиций из SPA и наполняет ими корзину
|
||||
OpenCart перед оформлением заказа. Создание самого заказа — за пределами фичи (см.
|
||||
[order](../order/CLAUDE.md), если описан).
|
||||
|
||||
## Точки входа
|
||||
|
||||
| action | Метод | Где объявлен |
|
||||
|------------|-------------------------|------------------|
|
||||
| `getCart` | `CartHandler::index` | `src/routes.php` |
|
||||
| `checkout` | `CartHandler::checkout` | `src/routes.php` |
|
||||
|
||||
> `storeOrder` (`OrderHandler::store`) относится к оформлению заказа, не к корзине.
|
||||
|
||||
## Ключевые файлы по слоям
|
||||
|
||||
- **Handler:** `module/oc_telegram_shop/upload/oc_telegram_shop/src/Handlers/CartHandler.php`
|
||||
— `index()` возвращает содержимое корзины, `checkout()` принимает позиции из SPA
|
||||
(`productId`, `quantity`, `options`) и кладёт их в корзину через `Cart\Cart::add()`.
|
||||
- **Service:** `module/oc_telegram_shop/upload/oc_telegram_shop/src/Services/CartService.php`
|
||||
— `getCart()` собирает данные через OpenCart-реестр (`OcRegistryDecorator`): язык
|
||||
`checkout/cart`, проверки стока, веса, цен, форматирование через `Cart\Currency`.
|
||||
- **OpenCart-зависимости:** `Cart\Cart`, `Cart\Currency` — нативные классы OpenCart,
|
||||
внедряются через DI.
|
||||
- **Фронтенд (SPA):**
|
||||
- `frontend/spa/src/stores/CartStore.js` — Pinia-стор корзины.
|
||||
- `frontend/spa/src/ShoppingCart.js` — клиентская логика корзины.
|
||||
- `frontend/spa/src/views/Cart.vue` — экран корзины.
|
||||
- `frontend/spa/src/components/CartButton.vue` — кнопка/индикатор.
|
||||
|
||||
## Поток данных
|
||||
|
||||
**Просмотр:** SPA → `action=getCart` → `CartHandler::index` → `CartService::getCart()`
|
||||
читает корзину из OpenCart-реестра → ответ оборачивается `Utf8Cleaner::clean()` →
|
||||
`JsonResponse { data }`.
|
||||
|
||||
**Передача в оформление:** SPA отправляет массив позиций → `action=checkout` →
|
||||
`CartHandler::checkout` маппит `options` (берёт `product_option_value_id`) и для каждой
|
||||
позиции вызывает `$cart->add(productId, quantity, options)` → возвращает очищенные позиции.
|
||||
|
||||
## Связанные фичи
|
||||
|
||||
- Оформление заказа (`OrderHandler::store` / `OrderCreateService`).
|
||||
- Настройки (`SettingsService`) влияют на отображение цен/стока в `getCart`.
|
||||
|
||||
## Нюансы и подводные камни
|
||||
|
||||
- **PHP 7.4.** Без синтаксиса 8.0+ (см. корневой `CLAUDE.md`).
|
||||
- **Весь вывод чистится `App\Support\Utf8Cleaner::clean()`** перед отдачей — невалидный
|
||||
UTF-8 из товаров OpenCart ломает `json_encode`.
|
||||
- **`getCart` зависит от состояния OpenCart-сессии и реестра** (сток, валюта, логин
|
||||
покупателя, язык `checkout/cart`) — это не чистая функция, результат меняется от конфига
|
||||
магазина (`config_stock_checkout`, `config_customer_price`, `config_cart_weight` и т.д.).
|
||||
- **`checkout` мутирует корзину OpenCart** (`Cart\Cart::add`), а не локальное состояние —
|
||||
он переносит выбор из SPA в нативную корзину OpenCart.
|
||||
@@ -1,168 +0,0 @@
|
||||
# Дизайн: честный и отключаемый heartbeat
|
||||
|
||||
Дата: 2026-06-30
|
||||
Статус: согласовано (ожидает финального ревью)
|
||||
|
||||
## Проблема
|
||||
|
||||
Модуль скрытно отправляет на сервер разработчика (`pulse.telecart.pro`) сведения об
|
||||
окружении, где он запущен (heartbeat). Покупатель об этом не уведомляется и не может
|
||||
это отключить. Это нечестно по отношению к покупателю и создаёт риски (GDPR / 152-ФЗ,
|
||||
претензии маркетплейсов).
|
||||
|
||||
## Цель
|
||||
|
||||
Сделать heartbeat **честным**: прозрачным и отключаемым, сохранив поведение по умолчанию.
|
||||
|
||||
- **Прозрачность:** в админ-панели покупатель видит, что именно собирается, куда
|
||||
отправляется и зачем.
|
||||
- **Контроль:** отдельный переключатель, которым покупатель может выключить heartbeat.
|
||||
- **По умолчанию включено** (текущее поведение сохраняется, но становится видимым и
|
||||
управляемым).
|
||||
|
||||
## Границы (scope)
|
||||
|
||||
- **Только heartbeat.** Остальной TeleCart Pulse (Telegram-рассылки, события кампаний)
|
||||
**не трогаем** — он гейтится наличием `pulse.api_key` и, по позиции владельца, прав не
|
||||
нарушает.
|
||||
- Концептуально heartbeat считаем частью фичи **TeleCart Pulse** (одна папка доков), но
|
||||
у него **отдельный собственный выключатель**, не завязанный на `pulse.api_key` и не
|
||||
завязанный на секрет подписи. Pulse и heartbeat включаются/выключаются независимо.
|
||||
|
||||
## Терминология
|
||||
|
||||
- **heartbeat** — периодическая (раз в час по кэшу) отправка информации об окружении:
|
||||
`domain`, `bot_name`, `php_version`, `module_version`, `opencart_version`,
|
||||
`opencart_version_core`. Триггерится из SPA при открытии WebApp.
|
||||
- **TeleCart Pulse** — фича рассылок и аналитики кампаний; heartbeat живёт внутри её
|
||||
namespace в коде (`framework/TeleCartPulse/`).
|
||||
|
||||
## Текущее устройство (как есть)
|
||||
|
||||
- `TeleCartPulseService::handleHeartbeat()`
|
||||
(`module/.../framework/TeleCartPulse/TeleCartPulseService.php:133`) — ставит кэш-флаг
|
||||
`telecart_pulse_heartbeat` (ttl 3600), дёргает `getMe()`, формирует payload и шлёт POST
|
||||
на `PULSE_API_HOST/heartbeat`.
|
||||
- Отправка фактически происходит только если есть `heartbeatPayloadSigner` (строки 168–170),
|
||||
который создаётся в провайдере при наличии секрета (`pulse.heartbeat_secret` /
|
||||
`PULSE_HEARTBEAT_SECRET`).
|
||||
- `TeleCartPulseServiceProvider`
|
||||
(`module/.../framework/TeleCartPulse/TeleCartPulseServiceProvider.php:22-35`) читает
|
||||
значения из конфига и инжектит их в `TeleCartPulseService` через конструктор.
|
||||
- **Пользовательской (per-install) настройки для heartbeat сейчас нет.**
|
||||
|
||||
## Решение
|
||||
|
||||
### Принцип
|
||||
|
||||
Использовать существующий паттерн: провайдер читает значение из конфига и **инжектит**
|
||||
его в сервис через конструктор (как уже делается для `pulse.api_key` и signer). Не вводить
|
||||
новых механизмов гейтинга.
|
||||
|
||||
### 1. Backend
|
||||
|
||||
**`module/.../configs/app.php`** — секция `pulse` (строки 86–90): добавить флаг с дефолтом
|
||||
`true`.
|
||||
|
||||
```php
|
||||
'pulse' => [
|
||||
'api_key' => '',
|
||||
'heartbeat_enabled' => true, // новый флаг
|
||||
'batch_size' => 50,
|
||||
'max_attempts' => 3,
|
||||
],
|
||||
```
|
||||
|
||||
**`TeleCartPulseServiceProvider`** — прочитать `pulse.heartbeat_enabled` и передать булевый
|
||||
флаг в конструктор `TeleCartPulseService` (рядом с `apiKey` и `heartbeatSigner`). Привести
|
||||
к `bool` явно.
|
||||
|
||||
**`TeleCartPulseService`** — принять флаг в конструкторе (новое поле `private bool
|
||||
$heartbeatEnabled`). В `handleHeartbeat()` — **ранний `return` в самом начале метода**, до
|
||||
проверки/установки кэша и до `getMe()`, если флаг выключен:
|
||||
|
||||
```php
|
||||
public function handleHeartbeat(): void
|
||||
{
|
||||
if (! $this->heartbeatEnabled) {
|
||||
return;
|
||||
}
|
||||
// ... существующая логика
|
||||
}
|
||||
```
|
||||
|
||||
Совместимость с PHP 7.4: без named arguments / промоушена свойств / union-типов. Параметр
|
||||
конструктора — позиционный, со значением по умолчанию `true` для обратной совместимости
|
||||
вызовов в тестах.
|
||||
|
||||
### 2. Frontend (админка) + прозрачность
|
||||
|
||||
**`frontend/admin/src/stores/settings.js`** — секция `pulse` (строки 89–92): добавить
|
||||
`heartbeat_enabled` в дефолтное состояние state.
|
||||
|
||||
**`frontend/admin/src/views/TeleCartPulseView.vue`** — добавить `ItemBool` с переключателем,
|
||||
привязанным к `settings.items.pulse.heartbeat_enabled`. В слот-описание — **честное, но
|
||||
нейтральное по тону раскрытие**:
|
||||
|
||||
- перечислить, что отправляется: домен, имя бота, версия PHP, версия модуля, версии
|
||||
OpenCart;
|
||||
- указать адрес назначения: `pulse.telecart.pro`;
|
||||
- описать цель мягко: для улучшения продукта и техподдержки (без акцента на проверке
|
||||
лицензии).
|
||||
|
||||
Точную формулировку владелец отредактирует; черновик в нейтральном тоне готовит реализация.
|
||||
|
||||
### 3. Поведение для существующих установок
|
||||
|
||||
Дефолт `true` в конфиге мержится с сохранёнными настройками
|
||||
(`ApplicationFactory`: `Arr::mergeArraysRecursively`). У установок без сохранённого значения
|
||||
heartbeat остаётся включённым — поведение не меняется, но теперь переключатель виден.
|
||||
Когда покупатель выключает тумблер и сохраняет, значение пишется в `oc_setting`
|
||||
(`module_telecart_settings`) и читается на backend.
|
||||
|
||||
### 4. Гейтинг — итоговая логика
|
||||
|
||||
heartbeat реально отправляется только если **одновременно**: `heartbeat_enabled = true`
|
||||
**и** есть signer (секрет настроен). Переключатель — пользовательский контроль поверх
|
||||
существующего серверного гейта; они независимы.
|
||||
|
||||
## Тестирование
|
||||
|
||||
Unit-тест на `TeleCartPulseService::handleHeartbeat()`:
|
||||
|
||||
- `heartbeat_enabled = false` → метод выходит сразу: HTTP-запрос не выполняется, кэш-флаг
|
||||
`telecart_pulse_heartbeat` **не** ставится, `getMe()` не вызывается.
|
||||
- `heartbeat_enabled = true` + signer присутствует → payload формируется и уходит (как
|
||||
сейчас).
|
||||
|
||||
Запуск: `make test-unit` (или `./vendor/bin/phpunit --filter ...` через `make ssh`).
|
||||
|
||||
## Документация (в этом порядке)
|
||||
|
||||
1. **`docs/features/settings/CLAUDE.md`** — каркас системы настроек: путь поля «форма в
|
||||
админке → `saveSettingsForm` → таблица `oc_setting` (`module_telecart_settings`) →
|
||||
`Config\Settings` / DTO → backend», и **чек-лист «как добавить новый toggle»** по слоям
|
||||
(config-дефолт → провайдер/DTO → store → view-компонент `ItemBool`). По шаблону
|
||||
`docs/features/_TEMPLATE.md`. Добавить строку в реестр в `docs/features/CLAUDE.md`.
|
||||
2. **`docs/features/telecart-pulse/CLAUDE.md`** — фича Pulse целиком: рассылки, события
|
||||
кампаний (гейт `api_key`) и heartbeat с новым выключателем. Ссылка на `[[settings]]`.
|
||||
Добавить строку в реестр.
|
||||
|
||||
## Затрагиваемые файлы (сводка)
|
||||
|
||||
- `module/.../configs/app.php` — флаг `pulse.heartbeat_enabled`.
|
||||
- `module/.../framework/TeleCartPulse/TeleCartPulseServiceProvider.php` — чтение и инжект.
|
||||
- `module/.../framework/TeleCartPulse/TeleCartPulseService.php` — поле + ранний return.
|
||||
- `frontend/admin/src/stores/settings.js` — дефолт в state.
|
||||
- `frontend/admin/src/views/TeleCartPulseView.vue` — `ItemBool` + текст раскрытия.
|
||||
- `module/.../tests/Unit/...` — тест на гейтинг heartbeat.
|
||||
- `docs/features/settings/CLAUDE.md`, `docs/features/telecart-pulse/CLAUDE.md`,
|
||||
`docs/features/CLAUDE.md` (реестр).
|
||||
|
||||
## Явно вне scope (YAGNI)
|
||||
|
||||
- Не делаем opt-in (по умолчанию остаётся включено).
|
||||
- Не делаем отдельный consent-баннер на первой установке (достаточно видимого
|
||||
переключателя с раскрытием).
|
||||
- Не трогаем события кампаний и рассылки Pulse.
|
||||
- Не выносим heartbeat в отдельный namespace/фичу из `TeleCartPulse`.
|
||||
1896
frontend/admin/package-lock.json
generated
1896
frontend/admin/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -25,7 +25,7 @@
|
||||
"@primeuix/themes": "^1.2.5",
|
||||
"@tailwindcss/vite": "^4.1.16",
|
||||
"@vueuse/core": "^14.0.0",
|
||||
"axios": "^1.15.2",
|
||||
"axios": "^1.13.5",
|
||||
"codemirror": "^6.0.2",
|
||||
"daisyui": "^5.4.2",
|
||||
"js-md5": "^0.8.3",
|
||||
@@ -51,7 +51,7 @@
|
||||
"npm-run-all2": "^8.0.4",
|
||||
"oxlint": "~1.23.0",
|
||||
"prettier": "3.6.2",
|
||||
"vite": "^7.3.3",
|
||||
"vite": "^7.1.11",
|
||||
"vite-plugin-vue-devtools": "^8.0.3"
|
||||
}
|
||||
}
|
||||
|
||||
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: 'Выключено'},
|
||||
];
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<div id="app-error"></div>
|
||||
<script src="https://telegram.org/js/telegram-web-app.js?61"></script>
|
||||
<script src="https://telegram.org/js/telegram-web-app.js?59"></script>
|
||||
<script type="module" src="/src/main.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
1786
frontend/spa/package-lock.json
generated
1786
frontend/spa/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -28,19 +28,18 @@
|
||||
"vue-router": "^4.6.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/typography": "^0.5.19",
|
||||
"@testing-library/jest-dom": "^6.9.1",
|
||||
"@testing-library/vue": "^8.1.0",
|
||||
"@vitejs/plugin-vue": "^6.0.1",
|
||||
"@vitest/ui": "^4.1.9",
|
||||
"@vitest/ui": "^4.0.8",
|
||||
"@vue/test-utils": "^2.4.6",
|
||||
"autoprefixer": "^10.4.21",
|
||||
"daisyui": "^5.3.10",
|
||||
"jsdom": "^27.1.0",
|
||||
"postcss": "^8.5.15",
|
||||
"postcss": "^8.5.6",
|
||||
"tailwindcss": "^4.1.16",
|
||||
"terser": "^5.44.0",
|
||||
"vite": "^7.3.3",
|
||||
"vitest": "^4.1.9"
|
||||
"vite": "^7.1.12",
|
||||
"vitest": "^4.0.8"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,16 +2,16 @@
|
||||
<a
|
||||
href="#"
|
||||
:key="category.id"
|
||||
class="flex items-center gap-3"
|
||||
class="flex items-center"
|
||||
@click.prevent="$emit('onSelect', category)"
|
||||
>
|
||||
<div class="avatar shrink-0">
|
||||
<div class="w-10 h-10 rounded">
|
||||
<img :src="category.image" :alt="category.name" loading="lazy" width="40" height="40" class="bg-base-400"/>
|
||||
<div class="avatar">
|
||||
<div class="w-8 h-8 rounded">
|
||||
<img :src="category.image" :alt="category.name" loading="lazy" width="30" height="30" class="bg-base-400"/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h3 class="text-base font-medium leading-snug line-clamp-2">{{ category.name }}</h3>
|
||||
<h3 class="ml-4 text-lg line-clamp-2">{{ category.name }}</h3>
|
||||
</a>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
<template>
|
||||
<section class="px-4">
|
||||
<header class="flex justify-between items-end mb-4 gap-2">
|
||||
<div class="min-w-0">
|
||||
<header class="flex justify-between items-end mb-4">
|
||||
<div>
|
||||
<div v-if="title" class="font-bold uppercase">{{ title }}</div>
|
||||
<div v-if="description" class="text-sm text-base-content/50">{{ description }}</div>
|
||||
</div>
|
||||
|
||||
<div v-if="moreLink" class="shrink-0">
|
||||
<RouterLink :to="moreLink" class="btn btn-soft btn-xs whitespace-nowrap" @click="haptic.selectionChanged">
|
||||
<div v-if="moreLink">
|
||||
<RouterLink :to="moreLink" class="btn btn-soft btn-xs" @click="haptic.selectionChanged">
|
||||
{{ moreText || 'Смотреть всё' }}
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-4">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="m8.25 4.5 7.5 7.5-7.5 7.5" />
|
||||
|
||||
@@ -2,23 +2,10 @@
|
||||
<BaseBlock
|
||||
:title="block.title"
|
||||
:description="block.description"
|
||||
:moreLink="hasCategory ? {name: 'product.categories.show', params: { category_id: block.data.category_id }} : null"
|
||||
:moreLink="{name: 'product.categories.show', params: { category_id: block.data.category_id }}"
|
||||
:moreText="block.data.all_text"
|
||||
>
|
||||
<div v-if="!hasCategory" role="alert" class="alert alert-warning alert-soft">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6 shrink-0 stroke-current" fill="none" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
|
||||
</svg>
|
||||
<span>Для блока не выбрана категория.</span>
|
||||
</div>
|
||||
<div v-else-if="!hasProducts" role="alert" class="alert alert-soft">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6 shrink-0 stroke-current" fill="none" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
<span>В этой категории пока нет товаров.</span>
|
||||
</div>
|
||||
<Swiper
|
||||
v-else
|
||||
class="select-none block-products-carousel"
|
||||
:slides-per-view="block.data?.carousel?.slides_per_view || 2.5"
|
||||
:space-between="block.data?.carousel?.space_between || 20"
|
||||
@@ -49,7 +36,6 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import {computed} from "vue";
|
||||
import {useYaMetrikaStore} from "@/stores/yaMetrikaStore.js";
|
||||
import {Swiper, SwiperSlide} from "swiper/vue";
|
||||
import {useHapticScroll} from "@/composables/useHapticScroll.js";
|
||||
@@ -66,9 +52,6 @@ const props = defineProps({
|
||||
}
|
||||
});
|
||||
|
||||
const hasCategory = computed(() => Boolean(props.block.data?.category_id));
|
||||
const hasProducts = computed(() => (props.block.data?.products?.data?.length ?? 0) > 0);
|
||||
|
||||
const freeModeSettings = {
|
||||
enabled: props.block.data?.carousel?.freemode?.enabled || false,
|
||||
};
|
||||
|
||||
@@ -2,19 +2,10 @@
|
||||
|
||||
@custom-variant dark (&:where(.dark, .dark *));
|
||||
|
||||
@plugin "@tailwindcss/typography";
|
||||
|
||||
@plugin "daisyui" {
|
||||
themes: all;
|
||||
}
|
||||
|
||||
.prose :where(table):not(:where([class~="not-prose"], [class~="not-prose"] *)) {
|
||||
display: block;
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
/**
|
||||
--color-base-100 - DaisyUI background
|
||||
*/
|
||||
|
||||
@@ -9,22 +9,31 @@
|
||||
</div>
|
||||
|
||||
<template v-else>
|
||||
<button v-if="parentId && parentCategory" class="py-1 px-4 flex items-center mb-3 cursor-pointer" @click="goBack">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-6 min-w-6">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M10.5 19.5 3 12m0 0 7.5-7.5M3 12h18" />
|
||||
</svg>
|
||||
|
||||
<span class="ml-2 line-clamp-2">Назад к "{{ parentCategory.name }}"</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
v-if="parentCategory && parentCategory.children?.length && settings.show_category_products_button"
|
||||
class="btn btn-soft btn-block btn-sm mb-3 justify-start"
|
||||
class="py-2 px-4 flex items-center mb-3 cursor-pointer border-b w-full pb-2 border-base-200"
|
||||
@click.prevent="showProductsInParentCategory"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-5 shrink-0">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-6">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M3.75 6A2.25 2.25 0 0 1 6 3.75h2.25A2.25 2.25 0 0 1 10.5 6v2.25a2.25 2.25 0 0 1-2.25 2.25H6a2.25 2.25 0 0 1-2.25-2.25V6ZM3.75 15.75A2.25 2.25 0 0 1 6 13.5h2.25a2.25 2.25 0 0 1 2.25 2.25V18a2.25 2.25 0 0 1-2.25 2.25H6A2.25 2.25 0 0 1 3.75 18v-2.25ZM13.5 6a2.25 2.25 0 0 1 2.25-2.25H18A2.25 2.25 0 0 1 20.25 6v2.25A2.25 2.25 0 0 1 18 10.5h-2.25a2.25 2.25 0 0 1-2.25-2.25V6ZM13.5 15.75a2.25 2.25 0 0 1 2.25-2.25H18a2.25 2.25 0 0 1 2.25 2.25V18A2.25 2.25 0 0 1 18 20.25h-2.25A2.25 2.25 0 0 1 13.5 18v-2.25Z" />
|
||||
</svg>
|
||||
<span>Показать все товары категории</span>
|
||||
|
||||
<span class="ml-2">Показать товары из "{{ parentCategory.name }}"</span>
|
||||
</button>
|
||||
|
||||
<TransitionGroup
|
||||
name="stagger"
|
||||
tag="ul"
|
||||
appear
|
||||
class="space-y-3"
|
||||
class="space-y-4"
|
||||
>
|
||||
<li
|
||||
v-for="(category, i) in categories"
|
||||
@@ -90,6 +99,10 @@ function onSelect(category) {
|
||||
}
|
||||
}
|
||||
|
||||
function goBack() {
|
||||
router.back();
|
||||
}
|
||||
|
||||
function showProductsInParentCategory() {
|
||||
if (parentId.value != null) {
|
||||
router.push({name: "product.categories.show", params: {category_id: parentId.value}});
|
||||
|
||||
@@ -87,11 +87,7 @@
|
||||
</svg>
|
||||
Описание
|
||||
</h3>
|
||||
<div
|
||||
class="prose prose-sm max-w-none text-base-content/80"
|
||||
v-html="product.description"
|
||||
@click="handleDescriptionClick"
|
||||
></div>
|
||||
<div class="prose prose-sm max-w-none text-base-content/80" v-html="product.description"></div>
|
||||
</div>
|
||||
|
||||
<!-- Attributes -->
|
||||
@@ -310,29 +306,6 @@ async function onCartBtnClick() {
|
||||
}
|
||||
}
|
||||
|
||||
function handleDescriptionClick(event) {
|
||||
const link = event.target.closest('a');
|
||||
if (!link) return;
|
||||
|
||||
const href = link.getAttribute('href');
|
||||
if (!href || href.startsWith('#') || href.startsWith('javascript:')) return;
|
||||
|
||||
event.preventDefault();
|
||||
|
||||
const telegramWebApp = window.Telegram?.WebApp;
|
||||
if (!telegramWebApp) {
|
||||
window.open(href, '_blank', 'noopener,noreferrer');
|
||||
return;
|
||||
}
|
||||
|
||||
if (/^https?:\/\/(t|telegram)\.me\//i.test(href) || href.startsWith('tg://')) {
|
||||
telegramWebApp.openTelegramLink(href);
|
||||
return;
|
||||
}
|
||||
|
||||
telegramWebApp.openLink(href, {try_instant_view: false});
|
||||
}
|
||||
|
||||
function openProductInMarketplace() {
|
||||
if (!product.value.share) {
|
||||
return;
|
||||
|
||||
@@ -1,30 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<modification>
|
||||
<name>TeleCart</name>
|
||||
<code>telecart</code>
|
||||
<version>1.0</version>
|
||||
<author>TeleCart</author>
|
||||
<link>https://docs.telecart.pro/</link>
|
||||
<file path="admin/controller/common/column_left.php">
|
||||
<operation>
|
||||
<search><![CDATA[// System]]></search>
|
||||
<add position="before"><![CDATA[ // BEGIN TeleCart modification
|
||||
$telecart = array();
|
||||
$telecart[] = array(
|
||||
'name' => 'Настройки',
|
||||
'href' => $this->url->link('extension/module/tgshop', 'user_token=' . $this->session->data['user_token'], true),
|
||||
'children' => array()
|
||||
);
|
||||
$data['menus'][] = array(
|
||||
'id' => 'menu-telecart',
|
||||
'icon' => 'fa-telegram',
|
||||
'name' => 'TeleCart',
|
||||
'href' => '',
|
||||
'children' => $telecart
|
||||
);
|
||||
// END TeleCart modification
|
||||
|
||||
]]></add>
|
||||
</operation>
|
||||
</file>
|
||||
</modification>
|
||||
@@ -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));
|
||||
|
||||
@@ -6,3 +6,7 @@ TELECART_CACHE_DRIVER=redis
|
||||
#TELECART_REDIS_HOST=redis
|
||||
#TELECART_REDIS_PORT=6379
|
||||
#TELECART_REDIS_DATABASE=0
|
||||
|
||||
SENTRY_ENABLED=false
|
||||
SENTRY_DSN=
|
||||
SENTRY_ENABLE_LOGS=false
|
||||
|
||||
@@ -5,3 +5,7 @@ TELECART_CACHE_DRIVER=mysql
|
||||
TELECART_REDIS_HOST=redis
|
||||
TELECART_REDIS_PORT=6379
|
||||
TELECART_REDIS_DATABASE=0
|
||||
|
||||
SENTRY_ENABLED=false
|
||||
SENTRY_DSN=
|
||||
SENTRY_ENABLE_LOGS=false
|
||||
|
||||
@@ -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'],
|
||||
|
||||
@@ -36,7 +36,8 @@
|
||||
"ramsey/uuid": "^4.2",
|
||||
"symfony/http-foundation": "^5.4",
|
||||
"symfony/console": "^5.4",
|
||||
"dragonmantank/cron-expression": "^3.5"
|
||||
"dragonmantank/cron-expression": "^3.5",
|
||||
"sentry/sentry": "^4.19"
|
||||
},
|
||||
"require-dev": {
|
||||
"doctrine/sql-formatter": "^1.3",
|
||||
|
||||
@@ -137,16 +137,16 @@
|
||||
},
|
||||
{
|
||||
"name": "doctrine/dbal",
|
||||
"version": "3.10.5",
|
||||
"version": "3.10.4",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/doctrine/dbal.git",
|
||||
"reference": "95d84866bf3c04b2ddca1df7c049714660959aef"
|
||||
"reference": "63a46cb5aa6f60991186cc98c1d1b50c09311868"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/doctrine/dbal/zipball/95d84866bf3c04b2ddca1df7c049714660959aef",
|
||||
"reference": "95d84866bf3c04b2ddca1df7c049714660959aef",
|
||||
"url": "https://api.github.com/repos/doctrine/dbal/zipball/63a46cb5aa6f60991186cc98c1d1b50c09311868",
|
||||
"reference": "63a46cb5aa6f60991186cc98c1d1b50c09311868",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
@@ -167,9 +167,9 @@
|
||||
"jetbrains/phpstorm-stubs": "2023.1",
|
||||
"phpstan/phpstan": "2.1.30",
|
||||
"phpstan/phpstan-strict-rules": "^2",
|
||||
"phpunit/phpunit": "9.6.34",
|
||||
"slevomat/coding-standard": "8.27.1",
|
||||
"squizlabs/php_codesniffer": "4.0.1",
|
||||
"phpunit/phpunit": "9.6.29",
|
||||
"slevomat/coding-standard": "8.24.0",
|
||||
"squizlabs/php_codesniffer": "4.0.0",
|
||||
"symfony/cache": "^5.4|^6.0|^7.0|^8.0",
|
||||
"symfony/console": "^4.4|^5.4|^6.0|^7.0|^8.0"
|
||||
},
|
||||
@@ -231,7 +231,7 @@
|
||||
],
|
||||
"support": {
|
||||
"issues": "https://github.com/doctrine/dbal/issues",
|
||||
"source": "https://github.com/doctrine/dbal/tree/3.10.5"
|
||||
"source": "https://github.com/doctrine/dbal/tree/3.10.4"
|
||||
},
|
||||
"funding": [
|
||||
{
|
||||
@@ -247,7 +247,7 @@
|
||||
"type": "tidelift"
|
||||
}
|
||||
],
|
||||
"time": "2026-02-24T08:03:57+00:00"
|
||||
"time": "2025-11-29T10:46:08+00:00"
|
||||
},
|
||||
{
|
||||
"name": "doctrine/deprecations",
|
||||
@@ -517,26 +517,25 @@
|
||||
},
|
||||
{
|
||||
"name": "guzzlehttp/guzzle",
|
||||
"version": "7.13.1",
|
||||
"version": "7.10.0",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/guzzle/guzzle.git",
|
||||
"reference": "55901a76dfd2006a0cc012b9e3c5b487f796478d"
|
||||
"reference": "b51ac707cfa420b7bfd4e4d5e510ba8008e822b4"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/guzzle/guzzle/zipball/55901a76dfd2006a0cc012b9e3c5b487f796478d",
|
||||
"reference": "55901a76dfd2006a0cc012b9e3c5b487f796478d",
|
||||
"url": "https://api.github.com/repos/guzzle/guzzle/zipball/b51ac707cfa420b7bfd4e4d5e510ba8008e822b4",
|
||||
"reference": "b51ac707cfa420b7bfd4e4d5e510ba8008e822b4",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"ext-json": "*",
|
||||
"guzzlehttp/promises": "^2.5",
|
||||
"guzzlehttp/psr7": "^2.12.3",
|
||||
"guzzlehttp/promises": "^2.3",
|
||||
"guzzlehttp/psr7": "^2.8",
|
||||
"php": "^7.2.5 || ^8.0",
|
||||
"psr/http-client": "^1.0",
|
||||
"symfony/deprecation-contracts": "^2.5 || ^3.0",
|
||||
"symfony/polyfill-php80": "^1.25"
|
||||
"symfony/deprecation-contracts": "^2.2 || ^3.0"
|
||||
},
|
||||
"provide": {
|
||||
"psr/http-client-implementation": "1.0"
|
||||
@@ -545,9 +544,8 @@
|
||||
"bamarni/composer-bin-plugin": "^1.8.2",
|
||||
"ext-curl": "*",
|
||||
"guzzle/client-integration-tests": "3.0.2",
|
||||
"guzzlehttp/test-server": "^0.6",
|
||||
"php-http/message-factory": "^1.1",
|
||||
"phpunit/phpunit": "^8.5.52 || ^9.6.34",
|
||||
"phpunit/phpunit": "^8.5.39 || ^9.6.20",
|
||||
"psr/log": "^1.1 || ^2.0 || ^3.0"
|
||||
},
|
||||
"suggest": {
|
||||
@@ -625,7 +623,7 @@
|
||||
],
|
||||
"support": {
|
||||
"issues": "https://github.com/guzzle/guzzle/issues",
|
||||
"source": "https://github.com/guzzle/guzzle/tree/7.13.1"
|
||||
"source": "https://github.com/guzzle/guzzle/tree/7.10.0"
|
||||
},
|
||||
"funding": [
|
||||
{
|
||||
@@ -641,29 +639,28 @@
|
||||
"type": "tidelift"
|
||||
}
|
||||
],
|
||||
"time": "2026-06-29T20:14:18+00:00"
|
||||
"time": "2025-08-23T22:36:01+00:00"
|
||||
},
|
||||
{
|
||||
"name": "guzzlehttp/promises",
|
||||
"version": "2.5.0",
|
||||
"version": "2.3.0",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/guzzle/promises.git",
|
||||
"reference": "4360e982f87f5f258bf872d094647791db2f4c8e"
|
||||
"reference": "481557b130ef3790cf82b713667b43030dc9c957"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/guzzle/promises/zipball/4360e982f87f5f258bf872d094647791db2f4c8e",
|
||||
"reference": "4360e982f87f5f258bf872d094647791db2f4c8e",
|
||||
"url": "https://api.github.com/repos/guzzle/promises/zipball/481557b130ef3790cf82b713667b43030dc9c957",
|
||||
"reference": "481557b130ef3790cf82b713667b43030dc9c957",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"php": "^7.2.5 || ^8.0",
|
||||
"symfony/deprecation-contracts": "^2.5 || ^3.0"
|
||||
"php": "^7.2.5 || ^8.0"
|
||||
},
|
||||
"require-dev": {
|
||||
"bamarni/composer-bin-plugin": "^1.8.2",
|
||||
"phpunit/phpunit": "^8.5.52 || ^9.6.34"
|
||||
"phpunit/phpunit": "^8.5.44 || ^9.6.25"
|
||||
},
|
||||
"type": "library",
|
||||
"extra": {
|
||||
@@ -709,7 +706,7 @@
|
||||
],
|
||||
"support": {
|
||||
"issues": "https://github.com/guzzle/promises/issues",
|
||||
"source": "https://github.com/guzzle/promises/tree/2.5.0"
|
||||
"source": "https://github.com/guzzle/promises/tree/2.3.0"
|
||||
},
|
||||
"funding": [
|
||||
{
|
||||
@@ -725,29 +722,27 @@
|
||||
"type": "tidelift"
|
||||
}
|
||||
],
|
||||
"time": "2026-06-02T12:23:43+00:00"
|
||||
"time": "2025-08-22T14:34:08+00:00"
|
||||
},
|
||||
{
|
||||
"name": "guzzlehttp/psr7",
|
||||
"version": "2.12.3",
|
||||
"version": "2.8.0",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/guzzle/psr7.git",
|
||||
"reference": "7ec62dc3f44aa218487dbed81a9bf9bc647be55d"
|
||||
"reference": "21dc724a0583619cd1652f673303492272778051"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/guzzle/psr7/zipball/7ec62dc3f44aa218487dbed81a9bf9bc647be55d",
|
||||
"reference": "7ec62dc3f44aa218487dbed81a9bf9bc647be55d",
|
||||
"url": "https://api.github.com/repos/guzzle/psr7/zipball/21dc724a0583619cd1652f673303492272778051",
|
||||
"reference": "21dc724a0583619cd1652f673303492272778051",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"php": "^7.2.5 || ^8.0",
|
||||
"psr/http-factory": "^1.0",
|
||||
"psr/http-message": "^1.1 || ^2.0",
|
||||
"ralouphie/getallheaders": "^3.0",
|
||||
"symfony/deprecation-contracts": "^2.5 || ^3.0",
|
||||
"symfony/polyfill-php80": "^1.25"
|
||||
"ralouphie/getallheaders": "^3.0"
|
||||
},
|
||||
"provide": {
|
||||
"psr/http-factory-implementation": "1.0",
|
||||
@@ -755,9 +750,8 @@
|
||||
},
|
||||
"require-dev": {
|
||||
"bamarni/composer-bin-plugin": "^1.8.2",
|
||||
"http-interop/http-factory-tests": "1.1.0",
|
||||
"jshttp/mime-db": "1.54.0.1",
|
||||
"phpunit/phpunit": "^8.5.52 || ^9.6.34"
|
||||
"http-interop/http-factory-tests": "0.9.0",
|
||||
"phpunit/phpunit": "^8.5.44 || ^9.6.25"
|
||||
},
|
||||
"suggest": {
|
||||
"laminas/laminas-httphandlerrunner": "Emit PSR-7 responses"
|
||||
@@ -828,7 +822,7 @@
|
||||
],
|
||||
"support": {
|
||||
"issues": "https://github.com/guzzle/psr7/issues",
|
||||
"source": "https://github.com/guzzle/psr7/tree/2.12.3"
|
||||
"source": "https://github.com/guzzle/psr7/tree/2.8.0"
|
||||
},
|
||||
"funding": [
|
||||
{
|
||||
@@ -844,7 +838,7 @@
|
||||
"type": "tidelift"
|
||||
}
|
||||
],
|
||||
"time": "2026-06-23T15:21:08+00:00"
|
||||
"time": "2025-08-23T21:21:41+00:00"
|
||||
},
|
||||
{
|
||||
"name": "intervention/image",
|
||||
@@ -1870,16 +1864,16 @@
|
||||
},
|
||||
{
|
||||
"name": "symfony/cache",
|
||||
"version": "v5.4.53",
|
||||
"version": "v5.4.46",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/symfony/cache.git",
|
||||
"reference": "bf581474737420d5c932ae80b868e253f465ee5b"
|
||||
"reference": "0fe08ee32cec2748fbfea10c52d3ee02049e0f6b"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/symfony/cache/zipball/bf581474737420d5c932ae80b868e253f465ee5b",
|
||||
"reference": "bf581474737420d5c932ae80b868e253f465ee5b",
|
||||
"url": "https://api.github.com/repos/symfony/cache/zipball/0fe08ee32cec2748fbfea10c52d3ee02049e0f6b",
|
||||
"reference": "0fe08ee32cec2748fbfea10c52d3ee02049e0f6b",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
@@ -1947,7 +1941,7 @@
|
||||
"psr6"
|
||||
],
|
||||
"support": {
|
||||
"source": "https://github.com/symfony/cache/tree/v5.4.53"
|
||||
"source": "https://github.com/symfony/cache/tree/v5.4.46"
|
||||
},
|
||||
"funding": [
|
||||
{
|
||||
@@ -1958,16 +1952,12 @@
|
||||
"url": "https://github.com/fabpot",
|
||||
"type": "github"
|
||||
},
|
||||
{
|
||||
"url": "https://github.com/nicolas-grekas",
|
||||
"type": "github"
|
||||
},
|
||||
{
|
||||
"url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
|
||||
"type": "tidelift"
|
||||
}
|
||||
],
|
||||
"time": "2026-05-24T08:41:21+00:00"
|
||||
"time": "2024-11-04T11:43:55+00:00"
|
||||
},
|
||||
{
|
||||
"name": "symfony/cache-contracts",
|
||||
@@ -2631,7 +2621,7 @@
|
||||
},
|
||||
{
|
||||
"name": "symfony/polyfill-php73",
|
||||
"version": "v1.37.0",
|
||||
"version": "v1.33.0",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/symfony/polyfill-php73.git",
|
||||
@@ -2687,7 +2677,7 @@
|
||||
"shim"
|
||||
],
|
||||
"support": {
|
||||
"source": "https://github.com/symfony/polyfill-php73/tree/v1.37.0"
|
||||
"source": "https://github.com/symfony/polyfill-php73/tree/v1.33.0"
|
||||
},
|
||||
"funding": [
|
||||
{
|
||||
@@ -2711,16 +2701,16 @@
|
||||
},
|
||||
{
|
||||
"name": "symfony/polyfill-php80",
|
||||
"version": "v1.37.0",
|
||||
"version": "v1.33.0",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/symfony/polyfill-php80.git",
|
||||
"reference": "dfb55726c3a76ea3b6459fcfda1ec2d80a682411"
|
||||
"reference": "0cc9dd0f17f61d8131e7df6b84bd344899fe2608"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/symfony/polyfill-php80/zipball/dfb55726c3a76ea3b6459fcfda1ec2d80a682411",
|
||||
"reference": "dfb55726c3a76ea3b6459fcfda1ec2d80a682411",
|
||||
"url": "https://api.github.com/repos/symfony/polyfill-php80/zipball/0cc9dd0f17f61d8131e7df6b84bd344899fe2608",
|
||||
"reference": "0cc9dd0f17f61d8131e7df6b84bd344899fe2608",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
@@ -2771,7 +2761,7 @@
|
||||
"shim"
|
||||
],
|
||||
"support": {
|
||||
"source": "https://github.com/symfony/polyfill-php80/tree/v1.37.0"
|
||||
"source": "https://github.com/symfony/polyfill-php80/tree/v1.33.0"
|
||||
},
|
||||
"funding": [
|
||||
{
|
||||
@@ -2791,7 +2781,7 @@
|
||||
"type": "tidelift"
|
||||
}
|
||||
],
|
||||
"time": "2026-04-10T16:19:22+00:00"
|
||||
"time": "2025-01-02T08:10:11+00:00"
|
||||
},
|
||||
{
|
||||
"name": "symfony/polyfill-php81",
|
||||
@@ -3929,11 +3919,11 @@
|
||||
},
|
||||
{
|
||||
"name": "phpstan/phpstan",
|
||||
"version": "2.1.40",
|
||||
"version": "2.1.39",
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/phpstan/phpstan/zipball/9b2c7aeb83a75d8680ea5e7c9b7fca88052b766b",
|
||||
"reference": "9b2c7aeb83a75d8680ea5e7c9b7fca88052b766b",
|
||||
"url": "https://api.github.com/repos/phpstan/phpstan/zipball/c6f73a2af4cbcd99c931d0fb8f08548cc0fa8224",
|
||||
"reference": "c6f73a2af4cbcd99c931d0fb8f08548cc0fa8224",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
@@ -3978,7 +3968,7 @@
|
||||
"type": "github"
|
||||
}
|
||||
],
|
||||
"time": "2026-02-23T15:04:35+00:00"
|
||||
"time": "2026-02-11T14:48:56+00:00"
|
||||
},
|
||||
{
|
||||
"name": "phpunit/php-code-coverage",
|
||||
@@ -4416,18 +4406,18 @@
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/Roave/SecurityAdvisories.git",
|
||||
"reference": "0d2dce39eb2bce812e8c71a73be98b0f5d78f489"
|
||||
"reference": "92c5ec5685cfbcd7ef721a502e6622516728011c"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/Roave/SecurityAdvisories/zipball/0d2dce39eb2bce812e8c71a73be98b0f5d78f489",
|
||||
"reference": "0d2dce39eb2bce812e8c71a73be98b0f5d78f489",
|
||||
"url": "https://api.github.com/repos/Roave/SecurityAdvisories/zipball/92c5ec5685cfbcd7ef721a502e6622516728011c",
|
||||
"reference": "92c5ec5685cfbcd7ef721a502e6622516728011c",
|
||||
"shasum": ""
|
||||
},
|
||||
"conflict": {
|
||||
"3f/pygmentize": "<1.2",
|
||||
"adaptcms/adaptcms": "<=1.3",
|
||||
"admidio/admidio": "<5.0.6",
|
||||
"admidio/admidio": "<=4.3.16",
|
||||
"adodb/adodb-php": "<=5.22.9",
|
||||
"aheinze/cockpit": "<2.2",
|
||||
"aimeos/ai-admin-graphql": ">=2022.04.1,<2022.10.10|>=2023.04.1,<2023.10.6|>=2024.04.1,<2024.07.2",
|
||||
@@ -4478,7 +4468,7 @@
|
||||
"automattic/jetpack": "<9.8",
|
||||
"awesome-support/awesome-support": "<=6.0.7",
|
||||
"aws/aws-sdk-php": "<3.368",
|
||||
"azuracast/azuracast": "<=0.23.3",
|
||||
"azuracast/azuracast": "<=0.23.1",
|
||||
"b13/seo_basics": "<0.8.2",
|
||||
"backdrop/backdrop": "<=1.32",
|
||||
"backpack/crud": "<3.4.9",
|
||||
@@ -4550,7 +4540,7 @@
|
||||
"commerceteam/commerce": ">=0.9.6,<0.9.9",
|
||||
"components/jquery": ">=1.0.3,<3.5",
|
||||
"composer/composer": "<1.10.27|>=2,<2.2.26|>=2.3,<2.9.3",
|
||||
"concrete5/concrete5": "<9.4.8",
|
||||
"concrete5/concrete5": "<9.4.3",
|
||||
"concrete5/core": "<8.5.8|>=9,<9.1",
|
||||
"contao-components/mediaelement": ">=2.14.2,<2.21.1",
|
||||
"contao/comments-bundle": ">=2,<4.13.40|>=5.0.0.0-RC1-dev,<5.3.4",
|
||||
@@ -4564,8 +4554,8 @@
|
||||
"cosenary/instagram": "<=2.3",
|
||||
"couleurcitron/tarteaucitron-wp": "<0.3",
|
||||
"cpsit/typo3-mailqueue": "<0.4.3|>=0.5,<0.5.1",
|
||||
"craftcms/cms": "<=4.17.3|>=5,<=5.9.8",
|
||||
"craftcms/commerce": ">=4,<4.11|>=5,<5.6",
|
||||
"craftcms/cms": "<4.17.0.0-beta1|>=5,<5.9.0.0-beta1",
|
||||
"craftcms/commerce": ">=4.0.0.0-RC1-dev,<=4.10|>=5,<=5.5.1",
|
||||
"craftcms/composer": ">=4.0.0.0-RC1-dev,<=4.10|>=5.0.0.0-RC1-dev,<=5.5.1",
|
||||
"craftcms/craft": ">=3.5,<=4.16.17|>=5.0.0.0-RC1-dev,<=5.8.21",
|
||||
"croogo/croogo": "<=4.0.7",
|
||||
@@ -4643,7 +4633,7 @@
|
||||
"drupal/umami_analytics": "<1.0.1",
|
||||
"duncanmcclean/guest-entries": "<3.1.2",
|
||||
"dweeves/magmi": "<=0.7.24",
|
||||
"ec-cube/ec-cube": "<2.4.4|>=2.11,<=2.17.1|>=3,<=3.0.18.0-patch4|>=4,<=4.3.1",
|
||||
"ec-cube/ec-cube": "<2.4.4|>=2.11,<=2.17.1|>=3,<=3.0.18.0-patch4|>=4,<=4.1.2",
|
||||
"ecodev/newsletter": "<=4",
|
||||
"ectouch/ectouch": "<=2.7.2",
|
||||
"egroupware/egroupware": "<23.1.20260113|>=26.0.20251208,<26.0.20260113",
|
||||
@@ -4700,7 +4690,6 @@
|
||||
"flarum/flarum": "<0.1.0.0-beta8",
|
||||
"flarum/framework": "<1.8.10",
|
||||
"flarum/mentions": "<1.6.3",
|
||||
"flarum/nicknames": "<1.8.3",
|
||||
"flarum/sticky": ">=0.1.0.0-beta14,<=0.1.0.0-beta15",
|
||||
"flarum/tags": "<=0.1.0.0-beta13",
|
||||
"floriangaerber/magnesium": "<0.3.1",
|
||||
@@ -4723,10 +4712,10 @@
|
||||
"friendsoftypo3/openid": ">=4.5,<4.5.31|>=4.7,<4.7.16|>=6,<6.0.11|>=6.1,<6.1.6",
|
||||
"froala/wysiwyg-editor": "<=4.3",
|
||||
"frosh/adminer-platform": "<2.2.1",
|
||||
"froxlor/froxlor": "<=2.3.3",
|
||||
"froxlor/froxlor": "<=2.2.5",
|
||||
"frozennode/administrator": "<=5.0.12",
|
||||
"fuel/core": "<1.8.1",
|
||||
"funadmin/funadmin": "<=7.1.0.0-RC4",
|
||||
"funadmin/funadmin": "<=5.0.2",
|
||||
"gaoming13/wechat-php-sdk": "<=1.10.2",
|
||||
"genix/cms": "<=1.1.11",
|
||||
"georgringer/news": "<1.3.3",
|
||||
@@ -4747,7 +4736,7 @@
|
||||
"gp247/core": "<1.1.24",
|
||||
"gree/jose": "<2.2.1",
|
||||
"gregwar/rst": "<1.0.3",
|
||||
"grumpydictator/firefly-iii": "<6.1.17|>=6.4.23,<=6.5",
|
||||
"grumpydictator/firefly-iii": "<6.1.17",
|
||||
"gugoan/economizzer": "<=0.9.0.0-beta1",
|
||||
"guzzlehttp/guzzle": "<6.5.8|>=7,<7.4.5",
|
||||
"guzzlehttp/oauth-subscriber": "<0.8.1",
|
||||
@@ -4773,7 +4762,7 @@
|
||||
"ibexa/solr": ">=4.5,<4.5.4",
|
||||
"ibexa/user": ">=4,<4.4.3|>=5,<5.0.4",
|
||||
"icecoder/icecoder": "<=8.1",
|
||||
"idno/known": "<1.6.4",
|
||||
"idno/known": "<=1.6.2",
|
||||
"ilicmiljan/secure-props": ">=1.2,<1.2.2",
|
||||
"illuminate/auth": "<5.5.10",
|
||||
"illuminate/cookie": ">=4,<=4.0.11|>=4.1,<6.18.31|>=7,<7.22.4",
|
||||
@@ -4823,7 +4812,7 @@
|
||||
"kelvinmo/simplexrd": "<3.1.1",
|
||||
"kevinpapst/kimai2": "<1.16.7",
|
||||
"khodakhah/nodcms": "<=3",
|
||||
"kimai/kimai": "<=2.50",
|
||||
"kimai/kimai": "<2.46",
|
||||
"kitodo/presentation": "<3.2.3|>=3.3,<3.3.4",
|
||||
"klaviyo/magento2-extension": ">=1,<3",
|
||||
"knplabs/knp-snappy": "<=1.4.2",
|
||||
@@ -4848,7 +4837,7 @@
|
||||
"lavalite/cms": "<=10.1",
|
||||
"lavitto/typo3-form-to-database": "<2.2.5|>=3,<3.2.2|>=4,<4.2.3|>=5,<5.0.2",
|
||||
"lcobucci/jwt": ">=3.4,<3.4.6|>=4,<4.0.4|>=4.1,<4.1.5",
|
||||
"league/commonmark": "<=2.8",
|
||||
"league/commonmark": "<2.7",
|
||||
"league/flysystem": "<1.1.4|>=2,<2.1.1",
|
||||
"league/oauth2-server": ">=8.3.2,<8.4.2|>=8.5,<8.5.3",
|
||||
"leantime/leantime": "<3.3",
|
||||
@@ -4857,7 +4846,7 @@
|
||||
"librenms/librenms": "<26.2",
|
||||
"liftkit/database": "<2.13.2",
|
||||
"lightsaml/lightsaml": "<1.3.5",
|
||||
"limesurvey/limesurvey": "<6.15.4",
|
||||
"limesurvey/limesurvey": "<6.5.12",
|
||||
"livehelperchat/livehelperchat": "<=3.91",
|
||||
"livewire-filemanager/filemanager": "<=1.0.4",
|
||||
"livewire/livewire": "<2.12.7|>=3.0.0.0-beta1,<3.6.4",
|
||||
@@ -4885,7 +4874,7 @@
|
||||
"marshmallow/nova-tiptap": "<5.7",
|
||||
"matomo/matomo": "<1.11",
|
||||
"matyhtf/framework": "<3.0.6",
|
||||
"mautic/core": "<5.2.10|>=6,<6.0.8|>=7.0.0.0-alpha,<7.0.1",
|
||||
"mautic/core": "<5.2.9|>=6,<6.0.7",
|
||||
"mautic/core-lib": ">=1.0.0.0-beta,<4.4.13|>=5.0.0.0-alpha,<5.1.1",
|
||||
"mautic/grapes-js-builder-bundle": ">=4,<4.4.18|>=5,<5.2.9|>=6,<6.0.7",
|
||||
"maximebf/debugbar": "<1.19",
|
||||
@@ -4918,7 +4907,7 @@
|
||||
"mongodb/mongodb": ">=1,<1.9.2",
|
||||
"mongodb/mongodb-extension": "<1.21.2",
|
||||
"monolog/monolog": ">=1.8,<1.12",
|
||||
"moodle/moodle": "<4.5.9|>=5.0.0.0-beta,<5.0.5|>=5.1.0.0-beta,<5.1.2",
|
||||
"moodle/moodle": "<4.4.12|>=4.5.0.0-beta,<4.5.8|>=5.0.0.0-beta,<5.0.4|>=5.1.0.0-beta,<5.1.1",
|
||||
"moonshine/moonshine": "<=3.12.5",
|
||||
"mos/cimage": "<0.7.19",
|
||||
"movim/moxl": ">=0.8,<=0.10",
|
||||
@@ -5036,7 +5025,7 @@
|
||||
"pimcore/demo": "<10.3",
|
||||
"pimcore/ecommerce-framework-bundle": "<1.0.10",
|
||||
"pimcore/perspective-editor": "<1.5.1",
|
||||
"pimcore/pimcore": "<=11.5.14.1|>=12,<12.3.3",
|
||||
"pimcore/pimcore": "<=11.5.13|>=12.0.0.0-RC1-dev,<12.3.1",
|
||||
"pimcore/web2print-tools-bundle": "<=5.2.1|>=6.0.0.0-RC1-dev,<=6.1",
|
||||
"piwik/piwik": "<1.11",
|
||||
"pixelfed/pixelfed": "<0.12.5",
|
||||
@@ -5086,7 +5075,7 @@
|
||||
"reportico-web/reportico": "<=8.1",
|
||||
"rhukster/dom-sanitizer": "<1.0.7",
|
||||
"rmccue/requests": ">=1.6,<1.8",
|
||||
"robrichards/xmlseclibs": "<3.1.5",
|
||||
"robrichards/xmlseclibs": "<=3.1.3",
|
||||
"roots/soil": "<4.1",
|
||||
"roundcube/roundcubemail": "<1.5.10|>=1.6,<1.6.11",
|
||||
"rudloff/alltube": "<3.0.3",
|
||||
@@ -5102,8 +5091,8 @@
|
||||
"setasign/fpdi": "<2.6.4",
|
||||
"sfroemken/url_redirect": "<=1.2.1",
|
||||
"sheng/yiicms": "<1.2.1",
|
||||
"shopware/core": "<6.6.10.15-dev|>=6.7,<6.7.8.1-dev",
|
||||
"shopware/platform": "<6.6.10.15-dev|>=6.7,<6.7.8.1-dev",
|
||||
"shopware/core": "<6.6.10.9-dev|>=6.7,<6.7.6.1-dev",
|
||||
"shopware/platform": "<6.6.10.7-dev|>=6.7,<6.7.3.1-dev",
|
||||
"shopware/production": "<=6.3.5.2",
|
||||
"shopware/shopware": "<=5.7.17|>=6.4.6,<6.6.10.10-dev|>=6.7,<6.7.6.1-dev",
|
||||
"shopware/storefront": "<6.6.10.10-dev|>=6.7,<6.7.5.1-dev",
|
||||
@@ -5137,7 +5126,7 @@
|
||||
"simplesamlphp/simplesamlphp-module-openid": "<1",
|
||||
"simplesamlphp/simplesamlphp-module-openidprovider": "<0.9",
|
||||
"simplesamlphp/xml-common": "<1.20",
|
||||
"simplesamlphp/xml-security": "<2.3.1",
|
||||
"simplesamlphp/xml-security": "==1.6.11",
|
||||
"simplito/elliptic-php": "<1.0.6",
|
||||
"sitegeist/fluid-components": "<3.5",
|
||||
"sjbr/sr-feuser-register": "<2.6.2|>=5.1,<12.5",
|
||||
@@ -5147,7 +5136,7 @@
|
||||
"slim/slim": "<2.6",
|
||||
"slub/slub-events": "<3.0.3",
|
||||
"smarty/smarty": "<4.5.3|>=5,<5.1.1",
|
||||
"snipe/snipe-it": "<8.3.7",
|
||||
"snipe/snipe-it": "<=8.3.4",
|
||||
"socalnick/scn-social-auth": "<1.15.2",
|
||||
"socialiteproviders/steam": "<1.1",
|
||||
"solspace/craft-freeform": "<4.1.29|>=5,<=5.14.6",
|
||||
@@ -5165,7 +5154,7 @@
|
||||
"starcitizentools/short-description": ">=4,<4.0.1",
|
||||
"starcitizentools/tabber-neue": ">=1.9.1,<2.7.2|>=3,<3.1.1",
|
||||
"starcitizenwiki/embedvideo": "<=4",
|
||||
"statamic/cms": "<5.73.11|>=6,<6.6.2",
|
||||
"statamic/cms": "<5.73.9|>=6,<6.3.2",
|
||||
"stormpath/sdk": "<9.9.99",
|
||||
"studio-42/elfinder": "<=2.1.64",
|
||||
"studiomitte/friendlycaptcha": "<0.1.4",
|
||||
@@ -5184,7 +5173,7 @@
|
||||
"sylius/grid-bundle": "<1.10.1",
|
||||
"sylius/paypal-plugin": "<1.6.2|>=1.7,<1.7.2|>=2,<2.0.2",
|
||||
"sylius/resource-bundle": ">=1,<1.3.14|>=1.4,<1.4.7|>=1.5,<1.5.2|>=1.6,<1.6.4",
|
||||
"sylius/sylius": "<1.9.12|>=1.10,<1.10.16|>=1.11,<1.11.17|>=1.12,<=1.12.22|>=1.13,<=1.13.14|>=1.14,<=1.14.17|>=2,<=2.0.15|>=2.1,<=2.1.11|>=2.2,<=2.2.2",
|
||||
"sylius/sylius": "<1.12.19|>=1.13.0.0-alpha1,<1.13.4",
|
||||
"symbiote/silverstripe-multivaluefield": ">=3,<3.1",
|
||||
"symbiote/silverstripe-queuedjobs": ">=3,<3.0.2|>=3.1,<3.1.4|>=4,<4.0.7|>=4.1,<4.1.2|>=4.2,<4.2.4|>=4.3,<4.3.3|>=4.4,<4.4.3|>=4.5,<4.5.1|>=4.6,<4.6.4",
|
||||
"symbiote/silverstripe-seed": "<6.0.3",
|
||||
@@ -5239,7 +5228,7 @@
|
||||
"thelia/thelia": ">=2.1,<2.1.3",
|
||||
"theonedemon/phpwhois": "<=4.2.5",
|
||||
"thinkcmf/thinkcmf": "<6.0.8",
|
||||
"thorsten/phpmyfaq": "<4.0.18|>=4.1.0.0-alpha,<=4.1.0.0-beta2",
|
||||
"thorsten/phpmyfaq": "<=4.0.16|>=4.1.0.0-alpha,<=4.1.0.0-beta2",
|
||||
"tikiwiki/tiki-manager": "<=17.1",
|
||||
"timber/timber": ">=0.16.6,<1.23.1|>=1.24,<1.24.1|>=2,<2.1",
|
||||
"tinymce/tinymce": "<7.2",
|
||||
@@ -5257,7 +5246,6 @@
|
||||
"ttskch/pagination-service-provider": "<1",
|
||||
"twbs/bootstrap": "<3.4.1|>=4,<4.3.1",
|
||||
"twig/twig": "<3.11.2|>=3.12,<3.14.1|>=3.16,<3.19",
|
||||
"typicms/core": "<16.1.7",
|
||||
"typo3/cms": "<9.5.29|>=10,<10.4.35|>=11,<11.5.23|>=12,<12.2",
|
||||
"typo3/cms-backend": "<4.1.14|>=4.2,<4.2.15|>=4.3,<4.3.7|>=4.4,<4.4.4|>=7,<=7.6.50|>=8,<=8.7.39|>=9,<9.5.55|>=10,<=10.4.54|>=11,<=11.5.48|>=12,<=12.4.40|>=13,<=13.4.22|>=14,<=14.0.1",
|
||||
"typo3/cms-belog": ">=10,<=10.4.47|>=11,<=11.5.41|>=12,<=12.4.24|>=13,<=13.4.2",
|
||||
@@ -5312,9 +5300,8 @@
|
||||
"wallabag/wallabag": "<2.6.11",
|
||||
"wanglelecc/laracms": "<=1.0.3",
|
||||
"wapplersystems/a21glossary": "<=0.4.10",
|
||||
"web-auth/webauthn-framework": ">=3.3,<3.3.4|>=4.5,<4.9|>=5.2,<5.2.4",
|
||||
"web-auth/webauthn-lib": ">=4.5,<4.9|>=5.2,<5.2.4",
|
||||
"web-auth/webauthn-symfony-bundle": ">=5.2,<5.2.4",
|
||||
"web-auth/webauthn-framework": ">=3.3,<3.3.4|>=4.5,<4.9",
|
||||
"web-auth/webauthn-lib": ">=4.5,<4.9",
|
||||
"web-feet/coastercms": "==5.5",
|
||||
"web-tp3/wec_map": "<3.0.3",
|
||||
"webbuilders-group/silverstripe-kapost-bridge": "<0.4",
|
||||
@@ -5326,7 +5313,7 @@
|
||||
"wikibase/wikibase": "<=1.39.3",
|
||||
"wikimedia/parsoid": "<0.12.2",
|
||||
"willdurand/js-translation-bundle": "<2.1.1",
|
||||
"winter/wn-backend-module": "<1.2.12",
|
||||
"winter/wn-backend-module": "<1.2.4",
|
||||
"winter/wn-cms-module": "<=1.2.9",
|
||||
"winter/wn-dusk-plugin": "<2.1",
|
||||
"winter/wn-system-module": "<1.2.4",
|
||||
@@ -5339,8 +5326,7 @@
|
||||
"wpanel/wpanel4-cms": "<=4.3.1",
|
||||
"wpcloud/wp-stateless": "<3.2",
|
||||
"wpglobus/wpglobus": "<=1.9.6",
|
||||
"wpmetabox/meta-box": "<5.11.2",
|
||||
"wwbn/avideo": "<25",
|
||||
"wwbn/avideo": "<21",
|
||||
"xataface/xataface": "<3",
|
||||
"xpressengine/xpressengine": "<3.0.15",
|
||||
"yab/quarx": "<2.4.5",
|
||||
@@ -5438,7 +5424,7 @@
|
||||
"type": "tidelift"
|
||||
}
|
||||
],
|
||||
"time": "2026-03-13T21:11:59+00:00"
|
||||
"time": "2026-02-20T22:06:39+00:00"
|
||||
},
|
||||
{
|
||||
"name": "sebastian/cli-parser",
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
};
|
||||
@@ -12,7 +12,7 @@ use Openguru\OpenCartFramework\MaintenanceTasks\MaintenanceTasksService;
|
||||
use Openguru\OpenCartFramework\MaintenanceTasks\MaintenanceTasksServiceProvider;
|
||||
use Openguru\OpenCartFramework\Migrations\MigrationsServiceProvider;
|
||||
use Openguru\OpenCartFramework\Router\Router;
|
||||
use Openguru\OpenCartFramework\Support\ExecutionTimeProfiler;
|
||||
use Openguru\OpenCartFramework\Sentry\SentryService;
|
||||
use Psr\Log\LoggerAwareInterface;
|
||||
use Psr\Log\LoggerInterface;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
@@ -27,11 +27,6 @@ class Application extends Container implements LoggerAwareInterface
|
||||
|
||||
private LoggerInterface $logger;
|
||||
|
||||
/**
|
||||
* @var ExecutionTimeProfiler
|
||||
*/
|
||||
private ExecutionTimeProfiler $profiler;
|
||||
|
||||
public static function getInstance(): Application
|
||||
{
|
||||
return static::$instance;
|
||||
@@ -39,13 +34,6 @@ class Application extends Container implements LoggerAwareInterface
|
||||
|
||||
private function bootKernelServices(): void
|
||||
{
|
||||
$this->singleton(ExecutionTimeProfiler::class, function () {
|
||||
return new ExecutionTimeProfiler();
|
||||
});
|
||||
|
||||
$this->profiler = $this->get(ExecutionTimeProfiler::class);
|
||||
$this->profiler->start();
|
||||
|
||||
$this->singleton(Container::class, function (Container $container) {
|
||||
return $container;
|
||||
});
|
||||
@@ -56,8 +44,6 @@ class Application extends Container implements LoggerAwareInterface
|
||||
return new Settings($container->getConfigValue());
|
||||
});
|
||||
|
||||
$this->loadEnvironmentVariables();
|
||||
|
||||
$errorHandler = new ErrorHandler(
|
||||
$this->get(LoggerInterface::class),
|
||||
$this,
|
||||
@@ -68,16 +54,26 @@ 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;
|
||||
|
||||
SentryService::init($action);
|
||||
|
||||
SentryService::measure('boot_kernel_services', 'app.boot', function () {
|
||||
$this->bootKernelServices();
|
||||
});
|
||||
|
||||
SentryService::measure('dependency_registration', 'app.boot', function () {
|
||||
$this->initializeEventDispatcher(static::$events);
|
||||
|
||||
DependencyRegistration::register($this, $this->serviceProviders);
|
||||
});
|
||||
|
||||
static::$instance = $this;
|
||||
|
||||
$this->profiler->addCheckpoint('Bootstrap Application');
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
@@ -97,18 +93,25 @@ class Application extends Container implements LoggerAwareInterface
|
||||
throw new InvalidArgumentException('Invalid action: ' . $controller . '->' . $method);
|
||||
}
|
||||
|
||||
$this->profiler->addCheckpoint('Handle Middlewares.');
|
||||
$span = SentryService::startSpan('handle_middlewares', 'http.handle');
|
||||
|
||||
try {
|
||||
$next = fn($req) => $this->call($controller, $method);
|
||||
|
||||
foreach (array_reverse($this->middlewareStack) as $class) {
|
||||
$instance = $this->get($class);
|
||||
$next = static fn($req) => $instance->handle($req, $next);
|
||||
}
|
||||
} finally {
|
||||
SentryService::endSpan($span);
|
||||
}
|
||||
|
||||
$span = SentryService::startSpan('handle_controller', 'http.handle');
|
||||
|
||||
try {
|
||||
$response = $next($request);
|
||||
|
||||
$this->profiler->addCheckpoint('Handle HTTP request.');
|
||||
} finally {
|
||||
SentryService::endSpan($span);
|
||||
}
|
||||
|
||||
return $response;
|
||||
}
|
||||
@@ -117,9 +120,11 @@ class Application extends Container implements LoggerAwareInterface
|
||||
{
|
||||
$this->boot();
|
||||
|
||||
SentryService::measure('handle_request', 'http.handle', function () {
|
||||
$request = Request::createFromGlobals();
|
||||
$response = $this->handleRequest($request);
|
||||
$response->send();
|
||||
});
|
||||
}
|
||||
|
||||
public function withServiceProviders(array $serviceProviders): Application
|
||||
@@ -179,9 +184,10 @@ class Application extends Container implements LoggerAwareInterface
|
||||
*/
|
||||
private function loadEnvironmentVariables(): void
|
||||
{
|
||||
if (!defined('BP_PHAR_BASE_PATH') || !defined('BP_REAL_BASE_PATH')) {
|
||||
if (! defined('BP_PHAR_BASE_PATH') || ! defined('BP_REAL_BASE_PATH')) {
|
||||
$dotenv = Dotenv::createMutable(__DIR__ . '/../');
|
||||
$dotenv->load();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ use Openguru\OpenCartFramework\Exceptions\ActionNotFoundException;
|
||||
use Openguru\OpenCartFramework\Exceptions\HttpNotFoundException;
|
||||
use Openguru\OpenCartFramework\Exceptions\InvalidApiTokenException;
|
||||
use Openguru\OpenCartFramework\Exceptions\NonLoggableExceptionInterface;
|
||||
use Openguru\OpenCartFramework\Sentry\SentryService;
|
||||
use Symfony\Component\HttpFoundation\JsonResponse;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Psr\Log\LoggerInterface;
|
||||
@@ -110,6 +111,8 @@ class ErrorHandler
|
||||
|
||||
public function handleShutdown(): void
|
||||
{
|
||||
SentryService::finishTransaction();
|
||||
|
||||
$error = error_get_last();
|
||||
|
||||
if ($error !== null && in_array($error['type'], [E_ERROR, E_CORE_ERROR, E_COMPILE_ERROR], true)) {
|
||||
|
||||
@@ -2,9 +2,11 @@
|
||||
|
||||
namespace Openguru\OpenCartFramework\QueryBuilder\Connections;
|
||||
|
||||
use Openguru\OpenCartFramework\Sentry\SentryService;
|
||||
use Openguru\OpenCartFramework\Support\Utils;
|
||||
use PDO;
|
||||
use RuntimeException;
|
||||
use Sentry\Tracing\SpanContext;
|
||||
|
||||
class MySqlConnection implements ConnectionInterface
|
||||
{
|
||||
@@ -25,10 +27,23 @@ class MySqlConnection implements ConnectionInterface
|
||||
|
||||
public function select(string $sql, array $bindings = []): array
|
||||
{
|
||||
$span = SentryService::startSpan($sql, 'db.mysql.query', function (SpanContext $context) use ($sql) {
|
||||
$context->setData([
|
||||
'db.system' => 'mysql',
|
||||
'db.statement' => $sql,
|
||||
]);
|
||||
});
|
||||
|
||||
try {
|
||||
$statement = $this->pdo->prepare($sql);
|
||||
$statement->execute($bindings);
|
||||
|
||||
return $statement->fetchAll(PDO::FETCH_ASSOC);
|
||||
$results = $statement->fetchAll(PDO::FETCH_ASSOC);
|
||||
} finally {
|
||||
SentryService::endSpan($span);
|
||||
}
|
||||
|
||||
return $results;
|
||||
}
|
||||
|
||||
public function escape($value): string
|
||||
@@ -88,10 +103,19 @@ class MySqlConnection implements ConnectionInterface
|
||||
|
||||
public function statement(string $sql, array $bindings = []): bool
|
||||
{
|
||||
$span = SentryService::startSpan($sql, 'db.mysql.query', function (SpanContext $context) use ($sql) {
|
||||
$context->setData([
|
||||
'db.system' => 'mysql',
|
||||
'db.statement' => $sql,
|
||||
]);
|
||||
});
|
||||
|
||||
try {
|
||||
$statement = $this->pdo->prepare($sql);
|
||||
|
||||
if (! $statement) {
|
||||
$this->lastError = $this->pdo->errorInfo();
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -99,8 +123,12 @@ class MySqlConnection implements ConnectionInterface
|
||||
|
||||
if (! $success) {
|
||||
$this->lastError = $statement->errorInfo();
|
||||
|
||||
return false;
|
||||
}
|
||||
} finally {
|
||||
SentryService::endSpan($span);
|
||||
}
|
||||
|
||||
return $success;
|
||||
}
|
||||
@@ -147,7 +175,7 @@ class MySqlConnection implements ConnectionInterface
|
||||
public function insert(string $table, array $data): bool
|
||||
{
|
||||
$placeholders = implode(',', array_fill(0, count($data), '?'));
|
||||
$columns = implode(',', array_map(static fn ($key) => "`${key}`", array_keys($data)));
|
||||
$columns = implode(',', array_map(static fn($key) => "`${key}`", array_keys($data)));
|
||||
$sql = sprintf('INSERT INTO `%s` (%s) VALUES (%s)', $table, $columns, $placeholders);
|
||||
|
||||
return $this->statement($sql, array_values($data));
|
||||
|
||||
@@ -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)
|
||||
);
|
||||
});
|
||||
//
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Openguru\OpenCartFramework\Sentry;
|
||||
|
||||
use Closure;
|
||||
use Sentry\SentrySdk;
|
||||
use Sentry\Tracing\Span;
|
||||
use Sentry\Tracing\SpanContext;
|
||||
use Sentry\Tracing\Transaction;
|
||||
use Sentry\Tracing\TransactionContext;
|
||||
use SplObjectStorage;
|
||||
|
||||
use function Sentry\init;
|
||||
use function Sentry\startTransaction;
|
||||
|
||||
class SentryService
|
||||
{
|
||||
private static ?string $currentAction = null;
|
||||
public static ?Transaction $currentTransaction = null;
|
||||
|
||||
/**
|
||||
* Stores parent span for each created child span to correctly restore nesting.
|
||||
*
|
||||
* @var SplObjectStorage<Span, Span|null>|null
|
||||
*/
|
||||
private static ?SplObjectStorage $spanParents = null;
|
||||
|
||||
public static array $excludeActions = [
|
||||
'health',
|
||||
];
|
||||
|
||||
private static function resolveOptionsFromEnv(): array
|
||||
{
|
||||
return [
|
||||
'dsn' => env('SENTRY_DSN'),
|
||||
'send_default_pii' => true,
|
||||
'enable_logs' => (bool) filter_var(env('SENTRY_ENABLE_LOGS', false), FILTER_VALIDATE_BOOLEAN),
|
||||
'traces_sample_rate' => (float) env('SENTRY_TRACES_SAMPLE_RATE', 1.0),
|
||||
];
|
||||
}
|
||||
|
||||
private static function isSentryEnabled(): bool
|
||||
{
|
||||
return self::$currentAction !== null
|
||||
&& ! in_array(self::$currentAction, self::$excludeActions, true)
|
||||
&& env('SENTRY_ENABLED', false);
|
||||
}
|
||||
|
||||
public static function init(?string $action): void
|
||||
{
|
||||
$options = self::resolveOptionsFromEnv();
|
||||
self::$currentAction = $action;
|
||||
|
||||
if (! self::isSentryEnabled()) {
|
||||
return;
|
||||
}
|
||||
|
||||
init($options);
|
||||
|
||||
$transactionContext = TransactionContext::make()
|
||||
->setName(self::$currentAction)
|
||||
->setOp('http.server');
|
||||
|
||||
self::$currentTransaction = startTransaction($transactionContext);
|
||||
SentrySdk::getCurrentHub()->setSpan(self::$currentTransaction);
|
||||
}
|
||||
|
||||
public static function startSpan(string $description, string $op, ?Closure $closure = null): ?Span
|
||||
{
|
||||
$parent = self::resolveParent();
|
||||
|
||||
if (! self::isSentryEnabled() || ! $parent) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$context = SpanContext::make()
|
||||
->setOp($op)
|
||||
->setDescription($description);
|
||||
|
||||
if ($closure) {
|
||||
$closure($context);
|
||||
}
|
||||
|
||||
$span = $parent->startChild($context);
|
||||
|
||||
if (self::$spanParents === null) {
|
||||
self::$spanParents = new SplObjectStorage();
|
||||
}
|
||||
self::$spanParents[$span] = $parent;
|
||||
|
||||
SentrySdk::getCurrentHub()->setSpan($span);
|
||||
|
||||
return $span;
|
||||
}
|
||||
|
||||
public static function endSpan(?Span $span = null): void
|
||||
{
|
||||
if (! $span || ! self::isSentryEnabled()) {
|
||||
return;
|
||||
}
|
||||
|
||||
$parent = null;
|
||||
if (self::$spanParents !== null && self::$spanParents->contains($span)) {
|
||||
$parent = self::$spanParents[$span] ?? null;
|
||||
self::$spanParents->detach($span);
|
||||
}
|
||||
|
||||
$span->finish();
|
||||
SentrySdk::getCurrentHub()->setSpan($parent ?? self::$currentTransaction);
|
||||
}
|
||||
|
||||
public static function resolveParent(): ?Span
|
||||
{
|
||||
return SentrySdk::getCurrentHub()->getSpan() ?? self::$currentTransaction;
|
||||
}
|
||||
|
||||
public static function measure(string $description, string $op, Closure $closure, ?Closure $params = null): void
|
||||
{
|
||||
$span = self::startSpan($description, $op, $params);
|
||||
|
||||
try {
|
||||
$closure();
|
||||
} finally {
|
||||
self::endSpan($span);
|
||||
}
|
||||
}
|
||||
|
||||
public static function finishTransaction(): void
|
||||
{
|
||||
if (self::$currentTransaction) {
|
||||
self::$currentTransaction->finish();
|
||||
self::$currentTransaction = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
namespace App\Handlers;
|
||||
|
||||
use App\Services\CartService;
|
||||
use App\Support\Utf8Cleaner;
|
||||
use Cart\Cart;
|
||||
use Symfony\Component\HttpFoundation\JsonResponse;
|
||||
use Openguru\OpenCartFramework\Http\Request;
|
||||
@@ -24,7 +23,7 @@ class CartHandler
|
||||
$items = $this->cartService->getCart();
|
||||
|
||||
return new JsonResponse([
|
||||
'data' => Utf8Cleaner::clean($items),
|
||||
'data' => $items,
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -49,7 +48,7 @@ class CartHandler
|
||||
}
|
||||
|
||||
return new JsonResponse([
|
||||
'data' => Utf8Cleaner::clean($items),
|
||||
'data' => $items,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -4,7 +4,6 @@ namespace App\Handlers;
|
||||
|
||||
use App\Exceptions\OrderValidationFailedException;
|
||||
use App\Services\OrderCreateService;
|
||||
use App\Support\Utf8Cleaner;
|
||||
use Symfony\Component\HttpFoundation\JsonResponse;
|
||||
use Openguru\OpenCartFramework\Http\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
@@ -27,11 +26,11 @@ class OrderHandler
|
||||
]);
|
||||
|
||||
return new JsonResponse([
|
||||
'data' => Utf8Cleaner::clean($order),
|
||||
'data' => $order,
|
||||
], Response::HTTP_CREATED);
|
||||
} catch (OrderValidationFailedException $exception) {
|
||||
return new JsonResponse([
|
||||
'data' => Utf8Cleaner::clean($exception->getErrorBag()->firstOfAll()),
|
||||
'data' => $exception->getErrorBag()->firstOfAll(),
|
||||
], Response::HTTP_UNPROCESSABLE_ENTITY);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@ use Openguru\OpenCartFramework\QueryBuilder\Builder;
|
||||
use Openguru\OpenCartFramework\QueryBuilder\JoinClause;
|
||||
use Openguru\OpenCartFramework\QueryBuilder\RawExpression;
|
||||
use Openguru\OpenCartFramework\QueryBuilder\Table;
|
||||
use Openguru\OpenCartFramework\Sentry\SentryService;
|
||||
use Openguru\OpenCartFramework\Support\Arr;
|
||||
use Openguru\OpenCartFramework\Support\PaginationHelper;
|
||||
use Openguru\OpenCartFramework\Support\Str;
|
||||
@@ -157,6 +158,7 @@ class ProductsService
|
||||
->get();
|
||||
}
|
||||
|
||||
$span = SentryService::startSpan('crop_images', 'image.process');
|
||||
$productsImagesMap = [];
|
||||
foreach ($productsImages as $item) {
|
||||
$productId = $item['product_id'];
|
||||
@@ -174,6 +176,8 @@ class ProductsService
|
||||
}
|
||||
}
|
||||
|
||||
SentryService::endSpan($span);
|
||||
|
||||
$debug = [];
|
||||
if (env('APP_DEBUG')) {
|
||||
$debug = [
|
||||
|
||||
@@ -1,49 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Support;
|
||||
|
||||
class Utf8Cleaner
|
||||
{
|
||||
/**
|
||||
* Рекурсивно проходит по массиву/объекту и приводит все строковые значения к валидному UTF-8.
|
||||
*/
|
||||
public static function clean($value)
|
||||
{
|
||||
if (is_array($value)) {
|
||||
foreach ($value as $key => $item) {
|
||||
$value[$key] = self::clean($item);
|
||||
}
|
||||
|
||||
return $value;
|
||||
}
|
||||
|
||||
if (is_object($value)) {
|
||||
foreach (get_object_vars($value) as $property => $item) {
|
||||
$value->{$property} = self::clean($item);
|
||||
}
|
||||
|
||||
return $value;
|
||||
}
|
||||
|
||||
if (is_string($value)) {
|
||||
// Попробовать нормализовать строку в UTF-8, подменяя невалидные последовательности.
|
||||
$converted = @mb_convert_encoding($value, 'UTF-8', 'UTF-8');
|
||||
|
||||
if ($converted === false) {
|
||||
// Фолбэк: заменить невалидные байты стандартным алгоритмом json_encode.
|
||||
$encoded = json_encode($value, JSON_INVALID_UTF8_SUBSTITUTE);
|
||||
|
||||
if ($encoded === false) {
|
||||
return '';
|
||||
}
|
||||
|
||||
// Убираем кавычки, которые добавляет json_encode.
|
||||
return json_decode($encoded, true) ?? '';
|
||||
}
|
||||
|
||||
return $converted;
|
||||
}
|
||||
|
||||
return $value;
|
||||
}
|
||||
}
|
||||
@@ -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]);
|
||||
|
||||
@@ -1,90 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Unit\Support;
|
||||
|
||||
use App\Support\Utf8Cleaner;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
class Utf8CleanerTest extends TestCase
|
||||
{
|
||||
/** @test */
|
||||
public function returns_non_string_values_as_is(): void
|
||||
{
|
||||
$this->assertSame(123, Utf8Cleaner::clean(123));
|
||||
$this->assertSame(12.3, Utf8Cleaner::clean(12.3));
|
||||
$this->assertSame(true, Utf8Cleaner::clean(true));
|
||||
$this->assertSame(false, Utf8Cleaner::clean(false));
|
||||
$this->assertNull(Utf8Cleaner::clean(null));
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function leaves_valid_utf8_strings_unchanged(): void
|
||||
{
|
||||
$value = 'Привет, мир! 😀';
|
||||
|
||||
$this->assertSame($value, Utf8Cleaner::clean($value));
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function cleans_strings_with_invalid_utf8_bytes(): void
|
||||
{
|
||||
// Строка с заведомо некорректной UTF-8 последовательностью.
|
||||
$invalid = "\xF0\x28\x8C\x28";
|
||||
|
||||
$cleaned = Utf8Cleaner::clean($invalid);
|
||||
|
||||
$this->assertIsString($cleaned);
|
||||
$this->assertNotSame($invalid, $cleaned);
|
||||
|
||||
$json = json_encode($cleaned);
|
||||
$this->assertNotFalse($json, 'Resulting string must be valid UTF-8 for json_encode');
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function cleans_nested_arrays_recursively(): void
|
||||
{
|
||||
$invalid = "\xF0\x28\x8C\x28";
|
||||
|
||||
$data = [
|
||||
'foo' => 'bar',
|
||||
'nested' => [
|
||||
'value' => $invalid,
|
||||
],
|
||||
];
|
||||
|
||||
$cleaned = Utf8Cleaner::clean($data);
|
||||
|
||||
$this->assertIsArray($cleaned);
|
||||
$this->assertSame('bar', $cleaned['foo']);
|
||||
$this->assertIsString($cleaned['nested']['value']);
|
||||
$this->assertNotSame($invalid, $cleaned['nested']['value']);
|
||||
|
||||
$json = json_encode($cleaned);
|
||||
$this->assertNotFalse($json);
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function cleans_objects_recursively(): void
|
||||
{
|
||||
$invalid = "\xF0\x28\x8C\x28";
|
||||
|
||||
$object = new class {
|
||||
public string $name = 'test';
|
||||
public array $meta = [];
|
||||
};
|
||||
|
||||
$object->meta['invalid'] = $invalid;
|
||||
|
||||
$cleaned = Utf8Cleaner::clean($object);
|
||||
|
||||
$this->assertSame('test', $cleaned->name);
|
||||
$this->assertIsString($cleaned->meta['invalid']);
|
||||
$this->assertNotSame($invalid, $cleaned->meta['invalid']);
|
||||
|
||||
$json = json_encode($cleaned);
|
||||
$this->assertNotFalse($json);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,51 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# Создаёт симлинк install.xml в system/ OpenCart внутри контейнера.
|
||||
# OpenCart при «Обновить» (Расширения → Модификации) подхватывает файлы
|
||||
# DIR_SYSTEM . '*.ocmod.xml' и применяет их своей логикой — так же, как на сервере.
|
||||
#
|
||||
# Использование:
|
||||
# ./scripts/apply_ocmod.sh
|
||||
#
|
||||
# Переменные (по умолчанию из docker-compose.yaml):
|
||||
# CONTAINER — сервис/контейнер (по умолчанию web)
|
||||
# TARGET_DIR — каталог system в контейнере (по умолчанию /web/upload/system)
|
||||
# SOURCE — путь к install.xml в контейнере (по умолчанию /module/oc_telegram_shop/ocmod/install.xml)
|
||||
#
|
||||
# После запуска: открой админку → Расширения → Модификации → нажми «Обновить».
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
CONTAINER="${CONTAINER:-web}"
|
||||
TARGET_DIR="${TARGET_DIR:-/web/upload/system}"
|
||||
SOURCE="${SOURCE:-/module/oc_telegram_shop/install.xml}"
|
||||
TARGET="$TARGET_DIR/telecart.ocmod.xml"
|
||||
|
||||
if ! docker compose exec "$CONTAINER" test -f "$SOURCE" 2>/dev/null; then
|
||||
echo "Ошибка: в контейнере $CONTAINER не найден $SOURCE" >&2
|
||||
echo "Проверь, что том ./module примонтирован в контейнер." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! docker compose exec "$CONTAINER" test -d "$TARGET_DIR" 2>/dev/null; then
|
||||
echo "Ошибка: в контейнере $CONTAINER не найден каталог $TARGET_DIR" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
docker compose exec "$CONTAINER" bash -c "
|
||||
if [ -e '$TARGET' ]; then
|
||||
if [ -L '$TARGET' ]; then
|
||||
current=\$(readlink '$TARGET')
|
||||
if [ \"\$current\" = '$SOURCE' ]; then
|
||||
echo 'Симлинк уже указывает на модуль: $TARGET'
|
||||
exit 0
|
||||
fi
|
||||
fi
|
||||
rm -f '$TARGET'
|
||||
fi
|
||||
ln -sf '$SOURCE' '$TARGET'
|
||||
echo 'Симлинк создан в контейнере: $TARGET → $SOURCE'
|
||||
"
|
||||
|
||||
echo ""
|
||||
echo "Дальше: открой админку OpenCart → Расширения → Модификации → нажми «Обновить»."
|
||||
echo "Модификация применится той же логикой, что и при установке на сервере."
|
||||
Reference in New Issue
Block a user