From c9bab8fb024aec65a4438c59eab1656576b175e3 Mon Sep 17 00:00:00 2001 From: xboard Date: Mon, 21 Jul 2025 13:29:17 +0800 Subject: [PATCH] feat: add multiple hooks, pligun schedule support ,add hook:list artisan command --- app/Console/Commands/HookList.php | 42 +++++++ app/Console/Kernel.php | 8 +- .../V1/Guest/PaymentController.php | 7 +- .../Controllers/V1/User/TicketController.php | 3 + app/Http/Middleware/InitializePlugins.php | 31 ++--- app/Models/Plugin.php | 13 +++ app/Services/Auth/LoginService.php | 4 + app/Services/Auth/RegisterService.php | 5 + app/Services/OrderService.php | 2 + app/Services/Plugin/AbstractPlugin.php | 13 ++- app/Services/Plugin/HookManager.php | 107 ++++++++++-------- app/Services/Plugin/PluginManager.php | 101 +++++++++++++++-- app/Services/ServerService.php | 4 +- app/Services/ThemeService.php | 2 +- app/Services/TicketService.php | 2 + app/Services/TrafficResetService.php | 2 + 16 files changed, 271 insertions(+), 75 deletions(-) create mode 100644 app/Console/Commands/HookList.php diff --git a/app/Console/Commands/HookList.php b/app/Console/Commands/HookList.php new file mode 100644 index 0000000..cec2bfd --- /dev/null +++ b/app/Console/Commands/HookList.php @@ -0,0 +1,42 @@ +filter(fn($f) => Str::endsWith($f, '.php')); + foreach ($files as $file) { + $content = @file_get_contents($file); + if ($content && preg_match_all($pattern, $content, $matches)) { + foreach ($matches[2] as $hook) { + $hooks->push($hook); + } + } + } + } + $hooks = $hooks->unique()->sort()->values(); + if ($hooks->isEmpty()) { + $this->info('未扫描到任何 hook'); + } else { + $this->info('All Supported Hooks:'); + foreach ($hooks as $hook) { + $this->line(' ' . $hook); + } + } + } +} \ No newline at end of file diff --git a/app/Console/Kernel.php b/app/Console/Kernel.php index 19e3f14..43880dd 100644 --- a/app/Console/Kernel.php +++ b/app/Console/Kernel.php @@ -2,6 +2,7 @@ namespace App\Console; +use App\Services\Plugin\PluginManager; use App\Utils\CacheKey; use Illuminate\Console\Scheduling\Schedule; use Illuminate\Foundation\Console\Kernel as ConsoleKernel; @@ -25,7 +26,7 @@ class Kernel extends ConsoleKernel * @param \Illuminate\Console\Scheduling\Schedule $schedule * @return void */ - protected function schedule(Schedule $schedule) + protected function schedule(Schedule $schedule): void { Cache::put(CacheKey::get('SCHEDULE_LAST_CHECK_AT', null), time()); // v2board @@ -48,7 +49,10 @@ class Kernel extends ConsoleKernel // 每分钟清理过期的在线状态 $schedule->call(function () { app(UserOnlineService::class)->cleanExpiredOnlineStatus(); - })->everyMinute(); + })->everyMinute()->name('cleanup:expired-online-status')->onOneServer(); + + app(PluginManager::class)->registerPluginSchedules($schedule); + } /** diff --git a/app/Http/Controllers/V1/Guest/PaymentController.php b/app/Http/Controllers/V1/Guest/PaymentController.php index b7cf604..3cee1a4 100644 --- a/app/Http/Controllers/V1/Guest/PaymentController.php +++ b/app/Http/Controllers/V1/Guest/PaymentController.php @@ -10,16 +10,21 @@ use App\Services\OrderService; use App\Services\PaymentService; use App\Services\TelegramService; use Illuminate\Http\Request; +use App\Services\Plugin\HookManager; class PaymentController extends Controller { public function notify($method, $uuid, Request $request) { + HookManager::call('payment.notify.before', [$method, $uuid, $request]); try { $paymentService = new PaymentService($method, null, $uuid); $verify = $paymentService->notify($request->input()); - if (!$verify) + if (!$verify) { + HookManager::call('payment.notify.failed', [$method, $uuid, $request]); return $this->fail([422, 'verify error']); + } + HookManager::call('payment.notify.verified', $verify); if (!$this->handle($verify['trade_no'], $verify['callback_no'])) { return $this->fail([400, 'handle error']); } diff --git a/app/Http/Controllers/V1/User/TicketController.php b/app/Http/Controllers/V1/User/TicketController.php index ff93250..97b02c5 100644 --- a/app/Http/Controllers/V1/User/TicketController.php +++ b/app/Http/Controllers/V1/User/TicketController.php @@ -14,6 +14,7 @@ use App\Services\TicketService; use App\Utils\Dict; use Illuminate\Http\Request; use Illuminate\Support\Facades\DB; +use App\Services\Plugin\HookManager; class TicketController extends Controller { @@ -65,6 +66,7 @@ class TicketController extends Controller throw new \Exception(__('Failed to open ticket')); } DB::commit(); + HookManager::call('ticket.create.after', $ticket); $this->sendNotify($ticket, $request->input('message'), $request->user()->id); return $this->success(true); }catch(\Exception $e){ @@ -103,6 +105,7 @@ class TicketController extends Controller )) { return $this->fail([400, __('Ticket reply failed')]); } + HookManager::call('ticket.reply.user.after', [$ticket, $this->getLastMessage($ticket->id)]); $this->sendNotify($ticket, $request->input('message'), $request->user()->id); return $this->success(true); } diff --git a/app/Http/Middleware/InitializePlugins.php b/app/Http/Middleware/InitializePlugins.php index 7e74992..0c5ae8d 100644 --- a/app/Http/Middleware/InitializePlugins.php +++ b/app/Http/Middleware/InitializePlugins.php @@ -2,35 +2,36 @@ namespace App\Http\Middleware; -use App\Models\Plugin; use App\Services\Plugin\PluginManager; use Closure; use Illuminate\Http\Request; -use Illuminate\Support\Facades\Log; +/** + * Middleware to initialize all enabled plugins at the beginning of a request. + * It ensures that all plugin hooks, routes, and services are ready. + */ class InitializePlugins { - protected $pluginManager; + protected PluginManager $pluginManager; public function __construct(PluginManager $pluginManager) { $this->pluginManager = $pluginManager; } + /** + * Handle an incoming request. + * + * @param \Illuminate\Http\Request $request + * @param \Closure $next + * @return mixed + */ public function handle(Request $request, Closure $next) { - try { - $plugins = Plugin::query() - ->where('is_enabled', true) - ->get(); - - foreach ($plugins as $plugin) { - $this->pluginManager->enable($plugin->code); - } - } catch (\Exception $e) { - Log::error('Failed to load plugins: ' . $e->getMessage()); - } + // This single method call handles loading and booting all enabled plugins. + // It's safe to call multiple times, as it will only run once per request. + $this->pluginManager->initializeEnabledPlugins(); return $next($request); } -} \ No newline at end of file +} \ No newline at end of file diff --git a/app/Models/Plugin.php b/app/Models/Plugin.php index 36b2469..01898c2 100644 --- a/app/Models/Plugin.php +++ b/app/Models/Plugin.php @@ -4,6 +4,19 @@ namespace App\Models; use Illuminate\Database\Eloquent\Model; +/** + * @property int $id + * @property string $code + * @property string $name + * @property string $description + * @property string $version + * @property string $author + * @property string $url + * @property string $email + * @property string $license + * @property string $requires + * @property string $config + */ class Plugin extends Model { protected $table = 'v2_plugins'; diff --git a/app/Services/Auth/LoginService.php b/app/Services/Auth/LoginService.php index 1799d2a..ca21a9d 100644 --- a/app/Services/Auth/LoginService.php +++ b/app/Services/Auth/LoginService.php @@ -3,6 +3,7 @@ namespace App\Services\Auth; use App\Models\User; +use App\Services\Plugin\HookManager; use App\Utils\CacheKey; use App\Utils\Helper; use Illuminate\Support\Facades\Cache; @@ -70,6 +71,7 @@ class LoginService $user->last_login_at = time(); $user->save(); + HookManager::call('user.login.after', $user); return [true, $user]; } @@ -111,6 +113,8 @@ class LoginService return [false, [500, __('Reset failed')]]; } + HookManager::call('user.password.reset.after', $user); + // 清除邮箱验证码 Cache::forget(CacheKey::get('EMAIL_VERIFY_CODE', $email)); diff --git a/app/Services/Auth/RegisterService.php b/app/Services/Auth/RegisterService.php index 55f01c9..c55aea8 100644 --- a/app/Services/Auth/RegisterService.php +++ b/app/Services/Auth/RegisterService.php @@ -6,6 +6,7 @@ use App\Models\InviteCode; use App\Models\Plan; use App\Models\User; use App\Services\CaptchaService; +use App\Services\Plugin\HookManager; use App\Services\UserService; use App\Utils\CacheKey; use App\Utils\Dict; @@ -141,6 +142,8 @@ class RegisterService return [false, $error]; } + HookManager::call('user.register.before', $request); + $email = $request->input('email'); $password = $request->input('password'); $inviteCode = $request->input('invite_code'); @@ -164,6 +167,8 @@ class RegisterService return [false, [500, __('Register failed')]]; } + HookManager::call('user.register.after', $user); + // 清除邮箱验证码 if ((int) admin_setting('email_verify', 0)) { Cache::forget(CacheKey::get('EMAIL_VERIFY_CODE', $email)); diff --git a/app/Services/OrderService.php b/app/Services/OrderService.php index 620ac1f..e69fa7e 100644 --- a/app/Services/OrderService.php +++ b/app/Services/OrderService.php @@ -304,6 +304,7 @@ class OrderService public function cancel(): bool { $order = $this->order; + HookManager::call('order.cancel.before', $order); try { DB::beginTransaction(); $order->status = Order::STATUS_CANCELLED; @@ -317,6 +318,7 @@ class OrderService } } DB::commit(); + HookManager::call('order.cancel.after', $order); return true; } catch (\Exception $e) { DB::rollBack(); diff --git a/app/Services/Plugin/AbstractPlugin.php b/app/Services/Plugin/AbstractPlugin.php index ca242a7..9b8701f 100644 --- a/app/Services/Plugin/AbstractPlugin.php +++ b/app/Services/Plugin/AbstractPlugin.php @@ -129,7 +129,7 @@ abstract class AbstractPlugin /** * 插件卸载时调用 */ - public function uninstall(): void + public function cleanup(): void { // 插件卸载时的清理逻辑 } @@ -181,4 +181,15 @@ abstract class AbstractPlugin { return $this->basePath . '/resources/assets'; } + + /** + * Register plugin scheduled tasks. Plugins can override this method. + * + * @param \Illuminate\Console\Scheduling\Schedule $schedule + * @return void + */ + public function schedule(\Illuminate\Console\Scheduling\Schedule $schedule): void + { + // Plugin can override this method to register scheduled tasks + } } \ No newline at end of file diff --git a/app/Services/Plugin/HookManager.php b/app/Services/Plugin/HookManager.php index a4fbd02..5b6e9af 100644 --- a/app/Services/Plugin/HookManager.php +++ b/app/Services/Plugin/HookManager.php @@ -8,11 +8,11 @@ use Illuminate\Support\Facades\App; class HookManager { /** - * 存储动作钩子的容器 + * Container for storing action hooks * - * 使用request()存储周期内的钩子数据,避免Octane内存泄漏 + * Uses request() to store hook data within the cycle to avoid Octane memory leaks */ - protected static function getActions(): array + public static function getActions(): array { if (!App::has('hook.actions')) { App::instance('hook.actions', []); @@ -22,9 +22,9 @@ class HookManager } /** - * 存储过滤器钩子的容器 + * Container for storing filter hooks */ - protected static function getFilters(): array + public static function getFilters(): array { if (!App::has('hook.filters')) { App::instance('hook.filters', []); @@ -34,7 +34,7 @@ class HookManager } /** - * 设置动作钩子 + * Set action hooks */ protected static function setActions(array $actions): void { @@ -42,7 +42,7 @@ class HookManager } /** - * 设置过滤器钩子 + * Set filter hooks */ protected static function setFilters(array $filters): void { @@ -50,9 +50,38 @@ class HookManager } /** - * 拦截响应 + * Generate unique identifier for callback + * + * @param callable $callback + * @return string + */ + protected static function getCallableId(callable $callback): string + { + if (is_object($callback)) { + return spl_object_hash($callback); + } + + if (is_array($callback) && count($callback) === 2) { + [$class, $method] = $callback; + + if (is_object($class)) { + return spl_object_hash($class) . '::' . $method; + } else { + return (string) $class . '::' . $method; + } + } + + if (is_string($callback)) { + return $callback; + } + + return 'callable_' . uniqid(); + } + + /** + * Intercept response * - * @param SymfonyResponse|string|array $response 新的响应内容 + * @param SymfonyResponse|string|array $response New response content * @return never * @throws \Exception */ @@ -68,10 +97,10 @@ class HookManager } /** - * 触发动作钩子 + * Trigger action hook * - * @param string $hook 钩子名称 - * @param mixed $payload 传递给钩子的数据 + * @param string $hook Hook name + * @param mixed $payload Data passed to hook * @return void */ public static function call(string $hook, mixed $payload = null): void @@ -82,7 +111,6 @@ class HookManager return; } - // 按优先级排序 ksort($actions[$hook]); foreach ($actions[$hook] as $callbacks) { @@ -93,11 +121,11 @@ class HookManager } /** - * 触发过滤器钩子 + * Trigger filter hook * - * @param string $hook 钩子名称 - * @param mixed $value 要过滤的值 - * @param mixed ...$args 其他参数 + * @param string $hook Hook name + * @param mixed $value Value to filter + * @param mixed ...$args Other parameters * @return mixed */ public static function filter(string $hook, mixed $value, mixed ...$args): mixed @@ -108,7 +136,6 @@ class HookManager return $value; } - // 按优先级排序 ksort($filters[$hook]); $result = $value; @@ -122,11 +149,11 @@ class HookManager } /** - * 注册动作钩子监听器 + * Register action hook listener * - * @param string $hook 钩子名称 - * @param callable $callback 回调函数 - * @param int $priority 优先级 + * @param string $hook Hook name + * @param callable $callback Callback function + * @param int $priority Priority * @return void */ public static function register(string $hook, callable $callback, int $priority = 20): void @@ -141,18 +168,17 @@ class HookManager $actions[$hook][$priority] = []; } - // 使用随机键存储回调,避免相同优先级覆盖 - $actions[$hook][$priority][spl_object_hash($callback)] = $callback; + $actions[$hook][$priority][self::getCallableId($callback)] = $callback; self::setActions($actions); } /** - * 注册过滤器钩子 + * Register filter hook * - * @param string $hook 钩子名称 - * @param callable $callback 回调函数 - * @param int $priority 优先级 + * @param string $hook Hook name + * @param callable $callback Callback function + * @param int $priority Priority * @return void */ public static function registerFilter(string $hook, callable $callback, int $priority = 20): void @@ -167,17 +193,16 @@ class HookManager $filters[$hook][$priority] = []; } - // 使用随机键存储回调,避免相同优先级覆盖 - $filters[$hook][$priority][spl_object_hash($callback)] = $callback; + $filters[$hook][$priority][self::getCallableId($callback)] = $callback; self::setFilters($filters); } /** - * 移除钩子监听器 + * Remove hook listener * - * @param string $hook 钩子名称 - * @param callable|null $callback 回调函数 + * @param string $hook Hook name + * @param callable|null $callback Callback function * @return void */ public static function remove(string $hook, ?callable $callback = null): void @@ -185,7 +210,6 @@ class HookManager $actions = self::getActions(); $filters = self::getFilters(); - // 如果回调为null,直接移除整个钩子 if ($callback === null) { if (isset($actions[$hook])) { unset($actions[$hook]); @@ -200,21 +224,17 @@ class HookManager return; } - // 移除特定回调 - $callbackId = spl_object_hash($callback); + $callbackId = self::getCallableId($callback); - // 从actions中移除 if (isset($actions[$hook])) { foreach ($actions[$hook] as $priority => $callbacks) { if (isset($callbacks[$callbackId])) { unset($actions[$hook][$priority][$callbackId]); - // 如果优先级下没有回调了,删除该优先级 if (empty($actions[$hook][$priority])) { unset($actions[$hook][$priority]); } - // 如果钩子下没有任何优先级了,删除该钩子 if (empty($actions[$hook])) { unset($actions[$hook]); } @@ -223,18 +243,15 @@ class HookManager self::setActions($actions); } - // 从filters中移除 if (isset($filters[$hook])) { foreach ($filters[$hook] as $priority => $callbacks) { if (isset($callbacks[$callbackId])) { unset($filters[$hook][$priority][$callbackId]); - // 如果优先级下没有回调了,删除该优先级 if (empty($filters[$hook][$priority])) { unset($filters[$hook][$priority]); } - // 如果钩子下没有任何优先级了,删除该钩子 if (empty($filters[$hook])) { unset($filters[$hook]); } @@ -245,9 +262,9 @@ class HookManager } /** - * 检查是否存在钩子 + * Check if hook exists * - * @param string $hook 钩子名称 + * @param string $hook Hook name * @return bool */ public static function hasHook(string $hook): bool @@ -259,7 +276,7 @@ class HookManager } /** - * 清理所有钩子(在Octane重置时调用) + * Clear all hooks (called when Octane resets) */ public static function reset(): void { diff --git a/app/Services/Plugin/PluginManager.php b/app/Services/Plugin/PluginManager.php index ac0e6c1..7ab72e6 100644 --- a/app/Services/Plugin/PluginManager.php +++ b/app/Services/Plugin/PluginManager.php @@ -3,6 +3,7 @@ namespace App\Services\Plugin; use App\Models\Plugin; +use Illuminate\Console\Scheduling\Schedule; use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\File; use Illuminate\Support\Facades\View; @@ -16,6 +17,7 @@ class PluginManager { protected string $pluginPath; protected array $loadedPlugins = []; + protected bool $pluginsInitialized = false; public function __construct() { @@ -41,7 +43,7 @@ class PluginManager /** * 加载插件类 */ - protected function loadPlugin(string $pluginCode) + protected function loadPlugin(string $pluginCode): ?AbstractPlugin { if (isset($this->loadedPlugins[$pluginCode])) { return $this->loadedPlugins[$pluginCode]; @@ -298,9 +300,7 @@ class PluginManager 'updated_at' => now(), ]); // 初始化插件 - if (method_exists($plugin, 'boot')) { - $plugin->boot(); - } + $plugin->boot(); return true; } @@ -315,7 +315,6 @@ class PluginManager throw new \Exception('Plugin not found'); } - // 更新数据库状态 Plugin::query() ->where('code', $pluginCode) ->update([ @@ -323,10 +322,7 @@ class PluginManager 'updated_at' => now(), ]); - // 清理插件 - if (method_exists($plugin, 'cleanup')) { - $plugin->cleanup(); - } + $plugin->cleanup(); return true; } @@ -452,4 +448,91 @@ class PluginManager return true; } + + /** + * Initializes all enabled plugins from the database. + * This method ensures that plugins are loaded, and their routes, views, + * and service providers are registered only once per request cycle. + */ + public function initializeEnabledPlugins(): void + { + if ($this->pluginsInitialized) { + return; + } + + $enabledPlugins = Plugin::where('is_enabled', true)->get(); + + foreach ($enabledPlugins as $dbPlugin) { + try { + $pluginCode = $dbPlugin->code; + + $pluginInstance = $this->loadPlugin($pluginCode); + if (!$pluginInstance) { + continue; + } + + if (!empty($dbPlugin->config)) { + $pluginInstance->setConfig(json_decode($dbPlugin->config, true)); + } + + $this->registerServiceProvider($pluginCode); + $this->loadRoutes($pluginCode); + $this->loadViews($pluginCode); + + $pluginInstance->boot(); + + } catch (\Exception $e) { + Log::error("Failed to initialize plugin '{$dbPlugin->code}': " . $e->getMessage()); + } + } + + $this->pluginsInitialized = true; + } + + /** + * Register scheduled tasks for all enabled plugins. + * Called from Console Kernel. Only loads main plugin class and config for scheduling. + * Avoids full HTTP/plugin boot overhead. + * + * @param \Illuminate\Console\Scheduling\Schedule $schedule + */ + public function registerPluginSchedules(Schedule $schedule): void + { + Plugin::where('is_enabled', true) + ->get() + ->each(function ($dbPlugin) use ($schedule) { + try { + $pluginInstance = $this->loadPlugin($dbPlugin->code); + if (!$pluginInstance) { + return; + } + if (!empty($dbPlugin->config)) { + $pluginInstance->setConfig(json_decode($dbPlugin->config, true)); + } + $pluginInstance->schedule($schedule); + + } catch (\Exception $e) { + Log::error("Failed to register schedule for plugin '{$dbPlugin->code}': " . $e->getMessage()); + } + }); + } + + /** + * Get all enabled plugin instances. + * + * This method ensures that all enabled plugins are initialized and then returns them. + * It's the central point for accessing active plugins. + * + * @return array + */ + public function getEnabledPlugins(): array + { + $this->initializeEnabledPlugins(); + + $enabledPluginCodes = Plugin::where('is_enabled', true) + ->pluck('code') + ->all(); + + return array_intersect_key($this->loadedPlugins, array_flip($enabledPluginCodes)); + } } \ No newline at end of file diff --git a/app/Services/ServerService.php b/app/Services/ServerService.php index 15eb63e..f1066b0 100644 --- a/app/Services/ServerService.php +++ b/app/Services/ServerService.php @@ -5,6 +5,7 @@ namespace App\Services; use App\Models\Server; use App\Models\ServerRoute; use App\Models\User; +use App\Services\Plugin\HookManager; use App\Utils\Helper; use Illuminate\Support\Collection; @@ -66,7 +67,7 @@ class ServerService */ public static function getAvailableUsers(array $groupIds) { - return User::toBase() + $users = User::toBase() ->whereIn('group_id', $groupIds) ->whereRaw('u + d < transfer_enable') ->where(function ($query) { @@ -81,6 +82,7 @@ class ServerService 'device_limit' ]) ->get(); + return HookManager::filter('server.users.get', $users, $groupIds); } // 获取路由规则 diff --git a/app/Services/ThemeService.php b/app/Services/ThemeService.php index b0c5537..666712c 100644 --- a/app/Services/ThemeService.php +++ b/app/Services/ThemeService.php @@ -388,7 +388,7 @@ class ThemeService } catch (Exception $e) { Log::error('Failed to refresh current theme', [ - 'theme' => $currentTheme ?? 'unknown', + 'theme' => $currentTheme, 'error' => $e->getMessage() ]); return false; diff --git a/app/Services/TicketService.php b/app/Services/TicketService.php index 11cbed1..8efdfc4 100644 --- a/app/Services/TicketService.php +++ b/app/Services/TicketService.php @@ -9,6 +9,7 @@ use App\Models\TicketMessage; use App\Models\User; use Illuminate\Support\Facades\Cache; use Illuminate\Support\Facades\DB; +use App\Services\Plugin\HookManager; class TicketService { public function reply($ticket, $message, $userId) @@ -60,6 +61,7 @@ class TicketService { throw new ApiException('工单回复失败'); } DB::commit(); + HookManager::call('ticket.reply.admin.after', [$ticket, $ticketMessage]); }catch(\Exception $e){ DB::rollBack(); throw $e; diff --git a/app/Services/TrafficResetService.php b/app/Services/TrafficResetService.php index 9426130..e9040d9 100644 --- a/app/Services/TrafficResetService.php +++ b/app/Services/TrafficResetService.php @@ -9,6 +9,7 @@ use Carbon\Carbon; use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Log; use Illuminate\Support\Facades\Cache; +use App\Services\Plugin\HookManager; /** * Service for handling traffic reset. @@ -60,6 +61,7 @@ class TrafficResetService ]); $this->clearUserCache($user); + HookManager::call('traffic.reset.after', $user); return true; }); } catch (\Exception $e) {