Files
Xboard/app/Services/Plugin/PluginManager.php

727 lines
22 KiB
PHP

<?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;
}
}