Files
ak 01861f8809 feat: 插件使用说明(usage)+ 页面缓存命中统计
- plugin.json 新增 usage 字段(多行文本),后台插件卡片折叠展示;四个内置插件补齐使用说明
- PageCacheMiddleware 记录命中/未命中计数(按日滚动),仪表盘新增「页面缓存」统计卡(条目数 + 命中率),用于判断前台缓存效果、是否需额外加速
- 文档与测试同步
2026-08-12 03:57:22 +08:00

211 lines
8.5 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 插件开发指南
## 目录结构
```
plugins/{vendor}.{name}/
├── plugin.json # 插件清单(必填)
├── src/
│ ├── ServiceProvider.php # 插件入口(继承基类)
│ ├── ... # 业务代码
│ └── Filament/ # 可选:后台页面/资源
├── routes/web.php # 可选:前台路由(boot 时自动加载)
├── database/migrations/ # 可选:迁移(migrate 时自动加载)
└── views/ # 可选:视图(命名空间 plugin.{vendor}.{name}
```
## plugin.json
```json
{
"title": "插件标题",
"version": "1.0.0",
"description": "插件描述",
"usage": "使用说明(多行文本,后台插件卡片「使用说明」折叠展示)",
"author": "作者",
"type": "core",
"provider": "Plugins\\Vendor\\Name\\ServiceProvider",
"requires": ["neatstudio.payment"],
"filament_pages": ["Plugins\\Vendor\\Name\\Filament\\Pages\\SettingsPage"],
"filament_resources": ["Plugins\\Vendor\\Name\\Filament\\Resources\\OrderResource"]
}
```
| 字段 | 说明 |
|------|------|
| `provider` | 入口类 FQCN;缺省时自动推断 `src/ServiceProvider.php` |
| `requires` | 依赖的其他插件(如会员依赖支付),按 `vendor.name` 引用 |
| `usage` | 使用说明(换行文本),后台插件卡片可折叠查看 |
| `filament_pages` / `filament_resources` | 注册到后台的页面/资源(由 `PluginPages` 汇总,统一归入「插件」导航分组) |
## 依赖管理(requires 强制校验)
- `enable()`:依赖未安装或未启用 → 拒绝启用并提示(后台弹错误通知)
- `disable()` / `uninstall()`:有已启用的插件依赖它 → 拒绝操作,需先停用上层插件
- 应用启动时依赖未满足的插件**跳过 boot**,避免运行时缺底层能力
- 后台插件卡片展示依赖链状态(徽章:✓ 已启用 / ✗ 未安装 / ✗ 未启用),依赖不满足的插件无法启用
插件分层示例:`payment`(支付)→ `membership`(会员,requires payment)→ 你的业务插件(requires membership 或 payment)。上层插件可放心假设底层能力存在。
## ServiceProvider
```php
<?php
declare(strict_types=1);
namespace Plugins\Vendor\Name;
use App\Blog\Support\PluginManager;
use App\Blog\Support\PluginServiceProvider;
class ServiceProvider extends PluginServiceProvider
{
protected function boot(PluginManager $manager): void
{
// 加载前台路由(可选)
$this->loadRoutes(__DIR__.'/../routes/web.php');
// 注册视图命名空间(可选)
$this->loadViews(__DIR__.'/../views', 'plugin.vendor.name');
// 注册动作/过滤器
$manager->addAction('comment.created', function ($comment) { ... }, 10);
$manager->addFilter('post.rendered', fn (string $html, $post) => $html, 10);
}
}
```
> 注意:插件入口类**不要**命名为 `PluginServiceProvider`(与基类短名冲突会导致 PHP 声明错误),统一用 `ServiceProvider`。
## 钩子系统
### addAction(hook, callback, priority) — 无返回值
| Hook | 参数 | 用途 |
|------|------|------|
| `comment.created` | `Comment` | 新评论创建(AI 审核在此监听) |
| `payment.paid` | `Payment` | 支付成功(按 `$payment->payable` 分发:订阅激活/解锁) |
| `filament.post_form` | `Schema` | 文章编辑表单追加字段(会员付费设置在此注入) |
| `filament.post_table` | `Table` | 文章列表追加列/操作/过滤 |
### addFilter(hook, callback, priority) — 返回值传给下一个过滤器
| Hook | 签名 | 用途 |
|------|------|------|
| `post.rendered` | `(string $html, Post $post): string` | 文章渲染后处理(付费内容过滤、paywall) |
| `seo.structured_data` | `(array $data): array` | 扩展 JSON-LD 结构化数据 |
| `payment.gateway` | `(array $gateways): array` | 注册支付渠道 |
## 后台注入(Filament 表单 / 表格)
核心资源的表单/表格会触发 `filament.post_form` / `filament.post_table` 两个钩子,插件可追加自己的字段与列,停用插件后字段自动消失,核心零侵入。
> 注意:`Schema::components()` 与 `Table::columns()` 都是**整体替换**语义,追加要这样写:
> - 表单:`$schema->components([...$schema->getComponents(), Section::make(...)])`
> - 表格:`$table->pushColumns([...])`
示例(给文章加「付费设置」):
```php
$manager->addAction('filament.post_form', function (Schema $schema) {
$schema->components([
...$schema->getComponents(),
Section::make('付费设置')->columns(3)->collapsible()->schema([
TextInput::make('meta.price')->label('单篇价格(分)')->numeric()->default(0),
Toggle::make('meta.members_only')->label('会员专享'),
]),
]);
});
$manager->addAction('filament.post_table', function (Table $table) {
$table->pushColumns([
TextColumn::make('meta.price')->label('单篇价格'),
IconColumn::make('meta.members_only')->label('会员')->boolean(),
]);
});
```
字段用点号路径(`meta.price`)直接映射到 `Post``meta` JSON。核心保存时会把表单里的 meta 键与已有 meta **合并**,不会覆盖其他插件写入的键(如解锁用户)。
## 订单商品化(可购买实体)
`Payment` 用多态关联指向被购买的实体(`payable_type` / `payable_id`)。支付成功后的 `payment.paid` 钩子按 `$payment->payable` 分发(`instanceof` 判断),**不要**再用字符串解析业务类型。
订单后台的「商品」列与跳转链接由实体上的两个约定方法生成:
```php
public function payableLabel(): string { return '文章:'.$this->title; }
public function payableUrl(): ?string { return route('filament.admin.resources.posts.edit', $this); }
```
已实现:`App\Models\Post`(单篇解锁)、`MembershipPlan`(会员套餐)。新实体(皮肤授权、插件授权、打赏)实现同样两个方法即自动接入订单展示,支付插件零改动。
## 付费内容前台展示(paywall)
会员插件在 `post.rendered` 过滤器里处理两种付费形态:
- 整篇锁定(`meta.members_only`)→ 渲染 `membership.paywall`(封面 + 标题 + 价格 + 单篇解锁/开通会员按钮)
- `[paid]` 付费块 → 替换为 `membership.paid-teaser`(内嵌解锁按钮的提示块)
缺省视图在 `resources/views/membership/`,主题可覆盖:`themes/{name}/views/membership/paywall.blade.php`。视图内可用 `$post`,按钮沿用 `.btn` 类,`.paywall` / `.paid-teaser` 样式两套内置主题已带。
## 迁移
插件迁移放在 `database/migrations/``app/Providers/AppServiceProvider` 启动时自动注册,`php artisan migrate` 会一并执行(无需手动 --path)。
## 打包与安装
- 将插件目录打成 ZIP(根目录含 plugin.json
- 后台「插件管理」→ 上传 ZIP 安装,或配置远程市场 `MARKET_URL`
- 内置插件(config/plugins.php enabled 列表)不可卸载,只能停用
## 远程市场契约
配置 `MARKET_URL` 后,后台「插件管理 / 主题管理」会请求 `{MARKET_URL}/items``market.token` 作为 Bearer Token),响应格式:
```json
{
"items": [
{
"name": "vendor.demo",
"type": "plugin",
"title": "演示插件",
"description": "描述",
"version": "1.0.0",
"author": "作者",
"price": 9900,
"download_url": "https://example.com/vendor.demo.zip"
}
]
}
```
- `name`:包键(插件为 `vendor.name`,主题为主题名),**购买授权按此键记录**
- `type``plugin` / `theme`——插件市场 Tab 只显示 plugin,主题市场 Tab 只显示 theme
- `price`:价格(分),`0` = 免费;`> 0` 显示「购买」按钮,走 `Payment` 订单(可 `payable``MarketPackage`),支付成功后标记已购,未购买无法安装
## 异步任务(配合 Workerman
实现 `App\Blog\Jobs\AiJob` 接口并在 Workerman 常驻进程执行:
```php
use App\Blog\Jobs\AiJob;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
class MyAiJob implements AiJob, ShouldQueue
{
use Queueable;
public function __construct(public int $id) {}
public function handle(\App\Blog\Services\LlmClient $llm): void
{
// LlmClient 由容器自动注入
}
}
```
入队:`dispatch(new MyAiJob($id))->onQueue('ai')`Workerman 默认消费 `default,ai` 队列)。