新主题和插件可以使用 extension.php 注册能力。入口文件返回一个注册函数,函数接收当前扩展的 ExtensionContext:
<?php
return static function (\Pafish\Services\ExtensionContext $ctx): void {
$ctx->on('after_post_published', static function (array $post): void {
// 业务逻辑
});
$ctx->route('GET', '/reports', static function ($request, $ctx): array {
return ['html' => $ctx->render('reports')];
}, ['auth' => 'login', 'response' => 'html']);
$ctx->settings([
['key' => 'enabled', 'label' => '启用功能', 'type' => 'switcher', 'default' => '1'],
]);
};规则
插件入口位于
plugins/<name>/extension.php;主题只有在需要钩子、注入、生命周期或自定义路由时才需要themes/<name>/extension.php。主题自然路由只在当前主题激活时生效;插件路由默认挂在
/plugin/<name>/下。插件需要占用自然路径时显式传入
public => true;后台路由使用admin => true和已有 capability。POST 路由默认启用 CSRF,
auth支持guest、login、editor、admin。字符串 handler 通过
$ctx->provider('能力名', $handler)注册;当能力名与 manifest 路由的handler相同,核心会自动完成绑定。这样路由元数据只写在 manifest,入口只提供实现。$ctx->route()仍保留给旧扩展和确实需要运行时追加路由的扩展。插件仍以
extension.php为运行入口;纯模板主题可以没有该文件。路由可以同时出现在 manifest 和
extension.php:manifest 描述路径、鉴权和响应格式,$ctx->route()只负责把处理器绑定到声明的路由;同一路径不会重复注册。主题和插件都可在 manifest 中声明
requires。依赖使用plugin:name或theme:name,可附加版本约束(如plugin:notify_hub >=1.0.0);启用和启动时都会检查依赖是否存在、已启用/激活且满足版本。商店目录的requiresPhp仍是另一套安装前检查,不写进扩展依赖。依赖检查递归遍历完整链路,并在循环时报告链路;manifest 是 API v2 路由元数据的唯一来源,入口通过同名 provider 绑定实现。
ctx->route()只作为 API v1 兼容接口保留。extension.php除了返回注册闭包,也可以返回包含register键的数组;核心只认这两种形态。返回数组时,register必须是可调用值,其余键不会被核心自动解释。生命周期通过
$ctx->lifecycle($phase, $fn)注册,$phase取onActivate、onDeactivate、onUninstall、onUpgrade。onUpgrade额外收到$fromVersion、$toVersion两个字符串参数,只在版本号变化时执行。扩展的日志、私有数据和设置都以扩展身份隔离;扩展表按归属登记,跨扩展读写必须经过对方公开的 provider 或钩子,不能直接按物理表名访问。
这个接口提供一套钩子、路由和上下文运行时。模板文件可以继续按主题需要命名,但不会被核心当作扩展入口自动执行。
插件和主题的设置、小数据、版本及 schema 指纹共用 extension_storage,以 (extension_type, extension_name, data_key) 隔离。核心站点设置继续使用 settings。兼容层将既有扩展键名映射到公共表;旧源只作读取兜底,不能覆盖已存在的新值。
ExtensionContext::updateData(callable $update): array 在事务和行锁中修改当前扩展最新的 JSON 数组。回调必须返回数组,异常时回滚;已有外层事务时使用保存点。日志追加和插件局部设置合并使用同样的更新机制。setData() 是整体替换,不提供读取后合并的并发保证。
需要分页、条件查询、关联或唯一约束的业务数据,继续通过 manifest 声明并使用 $ctx->table();公共 JSON 存储只用于有界的小数据。扩展表按归属登记并只做幂等增量同步,不会因为停用自动删除业务数据。
声明指向核心表的外键
扩展自有表的列支持 references,例如 manifest 的 schema:
{
"tables": [{
"name": "records",
"columns": [
{"name": "id", "type": "bigint", "unsigned": true, "autoIncrement": true},
{"name": "user_id", "type": "bigint", "unsigned": true,
"nullable": true, "default": null,
"references": {"table": "users", "onDelete": "SET NULL"}}
],
"primary": ["id"]
}]
}目标只允许 users、posts、pages、micro_statuses、categories、tags、comments、uploads 的 id;不接受任意表名、多态引用或自定义 SQL。
引用列必须显式声明 bigint、unsigned:true,和核心 ID 一致;SET NULL 必须同时声明 nullable:true。
onDelete/onUpdate 支持 RESTRICT、CASCADE、SET NULL,省略时为 RESTRICT。删除父记录时保留扩展记录还是级联删除,应按业务明确选择。
同步按“建表/补列/索引 → 建外键”执行,重复同步不会重复建约束。InnoDB 的索引要求和已有数据由实际建约束验证。
历史孤儿数据、已有列宽度/符号/可空差异、外键目标或规则漂移会阻止同步,要求显式迁移;不会自动删数据、重建字段或悄悄替换约束。DDL 不能整体事务回滚,修复后可重试。
本轮没有替既有插件改私有业务表;只有声明 references 的扩展接入这套规则。表归属仍由 extension_storage 登记。
查看扩展表归属
管理员从插件管理或数据备份进入“扩展数据表”(GET /admin/extensions/data)。页面合并归属登记、当前安装清单与库内 pfplugin/pftheme 表:展示扩展身份、声明/登记状态、估算行数、数据及索引占用、核心卸载规则。未声明且未登记的旧表不通过拆分下划线猜测归属。
未建的声明和已登记却缺失的表分别展示;保留登记但找不到清单、清单不再声明、归属冲突、损坏登记也单独标识。停用扩展的清单同样参与盘点和冲突检查。页面只读,不认领表、不执行 DDL、不逐表 COUNT(*),也不读取业务内容;行数来自 INFORMATION_SCHEMA 的估算,不能当作精确审计数量。
核心默认保留卸载数据;选择删除数据时只清理当前有效清单声明、无归属冲突的表。不再声明的旧表不会自动删除。这里描述的是核心行为,扩展自己的 onUninstall 回调仍可能修改数据。目录仍存在但清单缺失时也归入“扩展缺失”,需要人工核查。
事务与缓存
写操作及提交后副作用使用 DB::transaction() / DB::afterCommit()。保存点成功只将回调交给父事务,保存点或外层回滚会丢弃相应回调;最外层提交后按登记顺序执行。提交后回调异常会记录日志并继续后续回调,不把已提交的数据报告成回滚。
Cache 在任何数据库事务中绕过共享缓存读取,也不发布查询快照;delete/clearPrefix/clear 在受管事务中延迟到最外层提交。原生 PDO 手工事务无法观察最终提交,因此在其中登记提交后回调或失效共享缓存会抛 LogicException(包括在手工事务内调用 DB::transaction 的情况)。需要这些操作的调用方必须改为 DB::transaction 管理最外层,并在任何异常时回滚;没有副作用回调的原生事务仍可嵌套数据库保存点。
公开列表、分类树和最新公开微语通过 Cache::remember() 回填:查询前记录失效代次,查询后在短文件锁内核对;期间发生 delete/clearPrefix/clear 或直接 set 时拒绝旧结果入缓存。数据库查询不占用全局缓存锁,失效不会等查询结束。按缓存键哈希的前两位选择回填锁,最多 256 个稳定锁文件;相撞的键可能短暂互等。等待预算约 250ms,超时回源但不发布,因此慢查询时仍可能多次回源。
这保证受管事务的提交时机、回滚隔离和 remember 回填保护,不是数据库与缓存的线性一致性保证:提交和 afterCommit 失效之间仍有窗口;进行中的请求可返回自己的旧快照。为保持任意前缀语义并保留现有文件布局,当前用全局代次与序号保守拒绝其他命名空间写入期间的回填;除标记上限轮换外,已有其他键仍保留。get()/set() 分开回填不具备这项保护。
clearPrefix 只更新同一稳定锁文件中的有界 JSON 状态,不扫描缓存值。读取检查 payload 的代次、序号和对应前缀/精确删除标记;旧值逻辑失效,文件可保留至原键重新加载或 Cache::clear()。精确 delete 除记录标记外仍尝试删文件,因此文件被占用时也不能让旧值继续有效。前缀与精确删除标记合计最多 256 条,超过上限生成新代次并使全部值冷启动;这是有意的有界退让,可能增加短时回源。更宽前缀会压缩其覆盖的旧标记,重复同一前缀不持续增长。
普通失效开销取决于有界标记量,不随缓存文件数线性增长;读取仍需解析这些标记。旧值/过期文件没有新增自动垃圾回收任务,手工完整 clear 仍扫描文件且持有独占锁,大目录应安排低峰。旧格式状态或值安全视为未命中;无需数据库迁移,但部署需停止旧 worker 再切换,避免不同实现混写。
不要在服务运行期间手工删除 .cache.lock 或 .fill-xx.lock;正常 Cache::clear() 只删除值文件、保留稳定锁,避免两个进程锁到不同文件。清理整个运行目录应先停服务。缓存读取、发布和清理由同一短全局锁协调;文件替换/删除同时失效当前 PHP 进程可见的 OPcache 项。当前实测环境为本地 Windows/PHP CLI,未覆盖 FPM、网络文件系统或多节点共享目录。