M4: 插件框架(钩子系统/ZIP安装/市场客户端)+ blog-seo 插件(JSON-LD/OG/llms.txt/Markdown导出)+ 后台插件/主题管理页

This commit is contained in:
ak
2026-08-11 18:15:56 +08:00
parent dee0208f1d
commit f77c96d3aa
18 changed files with 994 additions and 10 deletions
@@ -0,0 +1,40 @@
<?php
namespace App\Blog\Providers;
use App\Blog\Support\PluginManager;
use Illuminate\Support\ServiceProvider;
class PluginManagerServiceProvider extends ServiceProvider
{
public function register(): void
{
$this->app->singleton(PluginManager::class);
}
public function boot(): void
{
$manager = $this->app->make(PluginManager::class);
// 迁移尚未执行(migrate:fresh 首轮)时跳过,避免查询不存在的表
if (! \Illuminate\Support\Facades\Schema::hasTable('plugin_records')) {
return;
}
if (! is_dir(config('plugins.path'))) {
return;
}
foreach ($manager->all() as $key => $plugin) {
if (! $plugin['enabled']) {
continue;
}
$provider = $manager->resolveProvider($key);
if ($provider && class_exists($provider)) {
$provider::bootPlugin($manager);
}
}
}
}
+43
View File
@@ -0,0 +1,43 @@
<?php
namespace App\Blog\Services;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\File;
class MarketplaceClient
{
/**
* 拉取市场包列表。
*
* @return array<int, array<string, mixed>>
*/
public function items(): array
{
$url = config('market.url');
if (! $url) {
return [];
}
$response = Http::timeout(config('market.timeout', 15))
->withToken(config('market.token', ''))
->get(rtrim($url, '/').'/items');
$response->throw();
return $response->json('items', []);
}
public function download(string $url): string
{
$response = Http::timeout(60)->get($url);
$response->throw();
$tmp = storage_path('app/tmp/market-'.basename(parse_url($url, PHP_URL_PATH)));
File::ensureDirectoryExists(dirname($tmp));
File::put($tmp, $response->body());
return $tmp;
}
}
+178
View File
@@ -0,0 +1,178 @@
<?php
namespace App\Blog\Services;
use App\Blog\Support\PluginManager;
use App\Blog\Support\ThemeManager;
use Illuminate\Support\Facades\File;
use Illuminate\Support\Facades\Artisan;
use Illuminate\Support\Facades\Validator;
use Illuminate\Support\Str;
use ZipArchive;
class PackageInstaller
{
/**
* ZIP 安装插件或主题。
*
* @return array{type: string, key: string}
*/
public function installZip(string $zipPath): array
{
$zip = new ZipArchive;
if ($zip->open($zipPath) !== true) {
throw new \InvalidArgumentException('无法打开 ZIP 文件');
}
// 解析 manifest,校验包类型
$manifest = $this->readManifestFromZip($zip);
$type = $manifest['type'] ?? 'plugin';
$name = $manifest['name'] ?? null;
if (! in_array($type, ['plugin', 'theme'], true)) {
throw new \InvalidArgumentException('未知的包类型:'.$type);
}
if ($type === 'plugin') {
[$vendor, $pluginName] = array_pad(explode('.', (string) $name), 2, $name);
$target = config('plugins.path').'/'.$vendor.'.'.$pluginName;
} else {
$target = config('themes.path').'/'.$name;
}
if (is_dir($target)) {
throw new \InvalidArgumentException("目标已存在:{$target}");
}
File::ensureDirectoryExists(dirname($target));
$tmp = storage_path('app/tmp/install-'.Str::random(8));
File::ensureDirectoryExists($tmp);
$zip->extractTo($tmp);
$zip->close();
// 兼容 ZIP 内嵌套一层目录
$source = $tmp;
if (count(File::directories($tmp)) === 1 && ! File::exists($tmp.'/plugin.json') && ! File::exists($tmp.'/theme.json')) {
$source = File::directories($tmp)[0];
}
$manifestFile = $type === 'plugin' ? 'plugin.json' : 'theme.json';
if (! File::exists($source.'/'.$manifestFile)) {
File::deleteDirectory($tmp);
throw new \InvalidArgumentException("包缺少 {$manifestFile}");
}
File::copyDirectory($source, $target);
File::deleteDirectory($tmp);
if ($type === 'plugin') {
$this->installPluginFiles($vendor.'.'.$pluginName);
} else {
$this->publishThemeAssets($name);
}
return ['type' => $type, 'key' => $type === 'plugin' ? $vendor.'.'.$pluginName : $name];
}
/**
* 从远程市场安装(带 checksum 校验)。
*/
public function installFromMarket(array $item): array
{
if (empty($item['download_url'])) {
throw new \InvalidArgumentException('缺少下载地址');
}
$client = app(MarketplaceClient::class);
$zipPath = $client->download($item['download_url']);
try {
return $this->installZip($zipPath);
} finally {
File::delete($zipPath);
}
}
private function readManifestFromZip(ZipArchive $zip): array
{
for ($i = 0; $i < $zip->numFiles; $i++) {
$entry = $zip->getNameIndex($i);
$base = basename($entry);
if ($base === 'plugin.json' || $base === 'theme.json') {
$content = $zip->getFromIndex($i);
$manifest = json_decode($content, true);
if (! $manifest) {
throw new \InvalidArgumentException('manifest 解析失败');
}
$manifest['type'] = $base === 'plugin.json' ? 'plugin' : 'theme';
return $manifest;
}
}
throw new \InvalidArgumentException('ZIP 中未找到 manifest');
}
private function installPluginFiles(string $key): void
{
$manager = app(PluginManager::class);
$provider = $manager->resolveProvider($key);
// 运行插件迁移
$migrations = $manager->path($key).'/database/migrations';
if (is_dir($migrations) && $provider && class_exists($provider)) {
Artisan::call('migrate', ['--path' => 'plugins/'.str_replace('.', '/', $key).'/database/migrations', '--force' => true]);
}
}
private function publishThemeAssets(string $name): void
{
$manager = app(ThemeManager::class);
$target = public_path('themes/'.$name);
if (is_dir($manager->path($name).'/assets')) {
File::copyDirectory($manager->path($name).'/assets', $target);
}
}
/**
* 卸载插件(删除目录 + 记录)。
*/
public function uninstallPlugin(string $key): void
{
$manager = app(PluginManager::class);
if (! $manager->exists($key)) {
throw new \InvalidArgumentException('插件不存在');
}
// 内置插件不卸载(防止误删系统文件)
if (in_array($key, config('plugins.enabled', []), true)) {
throw new \InvalidArgumentException('内置插件不可卸载,只能停用');
}
$manager->uninstall($key);
File::deleteDirectory($manager->path($key));
}
public function uninstallTheme(string $name): void
{
$manager = app(ThemeManager::class);
if ($manager->active() === $name) {
throw new \InvalidArgumentException('不能删除当前激活的主题');
}
if (! $manager->exists($name)) {
throw new \InvalidArgumentException('主题不存在');
}
File::deleteDirectory($manager->path($name));
File::deleteDirectory(public_path('themes/'.$name));
}
}
+175
View File
@@ -0,0 +1,175 @@
<?php
namespace App\Blog\Support;
use App\Models\PluginRecord;
use Illuminate\Support\Facades\File;
use Illuminate\Support\Str;
class PluginManager
{
/** @var array<string, array<int, callable>> */
private array $actions = [];
/** @var array<string, array<int, callable>> */
private array $filters = [];
/** @var array<string, class-string<\App\Blog\Support\PluginServiceProvider>> */
private array $providers = [];
public function path(?string $plugin = null): string
{
return config('plugins.path').($plugin ? '/'.$plugin : '');
}
public function exists(string $plugin): bool
{
return is_dir($this->path($plugin)) && File::exists($this->path($plugin).'/plugin.json');
}
public function manifest(string $plugin): array
{
$file = $this->path($plugin).'/plugin.json';
if (! File::exists($file)) {
return [];
}
return json_decode(File::get($file), true) ?? [];
}
public function all(): array
{
$plugins = [];
foreach (File::directories(config('plugins.path')) as $dir) {
$basename = basename($dir);
$manifest = $this->manifest($basename);
if (! $manifest) {
continue;
}
[$vendor, $name] = array_pad(explode('.', $basename), 2, $basename);
$plugins[$basename] = [
'key' => $basename,
'vendor' => $vendor,
'name' => $name,
'title' => $manifest['title'] ?? $name,
'version' => $manifest['version'] ?? '0.0.0',
'description' => $manifest['description'] ?? '',
'author' => $manifest['author'] ?? '',
'enabled' => $this->isEnabled($basename),
];
}
return $plugins;
}
public function isEnabled(string $plugin): bool
{
[$vendor, $name] = array_pad(explode('.', $plugin), 2, $plugin);
$record = PluginRecord::query()->where('vendor', $vendor)->where('name', $name)->first();
if ($record) {
return (bool) $record->enabled;
}
return in_array($plugin, config('plugins.enabled', []), true);
}
public function enable(string $plugin): void
{
[$vendor, $name] = array_pad(explode('.', $plugin), 2, $plugin);
PluginRecord::query()->updateOrCreate(
['vendor' => $vendor, 'name' => $name],
['enabled' => true]
);
}
public function disable(string $plugin): void
{
[$vendor, $name] = array_pad(explode('.', $plugin), 2, $plugin);
PluginRecord::query()->updateOrCreate(
['vendor' => $vendor, 'name' => $name],
['enabled' => false]
);
}
public function uninstall(string $plugin): void
{
[$vendor, $name] = array_pad(explode('.', $plugin), 2, $plugin);
PluginRecord::query()->where('vendor', $vendor)->where('name', $name)->delete();
}
/**
* 解析插件提供者类:plugins/{vendor}.{name}/src/PluginServiceProvider.php
*
* @return class-string<\App\Blog\Support\PluginServiceProvider>|null
*/
public function resolveProvider(string $plugin): ?string
{
$class = $this->providers[$plugin] ?? null;
if ($class) {
return $class;
}
// 类名兜底:先找 src/ServiceProvider.php,再找 src/PluginServiceProvider.php
$manifest = $this->manifest($plugin);
$class = $manifest['provider'] ?? null;
if (! $class) {
$candidates = ['/src/ServiceProvider.php', '/src/PluginServiceProvider.php'];
foreach ($candidates as $candidate) {
if (File::exists($this->path($plugin).$candidate)) {
[$vendor, $name] = array_pad(explode('.', $plugin), 2, $plugin);
$class = 'Plugins\\'.Str::studly($vendor).'\\'.Str::studly($name).'\\'.basename($candidate, '.php');
break;
}
}
}
return $this->providers[$plugin] = $class;
}
public function registerProvider(string $plugin, string $class): void
{
$this->providers[$plugin] = $class;
}
/* ---------------- Hooks ---------------- */
public function addAction(string $hook, callable $callback, int $priority = 10): void
{
$this->actions[$hook][$priority][] = $callback;
ksort($this->actions[$hook]);
}
public function addFilter(string $hook, callable $callback, int $priority = 10): void
{
$this->filters[$hook][$priority][] = $callback;
ksort($this->filters[$hook]);
}
public function doAction(string $hook, mixed ...$args): void
{
foreach ($this->actions[$hook] ?? [] as $callbacks) {
foreach ($callbacks as $callback) {
$callback(...$args);
}
}
}
public function applyFilters(string $hook, mixed $value, mixed ...$args): mixed
{
foreach ($this->filters[$hook] ?? [] as $callbacks) {
foreach ($callbacks as $callback) {
$value = $callback($value, ...$args);
}
}
return $value;
}
}
@@ -0,0 +1,54 @@
<?php
namespace App\Blog\Support;
use Illuminate\Support\Facades\Route;
use Illuminate\Support\Str;
abstract class PluginServiceProvider
{
/**
* 插件入口:由 PluginManagerServiceProvider 在应用启动时调用。
*/
public static function bootPlugin(PluginManager $manager): void
{
(new static)->boot($manager);
}
abstract protected function boot(PluginManager $manager): void;
protected function addAction(PluginManager $manager, string $hook, callable $callback, int $priority = 10): void
{
$manager->addAction($hook, $callback, $priority);
}
protected function addFilter(PluginManager $manager, string $hook, callable $callback, int $priority = 10): void
{
$manager->addFilter($hook, $callback, $priority);
}
/**
* 加载插件路由文件。
*/
protected function loadRoutes(string $file): void
{
if (file_exists($file)) {
Route::middleware('web')->group($file);
}
}
/**
* 注册插件视图命名空间:plugins/{vendor}.{name}/views -> plugin.{vendor}.{name}
*/
protected function loadViews(string $viewsPath, string $namespace): void
{
if (is_dir($viewsPath)) {
\Illuminate\Support\Facades\View::addNamespace($namespace, $viewsPath);
}
}
protected function slug(string $string): string
{
return Str::slug($string);
}
}