first commit
This commit is contained in:
222
Xboard/app/Services/Plugin/AbstractPlugin.php
Normal file
222
Xboard/app/Services/Plugin/AbstractPlugin.php
Normal file
@@ -0,0 +1,222 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Plugin;
|
||||
|
||||
use Illuminate\Support\Facades\File;
|
||||
use Illuminate\Support\Str;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
abstract class AbstractPlugin
|
||||
{
|
||||
protected array $config = [];
|
||||
protected string $basePath;
|
||||
protected string $pluginCode;
|
||||
protected string $namespace;
|
||||
|
||||
public function __construct(string $pluginCode)
|
||||
{
|
||||
$this->pluginCode = $pluginCode;
|
||||
$this->namespace = 'Plugin\\' . Str::studly($pluginCode);
|
||||
$reflection = new \ReflectionClass($this);
|
||||
$this->basePath = dirname($reflection->getFileName());
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取插件代码
|
||||
*/
|
||||
public function getPluginCode(): string
|
||||
{
|
||||
return $this->pluginCode;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取插件命名空间
|
||||
*/
|
||||
public function getNamespace(): string
|
||||
{
|
||||
return $this->namespace;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取插件基础路径
|
||||
*/
|
||||
public function getBasePath(): string
|
||||
{
|
||||
return $this->basePath;
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置配置
|
||||
*/
|
||||
public function setConfig(array $config): void
|
||||
{
|
||||
$this->config = $config;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取配置
|
||||
*/
|
||||
public function getConfig(?string $key = null, $default = null): mixed
|
||||
{
|
||||
$config = $this->config;
|
||||
if ($key) {
|
||||
$config = $config[$key] ?? $default;
|
||||
}
|
||||
return $config;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取视图
|
||||
*/
|
||||
protected function view(string $view, array $data = [], array $mergeData = []): \Illuminate\Contracts\View\View
|
||||
{
|
||||
return view(Str::studly($this->pluginCode) . '::' . $view, $data, $mergeData);
|
||||
}
|
||||
|
||||
/**
|
||||
* 注册动作钩子监听器
|
||||
*/
|
||||
protected function listen(string $hook, callable $callback, int $priority = 20): void
|
||||
{
|
||||
HookManager::register($hook, $callback, $priority);
|
||||
}
|
||||
|
||||
/**
|
||||
* 注册过滤器钩子
|
||||
*/
|
||||
protected function filter(string $hook, callable $callback, int $priority = 20): void
|
||||
{
|
||||
HookManager::registerFilter($hook, $callback, $priority);
|
||||
}
|
||||
|
||||
/**
|
||||
* 移除事件监听器
|
||||
*/
|
||||
protected function removeListener(string $hook): void
|
||||
{
|
||||
HookManager::remove($hook);
|
||||
}
|
||||
|
||||
/**
|
||||
* 注册 Artisan 命令
|
||||
*/
|
||||
protected function registerCommand(string $commandClass): void
|
||||
{
|
||||
if (class_exists($commandClass)) {
|
||||
app('Illuminate\Contracts\Console\Kernel')->registerCommand(new $commandClass());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 注册插件命令目录
|
||||
*/
|
||||
public function registerCommands(): void
|
||||
{
|
||||
$commandsPath = $this->basePath . '/Commands';
|
||||
if (File::exists($commandsPath)) {
|
||||
$files = File::glob($commandsPath . '/*.php');
|
||||
foreach ($files as $file) {
|
||||
$className = pathinfo($file, PATHINFO_FILENAME);
|
||||
$commandClass = $this->namespace . '\\Commands\\' . $className;
|
||||
|
||||
if (class_exists($commandClass)) {
|
||||
$this->registerCommand($commandClass);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 中断当前请求并返回新的响应
|
||||
*
|
||||
* @param Response|string|array $response
|
||||
* @return never
|
||||
*/
|
||||
protected function intercept(Response|string|array $response): never
|
||||
{
|
||||
HookManager::intercept($response);
|
||||
}
|
||||
|
||||
/**
|
||||
* 插件启动时调用
|
||||
*/
|
||||
public function boot(): void
|
||||
{
|
||||
// 插件启动时的初始化逻辑
|
||||
}
|
||||
|
||||
/**
|
||||
* 插件安装时调用
|
||||
*/
|
||||
public function install(): void
|
||||
{
|
||||
// 插件安装时的初始化逻辑
|
||||
}
|
||||
|
||||
/**
|
||||
* 插件卸载时调用
|
||||
*/
|
||||
public function cleanup(): void
|
||||
{
|
||||
// 插件卸载时的清理逻辑
|
||||
}
|
||||
|
||||
/**
|
||||
* 插件更新时调用
|
||||
*/
|
||||
public function update(string $oldVersion, string $newVersion): void
|
||||
{
|
||||
// 插件更新时的迁移逻辑
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取插件资源URL
|
||||
*/
|
||||
protected function asset(string $path): string
|
||||
{
|
||||
return asset('plugins/' . $this->pluginCode . '/' . ltrim($path, '/'));
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取插件配置项
|
||||
*/
|
||||
protected function getConfigValue(string $key, $default = null)
|
||||
{
|
||||
return $this->config[$key] ?? $default;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取插件数据库迁移路径
|
||||
*/
|
||||
protected function getMigrationsPath(): string
|
||||
{
|
||||
return $this->basePath . '/database/migrations';
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取插件视图路径
|
||||
*/
|
||||
protected function getViewsPath(): string
|
||||
{
|
||||
return $this->basePath . '/resources/views';
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取插件资源路径
|
||||
*/
|
||||
protected function getAssetsPath(): string
|
||||
{
|
||||
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
|
||||
}
|
||||
}
|
||||
286
Xboard/app/Services/Plugin/HookManager.php
Normal file
286
Xboard/app/Services/Plugin/HookManager.php
Normal file
@@ -0,0 +1,286 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Plugin;
|
||||
|
||||
use Symfony\Component\HttpFoundation\Response as SymfonyResponse;
|
||||
use Illuminate\Support\Facades\App;
|
||||
|
||||
class HookManager
|
||||
{
|
||||
/**
|
||||
* Container for storing action hooks
|
||||
*
|
||||
* Uses request() to store hook data within the cycle to avoid Octane memory leaks
|
||||
*/
|
||||
public static function getActions(): array
|
||||
{
|
||||
if (!App::has('hook.actions')) {
|
||||
App::instance('hook.actions', []);
|
||||
}
|
||||
|
||||
return App::make('hook.actions');
|
||||
}
|
||||
|
||||
/**
|
||||
* Container for storing filter hooks
|
||||
*/
|
||||
public static function getFilters(): array
|
||||
{
|
||||
if (!App::has('hook.filters')) {
|
||||
App::instance('hook.filters', []);
|
||||
}
|
||||
|
||||
return App::make('hook.filters');
|
||||
}
|
||||
|
||||
/**
|
||||
* Set action hooks
|
||||
*/
|
||||
protected static function setActions(array $actions): void
|
||||
{
|
||||
App::instance('hook.actions', $actions);
|
||||
}
|
||||
|
||||
/**
|
||||
* Set filter hooks
|
||||
*/
|
||||
protected static function setFilters(array $filters): void
|
||||
{
|
||||
App::instance('hook.filters', $filters);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 New response content
|
||||
* @return never
|
||||
* @throws \Exception
|
||||
*/
|
||||
public static function intercept(SymfonyResponse|string|array $response): never
|
||||
{
|
||||
if (is_string($response)) {
|
||||
$response = response($response);
|
||||
} elseif (is_array($response)) {
|
||||
$response = response()->json($response);
|
||||
}
|
||||
|
||||
throw new InterceptResponseException($response);
|
||||
}
|
||||
|
||||
/**
|
||||
* Trigger action hook
|
||||
*
|
||||
* @param string $hook Hook name
|
||||
* @param mixed $payload Data passed to hook
|
||||
* @return void
|
||||
*/
|
||||
public static function call(string $hook, mixed $payload = null): void
|
||||
{
|
||||
$actions = self::getActions();
|
||||
|
||||
if (!isset($actions[$hook])) {
|
||||
return;
|
||||
}
|
||||
|
||||
ksort($actions[$hook]);
|
||||
|
||||
foreach ($actions[$hook] as $callbacks) {
|
||||
foreach ($callbacks as $callback) {
|
||||
$callback($payload);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Trigger filter hook
|
||||
*
|
||||
* @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
|
||||
{
|
||||
$filters = self::getFilters();
|
||||
|
||||
if (!isset($filters[$hook])) {
|
||||
return $value;
|
||||
}
|
||||
|
||||
ksort($filters[$hook]);
|
||||
|
||||
$result = $value;
|
||||
foreach ($filters[$hook] as $callbacks) {
|
||||
foreach ($callbacks as $callback) {
|
||||
$result = $callback($result, ...$args);
|
||||
}
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Register action hook listener
|
||||
*
|
||||
* @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
|
||||
{
|
||||
$actions = self::getActions();
|
||||
|
||||
if (!isset($actions[$hook])) {
|
||||
$actions[$hook] = [];
|
||||
}
|
||||
|
||||
if (!isset($actions[$hook][$priority])) {
|
||||
$actions[$hook][$priority] = [];
|
||||
}
|
||||
|
||||
$actions[$hook][$priority][self::getCallableId($callback)] = $callback;
|
||||
|
||||
self::setActions($actions);
|
||||
}
|
||||
|
||||
/**
|
||||
* Register filter hook
|
||||
*
|
||||
* @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
|
||||
{
|
||||
$filters = self::getFilters();
|
||||
|
||||
if (!isset($filters[$hook])) {
|
||||
$filters[$hook] = [];
|
||||
}
|
||||
|
||||
if (!isset($filters[$hook][$priority])) {
|
||||
$filters[$hook][$priority] = [];
|
||||
}
|
||||
|
||||
$filters[$hook][$priority][self::getCallableId($callback)] = $callback;
|
||||
|
||||
self::setFilters($filters);
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove hook listener
|
||||
*
|
||||
* @param string $hook Hook name
|
||||
* @param callable|null $callback Callback function
|
||||
* @return void
|
||||
*/
|
||||
public static function remove(string $hook, ?callable $callback = null): void
|
||||
{
|
||||
$actions = self::getActions();
|
||||
$filters = self::getFilters();
|
||||
|
||||
if ($callback === null) {
|
||||
if (isset($actions[$hook])) {
|
||||
unset($actions[$hook]);
|
||||
self::setActions($actions);
|
||||
}
|
||||
|
||||
if (isset($filters[$hook])) {
|
||||
unset($filters[$hook]);
|
||||
self::setFilters($filters);
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$callbackId = self::getCallableId($callback);
|
||||
|
||||
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]);
|
||||
}
|
||||
}
|
||||
}
|
||||
self::setActions($actions);
|
||||
}
|
||||
|
||||
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]);
|
||||
}
|
||||
}
|
||||
}
|
||||
self::setFilters($filters);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if hook exists
|
||||
*
|
||||
* @param string $hook Hook name
|
||||
* @return bool
|
||||
*/
|
||||
public static function hasHook(string $hook): bool
|
||||
{
|
||||
$actions = self::getActions();
|
||||
$filters = self::getFilters();
|
||||
|
||||
return isset($actions[$hook]) || isset($filters[$hook]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all hooks (called when Octane resets)
|
||||
*/
|
||||
public static function reset(): void
|
||||
{
|
||||
App::instance('hook.actions', []);
|
||||
App::instance('hook.filters', []);
|
||||
}
|
||||
}
|
||||
22
Xboard/app/Services/Plugin/InterceptResponseException.php
Normal file
22
Xboard/app/Services/Plugin/InterceptResponseException.php
Normal file
@@ -0,0 +1,22 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Plugin;
|
||||
|
||||
use Exception;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
class InterceptResponseException extends Exception
|
||||
{
|
||||
protected Response $response;
|
||||
|
||||
public function __construct(Response $response)
|
||||
{
|
||||
parent::__construct('Response intercepted');
|
||||
$this->response = $response;
|
||||
}
|
||||
|
||||
public function getResponse(): Response
|
||||
{
|
||||
return $this->response;
|
||||
}
|
||||
}
|
||||
111
Xboard/app/Services/Plugin/PluginConfigService.php
Normal file
111
Xboard/app/Services/Plugin/PluginConfigService.php
Normal file
@@ -0,0 +1,111 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Plugin;
|
||||
|
||||
use App\Models\Plugin;
|
||||
use Illuminate\Support\Facades\File;
|
||||
|
||||
class PluginConfigService
|
||||
{
|
||||
protected $pluginManager;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->pluginManager = app(PluginManager::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取插件配置
|
||||
*
|
||||
* @param string $pluginCode
|
||||
* @return array
|
||||
*/
|
||||
public function getConfig(string $pluginCode): array
|
||||
{
|
||||
$defaultConfig = $this->getDefaultConfig($pluginCode);
|
||||
if (empty($defaultConfig)) {
|
||||
return [];
|
||||
}
|
||||
$dbConfig = $this->getDbConfig($pluginCode);
|
||||
|
||||
$result = [];
|
||||
foreach ($defaultConfig as $key => $item) {
|
||||
$result[$key] = [
|
||||
'type' => $item['type'],
|
||||
'label' => $item['label'] ?? '',
|
||||
'placeholder' => $item['placeholder'] ?? '',
|
||||
'description' => $item['description'] ?? '',
|
||||
'value' => $dbConfig[$key] ?? $item['default'],
|
||||
'options' => $item['options'] ?? []
|
||||
];
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新插件配置
|
||||
*
|
||||
* @param string $pluginCode
|
||||
* @param array $config
|
||||
* @return bool
|
||||
*/
|
||||
public function updateConfig(string $pluginCode, array $config): bool
|
||||
{
|
||||
$defaultConfig = $this->getDefaultConfig($pluginCode);
|
||||
if (empty($defaultConfig)) {
|
||||
throw new \Exception('插件配置结构不存在');
|
||||
}
|
||||
$values = [];
|
||||
foreach ($config as $key => $value) {
|
||||
if (!isset($defaultConfig[$key])) {
|
||||
continue;
|
||||
}
|
||||
$values[$key] = $value;
|
||||
}
|
||||
Plugin::query()
|
||||
->where('code', $pluginCode)
|
||||
->update([
|
||||
'config' => json_encode($values),
|
||||
'updated_at' => now()
|
||||
]);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取插件默认配置
|
||||
*
|
||||
* @param string $pluginCode
|
||||
* @return array
|
||||
*/
|
||||
protected function getDefaultConfig(string $pluginCode): array
|
||||
{
|
||||
$configFile = $this->pluginManager->getPluginPath($pluginCode) . '/config.json';
|
||||
if (!File::exists($configFile)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$config = json_decode(File::get($configFile), true);
|
||||
return $config['config'] ?? [];
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取数据库中的配置
|
||||
*
|
||||
* @param string $pluginCode
|
||||
* @return array
|
||||
*/
|
||||
public function getDbConfig(string $pluginCode): array
|
||||
{
|
||||
$plugin = Plugin::query()
|
||||
->where('code', $pluginCode)
|
||||
->first();
|
||||
|
||||
if (!$plugin || empty($plugin->config)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return json_decode($plugin->config, true);
|
||||
}
|
||||
}
|
||||
727
Xboard/app/Services/Plugin/PluginManager.php
Normal file
727
Xboard/app/Services/Plugin/PluginManager.php
Normal file
@@ -0,0 +1,727 @@
|
||||
<?php
|
||||
|
||||
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;
|
||||
use Illuminate\Support\Facades\Route;
|
||||
use Illuminate\Support\Facades\Artisan;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class PluginManager
|
||||
{
|
||||
protected string $pluginPath;
|
||||
protected array $loadedPlugins = [];
|
||||
protected bool $pluginsInitialized = false;
|
||||
protected array $configTypesCache = [];
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->pluginPath = base_path('plugins');
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取插件的命名空间
|
||||
*/
|
||||
public function getPluginNamespace(string $pluginCode): string
|
||||
{
|
||||
return 'Plugin\\' . Str::studly($pluginCode);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取插件的基础路径
|
||||
*/
|
||||
public function getPluginPath(string $pluginCode): string
|
||||
{
|
||||
return $this->pluginPath . '/' . Str::studly($pluginCode);
|
||||
}
|
||||
|
||||
/**
|
||||
* 加载插件类
|
||||
*/
|
||||
protected function loadPlugin(string $pluginCode): ?AbstractPlugin
|
||||
{
|
||||
if (isset($this->loadedPlugins[$pluginCode])) {
|
||||
return $this->loadedPlugins[$pluginCode];
|
||||
}
|
||||
|
||||
$pluginClass = $this->getPluginNamespace($pluginCode) . '\\Plugin';
|
||||
|
||||
if (!class_exists($pluginClass)) {
|
||||
$pluginFile = $this->getPluginPath($pluginCode) . '/Plugin.php';
|
||||
if (!File::exists($pluginFile)) {
|
||||
Log::warning("Plugin class file not found: {$pluginFile}");
|
||||
Plugin::query()->where('code', $pluginCode)->delete();
|
||||
return null;
|
||||
}
|
||||
require_once $pluginFile;
|
||||
}
|
||||
|
||||
if (!class_exists($pluginClass)) {
|
||||
Log::error("Plugin class not found: {$pluginClass}");
|
||||
return null;
|
||||
}
|
||||
|
||||
$plugin = new $pluginClass($pluginCode);
|
||||
$this->loadedPlugins[$pluginCode] = $plugin;
|
||||
|
||||
return $plugin;
|
||||
}
|
||||
|
||||
/**
|
||||
* 注册插件的服务提供者
|
||||
*/
|
||||
protected function registerServiceProvider(string $pluginCode): void
|
||||
{
|
||||
$providerClass = $this->getPluginNamespace($pluginCode) . '\\Providers\\PluginServiceProvider';
|
||||
|
||||
if (class_exists($providerClass)) {
|
||||
app()->register($providerClass);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 加载插件的路由
|
||||
*/
|
||||
protected function loadRoutes(string $pluginCode): void
|
||||
{
|
||||
$routesPath = $this->getPluginPath($pluginCode) . '/routes';
|
||||
if (File::exists($routesPath)) {
|
||||
$webRouteFile = $routesPath . '/web.php';
|
||||
$apiRouteFile = $routesPath . '/api.php';
|
||||
if (File::exists($webRouteFile)) {
|
||||
Route::middleware('web')
|
||||
->namespace($this->getPluginNamespace($pluginCode) . '\\Controllers')
|
||||
->group(function () use ($webRouteFile) {
|
||||
require $webRouteFile;
|
||||
});
|
||||
}
|
||||
if (File::exists($apiRouteFile)) {
|
||||
Route::middleware('api')
|
||||
->namespace($this->getPluginNamespace($pluginCode) . '\\Controllers')
|
||||
->group(function () use ($apiRouteFile) {
|
||||
require $apiRouteFile;
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 加载插件的视图
|
||||
*/
|
||||
protected function loadViews(string $pluginCode): void
|
||||
{
|
||||
$viewsPath = $this->getPluginPath($pluginCode) . '/resources/views';
|
||||
if (File::exists($viewsPath)) {
|
||||
View::addNamespace(Str::studly($pluginCode), $viewsPath);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 注册插件命令
|
||||
*/
|
||||
protected function registerPluginCommands(string $pluginCode, AbstractPlugin $pluginInstance): void
|
||||
{
|
||||
try {
|
||||
// 调用插件的命令注册方法
|
||||
$pluginInstance->registerCommands();
|
||||
} catch (\Exception $e) {
|
||||
Log::error("Failed to register commands for plugin '{$pluginCode}': " . $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 安装插件
|
||||
*/
|
||||
public function install(string $pluginCode): bool
|
||||
{
|
||||
$configFile = $this->getPluginPath($pluginCode) . '/config.json';
|
||||
|
||||
if (!File::exists($configFile)) {
|
||||
throw new \Exception('Plugin config file not found');
|
||||
}
|
||||
|
||||
$config = json_decode(File::get($configFile), true);
|
||||
if (!$this->validateConfig($config)) {
|
||||
throw new \Exception('Invalid plugin config');
|
||||
}
|
||||
|
||||
// 检查插件是否已安装
|
||||
if (Plugin::where('code', $pluginCode)->exists()) {
|
||||
throw new \Exception('Plugin already installed');
|
||||
}
|
||||
|
||||
// 检查依赖
|
||||
if (!$this->checkDependencies($config['require'] ?? [])) {
|
||||
throw new \Exception('Dependencies not satisfied');
|
||||
}
|
||||
|
||||
// 运行数据库迁移
|
||||
$this->runMigrations(pluginCode: $pluginCode);
|
||||
|
||||
DB::beginTransaction();
|
||||
try {
|
||||
// 提取配置默认值
|
||||
$defaultValues = $this->extractDefaultConfig($config);
|
||||
|
||||
// 创建插件实例
|
||||
$plugin = $this->loadPlugin($pluginCode);
|
||||
|
||||
// 注册到数据库
|
||||
Plugin::create([
|
||||
'code' => $pluginCode,
|
||||
'name' => $config['name'],
|
||||
'version' => $config['version'],
|
||||
'type' => $config['type'] ?? Plugin::TYPE_FEATURE,
|
||||
'is_enabled' => false,
|
||||
'config' => json_encode($defaultValues),
|
||||
'installed_at' => now(),
|
||||
]);
|
||||
|
||||
// 运行插件安装方法
|
||||
if (method_exists($plugin, 'install')) {
|
||||
$plugin->install();
|
||||
}
|
||||
|
||||
// 发布插件资源
|
||||
$this->publishAssets($pluginCode);
|
||||
|
||||
DB::commit();
|
||||
return true;
|
||||
} catch (\Exception $e) {
|
||||
if (DB::transactionLevel() > 0) {
|
||||
DB::rollBack();
|
||||
}
|
||||
throw $e;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 提取插件默认配置
|
||||
*/
|
||||
protected function extractDefaultConfig(array $config): array
|
||||
{
|
||||
$defaultValues = [];
|
||||
if (isset($config['config']) && is_array($config['config'])) {
|
||||
foreach ($config['config'] as $key => $item) {
|
||||
if (is_array($item)) {
|
||||
$defaultValues[$key] = $item['default'] ?? null;
|
||||
} else {
|
||||
$defaultValues[$key] = $item;
|
||||
}
|
||||
}
|
||||
}
|
||||
return $defaultValues;
|
||||
}
|
||||
|
||||
/**
|
||||
* 运行插件数据库迁移
|
||||
*/
|
||||
protected function runMigrations(string $pluginCode): void
|
||||
{
|
||||
$migrationsPath = $this->getPluginPath($pluginCode) . '/database/migrations';
|
||||
|
||||
if (File::exists($migrationsPath)) {
|
||||
Artisan::call('migrate', [
|
||||
'--path' => "plugins/" . Str::studly($pluginCode) . "/database/migrations",
|
||||
'--force' => true
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 回滚插件数据库迁移
|
||||
*/
|
||||
protected function runMigrationsRollback(string $pluginCode): void
|
||||
{
|
||||
$migrationsPath = $this->getPluginPath($pluginCode) . '/database/migrations';
|
||||
|
||||
if (File::exists($migrationsPath)) {
|
||||
Artisan::call('migrate:rollback', [
|
||||
'--path' => "plugins/" . Str::studly($pluginCode) . "/database/migrations",
|
||||
'--force' => true
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 发布插件资源
|
||||
*/
|
||||
protected function publishAssets(string $pluginCode): void
|
||||
{
|
||||
$assetsPath = $this->getPluginPath($pluginCode) . '/resources/assets';
|
||||
if (File::exists($assetsPath)) {
|
||||
$publishPath = public_path('plugins/' . $pluginCode);
|
||||
File::ensureDirectoryExists($publishPath);
|
||||
File::copyDirectory($assetsPath, $publishPath);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证配置文件
|
||||
*/
|
||||
protected function validateConfig(array $config): bool
|
||||
{
|
||||
$requiredFields = [
|
||||
'name',
|
||||
'code',
|
||||
'version',
|
||||
'description',
|
||||
'author'
|
||||
];
|
||||
|
||||
foreach ($requiredFields as $field) {
|
||||
if (!isset($config[$field]) || empty($config[$field])) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// 验证插件代码格式
|
||||
if (!preg_match('/^[a-z0-9_]+$/', $config['code'])) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 验证版本号格式
|
||||
if (!preg_match('/^\d+\.\d+\.\d+$/', $config['version'])) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 验证插件类型
|
||||
if (isset($config['type'])) {
|
||||
$validTypes = ['feature', 'payment'];
|
||||
if (!in_array($config['type'], $validTypes)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 启用插件
|
||||
*/
|
||||
public function enable(string $pluginCode): bool
|
||||
{
|
||||
$plugin = $this->loadPlugin($pluginCode);
|
||||
|
||||
if (!$plugin) {
|
||||
Plugin::where('code', $pluginCode)->delete();
|
||||
throw new \Exception('Plugin not found: ' . $pluginCode);
|
||||
}
|
||||
|
||||
// 获取插件配置
|
||||
$dbPlugin = Plugin::query()
|
||||
->where('code', $pluginCode)
|
||||
->first();
|
||||
|
||||
if ($dbPlugin && !empty($dbPlugin->config)) {
|
||||
$values = json_decode($dbPlugin->config, true) ?: [];
|
||||
$values = $this->castConfigValuesByType($pluginCode, $values);
|
||||
$plugin->setConfig($values);
|
||||
}
|
||||
|
||||
// 注册服务提供者
|
||||
$this->registerServiceProvider($pluginCode);
|
||||
|
||||
// 加载路由
|
||||
$this->loadRoutes($pluginCode);
|
||||
|
||||
// 加载视图
|
||||
$this->loadViews($pluginCode);
|
||||
|
||||
// 更新数据库状态
|
||||
Plugin::query()
|
||||
->where('code', $pluginCode)
|
||||
->update([
|
||||
'is_enabled' => true,
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
// 初始化插件
|
||||
$plugin->boot();
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 禁用插件
|
||||
*/
|
||||
public function disable(string $pluginCode): bool
|
||||
{
|
||||
$plugin = $this->loadPlugin($pluginCode);
|
||||
if (!$plugin) {
|
||||
throw new \Exception('Plugin not found');
|
||||
}
|
||||
|
||||
Plugin::query()
|
||||
->where('code', $pluginCode)
|
||||
->update([
|
||||
'is_enabled' => false,
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
|
||||
$plugin->cleanup();
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 卸载插件
|
||||
*/
|
||||
public function uninstall(string $pluginCode): bool
|
||||
{
|
||||
$this->disable($pluginCode);
|
||||
$this->runMigrationsRollback($pluginCode);
|
||||
Plugin::query()->where('code', $pluginCode)->delete();
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除插件
|
||||
*
|
||||
* @param string $pluginCode
|
||||
* @return bool
|
||||
* @throws \Exception
|
||||
*/
|
||||
public function delete(string $pluginCode): bool
|
||||
{
|
||||
// 先卸载插件
|
||||
if (Plugin::where('code', $pluginCode)->exists()) {
|
||||
$this->uninstall($pluginCode);
|
||||
}
|
||||
|
||||
$pluginPath = $this->getPluginPath($pluginCode);
|
||||
if (!File::exists($pluginPath)) {
|
||||
throw new \Exception('插件不存在');
|
||||
}
|
||||
|
||||
// 删除插件目录
|
||||
File::deleteDirectory($pluginPath);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查依赖关系
|
||||
*/
|
||||
protected function checkDependencies(array $requires): bool
|
||||
{
|
||||
foreach ($requires as $package => $version) {
|
||||
if ($package === 'xboard') {
|
||||
// 检查xboard版本
|
||||
// 实现版本比较逻辑
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 升级插件
|
||||
*
|
||||
* @param string $pluginCode
|
||||
* @return bool
|
||||
* @throws \Exception
|
||||
*/
|
||||
public function update(string $pluginCode): bool
|
||||
{
|
||||
$dbPlugin = Plugin::where('code', $pluginCode)->first();
|
||||
if (!$dbPlugin) {
|
||||
throw new \Exception('Plugin not installed: ' . $pluginCode);
|
||||
}
|
||||
|
||||
// 获取插件配置文件中的最新版本
|
||||
$configFile = $this->getPluginPath($pluginCode) . '/config.json';
|
||||
if (!File::exists($configFile)) {
|
||||
throw new \Exception('Plugin config file not found');
|
||||
}
|
||||
|
||||
$config = json_decode(File::get($configFile), true);
|
||||
if (!$config || !isset($config['version'])) {
|
||||
throw new \Exception('Invalid plugin config or missing version');
|
||||
}
|
||||
|
||||
$newVersion = $config['version'];
|
||||
$oldVersion = $dbPlugin->version;
|
||||
|
||||
if (version_compare($newVersion, $oldVersion, '<=')) {
|
||||
throw new \Exception('Plugin is already up to date');
|
||||
}
|
||||
|
||||
$this->disable($pluginCode);
|
||||
$this->runMigrations($pluginCode);
|
||||
|
||||
$plugin = $this->loadPlugin($pluginCode);
|
||||
if ($plugin) {
|
||||
if (!empty($dbPlugin->config)) {
|
||||
$values = json_decode($dbPlugin->config, true) ?: [];
|
||||
$values = $this->castConfigValuesByType($pluginCode, $values);
|
||||
$plugin->setConfig($values);
|
||||
}
|
||||
|
||||
$plugin->update($oldVersion, $newVersion);
|
||||
}
|
||||
|
||||
$dbPlugin->update([
|
||||
'version' => $newVersion,
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
|
||||
$this->enable($pluginCode);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 上传插件
|
||||
*
|
||||
* @param \Illuminate\Http\UploadedFile $file
|
||||
* @return bool
|
||||
* @throws \Exception
|
||||
*/
|
||||
public function upload($file): bool
|
||||
{
|
||||
$tmpPath = storage_path('tmp/plugins');
|
||||
if (!File::exists($tmpPath)) {
|
||||
File::makeDirectory($tmpPath, 0755, true);
|
||||
}
|
||||
|
||||
$extractPath = $tmpPath . '/' . uniqid();
|
||||
$zip = new \ZipArchive();
|
||||
|
||||
if ($zip->open($file->path()) !== true) {
|
||||
throw new \Exception('无法打开插件包文件');
|
||||
}
|
||||
|
||||
$zip->extractTo($extractPath);
|
||||
$zip->close();
|
||||
|
||||
$configFile = File::glob($extractPath . '/*/config.json');
|
||||
if (empty($configFile)) {
|
||||
$configFile = File::glob($extractPath . '/config.json');
|
||||
}
|
||||
|
||||
if (empty($configFile)) {
|
||||
File::deleteDirectory($extractPath);
|
||||
throw new \Exception('插件包格式错误:缺少配置文件');
|
||||
}
|
||||
|
||||
$pluginPath = dirname(reset($configFile));
|
||||
$config = json_decode(File::get($pluginPath . '/config.json'), true);
|
||||
|
||||
if (!$this->validateConfig($config)) {
|
||||
File::deleteDirectory($extractPath);
|
||||
throw new \Exception('插件配置文件格式错误');
|
||||
}
|
||||
|
||||
$targetPath = $this->pluginPath . '/' . Str::studly($config['code']);
|
||||
if (File::exists($targetPath)) {
|
||||
$installedConfigPath = $targetPath . '/config.json';
|
||||
if (!File::exists($installedConfigPath)) {
|
||||
throw new \Exception('已安装插件缺少配置文件,无法判断是否可升级');
|
||||
}
|
||||
$installedConfig = json_decode(File::get($installedConfigPath), true);
|
||||
|
||||
$oldVersion = $installedConfig['version'] ?? null;
|
||||
$newVersion = $config['version'] ?? null;
|
||||
if (!$oldVersion || !$newVersion) {
|
||||
throw new \Exception('插件缺少版本号,无法判断是否可升级');
|
||||
}
|
||||
if (version_compare($newVersion, $oldVersion, '<=')) {
|
||||
throw new \Exception('上传插件版本不高于已安装版本,无法升级');
|
||||
}
|
||||
|
||||
File::deleteDirectory($targetPath);
|
||||
}
|
||||
|
||||
File::copyDirectory($pluginPath, $targetPath);
|
||||
File::deleteDirectory($pluginPath);
|
||||
File::deleteDirectory($extractPath);
|
||||
|
||||
if (Plugin::where('code', $config['code'])->exists()) {
|
||||
return $this->update($config['code']);
|
||||
}
|
||||
|
||||
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)) {
|
||||
$values = json_decode($dbPlugin->config, true) ?: [];
|
||||
$values = $this->castConfigValuesByType($pluginCode, $values);
|
||||
$pluginInstance->setConfig($values);
|
||||
}
|
||||
|
||||
$this->registerServiceProvider($pluginCode);
|
||||
$this->loadRoutes($pluginCode);
|
||||
$this->loadViews($pluginCode);
|
||||
$this->registerPluginCommands($pluginCode, $pluginInstance);
|
||||
|
||||
$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)) {
|
||||
$values = json_decode($dbPlugin->config, true) ?: [];
|
||||
$values = $this->castConfigValuesByType($dbPlugin->code, $values);
|
||||
$pluginInstance->setConfig($values);
|
||||
}
|
||||
$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<AbstractPlugin>
|
||||
*/
|
||||
public function getEnabledPlugins(): array
|
||||
{
|
||||
$this->initializeEnabledPlugins();
|
||||
|
||||
$enabledPluginCodes = Plugin::where('is_enabled', true)
|
||||
->pluck('code')
|
||||
->all();
|
||||
|
||||
return array_intersect_key($this->loadedPlugins, array_flip($enabledPluginCodes));
|
||||
}
|
||||
|
||||
/**
|
||||
* Get enabled plugins by type
|
||||
*/
|
||||
public function getEnabledPluginsByType(string $type): array
|
||||
{
|
||||
$this->initializeEnabledPlugins();
|
||||
|
||||
$enabledPluginCodes = Plugin::where('is_enabled', true)
|
||||
->byType($type)
|
||||
->pluck('code')
|
||||
->all();
|
||||
|
||||
return array_intersect_key($this->loadedPlugins, array_flip($enabledPluginCodes));
|
||||
}
|
||||
|
||||
/**
|
||||
* Get enabled payment plugins
|
||||
*/
|
||||
public function getEnabledPaymentPlugins(): array
|
||||
{
|
||||
return $this->getEnabledPluginsByType('payment');
|
||||
}
|
||||
|
||||
/**
|
||||
* install default plugins
|
||||
*/
|
||||
public static function installDefaultPlugins(): void
|
||||
{
|
||||
foreach (Plugin::PROTECTED_PLUGINS as $pluginCode) {
|
||||
if (!Plugin::where('code', $pluginCode)->exists()) {
|
||||
$pluginManager = app(self::class);
|
||||
$pluginManager->install($pluginCode);
|
||||
$pluginManager->enable($pluginCode);
|
||||
Log::info("Installed and enabled default plugin: {$pluginCode}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据 config.json 的类型信息对配置值进行类型转换(仅处理 type=json 键)。
|
||||
*/
|
||||
protected function castConfigValuesByType(string $pluginCode, array $values): array
|
||||
{
|
||||
$types = $this->getConfigTypes($pluginCode);
|
||||
foreach ($values as $key => $value) {
|
||||
$type = $types[$key] ?? null;
|
||||
|
||||
if ($type === 'json') {
|
||||
if (is_array($value)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (is_string($value) && $value !== '') {
|
||||
$decoded = json_decode($value, true);
|
||||
if (json_last_error() === JSON_ERROR_NONE) {
|
||||
$values[$key] = $decoded;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return $values;
|
||||
}
|
||||
|
||||
/**
|
||||
* 读取并缓存插件 config.json 中的键类型映射。
|
||||
*/
|
||||
protected function getConfigTypes(string $pluginCode): array
|
||||
{
|
||||
if (isset($this->configTypesCache[$pluginCode])) {
|
||||
return $this->configTypesCache[$pluginCode];
|
||||
}
|
||||
$types = [];
|
||||
$configFile = $this->getPluginPath($pluginCode) . '/config.json';
|
||||
if (File::exists($configFile)) {
|
||||
$config = json_decode(File::get($configFile), true);
|
||||
$fields = $config['config'] ?? [];
|
||||
foreach ($fields as $key => $meta) {
|
||||
$types[$key] = is_array($meta) ? ($meta['type'] ?? 'string') : 'string';
|
||||
}
|
||||
}
|
||||
$this->configTypesCache[$pluginCode] = $types;
|
||||
return $types;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user