feat: 插件依赖强制校验 + 文章后台表单/表格注入 hook
- PluginManager: requires 依赖链校验(enable 需依赖已启用,disable 阻止有启用依赖者的插件,boot 跳过依赖未满足插件),all() 输出依赖状态供后台展示 - Plugins 页:卡片显示依赖链徽章,依赖未满足时禁用启用按钮,启停失败弹错误通知 - PostResource: form()/table() 增加 filament.post_form / filament.post_table 注入点 - membership 插件:注入「付费设置」Section(meta.price 单篇价格 + meta.members_only 会员专享)与表格价格/会员列
This commit is contained in:
@@ -28,6 +28,11 @@ class PluginManagerServiceProvider extends ServiceProvider
|
||||
continue;
|
||||
}
|
||||
|
||||
// 依赖未满足的插件跳过加载,避免运行时缺底层能力
|
||||
if ($manager->dependencyProblems($key)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$provider = $manager->resolveProvider($key);
|
||||
|
||||
if ($provider && class_exists($provider)) {
|
||||
|
||||
@@ -8,6 +8,7 @@ namespace App\Blog\Support;
|
||||
use App\Models\PluginRecord;
|
||||
use Illuminate\Support\Facades\File;
|
||||
use Illuminate\Support\Str;
|
||||
use RuntimeException;
|
||||
|
||||
class PluginManager
|
||||
{
|
||||
@@ -54,6 +55,25 @@ class PluginManager
|
||||
|
||||
[$vendor, $name] = array_pad(explode('.', $basename), 2, $basename);
|
||||
|
||||
$requires = $manifest['requires'] ?? [];
|
||||
$dependencies = [];
|
||||
$dependencyErrors = [];
|
||||
|
||||
foreach ($requires as $req) {
|
||||
$installed = $this->exists($req);
|
||||
$enabled = $installed && $this->isEnabled($req);
|
||||
$dependencies[] = [
|
||||
'key' => $req,
|
||||
'title' => $this->manifest($req)['title'] ?? $req,
|
||||
'ok' => $enabled,
|
||||
'reason' => $installed ? ($enabled ? '' : '未启用') : '未安装',
|
||||
];
|
||||
|
||||
if (! $enabled) {
|
||||
$dependencyErrors[] = $req;
|
||||
}
|
||||
}
|
||||
|
||||
$plugins[$basename] = [
|
||||
'key' => $basename,
|
||||
'vendor' => $vendor,
|
||||
@@ -63,6 +83,9 @@ class PluginManager
|
||||
'description' => $manifest['description'] ?? '',
|
||||
'author' => $manifest['author'] ?? '',
|
||||
'enabled' => $this->isEnabled($basename),
|
||||
'requires' => $requires,
|
||||
'dependencies' => $dependencies,
|
||||
'dependency_errors' => $dependencyErrors,
|
||||
];
|
||||
}
|
||||
|
||||
@@ -84,8 +107,53 @@ class PluginManager
|
||||
return in_array($plugin, config('plugins.enabled', []), true);
|
||||
}
|
||||
|
||||
public function requires(string $plugin): array
|
||||
{
|
||||
return $this->manifest($plugin)['requires'] ?? [];
|
||||
}
|
||||
|
||||
/**
|
||||
* 依赖未满足的问题列表(空数组 = 依赖完好)。
|
||||
*/
|
||||
public function dependencyProblems(string $plugin): array
|
||||
{
|
||||
$problems = [];
|
||||
|
||||
foreach ($this->requires($plugin) as $req) {
|
||||
if (! $this->exists($req)) {
|
||||
$problems[] = "缺少依赖插件:{$req}(未安装)";
|
||||
} elseif (! $this->isEnabled($req)) {
|
||||
$problems[] = '依赖插件未启用:'.$req.'('.$this->manifest($req)['title'].')';
|
||||
}
|
||||
}
|
||||
|
||||
return $problems;
|
||||
}
|
||||
|
||||
/**
|
||||
* 已启用且依赖本插件的插件列表。
|
||||
*/
|
||||
public function dependents(string $plugin): array
|
||||
{
|
||||
$dependents = [];
|
||||
|
||||
foreach ($this->all() as $key => $p) {
|
||||
if ($p['enabled'] && in_array($plugin, $p['requires'] ?? [], true)) {
|
||||
$dependents[] = $key;
|
||||
}
|
||||
}
|
||||
|
||||
return $dependents;
|
||||
}
|
||||
|
||||
public function enable(string $plugin): void
|
||||
{
|
||||
$problems = $this->dependencyProblems($plugin);
|
||||
|
||||
if ($problems) {
|
||||
throw new RuntimeException('无法启用 '.$plugin.':'.implode(';', $problems));
|
||||
}
|
||||
|
||||
[$vendor, $name] = array_pad(explode('.', $plugin), 2, $plugin);
|
||||
PluginRecord::query()->updateOrCreate(
|
||||
['vendor' => $vendor, 'name' => $name],
|
||||
@@ -95,6 +163,12 @@ class PluginManager
|
||||
|
||||
public function disable(string $plugin): void
|
||||
{
|
||||
$dependents = $this->dependents($plugin);
|
||||
|
||||
if ($dependents) {
|
||||
throw new RuntimeException('无法停用 '.$plugin.':以下已启用插件依赖它:'.implode('、', $dependents).'。请先停用这些插件。');
|
||||
}
|
||||
|
||||
[$vendor, $name] = array_pad(explode('.', $plugin), 2, $plugin);
|
||||
PluginRecord::query()->updateOrCreate(
|
||||
['vendor' => $vendor, 'name' => $name],
|
||||
|
||||
@@ -38,14 +38,22 @@ class Plugins extends Page
|
||||
|
||||
public function enablePlugin(string $key): void
|
||||
{
|
||||
app(PluginManager::class)->enable($key);
|
||||
Notification::make()->title('已启用:'.$key)->success()->send();
|
||||
try {
|
||||
app(PluginManager::class)->enable($key);
|
||||
Notification::make()->title('已启用:'.$key)->success()->send();
|
||||
} catch (\Throwable $e) {
|
||||
Notification::make()->title('启用失败')->body($e->getMessage())->danger()->send();
|
||||
}
|
||||
}
|
||||
|
||||
public function disablePlugin(string $key): void
|
||||
{
|
||||
app(PluginManager::class)->disable($key);
|
||||
Notification::make()->title('已停用:'.$key)->warning()->send();
|
||||
try {
|
||||
app(PluginManager::class)->disable($key);
|
||||
Notification::make()->title('已停用:'.$key)->warning()->send();
|
||||
} catch (\Throwable $e) {
|
||||
Notification::make()->title('停用失败')->body($e->getMessage())->danger()->send();
|
||||
}
|
||||
}
|
||||
|
||||
public function uninstallPlugin(string $key): void
|
||||
|
||||
@@ -33,12 +33,22 @@ class PostResource extends Resource
|
||||
|
||||
public static function form(Schema $schema): Schema
|
||||
{
|
||||
return PostForm::configure($schema);
|
||||
$schema = PostForm::configure($schema);
|
||||
|
||||
// 插件注入:会员付费设置、AI 选项等后台扩展
|
||||
app(\App\Blog\Support\PluginManager::class)->doAction('filament.post_form', $schema);
|
||||
|
||||
return $schema;
|
||||
}
|
||||
|
||||
public static function table(Table $table): Table
|
||||
{
|
||||
return PostsTable::configure($table);
|
||||
$table = PostsTable::configure($table);
|
||||
|
||||
// 插件注入:价格、会员专享等表格列
|
||||
app(\App\Blog\Support\PluginManager::class)->doAction('filament.post_table', $table);
|
||||
|
||||
return $table;
|
||||
}
|
||||
|
||||
public static function getRelations(): array
|
||||
|
||||
@@ -7,6 +7,13 @@ namespace Plugins\Neatstudio\Membership;
|
||||
|
||||
use App\Blog\Support\PluginManager;
|
||||
use App\Blog\Support\PluginServiceProvider;
|
||||
use Filament\Forms\Components\TextInput;
|
||||
use Filament\Forms\Components\Toggle;
|
||||
use Filament\Schemas\Components\Section;
|
||||
use Filament\Schemas\Schema;
|
||||
use Filament\Tables\Columns\IconColumn;
|
||||
use Filament\Tables\Columns\TextColumn;
|
||||
use Filament\Tables\Table;
|
||||
use Illuminate\Support\Str;
|
||||
use Plugins\Neatstudio\Membership\Models\MembershipPlan;
|
||||
use Plugins\Neatstudio\Membership\Models\Subscription;
|
||||
@@ -75,5 +82,39 @@ class ServiceProvider extends PluginServiceProvider
|
||||
|
||||
return $html;
|
||||
}, 10);
|
||||
|
||||
// 后台文章表单注入:付费设置(meta.price 单篇价格 + meta.members_only 会员专享)
|
||||
$manager->addAction('filament.post_form', function (Schema $schema) {
|
||||
$schema->components([
|
||||
...$schema->getComponents(),
|
||||
Section::make('付费设置')
|
||||
->description('需启用支付插件并配置支付渠道;0 = 免费')
|
||||
->columns(3)
|
||||
->collapsible()
|
||||
->schema([
|
||||
TextInput::make('meta.price')
|
||||
->label('单篇价格(分)')
|
||||
->numeric()
|
||||
->minValue(0)
|
||||
->default(0)
|
||||
->helperText('例:9900 = ¥99;填 0 表示单篇免费'),
|
||||
Toggle::make('meta.members_only')
|
||||
->label('会员专享')
|
||||
->helperText('整篇文章仅会员可读'),
|
||||
]),
|
||||
]);
|
||||
});
|
||||
|
||||
// 后台文章表格注入:价格 / 会员列
|
||||
$manager->addAction('filament.post_table', function (Table $table) {
|
||||
$table->pushColumns([
|
||||
TextColumn::make('meta.price')
|
||||
->label('单篇价格')
|
||||
->formatStateUsing(fn ($state) => $state > 0 ? '¥'.number_format((float) $state / 100, 2) : '—'),
|
||||
IconColumn::make('meta.members_only')
|
||||
->label('会员')
|
||||
->boolean(),
|
||||
]);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,11 +17,24 @@
|
||||
<p class="mt-1 flex-1 text-sm text-gray-500 dark:text-gray-400">{{ $plugin['description'] }}</p>
|
||||
<p class="mt-2 text-xs text-gray-400">v{{ $plugin['version'] }} · {{ $plugin['vendor'] }}.{{ $plugin['name'] }}</p>
|
||||
|
||||
@if(! empty($plugin['dependencies']))
|
||||
<div class="mt-2 flex flex-wrap items-center gap-1">
|
||||
<span class="text-xs text-gray-400">依赖:</span>
|
||||
@foreach($plugin['dependencies'] as $dep)
|
||||
<x-filament::badge :color="$dep['ok'] ? 'success' : 'danger'" size="xs">
|
||||
{{ $dep['title'] }}@if(! $dep['ok'])({{ $dep['reason'] }})@endif
|
||||
</x-filament::badge>
|
||||
@endforeach
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<div class="mt-4 flex flex-wrap gap-2">
|
||||
@if($plugin['enabled'])
|
||||
<x-filament::button size="sm" color="gray" wire:click="disablePlugin('{{ $plugin['key'] }}')">停用</x-filament::button>
|
||||
@else
|
||||
@elseif(empty($plugin['dependency_errors']))
|
||||
<x-filament::button size="sm" color="success" wire:click="enablePlugin('{{ $plugin['key'] }}')">启用</x-filament::button>
|
||||
@else
|
||||
<x-filament::badge color="warning">依赖未满足,无法启用</x-filament::badge>
|
||||
@endif
|
||||
@if(! in_array($plugin['key'], config('plugins.enabled', []), true))
|
||||
<x-filament::button size="sm" color="danger" wire:click="uninstallPlugin('{{ $plugin['key'] }}')" wire:confirm="确定卸载该插件?">卸载</x-filament::button>
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use App\Blog\Support\PluginManager;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use RuntimeException;
|
||||
use Tests\TestCase;
|
||||
|
||||
class PluginDependencyTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
private function manager(): PluginManager
|
||||
{
|
||||
return app(PluginManager::class);
|
||||
}
|
||||
|
||||
public function test_enable_plugin_with_disabled_dependency_throws(): void
|
||||
{
|
||||
$manager = $this->manager();
|
||||
|
||||
// 先停用无依赖者的 membership,再停用 payment(此时无启用的依赖者)
|
||||
$manager->disable('neatstudio.membership');
|
||||
$manager->disable('neatstudio.payment');
|
||||
|
||||
try {
|
||||
$manager->enable('neatstudio.membership');
|
||||
$this->fail('依赖未启用时应抛出异常');
|
||||
} catch (RuntimeException $e) {
|
||||
$this->assertStringContainsString('neatstudio.payment', $e->getMessage());
|
||||
}
|
||||
|
||||
$this->assertFalse($manager->isEnabled('neatstudio.membership'));
|
||||
}
|
||||
|
||||
public function test_disable_plugin_with_enabled_dependent_throws(): void
|
||||
{
|
||||
$manager = $this->manager();
|
||||
|
||||
// 默认状态下 payment 已启用且 membership 依赖它
|
||||
try {
|
||||
$manager->disable('neatstudio.payment');
|
||||
$this->fail('存在已启用的依赖者时应抛出异常');
|
||||
} catch (RuntimeException $e) {
|
||||
$this->assertStringContainsString('neatstudio.membership', $e->getMessage());
|
||||
}
|
||||
|
||||
$this->assertTrue($manager->isEnabled('neatstudio.payment'));
|
||||
}
|
||||
|
||||
public function test_disable_plugin_without_dependents_works(): void
|
||||
{
|
||||
$manager = $this->manager();
|
||||
|
||||
$manager->disable('neatstudio.membership');
|
||||
$this->assertFalse($manager->isEnabled('neatstudio.membership'));
|
||||
|
||||
// 依赖(payment)仍启用,可重新启用
|
||||
$manager->enable('neatstudio.membership');
|
||||
$this->assertTrue($manager->isEnabled('neatstudio.membership'));
|
||||
}
|
||||
|
||||
public function test_all_reports_dependency_status(): void
|
||||
{
|
||||
$manager = $this->manager();
|
||||
|
||||
$plugins = $manager->all();
|
||||
$member = $plugins['neatstudio.membership'];
|
||||
|
||||
$this->assertSame(['neatstudio.payment'], $member['requires']);
|
||||
$this->assertTrue($member['dependencies'][0]['ok']);
|
||||
$this->assertEmpty($member['dependency_errors']);
|
||||
|
||||
// 停用依赖后,依赖状态应变为失败
|
||||
$manager->disable('neatstudio.membership');
|
||||
$manager->disable('neatstudio.payment');
|
||||
|
||||
$plugins = $manager->all();
|
||||
$this->assertFalse($plugins['neatstudio.membership']['dependencies'][0]['ok']);
|
||||
$this->assertSame(['neatstudio.payment'], $plugins['neatstudio.membership']['dependency_errors']);
|
||||
}
|
||||
|
||||
public function test_dependency_problems_for_missing_plugin(): void
|
||||
{
|
||||
$manager = $this->manager();
|
||||
|
||||
// 默认依赖完好
|
||||
$this->assertSame([], $manager->dependencyProblems('neatstudio.membership'));
|
||||
|
||||
// 停用依赖后出现问题(先停用无依赖者的 membership)
|
||||
$manager->disable('neatstudio.membership');
|
||||
$manager->disable('neatstudio.payment');
|
||||
$this->assertNotEmpty($manager->dependencyProblems('neatstudio.membership'));
|
||||
|
||||
// 不存在的插件没有依赖问题(空 requires)
|
||||
$this->assertSame([], $manager->dependencyProblems('neatstudio.does-not-exist'));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use App\Filament\Resources\Posts\PostResource;
|
||||
use Filament\Schemas\Schema;
|
||||
use Filament\Tables\Table;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use ReflectionClass;
|
||||
use Tests\TestCase;
|
||||
|
||||
class PostFormInjectionTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
public function test_post_form_includes_paid_section_from_membership_plugin(): void
|
||||
{
|
||||
$schema = Schema::make();
|
||||
PostResource::form($schema);
|
||||
|
||||
// Section 的标题在 heading 而非 label
|
||||
$headings = collect($schema->getComponents())
|
||||
->map(fn ($component) => method_exists($component, 'getHeading') ? $component->getHeading() : $component->getLabel())
|
||||
->all();
|
||||
|
||||
$this->assertContains('付费设置', $headings);
|
||||
|
||||
// 找到付费设置 Section;无 Livewire 环境下用反射读取默认内容槽(childComponents 按 schema key 分组)
|
||||
$section = collect($schema->getComponents())
|
||||
->first(fn ($component) => $component->getHeading() === '付费设置');
|
||||
|
||||
$property = (new ReflectionClass($section))->getProperty('childComponents');
|
||||
$fieldNames = collect($property->getValue($section)['default'] ?? [])
|
||||
->map(fn ($field) => $field->getName())
|
||||
->all();
|
||||
|
||||
$this->assertContains('meta.price', $fieldNames);
|
||||
$this->assertContains('meta.members_only', $fieldNames);
|
||||
}
|
||||
|
||||
public function test_post_table_includes_price_and_member_columns(): void
|
||||
{
|
||||
// Table 构造器要求 HasTable Livewire 组件,测试中绕过构造器
|
||||
$table = (new ReflectionClass(Table::class))->newInstanceWithoutConstructor();
|
||||
PostResource::table($table);
|
||||
|
||||
$names = array_keys($table->getColumns());
|
||||
|
||||
$this->assertContains('meta.price', $names);
|
||||
$this->assertContains('meta.members_only', $names);
|
||||
$this->assertContains('title', $names);
|
||||
}
|
||||
|
||||
public function test_meta_price_maps_to_post_meta_json(): void
|
||||
{
|
||||
$post = \App\Models\Post::create([
|
||||
'title' => '付费文章',
|
||||
'content' => '内容',
|
||||
'content_format' => 'markdown',
|
||||
'status' => 'published',
|
||||
'published_at' => now(),
|
||||
'meta' => ['price' => 9900, 'members_only' => true],
|
||||
]);
|
||||
|
||||
$this->assertSame(9900, $post->meta['price']);
|
||||
$this->assertTrue($post->meta['members_only']);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user