diff --git a/README.md b/README.md index c599663..e4e4cba 100644 --- a/README.md +++ b/README.md @@ -120,9 +120,26 @@ php artisan workerman:serve stop ## 测试 ```bash -php artisan test # 25 个用例:迁移/老路由 301/主题/插件/会员支付流/AI 审核 +php artisan test # 84 个用例:迁移/老路由 301/主题/插件/会员支付流/AI 审核/商城购买 ``` +## 开发者文档 + +| 文档 | 内容 | +|------|------| +| [docs/development.md](docs/development.md) | 开发规范(代码/插件/主题/队列/测试/提交) | +| [docs/tutorial-plugin.md](docs/tutorial-plugin.md) | 插件开发教程(10 分钟上手,配套 demo) | +| [docs/tutorial-theme.md](docs/tutorial-theme.md) | 主题开发教程(10 分钟上手,配套 demo) | +| [docs/plugins.md](docs/plugins.md) | 插件 API 详解(钩子/依赖/商品化/市场契约) | +| [docs/themes.md](docs/themes.md) | 主题 API 详解(视图解析/注入点/变量) | +| [docs/architecture.md](docs/architecture.md) | 架构与设计决策 | +| [docs/api.md](docs/api.md) | 对外 API(小程序/第三方) | +| [docs/deploy.md](docs/deploy.md) | 部署(nginx/systemd/workerman/cron) | + +**上手示例**: +- 插件:`plugins/demo.hello-world/`(短代码 / 钩子 / 路由 / 后台页 / 迁移 / 表单注入,全演示) +- 主题:`themes/demo/`(最小可运行主题,绿色系,覆盖 partials 即可换肤) + ## 目录结构 ``` @@ -130,9 +147,9 @@ app/ Blog/ # 前台域模块(Controllers/Jobs/Services/Support/View) Filament/ # 后台资源与页面 Console/Commands # sablog:import / attachments:sync-s3 / workerman:serve / content:convert -themes/ # 主题(sablog / modern) -plugins/ # 内置插件(blog-seo / ai-moderation / payment / membership) -docs/deploy.md # 部署指南 +themes/ # 主题(sablog / modern / demo 示例) +plugins/ # 内置插件(blog-seo / ai-moderation / payment / membership)+ demo.hello-world 示例 +docs/ # 全部文档(见上文表格) ``` ## 许可 diff --git a/app/Blog/Providers/PluginManagerServiceProvider.php b/app/Blog/Providers/PluginManagerServiceProvider.php index a100220..df5b7ea 100644 --- a/app/Blog/Providers/PluginManagerServiceProvider.php +++ b/app/Blog/Providers/PluginManagerServiceProvider.php @@ -6,23 +6,32 @@ declare(strict_types=1); namespace App\Blog\Providers; use App\Blog\Support\PluginManager; +use Illuminate\Support\Facades\File; use Illuminate\Support\ServiceProvider; +use Illuminate\Support\Str; class PluginManagerServiceProvider extends ServiceProvider { public function register(): void { $this->app->singleton(PluginManager::class); + + // 运行期自动加载:扫描 plugins/*/src,按 Plugins\{Vendor}\{Name} 注册 PSR-4。 + // 必须在 register() 阶段完成:Filament 面板 boot 时要解析插件页面类, + // 第三方 ZIP 安装的插件无需修改 composer.json。 + if (is_dir(config('plugins.path'))) { + $this->registerPluginAutoload(); + } } public function boot(): void { - $manager = $this->app->make(PluginManager::class); - if (! is_dir(config('plugins.path'))) { return; } + $manager = $this->app->make(PluginManager::class); + foreach ($manager->all() as $key => $plugin) { if (! $plugin['enabled']) { continue; @@ -40,4 +49,21 @@ class PluginManagerServiceProvider extends ServiceProvider } } } + + private function registerPluginAutoload(): void + { + $loaders = \Composer\Autoload\ClassLoader::getRegisteredLoaders(); + $loader = reset($loaders); + + if (! $loader) { + return; + } + + foreach (File::directories(config('plugins.path')) as $dir) { + $basename = basename($dir); + [$vendor, $name] = array_pad(explode('.', $basename), 2, $basename); + + $loader->addPsr4('Plugins\\'.Str::studly($vendor).'\\'.Str::studly($name).'\\', $dir.'/src/'); + } + } } diff --git a/composer.json b/composer.json index dba39e7..481ccc2 100644 --- a/composer.json +++ b/composer.json @@ -38,11 +38,7 @@ "psr-4": { "App\\": "app/", "Database\\Factories\\": "database/factories/", - "Database\\Seeders\\": "database/seeders/", - "Plugins\\Neatstudio\\BlogSeo\\": "plugins/neatstudio.blog-seo/src/", - "Plugins\\Neatstudio\\AiModeration\\": "plugins/neatstudio.ai-moderation/src/", - "Plugins\\Neatstudio\\Payment\\": "plugins/neatstudio.payment/src/", - "Plugins\\Neatstudio\\Membership\\": "plugins/neatstudio.membership/src/" + "Database\\Seeders\\": "database/seeders/" }, "files": [ "app/Support/helpers.php" diff --git a/docs/development.md b/docs/development.md new file mode 100644 index 0000000..df750de --- /dev/null +++ b/docs/development.md @@ -0,0 +1,85 @@ +# 开发规范 + +面向所有为 LaraLog 写代码的人(核心 / 插件 / 主题作者)。教程见 `tutorial-plugin.md`、`tutorial-theme.md`,配套示例见 `plugins/demo.hello-world/`、`themes/demo/`。 + +## 1. 代码规范 + +- **PHP 8.2+**,所有文件以 `declare(strict_types=1);` 开头 +- 命名空间:核心 `App\...`;插件 `Plugins\{Vendor}\{Name}\...`(Vendor/Name 用 `Str::studly` 规则) +- 文件命名:类文件与类名一致(PSR-4 自动加载) +- 方法/属性/变量:`camelCase`;常量:`UPPER_SNAKE`;数据库表:`snake_case` +- 中文文案直接写在代码/语言包中(本项目默认中文,不硬编码英文 UI) +- 不写无意义注释;只在「为什么」不明显处加一行注释 +- **安全红线**:不提交 `.env`、密钥、证书;用户输入一律 `e()` 转义或框架验证;数据库操作用 Eloquent/查询构造器(防注入) + +## 2. 目录约定 + +``` +app/Blog/ # 前台域模块:Controllers / Jobs / Services / Support / View +app/Filament/ # 后台资源与页面 +plugins/ # 插件,目录名 vendor.name +themes/ # 主题,目录名即主题名 +routes/web.php # 前台规范路由;routes/legacy.php 老 URL 301(新增老入口兼容放这里) +``` + +## 3. 插件规范(详见 docs/plugins.md) + +- 目录 `plugins/{vendor}.{name}/`,**目录名是插件唯一标识** +- 类自动加载:系统启动时扫描 `plugins/*/src` 注册 PSR-4(`Plugins\{Vendor}\{Name}`),**无需改 composer.json** +- `plugin.json` 必填:`title/version/description/author/type/provider`;可选 `usage/requires/filament_pages/filament_resources` +- 入口类 `src/ServiceProvider.php` 继承 `App\Blog\Support\PluginServiceProvider`,**不要命名为 PluginServiceProvider** +- 依赖声明在 `requires`(`vendor.name`),系统强制校验:依赖未启用不能启用本插件;停用/卸载被依赖者阻止 +- 后台扩展**一律走钩子注入**(`filament.post_form` / `filament.post_table`),禁止改核心资源文件 +- 可购买实体约定:实现 `payableLabel(): string` 与 `payableUrl(): ?string` 即接入订单展示(支付插件零改动) +- 插件视图放插件目录并用 `loadViews()` 注册命名空间;前台页面用 `@include('partials.header')` 复用主题公共片段 + +### 钩子清单 + +| Hook | 类型 | 参数 | 用途 | +|------|------|------|------| +| `post.rendered` | filter | `(string $html, Post): string` | 文章渲染后处理 | +| `comment.created` | action | `Comment` | 新评论创建 | +| `payment.paid` | action | `Payment` | 支付成功分发 | +| `filament.post_form` | action | `Schema` | 文章表单注入 | +| `filament.post_table` | action | `Table` | 文章表格注入 | +| `seo.structured_data` | filter | `(array): array` | JSON-LD 扩展 | +| `theme.inject.{point}` | filter | `(string): string` | 注入点追加 HTML | + +> `Schema::components()` / `Table::columns()` 是**替换**语义:表单用 `[...$schema->getComponents(), Section::make(...)]` 合并,表格用 `pushColumns()`。 + +## 4. 主题规范(详见 docs/themes.md) + +- 目录 `themes/{name}/`,`theme.json` 必填;views 前置到全局视图路径,同名覆盖、缺失回退 +- 语义化 HTML(header/nav/main/aside/footer/article/time)是 SEO 要求,不是可选项 +- 保留注入点 `@stack('theme:xxx')`,并尽量在 theme.json 声明 `inject_points` +- 必须有移动端断点(≤768px 两栏改单栏) +- 资产开发期流式返回、生产 `theme:publish` + +## 5. 队列任务规范 + +- 实现 `App\Blog\Jobs\AiJob` 接口(`handle(LlmClient)` 由容器注入)+ `ShouldQueue` +- 入队 `dispatch(new XxxJob($id))->onQueue('ai')` +- 消费者二选一:`php artisan workerman:serve`(常驻+WS)或 `php artisan queue:work --queue=default,ai`(等价,见 docs/deploy.md) +- LLM 一律走 `LlmClient`(OpenAI 兼容,base_url/key/model 可配),不要直连第三方 SDK + +## 6. 测试规范 + +- 新增功能必须配 Feature 测试(`tests/Feature/`),跑 `php artisan test` 全绿再提交 +- 示例参照:`PluginDependencyTest`(依赖校验)、`PostFormInjectionTest`(表单注入)、`MarketPurchaseTest`(商城购买流)、`MembershipFlowTest`(会员/支付流) +- 测试用 RefreshDatabase + 沙箱,**不触碰真实支付/真实 LLM 凭据** + +## 7. 提交规范 + +- 一次提交一件事,信息用中文描述「做了什么 + 为什么」 +- 不提交:`.env`、密钥/证书、`storage/media-library/temp/`、构建产物 +- 完成后更新对应 `docs/` 文档 + +## 8. 常用命令速查 + +```bash +php artisan test # 全量测试 +php artisan dev:watch # 开发热重载(改插件/核心代码自动重启 workerman) +php artisan migrate # 插件迁移自动加载 +php artisan workerman:serve start # 队列消费者 + WebSocket +php artisan theme:publish # 主题资产发布到 public +``` diff --git a/docs/tutorial-plugin.md b/docs/tutorial-plugin.md new file mode 100644 index 0000000..67b01bd --- /dev/null +++ b/docs/tutorial-plugin.md @@ -0,0 +1,156 @@ +# 插件开发教程(10 分钟上手) + +配套示例:**`plugins/demo.hello-world/`**。本文带你从零写一个同样功能的插件,每个文件都有对照。 + +## 1. 目录结构 + +``` +plugins/{vendor}.{name}/ # 目录名必须 vendor.name 格式(如 demo.hello-world) +├── plugin.json # 插件清单(必填) +├── src/ +│ ├── ServiceProvider.php # 入口类(必填,继承基类) +│ ├── Http/ # 前台控制器 +│ ├── Models/ # 模型 +│ └── Filament/Pages/ # 后台页面 +├── routes/web.php # 可选:前台路由(boot 时自动加载) +├── database/migrations/ # 可选:迁移(migrate 自动执行) +└── views/ # 可选:视图(注册命名空间后引用) +``` + +## 2. plugin.json + +```json +{ + "title": "Hello World 示例插件", + "version": "1.0.0", + "description": "一句话描述", + "usage": "使用说明(多行文本,后台插件卡片可折叠查看)", + "author": "你的名字", + "type": "demo", + "provider": "Plugins\\Demo\\HelloWorld\\ServiceProvider", + "requires": ["neatstudio.payment"], // 可选:依赖的插件 + "filament_pages": ["Plugins\\Demo\\HelloWorld\\Filament\\Pages\\HelloWorldSettings"], + "filament_resources": ["...\\Resources\\XxxResource"] +} +``` + +字段说明见 `docs/plugins.md`。`provider` 指向入口类;缺省时系统会自动推断 `src/ServiceProvider.php`。 + +## 3. 入口类 ServiceProvider + +```php +loadRoutes(__DIR__.'/../routes/web.php'); // 加载路由 + $this->loadViews(__DIR__.'/../views', 'demo-hello'); // 注册视图命名空间 demo-hello:: + + // 过滤器:文章渲染后处理(返回值传给下一个过滤器) + $manager->addFilter('post.rendered', function (string $html, \App\Models\Post $post) { + return $html; + }, 20); + + // 动作:监听事件(无返回值) + $manager->addAction('comment.created', function (\App\Models\Comment $comment) { + // 做点什么 + }); + } +} +``` + +> 入口类**不要**命名为 `PluginServiceProvider`(与基类短名冲突),统一用 `ServiceProvider`。 + +> **类自动加载**:系统启动时自动扫描 `plugins/*/src` 并按 `Plugins\{Vendor}\{Name}` 注册 PSR-4——**无需修改 composer.json**,第三方 ZIP 安装的插件同样生效。 + +### 常用钩子 + +| Hook | 类型 | 参数 | 用途 | +|------|------|------|------| +| `post.rendered` | filter | `(string $html, Post) : string` | 文章渲染后处理(短代码/付费过滤) | +| `comment.created` | action | `Comment` | 新评论创建 | +| `payment.paid` | action | `Payment` | 支付成功(按 `$payment->payable` 分发) | +| `filament.post_form` | action | `Schema` | 文章编辑表单追加字段 | +| `filament.post_table` | action | `Table` | 文章列表追加列 | +| `seo.structured_data` | filter | `(array $data) : array` | 扩展 JSON-LD | + +完整列表见 `docs/plugins.md`。 + +## 4. 短代码示例(过滤器) + +```php +$manager->addFilter('post.rendered', function (string $html, Post $post) { + $greeting = (string) \App\Models\Setting::get('hello_world_greeting', '你好'); + + return preg_replace_callback( + '/\[hello\s+([^\]]+)\]/', + fn (array $m) => ''.$greeting.','.e($m[1]).'!', + $html + ); +}, 20); +``` + +文章里写 `[hello 世界]`,渲染时变成「你好,世界!」。`e()` 转义用户输入防 XSS。 + +## 5. 路由 + 视图 + +`routes/web.php`: + +```php +Route::get('/hello-world', [HelloWorldController::class, 'index'])->name('hello-world.index'); +``` + +控制器返回插件视图(命名空间 `demo-hello::`): + +```php +return view('demo-hello::hello', compact('logs')); +``` + +视图里可以 `@include('partials.header')` 复用当前主题的公共片段。 + +## 6. 迁移 + 模型 + +迁移放在 `database/migrations/`,**无需手动 --path**,`php artisan migrate` 自动执行(AppServiceProvider 已注册 `plugins/*/database/migrations`)。 + +模型照常写,命名空间 `Plugins\Demo\HelloWorld\Models`。 + +## 7. 后台页面 + +`src/Filament/Pages/HelloWorldSettings.php`(继承 `Filament\Pages\Page` + `InteractsWithForms`),在 `plugin.json` 的 `filament_pages` 注册后自动出现在「插件」导航分组。可复用默认表单页视图 `filament.pages.plugin-settings`(见示例)。 + +## 8. 打包与安装 + +```bash +cd plugins/demo.hello-world && zip -r ../demo.hello-world.zip . -x ".*" +``` + +后台「插件管理」→ 上传 ZIP 安装 → 启用。目录名即包键,重复安装会提示已存在。 + +## 9. 测试你的插件 + +```bash +# 快速验证短代码 +php artisan tinker --execute=" +\Plugins\Demo\HelloWorld\ServiceProvider::bootPlugin(app(\App\Blog\Support\PluginManager::class)); +\$html = '正文 [hello 世界]'; +echo app(\App\Blog\Support\PluginManager::class)->applyFilters('post.rendered', \$html, \App\Models\Post::first()); +" +``` + +建议为插件写 Feature 测试(参照 `tests/Feature/` 现有用例)。 + +## 常见问题 + +- **改了代码不生效**:后台启停一次,或重启队列消费者(workerman/dev:watch) +- **依赖其他插件**:在 `requires` 声明,系统会强制校验(依赖未启用无法启用) +- **想让商品出现在订单页**:实体实现 `payableLabel()` / `payableUrl()` 两个方法(见 `docs/plugins.md`「订单商品化」) +- **不想污染核心**:后台表单/表格一律走 `filament.post_form` / `filament.post_table` 钩子注入,核心零改动 diff --git a/docs/tutorial-theme.md b/docs/tutorial-theme.md new file mode 100644 index 0000000..c4ed249 --- /dev/null +++ b/docs/tutorial-theme.md @@ -0,0 +1,97 @@ +# 主题开发教程(10 分钟上手) + +配套示例:**`themes/demo/`**。本文用最小结构带你做一个能用的主题。 + +## 1. 最小主题结构 + +``` +themes/demo/ +├── theme.json # 主题清单(必填) +├── views/ +│ └── partials/ # 覆盖公共片段 +│ ├── head.blade.php +│ ├── header.blade.php +│ └── footer.blade.php +└── assets/ + └── style.css # 主题样式(/themes/demo/assets/style.css 访问) +``` + +**核心机制**:激活主题的 `views/` 目录被前置到全局视图路径。页面视图(`index`/`show`/`list`…)按名字解析——主题有同名视图就用主题的,**没有就自动回退默认视图**(`resources/views/`)。 + +所以最小主题只需要覆盖 `partials/`(head/header/sidebar/footer),页面样式自己定,其他全部继承默认。 + +## 2. theme.json + +```json +{ + "title": "Demo 演示主题", + "version": "1.0.0", + "description": "一句话描述", + "author": "你的名字", + "screenshot": "screenshot.png", + "inject_points": ["head", "header", "footer"] +} +``` + +- `screenshot`:`assets/` 下的预览图,后台主题卡片展示(可选) +- `inject_points`:声明实现了哪些注入点(后台会展示);缺省视为全部支持 + +## 3. 覆盖 partials + +`views/partials/head.blade.php` 需要包含:`