From 3c24c250e585ecd2062614b1ff12664c894cbe1c Mon Sep 17 00:00:00 2001 From: root Date: Tue, 13 Jan 2026 12:08:40 +0300 Subject: [PATCH] add EventManager and Subscription --- app/Config/Events.php | 19 + app/Config/Services.php | 36 ++ ...3-163701_AddTrialEndsAtToSubscriptions.php | 33 ++ app/Services/EventManager.php | 239 +++++++++++ app/Services/ModuleSubscriptionService.php | 394 ++++++++++++++++++ docs/EventManager.txt | 64 +++ 6 files changed, 785 insertions(+) create mode 100644 app/Database/Migrations/2026-01-13-163701_AddTrialEndsAtToSubscriptions.php create mode 100644 app/Services/EventManager.php create mode 100644 app/Services/ModuleSubscriptionService.php create mode 100644 docs/EventManager.txt diff --git a/app/Config/Events.php b/app/Config/Events.php index 946285b..17f84b2 100644 --- a/app/Config/Events.php +++ b/app/Config/Events.php @@ -53,3 +53,22 @@ Events::on('pre_system', static function (): void { } } }); + +/* + * -------------------------------------------------------------------- + * Module Events Registration + * -------------------------------------------------------------------- + * Здесь можно регистрировать события модулей с проверкой подписок. + * Для подписки на события с проверкой статуса модуля используйте: + * + * $em = service('eventManager'); + * $em->forModule('crm')->moduleOn('user.created', function($user) { + * // Этот код выполнится только если модуль CRM активен + * }); + * + * Для системных событий без проверки: + * + * $em->systemOn('db.query', function($query) { + * // Этот код выполнится всегда + * }); + */ diff --git a/app/Config/Services.php b/app/Config/Services.php index ede6022..725b287 100644 --- a/app/Config/Services.php +++ b/app/Config/Services.php @@ -87,4 +87,40 @@ class Services extends BaseService return null; } } + + /** + * Сервис для управления подписками на модули + * + * Предоставляет методы для проверки активности модулей, + * управления пробными периодами и проверки доступа к функциональности. + * + * @param bool $getShared + * @return \App\Services\ModuleSubscriptionService + */ + public static function moduleSubscription(bool $getShared = true) + { + if ($getShared) { + return static::getSharedInstance('moduleSubscription'); + } + + return new ModuleSubscriptionService(); + } + + /** + * Сервис для управления событиями с проверкой подписок на модули + * + * Предоставляет методы для подписки на события с проверкой + * статуса подписки на модуль (moduleOn) и без проверки (systemOn). + * + * @param bool $getShared + * @return \App\Services\EventManager + */ + public static function eventManager(bool $getShared = true) + { + if ($getShared) { + return static::getSharedInstance('eventManager'); + } + + return new EventManager(); + } } diff --git a/app/Database/Migrations/2026-01-13-163701_AddTrialEndsAtToSubscriptions.php b/app/Database/Migrations/2026-01-13-163701_AddTrialEndsAtToSubscriptions.php new file mode 100644 index 0000000..d22dc38 --- /dev/null +++ b/app/Database/Migrations/2026-01-13-163701_AddTrialEndsAtToSubscriptions.php @@ -0,0 +1,33 @@ +forge->addColumn('organization_subscriptions', [ + 'trial_ends_at' => [ + 'type' => 'DATETIME', + 'null' => true, + 'after' => 'status', + 'comment' => 'Дата окончания триального периода', + ], + ]); + + // Обновляем существующие триальные подписки (14 дней от created_at) + $this->db->query(" + UPDATE organization_subscriptions + SET trial_ends_at = DATE_ADD(created_at, INTERVAL 14 DAY) + WHERE status = 'trial' AND trial_ends_at IS NULL + "); + } + + public function down() + { + $this->forge->dropColumn('organization_subscriptions', 'trial_ends_at'); + } +} diff --git a/app/Services/EventManager.php b/app/Services/EventManager.php new file mode 100644 index 0000000..7379654 --- /dev/null +++ b/app/Services/EventManager.php @@ -0,0 +1,239 @@ +moduleSubscriptionService === null) { + $this->moduleSubscriptionService = service('moduleSubscription'); + } + + return $this->moduleSubscriptionService; + } + + /** + * Получить конфигурацию модулей + * + * @return \Config\BusinessModules + */ + private function getModulesConfig(): \Config\BusinessModules + { + if ($this->modulesConfig === null) { + $this->modulesConfig = new \Config\BusinessModules(); + } + + return $this->modulesConfig; + } + + /** + * Привязать события к конкретному модулю + * + * Все последующие вызовы moduleOn() будут проверять + * подписку на указанный модуль. + * + * @param string $moduleCode Код модуля + * @return $this + */ + public function forModule(string $moduleCode): self + { + $this->moduleCode = $moduleCode; + $this->moduleActive = null; + + return $this; + } + + /** + * Проверить, активен ли модуль + * + * @return bool + */ + private function isModuleActive(): bool + { + if ($this->moduleCode === null) { + return false; + } + + if ($this->moduleActive === null) { + $orgId = session('org_id') ?? null; + $this->moduleActive = $this->getModuleSubscriptionService() + ->isModuleActive($this->moduleCode, $orgId); + } + + return $this->moduleActive; + } + + /** + * Подписаться на событие с проверкой подписки на модуль + * + * Обработчик будет выполнен только если: + * 1. Модуль глобально включен в конфигурации + * 2. У организации есть активная подписка на модуль + * + * @param string $event Имя события + * @param callable $callback Обработчик события + * @param int $priority Приоритет события (по умолчанию 100) + * @return bool True если подписка создана, False если модуль не активен + */ + public function moduleOn( + string $event, + callable $callback, + int $priority = 100 + ): bool { + if ($this->moduleCode === null) { + throw new \RuntimeException( + 'Module code not set. Use forModule() method first.' + ); + } + + $modulesConfig = $this->getModulesConfig(); + + // Проверяем, что модуль существует в конфигурации + if (!isset($modulesConfig->modules[$this->moduleCode])) { + log_message( + 'error', + "EventManager: Module '{$this->moduleCode}' not found in config" + ); + return false; + } + + // Если модуль отключен глобально, не подписываемся + if (empty($modulesConfig->modules[$this->moduleCode]['enabled'])) { + log_message( + 'info', + "EventManager: Module '{$this->moduleCode}' is disabled globally" + ); + return false; + } + + // Проверяем подписку организации + if (!$this->isModuleActive()) { + log_message( + 'debug', + "EventManager: Organization subscription not active for module '{$this->moduleCode}'" + ); + return false; + } + + // Подписываемся на событие + Events::on($event, $callback, $priority); + + log_message( + 'debug', + "EventManager: Subscribed to event '{$event}' for module '{$this->moduleCode}'" + ); + + return true; + } + + /** + * Подписаться на событие без проверки подписки + * + * Обработчик будет выполнен всегда, независимо + * от статуса подписки на модуль. + * + * Используется для системных событий, которые должны + * работать для всех организаций. + * + * @param string $event Имя события + * @param callable $callback Обработчик события + * @param int $priority Приоритет события (по умолчанию 100) + * @return void + */ + public function systemOn( + string $event, + callable $callback, + int $priority = 100 + ): void { + Events::on($event, $callback, $priority); + + log_message( + 'debug', + "EventManager: System event subscribed: '{$event}'" + ); + } + + /** + * Отписаться от события + * + * @param string $event Имя события + * @param callable|null $callback Конкретный обработчик (если null - все обработчики) + * @return void + */ + public function off(string $event, ?callable $callback = null): void + { + if ($callback === null) { + Events::off($event); + } else { + Events::off($event, $callback); + } + } + + /** + * Проверить, активен ли текущий модуль + * + * Удобный метод для использования внутри обработчиков событий. + * + * @return bool + */ + public function currentModuleActive(): bool + { + return $this->isModuleActive(); + } + + /** + * Получить код текущего модуля + * + * @return string|null + */ + public function getCurrentModuleCode(): ?string + { + return $this->moduleCode; + } +} diff --git a/app/Services/ModuleSubscriptionService.php b/app/Services/ModuleSubscriptionService.php new file mode 100644 index 0000000..8028f15 --- /dev/null +++ b/app/Services/ModuleSubscriptionService.php @@ -0,0 +1,394 @@ +subscriptionModel = $subscriptionModel ?? new OrganizationSubscriptionModel(); + $this->modulesConfig = $modulesConfig ?? config('BusinessModules'); + } + + /** + * Получение активного ID организации из сессии + */ + protected function getActiveOrgId(): ?int + { + if ($this->activeOrgId === null) { + $this->activeOrgId = session()->get('active_org_id') ?? 0; + } + + return $this->activeOrgId ?: null; + } + + /** + * Проверка активности модуля для текущей организации + * + * @param string $moduleCode Код модуля (crm, booking, tasks, proof) + * @param int|null $organizationId ID организации (null = из сессии) + * @return bool + */ + public function isModuleActive(string $moduleCode, ?int $organizationId = null): bool + { + $orgId = $organizationId ?? $this->getActiveOrgId(); + + if (!$orgId) { + return false; + } + + // Базовый модуль всегда активен + if ($moduleCode === 'base') { + return true; + } + + return $this->subscriptionModel->isModuleActive($orgId, $moduleCode); + } + + /** + * Проверка что модуль доступен (активен или в триале) + * + * @param string $moduleCode + * @param int|null $organizationId + * @return bool + */ + public function isModuleAvailable(string $moduleCode, ?int $organizationId = null): bool + { + $orgId = $organizationId ?? $this->getActiveOrgId(); + + if (!$orgId) { + return false; + } + + // Базовый модуль всегда доступен + if ($moduleCode === 'base') { + return true; + } + + // Проверяем подписку + $subscription = $this->subscriptionModel->getSubscription($orgId, $moduleCode); + + if (!$subscription) { + return false; + } + + // Активен или в триале + return in_array($subscription['status'], ['trial', 'active'], true); + } + + /** + * Проверка что модуль в триальном периоде + * + * @param string $moduleCode + * @param int|null $organizationId + * @return bool + */ + public function isInTrial(string $moduleCode, ?int $organizationId = null): bool + { + // Базовый модуль не имеет триала + if ($moduleCode === 'base') { + return false; + } + + $orgId = $organizationId ?? $this->getActiveOrgId(); + + if (!$orgId) { + return false; + } + + return $this->subscriptionModel->isInTrial($orgId, $moduleCode); + } + + /** + * Проверка что триал истекает скоро (для показа уведомлений) + * + * @param string $moduleCode + * @param int $daysThreshold Порог в днях + * @param int|null $organizationId + * @return bool + */ + public function isTrialExpiringSoon( + string $moduleCode, + int $daysThreshold = 3, + ?int $organizationId = null + ): bool { + if (!$this->isInTrial($moduleCode, $organizationId)) { + return false; + } + + $orgId = $organizationId ?? $this->getActiveOrgId(); + $daysLeft = $this->subscriptionModel->getDaysUntilExpire($orgId, $moduleCode); + + return $daysLeft !== null && $daysLeft <= $daysThreshold && $daysLeft > 0; + } + + /** + * Получение дней до окончания подписки/триала + * + * @param string $moduleCode + * @param int|null $organizationId + * @return int|null Количество дней или null если не активна + */ + public function getDaysUntilExpire(string $moduleCode, ?int $organizationId = null): ?int + { + $orgId = $organizationId ?? $this->getActiveOrgId(); + + if (!$orgId) { + return null; + } + + return $this->subscriptionModel->getDaysUntilExpire($orgId, $moduleCode); + } + + /** + * Получение информации о модуле из конфигурации + * + * @param string $moduleCode + * @return array|null + */ + public function getModuleInfo(string $moduleCode): ?array + { + return $this->modulesConfig->getModule($moduleCode); + } + + /** + * Получение всех доступных модулей для организации + * + * @param int|null $organizationId + * @return array Список кодов модулей + */ + public function getActiveModules(?int $organizationId = null): array + { + $orgId = $organizationId ?? $this->getActiveOrgId(); + + if (!$orgId) { + return ['base']; + } + + $activeModules = $this->subscriptionModel->getActiveModules($orgId); + + // Всегда добавляем базовый модуль + $activeModules[] = 'base'; + + return array_unique($activeModules); + } + + /** + * Запуск триального периода для модуля + * + * @param string $moduleCode + * @param int|null $organizationId + * @param int $trialDays + * @return bool + */ + public function startTrial( + string $moduleCode, + ?int $organizationId = null, + int $trialDays = 14 + ): bool { + $orgId = $organizationId ?? $this->getActiveOrgId(); + + if (!$orgId) { + return false; + } + + // Проверяем что модуль существует + if (!$this->modulesConfig->exists($moduleCode)) { + return false; + } + + // Проверяем что модуль платный + $moduleInfo = $this->modulesConfig->getModule($moduleCode); + if (!$moduleInfo || $moduleInfo['trial_days'] <= 0) { + return false; + } + + // Проверяем что триал ещё не был использован + if ($this->subscriptionModel->isInTrial($orgId, $moduleCode)) { + return false; // Уже в триале + } + + // Проверяем что нет активной подписки + if ($this->isModuleActive($moduleCode, $orgId)) { + return false; // Уже активна + } + + return (bool) $this->subscriptionModel->startTrial( + $orgId, + $moduleCode, + $moduleInfo['trial_days'] + ); + } + + /** + * Активация платной подписки (для ручного включения или после платежа) + * + * @param string $moduleCode + * @param int $months + * @param int|null $organizationId + * @return bool + */ + public function activate( + string $moduleCode, + int $months = 1, + ?int $organizationId = null + ): bool { + $orgId = $organizationId ?? $this->getActiveOrgId(); + + if (!$orgId) { + return false; + } + + // Проверяем что модуль существует + if (!$this->modulesConfig->exists($moduleCode)) { + return false; + } + + return $this->subscriptionModel->activate($orgId, $moduleCode, $months); + } + + /** + * Отмена подписки + * + * @param string $moduleCode + * @param int|null $organizationId + * @return bool + */ + public function cancel(string $moduleCode, ?int $organizationId = null): bool + { + $orgId = $organizationId ?? $this->getActiveOrgId(); + + if (!$orgId) { + return false; + } + + return $this->subscriptionModel->cancel($orgId, $moduleCode); + } + + /** + * Проверка доступа к функции модуля + * + * @param string $moduleCode + * @param string $feature Опционально - конкретная фича + * @return bool + */ + public function canUseModule(string $moduleCode, string $feature = ''): bool + { + if (!$this->isModuleActive($moduleCode)) { + return false; + } + + // Для триальных версий могут быть ограничения на некоторые фичи + if ($this->isInTrial($moduleCode)) { + // Проверяем доступность фичи в триале + // (можно расширить через конфигурацию) + } + + return true; + } + + /** + * Получение статуса подписки для отображения в UI + * + * @param string $moduleCode + * @param int|null $organizationId + * @return array + */ + public function getSubscriptionStatus( + string $moduleCode, + ?int $organizationId = null + ): array { + $orgId = $organizationId ?? $this->getActiveOrgId(); + + $moduleInfo = $this->modulesConfig->getModule($moduleCode); + $subscription = $orgId + ? $this->subscriptionModel->getSubscription($orgId, $moduleCode) + : null; + + $status = 'unavailable'; + $daysLeft = null; + $message = ''; + + if ($moduleCode === 'base') { + $status = 'active'; + $message = 'Базовый модуль'; + } elseif (!$subscription) { + if ($moduleInfo && $moduleInfo['trial_days'] > 0) { + $status = 'trial_available'; + $message = 'Доступен триал ' . $moduleInfo['trial_days'] . ' дней'; + } else { + $status = 'locked'; + $message = 'Приобретите модуль'; + } + } elseif ($subscription['status'] === 'trial') { + $daysLeft = $this->subscriptionModel->getDaysUntilExpire($orgId, $moduleCode); + if ($daysLeft && $daysLeft > 0) { + $status = 'trial'; + $message = "Триал: {$daysLeft} дн."; + } else { + $status = 'expired'; + $message = 'Триал истёк'; + } + } elseif ($subscription['status'] === 'active') { + $daysLeft = $this->subscriptionModel->getDaysUntilExpire($orgId, $moduleCode); + $status = 'active'; + $message = $daysLeft ? "Осталось {$daysLeft} дн." : 'Активна'; + } elseif (in_array($subscription['status'], ['expired', 'cancelled'])) { + $status = 'expired'; + $message = 'Подписка завершена'; + } + + return [ + 'status' => $status, + 'message' => $message, + 'days_left' => $daysLeft, + 'module' => $moduleInfo, + 'subscription' => $subscription, + ]; + } + + /** + * Получение цены модуля + * + * @param string $moduleCode + * @param string $period + * @return int + */ + public function getPrice(string $moduleCode, string $period = 'monthly'): int + { + return $this->modulesConfig->getPrice($moduleCode, $period); + } + + /** + * Проверка что пользователь может управлять подписками организации + * + * @param int|null $organizationId + * @return bool + */ + public function canManageSubscriptions(?int $organizationId = null): bool + { + $orgId = $organizationId ?? $this->getActiveOrgId(); + + if (!$orgId) { + return false; + } + + // Проверяем права через AccessService + return service('access')->canManageModules(); + } +} diff --git a/docs/EventManager.txt b/docs/EventManager.txt new file mode 100644 index 0000000..52afa1b --- /dev/null +++ b/docs/EventManager.txt @@ -0,0 +1,64 @@ +Примеры использования событийной системы + +Для создания интеграций между модулями, которые должны работать только при наличии активной подписки, используется метод moduleOn(). Ниже приведен пример инициализации событий в файле модуля, например в app/Modules/Crm/Config/Events.php: + +php + +forModule('crm'); + +// При создании нового клиента отправляем приветственное письмо +$em->moduleOn('clients.created', function($client) { + $emailService = service('email'); + $emailService->sendWelcomeEmail($client->email, $client->name); +}); + +// При изменении статуса сделки обновляем метрики +$em->moduleOn('deals.status_changed', function($deal, $oldStatus, $newStatus) { + service('analytics')->trackDealStatusChange($deal->id, $oldStatus, $newStatus); +}); + +// Логируем все действия с клиентами +$em->moduleOn('clients.*', function($event, $data) { + service('audit')->log('crm_client_activity', $data); +}); + + +Для системных событий, которые должны выполняться всегда независимо от статуса подписки, используется метод systemOn(). Такие события подходят для сквозной функциональности, например, логирования, аудита или сбора аналитики: + +php + +systemOn('DBQuery', function($query) { + if (ENVIRONMENT === 'development') { + log_message('debug', 'DB Query: ' . $query); + } +}); + +// Системное событие для записи активности пользователя +$em->systemOn('user.login', function($user) { + service('activityLogger')->logLogin($user->id); +}); + + +Архитектурные преимущества решения + +Разделение событий на два типа обеспечивает гибкость при проектировании интеграций между модулями. События типа moduleOn() гарантируют, что бизнес-логика модуля выполняется только для организаций, которые оплатили доступ к этому модулю, что защищает коммерческие интересы и предотвращает несанкционированное использование функциональности. События типа systemOn() позволяют реализовывать сквозную функциональность, которая должна присутствовать в системе независимо от того, какие модули оплачены организацией, например, общие уведомления, аудит безопасности или интеграция с внешними системами мониторинга. + +Кэширование результата проверки статуса модуля в рамках одного запроса обеспечивает высокую производительность событийной системы. При множественных подписках на события одного модуля проверка подписки выполняется только один раз, а затем результат кэшируется в свойстве $moduleActive. \ No newline at end of file